Ch. 03

Classification

Confusion matrix vocabulary, precision-recall tradeoffs, multi-label, the metric-choice that lies.

metricsconfusion-matrixROCPR-curve

FIG 03 · Explainer video


A classifier is a function from inputs to a finite set of labels. The simplest one outputs a single integer in {0,1,,K1}\{0, 1, \ldots, K-1\}, the better ones output a distribution over those KK 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 is multi-class classification over a 50,000-token . 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. lies. lies. 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 SGDClassifier and 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


FIG 03.3.1

The classification setup

Given training data {(xi,yi)}i=1N\{(x_i, y_i)\}_{i=1}^N with yi{1,2,,K}y_i \in \{1, 2, \ldots, K\}, learn a function f:X{1,,K}f: \mathcal{X} \to \{1, \ldots, K\}. Sometimes ff also outputs class probabilities p(y=kx)p(y = k \mid x), and the discrete prediction is argmaxkp(y=kx)\arg\max_k p(y = k \mid x).

Three flavors:

  • Binary classification: K=2K = 2. Spam vs. not spam, cancer vs. healthy, click vs. no-click.
  • Multi-class classification: K>2K > 2, each example has exactly one true class. MNIST (digits 0-9), CIFAR (10 categories), language-model next- (vocab size KK).
  • 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.

Python
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:

Python
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 class

This is an imbalanced binary problem. Random guessing would predict "not 5" for everything and achieve 91% . This is the trap that motivates every other metric in the chapter.

Library path with SGDClassifier:

Python
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 . 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 max(0,1ys)\max(0, 1 - y \cdot s), where s=wx+bs = w^\top x + b is the score and y{1,+1}y \in \{-1, +1\}, 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 .

FIG 03.3.3

Why accuracy lies (and what to use instead)

For the "is this a 5" problem, is misleading because the classes are imbalanced. A dummy classifier that always predicts "not 5" gets 91% accuracy and learns nothing. To see this:

Python
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.91

To meaningfully compare your classifier to the dummy, you need a metric that penalizes the "always negative" strategy. The first such metric is balanced accuracy:

balanced acc=12(TPTP+FN+TNTN+FP)=12(TPR+TNR)\text{balanced acc} = \frac{1}{2}\left(\frac{TP}{TP + FN} + \frac{TN}{TN + FP}\right) = \frac{1}{2}(\text{TPR} + \text{TNR})

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 .

FIG 03.3.4

The confusion matrix

The is the four-number summary of binary classification performance.

predicted negpredicted pos
actual negTNFP
actual posFNTP
Python
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:

accuracy=TP+TNTP+TN+FP+FN\text{accuracy} = \frac{TP + TN}{TP + TN + FP + FN} precision=TPTP+FP("of things I called 5, what fraction really were?")\text{precision} = \frac{TP}{TP + FP} \quad \text{("of things I called 5, what fraction really were?")} recall (sensitivity, TPR)=TPTP+FN("of true 5s, what fraction did I catch?")\text{recall (sensitivity, TPR)} = \frac{TP}{TP + FN} \quad \text{("of true 5s, what fraction did I catch?")} specificity (TNR)=TNTN+FP("of true non-5s, what fraction did I correctly reject?")\text{specificity (TNR)} = \frac{TN}{TN + FP} \quad \text{("of true non-5s, what fraction did I correctly reject?")} F1=2precisionrecallprecision+recall\text{F1} = \frac{2 \cdot \text{precision} \cdot \text{recall}}{\text{precision} + \text{recall}}
Python
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 output, you choose a threshold τ\tau. Predict positive if p(y=1x)>τp(y = 1 \mid x) > \tau. As you raise τ\tau, you get fewer positive predictions, so goes up and goes down. As you lower τ\tau, 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 s=wx+bs = w^\top x + b 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.

Python
# 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:

Python
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):

Python
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:

Python
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 () on the y-axis against FPR (false positive rate) on the x-axis, varying the threshold.

FPR=FPFP+TN\text{FPR} = \frac{FP}{FP + TN}

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.

Python
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 ( 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 outputs, you want logistic regression: a linear model trained to maximize the likelihood under a -of-linear model.

p(y=1x)=σ(wx+b)=11+e(wx+b)p(y = 1 \mid x) = \sigma(w^\top x + b) = \frac{1}{1 + e^{-(w^\top x + b)}}

The loss is binary :

L(w,b)=1Ni=1N[yilogσ(wxi+b)+(1yi)log(1σ(wxi+b))]L(w, b) = -\frac{1}{N} \sum_{i=1}^N \left[ y_i \log \sigma(w^\top x_i + b) + (1 - y_i) \log(1 - \sigma(w^\top x_i + b)) \right]

This is NLL under the Bernoulli model The with respect to ww has a clean form: 1Ni(σ(wxi+b)yi)xi=1NX(p^y)\frac{1}{N} \sum_i (\sigma(w^\top x_i + b) - y_i) x_i = \frac{1}{N} X^\top (\hat{p} - y). The "predicted minus actual" times input. Recognize it.

LogisticRegression vs. from scratch

Classical ML
LIBRARY
clf = 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,) ~ b
FROM SCRATCH
def 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. 1clf.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. 2the 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. 3the lbfgs (default) quasi-Newton step the solver takes dw = 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. 4clf.coef_ and clf.intercept_ after fitting the returned w (shape (d,)) and scalar b
  5. 5clf.predict_proba(X)[:, 1] predict_proba: sigmoid(X @ w + b)
  6. 6clf.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 KK classes. The model produces KK logits and a turns them into a distribution:

