NLP-基于前馈神经网络的姓氏分类
前言
本文是一篇实验博客,适合正在学习NLP的同学阅读。受篇幅限制,本文仅给出部分代码示例。
实验内容:分别使用MLP和CNN进行姓氏分类
实验环境:Python3.6.7,基于Pytorch实现
一、多层感知机(MLP)
多层感知机(Multilayer Perceptron, MLP)是一种前馈神经网络,包含至少一个隐藏层。它是人工神经网络(ANN)的一种,主要用于解决非线性分类和回归问题。MLP通过使用反向传播算法进行训练,能够有效地学习复杂的输入-输出关系。
1. 基本网络结构
一个典型的MLP由以下几层组成:
输入层:接收输入数据,通常不进行计算。
隐藏层:执行大部分计算,通过激活函数引入非线性。
输出层:产生最终输出。
为方便推导和理解,我们先假设一个MLP具有一个输入层、一个隐藏层和一个输出层,输入层有
2. 数学推导
为深入理解和掌握MLP的作用原理,下面进行详细的数学建模分析:
2.1 输入层到隐藏层
假设输入向量为
其中:
-
-
-
-
2.2 激活函数
为了引入非线性,隐藏层的输出通过激活函数
其中:
-
-
2.3 隐藏层到输出层
输出层的权重矩阵为
其中:
-
-
-
2.4 输出层激活函数
输出层的激活函数根据具体任务选择。对于分类任务,常用 softmax
激活函数;对于回归任务,常用线性激活函数。输出层的输出
其中:
-
-
3. MLP训练过程
MLP的训练过程使用反向传播算法,通过最小化损失函数来调整权重和偏置。常见的损失函数包括均方误差(MSE)和交叉熵损失(Cross-Entropy Loss)。训练过程包括以下步骤:
3.1 前向传播
根据输入数据,通过网络进行前向传播计算输出。
3.2 计算损失
根据网络输出和真实标签计算损失值
3.3 反向传播
计算损失关于各层权重和偏置的梯度。利用链式法则,梯度从输出层反向传播到隐藏层和输入层。对于权重
其中: -
-
-
3.4 更新参数
利用梯度下降法或其变种(如Adam优化算法)更新权重和偏置:
其中: -
-
-
二、卷积神经网络(CNN)
1.原理简介
卷积神经网络(CNN)是一种深度学习模型,主要用于处理具有网格结构的数据,例如图像。CNN 通过局部感知和权值共享的方式,能够有效地提取图像中的空间特征。其典型应用包括图像分类、目标检测和图像生成等。
2.基本概念
2.1 卷积(Convolution)
卷积是 CNN 中的核心操作,通过卷积核(过滤器)在输入数据上滑动进行局部计算,提取局部特征。卷积操作可以表示为:
其中:
-
-
-
-
2.2 感受野(Receptive Field)
感受野是指卷积神经网络中某一层的一个神经元在输入层所覆盖的区域大小。感受野的大小决定了神经元能够捕获的输入图像的信息量。更大的感受野能够捕捉到更多的全局信息,而较小的感受野则专注于局部细节。
2.3 权值共享(Weight Sharing)
权值共享是指同一卷积核在输入数据的不同位置进行卷积操作时使用相同的参数,这种方式大大减少了模型的参数数量,提高了计算效率,并且有助于模型对平移不变性进行建模。
2.4 上&下采样(Up & Down sampling)
下采样(Pooling)用于减少特征图的尺寸,通常通过最大池化(Max Pooling)或平均池化(Average Pooling)来实现。其目的是减少计算量和防止过拟合,同时保留特征的主要信息。
上采样(Upsampling)用于恢复特征图的尺寸,常见的方法有最近邻插值、双线性插值和反卷积(Transposed Convolution)。上采样在生成模型和图像分割等任务中广泛应用。
3.网络结构
3.1 输入层(Input Layer)
输入层是卷积神经网络的第一层,接收原始数据(如图像)并将其传递给后续的卷积层进行特征提取。输入层的数据通常是一个多维数组(如 RGB 图像的三维数组)。
3.2 卷积层(Convolutional Layer)
卷积层是 CNN 的核心层,通过卷积操作提取输入数据的局部特征。每个卷积层包含多个卷积核,每个卷积核学习到不同的特征,如边缘、纹理等。
3.3 激活层(Activation Layer)
激活层在卷积层之后应用非线性激活函数,常用的激活函数有 ReLU、Sigmoid 和 Tanh。激活函数引入非线性,使模型能够表示更加复杂的函数。
其中:
-
-
-
3.4 池化层(Pooling Layer)
池化层用于对卷积层的输出进行下采样,常用的池化方法有最大池化和平均池化。池化层能够减少特征图的尺寸,降低模型的计算复杂度,同时保留重要的特征。
3.5 全连接层(Fully Connected Layer)
全连接层通常位于卷积神经网络的末端,连接到所有前一层的神经元。全连接层通过线性变换和激活函数进行最终的特征组合和决策。全连接层的输出用于分类或回归任务的最终预测。
其中:
-
-
-
-
三、基于MLP的姓氏分类
在这一部分中,我们将使用多层感知器(MLP)来进行姓氏分类。首先,我们对每个姓氏进行字符拆分,然后通过词汇表、向量化器和 DataLoader 类逐步将姓氏字符串转换为向量化的小批量数据。
接下来,我将详细描述姓氏分类器模型及其设计思路。除了对模型进行调整之外,这个例子还引入了多类输出及其对应的损失函数。在描述模型之后,我们将完成训练过程。
1. The Surname Dataset
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
为了创建最终的数据集,我们从一个比课程补充材料中包含的版本处理更少的版本开始,并执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为此,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。
下面给出关键代码: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class SurnameDataset(Dataset):
"""
姓氏数据集类,继承自PyTorch的Dataset类
"""
def __getitem__(self, index):
"""
获取数据集中的一个样本
参数:
index (int): 要获取的样本的索引
返回:
dict: 包含姓氏向量和国籍索引的字典
"""
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} # 返回国籍索引
2. 词汇表构建与向量化处理
为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。
词汇表是两个Python字典的协调,这两个字典在令牌(在本例中是字符)和整数之间形成一个双射;也就是说,第一个字典将字符映射到整数索引,第二个字典将整数索引映射到字符。add_token方法用于向词汇表中添加新的令牌,lookup_token方法用于检索索引,lookup_index方法用于检索给定索引的令牌(在推断阶段很有用)。与Yelp评论的词汇表不同,我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90class Vocabulary(object):
"""处理文本并提取用于映射的词汇的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
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
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):
"""根据令牌更新映射字典。
Args:
token (str): 要添加到词汇中的项
Returns:
index (int): 与令牌对应的整数索引
"""
if token not in self._token_to_idx:
index = len(self._token_to_idx)
self._token_to_idx[token] = index
self._idx_to_token[index] = token
else:
index = self._token_to_idx[token]
return index
def add_many(self, tokens):
"""将多个令牌添加到词汇表中
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): 与令牌对应的索引
"""
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(f"索引({index})不在词汇表中")
return self._idx_to_token[index]
def __str__(self):
return f"<Vocabulary(size={len(self)})>"
def __len__(self):
return len(self._token_to_idx)
虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。注意,在实例化和使用中,字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
1 |
|
3. 构建分类模型
第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。
在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
1 |
|
4. 训练模型
训练过程参考常规的神经网络,下面给出args方便理解: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/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, # 早停标准,如果验证损失在连续5个周期内没有降低,则停止训练
learning_rate=0.001, # 学习率
batch_size=64, # 批量大小
)
5. 模型评估与分类结果
要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。
该模型对测试数据的准确性达到50%左右。如果在附带的notebook中运行训练例程,会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据,所以训练数据的性能并不代表新数据的性能。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。其主要原因是收缩的onehot向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量,但它丢弃了字符之间的顺序信息,这对于识别起源非常重要。
NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20def predict_topk_nationality(name, classifier, vectorizer, k=5):
"""
预测给定姓氏的前k个可能国籍及其概率
"""
vectorized_name = vectorizer.vectorize(name) # 将姓氏向量化
vectorized_name = torch.tensor(vectorized_name).view(1, -1) # 将向量转换为张量,并调整形状为 (1, -1)
prediction_vector = classifier(vectorized_name, apply_softmax=True) # 通过分类器预测结果,并应用softmax激活函数
probability_values, indices = torch.topk(prediction_vector, k=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 # 返回预测结果列表
MLP的最终分类结果示例如下:
四、基于CNN的姓氏分类
1. The SurnameDataset
虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述,但建议参考“姓氏数据集”来了解它的描述。尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)
我们使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因。首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。
1 |
|
2. 向量化
在本例中,尽管词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。具体来说,该函数将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。重要的是,矩阵中的每一列都是不同的onehot向量。主要原因是,我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。
除了更改为使用onehot矩阵之外,我们还修改了矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38class SurnameVectorizer(object):
"""一个协调词汇表并将其用于向姓氏数据集中的姓氏生成独热编码矩阵的向量化器"""
def vectorize(self, surname):
"""
将姓氏向量化为独热编码矩阵
参数:
surname (str): 姓氏
返回:
one_hot_matrix (np.ndarray): 一个独热编码向量的矩阵
"""
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):
"""从数据框中实例化一个SurnameVectorizer对象
参数:
surname_df (pandas.DataFrame): 姓氏数据集
返回:
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. 构建CNN分类模型
本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于实验3中介绍的ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性(Clevert et al., 2015)。
在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现256足够大,可以使模型达到合理的性能。
1 |
|
4. 模型训练
对于这个例子,我们将不再详细描述具体的训练例程,因为它与“多层感知器的姓氏分类”中的例程完全相同。但是,输入参数是不同的,可以在示例中看到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19args = 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, # 早停策略的参数,表示在连续多少个epoch验证集损失没有降低时停止训练
dropout_p=0.1, # dropout概率
# 运行时参数已省略以节省空间
)
5. 模型评估与分类结果
在本例中,predict_topk_nationality()函数的一部分发生了更改,如示例所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()函数在批处理应该在的位置添加大小为1的维度。
1 |
|
CNN的最终分类结果示例如下: