← COMP5511 全部讲次
T03 · 第 3 周

T03 Tutorial 3:四份 PyTorch notebook,一套训练骨架

张量、autograd、nn.Module、训练循环——先把这四件事跑通,assignment 和 project 才动得了。

一句话版

这一周的 tutorial 是四份 PyTorch notebook,一行逻辑公式都没有,练的是张量、自动求导、nn.Module、训练循环这四件事——它们是后面 assignment 和 project 的动手基线。

题目地图

四份材料有重叠,按依赖关系读比按文件名读省事。只有时间序列那份以生成一个可提交的预测文件收尾,其余三份跑完即弃。

顺序材料学什么主要失分点
1张量基础创建、属性、运算、与 NumPy 互通以为 .to() 是原地操作
2手写线性回归自己写梯度下降的四步忘了清零或忘了 no_grad()
3autogradbackward() 到底在算什么对非标量调裸的 backward()
4nn.Module 与 LeNet层怎么声明、shape 怎么推16*5*5 推不出来
5图像分类完整流程数据到测试的五步在最后一层多加一个 softmax
6字符级 RNN变长序列、隐状态传递训练函数有三行被注释掉
7时间序列预测滑窗、三个模型、外推batch_first、漏逆变换

想先试水从手写线性回归那份开始最省事:它的数据直接写死在代码里,不依赖任何外部文件。

概念卡

1. 五步训练骨架(Training Loop)

人话定义:所有训练循环都是同一副骨架——清梯度、前向、算损失、反向、更新,换模型只换中间那一段。

例子:写成代码就是 optimizer.zero_grad()output = net(x)loss = criterion(output, y)loss.backward()optimizer.step()。手写版把最后一步拆开成 w -= w.grad * lr 并手动 w.grad.zero_(),两处细节必须懂:更新参数本身也是张量运算,对需要梯度的叶子参数直接原地更新、却不包在 torch.no_grad() 里,通常会立即报错;而 .grad 是累加的,不清零第二个 batch 的梯度会叠在第一个上,越滚越大。

常见误解

以为 optimizer.step() 会自己算梯度 → 它只做一件事:按 .grad 里已有的值更新参数。没有新梯度时不应调用 step();若 .grad 留着旧值,仍可能错误更新。梯度不是 backward() 的返回值,是它写进每个参数 .grad 属性的副作用。

2. shape 推导(Output Shape)

人话定义:给定网络结构问某层输出形状,是考卷上的高频题型,靠两条公式就能一路推到底。

例子:无 padding、stride 为 1 时输出边长 = 输入边长 − 核边长 + 1,池化边长除以 2。LeNet 输入 1×32×32:conv1 的 5×5 核把 32 推到 28,池化到 14;conv2 再推到 10,池化到 5;于是 flatten 之后是 16 × 5 × 5 = 400,正好对上 nn.Linear(16*5*5, 120) 的输入维。当 dilation=1 时,带 padding 和 stride 的公式是 ⌊(n + 2p − k)/s⌋ + 1。

常见误解

nn.Linear 的 weight 形状记成构造参数的顺序 → Linear(120, 84) 的 weight 是 (84, 120),也就是 (out, in),和写法相反。手写线性回归里 X @ w.t() + b 的那个 .t() 就是在补这个转置。另外本例的参数张量数是有参数层数 × 2(每层启用 weight 和 bias);标量参数总数另算,池化层没有可学习参数,不计入。

3. 最后一层与损失函数的配对(Loss Matching)

人话定义:网络最后一层输出什么,就决定了该配哪个损失函数,配错了不报错但会学不动。

例子:图像分类那份的 forward 末尾是裸的 self.fc3(x),没有 softmax——这是对的,因为 nn.CrossEntropyLoss 内部已经包含 LogSoftmax + NLLLoss,再手动加一层等于做了两次,会把梯度压扁。字符级 RNN 那份走的是另一条路:网络里写了 nn.LogSoftmax,损失就用 NLLLoss。两条路数学上等价。补充:LogSoftmax 后再接交叉熵通常只是冗余;真正要避免的是把 Softmax 概率当成 logits 再输入交叉熵。

常见误解

看到分类网络输出的一串数就当成概率 → 未经 softmax 的 logits 既不在 [0,1] 也不和为 1,比较大小完全有效,当概率读就错了。至于 LogSoftmax 的输出是对数概率,负值正常,越接近 0 表示概率越大:−0.47 对应 p ≈ 0.63。回归任务则配 MSELoss

4. 归一化与可用学习率(Normalization)

人话定义:输入的数值量级直接决定学习率能开多大,归一化是把这个上限抬高的手段。

例子:手写线性回归那份的输入量级是 70–140,没做任何缩放,梯度里带着这个量级,学习率稍微大一点 loss 就变 nan,所以材料选用 1e-6,还要跑 300 个 epoch。时间序列那份先用 MinMaxScaler 压到 [0, 1],学习率用 1e-3。两例模型与数据不同,不能把千倍差异全归因于缩放。若报告原单位 MSE,预测值和真值两边都要逆变换;归一化 MSE 也可在同一缩放下比较模型,但需明确尺度。

常见误解

把 scaler 在训练集和验证集上一起 fit 当成无条件正确 → 这等于让预处理环节看到了验证集的分布,属于信息泄漏。用于评估泛化时,应只在训练集上 fit,再用同一个 scaler 去 transform 验证集。

逐题拆解

张量那一段最值钱的是内存共享。CPU 上的张量和 ndarray 用 numpy()from_numpy() 互转之后共享同一块内存,改一边动另一边——你以为拿到了副本,其实是同一块内存的两个视图,要真副本得 clone()。搬到 GPU 之后才是真拷贝。另外两条高频坑:.to(device) 返回新张量,写成光秃秃的 tensor.to('cuda') 不报错但张量根本没动;cat 在已有维度上接长(三份 4×4 在 dim=1 上得到 4×12),stack 新开一个维度(同样三份得到 4×3×4)。

autograd 那一段有一个能手算验证的例子,值得自己重做。Q = 3a³ − b²a = [2, 3]b = [6, 4],那么 ∂Q/∂a = 9a² = [36, 81],∂Q/∂b = −2b = [−12, −8],材料下一个 cell 正是用这两条恒等式自校验的。这里 backward() 必须传 gradient= 参数,因为 Q 是长度 2 的向量:backward() 算的是向量-雅可比乘积,需要一个同形状的权重向量。平时不用传,是因为 loss 是标量、默认权重就是 1.0。看到 “grad can be implicitly created only for scalar outputs” 这个报错,就是对非标量调了裸的 backward()

字符级 RNN 那一段给出了「什么是循环神经网络」最直接的答案:把当前输入和上一步的隐状态拼起来,过一个线性层得到新隐状态,再映射到输出。「循环」不在这个类里,在调用它的 for 循环里——同一组权重反复作用于序列的每一步。形状对账:输入 (1, 57)、隐状态 (1, 128),拼接得 (1, 185),所以 i2h 的输入维必须是 57 + 128。字母表 57 = 52 个英文字母 + 5 个标点,每个字符编成 57 维 one-hot,中间那个长度 1 的维度是 batch 维。

时间序列那一段是前面所有东西的合流。骨架七步:归一化 → 滑窗 → Dataset/DataLoader → 模型 → 训练 → 逆变换评估 → 外推。滑窗是核心——用过去 8 个时刻预测第 9 个,长度 N 的序列切出 N − 8 个样本,X 形状 (N−8, 8, 1)。自定义 Dataset 只需要写 __len____getitem__ 两个方法,这是 project 里处理自己数据时唯一要写的接口。三个模型共用同一个接口,都靠 out[:, -1, :] 取最后时刻的隐状态做单步预测。

课件里的坑

  • [课件有误] torch.randn 应为标准正态分布,不是说明文字中的 uniform。注意:Uniform(−√3, √3) 也有均值 0、标准差 1,因此这三个条件本身不矛盾,错误在函数对应的分布名称。
  • [课件有误] 字符级 RNN 那份的 train() 函数里,参数更新和 return 三行全被注释掉了。于是它算了梯度却从不更新参数,还返回 None,下一个 cell 对 None 解包直接抛 TypeError。照原样跑必崩,这不是环境问题;解开那三行即可,这大概是留给你补的练习。
  • [课件有误] 时间序列那份的说明文字写输出 submission.csv、列名 Predict Target,代码实际写出的是 future_forecast.csv、列名 Predicted Target(多了个 ed);读数据的列名同样对不上。提交前先 print(df.columns) 看真实列名,文件名和表头以课程给的样例文件为准。
  • [代码对齐问题] 时间序列 cell 18 从 X_val[-1] 外推,第一步仍对应最后一个已知目标。补充修正:从 val_scaled[-seq_len:](最新 8 个已知值)起步。例:已知 t0…t9,原窗口 t1…t8 预测的是 t9;未来 t10 应用 t2…t9。日期需从最后已知日期续排,原代码固定从 W02 开始也不可靠。
  • [课件留白] 那份的 TransformerModel 没有位置编码。无位置编码且无顺序掩码的自注意力是置换等变(permutation equivariant),不是不变。输出会随输入重排;取最后一项能区分末项,但不能完整表达其余历史的先后。想用它,补位置编码是第一个必须做的改动。
  • [补充] 字符级 RNN 的隐状态没过激活函数,而教科书公式是 h_t = tanh(W·[x_t, h_{t−1}])。两层线性叠加在数学上仍是线性的,所以它的表达能力比标准 RNN 弱。要改进,先在 hidden 后面加一个 torch.tanh()
  • [补充] 累加损失一律写 loss.item()loss 是挂在计算图上的张量,直接累加会把整个图留在内存里不释放,是经典的显存泄漏写法。同理,推理时 no_grad()model.eval() 是两件事,前者管要不要记梯度,后者管 dropout 和 BatchNorm 用哪套行为,两个都要调。

课后 10 分钟:考点复习

这 10 分钟怎么用:合上页面,先默写三条——训练五步的顺序、卷积输出边长的公式、CrossEntropyLoss 该配什么样的最后一层;再把下面的「变式题」算一遍;最后回查两处最容易错的地方——忘了 zero_grad()、漏了 batch_first=True。三步做完再往下看答案。

必背

  1. 训练循环的五步顺序是 zero_grad → 前向 → 算损失 → backward → step,四份 notebook 的训练函数全是它的变体。
  2. 梯度是累加进 .grad 的不是覆盖的,不清零第二个 batch 的梯度会叠在第一个上;手写原地参数更新用 torch.no_grad();普通 optimizer.step() 已处理这一点。
  3. 无 padding、stride 为 1 时卷积输出边长等于输入边长减核边长加一,本例 2×2、stride=2 的池化边长减半;LeNet 里 16×5×5 = 400 要能自己推出来。
  4. nn.Linear 的 weight 形状是 (out_features, in_features),与构造函数的参数顺序相反,手写线性回归写 w.t() 就是为了补这个转置。
  5. CrossEntropyLoss 内部自带 LogSoftmax,网络最后一层应输出裸 logits;网络里已经有 LogSoftmax 就配 NLLLoss,推荐这两种配对,别把概率误当 logits。
  6. 材料两例分别用 1e-6 和 1e-3;归一化有助于优化,但不能据不同模型断言学习率必提高千倍。
  7. 循环层漏掉 batch_first=True 不会报错,模型会把时间维当成 batch 维处理,属于典型的不报错的错。
  8. 自回归外推把预测值滚回输入窗口,误差逐步累积,长程结果是题目要求的输出长度而不是模型的有效预测能力。

完整例题

把 LeNet 的 shape 链和参数清单一次推完。网络是 Conv2d(1, 6, 5) → 池化 2 → Conv2d(6, 16, 5) → 池化 2 → Linear(400, 120)Linear(120, 84)Linear(84, 10),输入 1×32×32。

步骤运算输出 shape怎么来的
输入1 × 32 × 32给定
conv1Conv2d(1, 6, 5)6 × 28 × 2832 − 5 + 1 = 28
pool池化 26 × 14 × 1428 ÷ 2
conv2Conv2d(6, 16, 5)16 × 10 × 1014 − 5 + 1 = 10
pool池化 216 × 5 × 510 ÷ 2
flattentorch.flatten(x, 1)40016 × 5 × 5

逐步说明:

  1. 通道数与边长分开算。卷积核个数决定输出通道(1→6→16),核边长决定输出边长。两条线互不干扰,一起算才容易乱。
  2. torch.flatten(x, 1) 里的 1 表示从第 1 维开始拉平,保留第 0 维的 batch。写成 x.view(-1, 400) 效果相同但更脆,换了输入尺寸就崩。
  3. 参数清点list(net.parameters()) 的长度是 10 = 5 层 × 2,池化没有可学习参数所以不计入。conv1 的 weight 是 (6, 1, 5, 5),conv2 是 (16, 6, 5, 5),fc1 是 (120, 400)——注意 Linear 的 weight 是 (out, in),和构造参数顺序相反。
  4. 换彩色图不用重推。同样 32×32 的三通道输入,只把第一层改成 Conv2d(3, 6, 5),后面整条推导链原样复用,400 这个数不变。

变式题(先自己做)

自己搭一个网络:输入 3 × 64 × 64,依次是 Conv2d(3, 8, 5) → 池化 2 → Conv2d(8, 16, 3) → 池化 2 → flattenLinear(?, 100)Linear(100, 10),卷积均无 padding、stride=1;两次池化均为 2×2、stride=2。

(1) 把每一层的输出 shape 推出来,Linear 第一个参数该填多少?(2) batch 取 32 时,输入张量和 flatten 之后的张量形状分别是什么?(3) list(net.parameters()) 的长度是多少,两个卷积层各有多少个可学习参数?(4) 这是个 10 类分类任务,最后一层要不要加 softmax,该配哪个损失函数?

提示

第 (1) 问两条公式交替用:卷积减核边长加一,本例 2×2、stride=2 的池化边长减半。第 (3) 问卷积 weight 的形状是 (输出通道, 输入通道, 核高, 核宽),别忘了 bias。第 (4) 问回想图像分类那份 forward 的最后一行长什么样。

参考答案与自检(非官方评分标准)

自检要点:① 通道数和边长分开推,不把两者乘混;② flatten 之后保留 batch 维;③ 参数个数按层数 × 2 数,池化不计入;④ 答「不加 softmax」并说出 CrossEntropyLoss 自带。

(1) 3×64×64 → conv1 得 8×60×60(64 − 5 + 1)→ 池化 8×30×30 → conv2 得 16×28×28(30 − 3 + 1)→ 池化 16×14×14。所以 flatten 之后是 16 × 14 × 14 = 3136Linear 第一个参数填 3136。

(2) 输入是 (32, 3, 64, 64),flatten 之后是 (32, 3136)torch.flatten(x, 1) 只拉平第 1 维往后的部分,batch 维始终保留。

(3) 长度是 8(两个卷积层、两个全连接层,各有 weight 和 bias;池化无参数)。conv1:weight 形状 (8, 3, 5, 5) 共 8 × 3 × 25 = 600 个,加 8 个 bias,合计 608。conv2:weight 形状 (16, 8, 3, 3) 共 16 × 8 × 9 = 1152 个,加 16 个 bias,合计 1168。顺带一提 fc1 的 weight 是 (100, 3136) = 313600 个,全连接层才是参数大户。

(4) 不加。nn.CrossEntropyLoss 内部已经包含 LogSoftmax,最后一层直接输出 10 个裸 logits 即可;先做 Softmax 会把概率再当 logits,改变目标函数。若网络输出 LogSoftmax,推荐配 NLLLoss;再接交叉熵通常冗余,并不等于训练必然失败。

闪卡自测

1. 训练循环的五步顺序是什么?

zero_grad → 前向 → 算损失 → backwardstep。顺序不能乱,四份 notebook 的训练函数全是它的变体。

2. 去掉 no_grad() 和去掉 zero_() 的后果分别是什么?

去掉 no_grad():对需要梯度的叶子参数做原地更新,通常会报错;不是必然先出现显存爆掉。去掉 zero_():梯度累加不清,第二个 batch 的梯度叠在第一个上,越滚越大。两者后果不同。

3. Q = 3a³ − b²,a = [2, 3]、b = [6, 4],梯度分别是多少?

∂Q/∂a = 9a² = [36, 81],∂Q/∂b = −2b = [−12, −8]。因为 Q 是向量不是标量,backward() 必须传一个同形状的 gradient= 权重向量。

4. cat 和 stack 作用在三个 4×4 张量上,dim=1,各得到什么形状?

cat 在已有维度上接长,得 (4, 12);stack 新开一个维度,得 (4, 3, 4)。记法:cat 是串起来,stack 是摞起来。

5. t = torch.ones(5); n = t.numpy(); t.add_(1) 之后 n 是什么?

[2. 2. 2. 2. 2.]。CPU 张量和 ndarray 共享同一块内存,改一边动另一边。搬到 GPU 之后是真拷贝,不再关联;要真副本用 clone()

6. 分类网络最后一层没有 softmax 是漏写吗?

不是漏写。CrossEntropyLoss 自带 LogSoftmax,最后一层输出裸 logits 才对。网络里已有 LogSoftmax 的话,损失要换成 NLLLoss

7. 循环层漏掉 batch_first=True 会怎样?

多半不报错,但输入默认按 (seq_len, batch, feature) 解读,模型把时间维当成 batch 维处理,学出来的东西没有意义。这是最典型的不报错的错。

8. 用 seq_len=8 切一条长度 100 的序列,得到几个样本?形状呢?

92 个(100 − 8)。X 形状 (92, 8, 1),y 形状 (92, 1),最后那个 1 是特征维度,对应模型的 input_dim=1

下一步

先从不依赖外部数据的那份开始,把手写梯度下降那段不看原文重写一遍——四步顺序和两处 no_grad() / zero_() 能一次写对,基础就过关了。字符级 RNN 那份务必先解开被注释掉的三行再跑,跑通之后按材料末尾的练习方向改一处:在隐状态后面加 torch.tanh(),或把 nn.RNN 换成 nn.LSTM,看混淆矩阵有没有变好。时间序列那份要真跑起来先解决数据来源,拿到数据第一件事是打印列名。行有余力就把三个模型在固定 epoch 数下各跑一遍、把验证 MSE 记成一张表——一次只改一个变量,这是 project 里必然要做的最小对比实验。

下一讲 →
L03b L03b Knowledge Representation:岛民说「我是骗子」,这句话在逻辑上不可能被说出来

个人整理的学习笔记,不是官方材料;数字与结论以课件和讲师为准。