1. 剥掉包装:人工神经网络其实就是一摞可调参数的筛子
1.1 先把名词拆开,别被“神经网络”四个字吓住
“人工神经网络”这个说法自带一层神秘滤镜,很多人第一次听到会下意识觉得这是某种仿生黑科技,得先修完神经科学才能碰。实际情况恰恰相反:它是一套极其朴素的数学结构,核心动作只有三个——乘加、过函数、比误差。把输入数字乘上几个权重、加在一起、再经过一个非线性函数压一下,输出一个结果;拿结果和正确答案比一比,算出差多少;然后按差值的方向把权重拧一拧。就这么三步,反复几万次,网络就“学会”了。
我在社区里回答过不少类似问题,问的人背景五花八门,有做后端想转方向的,有做数据分析想往建模走的,也有纯粹好奇的。他们的共同点不是数学不好,而是被术语墙挡住了:张量、反向传播、梯度消失、激活函数,每一个词单独看都懂,串起来就懵。这篇内容要做的事,就是把墙拆掉,从最底层的数字运算开始,一层一层搭到能跑起来的完整网络。看完之后你应该能做到三件事:手算一个单神经元的前向传播;说清楚“训练”这个词在数学上到底指什么;用几十行代码从零实现一个能解 XOR 的两层网络,不依赖任何深度学习框架。
适合谁看?完全没接触过神经网络的零基础读者能顺着读下来,因为所有公式我都配了具体数字;学过但没学透的人可以重点看第 4 章和第 5 章,那里有反向传播的手工推导和完整的代码实现;想带新人的老手可以把第 3 章的手算部分直接拿去当教学材料。整篇内容不需要微积分之外的前置知识,链式法则我会现讲现用,不假设你记得住。
1.2 我为什么坚持让新手先手算一遍再碰框架
现在入门太方便了,三行代码就能定义一个网络,几十行就能在标准数据集上刷出不错的准确率。方便是好事,但也带来一个副作用:很多人跑通了却不知道自己在跑什么。我见过不止一个同学,模型不收敛的时候只会调学习率、换优化器、加层数,像在老虎机前面拉杆,完全靠运气。
我自己的做法是反过来的:新东西先手算小数,再写小代码,最后才上框架。手算的意义不在于算得多准,而在于强迫你把每一步的维度、每一处运算都走一遍。举个具体例子,很多人第一次写反向传播会卡在“为什么这里要转置矩阵”上,框架里自动求导一包,这问题就被藏起来了,等到哪天要做自定义算子、要改损失函数、要排查梯度为 NaN 的时候,藏起来的问题会一次性全冒出来。
注意:手算不是目的,是手段。你不需要手算一个 100 层的网络,那是自虐。手算的价值集中在单神经元和两层小网络这两个规模上,把这两个规模吃透,剩下的都是同一套逻辑的重复。
另外一个经常被忽略的点是:神经网络本质上是个函数拟合器。它不“理解”任何东西,只是在用一堆参数逼近一个从输入到输出的映射关系。这句话听起来平淡,但它能帮你避开很多莫名其妙的预期——比如为什么会话模型会一本正经地胡说八道,为什么模型在训练集上完美、换个分布就崩。它不是在想,它是在插值。
1.3 一个筛子的类比:信号怎么被一层层过滤
如果让我用一句话解释神经网络在干什么,我会说:它把输入信号倒进一摞筛子,每层筛子孔的大小和形状都是可调的,调节的目标是让最后漏出来的东西尽量接近你想要的。
这个类比值得展开讲,因为它几乎对上了每一个技术名词。
第一层筛子可能只关心“左边重还是右边重”这种粗粒度特征,它的孔比较大,过筛的是一些基础模式。第二层筛子接收第一层的输出,开始组合出稍微复杂一点的模式,比如“左上和右下同时亮”——这正是 XOR 这类问题需要的。层数越多,能组合出的模式越抽象。图像识别里的经典现象就是这么来的:浅层学到边缘和色块,中层学到纹理和部件,深层才学到“这是一只猫”。
权重决定每个孔对输入信号的敏感程度。某个输入重要,权重就大,筛子对这个方向就敏感;不重要,权重就趋近于零,信号基本被忽略。偏置相当于把筛子整体挪一挪位置,控制这个神经元“默认有多容易被激活”。有些神经元天生迟钝,需要很强的输入才响应;有些很敏感,稍微有点信号就亮。这个“迟钝程度”就是偏置调的。
训练则是另一回事:你不知道每种形状的筛子该开多大孔,于是拿一堆已知答案的样本反复试,试完看漏出来的东西和正确答案差多少,再根据差值微调每个孔的尺寸。调一次改一点点,调几千几万次,筛子就成型了。
这个类比有一个地方需要修正,避免你产生错误直觉:筛子的孔不是独立存在的,它们互相耦合。改第一层某个权重,会顺带改变第二层看到的东西,进而改变第三层的输出。这种连锁反应正是为什么需要反向传播来算梯度——你不能孤立地评价任何一个权重的好坏,只能在一个完整的前向过程结束后,回头把责任一层层分下去。
2. 从生物神经元到数学神经元:一次必要的简化
2.1 生物侧发生了什么,我们只抄了哪一部分
真实的生物神经元干的事情比我们模型里写的复杂几个数量级。树突接收来自上千个突触的信号,这些信号有兴奋性的也有抑制性的,在胞体内做时间和空间上的整合,达到轴突丘的阈值才触发动作电位,然后沿轴突传导、在突触末端释放神经递质。整个过程中还牵涉离子通道动力学、神经调质、可塑性规则,随便挑一个分支都够研究一辈子。
人工神经元只抄了一个极简的骨架:多个输入按不同权重汇聚、求和、超过某个条件就产生输出。就这些。没有离子通道,没有时间动力学,没有脉冲发放频率,没有任何一个环节试图还原生物细节。
这个取舍不是偷懒,是有意的。上世纪中叶那批研究者一开始确实想做“大脑模型”,但很快发现两个问题:一是生物神经元的细节太多,算不动也说不清;二是当目标只是做模式识别和函数逼近时,极简模型已经足够好用。于是方向就从“模拟大脑”转向“构建可训练的计算单元”,名字里的“神经”两个字更多是历史遗留,你完全可以把它理解成“加权求和单元”。
我在带新人的时候会强调一句:不要把神经网络的效果归因于"它像大脑"。它现在能做的事,靠的是一层层非线性变换加上大规模参数优化,和生物智能的运作机制之间的关系,目前还停留在启发层面。把它当成一个数学工具,心态会稳很多,也更容易定位问题。
理解了这一点,后面看到“注意力机制”“残差连接”这些听起来很生物的词,你就不会被名字带偏,会习惯性地去问:它实际做的是什么运算?
2.2 数学神经元的三件套:加权求和、加偏置、过激活
一个标准的人工神经元,运算可以拆成三步,我用具体数字走一遍。假设这个神经元有两个输入,x1 = 1.0,x2 = 0.5;两个权重 w1 = 0.5,w2 = 0.3;偏置 b = 0.1。
第一步,加权求和:
z = w1*x1 + w2*x2 + b = 0.5*1.0 + 0.3*0.5 + 0.1 = 0.5 + 0.15 + 0.1 = 0.75这一步的含义很直白:每个输入按重要程度打折后加起来,再加一个基准值。权重为正是兴奋,为负是抑制,权重接近零等于这个输入基本被忽略。
第二步,过激活函数。用最经典的 sigmoid:
a = σ(z) = 1 / (1 + e^(-z)) = 1 / (1 + e^(-0.75)) = 1 / (1 + 0.4724) = 0.6792sigmoid 把任意实数压到 (0, 1) 区间。z 很大时输出趋近 1,z 很小(负得很多)时趋近 0,z = 0 时正好 0.5。你可以把它理解成一个“软化版的门”:不是硬邦邦地开或关,而是连续地给出一个 0 到 1 之间的“开启程度”。
第三步,输出传递。这个 0.6792 就是神经元的输出,它会成为下一层神经元的输入之一。
三步合起来,一个神经元就干了一件事:把多个输入按权重混合,得到一个标量,再把这个标量重新映射到一个合适的范围。整个网络就是成千上万个这样的单元按层组织起来,前一层所有神经元的输出,全连接到后一层每个神经元的输入端。
这里有个容易搞混的细节:偏置 b 不是可选的装饰。设想所有输入都是 0,如果没有偏置,z 恒为 0,经过 sigmoid 后输出恒为 0.5,这个神经元就废了。有了偏置,即使输入全零,神经元仍然可以输出不同的值。从几何角度看,权重决定超平面的方向,偏置决定这个平面离原点多远。少了偏置,所有决策边界都必须过原点,表达能力会被砍掉一大截。这也是为什么你在代码里几乎总能见到bias=True这个默认参数。
2.3 激活函数必须非线性:这条红线不能碰
初学者最常见的疑问之一是:既然加权求和已经有了,为什么不直接把 z 输出,非要套一个 sigmoid?
答案藏在一个代数事实里:线性运算的复合仍然是线性运算。假设你不用激活函数,第一层算 z1 = W1·x + b1,第二层算 z2 = W2·z1 + b2,代入展开:
z2 = W2·(W1·x + b1) + b2 = (W2·W1)·x + (W2·b1 + b2)括号里的东西都是常数矩阵,把它记作 W' 和 b',你会发现 z2 = W'·x + b'——十层网络塌缩成一层。堆多少层都没意义,表达能力等价于一个线性回归。这就是为什么非线性激活是深度网络的命门,没有它,“深度”只是个营销词。
这也能直接解释为什么单层感知机解不了 XOR。XOR 的四个样本点在二维平面上是 (0,0)→0、(0,1)→1、(1,0)→1、(1,1)→0,你画一画就知道,没有任何一条直线能把两个类别分开。而线性分类器的决策边界就是一条直线,所以它必然失败。这个坑在历史上真实卡了研究者十几年,直到多层网络 + 非线性激活的组合出现才被绕过去。第 5 章我会用代码让你亲眼看到,同样的数据,加一个隐藏层就能解。
常用激活函数就这么几个,各自的脾气值得提前摸清:
| 激活函数 | 表达式 | 输出范围 | 主要特点 | 典型问题 |
|---|---|---|---|---|
| Sigmoid | 1/(1+e^-z) | (0,1) | 输出像概率,平滑易解释 | 两端饱和,梯度趋近 0 |
| Tanh | (e^z-e^-z)/(e^z+e^-z) | (-1,1) | 零中心,收敛通常快于 sigmoid | 同样存在两端饱和 |
| ReLU | max(0, z) | [0,+∞) | 计算极快,正区间不饱和 | 负区间梯度为 0,可能“死亡” |
| Leaky ReLU | max(αz, z) | (-∞,+∞) | 给负区间留一点梯度 | 多一个超参 α |
| Softmax | e^zi/Σe^zj | (0,1) 且和为 1 | 多分类输出概率分布 | 只在输出层用 |
表格里“饱和”这个词值得多说两句。sigmoid 在 z 很大或很小时,曲线几乎是平的,导数接近 0。而反向传播是靠导数把误差往回传的,导数接近 0 意味着误差传不回去,浅层的权重几乎不更新——这就是经典的梯度消失。层数一多,这个问题会指数级放大。ReLU 之所以在过去十年成为默认选择,很大程度上就是因为它在大片区域内导数恒为 1,误差能顺畅地传回浅层。
注意:激活函数的选择没有绝对最优解。做二分类输出层用 sigmoid,多分类输出层用 softmax,中间层现代网络基本默认 ReLU 及其变体。如果你的网络训练不动、损失完全卡住,先把中间层的 sigmoid 换成 ReLU 试一次,这个动作能救回相当一部分“莫名其妙的失败”。
3. 手算一个网络:前向传播到底在算什么
3.1 第一个算例:两输入一输出的完整数字
上一节算了一个神经元。现在把它放到一个小网络里,看看“层”是怎么接起来的。
设想一个结构:输入层 2 个节点,隐藏层 3 个节点,输出层 1 个节点。输入是 x = [1.0, 0.5]。隐藏层三个神经元各自的权重和偏置我随手给一组:
- 神经元 h1:w = [0.5, 0.3],b = 0.1
- 神经元 h2:w = [-0.2, 0.8],b = -0.3
- 神经元 h3:w = [0.1, -0.4],b = 0.2
逐个算。h1 我们在上一节已经算过,z1 = 0.75,a1 = 0.6792。
h2:
z2 = -0.2*1.0 + 0.8*0.5 + (-0.3) = -0.2 + 0.4 - 0.3 = -0.1 a2 = σ(-0.1) = 1/(1+e^0.1) = 1/(1+1.1052) = 0.4750h3:
z3 = 0.1*1.0 + (-0.4)*0.5 + 0.2 = 0.1 - 0.2 + 0.2 = 0.1 a3 = σ(0.1) = 1/(1+e^-0.1) = 1/(1+0.9048) = 0.5249隐藏层输出是 a = [0.6792, 0.4750, 0.5249]。这个三元组现在就是输出层那个神经元的“输入”。
输出层神经元设权重 w = [0.6, -0.5, 0.4],b = -0.1:
z_out = 0.6*0.6792 + (-0.5)*0.4750 + 0.4*0.5249 - 0.1 = 0.4075 - 0.2375 + 0.2100 - 0.1 = 0.2800 a_out = σ(0.28) = 1/(1+e^-0.28) = 1/(1+0.7558) = 0.5695完整的前向传播就走完了,从 [1.0, 0.5] 得到 0.5695。
这个数字有什么意义?如果你的任务是二分类,它表示模型认为“正类”的概率是 56.95%,还不到 0.5 以上多少,是个很没底气的判断。和真实标签一比,比如真实标签是 1,误差就是 1 - 0.5695 = 0.4305,训练要做的事就是调整所有那些权重和偏置,让这个输出往 1 靠。
手算这一遍的价值在于:你会亲眼看到每一层的输出范围都被 sigmoid 限制住了。隐藏层的三个值全在 0.47 到 0.68 之间,输出层在 0.57。这意味着后面接的层,输入永远在 (0,1) 这个小区间里,这会引出后面讲梯度消失时的一个关键结论。
3.2 从单个神经元到一层:向量化怎么省时间
实际实现当然不会用 for 循环一个一个神经元地算。上面那三个隐藏神经元,可以一次性算完:
z = W·x + b其中 W 是 3×2 的矩阵(3 个神经元,每个 2 个权重),x 是 2×1 的列向量,b 是 3×1。W 的三行正好是 h1、h2、h3 的权重:
W = [[ 0.5, 0.3], [-0.2, 0.8], [ 0.1, -0.4]] b = [ 0.1, -0.3, 0.2]矩阵乘法一次算完三个 z,然后a = sigmoid(z)逐元素作用。这就是向量化:把“对每个神经元做一次相同的运算”变成“对整层做一次矩阵运算”。
这件事不只是代码更短。在 CPU 和 GPU 上,矩阵乘法都经过了极度优化,能利用多核和缓存;而 Python 层面的 for 循环每次迭代都有解释器开销,慢一到两个数量级很常见。所以有句话在实现圈子里流传很广:先用循环写对,再用矩阵写快,最后对比两者结果是否一致。这个流程我自己一直在用,因为它同时兼顾了正确性和性能。
再往上一层,还可以把多个样本也堆成矩阵。假设一个批次有 N 个样本,输入矩阵 X 是 N×2,那么:
Z = X·Wᵀ + b # N×3 A = sigmoid(Z) # N×3一个矩阵乘法处理整个批次,这就是批处理的底层形态。你会看到Wᵀ这里出现了转置,原因是习惯上把 W 定义为“输出维度×输入维度”,而 X 是“样本数×特征数”,两者要对齐就得转一下。这类形状对齐问题几乎每个手写实现的人都会栽一次,我在第 5 章会专门列一个约定。
3.3 参数量怎么数,层数怎么算
有两组数字初学者经常搞混:层数和参数量。
层数按惯例只数有权重的层。一个 2-3-1 的网络,叫“两层网络”或者“一个隐藏层的网络”,因为输入层只是把原始数据摆在那里,没有任何运算。有人把它叫三层,那是把输入层也算进去了,容易和别人对不上,建议统一按“有权重的层数”来数。
参数量就是把所有可训练的权重和偏置加起来。还是 2-3-1:
- 第一层:3 个神经元 × 2 个权重 = 6,加上 3 个偏置 = 9
- 第二层:1 个神经元 × 3 个权重 = 3,加上 1 个偏置 = 4
- 合计 13 个参数
用公式写,从 n_in 到 n_out 的全连接层参数量是n_in × n_out + n_out。
这个公式在估算模型规模时非常有用。比如一个 1000 维输入、隐藏层 512、输出 10 的网络,第一层就有 1000×512+512 = 512,512 个参数,第二层 512×10+10 = 5,130,总共五十多万。而同样结构如果输入是 224×224×3 的图片(展平后约 15 万维),第一层直接飙到七千多万参数——这就是为什么图像任务不能简单用全连接层堆,得靠卷积来共享权重。理解参数量从哪来,是判断“这个模型我跑不跑得动”的第一步。
| 网络结构 | 第一层参数 | 第二层参数 | 合计 |
|---|---|---|---|
| 2-3-1 | 9 | 4 | 13 |
| 784-128-10 | 100,480 | 1,290 | 101,770 |
| 1000-512-10 | 512,512 | 5,130 | 517,642 |
| 150528-512-10 | 77,070,336 | 5,130 | 77,075,466 |
看到最后一行那个七千七百万了吗?这就是为什么早期的图像模型参数量动辄上亿,也是为什么卷积神经网络的出现被视作一次结构性突破——它靠局部连接和权重共享,把参数量压下去好几个数量级。
4. 网络怎么“学会”东西:损失、梯度与反向传播
4.1 损失函数:给错误打分的那把尺子
前向传播算出预测值 a,接下来必须回答一个问题:这个预测到底有多差?把这个问题量化成一个数字,就是损失函数。
最直观的是均方误差(MSE):
L = (1/N) · Σ (a_i - y_i)²它把每个样本的预测和真实值相减、平方、求平均。平方的作用是让正负误差不互相抵消,同时放大大误差的惩罚——差 2 的样本贡献 4,差 4 的样本贡献 16,模型会更用力地去修那些离谱的预测。
二分类任务更常用的是交叉熵(配合 sigmoid 输出):
L = -(1/N) · Σ [ y_i·log(a_i) + (1-y_i)·log(1-a_i) ]这个公式初看有点绕,拆开看很干净。当 y = 1 时,第二项消失,L = -log(a)。a 越接近 1,-log(a) 越接近 0,损失越小;a 接近 0,-log(a) 会迅速飙到很大。当 y = 0 时对称地看第一项。所以它惩罚的是“对自己错误的判断过于自信”:模型说 99% 是正类,结果真是负类,那损失会大得吓人。
为什么二分类更偏爱交叉熵而不是 MSE?一个实操层面的理由是梯度形态。sigmoid + MSE 的组合,梯度里会带一个 σ'(z) 因子,当输出饱和(a 接近 0 或 1)时这个因子趋近 0,梯度几乎消失,模型在“自信地错”的时候反而学不动。而 sigmoid + 交叉熵的梯度会把这个因子约掉,简化成a - y,误差多大梯度就多大,学得干脆。
这一点我在实际项目里验证过不止一次。早期做一个二分类的小任务,用 MSE 训练,损失降到一个平台期就再也不动了,看预测值全是 0.98 这种自信的错误。换成交叉熵,同样的网络结构和学习率,二十轮之内就冲过去了。这个坑值得记在心里:输出层的激活函数和损失函数是绑定的一对,sigmoid 配交叉熵,softmax 配多类交叉熵,别乱搭。
4.2 梯度下降:蒙眼下坡的三板斧
有了损失,问题就变成一个优化问题:在一大片参数构成的超高维空间里,找到让损失最小的那个点。
梯度下降的思路,用一句话说就是:站在当前点上,摸清哪个方向最陡,往反方向迈一小步,重复。就像蒙着眼站在山坡上,你看不见全局,但能用脚感受到哪边往下倾斜,然后朝那个方向走。
数学上,“哪个方向最陡”由梯度给出。梯度是一个向量,每个分量是损失对某个参数的偏导数,指向损失上升最快的方向。所以要下降,就往梯度的反方向走:
w_new = w_old - lr · ∂L/∂w这里的 lr 是学习率,控制每步迈多大。
实际操作中,计算梯度用的是哪个范围的样本,分出了三种变体:
| 方式 | 每步用多少样本 | 优点 | 缺点 |
|---|---|---|---|
| 批量梯度下降 | 全部 | 梯度方向准,下降平稳 | 数据量大时一步都走不动 |
| 随机梯度下降 | 1 个 | 极快,有随机性可跳出局部坑 | 方向抖得厉害,损失曲线很毛 |
| 小批量梯度下降 | 32~256 个 | 兼顾速度与稳定 | 需要选 batch size |
实践中几乎清一色用第三种。batch size 的选择有讲究:太小(比如 4)梯度噪声大,训练曲线抖;太大(比如 4096)显存吃紧,而且泛化性能有时反而下降。32 到 256 是比较安全的起点,显存富余就往大调,训练不稳就往小调。这是经验值,不是定理,具体还得看你自己的数据。
4.3 反向传播:链式法则的一次漂亮套用
现在到整个体系里最容易被讲玄乎的部分。反向传播听着高深,本质就是链式法则的工程化实现,目的是高效地算出损失对每一个参数的偏导数。
先用一个单神经元把链条走完。设:
z = w·x + b a = σ(z) L = ½(a - y)²要求 ∂L/∂w。按链式法则:
∂L/∂w = ∂L/∂a · ∂a/∂z · ∂z/∂w三段分别算:
∂L/∂a = a - y ∂a/∂z = σ'(z) = a·(1-a) ∂z/∂w = x合起来:
∂L/∂w = (a - y) · a(1-a) · x代入第 3 章的数字:w = 0.5,x = 1.0,y = 1.0,a = 0.6792。
a - y = -0.3208 a(1-a) = 0.6792 × 0.3208 = 0.2179 ∂L/∂w = -0.3208 × 0.2179 × 1.0 = -0.0699取学习率 0.1 更新:
w_new = 0.5 - 0.1 × (-0.0699) = 0.50699验证一下损失是否真的降了。更新后用同样的 x 和 b 重新前向:
z = 0.50699 × 1.0 + 0.3 × 0.5 + 0.1 = 0.75699 a = σ(0.75699) = 1/(1+e^-0.757) ≈ 0.6807 L = ½(0.6807 - 1)² = ½ × 0.10196 ≈ 0.05098更新前的损失是 ½ × 0.3208² ≈ 0.05146。降了大约 0.00048。降幅很小,但方向是对的——这正是“一小步”的含义。步长小是为了保证每次都往好的方向走,代价是慢。
为什么叫“反向”?因为在多层网络里,梯度是从输出层往输入层倒着算的。输出层先算出误差,然后把这个误差按权重的比例分配给上一层,上一层再往下分。这样做的好处是计算复用:每个神经元只需要把自己收到的误差汇总一次,再乘上本地导数往前传,整体复杂度跟前向传播是一个量级。如果对每个参数单独做数值微分(把参数加个极小数、重跑一遍前向、看损失变化),参数量一多就彻底跑不动了。
分配这个词值得再强调一下。假设输出层某个神经元的误差是 e,它和上一层的三个神经元分别有连线,权重是 w1、w2、w3。那么上一层这三个神经元各自“该背多少锅”,就是按 w 的大小来分的——权重大的连线上传过去的误差也多。这个机制很符合直觉:一根线越重要,它的预测偏差造成的后果就越严重。
4.4 学习率:最容易被忽略也最容易翻车的旋钮
学习率看起来是最无聊的超参数,一个数字而已。但在我见过的训练失败案例里,它的嫌疑程度排得上前二。
学习率太大,每步迈得太远,会直接跨过谷底,在两侧来回蹦,损失曲线呈现剧烈的锯齿甚至越来越大,最终发散成 NaN。学习率太小,方向是对的,但走得像蜗牛,跑一万轮损失还在高位晃,你会误以为模型没救,其实是没走完。
判断方法很直接:盯着损失曲线看前几十轮。正常的曲线应该是快速下降然后逐渐变缓。如果一开始就上蹿下跳、甚至往上走,学习率砍到 1/10;如果曲线平得像一条直线,学习率乘 10 试试。
几个常见的经验起点:
- 手写小网络、sigmoid/tanh 激活:0.1 到 0.5
- 现代网络配 ReLU:0.001 到 0.01
- 配合 Adam 这类自适应优化器:0.001 是默认值,出错概率低
Adam 值得单独提一句。它给每个参数维护了各自的学习率,根据历史梯度自动调节步长,所以对初始学习率不那么敏感。新手起步阶段用 Adam 会省掉不少调参的折磨,代价是最终精度有时不如调好的 SGD。
还有一个技巧是学习率衰减:训练初期用大步快走,后期用小步精修。常见做法是每过若干轮把学习率乘 0.1,或者用余弦退火平滑下降。道理跟找东西一样,大范围粗找的时候动作可以大,最后确认位置的时候得轻手轻脚。
我踩过最典型的一次坑:一个三层网络怎么都训不动,损失在 0.68 附近纹丝不动。排查了两小时,最后发现学习率写成了 0.0001,比应该用的值小了三个数量级。改回 0.01 之后三十轮就收敛了。教训是:损失完全不动的时候,先怀疑学习率和数据标准化,再怀疑模型结构,后面这两个出问题的概率低得多。
5. 用 numpy 从零撸一个能跑的网络
5.1 形状约定:先把张量尺寸对齐再写代码
我见过太多人卡在形状错误上,报错信息是“shapes not aligned",然后翻来覆去试转置。避免这种情况的办法只有一个:在动手写代码之前,把所有张量的形状写在纸上。
我用的约定是这样的:
- 输入 X:形状 (N, D_in),N 是样本数,D_in 是特征数
- 权重 W1:形状 (D_in, H),H 是隐藏层宽度
- 偏置 b1:形状 (1, H)
- 隐藏层激活 A1:(N, H)
- 权重 W2:(H, D_out)
- 偏置 b2:(1, D_out)
- 输出 A2:(N, D_out)
这套约定的好处是前向传播写成A1 = act(X @ W1 + b1),@是矩阵乘法,形状自然对齐:(N, D_in) @ (D_in, H) = (N, H)。偏置 (1, H) 会自动广播到每一行,这是 numpy 的广播机制,不用手动复制。
反向传播里的矩阵形状我也提前写下来,避免临场推:
- dA2:(N, D_out),和 A2 同形
- dW2:(H, D_out),和 W2 同形
- dA1:(N, H)
- dW1:(D_in, H)
原则很简单:每个梯度的形状必须和对应参数完全一致。如果不一致,那一定是某处转置搞错了,不用猜,回去核对形状。
顺带一提,我习惯把输入数据的每一行当作一个样本,这在机器学习圈里是主流约定。但也有些老代码用列向量,一行一个特征。接手别人代码时,第一件事就是确认这个约定,不然会算出一堆看着合理实际全错的结果。
5.2 前向与反向的核心几行
下面这段代码不依赖任何深度学习框架,只用 numpy。任务是 XOR:
import numpy as np np.random.seed(42) # 数据:四个样本,两个特征 X = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]]) y = np.array([[0.0], [1.0], [1.0], [0.0]]) def sigmoid(z): return 1.0 / (1.0 + np.exp(-z)) def sigmoid_grad(a): # 输入是激活后的值 a,不是 z return a * (1.0 - a) # 参数初始化 W1 = np.random.randn(2, 4) * 0.5 b1 = np.zeros((1, 4)) W2 = np.random.randn(4, 1) * 0.5 b2 = np.zeros((1, 1)) lr = 0.5 n = X.shape[0] for epoch in range(50000): # ---- 前向 ---- z1 = X @ W1 + b1 # (4, 4) a1 = sigmoid(z1) z2 = a1 @ W2 + b2 # (4, 1) a2 = sigmoid(z2) loss = 0.5 * np.mean((a2 - y) ** 2) # ---- 反向 ---- # 输出层 d2 = (a2 - y) * sigmoid_grad(a2) / n # (4, 1) dW2 = a1.T @ d2 # (4, 1) db2 = d2.sum(axis=0, keepdims=True) # (1, 1) # 隐藏层 d1 = (d2 @ W2.T) * sigmoid_grad(a1) # (4, 4) dW1 = X.T @ d1 # (2, 4) db1 = d1.sum(axis=0, keepdims=True) # (1, 4) # ---- 更新 ---- W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 if epoch % 5000 == 0: print(f"epoch {epoch:6d} loss {loss:.8f}") print("\n最终预测:") print(np.round(a2, 4).ravel()) print("真实标签:", y.ravel().astype(int))反向那几行是全篇的重点,我逐行解释一下意图。
d2 = (a2 - y) * sigmoid_grad(a2) / n—— 这是输出层的误差信号。a2 - y是损失对激活值的导数(MSE 的情况),sigmoid_grad是激活函数在本地贡献的导数,除以 n 是因为损失定义里带了均值。
dW2 = a1.T @ d2—— 形状对账:(4,4).T @ (4,1) = (4,1),和 W2 一致。这一行的含义是“每个权重该承担多少责任”,等于该权重连接的上游激活值乘以该权重连接的下游误差。所以公式是“上游激活的转置 乘 下游误差”,这个模式在几乎所有反向传播代码里都能看到。
d1 = (d2 @ W2.T) * sigmoid_grad(a1)—— 这是误差回传到隐藏层。d2 @ W2.T把输出层的误差按权重比例分发回隐藏层四个节点上,(4,1) @ (1,4) = (4,4),对应隐藏层四个节点各自的误差。再乘上本地的 sigmoid 导数,得到最终的梯度信号。
db1 = d1.sum(axis=0, keepdims=True)—— 偏置的梯度比较特殊,因为它对每个样本都产生了同样的影响,所以要把所有样本的误差加起来。这一点经常被写错成d1.mean()或者忘记求和维度。
5.3 用 XOR 做验收:线性模型做不做得到
跑一下这段代码,你会看到损失从初始的零点几一路降到小数点后好几位,最终预测大概是[0.01, 0.99, 0.99, 0.01]这个量级,四舍五入后和标签完全对得上。
这个结果本身平平无奇,但它证明了一件重要的事:刚才那二十几行代码,做到了一个单层感知机永远做不到的事。第 2 章说过,XOR 的四个点在平面上无法用一条直线分开,线性模型无论怎么训练都只能达到 75% 的准确率——它必须做错一个。隐藏层加上非线性激活,把输入映射到了一个四维空间,在那个空间里四个点变得线性可分了,输出层才得以完成分类。
想更直观地感受这个差距,你可以把隐藏层规模改成 0(也就是直接W2接X),跑一遍,你会发现损失卡在 0.125 左右降不下去。这个 0.125 就是“必然错一个点”对应的最小损失。这个小实验我自己做过,给不少新人演示过,效果比讲十遍理论都好。
还有个小细节值得注意:初始化用了
randn(2,4) * 0.5,乘 0.5 是为了把初始权重压小一点。如果把权重初始化得太大,sigmoid 一上来就饱和,梯度几乎为零,训练会异常艰难。这个经验在更深的网络里是必须遵守的纪律,只是在小网络上容错高一些,不容易暴露出来。
5.4 训练日志怎么读
上面代码每 5000 轮打印一次损失。这些数字不是用来欣赏的,是诊断工具。我一般的读法是这样:
前几千轮应该能看到明显的下降,比如从 0.15 掉到 0.02。如果没有,说明学习率、初始化或者数据有问题,先别急着训练五万轮,先改这两个。
中段下降速度变慢,这是正常的,说明已经接近这个结构能做到的水平,在做精修。
后段趋于平坦,损失下降到小数点后三到四位,对 XOR 这种四个样本的任务,基本就是收敛了。
如果损失曲线是震荡的,先砍学习率。发散(越来越大甚至 NaN)的,除了学习率,还要检查分母上有没有零、有没有对负数取 log。完全不动的,前面讲过了,学习率太小或者数据没标准化,这两个嫌疑最大。
还有一种情况是损失平稳下降但最终很高,比如卡在 0.125——这就是模型容量不够,得加隐藏层宽度或者加层数。容量和训练是两回事,前者决定能拟合多复杂的关系,后者决定能不能找到最优参数。损失降不下去的时候,先分清是“找不到”还是“装不下”。
6. 新手最容易踩的坑与排查手册
6.1 症状对照表:损失不降到底怪谁
排查神经网络的毛病,最忌讳的是无脑改结构。先按症状定位,缩小嫌疑范围,再动手。
| 症状 | 最可能的原因 | 优先尝试的修复 |
|---|---|---|
| 损失完全不动 | 学习率过小 / 数据未标准化 | 学习率乘 10;输入归一化到均值 0、方差 1 |
| 损失震荡剧烈 | 学习率过大 / batch 太小 | 学习率除以 10;增大 batch |
| 损失变成 NaN | 学习率过大 / 出现 log(0) / 除零 | 降学习率;给 log 加 eps 平滑 |
| 损失下降但固定在一个平台 | 模型容量不足 / 激活函数饱和 | 加宽隐藏层;sigmoid 换 ReLU |
| 训练集完美,验证集崩 | 过拟合 | 加正则、加 dropout、扩数据 |
| 训练集和验证集都很差 | 欠拟合或数据有问题 | 检查标签对齐;加深加宽 |
| 输出全是同一个值 | 数据类别极不平衡 / 初始化有问题 | 查类别分布;换初始化方式 |
| 前几轮正常,后来越来越差 | 学习率没衰减 / 数据有脏样本 | 加学习率衰减;抽查异常样本 |
表格里“标签对齐”这一项值得单独说。我自己犯过一次,特征和标签的顺序在两次读取中不一致,训练了三天,指标始终在随机水平附近。这类错误最恶心的地方在于它不会报错,程序跑得好好的,只是结果毫无意义。所以每接一份新数据,我的第一步都是:抽三条样本,把特征和标签打印出来,人工看一眼对不对。这个习惯救过我很多次。
6.2 几条用时间换来的经验
第一条,从最小可跑配置开始。不要一上来就搭十层网络、上百万参数。先用一个能跑通的小模型把数据管道走通,确认数据加载、预处理、标签映射都没问题,再逐步加复杂度。这个顺序能让你在出问题时明确知道是哪一步引入的。
第二条,永远先跑一遍“傻子基线”。做二分类,先看全猜多数类能得多少准确率;做回归,先看全猜均值能得多少误差。你的模型必须显著超过这条线,否则说明还没学到任何有效模式。我见过准确率 92% 的模型在 92% 都是负类的数据上沾沾自喜,那种场面挺尴尬的。
第三条,固定随机种子。训练结果有随机性,不固定种子的话,你改一处代码跑两次,得到的差异分不清是改动带来的还是随机波动。调试阶段一律固定种子,确认有效果了再换多个种子跑,看稳定性。
第四条,梯度可以数值校验。手写的反向传播如果怀疑有错,可以拿数值微分对一下:把某个参数加一个极小的 ε,重算损失,再减 ε 重算,用(L+ - L-) / (2ε)近似导数,和你的解析梯度比。两者相对误差在 1e-6 量级就说明推导正确。这个技巧在写自定义层时几乎是必备的,虽然慢,但只跑一次,成本可以接受。
第五条,不要迷信复杂度。很多实际问题用逻辑回归、梯度提升树这类方法就能解决得不错,不需要上神经网络。神经网络擅长的是高维、非结构化、有大量数据的场景,比如图像、音频、文本。表格数据、样本量几千条的任务,先试试传统方法,可能更快出结果,也更好解释。
最后再强调一点:先把这篇里的手算和 XOR 完整跑一遍,再去看框架文档。这中间隔的不是几个小时,而是一层理解。很多人直接跳到框架,用了半年还在靠试错调参,问题就出在跳过了这一步。
7. 下一步该往哪走
7.1 从这层理解到真实项目还差什么
把 XOR 跑通,你已经摸清了神经网络最核心的骨架:前向传播、损失计算、反向传播、参数更新。这套骨架在真实的深度网络里一个都没变,变的是规模和细节。
接下来最该补的是数据预处理。真实数据不像 XOR 那样天生规整,尺度差异、缺失值、异常点、类别不平衡,每一项都能让训练彻底失败。标准化、归一化、编码方式、训练测试划分,这些东西的重要性在我看来超过模型结构本身,只是它们不像“Transformer”那样有话题性,容易被忽略。
再往后是优化器和正则化。这篇里用的是最朴素的梯度下降,实际项目里几乎都会换成 Adam 或者带动量的 SGD。正则化里的 L2 惩罚、dropout、早停,是控制过拟合的常规武器,值得单独花时间搞清楚它们各自在什么场景下起作用。
然后是结构本身。全连接层处理不了图像的空间结构,于是有了卷积;处理不了序列的时序依赖,于是有了循环网络和注意力机制。这些结构看着花哨,底层仍然是这篇讲的加权求和加非线性激活,只是连接方式变了。理解了基础单元,看新结构时你会本能地去问:它改了哪部分连接?为什么这样改能解决原结构的问题?
最后是工程实践。显存怎么省、分布式怎么切、推理怎么加速、模型怎么部署,这些是另一条技能线,和算法理解同等重要。
我自己学这块的路径是:先手推一遍单神经元,再写一遍两层网络,然后跑通一个框架的最简示例,接着把一个真实的小数据集从头到尾走一遍。中间卡住过很多次,卡得最久的都不是理论,而是一些琐碎的细节——形状没对齐、标签没打平、学习率写错一位。所以如果你现在正准备开始,我的建议是:先把这一篇的代码原样敲一遍,跑通,再改参数看会发生什么。改学习率、改隐藏层宽度、改激活函数,观察损失曲线的变化。这种“改一处看一次”的循环,比读十篇文章记得牢。