TAI BUI
Back to curriculum
CapstonePython

Training Loop and Evaluation

A loop that does not measure is a loop that lies. This one drives the GPT model with AdamW (weight decay split), a warmup plus cosine learning rate schedule, a `calc_loss_batch` helper, an `evaluate_model` pass on held out data, a `generate_and_print_sample` probe every K steps, and a JSONL log of losses you can plot afterward. The same skeleton trains the decoder LLMs you build later.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 30 to 35

Learning Objectives

  • Build a training loop that computes cross entropy loss with the correct input and target alignment for next token prediction.
  • Configure AdamW with weight decay applied to weight tensors and not to LayerNorm or bias tensors.
  • Implement a learning rate schedule with linear warmup and cosine decay, and read the resulting LR over time.
  • Evaluate on a held out split with `evaluate_model` so the eval loss is comparable across runs.
  • Generate a qualitative sample every K steps with `generate_and_print_sample` to catch divergence before the loss curve does.
  • Persist per step loss to JSONL so you can reload, plot, and ship the training log as a deliverable.

The Problem

A training loop that only prints the loss fails in three ways. It cannot tell you whether the loss is falling for the right reason, because a model can overfit the training set and never learn anything general. It cannot tell you when a divergence is starting: the loss can spike for one step and recover, or spike for one step and crash. And it cannot tell you what the model has learned, since loss is a scalar and a generated sample is a paragraph. All three stay hidden unless the loop measures.

The loop in this lesson measures three things. It logs the loss on the training batch every step. It computes the loss on held out batches every K steps and on the final step. And it generates a continuation from a fixed prompt every K steps. Everything lands in a JSONL log, so the artifact is the loop's testimony.

Warm-up0/3
1. In next-token prediction on a token sequence, what should the target be for the input token at position i?Warm-up
2. Training loss keeps falling, but loss on data the model never trained on starts rising. What is the most likely cause?Warm-up
3. Why do you hold back part of a dataset as a validation split before training?Warm-up

The Concept

Mermaid diagram

A training loop repeats one cycle: forward pass, loss, backward pass, optimizer step. The loop here adds measurement around that cycle. Two pieces are not obvious, the loss alignment and the AdamW decay split, so they come first.

Loss alignment

Loss alignment means the target at each position is the token that follows the input token. If the input batch is tokens [t0, t1, t2, t3], the target batch is [t1, t2, t3, t4]. Cross entropy is computed on the flat logits of shape (batch * seq, vocab) against the flat targets of shape (batch * seq,). Skip the shift and you train the model to predict its own input, which converges to zero loss while learning nothing useful.

AdamW decay split

The decay split gives weight decay to weight tensors and withholds it from normalization scales and biases. Decay on a LayerNorm scale slowly drives the scale toward zero and breaks normalization. Decay on a bias is mathematically harmless but wasted work. So matrix shaped tensors (linear weights, embedding tables) get decay, and anything that acts as a scale or shift does not.

Warmup plus cosine schedule

Warmup ramps the learning rate linearly up to the target over the first few hundred steps (10 in the demo). It starts at max_lr / warmup_steps, not at zero, so step 0 runs at 3e-4 in the demo. The slow start gives the optimizer state time to populate. Cosine decay then lowers the learning rate toward a floor, min_lr, over the remaining steps (3e-4 in the demo, 10 percent of the 3e-3 max_lr), so the final phase adjusts the weights with small steps. This pairing is the most common schedule in open weights LLM training because it removes most of the brittle moments in the first thousand steps and the last thousand steps.

Held out evaluation

evaluate_model runs a fixed number of batches from the validation split, accumulates the loss, divides by the batch count, and returns the mean. It uses no gradient and no dropout. The training loop rebuilds the eval batches at every probe with make_batches on the validation tokens and a fixed seed (cfg.seed + 1), so the same split and seed give the same number across runs. Read the held out loss next to the training loss and overfitting shows up as a gap.

Qualitative sampling as an early signal

A model whose training loss falls nicely but whose samples are all the same token is broken. A model whose loss curve looks flat but whose samples sharpen into coherent words is learning. The probe is quicker to read than the full curve, and it catches failure modes the scalar misses.

