文章目录
- 一、多层感知机
- 二、卷积神经网络
- 三、基于多层感知机的姓氏分类
- 四、基于CNN处理姓氏分类
一、多层感知机
多层感知器(MLP)被认为是最基本的神经网络构建模块之一。感知器将数据向量作为输入,计算出一个输出值。在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。在PyTorch中,只需设置线性层中的输出特性的数量即可完成。MLP的另一个方面是,它将多个层与每个层之间的非线性结合在一起。
最简单的MLP,如下图所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。在多类设置中,将在本篇后面的“基于有多层感知器的姓氏分类”一节中看到,输出向量是类数量的大小。虽然在这个例子中,我们只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。
mlp的力量来自于添加第二个线性层和允许模型学习一个线性分割的的中间表示——该属性的能表示一个直线(或更一般的,一个超平面)可以用来区分数据点落在线(或超平面)的哪一边的。学习具有特定属性的中间表示,如分类任务是线性可分的,这是使用神经网络的最深刻后果之一,也是其建模能力的精髓。具体有关于多层感知机的介绍,可以上网翻阅其他更为详细的博主介绍,因篇幅问题,我们不再过多赘述。
二、卷积神经网络
卷积神经网络(CNN),这是一种非常适合检测空间子结构(并因此创建有意义的空间子结构)的神经网络。CNNs通过使用少量的权重来扫描输入数据张量来实现这一点。通过这种扫描,它们产生表示子结构检测(或不检测)的输出张量。CNNs的名称和基本功能源于经典的数学运算卷积。卷积已经应用于各种工程学科,包括数字信号处理和计算机图形学。一般来说,卷积使用程序员指定的参数。这些参数被指定来匹配一些功能设计,如突出边缘或抑制高频声音。事实上,许多Photoshop滤镜都是应用于图像的固定卷积运算。然而,在本片文章中,我们从数据中学习卷积滤波器的参数,因此它对于解决当前的任务是最优的。以下放置一张通用的关于卷积的图例,其他关于CNN卷积神经网络的介绍,可以上网翻阅其他更为详细的博主介绍,因篇幅问题,我们不再过多赘述。
三、基于多层感知机的姓氏分类
在这一部分中,我将MLP应用于将姓氏分类到其原籍国的任务。从公开观察到的数据推断人口统计信息(如国籍)具有从产品推荐到确保不同人口统计用户获得公平结果的应用。人口统计和其他自我识别信息统称为“受保护属性”。“在建模和产品中使用这些属性时,必须小心。”我们首先对每个姓氏的字符进行拆分。除了数据上的差异,字符层模型在结构和实现上与基于单词的模型基本相似。我们使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的管道。
3.1The Surname Dataset
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本博客的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
为了创建最终的数据集,我执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为此,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。
下列是该部分的代码,相应有注释做出解释:
# 定义姓氏数据集类,继承自PyTorch的Dataset类
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
初始化函数
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_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):
"""返回向量化器"""
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数据集的主要入口方法
Args:
index (int): 数据点的索引
Returns:
包含数据点特征和标签的字典
"""
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):
"""给定批量大小,返回数据集中的批次数量
Args:
batch_size (int)
Returns:
数据集中的批次数量
"""
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) # 创建DataLoader
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.2Vocabulary, Vectorizer, and DataLoader
为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。这些数据结构举例说明了一种多态性。数据不是通过将字令牌映射到整数来向量化的,而是通过将字符映射到整数来向量化的。
3.2.1Vocabulary
该词汇类将Yelp评论中的单词映射到对应的整数。简要概述一下,词汇表是两个Python字典的协调,这两个字典在令牌(在本例中是字符)和整数之间形成一个双射;也就是说,第一个字典将字符映射到整数索引,第二个字典将整数索引映射到字符。add_token方法用于向词汇表中添加新的令牌,lookup_token方法用于检索索引,lookup_index方法用于检索给定索引的令牌(在推断阶段很有用)。与Yelp评论的词汇表不同,我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。
class Vocabulary(object):
"""处理文本并提取词汇映射的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
初始化函数
Args:
token_to_idx (dict): 预先存在的从token到索引的映射
add_unk (bool): 标志是否添加UNK token
unk_token (str): 要添加到词汇表中的UNK token
"""
if token_to_idx is None:
token_to_idx = {} # 如果没有提供映射,则创建一个空字典
self._token_to_idx = token_to_idx # 保存token到索引的映射
# 创建索引到token的反向映射
self._idx_to_token = {idx: token for token, idx in self._token_to_idx.items()}
self._add_unk = add_unk # 是否添加UNK token的标志
self._unk_token = unk_token # UNK token的值
self.unk_index = -1 # 初始化UNK索引
if add_unk:
self.unk_index = self.add_token(unk_token) # 如果需要添加UNK token,则添加
def to_serializable(self):
"""返回可以序列化的字典"""
return {'token_to_idx': self._token_to_idx,
'add_unk': self._add_unk,
'unk_token': self._unk_token}
@classmethod
def from_serializable(cls, contents):
"""从序列化字典实例化词汇表"""
return cls(**contents)
def add_token(self, token):
"""基于token更新映射字典
Args:
token (str): 要添加到词汇表中的项
Returns:
index (int): 对应于该token的整数索引
"""
try:
index = self._token_to_idx[token] # 如果token已存在,返回其索引
except KeyError:
index = len(self._token_to_idx) # 如果token不存在,添加到映射中
self._token_to_idx[token] = index
self._idx_to_token[index] = token
return index
def add_many(self, tokens):
"""将多个token添加到词汇表中
Args:
tokens (list): 字符串token的列表
Returns:
indices (list): 对应于这些token的索引列表
"""
return [self.add_token(token) for token in tokens] # 遍历添加每个token
def lookup_token(self, token):
"""检索与token关联的索引,如果token不存在则返回UNK索引
Args:
token (str): 要查找的token
Returns:
index (int): 对应于该token的索引
Notes:
`unk_index` 需要 >= 0(已添加到词汇表中)才能使用UNK功能
"""
if self.unk_index >= 0:
return self._token_to_idx.get(token, self.unk_index) # 如果token不存在,返回UNK索引
else:
return self._token_to_idx[token]
def lookup_index(self, index):
"""返回与索引关联的token
Args:
index (int): 要查找的索引
Returns:
token (str): 对应于该索引的token
Raises:
KeyError: 如果索引不在词汇表中
"""
if index not in self._idx_to_token:
raise KeyError("索引 (%d) 不在词汇表中" % index) # 如果索引不存在,抛出异常
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.2.2Vectorizer
虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。实例化和使用有一个关键:字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
class SurnameVectorizer(object):
""" 负责协调词汇表并将其投入使用的向量化器类 """
def __init__(self, surname_vocab, nationality_vocab):
"""
初始化函数
Args:
surname_vocab (Vocabulary): 将字符映射到整数的词汇表
nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
"""
self.surname_vocab = surname_vocab # 姓氏词汇表
self.nationality_vocab = nationality_vocab # 国籍词汇表
def vectorize(self, surname):
"""
将姓氏向量化为一维的one-hot编码
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 # 将对应token的位置设为1
return one_hot
@classmethod
def from_dataframe(cls, surname_df):
"""从数据集数据框实例化向量化器
Args:
surname_df (pandas.DataFrame): 姓氏数据集
Returns:
SurnameVectorizer的一个实例
"""
surname_vocab = Vocabulary(unk_token="@") # 创建姓氏词汇表,使用'@'作为UNK token
nationality_vocab = Vocabulary(add_unk=False) # 创建国籍词汇表,不添加UNK token
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):
"""从可序列化字典实例化向量化器
Args:
contents (dict): 序列化字典
Returns:
SurnameVectorizer的一个实例
"""
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):
"""将向量化器转换为可序列化的字典
Returns:
序列化字典
"""
return {'surname_vocab': self.surname_vocab.to_serializable(), # 将姓氏词汇表转换为可序列化字典
'nationality_vocab': self.nationality_vocab.to_serializable()} # 将国籍词汇表转换为可序列化字典
3.3The Surname Classifier Model
SurnameClassifier是本次介绍的MLP的实现,第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。
在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
class SurnameClassifier(nn.Module):
"""用于姓氏分类的两层多层感知器 (Multilayer Perceptron)"""
def __init__(self, input_dim, hidden_dim, output_dim):
"""
初始化函数
Args:
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):
"""分类器的前向传播
Args:
x_in (torch.Tensor): 输入数据张量。x_in的形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用softmax激活函数的标志
如果与交叉熵损失一起使用,应为False
Returns:
结果张量。张量的形状应为 (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.4The Training Routine
3.4.1Helper functions:
以下是一些有用的函数模块:
def make_train_state(args):
"""初始化训练状态
Args:
args: 包含训练参数的命令行参数对象
Returns:
一个字典,表示训练状态
"""
return {'stop_early': False, # 是否提前停止训练的标志
'early_stopping_step': 0, # 提前停止的步数
'early_stopping_best_val': 1e8, # 最好的验证损失值
'learning_rate': args.learning_rate, # 学习率
'epoch_index': 0, # 当前的epoch索引
'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:
args: 主参数
model: 要训练的模型
train_state: 表示训练状态值的字典
Returns:
一个新的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 # 返回更新后的训练状态
def compute_accuracy(y_pred, y_target):
"""计算准确率
Args:
y_pred (torch.Tensor): 模型预测输出
y_target (torch.Tensor): 实际标签
Returns:
准确率 (float): 预测正确的百分比
"""
_, 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 # 返回正确率百分比
3.4.2the args for classifying surnames with an MLP
args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/surnames_with_splits.csv", # 姓氏数据集的CSV文件路径
vectorizer_file="vectorizer.json", # 向量化器文件路径
model_state_file="model.pth", # 模型状态文件路径
save_dir="model_storage/ch4/surname_mlp", # 保存目录
# 模型超参数
hidden_dim=300, # 隐藏层维度
# 训练超参数
seed=1337, # 随机种子
num_epochs=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, # 是否扩展文件路径到保存目录
)
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)) # 打印扩展后的模型状态文件路径
# 检查CUDA
if not torch.cuda.is_available():
args.cuda = False # 如果CUDA不可用,设置cuda为False
args.device = torch.device("cuda" if args.cuda else "cpu") # 设置设备为CUDA或CPU
print("Using CUDA: {}".format(args.cuda)) # 打印是否使用CUDA
# 设置随机种子以确保可重复性
set_seed_everywhere(args.seed, args.cuda)
# 处理保存目录
handle_dirs(args.save_dir)
3.4.3THE TRAINING LOOP
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))
classifier = classifier.to(args.device) # 将分类器移动到指定设备(CPU或GPU)
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='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 # 设置当前epoch索引
# 遍历训练数据集
# 设置:批量生成器,将损失和准确度置零,开启训练模式
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):
# 训练过程的五个步骤:
# --------------------------------------
# 步骤1. 清零梯度
optimizer.zero_grad()
# 步骤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) # 更新运行中的损失
# 步骤4. 使用损失计算梯度
loss.backward()
# 步骤5. 使用优化器进行梯度更新
optimizer.step()
# -----------------------------------------
# 计算准确度
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) # 记录训练损失
train_state['train_acc'].append(running_acc) # 记录训练准确度
# 遍历验证数据集
# 设置:批量生成器,将损失和准确度置零,开启评估模式
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):
# 计算输出
y_pred = classifier(batch_dict['x_surname'])
# 步骤3. 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.to("cpu").item()
running_loss += (loss_t - running_loss) / (batch_index + 1) # 更新运行中的损失
# 计算准确度
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1) # 更新运行中的准确度
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() # 更新epoch进度条
except KeyboardInterrupt:
print("Exiting loop") # 捕获键盘中断异常并退出循环
3.5Model Evaluation and Prediction
要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。
3.5.1EVALUATING ON THE TEST DATASET
评价SurnameClassifier测试数据,我们执行将数据集设置为遍历测试数据,调用classifier.eval()
方法,并遍历测试数据以同样的方式与其他数据。在这个例子中,调用classifier.eval()
可以防止PyTorch在使用测试/评估数据时更新模型参数。
# 使用最佳模型在测试集上计算损失和准确率
classifier.load_state_dict(torch.load(train_state['model_filename'])) # 加载最佳模型的参数
classifier = classifier.to(args.device) # 将分类器移动到指定设备(CPU或GPU)
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):
# 计算输出
y_pred = classifier(batch_dict['x_surname'])
# 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1) # 更新运行中的损失
# 计算准确度
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1) # 更新运行中的准确度
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']))
我们可以得出以下结果:
该模型对测试数据的准确性达到50%左右。如果在附带的notebook中运行训练例程,会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据,所以训练数据的性能并不代表新数据的性能。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。其主要原因是收缩的onehot向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量,但它丢弃了字符之间的顺序信息,这对于识别起源非常重要。
3.5.2CLASSIFYING A NEW SURNAME
该部分显示了分类新姓氏的代码。给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。注意,我们包含了apply_softmax标志,所以结果包含概率。模型预测,在多项式的情况下,是类概率的列表。我们使用PyTorch张量最大函数来得到由最高预测概率表示的最优类。
def predict_nationality(surname, classifier, vectorizer):
"""预测一个新姓氏的国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifer): 分类器的实例
vectorizer (SurnameVectorizer): 对应的向量化器
Returns:
一个字典,包含最可能的国籍及其概率
"""
vectorized_surname = vectorizer.vectorize(surname) # 向量化姓氏
vectorized_surname = torch.tensor(vectorized_surname).view(1, -1) # 转换为张量,并添加批次维度
result = classifier(vectorized_surname, apply_softmax=True) # 使用分类器进行预测
probability_values, indices = result.max(dim=1) # 获取最大概率值和对应的索引
index = indices.item() # 获取索引值
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index) # 根据索引获取预测的国籍
probability_value = probability_values.item() # 获取预测的概率值
return {'nationality': predicted_nationality, 'probability': probability_value} # 返回预测结果的字典
# 获取用户输入的姓氏
new_surname = input("Enter a surname to classify: ")
# 将分类器移动到CPU上
classifier = classifier.to("cpu")
# 使用函数预测输入姓氏的国籍
prediction = predict_nationality(new_surname, classifier, vectorizer)
# 打印预测结果,显示姓氏、预测的国籍及其概率
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
我们可以得出以下结果:
3.5.3RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME
不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测,如下所示:
def predict_topk_nationality(name, classifier, vectorizer, k=5):
"""预测输入姓名的前k个国籍及其概率
Args:
name (str): 要分类的姓名
classifier (SurnameClassifer): 分类器的实例
vectorizer (SurnameVectorizer): 对应的向量化器
k (int): 要返回的前k个预测结果,默认为5
Returns:
一个包含前k个预测结果的列表,每个结果是一个字典包含国籍和概率
"""
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个最大概率值和对应的索引
# 转换为NumPy数组,并取出第一维(由于batch大小为1,因此只有一维)
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 # 返回前k个预测结果的列表
new_surname = input("Enter a surname to classify: ") # 获取用户输入的姓氏
classifier = classifier.to("cpu") # 将分类器移动到CPU上
k = int(input("How many of the top predictions to see? ")) # 获取用户要查看的前k个预测结果数
if k > len(vectorizer.nationality_vocab): # 如果k大于国籍词汇表的长度,则将k设置为国籍词汇表的长度
print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
k = len(vectorizer.nationality_vocab)
# 使用函数预测输入姓氏的前k个国籍及其概率
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']))
我们可以得出如下结果:
四、基于CNN处理姓氏分类
为了证明CNN的有效性,让我们应用一个简单的CNN模型来分类姓氏。这项任务的许多细节与前面的MLP示例相同,但真正发生变化的是模型的构造和向量化过程。模型的输入,而不是我们在上一个例子中看到的收缩的onehot,将是一个onehot的矩阵。这种设计将使CNN能够更好地“view”字符的排列,并对在“示例:带有多层感知器的姓氏分类”中使用的收缩的onehot编码中丢失的序列信息进行编码。
4.1The SurnameDataset
虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述,但建议参考“姓氏数据集”来了解它的描述。尽管我们使用了来自“带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。以下代码显示了对SurnameDataset.__getitem__
的更改;我们显示对SurnameVectorizer的更改。
我们使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因。首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
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_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)
# 省略了 load_dataset_and_make_vectorizer 和 load_dataset_and_load_vectorizer 方法
def __len__(self):
return self._target_size # 返回当前拆分数据集的大小
def __getitem__(self, index):
"""主要的数据访问方法,用于 PyTorch 数据集
Args:
index (int): 数据点的索引
Returns:
包含数据点特征 (x_data) 和标签 (y_target) 的字典
"""
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):
"""给定批次大小,返回数据集中的批次数
Args:
batch_size (int)
Returns:
数据集中的批次数
"""
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
4.2Vocabulary, Vectorizer, and DataLoader
在本例中,尽管词汇表和DataLoader的实现方式与“带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。具体来说,正如我们在示例4-18中的代码中所示,该函数将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。重要的是,矩阵中的每一列都是不同的onehot向量。主要原因是,我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。除了更改为使用onehot矩阵之外,我们还修改了矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length
4.2.1Vectorizer
class SurnameVectorizer(object):
"""处理词汇表并将其用于向量化的向量化器类"""
def __init__(self, surname_vocab, nationality_vocab, max_surname_length):
"""
初始化向量化器
Args:
surname_vocab (Vocabulary): 将字符映射到整数的词汇表
nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
max_surname_length (int): 最长姓氏的长度
"""
self.surname_vocab = surname_vocab # 保存姓氏词汇表
self.nationality_vocab = nationality_vocab # 保存国籍词汇表
self._max_surname_length = max_surname_length # 保存最大姓氏长度
def vectorize(self, surname):
"""
将姓氏向量化为one-hot编码矩阵
Args:
surname (str): 姓氏
Returns:
one_hot_matrix (np.ndarray): 一个one-hot编码矩阵
"""
# 初始化one-hot编码矩阵
one_hot_matrix_size = (len(self.surname_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.surname_vocab.lookup_token(character)
one_hot_matrix[character_index][position_index] = 1
return one_hot_matrix
@classmethod
def from_dataframe(cls, surname_df):
"""从数据框实例化向量化器
Args:
surname_df (pandas.DataFrame): 姓氏数据集
Returns:
SurnameVectorizer的一个实例
"""
# 初始化姓氏和国籍词汇表
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):
"""从序列化内容实例化向量化器
Args:
contents (dict): 序列化内容
Returns:
SurnameVectorizer的一个实例
"""
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):
"""序列化向量化器
Returns:
contents (dict): 包含向量化器信息的字典
"""
return {'surname_vocab': self.surname_vocab.to_serializable(), # 姓氏词汇表序列化
'nationality_vocab': self.nationality_vocab.to_serializable(), # 国籍词汇表序列化
'max_surname_length': self._max_surname_length} # 最大姓氏长度
4.2.2Vocabulary
class Vocabulary(object):
"""处理文本并提取用于映射的词汇表的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
初始化函数
Args:
token_to_idx (dict): 令牌到索引的预先存在的映射
add_unk (bool): 一个指示是否添加UNK令牌的标志
unk_token (str): 要添加到词汇表中的UNK令牌
"""
if token_to_idx is None: # 如果没有提供token_to_idx,则初始化为空字典
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令牌的索引为-1
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):
"""基于令牌更新映射字典
Args:
token (str): 要添加到词汇表中的项
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):
"""将多个令牌添加到词汇表中
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_index`需要>=0(已添加到词汇表中)才能使用UNK功能
"""
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):
"""返回与索引关联的令牌
Args:
index (int): 要查找的索引
Returns:
token (str): 对应于索引的令牌
Raises:
KeyError: 如果索引不在词汇表中
"""
if index not in self._idx_to_token:
raise KeyError("索引 (%d) 不在词汇表中" % index) # 如果索引不存在,则抛出错误
return self._idx_to_token[index] # 返回索引对应的令牌
def __str__(self):
return "<Vocabulary(size=%d)>" % len(self) # 返回词汇表的大小
def __len__(self):
return len(self._token_to_idx) # 返回词汇表的长度
4.3The Surname Classifier Model
实际上,我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。下面代码所看到的,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。
然而,本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性。
在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现256足够大,可以使模型达到合理的性能。
class SurnameClassifier(nn.Module):
def __init__(self, initial_num_channels, num_classes, num_channels):
"""
初始化函数
Args:
initial_num_channels (int): 输入特征向量的大小
num_classes (int): 输出预测向量的大小
num_channels (int): 在整个网络中使用的常量通道大小
"""
super(SurnameClassifier, self).__init__()
# 定义卷积神经网络模型
self.convnet = nn.Sequential(
nn.Conv1d(in_channels=initial_num_channels,
out_channels=num_channels, kernel_size=3), # 第一层卷积层
nn.ELU(), # 使用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):
"""模型的前向传播
Args:
x_surname (torch.Tensor): 输入数据张量。x_surname.shape应为(batch, initial_num_channels, max_surname_length)
apply_softmax (bool): 一个标志,表示是否应用softmax激活函数
如果与交叉熵损失一起使用,应为False
Returns:
结果张量。tensor.shape应为(batch, num_classes)
"""
# 使用卷积神经网络处理输入数据
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 # 返回预测向量
4.4The Training Routine
训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。
4.4.1the args for classifying surnames with a CNN
args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/surnames_with_splits.csv", # 姓氏数据集的CSV文件路径
vectorizer_file="vectorizer.json", # 向量化器的保存路径
model_state_file="model.pth", # 模型状态的保存路径
save_dir="model_storage/ch4/cnn", # 保存模型的根目录
# 模型超参数
hidden_dim=100, # 隐藏层维度
num_channels=256, # 卷积层通道数
# 训练超参数
seed=1337, # 随机种子
learning_rate=0.001, # 学习率
batch_size=128, # 批量大小
num_epochs=100, # 训练迭代次数
early_stopping_criteria=5, # 提前停止的标准
dropout_p=0.1, # Dropout概率
# 运行时选项
cuda=False, # 是否使用CUDA加速
reload_from_files=False, # 是否从文件重新加载模型和向量化器
expand_filepaths_to_save_dir=True, # 是否将文件路径扩展到保存目录中
catch_keyboard_interrupt=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("扩展后的文件路径: ")
print("\t{}".format(args.vectorizer_file))
print("\t{}".format(args.model_state_file))
# 检查CUDA是否可用
if not torch.cuda.is_available():
args.cuda = False
args.device = torch.device("cuda" if args.cuda else "cpu") # 设置设备类型
print("使用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)
def handle_dirs(dirpath):
if not os.path.exists(dirpath):
os.makedirs(dirpath)
# 设置种子以确保可重复性
set_seed_everywhere(args.seed, args.cuda)
# 处理目录
handle_dirs(args.save_dir) # 创建保存模型的目录
4.4.2Helper functions:
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, # 当前迭代次数初始化为0
'train_loss': [], # 训练集损失列表
'train_acc': [], # 训练集准确率列表
'val_loss': [], # 验证集损失列表
'val_acc': [], # 验证集准确率列表
'test_loss': -1, # 测试集损失初始化为-1
'test_acc': -1, # 测试集准确率初始化为-1
'model_filename': args.model_state_file} # 模型文件名
def update_train_state(args, model, train_state):
"""处理训练状态更新。
组件:
- 提前停止:防止过拟合。
- 模型检查点:如果模型更好,则保存模型
Args:
args: 主要参数
model: 要训练的模型
train_state: 表示训练状态值的字典
Returns:
一个新的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
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 # 计算准确率并转换为百分比
4.4.3THE TRAINING LOOP
if args.reload_from_files:
# 从检查点继续训练
dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
args.vectorizer_file)
else:
# 创建数据集和向量化器
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) # 初始化分类器
classifier = 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) # 创建训练状态字典
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): # 遍历每个 epoch
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
running_acc = 0.0
classifier.train() # 设置模型为训练模式
for batch_index, batch_dict in enumerate(batch_generator):
optimizer.zero_grad() # 清零梯度
y_pred = classifier(batch_dict['x_surname']) # 计算模型输出
loss = loss_func(y_pred, batch_dict['y_nationality']) # 计算损失
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
loss.backward() # 反向传播
optimizer.step() # 更新模型参数
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality']) # 计算准确率
running_acc += (acc_t - running_acc) / (batch_index + 1)
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)
# 遍历验证集
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):
y_pred = classifier(batch_dict['x_surname']) # 计算模型输出
loss = loss_func(y_pred, batch_dict['y_nationality']) # 计算损失
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality']) # 计算准确率
running_acc += (acc_t - running_acc) / (batch_index + 1)
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") # 打印信息
4.5Model Evaluation and Prediction
4.5.1EVALUATING ON THE TEST DATASET
正如“基于多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样,执行评估的代码也没有变化。总之,调用分类器的eval()
方法来防止反向传播,并迭代测试数据集。与 MLP 约 50% 的性能相比,该模型的测试集性能准确率约为60%。尽管这些性能数字绝不是这些特定架构的上限,但是通过一个相对简单的CNN模型获得的改进应该足以让您在文本数据上尝试CNNs。
# 加载最佳模型的状态字典
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)
# 设置数据集为测试集
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):
# 计算模型输出
y_pred = classifier(batch_dict['x_surname'])
# 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# 计算准确率
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
# 更新 train_state 字典中的测试损失和测试准确率
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']))
我们可以得出以下结果:
4.5.2Classifying or retrieving top predictions for a new surname
在本例中,predict_nationality()
函数的一部分发生了更改,如下面代码所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()
函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在predict_topk_nationality()
函数中。
def predict_nationality(surname, classifier, vectorizer):
"""从一个新的姓氏预测国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifier): 分类器的一个实例
vectorizer (SurnameVectorizer): 对应的向量化器
Returns:
一个包含最可能的国籍及其概率的字典
"""
vectorized_surname = vectorizer.vectorize(surname) # 将姓氏向量化
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0) # 转换为张量并增加一个维度
result = classifier(vectorized_surname, apply_softmax=True) # 用分类器进行预测,并应用softmax函数
probability_values, indices = result.max(dim=1) # 获取最大概率值及其索引
index = indices.item() # 获取索引的整数值
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index) # 根据索引查找国籍
probability_value = probability_values.item() # 获取概率值
return {'nationality': predicted_nationality, 'probability': probability_value} # 返回包含国籍和概率的字典
new_surname = input("Enter a surname to classify: ")
classifier = classifier.cpu()
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
有如下结果:
4.5.3RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME
def predict_topk_nationality(surname, classifier, vectorizer, k=5):
"""从一个新的姓氏预测前 K 个国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifier): 分类器的一个实例
vectorizer (SurnameVectorizer): 对应的向量化器
k (int): 返回的前 K 个国籍数量
Returns:
字典列表,每个字典包含一个国籍及其概率
"""
vectorized_surname = vectorizer.vectorize(surname) # 将姓氏向量化
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0) # 转换为张量并增加一个维度
prediction_vector = classifier(vectorized_surname, apply_softmax=True) # 用分类器进行预测,并应用softmax函数
probability_values, indices = torch.topk(prediction_vector, k=k) # 获取前 K 个最大概率值及其索引
# 返回的尺寸为 1,k
probability_values = probability_values[0].detach().numpy() # 将概率值转换为 NumPy 数组
indices = indices[0].detach().numpy() # 将索引转换为 NumPy 数组
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) # 如果输入的 K 大于国籍总数,则默认最大值
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'])) # 打印预测结果
有如下结果: