Skip to content

第 3 章 让小网络学会预测 ​

自动求导引擎不会自己知道什么叫 “预测正确”。我们还需要定义模型、目标和衡量误差的办法。三者接好以后,训练就是反复运行程序、求梯度、改参数。先用四条记录看清这个闭环,比直接面对成千上万行数据容易检查。

3.1 神经元:带参数的加权求和 ​

输入向量 x=(x1,…,xd) 有 d 个数,权重 w=(w1,…,wd) 与它等长,偏置 b 是一个数。一个神经元计算

n=∑i=1dwixi+b,o=tanh⁡(n).

权重决定不同输入对结果的影响,偏置负责平移阈值。tanh 把输出限制在 -1 到 1 之间,并引入非线性。如果所有层都只有仿射变换,那么 (xW1+b1)W2+b2=x(W1W2)+(b1W2+b2),多层仍能合并成一层。非线性让叠层具有不同的表达能力。

沿用课程的单神经元示例:x1=2,x2=0,w1=−3,w2=1,b≈6.881373587。于是 n≈0.881373587,o≈0.707106781,局部导数 1−o2=0.5。从输出设梯度 1 往回传,得到

∂o∂w1=x1⋅0.5=1,∂o∂w2=x2⋅0.5=0,∂o∂b=0.5.

若把输入也当作待求导变量,∂o/∂x1=w1×0.5=−1.5,∂o/∂x2=w2×0.5=0.5。两个乘法输出,以及它们相加后的中间和,对最终输出的梯度都是 0.5;加法把收到的同一个梯度分别传回两支。这对应先手动给输出梯度赋 1,再逐个调用局部反向函数的过程。

第二个权重的梯度为零,因为这一条记录的第二个输入为零。这不表示权重永远没有作用,换一条记录它就可能收到梯度。课程神经元实例

3.2 从一个神经元到一层,再到 MLP ​

一层包含多个神经元:每个神经元都接收相同输入,但有各自独立的参数。输入长度为 3,一层有 4 个神经元,输出就是长度 4 的列表。再把它交给下一层,逐层继续。

3→4→4→1 多层感知机的数据流
可切换原始尺寸,滚动查看细节

查看大图

尺寸读取中

适合窗口:完整显示图片,不放大超过原始尺寸。 按 Esc 关闭。

MLP 是 multilayer perceptron,即多层感知机。本书 MLP([3,4,4,1]) 的参数数量可以直接数出来。第一层每个神经元有 3 个权重、1 个偏置,共 4(3+1)=16 个参数;第二层共 4(4+1)=20 个;最后一层共 1(4+1)=5 个,总共 41。

python
class Neuron:
    def __call__(self, x):
        if len(x) != len(self.w):
            raise ValueError('Input width mismatch')
        return sum((w*v for w, v in zip(self.w, x)), self.b).tanh()

输入长度检查是本书添加的防错。zip 会在较短的列表结束时停止,不检查长度的话,输错维度可能悄悄丢掉部分输入。另一个小设计是每层始终返回列表,包括只有一个神经元时。课程用单元素时返回标量的简便接口;它在结尾方便,但若中间层只有一个神经元,需要谨慎处理下层仍期待序列的问题。

parameters() 将每层、每个神经元的权重和偏置摊平成一个列表。优化器不需要知道某个参数属于哪一层,只需把它的数值按梯度改动。它们必须是原有 Value 的引用,不能新建一套相同数值的对象,否则前向图和更新对象会脱节。

3.3 用一个数表达 “预测得多差” ​

本实验使用课程后半 notebook 的四条三维输入与目标 1,−1,−1,1。设第 i 条预测为 y^i,目标为 yi,损失取平方误差之和:

L=∑i=14(y^i−yi)2.

平方使正负误差都产生非负惩罚,并让较大误差受罚更多。对一个预测值的导数是 2(y^i−yi),所以偏大时更新倾向于降低预测,偏小时相反。多个样本共用同一套参数;梯度经过不同样本的图分支,最终累加到同一个权重节点。

这时可以画出完整损失图,理解参数为何连接许多路径。但图很快会变大。课程的 Graphviz 工具先遍历可达节点,再为每个数值画矩形、运算画小节点,并显示 data 与 grad。它是调试显示,不参与求导。实际大模型通常观察层级形状和统计量,而不是打印每次标量加法。

平方误差 “之和” 与 “均值” 相差样本数。若把这里改为均值,同一个学习率下梯度会缩小四倍。比较训练代码时,先核对损失的归约方式,再判断学习率是否 “太小”。

