[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
This commit is contained in:
Melissa Alvarez 2019-12-12 12:02:04 -07:00 committed by GitHub
parent 79a8528646
commit 0cd5bb0ca9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1514 additions and 116 deletions

View file

@ -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';

View file

@ -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<string, any>;
}
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<any>;
}
interface RegressionResultsSearchTermQuery {
interface ResultsSearchTermQuery {
term: Dictionary<any>;
}
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<LoadDocsCountResponse> => {
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,
};
}
};

View file

@ -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

View file

@ -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,

View file

@ -0,0 +1,4 @@
.euiFormRow.mlDataFrameAnalyticsClassification__actualLabel {
padding-top: $euiSize * 4;
}

View file

@ -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 }) => (
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle', {
defaultMessage: 'Destination index for classification job ID {jobId}',
values: { jobId },
})}
</span>
</EuiTitle>
);
interface Props {
jobId: string;
jobStatus: DATA_FRAME_TASK_STATE;
}
export const ClassificationExploration: FC<Props> = ({ jobId, jobStatus }) => {
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
const [isLoadingJobConfig, setIsLoadingJobConfig] = useState<boolean>(false);
const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState<undefined | string>(undefined);
const [searchQuery, setSearchQuery] = useState<ResultsSearchQuery>(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 (
<EuiPanel grow={false}>
<ExplorationTitle jobId={jobId} />
<EuiSpacer />
<EuiCallOut
title={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationFetchError',
{
defaultMessage:
'Unable to fetch results. An error occurred loading the job configuration data.',
}
)}
color="danger"
iconType="cross"
>
<p>{jobConfigErrorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
}
return (
<Fragment>
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false && jobConfig !== undefined && (
<EvaluatePanel jobConfig={jobConfig} jobStatus={jobStatus} searchQuery={searchQuery} />
)}
<EuiSpacer />
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false && jobConfig !== undefined && (
<ResultsTable
jobConfig={jobConfig}
jobStatus={jobStatus}
setEvaluateSearchQuery={setSearchQuery}
/>
)}
</Fragment>
);
};

View file

@ -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<ColumnData[]> = [];
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: <span />,
},
];
colData.forEach((data: any) => {
columns.push({ id: data.predicted_class });
});
return { columns, columnData: colData };
}

View file

