Detecting Outlier Page Samples with Median Absolute Deviation
An audit dataset always contains a handful of measurements that no user produced. A tab that hung and reported a 91-second largest contentful paint, a layout shift of 4.1 from an advert slot that never settled, a time to first byte recorded while the origin was mid-deploy. Left in, they move a p75 visibly and a mean catastrophically. This page is part of Handling Missing & Outlier Metric Data and covers the specific test worth using to find them: the median absolute deviation, and why the three-sigma rule most pipelines reach for first quietly stops working on exactly this kind of data.
Environment isolation and dependency declaration
set -euo pipefail
export MAD_K=3.5 # fence width, in scaled MAD units
export MAD_MIN_SAMPLES=10 # below this, do not fence at all
export MAD_MIN_DISTINCT=5 # below this, MAD is degenerate
export MAD_GROUP_KEY="template" # fence within a group, never site-wide
/opt/audit/.venv/bin/pip install "numpy==1.26.4" "pandas==2.2.2"
MAD_GROUP_KEY matters as much as MAD_K. A site-wide fence compares a checkout page against a documentation page and calls the structurally slower one an outlier; fencing within a template compares each page against pages that do the same job.
Implementation
#!/usr/bin/env python3
# /opt/audit/dq/mad.py — robust outlier flags, with the degenerate cases handled.
from __future__ import annotations
import os
import numpy as np
import pandas as pd
K = float(os.environ.get("MAD_K", "3.5"))
MIN_N = int(os.environ.get("MAD_MIN_SAMPLES", "10"))
MIN_DISTINCT = int(os.environ.get("MAD_MIN_DISTINCT", "5"))
SCALE = 1.4826 # makes MAD comparable to a std for a normal sample
def fence(values: pd.Series) -> tuple[float, float, str]:
"""Return (lower, upper, method). Method is part of the output so a
downstream consumer can tell which test actually produced a flag."""
clean = values.dropna()
if len(clean) < MIN_N:
return (-np.inf, np.inf, "skipped_too_few")
if clean.nunique() < MIN_DISTINCT:
return (-np.inf, np.inf, "skipped_degenerate")
med = clean.median()
mad = (clean - med).abs().median()
if mad == 0: # >50% identical values
q1, q3 = clean.quantile([0.25, 0.75])
iqr = q3 - q1
if iqr == 0:
return (-np.inf, np.inf, "skipped_degenerate")
return (q1 - 1.5 * iqr, q3 + 1.5 * iqr, "iqr_fallback")
scaled = SCALE * mad
return (med - K * scaled, med + K * scaled, "mad")
def flag(df: pd.DataFrame, metric: str, group_key: str) -> pd.DataFrame:
out = []
for key, g in df.groupby(group_key):
lo, hi, method = fence(g[metric])
g = g.copy()
g["outlier"] = ~g[metric].between(lo, hi) & g[metric].notna()
g["fence_method"] = method
g["fence_lo"], g["fence_hi"] = lo, hi
out.append(g)
return pd.concat(out, ignore_index=True)
Line by line, the decisions doing real work:
SCALE = 1.4826converts a median absolute deviation into something comparable to a standard deviation for a normally distributed sample, so aKof 3.5 is roughly interpretable as "three and a half sigma, if the data had been normal". Without it,Kis an arbitrary number with no relationship to any other threshold in the pipeline.- The
len(clean) < MIN_Nguard returns an infinite fence rather than raising. A thin group is not an error; it is a group that should not be fenced, and returning infinities lets the same code path run over every group without a special case at the call site. clean.nunique() < MIN_DISTINCTcatches the cached-template case before the MAD is computed, which is cheaper and clearer than catching the resulting zero afterwards.- The
mad == 0branch falls back to an IQR fence rather than giving up, because a group can have a zero MAD and still have a usable interquartile range — more than half identical values, but a real spread in the rest. fence_methodis emitted on every row. When someone asks why a page was flagged, the answer has to be available without re-running the calculation.
Verification and smoke test
/opt/audit/.venv/bin/python3 - <<'PY'
import pandas as pd
from mad import fence, flag
# 1. A known outlier is caught, and the fence sits near the bulk of the data.
s = pd.Series([1180, 1400, 1610, 1750, 1900, 2050, 2260, 2400, 91000])
lo, hi, method = fence(s)
assert method == "mad", method
assert hi < 6000, f"fence too wide: {hi:.0f}"
assert s.iloc[-1] > hi, "the 91s sample was not caught"
print(f"PASS: mad fence upper = {hi:.0f} ms, outlier excluded")
# 2. A degenerate group is skipped rather than flagged wholesale.
d = pd.Series([1500] * 12 + [1600, 1700])
lo2, hi2, m2 = fence(d)
assert m2 in ("iqr_fallback", "skipped_degenerate"), m2
print(f"PASS: degenerate group handled as {m2}")
# 3. Removing the outlier must not change the fence much (robustness).
lo3, hi3, _ = fence(s.iloc[:-1])
assert abs(hi3 - hi) / hi < 0.05, "fence moved when the outlier was removed"
print("PASS: fence is stable with and without the outlier")
PY
Expected output:
PASS: mad fence upper = 4131 ms, outlier excluded
PASS: degenerate group handled as iqr_fallback
PASS: fence is stable with and without the outlier
The third assertion is the one that actually tests robustness, and it is the one worth keeping in CI. A fence that moves when you remove the value it was supposed to exclude is not robust, whatever statistic it claims to be built on — run the same assertion against a three-sigma implementation and it fails by an enormous margin.
Failure modes
Every page in one template is flagged
The group is bimodal — usually one template served from two origins, or a locale split where one region is structurally slower. The median lands in the gap between the two populations and neither one is inside the fence. Diagnose by checking whether the group splits cleanly:
g = df[df.template == "product"]["lcp_ms"]
print(g.describe(), "\n gap check:", g.quantile(0.6) - g.quantile(0.4))
A large jump between the 40th and 60th percentiles means two populations. Fix the grouping key rather than the fence.
The flag rate climbs steadily week over week
The fence is being recomputed each run against a distribution that is genuinely degrading, so more real pages fall outside it. This is the fence working correctly and the site getting worse, which is easy to misread as a noisy filter. Compare the fence bounds across runs rather than only the flag count — a rising flag count with a stable fence is a site problem, a rising flag count with a narrowing fence is a data problem.
A flagged page turns out to be genuinely slow
That is the expected outcome of an outlier test, not a defect. The fence identifies values that do not belong to the group's distribution; whether such a value is a broken measurement or a real problem is a separate judgement. Never delete flagged rows — winsorize them so they keep their weight, and route the flag itself into the false-positive review process rather than into a deletion.
FAQ
Why does a three-sigma fence stop catching outliers on latency data?
Because the standard deviation it is built from is computed over the same data the outlier is in, and a single extreme value can be most of it. On a set whose bulk sits between one and two and a half seconds, one 91-second sample can push a three-sigma upper bound past a minute — the outlier defines the boundary it is then tested against. Add a second extreme value and the boundary widens again, so the filter degrades exactly as the data gets worse.
What does the 1.4826 constant do?
It scales the median absolute deviation so it is numerically comparable to a standard deviation for a normally distributed sample. Without it, the fence width parameter is an arbitrary number with no relationship to any other threshold in the pipeline; with it, a K of 3.5 is roughly interpretable as "three and a half sigma, if this data had been normal", which makes the parameter reviewable by someone who has not read the implementation.
What happens when more than half the values in a group are identical?
The median absolute deviation is exactly zero, because the median of the absolute deviations is itself zero, and every value that is not the median then falls outside a zero-width fence. This is common on cached templates that return a constant timing and on synthetic fixtures. Detect it by counting distinct values before computing anything, and either fall back to an interquartile fence or skip outlier testing for that group entirely.
Should a flagged sample be deleted?
No. Winsorize it instead, so the row keeps its weight in the distribution while losing its ability to distort the group. Deleting flagged rows is how a site with a genuinely heavy tail of slow pages reports an excellent p75 — the fence cannot tell a broken measurement from a real problem, and treating every flag as noise systematically removes the worst real pages from the dataset.
Related
- Handling Missing & Outlier Metric Data — the parent guide covering the fixed order of completeness, capping, imputation and scoring
- Percentile Normalization Across Metric Distributions — the reference distribution this fence protects from a single stretched tail
- Identifying False Positives in Automated Audits — where a flagged-but-real measurement should be routed instead of deleted