算法的故事 / 第一季第二季目录0 / 30 章已读

第二季 · 第 57 章

错误怎样穿越一层层神经元

《算法的故事》第二季 · 第六篇:会学习的算法——从1964年的自动求导、1970年的反向累积,到1986年让多层神经网络学会表示世界1986年10月9日。英国科学期刊《Nature》刊登了一篇只有四页的论文。论文标题是:Learning Representations by Back-Propagating Errors《通过误差反向传播学习表示》。三位作者分别是:戴维·鲁梅尔哈特(David E. Rumelhart)。杰弗里·辛顿(Geoffrey E. Hinton)。罗纳德·威廉姆斯(Ronald J. Williams)。他们研究一种能够根据输出误差,调整多层神经网络连接权重的方法。论文发表于《Nature》第323卷,第533—536页。它在1986年5月1日收到,7月31日被接受,10月9日正式发表。 [Nature]如果我们今天打开这篇论文,首先看到的并不是复杂的GPU程序。没有PyTorch。没有TensorFlow。没有数十亿参数的大语言模型。作者研究的是一类由输入单元、隐藏单元与输出单元组成的网络。网络接收输入。计算输出。比较实际输出和目标输出。然后修改内部连接的权重。但最有价值的问题在于:如果只有最后的输出单元知道自己错了,处于前面几层的隐藏单元,怎么知道它们分别应该改变多少?假设一个网络只有两个权重。我们可以写出公式,分别求导。假设它有两百个权重。我们仍然可能耐心地推导。但如果有两百万个权重呢?如果每个权重都需要从头分析它与最终损失之间的全部数学关系,那么训练就会变得极其昂贵。更糟糕的是,不同权重可能共享大量相同的中间计算。一层网络的输出,会成为下一层的输入。一条中间计算结果,还可能同时进入多个后续分支。如果我们每求一个导数,都重新计算全部路径,就会反复执行大量相同的工作。三位研究者展示了一种更高效的方法。先从输入向输出,完成一次前向计算。记录必要的中间结果。然后从最终损失开始,沿计算关系反向传递导数。每到一个节点,利用局部的链式法则。如果一个节点影响了多个后续计算,就把这些分支贡献合并起来。最终,每个权重都能获得自己对损失的梯度。于是,上一章的梯度下降就可以真正发挥作用。wnew=wold−η∂L∂w\boxed{ w_{\mathrm{new}} = w_{\mathrm{old}} - \eta\frac{\partial L}{\partial w} }不过,这段历史有一个重要细节。1986年的论文,既不是人类第一次发现微积分的链式法则,也不是计算机第一次自动计算导数。与反向传播密切相关的算法思想,在此前几十年里已经出现于数值分析、控制理论、自动微分和学习系统研究。1986年的重要性在于,它清楚地展示了如何利用这项方法训练多层神经网络,使隐藏单元能够学习有用的内部表示,并推动了这条研究路线的广泛传播。因此,要理解1986年发生了什么,我们需要回到更早的年代。首先是1964年。然后是1970年。接着是1974年。最后,再回到1986年。这个故事的中心不是某一个人突然发现了一个神秘的训练技巧。而是一项非常经典的数学定律,怎样一步步变成能够对复杂计算程序求导的算法。链式法则(Chain Rule)。

第一幕:微积分早已知道怎样求导,但计算机为什么还会遇到困难?

在第56章中,我们已经知道:梯度下降需要计算:∇θL\nabla_\theta L然后沿负梯度方向修改参数。但是,我们实际上留下了一个问题。梯度究竟从哪里来?如果损失函数很简单,例如:L(w)=(w−3)2L(w)=(w-3)^2那么:dLdw=2(w−3)\frac{dL}{dw}=2(w-3)直接计算即可。但神经网络的损失函数通常是很多运算的复合结果。例如:x→z1→h1→z2→y^→Lx\rightarrow z_1\rightarrow h_1\rightarrow z_2\rightarrow\hat y\rightarrow L其中:每一步都依赖前一步。权重可能分布在不同位置。

从三层函数开始

假设:a=2xa=2xb=a2b=a^2L=b+1L=b+1我们希望知道:dLdx\frac{dL}{dx}最直接的方法,是先代入:L=(2x)2+1L=(2x)^2+1所以:L=4x2+1L=4x^2+1于是:dLdx=8x\boxed{\frac{dL}{dx}=8x}但如果计算过程包含几百步,直接展开整个函数就可能十分复杂。于是,我们可以保留中间变量。

链式法则逐步计算

由于:L=b+1L=b+1因此:dLdb=1\frac{dL}{db}=1又因为:b=a2b=a^2所以:dbda=2a\frac{db}{da}=2a而:a=2xa=2x得到:dadx=2\frac{da}{dx}=2将它们相乘:dLdx=dLdbdbdadadx\frac{dL}{dx} = \frac{dL}{db} \frac{db}{da} \frac{da}{dx}于是:=1×2a×2=1\times2a\times2=4a=4a再代入:a=2xa=2x得到:dLdx=8x\boxed{\frac{dL}{dx}=8x}这与直接展开求导完全一致。

为什么这项方法适合计算机?

因为每一次局部运算都很简单。加法的导数容易计算。乘法的导数容易计算。平方函数的导数容易计算。指数函数和三角函数也有相应的局部求导规则。因此,程序不一定需要先将全部计算展开为一个庞大的数学表达式。它只需要知道:每一个中间结果由哪些输入计算而来?以及这个局部操作的导数是什么?
图57-1:复合函数的局部求导xabL×2平方+1反向:局部导数逐项相乘上方实线代表计算结果由左向右产生。下方虚线代表求导信息由右向左传递。这只是单一路径;真实计算图可能存在许多分叉与合并。

可是,我们为什么不能一直直接手算?

因为复杂程序不仅仅是一条长链。它可能同时出现:一个中间结果被使用三次。一个节点接受多个输入。同一个参数在许多位置重复使用。某些运算还包含矩阵和张量。这意味着:真实的求导问题不仅是:沿一条路径不断乘导数。还需要正确处理:多个路径共同对同一个参数产生的影响。而且,程序必须避免为每个参数重新计算相同的中间结果。所以,数学的链式法则虽然早已存在,但将它组织成一套高效、可自动运行的算法,仍然是一项重要的计算机科学问题。

第二幕:1964年,Wengert发现程序可以像账本一样记录每一次计算

1964年8月。《Communications of the ACM》发表了一篇短论文。作者是:R. E. Wengert(罗伯特·温格特)。论文标题:A Simple Automatic Derivative Evaluation Program《一个简单的自动导数计算程序》。它刊登于第7卷第8期,第463—464页。[users.fmi.uni-jena.de]这篇论文关注一个非常实际的问题。如果我们已经写出一个能够计算复杂函数的程序。能否让计算机自动计算这个函数对输入变量的导数?而不是由程序员另外推导一套庞大的解析公式。

把复杂函数拆成基本运算

例如:L=(wx+b)2L=(wx+b)^2我们不必直接展开:w2x2+2wxb+b2w^2x^2+2wxb+b^2而是按照程序执行顺序,写成:v1=wxv_1=wxv2=v1+bv_2=v_1+bv3=v22v_3=v_2^2L=v3L=v_3现在,复杂函数变成了三项简单运算。每一项都可以记录:输入是什么?操作是什么?输出是什么?局部导数是什么?这类中间变量顺序记录,后来常被称为:Wengert List。也就是一种描述计算执行过程的列表或计算轨迹。
图57-2:把程序变成一张计算账本
顺序计算本地求导规则
1v1=wxv_1=wx∂v1/∂w=x\partial v_1/\partial w=x
2v2=v1+bv_2=v_1+b∂v2/∂v1=1\partial v_2/\partial v_1=1
3v3=v22v_3=v_2^2∂v3/∂v2=2v2\partial v_3/\partial v_2=2v_2
4L=v3L=v_3最终输出

为什么这种结构如此有用?

因为它为自动求导提供了一种明确的程序表示。如果每个操作都知道自身的局部求导规则,就能按照计算依赖关系传播导数。Wengert的论文重点展示了:通过将代数计算拆解成基本表达式,并利用预先准备的局部导数规则,自动求出函数的偏导数或全导数。这项工作是自动微分发展史上的重要早期成果。[CiNii Research][机器学习研究杂志]

自动微分不是符号求导,也不是数值差分

这里必须区分三种技术。第一种是:Symbolic Differentiation(符号求导)。例如,将:(x+1)2(x+1)^2展开或者利用求导法则,得到一个新的符号表达式。第二种是:Numerical Differentiation(数值差分)。例如:f′(x)≈f(x+h)−f(x−h)2hf'(x)\approx\frac{f(x+h)-f(x-h)}{2h}通过函数值的微小扰动近似导数。第三种是:Automatic Differentiation(自动微分,AD)。它将程序分解成基本可微运算,并利用链式法则,在给定输入处计算导数。它不需要对每个输入变量重新使用差分近似,也不必生成一份完全展开的巨大符号公式。在基本运算与实现正确的条件下,AD得到的是由链式法则计算的导数,精度主要受到普通浮点运算误差的影响,而非有限差分步长的截断误差。这项区别,非常重要。

Wengert还没有给出现代深度学习的全部答案

1964年的研究,已经清楚地展示了程序自动求导的可行性。但今天广泛使用的反向模式自动微分,还涉及非常重要的计算组织方式:从最终的一个标量输出出发,反向计算它对大量输入参数的导数。这类思想在数值分析领域继续发展。接下来,故事要来到芬兰。

第三幕:1970年,Linnainmaa研究的竟然是浮点数舍入误差

1970年。芬兰赫尔辛基大学。一位年轻研究者完成了一篇硕士论文。他的名字是:塞波·林奈恩马(Seppo Linnainmaa)。论文研究:The Representation of the Cumulative Rounding Error of an Algorithm as a Taylor Expansion of the Local Rounding Errors《将算法累计舍入误差表示为局部舍入误差的泰勒展开》。论文使用芬兰语写成,提交于赫尔辛基大学计算机科学相关部门。这一书目信息,可由Linnainmaa后来1976年正式发表的论文参考文献核查。[Springer Nature Link]

为什么研究浮点舍入误差?

因为计算机不能无限精确地表示所有实数。例如:某个精确数学结果可能需要无限小数位。计算机只能保留有限位数。因此,在执行加法、乘法等运算时,可能引入微小的舍入误差。当一段程序执行很多步时,这些局部误差可能累积。而且,某一次早期运算产生的误差,可能通过后续大量计算,被放大或缩小。于是,一个重要的问题出现了。最终结果的误差,究竟对前面哪一次局部计算误差最敏感?这与现代神经网络的问题具有惊人的结构相似性。神经网络希望知道:最终损失对早期某个权重有多敏感?舍入误差研究希望知道:最终计算结果对早期某次局部舍入误差有多敏感?二者都可以通过计算程序的依赖关系,反向传播导数信息。

1976年:相关成果正式发表

1976年6月,Linnainmaa在《BIT Numerical Mathematics》发表:Taylor Expansion of the Accumulated Rounding Error《累计舍入误差的泰勒展开》。论文第16卷,第146—160页。[Springer Nature Link]这篇论文讨论如何计算累计舍入误差对局部误差的一阶与高阶影响系数,也明确考虑计算过程中存储需求的问题。在自动微分史的后续梳理中,Linnainmaa的1970年论文及1976年发表成果,被认为是反向累积方法发展的重要来源。[机器学习研究杂志]

反向累积为什么是一项突破?

假设某个程序有:nn个输入变量。但最终只有一个标量输出:LL我们希望计算:∂L∂x1,∂L∂x2,…,∂L∂xn\frac{\partial L}{\partial x_1}, \frac{\partial L}{\partial x_2}, \ldots, \frac{\partial L}{\partial x_n}如果每次只跟踪一个输入方向的导数,那么可能需要针对不同输入,分别传播很多次导数信息。而反向模式可以利用一次前向执行所记录的中间结果,从标量输出开始反向累积,获得全部输入变量的梯度。在通常的标量损失和基本运算模型下,它的运算量通常与一次函数求值处于相同数量级,而不是随着参数数量线性增加到需要完整重复n次函数计算。当然,实际耗时和内存仍取决于计算图、具体算子和实现方式。

反向累积的核心量

对于计算图中的某个中间变量:viv_i定义:vˉi=∂L∂vi\boxed{ \bar v_i=\frac{\partial L}{\partial v_i} }这个带横线的变量,通常称为:Adjoint(伴随量)。在现代自动微分语境里,也经常称作:反向梯度。或Cotangent。如果某个节点:vj=fj(vi,…)v_j=f_j(v_i,\ldots)那么,从后续节点传回来的贡献是:vˉi+=vˉj∂vj∂vi\bar v_i \mathrel{+}= \bar v_j\frac{\partial v_j}{\partial v_i}最值得注意的是:+=\boxed{\mathrel{+}=}为什么不是简单的赋值?因为一个中间变量可能同时进入多个后续计算。那么,最终损失对它的总导数,需要将不同路径的贡献全部加起来。这正是下一幕需要解决的问题。

一个不能忽略的历史事实

Linnainmaa的主要动机并不是训练神经网络。1970年的硕士论文研究的是数值计算的舍入误差。而1986年神经网络研究者所强调的是训练中隐藏层的误差分配与表示学习。从数学上看,它们共享反向链式求导的核心结构。但在历史上,它们属于不同研究背景下逐步汇合的技术路线。一种在数值误差研究中发展出来的方法,后来成为机器学习梯度计算的重要基础。这正是算法史中非常值得理解的一种跨领域联系。

第四幕:1974年,Werbos开始思考怎样把终点的误差传给整个系统

现在,故事来到美国。1974年。哈佛大学。研究者:保罗·韦博斯(Paul John Werbos)完成了关于预测、统计分析与动态系统学习的博士研究。论文题目是:Beyond Regression: New Tools for Prediction and Analysis in the Behavioral Sciences《超越回归:用于行为科学预测与分析的新工具》。论文完成于1974年;部分学位论文数据库记录的编目年份为1975年,因此在不同资料中可以看到两个年份。这里采用Werbos本人及后续历史综述通常使用的1974年。[Gwern][ProQuest]

Werbos面对的是怎样的问题?

他希望研究更加复杂的预测和学习系统。传统回归模型往往具有相对明确的参数和输出关系。但如果系统内部存在许多层次的中间计算,最终结果对内部参数的影响就可能非常复杂。例如:
text
参数 → 中间状态 → 预测结果 → 误差
如果只是修改最后一层参数,可能无法让整个系统形成更好的内部表示。所以,需要把最终误差与更早的内部参数联系起来。这与后来神经网络训练中的核心问题高度一致。

把链式法则用于训练

假设:z=f(x;w1)z=f(x;w_1)y^=g(z;w2)\hat y=g(z;w_2)损失:L=ℓ(y^,y)L=\ell(\hat y,y)那么:∂L∂w2=∂L∂y^∂y^∂w2\frac{\partial L}{\partial w_2} = \frac{\partial L}{\partial\hat y} \frac{\partial\hat y}{\partial w_2}而第一层参数:w1w_1必须通过中间状态z计算:∂L∂w1=∂L∂y^∂y^∂z∂z∂w1\boxed{ \frac{\partial L}{\partial w_1} = \frac{\partial L}{\partial\hat y} \frac{\partial\hat y}{\partial z} \frac{\partial z}{\partial w_1} }这意味着:虽然:w1w_1并不直接出现在最终输出层,但只要它影响后续计算,我们仍然可以求出它对损失的影响。这就是多层学习系统中反向传播的基本思路。

Werbos的研究为何重要?

1974年的博士研究已经讨论了后来与误差反向传播密切相关的敏感度分析和训练思想。Werbos本人在后来的教程与回顾中明确将这项工作列为其早期贡献,并说明他最初的兴趣不仅涉及感知机,还涉及预测、动态系统与强化学习相关问题。[Tutorial_IJCNN04_Werbos.doc]但需要区分:提出数学思想。形成通用自动微分技术。发表实际神经网络训练方法。以及推动方法广泛使用。这些不是同一件事。Linnainmaa的工作与反向模式自动微分有关。Werbos的研究则将类似的导数传播思想联系到学习和预测系统。而1980年代的不同研究者,又将它们发展成实用的神经网络训练方法。

历史不只有这两条研究路线

在控制理论与最优控制研究中,研究者也早已使用伴随变量与反向敏感度计算。这些方法在数学上与反向求导有深刻联系。1964年的Wengert、1970年的Linnainmaa、1974年的Werbos,以及此后其他研究者的工作,也并不是所有成果都存在一条明确的直接引用链。2018年发表在《Journal of Machine Learning Research》的自动微分综述,专门讨论了自动微分与神经网络反向传播各自发展的历史,以及不同领域之间曾经存在的相对独立研究路线。[机器学习研究杂志]所以,这段历史不能简单地写成:某个人在某一天独自发明了一切。更准确的说法是:反向链式求导作为一项数学计算方法,在多个领域逐渐成熟;神经网络反向传播则将它用于多层网络参数的训练,并在1980年代获得广泛影响。现在,我们终于有足够的历史背景,进入本章最核心的算法实验。

第五幕:一个只有六个变量的计算图,竟然已经包含反向传播最难的一项规则

x,w,bzz²2zŷ同一个中间变量z,被后续两条路径共同使用逆向求导时,来自两条分支的贡献必须相加图57-3:一个中间结果被重复使用时,计算图会出现分叉与重新合并。
我们构造一个完全人工的计算过程。输入:x=2x=2参数:w=1,b=1w=1,\qquad b=1首先:z=wx+b\boxed{z=wx+b}然后计算两条分支:a=z2a=z^2c=2zc=2z再合并:y^=a+c\boxed{\hat y=a+c}最后定义目标:y∗=14y^*=14平方损失:L=12(y^−y∗)2\boxed{L=\frac12(\hat y-y^*)^2}这不是一张真实的神经网络训练图,而是为了将最重要的反向传播规则充分暴露出来的教学案例。

第一步:前向计算

先计算:z=1×2+1=3z=1\times2+1=3然后:a=32=9a=3^2=9另一条分支:c=2×3=6c=2\times3=6合并:y^=9+6=15\hat y=9+6=15目标是:1414所以:L=12(15−14)2L=\frac12(15-14)^2得到:L=0.5\boxed{L=0.5}完整计算表:
中间量公式数值
zwx+bwx+b3
az2z^29
c2z2z6
y^\hat ya+ca+c15
L(y^−14)2/2(\hat y-14)^2/20.5
现在,我们只知道:最终损失为0.5。如果想让它下降,需要分别求出:∂L∂w\frac{\partial L}{\partial w}和:∂L∂b\frac{\partial L}{\partial b}

第二步:从最终损失开始

由于:L=12(y^−14)2L=\frac12(\hat y-14)^2所以:∂L∂y^=y^−14\frac{\partial L}{\partial\hat y} = \hat y-14代入:y^=15\hat y=15得到:∂L∂y^=1\boxed{\frac{\partial L}{\partial\hat y}=1}这就是反向传播的起点。输出误差经过平方损失的求导,转化成了对预测值的梯度。

第三步:将梯度传给两个分支

由于:y^=a+c\hat y=a+c所以:∂y^∂a=1\frac{\partial\hat y}{\partial a}=1∂y^∂c=1\frac{\partial\hat y}{\partial c}=1因此:∂L∂a=∂L∂y^×1=1\frac{\partial L}{\partial a} = \frac{\partial L}{\partial\hat y} \times1 =1同理:∂L∂c=1\boxed{\frac{\partial L}{\partial c}=1}现在,最终误差已经进入了两条分支。

第四步:两条分支分别对z产生梯度贡献

第一条:a=z2a=z^2所以:∂a∂z=2z=6\frac{\partial a}{\partial z}=2z=6它对z贡献:1×6=61\times6=6第二条:c=2zc=2z所以:∂c∂z=2\frac{\partial c}{\partial z}=2它对z贡献:1×2=21\times2=2现在出现了本章最重要的一步。z的最终梯度不是6,也不是2,而是两条路径的贡献之和。∂L∂z=∂L∂a∂a∂z+∂L∂c∂c∂z\frac{\partial L}{\partial z} = \frac{\partial L}{\partial a} \frac{\partial a}{\partial z} + \frac{\partial L}{\partial c} \frac{\partial c}{\partial z}所以:∂L∂z=6+2=8\boxed{ \frac{\partial L}{\partial z}=6+2=8 }这便是:梯度累加(Gradient Accumulation)。如果在程序里,我们只写:
python
z.grad = 6
然后又写:
python
z.grad = 2
那么第二次赋值会覆盖第一次的结果。最终获得错误梯度。正确思想应该是:
python
z.grad += 6
z.grad += 2
得到:
python
z.grad == 8

第五步:继续传回参数w与b

由于:z=wx+bz=wx+b所以:∂z∂w=x=2\frac{\partial z}{\partial w}=x=2∂z∂b=1\frac{\partial z}{\partial b}=1因此:∂L∂w=∂L∂z∂z∂w\frac{\partial L}{\partial w} = \frac{\partial L}{\partial z} \frac{\partial z}{\partial w}得到:∂L∂w=8×2=16\boxed{ \frac{\partial L}{\partial w}=8\times2=16 }对于b:∂L∂b=8\boxed{ \frac{\partial L}{\partial b}=8 }而如果希望知道损失对输入x的敏感度:∂z∂x=w=1\frac{\partial z}{\partial x}=w=1所以:∂L∂x=8\boxed{ \frac{\partial L}{\partial x}=8 }

整个反向传播过程

图57-4:局部梯度如何反向汇总
变量反向梯度来源
y^\hat y1损失函数
a1加法左分支
c1加法右分支
z86 + 2
w168 × x
b88 × 1
我们终于完成了一次真正具有分支与合并的反向传播。现在,上一章的梯度下降就能使用结果了。

第六幕:为什么求出了正确的梯度,损失仍然可能变大?

我们已经计算出:∂L∂w=16\frac{\partial L}{\partial w}=16∂L∂b=8\frac{\partial L}{\partial b}=8如果采用学习率:η\eta那么:w′=w−16ηw'=w-16\etab′=b−8ηb'=b-8\eta现在,让我们使用不同学习率试验。

选择学习率0.002

η=0.002\eta=0.002那么:w′=1−16×0.002=0.968w'=1-16\times0.002=0.968b′=1−8×0.002=0.984b'=1-8\times0.002=0.984更新后:z′=0.968×2+0.984=2.92z'=0.968\times2+0.984=2.92新预测:y^′=2.922+2×2.92\hat y'=2.92^2+2\times2.92得到:y^′=14.3664\hat y'=14.3664新损失:L′=12(14.3664−14)2L'=\frac12(14.3664-14)^2约为:L′≈0.0671\boxed{L'\approx0.0671}成功下降。

选择学习率0.01

η=0.01\eta=0.01那么:w′=0.84w'=0.84b′=0.92b'=0.92更新后:z′=0.84×2+0.92=2.6z'=0.84\times2+0.92=2.6新的预测:y^′=2.62+2×2.6=11.96\hat y'=2.6^2+2\times2.6=11.96因此:L′=12(11.96−14)2L'=\frac12(11.96-14)^2得到:L′=2.0808\boxed{L'=2.0808}原来的损失是:0.50.5更新后却变成:2.08082.0808增加了四倍以上。

是不是反向传播计算错了?

没有。我们计算的梯度完全正确。错误来自学习率太大。在当前点附近,负梯度的确提供局部下降方向。但如果迈出太大一步,就可能跨过目标区域,最终落在损失更高的地方。这正好与上一章的学习率研究相呼应。反向传播只负责计算导数,不负责保证任意学习率都能让训练成功。我们必须严格区分:反向求导的正确性。与:优化更新的有效性。它们是两项不同的算法责任。

第七幕:互动实验室——点击每一次反向传播,亲眼看到梯度怎样汇合

现在,让我们把刚才的计算图变成一个可以逐步执行的实验。我们保持:x=2,w=1,b=1x=2,\qquad w=1,\qquad b=1目标:y∗=14y^*=14你可以逐步查看:前向计算。输出梯度。两条分支。梯度在z处相加。参数梯度。以及更新后的损失。然后改变学习率,观察正确的梯度为什么仍然可能导致训练失败。
图57-5:完整的反向传播计算实验台蓝色为前向计算,绿色为局部梯度传播,橙色表示从两条路径汇总的梯度。逐步推进会揭示计算过程。
0 / 5
当前阶段前向计算:建立计算图
w,x,b1.00,2,1.00z3z²92z6ŷ15L=0.5
当前已经得到的梯度
导数量计算关系数值
∂L/∂y^\partial L/\partial\hat y预测−目标—
平方分支贡献2z ∂L/∂a2z\,\partial L/\partial a—
线性分支贡献2 ∂L/∂c2\,\partial L/\partial c—
∂L/∂z\partial L/\partial z两个贡献相加—
∂L/∂w\partial L/\partial wx ∂L/∂zx\,\partial L/\partial z—
∂L/∂b\partial L/\partial b∂L/∂z\partial L/\partial z—

0.0020
这是一个单样本、标量损失、可微计算图。每次应用更新后,程序会使用新参数重新前向计算。不存在自动开始的后台训练。

实验一:观察z处的梯度汇合

点击:“下一步”。直到第四阶段。你会看到:平方分支贡献:66线性分支贡献:22最终:∂L∂z=8\boxed{\frac{\partial L}{\partial z}=8}这项计算是理解反向传播的核心。如果某个变量被使用多次,必须把所有下游路径的贡献累加起来。

实验二:比较学习率

将学习率设为:0.0020.002执行第五阶段。新损失会明显下降。再将参数恢复到初始状态,把学习率改成:0.010.01重新运行。你会发现:即使梯度没有变化,更新效果仍然完全不同。这进一步证实:反向传播与优化器是两项不同的算法。

实验三:连续更新

使用较小的学习率。点击:“应用更新,再执行一轮”。观察:预测。损失。以及参数梯度怎样变化。你会发现:每次参数变化以后,都需要重新执行前向计算和反向求导。因为新的参数会改变全部中间变量。因此,训练不是计算一次梯度以后反复使用同一个结果。而是:前向计算 → 反向求导 → 参数更新 → 再前向计算。这才是一轮轮神经网络训练的基本结构。

第八幕:前向模式与反向模式,为什么同样使用链式法则,计算成本却可能截然不同?

现在,我们已经真正计算过一次反向传播。但这里还隐藏着一个深刻的算法问题。为什么一定要从最终损失反向计算?如果正向传播也能使用链式法则,我们能不能一边计算函数,一边计算导数?当然可以。这就是:Forward-Mode Automatic Differentiation(前向模式自动微分)。而我们刚才使用的是:Reverse-Mode Automatic Differentiation(反向模式自动微分)。二者都利用局部导数和链式法则。但它们传播的信息不同。

前向模式:从一个输入方向出发

假设:z=wx+bz=wx+b我们希望求:∂z∂w\frac{\partial z}{\partial w}可以在计算值的同时,传播:w˙=1\dot w=1x˙=0\dot x=0b˙=0\dot b=0于是:z˙=w˙ x+wx˙+b˙\dot z=\dot w\,x+w\dot x+\dot b所以:z˙=x\boxed{\dot z=x}再执行:y=z2+2zy=z^2+2z导数继续传播:y˙=(2z+2)z˙\dot y=(2z+2)\dot z最后就得到:∂y∂w\frac{\partial y}{\partial w}这项计算可以和函数值一起,从输入向输出推进。

