Skip to content

Commit

Permalink
Add Missing Value Imputation Options and Update Shingle Size Limit (#851
Browse files Browse the repository at this point in the history
)

* Add Missing Value Imputation Options and Update Shingle Size Limit

This PR introduces a new missing value imputation feature with three options: zero, fixed values, and previous values. When the fixed values option is selected, users can input custom values for each feature. Validation logic has been added to ensure that the feature names and the number of custom values match the number of enabled features.

Additionally, the review page and model configuration page have been updated to properly display these new parameters.

This PR also increases the maximum shingle size to 128, aligning with the backend implementation.

Testing:
* Updated existing unit tests to reflect these changes.
* Conducted manual end-to-end testing.

Signed-off-by: Kaituo Li <kaituo@amazon.com>

* remove console log

Signed-off-by: Kaituo Li <kaituo@amazon.com>

---------

Signed-off-by: Kaituo Li <kaituo@amazon.com>
  • Loading branch information
kaituo committed Aug 26, 2024
1 parent a426e41 commit 3ed0058
Show file tree
Hide file tree
Showing 22 changed files with 1,067 additions and 61 deletions.
2 changes: 2 additions & 0 deletions public/models/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { DETECTOR_STATE } from '../../server/utils/constants';
import { Duration } from 'moment';
import moment from 'moment';
import { MDSQueryParams } from '../../server/models/types';
import { ImputationOption } from './types';

export type FieldInfo = {
label: string;
Expand Down Expand Up @@ -210,6 +211,7 @@ export type Detector = {
taskState?: DETECTOR_STATE;
taskProgress?: number;
taskError?: string;
imputationOption?: ImputationOption;
};

export type DetectorListItem = {
Expand Down
21 changes: 21 additions & 0 deletions public/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,24 @@
export type AggregationOption = {
label: string;
};

export type ImputationOption = {
method: ImputationMethod;
defaultFill?: Array<{ featureName: string; data: number }>;
};

export enum ImputationMethod {
/**
* This method replaces all missing values with 0's. It's a simple approach, but it may introduce bias if the data is not centered around zero.
*/
ZERO = 'ZERO',
/**
* This method replaces missing values with a predefined set of values. The values are the same for each input dimension, and they need to be specified by the user.
*/
FIXED_VALUES = 'FIXED_VALUES',
/**
* This method replaces missing values with the last known value in the respective input dimension. It's a commonly used method for time series data, where temporal continuity is expected.
*/
PREVIOUS = 'PREVIOUS',
}

Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import {
EuiTitle,
EuiCompressedFieldNumber,
EuiSpacer,
EuiCompressedSelect,
EuiButtonIcon,
EuiCompressedFieldText,
} from '@elastic/eui';
import { Field, FieldProps } from 'formik';
import React, { useState } from 'react';
import { Field, FieldProps, FieldArray, } from 'formik';
import React, { useEffect, useState } from 'react';
import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
import { BASE_DOCS_LINK } from '../../../../utils/constants';
import {
Expand All @@ -28,13 +31,22 @@ import {
validatePositiveInteger,
} from '../../../../utils/utils';
import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow';
import { SparseDataOptionValue } from '../../utils/constants';

interface AdvancedSettingsProps {}

export function AdvancedSettings(props: AdvancedSettingsProps) {
const [showAdvancedSettings, setShowAdvancedSettings] =
useState<boolean>(false);

// Options for the sparse data handling dropdown
const sparseDataOptions = [
{ value: SparseDataOptionValue.IGNORE, text: 'Ignore missing value' },
{ value: SparseDataOptionValue.PREVIOUS_VALUE, text: 'Previous value' },
{ value: SparseDataOptionValue.SET_TO_ZERO, text: 'Set to zero' },
{ value: SparseDataOptionValue.CUSTOM_VALUE, text: 'Custom value' },
];

return (
<ContentPanel
title={
Expand All @@ -58,41 +70,148 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
>
{showAdvancedSettings ? <EuiSpacer size="m" /> : null}
{showAdvancedSettings ? (
<Field name="shingleSize" validate={validatePositiveInteger}>
{({ field, form }: FieldProps) => (
<FormattedFormRow
title="Shingle size"
hint={[
`Set the number of intervals to consider in a detection
<>
<Field name="shingleSize" validate={validatePositiveInteger}>
{({ field, form }: FieldProps) => (
<FormattedFormRow
title="Shingle size"
hint={[
`Set the number of intervals to consider in a detection
window for your model. The anomaly detector expects the
shingle size to be in the range of 1 and 60. The default
shingle size to be in the range of 1 and 128. The default
shingle size is 8. We recommend that you don’t choose 1
unless you have two or more features. Smaller values might
increase recall but also false positives. Larger values
might be useful for ignoring noise in a signal.`,
]}
hintLink={`${BASE_DOCS_LINK}/ad`}
isInvalid={isInvalid(field.name, form)}
error={getError(field.name, form)}
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiCompressedFieldNumber
id="shingleSize"
placeholder="Shingle size"
data-test-subj="shingleSize"
{...field}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<p className="minutes">intervals</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</FormattedFormRow>
)}
</Field>
]}
hintLink={`${BASE_DOCS_LINK}/ad`}
isInvalid={isInvalid(field.name, form)}
error={getError(field.name, form)}
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiCompressedFieldNumber
id="shingleSize"
placeholder="Shingle size"
data-test-subj="shingleSize"
{...field}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<p className="minutes">intervals</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</FormattedFormRow>
)}
</Field>

<Field
name="imputationOption.imputationMethod"
id="imputationOption.imputationMethod"
>
{({ field, form }: FieldProps) => {
// Add an empty row if CUSTOM_VALUE is selected and no rows exist
useEffect(() => {
if (
field.value === SparseDataOptionValue.CUSTOM_VALUE &&
(!form.values.imputationOption?.custom_value ||
form.values.imputationOption.custom_value.length === 0)
) {
form.setFieldValue('imputationOption.custom_value', [
{ featureName: '', value: undefined },
]);
}
}, [field.value, form]);

return (
<>
<FormattedFormRow
title="Sparse data handling"
hint={[`Choose how to handle missing data points.`]}
hintLink={`${BASE_DOCS_LINK}/ad`}
isInvalid={isInvalid(field.name, form)}
error={getError(field.name, form)}
>
<EuiCompressedSelect {...field} options={sparseDataOptions}/>
</FormattedFormRow>

{/* Conditionally render the "Custom value" title and the input fields when 'Custom value' is selected */}
{field.value === SparseDataOptionValue.CUSTOM_VALUE && (
<>
<EuiSpacer size="m" />
<EuiText size="xs">
<h5>Custom value</h5>
</EuiText>
<EuiSpacer size="s" />
<FieldArray name="imputationOption.custom_value">
{(arrayHelpers) => (
<>
{form.values.imputationOption.custom_value?.map((_, index) => (
<EuiFlexGroup
key={index}
gutterSize="s"
alignItems="center"
>
<EuiFlexItem grow={false}>
<Field
name={`imputationOption.custom_value.${index}.featureName`}
id={`imputationOption.custom_value.${index}.featureName`}
>
{({ field }: FieldProps) => (
<EuiCompressedFieldText
placeholder="Feature name"
{...field}
/>
)}
</Field>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Field
name={`imputationOption.custom_value.${index}.data`}
id={`imputationOption.custom_value.${index}.data`}
>
{/* the value is set to field.value || '' to avoid displaying 0 as a default value. */ }
{({ field, form }: FieldProps) => (
<EuiCompressedFieldNumber
fullWidth
placeholder="Custom value"
{...field}
value={field.value || ''}
/>
)}
</Field>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label="Delete row"
onClick={() => arrayHelpers.remove(index)}
/>
</EuiFlexItem>
</EuiFlexGroup>
))}
<EuiSpacer size="s" />
{ /* add new rows with empty values when the add button is clicked. */}
<EuiButtonIcon
iconType="plusInCircle"
onClick={() =>
arrayHelpers.push({ featureName: '', value: 0 })
}
aria-label="Add row"
/>
</>
)}
</FieldArray>
</>
)}
</>
);
}}
</Field>
</>
) : null}
</ContentPanel>
);
Expand Down
64 changes: 62 additions & 2 deletions public/pages/ConfigureModel/containers/ConfigureModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ import {
focusOnFirstWrongFeature,
getCategoryFields,
focusOnCategoryField,
getShingleSizeFromObject,
modelConfigurationToFormik,
focusOnImputationOption,
} from '../utils/helpers';
import { formikToDetector } from '../../ReviewAndCreate/utils/helpers';
import { formikToModelConfiguration } from '../utils/helpers';
Expand All @@ -53,7 +53,7 @@ import { CoreServicesContext } from '../../../components/CoreServices/CoreServic
import { Detector } from '../../../models/interfaces';
import { prettifyErrorMessage } from '../../../../server/utils/helpers';
import { DetectorDefinitionFormikValues } from '../../DefineDetector/models/interfaces';
import { ModelConfigurationFormikValues } from '../models/interfaces';
import { ModelConfigurationFormikValues, FeaturesFormikValues } from '../models/interfaces';
import { CreateDetectorFormikValues } from '../../CreateDetectorSteps/models/interfaces';
import { DETECTOR_STATE } from '../../../../server/utils/constants';
import { getErrorMessage } from '../../../utils/utils';
Expand All @@ -68,6 +68,7 @@ import {
getSavedObjectsClient,
} from '../../../services';
import { DataSourceViewConfig } from '../../../../../../src/plugins/data_source_management/public';
import { SparseDataOptionValue } from '../utils/constants';

interface ConfigureModelRouterProps {
detectorId?: string;
Expand Down Expand Up @@ -173,6 +174,49 @@ export function ConfigureModel(props: ConfigureModelProps) {
}
}, [hasError]);

const validateImputationOption = (
formikValues: ModelConfigurationFormikValues,
errors: any
) => {
const imputationOption = get(formikValues, 'imputationOption', null);

// Initialize an array to hold individual error messages
const customValueErrors: string[] = [];

// Validate imputationOption when method is CUSTOM_VALUE
if (imputationOption && imputationOption.imputationMethod === SparseDataOptionValue.CUSTOM_VALUE) {
const enabledFeatures = formikValues.featureList.filter(
(feature: FeaturesFormikValues) => feature.featureEnabled
);

// Validate that the number of custom values matches the number of enabled features
if ((imputationOption.custom_value || []).length !== enabledFeatures.length) {
customValueErrors.push(
`The number of custom values (${(imputationOption.custom_value || []).length}) does not match the number of enabled features (${enabledFeatures.length}).`
);
}

// Validate that each enabled feature has a corresponding custom value
const missingFeatures = enabledFeatures
.map((feature: FeaturesFormikValues) => feature.featureName)
.filter(
(name: string | undefined) =>
!imputationOption.custom_value?.some((cv) => cv.featureName === name)
);

if (missingFeatures.length > 0) {
customValueErrors.push(
`The following enabled features are missing in custom values: ${missingFeatures.join(', ')}.`
);
}

// If there are any custom value errors, join them into a single string with proper formatting
if (customValueErrors.length > 0) {
errors.custom_value = customValueErrors.join(' ');
}
}
};

const handleFormValidation = async (
formikProps: FormikProps<ModelConfigurationFormikValues>
) => {
Expand All @@ -185,7 +229,12 @@ export function ConfigureModel(props: ConfigureModelProps) {
formikProps.setFieldTouched('featureList');
formikProps.setFieldTouched('categoryField', isHCDetector);
formikProps.setFieldTouched('shingleSize');
formikProps.setFieldTouched('imputationOption');

formikProps.validateForm().then((errors) => {
// Call the extracted validation method
validateImputationOption(formikProps.values, errors);

if (isEmpty(errors)) {
if (props.isEdit) {
// TODO: possibly add logic to also start RT and/or historical from here. Need to think
Expand All @@ -204,11 +253,22 @@ export function ConfigureModel(props: ConfigureModelProps) {
props.setStep(3);
}
} else {
const customValueError = get(errors, 'custom_value')
if (customValueError) {
core.notifications.toasts.addDanger(
customValueError
);
focusOnImputationOption();
return;
}

// TODO: can add focus to all components or possibly customize error message too
if (get(errors, 'featureList')) {
focusOnFirstWrongFeature(errors, formikProps.setFieldTouched);
} else if (get(errors, 'categoryField')) {
focusOnCategoryField();
} else {
console.log(`unexpected error ${JSON.stringify(errors)}`);
}

core.notifications.toasts.addDanger(
Expand Down
11 changes: 11 additions & 0 deletions public/pages/ConfigureModel/models/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ModelConfigurationFormikValues {
categoryFieldEnabled: boolean;
categoryField: string[];
shingleSize: number;
imputationOption?: ImputationFormikValues;
}

export interface FeaturesFormikValues {
Expand All @@ -30,3 +31,13 @@ export interface FeaturesFormikValues {
aggregationOf?: AggregationOption[];
newFeature?: boolean;
}

export interface ImputationFormikValues {
imputationMethod?: string;
custom_value?: CustomValueFormikValues[];
}

export interface CustomValueFormikValues {
featureName: string;
data: number;
}
Loading

0 comments on commit 3ed0058

Please sign in to comment.