# 四、自然语言处理的前馈网络

> 本文标题：[Natural-Language-Processing-with-PyTorch（四）](https://yifdu.github.io/2018/12/20/Natural-Language-Processing-with-PyTorch%EF%BC%88%E5%9B%9B%EF%BC%89/)
> 
> 文章作者：[Yif Du](https://yifdu.github.io/ "访问 Yif Du 的个人博客")
> 
> 发布时间：2018 年 12 月 20 日 - 09:12
> 
> 最后更新：2018 年 12 月 28 日 - 11:12
> 
> 原始链接：[http://yifdu.github.io/2018/12/20/Natural-Language-Processing-with-PyTorch（四）/](https://yifdu.github.io/2018/12/20/Natural-Language-Processing-with-PyTorch%EF%BC%88%E5%9B%9B%EF%BC%89/)
> 
> 许可协议：[署名-非商业性使用-禁止演绎 4.0 国际](https://creativecommons.org/licenses/by-nc-nd/4.0/)  转载请保留原文链接及作者。

在第 3 章中，我们通过观察感知器来介绍神经网络的基础，感知器是现存最简单的神经网络。感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如，查看图 4-1 中绘制的数据点。这相当于非此即彼（XOR）的情况，在这种情况下，决策边界不能是一条直线（也称为线性可分）。在这个例子中，感知器失败了。 

![classifier](img/8dc539599dfa8638d1c07a77f68a8ed6.jpg)

在这一章中，我们将探索传统上称为前馈网络的神经网络模型，以及两种前馈神经网络:多层感知器和卷积神经网络。多层感知器在结构上扩展了我们在第 3 章中研究的简单感知器，将多个感知器分组在一个单层，并将多个层叠加在一起。我们稍后将介绍多层感知器，并在“示例:带有多层感知器的姓氏分类”中展示它们在多层分类中的应用。

本章研究的第二种前馈神经网络，卷积神经网络，在处理数字信号时深受窗口滤波器的启发。通过这种窗口特性，卷积神经网络能够在输入中学习局部化模式，这不仅使其成为计算机视觉的主轴，而且是检测单词和句子等序列数据中的子结构的理想候选。我们在“卷积神经网络”中概述了卷积神经网络，并在“示例:使用 CNN 对姓氏进行分类”中演示了它们的使用。

在本章中，多层感知器和卷积神经网络被分组在一起，因为它们都是前馈神经网络，并且与另一类神经网络——循环神经网络（RNNs）形成对比，循环神经网络（RNNs）允许反馈（或循环），这样每次计算都可以从之前的计算中获得信息。在第 6 章和第 7 章中，我们将介绍 RNNs 以及为什么允许网络结构中的循环是有益的。

在我们介绍这些不同的模型时，确保您理解事物如何工作的一个有用方法是在计算数据张量时注意它们的大小和形状。每种类型的神经网络层对它所计算的数据张量的大小和形状都有特定的影响，理解这种影响可以极大地有助于对这些模型的深入理解。

## 多层感知机

多层感知器（MLP）被认为是最基本的神经网络构建模块之一。最简单的 MLP 是对第 3 章感知器的扩展。感知器将数据向量作为输入，计算出一个输出值。在 MLP 中，许多感知器被分组，以便单个层的输出是一个新的向量，而不是单个输出值。在 PyTorch 中，正如您稍后将看到的，这只需设置线性层中的输出特性的数量即可完成。MLP 的另一个方面是，它将多个层与每个层之间的非线性结合在一起。

最简单的 MLP，如图 4-2 所示，由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。在“示例:对餐馆评论的情绪进行分类”中，输入向量是 Yelp 评论的一个收缩的单热表示。给定输入向量，第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用，是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量，第二个线性层计算一个输出向量。在像 Yelp 评论分类这样的二进制任务中，输出向量仍然可以是 1。在多类设置中，您将在本章后面的“示例:带有多层感知器的姓氏分类”一节中看到，输出向量是类数量的大小。虽然在这个例子中，我们只展示了一个隐藏的向量，但是有可能有多个中间阶段，每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。

![MLP](img/fbf11b4ef1911b77ba3253eec8dddeef.jpg)

mlp 的力量来自于添加第二个线性层和允许模型学习一个线性分割的的中间表示——该属性的能表示一个直线（或更一般的,一个超平面）可以用来区分数据点落在线（或超平面）的哪一边的。学习具有特定属性的中间表示，如分类任务是线性可分的，这是使用神经网络的最深刻后果之一，也是其建模能力的精髓。在下一节中，我们将更深入地研究这意味着什么。

### 简单示例：XOR

让我们看一下前面描述的 XOR 示例，看看感知器与 MLP 之间会发生什么。在这个例子中，我们在一个二元分类任务中训练感知器和 MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下，最终的模型预测如图 4-3 所示。在这个图中，错误分类的数据点用黑色填充，而正确分类的数据点没有填充。在左边的面板中，从填充的形状可以看出，感知器在学习一个可以将星星和圆分开的决策边界方面有困难。然而，MLP（右面板）学习了一个更精确地对恒星和圆进行分类的决策边界。

![MLP_1](img/63f1236fcd5854c18f80493f93cbfe3c.jpg)

虽然在图中显示 MLP 有两个决策边界，这是它的优点，但它实际上只是一个决策边界!决策边界就是这样出现的，因为中间表示法改变了空间，使一个超平面同时出现在这两个位置上。在图 4-4 中，我们可以看到 MLP 计算的中间值。这些点的形状表示类（星形或圆形）。我们所看到的是，神经网络（本例中为 MLP）已经学会了“扭曲”数据所处的空间，以便在数据通过最后一层时，用一线来分割它们。  MLP 的输入和中间表示是可视化的。从左到右:（1）网络的输入，（2）第一个线性模块的输出，（3）第一个非线性模块的输出，（4）第二个线性模块的输出。如您所见，第一个线性模块的输出将圆和星分组，而第二个线性模块的输出将数据点重新组织为线性可分的。

相反，如图 4-5 所示，感知器没有额外的一层来处理数据的形状，直到数据变成线性可分的。 

![MLP_3](img/a0cec65c8e77beeb01f46f5fef1a61ad.jpg)

### 在 PyTorch 中实现 MLP

在上一节中，我们概述了 MLP 的核心思想。在本节中，我们将介绍 PyTorch 中的一个实现。如前所述，MLP 除了第 3 章中简单的感知器之外，还有一个额外的计算层。在我们在例 4-1 中给出的实现中，我们用 PyTorch 的两个线性模块实例化了这个想法。线性对象被命名为`fc1`和`fc2`，它们遵循一个通用约定，即将线性模块称为“完全连接层”，简称为“fc 层”。除了这两个线性层外，还有一个修正的线性单元（ReLU）非线性（在第 3 章“激活函数”一节中介绍），它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性，您必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的，因为没有它，两个线性层在数学上等价于一个线性层 4，因此不能建模复杂的模式。MLP 的实现只实现反向传播的前向传递。这是因为 PyTorch 根据模型的定义和向前传递的实现，自动计算出如何进行向后传递和梯度更新。

示例 4-1：多层感知机

```py
import torch.nn as nn
import torch.nn.functional as F

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        Args:
            input_dim (int): the size of the input vectors
            hidden_dim (int): the output size of the first Linear layer
            output_dim (int): the output size of the second Linear layer
        """
        super(MultilayerPerceptron, self).__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x_in, apply_softmax=False):
        """The forward pass of the MLP

        Args:
            x_in (torch.Tensor): an input data tensor.
                x_in.shape should be (batch, input_dim)
            apply_softmax (bool): a flag for the softmax activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))
        output = self.fc2(intermediate)

        if apply_softmax:
            output = F.softmax(output, dim=1)
        return output

```

在例 4-2 中，我们实例化了 MLP。由于 MLP 实现的通用性，我们可以为任何大小的输入建模。为了演示，我们使用大小为 3 的输入维度、大小为 4 的输出维度和大小为 100 的隐藏维度。请注意，在`print`语句的输出中，每个层中的单元数很好地排列在一起，以便为维度 3 的输入生成维度 4 的输出。

示例 4-2：NLP 的示例实例化

```py
Input[0]
batch_size = 2 # number of samples input at once
input_dim = 3
hidden_dim = 100
output_dim = 4

# Initialize model
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
print(mlp)
Output[0]
MultilayerPerceptron(
  (fc1): Linear(in_features=3, out_features=100, bias=True)
  (fc2): Linear(in_features=100, out_features=4, bias=True)
  (relu): ReLU()
)

```

我们可以通过传递一些随机输入来快速测试模型的“连接”，如示例 4-3 所示。因为模型还没有经过训练，所以输出是随机的。在花费时间训练模型之前，这样做是一个有用的完整性检查。请注意 PyTorch 的交互性是如何让我们在开发过程中实时完成所有这些工作的，这与使用 NumPy 或 panda 没有太大区别:

示例 4-3：使用随机输入测试 MLP

```py
Input[0]
def describe(x):
    print("Type: {}".format(x.type()))
    print("Shape/size: {}".format(x.shape))
    print("Values: \n{}".format(x))

x_input = torch.rand(batch_size, input_dim)
describe(x_input)
Output[0]
Type: torch.FloatTensor
Shape/size: torch.Size([2, 3])
Values:
tensor([[ 0.8329,  0.4277,  0.4363],
        [ 0.9686,  0.6316,  0.8494]])
Input[1]
y_output = mlp(x_input, apply_softmax=False)
describe(y_output)
Output[1]
Type: torch.FloatTensor
Shape/size: torch.Size([2, 4])
Values:
tensor([[-0.2456,  0.0723,  0.1589, -0.3294],
        [-0.3497,  0.0828,  0.3391, -0.4271]])

```

学习如何读取 PyTorch 模型的输入和输出非常重要。在前面的例子中，MLP 模型的输出是一个有两行四列的张量。这个张量中的行与批量维数对应，批量维数是小批量中的数据点的数量。列是每个数据点的最终特征向量。在某些情况下，例如在分类设置中，特征向量是一个预测向量。名称为“预测向量”表示它对应于一个概率分布。预测向量会发生什么取决于我们当前是在进行训练还是在执行推理。在训练期间，输出按原样使用，带有一个损失函数和目标类标签的表示。我们将在“示例:带有多层感知器的姓氏分类”中对此进行深入介绍。

但是，如果您想将预测向量转换为概率，则需要额外的步骤。具体来说，您需要 softmax 函数，它用于将一个值向量转换为概率。softmax 有许多根。在物理学中，它被称为玻尔兹曼或吉布斯分布;在统计学中，它是多项式逻辑回归;在自然语言处理（NLP）社区，它是最大熵（MaxEnt）分类器。不管叫什么名字，这个函数背后的直觉是，大的正值会导致更高的概率，小的负值会导致更小的概率。在示例 4-3 中，`apply_softmax`参数应用了这个额外的步骤。在例 4-4 中，您可以看到相同的输出，但是这次将`apply_softmax`标志设置为`True`:

示例 4-4：带有`apply_softmax=True`的 MLP

```py
Input[0]
y_output = mlp(x_input, apply_softmax=True)
describe(y_output)
Output[0]
Type: torch.FloatTensor
Shape/size: torch.Size([2, 4])
Values:
tensor([[ 0.2087,  0.2868,  0.3127,  0.1919],
        [ 0.1832,  0.2824,  0.3649,  0.1696]])

```

综上所述，mlp 是将张量映射到其他张量的线性层。在每一对线性层之间使用非线性来打破线性关系，并允许模型扭曲向量空间。在分类设置中，这种扭曲应该导致类之间的线性可分性。另外，您可以使用 softmax 函数将 MLP 输出解释为概率，但是不应该将 softmax 与特定的损失函数一起使用，因为底层实现可以利用高级数学/计算捷径。

# Example: Surname Classification with a Multilayer Perceptron

在本节中，我们将 MLP 应用于将姓氏分类到其原籍国的任务。从公开观察到的数据推断人口统计信息（如国籍）具有从产品推荐到确保不同人口统计用户获得公平结果的应用。人口统计和其他自我识别信息统称为“受保护属性”。“在建模和产品中使用这些属性时，必须小心。”我们首先对每个姓氏的字符进行拆分，并像对待“示例:将餐馆评论的情绪分类”中的单词一样对待它们。除了数据上的差异，字符层模型在结构和实现上与基于单词的模型基本相似.

您应该从这个例子中吸取的一个重要教训是，MLP 的实现和训练是从我们在第 3 章中看到的感知器的实现和训练直接发展而来的。事实上，我们在本书的第 3 章中提到了这个例子，以便更全面地了解这些组件。此外，我们不包括你可以在“例子:餐馆评论的情绪分类”中看到的代码，如果你想在一个地方看到例子代码，我们强烈建议你跟随补充材料。

本节的其余部分将从姓氏数据集及其预处理步骤的描述开始。然后，我们使用`Vocabulary`，`Vectorizer`和`DataLoader`类逐步完成从姓氏字符串到向量化小批量的管道。如果您通读了第 3 章，您应该将这些辅助类视为老朋友，只是做了一些小小的修改。

我们将通过描述姓氏分类器模型及其设计背后的思想过程来继续本节。MLP 类似于我们在第 3 章中看到的感知器例子，但是除了模型的改变，我们在这个例子中引入了多类输出及其对应的损失函数。在描述了模型之后，我们完成了训练例程。训练程序与您在“示例:对餐馆评论的情绪进行分类”中看到的非常相似，因此为了简洁起见，我们在这里不像在该部分中那样深入。我们强烈建议您回顾这一节以获得更多的澄清。

### 姓氏数据集

在这个例子中，我们介绍了一个姓氏数据集，它收集了来自 18 个不同国家的 10,000 个姓氏，这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本书的几个示例中重用，并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的 60% 以上:27% 是英语，21% 是俄语，14% 是阿拉伯语。剩下的 15 个民族的频率也在下降——这也是语言特有的特性。第二个特点是，在国籍和姓氏正字法（拼写）之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密（比如`O'Neill`、`Antonopoulos`、`Nagasawa`或`Zhu`）。

为了创建最终的数据集，我们从一个比本书补充材料中包含的版本处理更少的版本开始，并执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中 70% 以上是俄文，这可能是由于抽样偏差或俄文姓氏的增多。为此，我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来，我们根据国籍对数据集进行分组，并将数据集分为三个部分:70% 到训练数据集，15% 到验证数据集，最后 15% 到测试数据集，以便跨这些部分的类标签分布具有可比性。

`SurnameDataset`的实现与“示例：餐馆评论的情感分类”中的`ReviewDataset`几乎相同，只是在`__getitem__`方法的实现方式上略有不同。回想一下，本书中呈现的数据集类继承自 PyTorch 的数据集类，因此，我们需要实现两个函数:`__getitem__`方法，它在给定索引时返回一个数据点;以及`__len__`方法，该方法返回数据集的长度。“示例:餐厅评论的情绪分类”中的示例与本示例的区别在`__getitem__`中，如示例 4-5 所示。它不像“示例:将餐馆评论的情绪分类”那样返回一个向量化的评论，而是返回一个向量化的姓氏和与其国籍相对应的索引:

示例 4-5：实现`SurnameDataset.__getitem__()`

```py
class SurnameDataset(Dataset):
    # Implementation is nearly identical to Section 3.5

    def __getitem__(self, index):
        row = self._target_df.iloc[index]
        surname_vector = \
            self._vectorizer.vectorize(row.surname)
        nationality_index = \
            self._vectorizer.nationality_vocab.lookup_token(row.nationality)

        return {'x_surname': surname_vector,
                'y_nationality': nationality_index}

```

### `Vocabulary`，`Vectorizer`和`DataLoader`

为了使用字符对姓氏进行分类，我们使用`Vocabulary`，`Vectorizer`和`DataLoader`将姓氏字符串转换为向量化的小批量。这些数据结构与“示例：餐馆评论的情感分类”中使用的数据结构相同，它们举例说明了一种多态性，这种多态性将姓氏的字符标记与 Yelp 评论的单词标记相同对待。数据不是通过将字标记映射到整数来向量化的，而是通过将字符映射到整数来向量化的。

## 词汇表的类

本例中使用的词汇类与“示例：餐馆评论的情感分类”中的词汇完全相同，该词汇类将 Yelp 评论中的单词映射到对应的整数。简要概述一下，词汇表是两个 Python 字典的协调，这两个字典在标记（在本例中是字符）和整数之间形成一个双射;也就是说，第一个字典将字符映射到整数索引，第二个字典将整数索引映射到字符。`add_token`方法用于向词汇表中添加新的标记，`lookup_token`方法用于检索索引，`lookup_index`方法用于检索给定索引的标记（在推断阶段很有用）。与 Yelp 评论的词汇表不同，我们使用的是单热词汇表，不计算字符出现的频率，只对频繁出现的条目进行限制。这主要是因为数据集很小，而且大多数字符足够频繁。

## `SurnameVectorizer`

虽然词汇表将单个标记（字符）转换为整数，但`SurnameVectorizer`负责应用词汇表并将姓氏转换为向量。实例化和使用非常类似于“示例:对餐馆评论的情绪进行分类”中的`ReviewVectorizer`，但有一个关键区别:字符串没有在空格上分割。姓氏是字符的序列，每个字符在我们的词汇表中是一个单独的标记。然而，在“卷积神经网络”出现之前，我们将忽略序列信息，通过迭代字符串输入中的每个字符来创建输入的收缩单热向量表示。我们为以前未遇到的字符指定一个特殊的标记，即`UNK`。由于我们仅从训练数据实例化词汇表，而且验证或测试数据中可能有惟一的字符，所以在字符词汇表中仍然使用`UNK`符号。

您应该注意，虽然我们在这个示例中使用了收缩的单热，但是在后面的章节中，您将了解其他向量化方法，它们是单热编码的替代方法，有时甚至更好。具体来说，在“示例:使用 CNN 对姓氏进行分类”中，您将看到一个热门矩阵，其中每个字符都是矩阵中的一个位置，并具有自己的热门向量。然后，在第 5 章中，您将学习嵌入层，返回整数向量的向量化，以及如何使用它们创建密集向量矩阵。但是现在，让我们看一下示例 4-6 中`SurnameVectorizer`的代码。

示例 4-6：实现`SurnameVectorizer`

```py
class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""
    def __init__(self, surname_vocab, nationality_vocab):
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

    def vectorize(self, surname):
        """Vectorize the provided surname

        Args:
            surname (str): the surname
        Returns:
            one_hot (np.ndarray): a collapsed onehot encoding
        """
        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1
        return one_hot

    @classmethod
    def from_dataframe(cls, surname_df):
        """Instantiate the vectorizer from the dataset dataframe

        Args:
            surname_df (pandas.DataFrame): the surnames dataset
        Returns:
            an instance of the SurnameVectorizer
        """
        surname_vocab = Vocabulary(unk_token="@")
        nationality_vocab = Vocabulary(add_unk=False)

        for index, row in surname_df.iterrows():
            for letter in row.surname:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(surname_vocab, nationality_vocab)

```

### 姓氏分类器模型

`SurnameClassifier`是本章前面介绍的 MLP 的实现（示例 4-7）。第一个线性层将输入向量映射到中间向量，并对该向量应用非线性。第二线性层将中间向量映射到预测向量。

在最后一步中，可选地应用 softmax 操作，以确保输出和为 1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下，交叉熵损失对于多类分类是最理想的，但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。

示例 4-7：作为 MLP 的`SurnameClassifier`

```py
import torch.nn as nn
import torch.nn.functional as F

class SurnameClassifier(nn.Module):
    """ A 2-layer Multilayer Perceptron for classifying surnames """
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        Args:
            input_dim (int): the size of the input vectors
            hidden_dim (int): the output size of the first Linear layer
            output_dim (int): the output size of the second Linear layer
        """
        super(SurnameClassifier, self).__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x_in, apply_softmax=False):
        """The forward pass of the classifier

        Args:
            x_in (torch.Tensor): an input data tensor.
                x_in.shape should be (batch, input_dim)
            apply_softmax (bool): a flag for the softmax activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch, output_dim)
        """
        intermediate_vector = F.relu(self.fc1(x_in))
        prediction_vector = self.fc2(intermediate_vector)

        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)

        return prediction_vector

```

### 训练例程

虽然我们使用了不同的模型、数据集和损失函数，但是训练例程是相同的。因此，在例 4-8 中，我们只展示了`args`以及本例中的训练例程与“示例:餐厅评论情绪分类”中的示例之间的主要区别。

示例 4-8：用于使用 MLP 分类姓氏的参数

```py
args = Namespace(
    # Data and path information
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/surname_mlp",
    # Model hyper parameters
    hidden_dim=300
    # Training  hyper parameters
    seed=1337,
    num_epochs=100,
    early_stopping_criteria=5,
    learning_rate=0.001,
    batch_size=64,
    # Runtime options omitted for space
)

```

训练中最显著的差异与模型中输出的种类和使用的损失函数有关。在这个例子中，输出是一个多类预测向量，可以转换为概率。正如在模型描述中所描述的，这种输出的损失类型仅限于`CrossEntropyLoss`和`NLLLoss`。由于它的简化，我们使用了`CrossEntropyLoss`。

在例 4-9 中，我们展示了数据集、模型、损失函数和优化器的实例化。这些实例应该看起来与“示例:将餐馆评论的情绪分类”中的实例几乎相同。事实上，在本书后面的章节中，这种模式将对每个示例进行重复。

示例 4-9：实例化数据集，模型，损失和优化器

```py
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
vectorizer = dataset.get_vectorizer()

classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                               hidden_dim=args.hidden_dim,
                               output_dim=len(vectorizer.nationality_vocab))

classifier = classifier.to(args.device)    

loss_func = nn.CrossEntropyLoss(dataset.class_weights)
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)

```

## 训练循环

与“示例：餐馆评论的情感分类”中的训练循环相比，本例的训练循环除了变量名以外几乎是相同的。具体来说，示例 4-10 显示了使用不同的键从`batch_dict`中获取数据。除了外观上的差异，训练循环的功能保持不变。利用训练数据，计算模型输出、损失和梯度。然后，使用梯度来更新模型。

示例 4-10：训练循环的代码段

```py
# the training routine is these 5 steps:

# --------------------------------------
# step 1\. zero the gradients
optimizer.zero_grad()

# step 2\. compute the output
y_pred = classifier(batch_dict['x_surname'])

# step 3\. compute the loss
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_batch = loss.to("cpu").item()
running_loss += (loss_batch - running_loss) / (batch_index + 1)

# step 4\. use loss to produce gradients
loss.backward()

# step 5\. use optimizer to take gradient step
optimizer.step()

```

### 模型评估和预测

要理解模型的性能，您应该使用定量和定性方法分析模型。定量测量出的测试数据的误差，决定了分类器能否推广到不可见的例子。定性地说，您可以通过查看分类器的前`k`个预测来为一个新示例开发模型所了解的内容的直觉。 

## 在测试数据集上评估


评价`SurnameClassifier`测试数据,我们执行文本分类的例子“餐馆评论的例子:分类情绪”的相同的常规例程:我们将数据集设置为遍历测试数据,调用`classifier.eval()`方法,并遍历测试数据以同样的方式与其他数据。在这个例子中，调用`classifier.eval()`可以防止 PyTorch 在使用测试/评估数据时更新模型参数。

该模型对测试数据的准确性达到 50% 左右。如果您在附带的笔记本中运行训练例程，您会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据，所以训练数据的性能并不代表新数据的性能。如果您遵循代码，我们鼓励您尝试隐藏维度的不同大小。您应该注意到性能的提高。然而，这种增长不会很大（尤其是与“用 CNN 对姓氏进行分类的例子”中的模型相比）。其主要原因是收缩的单热向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量，但它丢弃了字符之间的顺序信息，这对于识别起源非常重要。

CLASSIFYING A NEW SURNAME

示例 4-11 显示了分类新姓氏的代码。给定一个姓氏作为字符串，该函数将首先应用向量化过程，然后获得模型预测。注意，我们包含了`apply_softmax`标志，所以结果包含概率。模型预测，在多项式的情况下，是类概率的列表。我们使用 PyTorch 张量最大函数来得到由最高预测概率表示的最优类。

示例 4-11：执行国籍预测的函数

```py
def predict_nationality(name, classifier, vectorizer):
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    result = classifier(vectorized_name, apply_softmax=True)

    probability_values, indices = result.max(dim=1)
    index = indices.item()

    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    probability_value = probability_values.item()

    return {'nationality': predicted_nationality,
            'probability': probability_value}

```

## 为新的姓氏获取前`k`个预测

RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME 

不仅要看最好的预测，还要看更多的预测。例如，NLP 中的标准实践是采用`k`个最佳预测并使用另一个模型对它们重新排序。PyTorch 提供了一个`torch.topk`函数，它提供了一种方便的方法来获得这些预测，如示例 4-12 所示。

示例 4-12：预测前`k`个国籍

```py
def predict_topk_nationality(name, classifier, vectorizer, k=5):
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    probability_values, indices = torch.topk(prediction_vector, k=k)

    # returned size is 1,k
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]

    results = []
    for prob_value, index in zip(probability_values, indices):
        nationality = vectorizer.nationality_vocab.lookup_index(index)
        results.append({'nationality': nationality,
                        'probability': prob_value})

    return results

```

### MLPs 正则化：权重正则化和结构化正则化（或丢弃）

在第三章中，我们解释了正则化是如何解决过拟合问题的，并研究了两种重要的权重正则化类型——l1 和 L2。这些权值正则化方法也适用于 MLPs 和卷积神经网络，我们将在本章后面介绍。除权值正则化外，对于深度模型（即例如本章讨论的前馈网络，一种称为丢弃的结构正则化方法变得非常重要。

DROPOUT

简单地说，在训练过程中，丢弃有一定概率使属于两个相邻层的单元之间的连接减弱。这有什么用呢?我们从斯蒂芬•梅里蒂（Stephen Merity）的一段直观（且幽默）的解释开始: “丢弃，简单地说，是指如果你能在喝醉的时候反复学习如何做一件事，那么你应该能够在清醒的时候做得更好。这一见解产生了许多最先进的结果和一个新兴的领域。” 神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“共同适应”（Coadaptation）是神经科学中的一个术语，但在这里它只是指一种情况，即两个单元之间的联系变得过于紧密，而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接，我们可以确保没有一个单元总是依赖于另一个单元，从而产生健壮的模型。丢弃不会向模型中添加额外的参数，但是需要一个超参数——“丢弃概率”。丢弃概率，正如你可能已经猜到的，是单位之间的连接被丢弃的概率。通常将下降概率设置为 0.5。例 4-13 给出了一个带丢弃的 MLP 的重新实现。 

示例 4-13：带有丢弃的 MLP

```py
import torch.nn as nn
import torch.nn.functional as F

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        Args:
            input_dim (int): the size of the input vectors
            hidden_dim (int): the output size of the first Linear layer
            output_dim (int): the output size of the second Linear layer
        """
        super(MultilayerPerceptron, self).__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x_in, apply_softmax=False):
        """The forward pass of the MLP

        Args:
            x_in (torch.Tensor): an input data tensor.
                x_in.shape should be (batch, input_dim)
            apply_softmax (bool): a flag for the softmax activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))
        output = self.fc2(F.dropout(intermediate, p=0.5))

        if apply_softmax:
            output = F.softmax(output, dim=1)
        return output

```

请注意，丢弃只适用于训练期间，不适用于评估期间。作为练习，我们鼓励您试验带有丢弃的`SurnameClassifier`模型，看看它如何更改结果。

## 卷积神经网络

在本章的第一部分中，我们深入研究了 MLPs、由一系列线性层和非线性函数构建的神经网络。mlp 不是利用顺序模式的最佳工具。例如，在姓氏数据集中，姓氏可以有（不同长度的）段，这些段可以显示出相当多关于其起源国家的信息（如`O'Neill`中的`O`、`Antonopoulos`中的`opoulos`、`Nagasawa`中的`sawa`或`Zhu`中的`Zh`）。这些段的长度可以是可变的，挑战是在不显式编码的情况下捕获它们。

在本节中，我们将介绍卷积神经网络（CNN），这是一种非常适合检测空间子结构（并因此创建有意义的空间子结构）的神经网络。CNNs 通过使用少量的权重来扫描输入数据张量来实现这一点。通过这种扫描，它们产生表示子结构检测（或不检测）的输出张量。

在本节的其余部分中，我们首先描述 CNN 的工作方式，以及在设计 CNN 时应该考虑的问题。我们深入研究 CNN 超参数，目的是提供直观的行为和这些超参数对输出的影响。最后，我们通过几个简单的例子逐步说明 CNNs 的机制。在“示例:使用 CNN 对姓氏进行分类”中，我们将深入研究一个更广泛的示例。

## 历史背景

CNNs 的名称和基本功能源于经典的数学运算卷积。卷积已经应用于各种工程学科，包括数字信号处理和计算机图形学。一般来说，卷积使用程序员指定的参数。这些参数被指定来匹配一些功能设计，如突出边缘或抑制高频声音。事实上，许多 Photoshop 滤镜都是应用于图像的固定卷积运算。然而，在深度学习和本章中，我们从数据中学习卷积滤波器的参数，因此它对于解决当前的任务是最优的。

### CNN 超参数

为了理解不同的设计决策对 CNN 意味着什么，我们在图 4-6 中展示了一个示例。在本例中，单个“核”应用于输入矩阵。卷积运算（线性算子）的精确数学表达式对于理解这一节并不重要，但是从这个图中可以直观地看出，核是一个小的方阵，它被系统地应用于输入矩阵的不同位置。

虽然经典卷积是通过指定核的具体值来设计的，但是 CNN 是通过指定控制 CNN 行为的超参数来设计的，然后使用梯度下降来为给定数据集找到最佳参数。两个主要的超参数控制卷积的形状（称为`kernel_size`）和卷积将在输入数据张量（称为`stride`）中相乘的位置。还有一些额外的超参数控制输入数据张量被 0 填充了多少（称为`padding`），以及当应用到输入数据张量（称为`dilation`）时，乘法应该相隔多远。在下面的小节中，我们将更详细地介绍这些超参数。

## 卷积操作的维度

首先要理解的概念是卷积运算的维数。在图 4-6 和本节的其他图中，我们使用二维卷积进行说明，但是根据数据的性质，还有更适合的其他维度的卷积。在 PyTorch 中，卷积可以是一维、二维或三维的，分别由`Conv1d`、`Conv2d`和`Conv3d`模块实现。一维卷积对于每个时间步都有一个特征向量的时间序列非常有用。在这种情况下，我们可以在序列维度上学习模式。NLP 中的卷积运算大多是一维的卷积。另一方面，二维卷积试图捕捉数据中沿两个方向的时空模式;例如，在图像中沿高度和宽度维度——为什么二维卷积在图像处理中很流行。类似地，在三维卷积中，模式是沿着数据中的三维捕获的。例如，在视频数据中，信息是三维的，二维表示图像的帧，时间维表示帧的序列。就本书而言，我们主要使用`Conv1d`。

## 通道

非正式地，通道（channel）是指沿输入中的每个点的特征维度。例如，在图像中，对应于 RGB 组件的图像中的每个像素有三个通道。在使用卷积时，文本数据也可以采用类似的概念。从概念上讲，如果文本文档中的“像素”是单词，那么通道的数量就是词汇表的大小。如果我们更细粒度地考虑字符的卷积，通道的数量就是字符集的大小（在本例中刚好是词汇表）。在 PyTorch 卷积实现中，输入通道的数量是`in_channels`参数。卷积操作可以在输出（`out_channels`）中产生多个通道。您可以将其视为卷积运算符将输入特征维“映射”到输出特征维。图 4-7 和图 4-8 说明了这个概念。

很难立即知道有多少输出通道适合当前的问题。为了简化这个困难，我们假设边界是 1,1,024——我们可以有一个只有一个通道的卷积层，也可以有一个只有 1,024 个通道的卷积层。现在我们有了边界，接下来要考虑的是有多少个输入通道。一种常见的设计模式是，从一个卷积层到下一个卷积层，通道数量的缩减不超过 2 倍。这不是一个硬性的规则，但是它应该让您了解适当数量的`out_channels`是什么样子的。

## 核大小

核矩阵的宽度称为核大小（PyTorch 中的`kernel_size`）。在图 4-6 中，核大小为 2，而在图 4-9 中，我们显示了一个大小为 3 的内核。您应该形成的直觉是，卷积将输入中的空间（或时间）本地信息组合在一起，每个卷积的本地信息量由内核大小控制。然而，通过增加核的大小，也会减少输出的大小（Dumoulin 和 Visin, 2016）。这就是为什么当核大小为 3 时，输出矩阵是图 4-9 中的`2x2`，而当核大小为 2 时，输出矩阵是图 4-6 中的`3x3`。

此外，您可以将 NLP 应用程序中核大小的行为看作类似于通过查看单词组捕获语言模式的 N 元组的行为。使用较小的核大小，可以捕获较小的频繁模式，而较大的核大小会导致较大的模式，这可能更有意义，但是发生的频率更低。较小的核大小会导致输出中的细粒度特性，而较大的核大小会导致粗粒度特性。

## 跨步

跨步控制卷积之间的步长。如果步长与核相同，则内核计算不会重叠。另一方面，如果跨度为 1，则内核重叠最大。输出张量可以通过增加步幅的方式被有意的压缩来总结信息，如图 4-10 所示。 

## 填充

即使跨步和`kernel_size`允许控制每个计算出的特征值有多大范围，它们也有一个有害的、有时是无意的副作用，那就是缩小特征映射的总大小（卷积的输出）。为了抵消这一点，输入数据张量被人为地增加了长度（如果是一维、二维或三维）、高度（如果是二维或三维）和深度（如果是三维），方法是在每个维度上附加和前置 0。这意味着 CNN 将执行更多的卷积，但是输出形状可以控制，而不会影响所需的核大小、步幅或扩展。图 4-11 展示了正在运行的填充。 

## 膨胀

膨胀控制卷积核如何应用于输入矩阵。在图 4-12 中，我们显示，将膨胀从 1（默认值）增加到 2 意味着当应用于输入矩阵时，核的元素彼此之间是两个空格。另一种考虑这个问题的方法是在核中跨跃——在核中的元素或核的应用之间存在一个步长，即存在“洞”。这对于在不增加参数数量的情况下总结输入空间的更大区域是有用的。当卷积层被叠加时，扩张卷积被证明是非常有用的。连续扩张的卷积指数级地增大了“接受域”的大小;即网络在做出预测之前所看到的输入空间的大小。

### 在 PyTorch 实现 CNNs

在本节中，我们将通过端到端示例来利用上一节中介绍的概念。一般来说，神经网络设计的目标是找到一个能够完成任务的超参数组态。我们再次考虑在“示例:带有多层感知器的姓氏分类”中引入的现在很熟悉的姓氏分类任务，但是我们将使用 CNNs 而不是 MLP。我们仍然需要应用最后一个线性层，它将学会从一系列卷积层创建的特征向量创建预测向量。这意味着目标是确定卷积层的配置，从而得到所需的特征向量。所有 CNN 应用程序都是这样的:首先有一组卷积层，它们提取一个特征映射，然后将其作为上游处理的输入。在分类中，上游处理几乎总是应用线性（或 fc）层。

本节中的实现遍历设计决策，以构建一个特征向量。我们首先构造一个人工数据张量，以反映实际数据的形状。数据张量的大小是三维的——这是向量化文本数据的最小批大小。如果你对一个字符序列中的每个字符使用单热向量，那么单热向量序列就是一个矩阵，而单热矩阵的小批量就是一个三维张量。使用卷积的术语，每个单热向量（通常是词汇表的大小）的大小是输入通道的数量，字符序列的长度是宽度。

在例 4-14 中，构造特征向量的第一步是将 PyTorch 的`Conv1d`类的一个实例应用到三维数据张量。通过检查输出的大小，你可以知道张量减少了多少。我们建议您参考图 4-9 来直观地解释为什么输出张量在收缩。

示例 4-14：人造数据和使用`Conv1d`类

```py
Input[0]
batch_size = 2
one_hot_size = 10
sequence_width = 7
data = torch.randn(batch_size, one_hot_size, sequence_width)
conv1 = Conv1d(in_channels=one_hot_size, out_channels=16,
               kernel_size=3)
intermediate1 = conv1(data)
print(data.size())
print(intermediate1.size())
Output[0]
torch.Size([2, 10, 7])
torch.Size([2, 16, 5])

```

进一步减小输出张量的主要方法有三种。第一种方法是创建额外的卷积并按顺序应用它们。最终，对应的`sequence_width`（`dim=2`）维度的大小将为 1。我们在例 4-15 中展示了应用两个额外卷积的结果。一般来说，对输出张量的约简应用卷积的过程是迭代的，需要一些猜测工作。我们的示例是这样构造的:经过三次卷积之后，最终的输出在最终维度上的大小为 1。

示例 4-15：卷积在数据上的迭代应用

```py
Input[0]
conv2 = nn.Conv1d(in_channels=16, out_channels=32, kernel_size=3)
conv3 = nn.Conv1d(in_channels=32, out_channels=64, kernel_size=3)

intermediate2 = conv2(intermediate1)
intermediate3 = conv3(intermediate2)

print(intermediate2.size())
print(intermediate3.size())
Output[0]
torch.Size([2, 32, 3])
torch.Size([2, 64, 1])
Input[1]
y_output = intermediate3.squeeze()
print(y_output.size())
Output[1]
torch.Size([2, 64])

```

在每次卷积中，通道维数的大小都会增加，因为通道维数是每个数据点的特征向量。张量实际上是一个特征向量的最后一步是去掉讨厌的尺寸为 1 维。您可以使用`squeeze()`方法来实现这一点。该方法将删除`size=1`的所有维度并返回结果。然后，得到的特征向量可以与其他神经网络组件（如线性层）一起使用来计算预测向量。

另外还有两种方法可以将张量简化为每个数据点的一个特征向量:将剩余的值压平为特征向量，并在额外维度上求平均值。这两种方法如示例 4-16 所示。使用第一种方法，只需使用 PyTorch 的`view()`方法将所有向量平展成单个向量。第二种方法使用一些数学运算来总结向量中的信息。最常见的操作是算术平均值，但沿特征映射维数求和和使用最大值也是常见的。每种方法都有其优点和缺点。扁平化保留了所有的信息，但会导致比预期（或计算上可行）更大的特征向量。平均变得与额外维度的大小无关，但可能会丢失信息。

示例 4-16：用于归约到特征向量的两个相加方法

```py
Input[0]
# Method 2 of reducing to feature vectors
print(intermediate1.view(batch_size, -1).size())

# Method 3 of reducing to feature vectors
print(torch.mean(intermediate1, dim=2).size())
# print(torch.max(intermediate1, dim=2).size())
# print(torch.sum(intermediate1, dim=2).size())
Output[0]
torch.Size([2, 80])
torch.Size([2, 16])

```

这种设计一系列卷积的方法是基于经验的:从数据的预期大小开始，处理一系列卷积，最终得到适合您的特征向量。虽然这种方法在实践中效果很好，但在给定卷积的超参数和输入张量的情况下，还有另一种计算张量输出大小的方法，即使用从卷积运算本身推导出的数学公式。

## Example: Classifying Surnames by Using a CNN

为了证明 CNN 的有效性，让我们应用一个简单的 CNN 模型来分类姓氏。这项任务的许多细节与前面的 MLP 示例相同，但真正发生变化的是模型的构造和向量化过程。模型的输入，而不是我们在上一个例子中看到的收缩的单热向量，将是一个单热的矩阵。这种设计将使 CNN 能够更好地“查看”字符的排列，并对在“示例:带有多层感知器的姓氏分类”中使用的收缩的单热编码中丢失的序列信息进行编码。

### The SurnameDataset

虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述，但是我们建议您参考“姓氏数据集”来了解它的描述。尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集，但在实现上有一个不同之处:数据集由单热向量矩阵组成，而不是一个收缩的单热向量。为此，我们实现了一个数据集类，它跟踪最长的姓氏，并将其作为矩阵中包含的行数提供给向量化器。列的数量是单热向量的大小（词汇表的大小）。示例 4-17 显示了对`SurnameDataset.__getitem__`的更改;我们显示对`SurnameVectorizer`的更改。在下一小节向量化。

我们使用数据集中最长的姓氏来控制单热矩阵的大小有两个原因。首先，将每一小批姓氏矩阵组合成一个三维张量，要求它们的大小相同。其次，使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批量。

示例 4-17：为传递最大姓氏长度而修改的`SurnameDataset`

```py
class SurnameDataset(Dataset):
    # ... existing implementation from Section 4.2

    def __getitem__(self, index):
        row = self._target_df.iloc[index]

        surname_matrix = \
            self._vectorizer.vectorize(row.surname, self._max_seq_length)

        nationality_index = \
             self._vectorizer.nationality_vocab.lookup_token(row.nationality)

        return {'x_surname': surname_matrix,
                'y_nationality': nationality_index}

```

### `Vocabulary`，`Vectorizer`和`DataLoader`

在本例中，尽管词汇表和`DataLoader`的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同，但`Vectorizer`的`vectorize()`方法已经更改，以适应 CNN 模型的需要。具体来说，正如我们在示例 4-18 中的代码中所示，该函数将字符串中的每个字符映射到一个整数，然后使用该整数构造一个由单热向量组成的矩阵。重要的是，矩阵中的每一列都是不同的单热向量。主要原因是，我们将使用的`Conv1d`层要求数据张量在第 0 维上具有批量，在第 1 维上具有通道，在第 2 维上具有特性。

除了更改为使用单热矩阵之外，我们还修改了向量化器，以便计算姓氏的最大长度并将其保存为`max_surname_length`

示例 4-18：为 CNN 实现姓氏向量化器

```py
class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""
    def vectorize(self, surname):
        """
        Args:
            surname (str): the surname
        Returns:
            one_hot_matrix (np.ndarray): a matrix of onehot vectors
        """

        one_hot_matrix_size = (len(self.character_vocab), self.max_surname_length)
        one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)

        for position_index, character in enumerate(surname):
            character_index = self.character_vocab.lookup_token(character)
            one_hot_matrix[character_index][position_index] = 1

        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        """Instantiate the vectorizer from the dataset dataframe

        Args:
            surname_df (pandas.DataFrame): the surnames dataset
        Returns:
            an instance of the SurnameVectorizer
        """
        character_vocab = Vocabulary(unk_token="@")
        nationality_vocab = Vocabulary(add_unk=False)
        max_surname_length = 0

        for index, row in surname_df.iterrows():
            max_surname_length = max(max_surname_length, len(row.surname))
            for letter in row.surname:
                character_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(character_vocab, nationality_vocab, max_surname_length)

```

### 使用 CNN 重新实现`SurnameClassifier`

我们在本例中使用的模型是使用我们在“卷积神经网络”中介绍的方法构建的。实际上，我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的向量化器的数据张量的大小完全匹配。正如您在示例 4-19 中所看到的，它与我们在“卷积神经网络”中引入的`Conv1d`序列既有相似之处，也有需要解释的新添加内容。具体来说，该模型类似于“卷积神经网络”，它使用一系列一维卷积来增量地计算更多的特征，从而得到一个单特征向量。

然而，本例中的新内容是使用`sequence`和 ELU PyTorch 模块。序列模块是封装线性操作序列的方便包装器。在这种情况下，我们使用它来封装`Conv1d`序列的应用程序。ELU 是类似于第 3 章中介绍的 ReLU 的非线性函数，但是它不是将值裁剪到 0 以下，而是对它们求幂。ELU 已经被证明是卷积层之间使用的一种很有前途的非线性（Clevert et al.， 2015）。

在本例中，我们将每个卷积的通道数与`num_channels`超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现 256 足够大，可以使模型达到合理的性能。

示例 4-19：基于 CNN 的`SurnameClassifier`

```py
import torch.nn as nn
import torch.nn.functional as F

class SurnameClassifier(nn.Module):
    def __init__(self, initial_num_channels, num_classes, num_channels):
        """
        Args:
            initial_num_channels (int): size of the incoming feature vector
            num_classes (int): size of the output prediction vector
            num_channels (int): constant channel size to use throughout network
        """
        super(SurnameClassifier, self).__init__()

        self.convnet = nn.Sequential(
            nn.Conv1d(in_channels=initial_num_channels,
                      out_channels=num_channels, kernel_size=3),
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
                      kernel_size=3, stride=2),
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
                      kernel_size=3, stride=2),
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
                      kernel_size=3),
            nn.ELU()
        )
        self.fc = nn.Linear(num_channels, num_classes)

    def forward(self, x_surname, apply_softmax=False):
        """The forward pass of the classifier

        Args:
            x_surname (torch.Tensor): an input data tensor.
                x_surname.shape should be (batch, initial_num_channels,
                                           max_surname_length)
            apply_softmax (bool): a flag for the softmax activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch, num_classes)
        """
        features = self.convnet(x_surname).squeeze(dim=2)
        prediction_vector = self.fc(features)

        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)

        return prediction_vector

```

### 训练例程

训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。此时，这是本书到目前为止的第三个训练例程实现，应该将这个操作序列内部化。对于这个例子，我们将不再详细描述具体的训练例程，因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同。但是，输入参数是不同的，您可以在示例 4-20 中看到。

示例 4-20：CNN 姓氏分类器的输入参数

```py
args = Namespace(
    # Data and Path information
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/cnn",
    # Model hyper parameters
    hidden_dim=100,
    num_channels=256,
    # Training hyper parameters
    seed=1337,
    learning_rate=0.001,
    batch_size=128,
    num_epochs=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # Runtime omitted for space ...
)

```

### 模型评估和预测

要理解模型的性能，需要对性能进行定量和定性的度量。下面将描述这两个度量的基本组件。我们鼓励您扩展它们，以探索该模型及其所学习到的内容。

## 在测试集上评估

正如“示例:带有多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样，执行评估的代码也没有变化。总之，调用分类器的`eval()`方法来防止反向传播，并迭代测试数据集。与 MLP 约 50% 的性能相比，该模型的测试集性能准确率约为 56%。尽管这些性能数字绝不是这些特定架构的上限，但是通过一个相对简单的 CNN 模型获得的改进应该足以让您在文本数据上尝试 CNNs。

## 为新的姓氏分类或获取最佳预测

在本例中，`predict_nationality()`函数的一部分发生了更改，如示例 4-21 所示:我们没有使用视图方法重塑新创建的数据张量以添加批量维度，而是使用 PyTorch 的`unsqueeze()`函数在批量应该在的位置添加大小为 1 的维度。相同的更改反映在`predict_topk_nationality()`函数中。

示例 4-21：使用训练过的模型做出预测

```py
def predict_nationality(surname, classifier, vectorizer):
    """Predict the nationality from a new surname

    Args:
        surname (str): the surname to classifier
        classifier (SurnameClassifer): an instance of the classifier
        vectorizer (SurnameVectorizer): the corresponding vectorizer
    Returns:
        a dictionary with the most likely nationality and its probability
    """
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
    result = classifier(vectorized_surname, apply_softmax=True)

    probability_values, indices = result.max(dim=1)
    index = indices.item()

    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    probability_value = probability_values.item()

    return {'nationality': predicted_nationality, 'probability': probability_value}

```

## CNN 中的其他话题

为了结束我们的讨论，我们概述了几个其他的主题，这些主题是 CNNs 的核心，但在它们的共同使用中起着主要作用。特别是，您将看到池化操作、批量规范化、网络中的网络连接和残差连接的描述。

### 池化操作

池化是将高维特征映射总结为低维特征映射的操作。卷积的输出是一个特征映射。特征映射中的值总结了输入的一些区域。由于卷积计算的重叠性，许多计算出的特征可能是冗余的。池化是一种将高维（可能是冗余的）特征映射总结为低维特征映射的方法。在形式上，池是一种像求和，均值或最大值这样的算术运算符，系统地应用于特征映射中的局部区域，得到的池操作分别称为求和池化、平均池化和最大池化。池还可以作为一种方法，将较大但较弱的特征映射的统计强度改进为较小但较强的特征映射。图 4-13 说明了池化。

### 批量规范化（BatchNorm)

批量标准化是设计网络时经常使用的一种工具。BatchNorm 对 CNN 的输出进行转换，方法是将激活量缩放为零均值和单位方差。它用于 Z 转换的平均值和方差值每批更新一次，这样任何单个批中的波动都不会太大地移动或影响它。BatchNorm 允许模型对参数的初始化不那么敏感，并且简化了学习速率的调整（Ioffe and Szegedy, 2015）。在 PyTorch 中，批量规范是在`nn`模块中定义的。例 4-22 展示了如何用卷积和线性层实例化和使用批量规范。

示例 4-22：使用`Conv1D`层和批量规范化

```py
# ...
        self.conv1 = nn.Conv1d(in_channels=1, out_channels=10,
                               kernel_size=5,
                               stride=1)
        self.conv1_bn = nn.BatchNorm1d(num_features=10)
        # ...

    def forward(self, x):
       # ...
       x = F.relu(self.conv1(x))
       x = self.conv1_bn(x)
       # ...

```

### 网络中的网络连接（`1x1`卷积）

网络中的网络连接（NiN）连接是具有`kernel_size=1`的卷积内核，具有一些有趣的特性。具体来说，`1x1`卷积就像通道之间的一个完全连通的线性层。这在从多通道特征映射映射到更浅的特征映射时非常有用。在图 4-14 中，我们展示了一个应用于输入矩阵的 NiN 连接。如您所见，它将两个通道简化为一个通道。因此，NiN 或`1x1`卷积提供了一种廉价的方法来合并参数较少的额外非线性（Lin et al.， 2013）。

### 残差连接/残差块

CNNs 中最重要的趋势之一是残差连接，它支持真正深层的网络（超过 100 层）。它也称为跳跃连接。如果将卷积函数表示为`conv`，则残差块的输出如下:

## 总结

在本章中，您学习了两个基本的前馈架构:多层感知器（MLP;也称为“全连接”网络）和卷积神经网络（CNN）。我们看到了 MLPs 在近似任何非线性函数方面的威力，并展示了 MLPs 在 NLP 中的应用，以及从姓氏中对国籍进行分类的应用。我们研究了 mlp 的一个主要缺点/限制—缺乏参数共享—并介绍了卷积网络架构作为一种可能的解决方案。最初为计算机视觉开发的 CNNs，现已成为 NLP 的中流砥柱;主要是因为它们的高效实现和低内存需求。我们研究了卷积的不同变体——填充、扩展和扩展——以及它们如何转换输入空间。本章还专门讨论了卷积滤波器的输入和输出大小选择的实际问题。我们展示了卷积操作如何通过扩展姓氏分类示例来使用卷积网络来帮助捕获语言中的子结构信息。最后，我们讨论了与卷积网络设计相关的一些杂项但重要的主题:1）池化，2）批量规范化，3）`1x1`卷积，4）残差连接。在现代 CNN 的设计中，经常可以看到像 Inception 架构（Szegedy et al.， 2015）那样同时使用许多这样的技巧。在 Inception 架构中，小心地使用这些技巧可以让卷积网络深入到数百层，不仅精确，而且训练速度快。在第 5 章中，我们探讨了学习和使用表示表示离散单元的主题，例如使用嵌入的单词、句子、文档和其他特征类型。