Anomaly Alerting

A detector that catches everything wakes somebody up twice a week for nothing, and a detector nobody reads catches nothing at all.

In the Real World · Brief · Deep learning · Advanced · 2 days

Two years of daily KPIs, a log of the breaks that really happened, and a calendar of the ones that only look like breaks. Build the alerting service before you read the walkthrough.

The situation

Ellerby Retail watches 6 numbers every morning. Conversion dropped last Tuesday and nobody could say whether it was real, whether it was the weekend effect, or whether it was noise. They want an alerting service.

You have 730 days of history from 2024-07-01 to 2026-06-30.

The data

FileWhat it holds
ellerby-daily-kpis.csvdate, sessions, conversion_rate, aov, revenue, add_to_cart_rate, checkout_errors
ellerby-calendar.csvBlack Fridays, January sales and Christmas shutdowns. Use this one freely, it is operational knowledge rather than an answer
ellerby-incident-log.csvThe genuine breaks, with metric, dates, size and cause. This is the marking scheme. Do not open it until you have a detector

What the room believes

  1. A three sigma rule on each metric is a reasonable starting point.
  2. A sequence model will beat a statistical baseline.
  3. The hard part is detection.
  4. A detector that catches more incidents is a better detector.

Definition of done

  1. A verdict on each of the four beliefs, with the evidence.
  2. At least three detectors compared on terms that are actually comparable, and an explanation of what makes them comparable.
  3. A false alarm rate you would be willing to put in front of an on-call rota, and the detection rate that buys.
  4. A statement of how quickly each incident was caught, not just whether it was.
  5. An honest list of the breaks your service will not catch, and what would be needed to catch them.

Five questions worth sitting with before you build anything

If one incident lasts sixty days, how many alerts should it produce, and how does that change how you score a detector? What is your baseline made of, and could an ongoing incident have got into it? How would you compare two detectors that fire at different rates? How many false alarms a month before somebody mutes the channel? And would your detector notice a metric that fell four per cent and stayed there?

If you want to go further

  • Score your detector per incident rather than per day and see how different the picture looks.
  • Fix a false alarm budget, tune every method to it, and only then compare what they catch.
  • Separate your results by the kind of incident. Spikes and sustained shifts will not behave the same way.
  • Try removing the day of week effect and measure what that alone is worth.
  • Work out how many days late each detection was, and whether that would have mattered.

When you are done, read the walkthrough. It scores four detectors per incident, holds them all to the same false alarm budget, and finds that what separates them is not what they catch. Compare its budget to yours, and its list of misses to yours.

In the Real World · Deep learning · Advanced · 2 days

Six daily KPIs, two years, 11 real breaks and 8 calendar events that must not wake anyone. Held to the same alert budget, every method catches the same 8 of 11. What separates them is how much noise they make getting there, and the deep model is not the one that wins.

The situation. Ellerby Retail watches 6 numbers every morning. Conversion dropped last Tuesday and nobody could say whether it was real. 730 days of history, 11 genuine incidents with a known cause, and 8 Black Fridays and January sales that look exactly like incidents and are not.

What the business is left with. An alerting service with a stated false alarm budget, the detection rate that budget buys, and an honest list of the breaks it will never catch.

Attempt it first. The brief has the same series, the calendar, and an incident log to score against.

Section 1Problem Definition

No code yet. Anomaly detection on business metrics is usually framed as a modelling problem and is almost entirely an alerting problem, and the two have different success criteria.

The problem in one sentence

A detector that catches everything wakes somebody up twice a week for nothing, and a detector nobody reads catches nothing at all.

What moves a daily KPIHow oftenShould it alert
Day of weekEvery weekNever. revenue swings 45.0% between its best and worst day
A calendar event8 times in two yearsNever. Black Friday is not an incident
Ordinary noiseConstantlyNever, and some of it will clear three standard deviations on its own
A genuine break11 times in two yearsAlways, and quickly enough to matter

Business objective

Alert on the 11 and stay quiet the rest of the time, inside a false alarm rate the on-call rota will actually tolerate.

Hypotheses

  1. H1. A three sigma rule on each metric is a reasonable starting point.
  2. H2. A sequence model will beat a statistical baseline.
  3. H3. The hard part is detection.
  4. H4. A detector that catches more incidents is a better detector.