@ -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<Props> = ({ jobConfig, jobStatus, searchQuery }) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [confusionMatrixData, setConfusionMatrixData] = useState<ConfusionMatrix[]>([]);
const [columns, setColumns] = useState<any>([]);
const [columnsData, setColumnsData] = useState<any>([]);
const [popoverContents, setPopoverContents] = useState<any>([]);
const [docsCount, setDocsCount] = useState<null | number>(null);
const [error, setError] = useState<null | string>(null);
const [panelWidth, setPanelWidth] = useState<number>(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 (
<span>{typeof cellValue === 'number' ? `${Math.round(cellValue * 100)}%` : cellValue}</span>
);
};
if (isLoading === true) {
return <LoadingPanel />;
}
return (
<EuiPanel style={{ width: `${panelWidth}px` }}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<span>
{i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle',
{
defaultMessage: 'Evaluation of classification job ID {jobId}',
values: { jobId: jobConfig.id },
}
)}
</span>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>{getTaskStateBadge(jobStatus)}</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{error !== null && (
<EuiFlexItem grow={false}>
<ErrorCallout error={error} />
</EuiFlexItem>
)}
{error === null && (
<Fragment>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
<EuiTitle size="xxs">
<span>
{i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixHelpText',
{
defaultMessage: 'Normalized confusion matrix',
}
)}
</span>
</EuiTitle>
<EuiFlexItem grow={false}>
<EuiIconTip
anchorClassName="mlDataFrameAnalyticsClassificationInfoTooltip"
content={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTooltip',
{
defaultMessage:
'The multi-class confusion matrix contains the number of occurrences where the analysis classified data points correctly with their actual class as well as the number of occurrences where it misclassified them with another class',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{docsCount !== null && (
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount"
defaultMessage="{docsCount, plural, one {# doc} other {# docs}} evaluated"
values={{ docsCount }}
/>
</EuiText>
</EuiFlexItem>
)}
{/* BEGIN TABLE ELEMENTS */}
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" style={{ paddingLeft: '10%', paddingRight: '10%' }}>
<EuiFlexItem grow={false}>
<EuiFormRow
className="mlDataFrameAnalyticsClassification__actualLabel"
helpText={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixActualLabel',
{
defaultMessage: 'Actual label',
}
)}
>
<Fragment />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{columns.length > 0 && columnsData.length > 0 && (
<Fragment>
<EuiFlexGroup direction="column" justifyContent="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiSpacer />
</EuiFlexItem>
<EuiFlexItem>
<EuiSpacer />
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
helpText={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixPredictedLabel',
{
defaultMessage: 'Predicted label',
}
)}
>
<Fragment />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '90%' }}>
<EuiDataGrid
aria-label="Data grid demo"
columns={columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
rowCount={columnsData.length}
renderCellValue={renderCellValue}
inMemory={{ level: 'sorting' }}
toolbarVisibility={false}
popoverContents={popoverContents}
gridStyle={{ rowHover: 'none' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</Fragment>
)}
{/* END TABLE ELEMENTS */}
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -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';

View file

@ -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<TableItem>();
interface Props {
jobConfig: DataFrameAnalyticsConfig;
jobStatus: DATA_FRAME_TASK_STATE;
setEvaluateSearchQuery: React.Dispatch<React.SetStateAction<object>>;
}
export const ResultsTable: FC<Props> = 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<SavedSearchQuery>(defaultSearchQuery);
const [searchError, setSearchError] = useState<any>(undefined);
const [searchString, setSearchString] = useState<string | undefined>(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<ColumnType<TableItem>> = [];
if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) {
columns.push(
...selectedFields.sort(sortRegressionResultsColumns(tableItems[0], jobConfig)).map(k => {
const column: ColumnType<TableItem> = {
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 (
<EuiToolTip
content={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.indexArrayToolTipContent',
{
defaultMessage:
'The full content of this array based column cannot be displayed.',
}
)}
>
<EuiBadge>
{i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.indexArrayBadgeContent',
{
defaultMessage: 'array',
}
)}
</EuiBadge>
</EuiToolTip>
);
} 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 (
<EuiToolTip
content={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.indexObjectToolTipContent',
{
defaultMessage:
'The full content of this object based column cannot be displayed.',
}
)}
>
<EuiBadge>
{i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.indexObjectBadgeContent',
{
defaultMessage: 'object',
}
)}
</EuiBadge>
</EuiToolTip>
);
}
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 (`<dependent_varible or prediction_field_name>_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 (
<EuiPanel grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>{getTaskStateBadge(jobStatus)}</span>
</EuiFlexItem>
</EuiFlexGroup>
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.indexError', {
defaultMessage: 'An error occurred loading the index data.',
})}
color="danger"
iconType="cross"
>
<p>{errorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
}
const tableError =
status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception')
? errorMessage
: searchError;
return (
<EuiPanel grow={false} id="mlDataFrameAnalyticsTableResultsPanel">
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>{getTaskStateBadge(jobStatus)}</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem style={{ textAlign: 'right' }}>
{docFieldsCount > MAX_COLUMNS && (
<EuiText size="s">
{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 },
}
)}
</EuiText>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
<EuiPopover
id="popover"
button={
<EuiButtonIcon
iconType="gear"
onClick={toggleColumnsPopover}
aria-label={i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.selectColumnsAriaLabel',
{
defaultMessage: 'Select columns',
}
)}
/>
}
isOpen={isColumnsPopoverVisible}
closePopover={closeColumnsPopover}
ownFocus
>
<EuiPopoverTitle>
{i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.selectFieldsPopoverTitle',
{
defaultMessage: 'Select fields',
}
)}
</EuiPopoverTitle>
<div style={{ maxHeight: '400px', overflowY: 'scroll' }}>
{docFields.map(d => (
<EuiCheckbox
key={d}
id={d}
label={d}
checked={selectedFields.includes(d)}
onChange={() => toggleColumn(d)}
disabled={selectedFields.includes(d) && selectedFields.length === 1}
/>
))}
</div>
</EuiPopover>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{status === INDEX_STATUS.LOADING && <EuiProgress size="xs" color="accent" />}
{status !== INDEX_STATUS.LOADING && (
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && (
<Fragment>
{tableItems.length === SEARCH_SIZE && (
<EuiFormRow
helpText={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.documentsShownHelpText',
{
defaultMessage: 'Showing first {searchSize} documents',
values: { searchSize: SEARCH_SIZE },
}
)}
>
<Fragment />
</EuiFormRow>
)}
<EuiSpacer />
<MlInMemoryTableBasic
allowNeutralSort={false}
columns={columns}
compressed
hasActions={false}
isSelectable={false}
items={tableItems}
onTableChange={onTableChange}
pagination={pagination}
responsive={false}
search={search}
error={tableError}
sorting={sorting}
/>
</Fragment>
)}
</EuiPanel>
);
}
);

View file

@ -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<string, any>;
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<React.SetStateAction<EsFieldName[]>>
): UseExploreDataReturnType => {
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(INDEX_STATUS.UNUSED);
const [tableItems, setTableItems] = useState<TableItem[]>([]);
const [sortField, setSortField] = useState<string>('');
const [sortDirection, setSortDirection] = useState<SortDirection>(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<any> = 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 };
};

View file

@ -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';

View file

@ -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';

View file

@ -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 = () => (
<EuiPanel className="eui-textCenter">
<EuiLoadingSpinner size="xl" />
</EuiPanel>
);

View file

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ jobConfig, jobStatus, searchQuery })
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<span>
{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 },
}
)}
</span>
</EuiTitle>
</EuiFlexItem>

View file

@ -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 = () => (
<EuiPanel className="eui-textCenter">
<EuiLoadingSpinner size="xl" />
</EuiPanel>
);
export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => (
<EuiTitle size="xs">
<span>
{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 },
})}
</span>
@ -45,7 +40,7 @@ export const RegressionExploration: FC<Props> = ({ jobId, jobStatus }) => {
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
const [isLoadingJobConfig, setIsLoadingJobConfig] = useState<boolean>(false);
const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState<undefined | string>(undefined);
const [searchQuery, setSearchQuery] = useState<RegressionResultsSearchQuery>(defaultSearchQuery);
const [searchQuery, setSearchQuery] = useState<ResultsSearchQuery>(defaultSearchQuery);
const loadJobConfig = async () => {
setIsLoadingJobConfig(true);

View file

@ -60,6 +60,8 @@ import { ExplorationTitle } from './regression_exploration';
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
const MlInMemoryTableBasic = mlInMemoryTableBasicFactory<TableItem>();
interface Props {
jobConfig: DataFrameAnalyticsConfig;
jobStatus: DATA_FRAME_TASK_STATE;
@ -363,8 +365,6 @@ export const ResultsTable: FC<Props> = React.memo(
? errorMessage
: searchError;
const MlInMemoryTableBasic = mlInMemoryTableBasicFactory<TableItem>();
return (
<EuiPanel grow={false}>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}>

View file

@ -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';

View file

@ -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 && (
<RegressionExploration jobId={jobId} jobStatus={jobStatus} />
)}
{analysisType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && (
<ClassificationExploration jobId={jobId} jobStatus={jobStatus} />
)}
</EuiPageContentBody>
</EuiPageBody>
</EuiPage>

View file

@ -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 (

View file

@ -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<Props> = ({ 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<Props> = ({ 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,

View file

@ -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": "空のインデックスクエリ結果。",

View file

@ -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": "空的索引查询结果。",