12.3. 梯度下降
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

在本节中,我们将介绍*梯度下降*(gradient descent)的基本概念。尽管梯度下降在深度学习中很少被直接使用,但对它的理解是理解随机梯度下降算法的关键。例如,由于学习率过大,优化问题可能会发散,这种现象在梯度下降中已经可以看到。同样,预处理(preconditioning)是梯度下降中的一种常用技术,并被沿用到更高级的算法中。让我们从一个简单的特例开始。

12.3.1. 一维梯度下降

一维梯度下降是一个很好的例子,可以解释为什么梯度下降算法可以降低目标函数的值。考虑某个连续可微的实值函数 \(f: \mathbb{R} \rightarrow \mathbb{R}\)。使用泰勒展开,我们得到

(12.3.1)\[f(x + \epsilon) = f(x) + \epsilon f'(x) + \mathcal{O}(\epsilon^2).\]

也就是说,在一阶近似中,\(f(x+\epsilon)\) 由函数值 \(f(x)\) 和在 \(x\) 处的一阶导数 \(f'(x)\) 给出。可以不失一般性地假设,对于小的 \(\epsilon\),沿负梯度方向移动会减小 \(f\)。为简单起见,我们选择一个固定的步长 \(\eta > 0\),并选择 \(\epsilon = -\eta f'(x)\)。将其代入上面的泰勒展开式,我们得到

(12.3.2)\[f(x - \eta f'(x)) = f(x) - \eta f'^2(x) + \mathcal{O}(\eta^2 f'^2(x)).\]

如果导数 \(f'(x) \neq 0\) 不为零,我们就能取得进展,因为 \(\eta f'^2(x)>0\)。此外,我们总是可以选择足够小的 \(\eta\) 使得高阶项变得无关紧要。因此我们得到

(12.3.3)\[f(x - \eta f'(x)) \lessapprox f(x).\]

这意味着,如果我们使用

(12.3.4)\[x \leftarrow x - \eta f'(x)\]

来迭代 \(x\),函数 \(f(x)\) 的值可能会下降。因此,在梯度下降中,我们首先选择一个初始值 \(x\) 和一个常数 \(\eta > 0\),然后用它们连续迭代 \(x\) 直到达到停止条件,例如,当梯度 \(|f'(x)|\) 的大小足够小或迭代次数达到某个值时。

为简单起见,我们选择目标函数 \(f(x)=x^2\) 来展示如何实现梯度下降。虽然我们知道 \(x=0\) 是最小化 \(f(x)\) 的解,我们仍然使用这个简单的函数来观察 \(x\) 是如何变化的。

%matplotlib inline
import numpy as np
import torch
from d2l import torch as d2l

def f(x):  # Objective function
    return x ** 2

def f_grad(x):  # Gradient (derivative) of the objective function
    return 2 * x
%matplotlib inline
from mxnet import np, npx
from d2l import mxnet as d2l

npx.set_np()

def f(x):  # Objective function
    return x ** 2

def f_grad(x):  # Gradient (derivative) of the objective function
    return 2 * x
%matplotlib inline
import numpy as np
import tensorflow as tf
from d2l import tensorflow as d2l

def f(x):  # Objective function
    return x ** 2

def f_grad(x):  # Gradient (derivative) of the objective function
    return 2 * x

接下来,我们使用 \(x=10\) 作为初始值,并假设 \(\eta=0.2\)。使用梯度下降迭代 \(x\) 10次,我们可以看到,最终 \(x\) 的值接近最优解。

def gd(eta, f_grad):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x)
        results.append(float(x))
    print(f'epoch 10, x: {x:f}')
    return results

results = gd(0.2, f_grad)
epoch 10, x: 0.060466
def gd(eta, f_grad):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x)
        results.append(float(x))
    print(f'epoch 10, x: {x:f}')
    return results

results = gd(0.2, f_grad)
epoch 10, x: 0.060466
def gd(eta, f_grad):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x)
        results.append(float(x))
    print(f'epoch 10, x: {x:f}')
    return results

results = gd(0.2, f_grad)
epoch 10, x: 0.060466

\(x\) 进行优化的过程可以绘制如下。

def show_trace(results, f):
    n = max(abs(min(results)), abs(max(results)))
    f_line = torch.arange(-n, n, 0.01)
    d2l.set_figsize()
    d2l.plot([f_line, results], [[f(x) for x in f_line], [
        f(x) for x in results]], 'x', 'f(x)', fmts=['-', '-o'])

show_trace(results, f)
../_images/output_gd_79c039_27_0.svg
def show_trace(results, f):
    n = max(abs(min(results)), abs(max(results)))
    f_line = np.arange(-n, n, 0.01)
    d2l.set_figsize()
    d2l.plot([f_line, results], [[f(x) for x in f_line], [
        f(x) for x in results]], 'x', 'f(x)', fmts=['-', '-o'])

show_trace(results, f)
[22:06:08] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
../_images/output_gd_79c039_30_1.svg
def show_trace(results, f):
    n = max(abs(min(results)), abs(max(results)))
    f_line = tf.range(-n, n, 0.01)
    d2l.set_figsize()
    d2l.plot([f_line, results], [[f(x) for x in f_line], [
        f(x) for x in results]], 'x', 'f(x)', fmts=['-', '-o'])

show_trace(results, f)
../_images/output_gd_79c039_33_0.svg

12.3.1.1. 学习率

学习率 \(\eta\) 可以由算法设计者设置。如果我们使用过小的学习率,会导致 \(x\) 更新非常缓慢,需要更多次迭代才能得到更好的解。为了展示这种情况会发生什么,考虑在相同的优化问题中,当 \(\eta = 0.05\) 时的进展。正如我们所看到的,即使经过10步,我们仍然离最优解很远。

show_trace(gd(0.05, f_grad), f)
epoch 10, x: 3.486784
../_images/output_gd_79c039_39_1.svg
show_trace(gd(0.05, f_grad), f)
epoch 10, x: 3.486784
../_images/output_gd_79c039_42_1.svg
show_trace(gd(0.05, f_grad), f)
epoch 10, x: 3.486784
../_images/output_gd_79c039_45_1.svg

相反,如果我们使用过高的学习率,\(\left|\eta f'(x)\right|\) 对于一阶泰勒展开公式来说可能太大了。也就是说,在 (12.3.2) 中的 \(\mathcal{O}(\eta^2 f'^2(x))\) 项可能变得很重要。在这种情况下,我们不能保证 \(x\) 的迭代能够降低 \(f(x)\) 的值。例如,当我们把学习率设为 \(\eta=1.1\) 时,\(x\) 会越过最优解 \(x=0\) 并逐渐发散。

show_trace(gd(1.1, f_grad), f)
epoch 10, x: 61.917364
../_images/output_gd_79c039_51_1.svg
show_trace(gd(1.1, f_grad), f)
epoch 10, x: 61.917364
../_images/output_gd_79c039_54_1.svg
show_trace(gd(1.1, f_grad), f)
epoch 10, x: 61.917364
../_images/output_gd_79c039_57_1.svg

12.3.1.2. 局部最小值

为了说明非凸函数会发生什么,考虑 \(f(x) = x \cdot \cos(cx)\) 的情况,其中 \(c\) 为某个常数。这个函数有无穷多个局部最小值。根据我们选择的学习率以及问题的条件如何,我们可能最终会得到许多解中的一个。下面的例子说明了(不切实际的)高学习率将如何导致一个差的局部最小值。

c = torch.tensor(0.15 * np.pi)

def f(x):  # Objective function
    return x * torch.cos(c * x)

def f_grad(x):  # Gradient of the objective function
    return torch.cos(c * x) - c * x * torch.sin(c * x)

show_trace(gd(2, f_grad), f)
epoch 10, x: -1.528166
../_images/output_gd_79c039_63_1.svg
c = np.array(0.15 * np.pi)

def f(x):  # Objective function
    return x * np.cos(c * x)

def f_grad(x):  # Gradient of the objective function
    return np.cos(c * x) - c * x * np.sin(c * x)

show_trace(gd(2, f_grad), f)
epoch 10, x: -1.528165
../_images/output_gd_79c039_66_1.svg
c = tf.constant(0.15 * np.pi)

def f(x):  # Objective function
    return x * tf.cos(c * x)

def f_grad(x):  # Gradient of the objective function
    return tf.cos(c * x) - c * x * tf.sin(c * x)

show_trace(gd(2, f_grad), f)
epoch 10, x: -1.528165
../_images/output_gd_79c039_69_1.svg

12.3.2. 多变量梯度下降

现在我们对单变量情况有了更好的直觉,让我们考虑 \(\mathbf{x} = [x_1, x_2, \ldots, x_d]^\top\) 的情况。也就是说,目标函数 \(f: \mathbb{R}^d \to \mathbb{R}\) 将向量映射为标量。相应地,它的梯度也是多变量的。它是一个由 \(d\) 个偏导数组成的向量:

(12.3.5)\[\nabla f(\mathbf{x}) = \bigg[\frac{\partial f(\mathbf{x})}{\partial x_1}, \frac{\partial f(\mathbf{x})}{\partial x_2}, \ldots, \frac{\partial f(\mathbf{x})}{\partial x_d}\bigg]^\top.\]

梯度中的每个偏导数元素 \(\partial f(\mathbf{x})/\partial x_i\) 表示在 \(\mathbf{x}\)\(f\) 关于输入 \(x_i\) 的变化率。和之前在单变量情况中一样,我们可以使用多变量函数的相应泰勒展开来得到我们应该做什么的一些想法。特别地,我们有

(12.3.6)\[f(\mathbf{x} + \boldsymbol{\epsilon}) = f(\mathbf{x}) + \mathbf{\boldsymbol{\epsilon}}^\top \nabla f(\mathbf{x}) + \mathcal{O}(\|\boldsymbol{\epsilon}\|^2).\]

换句话说,在 \(\boldsymbol{\epsilon}\) 的二阶项之前,最陡下降方向由负梯度 \(-\nabla f(\mathbf{x})\) 给出。选择一个合适的学习率 \(\eta > 0\) 可以得到典型的梯度下降算法

(12.3.7)\[\mathbf{x} \leftarrow \mathbf{x} - \eta \nabla f(\mathbf{x}).\]

为了看看该算法在实践中的表现,我们构造一个目标函数 \(f(\mathbf{x})=x_1^2+2x_2^2\),输入是一个二维向量 \(\mathbf{x} = [x_1, x_2]^\top\),输出是一个标量。梯度由 \(\nabla f(\mathbf{x}) = [2x_1, 4x_2]^\top\) 给出。我们将观察 \(\mathbf{x}\) 从初始位置 \([-5, -2]\) 开始,通过梯度下降的轨迹。

首先,我们需要两个辅助函数。第一个使用一个更新函数并将其应用于初始值20次。第二个辅助函数可视化 \(\mathbf{x}\) 的轨迹。

def train_2d(trainer, steps=20, f_grad=None):  #@save
    """Optimize a 2D objective function with a customized trainer."""
    # `s1` and `s2` are internal state variables that will be used in Momentum, adagrad, RMSProp
    x1, x2, s1, s2 = -5, -2, 0, 0
    results = [(x1, x2)]
    for i in range(steps):
        if f_grad:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2, f_grad)
        else:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2)
        results.append((x1, x2))
    print(f'epoch {i + 1}, x1: {float(x1):f}, x2: {float(x2):f}')
    return results

def show_trace_2d(f, results):  #@save
    """Show the trace of 2D variables during optimization."""
    d2l.set_figsize()
    d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
    x1, x2 = torch.meshgrid(torch.arange(-5.5, 1.0, 0.1),
                          torch.arange(-3.0, 1.0, 0.1), indexing='ij')
    d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
    d2l.plt.xlabel('x1')
    d2l.plt.ylabel('x2')
def train_2d(trainer, steps=20, f_grad=None):  #@save
    """Optimize a 2D objective function with a customized trainer."""
    # `s1` and `s2` are internal state variables that will be used in Momentum, adagrad, RMSProp
    x1, x2, s1, s2 = -5, -2, 0, 0
    results = [(x1, x2)]
    for i in range(steps):
        if f_grad:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2, f_grad)
        else:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2)
        results.append((x1, x2))
    print(f'epoch {i + 1}, x1: {float(x1):f}, x2: {float(x2):f}')
    return results

