12.5. 小批量随机梯度下降
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

到目前为止,我们遇到的梯度学习方法都存在两个极端:在 12.3节 中,我们使用完整数据集来计算梯度并一次性更新参数。相反,在 12.4节 中,我们一次只处理一个训练样本来取得进展。这两种方法都有其缺点。当数据非常相似时,梯度下降的*数据效率*并不高。而随机梯度下降的*计算效率*也不高,因为CPU和GPU无法充分利用向量化的全部能力。这表明,可能存在一种介于两者之间的方法,而事实上,这正是我们在之前讨论的示例中一直使用的方法。

12.5.1. 向量化与缓存

使用小批量的核心决策在于计算效率。这一点在考虑并行化到多个GPU和多个服务器时最容易理解。在这种情况下,我们需要向每个GPU至少发送一张图像。如果每个服务器有8个GPU,共有16个服务器,我们的小批量大小就已经不小于128了。

当涉及到单个GPU甚至CPU时,情况要微妙一些。这些设备有多种类型的内存,通常有多种类型的计算单元,并且它们之间存在不同的带宽限制。例如,一个CPU有少量的寄存器,然后是L1、L2,有时甚至是L3缓存(在不同处理器核心之间共享)。这些缓存的大小和延迟依次增加(同时带宽依次减小)。可以说,处理器能够执行的操作远多于主内存接口所能提供的数据。

首先,一个2GHz的CPU,拥有16个核心和AVX-512向量化,每秒最多可以处理 \(2 \cdot 10^9 \cdot 16 \cdot 32 = 10^{12}\) 字节。GPU的能力轻易超过这个数字100倍。另一方面,一个中端服务器处理器可能没有超过100 GB/s的带宽,即不到维持处理器满负荷所需带宽的十分之一。更糟糕的是,并非所有的内存访问都是平等的:内存接口通常是64位或更宽(例如,在GPU上高达384位),因此读取单个字节会产生访问更宽内存的成本。

其次,首次访问有显著的开销,而顺序访问相对便宜(这通常被称为突发读取)。还有很多其他需要考虑的事情,比如当我们有多个插槽、小芯片和其他结构时的缓存问题。有关更深入的讨论,请参阅这篇维基百科文章

缓解这些限制的方法是使用一个CPU缓存层次结构,这个层次结构的速度足够快,可以为处理器提供数据。这是深度学习中批处理的*主要*驱动力。为了简化问题,考虑矩阵-矩阵乘法,比如 \(\mathbf{A} = \mathbf{B}\mathbf{C}\)。我们有多种计算 \(\mathbf{A}\) 的选项。例如,我们可以尝试以下方法:

  1. 我们可以计算 \(\mathbf{A}_{ij} = \mathbf{B}_{i,:} \mathbf{C}_{:,j}\),也就是说,我们可以通过点积逐元素计算。

  2. 我们可以计算 \(\mathbf{A}_{:,j} = \mathbf{B} \mathbf{C}_{:,j}\),也就是说,我们可以一次计算一列。同样,我们也可以一次计算 \(\mathbf{A}\) 的一行 \(\mathbf{A}_{i,:}\)

  3. 我们可以简单地计算 \(\mathbf{A} = \mathbf{B} \mathbf{C}\)

  4. 我们可以将 \(\mathbf{B}\)\(\mathbf{C}\) 分解成更小的块矩阵,然后一次计算 \(\mathbf{A}\) 的一个块。

如果我们采用第一种方案,每次要计算一个元素 \(\mathbf{A}_{ij}\) 时,都需要将一个行向量和一个列向量复制到CPU中。更糟糕的是,由于矩阵元素是顺序排列的,当我们从内存中读取其中一个向量时,需要访问许多不连续的位置。第二种方案要有利得多。在这种方案中,当我们在 \(\mathbf{B}\) 中遍历时,可以将列向量 \(\mathbf{C}_{:,j}\) 保留在CPU缓存中。这使内存带宽需求减半,相应地访问速度也更快。当然,第三种方案是最理想的。不幸的是,大多数矩阵可能无法完全放入缓存(这正是我们正在讨论的问题)。然而,第四种方案提供了一个实践上有用的替代方案:我们可以将矩阵的块移动到缓存中,并在本地进行乘法。优化的库会为我们处理这些事情。让我们看看这些操作在实践中的效率如何。

