Curve-Based Discrimination
Uses ROC, AUC, KS, Gini, and related curves to measure how well the model separates positives from negatives across the full score distribution.
Overview
The Curve-Based Discrimination bucket measures how well your model’s scores separate positive from negative outcomes across the full score range, using classic evaluation curves.
This bucket is implemented for binary classification. Multiclass models can be evaluated via one-vs-rest or macro-averaged strategies by defining separate metrics per class.
It uses:
- Receiver Operating Characteristic (ROC) and AUC-ROC
- Gini coefficient
- KS (Kolmogorov–Smirnov) statistic and score
Metrics
All metrics are derived from (fpr, tpr) pairs computed across a grid of thresholds on the positive-class score.
auc_roc
Area under the ROC curve, approximated via the trapezoidal rule:
For adjacent ROC points (fpr_i, tpr_i) and (fpr_{i+1}, tpr_{i+1}):
ΔAUC_i = (fpr_{i+1} − fpr_i) × (tpr_{i+1} + tpr_i) / 2
auc_roc = Σ_i ΔAUC_igini_coefficient
Linear transform of AUC commonly used in risk/scoring domains:
gini_coefficient = 2 × auc_roc − 1ks_statistic
Maximum separation between the cumulative distributions of scores for positives vs negatives:
ks_statistic = max_threshold | CDF_positive(score ≥ t) − CDF_negative(score ≥ t) |ks_score
The threshold value t* at which ks_statistic is attained. This is often used as a candidate operating point.
Data Requirements
{{label_col}}– binary ground truth (0 for negative, 1 for positive){{score_col}}– probability or score for the positive class (continuous){{timestamp_col}}– event timestamp
Base Metric SQL — ROC Components
WITH base AS (
SELECT
{{timestamp_col}} AS event_ts,
{{label_col}} AS label,
{{score_col}} AS score
FROM {{dataset}}
),
grid AS (
SELECT generate_series(0.0, 1.0, 0.01) AS threshold
),
scored AS (
SELECT
time_bucket(INTERVAL '5 minutes', event_ts) AS ts,
g.threshold,
label,
score,
CASE WHEN score >= g.threshold THEN 1 ELSE 0 END AS pred_pos
FROM base
CROSS JOIN grid g
)
SELECT
ts AS ts,
threshold AS threshold,
SUM(CASE WHEN label = 1 THEN 1 ELSE 0 END) AS actual_pos,
SUM(CASE WHEN label = 0 THEN 1 ELSE 0 END) AS actual_neg,
SUM(CASE WHEN pred_pos = 1 AND label = 1 THEN 1 ELSE 0 END) AS tp,
SUM(CASE WHEN pred_pos = 1 AND label = 0 THEN 1 ELSE 0 END) AS fp
FROM scored
GROUP BY ts, threshold
ORDER BY ts, threshold;You can materialize this as {{bucket_5_curve_metrics_daily}}.
Computing AUC-ROC, Gini, and KS
AUC-ROC (Binary Only)
WITH roc_points AS (
SELECT
day,
threshold,
tpr,
fpr,
LAG(fpr) OVER (PARTITION BY day ORDER BY fpr) AS prev_fpr,
LAG(tpr) OVER (PARTITION BY day ORDER BY fpr) AS prev_tpr
FROM {{bucket_5_curve_metrics_daily}}
)
SELECT
day,
SUM(
COALESCE((fpr - prev_fpr) * (tpr + prev_tpr) / 2.0, 0.0)
) AS auc_roc
FROM roc_points
GROUP BY day;Gini Coefficient
SELECT
day,
auc_roc,
2 * auc_roc - 1 AS gini_coefficient
FROM {{bucket_5_auc_metrics}};KS Statistic and Score
WITH roc_points AS (
SELECT
day,
threshold,
tpr,
fpr
FROM {{bucket_5_curve_metrics_daily}}
),
cum AS (
SELECT
day,
threshold,
SUM(tpr) OVER (PARTITION BY day ORDER BY threshold DESC) AS cum_tpr,
SUM(fpr) OVER (PARTITION BY day ORDER BY threshold DESC) AS cum_fpr
FROM roc_points
)
SELECT
day,
MAX(ABS(cum_tpr - cum_fpr)) AS ks_statistic,
FIRST_VALUE(threshold) OVER (
PARTITION BY day
ORDER BY ABS(cum_tpr - cum_fpr) DESC
) AS ks_score
FROM cum
GROUP BY day;Plots
Plot 13 — AUC + Gini Over Time
Uses:
auc_rocgini_coefficient
SELECT
day,
auc_roc,
gini_coefficient
FROM {{bucket_5_auc_gini_metrics}}
ORDER BY day;What this shows
This plot tracks both AUC and its corresponding Gini coefficient over time, providing two familiar views of discrimination strength.
How to interpret it
- Sustained drops in auc_roc or gini_coefficient usually indicate that the model’s ability to separate positives from negatives has degraded.
- If AUC remains stable while business KPIs worsen, the issue may be threshold selection or data mix, not raw score discrimination.
- Gini is often the metric business stakeholders and regulators expect in credit/risk settings, so having both on one chart helps bridge ML and business views.
Plot 14 — KS Statistic Over Time
Uses:
ks_statisticks_score
SELECT
day,
ks_statistic,
ks_score
FROM {{bucket_5_ks_metrics}}
ORDER BY day;What this shows
This plot tracks the maximum separation between positive and negative score distributions (ks_statistic) and where that separation occurs in score space (ks_score).
How to interpret it
- Higher
ks_statisticvalues indicate stronger separation; many credit models target KS in a specific band (e.g., 0.3–0.5). - Changes in
ks_scoretell you where in the score range the separation is happening—if it drifts, your best cut-off point may be moving. - If KS drops while AUC is flat, the problem may be in specific parts of the distribution (e.g., tail behavior) rather than global ranking.
Plot 15A — Combined AUC + KS
Uses:
auc_rocks_statisticks_score
SELECT
a.day,
a.auc_roc,
k.ks_statistic,
k.ks_score
FROM {{bucket_5_auc_gini_metrics}} a
JOIN {{bucket_5_ks_metrics}} k USING (day)
ORDER BY a.day;What this shows
This plot overlays global ranking quality (AUC) with maximum local separation (KS) on the same time axis.
How to interpret it
- When AUC and KS move together, the model’s ranking power is consistently changing.
- Divergence (e.g., AUC flat, KS moving) suggests that certain score regions become more/less separative even though global ranking is unchanged.
- This is an excellent high-level monitoring view for risk committees and model governance reviews.
Plot 15B — KS-Only Variant (optional)
Uses:
ks_statistic
SELECT
day,
ks_statistic
FROM {{bucket_5_ks_metrics}}
ORDER BY day;What this shows
A lightweight KS-only time series focusing on separation strength.
How to interpret it
- Useful when you want a single scalar to monitor drift in discrimination.
- Can be wired into simple guardrails (e.g., “alert if KS falls below 0.25”).
Alternative SQL
WITH scored AS (
SELECT
time_bucket(INTERVAL '5 minutes', {{timestamp_col}}) AS bucket,
{{ground_truth}} AS label,
{{score_col}} AS score
FROM
{{dataset}}
),
-- Total positives/negatives per bucket (for guards & normalization)
bucket_totals AS (
SELECT
bucket,
SUM(CASE WHEN label = 1 THEN 1 ELSE 0 END)::float AS num_pos,
SUM(CASE WHEN label = 0 THEN 1 ELSE 0 END)::float AS num_neg
FROM
scored
GROUP BY
bucket
),
-- Use each distinct score in a bucket as a threshold candidate
thresholds AS (
SELECT DISTINCT
bucket,
score AS threshold
FROM
scored
),
-- Confusion matrix per (bucket, threshold)
metrics_per_threshold AS (
SELECT
t.bucket,
t.threshold,
SUM(
CASE
WHEN s.label = 1 AND s.score >= t.threshold THEN 1
ELSE 0
END
)::float AS tp,
SUM(
CASE
WHEN s.label = 0 AND s.score >= t.threshold THEN 1
ELSE 0
END
)::float AS fp,
SUM(
CASE
WHEN s.label = 0 AND s.score < t.threshold THEN 1
ELSE 0
END
)::float AS tn,
SUM(
CASE
WHEN s.label = 1 AND s.score < t.threshold THEN 1
ELSE 0
END
)::float AS fn
FROM
thresholds t
JOIN scored s
ON s.bucket = t.bucket
GROUP BY
t.bucket,
t.threshold
),
-- ROC points: TPR vs FPR for each threshold
roc_points AS (
SELECT
m.bucket,
m.threshold,
COALESCE(m.tp / NULLIF(m.tp + m.fn, 0), 0) AS tpr,
COALESCE(m.fp / NULLIF(m.fp + m.tn, 0), 0) AS fpr
FROM
metrics_per_threshold m
),
-- Add (0,0) and (1,1) endpoints for each bucket
roc_with_endpoints AS (
SELECT
rp.bucket,
0::float AS fpr,
0::float AS tpr
FROM
roc_points rp
GROUP BY
rp.bucket
UNION ALL
SELECT
rp.bucket,
rp.fpr,
rp.tpr
FROM
roc_points rp
UNION ALL
SELECT
rp.bucket,
1::float AS fpr,
1::float AS tpr
FROM
roc_points rp
GROUP BY
rp.bucket
),
-- Order ROC points by FPR per bucket
roc_ordered AS (
SELECT
bucket,
fpr,
tpr,
ROW_NUMBER() OVER (PARTITION BY bucket ORDER BY fpr) AS rn
FROM
roc_with_endpoints
),
-- Pair each point with the next one (for trapezoids)
roc_pairs AS (
SELECT
r.bucket,
r.fpr AS fpr_curr,
r.tpr AS tpr_curr,
LEAD(r.fpr) OVER (PARTITION BY r.bucket ORDER BY r.rn) AS fpr_next,
LEAD(r.tpr) OVER (PARTITION BY r.bucket ORDER BY r.rn) AS tpr_next
FROM
roc_ordered r
),
-- AUC via trapezoidal rule: sum over (ΔFPR * (TPR_i + TPR_{i+1}) / 2)
auc_per_bucket AS (
SELECT
bucket,
COALESCE(
SUM(
CASE
WHEN fpr_next IS NULL THEN 0
ELSE (fpr_next - fpr_curr) * (tpr_next + tpr_curr) / 2.0
END
),
0
) AS auc_roc
FROM
roc_pairs
GROUP BY
bucket
),
-- KS statistic: max |TPR - FPR| over ROC curve
ks_per_bucket AS (
SELECT
bucket,
MAX(ABS(tpr - fpr)) A
Updated 1 day ago