def show_trace_2d(f, results):  #@save
    """Show the trace of 2D variables during optimization."""
    d2l.set_figsize()
    d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
    x1, x2 = np.meshgrid(np.arange(-55, 1, 1),
                          np.arange(-30, 1, 1))
    x1, x2 = x1.asnumpy()*0.1, x2.asnumpy()*0.1
    d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
    d2l.plt.xlabel('x1')
    d2l.plt.ylabel('x2')
def train_2d(trainer, steps=20, f_grad=None):  #@save
    """Optimize a 2D objective function with a customized trainer."""
    # `s1` and `s2` are internal state variables that will be used in Momentum, adagrad, RMSProp
    x1, x2, s1, s2 = -5, -2, 0, 0
    results = [(x1, x2)]
    for i in range(steps):
        if f_grad:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2, f_grad)
        else:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2)
        results.append((x1, x2))
    print(f'epoch {i + 1}, x1: {float(x1):f}, x2: {float(x2):f}')
    return results

def show_trace_2d(f, results):  #@save
    """Show the trace of 2D variables during optimization."""
    d2l.set_figsize()
    d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
    x1, x2 = tf.meshgrid(tf.range(-5.5, 1.0, 0.1),
                          tf.range(-3.0, 1.0, 0.1))
    d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
    d2l.plt.xlabel('x1')
    d2l.plt.ylabel('x2')

