大规模 MLOps 工程(二)(3)https://developer.aliyun.com/article/1517766
6.1 理解自动微分的基础知识
本节介绍了自动微分的概念,并通过使用纯粹的 Python 编程语言构造,在没有使用 PyTorch 的情况下,通过一个简单的例子来教授其基础知识。在这个过程中,您将深入理解 PyTorch 自动微分功能,并开发出使您能够在项目中解决 PyTorch 自动微分问题的知识。在本节中,您将看到自动微分虽然出奇地简单,但它是一个支持复杂应用微积分链式法则的算法。在后续章节中,您将应用所学知识,并使用 PyTorch 张量的自动微分功能。
PyTorch 张量的自动微分功能是该框架成为深度学习和许多依赖于梯度下降以及相关优化技术的机器学习算法流行的核心原因之一。虽然可以将自动微分视为一个黑盒子来使用,而不完全理解它的工作方式,但如果您希望开发用于在生产场景中排除自动微分问题的技巧,了解这个关键的 PyTorch 功能至少是有价值的。
PyTorch 实现了一种名为反向模式积累自动微分的自动微分方法,这是一种高效的方法,用于计算常用于机器学习的损失函数(在附录 A 中定义)的梯度,包括均方误差和交叉熵。更准确地说,PyTorch 自动微分具有 O(n)的计算复杂度,其中 n 是函数中操作(如加法或乘法操作)的总数,只要函数的输入变量多于输出变量。
如果您已经熟悉反向模式积累自动微分,可以跳转到第 6.2 节,其中解释如何使用 PyTorch 自动微分 API 进行机器学习。否则,本节将帮助您更深入地了解 PyTorch 自动微分 API 设计及其用途。
如果您刚开始学习自动微分,需要知道它与其他流行的微分技术(如数值微分或符号微分)是不同的。数值微分通常在本科计算机科学课程中教授,基于对![006-01_EQ01]的近似。与数值微分不同,自动微分在数值上是稳定的,这意味着它在不同函数值的极端值时提供准确的梯度值,并且对实数的浮点数近似所引入的小误差的累积是有决策力的。
与符号微分不同,自动微分不尝试派生一个差分函数的符号表达式。因此,自动微分通常需要更少的计算和内存。然而,符号微分推导了一个可应用于任意输入值的差异函数,不像自动微分,它一次为函数的特定输入变量的值进行差异。
理解自动微分的一个好方法是自己实现一个玩具示例。在本节中,您将为一个微不足道的张量实现自动微分,一个纯量,添加支持计算使用加法和乘法的函数的梯度,然后探索如何使用您的实现来区分常见函数。
要开始,定义一个标量 Python 类,存储标量的值(val)和其梯度(grad):²
class Scalar: def __init__(self, val): self.val = val self.grad = 0
为了更好地跟踪标量实例的内容并支持更好的实例值输出打印,让我们也添加一个 repr 方法,返回实例的字符串表示形式:
def __repr__(self): return f"Value: {self.val}, Gradient: {self.grad}"
有了这个实现,您可以实例化一个标量类的对象,例如使用 Scalar(3.14)。
列表 6.1 grad 属性用于存储标量张量的梯度
class Scalar: def __init__(self, val): self.val = val self.grad = 0.0 def __repr__(self): return f"Value: {self.val}, Gradient: {self.grad}" print(Scalar(3.14))
一旦执行,这个操作应该返回输出
Value: 3.14, Gradient: 0
这与 repr 方法返回的字符串相对应。
接下来,让我们通过重写相应的 Python 方法来实现对标量实例的加法和乘法。在反向模式自动微分中,这被称为前向传播 过程,它仅仅计算标量运算的值:
def __add__(self, other): out = Scalar(self.val + other.val) return out def __mul__(self, other): out = Scalar(self.val * other.val) return out
此时,您可以对标量实例执行基本的算术运算,
class Scalar: def __init__(self, val): self.val = val self.grad = 0 def __repr__(self): return f"Value: {self.val}, Gradient: {self.grad}" def __add__(self, other): out = Scalar(self.val + other.val) return out def __mul__(self, other): out = Scalar(self.val * other.val) return out Scalar(3) + Scalar(4) * Scalar(5)
正确计算为
Value: 23, Gradient: 0
并证实该实现遵守算术优先规则。
在这一点上,整个实现只需大约十二行代码,应该很容易理解。您已经完成了一半以上的工作,因为这个实现正确计算了自动微分的前向传播。
为了支持计算和累积梯度的反向传播,您需要对实现做一些小的更改。首先,标量类需要用默认设置为一个空操作的反向函数进行初始化❶。
列表 6.2 反向传播支持的后向方法占位符
class Scalar: def __init__(self, val): self.val = val self.grad = 0 self.backward = lambda: None ❶ def __repr__(self): return f"Value: {self.val}, Gradient: {self.grad}" def __add__(self, other): out = Scalar(self.val + other.val) return out def __mul__(self, other): out = Scalar(self.val * other.val) return ou
❶ 使用 lambda: None 作为默认实现。
令人惊讶的是,这个实现足以开始计算琐碎线性函数的梯度。例如,要找出线性函数y = x在 x = 2.0 处的梯度,您可以从评估开始
x = Scalar(2.0) y = x
它将 x 变量初始化为一个 Scalar(2.0),并声明函数y = x。此外,由于这是一个非常简单的案例,计算 y 的前向传播只是一个空操作,什么也不做。
接下来,在使用反向函数之前,您需要执行两个先决步骤:首先,将变量的梯度清零(我将很快解释为什么),其次,指定输出 y 的梯度。由于 x 是函数中的一个单独变量,清零其梯度就相当于运行
x.grad = 0.0
如果您觉得设置 x.grad = 0.0 这个步骤是不必要的,因为 grad 已经在 init 方法中设置为零,那么请记住,这个例子是针对一个琐碎函数的,当您稍后将实现扩展到更复杂的函数时,设置梯度为零的必要性会变得更加明显。
第二步是指定输出 y 的梯度值,即关于自身的。幸运的是,如果您曾经将一个数字除以自身,那么这个值就很容易找出:y.grad 就是 1.0。
因此,要在这个简单线性函数上执行反向累积自动微分,你只需要执行以下操作:
x = Scalar(2.0) y = x x.grad = 0.0 y.grad = 1.0 y.backward()
然后使用
print(x.grad)
计算出 的值,其结果正确显示为 1.0。
如果你一直关注 y = x 的定义,你完全有权利提出反对,认为这整个计算过程只是将梯度从 y.grad = 1.0 语句中取出并打印出来。如果这是你的思维线路,那么你是绝对正确的。就像前面介绍 的例子一样,当计算 的梯度时,对于函数 y = x,y 相对于 x 的变化与 x 相对于 y 的变化的比率就是 1.0。然而,这个简单的例子展示了一系列自动微分操作的重要顺序,即使对于复杂函数也是如此:
- 指定变量的值
- 指定变量的输出(前向传播)
- 确保变量的梯度设置为零
- 调用 backward() 来计算梯度(后向传播)
如果你对微分的推理感到舒适,那么你就可以进一步计算更复杂函数的梯度了。使用自动微分,梯度的计算发生在实现数学运算的函数内部。让我们从比较容易的加法开始:
def __add__(self, other): out = Scalar(self.val + other.val) def backward(): self.grad += out.grad other.grad += out.grad self.backward() other.backward() out.backward = backward return out
注意,梯度的直接计算、累积和递归计算发生在分配给加法操作所产生的 Scalar 对象的 backward 函数的主体中。
要理解 self.grad += out.grad 和类似的 other.val += out.grad 指令背后的逻辑,你可以应用微积分的基本规则或者进行一些有关变化的直观推理。微积分中的相关事实告诉我们,对于一个函数 y = x + c,其中 c 是某个常数,那么 = 1.0。这与之前计算 y = x 的梯度的例子几乎完全相同:尽管给 x 添加了一个常数,但是 y 相对于 x 的变化与 x 相对于 y 的变化的比率仍然是 1.0。对于代码来说,这意味着 self.grad 对 out.grad 贡献的变化量与 out.grad 的值是一样的。
那么对于代码计算没有常数的函数的梯度的情况呢,换句话说y = x + z,其中 x 和 z 都是变量?在实现方面,当计算 self.grad 时,为什么 out.grad 应被视为常数呢?答案归结于梯度或关于一个变量的偏导数的定义。找到 self.grad 的梯度相当于回答问题“假设所有变量,除了 self.grad,都保持不变,那么 y 对 self.grad 的变化率是多少?”因此,在计算梯度 self.grad 时,其他变量可以视为常量值。当计算 other.grad 的梯度时,这种推理也适用,唯一的区别是 self.grad 被视为常量。
还要注意,在 add 方法的梯度计算的一部分中,both self.grad 和 other.grad 都使用+=运算符累积梯度。理解 autodiff 中的这部分是理解为什么在运行 backward 方法之前需要将梯度清零至关重要。简单地说,如果你多次调用 backward 方法,则梯度中的值将继续累积,导致不良结果。
最后但并非最不重要的是,调用 backward 方法递归触发的 self.backward()和 other.backward()代码行确保 autodiff 的实现也处理了函数组合,例如 f(g(x))。请回忆,在基本情况下,backward 方法只是一个无操作的 lambda:None 函数,它确保递归调用始终终止。
要尝试带有后向传递支持的 add 实现,让我们通过将 y 重新定义为 x 值的和来查看更复杂的示例:
x = Scalar(2.0) y = x + x
从微积分中,你可能会记得y = x + x = 2 * x的导数只是 2。
使用你的 Scalar 实现确认一下。同样,你需要确保 x 的梯度被清零,初始化 = 1,然后执行后向函数:
x.grad = 0.0 y.grad = 1.0 y.backward()
此时,如果你打印出来
x.grad
它返回正确的值
2.0
要更好地理解为什么评估为 2.0,回想一下在 add 方法中实现的向后函数的定义。由于 y 定义为 y = x + x,self.grad 和 other.grad 都引用 backward 方法中 x 变量的同一实例。因此,对 x 的更改相当于对 y 或梯度的更改两倍,因此梯度为 2。
接下来,让我们扩展 Scalar 类的实现,以支持在乘法 Scalars 时计算梯度。在 mul 函数中,实现只需要六行额外代码:
def __mul__(self, other): out = Scalar(self.val * other.val) def backward(): self.grad += out.grad * other.val other.grad += out.grad * self.val self.backward() other.backward() out.backward = backward return out
乘法的梯度推导逻辑比加法更复杂,因此值得更详细地审查。与加法一样,假设你试图根据 self.grad 来推导梯度,这意味着在计算时,other.val 可以被视为常数 c。当计算 y = c * x 关于 x 的梯度时,梯度就是 c,这意味着对于 x 的每一个变化,y 都会变化 c。当计算 self.grad 的梯度时,c 就是 other.val 的值。类似地,你可以翻转对 other.grad 的梯度计算,并将 self 标量视为常数。这意味着 other.grad 是 self.val 与 out.grad 的乘积。
有了这个改变,标量类的整个实现只需以下 23 行代码:
class Scalar: def __init__(self, val): self.val = val self.grad = 0 self.backward = lambda: None def __repr__(self): return f"Value: {self.val}, Gradient: {self.grad}" def __add__(self, other): out = Scalar(self.val + other.val) def backward(): self.grad += out.grad other.grad += out.grad self.backward(), other.backward() out.backward = backward return out def __mul__(self, other): out = Scalar(self.val * other.val) def backward(): self.grad += out.grad * other.val other.grad += out.grad * self.val self.backward(), other.backward() out.backward = backward return out
为了更加确信实现正确,你可以尝试运行以下测试案例:
x = Scalar(3.0) y = x * x
重复早期步骤将梯度归零,并将输出梯度值指定为 1.0:
x.grad = 0.0 y.grad = 1.0 y.backward()
使用微积分规则,可以很容易地通过解析方法找出预期结果:给定 y = x²,则 = 2 * x。因此对于 x = 3.0,你的标量实现应返回梯度值为 6.0。
你可以通过打印输出来确认
x.grad
返回结果为
6.0.
标量实现也可扩展到更复杂的函数。以y = x³ + 4**x* + 1 为例,其中梯度 = 3 * x² + 4,所以 在 x = 3 时等于 31,你可以通过指定如下代码来使用标量类实现这个函数 y:
x = Scalar(3.0) y = (x * x * x) + (Scalar(4.0) * x) + Scalar(1.0)
然后运行
x.grad = 0.0 y.grad = 1.0 y.backward() x.grad
确认实现正确返回值为 31.0。
对于标量(Scalar)的自动微分实现与 PyTorch 张量提供的功能相比较简单。然而,它可以让你更深入地了解 PyTorch 在计算梯度时提供的功能,并阐明为什么以及何时需要使用看似神奇的zero_grad
和backward
函数。
大规模 MLOps 工程(二)(5)https://developer.aliyun.com/article/1517770