3.4 四步循环,每一步都能出错 ​

python
for step in range(100):
    loss = sum((model(x)[0] - y)**2 for x, y in zip(xs, ys))
    loss.backward()
    for parameter in model.parameters():
        parameter.data -= 0.05 * parameter.grad

本书引擎会在 backward 内清空可达梯度,所以上面不另外清零;使用课程引擎时,应在 loss.backward() 前循环执行 p.grad=0.0。每步都重新前向构图,因为参数已经改变,旧图上的数值和导数不再代表新的模型。

先做前向,得出当前预测和损失;再清空上次累积的梯度;然后反向算新梯度;最后更新参数。这四步次序不要靠记口号,要检查各步读写什么。更新发生在反向之前,会让一些简化引擎读到错误的参数值。清零发生在反向之后,会把刚算好的梯度抹掉。忘了清零,则旧批次与新批次的贡献混在一起,训练算法已经变了。

在随机初始化上,不宜让同层神经元的入边、出边以及优化器状态都保持完全相同的置换对称;在这个条件下,它们会收到相同梯度,并一直相同。仅仅入边权重相同还不足以下这个结论,因为不同的出边权重可能传回不同梯度。偏置统一为零没有这个问题,因为不同的随机权重已经打破了神经元之间的对称。本实验使用 random.Random(42) 生成均匀分布的权重,偏置为零。

3.5 真实小实验与它没有证明的事 ​

运行 python examples/part1/scalar_engine.py,本书环境 Python 3.12.14 的实际记录如下:

检查项结果
参数数41
更新前平方误差之和5.278844
100 次更新后平方误差之和0.010930
四条预测0.9653,-0.9375,-0.9562,0.9376

这些数字证明,这个具体实现可以把四条训练数据拟合得更好。它没有证明模型学到了可推广规律,因为没有拿新数据测试。输出层用了 tanh,有限输入时输出不会恰好等于 ±1;为了越来越接近这些目标,预激活可能需要更大的绝对值,同时梯度越来越小。后面分类任务用 logits 和交叉熵,会有更合适的输出解释。

如果损失没有下降,先固定一小批数据反复训练。连少量样本都拟合不了,优先排查标签、参数是否收到梯度、参数更新是否实际发生和学习率。不要立即加深网络掩盖代码错误。

3.6 notebook 与完整 micrograd 库有什么不同 ​

课程后半 notebook 使用 tanh 神经元和四点平方误差演示。独立 micrograd 库 则提供 Module 基类、zero_grad、隐藏层 ReLU 与线性输出层。它的 demo notebook 使用双月形二维数据和最大间隔损失:

Ldata=1m∑imax(0,1−yisi),L=Ldata+α∑jθj2.

这里 m 是样本数,yi∈{−1,1},si 是线性输出分数,α 是正则化强度。当正确类别分数满足 yisi≥1 时,该样本的间隔损失为零。正则项的梯度是 2αθj,会推动参数向零缩小。

demo 还用规则网格采样二维平面,将每个网格点的分数正负画成决策边界。那张图回答模型在哪里预测哪类;训练准确率只是数对了多少条,两者不能互相代替。本书没有声称复现该 demo 的双月形训练结果,前面的数值只来自明确记录的四点实验。

练习与解答 ​

补充题一:把最后一层改成两个神经元,参数数会是多少?前两层仍为 36,最后一层变成 2(4+1)=10,总共 46。目标与损失也必须改成两维,不能仅改变模型接口。

补充题二:若入边、出边、偏置和优化器状态都使同层神经元完全对称,训练会自动让它们分工吗?在确定性计算下不会,两个神经元会接收相同梯度。写一段程序比较它们每一步的权重,是比 “随机性很重要” 更具体的检查。

补充题三:故意删掉参数更新,再跑 100 步。每次构图和求导都可以正常完成,但损失不应改善。这个反例区分了 “能求导” 与 “真的在学习”。

来源与下一步 ​

本章完整覆盖 第一讲后半 notebook 的神经元、层、MLP、参数收集、四条数据、训练与预测单元;另读取固定版本 micrograd 的 engine.py、nn.py、测试和 demo,解释实现差异。接下来仍然是 “模型、损失、梯度、更新” 这个闭环,但数据变成字符,输出变成一组概率,标量列表则换成张量。

保留各书来源与许可 · 示例运行条件以正文为准