Build a GPT from scratch in Python
Write a tiny GPT in PyTorch, train it on a product catalog, and watch it write new descriptions. The whole program is about 70 lines.
What you'll build
A GPT is smaller than it sounds. This tutorial writes the whole thing, attention included, and trains it on 300 short product descriptions. It's a real transformer with about 38,000 parameters, where the big models have hundreds of billions. The design is the same.
What you'll learn
- 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 you need
Python and basic PyTorch, and it helps to know what attention is. In the lesson it runs in your browser, on an in-browser subset of PyTorch, and takes a while. On your own machine, install PyTorch and change the corpus address to https://www.indexaiacademy.com/mock/v1/corpus. Real PyTorch gives somewhat different numbers.
Step 1: Tokenize the text
Cut the text into tokens and give each one an ID. Here a token is a word, a number, a punctuation mark or a line break, which gives a vocabulary small enough to train quickly. Real models use BPE instead, which splits rare words into pieces. The last 10% is held back to show how the model does on text it hasn't seen.
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)}")
8,758 tokens, vocabulary of 183
Step 2: Cut it into training windows
A training example is a window of text. The target y is the same window moved along by one token, so at every position the model learns to predict what comes next. One window of 24 tokens is really 24 small lessons.
# 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
Rather write it yourself?
In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.
Step 3: Write a transformer block
This is the heart of it. Each token produces a query, a key and a value. A query compared with every key says how much attention to pay to each token. split and view divide the work among the heads, so each can look for something different. The tril mask hides the future, because a model that could see the next token would learn nothing. The result goes through a small network, with a shortcut around each stage that keeps training stable.
# 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))
Step 4: Assemble the GPT
A GPT is two embeddings, a stack of blocks and a final layer. One embedding says which token this is, and the other says where it sits, because attention alone has no sense of order. The final layer turns each position's numbers into a score for every token in the vocabulary.
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)))
Step 5: Train it
Before training, the model guesses evenly among 183 tokens, and the natural log of 183 is 5.209, so a starting loss near that says the setup is right. Then the loop is the usual one: run the model, measure the loss, backpropagate, step. Validation loss staying close to training loss means it's learning patterns, and not yet memorizing the text.
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}")
38,135 parameters step 0 training loss 5.397 validation loss 5.326 step 50 training loss 2.636 validation loss 2.646 step 100 training loss 1.379 validation loss 1.561 step 150 training loss 1.173 validation loss 1.221 step 200 training loss 0.970 validation loss 1.067 step 250 training loss 0.908 validation loss 0.989 step 300 training loss 0.861 validation loss 0.908
Step 6: Generate text
To generate, run the model, take the scores for the last position, turn them into probabilities and sample one token. Add it to the sequence and repeat. Temperature below 1 makes the likely choices more likely.
# 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())
Our Vista camp mug is made from recycled ripstop nylon. It is packable, light and hikes. Made for day hikes. Packs into its own pocket. Meet the River sleeping bag, made from a recycled polyester fleece. It is light, easy to layer and light. Made for travel. Packs into its own pocket. Meet the Nomad rain jacket
This is only 300 training steps on a tiny dataset, and it has learned the catalog's sentence patterns and which words go where. It also has no idea what's true, which is why "light and hikes" comes out. A large language model is this design with every number turned up.
Where to take it next
- Raise the width, the layers or the context, and see how the loss and the time respond.
- Swap in your own text. The tokenizer will need to handle what's in it.
- Replace the word tokenizer with BPE, which is what real models use.
Mistakes to watch for
- Thinking a model that writes fluently must be accurate.
- Assuming bigger models work differently rather than at larger scale.
- Evaluating a language model only on its training text.
- Believing the model stores sentences and retrieves them.
The complete program
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())
Write it yourself
In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.