目录
目录
前言
在本博客中,我将使用文字、代码、图片与实验结果来简要介绍最基本神经网络的基本概念,以及如何使用它们进行姓氏分类。
1. 实验介绍
1.1 背景引入
在上一次实验中,我们通过观察感知器来介绍神经网络的基础,通过学习了解到感知器很大的一个缺点是它不能学习数据中存在的一些非常重要的模式。具体而言,感知器仅可处理线性可分问题,面对多类别多分类问题需要多个分类器导致训练成本增加。
在此基础上,多层感知机(MLP)应运而生,多层感知器在结构上扩展了我们之前研究的简单感知器,将多个感知器分组在一个单层,并将多个层叠加在一起。
本实验研究的第二种前馈神经网络,卷积神经网络(CNN),是多层感知机的变种,其本质是一个多层感知机,成功的原因在于其所采用的局部连接和权值共享的方式:
- 减少了权值的数量使得网络易于优化
- 降低了模型的复杂度,即减小了过拟合的风险
1.2 实验要点
- 通过“示例:带有多层感知器的姓氏分类”,掌握多层感知器在多层分类中的应用
- 掌握每种类型的神经网络层对它所计算的数据张量的大小和形状的影响
2. 多层感知机
2.1 概述
多层感知机(Multilayer Perceptron,简称MLP),是一种基于前馈神经网络的深度学习模型,由多个神经元层组成,其中每个神经元层与前一层全连接,并通过带有权重的连接传递信号。多层感知机可以用于解决分类、回归和聚类等各种机器学习问题。
多层感知机的每个神经元层由许多神经元组成,其中输入层接收输入特征,输出层给出最终的预测结果,中间的隐藏层用于提取特征和进行非线性变换。每个神经元接收前一层的输出,进行加权和和激活函数运算,得到当前层的输出。通过不断迭代训练,多层感知机可以自动学习到输入特征之间的复杂关系,并对新的数据进行预测。
2.2 基本结构
最简单的MLP,如图2-1所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。MLP允许有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量——第三阶段。
图2-1 MLP基本结构
概括为输入层—>隐藏层—>输出层,MLP可以有多个隐藏层。
MLP基本流程如下:
- 输入层:接收输入特征,并将其传递给下一层。每个输入特征对应于输入层中的一个神经元。
- 隐藏层:位于输入层和输出层之间的一层或多层神经元。隐藏层的作用是对输入数据进行非线性变换和特征提取。
- 输出层:产生最终的输出结果。通常,输出层的神经元数量取决于任务的类型,例如二分类任务有一个输出神经元,多分类任务有多个输出神经元。
- 激活函数:隐藏层和输出层的神经元通常会应用激活函数,以引入非线性性质,使得模型能够学习非线性关系。
- 前向传播:从输入层到输出层的信号传递过程。每个神经元接收上一层的输出,并计算加权和,然后应用激活函数。
- 损失函数:衡量模型输出与真实标签之间的差异。常见的损失函数包括均方误差(MSE)用于回归任务,交叉熵用于分类任务。
- 反向传播:使用梯度下降等优化算法,通过计算损失函数对模型参数的梯度,并利用链式法则逐层传播误差,更新模型参数。
- 训练:通过迭代的前向传播和反向传播过程,不断调整模型参数,使得模型的预测结果与真实标签更加接近。
2.3 示例:带有多层感知器的姓氏分类
在本小节,通过将MLP应用于将姓氏分类到其原籍国的任务,从公开观察到的数据来推断人口统计信息,具有从产品推荐到确保不同人口统计用户获得公平结果的应用。首先对每个姓氏的字符进行拆分,除了数据上的差异,字符层模型在结构和实现上与基于单词的模型基本相似。其余部分将从姓氏数据集及其预处理步骤的描述开始,接着通过使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的步骤。
示例主要流程如下:
- 数据预处理:将每个姓氏拆分为字符并构建词汇表
- 数据向量化:将字符转换为数字表示,创建DataLoader来加载数据
- 设计MLP模型:包括两个线性层和ReLU非线性激活函数,输出带有softmax激活函数的线性层,用于多类别分类。并选择适当的损失函数以及随机梯度下降等优化算法来训练模型
- 训练模型:迭代数据批次,计算模型的损失,反向传播更新模型参数
- 评估模型性能:根据需要进行调整和改进
下面,我们结合代码具体分析案例。
2.3.1 导入库和软件包
from argparse import Namespace # 导入Namespace类,用于创建命名空间
from collections import Counter # 导入Counter类,用于计数
import json # 导入json模块,用于JSON数据的处理
import os # 导入os模块,用于操作系统相关的功能
import string # 导入string模块,用于字符串操作
import numpy as np # 导入NumPy库,用于数值计算
import pandas as pd # 导入Pandas库,用于数据处理
import torch # 导入PyTorch库
import torch.nn as nn # 导入PyTorch中的神经网络模块
import torch.nn.functional as F # 导入PyTorch中的函数模块
import torch.optim as optim # 导入PyTorch中的优化器模块
from torch.utils.data import Dataset, DataLoader # 导入PyTorch中的数据加载模块
from tqdm import tqdm_notebook # 导入tqdm_notebook函数,用于显示进度条
2.3.2 数据集引入
本次实验中使用到的数据集为姓氏数据集,它收集了来自18个不同国家的10,000个姓氏。该数据集将在本次实验中有较大用处,并具有一些特殊的属性。首先是不平衡性:排名前三的课程占数据的60%以上,其中,27%是英语,21%是俄语,14%是阿拉伯语。其次,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系,有些拼写变体与原籍国联系非常紧密,如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”等等。
为了创建最终可以有效使用的数据集,先对简单的数据集执行相关修改操作。由于原始数据集中70%以上是俄文,通过选择标记为俄语的姓氏的随机子集对这个类进行子样本的划分处理,来减少数据的不平衡性。根据国籍对数据集进行分组,并将数据集分为三个部分:70%为训练数据集,15%为验证数据集,最后15%为测试数据集。
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
初始化 SurnameDataset 对象
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 weights
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):
""" returns the vectorizer """
return self._vectorizer
def set_split(self, split="train"):
""" selects the splits in the dataset using a column in the dataframe """
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):
"""the primary entry point method for PyTorch datasets
Args:
index (int): the index to the data point
Returns:
a dictionary holding the data point's:
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):
"""Given a batch size, return the number of batches in the dataset
Args:
batch_size (int)
Returns:
number of batches in the dataset
"""
return len(self) // batch_size
def generate_batches(dataset, batch_size, shuffle=True,
drop_last=True, device="cpu"):
"""
A generator function which wraps the PyTorch DataLoader. It will
ensure each tensor is on the write device location.
"""
dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
shuffle=shuffle, drop_last=drop_last)
for data_dict in dataloader: # 遍历 DataLoader 生成的批次
out_data_dict = {}
for name, tensor in data_dict.items():
out_data_dict[name] = data_dict[name].to(device)
yield out_data_dict
2.3.3 数据矢量化
成功引入数据集后,为使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。
THE VOCABULARY CLASS
词汇表是两个Python字典的协调,第一个字典将字符映射到整数索引,第二个字典将整数索引映射到字符。add_token方法用于向词汇表中添加新的字符,lookup_token方法用于检索索引,lookup_index方法用于检索给定索引的字符。与Yelp评论的词汇表不同,这里使用的是one-hot词汇表,不计算字符出现的频率,只限制频繁出现的内容。主要是因为数据集很小,而且大多数字符出现次数很多。
class Vocabulary(object):
"""Class to process text and extract vocabulary for mapping"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
初始化函数,创建一个 Vocabulary 对象
Args:
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 # 将 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 索引为 -1
if add_unk:
self.unk_index = self.add_token(unk_token) # 如果需要添加 UNK 标记,则将 UNK 标记添加到词汇表中,获取其索引
def to_serializable(self):
""" 返回可序列化的字典 """
return {'token_to_idx': self._token_to_idx, # 返回包含 token_to_idx 字典、add_unk 和 unk_token 的字典
'add_unk': self._add_unk,
'unk_token': self._unk_token}
@classmethod
def from_serializable(cls, contents):
""" 从序列化的字典中实例化 Vocabulary 对象 """
return cls(**contents) # 使用字典中的内容初始化 Vocabulary 对象
def add_token(self, token):
"""根据标记更新映射字典
Args:
token (str): 要添加到 Vocabulary 中的标记
Returns:
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):
"""将标记列表添加到 Vocabulary 中
Args:
tokens (list): 一个包含字符串标记的列表
Returns:
indices (list): 与标记对应的索引列表
"""
return [self.add_token(token) for token in tokens] # 调用 add_token() 方法添加标记列表中的每个标记,并返回相应的索引列表
def lookup_token(self, token):
"""检索与标记关联的索引,如果标记不存在,则返回 UNK 索引
Args:
token (str): 要查找的标记
Returns:
index (int): 与标记对应的索引
Notes:
UNK 功能需要 unk_index >= 0(已添加到 Vocabulary 中)
"""
if self.unk_index >= 0: # 如果 UNK 索引已存在
return self._token_to_idx.get(token, self.unk_index) # 返回标记对应的索引,如果标记不存在则返回 UNK 索引
else:
return self._token_to_idx[token] # 直接返回标记对应的索引
def lookup_index(self, index):
"""返回与索引关联的标记
Args:
index (int): 要查找的索引
Returns:
token (str): 与索引对应的标记
Raises:
KeyError: 如果索引不在 Vocabulary 中
"""
if index not in self._idx_to_token: # 如果索引不在将索引映射回标记的字典中
raise KeyError("索引 (%d) 不在 Vocabulary 中" % 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) # 返回词汇表的大小
THE SURNAMEVECTORIZER
词汇表将每个单独的字符映射到一个整数,而SurnameVectorizer则负责将姓氏转换为向量表示。本示例中的姓氏不是根据空格分割的,而是字符序列。在没有卷积神经网络的情况下,我们会简单地将每个字符视为一个标记,并通过one-hot编码将其转换为向量表示。我们会为未在训练数据中遇到过的字符指定一个特殊的标记,通常是UNK(表示未知)。
虽然在这个示例中我们使用了简单的one-hot编码,但后续的实验将介绍其他向量化方法,它们是one-hot编码的替代方案,有时甚至更有效。例如,在“使用CNN对姓氏进行分类”示例中,我们将使用热门矩阵,其中每个字符都在矩阵中有一个位置,并具有自己的热门向量。
class SurnameVectorizer(object):
"""用于协调词汇表并将其用于向量化的向量化器"""
def __init__(self, surname_vocab, nationality_vocab):
"""
初始化 SurnameVectorizer 对象
Args:
surname_vocab (Vocabulary): 将字符映射到整数的词汇表
nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
"""
self.surname_vocab = surname_vocab # 姓氏词汇表
self.nationality_vocab = nationality_vocab # 国籍词汇表
def vectorize(self, surname):
"""
向量化姓氏
Args:
surname (str): 姓氏
Returns:
one_hot (np.ndarray): 压缩的 one-hot 编码
"""
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):
"""从数据框实例化向量化器
Args:
surname_df (pandas.DataFrame): 姓氏数据集
Returns:
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) # 返回一个 SurnameVectorizer 对象
@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) # 返回一个 SurnameVectorizer 对象
def to_serializable(self):
return {'surname_vocab': self.surname_vocab.to_serializable(),
'nationality_vocab': self.nationality_vocab.to_serializable()}
2.3.4 定义MLP模型
我们定义SurnameClassifier类来实现MLP模型。它有两个线性层,第一个线性层将输入的姓氏向量映射到一个中间向量,并对该向量应用非线性变换。第二个线性层将中间向量映射到预测向量。
在最后一步,我们可以选择应用softmax操作,将输出转换为“概率”,确保它们的总和为1。这是因为我们使用的损失函数是交叉熵损失,在多类别分类问题中表现良好。但是,计算softmax函数不仅在计算上会产生浪费,而且在训练过程中有时也会不稳定。
#一个用于分类姓氏的2层多层感知器
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.shape 应为 (batch, input_dim)
apply_softmax (bool): softmax 激活的标志
如果与交叉熵损失一起使用,则应为 false
返回:
结果张量。tensor.shape 应为 (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)
return prediction_vector
2.3.5 模型训练
(1) 配置参数
在训练之前,需要初始化、配置和准备训练所需的各种参数和实用函数。具体功能如下:
- make_train_state函数:
函数创建了一个包含训练状态的字典,其中包括了一些训练过程中需要跟踪的信息,比如停止早期训练、学习率、当前训练轮数、训练和验证的损失和准确率等等。这个函数的参数args是一个命名空间,包含了各种训练相关的参数。
-
update_train_state函数:
用于更新训练状态。
-
compute_accuracy函数:
计算模型的准确率。给定模型的预测值y_pred和实际目标值y_target,它首先找到每个预测中最大值的索引,然后将预测值与目标值进行比较,计算出正确预测的数量,最后将正确预测的数量除以总样本数,得到准确率。
-
set_seed_everywhere函数:
在所有设备上设置种子,以确保结果的可重复性。它接受一个种子参数和一个布尔值参数cuda(表示是否使用CUDA),然后设置相应的随机种子。
-
handle_dirs函数:
检查给定的目录路径是否存在,如果不存在,则创建该目录。在这里,它用于确保保存模型的目录存在。
#创建训练状态字典
def make_train_state(args):
"""
参数:
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}
#处理训练状态更新
def update_train_state(args, model, train_state):
"""
组件:
- 提前停止: 防止过拟合。
- 模型检查点: 如果模型更好,则保存模型
参数:
args: 主要参数
model: 要训练的模型
train_state: 表示训练状态值的字典
返回:
更新后的 train_state
"""
# 至少保存一个模型
if train_state['epoch_index'] == 0:
torch.save(model.state_dict(), train_state['model_filename'])
train_state['stop_early'] = False
# Save model if performance improved
elif train_state['epoch_index'] >= 1:
loss_tm1, loss_t = train_state['val_loss'][-2:]
# If loss worsened
if loss_t >= train_state['early_stopping_best_val']:
# Update step
train_state['early_stopping_step'] += 1
# Loss decreased
else:
# Save the best model
if loss_t < train_state['early_stopping_best_val']:
torch.save(model.state_dict(), train_state['model_filename'])
# Reset early stopping step
train_state['early_stopping_step'] = 0
# Stop early ?
train_state['stop_early'] = \
train_state['early_stopping_step'] >= args.early_stopping_criteria
return train_state
#计算准确度
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
'''general utilities'''
#在所有设备上设置种子
def set_seed_everywhere(seed, cuda):
np.random.seed(seed) # 设置随机种子
torch.manual_seed(seed) # 设置随机种子
if cuda:
torch.cuda.manual_seed_all(seed) # 设置所有 CUDA 设备的随机种子
#设置目录
def handle_dirs(dirpath):
if not os.path.exists(dirpath):
os.makedirs(dirpath) # 创建目录
'''Settings and some prep work'''
args = Namespace(
# Data and path information
surname_csv="surnames_with_splits.csv",
vectorizer_file="vectorizer.json",
model_state_file="model.pth",
save_dir="model_storage/ch4/surname_mlp",
# Model hyper parameters
hidden_dim=300,
# Training hyper parameters
seed=1337,
num_epochs=100,
early_stopping_criteria=5,
learning_rate=0.001,
batch_size=64,
# Runtime options
cuda=False,
reload_from_files=False,
expand_filepaths_to_save_dir=True,
)
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("Expanded filepaths: ")
print("\t{}".format(args.vectorizer_file))
print("\t{}".format(args.model_state_file))
# Check CUDA
if not torch.cuda.is_available():
args.cuda = False
args.device = torch.device("cuda" if args.cuda else "cpu")
print("Using CUDA: {}".format(args.cuda))
# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda)
# handle dirs
handle_dirs(args.save_dir)
前期配置好参数后,再根据args.reload_from_files
的值,决定是从之前的检查点文件中重新加载训练,还是创建一个新的训练过程。然后,根据加载或创建的数据集和向量化器,初始化一个用于姓氏分类的神经网络模型。
if args.reload_from_files:
# 从检查点重新加载训练
print("Reloading!") # 输出提示信息
dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv, # 加载数据集和向量化器
args.vectorizer_file)
else:
# 创建数据集和向量化器
print("Creating fresh!") # 输出提示信息
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))
(2) 训练循环
与上个例子的训练循环相比,本例的训练循环除了变量名以外几乎是相同的。具体来说,代码使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度,最后使用梯度来更新模型。
代码实现了训练循环,用于训练神经网络模型以进行姓氏分类任务,具体功能如下:
- 加载之前保存的模型状态字典,使得模型能够从之前训练的状态继续训练。
- 将模型和数据集的类别权重移动到指定的设备(如CPU或GPU)上进行训练。
- 定义交叉熵损失函数,用于衡量模型预测与真实标签之间的差异,同时考虑了类别权重。
- 初始化优化器(Adam优化器)、学习率调度器(ReduceLROnPlateau调度器)和训练状态(用于保存训练过程中的各种信息)。
- 使用tqdm_notebook创建进度条,用于显示训练和验证的进度。
- 循环遍历每个epoch,对训练集和验证集进行迭代训练和评估:
- 在每个epoch内,对训练集进行迭代,计算并更新模型的参数以最小化损失;对验证集进行迭代,计算并记录模型在验证集上的损失和准确率。
- 在每个epoch结束时,根据验证集上的损失更新学习率调度器,并根据训练状态决定是否提前停止训练。
# 加载模型的状态字典
classifier.load_state_dict(torch.load(train_state['model_filename']))
# 将分类器移动到设备(如CPU或GPU)
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)
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='training routine',
total=args.num_epochs,
position=0)
dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train',
total=dataset.get_num_batches(args.batch_size),
position=1,
leave=True)
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val',
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
# Iterate over training dataset
# setup: batch generator, set loss and acc to 0, set train mode on
dataset.set_split('train')
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device)
running_loss = 0.0
running_acc = 0.0
classifier.train()
for batch_index, batch_dict in enumerate(batch_generator):
# 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_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# step 4. use loss to produce gradients
loss.backward()
# step 5. use optimizer to take gradient step
optimizer.step()
# -----------------------------------------
# compute the accuracy
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
# update bar
train_bar.set_postfix(loss=running_loss, acc=running_acc,
epoch=epoch_index)
train_bar.update()
train_state['train_loss'].append(running_loss)
train_state['train_acc'].append(running_acc)
# Iterate over val dataset
# setup: batch generator, set loss and acc to 0; set eval mode on
dataset.set_split('val')
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device)
running_loss = 0.
running_acc = 0.
classifier.eval()
for batch_index, batch_dict in enumerate(batch_generator):
# 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_t = loss.to("cpu").item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# compute the accuracy
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)
train_state['val_acc'].append(running_acc)
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("Exiting loop")
2.3.6 模型评估与预测
要理解模型在训练集上训练出的的性能,应该使用定量和定性方法分析模型。从定量角度,可以通过评价测试集在模型上的性能指标,如损失值与准确率等,来判断模型的泛化能力。从定性角度,可以通过查看分类器的top-k预测,来为一个测试案例检测模型性能,并进行结果预测。
(1) 测试集损失与准确率
代码的功能是在测试集上评估经过训练的分类器模型的性能。它加载了之前保存的模型状态字典,将模型移动到指定的设备上,将数据集的类别权重也移动到相同的设备上。然后,它使用加载的模型对测试集进行推断,计算模型在测试集上的损失和准确率。最后,它将测试集上的损失和准确率保存到训练状态中,以便后续分析和报告模型的性能。
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.
running_acc = 0.
classifier.eval()
for batch_index, batch_dict in enumerate(batch_generator):
# compute the output
y_pred = classifier(batch_dict['x_surname'])
# compute the loss
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# compute the accuracy
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("Test loss: {};".format(train_state['test_loss']))
print("Test Accuracy: {}".format(train_state['test_acc']))
(2) 为新姓氏分类
给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。我们使用了apply_softmax函数,所以结果包含概率。在多项式的情况下,模型预测是类概率的列表。我们使用PyTorch张量最大函数来得到由最高预测概率表示的最优类。
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).view(1, -1)
# 使用分类器进行预测,并对输出应用softmax函数
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}
#输入姓氏
new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
当输入姓氏为 "MaMchan"时,模型预测其可能是爱尔兰姓氏,预测的概率为 0.41。
(3) 检索新姓氏的前K个预测
第(2)小节展示了概率值最高的预测,但是不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测。
# 查找索引为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)
# 使用分类器进行预测,应用softmax函数
prediction_vector = classifier(vectorized_name, apply_softmax=True)
# 获取前k个最高概率值和对应的索引
probability_values, indices = torch.topk(prediction_vector, k=k)
# 转换为NumPy数组并提取概率值和索引
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
# 输入要分类的新姓氏
new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")
# 获取要显示的前k个预测
k = int(input("How many of the top predictions to see? "))
if k > len(vectorizer.nationality_vocab):
print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
k = len(vectorizer.nationality_vocab)
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)
print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
当输入“Alice”时,可以得到模型对输入姓氏 "Alice" 的预测结果。模型给出了对应于不同民族的概率分布,并列出了概率最高的前6个预测结果。按照概率从高到低的顺序,模型预测 "Alice" 大概率是意大利姓氏(概率为 0.75),其次是西班牙(概率为 0.12),接着是英国(概率为 0.02),荷兰(概率为 0.02),捷克(概率为 0.02),最后是法国(概率为 0.01)。
2.4 带Dropout的MLP实现
在训练过程中,Dropout有一定概率使属于两个相邻层的单元之间的连接减弱。通过在隐藏层之间应用Dropout,随机地将一部分神经元的输出置零,以减少模型对训练数据的过拟合,提高泛化能力。Dropout层的引入可以看作是对模型的集成学习,使得每个神经元都必须学习独立于其他神经元的特征,从而减少了模型的复杂度。
总的来说,带有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): 输入向量的大小
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)
#MLP的前向传播
def forward(self, x_in, apply_softmax=False):
"""
Args:
x_in (torch.Tensor): 输入数据张量。
x_in.shape 应为 (batch, input_dim)
apply_softmax (bool): softmax 激活的标志
如果与交叉熵损失一起使用,应设置为 False
Returns:结果张量。tensor.shape 应为 (batch, output_dim)
"""
#ReLU激活的第一个全连接层
intermediate = F.relu(self.fc1(x_in))
#第二个全连接层,带有0.5的dropout
output = self.fc2(F.dropout(intermediate, p=0.5))
if apply_softmax:
#如果需要应用 softmax
output = F.softmax(output, dim=1)
return output
3. 卷积神经网络
3.1 概述
卷积神经网络(Convolutional Neural Networks,CNN)是多层感知机(MLP)的变种,由生物学家休博尔和维瑟尔在早期关于猫视觉皮层的研究发展而来,视觉皮层的细胞存在一个复杂的构造,这些细胞对视觉输入空间的子区域非常敏感,称之为感受野。
卷积神经网络,由纽约大学的 Yann Lecun 于 1998 年提出,其本质是一个多层感知机,成功的原因在于其所采用的局部连接和权值共享的方式:
- 减少了权值的数量使得网络易于优化
- 降低了模型的复杂度,也就是减小了过拟合的风险
卷积神经网络是一种非常适合检测空间子结构(并因此创建有意义的空间子结构)的神经网络。CNNs通过使用少量的权重来扫描输入数据张量来实现这一点。通过这种扫描,它们产生表示子结构检测(或不检测)的输出张量。
它是一种带有卷积结构的深度神经网络,卷积结构可以减少深层网络占用的内存量,其三个关键的操作,其一是局部感受野,其二是权值共享,其三是 pooling 层,有效的减少了网络的参数个数,缓解了模型的过拟合问题。
3.2 基本结构
卷积神经网络是一种多层的监督学习神经网络,隐含层的卷积层和池采样层是实现卷积神经网络特征提取功能的核心模块。该网络模型通过采用梯度下降法最小化损失函数对网络中的权重参数逐层反向调节,通过频繁的迭代训练提高网络的精度。
卷积神经网络的低隐层是由卷积层和最大池采样层交替组成,高层是全连接层对应传统多层感知器的隐含层和逻辑回归分类器。
第一个全连接层的输入是由卷积层和子采样层进行特征提取得到的特征图像。
最后一层输出层是一个分类器,可以采用逻辑回归,Softmax 回归甚至是支持向量机对输入图像进行分类。
图3-1 CNN基本结构
CNN的五种结构组成:
- 输入层(Input Layer)
在处理图像的CNN中,输入层一般代表了一张图片的像素矩阵。可以用三维矩阵代表一张图片。三维矩阵的长和宽代表了图像的大小,而三维矩阵的深度代表了图像的色彩通道。比如黑白图片的深度为1,而在RGB色彩模式下,图像的深度为3。
- 卷积层(Convolution Layer)
卷积层是CNN最重要的部分。它与传统全连接层不同,卷积层中每一个节点的输入只是上一层神经网络的一小块。卷积层被称为过滤器(filter)或者内核(kernel)。在一个卷积层中,过滤器(filter)所处理的节点矩阵的长和宽都是由人工指定的,这个节点矩阵的尺寸也被称为过滤器尺寸。常用的尺寸有3x3或5x5,而过滤层处理的矩阵深度和当前处理的神经层网络节点矩阵的深度一致。
- 池化层(Pooling Layer)
池化层不会改变三维矩阵的深度,但是它可以缩小矩阵的大小。通过池化层,可以进一步缩小最后全连接层中节点的个数,从而达到减少整个神经网络参数的目的。使用池化层既可以加快计算速度也可以防止过拟合。池化层filter的计算不是节点的加权和,而是采用最大值或者平均值计算。使用最大值操作的池化层被称之为最大池化层(max pooling)(最大池化层是使用的最多的磁化层结构)。使用平均值操作的池化层被称之为平均池化层(mean pooling)。
- 全连接层(Fully connected layer)
在经过多轮卷积层和池化层的处理之后,在CNN的最后一般会由1到2个全连接层来给出最后的分类结果。经过几轮卷积层和池化层的处理之后,可以认为图像中的信息已经被抽象成了信息含量更高的特征。我们可以将卷积层和池化层看成自动图像特征提取的过程。在提取完成之后,仍然需要使用全连接层来完成分类任务。
- Softmax层
通过Softmax层,可以得到当前样例属于不同种类的概率分布问题。
3.3 超参数
CNN是通过指定控制CNN行为的超参数来设计的,然后使用梯度下降来为给定数据集找到最佳参数。主要的超参数如下:
- 维度
在PyTorch中,一维、二维或三维卷积分别由Conv1d、Conv2d和Conv3d模块实现。一维卷积对于每个时间步都有一个特征向量的时间序列非常有用。在这种情况下,我们可以在序列维度上学习模式。NLP中的卷积运算大多是一维的卷积。
- 通道数
通道是指沿输入中的每个点的特征维度。例如,在图像中,对应于RGB组件的图像中的每个像素有三个通道。在使用卷积时,文本数据也可以采用类似的概念。从概念上讲,如果文本文档中的“像素”是单词,那么通道的数量就是词汇表的大小。如果我们更细粒度地考虑字符的卷积,通道的数量就是字符集的大小(在本例中刚好是词汇表)。在PyTorch卷积实现中,输入通道的数量是in_channels参数。卷积操作可以在输出(out_channels)中产生多个通道。
- 卷积核大小
卷积将输入中的空间(或时间)本地信息组合在一起,每个卷积的本地信息量由内核大小控制。通过增加核的大小,会减少输出的大小。可以将NLP应用程序中核大小的行为看作类似于通过查看单词组捕获语言模式的n-gram的行为。使用较小的核大小,可以捕获较小的频繁模式,而较大的核大小会导致较大的模式。
图3-2 卷积核大小为3时的卷积示例
- 步长
Stride控制卷积之间的步长。如果步长与核相同,则内核计算不会重叠。另一方面,如果跨度为1,则内核重叠最大。输出张量可以通过增加步幅的方式被有意的压缩来总结信息。
图3-3 步长为2时的卷积示例
- 填充数
输入数据张量被人为地增加了长度(如果是一维、二维或三维)、高度(如果是二维或三维)和深度(如果是三维),方法是在每个维度上附加和前置0。这意味着CNN将执行更多的卷积,但是输出形状可以控制,而不会影响所需的核大小、步幅或扩展。
- 膨胀卷积
当卷积层被叠加时,扩张卷积被证明是非常有用的。连续扩张的卷积指数级地增大了“接受域”的大小;即网络在做出预测之前所看到的输入空间的大小。下图显示,将膨胀从1(默认值)增加到2意味着当应用于输入矩阵时,核的元素彼此之间是两个空格。
图3-4 dilation=2的输入矩阵和kernel_size=2的卷积示例
3.4 示例:使用CNN的姓氏分类
为了证明CNN的有效性,我们应用一个简单的CNN模型来分类姓氏。这项任务的许多细节与前面的MLP示例相同,但真正发生变化的是模型的构造和向量化过程。区别在于模型的输入是一个onehot的矩阵,这种设计将使CNN能够更好地查看字符的排列。
3.3.1 数据集引入
我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。
使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因:首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
# 初始化数据集对象
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_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_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):
# 仅加载已保存的矢量化器对象
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp))
def save_vectorizer(self, vectorizer_filepath):
# 保存矢量化器对象
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):
row = self._target_df.iloc[index]
surname_matrix = \
self._vectorizer.vectorize(row.surname)
nationality_index = \
self._vectorizer.nationality_vocab.lookup_token(row.nationality)
return {'x_surname': surname_matrix,
'y_nationality': nationality_index}
def get_num_batches(self, batch_size):
return len(self) // batch_size
def generate_batches(dataset, batch_size, shuffle=True,
drop_last=True, device="cpu"):
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.3.2 数据矢量化
Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要,函数将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。由于我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性,则矩阵中的每一列都是不同的onehot向量。
除了更改为使用onehot矩阵之外,还需修改矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length。
THE VOCABULARY CLASS
与2.3.3小节“示例:带有多层感知器的姓氏分类”中相同。
THE SURNAMEVECTORIZER
class SurnameVectorizer(object):
def __init__(self, surname_vocab, nationality_vocab, max_surname_length):
# 初始化姓氏词汇表、国籍词汇表和最大姓氏长度
self.surname_vocab = surname_vocab
self.nationality_vocab = nationality_vocab
self._max_surname_length = max_surname_length
def vectorize(self, surname):
# 向量化姓氏
one_hot_matrix_size = (len(self.surname_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.surname_vocab.lookup_token(character)
one_hot_matrix[character_index][position_index] = 1
return one_hot_matrix
@classmethod
def from_dataframe(cls, surname_df):
# 从数据框实例化向量化器
surname_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:
# 将姓氏中的每个字符添加到姓氏词汇表中
surname_vocab.add_token(letter)
# 将国籍添加到国籍词汇表中
nationality_vocab.add_token(row.nationality)
return cls(surname_vocab, nationality_vocab, max_surname_length)
@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,
max_surname_length=contents['max_surname_length'])
def to_serializable(self):
return {'surname_vocab': self.surname_vocab.to_serializable(),
'nationality_vocab': self.nationality_vocab.to_serializable(),
'max_surname_length': self._max_surname_length}
3.3.3 定义CNN模型
以下定义了一个名为SurnameClassifier的PyTorch模型类。该模型是一个卷积神经网络,用于对姓氏进行分类。在初始化函数中,定义了卷积神经网络的结构,包括四个卷积层,每个卷积层之间使用ELU(指数线性单元)作为非线性激活函数。接着是一个全连接层,将卷积层的输出映射到类别数目的空间上。在前向传播函数中,输入的姓氏数据经过卷积神经网络处理后,通过全连接层得到最终的预测向量。
定义模型时使用到了sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。
在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算,这样需要优化更多的超参数。
class SurnameClassifier(nn.Module):
def __init__(self, initial_num_channels, num_classes, num_channels):
# 初始化函数
super(SurnameClassifier, self).__init__()
# 定义卷积神经网络结构
self.convnet = nn.Sequential(
nn.Conv1d(in_channels=initial_num_channels,
out_channels=num_channels, kernel_size=3),
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, 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):
# 前向传播函数
features = self.convnet(x_surname).squeeze(dim=2) # 卷积网络并压缩维度
prediction_vector = self.fc(features) # 全连接层
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1) # 应用softmax函数获取概率
return prediction_vector
3.3.4 模型训练
训练程序包括以下操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。与“示例:带有多层感知器的姓氏分类”中的训练过程完全相同,仅有输入参数不同。
以下步骤与2.3.5小节中内容完全相同。
配置参数
在训练之前,需要初始化、配置和准备训练所需的各种参数和实用函数。具体功能如下:
- make_train_state函数:
函数创建了一个包含训练状态的字典,其中包括了一些训练过程中需要跟踪的信息,比如停止早期训练、学习率、当前训练轮数、训练和验证的损失和准确率等等。这个函数的参数args是一个命名空间,包含了各种训练相关的参数。
-
update_train_state函数:
用于更新训练状态。
-
compute_accuracy函数:
计算模型的准确率。给定模型的预测值y_pred和实际目标值y_target,它首先找到每个预测中最大值的索引,然后将预测值与目标值进行比较,计算出正确预测的数量,最后将正确预测的数量除以总样本数,得到准确率。
-
set_seed_everywhere函数:
在所有设备上设置种子,以确保结果的可重复性。它接受一个种子参数和一个布尔值参数cuda(表示是否使用CUDA),然后设置相应的随机种子。
-
handle_dirs函数:
检查给定的目录路径是否存在,如果不存在,则创建该目录。在这里,它用于确保保存模型的目录存在。
#创建训练状态字典
def make_train_state(args):
"""
参数:
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}
#处理训练状态更新
def update_train_state(args, model, train_state):
"""Handle the training state updates.
:param args: main arguments # 主要参数
:param model: model to train # 待训练的模型
:param train_state: a dictionary representing the training state values # 表示训练状态值的字典
:returns:
a new train_state # 更新后的训练状态
"""
# 至少保存一个模型
if train_state['epoch_index'] == 0:
torch.save(model.state_dict(), train_state['model_filename'])
train_state['stop_early'] = False
# Save model if performance improved
elif train_state['epoch_index'] >= 1: # 如果不是第一轮次
loss_tm1, loss_t = train_state['val_loss'][-2:] # 获取最近两次验证集损失值
# If loss worsened
if loss_t >= train_state['early_stopping_best_val']: # 如果损失值变差
# Update step
train_state['early_stopping_step'] += 1 # 更新提前停止步数
# Loss decreased
else:
# Save the best model
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):
# 获取预测标签的索引
y_pred_indices = y_pred.max(dim=1)[1]
# 计算预测正确的数量
n_correct = torch.eq(y_pred_indices, y_target).sum().item()
# 计算准确率百分比
return n_correct / len(y_pred_indices) * 100
args = Namespace(
# Data and Path information
surname_csv="surnames_with_splits.csv", # 姓氏数据的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, # dropout概率
# Runtime options
cuda=False, # 是否使用CUDA加速
reload_from_files=False, # 是否从文件重新加载模型
expand_filepaths_to_save_dir=True, # 是否扩展文件路径以保存到目录中
catch_keyboard_interrupt=True # 是否捕获键盘中断信号
)
# If expand_filepaths_to_save_dir is True, expand save_dir.
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("Expanded filepaths: ")
print("\t{}".format(args.vectorizer_file))
print("\t{}".format(args.model_state_file))
# Check if CUDA is available and set device accordingly.
if not torch.cuda.is_available():
args.cuda = False
args.device = torch.device("cuda" if args.cuda else "cpu") # 设置设备为CUDA或CPU
print("Using CUDA: {}".format(args.cuda))
def set_seed_everywhere(seed, cuda):
np.random.seed(seed) # 设置随机种子
torch.manual_seed(seed) # 设置随机种子
if cuda:
torch.cuda.manual_seed_all(seed) # 设置所有CUDA设备的随机种子
def handle_dirs(dirpath):
if not os.path.exists(dirpath): # 如果目录不存在,则创建
os.makedirs(dirpath)
# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda) # 设置随机种子
# handle dirs
handle_dirs(args.save_dir) # 处理保存目录
前期配置好参数后,再根据args.reload_from_files
的值,决定是从之前的检查点文件中重新加载训练,还是创建一个新的训练过程。然后,根据加载或创建的数据集和向量化器,初始化一个用于姓氏分类的神经网络模型。
if args.reload_from_files:
# Training from a checkpoint
dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
args.vectorizer_file) # 加载数据集和向量化器
else:
# Create dataset and vectorizer
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv) # 创建数据集和向量化器
dataset.save_vectorizer(args.vectorizer_file) # 保存向量化器
vectorizer = dataset.get_vectorizer() # 获取向量化器
classifier = SurnameClassifier(initial_num_channels=len(vectorizer.surname_vocab),
num_classes=len(vectorizer.nationality_vocab),
num_channels=args.num_channels) # 初始化分类器
classifer = classifier.to(args.device) # 将分类器移动到指定设备
dataset.class_weights = dataset.class_weights.to(args.device) # 将类别权重移动到指定设备
loss_func = nn.CrossEntropyLoss(weight=dataset.class_weights) # 定义损失函数
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate) # 定义优化器
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
mode='min', factor=0.5,
patience=1) # 定义学习率调度器
train_state = make_train_state(args) # 创建训练状态
3.3.5 模型预测
(1) 预测姓氏的国籍和概率值
在本例中,predict_nationality()
函数的一部分发生了更改,没有继续使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()
函数在批处理应该在的位置添加大小为1的维度。
def predict_nationality(surname, classifier, vectorizer):
"""
预测姓氏的国籍和概率值。
参数:
surname (str): 姓氏字符串。
classifier (torch.nn.Module): 训练好的分类器模型。
vectorizer (SurnameVectorizer): SurnameVectorizer对象,用于向量化输入数据。
返回:
dict: 包含预测的国籍和对应概率值的字典。
格式为 {'nationality': predicted_nationality, 'probability': probability_value}。
"""
# 向量化输入的姓氏
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}
new_surname = input("Enter a surname to classify: ") # 提示用户输入一个姓氏
classifier = classifier.cpu() # 将分类器移动到 CPU 上(如果尚未在 CPU 上)
prediction = predict_nationality(new_surname, classifier, vectorizer) # 使用预训练的模型预测姓氏的国籍和概率值
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability'])) # 打印预测结果,包括输入姓氏、预测的国籍以及概率值
当输入姓氏为 "Yau"时,模型预测其可能对应于中国国籍,预测的概率为 0.85。
(2) 预测姓氏的前k个可能国籍及概率值
def predict_topk_nationality(surname, classifier, vectorizer, k=5):
"""
预测姓氏的前k个可能国籍及其概率值。
参数:
surname (str): 姓氏字符串。
classifier (torch.nn.Module): 训练好的分类器模型。
vectorizer (SurnameVectorizer): SurnameVectorizer对象,用于向量化输入数据。
k (int): 要返回的前k个预测结果数量,默认为5。
返回:
list: 包含前k个预测结果的列表,每个结果为包含国籍和概率值的字典。
"""
# 向量化输入的姓氏
vectorized_surname = vectorizer.vectorize(surname)
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0) # 添加批次维度
# 使用分类器进行预测,并获取前k个概率值和对应的索引
prediction_vector = classifier(vectorized_surname, apply_softmax=True)
probability_values, indices = torch.topk(prediction_vector, k=k)
# 将张量转换为 NumPy 数组
probability_values = probability_values[0].detach().numpy()
indices = indices[0].detach().numpy()
# 构建前k个预测结果的列表
results = []
for kth_index in range(k):
nationality = vectorizer.nationality_vocab.lookup_index(indices[kth_index])
probability_value = probability_values[kth_index]
results.append({'nationality': nationality,
'probability': probability_value})
return results
new_surname = input("Enter a surname to classify: ") # 提示用户输入一个姓氏
k = int(input("How many of the top predictions to see? ")) # 提示用户输入要查看的前k个预测结果数量
if k > len(vectorizer.nationality_vocab):
print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
k = len(vectorizer.nationality_vocab)
# 调用函数进行预测
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)
# 打印前k个预测结果
print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
当输入“Alice”时,可以得到模型对输入姓氏 "Alice" 的预测结果。模型给出了对应于不同国籍的概率分布,并列出了概率最高的前6个预测结果。按照概率从高到低的顺序,模型预测 "Alice" 大概率是意大利姓氏(概率为 0.59),其次是西班牙(概率为 0.22),接着是英国(概率为 0.07),荷兰(概率为 0.05),捷克(概率为 0.05),最后是法国(概率为 0.01)。
对比看出,两种模型预测的结果在概率值排序上完全相同,仅在具体的概率值中有部分差别。
4. 总结
通过以上两个示例,我们深入了解了多层感知器和卷积神经网络在多类别分类中的应用。MLP通过多个层级的神经元组成,适用于学习非线性关系,而CNN则通过卷积和池化操作有效地提取特征。在MLP中,全连接层将输入展平为一维张量,而在CNN中,卷积层和池化层保持输入张量的维度,并通过卷积核的移动提取局部特征。这两种网络结构各有优势,可以根据问题的复杂度和数据的特征进行选择。
综上所述,我们通过这两个示例深入了解了两种网络对于姓氏分类问题的基本处理,为我们进一步探索深度学习模型与NLP提供了重要的基础。