Week 11
Week 11 - Training 与 Inference:让同一个 MiniGPT 正确学习、测量、保存与生成
Key question怎样围绕 Week 10 的同一个 MiniGPT 安排训练、验证、checkpoint 与生成,并准确说出每一行改变了什么 state?
Learning objectives
- 沿固定六个监督信号解释 forward、loss、backward、gradient clipping 与 AdamW step 的状态变化和先后顺序。
- 用 ln(5)≈1.609 建立均匀预测基线,并用 token-weighted held-out loss 区分训练效果与泛化测量。
- 识别重复上下文 [我] 的冲突,推导 one-batch mean NLL 只能趋近 ln(2)/3≈0.231 而不能趋近 0。
- 复用 Week 10 的精确模型、tokenizer 与 checkpoint identity,在最后位置 logits 上依次应用 τ、top-k、Softmax 与 multinomial。
100 min estimated reading time
Week 10 返回的是一次预测。本周让它重复学习,并区分训练、验证和生成。先读单 batch 的最小循环;梯度累积及严格恢复检查保留在选读小节,不是理解一次更新的门槛。
Scroll horizontally to view all columns.
| 学习单元 | 本次解决的问题 |
|---|---|
| 一:完成一次更新 | 区分参数、梯度和 optimizer 状态;用一个参数算两步 AdamW。 |
| 二:重复与测量 | 先学固定数据,再用不参与更新的文档验证;日志明确在什么时候测量。 |
| 三:保存和加载 | 保存模型配置、词表和权重;能恢复推理不等于精确复现中断后的训练。 |
| 四:生成 | 只取当前最后位置的 logits,调温度、限制候选、抽样、追加,再 forward。 |
运行 python week11_adamw_numbers.py 观察两步计算。独立文档训练命令在下一周完整项目;不要把固定三句的 Loss 下降称为泛化。数值比较先看概率表,不凭一条随机续写判断温度效果。
建议阅读、手算、改代码交替进行,每个单元可拆成几次完成。章节编号保留用于旧链接和回查;按页面从上到下的新顺序学习,不需要按旧编号来回跳转。
Week 11 核心目标:同一 MiniGPT 的四种工作
直白地说,模型只负责计算;外层 Week 11 training/generation module 决定怎样使用计算结果。Training 用 labels 并且是唯一会故意更新 θ 的 phase;validation 用真正 held-out labels 测量当前 θ;checkpointing 把兼容的长期 state 写出或读回;inference 没有 targets,只选择并追加 token。mini-gpt-v1 按空格切分,ordered tokens 固定为 [我, 喜欢, AI, 学习, 猫],IDs 固定为 0..4,没有 special、padding 或 unknown token;canonical GPTConfig 是 vocab_size=5、block_size=2、n_embd=4、n_head=2、n_layer=2,token embedding 与 LM head 保持 untied。
# week11_training_and_generation.py
# This Week 11 caller imports the frozen Week 10 implementation.
import math
import torch
import torch.nn.functional as F
from mini_gpt_walkthrough import (
GPTConfig,
MiniGPT,
load_mini_gpt_for_inference,
save_mini_gpt_training_checkpoint,
validate_checkpoint_tokenizer_identity,
)
CANONICAL_ORDERED_TOKENS = ("我", "喜欢", "AI", "学习", "猫")
CANONICAL_TOKENIZER_POLICY = (
"whitespace-delimited;no-specials;no-pad;no-unk"
)
CANONICAL_TOKENIZER_VERSION = "mini-gpt-v1"Scroll horizontally to view all columns.
| phase | 输入与用途 | 允许改变的 state | 明确禁止 |
|---|---|---|---|
| training | inputs [3,2] + targets [3,2];拟合训练 split | backward 改 .grad;step 改 θ 与 AdamW state | 不能只评分最后位置 |
| validation | held-out inputs/targets;读取 current θ 并测量 | 暂时改变 module mode,再恢复;不改 θ/optimizer | 不能 backward 或 step |
| checkpointing | save 读取并持久化 parameters、optimizer、config、tokenizer、progress;load 验证后恢复 | model/optimizer load_state_dict 明确替换长期 values;不是 learning update | 不能用相同 shape 代替 identity validation |
| inference | target-free prompt;逐轮追加一个 ID | caller 的 uncropped history 增长 | 不创建 loss、backward 或 step |
Scroll horizontally to view all columns.
| state | owner | 何时改变 | 谁读取或持久化 |
|---|---|---|---|
| parameters θ | model | optimizer.step() 学习更新;model.load_state_dict() 显式恢复 | training、validation、inference 读取;checkpoint save 持久化 |
| parameter.grad | 各 parameter | backward 累加;zero_grad 清除 | optimizer.step() 读取;canonical checkpoint 不保存 transient .grad |
| AdamW moments / counters | optimizer | optimizer.step() 学习更新;optimizer.load_state_dict() 显式恢复 | training step 读取/更新;checkpoint save 持久化供 faithful resume |
| completed_updates | training caller | 每次成功 optimizer.step() 后加 1;checkpoint load 恢复 | logging/checkpoint save;resume 再与 AdamW step state 核对 |
| GPTConfig / tokenizer identity | model / data caller | 本 run 内冻结;load 先验证 canonical values,再构造 matching objects | 所有 phase 依赖;checkpoint save 明确持久化 |
| activations / loss value / training graph | 当前 forward | forward 建立 values;只有 grad-enabled training 建 graph | training/validation 当前计算;checkpoint 不持久化 |
“只有 optimizer.step() 执行 learning update”仍然成立:load_state_dict() 也会替换 model/optimizer values,但那是显式 restoration,不是从当前 batch error 学习。Checkpoint save 读取并持久化 parameters、optimizer、config、tokenizer 与 progress;faithful load 先验证 config/tokenizer identity,再用已保存值替换 parameters、optimizer state 与 progress。
- training:forward [3,2] → logits [3,2,5] + loss [] → backward → step
- validation:held-out forward → token-loss sum / valid-token count;没有 update
- checkpointing:验证并保存/恢复同一套 identity 与 long-lived state
- inference:prompt → last logits [B,5] → next_id [B,1] → append history
固定 Week 6/11 training batch 才取 B=3、T=2,因此其一次 forward 是 [3,2]→[3,2,5],也就是 30 个 raw scores,而不是一句自动生成的文字。Inference 可以是 [1,2]→[1,2,5],validation 则是 [B_i,T_i]→[B_i,T_i,5]。Validation 与 inference 都应使用 no-grad,但前者有 held-out targets 并聚合 loss,后者没有 targets 且只消费当前最后位置的分布。
Knowledge check
四种工作里,哪一种可以调用 optimizer.step() 来学习?
1. 一个 Batch 提供六个训练信号
直白地说,一个 batch 把多行 independent sequences 同时交给模型;teacher forcing 在每个位置使用真实左侧 token,所以一次 forward 同时回答六道 next-token 题。这里 B=3 是 batch rows,N=3 是 shift 前每行 raw IDs 数,T=2 是每行 teacher-forced positions,C=4 是每个位置的 representation width,V=5 是每道题的 candidate 数。
Scroll horizontally to view all columns.
| ID | token |
|---|---|
| 0 | 我 |
| 1 | 喜欢 |
| 2 | AI |
| 3 | 学习 |
| 4 | 猫 |
raw_ids = torch.tensor([
[0, 1, 2], # 我 喜欢 AI
[4, 1, 0], # 猫 喜欢 我
[0, 3, 2], # 我 学习 AI
], dtype=torch.long) # [B,N] = [3,3]
inputs = raw_ids[:, :-1] # [[0,1],[4,1],[0,3]],shape [3,2]
targets = raw_ids[:, 1:] # [[1,2],[1,0],[3,2]],shape [3,2]
assert inputs.shape == targets.shape == (3, 2)
assert inputs.dtype == targets.dtype == torch.longScroll horizontally to view all columns.
| 位置 | 可见 causal context | target | 对应五个 scores |
|---|---|---|---|
| b=0, t=0 | 我 | 喜欢 | logits[0,0,:] |
| b=0, t=1 | 我 喜欢 | AI | logits[0,1,:] |
| b=1, t=0 | 猫 | 喜欢 | logits[1,0,:] |
| b=1, t=1 | 猫 喜欢 | 我 | logits[1,1,:] |
| b=2, t=0 | 我 | 学习 | logits[2,0,:] |
| b=2, t=1 | 我 学习 | AI | logits[2,1,:] |
实际用途是让 accelerator 并行计算,并让一次 gradient estimate 汇总六个监督信号。logits[b,t,:] 始终按 [我, 喜欢, AI, 学习, 猫] 排列五个 raw scores;targets[b,t] 是其中正确 class 的一个 long ID,不是 one-hot vector。
- raw IDs [B,N] = [3,3]
- shift → inputs [3,2] 与 targets [3,2]
- MiniGPT token + position representations [3,2,4]
- two pre-norm Blocks + final_norm + lm_head → logits [3,2,5]
- row-major reshape → logits [6,5] 与 targets [6]
- 六个 per-position NLL 的 mean → scalar loss []
inputs [B,T]=[3,2] → token/position representation [B,T,C]=[3,2,4] → logits [B,T,V]=[3,2,5] → logits.reshape(B×T,V)=[6,5] 与 targets.reshape(B×T)=[6] → scalar mean cross-entropy loss []。
Knowledge check
这个 batch 为什么提供六个 target labels,而不是三个?
2. Step、Microbatch 与 Epoch:先给训练进度单位
直白地说,microbatch 是一次装得进 memory 的数据切片;optimizer step 是一次实际参数更新;epoch 是完整走过 training loader 一遍。本周固定三句语料若作为一个 batch 且 accumulation_steps=1,一轮 epoch 恰好有一个 step,但这只是极小示例,不是定义。
Scroll horizontally to view all columns.
| 单位 | 发生什么 | Week 11 如何计数 |
|---|---|---|
| microbatch | 一次 forward + backward contribution | 不自动等于 update |
| optimizer step | 读取当前 accumulated gradients 并更新 state | completed_updates 加 1 |
| epoch | training batches 完整遍历一次 | 可能含许多 updates |
| generation iteration | sample 并 append 一个 ID | 不是 training step |
Shape 仍是每个固定 microbatch 的 [3,2]→[3,2,4]→[3,2,5]→[6,5]+[6]→[];epoch 只改变这条计算被重复多少次,不改变 tensor rank。Checkpoint 的 completed_updates 记录已经执行完的 optimizer.step() 次数,而不是零起始 loop index 或下一步编号。
Knowledge check
120 个 microbatches 以每四个完整累积窗口更新一次,一轮 epoch 有多少 optimizer steps?
3. AdamW:把历史梯度变成这一步的参数改变量
直白地说,AdamW 不只看当前 gradient g_t;它为每个 parameter coordinate 维护一阶 moving average m_t 与平方 gradient 的 moving average v_t,再用它们调节 update scale。weight decay 另行轻微收缩 weights。它适合 noisy、scale 差异大的 language-model gradients,但仍需要选择 learning rate。
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,
weight_decay=1e-2,
)先把 model 移到目标 device,再把这些 parameter objects 交给 AdamW。构造 optimizer 时 param groups 已存在;第一次有 gradient 的 optimizer.step() 才建立并更新相应 moment tensors 与 counters。optimizer.zero_grad() 只处理 parameter.grad,不会清空 m、v 或 step history。
Scroll horizontally to view all columns.
| 对象 | 示例 shape | 谁改变它 |
|---|---|---|
| token_embedding.weight | [5,4] | optimizer.step() |
| token_embedding.weight.grad | [5,4] | backward 累加;zero_grad 清除 |
| 对应 AdamW m 与 v | 各 [5,4] | optimizer.step() |
| 当前 logits / loss | [3,2,5] / [] | forward 重新计算 |
Knowledge check
为什么 faithful resume 必须恢复 AdamW state?
4. 一个标准 Training Step:逐行追踪 State
# 在 course_examples 目录运行:python week11_minimal_loop.py
import torch
from mini_gpt_walkthrough import GPTConfig, MiniGPT
from course_data import DEMO_DOCUMENTS, FIVE_WORD_TOKENIZER, make_windows, configure_console
configure_console()
torch.set_num_threads(1)
torch.manual_seed(7)
x, y = make_windows(DEMO_DOCUMENTS, FIVE_WORD_TOKENIZER, block_size=2)
inputs = torch.tensor(x, dtype=torch.long)
targets = torch.tensor(y, dtype=torch.long)
model = MiniGPT(GPTConfig())
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
for step in range(1, 101):
model.train()
optimizer.zero_grad(set_to_none=True)
logits, loss = model(inputs, targets)
if loss is None or not torch.isfinite(loss):
raise ValueError("Non-finite loss; no parameter update performed")
loss.backward()
optimizer.step()
if step == 1 or step % 20 == 0:
print("completed_update=", step, "loss_before_this_update=", loss.item())
model.eval()
with torch.no_grad():
_, final_loss = model(inputs, targets)
print("same_batch_loss_after_100_updates=", final_loss.item())
print("这是三句固定数据的流程演示,不是独立验证或泛化证明。")日志中的 loss 是本次更新之前那次 forward 的值;最后的 final_loss 才是 100 次更新后重新计算的结果。optimizer.step 不会改写旧 loss 变量。请先预测删除 step 或每轮重建 model 会发生什么,再看下面带诊断保护的可复用函数。
直白地说,一个 training step 把一个 batch 的监督误差变成一次 parameter update。以下函数属于 week11_training_and_generation.py,并直接使用导入的 canonical MiniGPT;它没有重定义 config、attention、blocks、initializer 或 state-dict names。
def train_mini_gpt_step(
model: MiniGPT,
optimizer: torch.optim.AdamW,
inputs: torch.Tensor,
targets: torch.Tensor,
device: torch.device,
max_grad_norm: float = 1.0,
completed_updates: int = 0,
) -> tuple[torch.Tensor, torch.Tensor, int]:
if not math.isfinite(max_grad_norm) or max_grad_norm <= 0:
raise ValueError("max_grad_norm must be positive")
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
model.train()
inputs = inputs.to(device)
targets = targets.to(device)
optimizer.zero_grad(set_to_none=True)
logits, loss = model(inputs, targets)
assert logits.shape == (*inputs.shape, model.config.vocab_size)
assert loss is not None
if not torch.isfinite(loss):
raise ValueError("训练 loss 非有限,停止本次更新")
loss.backward()
grad_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=max_grad_norm,
error_if_nonfinite=True,
)
optimizer.step()
completed_updates += 1 # only after optimizer.step succeeds
return loss.detach(), grad_norm.detach(), completed_updatesScroll horizontally to view all columns.
| line | 立即改变或创建什么 | 为什么必须在这里 |
|---|---|---|
| model.train() | 递归设置 module training flags | 在 forward 前选择 training behaviour;不更新 weights |
| inputs/targets.to(device) | 创建或返回共置的 batch tensors | CPU batch 不能直接乘 CUDA parameters |
| optimizer.zero_grad(set_to_none=True) | 清除旧 .grad references | 防止本 step 与上个 window 的 gradients 意外相加 |
| model(inputs, targets) | 创建 activations/graph、logits [3,2,5]、loss [] | parameters 与 AdamW state 仍未改变 |
| loss.backward() | 把 ∂L/∂θ 累加到 parameter.grad | step 必须先拥有当前 gradients |
| clip_grad_norm_ | 必要时原地缩放全部 gradient tensors | 在所有 intended backward 后、step 前限制 norm |
| optimizer.step() | 改变 parameter values、AdamW moments/counters;成功返回后 caller count 加 1 | 这是唯一真正学习的一行 |
| loss.detach() | 返回与 graph 分离的 logging tensor | 避免日志留住已经用完的 graph |
- inputs/targets [3,2] on model device
- forward → representations [3,2,4] → raw logits [3,2,5]
- reshape [6,5] + [6] → scalar loss []
- backward → one .grad tensor per participating parameter
- clip complete gradient set → optimizer.step()
- same parameter shapes, new parameter values and AdamW state
参数更新可概念化为 θ←θ+Δθ,但这不会 retroactively 改写刚才 forward 的 inputs、targets 或 logits。下一次 forward 才会使用新 θ 计算新 logits。
可执行版本在 backward 前检查 loss 是否有限,裁剪时也要求梯度有限;遇到 NaN/Inf 就停止本次 step。不能等参数已经更新后,才从日志发现本应停止的数值问题。
Knowledge check
loss.backward() 已执行而 optimizer.step() 尚未执行时,什么 state 是新的?
6. 初始 Loss 的参照:从均匀五选一推导 ln(5)
直白地说,cross-entropy 衡量模型给真实 target 留了多少 probability。若每道五分类题的 logits 都是 [0,0,0,0,0],Softmax 在候选顺序 [我, 喜欢, AI, 学习, 猫] 上就是 [0.2,0.2,0.2,0.2,0.2];无论 target 是 喜欢 还是 AI,正确项都只拿到 0.2。
Scroll horizontally to view all columns.
| 量 | 固定 batch 中的含义 | shape / value |
|---|---|---|
| raw equal logits | 六道题各一行 [0,0,0,0,0] | [6,5] |
| uniform probabilities | 每行五项都是 0.2 | [6,5] |
| per-position NLL | 六项各约 1.609 | [6] |
| mean CE | 六项 mean | scalar [],约 1.609 |
实际用途是为 loss 曲线提供数量级参照,而不是 pass/fail threshold。随机 initialization 的 logits 不会恰好全零,所以第一次 loss 可略高或略低于 1.609。inputs [B,T]=[3,2] → token/position representation [B,T,C]=[3,2,4] → logits [B,T,V]=[3,2,5] → logits.reshape(B×T,V)=[6,5] 与 targets.reshape(B×T)=[6] → scalar mean cross-entropy loss []。
Knowledge check
五 token vocabulary 中,模型给真实 target 概率 0.2 时 per-position CE 是多少?
7. Validation 为什么必要:测量没有参与 Update 的数据
直白地说,training split 用来选择 gradients,validation split 只用来观察当前 parameters。它们使用相同 tokenizer、input/target alignment 与 CE 定义,但数据来源不同;validation 结果可帮助选择 checkpoint、停止点、learning rate 或容量,却绝不能对这个 batch backward。
Scroll horizontally to view all columns.
| train loss | held-out validation loss | 较合理的解读 | 下一项直接检查 |
|---|---|---|---|
| 下降且两者保持接近 | 也下降 | 该 split 上的 generalization 在改善 | 继续看 samples 与 checkpoints |
| 持续下降 | 持续上升 | 开始过拟合 training data | 较早 checkpoint、更多数据/regularization 或较小模型 |
| 长期近 ln(5) | 也近 ln(5) | underfit 或 signal/update broken | one-batch diagnostic、targets、gradients、LR |
| 剧烈波动或非有限 | 同样不稳 | data/numerical/update instability | 定位第一处 non-finite,检查 LR 与 grad norm |
每个 validation batch 仍从 inputs [B_i,T_i] 产生 logits [B_i,T_i,5],再对有效 targets 求 loss;B_i 可以不同,T_i 必须在 1..block_size=2。跨 batch 汇总时要把 token-level loss sums 相加后除以有效 target 总数,不能把大小不同的 batch means 等权平均。
Knowledge check
Training loss 继续下降而 held-out validation loss 持续上升,最直接的担忧是什么?
8. 正确的 Validation:Mode、No-Grad 与 Token Weighting
直白地说,eval() 决定 layers 怎样运行,no_grad() 决定是否记录 backward graph;两者解决不同问题。Token-weighted aggregation 则确保含 6 个 targets 的 batch 对总 loss 的贡献是含 2 个 targets batch 的三倍,而不是两者各占一半。
def evaluate_mini_gpt_loss(
model: MiniGPT,
validation_batches,
device: torch.device,
ignore_index: int = -100,
) -> float:
if 0 <= ignore_index < model.config.vocab_size:
raise ValueError("ignore_index must not be a vocabulary ID")
was_training = model.training
batch_count = 0
valid_target_count = 0
loss_sum = 0.0
model.eval()
try:
with torch.no_grad():
for inputs, targets in validation_batches:
batch_count += 1
if targets.shape != inputs.shape:
raise ValueError("targets must match inputs shape")
if targets.dtype != torch.long:
raise TypeError("targets must have dtype torch.long")
inputs = inputs.to(device)
targets = targets.to(device)
valid_mask = targets.ne(ignore_index)
batch_valid_count = int(valid_mask.sum().item())
if batch_valid_count == 0:
continue
valid_targets = targets[valid_mask]
if (
int(valid_targets.min().item()) < 0
or int(valid_targets.max().item())
>= model.config.vocab_size
):
raise ValueError("valid target IDs are outside vocabulary")
# Do not pass -100 targets into canonical MiniGPT.forward.
logits, no_loss = model(inputs)
assert no_loss is None
batch_loss_sum = F.cross_entropy(
logits.reshape(-1, model.config.vocab_size),
targets.reshape(-1),
ignore_index=ignore_index,
reduction="sum",
)
loss_sum += batch_loss_sum.item()
valid_target_count += batch_valid_count
finally:
model.train(was_training)
if batch_count == 0:
raise ValueError("validation_batches must not be empty")
if valid_target_count == 0:
raise ValueError("validation has no valid target tokens")
return loss_sum / valid_target_countCanonical MiniGPT 会拒绝 target ID -100,因此 masked validation 不能调用 model(inputs, targets)。这里先调用 model(inputs) 取得 raw logits,再在外部用 ignore_index 计算 summed CE。没有 padding 的普通 batch 也走同一 token-weighted path。try/finally 保证函数原来处于 train mode 就恢复 train,原来处于 eval mode 就保持 eval,即使 iterator 或 shape validation 抛错也不遗留 mode change。
Scroll horizontally to view all columns.
| batch | 有效 targets | loss sum | 错误的 batch mean 权重 | 正确 token 权重 |
|---|---|---|---|---|
| A | 6 | 6.0 | 1/2 | 6/8 |
| B | 2 | 6.0 | 1/2 | 2/8 |
| 聚合 | 8 | 12.0 | (1.0+3.0)/2=2.0 | 12.0/8=1.5 |
- remember was_training
- model.eval() + torch.no_grad()
- inputs [B_i,T_i] → logits [B_i,T_i,5],不传 masked targets 给 model
- external CE reduction=sum over valid target IDs
- accumulate loss_sum and valid_target_count
- finally restore exact prior mode
- guard empty/all-masked → return token-weighted Python float
Knowledge check
哪一步防止 validation forward 建立 backward graph?eval() 是否能替代它?
9. Gradient Clipping:在 Update 前限制完整 Gradient
L2 norm 可以先读成一组数的总长度:梯度 [3,4] 的长度是 √(3²+4²)=5。若上限为 1,就同乘 1/5,得到 [0.6,0.8],长度为 1;方向比例仍是 3:4。
裁剪直接限制的是梯度长度。对普通 SGD,更新长度随 η×梯度长度变化;AdamW 还要使用历史均值、平方均值与 weight decay,所以不能把“梯度上限 1”解释成“参数更新长度严格不超过 1”。
直白地说,把所有 parameter gradients 想成一个很长向量 g;若它的 L2 norm 超过 cap c,就把所有分量乘同一个比例,保留 direction 而限制 magnitude。实际用途是给异常大 update 加一道 guardrail,同时保留 grad_norm 日志帮助定位根因。
Scroll horizontally to view all columns.
| raw global norm | cap c | 共同 scale | 结果 |
|---|---|---|---|
| 5.0 | 1.0 | 约 1/5 | norm 降到约 1.0,direction 不变 |
| 0.6 | 1.0 | 1 | gradients 不变 |
固定模型中 token_embedding.weight.grad 仍是 [5,4],每个 block 的 qkv.weight.grad 仍是 [12,4];clipping 没有 [B,T] activation shape。train_mini_gpt_step 中 clip_grad_norm_ 返回 pre-clip norm,可与 scalar loss 一起 detached logging。Accumulation 时必须等完整 window 的 contributions 都进入 .grad 后再 clip 一次。
def backward_and_clip_mini_gpt(
model: MiniGPT,
loss: torch.Tensor,
max_grad_norm: float = 1.0,
) -> torch.Tensor:
if loss.ndim != 0:
raise ValueError("loss must be a scalar tensor")
if not math.isfinite(max_grad_norm) or max_grad_norm <= 0:
raise ValueError("max_grad_norm must be finite and positive")
loss.backward()
grad_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=max_grad_norm,
)
return grad_norm.detach()
# Caller order inside one update window:
# optimizer.zero_grad(...) -> forward creates loss -> helper above
# -> optimizer.step() -> increment completed_updatesKnowledge check
Gradient accumulation 时 clipping 应在哪一刻执行?
10. Learning Rate:AdamW 仍需要全局步幅
直白地说,lr 决定每次 optimizer.step() 大致走多远。它不直接修改当前 logits [3,2,5],而是改变 θ,因而只在下一次 forward 间接改变 logits values。实际使用时应记录当前 lr、completed_updates、loss 与 grad norm,并一次只改变一个实验变量。
Scroll horizontally to view all columns.
| controlled-run symptom | 可能的 LR reading | 先做的检查 |
|---|---|---|
| loss 多次 update 后仍近 ln(5) | 1e-6 可能太小 | 确认 labels 对齐、grads 非 None、step 真执行 |
| loss 下降平滑 | 1e-3 在该 tiny run 可作为起点 | 继续比较 held-out loss 与 samples |
| loss spike / oscillation | 可能太大 | 查看 first bad update 与 pre-clip grad norm |
| loss 或 gradients 非有限 | update 可能失稳 | 先定位第一个 NaN/Inf,再调整 LR |
固定教学 run 使用 AdamW(..., lr=1e-3, weight_decay=1e-2),但这不是所有模型的 magic value。比较 1e-6 与 1e-3 时必须固定 tokenizer、split、batch/accumulation policy、seed 与 update 数,否则差异不能归因给 LR。
Knowledge check
Controlled run 中什么现象可能提示 LR 太小?
11. Overfit One Batch:先识别不可消除的标签冲突
下面 inf 读作“能无限接近的下界”。两个相同前缀必须使用同一分布,却被要求回答两个不同词,因此最好各给一半概率,不能同时给两个词概率 1。0.231 是仅按可见上下文和数据冲突推导的理想参照;有限大小的网络、训练步数与优化器不保证达到它。
直白地说,这个 diagnostic 问的是“完整监督路径能否明显学习”,而不是“模型是否 generalize”。反复使用 inputs [[0,1],[4,1],[0,3]] 与 targets [[1,2],[1,0],[3,2]] 后,loss 应从 random reference 约 ln(5) 明显下降,并朝该 batch 的 empirical conditional optimum 走;但不能要求 deterministic causal model argmax 正确预测全部六个 labels。
def overfit_mini_gpt_one_batch(
model: MiniGPT,
optimizer: torch.optim.AdamW,
inputs: torch.Tensor,
targets: torch.Tensor,
device: torch.device,
updates: int = 200,
completed_updates: int = 0,
) -> tuple[list[float], int]:
if type(updates) is not int or updates < 1:
raise ValueError("updates must be a positive integer")
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
model.train()
inputs = inputs.to(device)
targets = targets.to(device)
history: list[float] = []
for _ in range(updates):
optimizer.zero_grad(set_to_none=True)
logits, loss = model(inputs, targets)
assert logits.shape == (3, 2, 5)
assert loss is not None and torch.isfinite(loss)
loss.backward()
optimizer.step()
completed_updates += 1 # only after optimizer.step succeeds
history.append(loss.detach().item())
return history, completed_updatesScroll horizontally to view all columns.
| position(s) | causal context | observed target(s) | empirical optimum |
|---|---|---|---|
| b=0,t=0 与 b=2,t=0 | [我] | 喜欢、学习 | P(喜欢|我)=0.5,P(学习|我)=0.5 |
| b=0,t=1 | [我,喜欢] | AI | 该 row 可趋近给 AI probability 1 |
| b=1,t=0 | [猫] | 喜欢 | 可趋近给 喜欢 probability 1 |
| b=1,t=1 | [猫,喜欢] | 我 | 该 row 可趋近给 我 probability 1 |
| b=2,t=1 | [我,学习] | AI | 该 row 可趋近给 AI probability 1 |
- same [我] prefix at t=0 → exactly one shared five-token distribution
- two conflicting labels → empirical mass 0.5 on 喜欢 and 0.5 on 学习
- four distinguishable contexts → correct-label probability can approach 1
- mean loss falls materially below ln(5) toward, but not to, ln(2)/3
- inspect [我,喜欢] versus [猫,喜欢] final logits to confirm context can create different predictions
实际诊断应记录 first/last loss、finite values 与 gradients,并比较 logits.argmax(dim=-1) [3,2] 时承认冲突位置最多选中其中一个。Helper 接受已有 completed_updates,只在每次 optimizer.step() 成功返回后加 1,并把 cumulative count 与 history 一起交还 caller;它不会用 loop 上限冒充已完成进度。更有信息量的 context check 是让 [我,喜欢] 与 [猫,喜欢] 在 t=1 产生可不同 distributions;它们的 visible prefixes 确实不同。
Knowledge check
为什么这个 one-batch diagnostic 不能要求六个 positions 全部 argmax 正确?
13. Inference、Training 与 Validation:都 Forward,但目的不同
直白地说,training 用目标答案计算 loss 并把误差反传;validation 也有答案,但只用来计分;inference 没有答案,必须把当前最后位置分布转换成一个 ID 并循环追加。MiniGPT.forward 保持不变,差异由 Week 11 caller 建立。
Scroll horizontally to view all columns.
| phase | model call | graph / mode | 之后做什么 |
|---|---|---|---|
| training | model(inputs [3,2], targets [3,2]) | train mode + Autograd graph | all-position loss → backward → step |
| validation | model(held_out_inputs),external summed CE | eval mode + no_grad | token-weighted report;不更新 |
| inference | model(cropped context),无 targets | eval mode + no_grad | last logits → sample [B,1] → append |
prompt = torch.tensor([[0, 1]], dtype=torch.long, device=device)
was_training = model.training
model.eval()
try:
with torch.no_grad():
prompt_logits, no_loss = model(prompt)
finally:
model.train(was_training)
assert prompt_logits.shape == (1, 2, 5)
assert no_loss is None
next_logits = prompt_logits[:, -1, :] # [1,5] after 我 喜欢- training:([3,2],[3,2]) → (logits [3,2,5], loss []) → gradients → updated θ
- validation:held-out ([B,T],[B,T]) → logits → external loss sum/count → Python float
- inference:[B,T_context] → logits [B,T_context,5] → last [B,5] → next_id [B,1]
eval() 不会删除之前残留的 .grad;它只改变 module mode。no_grad() 防止本 forward 建 graph,却也不清除旧 .grad。真正保证 inference 不更新的是调用链中完全没有 loss.backward() 与 optimizer.step()。
Knowledge check
Training 与 inference 都一定能得到哪个 output?loss 何时才存在?
14. 为什么生成只取最后一个位置
直白地说,prompt [我, 喜欢] 的 position 0 logits 回答“我后面是什么”,position 1 logits 回答“我 喜欢后面是什么”。生成当前轮只会 append 后一个问题的答案;早期 logits 在 teacher-forced training 中有监督价值,在本 generation iteration 却不是 prompt 末尾之后的预测。
prompt = torch.tensor([[0, 1]], dtype=torch.long, device=device)
was_training = model.training
model.eval()
try:
with torch.no_grad():
logits, no_loss = model(prompt) # targets intentionally absent
finally:
model.train(was_training)
assert logits.shape == (1, 2, 5)
assert no_loss is None
next_logits = logits[:, -1, :]
assert next_logits.shape == (1, 5)Scroll horizontally to view all columns.
| tensor slice | shape | labelled meaning |
|---|---|---|
| logits[0,0,:] | [5] | after 我:五个候选 scores |
| logits[0,1,:] | [5] | after 我 喜欢:五个候选 scores |
| logits[:,-1,:] | [1,5] | 每个 batch prompt 的当前最后位置 distribution source |
若输入三条 prompts 的 logits 是 [3,2,5],同一 indexing 得到 [3,5],每行独立决定一个 next ID。这里 -1 表示当前 sequence axis 的最后位置,不是 token ID -1。
- training:logits [3,2,5] 全部 reshape 为 [6,5] 并评分六个 positions
- generation:logits [B,T_context,5] 先选择 [:,-1,:]
- next logits [B,5] 再经过 τ / top-k / Softmax / sampling
Knowledge check
logits shape 是 [3,2,5] 时,logits[:,-1,:] 是什么 shape?
15. Temperature τ:只改变 Sampling 分布的尖锐程度
直白地说,τ<1 会放大 logit 差距,让最高分候选更集中;τ>1 会缩小差距,让分布更平;τ=1 保持普通 Softmax。实际计算先让每行减去自己的 maximum,使最大值变成 0,再除以 τ;所有 candidates 同减一个常数不会改变 ranking 或 Softmax probabilities,却避免正方向的 exponential overflow。它是 inference-time sampling control,不改变 model parameters、AdamW state 或训练目标。T 在本周始终保留给 sequence/time axis。
Scroll horizontally to view all columns.
| τ setting | scaled logits 的相对间距 | sampling effect |
|---|---|---|
| τ=0.5 | 原差距乘 2 | 更尖,最高 logit probability 上升 |
| τ=1 | 不变 | 普通 Softmax |
| τ=2 | 原差距减半 | 更平,低分候选 probability 上升 |
- finite floating last logits z [B,5]
- promote float16/bfloat16 至至少 float32,并 validate τ>0
- row-center z−max(z) [B,5],再 scale /τ [B,5]
- reject non-finite centered/scaled values with a clear numeric-domain error
- ranking 不变;relative gaps 改变
- 之后才做 optional top-k 与 Softmax
Positive τ 除法保持 candidate ranking;它只改变相对概率差距。τ=0 会除零,τ<0 会翻转 ranking,都不是本 sampling API 允许的设置。即使 raw logits 都 finite,极小 τ 也可能让负的 centered gaps 在 working dtype 中溢出为 -∞;helper 会在 Softmax 前用明确 ValueError 拒绝,而不是把 NaN 交给 multinomial。
Knowledge check
把 τ 从 1 降到 0.5 会改变 checkpoint 中的 model weights 吗?
16. 手算 Temperature τ:同一组五个带标签概率
直白地说,手算同一组 scores 能把“更尖、更平”变成可核对的 probability changes;实际使用时据此选择 sampling diversity。仍用 candidate order [我, 喜欢, AI, 学习, 猫] 与 z=[0,2,1,-1,-0.5]。Softmax 只在 τ scaling 之后发生;表中值为四舍五入,所以每列显示值可能不严格相加为 1。
Scroll horizontally to view all columns.
| token | raw z | p(τ=1) | p(τ=0.5) | p(τ=2) |
|---|---|---|---|---|
| 我 | 0.0 | 0.083 | 0.016 | 0.148 |
| 喜欢 | 2.0 | 0.612 | 0.860 | 0.403 |
| AI | 1.0 | 0.225 | 0.116 | 0.244 |
| 学习 | -1.0 | 0.030 | 0.002 | 0.090 |
| 猫 | -0.5 | 0.050 | 0.006 | 0.115 |
τ=0.5 时,原始等价手算仍是 z/τ=[0,4,2,-2,-1],其 exponentials 总和约 63.490。稳定实现先减 m=2,再除 τ,得到 [-4,0,-2,-6,-5];这只是把前一向量所有项同减 4,所以 probabilities 完全相同,而最大 exponential 现在只是 exp(0)=1。最高 logit“喜欢”仍得到约 0.860。τ=2 时差距缩小,centering 同样不改变结果。
next_logits = torch.tensor(
[[0.0, 2.0, 1.0, -1.0, -0.5]],
device=device,
) # [1,5] ordered as 我, 喜欢, AI, 学习, 猫
temperature = 0.5
if not math.isfinite(temperature) or temperature <= 0:
raise ValueError("temperature must be positive")
sampling_logits = (
next_logits.float()
if next_logits.dtype in (torch.float16, torch.bfloat16)
else next_logits
)
centered_logits = sampling_logits - sampling_logits.amax(
dim=-1,
keepdim=True,
)
scaled_logits = centered_logits / temperature # [-4,0,-2,-6,-5]
if not bool(torch.isfinite(scaled_logits).all()):
raise ValueError("temperature is too small for stable scaling")
probabilities = F.softmax(scaled_logits, dim=-1) # [1,5]
if not bool(torch.isfinite(probabilities).all()):
raise ValueError("sampling probabilities must be finite")
next_id = torch.multinomial(
probabilities,
num_samples=1,
) # torch.long [1,1]torch.argmax(probabilities, dim=-1, keepdim=True) 也输出 [1,1],但总选最高 probability;torch.multinomial 会按概率随机抽样,所以可能选择非 argmax token。二者都是 forward 之后的 choice policy,不是另一次模型计算。
若某词概率为 0.6,可以想象多次从同一分布独立抽样,大约六成会选到它;只抽一次可能选不到。实际生成每追加一个 token,前缀会变化,下一轮分布也通常变化,所以不能把整段续写当成反复从同一张表抽签。降低温度只是改变本轮选择的集中程度,不会让知识变多,也不保证事实更正确。
Knowledge check
本例 τ 从 1 降到 0.5 时,哪个 token 的 probability 增加最多?为什么?
17. Top-k:在 τ Scaling 后限制候选集
直白地说,top-k 在 sampling 前缩小“本轮允许抽中的词表”。正 τ 不改变 ranking,因此先做 τ scaling,再取最高 k 项;被过滤项设为 -∞,Softmax 后精确变成 0,保 survivors 会重新归一化。它不更新 model,也不是 training regularizer。
Scroll horizontally to view all columns.
| token | scaled logit at τ=1 | after k=3 filter | final probability |
|---|---|---|---|
| 我 | 0.0 | 0.0 | 0.090 |
| 喜欢 | 2.0 | 2.0 | 0.665 |
| AI | 1.0 | 1.0 | 0.245 |
| 学习 | -1.0 | -∞ | 0.000 |
| 猫 | -0.5 | -∞ | 0.000 |
表格保留前一节的 uncentered τ=1 scores,方便逐 token 核对 11.107 与 probabilities。实际 helper 对每行同减 maximum 2,使用 [-2,0,-1,-3,-2.5];top-3 survivors、ranking 与最后 probabilities 完全相同,只把 survivor denominator 等价地缩放为 exp(-2)+exp(0)+exp(-1)。
def sample_mini_gpt_next_id(
last_logits: torch.Tensor,
temperature: float = 1.0,
top_k: int | None = None,
) -> torch.Tensor:
if not isinstance(last_logits, torch.Tensor):
raise TypeError("last_logits must be a tensor")
if last_logits.ndim != 2:
raise ValueError("last_logits must have shape [B,V]")
if last_logits.size(0) < 1:
raise ValueError("last_logits must contain at least one batch row")
if last_logits.size(-1) != 5:
raise ValueError("mini-gpt-v1 requires V=5")
if not torch.is_floating_point(last_logits):
raise TypeError("last_logits must use a floating dtype")
if not bool(torch.isfinite(last_logits).all()):
raise ValueError("last_logits must be finite")
if type(temperature) not in (int, float):
raise TypeError("temperature must be a real number")
temperature = float(temperature)
if not math.isfinite(temperature) or temperature <= 0:
raise ValueError("temperature must be positive")
vocabulary_size = last_logits.size(-1)
if top_k is not None:
if type(top_k) is not int or not 1 <= top_k <= vocabulary_size:
raise ValueError("top_k must be an integer in [1,V]")
# Softmax support and numeric headroom are safer than half precision.
working_dtype = (
torch.float64
if last_logits.dtype == torch.float64
else torch.float32
)
sampling_logits = last_logits.to(dtype=working_dtype)
dtype_limits = torch.finfo(working_dtype)
if temperature < dtype_limits.tiny:
raise ValueError("temperature is too small for the sampling dtype")
if temperature > dtype_limits.max:
raise ValueError("temperature is too large for the sampling dtype")
row_max = sampling_logits.amax(dim=-1, keepdim=True)
centered_logits = sampling_logits - row_max
if not bool(torch.isfinite(centered_logits).all()):
raise ValueError("last_logits range is too wide after centering")
scaled_logits = centered_logits / temperature
if not bool(torch.isfinite(scaled_logits).all()):
raise ValueError(
"temperature is too small for this logit range and sampling dtype"
)
# Top-k stays after temperature scaling and before Softmax.
filtered_logits = scaled_logits
if top_k is not None:
top_values, top_indices = torch.topk(
scaled_logits,
k=top_k,
dim=-1,
)
filtered_logits = torch.full_like(
scaled_logits,
float("-inf"),
)
filtered_logits.scatter_(
dim=-1,
index=top_indices,
src=top_values,
)
probabilities = F.softmax(filtered_logits, dim=-1)
probability_sums = probabilities.sum(dim=-1)
if (
not bool(torch.isfinite(probabilities).all())
or bool((probabilities < 0).any())
or not bool(torch.isfinite(probability_sums).all())
or bool((probability_sums <= 0).any())
):
raise ValueError("temperature/top_k produced unusable probabilities")
next_id = torch.multinomial(probabilities, num_samples=1)
assert next_id.dtype == torch.long
return next_id # [B,1]- last_logits z [B,V]=[B,5]
- validate floating/finite z、τ>0 与 optional integer 1≤k≤V
- promote low precision → row-center → finite scale (z−max(z))/τ [B,5]
- retain top k logits; others become -∞ [B,5]
- Softmax renormalizes survivors;verify finite nonnegative row mass [B,5]
- multinomial samples next_id torch.long [B,1]
k=1 使剩余 distribution 在唯一最高项上确定;k=V=5 不过滤任何 finite candidate。Cutoff 处相同 logits 的 tie selection 可依实现而定,不能承诺相等分数中固定保留哪一个。
Knowledge check
一个 candidate 被 top-k 设为 -∞ 后,其 Softmax probability 是多少?
18. 完整 Generation Loop:Crop、Last、Sample、Append
直白地说,每轮只把完整 history 的最后至多两个 IDs 送进 MiniGPT;模型返回每个 visible position 的 logits;caller 只拿最后一行,抽一个 [B,1] integer ID,再把它接到未裁剪 history 上。整个过程 target-free 且 read-only。
@torch.no_grad()
def generate_mini_gpt_sampled(
model: MiniGPT,
history: torch.Tensor,
max_new_tokens: int,
temperature: float = 1.0,
top_k: int | None = None,
) -> torch.Tensor:
if type(max_new_tokens) is not int or max_new_tokens < 0:
raise ValueError("max_new_tokens must be a non-negative integer")
if history.ndim != 2:
raise ValueError("history must have shape [B,L_history]")
if history.dtype != torch.long:
raise TypeError("history must have dtype torch.long")
if history.numel() == 0 or history.size(1) < 1:
raise ValueError("history must contain at least one token per row")
if int(history.min().item()) < 0 or int(history.max().item()) >= 5:
raise ValueError("history IDs must be in [0,4]")
if history.device != next(model.parameters()).device:
raise ValueError("history and model must be on the same device")
if not math.isfinite(temperature) or temperature <= 0:
raise ValueError("temperature must be positive")
if top_k is not None:
if type(top_k) is not int or not 1 <= top_k <= 5:
raise ValueError("top_k must be an integer in [1,5]")
was_training = model.training
model.eval()
try:
for _ in range(max_new_tokens):
context = history[:, -model.config.block_size :]
logits, no_loss = model(context) # no targets in generation
assert no_loss is None
last_logits = logits[:, -1, :]
next_id = sample_mini_gpt_next_id(
last_logits,
temperature=temperature,
top_k=top_k,
)
assert next_id.shape == (history.size(0), 1)
history = torch.cat((history, next_id), dim=1)
finally:
model.train(was_training)
return historyScroll horizontally to view all columns.
| first iteration from 我 喜欢 | value | shape |
|---|---|---|
| full history | [[0,1]] = [我,喜欢] | [1,2] |
| cropped context | [[0,1]],仍在 block_size=2 内 | [1,2] |
| model logits | 两个 visible positions,各五个 scores | [1,2,5] |
| last_logits | after 我 喜欢 | [1,5] |
| sampled example next_id | [[2]] = AI | [1,1] |
| new full history | [[0,1,2]] = [我,喜欢,AI] | [1,3] |
- uncropped history [B,L_history]
- crop only forward context → [B,min(L_history,2)]
- MiniGPT(context), no targets → [B,T_context,5]
- logits[:,-1,:] → [B,5]
- promote + row-center + τ scale → optional top-k → checked Softmax → multinomial
- next_id torch.long [B,1]
- append to uncropped history → [B,L_history+1]
第二轮 full history 已是 [0,1,2],但进入 model 的 context 是尾部 [1,2]。若需要 sampled demo 可复现,应在 caller 明确设置 torch RNG seed;这不把 sampling 变成 model state update。
Knowledge check
Append 前哪个 tensor 的 shape 必须是 [B,1]?
19. 为什么只截断 Forward Context,不截断 History
直白地说,history 是要交给用户的完整结果,context 是本轮模型能看的尾部窗口。Caller 保留前者,只有在调用 forward 前才计算后者。这样 output 不会丢词,但模型的决定确实只依赖最近至多两个 token。
Scroll horizontally to view all columns.
| 对象 | IDs / tokens | shape | owner |
|---|---|---|---|
| full history | [0,1,2] = 我 喜欢 AI | [1,3] | generation caller |
| tail context | [1,2] = 喜欢 AI | [1,2] | 本轮 forward input |
| model logits | context 两个 positions × 五 candidates | [1,2,5] | MiniGPT forward |
| last logits | after 喜欢 AI | [1,5] | caller sampling path |
history = torch.tensor(
[[0, 1, 2]],
dtype=torch.long,
device=device,
) # 我 喜欢 AI,shape [1,3]
context = history[:, -model.config.block_size :]
assert context.tolist() == [[1, 2]]
assert context.shape == (1, 2)
was_training = model.training
model.eval()
try:
with torch.no_grad():
logits, no_loss = model(context) # targets intentionally absent
finally:
model.train(was_training)
assert logits.shape == (1, 2, 5)
assert no_loss is None实际用途是遵守 position_embedding.weight [2,4]、每个 causal_mask [1,1,2,2] 和 forward 的 1≤T≤2 guard。Crop 不是 causal mask:mask 限制当前窗口内 query 能看哪些 key;crop 直接让更早 tokens 不进入计算,因此模型无法“暗中记得”我@history position 0。
Knowledge check
Full history 是 [0,1,2] 且 block_size=2 时,下一次 forward 收到哪些 IDs?
20. 常见 Bug Checklist:按症状检查 Phase 边界
直白地说,先确认你正在做哪一个 phase,再检查该 phase 允许读取和改变什么。以下表应在“加层、换 optimizer、增数据”之前使用;每一行都指向一个最小观察点。
Scroll horizontally to view all columns.
| symptom | 最可能的 boundary mistake | first direct check |
|---|---|---|
| one-batch loss 完全不降 | targets 错位、parameters 未注册/未进 optimizer、缺 backward/step、LR 不合适 | 打印六个 pairs;检查 non-None grads 与 completed_updates |
| loss/gradients 变成 NaN 或 Inf | LR/update 过大、输入或中间值先非有限 | 定位 first non-finite value;记录 pre-clip grad norm |
| 期待 loss→0 却停在约 0.231 | 忽略两个 [我] contexts 的 conflicting labels | 核对 P(喜欢|我) 与 P(学习|我) 是否趋近 0.5/0.5 |
| accumulation 像单 microbatch | window 内 zero_grad 或过早 step | 数每个 step 前有几次 backward |
| earlier logits 随 future token 改变 | causal mask/slice/axis 泄漏答案 | 固定 prefix,只改变右侧 token,比较 t=0 logits |
| validation 占 memory 或随机波动 | 缺 no_grad、缺 eval,或 split/样本太小 | 同时使用两者并恢复 mode;检查 held-out 数据量 |
| validation 值受 batch packing 改变 | 平均了 batch means | 累加 reduction=sum 与 valid target count |
| checkpoint load 后乱码/异常 | tokenizer/config/tie policy/member keys 或 AdamW group/order 不匹配 | 先核 identity,再在 optimizer load 前核 canonical IDs/state shapes |
| device error | model、inputs、targets 不共置 | 打印各 tensor/parameter device;用 map_location 和 .to(device) |
| generation 超过两 tokens 失败 | 忘记 crop forward context | assert context.size(1)≤block_size |
| next value shape/type 不对 | 使用了所有 positions 或把 probability vector 当 ID | assert last [B,5];next_id long [B,1] |
| τ/top-k 产生 invalid distribution | τ≤0/过小、low-precision overflow、k 越界或 filter 顺序错误 | promote、row-center、核 scaled/probabilities finite,再 Softmax/sample |
assert inputs.shape == (3, 2)
assert targets.shape == (3, 2)
logits, loss = model(inputs.to(device), targets.to(device))
assert logits.shape == (3, 2, 5)
assert loss is not None and loss.ndim == 0
last_logits = logits[:, -1, :]
assert last_logits.shape == (3, 5)
# Shape assertions locate axes; they do not prove targets or causality.- training contract:inputs [3,2] + targets [3,2]
- MiniGPT representations [3,2,4] → logits [3,2,5]
- reshape [6,5] + [6] → mean loss [] → backward → step
- generation contract:context [B,T_context] → logits [B,T_context,5]
- select last [B,5] → safe center/τ/top-k/Softmax → next_id [B,1]
一个 successful sample 不能证明 model quality;一个 passing shape assertion 也不能证明 target semantics、causality 或 held-out integrity。Diagnosis 要沿 data→forward→loss→gradient→update 或 prompt→crop→last→sample→append 顺序观察实际 state。
Knowledge check
Generation 收到 [B,T,V] logits 后,第一项必要 indexing 是什么?
21. Week 11 最应该理解的 7 件事
直白地说,下面七条是阅读任何 small language-model run 的最短路线,而不是孤立术语:先认 data signal,再认 state change,随后认 measurement、persistence 与 target-free generation;实际排错时也按这个顺序定位边界。
- 固定 corpus 的 inputs/targets 都是 [3,2];一次 forward 产生 logits [3,2,5],reshape 为 [6,5]+[6] 后评分全部六个 next-token signals。
- Uniform five-way prediction 的 mean NLL reference 是 ln(5)≈1.609;random first loss 可在其附近,而它绝不是训练完成标准。
- backward() 把 gradients 累加到 parameter.grad,zero_grad() 定义 accumulation window;只有 optimizer.step() 改 parameter values 与 AdamW moments/counters。
- Step 是一次 parameter update,epoch 是走完 training batches 一遍;accumulation 可让多次 forward/backward 只产生一次 completed update。
- Held-out validation 必须同时用 eval() 与 no_grad()、恢复先前 mode,并按 reduction=sum / valid target count 做 token-weighted 聚合;same-corpus score 不是 validation。
- 两个相同 [我] causal contexts 分别标为 喜欢 与 学习,所以 empirical optimum 是 0.5/0.5,batch mean NLL 只会趋近而不会以 finite weights 达到 ln(2)/3≈0.231;检查 [我,喜欢] 与 [猫,喜欢] 等可区分 contexts 是否能分化。
- 兼容 checkpoint 复用 Week 10 exact schema/tokenizer SHA-256/config/untied state keys,并校验 AdamW 对 canonical parameters 的 group/object/order 绑定;inference 则 target-free 地 crop context、取 logits[:,-1,:]、promote/center 后按 τ 和 optional top-k 处理、检查 Softmax probabilities,再 multinomial 得 [B,1] 并 append full history。
Scroll horizontally to view all columns.
| training trace | generation trace |
|---|---|
| inputs [3,2] | history [1,2] = [我,喜欢] |
| representations [3,2,4] | cropped context [1,2] |
| logits [3,2,5] | logits [1,2,5] → last [1,5] |
| loss [] → gradients → updated θ | sample next_id [1,1] → history [1,3] |
实际用途是把每个 symptom 放回所属 boundary:没有下降先查 supervised path;validation 异常先查 measurement;load 异常先查 identity;generation 异常先查 crop、last-position 与 sampling transforms。
Knowledge check
用一句话区分 zero_grad() 与 optimizer.step()。
5. 工程选读:多个小 Batch 怎样合成一次更新
第一遍先使用上一节 train_mini_gpt_step,accumulation_steps=1,确认一次 forward/backward 对应一次更新。下面的 epoch helper 多了“先检查一组等大小小批次,再合成一次更新”的工程边界;这是第二遍扩展,不需要在第一次看到循环时同时掌握无限数据流、变长 batch 和梯度累积。
直白地说,backward 会把新 gradient contribution 加进现有 .grad;这既支持一个 parameter 在 graph 中多次贡献,也允许若干 microbatches 共同形成一个 effective batch。只有当 loss scaling、zeroing 与 step boundary 全部配套时,这种累加才是故意的。
def train_mini_gpt_epoch(
model: MiniGPT,
optimizer: torch.optim.AdamW,
train_batches,
device: torch.device,
accumulation_steps: int = 1,
completed_updates: int = 0,
) -> tuple[int, float]:
if type(accumulation_steps) is not int or accumulation_steps < 1:
raise ValueError("accumulation_steps must be a positive integer")
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
# Validate all batches before touching model mode, gradients, or optimizer.
batches = list(train_batches)
if not batches:
raise ValueError("train_batches must not be empty")
if len(batches) % accumulation_steps != 0:
raise ValueError(
"train_batches must form complete accumulation windows"
)
reference_shape = None
reference_target_count = None
for batch_number, batch in enumerate(batches, start=1):
if not isinstance(batch, (tuple, list)) or len(batch) != 2:
raise TypeError(f"batch {batch_number} must be (inputs, targets)")
inputs, targets = batch
if not isinstance(inputs, torch.Tensor) or not isinstance(
targets,
torch.Tensor,
):
raise TypeError(f"batch {batch_number} values must be tensors")
if inputs.ndim != 2 or targets.shape != inputs.shape:
raise ValueError(
f"batch {batch_number} inputs/targets must share [B,T]"
)
if inputs.dtype != torch.long or targets.dtype != torch.long:
raise TypeError(
f"batch {batch_number} inputs/targets must be torch.long"
)
target_count = targets.numel()
if target_count <= 0:
raise ValueError(f"batch {batch_number} has no target tokens")
if not 1 <= inputs.size(1) <= model.config.block_size:
raise ValueError(f"batch {batch_number} has invalid T")
for name, token_ids in (("inputs", inputs), ("targets", targets)):
if (
int(token_ids.min().item()) < 0
or int(token_ids.max().item()) >= model.config.vocab_size
):
raise ValueError(
f"batch {batch_number} {name} IDs are outside vocabulary"
)
if reference_shape is None:
reference_shape = inputs.shape
reference_target_count = target_count
elif (
inputs.shape != reference_shape
or target_count != reference_target_count
):
raise ValueError(
"this teaching helper requires equal-token microbatches"
)
model.train()
optimizer.zero_grad(set_to_none=True)
detached_loss_sum = 0.0
for window_start in range(0, len(batches), accumulation_steps):
window = batches[
window_start : window_start + accumulation_steps
]
for inputs, targets in window:
inputs = inputs.to(device)
targets = targets.to(device)
logits, loss = model(inputs, targets)
assert logits.shape[-1] == model.config.vocab_size
assert loss is not None
(loss / accumulation_steps).backward()
detached_loss_sum += loss.detach().item()
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
optimizer.step()
completed_updates += 1 # only after optimizer.step succeeds
optimizer.zero_grad(set_to_none=True)
mean_microbatch_loss = detached_loss_sum / len(batches)
return completed_updates, mean_microbatch_loss这个教学 helper 明确只支持 equal-token microbatches。它先 materialize 全部输入,并在 model.train() 或 zero_grad() 之前验证:非空、完整 accumulation windows、每项确实是 input/target tensors、两者 shape 相同且所有 batches 共享同一 [B,T] shape、dtype 都是 torch.long、target count 相同且大于零、T 与 IDs 合法。于是 loss/accumulation_steps 的 gradient 与 mean_microbatch_loss 都恰好是按 tokens 等权的结果。
空 iterator 会得到清晰错误;7 个 microbatches 配 accumulation_steps=4,或混入较短/较小 batch,也会在任何 model/mode/gradient/optimizer mutation 前拒绝,不会残留 partial gradients。大型、无限或 variable-token stream 不适合这个教学 helper;production code 应按 window 的 token-loss sum 除以 valid-token 总数,或使用已知长度且等大小的 sampler/drop_last。
Scroll horizontally to view all columns.
| window 时刻 | .grad | parameters / AdamW state |
|---|---|---|
| zero_grad 后 | None | 不变 |
| 第 1–3 次 backward 后 | 逐次累加 partial window | 不变 |
| 第 4 次 backward + clip 后 | 完整且可能已缩放 | 仍不变 |
| optimizer.step 后 | 仍存在,直到下一行清除 | 两者更新一次 |
| step 成功返回后 | 同一完整 window 已消费 | completed_updates 才加 1 |
| 随后 zero_grad 后 | None,下一窗口干净开始 | 保留刚更新的长期 state |
Knowledge check
为什么 accumulation loop 在 optimizer.step() 后才 zero_grad?
12. 工程选读:继续训练需要保存哪些状态
直白地说,checkpoint 是一份 compatibility contract,而不是“任意 tensors 的袋子”。mini-gpt-v1 按空格切分,ordered tokens 固定为 [我, 喜欢, AI, 学习, 猫],IDs 固定为 0..4,没有 special、padding 或 unknown token;canonical GPTConfig 是 vocab_size=5、block_size=2、n_embd=4、n_head=2、n_layer=2,token embedding 与 LM head 保持 untied。 Week 10 的 stable members(token_embedding、position_embedding、blocks[i].ln1/attention/ln2/feed_forward、final_norm、lm_head)及 _init_weights 原样保留;Week 11 只 import 并驱动它们。
Scroll horizontally to view all columns.
| checkpoint key | exact meaning / value |
|---|---|
| schema | {name: mini-gpt-training-checkpoint, version: 1} |
| tokenizer | version + ordered_tokens + policy + sha256 |
| config | vocab_size=5, block_size=2, n_embd=4, n_head=2, n_layer=2 |
| weight_policy | token_embedding_lm_head=untied |
| model_state | Week 10 exact state-dict names and tensors |
| optimizer | exact AdamW class + one canonical ordered model-parameter group + state_dict |
| completed_updates | 已完成 optimizer.step() 的非负整数次数 |
Scroll horizontally to view all columns.
| canonical tokenizer identity | exact value |
|---|---|
| compact sorted-key UTF-8 JSON | {"ordered_tokens":["我","喜欢","AI","学习","猫"],"policy":"whitespace-delimited;no-specials;no-pad;no-unk","version":"mini-gpt-v1"} |
| SHA-256 | 38d630f4c589664c9bef567457d48764cbe2307734777e80f7d5d5c63ac88dd6 |
def validate_mini_gpt_adamw_model_binding(
model: MiniGPT,
optimizer: torch.optim.AdamW,
) -> list[torch.nn.Parameter]:
if type(model) is not MiniGPT:
raise TypeError("faithful training requires exactly MiniGPT")
if type(optimizer) is not torch.optim.AdamW:
raise TypeError("faithful training requires exactly torch.optim.AdamW")
model_parameters = list(model.parameters())
if not model_parameters:
raise ValueError("MiniGPT must have parameters")
if len(optimizer.param_groups) != 1:
raise ValueError("AdamW must have exactly one canonical param group")
live_group = optimizer.param_groups[0]
live_parameters = live_group.get("params")
if not isinstance(live_parameters, list):
raise ValueError("AdamW live params must be a list")
if len(live_parameters) != len(model_parameters):
raise ValueError("AdamW must own every MiniGPT parameter exactly once")
if any(
actual is not expected
for actual, expected in zip(live_parameters, model_parameters)
):
raise ValueError("AdamW parameters must match MiniGPT identity and order")
return model_parameters
def validate_mini_gpt_adamw_state_dict(
model: MiniGPT,
optimizer_state: object,
completed_updates: int,
) -> None:
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
if not isinstance(optimizer_state, dict):
raise ValueError("AdamW state_dict must be a dictionary")
if set(optimizer_state) != {"state", "param_groups"}:
raise ValueError("AdamW state_dict keys mismatch")
state = optimizer_state["state"]
param_groups = optimizer_state["param_groups"]
if not isinstance(state, dict) or not isinstance(param_groups, list):
raise ValueError("malformed AdamW state_dict")
if len(param_groups) != 1 or not isinstance(param_groups[0], dict):
raise ValueError("serialized AdamW must have one canonical param group")
model_parameters = list(model.parameters())
expected_ids = list(range(len(model_parameters)))
stored_ids = param_groups[0].get("params")
if not isinstance(stored_ids, list) or not all(
type(parameter_id) is int for parameter_id in stored_ids
):
raise ValueError("serialized AdamW parameter IDs must be integers")
if stored_ids != expected_ids:
raise ValueError("serialized AdamW parameter IDs/order are not canonical")
# AdamW creates per-parameter state lazily on its first successful step.
if completed_updates == 0:
if state:
raise ValueError("zero completed updates require empty AdamW state")
return
if not all(type(parameter_id) is int for parameter_id in state):
raise ValueError("AdamW state keys must be integer parameter IDs")
if set(state) != set(expected_ids):
raise ValueError("nonzero progress requires state for every parameter")
amsgrad = param_groups[0].get("amsgrad")
if type(amsgrad) is not bool:
raise ValueError("AdamW amsgrad metadata must be boolean")
expected_state_keys = {"step", "exp_avg", "exp_avg_sq"}
if amsgrad:
expected_state_keys.add("max_exp_avg_sq")
for parameter_id, parameter in enumerate(model_parameters):
parameter_state = state[parameter_id]
if not isinstance(parameter_state, dict):
raise ValueError("each AdamW parameter state must be a dictionary")
if set(parameter_state) != expected_state_keys:
raise ValueError("AdamW per-parameter state keys mismatch")
raw_step = parameter_state["step"]
if torch.is_tensor(raw_step):
if raw_step.numel() != 1:
raise ValueError("AdamW step must be scalar")
raw_step = raw_step.detach().cpu().item()
if isinstance(raw_step, bool) or not isinstance(raw_step, (int, float)):
raise ValueError("AdamW step must be a finite integer")
numeric_step = float(raw_step)
if not math.isfinite(numeric_step) or not numeric_step.is_integer():
raise ValueError("AdamW step must be a finite integer")
if int(numeric_step) != completed_updates:
raise ValueError("completed_updates disagrees with AdamW step state")
moment_names = ["exp_avg", "exp_avg_sq"]
if amsgrad:
moment_names.append("max_exp_avg_sq")
for moment_name in moment_names:
moment = parameter_state[moment_name]
if not torch.is_tensor(moment) or moment.shape != parameter.shape:
raise ValueError(
f"AdamW {moment_name} shape mismatches parameter order"
)
def validate_mini_gpt_adamw_completed_updates(
model: MiniGPT,
optimizer: torch.optim.AdamW,
completed_updates: int,
) -> None:
validate_mini_gpt_adamw_model_binding(model, optimizer)
validate_mini_gpt_adamw_state_dict(
model,
optimizer.state_dict(),
completed_updates,
)
def train_and_save_week11_one_batch(
path: str,
*,
model: MiniGPT,
optimizer: torch.optim.AdamW,
inputs: torch.Tensor,
targets: torch.Tensor,
device: torch.device,
requested_updates: int = 200,
completed_updates: int = 0,
) -> tuple[list[float], int]:
# Check resume progress before training, then derive new progress from
# successful optimizer.step calls rather than from requested_updates.
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
history, completed_updates = overfit_mini_gpt_one_batch(
model,
optimizer,
inputs,
targets,
device,
updates=requested_updates,
completed_updates=completed_updates,
)
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
# Save through the canonical Week 10 API; do not invent new keys.
save_mini_gpt_training_checkpoint(
path,
model=model,
optimizer=optimizer,
completed_updates=completed_updates,
ordered_tokens=CANONICAL_ORDERED_TOKENS,
tokenizer_policy=CANONICAL_TOKENIZER_POLICY,
tokenizer_version=CANONICAL_TOKENIZER_VERSION,
)
return history, completed_updatesLive check 不只数 parameters:它要求 AdamW 恰有一个 group,且 group.params 与 list(model.parameters()) 等长、同序,并对每一项用 is 验证是同一个 Parameter object;因此 subset、reorder、duplicate 或 multiple groups 都会在 training/save 前被拒绝。Serialized IDs 则是 PyTorch 的 positional bookkeeping,不是 Python id(parameter):本 canonical one-group layout 必须精确为 0..n−1,且在 optimizer.load_state_dict 前就检查 group/order、state keys、moment shapes 与每个 step。
def week11_config_fields(config: GPTConfig) -> dict[str, int]:
return {
"vocab_size": config.vocab_size,
"block_size": config.block_size,
"n_embd": config.n_embd,
"n_head": config.n_head,
"n_layer": config.n_layer,
}
def load_mini_gpt_training_resume(
path: str,
*,
device: torch.device,
) -> tuple[MiniGPT, torch.optim.AdamW, int]:
checkpoint = torch.load(
path,
map_location=device,
weights_only=False,
)
if not isinstance(checkpoint, dict):
raise ValueError("checkpoint must be a dictionary")
required_keys = {
"schema",
"tokenizer",
"config",
"weight_policy",
"model_state",
"optimizer",
"completed_updates",
}
if set(checkpoint) != required_keys:
raise ValueError("training checkpoint keys mismatch")
if checkpoint["schema"] != {
"name": "mini-gpt-training-checkpoint",
"version": 1,
}:
raise ValueError("checkpoint schema/version mismatch")
validate_checkpoint_tokenizer_identity(
checkpoint,
expected_ordered_tokens=CANONICAL_ORDERED_TOKENS,
expected_tokenizer_policy=CANONICAL_TOKENIZER_POLICY,
expected_tokenizer_version=CANONICAL_TOKENIZER_VERSION,
)
expected_config = GPTConfig()
if checkpoint["config"] != week11_config_fields(expected_config):
raise ValueError("checkpoint config mismatch")
if checkpoint["weight_policy"] != {
"token_embedding_lm_head": "untied",
}:
raise ValueError("checkpoint weight policy mismatch")
completed_updates = checkpoint["completed_updates"]
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
optimizer_payload = checkpoint["optimizer"]
if not isinstance(optimizer_payload, dict):
raise ValueError("optimizer checkpoint must be a dictionary")
if set(optimizer_payload) != {"class", "state"}:
raise ValueError("optimizer checkpoint keys mismatch")
expected_optimizer_class = (
f"{torch.optim.AdamW.__module__}."
f"{torch.optim.AdamW.__qualname__}"
)
if optimizer_payload["class"] != expected_optimizer_class:
raise ValueError("optimizer class mismatch")
serialized_optimizer_state = optimizer_payload["state"]
if not isinstance(serialized_optimizer_state, dict):
raise ValueError("optimizer state must be a dictionary")
# Construct and use state only after every identity check above passes.
model = MiniGPT(expected_config).to(device)
model.load_state_dict(checkpoint["model_state"], strict=True)
if model.lm_head.weight is model.token_embedding.weight:
raise ValueError("restored model must keep canonical untied weights")
optimizer = torch.optim.AdamW(
model.parameters(),
lr=1e-3,
weight_decay=1e-2,
)
validate_mini_gpt_adamw_model_binding(model, optimizer)
validate_mini_gpt_adamw_state_dict(
model,
serialized_optimizer_state,
completed_updates,
)
optimizer.load_state_dict(serialized_optimizer_state)
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
return model, optimizer, completed_updatesdef load_week11_mini_gpt_for_inference(
path: str,
*,
device: torch.device,
) -> MiniGPT:
# Inference-only restore delegates to the frozen Week 10 loader.
inference_model = load_mini_gpt_for_inference(
path,
expected_ordered_tokens=CANONICAL_ORDERED_TOKENS,
expected_tokenizer_policy=CANONICAL_TOKENIZER_POLICY,
expected_tokenizer_version=CANONICAL_TOKENIZER_VERSION,
map_location=device,
)
inference_model.eval()
return inference_modelScroll horizontally to view all columns.
| restore goal | 必须有 | 可以省略 |
|---|---|---|
| inference only | schema/tokenizer/config/untied policy/model_state | optimizer 与 completed_updates |
| faithful optimizer resume | inference fields + exact optimizer class/state + completed_updates | 不能省 optimizer moments/history |
| bit-for-bit continuation | 还需相同 data order 及相关 CPU/CUDA/Python RNG state | 本教学 checkpoint 不宣称做到 |
map_location 决定 serialized tensors 映射到哪个 CPU/CUDA/MPS device;model、后续 inputs 与 targets 仍必须共置。Fresh optimizer 的合法起点是 completed_updates=0、一个完整 canonical param group 且 per-parameter state 为空;非零 progress 则要求同一组每个 parameter 都有匹配 shape 的 AdamW moments,且所有 step 恰好等于 completed_updates。Resume 在 load_state_dict 前验证 serialized layout,load 后再验证 live object binding 与 state。由于 frozen schema 不另存 parameter names,恶意交换两个同 shape moment payload 无法仅凭 IDs 证明来源;所以仍只 load trusted files。SHA-256 绑定 tokenizer canonical bytes、用于发现 identity mismatch 或损坏,但不是来源签名。
Knowledge check
Meaningful inference 与 faithful training resume 的 checkpoint 要求差在哪里?
22. Week 11 → Week 12:把 Lifecycle 接回完整 Pipeline
直白地说,Week 9 决定 text 怎样成为 model-bound IDs;Week 10 的 frozen MiniGPT 决定一次 forward 怎样产生 logits;Week 11 决定 labels、gradients、updates、measurement、persistence 与 sampling 何时发生。Week 12 将把同一代码和同一 state contract 放进一个完整 project trace,而不是再造另一套类或 ID space。
- raw corpus:我 喜欢 AI / 猫 喜欢 我 / 我 学习 AI
- mini-gpt-v1 IDs [B,N]=[3,3]
- shift → inputs/targets [3,2]
- token + position representations [3,2,4]
- Week 10 two-block MiniGPT → logits [3,2,5]
- reshape [6,5]+[6] → loss [] → backward → optimizer.step()
- token-weighted held-out validation and/or canonical checkpoint
- target-free prompt history → crop → last logits [B,5] → sampled ID [B,1] → append
Scroll horizontally to view all columns.
| handoff point | 之前 | 之后 |
|---|---|---|
| after optimizer step | supervised training,可改变 θ | 可验证、保存或继续 training |
| after checkpoint identity validation/load | serialized compatible state | restored model/optimizer 或 inference model |
| target-free eval/no-grad forward | prompt context IDs | 只取 final-position logits 并开始 autoregressive append |
从 supervised training 切到 autoregressive inference 的精确时刻,是 caller 对没有 targets 的 prompt 做 eval/no-grad forward,并从 logits[:, -1, :] 开始 sampling 与 append。Checkpoint 可以夹在两者之间,但它不构成学习 update。
Knowledge check
端到端 trace 在哪一步从 supervised training 切换为 autoregressive inference?