Index Ai Academy
Phase 5 · Transformers · The transformer

Build a tiny GPT

Write and train a small GPT from scratch.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Everything in this phase comes together here. You'll build a working GPT from parts you already know. You'll train it on real text and then let it write. It's tiny: about 40,000 parameters, trained for 300 steps in your browser. Yet it has the same design and the same training goal as the chatbots you use every day. Its mistakes are worth a close look, because they show how big models go wrong too.

What you'll be able to do

  • Prepare a corpus for a language model: tokenize, split, and batch with shifted targets
  • Train a small GPT from scratch and watch its loss fall
  • Generate text from it and judge what it has and hasn't learned
  • Connect what you see to how large language models behave

What it covers

  1. The plan
  2. Worked example: a window and its targets
  3. The model's settings
  4. Reading the loss
  5. What did it learn, and what didn't it?
  6. From here to an LLM

The plan

Picture a child who has read a big stack of recipe cards. Soon they can write a new card that looks right: a title, a list of ingredients, then the steps. They copy the pattern without knowing much about cooking.

Your tiny GPT learns the same way. It reads the course's product catalog, a set of short product descriptions. Then it writes new ones. There are four steps.

  1. Tokenize. Cut the text into tokens and give each token an ID number. Here each word, number, punctuation mark and line break is one token. That gives a vocabulary of about 180 tokens, small enough to train in a browser. (Real models use BPE from the tokenization lesson instead.)
  2. Split and batch. Keep the first 90% of the text for training. Hold back the last 10% as validation text. The model never trains on it, so it shows how the model does on text it hasn't seen. Then cut random windows of 24 tokens from the text. Each window comes with its targets: the same window, moved one token later.
  3. Train. Build a small GPT and train it for 300 steps.
  4. Generate. Ask it to write product descriptions, and read them with a critical eye.

The targets are the key idea, so start there.

Try it now

The same loop you've used since Phase 3. The model is what's new.

Worked example

Worked example: a window and its targets

Here is a short piece of catalog-style text, already cut into tokens. Take a window of 4 tokens that starts at position 2 (counting from 0, as Python does). The targets are the same window, moved one token later. Work out both, then line them up.

tokens = ["It", "is", "light", ".", "It", "is", "warm", "."]
block = 4                                   # how many tokens in a window
start = 2                                   # where this window begins
x = tokens[start:start + block]             # what the model reads
y = tokens[start + 1:start + block + 1]     # what it should guess at each spot
print(x)
print(y)
for seen, target in zip(x, y):
    print(seen, "->", target)
It runs right here in your browser, with nothing to install.

The window x is light, ".", It, is. The targets y are ".", It, is, warm. Line them up and each token in x sits above the token that comes right after it.

So one window holds 4 small tests. After "light", guess ".". After "light .", guess "It". And so on. The causal mask keeps each test fair: at each spot, the model sees only the tokens up to that spot.

The real code does the same thing with windows of 24 tokens, 16 windows at a time. That's 16 × 24 = 384 graded guesses in every training step.

Quick check
One training window is 24 tokens long. How many next-token guesses does the model make and get graded on from that one window?
  1. 23
  2. 48
  3. 1: only at the last position
  4. 24: one at every position
Show the answer

24: one at every position

Every position's target is the token after it, and the causal mask hides the rest of the window. So all 24 positions give a fair guess at the same time.

The model's settings

You built this model in the last lesson. Here are its settings, in plain words.

  • Width 32. Each token becomes a list of 32 numbers as it flows through the model.
  • 4 heads. Each block runs 4 attention patterns side by side. Each head works with 32 ÷ 4 = 8 of those numbers.
  • 2 layers. Two transformer blocks, one after the other.
  • Context 24. The model reads at most 24 tokens at a time.

Together that's about 40,000 parameters, the numbers that training adjusts. The optimizer is AdamW from Phase 4. Its learning rate is 0.003, written 3e-3 in the code. Its weight decay of 0.01 gently pulls the weights toward zero, which helps against memorizing.

Run it

The cell below does all four steps. It takes a while, and the Run button counts the seconds. It uses the course's in-browser PyTorch subset. On a laptop with real PyTorch, the same code runs in about a second.

import math
import re
import requests
import torch
import torch.nn as nn

# 1. tokenize: words, numbers, punctuation and line breaks
text = requests.get("https://api.gradient.test/v1/corpus").json()["text"]
tokens = re.findall(r"[A-Za-z]+|[0-9]+|[^\sA-Za-z0-9]|\n", text)
vocab = sorted(set(tokens))
stoi = {t: i for i, t in enumerate(vocab)}
data = torch.tensor([stoi[t] for t in tokens])
split = int(0.9 * len(data))
train_data, val_data = data[:split], data[split:]
print(f"{len(tokens):,} tokens, vocabulary of {len(vocab)}")