除了计算效率之外,Python和深度学习框架本身引入的开销也相当可观。回想一下,每次我们执行一个命令,Python解释器都会向MXNet引擎发送一个命令,引擎需要将其插入计算图并在调度期间处理它。这样的开销可能非常有害。简而言之,强烈建议尽可能使用向量化(和矩阵)。

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

A = torch.zeros(256, 256)
B = torch.randn(256, 256)
C = torch.randn(256, 256)
%matplotlib inline
import time
from mxnet import autograd, gluon, init, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l

npx.set_np()

A = np.zeros((256, 256))
B = np.random.normal(0, 1, (256, 256))
C = np.random.normal(0, 1, (256, 256))
[22:02:54] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
%matplotlib inline
import time
import numpy as np
import tensorflow as tf
from d2l import tensorflow as d2l

A = tf.Variable(tf.zeros((256, 256)))
B = tf.Variable(tf.random.normal([256, 256], 0, 1))
C = tf.Variable(tf.random.normal([256, 256], 0, 1))

由于在本书的其余部分我们会经常对运行时间进行基准测试,让我们定义一个计时器。

class Timer:  #@save
    """Record multiple running times."""
    def __init__(self):
        self.times = []
        self.start()

    def start(self):
        """Start the timer."""
        self.tik = time.time()

    def stop(self):
        """Stop the timer and record the time in a list."""
        self.times.append(time.time() - self.tik)
        return self.times[-1]

    def avg(self):
        """Return the average time."""
        return sum(self.times) / len(self.times)

    def sum(self):
        """Return the sum of time."""
        return sum(self.times)

    def cumsum(self):
        """Return the accumulated time."""
        return np.array(self.times).cumsum().tolist()

timer = Timer()
class Timer:  #@save
    """Record multiple running times."""
    def __init__(self):
        self.times = []
        self.start()

    def start(self):
        """Start the timer."""
        self.tik = time.time()

    def stop(self):
        """Stop the timer and record the time in a list."""
        self.times.append(time.time() - self.tik)
        return self.times[-1]

    def avg(self):
        """Return the average time."""
        return sum(self.times) / len(self.times)

    def sum(self):
        """Return the sum of time."""
        return sum(self.times)

    def cumsum(self):
        """Return the accumulated time."""
        return np.array(self.times).cumsum().tolist()

timer = Timer()
class Timer:  #@save
    """Record multiple running times."""
    def __init__(self):
        self.times = []
        self.start()

    def start(self):
        """Start the timer."""
        self.tik = time.time()

    def stop(self):
        """Stop the timer and record the time in a list."""
        self.times.append(time.time() - self.tik)
        return self.times[-1]

    def avg(self):
        """Return the average time."""
        return sum(self.times) / len(self.times)

    def sum(self):
        """Return the sum of time."""
        return sum(self.times)

    def cumsum(self):
        """Return the accumulated time."""
        return np.array(self.times).cumsum().tolist()

timer = Timer()

逐元素赋值简单地遍历 \(\mathbf{B}\)\(\mathbf{C}\) 的所有行和列,以将值赋给 \(\mathbf{A}\)

# Compute A = BC one element at a time
timer.start()
for i in range(256):
    for j in range(256):
        A[i, j] = torch.dot(B[i, :], C[:, j])
timer.stop()
1.7845737934112549
# Compute A = BC one element at a time
timer.start()
for i in range(256):
    for j in range(256):
        A[i, j] = np.dot(B[i, :], C[:, j])
A.wait_to_read()
timer.stop()
70.24642848968506
# Compute A = BC one element at a time
timer.start()
for i in range(256):
    for j in range(256):
        A[i, j].assign(tf.tensordot(B[i, :], C[:, j], axes=1))
timer.stop()
160.60629391670227

一个更快的策略是执行逐列赋值。

# Compute A = BC one column at a time
timer.start()
for j in range(256):
    A[:, j] = torch.mv(B, C[:, j])
timer.stop()
0.06541275978088379
# Compute A = BC one column at a time
timer.start()
for j in range(256):
    A[:, j] = np.dot(B, C[:, j])
A.wait_to_read()
timer.stop()
0.19795489311218262
timer.start()
for j in range(256):
    A[:, j].assign(tf.tensordot(B, C[:, j], axes=1))
timer.stop()
0.4975430965423584

最后,最有效的方式是在一个块中执行整个操作。注意,当标量乘法和加法被视为独立操作时(实践中会融合),任何两个矩阵 \(\mathbf{B} \in \mathbb{R}^{m \times n}\)\(\mathbf{C} \in \mathbb{R}^{n \times p}\) 的乘法大约需要 \(2mnp\) 次浮点运算。因此,两个 \(256 \times 256\) 矩阵相乘需要 \(0.03\) 亿次浮点运算。让我们看看这些操作的各自速度。

# Compute A = BC in one go
timer.start()
A = torch.mm(B, C)
timer.stop()

gigaflops = [0.03 / i for i in timer.times]
print(f'performance in Gigaflops: element {gigaflops[0]:.3f}, '
      f'column {gigaflops[1]:.3f}, full {gigaflops[2]:.3f}')
performance in Gigaflops: element 0.017, column 0.459, full 51.633
# Compute A = BC in one go
timer.start()
A = np.dot(B, C)
A.wait_to_read()
timer.stop()

gigaflops = [0.03 / i for i in timer.times]
print(f'performance in Gigaflops: element {gigaflops[0]:.3f}, '
      f'column {gigaflops[1]:.3f}, full {gigaflops[2]:.3f}')
performance in Gigaflops: element 0.000, column 0.152, full 3.219
timer.start()
A.assign(tf.tensordot(B, C, axes=1))
timer.stop()

gigaflops = [0.03 / i for i in timer.times]
print(f'performance in Gigaflops: element {gigaflops[0]:.3f}, '
      f'column {gigaflops[1]:.3f}, full {gigaflops[2]:.3f}')
performance in Gigaflops: element 0.000, column 0.060, full 1.151

12.5.2. 小批量

过去我们理所当然地认为,我们会读取*小批量*数据而不是单个观测值来更新参数。现在我们为其提供一个简短的理由。处理单个观测值需要我们执行许多单个矩阵-向量(甚至向量-向量)乘法,这相当昂贵,并且会因底层深度学习框架而产生显著的开销。这既适用于将网络应用于数据时(通常称为推理),也适用于计算梯度以更新参数时。也就是说,这适用于我们执行 \(\mathbf{w} \leftarrow \mathbf{w} - \eta_t \mathbf{g}_t\) 的任何时候,其中

(12.5.1)\[\mathbf{g}_t = \partial_{\mathbf{w}} f(\mathbf{x}_{t}, \mathbf{w})\]

我们可以通过一次对一小批观测值应用此操作来提高此操作的*计算*效率。也就是说,我们将单个观测值的梯度 \(\mathbf{g}_t\) 替换为一小批次的梯度:

(12.5.2)\[\mathbf{g}_t = \partial_{\mathbf{w}} \frac{1}{|\mathcal{B}_t|} \sum_{i \in \mathcal{B}_t} f(\mathbf{x}_{i}, \mathbf{w})\]

让我们看看这对 \(\mathbf{g}_t\) 的统计特性有何影响:由于 \(\mathbf{x}_t\) 和小批量 \(\mathcal{B}_t\) 的所有元素都是从训练集中均匀随机抽取的,因此梯度的期望保持不变。另一方面,方差显著减小。由于小批量梯度由 \(b \stackrel{\textrm{def}}{=} |\mathcal{B}_t|\) 个独立梯度平均而成,其标准差减小了 \(b^{-\frac{1}{2}}\) 倍。这本身是件好事,因为它意味着更新更可靠地与完整梯度对齐。

天真地看,这似乎意味着选择一个大的小批量 \(\mathcal{B}_t\) 总是可取的。可惜的是,在某一点之后,标准差的额外减少与计算成本的线性增加相比就微不足道了。在实践中,我们选择一个足够大以提供良好计算效率同时仍能放入GPU内存的小批量。为了说明节省的效果,我们来看一些代码。在其中,我们执行相同的矩阵-矩阵乘法,但这次分解为每次64列的“小批量”。

timer.start()
for j in range(0, 256, 64):
    A[:, j:j+64] = torch.mm(B, C[:, j:j+64])
timer.stop()
print(f'performance in Gigaflops: block {0.03 / timer.times[3]:.3f}')
performance in Gigaflops: block 37.640
timer.start()
for j in range(0, 256, 64):
    A[:, j:j+64] = np.dot(B, C[:, j:j+64])
timer.stop()
print(f'performance in Gigaflops: block {0.03 / timer.times[3]:.3f}')
performance in Gigaflops: block 2.768
timer.start()
for j in range(0, 256, 64):
    A[:, j:j+64].assign(tf.tensordot(B, C[:, j:j+64], axes=1))