Check: The Concept0/16
1. What is loss alignment in next-token training?Define
2. Which parameters receive weight decay in the two-group AdamW setup?Define
3. What is learning rate warmup?Define
4. What are the eval batches in this training loop?Define
5. What is a qualitative probe?Define
6. You build targets by copying the inputs with no shift, and the loss drops to near zero within a few steps. What does that tell you?Explain
7. Why is a LayerNorm scale kept out of the weight decay group?Explain
8. Why does the schedule ramp the learning rate up instead of starting at the maximum?Explain
9. With the demo defaults, where does the learning rate end up after the cosine phase?Explain
10. What does `evaluate_model` do that a training step does not?Explain
11. Why print a generated sample every K steps when the loss is already logged?Explain
12. Training loss falls smoothly, but every printed sample repeats one token. What should you conclude?Analyze
13. A run at the full learning rate diverges within its first few hundred steps. Which missing ingredient is the best fit for this symptom?Analyze
14. A teammate leaves dropout on during `evaluate_model`. The windows are fixed, yet val_loss jumps around between probes. Why?Analyze
15. Two designs for the sample probe: a fixed prompt every K steps, or a new random prompt each time. Which makes samples comparable across the run?Analyze
16. You add a learned per-channel gain vector and a new linear projection matrix to the model. How should `build_param_groups` treat them?Design

Build It

code/main.py implements:

  • make_batches(token_ids, batch_size, context_length, seed) which draws random start positions with a seeded generator, so runs reproduce, and yields input and target pairs (target is the input shifted by one). It is an infinite generator, so callers stop after a fixed number of batches.
  • calc_loss_batch(model, inputs, targets) which runs the forward pass, flattens, and returns the scalar cross entropy.
  • evaluate_model(model, val_loader, max_batches) which iterates a fixed number of validation batches under no grad, stops after max_batches, and returns the mean loss.
  • generate_and_print_sample(model, prompt, max_new_tokens, temperature, top_k, seed) which runs its own sampling loop on a fixed prompt rather than calling lesson 35's function, and prints the generated token list. train calls it with temperature 0.8, top_k 20, and seed=step.
  • build_param_groups(model, weight_decay) which produces the two-group AdamW parameter list.
  • cosine_with_warmup(step, warmup_steps, total_steps, max_lr, min_lr) which returns the learning rate at a given step.
  • train(...) which runs the loop, persists outputs/losses.jsonl, and at every probe step (every eval_every steps and the last) prints the train loss, the eval loss, and a sample.
  • A demo that trains a tiny model on synthetic data for a small number of steps, writes the JSONL log, and prints the eval loss and a sample at the probe points. On CPU it runs in well under a minute.

Run it:

bash
python3 code/main.py

Output: at every probe step, a line with the train loss and eval loss plus a generated token list, and a final outputs/losses.jsonl you can load with json.loads per line. Per step loss goes to the JSONL log, not the console. Each record is {step, train_loss, lr}, plus val_loss at probe steps.

Check: Build It0/9
1. Looking at the demo output, how does `generate_and_print_sample` show what the model generated?Apply
2. With warmup_steps=10 and max_lr=3e-3, what does `cosine_with_warmup` return at step 4?Apply
3. With warmup_steps=10, total_steps=80, max_lr=3e-3 and min_lr=3e-4, what does `cosine_with_warmup` return at step 45?Apply
4. With batch_size 4, context_length 32 and vocab_size 256, which shapes does `calc_loss_batch` pass to cross entropy?Apply
5. With num_steps=80 and eval_every=20, which steps carry a `val_loss` field in the demo log?Apply
6. The token stream is [10, 11, 12, 13, 14, 15, 16, 17] and context_length is 4. `make_batches` draws a window starting at index 2. What is the target?Apply
7. The test calls `evaluate_model` on a model left in train mode, with max_batches=3. What is `model.training` afterward?Apply
8. The test calls `generate_and_print_sample` with a 3-token prompt and max_new_tokens=4. How many tokens does it return?Apply
9. You open `losses.jsonl` and find a line with no `val_loss` field. What is the most likely explanation?Analyze

Stack

  • torch for autograd, the optimizer, and modules.
  • main.py reimplements the lesson 35 GPTModel and its supporting modules locally.

Production patterns in the wild

Three patterns turn the textbook loop into something you can leave running overnight.

