Frontend Labs
⋯

Chapter 26 — Hands-on: a tiny Transformer you can run

The goal

Train a small Transformer on a text file and watch it learn to write. On a laptop CPU this takes a few minutes for something recognisably English-shaped.

Get some text

# ~1 MB of Shakespeare -- the standard toy dataset
curl -o input.txt https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt

The full model (about 80 lines)

import torch, torch.nn as nn, torch.nn.functional as F

# ---------------- data ----------------
text = open("input.txt", encoding="utf-8").read()
chars = sorted(set(text))
vocab_size = len(chars)
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: "".join(itos[i] for i in ids)

data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:]

block_size = 128       # how many characters of context
batch_size = 32
n_embd, n_head, n_layer = 128, 4, 4
device = "cuda" if torch.cuda.is_available() else "cpu"

def get_batch(split):
    d = train_data if split == "train" else val_data
    ix = torch.randint(len(d) - block_size - 1, (batch_size,))
    x = torch.stack([d[i:i+block_size] for i in ix])
    y = torch.stack([d[i+1:i+1+block_size] for i in ix])     # targets = shifted by 1
    return x.to(device), y.to(device)

# ---------------- model ----------------
class Block(nn.Module):
    def __init__(self):
        super().__init__()
        self.ln1 = nn.LayerNorm(n_embd)
        self.attn = nn.MultiheadAttention(n_embd, n_head, batch_first=True)
        self.ln2 = nn.LayerNorm(n_embd)
        self.ff = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd), nn.GELU(), nn.Linear(4 * n_embd, n_embd))

    def forward(self, x, mask):
        h = self.ln1(x)
        a, _ = self.attn(h, h, h, attn_mask=mask, need_weights=False)
        x = x + a                       # residual 1
        x = x + self.ff(self.ln2(x))    # residual 2
        return x

class TinyGPT(nn.Module):
    def __init__(self):
        super().__init__()
        self.tok = nn.Embedding(vocab_size, n_embd)
        self.pos = nn.Embedding(block_size, n_embd)
        self.blocks = nn.ModuleList([Block() for _ in range(n_layer)])
        self.lnf = nn.LayerNorm(n_embd)
        self.head = nn.Linear(n_embd, vocab_size)

    def forward(self, idx, targets=None):
        B, T = idx.shape
        x = self.tok(idx) + self.pos(torch.arange(T, device=idx.device))
        mask = torch.triu(torch.ones(T, T, device=idx.device, dtype=torch.bool), 1)
        for b in self.blocks:
            x = b(x, mask)
        logits = self.head(self.lnf(x))
        if targets is None:
            return logits, None
        loss = F.cross_entropy(logits.view(-1, vocab_size), targets.view(-1))
        return logits, loss

    @torch.no_grad()
    def generate(self, idx, max_new_tokens, temperature=1.0):
        for _ in range(max_new_tokens):
            logits, _ = self(idx[:, -block_size:])
            logits = logits[:, -1, :] / temperature      # last position only
            probs = F.softmax(logits, dim=-1)
            nxt = torch.multinomial(probs, 1)
            idx = torch.cat([idx, nxt], dim=1)
        return idx

# ---------------- train ----------------
model = TinyGPT().to(device)
print("parameters:", sum(p.numel() for p in model.parameters()) / 1e6, "M")
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)

for step in range(3001):
    x, y = get_batch("train")
    _, loss = model(x, y)
    opt.zero_grad(set_to_none=True)
    loss.backward()
    opt.step()

    if step % 500 == 0:
        model.eval()
        with torch.no_grad():
            vx, vy = get_batch("val")
            _, vloss = model(vx, vy)
        model.train()
        print(f"step {step:4d}  train {loss.item():.3f}  val {vloss.item():.3f}")

# ---------------- generate ----------------
start = torch.zeros((1, 1), dtype=torch.long, device=device)
print("\n" + decode(model.generate(start, 500)[0].tolist()))

What you should see

Step Loss Sample output
0 ~4.2 qX3z!,fJ ;kW — pure noise
500 ~2.5 the the and to hor the — words forming
1500 ~2.0 KING RICHARD: What shall the world — structure!
3000 ~1.7 Shakespeare-shaped dialogue with names, line breaks, and plausible-looking (but meaningless) speech

Loss ~4.2 at the start is not random. It's ln(vocab_size) — the model assigns equal probability to all ~65 characters. ln(65) = 4.17. Seeing this at step 0 is a great sanity check that your setup is correct.

Experiments (this is where the learning happens)

  1. Break the causal mask. Comment out the mask line (pass attn_mask=None). Loss will drop much faster — because the model can now see the answer. The generated text will be garbage. You've just demonstrated why the mask is essential.
  2. Remove the residuals. Change x = x + a to x = a. Watch training get much worse or fail. That's Chapter 15's gradient highway, gone.
  3. Change n_layer to 1, then 8. Plot the final validation loss.
  4. Change temperature in generate to 0.5, then 1.5. Cooler = repetitive and safe; hotter = creative and unhinged. (Full explanation in Chapter 37.)
  5. Train on your own text — your emails, a book you like, code. It picks up style remarkably fast.
  6. Watch it overfit: set n_layer=8, n_embd=256 and train for 10,000 steps. The train loss keeps falling while val loss turns upward. That's Chapter 13's overfitting curve, live.

What you have just built

A real, working, generative language model with a Transformer architecture, trained by gradient descent on next-token prediction. The differences between this and a frontier LLM are:

Your model A frontier LLM
~0.2M parameters 8B – 2T parameters
~1 MB of text 10–20 trillion tokens of text
character-level subword tokens (Part 7)
LayerNorm, learned positions RMSNorm, RoPE, GQA, SwiGLU
5 minutes on a laptop months on thousands of GPUs
no instruction tuning SFT + RLHF (Chapter 34)

Architecturally, they are the same thing. That is the point of this chapter.

Check yourself

  1. Why is the initial loss ln(vocab_size)?
  2. What happened when you removed the mask, and why?
  3. What are the only fundamental differences between your model and GPT?

Further reading

  • Video (essential, ~2 h): Karpathy, "Let's build GPT: from scratch, in code, spelled out" — the definitive walkthrough of exactly this code.
  • Code: github.com/karpathy/nanoGPT — the cleanest reference implementation in existence (~300 lines). Read model.py line by line; you now can.
  • Video series: Karpathy, "Neural Networks: Zero to Hero" — the full 8-part course, ending in this GPT.
  • Book: Sebastian Raschka, Build a Large Language Model (From Scratch) — a whole book doing exactly this, carefully, chapter by chapter. Highly recommended as your next purchase.

End of Part 6 — Milestone check

  • I can do the five steps of attention by hand
  • I can explain queries, keys, and values with an analogy
  • I know what the causal mask does and why
  • I can explain why we need positional encoding
  • I know what the feed-forward layer is for
  • I know why residual connections matter
  • I have trained a Transformer and generated text with it

If the last box is unticked, go do it. It takes 20 minutes and it changes how the rest of this guide reads.


Generated from real-life-ai-example-v1.md · 8 Oct 2026 16:36