Current: Week 10

0%

Week 10

Week 10 — GPT architecture: assemble a trainable Mini GPT

Key questionHow do we assemble the token, attention, Transformer and tokenizer interfaces from Weeks 6–9 into a MiniGPT that supports training, saving and generation?

Learning objectives

  • Use one GPTConfig to explain vocabulary, context, width, head-divisibility and depth constraints.
  • Trace [2,2] → [2,2,4] → two Pre-Norm blocks → [2,2,5] through a complete forward.
  • Identify the owner of every parameter, buffer and temporary activation, and count the canonical untied model's 520 parameters by hand.
  • Implement and inspect stable forward, state-dict/checkpoint and external generation boundaries in preparation for Week 11's training loop.

85 min estimated reading time

Week progress: 0 of 18 sections (0%)Course progress: 0 of 321 sections (0%)

This week assembles the prediction function; you do not need to master checkpoint tooling first. Keep the five-token input and connect embeddings, attention, FFNs, residuals and normalization step by step. The final interface still returns logits and an optional loss.

Scroll horizontally to view all columns.

Course data table
Learning unitProblem to solve
1: The smallest modelStart with token/position embeddings and an output head. Identify inputs, parameters and outputs.
2: Assemble one component at a timeSingle head → multiple heads → a complete Pre-Norm block → stacked blocks. Observe the parameters and shapes added at each stage.
3: Inspect axes and parametersTrack numbered elements through split/transpose/merge operations. Check ModuleList registration and parameter counts.
4: Hand the model to the training loopThe model handles forward computation. The full checkpoint format and weight sharing are later engineering extensions.

Run python week10_stages.py --stage embedding, then use single, multi, block and full. The full default model matches mini_gpt_walkthrough.py. Its 520 parameters belong to the specified configuration, not every possible MiniGPT.

Alternate reading, hand calculation and code changes. Each unit can take several sessions. Section numbers remain stable for old links and references; follow the page from top to bottom rather than jumping around to restore numerical order.

Week 10 Goal: Connect the Components Behind One Stable Interface

The English mini-gpt-v1 tokenizer splits on whitespace and fixes ordered tokens [you, like, AI, study, we] at IDs 0..4. It has no BOS, EOS, PAD or UNK. Week 9's independent V=11, T=4 artifact is no longer in use; do not pass its integers directly into this model.

Scroll horizontally to view all columns.

mini-gpt-v1,V=5
IDordered token
0you
1like
2AI
3study
4we

This week's fixed mini-gpt-v1 uses B=2 batch rows, T=2 current token positions, C=4 representation channels per position, H=2 attention heads, d_head=C/H=2 channels per head, V=5 candidate tokens and n_layer=2 Transformer blocks. Reserve L for a token stream or generation-history length, never the number of layers.

Scroll horizontally to view all columns.

idx=[[0,1],[4,1]] and targets=[[1,2],[1,0]] are both torch.long tensors of shape [B,T]=[2,2].
batch rowidx IDsinput tokenstarget IDsFour teacher-forced prediction tasks
b=0[0,1][you, like][1,2][like, AI]
b=1[4,1][we, like][1,0][like, you]
mini_gpt_walkthrough.py
import torch


idx = torch.tensor([
    [0, 1],  # you like
    [4, 1],  # we like
], dtype=torch.long)  # [B,T] = [2,2]

targets = torch.tensor([
    [1, 2],  # you->like, like->AI
    [1, 0],  # we->like, like->you
], dtype=torch.long)  # [B,T] = [2,2]

Concept sequence
  1. mini-gpt-v1 IDs [B,T] = [2,2]
  2. token + position representations [B,T,C] = [2,2,4]
  3. pre-norm Block 1 [2,2,4]
  4. pre-norm Block 2 [2,2,4]
  5. final LayerNorm [2,2,4]
  6. bias-free LM head logits [B,T,V] = [2,2,5]
  7. optional reshape [4,5] with targets [4] → scalar mean cross-entropy

Scroll horizontally to view all columns.

Course data table
caller modeforward inputforward outputcaller consumes
training / evaluationidx [2,2] + targets [2,2]logits [2,2,5] + scalar lossall four aligned positions
generationcropped context, no targetslogits [B,T,5] + Noneonly logits[:,-1,:] for one append

Knowledge check

If idx has shape [2,2], what are the shapes of logits and flattened targets?

1. Model Configuration: What Does Each Number Control?

Scroll horizontally to view all columns.

Course data table
When you encounter this in codeRead it as
class GPTConfigDeclares the specifications to build
config = GPTConfig()Creates one concrete specification
class MiniGPT(nn.Module)Defines how to build a model from that specification and compute with it
model = MiniGPT(config)Creates an actual set of parameters
self.xxxAn object owned by this model instance
forward / model(idx)Performs one computation using current parameters

dataclass reduces configuration boilerplate. frozen=True fixes configuration fields; it does not stop the neural network from learning. An annotation such as idx: torch.Tensor helps readers and tooling, but runtime input checks must still be implemented inside the function.

Configuration answers “what should we build?” nn.Parameter identifies numbers that training can change. frozen=True prevents accidental configuration edits, not model-parameter updates. This minimal architecture deliberately has no dropout field.

mini_gpt_walkthrough.py
import hashlib
import json
from dataclasses import dataclass

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


@dataclass(frozen=True)
class GPTConfig:
    vocab_size: int = 5
    block_size: int = 2
    n_embd: int = 4
    n_head: int = 2
    n_layer: int = 2

    def validate(self) -> None:
        if self.vocab_size <= 0:
            raise ValueError("vocab_size must be positive")
        if self.block_size <= 0:
            raise ValueError("block_size must be positive")
        if self.n_embd <= 0:
            raise ValueError("n_embd must be positive")
        if self.n_head <= 0:
            raise ValueError("n_head must be positive")
        if self.n_layer <= 0:
            raise ValueError("n_layer must be positive")
        if self.n_embd % self.n_head != 0:
            raise ValueError("n_embd must be divisible by n_head")

Scroll horizontally to view all columns.

Course data table
field / valueFailure or ambiguity it preventsconcrete owners
vocab_size=5No valid range for input IDs or output classestoken_embedding rows and lm_head outputs
block_size=2Disagreement between maximum context, position rows, mask and generation cropposition_embedding、causal_mask、caller crop
n_embd=4Inconsistent representation widths between componentsBoth embeddings, norms, attention, FFN and the lm_head input
n_head=2No valid equal-width partition into headsqkv reshape and the score tensor's head axis
n_layer=2Ambiguous block depth/order or unregistered repeated modulesModuleList construction and the forward loop
dhead=nembdnhead=42=2d_{\mathrm{head}}=\frac{n_{\mathrm{embd}}}{n_{\mathrm{head}}}=\frac{4}{2}=2

This contract requires token_embedding.weight [5,4] and position_embedding.weight [2,4]. The lm_head maps the final dimension from four channels to five candidate scores. The same configuration belongs in checkpoint compatibility metadata.

Knowledge check

Which field determines the position-table row count, causal-mask side length and generation crop?

2. Configuration Constraints: Report Errors Early

C=n_embd=4 is the width shared by the blocks and H=n_head=2 is the number of heads. Thus 4 mod 2=0 and d_head=2. In contrast, n_embd=4 with n_head=3 has no integer head width. Reject it in config.validate(), not later in view.

CmodH=0,dhead=CH,1Tblock_sizeC\bmod H=0,\qquad d_{\mathrm{head}}=\frac{C}{H},\qquad 1\le T\le \mathrm{block\_size}

Scroll horizontally to view all columns.

Course data table
input / configResultReason to report it early
n_embd=4, n_head=2Valid: d_head=2[B,T,4] can be reshaped/reordered into [B,H,T,d_head]
n_embd=4, n_head=3ValueErrorFour channels cannot be divided equally into three integer-width heads
idx shape [B,1] or [B,2]Validblock_size is an upper bound, not a requirement to fill every position
idx shape [B,0] or [B,3]ValueErrorAn empty sequence has no final position; T=3 exceeds the position/mask capacity
Floating-point IDs or ID=5TypeError / ValueErrorThis model's input contract requires torch.long IDs in the range 0..4
python
config = GPTConfig()
config.validate()
assert config.n_embd // config.n_head == 2

try:
    GPTConfig(n_head=3).validate()
except ValueError as error:
    print(error)  # n_embd must be divisible by n_head

MiniGPT.forward first checks rank and dtype, then reads B and T. It rejects empty tensors before calling min/max, then checks the ID range. targets must also have the same shape as idx, use torch.long and contain IDs 0..4. Section 7 puts these checks in the canonical class.

Knowledge check

Why can [B,2,4] be split into two equal-width heads but not three?

3. GPT Data Flow: Start with Inputs and Outputs

MiniGPT.forward always computes logits. When a training/evaluation caller supplies aligned targets, it also returns scalar mean cross entropy. A generation caller supplies no targets and consumes logits only. Forward is the neural-network map: it does not update the optimizer or select the next token.

Scroll horizontally to view all columns.

Course data table
ownerinputoutput / responsibility
calleridx [2,2], optional targets [2,2]Chooses how to use the outputs for training/evaluation or generation
token_embeddingIDs 0..4identity representations [2,2,4]
position_embeddingpositions [2]Position rows [2,4], broadcast across B
blocks[0] and blocks[1][2,2,4]Sequentially produces contextual representations [2,2,4]
final_norm + lm_head[2,2,4]Per-position logits [2,2,5]
loss branchlogits + aligned targetsScalar mean CE only when targets are present
generation callerFinal-position logits [B,5]Selects and appends next_id [B,1] outside forward
Concept sequence
  1. idx [2,2]
  2. token rows [2,2,4] + position rows [2,4] broadcast
  3. blocks[0] [2,2,4]
  4. blocks[1] [2,2,4]
  5. final_norm [2,2,4]
  6. lm_head logits [2,2,5]
  7. targets present? reshape logits [4,5] and targets [4] → mean loss []
logits=fθ(idx)RB×T×V\mathrm{logits}=f_{\theta}(\mathrm{idx})\in\mathbb{R}^{B\times T\times V}
L=CE ⁣(reshape(logits,[BT,V]),reshape(targets,[BT]))\mathcal{L}=\operatorname{CE}\!\left(\operatorname{reshape}(\mathrm{logits},[BT,V]),\operatorname{reshape}(\mathrm{targets},[BT])\right)

Knowledge check

Which output do both training and generation receive? Which requires targets?

4. Causal Self-Attention: The Same Operation with Combined QKV

Q describes the current query's matching needs, K describes how each visible position can be matched, and Value contains the information to bring back. Week 7 used three separate projections for one head. Here one bias-free qkv Linear produces three width-C tensors, then we explicitly introduce the H axis. The mathematical operation is the same; class organization and state-dict keys differ.

