← All blogs

Deep Learning CourseLesson 4 of 16 22 min

Is My Model Any Good?

What you'll learn: how to tell whether a model is actually good. We'll train a fraud detector that scores 96% accuracy and then discover it is barely better than doing nothing. From there: train, validation and test sets, the confusion matrix, precision, recall, F1, ROC curves and AUC. The questions along the way lead to why we train on a loss but report accuracy, where the −log p in cross-entropy comes from (likelihood and the Bernoulli distribution), why F1 uses a harmonic mean, and how much to trust a test score (the binomial distribution).

Before you start: Lessons 1 to 3. You'll need probability distributions (Lesson 1 and Lesson 3) and the idea of a training loop.

A model with 96% accuracy

We generate a dataset of 10,000 card transactions, each described by 20 numeric features, where 5% are fraudulent. Then we train a small PyTorch network to spot fraud and measure its accuracy on transactions it hasn't seen:

Python
import numpy as np
import torch
from torch import nn
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=10_000, n_features=20, n_informative=6,
                           weights=[0.95, 0.05], class_sep=0.8, random_state=0)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)

mean, std = X_train.mean(axis=0), X_train.std(axis=0)              # standardise (Lesson 3)
Xtr = torch.tensor((X_train - mean) / std, dtype=torch.float32)
Xte = torch.tensor((X_test - mean) / std, dtype=torch.float32)
ytr = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1)

torch.manual_seed(0)
model = nn.Sequential(nn.Linear(20, 32), nn.ReLU(), nn.Linear(32, 1))
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.BCEWithLogitsLoss()
for step in range(300):
    loss = loss_fn(model(Xtr), ytr)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

with torch.no_grad():
    scores = torch.sigmoid(model(Xte)).squeeze(1).numpy()    # probability of fraud
predictions = (scores >= 0.5).astype(int)
print(f"accuracy: {(predictions == y_test).mean():.1%}")
Output
accuracy: 96.1%

96% sounds excellent. Now compare it with a "model" that doesn't look at the data at all and declares every transaction legitimate:

Python
always_legit = np.zeros_like(y_test)
print(f"accuracy of always saying 'legit': {(always_legit == y_test).mean():.1%}")
Output
accuracy of always saying 'legit': 94.5%

94.5%. Our network beats doing nothing by less than two percentage points. Nobody who saw the details would call it "96% accurate". When one class is rare, accuracy is dominated by the common class and hides almost everything that matters. We need better ways to look.

What just happened

Accuracy counts every mistake equally and every correct answer equally. With 95% legitimate transactions, the easy majority swamps the score. But the two kinds of mistakes have very different costs: missing a fraud loses money, while flagging a legitimate purchase annoys a customer. Evaluating a model means measuring each kind of success and failure separately, on data the model has never touched, and knowing how much to trust the numbers. We take those in turn.

Train, validation and test sets

A model is evaluated on data it didn't learn from, or the score measures memory, not skill. In practice we need three sets, not two. The training set fits the weights. The validation set is for every decision you make while developing: which architecture, which learning rate, which threshold, when to stop. The test set is touched once, at the very end, to report the final number.

Why a separate validation set? Because every decision tuned on the test set leaks a little information about it into the model. Try fifty settings and keep the one with the best test score, and that score is now optimistic: you've selected the luckiest of fifty. The test set must stay unseen to remain an honest estimate.

When data is scarce, k-fold cross-validation reuses it: split the training data into k parts, train k times, each time validating on a different part, and average the k scores. With stratify=y, as in our split above, each part keeps the same 5% fraud rate.

The confusion matrix

Split the predictions by what the model said and what was true. Every prediction falls into one of four boxes:

Predicted fraudPredicted legit
Actually fraudTP (true positive)FN (false negative): missed fraud
Actually legitFP (false positive): false alarmTN (true negative)
Python
from sklearn.metrics import confusion_matrix

tn, fp, fn, tp = confusion_matrix(y_test, predictions).ravel()
print(f"caught fraud (TP): {tp}   missed fraud (FN): {fn}")
print(f"false alarms (FP): {fp}   correct legit (TN): {tn}")
Output
caught fraud (TP): 54   missed fraud (FN): 83
false alarms (FP): 14   correct legit (TN): 2349

Now the problem is visible: of the 137 fraudulent transactions in the test set, the model misses 83, more than half. Accuracy is (TP + TN) / total, and the huge TN count carries it. Two numbers built from the matrix tell the real story.

Precision and recall

precision=TPTP+FPof everything flagged as fraud, how much really was?recall=TPTP+FNof all the real fraud, how much did we catch?\begin{aligned} \text{precision} &= \frac{TP}{TP + FP} && \text{of everything flagged as fraud, how much really was?} \\[6pt] \text{recall} &= \frac{TP}{TP + FN} && \text{of all the real fraud, how much did we catch?} \end{aligned}

Precision answers "when the model raises an alarm, should I believe it?". Recall answers "how much fraud slips through?". A model that flags only the single most suspicious transaction can have perfect precision and terrible recall; a model that flags everything has perfect recall and terrible precision. The always-legit model has a recall of zero, which accuracy completely hid.

Python
from sklearn.metrics import precision_score, recall_score, f1_score

print(f"precision: {precision_score(y_test, predictions):.2f}")
print(f"recall:    {recall_score(y_test, predictions):.2f}")
print(f"F1:        {f1_score(y_test, predictions):.2f}")
Output
precision: 0.79
recall:    0.39
F1:        0.53

Why does F1 use a harmonic mean?

Question 1: F1 combines precision P and recall R into one number. Why not just average them?

F1=21P+1R=2PRP+Rthe harmonic mean of P and RF_1 = \frac{2}{\frac{1}{P} + \frac{1}{R}} = \frac{2PR}{P + R} \qquad \text{the harmonic mean of } P \text{ and } R

The ordinary (arithmetic) average lets a high value hide a low one. The model that flags every transaction has R = 1 and P = 0.05, and their average is 0.525, which sounds respectable for a useless model. The harmonic mean averages the reciprocals, and the reciprocal of a small number is huge, so the result is dragged towards the smaller value: here F1 = 0.095. To score well on F1, a model must be good at both.

The harmonic mean is the natural average for rates. If you drive one kilometre at 30 km/h and another at 90 km/h, your average speed is not 60 but 45, the harmonic mean, because you spend more time on the slow kilometre. Substituting the definitions of P and R also shows F1 has a simple form in the confusion matrix:

F1=2 TP2 TP+FP+FNF_1 = \frac{2\,TP}{2\,TP + FP + FN}
Python
P, R = 0.05, 1.0
print("arithmetic mean:", (P + R) / 2, "  harmonic mean (F1):", round(2 * P * R / (P + R), 3))
print("F1 from counts:", round(2 * tp / (2 * tp + fp + fn), 4), " sklearn:", round(f1_score(y_test, predictions), 4))
Output
arithmetic mean: 0.525   harmonic mean (F1): 0.095
F1 from counts: 0.5268  sklearn: 0.5268

Thresholds: trading precision for recall

The model outputs a probability of fraud; turning it into a yes or no needs a threshold, and 0.5 was an arbitrary choice. Lowering it flags more transactions: recall rises, precision falls. Which threshold is right is a business decision about the cost of each mistake, made on the validation set:

Python
for threshold in [0.5, 0.3, 0.2, 0.1, 0.05]:
    pred = (scores >= threshold).astype(int)
    print(f"threshold {threshold:<5} precision {precision_score(y_test, pred):.2f}"
          f"  recall {recall_score(y_test, pred):.2f}  F1 {f1_score(y_test, pred):.2f}")
