Anomaly Detection — Learn MAD, EWMA, and Consecutive Anomaly Detection in Practice
AdvancedAnomaly DetectionPARTITION BYMAD / Robust StatisticsEWMA / Recursive CTEgaps and islandsPostgreSQL Compatible5 questions
QUESTION 1
Segmented Moving Statistics — Build independent baselines per store with PARTITION BY
PARTITION BYROWS BETWEENSegmented AggregationMoving Statistics
Background

In the fundamentals section we worked with a single series, but real-world data usually mixes multiple segments—stores, products, or users—in one table. If you calculate a moving average across segments, the first row of one store can slip into the frame after the last row of another store, producing a meaningless baseline. PARTITION BY prevents this.

AVG(amount) OVER (
  PARTITION BY store_id   -- Create a wall for each segment
  ORDER BY dt
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)
-- The frame resets independently for each PARTITION BY group
-- Moving averages and standard deviations recalculate at a store boundary
PARTITION BY is a “wall” that splits the window: A window function proceeds as split into groups with PARTITION BY → ORDER BY within each partition → determine the frame with ROWS BETWEEN. A frame never crosses a partition boundary. Unlike GROUP BY, it does not reduce the row count; it attaches an aggregate value to every row.
Problem

From the store_sales table of daily sales by store, calculate a three-day moving average (moving_avg) and moving standard deviation (moving_stddev) separately for each store (store_id). Return store_id, dt, amount, moving_avg, moving_stddev in ascending store_id, dt order. Round to two decimal places.

Table
► store_sales (8 rows / 2 stores × 4 days)
store_iddtamount
A2024-01-01100
A2024-01-02110
A2024-01-03105
A2024-01-04500
B2024-01-0150
B2024-01-0255
B2024-01-0352
B2024-01-04200

※ Store A and store B differ by about 2× in scale. Without PARTITION BY, A's values would contaminate B's baseline and both moving averages would be wrong. The final days (500 / 200) are spikes.

Expected Output

Expected output (ascending store_id, dt):

store_iddtamountmoving_avgmoving_stddev
A2024-01-01100100.00NULL
A2024-01-02110105.007.07
A2024-01-03105105.005.00
A2024-01-04500238.33226.62
B2024-01-015050.00NULL
B2024-01-025552.503.54
B2024-01-035252.332.52
B2024-01-04200102.3384.60

On the first day (01-01) of each store, the partition frame has only one row, so stddev is NULL. On 01-04, the spike causes stddev to expand sharply for both stores. Notice that no B value enters store A's baseline.

QUESTION 2
Leakage-Resistant z-Score — Exclude today with a trailing window (n PRECEDING AND 1 PRECEDING)
ROWS BETWEEN1 PRECEDINGLeakage Preventionz-score
Background

Q3 in the fundamentals section had a weakness called “spike contamination.” If the mean and standard deviation include today, the spike itself pushes up those statistics and makes its own z-score smaller. The practical standard is to build the baseline from a trailing window containing only the past. Then the spike day is compared with an uncontaminated history and can be detected correctly.

AVG(amount) OVER (
  ORDER BY dt
  ROWS BETWEEN 5 PRECEDING
       AND 1 PRECEDING   -- Exclude today (CURRENT ROW)!
)
-- Frame = one to five days ago, at most five rows (today excluded)
-- Today's value does not affect the baseline at all = no leakage
Change the frame end from CURRENT ROW to 1 PRECEDING: The fundamentals version included today with ... AND CURRENT ROW. Changing only the end to 1 PRECEDING creates a pure historical baseline that excludes today. This is the same idea as preventing data leakage in machine learning: never build the detection baseline from the value being tested.
Problem

From the daily_sales table, calculate a z-score using the moving average (base_avg) and moving standard deviation (base_stddev) of the previous five days only (one to five days ago), then set anomaly_flag to 'anomaly' when |z| ≥ 3 and 'normal' otherwise. Return dt, amount, base_avg, base_stddev, z_score, anomaly_flag in ascending dt order. Round base_avg, base_stddev, and z_score to two decimal places.

Table
► daily_sales (8 rows)
dtamount
2024-01-01100
2024-01-02101
2024-01-03100
2024-01-04101
2024-01-05100
2024-01-06300
2024-01-07101
2024-01-08100

※ The 300 on 01-06 is an obvious spike. With the fundamentals window (including today), the spike inflates stddev and is missed at about z≈1.7; with today's value excluded, z becomes much larger and it is detected correctly.

