Thresholds, ROC and PR
predict() hides a choice you should be making yourself
predict_probathresholdroc_curveroc_auc_scoreprecision_recall_curveaverage_precision_scoreWatch it happen
Play it through, or step back and forth yourself.
proba = model.predict_proba(X_test)[:, 1] model.classes_ # [0 1] — column 1 is "late" pred = model.predict(X_test) same = (proba >= 0.5).astype(int) (pred == same).all() # True — that IS predict() # so you can choose pred = (proba >= 0.4).astype(int)
predict() doesn't ask the model a yes/no question. The model produces a probability, and predict cuts it at 0.5. That 0.5 is a default, not a fact about your problem — and almost nobody realises it's a dial.
The idea
Here is something predict() doesn't tell you: the model never made a yes/no decision. It produced a probability, and predict cut it at 0.5.
proba = model.predict_proba(X_test)[:, 1] # the number underneath
pred = model.predict(X_test)
((proba >= 0.5).astype(int) == pred).all() # True — that IS predict()That 0.5 is a default, not a fact about your problem. It's the single easiest improvement available in most classification projects, and it costs no retraining at all.
One caution: predict_proba returns a column per class, ordered by model.classes_. Column 1 is the positive class here. Take the wrong column and you get a perfectly functioning, perfectly inverted model.
Moving the cut
Same model, same predictions, different threshold:
threshold accuracy precision recall flagged
0.50 0.796 0.567 0.340 30
0.40 0.796 0.545 0.480 44
0.30 0.747 0.444 0.560 63
0.25 0.733 0.438 0.700 80
0.20 0.711 0.418 0.760 91
0.15 0.658 0.374 0.800 107Look at the 0.40 row.
Accuracy is identical to the default — 0.796 either way — and recall goes from 0.34 to 0.48. Seven more late deliveries caught out of fifty, for free, by changing a number nobody told you was a choice. No new features, no better algorithm, no retraining.
Below that, you start paying: at 0.25 you catch 70% of late deliveries and accuracy falls to 0.733. Whether that's a good trade is a question about the business, not about the model.
Choosing the threshold from costs
Put numbers on the mistakes and the choice becomes arithmetic:
# a missed late delivery costs ₹200 in refunds and goodwill
# a false alarm costs ₹5 — one apologetic text
cost = misses * 200 + false_alarms * 5With that ratio the cheapest threshold is far below 0.5, because misses are forty times more expensive than alarms. With the ratio reversed — say each alarm dispatches a rider — you'd want a threshold well above 0.5.
Tune the threshold on validation data or by cross-validation, never on the test set. It's a decision like any other, and lesson 3's golden rule applies to it in full.
ROC curves
Rather than picking one threshold, sweep all of them. The ROC curve plots true-positive rate against false-positive rate at every possible cut:
from sklearn.metrics import roc_curve, roc_auc_score
fpr, tpr, thresholds = roc_curve(y_test, proba)
roc_auc_score(y_test, proba) # 0.793AUC is the area underneath, and it has a genuinely elegant interpretation: the probability that the model scores a randomly chosen late delivery above a randomly chosen on-time one. It is a statement about ranking. 0.5 is a coin flip; 1.0 is perfect separation.
Because it needs no threshold, AUC is the right metric when:
- you'll tune the cut later, and want to compare models before that;
- you'll use the scores to prioritise a queue rather than to decide yes/no;
- the class balance might shift, since AUC is insensitive to it.
And the wrong one when you won't. A model with a fine AUC can be useless at every threshold you'd actually deploy — the ranking can be good in a region nobody operates in.
Why PR curves are better here
ROC has a specific weakness with rare positives, and it's worth understanding rather than memorising. The false-positive rate is FP / (FP + TN), and TN is huge — 175 of our 225 test rows. So a hundred false alarms move the x axis barely at all, and the curve stays flattering.
Precision has no such cushion: TP / (TP + FP) has no large denominator to hide in, so a hundred false alarms wreck it immediately.
from sklearn.metrics import precision_recall_curve, average_precision_score
precision, recall, thresholds = precision_recall_curve(y_test, proba)
average_precision_score(y_test, proba) # 0.534
y_test.mean() # 0.222 — the no-skill floorAnd note the floors differ, which is the detail people miss. ROC's no-skill line is always 0.5. A PR curve's floor is the base rate — so our 0.534 has to be read against 0.222, not against zero. Quoting an average precision without the base rate beside it is the same error as quoting an accuracy without its baseline.
Which to reach for
- Roughly balanced classes, ranking matters → ROC AUC.
- Rare positives, you care about the positive class → PR curve and average precision.
- You must ship a yes/no decision → pick the threshold from costs, then report precision and recall at that threshold. The curves are for choosing; the confusion matrix is for reporting.
See it run
The lesson's code, ready to run and to fiddle with.
Putting the kettle on…
Starting up…
Worked example
not gradedAlready written and ready to go — press Run to see what it does, then change a number, a column name, anything, and run it again.
trychanging the cost of a false alarm from 5 to 300 and watching the cheapest threshold move up.
Your turn
3 exercises. Write the code yourself, then press Check — a nudge and the answer are there if you want them.
Show that predict() is just a cut at 0.5: return whether (proba >= 0.5) matches predict() exactly, as a bool.
Cut at 0.4 instead of 0.5 and return [accuracy, recall], rounded to 4 places. Compare the accuracy to the 0.7956 you already know.
Return [roc_auc, average_precision, no_skill_floor], rounded to 4 places — where the floor is the base rate that average precision has to be read against.
