XP: 0
⚙️ Part 1 · Module 6

nanoGPT — Build GPT From Scratch

This is the summit. You'll assemble a real GPT — the same architecture as ChatGPT, just tiny — from parts you already understand: tokens, embeddings, self-attention, transformer blocks, and the training loop. Every concept from the fundamentals course becomes ~300 lines of working PyTorch.
The summit

From name generator to GPT — same idea, real architecture

makemore's ladder ended at "Transformer." That's this module. GPT does the exact same job as makemore — predict the next token — but with self-attention so it can look back at every previous token, at any distance (fundamentals Modules 9–13, finally in code).

The good news: you've already learned every ingredient. Today we just wire them together in the right order.

nanoGPT is real and tiny

Andrej Karpathy's nanoGPT is a full GPT in ~300 lines — small enough to read in an afternoon, real enough to train on Shakespeare and generate convincing text. We'll build its pieces here; the copyable code is the real thing you can run in Colab.

🎮 Game 1

The GPT architecture (click each block)

Here's the whole model, bottom to top. Data flows up: tokens come in, get embedded, pass through transformer blocks, and out come next-token probabilities. Click each block to see its role — and the real code that builds it.

tokens → embeddings → blocks → output
Click the colored blocks (bottom = input).
👆 Click any block to see what it does and its code.
Ingredient 1 & 2

Tokens + Embeddings + Positions

Input text → token IDs (Module 4). Each ID looks up a learned embedding vector (fundamentals Module 4). We also add a positional embedding so the model knows word order (fundamentals Module 12 — attention sees all tokens at once, so it needs position info).

python · cell 1 · data, tokens, batches, embeddings
import urllib.request
import torch
import torch.nn as nn
from torch.nn import functional as F

torch.manual_seed(1337)
device = "cuda" if torch.cuda.is_available() else "cpu"

# 1) Load raw text. This is the dataset the tiny GPT will imitate.
url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
text = urllib.request.urlopen(url).read().decode("utf-8")

# 2) Tokenize characters: each unique character gets an integer ID.
chars = sorted(list(set(text)))
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[ch] for ch in s]
decode = lambda ids: "".join(itos[i] for i in ids)

# 3) Split into train and validation text.
data = torch.tensor(encode(text), dtype=torch.long)
split = int(0.9 * len(data))
train_data, val_data = data[:split], data[split:]

# 4) Hyperparameters. n_embd=128 means each token becomes a 128-number vector.
batch_size = 32
block_size = 64
n_embd = 128
n_head = 4
n_layer = 4
dropout = 0.2

def get_batch(split_name):
    source = train_data if split_name == "train" else val_data
    # Pick 32 random starting points. Each example is 64 characters long.
    starts = torch.randint(len(source) - block_size, (batch_size,))
    x = torch.stack([source[i:i + block_size] for i in starts])
    y = torch.stack([source[i + 1:i + block_size + 1] for i in starts])
    return x.to(device), y.to(device)

# 5) Embeddings: token meaning-vector + position/order-vector.
token_embedding = nn.Embedding(vocab_size, n_embd).to(device)
position_embedding = nn.Embedding(block_size, n_embd).to(device)
xb, yb = get_batch("train")
positions = torch.arange(block_size, device=device)
x = token_embedding(xb) + position_embedding(positions)

print("device:", device)
print("characters:", len(text), "| vocabulary:", vocab_size)
print("input batch:", xb.shape)
print("combined embeddings:", x.shape)
Expected output · Cell 1
device: cuda
characters: 1115394 | vocabulary: 65
input batch: torch.Size([32, 64])
combined embeddings: torch.Size([32, 64, 128])
32batches
×
64characters per row
128numbers per token
Ingredient 3 · the heart

🎮 Game 2 — Self-attention (with the causal mask)

The core of GPT. Every token asks "which earlier tokens matter to me?" and blends their info (fundamentals Modules 9–11: Query·Key→scores→weighted Values). Crucially, it's masked: a token can only attend to itself and earlier tokens — never the future (that would be cheating at next-token prediction).

"the cat sat" — who attends to whom
Rows = query token · Columns = key token · upper-right is masked 🔒.
The lower-left triangle is filled; the future (upper-right) is masked out.
python · cell 2 · causal multi-head self-attention
class Head(nn.Module):
    def __init__(self, head_size):
        super().__init__()
        self.key = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)
        self.register_buffer(
            "causal_mask",
            torch.tril(torch.ones(block_size, block_size)),
        )
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        B, T, C = x.shape
        # key = what each token contains; query = what each token is looking for.
        k = self.key(x)
        q = self.query(x)

        # Query @ Key scores decide which earlier tokens matter.
        weights = q @ k.transpose(-2, -1)
        weights = weights * (k.shape[-1] ** -0.5)
        # Causal mask hides future tokens, so GPT cannot peek ahead.
        weights = weights.masked_fill(
            self.causal_mask[:T, :T] == 0,
            float("-inf"),
        )
        weights = F.softmax(weights, dim=-1)
        weights = self.dropout(weights)

        # value = information to mix together using the attention weights.
        v = self.value(x)
        return weights @ v


class MultiHeadAttention(nn.Module):
    def __init__(self, num_heads, head_size):
        super().__init__()
        self.heads = nn.ModuleList(
            [Head(head_size) for _ in range(num_heads)]
        )
        self.projection = nn.Linear(num_heads * head_size, n_embd)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        joined = torch.cat([head(x) for head in self.heads], dim=-1)
        return self.dropout(self.projection(joined))


# Shape check: attention changes the content, but keeps the same shape.
attention = MultiHeadAttention(n_head, n_embd // n_head).to(device)
print("attention output:", attention(x).shape)
Expected output · Cell 2
attention output: torch.Size([32, 64, 128])
[32,64,128]in
→ attention mixes past context →
[32,64,128]out
Ingredient 4

🎮 Game 3 — Assemble one Transformer block

A layer here means one processing operation, such as attention, LayerNorm, or a linear layer. A Transformer block is a reusable package of several layers. GPT sends the text through a stack of these blocks, so every block gets another chance to understand the context.

The two jobs inside every block

Attention is communication: each token reads useful information from earlier tokens. The feed-forward network is computation: each token privately transforms what it learned. In short: look around, then think.

Worked example: follow “sat” through one Transformer block
1

Start with token vectors

In “The cat sat on the mat,” every token starts as a vector of 128 learned numbers. The vector for “sat” contains the model's current understanding of that token.

"The" → [128 numbers] "cat" → [128 numbers] "sat" → [128 numbers]
2

LayerNorm prepares stable values

LayerNorm recenters and rescales each token's numbers before attention. It changes their scale, not the tensor's dimensions or the identity of the token.

"sat": [12.4, -0.02, 7.8, ...] → [1.2, -0.41, 0.7, ...] shape: [32, 64, 128] → [32, 64, 128]
3

Self-attention looks around

“Sat” examines itself and earlier tokens. For example, it might give “cat” the most attention because the cat performed the action. Different heads can search for different relationships.

context for "sat" = 15% of "The" + 60% of "cat" + 25% of "sat" Head 1: Who performed the action? → "cat" Head 2: What grammatical pattern is this?
4

The residual connection preserves the original

Attention adds useful context, but the model should not discard the original meaning of “sat.” It adds the attention result to the original vector.

new "sat" = original "sat" + contextual information "sat" + "the cat performed this action" = context-aware "sat"
5

The feed-forward network thinks privately

The FFN processes each token independently. It expands the 128 values to 512, applies a nonlinearity, and returns to 128. This gives it room to recognize features such as “verb,” “past tense,” and “action.”

"sat": 128 values → 512 values → ReLU → 128 values Attention: tokens communicate with each other FFN: each token processes its own gathered information
6

Stack blocks for deeper understanding

One block refines every token once. Later blocks receive those improved vectors and can build richer relationships before the model predicts the next token.

Block 1: "sat" is related to "cat" Block 2: "cat sat" describes an event Later blocks: learn richer grammar, meaning, and likely continuations
Remember: attention gathers information from other tokens. The FFN processes that gathered information. One block does both once; GPT stacks many blocks to repeat and deepen the transformation.
Follow one token through a block
Click each stage. We follow "sat" in “the cat sat on the mat.”
Attention mixes across tokens.
“sat” can read “the” and “cat.” This is where context moves between positions.
The FFN does not mix tokens.
It applies the same small neural network separately to every token vector.
One block refines the representation once. Stacking blocks repeats the process:
raw token meaningBlock 1
local patterns
Block 2
richer context
Block 3
better prediction
Build a block: attention → FFN (+ norm + residual)
Now assemble the same four operations in execution order.
python · cell 3 · feed-forward network and transformer block
class FeedForward(nn.Module):
    def __init__(self, embedding_size):
        super().__init__()
        # Attention communicates BETWEEN tokens. The FFN then processes each
        # token independently; it never reads a neighboring token directly.
        # 128 → 512 gives it room to learn richer features, then 512 → 128
        # returns to the original size so it can be added back to x.
        self.network = nn.Sequential(
            nn.Linear(embedding_size, 4 * embedding_size),
            nn.ReLU(),
            nn.Linear(4 * embedding_size, embedding_size),
            nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.network(x)


class Block(nn.Module):
    def __init__(self, embedding_size, num_heads):
        super().__init__()
        head_size = embedding_size // num_heads
        self.attention = MultiHeadAttention(num_heads, head_size)
        self.feed_forward = FeedForward(embedding_size)
        # LayerNorm keeps each token vector at a stable numerical scale.
        # It changes values, not shape: [B, T, 128] stays [B, T, 128].
        self.layer_norm_1 = nn.LayerNorm(embedding_size)
        self.layer_norm_2 = nn.LayerNorm(embedding_size)

    def forward(self, x):
        # Residual 1: preserve the old token representation and add context.
        x = x + self.attention(self.layer_norm_1(x))
        # Residual 2: preserve that result and add the FFN's computation.
        x = x + self.feed_forward(self.layer_norm_2(x))
        return x


block = Block(n_embd, n_head).to(device)
print("transformer block output:", block(x).shape)
Expected output · Cell 3
transformer block output: torch.Size([32, 64, 128])
attentionlook back
+
FFNthink
+
residualspreserve signal
🎮 Game 4

Build the full GPT — the checklist

Assemble the whole model by ticking off each part. Every one is something you've learned — click to check it and see the line of code.

Tick each ingredient to complete your GPT
0 / 6 built
python · cell 4 · complete GPT model and training loop
class GPTLanguageModel(nn.Module):
    def __init__(self):
        super().__init__()
        # Convert token IDs and positions into vectors.
        self.token_embedding = nn.Embedding(vocab_size, n_embd)
        self.position_embedding = nn.Embedding(block_size, n_embd)
        # Stack 4 Transformer blocks: attention + feed-forward, repeated.
        self.blocks = nn.Sequential(
            *[Block(n_embd, n_head) for _ in range(n_layer)]
        )
        self.final_layer_norm = nn.LayerNorm(n_embd)
        self.language_model_head = nn.Linear(n_embd, vocab_size)
        self.apply(self._initialize_weights)

    def _initialize_weights(self, module):
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def forward(self, idx, targets=None):
        B, T = idx.shape
        if T > block_size:
            raise ValueError(f"Sequence length {T} exceeds block_size {block_size}")

        # Add meaning (token) vectors and order (position) vectors.
        token_vectors = self.token_embedding(idx)
        positions = torch.arange(T, device=idx.device)
        position_vectors = self.position_embedding(positions)
        x = token_vectors + position_vectors

        # Transformer stack reads the context and produces one vector per token.
        x = self.blocks(x)
        x = self.final_layer_norm(x)
        # Logits are raw scores for the next character in the vocabulary.
        logits = self.language_model_head(x)

        loss = None
        if targets is not None:
            B, T, C = logits.shape
            # Cross-entropy asks: did the model score the true next token highly?
            loss = F.cross_entropy(logits.reshape(B * T, C), targets.reshape(B * T))

        return logits, loss

    def generate(self, idx, max_new_tokens, temperature=0.8, top_k=40):
        self.eval()
        for _ in range(max_new_tokens):
            # Only the latest block_size tokens fit into the model's context window.
            context = idx[:, -block_size:]
            logits, _ = self(context)
            # Use only the final time step to choose the next character.
            logits = logits[:, -1, :] / temperature

            if top_k is not None:
                # Keep the top choices so sampling is creative but not totally wild.
                k = min(top_k, logits.shape[-1])
                threshold = torch.topk(logits, k).values[:, [-1]]
                logits = logits.masked_fill(logits < threshold, float("-inf"))

            probabilities = F.softmax(logits, dim=-1)
            next_id = torch.multinomial(probabilities, num_samples=1)
            idx = torch.cat((idx, next_id), dim=1)

        self.train()
        return idx


model = GPTLanguageModel().to(device)
parameter_count = sum(p.numel() for p in model.parameters())
print(f"parameters: {parameter_count / 1e6:.2f} million")

learning_rate = 3e-4
max_steps = 2000
eval_interval = 200
eval_batches = 50
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)

@torch.no_grad()
def estimate_loss():
    # Average many random batches so the printed loss is not too noisy.
    model.eval()
    result = {}
    for split_name in ("train", "val"):
        losses = torch.zeros(eval_batches)
        for i in range(eval_batches):
            inputs, targets = get_batch(split_name)
            _, batch_loss = model(inputs, targets)
            losses[i] = batch_loss.item()
        result[split_name] = losses.mean().item()
    model.train()
    return result


for step in range(max_steps + 1):
    if step % eval_interval == 0:
        losses = estimate_loss()
        print(
            f"step {step:4d} | "
            f"train {losses['train']:.4f} | val {losses['val']:.4f}"
        )
    if step == max_steps:
        break

    # One real training step: forward, loss, backward, optimizer step.
    inputs, targets = get_batch("train")
    _, loss = model(inputs, targets)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()
Expected output · Cell 4
parameters: 0.82 million
step    0 | train 4.1623 | val 4.1627
step  200 | train 2.4830 | val 2.4907
step  400 | train 2.3130 | val 2.3258
step  600 | train 2.0809 | val 2.1146
step  800 | train 1.9750 | val 2.0289
step 1000 | train 1.8652 | val 1.9746
step 1200 | train 1.8191 | val 1.9441
step 1400 | train 1.7646 | val 1.9005
step 1600 | train 1.7283 | val 1.8928
step 1800 | train 1.6938 | val 1.8591
step 2000 | train 1.6781 | val 1.8419
loss starts high train 1.6781 val 1.8419 0 2000 steps
🎮 Game 5

Generate text (the payoff)

A GPT trained on Shakespeare generates one token at a time — auto-regression, exactly like makemore, but with attention. The browser illustration previews the behavior; Cell 5 runs your actually trained model.

Trained on Shakespeare → generating
One token at a time, until it decides to stop.
Press "Generate" to watch your GPT write…
python · cell 5 · generate from your trained GPT
# Run after Cell 4 finishes training.
# Start the model with a prompt, then let it predict one character at a time.
prompt = "ROMEO:\n"
context = torch.tensor([encode(prompt)], dtype=torch.long, device=device)

with torch.no_grad():
    generated_ids = model.generate(
        context,
        max_new_tokens=500,  # how much new text to write
        temperature=0.8,    # lower = safer, higher = wilder
        top_k=40,           # sample from the top 40 choices
    )

generated_text = decode(generated_ids[0].tolist())
print(generated_text)
Expected output · Cell 5
ROMEO: He love thee know's far speak that is heart that show The noth wife, their be his of your seep, Iunable all thing some and dother grace I dose, There the thus, greasumpart our shall deftly of what be's this speak eye fr to my sturlives all then himself throng's and she And I am stephy bother in wears and in hide remine: I which reshould this here truse me, for hears Were for for the comment is so a which all him. PAULINA: My genature I press thing lose follot seet of were If the to with you, to
🎮 Game 6

From nanoGPT to GPT-4 — just scale

Here's the wild part: GPT-4 is this same architecture, scaled up. Slide the size and watch the parameter count (and capability) grow. The recipe doesn't change — just bigger blocks, more of them, more data.

Same code, bigger numbers
Drag to scale the model.
nanosmallmediumlargeGPT-4-ish
The humbling truth

The architecture you just built is the architecture behind ChatGPT, Claude, and Gemini. The difference is scale (billions of parameters vs. thousands), data (much of the internet vs. Shakespeare), and the training stages you saw in fundamentals Module 15 (pretraining → fine-tuning → RLHF). But the core? You just built it.

🎯 Check your understanding

Quick challenge

1. What job does GPT do (same as makemore)?
2. Why is self-attention masked in GPT?
3. Why add positional embeddings?
4. A Transformer block contains…
5. How does GPT-4 differ from the nanoGPT you built?
Takeaway

What you built

You assembled a real GPT from parts you already knew: token + positional embeddings → a stack of Transformer blocks (masked self-attention + feed-forward, each with norm + residual) → a final layer giving next-token probabilities. Training is Module 3's loop; generation is makemore's sample-and-append, now with attention. Masking keeps it honest (no peeking at the future). And the big reveal: GPT-4 is this same architecture, just scaled — you now understand the machine behind modern AI, top to bottom.
🏔️ You reached the summit of Half 1.

Tensors → autograd → training loop → data → makemore → GPT from scratch. You can now read (and run) nanoGPT and genuinely understand every line. Half 2 shifts from building models to building with them — RAG, vector databases, and memory.

Next up → Module 7: Fine-tuning a Pretrained Model — instead of training from zero, take an existing model (GPT-2) and specialize it on your own data. Checkpoints, saving/loading, and the practical workflow people actually use.