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
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.
| Learning unit | Problem to solve |
|---|---|
| 1: The smallest model | Start with token/position embeddings and an output head. Identify inputs, parameters and outputs. |
| 2: Assemble one component at a time | Single head → multiple heads → a complete Pre-Norm block → stacked blocks. Observe the parameters and shapes added at each stage. |
| 3: Inspect axes and parameters | Track numbered elements through split/transpose/merge operations. Check ModuleList registration and parameter counts. |
| 4: Hand the model to the training loop | The 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.
| ID | ordered token |
|---|---|
| 0 | you |
| 1 | like |
| 2 | AI |
| 3 | study |
| 4 | we |
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.
| batch row | idx IDs | input tokens | target IDs | Four teacher-forced prediction tasks |
|---|---|---|---|---|
| b=0 | [0,1] | [you, like] | [1,2] | [like, AI] |
| b=1 | [4,1] | [we, like] | [1,0] | [like, you] |
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]- mini-gpt-v1 IDs [B,T] = [2,2]
- token + position representations [B,T,C] = [2,2,4]
- pre-norm Block 1 [2,2,4]
- pre-norm Block 2 [2,2,4]
- final LayerNorm [2,2,4]
- bias-free LM head logits [B,T,V] = [2,2,5]
- optional reshape [4,5] with targets [4] → scalar mean cross-entropy
Scroll horizontally to view all columns.
| caller mode | forward input | forward output | caller consumes |
|---|---|---|---|
| training / evaluation | idx [2,2] + targets [2,2] | logits [2,2,5] + scalar loss | all four aligned positions |
| generation | cropped context, no targets | logits [B,T,5] + None | only 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.
| When you encounter this in code | Read it as |
|---|---|
| class GPTConfig | Declares 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.xxx | An 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.
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.
| field / value | Failure or ambiguity it prevents | concrete owners |
|---|---|---|
| vocab_size=5 | No valid range for input IDs or output classes | token_embedding rows and lm_head outputs |
| block_size=2 | Disagreement between maximum context, position rows, mask and generation crop | position_embedding、causal_mask、caller crop |
| n_embd=4 | Inconsistent representation widths between components | Both embeddings, norms, attention, FFN and the lm_head input |
| n_head=2 | No valid equal-width partition into heads | qkv reshape and the score tensor's head axis |
| n_layer=2 | Ambiguous block depth/order or unregistered repeated modules | ModuleList construction and the forward loop |
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.
Scroll horizontally to view all columns.
| input / config | Result | Reason to report it early |
|---|---|---|
| n_embd=4, n_head=2 | Valid: d_head=2 | [B,T,4] can be reshaped/reordered into [B,H,T,d_head] |
| n_embd=4, n_head=3 | ValueError | Four channels cannot be divided equally into three integer-width heads |
| idx shape [B,1] or [B,2] | Valid | block_size is an upper bound, not a requirement to fill every position |
| idx shape [B,0] or [B,3] | ValueError | An empty sequence has no final position; T=3 exceeds the position/mask capacity |
| Floating-point IDs or ID=5 | TypeError / ValueError | This model's input contract requires torch.long IDs in the range 0..4 |
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_headMiniGPT.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.
| owner | input | output / responsibility |
|---|---|---|
| caller | idx [2,2], optional targets [2,2] | Chooses how to use the outputs for training/evaluation or generation |
| token_embedding | IDs 0..4 | identity representations [2,2,4] |
| position_embedding | positions [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 branch | logits + aligned targets | Scalar mean CE only when targets are present |
| generation caller | Final-position logits [B,5] | Selects and appends next_id [B,1] outside forward |
- idx [2,2]
- token rows [2,2,4] + position rows [2,4] broadcast
- blocks[0] [2,2,4]
- blocks[1] [2,2,4]
- final_norm [2,2,4]
- lm_head logits [2,2,5]
- targets present? reshape logits [4,5] and targets [4] → mean loss []
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.
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)- x [B,T,C] = [2,2,4]
- qkv(x) [2,2,12]
- chunk → q, k, value_states each [2,2,4]
- reshape + transpose → each [B,H,T,d_head] = [2,2,2,2]
- scores / weights [B,H,T,T] = [2,2,2,2]
- weighted Values [2,2,2,2]
- transpose + contiguous + view [2,2,4]
- biased output_projection [2,2,4]
Scroll horizontally to view all columns.
| causal_mask row=query / column=key | j=0: first token | j=1: second token |
|---|---|---|
| t=0: first query | 1 allow | 0 forbid |
| t=1: final like query | 1 allow | 1 allow |
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.
| stage | shape with named axes | operation 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 |
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.
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- x0 [2,2,4]
- ln1(x0) [2,2,4] → attention [2,2,4]
- x1 = x0 + attention update [2,2,4]
- ln2(x1) [2,2,4] → FFN [2,2,4] through 4→16→4
- x2 = x1 + FFN update [2,2,4]
- A second independent TransformerBlock repeats the same shape contract
Scroll horizontally to view all columns.
| sublayer | mixes positions? | input → internal → output |
|---|---|---|
| causal attention | Yes, reads only j≤t | [2,2,4] → scores [2,2,2,2] → [2,2,4] |
| feed_forward | No, each [b,t] is independent | [2,2,4] → [2,2,16] → [2,2,4] |
| residual add | No, 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.
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, lossScroll horizontally to view all columns.
| stable owner | canonical shape / children |
|---|---|
| token_embedding.weight | [V,C]=[5,4] |
| position_embedding.weight | [block_size,C]=[2,4] |
| blocks | ModuleList of length n_layer=2; each item contains ln1, attention, ln2 and feed_forward |
| attention | qkv, output_projection, causal_mask |
| final_norm | weight [4] + bias [4] |
| lm_head.weight | [V,C]=[5,4], bias-free and not tied to token_embedding |
- idx [2,2] passes rank / long / nonempty / T / ID checks
- positions [2]
- token_rows [2,2,4] + position_rows [2,4] → x [2,2,4]
- blocks[0] [2,2,4] → blocks[1] [2,2,4]
- final_norm [2,2,4] → lm_head logits [2,2,5]
- 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.
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.
| operation | plain list children | ModuleList children |
|---|---|---|
| Explicit loop in forward | Works | Works |
| model.parameters() / optimizer | Missing | Included |
| state_dict() | Child state omitted | Includes named parameters and persistent buffers |
| model.to(device) | Does not recursively move these children | Recursively moves these children |
| train() / eval() | Does not recursively switch these children | Recursively switches these children |
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.
| owner / object | shape in canonical model | kind |
|---|---|---|
| token_embedding.weight | [5,4] | learned parameter |
| position_embedding.weight | [2,4] | learned parameter |
| blocks.i.attention.qkv.weight | [12,4], no bias | learned parameter |
| blocks.i.attention.output_projection | weight [4,4] + bias [4] | learned parameters |
| blocks.i.ln1 / ln2 | Each has weight [4] + bias [4] | learned parameters |
| blocks.i.feed_forward.net.0 | weight [16,4] + bias [16] | learned parameters |
| blocks.i.feed_forward.net.2 | weight [4,16] + bias [4] | learned parameters |
| blocks.i.attention.causal_mask | [1,1,2,2] | registered buffer,not trained |
| final_norm | weight [4] + bias [4] | learned parameters |
| lm_head.weight | [5,4], no bias and not tied | learned parameter |
| idx / positions / scores / logits / loss | Changes with the call | inputs or temporary activations |
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.
| owner | calculation | parameters |
|---|---|---|
| token embedding | V×C = 5×4 | 20 |
| position embedding | block_size×C = 2×4 | 8 |
| one attention | qkv 3C×C = 12×4;output C×C+C = 4×4+4 | 48+20=68 |
| one FFN | (4C×C+4C) + (C×4C+C) | 80+68=148 |
| two block LayerNorms | 2×(C+C) | 16 |
| one whole block | 68+148+16 | 232 |
| two independent blocks | n_layer×232 = 2×232 | 464 |
| final LayerNorm | C+C | 8 |
| independent bias-free LM head | V×C = 5×4 | 20 |
| canonical untied total | 20+8+464+8+20 | 520 |
canonical_model = MiniGPT(GPTConfig())
parameter_count = sum(
parameter.numel() for parameter in canonical_model.parameters()
)
assert parameter_count == 520causal_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.
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")- idx [2,2]
- embeddings [2,2,4]
- block 1 [2,2,4]
- block 2 [2,2,4]
- logits [2,2,5]
- reshape logits [4,5] + targets [4]
- mean cross-entropy loss []
Scroll horizontally to view all columns.
| successful assertion | What this establishes | What this does not yet establish |
|---|---|---|
| logits.shape==(2,2,5) | top-level output interface | Correct causal-mask direction or label meaning |
| loss.ndim==0 and finite | Mean CE returns a usable scalar | That the model has learned the corpus |
| no targets → no_loss is None | inference branch contract | Correct generation appending |
| invalid inputs raise | The API boundary rejects these known invalid inputs | Coverage 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.
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.
| slice | meaning | expected comparison |
|---|---|---|
| causal_logits[0,0,:] | Row 0, earlier query t=0: five candidate logits | Equal to row 1 at t=0 |
| causal_logits[1,0,:] | Row 1, the same you prefix at t=0 | Equal to row 0 at t=0 |
| causal_logits[:,1,:] | Final positions of the two rows | May differ; not the comparison used to detect future leakage |
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.
| component | input / stored weight | output | bias policy |
|---|---|---|---|
| combined qkv | X[...,4],W_qkv [12,4] | QKV[...,12] | No bias |
| attention output_projection | X[...,4],W_o [4,4] | X[...,4] | bias [4] |
| FFN first / second | [...,4]→[...,16]→[...,4] | per-token nonlinear update | Biases [16] and [4] |
| bias-free lm_head | H[...,4],W_head [5,4] | logits[...,5] | No bias |
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
- Configuration is a shared shape/checkpoint contract. C=4 is divisible by H=2, so d_head=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.
- Two independent Pre-Norm blocks preserve [B,T,4]. Attention causally mixes visible positions; feed_forward processes channels separately at each position.
- final_norm and the bias-free output head map [B,T,4] to raw logits [B,T,5], not probabilities.
- With targets, reshape [2,2,5]→[4,5] and [2,2]→[4] to compute scalar mean CE. Without targets, loss=None.
- 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.
- 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.
- [you,like] / [we,like] → idx [2,2]
- token + position [2,2,4]
- two causal pre-norm blocks [2,2,4]
- final norm + head → logits [2,2,5]
- generation context = history[:,-2:]
- next_logits = logits[:,-1,:] [B,5]
- 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.
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.
# 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()) == 500Scroll horizontally to view all columns.
| policy | two [5,4] names contribute | whole-model total | checkpoint identity |
|---|---|---|---|
| canonical untied | 20+20 distinct parameters | 520 | weight_policy=untied |
| optional tied variant | 20 unique parameters | 500 | Record 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.
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 modelScroll horizontally to view all columns.
| canonical identity representation | exact 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 digest | 38d630f4c589664c9bef567457d48764cbe2307734777e80f7d5d5c63ac88dd6 |
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.
| restore goal | required fields | what may be omitted |
|---|---|---|
| inference only | schema/version、tokenizer identity、exact config、untied policy、model_state | Optimizer state and completed_updates |
| faithful optimizer resume | inference fields + optimizer class/state + unambiguous completed_updates | Do not omit optimizer moments or confuse an update index with a completed count |
| bit-for-bit resume claim | Also needs matching data order and the CPU/CUDA/Python RNG states actually used | This minimal function does not claim to save those additional states |
Scroll horizontally to view all columns.
| saved key example | compatibility consequence |
|---|---|
| tokenizer.sha256 | Canonicalize 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=untied | Must not silently restore a tied alias |
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.
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 NoneScroll horizontally to view all columns.
| line / phase | state change or observation |
|---|---|
| model.train() | Recursively sets module training flags; does not change this no-dropout model's numerical output |
| forward with targets | Builds 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 |
@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- uncropped history [B,L_history]
- context = history[:,-block_size:] → [B,min(L_history,2)]
- forward(context) → logits [B,T_context,5]
- next_logits = logits[:,-1,:] → [B,5]
- choose next_id outside forward → [B,1]
- append to uncropped history → [B,L_history+1]
Knowledge check
Why crop only the generation input but append next_id to full history?