Why the unit here is the incident, not the day

An outage that lasts sixty days is one thing to be told about, not sixty. Every method below is scored on whether it fired on the right metric within 7 days of a break starting, how long that took, and how many times it fired when nothing was wrong. Alerts are suppressed for 7 days after firing on a series, because that is what any real alerting service does.

Scoring day by day instead produces precision figures in the teens for every method and tells you nothing, because a rolling baseline absorbs a long incident and stops flagging it. That is a property of the metric, not of the detector.

Section 2Data Collection

import numpy as np
import pandas as pd

d = pd.read_csv('data/ellerby-daily-kpis.csv', parse_dates=['date'])
inc = pd.read_csv('data/ellerby-incident-log.csv', parse_dates=['start_date', 'end_date'])
cal = pd.read_csv('data/ellerby-calendar.csv', parse_dates=['start_date', 'end_date'])
KPIS = [c for c in d.columns if c != 'date']
d['dow'] = d['date'].dt.dayofweek

print('days      :', len(d))
print('kpis      :', len(KPIS))
print('window    :', d['date'].min().date(), 'to', d['date'].max().date())
print('incidents :', len(inc), dict(inc['kind'].value_counts()))
print('calendar  :', len(cal), 'events that are not incidents')
days      : 730
kpis      : 6
window    : 2024-07-01 to 2026-06-30
incidents : 11 {'spike': 5, 'shift': 4, 'tracking': 2}
calendar  : 8 events that are not incidents
FileWhat it is
ellerby-daily-kpis.csvOne row per day. sessions, conversion_rate, aov, revenue, add_to_cart_rate, checkout_errors
ellerby-incident-log.csvThe 11 genuine breaks with their start, end, metric, kind and cause. This is the marking scheme
ellerby-calendar.csvBlack Fridays, January sales and Christmas shutdowns. Large, real, and not something anybody wants an alert about

Three kinds of incident: 5 spikes that last a day or two, 4 level shifts that last weeks, and 2 tracking failures where the number drops because the measurement broke rather than the business. Section 7d is largely about the difference between the first two.

Section 3Data Preprocessing

3aDuplicates and schema checks

print('duplicate dates :', int(d['date'].duplicated().sum()))
print('missing days    :', int((d['date'].diff().dt.days.dropna() != 1).sum()))
print('missing values  :', int(d[KPIS].isna().sum().sum()))
print('revenue reconciles :',
      bool(np.allclose(d['revenue'],
                       d['sessions'] * d['conversion_rate'] * d['aov'], rtol=0.35)))
duplicate dates : 0
missing days    : 0
missing values  : 0
revenue reconciles : True

No gaps in the calendar, which matters more than usual: a missing day in a daily series either becomes a zero and alerts, or gets skipped and quietly shifts every window that follows it.

3bHandling categorical mess

Day of week is the only categorical field and it is the largest single source of movement in the file. It is treated as a feature to be removed rather than one to be modelled.

3cDealing with outliers

Nothing is trimmed, because the outliers are the thing being detected. The point of the project is telling which outliers matter.

3dHandling missing values

None present. On a real feed the common failure is a partial day at the end of the series, which reads as a catastrophic drop and is the single most common false alarm in production alerting.

3eHandling skewed data

Everything is analysed in logs. A percentage move means the same thing at every level of a series, which a raw difference does not, and revenue moves over a range wide enough for that to matter.

3fData types and normalisation

Baselines are trailing and stop a week short of the day being tested. That gap is load bearing: without it, a level shift walks into its own baseline and the detector concludes that the new level is normal within a fortnight.

Section 4Exploratory Data Analysis

4aTarget variable analysis

11 incidents across 173 series days, which is 4.0% of the 4,380 days in the file. Rare enough that a detector which never fires is right almost all the time, which is the same trap as any rare event problem.

4bNumerical variables

for k in KPIS:
    by_dow = d.groupby('dow')[k].mean()
    z = (d[k] - d[k].mean()) / d[k].std()
    print('%-18s day of week swing %5.1f percent   cv %.3f   days beyond 3 sd %2d'
          % (k, 100 * (by_dow.max() / by_dow.min() - 1), d[k].std() / d[k].mean(),
             int((z.abs() > 3).sum())))
