Part 2 · Large language models · module 2 of 4 · 19 min
Pretrain LLMs
Training a model from random weights — AdamW, custom losses, weight initialisation, dropout, numerical scaling and the datasets involved.
The essence
Pretraining is one objective repeated over an enormous corpus: predict the next token. There is no labelling, because the text is its own label — the answer to 'what comes next' is simply the next token. Everything a base model knows about grammar, facts and reasoning is a side effect of getting steadily better at that single prediction.
Why it matters
Pretraining is where capability comes from and where nearly all the compute goes; fine-tuning only redirects what already exists. It is also where the practical machinery of deep learning bites hardest — the optimiser, the initialisation, the learning rate, numerical range — and where a subtle mistake costs days rather than seconds.
Topics covered (19)
- 01What pretraining is, and whether it is necessary
- 02Hugging Face as the model and dataset hub
- 03The AdamW optimizer
- 04SGD vs. Adam vs. AdamW compared
- 05Training model 1
- 06Adding a held-out test set
- 07Training with pretrained GPT-2 embeddings
- 08Training the full model with modifications
- 09Writing a custom loss function
- 10Deliberately biasing a model toward a token
- 11Numerical scaling issues in deep models
- 12Weight initialisation schemes
- 13Training with explicit weight initialisation
- 14Dropout in theory and in PyTorch
- 15Whether to output logits or log-softmax
- 16The FineWeb dataset
- 17Tuning dropout in a full model
- 18What happens to tokens that never appear
- 19Optimisation options and trade-offs
What pretraining is
Take a very large corpus, tokenize it, and repeatedly ask the model to predict token t+1 from tokens up to t. Compare its distribution to the true next token with cross-entropy, backpropagate, step the optimiser. Repeat for billions of tokens.
The reason this produces something impressive is that predicting text well requires modelling everything text describes. To predict the last word of 'the capital of France is' you must have stored a fact. To close a bracket correctly you must track state. Nobody trains those abilities directly; they are what low loss requires.
SGD, Adam, AdamW
| Optimiser | Step rule | Behaviour |
|---|---|---|
| SGD | w ← w − lr·g | Simple; needs careful tuning and momentum; slow on ill-conditioned losses |
| SGD + momentum | Accumulates a velocity | Smooths noise, accelerates along consistent directions |
| Adam | Per-parameter step from running mean and variance of g | Robust to bad scaling; the default for years |
| AdamW | Adam with weight decay applied directly to w | Correct decoupled regularisation; the transformer standard |
Why AdamW rather than Adam
Adam divides each parameter's step by the running root-mean-square of its own gradients, so parameters with small gradients still move. That adaptivity is why it trains transformers out of the box where SGD needs careful schedules.
The flaw is weight decay. In Adam, L2 regularisation is added into the gradient, so it then gets divided by that same running RMS — parameters with large gradients end up barely regularised. AdamW applies the decay directly to the weights, outside the adaptive step, which restores the intended uniform pull toward zero. On transformers the difference is consistently measurable.
One more standard practice: do not decay biases or layernorm gains. Shrinking them toward zero has no regularising benefit and actively hurts.
import torch
def make_optimizer(model, lr=3e-4, weight_decay=0.1):
decay, no_decay = [], []
for name, p in model.named_parameters():
if not p.requires_grad:
continue
# Matrices get decay; biases and layernorm gains do not.
if p.dim() >= 2:
decay.append(p)
else:
no_decay.append(p)
return torch.optim.AdamW(
[{"params": decay, "weight_decay": weight_decay},
{"params": no_decay, "weight_decay": 0.0}],
lr=lr, betas=(0.9, 0.95), eps=1e-8,
)
# betas=(0.9, 0.95) rather than the (0.9, 0.999) default is the usual
# transformer choice: a shorter variance window reacts faster to changes.import torch, torch.nn as nn
@torch.no_grad()
def evaluate(model, loader, loss_fn, device):
model.eval() # disables dropout
total, n = 0.0, 0
for x, y in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
loss = loss_fn(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
total += loss.item() * y.numel()
n += y.numel()
model.train()
return total / n
def train(model, train_loader, test_loader, epochs=5, device="cuda"):
model.to(device).train()
opt = make_optimizer(model)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(epochs):
for x, y in train_loader:
x, y = x.to(device), y.to(device)
logits = model(x)
loss = loss_fn(logits.reshape(-1, logits.size(-1)), y.reshape(-1))
opt.zero_grad(set_to_none=True) # cheaper than zeroing
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
print(epoch, "train", loss.item(), "test", evaluate(model, test_loader, loss_fn, device))
# The test set must be a contiguous held-out slice of the corpus, not random
# windows: overlapping windows leak the same text into both splits.Note: Forgetting model.eval() leaves dropout active during evaluation, which makes test loss look worse and noisier than it is.
Weight initialisation
Initialise all weights to zero and every neuron in a layer computes the same thing and receives the same gradient forever — the symmetry never breaks. Initialise too large and activations explode through depth; too small and they vanish.
The principled schemes keep the variance of activations roughly constant across layers. Xavier scales by 1/√fan_in for symmetric activations; He scales by 2/√fan_in to compensate for ReLU discarding half the distribution. GPT-2 uses a normal with standard deviation 0.02, and additionally scales the residual-projection weights by 1/√(2·n_layers) so that the variance added to the residual stream does not grow with depth.
import math, torch.nn as nn
def init_weights(module, n_layers):
if isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
model.apply(lambda m: init_weights(m, n_layers))
# Scale down anything writing into the residual stream, so the accumulated
# variance stays O(1) regardless of depth.
for name, p in model.named_parameters():
if name.endswith("proj.weight"):
nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * n_layers))Starting from pretrained embeddings
A cheap and effective middle path: initialise your embedding table from GPT-2's rather than randomly, so the model begins with a sensible semantic geometry and spends its capacity on the harder parts. Loss drops faster and lower on small corpora.
The requirement is a matching tokenizer — the rows must correspond to the same strings. Use GPT-2's tokenizer and its embedding rows line up by construction. You can either freeze them, saving memory and preserving the pretrained structure, or let them adapt.
Dropout
Dropout zeroes each activation independently with probability p during training, and scales the survivors by 1/(1−p) so the expected magnitude is unchanged. At evaluation it does nothing.
It regularises by preventing any unit from being relied upon — the network must build redundant paths. Transformers apply it after attention weights, after the output projection, and inside the MLP. Typical values are 0.1 for large-corpus pretraining and up to 0.3 when fine-tuning on a small dataset where overfitting is the real risk.
The 1/(1−p) scaling is the part worth remembering: it is why you must call model.eval(), and why a hand-rolled dropout that forgets the rescale shifts every downstream activation.
import torch, torch.nn as nn
x = torch.ones(1, 10)
drop = nn.Dropout(p=0.5)
drop.train()
print(drop(x)) # about half are 0, the rest are 2.0 — note the 1/(1-p) scale
drop.eval()
print(drop(x)) # identity: all ones
# Equivalent by hand:
def manual_dropout(x, p, training):
if not training or p == 0:
return x
keep = (torch.rand_like(x) > p).float()
return x * keep / (1 - p) # omitting /(1-p) shifts the scaleNumerical scaling
float32 holds roughly seven significant digits and float16 about three, with a maximum around 65,504. Mixed-precision training runs the forward and backward passes in low precision for speed and keeps a float32 copy of the weights for the update, with a loss scaler multiplying the loss so small gradients do not flush to zero.
bfloat16 has the same exponent range as float32 with fewer mantissa bits, so it rarely overflows and needs no loss scaling. Where available it is the easier choice. When you see NaNs appear suddenly mid-training, the usual causes are an overflow in float16, a division by a near-zero denominator, or a log of zero.
import torch, torch.nn as nn, torch.nn.functional as F
class BiasedLoss(nn.Module):
"""Cross-entropy plus a term rewarding probability mass on target_id.
A deliberately blunt demonstration that the loss defines the behaviour."""
def __init__(self, target_id, strength=0.1):
super().__init__()
self.target_id, self.strength = target_id, strength
def forward(self, logits, targets):
ce = F.cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1))
logp = F.log_softmax(logits, dim=-1)[..., self.target_id]
return ce - self.strength * logp.mean()
# Train with this and the model over-produces that token everywhere. It is the
# clearest possible demonstration of the general principle: a model optimises
# exactly what you measure, including the parts you did not intend to specify.Data, and tokens that never appear
Modern open pretraining uses large filtered web corpora — FineWeb is the well-documented example, derived from Common Crawl with deduplication, language filtering and quality heuristics. The filtering is most of the work; raw web text trains a markedly worse model than the same volume cleaned.
A curiosity with real consequences: tokens in the vocabulary that never appear in training keep their initialised embedding rows and receive no gradient. Their unembedding rows are similarly untrained, so their logits are effectively arbitrary. These are the 'glitch tokens' that make models behave bizarrely when one is forced into a prompt — a direct, visible artefact of the vocabulary being fixed before the data was seen.
The optimisation options you actually turn
- Learning rate — by far the most impactful. Warm up linearly, then decay with cosine.
- Batch size — larger gives less gradient noise; use gradient accumulation to simulate it when memory is short.
- Gradient clipping — clip the global norm to 1.0; cheap insurance against a single bad batch.
- Weight decay — around 0.1 on matrices only.
- Dropout — 0.0–0.1 for large corpora, higher when data is scarce.
- Precision — bf16 where supported, otherwise fp16 with a loss scaler.
- Context length — quadratic cost in attention, so this dominates memory.
Worth remembering
- Next-token prediction is self-supervised: no labels are needed because the text supplies them.
- AdamW decouples weight decay from the adaptive step, which is why it beats Adam on transformers.
- Always return raw logits from a model and let the loss function apply log-softmax internally.