接下来,我们观察学习率为 \(\eta = 0.1\) 时优化变量 \(\mathbf{x}\) 的轨迹。我们可以看到,经过20步后,\(\mathbf{x}\) 的值接近其在 \([0, 0]\) 的最小值。进展相当平稳,尽管相当缓慢。

def f_2d(x1, x2):  # Objective function
    return x1 ** 2 + 2 * x2 ** 2

def f_2d_grad(x1, x2):  # Gradient of the objective function
    return (2 * x1, 4 * x2)

def gd_2d(x1, x2, s1, s2, f_grad):
    g1, g2 = f_grad(x1, x2)
    return (x1 - eta * g1, x2 - eta * g2, 0, 0)

eta = 0.1
show_trace_2d(f_2d, train_2d(gd_2d, f_grad=f_2d_grad))
epoch 20, x1: -0.057646, x2: -0.000073
../_images/output_gd_79c039_87_1.svg
def f_2d(x1, x2):  # Objective function
    return x1 ** 2 + 2 * x2 ** 2

def f_2d_grad(x1, x2):  # Gradient of the objective function
    return (2 * x1, 4 * x2)

def gd_2d(x1, x2, s1, s2, f_grad):
    g1, g2 = f_grad(x1, x2)
    return (x1 - eta * g1, x2 - eta * g2, 0, 0)

eta = 0.1
show_trace_2d(f_2d, train_2d(gd_2d, f_grad=f_2d_grad))
epoch 20, x1: -0.057646, x2: -0.000073
../_images/output_gd_79c039_90_1.svg
def f_2d(x1, x2):  # Objective function
    return x1 ** 2 + 2 * x2 ** 2

def f_2d_grad(x1, x2):  # Gradient of the objective function
    return (2 * x1, 4 * x2)

def gd_2d(x1, x2, s1, s2, f_grad):
    g1, g2 = f_grad(x1, x2)
    return (x1 - eta * g1, x2 - eta * g2, 0, 0)

eta = 0.1
show_trace_2d(f_2d, train_2d(gd_2d, f_grad=f_2d_grad))
epoch 20, x1: -0.057646, x2: -0.000073
../_images/output_gd_79c039_93_1.svg

12.3.3. 自适应方法

正如我们在 12.3.1.1节 中看到的那样,将学习率 \(\eta\) 设得“恰到好处”是很棘手的。如果我们选得太小,进展会很慢。如果我们选得太大,解会振荡,最坏的情况下甚至可能发散。如果我们能自动确定 \(\eta\),或者完全摆脱选择学习率的麻烦呢?二阶方法不仅着眼于目标函数的值和梯度,还着眼于其*曲率*,这在这种情况下可以提供帮助。虽然由于计算成本的原因,这些方法不能直接应用于深度学习,但它们为如何设计高级优化算法提供了有用的直觉,这些算法模仿了下面概述的算法的许多理想属性。