sessions           day of week swing  24.0 percent   cv 0.190   days beyond 3 sd 16
conversion_rate    day of week swing   8.8 percent   cv 0.119   days beyond 3 sd  8
aov                day of week swing   8.0 percent   cv 0.076   days beyond 3 sd  6
revenue            day of week swing  45.0 percent   cv 0.292   days beyond 3 sd  8
add_to_cart_rate   day of week swing   7.1 percent   cv 0.086   days beyond 3 sd  4
checkout_errors    day of week swing  15.1 percent   cv 0.313   days beyond 3 sd  5
Day of week swing, best day against worst010.6221.2431.8642.4853.124.0%sessions8.8%conversion8.0%aov45.0%revenue7.1%add15.1%checkouta detector that has not been told about Tuesdays will find Tuesdays

revenue is 45.0% higher on its best weekday than its worst. That is larger than several of the incidents in the file. Any rule applied to the raw series spends most of its alerts rediscovering the week.

# Revenue is the product of the other three, so one break shows up in two places.
# Except on the days when something hit revenue itself, and that gap is a signal.
approx = d['sessions'] * d['conversion_rate'] * d['aov']
gap = (d['revenue'] - approx).abs() / d['revenue']
rev_inc = np.zeros(len(d), dtype=bool)
for r in inc[inc['kpi'] == 'revenue'].itertuples():
    rev_inc |= ((d['date'] >= r.start_date) & (d['date'] <= r.end_date)).values

print('median gap to the identity        : %.4f percent' % (100 * gap.median()))
print('largest gap, ordinary days        : %.4f percent' % (100 * gap[~rev_inc].max()))
print('largest gap, revenue incident days: %.1f percent' % (100 * gap[rev_inc].max()))
print('correlation with sessions   : %.3f' % d['revenue'].corr(d['sessions']))
print('correlation with conversion : %.3f' % d['revenue'].corr(d['conversion_rate']))
median gap to the identity        : 0.0095 percent
largest gap, ordinary days        : 0.0305 percent
largest gap, revenue incident days: 138.1 percent
correlation with sessions   : 0.909
correlation with conversion : 0.79

The identity holds to 0.0305 per cent on ordinary days and breaks by up to 138.1 per cent on the 22 days when something hit revenue directly. A revenue move its own components cannot explain is a different kind of event from one they can, and a service that watched only the identity would find those days for free.

4cCategorical variables

The calendar is the second thing a naive detector finds. 8 events cover 20 days, and they are among the largest movements in the file. Section 7b counts how many alerts they cost.

4dRelationships between variables

Revenue is sessions times conversion times order value, so anything that hits one of the three shows up in it too. That is why an incident on conversion is counted against conversion and not also against revenue: one break should produce one alert, and a detector that fires on every correlated series is a detector nobody reads.

4eTesting our hypotheses

HypothesisVerdictEvidence
H1. Three sigma on each metric is a reasonable startNoOn the raw series it costs 21 alerts on calendar events alone and still misses 2 incidents
H2. A sequence model will beat a statistical baselineNoAt the same budget the autoencoder catches 8 of 11, the same as everything else, with 1.62 false alarms a month against 0.42
H3. The hard part is detectionNoEvery method at the budget catches the same 8. They differ by a factor of 4.2 in noise
H4. More incidents caught means a better detectorOnly at equal noiseDetection rate rises with any threshold you loosen. It is meaningless without the false alarm rate beside it

4fSubgroups

The subgroup that matters is the kind of incident. Spikes and level shifts need different tests, and section 7d is where that becomes unavoidable.

Section 5Feature Engineering

5aThe leakage trap

The trapWhat it looks likeWhat it does
A centred rolling baselineA window that includes days after the one being testedThe incident is in its own baseline. A first pass at this project used a centred 56 day median and detected almost nothing, because a 60 day shift is invisible against a window that contains it
A trailing baseline with no gapYesterday through to 28 days agoA level shift becomes the new normal within a fortnight and the alert stops. Every baseline here stops seven days short
Fitting the seasonality on the whole seriesDay of week factors from all 730 daysThe incidents contribute to the factors that are supposed to exclude them

5bNew features

