Time Series and Forecasting

Anomaly detection

Predict what the next value should have been, then raise an alarm when the real value is too far from it — because an anomaly is a surprise, not a large number.

On this page 10
  1. The short answer
  2. The analogy you have already lived
  3. Why "big number" is the wrong definition
  4. How it actually works
  5. The three shapes an anomaly takes
  6. The trap that hides real problems
  7. Where you have already seen it
  8. What is honestly hard here
  9. Remember this
  10. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

An anomaly is a value that surprises you. To find one, work out what you expected, then measure how far off reality was.

The analogy you have already lived

Sit under your ceiling fan for a minute.

You do not hear it. The hum has been there for years, and your brain filtered it out long ago. Then one day a small rattle starts, and you notice it instantly, from another room.

The rattle is not louder than the hum. It is unexpected, and that is a completely different thing.

Anomaly detection in a time series works the same way. You subtract everything you already expected: the usual level, the weekend rise, the festival bump. Then you listen to what is left over.

Why "big number" is the wrong definition

The most common first attempt is to flag anything far from the overall average. It fails badly, and here is why.

Suppose your site gets 3,000 visits on weekdays and 3,700 on weekends. On a Saturday, 3,700 is normal. On a Tuesday, 3,700 would be remarkable.

The number is identical. Only the context changed.

A method that looks only at size will flag every single weekend and miss the strange Tuesday completely. The developer section shows exactly that: one method raised sixty-six alarms, and sixty-three of them were ordinary weekends.

How it actually works

   what really happened     :  3,700 visits on a Tuesday
   what you expected        :  3,000 visits
   -----------------------------------------------------
   the leftover             :    700

   how big is a normal leftover?  about 90
   so this leftover is about eight times the usual size
                                        -> raise an alarm

Three steps, every time.

  1. Build an expectation. Use the trend and the repeating pattern from trend, seasonality and noise, or a forecast from any model.
  2. Subtract it. What remains is the leftover, also called the residual.
  3. Judge the leftover against its usual size. Big compared to the usual wobble means anomaly.

The three shapes an anomaly takes

A single odd point. One day the number spikes. A viral post, a data-entry slip, a payment gateway failure.

An odd point for its context. The value itself is ordinary, but not for that day or that hour. Full traffic at 3 a.m. is the classic case.

A run of odd points together. No single day is extreme, but seven days in a row sit slightly low. This is often the most important kind and the hardest to catch, because each day on its own looks acceptable.

The trap that hides real problems

A group of anomalies can hide each other. This is called masking, and it catches almost everyone.

Your threshold depends on the usual size of a leftover. Suppose a fault produces five bad readings instead of one. Those five inflate your idea of "usual". Then none of them looks unusual any more.

The fix is to measure the usual size in a way that ignores extremes. Instead of the average, use the middle value. Instead of the standard spread, use the middle distance from the middle value. Those two are barely affected by a handful of wild readings.

The developer section shows the failure and the fix side by side. Five bad readings drag the ordinary alarm score from 7.7 down to 3.5, below a typical threshold. The robust version stays above 70.

Where you have already seen it

  • A bank freezing a card after a spend that does not fit your pattern.
  • UPI apps asking you to confirm an unusually large transfer.
  • Your electricity board flagging a meter whose reading dropped to nothing.
  • A phone alerting you to unusual battery drain.
  • Server dashboards paging an engineer when traffic dies at 2 a.m.

What is honestly hard here

Two things, and they are why this is genuinely difficult work rather than a formula.

Real anomalies are extremely rare. Perhaps one day in a thousand. Even a detector that is right ninety-nine percent of the time will drown you in false alarms. There are so many more normal days than strange ones. When every alert is noise, people stop reading alerts, and then a real one arrives and nobody looks.

Nobody agrees what counts as an anomaly. Was the Diwali spike an anomaly or an expected festival? It depends entirely on what you are going to do about it. You cannot answer that question with statistics; you answer it by deciding what action an alarm should trigger.

Set the threshold from the cost of a missed alarm against the cost of a false one. There is no correct value in the abstract.

Remember this

  • Subtract what you expected, then judge what is left over.
  • Context beats size: the same number can be normal one day and alarming the next.
  • Use the middle value and the middle distance, not the average and the spread. Then a cluster of bad readings cannot hide itself.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy pandas statsmodels

Three methods, one series, one honest comparison

Three known anomalies are planted in a series with a strong weekend pattern. Each method gets the same threshold.

three_detectors.py
import numpy as np
import pandas as pd
from statsmodels.tsa.seasonal import STL

