TAI BUI
Back to curriculum
CapstonePython

Cosine LR with Linear Warmup

A cosine schedule with linear warmup ramps the learning rate from zero to a configured peak, then decays it along half a cosine toward a floor. Paired with AdamW, it is the modern default for language-model training: the first thousand updates are brittle, and a small early step size keeps them safe. Build the schedule, plot it over training steps, log gradient norms beside it, and test the warmup, peak, and decay boundaries.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 30-37

Learning Objectives

  • Implement an AdamW optimizer wired to a cosine learning-rate schedule with linear warmup.
  • Compute the schedule's exact value at any step without floating-point drift across runs.
  • Log gradient L2 norm side by side with the learning rate so training health is observable.
  • Render the schedule to a text plot the eye can read and a CSV any tool can consume.

The Problem

A cosine-with-warmup schedule is a learning rate that climbs linearly from zero to a peak, then decays along the upper half of a cosine curve to a floor. It has three regions. From step zero to step warmup_steps the rate scales linearly from zero to the configured peak lr_max. From warmup_steps to total_steps it follows the cosine, decaying from lr_max to lr_min. After total_steps it stays pinned at lr_min, so a misconfigured trainer that overshoots does not silently walk off the end of the schedule.

The warmup exists because the first thousand training updates are the loudest. The weights are still close to initialization, the optimizer's running second-moment estimate has not stabilised, and the gradient norm is large and noisy. Run those updates at peak learning rate and the model either diverges outright or settles into a loss plateau it never escapes. Gradient clipping is the other well-known fix, and Phase 19 lesson 45 covers it. The schedule is the one that starts small and ramps up.

The hard part of building this is that schedules are easy to get wrong by one step. The error surfaces six hours into a run, as a learning rate 1 percent too high or too low just as the model starts overfitting. You will not see it unless you test the schedule at its boundaries.

Warm-up0/3
1. When training a neural network with gradient descent, what does the learning rate control?Warm-up
2. A model is trained with a learning rate far too large. What is the most likely result?Warm-up
3. What does the L2 norm of a vector measure?Warm-up
Check: The Problem0/2
1. What is learning-rate warmup?Define
2. Why does the schedule start the learning rate near zero instead of at lr_max?Explain

The Concept

Mermaid diagram

Warmup formula

For step in [0, warmup_steps] with warmup_steps > 0, the learning rate is lr_max * step / warmup_steps. The degenerate case warmup_steps = 0 means no warmup: the schedule starts at lr_max on step zero and goes straight into cosine decay. Some test harnesses pass zero to confirm the schedule still produces a usable curve.

Cosine formula

For step in (warmup_steps, total_steps] the learning rate is lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * progress)), where progress = (step - warmup_steps) / max(1, total_steps - warmup_steps). At step = warmup_steps the cosine evaluates to cos(0) = 1, which gives lr_max and matches the warmup endpoint exactly. At step = total_steps it evaluates to cos(pi) = -1, which gives lr_min and matches the decay endpoint exactly.

That continuity is deliberate, and it is why the schedule is one function of step instead of three functions glued together. A glued schedule loses a boundary the first time someone changes lr_max.

Floor after total steps

For step > total_steps the rate stays at lr_min. The schedule does not raise and does not extrapolate. It pins at the floor and logs nothing, so the trainer must watch its own step count. A trainer that needs more steps changes the schedule's total_steps, not the loop.

Gradient norm logging alongside the rate

The schedule is half of training health, and the gradient norm is the other half. The loop logs both on every step. A divergent run shows its gradient norm spike before the loss does. A well-tuned warmup keeps the norm rising linearly with the rate, while a peak set too aggressively shows up as a norm that stays high after warmup. write_step_log_csv writes that log with the columns step, lr, grad_l2_norm, loss, and the CSV is the durable record once you call it (the demo only prints the rows).

