Anomaly Detection — Learn Rolling z-Scores, Seasonality, and Composite Signals in Practice
AdvancedAnomaly DetectionRolling z-ScoreGaps and IslandsSeasonality / LAGROLLUP / FILTERPostgreSQL Compatible5 questions
QUESTION 6
Rolling z-Score — Measure anomalies against a recent dynamic baseline while preventing leakage and warm-up false positives
ROWS BETWEEN n PRECEDINGExclude current row / leakageRolling z-ScoreDynamic-baseline anomaly detection
Background

Q3 used a fixed mean and standard deviation over the entire period as the z-score baseline. But when a series has a trend or a level shift, a fixed baseline quickly becomes stale. In practice, a moving z-score (rolling z) based on only the "most recent N days" is standard. The two keys are to exclude the current day from the baseline (do not evaluate a value against itself—avoid leakage) and to defer classification until enough baseline data has accumulated (min_periods).

AVG(amount)    OVER (ORDER BY dt ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING)
-- "3 PRECEDING AND 1 PRECEDING" = use only the prior three days, excluding today
-- With "... AND CURRENT ROW", today (the value being evaluated) would enter the baseline
Excluding today is the key to avoiding leakage: Ending the frame at 1 PRECEDING instead of CURRENT ROW keeps today's value out of the baseline calculation. This prevents an anomalous value from inflating its own baseline mean and standard deviation and making itself look normal (an application of Q1's frame specification).
Problem

From sensor_readings, calculate the moving average roll_avg and moving standard deviation roll_std over the prior three days (excluding today), then calculate z =(amount − roll_avg)/roll_std. Rows with fewer than three usable baseline days, n_base<3, must be held as 'warmup'; otherwise, classify |z|≥2 as 'anomaly' and smaller values as 'normal'. Return dt, amount, roll_avg, roll_std, z_score, status in ascending dt order. Round the average, standard deviation, and z-score to two decimal places.

Table
► sensor_readings (9 rows)
dtamount
2024-01-01100
2024-01-0296
2024-01-03104
2024-01-04100
2024-01-0596
2024-01-06200
2024-01-07100
2024-01-0896
2024-01-09104

※ The normal pattern cycles through 96/100/104. 01-06 (200) is the only spike. Its prior three days are always {96,100,104}, giving a stable mean of 100 and SD of 4, so 200 produces the obvious z=25.

Expected Output

Expected output (ascending dt):

dtamountroll_avgroll_stdz_scorestatus
2024-01-01100NULLNULLNULLwarmup
2024-01-0296100.00NULLNULLwarmup
2024-01-0310498.002.832.12warmup
2024-01-04100100.004.000.00normal
2024-01-0596100.004.00-1.00normal
2024-01-06200100.004.0025.00anomaly
2024-01-07100132.0058.92-0.54normal
2024-01-0896132.0058.92-0.61normal
2024-01-09104132.0058.92-0.48normal

01-03 has |z|=2.12, but its baseline contains only two days (n_base<3), so it is held as warmup to suppress a false positive. From 01-06 onward, note that the baseline SD has expanded to 58.92 because the spike contaminates later baselines.

QUESTION 7
Detect Consecutive Anomalies — Use Gaps & Islands to group only runs of N consecutive anomalous days
ROW_NUMBER difference trickDate − row numberGaps & IslandsPersistent-incident detection
Background

A one-off anomaly (point anomaly) means something different from a persistent incident where the anomaly continues for several days. The latter is the issue that usually deserves a response. Gaps & Islands is the classic technique for grouping consecutive rows into one island. Its core property is that the difference from a sequence number stays constant only while rows are consecutive.

-- Sort anomalous days by date and assign a row number...
dt:   01-05  01-06  01-07     01-10  01-11
rn:     1      2      3         4      5
dt-rn: 01-04  01-04  01-04     01-06  01-06   ← the value is constant within each consecutive run
Why "date − row number" becomes the island key: Consecutive dates increase by one day and the row number also increases by one, so their difference remains constant. When there is a gap, the difference shifts and a new island begins. Group by this difference to aggregate each consecutive run (combining Q6's ROW_NUMBER with Q9's GROUP BY/HAVING).
Problem

From service_health (daily error rates), define error_rate > 5 as an anomalous day and extract only runs of at least three consecutive anomalous days (persistent incidents). For each run, calculate start_dt, end_dt, consecutive-day count run_days, and the maximum error rate peak_error. Return rows ordered by start_dt ascending.