zk=wkx+bkz_k = w_k^\top x + b_k p(y=kx)=ezkj=1Kezjp(y = k \mid x) = \frac{e^{z_k}}{\sum_{j=1}^K e^{z_j}}

This is the softmax regression (also called multinomial logistic regression) classifier. The loss is categorical :

L=1Ni=1Nlogp(y=yixi)=1Nilogezyi(i)jezj(i)L = -\frac{1}{N} \sum_{i=1}^N \log p(y = y_i \mid x_i) = -\frac{1}{N} \sum_i \log \frac{e^{z_{y_i}^{(i)}}}{\sum_j e^{z_j^{(i)}}}

The with respect to logits zkz_k for example ii is pk(i)1[yi=k]p_k^{(i)} - \mathbf{1}[y_i = k]. Predicted minus one-hot target. Same shape as logistic regression's p^y\hat{p} - y.

Python
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.91

One-vs-rest and one-vs-one decompositions. Older multi-class strategies that build a KK-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 kk 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.

Python
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 is a K×KK \times K array where entry (i,j)(i, j) is the count of examples with true class ii predicted as jj.

Python
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:

  1. Find the largest off-diagonal entries. These are the most confused class pairs.
  2. For each confused pair, look at a sample of misclassified images. What do they have in common?
  3. Hypothesize what the model is missing or what training data is underrepresented.
Python
# 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-KK binary vector y{0,1}Ky \in \{0, 1\}^K where yk=1y_k = 1 if class kk applies.

The model output is KK independent outputs (not one ). Loss is the sum of KK binary cross-entropies. This is not the same as multi-class.

Python
# 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] prediction

In PyTorch, the loss is F.binary_cross_entropy_with_logits applied to a KK-dim sigmoid output. This is the loss for image tagging, multi-topic classification, and many recommender system formulations.

Metric for multi-label. Per-class //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.
Python
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:

  1. Predict the majority class for almost everything.
  2. Have high but very low on the minority class.
  3. Produce miscalibrated probabilities skewed toward the majority class.

Three families of fixes:

Class weights. Re- the loss so each class contributes equally to the regardless of frequency. sklearn supports this via class_weight='balanced':

Python
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 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 , 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 floor). This is often the cleanest fix and gets ignored.

Python
# 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% 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.

Python
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 curve, also called a reliability diagram. A perfectly calibrated classifier lies on the diagonal. Overconfident classifiers fall below it.

Two fixes:

Platt . Fit a logistic regression on the with the classifier's scores as the only . Use the fitted 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:

Python
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--aware classification pipeline.

Python
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 are broadly useful. Class weights and 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 / 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 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.

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_classification

    the full Géron chapter. Read after this distillation.

  • 13-fastbook/04_mnist_basics and 13-fastbook/05_pet_breeds — fast.ai's coverage of classification with neural networks. Different angle.
  • 13-fastbook/06_multicat

    multi-label classification, in depth.

  • 03-curricula/google-mlcc-classification

    Google's MLCC unit. Excellent on metric intuition.

  • 03-curricula/google-mlcc-logistic-regression

    Google's logistic regression unit. Visual.

  • 16-d2l-sections/chapter_linear-classification__softmax-regression-scratch

    D2L's from-scratch softmax regression.

  • 02-code-refs/amidi-cs229-supervised

    Stanford CS229's classification cheat sheet.

  • 06-practice/lilianweng-posts-2024-07-07-hallucination

    Lilian Weng on calibration and hallucination. The eval-metrics part is the relevant chunk.

  • 01-explorables/seeingtheory-regression-analysis

    interactive 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
  1. 01-explorables/seeingtheory-regression-analysis
  2. 02-code-refs/amidi-cs229-ml-tips
  3. 02-code-refs/amidi-cs229-supervised
  4. 03-curricula/google-mlcc-classification
  5. 03-curricula/google-mlcc-logistic-regression
  6. 04-stanford/cs229-main-notes-pdf
  7. 05-safety/aisafetyatlas-index
  8. 05-safety/aisf-alignment
  9. 06-practice/lilianweng-posts-2024-07-07-hallucination
  10. 06-practice/madewithml-mlops-eda
  11. 06-practice/madewithml-mlops-evaluation
  12. 06-practice/madewithml-mlops-training
  13. 08-geron-notebooks/03_classification
  14. 08-geron-notebooks/04_training_linear_models
  15. 08-geron-notebooks/05_support_vector_machines
  16. 11-polo-club/cnn-explainer
  17. 12-karpathy-code/makemore_part1_bigrams
  18. 13-fastbook/03_ethics
  19. 13-fastbook/04_mnist_basics
  20. 13-fastbook/05_pet_breeds
  21. 13-fastbook/06_multicat
  22. 16-d2l-sections/chapter_linear-classification__classification
  23. 16-d2l-sections/chapter_linear-classification__image-classification-dataset
  24. 16-d2l-sections/chapter_linear-classification__softmax-regression
  25. 16-d2l-sections/chapter_linear-classification__softmax-regression-scratch