Expected Output

Expected output (ascending dt):

dtamountbase_avgbase_stddevz_scoreanomaly_flag
2024-01-01100NULLNULLNULLnormal
2024-01-02101100.00NULLNULLnormal
2024-01-03100100.500.71-0.71normal
2024-01-04101100.330.581.15normal
2024-01-05100100.500.58-0.87normal
2024-01-06300100.400.55364.42anomaly
2024-01-07101140.4089.22-0.44normal
2024-01-08100140.4089.22-0.45normal

The 01-06 spike is compared with uncontaminated history (avg=100.4, std=0.55), so z=364 is extreme and is detected with certainty. On 01-07 and 01-08, the spike enters the historical window and inflates stddev, temporarily reducing sensitivity (see the column). The first two rows have insufficient frame elements and therefore NULL.

QUESTION 3
Robust Anomaly Detection — Build an outlier-resistant score with the median and MAD
PERCENTILE_CONTMADRobust StatisticsModified z-score
Background

z-scores have a weakness: the mean and standard deviation themselves are pulled toward outliers. When one large spike appears, both statistics inflate and the spike's own z-score becomes smaller, causing it to be missed. This masking effect is addressed with the outlier-resistant median and MAD (median absolute deviation).

-- MAD = median( |x_i - median(x)| )
-- Modified z-score (Iglewicz-Hoaglin):
modified_z = 0.6745 * (x - median) / MAD
-- 0.6745 = the 0.75 quantile of the standard normal distribution
-- |modified_z| > 3.5 is a common outlier guideline
Why the median and MAD resist outliers: The median is the 50th-percentile value, so it is unaffected when fewer than half the data points are outliers (50% breakdown point). The mean has a 0% breakdown point and can fail from a single point. MAD has the same robustness. This is robust statistics: a stable baseline even in contaminated data.
Problem

From the daily_sales table, calculate the all-period median (median_val) and MAD (mad_val), then each day's modified z-score (mod_z = 0.6745×(amount−median)/MAD). Set flag to 'anomaly' when |mod_z| > 3.5 and 'normal' otherwise. Return dt, amount, median_val, mad_val, mod_z, flag in ascending dt order. Round mod_z to two decimal places.

Table
► daily_sales (8 rows)
dtamount
2024-01-01100
2024-01-02102
2024-01-0398
2024-01-04101
2024-01-0599
2024-01-06500
2024-01-07103
2024-01-08100

※ The 500 on 01-06 is an extreme spike. With an ordinary z-score, the mean expands to 150 and the spike's own z≈2.47 stays below |z|≥3, so it is missed (masking). With MAD, mod_z≈179.6 and it is detected with certainty. That is the point of this question.

Expected Output

Expected output (ascending dt):

dtamountmedian_valmad_valmod_zflag
2024-01-01100100.501.50-0.22normal
2024-01-02102100.501.500.67normal
2024-01-0398100.501.50-1.12normal
2024-01-04101100.501.500.22normal
2024-01-0599100.501.50-0.67normal
2024-01-06500100.501.50179.64anomaly
2024-01-07103100.501.501.12normal
2024-01-08100100.501.50-0.22normal

median=100.5 and MAD=1.5 are almost unaffected by the 500 spike (the same all-period values are used for every row). Only 01-06 has mod_z=179.6 and becomes 'anomaly'. Because the spike does not contaminate median/MAD, normal points retain small mod_z values.

QUESTION 4
EWMA (Exponentially Weighted Moving Average) — Smooth a series by recursively updating the previous value
WITH RECURSIVEUNION ALLEWMARecursive Smoothing
Background

A simple moving average (SMA) abruptly “forgets” old values when they leave the window, which can make the smoothed curve jagged. An EWMA (exponentially weighted moving average) gives more weight to recent values and exponentially less weight to older values, following trends smoothly but quickly. Because each EWMA value is updated from the previous EWMA, a recursive CTE (WITH RECURSIVE) is a natural implementation.

-- EWMA recurrence (α = smoothing factor, 0<α≤1)
ema1 = amount1                       -- First term (anchor)
emat = α·amountt + (1−α)·emat−1   -- Recurrence
-- Larger α → emphasize recent values (faster response)
-- Smaller α → emphasize history (smoother)

