Skip to content
汉松札记
Go back

机器学习基础之梯度计算:Pytorch代码与公式

技术笔记

我们看教科书里面讲批量梯度下降的算法数学公式都是类似这样的:

(w,b)←(w,b)−η|B|∑i∈B∂(w,b)l(i)(w,b).(\mathbf{w},b) \leftarrow (\mathbf{w},b) - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_{(\mathbf{w},b)} l^{(i)}(\mathbf{w},b). \\

压根没有提到工程实现上面要面临的实际问题。本文主要就是通过结合代码和公式的分析让理论和实践之间完成一次双向奔赴。

机器学习中用来优化损失函数的一般都是用梯度下降算法,梯度的计算现在基本都是用Pytorch的自动微分来自动计算梯度。这就造成了初学者会有的一个学习困难:平时看书本里面都是数学公式,实际写代码的时候梯度计算都是框架自动完成的,这样对于算法编程是友好的,但对于理解原理是有阻碍的。

因此,这篇文章的主要目标就是为机器学习初学者解释Pytorch梯度计算代码跟数学公式之间的联系,便于更加深入理解机器学习的原理。

一个简单的梯度计算例子

假设我们想对函数y=2x⊤xy=2\mathbf{x}^{\top}\mathbf{x}关于列向量x\mathbf{x}求导,将 y=2x⊤xy = 2\mathbf{x}^{\top}\mathbf{x} 表示为非向量形式,设x=[x1,x2,x3,x4]⊤\mathbf{x} = [x_1, x_2, x_3, x_4]^{\top}。

在这种情况下,x⊤x\mathbf{x}^{\top}\mathbf{x} 是向量 x\mathbf{x}自身的点积,可以表示为:

\mathbf{x}^{\top}\mathbf{x} = x_1^2 + x_2^2 + x_3^2 + x_4^2 \\

因此,y 可以表示为:

y = 2(x_1^2 + x_2^2 + x_3^2 + x_4^2) \\

这是 \mathbf{x} 中每个元素平方之和的两倍。注意这里的y是一个标量,即纯数值,没有方向。简单来说,标量是单个数值,而向量是数值的集合,可以表示方向和大小。

首先,我们创建变量x​并为其分配一个初始值。

import torch

x = torch.arange(4.0)
x

这段代码创建了一个一维的PyTorch张量 x​,其中包含了从0到3的四个浮点数。在数学表达式中,这可以表示为一个向量:

x = \begin{bmatrix} 0.0 \\ 1.0 \\ 2.0 \\ 3.0 \end{bmatrix} \\

x.requires_grad_(True)  # 等价于x=torch.arange(4.0,requires_grad=True)
x.grad  # 默认值是None

这段代码使 x​ 的 requires_grad​ 属性变为 True​,这意味着PyTorch将会追踪对 x​ 的所有操作以便自动计算梯度。在数学中,这是为了准备计算 x​ 相关函数的导数。

初始时,x.grad​ 的值是 None​,因为还没有进行任何梯度计算。在梯度计算后(例如,通过反向传播),x.grad​ 会存储 x​ 的梯度。在数学上,这对应于函数相对于 x​ 的导数。

y = 2 * torch.dot(x, x)
y

这段代码计算了 y​,它是向量 x​ 与自身的点积乘以2。在数学上,这可以表示为:

y = 2 \cdot \sum_{i=1}^{n} x_i^2 \\

其中,x_i 是向量 x​ 中的第 i 个元素。计算过程是:

y = 2 \cdot (0^2 + 1^2 + 2^2 + 3^2) = 2 \cdot (0 + 1 + 4 + 9) = 2 \cdot 14 = 28 \\

y.backward()
x.grad

执行 y.backward()​ 的操作会触发PyTorch的自动微分机制,计算 y​ 关于 x​ 的梯度,并将这个梯度存储在 x.grad​ 中。在这个具体的例子中,y​ 是 x​ 的元素平方的和的两倍。因此,y​ 对 x​ 中每个元素的偏导数是该元素的两倍。

对于给定的 x = [0, 1, 2, 3]​,y​ 关于 x​ 的梯度(即 x.grad​)可以通过对 y​ 对 x​ 的每个元素求偏导来计算:

\frac{\partial y}{\partial x_i} = 2 \cdot 2 \cdot x_i = 4x_i \\

因此,对于每个元素 x_i(即0, 1, 2, 3),梯度将是 4x_i。所以,x.grad​ 将是:

x.\text{grad} = \begin{bmatrix} 4 \cdot 0 \\ 4 \cdot 1 \\ 4 \cdot 2 \\ 4 \cdot 3 \end{bmatrix} = \begin{bmatrix} 0 \\ 4 \\ 8 \\ 12 \end{bmatrix} \\

x.grad​ 中的梯度代表的是函数 y = 2\mathbf{x}^{\top}\mathbf{x} 在点 \mathbf{x} = [0, 1, 2, 3]^{\top} 处相对于 \mathbf{x} 的变化率。具体来说,每个元素的梯度 4x_i 表示在该点 y 对 x_i 的偏导数,即当 x_i 在该点变化时,y 的变化率。

在 y = 28 这一点,梯度 \mathbf{x}.\text{grad} = [0, 4, 8, 12]^{\top} 意味着:

  1. x_1(即0)的梯度为0,表明在 x_1 处 y 对 x_1 的变化不敏感。
  2. x_2(即1)的梯度为4,表明若 x_2 增加一个很小的值,y 将大约增加4倍这个小的增量。
  3. x_3(即2)和 x_4(即3)的梯度分别为8和12,表明 y 对这些元素的变化更为敏感。

梯度在机器学习中非常重要,因为它指明了如何调整 \mathbf{x} 以最快地增加或减少 y。例如,在梯度下降算法中,我们会沿着梯度的反方向更新 \mathbf{x},从而最小化 y。

对损失向量如何计算梯度

问题引入

热身完毕,我们进入正题。首先我们可以利用这个简单自动微分的能力来实现一个线性回归的代码:

import torch
import numpy as np

# 定义均方损失函数
def squared_loss(y_hat, y):
    """均方损失"""
    return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

# 定义小批量随机梯度下降函数
def sgd(params, lr, batch_size):
    """小批量随机梯度下降"""
    with torch.no_grad():
        for param in params:
            param -= lr * param.grad / batch_size
            param.grad.zero_()

# 定义线性回归模型
def linreg(X, w, b):
    """线性回归模型"""
    return torch.matmul(X, w) + b

# 定义生成数据集的函数
def synthetic_data(w, b, num_examples):
    """生成 y = Xw + b + 噪声"""
    X = torch.normal(0, 1, (num_examples, len(w)))
    y = torch.matmul(X, w) + b
    y += torch.normal(0, 0.01, y.shape)
    return X, y.reshape((-1, 1))

# 设置真实权重和偏差
true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)

# 定义小批量随机读取数据集的函数
def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples))
    # 这些样本是随机读取的,没有特定顺序
    np.random.shuffle(indices)
    for i in range(0, num_examples, batch_size):
        batch_indices = torch.tensor(
            indices[i: min(i + batch_size, num_examples)])
        yield features[batch_indices], labels[batch_indices]

