Loading Pretrained Weights
Training a 124 million parameter model from scratch is a budget decision; loading a published checkpoint is a Tuesday. This lesson loads pretrained GPT-2 style weights from a safetensors file into the exact architecture from lesson 35, walks the parameter name mapping piece by piece, and sanity generates a continuation to prove the load worked. No network, no third party loaders, no opaque magic.
Learning Objectives
- Read a safetensors file with the `safetensors` Python library and inspect the tensor names and shapes.
- Map each pretrained parameter name onto a parameter inside the lesson 35 GPT model.
- Handle the two name conventions that differ between published GPT-2 weights and the model in this track: `wte/wpe/h.N.attn.c_attn/c_proj` and `mlp.c_fc/c_proj` versus the locally named `tok_embed/pos_embed/blocks.N.attn.qkv/out_proj` and `mlp.fc1/fc2`.
- Detect a shape mismatch before any weight assignment happens, record it in `report.shape_mismatch`, and return without assigning anything.
- Generate a short continuation with the loaded weights and confirm the tokens come from the loaded distribution, not the randomly initialized one.
The Problem
A published checkpoint is a file of named tensors, and the names, shapes, and layouts come from the implementation that wrote it, not from yours. The pretrained file has h.0.attn.c_attn.weight of shape (768, 2304). Your model expects blocks.0.attn.qkv.weight of shape (2304, 768), because nn.Linear stores its weight as (out, in) and the published file stores the transpose. One parameter carries three identities (name, shape, byte layout), and the loader has to reconcile all three.
A loader that copies blindly puts the right tensor in the wrong place, and you get a model that generates nonsense. A loader that refuses to copy on a shape difference but logs nothing leaves you guessing which tensor failed to land. The loader here is explicit: every assignment is logged, every shape is checked, and a LoadReport summarizes hits, misses, and shape mismatches so you can read what happened.
The Concept
Loading pretrained weights means reading each named tensor from a file, translating its name to a local parameter name, checking its shape, and copying it into the model. The name mapper is just a function from string to string. The shape check is one if. The assignment happens inside torch.no_grad() so autograd does not track the load, and the report holds the outcome of every name.
The GPT-2 naming convention
Published GPT-2 weights live under names like:
| Pretrained name | Shape | Meaning |
|---|---|---|
wte.weight | (50257, 768) | Token embedding |
wpe.weight | (1024, 768) | Position embedding |
h.N.ln_1.weight | (768,) | LayerNorm 1 scale at block N |
h.N.ln_1.bias | (768,) | LayerNorm 1 shift at block N |
h.N.attn.c_attn.weight | (768, 2304) | Fused QKV linear weight |
h.N.attn.c_attn.bias | (2304,) | Fused QKV linear bias |
h.N.attn.c_proj.weight | (768, 768) | Attention output projection |
h.N.attn.c_proj.bias | (768,) | Attention output projection bias |
h.N.ln_2.weight | (768,) | LayerNorm 2 scale |
h.N.ln_2.bias | (768,) | LayerNorm 2 shift |
h.N.mlp.c_fc.weight | (768, 3072) | MLP fc1 weight |
h.N.mlp.c_fc.bias | (3072,) | MLP fc1 bias |
h.N.mlp.c_proj.weight | (3072, 768) | MLP fc2 weight |
h.N.mlp.c_proj.bias | (768,) | MLP fc2 bias |
ln_f.weight | (768,) | Final LayerNorm scale |
ln_f.bias | (768,) | Final LayerNorm shift |
Two things to plan for. First, the c_attn, c_proj, and c_fc linears are stored transposed relative to what nn.Linear.weight expects, so the loader transposes during assignment. Second, the LM head is not in the file at all. The GPTModel constructor already ties the head to tok_embed, so loading wte fills both, and the loader re-aliases after the load only if the two data pointers differ.
The local naming convention
The model in this track uses descriptive names:
| Local name | Meaning |
|---|---|
tok_embed.weight | Token embedding |
pos_embed.weight | Position embedding |
blocks.N.ln1.scale | LayerNorm 1 scale at block N |
blocks.N.ln1.shift | LayerNorm 1 shift |
blocks.N.attn.qkv.weight | Fused QKV |
blocks.N.attn.qkv.bias | Fused QKV bias |
blocks.N.attn.out_proj.weight | Attention output projection |
blocks.N.attn.out_proj.bias | Output projection bias |
blocks.N.ln2.scale | LayerNorm 2 scale |
blocks.N.ln2.shift | LayerNorm 2 shift |
blocks.N.mlp.fc1.weight | MLP fc1 |
blocks.N.mlp.fc1.bias | MLP fc1 bias |
blocks.N.mlp.fc2.weight | MLP fc2 |
blocks.N.mlp.fc2.bias | MLP fc2 bias |
final_ln.scale | Final LayerNorm scale |
final_ln.shift | Final LayerNorm shift |
The mapping is a fixed function, shipped as a dict that the loader consults for each name.
The stub fixture
Real GPT-2 weights are 0.5 GB, so the demo does not download them. It generates a small safetensors fixture on first run, using the exact GPT-2 naming convention and shapes for a 4-block model (d_model 192, 6 heads, vocab 256, context 64) instead of the 12-block, 768-wide original. The fixture has the right structure to exercise every code path in the loader. Swap in the real file and the loader works without modification.
Build It
code/main.py implements:
- A small replica of the lesson 35
GPTModel, so this lesson stands on its own. make_pretrained_to_local(num_layers), which expands the per-layer entries.load_safetensors(model, path), which iterates names, maps them, checks shape, transposes the conv1d-style weights, and assigns undertorch.no_grad(). It returns aLoadReport.make_stub_safetensors(path, cfg), which generates a fixture file with the exact pretrained naming convention.- A demo that creates
outputs/gpt2-stub.safetensorson first run, builds a fresh model, captures one generated continuation from random init, loads the stub, captures another continuation, prints both, and verifies the two differ (the load actually changed the model).
Run it:
python3 code/main.pyOutput: the fixture path, a per-name load log, a LoadReport summary, a continuation before the load, a continuation after the load, and and a shape mismatch check: the demo writes a separate outputs/gpt2-bad.safetensors whose wte.weight has the wrong width, loads it into a fresh model, prints the report, and deletes the file. The report shows the mismatch, nothing is assigned, and the model's parameters are listed under missing. Only a missing file raises (FileNotFoundError).
Stack
safetensorsfor the on disk format and a streaming reader.torchfor the model and the assignment math.- No
transformers, nohuggingface_hub, no network calls.
Production patterns in the wild
Three patterns let a loader survive weights you did not create.
Validate the file before any assignment. Open the file, list every tensor name with its dtype and shape, run the full mapping with shape checks, and start assigning only on success. A half-loaded model fails silently.
Log each assignment with the source name and the destination name. When something looks wrong, the log tells you which tensor landed where, and the alternative is reading hexdumps. The LoadReport dataclass here tracks loaded, missing, unexpected, and shape_mismatch lists and prints a summary at the end.
Treat the LM head as a weight tying alias, not a separate copy. The constructor sets self.lm_head.weight = self.tok_embed.weight, and the loader re-aliases after the load only if the data pointers differ. Copying the embedding matrix into a fresh lm_head.weight parameter breaks tying and quietly doubles your parameter count.
Use It
- The loader works for any safetensors file that uses the pretrained naming convention. Real GPT-2 files (small / medium / large / xl) load without code changes; only the model config differs.
- The same pattern extends to LLaMA, Mistral, and Qwen weights once you update the name map. The shape checks and the report stay identical.
- Sanity generation after a load is a quick gate. If the post-load samples look like the pre-load samples, the load did not change the model, which means the mapping silently missed every tensor.
Exercises
- Add a
dtypeargument to the loader that casts each tensor to a target dtype (bfloat16,float16,float32) during assignment. Confirm afloat32model can be downcast tobfloat16and still generate. - Add an
expected_layersargument that refuses to load a checkpoint whoseh.Nindices do not match the model'snum_layers. - Plug the loader into the lesson 35 generation function and produce two side by side samples: one from random init, one from the loaded fixture.
- Add an export path: write the current model state into a fresh safetensors file using the pretrained naming convention. Round trip the loader and confirm the report has zero shape mismatches.
- Extend
make_pretrained_to_localto handle the LLaMA naming convention (no biases, RMSNorm, fused qkv layout) and re-run the loader on a stub LLaMA fixture you generate.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Name map | "Key remapping" | The function from pretrained tensor names to local parameter names; usually a literal dict with one entry per layer index, expanded over a loop |
| Shape mismatch | "Bad shape" | The pretrained tensor exists under the mapped name but its dimensions disagree with the local parameter; the loader records the pair in shape_mismatch and returns without assigning anything |
| Transpose-on-load | "Conv1d layout" | Published GPT-2 stores attention and MLP projections in the transpose of what nn.Linear expects; the loader transposes during assignment |
| Weight tying alias | "Shared LM head" | Setting model.lm_head.weight = model.tok_embed.weight so the head and embedding share storage; the constructor does this, and the loader re-aliases after the load only if the pointers differ. The head is not in the file because of this |
| Load report | "Coverage summary" | A small dataclass that tracks loaded, missing, unexpected, and shape_mismatch lists; printing it is how you tell whether the load succeeded |
My bet is that the post-load sample comparison earns its place in the demo. It costs two short generations, and it catches a quiet failure: a mapping that matched nothing and left you holding a randomly initialized model. I'd run it before trusting any load, and I'd read the LoadReport next to it, since the report says which names landed and the samples say whether the model changed. I'd also log assignments even when the load succeeds. When a tensor lands in the wrong place, you want the source and destination names already printed rather than a hexdump to read.
Further Reading
- Phase 19 lesson 35 for the architecture that receives the weights.
- Phase 19 lesson 36 for the training loop that produces a checkpoint of the same shape.
- Phase 10 lesson 11 (quantization) for what to do with the loaded weights when memory is tight.
- Phase 10 lesson 13 (building a complete LLM pipeline) for the full lifecycle around load and inference.
Code
- main.py (python, 412 lines)
- tests/test_load.py (python, 167 lines)