timer.stop()
print(f'performance in Gigaflops: block {0.03 / timer.times[3]:.3f}')
performance in Gigaflops: block 2.426

正如我们所见,在小批量上的计算与在完整矩阵上的计算效率基本相同。需要提醒的是,在 8.5节 中,我们使用了一种正则化方法,它严重依赖于小批量中的方差量。随着批量大小的增加,方差减小,批量归一化带来的噪声注入的好处也随之减小。关于如何重新缩放和计算适当的项,请参阅例如 Ioffe (2017) 的详细说明。

12.5.3. 读取数据集

我们来看一下如何从数据中高效地生成小批量。在下文中,我们使用由NASA开发的数据集来测试不同飞机的机翼噪声,以比较这些优化算法。为方便起见,我们只使用前 \(1,500\) 个样本。数据经过白化预处理,即我们移除了均值并将每个坐标的方差重新缩放为 \(1\)

#@save
d2l.DATA_HUB['airfoil'] = (d2l.DATA_URL + 'airfoil_self_noise.dat',
                           '76e5be1548fd8222e5074cf0faae75edff8cf93f')

#@save
def get_data_ch11(batch_size=10, n=1500):
    data = np.genfromtxt(d2l.download('airfoil'),
                         dtype=np.float32, delimiter='\t')
    data = torch.from_numpy((data - data.mean(axis=0)) / data.std(axis=0))
    data_iter = d2l.load_array((data[:n, :-1], data[:n, -1]),
                               batch_size, is_train=True)
    return data_iter, data.shape[1]-1
#@save
d2l.DATA_HUB['airfoil'] = (d2l.DATA_URL + 'airfoil_self_noise.dat',
                           '76e5be1548fd8222e5074cf0faae75edff8cf93f')

#@save
def get_data_ch11(batch_size=10, n=1500):
    data = np.genfromtxt(d2l.download('airfoil'),
                         dtype=np.float32, delimiter='\t')
    data = (data - data.mean(axis=0)) / data.std(axis=0)
    data_iter = d2l.load_array(
        (data[:n, :-1], data[:n, -1]), batch_size, is_train=True)
    return data_iter, data.shape[1]-1
#@save
d2l.DATA_HUB['airfoil'] = (d2l.DATA_URL + 'airfoil_self_noise.dat',
                           '76e5be1548fd8222e5074cf0faae75edff8cf93f')

#@save
def get_data_ch11(batch_size=10, n=1500):
    data = np.genfromtxt(d2l.download('airfoil'),
                         dtype=np.float32, delimiter='\t')
    data = (data - data.mean(axis=0)) / data.std(axis=0)
    data_iter = d2l.load_array((data[:n, :-1], data[:n, -1]),
                               batch_size, is_train=True)
    return data_iter, data.shape[1]-1

12.5.4. 从零开始实现

回顾一下在 3.4节 中的小批量随机梯度下降实现。在下文中,我们提供一个稍微更通用的实现。为方便起见,它的调用签名与本章后面介绍的其他优化算法相同。具体来说,我们添加了状态输入 states 并将超参数放在字典 hyperparams 中。此外,我们将在训练函数中对每个小批量样本的损失进行平均,因此优化算法中的梯度不需要除以批量大小。

def sgd(params, states, hyperparams):
    for p in params:
        p.data.sub_(hyperparams['lr'] * p.grad)
        p.grad.data.zero_()
def sgd(params, states, hyperparams):
    for p in params:
        p[:] -= hyperparams['lr'] * p.grad
def sgd(params, grads, states, hyperparams):
    for param, grad in zip(params, grads):
        param.assign_sub(hyperparams['lr']*grad)

接下来,我们实现一个通用的训练函数,以方便使用本章后面介绍的其他优化算法。它初始化一个线性回归模型,并可用于通过小批量随机梯度下降和随后介绍的其他算法来训练该模型。

#@save
def train_ch11(trainer_fn, states, hyperparams, data_iter,
               feature_dim, num_epochs=2):
    # Initialization
    w = torch.normal(mean=0.0, std=0.01, size=(feature_dim, 1),
                     requires_grad=True)
    b = torch.zeros((1), requires_grad=True)
    net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
    # Train
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()
    for _ in range(num_epochs):
        for X, y in data_iter:
            l = loss(net(X), y).mean()
            l.backward()
            trainer_fn([w, b], states, hyperparams)
            n += X.shape[0]
            if n % 200 == 0:
                timer.stop()
                animator.add(n/X.shape[0]/len(data_iter),
                             (d2l.evaluate_loss(net, data_iter, loss),))
                timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch')
    return timer.cumsum(), animator.Y[0]
#@save
def train_ch11(trainer_fn, states, hyperparams, data_iter,
               feature_dim, num_epochs=2):
    # Initialization
    w = np.random.normal(scale=0.01, size=(feature_dim, 1))
    b = np.zeros(1)
    w.attach_grad()
    b.attach_grad()
    net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
    # Train
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()
    for _ in range(num_epochs):
        for X, y in data_iter:
            with autograd.record():
                l = loss(net(X), y).mean()
            l.backward()
            trainer_fn([w, b], states, hyperparams)
            n += X.shape[0]
            if n % 200 == 0:
                timer.stop()
                animator.add(n/X.shape[0]/len(data_iter),
                             (d2l.evaluate_loss(net, data_iter, loss),))
                timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch')
    return timer.cumsum(), animator.Y[0]
#@save
def train_ch11(trainer_fn, states, hyperparams, data_iter,
               feature_dim, num_epochs=2):
    # Initialization
    w = tf.Variable(tf.random.normal(shape=(feature_dim, 1),
                                   mean=0, stddev=0.01),trainable=True)
    b = tf.Variable(tf.zeros(1), trainable=True)

    # Train
    net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()

    for _ in range(num_epochs):
        for X, y in data_iter:
          with tf.GradientTape() as g:
            l = tf.math.reduce_mean(loss(net(X), y))

          dw, db = g.gradient(l, [w, b])
          trainer_fn([w, b], [dw, db], states, hyperparams)
          n += X.shape[0]
          if n % 200 == 0:
              timer.stop()
              p = n/X.shape[0]
              q = p/tf.data.experimental.cardinality(data_iter).numpy()
              r = (d2l.evaluate_loss(net, data_iter, loss),)
              animator.add(q, r)
              timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch')
    return timer.cumsum(), animator.Y[0]

让我们看看批量梯度下降的优化过程。这可以通过将小批量大小设置为1500(即样本总数)来实现。结果,模型参数每个周期只更新一次。进展甚微。实际上,在6个步骤之后,进展就停滞了。

def train_sgd(lr, batch_size, num_epochs=2):
    data_iter, feature_dim = get_data_ch11(batch_size)
    return train_ch11(
        sgd, None, {'lr': lr}, data_iter, feature_dim, num_epochs)

gd_res = train_sgd(1, 1500, 10)
loss: 0.247, 0.020 sec/epoch
../_images/output_minibatch-sgd_f4d60f_111_1.svg
def train_sgd(lr, batch_size, num_epochs=2):
    data_iter, feature_dim = get_data_ch11(batch_size)
    return train_ch11(
        sgd, None, {'lr': lr}, data_iter, feature_dim, num_epochs)

gd_res = train_sgd(1, 1500, 10)
loss: 0.254, 0.034 sec/epoch
../_images/output_minibatch-sgd_f4d60f_114_1.svg
def train_sgd(lr, batch_size, num_epochs=2):
    data_iter, feature_dim = get_data_ch11(batch_size)
    return train_ch11(
        sgd, None, {'lr': lr}, data_iter, feature_dim, num_epochs)

gd_res = train_sgd(1, 1500, 10)
loss: 0.249, 0.022 sec/epoch
../_images/output_minibatch-sgd_f4d60f_117_1.svg

当批量大小等于1时,我们使用随机梯度下降进行优化。为了简化实现,我们选择了一个恒定(尽管很小)的学习率。在随机梯度下降中,每处理一个样本,模型参数就会更新一次。在我们的例子中,这相当于每个周期更新1500次。正如我们所看到的,目标函数值的下降在一个周期后变慢了。尽管在我们的实验中,两种方法在一个周期内都处理了1500个样本,但随机梯度下降比梯度下降消耗了更多的时间。这是因为随机梯度下降更新参数的频率更高,而且一次处理单个观测值的效率较低。

sgd_res = train_sgd(0.005, 1)
loss: 0.245, 0.685 sec/epoch
../_images/output_minibatch-sgd_f4d60f_123_1.svg
sgd_res = train_sgd(0.005, 1)
loss: 0.243, 3.216 sec/epoch
../_images/output_minibatch-sgd_f4d60f_126_1.svg
sgd_res = train_sgd(0.005, 1)
loss: 0.246, 6.006 sec/epoch
../_images/output_minibatch-sgd_f4d60f_129_1.svg

