Week 5
Week 5 - Tensor 与PyTorch:把手算交给计算机
Key question怎样让计算机自动执行大规模 forward/backward?
Learning objectives
- 理解并应用 Week 5 - Tensor 与 PyTorch:把手算交给计算机
45 min estimated reading time
本周不换数据、不换损失、不换学习率,只把已经会的计算交给 PyTorch。共同基线是 Week 2 四样本:x=[1,2,3,4]、y=[3,5,7,9]、w=b=0、mean MSE、learning_rate=0.01。
先记住可核对的一步:Loss=41,dw=−35、db=−12;更新后 w=0.35、b=0.12,再 forward 的 Loss=28.45315。框架如果给出别的数,先检查初值、shape、平均方式与数据,而不是认为自动求导使用另一种数学。
Scroll horizontally to view all columns.
| 分次安排 | 把什么交给 PyTorch |
|---|---|
| 一:Tensor 与接口 | 明确各轴、dtype、device;区分逐元素乘法与矩阵乘法。 |
| 二:Autograd | 对照手算,看 requires_grad、graph、.grad 各保存什么。 |
| 三:完整一步 | 清梯度、forward、backward、step;用状态表核对。 |
| 四:Module 与诊断 | 运行 course_examples/week05_three_ways.py,制造并解释一次错误广播。 |
CPU 足够。安装方式见下载包 README。完成后你应能指出哪一行改变参数,并解释为什么 eval() 和 no_grad() 不是同一个开关。下一周输入变成 token ID,但这套训练步骤不变。
Week 5 核心目标
Tensor → 保存和批量计算数字
Autograd → 自动执行Chain Rule
nn.Module → 组织Parameters 和Forward
Optimizer → 更新Parameters
本周重点只有四个:Tensor、Shape、Autograd、Training Loop。
1. Tensor 到底是什么
Tensor 可以先理解为:
带有shape、dtype、device 等信息的多维数字容器。
这个定义里的每一部分都有实际作用:
- 数字内容告诉operation 要计算什么;
- shape 告诉operation 数据怎样组织;
- dtype 决定每个数字怎样存储和可以参加哪些运算;
- device 决定运算在CPU、GPU 或其他加速器上执行;
- autograd metadata 记录它是否需要gradient 以及怎样回到前面的operations。
import torch
scalar = torch.tensor(3.0)
vector = torch.tensor([1.0, 2.0, 3.0])
matrix = torch.tensor([
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
])对应shape:
scalar: []
vector: [3]
matrix: [2, 3]
Tensor 不是一种新数学。它把Week 1 的scalar、vector 和matrix 放进一个统一接口。
为什么Python List 不够
Python list 可以保存数字,却不是为大规模数值计算设计的。对百万个元素逐个执行Python loop,会产生大量解释器开销;list 也没有统一的shape、device 和automatic differentiation 语义。
Tensor 把同类数字排列成规则内存,并把整个operation 交给经过优化的底层kernel:
Python 发出一次matrix multiplication 请求
底层并行计算大量乘加操作
Autograd 同时记录这次依赖关系
因此Tensor 是数学、硬件加速和Backpropagation 的共同数据载体。
2. Dimension 与Shape
假设一个batch 有四套房子,每套有三个features:
x = torch.tensor([
[0.8, 0.6, 0.2],
[0.5, 0.4, 0.9],
[0.9, 0.7, 0.1],
[0.6, 0.8, 0.4],
])
print(x.shape) # torch.Size([4, 3])dimension 0 = batch
dimension 1 = features
后面处理文本时常见:
[B, T, C]
- B:Batch size;
- T:Sequence length;
- C:Channel / Embedding dimension。
读PyTorch 代码时,先追shape,再追公式。
Axis 的语义不能靠猜
[B,T,C] 里的三个字母不是PyTorch 强制规定的,只是项目中的约定。同一个[4,8,32] tensor,如果某段代码认为axis 1 是token,而另一段认为axis 1 是channel,运算可能照样运行却产生错误含义。
建议在复杂代码旁写shape trace:
# x: [batch, sequence, embedding]
x = token_embedding(token_ids)它相当于给动态tensor 增加可阅读的type annotation。
3. Dtype 与Device
Dtype
x = torch.tensor([1.0, 2.0], dtype=torch.float32)
token_ids = torch.tensor([3, 8, 2], dtype=torch.long)神经网络计算通常使用浮点数;Embedding lookup 的token IDs 通常使用整数long。
为什么token ID 要是整数?因为它用于选择Embedding Matrix 的某一行,本质上是index。为什么weights 通常是浮点数?因为训练需要连续地做小幅parameter update,整数无法表达这些细微变化。
Device
device = "cuda" if torch.cuda.is_available() else "cpu"
x = x.to(device)参与同一个operation 的tensors 必须在兼容的device 上。常见错误:model 在GPU,input 还在CPU。
把tensor 移到GPU 不是给它加一个标签,而是把真实数据复制到另一块内存。频繁在CPU 和GPU 之间搬运会有成本,所以常见做法是把model、inputs、targets 长时间放在同一device,只把最终统计值移回CPU。
Scroll horizontally to view all columns.
| 代码读法 | 普通数字/含义 |
|---|---|
| 1e-3 | 0.001;e 在科学计数法里表示乘 10 的幂 |
| 1e-8 | 0.00000001 |
| float32 | 每个元素用 32 位浮点格式近似存储 |
| torch.long | 本课用于整数类别 ID,不是“更精确的小数” |
| NaN / Inf | 不是可用的有限数:可能来自非法运算或溢出 |
这里的 1e-3 与 math.exp(-3) 完全不同:前者是 10⁻³,后者是自然指数 e⁻³。检查计算结果时用允许小误差的 assert_close;不要要求不同计算顺序的浮点结果逐位相同。
4. 用Tensor 完成一个Layer
公式:
假设:
X: [4, 3] 4 个examples,每个3 个features
W: [3, 2] 3 个inputs 连接到2 个neurons
b: [2]
每个neuron 一个bias
输出:
Z: [4, 2]
代码:
X = torch.tensor([
[0.8, 0.6, 0.2],
[0.5, 0.4, 0.9],
[0.9, 0.7, 0.1],
[0.6, 0.8, 0.4],
])
W = torch.tensor([
[0.7, 0.2],
[0.5, -0.1],
[-0.4, 0.8],
])
b = torch.tensor([0.1, -0.2])
Z = X @ W + b
print(Z.shape) # torch.Size([4, 2])@ 是matrix multiplication。
一行代码实际完成了多少计算
X @ W 中的四个examples 会同时进入两个neurons,因此一共执行4 × 2 = 8 个dot products。Bias 再被加到每一行。代码虽然只有一行,背后已经批量完成了整个layer 的forward。
这正是Tensor abstraction 的价值:业务代码描述高层数据变换,底层kernel 负责安排大量相同的小运算。
5. Broadcasting:为什么[2] 能加到[4,2]
Z 的shape 是[4,2],b 的shape 是[2]。
PyTorch 会把同一个bias vector 应用到batch 中每一行:
[4, 2]
+ [2]
-------
[4, 2]
这叫Broadcasting。
现在只要记住:PyTorch 会尝试从最后一个dimension 开始对齐;维度相同或其中一个为1 时通常可以广播。
Broadcasting 为什么存在
Broadcasting 的危险
能广播不等于语义正确。例如[B,T,C] + [T,1] 可能运行成功,但你原本也许想加的是[C] bias。遇到意外结果时,先把两个shapes 从右往左对齐,并逐维说明“这一维为什么应该复用”。
Knowledge check
prediction:[4,1],target:[4],相减结果为什么不是你想要的四个误差?
6. 用Tensor 重写Week 2
现在回到 Week 2 第 17 节的四个样本:x=[1,2,3,4]、y=[3,5,7,9],w=b=0,MSE 取平均,η=0.01。不改变这些条件,就应得到同样的第一步:loss=41,dw=−35,db=−12,更新后 w=0.35、b=0.12。Tensor 只是把四项同时算,不是另一种学习规律。
数据:
x = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])真实关系是:
初始化parameters:
w = torch.tensor([[0.0]], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)Forward:
prediction = x @ w + b
loss = ((prediction - y) ** 2).mean()数学仍然是:
7. requires_grad=True
w = torch.tensor([[0.0]], requires_grad=True)意思是:
请追踪所有依赖w 的operations,以便以后计算Loss 对w 的gradient。
Forward 时,PyTorch 动态建立Computational Graph。
w, b
↓
prediction
↓
error
↓
square
↓
mean
↓
loss
哪些Tensor 应该追踪Gradient
通常只有需要学习的浮点parameters 设置requires_grad=True。Inputs 和targets 默认不需要被optimizer更新,因此不必追踪它们的gradient。
requires_grad 不是“这个tensor 已经有gradient”,而是“如果后面的scalar loss 依赖它,请记录足够信息,以便未来计算gradient”。真正的数值要等backward() 后才会出现在.grad。
Leaf Tensor 与中间Tensor
直接创建并作为parameter 使用的w、b 通常是leaf tensors。prediction、error、loss 是operations 产生的中间tensors,并带有grad_fn 指向它们怎样被计算出来。
Optimizer 主要持有leaf parameters;中间tensors 用于连接graph,不是长期保存的模型状态。
8. .backward()
loss.backward()这行代码从scalar loss 开始执行Week 4 的反向传播。
结果保存在:
print(w.grad)
print(b.grad)它们分别对应:
PyTorch 并没有“猜” gradient。它按照记录下来的graph,调用每个operation 的局部导数,再使用Chain Rule。
为什么通常对Scalar Loss 调用Backward
loss.backward() 隐含的起点是:
如果对一个多元素tensor 直接backward,就必须额外指定每个元素怎样被加权,因为“一个vector 对parameters 的gradient”没有唯一方向。把batch errors 求mean 得到scalar loss,既定义了统一目标,也提供了清晰的backward 起点。
.grad 表示什么
w.grad 不是新weight,也不是本次应该直接加多少。它是当前位置:
Optimizer 还会结合learning rate、momentum 等规则决定真正update。
loss.backward() 不传额外参数时,通常要求 loss 只有一个元素。若你保留了每个样本的 loss 向量,可以先 .mean() 或 .sum() 明确你要优化什么,再 backward。向量本身也能求导,但必须指定各输出如何贡献反向信号;不是“只有标量才能求导”。
9. 为什么要清空Gradient
PyTorch 默认会累加gradients:
new grad = old grad + current grad
这对某些算法有用,但标准training loop 每一步通常要先清零:
w.grad.zero_()
b.grad.zero_()使用optimizer 时:
optimizer.zero_grad()如果忘记,下一步update 会混入前面batches 的gradients。
为什么默认设计成累加?因为同一个parameter 可能通过多条路径被使用,或用户希望用多个小batches模拟一个大batch。Autograd 无法假设你何时想结束累加,所以把清零时机交给training loop。
标准顺序通常是先zero_grad() 再forward。只要每个step 恰好清一次,放在上一步末尾也能工作;放在开头更容易读出“本step 从干净gradient 开始”。
Scroll horizontally to view all columns.
| 两种相加 | 为什么发生 | 如何处理 |
|---|---|---|
| 同一次计算图中多个分支 | 同一参数确实影响多个位置 | 反向传播必须汇总,不能删掉分支 |
| 前后两次 backward 调用 | .grad 默认继续累加 | 普通独立 step 先 zero_grad;有意积累则另定窗口 |
在第一次 backward 前,parameter.grad 通常是 None,不能无条件调用 parameter.grad.zero_()。使用 optimizer.zero_grad(set_to_none=True) 可以清理已有梯度;None 表示当前没有存储梯度,与数值全零是不同状态。Week 11 再讲有意的梯度累积。
10. 手动更新Parameter
更新时不应该把“更新操作本身”继续记录到下一张graph:
with torch.no_grad():
w -= learning_rate * w.grad
b -= learning_rate * b.grad公式仍然是:
这里的w 是requires_grad=True 的leaf tensor;在torch.no_grad() 之外执行原地更新w -= ... 会被autograd 拒绝并抛出RuntimeError。若改写成非原地的w = w - ...,新w 才可能携带历史并把迭代连接起来(同时它不再是leaf)。参数更新应放在torch.no_grad() 中,或交给optimizer.step()。
11. 完整的Tensor Training Loop
import torch
x = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])
w = torch.tensor([[0.0]], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)
learning_rate = 0.01
for step in range(1000):
# Forward
prediction = x @ w + b
loss = ((prediction - y) ** 2).mean()
# Backward
loss.backward()
# Update
with torch.no_grad():
w -= learning_rate * w.grad
b -= learning_rate * b.grad
# Reset gradients
w.grad.zero_()
b.grad.zero_()
print(w.item()) # approximately 2
print(b.item()) # approximately 1这就是Week 2 和Week 4 的代码化版本。
12. nn.Module:组织Parameters 和Forward
真实模型不应手工管理大量parameters。
import torch.nn as nn
class LinearModel(nn.Module):
def __init__(self):
super().__init__()
self.linear = nn.Linear(1, 1)
def forward(self, x):
return self.linear(x)nn.Linear(1,1) 内部已经包含:
weight
bias
model(x) 会调用forward(x),同时保留framework 所需的hooks 和graph 行为。
nn.Module 为什么不仅是代码封装
普通class 也能写forward,但nn.Module 还维护一棵可遍历的模型对象树。把nn.Parameter 或child module 赋值给self 后,PyTorch 才能统一完成:
model.parameters() 收集可训练参数
model.to(device)
递归移动参数和buffers
model.state_dict() 保存可恢复状态
model.train()/eval() 切换子模块运行模式
因此Module registration 是训练框架能够管理模型生命周期的基础。
nn.Linear(1,1) 默认随机初始化,因此直接运行未必和手算第一步相同。若要公平对照 Week 2,先在 no_grad 中把 weight 与 bias 都设为 0。它的 weight shape=[1,1],一般规则是 [输出数,输入数];forward 对行样本做 X @ weight.T + bias。完整三版本对照见 course_examples/week05_three_ways.py。
13. Loss Function 与Optimizer
model = LinearModel()
loss_function = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)它们的职责:
model → 产生prediction
loss_function → 衡量prediction 有多差
autograd → 计算gradients
optimizer → 更新parameters
Loss function 和optimizer 被分开,是因为它们回答不同问题:Loss 定义“什么叫变好”;optimizer 决定“根据gradient 怎样走”。同一个model 可以换训练目标,同一个loss 也可以搭配不同optimizer,而Backprop 负责连接二者。
14. 标准训练循环:每一行究竟改变了什么
现在把 Tensor、自动求导、Module 和 optimizer 放回同一件事。下面程序可独立运行;Linear 默认随机初始化,所以先显式设为零,才能与 Week 2 比较。
import torch
X = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])
model = torch.nn.Linear(1, 1)
with torch.no_grad():
model.weight.zero_()
model.bias.zero_()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
optimizer.zero_grad(set_to_none=True)
prediction = model(X)
if prediction.shape != y.shape:
raise ValueError("prediction and target must describe the same pairs")
loss = torch.nn.functional.mse_loss(prediction, y, reduction="mean")
loss.backward()
print("before step", loss.item(), model.weight.grad, model.bias.grad)
optimizer.step()
with torch.no_grad():
new_loss = torch.nn.functional.mse_loss(model(X), y)
print("after step", model.weight.item(), model.bias.item(), new_loss.item())Scroll horizontally to view all columns.
| 时刻 | w / b | loss | grad(w) / grad(b) |
|---|---|---|---|
| 清梯度后 | 0 / 0 | 本轮还未计算 | None / None |
| forward 后 | 0 / 0 | 41 | None / None |
| backward 后 | 0 / 0 | 旧 forward 的 41 | −35 / −12 |
| step 后 | 0.35 / 0.12 | 旧变量仍是 41 | 仍保留 −35 / −12 |
| 重新 forward | 0.35 / 0.12 | new_loss≈28.45315 | no_grad 不会清除原有梯度 |
表中近似值来自同一固定输入的数学计算。实际浮点打印会有尾数差异。把这些步骤放进循环后,每轮第一步清除旧梯度,避免上一轮贡献混入。zero_grad 不会把学到的参数清零;optimizer.step 也不会自动把 grad 清零。
能运行不代表配对正确:prediction 的 shape 是 [4,1],若把 y 写成 [4],广播可能把它们扩成 [4,4],得到样本之间的两两比较。原本只该比较四对数据,却算了十六对。保留显式 shape 检查,比只盯着 scalar loss 更可靠。
下一周预测会变成 [B,T,Vocab],目标是 [B,T],这是分类接口有意规定的不同 shape,不沿用回归的“完全相等”检查。每种检查都要先说明轴的含义和损失函数的输入约定。
Knowledge check
EX05:先预测三种改动的后果:A 删除 step;B 第二轮不清 grad;C 只调用 eval,正常计算 loss.backward。哪些参数或梯度会改变?
15. Shape Debugging 的固定方法
当代码出现matmul、reshape 或loss 错误时,不要盲猜。
在关键位置打印:
print("x:", x.shape)
print("weight:", model.linear.weight.shape)
print("prediction:", prediction.shape)
print("target:", y.shape)逐项确认:
- 每个dimension 的业务含义;
- Matrix multiplication 的中间维度是否相等;
- Prediction 和target 是否对齐;
- Batch dimension 是否意外丢失;
- dtype 和device 是否一致。
Shape 在AI 工程里相当于接口契约和数据schema。
Scroll horizontally to view all columns.
| 看到的对象 | 先问什么 | 后面典型 shape |
|---|---|---|
| 整数输入 | 有几行,每行几个 token? | [B,T] |
| 浮点表示 | 每个 token 有几个特征? | [B,T,C] |
| 候选分数 | 每个位置有几个可选答案? | [B,T,V] |
B、T、C、V 是轴的名字,不是四种新数学。先把 [2,3,4] 读成“两行、每行三个位置、每个位置四个数”,再看后面的层改变了哪一项。
Knowledge check
从 [2,3] 的 token IDs 查一张 [5,4] 表,为什么结果是 [2,3,4]?
16. train() 与eval():知道用途即可
model.train()
model.eval()它们不是控制Autograd 开关,而是改变Dropout、BatchNorm 等layers 的运行方式。
Validation 常见写法:
model.eval()
with torch.no_grad():
prediction = model(x)后面训练GPT 时再详细使用。
三个容易混淆的开关:
model.train() → layers 使用training 行为
model.eval() → layers 使用evaluation 行为
torch.no_grad() → 不记录backward graph
eval() 不会自动关闭gradient;no_grad() 也不会自动关闭Dropout。Validation 通常同时使用eval() 和no_grad(),因为两种需求都存在。
17. Week 5 最应该理解的6 件事
- Tensor 是带shape、dtype、device 的多维数字容器。
- X @ W + b 可以并行处理整个batch。
- Shape 是最重要的debugging 信息之一。
- requires_grad=True 让PyTorch 记录计算依赖。
- loss.backward() 执行Chain Rule 并填充.grad。
- 标准training loop 是zero → forward → loss → backward → step。
18. Week 5 → Week 6
目前模型接收的是数字features。
但GPT 的input 是文字。
下一个问题:
怎样把文字变成tensor?模型怎样用这些tensors 预测下一个token?
Week 6 会建立这条路径:
Text
↓
Token IDs
↓
Embeddings
↓
Logits
↓
Cross Entropy Loss