Difficulty: ⭐⭐☆☆☆ Intermediate
Source file:apex/training/scheduler.py
You will learn: What AdamW is, why learning rate scheduling matters, and how cosine warmup works.
During training, after computing the loss, we calculate gradients — the direction in which each parameter should move to reduce the loss. The optimizer applies those gradients to update the parameters.
Imagine you are blindfolded on a hilly landscape, trying to reach the lowest valley (minimum loss). You can only feel which direction is downhill at your current position (that is the gradient). The optimizer decides:
- How big a step to take (learning rate)
- Whether to remember previous steps (momentum)
- How to handle parameters that rarely get updated (adaptive learning rate)
AdamW (Adam with decoupled weight decay) is the standard optimizer for modern LLMs.
Instead of using the raw gradient
Track how "spiky" each gradient is:
Early in training,
where:
-
$\alpha$ = learning rate -
$\varepsilon = 10^{-8}$ = numerical stability -
$\lambda$ = weight decay (e.g., 0.1) — shrinks parameters toward zero each step
Why weight decay? Without it, parameters can grow very large — this is called overfitting. Weight decay acts as regularisation, keeping parameters small and forcing the model to use its capacity efficiently.
AdamW vs Adam: In vanilla Adam, weight decay is applied to the adaptive update (wrong — it interacts with the variance estimate). AdamW applies it directly to the parameter (correct, independent of the gradient scale).
The learning rate
Early in training, the model's parameters are random. With a high learning rate, random gradients cause chaotic, large updates that destabilise training.
Warmup: Slowly increase warmup_steps steps:
After warmup, the model is in a good region. Now we want to make progressively finer adjustments. Cosine decay smoothly reduces the learning rate:
This curves from
"""
Cosine Warmup Learning Rate Scheduler for APEX-1.
Phase 1: Linear warmup from 0 to peak_lr over warmup_steps steps.
Phase 2: Cosine decay from peak_lr to min_lr over remaining steps.
"""
import math
import torch
class CosineWarmupScheduler:
"""Learning rate scheduler with linear warmup + cosine decay.
Args:
optimizer: The PyTorch optimizer to control.
peak_lr: Maximum learning rate (reached at end of warmup).
warmup_steps: Number of steps for linear warmup.
max_steps: Total training steps.
min_lr_ratio: Final LR = peak_lr × min_lr_ratio. Default: 0.1.
"""
def __init__(
self,
optimizer: torch.optim.Optimizer,
peak_lr: float,
warmup_steps: int,
max_steps: int,
min_lr_ratio: float = 0.1,
) -> None:
self.optimizer = optimizer
self.peak_lr = peak_lr
self.warmup_steps = warmup_steps
self.max_steps = max_steps
self.min_lr = peak_lr * min_lr_ratio
# Current step counter (starts at 0)
self.current_step = 0
def get_lr(self) -> float:
"""Compute the learning rate for the current step."""
t = self.current_step
if t < self.warmup_steps:
# Phase 1: Linear warmup
# LR grows from 0 to peak_lr over warmup_steps steps
# At t=0: LR=0, at t=warmup_steps-1: LR≈peak_lr
return self.peak_lr * t / max(self.warmup_steps, 1)
elif t >= self.max_steps:
# After training: hold at min_lr
return self.min_lr
else:
# Phase 2: Cosine decay
# progress goes from 0 (start of decay) to 1 (end of training)
progress = (t - self.warmup_steps) / max(
self.max_steps - self.warmup_steps, 1
)
# Cosine: starts at 1, decays to 0
# Range: [0, π] → cos goes from 1 to -1, halved and shifted to [1, 0]
cosine_factor = 0.5 * (1.0 + math.cos(math.pi * progress))
# Scale to [min_lr, peak_lr]
return self.min_lr + cosine_factor * (self.peak_lr - self.min_lr)
def step(self) -> None:
"""Advance one training step and update optimizer LR."""
self.current_step += 1
lr = self.get_lr()
# Update all parameter groups in the optimizer
for param_group in self.optimizer.param_groups:
param_group["lr"] = lr
def state_dict(self) -> dict:
"""Serialize scheduler state for checkpointing."""
return {
"current_step": self.current_step,
"peak_lr": self.peak_lr,
"warmup_steps": self.warmup_steps,
"max_steps": self.max_steps,
"min_lr": self.min_lr,
}
def load_state_dict(self, state: dict) -> None:
"""Restore scheduler state from a checkpoint."""
self.current_step = state["current_step"]
self.peak_lr = state["peak_lr"]
self.warmup_steps = state["warmup_steps"]
self.max_steps = state["max_steps"]
self.min_lr = state["min_lr"]LR
|
peak_lr ──────────── ╮
| ╰──── cosine decay
| / ╰──
| warmup ──── min_lr
|
└────────────────────────────────────────────→ step
warmup_steps max_steps
| Parameter | APEX-1 Default | Why |
|---|---|---|
peak_lr |
3e-4 | Standard for 512d models; scale down for larger |
warmup_steps |
1000 | 1–2% of training steps |
weight_decay |
0.1 | Moderate regularisation |
beta1 |
0.9 | Standard momentum |
beta2 |
0.95 | Slightly more aggressive than default (0.999) |
eps |
1e-8 | Standard numerical stability |
min_lr_ratio |
0.1 | Final LR = 10% of peak |
Next: 18 — Training Pipeline →