AI Knowledge Tree

Statistical anomaly detection

Flag points beyond IQR, ESD, or CUSUM thresholds, the simplest and most defensible AD baselines.

The simplest anomaly detectors are statistical thresholds: flag any point that sits far from where the distribution says it should. IQR and ESD test single observations against a fitted distribution; CUSUM detects when a sequence of observations starts drifting[1]. These methods have one huge advantage over more complex AD: they come with calibrated false-alarm rates.

Tukey's IQR rule

Compute the interquartile range IQR=Q3−Q1\text{IQR} = Q_3 - Q_1. Flag any point outside the fences

[Q1−1.5 IQR, Q3+1.5 IQR].[Q_1 - 1.5\,\text{IQR},\ Q_3 + 1.5\,\text{IQR}].

For a normal distribution this catches roughly 0.7% of points (a 99.3% confidence interval). The constant 1.5 is conventional; 3 is sometimes used to flag only extreme outliers. The rule is nonparametric: it does not assume Gaussianity, just that the bulk of the data is well-summarized by its quartiles.

Z-score and modified Z-score

For roughly normal data:

zi=xi−xˉs,flag if ∣zi∣>3.z_i = \frac{x_i - \bar x}{s}, \qquad \text{flag if } |z_i| > 3.

The fraction of normally-distributed points beyond 3 standard deviations is ~0.27%, giving a controlled false-alarm rate. The catch: xˉ\bar x and ss are themselves contaminated by the outliers you want to find, so a few large outliers can mask each other (masking effect).

Modified Z-score uses median and MAD (median absolute deviation), which are robust to outliers:

zimod=0.6745 (xi−median(x))MAD(x).z_i^{\text{mod}} = \frac{0.6745\,(x_i - \text{median}(x))}{\text{MAD}(x)}.

The 0.6745 makes the modified Z-score consistent with the standard N(0,1)\mathcal{N}(0, 1) for normal data. Threshold at ∣zmod∣>3.5|z^{\text{mod}}| > 3.5.

These two scores are routinely conflated, but they diverge whenever outliers are present, because they estimate scale differently. On the closing block's 200 clean draws plus three planted outliers at 6,−7,86, -7, 8, the three big values inflate the standard deviation to s=1.28s = 1.28, while the MAD-implied scale MAD/0.6745=0.997\text{MAD}/0.6745 = 0.997 stays near the true 11. The point x=6x = 6 then reads as z=4.64z = 4.64 under the contaminated ss but zmod=5.97z^{\text{mod}} = 5.97 under the robust scale: the outlier looks 1.3×1.3\times less extreme to the plain Z-score because it has corrupted its own yardstick. With enough large outliers this gap pushes a true anomaly back under the threshold (the masking effect); the robust estimator does not move, so it keeps flagging it.

