使用前馈神经网络进行姓氏分类
前言
在现代数据科学中,分类问题是一个非常常见的任务,而前馈神经网络(Feedforward Neural Network, FNN)作为一种简单且有效的深度学习模型,被广泛应用于各种分类任务中。在这篇博客中,我们将探讨如何使用前馈神经网络对姓氏进行分类。具体而言,我们将介绍数据预处理、模型定义、训练过程以及预测结果。通过这篇博客,读者可以了解如何构建和应用一个简单的前馈神经网络模型来解决实际的分类问题。
姓氏分类任务的核心是根据给定的姓氏预测其所属的国家或地区。这一任务具有一定的挑战性,因为姓氏的拼写形式多种多样,并且不同国家的姓氏可能存在相似性。因此,选择合适的模型和特征提取方法至关重要。我们将采用PyTorch作为主要工具,构建一个两层的多层感知器(MLP)模型,并展示其在姓氏分类任务中的应用。
一、实验介绍
1.1 实验内容
感知器是现存最简单的神经网络。感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如,查看图4-1中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(也称为线性可分)。在这个例子中,感知器失败了。
在这一实验中,我们将探索传统上称为前馈网络的神经网络模型,以及两种前馈神经网络:多层感知器和卷积神经网络。多层感知器在结构上扩展了我们在实验3中研究的简单感知器,将多个感知器分组在一个单层,并将多个层叠加在一起。
卷积神经网络,在处理数字信号时深受窗口滤波器的启发。通过这种窗口特性,卷积神经网络能够在输入中学习局部化模式,这不仅使其成为计算机视觉的主轴,而且是检测单词和句子等序列数据中的子结构的理想候选。
多层感知器和卷积神经网络被分组在一起,因为它们都是前馈神经网络,并且与另一类神经网络——递归神经网络(RNNs)形成对比,递归神经网络(RNNs)允许反馈(或循环),这样每次计算都可以从之前的计算中获得信息。
2. 实验要点
- 通过“示例:带有多层感知器的姓氏分类”,掌握多层感知器在多层分类中的应用
- 掌握每种类型的神经网络层对它所计算的数据张量的大小和形状的影响
3. 实验环境
- Python 3.6.7
二、The Multilayer Perceptron(多层感知器)
多层感知器(MLP)被认为是最基本的神经网络构建模块之一。感知器将数据向量作为输入,计算出一个输出值。在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。MLP的另一个方面是,它将多个层与每个层之间的非线性结合在一起。
最简单的MLP,如图4-2所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。
给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。
最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。
mlp的力量来自于添加第二个线性层和允许模型学习一个线性分割的的中间表示——该属性的能表示一个直线(或更一般的,一个超平面)可以用来区分数据点落在线(或超平面)的哪一边的。
2.1 A Simple Example: XOR
在这个例子中,我们在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如图4-3所示。在这个图中,错误分类的数据点用黑色填充,而正确分类的数据点没有填充。
图4-3中,每个数据点的真正类是该点的形状:星形或圆形。错误的分类用块填充,正确的分类没有填充。这些线是每个模型的决策边界。在边的面板中,感知器学习—个不能正确地将圆与星分开的决策边界。
决策边界就是这样出现的,因为中间表示法改变了空间,使一个超平面同时出现在这两个位置上。在图4-4中,我们可以看到MLP计算的中间值。这些点的形状表示类(星形或圆形)。
相反,如图4-5所示,感知器没有额外的一层来处理数据的形状,直到数据变成线性可分的。
2.2 Implementing MLPs in PyTorch
如前所述,MLP除了实验3中简单的感知器之外,还有一个额外的计算层。在我们在例4-1中给出的实现中,我们用PyTorch的两个线性模块实例化了这个想法。
线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层”。除了这两个线性层外,还有一个修正的线性单元(ReLU)非线性,它在被输入到第二个线性层之前应用于第一个线性层的输出。
由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。
import torch.nn as nn # 导入PyTorch的神经网络模块
import torch.nn.functional as F # 导入PyTorch的函数式神经网络模块
class MultilayerPerceptron(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
"""
参数:
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的前向传播
参数:
x_in (torch.Tensor): 输入数据张量。
x_in的形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用Softmax激活函数的标志
如果与交叉熵损失一起使用,应为False
返回:
结果张量。张量的形状应为 (batch, output_dim)
"""
intermediate = F.relu(self.fc1(x_in)) # 应用ReLU激活函数计算第一层的输出
output = self.fc2(intermediate) # 计算第二层的输出
if apply_softmax:
output = F.softmax(output, dim=1) # 如果apply_softmax为True,应用Softmax激活函数
return output # 返回输出张量
使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层4,因此不能建模复杂的模式。MLP的实现只实现反向传播的前向传递。这是因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新。
在例4-2中,我们实例化了MLP。
Example 4-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实现的通用性,可以为任何大小的输入建模。为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度。在print语句的输出中,每个层中的单元数很好地排列在一起,以便为维度3的输入生成维度4的输出。
这个张量中的行与批处理维数对应,批处理维数是小批处理中的数据点的数量。列是每个数据点的最终特征向量。在某些情况下,例如在分类设置中,特征向量是一个预测向量。名称为“预测向量”表示它对应于一个概率分布。预测向量会发生什么取决于我们当前是在进行训练还是在执行推理。在训练期间,输出按原样使用,带有一个损失函数和目标类标签的表示。
如果想将预测向量转换为概率,则需要额外的步骤。具体来说,需要softmax函数,它用于将一个值向量转换为概率。这个函数背后的直觉是,大的正值会导致更高的概率,小的负值会导致更小的概率。
Example 4-4. MLP with apply_softmax=True
# 使用MLP模型进行前向传播,计算输出张量,并应用Softmax激活函数
y_output = mlp(x_input, apply_softmax=True)
# 调用describe函数打印y_output的相关信息
describe(y_output)
上述代码运行结果:
综上所述,mlp是将张量映射到其他张量的线性层。在每一对线性层之间使用非线性来打破线性关系,并允许模型扭曲向量空间。在分类设置中,这种扭曲应该导致类之间的线性可分性。另外,可以使用softmax函数将MLP输出解释为概率,但是不应该将softmax与特定的损失函数一起使用,因为底层实现可以利用高级数学/计算捷径。
三、实验步骤
3.1 Example: Surname Classification with a Multilayer Perceptron
在本节中,我们将MLP应用于将姓氏分类到其原籍国的任务。从公开观察到的数据推断人口统计信息(如国籍)具有从产品推荐到确保不同人口统计用户获得公平结果的应用。人口统计和其他自我识别信息统称为“受保护属性”。“在建模和产品中使用这些属性时,必须小心。
MLP类似于我们在实验3中看到的感知器例子,但是除了模型的改变,我们在这个例子中引入了多类输出及其对应的损失函数。
3.1.1 The Surname Dataset
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。
第一个性质是它是相当不平衡的。
第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。
为了创建最终的数据集,我们从一个比课程补充材料中包含的版本处理更少的版本开始,并执行了几个数据集修改操作。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。
__getitem
方法,它在给定索引时返回一个数据点;以及len方法,该方法返回数据集的长度。
class SurnameDataset(Dataset):
# 实现与第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方法用于检索给定索引的令牌(在推断阶段很有用)。
THE SURNAMEVECTORIZER
虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。
我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
Example 4-6. Implementing SurnameVectorizer
class SurnameVectorizer(object):
""" 协调词汇表并使用它们的向量化器"""
def __init__(self, surname_vocab, nationality_vocab):
self.surname_vocab = surname_vocab # 姓氏词汇表
self.nationality_vocab = nationality_vocab # 国籍词汇表
def vectorize(self, surname):
"""对提供的姓氏进行向量化
参数:
surname (str): 姓氏
返回:
one_hot (np.ndarray): 压缩的one-hot编码
"""
vocab = self.surname_vocab
one_hot = np.zeros(len(vocab), dtype=np.float32) # 创建一个全零的one-hot编码数组
for token in surname:
one_hot[vocab.lookup_token(token)] = 1 # 将对应字母的位置设为1
return one_hot
@classmethod
def from_dataframe(cls, surname_df):
"""从数据集数据框中实例化向量化器
参数:
surname_df (pandas.DataFrame): 姓氏数据集
返回:
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) # 返回实例化的向量化器
虽然我们在这个示例中使用了收缩的one-hot,但是在后面的实验中,将了解其他向量化方法,它们是one-hot编码的替代方法,有时甚至更好。具体来说,在“示例:使用CNN对姓氏进行分类”中,将看到一个热门矩阵,其中每个字符都是矩阵中的一个位置,并具有自己的热门向量。
3.1.3 The Surname Classifier Model
SurnameClassifier是本实验前面介绍的MLP的实现(示例4-7)。第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。
import torch.nn as nn # 导入PyTorch的神经网络模块
import torch.nn.functional as F # 导入PyTorch的函数式神经网络模块
class SurnameClassifier(nn.Module):
""" 用于姓氏分类的两层多层感知器 """
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的形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用Softmax激活函数的标志
如果与交叉熵损失一起使用,应为False
返回:
结果张量。张量的形状应为 (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) # 如果apply_softmax为True,应用Softmax激活函数
return prediction_vector # 返回输出张量
在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
3.1.4 The Training Routine
训练中最显著的差异与模型中输出的种类和使用的损失函数有关。
args = Namespace(
# 数据和路径信息
surname_csv="surnames_with_splits.csv", # 姓氏数据的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, # 批大小
# 运行时选项省略以节省空间
)
在这个例子中,输出是一个多类预测向量,可以转换为概率。正如在模型描述中所描述的,这种输出的损失类型仅限于CrossEntropyLoss和NLLLoss。由于它的简化,我们使用了CrossEntropyLoss。
在例4-9中,我们展示了数据集、模型、损失函数和优化器的实例化。
Example 4-9. Instantiating 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))
# # 将模型移动到指定设备(如CPU或GPU)
# classifier = classifier.to(args.device)
# 定义损失函数
loss_func = nn.CrossEntropyLoss(dataset.class_weights) # 使用交叉熵损失函数,考虑类别权重
# 定义优化器
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate) # 使用Adam优化器
THE TRAINING LOOP
示例4-10显示了使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。
Example 4-10. A snippet of the training loop
# 训练流程包括以下5个步骤:
# 第1步. 清零梯度
optimizer.zero_grad()
# 第2步. 计算输出
y_pred = classifier(batch_dict['x_surname'])
# 第3步. 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality']) # 计算预测值和真实标签之间的损失
loss_batch = loss.to("cpu").item() # 将损失值转移到CPU并获取其数值
running_loss += (loss_batch - running_loss) / (batch_index + 1) # 更新当前的平均损失
# 第4步. 使用损失生成梯度
loss.backward() # 反向传播计算梯度
# 第5步. 使用优化器进行梯度下降步骤
optimizer.step() # 更新模型参数
3.1.5 Model Evaluation and Prediction
要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。
3.1.5.2 CLASSIFYING A NEW SURNAME
示例4-11显示了分类新姓氏的代码。给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。注意,我们包含了apply_softmax标志,所以结果包含概率。
Example 4-11. 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) # 转换为PyTorch张量并调整形状
# 使用分类器进行预测,应用Softmax激活函数
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}
模型预测,在多项式的情况下,是类概率的列表。我们使用PyTorch张量最大函数来得到由最高预测概率表示的最优类。
3.1.5.3 RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME
不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。
Example 4-12. 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) # 转换为PyTorch张量并调整形状
# 使用分类器进行预测,应用Softmax激活函数
prediction_vector = classifier(vectorized_name, apply_softmax=True)
# 获取前k个最大概率值及其对应的索引
probability_values, indices = torch.topk(prediction_vector, k=k)
# 返回的大小是 (1, k)
probability_values = probability_values.detach().numpy()[0] # 转换为NumPy数组
indices = indices.detach().numpy()[0] # 转换为NumPy数组
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 # 返回结果列表
3.1.6 Regularizing MLPs: Weight Regularization and Structural Regularization (or Dropout)
除权值正则化外,对于深度模型(即例如本实验讨论的前馈网络,一种称为dropout的结构正则化方法变得非常重要。
DROPOUT
简单地说,在训练过程中,dropout有一定概率使属于两个相邻层的单元之间的连接减弱。
神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“Coadaptation”是神经科学中的一个术语,但在这里它只是指一种情况,即两个单元之间的联系变得过于紧密,而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接,我们可以确保没有一个单元总是依赖于另一个单元,从而产生健壮的模型。
例4-13给出了一个带dropout的MLP的重新实现。
Example 4-13. MLP with dropout
import torch.nn as nn # 导入PyTorch的神经网络模块
import torch.nn.functional as F # 导入PyTorch的函数式神经网络模块
class MultilayerPerceptron(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
"""
参数:
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):
"""多层感知器的前向传播
参数:
x_in (torch.Tensor): 输入数据张量。
x_in的形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用Softmax激活函数的标志
如果与交叉熵损失一起使用,应为False
返回:
结果张量。张量的形状应为 (batch, output_dim)
"""
intermediate = F.relu(self.fc1(x_in)) # 应用ReLU激活函数计算第一层的输出
output = self.fc2(F.dropout(intermediate, p=0.5)) # 计算第二层的输出,并应用Dropout防止过拟合
if apply_softmax:
output = F.softmax(output, dim=1) # 如果apply_softmax为True,应用Softmax激活函数
return output # 返回输出张量
dropout不会向模型中添加额外的参数,但是需要一个超参数——“drop probability”。drop probability,它是单位之间的连接drop的概率。通常将下降概率设置为0.5。
请注意,dropout只适用于训练期间,不适用于评估期间。
3.2 Convolutional Neural Networks
在本节中,我们将介绍卷积神经网络(CNN),这是一种非常适合检测空间子结构(并因此创建有意义的空间子结构)的神经网络。CNNs通过使用少量的权重来扫描输入数据张量来实现这一点。通过这种扫描,它们产生表示子结构检测(或不检测)的输出张量。
CNN Hyperparameters
为了理解不同的设计决策对CNN意味着什么,我们在图4-6中展示了一个示例。在本例中,单个“核”应用于输入矩阵。卷积运算(线性算子)的精确数学表达式对于理解这一节并不重要,但是从这个图中可以直观地看出,核是一个小的方阵,它被系统地应用于输入矩阵的不同位置。
输入矩阵与单个产生输出矩阵的卷积核(也称为特征映射)在输入矩阵的每个位置应用内核。
DIMENSION OF THE CONVOLUTION OPERATION
在PyTorch中,卷积可以是一维、二维或三维的,分别由Conv1d、Conv2d和Conv3d模块实现。一维卷积对于每个时间步都有一个特征向量的时间序列非常有用。在这种情况下,我们可以在序列维度上学习模式。NLP中的卷积运算大多是一维的卷积。另一方面,二维卷积试图捕捉数据中沿两个方向的时空模式。
CHANNELS
非正式地,通道(channel)是指沿输入中的每个点的特征维度。例如,在图像中,对应于RGB组件的图像中的每个像素有三个通道。在使用卷积时,文本数据也可以采用类似的概念。从概念上讲,如果文本文档中的“像素”是单词,那么通道的数量就是词汇表的大小。如果我们更细粒度地考虑字符的卷积,通道的数量就是字符集的大小(在本例中刚好是词汇表)。
KERNEL SIZE
核矩阵的宽度称为核大小(PyTorch中的kernel_size)。在图4-6中,核大小为2,而在图4-9中,我们显示了一个大小为3的内核。卷积将输入中的空间(或时间)本地信息组合在一起,每个卷积的本地信息量由内核大小控制。然而,通过增加核的大小,也会减少输出的大小(Dumoulin和Visin, 2016)。
此外,可以将NLP应用程序中核大小的行为看作类似于通过查看单词组捕获语言模式的n-gram的行为。较小的核大小会导致输出中的细粒度特性,而较大的核大小会导致粗粒度特性。
STRIDE
Stride控制卷积之间的步长。如果步长与核相同,则内核计算不会重叠。另一方面,如果跨度为1,则内核重叠最大。输出张量可以通过增加步幅的方式被有意的压缩来总结信息,如图4-10所示。
PADDING
即使stride和kernel_size允许控制每个计算出的特征值有多大范围,它们也有一个有害的、有时是无意的副作用,那就是缩小特征映射的总大小(卷积的输出)。
为了抵消这一点,输入数据张量被人为地增加了长度(如果是一维、二维或三维)、高度(如果是二维或三维)和深度(如果是三维),方法是在每个维度上附加和前置0。这意味着CNN将执行更多的卷积,但是输出形状可以控制,而不会影响所需的核大小、步幅或扩展。图4-11展示了正在运行的填充。
DILATION
膨胀控制卷积核如何应用于输入矩阵。在图4-12中,我们显示,将膨胀从1(默认值)增加到2意味着当应用于输入矩阵时,核的元素彼此之间是两个空格。
当卷积层被叠加时,扩张卷积被证明是非常有用的。连续扩张的卷积指数级地增大了“接受域”的大小;即网络在做出预测之前所看到的输入空间的大小。
3.3 Implementing CNNs in PyTorch
在本节中,我们将通过端到端示例来利用上一节中介绍的概念。一般来说,神经网络设计的目标是找到一个能够完成任务的超参数组态。所有CNN应用程序都是这样的:首先有一组卷积层,它们提取一个feature map,然后将其作为上游处理的输入。在分类中,上游处理几乎总是应用线性(或fc)层。
本课程中的实现遍历设计决策,以构建一个特征向量。我们首先构造一个人工数据张量,以反映实际数据的形状。数据张量的大小是三维的——这是向量化文本数据的最小批大小。
Example 4-14. Artificial data and using a Conv1d class
import torch
import torch.nn as nn
# 定义数据尺寸
batch_size = 2 # 一次输入的样本数量
one_hot_size = 10 # 输入向量的维度(one-hot编码的大小)
sequence_width = 7 # 序列长度
# 生成随机数据,形状为 (batch_size, one_hot_size, sequence_width)
data = torch.randn(batch_size, one_hot_size, sequence_width)
# 定义1维卷积层
conv1 = nn.Conv1d(in_channels=one_hot_size, out_channels=16, kernel_size=3)
# 通过卷积层计算中间输出
intermediate1 = conv1(data)
# 打印输入数据的尺寸
print(data.size()) # 输出: torch.Size([2, 10, 7])
# 打印中间输出的尺寸
print(intermediate1.size()) # 输出: torch.Size([2, 16, 5])
在例4-14中,构造特征向量的第一步是将PyTorch的Conv1d类的一个实例应用到三维数据张量。通过检查输出的大小,你可以知道张量减少了多少。
进一步减小输出张量的主要方法有三种。第一种方法是创建额外的卷积并按顺序应用它们。
最终,对应的sequence_width (dim=2)维度的大小将为1。我们在例4-15中展示了应用两个额外卷积的结果。
Example 4-15. The iterative application of convolutions to data
import torch
import torch.nn as nn
# 定义卷积层
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())
# 去掉维度为1的维度(在此例中,即去掉最后一个维度)
y_output = intermediate3.squeeze()
# 打印去掉维度后的张量尺寸
print(y_output.size())
在每次卷积中,通道维数的大小都会增加,因为通道维数是每个数据点的特征向量。张量实际上是一个特征向量的最后一步是去掉讨厌的尺寸=1维。
另外还有两种方法可以将张量简化为每个数据点的一个特征向量:将剩余的值压平为特征向量,并在额外维度上求平均值。这两种方法如示例4-16所示。使用第一种方法,只需使用PyTorch的view()方法将所有向量平展成单个向量。第二种方法使用一些数学运算来总结向量中的信息。
Example 4-16. Two additional methods for reducing to feature vectors
# 方法2:将张量展平成特征向量
# 将intermediate1的尺寸变为 (batch_size, -1),即 (batch_size, one_hot_size * sequence_width)
print(intermediate1.view(batch_size, -1).size())
# 方法3:通过计算平均值将张量减少为特征向量
# 沿着维度2(序列长度方向)计算平均值,结果尺寸为 (batch_size, one_hot_size)
print(torch.mean(intermediate1, dim=2).size())
# 下面是其他减少为特征向量的方法(已注释)
# 沿着维度2计算最大值
# print(torch.max(intermediate1, dim=2).size())
# 沿着维度2计算总和
# print(torch.sum(intermediate1, dim=2).size())
3.4 Example: Classifying Surnames by Using a CNN
为了证明CNN的有效性,让我们应用一个简单的CNN模型来分类姓氏。这项任务的许多细节与前面的MLP示例相同,但真正发生变化的是模型的构造和向量化过程。
3.4.1 The SurnameDataset
尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。
class SurnameDataset(Dataset):
# ... 现有的第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}
3.4.2 Vocabulary, Vectorizer, and DataLoader
我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。
3.4.3 Reimplementing the SurnameClassifier with Convolutional Networks
在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现256足够大,可以使模型达到合理的性能。
3.4.4 The Training Routine
训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。
Example 4-20. Input arguments to the CNN surname classifier
args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/surnames_with_splits.csv", # 姓氏数据的CSV文件路径
vectorizer_file="vectorizer.json", # 向量化器文件的路径
model_state_file="model.pth", # 模型状态文件的路径
save_dir="model_storage/ch4/cnn", # 模型存储目录
# 模型超参数
hidden_dim=100, # 隐藏层的维度
num_channels=256, # 网络中使用的通道数
# 训练超参数
seed=1337, # 随机种子
learning_rate=0.001, # 学习率
batch_size=128, # 批大小
num_epochs=100, # 训练周期数
early_stopping_criteria=5, # 早期停止的准则
dropout_p=0.1, # Dropout概率
)
3.4.5 Model Evaluation and Prediction
要理解模型的性能,需要对性能进行定量和定性的度量。
Evaluating on the Test Dataset
调用分类器的eval()
方法来防止反向传播,并迭代测试数据集。
Classifying or retrieving top predictions for a new surname
在本例中,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):
"""从新的姓氏预测国籍
参数:
surname (str): 要分类的姓氏
classifier (SurnameClassifier): 分类器的一个实例
vectorizer (SurnameVectorizer): 相应的向量化器
返回:
一个包含最可能的国籍及其概率的字典
"""
vectorized_surname = vectorizer.vectorize(surname) # 将姓氏向量化
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0) # 转换为PyTorch张量并添加批次维度
result = classifier(vectorized_surname, apply_softmax=True) # 使用分类器进行预测,并应用Softmax激活函数
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.5.1 Pooling Operation
Pooling是将高维特征映射总结为低维特征映射的操作。卷积的输出是一个特征映射。feature map中的值总结了输入的一些区域。由于卷积计算的重叠性,许多计算出的特征可能是冗余的。Pooling是一种将高维(可能是冗余的)特征映射总结为低维特征映射的方法。
在形式上,池是一种像sum、mean或max这样的算术运算符,系统地应用于feature map中的局部区域,得到的池操作分别称为sum pooling、average pooling和max pooling。池还可以作为一种方法,将较大但较弱的feature map的统计强度改进为较小但较强的feature map。
3.5.2 Batch Normalization (BatchNorm)
BatchNorm对CNN的输出进行转换,方法是将激活量缩放为零均值和单位方差。在PyTorch中,批处理规范是在nn模块中定义的。例4-22展示了如何用卷积和线性层实例化和使用批处理规范。
Example 4-22. Using s Conv1D layer with batch normalization.
# ...
self.conv1 = nn.Conv1d(in_channels=1, out_channels=10, # 定义第一层1D卷积层
kernel_size=5, # 卷积核大小为5
stride=1) # 步幅为1
self.conv1_bn = nn.BatchNorm1d(num_features=10) # 定义批归一化层,特征数为10
# ...
def forward(self, x):
# ...
x = F.relu(self.conv1(x)) # 通过第一层卷积层,并应用ReLU激活函数
x = self.conv1_bn(x) # 通过批归一化层
# ...
它用于Z-transform的平均值和方差值每批更新一次,这样任何单个批中的波动都不会太大地移动或影响它。BatchNorm允许模型对参数的初始化不那么敏感,并且简化了学习速率的调整。
3.5.3 Network-in-Network Connections (1x1 Convolutions)
Network-in-Network (NiN)连接是具有kernel_size=1
的卷积内核,具有一些有趣的特性。具体来说,1x1卷积就像通道之间的一个完全连通的线性层。
它将两个通道简化为一个通道。因此,NiN或1x1卷积提供了一种廉价的方法来合并参数较少的额外非线性。
3.5.4 Residual Connections/Residual Block
CNNs中最重要的趋势之一是Residual connection,它支持真正深层的网络(超过100层)。它也称为skip connection。如果将卷积函数表示为conv,则residual block的输出如下:
𝑜𝑢𝑡𝑝𝑢𝑡=𝑐𝑜𝑛𝑣(𝑖𝑛𝑝𝑢𝑡)+𝑖𝑛𝑝𝑢𝑡
然而,这个操作有一个隐含的技巧,如图4-15所示。对于要添加到卷积输出的输入,它们必须具有相同的形状。为此,标准做法是在卷积之前应用填充。在图4-15中,填充尺寸为1,卷积大小为3。
四、实验任务
通过“示例:带有多层感知器的姓氏分类”,掌握多层感知器在多层分类中的应用
实验主要代码及实验结果:
for batch_index, batch_dict in enumerate(batch_generator):
optimizer.zero_grad()
y_pred = classifier(batch_dict['x_surname'])
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
loss.backward()
optimizer.step()
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
for batch_index, batch_dict in enumerate(batch_generator):
y_pred = classifier(batch_dict['x_surname'])
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
def predict_nationality(surname, classifier, vectorizer):
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}
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)
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
总结
通过本文的探讨,我们展示了如何使用前馈神经网络(FNN)进行姓氏分类。从数据预处理、模型构建、训练过程到最终的预测结果,我们全面介绍了这一过程的每个步骤。前馈神经网络,作为一种基础的神经网络结构,虽然简单但在处理分类问题时依然表现出色。通过合理的数据预处理和模型设计,我们可以有效提升模型的分类性能。
姓氏分类任务不仅展示了FNN在文本分类中的应用潜力,还为我们在其他类似任务中的模型选择和设计提供了参考。未来的研究可以尝试更复杂的模型结构,如卷积神经网络(CNN)或循环神经网络(RNN),以进一步提高分类准确性。同时,更多的数据和更丰富的特征提取方法也将有助于提升模型的泛化能力。
希望本文对读者理解前馈神经网络在实际分类任务中的应用有所帮助,也期待大家在自己的项目中灵活运用这些技术,解决更多实际问题。