但如果要对一百万个参数全部求导呢?

假设损失函数:L=f(w1,w2,…,w1000000)L=f(w_1,w_2,\ldots,w_{1000000})最终只有一个标量输出。我们希望计算:∇L\nabla L也就是一百万个偏导数。标准的前向模式一次计算,通常适合传播一个选定的输入方向,或少量输入方向的导数信息。如果逐方向计算全部一百万个独立参数方向,就需要很多次导数传播。当然,也可以并行携带多个方向,但代价会随携带的方向数量增长。

反向模式:从一个输出出发

反向模式则相反。先完整计算:LL然后设:∂L∂L=1\frac{\partial L}{\partial L}=1从这个标量输出出发,沿计算图反向传播。只要某个中间变量已经收集完它所有下游贡献,就可以把该变量的梯度分配给它的上游输入。于是,通常只需要一次反向遍历,就能得到所有被追踪参数的梯度。

两种模式的优势

图57-6:前向与反向自动微分的计算结构
前向模式L适合输入维数较小,或只需要少数指定方向的导数。
反向模式L特别适合从少数输出,例如一个标量损失,对大量输入参数求梯度。
更精确地说,如果:f:Rn→Rmf:\mathbb R^n\rightarrow\mathbb R^m那么,其雅可比矩阵是:J=∂f∂x\boxed{ J=\frac{\partial f}{\partial x} }它的维度为:m×nm\times n前向模式天然适合计算:Jv\boxed{Jv}即:Jacobian–Vector Product(JVP)。反向模式天然适合计算:v⊤J\boxed{v^\top J}即:Vector–Jacobian Product(VJP)。当:m=1m=1且:nn非常大时,反向模式尤其适合计算完整梯度。但如果:nn很小,而输出:mm很大,前向模式也可能更合适。2018年Baydin等人的自动微分综述,详细解释了两种模式及其在机器学习中的应用。[机器学习研究杂志]

反向模式真的没有额外代价吗?

当然不是。虽然反向模式通常能够以与前向计算相同数量级的算术工作量得到标量损失的梯度,但它通常需要保存:中间计算结果。运算依赖关系。以及某些反向公式所需的临时数据。例如,为了计算:ddxx2=2x\frac{d}{dx}x^2=2x反向阶段需要知道对应的x。如果前向阶段没有保存它,就可能需要重新计算。因此:反向模式通常节省了对大量参数重复求导的计算量,却可能增加内存需求。这种计算与内存之间的权衡,将一直延续到现代深度学习训练。

第九幕:1985年,多位研究者正在独立寻找训练隐藏层的方法

现在,我们终于可以理解1980年代的关键困难。假设一个网络只有输入层与输出层。如果预测错误,可以直接计算输出层参数应该如何调整。但如果网络包含隐藏层:
text
输入 → 隐藏层1 → 隐藏层2 → 输出
那么,隐藏层并没有直接对应的目标标签。我们不能简单地说:第一个隐藏神经元应该输出1。第二个隐藏神经元应该输出0。因为隐藏单元的作用,是网络训练过程中形成的内部表示。这便是:Credit Assignment Problem(贡献分配问题)。也可以称为:责任分配问题。最终预测错了。究竟应该由哪些隐藏权重承担多大比例的调整?

1985年:Yann LeCun的研究

1985年6月,法国研究者:扬·勒昆(Yann LeCun)在巴黎举行的Cognitiva 85会议上发表:Une procédure d'apprentissage pour réseau à seuil asymétrique《一种非对称阈值网络的学习程序》。论文讨论了具有隐藏单元的网络,以及通过修改连接参数来优化学习目标的方法。[yann.lol][Belinrae]这说明:1986年Nature论文发表之前,相关学习算法已经在不同研究团队中被探索。1986年Rumelhart等人的论文,甚至在参考文献中引用了LeCun的1985年会议成果。[Nature]

David Parker也在研究相关方法

在20世纪80年代中期,美国研究者David Parker也独立研究了与多层网络反向学习有关的方法。有关神经网络史的后续学术研究,记录了Parker、LeCun以及Rumelhart团队在相近时期各自开展相关探索的情况。[Gwern]所以,不能将1986年描述成此前全世界没有任何人想过训练隐藏层。更加准确的历史是:多个研究方向逐渐形成了能够训练多层可微网络的算法,而1986年的研究使这种方法获得更广泛的关注与采用。

1986年论文真正强调的是什么?

Rumelhart、Hinton与Williams在摘要中特别强调:隐藏单元可以通过权重调整,形成对任务有用的内部特征。这意味着:网络不再只是使用研究者提前规定好的全部特征。而是能够利用训练数据,形成新的中间表示。例如,输入可能是图像像素。某些隐藏单元可能逐步对特定局部结构更加敏感。其他隐藏单元可能组合这些结构,参与更复杂的判断。当然,1986年的实验规模与现代图像模型完全不同。但其重要思想已经清晰。训练不仅可以调整最后的预测结果,还能够改变模型内部怎样表示输入。

1986年10月9日的论文为什么具有标志性?

因为它将:多层网络。隐藏表示。误差函数。以及反向计算的权重梯度。清晰地组织成一套可训练的学习方法。同时,它还通过实验展示了学习内部表示的可能性。这使多层神经网络的训练问题从:“隐藏层究竟应该怎样得到反馈?”转变为:“我们已经有一种系统的求导方法,接下来应该怎样设计结构、损失与优化过程?”这并不是神经网络全部困难的终点。但它确实改变了之后大量研究者能够开展的实验。

第十幕:让一个真正的隐藏神经元学会调整自己的权重

现在,我们把刚才的计算图改成一个真正具有隐藏激活函数的神经网络。为了便于手算,网络非常小。只有:一个输入。一个隐藏神经元。一个输出神经元。

前向结构

输入:xx第一层线性变换:z1=w1x+b1z_1=w_1x+b_1隐藏激活:h=σ(z1)h=\sigma(z_1)其中:σ(z)=11+e−z\boxed{ \sigma(z)=\frac1{1+e^{-z}} }是Sigmoid函数。第二层输出:y^=w2h+b2\hat y=w_2h+b_2最后定义平方损失:L=12(y^−y)2L=\frac12(\hat y-y)^2整个网络可以写成:x→w1,b1z1→σh→w2,b2y^→误差L\boxed{ x\xrightarrow{w_1,b_1}z_1 \xrightarrow{\sigma}h \xrightarrow{w_2,b_2}\hat y \xrightarrow{\text{误差}}L }

先给出具体数字

我们选择:x=1,y=0x=1,\qquad y=0初始参数:w1=0,b1=0w_1=0,\qquad b_1=0w2=2,b2=0w_2=2,\qquad b_2=0先计算:z1=0×1+0=0z_1=0\times1+0=0Sigmoid:h=σ(0)=0.5h=\sigma(0)=0.5输出:y^=2×0.5+0=1\hat y=2\times0.5+0=1损失:L=12(1−0)2L=\frac12(1-0)^2得到:L=0.5\boxed{L=0.5}

第一步:输出层梯度

对于损失:L=12(y^−y)2L=\frac12(\hat y-y)^2有:∂L∂y^=y^−y=1\frac{\partial L}{\partial\hat y} = \hat y-y=1因此:∂L∂w2=∂L∂y^∂y^∂w2\frac{\partial L}{\partial w_2} = \frac{\partial L}{\partial\hat y} \frac{\partial\hat y}{\partial w_2}由于:∂y^∂w2=h=0.5\frac{\partial\hat y}{\partial w_2}=h=0.5得到:∂L∂w2=0.5\boxed{ \frac{\partial L}{\partial w_2}=0.5 }而:∂L∂b2=1\boxed{ \frac{\partial L}{\partial b_2}=1 }

第二步:把梯度传给隐藏输出h

因为:y^=w2h+b2\hat y=w_2h+b_2所以:∂y^∂h=w2=2\frac{\partial\hat y}{\partial h}=w_2=2于是:∂L∂h=2\boxed{ \frac{\partial L}{\partial h}=2 }注意:隐藏神经元并没有直接的正确标签。它只通过后面输出层的计算关系,获得来自损失的导数信息。

第三步:穿过Sigmoid

