learn-anomaly-detection-with-phoebe / Builder session 7 of 10
Learn Anomaly Detection with Phoebe · Builder track · Session 7 of 10

Time-series anomalies: when the series itself shifts

So far we have hunted points that stand out from their neighbours. Tonight the anomaly is the series changing character - a level that steps up and stays up, a variance that widens, a drift that a point detector never notices because no single day looks wrong. We build a CUSUM detector from scratch to accumulate those small persistent deviations, then a forecast-residual detector that predicts the next value and flags when reality lands far outside the band. This is the direct handoff from the forecasting course: its monitoring session ends where this one begins.

🟠 Builder track Practitioners: analysts · data scientists · engineers Python 3.10+ · a notebook ~45 min
0-3 · Welcome 3-16 · Change-points + CUSUM 16-42 · Build-along: CUSUM + forecast residual 42-45 · Q&A
Part 0

Point anomaly vs change-point

A point anomaly is a transient - one day spikes, then the series returns to normal. A change-point is a sustained shift - the level, trend, or variance moves to a new regime and stays there. That difference matters: a z-score or isolation forest scores each day against a fixed picture of "normal," so once the new regime becomes the majority, the detector quietly accepts it. Time-series detectors watch the series evolve instead, and that is what catches the drift a point detector sleeps through.

Live - presented in session Self-study - read after class ★ Run it now (Python) Sources covered (cited)
★ What you walk out with today A from-scratch tabular CUSUM detector that accumulates standardized deviations and alarms when the running sum crosses a threshold - catching the sustained collective run a point detector misses - and a forecast-residual detector that predicts each day one step ahead (seasonal-naive) and flags any actual outside a robust residual band. Both scored with precision and recall against the labeled Lumen series.
Part 1 · CUSUM & change detection

Accumulate what a point detector misses 6 min live

CUSUM - the cumulative sum control chart (Page, 1954) - keeps a running total of how far each point sits above (or below) an expected level, minus a slack. A single small deviation barely moves it; a persistent drift piles up until the sum crosses a threshold and alarms. That is exactly the anomaly a per-point z-score cannot see: no one day is extreme, but together they have walked the series to a new level.

Same series, two very different anomalies Point anomaly one day spikes, then normal again - transient Change-point level steps up and stays - sustained shift A point detector accepts the new level once it becomes the majority. CUSUM sees the sustained drift and alarms.
🔍 Click to zoom - a transient point anomaly versus a sustained change-point
LiveHow tabular CUSUM works3 min▶

Work on a standardized residual (deseasonalize first, then scale robustly), then keep two running sums:

  • High side - S⁺ = max(0, S⁺ + x - k). It rewards deviations above a slack k and floors at zero, so noise cannot build it up.
  • Low side - S⁻ = min(0, S⁻ + x + k). The mirror image for drops.
  • Alarm - when S⁺ crosses a threshold h (or S⁻ crosses -h). Reset to zero and keep scanning.

The slack k (often 0.5 sigma) sets how big a shift you care about; the threshold h trades detection speed against false alarms - the same precision/recall dial as always.

Real world

Reach for a library when it gets serious. ruptures (Truong et al.) offers Pelt, binary segmentation, and window-based change-point detection over multiple cost models, and Basseville & Nikiforov (1993) is the reference text for the theory. Build CUSUM once by hand to own the intuition, then lean on the library for multi-regime, multivariate work.

Part 2 · forecast-residual detection

Predict the next value, flag the surprise 5 min live

The forecasting course ended by monitoring its own predictions - and that is a detector. Forecast each day one step ahead (seasonal-naive, or Holt-Winters from that course), then look at the residual: actual minus forecast. If the model already captures trend and weekly rhythm, the residuals are small and structureless - so a point that lands far outside their band is a genuine surprise, context already accounted for.

Forecast + prediction band; the actual outside the band is flagged prediction band forecast actual (in band) actual outside band = anomaly
🔍 Click to zoom - forecast-residual detection with a robust prediction band
Self-studyWhy residuals, not raw values2 min read▶

Flagging raw values drowns you in false alarms: every weekend low and every trend high looks "extreme" against a flat mean. Residuals fix that by folding the known structure into the forecast:

  • Trend and seasonality live in the forecast, so the residual is what the model could not explain.
  • Set the band on a robust scale of the residuals - the median and MAD, not the mean and standard deviation - so a few big anomalies do not inflate the very band meant to catch them (the same trap as the z-score in b1).
  • This is why a contextual anomaly - a weekday sitting at a weekend level - shows up here: it is normal in value but far from what the same weekday forecast expected.
Demo 1 of 2

CUSUM from scratch ★ 13 min · everyone builds

Deseasonalize the Lumen series, standardize the residual robustly, then run a tabular CUSUM by hand. It accumulates the sustained collective run that the per-point z-score in b1 was blind to.

Rebuild the labeled Lumen series (from b1) so the notebook runs standalone.

Remove the weekly context (subtract the weekday median) and standardize with median / MAD.

Walk the tabular CUSUM: two running sums, a slack k, a threshold h, reset on alarm.

Score precision, recall, and F1 against truth - note it catches the sustained run.

