实验1 用多层感知器的姓氏分类
WA2114001王晗宇
这篇文章适合什么样的人阅读?
大学生,有《自然语言处理实验》课的。
因为写的太简了,可能不适合专业人员阅读,还请大佬多多指正(抱拳)
目录
一、实验要点
二、实验原理
2.1 多层感知机 (本来是要和卷积神经网络一起写的,最后想想还是分开叭)
2.1.1. 基本结构
2.1.2. 神经元和激活函数
2.1.3. 训练过程
2.1.4. 优化和正则化
2.1.5. 应用
2.1.6. 结论
2.1.7. 编码
三、实验步骤
3.1 例子: 使用多层感知机进行姓氏分类
3.1.1 姓氏数据集
3.1.2 特征工程(词汇表、向量化器和DataLoader)
3.1.3 姓氏分类器模型
3.1.4 训练和验证
3.1.5 测试
一、实验要点
* 通过“示例:带有多层感知器的姓氏分类”,掌握多层感知器在多层分类中的应用
* 掌握每种类型的神经网络层对它所计算的数据张量的大小和形状的影响
二、实验原理
2.1 多层感知机
多层感知机(Multilayer Perceptron, MLP)是一种前馈神经网络,是深度学习和机器学习中常用的模型。MLP由输入层、一个或多个隐藏层和输出层组成,每一层由若干神经元(节点)构成。我下面详细介绍一下:
2.1.1. 基本结构
输入层
输入层接受来自外部的信号,输入层的神经元数等于特征向量的维度。
隐藏层
隐藏层位于输入层和输出层之间,可以有一个或多个隐藏层。每个隐藏层中的神经元与前一层的所有神经元相连。隐藏层的主要功能是通过非线性激活函数处理输入数据,从而能够学习复杂的模式和特征。
输出层
输出层生成最终的预测结果,输出层的神经元数取决于具体的任务。例如,对于二分类问题,输出层通常只有一个神经元,对于多分类问题,输出层的神经元数等于类别数。
图1.1 多层感知机基本结构(画得好丑别介意)
2.1.2. 神经元和激活函数
每个神经元计算加权和并通过激活函数输出。神经元的输出可以表示为:
$$y = (\sum_{i=1}^{n} w_i x_i + b)$$
\[ y = (\sum_{i=1}^{n} w_i x_i + b) \]
(
突然发现不能插入latex
不是啊,latex都输入不了啊,必须吐槽一下平台(其实着之前是个作业,在某个平台(不是指CSDN))
看来只能在其他地方编辑好截图粘贴在这里了
)
下面继续
(jupyternotebook编辑好的截图)
2.1.3. 训练过程
神经网络的训练过程包括前向传播和后向传播。
图1.2 前向传播和反向传播
前向传播
在前向传播过程中,输入数据经过各层的加权和激活函数处理,最终生成输出。每一层的输出作为下一层的输入。
图1.3 前向传播过程
反向传播
反向传播通过计算损失函数的梯度来更新权重和偏置。常用的损失函数包括均方误差(用于回归)和交叉熵损失(用于分类)。反向传播算法通过链式法则计算每一层的梯度,并使用优化算法(如梯度下降、Adam等)更新权重。
图1.4 多层感知机训练
2.1.4. 优化和正则化
优化算法
优化算法用于更新模型参数,使损失函数最小化。常用的优化算法包括:
- 梯度下降(Gradient Descent)
在求解损失函数的最小值时,可以通过梯度下降法来一步步的迭代求解,得到最小化的损失函数和模型参数值。详细见梯度下降(Gradient Descent)-CSDN博客
图1.5 梯度下降
- 随机梯度下降(Stochastic Gradient Descent, SGD)
一批样本训练完求总的损失函数效率不高,于是就有了每个样本都梯度下降一次,由于样本顺序水机,所以脚随机梯度下降(我瞎扯的,具体自己搜官方说法)
- Adam 优化器
图1.6 Adam优化器原理
由于这个地方写不了latex公式,我就不写了。
正则化
有时候模型过于复杂,能够记住训练数据的细节,但不是学习数据的通用模式时,这叫做过拟合。为了防止过拟合,要么增加训练数据,要么对模型参数进行限制,后者叫做正则化。
MLP中常用的正则化技术包括:
- L2 正则化(权重衰减)
即加入一项正则项参数变小。
L2正则化会使得W的值变小,因此Z的值也会变得很小,带入激活函数后,g(z)的值会取再下图中红色区域。
图1.7 L2正则化原理
- Dropout
即随即失活,就下面这图一看就懂。
图1.8 Dropout原理
- 早停(Early Stopping)
2.1.5. 应用
多层感知机可以用于各种任务,包括但不限于:
- 图像分类
- 自然语言处理
- 回归分析
- 信号处理
2.1.6.结论
省流:多层感知机是神经网络的基础模型,通过层级结构和非线性激活函数能够捕捉复杂的模式。
尽管结构简单,但MLP在许多应用中表现良好,尤其在有足够数据和适当调整超参数的情况下。
2.1.7.编码:
(这里待补充一下torch.nn和nn.Module内层逻辑)
torch.nn
是PyTorch中用于构建神经网络的模块,它提供了许多构建神经网络所需的基本组件和功能。而nn.Module
是所有神经网络模块的基类。了解这两个模块的内层逻辑有助于更好地理解和使用PyTorch进行深度学习任务。
torch.nn
模块
torch.nn
模块包含许多用于构建和训练神经网络的组件,包括各种层(如线性层、卷积层、RNN层)、激活函数、损失函数等。这里简要介绍几个主要组件:
-
层(Layers):
nn.Linear
: 全连接层(线性变换)。nn.Conv2d
: 二维卷积层。nn.RNN
,nn.LSTM
,nn.GRU
: 不同类型的循环神经网络层。
-
激活函数(Activation Functions):
nn.ReLU
: Rectified Linear Unit激活函数。nn.Sigmoid
: Sigmoid激活函数。nn.Tanh
: 双曲正切激活函数。
-
损失函数(Loss Functions):
nn.MSELoss
: 均方误差损失函数。nn.CrossEntropyLoss
: 交叉熵损失函数。
-
其他实用功能:
nn.Dropout
: Dropout层,用于防止过拟合。nn.BatchNorm2d
: 二维批归一化层。
nn.Module
类
nn.Module
是所有神经网络模块的基类,几乎所有的神经网络层都是从这个类继承的。它提供了一些基础设施,用于构建和管理神经网络模型的参数、层、子模块等。
nn.Module
的主要功能
-
参数管理:
parameters()
: 返回模型的所有参数。named_parameters()
: 返回模型的所有参数及其名称。
-
子模块管理:
add_module(name, module)
: 向当前模块添加子模块。children()
: 返回当前模块的直接子模块。named_children()
: 返回当前模块的直接子模块及其名称。
-
前向传播(Forward Propagation):
- 每个继承自
nn.Module
的类都需要重写forward
方法,用于定义前向传播的过程。
- 每个继承自
import torch.nn as nn
import torch.nn.functional as F
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) # 如果标志为真,应用softmax激活函数
return output # 返回结果张量
为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度
batch_size = 2 # 一次输入的样本数量
input_dim = 3 # 输入向量的维度
hidden_dim = 100 # 隐藏层的神经元数量
output_dim = 4 # 输出向量的维度
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim) # 初始化模型
print(mlp) # 打印模型结构
我们可以通过传递一些随机输入来快速测试模型的“连接”。因为模型还没有经过训练,所以输出应该是随机的。
import torch
def describe(x):
print("类型: {}".format(x.type()))
print("大小: {}".format(x.shape))
print("值: \n{}".format(x))
x_input = torch.rand(batch_size, input_dim)
describe(x_input)
y_output = mlp(x_input, apply_softmax=False)
describe(y_output)
三、实验步骤
3.1 例子: 使用多层感知机进行姓氏分类
本节展示如何使用MLP将姓氏分类到原籍国。此任务通过推断人口统计信息,具有重要应用。处理时需谨慎对待“受保护属性”。
我们将姓氏拆分为字符,处理方式类似于“例子: 将餐馆评论的情绪分类”。字符级模型在结构和实现上与单词级模型类似。
关键教训是MLP的实现和训练直接来源于第3章中的感知器。本节不包含“例子: 餐馆评论的情绪分类”中的代码。
本节描述了姓氏数据集的预处理步骤,并使用词汇表、向量化器和DataLoader类完成从姓氏字符串到向量化小批处理的管道,与实验3的方法类似。
接下来,我们描述姓氏分类器模型及其设计思路。MLP与实验3中的感知器类似,但增加了多类输出及其对应的损失函数。最后,我们简要介绍了训练过程。
图3.1 实验步骤
下面将逐一解释实验步骤
3.1.1 姓氏数据集
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
图3.2 数据集
流程如下 ——王晗宇(画的丑别介意)
1.读取原始数据:从 CSV 文件中读取原始训练数据。(输入)
↓
2.存储数据:创建一个按国籍存储数据的字典。
↓
3.划分数据集:将数据集划分为训练集、验证集和测试集。
↓
4.保存处理后的数据:将最终处理后的数据保存到 CSV 文件中。(输出)
下面开始逐步编码 ——王晗宇
第0步:先来定义一些参数: ——王晗宇
我们要先定义一个自定义的 SurnameDataset
类,继承自 Dataset
类。它的 __getitem__
方法用于获取指定索引处的数据样本。它从目标数据框中提取出对应索引的姓氏和国籍信息,并使用 Vectorizer
对象将姓氏转换为向量,同时使用 Vocabulary
对象查找国籍对应的索引。最后,它返回一个包含姓氏向量和国籍索引的字典作为数据样本。
from torch.utils.data import Dataset
class SurnameDataset(Dataset):
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}
原始数据集
https://course.educg.net/a5eedf85fdf8ef11eeb6b027c1b76ae8/lab/tree/surnames.csv
import collections
import numpy as np
import pandas as pd
import re
from argparse import Namespace
# 划分数据集(也可以说是存储划分数据集参数)
args = Namespace(
raw_dataset_csv="surnames.csv",
train_proportion=0.7,
val_proportion=0.15,
test_proportion=0.15,
output_munged_csv="surnames_with_splits.csv",
seed=1337
)
1.读取原始数据:从 CSV 文件中读取原始训练数据。——王晗宇
# 读取
surnames = pd.read_csv(args.raw_dataset_csv, header=0)
# 看看前几行知道长啥样
surnames.head()
# 类别
set(surnames.nationality)
2.存储数据:创建一个按国籍存储数据的字典。——王晗宇
# 按国籍划分训练集
# 创建字典
by_nationality = collections.defaultdict(list)
for _, row in surnames.iterrows():
by_nationality[row.nationality].append(row.to_dict())
3.划分数据集:将数据集划分为训练集、验证集和测试集。 ——王晗宇
分组:将字典按 nationality 列进行分组。
↓
按比例分割数据:将每个评分的数据按比例分为训练集、验证集和测试集。
↓
逐个处理并标记:为每个数据点标记 split 属性。
↓
合并数据:将处理后的数据添加到 final_list 中。
from tqdm.notebook import tqdm # 画进度条的
# 创建分割数据
final_list = []
np.random.seed(args.seed)
# for _, item_list in sorted(by_nationality.items()):
for _, item_list in tqdm(sorted(by_nationality.items()), desc="处理进度"):
np.random.shuffle(item_list)
n = len(item_list)
# 计算每个集数量
n_train = int(args.train_proportion*n)
n_val = int(args.val_proportion*n)
n_test = int(args.test_proportion*n)
# 贴标签
for item in item_list[:n_train]: # 不想用原来的
# for item in tqdm(item_list[:n_train], desc="处理训练集"): # 我改成有进度条的(下同)
item['split'] = 'train'
for item in item_list[n_train:n_train+n_val]:
# for item in tqdm(item_list[n_train:n_train+n_val], desc="处理验证集"):
item['split'] = 'val'
for item in item_list[n_train+n_val:]:
# for item in tqdm(item_list[n_train+n_val:n_train+n_val+n_test], desc="处理测试集"):
item['split'] = 'test'
# 写入
final_list.extend(item_list)
# 将分割数据写入文件
final_surnames = pd.DataFrame(final_list)
# 康康每个数据集的样本数
final_surnames.split.value_counts()
final_surnames.head() # 看看前几行知道长啥样
4.保存处理后的数据:将最终处理后的数据保存到 CSV 文件中。(输出)——王晗宇
# 改成csv
final_surnames.to_csv(args.output_munged_csv, index=False)
搞好之后应该是这个
https://course.educg.net/a5eedf85fdf8ef11eeb6b027c1b76ae8/lab/tree/surnames_with_splits.csv
3.1.2 特征工程(词汇表、向量化器和DataLoader)
在姓氏分类任务中,我们利用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的小批量数据。这些数据结构类似于用于情感分类的示例中的结构。
姓氏向量化器将姓氏字符串转换为向量,利用词汇表将字符映射到整数,然后通过创建one-hot向量表示。
图3.3 特征工程
(后续实验中将介绍其他向量化方法,如热门矩阵和嵌入层,它们可能在某些情况下更有效)
3.1.2.1 词汇表(The Vocabulary)
图3.3.1 特征工程1:词汇表(The Vocabulary)
功能表: ——王晗宇
一、功能:
1.映射现有的令牌:传入一个包含令牌到索引(数字)映射的字典,词汇表会用这个字典来初始化自己。
2.处理未知令牌(UNK):词汇表可以自动添加一个特殊的“未知”令牌(通常表示为 )。这个令牌用于处理那些在构建词汇表时没有见过的单词。当遇到这些未知单词时,词汇表会返回这个特殊令牌对应的索引,以确保程序不会因为找不到单词而报错。
二、令牌管理函数:
to_serializable(self): 返回可序列化的字典。
from_serializable(cls, contents): 从序列化字典实例化词汇表。
add_token(token): 向词汇表中添加新令牌,并返回对应的索引。
add_many(self, tokens): 添加多个令牌,返回索引列表。
lookup_token(token): 查找与令牌关联的索引,不存在且 add_unk 为 True,则返回 UNK 索引。
lookup_index(index): 根据索引查找对应的令牌,如果索引不存在,则报错(KeyError)。
三、辅助函数:
__str__(): 返回词汇表的字符串表示,显示词汇表的大小。
__len__(): 返回词汇表中的令牌数量。
class Vocabulary(object):
"""处理文本并提取词汇表以进行映射的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token=""):
"""
参数:
token_to_idx (dict): 预先存在的令牌到索引的映射
add_unk (bool): 是否添加UNK令牌的标志
unk_token (str): 要添加到词汇表中的UNK令牌
"""
if token_to_idx is None:
token_to_idx = {} # 如果没有提供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 # 是否添加UNK令牌的标志
self._unk_token = unk_token # UNK令牌的值
self.unk_index = -1 # 初始化UNK令牌的索引
if add_unk:
self.unk_index = self.add_token(unk_token) # 如果需要添加UNK令牌,则添加并保存其索引
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):
"""根据令牌更新映射字典。
参数:
token (str): 要添加到词汇表中的项目
返回:
index (int): 对应于令牌的整数索引
"""
try:
index = self._token_to_idx[token] # 获取令牌对应的索引
except KeyError:
index = len(self._token_to_idx) # 如果令牌不在映射中,分配新的索引
self._token_to_idx[token] = index # 添加令牌到索引的映射
self._idx_to_token[index] = token # 添加索引到令牌的映射
return index # 返回令牌的索引
def add_many(self, tokens):
"""将多个令牌添加到词汇表中
参数:
tokens (list): 字符串令牌列表
返回:
indices (list): 对应于令牌的索引列表
"""
return [self.add_token(token) for token in tokens] # 为每个令牌调用add_token并返回索引列表
def lookup_token(self, token):
"""检索与令牌关联的索引,如果令牌不存在,则返回UNK索引。
参数:
token (str): 要查找的令牌
返回:
index (int): 对应于令牌的索引
注意:
UNK功能需要unk_index >= 0 (已添加到词汇表中)
"""
if self.unk_index >= 0:
return self._token_to_idx.get(token, self.unk_index) # 返回令牌的索引或UNK索引
else:
return self._token_to_idx[token] # 如果没有UNK令牌,返回令牌的索引
def lookup_index(self, index):
"""返回与索引关联的令牌
参数:
index (int): 要查找的索引
返回:
token (str): 对应于索引的令牌
抛出:
KeyError: 如果索引不在词汇表中
"""
if index not in self._idx_to_token:
raise KeyError("索引 (%d) 不在词汇表中" % index) # 如果索引不在映射中,抛出KeyError
return self._idx_to_token[index] # 返回与索引关联的令牌
def __str__(self):
return "<Vocabulary(size=%d)>" % len(self) # 返回词汇表的字符串表示
def __len__(self):
return len(self._token_to_idx) # 返回词汇表的大小
3.1.2.2 向量化器(The Vectorizer)
图3.3.2 特征工程2:向量化器(The Vectorizer)
功能表: ——王晗宇
一、功能:
1.矢量化评论文本:将评论文本转换为one-hot向量
2.从数据框创建矢量化器:从数据框中提取频繁出现的单词并创建矢量化器
3.从可序列化的字典创建和保存矢量化器:从可序列化的字典中实例化矢量化器,并将矢量化器转换为可序列化的字典以便保存
二、函数:
vectorize(self, review):为评论创建one-hot向量
from_dataframe(cls, review_df, cutoff=25):从数据框实例化矢量化器,基于频率的过滤参数选择单词
from_serializable(cls, contents):从可序列化的字典实例化矢量化器
to_serializable(self):创建可缓存的可序列化字典
class SurnameVectorizer(object):
""" 协调词汇表并将其应用的向量化器类 """
def __init__(self, surname_vocab, nationality_vocab):
"""
参数:
surname_vocab (Vocabulary): 将字符映射到整数的词汇表
nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
"""
self.surname_vocab = surname_vocab # 姓氏词汇表
self.nationality_vocab = nationality_vocab # 国籍词汇表
def vectorize(self, surname):
"""
参数:
surname (str): 姓氏
返回:
one_hot (np.ndarray): 压缩的一热编码
"""
vocab = self.surname_vocab # 获取姓氏词汇表
one_hot = np.zeros(len(vocab), dtype=np.float32) # 初始化一热编码向量
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="@") # 初始化姓氏词汇表,UNK令牌为"@"
nationality_vocab = Vocabulary(add_unk=False) # 初始化国籍词汇表,不添加UNK令牌
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) # 返回向量化器实例
@classmethod
def from_serializable(cls, contents):
"""从可序列化字典实例化向量化器"""
surname_vocab = Vocabulary.from_serializable(contents['surname_vocab']) # 从序列化数据中恢复姓氏词汇表
nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab']) # 从序列化数据中恢复国籍词汇表
return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab) # 返回向量化器实例
def to_serializable(self):
"""返回可序列化的字典"""
return {'surname_vocab': self.surname_vocab.to_serializable(), # 姓氏词汇表的序列化
'nationality_vocab': self.nationality_vocab.to_serializable()} # 国籍词汇表的序列化
3.1.2.3 DataLoader
图3.3.3 特征工程3:DataLoader
SurnameDataset 类:处理姓氏数据集并实现数据的加载、处理和批量生成。(与PyTorch的Dataset和DataLoader一起工作)
一、功能:
数据加载和向量化器管理:创建相应的向量化器。提供从CSV文件加载数据集并创建新向量化器的方法或从文件加载已保存向量化器的方法。
数据集分割:按照训练集、验证集和测试集的分割存储数据。
计算类权重:统计每个国籍的样本数量,并计算类别权重,用于处理类别不平衡问题。
数据访问:根据索引返回数据点,包括特征(姓氏的向量表示)和标签(国籍索引)。
批量生成:提供生成批量数据的生成器函数generate_batches,确保张量在指定设备上。
二、方法:
load_dataset_and_make_vectorizer(cls, surname_csv):从CSV文件加载数据集,并创建一个新的向量化器。返回SurnameDataset实例。
load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):和上面一个差不多,但是从文件加载已保存的向量化器。
load_vectorizer_only(vectorizer_filepath):只从文件加载已保存的向量化器。
save_vectorizer(self, vectorizer_filepath):将向量化器保存到指定文件。
get_vectorizer(self):返回当前的向量化器。
set_split(self, split="train"):设置当前使用的数据集分割(训练集、验证集、测试集)。
三、辅助方法:
__len__(self):看数据集分割的大小。
__getitem__(self, index):根据索引返回数据点,包括特征(姓氏的向量表示)和标签(国籍索引)。
get_num_batches(self, batch_size):返回批次数量。
generate_batches(dataset, batch_size, shuffle=True, drop_last=True, device="cpu"):生成批量数据,确保张量在指定设备上。
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().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):
"""加载数据集并从头创建一个新的向量化器
参数:
surname_csv (str): 数据集的位置
返回:
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):
"""加载数据集和相应的向量化器
在向量化器已经缓存以便重用的情况下使用
参数:
surname_csv (str): 数据集的位置
vectorizer_filepath (str): 已保存的向量化器的位置
返回:
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):
"""从文件加载向量化器的静态方法
参数:
vectorizer_filepath (str): 序列化的向量化器的位置
返回:
SurnameVectorizer实例
"""
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp)) # 从文件加载向量化器
def save_vectorizer(self, vectorizer_filepath):
"""使用json保存向量化器
参数:
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数据集的主要入口方法
参数:
index (int): 数据点的索引
返回:
包含数据点的字典:
features (x_surname): 姓氏的向量表示
label (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):
"""给定批量大小,返回数据集中的批次数量
参数:
batch_size (int)
返回:
数据集中的批次数量
"""
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 # 生成数据字典
3.1.3 姓氏分类器模型
这里利用第二节将的多层感知机
图3.4 构建模型
这里用两层多层感知器,原理见2.1.7节,这里不介绍了
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) # 如果需要,应用softmax激活函数
return prediction_vector # 返回预测结果张量
3.1.4 训练和验证
我们只展示了args以及本例中的训练例程与“示例:餐厅评论情绪分类”中的示例之间的主要区别
图3.5 训练/验证模型
(1)训练前的组件:
下面要定义三个组件函数:——王晗宇
初始化训练状态:创建并初始化一个包含训练状态信息的字典。
更新训练状态:在训练过程中根据验证损失更新训练状态,包括提前停止和模型检查点保存。
计算准确率:根据模型预测和实际目标计算准确率。
(具体描述放代码注释了)
'''
王晗宇描述的外部特征,下同
1. make_train_state(args)(初始化训练状态)
输入:args: 一个包含训练参数的对象,通常包括学习率、模型文件名等。
输出:一个字典,包含一堆键值对(不列了)
功能:初始化并返回一个训练状态字典,用于记录训练过程中的各种状态和统计信息。
'''
def make_train_state(args):
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} # 模型文件名
'''
2. update_train_state(args, model, train_state)(更新训练状态)
输入:
args: 一个包含训练参数的对象,通常包括提前停止标准等。
model: 当前训练的模型实例,用于保存模型参数。
train_state: 记录当前训练状态的字典。
输出:更新后的训练状态字典。
功能:根据验证损失更新训练状态字典,。
说明:包含提前停止逻辑和模型检查点保存逻辑;要检查最近两次的验证损失,如果损失增加,则增加提前停止步骤计数;如果模型更好,则更新模型。
'''
def update_train_state(args, model, train_state):
# 至少保存一个模型
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 # 返回更新后的训练状态
'''
3. compute_accuracy(y_pred, y_target)(计算准确率)
输入:
y_pred: 预测值(tensor型,不然报错)
y_target: 目标值(tensor型,不然报错)
输出:accuracy(准确率)
功能:算accuracy(准确率)呗
'''
def compute_accuracy(y_pred, y_target):
_, 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 # 返回准确度百分比
两个辅助函数:
# 两个辅助的函数
def set_seed_everywhere(seed, cuda): # 芝士随机种子
np.random.seed(seed)
torch.manual_seed(seed)
if cuda:
torch.cuda.manual_seed_all(seed)
def handle_dirs(dirpath): # 芝士检查是否有文件夹没有就创建的
if not os.path.exists(dirpath):
os.makedirs(dirpath)
(2)训练前夕,检查东西
# 检查点东西
import datetime # 打印时间的,别介意
def why_time():
current_time = datetime.datetime.now()
formatted_time = current_time.strftime("%Y-%m-%d %H:%M:%S")
return formatted_time
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=20, # 训练轮数(不要100)
early_stopping_criteria=5, # 提前停止标准
learning_rate=0.001, # 学习率
batch_size=64, # 批量大小
# 运行时选项
cuda=False, # 是否使用CUDA
reload_from_files=False, # 是否从文件重新加载
expand_filepaths_to_save_dir=True, # 是否扩展文件保存路径
)
print(f'\033[0;31m叮咚~ \033[0;32m数据信息检查完毕 \033[0;33mby WA2114001王晗宇 \033[0;35m{why_time()}\033[0m')
# 扩展文件路径
if args.expand_filepaths_to_save_dir:
args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file) # 扩展向量化器文件路径
args.model_state_file = os.path.join(args.save_dir, args.model_state_file) # 扩展模型状态文件路径
print(f'\033[0;31m叮咚~ \033[0;32m文件路径设置完毕 \033[0;33mby WA2114001王晗宇 \033[0;35m{why_time()}\033[0m')
print("\t向量化器文件路径:{}".format(args.vectorizer_file)) # 打印扩展后的向量化器文件路径
print("\t模型状态文件路径{}".format(args.model_state_file)) # 打印扩展后的模型状态文件路径
# 检查CUDA
if not torch.cuda.is_available():
args.cuda = False # 如果CUDA不可用,设置为False
args.device = torch.device("cuda" if args.cuda else "cpu") # 设置设备为CUDA或CPU
print("是不是cuda?: {}".format(args.cuda))
# 设置随机种子以确保可重复性
set_seed_everywhere(args.seed, args.cuda)
print(f'\033[0;31m叮咚~ \033[0;32m随机种子设置完毕 \033[0;33mby WA2114001王晗宇 \033[0;35m{why_time()}\033[0m')
# 处理目录
handle_dirs(args.save_dir) # 创建保存目录
print(f'\033[0;31m叮咚~ \033[0;32m目录设置完毕 \033[0;33mby WA2114001王晗宇 \033[0;35m{why_time()}\033[0m')
if args.reload_from_files: # 如果参数 reload_from_files 为真
print("重新加载!")
dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv, args.vectorizer_file)
else:
print("创建新的!")
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv) # 加载数据集并创建向量化器
dataset.save_vectorizer(args.vectorizer_file) # 保存向量化器
vectorizer = dataset.get_vectorizer() # 获取向量化器
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
hidden_dim=args.hidden_dim,
output_dim=len(vectorizer.nationality_vocab))
# 创建姓氏分类器(输入维度为姓氏词汇表长度,隐藏层维度为 args.hidden_dim,输出维度为国籍词汇表长度)
(3)开始训练
训练/验证的步骤如下图所示(王晗宇自创图,部分参考张鹏老师,有很大改动,其他同学如果有也是借鉴我的):
图3.6 训练/验证过程
根据这个过程,我们可以写出代码:
classifier = classifier.to(args.device) # 将分类器移动到指定设备
dataset.class_weights = dataset.class_weights.to(args.device) # 将数据集的类别权重移动到指定设备
loss_func = nn.CrossEntropyLoss(dataset.class_weights) # 交叉熵损失函数
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate) # Adam优化器
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
mode='min', factor=0.5,
patience=1) # 学习率调度器,当验证损失不再减少时降低学习率
train_state = make_train_state(args) # 初始化训练状态
epoch_bar = tqdm_notebook(desc='整体训练进度',
total=args.num_epochs,
position=0) # 训练过程进度条
dataset.set_split('train') # 设置数据集为训练模式
train_bar = tqdm_notebook(desc='训练集',
total=dataset.get_num_batches(args.batch_size),
position=1,
leave=True) # 训练集进度条
dataset.set_split('val') # 设置数据集为验证模式
val_bar = tqdm_notebook(desc='验证集',
total=dataset.get_num_batches(args.batch_size),
position=1,
leave=True) # 验证集进度条
try:
for epoch_index in range(args.num_epochs):
train_state['epoch_index'] = epoch_index # 记录当前周期索引
# ----------------------------------------------------------------
dataset.set_split('train') # 训练模式
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device) # 生成批次
running_loss = 0.0 # loss
running_acc = 0.0 # accuracy
classifier.train() # 声明训练模式
for batch_index, batch_dict in enumerate(batch_generator):
# 1.梯度置0
optimizer.zero_grad()
# 2.算输出(y_pred)
y_pred = classifier(batch_dict['x_surname'])
# 3.算loss
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item() # 获取损失值
running_loss += (loss_t - running_loss) / (batch_index + 1) # 更新运行中的损失
# 4.算梯度
loss.backward()
# 5.梯度下降更新
optimizer.step()
# 6.算accuracy
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1) # 更新运行中的准确度
# 更新进度条
train_bar.set_postfix(loss=running_loss, acc=running_acc,
epoch=epoch_index)
train_bar.update()
train_state['train_loss'].append(running_loss) # 记录训练集loss
train_state['train_acc'].append(running_acc) # 记录训练集accuracy
# ----------------------------------------------------------------
dataset.set_split('val') # 验证(评估)模式
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device) # 生成批次
running_loss = 0.0
running_acc = 0.0
classifier.eval() # 声明验证(评估)模式
for batch_index, batch_dict in enumerate(batch_generator): # 迭代每个批次
# 2
y_pred = classifier(batch_dict['x_surname'])
# 3
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.to("cpu").item() # 获取损失值并转移到CPU
running_loss += (loss_t - running_loss) / (batch_index + 1) # 更新运行中的损失
# 6
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1) # 更新运行中的准确度
val_bar.set_postfix(loss=running_loss, acc=running_acc,
epoch=epoch_index)
val_bar.update()
train_state['val_loss'].append(running_loss) # 记录验证集loss
train_state['val_acc'].append(running_acc) # 记录验证集accuracy
train_state = update_train_state(args=args, model=classifier,
train_state=train_state) # 更新训练状态
scheduler.step(train_state['val_loss'][-1]) # 更新学习率调度器
if train_state['stop_early']: # 如果需要提前停止
break
train_bar.n = 0 # 重置训练进度条
val_bar.n = 0 # 重置验证进度条
epoch_bar.update() # 更新周期进度条
except KeyboardInterrupt: # 如果中断
print("训练完成")
版本不太一样会报一堆警告,但不要紧
3.1.5 测试
图3.7 使用模型
(1)测试集测试
使用测试集测试看loss和accuracy,和3.14验证的步骤差不多。
# 测试(其实和验证过程一样的)
classifier.load_state_dict(torch.load(train_state['model_filename']))
classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
dataset.set_split('test')
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device)
running_loss = 0.0
running_acc = 0.0
classifier.eval()
for batch_index, batch_dict in enumerate(batch_generator):
# 2
y_pred = classifier(batch_dict['x_surname'])
# 3
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# 6
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc
print("测试集loss: {};".format(train_state['test_loss']))
print("测试集Accuracy: {}".format(train_state['test_acc']))
(2)手动测试
手动预测函数输入输出功能写注释里面了
"""
输入:
surname (str): 要分类的姓氏
classifier (SurnameClassifier): 分类器的实例
vectorizer (SurnameVectorizer): 对应的向量化器
输出:
包含最可能的国籍及其概率的字典
功能:预测新姓氏的国籍
"""
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() # 将索引转换为Python标量
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index) # 根据索引查找预测的国籍
probability_value = probability_values.item() # 获取预测概率值
return {'国籍': predicted_nationality, '概率': probability_value} # 返回包含预测国籍和概率的字典
应用
我咋成韩国人了(。)
这样就对了
下面试试更好玩的
查找索引为 8 的国籍
vectorizer.nationality_vocab.lookup_index(8)
也可以改一下刚才的函数让它输出多个答案
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个最大概率值和对应的索引
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,
'概率': prob_value})
return results
new_surname = input("看姓氏: ")
classifier = classifier.to("cpu")
k = int(input("你想看前多少个答案"))
if k > len(vectorizer.nationality_vocab):
print("抱歉,总共莓那么多国家:)")
k = len(vectorizer.nationality_vocab)
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)
print("前{}个可能的国家为:".format(k))
print("===================")
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['国籍'],
prediction['概率']))
看来它唯独不承认()同志是American(误,doge)
四、总结
个人硬件问题,训练时间较短,epoch少,效果不是太好,请见谅
大学生自然语言处理实验课作业
求求点个赞叭 ≧ω≦ ≧ω≦ ≧ω≦