Learning-Rate Schedules
The learning rate is the one hyperparameter that must move during training. Warmup exists because the optimiser starts blind; decay exists because the loss surface narrows.
Standing
Current standardPart of the default recipe. Present in most frontier models trained today, and the thing a new design departs from rather than argues for.
Warmup plus decay is universal; the shape of the decay is where the disagreement is.
judged as of 2026-09 · what the labels mean
Theory
Warmup
Adam’s second moment is a running average with an effective window of steps. Before that many steps have passed, is noisy, and a noisy denominator on a full-size learning rate produces steps in arbitrary directions. Linear warmup holds the rate small until the estimate settles.
Cosine decay
Cosine has one serious drawback, and it is structural rather than empirical: appears in the formula. The schedule must know the total step count in advance, and a run stopped early sits at a high learning rate with a correspondingly poor loss. Every intermediate checkpoint is off the compute-optimal frontier.
Warmup–stable–decay
WSD holds constant for most of the run and decays sharply over the last 10–20%. Because the stable phase does not depend on , a run can be extended indefinitely, and any point on it can be turned into a finished model by decaying from there.
The loss curve under WSD looks worse than cosine for most of training and then drops abruptly during the decay, ending in the same place. Judging a WSD run by its mid-training loss is judging it before it has been asked the question.
Implementation
import math
from torch.optim.lr_scheduler import LambdaLR
def cosine_with_warmup(optimizer, warmup: int, total: int, floor: float = 0.1):
def factor(step: int) -> float:
if step < warmup:
return step / max(1, warmup)
progress = (step - warmup) / max(1, total - warmup)
cosine = 0.5 * (1 + math.cos(math.pi * min(1.0, progress)))
return floor + (1 - floor) * cosine
return LambdaLR(optimizer, factor)
def wsd(optimizer, warmup: int, total: int, decay_frac: float = 0.1):
"""Stable until the final decay_frac, so the run can be extended."""
decay_start = int(total * (1 - decay_frac))
def factor(step: int) -> float:
if step < warmup:
return step / max(1, warmup)
if step < decay_start:
return 1.0
return max(0.0, (total - step) / (total - decay_start))
return LambdaLR(optimizer, factor)Decaying to zero rather than a floor is common and mostly harmless, but a floor of leaves the model able to keep moving if the run is continued. Note that both schedules step per optimiser step, not per epoch — stepping per epoch on a single-epoch pretraining run means never stepping at all.