Skip to content
ML Academy

Core day 13 of 60 · Week 3 · Part I · application depth

Classification in Practice

A no-show classifier keeps its ROC-AUC near 0.77 when no-shows fall from 12% to 1% of bookings, yet almost every call-back it triggers is wasted. Which threshold should it use, which metric describes the call list honestly, and what does class weighting or SMOTE do to the probabilities you apply that threshold to?

By the end: You derive a threshold from a cost matrix, show ROC-AUC staying flat while average precision and precision at a fixed recall fall with prevalence, and correct the probabilities of a class-weighted model before acting on them.

At a glance

Rank with the model, price the errors, cut where acting pays, and undo any class re-weighting before you trust a probability.

  • Cut from costs

    Flag when p exceeds C_FP / (C_FP + C_FN); here 5 / 85 = 0.059, not 0.5.

  • AUC hides rarity

    ROC-AUC ignores prevalence; precision and AP fall toward the base rate.

  • Report the list

    Precision, AP and EUR per 1,000 cases at the real prevalence.

  • Weights shift p

    Class weights and SMOTE inflate probabilities; prior-correct or recalibrate first.

  • Tune on validation

    Resample inside training folds, pick the cut on validation, score the test block once.

Recall · 5 min

Without notes: a screen catches 95% of fraud with a 2% false-alarm rate and fraud is 1 in 500 payments. What share of alerts is fraud (Day 3)? Then write the Day 9 rule for where resampling or encoding may be fit.

Open due reviews

Sign in to record this block.

Concept · 10 min

A classifier hands you a ranking and a probability for each case; an action needs a cut. Rarity leaves the ranking (ROC-AUC) almost untouched but fills every cut with false alarms (Day 3). So the cut is a cost decision, and anything that re-weights the classes changes the probabilities you cut.

ROC-AUC stays almost flat as positives get rare, while average precision and precision fall towards the prevalence

Predict first

Prevalence falls from 10% to 1% and the score distributions of both classes stay exactly the same. What happens to precision at a fixed threshold?

Text description of the figure

A prevalence slider from 0.5% to 50% with class separation, threshold, positive class weight and false-positive and false-negative costs. Charts show ROC-AUC and average precision against prevalence, the score histograms of both classes with the threshold, the precision-recall curve and the ROC curve. Readouts give precision, recall, total cost, the lowest-cost threshold, the cost-based threshold and the mean predicted probability against the prevalence.

From a score to a call-back Diagram
Model scoreone per bookingCalibrated?RecalibratePlatt, isotonic or theprior odds factorp > C_FP/(C_FP+ C_FN)?Do not callFits today'scapacity?Call backCall the top kthe k-th score is theday's cutyesnothennoyesyesno
  1. Model scoreone per booking
  2. Calibrated?
    • yes: to p > C_FP/(C_FP + C_FN)?
  3. RecalibratePlatt, isotonic or the prior odds factor
  4. p > C_FP/(C_FP + C_FN)?
    • yes: to Fits today's capacity?
  5. Do not call
  6. Fits today's capacity?
    • no: to Call the top k
  7. Call back
  8. Call the top kthe k-th score is the day's cut

Calibrated means calibrated at the real prevalence. Costs decide whether a call is worth making; capacity decides which worthwhile calls are made first.

Three numbers, three questions Comparison
CriterionROC-AUCAverage precisionCost per 1,000 cases
Built fromTPR against FPR, each computed within one classPrecision against recall, mixing both classesThe cost matrix applied at the chosen cut
Moves with prevalenceHardly: 0.771 to 0.761 in today's runYes: 0.348 to 0.046 in today's runYes, and it is the decision's unit
Score of a useless model0.5 (the diagonal)The prevalenceFlag nobody: C_FN times prevalence per case
AnswersHow well are positives ranked above negatives?How clean is the list I would act on?Is acting on this model worth more than not acting?

Quote ROC-AUC for ranking quality, AP or precision for the list, and money for the decision.

Same scores, rarer positives - ROC stays, precision-recall sinks Illustration
ROC at 20%, 5% and 1% positives00.250.50.75100.250.50.751false-positive ratetrue-positive raterandom: 0.5all three prevalences, one curve

ROC-AUC 0.754, 0.753, 0.734 against 0.760 in theory for these two distributions: the curves coincide up to the sampling noise of only 61 positives at 1%. ROC uses rates within each class, so the mix of classes never enters.

Precision-recall, same scores00.250.50.75100.250.50.751recallprecision20% positives5% positives1% positives

Average precision 0.454, 0.175, 0.035. Each PR curve sinks towards its own floor (dashed), the prevalence: at 1% the list is mostly false alarms at every recall.

  • 20% positives (1,500 among 6,000)
  • 5% positives (316 among 6,000)
  • 1% positives (61 among 6,000)

Synthetic scores: negatives from a standard normal, positives one standard deviation higher, identical at every prevalence. Curves and metrics computed on the page with the same code as the imbalance lab.

Only the number of positives changes between the curves. Today's run shows the same pattern (AP 0.348 to 0.046, ROC-AUC 0.771 to 0.761).

Three models at 1% prevalence, cut at 0.059 (computed) Table
ModelMean pShare flaggedBrier score
Unweighted logistic regression0.0101.3%0.0097
Balanced weights, raw0.39198.0%0.195
Balanced weights, prior-corrected0.0101.2%0.0097

Same test block as the application script (actual rate 0.010). The correction recovers the unweighted model; the weights bought nothing.

Key terms: cost matrix, base rate, average precision, SMOTE, prior correction and label shift. The pictures above carry the mechanism; the optional technical depth below has the derivations.

  • Day 2 card at booking grain: call back a likely no-show so the seat can be resold; baseline flag nobody; EUR per 1,000 bookings; EUR 5 per wasted call, EUR 80 per unflagged empty seat (assumed).
  • Flag when p>CFP/(CFP+CFN)=5/85≈0.059p > C_{FP}/(C_{FP}+C_{FN}) = 5/85 \approx 0.059, for calibrated probabilities.
  • Weight or resample only inside training folds, and correct the odds to the deployment prior before any threshold.

Assumptions

  • Costs are known, stable and the same across segments; a different cost per claim type, customer tier or fare class needs its own threshold.

  • Probabilities are calibrated at the deployment prevalence, or have been corrected to it (Day 16 checks calibration).

  • Decisions are independent; with a fixed call capacity the rule becomes "top k", and the marginal case sets the effective threshold.

Sign in to record this block.

Application and workplace decision · 10 min

  • The script builds 300,000 synthetic bookings in time order and trains on the first 70% (Day 9 boundary).
  • It keeps the signal fixed, thins the positives to 12%, 3% and 1%, fits a logistic regression at each, applies the cost threshold and prices the result.
  • A last block fits a class-weighted model at 1% and corrects its probabilities.
  • Predict first: which of ROC-AUC, AP and precision at recall 0.8 changes by more than a factor of two?

Python

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, precision_recall_curve, roc_auc_score

rng = np.random.default_rng(13)
n = 300_000                                       # synthetic bookings in booking-date order (Day 9 boundary: first 70% train)
X = rng.normal(size=(n, 3))
y = rng.random(n) < 1 / (1 + np.exp(-(X @ [0.9, 0.6, 0.4] - 2.0)))      # about 16.5% no-shows; at_prevalence() thins them
cut = int(0.7 * n)

def at_prevalence(idx, prev):                     # keep all negatives, thin the positives: same signal, lower prevalence
    pos, neg = idx[y[idx]], idx[~y[idx]]
    return np.r_[neg, pos[: int(prev / (1 - prev) * len(neg))]]

c_fp, c_fn = 5.0, 80.0                            # EUR: call-back to a passenger who shows / an unflagged empty seat (assumed)
t_star = c_fp / (c_fp + c_fn)                     # act when p exceeds C_FP / (C_FP + C_FN), for calibrated p
print(f"cost-matrix threshold {t_star:.3f}, not 0.5")
for prev in (0.12, 0.03, 0.01):
    tr, te = at_prevalence(np.arange(cut), prev), at_prevalence(np.arange(cut, n), prev)
    p = LogisticRegression().fit(X[tr], y[tr]).predict_proba(X[te])[:, 1]
    prec, rec, _ = precision_recall_curve(y[te], p)
    flag, yt = p >= t_star, y[te]
    cost = (c_fp * (flag & ~yt).sum() + c_fn * (~flag & yt).sum()) / len(te) * 1000
    print(f"prevalence {yt.mean():5.1%}: ROC-AUC {roc_auc_score(yt, p):.3f} | AP {average_precision_score(yt, p):.3f} | "
          f"precision at recall 0.8 {prec[rec >= 0.8].max():.3f} | EUR per 1,000 {cost:,.0f} vs flag nobody {c_fn * yt.mean() * 1000:,.0f}")

