《动手学深度学习(第二版)》读书笔记


1. K折交叉验证

1.1 K折交叉验证的原理

原始训练数据被分成 $𝐾$ 个不重叠的子集。然后执行 $𝐾$ 次模型训练和验证,每次在 $𝐾−1$ 个子集上进行训练,并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。最后,通过对 $𝐾$ 次实验的结果取平均来估计训练和验证误差。

1.2 K折交叉验证的使用

import numpy as np
import torch
from sklearn.model_selection import KFold
from torch.utils.data import DataLoader
from torch.utils.data.dataset import Subset


def cross_valid(k_fold, model, criterion, optimizer, dataset, batch_size, num_epochs):
    kf = KFold(n_splits=k_fold, shuffle=True)
    for train_indices, test_indices in kf.split(dataset):
        train_dataset = Subset(dataset, train_indices)
        test_dataset = Subset(dataset, test_indices)
        train_losses, test_losses = train(
            model, criterion, optimizer, train_dataset, batch_size, num_epochs, test_dataset=test_dataset
        )
        print("train loss", np.mean(train_losses), "test loss", np.mean(test_losses))


def train(model, criterion, optimizer, dataset, batch_size, num_epochs, *, test_dataset=None):
    train_losses, test_losses = [], []

    dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=False)
    for _ in range(num_epochs):
        model.train(True)
        for features, labels in dataloader:
            optimizer.zero_grad()
            loss = criterion(model(features), labels)
            loss.backward()
            optimizer.step()

        model.train(False)
        with torch.no_grad():
            train_loss = criterion(model(dataset[:][0]), dataset[:][1])
            train_losses.append(train_loss.item())

            if test_dataset:
                test_loss = criterion(model(test_dataset[:][0]), test_dataset[:][1])
                test_losses.append(test_loss)

    return train_losses, test_losses

完整代码:kaggle_house_pred.py

2. Dropout

2.1 Dropout的原理

在训练过程的每一次迭代中,dropout 在计算下一层之前将当前层中的一些节点置零。

dropout 的作者认为,神经网络过拟合的特征是每一层都依赖于前一层激活值的特定模式,称这种情况为”共适应性”。dropout 会破坏共适应性。

关键的挑战就是如何注入这种噪声。一种想法是以一种无偏的方式注入噪声。这样在固定住其他层时,每一层的期望值等于没有噪音时的值。

在毕晓普的工作中,他将高斯噪声添加到线性模型的输入中。在每次训练迭代中,他将从均值为零的分布 $\epsilon \sim \mathcal{N}(0,\sigma^2)$ 采样噪声添加到输入 $\mathbf{x}$ ,从而产生扰动点 $\mathbf{x}’ = \mathbf{x} + \epsilon$ 。预期是 $E[\mathbf{x}’] = \mathbf{x}$ 。

在标准 dropout 正则化中,通过按保留(未丢弃)的节点的分数进行归一化来消除每一层的偏差。换言之,每个中间激活值 $h$ 以 丢弃概率 $p$ 由随机变量 $h’$ 替换,如下所示:

根据设计,期望值保持不变,即 $E[h’] = h$ 。

2.2 Dropout的实现

def dropout_layer(X, dropout):
    assert 0 <= dropout <= 1
    # 在本情况中,所有元素都被丢弃。
    if dropout == 1:
        return torch.zeros_like(X)
    # 在本情况中,所有元素都被保留。
    if dropout == 0:
        return X
    mask = (torch.Tensor(X.shape).uniform_(0, 1) > dropout).float()
    return mask * X / (1.0 - dropout)

class Net(nn.Module):
    def __init__(self, num_inputs, num_outputs, num_hiddens, dropout):
        super().__init__()
        self.fc1 = nn.Linear(num_inputs, num_hiddens)
        self.fc2 = nn.Linear(num_hiddens, num_outputs)
        self.dropout = dropout

    def forward(self, X):
        H1 = torch.relu(self.fc1(X))
        if self.training:
            H1 = dropout_layer(H1, self.dropout)
        return self.fc2(H1)

3. Xavier初始化

3.1 Xavier的原理

对于 没有非线性 的全连接层输出,该层 $n_\mathrm{in}$ 输入 $x_j$ 及其相关权重 $w_{ij}$ ,其输出由下式给出

权重 $w_{ij}$ 都是从同一分布中独立抽取的。假设该分布具有零均值和方差 $\sigma^2$ 。注意,这并不意味着分布必须是高斯的,只是均值和方差需要存在。

同时,假设层 $x_j$ 的输入也具有零均值和方差 $\gamma^2$ (因为对于训练数据,我们常常会做归一化),并且它们独立于 $w_{ij}$ 并且彼此独立。

在这种情况下,可以按如下方式计算 $o_i$ 的平均值和方差:

为了避免梯度爆炸,所以需要保持输出方差 $n_\mathrm{in} \sigma^2 \gamma^2$ 与输入方差 $\gamma^2$ 不变,一种方法是设置 $n_\mathrm{in} \sigma^2 = 1$ 。在反向传播过程,有类似的问题,尽管梯度是从更靠近输出的层传播的。与正向传播类似,除非 $n_\mathrm{out} \sigma^2 = 1$ ,否则梯度的方差可能会增大,其中 $n_\mathrm{out}$ 是该层的输出的数量。

我们不可能同时满足 $n_\mathrm{in} \sigma^2 = 1$ 和 $n_\mathrm{out} \sigma^2 = 1$ 这两个条件。相反,可以选择一个折中的方案:

这就是 Xavier初始化 的基础,通常,Xavier 初始化从均值为零,方差 $\sigma^2 = \frac{2}{n_\mathrm{in} + n_\mathrm{out}}$ 的高斯分布中采样权重。

也可以利用 Xavier 的直觉来选择从均匀分布中抽取权重时的方差。注意,公式有对于均匀分布 $U(-a, a)$ 其方差为 $\frac{a^2}{3}$ 。将 $\frac{a^2}{3}$ 代入到 $\sigma^2$ 的条件中,得到:

