Skip to content

Commit 0e7eb49

Browse files
Add Missing Value Imputation Options and Update Shingle Size Limit (opensearch-project#851) (opensearch-project#853)
* 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> (cherry picked from commit 3ed0058) Co-authored-by: Kaituo Li <kaituo@amazon.com>
1 parent 960b409 commit 0e7eb49

File tree

22 files changed

+1052
-57
lines changed

22 files changed

+1052
-57
lines changed

public/models/interfaces.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { DETECTOR_STATE } from '../../server/utils/constants';
1515
import { Duration } from 'moment';
1616
import moment from 'moment';
1717
import { MDSQueryParams } from '../../server/models/types';
18+
import { ImputationOption } from './types';
1819

1920
export type FieldInfo = {
2021
label: string;
@@ -209,6 +210,7 @@ export type Detector = {
209210
taskState?: DETECTOR_STATE;
210211
taskProgress?: number;
211212
taskError?: string;
213+
imputationOption?: ImputationOption;
212214
};
213215

214216
export type DetectorListItem = {

public/models/types.ts

+21
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,24 @@
1212
export type AggregationOption = {
1313
label: string;
1414
};
15+
16+
export type ImputationOption = {
17+
method: ImputationMethod;
18+
defaultFill?: Array<{ featureName: string; data: number }>;
19+
};
20+
21+
export enum ImputationMethod {
22+
/**
23+
* 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.
24+
*/
25+
ZERO = 'ZERO',
26+
/**
27+
* 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.
28+
*/
29+
FIXED_VALUES = 'FIXED_VALUES',
30+
/**
31+
* 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.
32+
*/
33+
PREVIOUS = 'PREVIOUS',
34+
}
35+

public/pages/ConfigureModel/components/AdvancedSettings/AdvancedSettings.tsx

+151-32
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ import {
1717
EuiTitle,
1818
EuiFieldNumber,
1919
EuiSpacer,
20+
EuiSelect,
21+
EuiButtonIcon,
22+
EuiFieldText,
2023
} from '@elastic/eui';
21-
import { Field, FieldProps } from 'formik';
22-
import React, { useState } from 'react';
24+
import { Field, FieldProps, FieldArray, } from 'formik';
25+
import React, { useEffect, useState } from 'react';
2326
import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
2427
import { BASE_DOCS_LINK } from '../../../../utils/constants';
2528
import {
@@ -28,13 +31,22 @@ import {
2831
validatePositiveInteger,
2932
} from '../../../../utils/utils';
3033
import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow';
34+
import { SparseDataOptionValue } from '../../utils/constants';
3135

3236
interface AdvancedSettingsProps {}
3337

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

42+
// Options for the sparse data handling dropdown
43+
const sparseDataOptions = [
44+
{ value: SparseDataOptionValue.IGNORE, text: 'Ignore missing value' },
45+
{ value: SparseDataOptionValue.PREVIOUS_VALUE, text: 'Previous value' },
46+
{ value: SparseDataOptionValue.SET_TO_ZERO, text: 'Set to zero' },
47+
{ value: SparseDataOptionValue.CUSTOM_VALUE, text: 'Custom value' },
48+
];
49+
3850
return (
3951
<ContentPanel
4052
title={
@@ -58,41 +70,148 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
5870
>
5971
{showAdvancedSettings ? <EuiSpacer size="m" /> : null}
6072
{showAdvancedSettings ? (
61-
<Field name="shingleSize" validate={validatePositiveInteger}>
62-
{({ field, form }: FieldProps) => (
63-
<FormattedFormRow
64-
title="Shingle size"
65-
hint={[
66-
`Set the number of intervals to consider in a detection
73+
<>
74+
<Field name="shingleSize" validate={validatePositiveInteger}>
75+
{({ field, form }: FieldProps) => (
76+
<FormattedFormRow
77+
title="Shingle size"
78+
hint={[
79+
`Set the number of intervals to consider in a detection
6780
window for your model. The anomaly detector expects the
68-
shingle size to be in the range of 1 and 60. The default
81+
shingle size to be in the range of 1 and 128. The default
6982
shingle size is 8. We recommend that you don’t choose 1
7083
unless you have two or more features. Smaller values might
7184
increase recall but also false positives. Larger values
7285
might be useful for ignoring noise in a signal.`,
73-
]}
74-
hintLink={`${BASE_DOCS_LINK}/ad`}
75-
isInvalid={isInvalid(field.name, form)}
76-
error={getError(field.name, form)}
77-
>
78-
<EuiFlexGroup gutterSize="s" alignItems="center">
79-
<EuiFlexItem grow={false}>
80-
<EuiFieldNumber
81-
id="shingleSize"
82-
placeholder="Shingle size"
83-
data-test-subj="shingleSize"
84-
{...field}
85-
/>
86-
</EuiFlexItem>
87-
<EuiFlexItem>
88-
<EuiText>
89-
<p className="minutes">intervals</p>
90-
</EuiText>
91-
</EuiFlexItem>
92-
</EuiFlexGroup>
93-
</FormattedFormRow>
94-
)}
95-
</Field>
86+
]}
87+
hintLink={`${BASE_DOCS_LINK}/ad`}
88+
isInvalid={isInvalid(field.name, form)}
89+
error={getError(field.name, form)}
90+
>
91+
<EuiFlexGroup gutterSize="s" alignItems="center">
92+
<EuiFlexItem grow={false}>
93+
<EuiFieldNumber
94+
id="shingleSize"
95+
placeholder="Shingle size"
96+
data-test-subj="shingleSize"
97+
{...field}
98+
/>
99+
</EuiFlexItem>
100+
<EuiFlexItem>
101+
<EuiText>
102+
<p className="minutes">intervals</p>
103+
</EuiText>
104+
</EuiFlexItem>
105+
</EuiFlexGroup>
106+
</FormattedFormRow>
107+
)}
108+
</Field>
109+
110+
<Field
111+
name="imputationOption.imputationMethod"
112+
id="imputationOption.imputationMethod"
113+
>
114+
{({ field, form }: FieldProps) => {
115+
// Add an empty row if CUSTOM_VALUE is selected and no rows exist
116+
useEffect(() => {
117+
if (
118+
field.value === SparseDataOptionValue.CUSTOM_VALUE &&
119+
(!form.values.imputationOption?.custom_value ||
120+
form.values.imputationOption.custom_value.length === 0)
121+
) {
122+
form.setFieldValue('imputationOption.custom_value', [
123+
{ featureName: '', value: undefined },
124+
]);
125+
}
126+
}, [field.value, form]);
127+
128+
return (
129+
<>
130+
<FormattedFormRow
131+
title="Sparse data handling"
132+
hint={[`Choose how to handle missing data points.`]}
133+
hintLink={`${BASE_DOCS_LINK}/ad`}
134+
isInvalid={isInvalid(field.name, form)}
135+
error={getError(field.name, form)}
136+
>
137+
<EuiSelect {...field} options={sparseDataOptions}/>
138+
</FormattedFormRow>
139+
140+
{/* Conditionally render the "Custom value" title and the input fields when 'Custom value' is selected */}
141+
{field.value === SparseDataOptionValue.CUSTOM_VALUE && (
142+
<>
143+
<EuiSpacer size="m" />
144+
<EuiText size="xs">
145+
<h5>Custom value</h5>
146+
</EuiText>
147+
<EuiSpacer size="s" />
148+
<FieldArray name="imputationOption.custom_value">
149+
{(arrayHelpers) => (
150+
<>
151+
{form.values.imputationOption.custom_value?.map((_, index) => (
152+
<EuiFlexGroup
153+
key={index}
154+
gutterSize="s"
155+
alignItems="center"
156+
>
157+
<EuiFlexItem grow={false}>
158+
<Field
159+
name={`imputationOption.custom_value.${index}.featureName`}
160+
id={`imputationOption.custom_value.${index}.featureName`}
161+
>
162+
{({ field }: FieldProps) => (
163+
<EuiFieldText
164+
placeholder="Feature name"
165+
{...field}
166+
/>
167+
)}
168+
</Field>
169+
</EuiFlexItem>
170+
<EuiFlexItem grow={false}>
171+
<Field
172+
name={`imputationOption.custom_value.${index}.data`}
173+
id={`imputationOption.custom_value.${index}.data`}
174+
>
175+
{/* the value is set to field.value || '' to avoid displaying 0 as a default value. */ }
176+
{({ field, form }: FieldProps) => (
177+
<EuiFieldNumber
178+
fullWidth
179+
placeholder="Custom value"
180+
{...field}
181+
value={field.value || ''}
182+
/>
183+
)}
184+
</Field>
185+
</EuiFlexItem>
186+
<EuiFlexItem grow={false}>
187+
<EuiButtonIcon
188+
iconType="trash"
189+
color="danger"
190+
aria-label="Delete row"
191+
onClick={() => arrayHelpers.remove(index)}
192+
/>
193+
</EuiFlexItem>
194+
</EuiFlexGroup>
195+
))}
196+
<EuiSpacer size="s" />
197+
{ /* add new rows with empty values when the add button is clicked. */}
198+
<EuiButtonIcon
199+
iconType="plusInCircle"
200+
onClick={() =>
201+
arrayHelpers.push({ featureName: '', value: 0 })
202+
}
203+
aria-label="Add row"
204+
/>
205+
</>
206+
)}
207+
</FieldArray>
208+
</>
209+
)}
210+
</>
211+
);
212+
}}
213+
</Field>
214+
</>
96215
) : null}
97216
</ContentPanel>
98217
);

public/pages/ConfigureModel/containers/ConfigureModel.tsx

+62-2
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ import {
4040
focusOnFirstWrongFeature,
4141
getCategoryFields,
4242
focusOnCategoryField,
43-
getShingleSizeFromObject,
4443
modelConfigurationToFormik,
44+
focusOnImputationOption,
4545
} from '../utils/helpers';
4646
import { formikToDetector } from '../../ReviewAndCreate/utils/helpers';
4747
import { formikToModelConfiguration } from '../utils/helpers';
@@ -54,7 +54,7 @@ import { CoreServicesContext } from '../../../components/CoreServices/CoreServic
5454
import { Detector } from '../../../models/interfaces';
5555
import { prettifyErrorMessage } from '../../../../server/utils/helpers';
5656
import { DetectorDefinitionFormikValues } from '../../DefineDetector/models/interfaces';
57-
import { ModelConfigurationFormikValues } from '../models/interfaces';
57+
import { ModelConfigurationFormikValues, FeaturesFormikValues } from '../models/interfaces';
5858
import { CreateDetectorFormikValues } from '../../CreateDetectorSteps/models/interfaces';
5959
import { DETECTOR_STATE } from '../../../../server/utils/constants';
6060
import { getErrorMessage } from '../../../utils/utils';
@@ -69,6 +69,7 @@ import {
6969
getSavedObjectsClient,
7070
} from '../../../services';
7171
import { DataSourceViewConfig } from '../../../../../../src/plugins/data_source_management/public';
72+
import { SparseDataOptionValue } from '../utils/constants';
7273

7374
interface ConfigureModelRouterProps {
7475
detectorId?: string;
@@ -174,6 +175,49 @@ export function ConfigureModel(props: ConfigureModelProps) {
174175
}
175176
}, [hasError]);
176177

178+
const validateImputationOption = (
179+
formikValues: ModelConfigurationFormikValues,
180+
errors: any
181+
) => {
182+
const imputationOption = get(formikValues, 'imputationOption', null);
183+
184+
// Initialize an array to hold individual error messages
185+
const customValueErrors: string[] = [];
186+
187+
// Validate imputationOption when method is CUSTOM_VALUE
188+
if (imputationOption && imputationOption.imputationMethod === SparseDataOptionValue.CUSTOM_VALUE) {
189+
const enabledFeatures = formikValues.featureList.filter(
190+
(feature: FeaturesFormikValues) => feature.featureEnabled
191+
);
192+
193+
// Validate that the number of custom values matches the number of enabled features
194+
if ((imputationOption.custom_value || []).length !== enabledFeatures.length) {
195+
customValueErrors.push(
196+
`The number of custom values (${(imputationOption.custom_value || []).length}) does not match the number of enabled features (${enabledFeatures.length}).`
197+
);
198+
}
199+
200+
// Validate that each enabled feature has a corresponding custom value
201+
const missingFeatures = enabledFeatures
202+
.map((feature: FeaturesFormikValues) => feature.featureName)
203+
.filter(
204+
(name: string | undefined) =>
205+
!imputationOption.custom_value?.some((cv) => cv.featureName === name)
206+
);
207+
208+
if (missingFeatures.length > 0) {
209+
customValueErrors.push(
210+
`The following enabled features are missing in custom values: ${missingFeatures.join(', ')}.`
211+
);
212+
}
213+
214+
// If there are any custom value errors, join them into a single string with proper formatting
215+
if (customValueErrors.length > 0) {
216+
errors.custom_value = customValueErrors.join(' ');
217+
}
218+
}
219+
};
220+
177221
const handleFormValidation = async (
178222
formikProps: FormikProps<ModelConfigurationFormikValues>
179223
) => {
@@ -186,7 +230,12 @@ export function ConfigureModel(props: ConfigureModelProps) {
186230
formikProps.setFieldTouched('featureList');
187231
formikProps.setFieldTouched('categoryField', isHCDetector);
188232
formikProps.setFieldTouched('shingleSize');
233+
formikProps.setFieldTouched('imputationOption');
234+
189235
formikProps.validateForm().then((errors) => {
236+
// Call the extracted validation method
237+
validateImputationOption(formikProps.values, errors);
238+
190239
if (isEmpty(errors)) {
191240
if (props.isEdit) {
192241
// TODO: possibly add logic to also start RT and/or historical from here. Need to think
@@ -205,11 +254,22 @@ export function ConfigureModel(props: ConfigureModelProps) {
205254
props.setStep(3);
206255
}
207256
} else {
257+
const customValueError = get(errors, 'custom_value')
258+
if (customValueError) {
259+
core.notifications.toasts.addDanger(
260+
customValueError
261+
);
262+
focusOnImputationOption();
263+
return;
264+
}
265+
208266
// TODO: can add focus to all components or possibly customize error message too
209267
if (get(errors, 'featureList')) {
210268
focusOnFirstWrongFeature(errors, formikProps.setFieldTouched);
211269
} else if (get(errors, 'categoryField')) {
212270
focusOnCategoryField();
271+
} else {
272+
console.log(`unexpected error ${JSON.stringify(errors)}`);
213273
}
214274

215275
core.notifications.toasts.addDanger(

public/pages/ConfigureModel/models/interfaces.ts

+11
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface ModelConfigurationFormikValues {
1818
categoryFieldEnabled: boolean;
1919
categoryField: string[];
2020
shingleSize: number;
21+
imputationOption?: ImputationFormikValues;
2122
}
2223

2324
export interface FeaturesFormikValues {
@@ -30,3 +31,13 @@ export interface FeaturesFormikValues {
3031
aggregationOf?: AggregationOption[];
3132
newFeature?: boolean;
3233
}
34+
35+
export interface ImputationFormikValues {
36+
imputationMethod?: string;
37+
custom_value?: CustomValueFormikValues[];
38+
}
39+
40+
export interface CustomValueFormikValues {
41+
featureName: string;
42+
data: number;
43+
}

0 commit comments

Comments
 (0)