Classification in Practice
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
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.
Concept
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.
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.
- Model scoreone per booking
- Calibrated?
- yes: to p > C_FP/(C_FP + C_FN)?
no - RecalibratePlatt, isotonic or the prior odds factorthen
- p > C_FP/(C_FP + C_FN)?
- yes: to Fits today's capacity?
no - Do not call
- Fits today's capacity?
- no: to Call the top k
yes - Call back
- 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.
| Criterion | ROC-AUC | Average precision | Cost per 1,000 cases |
|---|---|---|---|
| Built from | TPR against FPR, each computed within one class | Precision against recall, mixing both classes | The cost matrix applied at the chosen cut |
| Moves with prevalence | Hardly: 0.771 to 0.761 in today's run | Yes: 0.348 to 0.046 in today's run | Yes, and it is the decision's unit |
| Score of a useless model | 0.5 (the diagonal) | The prevalence | Flag nobody: C_FN times prevalence per case |
| Answers | How 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.
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.
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).
| Model | Mean p | Share flagged | Brier score |
|---|---|---|---|
| Unweighted logistic regression | 0.010 | 1.3% | 0.0097 |
| Balanced weights, raw | 0.391 | 98.0% | 0.195 |
| Balanced weights, prior-corrected | 0.010 | 1.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 , 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.
Application and workplace decision
- 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}")
Mini-assessment
Technical depth
The confusion matrix and its rates, by handFour 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.
| Predicted ↓ | Fraud (20) | Legitimate (180) |
|---|---|---|
| Flagged (30) | 12 | 18 |
| Not flagged (170) | 8 | 162 |
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.
| Rate | Formula with the cells | Value |
|---|---|---|
| Recall (TPR, sensitivity) | TP / (TP + FN) = 12 / 20 | 0.600 |
| Specificity (TNR) | TN / (TN + FP) = 162 / 180 | 0.900 |
| False-positive rate | FP / (FP + TN) = 18 / 180 | 0.100 |
| Precision (PPV) | TP / (TP + FP) = 12 / 30 | 0.400 |
| Negative predictive value | TN / (TN + FN) = 162 / 170 | 0.953 |
| Accuracy | (TP + TN) / 200 = 174 / 200 | 0.870 |
| F1 | 2TP / (2TP + FP + FN) = 24 / 50 | 0.480 |
| Balanced accuracy | (recall + specificity) / 2 | 0.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).
| Predicted ↓ | No-show (120) | Shows (880) |
|---|---|---|
| Flagged (625.5) | 107.7 | 517.8 |
| Not flagged (374.5) | 12.3 | 362.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.
- Flag nobody: 120 empty seats9,600 EUR
- 107.7 seats resold80 x 107.7 no longer lost−8,616 EUR
- 517.8 needless calls5 x 517.8+2,589 EUR
- 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.
| Rate | Formula | Value here |
|---|---|---|
| Recall (TPR, sensitivity) | TP / (TP + FN) | 0.897 |
| False-positive rate | FP / (FP + TN) | 0.588 |
| Specificity | TN / (TN + FP) | 0.412 |
| Precision | TP / (TP + FP) | 0.172 |
| Accuracy | (TP + TN) / all | 0.470 |
ROC uses the first two (within-class rates); precision mixes the columns, so it depends on prevalence.
The threshold from a cost matrixWrite the four costs, derive the break-even probability step by step, and see the cost curve bottom out exactly there.
| Action | Passenger shows | Passenger is a no-show |
|---|---|---|
| Call back (flag) | EUR 5: false positive | EUR 0: true positive, seat resold |
| Do not call | EUR 0: true negative | EUR 80: false negative, seat flies empty |
- calibrated probability that this booking is a no-show
- cost of acting on a negative: a needless call, EUR 5
- cost of missing a positive: an empty seat, EUR 80
- 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.
Price both actions
For a case with probability p: flagging costs (1 - p) C_FP in expectation, leaving it costs p C_FN.
Compare
Act exactly when acting is cheaper: p C_FN > (1 - p) C_FP.
Solve for p
p (C_FN + C_FP) > C_FP, so flag when p > C_FP / (C_FP + C_FN).
Non-zero correct costs
Replace the two costs by the differences (C_FP - c_TN) and (C_FN - c_TP); the algebra is unchanged.
Why it is optimal
Deciding every case by its own expected cost minimizes the total expected cost: no other rule does better.
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.
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.
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).
- 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 precisionWhy ROC ignores prevalence, why precision cannot, and how AP is computed and read against its floor.
- prevalence (share of positives)
- fixed by the threshold and the class score distributions
- false alarms; they come from the large negative class and dominate as pi falls
Computed from the formula above. The classifier is identical at every point; only the mix of cases changes.
| Step | Working | Value |
|---|---|---|
| Scores | readmitted within 30 days: 0.9, 0.6, 0.4; not readmitted: 0.7, 0.3, 0.2, 0.1 | 3 positives, 4 negatives |
| All positive-negative pairs | 3 x 4 | 12 pairs |
| Pairs ranked correctly | 0.9 beats 4 negatives, 0.6 beats 3, 0.4 beats 3 | 10 of 12 |
| ROC-AUC | share of pairs where the positive scores higher (ties count half) | 10 / 12 = 0.833 |
| Same number, named | Mann-Whitney U / (positives x negatives): only the order of scores enters, never their scale | 10 / 12 |
| Ranked list | sort by score, read the labels from the top | P N P P N N N |
| Precision at each positive | positives so far / rows so far | 1/1, 2/3, 3/4 |
| Average precision | mean of those precisions, one per positive | (1 + 0.667 + 0.750) / 3 = 0.806 |
| Floors | a random ranking: AUC 0.5, AP about the prevalence | 0.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.
- precision and recall at the n-th threshold, moving down the ranked list
- the recall gained at that step
- 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 SMOTEWhat each remedy does to the loss, the probabilities and your evaluation, and how SMOTE leaks.
| Criterion | Cost threshold only | Class weights | Random undersampling | Oversampling or SMOTE |
|---|---|---|---|---|
| What it does | Train on data as given; apply the cost matrix to calibrated p | Each positive counts more in the loss (balanced: n / (2 n_class)) | Drop negatives until classes balance | Copy positives, or interpolate new ones between neighbours |
| Probabilities afterwards | Stay at the real prevalence | Inflated toward a balanced world | Inflated; correct with the sampling rate | Inflated (0.406 mean vs 0.021 real in the lab) |
| Main caveat | Needs calibrated scores and agreed costs | Prior-correct or recalibrate before any cut | Throws away real rows; noisier model | No 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.
Pick a positive
Choose a minority-class row at random.
Find its neighbours
Its k nearest minority neighbours (k = 5 in the paper and in imbalanced-learn).
Pick one neighbour
Choose one of the k at random.
Interpolate
New row = row + u x (neighbour - row), with u uniform in [0, 1].
Repeat
Until the requested class ratio is reached; then fit the model.
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.
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 shiftadvancedMove probabilities from one class prior to another, and what to do when the deployment prevalence drifts.
- the model's probability under the training (or weighted) prior
- effective training prior; 0.5 with balanced weights, so the last factor is 1
- deployment prevalence
- 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.
- Monitor the flagged rateand the score mix; labels arrive laterate drifts ↓
- Estimate the new prevalenceEM on the predictions or a confusion matrixnew π ↓
- Correct the oddsthe prior correction above; ranking unchangedre-cut ↓
- Re-derive the flagged countand precision at the operating pointdeploy ↓
- ↺ 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, BrierHow to see miscalibration, how to measure it with a proper score, and which calibrator to fit.
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.
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.
- predicted probability for case i
- outcome, 1 or 0
- 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.
| Criterion | Platt (sigmoid) | Isotonic | Prior correction |
|---|---|---|---|
| Fits | A logistic curve on the scores; monotone, keeps the ranking | A monotone step function of the score | One odds factor |
| Good when | Few calibration rows; distortion is S-shaped | Plenty of calibration rows; any monotone distortion | Only the class prior changed (weights, resampling, label shift) |
| Risk | Cannot fix distortions of other shapes | Overfits small calibration sets; creates ties | Wrong if the model was miscalibrated anyway |
| In scikit-learn | CalibratedClassifierCV, 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 testWhich block fits, which block tunes, which block reports, and when to revisit the cut.
- Training blockfit; resample or weight only here
- Validation blockcalibrate; pick threshold or k
- Test blockscore once at real prevalence
- 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?
Prerequisites and related concepts
- Logistic regression and log loss · day 11
- Conditional probability and Bayes · day 3
- Cost of being wrong · day 1
- Holdout boundary before fitting · day 9
- Baseline-first culture · day 1
- Class imbalance
- Resampling
- Thresholds from cost matrices
- Probability calibration
- Classification metrics · day 16
- Business vs ML metric · day 16
- Drift and monitoring · day 19
- Anomaly detection · day 14
References
- The Precision-Recall Plot Is More Informative than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasetscore
Precision-recall plots reflect performance on the minority class under imbalance where ROC plots can look optimistic, which supports reporting AP and precision at the real prevalence.
- The Foundations of Cost-Sensitive Learningcore
Optimal decisions apply a cost matrix to probability estimates, and rebalancing the training data has little effect on some learners; supports deriving the threshold from costs.
- Tuning the decision threshold for class prediction (scikit-learn user guide 3.3)optional
Post-hoc tuning of the decision threshold from a business cost, including a fixed-threshold wrapper and a tuned-threshold classifier. (3.3 Tuning the decision threshold for class prediction)
- The harm of class imbalance corrections for risk prediction models: illustration and simulation using logistic regressioncore
In a logistic-regression simulation, random over- or undersampling and SMOTE produced miscalibrated risk estimates without improving discrimination.
- Common pitfalls and recommended practices (imbalanced-learn User Guide, section 9)optional
Resampling before splitting or cross-validation leaks and balances the test data; the library's pipeline resamples only during fit. (Section 9, common pitfalls)
- SMOTE: Synthetic Minority Over-sampling Techniqueoptional
Defines SMOTE as creating synthetic minority examples by interpolating between a minority example and its nearest minority neighbours.
- Adjusting the Outputs of a Classifier to New a Priori Probabilities: A Simple Procedureoptional
Posteriors learned under one class prior are not valid under another and can be adjusted; the basis of the prior correction used here.
- The Relationship Between Precision-Recall and ROC Curvesoptional
A curve dominates in ROC space if and only if it dominates in precision-recall space, and PR points must not be linearly interpolated; supports the PR versus ROC chapter.
- Detecting and Correcting for Label Shift with Black Box Predictorsoptional
Defines label shift (p(y) changes, p(x given y) fixed) and estimates the new class prior from a black-box predictor's confusion matrix.
Optional sections
Deep dive
Derivation of the threshold. Flagging costs in expectation; not flagging costs . Flag when the first is smaller, which gives . With zero cost for correct calls this is ; a true positive that recovers a resold seat enters as a negative .
Prior correction. Suppose a model was trained with positives weighted so that the effective prior is , and it returns . Under label shift ( unchanged) the posterior at prior satisfies . With balanced weights and the last factor is 1, which is the line in the script. Saerens et al. give an EM procedure for estimating 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-; 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
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:
- Explain why the first number is so high although validation rows are all original. Which rows leaked what?
- A constant score would reach AP 0.02. How much of the honest AP of 0.078 is signal?
- 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
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?