Skip to content

Commit

Permalink
[ML] Data Frame Analytics: Adds scatterplot matrix to regression/clas…
Browse files Browse the repository at this point in the history
…sification results pages. (#88353)

- Adds support for scatterplot matrices to regression/classification results pages
- Lazy loads the scatterplot matrix including Vega code using Suspense. The approach is taken from the Kibana Vega plugin, creating this separate bundle means you'll load the 600kb+ Vega code only on pages where actually needed and not e.g. already on the analytics job list. Note for reviews: The file scatterplot_matrix_view.tsx did not change besides the default export, it just shows up as a new file because of the refactoring to support lazy loading.
- Adds support for analytics configuration that use the excludes instead of includes field list.
- Adds the field used for color legends to tooltips.
  • Loading branch information
walterra committed Feb 1, 2021
1 parent 1b8c3c1 commit fb19aab
Show file tree
Hide file tree
Showing 23 changed files with 629 additions and 345 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

export { useScatterplotFieldOptions } from './use_scatterplot_field_options';
export { LEGEND_TYPES } from './scatterplot_matrix_vega_lite_spec';
export { ScatterplotMatrix } from './scatterplot_matrix';
export type { ScatterplotMatrixViewProps as ScatterplotMatrixProps } from './scatterplot_matrix_view';
Original file line number Diff line number Diff line change
Expand Up @@ -4,316 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/

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

// There is still an issue with Vega Lite's typings with the strict mode Kibana is using.
// @ts-ignore
import { compile } from 'vega-lite/build-es5/vega-lite';
import { parse, View, Warn } from 'vega';
import { Handler } from 'vega-tooltip';
import type { ScatterplotMatrixViewProps } from './scatterplot_matrix_view';
import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading';

import {
htmlIdGenerator,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiLoadingSpinner,
EuiSelect,
EuiSpacer,
EuiSwitch,
EuiText,
} from '@elastic/eui';
const ScatterplotMatrixLazy = React.lazy(() => import('./scatterplot_matrix_view'));

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

import type { SearchResponse7 } from '../../../../common/types/es_client';

import { useMlApiContext } from '../../contexts/kibana';

import { getProcessedFields } from '../data_grid';
import { useCurrentEuiTheme } from '../color_range_legend';

import {
getScatterplotMatrixVegaLiteSpec,
LegendType,
OUTLIER_SCORE_FIELD,
} from './scatterplot_matrix_vega_lite_spec';

import './scatterplot_matrix.scss';

const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4;
const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000;
const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1;
const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000;

const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', {
defaultMessage: 'On',
});
const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', {
defaultMessage: 'Off',
});

const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d }));

interface ScatterplotMatrixProps {
fields: string[];
index: string;
resultsField?: string;
color?: string;
legendType?: LegendType;
}

export const ScatterplotMatrix: FC<ScatterplotMatrixProps> = ({
fields: allFields,
index,
resultsField,
color,
legendType,
}) => {
const { esSearch } = useMlApiContext();

// dynamicSize is optionally used for outlier charts where the scatterplot marks
// are sized according to outlier_score
const [dynamicSize, setDynamicSize] = useState<boolean>(false);

// used to give the use the option to customize the fields used for the matrix axes
const [fields, setFields] = useState<string[]>([]);

useEffect(() => {
const defaultFields =
allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS
? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS)
: allFields;
setFields(defaultFields);
}, [allFields]);

// the amount of documents to be fetched
const [fetchSize, setFetchSize] = useState<number>(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE);
// flag to add a random score to the ES query to fetch documents
const [randomizeQuery, setRandomizeQuery] = useState<boolean>(false);

const [isLoading, setIsLoading] = useState<boolean>(false);

// contains the fetched documents and columns to be passed on to the Vega spec.
const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>();

// formats the array of field names for EuiComboBox
const fieldOptions = useMemo(
() =>
allFields.map((d) => ({
label: d,
})),
[allFields]
);

const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => {
setFields(newFields.map((d) => d.label));
};

const fetchSizeOnChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setFetchSize(
Math.min(
Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE),
SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE
)
);
};

const randomizeQueryOnChange = () => {
setRandomizeQuery(!randomizeQuery);
};

const dynamicSizeOnChange = () => {
setDynamicSize(!dynamicSize);
};

const { euiTheme } = useCurrentEuiTheme();

useEffect(() => {
async function fetchSplom(options: { didCancel: boolean }) {
setIsLoading(true);
try {
const queryFields = [
...fields,
...(color !== undefined ? [color] : []),
...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]),
];

const query = randomizeQuery
? {
function_score: {
random_score: { seed: 10, field: '_seq_no' },
},
}
: { match_all: {} };

const resp: SearchResponse7 = await esSearch({
index,
body: {
fields: queryFields,
_source: false,
query,
from: 0,
size: fetchSize,
},
});

if (!options.didCancel) {
const items = resp.hits.hits.map((d) =>
getProcessedFields(d.fields, (key: string) =>
key.startsWith(`${resultsField}.feature_importance`)
)
);

setSplom({ columns: fields, items });
setIsLoading(false);
}
} catch (e) {
// TODO error handling
setIsLoading(false);
}
}

const options = { didCancel: false };
fetchSplom(options);
return () => {
options.didCancel = true;
};
// stringify the fields array, otherwise the comparator will trigger on new but identical instances.
}, [fetchSize, JSON.stringify(fields), index, randomizeQuery, resultsField]);

const htmlId = useMemo(() => htmlIdGenerator()(), []);

useEffect(() => {
if (splom === undefined) {
return;
}

const { items, columns } = splom;

const values =
resultsField !== undefined
? items
: items.map((d) => {
d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0;
return d;
});

const vegaSpec = getScatterplotMatrixVegaLiteSpec(
values,
columns,
euiTheme,
resultsField,
color,
legendType,
dynamicSize
);

const vgSpec = compile(vegaSpec).spec;

const view = new View(parse(vgSpec))
.logLevel(Warn)
.renderer('canvas')
.tooltip(new Handler().call)
.initialize(`#${htmlId}`);

view.runAsync(); // evaluate and render the view
}, [resultsField, splom, color, legendType, dynamicSize]);

return (
<>
{splom === undefined ? (
<EuiText textAlign="center">
<EuiSpacer size="l" />
<EuiLoadingSpinner size="l" />
<EuiSpacer size="l" />
</EuiText>
) : (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.ml.splom.fieldSelectionLabel', {
defaultMessage: 'Fields',
})}
display="rowCompressed"
fullWidth
>
<EuiComboBox
compressed
fullWidth
placeholder={i18n.translate('xpack.ml.splom.fieldSelectionPlaceholder', {
defaultMessage: 'Select fields',
})}
options={fieldOptions}
selectedOptions={fields.map((d) => ({
label: d,
}))}
onChange={fieldsOnChange}
isClearable={true}
data-test-subj="mlScatterplotMatrixFieldsComboBox"
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem style={{ width: '200px' }} grow={false}>
<EuiFormRow
label={i18n.translate('xpack.ml.splom.SampleSizeLabel', {
defaultMessage: 'Sample size',
})}
display="rowCompressed"
fullWidth
>
<EuiSelect
compressed
options={sampleSizeOptions}
value={fetchSize}
onChange={fetchSizeOnChange}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem style={{ width: '120px' }} grow={false}>
<EuiFormRow
label={i18n.translate('xpack.ml.splom.RandomScoringLabel', {
defaultMessage: 'Random scoring',
})}
display="rowCompressed"
fullWidth
>
<EuiSwitch
name="mlScatterplotMatrixRandomizeQuery"
label={randomizeQuery ? TOGGLE_ON : TOGGLE_OFF}
checked={randomizeQuery}
onChange={randomizeQueryOnChange}
disabled={isLoading}
/>
</EuiFormRow>
</EuiFlexItem>
{resultsField !== undefined && legendType === undefined && (
<EuiFlexItem style={{ width: '120px' }} grow={false}>
<EuiFormRow
label={i18n.translate('xpack.ml.splom.dynamicSizeLabel', {
defaultMessage: 'Dynamic size',
})}
display="rowCompressed"
fullWidth
>
<EuiSwitch
name="mlScatterplotMatrixDynamicSize"
label={dynamicSize ? TOGGLE_ON : TOGGLE_OFF}
checked={dynamicSize}
onChange={dynamicSizeOnChange}
disabled={isLoading}
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>

<div id={htmlId} className="mlScatterplotMatrix" />
</>
)}
</>
);
};
export const ScatterplotMatrix: FC<ScatterplotMatrixViewProps> = (props) => (
<Suspense fallback={<ScatterplotMatrixLoading />}>
<ScatterplotMatrixLazy {...props} />
</Suspense>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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 { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';

export const ScatterplotMatrixLoading = () => {
return (
<EuiText textAlign="center">
<EuiSpacer size="l" />
<EuiLoadingSpinner size="l" />
<EuiSpacer size="l" />
</EuiText>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ describe('getScatterplotMatrixVegaLiteSpec()', () => {
type: 'nominal',
});
expect(vegaLiteSpec.spec.encoding.tooltip).toEqual([
{ field: 'the-color-field', type: 'nominal' },
{ field: 'x', type: 'quantitative' },
{ field: 'y', type: 'quantitative' },
]);
Expand Down

0 comments on commit fb19aab

Please sign in to comment.