Ch. 03
Classification
Confusion matrix vocabulary, precision-recall tradeoffs, multi-label, the metric-choice that lies.
A classifier is a function from inputs to a finite set of labels. The simplest one outputs a single integer in , the better ones output a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → distribution over those classes. Most ML systems in production are classifiers in disguise. Search ranking is "is this document relevant: yes/no". Fraud detection is "is this transaction fraudulent: yes/no". A language model generating the next A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → is multi-class classification over a 50,000-token The fixed set of all chunks a model is allowed to read or produce.Full glossary →. The thing you will spend most of this chapter on is not how to write a classifier (sklearn does that in three lines) but how to know whether your classifier is good. The share of guesses the model got right out of all its guesses.Full glossary → lies. Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → lies. Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → lies. They lie in different directions, and the part of ML practice that takes years to learn is which lie is dangerous for your specific application.
FIG 03.1 · Learning outcomes
By the end of this chapter you will be able to:
- Train a binary classifier on MNIST-5 ("is this digit a 5?") with
SGDClassifierand verify it beats the obvious dummy baseline. - Read and explain a confusion matrix, including the four entries by name and what each error costs.
- Compute precision, recall, F1, and ROC-AUC by hand from a confusion matrix and verify your numbers against sklearn.
- Pick the right threshold for a binary classifier given a precision target or a recall target.
- Distinguish multi-class, multi-label, and multi-output classification by their target shapes, and pick the right loss for each.
- Run error analysis on a 10-class classifier: find the most confused pair, look at examples, hypothesize what the model is missing.
- Build a complete sklearn classification pipeline with stratified CV, calibrated probabilities, and class-weight adjustment for imbalanced data.
FIG 03.2 · What you need first
- Ch 0 — Math & Python Prereqs — softmax, cross-entropy, NumPy fluency.
- Ch 1 — The ML Landscape — the metric-choice intuitions.
- Ch 2 — End-to-End ML Project — the pipeline workflow. We reuse it.
FIG 03.3.1
The classification setup
Given training data with , learn a function . Sometimes also outputs class probabilities , and the discrete prediction is .
Three flavors:
- Binary classification: . Spam vs. not spam, cancer vs. healthy, click vs. no-click.
- Multi-class classification: , each example has exactly one true class. MNIST (digits 0-9), CIFAR (10 categories), language-model next-A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → (vocab size ).
- Multi-label classification: each example can have multiple true classes. Tagging an image with all objects it contains. Categorizing a news article into multiple topics.
A fourth thing that is often conflated: multi-output classification, where the model outputs multiple independent class predictions per example (e.g. predicting both digit identity and writer demographic). This is just stacking multiple classifiers; nothing new conceptually.
The MNIST dataset is the canonical sandbox. 70,000 28x28 grayscale images of handwritten digits, with the integer label. We'll use it for almost every example in this chapter.
from sklearn.datasets import fetch_openml
import numpy as np
mnist = fetch_openml('mnist_784', as_frame=False, parser='auto')
X, y = mnist.data, mnist.target.astype(np.uint8)
print(X.shape, y.shape) # (70000, 784), (70000,)
# The standard train/test split: first 60k train, last 10k test
X_train, X_test = X[:60000], X[60000:]
y_train, y_test = y[:60000], y[60000:]FIG 03.3.2
Binary classification: the "is this a 5?" baseline
Start with the simplest version of the problem. Build a binary classifier for "is this digit a 5". The target becomes:
y_train_5 = (y_train == 5)
y_test_5 = (y_test == 5)
print(y_train_5.sum(), "fives in training set out of", len(y_train_5))
# 5421 fives out of 60000 -> ~9% positive classThis is an imbalanced binary problem. Random guessing would predict "not 5" for everything and achieve 91% The share of guesses the model got right out of all its guesses.Full glossary →. This is the trap that motivates every other metric in the chapter.
Library path with SGDClassifier:
from sklearn.linear_model import SGDClassifier
sgd = SGDClassifier(loss='hinge', random_state=42, max_iter=1000)
sgd.fit(X_train, y_train_5)
# Predict on one example
print(sgd.predict([X_train[0]])) # array([ True]) or [False]
# Accuracy (warning: misleading on imbalanced data)
from sklearn.metrics import accuracy_score
print("Train accuracy:", accuracy_score(y_train_5, sgd.predict(X_train)))
print("Test accuracy:", accuracy_score(y_test_5, sgd.predict(X_test)))The SGDClassifier with loss='hinge' is a linear SVM trained with stochastic The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary →. A linear support vector machine (SVM) is a linear classifier that places the decision boundary to maximize the margin — the distance from the boundary to the nearest training points of each class (the support vectors). The hinge loss , where is the score and , is what enforces that margin: it charges nothing once a point is on the correct side by a safe distance, and grows linearly as points cross into or past the boundary. SVMs get a full treatment in a later chapter; for now, treat this as a fast linear classifier whose raw output is a signed score, not a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary →.
FIG 03.3.3
Why accuracy lies (and what to use instead)
For the "is this a 5" problem, The share of guesses the model got right out of all its guesses.Full glossary → is misleading because the classes are imbalanced. A dummy classifier that always predicts "not 5" gets 91% accuracy and learns nothing. To see this:
from sklearn.dummy import DummyClassifier
dummy = DummyClassifier(strategy='most_frequent')
dummy.fit(X_train, y_train_5)
print("Dummy accuracy:", accuracy_score(y_test_5, dummy.predict(X_test))) # ~0.91To meaningfully compare your classifier to the dummy, you need a metric that penalizes the "always negative" strategy. The first such metric is balanced accuracy:
A dummy classifier gets balanced accuracy = 0.5 (it catches all of one class, none of the other). A useful classifier gets > 0.5.
But balanced accuracy still throws away information. The full picture is the A small table that splits the model's calls into four boxes: correct yeses, false alarms, correct nos, and misses.Full glossary →.
FIG 03.3.4
The confusion matrix
The A small table that splits the model's calls into four boxes: correct yeses, false alarms, correct nos, and misses.Full glossary → is the four-number summary of binary classification performance.
| predicted neg | predicted pos | |
|---|---|---|
| actual neg | TN | FP |
| actual pos | FN | TP |
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import cross_val_predict
# Get cross-validated predictions (so we don't peek at val data)
y_train_pred = cross_val_predict(sgd, X_train, y_train_5, cv=3)
cm = confusion_matrix(y_train_5, y_train_pred)
print(cm)
# [[TN, FP],
# [FN, TP]]A typical output:
[[53892, 687], # actual not-5: 53892 correctly classified, 687 wrong
[ 1891, 3530]] # actual 5: 1891 missed (FN), 3530 caught (TP)From this:
from sklearn.metrics import precision_score, recall_score, f1_score
print(f"Precision: {precision_score(y_train_5, y_train_pred):.3f}")
print(f"Recall: {recall_score(y_train_5, y_train_pred):.3f}")
print(f"F1: {f1_score(y_train_5, y_train_pred):.3f}")The four metrics each measure something different. Which one you optimize depends on the cost of the two error types.
FIG 03.3.5
Precision vs recall: the trade-off you cannot escape
For a binary classifier with a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → output, you choose a threshold . Predict positive if . As you raise , you get fewer positive predictions, so Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → goes up and Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → goes down. As you lower , recall goes up and precision goes down. You cannot have both unless your classifier is perfect.
An SVM doesn't output probabilities, but it does output a score: decision_function returns for each example, a signed value that is positive on the "is a 5" side of the boundary and negative on the other, with magnitude growing the farther the point sits from the boundary. The default predict simply thresholds this score at zero. To trade precision against recall we threshold the score ourselves instead.
# Get decision function scores (signed distance to decision boundary, not calibrated probability)
y_scores = cross_val_predict(sgd, X_train, y_train_5, cv=3, method="decision_function")
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)
# Find the threshold that achieves precision >= 0.90
idx = np.argmax(precisions >= 0.90)
threshold_90 = thresholds[idx]
print(f"Threshold for 90% precision: {threshold_90:.3f}")
print(f"Recall at that threshold: {recalls[idx]:.3f}")Plot precision and recall against the threshold:
import matplotlib.pyplot as plt
plt.plot(thresholds, precisions[:-1], 'b-', label='Precision')
plt.plot(thresholds, recalls[:-1], 'g-', label='Recall')
plt.axvline(threshold_90, color='red', linestyle='--')
plt.xlabel('Threshold')
plt.legend()
plt.grid(True)
plt.show()Or plot recall on x-axis vs precision on y-axis (the PR curve):
plt.plot(recalls, precisions)
plt.xlabel('Recall')
plt.ylabel('Precision')
plt.grid(True)
plt.show()The PR curve summarizes the trade-off. The area under it (PR-AUC, sometimes called average precision) is one threshold-free metric:
from sklearn.metrics import average_precision_score
print(f"PR-AUC (average precision): {average_precision_score(y_train_5, y_scores):.3f}")FIG 03.3.6
The ROC curve
ROC (Receiver Operating Characteristic) plots TPR (Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary →) on the y-axis against FPR (false positive rate) on the x-axis, varying the threshold.
A perfect classifier reaches (0, 1) in the top-left corner. A random classifier traces the diagonal. ROC-AUC is the area under this curve. AUC = 0.5 is random; AUC = 1.0 is perfect.
from sklearn.metrics import roc_curve, roc_auc_score
fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)
print(f"ROC-AUC: {roc_auc_score(y_train_5, y_scores):.3f}")
plt.plot(fpr, tpr)
plt.plot([0, 1], [0, 1], 'k--') # diagonal
plt.xlabel('False Positive Rate')
plt.ylabel('True Positive Rate')
plt.grid(True)
plt.show()When to use PR vs ROC. Both summarize the threshold-vs-quality trade-off. They differ in what they emphasize:
- ROC treats positives and negatives symmetrically (TPR vs FPR). Useful when classes are roughly balanced and both kinds of error matter.
- PR focuses on the positive class (Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → vs recall). More informative when positives are rare (imbalanced data) or when you care specifically about the positive class.
For the MNIST-5 problem (9% positive), PR-AUC is the better summary. For balanced problems, use ROC.
FIG 03.3.7
Logistic regression: the probabilistic binary classifier
SGDClassifier(loss='hinge') gives you an SVM. To get A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → outputs, you want logistic regression: a linear model trained to maximize the likelihood under a A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary →-of-linear model.
The loss is binary A loss that measures how far a model's predicted chances are from the true answer.Full glossary →:
This is NLL under the Bernoulli model The A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → with respect to has a clean form: . The "predicted minus actual" times input. Recognize it.
LogisticRegression vs. from scratch
Classical MLclf = LogisticRegression(penalty=None, fit_intercept=True, max_iter=1000) # penalty=None matches the unregularized scratch math; default is penalty='l2', C=1.0
clf.fit(X, y) # X: (N, d), y in {0, 1}
p = clf.predict_proba(X)[:, 1] # P(y=1 | x)
yhat = clf.predict(X) # thresholds p at 0.5
# fitted params: clf.coef_ (1, d) ~ w , clf.intercept_ (1,) ~ bdef sigmoid(z):
# Stable form: for negative z use exp(z) / (1 + exp(z))
out = np.empty_like(z, dtype=np.float64)
pos = z >= 0
out[pos] = 1.0 / (1.0 + np.exp(-z[pos]))
expz = np.exp(z[~pos])
out[~pos] = expz / (1.0 + expz)
return out
def fit_logistic_regression(X, y, *, lr=0.1, n_steps=1000, l2=0.0):
X = np.asarray(X, np.float64); y = np.asarray(y, np.float64)
N, d = X.shape
w = np.zeros(d); b = 0.0
for step in range(n_steps):
z = X @ w + b
p = sigmoid(z)
dw = X.T @ (p - y) / N + l2 * w # grad of BCE (+ L2 on w)
db = float((p - y).mean())
w -= lr * dw
b -= lr * db
return w, b
def predict_proba(X, w, b):
return sigmoid(np.asarray(X, np.float64) @ w + b)
def predict(X, w, b, threshold=0.5):
return (predict_proba(X, w, b) >= threshold).astype(int)from scratch: lab/solution.py: fit_logistic_regression (with sigmoid, predict_proba, predict)
- 1
clf.fit(X, y)the for step in range(n_steps) loop that repeatedly computes p = sigmoid(X @ w + b) and steps w, b down the BCE gradient - 2
the log_loss objective minimized internally (penalty/C control the L2 strength)binary_cross_entropy(y, p) plus the 0.5 * l2 * (w @ w) penalty (l2 * w added to dw) - 3
the lbfgs (default) quasi-Newton step the solver takesdw = X.T @ (p - y) / N + l2*w ; db = (p - y).mean() ; w -= lr*dw ; b -= lr*db (first-order GD with the clean (p - y)*x gradient) - 4
clf.coef_ and clf.intercept_ after fittingthe returned w (shape (d,)) and scalar b - 5
clf.predict_proba(X)[:, 1]predict_proba: sigmoid(X @ w + b) - 6
clf.predict(X)predict: (predict_proba(...) >= 0.5).astype(int)
What the one call hides
- Default penalty='l2' with C=1.0 means sklearn ALWAYS regularizes (loss is 0.5*||w||^2 + C*sum(log_loss)); the scratch code defaults to l2=0.0, i.e. NO regularization unless you pass it. To match the scratch math you must pass penalty=None.
- Convergence: sklearn runs lbfgs to a tolerance (tol=1e-4) and warns if it does not converge; the scratch code runs a fixed n_steps of fixed-lr full-batch GD with no convergence check.
- Numerical stability of the sigmoid/log-loss is handled internally; the scratch code needs the piecewise sigmoid (and an eps clip in BCE) to avoid overflow and log(0).
- Feature scaling is on you for both, but lbfgs is far more robust to unscaled features than fixed-lr GD, which can diverge or crawl on badly-conditioned data.
- sklearn's C regularizes the per-sample-summed loss (not the mean) and does NOT penalize the intercept, so 'C' and the scratch 'l2' are not the same number even at the same nominal strength.
- Optimizer choice: lbfgs (second-order) reaches the convex minimum in tens of iterations; the scratch first-order GD needs thousands of steps to reach the same point.
- Gotcha: C is INVERSE regularization strength: small C = strong regularization. Beginners expect bigger = more and underfit by accident.
- Gotcha: Default penalty='l2' is ON, so 'plain' LogisticRegression is already regularized; passing penalty=None is what reproduces the unregularized scratch result (verified bit-for-bit close below).
- Gotcha: predict() uses a hard 0.5 threshold; on imbalanced data (the chapter's ~9%-positive MNIST-5) that gives high accuracy but terrible recall — threshold predict_proba yourself.
- Gotcha: predict_proba returns a 2-column array [P(class0), P(class1)]; grabbing column 0 by mistake silently inverts your probabilities.
Prefer sklearn LogisticRegression (or SGDClassifier(loss='log_loss') for streaming/huge data) in production — it is faster, regularized by default, and converges reliably; write the from-scratch version once to internalize that .fit() is just gradient descent driving w,b down the binary-cross-entropy loss with the clean (p - y)*x gradient.
On the job: On the job you almost never reimplement this — you call LogisticRegression, but you DO hand-write the feature scaling, the class-weight / decision-threshold tuning, and the (p - y)*x gradient intuition when you debug why a custom log-loss head in a deep net isn't learning.
This is the smallest probabilistic classifier and the foundation of every more complex model in deep learning. another chapter will derive it from maximum likelihood; another chapter will turn it into a single-layer neural network. Same equations.
FIG 03.3.8
Multi-class: softmax regression
Generalize logistic regression to classes. The model produces logits and a A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → turns them into a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → distribution:
This is the softmax regression (also called multinomial logistic regression) classifier. The loss is categorical A loss that measures how far a model's predicted chances are from the true answer.Full glossary →:
The A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → with respect to logits for example is . Predicted minus one-hot target. Same shape as logistic regression's .
from sklearn.linear_model import LogisticRegression
# 'multinomial' uses softmax cross-entropy (one model, K classes)
multi = LogisticRegression(multi_class='multinomial', solver='lbfgs', max_iter=500)
multi.fit(X_train[:10000], y_train[:10000])
print("Test accuracy:", multi.score(X_test, y_test)) # ~0.91One-vs-rest and one-vs-one decompositions. Older multi-class strategies that build a -class predictor out of binary classifiers, which matters when your base classifier is inherently binary (like an SVM). One-vs-rest (OvR) trains one "class vs. everything else" classifier per class and predicts the class whose classifier scores highest; one-vs-one (OvO) trains one classifier per pair of classes and predicts by majority vote. For most modern models, just use the multinomial formulation.
from sklearn.multiclass import OneVsRestClassifier
ovr = OneVsRestClassifier(SGDClassifier())
ovr.fit(X_train[:10000], y_train[:10000])
print("OvR accuracy:", ovr.score(X_test, y_test))FIG 03.3.9
Multi-class confusion matrix and error analysis
The multi-class A small table that splits the model's calls into four boxes: correct yeses, false alarms, correct nos, and misses.Full glossary → is a array where entry is the count of examples with true class predicted as .
from sklearn.metrics import confusion_matrix
y_pred = multi.predict(X_test)
cm = confusion_matrix(y_test, y_pred)
print(cm)
# 10x10 matrix; diagonal entries are correct, off-diagonal are errors
# Normalize each row by class size to see the per-class error pattern
cm_norm = cm / cm.sum(axis=1, keepdims=True)
plt.imshow(cm_norm, cmap='Blues')
plt.colorbar()
plt.xlabel('predicted')
plt.ylabel('true')
plt.show()The error analysis workflow:
- Find the largest off-diagonal entries. These are the most confused class pairs.
- For each confused pair, look at a sample of misclassified images. What do they have in common?
- Hypothesize what One piece of information about an example that the model looks at when making a guess.Full glossary → the model is missing or what training data is underrepresented.
# Find the worst confusion
errors = cm.copy()
np.fill_diagonal(errors, 0)
worst_pairs = []
for _ in range(5):
i, j = np.unravel_index(np.argmax(errors), errors.shape)
worst_pairs.append((i, j, errors[i, j]))
errors[i, j] = 0
print("Worst confusions (true -> predicted):", worst_pairs)
# typical: (5, 3, ~50), (3, 5, ~45), (8, 5, ~40), (4, 9, ~35), (9, 4, ~30)
# Look at sample errors for the worst pair
true_class, pred_class, _ = worst_pairs[0]
mask = (y_test == true_class) & (y_pred == pred_class)
errors_X = X_test[mask][:9]
fig, axes = plt.subplots(3, 3, figsize=(6, 6))
for ax, img in zip(axes.flat, errors_X):
ax.imshow(img.reshape(28, 28), cmap='gray_r')
ax.axis('off')
plt.suptitle(f'true {true_class}, predicted {pred_class}')
plt.show()Looking at the worst MNIST confusion (often 3 vs 5 or 5 vs 8), you'll see digits with ambiguous loops, partial strokes, or stylistic variations the linear model can't disambiguate. This points you to: try a CNN (another chapter), or augment data with rotations and small distortions.
FIG 03.3.10
Multi-label classification
Some examples have multiple true labels. A picture might contain both "cat" and "dog". A news article might be tagged "politics" and "economy". The target becomes a length- binary vector where if class applies.
The model output is independent A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → outputs (not one A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary →). Loss is the sum of binary cross-entropies. This is not the same as multi-class.
# Synthetic multi-label MNIST: predict (is_large, is_odd) for each digit
y_train_large = (y_train >= 7)
y_train_odd = (y_train % 2 == 1)
y_multilabel = np.c_[y_train_large, y_train_odd] # shape (N, 2)
from sklearn.neighbors import KNeighborsClassifier
knn = KNeighborsClassifier()
knn.fit(X_train, y_multilabel)
preds = knn.predict(X_test[:5])
print(preds)
# Each row is [is_large, is_odd] predictionIn PyTorch, the loss is F.binary_cross_entropy_with_logits applied to a -dim sigmoid output. This is the loss for image tagging, multi-topic classification, and many recommender system formulations.
Metric for multi-label. Per-class Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary →/Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary →/F1, then averaged. Three averaging strategies:
- macro: unweighted average across classes. Each class counts equally.
- micro: pool TP/FP/FN counts across classes, then compute. Each example counts equally.
- weighted: average across classes, weighted by class frequency.
from sklearn.metrics import f1_score
print(f"Macro F1: {f1_score(y_test_multilabel, preds, average='macro'):.3f}")
print(f"Micro F1: {f1_score(y_test_multilabel, preds, average='micro'):.3f}")Use macro when each class is equally important. Use micro when each example is equally important. Weighted is a compromise.
FIG 03.3.11
Class imbalance: when one class is rare
In the MNIST-5 problem, positives are 9% of the data. In real-world problems like fraud detection or rare disease screening, the positive class can be 0.1% or less. Standard classifiers trained on imbalanced data tend to:
- Predict the majority class for almost everything.
- Have high The share of guesses the model got right out of all its guesses.Full glossary → but very low Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → on the minority class.
- Produce miscalibrated probabilities skewed toward the majority class.
Three families of fixes:
Class weights. Re-A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → the loss so each class contributes equally to the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → regardless of frequency. sklearn supports this via class_weight='balanced':
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(class_weight='balanced', max_iter=1000)
clf.fit(X_train, y_train_5)In PyTorch: F.cross_entropy(..., weight=class_weights) where class_weights is a vector with one entry per class.
Resampling. Rebalance the The batch of examples the model actually studies and learns from.Full glossary → before fitting: oversample the minority class so it appears more often, or undersample the majority class so it appears less. The imblearn library packages these — RandomOverSampler duplicates minority examples, SMOTE instead synthesizes new ones by interpolating between nearby minority points. Trade-off: oversampling causes When a model memorizes the quirks and flukes of its study examples instead of the real pattern, so it flops on anything new.Full glossary →, undersampling discards data.
Threshold tuning. Train a normal classifier, then pick the decision threshold on validation data to optimize the metric you care about (often F1 or recall at a Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary → floor). This is often the cleanest fix and gets ignored.
# Find the threshold maximizing F1
from sklearn.metrics import f1_score
probs = clf.predict_proba(X_val)[:, 1]
best_f1, best_t = 0, 0.5
for t in np.linspace(0.05, 0.95, 19):
preds = (probs > t).astype(int)
f1 = f1_score(y_val_5, preds)
if f1 > best_f1:
best_f1, best_t = f1, t
print(f"Best F1 {best_f1:.3f} at threshold {best_t:.2f}")FIG 03.3.12
Calibration: do your probabilities mean anything?
A classifier is calibrated if when it says "80% A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → of class 1", the true frequency in cases where it said that is also 80%. Most classifiers, especially neural networks, are miscalibrated. They are overconfident.
from sklearn.calibration import calibration_curve
probs_test = pipe.predict_proba(X_test)[:, 1]
frac_pos, mean_pred = calibration_curve(y_test_5, probs_test, n_bins=10)
plt.plot(mean_pred, frac_pos, marker='o')
plt.plot([0, 1], [0, 1], 'k--')
plt.xlabel('Mean predicted probability')
plt.ylabel('Fraction of positives')
plt.title('Calibration curve')
plt.grid(True)
plt.show()This plot — predicted probability on the x-axis, observed frequency on the y-axis — is the How well a model's stated confidence matches how often it's actually right.Full glossary → curve, also called a reliability diagram. A perfectly calibrated classifier lies on the diagonal. Overconfident classifiers fall below it.
Two fixes:
Platt Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary →. Fit a logistic regression on the A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary → with the classifier's scores as the only One piece of information about an example that the model looks at when making a guess.Full glossary →. Use the fitted A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → to recalibrate predictions.
Isotonic regression. A non-parametric monotonic mapping from scores to calibrated probabilities. More flexible than Platt but needs more data.
sklearn.calibration.CalibratedClassifierCV does both:
from sklearn.calibration import CalibratedClassifierCV
calibrated = CalibratedClassifierCV(sgd, cv=3, method='isotonic')
calibrated.fit(X_train, y_train_5)
probs_calib = calibrated.predict_proba(X_test)[:, 1]This matters in production. If you use predicted probabilities as inputs to downstream business logic (rank items, decide thresholds, compute expected costs), miscalibrated probabilities corrupt everything downstream. Always check calibration on a held-out set before deploying.
FIG 03.3.13
A complete classification pipeline
Putting it all together: a stratified, cross-validated, calibrated, class-A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary →-aware classification pipeline.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.calibration import CalibratedClassifierCV
from sklearn.metrics import classification_report, confusion_matrix
# Stratified CV preserves class proportions across folds
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
base = Pipeline([
("scale", StandardScaler()),
("clf", LogisticRegression(class_weight='balanced', max_iter=1000)),
])
calibrated = CalibratedClassifierCV(base, cv=skf, method='isotonic')
# Cross-validated metrics
scores = cross_val_score(calibrated, X_train, y_train_5, cv=skf, scoring='f1')
print(f"CV F1: {scores.mean():.3f} ± {scores.std():.3f}")
# Final fit on all training data
calibrated.fit(X_train, y_train_5)
# Test (once)
y_test_pred = calibrated.predict(X_test)
print(classification_report(y_test_5, y_test_pred))
print(confusion_matrix(y_test_5, y_test_pred))This is a strong starting template for many tabular classification problems. Pipeline composition, stratified CV, and metrics beyond The share of guesses the model got right out of all its guesses.Full glossary → are broadly useful. Class weights and A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → How well a model's stated confidence matches how often it's actually right.Full glossary → are conditional: use them when the class/cost structure or downstream probability decisions justify them, and validate the choice on held-out data.
FIG 03.4 · Safety lens · this chapter
Classification metrics are not value-neutral. Every choice along the Out of all the times the model shouted 'yes,' how often it was actually right.Full glossary →/Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → trade-off is a choice about whose errors matter more, and ML systems have a documented track record of making that choice badly when no one's watching.
Per-subgroup error analysis is non-negotiable. A classifier with 95% overall The share of guesses the model got right out of all its guesses.Full glossary → can have 99% accuracy on one demographic group and 80% accuracy on another. The textbook example is the Gender Shades audit (Buolamwini & Gebru 2018): commercial face-classification systems had 30+ percentage-point gaps in accuracy between light-skinned men and dark-skinned women. The classifiers were "accurate". The disparate impact was the problem. ProPublica's investigation of COMPAS (13-fastbook/03_ethics §propublica-compas) found a similar pattern: similar overall AUC, very different false-positive rates across racial groups. The fix is mechanical: every classification report you generate should be segmented by relevant protected attributes, and you should flag any per-subgroup metric that differs by more than (say) 5 percentage points from the population average. sklearn.metrics.classification_report segmented by group is a 10-line check. The deeper habit: the classifier you ship is the classifier whose disparate impact you have measured.
A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → How well a model's stated confidence matches how often it's actually right.Full glossary → is a safety property. When you say "80% probability of fraud", and downstream business logic acts on that number (set a $1000 hold, route to manual review, deny the transaction), miscalibrated probabilities translate into miscalibrated decisions. Anthropic's work on LLM calibration (06-practice/lilianweng-posts-2024-07-07-hallucination §calibration) and the AI Safety Fundamentals curriculum (05-safety/aisf-alignment §evaluation) both emphasize this. The mechanism that hurts here: neural network classifiers tend to be overconfident, so the model's "80% confident" is empirically more like "65% true". Decisions made by treating the 80% as gospel will systematically over-trigger the downstream action. The mitigation is the CalibratedClassifierCV you saw in §12. The safety habit: every classifier shipped to production has a calibration curve attached to its release notes.
Threshold choice is policy. When you pick the decision threshold for a classifier, you are picking an operating point on the precision-recall curve. That choice has consequences: setting a low threshold for "fraudulent transaction" catches more fraud but blocks more legitimate users. Setting a high threshold for "harmful content" lets through more legitimate speech but also more harm. The choice is a policy question, not a technical one. The OWASP ML Security Top 10 and the AI Safety Atlas (05-safety/aisafetyatlas-index) both frame this as decision-time governance: the threshold should be chosen explicitly, documented, audited, and re-evaluated quarterly. The thing not to do: ship with threshold 0.5 because it was the sklearn default. The 0.5 threshold is correct only when (a) the classes are balanced and (b) the costs of FP and FN are equal. Neither is true in any safety-relevant application.
Habits to adopt before you ship any classifier:
- Always report per-subgroup metrics. Aggregate accuracy is the headline; per-subgroup is the truth.
- Always plot a calibration curve. A miscalibrated 99%-accurate classifier hurts more than a calibrated 90% one in many downstream contexts.
- Always pick the threshold explicitly. Document the chosen operating point and re-evaluate when context changes.
FIG 03.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A digit classifier from a one-line baseline, then a real model you can defend.
- The confusion matrix, precision/recall, and the ROC and PR curves — computed by hand from the same predictions, then checked against scikit-learn.
- A threshold you choose on purpose, and the evidence that 91% accuracy can be the worst model in the room.
~2 min on CPU · 35 cells · 4 checked exercises · runs in Colab
FIG 03.7 · Going further
08-geron-notebooks/03_classificationthe full Géron chapter. Read after this distillation.
13-fastbook/04_mnist_basicsand13-fastbook/05_pet_breeds— fast.ai's coverage of classification with neural networks. Different angle.13-fastbook/06_multicatmulti-label classification, in depth.
03-curricula/google-mlcc-classificationGoogle's MLCC unit. Excellent on metric intuition.
03-curricula/google-mlcc-logistic-regressionGoogle's logistic regression unit. Visual.
16-d2l-sections/chapter_linear-classification__softmax-regression-scratchD2L's from-scratch softmax regression.
02-code-refs/amidi-cs229-supervisedStanford CS229's classification cheat sheet.
06-practice/lilianweng-posts-2024-07-07-hallucinationLilian Weng on calibration and hallucination. The eval-metrics part is the relevant chunk.
01-explorables/seeingtheory-regression-analysisinteractive companion to the logistic curve. 10 minutes well spent.
FIG 03.8 · What this enables
Chapters you can now read, with the connecting idea written out.
We will derive logistic regression from maximum likelihood and explain its convex loss. The pattern here ("predicted minus target gradient") is the foundation.
We replace linear models with decision trees and random forests; all the metric machinery from this chapter carries over.
A logistic regression is a one-layer neural network. The loss, the gradient, the optimization are identical.
Next-token prediction is multi-class classification over a 50,000-token vocabulary. The cross-entropy from §8 is the loss for every modern LLM.
FIG 03.9 · 25 sources
- 01-explorables/seeingtheory-regression-analysis
- 02-code-refs/amidi-cs229-ml-tips
- 02-code-refs/amidi-cs229-supervised
- 03-curricula/google-mlcc-classification
- 03-curricula/google-mlcc-logistic-regression
- 04-stanford/cs229-main-notes-pdf
- 05-safety/aisafetyatlas-index
- 05-safety/aisf-alignment
- 06-practice/lilianweng-posts-2024-07-07-hallucination
- 06-practice/madewithml-mlops-eda
- 06-practice/madewithml-mlops-evaluation
- 06-practice/madewithml-mlops-training
- 08-geron-notebooks/03_classification
- 08-geron-notebooks/04_training_linear_models
- 08-geron-notebooks/05_support_vector_machines
- 11-polo-club/cnn-explainer
- 12-karpathy-code/makemore_part1_bigrams
- 13-fastbook/03_ethics
- 13-fastbook/04_mnist_basics
- 13-fastbook/05_pet_breeds
- 13-fastbook/06_multicat
- 16-d2l-sections/chapter_linear-classification__classification
- 16-d2l-sections/chapter_linear-classification__image-classification-dataset
- 16-d2l-sections/chapter_linear-classification__softmax-regression
- 16-d2l-sections/chapter_linear-classification__softmax-regression-scratch