神经网络怎么知道该往哪里调整?

本文目录

English · 中文 · Français

First Principles of LLMs & RL · Article 03 · 上一篇文章

写作说明:问题与初始草稿材料来自作者本人;ChatGPT 协助了结构、编辑、配图和翻译。

从局部近似,到梯度下降与反向传播

上一篇解释了交叉熵怎样衡量模型的预测损失。但得到一个损失值以后,参数并不会自己改变。

一个数字可以告诉我们当前预测有多差,却不会直接附上一份修改说明:哪个参数应该增加,哪个应该减少,又应该改多少?

从“衡量预测”到“调整参数”,还缺少对变化的描述。

这篇文章从这里开始。先看一个很小的参数改动会怎样影响损失,再选择有利的改动方向,最后把这种变化关系一路算回模型的参数。Taylor expansion、gradient descent 和 backpropagation 分别回答这条路上的三个问题。

一、损失值没有方向,导数有

先暂时把复杂网络放在一边。假设只有一个实数参数 θ,损失函数为:

\[L(\theta)=(\theta-3)^2.\]

参数等于 1 和等于 5 时,损失都是 4。但要靠近最低点,前者应该增加,后者应该减少。只看到损失值,分不清这两个情况。

导数提供的正是缺少的变化信息:

\[L'(\theta)=2(\theta-3).\]

在 θ 等于 1 时,导数是 −4。它不是说损失为负,而是说:在当前位置,把参数稍微增大,损失会下降。

这个解释可以直接从导数定义读出来。用 δ 表示参数的小改动:

\[L'(\theta) = \lim_{\delta\to0} \frac{L(\theta+\delta)-L(\theta)}{\delta}.\]

如果 δ 足够小,就可以用导数近似这个变化比率,得到:

\[L(\theta+\delta) \approx L(\theta)+L'(\theta)\delta.\]

例如,从 θ 等于 1 出发,增加 0.01。一阶近似预测损失下降约 0.04,变成 3.96;重新计算真实损失,得到 3.9601。

近似并不完全相同,但它正确描述了这次微小改动的效果。

导数不是当前错误的大小,而是损失对参数变化的局部敏感性。 同样大小的损失,可能对应完全不同的敏感性。

二、Taylor:用局部信息构造附近的形状

刚才那条近似就是一阶 Taylor 近似。它用经过当前点、斜率与原函数一致的直线,代替附近的曲线。

这也给出了更高阶展开的出发点:既然直线只能匹配高度和斜率,能不能再匹配函数的弯曲?

用 f 表示一个标量函数,a 表示展开点,δ 表示相对 a 的位移。先构造一个二次多项式:

\[p_2(\delta)=c_0+c_1\delta+c_2\delta^2.\]

要求它在 δ 等于零时,与原函数在 a 处具有相同的值、一阶导数和二阶导数。这三个条件分别给出:

\[c_0=f(a),\qquad c_1=f'(a),\qquad 2c_2=f''(a).\]

于是:

\[f(a+\delta) \approx f(a)+f'(a)\delta+\frac{f''(a)}{2}\delta^2.\]

二分之一没有什么神秘之处:二次项求两次导数会产生系数 2,因此需要先除掉它。继续匹配更高阶导数,就出现相应的阶乘。

更一般地,用 n 表示多项式的最高次数,并把近似误差记为 Rₙ:

\[f(a+\delta) = \sum_{k=0}^{n}\frac{f^{(k)}(a)}{k!}\delta^k +R_n(\delta).\]

这里 n 是非负整数,第零阶导数就是函数本身。这个等式保留了余项;只有在说明误差可以忽略时,才把它写成近似。

例如,若 f 在相关区间上具有连续的 n+1 阶导数,而且该导数的绝对值不超过 M,Taylor 定理给出:

\[|R_n(\delta)| \leq \frac{M}{(n+1)!}|\delta|^{n+1}.\]

这才说明为什么“离展开点近”有用:在这些条件下,缩小位移能控制误差。有限阶近似也不意味着,任意光滑函数都等于它的无穷 Taylor 级数。1

Taylor 的核心不是把复杂公式变长,而是用一个点上的导数,构造附近的简单模型。

为了看出这个“附近”,考虑在零点展开指数函数:

\[e^x\approx1+x+\frac{x^2}{2}.\]

指数函数及其在零点的一阶、二阶 Taylor 近似。

三条曲线在展开点具有匹配的局部信息,但不是同一条函数。横轴表示离展开点的位移。

当 x 等于 0.2 时,一阶近似为 1.2,二阶近似为 1.22,真实值约为 1.2214。把 x 移到 1,一阶和二阶近似分别给出 2 和 2.5,而真实值约为 2.7183。

这里专门用了非二次函数。如果画的是抛物线,二阶展开已经精确恢复整个函数,反而看不见一般的近似误差。

现在可以回到学习问题了:如果局部近似能预测一个小改动的效果,我们应该选择怎样的改动?

三、为什么沿负梯度走,还需要控制步长?

真实模型不止一个参数。用粗体 θ 表示 m 个实数参数组成的向量,并暂时固定训练数据、随机性和当前计算,使损失成为这个参数向量的确定函数。

把损失对每个参数的偏导数组合起来,就得到梯度:

\[g=\nabla L(\boldsymbol\theta) = \begin{bmatrix} \frac{\partial L}{\partial\theta_1}\\ \vdots\\ \frac{\partial L}{\partial\theta_m} \end{bmatrix}.\]

在当前位置可微时,多变量的一阶近似为:

\[L(\boldsymbol\theta+\Delta\boldsymbol\theta) -L(\boldsymbol\theta) \approx g^\top\Delta\boldsymbol\theta.\]

右边是内积:每个参数的改动乘以它对应的敏感性,再把贡献相加。梯度因此不只是几个互不相关的偏导数;它能评价一整个小的参数改动。

但要说哪个方向“下降最快”,必须先公平地比较方向。这里用欧氏长度衡量步长,并用 u 表示单位方向。根据 Cauchy–Schwarz 不等式:

\[g^\top u\geq-\|g\|_2, \qquad \|u\|_2=1.\]

当梯度不为零时,等号在下面的方向取到:

\[u_*=-\frac{g}{\|g\|_2}.\]

所以,在欧氏长度相同的微小改动中,负梯度方向给出最大的瞬时下降率。 “最陡”依赖于怎样度量步长,并不是一个脱离坐标尺度的绝对说法。2

最简单的梯度下降使用一个正的学习率 η,直接缩放负梯度:

\[\boldsymbol\theta_{t+1} = \boldsymbol\theta_t-\eta\nabla L(\boldsymbol\theta_t).\]

把这个改动放回一阶近似:

\[L(\boldsymbol\theta-\eta g)-L(\boldsymbol\theta) \approx -\eta\|g\|_2^2.\]

梯度非零时,右边为负。这解释了更新方向,却没有替我们选好 η:近似是在当前位置建立的,不保证很远的地方仍然适用。

梯度告诉我们小步往哪里走,学习率决定这一次实际走多远。

回到前面的抛物线,从 θ 等于 1 出发。导数为 −4,因此下一步的位置是:

\[\theta_{\mathrm{new}}=1+4\eta.\]
学习率 η 新参数 新损失 与原损失 4 比较
0.10 1.4 2.56 下降
0.75 4.0 1.00 越过最低点,但仍然下降
1.00 5.0 4.00 没有改善
1.10 5.4 5.76 反而上升

从同一个起点出发,不同学习率对应不同的一步更新。

箭头比较的是从同一起点出发的不同选择,不是同一次训练中的连续迭代,也不是参数在更新过程中真实经过的曲线。

甚至不用画图,也可以看出这个例子的收敛条件。每次更新以后,到最优参数 3 的误差满足:

\[\theta_{t+1}-3=(1-2\eta)(\theta_t-3).\]

因此,对这个特定的二次函数,固定学习率位于 0 和 1 之间时,误差逐渐缩小;学习率为 1 时,非最优起点会来回振荡;大于 1 时,误差的绝对值会增长。这个数值范围依赖当前函数,不能当成神经网络的通用学习率规则。

局部与全局的区别也在这里。对于可微函数,只要当前梯度非零,就存在足够小的负梯度步能够降低损失。但这并不保证整个迭代收敛,更不保证找到全局最优点。梯度为零也可能对应局部最大值或鞍点。2

凸性能够提供额外的全局结构:在无约束的可微凸问题中,若找到了梯度为零的点,它就是全局最优点;算法能否到达它,还需要适当的条件。不能把一般梯度下降概括成“至少保证局部最优”。

到目前为止,我们一直假设梯度已经在手里。接下来的问题才是神经网络真正需要面对的:参数没有直接产生损失,中间隔着许多计算,这些偏导数怎样得到?

四、反向传播:沿计算关系传递敏感性

设一个参数先经过函数 φ,产生中间量 u,u 再经过函数 f,得到损失:

\[u=\phi(\theta),\qquad L=f(u).\]

参数稍微改变,先引起 u 的变化,再引起损失的变化:

\[\Delta u\approx \phi'(\theta)\Delta\theta, \qquad \Delta L\approx f'(u)\Delta u.\]

把两步合起来,取极限,就得到微积分的链式法则:

\[\frac{dL}{d\theta} = \frac{dL}{du}\frac{du}{d\theta}.\]

同一条计算路径上的敏感性相乘,是因为前一步造成的变化,又被后一步继续放大、缩小或反转。

这里的 chain rule 和第一篇的概率链式法则不是同一个公式。前者处理复合函数的导数,后者分解联合概率;它们都能分步计算,但不能混为一谈。

如果参数通过多条中间路径影响损失,就不能只挑一条。每条路径都贡献一部分,汇合时需要相加。

反向传播把这些关系组织在计算图上。前向计算得到各个中间值;随后从最终损失开始,按相反的依赖顺序计算敏感性。某个节点通往损失的导数一旦算好,就可以供它更前面的多个输入复用,不必从每个参数出发重复计算整条路径。3

它也不是把每个参数稍微动一下,重新跑一次网络来估计导数。那是有限差分,可以用来检查一个小例子,但不是这里的反向计算方式。我们用的是每个运算的导数规则,再通过链式法则组合它们。

Backpropagation 负责算梯度;optimizer 负责利用梯度更新参数。 算完梯度之后,参数仍然可以停在原地。

这时最值得做的,不是再增加一个抽象符号,而是让它实际改变前两篇的那个模型。

五、让 cats / dogs / pizza 真正更新一次

仍然考虑同一个上下文 I like。五次观测给出的经验分布是:

\[P=(0.6,0.2,0.2).\]

三个结果的顺序是 cats、dogs、pizza。模型输出 logits z,使用温度为 1 的 Softmax,得到:

\[Q_k=\frac{e^{z_k}}{\sum_{j=1}^{3}e^{z_j}}.\]

把 P 固定,并使用自然对数,平均负对数损失为:

\[\ell(z)=-\sum_{k=1}^{3}P_k\log Q_k.\]

这里用 ℓ 表示把 logits 直接当作输入的损失函数。使用平均损失而不是五次观测的总和很重要:总损失和总梯度都会是这里的五倍;如果学习率不变,更新幅度也会改变。

先求损失对 logits 的导数。利用 Softmax 的定义和目标概率之和为一,可以把同一个损失改写成:

\[\ell(z) = \log\left(\sum_{j=1}^{3}e^{z_j}\right) -\sum_{j=1}^{3}P_jz_j.\]

第一项对 zₖ 的偏导数,正好是模型概率 Qₖ;第二项的偏导数是 −Pₖ。因此:

\[\boxed{ \frac{\partial\ell}{\partial z_k}=Q_k-P_k }\]

这个结果是在上述 Softmax、自然对数和固定目标的设定下推出来的,并不是任何损失函数都具有的形式。

沿用前两篇的 logits:

\[z=(0,1,2).\]

于是模型概率和梯度分别约为:

\[Q\approx(0.090031,0.244728,0.665241),\] \[\nabla_z\ell\approx(-0.509969,0.044728,0.465241).\]

如果三个 logits 可以独立调整,负梯度更新会增加 cats 的分数,降低另外两个分数。原来那句“给实际数据更合适的概率”,终于对应到了具体的变化方向。

但网络通常修改的是共享参数,而不是给每一个上下文独立保存的 logits。为了让这一步也能手算,构造一个只有一个可训练参数的模型:

\[z(\theta)=(2\theta,\theta+1,2).\]

这是一个专门用来说明共享计算的模型,不是对真实语言模型架构的描述。它的表示能力很有限。初始参数取零时,恰好得到前面的那组 logits。

参数 θ 同时影响 cats 和 dogs 两个分数。定义参数层面的损失:

\[L(\theta)=\ell(z(\theta)).\]

链式法则要求我们把各条路径的贡献相加:

\[\frac{dL}{d\theta} = \sum_{k=1}^{3} \frac{\partial\ell}{\partial z_k} \frac{dz_k}{d\theta}.\]

在 θ 等于零时:

\[\begin{aligned} \frac{dL}{d\theta} &=2(-0.509969\ldots) +1(0.044728\ldots) +0(0.465241\ldots)\\ &\approx-0.975210. \end{aligned}\]

共享参数收到的各条 logits 路径贡献,以及相加后的总梯度。

三条路径的贡献有不同的符号。底部是它们的和,不是第四条路径。pizza 的 logit 不依赖 θ,因此这条路径的局部导数为零。

这就是“沿路径相乘、在汇合处相加”的具体样子。cats 那条路径要求参数增加,dogs 那条路径要求参数减少,最终方向由加总后的局部敏感性决定。

现在采用学习率 0.5,做一次梯度下降:

\[\theta_{\mathrm{new}} =0-0.5(-0.975210\ldots) \approx0.487605.\]

重新计算 logits、概率和损失,得到:

量 更新前 更新后
θ 0.000000 0.487605
cats logit 0.000000 0.975210
dogs logit 1.000000 1.487605
pizza logit 2.000000 2.000000
cats 概率 0.090031 0.183291
dogs 概率 0.244728 0.305965
pizza 概率 0.665241 0.510743
平均 NLL 1.807606 1.389241

这里的结果有个值得停一下的细节:dogs 原来的概率已经高于目标 0.2,更新后却进一步上升了。

这并不与梯度计算矛盾。我们改变的是同时影响多个分数的一个共享参数,不能把每个候选的概率当作独立旋钮。损失考虑的是各结果按 P 加权后的总 log-loss,并不是分别最小化每个概率与目标之间的绝对误差。这次更新降低了前一种目标,却没有让每个概率都同时靠近目标。

而且,虽然 pizza 的 logit 没变,它的概率仍然变了,因为 Softmax 的归一化分母依赖所有分数。

参数更新可以让总损失下降,却不保证每个结果的概率都在同一步里更接近目标。

以上都是从给定公式复算的一步教学计算,不是训练实验的性能结果。

六、把求导与更新分开写进代码

这个小模型可以直接写成带类型标注的 PyTorch 代码:

import torch


def toy_logits(theta: torch.Tensor) -> torch.Tensor:
    """Map a scalar parameter to three shared logits."""
    return torch.stack((2.0 * theta, theta + 1.0, theta.new_tensor(2.0)))


def toy_loss(theta: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
    """Return the average natural-log loss for a fixed target distribution."""
    log_probs = toy_logits(theta).log_softmax(dim=-1)
    return -(target * log_probs).sum()


target: torch.Tensor = torch.tensor([0.6, 0.2, 0.2], dtype=torch.float64)
theta: torch.Tensor = torch.tensor(0.0, dtype=torch.float64, requires_grad=True)
learning_rate: float = 0.5

loss_before = toy_loss(theta, target)
loss_before.backward()  # Compute gradients; theta is still zero.

if theta.grad is None:
    raise RuntimeError("The parameter gradient was not computed.")

with torch.no_grad():
    theta -= learning_rate * theta.grad  # Perform the parameter update.

theta.grad = None  # Do not accumulate this step's gradient into the next one.
loss_after = toy_loss(theta, target)

print(f"{loss_before.item():.6f} -> {loss_after.item():.6f}")

本次计算的输出是:

1.807606 -> 1.389241

backward() 沿计算图计算梯度并累积到叶子张量的 .grad,不会替我们完成这里的参数更新。更新被放进 no_grad() 中,然后重新前向计算,才得到新参数对应的损失。下一次求导前还需要清理不打算继续累积的梯度。4

有些地方使用了近似,有些地方没有。Taylor 用局部多项式近似有限步长的函数变化;上面光滑计算图中的反向传播,则按导数规则计算当前点的梯度,并不是用切线粗略猜每个偏导数。实际浮点运算当然仍有数值误差。3

训练到这里才真正形成一个循环:前向计算给出损失,反向传播给出敏感性,更新规则改变参数,然后在新的位置重新计算。

我们没有因此获得一张通往最优模型的完整地图。我们得到的是一个可以反复执行的局部过程。

前两篇解释了输出分布怎样表示、损失怎样定义;这一篇让损失通过计算关系影响了参数。但例子中,从上下文到 logits 的那部分仍然被压得很小。

下一篇要打开的正是这个部分:当前 token 怎样从前文取出有用的信息,并让这种取用方式也能被同一个损失训练?

  1. OpenStax, Calculus Volume 2, Taylor and Maclaurin Series. Taylor 多项式、余项与级数收敛的条件是不同层次的问题。 ↩

  2. Ian Goodfellow, Yoshua Bengio, and Aaron Courville, Deep Learning, Chapter 4: Numerical Computation, Section 4.3. 本文的抛物线数值和固定步长结论由给定函数直接计算。 ↩ ↩2

  3. Atilim Gunes Baydin, Barak A. Pearlmutter, Alexey Andreyevich Radul, and Jeffrey Mark Siskind, Automatic Differentiation in Machine Learning: a Survey, JMLR 18(153):1–43, 2018. 另见 PyTorch 的 Autograd mechanics;本文只推导光滑运算,未展开不可微点处的约定。 ↩ ↩2

  4. PyTorch 官方文档,torch.autograd.backward 与 Autograd mechanics。 ↩