# class weights change the probabilities, not the ranking: correct them before applying the cost threshold
tr, te = at_prevalence(np.arange(cut), 0.01), at_prevalence(np.arange(cut, n), 0.01)
raw = LogisticRegression(class_weight="balanced").fit(X[tr], y[tr]).predict_proba(X[te])[:, 1]
odds = raw / (1 - raw) * y[tr].mean() / (1 - y[tr].mean())      # undo the weight (1 - prevalence) / prevalence
fixed = odds / (1 + odds)
print(f"1% prevalence, balanced weights: mean p {raw.mean():.3f} raw vs {fixed.mean():.3f} corrected (actual {y[te].mean():.3f}); "
      f"share flagged {np.mean(raw >= t_star):.0%} raw vs {np.mean(fixed >= t_star):.1%} corrected; ROC-AUC {roc_auc_score(y[te], raw):.3f}")

This code is executed in the repository's checks (pnpm lesson:py / lesson:sql). It is not run on this site; copy it into your notebook.

Sign in to record this block.

Mini-assessment · 8 min

Your first answer is your evidence. You can re-answer later in practice mode; that is labelled practice.

Question 1 · easy

Positives fall from 12% to 1% of cases, but the score distributions of positives and negatives stay exactly the same. What happens to ROC-AUC and to average precision?
Options

How confident are you? (optional, used for calibration)

Question 2 · medium

A fraud model's probabilities are calibrated at deployment prevalence. Reviewing a legitimate payment costs EUR 12 of analyst time; letting a fraudulent one through costs EUR 90. Correct decisions cost nothing. At what predicted probability of fraud should a payment be sent for review?
probability

How confident are you? (optional, used for calibration)

Question 3 · hard

A churn team balances its data to 50/50 with SMOTE, then runs 5-fold cross-validation on the balanced table. CV average precision is 0.62. In production from the first week, where 1.5% of customers churn, average precision is 0.08 and the mean predicted probability is 0.4. What is the most likely explanation and the fix?
Options

How confident are you? (optional, used for calibration)

Question 4 · medium

A no-show model was trained and calibrated when 1% of bookings were no-shows. It gives one booking a probability of 0.10. After a schedule change the deployment prevalence is 5%, while the distribution of the features within each class is unchanged (label shift). What probability should this booking receive at 5% prevalence? Give two decimals.
probability

How confident are you? (optional, used for calibration)

Question 5 · hard

The call-back team can phone 500 passengers a day. With calibrated probabilities and the cost threshold of 0.059, about 300 of 20,000 bookings pass on a normal day, but 1,400 pass on a peak day. What should the policy do on the peak day?
Options

How confident are you? (optional, used for calibration)

Sign in to complete lessons and keep your progress.

Technical depth · optional · 28 min in total

Open only what you need. Nothing here is required to complete the day; the assessment covers the core above.

The confusion matrix and its rates, by hand6 minFour cells on 200 claims pushed through every rate by hand, then the same table from today's run, and why the cost-optimal cut can have the worst accuracy.
A confusion matrix by hand: 200 insurance claims Grid
Actual class →
Predicted ↓Fraud (20)Legitimate (180)
Flagged (30)1218
Not flagged (170)8162

A hypothetical claims book: 10% fraudulent, 30 claims flagged. Cells: TP 12, FP 18, FN 8, TN 162. Every rate below is a ratio of these four cells; all values verified with scikit-learn.

Every rate from the same four cells Table
RateFormula with the cellsValue
Recall (TPR, sensitivity)TP / (TP + FN) = 12 / 200.600
Specificity (TNR)TN / (TN + FP) = 162 / 1800.900
False-positive rateFP / (FP + TN) = 18 / 1800.100
Precision (PPV)TP / (TP + FP) = 12 / 300.400
Negative predictive valueTN / (TN + FN) = 162 / 1700.953
Accuracy(TP + TN) / 200 = 174 / 2000.870
F12TP / (2TP + FP + FN) = 24 / 500.480
Balanced accuracy(recall + specificity) / 20.750

Recall, specificity and FPR each live inside one column, so prevalence cannot move them. Precision, NPV and accuracy mix the columns and travel with the 10% base rate (Day 3).

Confusion matrix at the cost threshold, per 1,000 bookings (12%) Grid
Actual →
Predicted ↓No-show (120)Shows (880)
Flagged (625.5)107.7517.8
Not flagged (374.5)12.3362.2

Computed from the script's 12% block: 107.7 true positives, 517.8 false positives, 12.3 false negatives, 362.2 true negatives. The cost bridge below prices them.

From flagging nobody to the cost-threshold policy, per 1,000 bookings Bridge
  1. Flag nobody: 120 empty seats9,600 EUR
  2. 107.7 seats resold80 x 107.7 no longer lost−8,616 EUR
  3. 517.8 needless calls5 x 517.8+2,589 EUR
  4. Cost at the cut (12.3 misses left)3,573 EUR

The 12.3 missed no-shows still cost 80 x 12.3 = EUR 984; with the calls that gives EUR 3,573, against 9,600 for flagging nobody.

