Learning Rate Schedules
Starting slow to avoid breaking the model, speeding up to make progress, and then slowing down again to carefully settle into the perfect solution.
Why Does This Exist?
One fixed step size cannot serve a whole training run. Early steps start from random weights where large updates can blow up activations, while late steps near a minimum need small updates because the same large step bounces across the valley instead of settling. A single rate either crawls early or rattles late.
Schedules resolve the tension by varying the learning rate , where multiplies the gradient in the update . Warmup ramps gently to protect early steps, step decay drops in stages to spend most time at productive mid-range rates, and cosine annealing glides smoothly to zero for a clean finish. The batch-size choice around those steps lives in gradient descent variants, per-coordinate adaptation lives in stochastic gradient descent variants, and the convex landscape where decay guarantees most lives in convex optimization.
The prerequisite is the gradient update itself: the gradient points uphill, so training subtracts a scaled copy. When that picture is fuzzy, read gradients first.
Think of It Like This
Driving to a stop line in fog
You must stop exactly at a painted line you can barely see. Launching at full speed risks overshooting before the line appears, crawling the whole way wastes the trip, and holding speed through the line guarantees bouncing past it.
The schedule is the driving plan. Ease out of the driveway during warmup, cruise at road speed while the road is clear, then brake smoothly through cosine decay so arrival is exact. Step decay is the same plan with discrete gear shifts instead of a smooth pedal.
Where it stops: a car brakes on distance while training brakes on epochs, and epochs are a clock rather than a distance. A schedule that decays before the loss has fallen strands the run with no way back up, which is why decay timing is tuned against validation, not set blindly.
How It Actually Works
Write for the learning rate at epoch , where counts completed passes over the data. All three schedules below share endpoints and over different shapes, so the diagram compares shape rather than scale.
Warmup then decay
Linear warmup over epochs from 0 to is for . The hand values are epoch 0 at 0, epoch 1 at 0.02, epoch 2 at 0.04, epoch 3 at 0.06, epoch 4 at 0.08, and epoch 5 at 0.1. Early variance is highest when weights are random and optimizer statistics are empty, so starting near zero avoids one giant destructive first step, especially with large batches and transformers.
Step decay
Step decay holds a rate constant, then multiplies by at milestones 30, 60, and 90. From 0.1 the hand values are 0.1 before epoch 30, 0.05 from epoch 30, 0.025 from epoch 60, and 0.0125 from epoch 90. Each drop cuts bounce roughly in half while keeping the implementation to two numbers. The cost is the cliff itself: a drop before the run has exploited the current scale wastes the remaining budget at too-small steps.
Cosine annealing
Cosine annealing over epochs from to 0 is , where is the cosine function and the half-period constant. The hand values are epoch 0 at 0.1, epoch 50 at 0.05, and epoch 100 at 0. The curve falls slowly at both ends and fastest in the middle, spending little time at the destructive top and arriving flat at the bottom. After warmup the same curve restarts from the warmed peak, which is why pipelines pair the two.
Worked example
Fix and for all three. Warmup reaches 0.1 exactly at epoch 5. Step decay reads 0.1, 0.05, 0.025, and 0.0125 across milestones 0, 30, 60, and 90. Cosine reads 0.1 at epoch 0, 0.05 at epoch 50, and 0 at epoch 100, since , , and . The diagram draws exactly these four curves from the shared start to the shared end , so shape is the only difference on display.
Code
The snippet evaluates the three shapes with only the standard library.
import math
def cosine(t: int, total: int = 100) -> float: return 0.05 * (1 + math.cos(math.pi * t / total))
def step_decay(t: int) -> float: lr = 0.1 if t >= 30: lr *= 0.5 if t >= 60: lr *= 0.5 if t >= 90: lr *= 0.5 return lr
print(round(cosine(0), 4)) # -> 0.1print(round(cosine(50), 4)) # -> 0.05print(round(cosine(100), 4)) # -> 0.0print(step_decay(0), step_decay(30), step_decay(60), step_decay(90)) # -> 0.1 0.05 0.025 0.0125print(round(0.1 * 3 / 5, 4)) # -> 0.06Cosine passes through 0.1, 0.05, and 0.0, step decay halves on schedule, and warmup hits 0.06 at epoch 3, matching the hand values above.
Watch Out For
Decaying before the run has earned it
The symptom is a loss that was falling nicely, flattens the epoch after a milestone, and never recovers. The schedule spent the large-rate budget before the weights reached the valley where small rates pay off.
Fix by tying milestones to validation rather than round numbers: decay when the plateau metric stalls, not at epoch 30 because 30 looks tidy. Cosine with a longer horizon fails the same way, arriving near zero while gradients are still large.
Skipping warmup with a large-batch peak rate
The symptom is NaN or a loss spike inside the first few epochs, exactly when the peak rate first applies to random weights with empty optimizer statistics. Lowering the peak rate for the whole run then fixes stability but doubles training time.
Fix with a short linear warmup of a few epochs before any decay shape, keeping the peak rate for the productive middle. If the first-epoch gradient norm dwarfs later norms, warmup length is the knob, not the peak.
The Quick Version
- One fixed rate cannot cover random starts and sharp finishes, so the rate becomes a schedule over epochs.
- Linear warmup ramps 0 to 0.1 across the first 5 epochs to protect early steps.
- Step decay halves 0.1 to 0.05 to 0.025 to 0.0125 at epochs 30, 60, and 90.
- Cosine annealing glides 0.1 through 0.05 at epoch 50 to 0 at epoch 100 with no cliffs.
- Pair warmup with a decay shape rather than running either alone.
- Time decays against validation plateaus, because early decay strands the run.