Table
► service_health (12 rows)
dterror_rate
2024-01-012
2024-01-028
2024-01-033
2024-01-042
2024-01-059
2024-01-067
2024-01-076
2024-01-081
2024-01-092
2024-01-1010
2024-01-1112
2024-01-123

※ Anomalous days (error_rate>5) are 01-02 / 01-05–07 / 01-10–11. There are three islands: one isolated day, one three-day run, and one two-day run. Filtering for "at least three days" leaves only 01-05–07.

Expected Output

Expected output (ascending start_dt):

start_dtend_dtrun_dayspeak_error
2024-01-052024-01-0739

The isolated 01-02 and the two-day run 01-10–11 are removed by HAVING COUNT(*) >= 3. Only the three-day run 01-05–07 remains as a persistent incident.

QUESTION 8
Seasonal Baseline — Use LAG(amount, 7) to compare the same weekday last week and avoid periodicity traps
LAG(col, n)Offset referenceWeekly seasonalitySeasonality-aware detection
Background

Many metrics have weekly seasonality. Weekdays may be low while weekends are high, for example. Monitoring with a "previous-day comparison" turns normal weekly changes into false alerts every weekend and Monday. The fix is simple: compare with "the same weekday one week ago" instead of yesterday. LAG(amount, 7) retrieves the value seven rows earlier.

LAG(amount, 7) OVER (ORDER BY dt)   -- Seven days earlier (same weekday last week)
LAG(amount)    OVER (ORDER BY dt)   -- If omitted, the second argument defaults to 1 (yesterday)
-- Change the offset to switch the comparison baseline between yesterday and the same weekday last week
The second argument to LAG is the number of rows back: LAG(col, n) returns the value n rows before the current row (n defaults to 1). With daily data ordered by date, seven rows back is the same weekday last week. The first seven days have no comparison value and become NULL, so exclude them from classification as no_baseline.
Problem

From daily_traffic (14 days with high weekend values and weekly seasonality), compare each day's amount with the value seven days earlier and calculate the week-over-week change rate wow_pct =(amount − base_7d)/base_7d×100. Assign 'no_baseline' to the first seven days without a seven-day baseline, 'anomaly' when |wow_pct| exceeds 50%, and 'normal' otherwise. Return dt, amount, base_7d, wow_pct, status in ascending dt order. Round pct to one decimal place.

Table
► daily_traffic (14 rows)
dtamount
2024-01-01100
2024-01-02110
2024-01-03120
2024-01-04130
2024-01-05140
2024-01-06300
2024-01-07310
2024-01-08105
2024-01-09115
2024-01-10125
2024-01-11135
2024-01-12500
2024-01-13305
2024-01-14315

※ Strong weekly cycle: weekdays 100–140 on 01-01 (Mon)–05 (Fri), weekends around 300 on 01-06 (Sat)–07 (Sun). A previous-day comparison would falsely alert on every weekend (+114%) and Monday (−66%). 01-12 (Fri) is a true anomaly: 500 versus 140 on the same weekday last week (+257%).

Expected Output

Expected output (ascending dt):

dtamountbase_7dwow_pctstatus
2024-01-01100NULLNULLno_baseline
2024-01-02110NULLNULLno_baseline
2024-01-03120NULLNULLno_baseline
2024-01-04130NULLNULLno_baseline
2024-01-05140NULLNULLno_baseline
2024-01-06300NULLNULLno_baseline
2024-01-07310NULLNULLno_baseline
2024-01-081051005.0normal
2024-01-091151104.5normal
2024-01-101251204.2normal
2024-01-111351303.8normal
2024-01-12500140257.1anomaly
2024-01-133053001.7normal
2024-01-143153101.6normal

The weekend (01-13: 305 vs. 300 the previous week) is only +1.7% week over week and is normal. Aligning the seasonal cycle removes the false alerts and isolates the genuine 01-12 spike.

QUESTION 9
Composite-Signal Severity — Combine level and sudden-change signals with CASE to classify severity
Multiple CASE branchesBoolean evaluation orderComposite signalsSeverity scoring
Background

Operational alerts need more than a binary "anomaly or not" flag. A common approach is to combine multiple signals and rank events by severity. This question combines two independent perspectives—level (is the value high?) and jump (did it change sharply from the previous day?)—to produce four levels. The keys are the order of CASE branches and using boolean values directly as conditions.