12.3.3.1. 牛顿法

回顾某个函数 \(f: \mathbb{R}^d \rightarrow \mathbb{R}\) 的泰勒展开,没有必要在第一项之后停止。事实上,我们可以将其写为

(12.3.8)\[f(\mathbf{x} + \boldsymbol{\epsilon}) = f(\mathbf{x}) + \boldsymbol{\epsilon}^\top \nabla f(\mathbf{x}) + \frac{1}{2} \boldsymbol{\epsilon}^\top \nabla^2 f(\mathbf{x}) \boldsymbol{\epsilon} + \mathcal{O}(\|\boldsymbol{\epsilon}\|^3).\]

为了避免繁琐的记法,我们定义 \(\mathbf{H} \stackrel{\textrm{def}}{=} \nabla^2 f(\mathbf{x})\)\(f\) 的Hessian矩阵,它是一个 \(d \times d\) 矩阵。对于小的 \(d\) 和简单的问题,\(\mathbf{H}\) 很容易计算。然而,对于深度神经网络,\(\mathbf{H}\) 可能会非常大,因为存储 \(\mathcal{O}(d^2)\) 个条目的成本很高。此外,通过反向传播计算它可能太昂贵了。现在,让我们暂时忽略这些考虑,看看我们会得到什么样的算法。

毕竟,\(f\) 的最小值满足 \(\nabla f = 0\)。根据 2.4.3节 中的微积分规则,对 (12.3.8) 关于 \(\boldsymbol{\epsilon}\) 求导并忽略高阶项,我们得到

(12.3.9)\[\nabla f(\mathbf{x}) + \mathbf{H} \boldsymbol{\epsilon} = 0 \textrm{ 因此 } \boldsymbol{\epsilon} = -\mathbf{H}^{-1} \nabla f(\mathbf{x}).\]

也就是说,我们需要在优化问题中对Hessian矩阵 \(\mathbf{H}\) 求逆。

举一个简单的例子,对于 \(f(x) = \frac{1}{2} x^2\),我们有 \(\nabla f(x) = x\)\(\mathbf{H} = 1\)。因此对于任何 \(x\),我们得到 \(\epsilon = -x\)。换句话说,*一步*就足以完美收敛,无需任何调整!可惜,我们在这里有点幸运:泰勒展开是精确的,因为 \(f(x+\epsilon)= \frac{1}{2} x^2 + \epsilon x + \frac{1}{2} \epsilon^2\)

让我们看看在其他问题中会发生什么。给定一个凸双曲余弦函数 \(f(x) = \cosh(cx)\),其中 \(c\) 是某个常数,我们可以看到在几次迭代后,全局最小值在 \(x=0\) 处被达到。

c = torch.tensor(0.5)

def f(x):  # Objective function
    return torch.cosh(c * x)

def f_grad(x):  # Gradient of the objective function
    return c * torch.sinh(c * x)

def f_hess(x):  # Hessian of the objective function
    return c**2 * torch.cosh(c * x)

def newton(eta=1):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x) / f_hess(x)
        results.append(float(x))
    print('epoch 10, x:', x)
    return results

show_trace(newton(), f)
epoch 10, x: tensor(0.)
../_images/output_gd_79c039_99_1.svg
c = np.array(0.5)

def f(x):  # Objective function
    return np.cosh(c * x)

def f_grad(x):  # Gradient of the objective function
    return c * np.sinh(c * x)

def f_hess(x):  # Hessian of the objective function
    return c**2 * np.cosh(c * x)

def newton(eta=1):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x) / f_hess(x)
        results.append(float(x))
    print('epoch 10, x:', x)
    return results

show_trace(newton(), f)
epoch 10, x: 0.0
../_images/output_gd_79c039_102_1.svg
c = tf.constant(0.5)

def f(x):  # Objective function
    return tf.cosh(c * x)

def f_grad(x):  # Gradient of the objective function
    return c * tf.sinh(c * x)