最后,当批量大小等于100时,我们使用小批量随机梯度下降进行优化。每个周期所需的时间比随机梯度下降和批量梯度下降所需的时间都短。

mini1_res = train_sgd(.4, 100)
loss: 0.246, 0.025 sec/epoch
../_images/output_minibatch-sgd_f4d60f_135_1.svg
mini1_res = train_sgd(.4, 100)
loss: 0.248, 0.064 sec/epoch
../_images/output_minibatch-sgd_f4d60f_138_1.svg
mini1_res = train_sgd(.4, 100)
loss: 0.245, 0.074 sec/epoch
../_images/output_minibatch-sgd_f4d60f_141_1.svg

将批量大小减小到10,每个周期的耗时增加,因为每个批次的执行效率较低。

mini2_res = train_sgd(.05, 10)
loss: 0.246, 0.090 sec/epoch
../_images/output_minibatch-sgd_f4d60f_147_1.svg
mini2_res = train_sgd(.05, 10)
loss: 0.243, 0.374 sec/epoch
../_images/output_minibatch-sgd_f4d60f_150_1.svg
mini2_res = train_sgd(.05, 10)
loss: 0.245, 0.617 sec/epoch
../_images/output_minibatch-sgd_f4d60f_153_1.svg

现在我们可以比较之前四个实验的时间与损失。可以看出,尽管就处理的样本数量而言,随机梯度下降比梯度下降收敛得更快,但它达到相同损失所需的时间比梯度下降更长,因为逐个样本计算梯度效率不高。小批量随机梯度下降能够在收敛速度和计算效率之间进行权衡。批量大小为10比随机梯度下降更有效率;批量大小为100甚至在运行时间上优于梯度下降。

d2l.set_figsize([6, 3])
d2l.plot(*list(map(list, zip(gd_res, sgd_res, mini1_res, mini2_res))),
         'time (sec)', 'loss', xlim=[1e-2, 10],
         legend=['gd', 'sgd', 'batch size=100', 'batch size=10'])
d2l.plt.gca().set_xscale('log')
../_images/output_minibatch-sgd_f4d60f_159_0.svg
d2l.set_figsize([6, 3])
d2l.plot(*list(map(list, zip(gd_res, sgd_res, mini1_res, mini2_res))),
         'time (sec)', 'loss', xlim=[1e-2, 10],
         legend=['gd', 'sgd', 'batch size=100', 'batch size=10'])
d2l.plt.gca().set_xscale('log')
../_images/output_minibatch-sgd_f4d60f_162_0.svg
d2l.set_figsize([6, 3])
d2l.plot(*list(map(list, zip(gd_res, sgd_res, mini1_res, mini2_res))),
         'time (sec)', 'loss', xlim=[1e-2, 10],
         legend=['gd', 'sgd', 'batch size=100', 'batch size=10'])
d2l.plt.gca().set_xscale('log')
../_images/output_minibatch-sgd_f4d60f_165_0.svg

12.5.5. 简洁实现

在Gluon中,我们可以使用 Trainer 类来调用优化算法。这用于实现一个通用的训练函数。我们将在本章的其余部分使用它。

#@save
def train_concise_ch11(trainer_fn, hyperparams, data_iter, num_epochs=4):
    # Initialization
    net = nn.Sequential(nn.Linear(5, 1))
    def init_weights(module):
        if type(module) == nn.Linear:
            torch.nn.init.normal_(module.weight, std=0.01)
    net.apply(init_weights)

    optimizer = trainer_fn(net.parameters(), **hyperparams)
    loss = nn.MSELoss(reduction='none')
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()
    for _ in range(num_epochs):
        for X, y in data_iter:
            optimizer.zero_grad()
            out = net(X)
            y = y.reshape(out.shape)
            l = loss(out, y)
            l.mean().backward()
            optimizer.step()
            n += X.shape[0]
            if n % 200 == 0:
                timer.stop()
                # `MSELoss` computes squared error without the 1/2 factor
                animator.add(n/X.shape[0]/len(data_iter),
                             (d2l.evaluate_loss(net, data_iter, loss) / 2,))
                timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch')
