自然语言处理前馈网络
- 背景介绍
感知机
感知器是现存最简单的神经网络。感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如,查看图1中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(也称为线性可分)。在这个例子中,感知器失败了.
图1 XOR数据集中的两个类绘制为圆形和心性
本实验研究的第二种前馈神经网络,卷积神经网络,在处理数字信号时深受窗口滤波器的启发。通过这种窗口特性,卷积神经网络能够在输入中学习局部化模式,这不仅使其成为计算机视觉的主轴,而且是检测单词和句子等序列数据中的子结构的理想候选。我们在“卷积神经网络”中概述了卷积神经网络,并在“示例:使用CNN对姓氏进行分类”中演示了它们的使用。
在本实验中,多层感知器和卷积神经网络被分组在一起,因为它们都是前馈神经网络,并且与另一类神经网络——递归神经网络(RNNs)形成对比,递归神经网络(RNNs)允许反馈(或循环),这样每次计算都可以从之前的计算中获得信息。
- 多层感知机
当我们谈论多层感知器(MLP)时,我们指的是一种基本的神经网络结构,它由多个层组成,每个层中包含多个感知器(或神经元)。感知器接收输入数据向量,并通过线性变换计算出一个输出值。在MLP中,多个感知器被组织成层,每一层的输出是一个新的向量,而不是单个输出值。在PyTorch中,创建一个MLP只需要设置每个线性层中输出特性的数量。
一个简单的MLP通常包含多个阶段和多个线性层。例如,在图中所示的示例中,第一阶段是输入向量,例如Yelp评论的收缩的one-hot表示。这个输入向量经过第一个线性层后产生一个隐藏向量,该向量被称为“第二阶段”的输出。隐藏向量的名称源于它位于输入和最终输出之间的层。每个层的输出是通过层内各个感知器的输出值组成的。在一个二进制分类任务中,如Yelp评论的情绪分类,最终的输出向量可以表示两个类别之一。在多类别设置中,输出向量的维度将等于类别的数量,每个元素表示相应类别的概率或分数。尽管本例中只展示了一个隐藏向量,实际上MLP可以包含多个中间层,每个中间层都生成自己的隐藏向量。每个隐藏向量都通过线性层和非线性激活函数的组合,映射到下一个阶段的输出向量。
综上所述,MLP通过多个层和每层之间的非线性结合,能够学习和表示复杂的数据模式和关系。
图2 一种具有两个线性层和三个表示阶段的MLP的可视化表示
2.1 一个简单的例子:XOR
在这个例子中,我们在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如图3所示。在这个图中,错误分类的数据点用黑色填充,而正确分类的数据点没有填充。在左边的面板中,从填充的形状可以看出,感知器在学习一个可以将星星和圆分开的决策边界方面有困难。然而,MLP(右面板)学习了一个更精确地对恒星和圆进行分类的决策边界。
图3 从感知器(左)和MLP(右)学习的XOR问题的解决方案显示
2.2 MLPs在PyTorch中的实现与应用
在一个多层感知器(MLP)中,通常会定义几个线性层,如 `fc1` 和 `fc2`,这些层被称为“全连接层”或简称为“fc层”。除了这些线性层之外,通常还会使用修正线性单元(ReLU)作为非线性激活函数,将第一个线性层的输出作为输入,然后再输入到第二个线性层中。这种非线性的引入非常重要,因为如果仅使用线性层,多个线性层的组合等效于单个线性层,这会限制模型学习复杂的数据模式和关系。
在PyTorch中,MLP的实现涉及到正向传播和反向传播。正向传播是指输入数据通过网络,层层传递,最终产生预测结果。每个层的输出数量必须与下一层的输入数量匹配,这保证了数据的流动和计算的正确性。PyTorch通过定义模型的正向传播方法来实现这一过程。一旦正向传播方法定义好,PyTorch能够自动推导出反向传播的计算图,并据此进行梯度的计算和参数更新。这种自动求导的机制使得实现和训练MLP变得更加简洁和高效。
总结来说,MLP通过多个全连接层和非线性激活函数的组合,能够有效地建模和学习复杂的数据模式,而PyTorch的自动求导机制简化了MLP模型的实现和训练过程。
Example 1 Multilayer Perception
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)) #第一个全连接层后使用relu激活函数
output = self.fc2(intermediate)
if apply_softmax:
output = F.softmax(output, dim=1)
return output
在例2中,使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度实例化MLP。
Example 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)
print(mlp)
上述代码运行结果:
MultilayerPerceptron(
(fc1): Linear(in_features=3, out_features=100, bias=True)
(fc2): Linear(in_features=100, out_features=4, bias=True)
)
我们可以通过传递一些随机输入来快速测试模型的“连接”,如示例4-3所示。
Example 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)
上述代码运行结果:
Type: torch.FloatTensor
Shape/size: torch.Size([2, 3])
Values:
tensor([[0.6193, 0.7045, 0.7812],
[0.6345, 0.4476, 0.9909]])
在前面例子中,MLP模型的输出是一个有两行四列的张量。这个张量中的行与批处理维数对应,批处理维数是小批处理中的数据点的数量。列是每个数据点的最终特征向量。在某些情况下,例如在分类设置中,特征向量是一个预测向量。名称为“预测向量”表示它对应于一个概率分布。预测向量会发生什么取决于我们当前是在进行训练还是在执行推理。在训练期间,输出按原样使用,带有一个损失函数和目标类标签的表示。
但是,如果想将预测向量转换为概率,则需要额外的步骤。具体来说,需要softmax函数,它用于将一个值向量转换为概率,这个函数背后的直觉是,大的正值会导致更高的概率,小的负值会导致更小的概率。在示例3中,apply_softmax参数应用了这个额外的步骤。在例4中,可以看到相同的输出,但是这次将apply_softmax标志设置为True:
Example 4 MLP with apply_softmax=True
y_output = mlp(x_input, apply_softmax=False)
describe(y_output)
上述代码运行结果:
Type: torch.FloatTensor
Shape/size: torch.Size([2, 4])
Values:
tensor([[ 0.2356, 0.0983, -0.0111, -0.0156],
[ 0.1604, 0.1586, -0.0642, 0.0010]], grad_fn=<AddmmBackward>)
- 实验步骤
3.1 基于MLP的姓氏分类任务
在这个章节中,我们的目标是使用多层感知器(MLP)来解决一个姓氏分类的任务,即根据姓氏将其分类到其原籍国家。接下来的部分将详细描述姓氏数据集及其预处理步骤。
首先,我们会介绍姓氏数据集的结构和如何进行预处理,包括数据的加载、清洗和准备。然后,我们会使用词汇表来构建一个向量化器,将姓氏字符串转换成模型可以处理的数值向量,并通过DataLoader类来实现对数据的批处理和加载,这是训练过程中常用的工具。接着,我们将详细描述姓氏分类器模型的设计及其背后的思想过程。MLP类似于感知器,但在这个例子中,我们会引入多类输出,并使用适当的损失函数来处理多类别分类任务。在模型描述之后,我们会展示如何实现训练过程,包括设置优化器、定义损失函数、迭代数据并更新模型参数,以便使模型能够逐步学习和优化,总结来说,本节将详细介绍从姓氏数据到MLP模型的完整流程,包括数据预处理、模型设计和训练例程,旨在帮助读者理解如何利用MLP解决实际的分类问题。
3.1.1 The Surname Dataset
姓氏数据集,SurnameDataset数据集包含来自18个国家的10,000个姓氏。数据集不平衡,前三国家占60%以上:英语27%、俄语21%、阿拉伯语14%。姓氏与国家有密切关系,如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”。为减少不平衡,从俄语姓氏中随机选取子集。数据集按国籍分为训练集70%、验证集15%和测试集15%。SurnameDataset继承自PyTorch数据集类,实现了__getitem__和len方法。__getitem__方法如示例5所示。它返回一个向量化的姓氏和与其国籍相对应的索引:
Example 5 Implementing SurnameDataset:__getitem()
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}
3.1.2 Vocabulary,Vectorizer,and DataLoader
为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。
THE VOCABULARY CLASS
词汇表包含两个Python字典,用于字符到整数和整数到字符的双向映射。add_token方法添加新令牌,lookup_token方法按令牌查找索引,lookup_index方法按索引查找令牌(用于推断)。这是一个基于one-hot编码的简单词汇表,未计算字符频率,仅限制常见条目,因为数据集小且大多数字符频率相当。数据集类定义如例6所示:
Example 6 SurnameDataset
import pandas as pd
import torch
from torch.utils.data import Dataset, DataLoader
import json
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
Args:
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):
"""加载数据集并从头开始创建一个新的向量化器
Args:
surname_csv (str): 数据集的位置
Returns:
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的实例
"""
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):
"""静态方法,从文件加载向量化器
Args:
vectorizer_filepath (str): 序列化的向量化器的位置
Returns:
SurnameVectorizer的实例
"""
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp))
def save_vectorizer(self, vectorizer_filepath):
"""使用json将向量化器保存到磁盘
Args:
vectorizer_filepath (str): 保存向量化器的位置
"""
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):
"""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
THE SURNAMEVECTORIZER
虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
Example 7 Implementing SurnameVectorizer
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 one-hot 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 #将提供的姓氏转换为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)
3.1.3 The Surname Classifier Model
SurnameClassifier是前面介绍的MLP的实现(示例7)。第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。
在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。
Example 8 The SurnameClassifier as an MLP
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
pass
3.1.4 The Training Routine
训练例程如例9所示:
Example 9 The args for classifying surnames with an MLP
import torch
#Helper Functions
def make_train_state(args):
"""
创建一个初始的训练状态字典。
Args:
args: 包含训练参数的命名空间
Returns:
dict: 包含初始训练状态的字典
"""
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):
"""
处理训练状态的更新,包括早停和模型保存。
Args:
args: 主要参数
model: 待训练的模型
train_state: 表示训练状态值的字典
Returns:
dict: 更新后的训练状态字典
"""
# 第一轮保存模型
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):
"""
计算预测准确率。
Args:
y_pred: 模型预测的结果
y_target: 真实的目标标签
Returns:
float: 准确率百分比
"""
_, 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
在例10中,我们展示了数据集、模型、损失函数和优化器的实例化:
Example 10 Insantiating the dataset,model,loss,and optimizer
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)
THE TRAINING LOOP
示例11显示了使用不同的key从batch_dict中获取数据,利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。
Example 11 A snippet of the training loop
# 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()
3.1.5 模型评估与预测
为了全面了解模型的性能,我们需要使用定量和定性方法来分析它的表现。定量方法测量测试数据的误差,帮助确定分类器在未见过的例子上的泛化能力。定性方法则通过查看分类器的前k个预测结果来直观地了解模型在新示例上的表现。
3.1.5.1 在测试数据集上评估
评估姓氏分类器在测试数据集上的表现时,首先确保调用 `classifier.eval()`,这可以防止PyTorch在评估时更新模型参数,确保评估结果的稳定性。
根据我们的评估,模型在测试数据集上的准确率约为50%。然而,在训练数据集上,我们可能会观察到更高的性能。这是因为模型通常更适应于它所训练的数据,而训练数据上的性能并不总能反映出模型在新数据上的表现。
在我们的训练示例中,你可能会注意到尝试不同隐藏层维度大小会影响性能。尽管性能可能会略有改善,但这种改进并不会很大。主要原因在于我们采用了简单的one-hot向量化方法,它虽然简洁地将每个姓氏表示为一个向量,但却丢失了字符顺序之间的重要信息,而这些信息对于姓氏起源的识别是非常关键的。
3.1.5.2 分类一个新的姓氏
以下是分类新姓氏的示例代码(示例12)。给定一个姓氏作为字符串,该函数首先将其向量化,然后获取模型的预测结果。我们设置了 `apply_softmax` 标志,因此结果中包含类别的概率分布。在多类别分类问题中,模型预测的结果是每个类别的概率列表。我们使用PyTorch张量的 `argmax` 函数来确定具有最高预测概率的类别。
这些方法和步骤能够帮助我们评估和预测姓氏分类模型的性能,同时也提供了改进模型的方向和洞察。
Example 12 A function for performing nationality prediction
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}
3.1.5.3 RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME
不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测,如示例13所示。
Example 13 Predicting the top-k nationalities
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) #获取预测概率最高的的前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) #查找第i个索引对应的国籍
results.append({'nationality': nationality,
'probability': prob_value})
return results
3.1.6 Regularizing MLPs: Weight Regularization and Structural Regularization (or Dropout)
正则化有助于解决过拟合问题,可以分为权重正则化(如L1和L2范数惩罚)和结构正则化(例如dropout)。这两种方法同样适用于多层感知机(MLP)和卷积神经网络。
**Dropout** 是一种重要的结构正则化方法,特别适用于深度模型,如前馈神经网络。在训练过程中,dropout以一定的概率随机丢弃相邻层之间的单元连接。这种随机丢弃可以防止神经网络中的单元过度依赖彼此,从而促进模型的鲁棒性,减少过拟合风险。
**具体实现**:在每个训练步骤中,dropout以一定的概率(称为“drop probability”)随机将单元的连接置为零。这个概率通常设置为0.5,意味着每个单元有一半的概率被丢弃。需要注意的是,dropout并不会增加模型的参数量,但需要额外的超参数来控制丢弃的概率。
例子14展示了一个使用dropout重新实现的MLP,通过这种方式可以有效地应用结构正则化,提高模型的泛化能力。
这种方式通过随机丢弃单元之间的连接,避免了神经网络中的“coadaptation”,从而减少了过拟合风险,使得模型能够更好地适应不同的数据集。
Example 14 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): 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)) #对fc1和fc2之间使用dropout,drop probability=0.5
if apply_softmax:
output = F.softmax(output, dim=1)
return output
请注意,dropout只适用于训练期间,不适用于评估期间。
3.2 Convolutional Neural Networks
在本节中,我们将探讨卷积神经网络(CNN),这是一种非常适合处理具有空间结构的数据的神经网络模型。MLPs(多层感知机)虽然在许多任务上表现出色,但它们并不是处理序列数据的最佳工具。例如,在姓氏数据集中,姓氏可能包含多个有意义的子结构,如“O’Neill”中的“O”,“Antonopoulos”中的“opoulos”,“Nagasawa”中的“sawa”或“Zhu”中的“Zh”,这些子结构长度不固定且难以显式编码。
CNN通过使用少量的权重来扫描输入数据张量,非常适合检测这种空间子结构。在CNN中,核(kernel)是一种小型的方形矩阵,它以系统化的方式应用于输入矩阵的不同位置。这种卷积运算可以有效地捕获输入数据中的局部模式,从而生成输出张量,表示检测到或未检测到的子结构。
图4展示了一个示例,说明了单个核如何应用于输入矩阵。虽然卷积运算的精确数学表达式可能超出本节范围,但通过这个示例可以直观地理解卷积操作如何在CNN中工作。
通过使用CNN,我们能够利用其优秀的空间处理能力来提取和利用数据中的结构信息,而无需显式地对子结构进行编码。这使得CNN成为处理诸如姓氏数据集中所示复杂结构的强大工具。
图4 二位卷积运算
输入矩阵与单个产生输出矩阵的卷积核(也称为特征映射)在输入矩阵的每个位置应用内核。在每个应用程序中,内核乘以输入矩阵的值及其自身的值,然后将这些乘法相加kernel具有以下超参数配置:kernel_size=2,stride=1,padding=0,以及dilation=1。这些超参数解释如下:
虽然经典卷积是通过指定核的具体值来设计的,但是CNN是通过指定控制CNN行为的超参数来设计的,然后使用梯度下降来为给定数据集找到最佳参数。两个主要的超参数控制卷积的形状(称为kernel_size)和卷积将在输入数据张量(称为stride)中相乘的位置。还有一些额外的超参数控制输入数据张量被0填充了多少(称为padding),以及当应用到输入数据张量(称为dilation)时,乘法应该相隔多远。在下面的小节中,我们将更详细地介绍这些超参数。
DIMENSION OF THE CONVOLUTION OPERATION
首先要理解的概念是卷积运算的维数。在图4和本节的其他图中,我们使用二维卷积进行说明,但是根据数据的性质,还有更适合的其他维度的卷积。在PyTorch中,卷积可以是一维、二维或三维的,分别由Conv1d、Conv2d和Conv3d模块实现。一维卷积对于每个时间步都有一个特征向量的时间序列非常有用。在这种情况下,我们可以在序列维度上学习模式。NLP中的卷积运算大多是一维的卷积。另一方面,二维卷积试图捕捉数据中沿两个方向的时空模式;例如,在图像中沿高度和宽度维度——为什么二维卷积在图像处理中很流行。类似地,在三维卷积中,模式是沿着数据中的三维捕获的。例如,在视频数据中,信息是三维的,二维表示图像的帧,时间维表示帧的序列。
CHANNELS
非正式地,通道(channel)是指沿输入中的每个点的特征维度。例如,在图像中,对应于RGB组件的图像中的每个像素有三个通道。在使用卷积时,文本数据也可以采用类似的概念。在PyTorch卷积实现中,输入通道的数量是in_channels参数。卷积操作可以在输出(out_channels)中产生多个通道。您可以将其视为卷积运算符将输入特征维“映射”到输出特征维。图5和图6说明了这个概念。
图5 两个输入矩阵表示相应的核也有两层
图6 一种具有一个输入矩阵和两个卷积的卷积运算核
KERNEL SIZE
核矩阵的宽度称为核大小(PyTorch中的kernel_size)。在图4中,核大小为2,而在图7中,我们显示了一个大小为3的内核。卷积将输入中的空间(或时间)本地信息组合在一起,每个卷积的本地信息量由内核大小控制。然而,通过增加核的大小,也会减少输出的大小。这就是为什么当核大小为3时,输出矩阵是图7中的2x2,而当核大小为2时,输出矩阵是图4中的3x3。
图7 将kernal_size=3的卷积应用于输入矩阵
STRIDE
Stride控制卷积之间的步长。如果步长与核相同,则内核计算不会重叠。另一方面,如果跨度为1,则内核重叠最大。输出张量可以通过增加步幅的方式被有意的压缩来总结信息,如图8所示。
图8 应用于具有超参数步长的输入的kernel_size=2的卷积核等于2
PADDING
即使stride和kernel_size允许控制每个计算出的特征值有多大范围,它们也有一个有害的、有时是无意的副作用,那就是缩小特征映射的总大小(卷积的输出)。为了抵消这一点,输入数据张量被人为地增加了长度(如果是一维、二维或三维)、高度(如果是二维或三维)和深度(如果是三维),方法是在每个维度上附加和前置0。这意味着CNN将执行更多的卷积,但是输出形状可以控制,而不会影响所需的核大小、步幅或扩展。图9展示了正在运行的填充。
图9 由于填充,输出矩阵将等于输入矩阵的大小
DILATION
膨胀控制卷积核如何应用于输入矩阵。在图10中,我们显示,将膨胀从1(默认值)增加到2意味着当应用于输入矩阵时,核的元素彼此之间是两个空格。另一种考虑这个问题的方法是在核中跨跃——在核中的元素或核的应用之间存在一个step size,即存在“holes”。这对于在不增加参数数量的情况下总结输入空间的更大区域是有用的。当卷积层被叠加时,扩张卷积被证明是非常有用的。连续扩张的卷积指数级地增大了“接受域”的大小;即网络在做出预测之前所看到的输入空间的大小。
3.3 基于卷积神经网络(CNN)进行姓氏分类
为了证明CNN在分类姓氏任务中的有效性,我们可以构建一个简单的CNN模型。与之前的多层感知器(MLP)示例相比,这里的关键变化是模型的架构和输入数据的向量化方式。
在CNN模型中,输入将是一个one-hot编码的矩阵,而不是之前使用的收缩的one-hot编码。这种设计允许CNN更好地捕捉字符排列的信息,并且能够编码在之前示例中使用的收缩的one-hot编码中可能丢失的序列信息
3.4.1 The SurnameDataset
尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。示例15显示了对SurnameDataset.__getitem__的更改;这里使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因。首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。
Example 15 SurnameDataset modified for passing the maximum surname length
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}
pass
3.4.2 Vocabulary, Vectorizer, and DataLoader
在本例中,尽管词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。具体来说,正如我们在示例16中的代码中所示,该函数将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。重要的是,矩阵中的每一列都是不同的onehot向量。主要原因是,我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。
除了更改为使用onehot矩阵之外,我们还修改了矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length
Example 16 Implementing the Surname Vectorizer for CNNs
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 one-hot 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)
3.4.3 Reimplementing the SurnameClassifier with Convolutional Networks
正如在示例17中所看到的,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。
本例中的内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性。在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现256足够大,可以使模型达到合理的性能。
Example 17 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):
"""
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(), #采用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
3.4.4 The Training Routine
训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。对于这个例子,我们将不再详细描述具体的训练例程,因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同。但是,输入参数是不同的,可以在示例18中看到。
Example 18 Input arguments to the CNN surname classifier
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 ...
)
3.4.5 Model Evaluation and Prediction
在评估模型性能时,定量和定性度量是至关重要的。
定量度量包括使用准确率等指标来量化模型在测试数据集上的表现。例如,对于姓氏分类任务,模型的准确率约为56%。这种度量方法提供了一个清晰的数字来评估模型在预测上的整体准确性。
定性度量则涉及分析模型的预测结果,包括查看模型在一些典型样本上的表现以及对其错误分类的案例进行深入探讨。这种方法可以揭示模型在特定类别或样本上的弱点,并提供改进模型的见解。
综上所述,结合定量和定性度量可以全面评估模型的性能,帮助指导进一步的改进和优化策略。在本例中,predict_nationality()函数的一部分发生了更改,如示例19所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在predict_topk_nationality()函数中。
Example 19 Using the trained model to make predictions
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}