Check: The Concept0/14
1. What is cosine decay in this schedule?Define
2. What is the floor of the schedule?Define
3. What does the logged gradient norm report?Define
4. Why is the schedule implemented as a single function over step rather than three glued segments?Explain
5. Why does the cosine segment join the warmup ramp without a jump at warmup_steps?Explain
6. What is the cost of the schedule pinning silently at lr_min after total_steps?Explain
7. Why log gradient L2 norm next to the learning rate?Explain
8. With warmup_steps=100 and lr_max=1e-3, what is the learning rate at step 50?Apply
9. With warmup_steps=0 and lr_max=1.0, what does the schedule return at step 0?Apply
10. A run built with total_steps=20, lr_max=1e-2 and lr_min=1e-4 is still going at step 520. What does CosineWithWarmup.lr(520) return?Apply
11. Your run diverges within the first 50 steps at peak lr_max, with a large grad_l2_norm. What is the best first change?Analyze
12. Gradient norm stays high long after warmup ends while the rate is at its peak. Which cause fits best?Analyze
13. After someone raises lr_max in a config, the rate plot shows a jump at warmup_steps. Which design flaw matches?Analyze
14. A run reaches total_steps with the loss still improving, and you want 5,000 more steps. What is the right change?Design

Build It

code/main.py implements:

  • CosineWithWarmup: a stateless function lr(step) -> float over the configured schedule.
  • TrainState: wraps a model, an AdamW optimizer, and the schedule into a single step function.
  • TrainState.step: runs one forward pass and one backward pass, logs the gradient L2 norm, and applies lr(step) to the optimizer.
  • plot_schedule_ascii: renders the schedule as a text plot you can read in a terminal.
  • write_schedule_csv: writes one row per step with the learning rate.
  • write_step_log_csv: writes the per-step training log (step, lr, grad_l2_norm, loss). The demo does not call it.
  • Comparison and utility helpers: InverseSqrtWarmup, LinearWarmupConstant, EWMA, split_decay_groups, and summarize_step_log.

CosineWithWarmup raises ValueError on an invalid config, such as warmup_steps >= total_steps or lr_max <= 0.

The demo at the bottom of the file builds a tiny two-layer model (nn.Sequential of Linear, GELU, Linear) and trains it for 20 steps on a fixed input batch. It prints the learning rate, gradient norm, and loss for each step, then renders the schedule as a text plot for a visual sanity check.

Run it:

bash
python3 code/main.py

The script exits zero and prints a per-step training log plus the schedule plot.

Check: Build It0/9
1. What does it mean that CosineWithWarmup is stateless?Define
2. Why does the lesson produce both a text plot and a CSV of the schedule?Explain
3. In TrainState.step, how does the schedule's rate reach the optimizer?Explain
4. The demo builds CosineWithWarmup(warmup_steps=4, total_steps=20, lr_max=1e-2, lr_min=1e-4). What is lr(1)?Apply
5. With the same demo config (warmup 4, total 20, lr_max 1e-2, lr_min 1e-4), what is lr(12)?Apply
6. A config sets warmup_steps=1000 and total_steps=1000. What does CosineWithWarmup do?Apply
7. Two parameters hold gradients [3, 0, 4] and [0, 0]. What does the concatenated gradient L2 norm equal?Apply
8. plot_schedule_ascii is called with width=10 and height=6. How many lines does the returned text have?Apply
9. TrainState starts at global_step 0 with warmup_steps=4 and lr_max=1e-2. Which rates are applied by the first and fifth updates?Apply

Production Patterns

Four patterns turn the schedule into a production artifact.

The schedule lives in a config, not in code. The trainer reads warmup_steps, total_steps, lr_max, and lr_min from a YAML or JSON config committed to git. Because the config is content-addressed, the schedule is reproducible. Because it shows up in the PR diff, it is auditable.

The step counter is monotonic and decoupled from epochs. Some frameworks confuse step and epoch when the dataset is sharded or the dataloader restarts. Read global_step from the trainer's checkpoint instead of a local counter, and a resumed run picks up at the right point in the schedule. The step counter is the durable axis.

