一.引言
1. 背景介绍
2.技术动机
3.研究目的
二.多层感知器基础
1.概念解释
2.工作原理
三.卷积神经网络
1.概念解释
2.工作原理
四.姓氏分类挑战
1.问题定义
2.数据准备
五.方法论
(一)多层感知器(MLP)
1.模型设计
2.训练与验证
(二)卷积神经网络
1.模型设计
2.训练与验证
六.实验结果与分析
1.性能评估
2.案例研究
七.结论比较
一.引言
1. 背景介绍
社会与文化背景
随着全球化进程的加速,个人数据的跨国流通日益频繁,对个人身份信息的精准识别和分类成为了众多领域中的一个重要需求。姓氏作为个体身份的重要组成部分,往往蕴含着丰富的文化和地理信息。不同的国家和地区有着独特的命名习俗和姓氏分布,这为通过姓氏来推断个人的族裔或国籍背景提供了可能性。
2. 技术动机
在信息技术和数据科学领域,尤其是自然语言处理(NLP)的发展中,自动且准确地分类和解析文本信息变得尤为重要。姓氏分类不仅有助于人口统计分析、市场营销的精准定位、族裔文化研究,还能应用于网络安全、移民服务、基因alogy研究等多个方面,提升数据处理的效率和精确度。
传统方法的局限
以往的手动分类或基于简单规则的系统在面对大规模、多语种、多文化的数据集时显得力不从心,容易出现误分类和遗漏。此外,由于命名习惯随时间和社会变迁而变化,静态的规则难以适应这种动态性。
3.研究目的
鉴于上述挑战,利用机器学习尤其是深度学习技术,特别是多层感知器(MLP)这样的模型,成为了研究热点。MLP作为一种强大的非线性模型,能够学习复杂的输入-输出映射关系,适合处理非结构化数据,如文本数据中的模式识别和分类任务。
二.多层感知器(MLP)基础
1.概念解释
多层感知器(Multilayer Perceptron,简称MLP)是一种人工神经网络模型,属于前馈型网络的一种。它的基本结构包含输入层、一个或多个隐藏层和输出层,各层之间的神经元全连接,形成一种层级结构。下面是多层感知器的详细概念解释:
-
前馈结构:信息在MLP中是单向流动的,从输入层开始,经过一个或多个隐藏层,最终到达输出层。每个神经元将信号传递给下一层的所有神经元,但不会接收来自下一层的反馈,因此称为前馈网络。
-
层与神经元:
- 输入层:接收原始数据,每个输入特征对应一个神经元。
- 隐藏层:位于输入层和输出层之间,可以有一个或多个隐藏层。隐藏层的神经元对输入数据进行复杂的变换,以提取更高层次的特征。每个隐藏层的神经元都会使用加权求和的方式整合前一层的输出,并加上一个偏置项,然后通过一个非线性激活函数转换。
- 输出层:产生网络的最终输出,神经元的数量取决于任务需求,例如分类任务中输出层的神经元数量通常等于类别数。
-
权重与偏置:网络中的每个连接都有一个关联的权重,用于调整该连接所传输信号的重要性。偏置项则是为了增加网络的表达能力,允许神经元产生一个非零输出即使没有输入。
-
激活函数:每个神经元在计算加权输入的和后,会通过一个非线性激活函数(如Sigmoid、ReLU、Tanh等)来引入非线性特性,这是多层感知器能够学习和表示复杂函数的关键。
-
学习过程:通过监督学习方法训练MLP,最常用的是反向传播算法。该算法计算输出层的误差,并将误差逐层向前传播,以便调整网络中的权重和偏置,最小化损失函数(如均方误差或交叉熵)。
-
应用范围:多层感知器因其强大的非线性建模能力,广泛应用于分类和回归问题,如图像识别、语音识别、自然语言处理、金融预测等领域。最简单的MLP,如图4-2所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。在“示例:对餐馆评论的情绪进行分类”中,输入向量是Yelp评论的一个收缩的one-hot表示。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。在像Yelp评论分类这样的二进制任务中,输出向量仍然可以是1。在多类设置中,将在本实验后面的“示例:带有多层感知器的姓氏分类”一节中看到,输出向量是类数量的大小。虽然在这个例子中,我们只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。
2.工作原理
多层感知器(Multilayer Perceptron, MLP)的工作原理主要包括两个主要阶段:前向传播(Forward Propagation)和反向传播(Backward Propagation)。下面是对这两个阶段的详细解释:
前向传播
-
输入层接收数据:多层感知器的输入层接收外部输入数据,这些数据可以是数值型、向量或矩阵等形式,代表了问题的具体实例。
-
隐藏层处理信息:输入数据通过权重矩阵加权并加上偏置项后,传递给第一个隐藏层的每个神经元。每个神经元会对其加权输入应用一个非线性激活函数(如Sigmoid、ReLU、Tanh等),生成该神经元的输出。这一过程在所有隐藏层中重复进行,每一层的输出作为下一层的输入,直至达到最后一个隐藏层。
-
输出层生成结果:最后一个隐藏层的输出会被进一步加权、偏置并经过激活函数处理,产生网络的最终输出。输出层的设计依据具体任务,如果是分类问题,可能会采用softmax函数来产生概率分布;如果是回归问题,则可能直接输出实数值。
反向传播
-
计算误差:网络的预测输出与实际目标值(即训练数据中的标签)进行比较,计算出损失函数(如均方误差、交叉熵等)的值,以此衡量预测误差。
-
误差反向传播:从输出层开始,计算损失函数关于每个权重和偏置的梯度(即误差对它们的偏导数)。这些梯度表示了调整权重的方向和大小,以减少网络的预测误差。
-
权重更新:利用梯度下降或其他优化算法,根据计算出的梯度调整网络中的权重和偏置。调整的幅度通常由学习率参数控制。这个过程在所有连接上重复进行,从输出层回传至输入层。
-
迭代过程:前向传播和反向传播构成了一次完整的训练迭代。这个过程会重复进行多次(即多个epoch),直到网络的性能(如准确率)不再显著提升,或者达到预设的停止条件。
通过不断的迭代和优化,多层感知器能够学习到输入数据到输出数据之间复杂的非线性映射关系,从而在各种任务中表现出良好的性能。
最简单的MLP,如图所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。在像Yelp评论分类这样的二进制任务中,输出向量仍然可以是1。在多类设置中,将在本实验后面的“示例:带有多层感知器的姓氏分类”一节中看到,输出向量是类数量的大小。虽然在这个例子中,我们只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。
三.卷积神经网络
1.概念解释
卷积神经网络(CNN)中的超参数是指在构建和训练模型时需要预先设定的值,它们不能通过训练过程自动学习,对模型的性能有着重要影响。以下是一些关键的CNN超参数及其解释:
-
滤波器数量(Filter Count / Number of Filters):每个卷积层使用的滤波器数量,决定了该层能学习到的特征种类。增加滤波器数量可以提高模型的表达能力,但也会增加计算复杂性和过拟合的风险。
-
滤波器大小(Kernel Size):卷积核的宽度和高度,决定了每次卷积操作覆盖输入数据的区域大小。较小的滤波器尺寸有助于捕捉更精细的细节,而较大的尺寸则能捕获更大范围的上下文信息。
-
步长(Stride):卷积核在输入数据上滑动的步长。较大的步长可以减少输出的尺寸,加速计算,但可能会丢失一些空间信息。
-
填充(Padding):在输入数据边缘添加的额外像素层,通常用来保持输出尺寸与输入尺寸相同或控制输出尺寸的缩小程度。常用的有“SAME”填充(保持输出尺寸与输入相同)和“VALID”填充(无填充,输出尺寸会减小)。
-
激活函数类型(Activation Function):如ReLU、Leaky ReLU、tanh或sigmoid等,决定了神经元的输出如何被非线性地转换。不同的激活函数对模型的学习能力和训练动态有不同的影响。
-
池化大小(Pooling Size):在池化层中使用的窗口大小,决定了下采样的程度。常见的有最大池化和平均池化。
-
学习率(Learning Rate):优化器在更新权重时的步长,决定了学习过程的速度和稳定性。过高的学习率可能导致训练不稳定,过低则可能使训练过程缓慢。
-
批量大小(Batch Size):一次迭代中处理的样本数。较大的批量大小可以加速计算,但也需要更多的内存,并且可能降低模型收敛的稳定性。
-
网络深度(Depth):网络中包含的层数,特别是卷积层的数量。更深的网络理论上能学习更复杂的特征,但也更容易发生梯度消失/爆炸和过拟合。
-
正则化项(Regularization Terms):如L1、L2正则化或Dropout比例,用于防止模型过拟合,通过在损失函数中加入惩罚项来约束模型复杂度。
2.工作原理
-
卷积层:CNN的核心组成部分,使用一组可学习的滤波器(或称卷积核)在输入数据上滑动,执行局部卷积运算。这些滤波器旨在自动检测输入数据中的特征,如边缘、纹理等低级特征到更复杂的形状和结构。
-
权重共享与稀疏交互:在卷积层中,每个滤波器的参数在整个输入空间中是共享的,这意味着对于图像的每个位置,都使用相同的滤波器进行特征提取,这大大减少了模型的参数量,同时允许网络学习到平移不变的特征。此外,相比全连接网络,卷积层中的连接是稀疏的,只有一小部分输入与输出有直接联系。
-
池化层:通常紧跟在卷积层之后,用于进一步降低数据的空间维度,提取更加鲁棒的特征,同时减少计算量。常见的池化方式有最大池化和平均池化。
-
全连接层:在卷积和池化层之后,CNN通常包含一个或多个全连接层,用于将学到的特征进行分类或其他高级抽象处理,最终产生输出。
-
激活函数:如ReLU(Rectified Linear Unit)等非线性激活函数被应用于每个神经元,增加了网络的表达能力,使其能够学习复杂的数据模式。
-
学习过程:通过反向传播算法结合优化策略(如梯度下降)调整网络中的权重,以最小化预测输出与实际标签之间的差异(损失函数)。
在PyTorch中实现卷积神经网络(CNN)
一般来说,神经网络设计的目标是找到一个能够完成任务的超参数组态。我们再次考虑在“示例:带有多层感知器的姓氏分类”中引入的现在很熟悉的姓氏分类任务,但是我们将使用CNNs而不是MLP。我们仍然需要应用最后一个线性层,它将学会从一系列卷积层创建的特征向量创建预测向量。这意味着目标是确定卷积层的配置,从而得到所需的特征向量。所有CNN应用程序都是这样的:首先有一组卷积层,它们提取一个feature map,然后将其作为上游处理的输入。在分类中,上游处理几乎总是应用线性(或fc)层。
本课程中的实现遍历设计决策,以构建一个特征向量。我们首先构造一个人工数据张量,以反映实际数据的形状。数据张量的大小是三维的——这是向量化文本数据的最小批大小。如果你对一个字符序列中的每个字符使用onehot向量,那么onehot向量序列就是一个矩阵,而onehot矩阵的小批量就是一个三维张量。使用卷积的术语,每个onehot(通常是词汇表的大小)的大小是”input channels”的数量,字符序列的长度是“width”。
在例4-14中,构造特征向量的第一步是将PyTorch的Conv1d类的一个实例应用到三维数据张量。通过检查输出的大小,你可以知道张量减少了多少。
# 设置批次大小、one-hot 编码大小和序列宽度
batch_size = 2
one_hot_size = 10
sequence_width = 7
# 创建一个随机张量作为输入数据
data = torch.randn(batch_size, one_hot_size, sequence_width)
# 定义一个一维卷积层,输入通道数为 one_hot_size,输出通道数为 16,卷积核大小为 3
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。一般来说,对输出张量的约简应用卷积的过程是迭代的,需要一些猜测工作。我们的示例是这样构造的:经过三次卷积之后,最终的输出在最终维度上的大小为1。
# 定义第二个一维卷积层,输入通道数为 16,输出通道数为 32,卷积核大小为 3
conv2 = nn.Conv1d(in_channels=16, out_channels=32, kernel_size=3)
# 定义第三个一维卷积层,输入通道数为 32,输出通道数为 64,卷积核大小为 3
conv3 = nn.Conv1d(in_channels=32, out_channels=64, kernel_size=3)
# 应用第二个卷积层到 intermediate1 上
intermediate2 = conv2(intermediate1)
# 应用第三个卷积层到 intermediate2 上
intermediate3 = conv3(intermediate2)
# 打印 intermediate2 张量的尺寸
print(intermediate2.size())
# 打印 intermediate3 张量的尺寸
print(intermediate3.size())
在每次卷积中,通道维数的大小都会增加,因为通道维数是每个数据点的特征向量。张量实际上是一个特征向量的最后一步是去掉讨厌的尺寸=1维。您可以使用squeeze()方法来实现这一点。该方法将删除size=1的所有维度并返回结果。然后,得到的特征向量可以与其他神经网络组件(如线性层)一起使用来计算预测向量。
另外还有两种方法可以将张量简化为每个数据点的一个特征向量:将剩余的值压平为特征向量,并在额外维度上求平均值。这两种方法如示例4-16所示。使用第一种方法,只需使用PyTorch的view()方法将所有向量平展成单个向量。第二种方法使用一些数学运算来总结向量中的信息。最常见的操作是算术平均值,但沿feature map维数求和和使用最大值也是常见的。每种方法都有其优点和缺点。扁平化保留了所有的信息,但会导致比预期(或计算上可行)更大的特征向量。平均变得与额外维度的大小无关,但可能会丢失信息。
# 方法2:通过扁平化转换为特征向量
print(intermediate1.view(batch_size, -1).size())
# 方法3:通过取平均值来减少到特征向量
print(torch.mean(intermediate1, dim=2).size())
# print(torch.max(intermediate1, dim=2).size())
# print(torch.sum(intermediate1, dim=2).size())
四.姓氏分类挑战
1.问题定义
目标
- 任务目标:开发一个模型或系统,能够接收一个或多个姓氏作为输入,并预测这些姓氏所属的文化或地理区域。
- 应用场景:可用于族谱研究、人口统计分析、市场营销的地域定向、语言学研究等领域。
数据
- 数据收集:构建一个包含各种姓氏及其对应文化或地理标签的大型数据库。数据需要覆盖广泛的文化背景,包括但不限于汉语、英语、西班牙语、阿拉伯语等。
- 预处理:对姓氏进行标准化处理,如去除特殊字符、统一大小写、转换为标准编码等。可能还需要对姓氏进行分词或转换成字符序列。
特征提取
- 字符级表示:将姓氏转换为字符序列,利用嵌入技术(如Word2Vec、Char2Vec)捕捉字符间的上下文关系。
- 文化特征:提取可能指示特定文化或地理区域的特征,比如特定字母组合、发音模式等。
2.数据准备
- 多样性和覆盖范围:确保收集的姓氏覆盖全球主要文化区域和语言,包括常见的和稀有的姓氏,以提高模型的普遍适用性。
- 来源:从官方人口普查数据、公开的姓氏数据库、历史记录、族谱资源、在线社交平台等多渠道获取数据。
- 合法性与隐私:确保数据收集过程遵守相关法律法规,保护个人隐私,避免敏感信息的泄露。
四.方法论
多层感知器(MLP)
1.模型设计
1.姓氏数据集
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
为了创建最终的数据集,我们从一个比课程补充材料中包含的版本处理更少的版本开始,并执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为此,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。
SurnameDataset的实现与“Example: classification of Sentiment of Restaurant Reviews”中的ReviewDataset几乎相同,只是在getitem方法的实现方式上略有不同。回想一下,本课程中呈现的数据集类继承自PyTorch的数据集类,因此,我们需要实现两个函数:__getitem
方法,它在给定索引时返回一个数据点;以及len方法,该方法返回数据集的长度。“示例:餐厅评论的情绪分类”中的示例与本示例的区别在getitem__中,如示例4-5所示。它不像“示例:将餐馆评论的情绪分类”那样返回一个向量化的评论,而是返回一个向量化的姓氏和与其国籍相对应的索引:
class SurnameDataset(Dataset):
# 实现与第3.5节类似
def __getitem__(self, index):
# _target_df 是一个包含目标数据的 pandas DataFrame,列如 'surname' 和 'nationality'
row = self._target_df.iloc[index]
# 使用 _vectorizer 对象将姓氏(字符串)转换为数值表示(例如,one-hot 编码向量或词嵌入)
surname_vector = self._vectorizer.vectorize(row.surname)
# 使用 _vectorizer 的 nationality_vocab 属性将国籍编码为整数索引
nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)
# 返回一个字典,其中包含模型输入('x_surname')和目标标签('y_nationality')
return {'x_surname': surname_vector,
'y_nationality': nationality_index}
2 .词汇表、向量化器与数据加载器
虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。实例化和使用非常类似于“示例:对餐馆评论的情绪进行分类”中的ReviewVectorizer,但有一个关键区别:字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
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编码数组
one_hot = np.zeros(len(vocab), dtype=np.float32)
# 遍历姓氏中的每个字符,将其在词汇表中的位置设为1
for token in surname:
one_hot[vocab.lookup_token(token)] = 1
# 返回编码后的one-hot数组
return 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.姓氏分类器模型
第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
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(输入维度, 隐藏层维度) # 第一层全连接层
self.fc2 = nn.Linear(隐藏层维度, 输出维度) # 第二层全连接层
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)
"""
# 使用ReLU激活函数对第一层全连接层的输出进行非线性变换
intermediate_vector = F.relu(self.fc1(x_in))
# 通过第二层全连接层得到预测向量
prediction_vector = self.fc2(intermediate_vector)
# 如果应用softmax,对预测向量进行softmax操作
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1)
return prediction_vector
2.训练流程和训练循环
设置超参数
- 学习率:确定模型参数更新的速度。
- 损失函数:根据任务类型选择合适的损失函数,如多分类问题常用的交叉熵损失。
- 优化器:选择优化算法,如Adam、SGD等,来更新模型参数。
- 批次大小:决定每次更新模型参数时使用的样本数量。
- 迭代次数:设置模型训练的总轮数(epochs)。
# 定义一个名为 args 的 Namespace 对象,用于存储参数和配置信息
args = Namespace(
# 数据和路径相关参数
surname_csv="data/surnames/surnames_with_splits.csv", # 含有姓氏数据的 CSV 文件路径
vectorizer_file="vectorizer.json", # 词汇转换器的 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, # 早停策略的容忍次数,如果验证集性能在连续 n 轮没有提升则停止训练
learning_rate=0.001, # 学习率,决定模型参数更新的速度
batch_size=64, # 批量大小,每次更新模型参数时使用的样本数量
# 省略了运行时选项以节省空间
)
训练中最显著的差异与模型中输出的种类和使用的损失函数有关。在这个例子中,输出是一个多类预测向量,可以转换为概率。正如在模型描述中所描述的,这种输出的损失类型仅限于CrossEntropyLoss和NLLLoss。由于它的简化,我们使用了CrossEntropyLoss。
下面我们展示了数据集、模型、损失函数和优化器的实例化。事实上,在本课程后面的实验中,这种模式将对每个示例进行重复。
# 加载数据集并创建词汇转换器对象
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
vectorizer = dataset.get_vectorizer() # 获取词汇转换器,用于将文本转换为向量
# 创建一个 SurnameClassifier 类的实例,输入维度是词汇转换器中姓氏词汇的数量
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
hidden_dim=args.hidden_dim,
output_dim=len(vectorizer.nationality_vocab))
# 将模型移动到指定的设备(通常为 GPU 或 CPU)
classifier = classifier.to(args.device)
# 使用交叉熵损失函数,考虑到类别不平衡,可能使用了加权
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
# 初始化优化器,使用 Adam 优化算法,学习率为 args.learning_rate
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。
- 前向传播:对每个批次的输入执行前向计算,从输入层到输出层,得到预测结果。
- 计算损失:比较模型预测结果与真实标签,计算损失值。
- 反向传播:根据损失值计算梯度,从输出层到输入层反向传播,更新所有权重和偏置。
- 优化参数:使用优化算法根据梯度调整模型参数,以减小损失。
- 记录指标:定期记录训练损失和验证集上的性能指标,监控过拟合。
# 步骤 1. 清零梯度
optimizer.zero_grad() # 重置优化器的梯度为零,准备进行新的反向传播计算
# 步骤 2. 计算模型预测
y_pred = classifier(batch_dict['x_surname']) # 通过模型预测输出,其中 '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() # 根据梯度和学习率更新模型参数
卷积神经网络
1.模型设计
1.姓氏数据集
虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述,但建议参考“姓氏数据集”来了解它的描述。尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。示例4-17显示了对SurnameDataset.__getitem__
的更改;我们显示对SurnameVectorizer的更改。在下一小节向量化。
我们使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因。首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。
class SurnameDataset(Dataset):
# ... 存在的实现,来自第4.2节
def __getitem__(self, index):
# 使用Pandas的iloc方法获取DataFrame中索引为index的行
row = self._target_df.iloc[index]
# 调用_vectorizer的vectorize方法,将当前行的surname字符串转换为矩阵形式
# _max_seq_length参数用于限制序列的最大长度,保证所有姓氏都是统一长度
surname_matrix = self._vectorizer.vectorize(row.surname, self._max_seq_length)
# 在_vectorizer的nationality_vocab词汇表中查找对应于当前行国籍的整数索引
nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)
# 返回一个字典,包含模型的输入('x_surname':姓氏特征矩阵)和输出('y_nationality':国籍整数索引)
return {'x_surname': surname_matrix,
'y_nationality': nationality_index}
2 .词汇表、向量化器与数据加载器
尽管词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。该函数将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。重要的是,矩阵中的每一列都是不同的onehot向量。主要原因是,我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。
除了更改为使用onehot矩阵之外,我们还修改了矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length
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矩阵的大小,基于字符词汇表的大小和最大姓氏长度
one_hot_matrix_size = (len(self.character_vocab), self.max_surname_length)
one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
# 遍历每个字符,使用字符词汇表找到对应的索引,并在one-hot矩阵中设置值
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)
# 初始化最大姓氏长度为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.使用卷积神经网络重新实现姓氏分类器
我们在本例中使用的模型是使用我们在“卷积神经网络”中介绍的方法构建的。实际上,我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。具体来说,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。
然而,本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于实验3中介绍的ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性(Clevert et al., 2015)。
在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现256足够大,可以使模型达到合理的性能。
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__()
# 定义卷积神经网络(CNN)序列
self.convnet = nn.Sequential(
# 第一个卷积层,步长为1
nn.Conv1d(in_channels=initial_num_channels,
out_channels=num_channels, kernel_size=3),
# 激活函数,使用ELU
nn.ELU(),
# 第二个卷积层,步长为2,减小序列长度
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3, stride=2),
nn.ELU(),
# 第三个卷积层,步长为2,进一步减小序列长度
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3, stride=2),
nn.ELU(),
# 第四个卷积层,步长为1
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)
"""
# 对卷积网络的输出进行 squeeze 操作,去除第三维(长度维度)
features = self.convnet(x_surname).squeeze(dim=2)
# 将卷积层的输出通过全连接层
prediction_vector = self.fc(features)
# 如果 apply_softmax 为 True,则应用 softmax 激活函数
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1)
return prediction_vector
2.训练流程和训练循环
训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。此时,这是本书到目前为止的第三个训练例程实现,应该将这个操作序列内部化。对于这个例子,我们将不再详细描述具体的训练例程,因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同。但是,输入参数是不同的,可以在下面示例中看到。
args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/surnames_with_splits.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, # 提前停止条件,如果验证集损失连续n次没有改善则停止训练
dropout_p=0.1, # 随机失活的概率,用于防止过拟合
# 运行时参数(此处省略以节省空间)
)
五.实验结果与分析
多层感知器(MLP)
1.性能评估与预测
在测试数据集上进行评估
评价SurnameClassifier测试数据,我们将数据集设置为遍历测试数据,调用classifier.eval()
方法,并遍历测试数据以同样的方式与其他数据。在这个例子中,调用classifier.eval()
可以防止PyTorch在使用测试/评估数据时更新模型参数。
该模型对测试数据的准确性达到50%左右。如果在附带的notebook中运行训练例程,会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据,所以训练数据的性能并不代表新数据的性能。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。其主要原因是收缩的onehot向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量,但它丢弃了字符之间的顺序信息,这对于识别起源非常重要。
对新姓氏进行分类
给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。注意,我们包含了apply_softmax标志,所以结果包含概率。模型预测,在多项式的情况下,是类概率的列表。我们使用PyTorch张量最大函数来得到由最高预测概率表示的最优类。
为新姓氏获取前K个预测结果
NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测
MLP正则化:权重正则化与结构正则化(或称为dropout)
DROPOUT
简单地说,在训练过程中,dropout有一定概率使属于两个相邻层的单元之间的连接减弱。这有什么用呢?我们从斯蒂芬•梅里蒂(Stephen Merity)的一段直观(且幽默)的解释开始:“Dropout,简单地说,是指如果你能在喝醉的时候反复学习如何做一件事,那么你应该能够在清醒的时候做得更好。这一见解产生了许多最先进的结果和一个新兴的领域。”
神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“Coadaptation”是神经科学中的一个术语,但在这里它只是指一种情况,即两个单元之间的联系变得过于紧密,而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接,我们可以确保没有一个单元总是依赖于另一个单元,从而产生健壮的模型。dropout不会向模型中添加额外的参数,但是需要一个超参数——“drop probability”。drop probability,它是单位之间的连接drop的概率。通常将下降概率设置为0.5。
卷积神经网络
在测试数据集上进行评估
正如“示例:带有多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样,执行评估的代码也没有变化。总之,调用分类器的eval()
方法来防止反向传播,并迭代测试数据集。与 MLP 约 50% 的性能相比,该模型的测试集性能准确率约为56%。尽管这些性能数字绝不是这些特定架构的上限,但是通过一个相对简单的CNN模型获得的改进应该足以让您在文本数据上尝试CNNs。
池化操作
Pooling是将高维特征映射总结为低维特征映射的操作。卷积的输出是一个特征映射。feature map中的值总结了输入的一些区域。由于卷积计算的重叠性,许多计算出的特征可能是冗余的。Pooling是一种将高维(可能是冗余的)特征映射总结为低维特征映射的方法。在形式上,池是一种像sum、mean或max这样的算术运算符,系统地应用于feature map中的局部区域,得到的池操作分别称为sum pooling、average pooling和max pooling。池还可以作为一种方法,将较大但较弱的feature map的统计强度改进为较小但较强的feature map
批规范化(BatchNorm)
批处理标准化是设计网络时经常使用的一种工具。BatchNorm对CNN的输出进行转换,方法是将激活量缩放为零均值和单位方差。它用于Z-transform的平均值和方差值每批更新一次,这样任何单个批中的波动都不会太大地移动或影响它。BatchNorm允许模型对参数的初始化不那么敏感,并且简化了学习速率的调整(Ioffe and Szegedy, 2015)。在PyTorch中,批处理规范是在nn模块中定义的。
# ...
self.conv1 = nn.Conv1d(in_channels=1, out_channels=10,
kernel_size=5,
stride=1) # 初始化第一个一维卷积层,输入通道1,输出通道10,卷积核大小5,步长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) # 将经过ReLU的特征通过批量归一化层
# ...
网络中的网络连接(1x1卷积)
Network-in-Network (NiN)连接是具有kernel_size=1
的卷积内核,具有一些有趣的特性。具体来说,1x1卷积就像通道之间的一个完全连通的线性层。这在从多通道feature map映射到更浅的feature map时非常有用。在图4-14中,我们展示了一个应用于输入矩阵的NiN连接。它将两个通道简化为一个通道。因此,NiN或1x1卷积提供了一种廉价的方法来合并参数较少的额外非线性(Lin et al., 2013)。
2.案例研究
给出进行国籍预测的函数例子
def predict_nationality(name, classifier, vectorizer):
# 将名字转换为向量表示
vectorized_name = vectorizer.vectorize(name)
# 转换为 PyTorch 张量并调整形状以适应模型输入
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 使用模型进行预测,这里指定了 `apply_softmax=True`,意味着模型会直接输出概率
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}
预测前k个国籍
def predict_topk_nationality(name, classifier, vectorizer, k=5):
# 向量化姓名
vectorized_name = vectorizer.vectorize(name)
# 转换为 PyTorch 张量并调整形状以适应模型输入
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 进行预测,得到概率分布,由于 `apply_softmax=True`,输出已经是概率
prediction_vector = classifier(vectorized_name, apply_softmax=True)
# 使用 `torch.topk` 获取概率最高的 k 个国籍及其对应的概率值
probability_values, indices = torch.topk(prediction_vector, k=k)
# 将张量转换为 numpy 数组,只保留概率值和索引的第一条数据(因为我们只有一个输入)
probability_values = probability_values.detach().numpy()[0]
indices = indices.detach().numpy()[0]
# 遍历 top k 的结果,根据索引查找对应的国籍,并存储为字典
results = []
for prob_value, index in zip(probability_values, indices):
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
results.append({'nationality': predicted_nationality,
'probability': prob_value})
# 返回 top k 的预测结果列表
return results
带有dropout的MLP(多层感知器)
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__() # 继承自 nn.Module 基类
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(F.dropout(intermediate, p=0.5)) # 使用 dropout 防止过拟合,然后通过第二层全连接层
# 如果 apply_softmax 为 True,则应用 softmax 函数
if apply_softmax:
output = F.softmax(output, dim=1) # 沿着第二个维度(输出维度)进行 softmax 归一化
return output
使用训练好的模型进行预测
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)
# 将向量转换为形状为(1, -1)的张量,以便于输入到分类器中
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
# 使用分类器进行预测,并在apply_softmax=True的情况下获得概率分布
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}
七.结论比较
- MLP表现:多层感知器能够通过隐藏层学习到输入姓氏数据的高级抽象特征,适用于处理较为简单的文本分类问题,但可能在处理具有复杂结构信息的文本时略显不足。
- CNN优势:卷积神经网络在处理文本序列时,通过滑动窗口机制捕捉到姓氏中的关键局部特征,特别是在处理具有空间局部相关性的字符或单词序列时表现优异,有助于区分不同文化和语言中的姓氏特征。
- 特征学习:两者都展示了自动学习特征的能力,无需手工特征工程,但CNN在保留局部信息方面更胜一筹。
- 综合运用:根据实验结果,对于姓氏分类这类具有特定结构和文化特征的任务,结合MLP和CNN的特性可能是提升分类性能的有效策略。
- 未来方向:进一步研究可能包括模型融合策略、更复杂的网络结构探索,以及利用循环神经网络(RNN)或长短时记忆网络(LSTM)处理序列信息的潜力。
- 实践意义:实验不仅增进了对前馈神经网络在NLP任务中应用的理解,也为类似的文化或语言属性分类问题提供了有价值的实践参考。