自然语言处理前馈网络

文章目录

自然语言处理前馈网络

一、实验介绍

感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如,查看图4-1中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(也称为线性可分)。在这个例子中,感知器失败了。
图4-1 XOR数据集中的两个绘制类为圆形和星型。但是没有任何一行可以分类这两类。
在这一实验中,我们将探索传统上称为前馈网络的神经网络模型,以及两种前馈神经网络:多层感知器和卷积神经网络。多层感知器在结构上扩展了我们在实验3中研究的简单感知器,将多个感知器分组在一个单层,并将多个层叠加在一起。我们稍后将介绍多层感知器,并在“示例:带有多层感知器的姓氏分类”中展示它们在多层分类中的应用。

本实验研究的第二种前馈神经网络,卷积神经网络,在处理数字信号时深受窗口滤波器的启发。通过这种窗口特性,卷积神经网络能够在输入中学习局部化模式,这不仅使其成为计算机视觉的主轴,而且是检测单词和句子等序列数据中的子结构的理想候选。举例来说,在分类姓氏的任务中,我们可以利用CNN来从姓氏的字符序列中提取特征,并将其映射到不同类别。通过在输入数据上应用一系列的卷积和池化操作,CNN能够有效地捕获姓氏中的模式和结构,并且在分类任务中取得很好的效果。

在本实验中,多层感知器和卷积神经网络被分组在一起,因为它们都是前馈神经网络,并且与另一类神经网络——递归神经网络(RNNs)形成对比,递归神经网络(RNNs)允许反馈(或循环),这样每次计算都可以从之前的计算中获得信息。

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

二、The Multilayer Perceptron(多层感知器)

多层感知机(Multilayer Perceptron, MLP)是一种经典的人工神经网络模型,也是最简单、最常见的深度学习模型之一。它由多个全连接的神经网络层(称为隐藏层)组成,每个隐藏层包含多个神经元,层与层之间通过非线性的激活函数连接。MLP通常用于解决分类和回归问题。

MLP的基本结构包括三个关键部分:

输入层(Input Layer):接受输入数据,并将其传递到下一层。每个输入节点代表输入数据的一个特征。

隐藏层(Hidden Layers):包括多个神经元,每个神经元与上一层的所有神经元都连接。隐藏层通过学习数据中的复杂模式和特征来提取数据的高阶表示。每个隐藏层通常都会应用非线性的激活函数,如ReLU(Rectified Linear Unit)、Sigmoid或Tanh等。

输出层(Output Layer):通常是一个单独的神经元或一组神经元,用于产生模型的输出。对于分类任务,输出层通常使用softmax函数来输出每个类别的概率分布;对于回归任务,输出层通常直接输出预测值。

结构示例图:
输入层                   隐藏层                   输出层
  │                       │                      │
 ┌▼┐                     ┌▼┐                    ┌▼┐
┌───┐                   ┌───┐                  ┌─────┐
│ x │─────┐         ┌──>│   │─────┐        ┌─>│  y  │
└───┘     │         │   └───┘     │        │  └─────┘
          │  W₁,b₁  │             │ W₂,b₂  │
          └─────────┘             └────────┘
在这个示意图中:

输入层包含一个或多个特征节点,每个节点代表输入数据的一个特征。

隐藏层是一系列全连接的神经元组成,通常有多层,每一层的神经元数可以根据需要设置。

输出层通常只有一个或几个神经元,用于产生模型的输出。每个输出节点代表模型的一个输出值。

神经元之间的连接表示权重,用于调整输入信号的强度。每个连接还有一个偏置项(bias)用于调整神经元的激活阈值。

在每个隐藏层和输出层中,都会应用一个激活函数来引入非线性,常见的激活函数包括ReLU、Sigmoid和Tanh等。

MLP通过前向传播和反向传播两个阶段进行训练。在前向传播阶段,输入数据沿着网络向前传播,计算出模型的预测值;在反向传播阶段,利用损失函数计算预测值与真实值之间的误差,并通过反向传播算法调整网络参数,以减小误差。

MLP之所以强大,在于它引入了多个线性层,允许模型学习一个线性分割的中间表示。这种能力使得MLP可以表示一个直线(或更一般的,一个超平面),从而能够将数据点分割成不同的类别。学习具有这种特性的中间表示,尤其在分类任务中,当数据是线性可分时,是神经网络最深刻的特点之一,也是其建模能力的精髓所在。

2.1 A Simple Example: XOR(异或运算)

让我们看一下前面描述的XOR示例,看看感知器与MLP之间会发生什么。在这个例子中,我们在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如图4-3所示。
在这里插入图片描述

在左边的面板中,我们观察到感知器的决策边界。由于感知器是线性模型,它只能学习到一个简单的直线来分隔数据点。然而,由于XOR问题的非线性特性,感知器无法准确地区分星星和圆圈,因此会产生许多错误分类的数据点。

相比之下,在右边的面板中,我们看到了MLP的决策边界。由于MLP具有多个隐藏层和非线性激活函数,它能够学习到更复杂的决策边界,从而更准确地将星星和圆圈分开。MLP能够捕捉到XOR问题的非线性特征,因此在这种情况下,它能够更好地适应数据并准确地分类数据点。

通过这个比较,我们可以清楚地看到MLP相对于感知器的优势,特别是在处理具有复杂非线性关系的数据时。MLP的多层结构和非线性激活函数使其具有更强的建模能力,能够更好地适应各种类型的数据和问题。

