
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默认用的是带asqrt(5)参数的 Kaiming 均匀分布偏置是从1/sqrt(fan_in)的均匀分布里采的。两套初始化策略在深层网络里对收敛速度的影响很可观在这个只有十几参数的小实验里影响没那么大但如果你想让两个版本的训练曲线长得像就得手动把初始化对齐。第二是损失函数的边界处理。原版的二元交叉熵在内部会把预测值裁剪到一个安全区间避免log(0)PyTorch 的nn.BCELoss不做这个裁剪如果你的 sigmoid 输出恰好饱和到 0 或 1损失会直接变成inf或者nan。这个坑在小实验里可能碰不到但只要你的学习率调大一点瞬间就炸。第三是数据类型默认值。用 NumPy 造的数据默认是float64torch.from_numpy会原样保留而nn.Linear的权重是float32。两者一相乘就是那句经典的expected scalar type Double but found Float。这个问题在纯 PyTorch 流程里反而不会出现因为你用torch.rand造数据默认就是float32恰恰是从 NumPy 迁过来的路径上容易中招。3. 环境准备与数据构造别在第一步翻车3.1 依赖版本与可复现性这个实验对环境的胃口极小torch、numpy、matplotlib三个包就够不需要 GPUCPU 版本完全能胜任。但能跑和结果可复现是两回事这里有几个细节值得花五分钟处理掉。先说版本。torch.meshgrid在 1.10 之前的默认行为和之后不一样旧版本会给你一个警告而且索引顺序是反的。画决策边界的时候如果你用的是indexingij参数那就必须 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, generatorg) * 2 - 1注意如果你在 notebook 里反复执行同一个 cell全局种子只在你第一次调用随机函数前设一次是不够的每次重新造数据前都得重设否则第二次跑出来的数据和第一次不同。用独立Generator的方式能规避这个问题我是被坑过两次之后改的习惯。还有一点关于设备。这个实验的量级完全没必要上 GPUtensor.to(cuda)只会给你增加形状和设备不匹配的报错来源。全部在 CPU 上跑代码干净调试快捷出图也方便。3.2 造两份目的不同的数据原实验用的数据集是课程内置的迁移到 PyTorch 版本时我强烈建议自己造数据原因有两个一是你能完全掌控数据的分布特性想验证什么就造什么二是造数据的过程本身就是理解什么样的问题线性模型解决不了的最好练习。我一般会造两份。第一份是线性可分的用来喂给单个神经元验证它能收敛到接近百分百的准确率。最简单的造法是取平面上的一条斜线作为分界def make_linear_data(n400, seed7): g torch.Generator().manual_seed(seed) X torch.rand(n, 2, generatorg) * 2 - 1 # [-1, 1] 的均匀分布 y (X[:, 0] X[:, 1] 0).float().unsqueeze(1) return X, y第二份是环形可分的也就是内圈一类、外圈一类。这种分布对线性模型来说是致命的任何一条直线切下去准确率都上不去多少。造法也很简单用半径的平方做阈值def make_ring_data(n400, seed42): g torch.Generator().manual_seed(seed) X torch.rand(n, 2, generatorg) * 2 - 1 r2 (X ** 2).sum(dim1) 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 bX是(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.Module5.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_in2, n_hidden3, n_out1): 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_hiddenFalse): 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_hidden3改成 8其余代码一行不用动。这也是模块化写法相比裸写版本的最大好处——改结构的成本趋近于零适合做对照实验。5.3 训练循环四步走和它的变体训练循环的骨架永远是那四步我把完整版写出来顺便加上损失记录import torch.optim as optim X, y make_ring_data(n600) model TwoLayerNet(n_hidden4) criterion nn.BCELoss() optimizer optim.Adam(model.parameters(), lr0.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(fepoch {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(), lr0.5)也能收敛只是要多跑几百轮。我一般先用 Adam 跑通看效果再用 SGD 跑一遍感受差异两相对比对优化器的理解会深一层。5.4 决策边界可视化眼见为实的验证手段代码跑完、损失降下来并不等于你真的相信模型学会了。唯一能让你信服的办法是把决策边界画出来看看它是不是真的绕出了一个圈。def plot_boundary(model, X, y, ax, res200): 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, indexingij) grid torch.stack([xx.reshape(-1), yy.reshape(-1)], dim1) with torch.no_grad(): zz model(grid).reshape(res, res) ax.contourf(xx, yy, zz, levels20, cmapRdBu_r, alpha0.55) pos, neg y.squeeze() 1, y.squeeze() 0 ax.scatter(X[pos, 0], X[pos, 1], s10, c#c0392b, labelclass 1) ax.scatter(X[neg, 0], X[neg, 1], s10, c#2c6fbb, labelclass 0) ax.set_xticks([]), ax.set_yticks([]) ax.legend(locupper right, fontsize8)几个实操要点。with torch.no_grad()不能省否则网格上的两万个点会一路带着计算图显存和内存都吃不消。indexingij对应xx沿第一维变化跟reshape(res, res)的顺序必须一致搞反了图会转 90 度很难发现。levels20让渐变层次足够细腻能看出模型对边界附近样本的犹豫程度。我最推荐的对照实验是把n_hidden分别设成 1、2、4、8各训一遍四张图并排放。n_hidden1的时候你会看到一条直线穿过环形数据准确率大概五成左右无论怎么调学习率、跑多久都救不回来n_hidden2能画出两条半直线拼成的折角n_hidden4就能围出一个像模像样的闭合形状n_hidden8边界会更平滑但因为参数量增加如果数据量不够反而容易过拟合边界会出现一些为了迁就个别点而扭出来的小凸起。这一组对比图放在一起比任何理论解释都有说服力。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 FloatNumPy 数据是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会直接产出数量级极大的zsigmoid 瞬间饱和到 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 去掉直接输出z2criterion nn.BCEWithLogitsLoss() # 模型里去掉最后的 self.act2forward 直接返回 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说明某处已经饱和了训练一开始就会有问题。这个三秒钟的自检帮我省过很多次半小时的排查。