rng = np.random.default_rng(23)
n = 420
days = pd.date_range("2024-01-01", periods=n, freq="D")
s = pd.Series((2000 + np.linspace(0, 900, n)
               + np.where(days.dayofweek >= 5, 700, 0)
               + rng.normal(0, 90, n)).round(), index=days, name="visits").asfreq("D")

truth = ["2024-06-14", "2024-09-02", "2025-01-20"]
s[truth[0]] += 1400        # a post went viral
s[truth[1]] -= 1500        # the server was down for half the day
s[truth[2]] += 1300        # a newsletter went out

z_raw = (s - s.mean()) / s.std()                                  # global z-score

resid = STL(s, period=7, robust=True).fit().resid                 # what STL cannot explain
z_res = resid / resid.std()

med = s.rolling(29, center=True, min_periods=15).median()         # rolling median + MAD
dev = s - med
mad = dev.abs().rolling(29, center=True, min_periods=15).median()
z_mad = 0.6745 * dev / mad

for name, score in [("global z-score", z_raw),
                    ("rolling median + MAD", z_mad),
                    ("STL residual z-score", z_res)]:
    hit = score.index[score.abs() > 3.5]
    caught = sum(t in [str(d.date()) for d in hit] for t in truth)
    weekend_share = int((hit.dayofweek >= 5).sum())
    print(f"{name:22s} flagged {len(hit):3d}  caught {caught}/3  of the flags, {weekend_share} were weekends")

print("\ndays the STL method flagged:")
print([str(d.date()) for d in z_res.index[z_res.abs() > 3.5]])
Output
global z-score         flagged   1  caught 1/3  of the flags, 0 were weekends
rolling median + MAD   flagged  66  caught 3/3  of the flags, 63 were weekends
STL residual z-score   flagged   3  caught 3/3  of the flags, 0 were weekends

days the STL method flagged:
['2024-06-14', '2024-09-02', '2025-01-20']

Three results, three different failure modes.

The global z-score caught one of three and flagged nothing else. It compares each day against the average of the whole 420 days. That average sits between the low early months and the high late months, so the spread is enormous and almost nothing looks extreme. The server outage on 2 September dropped visits to a level that was perfectly normal six months earlier, so it went unnoticed.

The rolling median with MAD caught all three, and raised 66 alarms to do it. 63 of those were weekends. It has a local sense of level, which is why it beat the global z-score, but it has no idea that Saturdays are supposed to be high. In production, sixty-six pages in fourteen months means nobody reads the pager.

The STL residual score flagged exactly three days, and they were exactly the three planted ones. STL removed the trend and the weekly pattern, so the leftover contains only noise and genuine surprises.

The general rule: model the structure you can explain, then alarm on what is left. Every good detector on a seasonal series works this way.

robust=True matters in that STL call. Without it, the three big anomalies bend the fitted seasonal and trend components toward themselves, which shrinks their own residuals. The robust option downweights points with large residuals across iterations, so the anomalies stay visible.

Masking, and the fix

Here is why the median and MAD appear everywhere in this field.

masking.py
import numpy as np

rng = np.random.default_rng(1)
clean = rng.normal(100, 5, 60)

for label, data in [("one bad reading", np.append(clean, [400.])),
                    ("five bad readings", np.append(clean, [400.] * 5))]:
    mean, sd = data.mean(), data.std()
    med = np.median(data)
    mad = np.median(np.abs(data - med))
    z_mean = (400 - mean) / sd
    z_mad = 0.6745 * (400 - med) / mad
    print(f"{label:20s} mean {mean:6.1f}  sd {sd:6.1f}  ->  z {z_mean:5.2f}"
          f"   |   median {med:6.1f}  MAD {mad:4.1f}  ->  robust z {z_mad:7.1f}")
Output
one bad reading      mean  104.7  sd   38.4  ->  z  7.70   |   median  100.1  MAD  2.7  ->  robust z    74.6
five bad readings    mean  122.9  sd   80.1  ->  z  3.46   |   median  100.2  MAD  2.8  ->  robust z    73.0

One bad reading scores 7.70 and gets caught. Five identical bad readings score 3.46 and slip under a threshold of 3.5.

The bad readings did the damage themselves. They pushed the mean from 100 to 123 and the standard deviation from 5 to 80. The detector then measured them against a yardstick they had bent.

The robust version barely moved, from 74.6 to 73.0. The median of 65 values is unaffected by five of them, and so is the median absolute deviation.