虽然在图中显示MLP有两个决策边界,这是它的优点,但它实际上只是一个决策边界!决策边界就是这样出现的,因为中间表示法改变了空间,使一个超平面同时出现在这两个位置上。在图4-4中,我们可以看到MLP计算的中间值。这些点的形状表示类(星形或圆形)。我们所看到的是,神经网络(本例中为MLP)已经学会了“扭曲”数据所处的空间,以便在数据通过最后一层时,用一线来分割它们。
在这里插入图片描述

相反,如图4-5所示,感知器没有额外的一层来处理数据的形状,直到数据变成线性可分的。
在这里插入图片描述

2.2 在PyTorch中实现MLP

在上一节中,我们概述了MLP的核心思想。在本节中,我们将介绍PyTorch中的一个实现。与简单的感知器相比,MLP包含了额外的计算层,使其能够处理更复杂的模式识别任务。在我们在例4-1中给出的实现中,我们使用了PyTorch的两个线性模块来实现这个想法。这些线性模块被命名为fc1和fc2,遵循了一种通用约定,即将线性模块称为“全连接层”,简称为“fc层”。

除了这两个线性层外,我们还引入了修正的线性单元(ReLU)作为非线性激活函数。这个ReLU函数应用于第一个线性层的输出,在其被输入到第二个线性层之前进行处理。由于层之间的顺序性,必须确保一个层的输出数量等于下一个层的输入数量。使用两个线性层之间的非线性是必要的,因为如果没有它,两个线性层在数学上等价于一个线性层,因此无法捕捉数据中的复杂模式。

MLP的实现只涉及反向传播的前向传递。这是因为PyTorch会根据模型的定义和前向传播的实现自动计算出反向传播的步骤,从而进行梯度更新。这使得在PyTorch中实现MLP变得更加简单和高效。

Example 4-1. Multilayer Perceptron:

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): 输入向量的大小
            hidden_dim (int): 第一个线性层的输出大小
            output_dim (int): 第二个线性层的输出大小
        """
        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):
        """
        多层感知机的前向传播
        
        Args:
            x_in (torch.Tensor): 输入数据张量。
                x_in.shape 应该为 (batch, input_dim)
            apply_softmax (bool): 一个用于 softmax 激活的标志。
                如果与交叉熵损失一起使用,应该为 False。
        
        Returns:
            结果张量。tensor.shape 应该为 (batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))  # ReLU 激活函数的中间层
        output = self.fc2(intermediate)

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

在例4-2中,我们实例化了一个MLP。MLP的实现非常通用,可以适用于任意大小的输入。在这个例子中,我们使用了一个输入维度为3、输出维度为4、隐藏层维度为100的MLP。在print语句的输出中,每个层中的单元数都被很好地排列,以便将大小为3的输入映射到大小为4的输出。

Example 4-2. An example instantiation of an MLP:

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)# 调用函数,输入维度=3,输出维度=4,隐藏维度=100
print(mlp)

结果:
在这里插入图片描述

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

Example 4-3. Testing the MLP with random inputs:

import torch
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)

结果:
在这里插入图片描述

y_output1 = mlp(x_input, apply_softmax=False)
describe(y_output1)
print(y_output1.sum(dim=1))

y_output2 = mlp(x_input, apply_softmax=True)# 应用softmax之后,归一化,转换为概率
describe(y_output2)
print(y_output2.sum(dim=1))

结果:
在这里插入图片描述

学习如何读取PyTorch模型的输入和输出是至关重要的。在之前的例子中,MLP模型的输出是一个大小为两行四列的张量。这个张量的行对应于批处理维度,即小批处理中数据点的数量。列则代表每个数据点的最终特征向量。在某些情况下,例如在分类设置中,特征向量被称为预测向量,因为它代表一个概率分布。预测向量在训练和推理期间有不同的作用。在训练期间,输出被直接用于计算损失函数并与目标类标签进行比较。但是,在推理期间,我们通常希望将预测向量转换为概率。

要将预测向量转换为概率,我们需要使用softmax函数。softmax函数在物理学中被称为玻尔兹曼或吉布斯分布,在统计学中是多项式逻辑回归,在自然语言处理(NLP)中是最大熵(MaxEnt)分类器。不管叫什么名字,它的作用都是将一个值向量转换为概率分布。它的直觉是,大的正值会转化为较高的概率,小的负值会转化为较低的概率。

在示例4-3中,apply_softmax参数表示是否应用这个额外的步骤。在示例4-4中,我们可以看到相同的输出,但是这次设置了apply_softmax标志为True,以将预测向量转换为概率。

Example 4-4. MLP with apply_softmax=True:

y_output = mlp(x_input, apply_softmax=True)
describe(y_output)

结果:
在这里插入图片描述

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

三、实验步骤

3.1 Example: 使用多层感知器进行姓氏分类

在这个例子中,我们将MLP应用于将姓氏分类到其原籍国的任务。通过公开观察的数据,我们可以推断人口统计信息(如国籍),这在许多应用中都很重要,从产品推荐到确保不同人群获得公平结果。人口统计和其他自我识别信息统称为“受保护属性”,在使用这些属性进行建模和产品设计时必须小心。

我们首先对每个姓氏的字符进行拆分,类似于“示例:将餐馆评论的情绪分类”中对待单词的方式。尽管数据上有所不同,但字符级别的模型在结构和实现上与基于单词的模型基本相似。

值得从这个例子中吸取的一个重要教训是,MLP的实现和训练是直接发展自我们在第3章中看到的感知器的实现和训练。实际上,在实验3中我们提到了这个例子,以便更全面地了解这些组件。此外,我们不会在这个例子中涉及到“示例:对餐馆评论的情绪进行分类”中的代码。

