Skip to main content

Hands-On: Build a Tiny GPT (Transformers Demystified in Code)

Introduction

You've learned every piece of the Transformer — tokens, embeddings, positional encoding, self-attention, multi-head, blocks. Now we assemble them into a working GPT in about 100 lines of PyTorch, train it, and watch it generate text. This is the moment the magic fully dissolves: you'll see there's no magic at all, just the pieces you already understand, wired together.

This is a learning toy (character-level, tiny), in the spirit of Andrej Karpathy's nanoGPT. It is the same architecture as a frontier model — just unimaginably smaller. Get this running and you can honestly say you've built a GPT from scratch.

You'll build:

  • A char-level tokenizer and data batches
  • The model: embeddings + positional + causal multi-head attention + feed-forward blocks
  • A training loop (next-character prediction)
  • A generator (the predict → sample → append loop)

Prereq: pip install torch, and any plain-text file saved as input.txt (the classic choice is "tiny Shakespeare").

The Plan (You Already Know All of This)

Before any code, notice that the whole build is just this section's lessons, in order:

StepConcept (lesson)
Char tokenizerTokenization
Token + position embeddingsEmbeddings + Positional Encoding
Causal self-attention headSelf-Attention (causal)
Several heads in parallelMulti-Head Attention
Block = attention + feed-forward + add&normThe Transformer Architecture
Stack N blocks → linear → softmaxRefinement stack → Next-Token Prediction
Train on next-char, then generateNext-Token Prediction (autoregressive)

Nothing here is new. We're just writing it down.

Step 1 — Data & a Char-Level Tokenizer

To keep it dead simple, our "tokens" are single characters (no BPE). We map each unique character to an integer and back, then make batches where the target of each position is simply the next character.

import torch
torch.manual_seed(1337)

text = open('input.txt').read()              # any plain text (e.g. tiny Shakespeare)
chars = sorted(set(text)); vocab_size = len(chars)
stoi = {c: i for i, c in enumerate(chars)}   # char -> int
itos = {i: c for c, i in stoi.items()}       # int  -> char
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 = 64     # context length (how many chars the model sees)
batch_size = 32

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

Step 2 — The Model (the whole Transformer)

Here's the heart of it. Read it against your mental model — every class is a lesson you've done. First, one self-attention head (the softmax(Q·Kᵀ/√d)·V you saw, plus the causal mask), and multi-head (run several, concatenate):

import torch.nn as nn
from torch.nn import functional as F

n_embd, n_head, n_layer = 128, 4, 4
head_size = n_embd // n_head        # the dimension is SPLIT across heads

class Head(nn.Module):              # one self-attention head
    def __init__(self):
        super().__init__()
        self.key   = nn.Linear(n_embd, head_size, bias=False)   # learned K
        self.query = nn.Linear(n_embd, head_size, bias=False)   # learned Q
        self.value = nn.Linear(n_embd, head_size, bias=False)   # learned V
        self.register_buffer('mask', torch.tril(torch.ones(block_size, block_size)))
    def forward(self, x):
        B, T, C = x.shape
        k, q, v = self.key(x), self.query(x), self.value(x)
        att = q @ k.transpose(-2, -1) * head_size**-0.5         # relevance scores
        att = att.masked_fill(self.mask[:T,:T] == 0, float('-inf'))  # CAUSAL: look back only
        att = F.softmax(att, dim=-1)                            # -> attention weights
        return att @ v                                          # weighted blend of values

class MultiHead(nn.Module):         # several heads in parallel = specialists
    def __init__(self):
        super().__init__()
        self.heads = nn.ModuleList([Head() for _ in range(n_head)])
        self.proj  = nn.Linear(n_embd, n_embd)
    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)     # concatenate heads
        return self.proj(out)                                   # project back

Now the feed-forward net, a block (attention + feed-forward, each wrapped in Add & Norm), and the full GPT — token+position embeddings, a stack of blocks, and a final linear head to next-char scores:

class FeedForward(nn.Module):       # the 'think' step (a small MLP)
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4*n_embd), nn.ReLU(), nn.Linear(4*n_embd, n_embd))
    def forward(self, x): return self.net(x)

class Block(nn.Module):             # attention -> feed-forward, with residual + layernorm
    def __init__(self):
        super().__init__()
        self.sa, self.ff = MultiHead(), FeedForward()
        self.ln1, self.ln2 = nn.LayerNorm(n_embd), nn.LayerNorm(n_embd)
    def forward(self, x):
        x = x + self.sa(self.ln1(x))    # residual + attention   (Add & Norm)
        x = x + self.ff(self.ln2(x))    # residual + feed-forward (Add & Norm)
        return x

class TinyGPT(nn.Module):
    def __init__(self):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, n_embd)   # token meaning
        self.pos_emb = nn.Embedding(block_size, n_embd)   # token position
        self.blocks  = nn.Sequential(*[Block() for _ in range(n_layer)])
        self.ln_f    = nn.LayerNorm(n_embd)
        self.head    = nn.Linear(n_embd, vocab_size)      # -> a score per char
    def forward(self, idx, targets=None):
        B, T = idx.shape
        x = self.tok_emb(idx) + self.pos_emb(torch.arange(T))   # meaning + position
        x = self.ln_f(self.blocks(x))                           # the refinement stack
        logits = self.head(x)
        loss = None
        if targets is not 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=300):
        for _ in range(max_new):
            logits, _ = self(idx[:, -block_size:])      # predict
            probs = F.softmax(logits[:, -1, :], dim=-1) # distribution over next char
            nxt = torch.multinomial(probs, 1)           # sample one
            idx = torch.cat([idx, nxt], dim=1)          # append -> repeat
        return idx