The schedule plot goes in the run directory. Each training run writes outputs/lr_schedule.png (a text plot, in this lesson) next to its other artifacts. A reviewer skimming the directory can sanity-check the schedule without re-running anything, which catches misconfigured schedules at PR time.

The log row schema is fixed. The columns are step, lr, grad_l2_norm, loss, in that order. Downstream notebooks and dashboards read that schema, and renaming a column without bumping a version breaks every existing dashboard.

Check: Production Patterns0/4
1. What is the global step?Define
2. Why do the log columns step, lr, grad_l2_norm, loss stay fixed and in order?Explain
3. Why should a resumed run read global_step from the trainer's checkpoint?Explain
4. You want a misconfigured schedule caught at PR time, not hours into a run. Which pair of practices supports that?Design

Use It

Production patterns:

  • Sweep peak before sweeping anything else. lr_max is the most sensitive knob. Sweep it on a small model first. The optimal lr_max scales weakly with model size, so the small-model sweep is a strong prior.
  • Set warmup as a fraction of total steps, not an absolute count. A 200-million-step run with 2,000 warmup steps reaches peak almost immediately, while a 20,000-step run with the same count warms up for 10 percent of training. Configure warmup as a fraction (typically 1-3 percent) so the schedule scales with training duration.
  • Keep lr_min non-zero on purpose. A floor at 10 percent of lr_max keeps the optimizer learning through the long tail. With lr_min = 0 the training curve looks great on a plot, but the model has not finished training.
Check: Use It0/5
1. Why configure warmup_steps as a fraction of total_steps in production?Explain
2. Which tuning plan does the lesson recommend for a new model?Explain
3. Why is a non-zero lr_min typical in production?Explain
4. A config with warmup_steps=2000 from a 20,000-step run is reused for a 200-million-step run. What goes wrong?Analyze
5. You must set up a new 100,000-step run with little tuning time. Which plan fits the lesson best?Design

Ship It

On a real project, outputs/skill-cosine-warmup.md would record which config carries the schedule, which trainer step the global counter is read from, and which lr_max sweep produced the deployed value. This lesson ships the engine.

Exercises

  1. Compare the existing InverseSqrtWarmup against the cosine on a 200-step toy run, then extend it with an lr_min floor. Which curve reaches the lower final loss?
  2. Add a --restart flag that inserts a second warmup at total_steps / 2. Decide whether warm restarts help or hurt on the toy run, and defend the call.
  3. Add a unit test that the schedule is continuous: for every step in [0, total_steps], |lr(step+1) - lr(step)| is bounded by lr_max / warmup_steps.
  4. Wire the schedule into a torch.optim.lr_scheduler.LambdaLR so it composes with framework code. The lesson uses a plain step function, so what does the wrapper change?
  5. Add a --plot-png flag that writes a real plot via matplotlib. Decide whether the text plot or the PNG is the better default for CI runs, and defend it.

Key Terms

TermWhat people sayWhat it actually means
Warmup"Slow start"Linear ramp from zero to lr_max over the first warmup_steps updates
Cosine decay"Smooth drop"Upper-half cosine curve from lr_max to lr_min over the remaining steps
Floor"After training"The fixed lr_min value the schedule pins at past total_steps
Gradient norm"L2 of grads"The Euclidean norm of the concatenated gradient vector, logged each step
Global step"Schedule axis"A monotonic step counter that survives restarts and drives the schedule
Author's Note

My bet is that the boundaries deserve tests before the hyperparameters deserve tuning. The closed form gives exactly lr_max at warmup_steps and exactly lr_min at total_steps, so those are the two values I'd assert first. An off-by-one there is the kind of bug that stays invisible until late in a run. After that I'd sweep lr_max on a small model, set warmup as a fraction of total steps, and log the gradient norm next to the rate from the very first run. A norm that spikes before the loss does is the early warning I'd want in front of me.

Further Reading

Code