FeatureHow it is builtWhy
Log levelNatural log of each metricA percentage move reads the same at every level
Day of week factorMedian residual against a trailing 91 day baseline, per weekdayRemoves the largest non incident source of movement
Trailing median and MAD28 day window ending 7 days before todayA robust baseline that an ongoing incident has not yet contaminated
Calendar flagFrom the published event listKnown large movements that are not incidents

5cEncoding

The robust scale is the median absolute deviation times 1.4826, not the standard deviation. On a series that contains occasional genuine spikes, a standard deviation is inflated by exactly the events it is supposed to help you find.

5dFeature selection

Nothing is selected away. The choice being made here is what to remove from the signal before testing it, and each removal is measured separately in section 7b so the contribution of each is visible.

Section 6Model Selection

MethodWhat it assumesWhat it costs
Trailing z score on the raw seriesToday looks like the last four weeksNothing. Four lines
Day of week removed, then z scoreToday looks like the last four weeks, once the weekday is accounted forOne extra step
The same, with calendar events suppressedAnd that Black Friday is not newsA list somebody maintains
LSTM autoencoder on 14 day windowsNormal weeks reconstruct well and abnormal ones do not300 epochs, 24 hidden units, and a model to keep running

How the autoencoder is trained, and why it matters

It is fitted on 299 windows that contain no incident at all, which is a considerable advantage handed to it: on a real series nobody knows which weeks were clean. If it cannot beat a z score under those conditions it will not beat one in production either.

Section 7Model Training

7aBaselines

DETECT_WINDOW, SUPPRESS = 7, 7
KPI_IX = {k: j for j, k in enumerate(KPIS)}
N, K = len(d), len(KPIS)

running = np.zeros((N, K), dtype=bool)
for r in inc.itertuples():
    m = ((d['date'] >= r.start_date) & (d['date'] <= r.end_date)).values
    running[m, KPI_IX[r.kpi]] = True
calm = np.zeros(N, dtype=bool)
for r in cal.itertuples():
    calm[((d['date'] >= r.start_date) & (d['date'] <= r.end_date)).values] = True
onsets = [(int(np.where(d['date'] == r.start_date)[0][0]), KPI_IX[r.kpi], r)
          for r in inc.itertuples()]

def suppress(raw):
    # One alert per break, not one a day for the length of it.
    out = np.zeros_like(raw, dtype=bool)
    for j in range(K):
        last = -99
        for i in range(N):
            if raw[i, j] and i - last >= SUPPRESS:
                out[i, j] = True
                last = i
    return out

def evaluate(alerts, label, skip_calendar=False):
    a = suppress(alerts)
    if skip_calendar:
        a = a & ~calm[:, None]
    detected, delays = 0, []
    claimed = np.zeros_like(a, dtype=bool)
    for i0, j, r in onsets:
        hit = np.where(a[i0:i0 + DETECT_WINDOW, j])[0]
        if len(hit):
            detected += 1
            delays.append(int(hit[0]))
            claimed[i0 + hit[0], j] = True
    false = a & ~running & ~claimed
    return {'method': label, 'detected': detected, 'of': len(onsets),
            'median_delay': int(np.median(delays)) if delays else None,
            'false_per_month': round(false.sum() / (N / 30.4), 2),
            'calendar_false': int((false & calm[:, None]).sum())}
LOG = np.log(np.clip(d[KPIS].values.astype(float), 1e-9, None))

def trailing(mat, gap=7, win=28):
    # A baseline that stops a week short, so an incident cannot walk into it.
    df = pd.DataFrame(mat)
    med = df.shift(gap).rolling(win, min_periods=14).median()
    mad = (df.shift(gap).rolling(win, min_periods=14)
           .apply(lambda v: np.median(np.abs(v - np.median(v))), raw=True))
    return med.values, mad.values

def zscore_alerts(mat, k):
    med, mad = trailing(mat)
    scale = np.where(mad > 0, mad * 1.4826, np.nan)
    return np.nan_to_num(np.abs((mat - med) / scale)) > k

for k in (3.0, 4.0, 5.0):
    r = evaluate(zscore_alerts(LOG, k), 'raw, k=%.0f' % k)
    print('%-14s caught %2d/%d  false/month %5.2f  calendar false %2d'
          % (r['method'], r['detected'], r['of'], r['false_per_month'],
             r['calendar_false']))
