EthicsComputerCourses
Join training +91 90532 10052

Evaluating & Debugging Training

Deep Learning · Lesson 27 of 30 Advanced 7 min read Updated

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:

  1. the confusion matrix: a table of true class against predicted class;
  2. per-class precision and recall, as explained in Classification Metrics;
  3. 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.

PythonRun it yourself
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")
Output
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 times

Each 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.

  1. 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?
  2. 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.
  3. 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.
  4. Check the gradients. Are any None or exactly zero? Are some layers' gradients thousands of times bigger than others?
  5. Tune the learning rate. Try a few values spaced by factors of 10 (0.1, 0.01, 0.001) for a short run each.
  6. Watch the curves. Training and validation loss every epoch tell you about underfitting and overfitting.
  7. 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.

PythonRun it yourself
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}")
Output
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.

PythonRun it yourself
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)}")
Output
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.

Python
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)
Output
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 seeLikely causes
Loss is nan or explodesLearning 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 risesOverfitting: regularise, augment, stop early, get more data
Both losses high and flatModel too small, learning rate wrong, or a bug: run the one-batch check
Validation better than trainingDropout and augmentation are on in training only; usually fine
Great validation, poor real-world resultsData leakage, or real data differs from the training data
Predictions change between runs of the same inputForgot 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
Python
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}")
Output
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?

2. Why try to overfit one small batch?

3. A parameter's .grad is None after loss.backward(). What is the likely reason?

4. Training loss falls while validation loss rises. What is happening?

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