def f_hess(x):  # Hessian of the objective function
    return c**2 * tf.cosh(c * x)

def newton(eta=1):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x) / f_hess(x)
        results.append(float(x))
    print('epoch 10, x:', x)
    return results

show_trace(newton(), f)
epoch 10, x: tf.Tensor(0.0, shape=(), dtype=float32)
../_images/output_gd_79c039_105_1.svg

现在让我们考虑一个*非凸*函数,例如 \(f(x) = x \cos(c x)\),其中 \(c\) 为某个常数。毕竟,请注意,在牛顿法中我们最终要除以Hessian矩阵。这意味着如果二阶导数是*负的*,我们可能会走向*增加* \(f\) 值的方向。这是该算法的一个致命缺陷。让我们看看在实践中会发生什么。

c = torch.tensor(0.15 * np.pi)

def f(x):  # Objective function
    return x * torch.cos(c * x)

def f_grad(x):  # Gradient of the objective function
    return torch.cos(c * x) - c * x * torch.sin(c * x)

def f_hess(x):  # Hessian of the objective function
    return - 2 * c * torch.sin(c * x) - x * c**2 * torch.cos(c * x)

show_trace(newton(), f)
epoch 10, x: tensor(26.8341)
../_images/output_gd_79c039_111_1.svg
c = np.array(0.15 * np.pi)

def f(x):  # Objective function
    return x * np.cos(c * x)

def f_grad(x):  # Gradient of the objective function
    return np.cos(c * x) - c * x * np.sin(c * x)

def f_hess(x):  # Hessian of the objective function
    return - 2 * c * np.sin(c * x) - x * c**2 * np.cos(c * x)

show_trace(newton(), f)
epoch 10, x: 26.834133
../_images/output_gd_79c039_114_1.svg
c = tf.constant(0.15 * np.pi)

def f(x):  # Objective function
    return x * tf.cos(c * x)

def f_grad(x):  # Gradient of the objective function
    return tf.cos(c * x) - c * x * tf.sin(c * x)

def f_hess(x):  # Hessian of the objective function
    return - 2 * c * tf.sin(c * x) - x * c**2 * tf.cos(c * x)

show_trace(newton(), f)
epoch 10, x: tf.Tensor(26.834133, shape=(), dtype=float32)
../_images/output_gd_79c039_117_1.svg

这真是大错特错。我们该如何修复它?一种方法是通过取Hessian矩阵的绝对值来“修复”它。另一个策略是重新引入学习率。这似乎违背了初衷,但又不完全是。拥有二阶信息使我们能够在曲率大时保持谨慎,在目标函数较平坦时采取更长的步长。让我们看看当学习率稍小一些,比如 \(\eta = 0.5\) 时,这是如何工作的。正如我们所看到的,我们有一个相当高效的算法。

show_trace(newton(0.5), f)
epoch 10, x: tensor(7.2699)
../_images/output_gd_79c039_123_1.svg
show_trace(newton(0.5), f)
epoch 10, x: 7.26986
../_images/output_gd_79c039_126_1.svg
show_trace(newton(0.5), f)
epoch 10, x: tf.Tensor(7.26986, shape=(), dtype=float32)
../_images/output_gd_79c039_129_1.svg

12.3.3.2. 收敛性分析

我们只分析牛顿法对于某个凸且三次可微的目标函数 \(f\) 的收敛速度,其中二阶导数非零,即 \(f'' > 0\)。多变量的证明是下面一维论证的直接扩展,由于对我们的直觉帮助不大,因此省略。

\(x^{(k)}\) 表示第 \(k^\textrm{th}\) 次迭代时 \(x\) 的值,并让 \(e^{(k)} \stackrel{\textrm{def}}{=} x^{(k)} - x^*\) 为第 \(k^\textrm{th}\) 次迭代时与最优值的距离。通过泰勒展开,条件 \(f'(x^*) = 0\) 可以写为

(12.3.10)\[0 = f'(x^{(k)} - e^{(k)}) = f'(x^{(k)}) - e^{(k)} f''(x^{(k)}) + \frac{1}{2} (e^{(k)})^2 f'''(\xi^{(k)}),\]

对于某个 \(\xi^{(k)} \in [x^{(k)} - e^{(k)}, x^{(k)}]\) 成立。将上述展开式除以 \(f''(x^{(k)})\) 得到

(12.3.11)\[e^{(k)} - \frac{f'(x^{(k)})}{f''(x^{(k)})} = \frac{1}{2} (e^{(k)})^2 \frac{f'''(\xi^{(k)})}{f''(x^{(k)})}.\]