That's the entire model. Look how it lines up with the architecture diagram: tok_emb + pos_emb (meaning + position) → blocks (N× attention + feed-forward) → ln_f + head (final linear) → softmax in generate. You've seen every line conceptually — now it's code.

Step 3 — Train It (next-character prediction)

Training is the self-supervised loop from the foundations lesson: predict the next char, compare to the real one, nudge the weights, repeat. Watch the loss drop — that's the model learning.

model = TinyGPT()
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)

for step in range(5000):
    xb, yb = get_batch('train')
    _, loss = model(xb, yb)
    opt.zero_grad(); loss.backward(); opt.step()   # backprop + update
    if step % 500 == 0:
        print(f"step {step:4d} | loss {loss.item():.3f}")

# step    0 | loss 4.21   (random — ~ln(vocab_size))
# step  500 | loss 2.49
# step 5000 | loss 1.61   (it has learned the statistics of the text)

Step 4 — Generate Text

Now sample from it — the autoregressive loop you've seen since lesson 1: predict a distribution, sample a character, append, repeat.

start = torch.zeros((1, 1), dtype=torch.long)        # begin from a single token
print(decode(model.generate(start, max_new=500)[0].tolist()))
A training-progression panel showing the same tiny GPT's output at three stages as the loss drops. At step 0 (loss ~4.2) the output is random gibberish of characters. At step 500 (loss ~2.5) it produces word-shaped fragments and spacing. At step 5000 (loss ~1.6) it produces coherent Shakespeare-style lines with character names and punctuation. A small downward loss curve accompanies it, with a caption that the model learned to write purely by predicting the next character.

Out of random characters, the model learns spelling, spacing, names, and punctuation — purely from predicting the next character. No grammar rules were programmed; it absorbed them from the text. That's the whole thesis of this section, demonstrated in code you wrote.

What You Just Did

Take a second — you just implemented, from scratch:

  • Tokenization (char-level) and batching
  • Token + positional embeddings
  • Causal multi-head self-attention (softmax(Q·Kᵀ/√d)·V + a look-back mask)
  • Feed-forward networks, residual connections, and layer norm
  • A stack of Transformer blocks
  • Self-supervised training (next-token loss + backprop)
  • Autoregressive generation (predict → sample → append)

That is the entire architecture of a modern LLM. You're not looking at GPT from the outside anymore — you've built one.

So What's Different in a Real LLM?

If the architecture is the same, why is GPT-4 so much more capable than our toy? Scale and polish — not a different machine:

  • Size. Billions of parameters and dozens-to-hundreds of layers (we used 4 layers, 128 dims).
  • Data. Trillions of tokens of high-quality text (we used one small file).
  • Tokenizer. Subword BPE, not characters (far more efficient).
  • Positional encoding. RoPE instead of simple learned positions.
  • Engineering. Distributed training, careful optimization, huge compute (reproducing GPT-2 today costs only a few hundred dollars of compute; frontier models cost millions).
  • Post-training. SFT + RLHF/DPO to make it follow instructions and be helpful (the entire next sections).

The profound takeaway: the architecture you just built is the architecture. Everything else is scale, better data, and post-training — all of which we cover next.

See It: Train Your Tiny GPT

Before you run the real code, run the toy. Click train and watch the loss drop as the generated text sharpens from random noise → word-shaped → coherent Shakespeare. Then break it: shrink the model or the context length and feel why scale matters.

Interactive: Train Your Tiny GPT. The user trains a tiny character-level GPT in 500-step clicks (or jumps straight to 5000 steps) and watches the training loss fall while the text generated from a blank start sharpens through five tiers: random noise around loss 4.2, word-shaped gibberish, real but shaky words, names with grammar, and finally coherent Shakespeare-flavored lines around loss 1.5. Two break-it knobs set the achievable ceiling: model size, from small (2 layers, 32 dim) to medium to large (8 layers, 384 dim), and context length of 8, 64, or 256 tokens. A note always names the current bottleneck — keep training when the loss is still dropping, increase model size when a small model caps quality because it has less room to represent meaning, or increase context length when a short context can spell but cannot hold a long sentence together. The interactive embodies the lesson that a GPT trained only to predict the next character absorbs spelling, names, and grammar from the text with no rules programmed, and that a frontier model is this exact architecture scaled up with far more parameters, data, and training.

Nothing was programmed to know English — trained only to predict the next character, the model absorbed spelling, names, and grammar from the text. A frontier LLM is this same machine, just with far more size, data, and training — exactly what the next sections cover.

🧪 Try It Yourself

Your turn — break it to understand it. In the tiny-GPT code, try one change and predict the effect before running:

  • Shrink the context length (block size) to 8 → what can it no longer learn? (Long-range structure.)
  • Set embedding size very small → output gets more garbled. Why? (Less room to represent meaning.)
  • Train for 10× more steps → loss drops, samples sharpen.

Seeing your edits move the loss is the fastest way to make 'it's just next-token prediction' click.

Key Takeaways

  • A working GPT is ~100 lines: char tokenizer → token+position embeddings → N causal multi-head attention blocks → linear + softmax → train on next-char → generate.
  • Every line maps to a concept from this section — there is no hidden magic.
  • Trained only on next-character prediction, it learns spelling, grammar, and structure on its own.
  • A frontier LLM is the same architecture, massively scaled (more params/data/compute) plus better tokenization/position and post-training — not a different design.

You've completed the foundations of how AI works. Next, we zoom back out: Foundation Models Demystified — pretraining at internet scale, scaling laws, and the model families (GPT, Claude, Llama, …) you'll actually build with.