Channel-level correlation analysis — Compare and rank correlation strength across segments with CORR + GROUP BY
CORR(Y, X) is an ordinary aggregate function, so it can be combined with GROUP BY to calculate a correlation coefficient for every group in one query. Unlike RANK and similar functions, it cannot be used as a window function.
-- Calculate correlation for every group with GROUP BY + CORR SELECT channel, ROUND(CORR(revenue, ad_cost)::numeric, 4) AS corr_coef, REGR_COUNT(revenue, ad_cost) AS n -- Number of valid pairs excluding NULLs FROM channel_sales GROUP BY channel; -- To attach CORR at row level, OVER() is unavailable → use CTE GROUP BY + JOIN -- ABS(CORR) allows positive and negative correlation to be compared on one strength scale
GROUP BY channel, CORR is calculated from all rows within each channel. If you also need weekly correlations, adjust the grain to something like GROUP BY channel, week_group. If REGR_COUNT is smaller than expected, many NULLs may be reducing the reliability of the model.Using the channel_sales table, calculate, for each channel, the Pearson correlation coefficient (to 4 decimal places) between advertising cost (ad_cost) and revenue, the number of valid pairs (n), and a correlation-strength label (strength). Use ABS(CORR) >= 0.9 → 'Strong correlation', >= 0.7 → 'Moderate correlation', and 'Weak correlation' otherwise. Return the rows in descending corr_coef order.
| week | channel | ad_cost | revenue |
|---|---|---|---|
| 1 | Online | 100 | 280 |
| 2 | Online | 200 | 650 |
| 3 | Online | 300 | 780 |
| 4 | Online | 400 | 1100 |
| 5 | Online | 500 | 1250 |
| 6 | Online | 600 | 1640 |
| 1 | Offline | 100 | 500 |
| 2 | Offline | 200 | 420 |
| 3 | Offline | 300 | 680 |
| 4 | Offline | 400 | 550 |
| 5 | Offline | 500 | 820 |
| 6 | Offline | 600 | 700 |
| channel | corr_coef | n | strength |
|---|---|---|---|
| Online | 0.9914 | 6 | Strong correlation |
| Offline | 0.7498 | 6 | Moderate correlation |
Residual analysis — Attach predictions and residuals to every row with REGR_SLOPE + CROSS JOIN to identify outliers
Attaching the regression residual (residual = observed value − predicted value) to each row helps identify rows that deviate substantially from the model, which are candidates for outliers or anomalies. A standard SQL pattern is aggregate in a CTE (one row) → expand to every row with CROSS JOIN → calculate predictions and residuals.
-- ① Aggregate the model parameters into one row in a CTE WITH model AS ( SELECT REGR_SLOPE(Y, X) AS slope, REGR_INTERCEPT(Y, X) AS intercept FROM data_table ), -- ② Expand the parameters to every row with CROSS JOIN → calculate predictions and residuals with_pred AS ( SELECT d.*, ROUND((m.slope * d.X + m.intercept)::numeric, 0) AS predicted, ROUND((d.Y - (m.slope * d.X + m.intercept))::numeric, 0) AS residual FROM data_table d CROSS JOIN model m -- 1 row × N rows = N rows ) SELECT *, ABS(residual) AS abs_residual FROM with_pred ORDER BY abs_residual DESC;
ABS(residual) are outlier candidates. Also check REGR_R2 to see whether outliers are lowering the coefficient of determination.Using the store_data table, build a linear regression model from floor area (floor_sqm) to daily sales (daily_sales) in a CTE. Calculate each store's predicted sales (predicted), residual (residual), and absolute residual (abs_residual). Return the rows in descending abs_residual order and identify the store that is an outlier candidate.
| store_id | floor_sqm | daily_sales |
|---|---|---|
| S1 | 100 | 500 |
| S2 | 200 | 950 |
| S3 | 300 | 1400 |
| S4 | 400 | 1850 |
| S5 | 500 | 2300 |
| S6 | 600 | 2200 |
| S7 | 700 | 3200 |
| S8 | 800 | 3700 |
| store_id | floor_sqm | daily_sales | predicted | residual | abs_residual |
|---|---|---|---|---|---|
| S6 | 600 | 2200 | 2664 | -464 | 464 |
| S8 | 800 | 3700 | 3533 | 167 | 167 |
| S7 | 700 | 3200 | 3099 | 101 | 101 |
| S5 | 500 | 2300 | 2230 | 70 | 70 |
| S4 | 400 | 1850 | 1795 | 55 | 55 |
| S3 | 300 | 1400 | 1361 | 39 | 39 |
| S2 | 200 | 950 | 926 | 24 | 24 |
| S1 | 100 | 500 | 492 | 8 | 8 |
Category-specific regression models + batch prediction — Build models with GROUP BY REGR and apply them to new data at once
Combining GROUP BY with REGR_SLOPE and REGR_INTERCEPT builds an independent regression model for every category in one query. You can then join a new-data table with JOIN to batch-process predictions for multiple scenarios.
-- Build category-specific models in parallel with GROUP BY WITH models AS ( SELECT category, REGR_SLOPE(sales, ad_cost) AS slope, REGR_INTERCEPT(sales, ad_cost) AS intercept, REGR_R2(sales, ad_cost) AS r2 FROM product_data GROUP BY category ) -- Apply each category model to new data with JOIN for batch prediction SELECT f.category, f.future_ad, ROUND((m.slope * f.future_ad + m.intercept)::numeric, 0) AS predicted_sales FROM forecast_input f JOIN models m USING (category);
Using the product_data table, build a regression model from advertising cost (ad_cost) to sales, for each category. Then apply each category's model to the forecast advertising costs (future_ad) in forecast_input, and output predicted sales (predicted_sales), slope, intercept, and coefficient of determination (r2). Return rows in ascending category and future_ad order.
| category | ad_cost | sales |
|---|---|---|
| Electronics | 100 | 300 |
| Electronics | 200 | 720 |
| Electronics | 300 | 1000 |
| Electronics | 400 | 1420 |
| Electronics | 500 | 1660 |
| Fashion | 100 | 220 |
| Fashion | 200 | 300 |
| Fashion | 300 | 480 |
| Fashion | 400 | 490 |
| Fashion | 500 | 680 |
| category | future_ad |
|---|---|
| Electronics | 400 |
| Electronics | 600 |
| Fashion | 400 |
| Fashion | 600 |
| category | future_ad | predicted_sales | slope | intercept | r2 |
|---|---|---|---|---|---|
| Electronics | 400 | 1362 | 3.4200 | -6.00 | 0.9926 |
| Electronics | 600 | 2046 | 3.4200 | -6.00 | 0.9926 |
| Fashion | 400 | 545 | 1.1100 | 101.00 | 0.9513 |
| Fashion | 600 | 767 | 1.1100 | 101.00 | 0.9513 |
Order statistics and outlier detection — Identify IQR-based outliers with PERCENTILE_CONT and Tukey fences
PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY col) is an ordered-set aggregate function that sorts the data in ascending order and returns the value at position p (0–1) by linear interpolation. Q1, the median, and Q3 are obtained with p=0.25, 0.5, and 0.75.
-- Syntax: PERCENTILE_CONT(percentile) WITHIN GROUP (ORDER BY column) -- Linear interpolation: row position = 1 + p×(N−1), e.g. N=12, p=0.25 → position=3.75 PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY score) AS q1 PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY score) AS median PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY score) AS q3 -- PERCENTILE_DISC: return the nearest actual value without interpolation (discrete version) -- Tukey fences (box-plot outlier rule): -- lower_fence = Q1 − 1.5×IQR / upper_fence = Q3 + 1.5×IQR -- Values outside the fences are treated as outliers
GROUP BY category to calculate medians by category at once, but it cannot be used as an OVER() window function. NTILE is an alternative when you need to attach a percentile band to every row, but remember the difference: NTILE divides by row count, whereas PERCENTILE_CONT interpolates by value.Using the exam_results table, calculate IQR, the lower fence, and the upper fence from the first quartile, median, and third quartile in a CTE. Label whether each student's score is within the Tukey fences (lower=Q1−1.5×IQR, upper=Q3+1.5×IQR) with outlier_flag ('Outlier' / 'Normal'). Return the rows in ascending score order.
| student_id | score |
|---|---|
| S01 | 67 |
| S02 | 82 |
| S03 | 55 |
| S04 | 138 |
| S05 | 63 |
| S06 | 80 |
| S07 | 42 |
| S08 | 91 |
| S09 | 71 |
| S10 | 75 |
| S11 | 85 |
| S12 | 58 |
| student_id | score | lower_fence | upper_fence | outlier_flag |
|---|---|---|---|---|
| S07 | 42 | 30.25 | 114.25 | Normal |
| S03 | 55 | 30.25 | 114.25 | Normal |
| S12 | 58 | 30.25 | 114.25 | Normal |
| S05 | 63 | 30.25 | 114.25 | Normal |
| S01 | 67 | 30.25 | 114.25 | Normal |
| S09 | 71 | 30.25 | 114.25 | Normal |
| S10 | 75 | 30.25 | 114.25 | Normal |
| S06 | 80 | 30.25 | 114.25 | Normal |
| S02 | 82 | 30.25 | 114.25 | Normal |
| S11 | 85 | 30.25 | 114.25 | Normal |
| S08 | 91 | 30.25 | 114.25 | Normal |
| S04 | 138 | 30.25 | 114.25 | Outlier |
Moving average — Calculate a 3-month moving average and month-over-month change with a ROWS BETWEEN window frame
Explicitly specifying a window frame with ROWS BETWEEN n PRECEDING AND CURRENT ROW calculates a moving average over the most recent n+1 rows, including the current row.
-- Explicitly specify the moving-average window frame with ROWS BETWEEN AVG(revenue) OVER ( ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS ma3 -- 3-month moving average (latest 3 rows including current month) -- Difference from the default frame: -- ORDER BY alone: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- → cumulative average from the first row through the current month -- ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- → only the latest 3 physical rows (moving average) ← this is the correct frame -- Month-over-month pattern using NULLIF to prevent division by zero (LAG extension) -- (revenue - prev_revenue)::numeric / NULLIF(prev_revenue, 0) * 100
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (a cumulative frame that also includes rows with the same value). To specify “the most recent N physical rows” for a moving average, always write ROWS BETWEEN explicitly. The results differ when the ORDER BY column contains duplicate values.Using the sales_monthly table, attach the previous month's sales (prev_revenue) with LAG in a CTE. In the outer query, calculate both a 3-month moving average (ma3: the latest 3 rows, rounded to an integer) and the month-over-month growth rate (mom_pct: 2 decimal places, with NULLIF to prevent division by zero). Return rows in ascending month order.
| month | revenue |
|---|---|
| 2024-01 | 1200000 |
| 2024-02 | 1350000 |
| 2024-03 | 1180000 |
| 2024-04 | 1520000 |
| 2024-05 | 1680000 |
| 2024-06 | 1450000 |
| 2024-07 | 1820000 |
| 2024-08 | 1960000 |
| month | revenue | ma3 | mom_pct |
|---|---|---|---|
| 2024-01 | 1200000 | 1200000 | NULL |
| 2024-02 | 1350000 | 1275000 | 12.50 |
| 2024-03 | 1180000 | 1243333 | -12.59 |
| 2024-04 | 1520000 | 1350000 | 28.81 |
| 2024-05 | 1680000 | 1460000 | 10.53 |
| 2024-06 | 1450000 | 1550000 | -13.69 |
| 2024-07 | 1820000 | 1650000 | 25.52 |
| 2024-08 | 1960000 | 1743333 | 7.69 |