Skip to main content

Deep Learning Training Loop Explained: Build Your First Model from Scratch

Calculating read time…

A training loop is the short, repeating piece of code that actually teaches a neural network: it shows the model some data, measures how wrong its guess was, figures out which direction to nudge each weight, and makes that nudge — over and over, thousands of times, until the guesses get good. Everything else in deep learning — architectures, datasets, GPUs — exists to feed this one small loop. 🔁

If you can read and modify a training loop with confidence, you can understand almost any deep learning codebase you'll encounter, because the loop itself barely changes between a tiny toy model and a large production system — what changes around it (data pipelines, distributed strategies, logging) is built on top of this same five-line core. This post builds one from nothing, explains every single line, and then shows what a real one looks like in production. 🌱

Circular diagram of one training step showing four stations in a loop: forward pass, compute loss, backward pass, and optimizer step, with an arrow labeled zero gradients connecting back to the start, plus a side panel showing an occasional evaluation checkpoint where the model switches to evaluation mode

🔀 Quick Comparison: Training Step vs. Evaluation Step

Property Training step Evaluation step
Model mode model.train() model.eval()
Gradient tracking On — needed for backward() Off — wrapped in torch.no_grad()
Weight updates Yes — optimizer.step() runs Never
Dropout / BatchNorm Active / uses batch statistics Disabled / uses learned running statistics
Purpose Learn from this batch Measure honest performance

1. Foundations: What a Training Loop Actually Does

💭 Analogy first: think of learning to shoot free throws. You take a shot (guess), watch where the ball lands relative to the hoop (measure the error), think about what you did wrong — too much arc, not enough force (figure out the adjustment), and slightly change your form for the next shot (apply the adjustment). You repeat this hundreds of times. Nobody sits down and derives the physics equations for the perfect shot in advance — the correction comes from repeated, measured attempts.

A neural network "learns" the exact same way. It has a large number of adjustable numbers (weights). It doesn't know good values for them at the start — they're usually random. The training loop is the mechanism that repeatedly: makes a guess with the current weights, measures exactly how wrong that guess was, calculates which direction each weight should move to be less wrong, and moves each weight a small step in that direction. Do this enough times, on enough examples, and the weights settle into values that make good predictions.

What fails without a correctly structured loop is almost total: skip the measurement step and there's no signal for what to fix; skip the "move each weight" step and the model computes what's wrong but never actually improves; get the order of operations wrong and you can silently train on stale or doubled-up signals without any error message at all.

🎯 Use this when you're brand new to deep learning and need the plain-language version before looking at any code.

2. The Five-Step Core: Forward, Loss, Backward, Step, Zero

💭 Analogy first: an archer draws the bow (prepares), releases an arrow (makes a guess), looks at how far off-center it landed (measures error), works out how much to adjust their aim to correct for that specific miss (figures out the direction to correct), and resets their stance completely before the next arrow so old adjustments don't carry over and stack up (clears out old corrections).

Every framework's training loop, no matter how it's dressed up, is this same five-step sequence:

  1. Forward pass: feed a batch of inputs through the model to get predictions — pred = model(x).
  2. Compute loss: compare the predictions to the true answers with a loss function that returns a single number measuring how wrong they were — loss = loss_fn(pred, y).
  3. Backward pass: call automatic differentiation to compute, for every weight in the model, exactly how much that weight contributed to the error — loss.backward().
  4. Optimizer step: nudge every weight a small amount in the direction that reduces the error — optimizer.step().
  5. Zero gradients: clear out the error-direction calculations before the next batch, since they accumulate (add up) by default rather than being replaced — optimizer.zero_grad().

Why step 5 exists and is easy to forget: PyTorch's design deliberately makes .backward() add newly computed gradients onto whatever is already stored, rather than overwrite them — this is what makes advanced techniques like gradient accumulation across multiple small batches possible. The cost of that flexibility is that an ordinary training loop must explicitly clear gradients each step, or they will silently keep piling up.

What fails without this exact sequence: skip zeroing and gradients from every previous batch keep adding into the current one, making the effective step size grow uncontrollably as training proceeds — the loss can look fine for a while and then destabilize with no obvious single cause. Call optimizer.step() before loss.backward() and there's nothing to step with yet — gradients haven't been computed.

3. Train Mode vs. Eval Mode

💭 Analogy first: a student rehearsing with flashcards randomly shuffled and some deliberately hidden (to force generalization) behaves differently than the same student sitting a real, timed exam using everything they've got. The model needs to know which situation it's in.

What it does: certain layers — most notably dropout (which randomly disables neurons during training to prevent overfitting) and batch normalization (which uses statistics from the current batch during training but switches to stored, learned statistics afterward) — genuinely behave differently depending on whether the model is training or being evaluated. Calling model.train() or model.eval() tells every layer in the model, recursively, which behavior to use.