Every rate is a ratio of two cells Table
RateFormulaValue here
Recall (TPR, sensitivity)TP / (TP + FN)0.897
False-positive rateFP / (FP + TN)0.588
SpecificityTN / (TN + FP)0.412
PrecisionTP / (TP + FP)0.172
Accuracy(TP + TN) / all0.470

ROC uses the first two (within-class rates); precision mixes the columns, so it depends on prevalence.

The threshold from a cost matrix5 minWrite the four costs, derive the break-even probability step by step, and see the cost curve bottom out exactly there.
The cost matrix behind the threshold (assumed costs) Table
ActionPassenger showsPassenger is a no-show
Call back (flag)EUR 5: false positiveEUR 0: true positive, seat resold
Do not callEUR 0: true negativeEUR 80: false negative, seat flies empty
Where acting starts to pay Formula
p CFN>(1−p) CFP  ⟺  p>CFPCFP+CFN=585≈0.059p\,C_{FN} > (1-p)\,C_{FP} \iff p > \frac{C_{FP}}{C_{FP}+C_{FN}} = \frac{5}{85} \approx 0.059
pp
calibrated probability that this booking is a no-show
CFPC_{FP}
cost of acting on a negative: a needless call, EUR 5
CFNC_{FN}
cost of missing a positive: an empty seat, EUR 80
cTP,cTN≠0c_{TP}, c_{TN} \neq 0
use differences: (C_FP - c_TN) and (C_FN - c_TP) in place of the two costs

The threshold depends on costs only; prevalence enters through the calibrated probability itself.

Deriving the break-even cut (the Bayes rule for a cost matrix) Sequence
  1. Price both actions

    For a case with probability p: flagging costs (1 - p) C_FP in expectation, leaving it costs p C_FN.

  2. Compare

    Act exactly when acting is cheaper: p C_FN > (1 - p) C_FP.

  3. Solve for p

    p (C_FN + C_FP) > C_FP, so flag when p > C_FP / (C_FP + C_FN).

  4. Non-zero correct costs

    Replace the two costs by the differences (C_FP - c_TN) and (C_FN - c_TP); the algebra is unchanged.

  5. Why it is optimal

    Deciding every case by its own expected cost minimizes the total expected cost: no other rule does better.

  6. The catch

    Optimal only for the true (calibrated) p. A weighted or resampled model's p must be corrected first.

The same derivation with costs of 10 and 40 gives 10/50 = 0.2; with equal costs it collapses to 0.5, the only case in which 0.5 is the right cut.

The cost curve bottoms out at the cost-matrix threshold Illustration
Scores of each class (share within class)cost cut 0.059default 0.5showsno-shows00.10.20.30.40.50.6predicted probability (threshold)

Both classes overlap heavily: most no-shows (red) score below 0.5, so a 0.5 cut calls almost nobody. Any cut trades calls against empty seats.

EUR per 1,000 bookings against the cut02,0004,0006,0008,000flag nobodyempty seat EUR 80: min at 0.059empty seat EUR 20: min at 0.20000.10.20.30.40.50.6predicted probability (threshold)

Expected cost bottoms out where theory says: empirical minimum at 0.039 (theory 0.059) and 0.179 (theory 0.200). Cheaper misses move the cut right; 0.5 costs EUR 8,516 vs 3,524 per 1,000 under the first matrix.

Synthetic bookings: logistic regression fitted on 6,000 rows, scored on 8,000 fresh rows (11.8% no-shows). Costs are assumed. Upper curve: call EUR 5, empty seat EUR 80; lower curve: empty seat EUR 20. Dots mark the theoretical cut C_FP / (C_FP + C_FN); dotted lines show the cost of flagging nobody.

Fresh rows from a calibrated logistic model. Change the cost of a miss and the cheapest cut moves with C_FP / (C_FP + C_FN).

Where the cut comes from depends on what you can trust Hierarchy
calibrated pranking onlycosts differ by segmentValidation scorescosts agreed, prevalenceknownCut at C_FP / (C_FP +C_FN)5 / 85 = 0.059 hereCheapest scanned cuton validation rowsOne cut per segmentclaim type, risk tier,fare class
  • Validation scorescosts agreed, prevalence known
    • calibrated pCut at C_FP / (C_FP + C_FN)5 / 85 = 0.059 here
    • ranking onlyCheapest scanned cuton validation rows
    • costs differ by segmentOne cut per segmentclaim type, risk tier, fare class

Each leaf ends in a threshold that the untouched test block then scores once. A fixed daily capacity adds a ranking step on top (decision flow in the Concept block).

