Chapter 5 · Main chapter
Pretraining on Unlabeled Data
A model becomes a language model by doing one thing, billions of times: predict the next token, and adjust. Here is the loss that drives it, a real training run, the dials that control its output, and the shortcut of borrowing OpenAI's weights.
Adapted from the original notebook by Sebastian Raschka · the Python shown is the book's; the demos run a faithful JavaScript port in your browser
Act 1 · How wrong is the model?
targets = inputs shifted one token right
Training needs a number to minimize. Take two tiny sequences; the target for each token is simply the next token — the shifted labels from chapter 2.
A single loss value is the entire training signal. Point an optimizer at it for ten epochs and watch what happens.
Act 2 · Watch it learn
epoch 0· “Every effort moves you” →
Every effort moves you rentingetic wasnم refres RexMeCHicular stren Mortgage TT remember gard ACTIONSussedOND Land Engeleddedemate breaths proxies GalaxyForm therapies drying consultants FrazierVPN inhib prerequisite suralianlyakpotion shapes tabloid Roboticstw pronoun Primary Flame779 enumDet Valent pseudo peskyarp
A real 10-epoch run on one 20 KB short story. Before training, loss sits near 11.0 and the model emits pure noise: Every effort moves you rentingetic wasnم…
That model can generate — but always the same way. To control how it picks the
next token, we reach past argmax.
Act 3 · Choosing the next token
softmax · T = 1 · argmax → forward
A trained model outputs one score per vocabulary token. Softmax turns them into a distribution; greedy decoding always takes the tallest bar — here, always “forward”. Same input, same output, forever.
None of this matters if the weights are bad. Our 20 KB model only memorized. The fix isn't more clever decoding — it's real pretraining, which we can borrow.
Act 4 · Borrow OpenAI's weights
our model · trained 10 epochs on 20 KB
Every effort moves you know," was one of the axioms he laid down across the Sevres and silver of an exquisitely appointed lun
fluent — but copied word-for-word from the one story it saw
Our from-scratch model can only ever be as good as its data. Trained on one 20 KB story, its “fluency” is really just the story, played back.
The lab
Drive the real thing. These are OpenAI GPT-2's actual next-token logits — warm up the temperature, tighten top-k, and sample. The full continuations underneath were generated by the real model at build time.
Sampling playground
Real GPT-2 next-token logits. Warm up the temperature, tighten top-k, and sample.
top 14 of 200 real logits · dimmed = cut by top-k · heights are probabilities
sampled tokens
full continuations — generated at build time by the real GPT-2
greedy
Every effort moves you forward. The first step is to understand the importance of your work. The second
T=0.8, k=50
Every effort moves you toward finding an ideal life. You don't have to accept your problems and make them go away,
T=1.5, k=50
Every effort moves you toward finding an ideal new way to practice something! What makes us want to be on top
T=1.5, k=5
Every effort moves you forward," said the former governor, adding: "I am a man who is not going to sit
Scrub the training run epoch by epoch. Watch the training loss dive while the validation loss flattens — the signature of a model memorizing its tiny corpus.
Loss-curve explorer
Scrub through the 10-epoch run — read each epoch's loss and the text it generated.
“Every effort moves you” → at epoch 10
Every effort moves you know," was one of the axioms he laid down across the Sevres and silver of an exquisitely appointed luncheon-table, when, on a later day, I had again run over from Monte Carlo; and Mrs. Gis
A bonus from Appendix D: the training-loop refinements chapter 5 leaves out. Warm the learning rate up before decaying it, and clip gradients that grow too large — both pure functions, running live.
Warmup, cosine decay & gradient clipping
Appendix D's training-loop upgrades — shape the learning rate, then tame exploding gradients.
Ramp up linearly to avoid destabilizing early updates, then follow a half-cosine down toward zero as the model settles.
gradient clipping · max-norm
When the gradient's length exceeds the threshold, every component is scaled down to fit — a hard cap on any single destabilizing step.
The real code
The demos run a TypeScript port; here is the Python they mirror. The loss is one call — cross-entropy over the flattened logits and the shifted targets:
def calc_loss_batch(input_batch, target_batch, model, device):
input_batch, target_batch = input_batch.to(device), target_batch.to(device)
logits = model(input_batch)
loss = torch.nn.functional.cross_entropy(
logits.flatten(0, 1), target_batch.flatten())
return loss
perplexity = torch.exp(loss) # the "effective vocabulary size" of the model's doubtDecoding gets two new dials on top of chapter 4's greedy loop — temperature scaling and top-k filtering:
def generate(model, idx, max_new_tokens, context_size, temperature=0.0, top_k=None, eos_id=None):
for _ in range(max_new_tokens):
idx_cond = idx[:, -context_size:]
with torch.no_grad():
logits = model(idx_cond)
logits = logits[:, -1, :]
if top_k is not None: # keep only the k highest logits
top_logits, _ = torch.topk(logits, top_k)
logits = torch.where(
logits < top_logits[:, -1], torch.tensor(float("-inf")), logits)
if temperature > 0.0: # sample from a tempered softmax
probs = torch.softmax(logits / temperature, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
else: # or take the argmax (greedy)
idx_next = torch.argmax(logits, dim=-1, keepdim=True)
idx = torch.cat((idx, idx_next), dim=1)
return idxShow the training loop and the OpenAI weight-loading
def train_model_simple(model, train_loader, val_loader, optimizer, device,
num_epochs, eval_freq, eval_iter, start_context, tokenizer):
train_losses, val_losses, track_tokens_seen = [], [], []
tokens_seen, global_step = 0, -1
for epoch in range(num_epochs):
model.train()
for input_batch, target_batch in train_loader:
optimizer.zero_grad()
loss = calc_loss_batch(input_batch, target_batch, model, device)
loss.backward() # compute gradients
optimizer.step() # update weights
tokens_seen += input_batch.numel()
global_step += 1
if global_step % eval_freq == 0:
train_loss, val_loss = evaluate_model(
model, train_loader, val_loader, device, eval_iter)
train_losses.append(train_loss)
val_losses.append(val_loss)
generate_and_print_sample(model, tokenizer, device, start_context)
return train_losses, val_losses, track_tokens_seen# Save the trained weights — and the optimizer state, so training can resume
# exactly where it left off (AdamW carries per-parameter momentum):
torch.save({
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
}, "model_and_optimizer.pth")
checkpoint = torch.load("model_and_optimizer.pth", weights_only=True)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])# Same GPTModel class, real OpenAI weights (context 1024, qkv_bias=True):
settings, params = download_and_load_gpt2(model_size="124M", models_dir="gpt2")
gpt = GPTModel(NEW_CONFIG)
load_weights_into_gpt(gpt, params) # copy every tensor into place
token_ids = generate(
model=gpt, idx=text_to_token_ids("Every effort moves you", tokenizer),
max_new_tokens=25, context_size=1024, top_k=50, temperature=1.5)OpenAI released GPT-2 in four sizes; §5.5 loads the smallest, but the same
GPTModel class scales to all of them by changing three config numbers (§5.5):
| Model | Parameters | Layers | Heads | Embedding dim |
|---|---|---|---|---|
| GPT-2 small | 124M | 12 | 12 | 768 |
| GPT-2 medium | 355M | 24 | 16 | 1024 |
| GPT-2 large | 774M | 36 | 20 | 1280 |
| GPT-2 XL | 1558M | 48 | 25 | 1600 |
Four things to remember
- Loss is cross-entropy on next-token prediction. It's the mean negative log-probability the model gives the correct token; perplexity is its exponential — the effective number of tokens the model is choosing between.
- A tiny model on tiny data memorizes. Ten passes over one short story drove the training loss under 0.4 while validation stalled near 6.5 — the model learned the text, not the language.
- Temperature and top-k trade safety for diversity. Temperature reshapes the distribution before sampling; top-k clips the nonsense tail. Together they let a model be creative without being incoherent.
- Pretraining is expensive; loading it is not. The same architecture, filled with OpenAI's 124M weights, turns gibberish into fluent English — no training required.
Adapted from Build a Large Language Model (From Scratch)
by Sebastian Raschka — original notebook
(Apache 2.0). The loss, perplexity, and sampling math on this page run a
TypeScript port verified against its executed Python with
npm run verify:sampling; the training trajectory and pretrained continuations
were precomputed from real seed-123 runs.