Why it's needed: if evaluation accidentally runs with dropout still active, the model's outputs become randomly noisy and reported accuracy becomes unreliable and non-reproducible from run to run — you'd be measuring randomness, not the model.

The second, separate piece — gradient tracking: during evaluation you only want the model's output, not to update its weights, so tracking gradients is pure wasted computation and memory. Wrapping evaluation code in torch.no_grad() disables gradient tracking for everything inside that block. This is a completely separate switch from model.eval() — beginners very commonly conflate the two, but you need both: one changes layer *behavior*, the other changes whether *gradients* get computed at all.

💡 Trade-off/warning: forgetting to call model.eval() before validation and model.train() before resuming training is a famously silent bug — the code runs, produces numbers, and gives no error, but those numbers are quietly wrong.

4. Real Example: PyTorch's Official Train/Test Loop

PyTorch's own "Optimizing Model Parameters" tutorial documents the canonical structure directly, and it's worth reading line by line because every production training script is a more elaborate version of exactly this. The documented train_loop sets the model to training mode with a comment explicitly noting this matters for batch normalization and dropout layers, then for each batch computes the prediction and loss, and performs backpropagation in the order loss.backward(), optimizer.step(), optimizer.zero_grad().

The companion test_loop documented alongside it sets the model to evaluation mode — again with an explicit comment that this matters for the same batch normalization and dropout layers — and performs its forward passes without computing gradients, since nothing will be updated based on test data.

✅ Worked example: the documented pattern calls both functions once per epoch: train_loop(train_dataloader, model, loss_fn, optimizer) followed by test_loop(test_dataloader, model, loss_fn) — train on the full training set, then immediately check honest performance on held-out data, every single epoch.

5. Tracking Progress: Loss and Validation

A number alone doesn't tell you much without a comparison point. Two numbers matter from the start:

  1. Training loss: the average loss on the data the model is actively learning from. This should generally trend downward, epoch over epoch.
  2. Validation loss: the average loss on a held-out set the model never trains on. This tells you whether the model is actually generalizing, or just memorizing the training set.

The gap between these two numbers is one of the most important diagnostics in all of deep learning. Training loss dropping while validation loss stays flat or rises is the classic signature of overfitting — the model is getting better at the training examples specifically, not at the underlying pattern. Both trending down together, roughly in step, is the sign of healthy learning. Neither improving usually points to a more fundamental problem: a learning rate that's off, a bug in the loss calculation, or data that isn't set up correctly for the task.

🎯 Use this when your loss number is moving but you're not sure whether that's actually good news.

6. Implementation: Your First Complete Loop

Here is a full, runnable, beginner-friendly example combining everything above — a small model, a synthetic regression task, training and validation, all in one file. Every numeric value is a reasonable illustrative starting point, not a rule.

import torch from torch import nn from torch.utils.data import TensorDataset, DataLoader # 1. Data: a toy regression problem torch.manual_seed(0) X = torch.randn(1000, 10) true_w = torch.randn(10, 1) y = X @ true_w + 0.1 * torch.randn(1000, 1) train_X, val_X = X[:800], X[800:] train_y, val_y = y[:800], y[800:] train_loader = DataLoader(TensorDataset(train_X, train_y), batch_size=32, shuffle=True) val_loader = DataLoader(TensorDataset(val_X, val_y), batch_size=32) # 2. Model, loss, optimizer model = nn.Sequential(nn.Linear(10, 32), nn.ReLU(), nn.Linear(32, 1)) loss_fn = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) def train_one_epoch(): model.train() for xb, yb in train_loader: pred = model(xb) loss = loss_fn(pred, yb) loss.backward() optimizer.step() optimizer.zero_grad() def evaluate(): model.eval() total_loss = 0.0 with torch.no_grad(): for xb, yb in val_loader: pred = model(xb) total_loss += loss_fn(pred, yb).item() * xb.size(0) return total_loss / len(val_loader.dataset) # 3. The actual training loop num_epochs = 20 for epoch in range(num_epochs): train_one_epoch() val_loss = evaluate() print(f"epoch {epoch+1}/{num_epochs} val_loss={val_loss:.4f}")

Notice the shape: a small setup section, then two short functions (one for a training epoch, one for evaluation) that are each barely more than the five-step core described earlier, called in a simple outer loop over epochs. This structure — not the specific model or dataset — is what stays consistent as projects grow.

7. Enterprise Rollout: Making Training Reliable and Reproducible

A training loop that runs once on a laptop is a different thing from a training loop a team can trust to run unattended, be reproduced later, and be safely modified by more than one person.