Output
threshold 0.5   precision 0.79  recall 0.39  F1 0.53
threshold 0.3   precision 0.60  recall 0.49  F1 0.54
threshold 0.2   precision 0.49  recall 0.55  F1 0.52
threshold 0.1   precision 0.35  recall 0.68  F1 0.46
threshold 0.05  precision 0.25  recall 0.77  F1 0.38

ROC curves and AUC

To evaluate the model across all thresholds at once, plot the true positive rate (recall) against the false positive rate, FP / (FP + TN), as the threshold sweeps from 1 down to 0. That is the ROC curve. A perfect model shoots straight up to the top-left corner; a model that guesses at random follows the diagonal. The area under the curve, AUC, summarises it in one number between 0.5 (random) and 1 (perfect):

Python
from sklearn.metrics import roc_auc_score, roc_curve
import matplotlib.pyplot as plt

fpr, tpr, _ = roc_curve(y_test, scores)
print(f"AUC: {roc_auc_score(y_test, scores):.3f}")
plt.plot(fpr, tpr, label="our model")
plt.plot([0, 1], [0, 1], "--", label="random guessing")
plt.xlabel("false positive rate"); plt.ylabel("true positive rate (recall)")
plt.legend(); plt.show()
Output
AUC: 0.903
ROC curve. The model's curve rises steeply, reaching about 80% recall at a 20% false positive rate, and bends towards the top-left corner, far above the dashed diagonal for random guessing.

What does AUC actually measure?

Question 2: "area under a curve" is a strange thing to optimise. Does AUC have a plainer meaning?

It does, and it is one of the nicest facts in evaluation. AUC equals the probability that a randomly chosen fraudulent transaction gets a higher score than a randomly chosen legitimate one:

AUC=P(score(random positive)>score(random negative))\text{AUC} = P\big(\text{score(random positive)} > \text{score(random negative)}\big)

Why? Sweep the threshold down from the top. Each time it passes a positive example, the curve steps up; each time it passes a negative, the curve steps right. The area accumulated by each rightward step equals the fraction of positives already passed, which is the fraction of positives scored above that negative. Summing over all negatives gives the fraction of (positive, negative) pairs ranked correctly. So AUC measures ranking quality, independent of any threshold. We can check by brute force over every pair:

Python
pos = scores[y_test == 1]
neg = scores[y_test == 0]
pairs_correct = (pos[:, None] > neg[None, :]).mean()       # compare every positive with every negative
print(f"fraction of pairs ranked correctly: {pairs_correct:.3f}   sklearn AUC: {roc_auc_score(y_test, scores):.3f}")
Output
fraction of pairs ranked correctly: 0.903   sklearn AUC: 0.903

Why do we train on loss but report accuracy?

Question 3: if accuracy, recall and F1 are what we care about, why does the training loop minimise cross-entropy instead?

Because training needs gradients, and these metrics don't have useful ones. Accuracy counts predictions on the right side of a threshold. Nudge a weight slightly and either no prediction crosses the threshold, so accuracy doesn't change at all (derivative zero), or one prediction crosses and accuracy jumps (no derivative). As a function of the weights, accuracy is a staircase. Lesson 7 shows that gradient descent needs a function that is continuous and has a slope everywhere; a staircase gives no direction to move. The loss is a smooth stand-in: it rewards the model for putting more probability on the right answer, a little at a time.

Python
w = torch.linspace(-3, 3, 7)
toy_x, toy_y = torch.tensor([1.0, -1.0, 2.0]), torch.tensor([1.0, 0.0, 1.0])
for wi in w:
    p_ = torch.sigmoid(wi * toy_x)
    acc = ((p_ >= 0.5).float() == toy_y).float().mean()
    bce = nn.functional.binary_cross_entropy(p_, toy_y)
    print(f"weight {wi:+.1f}:  accuracy {acc:.2f}   loss {bce:.3f}")