Generalized ESD (Rosner's test)

Iterative procedure that explicitly tests for up to kk outliers in a normal sample:

  1. Compute the most extreme residual Ri=max⁡i∣xi−xˉ∣/sR_i = \max_i |x_i - \bar x|/s on the current data.
  2. Compare RiR_i to a critical value λi\lambda_i from the Student-t distribution.
  3. If Ri>λiR_i > \lambda_i, remove the point and repeat. Otherwise stop.

ESD handles masking better than a single Z-score by removing detected outliers and recomputing the mean/std at each round.

CUSUM for change detection

For a sequence of observations, CUSUM (cumulative sum) tracks how far the running sum has drifted from the baseline. Define

St+=max⁡(0, St−1++(xt−μ)−k),St−=min⁡(0, St−1−+(xt−μ)+k),S_t^+ = \max(0,\ S_{t-1}^+ + (x_t - \mu) - k), \qquad S_t^- = \min(0,\ S_{t-1}^- + (x_t - \mu) + k),

with target mean μ\mu and slack kk. Raise an alarm when ∣St±∣|S_t^\pm| exceeds a threshold hh. Calibrated for given (k,h)(k, h), CUSUM has a known average run length between false alarms, making it useful for quality control and time-series monitoring.

When statistical AD is the right tool

  • One-dimensional or low-dimensional continuous data.
  • Need calibrated false-alarm rates (regulated industries, safety monitoring).
  • As a fast baseline before reaching for complicated AD.

Limits

  • One-dimensional methods can miss multivariate anomalies (a point with every feature individually normal but jointly impossible). Multivariate extensions (Mahalanobis distance to fitted Gaussian) work for low dd; beyond that, the next lessons cover scalable methods.
  • Assume the bulk distribution is stationary. Drift over time breaks the threshold.
Threshold-based outlier detection: the IQR fences and the ±3σ z-score band both flag the planted outliers, with the robust IQR fences sitting tighter.
Threshold-based outlier detection: the IQR fences and the ±3σ z-score band both flag the planted outliers, with the robust IQR fences sitting tighter.

Check it on arrays

python · numpy
import numpy as np

rng = np.random.default_rng(0)

# Mostly normal with a few outliers
x = np.concatenate([rng.normal(loc=0, scale=1, size=200),
                    np.array([6.0, -7.0, 8.0])])           # 3 outliers
true_outlier_idx = np.array([200, 201, 202])

# IQR rule
q1, q3 = np.percentile(x, [25, 75])
iqr = q3 - q1
mask_iqr = (x < q1 - 1.5 * iqr) | (x > q3 + 1.5 * iqr)
print(f"IQR rule: flagged {mask_iqr.sum()} (true outliers in flagged: {mask_iqr[true_outlier_idx].sum()})")

# Z-score (sensitive to masking)
z = (x - x.mean()) / x.std()
mask_z = np.abs(z) > 3
print(f"Z-score: flagged {mask_z.sum()} (true in flagged: {mask_z[true_outlier_idx].sum()})")

# Modified Z-score (robust)
med = np.median(x)
mad = np.median(np.abs(x - med))
z_mod = 0.6745 * (x - med) / mad
mask_modz = np.abs(z_mod) > 3.5
print(f"Modified Z: flagged {mask_modz.sum()} (true in flagged: {mask_modz[true_outlier_idx].sum()})")

# CUSUM on a sequence with a level shift in the middle
n = 200
seq = np.concatenate([rng.normal(0, 1, n), rng.normal(0.5, 1, n)])  # mean shifts at t=200
mu, k_slack, h = 0.0, 0.3, 5.0
S_plus = np.zeros(len(seq))
for t in range(1, len(seq)):
    S_plus[t] = max(0, S_plus[t-1] + (seq[t] - mu) - k_slack)
alarm = np.argmax(S_plus > h) if np.any(S_plus > h) else None
print(f"\nCUSUM alarm at t={alarm} (true shift at t=200)")

The IQR and modified Z-score correctly find the planted outliers; the plain Z-score is partially masked by the outliers themselves; CUSUM detects the level shift in the synthetic sequence.

Exercises

Exercise 1. A small clean sample has quartiles Q1=10Q_1 = 10 and Q3=14Q_3 = 14. Compute Tukey's 1.5 IQR1.5\,\text{IQR} fences and decide whether the observations x=3x = 3 and x=19x = 19 are flagged.

Solution

Step 1. The interquartile range is IQR=Q3−Q1=14−10=4\text{IQR} = Q_3 - Q_1 = 14 - 10 = 4.

Step 2. The fence half-width is 1.5 IQR=1.5×4=61.5\,\text{IQR} = 1.5 \times 4 = 6, so the fences are

[Q1−6, Q3+6]=[10−6, 14+6]=[4, 20].[Q_1 - 6,\ Q_3 + 6] = [10 - 6,\ 14 + 6] = [4,\ 20].

Step 3. Compare each point to the interval [4,20][4, 20]. The value x=3x = 3 is below the lower fence 44, so it is flagged. The value x=19x = 19 lies inside the interval, so it is not flagged.

Exercise 2. A standard Z-score flags a point when ∣z∣>3|z| > 3. Show that for a single observation xx in a sample of nn points, ∣z∣|z| can never exceed n−1\sqrt{n-1}, and conclude that with n≤10n \le 10 no point is ever flagged at the ∣z∣>3|z| > 3 rule.

Solution

Step 1. Write zi=(xi−xˉ)/sz_i = (x_i - \bar x)/s with s2=1n−1∑j(xj−xˉ)2s^2 = \frac{1}{n-1}\sum_j (x_j - \bar x)^2 (the sample variance with n−1n-1 in the denominator). One single squared deviation cannot exceed the whole sum, so (xi−xˉ)2≤∑j(xj−xˉ)2=(n−1) s2(x_i - \bar x)^2 \le \sum_j (x_j - \bar x)^2 = (n-1)\,s^2.

Step 2. Divide both sides by s2s^2 and take the square root:

zi2=(xi−xˉ)2s2≤n−1⟹∣zi∣≤n−1.z_i^2 = \frac{(x_i - \bar x)^2}{s^2} \le n - 1 \quad\Longrightarrow\quad |z_i| \le \sqrt{n-1}.

Step 3. For n≤10n \le 10 the bound gives ∣zi∣≤9=3|z_i| \le \sqrt{9} = 3, so no point can satisfy ∣zi∣>3|z_i| > 3. The Z-score rule is structurally blind to outliers in tiny samples: the same outlier that inflates ss caps how large its own score can be. This is the masking effect in its sharpest form.

Exercise 3 (code). Take 200 clean draws from N(0,1)\mathcal{N}(0,1) and inject kk identical outliers at value 66. Show that as kk grows the plain Z-score becomes fully masked (flags none of the planted points), while the MAD-based modified Z-score keeps flagging every one.

Solution
python · numpy
import numpy as np

def flag_modified_z(x, thresh=3.5):
    med = np.median(x)
    mad = np.median(np.abs(x - med))
    z = 0.6745 * (x - med) / mad
    return np.abs(z) > thresh

rng = np.random.default_rng(0)
clean = rng.normal(0, 1, size=200)

# Inject k identical large outliers at value 6 and compare the two scores
for k in [5, 20]:
    x = np.concatenate([clean, np.full(k, 6.0)])
    out_idx = np.arange(200, 200 + k)
    z = (x - x.mean()) / x.std()
    plain = int((np.abs(z) > 3)[out_idx].sum())
    robust = int(flag_modified_z(x)[out_idx].sum())
    print(f"k={k:2d}: plain z flags {plain}/{k} (s={x.std():.3f}), modified z flags {robust}/{k}")

# At k = 20 the plain z-score is fully masked: it flags none of them.
x20 = np.concatenate([clean, np.full(20, 6.0)])
out20 = np.arange(200, 220)
z20 = (x20 - x20.mean()) / x20.std()
assert (np.abs(z20) > 3)[out20].sum() == 0
# The robust modified z-score flags every planted outlier at both levels.
assert flag_modified_z(x20)[out20].all()
print("plain z fully masked at k=20; robust z flags all 20")

At k=5k = 5 both rules flag all five planted points, but the inflated standard deviation (s=1.949s = 1.949 at k=20k = 20) pushes the plain Z-score of value 66 under 33, so it flags 0/200/20. The median and MAD do not move with the contamination, so the modified Z-score flags all 2020.

The next lesson generalizes to high dimensions with Isolation Forest.


References

  1. [1]Chandola, V., Banerjee, A., & Kumar, V. (2009). "Anomaly detection: a survey". ACM Computing Surveys, 41(3), 1-58. doi:10.1145/1541880.1541882. Surveys statistical, distance, and classification-based detectors with their false-alarm trade-offs.↩