这是一篇实验博客——数据集、代码均未完整公示,谨展示基本训练流程,请客官谨慎阅读。
前言
在上次实验中,我们通过观察感知器来介绍神经网络的基础,感知器是现存最简单的神经网络。感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如,查看图1中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(也称为线性可分)。在这个例子中,感知器失败了。
图1 XOR数据集中的两个类绘制为圆形和星形。请注意,没有任何一行可以分隔这两个类。
在这一实验中,我们将探索传统上称为前馈网络的神经网络模型,以及两种前馈神经网络:多层感知器和卷积神经网络。多层感知器在结构上扩展了我们在实验3中研究的简单感知器,将多个感知器分组在一个单层,并将多个层叠加在一起。我们稍后将介绍多层感知器,并在“示例:带有多层感知器的姓氏分类”中展示它们在多层分类中的应用。
本实验研究的第二种前馈神经网络,卷积神经网络,在处理数字信号时深受窗口滤波器的启发。通过这种窗口特性,卷积神经网络能够在输入中学习局部化模式,这不仅使其成为计算机视觉的主轴,而且是检测单词和句子等序列数据中的子结构的理想候选。我们在“卷积神经网络”中概述了卷积神经网络,并在“示例:使用CNN对姓氏进行分类”中演示了它们的使用。
在本实验中,多层感知器和卷积神经网络被分组在一起,因为它们都是前馈神经网络,并且与另一类神经网络——递归神经网络(RNNs)形成对比,递归神经网络(RNNs)允许反馈(或循环),这样每次计算都可以从之前的计算中获得信息。在实验6和实验7中,我们将介绍RNNs以及为什么允许网络结构中的循环是有益的。
在我们介绍这些不同的模型时,需要理解事物如何工作的一个有用方法是在计算数据张量时注意它们的大小和形状。每种类型的神经网络层对它所计算的数据张量的大小和形状都有特定的影响,理解这种影响可以极大地有助于对这些模型的深入理解。
本次实验环境:
- Python 3.6.7
- 本次实验基于torch架构,适合具有一定基础的读者阅读。
- 本次实验适合具有一定NLP基础的读者阅读
一、The Multilayer Perceptron(多层感知器)
多层感知器(MLP)被认为是最基本的神经网络构建模块之一。最简单的MLP是对第3章感知器的扩展。感知器将数据向量作为输入,计算出一个输出值。在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。在PyTorch中,正如您稍后将看到的,这只需设置线性层中的输出特性的数量即可完成。MLP的另一个方面是,它将多个层与每个层之间的非线性结合在一起。
最简单的MLP,如图2所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。在“示例:对餐馆评论的情绪进行分类”中,输入向量是Yelp评论的一个收缩的one-hot表示。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。在像Yelp评论分类这样的二进制任务中,输出向量仍然可以是1。在多类设置中,将在本实验后面的“示例:带有多层感知器的姓氏分类”一节中看到,输出向量是类数量的大小。虽然在这个例子中,我们只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。
图2 一种具有两个线性层和三个表示阶段(输入向量、隐藏向量和输出向量)的MLP的可视化表示
mlp的力量来自于添加第二个线性层和允许模型学习一个线性分割的的中间表示——该属性的能表示一个直线(或更一般的,一个超平面)可以用来区分数据点落在线(或超平面)的哪一边的。学习具有特定属性的中间表示,如分类任务是线性可分的,这是使用神经网络的最深刻后果之一,也是其建模能力的精髓。Torch中包含各种现成的函数与类,可以让我们快速便捷的直接调用MLP。
二、实验步骤
本次实验我们将MLP应用于将姓氏分类到其原籍国的任务。
2.1 数据集处理
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集。
图3 部分数据集展示
本实验中呈现的数据集类继承自PyTorch的数据集类,因此,我们需要实现两个函数:getitem
方法,它在给定索引时返回一个数据点;以及len方法,该方法返回数据集的长度。
代码如下所示:
class SurnameDataset(Dataset):
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}
接下来我们要将姓名向量化,如果读者对为什么要向量化不理解,请入门学习NLP。我们在vectorize函数中将surname转换为one-hot编码。
对于vectorize函数:
- 初始化一个与词汇表长度相同的零向量(
one_hot
)。 - 遍历姓氏中的每个字符,将对应词汇表位置的值设为1。
- 返回独热编码向量。
对于from_dataframe函数:
- 目的:创建一个
SurnameVectorizer
实例。 - 参数:
surname_df
:包含姓氏和国籍。
- 操作:
- 创建
surname_vocab
对象 - 创建
nationality_vocab
对象 - 遍历数据每一行,将姓氏中的每个字符添加到
surname_vocab
,并将国籍添加到nationality_vocab
。 - 返回一个使用构建的词汇表初始化的
SurnameVectorizer
实例。
- 创建
class SurnameVectorizer(object):
def __init__(self, surname_vocab, nationality_vocab):
self.surname_vocab = surname_vocab
self.nationality_vocab = nationality_vocab
def vectorize(self, surname):
"""Vectorize the provided surname
Args:
surname (str): the surname
Returns:
one_hot (np.ndarray): a collapsed one-hot encoding
"""
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):
"""Instantiate the vectorizer from the dataset dataframe
Args:
surname_df (pandas.DataFrame): the surnames dataset
Returns:
an instance of the 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)
2.2 模型搭建
本实验搭建MLP模型,具体细节可见前言部分。
在这里使用两层MLP,第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失(最常用的分类损失函数,它可以提高正确分类的概率同时压低其他分类的概率,如果有不了解的可以自行学习相关知识)。
我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
激活函数选择了Relu,在分类任务中这是比较常使用的激活函数,你也可以更换成其他激活函数比如LeakyRelu,在激活函数的选取中你可能会了解到梯度消失梯度爆炸等问题,欢迎自行探讨。
torch包含了各种简单实用的工具,比如torch.nn.functional,可以让我们直接调用softmax函数而不必手敲额外的代码去计算每一类的概率。感谢所有无私的开源者。
import torch.nn as nn
import torch.nn.functional as F
class SurnameClassifier(nn.Module):
""" A 2-layer Multilayer Perceptron for classifying surnames """
def __init__(self, input_dim, hidden_dim, output_dim):
"""
Args:
input_dim (int): the size of the input vectors
hidden_dim (int): the output size of the first Linear layer
output_dim (int): the output size of the second Linear layer
"""
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):
"""The forward pass of the classifier
Args:
x_in (torch.Tensor): an input data tensor.
x_in.shape should be (batch, input_dim)
apply_softmax (bool): a flag for the softmax activation
should be false if used with the Cross Entropy losses
Returns:
the resulting tensor. tensor.shape should be (batch, output_dim)
"""
intermediate_vector = F.relu(self.fc1(x_in))
prediction_vector = self.fc2(intermediate_vector)
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1)
return prediction_vector
2.3 模型训练
下述主要用于管理和跟踪模型的训练过程,特别是包含早停(Early Stopping)和模型检查点(Model Checkpoint)功能。当然这一部分不是很重要,如果不感兴趣可以直接跳过不影响理解。
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, # 测试集损失值,初始值设为-1
'test_acc': -1, # 测试集准确率,初始值设为-1
'model_filename': args.model_state_file # 模型文件名
}
def update_train_state(args, model, train_state):
"""Handle the training state updates.
Components:
- Early Stopping: Prevent overfitting.
- Model Checkpoint: Model is saved if the model is better
:param args: main arguments
:param model: model to train
:param train_state: a dictionary representing the training state values
:returns:
a new train_state
"""
# Save one model at least
if train_state['epoch_index'] == 0:
torch.save(model.state_dict(), train_state['model_filename'])
train_state['stop_early'] = False
# Save model if performance improved
elif train_state['epoch_index'] >= 1:
loss_tm1, loss_t = train_state['val_loss'][-2:]
# If loss worsened
if loss_t >= train_state['early_stopping_best_val']:
# Update step
train_state['early_stopping_step'] += 1
# Loss decreased
else:
# Save the best model
if loss_t < train_state['early_stopping_best_val']:
torch.save(model.state_dict(), train_state['model_filename'])
# Reset early stopping step
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
以下属于初始化内容,包括数据集的读取,创建分类器,姓氏向量化,代码如下所示:
if args.reload_from_files:
# training from a checkpoint
print("Reloading!")
dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
args.vectorizer_file)
else:
# create dataset and vectorizer
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))
在MLP的Linear中隐藏层节点数量可以自己设置。当然你第一层输入节点的维度要跟姓氏向量化后的维度保持一致咯。
现在开始训练!定义好分类器,数据集,并将他们转移到GPU上进行加速训练,当然如果你的配置没有GPU,将device改成CPU即可。定义好损失函数——交叉熵损失、优化器选择Adam,这是一个比较常用的优化器,优化器用于计算梯度更新参数。设置学习率调度器,前期可以使用一个较大的学习率进行搜索,当指标提升微弱或出现降低的势头时降低学习率,这是一种比较常用的策略,当然关于学习率的调整还有‘poly’、‘step’等方法,若读者感兴趣可自行上网查询。下面的代码是关于打印训练状态的,了解即可。
# 将分类器移动到指定的设备上,如GPU或CPU
classifier = classifier.to(args.device)
# 将数据集的类别权重移动到指定的设备上
dataset.class_weights = dataset.class_weights.to(args.device)
# 定义损失函数为交叉熵损失函数,使用数据集中的类别权重
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
# 定义优化器为Adam优化器,学习率为args.learning_rate,优化的参数为分类器的参数
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
# Iterate over training dataset
# setup: batch generator, set loss and acc to 0, set train mode on
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):
# the training routine is these 5 steps:
# --------------------------------------
# step 1. zero the gradients
optimizer.zero_grad()
# step 2. compute the output
y_pred = classifier(batch_dict['x_surname'])
# step 3. compute the loss
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# step 4. use loss to produce gradients
loss.backward()
# step 5. use optimizer to take gradient step
optimizer.step()
# -----------------------------------------
# compute the accuracy
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
# update bar
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)
# Iterate over val dataset
# setup: batch generator, set loss and acc to 0; set eval mode on
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):
# compute the output
y_pred = classifier(batch_dict['x_surname'])
# step 3. compute the loss
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.to("cpu").item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# compute the accuracy
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")
训练好模型要进行评估,可以使用model.eval()进行切换,对于某些策略来说在模型训练和评估使用的是不同的方法,比如batchnormal、dropout,读者感兴趣可以自行查询,model.eval()可以帮助我们自动切换模式。
# compute the loss & accuracy on the test set using the best available model
# 加载模型的状态字典
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):
# compute the output
y_pred = classifier(batch_dict['x_surname'])
# compute the loss
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# compute the accuracy
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
总结
以上就是今天要讲的内容,本文仅仅简单介绍了MLP的使用,使用了一个比较小的数据集进行实验,读者可以自行更换数据集。