Output
weight -3.0:  accuracy 0.00   loss 4.033
weight -2.0:  accuracy 0.00   loss 2.757
weight -1.0:  accuracy 0.00   loss 1.584
weight +0.0:  accuracy 0.67   loss 0.693
weight +1.0:  accuracy 1.00   loss 0.251
weight +2.0:  accuracy 1.00   loss 0.091
weight +3.0:  accuracy 1.00   loss 0.033

As the weight changes, accuracy jumps between a few values, while the loss changes smoothly and keeps improving even after accuracy has stopped. But why that particular smooth function, −log of a probability?

Where does −log p come from?

Question 4: the binary cross-entropy loss is −[y log p + (1 − y) log(1 − p)]. Where does that formula come from?

Start with a fair question: how good is a model that predicts probabilities? A natural answer is to ask how probable the model thought the labels that actually happened were. That quantity is called the likelihood.

For a single yes/no outcome, the model's prediction p is a Bernoulli distribution: the simplest of all probability distributions, over two outcomes, with P(y = 1) = p and P(y = 0) = 1 − p. Both cases fit in one formula, because a number to the power 0 is 1:

P(y∣p)=p y (1−p)1−yy=1 gives p,y=0 gives 1−pP(y \mid p) = p^{\,y}\,(1 - p)^{1 - y} \qquad y = 1 \text{ gives } p, \quad y = 0 \text{ gives } 1 - p

Assuming the examples are independent, the probability of all n labels together is the product of the individual probabilities. Maximum likelihood estimation chooses the weights that make this product as large as possible:

likelihood=∏iP(yi∣pi)=∏ipi yi (1−pi)1−yi\text{likelihood} = \prod_i P(y_i \mid p_i) = \prod_i p_i^{\,y_i}\,(1 - p_i)^{1 - y_i}

But a product of thousands of numbers below 1 is astronomically small, too small for a computer to store, and products are awkward to differentiate. The logarithm fixes both: log turns products into sums, log(ab) = log a + log b, and because log is increasing, maximising the log of something maximises the thing itself. Finally, we prefer to minimise, so we flip the sign and average:

−1nlog⁡likelihood=−1n∑i[ yilog⁡pi+(1−yi)log⁡(1−pi) ]=binary cross-entropy-\frac{1}{n} \log \text{likelihood} = -\frac{1}{n} \sum_i \big[\, y_i \log p_i + (1 - y_i) \log(1 - p_i) \,\big] \quad = \text{binary cross-entropy}

So the cross-entropy loss isn't an arbitrary formula. Minimising it is maximum likelihood: choosing the weights under which the observed labels were most probable. The same derivation with the categorical distribution, the multi-class version of the Bernoulli, gives the categorical cross-entropy used with softmax in Lesson 1. We can check both the underflow problem and the equivalence:

Python
with torch.no_grad():
    p_train = torch.sigmoid(model(Xtr)).squeeze(1).double().numpy()
y_np = y_train

per_example = p_train ** y_np * (1 - p_train) ** (1 - y_np)       # Bernoulli probability of each label
print("product of all probabilities:", np.prod(per_example))     # about 1e-296: the edge of what a float can hold
print("with twice as much data:      ", np.prod(np.concatenate([per_example, per_example])))   # underflows to 0.0
log_likelihood = np.sum(np.log(per_example))
print("log-likelihood:", round(log_likelihood, 2))
print("-log-likelihood / n:", round(-log_likelihood / len(y_np), 5))
print("PyTorch BCE loss:  ", round(loss_fn(model(Xtr), ytr).item(), 5))   # the same number
Output
product of all probabilities: 1.858588505071179e-296
with twice as much data:       0.0
log-likelihood: -680.95
-log-likelihood / n: 0.09079
PyTorch BCE loss:   0.09079

How much should you trust a test score?

Question 5: model A scores 91% on 200 test examples and model B scores 90%. Is A really better?