CASE
  WHEN lvl_hi AND jump_hi THEN 'CRITICAL'  -- High level and sharp jump (classify first)
  WHEN lvl_hi             THEN 'WARNING'   -- High level but gradual
  WHEN jump_hi            THEN 'WATCH'     -- Sharp jump but low level
  ELSE 'OK'
END
CASE evaluates from top to bottom and stops at the first true branch: Put the strictest condition (CRITICAL) first. If the order is reversed, rows that qualify as CRITICAL are captured by WARNING first and their severity is understated.
Problem

From metric_stream, calculate two signals: level signal lvl_hi =(amount > 200) and sudden-change signal jump_hi =(amount − previous amount ≥ 100). Combine them with CASE and assign severity as both true → 'CRITICAL' / level only → 'WARNING' / jump only → 'WATCH' / neither → 'OK'. Return dt, amount, delta, severity in ascending dt order, where delta = amount − previous amount.

Table
► metric_stream (8 rows)
dtamount
2024-01-01100
2024-01-02105
2024-01-03215
2024-01-04220
2024-01-05120
2024-01-06235
2024-01-0790
2024-01-08195

※ Level means above 200; a jump means at least +100 from the previous day. 01-03 (215,+110) and 01-06 (235,+115) satisfy both and are CRITICAL. 01-04 (220,+5) is high but gradual → WARNING. 01-08 (195,+105) jumps while remaining below 200 → WATCH.

Expected Output

Expected output (ascending dt):

dtamountdeltaseverity
2024-01-01100NULLOK
2024-01-02105+5OK
2024-01-03215+110CRITICAL
2024-01-04220+5WARNING
2024-01-05120-100OK
2024-01-06235+115CRITICAL
2024-01-0790-145OK
2024-01-08195+105WATCH

Among high-level rows, 01-03 and 01-06 are CRITICAL because they also jump, while gradual 01-04 is WARNING. 01-08 is below 200 but jumps sharply, so it is classified as WATCH as an early signal.

QUESTION 10
Aggregate an Alert Summary — Use multiple FILTERs and GROUP BY ROLLUP to show categories and the grand total in one table
COUNT(*) FILTER × multipleGROUP BY ROLLUPSeverity-level aggregationSummary + totalCapstone
Background

The advanced section closes by building an "alert summary" for operations. Extend Q10 from the fundamentals with COUNT(*) FILTER columns for multiple severity levels, count each severity at once, and use GROUP BY ROLLUP to place category detail rows and one grand-total row in the same result. One query produces a dashboard-ready table.

COUNT(*) FILTER (WHERE amount >= 300)                  -- Critical count
COUNT(*) FILTER (WHERE amount >= 200 AND amount < 300)  -- Warning count
GROUP BY ROLLUP (category)   -- Category rows + one grand-total row (category=NULL)
ROLLUP creates detail plus total in one step: GROUP BY ROLLUP (category) adds one grand-total row to the ordinary category groups. That row has category=NULL, so label it with COALESCE(category,'(ALL)') and control ordering with GROUPING(category).
Problem

From event_log, define amount>=300 as critical and 200≤amount<300 as warning. Aggregate total events, critical count, warning count, and critical rate % by category. Include a grand-total row for all categories with ROLLUP, label that row (ALL), and return category, total_events, critical, warning, crit_pct with category rows ascending and the grand total last. Round pct to one decimal place.

Table
► event_log (12 rows)
categorydtamount
api2024-01-01350
api2024-01-02120
api2024-01-03130
api2024-01-0495
auth2024-01-01260
auth2024-01-02280
auth2024-01-03240
auth2024-01-0490
web2024-01-01100
web2024-01-02250
web2024-01-03300
web2024-01-04320

※ Red=critical(≥300); orange=warning(200–299). api: crit1/warn0, auth: crit0/warn3, web: crit2/warn1. Overall: crit3, warn4.

Expected Output

Expected output (ascending category, grand total last):

categorytotal_eventscriticalwarningcrit_pct
api41025.0
auth4030.0
web42150.0
(ALL)123425.0

The final (ALL) row is the ROLLUP grand total. web is most severe by critical rate at 50%, while auth has three warnings. Detail and overall scope are visible in one table.