Precision, recall, PR versus ROC, average precision4 minWhy ROC ignores prevalence, why precision cannot, and how AP is computed and read against its floor.
Precision as a function of prevalence Formula
precision=TPR πTPR π+FPR (1−π)\text{precision} = \frac{\text{TPR}\,\pi}{\text{TPR}\,\pi + \text{FPR}\,(1-\pi)}
π\pi
prevalence (share of positives)
TPR,FPR\text{TPR}, \text{FPR}
fixed by the threshold and the class score distributions
FPR (1−π)\text{FPR}\,(1-\pi)
false alarms; they come from the large negative class and dominate as pi falls
Precision at TPR 0.8 and FPR 0.1, as positives get rare Chart
00.250.50.7511020304050Precision — Prevalence (%): 0.5, Precision: 0.039Precision — Prevalence (%): 1, Precision: 0.075Precision — Prevalence (%): 2, Precision: 0.14Precision — Prevalence (%): 5, Precision: 0.296Precision — Prevalence (%): 10, Precision: 0.471Precision — Prevalence (%): 20, Precision: 0.667Precision — Prevalence (%): 30, Precision: 0.774Precision — Prevalence (%): 50, Precision: 0.889Prevalence (%)Precision

Computed from the formula above. The classifier is identical at every point; only the mix of cases changes.

ROC-AUC and average precision by hand: seven patients Table
StepWorkingValue
Scoresreadmitted within 30 days: 0.9, 0.6, 0.4; not readmitted: 0.7, 0.3, 0.2, 0.13 positives, 4 negatives
All positive-negative pairs3 x 412 pairs
Pairs ranked correctly0.9 beats 4 negatives, 0.6 beats 3, 0.4 beats 310 of 12
ROC-AUCshare of pairs where the positive scores higher (ties count half)10 / 12 = 0.833
Same number, namedMann-Whitney U / (positives x negatives): only the order of scores enters, never their scale10 / 12
Ranked listsort by score, read the labels from the topP N P P N N N
Precision at each positivepositives so far / rows so far1/1, 2/3, 3/4
Average precisionmean of those precisions, one per positive(1 + 0.667 + 0.750) / 3 = 0.806
Floorsa random ranking: AUC 0.5, AP about the prevalence0.5 and 3/7 = 0.43

Hypothetical triage scores; scikit-learn's roc_auc_score and average_precision_score return 0.833 and 0.806. Add 40 negatives that score below every positive: AUC rises to 0.985 while AP stays at 0.806, because precision only counts negatives ranked above a positive.

Average precision (area under the PR curve, step-wise) Formula
AP=∑n(Rn−Rn−1) Pn\mathrm{AP} = \sum_{n} (R_n - R_{n-1})\, P_n
Pn,RnP_n, R_n
precision and recall at the n-th threshold, moving down the ranked list
Rn−Rn−1R_n - R_{n-1}
the recall gained at that step
floorfloor
a random ranking scores about the prevalence, not 0.5

scikit-learn's average_precision_score uses this step-wise sum; linear interpolation between PR points would be optimistic. One curve dominates another in ROC space exactly when it dominates in PR space (Davis and Goadrich, 2006), so the two disagree only about how much a gain is worth.

Class imbalance: weights, resampling and SMOTE4 minWhat each remedy does to the loss, the probabilities and your evaluation, and how SMOTE leaks.
Four ways to handle a rare class Comparison
CriterionCost threshold onlyClass weightsRandom undersamplingOversampling or SMOTE
What it doesTrain on data as given; apply the cost matrix to calibrated pEach positive counts more in the loss (balanced: n / (2 n_class))Drop negatives until classes balanceCopy positives, or interpolate new ones between neighbours
Probabilities afterwardsStay at the real prevalenceInflated toward a balanced worldInflated; correct with the sampling rateInflated (0.406 mean vs 0.021 real in the lab)
Main caveatNeeds calibrated scores and agreed costsPrior-correct or recalibrate before any cutThrows away real rows; noisier modelNo new information; leaks if done before the split

Elkan's framework prefers the first column, so ask first whether it already does the job; the others are for learners that cannot take costs or weights.

How SMOTE makes a synthetic positive Sequence
  1. Pick a positive

    Choose a minority-class row at random.

  2. Find its neighbours

    Its k nearest minority neighbours (k = 5 in the paper and in imbalanced-learn).

  3. Pick one neighbour

    Choose one of the k at random.

  4. Interpolate

    New row = row + u x (neighbour - row), with u uniform in [0, 1].

  5. Repeat

    Until the requested class ratio is reached; then fit the model.

  6. Where it misleads

    With overlapping classes or many dimensions, the segment can cut through regions that belong to the majority class.

Every synthetic row lies on a segment between two real positives, so it adds no information beyond them.