Sigmoid的导数为:σ′(z)=σ(z)(1−σ(z))\boxed{ \sigma'(z)=\sigma(z)(1-\sigma(z)) }当:z1=0z_1=0时:h=0.5h=0.5所以:σ′(0)=0.5(1−0.5)=0.25\sigma'(0)=0.5(1-0.5)=0.25因此:∂L∂z1=∂L∂h∂h∂z1\frac{\partial L}{\partial z_1} = \frac{\partial L}{\partial h} \frac{\partial h}{\partial z_1}得到:∂L∂z1=2×0.25=0.5\boxed{ \frac{\partial L}{\partial z_1} = 2\times0.25=0.5 }

第四步:得到第一层权重梯度

因为:z1=w1x+b1z_1=w_1x+b_1所以:∂z1∂w1=x=1\frac{\partial z_1}{\partial w_1}=x=1于是:∂L∂w1=0.5\boxed{ \frac{\partial L}{\partial w_1}=0.5 }同理:∂L∂b1=0.5\boxed{ \frac{\partial L}{\partial b_1}=0.5 }这便完成了对全部四个参数的求导。
图57-7:两层小网络的一次完整反向传播
参数原始值梯度
w1w_100.5
b1b_100.5
w2w_220.5
b2b_201

执行一次梯度下降

设:η=0.1\eta=0.1那么:w1′=0−0.1×0.5=−0.05w_1'=0-0.1\times0.5=-0.05b1′=−0.05b_1'=-0.05w2′=2−0.1×0.5=1.95w_2'=2-0.1\times0.5=1.95b2′=−0.1b_2'=-0.1使用新参数重新前向计算:z1′=−0.05×1−0.05=−0.1z_1'=-0.05\times1-0.05=-0.1h′=σ(−0.1)≈0.47502h'=\sigma(-0.1)\approx0.47502y^′=1.95×0.47502−0.1\hat y' = 1.95\times0.47502-0.1约为:0.826290.82629新损失:L′=12(0.82629)2L'=\frac12(0.82629)^2约为:L′≈0.3414\boxed{L'\approx0.3414}与原来的:0.50.5相比,成功下降。

最重要的发现

隐藏层的:w1w_1没有直接连接最终损失。但它仍然获得了:∂L∂w1\frac{\partial L}{\partial w_1}这项梯度。原因是:反向传播利用计算图中的全部依赖关系,把输出误差沿局部导数传回了隐藏层。因此,多层网络的每个可微参数,都有机会根据最终任务误差进行调整。这就是:Credit Assignment(贡献分配)在可微神经网络中的一项核心计算解决方案。

第十一幕:XOR为什么成为多层神经网络历史中如此著名的例子?

现在,我们终于能够理解:为什么多层网络需要隐藏层。考虑一个只有两个输入的逻辑问题。Exclusive OR(异或,XOR)。规则是:两个输入不同,输出1。两个输入相同,输出0。真值表如下。
x1x_1x2x_2XOR
000
011
101
110

为什么单个线性分类器做不到?

假设单个线性分类器使用:z=w1x1+w2x2+bz=w_1x_1+w_2x_2+b如果:z>0z>0就预测1。否则预测0。为了正确处理:(0,0)→0(0,0)\rightarrow0需要:b≤0b\le0对于:(1,0)→1(1,0)\rightarrow1需要:w1+b>0w_1+b>0对于:(0,1)→1(0,1)\rightarrow1需要:w2+b>0w_2+b>0最后:(1,1)→0(1,1)\rightarrow0需要:w1+w2+b≤0w_1+w_2+b\le0但是:从中间两个条件:w1+b>0w_1+b>0w2+b>0w_2+b>0相加:w1+w2+2b>0w_1+w_2+2b>0而由于:w1+w2+b≤0w_1+w_2+b\le0两式结合,就会推出:b>0b>0这与:b≤0b\le0矛盾。因此,单个线性阈值分类器不可能完全表示XOR。

这不意味着所有神经网络都无法解决XOR

关键在于:单层线性分类器的表达能力有限。如果我们引入隐藏层,并使用非线性激活函数,就可以组合多个简单决策区域。例如:一个隐藏单元检测:是否至少有一个输入为1?另一个隐藏单元检测:是否两个输入都为1?最后将两项信息组合。就有机会表达异或。

一个手工构造的两隐藏单元例子

令:σ(z)=11+e−z\sigma(z)=\frac1{1+e^{-z}}构造:h1=σ(10(x1+x2−0.5))h_1=\sigma\left(10(x_1+x_2-0.5)\right)h2=σ(10(x1+x2−1.5))h_2=\sigma\left(10(x_1+x_2-1.5)\right)最终输出:y^=σ(10(h1−h2−0.5))\boxed{ \hat y=\sigma\left(10(h_1-h_2-0.5)\right) }它对于四种XOR输入,会给出接近:0。1。1。0。的输出。这个例子只说明:一个具有非线性隐藏层的网络,能够表达单个线性分类器无法表达的关系。这里的权重是我们人为设计的。并不是通过训练自动学出来的。真正的学习任务,还需要从初始参数出发,借助数据、损失函数、反向传播与优化器,逐步找到合适的权重。

1969年的《Perceptrons》与XOR的历史

1969年,Marvin Minsky与Seymour Papert出版:Perceptrons: An Introduction to Computational Geometry《感知机:计算几何导论》。该书严肃研究了特定感知机架构的表达能力与局限。XOR以及其他非线性可分问题,成为人们讨论单层线性分类器局限时经常使用的例子。但不能将这段历史简单写成:“两位研究者发现XOR,于是全球神经网络研究立刻停止。”不同地区的神经网络、模式识别、控制与自适应系统研究,在此后仍然继续发展。真正需要理解的,是:表达能力不足的模型,即使优化方法再好,也无法通过调整权重获得它根本无法表示的函数。而多层非线性结构解决了部分表达能力问题之后,又带来了新的难题。隐藏层参数怎样根据最终误差学习?这就使1980年代反向传播研究显得格外重要。

1986年的成果,不只是让损失下降

Rumelhart、Hinton与Williams强调的是:网络的隐藏单元能够形成有用的内部表示。这项思想后来成为深度学习中最重要的主题之一。一层学习局部特征。另一层组合这些特征。更深层逐渐形成与任务有关的复杂表示。当然,这并不意味着每个隐藏单元都必然拥有一种能被人类轻松命名的语义。真实网络中的表示可能是分布式的。某项概念,也可能由大量神经元共同编码。但从算法角度说,1986年的关键已经非常明确。网络不仅能够修改最后的输出权重,还能够让隐藏层随着任务误差共同改变。

第十二幕:Python实战——只用一个Value类,手写微型自动微分引擎

现在,我们将本章的数学思想,真正写成一套可以运行的程序。它不需要:PyTorch。TensorFlow。JAX。也不需要NumPy。只使用Python标准库。但它能够自动完成:计算图构造。前向计算。局部求导。拓扑排序。多路径梯度累加。以及一次完整反向传播。

第一部分:Value对象

我们首先定义:Value。一个Value代表一个标量值。它不仅知道自己的数值,还知道:自己由哪些其他Value计算出来。自己的梯度是多少。以及应该怎样将梯度传回上游变量。
python

import math


class Value:
    def __init__(
        self,
        data,
        children=(),
        op="",
        label="",
    ):
        self.data = float(data)
        self.grad = 0.0

        # 当前节点依赖的上游节点
        self._prev = tuple(children)

        self._op = op
        self.label = label

        # 当前节点的局部反向传播函数
        self._backward = lambda: None

    def __repr__(self):
        return (
            f"Value(data={self.data:.6f}, "
            f"grad={self.grad:.6f})"
        )

    def __add__(self, other):
        other = (
            other
            if isinstance(other, Value)
            else Value(other)
        )

        out = Value(
            self.data + other.data,
            (self, other),
            op="+",
        )

        def backward():
            # 加法对两个输入的局部导数均为1。
            self.grad += out.grad
            other.grad += out.grad

        out._backward = backward
        return out

    def __radd__(self, other):
        return self + other

    def __mul__(self, other):
        other = (
            other
            if isinstance(other, Value)
            else Value(other)
        )

        out = Value(
            self.data * other.data,
            (self, other),
            op="*",
        )

        def backward():
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad

        out._backward = backward
        return out

    def __rmul__(self, other):
        return self * other

    def __pow__(self, exponent):
        if not isinstance(exponent, (int, float)):
            raise TypeError(
                "本教学版本仅支持常数指数"
            )

        out = Value(
            self.data ** exponent,
            (self,),
            op="pow",
        )

        def backward():
            self.grad += (
                exponent
                * self.data ** (exponent - 1)
                * out.grad
            )

        out._backward = backward
        return out

    def __neg__(self):
        return self * -1

    def __sub__(self, other):
        return self + (-other if isinstance(other, Value)
                       else -other)

    def __rsub__(self, other):
        return other + (-self)

    def tanh(self):
        result = math.tanh(self.data)

        out = Value(
            result,
            (self,),
            op="tanh",
        )

        def backward():
            self.grad += (
                (1 - result * result)
                * out.grad
            )

        out._backward = backward
        return out

    def sigmoid(self):
        # 数值稳定的Sigmoid
        if self.data >= 0:
            result = 1 / (1 + math.exp(-self.data))
        else:
            e = math.exp(self.data)
            result = e / (1 + e)

        out = Value(
            result,
            (self,),
            op="sigmoid",
        )

        def backward():
            self.grad += (
                result * (1 - result)
                * out.grad
            )

        out._backward = backward
        return out
我们还没有实现:backward()。但现在,前向计算已经能够自动建立计算图。例如:
python
w = Value(1)
x = Value(2)
b = Value(1)

z = w*x + b
程序生成的z,不只是数值3。它还知道:z依赖一次乘法和一次加法。因此,后续执行反向传播时,就能够利用这项结构。

第二部分:为什么反向传播需要拓扑排序?

考虑:
text
w,x,b → z → a,c → y → L
如果我们随便选择一个顺序执行局部求导,就可能出现:还没收集完下游梯度。就提前把某个节点的梯度传给上游。尤其当z同时进入a和c两条分支时,这样很容易出错。因此,我们需要按照计算依赖关系排序。前向计算时,任何节点都必须在依赖项已经完成之后,才能正确获得数值。反向计算时,则需要按照相反顺序传播梯度。这就是:Topological Order(拓扑顺序)。我们的计算图在一次普通前向执行中,可以看成一张有向无环图。于是,使用深度优先搜索,就能够建立一个拓扑顺序。

为Value添加backward方法

将下面的方法放入刚才的Value类中,或者在类定义结束后作为方法绑定。
python

def backward(self):
    """
    对当前标量输出执行反向模式自动微分。

    使用拓扑排序:
        先建立前向依赖顺序
        再按照逆序传播梯度
    """

    topo = []
    visited = set()

    def build(node):
        if node in visited:
            return

        visited.add(node)

        for parent in node._prev:
            build(parent)

        topo.append(node)

    build(self)

    # 本次调用重新计算完整梯度
    for node in topo:
        node.grad = 0.0

    # dL/dL = 1
    self.grad = 1.0

    for node in reversed(topo):
        node._backward()


Value.backward = backward
这里有三项必须理解的设计细节。第一:visited保证同一个节点不会在拓扑列表里被重复加入。第二:reversed(topo)确保先处理下游,再处理上游。第三:每个局部反向函数都使用:
python
+=
而不是:
python
=
这样,如果同一个节点被多条分支共同使用,其梯度贡献能够正确累加。

第三部分:用第五幕的分叉计算图验证

现在,我们重新实现:z=wx+bz=wx+ba=z2a=z^2c=2zc=2zy^=a+c\hat y=a+cL=12(y^−14)2L=\frac12(\hat y-14)^2
python

x = Value(2, label="x")
w = Value(1, label="w")
b = Value(1, label="b")

target = Value(14)

z = w*x + b

a = z**2
c = 2*z

prediction = a + c

loss = 0.5 * (prediction - target)**2

print("前向结果:")
print("z =", z.data)
print("a =", a.data)
print("c =", c.data)
print("预测 =", prediction.data)
print("损失 =", loss.data)

loss.backward()

print()
print("反向梯度:")
print("dL/dz =", z.grad)
print("dL/dw =", w.grad)
print("dL/db =", b.grad)
print("dL/dx =", x.grad)

assert abs(loss.data - 0.5) < 1e-12
assert abs(z.grad - 8.0) < 1e-12
assert abs(w.grad - 16.0) < 1e-12
assert abs(b.grad - 8.0) < 1e-12
assert abs(x.grad - 8.0) < 1e-12

print("自动微分测试通过")
预期输出:
text
前向结果:
z = 3.0
a = 9.0
c = 6.0
预测 = 15.0
损失 = 0.5

反向梯度:
dL/dz = 8.0
dL/dw = 16.0
dL/db = 8.0
dL/dx = 8.0

自动微分测试通过
我们没有为w单独手工写出:∂L∂w=16\frac{\partial L}{\partial w}=16的完整求导公式。只定义了:加法。乘法。幂运算。以及几个激活函数的局部反向规则。程序就能够根据实际执行的计算图,自动组合这些局部导数。这就是自动微分的魅力。

第四部分:验证变量被重复使用时,不会丢失梯度

现在使用:f(x)=x2+xf(x)=x^2+x在:x=3x=3时:f′(x)=2x+1=7f'(x)=2x+1=7
python

x = Value(3)

y = x*x + x

y.backward()

print("y =", y.data)
print("dy/dx =", x.grad)

assert y.data == 12
assert x.grad == 7

print("重复使用变量的梯度累加正确")
为什么这段测试很重要?因为:
python
x*x
不仅重复使用了x这个变量,还在一个乘法节点中,将同一个对象同时作为两个输入。正确的乘法反向规则,会分别给左右输入贡献梯度。由于它们恰好是同一个Value对象,这两次贡献必须累加。如果实现有误,就可能只得到:33或者:44而不是:77这项测试验证了:自动微分引擎能够正确处理计算图中的共享节点与重复输入。

第五部分:使用自动微分训练参数

现在,我们把求导结果真正用于梯度下降。注意,每轮训练都需要根据更新后的参数,重新构造新的前向计算图。
python

x = Value(2)
w = Value(1)
b = Value(1)

target = Value(14)

learning_rate = 0.002

for step in range(40):

    # 每轮重新建立计算图
    z = w*x + b
    prediction = z*z + 2*z
    loss = 0.5 * (prediction - target)**2

    # 计算全部参数梯度
    loss.backward()

    if step % 5 == 0:
        print(
            f"step={step:2d}",
            f"loss={loss.data:.8f}",
            f"w={w.data:.6f}",
            f"b={b.data:.6f}",
        )

    # 在图的本轮梯度全部计算完成后,
    # 再修改参数。
    w.data -= learning_rate * w.grad
    b.data -= learning_rate * b.grad

print("训练结束")
这里有一项必须遵守的原则。不要在反向传播尚未完成时,随意改变当前计算图所依赖的参数值。因为局部求导公式可能依赖前向执行时的原始中间值。如果这些值在反向过程中被修改,计算得到的梯度就可能与实际前向函数不一致。现代自动微分框架会通过计算图、保存张量和版本检查等机制,帮助处理这类问题。但我们自己写的教学引擎非常简单。所以,最安全的流程是:完整前向计算。完整反向求导。最后统一更新参数。再重新构建下一轮计算图。

第十三幕:反向传播算得很快,但我们怎么证明它没有算错?

一个非常实际的问题来了。假设我们已经编写了一套自动微分引擎。它可以返回:
text
dL/dw = 16
但是,程序没有报错,不意味着梯度一定正确。可能存在:局部导数写错。某个分支漏加。拓扑顺序错误。激活函数导数错误。或者参数共享时的梯度处理错误。那么,我们怎样验证它?

方法一:用数学手算验证小例子

例如,我们已经推导:L=12[(wx+b)2+2(wx+b)−14]2L=\frac12[(wx+b)^2+2(wx+b)-14]^2当:x=2,w=1,b=1x=2,\quad w=1,\quad b=1时:∂L∂w=16\frac{\partial L}{\partial w}=16∂L∂b=8\frac{\partial L}{\partial b}=8所以,自动微分程序应该得到相同结果。这是非常直接的单元测试。

方法二:使用有限差分

假设我们有一个参数:ww可以通过轻微改变它,估计损失对w的变化率。使用中心差分:∂L∂w≈L(w+h)−L(w−h)2h\boxed{ \frac{\partial L}{\partial w} \approx \frac{L(w+h)-L(w-h)}{2h} }如果:hh选择合适,那么数值结果应当与自动微分导数接近。

实现梯度检查

python

def ordinary_loss(w, b, x=2.0, target=14.0):
    z = w*x + b
    prediction = z*z + 2*z

    return 0.5 * (prediction-target)**2


def numerical_gradient_w(w, b, h=1e-6):
    return (
        ordinary_loss(w+h, b)
        - ordinary_loss(w-h, b)
    ) / (2*h)


def numerical_gradient_b(w, b, h=1e-6):
    return (
        ordinary_loss(w, b+h)
        - ordinary_loss(w, b-h)
    ) / (2*h)


print("w数值梯度:", numerical_gradient_w(1, 1))
print("b数值梯度:", numerical_gradient_b(1, 1))

assert abs(
    numerical_gradient_w(1, 1) - 16
) < 1e-5

assert abs(
    numerical_gradient_b(1, 1) - 8
) < 1e-5

print("有限差分梯度检查通过")
如果程序写得正确,应该分别得到接近:1616和:88的结果。

为什么有限差分不是反向传播的替代品?

因为如果模型具有一百万个参数,我们可能需要针对每个参数分别执行正负扰动。这意味着大量额外的前向计算。而反向模式自动微分通常可以在一次前向与相应反向遍历后,获得整个标量损失对全部参数的梯度。所以,有限差分更适合作为:开发和调试阶段的正确性检查工具。而不是大型神经网络每一步训练时的主要梯度计算方法。

三种求导方法的比较

图57-8:不同求导方法适合解决什么问题?
方法优势主要限制
手工解析求导透明、易于验证数学性质复杂计算图维护困难
有限差分实现简单,适合梯度检查近似误差、函数求值成本
前向AD适合少量输入方向或JVP全部高维梯度可能较贵
反向AD适合标量损失对大量参数求梯度反向中间量和内存成本
2018年的自动微分综述,对这些方法的定义、区别和计算模式进行了系统梳理。[机器学习研究杂志]

为什么现代框架仍然需要梯度检查?

例如,在PyTorch中,很多标准运算已经实现了自动求导。但如果我们自行开发一种特殊的自定义算子,仍然需要提供或验证相应的反向规则。因此,自动求导框架虽然能够替我们组合局部导数,却并不能保证我们自己写下的每一项数学定义天然正确。尤其当操作包含:非标准数值函数。条件分支。自定义矩阵运算。或者特殊的梯度处理规则。时,梯度检查仍然具有实际价值。PyTorch官方自动微分文档,也明确描述了计算图、反向累积以及梯度计算过程中需要注意的状态管理问题。[PyTorch main documentation]

第十四幕:为什么网络越来越深以后,错误可能再也传不到前面的神经元?

我们已经知道:反向传播可以将最终损失的梯度,一层层传向更早的参数。但这似乎还留下一个问题。如果网络只有:三层。五层。十层。可能相对容易训练。可如果网络拥有:几十层。上百层。甚至更加复杂的结构呢?梯度能否稳定地穿过全部这些运算?

链式法则有一个隐蔽的危险

考虑一条简化的计算链:x0→x1→x2→⋯→xnx_0\rightarrow x_1\rightarrow x_2\rightarrow\cdots\rightarrow x_n根据链式法则:∂xn∂x0=∏i=1n∂xi∂xi−1\boxed{ \frac{\partial x_n}{\partial x_0} = \prod_{i=1}^{n} \frac{\partial x_i}{\partial x_{i-1}} }也就是说:梯度需要经过许多局部导数的连乘。现在假设,每一层的局部导数都等于:0.250.25那么:穿过1层:0.250.25穿过5层:0.255≈0.00097660.25^5\approx0.0009766穿过10层:0.2510≈9.54×10−70.25^{10}\approx9.54\times10^{-7}穿过20层:0.2520≈9.09×10−130.25^{20}\approx9.09\times10^{-13}可以看到:梯度在不断连乘中,迅速趋近于零。这就是:Vanishing Gradient(梯度消失)的一种典型数学机制。

为什么Sigmoid容易出现这种问题?

Sigmoid导数为:σ′(z)=σ(z)(1−σ(z))\sigma'(z)=\sigma(z)(1-\sigma(z))由于:0<σ(z)<10<\sigma(z)<1所以:0<σ′(z)≤0.25\boxed{ 0<\sigma'(z)\le0.25 }如果一条反向传播路径连续经过许多Sigmoid局部导数,而且没有足够大的其他因子抵消这种衰减,梯度可能变得非常小。但不能由此推断:任何使用Sigmoid的网络,只要有十层就一定无法训练。因为完整反向传播还包含:权重矩阵。分支求和。不同激活区域。以及其他结构。实际梯度取决于这些因素的共同作用。

梯度也可能爆炸

如果反向链条中的一些局部增益持续大于1。例如每层因子都是:1.51.5那么:1.520≈33251.5^{20}\approx3325梯度可能迅速放大。这种现象叫作:Exploding Gradient(梯度爆炸)。它可能导致:参数更新过大。损失剧烈变化。以及数值不稳定。

1997年:LSTM尝试改善长期依赖学习

1997年11月15日,两位研究者:塞普·霍赫赖特(Sepp Hochreiter)。于尔根·施密德胡伯(Jürgen Schmidhuber)。发表:Long Short-Term Memory《长短期记忆》。发表于《Neural Computation》第9卷第8期,第1735—1780页。[MIT Press]研究关注循环网络在学习长期依赖时,梯度容易衰减的问题。LSTM通过特殊的状态与门控结构,使网络能够在某些条件下更加有效地保留和传递长期信息。这项方法并不意味着:所有长时间序列中的梯度消失问题都被无条件彻底解决。但它为训练具有长期依赖的循环网络提供了重要的结构性改进。这也将成为下一章的核心历史线索。

2015—2016年:残差网络让信息拥有另一条通路

另一项非常重要的发展,发生在深层前馈网络中。2015年12月,何恺明、张祥雨、任少卿与孙剑提交:Deep Residual Learning for Image Recognition《用于图像识别的深度残差学习》。该研究正式发表于2016年CVPR。[Microsoft Research]其核心结构之一,可以写成:y=x+F(x)\boxed{y=x+F(x)}其中:F(x)F(x)是某些可学习层的组合。而:xx通过一条跳跃连接,直接加入最终输出。从求导角度看:dydx=1+F′(x)\boxed{ \frac{dy}{dx}=1+F'(x) }这意味着:梯度不只沿着:FF内部的复杂计算路径传播。还存在一条直接的恒等映射贡献。这有助于改善某些深层网络中的优化难题。不过,残差连接也不保证:所有梯度问题都一定消失。更不能将其理解成:“无论F内部发生什么,梯度永远等于1。”因为总导数是:1+F′(x)1+F'(x)实际还可能存在矩阵、非线性以及其他复杂结构。

为什么这些方法都与反向传播有关?

因为我们已经能够计算梯度。但随之需要解决:如何让梯度在复杂计算图中有效地传递?这使研究者开始直接从:梯度传播的数学结构出发,设计神经网络的内部架构。所以,反向传播并不只是一个训练时调用的函数。它还深刻影响着研究者怎样设计能够训练成功的网络。

第十五幕:今天的自动微分系统,为什么既像一台计算机器,又像一本账本?

现在,让我们从1970年代与1980年代回到现代。如果我们使用PyTorch训练神经网络,可能会写出:
python
prediction = model(x)
loss = criterion(prediction, target)

loss.backward()

optimizer.step()
其中:
python
loss.backward()
看起来只是一行代码。但在这行代码背后,实际上包含了本章讨论的许多计算步骤。

前向阶段:记录运算依赖

现代自动微分框架会追踪相关张量运算。例如:矩阵乘法。加法。非线性激活。归一化。损失计算。框架会记录必要的计算关系,以及反向求导需要的中间结果。对于PyTorch的自动求导系统,官方文档将其概念化为:由算子与张量依赖关系构成的动态计算图。前向阶段记录运算。反向阶段根据链式法则计算并累加梯度。[PyTorch main documentation]

反向阶段:不再重新展开全部数学公式

当我们调用:
python
loss.backward()
时,系统从损失开始。对相应的计算图执行反向求导。对于每个算子,调用相应的局部梯度计算机制。在存在多个下游使用者时,将不同路径的梯度贡献合并。最后,参数的梯度便可以用于优化器更新。

为什么需要清空梯度?

在PyTorch中,参数的:
python
.grad
默认具有累加语义。如果连续执行两次:
python
loss.backward()
而没有按需要清空或管理参数梯度,梯度可能会在原来基础上继续累积。这与我们在第五幕讨论的:
python
+=
密切相关。但需要区分:计算图内部的多路径梯度累加。与:不同训练批次之间是否刻意进行梯度累积。前者是链式求导的必需规则。后者是一项训练策略。例如,在显存有限的情况下,我们可能将一个大批次拆成若干个小批次,分别计算梯度并累积,然后统一更新参数。但如果我们希望每个批次独立更新参数,就通常需要在合适的位置执行:
python
optimizer.zero_grad()
或者其他等价的梯度清空操作。官方文档也明确说明了这项状态管理机制。[torch.autograd — PyTorch main documentation]

反向传播为什么占用大量显存?

因为很多运算在反向求导时,需要使用前向阶段的中间值。例如:某层激活函数的输入或输出。矩阵乘法的部分操作数。归一化运算的统计量。如果网络非常深。或者中间激活张量非常大。保存这些信息就会消耗大量显存。有时候,显存中占据很大比例的,并不只是模型参数。还有:前向激活。参数梯度。优化器状态。以及临时计算缓冲区。

如果不保存全部中间值呢?

研究者可以在某些情况下使用:Activation Checkpointing(激活检查点)。基本思想是:前向阶段不保存每一项中间激活。只保存部分检查点。到了反向阶段,如果需要某些中间结果,就从之前的检查点重新计算。这种方法通常可以降低内存占用。但代价是:需要额外的重新计算。所以它是一项:用更多计算换取更少内存的工程权衡。这与Linnainmaa在1976年论文中讨论反向误差敏感度计算所需存储成本,具有很有意思的历史呼应。但不能据此说:现代神经网络激活检查点的全部具体机制,已经在1976年被完整设计出来。它们共享的是问题结构:反向计算需要前向信息。为了减少存储,我们可以选择性地重新计算。

还有一项深刻的区别:反向传播并不等于智能

一套自动微分引擎,能够准确计算:∂L∂w\frac{\partial L}{\partial w}但它并不知道:L究竟代表什么。这个损失函数是否有意义。数据是否正确。模型是否存在偏差。训练结果能否泛化。反向传播只保证,在满足相关可微与实现条件时,我们能够高效计算指定计算过程的导数。它不会自动决定:什么样的目标值得优化?什么样的行为才是正确的?这仍然需要人类研究者与系统设计者认真定义。自动微分让复杂模型的参数变得可以训练,但并没有替我们解决学习目标与现实任务之间的全部关系。

尾声:错误没有真正沿着电线倒流,但导数可以沿着计算关系返回

1964年。Wengert研究如何让计算机自动计算复杂程序的导数。他将函数拆解成一系列基本运算。这些运算能够保存中间变量与局部求导关系。1970年。Linnainmaa研究数值程序中的舍入误差。他希望知道最终计算误差,对前面每一项局部误差究竟有多敏感。这个问题,推动了反向累积求导方法的发展。1974年。Werbos研究预测与学习系统。他将类似的敏感度分析思想,与通过误差调整内部参数的问题联系起来。1985年。LeCun以及其他研究者,正在探索能够训练隐藏单元的网络学习规则。1986年。Rumelhart、Hinton与Williams的论文,进一步展示了多层网络怎样利用误差反向传播,调整隐藏层参数并学习任务所需要的内部表示。从此,这项方法在神经网络研究中获得了越来越重要的位置。

但是,反向传播究竟让什么东西向后流动?

它不是让已经发生的前向计算倒过来重新发生。也不是让最终预测误差作为一个普通数值,被每一层原封不动地接收。真正反向传播的,是:最终目标对中间变量的导数信息。如果最终损失为:LL中间变量为:vv那么反向传播关心的是:∂L∂v\boxed{\frac{\partial L}{\partial v}}即:如果v发生一个微小变化,最终损失的一阶变化将受到怎样的影响?对于每个局部运算,我们只需要知道:它的输入是什么?输出是什么?局部导数是什么?然后利用链式法则,把梯度传回上游。如果出现分支,就累加。如果一个变量被重复使用,就汇总所有贡献。这便形成了反向模式自动微分的计算基础。

本章最重要的五个公式

1. 链式法则dLdx=dLdydydx\boxed{ \frac{dL}{dx} = \frac{dL}{dy}\frac{dy}{dx} }
2. 分支梯度累加∂L∂z=∑j∂L∂vj∂vj∂z\boxed{ \frac{\partial L}{\partial z} = \sum_j \frac{\partial L}{\partial v_j} \frac{\partial v_j}{\partial z} }
3. 隐藏层误差信号对于全连接层与逐元素可微激活函数:δ(l)=(W(l+1))⊤δ(l+1)⊙ϕ′(z(l))\boxed{ \delta^{(l)} = \left(W^{(l+1)}\right)^\top \delta^{(l+1)} \odot\phi'(z^{(l)}) }⊙\odot表示逐元素相乘;这是一种标准前馈网络的反向传播表达,并非所有网络结构都只需照搬该公式。
4. 全连接层的权重梯度∂L∂W(l)=δ(l)(a(l−1))⊤\boxed{ \frac{\partial L}{\partial W^{(l)}} = \delta^{(l)} \left(a^{(l-1)}\right)^\top }
5. 利用梯度更新参数θt+1=θt−η∇θL\boxed{ \theta_{t+1} = \theta_t-\eta\nabla_\theta L }
第一项是微积分。第二项是反向模式自动微分中处理分叉计算图的关键。第三项与第四项,将链式法则组织成多层神经网络的梯度计算。第五项则回到了上一章的梯度下降。五个公式共同形成了:前向计算 → 反向求导 → 参数更新。这是一种极其重要的计算流程。但它仍然不保证:损失一定下降。训练一定收敛。模型一定学到真正有用的规律。更不能保证模型面对新数据时永远正确。它提供的是:一种让多层可微模型能够根据明确损失函数进行参数优化的高效计算机制。

第六篇前两章,终于连成了一套完整的学习机制

《会学习的算法》· 第56—57章
第56章
沿着山坡寻找最低点:梯度下降怎样利用已经获得的梯度,选择参数的更新方向与步长,使损失得到改善?

第57章
错误怎样穿越一层层神经元:反向传播怎样利用计算图、链式法则和梯度累加,高效计算大量隐藏参数对最终损失的影响?
上一章回答:知道梯度以后,下一步该怎样修改参数?这一章回答:面对复杂的多层计算过程,怎样高效得到每个参数的梯度?它们看起来只是两项数学计算。但组合起来,就使神经网络能够根据数据反复调整自身参数。一次预测。一次损失计算。一次反向传播。一次参数更新。然后,再次预测。这些过程重复成千上万次。隐藏参数逐渐改变。模型输出的行为也随之改变。这就是现代大量神经网络训练任务背后的基本机制。机器学习的关键,不是让错误神奇地消失,而是让错误成为可计算、可利用的参数调整信息。

历史档案:反向传播的真实发展脉络

年份人物与成果历史意义
1964年8月R. E. Wengert,自动导数计算程序将计算程序分解为基本运算,自动计算导数
1970年Seppo Linnainmaa,硕士论文反向累积敏感度分析的重要早期来源
1974年Paul Werbos,博士研究将相关导数传播思想用于预测和学习系统
1976年6月Linnainmaa,舍入误差的泰勒展开正式发表反向敏感度计算相关成果
1985年6月Yann LeCun,Cognitiva 85探索带有隐藏单元的网络训练方法
1980年代中期David Parker等研究独立发展与误差反向传播有关的学习算法
1986年10月9日Rumelhart、Hinton、Williams系统展示多层神经网络通过反向传播学习内部表示
1997年11月15日Hochreiter、Schmidhuber,LSTM研究循环网络的长期依赖与梯度传递问题
2015年12月He、Zhang、Ren、Sun,残差网络预印本通过残差学习和跳跃连接改善深层网络训练
2016年ResNet正式发表于CVPR展示更深层网络架构的训练与视觉识别能力

主要参考文献与一手资料

1964年 · Wengert的自动求导程序A Simple Automatic Derivative Evaluation Program《Communications of the ACM》第7卷第8期,第463—464页。自动微分计算结构的重要早期文献。

1970—1976年 · Linnainmaa的反向敏感度研究Taylor Expansion of the Accumulated Rounding Error《BIT Numerical Mathematics》第16卷,第146—160页。1976年的原论文及其参考文献,记录了1970年赫尔辛基大学的硕士研究。

1974年 · Werbos博士研究Beyond Regression: New Tools for Prediction and Analysis in the Behavioral Sciences(博士论文)Werbos本人关于反向传播历史的回顾教程用于核查Werbos如何把反向敏感度分析与学习、预测及动态系统联系起来;个人回顾与原论文应当区分对待。

1985年 · LeCun关于隐藏单元学习的论文Une procédure d'apprentissage pour réseau à seuil asymétrique(原论文)Cognitiva 85,巴黎,1985年6月4—7日,第599—604页。展示1986年以前已有独立的多层网络学习探索。

1986年 · 本章最重要的神经网络原始论文Rumelhart、Hinton、Williams — Learning Representations by Back-Propagating ErrorsGeoffrey Hinton官方研究文献目录《Nature》第323卷,第533—536页。重点核查误差反向传播如何使多层网络隐藏单元获得有用的内部表示。

2018年 · 自动微分的系统历史综述Automatic Differentiation in Machine Learning: A SurveyBaydin、Pearlmutter、Radul、Siskind,《Journal of Machine Learning Research》第18卷第153篇。用于核查前向AD、反向AD、VJP、JVP以及神经网络反向传播之间的关系。

1997年 · LSTMHochreiter & Schmidhuber — Long Short-Term Memory《Neural Computation》第9卷第8期,第1735—1780页。研究长期依赖学习中的误差信号衰减。

2016年 · 深度残差学习Deep Residual Learning for Image Recognition何恺明、张祥雨、任少卿、孙剑,CVPR 2016。介绍残差块与跳跃连接如何帮助更深层网络进行优化。

现代自动微分的工程实践PyTorch官方文档 — Autograd Mechanics计算图、反向求导、梯度累加和内存管理机制的官方说明。
**史实与教学重构说明:**1964年的Wengert论文、1970年Linnainmaa的学位研究、1974年Werbos论文、1985年LeCun会议论文和1986年Nature论文,均有对应的一手或可核查书目。本文没有将反向传播的全部思想归功于1986年三位作者,也没有将Linnainmaa的浮点舍入误差研究误写成神经网络训练实验。本章的分叉计算图、Sigmoid小网络、XOR示例、互动实验和Python自动微分引擎,均为教学构造。其数学公式和数值可以独立复核,但不代表历史人物当年运行了这些特定代码。Python程序只处理有限种标量可微运算,不包含张量广播、GPU并行、自定义算子完整检查或生产级自动微分框架所需的全部机制。
下一章预告 · 第六篇 第58章

《让机器记住昨天:循环神经网络与长短期记忆的诞生》

1980年代,神经网络研究者已经开始尝试训练多层模型。但普通前馈网络还有一个重要限制。它接收输入。计算输出。然后结束这次计算。如果下一个输入与前一个输入之间具有复杂关系,又应该怎么办?例如:一个句子的第十个单词,可能依赖第一个单词。一段语音中的当前声音,可能受到此前语音片段的影响。某项传感器读数,也可能需要结合更早的系统状态解释。于是,研究者考虑让神经网络拥有能够随时间更新的内部状态。ht=f(ht−1,xt)\boxed{ h_t=f(h_{t-1},x_t) }这种结构被称为:Recurrent Neural Network(循环神经网络,RNN)。但是,如果一项重要信息需要保存一百个时间步,网络又怎样知道如何调整一百步之前的参数?研究者可以将循环网络沿着时间展开。把每一个时间步,看成一次相互连接的计算。然后将本章学到的反向传播应用于整段展开的计算图。这就是:Backpropagation Through Time(BPTT,随时间反向传播)。但随着时间跨度增加,梯度可能在连乘中不断衰减。网络也许能够记住前几个时间步。却很难利用相隔很远的历史信息。1997年,Sepp Hochreiter与Jürgen Schmidhuber发表Long Short-Term Memory。他们希望改善循环网络长期依赖学习中的误差信号衰减问题。其中,一项重要设计是让网络拥有更适合长期保存信息的状态更新结构。 [PubMed]下一章,我们将从循环网络的早期研究出发,详细讲述随时间反向传播的发展与长期依赖问题,解释为什么普通RNN容易遗忘远处的信息,以及LSTM中的输入门、遗忘门、输出门和细胞状态究竟怎样工作。互动实验将提供一段可调长度的时间序列,让你观察梯度怎样从最后一个时间步返回到前面的位置,并比较普通RNN与LSTM式状态传递的差别。第57章,我们学会让误差穿越网络的空间层次;第58章,我们将尝试让误差跨越时间,让机器利用过去的信息改变未来的判断。
——《算法的故事》第二季 · 第六篇《会学习的算法》 · 第57章 完