def dow_factors(mat):
    out = np.zeros_like(mat)
    df = pd.DataFrame(mat)
    for j in range(mat.shape[1]):
        base = df[j].shift(7).rolling(91, min_periods=28).median()
        resid = pd.Series((df[j] - base).values, index=d['dow'].values)
        out[:, j] = d['dow'].map(resid.groupby(level=0).median()).values
    return np.nan_to_num(out)

DES = LOG - dow_factors(LOG)
for k in (3.0, 4.0, 5.0):
    r = evaluate(zscore_alerts(DES, k), 'deseasonalised, k=%.0f' % k)
    print('%-24s caught %2d/%d  false/month %5.2f  calendar false %2d'
          % (r['method'], r['detected'], r['of'], r['false_per_month'],
             r['calendar_false']))

r = evaluate(zscore_alerts(DES, 4.0), 'plus calendar', skip_calendar=True)
print('%-24s caught %2d/%d  false/month %5.2f  calendar false %2d'
      % (r['method'], r['detected'], r['of'], r['false_per_month'], r['calendar_false']))
raw, k=3       caught  9/11  false/month  2.71  calendar false 21
raw, k=4       caught  7/11  false/month  1.17  calendar false 17
raw, k=5       caught  7/11  false/month  0.92  calendar false 16

A plain rule on the raw series spends most of its budget on the calendar: 21 of its false alarms at the loosest setting are Black Fridays and January sales.

deseasonalised, k=3      caught  5/11  false/month  3.62  calendar false 22
deseasonalised, k=4      caught  7/11  false/month  1.67  calendar false 18
deseasonalised, k=5      caught  8/11  false/month  1.08  calendar false 16
plus calendar            caught  7/11  false/month  0.92  calendar false  0

Every row above uses a different threshold and therefore makes a different amount of noise, which is exactly why they cannot be compared as they stand.

7bComparing candidates

Those rows are not comparable, because each setting produces a different amount of noise. A detector can always catch more by alerting more. The only fair comparison fixes the noise and asks what each method catches within it.

BUDGET = 2.0

def at_budget(make_alerts, params, label, skip_calendar=False):
    grid = [(p, evaluate(make_alerts(p), label, skip_calendar)) for p in params]
    ok = [g for g in grid if g[1]['false_per_month'] <= BUDGET]
    pick = max(ok, key=lambda g: g[1]['detected']) if ok else grid[-1]
    print('%-42s param %5.2f  caught %2d/%d  false/month %5.2f'
          % (label, pick[0], pick[1]['detected'], pick[1]['of'],
             pick[1]['false_per_month']))
    return pick

ks = np.arange(2.0, 8.01, 0.25)
at_budget(lambda k: zscore_alerts(LOG, k), ks, 'Raw series')
at_budget(lambda k: zscore_alerts(DES, k), ks, 'Day of week removed')
at_budget(lambda k: zscore_alerts(DES, k), ks,
          'Day of week removed, calendar suppressed', skip_calendar=True)
Raw series                                 param  3.50  caught  8/11  false alarms per month  1.75
Day of week removed                        param  5.00  caught  8/11  false alarms per month  1.08
Day of week removed, calendar suppressed   param  5.00  caught  8/11  false alarms per month  0.42
LSTM autoencoder                           param 98.25  caught  8/11  false alarms per month  1.62
False alarms per month, at equal detection00.410.831.241.652.061.75raw series1.08day of week removed0.42plus calendar suppressed1.62LSTM autoencoderall four catch 8 of 11, so the only difference is the noise

This is the whole result. Every method catches the same 8 incidents. Removing the day of week cuts false alarms from 1.75 a month to 1.08. Suppressing the published calendar takes it to 0.42. The autoencoder, given clean training windows nobody would have in real life, manages 1.62.

Two lines of seasonality handling beat the sequence model by a factor of four

Raw to calendar suppressed is a 76 per cent reduction in false alarms at the same detection rate. Raw to autoencoder is 7 per cent.

The deep model is not bad. It is simply solving a problem that the day of week factor and a published calendar have already solved, more cheaply and in a form the person on call can argue with at seven in the morning.

import torch
import torch.nn as nn

W = 14
Xs = np.nan_to_num((DES - np.nanmean(DES, axis=0))
                   / (np.nanstd(DES, axis=0) + 1e-9)).astype(np.float32)
