1. 为什么三十多年前的这篇论文,今天读起来依然让人后背发凉
如果你正在入门深度学习,或者已经调过不少模型但总觉得心里没底,我建议你找个安静的下午,把这篇1986年发表在《自然》上的文章翻出来读一遍。标题很朴素,就叫《Learning representations by back-propagating errors》,翻译过来是“通过反向传播误差来学习表征”。作者是三位在认知科学和计算机科学交叉地带深耕多年的研究者。我第一次读的时候还在用某个早期框架搭多层感知机,当时只觉得反向传播就是一个链式求导的工程实现,没什么稀奇。直到后来自己从零手写了一遍训练循环,又回头重读这篇论文,才发现里面藏着很多被现代框架封装掉的、真正决定模型能不能训起来的细节。
这篇文章的核心贡献不是发明了反向传播——链式法则本身在微积分里早就有了——而是把反向传播系统地用在了多层非线性网络的权重学习上,并且明确指出隐藏层可以学到有意义的内部表征。这个观点在当年是很有冲击力的。因为在那个年代,很多人认为多层网络没法训练,隐藏层没有明确的语义,训练信号传不到中间层。这篇文章用实验说明,只要误差能一层层往回传,隐藏层自己就能组织出对任务有用的特征。今天你随手写一个几层的网络,框架自动帮你算梯度,你可能感受不到这个结论的分量。但如果你试过手动推导一个两层网络的梯度,就会明白当年这个想法有多大胆。
我写这篇博文,不是要逐句翻译论文,而是想从一个实际动手训练过模型的人的角度,把这篇论文里真正值得反复琢磨的东西拆开。适合谁看?如果你已经会用框架搭模型,但对反向传播的理解还停留在“调用一下backward就行”,或者你正准备从零实现一个小型神经网络来加深理解,那这篇内容会对你有帮助。我会讲清楚论文里几个关键设计背后的逻辑,补充一些论文里没写但实际训练中一定会遇到的问题,以及我自己在复现类似结构时踩过的坑。
2. 论文里那个“简单”的网络结构,藏着多少没明说的设计决策
2.1 前向传播的公式背后,为什么用Sigmoid而不是阶跃函数
论文里给出的前向传播形式很简洁:每个单元先把输入加权求和,再经过一个非线性函数得到输出。这个非线性函数用的是Sigmoid,也就是1/(1+exp(-x))。很多人读到这里会直接跳过,觉得这就是个激活函数的选择。但如果你仔细想,为什么不用当年感知机里常用的阶跃函数?原因其实很实际:阶跃函数的导数几乎处处为零,除了在零点附近不可导,这意味着梯度没法通过它往回传。Sigmoid虽然也有饱和区梯度消失的问题,但至少在大部分区域是光滑可导的,而且导数可以用输出值直接算出来,这在当年算力有限的情况下非常关键。
我在自己实现的时候试过用阶跃函数替换Sigmoid,结果就是隐藏层的权重几乎不更新,误差曲线平得像一条直线。后来换成Sigmoid,训练立刻就能动起来。论文里没有花篇幅解释这个选择,但从公式推导的角度看,Sigmoid的导数形式是σ(x)(1-σ(x)),这意味着你只需要保存前向传播的输出,就能在反向传播时直接算梯度,不需要重新计算输入。这个细节在当年内存和算力都很紧张的环境下,是一个很务实的工程决策。
2.2 隐藏层的存在到底解决了什么问题
论文里反复强调的一个点是:如果网络里没有隐藏层,那么输入到输出的映射只能是线性的或者非常简单的非线性组合,很多问题根本没法表示。比如经典的异或问题,单层感知机就解决不了,因为异或不是线性可分的。但加一个隐藏层之后,网络可以先在隐藏层把输入空间扭曲一下,让原本不可分的数据在新的表示空间里变得可分。
我刚开始学的时候对这个说法理解不深,直到自己动手做了一个小实验:用单层网络去拟合一个简单的异或数据,不管怎么调学习率,损失都降不下去。后来加了一层隐藏层,隐藏单元数设为2,训练几百轮之后,隐藏层的输出居然自动把四个输入点映射到了两个不同的区域。这个过程没有任何人告诉网络该怎么分,它自己从误差信号里学出来的。论文里把这个叫做“学习内部表征”,我觉得这个说法非常准确。隐藏层不是在直接做分类,它是在学一种新的坐标系统,让最后的输出层能在这个新坐标系里画一条直线就把问题解决掉。
2.3 权重初始化的随机性为什么是必要的
论文里提到权重一开始是随机的小值。这个描述很轻描淡写,但实际动手时你会发现,如果所有隐藏单元的初始权重完全一样,那么它们在训练过程中会一直保持一样,相当于你花了计算资源却只得到了一个隐藏单元。随机初始化打破了这种对称性,让不同的隐藏单元有机会学到不同的特征。我在早期实现时偷懒,把所有权重初始化为零,结果训练了半天,隐藏层的所有单元输出完全一致,模型容量白白浪费了。
至于为什么是小值而不是大值,论文没有展开。我的理解是,如果初始权重太大,Sigmoid函数会进入饱和区,梯度接近零,训练一开始就卡住了。小值能让单元工作在Sigmoid的线性区域附近,梯度比较健康。这个经验后来被总结成各种初始化方法,比如Xavier初始化、He初始化,但根源在这篇论文里已经埋下了。
3. 反向传播的推导:从误差到权重的完整链路
3.1 输出层误差信号是怎么定义的
论文里定义输出层的误差信号时,用的是实际输出和期望输出之间的差,再乘以激活函数的导数。这个形式看起来简单,但里面有一个很容易被忽略的点:误差信号不是单纯的差值,而是差值经过激活函数导数缩放后的结果。为什么要乘导数?因为你要把误差从输出空间映射回加权求和之前的空间。Sigmoid的输出范围是0到1,但加权求和的范围是负无穷到正无穷,两者之间隔着一个非线性变换。如果你直接拿差值去更新权重,相当于忽略了非线性变换对误差的缩放作用,更新方向会偏。
我见过一些从零实现反向传播的代码,在输出层直接写delta = output - target,然后拿这个delta去乘输入更新权重。这样写在小学习率下可能也能跑,但收敛速度明显慢,而且容易在饱和区震荡。正确的做法是乘上Sigmoid的导数,也就是output * (1 - output)。这个细节论文里写得很清楚,但现代框架自动帮你做了,所以很多人读论文时会一带而过。
3.2 隐藏层误差信号的递归计算
这是整篇论文最核心的部分。隐藏层没有直接的期望输出,它的误差信号是从上一层传回来的误差信号加权求和,再乘以本层激活函数的导数。用公式写就是:某一层的误差信号等于下一层所有单元的误差信号乘以对应连接权重之和,再乘以本层输出的导数。
我第一次推导这个公式的时候卡了很久,因为直觉上会觉得隐藏层的误差应该由它自己的输出决定,但实际上隐藏层的误差完全由它对最终输出的影响来决定。换句话说,如果一个隐藏单元对最终误差几乎没有贡献,那它的误差信号就接近零,权重几乎不更新。这个机制让网络自动把学习资源集中在那些真正影响输出的单元上。
论文里用了一个很形象的词叫“back-propagating”,误差就像从输出层往回倒着流一样,每经过一层就被该层的权重和导数重新分配一次。我在手写实现时,把每一层的误差信号存在一个列表里,从最后一层往前算,算完一层就用它去更新上一层的权重。这个顺序不能乱,因为计算第k层的误差信号需要用到第k+1层的误差信号和它们之间的权重。
3.3 权重更新为什么用梯度下降而不是直接解方程
论文里用的是梯度下降,也就是沿着误差对权重的负梯度方向小步更新。为什么不直接解一个最优权重的闭式解?因为多层非线性网络的误差函数关于权重是非凸的,存在大量局部极小值和鞍点,根本没有闭式解。梯度下降虽然慢,但它是通用的,而且每一步计算量可控。
我在实际训练中发现,学习率的选择比想象中敏感。论文里没有给出具体的学习率数值,只说了要小。我试过0.1、0.01、0.001几个量级,发现对于小网络,0.1左右能较快收敛,但容易在后期震荡;0.01比较稳,但需要更多轮数。后来我养成了一个习惯:先用一个中等学习率跑几百轮看损失曲线,如果下降太慢就调大,如果震荡就调小。这个经验论文里没有,但我觉得比任何理论分析都实用。
4. 论文里的实验设计,放到今天看依然有参考价值
4.1 那个编码任务到底在验证什么
论文里有一个实验让我印象很深:让网络学习一组对称的编码,输入和输出是同一组模式,但中间经过一个隐藏层。这个任务看起来像是自编码器的雏形,但论文的目的不是做降维,而是验证隐藏层能不能学到输入的有效编码。实验结果是,训练之后隐藏层的激活模式确实能唯一区分不同的输入,而且对输入的部分损坏有一定的容错能力。
我自己复现过类似的结构,用四个输入模式、两个隐藏单元、四个输出单元。训练收敛后,两个隐藏单元的输出组合确实对应了四个不同的区域。有意思的是,如果你把其中一个隐藏单元的输出固定住,只让另一个变化,网络对某些输入仍然能给出正确输出。这说明隐藏层学到的不是简单的查表,而是一种分布式的表示。这个观察后来在表示学习领域被反复讨论,但这篇论文在1986年就已经用实验展示了。
4.2 对称性破缺在实验中的体现
论文里提到一个细节:如果隐藏单元的数量和任务需要的特征数刚好匹配,训练往往能收敛到比较干净的表示;但如果隐藏单元太多,有些单元可能会“偷懒”,输出恒定值,对最终结果没有贡献。这个现象我在实际训练中经常遇到。比如用10个隐藏单元去学一个只需要3个特征的任务,训练完之后你会发现有六七个单元的权重几乎没怎么变,输出接近常数。
这其实不是坏事,它说明网络自动做了某种正则化,把多余的容量闲置了。但如果你希望每个单元都学到东西,就需要调整初始化或者加一点权重衰减。论文里没有深入讨论这个问题,但从实验描述里能看出作者注意到了隐藏单元数量的影响。我的经验是,隐藏单元数宁可稍微多一点,让网络自己选择用哪些,也不要卡得太紧导致欠拟合。
4.3 训练轮数和收敛判断的实际操作
论文里没有给出明确的停止条件,只说训练到误差足够小。这个“足够小”在实际操作中很难把握。我自己的做法是画两条曲线:一条是训练集上的平均误差,一条是验证集上的误差。如果训练误差还在降但验证误差开始上升,那就该停了。如果两条都在降但速度很慢,可以再等等。对于这篇论文里那种小规模任务,通常几千轮就能看到明显收敛。
还有一个细节:论文里用的是批量更新还是在线更新?从描述看,应该是每输入一个样本就更新一次权重,也就是随机梯度下降。这种方式在当年算力有限的情况下很实用,因为不需要把整个数据集加载进来。但它的缺点是更新方向噪声大,损失曲线会抖。我试过把批量大小设为1和设为全部,发现小批量(比如8到32)在稳定性和速度之间平衡得比较好。这个经验在现代深度学习里已经是常识,但根源还是在这类早期工作里。
5. 从论文到代码:手写反向传播时最容易翻车的几个地方
5.1 梯度检查:数值梯度和解析梯度的对比
如果你自己实现了反向传播,第一件事应该是做梯度检查。方法很简单:对某个权重,用数值方法算一个近似梯度,再和你反向传播算出来的解析梯度对比。如果两者差得很远,说明你的推导或者实现有问题。我当年第一次写反向传播时,隐藏层的误差信号忘了乘激活函数的导数,结果梯度检查直接报错,数值梯度和解析梯度符号都反了。
数值梯度的计算是:把权重加一个很小的epsilon,算前向传播的误差;再减一个epsilon,算误差;两者之差除以2epsilon。epsilon一般取1e-4到1e-5。如果解析梯度和数值梯度的相对误差在1e-6以下,基本可以认为实现是对的。这个步骤论文里没有提,但任何从零实现的人都应该做。
5.2 权重更新顺序和原地操作的陷阱
在更新权重的时候,有一个很容易犯的错误:一边更新前面的权重,一边用更新后的权重去算后面层的误差信号。正确的顺序是先用旧的权重把所有层的误差信号都算完,再统一更新权重。如果你在循环里更新了某一层的权重,然后继续往前算,那前面层用到的就是已经被修改过的权重,梯度方向就错了。
我在早期实现时犯过这个错,表现是训练初期损失下降很快,但很快就卡住不动了。后来把误差信号的计算和权重更新分成两个独立的循环,问题就解决了。这个坑在论文里没有明说,因为作者默认读者理解反向传播的链式法则要求所有梯度基于同一组权重计算。
5.3 学习率衰减和动量项的取舍
论文里用的是固定学习率,没有提衰减和动量。但在实际训练中,固定学习率往往在后期会让损失在最小值附近震荡。我后来加了一个简单的学习率衰减:每过一定轮数,学习率乘以0.9。这样前期下降快,后期稳定。动量项则是另一个有用的技巧,它把上一次的更新方向按一定比例加到当前更新上,能加速收敛并减少震荡。
不过对于这篇论文里那种小规模任务,不加这些技巧也能跑出结果。我的建议是先把基础版本跑通,确认梯度检查通过、损失能下降,再去加这些优化。否则你分不清是反向传播实现错了还是优化技巧没调好。
6. 这篇论文对今天做模型训练的人还有什么实际启发
6.1 表征学习的思想比反向传播本身影响更深远
很多人把这篇论文的贡献归结为反向传播,但我觉得它更大的影响是提出了“学习表征”这个思路。在它之前,做模式识别的人往往需要手工设计特征,比如边缘检测、纹理统计之类的。这篇论文说明,只要网络足够深,隐藏层可以自己从原始输入里学出有用的特征,不需要人告诉它该看什么。
这个思路后来演变成了深度学习的基本范式:端到端训练,让网络自己学特征。今天你用一个预训练模型做微调,本质上也是在利用它学到的表征。回头读这篇论文,你会发现作者在结论部分已经隐约提到了这个方向,只是当年的数据和算力还不足以展示深层网络的威力。
6.2 误差信号作为学习信号的通用性
论文里把误差信号定义为误差乘以激活函数导数,这个形式后来被推广到了各种网络结构和损失函数。比如交叉熵损失配Softmax,误差信号就变成了输出减目标,非常简洁。再比如均方误差配线性输出,误差信号就是差值本身。这些特例都可以从这篇论文的框架里推出来。
我在实际工作中遇到新的损失函数时,第一反应就是推一下它的误差信号形式。只要误差信号能写出来,反向传播就能用。这个思维方式比记住某个具体公式重要得多。
6.3 小网络实验对理解大模型的价值
这篇论文里的实验规模很小,隐藏单元只有几个到几十个,训练数据也就几十个模式。但正是这种小规模,让你能清楚地看到每个单元在做什么,每个权重怎么变。我建议每个学深度学习的人都找时间复现一下这种小实验,不要一上来就调大模型。大模型的很多现象,比如梯度消失、表示坍塌、过拟合,在小网络里都能观察到,而且更容易调试。
我自己在带新人的时候,通常会让他们先用这篇论文的结构手写一个两层网络,在异或数据上训到收敛,再做梯度检查。这个过程走一遍,比看十篇教程都管用。因为你会被迫面对每一个细节:激活函数选什么、权重怎么初始化、误差信号怎么传、学习率怎么调。这些细节在大框架里都被封装了,但它们是理解深度学习的基石。
7. 我在复现这篇论文时积累的几个实操心得
第一个心得是关于数据表示的。论文里的输入模式用的是0和1,输出也是0和1。如果你用-1和1,Sigmoid的饱和区会不一样,训练动态也会变。我试过两种表示,发现0和1在Sigmoid下更自然,因为Sigmoid的输出范围就是0到1,目标值落在范围边界上,梯度不会太大也不会太小。如果你用-1和1,输出层需要把Sigmoid的输出映射到-1到1,多了一层变换,反而麻烦。
第二个心得是关于隐藏单元数量的。论文里没有给出明确的选择规则,我的经验是:对于二分类或者小规模多分类任务,隐藏单元数取输入维度和输出维度之间的某个值,比如输入4维输出4维,隐藏层取2到4个就够。如果任务复杂,可以适当增加,但不要超过输入维度的两倍,否则容易过拟合。这个规则当然不是绝对的,但作为一个起点很实用。
第三个心得是关于训练轮数的。论文里的任务通常几千轮就能收敛,但如果你用的学习率很小,可能需要几万轮。我一般会设一个上限,比如50000轮,然后看损失曲线。如果损失还在缓慢下降,就再跑;如果已经平了,就停。不要盲目追求训练误差为零,因为那往往意味着过拟合。
第四个心得是关于随机种子的。论文里没有提随机种子,但实际训练中,不同的初始化会导致不同的收敛结果。有时候换个种子,损失曲线形状完全不一样。我的做法是跑三到五个种子,取平均表现,而不是只看一次结果。这个习惯在调参时特别重要,能避免你被单次运行的偶然性误导。
最后说一个我踩过的坑:在计算输出层误差信号时,如果你用的是均方误差,误差信号是(输出减目标)乘以输出导数。但如果你不小心把输出导数写成了输入导数,梯度方向就错了。这个错误很隐蔽,因为损失曲线可能还是会下降,只是速度慢很多。梯度检查能发现这个问题,所以千万别跳过那一步。