NLP - 基于MLP和CNN实现姓氏分类
一、基于多层感知机(MLP)实现姓氏分类
1.1 什么是多层感知机
多层感知机(Multilayer Perceptron,简称MLP),是一种基于前馈神经网络(Feedforward Neural Network)的深度学习模型,由多个神经元层组成,其中每个神经元层与前一层全连接。多层感知机可以用于解决分类、回归和聚类等各种机器学习问题。
多层感知机的每个神经元层由许多神经元组成,其中输入层接收输入特征,输出层给出最终的预测结果,中间的隐藏层用于提取特征和进行非线性变换。每个神经元接收前一层的输出,进行加权和和激活函数运算,得到当前层的输出。通过不断迭代训练,多层感知机可以自动学习到输入特征之间的复杂关系,并对新的数据进行预测。
1.2 激活函数
激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。激活函数的作用是在神经网络中引入非线性性质,使其能够学习复杂的非线性关系。 大多数激活函数都是非线性的,下面我们来介绍几种常见的激活函数。
1.2.1 ReLU函数
最受欢迎的激活函数是修正线性单元(Rectified linear unit,ReLU), 因为它实现简单,同时在各种预测任务中表现良好。 ReLU提供了一种非常简单的非线性变换。 给定元素(x),ReLU函数被定义为该元素与(0)的最大值:
ReLU
(
x
)
=
max
(
x
,
0
)
.
\operatorname{ReLU}(x) = \max(x, 0).
ReLU(x)=max(x,0).
通俗地说,ReLU函数通过将相应的活性值设为0,仅保留正元素并丢弃所有负元素。 正如从图中所看到,激活函数是分段线性的。
使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题。
1.2.2 sigmoid函数
对于一个定义域在 R 中的输入, sigmoid函数将输入变换为区间(0, 1)上的输出。 因此,sigmoid通常称为挤压函数(squashing function): 它将范围(-inf, inf)中的任意输入压缩到区间(0, 1)中的某个值:
sigmoid
(
x
)
=
1
1
+
exp
(
−
x
)
.
\operatorname{sigmoid}(x) = \frac{1}{1 + \exp(-x)}.
sigmoid(x)=1+exp(−x)1.
在梯度学习时,sigmoid函数是一个自然的选择,因为它是一个平滑的、可微的阈值单元近似。 当我们想要将输出视作二元分类问题的概率时, sigmoid仍然被广泛用作输出单元上的激活函数 。
1.2.3 tanh函数
与sigmoid函数类似, tanh(双曲正切)函数也能将其输入压缩转换到区间(-1, 1)上。 tanh函数的公式如下:
tanh
(
x
)
=
1
−
exp
(
−
2
x
)
1
+
exp
(
−
2
x
)
.
\operatorname{tanh}(x) = \frac{1 - \exp(-2x)}{1 + \exp(-2x)}.
tanh(x)=1+exp(−2x)1−exp(−2x).
当输入在0附近时,tanh函数接近线性变换。 函数的形状类似于sigmoid函数, 不同的是tanh函数关于坐标系原点中心对称。
1.3 前向传播
前向传播是神经网络中信息从输入层流向输出层的过程,它涉及从网络的输入计算到输出的过程。假设我们有一个MLP网络,它包括一个输入层、若干个隐藏层和一个输出层。以下是前向传播的具体步骤:
-
输入层:
-
输入层接收输入特征向量 ( x = ( x 1 , x 2 , . . . , x n ) ) ,其中 ( x i ) 是第 ( i ) 个特征。 输入层接收输入特征向量 ( \mathbf{x} = (x_1, x_2, ..., x_n) ),其中 ( x_i ) 是第 ( i ) 个特征。 输入层接收输入特征向量(x=(x1,x2,...,xn)),其中(xi)是第(i)个特征。
-
每个输入特征 ( x i ) 对应输入层的一个神经元。输入层的神经元将这些输入特征作为它们的激活值。 每个输入特征 ( x_i ) 对应输入层的一个神经元。输入层的神经元将这些输入特征作为它们的激活值。 每个输入特征(xi)对应输入层的一个神经元。输入层的神经元将这些输入特征作为它们的激活值。
-
-
隐藏层:
-
输入层的输出通过加权和和激活函数传递到第一个隐藏层。 输入层的输出通过加权和和激活函数传递到第一个隐藏层。 输入层的输出通过加权和和激活函数传递到第一个隐藏层。
-
对于每个隐藏层 ( l ) ,每个神经元 ( j ) 计算加权输入: [ z j ( l ) = ∑ i = 1 m ( l − 1 ) w i j ( l ) a i ( l − 1 ) + b j ( l ) ] 其中 ( w i j ( l ) ) 是从上一层 ( ( l − 1 ) ) 到当前层 ( l ) 的权重, ( a i ( l − 1 ) ) 是上一层的激活值, ( b j ( l ) ) 是当前层 ( l ) 的偏置项。 对于每个隐藏层 ( l ),每个神经元 ( j ) 计算加权输入: [ z_j^{(l)} = \sum_{i=1}^{m^{(l-1)}} w_{ij}^{(l)} a_i^{(l-1)} + b_j^{(l)} ] 其中 ( w_{ij}^{(l)} ) 是从上一层 ( (l-1) ) 到当前层 ( l ) 的权重,( a_i^{(l-1)} ) 是上一层的激活值,( b_j^{(l)} ) 是当前层 ( l ) 的偏置项。 对于每个隐藏层(l),每个神经元(j)计算加权输入:[zj(l)=i=1∑m(l−1)wij(l)ai(l−1)+bj(l)]其中(wij(l))是从上一层((l−1))到当前层(l)的权重,(ai(l−1))是上一层的激活值,(bj(l))是当前层(l)的偏置项。
-
然后,神经元 ( j ) 的输出 ( a j ( l ) ) 通过激活函数 ( f ( l ) ) 计算: [ a j ( l ) = f ( l ) ( z j ( l ) ) ] 然后,神经元 ( j ) 的输出 ( a_j^{(l)} ) 通过激活函数 ( f^{(l)} ) 计算: [ a_j^{(l)} = f^{(l)}(z_j^{(l)}) ] 然后,神经元(j)的输出(aj(l))通过激活函数(f(l))计算:[aj(l)=f(l)(zj(l))]
-
这些输出 ( a j ( l ) ) 成为下一层的输入。 这些输出 ( a_j^{(l)} ) 成为下一层的输入。 这些输出(aj(l))成为下一层的输入。
-
-
输出层:
-
最后一个隐藏层的输出通过加权和和激活函数传递到输出层。 最后一个隐藏层的输出通过加权和和激活函数传递到输出层。 最后一个隐藏层的输出通过加权和和激活函数传递到输出层。
-
对于输出层,如果是回归任务通常直接输出;如果是分类任务,则可能使用不同的激活函数(如 s o f t m a x )将输出映射到相应的类别。 对于输出层,如果是回归任务通常直接输出;如果是分类任务,则可能使用不同的激活函数(如softmax)将输出映射到相应的类别。 对于输出层,如果是回归任务通常直接输出;如果是分类任务,则可能使用不同的激活函数(如softmax)将输出映射到相应的类别。
-
-
输出预测:
- 网络的最终输出是通过输出层的激活函数得到的预测值,这个预测值可以与真实标签进行比较来计算损失。 网络的最终输出是通过输出层的激活函数得到的预测值,这个预测值可以与真实标签进行比较来计算损失。 网络的最终输出是通过输出层的激活函数得到的预测值,这个预测值可以与真实标签进行比较来计算损失。
1.4 反向传播
反向传播是通过计算梯度来更新神经网络中权重和偏置的过程,目的是使损失函数最小化。具体来说,反向传播根据损失函数的梯度沿着网络反向传播,更新每一层的参数。以下是反向传播的具体步骤:
-
计算损失函数梯度:
-
首先,通过计算损失函数对网络输出的梯度,得到输出层的误差信号 ( δ ( L ) ) 。 首先,通过计算损失函数对网络输出的梯度,得到输出层的误差信号 ( \delta^{(L)} )。 首先,通过计算损失函数对网络输出的梯度,得到输出层的误差信号(δ(L))。
-
对于平方损失函数 ( L ) ,有: [ δ ( L ) = ∂ L ∂ z ( L ) ] 其中 ( z ( L ) ) 是输出层的加权输入。 对于平方损失函数 ( \mathcal{L} ),有: [ \delta^{(L)} = \frac{\partial \mathcal{L}}{\partial z^{(L)}} ] 其中 ( z^{(L)} ) 是输出层的加权输入。 对于平方损失函数(L),有:[δ(L)=∂z(L)∂L]其中(z(L))是输出层的加权输入。
-
-
误差信号传播:
-
将误差信号 ( δ ( L ) ) 反向传播到前一层隐藏层: [ δ ( l ) = ( ( W ( l + 1 ) ) T δ ( l + 1 ) ) ⊙ f ′ ( l ) ( z ( l ) ) ] 将误差信号 ( \delta^{(L)} ) 反向传播到前一层隐藏层: [ \delta^{(l)} = \left( (W^{(l+1)})^T \delta^{(l+1)} \right) \odot f'^{(l)}(z^{(l)}) ] 将误差信号(δ(L))反向传播到前一层隐藏层:[δ(l)=((W(l+1))Tδ(l+1))⊙f′(l)(z(l))]
-
其中 ( W ( l + 1 ) ) 是从层 ( ( l ) ) 到层 ( ( l + 1 ) ) 的权重矩阵, ( f ′ ( l ) ) 是第 ( l ) 层激活函数的导数, ( ⊙ ) 表示元素对应相乘。 其中 ( W^{(l+1)} ) 是从层 ( (l) ) 到层 ( (l+1) ) 的权重矩阵, ( f'^{(l)} ) 是第 ( l ) 层激活函数的导数, ( \odot ) 表示元素对应相乘。 其中(W(l+1))是从层((l))到层((l+1))的权重矩阵,(f′(l))是第(l)层激活函数的导数,(⊙)表示元素对应相乘。
-
-
计算参数梯度:
-
使用误差信号 ( δ ( l ) ) 计算每一层的权重和偏置项的梯度: [ ∂ L ∂ w i j ( l ) = δ j ( l ) a i ( l − 1 ) ] [ ∂ L ∂ b j ( l ) = δ j ( l ) ] 使用误差信号 ( \delta^{(l)} ) 计算每一层的权重和偏置项的梯度: [ \frac{\partial \mathcal{L}}{\partial w_{ij}^{(l)}} = \delta_j^{(l)} a_i^{(l-1)} ] [ \frac{\partial \mathcal{L}}{\partial b_j^{(l)}} = \delta_j^{(l)} ] 使用误差信号(δ(l))计算每一层的权重和偏置项的梯度:[∂wij(l)∂L=δj(l)ai(l−1)][∂bj(l)∂L=δj(l)]
-
其中 ( δ j ( l ) ) 是第 ( l ) 层第 ( j ) 个神经元的误差信号。 其中 ( \delta_j^{(l)} ) 是第 ( l ) 层第 ( j ) 个神经元的误差信号。 其中(δj(l))是第(l)层第(j)个神经元的误差信号。
-
-
参数更新:
-
使用梯度下降或其变种(如 A d a m 优化器),更新每一层的权重和偏置项: [ w i j ( l ) ← w i j ( l ) − α ∂ L ∂ w i j ( l ) ] [ b j ( l ) ← b j ( l ) − α ∂ L ∂ b j ( l ) ] 使用梯度下降或其变种(如Adam优化器),更新每一层的权重和偏置项: [ w_{ij}^{(l)} \leftarrow w_{ij}^{(l)} - \alpha \frac{\partial \mathcal{L}}{\partial w_{ij}^{(l)}} ] [ b_j^{(l)} \leftarrow b_j^{(l)} - \alpha \frac{\partial \mathcal{L}}{\partial b_j^{(l)}} ] 使用梯度下降或其变种(如Adam优化器),更新每一层的权重和偏置项:[wij(l)←wij(l)−α∂wij(l)∂L][bj(l)←bj(l)−α∂bj(l)∂L]
-
其中 ( α ) 是学习率,控制参数更新的步长。 其中 ( \alpha ) 是学习率,控制参数更新的步长。 其中(α)是学习率,控制参数更新的步长。
-
1.5 多层感知机的训练学习过程
多层感知机的训练学习过程包括以下步骤:
- 准备数据集:将数据集划分为训练集和测试集,并进行必要的预处理(如归一化或标准化)。
- 构建模型:选择合适的网络结构,包括隐藏层的数量和神经元的数量,并确定激活函数和损失函数。
- 编译和训练模型:指定优化算法、学习率和迭代次数等参数,并使用训练集对模型进行训练。
- 模型评估和预测:使用测试集评估模型的性能,并对新数据进行预测。
1.6 基于多层感知机(MLP)实现姓氏分类(具体代码)
1.6.1 导入相关的库
from argparse import Namespace # 导入Namespace类,用于命令行参数解析
from collections import Counter # 导入Counter类,用于计数器功能
import json # 导入处理JSON格式数据的模块
import os # 导入操作系统功能的模块
import 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用于显示进度条的模块
1.6.2 定义数据向量化的类
为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。这些数据结构说明了一种多态性,这种多态性将姓氏的字符标记与Yelp评论的单词标记相同对待。数据不是通过将字令牌映射到整数来向量化的,而是通过将字符映射到整数来向量化的。
我们使用one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。
词汇表将单个字符转换为整数,SurnameVectorizer负责应用词汇表并将姓氏转换为向量。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
词汇表(The Vocabulary)
class Vocabulary(object):
"""用于处理文本并提取词汇以进行映射的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
初始化方法
参数:
token_to_idx (dict): 包含已存在的token到索引的映射字典
add_unk (bool): 是否添加未知token(UNK token)的标志
unk_token (str): 要添加到词汇表中的未知token(UNK token)
"""
if token_to_idx is None:
token_to_idx = {} # 初始化 token_to_idx 为一个空字典
self._token_to_idx = token_to_idx # 存储 token 到索引的映射关系
self._idx_to_token = {idx: token
for token, idx in self._token_to_idx.items()} # 创建索引到 token 的映射
self._add_unk = add_unk # 是否添加 UNK token 的标志
self._unk_token = unk_token # UNK token 的值
self.unk_index = -1 # UNK token 的索引,初始化为 -1
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
@classmethod
def from_serializable(cls, contents):
"""从序列化字典实例化Vocabulary。"""
return cls(**contents)
def add_token(self, token):
"""根据token更新映射字典。
参数:
token (str): 要添加到词汇表中的项
返回:
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 # 将 token 添加到词汇表
self._idx_to_token[index] = token # 更新索引到 token 的映射
return index
def add_many(self, tokens):
"""将一个 token 列表添加到词汇表中。
参数:
tokens (list): 一个字符串 token 的列表
返回:
indices (list): 对应于 tokens 的索引列表
"""
return [self.add_token(token) for token in tokens] # 添加多个 token,并返回它们的索引列表
def lookup_token(self, token):
"""检索与 token 相关联的索引,如果 token 不存在则返回 UNK 索引。
参数:
token (str): 要查找的 token
返回:
index (int): 对应于 token 的索引
注意:
UNK 功能需要 `unk_index` >=0(已添加到词汇表中)
"""
if self.unk_index >= 0:
return self._token_to_idx.get(token, self.unk_index) # 返回 token 的索引,如果不存在则返回 UNK 索引
else:
return self._token_to_idx[token] # 如果没有 UNK token,则直接返回 token 的索引
def lookup_index(self, index):
"""返回与索引相关联的token。
参数:
index (int): 要查找的索引
返回:
token (str): 对应于索引的token
异常:
KeyError: 如果索引不在词汇表中
"""
if index not in self._idx_to_token:
raise KeyError("the index (%d) is not in the Vocabulary" % index) # 如果索引不在词汇表中,抛出 KeyError
return self._idx_to_token[index] # 返回索引对应的 token
def __str__(self):
return "<Vocabulary(size=%d)>" % len(self) # 返回词汇表的大小
def __len__(self):
return len(self._token_to_idx) # 返回词汇表中 token 的数量
向量化器(The Vectorizer)
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): 折叠的 one-hot 编码
"""
vocab = self.surname_vocab # 获取姓氏词汇表
one_hot = np.zeros(len(vocab), dtype=np.float32) # 初始化一个零数组作为 one-hot 编码
for token in surname:
one_hot[vocab.lookup_token(token)] = 1 # 将对应的索引位置置为 1
return one_hot # 返回 one-hot 编码
@classmethod
def from_dataframe(cls, surname_df):
"""从数据框实例化向量化器
参数:
surname_df (pandas.DataFrame): 姓氏数据集
返回:
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)# 返回 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()}# 序列化国籍词汇表
数据集(The Dataset)
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 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)) # 返回 SurnameDataset 实例
@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) # 返回 SurnameDataset 实例
@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:
包含数据点以下内容的字典:
特征 (x_surname)
标签 (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):
"""给定批次大小,返回数据集中的批次数量
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 # 生成包含批次数据的字典
1.6.3 定义姓氏分类模型
第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。
在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
class SurnameClassifier(nn.Module):
"""用于姓氏分类的2层多层感知器"""
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) # 第一层线性层,输入维度为 input_dim,输出维度为 hidden_dim
self.fc2 = nn.Linear(hidden_dim, output_dim) # 第二层线性层,输入维度为 hidden_dim,输出维度为 output_dim
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:
结果张量。张量.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) # 如果需要,应用softmax激活函数
return prediction_vector # 返回预测向量
1.6.4 训练与模型评估
利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。
辅助函数定义*
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} # 模型文件名
def update_train_state(args, model, train_state):
"""处理训练状态的更新。
组件:
- 提前停止:防止过拟合。
- 模型检查点:如果模型更好,则保存模型。
:param args: 主要参数
:param model: 要训练的模型
:param 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
# 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 # 计算准确率并返回
def set_seed_everywhere(seed, cuda):
np.random.seed(seed) # 设置numpy的随机种子
torch.manual_seed(seed) # 设置torch的随机种子
if cuda:
torch.cuda.manual_seed_all(seed) # 如果使用cuda,设置cuda的随机种子
def handle_dirs(dirpath):
if not os.path.exists(dirpath): # 如果目录不存在
os.makedirs(dirpath) # 创建目录
准备工作与初始化
# 定义命名空间 Namespace,包含了各种参数和路径信息
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, # 训练的总epoch数
early_stopping_criteria=5, # 提前停止的标准
learning_rate=0.001, # 学习率
batch_size=64, # 批量大小
# 运行时选项
cuda=False, # 是否使用CUDA加速
reload_from_files=False, # 是否从文件中重新加载模型和向量化器
expand_filepaths_to_save_dir=True, # 是否将文件路径扩展到保存目录下
)
# 如果 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设置设备
args.device = torch.device("cuda" if args.cuda else "cpu")
# 打印是否使用CUDA
print("Using CUDA: {}".format(args.cuda))
# 设置随机种子,确保实验可重复性
set_seed_everywhere(args.seed, args.cuda)
# 处理保存目录,确保存在
handle_dirs(args.save_dir)
# 如果 args.reload_from_files 为 True,则从文件中重新加载数据集和向量化器
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()
# 创建姓氏分类器,输入维度为姓氏词汇表的长度,隐藏层维度为 args.hidden_dim,输出维度为国籍词汇表的长度
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
hidden_dim=args.hidden_dim,
output_dim=len(vectorizer.nationality_vocab))
迭代训练
# 将分类器移动到指定设备上(GPU或CPU)
classifier = classifier.to(args.device)
# 将数据集的类权重也移动到指定设备上
dataset.class_weights = dataset.class_weights.to(args.device)
# 定义损失函数为交叉熵损失,传入类权重
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
# 定义优化器为Adam,并传入分类器的参数和学习率
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)
# 初始化 tqdm 进度条,用于显示训练过程中的进度
epoch_bar = tqdm_notebook(desc='training routine',
total=args.num_epochs,
position=0)
# 设置数据集的训练集和验证集分割,并初始化对应的 tqdm 进度条
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
# 设置训练模式
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()
# 保存每个epoch的训练损失和准确率
train_state['train_loss'].append(running_loss)
train_state['train_acc'].append(running_acc)
# 设置验证模式
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.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()
# 保存每个epoch的验证损失和准确率
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")
# 加载训练过程中保存的最佳模型状态字典
classifier.load_state_dict(torch.load(train_state['model_filename']))
# 将模型移动到指定的设备(GPU或CPU)
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):
# 计算模型的输出
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']))
结果如下
Test loss: 1.819154896736145
Test Accuracy: 46.68749999999999
1.6.5 模型预测
要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。
def predict_nationality(surname, classifier, vectorizer):
"""Predict the nationality from a new surname
Args:
surname (str): 待分类的姓氏
classifier (SurnameClassifer): 已经训练好的分类器实例
vectorizer (SurnameVectorizer): 对应的向量化器
Returns:
dict: 包含最可能的国籍及其概率的字典
"""
# 使用向量化器将姓氏转换为向量表示,并转换为 PyTorch 张量
vectorized_surname = vectorizer.vectorize(surname)
vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)
# 使用分类器预测姓氏的国籍概率分布,apply_softmax=True 表示在预测结果上应用 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: ")
# 将分类器移动到 CPU 上进行推断
classifier = classifier.to("cpu")
# 使用定义好的函数预测输入姓氏的国籍及其概率
prediction = predict_nationality(new_surname, classifier, vectorizer)
# 打印预测结果:输入姓氏 -> 预测的国籍 (概率)
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
结果如下
Enter a surname to classify: Rahal
Rahal -> Irish (p=0.48)
top-k预测
不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测。
vectorizer.nationality_vocab.lookup_index(8)
def predict_topk_nationality(name, classifier, vectorizer, k=5):
# 使用向量化器将输入的姓名转换为向量表示,并转换为 PyTorch 张量
vectorized_name = vectorizer.vectorize(name)
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 使用分类器预测姓名的国籍概率分布,apply_softmax=True 表示在预测结果上应用 softmax 函数
prediction_vector = classifier(vectorized_name, apply_softmax=True)
# 获取概率最高的前 k 个国籍及其概率
probability_values, indices = torch.topk(prediction_vector, k=k)
# 将张量转换为 numpy 数组,并且因为结果是 1 维数组,所以取第一个元素
probability_values = probability_values.detach().numpy()[0]
indices = indices.detach().numpy()[0]
# 初始化结果列表
results = []
# 遍历前 k 个国籍的概率和对应的索引,将其添加到结果列表中
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: ")
# 将分类器移动到 CPU 上进行推断
classifier = classifier.to("cpu")
# 提示用户要看多少个前置预测
k = int(input("How many of the top predictions to see? "))
# 如果用户要求的前 k 个预测大于国籍词汇表的总数,则设置 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)
# 打印预测结果标题
print("Top {} predictions:".format(k))
print("===================")
# 遍历每个预测结果,并将其格式化输出
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
结果如下
Enter a surname to classify: Rahal
How many of the top predictions to see? 5
Top 5 predictions:
===================
Rahal -> Irish (p=0.48)
Rahal -> Arabic (p=0.13)
Rahal -> German (p=0.08)
Rahal -> English (p=0.06)
Rahal -> Dutch (p=0.06)
1.6.6 Dropout随机失活
正则化可以解决过拟合问题,其中有两种重要的权重正则化类型——L1和L2。这些权值正则化方法也适用于MLPs和卷积神经网络。除权值正则化外,对于深度模型,即例如本实验讨论的前馈网络,一种称为dropout的结构正则化方法变得非常重要。
简单地说,在训练过程中,dropout有一定概率使属于两个相邻层的单元之间的连接减弱。这有什么用呢?我们从斯蒂芬•梅里蒂(Stephen Merity)的一段直观且幽默的解释开始:“Dropout,简单地说,是指如果你能在喝醉的时候反复学习如何做一件事,那么你应该能够在清醒的时候做得更好。” 这一见解产生了许多最先进的结果和一个新兴的领域。
神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“Coadaptation”是神经科学中的一个术语,但在这里它只是指一种情况,即两个单元之间的联系变得过于紧密,而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接,我们可以确保没有一个单元总是依赖于另一个单元,从而产生健壮的模型。dropout不会向模型中添加额外的参数,但是需要一个超参数——“drop probability”。drop probability,它是单位之间的连接drop的概率。通常将下降概率设置为0.5。
下面是一串示例代码
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) # 第二个全连接层
def forward(self, x_in, apply_softmax=False):
"""MLP的前向传播
Args:
x_in (torch.Tensor): 输入数据张量.
x_in的形状应该是 (batch, input_dim)
apply_softmax (bool): 是否应用softmax激活
如果使用交叉熵损失函数,应设置为False
Returns:
结果张量. 张量的形状应该是 (batch, output_dim)
"""
intermediate = F.relu(self.fc1(x_in)) # 应用ReLU激活函数到第一个全连接层的输出
output = self.fc2(F.dropout(intermediate, p=0.5)) # 第二个全连接层的输出,并应用0.5的dropout
if apply_softmax:
output = F.softmax(output, dim=1) # 如果标志位为真,则应用softmax激活函数
return output # 返回输出张量
不过要注意的是,dropout只适用于训练期间,不适用于评估期间。
二、基于卷积神经网络(CNN)实现姓氏分类
2.1 什么是卷积神经网络
卷积神经网络(convolutional neural network, CNN),是一类包含卷积计算且具有深度结构的前馈神经网络。卷积神经网络是受生物学上感受野(Receptive Field)的机制而提出的。卷积神经网络专门用来处理具有类似网格结构的数据的神经网络。例如,时间序列数据(可以认为是在时间轴上有规律地采样形成的一维网格)和图像数据(可以看作是二维的像素网格)。
常见的CNN网络有LeNet-5、VGGNet、GoogleNet、ResNet、DenseNet、MobileNet等。
CNN主要应用场景: 图像分类、图像分割 、 目标检测 、 自然语言处理等领域。
2.2 卷积神经网络的基本结构
CNN主要由输入层、卷积层、池化层、全连接层及输出层构成。卷积层和池化层一般会取若干个,采用卷积层和池化层交替设置,即一个卷积层连接一个池化层,池化层后再连接一个卷积层,依此类推。由于卷积层中输出特征图的每个神经元与其输入进行局部连接,并通过对应的连接权值与局部输入进行加权求和再加上偏置值,得到该神经元输入值,该过程等同于卷积过程,CNN也由此而得名。
2.3 卷积层(convolutional layer)
卷积可以理解为使用一个过滤器(卷积核)来过滤数据的各个小区域,从而得到这些小区域的特征值。
卷积层是CNN的核心组件。它通过使用一组可学习的滤波器(或称为卷积核)对输入数据进行卷积操作。每个滤波器会在输入数据上滑动(通过卷积操作),计算出每个位置的输出。这样可以提取输入数据中的空间特征,如边缘、纹理等。
卷积操作可以看作是将一个滤波器(也称为卷积核)应用于输入数据的过程。卷积核是一个小的二维矩阵,它通过与输入数据的局部区域进行逐元素相乘,然后将结果求和,得到输出的一个值。在整个输入数据上,卷积核通过滑动窗口的方式移动,每次在输入数据上生成一个输出值。这样可以有效地提取输入数据中的不同特征,例如检测边缘或者颜色变化。
在进行卷积操作时,可以通过调整步长(stride)和填充(padding)来控制输出的空间大小:
步长决定卷积核在输入数据上滑动的距离。
填充在输入数据的边缘上添加额外的值,以便在卷积操作后保持输出的大小与输入相同或者具有所需的特定大小。
2.4 池化层(pooling layer)
池化层简单说就是下采样,他可以大大降低数据的维度。需要池化层的原因:池化层用于减少卷积层输出的空间维度,从而降低网络对位置的敏感度,同时保留重要的特征。常用的池化操作包括最大池化(Max Pooling)和平均池化(Average Pooling),它们分别在局部区域内选取最大值或平均值作为输出。
关于池化层,有一个局部线性变换的不变性(invariant)理论:如果输入数据的局部进行了线性变换操作(如平移或旋转等),那么经过池化操作后,输出的结果并不会发生变化。局部平移“不变性”特别有用,尤其是我们关心某个特征是否出现,而不关心它出现的位置时(例如,在模式识别场景中,当我们检测人脸时,我们只关心图像中是否具备人脸的特征,而并不关心人脸是在图像的左上角和右下角)。
为什么池化层可以降低过拟合的概率呢?因为池化函数使得模型更关注偏全局的特征(而非局部),所以可以尽量避免让模型专注于图像的一些特化细节(例如让模型更关注一张人脸,而不是他眼睛的大小)。
池化层相比卷积层可以更有效的降低数据维度,这么做不但可以大大减少运算量,还可以有效的避免过拟合。
2.5 全连接层(fully-connected layer)
全连接层位于网络的末尾,负责将卷积层和池化层提取的特征进行分类或回归。全连接层位于网络的末尾,负责将卷积层和池化层提取的特征进行分类或回归。每个神经元与上一层的所有神经元相连接,通过权重进行信息传递和处理。每个神经元与上一层的所有神经元相连接,通过权重进行信息传递和处理。
经过卷积层和池化层降维过的数据,全连接层才能”跑得动”,不然数据量太大,计算成本高,效率低下。“全连接”意味着,前层网络中的所有神经元都与下一层的所有神经元连接。
全连接层设计目的在于:它将前面各个层学习到的“分布式特征表示”映射到“样本标记空间”,然后利用损失函数来调控学习过程,最后给出对象的分类预测。
虽然池化层看似是整个网络结构中最不起眼的一步,但是由于其对所有的参数进行“连接”,其会造成大量的冗余参数,不良的设计会导致在全连接层极易出现过拟合的现象,对此,可以使用 Dropout 方法来缓解;同时其极高的参数量会导致性能的降低,对此,我们也可使用全局均值池化策略(Global Average Pooling,GAP)取代全连接层。
2.6 CNN 的三个核心特点
1.稀疏交互(sparse interactions)
在卷积神经网络中,卷积核尺度远小于输入的维度,这样每个输出神经元仅与前一层特定局部区域内的神经元存在连接权重(即产生交互),我们称这种特性为稀疏交互。
稀疏交互的物理意义:通常图像、文本、语音等现实世界中的数据都具有局部的特征结构, 我们可以先学习局部的特征, 再将局部的特征组合起来形成更复杂和抽象的特征。
2.参数共享(parameter sharing)
参数共享是指在同一个模型的不同模块中使用相同的参数。卷积运算中的参数共享让网络只需要学一个参数集合,而不是对于每一位置都需要学习一个单独的参数集合。
参数共享的物理意义:使得卷积层具有平移等变性。
3.等变表示(equivariant representations)
神经网络的输出对于平移变换来说应当是等变的。特别地,当函数 f(x)与g(x)满足f(g(x))=g(f(x))时,我们称f(x)关于变换g具有等变性。
2.7 基于卷积神经网络(CNN)实现姓氏分类(具体代码)
2.6.1 导入相关的库
from argparse import Namespace # 导入Namespace类,用于命令行参数解析
from collections import Counter # 导入Counter类,用于计数器功能
import json # 导入处理JSON格式数据的模块
import os # 导入操作系统功能的模块
import 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用于显示进度条的模块
2.6.2 定义数据向量化的类
为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。这些数据结构说明了一种多态性,这种多态性将姓氏的字符标记与Yelp评论的单词标记相同对待。数据不是通过将字令牌映射到整数来向量化的,而是通过将字符映射到整数来向量化的。
我们使用one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。
词汇表将单个字符转换为整数,SurnameVectorizer负责应用词汇表并将姓氏转换为向量。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
词汇表(The Vocabulary)
class Vocabulary(object):
"""用于处理文本并提取词汇以进行映射的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
初始化方法
参数:
token_to_idx (dict): 包含已存在的token到索引的映射字典
add_unk (bool): 是否添加未知token(UNK token)的标志
unk_token (str): 要添加到词汇表中的未知token(UNK token)
"""
if token_to_idx is None:
token_to_idx = {} # 初始化 token_to_idx 为一个空字典
self._token_to_idx = token_to_idx # 存储 token 到索引的映射关系
self._idx_to_token = {idx: token
for token, idx in self._token_to_idx.items()} # 创建索引到 token 的映射
self._add_unk = add_unk # 是否添加 UNK token 的标志
self._unk_token = unk_token # UNK token 的值
self.unk_index = -1 # UNK token 的索引,初始化为 -1
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
@classmethod
def from_serializable(cls, contents):
"""从序列化字典实例化Vocabulary。"""
return cls(**contents)
def add_token(self, token):
"""根据token更新映射字典。
参数:
token (str): 要添加到词汇表中的项
返回:
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 # 将 token 添加到词汇表
self._idx_to_token[index] = token # 更新索引到 token 的映射
return index
def add_many(self, tokens):
"""将一个 token 列表添加到词汇表中。
参数:
tokens (list): 一个字符串 token 的列表
返回:
indices (list): 对应于 tokens 的索引列表
"""
return [self.add_token(token) for token in tokens] # 添加多个 token,并返回它们的索引列表
def lookup_token(self, token):
"""检索与 token 相关联的索引,如果 token 不存在则返回 UNK 索引。
参数:
token (str): 要查找的 token
返回:
index (int): 对应于 token 的索引
注意:
UNK 功能需要 `unk_index` >=0(已添加到词汇表中)
"""
if self.unk_index >= 0:
return self._token_to_idx.get(token, self.unk_index) # 返回 token 的索引,如果不存在则返回 UNK 索引
else:
return self._token_to_idx[token] # 如果没有 UNK token,则直接返回 token 的索引
def lookup_index(self, index):
"""返回与索引相关联的token。
参数:
index (int): 要查找的索引
返回:
token (str): 对应于索引的token
异常:
KeyError: 如果索引不在词汇表中
"""
if index not in self._idx_to_token:
raise KeyError("the index (%d) is not in the Vocabulary" % index) # 如果索引不在词汇表中,抛出 KeyError
return self._idx_to_token[index] # 返回索引对应的 token
def __str__(self):
return "<Vocabulary(size=%d)>" % len(self) # 返回词汇表的大小
def __len__(self):
return len(self._token_to_idx) # 返回词汇表中 token 的数量
向量化器(The Vectorizer)
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): 折叠的 one-hot 编码
"""
vocab = self.surname_vocab # 获取姓氏词汇表
one_hot = np.zeros(len(vocab), dtype=np.float32) # 初始化一个零数组作为 one-hot 编码
for token in surname:
one_hot[vocab.lookup_token(token)] = 1 # 将对应的索引位置置为 1
return one_hot # 返回 one-hot 编码
@classmethod
def from_dataframe(cls, surname_df):
"""从数据框实例化向量化器
参数:
surname_df (pandas.DataFrame): 姓氏数据集
返回:
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)# 返回 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()}# 序列化国籍词汇表
数据集(The Dataset)
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 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)) # 返回 SurnameDataset 实例
@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) # 返回 SurnameDataset 实例
@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:
包含数据点以下内容的字典:
特征 (x_surname)
标签 (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):
"""给定批次大小,返回数据集中的批次数量
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 # 生成包含批次数据的字典
2.6.3 定义姓氏分类模型
我们在本例中使用的模型是使用我们在“卷积神经网络”中介绍的方法构建的。实际上,我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。正如在示例中所看到的,它与我们在“卷积神经网络”中引入的Conv1d序列既有相似之处,也有需要解释的新添加内容。具体来说,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。
然而,本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于实验3中介绍的ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性(Clevert et al., 2015)。
在本例中,我们将每个卷积的通道数与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), # 第二个卷积层,带有步长为2的卷积
nn.ELU(),
nn.Conv1d(in_channels=num_channels, out_channels=num_channels,
kernel_size=3, stride=2), # 第三个卷积层,带有步长为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)
return prediction_vector
2.6.4 训练与模型评估
利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。
辅助函数定义*
def make_train_state(args):
"""
创建训练过程中的状态字典
Args:
args (argparse.Namespace): 包含训练参数的命名空间
Returns:
dict: 包含训练状态的字典
"""
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, # 测试损失(初始为 -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
# 如果训练轮数大于等于1
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): 模型的预测输出张量,形状为 (batch_size, num_classes)
y_target (torch.Tensor): 真实标签张量,形状为 (batch_size,)
Returns:
float: 准确率,以百分比表示
"""
y_pred_indices = y_pred.max(dim=1)[1] # 获取预测类别的索引
n_correct = torch.eq(y_pred_indices, y_target).sum().item() # 计算正确预测的数量
accuracy = n_correct / len(y_pred_indices) * 100 # 计算准确率
return accuracy
准备工作与初始化
# 定义命名空间 Namespace,包含了各种参数和路径信息
args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/surnames_with_splits.csv", # 姓氏数据集的路径
vectorizer_file="vectorizer.json", # 向量化器保存文件的名称
model_state_file="model.pth", # 模型状态保存文件的名称
save_dir="model_storage/ch4/cnn", # 模型保存的目录路径
# 模型超参数
hidden_dim=100, # 隐藏层维度
num_channels=256, # 卷积通道数
# 训练超参数
seed=1337, # 随机种子
learning_rate=0.001, # 学习率
batch_size=128, # 批量大小
num_epochs=100, # 训练轮数
early_stopping_criteria=5, # 提前停止的条件(验证集损失连续不减次数)
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
# 设置设备为CUDA或CPU
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)
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)
迭代训练
# 将分类器移动到指定设备上(GPU或CPU)
classifier = classifier.to(args.device)
# 将数据集的类权重也移动到指定设备上
dataset.class_weights = dataset.class_weights.to(args.device)
# 定义损失函数为交叉熵损失,传入类权重
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
# 定义优化器为Adam,并传入分类器的参数和学习率
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)
# 初始化 tqdm 进度条,用于显示训练过程中的进度
epoch_bar = tqdm_notebook(desc='training routine',
total=args.num_epochs,
position=0)
# 设置数据集的训练集和验证集分割,并初始化对应的 tqdm 进度条
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
# 设置:批处理生成器,将损失和准确率设为0,设置为训练模式
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):
# 训练过程分为5个步骤:
# --------------------------------------
# 步骤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()
# 将当前epoch的损失和准确率记录到训练状态中
train_state['train_loss'].append(running_loss)
train_state['train_acc'].append(running_acc)
# Iterate over val dataset
# 设置:批处理生成器,将损失和准确率设为0,设置为评估模式
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()
# 将当前epoch的损失和准确率记录到训练状态中
train_state['val_loss'].append(running_loss)
train_state['val_acc'].append(running_acc)
# 更新训练状态,包括保存最佳模型和控制early stopping
train_state = update_train_state(args=args, model=classifier,
train_state=train_state)
# 根据验证集损失调整学习率
scheduler.step(train_state['val_loss'][-1])
# 如果满足early stopping条件,提前结束训练循环
if train_state['stop_early']:
break
# 重置进度条
train_bar.n = 0
val_bar.n = 0
epoch_bar.update()
except KeyboardInterrupt:
print("Exiting loop")
# 加载训练过程中保存的最佳模型状态字典
classifier.load_state_dict(torch.load(train_state['model_filename']))
# 将模型移动到指定的设备(GPU或CPU)
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):
# 计算模型的输出
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']))
结果如下
Test loss: 1.6242567462971512
Test Accuracy: 56.866275763725356
2.6.5 模型预测
要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。
def predict_nationality(surname, classifier, vectorizer):
"""Predict the nationality from a new surname
Args:
surname (str): 待分类的姓氏
classifier (SurnameClassifer): 已经训练好的分类器实例
vectorizer (SurnameVectorizer): 对应的向量化器
Returns:
dict: 包含最可能的国籍及其概率的字典
"""
# 使用向量化器将姓氏转换为向量表示,并转换为 PyTorch 张量
vectorized_surname = vectorizer.vectorize(surname)
vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)
# 使用分类器预测姓氏的国籍概率分布,apply_softmax=True 表示在预测结果上应用 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: ")
# 将分类器移动到 CPU 上进行推断
classifier = classifier.to("cpu")
# 使用定义好的函数预测输入姓氏的国籍及其概率
prediction = predict_nationality(new_surname, classifier, vectorizer)
# 打印预测结果:输入姓氏 -> 预测的国籍 (概率)
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
结果如下
Enter a surname to classify: Yuan
Yuan -> Chinese (p=0.62)
top-k预测
不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测。
vectorizer.nationality_vocab.lookup_index(8)
def predict_topk_nationality(name, classifier, vectorizer, k=5):
# 使用向量化器将输入的姓名转换为向量表示,并转换为 PyTorch 张量
vectorized_name = vectorizer.vectorize(name)
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 使用分类器预测姓名的国籍概率分布,apply_softmax=True 表示在预测结果上应用 softmax 函数
prediction_vector = classifier(vectorized_name, apply_softmax=True)
# 获取概率最高的前 k 个国籍及其概率
probability_values, indices = torch.topk(prediction_vector, k=k)
# 将张量转换为 numpy 数组,并且因为结果是 1 维数组,所以取第一个元素
probability_values = probability_values.detach().numpy()[0]
indices = indices.detach().numpy()[0]
# 初始化结果列表
results = []
# 遍历前 k 个国籍的概率和对应的索引,将其添加到结果列表中
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: ")
# 将分类器移动到 CPU 上进行推断
classifier = classifier.to("cpu")
# 提示用户要看多少个前置预测
k = int(input("How many of the top predictions to see? "))
# 如果用户要求的前 k 个预测大于国籍词汇表的总数,则设置 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)
# 打印预测结果标题
print("Top {} predictions:".format(k))
print("===================")
# 遍历每个预测结果,并将其格式化输出
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
结果如下
Enter a surname to classify: Yuan
How many of the top predictions to see? 5
Top 5 predictions:
===================
Yuan -> Chinese (p=0.62)
Yuan -> Korean (p=0.13)
Yuan -> German (p=0.06)
Yuan -> English (p=0.03)
Yuan -> Irish (p=0.03)
三、总结
在姓氏分类任务中,MLP(多层感知机)和CNN(卷积神经网络)是两种常见的神经网络模型选择。MLP适合处理简单的特征提取和分类任务,尤其是当数据集较小且特征维度不高时表现良好,但在处理图像等具有空间结构的数据时表现不佳。相比之下,CNN通过卷积层的参数共享和对空间局部性的敏感性,能够更有效地捕捉图像和序列数据中的特征和模式,因此在处理姓氏分类这样的任务时,通常会比MLP表现更优异,特别是在处理文字或字符序列数据时。选择适当的模型取决于数据的类型、任务的复杂性以及可用的计算资源和数据量。