# 初始化模型参数
w = torch.normal(0, 0.01, size=(2, 1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)

# 训练过程
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
    for X, y in data_iter(10, features, labels):
        l = loss(net(X, w, b), y)  # X和y的小批量损失
        l.sum().backward() #计算梯度
        sgd([w, b], lr, 10)  # 使用参数的梯度更新参数
    with torch.no_grad():
        train_l = loss(net(features, w, b), labels)
        print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

我们重点关注计算梯度的代码:l.sum().backward()​。我打印了中间结果X和l的值如下:

X: tensor([[-0.0288, -0.6765],
        [-1.5220, -0.6657],
        [ 1.6177, -0.8816],
        [ 0.0680,  0.3873],
        [ 0.5701, -0.1812],
        [-0.2708, -1.8384],
        [ 0.6061, -1.8384],
        [-1.8956, -0.2839],
        [ 0.0729,  0.2994],
        [ 0.6761,  0.8173]])
l: tensor([[3.0895e-05],
        [2.0113e-05],
        [4.9850e-07],
        [1.9001e-05],
        [1.9959e-08],
        [2.2982e-05],
        [2.5084e-05],
        [2.5881e-05],
        [1.8395e-06],
        [5.2921e-05]], grad_fn=<DivBackward0>)

可以发现上面代码中的l​是一个2x1​的二维矩阵,也就是每个样本的损失的一维向量。到这里初学者就会开始有疑问了:上面的代码看起来一次梯度计算是用了10个样本的损失并行算的,l​是一个向量怎么计算梯度呢?我们学微积分的时候可没有学过怎么对一个向量求偏导数的。

这个疑问就源于理论和工程实现的gap。从数学的角度看,我们只能用标量来计算多个自变量的梯度,比如y = 2(x_1^2 + x_2^2 + x_3^2 + x_4^2),所以第一个例子我们算梯度的时候是用一个样本单独计算对应的梯度的。但为了利用计算机的并行计算能力,所以实践中都是一批样本一起放进去算损失,这时梯度也需要一起计算。在这种y​是向量的情况,计算梯度就不能直接调用l.backward()​,而是要先将l​求和变成一个标量,然后再进行反向传播求梯度,即l.sum().backward()​。

向量求梯度的例子

下面我通过一个简化的例子帮助理解,参考第一个例子中的y=2\mathbf{x}^{\top}\mathbf{x},我们这次定义一个向量版本的函数:\mathbf{y}=2\mathbf{x}^2,假设 x​ 是一个四维向量 [x_1, x_2, x_3, x_4],这个表达式实际上是对 x​ 的每个元素进行平方然后乘以2。因此,也可以表示为:

y = \begin{bmatrix} 2x_1^2 \\ 2x_2^2 \\ 2x_3^2 \\ 2x_4^2 \end{bmatrix} \\

这里,y​ 是一个与 x​ 维度相同的向量,其每个元素是 x​ 对应元素平方的两倍。

y = 2 * x * x
# 等价于y.backward(torch.ones(len(x)))
y.sum().backward()
x.grad

首先计算 y = 2*x*x​,这是对 x​ 的每个元素进行平方乘以2的操作。接着,调用 y.sum().backward()​ 来计算 y​ 中所有元素之和的梯度。

对于 y = 2*x*x​,计算其对 x​ 的梯度(即 x.grad​)需要对每个元素 x_i 计算偏导数。偏导数的计算公式为:

\frac{\partial (2x_i^2)}{\partial x_i} = 4x_i \\

所以,对于每个元素 x_i(即0, 1, 2, 3),梯度将是 4x_i。因此,x.grad​ 将是:

x.\text{grad} = \begin{bmatrix} 4 \cdot 0 \\ 4 \cdot 1 \\ 4 \cdot 2 \\ 4 \cdot 3 \end{bmatrix} = \begin{bmatrix} 0 \\ 4 \\ 8 \\ 12 \end{bmatrix} \\

这与前面y=2\mathbf{x}^{\top}\mathbf{x}的求梯度结果一致,因为操作本质上相同,只是通过 y.sum()​ 来累加 y​ 的所有元素,从而形成一个可直接进行反向传播的标量。

自动微分对向量的兼容

注意这里的y.sum().backward()​等价于y.backward(torch.ones(len(x)))​。

在 PyTorch 中,当对非标量(例如向量或矩阵)调用 backward()​ 时,需要提供一个与非标量同形状的 gradient​ 参数。这个参数指定了非标量每个元素的梯度权重。

  1. y.sum().backward()​:这里首先计算 y​ 中所有元素的和,得到一个标量,然后对这个标量调用 backward()​。由于结果是标量,不需要提供 gradient​ 参数。
  2. y.backward(torch.ones(len(x)))​:这里直接对非标量 y​ 调用 backward()​,但提供了一个与 y​ 同形状的全1向量作为 gradient​ 参数。这意味着每个元素的梯度权重都是1。

两种方法都等价于计算 y​ 中每个元素相对于 x​ 的偏导数,并将这些偏导数加总。数学上,这可以表示为:

\text{对于 } y.sum().backward(): \quad \frac{\partial}{\partial x_i} \sum_{j=1}^{n} y_j \\\text{对于 } y.backward(torch.ones(len(x))): \quad \sum_{j=1}^{n} \frac{\partial y_j}{\partial x_i} \\

由于求和的导数是求和项导数的和(求导的线性特性),我们可以写出:

\frac{\partial}{\partial x_i} \sum_{j=1}^{n} y_j = \sum_{j=1}^{n} \frac{\partial y_j}{\partial x_i} \\

我们可以代入具体的数值来进行验证结论。假设 x = [0, 1, 2, 3]​,则 y = 2 * x * x​,可以计算出两种情况下的梯度。

1. 对于 y.sum().backward()

这里我们计算 sum(y)​​ 关于每个 x_i​​ 的偏导数:

y = 2 * x * x = [0, 2, 8, 18] \\\text{sum}(y) = \sum_{j=1}^{n} y_j = 0 + 2 + 8 + 18 = 28 \\\frac{\partial}{\partial x_i} \sum_{j=1}^{n} y_j = \frac{\partial}{\partial x_i} (2x_i^2) = 4x_i \\

代入 x​ 的值,我们得到:

x.\text{grad} = [0, 4, 8, 12] \\

2. 对于 y.backward(torch.ones(len(x)))​​

当我们在 y.backward(torch.ones(len(x)))​ 中使用全1向量作为 gradient​,我们实际上是对 y​ 中的每个元素赋予相同的权重。在数学上,这意味着我们在计算 y​ 中每个元素对最终梯度的贡献时,每个元素都被等同对待。具体到公式,我们可以这样表示:

设 y = [y_1, y_2, y_3, y_4]​,其中每个 y_i = 2x_i^2​,并且 gradient = [1, 1, 1, 1]​。那么,对于 y.backward(gradient)​,实际上进行的计算是:

\sum_{j=1}^{n} \text{gradient}_j \cdot \frac{\partial y_j}{\partial x_i} \\

由于每个 \text{gradient}_j = 1,这个表达式简化为:

\sum_{j=1}^{n} \frac{\partial y_j}{\partial x_i} \\

对于每个 y_j​(即 2x_j^2​),其关于 x_i​ 的偏导数是 4x_i​(因为 y_j​ 直接依赖于对应的 x_i​,并且偏导数为 4x_i​)。因此,这个公式变成:

\sum_{j=1}^{n} 4x_i \\

代入具体的 x 值 [0, 1, 2, 3],计算结果为:

x.\text{grad} = [4 \cdot 0, 4 \cdot 1, 4 \cdot 2, 4 \cdot 3] = [0, 4, 8, 12] \\

这与之前通过 y.sum().backward()​​ 得到的结果相同。在这种情况下,由于 gradient​​ 是全1向量,我们实际上是计算了 y​​ 中每个元素相对于 x​​ 的梯度之和,这与直接对 y.sum()​​ 求梯度是等价的。

y.sum().backward()​ 和 y.backward(torch.ones(len(x)))​ 之间,计算过程存在细微区别,主要体现在梯度累积的方式上。

  1. y.sum().backward()​: 首先计算 y​ 的所有元素之和,形成一个标量,然后对这个标量执行反向传播。在这个过程中,梯度是直接针对求和结果计算的,这意味着在内部,自动微分系统会将 y​ 中每个元素的梯度累加起来,然后一起反向传播。
  2. y.backward(torch.ones(len(x)))​: 对 y​ 的每个元素执行反向传播,其中 torch.ones(len(x))​ 指定了每个元素梯度的权重(在这个例子中,都是1)。在这种情况下,自动微分系统会分别计算 y​ 中每个元素对应的梯度,然后将这些梯度按照权重(这里是1)相加,最终得到总梯度。

区别在于:

在效果上,当使用全1的张量时,两者计算得到的梯度是相同的。但在内部实现上,y.backward(torch.ones(len(x)))​ 会对每个元素分别计算梯度然后累加,而 y.sum().backward()​ 直接对总和计算梯度。

这里我们举一个非全1的gradient​向量来进一步说明它的作用。

import torch

x = torch.arange(4.0)
x

x.requires_grad_(True)  # 等价于x=torch.arange(4.0,requires_grad=True)
x.grad  # 默认值是None

y = 2 * x * x
y.backward(torch.tensor([1, 1, 0.1, 0.1]))
x.grad
  1. 首先,定义了一个张量 x​,它是一个一维数组,包含从0到3的四个连续整数。数学上,可以表示为: x = \begin{bmatrix} 0 & 1 & 2 & 3 \end{bmatrix} \\

  2. 接下来,代码启用了 x​ 的梯度计算。在数学中,这意味着我们对 x​ 中的元素进行操作时,将跟踪这些操作以计算梯度。

  3. 然后,定义了 y​,作为 x​ 的元素平方的两倍。数学表达式为: y = 2x^2 \\ 展开来,对于每个元素,有: y = \begin{bmatrix} 0 & 2 & 8 & 18 \end{bmatrix} \\

  4. y.backward(torch.tensor([1, 1, 0.1, 0.1]))​ 这一步是计算 y​ 关于 x​ 的梯度。传递给 backward​ 的张量是梯度的权重。在这个例子中,权重是 [1, 1, 0.1, 0.1]​。这意味着梯度计算是针对 y​ 中每个元素的加权和。 对于函数 y = 2x^2,其导数(梯度)是 dy/dx = 4x。因此,对于每个元素,梯度(在没有权重的情况下)是: \frac{dy}{dx} = \begin{bmatrix} 0 & 4 & 8 & 12 \end{bmatrix} \\ 考虑到权重 [1, 1, 0.1, 0.1]​,最终的梯度是每个元素的梯度乘以其相应的权重。所以,计算结果是: x.\text{grad} = \begin{bmatrix} 0 \times 1 & 4 \times 1 & 8 \times 0.1 & 12 \times 0.1 \end{bmatrix} \\ = \begin{bmatrix} 0 & 4 & 0.8 & 1.2 \end{bmatrix} \\

反向传播的起点是标量

那么为什么反向传播需要从一个标量开始?原因在于它的核心算法——链式法则。链式法则用于计算复合函数的导数,而在多维情况下,这需要有一个清晰定义的输出方向来应用。

在深度学习中,我们通常关注的是如何根据损失函数(一个标量)来调整网络参数。损失函数是一个标量,因为它提供了一个单一的度量,表示当前模型的表现好坏。计算这个标量相对于模型参数的梯度,就是在问:“如果我改变这个参数一点点,损失函数会如何变化?”

当你有一个向量或矩阵输出时,这个输出的每个元素可能依赖于输入的不同部分,也可能相互依赖。如果直接从这样的结构开始反向传播,就会缺乏一个统一的量度来衡量整体的变化。转换成标量后,就提供了一个明确的、单一的值,表示整个输出的“总效应”,从而可以应用链式法则来计算对每个输入的影响。

因此,通过将输出转换为标量,我们可以更清晰地使用链式法则来逐步回溯,计算每个参数的梯度。这就是为什么反向传播通常需要从一个标量开始的原因。

打个比方,想象一下,你是一个园艺师,负责一个大花园的照顾。你的目标是让整个花园看起来尽可能美丽。这里,“花园的美丽程度”可以被看作是一个“标量”——它是一个单一的度量,表示整个花园的总体状况。

现在,假设你有一系列的工具和技术(相当于模型的参数),比如浇水、施肥、修剪等,来照顾花园的不同部分。你需要弄清楚,应该如何调整这些工具和技术的使用,以最大化花园的美丽程度。

如果你直接尝试同时关注花园的每一朵花、每一棵植物(相当于一个向量或矩阵输出),那就太复杂了。每个部分都有自己的需求,而且它们之间可能相互影响。这就像试图同时解决太多的问题,没有一个清晰的方向。

但如果你将注意力集中在“整个花园的美丽程度”上,那么你就有了一个明确的目标。你可以问自己:“如果我增加这个区域的浇水次数,整个花园的美丽程度会如何变化?”这样,你就可以一步步地调整你的方法,直到找到最优的照顾方式。

在机器学习中,反向传播的情况类似。我们需要一个标量(如损失函数),来衡量整个模型的表现。然后,我们可以计算模型每个参数对这个标量的影响(梯度),逐步调整这些参数,以优化整个模型的表现。这就是为什么反向传播需要从一个标量开始的原因。

在我们的例子中,当执行 y.sum().backward()​ 时,我们实际上是在计算 y​ 所有元素之和的梯度。计算的结果是一个向量,这个向量提供了如何调整 x​ 以最大程度地影响 y​ 的总和的信息。在梯度下降算法中,l.sum().backward()​计算出来的梯度会用于更新参数,让损失的和l.sum()​最小化,也就是说哪些参数让损失和的影响最大,就反向调整这些参数,让它们影响变小。

因此,不管是直接代入一个x​​计算损失的标量结果,还是代入N​个x​​批量计算出损失的向量结果后通过求和转为标量,最后的目的都是为了给优化算法确定一个可以量化的目标,这样梯度下降算法才能根据这个量化的目标去最小化。

总结

在简单例子中,y.sum().backward()​用于计算函数y = 2 * x * x​对x​的梯度。当我们调用.backward()​时,PyTorch 自动计算这些梯度并存储在x.grad​属性中。简单例子的核心是对一个单变量函数的梯度计算。

对于线性回归示例,l.sum().backward()​的作用类似,但上下文更复杂。在这里,l​是损失函数的输出,是一个批量数据的集合。线性回归模型的目标是找到最小化损失函数的参数值,即权重w​和偏差b​。

  1. 损失函数(Loss Function) : 在线性回归中,我们使用均方误差作为损失函数。这个函数计算了预测值(y_hat​)和真实值(y​)之间的差异。
  2. 梯度计算(Gradient Computation) : l.sum().backward()​计算损失函数相对于模型参数(在这个例子中是w​和b​)的梯度。这里的.sum()​是因为l​是一个批量数据的损失集合,我们需要一个单一的损失值来计算梯度。
  3. 批量数据(Batch Data) : 在线性回归的例子中,我们不是对单一数据点计算梯度,而是对一个数据批量。这是深度学习中常见的做法,可以加快训练速度,并且有助于提高模型的泛化能力。
  4. 参数更新(Parameter Update) : 计算得到的梯度用于更新模型的参数,这一步在sgd()​函数中完成。

总结来说,y.sum().backward()​和l.sum().backward()​都是在计算梯度,但是在简单例子中,我们是对单一函数的单个变量进行操作,而在线性回归中,我们是在处理一个批量数据集合,并且涉及到模型参数的更新。

l.sum().backward()​ 在深度学习的上下文中,对应于梯度下降算法中计算梯度的步骤。在文章开头给出的公式中:

(\mathbf{w},b) \leftarrow (\mathbf{w},b) - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_{(\mathbf{w},b)} l^{(i)}(\mathbf{w},b) \\

这个公式是一个批量梯度下降更新步骤的表示,其中:

在这个过程中,l.sum().backward()​ 乍一看是对应于计算 \sum_{i \in \mathcal{B}} \partial_{(\mathbf{w},b)} l^{(i)}(\mathbf{w},b) 这一部分。但其实是不一样的,在使用 l.sum().backward()​ 时,我们实际上是计算批量中所有损失的和相对于模型参数的梯度,而不是计算批量中每个损失相对于模型参数的梯度后再求和。

由于微积分中的求和与微分操作通常可以互换(即求和的微分等于微分的求和),所以这两个公式在大多数情况下是等价的。不等价的可能情况之一就是在实际的计算中,由于数值稳定性和精度限制,对每个损失项单独计算梯度然后求和与对总和求梯度可能会导致略有不同的数值结果。

至此我们终于将开头的公式跟Pytorch的代码联系起来了,简单的一个公式到代码实现需要跨越巨大的鸿沟,本文只是揭示了冰山一角,希望对初学者入门机器学习有一定的帮助。


订阅 技术笔记

RSS 邮件订阅待配置
Share this post on:

Previous Post
ChatGPT文本生成的工程原理分析与代码示例
Next Post
ChatGPT之GPTs的使用与分析:5分钟构建一个天气画报