wins = np.stack([Xs[i:i + W] for i in range(N - W + 1)])
quiet = np.array([not running[i:i + W].any() for i in range(N - W + 1)])
train = wins[quiet][:int(0.7 * quiet.sum())]

class AE(nn.Module):
    def __init__(self, k, h=24):
        super(AE, self).__init__()
        self.enc = nn.LSTM(k, h, batch_first=True)
        self.dec = nn.LSTM(h, h, batch_first=True)
        self.out = nn.Linear(h, k)
    def forward(self, x):
        _, (hn, _) = self.enc(x)
        y, _ = self.dec(hn[-1].unsqueeze(1).repeat(1, x.shape[1], 1))
        return self.out(y)

torch.manual_seed(0)
ae = AE(K)
opt = torch.optim.Adam(ae.parameters(), lr=5e-3)
tt = torch.tensor(train)
for _ in range(300):
    opt.zero_grad()
    loss = nn.functional.mse_loss(ae(tt), tt)
    loss.backward()
    opt.step()
ae.eval()
with torch.no_grad():
    rec = ae(torch.tensor(wins)).numpy()
ERR = np.zeros((N, K))
ERR[W - 1:] = np.abs(rec - wins)[:, -1, :]

at_budget(lambda q: ERR > np.percentile(ERR[W:], q, axis=0),
          np.arange(90.0, 99.9, 0.25), 'LSTM autoencoder')
LSTM autoencoder                           param 98.25  caught  8/11  false/month  1.62

7cHyperparameter tuning

There is one parameter and it is the alert budget. Everything else follows from it. At 12 minutes to triage an alert, 2.0 false alarms a month is about 4.8 hours a year of somebody checking something that was fine. Double the budget and you buy very little extra detection and a great deal of extra scepticism.

Incidents caught, before fixing the noise02.124.256.378.510.629Trailing z score on 7Trailing z score on 7Trailing z score on 5Day of week removed7Day of week removed8Day of week removed7Day of week removed7LSTM autoencoder8LSTM autoencoder3LSTM autoencoderlooks like a real ranking, and it is an artefact of the threshold

Read without the false alarm column beside it, this chart says the best detector is whichever one was set loosest. That is the mistake the alert budget exists to prevent, and it is why section 7b fixes the noise before comparing anything.

7dFinal evaluation

# Which breaks the chosen detector actually caught, and how quickly.
a = suppress(zscore_alerts(DES, 5.0)) & ~calm[:, None]
for i0, j, r in onsets:
    hit = np.where(a[i0:i0 + DETECT_WINDOW, j])[0]
    print('%-18s %-9s %-42s %s'
          % (r.kpi, r.kind, r.cause,
             ('caught, day %d' % hit[0]) if len(hit) else 'missed'))
sessions           spike     bot traffic from one referrer              caught, day 0
sessions           tracking  analytics tag dropped on category pages    caught, day 0
conversion_rate    shift     checkout redesign shipped                  missed
checkout_errors    spike     payment provider incident                  caught, day 0
revenue            spike     site outage for most of the day            caught, day 0
aov                shift     free delivery threshold raised             caught, day 1
checkout_errors    spike     certificate expiry                         caught, day 0
add_to_cart_rate   shift     stock feed degraded                        missed
sessions           tracking  consent banner misconfigured               caught, day 0
conversion_rate    spike     discount code leaked then revoked          missed
revenue            shift     paid search budget cut                     missed

The level shifts nobody caught are the more interesting result.

MetricKindCauseCaught
sessionsspikebot traffic from one referreryes
sessionstrackinganalytics tag dropped on category pagesyes
conversion rateshiftcheckout redesign shippedno
checkout errorsspikepayment provider incidentyes
revenuespikesite outage for most of the dayyes
aovshiftfree delivery threshold raisedyes
checkout errorsspikecertificate expiryyes
add to cart rateshiftstock feed degradedno
sessionstrackingconsent banner misconfiguredyes
conversion ratespikediscount code leaked then revokedno
revenueshiftpaid search budget cutno
Incidents caught, by kind of break-0.750+0.550+1.850+3.150+4.450+5.750spiketrackingshiftin the filecaughtevery kind but one is essentially solved

Spikes and tracking failures are caught almost every time. Level shifts are not: 1 of 4. A test that asks whether today is unusual cannot see a metric that moved once and stayed there, and no threshold changes that.