Clip the gradient norm. A bad batch (anomalous data, an LR spike, a numerical edge case) can produce a huge gradient that wipes out hours of training. Call torch.nn.utils.clip_grad_norm_(params, max_norm=1.0) after backward and before step to keep the optimizer in a safe range. The clipping value is a free parameter, and one is the default that survives most setups.

Log to resumable JSONL, not pickled state. Per step records shaped like {"step": int, "train_loss": float, "lr": float} are durable. A crash leaves a readable artifact, you can grep it, you can plot it with thirty lines of Python, and you can resume training by reading the last step. Pickled state ties you to the exact module layout that produced the file, which is brittle across refactors.

Draw eval batches from a fixed seed. Build the eval batches from the validation tokens with the same seed at every probe (the demo uses cfg.seed + 1). The windows are random start positions, but a fixed seed makes them identical from probe to probe. Comparing eval loss between two runs only works if the eval batches are identical from run to run. Otherwise the comparison measures the batch shuffle as much as the model.

Check: Production patterns in the wild0/5
1. Two runs differ only in `max_lr`. Why is comparing their `val_loss` values fair?Explain
2. Why does the lesson log per-step records to JSONL instead of pickled state?Explain
3. One bad batch produces an enormous gradient. Which line in the loop limits the damage?Apply
4. Run B shows a val_loss 0.02 below run A, but B rebuilt its eval windows with a different seed. What can you conclude?Analyze
5. You will leave a run on real data overnight. Which set of additions to the loop fits the lesson best?Design

Use It

  • This loop is the same skeleton that trains a 124M model on real data. Swap the synthetic token tensor for a datasets-style loader and the loop runs unchanged.
  • The JSONL log is the deliverable that turns a training run into evidence. The next lesson uses one to compare a freshly trained checkpoint with a pretrained one.
  • The qualitative sample probe covers what scalar loss cannot.
Check: Use It0/2
1. What does the JSONL log give you beyond debugging the current run?Explain
2. You want the demo to train on bytes from a small text file instead of synthetic tokens. Which change keeps the loop correct?Design

Exercises

  1. Add weight_decay_groups() unit tests that confirm scale and bias parameters land in the no decay group, and that linear and embedding weights land in the decay group.
  2. Replace the synthetic random tokens with bytes from a small text file so the demo trains on something legible. Verify that the generated sample uses characters present in the file.
  3. Change the existing min_lr floor in cosine_with_warmup (the demo default is 3e-4, 10 percent of max_lr) and re-plot the schedule.
  4. Save a checkpoint every eval_every steps in addition to the JSONL log. Add a resume_from flag that reloads the model state and the optimizer state.
  5. Log per step throughput (tokens per second) next to the loss and confirm it stays in a steady band.

Key Terms

TermWhat people sayWhat it actually means
Loss alignment"Shift by one"Input tokens sit at positions 0..T-1 and target tokens at positions 1..T; cross entropy is computed on flattened shapes
Decay split"Two groups"AdamW receives matrix shaped tensors with weight decay and scale or bias tensors with none
Warmup"Ramp"The learning rate climbs linearly from a small start (max_lr / warmup_steps at step 0) to its target over a fixed number of steps so the optimizer state can populate
Eval batches"Held out batches"Random windows from the validation tokens, drawn with a fixed seed and rebuilt identically at every probe
Qualitative probe"Sample print"A short generation from a fixed prompt, printed every K steps to catch failure modes that loss alone hides
Author's Note

I'd wire in the sample probe before I trusted any loss curve. A model can show a nicely falling training loss and still emit the same token every time, and a generated sample every K steps shows that at a glance. My bet is that the JSONL log is worth more than a pickled checkpoint for the same reason: a crash leaves a file I can grep and plot, and it doesn't tie me to the module layout that wrote it.

I'd also pin the eval seed before comparing any two runs. Otherwise the gap between two eval losses measures the batch shuffle as much as the model, and I'd have no way to tell which one moved.

Further Reading

  • Phase 19 lesson 35 for the model the loop drives.
  • Phase 19 lesson 37 for loading pretrained weights into the same model.
  • Phase 10 lesson 04 (pre training mini GPT) for the procedure on real data.
  • Phase 10 lesson 10 (evaluation) for the broader eval surface beyond cross entropy loss.

Code

Ships With