From 0cd5bb0ca97fcd91961ef3e67a6bbe3c5f8c96df Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 12 Dec 2019 12:02:04 -0700 Subject: [PATCH] [ML] DF Analytics: create classification jobs results view (#52584) * wip: create classification results page + table and evaluate panel * enable view link for classification jobs * wip: fetch classification eval data * wip: display confusion matrix in datagrid * evaluate panel: add heatmap for cells and doc count * Update use of loadEvalData in expanded row component * Add metric type for evaluate endpoint and fix localization error * handle no incorrect prediction classes case for confusion matrix. remove unused translation * setCellProps needs to be called from a lifecycle method - wrap in useEffect * TypeScript improvements * fix datagrid column resize affecting results table * allow custom prediction field for classification jobs * ensure values are rounded correctly and add tooltip * temp workaroun for datagrid width issues --- .../data_frame_analytics/_index.scss | 1 + .../data_frame_analytics/common/analytics.ts | 186 +++++-- .../data_frame_analytics/common/fields.ts | 57 ++- .../data_frame_analytics/common/index.ts | 2 + .../_classification_exploration.scss | 4 + .../classification_exploration/_index.scss | 1 + .../classification_exploration.tsx | 120 +++++ .../column_data.tsx | 80 +++ .../evaluate_panel.tsx | 343 +++++++++++++ .../classification_exploration/index.ts | 7 + .../results_table.tsx | 481 ++++++++++++++++++ .../use_explore_data.ts | 156 ++++++ .../error_callout.tsx | 0 .../components/error_callout/index.ts | 7 + .../components/loading_panel/index.ts | 7 + .../loading_panel/loading_panel.tsx | 14 + .../regression_exploration/evaluate_panel.tsx | 107 ++-- .../regression_exploration.tsx | 17 +- .../regression_exploration/results_table.tsx | 4 +- .../use_explore_data.ts | 5 - .../pages/analytics_exploration/page.tsx | 4 + .../components/analytics_list/actions.tsx | 5 +- .../analytics_list/expanded_row.tsx | 20 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 25 files changed, 1514 insertions(+), 116 deletions(-) create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_index.scss create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/results_table.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts rename x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/{regression_exploration => error_callout}/error_callout.tsx (100%) create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/loading_panel.tsx diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/_index.scss index c231c405b536..962d3f4c7bd5 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/_index.scss +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/_index.scss @@ -1,5 +1,6 @@ @import 'pages/analytics_exploration/components/exploration/index'; @import 'pages/analytics_exploration/components/regression_exploration/index'; +@import 'pages/analytics_exploration/components/classification_exploration/index'; @import 'pages/analytics_management/components/analytics_list/index'; @import 'pages/analytics_management/components/create_analytics_form/index'; @import 'pages/analytics_management/components/create_analytics_flyout/index'; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 0642c1fbe618..cadc1f01c6dd 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -35,6 +35,7 @@ interface ClassificationAnalysis { dependent_variable: string; training_percent?: number; num_top_classes?: string; + prediction_field_name?: string; }; } @@ -74,13 +75,33 @@ export interface RegressionEvaluateResponse { }; } +export interface PredictedClass { + predicted_class: string; + count: number; +} + +export interface ConfusionMatrix { + actual_class: string; + actual_class_doc_count: number; + predicted_classes: PredictedClass[]; + other_predicted_class_doc_count: number; +} + +export interface ClassificationEvaluateResponse { + classification: { + multiclass_confusion_matrix: { + confusion_matrix: ConfusionMatrix[]; + }; + }; +} + interface GenericAnalysis { [key: string]: Record; } interface LoadEvaluateResult { success: boolean; - eval: RegressionEvaluateResponse | null; + eval: RegressionEvaluateResponse | ClassificationEvaluateResponse | null; error: string | null; } @@ -109,6 +130,7 @@ export const getAnalysisType = (analysis: AnalysisConfig) => { export const getDependentVar = (analysis: AnalysisConfig) => { let depVar = ''; + if (isRegressionAnalysis(analysis)) { depVar = analysis.regression.dependent_variable; } @@ -124,17 +146,26 @@ export const getPredictionFieldName = (analysis: AnalysisConfig) => { let predictionFieldName; if (isRegressionAnalysis(analysis) && analysis.regression.prediction_field_name !== undefined) { predictionFieldName = analysis.regression.prediction_field_name; + } else if ( + isClassificationAnalysis(analysis) && + analysis.classification.prediction_field_name !== undefined + ) { + predictionFieldName = analysis.classification.prediction_field_name; } return predictionFieldName; }; -export const getPredictedFieldName = (resultsField: string, analysis: AnalysisConfig) => { +export const getPredictedFieldName = ( + resultsField: string, + analysis: AnalysisConfig, + forSort?: boolean +) => { // default is 'ml' const predictionFieldName = getPredictionFieldName(analysis); const defaultPredictionField = `${getDependentVar(analysis)}_prediction`; const predictedField = `${resultsField}.${ predictionFieldName ? predictionFieldName : defaultPredictionField - }`; + }${isClassificationAnalysis(analysis) && !forSort ? '.keyword' : ''}`; return predictedField; }; @@ -153,13 +184,32 @@ export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysi return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; }; -export const isRegressionResultsSearchBoolQuery = ( - arg: any -): arg is RegressionResultsSearchBoolQuery => { +export const isResultsSearchBoolQuery = (arg: any): arg is ResultsSearchBoolQuery => { const keys = Object.keys(arg); return keys.length === 1 && keys[0] === 'bool'; }; +export const isRegressionEvaluateResponse = (arg: any): arg is RegressionEvaluateResponse => { + const keys = Object.keys(arg); + return ( + keys.length === 1 && + keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION && + arg?.regression?.mean_squared_error !== undefined && + arg?.regression?.r_squared !== undefined + ); +}; + +export const isClassificationEvaluateResponse = ( + arg: any +): arg is ClassificationEvaluateResponse => { + const keys = Object.keys(arg); + return ( + keys.length === 1 && + keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && + arg?.classification?.multiclass_confusion_matrix !== undefined + ); +}; + export interface DataFrameAnalyticsConfig { id: DataFrameAnalyticsId; // Description attribute is not supported yet @@ -254,17 +304,14 @@ export function getValuesFromResponse(response: RegressionEvaluateResponse) { return { meanSquaredError, rSquared }; } -interface RegressionResultsSearchBoolQuery { +interface ResultsSearchBoolQuery { bool: Dictionary; } -interface RegressionResultsSearchTermQuery { +interface ResultsSearchTermQuery { term: Dictionary; } -export type RegressionResultsSearchQuery = - | RegressionResultsSearchBoolQuery - | RegressionResultsSearchTermQuery - | SavedSearchQuery; +export type ResultsSearchQuery = ResultsSearchBoolQuery | ResultsSearchTermQuery | SavedSearchQuery; export function getEvalQueryBody({ resultsField, @@ -274,16 +321,16 @@ export function getEvalQueryBody({ }: { resultsField: string; isTraining: boolean; - searchQuery?: RegressionResultsSearchQuery; + searchQuery?: ResultsSearchQuery; ignoreDefaultQuery?: boolean; }) { - let query: RegressionResultsSearchQuery = { + let query: ResultsSearchQuery = { term: { [`${resultsField}.is_training`]: { value: isTraining } }, }; if (searchQuery !== undefined && ignoreDefaultQuery === true) { query = searchQuery; - } else if (searchQuery !== undefined && isRegressionResultsSearchBoolQuery(searchQuery)) { + } else if (searchQuery !== undefined && isResultsSearchBoolQuery(searchQuery)) { const searchQueryClone = cloneDeep(searchQuery); searchQueryClone.bool.must.push(query); query = searchQueryClone; @@ -291,6 +338,27 @@ export function getEvalQueryBody({ return query; } +interface EvaluateMetrics { + classification: { + multiclass_confusion_matrix: object; + }; + regression: { + r_squared: object; + mean_squared_error: object; + }; +} + +interface LoadEvalDataConfig { + isTraining: boolean; + index: string; + dependentVariable: string; + resultsField: string; + predictionFieldName?: string; + searchQuery?: ResultsSearchQuery; + ignoreDefaultQuery?: boolean; + jobType: ANALYSIS_CONFIG_TYPE; +} + export const loadEvalData = async ({ isTraining, index, @@ -299,34 +367,38 @@ export const loadEvalData = async ({ predictionFieldName, searchQuery, ignoreDefaultQuery, -}: { - isTraining: boolean; - index: string; - dependentVariable: string; - resultsField: string; - predictionFieldName?: string; - searchQuery?: RegressionResultsSearchQuery; - ignoreDefaultQuery?: boolean; -}) => { + jobType, +}: LoadEvalDataConfig) => { const results: LoadEvaluateResult = { success: false, eval: null, error: null }; const defaultPredictionField = `${dependentVariable}_prediction`; - const predictedField = `${resultsField}.${ + let predictedField = `${resultsField}.${ predictionFieldName ? predictionFieldName : defaultPredictionField }`; + if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) { + predictedField = `${predictedField}.keyword`; + } + const query = getEvalQueryBody({ resultsField, isTraining, searchQuery, ignoreDefaultQuery }); + const metrics: EvaluateMetrics = { + classification: { + multiclass_confusion_matrix: {}, + }, + regression: { + r_squared: {}, + mean_squared_error: {}, + }, + }; + const config = { index, query, evaluation: { - regression: { + [jobType]: { actual_field: dependentVariable, predicted_field: predictedField, - metrics: { - r_squared: {}, - mean_squared_error: {}, - }, + metrics: metrics[jobType as keyof EvaluateMetrics], }, }, }; @@ -341,3 +413,57 @@ export const loadEvalData = async ({ return results; } }; + +interface TrackTotalHitsSearchResponse { + hits: { + total: { + value: number; + relation: string; + }; + hits: any[]; + }; +} + +interface LoadDocsCountConfig { + ignoreDefaultQuery?: boolean; + isTraining: boolean; + searchQuery: SavedSearchQuery; + resultsField: string; + destIndex: string; +} + +interface LoadDocsCountResponse { + docsCount: number | null; + success: boolean; +} + +export const loadDocsCount = async ({ + ignoreDefaultQuery = true, + isTraining, + searchQuery, + resultsField, + destIndex, +}: LoadDocsCountConfig): Promise => { + const query = getEvalQueryBody({ resultsField, isTraining, ignoreDefaultQuery, searchQuery }); + + try { + const body: SearchQuery = { + track_total_hits: true, + query, + }; + + const resp: TrackTotalHitsSearchResponse = await ml.esSearch({ + index: destIndex, + size: 0, + body, + }); + + const docsCount = resp.hits.total && resp.hits.total.value; + return { docsCount, success: docsCount !== undefined }; + } catch (e) { + return { + docsCount: null, + success: false, + }; + } +}; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 5621d77f6646..216836db4ccb 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -77,7 +77,7 @@ export const sortRegressionResultsFields = ( ) => { const dependentVariable = getDependentVar(jobConfig.analysis); const resultsField = jobConfig.dest.results_field; - const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis); + const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true); if (a === `${resultsField}.is_training`) { return -1; } @@ -96,6 +96,14 @@ export const sortRegressionResultsFields = ( if (b === dependentVariable) { return 1; } + + if (a === `${resultsField}.prediction_probability`) { + return -1; + } + if (b === `${resultsField}.prediction_probability`) { + return 1; + } + return a.localeCompare(b); }; @@ -107,7 +115,7 @@ export const sortRegressionResultsColumns = ( ) => (a: string, b: string) => { const dependentVariable = getDependentVar(jobConfig.analysis); const resultsField = jobConfig.dest.results_field; - const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis); + const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true); const typeofA = typeof obj[a]; const typeofB = typeof obj[b]; @@ -136,6 +144,14 @@ export const sortRegressionResultsColumns = ( return 1; } + if (a === `${resultsField}.prediction_probability`) { + return -1; + } + + if (b === `${resultsField}.prediction_probability`) { + return 1; + } + if (typeofA !== 'string' && typeofB === 'string') { return 1; } @@ -184,6 +200,43 @@ export function getFlattenedFields(obj: EsDocSource, resultsField: string): EsFi return flatDocFields.filter(f => f !== ML__ID_COPY); } +export const getDefaultClassificationFields = ( + docs: EsDoc[], + jobConfig: DataFrameAnalyticsConfig +): EsFieldName[] => { + if (docs.length === 0) { + return []; + } + const resultsField = jobConfig.dest.results_field; + const newDocFields = getFlattenedFields(docs[0]._source, resultsField); + return newDocFields + .filter(k => { + if (k === `${resultsField}.is_training`) { + return true; + } + // predicted value of dependent variable + if (k === getPredictedFieldName(resultsField, jobConfig.analysis, true)) { + return true; + } + // actual value of dependent variable + if (k === getDependentVar(jobConfig.analysis)) { + return true; + } + + if (k === `${resultsField}.prediction_probability`) { + return true; + } + + if (k.split('.')[0] === resultsField) { + return false; + } + + return docs.some(row => row._source[k] !== null); + }) + .sort((a, b) => sortRegressionResultsFields(a, b, jobConfig)) + .slice(0, DEFAULT_REGRESSION_COLUMNS); +}; + export const getDefaultRegressionFields = ( docs: EsDoc[], jobConfig: DataFrameAnalyticsConfig diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts index 02a1c30259cc..f7794af8b586 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -20,6 +20,7 @@ export { RegressionEvaluateResponse, getValuesFromResponse, loadEvalData, + loadDocsCount, Eval, getPredictedFieldName, INDEX_STATUS, @@ -31,6 +32,7 @@ export { export { getDefaultSelectableFields, getDefaultRegressionFields, + getDefaultClassificationFields, getFlattenedFields, sortColumns, sortRegressionResultsColumns, diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss new file mode 100644 index 000000000000..1141dddf398b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss @@ -0,0 +1,4 @@ +.euiFormRow.mlDataFrameAnalyticsClassification__actualLabel { + padding-top: $euiSize * 4; +} + diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_index.scss b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_index.scss new file mode 100644 index 000000000000..88edd92951d4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_index.scss @@ -0,0 +1 @@ +@import 'classification_exploration'; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx new file mode 100644 index 000000000000..f424ebee5812 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, useState, useEffect } from 'react'; +import { EuiCallOut, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ml } from '../../../../../services/ml_api_service'; +import { DataFrameAnalyticsConfig } from '../../../../common'; +import { EvaluatePanel } from './evaluate_panel'; +import { ResultsTable } from './results_table'; +import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; +import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; +import { LoadingPanel } from '../loading_panel'; + +interface GetDataFrameAnalyticsResponse { + count: number; + data_frame_analytics: DataFrameAnalyticsConfig[]; +} + +export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => ( + + + {i18n.translate('xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle', { + defaultMessage: 'Destination index for classification job ID {jobId}', + values: { jobId }, + })} + + +); + +interface Props { + jobId: string; + jobStatus: DATA_FRAME_TASK_STATE; +} + +export const ClassificationExploration: FC = ({ jobId, jobStatus }) => { + const [jobConfig, setJobConfig] = useState(undefined); + const [isLoadingJobConfig, setIsLoadingJobConfig] = useState(false); + const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + + const loadJobConfig = async () => { + setIsLoadingJobConfig(true); + try { + const analyticsConfigs: GetDataFrameAnalyticsResponse = await ml.dataFrameAnalytics.getDataFrameAnalytics( + jobId + ); + if ( + Array.isArray(analyticsConfigs.data_frame_analytics) && + analyticsConfigs.data_frame_analytics.length > 0 + ) { + setJobConfig(analyticsConfigs.data_frame_analytics[0]); + setIsLoadingJobConfig(false); + } else { + setJobConfigErrorMessage( + i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationNoResultsMessage', + { + defaultMessage: 'No results found.', + } + ) + ); + } + } catch (e) { + if (e.message !== undefined) { + setJobConfigErrorMessage(e.message); + } else { + setJobConfigErrorMessage(JSON.stringify(e)); + } + setIsLoadingJobConfig(false); + } + }; + + useEffect(() => { + loadJobConfig(); + }, []); + + if (jobConfigErrorMessage !== undefined) { + return ( + + + + +

{jobConfigErrorMessage}

+
+
+ ); + } + + return ( + + {isLoadingJobConfig === true && jobConfig === undefined && } + {isLoadingJobConfig === false && jobConfig !== undefined && ( + + )} + + {isLoadingJobConfig === true && jobConfig === undefined && } + {isLoadingJobConfig === false && jobConfig !== undefined && ( + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx new file mode 100644 index 000000000000..5a08dd159aff --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ConfusionMatrix, PredictedClass } from '../../../../common/analytics'; + +interface ColumnData { + actual_class: string; + actual_class_doc_count: number; + predicted_class?: string; + count?: number; + error_count?: number; +} + +export function getColumnData(confusionMatrixData: ConfusionMatrix[]) { + const colData: Partial = []; + + confusionMatrixData.forEach((classData: any) => { + const correctlyPredictedClass = classData.predicted_classes.find( + (pc: PredictedClass) => pc.predicted_class === classData.actual_class + ); + const incorrectlyPredictedClass = classData.predicted_classes.find( + (pc: PredictedClass) => pc.predicted_class !== classData.actual_class + ); + + let accuracy; + if (correctlyPredictedClass !== undefined) { + accuracy = correctlyPredictedClass.count / classData.actual_class_doc_count; + // round to 2 decimal places without converting to string; + accuracy = Math.round(accuracy * 100) / 100; + } + + let error; + if (incorrectlyPredictedClass !== undefined) { + error = incorrectlyPredictedClass.count / classData.actual_class_doc_count; + error = Math.round(error * 100) / 100; + } + + let col: any = { + actual_class: classData.actual_class, + actual_class_doc_count: classData.actual_class_doc_count, + }; + + if (correctlyPredictedClass !== undefined) { + col = { + ...col, + predicted_class: correctlyPredictedClass.predicted_class, + [correctlyPredictedClass.predicted_class]: accuracy, + count: correctlyPredictedClass.count, + accuracy, + }; + } + + if (incorrectlyPredictedClass !== undefined) { + col = { + ...col, + [incorrectlyPredictedClass.predicted_class]: error, + error_count: incorrectlyPredictedClass.count, + }; + } + + colData.push(col); + }); + + const columns: any = [ + { + id: 'actual_class', + display: , + }, + ]; + + colData.forEach((data: any) => { + columns.push({ id: data.predicted_class }); + }); + + return { columns, columnData: colData }; +} diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx new file mode 100644 index 000000000000..ddf52943c2fe --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useEffect, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiDataGrid, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIconTip, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { ErrorCallout } from '../error_callout'; +import { + getDependentVar, + getPredictionFieldName, + loadEvalData, + loadDocsCount, + DataFrameAnalyticsConfig, +} from '../../../../common'; +import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; +import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; +import { + isResultsSearchBoolQuery, + isClassificationEvaluateResponse, + ConfusionMatrix, + ResultsSearchQuery, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { LoadingPanel } from '../loading_panel'; +import { getColumnData } from './column_data'; + +const defaultPanelWidth = 500; + +interface Props { + jobConfig: DataFrameAnalyticsConfig; + jobStatus: DATA_FRAME_TASK_STATE; + searchQuery: ResultsSearchQuery; +} + +export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) => { + const [isLoading, setIsLoading] = useState(false); + const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [columns, setColumns] = useState([]); + const [columnsData, setColumnsData] = useState([]); + const [popoverContents, setPopoverContents] = useState([]); + const [docsCount, setDocsCount] = useState(null); + const [error, setError] = useState(null); + const [panelWidth, setPanelWidth] = useState(defaultPanelWidth); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }: { id: string }) => id) + ); + + const index = jobConfig.dest.index; + const dependentVariable = getDependentVar(jobConfig.analysis); + const predictionFieldName = getPredictionFieldName(jobConfig.analysis); + // default is 'ml' + const resultsField = jobConfig.dest.results_field; + + const loadData = async ({ + isTrainingClause, + ignoreDefaultQuery = true, + }: { + isTrainingClause: { query: string; operator: string }; + ignoreDefaultQuery?: boolean; + }) => { + setIsLoading(true); + + const evalData = await loadEvalData({ + isTraining: false, + index, + dependentVariable, + resultsField, + predictionFieldName, + searchQuery, + ignoreDefaultQuery, + jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + }); + + const docsCountResp = await loadDocsCount({ + isTraining: false, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + + if ( + evalData.success === true && + evalData.eval && + isClassificationEvaluateResponse(evalData.eval) + ) { + const confusionMatrix = + evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; + setError(null); + setConfusionMatrixData(confusionMatrix || []); + setIsLoading(false); + } else { + setIsLoading(false); + setConfusionMatrixData([]); + setError(evalData.error); + } + + if (docsCountResp.success === true) { + setDocsCount(docsCountResp.docsCount); + } else { + setDocsCount(null); + } + }; + + const resizeHandler = () => { + const tablePanelWidth: number = + document.getElementById('mlDataFrameAnalyticsTableResultsPanel')?.clientWidth || + defaultPanelWidth; + // Keep the evaluate panel width slightly smaller than the results table + // to ensure results table can resize correctly. Temporary workaround DataGrid issue with flex + const newWidth = tablePanelWidth - 8; + setPanelWidth(newWidth); + }; + + useEffect(() => { + window.addEventListener('resize', resizeHandler); + resizeHandler(); + return () => { + window.removeEventListener('resize', resizeHandler); + }; + }, []); + + useEffect(() => { + if (confusionMatrixData.length > 0) { + const { columns: derivedColumns, columnData } = getColumnData(confusionMatrixData); + // Initialize all columns as visible + setVisibleColumns(() => derivedColumns.map(({ id }: { id: string }) => id)); + setColumns(derivedColumns); + setColumnsData(columnData); + setPopoverContents({ + numeric: ({ + cellContentsElement, + children, + }: { + cellContentsElement: any; + children: any; + }) => { + const rowIndex = children?.props?.rowIndex; + const colId = children?.props?.columnId; + const gridItem = columnData[rowIndex]; + + if (gridItem !== undefined) { + const count = colId === gridItem.actual_class ? gridItem.count : gridItem.error_count; + return `${count} / ${gridItem.actual_class_doc_count} * 100 = ${cellContentsElement.textContent}`; + } + + return cellContentsElement.textContent; + }, + }); + } + }, [confusionMatrixData]); + + useEffect(() => { + const hasIsTrainingClause = + isResultsSearchBoolQuery(searchQuery) && + searchQuery.bool.must.filter( + (clause: any) => clause.match && clause.match[`${resultsField}.is_training`] !== undefined + ); + const isTrainingClause = + hasIsTrainingClause && + hasIsTrainingClause[0] && + hasIsTrainingClause[0].match[`${resultsField}.is_training`]; + + loadData({ isTrainingClause }); + }, [JSON.stringify(searchQuery)]); + + const renderCellValue = ({ + rowIndex, + columnId, + setCellProps, + }: { + rowIndex: number; + columnId: string; + setCellProps: any; + }) => { + const cellValue = columnsData[rowIndex][columnId]; + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + setCellProps({ + style: { + backgroundColor: `rgba(0, 179, 164, ${cellValue})`, + }, + }); + }, [rowIndex, columnId, setCellProps]); + return ( + {typeof cellValue === 'number' ? `${Math.round(cellValue * 100)}%` : cellValue} + ); + }; + + if (isLoading === true) { + return ; + } + + return ( + + + + + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle', + { + defaultMessage: 'Evaluation of classification job ID {jobId}', + values: { jobId: jobConfig.id }, + } + )} + + + + + {getTaskStateBadge(jobStatus)} + + + + {error !== null && ( + + + + )} + {error === null && ( + + + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixHelpText', + { + defaultMessage: 'Normalized confusion matrix', + } + )} + + + + + + + + {docsCount !== null && ( + + + + + + )} + {/* BEGIN TABLE ELEMENTS */} + + + + + + + + + {columns.length > 0 && columnsData.length > 0 && ( + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + )} + {/* END TABLE ELEMENTS */} + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/index.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/index.ts new file mode 100644 index 000000000000..4c75d8315b23 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ClassificationExploration } from './classification_exploration'; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/results_table.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/results_table.tsx new file mode 100644 index 000000000000..1be158499a3f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/results_table.tsx @@ -0,0 +1,481 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useEffect, useState } from 'react'; +import moment from 'moment-timezone'; + +import { i18n } from '@kbn/i18n'; +import { + EuiBadge, + EuiButtonIcon, + EuiCallOut, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiPopover, + EuiPopoverTitle, + EuiProgress, + EuiSpacer, + EuiText, + EuiToolTip, + Query, +} from '@elastic/eui'; + +import { Query as QueryType } from '../../../analytics_management/components/analytics_list/common'; + +import { + ColumnType, + mlInMemoryTableBasicFactory, + OnTableChangeArg, + SortingPropType, + SORT_DIRECTION, +} from '../../../../../components/ml_in_memory_table'; + +import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils'; +import { SavedSearchQuery } from '../../../../../contexts/kibana'; + +import { + sortRegressionResultsColumns, + sortRegressionResultsFields, + toggleSelectedField, + DataFrameAnalyticsConfig, + EsFieldName, + EsDoc, + MAX_COLUMNS, + getPredictedFieldName, + INDEX_STATUS, + SEARCH_SIZE, + defaultSearchQuery, +} from '../../../../common'; +import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; +import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; + +import { useExploreData, TableItem } from './use_explore_data'; +import { ExplorationTitle } from './classification_exploration'; + +const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; + +const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); +interface Props { + jobConfig: DataFrameAnalyticsConfig; + jobStatus: DATA_FRAME_TASK_STATE; + setEvaluateSearchQuery: React.Dispatch>; +} + +export const ResultsTable: FC = React.memo( + ({ jobConfig, jobStatus, setEvaluateSearchQuery }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(25); + const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); + const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const [searchError, setSearchError] = useState(undefined); + const [searchString, setSearchString] = useState(undefined); + + function toggleColumnsPopover() { + setColumnsPopoverVisible(!isColumnsPopoverVisible); + } + + function closeColumnsPopover() { + setColumnsPopoverVisible(false); + } + + function toggleColumn(column: EsFieldName) { + if (tableItems.length > 0 && jobConfig !== undefined) { + // spread to a new array otherwise the component wouldn't re-render + setSelectedFields([...toggleSelectedField(selectedFields, column)]); + } + } + + const { + errorMessage, + loadExploreData, + sortField, + sortDirection, + status, + tableItems, + } = useExploreData(jobConfig, selectedFields, setSelectedFields); + + let docFields: EsFieldName[] = []; + let docFieldsCount = 0; + if (tableItems.length > 0) { + docFields = Object.keys(tableItems[0]); + docFields.sort((a, b) => sortRegressionResultsFields(a, b, jobConfig)); + docFieldsCount = docFields.length; + } + + const columns: Array> = []; + + if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) { + columns.push( + ...selectedFields.sort(sortRegressionResultsColumns(tableItems[0], jobConfig)).map(k => { + const column: ColumnType = { + field: k, + name: k, + sortable: true, + truncateText: true, + }; + + const render = (d: any, fullItem: EsDoc) => { + if (Array.isArray(d) && d.every(item => typeof item === 'string')) { + // If the cells data is an array of strings, return as a comma separated list. + // The list will get limited to 5 items with `…` at the end if there's more in the original array. + return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; + } else if (Array.isArray(d)) { + // If the cells data is an array of e.g. objects, display a 'array' badge with a + // tooltip that explains that this type of field is not supported in this table. + return ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.indexArrayBadgeContent', + { + defaultMessage: 'array', + } + )} + + + ); + } else if (typeof d === 'object' && d !== null) { + // If the cells data is an object, display a 'object' badge with a + // tooltip that explains that this type of field is not supported in this table. + return ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.indexObjectBadgeContent', + { + defaultMessage: 'object', + } + )} + + + ); + } + + return d; + }; + + let columnType; + + if (tableItems.length > 0) { + columnType = typeof tableItems[0][k]; + } + + if (typeof columnType !== 'undefined') { + switch (columnType) { + case 'boolean': + column.dataType = 'boolean'; + break; + case 'Date': + column.align = 'right'; + column.render = (d: any) => + formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); + break; + case 'number': + column.dataType = 'number'; + column.render = render; + break; + default: + column.render = render; + break; + } + } else { + column.render = render; + } + + return column; + }) + ); + } + + useEffect(() => { + if (jobConfig !== undefined) { + const predictedFieldName = getPredictedFieldName( + jobConfig.dest.results_field, + jobConfig.analysis + ); + const predictedFieldSelected = selectedFields.includes(predictedFieldName); + + const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; + const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; + loadExploreData({ field, direction, searchQuery }); + } + }, [JSON.stringify(searchQuery)]); + + useEffect(() => { + // by default set the sorting to descending on the prediction field (`_prediction`). + // if that's not available sort ascending on the first column. + // also check if the current sorting field is still available. + if (jobConfig !== undefined && columns.length > 0 && !selectedFields.includes(sortField)) { + const predictedFieldName = getPredictedFieldName( + jobConfig.dest.results_field, + jobConfig.analysis + ); + const predictedFieldSelected = selectedFields.includes(predictedFieldName); + + const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; + const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; + loadExploreData({ field, direction, searchQuery }); + } + }, [jobConfig, columns.length, sortField, sortDirection, tableItems.length]); + + let sorting: SortingPropType = false; + let onTableChange; + + if (columns.length > 0 && sortField !== '' && sortField !== undefined) { + sorting = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + onTableChange = ({ + page = { index: 0, size: 10 }, + sort = { field: sortField, direction: sortDirection }, + }: OnTableChangeArg) => { + const { index, size } = page; + setPageIndex(index); + setPageSize(size); + + if (sort.field !== sortField || sort.direction !== sortDirection) { + loadExploreData({ ...sort, searchQuery }); + } + }; + } + + const pagination = { + initialPageIndex: pageIndex, + initialPageSize: pageSize, + totalItemCount: tableItems.length, + pageSizeOptions: PAGE_SIZE_OPTIONS, + hidePerPageOptions: false, + }; + + const onQueryChange = ({ query, error }: { query: QueryType; error: any }) => { + if (error) { + setSearchError(error.message); + } else { + try { + const esQueryDsl = Query.toESQuery(query); + setSearchQuery(esQueryDsl); + setSearchString(query.text); + setSearchError(undefined); + // set query for use in evaluate panel + setEvaluateSearchQuery(esQueryDsl); + } catch (e) { + setSearchError(e.toString()); + } + } + }; + + const search = { + onChange: onQueryChange, + defaultQuery: searchString, + box: { + incremental: false, + placeholder: i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.searchBoxPlaceholder', + { + defaultMessage: 'E.g. avg>0.5', + } + ), + }, + filters: [ + { + type: 'field_value_toggle_group', + field: `${jobConfig.dest.results_field}.is_training`, + items: [ + { + value: false, + name: i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.isTestingLabel', + { + defaultMessage: 'Testing', + } + ), + }, + { + value: true, + name: i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.isTrainingLabel', + { + defaultMessage: 'Training', + } + ), + }, + ], + }, + ], + }; + + if (jobConfig === undefined) { + return null; + } + // if it's a searchBar syntax error leave the table visible so they can try again + if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) { + return ( + + + + + + + {getTaskStateBadge(jobStatus)} + + + +

{errorMessage}

+
+
+ ); + } + + const tableError = + status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception') + ? errorMessage + : searchError; + + return ( + + + + + + + + + {getTaskStateBadge(jobStatus)} + + + + + + + {docFieldsCount > MAX_COLUMNS && ( + + {i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.fieldSelection', + { + defaultMessage: + '{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected', + values: { selectedFieldsLength: selectedFields.length, docFieldsCount }, + } + )} + + )} + + + + + } + isOpen={isColumnsPopoverVisible} + closePopover={closeColumnsPopover} + ownFocus + > + + {i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.selectFieldsPopoverTitle', + { + defaultMessage: 'Select fields', + } + )} + +
+ {docFields.map(d => ( + toggleColumn(d)} + disabled={selectedFields.includes(d) && selectedFields.length === 1} + /> + ))} +
+
+
+
+
+
+
+ {status === INDEX_STATUS.LOADING && } + {status !== INDEX_STATUS.LOADING && ( + + )} + {(columns.length > 0 || searchQuery !== defaultSearchQuery) && ( + + {tableItems.length === SEARCH_SIZE && ( + + + + )} + + + + )} +
+ ); + } +); diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts new file mode 100644 index 000000000000..ba12fcab98a3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; + +import { SearchResponse } from 'elasticsearch'; + +import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_memory_table'; + +import { ml } from '../../../../../services/ml_api_service'; +import { getNestedProperty } from '../../../../../util/object_utils'; +import { SavedSearchQuery } from '../../../../../contexts/kibana'; + +import { + getDefaultClassificationFields, + getFlattenedFields, + DataFrameAnalyticsConfig, + EsFieldName, + getPredictedFieldName, + INDEX_STATUS, + SEARCH_SIZE, + defaultSearchQuery, + SearchQuery, +} from '../../../../common'; + +export type TableItem = Record; + +interface LoadExploreDataArg { + field: string; + direction: SortDirection; + searchQuery: SavedSearchQuery; +} +export interface UseExploreDataReturnType { + errorMessage: string; + loadExploreData: (arg: LoadExploreDataArg) => void; + sortField: EsFieldName; + sortDirection: SortDirection; + status: INDEX_STATUS; + tableItems: TableItem[]; +} + +export const useExploreData = ( + jobConfig: DataFrameAnalyticsConfig | undefined, + selectedFields: EsFieldName[], + setSelectedFields: React.Dispatch> +): UseExploreDataReturnType => { + const [errorMessage, setErrorMessage] = useState(''); + const [status, setStatus] = useState(INDEX_STATUS.UNUSED); + const [tableItems, setTableItems] = useState([]); + const [sortField, setSortField] = useState(''); + const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); + + const loadExploreData = async ({ field, direction, searchQuery }: LoadExploreDataArg) => { + if (jobConfig !== undefined) { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + try { + const resultsField = jobConfig.dest.results_field; + const body: SearchQuery = { + query: searchQuery, + }; + + if (field !== undefined) { + body.sort = [ + { + [field]: { + order: direction, + }, + }, + ]; + } + + const resp: SearchResponse = await ml.esSearch({ + index: jobConfig.dest.index, + size: SEARCH_SIZE, + body, + }); + + setSortField(field); + setSortDirection(direction); + + const docs = resp.hits.hits; + + if (docs.length === 0) { + setTableItems([]); + setStatus(INDEX_STATUS.LOADED); + return; + } + + if (selectedFields.length === 0) { + const newSelectedFields = getDefaultClassificationFields(docs, jobConfig); + setSelectedFields(newSelectedFields); + } + + // Create a version of the doc's source with flattened field names. + // This avoids confusion later on if a field name has dots in its name + // or is a nested fields when displaying it via EuiInMemoryTable. + const flattenedFields = getFlattenedFields(docs[0]._source, resultsField); + const transformedTableItems = docs.map(doc => { + const item: TableItem = {}; + flattenedFields.forEach(ff => { + item[ff] = getNestedProperty(doc._source, ff); + if (item[ff] === undefined) { + // If the attribute is undefined, it means it was not a nested property + // but had dots in its actual name. This selects the property by its + // full name and assigns it to `item[ff]`. + item[ff] = doc._source[`"${ff}"`]; + } + if (item[ff] === undefined) { + const parts = ff.split('.'); + if (parts[0] === resultsField && parts.length >= 2) { + parts.shift(); + if (doc._source[resultsField] !== undefined) { + item[ff] = doc._source[resultsField][parts.join('.')]; + } + } + } + }); + return item; + }); + + setTableItems(transformedTableItems); + setStatus(INDEX_STATUS.LOADED); + } catch (e) { + if (e.message !== undefined) { + setErrorMessage(e.message); + } else { + setErrorMessage(JSON.stringify(e)); + } + setTableItems([]); + setStatus(INDEX_STATUS.ERROR); + } + } + }; + + useEffect(() => { + if (jobConfig !== undefined) { + loadExploreData({ + field: getPredictedFieldName(jobConfig.dest.results_field, jobConfig.analysis), + direction: SORT_DIRECTION.DESC, + searchQuery: defaultSearchQuery, + }); + } + }, [jobConfig && jobConfig.id]); + + return { errorMessage, loadExploreData, sortField, sortDirection, status, tableItems }; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/error_callout.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx similarity index 100% rename from x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/error_callout.tsx rename to x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/index.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/index.ts new file mode 100644 index 000000000000..4f86d0d061c9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ErrorCallout } from './error_callout'; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/index.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/index.ts new file mode 100644 index 000000000000..40b9e000d6b0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LoadingPanel } from './loading_panel'; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/loading_panel.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/loading_panel.tsx new file mode 100644 index 000000000000..f71fbc944f0e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/loading_panel/loading_panel.tsx @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; + +export const LoadingPanel: FC = () => ( + + + +); diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx index d877ed40e587..a8a015c6ef34 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx @@ -8,40 +8,30 @@ import React, { FC, Fragment, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { ErrorCallout } from './error_callout'; +import { ErrorCallout } from '../error_callout'; import { getValuesFromResponse, getDependentVar, getPredictionFieldName, loadEvalData, + loadDocsCount, Eval, DataFrameAnalyticsConfig, } from '../../../../common'; -import { ml } from '../../../../../services/ml_api_service'; import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; import { EvaluateStat } from './evaluate_stat'; import { - getEvalQueryBody, - isRegressionResultsSearchBoolQuery, - RegressionResultsSearchQuery, - SearchQuery, + isResultsSearchBoolQuery, + isRegressionEvaluateResponse, + ResultsSearchQuery, + ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; interface Props { jobConfig: DataFrameAnalyticsConfig; jobStatus: DATA_FRAME_TASK_STATE; - searchQuery: RegressionResultsSearchQuery; -} - -interface TrackTotalHitsSearchResponse { - hits: { - total: { - value: number; - relation: string; - }; - hits: any[]; - }; + searchQuery: ResultsSearchQuery; } const defaultEval: Eval = { meanSquaredError: '', rSquared: '', error: null }; @@ -60,40 +50,6 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) // default is 'ml' const resultsField = jobConfig.dest.results_field; - const loadDocsCount = async ({ - ignoreDefaultQuery = true, - isTraining, - }: { - ignoreDefaultQuery?: boolean; - isTraining: boolean; - }): Promise<{ - docsCount: number | null; - success: boolean; - }> => { - const query = getEvalQueryBody({ resultsField, isTraining, ignoreDefaultQuery, searchQuery }); - - try { - const body: SearchQuery = { - track_total_hits: true, - query, - }; - - const resp: TrackTotalHitsSearchResponse = await ml.esSearch({ - index: jobConfig.dest.index, - size: 0, - body, - }); - - const docsCount = resp.hits.total && resp.hits.total.value; - return { docsCount, success: true }; - } catch (e) { - return { - docsCount: null, - success: false, - }; - } - }; - const loadGeneralizationData = async (ignoreDefaultQuery: boolean = true) => { setIsLoadingGeneralization(true); @@ -105,9 +61,14 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) predictionFieldName, searchQuery, ignoreDefaultQuery, + jobType: ANALYSIS_CONFIG_TYPE.REGRESSION, }); - if (genErrorEval.success === true && genErrorEval.eval) { + if ( + genErrorEval.success === true && + genErrorEval.eval && + isRegressionEvaluateResponse(genErrorEval.eval) + ) { const { meanSquaredError, rSquared } = getValuesFromResponse(genErrorEval.eval); setGeneralizationEval({ meanSquaredError, @@ -136,9 +97,14 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) predictionFieldName, searchQuery, ignoreDefaultQuery, + jobType: ANALYSIS_CONFIG_TYPE.REGRESSION, }); - if (trainingErrorEval.success === true && trainingErrorEval.eval) { + if ( + trainingErrorEval.success === true && + trainingErrorEval.eval && + isRegressionEvaluateResponse(trainingErrorEval.eval) + ) { const { meanSquaredError, rSquared } = getValuesFromResponse(trainingErrorEval.eval); setTrainingEval({ meanSquaredError, @@ -165,7 +131,13 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) if (isTrainingClause !== undefined && isTrainingClause.query === 'false') { loadGeneralizationData(); - const docsCountResp = await loadDocsCount({ isTraining: false }); + const docsCountResp = await loadDocsCount({ + isTraining: false, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + if (docsCountResp.success === true) { setGeneralizationDocsCount(docsCountResp.docsCount); } else { @@ -182,7 +154,13 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) // searchBar query is filtering for training data loadTrainingData(); - const docsCountResp = await loadDocsCount({ isTraining: true }); + const docsCountResp = await loadDocsCount({ + isTraining: true, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + if (docsCountResp.success === true) { setTrainingDocsCount(docsCountResp.docsCount); } else { @@ -201,6 +179,9 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const genDocsCountResp = await loadDocsCount({ ignoreDefaultQuery: false, isTraining: false, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, }); if (genDocsCountResp.success === true) { setGeneralizationDocsCount(genDocsCountResp.docsCount); @@ -212,6 +193,9 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const trainDocsCountResp = await loadDocsCount({ ignoreDefaultQuery: false, isTraining: true, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, }); if (trainDocsCountResp.success === true) { setTrainingDocsCount(trainDocsCountResp.docsCount); @@ -223,7 +207,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) useEffect(() => { const hasIsTrainingClause = - isRegressionResultsSearchBoolQuery(searchQuery) && + isResultsSearchBoolQuery(searchQuery) && searchQuery.bool.must.filter( (clause: any) => clause.match && clause.match[`${resultsField}.is_training`] !== undefined ); @@ -241,10 +225,13 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) - {i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.jobIdTitle', { - defaultMessage: 'Regression job ID {jobId}', - values: { jobId: jobConfig.id }, - })} + {i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle', + { + defaultMessage: 'Evaluation of regression job ID {jobId}', + values: { jobId: jobConfig.id }, + } + )} diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx index 2f7ff4feed2a..12a41e1e7d85 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx @@ -5,31 +5,26 @@ */ import React, { FC, Fragment, useState, useEffect } from 'react'; -import { EuiCallOut, EuiLoadingSpinner, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiCallOut, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ml } from '../../../../../services/ml_api_service'; import { DataFrameAnalyticsConfig } from '../../../../common'; import { EvaluatePanel } from './evaluate_panel'; import { ResultsTable } from './results_table'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; -import { RegressionResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; +import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; +import { LoadingPanel } from '../loading_panel'; interface GetDataFrameAnalyticsResponse { count: number; data_frame_analytics: DataFrameAnalyticsConfig[]; } -const LoadingPanel: FC = () => ( - - - -); - export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => ( - {i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.jobIdTitle', { - defaultMessage: 'Regression job ID {jobId}', + {i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.tableJobIdTitle', { + defaultMessage: 'Destination index for regression job ID {jobId}', values: { jobId }, })} @@ -45,7 +40,7 @@ export const RegressionExploration: FC = ({ jobId, jobStatus }) => { const [jobConfig, setJobConfig] = useState(undefined); const [isLoadingJobConfig, setIsLoadingJobConfig] = useState(false); const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState(undefined); - const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const loadJobConfig = async () => { setIsLoadingJobConfig(true); diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx index 37c2e40c89c3..1828297365f7 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/results_table.tsx @@ -60,6 +60,8 @@ import { ExplorationTitle } from './regression_exploration'; const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; +const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); + interface Props { jobConfig: DataFrameAnalyticsConfig; jobStatus: DATA_FRAME_TASK_STATE; @@ -363,8 +365,6 @@ export const ResultsTable: FC = React.memo( ? errorMessage : searchError; - const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); - return ( diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts index 3a83ad238d0e..8e9cf45c14ec 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts @@ -3,11 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ import React, { useEffect, useState } from 'react'; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx index b3d13db0a355..b00a38e2b5f6 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx @@ -24,6 +24,7 @@ import { NavigationMenu } from '../../../components/navigation_menu'; import { Exploration } from './components/exploration'; import { RegressionExploration } from './components/regression_exploration'; +import { ClassificationExploration } from './components/classification_exploration'; import { ANALYSIS_CONFIG_TYPE } from '../../common/analytics'; import { DATA_FRAME_TASK_STATE } from '../analytics_management/components/analytics_list/common'; @@ -72,6 +73,9 @@ export const Page: FC<{ {analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION && ( )} + {analysisType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && ( + + )} diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx index e189c961ccbc..fc3c00cbcf3e 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx @@ -17,6 +17,7 @@ import { getAnalysisType, isRegressionAnalysis, isOutlierAnalysis, + isClassificationAnalysis, } from '../../../../common/analytics'; import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; @@ -31,7 +32,9 @@ export const AnalyticsViewAction = { const analysisType = getAnalysisType(item.config.analysis); const jobStatus = item.stats.state; const isDisabled = - !isRegressionAnalysis(item.config.analysis) && !isOutlierAnalysis(item.config.analysis); + !isRegressionAnalysis(item.config.analysis) && + !isOutlierAnalysis(item.config.analysis) && + !isClassificationAnalysis(item.config.analysis); const url = getResultsUrl(item.id, analysisType, jobStatus); return ( diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx index 91b73307ef56..8772be698bf5 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row.tsx @@ -25,7 +25,11 @@ import { Eval, } from '../../../../common'; import { isCompletedAnalyticsJob } from './common'; -import { isRegressionAnalysis } from '../../../../common/analytics'; +import { + isRegressionAnalysis, + ANALYSIS_CONFIG_TYPE, + isRegressionEvaluateResponse, +} from '../../../../common/analytics'; import { ExpandedRowMessagesPane } from './expanded_row_messages_pane'; function getItemDescription(value: any) { @@ -81,9 +85,14 @@ export const ExpandedRow: FC = ({ item }) => { dependentVariable, resultsField, predictionFieldName, + jobType: ANALYSIS_CONFIG_TYPE.REGRESSION, }); - if (genErrorEval.success === true && genErrorEval.eval) { + if ( + genErrorEval.success === true && + genErrorEval.eval && + isRegressionEvaluateResponse(genErrorEval.eval) + ) { const { meanSquaredError, rSquared } = getValuesFromResponse(genErrorEval.eval); setGeneralizationEval({ meanSquaredError, @@ -106,9 +115,14 @@ export const ExpandedRow: FC = ({ item }) => { dependentVariable, resultsField, predictionFieldName, + jobType: ANALYSIS_CONFIG_TYPE.REGRESSION, }); - if (trainingErrorEval.success === true && trainingErrorEval.eval) { + if ( + trainingErrorEval.success === true && + trainingErrorEval.eval && + isRegressionEvaluateResponse(trainingErrorEval.eval) + ) { const { meanSquaredError, rSquared } = getValuesFromResponse(trainingErrorEval.eval); setTrainingEval({ meanSquaredError, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f047e6a506f8..18bfbff786ed 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7864,7 +7864,6 @@ "xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "データの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.regressionExploration.generalError": "データの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "一般化エラー", - "xpack.ml.dataframe.analytics.regressionExploration.jobIdTitle": "ジョブ ID {jobId}", "xpack.ml.dataframe.analytics.regressionExploration.meanSquaredErrorText": "平均二乗エラー", "xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutBody": "インデックスのクエリが結果を返しませんでした。ジョブが完了済みで、インデックスにドキュメントがあることを確認してください。", "xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutTitle": "空のインデックスクエリ結果。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1be193f83d3b..7f55866c17a1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7957,7 +7957,6 @@ "xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "加载数据时出错。", "xpack.ml.dataframe.analytics.regressionExploration.generalError": "加载数据时出错。", "xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "泛化误差", - "xpack.ml.dataframe.analytics.regressionExploration.jobIdTitle": "作业 ID {jobId}", "xpack.ml.dataframe.analytics.regressionExploration.meanSquaredErrorText": "均方误差", "xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutBody": "该索引的查询未返回结果。请确保作业已完成且索引包含文档。", "xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutTitle": "空的索引查询结果。",