Evaluating & Debugging Training
By the end of this lesson you will be able to judge a trained network properly, beyond one accuracy number, and to find out why a network is not learning. You will use a short checklist of sanity checks that catch most training bugs in minutes instead of days.
Neural network bugs are sneaky. A wrong line rarely crashes the program; the model just trains a bit worse, and you may never notice. The checks in this lesson give each kind of bug a visible symptom.
Part 1: evaluate more than accuracy
One accuracy number hides a lot. A model that is 90% accurate overall may fail badly on one class, and that class may be the one that matters. Always look at:
- the confusion matrix: a table of true class against predicted class;
- per-class precision and recall, as explained in Classification Metrics;
- a handful of actual mistakes, looked at by a human.
Think of a board exam result. A school's average marks of 80% sounds fine, until you see that most students scored poorly in maths. The breakdown by subject tells you what to fix.
Step 1: confusion matrix and per-class scores. Here we train a small network on the digits data with scikit-learn (a quick stand-in for any trained model) and break down its test results.
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
from sklearn.metrics import confusion_matrix, classification_report
import warnings
from sklearn.exceptions import ConvergenceWarning
warnings.filterwarnings("ignore", category=ConvergenceWarning) # a quick demo, not a fully converged run
X, y = load_digits(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X / 16.0, y, test_size=0.3, random_state=0, stratify=y)
model = MLPClassifier(hidden_layer_sizes=(16,), max_iter=300, random_state=0).fit(X_train, y_train)
pred = model.predict(X_test)
print(f"overall accuracy: {np.mean(pred == y_test):.3f}")
cm = confusion_matrix(y_test, pred)
print(cm)
print(classification_report(y_test, pred, digits=2))
off = cm - np.diag(np.diag(cm))
i, j = np.unravel_index(off.argmax(), off.shape)
print(f"most common mistake: true {i} predicted as {j}, {off[i, j]} times")overall accuracy: 0.974
[[54 0 0 0 0 0 0 0 0 0]
[ 1 54 0 0 0 0 0 0 0 0]
[ 0 1 52 0 0 0 0 0 0 0]
[ 0 0 1 52 0 1 0 0 0 1]
[ 0 1 0 0 52 0 0 0 1 0]
[ 0 0 0 1 0 53 0 0 1 0]
[ 0 1 0 0 0 0 53 0 0 0]
[ 0 0 0 0 0 0 0 54 0 0]
[ 0 3 0 0 0 0 0 0 49 0]
[ 0 0 0 0 0 1 0 0 0 53]]
precision recall f1-score support
0 0.98 1.00 0.99 54
1 0.90 0.98 0.94 55
2 0.98 0.98 0.98 53
3 0.98 0.95 0.96 55
4 1.00 0.96 0.98 54
5 0.96 0.96 0.96 55
6 1.00 0.98 0.99 54
7 1.00 1.00 1.00 54
8 0.96 0.94 0.95 52
9 0.98 0.98 0.98 54
accuracy 0.97 540
macro avg 0.97 0.97 0.97 540
weighted avg 0.97 0.97 0.97 540
most common mistake: true 8 predicted as 1, 3 timesEach row of the matrix is a true digit, each column a predicted digit, and the diagonal holds the correct answers. The report shows which digits have lower recall (missed often) or lower precision (predicted wrongly often). The last line points straight to the confusion worth investigating first.
Part 2: a debugging checklist
When a network does not learn, or learns badly, work through these checks in order. Each one is quick.
- Look at the data. Print a few examples with their labels, plot a few images. Are the labels right? Are the inputs scaled? Is any class nearly missing?
- Check the loss at the start. With random weights, a classifier should be unsure, so its first loss should be close to
ln(number of classes): about 2.30 for 10 classes. Much higher means the starting outputs are too confident: suspect initialisation or input scaling. - Overfit one small batch. Train on just 10 to 20 examples for a few hundred steps. A working network must memorise them and drive the loss near zero. If it cannot, the bug is in the model, the loss or the update, not in the data size.
- Check the gradients. Are any
Noneor exactly zero? Are some layers' gradients thousands of times bigger than others? - Tune the learning rate. Try a few values spaced by factors of 10 (0.1, 0.01, 0.001) for a short run each.
- Watch the curves. Training and validation loss every epoch tell you about underfitting and overfitting.
- Compare with a simple baseline. If logistic regression beats your network, something is wrong or the network is not needed.
Step 2: check the starting loss, and overfit one batch.
import numpy as np
from sklearn.datasets import load_digits
X, y = load_digits(return_X_y=True)
X = X / 16.0
rng = np.random.default_rng(0)
W1, b1 = rng.normal(0, 1, (64, 32)) * np.sqrt(2 / 64), np.zeros(32)
W2, b2 = rng.normal(0, 1, (32, 10)) * np.sqrt(2 / 32) * 0.1, np.zeros(10) # small last layer
def forward(X):
Z1 = X @ W1 + b1
A1 = np.maximum(0, Z1)
Z2 = A1 @ W2 + b2
P = np.exp(Z2 - Z2.max(axis=1, keepdims=True))
return Z1, A1, P / P.sum(axis=1, keepdims=True)
def loss_of(P, y):
return -np.log(P[np.arange(len(y)), y]).mean()
_, _, P = forward(X)
print(f"check 2, starting loss {loss_of(P, y):.3f} vs ln(10) = {np.log(10):.3f}")
xb, yb = X[:20], y[:20] # check 3: one tiny batch
for step in range(1, 301):
Z1, A1, P = forward(xb)
d = P.copy()
d[np.arange(len(yb)), yb] -= 1
d /= len(yb)
dZ1 = (d @ W2.T) * (Z1 > 0)
W2 -= 0.5 * A1.T @ d
b2 -= 0.5 * d.sum(axis=0)
W1 -= 0.5 * xb.T @ dZ1
b1 -= 0.5 * dZ1.sum(axis=0)
if step in (1, 50, 300):
print(f"check 3, step {step:>3}: loss on the 20 examples {loss_of(P, yb):.4f}")check 2, starting loss 2.297 vs ln(10) = 2.303 check 3, step 1: loss on the 20 examples 2.2929 check 3, step 50: loss on the 20 examples 0.0726 check 3, step 300: loss on the 20 examples 0.0042
The starting loss is close to 2.30, so the network starts out properly unsure. On the tiny batch the loss falls towards zero, so the forward pass, the gradients and the updates all work. Now you can trust the code and look at data size, regularisation or the learning rate.
Step 3: catch a hidden bug. Here is the same check with one deliberate, realistic bug: the softmax is taken over the wrong axis (across the batch instead of across the classes). Nothing crashes.
import numpy as np
from sklearn.datasets import load_digits
X, y = load_digits(return_X_y=True)
X, y = X[:20] / 16.0, y[:20]
rng = np.random.default_rng(0)
W1, b1 = rng.normal(0, 1, (64, 32)) * np.sqrt(2 / 64), np.zeros(32)
W2, b2 = rng.normal(0, 1, (32, 10)) * np.sqrt(2 / 32) * 0.1, np.zeros(10)
for step in range(1, 301):
Z1 = X @ W1 + b1
A1 = np.maximum(0, Z1)
Z2 = A1 @ W2 + b2
P = np.exp(Z2 - Z2.max(axis=0, keepdims=True))
P = P / P.sum(axis=0, keepdims=True) # BUG: axis=0 mixes different examples
d = P.copy()
d[np.arange(len(y)), y] -= 1
d /= len(y)
dZ1 = (d @ W2.T) * (Z1 > 0)
W2 -= 0.5 * A1.T @ d
b2 -= 0.5 * d.sum(axis=0)
W1 -= 0.5 * X.T @ dZ1
b1 -= 0.5 * dZ1.sum(axis=0)
if step in (1, 50, 300):
loss = -np.log(P[np.arange(len(y)), y]).mean()
print(f"step {step:>3}: loss {loss:.4f}, rows sum to 1? {np.allclose(P.sum(axis=1), 1)}")step 1: loss 2.9856, rows sum to 1? False step 50: loss inf, rows sum to 1? False step 300: loss inf, rows sum to 1? False
The starting loss is wrong, the loss does not behave like the working version, and the probabilities in each row do not add up to 1. The one-batch check turned an invisible bug into an obvious one.
Checking gradients in PyTorch
After loss.backward(), every parameter has a .grad. Printing their sizes per layer is a quick health check: a None means the parameter is not connected to the loss (perhaps a layer you never used in forward), and very tiny values in early layers suggest vanishing gradients. This is plain code with its real output.
import torch
from torch import nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(20, 64), nn.Sigmoid(), nn.Linear(64, 64), nn.Sigmoid(),
nn.Linear(64, 64), nn.Sigmoid(), nn.Linear(64, 2))
unused = nn.Linear(5, 5) # a layer someone forgot to use
x, y = torch.randn(32, 20), torch.randint(0, 2, (32,))
loss = nn.functional.cross_entropy(model(x), y)
loss.backward()
print(f"loss {loss.item():.3f} (ln 2 = 0.693)")
for name, p in model.named_parameters():
if name.endswith("weight"):
print(f"{name:<9} gradient size {p.grad.norm().item():.2e}")
print("unused layer gradient:", unused.weight.grad)loss 0.683 (ln 2 = 0.693) 0.weight gradient size 1.67e-03 2.weight gradient size 7.27e-03 4.weight gradient size 5.04e-02 6.weight gradient size 3.63e-01 unused layer gradient: None
The gradient shrinks layer by layer towards the input, the vanishing-gradient pattern of stacked sigmoids. Switching to ReLU or adding batch normalisation would even this out. The unused layer's gradient is None, a clear sign it is not part of the computation.
Symptoms and likely causes
| What you see | Likely causes |
|---|---|
Loss is nan or explodes | Learning rate too high; log(0); no gradient clipping in an RNN |
Loss stuck at ln(classes) | Labels shuffled separately from inputs; learning rate far too low; dead ReLUs; frozen layers |
| Training loss falls, validation loss rises | Overfitting: regularise, augment, stop early, get more data |
| Both losses high and flat | Model too small, learning rate wrong, or a bug: run the one-batch check |
| Validation better than training | Dropout and augmentation are on in training only; usually fine |
| Great validation, poor real-world results | Data leakage, or real data differs from the training data |
| Predictions change between runs of the same input | Forgot model.eval() |
Common mistakes
- Trusting a single accuracy number. Look at the confusion matrix and per-class scores.
- Debugging on the full dataset first. Shrink the problem: one batch, a few classes, a small model.
- Changing many things at once. Change one setting, rerun, compare.
- Not fixing the random seed while debugging. Otherwise you cannot tell a fix from luck.
- Skipping the baseline. A model that cannot beat a simple baseline is not finished.
Key points
- Evaluate with a confusion matrix, per-class precision and recall, and a look at real mistakes.
- The starting loss of a classifier should be close to
ln(number of classes). - A correct network can overfit one small batch to near-zero loss; if not, the bug is in the code.
- Inspect gradients per layer to find disconnected layers and vanishing or exploding gradients.
- Match symptoms (nan, flat loss, rising validation loss) to their usual causes, and change one thing at a time.
Exercise
Introduce a different bug into the working loop from Step 2: shuffle the labels of the 20 examples with rng.permutation(yb) before training, so inputs and labels no longer match. Run the one-batch check again. Can the network still memorise the batch? Why is this check alone not enough to catch label-shuffling bugs, and which check from the list would catch it?
Show solution
import numpy as np
from sklearn.datasets import load_digits
X, y = load_digits(return_X_y=True)
X = X / 16.0
rng = np.random.default_rng(0)
W1, b1 = rng.normal(0, 1, (64, 32)) * np.sqrt(2 / 64), np.zeros(32)
W2, b2 = rng.normal(0, 1, (32, 10)) * np.sqrt(2 / 32) * 0.1, np.zeros(10)
xb, yb = X[:20], rng.permutation(y[:20]) # labels no longer match the images
for step in range(1, 301):
Z1 = xb @ W1 + b1
A1 = np.maximum(0, Z1)
Z2 = A1 @ W2 + b2
P = np.exp(Z2 - Z2.max(axis=1, keepdims=True))
P /= P.sum(axis=1, keepdims=True)
d = P.copy()
d[np.arange(20), yb] -= 1
d /= 20
dZ1 = (d @ W2.T) * (Z1 > 0)
W2 -= 0.5 * A1.T @ d
b2 -= 0.5 * d.sum(axis=0)
W1 -= 0.5 * xb.T @ dZ1
b1 -= 0.5 * dZ1.sum(axis=0)
print(f"loss on 20 shuffled examples after 300 steps: {-np.log(P[np.arange(20), yb]).mean():.4f}")loss on 20 shuffled examples after 300 steps: 0.0073
The network still memorises the 20 wrong pairs, because a network can memorise anything, even nonsense. So the one-batch check proves the code can learn, but not that the data is right. Check 1 (look at examples with their labels) catches this bug, and so does the validation curve: with shuffled labels, validation accuracy stays at chance.
Quick Quiz
1. A 10-class classifier starts training with a loss of 2.30. What does that suggest?
ln(10) ≈ 2.30 is the loss of predicting all classes equally.
2. Why try to overfit one small batch?
If it cannot drive the loss near zero on 20 examples, there is a bug in the code.
3. A parameter's .grad is None after loss.backward(). What is the likely reason?
Only parameters that take part in the forward computation receive gradients.
4. Training loss falls while validation loss rises. What is happening?
The model is memorising the training set; regularise or stop earlier.
Learn Deep Learning with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
