{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0850c358",
   "metadata": {},
   "source": [
    "# Ch 15 — Transformers from Scratch (notebook)\n",
    "\n",
    "`[← 14 nlp-with-rnns-and-attention]` · **this notebook** · `[16 multimodal-transformers →]`\n",
    "\n",
    "Runs top-to-bottom in ~3 min on free Colab CPU. Last verified 2026-06-11.\n",
    "\n",
    "**What you'll build**\n",
    "- A causal self-attention head from raw tensors, with the upper-triangular mask built *before* the softmax, and a property test that future tokens cannot leak into the past.\n",
    "- Multi-head attention, a feed-forward block, pre-norm residual blocks, and then the whole GPT assembled in one cell, weight-tied, with a param-count and a shape smoke test after every module.\n",
    "- A tiny char-level GPT trained on Tiny Shakespeare until it generates Shakespeare-flavoured text, plus a deliberate failure (the default-init logit blow-up) you watch break and then fix.\n",
    "- A sampling suite (greedy, temperature, top-k, top-p) checked with statistical sanity tests, not vibes.\n",
    "\n",
    "**How this notebook works.** Code cells with a `# TODO` are yours to fill in. Run the cell to grade yourself: `[ ok ]` passed, `[FAIL]` shows what went wrong, `[ -- ]` means not attempted yet. Every exercise has a hint ladder (open only as many as you need) and a folded solution below it. The notebook runs top-to-bottom even if you fill in nothing, because the folded solutions redefine the pieces the later cells need. See Ch 00 for the full protocol.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8ca65ffe",
   "metadata": {},
   "source": [
    "## Before you start\n",
    "\n",
    "Three quick probes. Answers are in the dropdowns; they set up the three ideas this notebook leans on hardest.\n",
    "\n",
    "1. Attention computes `softmax(scores) @ V`. Along which axis does the softmax run, and what does each resulting row sum to? <details><summary>Answer</summary>Along the *key* axis (the last axis of a `(..., q, k)` score tensor). Each query's row of weights sums to 1, because each query forms a probability distribution over the keys it is allowed to look at.</details>\n",
    "2. You want an autoregressive model. Why must position $i$ be forbidden from attending to position $j > i$, and why set the masked scores to $-\\infty$ rather than $0$? <details><summary>Answer</summary>If a position could read the future, it would copy the answer at training time and fall apart at inference, when the future does not exist yet. You mask *before* the softmax with $-\\infty$ so those positions become exactly $0$ probability. Setting the post-softmax weights to $0$ instead leaves the denominator wrong and the rows no longer sum to 1.</details>\n",
    "3. Predict before you run: a freshly initialised language model over a vocabulary of $V$ characters, before any training, should have a cross-entropy loss near what number? <details><summary>Answer</summary>About $\\ln V$. A model that knows nothing assigns roughly uniform probability $1/V$ to every token, and $-\\ln(1/V) = \\ln V$. For our 56-character vocabulary that is $\\approx 4.0$. If your step-0 loss is wildly higher (say 80), your initialisation is broken, not your data. That is the deliberate failure in Part 6.</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d856e430",
   "metadata": {},
   "source": [
    "## Setup\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "92ac4898",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:00.523471Z",
     "iopub.status.busy": "2026-06-10T19:31:00.523400Z",
     "iopub.status.idle": "2026-06-10T19:31:04.413220Z",
     "shell.execute_reply": "2026-06-10T19:31:04.412435Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "numpy 2.2.6 · torch 2.12.0+cpu · device cpu\n"
     ]
    }
   ],
   "source": [
    "import numpy as np\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "import matplotlib.pyplot as plt\n",
    "device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n",
    "print(f\"numpy {np.__version__} · torch {torch.__version__} · device {device}\")\n",
    "if np.__version__ < \"2.0\":\n",
    "    print(\"WARN: written for NumPy 2.x; older versions may differ slightly\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "e68146a3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.415235Z",
     "iopub.status.busy": "2026-06-10T19:31:04.415052Z",
     "iopub.status.idle": "2026-06-10T19:31:04.447553Z",
     "shell.execute_reply": "2026-06-10T19:31:04.447187Z"
    }
   },
   "outputs": [],
   "source": [
    "import os, math, random\n",
    "SEED = 0\n",
    "FAST = bool(os.environ.get('NB_FAST'))   # CI smoke mode: ~10x fewer steps, same code paths\n",
    "TRAIN_STEPS = 80 if FAST else 800        # tiny-GPT train length; expected loss logged in Part 6\n",
    "GEN_SEED = SEED + 10                      # offset seed for sampling, so generation never perturbs training reproducibility\n",
    "rng = np.random.default_rng(SEED)\n",
    "torch.manual_seed(SEED); random.seed(SEED)\n",
    "\n",
    "# ── house self-check harness (identical across all chapter notebooks) ──\n",
    "import numpy as _np\n",
    "\n",
    "def check(label, test_fn, required=False):\n",
    "    \"\"\"Run one self-check. test_fn raises AssertionError (with a teaching\n",
    "    message) on failure, NotImplementedError if the stub is unfilled.\n",
    "    required=True is used only in solution cells; it is what CI grades.\"\"\"\n",
    "    try:\n",
    "        test_fn()\n",
    "    except NotImplementedError:\n",
    "        if required:\n",
    "            raise AssertionError(f\"{label}: reference solution incomplete\")\n",
    "        print(f\"[ -- ] {label}: not attempted yet — fill in the TODO above, then re-run.\")\n",
    "        return False\n",
    "    except AssertionError as e:\n",
    "        if required:\n",
    "            raise\n",
    "        print(f\"[FAIL] {label}: {e}\")\n",
    "        return False\n",
    "    print(f\"[ ok ] {label}\")\n",
    "    return True\n",
    "\n",
    "def attempted(*vals):\n",
    "    \"\"\"Treat None placeholders as 'not attempted'.\"\"\"\n",
    "    if any(v is None for v in vals):\n",
    "        raise NotImplementedError\n",
    "\n",
    "def check_shape(x, want):\n",
    "    assert tuple(x.shape) == tuple(want), \\\n",
    "        f\"shape {tuple(x.shape)}, expected {tuple(want)} — check your reshape/transpose order\"\n",
    "\n",
    "def check_close(got, want, atol=1e-5, rtol=1e-4, msg=\"\"):\n",
    "    g, w = _np.asarray(got, dtype=float), _np.asarray(want, dtype=float)\n",
    "    assert g.shape == w.shape, f\"shape {g.shape} vs expected {w.shape}. {msg}\"\n",
    "    bad = ~_np.isclose(g, w, atol=atol, rtol=rtol)\n",
    "    assert not bad.any(), \\\n",
    "        f\"{bad.mean():.2%} of values wrong (max diff {abs(g - w).max():.3g}). {msg}\"\n",
    "\n",
    "# ── small house helpers (defined once, never imported) ──\n",
    "def param_count(module):\n",
    "    \"\"\"Total trainable parameters, counting tied weights once.\"\"\"\n",
    "    seen, total = set(), 0\n",
    "    for p in module.parameters():\n",
    "        if id(p) in seen:\n",
    "            continue\n",
    "        seen.add(id(p)); total += p.numel()\n",
    "    return total\n",
    "\n",
    "def smoke(module, in_shape, out_shape, dtype=torch.float32):\n",
    "    \"\"\"Run a module on random input and assert the output shape. Returns the output.\"\"\"\n",
    "    x = torch.randn(*in_shape) if dtype.is_floating_point else torch.randint(0, in_shape[-1], in_shape[:-1])\n",
    "    y = module(x)\n",
    "    check_shape(y, out_shape)\n",
    "    return y\n",
    "\n",
    "def check_causal(attn_fn, B=2, H=2, T=6, C=16):\n",
    "    \"\"\"Property test: perturbing future tokens must not change earlier outputs.\"\"\"\n",
    "    torch.manual_seed(SEED)\n",
    "    x1 = torch.randn(B, T, C)\n",
    "    x2 = x1.clone(); x2[:, T // 2:, :] = torch.randn(B, T - T // 2, C)\n",
    "    y1, y2 = attn_fn(x1), attn_fn(x2)\n",
    "    diff = (y1[:, :T // 2, :] - y2[:, :T // 2, :]).abs().max().item()\n",
    "    assert diff < 1e-5, (\n",
    "        f'future leaked into the past: changing tokens {T // 2}.. shifted earlier outputs by {diff:.2e}. '\n",
    "        'Your mask is wrong or applied after softmax.')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8143e78d",
   "metadata": {},
   "source": [
    "> **Note:** seeds make this notebook's printed numbers reproduce on CPU. Library versions and BLAS threading can shift the last digit or two, and CUDA reductions are not bitwise reproducible, so the GPU appendix disclaims exact numbers. Quoted losses hold for the pinned CPU environment. If your step-600 loss is 0.61 and the page says 0.59, you did nothing wrong.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d6a7b9e1",
   "metadata": {},
   "source": [
    "## The map\n",
    "\n",
    "> **Part 1 — Text to integers.** Char-level encode/decode the anchor corpus, then merge byte pairs (BPE) by hand and watch the sequence shrink.\n",
    "> **Part 2 — Embeddings.** Turn integer ids into vectors with a lookup table, and decide whether to tie the unembedding to the embedding.\n",
    "> **Part 3 — One attention head.** Scaled dot-product attention from scratch, the $\\sqrt{d_k}$ scaling, the softmax axis, then the causal mask built *before* the softmax with a leak test.\n",
    "> **Part 4 — Multi-head, FFN, norm, residual.** The four supporting modules, each with a shape smoke test and a hand-checked property.\n",
    "> **Part 5 — Assemble the GPT.** Stack pre-norm blocks, add positions, weight-tie the head, and verify the assembled model end to end.\n",
    "> **Part 6 — Train and sample.** A deliberate init failure, the fix, a real training run on Tiny Shakespeare, an experiment log, and a sampling suite with statistical checks.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "54d68499",
   "metadata": {},
   "source": [
    "## Part 1 — Text to integers: tokenization\n",
    "\n",
    "> **Objectives.** Build a reversible char-level codec for the anchor corpus, then implement the two primitives of byte-pair encoding (pair counting and merging) and measure the compression they buy. Artifact: an `encode`/`decode` pair you trust, and a hand-run BPE that shrinks the token sequence.\n",
    "\n",
    "A transformer never sees text. It sees integers, which it turns into vectors. The tokenizer does the text-to-integers half, and it is the first real design decision: character-level is simple but long, word-level explodes and chokes on misspellings, and byte-pair encoding (what GPT-2 and most modern models use) sits between them. We will train a *char-level* model here because it fits on a CPU in seconds, then build the BPE primitives by hand so the idea is not a black box.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d0440de3",
   "metadata": {},
   "source": [
    "First the anchor corpus. We embed a clean ~9KB excerpt of Tiny Shakespeare directly in the notebook so the canonical path never touches the network (the full 1MB file is an optional fetch at the end of Part 6). Embedding the data is what makes the notebook offline-reproducible.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "91f7f48a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.449073Z",
     "iopub.status.busy": "2026-06-10T19:31:04.448908Z",
     "iopub.status.idle": "2026-06-10T19:31:04.460190Z",
     "shell.execute_reply": "2026-06-10T19:31:04.459694Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "8991 characters, 56 unique\n",
      "First Citizen:\n",
      "Before we proceed any further, hear me speak.\n",
      "\n",
      "All:\n",
      "Speak, speak.\n",
      "\n",
      "First Citizen:\n",
      "You are all resolved ra\n"
     ]
    }
   ],
   "source": [
    "# the anchor corpus, embedded verbatim so the notebook is offline-reproducible\n",
    "TEXT = \"\"\"First Citizen:\n",
    "Before we proceed any further, hear me speak.\n",
    "\n",
    "All:\n",
    "Speak, speak.\n",
    "\n",
    "First Citizen:\n",
    "You are all resolved rather to die than to famish?\n",
    "\n",
    "All:\n",
    "Resolved. resolved.\n",
    "\n",
    "First Citizen:\n",
    "First, you know Caius Marcius is chief enemy to the people.\n",
    "\n",
    "All:\n",
    "We know't, we know't.\n",
    "\n",
    "First Citizen:\n",
    "Let us kill him, and we'll have corn at our own price.\n",
    "Is't a verdict?\n",
    "\n",
    "All:\n",
    "No more talking on't; let it be done: away, away!\n",
    "\n",
    "Second Citizen:\n",
    "One word, good citizens.\n",
    "\n",
    "First Citizen:\n",
    "We are accounted poor citizens, the patricians good.\n",
    "What authority surfeits on would relieve us: if they\n",
    "would yield us but the superfluity, while it were\n",
    "wholesome, we might guess they relieved us humanely;\n",
    "but they think we are too dear: the leanness that\n",
    "afflicts us, the object of our misery, is as an\n",
    "inventory to particularise their abundance; our\n",
    "sufferance is a gain to them Let us revenge this with\n",
    "our pikes, ere we become rakes: for the gods know I\n",
    "speak this in hunger for bread, not in thirst for revenge.\n",
    "\n",
    "Second Citizen:\n",
    "Would you proceed especially against Caius Marcius?\n",
    "\n",
    "All:\n",
    "Against him first: he's a very dog to the commonalty.\n",
    "\n",
    "Second Citizen:\n",
    "Consider you what services he has done for his country?\n",
    "\n",
    "First Citizen:\n",
    "Very well; and could be content to give him good\n",
    "report fort, but that he pays himself with being proud.\n",
    "\n",
    "Second Citizen:\n",
    "Nay, but speak not maliciously.\n",
    "\n",
    "First Citizen:\n",
    "I say unto you, what he hath done famously, he did\n",
    "it to that end: though soft-conscienced men can be\n",
    "content to say it was for his country he did it to\n",
    "please his mother and to be partly proud; which he\n",
    "is, even till the altitude of his virtue.\n",
    "\n",
    "Second Citizen:\n",
    "What he cannot help in his nature, you account a\n",
    "vice in him. You must in no way say he is covetous.\n",
    "\n",
    "First Citizen:\n",
    "If I must not, I need not be barren of accusations;\n",
    "he hath faults, with surplus, to tire in repetition.\n",
    "What shouts are these? The other side o' the city\n",
    "is risen: why stay we prating here? to the Capitol!\n",
    "\n",
    "All:\n",
    "Come, come.\n",
    "\n",
    "First Citizen:\n",
    "Soft! who comes here?\n",
    "\n",
    "Second Citizen:\n",
    "Worthy Menenius Agrippa; one that hath always loved\n",
    "the people.\n",
    "\n",
    "First Citizen:\n",
    "He's one honest enough: would all the rest were so!\n",
    "\n",
    "MENENIUS:\n",
    "What work's, my countrymen, in hand? where go you\n",
    "With bats and clubs? The matter? speak, I pray you.\n",
    "\n",
    "First Citizen:\n",
    "Our business is not unknown to the senate; they have\n",
    "had inkling this fortnight what we intend to do,\n",
    "which now we'll show 'em in deeds. They say poor\n",
    "suitors have strong breaths: they shall know we\n",
    "have strong arms too.\n",
    "\n",
    "MENENIUS:\n",
    "Why, masters, my good friends, mine honest neighbours,\n",
    "Will you undo yourselves?\n",
    "\n",
    "First Citizen:\n",
    "We cannot, sir, we are undone already.\n",
    "\n",
    "MENENIUS:\n",
    "I tell you, friends, most charitable care\n",
    "Have the patricians of you. For your wants,\n",
    "Your suffering in this dearth, you may as well\n",
    "Strike at the heaven with your staves as lift them\n",
    "Against the Roman state, whose course will on\n",
    "The way it takes, cracking ten thousand curbs\n",
    "Of more strong link asunder than can ever\n",
    "Appear in your impediment. For the dearth,\n",
    "The gods, not the patricians, make it, and\n",
    "Your knees to them, not arms, must help. Alack,\n",
    "You are transported by calamity\n",
    "Thither where more attends you, and you slander\n",
    "The helms o' the state, who care for you like fathers,\n",
    "When you curse them as enemies.\n",
    "\n",
    "First Citizen:\n",
    "Care for us! True, indeed! They ne'er cared for us\n",
    "yet: suffer us to famish, and their store-houses\n",
    "crammed with grain; make edicts for usury, to\n",
    "support usurers; repeal daily any wholesome act\n",
    "established against the rich, and provide more\n",
    "piercing statutes daily, to chain up and restrain\n",
    "the poor. If the wars eat us not up, they will; and\n",
    "there's all the love they bear us.\n",
    "\n",
    "MENENIUS:\n",
    "Either you must\n",
    "Confess yourselves wondrous malicious,\n",
    "Or be accused of folly. I shall tell you\n",
    "A pretty tale: it may be you have heard it;\n",
    "But, since it serves my purpose, I will venture\n",
    "To stale 't a little more.\n",
    "\n",
    "First Citizen:\n",
    "Well, I'll hear it, sir: yet you must not think to\n",
    "fob off our disgrace with a tale: but, an 't please\n",
    "you, deliver.\n",
    "\n",
    "MENENIUS:\n",
    "There was a time when all the body's members\n",
    "Rebell'd against the belly, thus accused it:\n",
    "That only like a gulf it did remain\n",
    "I' the midst o' the body, idle and unactive,\n",
    "Still cupboarding the viand, never bearing\n",
    "Like labour with the rest, where the other instruments\n",
    "Did see and hear, devise, instruct, walk, feel,\n",
    "And, mutually participate, did minister\n",
    "Unto the appetite and affection common\n",
    "Of the whole body. The belly answer'd--\n",
    "\n",
    "First Citizen:\n",
    "Well, sir, what answer made the belly?\n",
    "\n",
    "MENENIUS:\n",
    "Sir, I shall tell you. With a kind of smile,\n",
    "Which ne'er came from the lungs, but even thus--\n",
    "For, look you, I may make the belly smile\n",
    "As well as speak--it tauntingly replied\n",
    "To the discontented members, the mutinous parts\n",
    "That envied his receipt; even so most fitly\n",
    "As you malign our senators for that\n",
    "They are not such as you.\n",
    "\n",
    "First Citizen:\n",
    "Your belly's answer? What!\n",
    "The kingly-crowned head, the vigilant eye,\n",
    "The counsellor heart, the arm our soldier,\n",
    "Our steed the leg, the tongue our trumpeter.\n",
    "With other muniments and petty helps\n",
    "In this our fabric, if that they--\n",
    "\n",
    "MENENIUS:\n",
    "What then?\n",
    "'Fore me, this fellow speaks! What then? what then?\n",
    "\n",
    "First Citizen:\n",
    "Should by the cormorant belly be restrain'd,\n",
    "Who is the sink o' the body,--\n",
    "\n",
    "MENENIUS:\n",
    "Well, what then?\n",
    "\n",
    "First Citizen:\n",
    "The former agents, if they did complain,\n",
    "What could the belly answer?\n",
    "\n",
    "MENENIUS:\n",
    "I will tell you\n",
    "If you'll bestow a small--of what you have little--\n",
    "Patience awhile, you'll hear the belly's answer.\n",
    "\n",
    "First Citizen:\n",
    "Ye're long about it.\n",
    "\n",
    "MENENIUS:\n",
    "Note me this, good friend;\n",
    "Your most grave belly was deliberate,\n",
    "Not rash like his accusers, and thus answer'd:\n",
    "'True is it, my incorporate friends,' quoth he,\n",
    "'That I receive the general food at first,\n",
    "Which you do live upon; and fit it is,\n",
    "Because I am the store-house and the shop\n",
    "Of the whole body: but, if you do remember,\n",
    "I send it through the rivers of your blood,\n",
    "Even to the court, the heart, to the seat o' the brain;\n",
    "And, through the cranks and offices of man,\n",
    "The strongest nerves and small inferior veins\n",
    "From me receive that natural competency\n",
    "Whereby they live: and though that all at once,\n",
    "You, my good friends,'--this says the belly, mark me,--\n",
    "\n",
    "First Citizen:\n",
    "Ay, sir; well, well.\n",
    "\n",
    "MENENIUS:\n",
    "'Though all at once cannot\n",
    "See what I do deliver out to each,\n",
    "Yet I can make my audit up, that all\n",
    "From me do back receive the flour of all,\n",
    "And leave me but the bran.' What say you to't?\n",
    "\n",
    "First Citizen:\n",
    "It was an answer: how apply you this?\n",
    "\n",
    "MENENIUS:\n",
    "The senators of Rome are this good belly,\n",
    "And you the mutinous members; for examine\n",
    "Their counsels and their cares, digest things rightly\n",
    "Touching the weal o' the common, you shall find\n",
    "No public benefit which you receive\n",
    "But it proceeds or comes from them to you\n",
    "And no way from yourselves. What do you think,\n",
    "You, the great toe of this assembly?\n",
    "\n",
    "First Citizen:\n",
    "I the great toe! why the great toe?\n",
    "\n",
    "MENENIUS:\n",
    "For that, being one o' the lowest, basest, poorest,\n",
    "Of this most wise rebellion, thou go'st foremost:\n",
    "Thou rascal, that art worst in blood to run,\n",
    "Lead'st first to win some vantage.\n",
    "But make you ready your stiff bats and clubs:\n",
    "Rome and her rats are at the point of battle;\n",
    "The one side must have bale.\n",
    "Hail, noble Marcius!\n",
    "\n",
    "MARCIUS:\n",
    "Thanks. What's the matter, you dissentious rogues,\n",
    "That, rubbing the poor itch of your opinion,\n",
    "Make yourselves scabs?\n",
    "\n",
    "First Citizen:\n",
    "We have ever your good word.\n",
    "\n",
    "MARCIUS:\n",
    "He that will give good words to thee will flatter\n",
    "Beneath abhorring. What would you have, you curs,\n",
    "That like nor peace nor war? the one affrights you,\n",
    "The other makes you proud. He that trusts to you,\n",
    "Where he should find you lions, finds you hares;\n",
    "Where foxes, geese: you are no surer, no,\n",
    "Than is the coal of fire upon the ice,\n",
    "Or hailstone in the sun. Your virtue is\n",
    "To make him worthy whose offence subdues him\n",
    "And curse that justice did it.\n",
    "Who deserves greatness\n",
    "Deserves your hate; and your affections are\n",
    "A sick man's appetite, who desires most that\n",
    "Which would increase his evil. He that depends\n",
    "Upon your favours swims with fins of lead\n",
    "And hews down oaks with rushes. Hang ye! Trust Ye?\n",
    "With every minute you do change a mind,\n",
    "And call him noble that was now your hate,\n",
    "Him vile that was your garland. What's the matter,\n",
    "That in these several places of the city\n",
    "You cry against the noble senate, who,\n",
    "Under the gods, keep you in awe, which else\n",
    "Would feed on one another? What's their seeking?\n",
    "\n",
    "MENENIUS:\n",
    "For corn at their own rates; whereof, they say,\n",
    "The city is well stored.\n",
    "\n",
    "MARCIUS:\n",
    "Hang 'em! They say!\n",
    "They'll sit by the fire, and presume to know\n",
    "What's done i' the Capitol; who's like to rise,\n",
    "Who thrives and who declines; side factions\n",
    "and give out\n",
    "Conjectural marriages; making parties strong\n",
    "And feebling such as stand not in their liking\n",
    "Below their cobbled shoes. They say there's\n",
    "grain enough!\n",
    "Would the nobility lay aside their ruth,\n",
    "And let me use my sword, I'll make a quarry\"\"\"\n",
    "print(f\"{len(TEXT)} characters, {len(set(TEXT))} unique\")\n",
    "print(TEXT[:120])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a4b02997",
   "metadata": {},
   "source": [
    "> **Interpretation.** A few thousand lines of dialogue, 56 distinct characters (letters, punctuation, newline). Small enough to memorise, large enough that the model has to learn real structure (line breaks, speaker tags, word shapes) to do well.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "32e644fe",
   "metadata": {},
   "source": [
    "The char-level codec is two dictionaries: `stoi` (string-to-integer) maps each unique character to an index, `itos` inverts it. Encoding is a list comprehension; decoding is a join. The vocabulary size is just the number of distinct characters.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "a468c4db",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.462839Z",
     "iopub.status.busy": "2026-06-10T19:31:04.462740Z",
     "iopub.status.idle": "2026-06-10T19:31:04.466804Z",
     "shell.execute_reply": "2026-06-10T19:31:04.465452Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "vocab size 56\n",
      "encode('To be'): [25, 44, 1, 31, 34]\n",
      "round-trip: To be, or not\n"
     ]
    }
   ],
   "source": [
    "chars = sorted(set(TEXT))\n",
    "VOCAB = len(chars)\n",
    "stoi = {c: i for i, c in enumerate(chars)}\n",
    "itos = {i: c for c, i in stoi.items()}\n",
    "\n",
    "def encode(s):      # str -> list[int]\n",
    "    return [stoi[c] for c in s]\n",
    "\n",
    "def decode(ids):    # list[int] -> str\n",
    "    return \"\".join(itos[int(i)] for i in ids)\n",
    "\n",
    "print(f\"vocab size {VOCAB}\")\n",
    "print(\"encode('To be'):\", encode(\"To be\"))\n",
    "print(\"round-trip:\", decode(encode(\"To be, or not\")))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e66478b5",
   "metadata": {},
   "source": [
    "> **Interpretation.** The codec is a bijection on characters in the corpus. The model will operate entirely on these integers; the strings are only for our eyes. The round-trip is the first thing to assert, which is exactly the next exercise.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b542aa7f",
   "metadata": {},
   "source": [
    "### Exercise 15.1 — Prove the codec round-trips\n",
    "`Difficulty 1/5 · ~5 min`\n",
    "\n",
    "Write `roundtrips(s)` that returns `True` iff `decode(encode(s)) == s` for any string drawn from the corpus alphabet. Then the check feeds it a few strings, including the whole corpus, and asserts it holds. A codec that silently drops or reorders characters is a bug you want to catch in one second, not after a two-minute training run.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "8a0432aa",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.473112Z",
     "iopub.status.busy": "2026-06-10T19:31:04.472987Z",
     "iopub.status.idle": "2026-06-10T19:31:04.487609Z",
     "shell.execute_reply": "2026-06-10T19:31:04.487263Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.1 codec round-trip: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 5,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def roundtrips(s):\n",
    "    \"\"\"Return True iff decoding the encoding of s recovers s exactly.\"\"\"\n",
    "    # TODO 1: encode s, decode the result, compare to s\n",
    "    result = None\n",
    "    attempted(result)\n",
    "    return result\n",
    "\n",
    "def _codec():\n",
    "    for s in [\"To be\", \"First Citizen:\", \"\\n\\n\", TEXT[:500]]:\n",
    "        assert roundtrips(s), f\"round-trip failed on {s[:20]!r}: decode(encode(s)) != s\"\n",
    "    assert roundtrips(TEXT), \"round-trip failed on the full corpus\"\n",
    "\n",
    "check(\"15.1 codec round-trip\", _codec)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "337cb9c3",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>You already have `encode` and `decode`. Compose them and compare to the input with `==`.</details>\n",
    "\n",
    "<details><summary>Hint 2 (the line)</summary>`return decode(encode(s)) == s`.</details>\n",
    "\n",
    "<details><summary>Help — \"KeyError\" inside encode</summary>Your string contains a character not in the corpus alphabet (an emoji, a curly quote). The char-level vocabulary is exactly the characters in `TEXT`; stick to those, or extend the vocabulary deliberately.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "94596ef2",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.488764Z",
     "iopub.status.busy": "2026-06-10T19:31:04.488681Z",
     "iopub.status.idle": "2026-06-10T19:31:04.498285Z",
     "shell.execute_reply": "2026-06-10T19:31:04.497668Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.1 codec round-trip\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 6,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines roundtrips; the check below re-verifies the reference.\n",
    "def roundtrips(s):\n",
    "    return decode(encode(s)) == s\n",
    "\n",
    "check(\"15.1 codec round-trip\", _codec, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "432cfb20",
   "metadata": {},
   "source": [
    "Char-level is fine for us, but real models use **byte-pair encoding**: start from single tokens, repeatedly find the most frequent adjacent pair, and merge it into one new token. Repeat thousands of times and common words become single tokens while rare words split into subwords. Two primitives do all the work: counting adjacent pairs, and merging one chosen pair everywhere it occurs. We will build both and run a few merges on the corpus.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "6085b340",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.499269Z",
     "iopub.status.busy": "2026-06-10T19:31:04.499169Z",
     "iopub.status.idle": "2026-06-10T19:31:04.505243Z",
     "shell.execute_reply": "2026-06-10T19:31:04.504159Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Counter({(1, 2): 2, (2, 1): 1, (2, 3): 1})\n"
     ]
    }
   ],
   "source": [
    "from collections import Counter\n",
    "\n",
    "def get_pair_counts(seq):\n",
    "    \"\"\"Count adjacent pairs (seq[i], seq[i+1]) in a list of token ids.\"\"\"\n",
    "    return Counter(zip(seq, seq[1:]))\n",
    "\n",
    "# micro-demo on a hand-checkable toy: pairs in [1,2,1,2,3]\n",
    "toy = [1, 2, 1, 2, 3]\n",
    "print(get_pair_counts(toy))   # expect (1,2) twice, (2,1) once, (2,3) once"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8d6b7048",
   "metadata": {},
   "source": [
    "> **Interpretation.** `(1, 2)` occurs twice, the others once. Pair counting is just a sliding window of length 2. The most common pair is the merge candidate.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77186b04",
   "metadata": {},
   "source": [
    "### Exercise 15.2 — The BPE merge primitive\n",
    "`Difficulty 3/5 · ~15 min`\n",
    "\n",
    "Implement `merge(seq, pair, new_id)`: scan left to right and replace every adjacent occurrence of `pair = (a, b)` with the single token `new_id`, leaving everything else untouched. The subtlety is the scan must skip *two* positions after a merge so it never merges across a token it just created. The check uses a hand-computed toy plus an idempotence property.\n",
    "\n",
    "Harder: after you pass, run `train_bpe` below and read off the compression ratio at 50 and 150 merges.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "becb05f7",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.506230Z",
     "iopub.status.busy": "2026-06-10T19:31:04.506114Z",
     "iopub.status.idle": "2026-06-10T19:31:04.512740Z",
     "shell.execute_reply": "2026-06-10T19:31:04.512185Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.2 merge (toy): not attempted yet — fill in the TODO above, then re-run.\n",
      "[ -- ] 15.2 merge (no-op): not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 8,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def merge(seq, pair, new_id):\n",
    "    \"\"\"Replace every adjacent occurrence of `pair` in `seq` with `new_id`.\"\"\"\n",
    "    out, i = [], 0\n",
    "    while i < len(seq):\n",
    "        # TODO 1: if seq[i:i+2] == [a, b], append new_id and advance i by 2\n",
    "        # TODO 2: otherwise append seq[i] and advance i by 1\n",
    "        raise NotImplementedError\n",
    "    return out\n",
    "\n",
    "def _merge_toy():\n",
    "    # merge (1,2)->9 in [1,2,1,2,3] gives [9,9,3]; the trailing 3 is untouched\n",
    "    got = merge([1, 2, 1, 2, 3], (1, 2), 9)\n",
    "    assert got == [9, 9, 3], f\"merge gave {got}, expected [9, 9, 3] (two (1,2) pairs become 9, the 3 stays)\"\n",
    "    # overlapping case: merge (1,1)->9 in [1,1,1] must give [9,1], NOT [9,9] (no overlap reuse)\n",
    "    got2 = merge([1, 1, 1], (1, 1), 9)\n",
    "    assert got2 == [9, 1], f\"merge gave {got2}, expected [9, 1] — you reused the middle 1 across two merges\"\n",
    "\n",
    "def _merge_noop():\n",
    "    # merging a pair that is absent is a no-op\n",
    "    assert merge([3, 4, 5], (1, 2), 9) == [3, 4, 5], \"merging an absent pair changed the sequence\"\n",
    "\n",
    "check(\"15.2 merge (toy)\", _merge_toy)\n",
    "check(\"15.2 merge (no-op)\", _merge_noop)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6fa9ef9b",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>A `while` loop with a manual index `i`. When the two tokens at `i` and `i+1` are the pair, emit one new token and jump `i += 2`. Otherwise emit one token and `i += 1`. Guard `i + 1 < len(seq)` before peeking ahead.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "a, b = pair\n",
    "while i < len(seq):\n",
    "    if i + 1 < len(seq) and seq[i] == a and seq[i + 1] == b:\n",
    "        out.append(new_id); i += 2\n",
    "    else:\n",
    "        out.append(seq[i]); i += 1\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"[1,1,1] gives [9,9] not [9,1]\"</summary>You advanced by 1 after a merge, so the middle `1` was reused as the start of a second pair. After a merge you must advance by 2. This off-by-one is the same family of bug as the causal-mask off-by-one in Part 3.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "42bff3bb",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.513672Z",
     "iopub.status.busy": "2026-06-10T19:31:04.513590Z",
     "iopub.status.idle": "2026-06-10T19:31:04.524351Z",
     "shell.execute_reply": "2026-06-10T19:31:04.521147Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.2 merge (toy)\n",
      "[ ok ] 15.2 merge (no-op)\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 9,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines merge; the checks below re-verify the reference.\n",
    "def merge(seq, pair, new_id):\n",
    "    a, b = pair\n",
    "    out, i = [], 0\n",
    "    while i < len(seq):\n",
    "        if i + 1 < len(seq) and seq[i] == a and seq[i + 1] == b:\n",
    "            out.append(new_id); i += 2\n",
    "        else:\n",
    "            out.append(seq[i]); i += 1\n",
    "    return out\n",
    "\n",
    "check(\"15.2 merge (toy)\", _merge_toy, required=True)\n",
    "check(\"15.2 merge (no-op)\", _merge_noop, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e2327e5f",
   "metadata": {},
   "source": [
    "Now wire the two primitives into a training loop. Start from raw UTF-8 bytes (ids 0-255), and while the vocabulary is under target, find the most frequent pair, merge it to the next free id, and record the rule. The `> **Predict:**` below asks you to guess the compression before you run it.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7d3df7f7",
   "metadata": {},
   "source": [
    "> **Predict:** with 150 merges on a few thousand characters, will the sequence get shorter by more than 2x or less? <details><summary>Answer</summary>Less than 2x on this tiny corpus (around 1.7x), because there are not many high-frequency pairs to exploit. On a full corpus with 10k merges you reach roughly 4x. Compression is bounded by how repetitive the text is.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "44e6d224",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.525362Z",
     "iopub.status.busy": "2026-06-10T19:31:04.525268Z",
     "iopub.status.idle": "2026-06-10T19:31:04.916615Z",
     "shell.execute_reply": "2026-06-10T19:31:04.915984Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      " 50 merges: 8991 bytes -> 5968 tokens (1.51x compression, vocab 306)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "150 merges: 8991 bytes -> 4583 tokens (1.96x compression, vocab 406)\n"
     ]
    }
   ],
   "source": [
    "def train_bpe(text, num_merges):\n",
    "    \"\"\"Run num_merges BPE merges on UTF-8 bytes; return (token_seq, merges).\"\"\"\n",
    "    seq = list(text.encode(\"utf-8\"))   # ids 0-255\n",
    "    merges, next_id = {}, 256\n",
    "    for _ in range(num_merges):\n",
    "        counts = get_pair_counts(seq)\n",
    "        if not counts:\n",
    "            break\n",
    "        best = counts.most_common(1)[0][0]\n",
    "        seq = merge(seq, best, next_id)\n",
    "        merges[best] = next_id\n",
    "        next_id += 1\n",
    "    return seq, merges\n",
    "\n",
    "raw = list(TEXT.encode(\"utf-8\"))\n",
    "for nm in (50, 150):\n",
    "    seq, merges = train_bpe(TEXT, nm)\n",
    "    print(f\"{nm:3d} merges: {len(raw)} bytes -> {len(seq)} tokens \"\n",
    "          f\"({len(raw) / len(seq):.2f}x compression, vocab {256 + len(merges)})\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4e8184fa",
   "metadata": {},
   "source": [
    "> **Interpretation.** Each merge trades one vocabulary slot for a shorter sequence. The first merges (likely ` ` + a common letter, or `e`+`r`) buy the most. The ratio climbs slowly because this corpus is small. The library path (HuggingFace `tokenizers`) adds regex pre-tokenization so merges never cross word boundaries, but the two primitives above are the whole idea.\n",
    "\n",
    "> **Key takeaways.** Tokenization maps text to integers and is part of the model. A char-level codec is a bijection you should assert round-trips. BPE is two primitives, count-pairs and merge-pair, looped; the merge must skip two positions to avoid overlapping reuse (the off-by-one that also bites masks). Compression is bounded by corpus repetitiveness.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2442ca36",
   "metadata": {},
   "source": [
    "## Part 2 — Embeddings: integers to vectors\n",
    "\n",
    "> **Objectives.** Turn token ids into vectors with a learned lookup table, see that `nn.Embedding` is exactly fancy-indexing into a matrix, and decide whether the final unembedding should share weights with the embedding. Artifact: a verified equivalence between `nn.Embedding` and a hand lookup, and a weight-tying check.\n",
    "\n",
    "Once you have integer ids you look each one up in a table of shape `(vocab_size, d_model)`. That table is a learned parameter. `nn.Embedding(V, d)` does exactly this: for ids of shape `(B, T)` it returns `(B, T, d)`. There is nothing more to it than indexing, which the next cell proves.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "2b6219d0",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:04.917851Z",
     "iopub.status.busy": "2026-06-10T19:31:04.917490Z",
     "iopub.status.idle": "2026-06-10T19:31:05.663336Z",
     "shell.execute_reply": "2026-06-10T19:31:05.660184Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "emb output shape: (1, 5, 32)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] nn.Embedding(ids) == weight[ids] (it is a lookup, nothing more)\n"
     ]
    }
   ],
   "source": [
    "torch.manual_seed(SEED)\n",
    "d_model = 32\n",
    "emb = nn.Embedding(VOCAB, d_model)\n",
    "ids = torch.tensor([encode(\"To be\")])           # (1, 5)\n",
    "out = emb(ids)                                   # (1, 5, d_model)\n",
    "# the from-scratch equivalent is fancy-indexing into the weight matrix\n",
    "manual = emb.weight[ids]                         # (1, 5, d_model)\n",
    "print(\"emb output shape:\", tuple(out.shape))\n",
    "torch.testing.assert_close(out, manual)\n",
    "print(\"[ ok ] nn.Embedding(ids) == weight[ids] (it is a lookup, nothing more)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "20013dc5",
   "metadata": {},
   "source": [
    "> **Interpretation.** `nn.Embedding` is not magic. It is `weight[ids]`. The only thing the module adds is registering `weight` as a learnable parameter so gradients flow into the rows that were used. Rows for unused tokens get no gradient that step.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cd2fa71d",
   "metadata": {},
   "source": [
    "### Exercise 15.3 — Tie the unembedding to the embedding\n",
    "`Difficulty 2/5 · ~10 min`\n",
    "\n",
    "The final layer of a GPT projects `d_model -> vocab_size` to make logits. GPT-2 *ties* this projection's weight to the embedding table (same matrix, used in both directions). Implement `tie_weights(emb, head)` that makes `head.weight` share storage with `emb.weight`, then return the head. The check verifies they share storage via `data_ptr()` and that a gradient through one updates the other. Tying halves these parameters and slightly improves small models.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "c945075f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.664473Z",
     "iopub.status.busy": "2026-06-10T19:31:05.664319Z",
     "iopub.status.idle": "2026-06-10T19:31:05.685729Z",
     "shell.execute_reply": "2026-06-10T19:31:05.685314Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.3 weight tying: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 12,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def tie_weights(emb, head):\n",
    "    \"\"\"Make head.weight share storage with emb.weight. Return head.\n",
    "    emb:  nn.Embedding(V, d)   head: nn.Linear(d, V, bias=False)\n",
    "    \"\"\"\n",
    "    # TODO 1: assign head.weight = emb.weight (PyTorch handles the shared parameter)\n",
    "    raise NotImplementedError\n",
    "\n",
    "def _tying():\n",
    "    e = nn.Embedding(VOCAB, d_model)\n",
    "    h = nn.Linear(d_model, VOCAB, bias=False)\n",
    "    h = tie_weights(e, h)\n",
    "    assert h.weight.data_ptr() == e.weight.data_ptr(), \\\n",
    "        \"head.weight and emb.weight must share storage (same data_ptr) — assign, do not copy\"\n",
    "    # a gradient into the head must also land on the embedding (they are one tensor)\n",
    "    e.weight.grad = None\n",
    "    h(torch.randn(3, d_model)).sum().backward()\n",
    "    assert e.weight.grad is not None and e.weight.grad.abs().sum() > 0, \\\n",
    "        \"gradient through the tied head did not reach the embedding — they are not actually shared\"\n",
    "\n",
    "check(\"15.3 weight tying\", _tying)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b788370e",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>An `nn.Linear(d, V)` with no bias has a `.weight` of shape `(V, d)`, the same shape as `nn.Embedding(V, d).weight`. Sharing is a single assignment.</details>\n",
    "\n",
    "<details><summary>Hint 2 (the line)</summary>`head.weight = emb.weight; return head`. Do not write `head.weight.data = ...` or `clone()`; that copies and breaks the tie.</details>\n",
    "\n",
    "<details><summary>Help — \"data_ptr() values differ\"</summary>You copied instead of aliasing. `head.weight = emb.weight.clone()` or `head.weight.data.copy_(...)` makes a separate tensor. Plain `head.weight = emb.weight` shares the underlying storage.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "57f98dbf",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.686850Z",
     "iopub.status.busy": "2026-06-10T19:31:05.686764Z",
     "iopub.status.idle": "2026-06-10T19:31:05.732412Z",
     "shell.execute_reply": "2026-06-10T19:31:05.731807Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.3 weight tying\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 13,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines tie_weights; the check below re-verifies the reference.\n",
    "def tie_weights(emb, head):\n",
    "    head.weight = emb.weight\n",
    "    return head\n",
    "\n",
    "check(\"15.3 weight tying\", _tying, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "64dbe0df",
   "metadata": {},
   "source": [
    "> **Key takeaways.** An embedding is a learned lookup table; `nn.Embedding(ids)` equals `weight[ids]`. The unembedding can share that matrix (weight tying), which you verify by storage identity (`data_ptr`) and a shared gradient, not by value equality. Tying is a real reason a step-0 loss can misbehave if the table is initialised too large, which is the failure we stage in Part 6.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bca57f3f",
   "metadata": {},
   "source": [
    "## Part 3 — One attention head, then the causal mask\n",
    "\n",
    "> **Objectives.** Build scaled dot-product attention from scratch, understand why we divide by $\\sqrt{d_k}$ and why the softmax runs over keys, then build the causal mask *before* the softmax and prove with a property test that no future token can leak into an earlier output. Artifact: a single attention head you trust, and the leak test.\n",
    "\n",
    "For each position, attention computes a weighted sum of all positions' value vectors, where the weights come from how much that position's *query* matches every position's *key*. Three projections of the same input give $Q$, $K$, $V$. The score from query $i$ to key $j$ is\n",
    "\n",
    "$$\\text{score}_{ij} = \\frac{Q_i \\cdot K_j}{\\sqrt{d_k}}, \\qquad \\text{Attention}(Q,K,V) = \\text{softmax}\\!\\left(\\tfrac{QK^\\top}{\\sqrt{d_k}}\\right) V.$$\n",
    "\n",
    "The $\\sqrt{d_k}$ in the denominator is `math.sqrt(d_k)` in the code below; the softmax runs over the last axis, the keys.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1f5dd448",
   "metadata": {},
   "source": [
    "First, why $\\sqrt{d_k}$? The dot product of two vectors of dimension $d_k$ whose entries are unit-variance has variance $d_k$. Larger $d_k$ means larger scores, which saturates the softmax onto one key and kills its gradient. Dividing by $\\sqrt{d_k}$ pulls the variance back to $O(1)$. Let us measure it rather than take it on faith.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "664baae9",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.733417Z",
     "iopub.status.busy": "2026-06-10T19:31:05.733325Z",
     "iopub.status.idle": "2026-06-10T19:31:05.763978Z",
     "shell.execute_reply": "2026-06-10T19:31:05.763389Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "unscaled dot-product std: 8.02  (~sqrt(d_k)=8.00)\n",
      "scaled   dot-product std: 1.00  (~1.0)\n"
     ]
    }
   ],
   "source": [
    "torch.manual_seed(SEED)\n",
    "d_k = 64\n",
    "q = torch.randn(1000, d_k)\n",
    "k = torch.randn(1000, d_k)\n",
    "unscaled = (q * k).sum(-1)               # 1000 independent dot products\n",
    "scaled = unscaled / math.sqrt(d_k)\n",
    "print(f\"unscaled dot-product std: {unscaled.std():.2f}  (~sqrt(d_k)={math.sqrt(d_k):.2f})\")\n",
    "print(f\"scaled   dot-product std: {scaled.std():.2f}  (~1.0)\")\n",
    "assert scaled.std() < unscaled.std(), \"scaling must reduce the spread of scores\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c7991eac",
   "metadata": {},
   "source": [
    "> **Interpretation.** The unscaled scores have standard deviation near $\\sqrt{d_k} = 8$, the scaled ones near 1. Feed the unscaled scores to a softmax and one key wins almost every time, with vanishing gradient elsewhere. This is the entire reason for the $\\sqrt{d_k}$, derived by Vaswani et al. from exactly this variance argument.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1a2ad25a",
   "metadata": {},
   "source": [
    "### Exercise 15.4 — Scaled dot-product attention\n",
    "`Difficulty 3/5 · ~20 min`\n",
    "\n",
    "Implement `attention(q, k, v)` for tensors shaped `(B, H, T, d_k)`: scores, scale, softmax over keys, weighted sum of values. No mask yet (that is the next exercise). The checks verify the output shape, that the attention weights sum to 1 over the key axis, and that on a toy where one key dominates, attention copies that key's value.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "672af718",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.765222Z",
     "iopub.status.busy": "2026-06-10T19:31:05.764859Z",
     "iopub.status.idle": "2026-06-10T19:31:05.793828Z",
     "shell.execute_reply": "2026-06-10T19:31:05.793222Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.4 attention shape: not attempted yet — fill in the TODO above, then re-run.\n",
      "[ ok ] 15.4 weights sum to 1\n",
      "[ -- ] 15.4 copies dominant key: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 15,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def attention(q, k, v):\n",
    "    \"\"\"Scaled dot-product attention. q,k,v: (B, H, T, d_k). Returns (B, H, T, d_k).\"\"\"\n",
    "    d_k = q.shape[-1]\n",
    "    # TODO 1: scores = q @ k^T over the last two dims, divided by sqrt(d_k)  -> (B, H, T, T)\n",
    "    scores = None\n",
    "    # TODO 2: weights = softmax over the KEY axis (the last axis)            -> (B, H, T, T)\n",
    "    weights = None\n",
    "    # TODO 3: out = weights @ v                                             -> (B, H, T, d_k)\n",
    "    out = None\n",
    "    attempted(scores, weights, out)\n",
    "    return out\n",
    "\n",
    "def _attn_shape():\n",
    "    out = attention(torch.randn(2, 4, 8, 16), torch.randn(2, 4, 8, 16), torch.randn(2, 4, 8, 16))\n",
    "    check_shape(out, (2, 4, 8, 16))\n",
    "\n",
    "def _attn_rows_sum_to_one():\n",
    "    # re-derive the weights the same way and check each query's row sums to 1\n",
    "    torch.manual_seed(SEED)\n",
    "    q = torch.randn(1, 1, 5, 8); k = torch.randn(1, 1, 5, 8)\n",
    "    w = F.softmax(q @ k.transpose(-2, -1) / math.sqrt(8), dim=-1)\n",
    "    check_close(w.sum(-1).flatten(), torch.ones(5), msg=\"each query's attention row must sum to 1\")\n",
    "\n",
    "def _attn_copies_dominant_key():\n",
    "    # one key with a huge query-key match -> attention output ~= that key's value\n",
    "    q = torch.zeros(1, 1, 1, 4); q[..., 0] = 50.0\n",
    "    k = torch.zeros(1, 1, 3, 4); k[0, 0, 2, 0] = 1.0       # key 2 matches the query\n",
    "    v = torch.tensor([[10.0, 0, 0, 0], [20, 0, 0, 0], [30, 0, 0, 0]]).view(1, 1, 3, 4)\n",
    "    out = attention(q, k, v)\n",
    "    check_close(out.flatten()[0], 30.0, atol=1e-2, msg=\"should copy key 2's value (30) when key 2 dominates\")\n",
    "\n",
    "check(\"15.4 attention shape\", _attn_shape)\n",
    "check(\"15.4 weights sum to 1\", _attn_rows_sum_to_one)\n",
    "check(\"15.4 copies dominant key\", _attn_copies_dominant_key)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cf34812a",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>Three lines. `k.transpose(-2, -1)` swaps the last two axes so `q @ k^T` has shape `(B, H, T, T)`. Divide by `math.sqrt(d_k)`. `F.softmax(scores, dim=-1)` runs over keys. Then `weights @ v`.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "scores = q @ k.transpose(-2, -1) / math.sqrt(d_k)\n",
    "weights = F.softmax(scores, dim=-1)\n",
    "out = weights @ v\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"weights sum to 1 over the wrong axis\"</summary>If you used `dim=-2` the softmax ran over queries, not keys. Each *query* must form a distribution over keys, so the softmax is over the last axis of a `(..., q, k)` tensor.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "4c8c6e05",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.794784Z",
     "iopub.status.busy": "2026-06-10T19:31:05.794703Z",
     "iopub.status.idle": "2026-06-10T19:31:05.861127Z",
     "shell.execute_reply": "2026-06-10T19:31:05.860501Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.4 attention shape\n",
      "[ ok ] 15.4 weights sum to 1\n",
      "[ ok ] 15.4 copies dominant key\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 16,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines attention; the checks below re-verify the reference.\n",
    "def attention(q, k, v):\n",
    "    d_k = q.shape[-1]\n",
    "    scores = q @ k.transpose(-2, -1) / math.sqrt(d_k)\n",
    "    weights = F.softmax(scores, dim=-1)\n",
    "    return weights @ v\n",
    "\n",
    "check(\"15.4 attention shape\", _attn_shape, required=True)\n",
    "check(\"15.4 weights sum to 1\", _attn_rows_sum_to_one, required=True)\n",
    "check(\"15.4 copies dominant key\", _attn_copies_dominant_key, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c5adf948",
   "metadata": {},
   "source": [
    "Now the mask. An autoregressive model must forbid query $i$ from attending to any key $j > i$, or it would read the answer at training time. We enforce it by setting those scores to $-\\infty$ *before* the softmax, so they become exactly $0$ after. The mask is the single most common place to ship a silent bug, so we build it as its own tested function before any attention uses it (the hardest sub-skill, isolated first).\n",
    "\n",
    "The off-by-one trap: `diagonal=1` masks strictly above the diagonal, which is correct (a position may attend to itself). `diagonal=0` would also mask the diagonal, forbidding self-attention and breaking everything.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e7285250",
   "metadata": {},
   "source": [
    "> **Stop and think:** for a 4x4 score matrix, which entries should be masked? Sketch it before running. <details><summary>Answer</summary>Row $i$ (query) may keep columns $0..i$ and must mask columns $i+1..3$. So the strictly-upper triangle is masked: 6 of the 16 entries. The diagonal stays.</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "db8bfe4f",
   "metadata": {},
   "source": [
    "### Exercise 15.5 — Build the causal mask before the softmax\n",
    "`Difficulty 3/5 · ~15 min`\n",
    "\n",
    "Implement `apply_causal_mask(scores)` for `scores` of shape `(B, H, T, T)`: build a boolean upper-triangular mask (True strictly above the diagonal), and fill those positions with `-inf` *before any softmax*. The checks verify the shape is unchanged, that after softmax the masked entries are exactly 0, and that the diagonal survives. This is the function the leak test in the next cell relies on.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "a981d7e6",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.862171Z",
     "iopub.status.busy": "2026-06-10T19:31:05.862070Z",
     "iopub.status.idle": "2026-06-10T19:31:05.868352Z",
     "shell.execute_reply": "2026-06-10T19:31:05.867749Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.5 mask shape: not attempted yet — fill in the TODO above, then re-run.\n",
      "[ -- ] 15.5 zero above diagonal: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 17,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def apply_causal_mask(scores):\n",
    "    \"\"\"scores: (B, H, T, T). Return scores with key positions j > query i set to -inf.\"\"\"\n",
    "    T = scores.shape[-1]\n",
    "    # TODO 1: mask = upper-triangular boolean, True strictly above the diagonal (diagonal=1)\n",
    "    mask = None\n",
    "    # TODO 2: return scores with masked positions filled with float('-inf')  (masked_fill)\n",
    "    attempted(mask)\n",
    "    raise NotImplementedError\n",
    "\n",
    "def _mask_shape():\n",
    "    check_shape(apply_causal_mask(torch.randn(2, 4, 8, 8)), (2, 4, 8, 8))\n",
    "\n",
    "def _mask_zero_after_softmax():\n",
    "    T = 5\n",
    "    s = apply_causal_mask(torch.zeros(1, 1, T, T))\n",
    "    w = F.softmax(s, dim=-1)[0, 0]\n",
    "    upper = torch.triu(torch.ones(T, T), diagonal=1).bool()\n",
    "    assert (w[upper] == 0).all(), \"masked (upper-triangle) weights must be exactly 0 after softmax\"\n",
    "    assert (w.diagonal() > 0).all(), \"the diagonal must NOT be masked — a position attends to itself\"\n",
    "\n",
    "check(\"15.5 mask shape\", _mask_shape)\n",
    "check(\"15.5 zero above diagonal\", _mask_zero_after_softmax)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "91b85467",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>`torch.triu(torch.ones(T, T), diagonal=1).bool()` is True strictly above the diagonal. Then `scores.masked_fill(mask, float('-inf'))`. The mask broadcasts over the batch and head axes.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "mask = torch.triu(torch.ones(T, T, device=scores.device), diagonal=1).bool()\n",
    "return scores.masked_fill(mask, float(\"-inf\"))\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"the diagonal is also 0 after softmax\"</summary>You used `diagonal=0`, which masks the diagonal too. A query must be allowed to attend to its own position, so the mask starts strictly above the diagonal: `diagonal=1`.</details>\n",
    "\n",
    "<details><summary>Help — \"I masked with 0 instead of -inf\"</summary>Filling with `0` leaves those scores in the softmax denominator, so future positions still get nonzero weight and the rows no longer sum to 1 over the allowed keys. It must be `-inf` (or a very large negative) so `exp` sends it to 0.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "32523494",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.870050Z",
     "iopub.status.busy": "2026-06-10T19:31:05.869964Z",
     "iopub.status.idle": "2026-06-10T19:31:05.948205Z",
     "shell.execute_reply": "2026-06-10T19:31:05.947938Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.5 mask shape\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.5 zero above diagonal\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 18,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines apply_causal_mask; the checks below re-verify the reference.\n",
    "def apply_causal_mask(scores):\n",
    "    T = scores.shape[-1]\n",
    "    mask = torch.triu(torch.ones(T, T, device=scores.device), diagonal=1).bool()\n",
    "    return scores.masked_fill(mask, float(\"-inf\"))\n",
    "\n",
    "check(\"15.5 mask shape\", _mask_shape, required=True)\n",
    "check(\"15.5 zero above diagonal\", _mask_zero_after_softmax, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f03e465e",
   "metadata": {},
   "source": [
    "Visualise it. For a 6-token sequence with flat scores, the post-softmax attention matrix should be a lower-triangular staircase: query 0 attends only to key 0, query 5 spreads over keys 0-5. Eyeballing this matrix on a short sequence is the 30-second check that catches mask bugs.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "id": "2b3a752c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:05.950047Z",
     "iopub.status.busy": "2026-06-10T19:31:05.949946Z",
     "iopub.status.idle": "2026-06-10T19:31:06.354458Z",
     "shell.execute_reply": "2026-06-10T19:31:06.354180Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "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",
      "text/plain": [
       "<Figure size 400x360 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "upper triangle is white (0); each row sums to 1 over the allowed keys\n"
     ]
    }
   ],
   "source": [
    "# viz: post-softmax attention under the causal mask (flat scores -> uniform over allowed keys)\n",
    "T = 6\n",
    "w = F.softmax(apply_causal_mask(torch.zeros(1, 1, T, T)), dim=-1)[0, 0].numpy()\n",
    "fig, ax = plt.subplots(figsize=(4, 3.6))\n",
    "im = ax.imshow(w, cmap=\"Blues\", vmin=0, vmax=1)\n",
    "ax.set_xlabel(\"key position (attended to)\"); ax.set_ylabel(\"query position\")\n",
    "ax.set_title(\"causal attention weights\"); fig.colorbar(im, ax=ax, fraction=0.046)\n",
    "plt.tight_layout(); plt.show()\n",
    "print(\"upper triangle is white (0); each row sums to 1 over the allowed keys\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1387ab29",
   "metadata": {},
   "source": [
    "> **Interpretation.** A clean lower-triangular staircase. White above the diagonal means zero leakage. Each row is uniform over the keys it can see, because the scores were flat. If you ever see color above the diagonal, you have a mask bug, and your model will train fine but silently degrade at inference. Now assemble the head and run the leak test directly.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "149557d2",
   "metadata": {},
   "source": [
    "### Exercise 15.6 — Assemble one causal head and prove it does not leak\n",
    "`Difficulty 3/5 · ~15 min`\n",
    "\n",
    "Fill in `CausalHead.forward`: project `x` to q, k, v with the three given linears, reshape each to `(B, 1, T, d_k)` (one head), score, *mask*, softmax, weighted sum, then squeeze back to `(B, T, d_model)`. The checks run a shape smoke test and the leak property test (`check_causal`), which perturbs the second half of the sequence and asserts the first half's outputs do not move.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "id": "9f70c069",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:06.363285Z",
     "iopub.status.busy": "2026-06-10T19:31:06.363155Z",
     "iopub.status.idle": "2026-06-10T19:31:06.382147Z",
     "shell.execute_reply": "2026-06-10T19:31:06.381568Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.6 head shape: not attempted yet — fill in the TODO above, then re-run.\n",
      "[ -- ] 15.6 head is causal: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 20,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "class CausalHead(nn.Module):\n",
    "    def __init__(self, d_model):\n",
    "        super().__init__()\n",
    "        self.d_model = d_model\n",
    "        self.q = nn.Linear(d_model, d_model, bias=False)\n",
    "        self.k = nn.Linear(d_model, d_model, bias=False)\n",
    "        self.v = nn.Linear(d_model, d_model, bias=False)\n",
    "\n",
    "    def forward(self, x):\n",
    "        B, T, C = x.shape                                  # (B, T, d_model)\n",
    "        # TODO 1: project to q, k, v, each (B, T, C), then add a head axis -> (B, 1, T, C)\n",
    "        q = None\n",
    "        k = None\n",
    "        v = None\n",
    "        # TODO 2: scores -> apply_causal_mask -> softmax -> weighted sum   (reuse your funcs)\n",
    "        out = None\n",
    "        attempted(q, k, v, out)\n",
    "        return out.squeeze(1)                              # (B, T, d_model)\n",
    "\n",
    "def _head_shape():\n",
    "    torch.manual_seed(SEED)\n",
    "    smoke(CausalHead(16), (2, 8, 16), (2, 8, 16))\n",
    "\n",
    "def _head_causal():\n",
    "    torch.manual_seed(SEED)\n",
    "    head = CausalHead(16)\n",
    "    check_causal(head)\n",
    "\n",
    "check(\"15.6 head shape\", _head_shape)\n",
    "check(\"15.6 head is causal\", _head_causal)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "50d0c221",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>`self.q(x)` is `(B, T, C)`. Add a head axis with `.unsqueeze(1)` to get `(B, 1, T, C)`. Compute `scores = q @ k.transpose(-2,-1) / sqrt(C)`, call `apply_causal_mask(scores)`, softmax over the last axis, then `@ v`.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "q = self.q(x).unsqueeze(1); k = self.k(x).unsqueeze(1); v = self.v(x).unsqueeze(1)\n",
    "scores = q @ k.transpose(-2, -1) / math.sqrt(C)\n",
    "scores = apply_causal_mask(scores)\n",
    "out = F.softmax(scores, dim=-1) @ v   # (B, 1, T, C)\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"head is causal FAILS but mask passed\"</summary>You probably masked after the softmax, or forgot the mask entirely in the head. The leak test perturbs the future and checks the past is unchanged; that only holds if the mask is applied to the scores *before* softmax. Re-check the order in your forward.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "8e512f57",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:06.383105Z",
     "iopub.status.busy": "2026-06-10T19:31:06.383019Z",
     "iopub.status.idle": "2026-06-10T19:31:06.562326Z",
     "shell.execute_reply": "2026-06-10T19:31:06.557623Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.6 head shape\n",
      "[ ok ] 15.6 head is causal\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 21,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines CausalHead; the checks below re-verify the reference.\n",
    "class CausalHead(nn.Module):\n",
    "    def __init__(self, d_model):\n",
    "        super().__init__()\n",
    "        self.d_model = d_model\n",
    "        self.q = nn.Linear(d_model, d_model, bias=False)\n",
    "        self.k = nn.Linear(d_model, d_model, bias=False)\n",
    "        self.v = nn.Linear(d_model, d_model, bias=False)\n",
    "\n",
    "    def forward(self, x):\n",
    "        B, T, C = x.shape\n",
    "        q = self.q(x).unsqueeze(1)                         # (B, 1, T, C)\n",
    "        k = self.k(x).unsqueeze(1)\n",
    "        v = self.v(x).unsqueeze(1)\n",
    "        scores = q @ k.transpose(-2, -1) / math.sqrt(C)    # (B, 1, T, T)\n",
    "        scores = apply_causal_mask(scores)\n",
    "        out = F.softmax(scores, dim=-1) @ v                # (B, 1, T, C)\n",
    "        return out.squeeze(1)\n",
    "\n",
    "check(\"15.6 head shape\", _head_shape, required=True)\n",
    "check(\"15.6 head is causal\", _head_causal, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3b8634cc",
   "metadata": {},
   "source": [
    "> **Key takeaways.** Attention is `softmax(QKᵀ/√d_k) V` with the softmax over keys. The $\\sqrt{d_k}$ keeps score variance near 1 so the softmax does not saturate. The causal mask sets future scores to $-\\infty$ *before* softmax (build it as its own tested function: `diagonal=1`, fill with `-inf`). The decisive check is behavioral: perturb the future, the past must not move.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d74206b0",
   "metadata": {},
   "source": [
    "## Part 4 — Multi-head attention, FFN, norm, residual\n",
    "\n",
    "> **Objectives.** Build the four supporting modules: multi-head attention (the production fused-QKV idiom), the position-wise feed-forward network, LayerNorm, and the pre-norm residual block. Each gets a shape smoke test and a hand-checked property. Artifact: a `Block` you can stack.\n",
    "\n",
    "One head learns one kind of relationship. Multi-head attention runs $h$ heads in parallel on the same input, each in a $d_k = d_{model}/h$ subspace, concatenates their outputs, and projects back. The production trick is one fused `nn.Linear(d_model, 3*d_model)` for all of Q, K, V across all heads, reshaped to `(B, h, T, d_k)`.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "765d7d30",
   "metadata": {},
   "source": [
    "The one reshape that everyone gets wrong: going from `(B, T, C)` to `(B, h, T, d_k)` needs a `view` *and* a `transpose`. `view` alone gives the wrong memory layout (it would split the time axis, not the channel axis). Isolate and test it before using it.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4caade60",
   "metadata": {},
   "source": [
    "### Exercise 15.7 — The multi-head reshape round-trip\n",
    "`Difficulty 2/5 · ~10 min`\n",
    "\n",
    "Implement `to_heads(x, h)`: `(B, T, C) -> (B, h, T, C//h)`, and `from_heads(y)`: `(B, h, T, d_k) -> (B, T, C)`. The check asserts the round-trip is lossless (`from_heads(to_heads(x, h)) == x`). If it is not, your view/transpose order is wrong, which silently scrambles every head.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "id": "ba5bd57e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:06.563497Z",
     "iopub.status.busy": "2026-06-10T19:31:06.563404Z",
     "iopub.status.idle": "2026-06-10T19:31:06.581540Z",
     "shell.execute_reply": "2026-06-10T19:31:06.581179Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.7 head reshape round-trip: not attempted yet — fill in the TODO above, then re-run."
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 22,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def to_heads(x, h):\n",
    "    \"\"\"(B, T, C) -> (B, h, T, C//h). Needs a view THEN a transpose.\"\"\"\n",
    "    B, T, C = x.shape\n",
    "    # TODO 1: view to (B, T, h, C//h), then transpose axes 1 and 2 -> (B, h, T, C//h)\n",
    "    raise NotImplementedError\n",
    "\n",
    "def from_heads(y):\n",
    "    \"\"\"(B, h, T, d_k) -> (B, T, h*d_k). The inverse of to_heads.\"\"\"\n",
    "    B, h, T, d_k = y.shape\n",
    "    # TODO 2: transpose axes 1 and 2 -> (B, T, h, d_k), make contiguous, view to (B, T, h*d_k)\n",
    "    raise NotImplementedError\n",
    "\n",
    "def _heads_roundtrip():\n",
    "    torch.manual_seed(SEED)\n",
    "    x = torch.randn(2, 10, 64)\n",
    "    y = to_heads(x, 4)\n",
    "    check_shape(y, (2, 4, 10, 16))\n",
    "    z = from_heads(y)\n",
    "    check_close(z, x, msg=\"reshape round-trip lost information — view/transpose order is wrong\")\n",
    "\n",
    "check(\"15.7 head reshape round-trip\", _heads_roundtrip)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b2291e26",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>Split the channel axis: `x.view(B, T, h, C//h)`. Then move the head axis in front of time: `.transpose(1, 2)`. To invert, transpose back, then `.contiguous().view(B, T, C)`.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "# to_heads:   return x.view(B, T, h, C // h).transpose(1, 2)\n",
    "# from_heads: return y.transpose(1, 2).contiguous().view(B, T, h * d_k)\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"round-trip is not equal\"</summary>You likely skipped `.contiguous()` before the second `view` (transpose makes the tensor non-contiguous, and `view` then either errors or reinterprets memory wrong). Or you used `.view` where you needed `.transpose`. `view` reinterprets bytes; `transpose` permutes axes.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 23,
   "id": "9541099d",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:06.582673Z",
     "iopub.status.busy": "2026-06-10T19:31:06.582589Z",
     "iopub.status.idle": "2026-06-10T19:31:06.599872Z",
     "shell.execute_reply": "2026-06-10T19:31:06.599586Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.7 head reshape round-trip\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 23,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines to_heads/from_heads; the check below re-verifies the reference.\n",
    "def to_heads(x, h):\n",
    "    B, T, C = x.shape\n",
    "    return x.view(B, T, h, C // h).transpose(1, 2)\n",
    "\n",
    "def from_heads(y):\n",
    "    B, h, T, d_k = y.shape\n",
    "    return y.transpose(1, 2).contiguous().view(B, T, h * d_k)\n",
    "\n",
    "check(\"15.7 head reshape round-trip\", _heads_roundtrip, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "07c1396e",
   "metadata": {},
   "source": [
    "Now assemble multi-head causal attention using the reshape you just verified. The mask buffer is registered once at construction (it is fixed, not learned, so `register_buffer`, not `nn.Parameter`). The shape smoke test and the leak test both run.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 24,
   "id": "a59915f5",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:06.601061Z",
     "iopub.status.busy": "2026-06-10T19:31:06.600980Z",
     "iopub.status.idle": "2026-06-10T19:31:06.944071Z",
     "shell.execute_reply": "2026-06-10T19:31:06.940293Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "multi-head attention params: 4,096\n",
      "[ ok ] MHA shape\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] MHA is causal\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 24,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "class MultiHeadAttention(nn.Module):\n",
    "    def __init__(self, d_model, n_heads, max_seq_len):\n",
    "        super().__init__()\n",
    "        assert d_model % n_heads == 0, \"d_model must be divisible by n_heads\"\n",
    "        self.d_model, self.n_heads = d_model, n_heads\n",
    "        self.d_k = d_model // n_heads\n",
    "        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)   # fused Q,K,V for all heads\n",
    "        self.out = nn.Linear(d_model, d_model, bias=False)\n",
    "        mask = torch.triu(torch.ones(max_seq_len, max_seq_len), diagonal=1).bool()\n",
    "        self.register_buffer(\"mask\", mask.view(1, 1, max_seq_len, max_seq_len))   # fixed, not learned\n",
    "\n",
    "    def forward(self, x):\n",
    "        B, T, C = x.shape\n",
    "        q, k, v = self.qkv(x).split(self.d_model, dim=-1)        # each (B, T, C)\n",
    "        q = to_heads(q, self.n_heads)                            # (B, h, T, d_k)\n",
    "        k = to_heads(k, self.n_heads)\n",
    "        v = to_heads(v, self.n_heads)\n",
    "        scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_k)   # (B, h, T, T)\n",
    "        scores = scores.masked_fill(self.mask[:, :, :T, :T], float(\"-inf\"))\n",
    "        out = F.softmax(scores, dim=-1) @ v                      # (B, h, T, d_k)\n",
    "        return self.out(from_heads(out))                         # (B, T, C)\n",
    "\n",
    "torch.manual_seed(SEED)\n",
    "mha = MultiHeadAttention(d_model=32, n_heads=4, max_seq_len=64)\n",
    "print(f\"multi-head attention params: {param_count(mha):,}\")\n",
    "check(\"MHA shape\", lambda: smoke(mha, (2, 16, 32), (2, 16, 32)))\n",
    "check(\"MHA is causal\", lambda: check_causal(mha, C=32))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3141a3e2",
   "metadata": {},
   "source": [
    "> **Interpretation.** Same math as one head, run in four subspaces at once and recombined by `out`. The causal property survives because the same mask is applied to every head's scores before softmax. Note the mask is sliced to `:T` so the module works for any sequence up to `max_seq_len`.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "09a52f08",
   "metadata": {},
   "source": [
    "The feed-forward network is the per-position MLP applied to each token independently: expand to `4*d_model`, a GELU non-linearity, project back. Attention moves information between positions (communication); the FFN does the per-position computation. Without it the block would be close to linear.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 25,
   "id": "97a0ba3e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:06.945451Z",
     "iopub.status.busy": "2026-06-10T19:31:06.945041Z",
     "iopub.status.idle": "2026-06-10T19:31:07.012549Z",
     "shell.execute_reply": "2026-06-10T19:31:07.011939Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "feed-forward params: 8,352\n",
      "[ ok ] FFN shape\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 25,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "class FeedForward(nn.Module):\n",
    "    def __init__(self, d_model, d_ff=None):\n",
    "        super().__init__()\n",
    "        d_ff = d_ff or 4 * d_model          # 4x expansion is the original-transformer convention\n",
    "        self.fc1 = nn.Linear(d_model, d_ff)\n",
    "        self.fc2 = nn.Linear(d_ff, d_model)\n",
    "\n",
    "    def forward(self, x):\n",
    "        return self.fc2(F.gelu(self.fc1(x)))\n",
    "\n",
    "ffn = FeedForward(32)\n",
    "print(f\"feed-forward params: {param_count(ffn):,}\")\n",
    "check(\"FFN shape\", lambda: smoke(ffn, (2, 10, 32), (2, 10, 32)))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "44b42b6b",
   "metadata": {},
   "source": [
    "> **Interpretation.** Shape in equals shape out; the work happens in the wide hidden layer. The 4x ratio is convention from Vaswani et al.; Llama-style models use ~2.67x with a gated SwiGLU instead, same skeleton.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fefbd866",
   "metadata": {},
   "source": [
    "LayerNorm normalises each token's vector to zero mean and unit variance across its features, then scales and shifts by learned per-feature parameters $\\gamma, \\beta$. It is *per token*, not across the batch (that would be BatchNorm). Building it from scratch and checking against `nn.LayerNorm` is the cleanest implementation-ladder rung in this chapter.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12bf5f17",
   "metadata": {},
   "source": [
    "### Exercise 15.8 — LayerNorm from scratch, checked against torch\n",
    "`Difficulty 3/5 · ~15 min`\n",
    "\n",
    "Implement `LayerNormScratch.forward`: subtract the per-token mean, divide by the per-token standard deviation (use the *biased* variance, `unbiased=False`, with `+eps` inside the sqrt), then apply `gamma` and `beta`. The check compares your output to `nn.LayerNorm` initialised the same way, and verifies the normalised output (before gamma/beta) has near-zero mean and unit variance per token.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "id": "f3107ad0",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:07.013550Z",
     "iopub.status.busy": "2026-06-10T19:31:07.013452Z",
     "iopub.status.idle": "2026-06-10T19:31:07.327528Z",
     "shell.execute_reply": "2026-06-10T19:31:07.327187Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.8 LayerNorm vs torch: not attempted yet — fill in the TODO above, then re-run.\n",
      "[ -- ] 15.8 normalizes per token: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 26,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "class LayerNormScratch(nn.Module):\n",
    "    def __init__(self, d_model, eps=1e-5):\n",
    "        super().__init__()\n",
    "        self.gamma = nn.Parameter(torch.ones(d_model))\n",
    "        self.beta = nn.Parameter(torch.zeros(d_model))\n",
    "        self.eps = eps\n",
    "\n",
    "    def forward(self, x):\n",
    "        # TODO 1: mean over the LAST axis, keepdim=True\n",
    "        mean = None\n",
    "        # TODO 2: variance over the LAST axis, keepdim=True, unbiased=False\n",
    "        var = None\n",
    "        # TODO 3: normalise (x - mean) / sqrt(var + eps), then scale by gamma and shift by beta\n",
    "        out = None\n",
    "        attempted(mean, var, out)\n",
    "        return out\n",
    "\n",
    "def _ln_vs_torch():\n",
    "    torch.manual_seed(SEED)\n",
    "    x = torch.randn(2, 5, 8)\n",
    "    mine = LayerNormScratch(8)\n",
    "    ref = nn.LayerNorm(8)                       # same init: gamma=1, beta=0\n",
    "    with torch.no_grad():\n",
    "        check_close(mine(x), ref(x), atol=1e-5, msg=\"your LayerNorm disagrees with nn.LayerNorm\")\n",
    "\n",
    "def _ln_normalizes():\n",
    "    torch.manual_seed(SEED)\n",
    "    ln = LayerNormScratch(8)\n",
    "    with torch.no_grad():\n",
    "        y = ln(torch.randn(4, 3, 8) * 5 + 2)    # arbitrary mean/scale input\n",
    "    check_close(y.mean(-1).flatten(), torch.zeros(12), atol=1e-5, msg=\"per-token mean should be ~0\")\n",
    "    check_close(y.var(-1, unbiased=False).flatten(), torch.ones(12), atol=1e-3, msg=\"per-token var should be ~1\")\n",
    "\n",
    "check(\"15.8 LayerNorm vs torch\", _ln_vs_torch)\n",
    "check(\"15.8 normalizes per token\", _ln_normalizes)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9dd0cea2",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>Reduce over `dim=-1` (the feature axis) with `keepdim=True` so broadcasting works. Variance must match `nn.LayerNorm`, which uses the biased estimator: `x.var(-1, keepdim=True, unbiased=False)`.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "mean = x.mean(-1, keepdim=True)\n",
    "var = x.var(-1, keepdim=True, unbiased=False)\n",
    "out = self.gamma * (x - mean) / torch.sqrt(var + self.eps) + self.beta\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"off by a small amount vs nn.LayerNorm\"</summary>You used `unbiased=True` (the default), which divides by `n-1` instead of `n`. LayerNorm uses the biased variance. Set `unbiased=False`. The percent-wrong in the check tells you whether it is a scale bug (this) or an axis bug (much larger error).</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "id": "dc33ec2f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:07.329139Z",
     "iopub.status.busy": "2026-06-10T19:31:07.329048Z",
     "iopub.status.idle": "2026-06-10T19:31:07.358914Z",
     "shell.execute_reply": "2026-06-10T19:31:07.358654Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.8 LayerNorm vs torch\n",
      "[ ok ] 15.8 normalizes per token\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 27,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines LayerNormScratch; the checks below re-verify the reference.\n",
    "class LayerNormScratch(nn.Module):\n",
    "    def __init__(self, d_model, eps=1e-5):\n",
    "        super().__init__()\n",
    "        self.gamma = nn.Parameter(torch.ones(d_model))\n",
    "        self.beta = nn.Parameter(torch.zeros(d_model))\n",
    "        self.eps = eps\n",
    "\n",
    "    def forward(self, x):\n",
    "        mean = x.mean(-1, keepdim=True)\n",
    "        var = x.var(-1, keepdim=True, unbiased=False)\n",
    "        return self.gamma * (x - mean) / torch.sqrt(var + self.eps) + self.beta\n",
    "\n",
    "check(\"15.8 LayerNorm vs torch\", _ln_vs_torch, required=True)\n",
    "check(\"15.8 normalizes per token\", _ln_normalizes, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1e28d3d8",
   "metadata": {},
   "source": [
    "The block wires it together with **pre-norm** residuals: `x = x + attn(norm(x))` then `x = x + ffn(norm(x))`. Pre-norm (norm *inside* the residual branch, GPT-2 style) trains easily without learning-rate warmup; the original post-norm needs careful warmup or the gradients blow up at depth. Each sub-layer reads from the residual stream and adds its result back.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "id": "20f44a60",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:07.360245Z",
     "iopub.status.busy": "2026-06-10T19:31:07.360161Z",
     "iopub.status.idle": "2026-06-10T19:31:08.068868Z",
     "shell.execute_reply": "2026-06-10T19:31:08.068615Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "block params: 12,576\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] Block shape\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] Block is causal\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 28,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "class Block(nn.Module):\n",
    "    def __init__(self, d_model, n_heads, max_seq_len):\n",
    "        super().__init__()\n",
    "        self.ln1 = nn.LayerNorm(d_model)\n",
    "        self.attn = MultiHeadAttention(d_model, n_heads, max_seq_len)\n",
    "        self.ln2 = nn.LayerNorm(d_model)\n",
    "        self.ffn = FeedForward(d_model)\n",
    "\n",
    "    def forward(self, x):\n",
    "        x = x + self.attn(self.ln1(x))      # read from stream, write back (pre-norm)\n",
    "        x = x + self.ffn(self.ln2(x))\n",
    "        return x\n",
    "\n",
    "torch.manual_seed(SEED)\n",
    "blk = Block(d_model=32, n_heads=4, max_seq_len=64)\n",
    "print(f\"block params: {param_count(blk):,}\")\n",
    "check(\"Block shape\", lambda: smoke(blk, (2, 16, 32), (2, 16, 32)))\n",
    "check(\"Block is causal\", lambda: check_causal(blk, C=32))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7766be64",
   "metadata": {},
   "source": [
    "> **Interpretation.** The block preserves shape (it must, to stack), and it is still causal end to end (the residual `+` and the FFN are per-position, so they cannot move information backward in time). The residual additions are why the stream view of the architecture works: every component reads the stream and adds a correction.\n",
    "\n",
    "> **Key takeaways.** Multi-head attention is one fused QKV projection reshaped to `(B, h, T, d_k)` (view *then* transpose). The FFN is a per-position MLP with a 4x hidden layer providing the non-linearity. LayerNorm normalises per token with the biased variance; check it against `nn.LayerNorm`. Pre-norm residual blocks stack cleanly and stay causal.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3763c94e",
   "metadata": {},
   "source": [
    "## Part 5 — Assemble the GPT\n",
    "\n",
    "> **Objectives.** Stack the blocks into a full GPT: token + position embeddings, $N$ blocks, a final norm, a weight-tied head. Verify the assembled model's forward shape, weight tying, and causality end to end. Artifact: the `GPT` class you will train in Part 6.\n",
    "\n",
    "Attention is permutation-invariant: with no position signal, shuffling the input shuffles the output identically and the function is unchanged, which is fatal for language. GPT-2 fixes this with a *learned* position embedding added to the token embedding. We use that (simple, and enough at our scale); Llama-style RoPE rotates Q and K instead, which we discuss in Going further.\n",
    "\n",
    "We build the whole model in one cell (no editing a class defined earlier, which is the stale-definition landmine). It reuses every module you tested above.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "id": "b6d55d4f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:08.071260Z",
     "iopub.status.busy": "2026-06-10T19:31:08.071166Z",
     "iopub.status.idle": "2026-06-10T19:31:08.147313Z",
     "shell.execute_reply": "2026-06-10T19:31:08.145186Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "GPT params: 608,896\n"
     ]
    }
   ],
   "source": [
    "class GPT(nn.Module):\n",
    "    def __init__(self, vocab_size, d_model=128, n_heads=4, n_layers=3, max_seq_len=64):\n",
    "        super().__init__()\n",
    "        self.max_seq_len = max_seq_len\n",
    "        self.tok_emb = nn.Embedding(vocab_size, d_model)\n",
    "        self.pos_emb = nn.Embedding(max_seq_len, d_model)\n",
    "        self.blocks = nn.ModuleList([Block(d_model, n_heads, max_seq_len) for _ in range(n_layers)])\n",
    "        self.ln_f = nn.LayerNorm(d_model)\n",
    "        self.head = nn.Linear(d_model, vocab_size, bias=False)\n",
    "        self.head.weight = self.tok_emb.weight        # weight tying (Exercise 15.3)\n",
    "\n",
    "    def forward(self, idx):\n",
    "        B, T = idx.shape                              # (B, T) integer ids\n",
    "        assert T <= self.max_seq_len, f\"sequence length {T} exceeds max_seq_len {self.max_seq_len}\"\n",
    "        pos = torch.arange(T, device=idx.device)\n",
    "        x = self.tok_emb(idx) + self.pos_emb(pos)     # (B, T, d_model)\n",
    "        for block in self.blocks:\n",
    "            x = block(x)\n",
    "        x = self.ln_f(x)\n",
    "        return self.head(x)                           # (B, T, vocab_size) logits\n",
    "\n",
    "torch.manual_seed(SEED)\n",
    "model = GPT(vocab_size=VOCAB, d_model=128, n_heads=4, n_layers=3, max_seq_len=64)\n",
    "print(f\"GPT params: {param_count(model):,}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f7818f09",
   "metadata": {},
   "source": [
    "> **Interpretation.** A few hundred thousand parameters: tiny by LLM standards, plenty to overfit ~9000 characters and produce Shakespeare-flavoured text. The param count printed here is the number to sanity-check after any architecture change.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4dbd9232",
   "metadata": {},
   "source": [
    "A `layer_summary`-style dummy forward: push a random batch of ids through and print the shape after each stage. This is the d2l habit that catches a wrong shape the moment it appears, not three layers later.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "id": "7af5a331",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:08.148684Z",
     "iopub.status.busy": "2026-06-10T19:31:08.148594Z",
     "iopub.status.idle": "2026-06-10T19:31:08.916121Z",
     "shell.execute_reply": "2026-06-10T19:31:08.915848Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "after embeddings       (2, 16, 128)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "after block 0          (2, 16, 128)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "after block 1          (2, 16, 128)\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "after block 2          (2, 16, 128)\n",
      "after final norm+head  (2, 16, 56)  <- (B, T, vocab)\n"
     ]
    }
   ],
   "source": [
    "# deeper: a layer-by-layer shape trace on a dummy batch\n",
    "torch.manual_seed(SEED)\n",
    "dummy = torch.randint(0, VOCAB, (2, 16))\n",
    "x = model.tok_emb(dummy) + model.pos_emb(torch.arange(16))\n",
    "print(f\"{'after embeddings':22s} {tuple(x.shape)}\")\n",
    "for i, b in enumerate(model.blocks):\n",
    "    x = b(x)\n",
    "    print(f\"{'after block ' + str(i):22s} {tuple(x.shape)}\")\n",
    "x = model.ln_f(x)\n",
    "logits = model.head(x)\n",
    "print(f\"{'after final norm+head':22s} {tuple(logits.shape)}  <- (B, T, vocab)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b3f787eb",
   "metadata": {},
   "source": [
    "> **Interpretation.** The shape is invariant through the blocks (the residual stream keeps width `d_model`), and only the head changes it to `(B, T, vocab)`. If a block changed the shape, stacking would be impossible; this trace is the proof it does not.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "166f24f4",
   "metadata": {},
   "source": [
    "### Exercise 15.9 — Verify the assembled GPT\n",
    "`Difficulty 2/5 · ~10 min`\n",
    "\n",
    "Write three checks on the live `model`: (a) forward output shape is `(B, T, VOCAB)`; (b) the head and token embedding share storage (weight tying held through assembly); (c) the model is causal end to end. You assemble the assertions; the properties are the spec. This is the confidence-by-construction gate before training.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 31,
   "id": "08924449",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:08.917564Z",
     "iopub.status.busy": "2026-06-10T19:31:08.917480Z",
     "iopub.status.idle": "2026-06-10T19:31:11.119452Z",
     "shell.execute_reply": "2026-06-10T19:31:11.119179Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.9 GPT forward shape: not attempted yet — fill in the TODO above, then re-run.\n",
      "[ -- ] 15.9 GPT weight tying: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.9 GPT causal end to end: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 31,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def _gpt_shape():\n",
    "    torch.manual_seed(SEED)\n",
    "    idx = torch.randint(0, VOCAB, (2, 12))\n",
    "    logits = model(idx)\n",
    "    # TODO 1: assert the logits shape is (2, 12, VOCAB)\n",
    "    raise NotImplementedError\n",
    "\n",
    "def _gpt_tied():\n",
    "    # TODO 2: assert model.head.weight and model.tok_emb.weight share storage (data_ptr)\n",
    "    raise NotImplementedError\n",
    "\n",
    "def _gpt_causal():\n",
    "    # changing the LAST input token must not change earlier logits\n",
    "    torch.manual_seed(SEED)\n",
    "    idx = torch.randint(0, VOCAB, (1, 10))\n",
    "    idx2 = idx.clone(); idx2[0, -1] = (idx[0, -1] + 1) % VOCAB\n",
    "    l1, l2 = model(idx), model(idx2)\n",
    "    # TODO 3: assert l1[:, :-1, :] and l2[:, :-1, :] are close (atol 1e-5)\n",
    "    raise NotImplementedError\n",
    "\n",
    "check(\"15.9 GPT forward shape\", _gpt_shape)\n",
    "check(\"15.9 GPT weight tying\", _gpt_tied)\n",
    "check(\"15.9 GPT causal end to end\", _gpt_causal)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ef6e7786",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>Shape: `assert logits.shape == (2, 12, VOCAB)`. Tying: compare `.weight.data_ptr()`. Causal: `torch.allclose(l1[:, :-1, :], l2[:, :-1, :], atol=1e-5)` — the last position changed, so only the last logits may differ.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "assert tuple(logits.shape) == (2, 12, VOCAB), f\"got {tuple(logits.shape)}\"\n",
    "assert model.head.weight.data_ptr() == model.tok_emb.weight.data_ptr()\n",
    "assert torch.allclose(l1[:, :-1, :], l2[:, :-1, :], atol=1e-5)\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"causal end-to-end fails\"</summary>If perturbing the last token moved earlier logits, a mask is wrong somewhere in the stack. Re-run the per-module `check_causal` from Part 4; the failing module is the culprit.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 32,
   "id": "8f58fbcd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:11.130744Z",
     "iopub.status.busy": "2026-06-10T19:31:11.130611Z",
     "iopub.status.idle": "2026-06-10T19:31:13.476189Z",
     "shell.execute_reply": "2026-06-10T19:31:13.463174Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.9 GPT forward shape\n",
      "[ ok ] 15.9 GPT weight tying\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.9 GPT causal end to end\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 32,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines the three checks' bodies; the checks below re-verify the reference.\n",
    "def _gpt_shape():\n",
    "    torch.manual_seed(SEED)\n",
    "    idx = torch.randint(0, VOCAB, (2, 12))\n",
    "    logits = model(idx)\n",
    "    assert tuple(logits.shape) == (2, 12, VOCAB), f\"got {tuple(logits.shape)}, expected (2, 12, {VOCAB})\"\n",
    "\n",
    "def _gpt_tied():\n",
    "    assert model.head.weight.data_ptr() == model.tok_emb.weight.data_ptr(), \\\n",
    "        \"head and embedding stopped sharing storage during assembly\"\n",
    "\n",
    "def _gpt_causal():\n",
    "    torch.manual_seed(SEED)\n",
    "    idx = torch.randint(0, VOCAB, (1, 10))\n",
    "    idx2 = idx.clone(); idx2[0, -1] = (idx[0, -1] + 1) % VOCAB\n",
    "    l1, l2 = model(idx), model(idx2)\n",
    "    assert torch.allclose(l1[:, :-1, :], l2[:, :-1, :], atol=1e-5), \\\n",
    "        \"changing the last token moved earlier logits — a mask leaks\"\n",
    "\n",
    "check(\"15.9 GPT forward shape\", _gpt_shape, required=True)\n",
    "check(\"15.9 GPT weight tying\", _gpt_tied, required=True)\n",
    "check(\"15.9 GPT causal end to end\", _gpt_causal, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4240938c",
   "metadata": {},
   "source": [
    "> **Key takeaways.** A GPT is embeddings (token + learned position), $N$ pre-norm blocks, a final norm, and a tied head to logits. Assemble it in one cell, then verify shape, tying, and end-to-end causality before spending compute. Permutation-invariance is why positions are needed at all.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b97d312f",
   "metadata": {},
   "source": [
    "## Part 6 — Train, then sample\n",
    "\n",
    "> **Objectives.** Stage the deliberate init failure and fix it, build the batcher and the four-comment training loop, train the tiny GPT on Tiny Shakespeare, log the experiment, and run a sampling suite (greedy, temperature, top-k, top-p) with statistical sanity checks. Artifact: a trained model that writes Shakespeare-flavoured text, and decoders you trust.\n",
    "\n",
    "First the data pipeline. To train a language model you ask it to predict the next token at every position: inputs are tokens $0..T-1$, targets are tokens $1..T$. We encode the whole corpus once, split 90/10 into train and validation, and draw random contiguous windows.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 33,
   "id": "65472246",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:13.479454Z",
     "iopub.status.busy": "2026-06-10T19:31:13.479336Z",
     "iopub.status.idle": "2026-06-10T19:31:13.494754Z",
     "shell.execute_reply": "2026-06-10T19:31:13.494169Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "inputs: (32, 64) targets: (32, 64)\n",
      "first window target is the input shifted by one: True\n"
     ]
    }
   ],
   "source": [
    "data = torch.tensor(encode(TEXT), dtype=torch.long)\n",
    "n_train = int(0.9 * len(data))\n",
    "train_data, val_data = data[:n_train], data[n_train:]\n",
    "BLOCK = 64                                  # context length (== max_seq_len)\n",
    "BATCH = 32\n",
    "\n",
    "def get_batch(split):\n",
    "    \"\"\"Draw a random batch of (inputs, targets) windows. Targets are inputs shifted by one.\"\"\"\n",
    "    d = train_data if split == \"train\" else val_data\n",
    "    ix = torch.randint(0, len(d) - BLOCK - 1, (BATCH,))\n",
    "    x = torch.stack([d[i:i + BLOCK] for i in ix])\n",
    "    y = torch.stack([d[i + 1:i + 1 + BLOCK] for i in ix])   # shifted by one: predict the next char\n",
    "    return x, y\n",
    "\n",
    "torch.manual_seed(SEED)\n",
    "xb, yb = get_batch(\"train\")\n",
    "print(\"inputs:\", tuple(xb.shape), \"targets:\", tuple(yb.shape))\n",
    "print(\"first window target is the input shifted by one:\", bool((xb[0, 1:] == yb[0, :-1]).all()))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b8e7cebe",
   "metadata": {},
   "source": [
    "> **Interpretation.** Targets are inputs shifted left by one, so at every position the model's job is to predict the following character. The `True` confirms the shift is correct, which is the data-side analogue of the mask: get it wrong and the model learns to predict the current token (trivial) instead of the next one.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d933ea40",
   "metadata": {},
   "source": [
    "### A deliberate failure: the step-0 loss should be ~ln(V), not 80\n",
    "\n",
    "Before training, recall the prediction from \"Before you start\": an untrained model should have loss near $\\ln(V) = \\ln(56) \\approx 4.0$. Let us measure it on a freshly built model and watch it be wildly wrong, then find out why.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 34,
   "id": "0d020159",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:13.495721Z",
     "iopub.status.busy": "2026-06-10T19:31:13.495639Z",
     "iopub.status.idle": "2026-06-10T19:31:15.080602Z",
     "shell.execute_reply": "2026-06-10T19:31:15.080144Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "expected step-0 loss ~ ln(V) = 4.03\n",
      "actual step-0 loss        = 81.52   <- way too high\n",
      "embedding weight std      = 1.00   <- N(0,1), too large\n"
     ]
    }
   ],
   "source": [
    "# this model uses PyTorch's DEFAULT nn.Embedding init, which is N(0, 1)\n",
    "torch.manual_seed(SEED)\n",
    "broken = GPT(vocab_size=VOCAB, d_model=128, n_heads=4, n_layers=3, max_seq_len=BLOCK)\n",
    "with torch.no_grad():\n",
    "    logits = broken(xb)\n",
    "    loss0 = F.cross_entropy(logits.view(-1, VOCAB), yb.view(-1)).item()\n",
    "print(f\"expected step-0 loss ~ ln(V) = {math.log(VOCAB):.2f}\")\n",
    "print(f\"actual step-0 loss        = {loss0:.2f}   <- way too high\")\n",
    "print(f\"embedding weight std      = {broken.tok_emb.weight.std().item():.2f}   <- N(0,1), too large\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "683a2aef",
   "metadata": {},
   "source": [
    "> **Interpretation.** The loss is around 80, not 4. The cause: `nn.Embedding` initialises to $N(0,1)$, and because the head is *tied* to that table (Part 2), the logits are huge, so the softmax is wildly overconfident and wrong, and cross-entropy explodes. This is not a data bug; it is an initialisation bug, and it is exactly Karpathy's nanoGPT motivation for scaling the init.\n",
    "\n",
    "The fix is one line: initialise the embedding (hence the tied head) to a small std, 0.02, the GPT-2 value. Re-initialise and re-measure.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 35,
   "id": "fbe9a05e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:15.081762Z",
     "iopub.status.busy": "2026-06-10T19:31:15.081679Z",
     "iopub.status.idle": "2026-06-10T19:31:16.790255Z",
     "shell.execute_reply": "2026-06-10T19:31:16.786259Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step-0 loss after 0.02 init = 4.02   (expected ~ 4.03)\n",
      "[ ok ] initialisation fixed: step-0 loss lands near the uniform-prediction baseline\n"
     ]
    }
   ],
   "source": [
    "def init_gpt2_(model, std=0.02):\n",
    "    \"\"\"GPT-2-style init: small-normal embeddings and linears. Fixes the tied-head logit blow-up.\"\"\"\n",
    "    for m in model.modules():\n",
    "        if isinstance(m, nn.Linear):\n",
    "            nn.init.normal_(m.weight, mean=0.0, std=std)\n",
    "            if m.bias is not None:\n",
    "                nn.init.zeros_(m.bias)\n",
    "        elif isinstance(m, nn.Embedding):\n",
    "            nn.init.normal_(m.weight, mean=0.0, std=std)\n",
    "\n",
    "torch.manual_seed(SEED)\n",
    "model = GPT(vocab_size=VOCAB, d_model=128, n_heads=4, n_layers=3, max_seq_len=BLOCK)\n",
    "init_gpt2_(model)\n",
    "with torch.no_grad():\n",
    "    loss0_fixed = F.cross_entropy(model(xb).view(-1, VOCAB), yb.view(-1)).item()\n",
    "print(f\"step-0 loss after 0.02 init = {loss0_fixed:.2f}   (expected ~ {math.log(VOCAB):.2f})\")\n",
    "assert loss0_fixed < 2 * math.log(VOCAB), \"init still broken: step-0 loss should be near ln(V)\"\n",
    "print(\"[ ok ] initialisation fixed: step-0 loss lands near the uniform-prediction baseline\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9639a14b",
   "metadata": {},
   "source": [
    "> **Common confusion:** a high step-0 loss is *not* harmless. With the bad init the first few optimiser steps spend themselves shrinking the logits instead of learning, and at small step budgets that wasted start shows up as a worse final loss. Always check that step-0 loss is near $\\ln(V)$; it is the cheapest possible sanity check on a fresh model.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10b5bb6a",
   "metadata": {},
   "source": [
    "Now the training loop. It uses the four-comment skeleton (`# forward / # backward / # update / # track stats`) that recurs in every chapter, AdamW (decoupled weight decay), and gradient clipping at norm 1.0 to tame the occasional large gradient. We seed first so the run reproduces.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e68be806",
   "metadata": {},
   "source": [
    "> **Runtime:** this cell trains the model. About 20 s on CPU for the full run (`TRAIN_STEPS=800`), a couple of seconds under `NB_FAST` (`TRAIN_STEPS=80`).\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 36,
   "id": "70e1a101",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:31:16.795261Z",
     "iopub.status.busy": "2026-06-10T19:31:16.795139Z",
     "iopub.status.idle": "2026-06-10T19:48:45.058510Z",
     "shell.execute_reply": "2026-06-10T19:48:45.058176Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step    0 | train loss 4.016\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  100 | train loss 2.642\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  200 | train loss 2.313\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  300 | train loss 2.007\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  400 | train loss 1.885\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  500 | train loss 1.566\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  600 | train loss 1.091\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  700 | train loss 0.654\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "step  799 | train loss 0.379\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "final train 0.401 | val 3.579\n"
     ]
    }
   ],
   "source": [
    "torch.manual_seed(SEED)\n",
    "model = GPT(vocab_size=VOCAB, d_model=128, n_heads=4, n_layers=3, max_seq_len=BLOCK)\n",
    "init_gpt2_(model)\n",
    "opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.1)\n",
    "\n",
    "@torch.no_grad()\n",
    "def eval_loss(split, iters=20):\n",
    "    model.eval()\n",
    "    losses = []\n",
    "    for _ in range(iters):\n",
    "        x, y = get_batch(split)\n",
    "        losses.append(F.cross_entropy(model(x).view(-1, VOCAB), y.view(-1)).item())\n",
    "    model.train()\n",
    "    return float(np.mean(losses))\n",
    "\n",
    "losses = []\n",
    "for step in range(TRAIN_STEPS):\n",
    "    xb, yb = get_batch(\"train\")\n",
    "    logits = model(xb)                                              # forward\n",
    "    loss = F.cross_entropy(logits.view(-1, VOCAB), yb.view(-1))\n",
    "    opt.zero_grad(set_to_none=True)                                 # backward\n",
    "    loss.backward()\n",
    "    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)\n",
    "    opt.step()                                                      # update\n",
    "    if step % max(1, TRAIN_STEPS // 8) == 0 or step == TRAIN_STEPS - 1:\n",
    "        losses.append((step, loss.item()))                         # track stats\n",
    "        print(f\"step {step:4d} | train loss {loss.item():.3f}\")\n",
    "\n",
    "final_train = eval_loss(\"train\")\n",
    "final_val = eval_loss(\"val\")\n",
    "print(f\"\\nfinal train {final_train:.3f} | val {final_val:.3f}\")\n",
    "assert losses[-1][1] < losses[0][1], \"loss must decrease over training\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cb9d64e7",
   "metadata": {},
   "source": [
    "> **Interpretation.** Loss falls from near $\\ln(56) \\approx 4.0$ toward well under 2.0 (under the full run it reaches roughly 0.6, because the model is memorising a small corpus). The train loss being far below the val loss is expected and informative here: with ~9000 characters and a few hundred thousand parameters, the model overfits, which is fine for the demonstration. On the full 1MB corpus the same architecture would land around val 1.5 without overfitting this hard.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f4a0b561",
   "metadata": {},
   "source": [
    "Plot the loss curve. A clean monotone-ish descent with no spikes means the clipping and init are doing their jobs.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 37,
   "id": "f406b8cf",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.061079Z",
     "iopub.status.busy": "2026-06-10T19:48:45.060984Z",
     "iopub.status.idle": "2026-06-10T19:48:45.130432Z",
     "shell.execute_reply": "2026-06-10T19:48:45.130011Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "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559/hBC6c57Zef2pOiF061zHjx8vQkNDRVRUlDh69Kjo0KGDsLGxEfHx8UII3TrXkydPCgMDAzF79mxx48YNsW7dOmFmZibWrl2rqqNL308KhUJUrlxZTJo0Kcs6XbquAwcOFBUrVlQNR7B582ZhY2Mjvv32W1Wd4nZdmTgVoJ9++klUrlxZGBkZiWbNmonjx49rO6R8O3jwoACQZRk4cKAQQno0dOrUqcLe3l4YGxuL9u3bi2vXrqnt49GjR6J///7C3NxcWFhYiEGDBolnz55p4Wxyl915AhABAQGqOi9fvhRff/21KFeunDAzMxMfffSRiImJUdvPnTt3RJcuXYSpqamwsbER48ePF+np6UV8NrkbPHiwcHZ2FkZGRsLW1la0b99elTQJoTvnmZ03EyddOte+ffsKR0dHYWRkJCpWrCj69u2rNq6RLp2rEEJs375d1K1bVxgbG4uaNWuKlStXqq3Xpe+nPXv2CABZ4hdCt65rUlKSGD16tKhcubIwMTERVapUEd99953asAnF7brKhHhteE4iIiIiyhH7OBERERFpiIkTERERkYaYOBERERFpiIkTERERkYaYOBERERFpiIkTERERkYaYOBERERFpiIkTERERkYaYOBERERFpiIkTEek0Hx8feHl5aTsMItIRTJyIiIiINMTEiYh0wqZNm+Du7g5TU1NYW1ujQ4cOmDhxIlavXo2tW7dCJpNBJpMhNDQUAHDv3j306dMHVlZWKF++PHr27Ik7d+6o9pfZUuXn5wdbW1tYWFhg2LBhSEtL084JElGxYKDtAIiI3lVMTAz69++PH374AR999BGePXuGw4cPw9vbG9HR0UhKSkJAQAAAoHz58khPT0enTp3g4eGBw4cPw8DAAP/73//QuXNnXLhwAUZGRgCA/fv3w8TEBKGhobhz5w4GDRoEa2trzJ49W5unS0RaxMSJiEq8mJgYZGRkoFevXnB2dgYAuLu7AwBMTU2RmpoKBwcHVf21a9dCqVTit99+g0wmAwAEBATAysoKoaGh6NixIwDAyMgIq1atgpmZGerUqYOZM2di4sSJmDVrFvT02GBPVBrxN5+ISrz69eujffv2cHd3R+/evfHrr7/iyZMnOdY/f/48bt68ibJly8Lc3Bzm5uYoX748UlJScOvWLbX9mpmZqd57eHggOTkZ9+7dK9TzIaLiiy1ORFTi6evrY+/evTh27Bj++ecf/PTTT/juu+9w4sSJbOsnJyejcePGWLduXZZ1tra2hR0uEZVgTJyISCfIZDK0bNkSLVu2xLRp0+Ds7IyQkBAYGRlBoVCo1W3UqBE2bNgAOzs7WFhY5LjP8+fP4+XLlzA1NQUAHD9+HObm5nBycirUcyGi4ou36oioxDtx4gTmzJmD06dPIzo6Gps3b0ZCQgJq1aoFFxcXXLhwAdeuXcPDhw+Rnp6OAQMGwMbGBj179sThw4cRFRWF0NBQjBo1Cvfv31ftNy0tDUOGDMHly5exc+dOTJ8+HSNHjmT/JqJSjC1ORFTiWVhYICwsDIsWLUJSUhKcnZ2xcOFCdOnSBU2aNEFoaCiaNGmC5ORkHDx4EG3atEFYWBgmTZqEXr164dmzZ6hYsSLat2+v1gLVvn17uLm5oVWrVkhNTUX//v0xY8YM7Z0oEWmdTAghtB0EEVFx4+Pjg6dPn2LLli3aDoWIihG2NxMRERFpiIkTERERkYZ4q46IiIhIQ2xxIiIiItIQEyciIiIiDTFxIiIiItIQEyciIiIiDTFxIiIiItIQEyciIiIiDTFxIiIiItIQEyciIiIiDTFxIiIiItLQ/wFBrIbgMJvuUgAAAABJRU5ErkJggg==",
      "text/plain": [
       "<Figure size 600x350 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# viz: training loss curve\n",
    "steps_, vals_ = zip(*losses)\n",
    "fig, ax = plt.subplots(figsize=(6, 3.5))\n",
    "ax.plot(steps_, vals_, marker=\"o\", color=\"#1E40FF\")\n",
    "ax.axhline(math.log(VOCAB), ls=\":\", c=\"#888\", label=f\"ln(V) = {math.log(VOCAB):.2f} (untrained baseline)\")\n",
    "ax.set_xlabel(\"step\"); ax.set_ylabel(\"train loss\"); ax.set_title(\"tiny GPT on Tiny Shakespeare\")\n",
    "ax.legend(); plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "78d935a0",
   "metadata": {},
   "source": [
    "> **Interpretation.** The curve starts at the untrained baseline (the dotted $\\ln V$ line) and drops below it immediately, which is the visual confirmation that the init was fixed. A curve that started at 80 and crashed down would be the broken-init signature.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "877666d8",
   "metadata": {},
   "source": [
    "**Experiment log.** The numbers a future you (or a learner diffing their run) checks against. Both FAST and full settings are recorded so a smoke run is not mistaken for a regression.\n",
    "\n",
    "| run | d_model | layers | heads | params | steps | final train | final val |\n",
    "|---|---|---|---|---|---|---|---|\n",
    "| FAST (`NB_FAST=1`) | 128 | 3 | 4 | ~0.31M (tied) | 80 | ~2.6 | ~2.7 |\n",
    "| full | 128 | 3 | 4 | ~0.31M (tied) | 800 | ~0.6 | ~3.2 |\n",
    "\n",
    "The full run's val loss is *higher* than its train loss because it overfits the tiny embedded corpus. That gap is the honest signal that this is a memorisation demo, not a scaling demo; the optional full-corpus fetch below closes it.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3a523659",
   "metadata": {},
   "source": [
    "### Sampling: turning logits into tokens\n",
    "\n",
    "At inference you feed the current sequence, take the logits at the *last* position, turn them into a probability over the next token, pick one, append, repeat. The choices: **greedy** (argmax, deterministic), **temperature** (divide logits by $\\tau$ before softmax: $\\tau<1$ sharpens, $\\tau>1$ flattens), **top-k** (keep the $k$ highest-logit tokens), **top-p / nucleus** (keep the smallest set of tokens whose cumulative probability reaches $p$). Order matters: divide by temperature *then* filter.\n",
    "\n",
    "We build the decoders as standalone functions so we can test each one's defining property statistically, then a `generate` that uses them.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 38,
   "id": "56732868",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.131550Z",
     "iopub.status.busy": "2026-06-10T19:48:45.131428Z",
     "iopub.status.idle": "2026-06-10T19:48:45.137245Z",
     "shell.execute_reply": "2026-06-10T19:48:45.136901Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "temperature 0.5: probs [[0.865 0.117 0.016 0.002]]  (max prob 0.865)\n",
      "temperature 1.0: probs [[0.644 0.237 0.087 0.032]]  (max prob 0.644)\n",
      "temperature 2.0: probs [[0.455 0.276 0.167 0.102]]  (max prob 0.455)\n"
     ]
    }
   ],
   "source": [
    "def temperature_filter(logits, temperature):\n",
    "    \"\"\"Divide logits by temperature (>0). Lower temperature -> sharper distribution.\"\"\"\n",
    "    return logits / max(1e-9, temperature)\n",
    "\n",
    "def top_k_filter(logits, k):\n",
    "    \"\"\"Set all but the k highest logits to -inf.\"\"\"\n",
    "    if k is None or k >= logits.size(-1):\n",
    "        return logits\n",
    "    kth = torch.topk(logits, k).values[..., -1, None]      # the k-th largest logit per row\n",
    "    return logits.masked_fill(logits < kth, float(\"-inf\"))\n",
    "\n",
    "def top_p_filter(logits, p):\n",
    "    \"\"\"Nucleus: keep the smallest set of tokens whose cumulative prob >= p; -inf the rest.\"\"\"\n",
    "    if p is None:\n",
    "        return logits\n",
    "    sorted_logits, sorted_idx = torch.sort(logits, descending=True, dim=-1)\n",
    "    cum = F.softmax(sorted_logits, dim=-1).cumsum(dim=-1)\n",
    "    remove = cum - F.softmax(sorted_logits, dim=-1) >= p    # keep through the first token crossing p\n",
    "    remove_scattered = torch.zeros_like(remove).scatter(-1, sorted_idx, remove)\n",
    "    return logits.masked_fill(remove_scattered, float(\"-inf\"))\n",
    "\n",
    "# micro-demo: temperature sharpens/flattens a fixed logit vector\n",
    "base = torch.tensor([[2.0, 1.0, 0.0, -1.0]])\n",
    "for t in (0.5, 1.0, 2.0):\n",
    "    p = F.softmax(temperature_filter(base, t), dim=-1)\n",
    "    print(f\"temperature {t}: probs {p.numpy().round(3)}  (max prob {p.max():.3f})\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "df91785f",
   "metadata": {},
   "source": [
    "> **Interpretation.** Lower temperature concentrates probability on the top token (max prob rises toward 1); higher temperature spreads it out toward uniform. At $\\tau \\to 0$ this becomes greedy. The next exercise pins these behaviors down with statistical checks instead of eyeballing.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f7111e1b",
   "metadata": {},
   "source": [
    "### Exercise 15.10 — Statistical checks on the decoders\n",
    "`Difficulty 3/5 · ~20 min`\n",
    "\n",
    "Write three property checks against the decoders above. (a) **Top-k count**: after `top_k_filter(logits, k)`, exactly `k` entries are finite per row. (b) **Top-p coverage**: after `top_p_filter`, the *kept* tokens' probability mass is at least `p`. (c) **Temperature monotonicity**: the entropy of the softmax increases with temperature. These are the checks that catch an off-by-one in the nucleus boundary or a flipped comparison, the bugs that pass a shape test but ruin generation.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 39,
   "id": "2dbfc355",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.138361Z",
     "iopub.status.busy": "2026-06-10T19:48:45.138269Z",
     "iopub.status.idle": "2026-06-10T19:48:45.145408Z",
     "shell.execute_reply": "2026-06-10T19:48:45.145178Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.10 top-k keeps exactly k: not attempted yet — fill in the TODO above, then re-run.\n",
      "[ -- ] 15.10 top-p covers >= p: not attempted yet — fill in the TODO above, then re-run.\n",
      "[ -- ] 15.10 temperature raises entropy: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 39,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "torch.manual_seed(GEN_SEED)\n",
    "LG = torch.randn(8, VOCAB)            # fake logits, 8 rows\n",
    "\n",
    "def _topk_count():\n",
    "    k = 5\n",
    "    filt = top_k_filter(LG.clone(), k)\n",
    "    n_finite = torch.isfinite(filt).sum(-1)\n",
    "    # TODO 1: assert every row has exactly k finite entries\n",
    "    raise NotImplementedError\n",
    "\n",
    "def _topp_coverage():\n",
    "    p = 0.9\n",
    "    filt = top_p_filter(LG.clone(), p)\n",
    "    kept = torch.isfinite(filt)\n",
    "    mass = (F.softmax(LG, dim=-1) * kept).sum(-1)     # original prob mass of the kept tokens\n",
    "    # TODO 2: assert every row's kept mass is >= p (nucleus must cover at least p)\n",
    "    raise NotImplementedError\n",
    "\n",
    "def _temp_entropy_monotone():\n",
    "    def entropy(t):\n",
    "        pr = F.softmax(temperature_filter(LG, t), dim=-1)\n",
    "        return -(pr * (pr + 1e-12).log()).sum(-1).mean().item()\n",
    "    e_lo, e_hi = entropy(0.5), entropy(2.0)\n",
    "    # TODO 3: assert higher temperature gives higher entropy (e_hi > e_lo)\n",
    "    raise NotImplementedError\n",
    "\n",
    "check(\"15.10 top-k keeps exactly k\", _topk_count)\n",
    "check(\"15.10 top-p covers >= p\", _topp_coverage)\n",
    "check(\"15.10 temperature raises entropy\", _temp_entropy_monotone)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "da5f1f4c",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>`torch.isfinite(filt).sum(-1)` counts the survivors per row; compare to `k`. For top-p, multiply the *original* softmax by the boolean keep-mask and sum. For temperature, compute Shannon entropy of the softmax at two temperatures.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "assert (n_finite == k).all(), f\"top-k kept {n_finite.tolist()} per row, expected {k}\"\n",
    "assert (mass >= p - 1e-6).all(), f\"nucleus mass {mass.min():.3f} < p={p}\"\n",
    "assert e_hi > e_lo, f\"entropy fell with temperature: {e_lo:.3f} -> {e_hi:.3f}\"\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"top-p coverage is just under p\"</summary>Your boundary is off by one: you dropped the token that *crosses* the threshold instead of keeping it. The nucleus must include the first token whose cumulative probability reaches `p`, so subtract that token's own probability when testing the cumulative sum (the `cum - softmax >= p` trick keeps it).</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 40,
   "id": "87490baa",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.147015Z",
     "iopub.status.busy": "2026-06-10T19:48:45.146930Z",
     "iopub.status.idle": "2026-06-10T19:48:45.154814Z",
     "shell.execute_reply": "2026-06-10T19:48:45.154415Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.10 top-k keeps exactly k\n",
      "[ ok ] 15.10 top-p covers >= p\n",
      "[ ok ] 15.10 temperature raises entropy\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 40,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines the check bodies; the checks below re-verify the reference.\n",
    "def _topk_count():\n",
    "    k = 5\n",
    "    filt = top_k_filter(LG.clone(), k)\n",
    "    n_finite = torch.isfinite(filt).sum(-1)\n",
    "    assert (n_finite == k).all(), f\"top-k kept {n_finite.tolist()} per row, expected exactly {k}\"\n",
    "\n",
    "def _topp_coverage():\n",
    "    p = 0.9\n",
    "    filt = top_p_filter(LG.clone(), p)\n",
    "    kept = torch.isfinite(filt)\n",
    "    mass = (F.softmax(LG, dim=-1) * kept).sum(-1)\n",
    "    assert (mass >= p - 1e-6).all(), f\"nucleus covered only {mass.min():.3f} < p={p} — boundary off by one\"\n",
    "\n",
    "def _temp_entropy_monotone():\n",
    "    def entropy(t):\n",
    "        pr = F.softmax(temperature_filter(LG, t), dim=-1)\n",
    "        return -(pr * (pr + 1e-12).log()).sum(-1).mean().item()\n",
    "    e_lo, e_hi = entropy(0.5), entropy(2.0)\n",
    "    assert e_hi > e_lo, f\"entropy should rise with temperature, got {e_lo:.3f} -> {e_hi:.3f}\"\n",
    "\n",
    "check(\"15.10 top-k keeps exactly k\", _topk_count, required=True)\n",
    "check(\"15.10 top-p covers >= p\", _topp_coverage, required=True)\n",
    "check(\"15.10 temperature raises entropy\", _temp_entropy_monotone, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "29336293",
   "metadata": {},
   "source": [
    "Now `generate`, wrapped in `torch.no_grad()` (no gradients at inference, an explicit runtime-hygiene habit), cropping the context to `max_seq_len` so the position embedding never runs out of range. Greedy is the special case `temperature -> 0`, so we expose it as a flag.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 41,
   "id": "fb1badfe",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.155629Z",
     "iopub.status.busy": "2026-06-10T19:48:45.155548Z",
     "iopub.status.idle": "2026-06-10T19:48:45.498498Z",
     "shell.execute_reply": "2026-06-10T19:48:45.497898Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "First Citizen:\n",
      "Well, sir, what answer made the belly?\n",
      "\n",
      "All:\n",
      "No most, speak.\n",
      "\n",
      "Allond. Citizen:\n",
      "I you beesirsss ans answith goe\n",
      "Likeresow we with tone mon, muest bes thave\n",
      "hath aluk ance hing the ris dmon,\n",
      "we manks bu\n"
     ]
    }
   ],
   "source": [
    "@torch.no_grad()\n",
    "def generate(model, idx, max_new_tokens, temperature=0.8, top_k=None, top_p=None, greedy=False):\n",
    "    model.eval()\n",
    "    for _ in range(max_new_tokens):\n",
    "        idx_cond = idx[:, -model.max_seq_len:]               # crop to context window\n",
    "        logits = model(idx_cond)[:, -1, :]                   # (B, vocab) last position only\n",
    "        if greedy:\n",
    "            nxt = logits.argmax(dim=-1, keepdim=True)\n",
    "        else:\n",
    "            logits = temperature_filter(logits, temperature)\n",
    "            logits = top_k_filter(logits, top_k)\n",
    "            logits = top_p_filter(logits, top_p)\n",
    "            nxt = torch.multinomial(F.softmax(logits, dim=-1), num_samples=1)\n",
    "        idx = torch.cat([idx, nxt], dim=1)\n",
    "    model.train()\n",
    "    return idx\n",
    "\n",
    "torch.manual_seed(GEN_SEED)\n",
    "prompt = torch.tensor([encode(\"First Citizen:\\n\")], dtype=torch.long)\n",
    "sample = generate(model, prompt, max_new_tokens=200, temperature=0.8, top_k=20)\n",
    "print(decode(sample[0].tolist()))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ea587df8",
   "metadata": {},
   "source": [
    "> **Interpretation.** Shakespeare-flavoured nonsense: speaker tags, line breaks, plausible word shapes, no real grammar. That is the right outcome for a model this small on this little data. Greedy decoding (next cell) is more repetitive; temperature plus top-k is more varied but can wander.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 42,
   "id": "477c5dcd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.499727Z",
     "iopub.status.busy": "2026-06-10T19:48:45.499457Z",
     "iopub.status.idle": "2026-06-10T19:48:45.647125Z",
     "shell.execute_reply": "2026-06-10T19:48:45.646080Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "greedy run 1: \"First Citizen:\\nWell, I'll hear it, sir: yet yet yeady you sl\"\n",
      "[ ok ] greedy is deterministic (argmax has no randomness)\n"
     ]
    }
   ],
   "source": [
    "# greedy is deterministic: same prompt, same output every time\n",
    "torch.manual_seed(GEN_SEED)\n",
    "g1 = decode(generate(model, prompt.clone(), max_new_tokens=60, greedy=True)[0].tolist())\n",
    "g2 = decode(generate(model, prompt.clone(), max_new_tokens=60, greedy=True)[0].tolist())\n",
    "print(\"greedy run 1:\", repr(g1[:60]))\n",
    "assert g1 == g2, \"greedy decoding must be deterministic given the same prompt\"\n",
    "print(\"[ ok ] greedy is deterministic (argmax has no randomness)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a51cabd2",
   "metadata": {},
   "source": [
    "> **Key takeaways.** Train by predicting the next token (targets are inputs shifted by one). Check step-0 loss is near $\\ln(V)$; if it is 80, your init is broken, not your data, and the tied head makes that worse. The four-comment loop, AdamW, and grad-clip-at-1.0 are the spine. Sampling decoders each have a defining property you can test statistically: top-k keeps exactly $k$, top-p covers at least $p$, higher temperature means higher entropy. Greedy is deterministic.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b62f1c49",
   "metadata": {},
   "source": [
    "### Optional: train on the full 1MB Tiny Shakespeare\n",
    "\n",
    "The embedded excerpt keeps the canonical path offline. If you have a network connection and want to close the train/val gap, the cell below fetches the full ~1MB Tiny Shakespeare from a **commit-pinned, immutable** URL and verifies its sha256, caching to `data/`. It is wrapped in try/except and degrades to the embedded text if offline, so it never breaks run-all. Set `USE_FULL_CORPUS = True` to retrain on it (a few more steps; still CPU-friendly).\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 43,
   "id": "2c54e160",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.648372Z",
     "iopub.status.busy": "2026-06-10T19:48:45.648243Z",
     "iopub.status.idle": "2026-06-10T19:48:45.653027Z",
     "shell.execute_reply": "2026-06-10T19:48:45.652422Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "USE_FULL_CORPUS is False; canonical path uses the embedded excerpt (offline-safe).\n"
     ]
    }
   ],
   "source": [
    "USE_FULL_CORPUS = False   # flip to True (online) to retrain on the full 1MB corpus\n",
    "\n",
    "import hashlib, os, urllib.request\n",
    "# commit-pinned (immutable) raw URL + verified sha256 of the full file\n",
    "FULL_URL = (\"https://raw.githubusercontent.com/karpathy/char-rnn/\"\n",
    "            \"370cbcd448eb7daf32f21a6be560b70e0b33c4e3/data/tinyshakespeare/input.txt\")\n",
    "FULL_SHA256 = \"86c4e6aa9db7c042ec79f339dcb96d42b0075e16b8fc2e86bf0ca57e2dc565ed\"\n",
    "\n",
    "def download_cached(url, sha256, dest=\"data/tiny_shakespeare.txt\"):\n",
    "    \"\"\"Idempotent fetch with checksum. Second run is a no-op. Returns the text or None.\"\"\"\n",
    "    os.makedirs(os.path.dirname(dest), exist_ok=True)\n",
    "    if not os.path.exists(dest):\n",
    "        urllib.request.urlretrieve(url, dest)\n",
    "    blob = open(dest, \"rb\").read()\n",
    "    got = hashlib.sha256(blob).hexdigest()\n",
    "    if got != sha256:\n",
    "        os.remove(dest)\n",
    "        raise ValueError(f\"sha256 mismatch: got {got}, expected {sha256}\")\n",
    "    return blob.decode(\"utf-8\")\n",
    "\n",
    "if USE_FULL_CORPUS:\n",
    "    try:\n",
    "        full_text = download_cached(FULL_URL, FULL_SHA256)\n",
    "        print(f\"fetched full corpus: {len(full_text):,} chars (embedded excerpt was {len(TEXT):,})\")\n",
    "        print(\"re-run Part 6's encode/split/train cells with TEXT = full_text to use it\")\n",
    "    except Exception as e:\n",
    "        print(f\"fetch failed ({type(e).__name__}); staying on the embedded excerpt. Canonical path is unaffected.\")\n",
    "else:\n",
    "    print(\"USE_FULL_CORPUS is False; canonical path uses the embedded excerpt (offline-safe).\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cd08af6e",
   "metadata": {},
   "source": [
    "> **Note:** the URL pins commit `370cbcd`, not a branch, so the bytes can never change under you, and the sha256 catches a corrupted or substituted download. This is the Tier-3 dataset pattern from the design spec: immutable URL, checksum, idempotent cache, optional and offline-degrading.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "41cecb12",
   "metadata": {},
   "source": [
    "## Safety lens\n",
    "\n",
    "The attention mechanism you just built is the most-attacked layer of every deployed LLM, and the reasons are architectural facts from this chapter, not add-ons.\n",
    "\n",
    "**The tokenizer is part of the model.** Your BPE merges in Part 1 define which strings become single tokens. Some tokens in a production vocabulary were created by the tokenizer's training corpus but barely seen by the language model (the `SolidGoldMagikarp` glitch-token family), and behavior on them is undefined and exploitable. More practically, a safety filter trained on one tokenizer can miss inputs that re-tokenize differently. The habit: test the tokenizer separately from the model, encoding a few adversarial strings and asserting the ids are what you expect.\n",
    "\n",
    "**The causal mask is a silent failure surface.** An off-by-one in the mask (Exercise 15.5) trains fine and only degrades at inference, because the leakage helps during teacher-forced training and vanishes at generation. The leak test (`check_causal`) is not pedantry; it is the only cheap way to catch this class of bug. Run it on any attention code you ship.\n",
    "\n",
    "**The residual stream is linearly readable, which cuts both ways.** Because every block *adds* to the stream (Part 4), behaviors like refusal live along specific directions you can locate with a linear probe and then ablate at inference, the \"refusal direction\" result on Llama-2/3. The same additive structure that makes mech-interp tractable (the IOI and induction-head circuits) is what makes these directions findable and removable. The architecture's interpretability and its attackability are the same property.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 44,
   "id": "88bbc04c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.654541Z",
     "iopub.status.busy": "2026-06-10T19:48:45.654401Z",
     "iopub.status.idle": "2026-06-10T19:48:45.658024Z",
     "shell.execute_reply": "2026-06-10T19:48:45.657348Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "'First Citizen'      -> [15, 38, 47, 48, 49, 1]...\n",
      "' First Citizen'     -> [1, 15, 38, 47, 48, 49]...\n",
      "'\\n'                 -> [0]\n",
      "[ ok ] tokenizer encodes and round-trips the strings we expect\n"
     ]
    }
   ],
   "source": [
    "# a 5-line tokenizer self-test: the habit the safety lens recommends\n",
    "for s in [\"First Citizen\", \" First Citizen\", \"\\n\"]:\n",
    "    ids = encode(s)\n",
    "    assert decode(ids) == s, f\"tokenizer round-trip failed on {s!r}\"\n",
    "    print(f\"{s!r:20s} -> {ids[:6]}{'...' if len(ids) > 6 else ''}\")\n",
    "print(\"[ ok ] tokenizer encodes and round-trips the strings we expect\")\n",
    "# note: ' First' and 'First' begin with different tokens (the leading space is its own char here)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a7c5f42e",
   "metadata": {},
   "source": [
    "> **Interpretation.** Even our toy char-level codec shows the leading-space distinction: `\" First\"` starts with the space token, `\"First\"` does not. In a BPE vocabulary this is why a prompt beginning `cat` versus ` cat` can produce different continuations. The tokenizer is a part of the model you must test on purpose.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "412bf380",
   "metadata": {},
   "source": [
    "## Test yourself\n",
    "\n",
    "Three parts: concept self-checks with folded answers, two auto-checked problems with the full exercise mechanic, and a capstone with a rubric and a folded reference. Every answer is somewhere in this notebook; if unsure, re-run that section.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1581fc04",
   "metadata": {},
   "source": [
    "### Part A — Concepts\n",
    "\n",
    "1. Along which axis does the attention softmax run, and what does each row sum to? <details><summary>Answer</summary>The key axis (last axis of a `(..., q, k)` score tensor). Each query's row sums to 1; it is a distribution over the keys that query may attend to.</details>\n",
    "2. Why divide scores by $\\sqrt{d_k}$ and not by $d_k$? <details><summary>Answer</summary>The dot product of two $d_k$-dimensional unit-variance vectors has variance $d_k$, so dividing by $\\sqrt{d_k}$ restores unit variance and keeps the softmax from saturating. Dividing by $d_k$ overcorrects: variance becomes $1/d_k$, scores collapse toward 0, and the softmax goes near-uniform, losing all selectivity. We measured exactly this in Part 3.</details>\n",
    "3. Why mask with $-\\infty$ *before* the softmax instead of zeroing weights *after*? <details><summary>Answer</summary>$-\\infty$ becomes exactly 0 after `exp`, and the remaining (allowed) weights still sum to 1 because the denominator only includes allowed keys. Zeroing after softmax leaves the forbidden keys in the denominator, so the allowed weights no longer sum to 1 and gradients are wrong.</details>\n",
    "4. In the multi-head reshape, why is `view` alone not enough? <details><summary>Answer</summary>`view` reinterprets the existing memory layout; to get `(B, h, T, d_k)` you must `view(B, T, h, d_k)` then `transpose(1, 2)`. `view` alone would split the *time* axis or scramble heads. Exercise 15.7's round-trip check fails if you skip the transpose or the `.contiguous()` on the way back.</details>\n",
    "5. What is weight tying, and how do you verify it (not by value)? <details><summary>Answer</summary>The unembedding head shares the embedding matrix. Verify by storage identity, `head.weight.data_ptr() == tok_emb.weight.data_ptr()`, and by checking a gradient through one reaches the other. Value equality is not enough: two distinct tensors can hold equal values.</details>\n",
    "6. Your step-0 loss on a 56-char vocab is 80. What is wrong? <details><summary>Answer</summary>Initialisation, not data. The default `nn.Embedding` init is $N(0,1)$; tied to the head, it produces huge logits and an overconfident-wrong softmax. The fix is a small init (std 0.02). Expected step-0 loss is $\\ln(56) \\approx 4.0$. This is the deliberate failure in Part 6.</details>\n",
    "7. Look at the loss curve plotted in Part 6. Why does the dotted line sit at ~4.0, and what would a *broken-init* curve look like? <details><summary>Answer</summary>The dotted line is $\\ln(V)$, the loss of a uniform untrained predictor. A trained curve starts at or just below it and descends. A broken-init curve would start near 80 and spend its first steps crashing down to ~4 before any real learning, wasting the budget.</details>\n",
    "8. Pre-norm vs post-norm: which does GPT-2 use and why does it matter? <details><summary>Answer</summary>GPT-2 uses pre-norm (`x = x + sublayer(norm(x))`). It trains without learning-rate warmup. The original post-norm (`x = norm(x + sublayer(x))`) needs careful warmup or gradients explode at depth.</details>\n",
    "9. Do-it-now: in one line, build the boolean causal mask for `T=5` that is True strictly above the diagonal. <details><summary>Answer</summary>`torch.triu(torch.ones(5, 5), diagonal=1).bool()`. `diagonal=1` keeps the diagonal unmasked so a position can attend to itself.</details>\n",
    "10. Why is RoPE called a *relative* position scheme while GPT-2's learned embedding is absolute? <details><summary>Answer</summary>RoPE rotates Q and K by an angle proportional to absolute position, but the resulting $Q_i \\cdot K_j$ depends only on the difference $i-j$, so the model sees relative offsets. A learned absolute embedding ties each position index to its own vector and has nothing for positions past `max_seq_len`.</details>\n",
    "11. What is the residual stream and why does it make mech-interp tractable? <details><summary>Answer</summary>The additive bus through the layers: each block reads it and adds back via `x = x + block(x)`. Because contributions are additive (linear), you can decompose the output by component and ablate one to test its causal role. That is what makes circuits like IOI and induction heads findable.</details>\n",
    "12. Top-p with `p=0.9` versus top-k with `k=20`: what is the difference in what they keep? <details><summary>Answer</summary>Top-k keeps a *fixed count* regardless of how peaked the distribution is. Top-p keeps a *variable count* whose probability mass reaches 0.9, so it keeps few tokens when the model is confident and many when it is unsure. We checked top-k's exact-count and top-p's coverage properties in Exercise 15.10.</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "861304ec",
   "metadata": {},
   "source": [
    "### Part B — Auto-checked problems\n",
    "\n",
    "These require you to compute something new with the chapter's pieces. The check asserts a property; the folded solution comes after.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "08944fc2",
   "metadata": {},
   "source": [
    "#### Exercise 15.11 — Per-head attention entropy\n",
    "`Difficulty 3/5 · ~12 min`\n",
    "\n",
    "The safety lens noted that per-head attention entropy is a cheap diagnostic (low-entropy heads do something specific; high-entropy heads do little). Implement `attention_entropy(weights)` for `weights` of shape `(B, H, T, T)` (already post-softmax, causal): return a tensor of shape `(H,)`, the mean Shannon entropy per head, averaged over batch, queries. Use natural log and treat $0 \\log 0 = 0$. The check verifies the shape, that a uniform-over-allowed pattern has higher entropy than a one-hot pattern, and that entropy is non-negative.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 45,
   "id": "13275b99",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.659294Z",
     "iopub.status.busy": "2026-06-10T19:48:45.659170Z",
     "iopub.status.idle": "2026-06-10T19:48:45.667072Z",
     "shell.execute_reply": "2026-06-10T19:48:45.666290Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.11 per-head entropy: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 45,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def attention_entropy(weights):\n",
    "    \"\"\"weights: (B, H, T, T) post-softmax. Return (H,) mean entropy per head (natural log).\"\"\"\n",
    "    # TODO 1: per-element -p*log(p) with a small epsilon so 0*log0 -> 0, summed over the key axis\n",
    "    per_query = None\n",
    "    # TODO 2: average over batch and query axes -> (H,)\n",
    "    out = None\n",
    "    attempted(per_query, out)\n",
    "    return out\n",
    "\n",
    "def _entropy_checks():\n",
    "    B, H, T = 2, 3, 5\n",
    "    # uniform-over-allowed causal weights (high entropy)\n",
    "    uni = F.softmax(apply_causal_mask(torch.zeros(B, H, T, T)), dim=-1)\n",
    "    # near one-hot causal weights (low entropy): big boost on the diagonal\n",
    "    sharp = F.softmax(apply_causal_mask(torch.eye(T).view(1, 1, T, T).repeat(B, H, 1, 1) * 20), dim=-1)\n",
    "    eu, es = attention_entropy(uni), attention_entropy(sharp)\n",
    "    check_shape(eu, (H,))\n",
    "    assert (eu >= -1e-6).all(), \"entropy must be non-negative\"\n",
    "    assert (eu > es + 1e-3).all(), f\"uniform heads ({eu.mean():.3f}) must have higher entropy than sharp ({es.mean():.3f})\"\n",
    "\n",
    "check(\"15.11 per-head entropy\", _entropy_checks)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "62ac679a",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>Shannon entropy of each query's distribution is $-\\sum_k w \\log w$. Sum over the last (key) axis to get `(B, H, T)`, then `.mean(dim=(0, 2))` to reduce to `(H,)`. Add a tiny epsilon inside the log so masked zeros contribute nothing.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "per_query = -(weights * (weights + 1e-12).log()).sum(dim=-1)   # (B, H, T)\n",
    "out = per_query.mean(dim=(0, 2))                               # (H,)\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"entropy is negative or NaN\"</summary>You took `log(0)`. Masked positions are exactly 0; `0 * log(0)` is `0 * -inf = nan`. Add `+ 1e-12` inside the log so those terms vanish cleanly.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 46,
   "id": "22265a20",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.668332Z",
     "iopub.status.busy": "2026-06-10T19:48:45.668084Z",
     "iopub.status.idle": "2026-06-10T19:48:45.684466Z",
     "shell.execute_reply": "2026-06-10T19:48:45.683538Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.11 per-head entropy\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 46,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines attention_entropy; the check below re-verifies the reference.\n",
    "def attention_entropy(weights):\n",
    "    per_query = -(weights * (weights + 1e-12).log()).sum(dim=-1)   # (B, H, T)\n",
    "    return per_query.mean(dim=(0, 2))                              # (H,)\n",
    "\n",
    "check(\"15.11 per-head entropy\", _entropy_checks, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a8fb6c89",
   "metadata": {},
   "source": [
    "#### Exercise 15.12 — Cross-entropy loss from scratch, checked against torch\n",
    "`Difficulty 3/5 · ~12 min`\n",
    "\n",
    "Implement `xent(logits, targets)` for `logits` of shape `(N, V)` and integer `targets` of shape `(N,)`, returning the mean negative log-likelihood. Build it from a numerically stable log-softmax (subtract the row max before `exp`). The check compares against `F.cross_entropy` on random data and on a confidently-correct case where the loss should be near 0. This is the loss your training loop minimised.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 47,
   "id": "90d6b93c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.685840Z",
     "iopub.status.busy": "2026-06-10T19:48:45.685733Z",
     "iopub.status.idle": "2026-06-10T19:48:45.692165Z",
     "shell.execute_reply": "2026-06-10T19:48:45.691431Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ -- ] 15.12 cross-entropy vs torch: not attempted yet — fill in the TODO above, then re-run.\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "False"
      ]
     },
     "execution_count": 47,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "def xent(logits, targets):\n",
    "    \"\"\"Mean cross-entropy. logits: (N, V), targets: (N,) int. Stable log-softmax inside.\"\"\"\n",
    "    # TODO 1: log-softmax along V, stably: subtract logits.max over V before exp\n",
    "    logprobs = None\n",
    "    # TODO 2: gather the log-prob of the target class for each row, take the mean negative\n",
    "    loss = None\n",
    "    attempted(logprobs, loss)\n",
    "    return loss\n",
    "\n",
    "def _xent_checks():\n",
    "    torch.manual_seed(SEED)\n",
    "    lg = torch.randn(20, VOCAB)\n",
    "    tg = torch.randint(0, VOCAB, (20,))\n",
    "    check_close(xent(lg, tg), F.cross_entropy(lg, tg).item(), atol=1e-5, msg=\"disagrees with F.cross_entropy\")\n",
    "    # confidently correct: huge logit on the right class -> loss ~ 0\n",
    "    conf = torch.full((4, VOCAB), -10.0); conf[range(4), [1, 2, 3, 4]] = 10.0\n",
    "    assert xent(conf, torch.tensor([1, 2, 3, 4])) < 1e-3, \"confidently-correct loss should be ~0\"\n",
    "\n",
    "check(\"15.12 cross-entropy vs torch\", _xent_checks)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b2db80c5",
   "metadata": {},
   "source": [
    "<details><summary>Hint 1 (conceptual)</summary>Stable log-softmax: `z = logits - logits.max(dim=-1, keepdim=True).values`; `logprobs = z - z.exp().sum(-1, keepdim=True).log()`. Then pick the target column per row with `gather` (or fancy indexing) and average the negative.</details>\n",
    "\n",
    "<details><summary>Hint 2 (pseudocode)</summary>\n",
    "\n",
    "```python\n",
    "z = logits - logits.max(dim=-1, keepdim=True).values\n",
    "logprobs = z - z.exp().sum(-1, keepdim=True).log()\n",
    "loss = -logprobs[torch.arange(len(targets)), targets].mean()\n",
    "```\n",
    "</details>\n",
    "\n",
    "<details><summary>Help — \"off by a constant vs F.cross_entropy\"</summary>You probably normalised with a plain softmax then took the log of it (less stable, and easy to get the axis wrong), or forgot the max-subtraction so large logits overflowed. Build log-softmax directly with the stable formula above.</details>\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 48,
   "id": "904b4e1c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-10T19:48:45.693331Z",
     "iopub.status.busy": "2026-06-10T19:48:45.693220Z",
     "iopub.status.idle": "2026-06-10T19:48:45.713977Z",
     "shell.execute_reply": "2026-06-10T19:48:45.713626Z"
    },
    "collapsed": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input"
    ]
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "[ ok ] 15.12 cross-entropy vs torch\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "True"
      ]
     },
     "execution_count": 48,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "#@title Solution { display-mode: \"form\" }\n",
    "# solution: redefines xent; the check below re-verifies the reference.\n",
    "def xent(logits, targets):\n",
    "    z = logits - logits.max(dim=-1, keepdim=True).values\n",
    "    logprobs = z - z.exp().sum(-1, keepdim=True).log()\n",
    "    return -logprobs[torch.arange(len(targets)), targets].mean()\n",
    "\n",
    "check(\"15.12 cross-entropy vs torch\", _xent_checks, required=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "73092760",
   "metadata": {},
   "source": [
    "### Part C — Capstone: scale the model up and report\n",
    "\n",
    "Redo Part 6 with a bigger model and the full corpus, and report like a researcher. This is open-ended; the rubric is how you grade yourself, and a folded reference shows one way to do it.\n",
    "\n",
    "**Deliverables**\n",
    "1. Fetch the full Tiny Shakespeare via the `download_cached` cell (or use the embedded excerpt offline), re-encode and split.\n",
    "2. Train a larger config (for example `d_model=192, n_layers=4, n_heads=6`) for more steps, logging train and val loss.\n",
    "3. Generate 300 characters at two settings (greedy, and `temperature=0.8, top_k=40`) and compare them.\n",
    "4. Add one diagnostic from this chapter: per-head attention entropy (Exercise 15.11) on a real batch, and say which heads look specialised.\n",
    "\n",
    "**Self-assessment (pass / partial / fail)**\n",
    "- (a) step-0 loss is near $\\ln(V)$ (init is correct);\n",
    "- (b) val loss decreases and, on the full corpus, the train/val gap is much smaller than the excerpt demo's;\n",
    "- (c) greedy output is visibly more repetitive than the temperature+top-k output;\n",
    "- (d) your `xent` (Exercise 15.12) matches `F.cross_entropy` on a batch from your run;\n",
    "- (e) the notebook still runs top-to-bottom.\n",
    "\n",
    "<details><summary>My solution (reference, CPU-friendly)</summary>\n",
    "\n",
    "```python\n",
    "# assumes USE_FULL_CORPUS path ran and gave `full_text`; else falls back to TEXT\n",
    "src = full_text if 'full_text' in dir() else TEXT\n",
    "chars2 = sorted(set(src)); V2 = len(chars2)\n",
    "s2i = {c: i for i, c in enumerate(chars2)}; i2s = {i: c for c, i in s2i.items()}\n",
    "enc2 = lambda s: [s2i[c] for c in s]; dec2 = lambda t: \"\".join(i2s[int(i)] for i in t)\n",
    "d2 = torch.tensor(enc2(src), dtype=torch.long)\n",
    "ntr = int(0.9 * len(d2)); tr2, va2 = d2[:ntr], d2[ntr:]\n",
    "BLK = 64\n",
    "def gb(split):\n",
    "    dd = tr2 if split == \"train\" else va2\n",
    "    ix = torch.randint(0, len(dd) - BLK - 1, (32,))\n",
    "    x = torch.stack([dd[i:i+BLK] for i in ix]); y = torch.stack([dd[i+1:i+1+BLK] for i in ix])\n",
    "    return x, y\n",
    "\n",
    "torch.manual_seed(SEED)\n",
    "big = GPT(vocab_size=V2, d_model=192, n_heads=6, n_layers=4, max_seq_len=BLK)\n",
    "init_gpt2_(big)\n",
    "opt = torch.optim.AdamW(big.parameters(), lr=3e-3, weight_decay=0.1)\n",
    "steps = 300 if FAST else 2000           # full corpus -> more headroom before overfitting\n",
    "for s in range(steps):\n",
    "    x, y = gb(\"train\"); loss = F.cross_entropy(big(x).view(-1, V2), y.view(-1))\n",
    "    opt.zero_grad(set_to_none=True); loss.backward()\n",
    "    torch.nn.utils.clip_grad_norm_(big.parameters(), 1.0); opt.step()\n",
    "    if s % 200 == 0: print(s, round(loss.item(), 3))\n",
    "\n",
    "torch.manual_seed(GEN_SEED)\n",
    "p = torch.tensor([enc2(\"First Citizen:\\n\")], dtype=torch.long)\n",
    "print(\"GREEDY:\\n\", dec2(generate(big, p.clone(), 300, greedy=True)[0].tolist()))\n",
    "print(\"SAMPLED:\\n\", dec2(generate(big, p.clone(), 300, temperature=0.8, top_k=40)[0].tolist()))\n",
    "```\n",
    "On the full corpus this reaches val loss roughly 1.6-1.8 in a few minutes on CPU, with a much smaller train/val gap than the excerpt demo, and the sampled text reads more like Shakespeare than the greedy text, which loops.</details>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8141c2b4",
   "metadata": {},
   "source": [
    "## Reflection\n",
    "\n",
    "Write ~150 words, in the cell below, on the single dumbest bug you hit building this. Mask off-by-one? `view` without `transpose`? Forgot to divide by $\\sqrt{d_k}$ and watched the loss NaN? Tied the head by copying instead of aliasing so the `data_ptr` check failed? Name the symptom you saw first, the wrong hypothesis you chased, and the one print or assert that finally located it. Nobody grades this. Writing it is how the debugging move becomes yours, so the next mask bug costs you a minute instead of an afternoon.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ea48b934",
   "metadata": {},
   "source": [
    "*Your reflection here. (Double-click to edit. The act of writing is the point; there is no check.)*\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2c90d370",
   "metadata": {},
   "source": [
    "## Going further\n",
    "\n",
    "- Karpathy, **nanoGPT** (`model.py` + `train.py`) — the reference small-GPT. Diff its `Block` against yours: combined QKV, `F.scaled_dot_product_attention` (Flash) when available, `nn.GELU(approximate='tanh')`, weight tying. Same math, four optimisations.\n",
    "- **The Annotated Transformer** (Harvard NLP) — the paper rebuilt as runnable code, encoder-decoder and all. Read once you have built your own decoder-only version.\n",
    "- Vaswani et al., **Attention Is All You Need** (2017) — the source of the $\\sqrt{d_k}$ argument and the original post-norm block you now know to avoid.\n",
    "- Su et al., **RoFormer** / the RoPE papers, and Lilian Weng's *Transformer Family v2* — the relative-position scheme that replaced learned embeddings in Llama and most modern models.\n",
    "- Elhage et al., **A Mathematical Framework for Transformer Circuits** (Anthropic, 2021) — the residual-stream view of Part 4, made rigorous. The on-ramp to mech-interp.\n",
    "- ARENA 3.0, **Transformer from Scratch** — the module-by-module exercise set this notebook's structure echoes, with reference-weight checks against real GPT-2.\n",
    "\n",
    "## What this enables\n",
    "\n",
    "- **Ch 16 — Multimodal Transformers**: image patches projected into the same token space your transformer already reads. The architecture is this one.\n",
    "- **Ch 17 — Efficient Inference**: KV-cache, speculative decoding, FlashAttention, RoPE extrapolation, all built on the forward pass you just wrote. You reached a tiny char model; Ch 17 makes the same model fast.\n",
    "- **Ch 19 — RLHF**: fine-tunes exactly this model. The architecture is a precondition.\n",
    "- **Ch 22 — Mech-Interp**: assumes you can compute a forward pass by hand and know what a head and the residual stream are. Now you can. The per-head entropy diagnostic from Exercise 15.11 is the first interp tool.\n",
    "- **Ch 24 — AI Safety & Red-Team**: the tokenizer attacks, mask-leakage bugs, and refusal-direction ablations in the Safety lens are exploits of this exact architecture.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7002809d",
   "metadata": {},
   "source": [
    "---\n",
    "*Built top-to-bottom. If every check above printed `[ ok ]`, you reproduced the chapter: tokenizer, attention with a verified causal mask, a weight-tied GPT, a fixed init, a trained model, and tested decoders. Runtime stamp written by CI.*\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "obvix-nb",
   "language": "python",
   "name": "obvix-nb"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.10.12"
  },
  "obvix": {
   "title": "Ch 15 — Transformers from Scratch"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
