目录
1.2Vocabulary, Vectorizer, and DataLoader
1.3 The Surname Classifier Model
1.5 Model Evaluation and Prediction
1.5.1 EVALUATING ON THE TEST DATASET
1.5.2 CLASSIFYING A NEW SURNAME
1.5.3 RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME
2.2 Vocabulary, Vectorizer, and DataLoader
2.3 Reimplementing the SurnameClassifier with Convolutional Networks
2.5 Model Evaluation and Prediction
2.5.1Evaluating on the Test Dataset
2.5.2Classifying or retrieving top predictions for a new surname
一、多层感知机
1原理介绍
多层感知机(MLP,Multilayer Perceptron)也叫人工神经网络(ANN,Artificial Neural Network),除了输入输出层,它中间可以有多个隐层,最简单的MLP只含一个隐层,即三层的结构,如下图
从上图可以看到,多层感知机层与层之间是全连接的。多层感知机最底层是输入层,中间是隐藏层,最后是输出层。假设输入层用向量表示,则隐藏层的输出就是 ,是权重(也叫连接系数),是偏置,激活函数 可以是常用的sigmoid函数或者tanh函数。
2激活函数
2.1sigmoid函数
从表达式中很容易看出,sigmoid 是一个光滑的、可微的函数。Torch 将sigmoid 实现为Torch .sigmoid()
import torch
import matplotlib.pyplot as plt
x = torch.range(-5., 5., 0.1)# 生成 x 值范围为 -5 到 5,步长为 0.1
y = torch.sigmoid(x)# 计算 Sigmoid 函数的值
plt.plot(x.numpy(), y.numpy())
plt.show()# 绘制图像
该函数具有如下的特性:当x趋近于负无穷时,y趋近于0;当x趋近于正无穷时,y趋近于1;当x=0时,y=1/2.
2.2tanh函数
它解决了Sigmoid函数的不以0为中心输出问题,然而,梯度消失的问题和幂运算的问题仍然存在。
import torch
import matplotlib.pyplot as plt
x = torch.range(-5., 5., 0.1)# 生成 x 值范围为 -5 到 5,步长为 0.1
y = torch.tanh(x)# 计算 tanh 函数的值
plt.plot(x.numpy(), y.numpy())
plt.show()# 打印图像
二、卷积神经网络
1卷积神经网络介绍
卷积神经网络是一种多层的监督学习神经网络,隐含层的卷积层和池采样层是实现卷积神经网络特征提取功能的核心模块。该网络模型通过采用梯度下降法最小化损失函数对网络中的权重参数逐层反向调节,通过频繁的迭代训练提高网络的精度。卷积神经网络的低隐层是由卷积层和最大池采样层交替组成,高层是全连接层对应传统多层感知器的隐含层和逻辑回归分类器。第一个全连接层的输入是由卷积层和子采样层进行特征提取得到的特征图像。最后一层输出层是一个分类器,可以采用逻辑回归,Softmax回归甚至是支持向量机对输入图像进行分类。
2输入层
主要工作就是输入图像等信息,对于输入图像,首先要将其转换为对应的二维矩阵,这个二位矩阵就是由图像每一个像素的像素值大小组成的.
3卷积层
卷积层主要是由一些卷积单元一起组成,并且为了优化该神经网络,还须使用反向传播算法。为了获得输入图片数据的一些特征,要对其进行卷积操作。一般来说,位于第一层的卷积层得到的特征都相对比较低级,比如边、角和线等特征,层数多的网络可以通过低层特征的迭代来获得更加复杂的特征 。
当数据被输入时,滤波器的窗口以固定的间隔进行滑动,滤波器上的 9 个格子 上对应的数字与输入数据上数字重合时,将重合的数字相乘并依次相加,会得到一 个求和结果。最后将求和结果当作输出,保存到与之对应的位置,在整个输入数据 中将此流程遍历,最终得到的数据即为输出。具体流程如下:
4激活层
把卷积层输出结果做非线性映射.
CNN采用的激活函数一般为ReLU,它的特点是收敛快,求梯度简单,但较脆弱,图像如下:
5池化层
池化层夹在连续的卷积层中间, 用于压缩数据和参数的量,减小过拟合。如果输入是图像的话,那么池化层的最主要作用就是压缩图像。
- 降维与减少计算量:通过池化操作,减少特征图的尺寸,从而降低后续层的计算量和存储需求。
- 防止过拟合:通过降维和简化特征,减少网络的复杂度,有助于防止过拟合。
- 增强特征的鲁棒性:池化操作在特征提取过程中保留了主要信息,丢弃了一些不重要的细节,使得网络对输入图像的小变化(如平移和旋转)更加鲁棒。
常见的池化操作
-
最大池化(Max Pooling):选择池化窗口内的最大值作为输出,保留了最显著的特征。
- 平均池化(Average Pooling):计算池化窗口内所有值的平均值作为输出,更注重整体信息。
6全连接层
两层之间所有神经元都有权重连接,通常全连接层在卷积神经网络尾部。也就是跟传统的神经网洛神经元的连接方式是一样的:
三、实验步骤
1基于多层感知机处理姓氏分类
这一示例中,我们将使用多层感知器(Multilayer Perceptron, MLP)来进行姓氏分类。多层感知器是一种前馈神经网络,通过多个隐藏层进行非线性变换,可以有效地处理复杂的分类任务。
我们首先对每个姓氏的字符进行拆分,并像对待“示例:将餐馆评论的情绪分类”中的单词一样对待它们。除了数据上的差异,字符层模型在结构和实现上与基于单词的模型基本相似。
然后,我们使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的管道。
我们将通过描述姓氏分类器模型及其设计背后的思想过程来继续本节。除了模型的改变,我们在这个例子中引入了多类输出及其对应的损失函数。在描述了模型之后,我们完成了训练例程。
1.1The Surname Dataset
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
为了创建最终的数据集,我们从一个比课程补充材料中包含的版本处理更少的版本开始,并执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为此,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。
下面给出简化代码:
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.val_df = self.surname_df[self.surname_df.split=='val']
self.test_df = self.surname_df[self.surname_df.split=='test']
self._lookup_dict = {
'train': (self.train_df, len(self.train_df)),
'val': (self.val_df, len(self.val_df)),
'test': (self.test_df, len(self.test_df))
}
self.set_split('train')
# 计算类别权重
class_counts = surname_df.nationality.value_counts().to_dict()
sorted_counts = sorted(class_counts.items(), key=lambda item: self._vectorizer.nationality_vocab.lookup_token(item[0]))
frequencies = [count for _, count in sorted_counts]
self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)
@classmethod
def load_dataset_and_make_vectorizer(cls, surname_csv):
"""从头加载数据集并创建新的向量化器"""
surname_df = pd.read_csv(surname_csv)
train_surname_df = surname_df[surname_df.split=='train']
return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))
@classmethod
def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
"""加载数据集和已保存的向量化器"""
surname_df = pd.read_csv(surname_csv)
vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
return cls(surname_df, vectorizer)
@staticmethod
def load_vectorizer_only(vectorizer_filepath):
"""仅加载向量化器"""
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp))
def save_vectorizer(self, vectorizer_filepath):
"""保存向量化器"""
with open(vectorizer_filepath, "w") as fp:
json.dump(self._vectorizer.to_serializable(), fp)
def get_vectorizer(self):
"""返回向量化器"""
return self._vectorizer
def set_split(self, split="train"):
"""选择数据集的分割部分"""
self._target_split = split
self._target_df, self._target_size = self._lookup_dict[split]
def __len__(self):
return self._target_size
def __getitem__(self, index):
"""返回指定索引的数据点"""
row = self._target_df.iloc[index]
surname_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 = {name: tensor.to(device) for name, tensor in data_dict.items()}
yield out_data_dict
1.2Vocabulary, Vectorizer, and DataLoader
为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。这些数据结构与“Example: Classifying Sentiment of Restaurant Reviews”中使用的数据结构相同,它们举例说明了一种多态性,这种多态性将姓氏的字符标记与Yelp评论的单词标记相同对待。数据不是通过将字令牌映射到整数来向量化的,而是通过将字符映射到整数来向量化的。
THE VOCABULARY CLASS
词汇表是两个Python字典的协调,这两个字典在令牌(在本例中是字符)和整数之间形成一个双射;也就是说,第一个字典将字符映射到整数索引,第二个字典将整数索引映射到字符。add_token方法用于向词汇表中添加新的令牌,lookup_token方法用于检索索引,lookup_index方法用于检索给定索引的令牌(在推断阶段很有用)。与Yelp评论的词汇表不同,我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。
下面给出简化代码:
class Vocabulary(object):
"""处理文本并提取用于映射的词汇的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
Args:
token_to_idx (dict): 预先存在的令牌到索引的映射字典
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): 与令牌对应的索引
"""
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(f"索引({index})不在词汇表中")
return self._idx_to_token[index]
def __str__(self):
return f"<Vocabulary(size={len(self)})>"
def __len__(self):
return len(self._token_to_idx)
THE SURNAMEVECTORIZER
虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。实例化和使用非常类似于“示例:对餐馆评论的情绪进行分类”中的ReviewVectorizer,但有一个关键区别:字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
下面给出简化代码:
class SurnameVectorizer(object):
""" 协调词汇表并将其应用的向量化器类 """
def __init__(self, surname_vocab, nationality_vocab):
"""
Args:
surname_vocab (Vocabulary): 将字符映射到整数的词汇表
nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
"""
self.surname_vocab = surname_vocab
self.nationality_vocab = nationality_vocab
def vectorize(self, surname):
"""
将姓氏向量化为一维one-hot编码
Args:
surname (str): 姓氏
Returns:
one_hot (np.ndarray): 一维one-hot编码
"""
vocab = self.surname_vocab
one_hot = np.zeros(len(vocab), dtype=np.float32)
for token in surname:
one_hot[vocab.lookup_token(token)] = 1
return one_hot
@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)
for _, row in surname_df.iterrows():
for letter in row.surname:
surname_vocab.add_token(letter)
nationality_vocab.add_token(row.nationality)
return cls(surname_vocab, nationality_vocab)
@classmethod
def from_serializable(cls, contents):
"""
从序列化字典中实例化向量化器
Args:
contents (dict): 序列化的内容
Returns:
SurnameVectorizer 的一个实例
"""
surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])
nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab'])
return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab)
def to_serializable(self):
"""
将向量化器转换为可序列化的字典
Returns:
dict: 可序列化的词汇表字典
"""
return {'surname_vocab': self.surname_vocab.to_serializable(),
'nationality_vocab': self.nationality_vocab.to_serializable()}
1.3 The Surname Classifier Model
第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。
在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
class SurnameClassifier(nn.Module):
""" 用于姓氏分类的两层多层感知器 """
def __init__(self, input_dim, hidden_dim, output_dim):
"""
初始化分类器
Args:
input_dim (int): 输入向量的维度大小
hidden_dim (int): 第一层线性层的输出大小
output_dim (int): 第二层线性层的输出大小
"""
super(SurnameClassifier, self).__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x_in, apply_softmax=False):
"""
分类器的前向传播
Args:
x_in (torch.Tensor): 输入数据张量,形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用softmax激活函数的标志
如果与交叉熵损失一起使用,应为False
Returns:
结果张量,形状应为 (batch, output_dim)
"""
intermediate_vector = F.relu(self.fc1(x_in)) # 第一层线性变换后,使用ReLU激活函数
prediction_vector = self.fc2(intermediate_vector) # 第二层线性变换
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1) # 应用softmax激活函数
return prediction_vector
1.4 The Training Routine
虽然我们使用了不同的模型、数据集和损失函数,但是训练例程是相同的。
下面给出部分代码:
# 设置参数
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)
训练中最显著的差异与模型中输出的种类和使用的损失函数有关。在这个例子中,输出是一个多类预测向量,可以转换为概率。正如在模型描述中所描述的,这种输出的损失类型仅限于CrossEntropyLoss和NLLLoss。由于它的简化,我们使用了CrossEntropyLoss。
THE TRAINING LOOP
示例显示了使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。
# 将模型和类权重移动到指定设备
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):
# 清零梯度
optimizer.zero_grad()
# 前向传播计算输出
y_pred = classifier(batch_dict['x_surname'])
# 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# 反向传播计算梯度
loss.backward()
# 更新模型参数
optimizer.step()
# 计算准确率
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
# 更新进度条
train_bar.set_postfix(loss=running_loss, acc=running_acc, epoch=epoch_index)
train_bar.update()
train_state['train_loss'].append(running_loss)
train_state['train_acc'].append(running_acc)
# 验证阶段
dataset.set_split('val')
batch_generator = generate_batches(dataset, batch_size=args.batch_size, device=args.device)
running_loss = 0.0
running_acc = 0.0
classifier.eval()
for batch_index, batch_dict in enumerate(batch_generator):
# 前向传播计算输出
y_pred = classifier(batch_dict['x_surname'])
# 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.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")
1.5 Model Evaluation and Prediction
要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。
1.5.1 EVALUATING ON THE TEST DATASET
该模型对测试数据的准确性达到50%左右。如果在附带的notebook中运行训练例程,会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据,所以训练数据的性能并不代表新数据的性能。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。其主要原因是收缩的onehot向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量,但它丢弃了字符之间的顺序信息,这对于识别起源非常重要。
1.5.2 CLASSIFYING A NEW SURNAME
给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。注意,我们包含了apply_softmax标志,所以结果包含概率。模型预测,在多项式的情况下,是类概率的列表。我们使用PyTorch张量最大函数来得到由最高预测概率表示的最优类。
部分代码展示:
def predict_nationality(name, classifier, vectorizer):
vectorized_name = vectorizer.vectorize(name)# 将姓氏矢量化
vectorized_name = torch.tensor(vectorized_name).view(1, -1) # 将矢量化后的姓氏转换为张量,并调整形状为 (1, -1)
result = classifier(vectorized_name, apply_softmax=True) # 使用分类器进行预测,应用 softmax 激活函数
# 获取最大概率值及其对应的索引
probability_values, indices = result.max(dim=1)
index = indices.item()
# 根据索引查找预测的民族
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
probability_value = probability_values.item()
# 返回包含预测的民族和概率的字典
return {'nationality': predicted_nationality,
'probability': probability_value}
new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
预测结果:
1.5.3 RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME
不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测。
部分代码展示:
def predict_topk_nationality(name, classifier, vectorizer, k=5):
"""
预测姓氏的前k个可能国籍
Args:
name (str): 姓氏
classifier (nn.Module): 训练好的分类器
vectorizer (SurnameVectorizer): 向量化工具
k (int): 返回的前k个预测
Returns:
results (list): 包含国籍和概率的字典列表
"""
# 向量化姓名
vectorized_name = vectorizer.vectorize(name)
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 获取预测向量并应用softmax
prediction_vector = classifier(vectorized_name, apply_softmax=True)
# 获取前k个预测
probability_values, indices = torch.topk(prediction_vector, k=k)
# 转换为numpy数组
probability_values = probability_values.detach().numpy()[0]
indices = indices.detach().numpy()[0]
# 构建结果列表
results = []
for prob_value, index in zip(probability_values, indices):
nationality = vectorizer.nationality_vocab.lookup_index(index)
results.append({'nationality': nationality, 'probability': prob_value})
return results
# 输入新的姓氏并设置分类器为CPU
new_surname = input("输入一个姓氏进行分类: ")
classifier = classifier.to("cpu")
# 输入要查看的前k个预测
k = int(input("您想查看前多少个预测结果? "))
if k > len(vectorizer.nationality_vocab):
print("对不起!这个数字超过了我们拥有的国籍数量... 默认设为最大值 :)")
k = len(vectorizer.nationality_vocab)
# 获取预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)
# 打印预测结果
print("前 {} 个预测:".format(k))
print("===================")
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
预测结果:
2基于CNN处理姓氏分类
为了证明CNN的有效性,让我们应用一个简单的CNN模型来分类姓氏。这项任务的许多细节与前面的MLP示例相同,但真正发生变化的是模型的构造和向量化过程。模型的输入,而不是我们在上一个例子中看到的收缩的onehot,将是一个onehot的矩阵。这种设计将使CNN能够更好地“view”字符的排列,并对在“示例:带有多层感知器的姓氏分类”中使用的收缩的onehot编码中丢失的序列信息进行编码。
2.1 The SurnameDataset
虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述,但建议参考“姓氏数据集”来了解它的描述。尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。
我们使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因。首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。
部分代码展示:
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
初始化数据集
Args:
surname_df (pandas.DataFrame): 数据集
vectorizer (SurnameVectorizer): 用于数据向量化的工具
"""
self.surname_df = surname_df
self._vectorizer = vectorizer
self.train_df = self.surname_df[self.surname_df.split == 'train']
self.val_df = self.surname_df[self.surname_df.split == 'val']
self.test_df = self.surname_df[self.surname_df.split == 'test']
self._lookup_dict = {
'train': (self.train_df, len(self.train_df)),
'val': (self.val_df, len(self.val_df)),
'test': (self.test_df, len(self.test_df))
}
self.set_split('train')
# 类别权重
class_counts = surname_df.nationality.value_counts().to_dict()
def sort_key(item):
return self._vectorizer.nationality_vocab.lookup_token(item[0])
sorted_counts = sorted(class_counts.items(), key=sort_key)
frequencies = [count for _, count in sorted_counts]
self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)
@classmethod
def load_dataset_and_make_vectorizer(cls, surname_csv):
"""加载数据集并从头创建新的向量化器
Args:
surname_csv (str): 数据集的位置
Returns:
SurnameDataset 的一个实例
"""
surname_df = pd.read_csv(surname_csv)
train_surname_df = surname_df[surname_df.split == 'train']
return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))
@classmethod
def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
"""加载数据集和对应的向量化器(向量化器已缓存以便重用)
Args:
surname_csv (str): 数据集的位置
vectorizer_filepath (str): 已保存的向量化器的位置
Returns:
SurnameDataset 的一个实例
"""
surname_df = pd.read_csv(surname_csv)
vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
return cls(surname_df, vectorizer)
@staticmethod
def load_vectorizer_only(vectorizer_filepath):
"""从文件中加载向量化器
Args:
vectorizer_filepath (str): 序列化向量化器的位置
Returns:
SurnameVectorizer 的一个实例
"""
with open(vectorizer_filepath) as fp:
return SurnameVectorizer.from_serializable(json.load(fp))
def save_vectorizer(self, vectorizer_filepath):
"""将向量化器保存到磁盘
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):
"""返回指定索引的数据点
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 的生成器函数,确保每个张量在正确的设备位置上
Args:
dataset (Dataset): PyTorch 数据集
batch_size (int): 批次大小
shuffle (bool): 是否随机打乱数据
drop_last (bool): 是否丢弃最后一个不完整的批次
device (str): 设备(如 "cpu" 或 "cuda")
Yields:
每个批次的数据字典
"""
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] = tensor.to(device)
yield out_data_dict
2.2 Vocabulary, Vectorizer, and DataLoader
在本例中,尽管词汇表和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): 与令牌对应的整数索引
"""
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):
"""
将姓氏向量化为one-hot编码矩阵
Args:
surname (str): 姓氏
Returns:
one_hot_matrix (np.ndarray): one-hot编码矩阵
"""
one_hot_matrix_size = (len(self.surname_vocab), self._max_surname_length)
one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
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 _, row in surname_df.iterrows():
max_surname_length = max(max_surname_length, len(row.surname))
for letter in row.surname:
surname_vocab.add_token(letter)
nationality_vocab.add_token(row.nationality)
return cls(surname_vocab, nationality_vocab, max_surname_length)
@classmethod
def from_serializable(cls, contents):
"""
从序列化字典实例化向量化器
Args:
contents (dict): 序列化的内容
Returns:
SurnameVectorizer 的一个实例
"""
surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])
nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab'])
return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab,
max_surname_length=contents['max_surname_length'])
def to_serializable(self):
"""
将向量化器转换为可序列化的字典
Returns:
dict: 可序列化的词汇表字典
"""
return {'surname_vocab': self.surname_vocab.to_serializable(),
'nationality_vocab': self.nationality_vocab.to_serializable(),
'max_surname_length': self._max_surname_length}
2.3 Reimplementing the SurnameClassifier with Convolutional Networks
我们在本例中使用的模型是使用我们在“卷积神经网络”中介绍的方法构建的。实际上,我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。正如在示例中所看到的,它与我们在“卷积神经网络”中引入的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(),
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): 输入数据张量。形状应为 (batch, initial_num_channels, max_surname_length)
apply_softmax (bool): 是否应用softmax激活的标志
如果与交叉熵损失一起使用,应为False
Returns:
结果张量。形状应为 (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
2.4 The Training Routine
训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。此时,这是本书到目前为止的第三个训练例程实现,应该将这个操作序列内部化。对于这个例子,我们将不再详细描述具体的训练例程,因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同。但是,输入参数是不同的,可以在示例中看到。
# 设置参数
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,
# 运行时选项
cuda=False,
reload_from_files=False,
expand_filepaths_to_save_dir=True,
catch_keyboard_interrupt=True
)
# 扩展文件路径
if args.expand_filepaths_to_save_dir:
args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)
args.model_state_file = os.path.join(args.save_dir, args.model_state_file)
print("扩展的文件路径: ")
print("\t{}".format(args.vectorizer_file))
print("\t{}".format(args.model_state_file))
# 检查 CUDA 可用性
if not torch.cuda.is_available():
args.cuda = False
args.device = torch.device("cuda" if args.cuda else "cpu")
print("使用 CUDA: {}".format(args.cuda))
def set_seed_everywhere(seed, cuda):
"""
设置随机种子以确保结果的可重复性
Args:
seed (int): 随机种子
cuda (bool): 是否使用 CUDA
"""
np.random.seed(seed)
torch.manual_seed(seed)
if cuda:
torch.cuda.manual_seed_all(seed)
def handle_dirs(dirpath):
"""
确保目录存在
Args:
dirpath (str): 目录路径
"""
if not os.path.exists(dirpath):
os.makedirs(dirpath)
# 设置随机种子以确保可重复性
set_seed_everywhere(args.seed, args.cuda)
# 处理目录
handle_dirs(args.save_dir)
2.5 Model Evaluation and Prediction
要理解模型的性能,需要对性能进行定量和定性的度量。下面将描述这两个度量的基本组件。建议你扩展它们,以探索该模型及其所学习到的内容。
2.5.1Evaluating on the Test Dataset
如“示例:带有多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样,执行评估的代码也没有变化。总之,调用分类器的eval()
方法来防止反向传播,并迭代测试数据集。与 MLP 约 50% 的性能相比,该模型的测试集性能准确率约为56%。尽管这些性能数字绝不是这些特定架构的上限,但是通过一个相对简单的CNN模型获得的改进应该足以让您在文本数据上尝试CNNs。
2.5.2Classifying or retrieving top predictions for a new surname
在本例中,predict_nationality()
函数的一部分发生了更改,如示例所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()
函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在predict_topk_nationality()
函数中。
部分代码展示:
def predict_nationality(surname, classifier, vectorizer):
"""
从新姓氏预测国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifier): 分类器实例
vectorizer (SurnameVectorizer): 对应的向量化器
Returns:
包含最可能国籍及其概率的字典
"""
# 将姓氏向量化
vectorized_surname = vectorizer.vectorize(surname)
# 将向量化后的姓氏转换为张量,并增加一个维度
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
# 使用分类器进行预测,并应用 softmax 激活函数
result = classifier(vectorized_surname, apply_softmax=True)
# 获取最大概率值及其对应的索引
probability_values, indices = result.max(dim=1)
index = indices.item()
probability_value = probability_values.item()
# 根据索引查找预测的国籍
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
# 返回包含预测国籍和概率的字典
return {'nationality': predicted_nationality, 'probability': probability_value}
new_surname = input("Enter a surname to classify: ")
classifier = classifier.cpu()# 将分类器移到 CPU 上进行推理
prediction = predict_nationality(new_surname, classifier, vectorizer)# 预测姓氏对应的国籍
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
预测结果:
def predict_topk_nationality(surname, classifier, vectorizer, k=5):
"""
预测一个新姓氏对应的前K个国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifier): 分类器的实例
vectorizer (SurnameVectorizer): 对应的向量化器
k (int): 返回的前K个国籍
Returns:
list of dictionaries, 每个字典包含一个国籍和对应的概率
"""
# 将姓氏向量化
vectorized_surname = vectorizer.vectorize(surname)
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)
# 使用分类器进行预测,并应用softmax激活函数
prediction_vector = classifier(vectorized_surname, apply_softmax=True)
# 获取前K个预测结果
probability_values, indices = torch.topk(prediction_vector, k=k)
# 返回的尺寸是 1, k
probability_values = probability_values[0].detach().numpy()
indices = indices[0].detach().numpy()
# 构建结果列表
results = []
for kth_index in range(k):
nationality = vectorizer.nationality_vocab.lookup_index(indices[kth_index])
probability_value = probability_values[kth_index]
results.append({'nationality': nationality, 'probability': probability_value})
return results
# 获取用户输入的新姓氏
new_surname = input("请输入要分类的姓氏: ")
# 获取用户希望查看的前K个预测
k = int(input("您想查看多少个预测结果? "))
if k > len(vectorizer.nationality_vocab):
print("抱歉!这个数字超过了我们拥有的国籍数量... 默认设置为最大值 :)")
k = len(vectorizer.nationality_vocab)
# 获取预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)
# 打印预测结果
print("前 {} 个预测:".format(k))
print("===================")
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname, prediction['nationality'], prediction['probability']))
预测结果: