Problem
Every optimizer on the optimizers page multiplies its update by a learning rate η. Set η too high and the loss oscillates or explodes; set it too low and training takes many times longer than it should. Between those, a fixed η still leaves a gap: with noisy minibatch gradients the parameters never settle at the minimum but jitter around it, and the size of the jitter is proportional to η.
So the right learning rate depends on where training is. Early on the parameters are far from any minimum and a large step makes fast progress. Late in training the remaining error is mostly gradient noise, and only a small step averages it away. Adaptive optimizers add a third phase: in the first few hundred steps their statistics are estimated from almost no data, and a full-size step can push the model somewhere it never recovers from. A schedule is a function from the step count to η that serves all three phases.
Intuition
Picture a ball rolling into a valley while being shaken. A big step size crosses the valley quickly but the shaking keeps it bouncing near the bottom; a small step size bounces less but takes forever to get there. Starting big and shrinking the step does both: cover the distance while the step is large, then let the bouncing die down as the step shrinks. Every decay schedule (steps, an exponential, a cosine) is a different curve for that shrinking.
Warmup is the opposite move at the very beginning: start tiny and ramp up over a few hundred or thousand steps, so the first updates, made with a freshly initialised network and an optimizer that has seen almost no gradients, cannot do lasting damage. One-cycle combines both: warm up to a high peak, then anneal far below the starting value.
The schedules in the worked example, computed with closed forms that the tests hold equal to PyTorch's own step-by-step updates. Every decay ends far below where it starts; warmup and one-cycle also start low.
Mechanics
What a scheduler is in PyTorch
A scheduler in torch.optim.lr_scheduler owns an optimizer and rewrites each param group's "lr" entry. Its constructor stores the starting rate as group["initial_lr"], copies those into base_lrs, and immediately calls step() once, which sets last_epoch to 0. Each later step() increments last_epoch and writes the new rate. Despite the name, last_epoch counts calls to step(): call it once per batch and every schedule length below is in batches.
The order matters. Since PyTorch 1.1 the loop is optimizer.step() then scheduler.step(); the constructor wraps optimizer.step so the first scheduler.step() can warn when it is called before any optimizer step, because that skips the first value of the schedule.
Most schedulers compute the next rate recursively from the current group["lr"], so that several schedulers can modify the same rate in turn, and also have a _get_closed_form_lr used only when an explicit epoch is passed. The closed forms below are the ones this page computes with, and the tests check that they equal the recursions step for step to within 1e−12.
Decay shapes
StepLR: ηt = η0 · γ⌊t / step_size⌋ ExponentialLR: ηt = η0 · γt
CosineAnnealingLR: ηt = ηmin + ½(η0 − ηmin)(1 + cos(π t / Tmax))
StepLR (default γ = 0.1) and its sibling MultiStepLR, which decays at a list of milestones, are the schedules classic image classifiers were trained with: a long plateau at a high rate, then drops by ten. ExponentialLR shrinks by the same factor every step. The cosine spends longer near the top than a straight line would, falls fastest in the middle and flattens out near ηmin, without the discontinuities of step decay and with only one shape parameter, the length Tmax. CosineAnnealingWarmRestarts restarts the cosine every T0 steps, optionally growing the period by Tmult.
Linear warmup
LinearLR: ηt = η0 · (s + (e − s) · min(t, T) / T), defaults s = 1/3, e = 1, T = 5
LinearLR multiplies the base rate by a factor that moves linearly from start_factor to end_factor over total_iters steps and then holds. Used with a small start factor it is a warmup. Warmup became standard for two reasons. Large-batch SGD (Goyal et al., 2017) scales the rate with the batch size, and the scaled rate is too large for the first few epochs. Adam-family optimizers divide by the square root of a second-moment estimate built from a handful of gradients, so their first updates have a step size near η in every coordinate regardless of how small or noisy the gradient is (see the worked example). Transformers trained with the original Post-LN layout are especially sensitive: Xiong et al. (2020) show the gradients near the output are large at initialisation, and Liu et al. (2020) trace the problem to the variance of Adam's adaptive rate in early steps, which RAdam corrects without a warmup.
One-cycle
OneCycleLR (Smith and Topin) starts at max_lr / div_factor (default 25), anneals up to max_lr over the first pct_start of total_steps (default 30%), then anneals down to initial_lr / final_div_factor (default 104), so it ends far below where it started. Each phase uses the cosine interpolation end + (start − end)/2 · (cos(π·pct) + 1) or a straight line with anneal_strategy="linear". With cycle_momentum=True (the default) momentum, or Adam's β1, moves the opposite way between 0.95 and 0.85, so the effective step is not doubly amplified at the peak. It must be told total_steps in advance and raises an error if stepped past it.
Plateaus and composition
ReduceLROnPlateau is the one scheduler that watches the training rather than the clock: step(metric) counts epochs without improvement, and after patience (default 10) of them multiplies the rate by factor (default 0.1), floored at min_lr. SequentialLR runs a list of schedulers one after another, switching at milestones; at each switch it calls the next scheduler's _update_lr(0), which restarts that scheduler from its base rate. ChainedScheduler instead steps every scheduler on every step, so their multiplicative effects stack. Warmup followed by cosine is SequentialLR([LinearLR, CosineAnnealingLR], milestones=[warmup]) with the cosine's T_max set to the steps that remain.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
LRScheduler |
optimizer, last_epoch=-1 | Base class: stores initial_lr and base_lrs, wraps optimizer.step to detect call order, takes the initial step. |
lr_scheduler.py 95-302, __init__ 119-180 |
step / _update_lr |
epoch=None (deprecated) | Warns if called before optimizer.step(), increments last_epoch, writes get_lr() (or the closed form when an epoch is passed) into each group. |
238-301 |
LambdaLR |
lr_lambda (function or list) | ηt = η0 · λ(t): any schedule written as a multiplier. | 343-466, get_lr 441-466 |
StepLR / MultiStepLR |
step_size or milestones, gamma=0.1 | Multiply by γ every step_size steps, or at each listed milestone. |
592-676, closed form 660-676; 679-770 |
LinearLR |
start_factor=1/3, end_factor=1.0, total_iters=5 | Linear ramp of the multiplier; the usual warmup. | 877-1004, recursion 937-980, closed form 982-1004 |
ExponentialLR |
gamma | Multiply by γ every step. | 1007-1079 |
CosineAnnealingLR |
T_max, eta_min=0.0 | Half a cosine from η0 to ηmin over Tmax steps. The recursion keeps going past Tmax and climbs back up. | 1338-1475, get_lr 1400-1454 |
CosineAnnealingWarmRestarts |
T_0, T_mult=1, eta_min=0.0 | Cosine that restarts at η0 every Ti steps, with Ti multiplied by Tmult after each restart (SGDR). | 2105-2274 |
OneCycleLR |
max_lr, total_steps, pct_start=0.3, anneal_strategy="cos", div_factor=25, final_div_factor=1e4, three_phase=False, base_momentum=0.85, max_momentum=0.95 | Warm up to max_lr, anneal to initial_lr / final_div_factor, with momentum cycled inversely. |
2285-2606, phases 2433-2472, get_lr 2546-2606 |
ReduceLROnPlateau |
mode="min", factor=0.1, patience=10, threshold=1e-4, cooldown=0, min_lr=0 | Cut the rate by factor after patience epochs without improvement in a monitored metric. |
1584-1785, step 1696-1721 |
SequentialLR |
schedulers, milestones (one fewer) | Hand over from one scheduler to the next at each milestone, restarting the next one from its base rate. | 1082-1235, step 1186-1196 |
ChainedScheduler |
schedulers | Step all schedulers every step, so their factors multiply. | 1478-1581 |
A training loop calls scheduler.step() once per epoch, but the CosineAnnealingLR was built with T_max equal to the total number of batches. What happens?
The rate barely moves: after the whole run, last_epoch equals the number of epochs, a tiny fraction of T_max, so the rate stays near its starting value and never anneals. The schedule length and the place step() is called must use the same unit.
Worked example
Every number here is exact arithmetic from stdlib helpers, with no sampling. The model is the simplest one where a learning rate matters: a loss L(w) = ½·h·w² with curvature h = 2, started at w0 = 3. Plain gradient descent updates w ← w − η·h·w = (1 − ηh)·w.
The stability limit
Each step multiplies w by 1 − ηh. Its size below 1 is the speed of convergence; a negative factor means overshooting the minimum; a size above 1 means divergence. The fastest rate is η = 1/h = 0.5, and anything above 2/h = 1 diverges.
| η | 1 − ηh | w1, w2, w3 | w10 | Behaviour |
|---|---|---|---|---|
| 0.05 | 0.90 | 2.700, 2.430, 2.187 | 1.046 | slow, monotone |
| 0.5 | 0.00 | 0.000, 0.000, 0.000 | 0.000 | exact in one step |
| 0.9 | −0.80 | −2.400, 1.920, −1.536 | 0.322 | overshoots, converges |
| 1.05 | −1.10 | −3.300, 3.630, −3.993 | 7.781 | diverges |
A real network has many curvatures at once, and the largest one sets the limit 2/hmax for the whole model while the smallest sets how slowly the flattest direction converges. That spread is why one constant rate cannot be both safe and fast.
Noise sets a floor, decay removes it
Now give each gradient additive noise of standard deviation σ = 1, as a minibatch gradient has. Write mt for the expected value of wt². The update w ← (1 − ηh)·w − η·ξ gives an exact recursion with no sampling:
mt+1 = (1 − ηth)² · mt + ηt²σ², E[Lt] = ½ · h · mt
constant η: E[L] → ησ² / (2(2 − ηh))
At η = 0.1 the floor is 0.0278; at η = 0.01 it is ten times lower, 0.0025, but reaching it takes hundreds of steps. The table runs 100 steps of each schedule from the diagram above.
| Schedule | E[L] at step 10 | step 30 | step 60 | step 100 |
|---|---|---|---|---|
| constant 0.1 | 0.1312 | 0.0278 | 0.0278 | 0.0278 |
| constant 0.01 | 6.0093 | 2.6798 | 0.7991 | 0.1608 |
| StepLR | 0.1312 | 0.0278 | 0.0100 | 0.0089 |
| cosine | 0.1345 | 0.0227 | 0.0115 | 0.0052 |
| warmup + cosine | 1.2982 | 0.0254 | 0.0133 | 0.0055 |
| OneCycleLR | 5.6144 | 0.0374 | 0.0181 | 0.0064 |
The high constant rate reaches its noise floor quickly and stops; the low one has a lower floor but has not reached it. Every decay gets the early speed of the high rate and ends below its floor. Warmup costs a few steps on this problem because plain SGD on a quadratic has nothing for it to protect.
Cosine ends lower than StepLR here because each tenfold drop leaves a rate too small to finish removing the error left by the phase before: at η = 0.001 a step shrinks mt by only 0.4%, so StepLR sits near 0.010 from step 60 on, while the cosine passes through every rate in between. Neither is better in general; the point is that both beat every constant.
Why Adam wants warmup
Adam's update is η · m̂ / (√v̂ + ε). On the first step both moment estimates are bias-corrected copies of one gradient, so m̂ / √v̂ = g / |g| = ±1 whatever the gradient's size. The helper runs three Adam steps at η = 0.001 on gradients that differ by a factor of 100000:
| Gradient | Step 1 | Step 2 | Step 3 |
|---|---|---|---|
| 0.0001 | 0.001000 | 0.001000 | 0.001000 |
| 10 | 0.001000 | 0.001000 | 0.001000 |
Every parameter moves by almost exactly η on each early step, including parameters whose gradients are pure noise. Later, v̂ averages many gradients and the step shrinks for noisy coordinates; at the start nothing does that, so a ramp from a small rate does it instead.
One-cycle in numbers
With max_lr = 0.1, 100 total steps and the defaults, the first phase ends at step pct_start · total_steps − 1 = 29. The rate starts at 0.1 / 25 = 0.004, peaks at 0.1 with momentum 0.85, and ends at 0.004 / 10000 = 4e−07 with momentum back at 0.95. Halfway down the second phase, at step 64, it is (0.1 + 4e−07) / 2. On the noisy quadratic it ends at 0.0064, below the constant floor, despite spending the first steps near zero.
An LR range test
How large should the peak be? An LR range test (Smith, 2017) trains for a short run while raising η geometrically on every step, and plots the loss against η. Here the model has three directions with curvatures 0.5, 2, 8, starting at 1 in each, and η rises from 0.0001 to 10 over 51 steps.
The loss bottoms out at η = 1, four times the stable limit of the stiffest direction, and explodes three steps later. The rule of thumb of one tenth of the minimum lands below the limit.
The minimum sits past the stable limit 0.25 because the test is cumulative: by the time η exceeds 0.25 the stiff direction has already been shrunk almost to zero by the earlier steps, so its growth takes a few more steps to show. That is why the curve's minimum is a ceiling, not a recommendation; practitioners take a peak rate several times smaller, here 0.1, which is safely stable for all three directions.
A cosine that runs past Tmax
CosineAnnealingLR does not stop at Tmax. Stepping the recursion past it with Tmax = 100 gives η = 0.000 at step 100, 0.050 at step 150 and 0.100 at step 200: the rate rises back to the start along the second half of the cosine period. A run longer than Tmax gets a surprise warm restart at the end of training.
Implementation
import torch
model = ...
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
total_steps = epochs * len(train_loader)
warmup = int(0.02 * total_steps)
scheduler = torch.optim.lr_scheduler.SequentialLR(
optimizer,
schedulers=[
torch.optim.lr_scheduler.LinearLR(optimizer, start_factor=0.01, total_iters=warmup),
torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=total_steps - warmup),
],
milestones=[warmup],
)
for epoch in range(epochs):
for x, y in train_loader:
loss = loss_fn(model(x), y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step() # after optimizer.step(), once per batch
checkpoint = {"model": model.state_dict(), "optimizer": optimizer.state_dict(),
"scheduler": scheduler.state_dict()}
# One-cycle instead:
# scheduler = torch.optim.lr_scheduler.OneCycleLR(optimizer, max_lr=1e-3, total_steps=total_steps)
The schedule is part of the training state: resuming from a checkpoint without scheduler.state_dict() restarts the warmup and the cosine. The training loop page places this call in a full loop, and mixed precision adds a grad scaler that can skip an optimizer step while the scheduler still advances.
Tradeoffs
- Step decay is easy to reason about and resumes cleanly, but needs the milestones tuned to the run length, and each drop causes a sudden change in the loss that can be mistaken for an improvement in the model.
- Cosine has a single length parameter and no discontinuities, which is why it is a common default in modern training, but it commits to a total length up front: stopping early leaves the rate high, training longer needs a new schedule.
- Warmup costs a small fraction of training and removes a common source of early divergence for adaptive optimizers and large batches. Too short a warmup gives no protection; for SGD at moderate batch size it is often unnecessary.
- One-cycle reaches good results in short runs and fixes the momentum schedule too, but needs
total_stepsexactly and a sensiblemax_lr, usually from a range test. - ReduceLROnPlateau needs no knowledge of the run length but depends on a noisy validation metric and on
patience, and cannot be put inside SequentialLR or ChainedScheduler. - The peak rate matters far more than the shape. A schedule tuned at one batch size usually needs its peak re-tuned at another.
Two runs use the same cosine schedule; one stops at 60% of T_max. Why is its final loss often clearly worse than a run planned for that length?
At 60% of the cosine the rate is still 35% of its peak, so the parameters are still bouncing at the noise level of a high rate, as the constant curve does in the loss figure above. A run planned for the shorter length would have annealed to near zero and averaged that noise away.
Connections
Read first
Read next
No page builds on this one yet.
The rate being scheduled is the step size of the update rules on the optimizers page, and the stability limit 2/h is the same bound as on derivatives and gradients. Warmup matters most for the transformer, whose layer norm placement decides how fragile the first steps are, and the training loop shows where scheduler.step() sits.
References
- Smith. Cyclical Learning Rates for Training Neural Networks. WACV, 2017 (arXiv 1506.01186; the LR range test).
- Loshchilov and Hutter. SGDR: Stochastic Gradient Descent with Warm Restarts. ICLR, 2017 (arXiv 1608.03983).
- Goyal, Dollár, Girshick, Noordhuis, Wesolowski, Kyrola, Tulloch, Jia and He. Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour. 2017 (arXiv 1706.02677; linear scaling and gradual warmup).
- Vaswani, Shazeer, Parmar, Uszkoreit, Jones, Gomez, Kaiser and Polosukhin. Attention Is All You Need. NeurIPS, 2017 (arXiv 1706.03762; warmup then inverse square root decay).
- Smith and Topin. Super-Convergence: Very Fast Training of Neural Networks Using Large Learning Rates. 2017 (arXiv 1708.07120; one-cycle).
- Liu, Jiang, He, Chen, Liu, Gao and Han. On the Variance of the Adaptive Learning Rate and Beyond. ICLR, 2020 (arXiv 1908.03265; RAdam).
- Xiong, Yang, He, Zheng, Zheng, Xing, Zhang, Lan, Wang and Liu. On Layer Normalization in the Transformer Architecture. ICML, 2020 (arXiv 2002.04745).
- PyTorch v2.15.0:
torch/optim/lr_scheduler.py.