尽管上述数学推理中,不存在非线性的假设在神经网络中很容易被违反,但 Xavier 初始化方法在实践中被证明是有效的。

3.2 应用到线性激活函数

数值稳定性 + 模型初始化和激活函数【动手学深度学习v2】

对于 线性激活函数 $\sigma(x) = \alpha x + \beta$ ,假设其输入 $x$ 具有零均值和方差 $\alpha^2$。

激活函数输出 $o_i$ 的均值:

为了保持输出均值不变,则 $\beta$ 应当等于 0,即 $\beta = 0$ 。

激活函数输出 $o_i$ 的方差:

为了保持输出方差依然为 $\alpha^2$ ,则 $\alpha^2 \mathrm{Var}[x_i]$ 应当等于 $\mathrm{Var}[x_i]$ ,即 $\alpha = 1$ 。

在反向传播过程,结论依然是一样的,即 $\beta = 0$ 和 $\alpha = 1$ 。

综上,如果想要让激活函数不改变输入输出数据的均值和方差的话,需要 $\beta = 0$ 和 $\alpha = 1$ ,即激活函数需要让输出数据等于输入数据 $f(x) = x$。

3.3 常用激活函数的性质

常用激活函数的曲线

对于常用的激活函数使用泰勒展开:

可以发现,在零点附近(神经网络的值通常也在零点附近), $\mathrm{tanh}(x)$ 和 $\mathrm{relu}(x)$ 都是近似于 $f(x) = x$ 这样的函数。但是 $\mathrm{sigmoid}(x)$ 却不是的。所以可以调整 sigmoid 函数来满足需求:

4. 卷积神经网络

4.1 基本的卷积运算

def corr2d(X: torch.Tensor, K: torch.Tensor) -> torch.Tensor:
    """
    >>> X = torch.tensor([[0.0, 1.0, 2.0],
    ...                   [3.0, 4.0, 5.0],
    ...                   [6.0, 7.0, 8.0]])
    >>> K = torch.tensor([[0.0, 1.0],
    ...                   [2.0, 3.0]])
    >>> corr2d(X, K)
    tensor([[19., 25.],
            [37., 43.]])
    """

    h, w = K.shape
    Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
    for i in range(Y.shape[0]):
        for j in range(Y.shape[1]):
            Y[i, j] = (X[i : i + h, j : j + w] * K).sum()
    return Y

class Conv2D(nn.Module):
    def __init__(self, kernel_size):
        super().__init__()
        self.weight = nn.Parameter(torch.rand(kernel_size))
        self.bias = nn.Parameter(torch.zeros(1))

    def forward(self, x):
        return corr2d(x, self.weight) + self.bias

4.2 多通道输入的卷积运算

def corr2d_multi_in(X: torch.Tensor, K: torch.Tensor) -> torch.Tensor:
    """
    >>> X = torch.tensor([[[0.0, 1.0, 2.0],
    ...                    [3.0, 4.0, 5.0],
    ...                    [6.0, 7.0, 8.0]],
    ...                   [[1.0, 2.0, 3.0],
    ...                    [4.0, 5.0, 6.0],
    ...                    [7.0, 8.0, 9.0]]])
    >>> K = torch.tensor([[[0.0, 1.0],
    ...                    [2.0, 3.0]],
    ...                   [[1.0, 2.0],
    ...                    [3.0, 4.0]]])
    >>> corr2d_multi_in(X, K)
    tensor([[ 56.,  72.],
            [104., 120.]])
    """

    return sum(corr2d(x, k) for x, k in zip(X, K))

4.3 多通道输入输出的卷积运算

def corr2d_multi_in_out(X: torch.Tensor, K: torch.Tensor) -> torch.Tensor:
    """
    >>> X = torch.tensor([[[0.0, 1.0, 2.0],
    ...                    [3.0, 4.0, 5.0],
    ...                    [6.0, 7.0, 8.0]],
    ...                   [[1.0, 2.0, 3.0],
    ...                    [4.0, 5.0, 6.0],
    ...                    [7.0, 8.0, 9.0]]])
    >>> K = torch.tensor([[[0.0, 1.0],
    ...                    [2.0, 3.0]],
    ...                   [[1.0, 2.0],
    ...                    [3.0, 4.0]]])
    >>> K = torch.stack((K, K + 1, K + 2), 0)
    >>> K.shape
    torch.Size([3, 2, 2, 2])
    >>> corr2d_multi_in_out(X, K)
    tensor([[[ 56.,  72.],
             [104., 120.]],
    <BLANKLINE>
            [[ 76., 100.],
             [148., 172.]],
    <BLANKLINE>
            [[ 96., 128.],
             [192., 224.]]])
    """

    return torch.stack([corr2d_multi_in(X, k) for k in K], 0)

5. 批量归一化

5.1 批量归一化的原理

批量归一化应用于单个可选层(也可以应用到所有层),其原理是:在每次训练迭代中,归一化输入,即通过减去其均值并除以其标准差,其中两者均基于当前小批量处理。

注意,不能使用大小为 1 的批量应用批量归一化。因为在减去均值之后,每个隐藏单元将为 0。在应用批量归一化时,批量大小的选择可能比没有批量归一化时更重要。

用 $\mathbf{x} \in \mathcal{B}$ 表示一个来自小批量 $\mathcal{B}$ 的输入,批量归一化 $\mathrm{BN}$ 表达式为:

其中,$\hat{\boldsymbol{\mu}}_\mathcal{B}$ 是样本均值,$\hat{\boldsymbol{\sigma}}_\mathcal{B}$ 是小批量 $\mathcal{B}$ 的样本标准差。小常量 $\epsilon > 0$ 确保不会除以零。

应用标准化后,生成的小批量的平均值为 0 和单位方差为 1。由于单位方差(与其他一些魔法数)是一个任意的选择,因此通常包含 拉伸参数(scale) $\boldsymbol{\gamma}$ 和 偏移参数(shift) $\boldsymbol{\beta}$,它们的形状与 $\mathbf{x}$ 相同。注意,$\boldsymbol{\gamma}$ 和 $\boldsymbol{\beta}$ 也是需要与其他模型参数一起学习的参数。

