Skip to content

Finding anomalies ​

Is today's number unusual? Fit a model on earlier data, then ask detectAnomalies how new measurements compare with the range the model expected for them. Values outside the range are anomalies. To look back over data you already have instead, see Looking back over a history.

ts
import { Effect } from "effect";
import { detectAnomalies, fit, prophetFittingBackendLayer } from "effect-prophet";

const date = (day: number) => new Date(Date.UTC(2025, 0, 1 + day)).toISOString();

const sales = (day: number) =>
  100 + day * 0.5 + 10 * Math.sin((2 * Math.PI * day) / 7) + 4 * Math.sin(day * 12.9898);

// Train on the first 90 days.
const history = Array.from({ length: 90 }, (_, day) => ({
  timestamp: date(day),
  value: sales(day),
}));

// Then check the next week's measurements: an outage on day 3 and a spike on day 6.
const incidents = new Map([
  [2, -40],
  [5, 30],
]);

const thisWeek = Array.from({ length: 7 }, (_, offset) => ({
  timestamp: date(90 + offset),
  value: sales(90 + offset) + (incidents.get(offset) ?? 0),
}));

const report = await Effect.runPromise(
  Effect.gen(function* () {
    const model = yield* fit(history);

    return yield* detectAnomalies(model, thisWeek, { seed: 42 });
  }).pipe(Effect.provide(prophetFittingBackendLayer)),
);

for (const row of report.rows) {
  console.log(
    new Date(row.timestamp).toISOString().slice(0, 10),
    `${row.actual.toFixed(1)}  (99% range ${row.lower.toFixed(1)} to ${row.upper.toFixed(1)})`,
    row.position,
  );
}

console.log(`${report.anomalies.length} of ${report.rows.length} days outside the range`);
txt
2025-04-01 138.8  (99% range 129.7 to 144.0) within
2025-04-02 148.5  (99% range 137.8 to 151.5) within
2025-04-03 117.6  (99% range 146.1 to 159.6) below
2025-04-04 160.2  (99% range 147.9 to 162.0) within
2025-04-05 154.8  (99% range 142.8 to 158.0) within
2025-04-06 175.5  (99% range 135.6 to 149.7) above
2025-04-07 139.0  (99% range 131.2 to 145.2) within
2 of 7 days outside the range

The model is never refitted, so the measurements you check can't pull the baseline toward themselves. That makes this the right tool for monitoring: train on last quarter, check this week.

What you get back ​

rows has one entry per measurement, sorted by date. Measurements with the same timestamp keep the order you passed them in. anomalies holds just the rows outside their range.

FieldMeaning
actualThe value you measured
predictedThe model's forecast for that moment, exactly as predict returns it
lowerBottom of the range
upperTop of the range
position"below", "within" or "above". A value exactly on a bound is within

Each measurement needs whatever the model needs to forecast it: a capacity for growth with a ceiling, regressor values, and conditions for conditional seasonalities. They use the same format as your history, so you can pass rows straight from the same source.

Options ​

detectAnomalies and anomalies take the same options as uncertainty ranges, except that the range defaults to 99% wide:

OptionDefaultMeaning
seedrequiredAny whole number. The same seed always gives the same ranges.
samples1000How many futures to simulate (up to 2,048)
intervalWidth0.99How wide the range is. A narrower range flags more measurements

output: "samples" is rejected with an UncertaintyOptionsError whose reason is unsupported-output. One call takes up to 10,000 measurements; past that, or past the forecast-length limit, it fails with a PredictionError whose reason is simulation-limit.

Looking back over a history ​

anomalies fits a model to a history, checks that same history against it, and returns the timestamps outside their range:

ts
import { Effect } from "effect";
import { anomalies, prophetFittingBackendLayer } from "effect-prophet";

const date = (day: number) => new Date(Date.UTC(2025, 0, 1 + day)).toISOString();

// An outage on day 40 and a spike on day 70.
const incidents = new Map([
  [40, -40],
  [70, 30],
]);

const history = Array.from({ length: 90 }, (_, day) => ({
  timestamp: date(day),
  value:
    100 +
    day * 0.5 +
    10 * Math.sin((2 * Math.PI * day) / 7) +
    4 * Math.sin(day * 12.9898) +
    (incidents.get(day) ?? 0),
}));

// Fit the whole history, then flag the days outside their own 99% range.
const flagged = await Effect.runPromise(
  anomalies(history, { seed: 42 }).pipe(Effect.provide(prophetFittingBackendLayer)),
);

for (const timestamp of flagged) {
  console.log(new Date(timestamp).toISOString().slice(0, 10));
}
txt
2025-02-10
2025-03-12

Timestamps are epoch milliseconds, in date order. Each flagged row adds its timestamp once, so if two rows share a timestamp and both are outside, it appears twice. Model options go in an optional third argument, exactly as fit takes them:

ts
anomalies(history, { seed: 42, intervalWidth: 0.95 }, { growth: "logistic" });

The whole history is simulated in one call, so anomalies takes up to 10,000 rows. A longer history fails with a PredictionError whose reason is simulation-limit, before anything is fitted. To check one anyway, fit it with fit and pass it to detectAnomalies in batches of up to 10,000 rows.

Which one to use ​

detectAnomaliesanomalies
ModelOne you fitted earlierFitted from the history you pass
ChecksAny measurements, usually later onesThe history it was fitted on
ReturnsEvery row, with its rangeTimestamps of the flagged rows
Needs a fitting backendNoYes

Use detectAnomalies for monitoring, where today is judged against what came before. Use anomalies for a quick look back over a history, for example to find days to clean up before fitting a forecast.

Things to know ​

  • With anomalies, outliers can hide themselves. The flagged days also shape the model. A large outlier pulls the trend toward itself and widens every range, so smaller outliers can go unflagged. Two big outliers in an otherwise quiet history can widen the ranges so much that even a 50% range flags nothing else. If a day matters, judge it with detectAnomalies against a model fitted without it.
  • A 99% range doesn't mean 1% false alarms. The ranges come from simulated trend changes and noise, and don't account for uncertainty in the seasonal patterns. A model that fits its history too closely gives narrow ranges and flags ordinary values. Check a few weeks of known normal data before alerting on the results.
  • You decide what the model has seen. detectAnomalies can't tell whether a measurement was part of the training data. If it was, the model has already partly adjusted to it.
  • No scores or probabilities. Each measurement is inside or outside its range. How far outside is actual minus upper (or lower minus actual), in your data's own units. anomalies returns only timestamps; use detectAnomalies to see the ranges.

Inspired by Prophet from Meta. Not affiliated with Meta.