提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
前言
前馈神经网络(FNN)是人工神经网络的一种,采用一种单向多层结构。其中每一层包含若干个神经元。在此种神经网络中,各神经元可以接收前一层神经元的信号,并产生输出到下一层。
一、内容概览
在本文中将要简述两种前馈神经网络(多层感知机、循环神经网络),并给出一个具体的事例——“姓氏分类”来分析。
在介绍多层感知机时,由感知机的缺点引出多层感知机,并通过XOR事例来直观显示出两者的区别。
本文的重点主要是在姓氏分类的应用,对于感知机以及卷积神经网络的相关知识需要读者自行去搜寻,这里只有一个大概的介绍。
二、多层感知机
2.1 什么是多层感知机
2.1.1 感知机
感知机是一种最简单的神经网络形式,由两层神经元组成:输入层和输出层。它适用于线性可分的问题,通过学习权重和偏置来实现分类任务。
感知机学习算法简单,通常使用梯度下降法更新权重。而且感知机只能解决线性可分问题。
为了能够处理非线性的问题,提出了多层感知机。
2.1.2 多层感知机
多层感知机是由多个全连接层组成的神经网络,每个层之间的神经元完全连接。MLP包括输入层、多个隐藏层和输出层,隐藏层使用非线性激活函数(如ReLU)来增强模型的表达能力。
最简单的MLP是对感知器的扩展,如图所示,由三个表示阶段和两个线性层组成。
多层感知机能够学习复杂的非线性关系。通常用于分类和回归任务。缺点包括可能过拟合,对参数敏感,处理序列数据时效果不佳。
为了显示出感知机和多层感知机的差异,这里我们将通过XOR事例来直观的感受到两者的区别。
2.1.3 XOR事例
事例:在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如图所示。在这个图中,错误分类的数据点用黑色填充,而正确分类的数据点没有填充。
结果:在左边的面板中,从填充的形状可以看出,感知器在学习一个可以将星星和圆分开的决策边界方面有困难。然而,MLP(右面板)学习了一个更精确地对恒星和圆进行分类的决策边界。
分析:图中的线代表着决策边界,在这个问题中是无法使用一条线就划分出来的,因此感知机无法实现功能。图中的MLP显示有两个决策边界,但它实际上只是一个决策边界!决策边界就是这样出现的,因为中间表示法改变了空间(“扭曲”数据所在空间),使一个超平面同时出现在这两个位置上。
2.2 多层感知机的应用(姓氏分类)
2.2.1 数据集的构建
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏。该数据集具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
为了创建最终的数据集,我们对数据集进行了一些修改,第一个目的是为了减少数据集中的不平衡。在原始数据集中,我们发现70%以上为俄文,因此,我们需要选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。在这里我们使用了getitem方法和len方法。
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
self.surname_df = surname_df
self._vectorizer = vectorizer
# 拆分数据集为训练集、验证集和测试集
self.train_df = self.surname_df[self.surname_df.split=='train']
self.train_size = len(self.train_df)
self.val_df = self.surname_df[self.surname_df.split=='val']
self.validation_size = len(self.val_df)
self.test_df = self.surname_df[self.surname_df.split=='test']
self.test_size = len(self.test_df)
self._lookup_dict = {'train': (self.train_df, self.train_size),
'val': (self.val_df, self.validation_size),
'test': (self.test_df, self.test_size)}
self.set_split('train')
# 类别权重
class_counts = surname_df.nationality.value_counts().to_dict()
def sort_key(item):
return self._vectorizer.nationality_vocab.lookup_token(item[0])
sorted_counts = sorted(class_counts.items(), key=sort_key)
frequencies = [count for _, count in sorted_counts]
self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)
@classmethod
def load_dataset_and_make_vectorizer(cls, surname_csv):
"""加载数据集并从头创建一个新的向量化器
参数:
surname_csv (str): 数据集的位置
返回:
SurnameDataset的一个实例
"""
surname_df = pd.read_csv(surname_csv)
train_surname_df = surname_df[surname_df.split=='train']
return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))
@classmethod
def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
"""加载数据集和相应的向量化器。用于向量化器已被缓存以供重用的情况
参数:
surname_csv (str): 数据集的位置
vectorizer_filepath (str): 已保存的向量化器的位置
返回:
SurnameDataset的一个实例
"""
surname_df = pd.read_csv(surname_csv)
vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
return cls(surname_df, vectorizer)
@staticmethod
def load_vectorizer_only(vectorizer_filepath):
"""从文件加载向量化器的静态方法
参数:
vectorizer_filepath (str): 序列化向量化器的位置
返回:
SurnameVectorizer的一个实例
"""
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp))
def save_vectorizer(self, vectorizer_filepath):
"""使用json将向量化器保存到磁盘
参数:
vectorizer_filepath (str): 保存向量化器的位置
"""
with open(vectorizer_filepath, "w") as fp:
json.dump(self._vectorizer.to_serializable(), fp)
def get_vectorizer(self):
""" 返回向量化器 """
return self._vectorizer
def set_split(self, split="train"):
""" 使用数据框中的列选择数据集的拆分 """
self._target_split = split
self._target_df, self._target_size = self._lookup_dict[split]
def __len__(self):
return self._target_size
def __getitem__(self, index):
"""PyTorch数据集的主要入口点方法
参数:
index (int): 数据点的索引
返回:
一个包含数据点的字典:
特征 (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):
return len(self) // batch_size
def generate_batches(dataset, batch_size, shuffle=True,
drop_last=True, device="cpu"):
dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
shuffle=shuffle, drop_last=drop_last)
for data_dict in dataloader:
out_data_dict = {}
for name, tensor in data_dict.items():
out_data_dict[name] = data_dict[name].to(device)
yield out_data_dict
2.2.2 词汇表、向量化器和DataLoader
为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。
class Vocabulary(object):
"""用于处理文本并提取词汇以进行映射的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
初始化Vocabulary实例。
参数:
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
# 如果要求,将UNK标记添加到词汇表中
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):
"""从序列化字典实例化Vocabulary。"""
return cls(**contents)
def add_token(self, token):
"""基于标记更新映射字典。
参数:
token (str): 要添加到Vocabulary中的项
返回:
index (int): 对应于标记的整数
"""
try:
index = self._token_to_idx[token]
except KeyError:
index = len(self._token_to_idx)
self._token_to_idx[token] = index
self._idx_to_token[index] = token
return index
def add_many(self, tokens):
"""将标记列表添加到Vocabulary中。
参数:
tokens (list): 字符串标记列表
返回:
indices (list): 与标记对应的索引列表
"""
return [self.add_token(token) for token in tokens]
def lookup_token(self, token):
"""检索与标记相关联的索引,如果标记不存在,则使用UNK索引。
参数:
token (str): 要查找的标记
返回:
index (int): 与标记相关的索引
注意:
UNK功能需要unk_index >= 0(已添加到Vocabulary中)
"""
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: 如果索引不在Vocabulary中
"""
if index not in self._idx_to_token:
raise KeyError("索引(%d)不在Vocabulary中" % index)
return self._idx_to_token[index]
def __str__(self):
"""返回Vocabulary的字符串表示形式。"""
return "<Vocabulary(size=%d)>" % len(self)
def __len__(self):
"""返回Vocabulary中唯一标记的数量。"""
return len(self._token_to_idx)
class SurnameVectorizer(object):
"""协调Vocabularies并将它们应用于实际用途的向量化器"""
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):
"""
将姓氏向量化为一种折叠的one-hot编码。
参数:
surname (str): 姓氏
返回:
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
return one_hot
@classmethod
def from_dataframe(cls, surname_df):
"""从数据集DataFrame实例化向量化器。
参数:
surname_df (pandas.DataFrame): 姓氏数据集
返回:
SurnameVectorizer的实例
"""
surname_vocab = Vocabulary(unk_token="@")
nationality_vocab = Vocabulary(add_unk=False)
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()}
2.2.3 姓氏分类器模型
MLP的具体实现:第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。在最后一步中,可选地应用softmax操作,以确保输出和为1;它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
import torch.nn as nn
import torch.nn.functional as F
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
2.2.4 训练过程
虽然我们使用了不同的模型、数据集和损失函数,但是训练例程是相同的。训练中最显著的差异与模型中输出的种类和使用的损失函数有关。
# 设置参数
args = Namespace(
# 数据和路径信息
surname_csv="data/surnames/surnames_with_splits.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,
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("扩展的文件路径: ")
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):
torch.manual_seed(seed)
if cuda:
torch.cuda.manual_seed_all(seed)
set_seed_everywhere(args.seed, args.cuda)
# 处理目录
def handle_dirs(dirpath):
if not os.path.exists(dirpath):
os.makedirs(dirpath)
handle_dirs(args.save_dir)
2.2.5 模型评估和预测
要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。
评估:
# 加载最佳模型
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']))
预测:
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)
# 使用分类器进行预测,并应用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']))
三、卷积神经网络
3.1 什么是卷积神经网络
3.1.1 卷积神经网络简介
卷积神经网络是专门用于处理具有网格结构(如图像)的数据的神经网络。它通过卷积层和池化层有效地提取图像中的特征。是一种非常适合检测空间子结构(并因此创建有意义的空间子结构)的神经网络。CNNs通过使用少量的权重来扫描输入数据张量来实现这一点。通过这种扫描,它们产生表示子结构检测(或不检测)的输出张量。
3.1.2 卷积、卷积计算
卷积操作:指将一个可移动的小窗口(称为数据窗口,如下图绿色矩形)与图像进行逐元素相乘然后相加的操作。这个小窗口其实是一组固定的权重,它可以被看作是一个特定的滤波器(filter)或卷积核。这个操作的名称“卷积”,源自于这种元素级相乘和求和的过程。
卷积需要注意哪些问题?
a.步长stride:每次滑动的位置步长。
b. 卷积核的个数:决定输出的depth厚度。同时代表卷积核的个数。
c. 填充值zero-padding:在外围边缘补充若干圈0,方便从初始位置以步长为单位可以刚好滑倒末尾位置,通俗地讲就是为了总长能被步长整除。
3.2 卷积神经网络的应用(姓氏分类)
3.2.1 姓氏数据集
我们使用数据集中最长的姓氏来控制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'] # 训练集DataFrame
self.train_size = len(self.train_df) # 训练集大小
self.val_df = self.surname_df[self.surname_df.split=='val'] # 验证集DataFrame
self.validation_size = len(self.val_df) # 验证集大小
self.test_df = self.surname_df[self.surname_df.split=='test'] # 测试集DataFrame
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) # 从CSV文件加载数据集
train_surname_df = surname_df[surname_df.split=='train'] # 获取训练集DataFrame
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) # 从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:
SurnameDataset的实例
"""
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) # 将向量化器序列化为JSON并保存到文件中
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] # 获取目标拆分的DataFrame和大小
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) # 创建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.2.2 词汇表和向量化器
词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。
将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。重要的是,矩阵中的每一列都是不同的onehot向量。主要原因是,我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。除了更改为使用onehot矩阵之外,我们还修改了矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length
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): 对应于标记的整数
"""
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]
def lookup_token(self, token):
"""检索与标记相关联的索引,如果标记不存在,则返回UNK索引。
Args:
token (str): 要查找的标记
Returns:
index (int): 与标记对应的索引
Notes:
对于UNK功能,unk_index需要>=0(已添加到词汇表中)
"""
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):
"""返回与索引相关联的标记
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 "<词汇表(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)
@classmethod
def from_serializable(cls, contents):
surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])
nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab'])
return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab,
max_surname_length=contents['max_surname_length'])
def to_serializable(self):
return {'surname_vocab': self.surname_vocab.to_serializable(),
'nationality_vocab': self.nationality_vocab.to_serializable(),
'max_surname_length': self._max_surname_length}
3.2.3 使用卷积网络重新实现姓氏分类器
我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。与我们在“卷积神经网络”中引入的Conv1d序列既有相似之处,也有需要解释的新添加内容。具体来说,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。
将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。
```import torch.nn as nn
import torch.nn.functional as F
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:
结果张量。张量.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 # 返回预测向量
3.2.4 训练过程
操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。
def make_train_state(args):
"""
创建训练状态字典
Args:
args: 参数对象,包含模型训练相关的参数
Returns:
train_state (dict): 训练状态字典,包含模型训练过程中的各种状态信息
"""
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):
"""
更新训练状态
Components:
- Early Stopping: 防止过拟合。
- Model Checkpoint: 如果模型性能更好,则保存模型。
Args:
args: 主要参数
model: 要训练的模型
train_state: 表示训练状态值的字典
Returns:
train_state (dict): 更新后的训练状态字典
"""
# 至少保存一个模型
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()
# 计算准确率
accuracy = n_correct / len(y_pred_indices) * 100
return accuracy
# 创建实验所需的文件路径
# Data and Path information
args = Namespace(
surname_csv="surnames_with_splits.csv", # 姓氏数据集的CSV文件路径
vectorizer_file="vectorizer.json", # 向量化器保存文件路径
model_state_file="model.pth", # 模型状态文件路径
save_dir="model_storage/ch4/cnn", # 实验结果保存目录路径
# Model hyper parameters
hidden_dim=100, # 隐藏层维度
num_channels=256, # 卷积层通道数
# Training hyper parameters
seed=1337, # 随机种子
learning_rate=0.001, # 学习率
batch_size=128, # 批量大小
num_epochs=10, # 迭代轮数 #为了减少实验轮数,将轮数从100调整为10
early_stopping_criteria=5, # 提前停止条件
dropout_p=0.1, # Dropout概率
# Runtime options
cuda=False, # 是否使用CUDA加速
reload_from_files=False, # 是否从文件中重新加载模型和向量化器
expand_filepaths_to_save_dir=True, # 是否将文件路径扩展到保存目录中
catch_keyboard_interrupt=True # 是否捕获键盘中断以提前停止训练
)
# 将文件路径扩展到保存目录中
if 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
# 设置设备
args.device = torch.device("cuda" if args.cuda else "cpu")
print("Using CUDA: {}".format(args.cuda))
# 设置随机种子
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)
# 将分类器移动到设备
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)
3.2.5 模型评估和预测
为了要了解模型的性能,需要对性能进行定量和定性的度量。现在通过以下代码,打印损失和准确率。
在本例中,predict_nationality()函数的一部分发生了更改,如示例4-21所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在predict_topk_nationality()函数中。
# 加载模型的参数
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.
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']))
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).unsqueeze(0)
# 使用分类器进行预测
result = classifier(vectorized_surname, apply_softmax=True)
# 获取预测结果中概率最高的值和对应的索引
probability_values, indices = result.max(dim=1)
index = indices.item()
# 根据索引查找预测的国籍
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
# 获取预测的国籍对应的概率值
probability_value = probability_values.item()
return {'nationality': predicted_nationality, 'probability': probability_value}
# 获取用户输入的姓氏
new_surname = input("Enter a surname to classify: ")
# 将分类器移动到 CPU 上进行推理
classifier = classifier.cpu()
# 使用预定义的函数预测姓氏的国籍及其概率
prediction = predict_nationality(new_surname, classifier, vectorizer)
# 输出预测结果
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
参考
希冀平台实验内容