-- ▼ Basic WITH RECURSIVE syntax ▼
WITH RECURSIVE cte_name AS (
  -- ① Non-recursive term (anchor: the first row)
  SELECT 1 AS rn, initial_value AS val

  UNION ALL

  -- ② Recursive term (reference cte_name to create the next row)
  SELECT c.rn + 1, calculate_next_val(c.val)
  FROM cte_name c
  WHERE c.rn < limit_condition
)
SELECT * FROM cte_name;
WITH RECURSIVE has two parts: A recursive CTE connects a non-recursive term (anchor: the first row) and a recursive term (reference itself to create the next row) with UNION ALL. JOIN seq ON s.rn = e.rn + 1 carries forward the previous row's ema and repeats until no next rn exists.
Problem

From the daily_sales table, calculate an EWMA (ewma) with smoothing factor α=0.5 using a recursive CTE. Return dt, amount, ewma in ascending dt order. Round ewma to two decimal places. The first row's ewma equals amount.

Table
► daily_sales (6 rows)
dtamount
2024-01-01100
2024-01-02100
2024-01-03100
2024-01-04200
2024-01-05100
2024-01-06100

※ The 200 on 01-04 is a spike. EWMA responds quickly to 150 that day, then decays exponentially to 125→112.5. This combination of responsiveness and smooth decay is the difference from SMA.

Expected Output

Expected output (ascending dt):

dtamountewma
2024-01-01100100.00
2024-01-02100100.00
2024-01-03100100.00
2024-01-04200150.00
2024-01-05100125.00
2024-01-06100112.50

ema4 = 0.5×200 + 0.5×100 = 150. ema5 = 0.5×100 + 0.5×150 = 125. ema6 = 0.5×100 + 0.5×125 = 112.5. The spike's influence trails off by half at each step, which is characteristic of EWMA.

QUESTION 5
Detect Consecutive Anomalies — Use gaps & islands to alert only on persistent anomalies, not one-off noise
ROW_NUMBERgaps & islandsConsecutive GroupingNoise Suppression
Background

A one-day threshold breach is often measurement noise or a temporary event. Alerting on every isolated spike causes alert fatigue. In practice, the standard is to notify only when an anomaly continues for N consecutive days. The classic gaps & islands technique (grouping consecutive runs) implements this rule.

-- Identify consecutive groups with the difference of two row numbers
grp = ROW_NUMBER() OVER (ORDER BY dt)
    - ROW_NUMBER() OVER (PARTITION BY is_high ORDER BY dt)
-- Consecutive rows with the same is_high have a constant grp
-- When continuity breaks, grp changes and a new island begins
Why the difference of two row numbers reveals continuity: The overall row number increases by one, and the row number within is_high also increases by one while the condition remains consecutive. Their difference stays constant during a run and changes when the run breaks. Use that difference as the island ID (grp), then measure each island with COUNT(*) OVER (PARTITION BY is_high, grp).
Problem

In the daily_sales table, treat amount ≥ 200 as high level (is_high=1). Set alert_type to 'sustained_anomaly' only for high-level runs lasting at least three days, 'noise' for isolated or two-day high-level runs, and 'normal' otherwise. Return dt, amount, is_high, run_length, alert_type in ascending dt order.

Table
► daily_sales (13 rows)
dtamount
2024-01-01100
2024-01-02300
2024-01-03100
2024-01-04100
2024-01-05300
2024-01-06320
2024-01-07310
2024-01-08100
2024-01-09300
2024-01-10100
2024-01-11310
2024-01-12300
2024-01-13100

※ 01-02 and 01-09 are isolated high-level days (one day) and therefore noise. 01-11–12 is a two-day noise run. 01-05–07 is a three-day high-level run and therefore a sustained anomaly. The goal is to distinguish short events from persistence.

Expected Output

Expected output (ascending dt):

dtamountis_highrun_lengthalert_type
2024-01-0110001normal
2024-01-0230011noise
2024-01-0310002normal
2024-01-0410002normal
2024-01-0530013sustained_anomaly
2024-01-0632013sustained_anomaly
2024-01-0731013sustained_anomaly
2024-01-0810002normal
2024-01-0930011noise
2024-01-1010002normal
2024-01-1131012noise
2024-01-1230012noise
2024-01-1310001normal

01-02, 01-09, and 01-11–12 have run_length below 3 and are excluded as short-term noise. 01-05–07 has run_length=3 and becomes 'sustained_anomaly'. The run_length of normal runs is informational and does not affect the alert.