SMOTE before or inside the folds (hands-on lab) Chart
  • SMOTE before CV (leak)neighbours of validation rows sat in training0.906 AP
  • SMOTE inside each foldhonest0.078 AP
  • Constant score (floor)the prevalence0.021 AP

Values printed by the lab script on 6,000 synthetic bookings with 2.1% no-shows; validation rows are all original. Resample inside each training fold only: imbalanced-learn's Pipeline does it during fit.

Prior correction and label shiftadvanced3 minMove probabilities from one class prior to another, and what to do when the deployment prevalence drifts.
Prior correction (label shift or re-weighting) Formula
p1−p=p~1−p~⋅π1−π⋅1−π~π~\frac{p}{1-p} = \frac{\tilde p}{1-\tilde p}\cdot\frac{\pi}{1-\pi}\cdot\frac{1-\tilde\pi}{\tilde\pi}
p~\tilde p
the model's probability under the training (or weighted) prior
π~\tilde\pi
effective training prior; 0.5 with balanced weights, so the last factor is 1
π\pi
deployment prevalence
pp
corrected probability; valid if p(x given y) is unchanged

Work on the odds, not the probability. The ranking is unchanged; the probabilities and the flagged count are not.

When the prevalence moves, the cut must be re-derived Loop
label shift:p(x | y) stays,p(y) moves1Monitor the flaggedrateand the score mix;…2Estimate the newprevalenceEM on the predictions…3Correct the oddsthe prior correctionabove; ranking unchanged4Re-derive theflagged countand precision at…rate driftsnew πre-cutdeploy
  1. Monitor the flagged rateand the score mix; labels arrive laterate drifts ↓
  2. Estimate the new prevalenceEM on the predictions or a confusion matrixnew π ↓
  3. Correct the oddsthe prior correction above; ranking unchangedre-cut ↓
  4. Re-derive the flagged countand precision at the operating pointdeploy ↓
  5. ↺ back to 1 · Monitor the flagged rate

Saerens et al. estimate the new prior by EM on the model's predictions; Lipton et al. use the confusion matrix, which works even with a biased predictor. Only the odds factor moves under label shift; if p(x | y) moved too, recalibrate on fresh labels.

Calibration: reliability diagrams, Platt, isotonic, Brier4 minHow to see miscalibration, how to measure it with a proper score, and which calibrator to fit.
Class weights bend the probabilities; one odds factor bends them back Illustration
Reliability, full scale00.5100.51perfect calibrationbalanced weights, rawmean predicted probability (bin)observed no-show rate

The weighted model believes no-shows are common: mean probability 0.383 against an actual 0.025. Every bin sits far right of the diagonal, and 98% of bookings clear the 0.059 cost cut.

Reliability, low range00.060.1200.060.12perfect calibrationunweighted ≈ correctedmean predicted probability (bin)observed no-show rate

Zoomed to 0-0.12: the unweighted and the corrected model follow the diagonal (mean 0.020 and 0.020; flagged 6.4% and 6.4%). ROC-AUC 0.784, 0.785, 0.785: the ranking never changed.

  • unweighted (Brier 0.0235)
  • balanced weights, raw (Brier 0.1879)
  • balanced, prior-corrected (Brier 0.0235)