接下来,我们将描述姓氏数据集及其预处理步骤。然后,我们将逐步构建从姓氏字符串到向量化小批处理的管道,使用词汇表、向量化器和DataLoader类。

然后,我们将继续描述姓氏分类器模型及其设计背后的思想。MLP与我们在实验3中看到的感知器例子类似,但除了模型的变化外,我们还引入了多类输出及其对应的损失函数。

最后,我们会完成训练过程。由于训练程序与“示例:对餐馆评论的情绪进行分类”非常相似,因此为了简洁起见,我们在这里不会像在那个部分中那样深入讨论,但你可以回顾一下相关内容。

3.1.1 姓氏数据集

这个姓氏数据集收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的来源收集的。数据集具有一些有趣的属性,首先是它的不平衡性,前三名姓氏占了数据的60%以上,其中27%是英语,21%是俄语,14%是阿拉伯语。其余15个国家的频率逐渐减少,这是语言特有的特点。另一个特点是,国籍和姓氏的正字法之间存在一种有效且直观的关系,有些姓氏与其原籍国联系非常紧密。

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

SurnameDataset类的实现与在“Example: classification of Sentiment of Restaurant Reviews”中呈现的ReviewDataset类类似,不同之处在于getitem方法的实现方式。这个方法不再返回向量化的评论,而是返回一个向量化的姓氏及其相应的国籍索引,如示例4-5所示:

Example 4-5. Implementing SurnameDataset.__getitem__()from argparse import Namespace
from collections import Counter
import json
import os
import string

import numpy as np
import pandas as pd

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm_notebook

