← All Problems

49. Warmup + Cosine Learning Rate Scheduler

General Medium General Cross-Lab PyTorch Fundamentals
Grounding: General pattern common across ML-research-lab technical interviews. Warmup + cosine decay is standard, widely-documented LLM pretraining methodology; no source in this research names this exact scheduler as an asked question at Anthropic, OpenAI, DeepMind, or Mistral, but it is exactly the kind of well-established public training-loop knowledge these labs' known focus on large-scale pretraining makes a plausible practical-coding topic.

Problem

Training large models from a random or freshly-loaded checkpoint with the full target learning rate from step 0 is unstable -- gradients are large and noisy early on. The standard fix is a linear warmup (ramp the LR up from 0 to the target base_lr over the first warmup_steps steps) followed by a cosine decay down to a floor min_lr over the rest of training. This shape -- linear ramp up, smooth cosine ramp down -- is close to the most common LR schedule used to pretrain transformer-scale models.

Implement a vectorized LR-schedule function: given a tensor of step indices, return the learning rate at each of those steps.

Source: src/49_warmup_cosine_lr_scheduler.py

def warmup_cosine_lr(
    steps: torch.Tensor, base_lr: float, warmup_steps: int,
    total_steps: int, min_lr: float = 0.0,
) -> torch.Tensor: ...

>>> steps = torch.tensor([0, 5, 10, 20])
>>> warmup_cosine_lr(steps, base_lr=1.0, warmup_steps=10, total_steps=20)
tensor([0.0000, 0.5000, 1.0000, 0.0000])

Step-by-Step Approach

  1. Cast steps to float so the arithmetic below produces a float tensor rather than truncating on integer division.
  2. Compute the linear-warmup value for every step, unconditionally: base_lr * step / warmup_steps. It's fine that this formula is "wrong" for steps past warmup -- it gets discarded by the selection step later.
  3. Compute progress, the fraction of the way through the post-warmup decay: (step - warmup_steps) / (total_steps - warmup_steps), clamped to [0, 1] so steps beyond total_steps stay pinned at the end of the decay instead of the cosine curve rising back up.
  4. Compute the cosine-decay value: min_lr + (base_lr - min_lr) * 0.5 * (1 + cos(pi * progress)) -- at progress=0 this equals base_lr; at progress=1 it equals min_lr.
  5. Select per-element between the two branches with torch.where(step < warmup_steps, warmup_lr, cosine_lr).
  6. Verify the boundary: at step == warmup_steps, progress == 0 so cosine_lr == base_lr, matching what the warmup branch would have given at that same step -- the two pieces meet with no discontinuity.

The key insight is computing both branches unconditionally and vectorized with torch.where, rather than branching in Python per step -- this makes the function work directly on a whole tensor of step indices at once, and avoids any risk of a Python-level if desyncing from the tensor's actual dtype or device.

Reference solution

def warmup_cosine_lr(
    steps: torch.Tensor,
    base_lr: float,
    warmup_steps: int,
    total_steps: int,
    min_lr: float = 0.0,
) -> torch.Tensor:
    steps_f = steps.to(torch.float32)

    # Linear warmup branch: computed for EVERY step (cheap, vectorized),
    # even the ones that will end up using the cosine branch instead --
    # torch.where below picks per-element which value survives.
    warmup_lr = base_lr * steps_f / warmup_steps

    # clamp to [0, 1] so steps past total_steps hold flat at min_lr instead
    # of the cosine continuing past its trough and rising again.
    progress = torch.clamp(
        (steps_f - warmup_steps) / (total_steps - warmup_steps), 0.0, 1.0
    )
    cosine_lr = min_lr + (base_lr - min_lr) * 0.5 * (1.0 + torch.cos(math.pi * progress))

    # steps_f < warmup_steps selects warmup_lr; the cosine branch handles
    # everything else, including step == warmup_steps exactly (no
    # discontinuity at the handoff).
    return torch.where(steps_f < warmup_steps, warmup_lr, cosine_lr)

Key Functions & Tricks

  • torch.where(cond, a, b) — elementwise selection between two precomputed tensors, the standard way to vectorize a per-element branch instead of looping in Python.
  • torch.clamp(t, 0.0, 1.0) — pins progress into a valid range so the cosine formula never runs past its intended domain.
  • torch.cos — elementwise cosine; combined with math.pi, implements the smooth decay curve.
  • steps.to(torch.float32) — avoids integer-division truncation when steps arrives as an int64 tensor.

How to Recognize This Pattern

Signal words: "warmup schedule," "cosine annealing," "learning rate schedule for pretraining." The tell is a training-step-dependent hyperparameter that needs two different formulas stitched together at a boundary, evaluated across many steps at once -- that's a torch.where-over-precomputed-branches problem, not a Python if/else per call. Common variations: cosine schedules with restarts (the decay repeats in cycles instead of running once); linear decay instead of cosine after warmup; or schedules expressed per-fraction of training (step / total_steps) rather than per-absolute-step, useful when total_steps isn't known exactly in advance. A common pitfall is computing progress without clamping, which lets the cosine term continue past total_steps and oscillate the LR back upward instead of holding flat at min_lr.