1. 先把这门实验的底摸清楚
这个实验编号在深度学习入门圈子里流传得挺广,属于某经典公开课第二门课第一周的第一个实验,主题就是"神经元与层"。原始版本用的是另一套深度学习框架的高层接口,几行代码就能把一个神经元、一个两层网络搭出来,但正因为太"高层",很多人跑完一遍之后脑子里只剩下几个函数名,对里面到底发生了什么依旧一片模糊。市面上流传的 PyTorch 版本,本质上就是把这层封装拆掉,让你用更底层、更显式的方式把同样的事情重做一遍。
它解决的问题非常具体:帮你建立"神经网络 = 一堆矩阵乘法 + 逐元素非线性"这个最朴素的认知,并且亲手把这个认知落到代码里。整个实验的数据量小到可以扔进任何一台笔记本里跑,模型参数量只有十几二十个,训练几秒钟就收敛,可视化出来就是一张二维平面上的决策边界图。正因为小,你才有机会把每一个张量的形状、每一次梯度回传的方向都盯得一清二楚。
适合谁来参考?三类人最受益。第一类是刚学完线性代数和基础微积分,看得懂公式但一写代码就懵的初学者;第二类是已经会用高层接口训模型、但被问到"你这个层的参数量是多少、权重形状为什么是这样"的时候答不上来的半熟手;第三类是想把课程作业从原框架迁移到 PyTorch 生态、顺便验证自己对两种框架差异理解到什么程度的人。如果你属于这三类中的任何一类,把这篇从头到尾跟着做一遍,收益远大于刷十个教程视频。
我自己的体会是,这个实验真正的价值不在"跑通",而在"改坏再修好"。你把它跑通只能说明你会抄代码,你把它故意改错——改错形状、改错激活、改错损失——然后自己定位出来,才算真的懂了。下面我就按这个思路,把这个实验从头拆到尾。
2. 实验的整体设计与思路拆解
2.1 这个实验的认知阶梯是怎么设计的
原课程在这个实验里的编排逻辑非常克制,它不追求让你一次学会训练一个能打的模型,而是把"神经元"和"层"这两个概念拆成两级台阶让你踩。第一级台阶是单个神经元:一个权重向量、一个偏置、一个激活函数,输出一个标量。这一级的核心命题是——单个神经元是一个线性分类器加一个非线性挤压,它的表达能力上限就是一条直线(或者高维空间里的一个超平面)。第二级台阶是把若干个神经元并排放,再把它们的输出喂给下一层,这时候模型的表达能力就从"一条直线"跃迁到"任意分段逼近"。
这个阶梯设计得妙的地方在于,它逼你先承认"我不行",再告诉你"怎么才行"。实验里通常会先让你用单个神经元去拟合一个线性可分的数据集,效果很好;然后换成一个环形或者月牙形的数据集,单个神经元怎么调都只有五成出头的准确率,你会直观地感受到"它画不出曲线"。等你加上一个隐藏层,把三个或四个神经元并排一放,曲线立刻出来了。这个从挫败到恍然的落差,比任何一句"多层网络具有更强的非线性表达能力"都管用。
所以你在做 PyTorch 版本的时候,千万别跳过第一级台阶直接上多层网络。很多人图快,上来就nn.Sequential一把梭,结果对"为什么需要隐藏层"这个问题始终没有体感。我的建议是严格按原实验的顺序来:先用裸张量手写单个神经元的前向和反向,跑一遍线性可分数据;再换环形数据感受失败;最后才引入隐藏层。每一步都配上决策边界的可视化,让结论是"看"出来的,不是"信"出来的。
2.2 为什么值得用另一套框架重写一遍
有人会问,原版代码都能跑了,为什么还要折腾一个 PyTorch 版本?这个问题值得认真回答,因为它是你决定要不要投入两三个小时的关键。
最直接的理由是训练循环的可见性。原版用的是高层接口的compile加fit,一行代码把前向、损失计算、反向传播、参数更新、日志打印全包了。这在工程上当然是好事,但在学习阶段是个黑箱。PyTorch 的写法强制你把zero_grad()、forward、loss.backward()、optimizer.step()这四步显式写出来,你会在反复手写这四步的过程中,真正记住梯度是累积的、必须手动清零,这个知识点在面试和实际调试里都极高频。
第二个理由是张量形状的显式暴露。高层接口的Dense层把输入维度藏在第一次调用时的自动推断里,而 PyTorch 的nn.Linear(in_features, out_features)会把两个维度都摆在你面前,权重矩阵的形状是(out_features, in_features),偏置是(out_features,)。这个形状约定跟数学公式里Wx + b的写法是反过来的(数学上 W 通常是(out, in)也说得通,但很多人第一次看到会愣一下),你必须自己想明白为什么X @ W.T才能对上,这个推导过程本身就是极好的训练。
第三个理由是框架差异的肌肉记忆。两套框架在权重初始化、损失函数数值处理、数据类型默认值上都有细微差别,这些差别在简单实验里无所谓,但在真实项目里会变成"同一份代码换个框架结果差一大截"的玄学问题。在小实验里把这些差异摸清楚,成本最低。
2.3 迁移过程中最容易想当然的三个点
从原框架迁到 PyTorch,绝大多数人会在三个地方栽跟头,而且都是"看起来一样其实不一样"的隐性差异,不跑起来根本发现不了。
第一是权重初始化。原版Dense层默认用的是 Glorot 均匀分布,偏置初始化为零;PyTorch 的nn.Linear默认用的是带a=sqrt(5)参数的 Kaiming 均匀分布,偏置是从1/sqrt(fan_in)的均匀分布里采的。两套初始化策略在深层网络里对收敛速度的影响很可观,在这个只有十几参数的小实验里影响没那么大,但如果你想让两个版本的训练曲线长得像,就得手动把初始化对齐。
第二是损失函数的边界处理。原版的二元交叉熵在内部会把预测值裁剪到一个安全区间,避免log(0);PyTorch 的nn.BCELoss不做这个裁剪,如果你的 sigmoid 输出恰好饱和到 0 或 1,损失会直接变成inf或者nan。这个坑在小实验里可能碰不到,但只要你的学习率调大一点,瞬间就炸。
第三是数据类型默认值。用 NumPy 造的数据默认是float64,torch.from_numpy会原样保留;而nn.Linear的权重是float32。两者一相乘就是那句经典的expected scalar type Double but found Float。这个问题在纯 PyTorch 流程里反而不会出现,因为你用torch.rand造数据默认就是float32,恰恰是从 NumPy 迁过来的路径上容易中招。
3. 环境准备与数据构造:别在第一步翻车
3.1 依赖版本与可复现性
这个实验对环境的胃口极小,torch、numpy、matplotlib三个包就够,不需要 GPU,CPU 版本完全能胜任。但"能跑"和"结果可复现"是两回事,这里有几个细节值得花五分钟处理掉。
先说版本。torch.meshgrid在 1.10 之前的默认行为和之后不一样,旧版本会给你一个警告而且索引顺序是反的。画决策边界的时候如果你用的是indexing="ij"参数,那就必须 1.10 以上。所以我的建议是直接上 2.x 的稳定版,省掉一堆兼容性啰嗦。
再说种子。很多人只写了torch.manual_seed(42)就以为万事大吉,结果发现每次跑出来的数据点位置还是不一样。原因是你的数据是用 NumPy 造的,而torch.manual_seed管不到 NumPy 的随机数发生器。正确的做法是两边都设,或者干脆把随机数生成器显式传进去:
import torch import numpy as np import matplotlib.pyplot as plt SEED = 42 torch.manual_seed(SEED) np.random.seed(SEED) # 更稳妥的写法:用独立的 Generator,不污染全局状态 g = torch.Generator().manual_seed(SEED) X = torch.rand(400, 2, generator=g) * 2 - 1注意:如果你在 notebook 里反复执行同一个 cell,全局种子只在你第一次调用随机函数前设一次是不够的,每次重新造数据前都得重设,否则第二次跑出来的数据和第一次不同。用独立
Generator的方式能规避这个问题,我是被坑过两次之后改的习惯。
还有一点关于设备。这个实验的量级完全没必要上 GPU,tensor.to("cuda")只会给你增加形状和设备不匹配的报错来源。全部在 CPU 上跑,代码干净,调试快捷,出图也方便。
3.2 造两份目的不同的数据
原实验用的数据集是课程内置的,迁移到 PyTorch 版本时,我强烈建议自己造数据,原因有两个:一是你能完全掌控数据的分布特性,想验证什么就造什么;二是造数据的过程本身就是理解"什么样的问题线性模型解决不了"的最好练习。
我一般会造两份。第一份是线性可分的,用来喂给单个神经元,验证它能收敛到接近百分百的准确率。最简单的造法是取平面上的一条斜线作为分界:
def make_linear_data(n=400, seed=7): g = torch.Generator().manual_seed(seed) X = torch.rand(n, 2, generator=g) * 2 - 1 # [-1, 1] 的均匀分布 y = (X[:, 0] + X[:, 1] > 0).float().unsqueeze(1) return X, y第二份是环形可分的,也就是内圈一类、外圈一类。这种分布对线性模型来说是致命的:任何一条直线切下去,准确率都上不去多少。造法也很简单,用半径的平方做阈值:
def make_ring_data(n=400, seed=42): g = torch.Generator().manual_seed(seed) X = torch.rand(n, 2, generator=g) * 2 - 1 r2 = (X ** 2).sum(dim=1) y = (r2 > 0.45).float().unsqueeze(1) return X, y这里有两个细节必须注意。unsqueeze(1)不能省,因为后面的损失函数要求预测值和标签的形状完全一致,都是(N, 1),如果你给的标签是(N,),PyTorch 的广播机制可能会悄悄给你算出个错误结果而不报错,这比直接报错更可怕。另外.float()也不能省,比较运算产生的布尔张量必须转成浮点才能参与损失计算。
提示:造完数据先画一张散点图看一眼。环形数据如果两个类别严重不平衡,比如内圈只有三十个点外圈有三百七十个,模型很容易躺平——全部预测成多数类就能拿到九成准确率,你会误以为模型学会了。让两类样本数量大致相当,判断才有意义。
4. 核心概念拆解:神经元、层与形状推导
4.1 单个神经元的数学本质到底是什么
一个神经元干的活,用一句话概括就是:把输入向量投影到一个方向上,加上偏移,再用非线性函数压一下。公式写出来是a = g(Wx + b),其中W是行向量,Wx是个内积,物理意义是"输入点在权重方向上的投影长度",b是阈值偏移,g是激活函数。
为什么说它的表达能力上限是一条直线?因为当g是 sigmoid 这类单调函数时,g(z) > 0.5等价于z > 0,也就是Wx + b > 0。这是一个标准的超平面判定条件,在二维平面上就是一条直线。所以单个神经元无论你怎么训练,本质上只是在找那条最优的分界线,它不可能学会"内圈 vs 外圈"这种闭合边界。
激活函数在这里扮演的角色经常被误解。很多初学者以为加了 sigmoid 就"非线性"了,模型就变强了。其实不是——单个神经元加激活,只是把线性输出挤压到了 (0,1) 区间,判定边界依然是一条直线。非线性表达能力的真正来源是"多层",或者说"多个神经元输出的线性组合再经过非线性"。这个区别极其重要,是理解整个实验的钥匙。
我习惯用一个比喻来解释:单个神经元像一把直尺,你可以在纸上画出任意方向的直线,但只能画直线;一个隐藏层里有三个神经元,就相当于三把不同角度的直尺,它们的输出再被下一层加权组合,组合出来的形状就有了折角,折角多了,曲线就能逼近出来。
4.2 "层"不过是把向量运算批量化
从单个神经元到一层,变化只有一个:把若干个权重行向量摞成一个矩阵。Z = XW^T + b,X是(N, d_in),W^T是(d_in, d_out),Z是(N, d_out)。这里的N是样本数,d_in是输入维度,d_out是这一层神经元的个数。
这个矩阵形式的妙处在于,它同时对 N 个样本做了同样的运算,而且每个样本的计算是独立的。这就是所谓的"批处理",也是为什么 GPU 在深度学习里这么吃香——同样的矩阵乘法,堆一万个样本和堆一个样本,在并行硬件上的耗时差距远远小于一万倍。
很多人写代码的时候喜欢用循环一个样本一个样本地算,算完再拼起来,这样写出来的代码又慢又容易出错,而且完全没有理解矩阵运算的意义。我的建议是从一开始就养成"所有样本一起算"的习惯,把X的第一维永远当成批次维。这个习惯养成之后,你后面学 mini-batch、学数据加载器、学序列模型,都会顺畅得多。
这里还有个概念需要澄清:PyTorch 的nn.Linear只做线性变换,不含激活函数。这是与某些框架不同的设计取向,好处是你可以自由组合。所以nn.Linear(2, 3)后面必须显式跟一个nn.Sigmoid()或者nn.ReLU(),两行连起来才等于一个完整的"层"。如果你忘了加激活,整个Sequential就退化成了一个大的线性变换,无论叠多少层都等价于一层——这是新手最常犯的"模型学不动"的原因之一。
4.3 参数形状的推导方法与常见误区
形状推导这件事,说难不难,说简单也确实能让不少人卡半天。我推荐一个固定的推导习惯:从左到右,逐层写形状,每写一个都标注它代表什么。
以输入维度 2、隐藏层 3 个神经元、输出 1 个概率值为例:
| 张量 | 形状 | 含义 |
|---|---|---|
X | (N, 2) | 批次大小为 N,每个样本 2 个特征 |
W1 | (3, 2) | nn.Linear(2, 3)的权重,(out, in) |
b1 | (3,) | 隐藏层偏置,每个神经元一个 |
Z1/A1 | (N, 3) | 隐藏层输出,3 个神经元各给一个值 |
W2 | (1, 3) | nn.Linear(3, 1)的权重 |
b2 | (1,) | 输出层偏置 |
Z2/A2 | (N, 1) | 每个样本一个预测概率 |
y | (N, 1) | 标签,必须和A2形状一致 |
参数量算下来是3*2 + 3 + 1*3 + 1 = 13个。十三个数就能把一条闭合曲线画出来,这个对比本身就挺震撼的。
最常见的误区有三个。第一,把W的形状记成(in, out),写X @ W而不是X @ W.T,结果报mat1 and mat2 shapes cannot be multiplied。第二,偏置的形状写成(N, 1)或者(1, 3)然后靠广播硬扛,虽然能跑,但一旦涉及批大小变化就会出乱子,偏置永远应该是(out,)这种一维形状。第三,忘了输入层不算"层",nn.Linear(2,3)加nn.Linear(3,1)是两层,不是三层。
提示:养成一个好习惯——每次新建模型,打印一遍
sum(p.numel() for p in model.parameters()),再打印每个参数的形状,跟你手算的结果对一下。对不上就说明你对某层的理解有偏差,早发现早修正。
5. 动手实现:从裸张量到 nn.Module
5.1 第一版:不用任何高级 API 的裸写实现
我强烈建议第一遍实现不要碰nn.Linear和优化器,就用最原始的torch.randn初始化权重,手写前向,手写损失。这一版代码丑陋,但每一行你都能看懂。
import torch def sigmoid(z): return 1.0 / (1.0 + torch.exp(-z)) # 1) 手动初始化参数,形状严格对照上一节的表格 torch.manual_seed(0) W1 = torch.randn(3, 2) * 0.5 b1 = torch.zeros(3) W2 = torch.randn(1, 3) * 0.5 b2 = torch.zeros(1) # 2) 前向:X (N,2) -> Z1 (N,3) -> A1 (N,3) -> Z2 (N,1) -> A2 (N,1) X, y = make_ring_data() Z1 = X @ W1.T + b1 A1 = sigmoid(Z1) Z2 = A1 @ W2.T + b2 A2 = sigmoid(Z2) # 3) 手写二元交叉熵,注意加 eps 防止 log(0) eps = 1e-7 loss = -(y * torch.log(A2 + eps) + (1 - y) * torch.log(1 - A2 + eps)).mean() print(f"初始损失: {loss.item():.4f}")跑完这一段,你可以做一件很有价值的事:手动验证反向传播。用torch.autograd算一遍梯度,再自己用链式法则推一遍,两者对比。比如dL/dZ2 = (A2 - y) / N,这个结论在 sigmoid 配交叉熵的组合下极其简洁,是必须记住的。再往前的dL/dW2 = A1.T @ dZ2,形状是(3,N) @ (N,1) = (3,1),正好等于W2.T的形状。
这个手工对梯度的过程,花二十分钟,收益比看十页推导大得多。因为你会在某个地方卡住,卡住的那个点就是你真正没理解的地方。
5.2 第二版:用 nn.Module 重构,但保留可检查性
裸写版本跑通之后,换成nn.Module就是水到渠成的事。这里我推荐一个折中写法:用Sequential搭结构,但同时把中间层的输出抓出来,方便可视化。
import torch.nn as nn class TwoLayerNet(nn.Module): def __init__(self, n_in=2, n_hidden=3, n_out=1): super().__init__() self.fc1 = nn.Linear(n_in, n_hidden) self.act1 = nn.Sigmoid() self.fc2 = nn.Linear(n_hidden, n_out) self.act2 = nn.Sigmoid() def forward(self, x, return_hidden=False): z1 = self.fc1(x) a1 = self.act1(z1) z2 = self.fc2(a1) a2 = self.act2(z2) if return_hidden: return a2, a1 return a2 model = TwoLayerNet() print(model) print("参数总量:", sum(p.numel() for p in model.parameters())) for name, p in model.named_parameters(): print(name, tuple(p.shape))输出应该是fc1.weight (3,2)、fc1.bias (3,)、fc2.weight (1,3)、fc2.bias (1,),总量 13。这个检查一定要做,我见过太多人把nn.Linear的参数顺序记反了还以为模型有问题。
return_hidden这个设计看起来有点多余,但它解决了一个实际痛点:你想看隐藏层到底学到了什么,想画隐藏层的激活值分布,想验证"三个神经元各自负责平面的一部分"这个直觉。有了这个开关,你就不用为了取中间层而把模型拆开重写。
如果想加宽隐藏层,比如改成 8 个神经元,只需要把n_hidden=3改成 8,其余代码一行不用动。这也是模块化写法相比裸写版本的最大好处——改结构的成本趋近于零,适合做对照实验。
5.3 训练循环:四步走和它的变体
训练循环的骨架永远是那四步,我把完整版写出来,顺便加上损失记录:
import torch.optim as optim X, y = make_ring_data(n=600) model = TwoLayerNet(n_hidden=4) criterion = nn.BCELoss() optimizer = optim.Adam(model.parameters(), lr=0.1) history = [] for epoch in range(1500): optimizer.zero_grad() # 1. 清空上一轮梯度 pred = model(X) # 2. 前向 loss = criterion(pred, y) # 3. 算损失 loss.backward() # 4. 反向传梯度 optimizer.step() # 5. 更新参数 history.append(loss.item()) if epoch % 300 == 0: acc = ((pred > 0.5).float() == y).float().mean().item() print(f"epoch {epoch:4d} | loss {loss.item():.4f} | acc {acc:.3f}")几个参数选择的理由值得说清楚。学习率 0.1 配 Adam 是这个规模下的甜蜜点,太小学得慢,太大会让 sigmoid 饱和导致梯度消失。迭代 1500 轮对 600 个样本、13 个参数来说是绰绰有余的,你从损失曲线上能看到它在前两三百轮就基本收敛了。损失函数用BCELoss是配套 sigmoid 输出层的标准选择。
批次大小这里直接用了全量,也就是每轮用所有 600 个样本算一次梯度。这在数据量小的时候是最优解,梯度估计准确、收敛稳定。等你以后处理大数据再引入 mini-batch,那时候才需要考虑打乱、分批、迭代次数换算这些事。
再说优化器。原课程用的是基础的梯度下降,PyTorch 里对应optim.SGD。但实测下来,在这个小实验里 Adam 收敛快得多,因为它对每个参数自适应调整步长,不容易在某个方向上卡住。如果你想严格复刻原版的行为,用optim.SGD(model.parameters(), lr=0.5)也能收敛,只是要多跑几百轮。我一般先用 Adam 跑通看效果,再用 SGD 跑一遍感受差异,两相对比,对优化器的理解会深一层。
5.4 决策边界可视化:眼见为实的验证手段
代码跑完、损失降下来,并不等于你真的相信模型学会了。唯一能让你信服的办法是把决策边界画出来,看看它是不是真的绕出了一个圈。
def plot_boundary(model, X, y, ax, res=200): x_min, x_max = X[:, 0].min() - 0.1, X[:, 0].max() + 0.1 y_min, y_max = X[:, 1].min() - 0.1, X[:, 1].max() + 0.1 xs = torch.linspace(x_min, x_max, res) ys = torch.linspace(y_min, y_max, res) xx, yy = torch.meshgrid(xs, ys, indexing="ij") grid = torch.stack([xx.reshape(-1), yy.reshape(-1)], dim=1) with torch.no_grad(): zz = model(grid).reshape(res, res) ax.contourf(xx, yy, zz, levels=20, cmap="RdBu_r", alpha=0.55) pos, neg = y.squeeze() == 1, y.squeeze() == 0 ax.scatter(X[pos, 0], X[pos, 1], s=10, c="#c0392b", label="class 1") ax.scatter(X[neg, 0], X[neg, 1], s=10, c="#2c6fbb", label="class 0") ax.set_xticks([]), ax.set_yticks([]) ax.legend(loc="upper right", fontsize=8)几个实操要点。with torch.no_grad()不能省,否则网格上的两万个点会一路带着计算图,显存和内存都吃不消。indexing="ij"对应xx沿第一维变化,跟reshape(res, res)的顺序必须一致,搞反了图会转 90 度,很难发现。levels=20让渐变层次足够细腻,能看出模型对边界附近样本的"犹豫程度"。
我最推荐的对照实验是:把n_hidden分别设成 1、2、4、8,各训一遍,四张图并排放。n_hidden=1的时候你会看到一条直线穿过环形数据,准确率大概五成左右,无论怎么调学习率、跑多久都救不回来;n_hidden=2能画出两条半直线拼成的折角;n_hidden=4就能围出一个像模像样的闭合形状;n_hidden=8边界会更平滑,但因为参数量增加,如果数据量不够反而容易过拟合,边界会出现一些为了迁就个别点而扭出来的小凸起。这一组对比图放在一起,比任何理论解释都有说服力。
6. 踩坑实录与排查清单
6.1 形状类报错的三步定位法
形状报错是这类实验里出现频率最高的一类问题,好消息是它们几乎都有明确的错误信息,坏消息是错误信息里指的行号经常不是你真正写错的地方。我总结了一个三步定位法,基本能覆盖九成情况。
第一步,看报错里的两个形状数字。典型的mat1 and mat2 shapes cannot be multiplied (600x2 and 3x2)已经把答案摆出来了:左边是600x2,说明你的X还是原始输入,没有被前面的层处理过;右边是3x2,说明这是第一层的权重。问题在于你把X直接喂给了第二层,或者第一层被跳过。这时候不要盯报错行,往上翻三行找漏掉的那次调用。
第二步,在可疑位置打印形状。在forward里插一行print(x.shape),或者用torch.Size断言。我习惯写一个装饰器或者上下文管理器,在调试模式下自动打印每一层的输入输出形状,关掉调试就零开销。
第三步,回到参数形状表核对。上面那张表不是画着好看的,遇到问题就逐行对一遍。nn.Linear的权重是(out, in)这个约定,跟很多人脑子里的数学直觉相反,忘了就必错。
常见的形状错误还有几类,我整理成一张表方便速查:
| 报错信息关键字 | 大概率原因 | 修法 |
|---|---|---|
cannot be multiplied | 漏了某一层,或W.T写反 | 对照形状表逐层检查 |
expected scalar type Double but found Float | NumPy 数据是float64 | .float()或astype(np.float32) |
Target and input must have the same number of elements | 标签是(N,)不是(N,1) | 加.unsqueeze(1) |
size mismatch for fc1.weight | 载入的权重和模型结构不匹配 | 检查in_features是否对 |
Boolean value of Tensor with more than one value is ambiguous | 用if tensor:判断张量真假 | 改成if tensor.any(): |
6.2 损失不下降的五个常见原因
比形状报错更折磨人的是"代码能跑但模型不学"。损失曲线横在那里纹丝不动,或者上下抖动,或者干脆变成nan。我按遇到频率从高到低排一下。
第一,忘了加激活函数。nn.Linear接到nn.Linear,中间没有非线性,整个模型等价于一层线性变换。判断方法很简单:把模型所有参数取出来,检查是否能用单次矩阵乘法表示。修法就是补上nn.Sigmoid()或nn.ReLU()。
第二,学习率不合适。太大,损失震荡甚至爆炸;太小,一千轮下来看不出变化。判断方法是把学习率调一个数量级再跑,看曲线有没有变好。sigmoid 配交叉熵的组合理想学习率通常在 0.05 到 0.5 之间。
第三,数据没归一化。这个实验的数据我特意造在[-1, 1]区间,就是为了规避这个问题。如果你的数据范围是[0, 1000],输入进nn.Linear会直接产出数量级极大的z,sigmoid 瞬间饱和到 0 或 1,梯度变成 0,参数再也不更新。
第四,标签和预测的形状虽然对上了,但语义反了。我见过有人把y造出来之后又做了个1 - y,或者argmax取错了维度,模型拼命往反方向学。这种情况的特征是损失能降到ln(2) ≈ 0.693就卡住,因为此时模型学会了全部预测成 0.5。这个 0.693 是个很好的诊断信号,看到它就该怀疑标签。
第五,zero_grad()忘了写。梯度会在backward()里累加,第二轮开始你用的就是两步的梯度之和,参数更新完全失控。表现是损失先降后剧烈震荡。
提示:遇到"不学习"的问题,先跑一个十轮的过拟合测试——拿十个小样本,把模型加宽到几十个神经元,看能不能把训练损失压到接近 0。能压下去说明模型和训练流程没问题,问题出在数据或超参;压不下去说明代码里有个硬伤。这个测试能在两分钟内把问题范围砍掉一半。
6.3 数值稳定性:别让 log(0) 毁掉整轮训练
nn.BCELoss在输入恰好是 0 或者 1 的时候会返回inf,随后一轮backward就把所有参数变成nan。而 sigmoid 在z稍微大一点的时候(比如 30)输出就饱和成1.0了,浮点数精度下根本分辨不出来。
两个解决方案。方案一是BCEWithLogitsLoss,它内部把 sigmoid 和交叉熵合并成一个数值稳定的运算,你只需要把模型的最后一层 sigmoid 去掉,直接输出z2:
criterion = nn.BCEWithLogitsLoss() # 模型里去掉最后的 self.act2,forward 直接返回 z2这个写法在工程上是标准做法,稳定性和速度都更好。但它有个副作用:模型的输出不再是概率,预测时得自己加一次torch.sigmoid()。初学者容易在这里懵,所以做教学实验时我更倾向于保留 sigmoid。
方案二是手动裁剪或者加 eps。前面手写损失时那个+1e-7就是干这个的。nn.BCELoss没有自带,但你可以在forward里把输出限制到[1e-6, 1-1e-6]:
pred = model(X).clamp(1e-6, 1 - 1e-6) loss = criterion(pred, y)我个人的偏好是:教学和调试阶段用方案二,代码直观;真要写生产代码用方案一,稳定高效。两套都动手写一遍,对数值稳定性这件事的理解会具体很多。
7. 中英对照版的使用姿势与延伸练习
中英对照版本最大的价值不在"看不懂英文时有个备胎",而在于术语的精准对应。深度学习的很多中文译名是约定俗成的,跟英文原词的意思有微妙偏移,如果你只记住中文,以后读论文或者看文档时会卡壳。我建议在过这个实验的时候,把关键术语列一张对照表贴在旁边:
| 英文 | 常见中文 | 需要留意的点 |
|---|---|---|
| neuron | 神经元 | 只是一个带激活的线性单元,别过度联想生物 |
| layer | 层 | 输入层通常不计入"几层"的统计 |
| dense / fully connected | 全连接层 | 指每个输入连到每个输出,与卷积层相对 |
| activation | 激活函数 | 关键作用是引入非线性,不是"模拟神经元放电" |
| logits | 未归一化的预测值 | 指激活函数之前的输出,很多损失函数直接吃它 |
| forward propagation | 前向传播 | 从输入算到输出,不含梯度 |
| backpropagation | 反向传播 | 链式法则的系统化应用,不是独立算法 |
| epoch / batch | 轮次 / 批次 | 一轮 = 遍历完整数据集一次 |
使用双语版本的一个实操建议:先用中文版把流程跑通,再用英文版复现一遍,期间不看中文版。这个"断奶"过程会很痛苦,尤其是遇到报错的时候,但撑过去之后你读英文文档的速度会上一个台阶。我在做这个实验的时候特意这么干过一次,收获最大的是意识到自己对某些术语的理解其实一直停留在"大概知道"的层次。
延伸练习我推荐三个方向,都基于这个已有代码做增量修改,成本很低。
方向一,换激活函数。把隐藏层的Sigmoid换成ReLU或者Tanh,保持其他不变,看决策边界和收敛速度怎么变。你会观察到 ReLU 学出来的边界更"硬",有明显的折线感,因为它是分段线性的;而 sigmoid 更平滑。这个对比能让你对"激活函数的选择影响什么"有具体感受。
方向二,手工实现一个隐藏层,不调用nn.Linear。用nn.Parameter自己声明W和b,自己在forward里写x @ W.T + b。这一步能让你彻底搞清楚nn.Module的参数注册机制,以及为什么优化器能找到你的参数。
方向三,把网络加深一层。变成三层,隐藏层都是 4 个神经元。你会遇到新问题:直接用 sigmoid 加上随机初始化,深层的梯度可能传不回去,损失卡在 0.693 附近不动。这时候再引入 ReLU 或者调整初始化,就能体会到"为什么深网络需要专门的设计"。这个坑我建议你一定要亲手踩一次,比看任何梯度消失的解释都深刻。
最后分享一个我自己反复用的检查习惯:每写完一段模型代码,先不训练,把模型设成评估模式、喂一个全零的输入、打印输出值。如果输出恰好是 0.5,说明偏置是零初始化、权重没被意外激活,一切正常;如果输出是 0 或者 1,说明某处已经饱和了,训练一开始就会有问题。这个三秒钟的自检,帮我省过很多次半小时的排查。