Week 10
Week 10 - GPT Architecture:把最小语言模型组装成可训练的 Mini GPT
Key question怎样把 Weeks 6–9 的 token、Attention、Transformer 与 tokenizer 接口组装成一个可训练、可保存、可生成的 MiniGPT?
Learning objectives
- 用唯一的 GPTConfig 解释 Vocabulary、context、width、head divisibility 与 depth 约束。
- 沿 [2,2] → [2,2,4] → 两个 pre-norm Blocks → [2,2,5] 追踪完整 forward。
- 认清每个 parameter、buffer 与 temporary activation 的 owner,并手算 canonical untied 模型的 520 个参数。
- 实现并检查稳定的 forward、state-dict/checkpoint 与外部 generation 边界,为 Week 11 的训练循环做准备。
85 min estimated reading time
这周的目标是组装预测函数,不是先掌握全部 checkpoint 工具。保留五词输入,逐步把 embedding、Attention、FFN、残差和归一化接起来;最后仍返回 logits 和可选 loss。
Scroll horizontally to view all columns.
| 学习单元 | 本次解决的问题 |
|---|---|
| 一:最小模型 | 先只有 token/position embedding 与输出层,定位输入、参数和输出。 |
| 二:逐项组装 | 单头 → 多头 → 完整 Pre-Norm block → 多层;每阶段观察新增参数与 shape。 |
| 三:看清轴与参数 | 用有编号的元素追踪 split/transpose/merge;检查 ModuleList 注册和参数数量。 |
| 四:交给训练循环 | 模型只负责 forward;完整保存格式与权重共享是后面的工程选读。 |
运行 python week10_stages.py --stage embedding,依次换成 single、multi、block、full;完整默认模型与 mini_gpt_walkthrough.py 一致。520 是指定配置的参数量,不是所有 MiniGPT 的固定大小。
建议阅读、手算、改代码交替进行,每个单元可拆成几次完成。章节编号保留用于旧链接和回查;按页面从上到下的新顺序学习,不需要按旧编号来回跳转。
Week 10 核心目标:把零件接成一个稳定接口
mini-gpt-v1 按空格切分,ordered tokens 固定为 [我, 喜欢, AI, 学习, 猫],IDs 固定为 0..4;它没有 BOS、EOS、PAD 或 UNK。Week 9 的 V=11、T=4 教学 artifact 已经结束,不能把那里的整数直接送入本模型。
Scroll horizontally to view all columns.
| ID | ordered token |
|---|---|
| 0 | 我 |
| 1 | 喜欢 |
| 2 | AI |
| 3 | 学习 |
| 4 | 猫 |
本周固定 mini-gpt-v1:B=2 是 batch 行数,T=2 是当前 token 位置数,C=4 是每个位置的表示宽度,H=2 是 attention head 数,d_head=C/H=2 是每个 head 的宽度,V=5 是候选 token 数,n_layer=2 是 Transformer block 数。L 只保留给 token stream 或生成历史长度,绝不用作层数。
Scroll horizontally to view all columns.
| batch row | idx IDs | input tokens | target IDs | 四道 teacher-forced 题 |
|---|---|---|---|---|
| b=0 | [0,1] | [我, 喜欢] | [1,2] | [喜欢, AI] |
| b=1 | [4,1] | [猫, 喜欢] | [1,0] | [喜欢, 我] |
import torch
idx = torch.tensor([
[0, 1], # 我 喜欢
[4, 1], # 猫 喜欢
], dtype=torch.long) # [B,T] = [2,2]
targets = torch.tensor([
[1, 2], # 我→喜欢,喜欢→AI
[1, 0], # 猫→喜欢,喜欢→我
], 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
idx 是 [2,2] 时,logits 与 flattened targets 分别是什么 shape?
1. 模型配置:每个数字控制什么
Scroll horizontally to view all columns.
| 读代码时遇到 | 先这样理解 |
|---|---|
| class GPTConfig | 列出要建什么规格 |
| config = GPTConfig() | 得到一份具体规格 |
| class MiniGPT(nn.Module) | 定义按规格建模型以及如何计算 |
| model = MiniGPT(config) | 创建一组实际参数 |
| self.xxx | 这个模型自己保存的对象 |
| forward / model(idx) | 用当前参数进行一次计算 |
dataclass 帮助少写配置样板代码;frozen=True 固定配置字段,不会阻止神经网络学习。类型标注如 idx: torch.Tensor 是给读者和检查工具的提示,真正的输入检查仍由函数内代码执行。
Configuration 回答“要建什么”;nn.Parameter 回答“训练会改变哪些数”。frozen=True 只阻止意外改写 config fields,并不会冻结模型参数。本最小架构刻意没有 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 | 它防止的失败或歧义 | concrete owners |
|---|---|---|
| vocab_size=5 | 输入 ID / 输出 class 没有合法范围 | token_embedding rows 与 lm_head outputs |
| block_size=2 | 最大 context、position rows、mask 与 generation crop 不一致 | position_embedding、causal_mask、caller crop |
| n_embd=4 | 组件的 representation width 不一致 | 两种 embeddings、norms、attention、FFN、lm_head input |
| n_head=2 | multi-head 没有可执行的 width partition | qkv reshape 与 score head axis |
| n_layer=2 | block depth/order 含糊或重复模块未注册 | ModuleList construction 与 forward loop |
这份合同要求 token_embedding.weight 为 [5,4]、position_embedding.weight 为 [2,4],并让 lm_head 把最后一维 4 映射为 5 个候选 scores。它也会原样进入 checkpoint compatibility metadata。
Knowledge check
哪个 config field 同时决定 position table 行数、causal mask 边长和 generation crop?
2. Configuration 的关键约束:让错误尽早发生
C=n_embd=4 是每个 block 的共同宽度,H=n_head=2 是 head 数,所以 4 mod 2=0 且 d_head=2。反例 n_embd=4、n_head=3 没有整数 head width,必须在 config.validate() 阶段拒绝,而不是等 view 失败。
Scroll horizontally to view all columns.
| input / config | 结果 | 尽早报告的原因 |
|---|---|---|
| n_embd=4, n_head=2 | 合法:d_head=2 | [B,T,4] 可重组为 [B,H,T,d_head] |
| n_embd=4, n_head=3 | ValueError | 四个 channels 无法均分成三个整数宽度 heads |
| idx shape [B,1] 或 [B,2] | 合法 | block_size 是上限,不要求每次填满 |
| idx shape [B,0] 或 [B,3] | ValueError | 空序列无最后位置;T=3 超出 position/mask capacity |
| float IDs 或 ID=5 | TypeError / ValueError | embedding 只接受 torch.long 且地址范围为 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_head完整 MiniGPT.forward 会先检查 rank 与 dtype,再读取 B、T;在任何 min/max 之前先拒绝 empty tensor,随后检查 ID range。targets 也必须与 idx 同 shape、同为 torch.long 且 IDs 在 0..4。第 7 节会把这些条件写进 canonical class。
Knowledge check
为什么 [B,2,4] 可拆成两个 heads,却不能拆成三个等宽 heads?
3. GPT 数据流:先看输入输出,再看各层
MiniGPT.forward 总会计算 logits。training/evaluation caller 提供 aligned targets 时,它额外返回 scalar mean cross-entropy;generation caller 不提供 targets,只消费 logits。forward 是神经网络映射,不在内部改变 optimizer,也不挑选 next token。
Scroll horizontally to view all columns.
| owner | input | output / responsibility |
|---|---|---|
| caller | idx [2,2],可选 targets [2,2] | 决定是 training/evaluation 还是 generation use |
| token_embedding | IDs 0..4 | identity representations [2,2,4] |
| position_embedding | positions [2] | position rows [2,4],沿 B broadcast |
| blocks[0] 与 blocks[1] | [2,2,4] | 依序产生 contextual [2,2,4] |
| final_norm + lm_head | [2,2,4] | 每个位置的 logits [2,2,5] |
| loss branch | logits + aligned targets | 仅 targets 存在时得到 scalar mean CE |
| generation caller | 最后位置 logits [B,5] | 在 forward 外选择并 append next_id [B,1] |
- 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
training 和 generation 都一定得到哪个输出?哪个输出只在 targets 存在时得到?
4. Causal Self-Attention:用 Combined QKV 重实现同一操作
Q 表示当前 query 要找什么,K 表示每个可见位置如何被匹配,Value 表示匹配后带回什么。Week 7 为单个 head 展示三条独立 projections;这里用一个 bias-free qkv Linear 一次产生三份宽度 C 的 tensors,再显式增加 H 轴。数学工作相同,class organization 与 state-dict keys 不同。
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 喜欢 query | 1 allow | 1 allow |
register_buffer 让 causal_mask 随 model.to(device)、state_dict 与 module traversal 一起管理,却不会被 optimizer 更新。最终 喜欢 的 query 可读取 position 0:第一行读到 我,第二行读到 猫,因此 contextual outputs 可能不同;随机 weights 并不保证某个具体预测。
Knowledge check
query position 0 的哪些 key columns 可以有非零 weight?为什么?
5. 拆分 Heads 时的 Shape:数字相同也要读 Axis
本周固定 mini-gpt-v1:B=2 是 batch 行数,T=2 是当前 token 位置数,C=4 是每个位置的表示宽度,H=2 是 attention head 数,d_head=C/H=2 是每个 head 的宽度,V=5 是候选 token 数,n_layer=2 是 Transformer block 数。L 只保留给 token stream 或生成历史长度,绝不用作层数。
Scroll horizontally to view all columns.
| stage | shape with named axes | operation meaning |
|---|---|---|
| q after chunk | [B,T,C]=[2,2,4] | 每个 token row 有四个 q channels |
| q.view | [B,T,H,d_head]=[2,2,2,2] | 把 C=4 分组为 2×2,不改变元素数 |
| q.transpose(1,2) | [B,H,T,d_head]=[2,2,2,2] | 每个 head 获得自己的 T×d_head matrix |
| q @ kᵀ | [B,H,T,T]=[2,2,2,2] | 第三轴为 query t,第四轴为 key j |
| weights @ Values | [B,H,T,d_head]=[2,2,2,2] | 每个 query 得到两个 head features |
| transpose back | [B,T,H,d_head]=[2,2,2,2] | 把 token position 放回 head 之前 |
| contiguous().view | [B,T,C]=[2,2,4] | 在 feature axis 合并 H×d_head |
transpose 改变 stride/layout,不复制数学元素;随后若用 view 假定 features 在内存中相邻,就先调用 contiguous()。reshape 有时会自行复制,但这里显式 contiguous().view 让 rejoin 意图可见。
调试时另建 GPTConfig(block_size=3),保持 C=4、H=2,输入 B=1,T=3。应经过 [1,3,4]→[1,3,2,2]→[1,2,3,2],score 为 [1,2,3,3]。不要拿 T=3 直接喂给本章默认 block_size=2 的模型;额外检查使用独立实例,不保存成 canonical checkpoint。
Knowledge check
计算 scores 前 q 的语义 axis order 是什么?
6. FeedForward 与 Block:通信之后逐位置计算
Attention 是 cross-position communication:喜欢@1 可以读取 我@0 或 猫@0。FFN 则对每个 x[b,t,:] 单独应用同一函数,不直接读取另一行。Pre-Norm 先规范 branch input,再把 branch update 加回未经该 norm 替换的 residual state。
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]
- 第二个独立 TransformerBlock 重复同一 shape contract
Scroll horizontally to view all columns.
| sublayer | mixes positions? | input → internal → output |
|---|---|---|
| causal attention | 是,只读 j≤t | [2,2,4] → scores [2,2,2,2] → [2,2,4] |
| feed_forward | 否,每个 [b,t] 独立 | [2,2,4] → [2,2,16] → [2,2,4] |
| residual add | 否,逐元素相加 | [2,2,4] + [2,2,4] → [2,2,4] |
Knowledge check
哪个 sublayer 能让 喜欢@1 使用 position 0,哪个只处理 喜欢@1 已有的 row?
7. 完整 MiniGPT:把已经学过的计算接起来
以下代码继续使用前面已定义的 imports、GPTConfig、CausalSelfAttention、FeedForward 与 TransformerBlock。__init__ 注册持久架构;forward 执行一次计算。canonical mini-gpt-v1 是两个 pre-norm blocks、untied token embedding/LM head,且没有 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 长度 n_layer=2;每项含 ln1, attention, ln2, feed_forward |
| attention | qkv, output_projection, causal_mask |
| final_norm | weight [4] + bias [4] |
| lm_head.weight | [V,C]=[5,4],bias-free 且与 token_embedding untied |
- 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
为什么 positions 是 [T],而 idx 是 [B,T]?
8. ModuleList 为什么重要:能执行不等于被 Model 拥有
把 nn.Module 赋给另一个 nn.Module 的 attribute 会注册它;可变数量的 children 应放进 nn.ModuleList。于是 blocks.0.attention.qkv.weight 与 blocks.1.attention.qkv.weight 成为独立、可命名的 parameters,两个 causal_mask 也成为 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 |
|---|---|---|
| forward 手动 loop | 可以 | 可以 |
| model.parameters() / optimizer | 遗漏 | 包含 |
| state_dict() | 遗漏 child state | 包含 named parameters 与 persistent buffers |
| model.to(device) | 不递归移动 | 递归移动 |
| train() / eval() | 不递归切换 | 递归切换 |
Knowledge check
普通 list 最少会造成哪两类实际遗漏?
9. Parameter 到底在哪里:把长期 State 与临时 Tensor 分开
Parameter 是注册且通常 requires_grad=True 的持久 Tensor;backward 把 gradient 写到 parameter.grad,optimizer 再更新 parameter。Buffer 会随 model 移动和保存,却不由 optimizer 学习。idx、positions、scores、weights、logits 与 loss 只是当前计算的 inputs/activations。
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],无 bias | learned parameter |
| blocks.i.attention.output_projection | weight [4,4] + bias [4] | learned parameters |
| blocks.i.ln1 / ln2 | 各 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],无 bias 且 untied | learned parameter |
| idx / positions / scores / logits / loss | 随 call 改变 | inputs or temporary activations |
Knowledge check
causal_mask 会训练吗?它会随模型保存和移动吗?
10. 手算参数量:默认不共享权重时为 520
这里 V=5、block_size=2、C=4、n_layer=2。QKV 与 LM head 无 bias;attention output_projection 和两个 FFN linears 有 bias;每个 block 有两个带 scale/bias 的 LayerNorm;token_embedding 与 lm_head 是两份独立 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] 是 buffer,所以贡献零个 trainable parameters。两个 blocks 只共享 class definition 与 shapes,不共享 tensor storage,因此必须算两次。
Knowledge check
为什么一个 block 的两个 LayerNorm 一共有 16 个 parameters?
12. 正确性首先检查 Shape 与 API Boundary
固定 idx 与 targets 经过同一个 canonical model:两行、每行两个 token positions、每个位置五个 logits;四个 target IDs 与四个 logit rows 对齐。loss 的 shape [] 表示 rank-0 scalar,不是长度一 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 | 它证明什么 | 它还没证明什么 |
|---|---|---|
| logits.shape==(2,2,5) | top-level output interface | causal mask 方向或 labels 语义 |
| loss.ndim==0 and finite | mean CE 返回可用 scalar | 模型已经学会语料 |
| no targets → no_loss is None | inference branch contract | generation append 正确 |
| invalid inputs raise | API boundary 拒绝已知坏数据 | 所有可能错误都被覆盖 |
Knowledge check
成功完成 shape/API assertions 后,还需哪项检查才能确认 future token 没有泄漏?
13. 必须检查 Causal 性:比较可观察行为
两行都以 我(ID 0)开始;第二个 token 分别是 喜欢(ID 1)与 猫(ID 4)。query position 0 只允许读取 key column 0,所以它的五个 vocabulary logits 必须一致。position 1 可以变化,因为它允许读取自己。
model.eval()
past_same_future_changed = torch.tensor([
[0, 1], # 我 喜欢
[0, 4], # 我 猫
], 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,五个 candidate logits | 与 row 1 的 t=0 相同 |
| causal_logits[1,0,:] | row 1,same 我 prefix at t=0 | 与 row 0 的 t=0 相同 |
| causal_logits[:,1,:] | 两行 final positions | 允许不同,不用于 future-leak assertion |
本 canonical model 没有 Dropout,所以同一次 eval forward 的比较是确定的;model.eval() 仍是正确习惯,因为将来的 Dropout 或 BatchNorm model 会让 mode 改变行为。
Knowledge check
这里若 causal_logits[0,0,:] 与 causal_logits[1,0,:] 不同,最可能是哪类错误?
15. 从 Week 2 的公式到 GPT:仍是 Affine Maps 与 Gradient Updates
PyTorch nn.Linear(in_features,out_features) 保存 weight [out_features,in_features],对 row-vector input 计算 XWᵀ+b。Combined qkv 把每个四维 row 变成十二维并切成三份;LM head 再把最终四维 row 变成五个 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] | 无 bias |
| attention output_projection | X[...,4],W_o [4,4] | X[...,4] | bias [4] |
| FFN first / second | [...,4]→[...,16]→[...,4] | per-token nonlinear update | bias [16] 与 [4] |
| bias-free lm_head | H[...,4],W_head [5,4] | logits[...,5] | 无 bias |
Backward 仍按 chain rule 把 loss gradient 传到每个参与的 parameter,optimizer 再更新 theta。Attention 让 earlier positions 进入 current representation,GELU 提供非线性,两条 residual paths 与 norms 支持组合深度。
Knowledge check
LM head 在一个 position 输出的五个数表示什么?
16. Week 10 最应该理解的 7 件事
- Config 是 shared shape/checkpoint contract;C=4 可被 H=2 整除,所以 d_head=2。
- mini-gpt-v1 的 token identity [B,T] 加 position rows [T] 后得到 [B,T,4];它与 Week 9 的 V=11 artifact 不兼容。
- 两个独立 pre-norm blocks 都保持 [B,T,4]:attention 因果混合可见 positions,feed_forward 逐位置处理 channels。
- final_norm 与 bias-free lm_head 把 [B,T,4] 变为 [B,T,5] raw logits,而不是 probabilities。
- 有 targets 时,[2,2,5]→[4,5] 与 [2,2]→[4] 得 scalar mean CE;没有 targets 时 loss=None。
- registered modules 持有的 parameters 会被 model.parameters() 枚举,并在 caller 把它们传给 optimizer 后成为可优化对象;registered parameters 与 persistent registered buffers 会进入 state_dict 并随 module 移动;train()/eval() 的 mode 递归作用于 modules;buffers 不是 optimizer parameters。canonical token table/head untied,总参数 520。
- 可用 checkpoint 需要 matching code、exact config、untied policy 与 tokenizer identity;generation 只 crop forward context、只读 last logits,却保留完整 history。
- [我,喜欢] / [猫,喜欢] → 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
这里把生成总记录的长度称为 L_history;它可以大于 2。每轮真正进入 model 的 T_context 至多为 block_size=2,避免把 L 错当 layer count。
Knowledge check
为何两个 prompt 都以“喜欢”结尾,最后 logits 仍可能不同?
11. 选读:输入表与输出层怎样共享权重
Canonical MiniGPT 保持 token_embedding.weight 与 lm_head.weight 独立,所以是 520 parameters。可选实验把 output weight 指向 input table;lookup 与 output scoring 随后从两条路径向同一 tensor 累积 gradients。它是另一个明确标注的 architecture variant,不是默认修补。
# 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 | 必须明确记录 tied policy 并在 construction 时重建 alias |
Knowledge check
可选 tied variant 中,两张 [5,4] names 合计贡献多少 unique parameters?
14. 工程选读:保存格式与词表一致性
mini-gpt-v1 的 tokenizer artifact 只含 version=mini-gpt-v1、ordered tokens [我,喜欢,AI,学习,猫] 与 policy=whitespace-delimited;no-specials;no-pad;no-unk。canonical bytes 用 JSON 的 sorted keys、无多余空格 separators、原样 Unicode 和 UTF-8 编码得到,再计算 SHA-256;因此同一 artifact 在不同进程中产生同一 digest。相同 V=5 绝不等于相同 ID semantics。
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=("我", "喜欢", "AI", "学习", "猫"),
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:
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":["我","喜欢","AI","学习","猫"],"policy":"whitespace-delimited;no-specials;no-pad;no-unk","version":"mini-gpt-v1"} |
| SHA-256 hex digest | 38d630f4c589664c9bef567457d48764cbe2307734777e80f7d5d5c63ac88dd6 |
保存端不再接受任意 hash 字符串:它从 exact artifact 直接计算 digest。加载端先从 stored fields 重建同样的 canonical bytes,验证 stored digest,再把 stored artifact 与 digest 同 caller 明确提供的 expected identity 比较;这些检查全部发生在构造和使用模型之前。SHA-256 能绑定这里记录的 bytes 并发现意外损坏或 identity mismatch,但它不是签名:它不证明来源可信,也不证明未记录的 tokenizer code、Unicode normalization 或 split 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 与 completed_updates |
| faithful optimizer resume | inference fields + optimizer class/state + unambiguous completed_updates | 不能省略 optimizer moments 或把 update index 猜成 count |
| bit-for-bit resume claim | 还需相同 data order 与所用 CPU/CUDA/Python RNG state | 本最小函数不宣称保存这些额外状态 |
Scroll horizontally to view all columns.
| saved key example | compatibility consequence |
|---|---|
| tokenizer.sha256 | 先对 stored version/tokens/policy 重新 canonicalize 并验 digest,再与 caller expected identity 比较 |
| token_embedding.weight [5,4] | 无法 strict-load 到 [6,4] 或 [5,8] |
| blocks.1.attention.qkv.weight [12,4] | 一层 model 没有 blocks.1;renamed member 也会 key mismatch |
| blocks.i.attention.causal_mask [1,1,2,2] | registered buffer 也在 state management 中 |
| weight_policy=untied | 不能静默恢复为 tied alias |
Knowledge check
为什么两个 tokenizer 都是 V=5,checkpoint 仍可能不可用?
17. Week 10 → Week 11:预测函数怎样接入训练循环
所有 interacting tensors 必须与 model 在同一 device。model.train() 与 model.eval() 标记 mode;本 Week 10 架构没有 Dropout 或 BatchNorm,所以两种 mode 的数值 forward 相同,但未来含这些 modules 时会改变行为。eval() 本身不关闭 gradients,仍要配合 torch.no_grad()。
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() | 递归设置 module training flags;本 no-dropout model 数值不变 |
| forward with targets | 构建 logits 与 scalar mean-loss computation graph |
| zero_grad(set_to_none=True) | 清除上一次 parameter.grad storage |
| loss.backward() | 计算并累积 gradients,不直接改 parameter values |
| optimizer.step() | 使用 gradients/moments 改变 parameters;completed_updates 增加 1 |
| eval() + no_grad() | 设置 evaluation mode 并避免构建 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
为什么 generation 只 crop forward input,却把 next_id append 到完整 history?