一、实验介绍
1. 实验内容
在本实验中,我们将研究两种前馈神经网络:多层感知器和卷积神经网络。多层感知器通过将多个感知器组合在单个层中,并叠加多个层来扩展简单感知器的结构。卷积神经网络受到窗口滤波器的启发,能够学习输入中的局部模式,使其在计算机视觉等领域中具有广泛应用。这两种神经网络都是前馈神经网络,与递归神经网络形成对比,后者允许反馈或循环,使得网络可以从之前的计算中获取信息。理解这些神经网络层对数据张量大小和形状的影响对于深入理解这些模型非常重要。
2. 实验要点
- 通过“示例:带有多层感知器的姓氏分类”,掌握多层感知器在多层分类中的应用
- 掌握每种类型的神经网络层对它所计算的数据张量的大小和形状的影响
3. 实验环境
- Python 3.6.7
二、The Multilayer Perceptron(多层感知器)
MLP(多层感知器)被认为是神经网络中最基本的构建模块之一。它是对感知器的扩展,将多个感知器组合在一起形成多个层,并引入非线性激活函数。在PyTorch中,通过设置线性层的输出特征数来构建MLP。一个简单的MLP通常包含三个阶段和两个线性层。第一个阶段是输入向量,代表给定给模型的数据。第一个线性层计算隐藏向量,称为第二阶段,因为它位于输入和输出之间。隐藏向量由多个感知器的输出组成。使用隐藏向量,第二个线性层计算输出向量。输出向量的大小取决于任务类型,可以是二元分类或多类分类的类别数。虽然本例中只展示了一个隐藏向量,但可以有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量通过线性层和非线性函数映射到输出向量。
mlp的力量来自于添加第二个线性层和允许模型学习一个线性分割的的中间表示——该属性的能表示一个直线(或更一般的,一个超平面)可以用来区分数据点落在线(或超平面)的哪一边的。学习具有特定属性的中间表示,如分类任务是线性可分的,这是使用神经网络的最深刻后果之一,也是其建模能力的精髓。在下一节中,我们将更深入地研究这意味着什么。
2.1 A Simple Example: XOR
让我们看一下前面描述的XOR示例,看看感知器与MLP之间会发生什么。在这个例子中,我们在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如图4-2所示。在这个图中,错误分类的数据点用黑色填充,而正确分类的数据点没有填充。在左边的面板中,从填充的形状可以看出,感知器在学习一个可以将星星和圆分开的决策边界方面有困难。然而,MLP(右面板)学习了一个更精确地对恒星和圆进行分类的决策边界。
图2中,每个数据点的真正类是该点的形状:星形或圆形。错误的分类用块填充,正确的分类没有填充。这些线是每个模型的决策边界。在边的面板中,感知器学习—个不能正确地将圆与星分开的决策边界。事实上,没有一条线可以。在右动的面板中,MLP学会了从圆中分离星。
虽然在图中显示MLP有两个决策边界,这是它的优点,但它实际上只是一个决策边界!决策边界就是这样出现的,因为中间表示法改变了空间,使一个超平面同时出现在这两个位置上。在图4-4中,我们可以看到MLP计算的中间值。这些点的形状表示类(星形或圆形)。我们所看到的是,神经网络(本例中为MLP)已经学会了“扭曲”数据所处的空间,以便在数据通过最后一层时,用一线来分割它们。
相反,如图3所示,感知器没有额外的一层来处理数据的形状,直到数据变成线性可分的。
2.2 Implementing MLPs in PyTorch
在上一节中,我们概述了MLP的核心思想。在本节中,我们将介绍PyTorch中的一个实现。如前所述,MLP除了简单的感知器之外,还有一个额外的计算层。在我们在例2-1中给出的实现中,我们用PyTorch的两个线性模块实例化了这个想法。线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层”。除了这两个线性层外,还有一个修正的线性单元(ReLU)非线性,它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层4,因此不能建模复杂的模式。MLP的实现只实现反向传播的前向传递。这是因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新。
Example 2-1. Multilayer Perceptron
seed = 1337
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
class MultilayerPerceptron(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
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):
intermediate = F.relu(self.fc1(x_in)) # 使用ReLU激活函数的第一个全连接层的输出
output = self.fc2(intermediate) # 第二个全连接层的输出
if apply_softmax:
output = F.softmax(output, dim=1) # 应用softmax激活函数到输出层
return output
在例2-2中,我们实例化了MLP。由于MLP实现的通用性,可以为任何大小的输入建模。为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度。请注意,在print语句的输出中,每个层中的单元数很好地排列在一起,以便为维度3的输入生成维度4的输出。
Example 2-2. An example instantiation of an MLP
# 定义输入批次大小、输入维度、隐藏层维度和输出维度
batch_size = 2 # 一次输入的样本数
input_dim = 3 # 输入向量的维度
hidden_dim = 100 # 第一个线性层(隐藏层)的输出大小
output_dim = 4 # 第二个线性层的输出大小
# 初始化模型
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
print(mlp) # 打印MLP模型的架构
MultilayerPerceptron(
(fc1): Linear(in_features=3, out_features=100, bias=True)
(fc2): Linear(in_features=100, out_features=4, bias=True)
)
我们可以通过传递一些随机输入来快速测试模型的“连接”,如示例2-3所示。因为模型还没有经过训练,所以输出是随机的。在花费时间训练模型之前,这样做是一个有用的完整性检查。请注意PyTorch的交互性是如何让我们在开发过程中实时完成所有这些工作的,这与使用NumPy或panda没有太大区别:
Example 2-3. Testing the MLP with random inputs
def describe(x):
print("Type: {}".format(x.type())) # 打印张量的数据类型
print("Shape/size: {}".format(x.shape)) # 打印张量的形状/大小
print("Values: \n{}".format(x)) # 打印张量的值
# Inputs
x_input = torch.rand(batch_size, input_dim)
describe(x_input)
Type: torch.FloatTensor
Shape/size: torch.Size([2, 3])
Values:
tensor([[0.8329, 0.4277, 0.4363],
[0.9686, 0.6316, 0.8494]])
y_output = mlp(x_input, apply_softmax=False)
describe(y_output)
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]], grad_fn=<AddmmBackward>)
上述代码运行结果:
Type: torch.FloatTensor
Shape/size: torch.Size([2, 3])
Values:
tensor([[0.6193, 0.7045, 0.7812],
[0.6345, 0.4476, 0.9909]])
学习如何读取PyTorch模型的输入和输出非常重要。在前面的例子中,MLP模型的输出是一个有两行四列的张量。这个张量中的行与批处理维数对应,批处理维数是小批处理中的数据点的数量。列是每个数据点的最终特征向量。在某些情况下,例如在分类设置中,特征向量是一个预测向量。名称为“预测向量”表示它对应于一个概率分布。预测向量会发生什么取决于我们当前是在进行训练还是在执行推理。在训练期间,输出按原样使用,带有一个损失函数和目标类标签的表示。我们将在“示例:带有多层感知器的姓氏分类”中对此进行深入介绍。
但是,如果想将预测向量转换为概率,则需要额外的步骤。具体来说,需要softmax函数,它用于将一个值向量转换为概率。softmax有许多根。在物理学中,它被称为玻尔兹曼或吉布斯分布;在统计学中,它是多项式逻辑回归;在自然语言处理(NLP)社区,它是最大熵(MaxEnt)分类器。不管叫什么名字,这个函数背后的直觉是,大的正值会导致更高的概率,小的负值会导致更小的概率。在示例2-3中,apply_softmax参数应用了这个额外的步骤。在例2-4中,可以看到相同的输出,但是这次将apply_softmax标志设置为True:
Example 2-4. MLP with apply_softmax=True
y_output = mlp(x_input, apply_softmax=True)
describe(y_output)
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]], grad_fn=<SoftmaxBackward>)
上述代码运行结果:
Type: torch.FloatTensor
Shape/size: torch.Size([2, 4])
Values:
tensor([[0.2915, 0.2541, 0.2277, 0.2267],
[0.2740, 0.2735, 0.2189, 0.2336]], grad_fn=<SoftmaxBackward>)
综上所述,mlp是将张量映射到其他张量的线性层。在每一对线性层之间使用非线性来打破线性关系,并允许模型扭曲向量空间。在分类设置中,这种扭曲应该导致类之间的线性可分性。另外,可以使用softmax函数将MLP输出解释为概率,但是不应该将softmax与特定的损失函数一起使用,因为底层实现可以利用高级数学/计算捷径。
三、实验步骤
3.1 Example: Surname Classification with a Multilayer Perceptron
在本节中,我们将MLP用于姓氏分类任务,通过从数据中推断人口统计信息,如国籍。这些人口统计信息称为“受保护属性”,在使用时需要谨慎处理。
首先,我们将姓氏拆分为字符,并处理这些字符。字符层模型与基于单词的模型在结构和实现上相似。
MLP的实现和训练直接从第3章的感知器发展而来。我们不包括“餐馆评论情绪分类”中的所有代码,但基础相同。
本节内容包括:
- 描述姓氏数据集及其预处理步骤。
- 使用词汇表、向量化器和DataLoader类将姓氏字符串转化为小批处理向量。
- 描述姓氏分类器模型及其设计过程。
- 训练例程,与“餐馆评论情绪分类”中的方法相似。
通过这些步骤,我们展示了如何将MLP应用于多类分类任务。
3.1.1 The Surname Dataset
姓氏数据集包含来自18个不同国家的10,000个姓氏,这些姓氏由作者从互联网上不同的姓名来源收集。这个数据集在多个实验中重用,具有一些有趣的属性:
- 不平衡性:数据集非常不平衡,排名前三的类别占60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族频率逐渐减少,这是语言特有的特性。
- 拼写与国籍的关系:国籍和姓氏拼写之间有有效且直观的关系。例如,“O 'Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”都与特定国籍紧密关联。
为了创建最终的数据集,我们从一个较少处理的版本开始,并进行了一些修改。首先,我们通过选择标记为俄语的姓氏的随机子集来减少不平衡性,因为原始数据集中70%以上是俄文。然后,我们根据国籍对数据集进行分组,并将数据集分为训练集(70%)、验证集(15%)和测试集(15%),以确保类标签在各部分间的分布相似。
Example 3-1. Implementing SurnameDataset.__getitem__()
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
初始化数据集
参数:
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(