★ Run it now (Python) - tabular CUSUM by handimport pandas as pd, numpy as np # --- rebuild the labeled Lumen series (from b1) --- idx = pd.date_range("2026-01-01", periods=90, freq="D") season = np.array([30,42,48,40,22,-95,-110]) i = np.arange(90) y = 900 + 1.2*i + season[i % 7] + 10*np.sin(i*1.7) + 7*np.cos(i*0.6) truth = [20, 45, 63, 70, 71, 72] y[20] += 430; y[45] -= 380 y[63] = y[63] - season[63 % 7] + season[6] - 40 y[[70,71,72]] += [175,190,170] s = pd.Series(np.round(y), index=idx, name="sessions") # --- deseasonalize (weekday median) + robust standardize --- resid = s - s.groupby(s.index.dayofweek).transform("median") med = resid.median(); mad = 1.4826 * (resid - med).abs().median() r = (resid - med) / mad # --- tabular CUSUM: accumulate deviations beyond slack k, alarm at h --- k, h = 0.5, 4.0 sh = sl = 0.0; hits = [] for t, x in enumerate(r): sh = max(0.0, sh + x - k) # high side sl = min(0.0, sl + x + k) # low side if sh > h or sl < -h: hits.append(t); sh = sl = 0.0 # alarm, then reset flags = set(hits) tp = len(flags & set(truth)); fp = len(flags - set(truth)); fn = len(set(truth) - flags) prec = tp/(tp+fp) if tp+fp else 0; rec = tp/(tp+fn) if tp+fn else 0 f1 = 2*prec*rec/(prec+rec) if prec+rec else 0 print(f"CUSUM caught {tp}/{len(truth)} precision {prec:.2f} recall {rec:.2f} F1 {f1:.2f}") # the accumulation catches the sustained run (70-72) a per-point z-score walks past
CUSUM is tuned by k and h, not by luck Raise h and you get fewer, later, higher-confidence alarms; lower it and you catch smaller shifts sooner at the cost of false alarms. That is the precision/recall trade in two knobs - set it to the cost of a missed shift versus the cost of a muted analyst.
Demo 2 of 2

Forecast-residual detector ★ 9 min · build your own

Forecast each day one step ahead with a seasonal-naive model (today looks like the same weekday a week ago), then flag any actual whose residual falls outside a robust band. This is the forecasting course's monitoring loop turned into a detector - and because the forecast already knows the weekly context, it catches the contextual dip too.

Forecast one step ahead: s.shift(7) is the seasonal-naive prediction.

Compute residuals and a robust band (median ± a MAD multiple).

Flag actuals outside the band and score precision, recall, and F1 against truth.

★ Run it now (Python) - seasonal-naive residual detectorimport numpy as np # seasonal-naive one-step forecast: same weekday, one week back fc = s.shift(7) resid = (s - fc).dropna() # robust residual band (median / MAD - not mean / std) m = resid.median(); mad = 1.4826 * (resid - m).abs().median() band = 3.5 * mad pos = np.where(np.abs(resid - m) > band)[0] + 7 # +7 undoes the shift offset flags = set(int(p) for p in pos) tp = len(flags & set(truth)); fp = len(flags - set(truth)); fn = len(set(truth) - flags) prec = tp/(tp+fp) if tp+fp else 0; rec = tp/(tp+fn) if tp+fn else 0 f1 = 2*prec*rec/(prec+rec) if prec+rec else 0 print(f"residual caught {tp}/{len(truth)} precision {prec:.2f} recall {rec:.2f} F1 {f1:.2f}") # for a stronger forecast, swap in statsmodels ExponentialSmoothing (Holt-Winters) # from statsmodels.tsa.holtwinters import ExponentialSmoothing
Real world

This is the pattern most production monitoring actually uses. Forecast the metric, band the residual, alert on the breach - it is interpretable ("we expected 1,050 sessions, got 1,480"), it deseasonalizes for free, and it degrades gracefully when the model is wrong. The catch: a forecast-residual detector is only as honest as its band. Fit the band on a clean stretch of history, refresh it as the series drifts, and never let the anomalies you are hunting set the width.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Sources covered (cited)

Taught from the change-detection canon, handed off from the forecasting course. This session covers:

✓Page (1954), "Continuous Inspection Schemes," Biometrika 41 - the CUSUM chartDemo 1 · cumulative-sum detection of a persistent shift
✓Basseville & Nikiforov (1993), Detection of Abrupt ChangesPart 1 · change-point theory; ruptures as the practical library
◐learn-timeseries-forecasting-with-phoebe · monitoring sessionPart 2 · seasonal-naive / Holt-Winters forecast, residual band
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What separates a change-point from a point anomaly?

The point anomaly is one-off and the series recovers; the change-point moves the level, trend, or variance and stays there - which a fixed-picture point detector eventually accepts.

2 · Why does CUSUM catch drifts a per-point z-score misses?

CUSUM keeps a running sum. Individually harmless deviations in the same direction add up; a point detector, scoring each day alone, never sees the accumulation.

3 · In a forecast-residual detector, why set the band with median and MAD?

Mean and standard deviation are dragged upward by the outliers you are hunting, widening the band until it misses them. Median and MAD resist that - the same lesson as the z-score trap in b1.

Builder session 7 cheat sheet · pin this

Point vs change-pointtransient spike that recovers vs a sustained shift to a new regime.
CUSUMrunning sum of deviations minus slack k; alarm when it crosses h. Page (1954).
Why CUSUM winsaccumulates persistent drift no single point makes extreme.
k and hk = smallest shift you care about; h = precision/recall dial for speed vs false alarms.
ruptureslibrary for change-point detection (Pelt, binseg, window) when it gets serious.
Forecast-residualpredict next value (seasonal-naive / Holt-Winters), flag actuals outside a robust band.
Robust bandmedian ± MAD multiple, not mean ± std - anomalies must not set their own band.
Next sessionb8 - deep anomaly detection: reconstruction error, autoencoders (a cited map).