Reproducibility: seed random number generators (data shuffling, weight initialization) and log the exact code version, data snapshot, and hyperparameters used for every run. Without this, "why did this run get a different result" becomes an unanswerable question.

Checkpointing: save model weights (and ideally optimizer state) periodically during training, not only at the end — a crash at epoch 47 of 50 shouldn't mean starting over from epoch 0. Keep the best checkpoint by validation metric, not just the most recent one.

Experiment tracking: log training and validation metrics for every run to a system the whole team can see and compare, rather than reading them off a terminal that will be closed and forgotten.

Early stopping and rollback criteria: define, in advance, what validation metric threshold or how many epochs without improvement should stop a run automatically — deciding this only after staring at a confusing loss curve for an hour is a bad time to design the policy.

Resource and cost monitoring: a training loop consumes real GPU-hours; alert on jobs that run far longer than their historical baseline, since that's often the first visible symptom of a stalled data pipeline or an accidental infinite loop.

Ownership and access controls: changes to the core training loop should go through the same review process as any other critical code, since a subtle bug here (like the ordering or zeroing mistakes below) can silently degrade every model trained afterward.

✅ Practical pattern: save a checkpoint whenever validation loss improves, keep the last few checkpoints as a safety margin, and log the exact command and configuration used to launch every run next to its results.

8. Common Mistakes

Forgetting optimizer.zero_grad(). Because gradients accumulate by design, skipping this step lets each new batch's gradients add onto the last, silently growing the effective step size until training becomes unstable — with no error message pointing at the cause.

Never switching modes. Leaving the model in training mode during validation (or vice versa) changes dropout and batch normalization behavior, quietly making reported metrics unreliable and non-reproducible.

Forgetting torch.no_grad() during evaluation. Evaluation will still work without it, but memory usage climbs unnecessarily as a computation graph is built and never used — on a large model this can be the difference between fitting on the available GPU memory and an out-of-memory crash.

Comparing validation loss computed with a different batch size or reduction than training loss. If one uses summed loss and the other uses mean loss, the two numbers aren't on the same scale, making the training-vs-validation comparison in Section 5 meaningless without realizing it.

Choosing an unreasonable learning rate. Too high and the loss oscillates or diverges to infinity/NaN; too low and training appears to make no progress at all, which is easy to misdiagnose as a bug in the loop itself rather than a hyperparameter choice.

❓ FAQ

What's the difference between a step, a batch, and an epoch?

A batch is one group of examples processed together. A step is one full pass through the five-step core (forward, loss, backward, step, zero) using one batch. An epoch is one complete pass through the entire training dataset, made up of many steps.

Why do we need a separate validation set instead of just checking training loss?

Training loss only tells you how well the model fits the exact examples it's learning from, which it can do by memorizing rather than by learning a generalizable pattern. A held-out validation set, never used for weight updates, is the only honest way to check whether the model will perform well on new data.

Do I always need to call model.eval() and torch.no_grad() together?

They're used together for evaluation, but they do different jobs: model.eval() changes how specific layers like dropout and batch normalization behave, while torch.no_grad() stops gradient tracking to save memory and computation. Use both for standard evaluation, but understand they're independent switches.

My loss isn't going down at all — what should I check first?

Check the five-step order and confirm zero_grad, backward, and step are all present and in a sensible order; check that the learning rate isn't absurdly small or large; and confirm the loss function actually matches the task (for example, a classification loss on a regression problem will not learn correctly).

Is this five-step loop the same in every deep learning framework?

The underlying idea — predict, measure error, compute gradients, update weights, reset — is universal. The exact syntax differs: TensorFlow's custom training loops use a GradientTape to record operations instead of calling backward() directly on a loss tensor, but the same five conceptual steps are present underneath.

🔗 References & Further Reading

PyTorch is a trademark of its respective owners;

📝 Summary

  • A training loop repeatedly predicts, measures error, computes gradients, updates weights, and resets — this five-step core is universal.
  • Gradients accumulate by default, which is why zeroing them each step is required, not optional.
  • Train mode and eval mode change how layers like dropout and batch normalization behave, and are independent from whether gradients are tracked.
  • torch.no_grad() disables gradient tracking during evaluation to save memory and computation.
  • PyTorch's own documentation defines the canonical train_loop/test_loop structure that real projects build on top of.
  • Comparing training loss to validation loss is the core diagnostic for detecting overfitting versus healthy learning.
  • A complete beginner loop is just a small setup section plus two short functions called from an outer epoch loop.
  • Enterprise training adds reproducibility, checkpointing, experiment tracking, and defined stopping criteria on top of the same core loop.
  • Most real-world training bugs are silent — missing zero_grad, wrong mode, missing no_grad — rather than loud crashes.

Thanks for reading — may your loss curves trend down and your gradients stay well-behaved. 🚀

Comments