一、单层感知机
单层感知机是一种最简单的神经网络,可以利用其对一些线性可分的问题进行求解,即生成一条可以完美分割两个类别的分割线。
二、多层感知机
前文说过,单层感知机可以很好的解决线性可分问题,但无法求解非线性可分问题,非线性可分问题如下图所示
对此,多层感知机应运而生。所谓多层感知机就是利用多个感知机对同一个问题进行多次划分,每次划分可以将一个问题切分成两类,使得问题中的同一类处在同一个象限,不同类处在不同象限
下面我们将定义一个多层感知机
import torch.nn as nn
import torch.nn.functional as F
class MultilayerPerceptron(nn.Module): # 定义了类的初始化方法,接受三个参数:input_dim表示输入特征的维度,hidden_dim表示隐藏层的维度,output_dim表示输出的维度
def __init__(self, input_dim, hidden_dim, output_dim):
super(MultilayerPerceptron, self).__init__()
# 定义了一个全连接层(fc1),输入维度为input_dim,输出维度为hidden_dim
self.fc1 = nn.Linear(input_dim, hidden_dim)
# 定义了另一个全连接层(fc2),输入维度为hidden_dim,输出维度为output_dim。
self.fc2 = nn.Linear(hidden_dim, output_dim)
# 定义了前向传播方法,接受输入张量x_in和一个可选的apply_softmax参数,默认为False
def forward(self, x_in, apply_softmax=False):
# 对输入数据进行第一个全连接层的运算,并应用ReLU激活函数
intermediate = F.relu(self.fc1(x_in))
# 对第一隐藏层的输出进行第二个全连接层的运算,得到最终输出
output = self.fc2(intermediate)
# 如果apply_softmax为True,则对输出应用softmax函数
if apply_softmax:
output = F.softmax(output, dim=1)
return output
初始化我们定义的感知机
batch_size = 2 # 每次训练使用的样本量为2
input_dim = 3 # 输入维度为3
hidden_dim = 100 # 隐藏层维度为100
output_dim = 4 # 输出层维度为4
# 初始化我们上述定义的模型
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
print(mlp)
运行后可以看到我们定义的模型的各层参数
三、前置知识展示
3.1、softmax
当数据经过模型的处理后,得到的输出为模型预测的分数,但是该分数不利于我们进行多分类任务的预测,故可以使用softmax函数将输出值转化为对应的概率,softmax的公式如下
表示模型对第j类的预测值
3.2、交叉熵
交叉熵(Cross Entropy)是一种用来衡量两个概率分布之间差异的数学方法。在分类问题中,交叉熵常用作损失函数,用于衡量模型的预测分布与真实分布之间的差异,从而指导模型参数的优化。交叉熵的计算公式为
p()表示第i类数据经过softmax函数后的概率值
3.3、早停
早停(Early stopping)是指在训练机器学习模型时提前停止模型的训练过程,以防止模型在训练集上过拟合,并在验证集上性能下降。早停是一种用于防止模型过拟合的正则化技术
在进行模型训练时,通常会将数据集分为训练集和验证集,模型在训练集上训练,并同时在验证集上进行验证。通过监控模型在验证集上的性能指标(如准确率、损失函数值等),可以判断模型是否出现过拟合情况,当模型的性能开始下降时,说明出现了过拟合,立即停止训练。
3.4、随机失活
随机失活(Dropout)是一种常用于深度学习模型中的正则化技术,旨在减少模型的过拟合。在随机失活中,以一定概率(通常为0.5)将神经网络中的一些神经元的输出置零,即将它们“丢弃”,这样可以在训练过程中减少神经元之间的共适应性,从而提高模型的泛化能力。
四、基于姓氏的国籍分类任务
4.1、神经网络模型
本次任务所使用的神经网络模型如下,有两层全连接层,输入的向量为原始数据经过预处理后的向量,输出为该姓氏属于各个国籍对应的分数
4.2、数据集加载与数据预处理
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
为了创建最终的数据集,我们从包含的版本处理较少的版本开始,并执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为此,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。
我们可以使用pytorch里的data方法加载数据集
torch.utils.data
之后对数据集进行划分、向量化、按定义大小进行批次聚集,得到我们想要的向量化的minibatches
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_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 = {}
for name, tensor in data_dict.items():
out_data_dict[name] = data_dict[name].to(device)
yield out_data_dict
4.3、词典生成与向量化
我们定义一个SurnameVectorizer类,用于将我们的姓氏数据转化为向量,并创建一张词典
首先我们需要将输入的姓氏数据转化为独热编码,并将所有序列拼接成一份词典
def vectorize(self, surname):
# 将姓氏转换为独热编码向量
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):
# 从数据框创建姓氏及国籍词汇表
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()}
该类完整的代码如下
class SurnameVectorizer(object):
""" 协调词汇表并将其应用的矢量化器 """
def __init__(self, surname_vocab, nationality_vocab):
# 初始化函数,接收姓氏词汇表和国籍词汇表
self.surname_vocab = surname_vocab
self.nationality_vocab = nationality_vocab
def vectorize(self, surname):
# 将姓氏转换为独热编码向量
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):
# 从数据框创建姓氏及国籍词汇表
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()}
4.4、姓氏分类模型
在本次任务中,我们使用两个全连接层,第一个全连接层与第二个全连接层之间使用RELU函数进行激活,最后我们使用softmax函数将输出值转化为对应姓氏的概率。
定义姓氏分类模型如下
import torch.nn as nn
import torch.nn.functional as F
class SurnameClassifier(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
# 初始化函数,定义神经网络结构
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):
# 前向传播函数
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
4.5、训练前预设置
首先,创建一个目录来存放我们训练过程产生的数据
def handle_dirs(dirpath):
if not os.path.exists(dirpath):
os.makedirs(dirpath)
为了使得我们每次训练过程不完全相同,可以设置随机种子来确保
def set_seed_everywhere(seed, cuda):
np.random.seed(seed)
torch.manual_seed(seed)
定义命名空间参数与文件路径扩展
args = Namespace(
surname_csv="surnames_with_splits.csv",
vectorizer_file="vectorizer.json",
model_state_file="model.pth",
save_dir="model_storage/ch4/surname_mlp",
hidden_dim=300, # 隐藏层维度为300
seed=1337, # 随机种子
num_epochs=100, # 迭代次数为100
early_stopping_criteria=5, # 优化量小于5则早停
learning_rate=0.001, # 学习率为0.001
batch_size=64, # 每次迭代使用的样本量为64
cuda=False, # 不使用cuda
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
运行后结果如下
文件路径根据相对路径进行了拓展,方便我们将模型与数据分开保存,而CUDA为False表示本机器上没有可用的cuda
4.6、实例化
在本小结中,我们将实例化我们上面定义的各种模型与方法
首先定义一个定义了一个名为Vocabulary
的类,用于创建词汇表对象可以用于训练,该词汇表与上文提到的词典不同,上文说的词典是将已有的数据映射为向量,不可进行训练,而本结说的词汇表是将文本数据中的单词映射到索引以便于后续的处理
class Vocabulary(object):
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<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):
# 将Vocabulary对象序列化为字典
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到Vocabulary中
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):
# 添加多个token到Vocabulary中
return [self.add_token(token) for token in tokens]
def lookup_token(self, token):
# 查找token对应的index
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查找对应的token
if index not in self._idx_to_token:
raise KeyError("the index (%d) is not in the Vocabulary" % 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)
实例化我们上文定义的分类器,使用pytorch库中的交叉熵函数作为本模型的损失函数,并使用Adam法来优化我们的梯度计算,若机器有可用的GPU,则将模型放在GPU上运行,否则使用CPU运行
import torch.optim as optim
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv) #加载数据集
vectorizer = dataset.get_vectorizer() # 将数据集转化为张量
# 定义分类器
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
hidden_dim=args.hidden_dim,
output_dim=len(vectorizer.nationality_vocab))
# 将模型放到CPU或在GPU上
classifier = classifier.to(args.device)
# 定义损失函数
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
# 使用adam梯度优化法优化梯度的计算
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
4.7、模型训练
在训练中,由于很多参数本身是不可见的,所有我们需要定义一个训练字典来跟踪训练过程各个参数的变化
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, # 当前epoch数
'train_loss': [], # 训练集损失记录
'train_acc': [], # 训练集准确率记录
'val_loss': [], # 验证集损失记录
'val_acc': [], # 验证集准确率记录
'test_loss': -1, # 测试集损失
'test_acc': -1, # 测试集准确率
'model_filename': args.model_state_file # 模型保存文件名
}
定义计算正确率的函数
def compute_accuracy(y_pred, y_target): # 计算准确率
_, y_pred_indices = y_pred.max(dim=1)
n_correct = torch.eq(y_pred_indices, y_target).sum().item()
return n_correct / len(y_pred_indices) * 100
定义更新训练状态的函数,实现早停功能,即在模型性能没有明显提升或出现过拟合时,根据验证集损失的变化来判断是否提前结束训练,以避免模型过拟合,保持模型的泛化能力
def update_train_state(args, model, train_state):
# 更新训练状态
if train_state['epoch_index'] == 0:
# 第一个epoch,保存模型参数
torch.save(model.state_dict(), train_state['model_filename'])
train_state['stop_early'] = False
elif train_state['epoch_index'] >= 1:
# 从第二个epoch开始
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'])
# 早停步数重置为0
train_state['early_stopping_step'] = 0
# 判断是否触发早停
train_state['stop_early'] = train_state['early_stopping_step'] >= args.early_stopping_criteria
return train_state
训练模型并打印训练结果
# 将分类器移至指定设备
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.
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")
4.8、模型测试
在上述模型训练完成后,定义一个predict_topk_nationality函数,将我们键入的姓氏进行向量化,通过模型得到预测的序列,并借由我们已经定义过的词典映射为对于的国籍,并输出预测值前五的预测结果
def predict_topk_nationality(name, classifier, vectorizer, k=5):
# 将名字转换为向量
vectorized_name = vectorizer.vectorize(name)
vectorized_name = torch.tensor(vectorized_name).view(1, -1)
# 使用分类器进行预测
prediction_vector = classifier(vectorized_name, apply_softmax=True)
# 获取概率最高的 k 个国籍及其对应的索引
probability_values, indices = torch.topk(prediction_vector, k=k)
# 将张量转换为 numpy 数组,返回的维度为 (1, k)
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
new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")
k = int(input("How many of the top predictions to see? "))
if k > len(vectorizer.nationality_vocab):
print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
k = len(vectorizer.nationality_vocab)
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)
print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
print("{} -> {} (p={:0.2f})".format(new_surname,
prediction['nationality'],
prediction['probability']))
可以看到,姓氏McMahan所属的国际最大概率为爱尔兰,排名前五的概率分别为爱尔兰、苏格兰、捷克、越南、德国,预测任务到此结束 .