Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Missing Value Imputation Options and Update Shingle Size Limit #851

Merged
merged 2 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading