Segmented Moving Statistics — Build independent baselines per store with PARTITION BY
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
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.
| store_id | dt | amount |
|---|---|---|
| A | 2024-01-01 | 100 |
| A | 2024-01-02 | 110 |
| A | 2024-01-03 | 105 |
| A | 2024-01-04 | 500 |
| B | 2024-01-01 | 50 |
| B | 2024-01-02 | 55 |
| B | 2024-01-03 | 52 |
| B | 2024-01-04 | 200 |
| store_id | dt | amount | moving_avg | moving_stddev |
|---|---|---|---|---|
| A | 2024-01-01 | 100 | 100.00 | NULL |
| A | 2024-01-02 | 110 | 105.00 | 7.07 |
| A | 2024-01-03 | 105 | 105.00 | 5.00 |
| A | 2024-01-04 | 500 | 238.33 | 226.62 |
| B | 2024-01-01 | 50 | 50.00 | NULL |
| B | 2024-01-02 | 55 | 52.50 | 3.54 |
| B | 2024-01-03 | 52 | 52.33 | 2.52 |
| B | 2024-01-04 | 200 | 102.33 | 84.60 |
SELECT store_id, dt, amount, ROUND(AVG(amount) OVER w, 2) AS moving_avg, ROUND(STDDEV(amount) OVER w, 2) AS moving_stddev FROM store_sales WINDOW w AS ( PARTITION BY store_id -- Keep each store's frame independent ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- Latest three days ) ORDER BY store_id, dt; /* Execution order (logical SQL evaluation order): 1. FROM store_sales → Read the rows 2. WINDOW w (PARTITION/ORDER) → Define the window 3. AVG(amount) OVER w → Evaluate the window function (preserve row count) 4. STDDEV(amount) OVER w → Evaluate the window function (preserve row count) 5. ROUND(..., 2) → Format the values 6. SELECT / ORDER BY → Sort and return the output */
LEGEND
① FROM store_sales (8 rows / 2 stores)
FROM store_salesRead 8 rows for 2 stores (A and B) across 4 days. Store A has the larger sales scale and B the smaller one. The key issue is that these differently scaled series coexist in one table.| store_id | dt | amount |
|---|---|---|
| A | 01-01 | 100 |
| A | 01-02 | 110 |
| A | 01-03 | 105 |
| A | 01-04 | 500 |
| B | 01-01 | 50 |
| B | 01-02 | 55 |
| B | 01-03 | 52 |
| B | 01-04 | 200 |
PARTITION BY preserves the row count and attaches an aggregate value to each row. For anomaly detection, where every detail row needs its store's moving average, a window function with PARTITION BY is the right tool.PARTITION BY store_id, product_id to create independent baselines at the store × product grain. In practice, combine store, product, channel, and weekday to define a precise normal range per segment.WINDOW w AS (...) defines it once and OVER w shares it. This prevents repeated-frame mistakes and makes changes local.OVER (ORDER BY dt), store B's first row follows store A's last row in the table, so A's values enter B's moving-average frame. Always add PARTITION BY when a table contains multiple segments.ORDER BY dt, id to make the order unique.Leakage-Resistant z-Score — Exclude today with a trailing window (n PRECEDING AND 1 PRECEDING)
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
... 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.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.
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 101 |
| 2024-01-03 | 100 |
| 2024-01-04 | 101 |
| 2024-01-05 | 100 |
| 2024-01-06 | 300 |
| 2024-01-07 | 101 |
| 2024-01-08 | 100 |
| dt | amount | base_avg | base_stddev | z_score | anomaly_flag |
|---|---|---|---|---|---|
| 2024-01-01 | 100 | NULL | NULL | NULL | normal |
| 2024-01-02 | 101 | 100.00 | NULL | NULL | normal |
| 2024-01-03 | 100 | 100.50 | 0.71 | -0.71 | normal |
| 2024-01-04 | 101 | 100.33 | 0.58 | 1.15 | normal |
| 2024-01-05 | 100 | 100.50 | 0.58 | -0.87 | normal |
| 2024-01-06 | 300 | 100.40 | 0.55 | 364.42 | anomaly |
| 2024-01-07 | 101 | 140.40 | 89.22 | -0.44 | normal |
| 2024-01-08 | 100 | 140.40 | 89.22 | -0.45 | normal |
WITH base AS ( SELECT dt, amount, AVG(amount) OVER w AS base_avg, STDDEV(amount) OVER w AS base_stddev FROM daily_sales WINDOW w AS ( ORDER BY dt ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING -- Exclude today: one to five days ago ) ) SELECT dt, amount, ROUND(base_avg, 2) AS base_avg, ROUND(base_stddev, 2) AS base_stddev, ROUND( (amount - base_avg) / NULLIF(base_stddev, 0), -- Standardize against the historical baseline 2 ) AS z_score, CASE WHEN ABS((amount - base_avg) / NULLIF(base_stddev, 0)) >= 3 THEN 'anomaly' ELSE 'normal' END AS anomaly_flag FROM base ORDER BY dt; /* Execution order (logical SQL evaluation order): 1. CTE base → Define the CTE (evaluate the historical window) 2. Outer query → Evaluate the z-score and classify anomaly/normal 3. ORDER BY dt → Sort and return the output */
LEGEND
① FROM daily_sales (8 rows)
FROM daily_salesRead 8 rows. The series is stable through 01-05, then a 300 spike appears on 01-06. Recall that the fundamentals question missed this spike.| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 101 |
| 01-03 | 100 |
| 01-04 | 101 |
| 01-05 | 100 |
| 01-06 | 300 |
| 01-07 | 101 |
| 01-08 | 100 |
... AND 1 PRECEDING removes this self-contamination.n PRECEDING (n rows earlier), 1 PRECEDING (one row earlier), CURRENT ROW (today), n FOLLOWING (n rows later), and UNBOUNDED PRECEDING (from the beginning) to create arbitrary windows. For “past only,” end at 1 PRECEDING; for “future only,” start at 1 FOLLOWING.COUNT(*) OVER w >= 3 so that only rows with enough baseline data are classified, suppressing unstable early detections.ROWS BETWEEN 5 PRECEDING AND CURRENT ROW, the spike inflates stddev and makes z smaller. This is a typical source of missed detections. Include today for smoothing or visualization, but exclude it from an anomaly-classification baseline.Robust Anomaly Detection — Build an outlier-resistant score with the median and MAD
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
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.
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 102 |
| 2024-01-03 | 98 |
| 2024-01-04 | 101 |
| 2024-01-05 | 99 |
| 2024-01-06 | 500 |
| 2024-01-07 | 103 |
| 2024-01-08 | 100 |
| dt | amount | median_val | mad_val | mod_z | flag |
|---|---|---|---|---|---|
| 2024-01-01 | 100 | 100.50 | 1.50 | -0.22 | normal |
| 2024-01-02 | 102 | 100.50 | 1.50 | 0.67 | normal |
| 2024-01-03 | 98 | 100.50 | 1.50 | -1.12 | normal |
| 2024-01-04 | 101 | 100.50 | 1.50 | 0.22 | normal |
| 2024-01-05 | 99 | 100.50 | 1.50 | -0.67 | normal |
| 2024-01-06 | 500 | 100.50 | 1.50 | 179.64 | anomaly |
| 2024-01-07 | 103 | 100.50 | 1.50 | 1.12 | normal |
| 2024-01-08 | 100 | 100.50 | 1.50 | -0.22 | normal |
WITH med AS ( -- ① Median over the full period SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median_val FROM daily_sales ), dev AS ( -- ② Absolute deviation from the row's median SELECT s.dt, s.amount, m.median_val, ABS(s.amount - m.median_val) AS abs_dev FROM daily_sales s CROSS JOIN med m -- Expand the one-row median to every row ), mad AS ( -- ③ Median of absolute deviations = MAD SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY abs_dev) AS mad_val FROM dev ) SELECT d.dt, d.amount, d.median_val, m.mad_val, ROUND( (0.6745 * (d.amount - d.median_val) / NULLIF(m.mad_val, 0))::numeric, -- Modified z-score 2 ) AS mod_z, CASE WHEN ABS(0.6745 * (d.amount - d.median_val) / NULLIF(m.mad_val, 0)) > 3.5 THEN 'anomaly' ELSE 'normal' END AS flag FROM dev d CROSS JOIN mad m ORDER BY d.dt; /* Execution order (logical SQL evaluation order): 1. CTE med → Define the CTE (aggregate the median) 2. CTE dev → Define the CTE (evaluate absolute deviations) 3. CTE mad → Define the CTE (aggregate the median deviation) 4. Outer query → Evaluate the modified z-score and classify anomaly/normal 5. ORDER BY → Sort and return the output */
LEGEND
① Sort ascending — Prepare the median
WITHIN GROUP (ORDER BY amount)Sort all 8 rows by amount. The median is the midpoint between the fourth and fifth values. The 500 spike is pushed to the end as the maximum and does not affect the central value.| Rank | dt | amount (ascending) |
|---|---|---|
| 1 | 01-03 | 98 |
| 2 | 01-05 | 99 |
| 3 | 01-01 | 100 |
| 4 | 01-08 | 100 |
| 5 | 01-04 | 101 |
| 6 | 01-02 | 102 |
| 7 | 01-07 | 103 |
| 8 | 01-06 | 500 |
med → dev → mad CTE chain and CROSS JOIN pattern distribute a one-row aggregate result to every row.NULLIF(mad_val, 0), and if needed provide a fallback such as mean absolute deviation or a small floor value.AVG(abs_dev) produces mean absolute deviation (MeanAD) and loses MAD's robustness because the mean is sensitive to outliers. Always calculate MAD with the median, PERCENTILE_CONT(0.5).PERCENTILE_CONT(...) OVER (...)—is expensive, so production often uses all-period batch aggregation (this question) or a segment-level GROUP BY. Q4 follows trends with EWMA, while Q5 detects persistence.EWMA (Exponentially Weighted Moving Average) — Smooth a series by recursively updating the previous value
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;
UNION ALL. JOIN seq ON s.rn = e.rn + 1 carries forward the previous row's ema and repeats until no next rn exists.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.
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 100 |
| 2024-01-03 | 100 |
| 2024-01-04 | 200 |
| 2024-01-05 | 100 |
| 2024-01-06 | 100 |
| dt | amount | ewma |
|---|---|---|
| 2024-01-01 | 100 | 100.00 |
| 2024-01-02 | 100 | 100.00 |
| 2024-01-03 | 100 | 100.00 |
| 2024-01-04 | 200 | 150.00 |
| 2024-01-05 | 100 | 125.00 |
| 2024-01-06 | 100 | 112.50 |
WITH RECURSIVE seq AS ( -- Assign a row number (rn) SELECT dt, amount, ROW_NUMBER() OVER (ORDER BY dt) AS rn FROM daily_sales ), ewma AS ( -- ① Non-recursive term (anchor): first row has ema = amount SELECT rn, dt, amount, amount::numeric AS ema FROM seq WHERE rn = 1 UNION ALL -- ② Recursive term: carry forward the previous ema to calculate the current one SELECT s.rn, s.dt, s.amount, 0.5 * s.amount + 0.5 * e.ema -- α=0.5 FROM ewma e JOIN seq s ON s.rn = e.rn + 1 -- Move to the next row ) SELECT dt, amount, ROUND(ema, 2) AS ewma FROM ewma ORDER BY rn; /* Execution order (logical SQL evaluation order): 1. CTE seq → Define the CTE (assign rn with ROW_NUMBER) 2. CTE ewma (recursive CTE): Evaluate the anchor term (starting point) Recurse (expand children in order) Stop when there are no more rows 3. Outer query → Apply ROUND / ORDER BY and return the output */
LEGEND
① CTE seq — Assign rn with ROW_NUMBER()
ROW_NUMBER() OVER (ORDER BY dt) AS rnAssign rn=1..6 to establish the traversal order for recursion. The recursive term follows this sequence one row at a time with s.rn = e.rn + 1.| rn | dt | amount |
|---|---|---|
| 1 | 01-01 | 100 |
| 2 | 01-02 | 100 |
| 3 | 01-03 | 100 |
| 4 | 01-04 | 200 |
| 5 | 01-05 | 100 |
| 6 | 01-06 | 100 |
FROM ewma e JOIN seq s ON s.rn = e.rn + 1 generates exactly one next row from the last accumulated EWMA row (e). One row is added per iteration; when the next rn is absent, the JOIN is empty and recursion stops naturally.α controls the response speed.amount::numeric makes the decimal calculation in 0.5 * ... type-compatible. A recursive CTE requires matching column types between the anchor and recursive terms, so align the type explicitly in the initial term.e.dt + interval '1 day' approach can stop early or multiply rows. Use ROW_NUMBER() to follow the unique “next row” sequence.s.rn = e.rn + 1 to an inequality or an accidental self-join, rows keep multiplying and PostgreSQL eventually errors at max_recursion. Enforce a design that advances exactly one row per iteration.Detect Consecutive Anomalies — Use gaps & islands to alert only on persistent anomalies, not one-off noise
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
COUNT(*) OVER (PARTITION BY is_high, grp).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.
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 300 |
| 2024-01-03 | 100 |
| 2024-01-04 | 100 |
| 2024-01-05 | 300 |
| 2024-01-06 | 320 |
| 2024-01-07 | 310 |
| 2024-01-08 | 100 |
| 2024-01-09 | 300 |
| 2024-01-10 | 100 |
| 2024-01-11 | 310 |
| 2024-01-12 | 300 |
| 2024-01-13 | 100 |
| dt | amount | is_high | run_length | alert_type |
|---|---|---|---|---|
| 2024-01-01 | 100 | 0 | 1 | normal |
| 2024-01-02 | 300 | 1 | 1 | noise |
| 2024-01-03 | 100 | 0 | 2 | normal |
| 2024-01-04 | 100 | 0 | 2 | normal |
| 2024-01-05 | 300 | 1 | 3 | sustained_anomaly |
| 2024-01-06 | 320 | 1 | 3 | sustained_anomaly |
| 2024-01-07 | 310 | 1 | 3 | sustained_anomaly |
| 2024-01-08 | 100 | 0 | 1 | normal |
| 2024-01-09 | 300 | 1 | 1 | noise |
| 2024-01-10 | 100 | 0 | 1 | normal |
| 2024-01-11 | 310 | 1 | 2 | noise |
| 2024-01-12 | 300 | 1 | 2 | noise |
| 2024-01-13 | 100 | 0 | 1 | normal |
WITH flagged AS ( -- ① Add the high-level flag SELECT dt, amount, CASE WHEN amount >= 200 THEN 1 ELSE 0 END AS is_high FROM daily_sales ), rownums AS ( -- ② Calculate two row numbers (overall and by is_high) SELECT dt, amount, is_high, ROW_NUMBER() OVER (ORDER BY dt) AS rn_all, ROW_NUMBER() OVER (PARTITION BY is_high ORDER BY dt) AS rn_flag FROM flagged ), grouped AS ( -- ③ Calculate the consecutive-run ID (grp) from the row-number difference SELECT dt, amount, is_high, (rn_all - rn_flag) AS grp FROM rownums ), runs AS ( -- ④ Count the length of each island (consecutive run) SELECT dt, amount, is_high, grp, COUNT(*) OVER (PARTITION BY is_high, grp) AS run_length FROM grouped ) SELECT dt, amount, is_high, run_length, CASE WHEN is_high = 1 AND run_length >= 3 THEN 'sustained_anomaly' WHEN is_high = 1 THEN 'noise' ELSE 'normal' END AS alert_type FROM runs ORDER BY dt; /* Execution order (logical SQL evaluation order): 1. CTE flagged → Define the CTE (add the high-level flag) 2. CTE rownums → Define the CTE (assign row numbers) 3. CTE grouped → Define the CTE (identify islands by row-number difference) 4. CTE runs → Define the CTE (count consecutive days per island) 5. Outer CASE → Evaluate the classification 6. ORDER BY dt → Sort and return the output */
LEGEND
① FROM daily_sales (13 rows)
FROM daily_salesRead 13 days of data. Spikes appear as isolated days (01-02, 01-09), three consecutive days (01-05–07), and two consecutive days (01-11–12).| dt | amount |
|---|---|
| 01-01 | 100 |
| 01-02 | 300 |
| 01-03 | 100 |
| 01-04 | 100 |
| 01-05 | 300 |
| 01-06 | 320 |
| 01-07 | 310 |
| 01-08 | 100 |
| 01-09 | 300 |
| 01-10 | 100 |
| 01-11 | 310 |
| 01-12 | 300 |
| 01-13 | 100 |
PARTITION BY is_high, grp and the two-column pair uniquely identifies an island, so only high-level islands are counted correctly.LAG(is_high,1), LAG(is_high,2), … to test “three consecutive days” must be rewritten whenever the run length changes and does not scale. Build islands with gaps & islands and measure their lengths with COUNT.