Think of each test example as a coin flip that lands "correct" with probability equal to the model's true accuracy, a. The number of correct answers out of n is then a binomial random variable, the distribution of the number of successes in n independent yes/no trials:

P(k correct out of n)=(nk)ak(1−a)n−k(nk)=n!k! (n−k)!mean of the measured accuracy:astandard deviation of the measured accuracy:a(1−a)n\begin{gathered} P(k \text{ correct out of } n) = \binom{n}{k} a^k (1 - a)^{n - k} \qquad \binom{n}{k} = \frac{n!}{k!\,(n - k)!} \\[10pt] \begin{aligned} \text{mean of the measured accuracy:}\quad & a \\[4pt] \text{standard deviation of the measured accuracy:}\quad & \sqrt{\frac{a(1 - a)}{n}} \end{aligned} \end{gathered}

The standard deviation follows from Lesson 3: each trial is a Bernoulli variable with variance a(1 − a), the variances of n independent trials add up to n·a(1 − a), and dividing the count by n to get a proportion divides the variance by n². With a = 0.9 and n = 200, the standard deviation is about 2.1 percentage points, and roughly 95% of measurements fall within two standard deviations, so a measured accuracy of anywhere from about 86% to 94% is unsurprising. A one-point difference between A and B on 200 examples is just noise. Simulation agrees:

Python
rng = np.random.default_rng(0)
a, n_test = 0.90, 200
measured = rng.binomial(n_test, a, size=100_000) / n_test     # 100,000 imaginary test sets
print(f"theory: std {np.sqrt(a * (1 - a) / n_test):.4f}")
print(f"simulation: std {measured.std():.4f}, 95% of scores between "
      f"{np.percentile(measured, 2.5):.3f} and {np.percentile(measured, 97.5):.3f}")
print(f"how often a 90% model scores 91% or more by luck: {(measured >= 0.91).mean():.0%}")
Output
theory: std 0.0212
simulation: std 0.0212, 95% of scores between 0.855 and 0.940
how often a 90% model scores 91% or more by luck: 37%

A model with a true accuracy of 90% scores 91% or better on a 200-example test set more than a third of the time, purely by luck. Because the standard deviation shrinks only like 1/√n, halving the uncertainty takes four times as much test data. For our fraud detector the problem is worse: recall is measured on only 137 frauds, so its uncertainty is several points wide.

Summary

Evaluate on data the model hasn't seen: a training set for fitting, a validation set for every decision, and a test set used once. Accuracy misleads when classes are imbalanced. The confusion matrix separates true and false positives and negatives; precision is TP/(TP + FP), recall is TP/(TP + FN), and F1 is their harmonic mean, which punishes a weak side. The threshold trades precision for recall, the ROC curve shows every threshold at once, and AUC is the probability that a random positive outranks a random negative. We train on cross-entropy because metrics like accuracy are staircases with no useful gradient, and cross-entropy is the negative log-likelihood of a Bernoulli (or categorical) distribution. A measured accuracy has binomial noise with standard deviation √(a(1 − a)/n), so small differences on small test sets mean little.

Exercises

1. Compute precision, recall and F1 of the always-legit model by hand. Which of them is undefined, and why? What does sklearn report?

2. Show that if precision equals recall, then F1 equals both of them.

3. Retrain the fraud model with pos_weight in BCEWithLogitsLoss (try torch.tensor([10.0])) so that missed fraud costs ten times more. How do precision, recall and AUC change? Why does AUC change much less than the others?

4. Derive the loss for a single example when y = 1 and when y = 0, and sketch both as functions of p. Why does the loss go to infinity as p approaches the wrong extreme?

5. How many test examples would you need so that the standard deviation of a 90% accuracy is 0.5 percentage points? Check your answer by simulation.

6. Implement 5-fold cross-validation for the fraud model using sklearn's StratifiedKFold. How much does the AUC vary between folds, and does that match your expectation from Question 5?