# 2. batches of windows; y is x shifted by one token
def get_batch(d, block=24, batch=16):
    starts = torch.randint(0, len(d) - block - 1, (batch,)).tolist()
    x = torch.stack([d[i:i + block] for i in starts])
    y = torch.stack([d[i + 1:i + block + 1] for i in starts])
    return x, y

# 3. the model from the last lesson
class Block(nn.Module):
    def __init__(self, d, h):
        super().__init__()
        self.h = h
        self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.qkv, self.proj = nn.Linear(d, 3 * d), nn.Linear(d, d)
        self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))

    def forward(self, x):
        B, T, d = x.shape
        q, k, v = self.qkv(self.ln1(x)).split(d, dim=-1)
        q, k, v = (t.view(B, T, self.h, d // self.h).transpose(1, 2) for t in (q, k, v))
        s = (q @ k.transpose(-2, -1) / math.sqrt(d // self.h)).masked_fill(~torch.tril(torch.ones(T, T)).bool(), float("-inf"))
        x = x + self.proj((torch.softmax(s, dim=-1) @ v).transpose(1, 2).reshape(B, T, d))
        return x + self.mlp(self.ln2(x))

class GPT(nn.Module):
    def __init__(self, vocab, context, d, n_layers, n_heads):
        super().__init__()
        self.context = context
        self.tok, self.pos = nn.Embedding(vocab, d), nn.Embedding(context, d)
        self.blocks = nn.Sequential(*[Block(d, n_heads) for _ in range(n_layers)])
        self.ln_f, self.head = nn.LayerNorm(d), nn.Linear(d, vocab)

    def forward(self, idx):
        x = self.tok(idx) + self.pos(torch.arange(idx.shape[1]))
        return self.head(self.ln_f(self.blocks(x)))

torch.manual_seed(0)
model = GPT(len(vocab), context=24, d=32, n_layers=2, n_heads=4)
print(f"{sum(p.numel() for p in model.parameters()):,} parameters")
opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.01)

# 4. train
for step in range(301):
    x, y = get_batch(train_data)
    loss = nn.functional.cross_entropy(model(x).view(-1, len(vocab)), y.view(-1))
    opt.zero_grad()
    loss.backward()
    opt.step()
    if step % 50 == 0:
        with torch.no_grad():
            xv, yv = get_batch(val_data, batch=64)
            val = nn.functional.cross_entropy(model(xv).view(-1, len(vocab)), yv.view(-1)).item()
        print(f"step {step:>3}  training loss {loss.item():.3f}  validation loss {val:.3f}")

# 5. generate
@torch.no_grad()
def generate(idx, n, temperature=0.8):
    for _ in range(n):
        logits = model(idx[:, -model.context:])[:, -1, :] / temperature
        idx = torch.cat([idx, torch.multinomial(torch.softmax(logits, dim=-1), 1)], dim=1)
    return idx

torch.manual_seed(1)
out = generate(torch.tensor([[stoi["\n"]]]), 70)[0].tolist()
words = " ".join(vocab[i] for i in out)
print("\n" + re.sub(r" ?\n ?", "\n", re.sub(r" ([.,])", r"\1", words)).strip())

Here's what each numbered part of the code does.

  1. re.findall cuts the text into tokens. stoi (short for "string to integer") maps each token to its ID.
  2. get_batch picks 16 random starting points. It cuts a window x and its targets y at each one, as in the worked example.
  3. Block and GPT are the model from the last lesson. torch.manual_seed(0) fixes the random starting weights, so every run matches.
  4. Each training step takes a fresh batch and measures the loss. Then loss.backward() works out the gradients and opt.step() nudges every weight. Every 50 steps it also measures the loss on 64 validation windows. It does this inside torch.no_grad(), so nothing is learned from them.
  5. generate starts from a line break and adds 70 tokens, one at a time. Each time it divides the scores by a temperature of 0.8, which makes likely tokens a little more likely (the sampling lesson). Then torch.multinomial draws one token. The last line tidies the spaces before punctuation.

Reading the loss

The loss is cross-entropy, from Phase 2. It's the penalty for how unlikely the model thought the right next token was. Lower is better.

Before training, the model knows nothing. The best it can do is spread its bets evenly over every token. Say the vocabulary has VV tokens. Then each token, including the right one, gets a chance of 1 in VV. The loss for that even guess is:

loss=−ln⁡(1V)=ln⁡V\text{loss} = -\ln\left(\frac{1}{V}\right) = \ln V

Here ln⁡\ln is the natural logarithm, which Python writes as math.log.

In plain words: a model that guesses evenly over VV tokens starts with a loss equal to the log of VV. The more tokens there are to choose from, the higher the starting loss.

Try small numbers first. With 4 tokens, each gets a chance of 1/4 = 0.25. The loss is minus the log of 0.25, which is about 1.386. That's the same as the log of 4. The tiny GPT's vocabulary has 183 tokens, so it starts near the log of 183, about 5.2.

import math

for V in [4, 183]:
    p = 1 / V                     # even guess: every token gets the same chance
    print(V, round(-math.log(p), 3), round(math.log(V), 3))

In the training run, the loss starts at about 5.4, close to that 5.2, and falls below 1 within a few hundred steps.

The printout has two loss columns. Training loss comes from the batch the model has just learned from. Validation loss comes from the held-back text. If both fall together, the model is learning patterns that carry over to new text. If validation loss starts to rise while training loss keeps falling, the model is memorizing its training text. That's overfitting, from Phase 3.

Go deeper What a loss below 1 means

Loss is minus the log of the chance the model gave the right token. You can turn that around. The chance is e (about 2.718) to the power of minus the loss. A loss of 1 means a chance of about 0.37. Since the loss is an average over many guesses, this is a rough typical figure, not an exact one. At the start, the chance was 1 in 183, about 0.0055.

import math

print(round(math.exp(-1), 2), round(1 / 183, 4))
Quick check
An untrained model guesses evenly among 8 tokens. Predict what this prints.
import math
print(round(-math.log(1 / 8), 2))
  1. 0.12
  2. -2.08
  3. 8.0
  4. 2.08
Show the answer

2.08

Minus the log of 1/8 is the same as the log of 8, about 2.08. A model that guesses evenly over V tokens starts with a loss of about the log of V.

What did it learn, and what didn't it?

The samples read like the catalog. You'll see sentence templates, real product types, and believable materials and features. Now and then there's a slip, such as "light and hikes".

Read the samples closely and you'll usually find three things.

  • Grammar and templates, learned well. "The X Y is made from Z. It is A and B. Built for C."
  • Words in the right slots. Product names, product types, materials and uses each turn up where they belong.
  • Facts, learned poorly. It might write a camp mug "made from recycled ripstop nylon", or a sleeping bag made from fleece. Each phrase fits its slot. But those pairings never appear in the catalog.

That last point is the heart of hallucination (Phase 1). The model learned what a believable description sounds like. It didn't learn which features belong to which product.

It's like someone who has read a hundred restaurant menus. They could write a menu that sounds real, with prices and dish names in the right places. But the dishes might not exist.

Larger models train on far more data. They learn many more of these pairings, so they're right far more often. The mechanism is the same, though. They write what's likely given the text so far. That's usually what's true, but not always. This is why grounding (RAG and tools) and evaluation matter.

Quick check
The tiny GPT writes 'Our Vista camp mug is made from recycled ripstop nylon', though no mug in the catalog is made of nylon. What does that show?
  1. The training data was corrupted
  2. It learned what fits each slot in the template, not which facts go together: a small-scale hallucination
  3. The model memorized that exact sentence
  4. The temperature was set to 0
Show the answer

It learned what fits each slot in the template, not which facts go together: a small-scale hallucination

Each word is plausible in its place, but the data doesn't support the combination.

From here to an LLM

A large language model (LLM) is your tiny GPT with every number turned up. Here's the comparison.

This lessonA frontier LLM
~180-word vocabulary100,000+ BPE tokens
24-token contexthundreds of thousands of tokens
width 32, 2 layers, 4 headswidth in the thousands, ~100 layers, dozens of heads
~40,000 parametershundreds of billions
300 steps on 9,000 tokenstrillions of tokens on thousands of GPUs
pretraining onlypretraining, then fine-tuning and reinforcement learning (Phase 6)

Pretraining is the stage you ran above: learning to predict the next token from lots of text. The extra stages in the last row teach a model to follow instructions and act as an assistant.

The code you just ran is, in its structure, the same program.

Quick check
You want your tiny GPT to work more like a frontier LLM. What mainly has to change?
  1. Only the tokenizer
  2. The basic design: big models don't predict the next token
  3. The scale: more tokens, a longer context, a wider and deeper model, far more data and compute, plus extra training stages
  4. Nothing: the tiny GPT already matches a frontier LLM
Show the answer

The scale: more tokens, a longer context, a wider and deeper model, far more data and compute, plus extra training stages

The skeleton is the same. Frontier models scale every number in the table, and add fine-tuning and reinforcement learning after pretraining.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

Why does a training batch use targets shifted by one position?

  1. So every position's target is the token that follows it: next-token prediction at all positions at once
  2. To make the batch smaller, as the last token in each window has nothing to predict and can be dropped from memory
  3. To hide the first token in each window from the model, so that it has to work that token out from the ones after it
  4. Because the model reads right to left, so the token just before a position is the one that it needs to predict next

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson