Skip to content

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?

every effort moves effort moves you
I really like really like chocolate

targets = inputs shifted one token right

01 / 05

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

02468100246810epoch
training validation

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

01 / 06

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

0.06
closer
2e-3
every
effort
0.57
forward
3e-3
inches
moves
pizza
0.36
toward
4e-3
you

softmax · T = 1 · argmax → forward

01 / 05

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

01 / 05

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.

0.20
forward
0.12
to
0.06
along
0.06
.
0.06
,
0.06
through
0.04
from
0.04
toward
0.04
in
0.04
towards
0.03
closer
0.03
and
0.02
on
0.02
away

top 14 of 200 real logits · dimmed = cut by top-k · heights are probabilities

sampled tokens

Every effort moves you→ press “sample a token”

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.

02468100246810epoch
training validation
train loss0.39
val loss6.45

“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.

warmup1e-30training step

Ramp up linearly to avoid destabilizing early updates, then follow a half-cosine down toward zero as the model settles.

gradient clipping · max-norm

1
2
2
4
‖g‖₂ = 5.00 > 1.0 → scale × 0.20

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:

ch05.ipynb · §5.1.2
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 doubt

Decoding gets two new dials on top of chapter 4's greedy loop — temperature scaling and top-k filtering:

ch05.ipynb · §5.3.3
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 idx
Show the training loop and the OpenAI weight-loading
ch05.ipynb · §5.2
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
ch05.ipynb · §5.4
# 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"])
ch05.ipynb · §5.5
# 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):

ModelParametersLayersHeadsEmbedding dim
GPT-2 small124M1212768
GPT-2 medium355M24161024
GPT-2 large774M36201280
GPT-2 XL1558M48251600

Four things to remember

  1. 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.
  2. 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.
  3. 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.
  4. 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.