PRD — Layer-wise LR decay (--llrd γ) for tinygpt sft
Goal
Add --llrd γ to tinygpt sft (and the DPO family for symmetry).
For γ < 1, each transformer block’s LR is multiplied by γ^(depth_from_top),
so the embedding + lower blocks see exponentially smaller updates than
the LM head + upper blocks. The standard “freeze the foundation, finetune
the head” trick from ULMFiT (Howard & Ruder 2018)
turned into a continuous knob.
Default γ = 0.95 (mild decay) when the flag is omitted with a value; γ = 1.0 (today’s behavior) when the flag isn’t passed at all so existing recipes don’t drift.
Why now
- We already ship
cfg.lrLayerDecayfor pretrain (verified 2026-06-02 in the third-audit pass). It is NOT wired intotinygpt sft’s optimizer construction — finetune runs use a flat LR across all parameter groups. Mismatched. - SFT on small + tiny corpora overfits the upper layers and degrades the embedding’s broader features unless you either freeze the lower layers (loses 1-2% quality) or scale their LR down. LLRD is the smooth middle option.
- Half-day add — the
Optimizers.swiftgroup-LR scaffolding already exists for the pretrain path; the work is exposing it in the SFT CLI + adapter loop.
Scope — in
--llrd γflag ontinygpt sft,dpo,finetune. Range check: 0.5 ≤ γ ≤ 1.0; γ outside that prints a usage error.- Per-block LR computed as
base_lr × γ^(n_layers - 1 - layer_idx)so the topmost transformer block getsbase_lr, and the embedding + block 0 getbase_lr × γ^(n_layers - 1). - LM head + final norm always at
base_lr(γ doesn’t decay them — they are the head). - One row in the SFT run banner:
LR layer-decay: γ=0.95 → block_0 LR=2.3e-6, head LR=3e-5.
Scope — out
- Per-group LR (group different parameter families differently) — that’s an orthogonal knob; defer.
- Adaptive γ (estimate γ from gradient norms per block) — research- grade; defer.
- LLRD for pretrain — already shipped via
cfg.lrLayerDecay; this PRD is only the SFT-side surfacing.
Files to touch
| File | Change |
|---|---|
Sources/TinyGPTModel/Optimizers.swift | factor groupedLRs(γ, layers) helper (likely already exists for pretrain — re-use, don’t duplicate) |
Sources/TinyGPT/SFT.swift | parse --llrd, pass to optimizer construction, log resolved per-group LRs in banner |
Sources/TinyGPT/DPO.swift | same flag, same plumbing |
Tests/TinyGPTModelTests/LLRDTests.swift | new — assert per-block LR matches γ^k closed-form |
Acceptance criteria
-
tinygpt sft --llrd 0.95 ...runs and reports per-block LR in the banner. - On a fixed shakespeare → alpaca SFT recipe, val loss at the end of training is ≤ the no-LLRD baseline (this is the no-regression floor; the real win is on larger bases).
- Held-out OOD eval (a domain unlike alpaca, e.g. code) shows ≤ 1% PPL regression vs no-LLRD, where no-LLRD typically regresses ~3-5%. This is the “preserve foundation features” payoff.
- LLRDTests.swift passes — verifies group LR computation matches γ^k at every depth for n_layers ∈ {4, 12, 24}.
Reference patterns
Sources/TinyGPT/Train.swift— already constructs parameter groups forcfg.lrLayerDecay. Lift the helper into a module-level fn so both Train and SFT can use it.- ULMFiT, Howard & Ruder 2018 — the original LLRD recipe (they used “slanted triangular LR” but the per-layer decay is the same concept).
- Modern adoption: BERT, GPT-3 finetuning notebooks; not novel, just unsurfaced in our SFT CLI.
Open questions
- Whether to default γ=1.0 (off) or γ=0.95 (on) when
--llrdisn’t passed. Recommendation: default OFF for back-compat; the recipe-doc and--llrd 0.95curated invocation become the recommended path. Decision logged indocs/decision_log.md.