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 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 (ascending store_id, dt):
| 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 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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
... 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 |
※ 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 (ascending dt):
| 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 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 |
※ 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 (ascending dt):
| 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 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 |
※ 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 (ascending dt):
| 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 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 |
※ 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 (ascending dt):
| 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 | 2 | normal |
| 2024-01-09 | 300 | 1 | 1 | noise |
| 2024-01-10 | 100 | 0 | 2 | normal |
| 2024-01-11 | 310 | 1 | 2 | noise |
| 2024-01-12 | 300 | 1 | 2 | noise |
| 2024-01-13 | 100 | 0 | 1 | normal |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free