5.2 批量归一化层的应用

  1. 全连接层

    通常将批量归一化层置于全连接层中的仿射变换和激活函数之间。

  2. 卷积层

    通常在卷积层之后和非线性激活函数之前应用批量归一化。

    当卷积有多个输出通道时,需要对这些通道的”每个”输出执行批量归一化,每个通道都有自己的拉伸(scale)和偏移(shift)参数,这两个参数都是标量。

  3. 预测过程中的批量归一化

    一种常用的方法是通过移动平均估算整个训练数据集的样本均值和方差,并在预测时使用它们得到确定的输出。

5.3 批量归一化的实现

def batch_norm(
    X: torch.Tensor,
    gamma: torch.Tensor,
    beta: torch.Tensor,
    moving_mean: torch.Tensor,
    moving_var: torch.Tensor,
    eps: float,
    momentum: float,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    # 通过 `is_grad_enabled` 来判断当前模式是训练模式还是预测模式
    if not torch.is_grad_enabled():
        # 如果是在预测模式下,直接使用传入的移动平均所得的均值和方差
        X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
    else:
        assert len(X.shape) in (2, 4)
        if len(X.shape) == 2:
            # 使用全连接层的情况,计算特征维上的均值和方差
            mean = X.mean(dim=0)
            var = ((X - mean) ** 2).mean(dim=0)
        else:
            # 使用二维卷积层的情况,计算通道维上(axis=1)的均值和方差。
            # 这里我们需要保持X的形状以便后面可以做广播运算
            mean = X.mean(dim=(0, 2, 3), keepdim=True)
            var = ((X - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
        # 训练模式下,用当前的均值和方差做标准化
        X_hat = (X - mean) / torch.sqrt(var + eps)
        # 更新移动平均的均值和方差
        moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
        moving_var = momentum * moving_var + (1.0 - momentum) * var
    Y = gamma * X_hat + beta  # 缩放和移位
    return Y, moving_mean.data, moving_var.data


class BatchNorm(nn.Module):
    # `num_features`:完全连接层的输出数量或卷积层的输出通道数
    # `num_dims`:2表示完全连接层,4表示卷积层
    def __init__(self, num_features, num_dims):
        super().__init__()
        if num_dims == 2:
            shape = (1, num_features)
        else:
            shape = (1, num_features, 1, 1)
        # 参与求梯度和迭代的拉伸和偏移参数,分别初始化成1和0
        self.gamma = nn.Parameter(torch.ones(shape))
        self.beta = nn.Parameter(torch.zeros(shape))
        # 非模型参数的变量初始化为0和1
        self.moving_mean = torch.zeros(shape)
        self.moving_var = torch.ones(shape)

    def forward(self, X):
        # 如果 `X` 不在内存上,将 `moving_mean` 和 `moving_var`
        # 复制到 `X` 所在显存上
        if self.moving_mean.device != X.device:
            self.moving_mean = self.moving_mean.to(X.device)
            self.moving_var = self.moving_var.to(X.device)
        # 保存更新过的 `moving_mean` 和 `moving_var`
        Y, self.moving_mean, self.moving_var = batch_norm(
            X, self.gamma, self.beta, self.moving_mean, self.moving_var, eps=1e-5, momentum=0.9
        )
        return Y

6. 残差网络(ResNet)

6.1 函数类

对于神经网络结构 $\mathcal{F}$ ,它能表达出来的所有 $f \in \mathcal{F}$ 。假设 $f^*$ 是想找的函数,

  1. 如果是 $f^* \in \mathcal{F}$,可以通过训练直接得到它,但很多时候要找的最优函数不在神经网络能表达出来的函数集中。

  2. 对于要找的最优函数不在神经网络能表达出来的函数集中情况,可以在 $\mathcal{F}$ 中找一个尽可能接近 函数的函数

    如何得到更近似真正 $f^*$ 函数的函数呢?

    可以设计一个更强大的结构 $\mathcal{F}’$ ,它所能表达的函数应当严格的比 $\mathcal{F}$ 所能表达的函数更多,并且$\mathcal{F}$ 所能表达的函数,$\mathcal{F}’$ 应当都能表示。

    对于非嵌套函数(non-nested function)类,并不一定满足上述的需求(复杂度由 $\mathcal{F}_1$ 向 $\mathcal{F}_6$ 递增)。而对于嵌套函数(nested function)类 $\mathcal{F}_1 \subseteq \ldots \subseteq \mathcal{F}_6$,则能满足上述的需求。

    函数类

    因此,只有当较复杂的函数类包含较简单的函数类时,才能确保复杂的函数类中能找到更近似真正 $f^*$ 函数的函数。

    对于深度神经网络,如果能将新添加的层训练成 恒等映射(identity function) $f(\mathbf{x}) = \mathbf{x}$ ,新模型和原模型将同样有效。同时,由于新模型可能得出更优的解来拟合训练数据集,因此添加层似乎更容易降低训练误差。这也是 残差网络的核心思想:每个附加层都应该更容易地包含原始函数作为其元素之一。

6.2 残差块

残差网络由残差块构成。对于每个残差块,假设输入为 $x$ ,希望学出的理想映射为 $f(\mathbf{x})$ 。

正常块和残差块

对于上图左边的正常块虚线框中的部分需要直接拟合出该映射 $f(\mathbf{x})$ , 而上图中右边的残差块虚线框中的部分则需要拟合出残差映射 $f(\mathbf{x}) - \mathbf{x}$ 。

残差映射在现实中往往更容易优化。假如希望学出的理想映射 $f(\mathbf{x})$ 是一个恒等映射 $f(\mathbf{x}) = \mathbf{x}$ ,只需将上图中右边的残差块虚线框上方的加权运算(如仿射)的权重和偏置参数设成 0, $f(\mathbf{x})$ 即为恒等映射。

实际中,当理想映射 $f(\mathbf{x})$ 极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。在残差块中,输入可通过跨层数据线路更快地向前传播。

6.3 实现残差块

class Residual(nn.Module):
    def __init__(self, input_channels, num_channels, use_1x1conv=False, strides=1):
        super().__init__()
        self.conv1 = nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1, stride=strides)
        self.conv2 = nn.Conv2d(num_channels, num_channels, kernel_size=3, padding=1)
        if use_1x1conv:
            self.conv3 = nn.Conv2d(input_channels, num_channels, kernel_size=1, stride=strides)
        else:
            self.conv3 = None
        self.bn1 = nn.BatchNorm2d(num_channels)
        self.bn2 = nn.BatchNorm2d(num_channels)
        self.relu = nn.ReLU(inplace=True)

    def forward(self, X):
        Y = F.relu(self.bn1(self.conv1(X)))
        Y = self.bn2(self.conv2(Y))
        if self.conv3:
            X = self.conv3(X)
        Y += X
        return F.relu(Y)

代码生成两种类型的网络:

  • 一种是在 use_1x1conv=False 、应用 ReLU 非线性函数之前,将输入添加到输出。
  • 另一种是在 use_1x1conv=True 时,添加通过 $1 \times 1$ 卷积调整通道和分辨率。

包含以及不包含1x1卷积层的残差块

7. 梯度裁剪

7.1 梯度裁剪的原理

训练的过程中,有时梯度可能很大,从而导致算法无法收敛。虽然可以降低学习率来缓解这个问题,但是如果训练过程中很少得到大的梯度时就不可行了。

一个流行的方案是通过将梯度 $\mathbf{g}$ 投影回给定半径(例如 $\theta$ )的球来裁剪梯度 $\mathbf{g}$ 。如下式:

裁剪后的梯度范数不会超过 $\theta$ ,方向与 $\mathbf{g}$ 的原始方向对齐。它还有一个好处是限制任何给定的小批量数据(以及其中任何给定的样本)对参数向量的影响,让模型的训练更加稳定。

梯度裁剪提供了一个快速修复梯度爆炸的方法。

7.2 梯度裁剪的实现

def grad_clipping(net, theta):
    if isinstance(net, nn.Module):
        params = [p for p in net.parameters() if p.requires_grad]
    else:
        params = net.params
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
            param.grad[:] *= theta / norm

8. 语言模型

8.1 语言模型的定义

语言模型的本质是一个模型(可以理解为一个函数)。对于一段长度为 $T$ 的文本序列,其词元依次为 $x_1, x_2, \ldots, x_T$ ,通过这个模型可以计算出该序列出现的概率,即:

8.2 学习语言模型

学习语言模型的过程就是用一个神经网络去拟合真实的语言模型的过程。

基本的概率规则:

例如,包含了四个单词的一个文本序列的概率是:

为了计算语言模型,我们需要计算单词的概率和给定前面几个单词后出现某个单词的条件概率。这些概率本质上就是语言模型的参数。

9. 困惑度(Perplexity)

如果想要压缩文本(在循环神经网络中,会把一段文本都压缩进隐状态中),可以询问根据当前词元集预测的下一个词元。一个更好的语言模型应该能更准确地预测下一个词元。

所以可以通过一个序列中所有的 $n$ 个词元的交叉熵损失的平均值来衡量:

其中 $P$ 由语言模型给出, $x_t$ 是在时间步 $t$ 从该序列中观察到的实际词元。由于历史原因,自然语言处理的科学家更喜欢使用一个叫做 困惑度(perplexity)的量。它是上面公式的指数:

当需要决定下一个词元是哪个时,困惑度最好的理解是下一个词元的实际选择数的调和平均数。比如:

  • 在最好的情况下,模型总是完美地估计标签词元的概率为1。在这种情况下,模型的困惑度为1。
  • 在最坏的情况下,模型总是预测标签词元的概率为0。在这种情况下,困惑度是正无穷大。
  • 在基线上,该模型的预测是词汇表的所有可用词元上的均匀分布。在这种情况下,困惑度等于词汇表中唯一词元的数量。事实上,如果我们在没有任何压缩的情况下存储序列,这将是我们能做的最好的编码方式。因此,这种方式提供了一个重要的上限,而任何实际的语言模型都必须超越这个上限。

10. 循环神经网络

完整的代码:time_machine.py

10.1 使用循环神经网络进行预测

prefix 是用于预测的字符串。循环遍历 prefix 中的字符,不断地将隐藏状态传递到下一个时间步,但不生成任何输出。这被称为”预热”(warm-up)期,因为在此期间模型会自我更新(例如,更新隐藏状态),但不会进行预测。预热期结束后,隐藏状态的值通常比刚开始的初始值更适合预测,从而预测字符并输出它们。

def predict_ch8(prefix, num_preds, net, vocab, device):
    """在`prefix`后面生成新字符。"""
    state = net.begin_state(batch_size=1, device=device)
    outputs = [vocab[prefix[0]]]

    def get_input():
        return torch.tensor([outputs[-1]], device=device).reshape((1, 1))

    for y in prefix[1:]:  # 预热期
        _, state = net(get_input(), state)
        outputs.append(vocab[y])

    for _ in range(num_preds):  # 预测`num_preds`步
        y, state = net(get_input(), state)
        outputs.append(int(y.argmax(dim=1).reshape(1)))

    return "".join([vocab.idx_to_token[i] for i in outputs])

10.2 使用循环神经网络进行训练

在训练模型之前,让我们定义一个函数在一个迭代周期内训练模型。

  1. 序列数据的不同采样方法(随机采样和顺序分区)将导致隐藏状态初始化的差异。

    随机采样和顺序分区这两种采样的主要区别就是,顺序分区采样出来的数据两个相邻的小批量中的子序列在原始序列上也是相邻的,而随机采样不是。

  2. 在更新模型参数之前裁剪梯度。这样的操作即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散。

  3. 用困惑度来评价模型。这样的度量确保了不同长度的序列具有可比性。

具体来说,当使用顺序分区时,只在每个迭代周期的开始位置初始化隐藏状态。由于下一个小批量数据中的第 $i$ 个子序列样本与当前第 $i$ 个子序列样本相邻,因此当前小批量数据最后一个样本的隐藏状态,将用于初始化下一个小批量数据第一个样本的隐藏状态。这样,存储在隐藏状态中的序列的历史信息可以在一个迭代周期内流经相邻的子序列。然而,在任何一点隐藏状态的计算,都依赖于同一迭代周期中前面所有的小批量数据,这使得梯度计算变得复杂。为了降低计算量,我们在处理任何一个小批量数据之前先分离梯度,使得隐藏状态的梯度计算总是限制在一个小批量数据的时间步内。

当使用随机抽样时,因为每个样本都是在一个随机位置抽样的,因此需要为每个迭代周期重新初始化隐藏状态。

def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
    """训练模型一个迭代周期(定义见第8章)。"""
    state = None
    metrics = [0.0, 0.0]  # 训练损失之和, 词元数量
    for X, Y in train_iter:
        if state is None or use_random_iter:
            # 在第一次迭代或使用随机抽样时初始化`state`
            state = net.begin_state(batch_size=X.shape[0], device=device)
        else:
            # `state`我们从零开始实现的模型是个张量
            for s in state:
                s.detach_()

        y = Y.T.reshape(-1)
        X, y = X.to(device), y.to(device)
        y_hat, state = net(X, state)

        l = loss(y_hat, y.long()).mean()
        l.backward()
        grad_clipping(net, 1)
        # 因为已经调用了`mean`函数
        updater(batch_size=1)

        metrics[0] = metrics[0] + l * y.numel()
        metrics[1] = metrics[1] + y.numel()
    return math.exp(metrics[0] / metrics[1])  # 困惑度

11. 机器翻译

11.1 编码器-解码器结构

编码器-解码器(encoder-decoder)结构常用于处理将输入序列转换成输出序列这类 序列转换模型(sequence transduction) 问题。

编码器-解码器结构

  1. 编码器(encoder)接受一个长度可变的序列作为输入,并将其转换为具有固定形状的编码状态。

    class Encoder(nn.Module):
        """编码器-解码器结构的基本编码器接口。"""
    
        def __init__(self, **kwargs):
            super(Encoder, self).__init__(**kwargs)
    
        def forward(self, X, *args):
            raise NotImplementedError
  2. 解码器(decoder)将固定形状的编码状态映射到长度可变的序列。

    class Decoder(nn.Module):
    """编码器-解码器结构的基本解码器接口。"""
    
    def __init__(self, **kwargs):
        super(Decoder, self).__init__(**kwargs)
    
    def init_state(self, enc_outputs, *args):
        raise NotImplementedError
    
    def forward(self, X, state):
        raise NotImplementedError
  3. 编码器-解码器组合

    class EncoderDecoder(nn.Module):
    """编码器-解码器结构的基类。"""
    
    def __init__(self, encoder, decoder, **kwargs):
        super(EncoderDecoder, self).__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder
    
    def forward(self, enc_X, dec_X, *args):
        enc_outputs = self.encoder(enc_X, *args)
        dec_state = self.decoder.init_state(enc_outputs, *args)
        return self.decoder(dec_X, dec_state)

11.2 序列到序列学习(seq2seq)

完整的代码:machine_translation.py

机器翻译是一种典型的序列转换模型的问题,所以也可以遵循”编码器-解码器”结构的设计原则,来进行序列到序列(sequence to sequence,seq2seq)的学习。

在数据预处理时,需要注意:

  1. 机器翻译中常做单词级词元化,而不是字符级的词元化。

  2. 单词级词元化后,词汇量可能会很大,可以将出现次数较少的次元(比如少于2次的低频次元)视为未知(”<unk>“)词元。另外,一般还会添加一些额外的词元。比如:在小批量时用于将序列填充到相同长度的填充词元(”<pad>“),以及序列的开始词元(”<bos>“)和结束词元(”<eos>“)。

    src_vocab = Vocab(source, min_freq=2, reserved_tokens=['<pad>', '<bos>', '<eos>'])
  3. 为了提高计算效率,一般会将一个小批量的文本序列截断(truncation)和 填充(padding)到相同的长度。

    def truncate_pad(line, num_steps, padding_token):
        """截断或填充文本序列。"""
        if len(line) > num_steps:
            return line[:num_steps]  # 截断
        return line + [padding_token] * (num_steps - len(line))  # 填充

    在训练的过程中,需要注意:

  4. 在每个时间步,解码器预测了输出词元的概率分布。类似于语言模型,可以使用 softmax 来获得分布,并通过计算交叉熵损失函数来进行优化。但是因为在数据预处理时,填充了一些词元到序列的末尾,所以在计算损失时,需要排除这些词元。

    def sequence_mask(X, valid_len, value=0):
        """在序列中屏蔽不相关的项。"""
        maxlen = X.size(1)
        mask = torch.arange((maxlen), dtype=torch.float32, device=X.device)[None, :] < valid_len[:, None]
        X[~mask] = value
        return X
    
    
    class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
        """带遮蔽的softmax交叉熵损失函数"""
        # `pred` 的形状:(`batch_size`, `num_steps`, `vocab_size`)
        # `label` 的形状:(`batch_size`, `num_steps`)
        # `valid_len` 的形状:(`batch_size`,)
        def forward(self, pred, label, valid_len):
            weights = torch.ones_like(label)
            weights = sequence_mask(weights, valid_len)
            self.reduction = "none"
            unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(pred.permute(0, 2, 1), label)
            weighted_loss = (unweighted_loss * weights).mean(dim=1)
            return weighted_loss
  5. 在训练的过程中,特定的序列开始词元(”<bos>“)和原始的输出序列(不包括序列结束词元 “<eos>“)拼接在一起作为解码器的输入。这被称为教师强制(teacher forcing)。

    序列到序列学习的细节

    比如,假如想要训练翻译 “谢谢你。” 为 “thank you.” 且预处理时序列长度为8,在进入网络之前,上图中的 Sources 和 Targets 会被处理为:

    sources = [["谢", "谢", "你", "。", "<eos>", "<pad>", "<pad>", "<pad>"]]
    targets = [["<bos>", "thank", "you", ".", "<eos>", "<pad>", "<pad>", "<pad>"]]

11.3 束搜索

在进行机器翻译预测时,输入数据后,会得到各个单词的概率,如何根据各个单词的概率来确定下一个是哪个单词呢?

  1. 贪心搜索

    最简单常用的方法是贪心搜索,即每次都选择概率最大的那个单词最为下一个单词。

    事实上,翻译最好的结果应该是,翻译后的句子中,各个单词对应概率的乘积的最大值,对应那个句子才是最好的翻译结果,即最优序列(optimal sequence)。但是,贪心搜索的策略并不能保证得到最优序列。

  2. 穷举搜索

    穷举搜索(exhaustive search):穷举地列举所有可能的输出序列及其条件概率,然后输出条件概率最高的一个。这样肯定能得到最优序列,但是计算量过大。

  3. 束搜索

    束搜索(beam search)是贪心搜索的一个改进版本。在每次选择单词时,选择最高条件概率的 $k$ 个单词来进行计算。束宽(beam size) $k$ 是一个超参数。

12. 注意力提示

12.1 非自主性提示

非自主性提示是基于环境中物体的突出性和易见性。比如在一堆白色的物品中,有一个红色的物品,你会不由自主的先注意到红色的物品。

在注意力机制的背景下,感官输入的非自主提示称为 键(Keys)

12.2 自主性提示

自主性提示指注意力受到了认知和意识的控制。比如在困倦的时候,你会有意识找可以休息的地方。可以简单的理解自主性提示为想干什么,需求。

在注意力机制的背景下,自主性提示称为 查询(Queries)

12.3 感官输入

在注意力机制的背景下,感官输入(sensory inputs,例如中间特征表示)被称为 值(Values)

举个例子帮助理解,比如在沙漠中:

  • 自主性提示、查询:想喝水或者想乘凉。
  • 非自主性提示、键:看到了一口井,一个树,还有沙子。
  • 感官输入、值:井在东边,树在西边,到处都是沙子。

根据需求的不同,我们会把注意力关注到不同的地方:

  • 想喝水时主要关注井的信息。
  • 想乘凉时主要关注树的信息。

类比到机器翻译中把”谢谢你。”翻译为”thank you.”时,我们希望神经网络在翻译原文”你”时,更多的关注译文中的”you”,而不是其他的词。

12.4 注意力汇聚

查询(自主提示)和键(非自主提示)之间的交互形成了 注意力汇聚(attention pooling)。注意力汇聚有选择地聚合了值(感官输入)以生成最终的输出。

注意力机制通过注意力汇聚将查询(自主性提示)和键(非自主性提示)结合在一起,实现对 值(感官输入)的选择倾向。

查询、键和值

12.4.1 非参数注意力汇聚

根据输入的位置对输出 $y_i$ 进行加权:

其中 $K$ 是 核(kernel) ,上面的公式称为 Nadaraya-Watson 核回归(Nadaraya-Watson kernel regression) 。可以从注意力机制的角度重写该公式为一个更加通用的 注意力汇聚(attention pooling) 公式:

其中 $x$ 是查询,$(x_i, y_i)$ 是键值对。注意力汇聚是 $y_i$ 的加权平均。将查询 $x$ 和键 $x_i$ 之间的关系建模为 注意力权重(attention weight) $\alpha(x, x_i)$,如上公式所示,这个权重将被分配给每一个对应值 $y_i$。对于任何查询,模型在所有键值对上的注意力权重都是一个有效的概率分布:它们是非负数的,并且总和为1。

为了更好地理解注意力汇聚,仅考虑一个 高斯核(Gaussian kernel) ,其定义为:

将高斯核代入改写后的注意力汇聚公式可以得到:

在上面的公式中,如果一个键 $x_i$ 越是接近给定的查询 $x$, 那么分配给这个键对应值 $y_i$ 的注意力权重就会越大, 也就是”获得了更多的注意力”。

非参数的 Nadaraya-Watson 核回归具有 一致性(consistency) 的优点:如果有足够的数据,此模型会收敛到最优结果。

12.4.2 带参数注意力汇聚

可以将可学习的参数集成到非参数注意力汇聚中。比如,在查询 $x$ 和键 $x_i$ 之间的距离乘以可学习参数 $w$:

13. 注意力评分函数

非参数注意力汇聚 的最后,使用高斯核来对查询和键之间的关系建模。可以将公式 12.4.1.4 中高斯核的指数部分 $-\frac{1}{2}((x - x_i)w)^2$ 视为 注意力评分函数(attention scoring function) ,简称 评分函数(scoring function) ,然后把这个函数的输出结果输入到 softmax 函数中进行运算。通过上述步骤,将得到与键对应的值的概率分布(即注意力权重)。最后,注意力汇聚的输出就是基于这些注意力权重的值的加权和。

从宏观来看,可以使用上述算法来实现注意力机制框架。如下图,将注意力汇聚的输出计算成为值的加权和,其中 $a$ 表示注意力评分函数。由于注意力权重是概率分布,因此加权和其本质上是加权平均值。

注意力机制框架

用数学语言描述,假设有一个查询 $\mathbf{q} \in \mathbb{R}^q$ 和 $m$ 个”键-值”对 $(\mathbf{k}_1, \mathbf{v}_1), \ldots, (\mathbf{k}_m, \mathbf{v}_m)$,其中 $\mathbf{k}_i \in \mathbb{R}^k$,$\mathbf{v}_i \in \mathbb{R}^v$。注意力汇聚函数 $f$ 就被表示成值的加权和:

其中查询 $\mathbf{q}$ 和键 $\mathbf{k}_i$ 的注意力权重(标量)是通过注意力评分函数 $a$ 将两个向量映射成标量,再经过 softmax 运算得到的:

13.1 遮蔽softmax操作

softmax 运算用于输出一个概率分布作为注意力权重。在某些情况下,并非所有的值都应该被纳入到注意力汇聚中。

def masked_softmax(X, valid_lens):
    """通过在最后一个轴上遮蔽元素来执行 softmax 操作"""
    # `X`: 3D张量, `valid_lens`: 1D或2D 张量
    if valid_lens is None:
        return nn.functional.softmax(X, dim=-1)
    else:
        shape = X.shape
        if valid_lens.dim() == 1:
            valid_lens = torch.repeat_interleave(valid_lens, shape[1])
        else:
            valid_lens = valid_lens.reshape(-1)
        # 在最后的轴上,被遮蔽的元素使用一个非常大的负值替换,从而其 softmax (指数)输出为 0
        X = sequence_mask(X.reshape(-1, shape[-1]), valid_lens, value=-1e6)
        return nn.functional.softmax(X.reshape(shape), dim=-1)

13.2 加性注意力

一般来说,当查询和键是不同长度的矢量时,可以使用加性注意力作为评分函数。给定查询 $\mathbf{q} \in \mathbb{R}^q$ 和键 $\mathbf{k} \in \mathbb{R}^k$ ,加性注意力(additive attention) 的评分函数为

其中可学习的参数是 $\mathbf W_q\in\mathbb R^{h\times q}$ 、 $\mathbf W_k\in\mathbb R^{h\times k}$ 和 $\mathbf w_v\in\mathbb R^{h}$ 。在上面的公式中,将查询和键连接起来后输入到一个多层感知机(MLP)中,感知机包含一个隐藏层,其隐藏单元数是一个超参数 $h$ 。通过使用 $\tanh$ 作为激活函数,并且禁用偏置项。

class AdditiveAttention(nn.Module):
    """加性注意力"""

    def __init__(self, key_size, query_size, num_hiddens, dropout, **kwargs):
        super(AdditiveAttention, self).__init__(**kwargs)
        self.W_k = nn.Linear(key_size, num_hiddens, bias=False)
        self.W_q = nn.Linear(query_size, num_hiddens, bias=False)
        self.w_v = nn.Linear(num_hiddens, 1, bias=False)
        self.dropout = nn.Dropout(dropout)

    def forward(self, queries, keys, values, valid_lens):
        queries, keys = self.W_q(queries), self.W_k(keys)
        # 在维度扩展后,
        # `queries` 的形状:(`batch_size`, 查询的个数, 1, `num_hidden`)
        # `key` 的形状:(`batch_size`, 1, "键-值"对的个数, `num_hiddens`)
        # 使用广播方式进行求和
        features = queries.unsqueeze(2) + keys.unsqueeze(1)
        features = torch.tanh(features)
        # `self.w_v` 仅有一个输出,因此从形状中移除最后那个维度。
        # `scores` 的形状:(`batch_size`, 查询的个数, "键-值"对的个数)
        scores = self.w_v(features).squeeze(-1)
        self.attention_weights = masked_softmax(scores, valid_lens)
        # `values` 的形状:(`batch_size`, "键-值"对的个数, 值的维度)
        return torch.bmm(self.dropout(self.attention_weights), values)

13.3 缩放点积注意力

使用点积可以得到计算效率更高的评分函数。但是点积操作要求查询和键具有相同的长度 $d$。假设查询和键的所有元素都是独立的随机变量,并且都满足均值为 $0$ 和方差为 $1$。那么两个向量的点积的均值为 $0$,方差为 $d$。为确保无论向量长度如何,点积的方差在不考虑向量长度的情况下仍然是 $1$,则可以使用 缩放点积注意力(scaled dot-product attention) 评分函数:

将点积除以 $\sqrt{d}$。在实践中,我们通常从小批量的角度来考虑提高效率,例如基于 $n$ 个查询和 $m$ 个键-值对计算注意力,其中查询和键的长度为 $d$,值的长度为 $v$。查询 $\mathbf Q\in\mathbb R^{n\times d}$、键 $\mathbf K\in\mathbb R^{m\times d}$ 和值 $\mathbf V\in\mathbb R^{m\times v}$ 的缩放点积注意力是

在下面的缩放点积注意力的实现中,我们使用了 dropout 进行模型正则化。

class DotProductAttention(nn.Module):
    """缩放点积注意力"""

    def __init__(self, dropout, **kwargs):
        super(DotProductAttention, self).__init__(**kwargs)
        self.dropout = nn.Dropout(dropout)

    # `queries` 的形状:(`batch_size`, 查询的个数, `d`)
    # `keys` 的形状:(`batch_size`, "键-值"对的个数, `d`)
    # `values` 的形状:(`batch_size`, "键-值"对的个数, 值的维度)
    # `valid_lens` 的形状: (`batch_size`,) 或者 (`batch_size`, 查询的个数)
    def forward(self, queries, keys, values, valid_lens=None):
        d = queries.shape[-1]
        # 设置 `transpose_b=True` 为了交换 `keys` 的最后两个维度
        scores = torch.bmm(queries, keys.transpose(1, 2)) / math.sqrt(d)
        self.attention_weights = masked_softmax(scores, valid_lens)
        return torch.bmm(self.dropout(self.attention_weights), values)

14. 包含注意力机制的序列到序列学习

图解谷歌神经机器翻译核心部分:注意力机制

将注意力机制应用到序列到序列学习的经典方法有两种:Bahdanau 和 Luong

  1. Bahdanau注意力 的实现: machine_translation_with_bahdanau_attention.py

  2. Luong注意力。

15. 多头注意力

15.1 多头注意力的本质

在实践中,当给定相同的查询、键和值的集合时,我们希望模型可以基于相同的注意力机制学习到不同的行为,然后将不同的行为作为知识组合起来,例如捕获序列内各种范围的依赖关系(例如,短距离依赖和长距离依赖)。因此,允许注意力机制组合使用查询、键和值的不同 子空间表示(representation subspaces) 可能是有益的。

为此,与使用单独一个注意力汇聚不同,我们可以用独立学习得到的 $h$ 组不同的 线性投影(linear projections) 来变换查询、键和值。然后,这 $h$ 组变换后的查询、键和值将并行地送到注意力汇聚中。最后,将这 $h$ 个注意力汇聚的输出拼接在一起,并且通过另一个可以学习的线性投影进行变换,以产生最终输出。这种设计被称为 多头注意力 ,其中 $h$ 个注意力汇聚输出中的每一个输出都被称作一个 头(head) 。下图展示了使用全连接层来实现可学习的线性变换的多头注意力。

多头注意力

15.2 多头注意力的数学描述

给定查询 $\mathbf{q} \in \mathbb{R}^{d_q}$、键 $\mathbf{k} \in \mathbb{R}^{d_k}$ 和值 $\mathbf{v} \in \mathbb{R}^{d_v}$,每个注意力头 $\mathbf{h}_i$ ($i = 1, \ldots, h$) 的计算方法为:

其中,可学习的参数包括 $\mathbf W_i^{(q)}\in\mathbb R^{p_q\times d_q}$、$\mathbf W_i^{(k)}\in\mathbb R^{p_k\times d_k}$ 和 $\mathbf W_i^{(v)}\in\mathbb R^{p_v\times d_v}$ ,以及代表注意力汇聚的函数 $f$ 。$f$ 可以加性注意力缩放点积注意力。多头注意力的输出需要经过另一个线性转换,它对应着 $h$ 个头连结后的结果,因此其可学习参数是 $\mathbf W_o\in\mathbb R^{p_o\times h p_v}$:

15.3 多头注意力的实现

class MultiHeadAttention(nn.Module):
    def __init__(self, key_size, query_size, value_size, num_hiddens, num_heads, dropout, bias=False, **kwargs):
        super(MultiHeadAttention, self).__init__(**kwargs)
        self.num_heads = num_heads
        self.attention = DotProductAttention(dropout)
        self.W_q = nn.Linear(query_size, num_hiddens, bias=bias)
        self.W_k = nn.Linear(key_size, num_hiddens, bias=bias)
        self.W_v = nn.Linear(value_size, num_hiddens, bias=bias)
        self.W_o = nn.Linear(num_hiddens, num_hiddens, bias=bias)

    def forward(self, queries, keys, values, valid_lens):
        # `queries`, `keys`, or `values` 的形状:
        # (`batch_size`, 查询或者"键-值"对的个数, `num_hiddens`)
        # `valid_lens` 的形状:
        # (`batch_size`,) or (`batch_size`, 查询的个数)
        # 经过变换后,输出的 `queries`, `keys`, or `values` 的形状:
        # (`batch_size` * `num_heads`, 查询或者"键-值"对的个数,
        # `num_hiddens` / `num_heads`)
        queries = transpose_qkv(self.W_q(queries), self.num_heads)
        keys = transpose_qkv(self.W_k(keys), self.num_heads)
        values = transpose_qkv(self.W_v(values), self.num_heads)

        if valid_lens is not None:
            # 在轴 0,将第一项(标量或者矢量)复制 `num_heads` 次,
            # 然后如此复制第二项,然后诸如此类。
            valid_lens = torch.repeat_interleave(valid_lens, repeats=self.num_heads, dim=0)

        # `output` 的形状: (`batch_size` * `num_heads`, 查询的个数,
        # `num_hiddens` / `num_heads`)
        output = self.attention(queries, keys, values, valid_lens)

        # `output_concat` 的形状: (`batch_size`, 查询的个数, `num_hiddens`)
        output_concat = transpose_output(output, self.num_heads)
        return self.W_o(output_concat)


def transpose_qkv(X, num_heads):
    # 输入 `X` 的形状: (`batch_size`, 查询或者"键-值"对的个数, `num_hiddens`).
    # 输出 `X` 的形状: (`batch_size`, 查询或者"键-值"对的个数, `num_heads`,
    # `num_hiddens` / `num_heads`)
    X = X.reshape(X.shape[0], X.shape[1], num_heads, -1)

    # 输出 `X` 的形状: (`batch_size`, `num_heads`, 查询或者"键-值"对的个数,
    # `num_hiddens` / `num_heads`)
    X = X.permute(0, 2, 1, 3)

    # `output` 的形状: (`batch_size` * `num_heads`, 查询或者"键-值"对的个数,
    # `num_hiddens` / `num_heads`)
    return X.reshape(-1, X.shape[2], X.shape[3])


def transpose_output(X, num_heads):
    """逆转 `transpose_qkv` 函数的操作"""
    X = X.reshape(-1, num_heads, X.shape[1], X.shape[2])
    X = X.permute(0, 2, 1, 3)
    return X.reshape(X.shape[0], X.shape[1], -1)

16. 自注意力

16.1 自注意力的原理

包含注意力机制的序列到序列学习中,如果把输入数据同时作为查询、键和值,就是自注意力,也称为 内部注意力(intra-attention)

16.2 自注意力的数学描述

给定一个由词元组成的输入序列 $\mathbf{x}_1, \ldots, \mathbf{x}_n$,其中任意 $\mathbf{x}_i \in \mathbb{R}^d$ ($1 \leq i \leq n$)。该序列的自注意力输出为一个长度相同的序列 $\mathbf{y}_1, \ldots, \mathbf{y}_n$,其中:

17. 自注意力对比CNN和RNN

自注意力对比CNN和RNN

CNN RNN 自注意力
计算复杂度 O( ) O( ) O( )
并⾏度 O( ) O( ) O( )
最⻓路径 O( ) O( ) O( )

说明:

  • k 为卷积层卷积核的大小。
  • n 为序列长度。
  • d 为输入和输出的通道数量。

文章作者: Kiba Amor
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 Kiba Amor !
  目录