class SurnameDataset(Dataset):
...
    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dictionary holding the data point's:
                features (x_surname)
                label (y_nationality)
        """
        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}
...
完整class SurnameDataset(Dataset)代码见后续

3.1.2 词汇表、向量化器和数据加载器

为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。这些数据结构与“Example: Classifying Sentiment of Restaurant Reviews”中使用的数据结构相同,它们举例说明了一种多态性,这种多态性将姓氏的字符标记与Yelp评论的单词标记相同对待。数据不是通过将字令牌映射到整数来向量化的,而是通过将字符映射到整数来向量化的。

THE VOCABULARY CLASS

本例中使用的词汇类与“example: Classifying Sentiment of Restaurant Reviews”中的词汇完全相同,该词汇类将Yelp评论中的单词映射到对应的整数。简要概述一下,词汇表是两个Python字典的协调,这两个字典在令牌(在本例中是字符)和整数之间形成一个双射;也就是说,第一个字典将字符映射到整数索引,第二个字典将整数索引映射到字符。add_token方法用于向词汇表中添加新的令牌,lookup_token方法用于检索索引,lookup_index方法用于检索给定索引的令牌(在推断阶段很有用)。与Yelp评论的词汇表不同,我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。

class Vocabulary(object):
    """用于处理文本并提取词汇以进行映射的类"""

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        初始化 Vocabulary 类
        
        Args:
            token_to_idx (dict): 一个预先存在的标记到索引的映射
            add_unk (bool): 一个指示是否添加 UNK 标记的标志
            unk_token (str): 要添加到词汇表中的 UNK 标记
        """

        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        # 通过 token_to_idx 创建 idx_to_token 映射
        self._idx_to_token = {idx: token for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk
        self._unk_token = unk_token
        
        self.unk_index = -1
        # 如果需要添加 UNK 标记,则调用 add_token 方法将其添加到词汇表中
        if add_unk:
            self.unk_index = self.add_token(unk_token) 
        
        
	def to_serializable(self):
    	"""返回一个可序列化的字典"""
    	return {'token_to_idx': self._token_to_idx, 
            	'add_unk': self._add_unk, 
            	'unk_token': self._unk_token}

		@classmethod
		def from_serializable(cls, contents):
    	"""从序列化的字典实例化 Vocabulary"""
    	return cls(**contents)

	def add_token(self, token):
    	"""基于标记更新映射字典。

    	Args:
        	token (str): 要添加到 Vocabulary 中的项目
    	Returns:
        	index (int): 对应于标记的整数
    	"""
    	try:
        	index = self._token_to_idx[token]
    	except KeyError:
        	index = len(self._token_to_idx)
        	self._token_to_idx[token] = index
        	self._idx_to_token[index] = token
    	return index

	def add_many(self, tokens):
    	"""将一组标记添加到 Vocabulary 中
        
    	Args:
        	tokens (list): 一组字符串标记
    	Returns:
        	indices (list): 与标记对应的索引列表
    	"""
    	return [self.add_token(token) for token in tokens]

	def lookup_token(self, token):
    	"""检索与标记关联的索引或如果标记不存在则返回 UNK 索引。
        
    	Args:
        	token (str): 要查找的标记
    	Returns:
        	index (int): 对应于标记的索引
    	Notes:
        	`unk_index` 需要 >=0(已添加到 Vocabulary 中)才能进行 UNK 功能
    	"""
    	if self.unk_index >= 0:
        	return self._token_to_idx.get(token, self.unk_index)
    	else:
        	return self._token_to_idx[token]
        
	def lookup_index(self, index):
    	"""返回与索引关联的标记。
        
    	Args: 
        	index (int): 要查找的索引
    	Returns:
        	token (str): 对应于索引的标记
    	Raises:
       	 KeyError: 如果索引不在词汇表中
    	"""
    	if index not in self._idx_to_token:
        	raise KeyError("索引(%d)不在词汇表中" % index)
    	return self._idx_to_token[index]

	def __str__(self):
    	return "<Vocabulary(size=%d)>" % len(self)

	def __len__(self):
    	return len(self._token_to_idx)

姓氏向量化器

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

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

Example 4-6. Implementing SurnameVectorizer:

class SurnameVectorizer(object):
    """ 
    一个用于对姓氏和国籍进行向量化的类。
    
    此类协调两个 Vocabulary 对象的使用:一个用于将字符映射到整数(surname_vocab),另一个用于将国籍映射到整数(nationality_vocab)。
    """

    def __init__(self, surname_vocab, nationality_vocab):
        """
        初始化 SurnameVectorizer。

        参数:
            surname_vocab (Vocabulary): 将字符映射到整数的 Vocabulary 对象。
            nationality_vocab (Vocabulary): 将国籍映射到整数的 Vocabulary 对象。
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

    def vectorize(self, surname):
        """
        将姓氏向量化。

        参数:
            surname (str): 要向量化的姓氏。

        返回:
            one_hot (np.ndarray): 姓氏的压缩独热编码。
        """
        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):
        """
        从数据框架实例化向量化器。

        参数:
            surname_df (pandas.DataFrame): 包含姓氏数据集的数据框架。

        返回:
            cls: 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)

    @classmethod
    def from_serializable(cls, contents):
        """
        从可序列化内容实例化 SurnameVectorizer。

        参数:
            contents (dict): 包含了 Vocabulary 对象序列化内容的字典。

        返回:
            cls: SurnameVectorizer 的一个实例。
        """
        surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])
        nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab'])
        return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab)

    def to_serializable(self):
        """
        序列化 SurnameVectorizer。

        返回:
            dict: 包含了 Vocabulary 对象序列化内容的字典。
        """
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable()}
class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        初始化 SurnameDataset 类的实例。

        参数:
            surname_df (pandas.DataFrame): 数据集
            vectorizer (SurnameVectorizer): 从数据集实例化的向量化器
        """
        self.surname_df = surname_df
        self._vectorizer = vectorizer

        self.train_df = self.surname_df[self.surname_df.split=='train']
        self.train_size = len(self.train_df)

        self.val_df = self.surname_df[self.surname_df.split=='val']
        self.validation_size = len(self.val_df)

        self.test_df = self.surname_df[self.surname_df.split=='test']
        self.test_size = len(self.test_df)

        self._lookup_dict = {'train': (self.train_df, self.train_size),
                             'val': (self.val_df, self.validation_size),
                             'test': (self.test_df, self.test_size)}

        self.set_split('train')
        
        # 类别权重
        class_counts = surname_df.nationality.value_counts().to_dict()
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)

    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """加载数据集并从头创建一个新的向量化器
        
        参数:
            surname_csv (str): 数据集的位置
        返回:
            SurnameDataset 的一个实例
        """
        surname_df = pd.read_csv(surname_csv)
        train_surname_df = surname_df[surname_df.split=='train']
        return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
    """加载数据集和相应的向量化器。
    当向量化器已经被缓存以便重复使用时使用
    
    Args:
        surname_csv (str): 数据集的位置
        vectorizer_filepath (str): 已保存的向量化器的位置
    Returns:
        SurnameDataset的一个实例
    """
    # 从CSV文件中加载数据集
    surname_df = pd.read_csv(surname_csv)
    # 使用静态方法load_vectorizer_only加载向量化器
    vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
    # 返回使用加载的数据集和向量化器初始化的SurnameDataset实例
    return cls(surname_df, vectorizer)

	@staticmethod
	def load_vectorizer_only(vectorizer_filepath):
    	"""从文件加载向量化器的静态方法
    
    	Args:
        	vectorizer_filepath (str): 序列化向量化器的位置
    	Returns:
        	SurnameVectorizer的一个实例
    	"""
    	# 打开包含序列化向量化器的文件
    	with open(vectorizer_filepath) as fp:
        	# 从JSON格式反序列化向量化器并返回SurnameVectorizer的一个实例
        	return SurnameVectorizer.from_serializable(json.load(fp))

	def save_vectorizer(self, vectorizer_filepath):
    	"""使用JSON格式将向量化器保存到磁盘
    
    	Args:
        	vectorizer_filepath (str): 保存向量化器的位置
    	"""
    	# 打开一个文件进行写入,并将序列化的向量化器保存为JSON
    	with open(vectorizer_filepath, "w") as fp:
        	json.dump(self._vectorizer.to_serializable(), fp)

	def get_vectorizer(self):
    	"""返回向量化器"""
    	# 返回与该数据集关联的向量化器
    	return self._vectorizer

	def set_split(self, split="train"):
    	"""使用数据框中的列选择数据集中的拆分"""
    	# 设置目标拆分(例如,“train”,“val”,“test”)
    	self._target_split = split
    	# 使用查找字典获取与指定拆分对应的数据框和大小
    	self._target_df, self._target_size = self._lookup_dict[split]

	def __len__(self):
    	# 返回数据集的长度,即目标拆分的大小
    	return self._target_size
  
	def __getitem__(self, index):
    	"""PyTorch数据集的主要入口方法
    
    	Args:
        	index (int): 数据点的索引
    	Returns:
        	包含数据点特征和标签的字典:
            	特征 (x_surname)
            	标签 (y_nationality)
    	"""
    	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}

	def get_num_batches(self, batch_size):
    	"""给定批量大小,返回数据集中的批次数
    
    	Args:
        	batch_size (int): 批量大小
    	Returns:
        	数据集中的批次数
    	"""
    	return len(self) // batch_size

	def generate_batches(dataset, batch_size, shuffle=True,
                     	drop_last=True, device="cpu"): 
    	"""
    	一个生成器函数,用于封装PyTorch DataLoader。它会确保每个张量位于正确的设备位置上。
    	"""
    	dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            	shuffle=shuffle, drop_last=drop_last)

    	for data_dict in dataloader:
        	out_data_dict = {}
        	for name, tensor in data_dict.items():
            	out_data_dict[name] = data_dict[name].to(device)
        	yield out_data_dict

3.1.3 姓氏分类器模型

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

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

Example 4-7. The SurnameClassifier as an MLP:

class SurnameClassifier(nn.Module):
    """用于分类姓氏的2层多层感知器"""
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        参数:
            input_dim (int): 输入向量的大小
            hidden_dim (int): 第一线性层的输出大小
            output_dim (int): 第二线性层的输出大小
        """
        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):
        """分类器的前向传播
        
        参数:
            x_in (torch.Tensor): 输入数据张量。x_in.shape应为 (batch, input_dim)
            apply_softmax (bool): softmax激活的标志,
                如果与交叉熵损失一起使用,则应为false
        返回:
            结果张量。tensor.shape应为 (batch, output_dim)
        """
        intermediate_vector = F.relu(self.fc1(x_in))  # ReLU激活函数
        prediction_vector = self.fc2(intermediate_vector)

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

        return prediction_vector

3.1.4 训练例程

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

Example 4-8. The args for classifying surnames with an MLP:

def make_train_state(args):
    return {'stop_early': False,
            'early_stopping_step': 0,
            'early_stopping_best_val': 1e8,
            'learning_rate': args.learning_rate,
            'epoch_index': 0,
            'train_loss': [],
            'train_acc': [],
            'val_loss': [],
            'val_acc': [],
            'test_loss': -1,
            'test_acc': -1,
            'model_filename': args.model_state_file}

def update_train_state(args, model, train_state):
    """处理训练状态的更新。

    组件:
     - 提前停止:防止过拟合。
     - 模型检查点:如果模型更好,则保存模型

    :param args: 主要参数
    :param model: 要训练的模型
    :param train_state: 表示训练状态值的字典
    :returns:
        一个新的train_state
    """

    # 至少保存一个模型
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # 如果性能提高,则保存模型
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]

        # 如果损失变坏
        if loss_t >= train_state['early_stopping_best_val']:
            # 更新步骤
            train_state['early_stopping_step'] += 1
        # 损失减少
        else:
            # 保存最佳模型
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

            # 重置提前停止步骤
            train_state['early_stopping_step'] = 0

        # 是否提前停止?
        train_state['stop_early'] = \
            train_state['early_stopping_step'] >= args.early_stopping_criteria

    return train_state
def compute_accuracy(y_pred, y_target):
    _, y_pred_indices = y_pred.max(dim=1)
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    return n_correct / len(y_pred_indices) * 100

def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)

def handle_dirs(dirpath):
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)

args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/surname_mlp",
    # 模型超参数
    hidden_dim=300,
    # 训练超参数
    seed=1337,
    num_epochs=100,
    early_stopping_criteria=5,
    learning_rate=0.001,
    batch_size=64,
    # 运行时选项
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
)

if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir,
                                        args.vectorizer_file)

    args.model_state_file = os.path.join(args.save_dir,
                                         args.model_state_file)
    
    print("扩展后的文件路径: ")
    print("\t{}".format(args.vectorizer_file))
    print("\t{}".format(args.model_state_file))
    
# 检查 CUDA
if not torch.cuda.is_available():
    args.cuda = False

args.device = torch.device("cuda" if args.cuda else "cpu")
    
print("使用 CUDA: {}".format(args.cuda))


# 设置种子以实现可重复性
set_seed_everywhere(args.seed, args.cuda)

# 处理目录
handle_dirs(args.save_dir)

if args.reload_from_files:
    # 从检查点训练
    print("重新加载!")
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                              args.vectorizer_file)
else:
    # 创建数据集和向量化器
    print("创建新的!")
    dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
    dataset.save_vectorizer(args.vectorizer_file)
    
vectorizer = dataset.get_vectorizer()
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab), 
                               hidden_dim=args.hidden_dim, 
                               output_dim=len(vectorizer.nationality_vocab))

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

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

Example 4-9. Instantiating the dataset, model, loss, and optimizer:

# 将分类器和类别权重移动到指定的设备上
classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)

# 定义损失函数、优化器和学习率调度器
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                                 mode='min', factor=0.5,
                                                 patience=1)

# 创建训练状态
train_state = make_train_state(args)

# 设置进度条
epoch_bar = tqdm_notebook(desc='training routine', 
                          total=args.num_epochs,
                          position=0)

# 设置训练数据集的进度条
dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train',
                          total=dataset.get_num_batches(args.batch_size), 
                          position=1, 
                          leave=True)

# 设置验证数据集的进度条
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val',
                        total=dataset.get_num_batches(args.batch_size), 
                        position=1, 
                        leave=True)

训练循环

与“Example: Classifying Sentiment of Restaurant Reviews”中的训练循环相比,本例的训练循环除了变量名以外几乎是相同的。具体来说,示例4-10显示了使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。

Example 4-10. A snippet of the training loop:

try:
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index

        # Iterate over training dataset

        # setup: batch generator, set loss and acc to 0, set train mode on

        dataset.set_split('train')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.0
        running_acc = 0.0
        classifier.train()

        for batch_index, batch_dict in enumerate(batch_generator):
            # 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_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

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

            # step 5. use optimizer to take gradient step
            optimizer.step()
            # -----------------------------------------
            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)

            # update bar
            train_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            train_bar.update()

        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # Iterate over val dataset

        # setup: batch generator, set loss and acc to 0; set eval mode on
        dataset.set_split('val')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.
        running_acc = 0.
        classifier.eval()

        for batch_index, batch_dict in enumerate(batch_generator):

            # 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_t = loss.to("cpu").item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)
            val_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            val_bar.update()

        train_state['val_loss'].append(running_loss)
        train_state['val_acc'].append(running_acc)

        train_state = update_train_state(args=args, model=classifier,
                                         train_state=train_state)

        scheduler.step(train_state['val_loss'][-1])

        if train_state['stop_early']:
            break

        train_bar.n = 0
        val_bar.n = 0
        epoch_bar.update()
except KeyboardInterrupt:
    print("Exiting loop")

# compute the loss & accuracy on the test set using the best available model

classifier.load_state_dict(torch.load(train_state['model_filename']))

classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

dataset.set_split('test')
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)
running_loss = 0.
running_acc = 0.
classifier.eval()

for batch_index, batch_dict in enumerate(batch_generator):
    # compute the output
    y_pred =  classifier(batch_dict['x_surname'])
    
    # compute the loss
    loss = loss_func(y_pred, batch_dict['y_nationality'])
    loss_t = loss.item()
    running_loss += (loss_t - running_loss) / (batch_index + 1)

    # compute the accuracy
    acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
    running_acc += (acc_t - running_acc) / (batch_index + 1)

train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc

print("Test loss: {};".format(train_state['test_loss']))
print("Test Accuracy: {}".format(train_state['test_acc']))

结果:
在这里插入图片描述在这里插入图片描述

3.1.5 模型评估与预测

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

3.1.5.1 在测试数据集上进行评估

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

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

3.1.5.2 对姓氏进行分类

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

Example 4-11. A function for performing nationality prediction:

def predict_nationality(surname, classifier, vectorizer):
    """预测姓氏的国籍
    
    参数:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifier): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
    返回:
        包含最可能的国籍及其概率的字典
    """
    # 将姓氏转换为向量
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)
    
    # 使用分类器进行预测
    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}


new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")  # 将分类器移到 CPU
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

预测结果:
在这里插入图片描述
在这里插入图片描述

姓氏可以自行输入

3.1.5.3 检索一个姓氏的前K个预测

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

vectorizer.nationality_vocab.lookup_index(8)

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)
    
    # 转换为 numpy 数组
    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


# 输入要分类的新姓氏
new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")  # 将分类器移至 CPU

# 询问要查看前几个预测结果
k = int(input("How many of the top predictions to see? "))
if k > len(vectorizer.nationality_vocab):
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
    k = len(vectorizer.nationality_vocab)
    
# 获取前 k 个预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))

结果:
在这里插入图片描述

步骤1
在这里插入图片描述
步骤2
在这里插入图片描述
步骤3
在这里插入图片描述

3.1.6 MLP的正则化:权重正则化和结构正则化(或Dropout)

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

DROPOUT层

简单地说,在训练过程中,dropout有一定概率使属于两个相邻层的单元之间的连接减弱。这有什么用呢?我们从斯蒂芬•梅里蒂(Stephen Merity)的一段直观(且幽默)的解释开始:“Dropout,简单地说,是指如果你能在喝醉的时候反复学习如何做一件事,那么你应该能够在清醒的时候做得更好。这一见解产生了许多最先进的结果和一个新兴的领域。”

神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“Coadaptation”是神经科学中的一个术语,但在这里它只是指一种情况,即两个单元之间的联系变得过于紧密,而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接,我们可以确保没有一个单元总是依赖于另一个单元,从而产生健壮的模型。dropout不会向模型中添加额外的参数,但是需要一个超参数——“drop probability”。drop probability,它是单位之间的连接drop的概率。通常将下降概率设置为0.5。例4-13给出了一个带dropout的MLP的重新实现。

Example 4-13. MLP with dropout:

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): 输入向量的大小
            hidden_dim (int): 第一个线性层的输出大小
            output_dim (int): 第二个线性层的输出大小
        """
        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):
        """MLP的前向传播
        
        Args:
            x_in (torch.Tensor): 输入数据张量。
                x_in.shape 应该是 (batch, input_dim)
            apply_softmax (bool): 是否应用 softmax 激活函数的标志
                如果与交叉熵损失一起使用,应该为 false
        Returns:
            结果张量。tensor.shape 应该是 (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

请注意,dropout只适用于训练期间,不适用于评估期间。作为练习,可以尝试带有dropout的SurnameClassifier模型,看看它如何更改结果。

3.2 卷积神经网络

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

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

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

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

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

在这里插入图片描述
输入矩阵与单个产生输出矩阵的卷积核(也称为特征映射)在输入矩阵的每个位置应用内核。在每个应用程序中,内核乘以输入矩阵的值及其自身的值,然后将这些乘法相加kernel具有以下超参数配置:kernel_size=2,stride=1,padding=0,以及dilation=1。这些超参数解释如下:

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

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

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


在这里插入图片描述

卷积运算用三个输入矩阵(三个输入通道)表示相应的核也有三层,它将每层分别相乘,然后对结果求和。得到一个(一层)输出通道。

在这里插入图片描述

很难立即知道有多少输出通道适合当前的问题。为了简化这个困难,我们假设边界是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-gram的行为。使用较小的核大小,可以捕获较小的频繁模式,而较大的核大小会导致较大的模式,这可能更有意义,但是发生的频率更低。较小的核大小会导致输出中的细粒度特性,而较大的核大小会导致粗粒度特性。

步长
Stride控制卷积之间的步长。如果步长与核相同,则内核计算不会重叠。另一方面,如果跨度为1,则内核重叠最大。输出张量可以通过增加步幅的方式被有意的压缩来总结信息
在这里插入图片描述

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

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

3.3在PyTorch中实现CNNs

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

本课程中的实现遍历设计决策,以构建一个特征向量。我们首先构造一个人工数据张量,以反映实际数据的形状。数据张量的大小是三维的——这是向量化文本数据的最小批大小。如果你对一个字符序列中的每个字符使用onehot向量,那么onehot向量序列就是一个矩阵,而onehot矩阵的小批量就是一个三维张量。使用卷积的术语,每个onehot(通常是词汇表的大小)的大小是”input channels”的数量,字符序列的长度是“width”。

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

Example 4-14. Artificial data and using a Conv1d classfrom argparse import Namespace
from collections import Counter
import json
import os
import string

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm_notebook

batch_size = 2
one_hot_size = 10
sequence_width = 7
data = torch.randn(batch_size, one_hot_size, sequence_width)
conv1 = nn.Conv1d(in_channels=one_hot_size, out_channels=16,
               kernel_size=3)
intermediate1 = conv1(data)
print(data.size())
print(intermediate1.size())

结果:
在这里插入图片描述

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

Example 4-15. The iterative application of convolutions to data:

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())

结果:
在这里插入图片描述

y_output = intermediate3.squeeze()
print(y_output.size())

结果:
在这里插入图片描述

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

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

Example 4-16. Two additional methods for reducing to feature vectors:

# 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())# 计算每个特征图在序列维度上的所有元素的平均值
max_values, _ = torch.max(intermediate1, dim=2)
print(max_values.size())
# print(torch.sum(intermediate1, dim=2).size())

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

3.4 Example: 使用CNN进行姓氏分类

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

3.4.1 姓氏数据集

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

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

class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        self.surname_df = surname_df
        self._vectorizer = vectorizer
        self.train_df = self.surname_df[self.surname_df.split=='train']
        self.train_size = len(self.train_df)

        self.val_df = self.surname_df[self.surname_df.split=='val']
        self.validation_size = len(self.val_df)

        self.test_df = self.surname_df[self.surname_df.split=='test']
        self.test_size = len(self.test_df)

        self._lookup_dict = {'train': (self.train_df, self.train_size),
                             'val': (self.val_df, self.validation_size),
                             'test': (self.test_df, self.test_size)}

        self.set_split('train')
        
        # Class weights
        class_counts = surname_df.nationality.value_counts().to_dict()
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)


    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        surname_df = pd.read_csv(surname_csv)
        train_surname_df = surname_df[surname_df.split=='train']
        return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
        surname_df = pd.read_csv(surname_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(surname_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        return self._vectorizer

    def set_split(self, split="train"):
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

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

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

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

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

    def get_num_batches(self, batch_size):
       
        return len(self) // batch_size

    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):

    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)

    for data_dict in dataloader:
        out_data_dict = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict

3.4.2 词汇表、向量化器和数据加载器

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

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

class Vocabulary(object):
    """Class to process text and extract vocabulary for mapping"""

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        self._idx_to_token = {idx: token 
                              for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk
        self._unk_token = unk_token
        
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token) 
        
        
    def to_serializable(self):
        return {'token_to_idx': self._token_to_idx, 
                'add_unk': self._add_unk, 
                'unk_token': self._unk_token}

    @classmethod
    def from_serializable(cls, contents):

        return cls(**contents)

    def add_token(self, token):
        try:
            index = self._token_to_idx[token]
        except KeyError:
            index = len(self._token_to_idx)
            self._token_to_idx[token] = index
            self._idx_to_token[index] = token
        return index
    
    def add_many(self, tokens):
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        if index not in self._idx_to_token:
            raise KeyError("the index (%d) is not in the Vocabulary" % index)
        return self._idx_to_token[index]

    def __str__(self):
        return "<Vocabulary(size=%d)>" % len(self)

    def __len__(self):
        return len(self._token_to_idx)


    
class SurnameVectorizer(object):

    def __init__(self, surname_vocab, nationality_vocab, max_surname_length):

        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab
        self._max_surname_length = max_surname_length

    def vectorize(self, surname):
        one_hot_matrix_size = (len(self.surname_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.surname_vocab.lookup_token(character)
            one_hot_matrix[character_index][position_index] = 1
        
        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        surname_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:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(surname_vocab, nationality_vocab, max_surname_length)

    @classmethod
    def from_serializable(cls, contents):
        surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])
        nationality_vocab =  Vocabulary.from_serializable(contents['nationality_vocab'])
        return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab, 
                   max_surname_length=contents['max_surname_length'])

    def to_serializable(self):
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable(), 
                'max_surname_length': self._max_surname_length}

3.4.3重新使用卷积神经网络实现姓氏分类器

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

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

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

Example 4-19. The CNN-based SurnameClassifier:

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):
        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):
        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

3.4.4 训练例程

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

Example 4-20. Input arguments to the CNN surname classifier:

def make_train_state(args):
    return {'stop_early': False,
            'early_stopping_step': 0,
            'early_stopping_best_val': 1e8,
            'learning_rate': args.learning_rate,
            'epoch_index': 0,
            'train_loss': [],
            'train_acc': [],
            'val_loss': [],
            'val_acc': [],
            'test_loss': -1,
            'test_acc': -1,
            'model_filename': args.model_state_file}