回想一下,我们有更新 \(x^{(k+1)} = x^{(k)} - f'(x^{(k)}) / f''(x^{(k)})\)。代入这个更新方程并取两边的绝对值,我们有

(12.3.12)\[\left|e^{(k+1)}\right| = \frac{1}{2}(e^{(k)})^2 \frac{\left|f'''(\xi^{(k)})\right|}{f''(x^{(k)})}.\]

因此,只要我们在一个有界的 \(\left|f'''(\xi^{(k)})\right| / (2f''(x^{(k)})) \leq c\) 区域内,我们就有二次递减的误差

(12.3.13)\[\left|e^{(k+1)}\right| \leq c (e^{(k)})^2.\]

顺便说一句,优化研究人员称之为*线性*收敛,而像 \(\left|e^{(k+1)}\right| \leq \alpha \left|e^{(k)}\right|\) 这样的条件则被称为*常数*收敛率。注意,这个分析有一些需要注意的地方。首先,我们并没有真正的保证何时能达到快速收敛的区域。相反,我们只知道一旦达到它,收敛将会非常快。其次,这个分析要求 \(f\) 在高阶导数上表现良好。这归结为确保 \(f\) 在其值可能如何变化方面没有任何“意外”的属性。

12.3.3.3. 预处理

毫不奇怪,计算和存储完整的Hessian矩阵非常昂贵。因此,寻找替代方案是可取的。一种改进的方法是*预处理*。它避免了计算整个Hessian矩阵,而只计算其*对角线*项。这导致了以下形式的更新算法

(12.3.14)\[\mathbf{x} \leftarrow \mathbf{x} - \eta \textrm{diag}(\mathbf{H})^{-1} \nabla f(\mathbf{x}).\]

虽然这不如完整的牛顿法好,但它仍然比不使用它好得多。要理解为什么这可能是一个好主意,考虑一个情况,其中一个变量表示以毫米为单位的高度,另一个表示以公里为单位的高度。假设两者的自然尺度都是米,我们在参数化上存在严重的不匹配。幸运的是,使用预处理可以消除这一点。实际上,使用梯度下降进行预处理相当于为每个变量(向量 \(\mathbf{x}\) 的坐标)选择一个不同的学习率。我们稍后会看到,预处理推动了随机梯度下降优化算法中的一些创新。

12.3.4. 小结

  • 学习率很重要。太大我们会发散,太小我们则没有进展。

  • 梯度下降可能会陷入局部最小值。

  • 在高维空间中,调整学习率很复杂。

  • 预处理可以帮助进行尺度调整。

  • 牛顿法在凸问题中一旦开始正常工作,速度会快得多。

  • 当用于非凸问题时,要小心使用未经任何调整的牛顿法。

12.3.5. 练习

  1. 尝试使用不同的学习率和目标函数进行梯度下降实验。

  2. 实现在区间 \([a, b]\) 内最小化凸函数的线搜索。

    1. 你是否需要导数来进行二分搜索,即决定是选择 \([a, (a+b)/2]\) 还是 \([(a+b)/2, b]\)

    2. 该算法的收敛速度有多快?

    3. 实现该算法,并将其应用于最小化 \(\log (\exp(x) + \exp(-2x -3))\)

  3. 设计一个定义在 \(\mathbb{R}^2\) 上的目标函数,使得梯度下降极其缓慢。提示:对不同坐标使用不同的尺度。

  4. 使用预处理实现牛顿法的轻量级版本

    1. 使用对角Hessian矩阵作为预处理器。

    2. 使用其绝对值而不是实际(可能有符号)的值。

    3. 将此应用于上述问题。

  5. 将上述算法应用于多个目标函数(凸或非凸)。如果将坐标旋转 \(45\) 度会发生什么?

讨论