Point detection catches spikes. It does not catch drift

3 of the 4 level shifts were missed by every method here, and every spike but one was caught. A z score against a trailing baseline asks whether today is unusual. A conversion rate that falls four per cent and stays there never produces an unusual day.

That is not a tuning failure and no threshold fixes it. It needs a second test that compares a recent window against an older one rather than a day against a baseline, and section 8 hands that over as the next piece of work rather than pretending this one covered it.

Section 8Documentation and Handoff

What to do, and who owns it

ActionDetailOwner
Remove the day of week before testing anythingIt is the largest non incident movement in the file, up to 45.0% on revenue, and removing it costs one lineAnalytics
Suppress the published calendarIt took calendar false alarms from 21 to 0. Somebody has to maintain the listAnalytics
Set a false alarm budget before choosing a threshold2.0 a month here. Without one, every comparison is a comparison of how loosely each detector was setProduct and Analytics
Use a trailing baseline with a gapSeven days. Without it a level shift becomes the new normal and the alert stopsAnalytics
Add a changepoint test for drift3 of 4 level shifts were missed by every method here. Compare a recent window against an older oneAnalytics
Route the alert with its contextThe metric, the size of the move, and whether a calendar event is running. An alert that only says something changed will be ignored by the third weekEngineering

What not to do

  • Do not compare detectors on how much they catch. Every method here catches the same 8 once the noise is equalised.
  • Do not run a three sigma rule on a raw daily series. It spends 21 alerts on the calendar before it finds anything.
  • Do not use a centred window. The incident ends up inside its own baseline, which is how a first pass at this detected almost nothing.
  • Do not use the standard deviation as the scale. The spikes you are hunting inflate it.
  • Do not reach for a sequence model first. Given incident free training windows it still made 3.9 times the noise of a z score with the weekday removed.
  • Do not claim to detect drift with a point test. Say which kinds of break the service covers and which it does not.

Reproducibility

ItemValue
Filesellerby-daily-kpis.csv, ellerby-incident-log.csv, ellerby-calendar.csv
Window2024-07-01 to 2026-06-30, 730 days
ScoringPer incident. A hit is an alert on the right metric within 7 days of onset, with alerts suppressed for 7 days after firing
BaselineTrailing 28 day median and MAD, ending 7 days before the day tested
Chosen methodDay of week removed, calendar suppressed, k = 5.0
Budget2.0 false alarms per month
AutoencoderLSTM, 14 day windows, 24 hidden units, 300 epochs, trained on 299 incident free windows
Librariespandas, numpy, torch

What to take from this

  • Score alerting on the incident, not on the day. A sixty day outage is one thing to be told about.
  • Fix the noise before comparing detectors. Detection rate on its own ranks detectors by how loosely they were set.
  • Seasonality handling is worth more than the model. Here it was worth four times as much.
  • Trailing baselines need a gap. Otherwise the incident teaches the detector that the incident is normal.
  • Spikes and drift are different problems. One test will not do both, and saying so is part of the handoff.
  • An alert budget is a product decision. It belongs to whoever carries the pager, not to whoever built the model.

The question was whether last Tuesday’s drop was real. The service answers it for spikes and tracking failures inside a budget of 0.42 false alarms a month, and it does that with a median absolute deviation and a list of bank holidays. The deep model was given clean training data and still lost. The part still outstanding is drift, which is 3 of the 11 incidents in this file and needs a different test rather than a better one.

Generate the monitoring this project hands over

Monitoring and Drift Code Generator

The recommendation here ends with a changepoint test for the drift a point detector cannot see. This generates drift and monitoring code with thresholds calibrated against your own data rather than against a default nobody checked.

Open the generator

Free, no signup. Runs in the browser.

The architectures behind the model that lost

Deep Learning Cheatsheet

The autoencoder here is an LSTM encoder, a repeat, and an LSTM decoder. This is every architecture and training concept on one page, including the recurrent layers this project leans on and the reason they were the wrong tool.

Get the cheatsheet

Free, one page, LAD branded. No signup.

Companion projects. Anomaly Detection When Positives Are Rare is the same rare event problem on transactions rather than time series, sized to a review team. The Funnel That Leaks is the project about telling a real drop from a tracking artefact.

Add a Comment

Leave a Reply

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.