22.3. 单变量微积分¶ 在 SageMaker Studio Lab 中打开 Notebook
在 第 2.4 节中,我们看到了微分学的基本要素。本节将更深入地探讨微积分的基本原理,以及如何在机器学习的背景下理解和应用它。
22.3.1. 微分学¶
微分学本质上是研究函数在微小变化下的行为。为了理解为什么这对于深度学习如此核心,让我们来看一个例子。
假设我们有一个深度神经网络,为了方便起见,其权重被连接成一个单一的向量\(\mathbf{w} = (w_1, \ldots, w_n)\)。给定一个训练数据集,我们考虑神经网络在该数据集上的损失,我们将其写为\(\mathcal{L}(\mathbf{w})\)。
这个函数非常复杂,它编码了给定架构的所有可能模型在该数据集上的性能,因此几乎不可能判断哪一组权重\(\mathbf{w}\)会使损失最小化。因此,在实践中,我们通常首先*随机*初始化我们的权重,然后迭代地朝着使损失下降最快的方向迈出小步。
接下来的问题表面上看起来并不简单:我们如何找到使权重下降最快的方向?为了深入探讨这个问题,让我们首先研究只有一个权重的情况:\(L(\mathbf{w}) = L(x)\),其中\(x\)是一个单一的实数值。
让我们取\(x\)并尝试理解当我们将它改变一个很小的量变成\(x + \epsilon\)时会发生什么。如果你希望更具体一些,可以想象一个像\(\epsilon = 0.0000001\)这样的数字。为了帮助我们可视化所发生的事情,让我们绘制一个示例函数\(f(x) = \sin(x^x)\)在区间\([0, 3]\)上的图像。
%matplotlib inline
import torch
from IPython import display
from d2l import torch as d2l
torch.pi = torch.acos(torch.zeros(1)).item() * 2 # Define pi in torch
# Plot a function in a normal range
x_big = torch.arange(0.01, 3.01, 0.01)
ys = torch.sin(x_big**x_big)
d2l.plot(x_big, ys, 'x', 'f(x)')
%matplotlib inline
from IPython import display
from mxnet import np, npx
from d2l import mxnet as d2l
npx.set_np()
# Plot a function in a normal range
x_big = np.arange(0.01, 3.01, 0.01)
ys = np.sin(x_big**x_big)
d2l.plot(x_big, ys, 'x', 'f(x)')
[21:56:22] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
%matplotlib inline
import tensorflow as tf
from IPython import display
from d2l import tensorflow as d2l
tf.pi = tf.acos(tf.zeros(1)).numpy() * 2 # Define pi in TensorFlow
# Plot a function in a normal range
x_big = tf.range(0.01, 3.01, 0.01)
ys = tf.sin(x_big**x_big)
d2l.plot(x_big, ys, 'x', 'f(x)')
在这样大的尺度上,函数的行为并不简单。然而,如果我们将范围缩小到像\([1.75, 2.25]\)这样更小的范围,我们会发现图像变得简单得多。
# Plot a the same function in a tiny range
x_med = torch.arange(1.75, 2.25, 0.001)
ys = torch.sin(x_med**x_med)
d2l.plot(x_med, ys, 'x', 'f(x)')
# Plot a the same function in a tiny range
x_med = np.arange(1.75, 2.25, 0.001)
ys = np.sin(x_med**x_med)
d2l.plot(x_med, ys, 'x', 'f(x)')
# Plot a the same function in a tiny range
x_med = tf.range(1.75, 2.25, 0.001)
ys = tf.sin(x_med**x_med)
d2l.plot(x_med, ys, 'x', 'f(x)')
再极端一点,如果我们放大到一个非常小的片段,行为会变得更加简单:它就是一条直线。
# Plot a the same function in a tiny range
x_small = torch.arange(2.0, 2.01, 0.0001)
ys = torch.sin(x_small**x_small)
d2l.plot(x_small, ys, 'x', 'f(x)')
# Plot a the same function in a tiny range
x_small = np.arange(2.0, 2.01, 0.0001)
ys = np.sin(x_small**x_small)
d2l.plot(x_small, ys, 'x', 'f(x)')
# Plot a the same function in a tiny range
x_small = tf.range(2.0, 2.01, 0.0001)
ys = tf.sin(x_small**x_small)
d2l.plot(x_small, ys, 'x', 'f(x)')
这是单变量微积分的关键观察:在足够小的范围内,常见函数的行为可以由一条直线来建模。这意味着对于大多数函数,我们可以合理地预期,当我们将函数的\(x\)值稍微改变一点时,输出\(f(x)\)也会相应地改变一点。我们唯一需要回答的问题是:“输出的变化量与输入的变化量相比有多大?是它的一半大?还是两倍大?”
因此,我们可以考虑函数输出的变化量与函数输入的微小变化量之比。我们可以将其正式写为:
这已经足够我们在代码中进行实验了。例如,假设我们知道\(L(x) = x^{2} + 1701(x-4)^3\),那么我们可以看到在\(x = 4\)这一点这个值有多大,如下所示。
# Define our function
def L(x):
return x**2 + 1701*(x-4)**3
# Print the difference divided by epsilon for several epsilon
for epsilon in [0.1, 0.001, 0.0001, 0.00001]:
print(f'epsilon = {epsilon:.5f} -> {(L(4+epsilon) - L(4)) / epsilon:.5f}')
epsilon = 0.10000 -> 25.11000
epsilon = 0.00100 -> 8.00270
epsilon = 0.00010 -> 8.00012
epsilon = 0.00001 -> 8.00001
# Define our function
def L(x):
return x**2 + 1701*(x-4)**3
# Print the difference divided by epsilon for several epsilon
for epsilon in [0.1, 0.001, 0.0001, 0.00001]:
print(f'epsilon = {epsilon:.5f} -> {(L(4+epsilon) - L(4)) / epsilon:.5f}')
epsilon = 0.10000 -> 25.11000
epsilon = 0.00100 -> 8.00270
epsilon = 0.00010 -> 8.00012
epsilon = 0.00001 -> 8.00001
# Define our function
def L(x):
return x**2 + 1701*(x-4)**3
# Print the difference divided by epsilon for several epsilon
for epsilon in [0.1, 0.001, 0.0001, 0.00001]:
print(f'epsilon = {epsilon:.5f} -> {(L(4+epsilon) - L(4)) / epsilon:.5f}')
epsilon = 0.10000 -> 25.11000
epsilon = 0.00100 -> 8.00270
epsilon = 0.00010 -> 8.00012
epsilon = 0.00001 -> 8.00001
现在,如果我们仔细观察,会发现这个数字的输出非常接近\(8\)。事实上,如果我们减小\(\epsilon\),我们会看到值越来越接近\(8\)。因此我们可以得出结论,我们所寻求的值(输入的变化引起输出变化的程度)在点\(x=4\)处应该是\(8\)。数学家编码这一事实的方式是
稍微离题谈点历史:在神经网络研究的最初几十年里,科学家们使用这种算法(*有限差分法*)来评估损失函数在微小扰动下的变化:只需改变权重,看看损失如何变化。这种方法计算效率低下,需要两次评估损失函数才能看到单个变量的变化如何影响损失。如果我们试图用这种方法处理哪怕只有区区几千个参数的网络,也需要在整个数据集上进行数千次网络评估!直到1986年,在Rumelhart等人(1988)中引入的*反向传播算法*才解决了这个问题,它提供了一种方法,可以在与网络在数据集上进行一次预测相同的计算时间内,计算出*任何*权重的共同变化将如何改变损失。
回到我们的例子,这个值\(8\)对于不同的\(x\)值是不同的,所以将它定义为\(x\)的函数是有意义的。更正式地说,这个依赖于值的变化率被称为*导数*,写作
不同的教科书会使用不同的导数记号。例如,下面所有的记号都表示同样的事情
大多数作者会选择一种记号并坚持使用,但即使这样也不能保证。最好熟悉所有这些记号。在本文中,我们将使用记号\(\frac{df}{dx}\),除非我们想对一个复杂的表达式求导,在这种情况下,我们将使用\(\frac{d}{dx}f\)来写出像下面这样的表达式
通常,直观地展开导数的定义(22.3.3)来理解当我们对\(x\)做一个微小改变时函数如何变化是很有用的
最后一个方程值得特别指出。它告诉我们,如果你对任何函数做一个微小的输入改变,输出的变化量将是这个微小改变量乘以导数。
通过这种方式,我们可以将导数理解为一个比例因子,它告诉我们从输入的变化中可以得到多大的输出变化。
22.3.2. 微积分法则¶
我们现在转向理解如何计算一个显式函数的导数。一个完全正式的微积分处理会从第一性原理推导一切。我们在这里不会沉溺于这种诱惑,而是提供对常见法则的理解。
22.3.2.1. 常见导数¶
正如在 第 2.4 节中所见,计算导数时通常可以利用一系列法则将计算简化为几个核心函数。我们在这里重复它们以便参考。
常数导数。 \(\frac{d}{dx}c = 0\)。
线性函数导数。 \(\frac{d}{dx}(ax) = a\)。
幂法则。 \(\frac{d}{dx}x^n = nx^{n-1}\)。
指数函数导数。 \(\frac{d}{dx}e^x = e^x\)。
对数函数导数。 \(\frac{d}{dx}\log(x) = \frac{1}{x}\)。
22.3.2.2. 求导法则¶
如果每个导数都需要单独计算并存储在表格中,那么微分学将几乎是不可能的。数学的馈赠在于,我们可以推广上述导数,并计算更复杂的导数,例如求\(f(x) = \log\left(1+(x-1)^{10}\right)\)的导数。正如在 第 2.4 节中提到的,关键在于将函数以各种方式(最重要的是:求和、乘积和复合)组合时发生的情况进行编码。
加法法则。 \(\frac{d}{dx}\left(g(x) + h(x)\right) = \frac{dg}{dx}(x) + \frac{dh}{dx}(x)\)。
乘法法则。 \(\frac{d}{dx}\left(g(x)\cdot h(x)\right) = g(x)\frac{dh}{dx}(x) + \frac{dg}{dx}(x)h(x)\)。
链式法则。 \(\frac{d}{dx}g(h(x)) = \frac{dg}{dh}(h(x))\cdot \frac{dh}{dx}(x)\)。
让我们看看如何使用 (22.3.6)来理解这些法则。对于加法法则,考虑以下推理链:
通过将这个结果与\(f(x+\epsilon) \approx f(x) + \epsilon \frac{df}{dx}(x)\)这一事实进行比较,我们看到\(\frac{df}{dx}(x) = \frac{dg}{dx}(x) + \frac{dh}{dx}(x)\),正如所期望的。这里的直觉是:当我们改变输入\(x\)时,\(g\)和\(h\)共同对输出的变化贡献了\(\frac{dg}{dx}(x)\)和\(\frac{dh}{dx}(x)\)。
乘积法则更为微妙,需要一个新的观察来处理这些表达式。我们将像以前一样使用 (22.3.6) 开始:
这类似于上面的计算,我们确实看到了我们的答案(\(\frac{df}{dx}(x) = g(x)\frac{dh}{dx}(x) + \frac{dg}{dx}(x)h(x)\))紧挨着\(\epsilon\),但存在那个大小为\(\epsilon^{2}\)的项的问题。我们称之为*高阶项*,因为\(\epsilon^2\)的幂高于\(\epsilon^1\)的幂。在后面的章节中,我们将看到我们有时需要跟踪这些项,但现在请注意,如果\(\epsilon = 0.0000001\),那么\(\epsilon^{2}= 0.0000000000001\),这要小得多。当我们让\(\epsilon \rightarrow 0\)时,我们可以安全地忽略高阶项。在本附录中,作为一个通用约定,我们将使用“\(\approx\)”来表示两个项在忽略高阶项的情况下是相等的。然而,如果我们希望更正式,我们可以检查差商:
可以看到当\(\epsilon \rightarrow 0\)时,右边的项也趋于零。
最后,对于链式法则,我们可以再次像之前一样使用 (22.3.6),然后看到
在第二行中,我们将函数\(g\)视为其输入(\(h(x)\))被一个微小的量\(\epsilon \frac{dh}{dx}(x)\)所移动。
这些法则为我们提供了一套灵活的工具,可以计算几乎任何想要的表达式。例如,
其中每一行都使用了以下规则
链式法则和对数函数的导数。
加法法则。
常数导数、链式法则和幂法则。
加法法则、线性函数导数、常数导数。
做完这个例子后,有两件事应该很清楚了:
任何我们可以用求和、乘积、常数、幂、指数和对数写出的函数,都可以通过遵循这些规则机械地计算出其导数。
让一个人来遵循这些规则可能会很繁琐且容易出错!
值得庆幸的是,这两个事实共同暗示了一条前进的道路:这是一个完美的机械化候选者!事实上,我们将在本节稍后重新讨论的反向传播正是如此。
22.3.2.3. 线性近似¶
在处理导数时,几何地解释上面使用的近似通常很有用。特别地,请注意方程
通过一条经过点\((x, f(x))\)且斜率为\(\frac{df}{dx}(x)\)的直线来近似\(f\)的值。通过这种方式,我们说导数给出了函数\(f\)的线性近似,如下图所示:
# Compute sin
xs = torch.arange(-torch.pi, torch.pi, 0.01)
plots = [torch.sin(xs)]
# Compute some linear approximations. Use d(sin(x))/dx = cos(x)
for x0 in [-1.5, 0.0, 2.0]:
plots.append(torch.sin(torch.tensor(x0)) + (xs - x0) *
torch.cos(torch.tensor(x0)))
d2l.plot(xs, plots, 'x', 'f(x)', ylim=[-1.5, 1.5])
# Compute sin
xs = np.arange(-np.pi, np.pi, 0.01)
plots = [np.sin(xs)]
# Compute some linear approximations. Use d(sin(x)) / dx = cos(x)
for x0 in [-1.5, 0, 2]:
plots.append(np.sin(x0) + (xs - x0) * np.cos(x0))
d2l.plot(xs, plots, 'x', 'f(x)', ylim=[-1.5, 1.5])
# Compute sin
xs = tf.range(-tf.pi, tf.pi, 0.01)
plots = [tf.sin(xs)]
# Compute some linear approximations. Use d(sin(x))/dx = cos(x)
for x0 in [-1.5, 0.0, 2.0]:
plots.append(tf.sin(tf.constant(x0)) + (xs - x0) *
tf.cos(tf.constant(x0)))
d2l.plot(xs, plots, 'x', 'f(x)', ylim=[-1.5, 1.5])
22.3.2.4. 高阶导数¶
现在让我们做一件表面上看起来可能很奇怪的事情。取一个函数\(f\)并计算其导数\(\frac{df}{dx}\)。这给了我们\(f\)在任何点的变化率。
然而,导数\(\frac{df}{dx}\)本身也可以看作一个函数,所以没有什么能阻止我们计算\(\frac{df}{dx}\)的导数来得到\(\frac{d^2f}{dx^2} = \frac{df}{dx}\left(\frac{df}{dx}\right)\)。我们称之为\(f\)的二阶导数。这个函数是\(f\)的变化率的变化率,换句话说,就是变化率是如何变化的。我们可以任意次地应用导数来获得所谓的\(n\)阶导数。为了保持记号简洁,我们将\(n\)阶导数表示为
让我们试着理解*为什么*这是一个有用的概念。下面,我们可视化了\(f^{(2)}(x)\), \(f^{(1)}(x)\), 和\(f(x)\)。
首先,考虑二阶导数\(f^{(2)}(x)\)是正常数的情况。这意味着一阶导数的斜率为正。因此,一阶导数\(f^{(1)}(x)\)可能从负值开始,在某一点变为零,然后最终变为正值。这告诉我们原始函数\(f\)的斜率,因此,函数\(f\)本身是先减小,然后变平,再增加。换句话说,函数\(f\)向上弯曲,并有一个单一的最小值,如 图 22.3.1所示。
图 22.3.1 如果我们假设二阶导数是正常数,那么一阶导数是增加的,这意味着函数本身有一个最小值。¶
其次,如果二阶导数是负常数,这意味着一阶导数是递减的。这意味着一阶导数可能从正值开始,在某一点变为零,然后变为负值。因此,函数\(f\)本身先增加,然后变平,再减少。换句话说,函数\(f\)向下弯曲,并有一个单一的最大值,如 图 22.3.2所示。
图 22.3.2 如果我们假设二阶导数是负常数,那么一阶导数是递减的,这意味着函数本身有一个最大值。¶
第三,如果二阶导数始终为零,那么一阶导数将永远不会改变——它是一个常数!这意味着\(f\)以固定的速率增加(或减少),并且\(f\)本身是一条直线,如 图 22.3.3所示。
图 22.3.3 如果我们假设二阶导数为零,那么一阶导数是常数,这意味着函数本身是一条直线。¶
总而言之,二阶导数可以被解释为描述函数\(f\)弯曲的方式。正的二阶导数导致向上弯曲,而负的二阶导数意味着\(f\)向下弯曲,零二阶导数意味着\(f\)根本不弯曲。
让我们更进一步。考虑函数\(g(x) = ax^{2}+ bx + c\)。我们可以计算出:
如果我们心中有一个原始函数\(f(x)\),我们可以计算前两个导数,并找到\(a, b\)和\(c\)的值,使它们与这个计算相匹配。与上一节我们看到一阶导数给出了用直线进行最佳近似类似,这种构造提供了用二次曲线进行最佳近似。让我们为\(f(x) = \sin(x)\)可视化这一点。
# Compute sin
xs = torch.arange(-torch.pi, torch.pi, 0.01)
plots = [torch.sin(xs)]
# Compute some quadratic approximations. Use d(sin(x)) / dx = cos(x)
for x0 in [-1.5, 0.0, 2.0]:
plots.append(torch.sin(torch.tensor(x0)) + (xs - x0) *
torch.cos(torch.tensor(x0)) - (xs - x0)**2 *
torch.sin(torch.tensor(x0)) / 2)
d2l.plot(xs, plots, 'x', 'f(x)', ylim=[-1.5, 1.5])
# Compute sin
xs = np.arange(-np.pi, np.pi, 0.01)
plots = [np.sin(xs)]
# Compute some quadratic approximations. Use d(sin(x)) / dx = cos(x)
for x0 in [-1.5, 0, 2]:
plots.append(np.sin(x0) + (xs - x0) * np.cos(x0) -
(xs - x0)**2 * np.sin(x0) / 2)
d2l.plot(xs, plots, 'x', 'f(x)', ylim=[-1.5, 1.5])
# Compute sin
xs = tf.range(-tf.pi, tf.pi, 0.01)
plots = [tf.sin(xs)]
# Compute some quadratic approximations. Use d(sin(x)) / dx = cos(x)
for x0 in [-1.5, 0.0, 2.0]:
plots.append(tf.sin(tf.constant(x0)) + (xs - x0) *
tf.cos(tf.constant(x0)) - (xs - x0)**2 *
tf.sin(tf.constant(x0)) / 2)
d2l.plot(xs, plots, 'x', 'f(x)', ylim=[-1.5, 1.5])
我们将在下一节中将这个想法扩展到*泰勒级数*的概念。
22.3.2.5. 泰勒级数¶
泰勒级数提供了一种方法,如果我们给定函数在点\(x_0\)处的前\(n\)个导数的值,即\(\left\{ f(x_0), f^{(1)}(x_0), f^{(2)}(x_0), \ldots, f^{(n)}(x_0) \right\}\),就可以近似函数\(f(x)\)。其思想是找到一个\(n\)次多项式,使其在\(x_0\)处的所有给定导数都匹配。
我们在上一节看到了\(n=2\)的情况,经过一些代数运算可以得到
正如我们在上面看到的,分母中的\(2\)是为了抵消我们对\(x^2\)求两次导数时得到的\(2\),而其他项都为零。同样的逻辑适用于一阶导数和函数值本身。
如果我们将逻辑进一步推到\(n=3\),我们会得出结论
其中\(6 = 3 \times 2 = 3!\)来自于我们对\(x^3\)求三次导数时得到的常数。
此外,我们可以通过以下方式得到一个\(n\)次多项式
其中记号为
实际上,\(P_n(x)\)可以被看作是我们的函数\(f(x)\)的最好的\(n\)次多项式近似。
虽然我们不会深入探讨上述近似的误差,但值得一提的是无限极限。在这种情况下,对于行为良好(称为实解析函数)的函数,如\(\cos(x)\)或\(e^{x}\),我们可以写出无限项并精确地近似同一个函数
以\(f(x) = e^{x}\)为例。由于\(e^{x}\)的导数是其本身,我们知道\(f^{(n)}(x) = e^{x}\)。因此,\(e^{x}\)可以通过在\(x_0 = 0\)处取泰勒级数来重构,即
让我们在代码中看看这是如何工作的,并观察增加泰勒近似的阶数如何使我们更接近期望的函数\(e^x\)。
# Compute the exponential function
xs = torch.arange(0, 3, 0.01)
ys = torch.exp(xs)
# Compute a few Taylor series approximations
P1 = 1 + xs
P2 = 1 + xs + xs**2 / 2
P5 = 1 + xs + xs**2 / 2 + xs**3 / 6 + xs**4 / 24 + xs**5 / 120
d2l.plot(xs, [ys, P1, P2, P5], 'x', 'f(x)', legend=[
"Exponential", "Degree 1 Taylor Series", "Degree 2 Taylor Series",
"Degree 5 Taylor Series"])
# Compute the exponential function
xs = np.arange(0, 3, 0.01)
ys = np.exp(xs)
# Compute a few Taylor series approximations
P1 = 1 + xs
P2 = 1 + xs + xs**2 / 2
P5 = 1 + xs + xs**2 / 2 + xs**3 / 6 + xs**4 / 24 + xs**5 / 120
d2l.plot(xs, [ys, P1, P2, P5], 'x', 'f(x)', legend=[
"Exponential", "Degree 1 Taylor Series", "Degree 2 Taylor Series",
"Degree 5 Taylor Series"])
# Compute the exponential function
xs = tf.range(0, 3, 0.01)
ys = tf.exp(xs)
# Compute a few Taylor series approximations
P1 = 1 + xs
P2 = 1 + xs + xs**2 / 2
P5 = 1 + xs + xs**2 / 2 + xs**3 / 6 + xs**4 / 24 + xs**5 / 120
d2l.plot(xs, [ys, P1, P2, P5], 'x', 'f(x)', legend=[
"Exponential", "Degree 1 Taylor Series", "Degree 2 Taylor Series",
"Degree 5 Taylor Series"])
泰勒级数有两个主要应用
理论应用:当我们试图理解一个过于复杂的函数时,使用泰勒级数可以将其转化为我们可以直接处理的多项式。
数值应用:像\(e^{x}\)或\(\cos(x)\)这样的函数对于机器来说很难计算。它们可以存储固定精度的值表(这通常也是这么做的),但这仍然留下了诸如“\(\cos(1)\)的第1000位小数是多少?”这样的问题。泰勒级数通常有助于回答这类问题。
22.3.3. 总结¶
导数可以用来表示当我们对输入进行微小改变时函数如何变化。
基本导数可以通过求导法则组合起来,以创建任意复杂的导数。
导数可以迭代以获得二阶或更高阶的导数。每增加一阶,就能提供关于函数行为更精细的信息。
利用单个数据样本的导数信息,我们可以通过泰勒级数得到的多项式来近似行为良好的函数。