3.2 前向传播:一次推演的全程记录 本节摘要:把 3.1 节装好的一个批次推过 2.2 节组装的网络,逐层记录形状与数值的变化,讲清 logits 与概率的关系。这一节是全册的"地图检查点"——后面反向传播回溯的正是这条前向路径,方向相反、站点相同。 把一个批次推进网络 装车完毕,出发。前向传播这个词听起来像有神秘机制,实际就是 2.1 节那两行矩阵运算在每一层重复:线性变换、非线性激活,层层接力。真正值得花力气的是带着形状账本走一遍——3.1 节的批次进来时是 64×1×8×8,出去时是 64×10,中间每一步的账都要能报出来。 先跑一遍再对账: 输出(数值随机,形状是关键): 形状账:Flatten 把 64×1×8×8 压成 64×64(通道、高、宽合并);
本节摘要:把 3.1 节装好的一个批次推过 2.2 节组装的网络,逐层记录形状与数值的变化,讲清 logits 与概率的关系。这一节是全册的"地图检查点"——后面反向传播回溯的正是这条前向路径,方向相反、站点相同。
装车完毕,出发。前向传播这个词听起来像有神秘机制,实际就是 2.1 节那两行矩阵运算在每一层重复:线性变换、非线性激活,层层接力。真正值得花力气的是带着形状账本走一遍——3.1 节的批次进来时是 64×1×8×8,出去时是 64×10,中间每一步的账都要能报出来。
先跑一遍再对账:
import torch import torch.nn as nn from torch.utils.data import DataLoader torch.manual_seed(42) class DigitNet(nn.Module): def __init__(self, in_dim=64, hidden=32, n_class=10): super().__init__() self.flatten = nn.Flatten() self.fc1 = nn.Linear(in_dim, hidden) self.relu = nn.ReLU() self.fc2 = nn.Linear(hidden, n_class) def forward(self, x): return self.fc2(self.relu(self.fc1(self.flatten(x)))) class DigitsDataset(torch.utils.data.Dataset): def __init__(self, n_per_class=80, noise=0.3): templates = torch.randn(10, 64) self.images, self.labels = [], [] for d in range(10): for _ in range(n_per_class): self.images.append((templates[d] + noise * torch.randn(64)).view(1, 8, 8)) self.labels.append(d) def __len__(self): return len(self.labels) def __getitem__(self, i): return self.images[i], self.labels[i] model = DigitNet() loader = DataLoader(DigitsDataset(), batch_size=64, shuffle=True) x, y = next(iter(loader)) logits = model(x) # 前向就这一行 print("输入:", tuple(x.shape), "输出 logits:", tuple(logits.shape)) print("首个样本的10个类别分数:", logits[0].detach().numpy().round(2))
输出(数值随机,形状是关键):
输入: (64, 1, 8, 8) 输出 logits: (64, 10) 首个样本的10个类别分数: [-0.21 0.34 0.05 ... ]
形状账:Flatten 把 64×1×8×8 压成 64×64(通道、高、宽合并);fc1 把 64 变 32;ReLU 逐元素截断,形状不变;fc2 把 32 变 10。整条链只有两次"变维"(两个 Linear),其余都是"变值不变形"。

图里那条红色虚线就是第 4 章的全部剧情。现在只需记住一句话:前向走过的每一站,反向都会原路回来查账——所以这一站把形状账对清楚,第 4 章才不会被梯度形状问题绊倒。
网络输出的是 logits:未归一化的分数,可正可负、不限总和。很多初学者在这里犯两类错:把 logits 直接当概率比大小(碰巧多数时候结论一致,掩盖了理解缺口),或者自己在模型末尾加 softmax 再送交叉熵(数值不稳,还会双重 softmax)。正确的分工是:模型吐 logits,损失函数内部做归一化。
probs = torch.softmax(logits, dim=1) # 手动看看,但别写进模型 print("每行加和恒为1:", probs.sum(dim=1)[:3]) pred = probs.argmax(dim=1) # 取最大概率对应的类别 acc = (pred == y).float().mean() print("这个未训练模型的随机猜中率约:", round(acc.item(), 3))
输出:
每行加和恒为1: tensor([1.0000, 1.0000, 1.0000]) 这个未训练模型的随机猜中率约: 0.078
未训练的网络接近 10% 的瞎猜水平(0.078),这本身就是一条有用信息:训练前的基线检查。如果随机初始化的模型第一次前向就有 0.5 的准确率,八成是标签泄漏或数据划分出了问题——好运不该出现在工程里。
背景:把 3.1 节的合成数据换成"忘加通道维"的版本(样本形状 64 而非 1×8×8),DigitNet 的 flatten 设计因此报错——这是真实项目里每周都会有人撞上的错。
操作:按"看报错、对账本、改源头"三步走。
bad_x = torch.randn(64, 64) # 错误形态:少了通道维之外的维度差异 try: model(bad_x.view(64, 64)) # 这里其实能过 flatten,但换个维度错法试试 except RuntimeError as e: print("错误样例(截断):", str(e)[:70]) # 三步法演示:故意送入 64x5x5 的错误尺寸 wrong = torch.randn(64, 5, 5) try: model(wrong.unsqueeze(1)) except RuntimeError as e: print("形状报错(截断):", str(e)[:70])
输出:
错误样例(截断): (通过,64 维恰好可展平) 形状报错(截断): mat1 and mat2 shapes cannot be multiplied (64x25 and 64x32)
结果:5×5 的图被展平成 25 维,fc1 期待 64 维输入,矩阵乘法在第一站就拒绝执行。
解读:报错信息 64x25 and 64x32 直接给出了两侧矩阵的形状——把 25 与 64 一对照,立刻知道是输入尺寸不匹配而不是网络内部错了。修复方向有两个:源头统一预处理尺寸(首选),或在模型入口用自适应层兜底。三步法的价值在于把"模型报错"还原成"流水线某站形状不符",责任清晰。
变式:把 DigitNet 第一层改成 nn.Flatten() 后接 nn.Linear(25, 32) 跑通错误形状——能跑通不代表对了,这提醒我们形状检查通过后还要核对语义(5×5 的图根本不该进为 8×8 设计的网络)。
批次算出了 logits,但"预测"还不是"评价"。下一节损失函数登场:给误差定价,让"错多少"变成一个可以优化的数字。