The constant 0.6745 makes MAD comparable to a standard deviation for normally distributed data — it is the value where a normal distribution has half its mass within that many deviations of centre. Without it, robust scores are not on the familiar "three sigma" scale.

Turning a score into an alarm you can live with

A threshold is a business decision, not a statistical one. Some practical structure:

python
# 1. Two levels, not one.
warn, page = 3.0, 5.0

# 2. Require persistence for the warn level, so single blips stay quiet.
sustained = (z_res.abs() > warn).rolling(3).sum() >= 2      # 2 of the last 3 days

# 3. Suppress alarms during known events.
known = pd.to_datetime(["2024-10-31", "2024-11-01"])        # your festival calendar
alarm = sustained & ~z_res.index.isin(known)

Two levels separate "look at this tomorrow" from "wake someone up".

Persistence kills the single-point noise that generates most false alarms, at the cost of one period of delay. That trade is almost always worth it.

A known-events list is unglamorous and effective. Every real monitoring system ends up with one.

Other approaches, and when they earn their place

MethodIdeaGood forWatch out for
STL or forecast residualAlarm on what the model missedAny series with structureNeeds enough history to fit
Rolling median + MADLocal level, robust spreadNon-seasonal, fast-moving seriesNo seasonal awareness
sklearn.ensemble.IsolationForestIsolate points with few random splitsMany variables at onceNo notion of time order; feed it lag features
Prediction interval breachAlarm outside the model's own bandYou already have a forecasterIntervals are often miscalibrated
Change-point detection (ruptures)Find where the level shiftedPermanent shifts, not spikesDifferent problem from spike detection

Do not use IsolationForest on raw timestamps and values and expect it to understand time. It treats rows as independent. Give it lag and rolling features, exactly as in feature engineering for time series, and it becomes useful.

Common mistakes

Fitting the detector on data that contains the anomalies, without a robust method. The anomalies teach the model that they are normal. Use robust=True, or fit on a period you believe is clean.

One global threshold across many series. A busy series and a quiet series have different residual spreads. Normalise per series, which is what dividing by the residual spread does.

Ignoring the base rate. With one true anomaly per 1,000 days and a detector that flags 1 percent of normal days, you get about 10 false alarms for each true one. That ratio, not the accuracy percentage, decides whether anyone will trust the system.

Alerting on a single point, always. Require persistence for anything that is not an emergency.

Forgetting that missing data is an anomaly. A sensor reporting nothing is often more urgent than a sensor reporting a strange value. Check for gaps in the index explicitly.

Never reviewing the alarms. Keep a log of every alert and what it turned out to be. That log is the only route to a threshold that fits your actual costs.

Try it yourself

In the first script, change the outage from -= 1500 to -= 400, making it a subtle drop rather than a crash.

Predict whether the STL method still catches it at a threshold of 3.5. Then lower the threshold to 2.5 and count how many extra false alarms you bought. That count, against that one extra catch, is the whole trade-off of this field in a single experiment.

What to learn next

Researcher — Mathematics and papers.

Framing

Given ${y_t}$, an anomaly detector is a map to a score $a_t \in \mathbb{R}$ and a threshold rule $\mathbf{1}{a_t > \tau}$. The residual-based family constructs

$$ r_t = y_t - \hat{y}{t \mid \mathcal{F}{t-1}}, \qquad a_t = \frac{|r_t - \mathrm{med}(r)|}{c \cdot \mathrm{MAD}(r)} $$

  • $\hat{y}{t\mid\mathcal{F}{t-1}}$ — the expectation from a model using only prior information.
  • $\mathrm{MAD}(r) = \mathrm{med}(|r - \mathrm{med}(r)|)$.
  • $c = 1/\Phi^{-1}(0.75) \approx 1.4826$, making $c \cdot \mathrm{MAD}$ a consistent estimator of $\sigma$ under normality. The reciprocal $0.6745 = \Phi^{-1}(0.75)$ appears when the scaling is written as a multiplier on the numerator instead.

The causality requirement matters for deployment. STL and centred rolling windows use future observations and are valid only for retrospective analysis. For real-time detection the expectation must come from a one-step-ahead forecast, or from an online decomposition.

Chandola, Banerjee and Kumar (2009), Anomaly detection: A survey, ACM Computing Surveys 41(3), gives the standard taxonomy: point, contextual and collective anomalies. Seasonal series are the canonical contextual case, and the contextual variable is the phase within the cycle.

Robustness, quantified

The breakdown point of an estimator is the smallest fraction of arbitrarily corrupted observations that can drive it to an arbitrary value.

