Statistical Analysis — Learn Segment Correlation, Residual Analysis, Percentiles, and Moving Averages in Practice
AdvancedStatistics AnalysisCORR + GROUP BYResidual AnalysisPERCENTILE_CONTMoving AveragePostgreSQL Compatible5 questions
QUESTION 6
Channel-level correlation analysis — Compare and rank correlation strength across segments with CORR + GROUP BY
CORRGROUP BYCorrelation AnalysisSegment ComparisonABS / CASE WHEN
Background

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
Watch the aggregation grain of GROUP BY: With 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.
Problem

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.

Source table
► channel_sales (12 rows)
weekchannelad_costrevenue
1Online100280
2Online200650
3Online300780
4Online4001100
5Online5001250
6Online6001640
1Offline100500
2Offline200420
3Offline300680
4Offline400550
5Offline500820
6Offline600700
Expected Output

Expected output (2 rows, descending corr_coef):

channelcorr_coefnstrength
Online0.99146Strong correlation
Offline0.74986Moderate correlation

Online: x̅=350, y̅=950 / Σ deviation product=446,000 / √(175,000×1,156,400)=449,856 → CORR=0.9914 ⁄ Offline: y̅=611.67 / Σ deviation product=103,500 / √(175,000×108,883)=138,038 → CORR=0.7498

QUESTION 7
Residual analysis — Attach predictions and residuals to every row with REGR_SLOPE + CROSS JOIN to identify outliers
REGR_SLOPECROSS JOINResidual AnalysisOutlier DetectionMulti-stage CTE
Background

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;
The sign and meaning of a residual: A positive residual (observed > predicted) means the model underestimates; a negative residual (observed < predicted) means the model overestimates. Rows with a large ABS(residual) are outlier candidates. Also check REGR_R2 to see whether outliers are lowering the coefficient of determination.
Problem

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.

Source table
► store_data (8 rows)
store_idfloor_sqmdaily_sales
S1100500
S2200950
S33001400
S44001850
S55002300
S66002200
S77003200
S88003700
Expected Output

Expected output (8 rows, descending abs_residual):

store_idfloor_sqmdaily_salespredictedresidualabs_residual
S660022002664-464464
S880037003533167167
S770032003099101101
S5500230022307070
S4400185017955555
S3300140013613939
S22009509262424
S110050049288

slope=4.3452, intercept=57.14, R²=0.9678 ⁄ S6: observed 2,200 versus predicted 2,664 (residual −464) — an outlier candidate whose sales are far below expectation for 600m²

QUESTION 8
Category-specific regression models + batch prediction — Build models with GROUP BY REGR and apply them to new data at once
REGR_SLOPEREGR_R2Category RegressionBatch PredictionGROUP BY + JOIN
Background

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);
A simple-regression slope can be interpreted as a category-specific ROI proxy: slope = Δsales / Δad spend is the “incremental sales per unit of ad spend” and a proxy for advertising ROI. A category with a higher slope has a higher advertising return, so slope can support budget allocation. Make a habit of checking R² and making decisions only for categories whose models are reliable.
Problem

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.

Source tables
► product_data (10 rows)
categoryad_costsales
Electronics100300
Electronics200720
Electronics3001000
Electronics4001420
Electronics5001660
Fashion100220
Fashion200300
Fashion300480
Fashion400490
Fashion500680
► forecast_input (4 rows)
categoryfuture_ad
Electronics400
Electronics600
Fashion400
Fashion600
Expected Output

Expected output (4 rows, ascending category and future_ad):

categoryfuture_adpredicted_salesslopeinterceptr2
Electronics40013623.4200-6.000.9926
Electronics60020463.4200-6.000.9926
Fashion4005451.1100101.000.9513
Fashion6007671.1100101.000.9513

Electronics: slope=3.42 (ad spend +100 → sales +342) / Fashion: slope=1.11 (ad spend +100 → sales +111) — advertising ROI is approximately three times higher for Electronics

QUESTION 9
Order statistics and outlier detection — Identify IQR-based outliers with PERCENTILE_CONT and Tukey fences
PERCENTILE_CONTWITHIN GROUPQuartilesOutlier DetectionTukey Fences
Background

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
It can be grouped as an aggregate: PERCENTILE_CONT can be combined with 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.
Problem

Using the exam_results table, calculate IQR, the lower fence, and the upper fence from Q1, the median, and Q3 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.

Source table
► exam_results (12 rows)
student_idscore
S0167
S0282
S0355
S04138
S0563
S0680
S0742
S0891
S0971
S1075
S1185
S1258
Expected Output

Expected output (12 rows, ascending score):

student_idscorelower_fenceupper_fenceoutlier_flag
S074230.25114.25Normal
S035530.25114.25Normal
S125830.25114.25Normal
S056330.25114.25Normal
S016730.25114.25Normal
S097130.25114.25Normal
S107530.25114.25Normal
S068030.25114.25Normal
S028230.25114.25Normal
S118530.25114.25Normal
S089130.25114.25Normal
S0413830.25114.25Outlier

sorted: 42,55,58,63,67,71,75,80,82,85,91,138 / Q1=61.75, median=73.00, Q3=82.75, IQR=21.00 / lower=30.25, upper=114.25 / only S04(138) exceeds the upper fence

QUESTION 10
Moving average — Calculate a 3-month moving average and month-over-month change with a ROWS BETWEEN window frame
ROWS BETWEENAVG OVERMoving AverageTime-series AnalysisLAG + NULLIF
Background

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
Difference between ROWS and RANGE: The default frame for a window function with ORDER BY is 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.
Problem

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.

Source table
► sales_monthly (8 rows)
monthrevenue
2024-011200000
2024-021350000
2024-031180000
2024-041520000
2024-051680000
2024-061450000
2024-071820000
2024-081960000
Expected Output

Expected output (8 rows, ascending month):

monthrevenuema3mom_pct
2024-0112000001200000NULL
2024-021350000127500012.50
2024-0311800001243333-12.59
2024-041520000135000028.81
2024-051680000146000010.53
2024-0614500001550000-13.69
2024-071820000165000025.52
2024-08196000017433337.69

ma3(Jan)=one row=1,200,000 / ma3(Feb)=(1.2M+1.35M)/2=1,275,000 / ma3(Mar)=(1.2M+1.35M+1.18M)/3=1,243,333 / thereafter, a sliding window of the latest 3 rows