Train and Load a GPT from Scratch
Build the 124M GPT, train it with AdamW and cosine warmup, load GPT-2 weights, gate the pipeline.
- Concepts and terms
- Tasks and commands
- Annotated code
- Diagrams
- Comparisons
- Defaults and numbers
- Troubleshooting
- Questions and tips
Run the lessons#
| Command | What it does |
|---|---|
python3 code/main.py (35) | Counts 124M params, generates with a tiny config |
python3 code/main.py (36) | Trains a tiny model, writes outputs/losses.jsonl |
python3 code/main.py (37) | Writes outputs/gpt2-stub.safetensors, loads it |
python3 code/main.py (44) | 20 steps, per step lr and grad norm, text plot |
python code/ | Validates manifest, cost estimate, prints DAG |
python code/ | Executes stages, writes manifest.out.yaml |
python code/ | Applies eval gates. Prints SHIP or HOLD |
build_param_groups(model, | AdamW groups, decay split |
GPTConfig defaults (124M)#
| Setting | Default | Note |
|---|---|---|
vocab_size | 50257 | Rows of the token table |
context_length | 1024 | Rows of the position table |
d_model | 768 | Embedding width |
num_heads | 12 | d_model must divide by it |
num_layers | 12 | Twelve TransformerBlock modules |
mlp_expansion | 4 | MLP hidden is 4 times d_model |
dropout | 0.1 | Tiny demo sets 0.0 |
weight_tying | True | lm_head shares tok_embed |
GPTModel forward#
Build, count and sample#
ref_model = GPTModel(GPTConfig())124M referencecount_parameters(ref_model)unique params, tied onceGPTModel(GPTConfig(weight_tying=False))adds vocab_size * d_modelself.lm_head.weight = self.tok_embed.weightshare, never copygenerate(tiny_model, prompt, max_new_tokens=12, temperature=0.8, top_k=20, seed=42)temperature must be positivetop_k_filter(logits, top_k)others to -infwindow = tokens[:, -model.cfg.context_length:]sliding windowOne training step#
Q&A#
- Why transpose on load?
c_attn,c_projandc_fcuse conv1d layout.nn.Linearwants the transpose.- Why print a sample during training?
- Loss is a scalar. A sample shows a model stuck on one token.
- Why slice eval batches once?
- Identical batches make eval loss comparable across runs.
- Where does the schedule read its step?
- From
global_stepin the checkpoint, not a local counter.
Training loop core#
optimizer = torch.optim.AdamW( build_param_groups(model, cfg.weight_decay),decay split lr=cfg.max_lr, betas=(0.9, 0.95))for step in range(cfg.num_steps): lr = cosine_with_warmup(step, cfg.warmup_steps, cfg.num_steps, cfg.max_lr, cfg.min_lr) for group in optimizer.param_groups: group["lr"] = lr inputs, targets = next(train_loader) optimizer.zero_grad(set_to_none=True) loss = calc_loss_batch(model, inputs, targets)flat cross entropy loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=cfg.grad_clip) optimizer.step()Schedule and size numbers#
- Warmup
lr_max * step / warmup_stepsup towarmup_steps.- Cosine
lr_min + 0.5 * (lr_max - lr_min) * (1 + cos(pi * progress)).- Floor
- past
total_stepsthe rate stays atlr_min. - Warmup size
- a fraction of total steps. Typical is 1 to 3 percent.
- Floor size
lr_minat 10 percent oflr_maxkeeps the optimizer learning.- Weight tying
- saves 50257 times 768 = 38 million parameters.
- Residual init
- scale out projections by
1 / sqrt(2 * num_layers). - Pre-training FLOPs
- 6 x params x tokens.
GPT-2 names to local names#
| Factor | GPT-2 file | Local model |
|---|---|---|
| Token embedding | wte.weight | tok_embed.weight |
| Position embedding | wpe.weight | pos_embed.weight |
| LayerNorm 1 scale | h.N.ln_1.weight | blocks.N.ln1.scale |
| Fused QKV, transposed | h.N.attn.c_attn.weight | blocks.N.attn.qkv.weight |
| Attention out, transposed | h.N.attn.c_proj.weight | blocks.N.attn.out_proj.weight |
| MLP fc1, transposed | h.N.mlp.c_fc.weight | blocks.N.mlp.fc1.weight |
| MLP fc2, transposed | h.N.mlp.c_proj.weight | blocks.N.mlp.fc2.weight |
| Final LayerNorm | ln_f.weight, ln_f.bias | final_ln.scale, final_ln.shift |
Load safetensors weights#
make_stub_safetensors(STUB_PATH, cfg, seed=42)offline fixturemodel = GPTModel(cfg)report = load_safetensors(model, STUB_PATH, verbose=False)report.summary()loaded= missing= unexpected= shape_mismatch=report.ok()no missing, no shape_mismatchmodel.lm_head.weight.data_ptr() == model.tok_embed.weight.data_ptr()tiedTroubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
| Loss near zero, model learns nothing | No shift by one | Target at positions 1..T |
| LayerNorm scale goes to zero | Weight decay on the scale | Scale and bias in the no decay group |
| One bad batch wipes hours | Huge gradient | clip_grad_norm_ before step |
| Same samples before and after load | Name map missed every tensor | Read the LoadReport |
shape_mismatch in the report | Dims differ from the config | Loader assigns nothing. Fix the config |
| Off by one LR late in the run | Glued three part schedule | One function over step, test the edges |
Pipeline eval gates#
| Setting | Default | Note |
|---|---|---|
mmlu | 65.0 | Must be at least this |
humaneval | 40.0 | Must be at least this |
truthfulqa | 50.0 | Must be at least this |
safety_refusal_rate | 0.05 | Must be at most this |
kl_from_reference | 25.0 | Must be at most this |
cost_total_usd | 50000.0 | Must be at most this |
budget_usd | 50000.0 | Run halts over budget |
seed | 42 | Manifest field |