一、环境配置
python 3.7
torch==2.1.0
二、数据预处理
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集具有一些使其有趣的性质。第一个性质是它是相当不平衡的。排名前三的国籍占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。本文中提到的姓氏分类是指将姓氏分类至其原有国籍类别中。
2.1 关于MLP的数据预处理
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), # 查找字典
self.set_split('train') # 默认设置为训练集
# 类权重
class_counts = surname_df.nationality.value_counts().to_dict() # 统计每个类别的数量
def sort_key(item):
return self._vectorizer.nationality_vocab.lookup_token(item[0])
sorted_counts = sorted(class_counts.items(), key=sort_key) # 根据类别排序
frequencies = [count for _, count in sorted_counts]
self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32) # 计算类权重
@classmethod
def load_dataset_and_make_vectorizer(cls, surname_csv):
"""加载数据集并从头开始创建一个新的向量化器
参数:
surname_csv (str): 数据集的位置
返回:
SurnameDataset 的一个实例
"""
surname_df = pd.read_csv(surname_csv) # 读取CSV文件
train_surname_df = surname_df[surname_df.split=='train'] # 提取训练集数据
return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df)) # 创建并返回实例
@classmethod
def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
"""加载数据集和相应的向量化器。
在向量化器已被缓存以便重用的情况下使用
参数:
surname_csv (str): 数据集的位置
vectorizer_filepath (str): 保存向量化器的位置
返回:
SurnameDataset 的一个实例
"""
surname_df = pd.read_csv(surname_csv) # 读取CSV文件
vectorizer = cls.load_vectorizer_only(vectorizer_filepath) # 加载向量化器
return cls(surname_df, vectorizer) # 创建并返回实例
@staticmethod
def load_vectorizer_only(vectorizer_filepath):
"""一个静态方法,从文件中加载向量化器
参数:
vectorizer_filepath (str): 序列化向量化器的位置
返回:
SurnameVectorizer 的一个实例
"""
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp)) # 反序列化并返回向量化器
def save_vectorizer(self, vectorizer_filepath):
"""使用json将向量化器保存到磁盘
参数:
vectorizer_filepath (str): 保存向量化器的位置
"""
with open(vectorizer_filepath, "w") as fp:
json.dump(self._vectorizer.to_serializable(), fp) # 序列化并保存向量化器
def get_vectorizer(self):
""" 返回向量化器 """
return self._vectorizer
def set_split(self, split="train"):
""" 使用数据框中的列选择数据集的分割 """
self._target_split = split
self._target_df, self._target_size = self._lookup_dict[split] # 设置目标数据集及其大小
def __len__(self):
return self._target_size # 返回当前数据集的大小
def __getitem__(self, index):
"""PyTorch数据集的主要入口方法
参数:
index (int): 数据点的索引
返回:
包含数据点的字典:
特征 (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):
"""给定批次大小,返回数据集中的批次数量
参数:
batch_size (int)
返回:
数据集中的批次数量
"""
return len(self) // batch_size # 计算批次数量
def generate_batches(dataset, batch_size, shuffle=True, drop_last=True, device="cpu"):
"""
一个生成器函数,包装了PyTorch的DataLoader。
它将确保每个张量在正确的设备位置上。
"""
dataloader = DataLoader(dataset=dataset, batch_size=batch_size, shuffle=shuffle, drop_last=drop_last) # 创建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 # 生成批次数据
我们定义了一个姓氏数据集类和一个批次生成器函数,用于处理和加载姓氏数据,以便在机器学习模型中使用。
class Vocabulary(object):
"""处理文本并提取用于映射的词汇的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
参数:
token_to_idx (dict): 预先存在的词汇到索引的映射
add_unk (bool): 是否添加UNK(未知)标记的标志
unk_token (str): 要添加到词汇中的UNK标记
"""
if token_to_idx is None:
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
self._unk_token = unk_token
self.unk_index = -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):
"""根据标记更新映射字典。
参数:
token (str): 要添加到词汇表中的项
返回:
index (int): 对应于该标记的整数索引
"""
try:
index = self._token_to_idx[token]
except KeyError:
index = len(self._token_to_idx)
self._token_to_idx[token] = index
self._idx_to_token[index] = token
return index
def add_many(self, tokens):
"""将标记列表添加到词汇表中
参数:
tokens (list): 字符串标记的列表
返回:
indices (list): 对应于这些标记的索引列表
"""
return [self.add_token(token) for token in tokens]
def lookup_token(self, token):
"""检索与标记关联的索引,如果标记不存在则返回UNK索引。
参数:
token (str): 要查找的标记
返回:
index (int): 对应于该标记的索引
注意:
`unk_index`需要 >=0(已添加到词汇表中)以实现UNK功能
"""
if self.unk_index >= 0:
return self._token_to_idx.get(token, self.unk_index)
else:
return self._token_to_idx[token]
def lookup_index(self, index):
"""返回与索引关联的标记
参数:
index (int): 要查找的索引
返回:
token (str): 对应于该索引的标记
引发:
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)
这段代码定义了一个词汇表的类,用于处理文本并提取词汇表以进行映射。我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。
class SurnameVectorizer(object):
""" 协调词汇表并使用它们的向量化器 """
def __init__(self, surname_vocab, nationality_vocab):
"""
参数:
surname_vocab (Vocabulary): 将字符映射到整数的词汇表
nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
"""
self.surname_vocab = surname_vocab
self.nationality_vocab = nationality_vocab
def vectorize(self, surname):
"""
参数:
surname (str): 姓氏
返回:
one_hot (np.ndarray): 一个压缩的独热编码
"""
vocab = self.surname_vocab
one_hot = np.zeros(len(vocab), dtype=np.float32) # 创建一个全零的数组,长度为词汇表的大小
for token in surname:
one_hot[vocab.lookup_token(token)] = 1 # 将姓氏中的每个字符在独热编码中置1
return one_hot
@classmethod
def from_dataframe(cls, surname_df):
"""从数据集数据框实例化向量化器
参数:
surname_df (pandas.DataFrame): 姓氏数据集
返回:
SurnameVectorizer 的一个实例
"""
surname_vocab = Vocabulary(unk_token="@") # 创建带有UNK标记的姓氏词汇表
nationality_vocab = Vocabulary(add_unk=False) # 创建不带UNK标记的国籍词汇表
for index, row in surname_df.iterrows():
for letter in row.surname:
surname_vocab.add_token(letter) # 向姓氏词汇表添加字符
nationality_vocab.add_token(row.nationality) # 向国籍词汇表添加国籍
return cls(surname_vocab, nationality_vocab) # 返回实例
@classmethod
def from_serializable(cls, contents):
"""从可序列化的字典中实例化向量化器"""
surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])
nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab'])
return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab)
def to_serializable(self):
"""返回可以序列化的字典"""
return {'surname_vocab': self.surname_vocab.to_serializable(),
'nationality_vocab': self.nationality_vocab.to_serializable()}
这段代码定义了一个名为 SurnameVectorizer
的类,用于协调和使用多个词汇表。该类主要用于将姓氏转换为向量表示。
2.2关于CNN的数据预处理
尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。
Vectorizer的vectorize()方法也要更改,以适应CNN模型的需要。现在,该函数将字符串中的每个字符映射到一个整数,然后使用这些整数构造一个由one-hot向量组成的矩阵,矩阵中的每一列都是不同的one-hot向量。这样设计的主要原因是将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。除了更改为使用one-hot矩阵之外,此代码还修改了矢量化器,以便计算姓氏的最大长度并将其保存。
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):
"""加载数据集和相应的向量化器。
"""
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 = {}
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
self._unk_token = unk_token
self.unk_index = -1
if add_unk:
self.unk_index = self.add_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):
"""根据令牌更新映射字典
Args:
token (str): 要添加到词汇中的项
Returns:
index (int): 与令牌对应的整数索引
"""
if token not in self._token_to_idx:
index = len(self._token_to_idx)
self._token_to_idx[token] = index
self._idx_to_token[index] = token
else:
index = self._token_to_idx[token]
return index
def add_many(self, tokens):
"""将多个令牌添加到词汇表中
Args:
tokens (list): 字符串令牌列表
Returns:
indices (list): 与令牌对应的索引列表
"""
return [self.add_token(token) for token in tokens]
def lookup_token(self, token):
"""检索与令牌关联的索引,如果令牌不存在则返回UNK索引
Args:
token (str): 要查找的令牌
Returns:
index (int): 与令牌对应的索引
"""
return self._token_to_idx.get(token, self.unk_index)
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)
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):
"""
Args:
surname (str): 姓氏
Returns:
one_hot_matrix (np.ndarray): 一个独热向量矩阵
"""
# 初始化全零矩阵
one_hot_matrix_size = (len(self.surname_vocab), self._max_surname_length)
one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
# 将姓氏中的每个字符转换为独热向量
for position_index, character in enumerate(surname):
character_index = self.surname_vocab.lookup_token(character)
one_hot_matrix[character_index][position_index] = 1
return one_hot_matrix
@classmethod
def from_dataframe(cls, surname_df):
"""从数据框实例化向量化器
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)
三、前馈神经网络
从数据流动方向来看,从第二层神经元开始,每一层神经元都可以接收前一层的输出,并输出给下一层,即整体上数据从左到右逐层传递,信号从输入层到输出层单向传播。所以,从信号(数据)传递方向上看,可以称作是前馈神经网络(Feedforward neural network,FNN)。
本次实验中我们使用的就是前馈神经网络中的多层感知机(MLP)。
3.1多层感知机(MLP)
对于一个分类问题,我们最常用的方法就是感知机,一个二分类的线性可分问题,单层感知机是很容易想到的。但对于一个与或(XOR)问题,我们就需要多层感知机来解决了。
左边是单层感知机,右边是多层感知机。
多层感知机(Multilayer Perceptron,简称MLP),是一种基于前馈神经网络(Feedforward Neural Network)的深度学习模型,由多个神经元层组成,其中每个神经元层与前一层全连接。多层感知机可以用于解决分类、回归和聚类等各种机器学习问题。
多层感知机的每个神经元层由许多神经元组成,其中输入层接收输入特征,输出层给出最终的预测结果,中间的隐藏层用于提取特征和进行非线性变换。每个神经元接收前一层的输出,进行加权和和激活函数运算,得到当前层的输出。通过不断迭代训练,多层感知机可以自动学习到输入特征之间的复杂关系,并对新的数据进行预测。
一个单隐藏层的多层感知机
import torch.nn as nn
import torch.nn.functional as F
class MultilayerPerceptron(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
"""
参数:
input_dim (int): 输入向量的大小
hidden_dim (int): 第一个线性层的输出大小
output_dim (int): 第二个线性层的输出大小
"""
super(MultilayerPerceptron, self).__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim) # 定义第一个全连接层
self.fc2 = nn.Linear(hidden_dim, output_dim) # 定义第二个全连接层
def forward(self, x_in, apply_softmax=False):
"""MLP的前向传播
参数:
x_in (torch.Tensor): 输入数据张量。
x_in的形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用softmax激活函数的标志
如果使用交叉熵损失,应为False
返回:
结果张量。张量形状应为 (batch, output_dim)
"""
intermediate = F.relu(self.fc1(x_in)) # 应用ReLU激活函数到第一个全连接层的输出
output = self.fc2(intermediate) # 第二个全连接层
if apply_softmax:
output = F.softmax(output, dim=1) # 如果apply_softmax为True,应用softmax激活函数
return output
我们用PyTorch的两个线性模块实例化了这个想法。线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层”。除了这两个线性层外,还有一个修正的线性单元(ReLU)非线性,它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层4,因此不能建模复杂的模式。
batch_size = 2 # number of samples input at once
input_dim = 3#设置输入纬度为3
hidden_dim = 100#设置隐藏维度为100
output_dim = 4#设置输出纬度为4
# Initialize model
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
print(mlp)
设置多层感知机的参数,使用print查看模型结构
可以看到两个线性层,一个输入3维度,输出100维度,另一个输入100维度,输出3维度。
import torch
def describe(x):
print("Type: {}".format(x.type()))#打印类型
print("Shape/size: {}".format(x.shape))#打印大小
print("Values: \n{}".format(x))#打印数值
x_input = torch.rand(batch_size, input_dim)
describe(x_input)
y_output = mlp(x_input, apply_softmax=False)
describe(y_output)
使用一个简单的代码查看模型是否完整,是否工作,此时模型还未训练,所以输出结果是随机的,只是作为一个可用的检测。
如果想将预测向量转换为概率,则需要额外的步骤。具体来说,需要softmax函数,它用于将一个值向量转换为概率。softmax有许多根。在物理学中,它被称为玻尔兹曼或吉布斯分布;在统计学中,它是多项式逻辑回归;在自然语言处理(NLP)社区,它是最大熵(MaxEnt)分类器。不管叫什么名字,这个函数背后的直觉是,大的正值会导致更高的概率,小的负值会导致更小的概率。这次将apply_softmax标志设置为True。
y_output = mlp(x_input, apply_softmax=True)
describe(y_output)
3.2 MLP进行姓氏分类
class SurnameClassifier(nn.Module):
""" 用于姓氏分类的两层多层感知器 """
def __init__(self, input_dim, hidden_dim, output_dim):
"""
参数:
input_dim (int): 输入向量的维度大小
hidden_dim (int): 第一个线性层的输出维度大小
output_dim (int): 第二个线性层的输出维度大小
"""
super(SurnameClassifier, self).__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim) # 第一个线性层
self.fc2 = nn.Linear(hidden_dim, output_dim) # 第二个线性层
def forward(self, x_in, apply_softmax=False):
"""分类器的前向传播
参数:
x_in (torch.Tensor): 输入数据张量。
x_in 的形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用 softmax 激活的标志
如果与交叉熵损失一起使用,应为 False
返回:
结果张量。张量的形状应为 (batch, output_dim)
"""
intermediate_vector = F.relu(self.fc1(x_in)) # 应用 ReLU 激活函数
prediction_vector = self.fc2(intermediate_vector) # 得到预测结果
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1) # 应用 softmax 激活函数
return prediction_vector # 返回预测结果张量
此段代码构建了一个用于姓氏分类的两层多层感知器(MLP)。
def create_surname_vocab(data):
# 从数据中提取姓氏,并创建姓氏词汇表
surname_vocab = set()
for example in data:
surname = example['surname']
surname_vocab.add(surname)
return list(surname_vocab)
# 现在 train_df, val_df, test_df 分别包含了训练集、验证集和测试集的数据
csv_file_path = 'surnames.csv'
# 使用 pandas 的 read_csv 函数加载数据
surname_df = pd.read_csv(csv_file_path)
# 使用 train_test_split 进行数据分割
train_df, test_df = train_test_split(surname_df, test_size=0.2, random_state=42) # 80% 训练集,20% 测试集
val_df, test_df = train_test_split(test_df, test_size=0.5, random_state=42) # 从测试集中分割出验证集和测试集
# 创建 SurnameVectorizer 实例
vectorizer = SurnameVectorizer.from_dataframe(train_df) # 通常使用训练集来创建向量化器
# 创建 SurnameDataset 实例
train_dataset = SurnameDataset(train_df, vectorizer)
val_dataset = SurnameDataset(val_df, vectorizer)
test_dataset = SurnameDataset(test_df, vectorizer)
# 创建 DataLoader 对象
batch_size = 64
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
# 现在 surname_df 是一个包含 CSV 文件数据的 pandas DataFrame
vectorizer = SurnameVectorizer.from_dataframe(surname_df)
args = {
# 数据和路径信息
'surname_csv': 'surnames.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, # 批量大小
# 省略了运行时选项以节省空间
}
# 使用 vectorizer 对象创建 SurnameClassifier 实例
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
hidden_dim=args['hidden_dim'],
output_dim=len(vectorizer.nationality_vocab))
读取数据集,设定好训练参数。
# 定义损失函数,这里以交叉熵损失为例
loss_func = nn.CrossEntropyLoss()
# 接下来,使用 num_epochs 执行训练循环
for epoch in range(num_epochs):
for batch in train_loader:
optimizer.zero_grad()
x_surname = batch['x_surname']
y_nationality = batch['y_nationality']
# 前向传播
y_pred = classifier(x_surname)
# 计算损失
loss = loss_func(y_pred, y_nationality)
# 反向传播和优化
loss.backward()
optimizer.step()
选择交叉熵损失函数,建立循环,通过反向传播来训练模型
def predict_nationality(name, classifier, vectorizer):
# 将名称转换为向量表示
vectorized_name = vectorizer.vectorize(name)
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 使用分类器进行预测
result = classifier(vectorized_name, apply_softmax=True)
# 获取预测结果的概率值和索引
probability_values, indices = result.max(dim=1)
index = indices.item()
# 从向量化器中查找预测的国籍
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
probability_value = probability_values.item()
return {'nationality': predicted_nationality,
'probability': probability_value}
new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
设定一个预测函数,让我们可以实验模型效果。
四、卷积神经网络
卷积神经网络(Convolutional Neural Networks, CNN)是一类包含卷积计算且具有深度结构的前馈神经网络(Feedforward Neural Networks),是深度学习(deep learning)的代表算法之一 [1-2]。卷积神经网络具有表征学习(representation learning)能力,能够按其阶层结构对输入信息进行平移不变分类(shift-invariant classification)
卷积神经网络的基本结构大致包括:卷积层、激活函数、池化层、全连接层、输出层等。
4.1卷积神经网络结构介绍
4.1.1卷积层
卷积层(Convolutional layer),这一层就是卷积神经网络最重要的一个层次,也是“卷积神经网络”的名字来源。卷积神经网路中每层卷积层由若干卷积单元组成,每个卷积单元的参数都是通过反向传播算法优化得到的。
4.1.2激活函数
激活函数,最常用的激活函数目前有Relu、tanh、sigmoid。
Sigmoid
性质:
- 输出值范围为(0, 1)。
- 常用于二分类问题。
- 可能导致梯度消失问题,尤其是在深层网络中。
图像:
Tanh(Hyperbolic Tangent)
性质:
- 输出值范围为(-1, 1)。
- 对称于原点(0,0),有利于加速收敛。
- 在输入非常大或非常小时,输出接近1或-1,可能导致梯度消失。
图像:
ReLU(Rectified Linear Unit)
性质:
- 当输入大于0时,输出等于输入;当输入小于或等于0时,输出为0。
- 计算简单,收敛速度快。
- 但在负轴上的输入会导致神经元“死亡”,即这些神经元在整个训练过程中都不会被激活。
图像:
4.1.3池化层
池化层(Pooling layer),通常在卷积层之后会得到维度很大的特征,将特征切成几个区域,取其最大值或平均值,得到新的、维度较小的特征。最常用的是最大池化和平均池化。
4.1.4全连接层
全连接层( Fully-Connected layer), 把所有局部特征结合变成全局特征,用来计算最后每一类的得分。
全连接层往往在分类问题中用作网络的最后层,作用主要为将数据矩阵进行全连接,然后按照分类数量输出数据,在回归问题中,全连接层则可以省略,但是我们需要增加卷积层来对数据进行逆卷积操作。
4.1.5输出层
输出层主要准备做好最后目标结果的输出。
4.2使用CNN进行姓氏分类
class SurnameClassifier(nn.Module):
def __init__(self, initial_num_channels, num_classes, num_channels):
"""
参数:
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):
"""分类器的前向传播
参数:
x_surname (torch.Tensor): 输入数据张量。
x_surname 的形状应为 (batch, initial_num_channels, max_surname_length)
apply_softmax (bool): softmax 激活的标志
如果与交叉熵损失一起使用,应为 false
返回:
结果张量。张量的形状应为 (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 # 返回预测结果张量
构建一个CNN的模型。
classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)
# 定义损失函数和优化器
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
mode='min', factor=0.5,
patience=1)
# 初始化训练状态
train_state = make_train_state(args)
# 创建进度条
epoch_bar = tqdm_notebook(desc='training routine',
total=args.num_epochs,
position=0)
# 设置训练和验证数据集
dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train',
total=dataset.get_num_batches(args.batch_size),
position=1,
leave=True)
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val',
total=dataset.get_num_batches(args.batch_size),
position=1,
leave=True)
try:
for epoch_index in range(args.num_epochs):
train_state['epoch_index'] = epoch_index
# 训练阶段
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()
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.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()
except KeyboardInterrupt:
print("Exiting loop")
模型进行训练,设定好所需参数
def predict_nationality(surname, classifier, vectorizer):
"""预测一个新姓氏的国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifer): 分类器的实例
vectorizer (SurnameVectorizer): 相应的向量化器
Returns:
dict: 包含最可能的国籍及其概率的字典
"""
# 向量化姓氏
vectorized_surname = vectorizer.vectorize(surname)
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
# 使用分类器进行预测
result = classifier(vectorized_surname, apply_softmax=True)
# 获取最可能的国籍及其概率
probability_values, indices = result.max(dim=1)
index = indices.item()
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
probability_value = probability_values.item()
return {'nationality': predicted_nationality, 'probability': probability_value}
csv_file_path = 'surnames.csv'
surname_df = pd.read_csv(csv_file_path)
vectorizer = SurnameVectorizer.from_dataframe(surname_df)
initial_num_channels = 82 # 姓氏特征向量的大小
num_classes = 18 # 国籍的类别数
num_channels = 64 # 网络中使用的通道数
# 初始化分类器
classifier = SurnameClassifier(initial_num_channels, num_classes, num_channels)
# 将分类器移到 CPU 上进行推理
classifier = classifier.cpu()
# 获取用户输入并进行预测
new_surname = input("Enter a surname to classify: ")
prediction = predict_nationality(new_surname, classifier, vectorizer)
# 打印预测结果
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
predict_nationality()函数的部分已被修改,不再使用view方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()函数在应该添加大小为1的维度的位置上添加批处理维度。
五、总结
本次实验所使用的两个模型都比较简单,也只是简单完成了一遍流程。在数据预处理步骤,由于两种模型所需的数据不同,所以构建的数据提取器也有所不同,请读者多加注意。如果你想要得到更好的结果可以尝试使用别的复杂些的模型,和更大的数据集。