SQL Anomaly Detection — Applied Rolling z-Scores, Signals

ADVAnomaly DetectionRolling z-ScoreGaps and IslandsSeasonality / LAGROLLUP / FILTERPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
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

The z-score used a fixed mean and standard deviation over the entire period as its 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 an explicit window-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
Expected Output
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
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 ROW_NUMBER with 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
Expected Output
start_dtend_dtrun_dayspeak_error
2024-01-052024-01-0739
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
Expected Output
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
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
Expected Output
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
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 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
Expected Output
categorytotal_eventscriticalwarningcrit_pct
api41025.0
auth4030.0
web42150.0
(ALL)123425.0