def update_train_state(args, model, train_state):
    # Save one model at least
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # Save model if performance improved
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]

        # If loss worsened
        if loss_t >= train_state['early_stopping_best_val']:
            # Update step
            train_state['early_stopping_step'] += 1
        # Loss decreased
        else:
            # Save the best model
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

            # Reset early stopping step
            train_state['early_stopping_step'] = 0

        # Stop early ?
        train_state['stop_early'] = \
            train_state['early_stopping_step'] >= args.early_stopping_criteria

    return train_state

def compute_accuracy(y_pred, y_target):
    y_pred_indices = y_pred.max(dim=1)[1]
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    return n_correct / len(y_pred_indices) * 100

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 options
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
    catch_keyboard_interrupt=True
)

if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir,
                                        args.vectorizer_file)

    args.model_state_file = os.path.join(args.save_dir,
                                         args.model_state_file)
    
    print("Expanded filepaths: ")
    print("\t{}".format(args.vectorizer_file))
    print("\t{}".format(args.model_state_file))
    
# Check CUDA
if not torch.cuda.is_available():
    args.cuda = False

args.device = torch.device("cuda" if args.cuda else "cpu")
print("Using CUDA: {}".format(args.cuda))

def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)
        
def handle_dirs(dirpath):
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)
        
# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda)

# handle dirs
handle_dirs(args.save_dir)

结果:
在这里插入图片描述
因为是在cg平台上操作,所以没用cuda。

if args.reload_from_files:
    # training from a checkpoint
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                              args.vectorizer_file)
else:
    # create dataset and vectorizer
    dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
    dataset.save_vectorizer(args.vectorizer_file)
    
vectorizer = dataset.get_vectorizer()

classifier = SurnameClassifier(initial_num_channels=len(vectorizer.surname_vocab), 
                               num_classes=len(vectorizer.nationality_vocab),
                               num_channels=args.num_channels)

classifer = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)

loss_func = nn.CrossEntropyLoss(weight=dataset.class_weights)
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                           mode='min', factor=0.5,
                                           patience=1)

train_state = make_train_state(args)
epoch_bar = tqdm_notebook(desc='training routine', 
                          total=args.num_epochs,
                          position=0)

dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train',
                          total=dataset.get_num_batches(args.batch_size), 
                          position=1, 
                          leave=True)
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val',
                        total=dataset.get_num_batches(args.batch_size), 
                        position=1, 
                        leave=True)

try:
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index

        # Iterate over training dataset

        # setup: batch generator, set loss and acc to 0, set train mode on

        dataset.set_split('train')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.0
        running_acc = 0.0
        classifier.train()

        for batch_index, batch_dict in enumerate(batch_generator):
            # 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_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

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

            # step 5. use optimizer to take gradient step
            optimizer.step()
            # -----------------------------------------
            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)

            # update bar
            train_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            train_bar.update()

        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # Iterate over val dataset

        # setup: batch generator, set loss and acc to 0; set eval mode on
        dataset.set_split('val')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.
        running_acc = 0.
        classifier.eval()

        for batch_index, batch_dict in enumerate(batch_generator):

            # 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_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)
            val_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            val_bar.update()

        train_state['val_loss'].append(running_loss)
        train_state['val_acc'].append(running_acc)

        train_state = update_train_state(args=args, model=classifier,
                                         train_state=train_state)

        scheduler.step(train_state['val_loss'][-1])

        if train_state['stop_early']:
            break

        train_bar.n = 0
        val_bar.n = 0
        epoch_bar.update()
except KeyboardInterrupt:
    print("Exiting loop")

训练过程:
在这里插入图片描述

classifier.load_state_dict(torch.load(train_state['model_filename']))

classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

dataset.set_split('test')
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)
running_loss = 0.
running_acc = 0.
classifier.eval()

for batch_index, batch_dict in enumerate(batch_generator):
    # compute the output
    y_pred =  classifier(batch_dict['x_surname'])
    
    # compute the loss
    loss = loss_func(y_pred, batch_dict['y_nationality'])
    loss_t = loss.item()
    running_loss += (loss_t - running_loss) / (batch_index + 1)

    # compute the accuracy
    acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
    running_acc += (acc_t - running_acc) / (batch_index + 1)

train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc
print("Test loss: {};".format(train_state['test_loss']))
print("Test Accuracy: {}".format(train_state['test_acc']))

结果:
在这里插入图片描述

3.4.5 模型评估和预测

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

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

对姓氏进行分类或检索前几个预测

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

Example 4-21. Using the trained model to make predictions:

def predict_nationality(surname, classifier, vectorizer):
    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}


new_surname = input("Enter a surname to classify: ")
classifier = classifier.cpu()
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

结果:
在这里插入图片描述

def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # returned size is 1,k
    probability_values = probability_values[0].detach().numpy()
    indices = indices[0].detach().numpy()
    
    results = []
    for kth_index in range(k):
        nationality = vectorizer.nationality_vocab.lookup_index(indices[kth_index])
        probability_value = probability_values[kth_index]
        results.append({'nationality': nationality, 
                        'probability': probability_value})
    return results

new_surname = input("Enter a surname to classify: ")

k = int(input("How many of the top predictions to see? "))
if k > len(vectorizer.nationality_vocab):
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
    k = len(vectorizer.nationality_vocab)
    
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))

结果:
在这里插入图片描述

3.5 CNN其他内容

3.5.1 Pooling Operation(池化操作)

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.5.2 Batch Normalization (BatchNorm)

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

3.5.3 Network-in-Network Connections (1x1 Convolutions)

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

3.5.4 Residual Connections/Residual Block(残差)

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值