Skip to content

Commit

Permalink
[ML] Data Frame Analytics: Fix race condition and support for feature…
Browse files Browse the repository at this point in the history
… influence legacy format. (elastic#81123)

- Fixes a race condition where searches for data grid results with different parameters would return in different order with the wrong results on display. Fix uses a pattern to cancel useEffect callback for getIndexData().
- Fixes identifying pre 7.10 feature influence format for outlier detection and will display a callout on the results page with information for a workaround.
- To fix identifying the legacy format, some cleanup of other code relating to the old format had to be done. The ml results object field is no longer treated as a "special" field for outlier detection and is treated and retrieved in the same way as other fields.
- Adds an error callout if no Kibana index pattern is available for source/dest index.
  • Loading branch information
walterra committed Oct 21, 2020
1 parent 81e194b commit e3c5565
Show file tree
Hide file tree
Showing 11 changed files with 151 additions and 74 deletions.
4 changes: 3 additions & 1 deletion x-pack/plugins/ml/common/types/data_frame_analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export type DataFrameAnalyticsId = string;
export interface OutlierAnalysis {
[key: string]: {};

outlier_detection: {};
outlier_detection: {
compute_feature_influence?: boolean;
};
}

interface Regression {
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/ml/common/util/analytics_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ import {
import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics';

export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION;
};

export const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION;
};

export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => {
if (typeof arg !== 'object' || arg === null) return false;
const keys = Object.keys(arg);
return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {

import {
FEATURE_IMPORTANCE,
FEATURE_INFLUENCE,
OUTLIER_SCORE,
TOP_CLASSES,
} from '../../data_frame_analytics/common/constants';
Expand Down Expand Up @@ -112,10 +111,7 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results
schema = NON_AGGREGATABLE;
}

if (
field === `${resultsField}.${OUTLIER_SCORE}` ||
field.includes(`${resultsField}.${FEATURE_INFLUENCE}`)
) {
if (field === `${resultsField}.${OUTLIER_SCORE}`) {
schema = 'numeric';
}

Expand Down Expand Up @@ -203,11 +199,6 @@ export const useRenderCellValue = (
}

function getCellValue(cId: string) {
if (cId.includes(`.${FEATURE_INFLUENCE}.`) && resultsField !== undefined) {
const results = getNestedProperty(tableItems[adjustedRowIndex], resultsField, null);
return results[cId.replace(`${resultsField}.`, '')];
}

if (tableItems.hasOwnProperty(adjustedRowIndex)) {
const item = tableItems[adjustedRowIndex];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
import { newJobCapsService } from '../../services/new_job_capabilities_service';

import { FEATURE_IMPORTANCE, FEATURE_INFLUENCE, OUTLIER_SCORE, TOP_CLASSES } from './constants';
import { FEATURE_IMPORTANCE, OUTLIER_SCORE, TOP_CLASSES } from './constants';
import { DataFrameAnalyticsConfig } from '../../../../common/types/data_frame_analytics';

export type EsId = string;
Expand Down Expand Up @@ -179,7 +179,6 @@ export const getDefaultFieldsFromJobCaps = (
const resultsField = jobConfig.dest.results_field;

const featureImportanceFields = [];
const featureInfluenceFields = [];
const topClassesFields = [];
const allFields: any = [];
let type: ES_FIELD_TYPES | undefined;
Expand All @@ -193,16 +192,6 @@ export const getDefaultFieldsFromJobCaps = (
name: `${resultsField}.${OUTLIER_SCORE}`,
type: KBN_FIELD_TYPES.NUMBER,
});

featureInfluenceFields.push(
...fields
.filter((d) => !jobConfig.analyzed_fields.excludes.includes(d.id))
.map((d) => ({
id: `${resultsField}.${FEATURE_INFLUENCE}.${d.id}`,
name: `${resultsField}.${FEATURE_INFLUENCE}.${d.name}`,
type: KBN_FIELD_TYPES.NUMBER,
}))
);
}
}

Expand Down Expand Up @@ -247,12 +236,7 @@ export const getDefaultFieldsFromJobCaps = (
}
}

allFields.push(
...fields,
...featureImportanceFields,
...featureInfluenceFields,
...topClassesFields
);
allFields.push(...fields, ...featureImportanceFields, ...topClassesFields);
allFields.sort(({ name: a }: { name: string }, { name: b }: { name: string }) =>
sortExplorationResultsFields(a, b, jobConfig)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { DataFrameAnalyticsConfig } from '../../../../common/types/data_frame_an
export const getIndexData = async (
jobConfig: DataFrameAnalyticsConfig | undefined,
dataGrid: UseDataGridReturnType,
searchQuery: SavedSearchQuery
searchQuery: SavedSearchQuery,
options: { didCancel: boolean }
) => {
if (jobConfig !== undefined) {
const {
Expand Down Expand Up @@ -52,22 +53,19 @@ export const getIndexData = async (
index: jobConfig.dest.index,
body: {
fields: ['*'],
_source: jobConfig.dest.results_field,
_source: [],
query: searchQuery,
from: pageIndex * pageSize,
size: pageSize,
...(Object.keys(sort).length > 0 ? { sort } : {}),
},
});

setRowCount(resp.hits.total.value);
const docs = resp.hits.hits.map((d) => ({
...getProcessedFields(d.fields),
[jobConfig.dest.results_field]: d._source[jobConfig.dest.results_field],
}));

setTableItems(docs);
setStatus(INDEX_STATUS.LOADED);
if (!options.didCancel) {
setRowCount(resp.hits.total.value);
setTableItems(resp.hits.hits.map((d) => getProcessedFields(d.fields)));
setStatus(INDEX_STATUS.LOADED);
}
} catch (e) {
setErrorMessage(extractErrorMessage(e));
setStatus(INDEX_STATUS.ERROR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import { useEffect, useState } from 'react';

import { i18n } from '@kbn/i18n';

import { IndexPattern } from '../../../../../../../src/plugins/data/public';

import { extractErrorMessage } from '../../../../common/util/errors';
Expand All @@ -32,6 +34,9 @@ export const useResultsViewConfig = (jobId: string) => {
const trainedModelsApiService = useTrainedModelsApiService();

const [indexPattern, setIndexPattern] = useState<IndexPattern | undefined>(undefined);
const [indexPatternErrorMessage, setIndexPatternErrorMessage] = useState<undefined | string>(
undefined
);
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const [needsDestIndexPattern, setNeedsDestIndexPattern] = useState<boolean>(false);
const [isLoadingJobConfig, setIsLoadingJobConfig] = useState<boolean>(false);
Expand Down Expand Up @@ -105,7 +110,11 @@ export const useResultsViewConfig = (jobId: string) => {
setNeedsDestIndexPattern(true);
const sourceIndex = jobConfigUpdate.source.index[0];
const sourceIndexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex;
indexP = await mlContext.indexPatterns.get(sourceIndexPatternId);
try {
indexP = await mlContext.indexPatterns.get(sourceIndexPatternId);
} catch (e) {
indexP = undefined;
}
}

if (indexP !== undefined) {
Expand All @@ -114,6 +123,16 @@ export const useResultsViewConfig = (jobId: string) => {
setIndexPattern(indexP);
setIsInitialized(true);
setIsLoadingJobConfig(false);
} else {
setIndexPatternErrorMessage(
i18n.translate(
'xpack.ml.dataframe.analytics.results.indexPatternsMissingErrorMessage',
{
defaultMessage:
'To view this page, a Kibana index pattern is necessary for either the destination or source index of this analytics job.',
}
)
);
}
} catch (e) {
setJobCapsServiceErrorMessage(extractErrorMessage(e));
Expand All @@ -129,6 +148,7 @@ export const useResultsViewConfig = (jobId: string) => {

return {
indexPattern,
indexPatternErrorMessage,
isInitialized,
isLoadingJobConfig,
jobCapsServiceErrorMessage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import React, { FC, useEffect, useState } from 'react';

import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';

import { useUrlState } from '../../../../../util/url_state';
Expand Down Expand Up @@ -70,6 +70,7 @@ export const ExplorationPageWrapper: FC<Props> = ({
}) => {
const {
indexPattern,
indexPatternErrorMessage,
isInitialized,
isLoadingJobConfig,
jobCapsServiceErrorMessage,
Expand Down Expand Up @@ -99,6 +100,22 @@ export const ExplorationPageWrapper: FC<Props> = ({
}
}, [jobConfig?.dest.results_field]);

if (indexPatternErrorMessage !== undefined) {
return (
<EuiPanel grow={false}>
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.exploration.indexError', {
defaultMessage: 'An error occurred loading the index data.',
})}
color="danger"
iconType="cross"
>
<p>{indexPatternErrorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
}

if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) {
return (
<JobConfigErrorCallout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,15 @@ export const useExplorationResults = (
dataGrid.resetPagination();
}, [JSON.stringify(searchQuery)]);

// The pattern using `didCancel` allows us to abort out of date remote request.
// We wrap `didCancel` in a object so we can mutate the value as it's being
// passed on to `getIndexData`.
useEffect(() => {
getIndexData(jobConfig, dataGrid, searchQuery);
const options = { didCancel: false };
getIndexData(jobConfig, dataGrid, searchQuery, options);
return () => {
options.didCancel = true;
};
// custom comparison
}, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,8 @@ export const getFeatureCount = (resultsField: string, tableItems: DataGridItem[]

const fullItem = tableItems[0];

if (
fullItem[resultsField] !== undefined &&
Array.isArray(fullItem[resultsField][FEATURE_INFLUENCE])
) {
return fullItem[resultsField][FEATURE_INFLUENCE].length;
if (Array.isArray(fullItem[`${resultsField}.${FEATURE_INFLUENCE}.feature_name`])) {
return fullItem[`${resultsField}.${FEATURE_INFLUENCE}.feature_name`].length;
}

return 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import React, { useState, FC } from 'react';

import { EuiSpacer, EuiText } from '@elastic/eui';
import { EuiCallOut, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';

import { i18n } from '@kbn/i18n';

import {
useColorRange,
Expand All @@ -15,7 +17,8 @@ import {
} from '../../../../../components/color_range_legend';
import { SavedSearchQuery } from '../../../../../contexts/ml';

import { defaultSearchQuery, useResultsViewConfig } from '../../../../common';
import { defaultSearchQuery, isOutlierAnalysis, useResultsViewConfig } from '../../../../common';
import { FEATURE_INFLUENCE } from '../../../../common/constants';

import { ExpandableSectionAnalytics, ExpandableSectionResults } from '../expandable_section';
import { ExplorationQueryBar } from '../exploration_query_bar';
Expand All @@ -30,17 +33,54 @@ interface ExplorationProps {
}

export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) => {
const { indexPattern, jobConfig, needsDestIndexPattern } = useResultsViewConfig(jobId);
const {
indexPattern,
indexPatternErrorMessage,
jobConfig,
needsDestIndexPattern,
} = useResultsViewConfig(jobId);
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
const outlierData = useOutlierData(indexPattern, jobConfig, searchQuery);

const { columnsWithCharts, tableItems } = outlierData;

const colorRange = useColorRange(
COLOR_RANGE.BLUE,
COLOR_RANGE_SCALE.INFLUENCER,
jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, tableItems) : 1
);
const featureCount = getFeatureCount(jobConfig?.dest?.results_field || '', tableItems);
const colorRange = useColorRange(COLOR_RANGE.BLUE, COLOR_RANGE_SCALE.INFLUENCER, featureCount);

// Show the color range only if feature influence is enabled and there's more than 0 features.
const showColorRange =
featureCount > 0 &&
isOutlierAnalysis(jobConfig?.analysis) &&
jobConfig?.analysis.outlier_detection.compute_feature_influence === true;

const resultsField = jobConfig?.dest.results_field ?? '';

// Identify if the results index has a legacy feature influence format.
// If feature influence was enabled for the legacy job we'll show a callout
// with some additional information for a workaround.
const showLegacyFeatureInfluenceFormatCallout =
!needsDestIndexPattern &&
isOutlierAnalysis(jobConfig?.analysis) &&
jobConfig?.analysis.outlier_detection.compute_feature_influence === true &&
columnsWithCharts.findIndex(
(d) => d.id === `${resultsField}.${FEATURE_INFLUENCE}.feature_name`
) === -1;

if (indexPatternErrorMessage !== undefined) {
return (
<EuiPanel grow={false}>
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.exploration.indexError', {
defaultMessage: 'An error occurred loading the index data.',
})}
color="danger"
iconType="cross"
>
<p>{indexPatternErrorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
}

return (
<>
Expand All @@ -58,8 +98,26 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
</>
)}
{typeof jobConfig?.id === 'string' && <ExpandableSectionAnalytics jobId={jobConfig?.id} />}
{showLegacyFeatureInfluenceFormatCallout && (
<>
<EuiCallOut
size="s"
title={i18n.translate(
'xpack.ml.dataframe.analytics.outlierExploration.legacyFeatureInfluenceFormatCalloutTitle',
{
defaultMessage:
'Color coded table cells based on feature influence are not available because the results index uses an unsupported legacy format. Please clone and rerun the job.',
}
)}
iconType="pin"
/>
<EuiSpacer size="m" />
</>
)}
<ExpandableSectionResults
colorRange={colorRange}
colorRange={
showColorRange && !showLegacyFeatureInfluenceFormatCallout ? colorRange : undefined
}
indexData={outlierData}
indexPattern={indexPattern}
jobConfig={jobConfig}
Expand Down
Loading

0 comments on commit e3c5565

Please sign in to comment.