#@save
def train_concise_ch11(tr_name, hyperparams, data_iter, num_epochs=2):
    # Initialization
    net = nn.Sequential()
    net.add(nn.Dense(1))
    net.initialize(init.Normal(sigma=0.01))
    trainer = gluon.Trainer(net.collect_params(), tr_name, hyperparams)
    loss = gluon.loss.L2Loss()
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()
    for _ in range(num_epochs):
        for X, y in data_iter:
            with autograd.record():
                l = loss(net(X), y)
            l.backward()
            trainer.step(X.shape[0])
            n += X.shape[0]
            if n % 200 == 0:
                timer.stop()
                animator.add(n/X.shape[0]/len(data_iter),
                             (d2l.evaluate_loss(net, data_iter, loss),))
                timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch')
#@save
def train_concise_ch11(trainer_fn, hyperparams, data_iter, num_epochs=2):
    # Initialization
    net = tf.keras.Sequential()
    net.add(tf.keras.layers.Dense(1,
            kernel_initializer=tf.random_normal_initializer(stddev=0.01)))
    optimizer = trainer_fn(**hyperparams)
    loss = tf.keras.losses.MeanSquaredError()
    animator = d2l.Animator(xlabel='epoch', ylabel='loss',
                            xlim=[0, num_epochs], ylim=[0.22, 0.35])
    n, timer = 0, d2l.Timer()
    for _ in range(num_epochs):
        for X, y in data_iter:
            with tf.GradientTape() as g:
                out = net(X)
                l = loss(y, out)
                params = net.trainable_variables
                grads = g.gradient(l, params)
            optimizer.apply_gradients(zip(grads, params))
            n += X.shape[0]
            if n % 200 == 0:
                timer.stop()
                p = n/X.shape[0]
                q = p/tf.data.experimental.cardinality(data_iter).numpy()
                # `MeanSquaredError` computes squared error without the 1/2
                # factor
                r = (d2l.evaluate_loss(net, data_iter, loss) / 2,)
                animator.add(q, r)
                timer.start()
    print(f'loss: {animator.Y[0][-1]:.3f}, {timer.sum()/num_epochs:.3f} sec/epoch')

使用Gluon重复上一个实验,显示出相同的行为。

data_iter, _ = get_data_ch11(10)
trainer = torch.optim.SGD
train_concise_ch11(trainer, {'lr': 0.01}, data_iter)
loss: 0.243, 0.096 sec/epoch
../_images/output_minibatch-sgd_f4d60f_183_1.svg
data_iter, _ = get_data_ch11(10)
train_concise_ch11('sgd', {'learning_rate': 0.05}, data_iter)
loss: 0.243, 0.393 sec/epoch
../_images/output_minibatch-sgd_f4d60f_186_1.svg
data_iter, _ = get_data_ch11(10)
trainer = tf.keras.optimizers.SGD
train_concise_ch11(trainer, {'learning_rate': 0.05}, data_iter)
loss: 0.249, 1.203 sec/epoch
../_images/output_minibatch-sgd_f4d60f_189_1.svg

12.5.6. 总结

  • 向量化通过减少深度学习框架产生的开销以及改善CPU和GPU上的内存局部性和缓存来提高代码效率。

  • 在随机梯度下降带来的统计效率和一次性处理大批量数据带来的计算效率之间存在权衡。

  • 小批量随机梯度下降提供了两全其美的方案:计算效率和统计效率。

  • 在小批量随机梯度下降中,我们处理通过对训练数据进行随机排列而获得的数据批次(即,每个观测值在每个周期只处理一次,尽管顺序是随机的)。

  • 建议在训练过程中衰减学习率。

  • 总的来说,当以时钟时间衡量时,小批量随机梯度下降在收敛到较小风险方面比随机梯度下降和梯度下降更快。

12.5.7. 练习

  1. 修改批量大小和学习率,观察目标函数值的下降速率和每个周期消耗的时间。

  2. 阅读MXNet文档,并使用 Trainer 类的 set_learning_rate 函数,在每个周期后将小批量随机梯度下降的学习率降低到其先前值的1/10。

  3. 将小批量随机梯度下降与一个实际*从训练集中有放回抽样*的变体进行比较。会发生什么?

  4. 一个邪恶的精灵在你不知情的情况下复制了你的数据集(即,每个观测值出现两次,你的数据集增长到原始大小的两倍,但没人告诉你)。随机梯度下降、小批量随机梯度下降和梯度下降的行为会如何变化?