c
This commit is contained in:
@@ -0,0 +1,581 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "b9c2fa10",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# makemore — the MLP character model\n",
|
||||||
|
"\n",
|
||||||
|
"Predict the next letter from the **previous few letters** with a small neural net — **concept -> code -> Your turn** each step.\n",
|
||||||
|
"\n",
|
||||||
|
"This is notebook **02** of the series. Notebook `00` built backprop by hand; the bigram in `01`\n",
|
||||||
|
"only ever looked at **one** character back. Here we take a real step up: a multi-layer\n",
|
||||||
|
"perceptron (MLP) that looks at a small **window** of previous characters, following Bengio et\n",
|
||||||
|
"al. 2003 and Karpathy's *\"makemore part 2: MLP\"* lecture.\n",
|
||||||
|
"\n",
|
||||||
|
"Dataset: `names.txt` — 32,033 real first names. Goal: **make more** name-like words."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "69f5f6bc",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Prologue — in plain English\n",
|
||||||
|
"\n",
|
||||||
|
"A bigram only remembers the last letter, so it cannot tell that `bri` should probably be\n",
|
||||||
|
"followed by `a` (Brianna) rather than a random vowel. We fix that by letting the model read a\n",
|
||||||
|
"**block** of the last few letters at once.\n",
|
||||||
|
"\n",
|
||||||
|
"The trick that makes this work is the **embedding**: instead of treating each letter as an\n",
|
||||||
|
"isolated symbol, we give every letter a short list of numbers (a vector) that the network\n",
|
||||||
|
"learns. Letters that behave similarly drift close together.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: imagine each letter is a person, and the embedding is their personality\n",
|
||||||
|
"profile. The network learns these profiles so that \"similar\" letters (say, the vowels) end up\n",
|
||||||
|
"with similar profiles, and it can generalise: a context it never saw can still be handled\n",
|
||||||
|
"because it resembles ones it did see."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2d5dee6c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.1 Imports\n",
|
||||||
|
"\n",
|
||||||
|
"We import **torch first** (before matplotlib) to avoid a Windows OpenMP DLL clash, and make\n",
|
||||||
|
"plotting optional so the notebook runs even without matplotlib.\n",
|
||||||
|
"\n",
|
||||||
|
"**-> Training:** `torch` holds the weight tensors and does autograd; `F.cross_entropy` turns\n",
|
||||||
|
"logits + the true next letter into the single `loss` number we minimise."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "07c035c6",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import os\n",
|
||||||
|
"os.environ.setdefault(\"KMP_DUPLICATE_LIB_OK\", \"TRUE\") # avoid duplicate-OpenMP crash on Windows\n",
|
||||||
|
"\n",
|
||||||
|
"import torch\n",
|
||||||
|
"import torch.nn.functional as F\n",
|
||||||
|
"import random\n",
|
||||||
|
"\n",
|
||||||
|
"try:\n",
|
||||||
|
" import matplotlib.pyplot as plt\n",
|
||||||
|
" HAS_PLT = True\n",
|
||||||
|
"except Exception:\n",
|
||||||
|
" HAS_PLT = False\n",
|
||||||
|
" print(\"matplotlib not installed -> plots will be skipped (everything else still works)\")\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"PyTorch version:\", torch.__version__)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "dc58a8fc",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.2 Load the names and build the alphabet\n",
|
||||||
|
"\n",
|
||||||
|
"Each name becomes letters plus a special **`.`** marker (id 0) that means *start* and *end*.\n",
|
||||||
|
"So `emma` is really `. e m m a .` — the dots tell the model where words begin and stop."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "6b7e8198",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"words = open(\"names.txt\").read().splitlines()\n",
|
||||||
|
"print(\"number of names:\", len(words))\n",
|
||||||
|
"print(\"examples:\", words[:8])\n",
|
||||||
|
"print(\"shortest/longest:\", min(len(w) for w in words), \"/\", max(len(w) for w in words))\n",
|
||||||
|
"\n",
|
||||||
|
"chars = sorted(list(set(\"\".join(words))))\n",
|
||||||
|
"stoi = {ch: i + 1 for i, ch in enumerate(chars)}\n",
|
||||||
|
"stoi[\".\"] = 0\n",
|
||||||
|
"itos = {i: ch for ch, i in stoi.items()}\n",
|
||||||
|
"vocab_size = len(itos)\n",
|
||||||
|
"print(\"vocab_size:\", vocab_size)\n",
|
||||||
|
"print(\"stoi:\", stoi)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "d7a9d8ab",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.1 The context window (`block_size`)\n",
|
||||||
|
"\n",
|
||||||
|
"`block_size` = how many previous letters the model sees before guessing the next one. With\n",
|
||||||
|
"`block_size = 3`, predicting the letters of `emma` looks like this:\n",
|
||||||
|
"\n",
|
||||||
|
"```\n",
|
||||||
|
"... -> e (start: three dots, predict 'e')\n",
|
||||||
|
"..e -> m\n",
|
||||||
|
".em -> m\n",
|
||||||
|
"emm -> a\n",
|
||||||
|
"mma -> . (predict the end marker)\n",
|
||||||
|
"```\n",
|
||||||
|
"\n",
|
||||||
|
"Each arrow is one training example. Real-life picture: a sliding window of 3 letters moves\n",
|
||||||
|
"left to right across the word; at each stop the model must guess what comes next."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "4c0464a0",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"block_size = 3\n",
|
||||||
|
"\n",
|
||||||
|
"def build_dataset(word_list):\n",
|
||||||
|
" X, Y = [], []\n",
|
||||||
|
" for w in word_list:\n",
|
||||||
|
" context = [0] * block_size # start with '...'\n",
|
||||||
|
" for ch in w + \".\":\n",
|
||||||
|
" ix = stoi[ch]\n",
|
||||||
|
" X.append(context)\n",
|
||||||
|
" Y.append(ix)\n",
|
||||||
|
" context = context[1:] + [ix] # slide the window\n",
|
||||||
|
" return torch.tensor(X), torch.tensor(Y)\n",
|
||||||
|
"\n",
|
||||||
|
"# show the examples for one name\n",
|
||||||
|
"print(\"examples generated from 'emma':\")\n",
|
||||||
|
"Xtmp, Ytmp = build_dataset([\"emma\"])\n",
|
||||||
|
"for ctx, target in zip(Xtmp.tolist(), Ytmp.tolist()):\n",
|
||||||
|
" print(\"\".join(itos[i] for i in ctx), \"->\", itos[target])"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "5f607e40",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.2 Train / dev / test split\n",
|
||||||
|
"\n",
|
||||||
|
"We shuffle the names and cut three piles:\n",
|
||||||
|
"\n",
|
||||||
|
"- **train (80%)** — the model learns from these.\n",
|
||||||
|
"- **dev (10%)** — we tune choices (size, learning rate) against these.\n",
|
||||||
|
"- **test (10%)** — touched only at the very end, to get an honest score.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: train = homework you study, dev = practice quiz you check yourself against,\n",
|
||||||
|
"test = the final exam you only sit once."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "5c8e4f0c",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"random.seed(42)\n",
|
||||||
|
"random.shuffle(words)\n",
|
||||||
|
"n1 = int(0.8 * len(words))\n",
|
||||||
|
"n2 = int(0.9 * len(words))\n",
|
||||||
|
"\n",
|
||||||
|
"Xtr, Ytr = build_dataset(words[:n1])\n",
|
||||||
|
"Xdev, Ydev = build_dataset(words[n1:n2])\n",
|
||||||
|
"Xte, Yte = build_dataset(words[n2:])\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"train:\", tuple(Xtr.shape), \"dev:\", tuple(Xdev.shape), \"test:\", tuple(Xte.shape))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "b10d4c19",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.1 The model — embedding, hidden layer, output\n",
|
||||||
|
"\n",
|
||||||
|
"Three learnable pieces:\n",
|
||||||
|
"\n",
|
||||||
|
"1. **`C`** — the embedding table: each of the 27 symbols gets an `n_embd`-dim vector.\n",
|
||||||
|
"2. **`W1, b1`** — a hidden layer with `tanh`: it mixes the 3 letter-vectors into `n_hidden`\n",
|
||||||
|
" features. Analogy: a committee that reads the 3 personalities and forms opinions.\n",
|
||||||
|
"3. **`W2, b2`** — the output layer: turns those opinions into `vocab_size` scores (logits) for\n",
|
||||||
|
" the next letter.\n",
|
||||||
|
"\n",
|
||||||
|
"**-> Training:** `parameters` is the full list of knobs; every one gets a gradient and a nudge."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "4a0bcc2f",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"\n",
|
||||||
|
"n_embd = 10 # size of each letter's vector\n",
|
||||||
|
"n_hidden = 200 # neurons in the hidden layer\n",
|
||||||
|
"\n",
|
||||||
|
"C = torch.randn((vocab_size, n_embd), generator=g)\n",
|
||||||
|
"W1 = torch.randn((n_embd * block_size, n_hidden), generator=g) * 0.2\n",
|
||||||
|
"b1 = torch.randn(n_hidden, generator=g) * 0.01\n",
|
||||||
|
"W2 = torch.randn((n_hidden, vocab_size), generator=g) * 0.01\n",
|
||||||
|
"b2 = torch.randn(vocab_size, generator=g) * 0.0\n",
|
||||||
|
"\n",
|
||||||
|
"parameters = [C, W1, b1, W2, b2]\n",
|
||||||
|
"for p in parameters:\n",
|
||||||
|
" p.requires_grad = True\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"total parameters:\", sum(p.nelement() for p in parameters))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "77778ae3",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.2 One forward pass, step by step\n",
|
||||||
|
"\n",
|
||||||
|
"Watch the shapes flow. The key move is `emb.view(-1, n_embd*block_size)`: it **flattens** the\n",
|
||||||
|
"3 letter-vectors of each example into one long row so the hidden layer can read them together.\n",
|
||||||
|
"\n",
|
||||||
|
"**-> Training:** `F.cross_entropy` = softmax + negative-log-likelihood in one numerically-stable\n",
|
||||||
|
"call; lower means more probability on the true next letter."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "13123079",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"ix = torch.randint(0, Xtr.shape[0], (4,), generator=g) # 4 random examples\n",
|
||||||
|
"Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
"\n",
|
||||||
|
"emb = C[Xb] # (4, 3, n_embd)\n",
|
||||||
|
"flat = emb.view(-1, n_embd * block_size) # (4, 30)\n",
|
||||||
|
"h = torch.tanh(flat @ W1 + b1) # (4, n_hidden)\n",
|
||||||
|
"logits = h @ W2 + b2 # (4, vocab_size)\n",
|
||||||
|
"loss = F.cross_entropy(logits, Yb)\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"emb :\", tuple(emb.shape))\n",
|
||||||
|
"print(\"flat :\", tuple(flat.shape))\n",
|
||||||
|
"print(\"h :\", tuple(h.shape))\n",
|
||||||
|
"print(\"logits:\", tuple(logits.shape))\n",
|
||||||
|
"print(\"loss :\", loss.item())"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "b90331ba",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.1 Finding a good learning rate\n",
|
||||||
|
"\n",
|
||||||
|
"The learning rate (how big each nudge is) matters a lot: too small and training crawls, too\n",
|
||||||
|
"big and the loss bounces or explodes. A cheap way to find a good one: sweep the rate from tiny\n",
|
||||||
|
"to large over a few hundred steps and watch where the loss drops fastest.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: testing how big a step to take downhill in fog — tiny steps are safe but\n",
|
||||||
|
"slow; huge steps overshoot the valley.\n",
|
||||||
|
"\n",
|
||||||
|
"We use a **fresh** set of weights here so the search does not disturb the real model."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "acae02ad",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# fresh throwaway weights just for the search\n",
|
||||||
|
"gs = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"Cs = torch.randn((vocab_size, n_embd), generator=gs)\n",
|
||||||
|
"W1s = torch.randn((n_embd * block_size, n_hidden), generator=gs) * 0.2\n",
|
||||||
|
"b1s = torch.randn(n_hidden, generator=gs) * 0.01\n",
|
||||||
|
"W2s = torch.randn((n_hidden, vocab_size), generator=gs) * 0.01\n",
|
||||||
|
"b2s = torch.randn(vocab_size, generator=gs) * 0.0\n",
|
||||||
|
"params_s = [Cs, W1s, b1s, W2s, b2s]\n",
|
||||||
|
"for p in params_s:\n",
|
||||||
|
" p.requires_grad = True\n",
|
||||||
|
"\n",
|
||||||
|
"# candidate learning rates spaced from 10^-3 up to 10^0\n",
|
||||||
|
"lre = torch.linspace(-3, 0, 1000)\n",
|
||||||
|
"lrs = 10 ** lre\n",
|
||||||
|
"lr_used, losses = [], []\n",
|
||||||
|
"\n",
|
||||||
|
"for i in range(1000):\n",
|
||||||
|
" ix = torch.randint(0, Xtr.shape[0], (32,), generator=gs)\n",
|
||||||
|
" Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
" emb = Cs[Xb]\n",
|
||||||
|
" h = torch.tanh(emb.view(-1, n_embd * block_size) @ W1s + b1s)\n",
|
||||||
|
" loss = F.cross_entropy(h @ W2s + b2s, Yb)\n",
|
||||||
|
" for p in params_s:\n",
|
||||||
|
" p.grad = None\n",
|
||||||
|
" loss.backward()\n",
|
||||||
|
" lr = lrs[i].item()\n",
|
||||||
|
" for p in params_s:\n",
|
||||||
|
" p.data += -lr * p.grad\n",
|
||||||
|
" lr_used.append(lre[i].item())\n",
|
||||||
|
" losses.append(loss.item())\n",
|
||||||
|
"\n",
|
||||||
|
"best = lre[min(range(len(losses)), key=lambda k: losses[k])].item()\n",
|
||||||
|
"print(f\"loss dropped fastest around 10^{best:.2f} ~= {10**best:.3f}\")\n",
|
||||||
|
"print(\"good practical choice: lr = 0.1\")\n",
|
||||||
|
"\n",
|
||||||
|
"if HAS_PLT:\n",
|
||||||
|
" plt.figure(figsize=(5, 4))\n",
|
||||||
|
" plt.plot(lr_used, losses)\n",
|
||||||
|
" plt.xlabel(\"log10(learning rate)\")\n",
|
||||||
|
" plt.ylabel(\"minibatch loss\")\n",
|
||||||
|
" plt.title(\"learning-rate sweep\")\n",
|
||||||
|
" plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "13569252",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 4.1 Train the model\n",
|
||||||
|
"\n",
|
||||||
|
"Now the real run on the actual `parameters`. Each step:\n",
|
||||||
|
"\n",
|
||||||
|
"1. grab a random **minibatch** of 32 examples (fast, noisy, good enough)\n",
|
||||||
|
"2. forward -> loss\n",
|
||||||
|
"3. `loss.backward()` -> gradients\n",
|
||||||
|
"4. nudge every parameter downhill\n",
|
||||||
|
"\n",
|
||||||
|
"We start at `lr = 0.1` and **decay** to `0.01` near the end to settle into the valley."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "c5ec35d5",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"max_steps = 20000\n",
|
||||||
|
"batch_size = 32\n",
|
||||||
|
"lossi = []\n",
|
||||||
|
"\n",
|
||||||
|
"for i in range(max_steps):\n",
|
||||||
|
" ix = torch.randint(0, Xtr.shape[0], (batch_size,), generator=g)\n",
|
||||||
|
" Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
"\n",
|
||||||
|
" emb = C[Xb]\n",
|
||||||
|
" h = torch.tanh(emb.view(-1, n_embd * block_size) @ W1 + b1)\n",
|
||||||
|
" logits = h @ W2 + b2\n",
|
||||||
|
" loss = F.cross_entropy(logits, Yb)\n",
|
||||||
|
"\n",
|
||||||
|
" for p in parameters:\n",
|
||||||
|
" p.grad = None\n",
|
||||||
|
" loss.backward()\n",
|
||||||
|
"\n",
|
||||||
|
" lr = 0.1 if i < 15000 else 0.01\n",
|
||||||
|
" for p in parameters:\n",
|
||||||
|
" p.data += -lr * p.grad\n",
|
||||||
|
"\n",
|
||||||
|
" lossi.append(loss.log10().item())\n",
|
||||||
|
" if i % 4000 == 0 or i == max_steps - 1:\n",
|
||||||
|
" print(f\"step {i:5d}/{max_steps} minibatch loss {loss.item():.4f}\")\n",
|
||||||
|
"\n",
|
||||||
|
"if HAS_PLT:\n",
|
||||||
|
" plt.figure(figsize=(5, 4))\n",
|
||||||
|
" plt.plot(lossi)\n",
|
||||||
|
" plt.xlabel(\"step\")\n",
|
||||||
|
" plt.ylabel(\"log10(loss)\")\n",
|
||||||
|
" plt.title(\"training loss\")\n",
|
||||||
|
" plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "e6baab73",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 4.2 Honest scores on train and dev\n",
|
||||||
|
"\n",
|
||||||
|
"Minibatch loss is noisy. Evaluate on the **whole** train and dev sets for a stable number.\n",
|
||||||
|
"If dev loss is much worse than train, the model is memorising; if they are close, it is\n",
|
||||||
|
"generalising. (A plain bigram on names scores around 2.45 — we should beat that.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "579fe1c8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"@torch.no_grad()\n",
|
||||||
|
"def split_loss(split):\n",
|
||||||
|
" x, y = {\"train\": (Xtr, Ytr), \"dev\": (Xdev, Ydev), \"test\": (Xte, Yte)}[split]\n",
|
||||||
|
" emb = C[x]\n",
|
||||||
|
" h = torch.tanh(emb.view(-1, n_embd * block_size) @ W1 + b1)\n",
|
||||||
|
" logits = h @ W2 + b2\n",
|
||||||
|
" return F.cross_entropy(logits, y).item()\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"train loss:\", round(split_loss(\"train\"), 4))\n",
|
||||||
|
"print(\"dev loss:\", round(split_loss(\"dev\"), 4))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "054682f1",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 5.1 Generate new names\n",
|
||||||
|
"\n",
|
||||||
|
"Sampling, letter by letter: start from `...`, predict a distribution, **draw** one letter,\n",
|
||||||
|
"slide the window, repeat until the model emits the end marker `.`.\n",
|
||||||
|
"\n",
|
||||||
|
"These are brand-new names the model invented — not copied from the file."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "06bf121c",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g2 = torch.Generator().manual_seed(2147483647 + 10)\n",
|
||||||
|
"\n",
|
||||||
|
"for _ in range(20):\n",
|
||||||
|
" out = []\n",
|
||||||
|
" context = [0] * block_size\n",
|
||||||
|
" while True:\n",
|
||||||
|
" emb = C[torch.tensor([context])]\n",
|
||||||
|
" h = torch.tanh(emb.view(1, -1) @ W1 + b1)\n",
|
||||||
|
" logits = h @ W2 + b2\n",
|
||||||
|
" probs = F.softmax(logits, dim=1)\n",
|
||||||
|
" ix = torch.multinomial(probs, num_samples=1, generator=g2).item()\n",
|
||||||
|
" context = context[1:] + [ix]\n",
|
||||||
|
" if ix == 0:\n",
|
||||||
|
" break\n",
|
||||||
|
" out.append(itos[ix])\n",
|
||||||
|
" print(\"\".join(out))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "0a0de526",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"**Your turn 5** — Re-run section 5.1 a few times (different random draws) and read the\n",
|
||||||
|
"names. Then try raising `n_hidden` (e.g. 300) or `block_size` (e.g. 4 or 5) back in section 2,\n",
|
||||||
|
"retrain, and see whether dev loss improves and the names look more realistic."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "bc2003e7",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 6.1 Bonus — visualise letters in 2-D\n",
|
||||||
|
"\n",
|
||||||
|
"When the embedding is 2-D we can literally plot it. We quickly train a tiny `n_embd = 2`\n",
|
||||||
|
"version and scatter the 27 symbols. Watch the **vowels** drift into their own neighbourhood —\n",
|
||||||
|
"the network discovered that they play a similar role, without ever being told what a vowel is.\n",
|
||||||
|
"\n",
|
||||||
|
"(Optional: needs matplotlib. Smaller/shorter than the main model, so the clustering is rough.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "7786f75a",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g3 = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"C2 = torch.randn((vocab_size, 2), generator=g3)\n",
|
||||||
|
"W1b = torch.randn((2 * block_size, n_hidden), generator=g3) * 0.2\n",
|
||||||
|
"b1b = torch.randn(n_hidden, generator=g3) * 0.01\n",
|
||||||
|
"W2b = torch.randn((n_hidden, vocab_size), generator=g3) * 0.01\n",
|
||||||
|
"b2b = torch.randn(vocab_size, generator=g3) * 0.0\n",
|
||||||
|
"params2 = [C2, W1b, b1b, W2b, b2b]\n",
|
||||||
|
"for p in params2:\n",
|
||||||
|
" p.requires_grad = True\n",
|
||||||
|
"\n",
|
||||||
|
"for i in range(8000):\n",
|
||||||
|
" ix = torch.randint(0, Xtr.shape[0], (32,), generator=g3)\n",
|
||||||
|
" Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
" emb = C2[Xb]\n",
|
||||||
|
" h = torch.tanh(emb.view(-1, 2 * block_size) @ W1b + b1b)\n",
|
||||||
|
" loss = F.cross_entropy(h @ W2b + b2b, Yb)\n",
|
||||||
|
" for p in params2:\n",
|
||||||
|
" p.grad = None\n",
|
||||||
|
" loss.backward()\n",
|
||||||
|
" for p in params2:\n",
|
||||||
|
" p.data += -0.1 * p.grad\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"2-D model dev-ish minibatch loss:\", round(loss.item(), 4))\n",
|
||||||
|
"\n",
|
||||||
|
"if HAS_PLT:\n",
|
||||||
|
" pts = C2.detach()\n",
|
||||||
|
" plt.figure(figsize=(6, 6))\n",
|
||||||
|
" plt.scatter(pts[:, 0], pts[:, 1], s=300)\n",
|
||||||
|
" for i in range(vocab_size):\n",
|
||||||
|
" plt.text(pts[i, 0].item(), pts[i, 1].item(), itos[i],\n",
|
||||||
|
" ha=\"center\", va=\"center\", color=\"white\")\n",
|
||||||
|
" plt.title(\"learned 2-D letter embedding (look for the vowels clustering)\")\n",
|
||||||
|
" plt.grid(True)\n",
|
||||||
|
" plt.show()\n",
|
||||||
|
"else:\n",
|
||||||
|
" print(\"matplotlib missing - skipping the embedding scatter\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "c5e5b7a7",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## What's next\n",
|
||||||
|
"\n",
|
||||||
|
"You went from \"one letter back\" (bigram) to a real neural net that reads a **window** of\n",
|
||||||
|
"letters through a learned **embedding**, and it makes noticeably better names.\n",
|
||||||
|
"\n",
|
||||||
|
"Where this leads in the series:\n",
|
||||||
|
"\n",
|
||||||
|
"- `03_batchnorm_activations.ipynb` — why deep nets are fragile, and how BatchNorm keeps the\n",
|
||||||
|
" signal healthy so we can train bigger models.\n",
|
||||||
|
"- `04_backprop_ninja.ipynb` — compute all these gradients **by hand**, cementing notebook `00`.\n",
|
||||||
|
"- `06_build_gpt_attention.ipynb` — replace the fixed window with **attention**, so the model\n",
|
||||||
|
" can look back as far as it needs.\n",
|
||||||
|
"\n",
|
||||||
|
"**Checklist**\n",
|
||||||
|
"- [ ] Names -> context windows (`block_size`) -> (X, Y)\n",
|
||||||
|
"- [ ] train / dev / test split\n",
|
||||||
|
"- [ ] embedding `C` + hidden `tanh` + output logits\n",
|
||||||
|
"- [ ] cross-entropy loss, minibatch training\n",
|
||||||
|
"- [ ] learning-rate search\n",
|
||||||
|
"- [ ] sampled new names; visualised the 2-D embedding"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": ".venv (3.12.10)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python",
|
||||||
|
"version": "3.12.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "aedd6523",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# makemore — activations, gradients, and BatchNorm\n",
|
||||||
|
"\n",
|
||||||
|
"Make the MLP **trainable and healthy** — **concept -> code -> Your turn** each step.\n",
|
||||||
|
"\n",
|
||||||
|
"This is notebook **03**. In `02` the MLP worked, but we got a bit lucky with how the weights\n",
|
||||||
|
"started. Here we look *inside* the network at the numbers flowing through it, see two common\n",
|
||||||
|
"ways training goes wrong, and fix them — first by hand (good initialisation), then with the\n",
|
||||||
|
"robust tool everyone uses: **Batch Normalization**. Follows Karpathy's *\"makemore part 3\"*."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2980029a",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Prologue — in plain English\n",
|
||||||
|
"\n",
|
||||||
|
"A neural net is a stack of layers. At each layer numbers flow forward (activations) and\n",
|
||||||
|
"gradients flow backward. If those numbers get **too big** or **too small**, training stalls.\n",
|
||||||
|
"\n",
|
||||||
|
"Two failures we will see and fix:\n",
|
||||||
|
"\n",
|
||||||
|
"1. **Confidently wrong at the start.** If the output layer starts with large random weights,\n",
|
||||||
|
" the model makes wild over-confident guesses, so the first loss is huge and the first many\n",
|
||||||
|
" steps are wasted just calming it down.\n",
|
||||||
|
"2. **Saturated `tanh`.** If the hidden numbers are too spread out, `tanh` slams them to -1 or\n",
|
||||||
|
" +1. There the curve is flat, so its gradient is ~0 and those neurons stop learning — they\n",
|
||||||
|
" are \"dead.\"\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: think of each layer as an audio stage. Too quiet and you hear nothing; too\n",
|
||||||
|
"loud and everything **clips** into distortion. We want every stage at a healthy volume.\n",
|
||||||
|
"**BatchNorm** is the auto-leveler that keeps the volume right at every stage."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "18a296f3",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.1 Imports\n",
|
||||||
|
"\n",
|
||||||
|
"`torch` first (avoids a Windows OpenMP DLL clash), matplotlib optional for the diagnostics."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "ac69da81",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import os\n",
|
||||||
|
"os.environ.setdefault(\"KMP_DUPLICATE_LIB_OK\", \"TRUE\")\n",
|
||||||
|
"\n",
|
||||||
|
"import math\n",
|
||||||
|
"import random\n",
|
||||||
|
"import torch\n",
|
||||||
|
"import torch.nn.functional as F\n",
|
||||||
|
"\n",
|
||||||
|
"try:\n",
|
||||||
|
" import matplotlib.pyplot as plt\n",
|
||||||
|
" HAS_PLT = True\n",
|
||||||
|
"except Exception:\n",
|
||||||
|
" HAS_PLT = False\n",
|
||||||
|
" print(\"matplotlib not installed -> plots will be skipped (everything else still works)\")\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"PyTorch version:\", torch.__version__)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "92143322",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.2 Data — same names, same context windows as notebook 02"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "d99c17df",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"words = open(\"names.txt\").read().splitlines()\n",
|
||||||
|
"chars = sorted(list(set(\"\".join(words))))\n",
|
||||||
|
"stoi = {ch: i + 1 for i, ch in enumerate(chars)}\n",
|
||||||
|
"stoi[\".\"] = 0\n",
|
||||||
|
"itos = {i: ch for ch, i in stoi.items()}\n",
|
||||||
|
"vocab_size = len(itos)\n",
|
||||||
|
"block_size = 3\n",
|
||||||
|
"\n",
|
||||||
|
"def build_dataset(word_list):\n",
|
||||||
|
" X, Y = [], []\n",
|
||||||
|
" for w in word_list:\n",
|
||||||
|
" context = [0] * block_size\n",
|
||||||
|
" for ch in w + \".\":\n",
|
||||||
|
" ix = stoi[ch]\n",
|
||||||
|
" X.append(context)\n",
|
||||||
|
" Y.append(ix)\n",
|
||||||
|
" context = context[1:] + [ix]\n",
|
||||||
|
" return torch.tensor(X), torch.tensor(Y)\n",
|
||||||
|
"\n",
|
||||||
|
"random.seed(42)\n",
|
||||||
|
"random.shuffle(words)\n",
|
||||||
|
"n1, n2 = int(0.8 * len(words)), int(0.9 * len(words))\n",
|
||||||
|
"Xtr, Ytr = build_dataset(words[:n1])\n",
|
||||||
|
"Xdev, Ydev = build_dataset(words[n1:n2])\n",
|
||||||
|
"Xte, Yte = build_dataset(words[n2:])\n",
|
||||||
|
"print(\"train:\", tuple(Xtr.shape), \" vocab:\", vocab_size)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "36b836bc",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.1 Problem 1 — confidently wrong at the start\n",
|
||||||
|
"\n",
|
||||||
|
"At step 0 the model knows nothing, so the *best* it can do is spread probability evenly over\n",
|
||||||
|
"all 27 symbols. That ideal first loss is `-ln(1/27) = ln(27) ~= 3.30`.\n",
|
||||||
|
"\n",
|
||||||
|
"But with large random output weights the logits are all over the place, so the first loss is\n",
|
||||||
|
"much higher. Let's measure it."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "1219a887",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g0 = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"n_embd, n_hidden = 10, 200\n",
|
||||||
|
"\n",
|
||||||
|
"# deliberately naive (large) initialisation\n",
|
||||||
|
"Cn = torch.randn((vocab_size, n_embd), generator=g0)\n",
|
||||||
|
"W1n = torch.randn((n_embd * block_size, n_hidden), generator=g0)\n",
|
||||||
|
"b1n = torch.randn(n_hidden, generator=g0)\n",
|
||||||
|
"W2n = torch.randn((n_hidden, vocab_size), generator=g0)\n",
|
||||||
|
"b2n = torch.randn(vocab_size, generator=g0)\n",
|
||||||
|
"\n",
|
||||||
|
"emb = Cn[Xtr[:32]]\n",
|
||||||
|
"h = torch.tanh(emb.view(-1, n_embd * block_size) @ W1n + b1n)\n",
|
||||||
|
"logits = h @ W2n + b2n\n",
|
||||||
|
"print(\"naive initial loss :\", F.cross_entropy(logits, Ytr[:32]).item())\n",
|
||||||
|
"print(\"ideal (ln 27) :\", math.log(27))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "4c418501",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.2 Fix 1 — make the output layer humble at the start\n",
|
||||||
|
"\n",
|
||||||
|
"We want the starting logits near zero (no opinion yet). So scale `W2` **down** and set `b2`\n",
|
||||||
|
"to zero. Now the first loss sits right near the ideal `~3.30`, and no steps are wasted."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "e1b9bb4a",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g0 = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"Cn = torch.randn((vocab_size, n_embd), generator=g0)\n",
|
||||||
|
"W1n = torch.randn((n_embd * block_size, n_hidden), generator=g0)\n",
|
||||||
|
"b1n = torch.randn(n_hidden, generator=g0)\n",
|
||||||
|
"W2n = torch.randn((n_hidden, vocab_size), generator=g0) * 0.01 # tiny\n",
|
||||||
|
"b2n = torch.randn(vocab_size, generator=g0) * 0.0 # zero\n",
|
||||||
|
"\n",
|
||||||
|
"emb = Cn[Xtr[:32]]\n",
|
||||||
|
"h = torch.tanh(emb.view(-1, n_embd * block_size) @ W1n + b1n)\n",
|
||||||
|
"logits = h @ W2n + b2n\n",
|
||||||
|
"print(\"fixed initial loss :\", F.cross_entropy(logits, Ytr[:32]).item())\n",
|
||||||
|
"print(\"ideal (ln 27) :\", math.log(27))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2ab522f7",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.1 Problem 2 — saturated `tanh` (dead neurons)\n",
|
||||||
|
"\n",
|
||||||
|
"Now look at the hidden activations `h = tanh(...)`. With large `W1`, the inputs to `tanh` are\n",
|
||||||
|
"very spread out, so most outputs are pinned at -1 or +1. That is the flat part of `tanh`,\n",
|
||||||
|
"where the gradient is ~0 — those neurons learn nothing.\n",
|
||||||
|
"\n",
|
||||||
|
"We measure the fraction of \"saturated\" activations (|h| > 0.99) and, if matplotlib is present,\n",
|
||||||
|
"show the tell-tale picture: bright cells are saturated; a fully bright column = a dead neuron."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "8fd25db6",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"emb = Cn[Xtr[:1000]]\n",
|
||||||
|
"hpre = emb.view(-1, n_embd * block_size) @ W1n + b1n # pre-activation\n",
|
||||||
|
"h = torch.tanh(hpre)\n",
|
||||||
|
"sat = (h.abs() > 0.99).float().mean().item()\n",
|
||||||
|
"print(f\"fraction of saturated activations: {sat:.1%} (high = bad)\")\n",
|
||||||
|
"\n",
|
||||||
|
"if HAS_PLT:\n",
|
||||||
|
" plt.figure(figsize=(10, 3))\n",
|
||||||
|
" plt.imshow(h.abs() > 0.99, cmap=\"gray\", aspect=\"auto\")\n",
|
||||||
|
" plt.title(\"white = saturated (|tanh| > 0.99); a full white column = a dead neuron\")\n",
|
||||||
|
" plt.xlabel(\"neuron\")\n",
|
||||||
|
" plt.ylabel(\"example\")\n",
|
||||||
|
" plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "e11aee42",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.2 Fix 2 — scale the hidden layer (Kaiming init)\n",
|
||||||
|
"\n",
|
||||||
|
"The cure is to make `W1` smaller so the pre-activations have a sensible spread (roughly unit\n",
|
||||||
|
"standard deviation). The principled recipe is **Kaiming init**: multiply random weights by\n",
|
||||||
|
"`gain / sqrt(fan_in)`, where `fan_in` is the number of inputs and the `gain` for `tanh` is\n",
|
||||||
|
"`5/3`.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: setting the input volume so the signal fills the range without clipping."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "71f808d3",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"fan_in = n_embd * block_size\n",
|
||||||
|
"W1k = torch.randn((fan_in, n_hidden), generator=torch.Generator().manual_seed(1)) * ((5/3) / fan_in**0.5)\n",
|
||||||
|
"b1k = torch.zeros(n_hidden)\n",
|
||||||
|
"\n",
|
||||||
|
"emb = Cn[Xtr[:1000]]\n",
|
||||||
|
"h = torch.tanh(emb.view(-1, fan_in) @ W1k + b1k)\n",
|
||||||
|
"sat = (h.abs() > 0.99).float().mean().item()\n",
|
||||||
|
"print(f\"saturated now: {sat:.1%} (much lower)\")\n",
|
||||||
|
"print(f\"activation std: {h.std().item():.3f} (we want it near ~0.6-0.7 for tanh)\")\n",
|
||||||
|
"\n",
|
||||||
|
"if HAS_PLT:\n",
|
||||||
|
" plt.figure(figsize=(5, 3))\n",
|
||||||
|
" plt.hist(h.view(-1).tolist(), bins=50)\n",
|
||||||
|
" plt.title(\"healthy tanh activations (not all piled at +/-1)\")\n",
|
||||||
|
" plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "50d6f683",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.1 BatchNorm — the auto-leveler\n",
|
||||||
|
"\n",
|
||||||
|
"Hand-tuning `W1` works, but in deep nets it is fiddly. **Batch Normalization** does it\n",
|
||||||
|
"automatically: before `tanh`, it **normalises** the pre-activations so that, across the\n",
|
||||||
|
"current batch, each neuron has mean 0 and std 1. Then it lets the network rescale/shift them\n",
|
||||||
|
"with two learnable knobs, `gamma` (gain) and `beta` (bias), in case 0/1 is not ideal.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: an audio compressor that auto-levels every stage to a healthy volume, with\n",
|
||||||
|
"a manual trim (`gamma`, `beta`) if the engineer wants to nudge it.\n",
|
||||||
|
"\n",
|
||||||
|
"One catch: at test time we have no batch to average over, so during training we also keep a\n",
|
||||||
|
"**running** mean/std to use later for single predictions."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "5aeb780e",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"n_embd, n_hidden = 10, 200\n",
|
||||||
|
"fan_in = n_embd * block_size\n",
|
||||||
|
"\n",
|
||||||
|
"C = torch.randn((vocab_size, n_embd), generator=g)\n",
|
||||||
|
"W1 = torch.randn((fan_in, n_hidden), generator=g) * ((5/3) / fan_in**0.5)\n",
|
||||||
|
"# no b1: BatchNorm's beta plays that role\n",
|
||||||
|
"W2 = torch.randn((n_hidden, vocab_size), generator=g) * 0.01\n",
|
||||||
|
"b2 = torch.randn(vocab_size, generator=g) * 0.0\n",
|
||||||
|
"\n",
|
||||||
|
"bngain = torch.ones((1, n_hidden))\n",
|
||||||
|
"bnbias = torch.zeros((1, n_hidden))\n",
|
||||||
|
"bnmean_running = torch.zeros((1, n_hidden))\n",
|
||||||
|
"bnstd_running = torch.ones((1, n_hidden))\n",
|
||||||
|
"\n",
|
||||||
|
"parameters = [C, W1, W2, b2, bngain, bnbias]\n",
|
||||||
|
"for p in parameters:\n",
|
||||||
|
" p.requires_grad = True\n",
|
||||||
|
"print(\"total parameters:\", sum(p.nelement() for p in parameters))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2b07d16a",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.2 Train with BatchNorm\n",
|
||||||
|
"\n",
|
||||||
|
"Each step normalises the pre-activations using the **current batch**, updates the running\n",
|
||||||
|
"stats (for later), then continues as usual. Watch the loss fall, starting near the ideal 3.3."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "2410e7c4",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"max_steps = 20000\n",
|
||||||
|
"batch_size = 32\n",
|
||||||
|
"lossi = []\n",
|
||||||
|
"\n",
|
||||||
|
"for i in range(max_steps):\n",
|
||||||
|
" ix = torch.randint(0, Xtr.shape[0], (batch_size,), generator=g)\n",
|
||||||
|
" Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
"\n",
|
||||||
|
" emb = C[Xb]\n",
|
||||||
|
" hpreact = emb.view(-1, fan_in) @ W1\n",
|
||||||
|
"\n",
|
||||||
|
" # --- BatchNorm ---\n",
|
||||||
|
" bnmeani = hpreact.mean(0, keepdim=True)\n",
|
||||||
|
" bnstdi = hpreact.std(0, keepdim=True)\n",
|
||||||
|
" hpreact = bngain * (hpreact - bnmeani) / bnstdi + bnbias\n",
|
||||||
|
" with torch.no_grad():\n",
|
||||||
|
" bnmean_running = 0.999 * bnmean_running + 0.001 * bnmeani\n",
|
||||||
|
" bnstd_running = 0.999 * bnstd_running + 0.001 * bnstdi\n",
|
||||||
|
" # -----------------\n",
|
||||||
|
"\n",
|
||||||
|
" h = torch.tanh(hpreact)\n",
|
||||||
|
" logits = h @ W2 + b2\n",
|
||||||
|
" loss = F.cross_entropy(logits, Yb)\n",
|
||||||
|
"\n",
|
||||||
|
" for p in parameters:\n",
|
||||||
|
" p.grad = None\n",
|
||||||
|
" loss.backward()\n",
|
||||||
|
" lr = 0.1 if i < 15000 else 0.01\n",
|
||||||
|
" for p in parameters:\n",
|
||||||
|
" p.data += -lr * p.grad\n",
|
||||||
|
"\n",
|
||||||
|
" lossi.append(loss.log10().item())\n",
|
||||||
|
" if i % 4000 == 0 or i == max_steps - 1:\n",
|
||||||
|
" print(f\"step {i:5d}/{max_steps} loss {loss.item():.4f}\")\n",
|
||||||
|
"\n",
|
||||||
|
"if HAS_PLT:\n",
|
||||||
|
" plt.figure(figsize=(5, 4))\n",
|
||||||
|
" plt.plot(lossi)\n",
|
||||||
|
" plt.xlabel(\"step\"); plt.ylabel(\"log10(loss)\"); plt.title(\"training loss (with BatchNorm)\")\n",
|
||||||
|
" plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "d80dccf6",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.3 Evaluate — using the running stats\n",
|
||||||
|
"\n",
|
||||||
|
"At evaluation we have no batch, so we normalise with the **running** mean/std collected during\n",
|
||||||
|
"training. Train and dev loss should be close (good generalisation) and beat the bigram (~2.45)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "30ae8013",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"@torch.no_grad()\n",
|
||||||
|
"def split_loss(split):\n",
|
||||||
|
" x, y = {\"train\": (Xtr, Ytr), \"dev\": (Xdev, Ydev), \"test\": (Xte, Yte)}[split]\n",
|
||||||
|
" emb = C[x]\n",
|
||||||
|
" hpreact = emb.view(-1, fan_in) @ W1\n",
|
||||||
|
" hpreact = bngain * (hpreact - bnmean_running) / bnstd_running + bnbias\n",
|
||||||
|
" h = torch.tanh(hpreact)\n",
|
||||||
|
" logits = h @ W2 + b2\n",
|
||||||
|
" return F.cross_entropy(logits, y).item()\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"train loss:\", round(split_loss(\"train\"), 4))\n",
|
||||||
|
"print(\"dev loss:\", round(split_loss(\"dev\"), 4))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "269c8943",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 4.1 Generate names with the BatchNorm model\n",
|
||||||
|
"\n",
|
||||||
|
"Same letter-by-letter sampling as before, but normalising with the running stats."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "63711cd1",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g2 = torch.Generator().manual_seed(2147483647 + 10)\n",
|
||||||
|
"\n",
|
||||||
|
"for _ in range(20):\n",
|
||||||
|
" out = []\n",
|
||||||
|
" context = [0] * block_size\n",
|
||||||
|
" while True:\n",
|
||||||
|
" emb = C[torch.tensor([context])]\n",
|
||||||
|
" hpreact = emb.view(1, -1) @ W1\n",
|
||||||
|
" hpreact = bngain * (hpreact - bnmean_running) / bnstd_running + bnbias\n",
|
||||||
|
" h = torch.tanh(hpreact)\n",
|
||||||
|
" logits = h @ W2 + b2\n",
|
||||||
|
" probs = F.softmax(logits, dim=1)\n",
|
||||||
|
" ix = torch.multinomial(probs, num_samples=1, generator=g2).item()\n",
|
||||||
|
" context = context[1:] + [ix]\n",
|
||||||
|
" if ix == 0:\n",
|
||||||
|
" break\n",
|
||||||
|
" out.append(itos[ix])\n",
|
||||||
|
" print(\"\".join(out))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "365be7ef",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"**Your turn 4** — In section 2.1, change `W1n`'s scale (e.g. `* 0.1` vs `* 3.0`) and re-run\n",
|
||||||
|
"the saturation check. Bigger scale -> more dead neurons. Then confirm BatchNorm (section 3.2)\n",
|
||||||
|
"trains fine even from a less careful init, because it re-levels the signal every step."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "65b60eec",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## What's next\n",
|
||||||
|
"\n",
|
||||||
|
"You learned to look **inside** the network and keep it healthy:\n",
|
||||||
|
"\n",
|
||||||
|
"- start the output layer humble (good first loss)\n",
|
||||||
|
"- keep `tanh` out of saturation (Kaiming init)\n",
|
||||||
|
"- or just use **BatchNorm** to auto-level every layer\n",
|
||||||
|
"\n",
|
||||||
|
"Where this leads in the series:\n",
|
||||||
|
"\n",
|
||||||
|
"- `04_backprop_ninja.ipynb` — compute every gradient here **by hand**, including through\n",
|
||||||
|
" BatchNorm, to truly own backprop.\n",
|
||||||
|
"- `05_wavenet.ipynb` — stack these healthy layers into a deeper, hierarchical model.\n",
|
||||||
|
"- `06_build_gpt_attention.ipynb` — the transformer, which uses LayerNorm (a cousin of\n",
|
||||||
|
" BatchNorm) for the same \"keep the signal healthy\" reason.\n",
|
||||||
|
"\n",
|
||||||
|
"**Checklist**\n",
|
||||||
|
"- [ ] Ideal initial loss = ln(vocab_size)\n",
|
||||||
|
"- [ ] Humble output init (small W2, zero b2)\n",
|
||||||
|
"- [ ] Saturation / dead neurons diagnosed\n",
|
||||||
|
"- [ ] Kaiming init (gain / sqrt(fan_in))\n",
|
||||||
|
"- [ ] BatchNorm: per-batch normalise + gamma/beta + running stats\n",
|
||||||
|
"- [ ] Trained, evaluated, sampled names"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": ".venv (3.12.10)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python",
|
||||||
|
"version": "3.12.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "6caa1908",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# Becoming a backprop ninja\n",
|
||||||
|
"\n",
|
||||||
|
"Compute every gradient **by hand** — **concept -> code -> Your turn** each step.\n",
|
||||||
|
"\n",
|
||||||
|
"This is notebook **04**. So far we have leaned on `loss.backward()` to do the calculus for us.\n",
|
||||||
|
"Here we turn it off and backpropagate **manually** through the whole MLP + BatchNorm +\n",
|
||||||
|
"cross-entropy, checking each result against PyTorch. After this, autograd is never a mystery.\n",
|
||||||
|
"Follows Karpathy's *\"makemore part 4: becoming a backprop ninja\"*."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "9ed68a60",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Prologue — in plain English\n",
|
||||||
|
"\n",
|
||||||
|
"`loss.backward()` answers one question for every weight: *if I nudge you a little, how does\n",
|
||||||
|
"the loss change?* That answer is the gradient. We have trusted PyTorch to compute it.\n",
|
||||||
|
"\n",
|
||||||
|
"Now we earn it. We will:\n",
|
||||||
|
"\n",
|
||||||
|
"1. Run the forward pass but **save every intermediate value**.\n",
|
||||||
|
"2. Walk backward, one operation at a time, applying the chain rule by hand (exactly the\n",
|
||||||
|
" `00_micrograd.ipynb` idea, now on whole tensors).\n",
|
||||||
|
"3. Compare each hand-derived gradient to PyTorch's — they should match.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: notebook `00` showed one gear; here we backprop through the whole gearbox,\n",
|
||||||
|
"and check our work against the manufacturer's spec (PyTorch)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "95e96905",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.1 Imports and data"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "3b29d3f0",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import os\n",
|
||||||
|
"os.environ.setdefault(\"KMP_DUPLICATE_LIB_OK\", \"TRUE\")\n",
|
||||||
|
"\n",
|
||||||
|
"import random\n",
|
||||||
|
"import torch\n",
|
||||||
|
"import torch.nn.functional as F\n",
|
||||||
|
"\n",
|
||||||
|
"words = open(\"names.txt\").read().splitlines()\n",
|
||||||
|
"chars = sorted(list(set(\"\".join(words))))\n",
|
||||||
|
"stoi = {ch: i + 1 for i, ch in enumerate(chars)}\n",
|
||||||
|
"stoi[\".\"] = 0\n",
|
||||||
|
"itos = {i: ch for ch, i in stoi.items()}\n",
|
||||||
|
"vocab_size = len(itos)\n",
|
||||||
|
"block_size = 3\n",
|
||||||
|
"\n",
|
||||||
|
"def build_dataset(word_list):\n",
|
||||||
|
" X, Y = [], []\n",
|
||||||
|
" for w in word_list:\n",
|
||||||
|
" context = [0] * block_size\n",
|
||||||
|
" for ch in w + \".\":\n",
|
||||||
|
" ix = stoi[ch]\n",
|
||||||
|
" X.append(context); Y.append(ix)\n",
|
||||||
|
" context = context[1:] + [ix]\n",
|
||||||
|
" return torch.tensor(X), torch.tensor(Y)\n",
|
||||||
|
"\n",
|
||||||
|
"random.seed(42)\n",
|
||||||
|
"random.shuffle(words)\n",
|
||||||
|
"n1, n2 = int(0.8 * len(words)), int(0.9 * len(words))\n",
|
||||||
|
"Xtr, Ytr = build_dataset(words[:n1])\n",
|
||||||
|
"Xdev, Ydev = build_dataset(words[n1:n2])\n",
|
||||||
|
"print(\"train:\", tuple(Xtr.shape), \" vocab:\", vocab_size)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "19612a96",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.2 The gradient-check helper\n",
|
||||||
|
"\n",
|
||||||
|
"`cmp` compares our hand-computed gradient `dt` to PyTorch's `t.grad`:\n",
|
||||||
|
"\n",
|
||||||
|
"- **exact** = bit-for-bit identical\n",
|
||||||
|
"- **approx** = equal within tiny floating-point tolerance (this is the one that matters)\n",
|
||||||
|
"- **maxdiff** = largest single difference"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "2c6c9ccf",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"def cmp(name, dt, t):\n",
|
||||||
|
" ex = torch.all(dt == t.grad).item()\n",
|
||||||
|
" app = torch.allclose(dt, t.grad, rtol=1e-4, atol=1e-7)\n",
|
||||||
|
" maxdiff = (dt - t.grad).abs().max().item()\n",
|
||||||
|
" print(f\"{name:12s} | exact: {str(ex):5s} | approx: {str(app):5s} | maxdiff: {maxdiff:.2e}\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "ed7b6dec",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.1 Parameters\n",
|
||||||
|
"\n",
|
||||||
|
"We initialise the same MLP + BatchNorm as notebook `03`. Biases are made small but non-zero\n",
|
||||||
|
"so every gradient path is actually exercised (a zero would hide bugs)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "70e88d72",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"n_embd, n_hidden = 10, 64\n",
|
||||||
|
"g = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"\n",
|
||||||
|
"C = torch.randn((vocab_size, n_embd), generator=g)\n",
|
||||||
|
"W1 = torch.randn((n_embd * block_size, n_hidden), generator=g) * (5/3) / ((n_embd * block_size) ** 0.5)\n",
|
||||||
|
"b1 = torch.randn(n_hidden, generator=g) * 0.1\n",
|
||||||
|
"W2 = torch.randn((n_hidden, vocab_size), generator=g) * 0.1\n",
|
||||||
|
"b2 = torch.randn(vocab_size, generator=g) * 0.1\n",
|
||||||
|
"bngain = torch.randn((1, n_hidden), generator=g) * 0.1 + 1.0\n",
|
||||||
|
"bnbias = torch.randn((1, n_hidden), generator=g) * 0.1\n",
|
||||||
|
"\n",
|
||||||
|
"parameters = [C, W1, b1, W2, b2, bngain, bnbias]\n",
|
||||||
|
"for p in parameters:\n",
|
||||||
|
" p.requires_grad = True\n",
|
||||||
|
"print(\"total parameters:\", sum(p.nelement() for p in parameters))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "04fa6a1e",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.2 Forward pass, broken into tiny steps\n",
|
||||||
|
"\n",
|
||||||
|
"Cross-entropy is written out by hand (max-subtract, exp, sum, log) so we can backprop through\n",
|
||||||
|
"each piece. Every intermediate calls `retain_grad()` so PyTorch will store its gradient for us\n",
|
||||||
|
"to compare against."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "6ad34b3b",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"n = 32 # fixed batch size for the manual math\n",
|
||||||
|
"ix = torch.randint(0, Xtr.shape[0], (n,), generator=g)\n",
|
||||||
|
"Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
"\n",
|
||||||
|
"emb = C[Xb] # embed the characters\n",
|
||||||
|
"embcat = emb.view(emb.shape[0], -1) # flatten\n",
|
||||||
|
"# linear 1\n",
|
||||||
|
"hprebn = embcat @ W1 + b1\n",
|
||||||
|
"# batchnorm\n",
|
||||||
|
"bnmeani = 1 / n * hprebn.sum(0, keepdim=True)\n",
|
||||||
|
"bndiff = hprebn - bnmeani\n",
|
||||||
|
"bndiff2 = bndiff ** 2\n",
|
||||||
|
"bnvar = 1 / (n - 1) * bndiff2.sum(0, keepdim=True) # Bessel's correction (n-1)\n",
|
||||||
|
"bnvar_inv = (bnvar + 1e-5) ** -0.5\n",
|
||||||
|
"bnraw = bndiff * bnvar_inv\n",
|
||||||
|
"hpreact = bngain * bnraw + bnbias\n",
|
||||||
|
"# nonlinearity\n",
|
||||||
|
"h = torch.tanh(hpreact)\n",
|
||||||
|
"# linear 2\n",
|
||||||
|
"logits = h @ W2 + b2\n",
|
||||||
|
"# cross-entropy (manual)\n",
|
||||||
|
"logit_maxes = logits.max(1, keepdim=True).values\n",
|
||||||
|
"norm_logits = logits - logit_maxes\n",
|
||||||
|
"counts = norm_logits.exp()\n",
|
||||||
|
"counts_sum = counts.sum(1, keepdim=True)\n",
|
||||||
|
"counts_sum_inv = counts_sum ** -1\n",
|
||||||
|
"probs = counts * counts_sum_inv\n",
|
||||||
|
"logprobs = probs.log()\n",
|
||||||
|
"loss = -logprobs[range(n), Yb].mean()\n",
|
||||||
|
"\n",
|
||||||
|
"for t in [logprobs, probs, counts, counts_sum, counts_sum_inv, norm_logits, logit_maxes,\n",
|
||||||
|
" logits, h, hpreact, bnraw, bnvar_inv, bnvar, bndiff2, bndiff, bnmeani, hprebn,\n",
|
||||||
|
" embcat, emb]:\n",
|
||||||
|
" t.retain_grad()\n",
|
||||||
|
"for p in parameters:\n",
|
||||||
|
" p.retain_grad()\n",
|
||||||
|
"loss.backward()\n",
|
||||||
|
"print(\"loss:\", loss.item())"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "6fcbe6af",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.1 Backprop through cross-entropy\n",
|
||||||
|
"\n",
|
||||||
|
"Walk backward from `loss`. For each line of the forward pass we write the local derivative and\n",
|
||||||
|
"multiply by the gradient coming from above (the chain rule). Read each line as: *how does this\n",
|
||||||
|
"intermediate affect the loss?*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "c7bc68e2",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"dlogprobs = torch.zeros_like(logprobs)\n",
|
||||||
|
"dlogprobs[range(n), Yb] = -1.0 / n # loss = -mean of the chosen logprobs\n",
|
||||||
|
"dprobs = (1.0 / probs) * dlogprobs # logprobs = log(probs)\n",
|
||||||
|
"dcounts_sum_inv = (counts * dprobs).sum(1, keepdim=True)\n",
|
||||||
|
"dcounts = counts_sum_inv * dprobs # probs = counts * counts_sum_inv\n",
|
||||||
|
"dcounts_sum = (-counts_sum ** -2) * dcounts_sum_inv\n",
|
||||||
|
"dcounts += torch.ones_like(counts) * dcounts_sum\n",
|
||||||
|
"dnorm_logits = counts * dcounts # counts = exp(norm_logits)\n",
|
||||||
|
"dlogits = dnorm_logits.clone()\n",
|
||||||
|
"dlogit_maxes = (-dnorm_logits).sum(1, keepdim=True)\n",
|
||||||
|
"dlogits += F.one_hot(logits.max(1).indices, num_classes=logits.shape[1]) * dlogit_maxes\n",
|
||||||
|
"\n",
|
||||||
|
"cmp(\"logprobs\", dlogprobs, logprobs)\n",
|
||||||
|
"cmp(\"probs\", dprobs, probs)\n",
|
||||||
|
"cmp(\"counts_sum_inv\", dcounts_sum_inv, counts_sum_inv)\n",
|
||||||
|
"cmp(\"counts_sum\", dcounts_sum, counts_sum)\n",
|
||||||
|
"cmp(\"counts\", dcounts, counts)\n",
|
||||||
|
"cmp(\"norm_logits\", dnorm_logits, norm_logits)\n",
|
||||||
|
"cmp(\"logit_maxes\", dlogit_maxes, logit_maxes)\n",
|
||||||
|
"cmp(\"logits\", dlogits, logits)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "51a6e8d7",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.2 Backprop through the second linear layer and tanh\n",
|
||||||
|
"\n",
|
||||||
|
"`logits = h @ W2 + b2`. Matrix-multiply gradients follow the standard transpose rules; `tanh`\n",
|
||||||
|
"contributes its local derivative `1 - tanh(x)^2`."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "39f0e645",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"dh = dlogits @ W2.T\n",
|
||||||
|
"dW2 = h.T @ dlogits\n",
|
||||||
|
"db2 = dlogits.sum(0)\n",
|
||||||
|
"dhpreact = (1.0 - h ** 2) * dh # tanh local derivative\n",
|
||||||
|
"\n",
|
||||||
|
"cmp(\"h\", dh, h)\n",
|
||||||
|
"cmp(\"W2\", dW2, W2)\n",
|
||||||
|
"cmp(\"b2\", db2, b2)\n",
|
||||||
|
"cmp(\"hpreact\", dhpreact, hpreact)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "a7ecc492",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.3 Backprop through BatchNorm\n",
|
||||||
|
"\n",
|
||||||
|
"This is the famously fiddly part. We go through each step: the scale/shift, the normalisation,\n",
|
||||||
|
"the variance, and the mean. Note how the **mean and variance both depend on every example**, so\n",
|
||||||
|
"gradients fan out across the batch."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "6f4f5d49",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"dbngain = (bnraw * dhpreact).sum(0, keepdim=True)\n",
|
||||||
|
"dbnbias = dhpreact.sum(0, keepdim=True)\n",
|
||||||
|
"dbnraw = bngain * dhpreact\n",
|
||||||
|
"dbndiff = bnvar_inv * dbnraw\n",
|
||||||
|
"dbnvar_inv = (bndiff * dbnraw).sum(0, keepdim=True)\n",
|
||||||
|
"dbnvar = (-0.5 * (bnvar + 1e-5) ** -1.5) * dbnvar_inv\n",
|
||||||
|
"dbndiff2 = (1.0 / (n - 1)) * torch.ones_like(bndiff2) * dbnvar\n",
|
||||||
|
"dbndiff += (2 * bndiff) * dbndiff2\n",
|
||||||
|
"dhprebn = dbndiff.clone()\n",
|
||||||
|
"dbnmeani = (-dbndiff).sum(0)\n",
|
||||||
|
"dhprebn += 1.0 / n * (torch.ones_like(hprebn) * dbnmeani)\n",
|
||||||
|
"\n",
|
||||||
|
"cmp(\"bngain\", dbngain, bngain)\n",
|
||||||
|
"cmp(\"bnbias\", dbnbias, bnbias)\n",
|
||||||
|
"cmp(\"bnraw\", dbnraw, bnraw)\n",
|
||||||
|
"cmp(\"bnvar_inv\", dbnvar_inv, bnvar_inv)\n",
|
||||||
|
"cmp(\"bnvar\", dbnvar, bnvar)\n",
|
||||||
|
"cmp(\"bndiff2\", dbndiff2, bndiff2)\n",
|
||||||
|
"cmp(\"bndiff\", dbndiff, bndiff)\n",
|
||||||
|
"cmp(\"bnmeani\", dbnmeani, bnmeani)\n",
|
||||||
|
"cmp(\"hprebn\", dhprebn, hprebn)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "3be0bf67",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.4 Backprop through the first linear layer and the embedding\n",
|
||||||
|
"\n",
|
||||||
|
"`hprebn = embcat @ W1 + b1`, then un-flatten back to `emb`, then scatter each row's gradient to\n",
|
||||||
|
"the right row of the embedding table `C` (an embedding lookup is just \"pick rows\", so its\n",
|
||||||
|
"backward is \"add the gradient back into those rows\")."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "b1317637",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"dembcat = dhprebn @ W1.T\n",
|
||||||
|
"dW1 = embcat.T @ dhprebn\n",
|
||||||
|
"db1 = dhprebn.sum(0)\n",
|
||||||
|
"demb = dembcat.view(emb.shape)\n",
|
||||||
|
"\n",
|
||||||
|
"dC = torch.zeros_like(C)\n",
|
||||||
|
"for k in range(Xb.shape[0]):\n",
|
||||||
|
" for j in range(Xb.shape[1]):\n",
|
||||||
|
" dC[Xb[k, j]] += demb[k, j]\n",
|
||||||
|
"\n",
|
||||||
|
"cmp(\"embcat\", dembcat, embcat)\n",
|
||||||
|
"cmp(\"W1\", dW1, W1)\n",
|
||||||
|
"cmp(\"b1\", db1, b1)\n",
|
||||||
|
"cmp(\"emb\", demb, emb)\n",
|
||||||
|
"cmp(\"C\", dC, C)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "3f6d3bff",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.1 The elegant shortcuts\n",
|
||||||
|
"\n",
|
||||||
|
"Doing it step-by-step proves we understand it. But two of those backward passes collapse into\n",
|
||||||
|
"beautiful one-liners once you do the algebra:\n",
|
||||||
|
"\n",
|
||||||
|
"- **Cross-entropy:** `dlogits = softmax(logits)`, then subtract 1 from the true class, divide by n.\n",
|
||||||
|
" Intuition: push down whatever probability the model gave, pull up the correct class.\n",
|
||||||
|
"- **BatchNorm:** one expression for `dhprebn` (no need for all the intermediate d's).\n",
|
||||||
|
"\n",
|
||||||
|
"We verify both against PyTorch."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "91248dc5",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# elegant cross-entropy backward\n",
|
||||||
|
"dlogits_fast = F.softmax(logits, 1)\n",
|
||||||
|
"dlogits_fast[range(n), Yb] -= 1\n",
|
||||||
|
"dlogits_fast /= n\n",
|
||||||
|
"print(\"dlogits | approx:\", torch.allclose(dlogits_fast, logits.grad, rtol=1e-4, atol=1e-7),\n",
|
||||||
|
" \" | maxdiff:\", (dlogits_fast - logits.grad).abs().max().item())\n",
|
||||||
|
"\n",
|
||||||
|
"# elegant batchnorm backward\n",
|
||||||
|
"dhprebn_fast = bngain * bnvar_inv / n * (\n",
|
||||||
|
" n * dhpreact - dhpreact.sum(0) - n / (n - 1) * bnraw * (dhpreact * bnraw).sum(0)\n",
|
||||||
|
")\n",
|
||||||
|
"print(\"dhprebn | approx:\", torch.allclose(dhprebn_fast, hprebn.grad, rtol=1e-4, atol=1e-7),\n",
|
||||||
|
" \" | maxdiff:\", (dhprebn_fast - hprebn.grad).abs().max().item())"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "09834b6c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.2 Train with 100% hand-written gradients\n",
|
||||||
|
"\n",
|
||||||
|
"The real proof: turn off autograd and train using only our manual gradients (the elegant\n",
|
||||||
|
"versions, plus the manual `W2/b2/W1/b1/C/bngain/bnbias`). If the loss falls, our calculus is\n",
|
||||||
|
"correct."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "9bfb8004",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# fresh params\n",
|
||||||
|
"g = torch.Generator().manual_seed(2147483647)\n",
|
||||||
|
"C = torch.randn((vocab_size, n_embd), generator=g)\n",
|
||||||
|
"W1 = torch.randn((n_embd * block_size, n_hidden), generator=g) * (5/3) / ((n_embd * block_size) ** 0.5)\n",
|
||||||
|
"b1 = torch.randn(n_hidden, generator=g) * 0.1\n",
|
||||||
|
"W2 = torch.randn((n_hidden, vocab_size), generator=g) * 0.1\n",
|
||||||
|
"b2 = torch.randn(vocab_size, generator=g) * 0.1\n",
|
||||||
|
"bngain = torch.randn((1, n_hidden), generator=g) * 0.1 + 1.0\n",
|
||||||
|
"bnbias = torch.randn((1, n_hidden), generator=g) * 0.1\n",
|
||||||
|
"parameters = [C, W1, b1, W2, b2, bngain, bnbias]\n",
|
||||||
|
"for p in parameters:\n",
|
||||||
|
" p.requires_grad = True\n",
|
||||||
|
"\n",
|
||||||
|
"n = 32\n",
|
||||||
|
"for i in range(8000):\n",
|
||||||
|
" ix = torch.randint(0, Xtr.shape[0], (n,), generator=g)\n",
|
||||||
|
" Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
"\n",
|
||||||
|
" # forward (keep what the manual backward needs)\n",
|
||||||
|
" emb = C[Xb]\n",
|
||||||
|
" embcat = emb.view(emb.shape[0], -1)\n",
|
||||||
|
" hprebn = embcat @ W1 + b1\n",
|
||||||
|
" bnmeani = hprebn.mean(0, keepdim=True)\n",
|
||||||
|
" bnvar = hprebn.var(0, keepdim=True, unbiased=True)\n",
|
||||||
|
" bnvar_inv = (bnvar + 1e-5) ** -0.5\n",
|
||||||
|
" bnraw = (hprebn - bnmeani) * bnvar_inv\n",
|
||||||
|
" hpreact = bngain * bnraw + bnbias\n",
|
||||||
|
" h = torch.tanh(hpreact)\n",
|
||||||
|
" logits = h @ W2 + b2\n",
|
||||||
|
" loss = F.cross_entropy(logits, Yb)\n",
|
||||||
|
"\n",
|
||||||
|
" # manual backward (no loss.backward())\n",
|
||||||
|
" dlogits = F.softmax(logits, 1)\n",
|
||||||
|
" dlogits[range(n), Yb] -= 1\n",
|
||||||
|
" dlogits /= n\n",
|
||||||
|
" dh = dlogits @ W2.T\n",
|
||||||
|
" dW2 = h.T @ dlogits\n",
|
||||||
|
" db2 = dlogits.sum(0)\n",
|
||||||
|
" dhpreact = (1.0 - h ** 2) * dh\n",
|
||||||
|
" dbngain = (bnraw * dhpreact).sum(0, keepdim=True)\n",
|
||||||
|
" dbnbias = dhpreact.sum(0, keepdim=True)\n",
|
||||||
|
" dhprebn = bngain * bnvar_inv / n * (\n",
|
||||||
|
" n * dhpreact - dhpreact.sum(0) - n / (n - 1) * bnraw * (dhpreact * bnraw).sum(0)\n",
|
||||||
|
" )\n",
|
||||||
|
" dembcat = dhprebn @ W1.T\n",
|
||||||
|
" dW1 = embcat.T @ dhprebn\n",
|
||||||
|
" db1 = dhprebn.sum(0)\n",
|
||||||
|
" demb = dembcat.view(emb.shape)\n",
|
||||||
|
" dC = torch.zeros_like(C)\n",
|
||||||
|
" dC.index_add_(0, Xb.view(-1), demb.view(-1, n_embd))\n",
|
||||||
|
"\n",
|
||||||
|
" grads = [dC, dW1, db1, dW2, db2, dbngain, dbnbias]\n",
|
||||||
|
" lr = 0.1 if i < 6000 else 0.01\n",
|
||||||
|
" with torch.no_grad():\n",
|
||||||
|
" for p, gr in zip(parameters, grads):\n",
|
||||||
|
" p += -lr * gr\n",
|
||||||
|
"\n",
|
||||||
|
" if i % 2000 == 0 or i == 7999:\n",
|
||||||
|
" print(f\"step {i:5d} loss {loss.item():.4f}\")\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"trained using only hand-written gradients.\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "422e7804",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"**Your turn 4** — In section 2.3, deliberately introduce a bug (e.g. drop the\n",
|
||||||
|
"`n/(n-1)` Bessel factor in `dbnvar`) and re-run. Watch `cmp` flip to `approx: False` and the\n",
|
||||||
|
"`maxdiff` jump. Then fix it. This is exactly how you debug a real backward pass."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "36c4bc1d",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## What's next\n",
|
||||||
|
"\n",
|
||||||
|
"You backpropagated by hand through cross-entropy, a linear layer, `tanh`, BatchNorm, and an\n",
|
||||||
|
"embedding, matched PyTorch exactly, and trained a net with zero autograd. Backprop is now\n",
|
||||||
|
"yours.\n",
|
||||||
|
"\n",
|
||||||
|
"Where this leads in the series:\n",
|
||||||
|
"\n",
|
||||||
|
"- `05_wavenet.ipynb` — stack layers into a deeper, hierarchical model using `torch.nn`-style\n",
|
||||||
|
" building blocks.\n",
|
||||||
|
"- `06_build_gpt_attention.ipynb` — the transformer; `loss.backward()` there is doing exactly\n",
|
||||||
|
" what you just did, across far more nodes.\n",
|
||||||
|
"\n",
|
||||||
|
"**Checklist**\n",
|
||||||
|
"- [ ] Forward pass saved every intermediate\n",
|
||||||
|
"- [ ] Manual backward matched PyTorch (cross-entropy, linear, tanh)\n",
|
||||||
|
"- [ ] Manual backward through BatchNorm matched\n",
|
||||||
|
"- [ ] Embedding backward = scatter-add into rows\n",
|
||||||
|
"- [ ] Elegant one-line cross-entropy and BatchNorm gradients\n",
|
||||||
|
"- [ ] Trained a net with only hand-written gradients"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": ".venv (3.12.10)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python",
|
||||||
|
"version": "3.12.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "d24a5a8b",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# makemore — a WaveNet\n",
|
||||||
|
"\n",
|
||||||
|
"Go **deeper and hierarchical** — **concept -> code -> Your turn** each step.\n",
|
||||||
|
"\n",
|
||||||
|
"This is notebook **05**. The MLP in `02`/`03` squashed the whole context into **one** layer.\n",
|
||||||
|
"Here we widen the context to 8 letters and fuse them **gradually**, two at a time, in a\n",
|
||||||
|
"tree — the structure behind DeepMind's WaveNet (2016). We also wrap everything in tidy\n",
|
||||||
|
"`torch.nn`-style building blocks. Follows Karpathy's *\"makemore part 5: WaveNet\"*."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "187db6ed",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Prologue — in plain English\n",
|
||||||
|
"\n",
|
||||||
|
"Reading 8 letters by mashing them into one layer throws away structure. WaveNet's idea: fuse\n",
|
||||||
|
"information **slowly**.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: a knockout tournament. 8 players (letters) play in pairs -> 4 winners ->\n",
|
||||||
|
"play in pairs -> 2 -> 1 champion. Each round combines neighbours into a richer summary. Our\n",
|
||||||
|
"network does the same: combine letters in pairs, then pairs-of-pairs, then again, until a\n",
|
||||||
|
"single vector represents the whole context.\n",
|
||||||
|
"\n",
|
||||||
|
"We also build small reusable layers (`Linear`, `BatchNorm1d`, `Tanh`, `Embedding`,\n",
|
||||||
|
"`FlattenConsecutive`, `Sequential`) that behave like PyTorch's own — so the model reads like a\n",
|
||||||
|
"clean list of steps."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "6bf209d0",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.1 Imports and data (context grows to 8)\n",
|
||||||
|
"\n",
|
||||||
|
"The only data change from earlier notebooks: `block_size = 8`, so the model sees up to 8\n",
|
||||||
|
"previous letters."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "88825091",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import os\n",
|
||||||
|
"os.environ.setdefault(\"KMP_DUPLICATE_LIB_OK\", \"TRUE\")\n",
|
||||||
|
"\n",
|
||||||
|
"import random\n",
|
||||||
|
"import torch\n",
|
||||||
|
"import torch.nn.functional as F\n",
|
||||||
|
"\n",
|
||||||
|
"try:\n",
|
||||||
|
" import matplotlib.pyplot as plt\n",
|
||||||
|
" HAS_PLT = True\n",
|
||||||
|
"except Exception:\n",
|
||||||
|
" HAS_PLT = False\n",
|
||||||
|
"\n",
|
||||||
|
"words = open(\"names.txt\").read().splitlines()\n",
|
||||||
|
"chars = sorted(list(set(\"\".join(words))))\n",
|
||||||
|
"stoi = {ch: i + 1 for i, ch in enumerate(chars)}\n",
|
||||||
|
"stoi[\".\"] = 0\n",
|
||||||
|
"itos = {i: ch for ch, i in stoi.items()}\n",
|
||||||
|
"vocab_size = len(itos)\n",
|
||||||
|
"block_size = 8\n",
|
||||||
|
"\n",
|
||||||
|
"def build_dataset(word_list):\n",
|
||||||
|
" X, Y = [], []\n",
|
||||||
|
" for w in word_list:\n",
|
||||||
|
" context = [0] * block_size\n",
|
||||||
|
" for ch in w + \".\":\n",
|
||||||
|
" ix = stoi[ch]\n",
|
||||||
|
" X.append(context); Y.append(ix)\n",
|
||||||
|
" context = context[1:] + [ix]\n",
|
||||||
|
" return torch.tensor(X), torch.tensor(Y)\n",
|
||||||
|
"\n",
|
||||||
|
"random.seed(42)\n",
|
||||||
|
"random.shuffle(words)\n",
|
||||||
|
"n1, n2 = int(0.8 * len(words)), int(0.9 * len(words))\n",
|
||||||
|
"Xtr, Ytr = build_dataset(words[:n1])\n",
|
||||||
|
"Xdev, Ydev = build_dataset(words[n1:n2])\n",
|
||||||
|
"Xte, Yte = build_dataset(words[n2:])\n",
|
||||||
|
"print(\"train:\", tuple(Xtr.shape), \" (context length =\", block_size, \")\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "4c8200dd",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.1 PyTorch-style building blocks\n",
|
||||||
|
"\n",
|
||||||
|
"Each layer is a tiny class with a `__call__` (forward) and `parameters()`. The interesting one\n",
|
||||||
|
"is **`FlattenConsecutive(n)`**: it groups every `n` neighbouring positions together — that is\n",
|
||||||
|
"the \"combine in pairs\" tournament step. `BatchNorm1d` is written to also handle the 3-D tensors\n",
|
||||||
|
"that appear between fusion steps (a subtle but important detail)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "7695c863",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"class Linear:\n",
|
||||||
|
" def __init__(self, fan_in, fan_out, bias=True):\n",
|
||||||
|
" self.weight = torch.randn((fan_in, fan_out), generator=g) / fan_in ** 0.5\n",
|
||||||
|
" self.bias = torch.zeros(fan_out) if bias else None\n",
|
||||||
|
" def __call__(self, x):\n",
|
||||||
|
" self.out = x @ self.weight\n",
|
||||||
|
" if self.bias is not None:\n",
|
||||||
|
" self.out = self.out + self.bias\n",
|
||||||
|
" return self.out\n",
|
||||||
|
" def parameters(self):\n",
|
||||||
|
" return [self.weight] + ([] if self.bias is None else [self.bias])\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class BatchNorm1d:\n",
|
||||||
|
" def __init__(self, dim, eps=1e-5, momentum=0.1):\n",
|
||||||
|
" self.eps = eps; self.momentum = momentum; self.training = True\n",
|
||||||
|
" self.gamma = torch.ones(dim); self.beta = torch.zeros(dim)\n",
|
||||||
|
" self.running_mean = torch.zeros(dim); self.running_var = torch.ones(dim)\n",
|
||||||
|
" def __call__(self, x):\n",
|
||||||
|
" if self.training:\n",
|
||||||
|
" dim = 0 if x.ndim == 2 else (0, 1) # 3-D inputs: average over batch AND time\n",
|
||||||
|
" xmean = x.mean(dim, keepdim=True)\n",
|
||||||
|
" xvar = x.var(dim, keepdim=True)\n",
|
||||||
|
" else:\n",
|
||||||
|
" xmean = self.running_mean\n",
|
||||||
|
" xvar = self.running_var\n",
|
||||||
|
" xhat = (x - xmean) / torch.sqrt(xvar + self.eps)\n",
|
||||||
|
" self.out = self.gamma * xhat + self.beta\n",
|
||||||
|
" if self.training:\n",
|
||||||
|
" with torch.no_grad():\n",
|
||||||
|
" self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * xmean\n",
|
||||||
|
" self.running_var = (1 - self.momentum) * self.running_var + self.momentum * xvar\n",
|
||||||
|
" return self.out\n",
|
||||||
|
" def parameters(self):\n",
|
||||||
|
" return [self.gamma, self.beta]\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class Tanh:\n",
|
||||||
|
" def __call__(self, x):\n",
|
||||||
|
" self.out = torch.tanh(x); return self.out\n",
|
||||||
|
" def parameters(self):\n",
|
||||||
|
" return []\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class Embedding:\n",
|
||||||
|
" def __init__(self, num_embeddings, embedding_dim):\n",
|
||||||
|
" self.weight = torch.randn((num_embeddings, embedding_dim), generator=g)\n",
|
||||||
|
" def __call__(self, IX):\n",
|
||||||
|
" self.out = self.weight[IX]; return self.out\n",
|
||||||
|
" def parameters(self):\n",
|
||||||
|
" return [self.weight]\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class FlattenConsecutive:\n",
|
||||||
|
" def __init__(self, n):\n",
|
||||||
|
" self.n = n\n",
|
||||||
|
" def __call__(self, x):\n",
|
||||||
|
" B, T, C = x.shape\n",
|
||||||
|
" x = x.view(B, T // self.n, C * self.n) # fuse every n neighbours\n",
|
||||||
|
" if x.shape[1] == 1:\n",
|
||||||
|
" x = x.squeeze(1)\n",
|
||||||
|
" self.out = x; return self.out\n",
|
||||||
|
" def parameters(self):\n",
|
||||||
|
" return []\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class Sequential:\n",
|
||||||
|
" def __init__(self, layers):\n",
|
||||||
|
" self.layers = layers\n",
|
||||||
|
" def __call__(self, x):\n",
|
||||||
|
" for layer in self.layers:\n",
|
||||||
|
" x = layer(x)\n",
|
||||||
|
" self.out = x; return self.out\n",
|
||||||
|
" def parameters(self):\n",
|
||||||
|
" return [p for layer in self.layers for p in layer.parameters()]\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"layers defined\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "ab8cd26d",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.2 Build the hierarchical (WaveNet) model\n",
|
||||||
|
"\n",
|
||||||
|
"`block_size = 8` fuses in three rounds: 8 -> 4 -> 2 -> 1. Each round is\n",
|
||||||
|
"`FlattenConsecutive(2) -> Linear -> BatchNorm1d -> Tanh`. Finally a `Linear` maps to the 27\n",
|
||||||
|
"next-letter scores. We shrink the last layer's weights so the first loss starts near the ideal\n",
|
||||||
|
"`ln(27)`."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "1dcd57ee",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g = torch.Generator().manual_seed(42)\n",
|
||||||
|
"n_embd = 24\n",
|
||||||
|
"n_hidden = 128\n",
|
||||||
|
"\n",
|
||||||
|
"model = Sequential([\n",
|
||||||
|
" Embedding(vocab_size, n_embd),\n",
|
||||||
|
" FlattenConsecutive(2), Linear(n_embd * 2, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),\n",
|
||||||
|
" FlattenConsecutive(2), Linear(n_hidden * 2, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),\n",
|
||||||
|
" FlattenConsecutive(2), Linear(n_hidden * 2, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(),\n",
|
||||||
|
" Linear(n_hidden, vocab_size),\n",
|
||||||
|
"])\n",
|
||||||
|
"\n",
|
||||||
|
"with torch.no_grad():\n",
|
||||||
|
" model.layers[-1].weight *= 0.1 # humble output\n",
|
||||||
|
"\n",
|
||||||
|
"parameters = model.parameters()\n",
|
||||||
|
"for p in parameters:\n",
|
||||||
|
" p.requires_grad = True\n",
|
||||||
|
"print(\"total parameters:\", sum(p.nelement() for p in parameters))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "dfe5653b",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.3 Watch the tournament shrink the sequence\n",
|
||||||
|
"\n",
|
||||||
|
"Run one batch and print each layer's output shape. See the time dimension halve 8 -> 4 -> 2 -> 1\n",
|
||||||
|
"as neighbours fuse, while the feature dimension does the work."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "5a37517c",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"ix = torch.randint(0, Xtr.shape[0], (4,), generator=g)\n",
|
||||||
|
"_ = model(Xtr[ix])\n",
|
||||||
|
"for layer in model.layers:\n",
|
||||||
|
" print(f\"{layer.__class__.__name__:18s} : {tuple(layer.out.shape)}\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "43949ea9",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.1 Train\n",
|
||||||
|
"\n",
|
||||||
|
"Standard loop: minibatch -> forward -> `loss.backward()` -> nudge. (We use autograd again now;\n",
|
||||||
|
"notebook `04` already proved we could do it by hand.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "f0800ce0",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"max_steps = 20000\n",
|
||||||
|
"batch_size = 32\n",
|
||||||
|
"lossi = []\n",
|
||||||
|
"\n",
|
||||||
|
"for i in range(max_steps):\n",
|
||||||
|
" ix = torch.randint(0, Xtr.shape[0], (batch_size,), generator=g)\n",
|
||||||
|
" Xb, Yb = Xtr[ix], Ytr[ix]\n",
|
||||||
|
"\n",
|
||||||
|
" logits = model(Xb)\n",
|
||||||
|
" loss = F.cross_entropy(logits, Yb)\n",
|
||||||
|
"\n",
|
||||||
|
" for p in parameters:\n",
|
||||||
|
" p.grad = None\n",
|
||||||
|
" loss.backward()\n",
|
||||||
|
" lr = 0.1 if i < 15000 else 0.01\n",
|
||||||
|
" for p in parameters:\n",
|
||||||
|
" p.data += -lr * p.grad\n",
|
||||||
|
"\n",
|
||||||
|
" lossi.append(loss.log10().item())\n",
|
||||||
|
" if i % 4000 == 0 or i == max_steps - 1:\n",
|
||||||
|
" print(f\"step {i:5d}/{max_steps} loss {loss.item():.4f}\")\n",
|
||||||
|
"\n",
|
||||||
|
"if HAS_PLT:\n",
|
||||||
|
" # smooth the noisy per-step loss by averaging in chunks of 200\n",
|
||||||
|
" lt = torch.tensor(lossi)\n",
|
||||||
|
" plt.figure(figsize=(5, 4))\n",
|
||||||
|
" plt.plot(lt.view(-1, 200).mean(1))\n",
|
||||||
|
" plt.xlabel(\"step / 200\"); plt.ylabel(\"log10(loss)\"); plt.title(\"WaveNet training loss\")\n",
|
||||||
|
" plt.show()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "053ba8a7",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.2 Evaluate\n",
|
||||||
|
"\n",
|
||||||
|
"Switch BatchNorm layers to eval mode (use running stats), then score train and dev. The deeper\n",
|
||||||
|
"hierarchical model should edge below the single-layer MLP's ~2.1."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "3a5d6228",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"for layer in model.layers:\n",
|
||||||
|
" if isinstance(layer, BatchNorm1d):\n",
|
||||||
|
" layer.training = False\n",
|
||||||
|
"\n",
|
||||||
|
"@torch.no_grad()\n",
|
||||||
|
"def split_loss(split):\n",
|
||||||
|
" x, y = {\"train\": (Xtr, Ytr), \"dev\": (Xdev, Ydev), \"test\": (Xte, Yte)}[split]\n",
|
||||||
|
" logits = model(x)\n",
|
||||||
|
" return F.cross_entropy(logits, y).item()\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"train loss:\", round(split_loss(\"train\"), 4))\n",
|
||||||
|
"print(\"dev loss:\", round(split_loss(\"dev\"), 4))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "b78eaf70",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.1 Generate names\n",
|
||||||
|
"\n",
|
||||||
|
"Sample letter by letter as before. With 8 letters of context and the hierarchical fusion, the\n",
|
||||||
|
"names tend to look a little more name-like."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "33833066",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"g2 = torch.Generator().manual_seed(2147483647 + 10)\n",
|
||||||
|
"\n",
|
||||||
|
"for _ in range(20):\n",
|
||||||
|
" out = []\n",
|
||||||
|
" context = [0] * block_size\n",
|
||||||
|
" while True:\n",
|
||||||
|
" logits = model(torch.tensor([context]))\n",
|
||||||
|
" probs = F.softmax(logits, dim=1)\n",
|
||||||
|
" ix = torch.multinomial(probs, num_samples=1, generator=g2).item()\n",
|
||||||
|
" context = context[1:] + [ix]\n",
|
||||||
|
" if ix == 0:\n",
|
||||||
|
" break\n",
|
||||||
|
" out.append(itos[ix])\n",
|
||||||
|
" print(\"\".join(out))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "31d52503",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"**Your turn 5** — The model is just a list. Add another fusion round (so it handles\n",
|
||||||
|
"`block_size = 16`), or raise `n_hidden`, retrain, and compare dev loss. Because everything is a\n",
|
||||||
|
"clean `Sequential`, experimenting is just editing the list."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "894cc92e",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## What's next\n",
|
||||||
|
"\n",
|
||||||
|
"You built a deeper, hierarchical model out of reusable PyTorch-style blocks — the same shape of\n",
|
||||||
|
"code real libraries use.\n",
|
||||||
|
"\n",
|
||||||
|
"Where this leads in the series:\n",
|
||||||
|
"\n",
|
||||||
|
"- `06_build_gpt_attention.ipynb` — the transformer. Instead of a fixed tournament that always\n",
|
||||||
|
" fuses neighbours, **attention** lets every position decide *which* earlier positions to listen\n",
|
||||||
|
" to. That flexibility is what makes GPT powerful.\n",
|
||||||
|
"- `07_gpt_tokenizer.ipynb` — how real models turn text into tokens in the first place.\n",
|
||||||
|
"\n",
|
||||||
|
"**Checklist**\n",
|
||||||
|
"- [ ] Context widened to 8 letters\n",
|
||||||
|
"- [ ] PyTorch-style layers (Linear, BatchNorm1d, Tanh, Embedding, FlattenConsecutive, Sequential)\n",
|
||||||
|
"- [ ] Hierarchical fusion 8 -> 4 -> 2 -> 1\n",
|
||||||
|
"- [ ] BatchNorm1d handles 3-D tensors\n",
|
||||||
|
"- [ ] Trained, evaluated, sampled names"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": ".venv (3.12.10)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python",
|
||||||
|
"version": "3.12.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "3d5936ca",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# Let's build GPT — self-attention to a full transformer\n",
|
||||||
|
"\n",
|
||||||
|
"Give every token the power to **look back** — **concept -> code -> Your turn** each step.\n",
|
||||||
|
"\n",
|
||||||
|
"This is notebook **06**, the payoff. Notebook `01` built a bigram that only sees one character\n",
|
||||||
|
"back. Here we add **self-attention** so each position can gather information from all earlier\n",
|
||||||
|
"positions, then stack it into a real (small) **GPT** and train it on tiny Shakespeare.\n",
|
||||||
|
"Follows Karpathy's *\"Let's build GPT from scratch\"*."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "8b6c4fb3",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Prologue — in plain English\n",
|
||||||
|
"\n",
|
||||||
|
"The bigram's weakness: predicting the next letter from only the previous one. WaveNet (`05`)\n",
|
||||||
|
"widened the view but fused neighbours in a fixed pattern. **Attention** is smarter: every\n",
|
||||||
|
"token looks at all earlier tokens and decides, on its own, *which* ones matter right now.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: you are reading a sentence and hit the word \"it.\" To know what \"it\" refers\n",
|
||||||
|
"to, your eyes jump back to the relevant earlier noun — not to a fixed position, but to whichever\n",
|
||||||
|
"word is relevant. Self-attention lets each token make that kind of targeted look-back.\n",
|
||||||
|
"\n",
|
||||||
|
"We build it up in small pieces: the averaging trick, then queries/keys/values, then multiple\n",
|
||||||
|
"heads, then the full transformer block (attention + feed-forward + residual + LayerNorm)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "028dce18",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 0.1 Setup and data (standalone)\n",
|
||||||
|
"\n",
|
||||||
|
"Self-contained: load `input.txt`, build the character vocabulary, and make `get_batch`. Uses\n",
|
||||||
|
"GPU if available, else CPU. The model is intentionally **small** so it trains on a laptop CPU;\n",
|
||||||
|
"the comments show how to scale it up if you have a GPU."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "36f4f6e6",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import os\n",
|
||||||
|
"os.environ.setdefault(\"KMP_DUPLICATE_LIB_OK\", \"TRUE\")\n",
|
||||||
|
"\n",
|
||||||
|
"import torch\n",
|
||||||
|
"import torch.nn as nn\n",
|
||||||
|
"from torch.nn import functional as F\n",
|
||||||
|
"\n",
|
||||||
|
"torch.manual_seed(1337)\n",
|
||||||
|
"device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
|
||||||
|
"print(\"device:\", device, \"| torch\", torch.__version__)\n",
|
||||||
|
"\n",
|
||||||
|
"# --- hyperparameters (small for CPU; bump these up on a GPU) ---\n",
|
||||||
|
"batch_size = 16\n",
|
||||||
|
"block_size = 32 # max context length\n",
|
||||||
|
"n_embd = 64\n",
|
||||||
|
"n_head = 4\n",
|
||||||
|
"n_layer = 3\n",
|
||||||
|
"dropout = 0.0\n",
|
||||||
|
"learning_rate = 1e-3\n",
|
||||||
|
"max_iters = 3000\n",
|
||||||
|
"eval_interval = 1000\n",
|
||||||
|
"eval_iters = 50\n",
|
||||||
|
"\n",
|
||||||
|
"# --- data ---\n",
|
||||||
|
"text = open(\"input.txt\", \"r\", encoding=\"utf-8\").read()\n",
|
||||||
|
"chars = sorted(list(set(text)))\n",
|
||||||
|
"vocab_size = len(chars)\n",
|
||||||
|
"stoi = {ch: i for i, ch in enumerate(chars)}\n",
|
||||||
|
"itos = {i: ch for i, ch in enumerate(chars)}\n",
|
||||||
|
"encode = lambda s: [stoi[c] for c in s]\n",
|
||||||
|
"decode = lambda l: \"\".join(itos[i] for i in l)\n",
|
||||||
|
"\n",
|
||||||
|
"data = torch.tensor(encode(text), dtype=torch.long)\n",
|
||||||
|
"n = int(0.9 * len(data))\n",
|
||||||
|
"train_data, val_data = data[:n], data[n:]\n",
|
||||||
|
"\n",
|
||||||
|
"def get_batch(split):\n",
|
||||||
|
" d = train_data if split == \"train\" else val_data\n",
|
||||||
|
" ix = torch.randint(len(d) - block_size, (batch_size,))\n",
|
||||||
|
" x = torch.stack([d[i:i + block_size] for i in ix])\n",
|
||||||
|
" y = torch.stack([d[i + 1:i + block_size + 1] for i in ix])\n",
|
||||||
|
" return x.to(device), y.to(device)\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"vocab_size:\", vocab_size, \"| chars in dataset:\", len(text))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "d2b12520",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.1 The math trick: a token can average its past\n",
|
||||||
|
"\n",
|
||||||
|
"Before real attention, here is the key move in its simplest form: let each position become the\n",
|
||||||
|
"**average of itself and all positions before it** (never the future — that would be cheating).\n",
|
||||||
|
"We show three ways that give the *same* result, from slow to elegant."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "54c69615",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"torch.manual_seed(1337)\n",
|
||||||
|
"B, T, C = 4, 8, 2\n",
|
||||||
|
"x = torch.randn(B, T, C)\n",
|
||||||
|
"\n",
|
||||||
|
"# v1: explicit loop — average of all previous tokens\n",
|
||||||
|
"xbow = torch.zeros((B, T, C))\n",
|
||||||
|
"for b in range(B):\n",
|
||||||
|
" for t in range(T):\n",
|
||||||
|
" xbow[b, t] = x[b, :t + 1].mean(0)\n",
|
||||||
|
"\n",
|
||||||
|
"# v2: same thing as a matrix multiply with a lower-triangular average matrix\n",
|
||||||
|
"wei = torch.tril(torch.ones(T, T))\n",
|
||||||
|
"wei = wei / wei.sum(1, keepdim=True)\n",
|
||||||
|
"xbow2 = wei @ x\n",
|
||||||
|
"\n",
|
||||||
|
"# v3: same again, but the weights come from softmax over a masked matrix\n",
|
||||||
|
"tril = torch.tril(torch.ones(T, T))\n",
|
||||||
|
"wei = torch.zeros((T, T)).masked_fill(tril == 0, float(\"-inf\"))\n",
|
||||||
|
"wei = F.softmax(wei, dim=-1)\n",
|
||||||
|
"xbow3 = wei @ x\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"v1 == v2 :\", torch.allclose(xbow, xbow2, atol=1e-6), \" (max diff %.1e - just float rounding)\" % (xbow - xbow2).abs().max())\n",
|
||||||
|
"print(\"v1 == v3 :\", torch.allclose(xbow, xbow3, atol=1e-6), \" (max diff %.1e - just float rounding)\" % (xbow - xbow3).abs().max())\n",
|
||||||
|
"print(\"\\nthe softmax weight matrix (row t can only attend to columns <= t):\")\n",
|
||||||
|
"print(wei)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "aa46672a",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"Why the `-inf` + softmax version matters: those weights do **not** have to be a flat\n",
|
||||||
|
"average. If the numbers in `wei` are *learned*, each token can decide how much to listen to each\n",
|
||||||
|
"earlier token. That is exactly what attention does next.\n",
|
||||||
|
"\n",
|
||||||
|
"Analogy: a meeting where you may only hear people who already spoke (the mask), and you choose\n",
|
||||||
|
"how much weight to give each speaker (the learned weights)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "b522ef98",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.1 A single self-attention head — query, key, value\n",
|
||||||
|
"\n",
|
||||||
|
"Each token emits three vectors:\n",
|
||||||
|
"\n",
|
||||||
|
"- **query**: \"what am I looking for?\"\n",
|
||||||
|
"- **key**: \"what do I offer?\"\n",
|
||||||
|
"- **value**: \"what will I actually pass on if chosen?\"\n",
|
||||||
|
"\n",
|
||||||
|
"A token's attention to another = how well its **query** matches the other's **key**\n",
|
||||||
|
"(dot product). We scale by `1/sqrt(head_size)` to keep the numbers tame, mask the future, softmax\n",
|
||||||
|
"into weights, then take the weighted sum of **values**."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "9adfb9ea",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"torch.manual_seed(1337)\n",
|
||||||
|
"B, T, C = 4, 8, 32\n",
|
||||||
|
"x = torch.randn(B, T, C)\n",
|
||||||
|
"head_size = 16\n",
|
||||||
|
"\n",
|
||||||
|
"key = nn.Linear(C, head_size, bias=False)\n",
|
||||||
|
"query = nn.Linear(C, head_size, bias=False)\n",
|
||||||
|
"value = nn.Linear(C, head_size, bias=False)\n",
|
||||||
|
"\n",
|
||||||
|
"k = key(x) # (B, T, head_size)\n",
|
||||||
|
"q = query(x) # (B, T, head_size)\n",
|
||||||
|
"wei = q @ k.transpose(-2, -1) * head_size ** -0.5 # (B, T, T) affinities\n",
|
||||||
|
"\n",
|
||||||
|
"tril = torch.tril(torch.ones(T, T))\n",
|
||||||
|
"wei = wei.masked_fill(tril == 0, float(\"-inf\"))\n",
|
||||||
|
"wei = F.softmax(wei, dim=-1)\n",
|
||||||
|
"\n",
|
||||||
|
"v = value(x)\n",
|
||||||
|
"out = wei @ v # (B, T, head_size)\n",
|
||||||
|
"print(\"attention output shape:\", tuple(out.shape))\n",
|
||||||
|
"print(\"\\nrow 0 of the learned attention weights (note they are NOT uniform):\")\n",
|
||||||
|
"print(wei[0, -1])"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "ff4eea4c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.1 The full GPT, assembled from clean modules\n",
|
||||||
|
"\n",
|
||||||
|
"Now we package it. The pieces:\n",
|
||||||
|
"\n",
|
||||||
|
"- **Head** — one self-attention head (above), with dropout and a causal mask buffer.\n",
|
||||||
|
"- **MultiHeadAttention** — several heads in parallel, concatenated, then projected. Analogy:\n",
|
||||||
|
" several readers each look back for a different kind of clue, then pool notes.\n",
|
||||||
|
"- **FeedForward** — a small per-token MLP: \"time to think about what I gathered.\"\n",
|
||||||
|
"- **Block** — attention + feed-forward, each wrapped with a **residual** connection and\n",
|
||||||
|
" **LayerNorm**. Residual = keep the original and add the new insight (so deep stacks still\n",
|
||||||
|
" train). LayerNorm = re-level each token's vector (the `03` lesson, per-token).\n",
|
||||||
|
"- **GPTLanguageModel** — token + position embeddings -> a stack of Blocks -> final LayerNorm ->\n",
|
||||||
|
" a linear head to vocab logits."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "b0a4b08d",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"class Head(nn.Module):\n",
|
||||||
|
" def __init__(self, head_size):\n",
|
||||||
|
" super().__init__()\n",
|
||||||
|
" self.key = nn.Linear(n_embd, head_size, bias=False)\n",
|
||||||
|
" self.query = nn.Linear(n_embd, head_size, bias=False)\n",
|
||||||
|
" self.value = nn.Linear(n_embd, head_size, bias=False)\n",
|
||||||
|
" self.register_buffer(\"tril\", torch.tril(torch.ones(block_size, block_size)))\n",
|
||||||
|
" self.dropout = nn.Dropout(dropout)\n",
|
||||||
|
"\n",
|
||||||
|
" def forward(self, x):\n",
|
||||||
|
" B, T, C = x.shape\n",
|
||||||
|
" k = self.key(x)\n",
|
||||||
|
" q = self.query(x)\n",
|
||||||
|
" wei = q @ k.transpose(-2, -1) * k.shape[-1] ** -0.5\n",
|
||||||
|
" wei = wei.masked_fill(self.tril[:T, :T] == 0, float(\"-inf\"))\n",
|
||||||
|
" wei = F.softmax(wei, dim=-1)\n",
|
||||||
|
" wei = self.dropout(wei)\n",
|
||||||
|
" v = self.value(x)\n",
|
||||||
|
" return wei @ v\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class MultiHeadAttention(nn.Module):\n",
|
||||||
|
" def __init__(self, num_heads, head_size):\n",
|
||||||
|
" super().__init__()\n",
|
||||||
|
" self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])\n",
|
||||||
|
" self.proj = nn.Linear(head_size * num_heads, n_embd)\n",
|
||||||
|
" self.dropout = nn.Dropout(dropout)\n",
|
||||||
|
"\n",
|
||||||
|
" def forward(self, x):\n",
|
||||||
|
" out = torch.cat([h(x) for h in self.heads], dim=-1)\n",
|
||||||
|
" return self.dropout(self.proj(out))\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class FeedForward(nn.Module):\n",
|
||||||
|
" def __init__(self, n_embd):\n",
|
||||||
|
" super().__init__()\n",
|
||||||
|
" self.net = nn.Sequential(\n",
|
||||||
|
" nn.Linear(n_embd, 4 * n_embd),\n",
|
||||||
|
" nn.ReLU(),\n",
|
||||||
|
" nn.Linear(4 * n_embd, n_embd),\n",
|
||||||
|
" nn.Dropout(dropout),\n",
|
||||||
|
" )\n",
|
||||||
|
"\n",
|
||||||
|
" def forward(self, x):\n",
|
||||||
|
" return self.net(x)\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class Block(nn.Module):\n",
|
||||||
|
" def __init__(self, n_embd, n_head):\n",
|
||||||
|
" super().__init__()\n",
|
||||||
|
" head_size = n_embd // n_head\n",
|
||||||
|
" self.sa = MultiHeadAttention(n_head, head_size)\n",
|
||||||
|
" self.ffwd = FeedForward(n_embd)\n",
|
||||||
|
" self.ln1 = nn.LayerNorm(n_embd)\n",
|
||||||
|
" self.ln2 = nn.LayerNorm(n_embd)\n",
|
||||||
|
"\n",
|
||||||
|
" def forward(self, x):\n",
|
||||||
|
" x = x + self.sa(self.ln1(x)) # residual around attention\n",
|
||||||
|
" x = x + self.ffwd(self.ln2(x)) # residual around feed-forward\n",
|
||||||
|
" return x\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"class GPTLanguageModel(nn.Module):\n",
|
||||||
|
" def __init__(self):\n",
|
||||||
|
" super().__init__()\n",
|
||||||
|
" self.token_embedding_table = nn.Embedding(vocab_size, n_embd)\n",
|
||||||
|
" self.position_embedding_table = nn.Embedding(block_size, n_embd)\n",
|
||||||
|
" self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)])\n",
|
||||||
|
" self.ln_f = nn.LayerNorm(n_embd)\n",
|
||||||
|
" self.lm_head = nn.Linear(n_embd, vocab_size)\n",
|
||||||
|
"\n",
|
||||||
|
" def forward(self, idx, targets=None):\n",
|
||||||
|
" B, T = idx.shape\n",
|
||||||
|
" tok_emb = self.token_embedding_table(idx) # (B,T,C)\n",
|
||||||
|
" pos_emb = self.position_embedding_table(torch.arange(T, device=device))\n",
|
||||||
|
" x = tok_emb + pos_emb\n",
|
||||||
|
" x = self.blocks(x)\n",
|
||||||
|
" x = self.ln_f(x)\n",
|
||||||
|
" logits = self.lm_head(x) # (B,T,vocab)\n",
|
||||||
|
" if targets is None:\n",
|
||||||
|
" return logits, None\n",
|
||||||
|
" B, T, Cc = logits.shape\n",
|
||||||
|
" loss = F.cross_entropy(logits.view(B * T, Cc), targets.view(B * T))\n",
|
||||||
|
" return logits, loss\n",
|
||||||
|
"\n",
|
||||||
|
" def generate(self, idx, max_new_tokens):\n",
|
||||||
|
" for _ in range(max_new_tokens):\n",
|
||||||
|
" idx_cond = idx[:, -block_size:] # never exceed the context window\n",
|
||||||
|
" logits, _ = self(idx_cond)\n",
|
||||||
|
" logits = logits[:, -1, :] # last position only\n",
|
||||||
|
" probs = F.softmax(logits, dim=-1)\n",
|
||||||
|
" idx_next = torch.multinomial(probs, num_samples=1)\n",
|
||||||
|
" idx = torch.cat((idx, idx_next), dim=1)\n",
|
||||||
|
" return idx\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"model = GPTLanguageModel().to(device)\n",
|
||||||
|
"print(\"parameters (millions):\", sum(p.numel() for p in model.parameters()) / 1e6)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "dbb781c5",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.2 Positional embeddings — why we need them\n",
|
||||||
|
"\n",
|
||||||
|
"Attention by itself is **order-blind**: it treats the context as a bag of tokens. But \"dog\n",
|
||||||
|
"bites man\" and \"man bites dog\" must differ. So besides each token's meaning\n",
|
||||||
|
"(`token_embedding_table`), we add a vector for its **position**\n",
|
||||||
|
"(`position_embedding_table`). Now a token knows both *what* it is and *where* it is.\n",
|
||||||
|
"\n",
|
||||||
|
"(That addition already happens inside `forward`: `x = tok_emb + pos_emb`.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "0385a0ef",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 4.1 Train the GPT\n",
|
||||||
|
"\n",
|
||||||
|
"Same training shape as always, with the AdamW optimizer. Watch train and val loss fall well\n",
|
||||||
|
"below the bigram's ~2.5. (Small model + CPU, so this is a few minutes; scale up on a GPU for\n",
|
||||||
|
"real Shakespeare.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "395e6922",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"@torch.no_grad()\n",
|
||||||
|
"def estimate_loss():\n",
|
||||||
|
" out = {}\n",
|
||||||
|
" model.eval()\n",
|
||||||
|
" for split in [\"train\", \"val\"]:\n",
|
||||||
|
" losses = torch.zeros(eval_iters)\n",
|
||||||
|
" for k in range(eval_iters):\n",
|
||||||
|
" X, Y = get_batch(split)\n",
|
||||||
|
" _, loss = model(X, Y)\n",
|
||||||
|
" losses[k] = loss.item()\n",
|
||||||
|
" out[split] = losses.mean().item()\n",
|
||||||
|
" model.train()\n",
|
||||||
|
" return out\n",
|
||||||
|
"\n",
|
||||||
|
"optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)\n",
|
||||||
|
"\n",
|
||||||
|
"for it in range(max_iters):\n",
|
||||||
|
" if it % eval_interval == 0 or it == max_iters - 1:\n",
|
||||||
|
" losses = estimate_loss()\n",
|
||||||
|
" print(f\"step {it:4d}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}\")\n",
|
||||||
|
" xb, yb = get_batch(\"train\")\n",
|
||||||
|
" _, loss = model(xb, yb)\n",
|
||||||
|
" optimizer.zero_grad(set_to_none=True)\n",
|
||||||
|
" loss.backward()\n",
|
||||||
|
" optimizer.step()\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"done training\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "0ae5a608",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 5.1 Generate Shakespeare-ish text\n",
|
||||||
|
"\n",
|
||||||
|
"Start from a single newline and let the model write. It will not be perfect Shakespeare (tiny\n",
|
||||||
|
"model, short training), but compared to the bigram it now produces real-looking words, spacing,\n",
|
||||||
|
"and line breaks — because each character can attend to the ones before it."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "61db808c",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"context = torch.zeros((1, 1), dtype=torch.long, device=device)\n",
|
||||||
|
"print(decode(model.generate(context, max_new_tokens=500)[0].tolist()))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "6b43261f",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"**Your turn 6** — If you have a GPU, scale up (e.g. `n_embd=384`, `n_head=6`,\n",
|
||||||
|
"`n_layer=6`, `block_size=256`, `dropout=0.2`, `max_iters=5000`) and retrain — the samples get\n",
|
||||||
|
"dramatically better. On CPU, try just raising `n_layer` to 4 or `max_iters` to 5000 and compare\n",
|
||||||
|
"the val loss."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "1e1142ab",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## What's next — and the connection to ChatGPT\n",
|
||||||
|
"\n",
|
||||||
|
"You built a real transformer: token + position embeddings, multi-head causal self-attention,\n",
|
||||||
|
"feed-forward layers, residuals, and LayerNorm — the exact architecture behind GPT.\n",
|
||||||
|
"\n",
|
||||||
|
"What you trained here is a **pretrained** model in miniature: it only learns to *continue text*\n",
|
||||||
|
"(predict the next token) from a big pile of text. ChatGPT starts the same way, then adds steps\n",
|
||||||
|
"this series does not cover:\n",
|
||||||
|
"\n",
|
||||||
|
"- **Supervised finetuning** on example conversations (teach it to answer, not just continue).\n",
|
||||||
|
"- **Reinforcement learning from human feedback (RLHF)** to align responses with what people\n",
|
||||||
|
" prefer.\n",
|
||||||
|
"\n",
|
||||||
|
"The remaining notebook:\n",
|
||||||
|
"\n",
|
||||||
|
"- `07_gpt_tokenizer.ipynb` — real models do not use one-character-per-token; they use **Byte\n",
|
||||||
|
" Pair Encoding**. That is the final piece of the pipeline.\n",
|
||||||
|
"\n",
|
||||||
|
"**Checklist**\n",
|
||||||
|
"- [ ] The averaging trick (loop = tril matmul = masked softmax)\n",
|
||||||
|
"- [ ] Query / key / value self-attention head\n",
|
||||||
|
"- [ ] Multi-head attention + projection\n",
|
||||||
|
"- [ ] Feed-forward, residuals, LayerNorm -> Block\n",
|
||||||
|
"- [ ] Token + position embeddings -> full GPT\n",
|
||||||
|
"- [ ] Trained below the bigram baseline; generated text"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": ".venv (3.12.10)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python",
|
||||||
|
"version": "3.12.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "67a422d8",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# The GPT tokenizer — Byte Pair Encoding from scratch\n",
|
||||||
|
"\n",
|
||||||
|
"Turn raw text into tokens the model can read — **concept -> code -> Your turn** each step.\n",
|
||||||
|
"\n",
|
||||||
|
"This is notebook **07**, the final piece of the pipeline. Every notebook so far fed the model\n",
|
||||||
|
"either single characters (Shakespeare) or single letters (names). Real GPTs use **subword**\n",
|
||||||
|
"tokens built by **Byte Pair Encoding (BPE)**. Here we build a BPE tokenizer from scratch, then\n",
|
||||||
|
"see why tokenization quietly causes many famous LLM quirks. Follows Karpathy's\n",
|
||||||
|
"*\"Let's build the GPT Tokenizer\"*."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2037ef52",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Prologue — in plain English\n",
|
||||||
|
"\n",
|
||||||
|
"A neural net only eats numbers, so text must be chopped into pieces (**tokens**) and each piece\n",
|
||||||
|
"mapped to an id. Two extremes:\n",
|
||||||
|
"\n",
|
||||||
|
"- **One token per character** (what we did): tiny vocabulary, but sequences are very long and\n",
|
||||||
|
" the model must relearn common spellings everywhere.\n",
|
||||||
|
"- **One token per word**: short sequences, but the vocabulary explodes and unseen words break it.\n",
|
||||||
|
"\n",
|
||||||
|
"**BPE** is the practical middle: start from raw bytes, then repeatedly **merge the most common\n",
|
||||||
|
"adjacent pair** into a new token. Frequent chunks like `the`, `ing`, or ` and` become single\n",
|
||||||
|
"tokens; rare text still survives as smaller pieces.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: inventing shorthand. You start writing letter by letter, notice you keep\n",
|
||||||
|
"writing \"t-h-e\", so you invent one squiggle for \"the.\" Then you notice \" a-n-d\" and give it a\n",
|
||||||
|
"squiggle too. BPE does this automatically, keeping the most useful shorthands."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "75d40ed6",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 1.1 Text -> UTF-8 bytes (the 256 starting tokens)\n",
|
||||||
|
"\n",
|
||||||
|
"Computers already store text as **bytes** (numbers 0-255) via UTF-8. So our alphabet starts\n",
|
||||||
|
"with exactly 256 tokens — every possible byte. Plain English letters are one byte each;\n",
|
||||||
|
"accented or non-Latin characters take several bytes (which is why they cost more tokens later)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "1037d36e",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"sample = \"Hello world! tokenization is fun.\"\n",
|
||||||
|
"the_bytes = sample.encode(\"utf-8\")\n",
|
||||||
|
"print(\"text :\", sample)\n",
|
||||||
|
"print(\"bytes :\", list(the_bytes))\n",
|
||||||
|
"print(\"length :\", len(the_bytes), \"bytes ->\", len(the_bytes), \"starting tokens\")\n",
|
||||||
|
"\n",
|
||||||
|
"multi = \"café naïve\" # accented letters take >1 byte each\n",
|
||||||
|
"print(\"\\nnon-ASCII example:\", multi)\n",
|
||||||
|
"print(\"bytes:\", list(multi.encode(\"utf-8\")), \"(note: more bytes than characters)\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "5db66687",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.1 Two tiny helpers: count pairs, and merge a pair\n",
|
||||||
|
"\n",
|
||||||
|
"- `get_stats` counts how often each adjacent pair appears.\n",
|
||||||
|
"- `merge` replaces every occurrence of a chosen pair with a single new token id.\n",
|
||||||
|
"\n",
|
||||||
|
"That is the entire mechanism of BPE."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "6bc93ad8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"def get_stats(ids):\n",
|
||||||
|
" counts = {}\n",
|
||||||
|
" for pair in zip(ids, ids[1:]):\n",
|
||||||
|
" counts[pair] = counts.get(pair, 0) + 1\n",
|
||||||
|
" return counts\n",
|
||||||
|
"\n",
|
||||||
|
"def merge(ids, pair, idx):\n",
|
||||||
|
" new_ids = []\n",
|
||||||
|
" i = 0\n",
|
||||||
|
" while i < len(ids):\n",
|
||||||
|
" if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:\n",
|
||||||
|
" new_ids.append(idx)\n",
|
||||||
|
" i += 2\n",
|
||||||
|
" else:\n",
|
||||||
|
" new_ids.append(ids[i])\n",
|
||||||
|
" i += 1\n",
|
||||||
|
" return new_ids\n",
|
||||||
|
"\n",
|
||||||
|
"# demo on a toy list: merge the most common pair\n",
|
||||||
|
"demo = [1, 2, 3, 1, 2, 3, 1, 2]\n",
|
||||||
|
"st = get_stats(demo)\n",
|
||||||
|
"top = max(st, key=st.get)\n",
|
||||||
|
"print(\"counts:\", st)\n",
|
||||||
|
"print(\"most common pair:\", top, \"->\", st[top], \"times\")\n",
|
||||||
|
"print(\"after merging\", top, \"into 99:\", merge(demo, top, 99))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "cfb961e0",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 2.2 Train BPE — learn the merges\n",
|
||||||
|
"\n",
|
||||||
|
"We train on a few KB of real text (Shakespeare). Starting from raw bytes, we repeatedly find\n",
|
||||||
|
"the most common pair and merge it, recording each merge. After `num_merges` rounds we have a\n",
|
||||||
|
"vocabulary of `256 + num_merges` tokens."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "f62fbf5d",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"train_text = open(\"input.txt\", \"r\", encoding=\"utf-8\").read()[:20000]\n",
|
||||||
|
"ids = list(train_text.encode(\"utf-8\"))\n",
|
||||||
|
"print(\"training bytes:\", len(ids))\n",
|
||||||
|
"\n",
|
||||||
|
"num_merges = 60\n",
|
||||||
|
"merges = {} # (a, b) -> new_id\n",
|
||||||
|
"work = list(ids)\n",
|
||||||
|
"for i in range(num_merges):\n",
|
||||||
|
" stats = get_stats(work)\n",
|
||||||
|
" pair = max(stats, key=stats.get)\n",
|
||||||
|
" idx = 256 + i\n",
|
||||||
|
" work = merge(work, pair, idx)\n",
|
||||||
|
" merges[pair] = idx\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"merges learned:\", len(merges))\n",
|
||||||
|
"print(\"tokens after training:\", len(work), \"(was\", len(ids), \"bytes)\")\n",
|
||||||
|
"print(f\"compression: {len(ids) / len(work):.2f}x\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "34b2bc27",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.1 encode and decode\n",
|
||||||
|
"\n",
|
||||||
|
"- **decode**: turn token ids back into text. We build each token's byte string by stitching the\n",
|
||||||
|
" merges back together.\n",
|
||||||
|
"- **encode**: turn new text into token ids by applying the learned merges, always doing the\n",
|
||||||
|
" *earliest-learned* applicable merge first."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "c4f1dcab",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"# build the id -> bytes table\n",
|
||||||
|
"vocab = {idx: bytes([idx]) for idx in range(256)}\n",
|
||||||
|
"for (p0, p1), idx in merges.items():\n",
|
||||||
|
" vocab[idx] = vocab[p0] + vocab[p1]\n",
|
||||||
|
"\n",
|
||||||
|
"def decode(ids):\n",
|
||||||
|
" data = b\"\".join(vocab[idx] for idx in ids)\n",
|
||||||
|
" return data.decode(\"utf-8\", errors=\"replace\")\n",
|
||||||
|
"\n",
|
||||||
|
"def encode(text):\n",
|
||||||
|
" tokens = list(text.encode(\"utf-8\"))\n",
|
||||||
|
" while len(tokens) >= 2:\n",
|
||||||
|
" stats = get_stats(tokens)\n",
|
||||||
|
" # pick the pair whose merge was learned earliest\n",
|
||||||
|
" pair = min(stats, key=lambda p: merges.get(p, float(\"inf\")))\n",
|
||||||
|
" if pair not in merges:\n",
|
||||||
|
" break\n",
|
||||||
|
" tokens = merge(tokens, pair, merges[pair])\n",
|
||||||
|
" return tokens\n",
|
||||||
|
"\n",
|
||||||
|
"trial = \"the king and the queen\"\n",
|
||||||
|
"enc = encode(trial)\n",
|
||||||
|
"print(\"text :\", trial)\n",
|
||||||
|
"print(\"tokens:\", enc)\n",
|
||||||
|
"print(\"count :\", len(enc), \"tokens for\", len(trial), \"characters\")\n",
|
||||||
|
"print(\"decode round-trip ok:\", decode(enc) == trial)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "a41ee40c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 3.2 See what the learned tokens are\n",
|
||||||
|
"\n",
|
||||||
|
"The merges discovered the common chunks of English on their own. Let's print a few learned\n",
|
||||||
|
"tokens (ids >= 256) as the text they stand for."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "dadcc751",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"print(\"a few learned tokens:\")\n",
|
||||||
|
"for idx in range(256, 256 + min(20, num_merges)):\n",
|
||||||
|
" piece = vocab[idx].decode(\"utf-8\", errors=\"replace\")\n",
|
||||||
|
" print(f\" id {idx}: {piece!r}\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "9193a4cd",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 4.1 Regex pre-splitting (what real GPTs add)\n",
|
||||||
|
"\n",
|
||||||
|
"A subtlety: plain BPE might merge across spaces and punctuation (e.g. glue `dog.` or `the the`\n",
|
||||||
|
"into weird tokens). GPT-2 first **splits** the text into sensible chunks with a regex (words,\n",
|
||||||
|
"runs of spaces, punctuation), then runs BPE **inside** each chunk only.\n",
|
||||||
|
"\n",
|
||||||
|
"Real GPT uses the `regex` library with Unicode classes; here is a simplified version with the\n",
|
||||||
|
"standard `re` module to show the idea."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "96cf29fc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import re\n",
|
||||||
|
"\n",
|
||||||
|
"# simplified GPT-2-style splitter (real one uses the `regex` lib with \\p{L}, \\p{N})\n",
|
||||||
|
"pat = re.compile(r\"'s|'t|'re|'ve|'m|'ll|'d| ?\\w+| ?[^\\s\\w]+|\\s+\")\n",
|
||||||
|
"\n",
|
||||||
|
"demo_text = \"Hello, world! It's 2026 already.\"\n",
|
||||||
|
"chunks = re.findall(pat, demo_text)\n",
|
||||||
|
"print(\"split into chunks (BPE then runs inside each one):\")\n",
|
||||||
|
"print(chunks)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "e825ed0b",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 4.2 Special tokens\n",
|
||||||
|
"\n",
|
||||||
|
"Real tokenizers also reserve **special tokens** that are not learned from text but inserted to\n",
|
||||||
|
"mark structure, for example `<|endoftext|>` between documents, or chat markers like\n",
|
||||||
|
"`<|im_start|>` / `<|im_end|>`. They get their own ids above the learned vocabulary.\n",
|
||||||
|
"\n",
|
||||||
|
"Real-life picture: punctuation the model never \"spells\" — single reserved symbols that mean\n",
|
||||||
|
"\"new document starts here\" or \"the user is speaking now.\"\n",
|
||||||
|
"\n",
|
||||||
|
"```\n",
|
||||||
|
"<|endoftext|> -> id 50256 in GPT-2 (separates documents)\n",
|
||||||
|
"<|im_start|> -> chat role marker in chat models\n",
|
||||||
|
"```"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "638d25d8",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### 5.1 Why tokenization causes LLM quirks\n",
|
||||||
|
"\n",
|
||||||
|
"Many puzzling LLM behaviours trace back to how text is tokenized. We measure a few.\n",
|
||||||
|
"\n",
|
||||||
|
"- **Spelling / reversing words is hard**: a word is often a *single* token, so the model does\n",
|
||||||
|
" not \"see\" its letters.\n",
|
||||||
|
"- **Arithmetic is fragile**: numbers split into arbitrary chunks, not clean digits.\n",
|
||||||
|
"- **Non-English costs more**: it falls back to many byte-tokens, so the same meaning uses more\n",
|
||||||
|
" tokens (and more money / context)."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "4f051e82",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"def n_tokens(s):\n",
|
||||||
|
" return len(encode(s))\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"spelling: 'extraordinary' ->\", n_tokens(\"extraordinary\"), \"token(s)\")\n",
|
||||||
|
"print(\" the model sees a chunk, not the 13 letters\\n\")\n",
|
||||||
|
"\n",
|
||||||
|
"print(\"numbers : '1234567' ->\", n_tokens(\"1234567\"), \"tokens (split oddly, not per-digit)\")\n",
|
||||||
|
"print(\" '127 + 677' ->\", n_tokens(\"127 + 677\"), \"tokens\\n\")\n",
|
||||||
|
"\n",
|
||||||
|
"eng = \"hello how are you\"\n",
|
||||||
|
"non = \"你好你好你好\" # non-English of similar visible length\n",
|
||||||
|
"print(\"non-English costs more tokens for similar content:\")\n",
|
||||||
|
"print(\" english :\", repr(eng), \"->\", n_tokens(eng), \"tokens\")\n",
|
||||||
|
"print(\" non-latin :\", repr(non), \"->\", n_tokens(non), \"tokens\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "cda06321",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"**Your turn 7** — Raise `num_merges` (e.g. to 300) in section 2.2, retrain, and re-check the\n",
|
||||||
|
"compression ratio and the token counts in section 5.1. More merges = better compression, but a\n",
|
||||||
|
"bigger vocabulary. This trade-off is exactly what real tokenizer designers tune."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "35388d46",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Series wrap-up\n",
|
||||||
|
"\n",
|
||||||
|
"You built the whole stack, from the ground up:\n",
|
||||||
|
"\n",
|
||||||
|
"- `00_micrograd.ipynb` — backprop from scratch (the engine)\n",
|
||||||
|
"- `01_build_gpt.ipynb` — the bigram baseline\n",
|
||||||
|
"- `02_makemore_mlp.ipynb` — an MLP with embeddings\n",
|
||||||
|
"- `03_batchnorm_activations.ipynb` — keeping deep nets healthy\n",
|
||||||
|
"- `04_backprop_ninja.ipynb` — gradients by hand\n",
|
||||||
|
"- `05_wavenet.ipynb` — a deeper, hierarchical model\n",
|
||||||
|
"- `06_build_gpt_attention.ipynb` — the transformer (GPT)\n",
|
||||||
|
"- `07_gpt_tokenizer.ipynb` — turning text into tokens (this notebook)\n",
|
||||||
|
"\n",
|
||||||
|
"Together these cover Andrej Karpathy's *Neural Networks: Zero to Hero* course, on real data,\n",
|
||||||
|
"with runnable code at every step.\n",
|
||||||
|
"\n",
|
||||||
|
"**Checklist**\n",
|
||||||
|
"- [ ] Text -> UTF-8 bytes (256 base tokens)\n",
|
||||||
|
"- [ ] `get_stats` + `merge` = the BPE mechanism\n",
|
||||||
|
"- [ ] Trained merges; measured compression\n",
|
||||||
|
"- [ ] encode / decode round-trips\n",
|
||||||
|
"- [ ] Regex pre-splitting and special tokens\n",
|
||||||
|
"- [ ] Explained tokenization-caused LLM quirks"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": ".venv (3.12.10)",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"name": "python",
|
||||||
|
"version": "3.12.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
cells = []
|
||||||
|
|
||||||
|
|
||||||
|
def _src(text):
|
||||||
|
lines = text.split("\n")
|
||||||
|
out = [line + "\n" if i < len(lines) - 1 else line for i, line in enumerate(lines)]
|
||||||
|
if out and out[-1] == "":
|
||||||
|
out.pop()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def md(text):
|
||||||
|
cells.append({"cell_type": "markdown", "id": uuid.uuid4().hex[:8],
|
||||||
|
"metadata": {}, "source": _src(text)})
|
||||||
|
|
||||||
|
|
||||||
|
def code(text):
|
||||||
|
cells.append({"cell_type": "code", "execution_count": None, "id": uuid.uuid4().hex[:8],
|
||||||
|
"metadata": {}, "outputs": [], "source": _src(text)})
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
md("""# The GPT tokenizer — Byte Pair Encoding from scratch
|
||||||
|
|
||||||
|
Turn raw text into tokens the model can read — **concept -> code -> Your turn** each step.
|
||||||
|
|
||||||
|
This is notebook **07**, the final piece of the pipeline. Every notebook so far fed the model
|
||||||
|
either single characters (Shakespeare) or single letters (names). Real GPTs use **subword**
|
||||||
|
tokens built by **Byte Pair Encoding (BPE)**. Here we build a BPE tokenizer from scratch, then
|
||||||
|
see why tokenization quietly causes many famous LLM quirks. Follows Karpathy's
|
||||||
|
*"Let's build the GPT Tokenizer"*.""")
|
||||||
|
|
||||||
|
md("""## Prologue — in plain English
|
||||||
|
|
||||||
|
A neural net only eats numbers, so text must be chopped into pieces (**tokens**) and each piece
|
||||||
|
mapped to an id. Two extremes:
|
||||||
|
|
||||||
|
- **One token per character** (what we did): tiny vocabulary, but sequences are very long and
|
||||||
|
the model must relearn common spellings everywhere.
|
||||||
|
- **One token per word**: short sequences, but the vocabulary explodes and unseen words break it.
|
||||||
|
|
||||||
|
**BPE** is the practical middle: start from raw bytes, then repeatedly **merge the most common
|
||||||
|
adjacent pair** into a new token. Frequent chunks like `the`, `ing`, or ` and` become single
|
||||||
|
tokens; rare text still survives as smaller pieces.
|
||||||
|
|
||||||
|
Real-life picture: inventing shorthand. You start writing letter by letter, notice you keep
|
||||||
|
writing "t-h-e", so you invent one squiggle for "the." Then you notice " a-n-d" and give it a
|
||||||
|
squiggle too. BPE does this automatically, keeping the most useful shorthands.""")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
md("""### 1.1 Text -> UTF-8 bytes (the 256 starting tokens)
|
||||||
|
|
||||||
|
Computers already store text as **bytes** (numbers 0-255) via UTF-8. So our alphabet starts
|
||||||
|
with exactly 256 tokens — every possible byte. Plain English letters are one byte each;
|
||||||
|
accented or non-Latin characters take several bytes (which is why they cost more tokens later).""")
|
||||||
|
|
||||||
|
code("""sample = "Hello world! tokenization is fun."
|
||||||
|
the_bytes = sample.encode("utf-8")
|
||||||
|
print("text :", sample)
|
||||||
|
print("bytes :", list(the_bytes))
|
||||||
|
print("length :", len(the_bytes), "bytes ->", len(the_bytes), "starting tokens")
|
||||||
|
|
||||||
|
multi = "café na\u00efve" # accented letters take >1 byte each
|
||||||
|
print("\\nnon-ASCII example:", multi)
|
||||||
|
print("bytes:", list(multi.encode("utf-8")), "(note: more bytes than characters)")""")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
md("""### 2.1 Two tiny helpers: count pairs, and merge a pair
|
||||||
|
|
||||||
|
- `get_stats` counts how often each adjacent pair appears.
|
||||||
|
- `merge` replaces every occurrence of a chosen pair with a single new token id.
|
||||||
|
|
||||||
|
That is the entire mechanism of BPE.""")
|
||||||
|
|
||||||
|
code("""def get_stats(ids):
|
||||||
|
counts = {}
|
||||||
|
for pair in zip(ids, ids[1:]):
|
||||||
|
counts[pair] = counts.get(pair, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def merge(ids, pair, idx):
|
||||||
|
new_ids = []
|
||||||
|
i = 0
|
||||||
|
while i < len(ids):
|
||||||
|
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
|
||||||
|
new_ids.append(idx)
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
new_ids.append(ids[i])
|
||||||
|
i += 1
|
||||||
|
return new_ids
|
||||||
|
|
||||||
|
# demo on a toy list: merge the most common pair
|
||||||
|
demo = [1, 2, 3, 1, 2, 3, 1, 2]
|
||||||
|
st = get_stats(demo)
|
||||||
|
top = max(st, key=st.get)
|
||||||
|
print("counts:", st)
|
||||||
|
print("most common pair:", top, "->", st[top], "times")
|
||||||
|
print("after merging", top, "into 99:", merge(demo, top, 99))""")
|
||||||
|
|
||||||
|
md("""### 2.2 Train BPE — learn the merges
|
||||||
|
|
||||||
|
We train on a few KB of real text (Shakespeare). Starting from raw bytes, we repeatedly find
|
||||||
|
the most common pair and merge it, recording each merge. After `num_merges` rounds we have a
|
||||||
|
vocabulary of `256 + num_merges` tokens.""")
|
||||||
|
|
||||||
|
code("""train_text = open("input.txt", "r", encoding="utf-8").read()[:20000]
|
||||||
|
ids = list(train_text.encode("utf-8"))
|
||||||
|
print("training bytes:", len(ids))
|
||||||
|
|
||||||
|
num_merges = 60
|
||||||
|
merges = {} # (a, b) -> new_id
|
||||||
|
work = list(ids)
|
||||||
|
for i in range(num_merges):
|
||||||
|
stats = get_stats(work)
|
||||||
|
pair = max(stats, key=stats.get)
|
||||||
|
idx = 256 + i
|
||||||
|
work = merge(work, pair, idx)
|
||||||
|
merges[pair] = idx
|
||||||
|
|
||||||
|
print("merges learned:", len(merges))
|
||||||
|
print("tokens after training:", len(work), "(was", len(ids), "bytes)")
|
||||||
|
print(f"compression: {len(ids) / len(work):.2f}x")""")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
md("""### 3.1 encode and decode
|
||||||
|
|
||||||
|
- **decode**: turn token ids back into text. We build each token's byte string by stitching the
|
||||||
|
merges back together.
|
||||||
|
- **encode**: turn new text into token ids by applying the learned merges, always doing the
|
||||||
|
*earliest-learned* applicable merge first.""")
|
||||||
|
|
||||||
|
code("""# build the id -> bytes table
|
||||||
|
vocab = {idx: bytes([idx]) for idx in range(256)}
|
||||||
|
for (p0, p1), idx in merges.items():
|
||||||
|
vocab[idx] = vocab[p0] + vocab[p1]
|
||||||
|
|
||||||
|
def decode(ids):
|
||||||
|
data = b"".join(vocab[idx] for idx in ids)
|
||||||
|
return data.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
def encode(text):
|
||||||
|
tokens = list(text.encode("utf-8"))
|
||||||
|
while len(tokens) >= 2:
|
||||||
|
stats = get_stats(tokens)
|
||||||
|
# pick the pair whose merge was learned earliest
|
||||||
|
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
|
||||||
|
if pair not in merges:
|
||||||
|
break
|
||||||
|
tokens = merge(tokens, pair, merges[pair])
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
trial = "the king and the queen"
|
||||||
|
enc = encode(trial)
|
||||||
|
print("text :", trial)
|
||||||
|
print("tokens:", enc)
|
||||||
|
print("count :", len(enc), "tokens for", len(trial), "characters")
|
||||||
|
print("decode round-trip ok:", decode(enc) == trial)""")
|
||||||
|
|
||||||
|
md("""### 3.2 See what the learned tokens are
|
||||||
|
|
||||||
|
The merges discovered the common chunks of English on their own. Let's print a few learned
|
||||||
|
tokens (ids >= 256) as the text they stand for.""")
|
||||||
|
|
||||||
|
code("""print("a few learned tokens:")
|
||||||
|
for idx in range(256, 256 + min(20, num_merges)):
|
||||||
|
piece = vocab[idx].decode("utf-8", errors="replace")
|
||||||
|
print(f" id {idx}: {piece!r}")""")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
md("""### 4.1 Regex pre-splitting (what real GPTs add)
|
||||||
|
|
||||||
|
A subtlety: plain BPE might merge across spaces and punctuation (e.g. glue `dog.` or `the the`
|
||||||
|
into weird tokens). GPT-2 first **splits** the text into sensible chunks with a regex (words,
|
||||||
|
runs of spaces, punctuation), then runs BPE **inside** each chunk only.
|
||||||
|
|
||||||
|
Real GPT uses the `regex` library with Unicode classes; here is a simplified version with the
|
||||||
|
standard `re` module to show the idea.""")
|
||||||
|
|
||||||
|
code("""import re
|
||||||
|
|
||||||
|
# simplified GPT-2-style splitter (real one uses the `regex` lib with \\p{L}, \\p{N})
|
||||||
|
pat = re.compile(r"'s|'t|'re|'ve|'m|'ll|'d| ?\\w+| ?[^\\s\\w]+|\\s+")
|
||||||
|
|
||||||
|
demo_text = "Hello, world! It's 2026 already."
|
||||||
|
chunks = re.findall(pat, demo_text)
|
||||||
|
print("split into chunks (BPE then runs inside each one):")
|
||||||
|
print(chunks)""")
|
||||||
|
|
||||||
|
md("""### 4.2 Special tokens
|
||||||
|
|
||||||
|
Real tokenizers also reserve **special tokens** that are not learned from text but inserted to
|
||||||
|
mark structure, for example `<|endoftext|>` between documents, or chat markers like
|
||||||
|
`<|im_start|>` / `<|im_end|>`. They get their own ids above the learned vocabulary.
|
||||||
|
|
||||||
|
Real-life picture: punctuation the model never "spells" — single reserved symbols that mean
|
||||||
|
"new document starts here" or "the user is speaking now."
|
||||||
|
|
||||||
|
```
|
||||||
|
<|endoftext|> -> id 50256 in GPT-2 (separates documents)
|
||||||
|
<|im_start|> -> chat role marker in chat models
|
||||||
|
```""")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
md("""### 5.1 Why tokenization causes LLM quirks
|
||||||
|
|
||||||
|
Many puzzling LLM behaviours trace back to how text is tokenized. We measure a few.
|
||||||
|
|
||||||
|
- **Spelling / reversing words is hard**: a word is often a *single* token, so the model does
|
||||||
|
not "see" its letters.
|
||||||
|
- **Arithmetic is fragile**: numbers split into arbitrary chunks, not clean digits.
|
||||||
|
- **Non-English costs more**: it falls back to many byte-tokens, so the same meaning uses more
|
||||||
|
tokens (and more money / context).""")
|
||||||
|
|
||||||
|
code("""def n_tokens(s):
|
||||||
|
return len(encode(s))
|
||||||
|
|
||||||
|
print("spelling: 'extraordinary' ->", n_tokens("extraordinary"), "token(s)")
|
||||||
|
print(" the model sees a chunk, not the 13 letters\\n")
|
||||||
|
|
||||||
|
print("numbers : '1234567' ->", n_tokens("1234567"), "tokens (split oddly, not per-digit)")
|
||||||
|
print(" '127 + 677' ->", n_tokens("127 + 677"), "tokens\\n")
|
||||||
|
|
||||||
|
eng = "hello how are you"
|
||||||
|
non = "\u4f60\u597d\u4f60\u597d\u4f60\u597d" # non-English of similar visible length
|
||||||
|
print("non-English costs more tokens for similar content:")
|
||||||
|
print(" english :", repr(eng), "->", n_tokens(eng), "tokens")
|
||||||
|
print(" non-latin :", repr(non), "->", n_tokens(non), "tokens")""")
|
||||||
|
|
||||||
|
md("""**Your turn 7** — Raise `num_merges` (e.g. to 300) in section 2.2, retrain, and re-check the
|
||||||
|
compression ratio and the token counts in section 5.1. More merges = better compression, but a
|
||||||
|
bigger vocabulary. This trade-off is exactly what real tokenizer designers tune.""")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
md("""## Series wrap-up
|
||||||
|
|
||||||
|
You built the whole stack, from the ground up:
|
||||||
|
|
||||||
|
- `00_micrograd.ipynb` — backprop from scratch (the engine)
|
||||||
|
- `01_build_gpt.ipynb` — the bigram baseline
|
||||||
|
- `02_makemore_mlp.ipynb` — an MLP with embeddings
|
||||||
|
- `03_batchnorm_activations.ipynb` — keeping deep nets healthy
|
||||||
|
- `04_backprop_ninja.ipynb` — gradients by hand
|
||||||
|
- `05_wavenet.ipynb` — a deeper, hierarchical model
|
||||||
|
- `06_build_gpt_attention.ipynb` — the transformer (GPT)
|
||||||
|
- `07_gpt_tokenizer.ipynb` — turning text into tokens (this notebook)
|
||||||
|
|
||||||
|
Together these cover Andrej Karpathy's *Neural Networks: Zero to Hero* course, on real data,
|
||||||
|
with runnable code at every step.
|
||||||
|
|
||||||
|
**Checklist**
|
||||||
|
- [ ] Text -> UTF-8 bytes (256 base tokens)
|
||||||
|
- [ ] `get_stats` + `merge` = the BPE mechanism
|
||||||
|
- [ ] Trained merges; measured compression
|
||||||
|
- [ ] encode / decode round-trips
|
||||||
|
- [ ] Regex pre-splitting and special tokens
|
||||||
|
- [ ] Explained tokenization-caused LLM quirks""")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
nb = {
|
||||||
|
"cells": cells,
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {"display_name": ".venv (3.12.10)", "language": "python", "name": "python3"},
|
||||||
|
"language_info": {"name": "python", "version": "3.12.10"},
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
out_path = Path(r"d:\BeastProjects\gpt_from_scratch\07_gpt_tokenizer.ipynb")
|
||||||
|
out_path.write_text(json.dumps(nb, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print("wrote", out_path, "with", len(cells), "cells")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import json, sys, time
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
nb = json.load(open(path, encoding="utf-8"))
|
||||||
|
g = {"__name__": "__main__"}
|
||||||
|
|
||||||
|
n = 0
|
||||||
|
ok = True
|
||||||
|
for c in nb["cells"]:
|
||||||
|
if c["cell_type"] != "code":
|
||||||
|
continue
|
||||||
|
n += 1
|
||||||
|
src = "".join(c["source"])
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
exec(src, g)
|
||||||
|
if "plt" in g and getattr(g["plt"], "show", None):
|
||||||
|
g["plt"].show = lambda *a, **k: None
|
||||||
|
except Exception as e:
|
||||||
|
print("CELL", n, "FAILED:", type(e).__name__, e)
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
print(f" (cell {n} ok, {time.time()-t0:.1f}s)")
|
||||||
|
if ok:
|
||||||
|
print("ALL", n, "CODE CELLS RAN OK")
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Local AI Coding Setup — Windows 11 (Aider + Ollama)
|
|
||||||
|
|
||||||
## Hardware
|
|
||||||
- GPU: RTX 5070 Ti, 12GB VRAM
|
|
||||||
- RAM: 32GB
|
|
||||||
- OS: Windows 11
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
### 1. Install Ollama
|
|
||||||
Download from https://ollama.com and install.
|
|
||||||
|
|
||||||
### 2. Pull a model
|
|
||||||
```powershell
|
|
||||||
ollama pull qwen3-coder:30b # 18GB, strong coding model (MoE, VRAM+RAM split)
|
|
||||||
ollama pull gemma4:12b # 12GB, responds in English more reliably
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm models are available:
|
|
||||||
```powershell
|
|
||||||
ollama list
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm Ollama is serving:
|
|
||||||
```powershell
|
|
||||||
curl http://localhost:11434/api/tags
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Install Aider
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pip install aider-chat
|
|
||||||
aider --version # should print version number
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Config
|
|
||||||
|
|
||||||
Create `.aider.conf.yml` in your project root:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
model: ollama/gemma4:12b
|
|
||||||
openai-api-base: http://localhost:11434/v1
|
|
||||||
openai-api-key: ollama
|
|
||||||
edit-format: whole
|
|
||||||
map-tokens: 2048
|
|
||||||
no-auto-commits: true
|
|
||||||
system-prompt: "Always respond in English only. Never use Chinese or any other language."
|
|
||||||
```
|
|
||||||
|
|
||||||
**Config notes:**
|
|
||||||
- `edit-format: whole` — more reliable file writes for local models (avoids code showing in chat only)
|
|
||||||
- `map-tokens: 2048` — limits repo map size, important for smaller context windows
|
|
||||||
- `no-auto-commits: true` — review changes before they hit git
|
|
||||||
- `system-prompt` — forces English responses (important for Qwen3 which defaults to Chinese)
|
|
||||||
|
|
||||||
Switch models without editing the file:
|
|
||||||
```
|
|
||||||
/model ollama/qwen3-coder:30b
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Optional: Performance Tuning (if inference is slow)
|
|
||||||
|
|
||||||
Create a `Modelfile` in the project root:
|
|
||||||
|
|
||||||
```
|
|
||||||
FROM qwen3-coder:30b
|
|
||||||
PARAMETER num_ctx 8192
|
|
||||||
PARAMETER num_gpu 99
|
|
||||||
```
|
|
||||||
|
|
||||||
Register it:
|
|
||||||
```powershell
|
|
||||||
ollama create qwen3-coder-tuned -f Modelfile
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `.aider.conf.yml`:
|
|
||||||
```yaml
|
|
||||||
model: ollama/qwen3-coder-tuned
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why:** `num_gpu 99` maximizes VRAM usage. `num_ctx 8192` prevents the KV cache from
|
|
||||||
spilling too much into RAM. Recommended context range: 4096–16384.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Daily Aider Workflow
|
|
||||||
|
|
||||||
Start Aider (reads config automatically):
|
|
||||||
```powershell
|
|
||||||
aider
|
|
||||||
```
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|-------------|
|
|
||||||
| `/add src/foo.py` | Add file to context |
|
|
||||||
| `/drop src/foo.py` | Remove file from context |
|
|
||||||
| `/diff` | See pending changes |
|
|
||||||
| `/undo` | Revert last change |
|
|
||||||
| `/run pytest` | Run command, share output with model |
|
|
||||||
| `/run git rm --cached file.py` | Run git commands from inside Aider |
|
|
||||||
| `/clear` | Clear chat history (keep files) |
|
|
||||||
| `/settings` | Show loaded config (verify auto_commits, system_prompt) |
|
|
||||||
| `/model ollama/gemma4:12b` | Switch model on the fly |
|
|
||||||
| `/exit` | Quit |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Issues & Fixes
|
|
||||||
|
|
||||||
### Model responds in Chinese
|
|
||||||
Cause: Qwen3 is a Chinese model and defaults to Chinese.
|
|
||||||
Fix: `system-prompt` in `.aider.conf.yml` (already included above). Restart Aider after config change.
|
|
||||||
|
|
||||||
### Aider shows code in chat but doesn't write files
|
|
||||||
Cause: Model didn't follow Aider's edit format.
|
|
||||||
Fix 1: `/add target_file.py` before asking — hints the target file to the model.
|
|
||||||
Fix 2: `edit-format: whole` in config (already included above).
|
|
||||||
|
|
||||||
### "File not found" / repo-map warnings for deleted files
|
|
||||||
Cause: File deleted from disk but still tracked in git index.
|
|
||||||
Fix:
|
|
||||||
```powershell
|
|
||||||
git rm --cached filename.py
|
|
||||||
```
|
|
||||||
Or from inside Aider:
|
|
||||||
```
|
|
||||||
/run git rm --cached filename.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Config changes not taking effect
|
|
||||||
Fix: Exit Aider (`/exit`) and restart. Config is only read at startup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verify Everything Works
|
|
||||||
|
|
||||||
1. `ollama list` — model appears
|
|
||||||
2. `aider --version` — prints version
|
|
||||||
3. Start Aider, run `/settings` — confirm `auto_commits: False` and `system_prompt` present
|
|
||||||
4. Ask Aider to create a simple file — it should write to disk, not just chat
|
|
||||||
5. Response is in English
|
|
||||||
-342
@@ -1,342 +0,0 @@
|
|||||||
# Local AI Coding Environment Implementation Plan
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Build a local-first AI coding environment on a Windows 11 workstation with the following requirements:
|
|
||||||
|
|
||||||
### Hardware
|
|
||||||
|
|
||||||
* NVIDIA RTX 5070 Ti 12GB VRAM
|
|
||||||
* 64GB+ system RAM (if available)
|
|
||||||
* Windows 11
|
|
||||||
* WSL2 Ubuntu
|
|
||||||
* VS Code
|
|
||||||
|
|
||||||
### Existing Software
|
|
||||||
|
|
||||||
* Ollama installed and operational
|
|
||||||
* Qwen3-Coder 30B installed in Ollama
|
|
||||||
* Claude Code CLI installed
|
|
||||||
* Git installed
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
* Source code is confidential.
|
|
||||||
* All code must remain local by default.
|
|
||||||
* Cloud models may be used only as an optional fallback.
|
|
||||||
* Minimize API costs.
|
|
||||||
* Support large codebases.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Target Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
VS Code
|
|
||||||
|
|
|
||||||
+---- Continue Extension
|
|
||||||
|
|
|
||||||
+---- Claude Code CLI
|
|
||||||
|
|
|
||||||
+---- Aider
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Ollama
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project Goals
|
|
||||||
|
|
||||||
Implement and validate the following workflows.
|
|
||||||
|
|
||||||
## Workflow 1: Local Chat
|
|
||||||
|
|
||||||
Developer can:
|
|
||||||
|
|
||||||
* Ask coding questions
|
|
||||||
* Explain code
|
|
||||||
* Generate code
|
|
||||||
* Review code
|
|
||||||
|
|
||||||
using:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VS Code
|
|
||||||
+
|
|
||||||
Continue
|
|
||||||
+
|
|
||||||
Ollama
|
|
||||||
+
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Workflow 2: Local Agent
|
|
||||||
|
|
||||||
Developer can:
|
|
||||||
|
|
||||||
* Refactor code
|
|
||||||
* Create files
|
|
||||||
* Modify files
|
|
||||||
* Run git-aware edits
|
|
||||||
|
|
||||||
using:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Aider
|
|
||||||
+
|
|
||||||
Ollama
|
|
||||||
+
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Workflow 3: Claude Code Optional
|
|
||||||
|
|
||||||
Developer can:
|
|
||||||
|
|
||||||
* Use Claude Code against local Ollama models
|
|
||||||
* Compare behavior against Aider
|
|
||||||
* Determine whether Claude Code provides additional value
|
|
||||||
|
|
||||||
using:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Claude Code
|
|
||||||
+
|
|
||||||
Ollama
|
|
||||||
+
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Deliverables
|
|
||||||
|
|
||||||
Produce the following:
|
|
||||||
|
|
||||||
## Deliverable 1
|
|
||||||
|
|
||||||
Environment verification script.
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
* Ollama installed
|
|
||||||
* NVIDIA GPU visible
|
|
||||||
* CUDA available
|
|
||||||
* WSL functioning
|
|
||||||
* Qwen3-Coder model available
|
|
||||||
|
|
||||||
Expected output:
|
|
||||||
|
|
||||||
```text
|
|
||||||
PASS: Ollama
|
|
||||||
PASS: GPU
|
|
||||||
PASS: CUDA
|
|
||||||
PASS: Qwen3-Coder
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deliverable 2
|
|
||||||
|
|
||||||
Aider installation guide.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
### Linux / WSL installation
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx install aider-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
or preferred installation method.
|
|
||||||
|
|
||||||
### Ollama integration
|
|
||||||
|
|
||||||
Configuration examples.
|
|
||||||
|
|
||||||
### Verification steps
|
|
||||||
|
|
||||||
Simple repository test.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deliverable 3
|
|
||||||
|
|
||||||
Continue configuration.
|
|
||||||
|
|
||||||
Create:
|
|
||||||
|
|
||||||
### VS Code setup instructions
|
|
||||||
|
|
||||||
Install Continue extension.
|
|
||||||
|
|
||||||
### Model configuration
|
|
||||||
|
|
||||||
Configure Continue to use:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Ollama
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example config files
|
|
||||||
|
|
||||||
Include complete examples.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deliverable 4
|
|
||||||
|
|
||||||
Claude Code local model integration.
|
|
||||||
|
|
||||||
Research and implement the best available approach for:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Claude Code
|
|
||||||
->
|
|
||||||
OpenAI-compatible endpoint
|
|
||||||
->
|
|
||||||
Ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
* Use officially supported methods where possible.
|
|
||||||
* Avoid unsupported hacks.
|
|
||||||
* Document limitations.
|
|
||||||
* Provide rollback procedure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deliverable 5
|
|
||||||
|
|
||||||
Performance optimization.
|
|
||||||
|
|
||||||
Analyze:
|
|
||||||
|
|
||||||
### VRAM usage
|
|
||||||
|
|
||||||
Qwen3-Coder 30B on RTX 5070 Ti 12GB.
|
|
||||||
|
|
||||||
### Recommended quantization
|
|
||||||
|
|
||||||
Evaluate:
|
|
||||||
|
|
||||||
* Q4
|
|
||||||
* Q5
|
|
||||||
* IQ3
|
|
||||||
|
|
||||||
Recommend the best balance between:
|
|
||||||
|
|
||||||
* Quality
|
|
||||||
* Speed
|
|
||||||
* Memory usage
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deliverable 6
|
|
||||||
|
|
||||||
Benchmark suite.
|
|
||||||
|
|
||||||
Create repeatable tests:
|
|
||||||
|
|
||||||
### Test 1
|
|
||||||
|
|
||||||
Generate a REST API.
|
|
||||||
|
|
||||||
### Test 2
|
|
||||||
|
|
||||||
Refactor a medium-sized module.
|
|
||||||
|
|
||||||
### Test 3
|
|
||||||
|
|
||||||
Write unit tests.
|
|
||||||
|
|
||||||
### Test 4
|
|
||||||
|
|
||||||
Debug a failing application.
|
|
||||||
|
|
||||||
Measure:
|
|
||||||
|
|
||||||
* Completion time
|
|
||||||
* Accuracy
|
|
||||||
* Token throughput
|
|
||||||
* User effort
|
|
||||||
|
|
||||||
Compare:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Aider + Qwen3-Coder 30B
|
|
||||||
Claude Code + Qwen3-Coder 30B
|
|
||||||
Continue + Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Preferred Outcome
|
|
||||||
|
|
||||||
Primary development workflow:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VS Code
|
|
||||||
+
|
|
||||||
Continue
|
|
||||||
+
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent workflow:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Aider
|
|
||||||
+
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional advanced workflow:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Claude Code
|
|
||||||
+
|
|
||||||
Qwen3-Coder 30B
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Success Criteria
|
|
||||||
|
|
||||||
The project is successful if:
|
|
||||||
|
|
||||||
1. All code remains local.
|
|
||||||
2. No cloud services are required.
|
|
||||||
3. Developer can perform daily coding tasks locally.
|
|
||||||
4. Aider successfully edits repositories.
|
|
||||||
5. Continue provides a productive IDE experience.
|
|
||||||
6. Claude Code local integration is evaluated and documented.
|
|
||||||
7. Setup can be reproduced on a fresh machine.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Final Report
|
|
||||||
|
|
||||||
Produce a final report containing:
|
|
||||||
|
|
||||||
* Architecture diagram
|
|
||||||
* Installation steps
|
|
||||||
* Configuration files
|
|
||||||
* Benchmark results
|
|
||||||
* Known limitations
|
|
||||||
* Recommended workflow
|
|
||||||
* Future upgrade path
|
|
||||||
|
|
||||||
The report should be suitable for long-term maintenance and onboarding of additional developers.
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Local AI Coding Environment — Reference & Lessons Learned
|
||||||
|
|
||||||
|
> Consolidates `local_ai_setup.md` and `local_llm.md`. Those files are superseded by this one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
| Component | Spec |
|
||||||
|
|-----------|------|
|
||||||
|
| GPU | NVIDIA RTX 5070 Ti, 12 GB VRAM |
|
||||||
|
| RAM | 32 GB system RAM |
|
||||||
|
| OS | Windows 11 |
|
||||||
|
| WSL | WSL2 Ubuntu |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Software Stack
|
||||||
|
|
||||||
|
| Tool | Purpose | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Ollama | Local model server | Serves OpenAI-compatible API at `localhost:11434/v1` |
|
||||||
|
| OpenCode | Primary AI coding agent | Installed via npm (`opencode-ai`) |
|
||||||
|
| Aider | Alternative AI coding agent | pip: `aider-chat` |
|
||||||
|
| Claude Code | Cloud AI coding agent | Optional; can point at Ollama |
|
||||||
|
| VS Code | Editor | With Continue extension for inline chat |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
VS Code
|
||||||
|
├── Continue Extension ──────────────┐
|
||||||
|
├── Claude Code CLI ────────────────┤
|
||||||
|
└── OpenCode / Aider ───────────────┤
|
||||||
|
▼
|
||||||
|
Ollama
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Qwen3-Coder 30B (primary)
|
||||||
|
```
|
||||||
|
|
||||||
|
All code stays local. Cloud models are optional fallback only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ollama — Available Models
|
||||||
|
|
||||||
|
| Model | Size | Use |
|
||||||
|
|-------|------|-----|
|
||||||
|
| `qwen3-coder:30b-fixed` | 18 GB | **Primary** — OpenCode agent (fixed config, see below) |
|
||||||
|
| `qwen3-coder:30b` | 18 GB | Original pull (unmodified) |
|
||||||
|
| `gemma4:latest` | 9.6 GB | English-reliable alternative |
|
||||||
|
| `gemma4:12b` | 7.6 GB | Lighter English-reliable option |
|
||||||
|
| `qwen2.5-coder:14b` | 9.0 GB | Mid-weight coding model |
|
||||||
|
| `qwen2.5-coder:7b` | 4.7 GB | Fast, lightweight coding |
|
||||||
|
| `qwen2.5-coder:1.5b` | 986 MB | Minimal footprint |
|
||||||
|
| `qwen3:latest` | 5.2 GB | General chat |
|
||||||
|
| `qwen3:1.7b` | 1.4 GB | Fast general chat |
|
||||||
|
| `bge-m3:latest` | 1.2 GB | Embeddings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenCode Configuration
|
||||||
|
|
||||||
|
**Config file:** `~/.config/opencode/opencode.jsonc`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"ollama": {
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://localhost:11434/v1"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"qwen3-coder:30b-fixed": { "name": "Qwen3 Coder 30B" },
|
||||||
|
"qwen2.5-coder:14b": { "name": "Qwen 2.5 Coder 14B" },
|
||||||
|
"qwen2.5-coder:7b": { "name": "Qwen 2.5 Coder 7B" },
|
||||||
|
"gemma4:latest": { "name": "Gemma 4 8B (latest)" },
|
||||||
|
"qwen3:latest": { "name": "Qwen3 8B" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": "ollama/qwen3-coder:30b-fixed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Model — qwen3-coder:30b-fixed Modelfile
|
||||||
|
|
||||||
|
```
|
||||||
|
FROM <local blob: sha256-9124a16eb1a7...>
|
||||||
|
TEMPLATE {{ .Prompt }}
|
||||||
|
PARAMETER repeat_penalty 1.05
|
||||||
|
PARAMETER stop <|im_start|>
|
||||||
|
PARAMETER stop <|im_end|>
|
||||||
|
PARAMETER stop <|endoftext|>
|
||||||
|
PARAMETER temperature 0.7
|
||||||
|
PARAMETER top_k 20
|
||||||
|
PARAMETER top_p 0.8
|
||||||
|
PARAMETER num_ctx 32768 ← fixed (was missing)
|
||||||
|
PARAMETER num_predict 8192 ← fixed (was missing)
|
||||||
|
```
|
||||||
|
|
||||||
|
Model specs: 30.5B params, MoE architecture (qwen3moe), Q4_K_M quantization, supports tools + completion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue & Fix — Tool Call JSON Truncation
|
||||||
|
|
||||||
|
### Error
|
||||||
|
|
||||||
|
```
|
||||||
|
llama-server returned invalid tool call arguments for "edit":
|
||||||
|
unexpected end of ... [retrying in 51s attempt N]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
Two Ollama parameters were missing from the model's Modelfile:
|
||||||
|
|
||||||
|
| Parameter | Default when missing | Required for tool calls |
|
||||||
|
|-----------|---------------------|------------------------|
|
||||||
|
| `num_ctx` | **2048 tokens** | Minimum ~8192; 32768 recommended |
|
||||||
|
| `num_predict` | **128 tokens** | Minimum ~1024; 8192 recommended |
|
||||||
|
|
||||||
|
With only 128 output tokens allowed, the model began generating valid JSON for the `edit` tool call but ran out of budget before closing all braces, producing malformed JSON. The 2048-token context also filled up quickly during long sessions, compounding the problem.
|
||||||
|
|
||||||
|
### Fix Applied
|
||||||
|
|
||||||
|
Rebuilt `qwen3-coder:30b-fixed` with the two parameters added:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export current Modelfile, append fixes, rebuild
|
||||||
|
ollama show qwen3-coder:30b-fixed --modelfile > Modelfile_fixed
|
||||||
|
# (add num_ctx 32768 and num_predict 8192)
|
||||||
|
ollama create qwen3-coder:30b-fixed -f Modelfile_fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes needed to the OpenCode config — same model name, same config file.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama show qwen3-coder:30b-fixed
|
||||||
|
# Should show: num_ctx 32768 and num_predict 8192 in Parameters section
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aider Configuration
|
||||||
|
|
||||||
|
**Config file:** `.aider.conf.yml` in project root
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
model: ollama/qwen3-coder:30b
|
||||||
|
openai-api-base: http://localhost:11434/v1
|
||||||
|
openai-api-key: ollama
|
||||||
|
edit-format: whole
|
||||||
|
map-tokens: 2048
|
||||||
|
no-auto-commits: true
|
||||||
|
system-prompt: "Always respond in English only. Never use Chinese or any other language."
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Why |
|
||||||
|
|--------|-----|
|
||||||
|
| `edit-format: whole` | More reliable file writes for local models |
|
||||||
|
| `map-tokens: 2048` | Limits repo map size for smaller context windows |
|
||||||
|
| `no-auto-commits: true` | Review changes before they hit git |
|
||||||
|
| `system-prompt` | Qwen3 defaults to Chinese; forces English |
|
||||||
|
|
||||||
|
### Aider Quick Reference
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/add src/foo.py` | Add file to context |
|
||||||
|
| `/drop src/foo.py` | Remove file from context |
|
||||||
|
| `/diff` | See pending changes |
|
||||||
|
| `/undo` | Revert last change |
|
||||||
|
| `/run pytest` | Run command, share output with model |
|
||||||
|
| `/clear` | Clear chat history (keep files) |
|
||||||
|
| `/settings` | Show loaded config |
|
||||||
|
| `/model ollama/gemma4:12b` | Switch model on the fly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues & Fixes
|
||||||
|
|
||||||
|
### Tool call JSON truncated (OpenCode / llama-server)
|
||||||
|
**Error:** `llama-server returned invalid tool call arguments for "edit": unexpected end of...`
|
||||||
|
**Cause:** `num_predict` too low (default 128) cuts off JSON mid-generation.
|
||||||
|
**Fix:** See "Issue & Fix" section above — add `num_ctx` and `num_predict` to Modelfile.
|
||||||
|
|
||||||
|
### Model responds in Chinese
|
||||||
|
**Cause:** Qwen3 is a Chinese model and defaults to Chinese.
|
||||||
|
**Fix (Aider):** `system-prompt` in `.aider.conf.yml`.
|
||||||
|
**Fix (OpenCode):** Add a system prompt in OpenCode's project config if supported, or use `gemma4` models which default to English.
|
||||||
|
|
||||||
|
### Aider writes code to chat but not to disk
|
||||||
|
**Cause:** Model didn't follow Aider's edit format.
|
||||||
|
**Fix 1:** `/add target_file.py` before the request — hints the target.
|
||||||
|
**Fix 2:** `edit-format: whole` in config (already included above).
|
||||||
|
|
||||||
|
### Config changes not taking effect (Aider)
|
||||||
|
**Fix:** Exit (`/exit`) and restart. Config is read only at startup.
|
||||||
|
|
||||||
|
### "File not found" / repo-map warnings for deleted files
|
||||||
|
**Cause:** File deleted from disk but still tracked in git index.
|
||||||
|
**Fix:**
|
||||||
|
```bash
|
||||||
|
git rm --cached filename.py
|
||||||
|
# or from inside Aider:
|
||||||
|
/run git rm --cached filename.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model loops with filler text, never executes tool calls
|
||||||
|
**Symptom:** Model repeatedly says "I'll continue..." but never reads/edits files. Context counter in OpenCode bottom-left shows a number near your `num_ctx` limit.
|
||||||
|
**Cause:** Context window is full. No tokens left for tool call JSON, so the model outputs short filler and stalls.
|
||||||
|
**Fix (immediate):** Start a new OpenCode session. Break large tasks (e.g. "translate all files") into one file per session.
|
||||||
|
**Fix (permanent):** Increase `num_ctx` in the Modelfile (e.g. 65536), rebuild, restart Ollama. Watch RAM usage — each doubling of `num_ctx` roughly doubles KV cache memory.
|
||||||
|
|
||||||
|
### Context fills up, model performance degrades
|
||||||
|
**Cause:** Long session accumulates tokens beyond `num_ctx`.
|
||||||
|
**Fix (Aider):** `/clear` to reset chat history without losing file context.
|
||||||
|
**Fix (Ollama model):** Increase `num_ctx` in Modelfile (current: 32768).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- qwen3-coder:30b is a MoE model — fits in VRAM+RAM split on RTX 5070 Ti (12 GB VRAM).
|
||||||
|
- `num_gpu 99` in Modelfile maximizes layers on VRAM; remainder spills to RAM.
|
||||||
|
- Safe `num_ctx` range for this hardware: **8192–32768**. Above 32768 risks RAM pressure.
|
||||||
|
- Q4_K_M quantization gives best quality/speed/memory balance for this size class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Verification Checklist
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama list # models appear
|
||||||
|
ollama show qwen3-coder:30b-fixed # num_ctx and num_predict present
|
||||||
|
curl http://localhost:11434/api/tags # Ollama serving
|
||||||
|
aider --version # Aider installed
|
||||||
|
opencode --version # OpenCode installed
|
||||||
|
nvidia-smi # GPU visible
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user