EstimatorBreakdown pointGaussian efficiency
Mean$1/n \to 0$100%
Standard deviation$1/n \to 0$100%
Median50%64%
MAD50%37%
$Q_n$ (Rousseeuw and Croux)50%82%

MAD's low Gaussian efficiency is the price of its breakdown point. Rousseeuw and Croux (1993), Alternatives to the median absolute deviation, JASA 88(424), propose $S_n$ and $Q_n$ with the same 50 percent breakdown and materially better efficiency; $Q_n$ is based on the 0.25 quantile of pairwise distances $|x_i - x_j|$ and computable in $O(n \log n)$.

MAD also degenerates when more than half the data are identical — a real failure on sparse count series where the median and MAD are both zero, giving division by zero. Guard with a floor, or use a quantile-range scale instead.

Extreme value theory, instead of a hand-picked threshold

Choosing $\tau$ by eye is the weakest link. The peaks-over-threshold approach places it on a footing. The Pickands-Balkema-de Haan theorem states that for a broad class of distributions the conditional excess distribution converges to a generalised Pareto:

$$ P(X - u \le x \mid X > u) \;\to\; G_{\xi,\beta}(x) = 1 - \left(1 + \frac{\xi x}{\beta}\right)^{-1/\xi} $$

  • $u$ — a high initial threshold.
  • $\xi$ — the shape (tail index); $\xi>0$ is heavy-tailed.
  • $\beta$ — the scale.

Fit $(\xi, \beta)$ by maximum likelihood on the exceedances, then set the alarm threshold at a chosen exceedance probability $q$. Siffer, Fouque, Termier and Largouët (2017), Anomaly Detection in Streams with Extreme Value Theory, KDD, give SPOT and DSPOT, which do this in streaming form with a drifting threshold and no distributional assumption on the bulk.

Evaluation is the hardest part of this subfield

Class imbalance of 1:1000 or worse makes accuracy meaningless. Use precision, recall and PR-AUC, not ROC-AUC — under extreme imbalance ROC-AUC stays high while precision is unusable (Davis and Goadrich, 2006).

Time complicates the definition of a true positive. An event lasting six hours detected at hour three: is that one hit, one hit and five misses, or a hit with a three-hour delay? Two conventions dominate:

  • Point-adjust (used by many deep-learning papers): if any point inside a true anomalous segment is flagged, all points in that segment count as detected. Kim et al. (2022), Towards a Rigorous Evaluation of Time-series Anomaly Detection, AAAI, show this metric is so permissive that a random scorer achieves high F1 on standard benchmarks. Results reported under point-adjust are not comparable to anything.
  • Range-based precision and recall (Tatbul et al., 2018, NeurIPS) parameterise credit for existence, size, position and cardinality of overlaps, and behave sensibly.

The benchmark datasets themselves are also compromised. Wu and Keogh (2023), Current Time Series Anomaly Detection Benchmarks are Flawed, TKDE, document triviality, mislabelling and run-to-failure bias in Yahoo S5, Numenta NAB and SMAP/MSL, and show that a one-line baseline is competitive on much of them. Treat published leaderboards on those datasets with scepticism.

Where the field is

Reconstruction-based deep methods — autoencoders, variational autoencoders, GAN discriminators — score by reconstruction error. See autoencoders for the mechanism. They handle multivariate, high-dimensional streams that classical methods cannot.

The honest summary from the benchmark critiques above: on univariate seasonal series, a well-specified decomposition with a robust residual score is hard to beat, and much of the reported deep-learning advantage in this area dissolves under a correct evaluation protocol. The genuine gains are in high-dimensional multivariate settings with cross-signal dependence, which is where classical residual methods have nothing to offer.

Reading

  • Chandola, Banerjee and Kumar (2009), Anomaly detection: A survey, ACM Computing Surveys 41(3).
  • Rousseeuw and Croux (1993), Alternatives to the median absolute deviation, JASA 88(424).
  • Siffer et al. (2017), Anomaly Detection in Streams with Extreme Value Theory, KDD.
  • Tatbul, Lee, Zdonik, Alam and Gottschlich (2018), Precision and Recall for Time Series, NeurIPS.
  • Kim, Choi, Choi, Lee and Yoon (2022), Towards a Rigorous Evaluation of Time-series Anomaly Detection, AAAI — arxiv.org/abs/2109.05257.
  • Wu and Keogh (2023), Current Time Series Anomaly Detection Benchmarks are Flawed, IEEE TKDE 35(3).

What to learn next