Synthetic bookings: logistic regressions (L2 penalty 1, as scikit-learn's default C = 1) fitted on 8,000 rows with 2.0% no-shows and scored on 8,000 fresh rows; eight equal-count bins. Correction: odds x prior / (1 - prior), since balanced weights act like a 50% prior.

Reliability diagrams on fresh rows. Same ranking in all three models; only the weighted one mis-states how likely a no-show is.

Brier score Formula
BS=1N∑i=1N(pi−yi)2\mathrm{BS} = \frac{1}{N}\sum_{i=1}^{N} (p_i - y_i)^2
pip_i
predicted probability for case i
yiy_i
outcome, 1 or 0
BSBS
lower is better; it rewards both ranking and calibration (0.0097 vs 0.195 in the table above)

Log loss is the other proper scoring rule; both punish miscalibrated probabilities that AUC ignores.

Three ways to repair probabilities Comparison
CriterionPlatt (sigmoid)IsotonicPrior correction
FitsA logistic curve on the scores; monotone, keeps the rankingA monotone step function of the scoreOne odds factor
Good whenFew calibration rows; distortion is S-shapedPlenty of calibration rows; any monotone distortionOnly the class prior changed (weights, resampling, label shift)
RiskCannot fix distortions of other shapesOverfits small calibration sets; creates tiesWrong if the model was miscalibrated anyway
In scikit-learnCalibratedClassifierCV, method='sigmoid' (the default)CalibratedClassifierCV, method='isotonic'One line of arithmetic

Calibration holds at one prevalence: re-check it after re-weighting or a prior shift (Day 16 adds segments and time). Fit the calibrator on rows its model did not train on; scikit-learn 1.9.1's CalibratedClassifierCV (cv=None: 5 stratified folds) does this, and FrozenEstimator wraps an already-fitted model.

Choose the threshold on validation, never on test2 minWhich block fits, which block tunes, which block reports, and when to revisit the cut.
Where each choice is made (time order) Diagram
Training blockfit; resample orweight only hereValidationblockcalibrate; pickthreshold or kTest blockscore once at realprevalenceProductionmonitor flaggedrate andprevalenceprior moved
  1. Training blockfit; resample or weight only here
  2. Validation blockcalibrate; pick threshold or k
  3. Test blockscore once at real prevalence
  4. Productionmonitor flagged rate and prevalence
    • prior moved: back to Validation block
  • scikit-learn's TunedThresholdClassifierCV tunes the cut by CV, but its default scoring is balanced_accuracy, not your costs: pass a scorer built from the cost matrix (checked in 1.9.1). FixedThresholdClassifier applies a chosen cut.

  • Few positives make every metric noisy; report an interval or the spread across time blocks.

Memory anchor

Costs set the cut, prevalence sets the precision, and resampled probabilities need correcting before you act.

Useful for

  • Setting a flag, review or call-back threshold from explicit costs and a capacity limit, for fraud, no-shows, churn or defects.

  • Explaining to stakeholders why a model with an unchanged AUC suddenly produces mostly false alarms on a rarer segment.

  • Deciding whether a resampling step is worth its complexity, by comparing it with an unweighted model plus a cost threshold.

Not suitable for

  • Choosing a metric or threshold when the costs are unknown or contested; first agree on a cost range and show sensitivity (Day 16).

  • Multi-class or ranking problems with per-item budgets, where a single probability threshold is not the decision (Day 16, Day 51).

Traps

Fixing recall with class weights and the 0.5 threshold

Weights or resampling inflate the predicted probabilities, so 0.5 flags far more cases and recall rises. The list grows with it, the precision collapses and the cost goes up, though ROC-AUC is unchanged.

How to avoid it

Keep the ranking, correct the probabilities to the deployment prevalence, and choose the cut from costs.

Evaluating on a balanced table

Precision, AP and cost on a 50/50 evaluation set describe a world that does not exist; every positive looks cheap to find, and synthetic rows may leak neighbours of real validation rows.

How to avoid it

Resample only training folds; evaluate on untouched rows at the deployment prevalence.

Senior reasoning

A senior writes the cost matrix before choosing a metric, derives the threshold, and reports value in money against flag-nobody at the real prevalence. They treat resampling as a probability-shifting device that needs a correction and a clean evaluation, ask whether an unweighted model plus a cost threshold already does the job, and expect the prevalence to move after launch, so they monitor the flagged rate and re-derive the cut.

Interview question

"Our fraud model has AUC 0.95 but investigators say most alerts are junk. What is going on and what do you do?" How would you structure the answer?

Recall cards scheduled after completion

  • What threshold does a cost matrix give for calibrated probabilities, and why is ROC-AUC the wrong number to quote for a rare event?
  • What do class weights and SMOTE change, where may they be applied, and what must you do before applying a cost threshold?

Reviewed on a 1 · 3 · 7 · 14 · 30-day schedule; a miss returns the card to 1 day.

References

Optional sections

Deep dive · 10 min

Derivation of the threshold. Flagging costs (1−p) cFP+p cTP(1-p)\,c_{FP} + p\,c_{TP} in expectation; not flagging costs p cFN+(1−p) cTNp\,c_{FN} + (1-p)\,c_{TN}. Flag when the first is smaller, which gives p>(cFP−cTN)/((cFP−cTN)+(cFN−cTP))p > (c_{FP}-c_{TN}) / \big((c_{FP}-c_{TN}) + (c_{FN}-c_{TP})\big). With zero cost for correct calls this is CFP/(CFP+CFN)C_{FP}/(C_{FP}+C_{FN}); a true positive that recovers a resold seat enters as a negative cTPc_{TP}.

Prior correction. Suppose a model was trained with positives weighted so that the effective prior is π~\tilde\pi, and it returns p~\tilde p. Under label shift (p(x∣y)p(x\mid y) unchanged) the posterior at prior π\pi satisfies p1−p=p~1−p~⋅π1−π⋅1−π~π~\frac{p}{1-p} = \frac{\tilde p}{1-\tilde p}\cdot\frac{\pi}{1-\pi}\cdot\frac{1-\tilde\pi}{\tilde\pi}. With balanced weights π~=0.5\tilde\pi = 0.5 and the last factor is 1, which is the line in the script. Saerens et al. give an EM procedure for estimating π\pi without labels; Lipton et al. use a confusion matrix, which works even with a biased predictor.

Library defaults. XGBoost's tuning notes say that if only the ranking (AUC) matters you may balance with scale_pos_weight, but if the probabilities must be right you should not rebalance; LightGBM's parameter documentation warns that is_unbalance and scale_pos_weight can give poor estimates of individual class probabilities. Both statements are the correction logic above in library terms.

Beyond one threshold. Capacity-limited queues use top-kk; fairness or service constraints need per-segment thresholds; and if actions interact (a call-back changes the outcome), the problem is an uplift problem (Day 5), not a classification one.

Hands-on lab: SMOTE before the folds versus inside them · 20 min

Run the script. It builds 6,000 bookings with about 2% no-shows, implements a minimal SMOTE, and cross-validates a random forest twice: with SMOTE applied to all rows before the folds (scored on original rows only) and with SMOTE inside each training fold. Then:

  1. Explain why the first number is so high although validation rows are all original. Which rows leaked what?
  2. A constant score would reach AP 0.02. How much of the honest AP of 0.078 is signal?
  3. Block B shows the mean probability after SMOTE to 50/50. What would the cost threshold of 0.059 do with it?
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score
from sklearn.model_selection import StratifiedKFold
from sklearn.neighbors import NearestNeighbors

rng = np.random.default_rng(13)
n = 6000                                          # synthetic bookings, 2% no-shows, a weak signal in 6 features
X = rng.normal(size=(n, 6))
y = rng.random(n) < 1 / (1 + np.exp(-(X[:, :3].sum(axis=1) * 0.5 - 4.4)))

def smote(Xp, n_new, k=5, seed=0):                # minimal SMOTE: interpolate between a minority row and one of its k neighbours
    r = np.random.default_rng(seed)
    nb = NearestNeighbors(n_neighbors=k + 1).fit(Xp).kneighbors(Xp, return_distance=False)[:, 1:]
    i = r.integers(0, len(Xp), n_new)
    j = nb[i, r.integers(0, k, n_new)]
    return Xp[i] + r.random((n_new, 1)) * (Xp[j] - Xp[i])

def balance(Xa, ya, seed=0):                      # oversample positives to 50/50 with SMOTE
    new = smote(Xa[ya], (~ya).sum() - ya.sum(), seed=seed)
    return np.vstack([Xa, new]), np.r_[ya, np.ones(len(new), bool)]

def cv_ap(make_model, outside):
    scores = []
    folds = StratifiedKFold(5, shuffle=True, random_state=0)
    if outside:                                   # WRONG: synthetic rows are created from all positives before the folds
        Xs, ys = balance(X, y)
        orig = np.r_[np.ones(n, bool), np.zeros(len(ys) - n, bool)]
        for tr, te in folds.split(Xs, ys):
            te = te[orig[te]]                     # score only on original rows, for a fair comparison
            m = make_model().fit(Xs[tr], ys[tr])
            scores.append(average_precision_score(ys[te], m.predict_proba(Xs[te])[:, 1]))
    else:                                         # RIGHT: oversample inside each training fold only
        for tr, te in folds.split(X, y):
            Xb, yb = balance(X[tr], y[tr])
            m = make_model().fit(Xb, yb)
            scores.append(average_precision_score(y[te], m.predict_proba(X[te])[:, 1]))
    return np.mean(scores)

rf = lambda: RandomForestClassifier(100, random_state=0, n_jobs=-1)
print(f"A) prevalence {y.mean():.1%}; AP of a constant score = {y.mean():.3f}")
print(f"A) SMOTE before CV (leak):   AP {cv_ap(rf, True):.3f}")
print(f"A) SMOTE inside each fold:   AP {cv_ap(rf, False):.3f}")
# B) what SMOTE does to probabilities: the model now believes positives are common
Xb, yb = balance(X, y)
lr = LogisticRegression().fit(Xb, yb)
print(f"B) mean predicted probability after SMOTE to 50/50: {lr.predict_proba(X)[:, 1].mean():.3f} (actual rate {y.mean():.3f})")
print(f"B) plain logistic regression: {LogisticRegression().fit(X, y).predict_proba(X)[:, 1].mean():.3f}")
Interview practice · 10 min

A teammate wants to use SMOTE on a 0.5% defect-rate dataset and report the F1 score from cross-validation. How do you respond?

Content version 7 · last verified 2026-10-11 · depends on scikit-learn, numpy