从导数到反向传播:Micrograd 背后的理论
训练神经网络 的过程涉及搜索权重和偏差,以使其预测更加准确。我们使用损失函数将这些预测与预期答案进行比较,该函数将它们的不匹配转化为数字,值越低表示匹配越好。要选择更新,我们需要知道更改每个参数将如何影响该损失。我们可以随机调整一个参数,再次计算损失,然后比较结果。但以这种方式一次检查数百万个参数将需要数百万次额外评估。
为了指导这个搜索,我们使用梯度,它收集 偏导数 与参数相关的损失。这些导数让我们可以估计参数的微小变化会对损失产生多大的影响,而无需重新计算。对于每个参数,导数描述了在其他参数保持固定的情况下损失对该参数的敏感程度。
从数学上来说,这是一个 最优化问题,特别是最小化:我们寻找使损失尽可能小的模型参数值。尽管零阶技术如 MeZO 可以通过一次随机扰动许多参数并评估损失来指导更新,标准方法是使用导数信息来指导搜索。今天的神经网络主要是 使用反向传播算法计算的梯度进行训练——我们将在这里开发的方法。
为了理解反向传播,我们将基于几个想法:局部导数、链式法则和计算图。在此过程中,我们将探索计算导数的三种方法:数值微分、符号微分和自动微分。我们将从足够小的函数开始,以便手动微分,并将其精确导数与有限差分估计进行比较,然后转向具有多个输入的函数。我们将使用偏导数来隔离每个输入的影响,同时保持其他输入的影响固定并将这些速率收集到梯度中。
接下来,我们将沿着相连的运算追踪变化,展示链式法则如何沿每条路径将导数相乘,并在一个输入通过多条路径影响输出时累加贡献。核心思想是:只要知道一小组基本运算的求导规则,就能对由这些运算组成的函数求导。甚至平方的导数也可以仅从乘法推导出来。我们会将这个思想与符号微分联系起来,再追踪一次前向计算和反向计算,看看反向模式自动微分如何复用中间结果来计算多个输入的梯度。这正是反向传播采用的方法。
本文为理解 Andrej Karpathy 的 Micrograd 奠定了理论基础,这是一个具有类似 PyTorch API 的小型 autograd 引擎。在他的 视频演练 中,他构建了引擎并使用它来训练神经网络。 PyTorch 的 torch.autograd.backward 也遵循相同的原理,它使用链式法则计算梯度。
从变化率到导数
让我们从一个简单的线性关系 开始,并通过检查其图形的斜率来找到其导数。导数告诉我们,在特定的输入值下,当我们改变函数的输入时,函数的输出变化的速度有多快。在此, 的任何增加都会导致 的增加三倍。使用 来表示变化,我们可以这样写:
当我们水平绘制 和垂直绘制 时,这个恒定的变化率给出了一条直线。对于直线,导数等于其斜率,因此我们可以通过计算输出变化与输入变化的比率(通常称为“上升超过运行”)来找到它:
我们可以在下图中看到,对于 的运行,蓝线 如何上升 ,斜率为 。
对于 的同一运行,灰色虚线 和紫色虚线 仅上升 和 。在相同的轴比例下,绝对斜率越大,线越陡。所有三个斜率都是正值,因此当我们向右移动时,线会上升;负斜率会使线下降。
我们发现蓝线的斜率为,因此的导数为。我们将其写为 ,并将其读为“ 相对于 的导数”。短语“相对于 ”标识了我们更改的输入:导数测量输出 随着 变化的速度。我们的函数只有一个输入 ,因此没有其他输入需要保持固定。对于我们的蓝线,我们得到:
符号表示导数,而 描述有限间隔内的平均变化率。对于 ,变化率是恒定的,不依赖于 ,因此无论我们在哪里计算它,导数都是 。
我们使用直线的斜率来求导数。我们还可以使用微分规则来计算它。从 开始,我们可以用它的表达式替换 并编写:
这里,微分算子 表示“对后面的表达式关于 求导”。在 中,这个表达式是 ,我们将它视为 的函数。当我们把较大表达式中的各个项视为函数并分别求导时,这种记法尤其有用。
使用 常量倍数规则,我们保留乘数 并对 进行微分。 相对于 的导数是 ,因为输入增加任意量都会使输出 增加完全相同的量:
微分规则为我们提供了一种系统的方法来计算在更复杂的表达式中很难看到的比率。当我们连接多个操作时,我们将引入更多这些规则。
随输入变化的导数
现在让我们看一个函数,其变化率取决于其输入。这使得导数的局部性质更加清晰:它描述了输出在我们评估它的特定输入值附近如何变化。为了演示这一点,我们将使用一个具有一个输入 的函数,名为 :
我们可以将此导数写为 ,使用与上面介绍的相同的符号,称为 Leibniz 符号。另一种写法是,读作“x的L素数”,称为拉格朗日表示法,或素数表示法。两者均表示 相对于 的导数。
为了求 的导数,我们将分别对它的两项进行微分,然后将结果相加。这使用了三个规则:
| 规则 | 我们如何使用它 |
|---|---|
| 幂函数求导法则 | 求导 |
| 不变的规则 | 微分常数项 |
| 总和规则 | 添加两项的导数 |
我们首先将 幂法则 应用于 :乘以指数,然后将指数减一。一般来说,
对于 ,这给出 。然后我们将常数规则应用于 。它相对于 的导数为零,因为它不会随着 的变化而变化。最后,我们使用 sum 规则 来添加这些导数。所以对于我们的函数,导数是:
为了了解这个公式告诉我们什么,让我们计算函数的输出及其在 三个值处的导数:
| 输入 | 输出 | 导数 |
|---|---|---|
与 中 的恒定速率不同,该速率取决于 的当前值。 和 的函数输出相同,但导数具有相反的符号。在 处,正导数意味着输出随着 的略微增加而增加。在 处,负导数意味着随着 略有增加,输出下降。
下图重点关注方向:两个点具有相同的高度,但绿色箭头显示了沿曲线移动以减少输出的不同方式。
该图向我们表明,如果我们的目标是减少函数的输出,导数的符号告诉我们从每个起点 开始移动的方向。绿色箭头显示了这些方向,我们可以确定这些方向,而无需尝试增加和减少 并重新计算函数的输出。两个箭头都指向 ,其中该函数达到最小值并且导数为零,尽管零导数并不总是表示最小值。
可视化导数的常见方法是作为图形在选定点处的切线的斜率。在 处,导数为 ,因此切线具有斜率 。更改下面的 以查看紫色切线的斜率如何沿着蓝色曲线变化。
正切还可以帮助我们估计函数输出附近的变化量。在 处,输出为 ,导数为 、, 中的微小变化会产生输出 的变化,大约为 倍:
我们在上面的表达式中使用 是因为我们正在估计一小步的输出变化。在 处,导数正是 ,但斜率随着我们沿着曲线移动而变化。在小部件中将 设置为 会得到 的斜率;对 进行较小的移动将得到 。步长越小,斜率变化越小,因此将其视为 的常数可以更准确地估计输出变化。
用有限差分估计导数
根据附近输入的函数值估计导数称为数值微分。有限差分是实现此目的的一种方法。我们只需要计算函数的值,而不需要知道其内部运算或推导其导数的公式。
前向差分估计将输出变化除以小的非零输入变化 :
让我们使用 计算有限差分估计。从 开始, 的一步将我们带到 :
这是两个输入之间的平均变化率。从几何角度来说,它是连接曲线上各点的直线的斜率,称为 secant。导数给出了起点处切线的斜率。
在下面的交互示例中,将 设为 ,此时紫色切线的斜率为 。将 设为 ,橙色割线的斜率就是 ,这便是有限差分估计。随着 缩小,橙色割线的斜率趋近精确导数 。
我们将其称为平均值,因为我们将输出的总变化除以该区间内输入的总变化。有限差分近似仅使用两个端点值,因此它不能告诉我们函数在它们之间的行为方式。其输出可能在该时间间隔的某个部分变化得更慢,而在另一部分变化得更快。
因为我们知道公式,所以我们可以绘制端点之间的曲线并看到区别。下图使用了更宽的区间,从 到 ,以使差异可见。连接端点的虚线具有平均斜率;绿色曲线显示输出的实际变化情况。阴影三角形显示总输入变化 和输出变化 ,因此平均斜率为 。
我们可以将整个区间的平均速率与其两半区间的速率进行比较。在 处进行拆分,其中输出为 ,得出:
| 间隔 | 输入变化 | 输出变化 | 平均费率 |
|---|---|---|---|
| 整个区间 | |||
| 上半场 | |||
| 下半场 |
极限定义精确地表达了单点变化率的概念:它是随着间隔向零收缩而平均变化率接近的值:
我们让 接近零,而不是替换 ,这将得到 。 采取这个限制让我们推导出规则 . 对于我们的函数,首先简化比率,同时保持 非零:
这使得区别变得明确:选定步骤的有限差分计算给出 。当 接近零时取极限会删除额外的 ,从而给出导数规则:
这解释了我们之前象征性应用的规则。一旦我们有了公式 ,我们就可以直接在任何输入处评估它,而无需评估附近输入处的函数。
前向差异比较 与 。 后面的采样给出后向差分估计,而两侧等量采样给出中心差分估计。这些是 有限差分近似 的其他类型。对于相同的函数 ,我们现在将使用更大的步长 :
| 方法 | 公式 | 估价为、 |
|---|---|---|
| 转发 | ||
| 向后 | ||
| 中环 |
在精确算术中,当我们减小步长 时,前向、后向和中心差分估计会收敛到精确导数 - 对于 处的函数,该值为 。在精确算术中,该二次方程的中心差是精确的,但通常仍然是近似值。
软件可以使用有限差分来估计导数:评估两个附近输入的函数,减去输出,然后除以输入差值。选择这种差异需要谨慎。有限步通常会引入截断误差,因为我们使用区间来估计某个点的速率。缩小步长可以减少这个误差,但是极小的步长会产生另一个问题:计算机存储的数字精度有限。减去几乎相等的函数值可能会留下由舍入误差主导的差异,除以微小的 会放大该差异。
这些方法对于检查导数很有用,但是当单独应用于数百万个参数时,它们的附加函数评估使得它们变得昂贵。
多个输入与偏导数
到目前为止,我们已经使用了只有一个参数 的函数 和 。现在让我们看看 和 ,其中有两个参数 和 我们可以分别改变。为了了解每个参数对 的影响,我们更改其中一个参数,同时保持另一个参数不变。同时改变两者会将它们的效果混合在一起。
之前,我们写了 来描述输出 如何随单个输入 变化。对于我们的双输入函数,我们需要指定我们改变的输入。为了测量当我们在保持 固定的情况下改变 时 如何变化,我们使用 偏导数,写作 。我们将其读作'相对于'的偏导数。订单的含义与之前相同;符号 表示我们正在改变一个输入,同时保持其他输入不变。
从几何角度来看,两个输入确定曲面 上的一个点。偏导数测量一个输入方向上该点的斜率,而另一个输入保持固定。因此,即使我们只检查一个方向的变化,我们也需要两个输入值来指定测量斜率的位置。
计算偏导数时,我们应用微分规则,同时将其他输入视为常数。让我们从加法开始,。保持 固定,将 增加 将 增加 ,因此 。交换角色得到 。
对于乘法 ,速率取决于我们保持固定的输入。下表显示了当我们增加 同时将 保持在恒定值 时, 如何变化:
我们可以看到,每当增加时,就会增加。由于 持有于 ,因此 的变化率为 :
正如您所看到的,变化率等于的常数值。这使用了我们应用于 的相同常数倍规则。 中的乘数为 。在中,乘数是,我们在对微分时保持不变。因此,当我们保持一个变量不变时,它仍然参与计算并影响输出如何响应另一个变量的变化。
对 进行微分的工作方式相同:我们保持 常数并应用常数倍规则,给出 。两个输入起到对称作用,但其效果取决于和的当前值。在 和 处,输出为 ,偏导数分别为 和 。
我们现在可以总结这两种操作的局部导数规则:
| 运营 | ||
|---|---|---|
对于加法,改变任一输入都会使输出改变相同的量。对于乘法,每个偏导数等于另一个输入。这些规则被称为局部规则,因为它们描述了单个操作的输出如何响应其输入。稍后,我们将连接它们以通过几个操作来跟踪更改。
偏导数 和 描述灵敏度,而不是输出 的部分。对于同样小的变化,单独改变 的效果是 倍于单独改变 的效果。为了估计改变两个输入的影响,我们需要导数和建议的输入变化。
为此,我们把偏导数组成一个向量,称为梯度,记作 。对于函数 ,在 、 处,我们写作 。这个向量在输入平面中也有几何意义:在欧几里得长度相同且足够小的位移中,它指向使输出的一阶近似增量最大的方向。它的反方向就是局部下降最快的方向。
下图展示了 的这些方向。您可以看到紫色箭头如何从 引向上坡,其中输出为 ,而绿色箭头则引向下坡。当我们沿梯度方向或其相反方向移动输入时,它们会跟踪表面路径。图表分别缩放高度;梯度指定输入平面中的方向。
一旦我们知道了偏导数,我们就可以使用 一阶泰勒近似,也称为 线性近似。它允许我们估计小的输入变化对输出的综合影响:我们将每个偏导数乘以相应的输入变化并将结果相加。
这是梯度和输入变化向量的点积。对于我们在 处的函数 ,它给出:
例如,将两个输入增加 得出:
将 增加 会贡献 到预测的变化,而将 增加 则会贡献 。每个贡献都是偏导数乘以相应的输入变化。导数描述灵敏度;所选择的变化决定了有多少敏感度进入预测。
评估新输入处的函数会得出 的实际变化。剩下的 是相互作用项 ,线性近似忽略了它。因此,术语 和 描述的是对预测变化的贡献,而不是原始输出 的份额。同样的区别也适用于反向传播:它计算损失对每个权重的敏感度。将这些导数乘以建议的权重变化可以估计它们对损失变化的贡献。
组合求导法则,求更复杂函数的导数
在上一节中,我们计算了加法和乘法的偏导数。现在我们将在表达式中结合微分规则与多个项和幂:
我们将比较计算选定点处的偏导数的两种等效方法:首先微分,然后代入值,或者首先代入另一个输入的值,对所得的单输入函数进行微分,然后评估其导数。两种方法都会产生导数公式。将单输入函数绘制为表面的截面将显示为什么每个偏导数也是普通的切线斜率。
先求导,再代入数值
我们将 和 保留为符号,为每个输入找到偏导数,同时将另一个输入视为常数。然后我们将评估 处的两个导数。
首先,我们对 与 进行微分,保持 固定并对每一项进行微分。因子 充当 的常数乘数:
保持 固定不会将其从计算中删除:它的值决定乘数 。我们使用幂函数求导法则对 进行微分。
然后我们将 与 进行微分,交换角色并保持 固定。现在是的常数乘数,并且整个项是常数:
术语 贡献为零,因为在保持 固定的情况下更改 无法更改其值。
这为我们提供了所选输入的以下偏导数:
| 我们改变输入 | 输入保持固定 | 的偏导数 | 预测产量变化 |
|---|---|---|---|
先固定一个输入,再对截面函数求导
现在来看第二种方法:先固定一个输入,再对截面函数求导。我们将一个输入的值代入 ,得到只依赖另一个输入的普通函数。
首先,我们设置 并让 自由变化:
这是原始函数的一部分,而不是导数:我们仅用一个值替换了一个输入。
对于带有 的截面,我们不再将 作为表达式中的变量,因为替换 已将 变成常数乘数 。不过, 的效果仍然通过乘法器 来实现。现在只剩下 作为变量,我们对 进行普通导数:
然后,返回到原始函数,我们设置 并让 自由变化:
对于 的截面,我们对 进行微分:
两个截面都经过同一选定点,即 。 对每个截面进行微分会得到与我们上面计算的相同的偏导数:
两种方法都保持另一个输入不变,因此可以先代入它的固定值再求导,也可以先保留符号,求导后再代入。第一种方法给出一般的导数公式 和 ,可在任意一对输入处求值。 第二种方法给出特定截面的公式:当 时为 ,当 时为 。例如,将 代入 得到 ,与对截面函数 求导的结果相同。每个截面都来自对原函数的一次独立代入。
这些导数衡量的是敏感性而不是当前产出的份额。和以前一样,我们将偏导数收集到梯度向量中。在 中,这给出了 。建议的输入更改为向量 。他们的点积估计了组合输出变化:
在 ,两个坐标都指定了我们测量坡度的点。按住 的同时沿 方向移动会得到 的切线斜率;按住 的同时沿 方向移动会产生 的斜率。这是通过表面上同一点的两个方向。每个速率都是原始双输入函数的偏导数及其对应的单输入截面的普通导数。梯度将这些速率组合到输入平面中局部增加最陡的方向。
下面的小部件演示了我们上面计算的内容:输出如何沿每个输入方向变化(通过偏导数测量),以及这些导数如何组合成梯度方向。紫色截面显示每个输入单独变化,它们的绿色切线显示偏导数,橙色箭头沿着梯度方向遵循表面路径。
∂f/∂x = 31
∂f/∂y = 12
从 开始,移动 滑块对两个图的影响不同。在左侧, 是变化的输入,因此所选点沿着相同的曲线 移动,而 保持固定。在右侧, 是定义截面的固定输入,因此将其从 更改为 会将公式从 更改为 。换句话说,移动 会沿左侧截面移动该点,并更改右侧显示的截面。移动 则相反。
沿着相连的运算追踪导数
之前,我们分别求导了两个项并添加了它们的导数,因为原始表达式添加了这些项。更一般地,我们可以将每个中间计算视为原始输入的函数。它们的结果如何连接决定了我们如何组合它们的导数。
下表显示了加法、乘法和嵌套的规则。我们将使用 和 作为 的两个可微函数的通用名称:
| 规则 | 功能如何组合 | 关于 的导数 |
|---|---|---|
| 总和规则 | ||
| 乘积法则 | ||
| Chain rule |
乘积法则将我们之前使用的常数倍规则推广到两个因素都发生变化的情况。两个函数相乘并不意味着它们的导数相乘。为了求导各个函数,我们可以使用幂函数求导法则等规则,或者将函数分解为更简单的运算,其导数我们已经知道。 链式法则连接它们的局部导数,以找出原始输入如何影响最终输出,使其成为反向传播的核心。
在上一节中,我们对各项进行了微分,同时将其他输入视为常数。现在我们将深入研究这些术语:每个算术运算本身都可以被视为其输入的函数。
首先,我们将函数 拆分为具有命名中间结果的单独操作:
例如,平方将 映射到 ,乘法将 和 映射到 。平方的输出成为乘法的输入。当一个函数的输出成为另一个函数的输入时,函数就会组合起来,这种关系称为函数组合,如上表中的 所示。操作有其自己的直接输入;连接操作使其结果也依赖于早期的输入。
拆分遵循表达式的依赖关系: 必须在 之前可用,并且 和 必须在 之前可用。由于 不依赖于 或 ,因此我们可以在该分支之前或之后计算它。这种分割并不是唯一的:我们可以将平方和立方表示为乘法。在这里,我们将它们视为单独的操作,并对其局部导数使用幂函数求导法则。
我们现在将使用链式法则通过各个操作计算导数,沿每个路径乘以局部导数并添加来自不同路径的贡献。我们将首先遵循一条路径,然后检查通过两条路径到达输出的输入。
链式法则:沿着一条路径
我们首先对 进行微分,保持 固定。这种分割显示了为什么 是一个有用的起点:更改它会影响 ,然后是 ,而 保持不变。之后,我们将检查 ,它会影响 和 。目前,从 到最终输出只有一条路径:
要了解局部导数和链式法则之间的区别,请仅关注两个运算节点:平方 和乘法 ,由 连接。
从其本身来看,乘法以 和 作为输入。其局部偏导数为:
为了导出每个局部导数,我们只需要运算的表达式和我们微分的输入。对于 ,将 视为常数会得到 ,无论 如何计算或在何处使用 。这一步我们不需要图表的其余部分。方形单独供货。
请注意链式法则如何分别包含节点 、 的导数,即使节点 已经使用了 的值。局部导数 仅告诉我们 如何响应 的变化。它不包括如何与变化。该汇率为 ,因此我们需要两个因素:
当 固定时,乘法是外函数,平方是内函数。仅靠外导数无法告诉我们内部函数如何变化:乘法接受 的值,而不知道它是来自 、 还是其他计算。在,和都生产,但它们的导数分别是和。因此,相同的值并不意味着对 中的更改有相同的响应。每当我们求导组合时,这种区别就适用。
如果我们将 替换为 ,则完整表达式 包含该内部依赖关系。对它进行微分直接给出 ,同时考虑了两种速率。该图清楚地表明了这两个因素。
下表仅列出了在 保持固定的情况下更改 的效果。重点关注和这两个操作,在处停止计算:
| 步骤 | 象征形式 |
|---|---|
| 定义操作 | ,然后 |
| 推导局部导数公式 | , |
| 使用链式法则组合公式 |
当我们组合局部导数时,连接很重要:路径 告诉我们将 乘以 来找到 。该操作提供局部规则;该图告诉我们如何将其与其他规则联系起来。
现在继续最后的添加。原始函数采用 和 ,但每个操作都有自己的输入。对于最终添加的 ,这些输入是 和 。其局部导数 询问当 变化而 保持固定时,加法的输出如何变化。这等于 ,因为在 中添加少量内容会在 中添加相同的金额。然后,链式法则将这些本地响应连接回原始输入 。
下表列出了沿完整路径的局部导数:
| 运营 | 局部导数 |
|---|---|
| 平方 获取 | |
| 将乘以固定值,得到 | |
| 加上固定值,得到 |
为了获得最终输出相对于 、的导数,我们现在可以应用链式法则,将沿 的局部导数相乘:
这与我们直接对 求导得到的公式相符。这里, 等于 ,因为最后一个操作将速率乘以 。
下面的小部件呈现了这些连接计算的图表。它以 和 开头,这是函数所依赖的输入。节点内的表达式(例如 和 )定义了操作。实线箭头从操作结果指向使用它的操作。因此, 意味着 使用 的结果,使它们的组成可见。乘法使用,而不是其导数;导数标签和链式法则框的虚线箭头显示了我们如何对计算进行微分。沿着橙色路径乘以局部导数即可得到 。
Scroll the graph sideways to see the chain-rule box.
当我们现在取导数公式 并代入 和 时,我们得到 ,与我们之前计算的偏导数相匹配。我们可以在任何输入对上评估这个公式,而无需首先计算中间函数值。
在 和 处, 中的 的微小变化会在 中产生大约 的变化。按 变化的乘法比例,给出 中大约 的变化。添加未更改的 会使更改保持不变。
多条路径:累加导数贡献
我们了解了 如何通过一条路径影响输出。现在让我们求 相对于 的偏导数,保持 固定。我们使用相同的中间结果:
值 保持不变,但 和 都随 变化。这为我们提供了从 到最终输出的两条路径:
我们已经通过将 与 微分来计算 ,保持 恒定。链式法则使用两条路径上的局部导数给出了相同的答案:
| 运营 | 局部导数 |
|---|---|
| 将乘以固定值,得到 | |
| 立方 获取 | |
| 将和相加得到,考虑的效果 | |
| 将和相加得到,考虑的效果 |
加法的每个局部导数都保持其其他输入固定。当 发生变化时,两个输入都会发生变化,因此我们需要这两个贡献。
我们现在应用多变量链式法则:沿每条路径乘以局部导数,然后将两个贡献相加以获得最终输出相对于的导数:
下图显示了从 到最终输出的两条路径: 和 。两者都跟踪改变 的影响,而 保持不变。节点 和 是灰色的,因为该分支保持固定;它仍然提供乘法使用的值 。橙色路径贡献,紫色路径贡献。活跃的操作框显示了它们的局部导数。上面的框将沿每条路径的导数相乘,然后添加贡献以获得 。
Scroll the graph sideways to follow the paths.
当我们现在取导数公式并代入和时,我们得到。这与我们之前计算的偏导数相匹配,而无需先计算中间函数值。
计算导数:符号微分与自动微分
前面的例子通过组合局部求导规则,对更复杂的函数求导。我们还可以进一步分解:平方和立方本身也能由乘法组成,因此它们的导数也可以从乘法的求导规则得到。
仅从乘法推导平方的导数
假设我们的引擎支持乘法,却没有幂运算。我们仍然可以把 写成 来计算。对于乘法 ,局部导数为:
这里,两个输入位置引用的是同一个变量:,。改变 会通过这两个位置影响结果,因此要把两部分贡献相加:
平方的导数来自乘法和链式法则,无须单独实现平方的求导规则。 在反向计算中,先将 的梯度设为 。每个输入位置都将另一个操作数的值累加到 的梯度中。当 时,两部分贡献为 。虽然图中只有一个 节点,但它的两次使用都必须计入。
同样的思路也适用于立方。先写 ,再写 。组合这些导数便得到:
因此,对于表示为重复乘法的正整数次幂,引擎不必实现幂函数求导法则,也能求出导数。其他函数需要合适的额外基本运算:例如,仅靠乘法无法表示 或任意分数次幂。一般原则是:为基本运算提供求导规则,再组合这些规则,对由它们构成的函数求导。
同样的规则可以构造表达式,也可以计算数值
在平方的例子中,我们保留符号 ,构造 ,再将其化简为 。这种构造导数表达式的方法就是符号微分。程序也可以利用相同的局部规则和依赖关系,自动构造这些表达式。
Micrograd 将这些规则应用于数值。当 时,原来的乘法计算得到 ,其反向规则则将 累加到 的梯度中。这就是自动微分:在数学上,它等价于将 代入 ,但并不构造这个符号公式。反向传播通过反向模式自动微分来计算训练所需的损失梯度。
两种方法组合的是同一套微积分规则。在这里,区别在于我们是构造导数表达式,还是用数值执行导数计算:
| 方法 | 处理对象 | 结果 |
|---|---|---|
| 符号微分 | 符号表达式,包括有名称的中间表达式 | 可以代入选定输入求值的导数公式 |
| 自动微分 | 程序的运算及其在选定输入处的值 | 通过组合局部导数计算出的导数数值 |
从导数公式到可执行的规则
在前面的单路径符号图中,我们在操作 旁边写下了 。该公式还告诉我们如何使用算术计算导数:将输入值乘以 。在 处,结果是 。这是 数值局部导数:在特定输入值下计算的局部导数。
自动微分不会从数值中推断基本运算的求导规则,也不会在执行时通过符号推导发现规则。引擎的实现者为每种基本运算提供规则,将已知的导数公式写成代码。如果将平方选为基本运算, 就写成 2 * input_value。公式从数学上描述规则,代码则用数值执行同样的规则。运行时,输入 2 就得到 4。如果把平方实现为 y * y,乘法的两个贡献也会得到相同结果:2 + 2 = 4。
引擎利用计算图和链式法则,对这些运算的组合求导。每种局部规则只需实现一次,每当对应运算出现时就可以复用:
| 程序运行 | 已知的局部导数规则 | 数值计算 |
|---|---|---|
u = y ** 2 | 2 * y.data | |
v = x * u | , | u.data,x.data |
w = x ** 3 | 3 * x.data ** 2 | |
f = v + w | 、 | 1,1 |
这里,.data 保存节点的数值。通过读取另一个操作数的值 获得相对于 的乘法导数。引擎不需要知道如何计算该操作数来应用此局部规则。
该引擎还记录哪些输入产生了每个结果,保留了图中显示的依赖关系。普通算术计算数值; autodiff 添加了依赖性跟踪和求导规则。它还可以遵循循环和条件分支内执行的操作,而无需将整个计算重写为单个公式。
在本文示例的计算图中,我们选择把幂运算作为一种独立的基本运算,让每次平方或立方都只占一个运算节点。这样就可以将它们实现为 y.pow(2) 和 x.pow(3)。这是一种实现选择;对于这两个幂,重复乘法同样可行。pow(exponent) 方法应用一般的幂函数求导法则:
对于这里使用的正整数指数,其计算为:
pow(exponent) {
return new Value(
this.data ** exponent, // function value
[[this, exponent * this.data ** (exponent - 1)]] // input and local derivative
);
}这里,this 是输入节点。该操作计算其输出值并记录与该输入的连接及其数值局部导数。向后传递将使用保存的导数将梯度贡献传播到输入。
调用 y.pow(2) 时传入指数 ,因此局部导数为 。调用 x.pow(3) 则得到 。引擎从运算的参数中取得指数,而程序员提供一般的求导规则。引擎不会在执行时发现幂函数求导法则。专门的 square() 方法可以使用相同的计算,只需将指数固定为 。
该图提供了连接;实施的规则提供本地衍生值。然后链式法则告诉引擎如何组合它们。
我们现在可以从符号示例中重新访问该表,再次仅遵循 并保持 固定来计算 。 Autodiff 对选定的输入执行相同的操作,因此它还计算它们的数字输出值。它仍然需要局部导数,但它会评估每个操作已经实现的规则,而不是在执行期间导出新公式:
| 步骤 | 在 、, 保持固定 |
|---|---|
| 使用相同的操作 | ,然后 |
| 计算运算值 | ,然后 |
| 评估局部导数规则 | , |
| 使用链式法则组合数值导数 |
继续最后的加法得到 。该表隔离了一个输入的影响以进行解释。我们的符号计算将组合成公式。 Autodiff 评估当前输入的各个规则并组合 。 它使用已知的导数规则作为数值指令,而不需要为整个函数构造符号导数公式。
下面的小部件使用与单路径示例相同的图表。在 和 之间切换,以比较组合导数表达式与组合 和 处的数值。
Scroll the graph sideways to see the chain-rule box.
在符号模式下,实线箭头显示了函数的组成方式:每个中间结果都成为另一个操作的输入。在自动微分模式下,相同的箭头也描述了数值的前向流动:平方产生,其乘法与一起使用产生。依赖关系保持不变;我们现在评估特定输入的操作。此比较小部件不会为该流程设置动画。下面的逐步演练将显示这些前向值和导数贡献的后向传播。
计算运算值与计算局部导数不同。每个导数规则使用它需要的值:沿着这条路径,平方规则需要 ,乘法相对于 的导数需要 。数值计算表中的行描述不同的任务,而不是单独的传递:实现可以在前向或后向传递期间评估局部导数规则。在本演练中,我们在前向传播期间评估并保存它们。
比较小部件显示评估的导数;后面的演练还将显示前向值。平方的输出 及其局部导数 在 处恰好等于 ,但它们的数量不同:在 处,它们将是 和 。
评估组合导数公式给出的结果与首先评估局部导数然后使用链式法则将它们组合起来的结果相同。在 处,计算 会得到 ,乘以 也是如此。操作及其依赖关系保持不变;改变的是我们是否组合导数表达式或数值。
避免扩展公式很重要,因为重复替换中间表达式可能会使导数公式比原始表达式大得多 - 这个问题称为 表达式 swell。扩展公式可能包含同一子表达式的许多副本,如果单独计算每个副本,则会占用更多空间并重复工作。保留命名的中间结果可以让我们重用这些计算。符号微分也可以保留和共享这些表达式,因此表达式膨胀并不是该方法不可避免的限制。
前向模式与反向模式
该小部件显示了我们组合的局部导数,但不显示我们传播它们的顺序。它的虚线连接将这些因子收集到一个乘积中。 前向模式 和 反向模式 autodiff 可以使用相同的计算图,并且都评估从输入到输出的函数。它们的不同之处在于导数的传播方式:前向模式将导数从输入传送到输出,而反向模式将导数贡献从输出传送回输入。
从一个选定的输入开始,正向模式可以计算所有输出如何在一次导数传递中响应该输入。从一个选定的输出开始,反向模式可以计算该输出如何在一次向后传递中响应所有输入。我们将重点关注反向模式,即反向传播所使用的方法。完整的后向传递将计算 和 ,累积两条路径对 的贡献。
前向模式导数传递将导数与函数值一起传播。相反,我们的反向模式计算的前向传递会评估函数并记录稍后的反向传递所需的内容;最终输出的导数在向后传递过程中累积。我们将分两遍进行此计算:
- 前向传递评估该函数。在本演练中,每个操作都会计算其值并保存其输入连接和数值局部导数。
- 向后传递 从输出相对于自身 的导数开始,并使用保存的局部导数使用链式法则向后传播数值贡献。对相同输入的贡献会累积。
下表显示了我们的前向传播在 和 中计算和存储的内容。操作的类型决定运行哪个导数规则;存储的边将其输入与评估的局部导数配对:
| 计算 | 的输出值 | 存储的输入连接和局部导数 |
|---|---|---|
| 输入,与 | ||
| 输入,带;输入 ,与 | ||
| 输入,与 | ||
| 输入,带;输入 ,与 |
产出值和局部导数具有不同的作用。例如,乘法使用 来计算其输出 ,并且该相同的操作数值提供其相对于 的局部导数。向后将存储的局部导数与累积的敏感度结合到最终输出。
模型是一个程序,因此 autodiff 可以访问产生其输出的操作;仅靠输入输出观测的有限表无法提供该信息或确定精确的导数。对于固定的训练批次,模型的预测和损失计算具有多个参数和一个损失值。反向模式重用中间结果来计算一次向后传递中的所有参数导数,而不是对每个参数重复整个计算。
我们已经确定了引擎记录的内容以及它如何使用本地求导规则。接下来,我们将在交互式前向和后向传递中跟踪相同的计算,观察值是如何记录的以及衍生贡献如何累积。
逐步理解反向模式自动微分
现在让我们使用 中的相同函数 进行完整的反向模式自动微分计算。我们将评估上表中的四个操作,然后向后求导以获得梯度 :
x = Value(3)
y = Value(2)
u = y ** 2
v = x * u
w = x ** 3
f = v + w
f.backward()前向传递给出 、、 和最终输出 。每个结果都保持与生成它的输入的连接,以及其局部导数规则所需的信息。反向传递计算最终输出如何响应每个节点;它不会改变这些远期值。
前向值也称为“原始值”。向后传递过程中累积的导数称为“伴随”:每个导数都测量最终输出相对于特定输入或中间值的变化情况。在我们的小部件中,它们分别标记为 value 和 grad。伴随是最终输出的导数,与操作的局部导数不同。
在向后传递之前,每个 .grad 从零开始。我们初始化 f.grad = 1,因为 。
每个标量节点的 .grad 存储最终输出相对于该节点的导数。 x.grad 和 y.grad 一起形成梯度向量 。本演练存储连接上的数值局部导数并在向后过程中使用它们:parent.grad += node.grad * local_derivative。
早期的比较小部件仅遵循从 开始的路径来计算 。下面的小部件在一次向后传递中处理整个图,计算相对于 和 的导数。这些步骤在同一遍中按顺序发生。每个框分别显示其输出值、数值局部导数和累积梯度。
输入固定为 和 ,相当于创建 x = Value(3) 和 y = Value(2)。选择 以查看四个操作产生的输出。然后选择 并按照后向步骤进行加法、立方、乘法和平方。每个后退步骤都会突出显示操作及其输入,然后将符号链式法则表达式与其数值替换以及图表下方相应的 .grad 更新连接起来。在 观看这两项贡献如何累积。
下表总结了您可以在小部件中执行的向后步骤。远期价值始终保持不变;只有累积的梯度发生变化。
| 后退一步 | 渐变更新 |
|---|---|
| 种子 | |
| 加法 | , |
| 立方 | |
| 乘法,对 的贡献 | 添加 ,给出 |
| 乘法,对 的贡献 | |
| 广场 |
来自 的两条路径分别贡献,而 通过 和 接收其贡献:
此顺序在乘法分支之前处理立方分支。其他有效的反向计算顺序给出相同的结果,前提是节点在将其累积导数传递到其输入之前接收所有下游贡献。
该小部件显示完整的向后传递。现在让我们将其乘法 和平方 步骤连接到代码。乘法的后退步骤将两个不同的导数相乘:
第一个因素告诉我们最终输出 如何响应 。第二个告诉我们 如何响应 ,保持 固定。将它们相乘就是链式法则:它给出了该路径对 导数的贡献。
本演练在前向传播过程中保存数值局部导数。另一种实现可以保存操作数值并在向后期间评估相同的局部规则。在该形式中,乘法方法 __mul__(self, other) 创建一个名为 out 的结果节点,其后向规则可以写为:
self.grad += out.grad * other.data # contribution to the left operand
other.grad += out.grad * self.data # contribution to the right operand对于 v = x * u,out 为 ,self 为 ,other 为 。用这些名字来写,乘法更新之后是平方更新:
# Multiplication: v = x * u
x.grad += v.grad * u.data # add 1 × 4 to the existing 27 → 31
u.grad += v.grad * x.data # add 1 × 3 → 3
# Square: u = y ** 2
y.grad += u.grad * (2 * y.data) # add 3 × 4 → 12前向传递提供了 、 和 。乘法对每个局部导数使用另一个操作数的值。平方使用 并将其乘以累加的 u.grad = 3。
| 代码 | 意义 |
|---|---|
out.grad | 最终输出相对于该操作结果的导数 |
other.data | 其他操作数的值,等于乘法的局部导数 |
+= | 将此路径的贡献添加到输入的累积导数 |
other.data 存储的是值,而不是导数。 根据乘法的求导规则,这个值恰好等于所需的局部导数。对于加法,局部导数为 ,因此相应的更新是 self.grad += out.grad * 1。对于幂运算,我们在输入值处计算幂函数求导法则:立方的局部导数为 ,平方的局部导数为 。
在符号小部件中,我们沿着 的路径组合局部导数表达式:
该代码遵循相同的连接,但组合了数字。加法得到v.grad = 1;乘法得到 u.grad = 1 × 3 = 3;平方给出 y.grad = 3 × 4 = 12。因此u.grad已经包含了符号表达式的前两个因子。 Autodiff 无需构造组合公式 即可获得相同的导数。
这些步骤计算 的导数;他们不会更新输入来最小化函数。 Value 类在对 f.backward() 的单个调用中自动执行后退步骤。
从计算过程到自动微分引擎
导数测量局部敏感性。偏导数隔离每个输入的影响,梯度将这些测量值收集到一个向量中。反向传播通过记录的操作向后工作来计算它们:沿着每条路径乘以导数并在路径相交处添加贡献。
要查看这些想法在 Python 中的实现,请遵循 Andrej Karpathy 的 Micrograd 演练。我们在这里探索的局部导数规则、梯度累积和向后遍历为理解他的实现提供了基础。
如需更深入的数学观点,请探索 这个关于自动微分和线性映射的演讲。它将微积分与线性代数联系起来,展示导数如何充当线性映射、雅可比矩阵如何表示它们以及链式法则如何将它们结合起来——自动微分的数学基础。