Skip to content
Open
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
4 changes: 4 additions & 0 deletions jest.config.ts → packages/scenes-ml/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ module.exports = {
moduleNameMapper: {
// D3 exposes ESModules. To avoid issues with Jest, we need to point them to the CJS version.
d3: '<rootDir>/../../node_modules/d3/dist/d3.min.js',
'@bsull/augurs/mstl': '<rootDir>/utils/mocks/augurs/mstl.ts',
'@bsull/augurs/outlier': '<rootDir>/utils/mocks/augurs/outlier.ts',
'@bsull/augurs/prophet': '<rootDir>/utils/mocks/augurs/prophet.ts',
'@bsull/augurs/seasons': '<rootDir>/utils/mocks/augurs/seasons.ts',
},
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./utils/setupTests.ts'],
Expand Down
56 changes: 43 additions & 13 deletions packages/scenes-ml/src/components/SceneBaseliner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ type ModelType = 'prophet' | 'ets';
type AnomalyDirection = 'upper' | 'lower';

// An anomaly detected by the baseliner.
interface Anomaly {
export interface Anomaly {
// The direction of the anomaly.
direction: AnomalyDirection;
// The index of the anomaly.
Expand Down Expand Up @@ -377,6 +377,40 @@ export interface AugursPredictionTransformationOptions {
lookBackFactor?: number;
}

// Detect anomalies by comparing original data values against confidence intervals.
// This function correctly compares original data against bounds to detect anomalies.
export function detectAnomalies(
originalValues: Array<number | null>,
modelValues: number[],
times: number[],
lower: number[] | undefined,
upper: number[] | undefined,
field: Field,
onAnomalyDetected?: (anomaly: Anomaly) => void
): void {
if (!onAnomalyDetected || !lower || !upper || lower.length === 0 || upper.length === 0) {
return;
}

// FIXED: Now correctly compares original values against bounds
for (let idx = 0; idx < originalValues.length; idx++) {
const value = originalValues[idx];

// Skip null values (out-of-sample predictions where we don't have original data)
if (value === null || value === undefined) {
continue;
}

const lowerValue = lower[idx];
const upperValue = upper[idx];
if (value < lowerValue) {
onAnomalyDetected({ direction: 'lower', idx, time: times[idx], field });
} else if (value > upperValue) {
onAnomalyDetected({ direction: 'upper', idx, time: times[idx], field });
}
}
}

function createBaselinesForFrame(
modelType: ModelType,
frame: DataFrame,
Expand Down Expand Up @@ -429,6 +463,9 @@ function createBaselinesForFrame(
let totalSteps = inSampleRange / freq + 1;
let times = createTimes(totalSteps, freq, timeField.values.at(0));

// Create aligned original values that match the model output times
let alignedOriginalValues: Array<number | null> = Array.from(y);

// If we've been given a time range, we can filter our in-sample
// predictions to only include data within that range. If the range
// extends beyond the end of the data, we can also add out-of-sample
Expand All @@ -453,6 +490,7 @@ function createBaselinesForFrame(
values = values.slice(fromIdx, toIdx);
lower = lower ? lower.slice(fromIdx, toIdx) : undefined;
upper = upper ? upper.slice(fromIdx, toIdx) : undefined;
alignedOriginalValues = alignedOriginalValues.slice(fromIdx, toIdx);

// Add out-of-sample predictions.
if (outOfSampleSteps > 0) {
Expand All @@ -466,21 +504,13 @@ function createBaselinesForFrame(
lower = lower.concat(Array.from(outOfSample.lower));
upper = upper.concat(Array.from(outOfSample.upper));
}
// Extend aligned original values with nulls for out-of-sample predictions
alignedOriginalValues = alignedOriginalValues.concat(new Array<null>(outOfSampleSteps).fill(null));
}
}

if (onAnomalyDetected !== undefined && lower && upper) {
for (let idx = 0; idx < values.length; idx++) {
const value = values[idx];
const lowerValue = lower[idx];
const upperValue = upper[idx];
if (value < lowerValue) {
onAnomalyDetected({ direction: 'lower', idx, time: times[idx], field: numField });
} else if (value > upperValue) {
onAnomalyDetected({ direction: 'upper', idx, time: times[idx], field: numField });
}
}
}
// Detect anomalies using the extracted function
detectAnomalies(alignedOriginalValues, values, times, lower!, upper!, numField, onAnomalyDetected);

const name = numField.config.displayNameFromDS ?? frame.name ?? numField.name;
const fields = createFields(name, timeField, times, values, lower, upper);
Expand Down
Loading