mini_gpt_walkthrough.py
class CausalSelfAttention(nn.Module):
    def __init__(self, config: GPTConfig) -> None:
        super().__init__()
        self.n_head = config.n_head
        self.head_size = config.n_embd // config.n_head
        self.qkv = nn.Linear(
            config.n_embd,
            3 * config.n_embd,
            bias=False,
        )
        self.output_projection = nn.Linear(
            config.n_embd,
            config.n_embd,
        )
        mask = torch.tril(
            torch.ones(config.block_size, config.block_size)
        )
        self.register_buffer(
            "causal_mask",
            mask.view(1, 1, config.block_size, config.block_size),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        B, T, C = x.shape
        q, k, value_states = self.qkv(x).chunk(3, dim=-1)
        q = q.view(B, T, self.n_head, self.head_size).transpose(1, 2)
        k = k.view(B, T, self.n_head, self.head_size).transpose(1, 2)
        value_states = value_states.view(
            B,
            T,
            self.n_head,
            self.head_size,
        ).transpose(1, 2)

        scores = (q @ k.transpose(-2, -1)) * (self.head_size ** -0.5)
        visible = self.causal_mask[:, :, :T, :T]
        scores = scores.masked_fill(visible == 0, float("-inf"))
        weights = F.softmax(scores, dim=-1)
        output = weights @ value_states
        output = output.transpose(1, 2).contiguous().view(B, T, C)
        return self.output_projection(output)

Concept sequence
  1. x [B,T,C] = [2,2,4]
  2. qkv(x) [2,2,12]
  3. chunk → q, k, value_states each [2,2,4]
  4. reshape + transpose → each [B,H,T,d_head] = [2,2,2,2]
  5. scores / weights [B,H,T,T] = [2,2,2,2]
  6. weighted Values [2,2,2,2]
  7. transpose + contiguous + view [2,2,4]
  8. biased output_projection [2,2,4]

Scroll horizontally to view all columns.

Every batch row and head uses [[1,0],[1,1]]. At runtime T=1, slice the mask to shape [1,1,1,1].
causal_mask row=query / column=keyj=0: first tokenj=1: second token
t=0: first query1 allow0 forbid
t=1: final like query1 allow1 allow
A=softmax ⁣(QKdhead+M),Attention(X)=AValueStatesA=\operatorname{softmax}\!\left(\frac{QK^{\top}}{\sqrt{d_{\mathrm{head}}}}+M\right),\qquad \operatorname{Attention}(X)=A\,\mathrm{ValueStates}

register_buffer makes causal_mask participate in model.to(device), state_dict and module traversal without optimizer updates. The final like query may read position 0: you in the first row, we in the second. Their contextual outputs can therefore differ. Random weights do not guarantee any particular prediction.

Knowledge check

Which key columns can have nonzero weight for query position 0, and why?

5. Splitting Heads: Read Axis Meanings, Not Just Numbers

This week's fixed mini-gpt-v1 uses B=2 batch rows, T=2 current token positions, C=4 representation channels per position, H=2 attention heads, d_head=C/H=2 channels per head, V=5 candidate tokens and n_layer=2 Transformer blocks. Reserve L for a token stream or generation-history length, never the number of layers.

Scroll horizontally to view all columns.

Identical numeric shapes can have different semantic axis orders.
stageshape with named axesoperation meaning
q after chunk[B,T,C]=[2,2,4]Four q channels per token row
q.view[B,T,H,d_head]=[2,2,2,2]Group C=4 as 2×2 without changing the element count
q.transpose(1,2)[B,H,T,d_head]=[2,2,2,2]Each head receives its own T×d_head matrix
q @ kᵀ[B,H,T,T]=[2,2,2,2]The third axis indexes query t; the fourth indexes key j
weights @ Values[B,H,T,d_head]=[2,2,2,2]Each query receives two features per head
transpose back[B,T,H,d_head]=[2,2,2,2]Put token position back before head
contiguous().view[B,T,C]=[2,2,4]Merge H×d_head along the feature axis
Qb,hRT×dhead,Qb,hKb,hRT×TQ_{b,h}\in\mathbb{R}^{T\times d_{\mathrm{head}}},\qquad Q_{b,h}K_{b,h}^{\top}\in\mathbb{R}^{T\times T}
Hdhead=22=C=4H\,d_{\mathrm{head}}=2\cdot2=C=4

transpose changes strides/layout without copying the underlying values. If the following view needs adjacent feature values in memory, call contiguous() first. reshape may make a copy automatically; contiguous().view here makes the rejoining step explicit.

For a diagnostic example, create a separate GPTConfig(block_size=3), keep C=4 and H=2, and use B=1,T=3. Shapes should pass through [1,3,4]→[1,3,2,2]→[1,2,3,2], with scores [1,2,3,3]. Do not feed T=3 into the default block_size=2 model. This separate instance is not the canonical checkpoint.

Knowledge check

What is q's semantic axis order before computing scores?

6. FeedForward and Block: Per-Position Computation After Communication

Attention communicates across positions: like@1 can read you@0 or we@0. The FFN applies the same function separately to each x[b,t,:]; it does not directly read another position. Pre-Norm normalizes the branch input and adds the resulting update back to the residual state, which was not replaced by that normalized input.

mini_gpt_walkthrough.py
class FeedForward(nn.Module):
    def __init__(self, config: GPTConfig) -> None:
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(config.n_embd, 4 * config.n_embd),
            nn.GELU(),
            nn.Linear(4 * config.n_embd, config.n_embd),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)


class TransformerBlock(nn.Module):
    def __init__(self, config: GPTConfig) -> None:
        super().__init__()
        self.ln1 = nn.LayerNorm(config.n_embd)
        self.attention = CausalSelfAttention(config)
        self.ln2 = nn.LayerNorm(config.n_embd)
        self.feed_forward = FeedForward(config)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = x + self.attention(self.ln1(x))
        x = x + self.feed_forward(self.ln2(x))
        return x

Concept sequence
  1. x0 [2,2,4]
  2. ln1(x0) [2,2,4] → attention [2,2,4]
  3. x1 = x0 + attention update [2,2,4]
  4. ln2(x1) [2,2,4] → FFN [2,2,4] through 4→16→4
  5. x2 = x1 + FFN update [2,2,4]
  6. A second independent TransformerBlock repeats the same shape contract
x=x+Attention(LN1(x)),xout=x+FFN(LN2(x))x' = x+\operatorname{Attention}(\operatorname{LN}_1(x)),\qquad x_{\mathrm{out}}=x'+\operatorname{FFN}(\operatorname{LN}_2(x'))
FFN(u)=GELU(uW1+b1)W2+b2\operatorname{FFN}(u)=\operatorname{GELU}(uW_1^{\top}+b_1)W_2^{\top}+b_2

Scroll horizontally to view all columns.

Course data table
sublayermixes positions?input → internal → output
causal attentionYes, reads only j≤t[2,2,4] → scores [2,2,2,2] → [2,2,4]
feed_forwardNo, each [b,t] is independent[2,2,4] → [2,2,16] → [2,2,4]
residual addNo, elementwise addition[2,2,4] + [2,2,4] → [2,2,4]

Knowledge check

Which sublayer lets like@1 use position 0, and which processes only like@1's current row?

7. Complete MiniGPT: Connect the Computations You Already Know

The code below uses the previously defined imports, GPTConfig, CausalSelfAttention, FeedForward and TransformerBlock. __init__ registers the persistent architecture; forward performs one computation. Canonical mini-gpt-v1 has two Pre-Norm blocks, independent token-embedding/output-head weights and no dropout.

mini_gpt_walkthrough.py
class MiniGPT(nn.Module):
    def __init__(self, config: GPTConfig) -> None:
        super().__init__()
        config.validate()
        self.config = config
        self.token_embedding = nn.Embedding(
            config.vocab_size,
            config.n_embd,
        )
        self.position_embedding = nn.Embedding(
            config.block_size,
            config.n_embd,
        )
        self.blocks = nn.ModuleList(
            [TransformerBlock(config) for _ in range(config.n_layer)]
        )
        self.final_norm = nn.LayerNorm(config.n_embd)
        self.lm_head = nn.Linear(
            config.n_embd,
            config.vocab_size,
            bias=False,
        )
        self.apply(self._init_weights)

    @staticmethod
    def _init_weights(module: nn.Module) -> None:
        if isinstance(module, nn.Linear):
            nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            nn.init.normal_(module.weight, mean=0.0, std=0.02)

    @staticmethod
    def _validate_token_ids(
        token_ids: torch.Tensor,
        *,
        name: str,
        vocab_size: int,
    ) -> None:
        if token_ids.dtype != torch.long:
            raise TypeError(f"{name} must have dtype torch.long")
        if token_ids.numel() == 0:
            raise ValueError(f"{name} must contain at least one token ID")
        minimum_id = int(token_ids.min().item())
        maximum_id = int(token_ids.max().item())
        if minimum_id < 0 or maximum_id >= vocab_size:
            raise ValueError(
                f"{name} IDs must be in [0, {vocab_size - 1}]"
            )

    def forward(
        self,
        idx: torch.Tensor,
        targets: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor | None]:
        if idx.ndim != 2:
            raise ValueError("idx must have rank 2 with shape [B,T]")
        if idx.dtype != torch.long:
            raise TypeError("idx must have dtype torch.long")

        B, T = idx.shape
        if T < 1 or T > self.config.block_size:
            raise ValueError(
                f"sequence length must be in [1, {self.config.block_size}]"
            )
        self._validate_token_ids(
            idx,
            name="idx",
            vocab_size=self.config.vocab_size,
        )

        if targets is not None:
            if targets.shape != idx.shape:
                raise ValueError("targets must have the same shape as idx")
            if targets.dtype != torch.long:
                raise TypeError("targets must have dtype torch.long")
            self._validate_token_ids(
                targets,
                name="targets",
                vocab_size=self.config.vocab_size,
            )

        positions = torch.arange(T, device=idx.device)  # [T]
        token_rows = self.token_embedding(idx)  # [B,T,C]
        position_rows = self.position_embedding(positions)  # [T,C]
        x = token_rows + position_rows  # broadcast to [B,T,C]
        for block in self.blocks:
            x = block(x)  # [B,T,C]
        x = self.final_norm(x)  # [B,T,C]
        logits = self.lm_head(x)  # [B,T,V]

        loss = None
        if targets is not None:
            loss = F.cross_entropy(
                logits.reshape(B * T, self.config.vocab_size),
                targets.reshape(B * T),
            )
        return logits, loss

Scroll horizontally to view all columns.

Keep these names stable from Week 10 through Week 12 so state-dict keys remain consistent.
stable ownercanonical shape / children
token_embedding.weight[V,C]=[5,4]
position_embedding.weight[block_size,C]=[2,4]
blocksModuleList of length n_layer=2; each item contains ln1, attention, ln2 and feed_forward
attentionqkv, output_projection, causal_mask
final_normweight [4] + bias [4]
lm_head.weight[V,C]=[5,4], bias-free and not tied to token_embedding
xb,t=Etoken[idxb,t]+Eposition[t]RCx_{b,t}=E_{\mathrm{token}}[\mathrm{idx}_{b,t}]+E_{\mathrm{position}}[t]\in\mathbb{R}^{C}
Concept sequence
  1. idx [2,2] passes rank / long / nonempty / T / ID checks
  2. positions [2]
  3. token_rows [2,2,4] + position_rows [2,4] → x [2,2,4]
  4. blocks[0] [2,2,4] → blocks[1] [2,2,4]
  5. final_norm [2,2,4] → lm_head logits [2,2,5]
  6. targets [2,2] → logits [4,5] plus targets [4] → scalar mean CE

Knowledge check

Why does positions have shape [T] while idx has [B,T]?

8. Why ModuleList Matters: Executable Does Not Mean Registered

Assigning an nn.Module to another nn.Module's attribute registers it. Use nn.ModuleList for a variable number of children. Then blocks.0.attention.qkv.weight and blocks.1.attention.qkv.weight are independent named parameters, and their two causal_mask tensors are registered buffers.

python
class IncorrectStack(nn.Module):
    def __init__(self, config: GPTConfig) -> None:
        super().__init__()
        self.blocks = [
            TransformerBlock(config) for _ in range(config.n_layer)
        ]


class RegisteredStack(nn.Module):
    def __init__(self, config: GPTConfig) -> None:
        super().__init__()
        self.blocks = nn.ModuleList(
            [TransformerBlock(config) for _ in range(config.n_layer)]
        )


config = GPTConfig()
incorrect = IncorrectStack(config)
registered = RegisteredStack(config)

assert len(list(incorrect.parameters())) == 0
assert len(list(registered.parameters())) > 0
assert "blocks.0.attention.qkv.weight" in registered.state_dict()

Scroll horizontally to view all columns.

Course data table
operationplain list childrenModuleList children
Explicit loop in forwardWorksWorks
model.parameters() / optimizerMissingIncluded
state_dict()Child state omittedIncludes named parameters and persistent buffers
model.to(device)Does not recursively move these childrenRecursively moves these children
train() / eval()Does not recursively switch these childrenRecursively switches these children
Blocki:RB×T×CRB×T×C,i{0,1}\operatorname{Block}_i:\mathbb{R}^{B\times T\times C}\to\mathbb{R}^{B\times T\times C},\qquad i\in\{0,1\}

Knowledge check

Name at least two practical omissions caused by an ordinary list.

9. Where Are the Parameters? Separate Persistent State from Temporary Tensors

A registered Parameter is a persistent tensor, usually with requires_grad=True. backward accumulates its gradient in parameter.grad; the optimizer then updates the parameter. Buffers move and are saved with the model but are not optimized through model.parameters(). idx, positions, scores, attention weights, logits and loss belong to the current computation as inputs or temporary tensors.

Scroll horizontally to view all columns.

Course data table
owner / objectshape in canonical modelkind
token_embedding.weight[5,4]learned parameter
position_embedding.weight[2,4]learned parameter
blocks.i.attention.qkv.weight[12,4], no biaslearned parameter
blocks.i.attention.output_projectionweight [4,4] + bias [4]learned parameters
blocks.i.ln1 / ln2Each has weight [4] + bias [4]learned parameters
blocks.i.feed_forward.net.0weight [16,4] + bias [16]learned parameters
blocks.i.feed_forward.net.2weight [4,16] + bias [4]learned parameters
blocks.i.attention.causal_mask[1,1,2,2]registered buffer,not trained
final_normweight [4] + bias [4]learned parameters
lm_head.weight[5,4], no bias and not tiedlearned parameter
idx / positions / scores / logits / lossChanges with the callinputs or temporary activations
θ={θi}i=1m,θiθiηLθi\theta=\{\theta_i\}_{i=1}^{m},\qquad \theta_i\leftarrow\theta_i-\eta\frac{\partial\mathcal{L}}{\partial\theta_i}

Knowledge check

Is causal_mask trained? Is it saved and moved with the model?

10. Count Parameters by Hand: 520 with the Default Untied Weights

Here V=5, block_size=2, C=4 and n_layer=2. QKV and the output head have no bias. Attention's output_projection and both FFN Linear layers have biases. Each block has two LayerNorms with learned scale/bias. token_embedding and lm_head are separate parameters.

Scroll horizontally to view all columns.

nn.Linear(in_features,out_features) stores weight [out_features,in_features]
ownercalculationparameters
token embeddingV×C = 5×420
position embeddingblock_size×C = 2×48
one attentionqkv 3C×C = 12×4;output C×C+C = 4×4+448+20=68
one FFN(4C×C+4C) + (C×4C+C)80+68=148
two block LayerNorms2×(C+C)16
one whole block68+148+16232
two independent blocksn_layer×232 = 2×232464
final LayerNormC+C8
independent bias-free LM headV×C = 5×420
canonical untied total20+8+464+8+20520
Nparams=VC+block_sizeC+nlayer(12C2+10C)+2C+VC=520N_{\mathrm{params}}=VC+\mathrm{block\_size}\,C+n_{\mathrm{layer}}(12C^2+10C)+2C+VC=520
python
canonical_model = MiniGPT(GPTConfig())
parameter_count = sum(
    parameter.numel() for parameter in canonical_model.parameters()
)
assert parameter_count == 520

causal_mask [1,1,2,2] is a buffer and contributes zero trainable parameters. The blocks share a class definition and shapes, not tensor storage, so count both.

Knowledge check

Why do the two LayerNorms in one block have 16 parameters altogether?

12. Start Correctness Checks with Shapes and API Boundaries

The canonical model takes two rows with two token positions each and returns five logits per position. Its four target IDs align with four logit rows. Loss shape [] denotes a rank-0 scalar, not a length-one vector.

python
config = GPTConfig()
model = MiniGPT(config)
logits, loss = model(idx, targets)

assert logits.shape == (2, 2, 5)
assert loss is not None and loss.ndim == 0
assert torch.isfinite(loss)

logits_without_targets, no_loss = model(idx)
assert logits_without_targets.shape == (2, 2, 5)
assert no_loss is None

# Boundary failures are deliberate and readable.
invalid_cases = [
    torch.tensor([0, 1], dtype=torch.long),  # rank 1
    torch.empty((2, 0), dtype=torch.long),  # T=0
    torch.tensor([[0, 1, 2]], dtype=torch.long),  # T=3
    torch.tensor([[0.0, 1.0]]),  # wrong dtype
    torch.tensor([[0, 5]], dtype=torch.long),  # ID out of 0..4
]
for invalid_idx in invalid_cases:
    try:
        model(invalid_idx)
    except (TypeError, ValueError):
        pass
    else:
        raise AssertionError("invalid idx was accepted")

invalid_target_cases = [
    torch.tensor([[1, 2]], dtype=torch.long),  # shape mismatch
    torch.tensor([[1.0, 2.0], [1.0, 0.0]]),  # wrong dtype
    torch.tensor([[1, 5], [1, 0]], dtype=torch.long),  # ID out of 0..4
]
for invalid_targets in invalid_target_cases:
    try:
        model(idx, invalid_targets)
    except (TypeError, ValueError):
        pass
    else:
        raise AssertionError("invalid targets were accepted")

Concept sequence
  1. idx [2,2]
  2. embeddings [2,2,4]
  3. block 1 [2,2,4]
  4. block 2 [2,2,4]
  5. logits [2,2,5]
  6. reshape logits [4,5] + targets [4]
  7. mean cross-entropy loss []
[2,2][2,2,4][2,2,4][2,2,4][2,2,5][4,5]+[4][][2,2]\to[2,2,4]\to[2,2,4]\to[2,2,4]\to[2,2,5]\to[4,5]+[4]\to[]

Scroll horizontally to view all columns.

Course data table
successful assertionWhat this establishesWhat this does not yet establish
logits.shape==(2,2,5)top-level output interfaceCorrect causal-mask direction or label meaning
loss.ndim==0 and finiteMean CE returns a usable scalarThat the model has learned the corpus
no targets → no_loss is Noneinference branch contractCorrect generation appending
invalid inputs raiseThe API boundary rejects these known invalid inputsCoverage of every possible error

Knowledge check

After shape/API assertions succeed, how do we check that future tokens do not leak?

13. Check Causality Through Observable Behavior

Both rows begin with you (ID 0); their second tokens are like (ID 1) and we (ID 4). Query position 0 may read only key column 0, so its five vocabulary logits must agree. Position 1 may differ because it can read itself.

python
model.eval()
past_same_future_changed = torch.tensor([
    [0, 1],  # you like
    [0, 4],  # you we: deliberately changed future token
], dtype=torch.long)

with torch.no_grad():
    causal_logits, _ = model(past_same_future_changed)

assert causal_logits.shape == (2, 2, 5)
assert torch.allclose(
    causal_logits[0, 0, :],
    causal_logits[1, 0, :],
)

Scroll horizontally to view all columns.

Course data table
slicemeaningexpected comparison
causal_logits[0,0,:]Row 0, earlier query t=0: five candidate logitsEqual to row 1 at t=0
causal_logits[1,0,:]Row 1, the same you prefix at t=0Equal to row 0 at t=0
causal_logits[:,1,:]Final positions of the two rowsMay differ; not the comparison used to detect future leakage
logitst(xt,x>t)=logitst(xt,x>t)\mathrm{logits}_{t}(x_{\le t},x_{>t})=\mathrm{logits}_{t}(x_{\le t},x'_{>t})
Mt,j=forj>tM_{t,j}=-\infty\quad\text{for}\quad j>t

This canonical model has no dropout, so the comparison within one eval forward is deterministic. model.eval() remains the right habit because modules such as Dropout or BatchNorm can change behavior with mode.

Knowledge check

If causal_logits[0,0,:] and causal_logits[1,0,:] differ here, what is the likely error?

15. From Week 2 to GPT: Affine Maps and Gradient Updates Still Apply

PyTorch nn.Linear(in_features,out_features) stores weight [out_features,in_features] and computes XWᵀ+b for row-vector inputs. Combined qkv maps each four-channel row to twelve channels and splits it into three parts. The output head maps each final four-channel row to five categorical next-token scores.

Scroll horizontally to view all columns.

Course data table
componentinput / stored weightoutputbias policy
combined qkvX[...,4],W_qkv [12,4]QKV[...,12]No bias
attention output_projectionX[...,4],W_o [4,4]X[...,4]bias [4]
FFN first / second[...,4]→[...,16]→[...,4]per-token nonlinear updateBiases [16] and [4]
bias-free lm_headH[...,4],W_head [5,4]logits[...,5]No bias
Y=XW+b,X:[,4],W:[12,4],Y:[,12]Y=XW^{\top}+b,\qquad X:[\ldots,4],\quad W:[12,4],\quad Y:[\ldots,12]
Z=HWhead,H:[B,T,4],Whead:[5,4],Z:[B,T,5]Z=H\,W_{\mathrm{head}}^{\top},\qquad H:[B,T,4],\quad W_{\mathrm{head}}:[5,4],\quad Z:[B,T,5]
L=1BTb=1Bt=1Tlogpθ(yb,txb,t)\mathcal{L}=-\frac{1}{BT}\sum_{b=1}^{B}\sum_{t=1}^{T}\log p_{\theta}(y_{b,t}\mid x_{b,\le t})

Backward still applies the chain rule to send loss gradients to participating parameters; the optimizer then updates theta. Attention brings earlier positions into the current representation, GELU supplies nonlinearity, and residual paths with normalization support deeper composition.

Knowledge check

What do the output head's five numbers mean at one position?

16. Seven Things to Understand from Week 10

  1. Configuration is a shared shape/checkpoint contract. C=4 is divisible by H=2, so d_head=2.
  2. mini-gpt-v1 looks up embeddings from token IDs [B,T] and position IDs [T], then adds their rows to obtain [B,T,4]. Its ID space is incompatible with Week 9's V=11 artifact.
  3. Two independent Pre-Norm blocks preserve [B,T,4]. Attention causally mixes visible positions; feed_forward processes channels separately at each position.
  4. final_norm and the bias-free output head map [B,T,4] to raw logits [B,T,5], not probabilities.
  5. With targets, reshape [2,2,5]→[4,5] and [2,2]→[4] to compute scalar mean CE. Without targets, loss=None.
  6. model.parameters() enumerates registered parameters; they become optimizer inputs when the caller passes them to an optimizer. Registered parameters and persistent buffers enter state_dict and move with the module. train()/eval() recursively change module modes. Buffers are not optimizer parameters. The canonical token table and output head are untied, giving 520 parameters.
  7. A usable checkpoint requires matching code, exact configuration, untied-weight policy and tokenizer identity. Generation crops only the forward context and reads its final logits while retaining full history.
Concept sequence
  1. [you,like] / [we,like] → idx [2,2]
  2. token + position [2,2,4]
  3. two causal pre-norm blocks [2,2,4]
  4. final norm + head → logits [2,2,5]
  5. generation context = history[:,-2:]
  6. next_logits = logits[:,-1,:] [B,5]
  7. next_id [B,1] append to uncropped history

Call the full generation-record length L_history; it can exceed 2. Each forward receives T_context≤block_size=2. Do not use L for the layer count.

Tcontext=min(Lhistory,block_size),block_size=2T_{\mathrm{context}}=\min(L_{\mathrm{history}},\mathrm{block\_size}),\qquad \mathrm{block\_size}=2

Knowledge check

Why can two prompts ending in like produce different final logits?

11. Optional: Sharing the Input Table and Output Weights

Canonical MiniGPT keeps token_embedding.weight and lm_head.weight independent, totaling 520 parameters. An optional variant points the output weight at the input table. Lookup and output scoring then accumulate gradients into the same tensor through two paths. This is a labeled architecture variant, not a default repair.

python
# Optional teaching variant only; canonical mini-gpt-v1 stays untied.
tied_variant = MiniGPT(GPTConfig())
tied_variant.lm_head.weight = tied_variant.token_embedding.weight

assert tied_variant.lm_head.weight is tied_variant.token_embedding.weight
assert sum(p.numel() for p in tied_variant.parameters()) == 500

Wout=EtokenRV×C,[B,T,C]Wout[B,T,V]W_{\mathrm{out}}=E_{\mathrm{token}}\in\mathbb{R}^{V\times C},\qquad [B,T,C]\,W_{\mathrm{out}}^{\top}\to[B,T,V]

Scroll horizontally to view all columns.

Course data table
policytwo [5,4] names contributewhole-model totalcheckpoint identity
canonical untied20+20 distinct parameters520weight_policy=untied
optional tied variant20 unique parameters500Record the tying policy explicitly and recreate the alias during construction.

Knowledge check

In the optional tied variant, how many unique parameters do the two [5,4] names contribute together?

14. Engineering Extension: Checkpoint Format and Vocabulary Identity

The English mini-gpt-v1 tokenizer artifact contains version=mini-gpt-v1, ordered tokens [you,like,AI,study,we] and policy=whitespace-delimited;no-specials;no-pad;no-unk. Canonical bytes use JSON with sorted keys, compact separators, unescaped Unicode and UTF-8 encoding; SHA-256 hashes those bytes. The same artifact produces the same digest across processes. The Chinese edition has different ordered tokens and therefore a different identity despite also having V=5.

mini_gpt_walkthrough.py
def make_tokenizer_artifact(
    *,
    version: str,
    ordered_tokens: tuple[str, ...],
    policy: str,
) -> dict[str, object]:
    return {
        "version": version,
        "ordered_tokens": list(ordered_tokens),
        "policy": policy,
    }


def tokenizer_artifact_sha256(artifact: dict[str, object]) -> str:
    canonical_bytes = json.dumps(
        artifact,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")
    return hashlib.sha256(canonical_bytes).hexdigest()


def canonical_mini_gpt_tokenizer_artifact() -> dict[str, object]:
    return make_tokenizer_artifact(
        version="mini-gpt-v1",
        ordered_tokens=("you", "like", "AI", "study", "we"),
        policy="whitespace-delimited;no-specials;no-pad;no-unk",
    )


def validate_checkpoint_tokenizer_identity(
    checkpoint: dict[str, object],
    *,
    expected_ordered_tokens: tuple[str, ...],
    expected_tokenizer_policy: str,
    expected_tokenizer_version: str,
) -> None:
    stored_tokenizer = checkpoint.get("tokenizer")
    if not isinstance(stored_tokenizer, dict):
        raise ValueError("checkpoint tokenizer metadata is missing")
    required_keys = {"version", "ordered_tokens", "policy", "sha256"}
    if set(stored_tokenizer) != required_keys:
        raise ValueError("checkpoint tokenizer metadata has unexpected keys")

    stored_artifact = {
        "version": stored_tokenizer["version"],
        "ordered_tokens": stored_tokenizer["ordered_tokens"],
        "policy": stored_tokenizer["policy"],
    }
    stored_digest = stored_tokenizer["sha256"]
    if not isinstance(stored_digest, str):
        raise ValueError("checkpoint tokenizer SHA-256 must be text")
    try:
        recomputed_digest = tokenizer_artifact_sha256(stored_artifact)
    except (TypeError, ValueError) as error:
        raise ValueError(
            "checkpoint tokenizer artifact is not canonical JSON data"
        ) from error
    if recomputed_digest != stored_digest:
        raise ValueError("checkpoint tokenizer artifact failed SHA-256 check")

    expected_artifact = make_tokenizer_artifact(
        version=expected_tokenizer_version,
        ordered_tokens=expected_ordered_tokens,
        policy=expected_tokenizer_policy,
    )
    canonical_artifact = canonical_mini_gpt_tokenizer_artifact()
    if expected_artifact != canonical_artifact:
        raise ValueError("caller tokenizer identity is not mini-gpt-v1")
    expected_digest = tokenizer_artifact_sha256(expected_artifact)
    if stored_artifact != expected_artifact:
        raise ValueError("stored tokenizer artifact does not match caller")
    if stored_digest != expected_digest:
        raise ValueError("stored tokenizer digest does not match caller")


def save_mini_gpt_training_checkpoint(
    path: str,
    *,
    model: MiniGPT,
    optimizer: torch.optim.Optimizer,
    completed_updates: int,
    ordered_tokens: tuple[str, ...],
    tokenizer_policy: str,
    tokenizer_version: str,
) -> None:
    if type(completed_updates) is not int or completed_updates < 0:
        raise ValueError("completed_updates must be a non-negative integer")
    tokenizer_artifact = make_tokenizer_artifact(
        version=tokenizer_version,
        ordered_tokens=ordered_tokens,
        policy=tokenizer_policy,
    )
    if tokenizer_artifact != canonical_mini_gpt_tokenizer_artifact():
        raise ValueError("tokenizer artifact does not match mini-gpt-v1")
    tokenizer_digest = tokenizer_artifact_sha256(tokenizer_artifact)
    if model.config != GPTConfig():
        raise ValueError("model config is not the canonical GPTConfig")
    if model.lm_head.weight is model.token_embedding.weight:
        raise ValueError("canonical checkpoint requires untied weights")

    checkpoint = {
        "schema": {
            "name": "mini-gpt-training-checkpoint",
            "version": 1,
        },
        "tokenizer": {
            **tokenizer_artifact,
            "sha256": tokenizer_digest,
        },
        "config": {
            "vocab_size": model.config.vocab_size,
            "block_size": model.config.block_size,
            "n_embd": model.config.n_embd,
            "n_head": model.config.n_head,
            "n_layer": model.config.n_layer,
        },
        "weight_policy": {
            "token_embedding_lm_head": "untied",
        },
        "model_state": model.state_dict(),
        "optimizer": {
            "class": (
                f"{optimizer.__class__.__module__}."
                f"{optimizer.__class__.__qualname__}"
            ),
            "state": optimizer.state_dict(),
        },
        "completed_updates": completed_updates,
    }
    torch.save(checkpoint, path)


def load_mini_gpt_for_inference(
    path: str,
    *,
    expected_ordered_tokens: tuple[str, ...],
    expected_tokenizer_policy: str,
    expected_tokenizer_version: str,
    map_location: str | torch.device,
) -> MiniGPT:
    # Load only a checkpoint you created or trust: weights_only=False uses pickle.
    checkpoint = torch.load(
        path,
        map_location=map_location,
        weights_only=False,
    )
    if not isinstance(checkpoint, dict):
        raise ValueError("checkpoint must be a dictionary")
    if checkpoint.get("schema") != {
        "name": "mini-gpt-training-checkpoint",
        "version": 1,
    }:
        raise ValueError("checkpoint schema/version mismatch")

    validate_checkpoint_tokenizer_identity(
        checkpoint,
        expected_ordered_tokens=expected_ordered_tokens,
        expected_tokenizer_policy=expected_tokenizer_policy,
        expected_tokenizer_version=expected_tokenizer_version,
    )

    expected_config = GPTConfig()
    expected_config_fields = {
        "vocab_size": expected_config.vocab_size,
        "block_size": expected_config.block_size,
        "n_embd": expected_config.n_embd,
        "n_head": expected_config.n_head,
        "n_layer": expected_config.n_layer,
    }
    if checkpoint.get("config") != expected_config_fields:
        raise ValueError("checkpoint config mismatch")
    if checkpoint.get("weight_policy") != {
        "token_embedding_lm_head": "untied",
    }:
        raise ValueError("checkpoint weight policy mismatch")

    model = MiniGPT(expected_config).to(map_location)
    model.load_state_dict(checkpoint["model_state"], strict=True)
    return model

Scroll horizontally to view all columns.

sort_keys=True, separators=(",", ":"), ensure_ascii=False, followed by UTF-8 encoding
canonical identity representationexact value
UTF-8 JSON text{"ordered_tokens":["you","like","AI","study","we"],"policy":"whitespace-delimited;no-specials;no-pad;no-unk","version":"mini-gpt-v1"}
SHA-256 hex digest38d630f4c589664c9bef567457d48764cbe2307734777e80f7d5d5c63ac88dd6

The save function computes the digest from the exact artifact instead of accepting an arbitrary hash string. Loading first reconstructs canonical bytes from stored fields and checks their digest, then compares both artifact and digest with the caller's expected identity. These checks precede model construction and use. SHA-256 binds the recorded bytes and helps detect accidental corruption or identity mismatches; it is not a signature. It does not authenticate the source or establish equality of unrecorded tokenizer code, normalization or splitting behavior.

Scroll horizontally to view all columns.

Course data table
restore goalrequired fieldswhat may be omitted
inference onlyschema/version、tokenizer identity、exact config、untied policy、model_stateOptimizer state and completed_updates
faithful optimizer resumeinference fields + optimizer class/state + unambiguous completed_updatesDo not omit optimizer moments or confuse an update index with a completed count
bit-for-bit resume claimAlso needs matching data order and the CPU/CUDA/Python RNG states actually usedThis minimal function does not claim to save those additional states

Scroll horizontally to view all columns.

Course data table
saved key examplecompatibility consequence
tokenizer.sha256Canonicalize stored version/tokens/policy, check the digest, then compare with the caller's expected identity
token_embedding.weight [5,4]Cannot strictly load into [6,4] or [5,8]
blocks.1.attention.qkv.weight [12,4]A one-block model has no blocks.1; renamed members also produce key mismatches
blocks.i.attention.causal_mask [1,1,2,2]Registered buffers participate in state management too
weight_policy=untiedMust not silently restore a tied alias
loadstrict:{nameshape}saved={nameshape}constructed\operatorname{load}_{\mathrm{strict}}:\{\mathrm{name}\mapsto\mathrm{shape}\}_{\mathrm{saved}}=\{\mathrm{name}\mapsto\mathrm{shape}\}_{\mathrm{constructed}}

Knowledge check

Why can a checkpoint be unusable even when both tokenizers have V=5?

17. Week 10 → Week 11: Connect the Prediction Function to Training

Interacting tensors must be on a compatible device with the model. model.train() and model.eval() set module modes. This Week 10 architecture has neither Dropout nor BatchNorm, so its numerical forward is the same in both modes; later modules may behave differently. eval() does not disable gradients—use torch.no_grad() for that.

mini_gpt_walkthrough.py
device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)
model = MiniGPT(GPTConfig()).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)

idx_on_device = idx.to(device)
targets_on_device = targets.to(device)

model.train()
logits, loss = model(idx_on_device, targets_on_device)
assert logits.shape == (2, 2, 5)
assert loss is not None
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()  # exactly one completed update

model.eval()
with torch.no_grad():
    evaluation_logits, no_loss = model(idx_on_device)
assert evaluation_logits.shape == (2, 2, 5)
assert no_loss is None

Scroll horizontally to view all columns.

Course data table
line / phasestate change or observation
model.train()Recursively sets module training flags; does not change this no-dropout model's numerical output
forward with targetsBuilds the logits and scalar mean-loss computation graph
zero_grad(set_to_none=True)Clears the previous parameter.grad storage
loss.backward()Computes and accumulates gradients without directly changing parameter values
optimizer.step()Uses gradients/moments to change parameters; completed_updates increases by 1
eval() + no_grad()Sets evaluation mode and avoids recording a gradient graph
mini_gpt_walkthrough.py
@torch.no_grad()
def generate_mini_gpt(
    model: MiniGPT,
    history: torch.Tensor,
    max_new_tokens: int,
) -> torch.Tensor:
    if max_new_tokens < 0:
        raise ValueError("max_new_tokens cannot be negative")

    was_training = model.training
    model.eval()
    for _ in range(max_new_tokens):
        context = history[:, -model.config.block_size:]
        logits, _ = model(context)
        next_logits = logits[:, -1, :]
        next_id = torch.argmax(
            next_logits,
            dim=-1,
            keepdim=True,
        )
        history = torch.cat((history, next_id), dim=1)

    if was_training:
        model.train()
    return history

Concept sequence
  1. uncropped history [B,L_history]
  2. context = history[:,-block_size:] → [B,min(L_history,2)]
  3. forward(context) → logits [B,T_context,5]
  4. next_logits = logits[:,-1,:] → [B,5]
  5. choose next_id outside forward → [B,1]
  6. append to uncropped history → [B,L_history+1]
E[Luniform]=ln(V)=ln(5)1.609\mathbb{E}[\mathcal{L}_{\mathrm{uniform}}]=\ln(V)=\ln(5)\approx1.609
[B,Lhistory][B,min(Lhistory,2)][B,5][B,1][B,Lhistory+1][B,L_{\mathrm{history}}]\to[B,\min(L_{\mathrm{history}},2)]\to[B,5]\to[B,1]\to[B,L_{\mathrm{history}}+1]

Knowledge check

Why crop only the generation input but append next_id to full history?