1 编写目的
姓氏分类问题是自然语言处理(NLP)中的一类问题,能够根据提供的姓氏来推断其可能的国家或地区背景。姓氏分类问题通常是一类分类任务,对于给定的姓氏分配到不同的国家或地区类别(标签)中。这个问题在语言学研究和人口统计学中很有意义,也在姓名识别和跨文化分析中有广泛的应用。
本篇使用前馈神经网络完成分类任务,展示了多层感知机(MLP)和卷积神经网络(CNN)如何学习姓氏的特征表示。
2 相关背景
2.1 多层感知机(MLP)
多层感知器(MLP)被认为是最基本的神经网络构建模块之一。感知器将数据向量作为输入,计算出一个输出值。在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。在PyTorch中,只需设置线性层中的输出特性的数量即可完成。MLP的另一个方面是,它将多个层与每个层之间非线性的结合在一起。
最简单的MLP,如图 1所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量,这是给定给模型的向量。在姓氏分类任务中,输入向量是给定姓氏的one-hot(one-hot编码详见本篇标题5 推荐阅读)表示。给定输入向量,第一个线性层计算一个隐藏向量——即图 1表示的第二阶段。使用这个隐藏的向量,第二个线性层计算一个输出向量,这就是第三阶段。在二分类这样的二进制任务中,输出向量仍然可以是1。在多类分类任务中,输出向量是类数量的大小。虽然在这个例子中,我们只展示了一个隐藏的向量,但是更多时候我们使用多个隐藏层、具有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。而我们使用数据集训练学习隐藏层的权重,即是求得输入向量与输出向量之间的映射关系,因此NLP能够解决分类问题。
图 1 一种具有两个线性层和三个表示阶段(输入向量、隐藏向量和输出向量)的MLP的可视化表示
MLP的优点在于添加了第二个线性层和允许模型学习一个线性分割的的中间表示——该属性能表示一个直线(或更一般的,一个超平面)以用来区分数据点落在线(或超平面)的哪一边。如果分类任务是线性可分的,它就能够学习具有特定属性的中间表示,这也是其建模能力的精髓。
2.2 卷积神经网络(CNN)
CNN的名称和基本功能源于经典的数学运算卷积。卷积已经应用于各种工程学科,包括数字信号处理和计算机图形学。一般来说,卷积使用程序员指定的参数。这些参数被指定来匹配一些功能设计,如突出边缘或抑制高频声音。事实上,许多Photoshop滤镜都是应用于图像的固定卷积运算。然而,在深度学习中,我们从数据中学习卷积滤波器的参数,因此它对于解决当前的任务是最优的。
要了解CNN,我们首先要知道卷积运算规则,图 2展示了滤波器filter的一次卷积过程。
图 2 filter滤波器卷积运算
图 2 使用的是一个3*3大小的卷积核(kernel_size=3),可以看到,通过卷积层后输出张量在收缩。即卷积核的大小决定了输出张量的大小,此外,输出张量大小还受步长(stride)限制,见图 3。
图 3 应用于具有超参数步长的输入的kernel_size=2的卷积核等于2。这会导致内核采取更大的步骤,从而产生更小的输出矩阵。对于更稀疏地对输入矩阵进行二次采样非常有用。
在数字图像处理中,我们可以运用卷积来提取图像特征,输入是待提取图像的灰度(RBG)矩阵。而对于姓氏分类问题,同样的,我们也可以使用姓氏的one-hot向量矩阵作为卷积输入。为此,我们实现一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器,列的数量则是onehot向量的大小(词汇表的大小)。
CNN对图像数据具有良好的特征学习能力,据此通过将待学习数据以向量矩阵的形式排列,我们可以让CNN也学习其特征,这也是我们能够使用CNN进行姓氏分类的原因。
3 设计思路
3.1 数据预处理
3.1.1 划分数据集
由于这里我使用的数据集并未整理成国籍-姓氏的对应向量,首先我们对原始字典进行重排以便输入,然后以训练集:测试集:验证集 = 0.7: 0.15: 0.15 的比例划分数据集。接着将处理好的新字典保存到"data/surnames/surnames_with_splits.csv"(可自行选择数据读取路径及保存路径)。
import collections
import numpy as np
import pandas as pd
import re
from argparse import Namespace
# 定义命令行参数
args = Namespace(
# 原始数据集文件路径
raw_dataset_csv="data/surnames/surnames.csv",
# 训练集、验证集和测试集的比例
train_proportion=0.7,
val_proportion=0.15,
test_proportion=0.15,
# 处理后的数据集文件路径
output_munged_csv="data/surnames/surnames_with_splits.csv",
# 随机种子
seed=1337
)
# 读取原始数据
surnames = pd.read_csv(args.raw_dataset_csv, header=0)
# 按国籍拆分数据集、创建字典
by_nationality = collections.defaultdict(list)
# 遍历姓氏数据集中的每一行
for _, row in surnames.iterrows():
# 获取当前行的国籍
nationality = row.nationality
# 将当前行的数据以字典的形式添加到对应国籍的列表中
by_nationality[nationality].append(row.to_dict())
# 创建分割后的数据集
final_list = []
# 设置随机种子,确保实验可重复
np.random.seed(args.seed)
# 遍历每个国籍对应的姓氏列表
for _, item_list in sorted(by_nationality.items()):
# 随机打乱列表顺序
np.random.shuffle(item_list)
# 计算训练集、验证集和测试集的样本数
n = len(item_list)
n_train = int(args.train_proportion*n)
n_val = int(args.val_proportion*n)
n_test = int(args.test_proportion*n)
# 给每个数据点添加 'split' 属性,标记其所属的数据集
for item in item_list[:n_train]:
item['split'] = 'train'
for item in item_list[n_train:n_train+n_val]:
item['split'] = 'val'
for item in item_list[n_train+n_val:]:
item['split'] = 'test'
# 将处理好的数据点添加到最终列表中
final_list.extend(item_list)
# 将分割数据写入文件
final_surnames = pd.DataFrame(final_list)
3.1.2 整形映射处理
划分完数据集后,我们仍然不能将其作为输入,因为此时的数据不是整形表示,无法被计算机很好学习。为此我们定义一个类将姓氏由字符映射到整形并建立索引以便进行ont-hot编码,将这个类命名为Vocabulary,要求它能够添加新索引、根据输入形式获得映射整形、将序列化的字典重新实体化为词汇表。
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_index` 需要 >=0(已添加到词汇表中)以实现UNK功能
"""
if self.unk_index >= 0:
return self._token_to_idx.get(token, self.unk_index)
else:
return self._token_to_idx[token]
def lookup_index(self, index):
"""返回与索引关联的标记
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)
3.2 one-hot编码
有了Vocabulary将词汇表序列化后,我们就可以对姓氏进行one-hot编码了,定义SurnameVectorizer类完成这个操作。
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):
"""
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 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()}
对于MLP而言,这个类的定义是没问题的,我们返回的是一串姓氏的one-hot编码。然而,我们还要讨论CNN的姓氏分类问题,而CNN的输入需要将one-hot编码以矩阵输出,因此对于CNN我们需要对one-hot编码部分进行适当修改,返回独热编码的矩阵。
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.3 输入初始化
在准备好整形映射和one-hot编码的类后,我们就可以通过它们实现对输入数据的初始化了,同样定义一个初始化的类SurnameDataset便于后续使用。
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} # 返回国籍在词汇表中的索引
3.4 模型构建
我们可以直接使用PyTorch提供的神经网络模型框架即可,非常方便。
3.4.1 MLP
我们定义一个简单的MLP,只包含两个线性层,使用ReLU或Softmax作为激活函数。
class SurnameClassifier(nn.Module):
"""用于分类姓氏的 2 层多层感知机"""
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): 输入数据张量。
x_in.shape 应该是 (batch, input_dim)
apply_softmax (bool): Softmax 激活的标志
如果与交叉熵损失一起使用,应该为 False
Returns:
结果张量。tensor.shape 应该是 (batch, output_dim)
"""
# 通过第一个线性层并应用 ReLU 激活
intermediate_vector = F.relu(self.fc1(x_in))
# 通过第二个线性层得到预测向量
prediction_vector = self.fc2(intermediate_vector)
if apply_softmax:
# 如果需要,对预测向量应用 Softmax 激活
prediction_vector = F.softmax(prediction_vector, dim=1)
return prediction_vector
3.4.2 CNN
CNN的模型定义需要根据输入和出处张量的大小具体分析。这里我的输入大小是(batch, initial_num_channels, max_surname_length), 那么我定义含四个卷积层和一个全连接层的网络,每层卷积核大小都设为3,第二、三层卷积设置步长为2,这样就能得到(batch, num_classes)的输出大小。
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): 输入数据张量。
x_surname.shape 应该是 (batch, initial_num_channels, max_surname_length)
apply_softmax (bool): Softmax 激活的标志
如果与交叉熵损失一起使用,应该为 False
Returns:
结果张量。tensor.shape 应该是 (batch, num_classes)
"""
# 通过卷积网络获得特征
features = self.convnet(x_surname).squeeze(dim=2)
# 通过全连接层得到预测向量
prediction_vector = self.fc(features)
# 如果需要,应用 Softmax 激活
if apply_softmax:
prediction_vector = F.softmax(prediction_vector, dim=1)
return prediction_vector
3.5 模型训练
由于输入初始化及模型构建的类名我并未对MLP于CNN区分,方便这里直接调用不必重复大量代码,实际编程建议进行区分。按以下5个步骤训练,设置超args.num_epochs作为停止条件。由于模型较为简单,我设置停止条件为训练100轮以获得更好的模型性能,每次训练完后都计算准确率并保存性能更好的模型。
步骤:
1. 梯度清零
2. 计算输出
3. 计算损失
4. 反向传播计算梯度
5. 使用优化器更新参数
classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)
# 定义损失函数和优化器
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
mode='min', factor=0.5,
patience=1)
# 初始化训练状态
train_state = make_train_state(args)
# 创建进度条
epoch_bar = tqdm_notebook(desc='training routine',
total=args.num_epochs,
position=0)
# 设置训练和验证数据集
dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train',
total=dataset.get_num_batches(args.batch_size),
position=1,
leave=True)
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val',
total=dataset.get_num_batches(args.batch_size),
position=1,
leave=True)
try:
for epoch_index in range(args.num_epochs):
train_state['epoch_index'] = epoch_index
# 训练阶段
dataset.set_split('train')
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device)
running_loss = 0.0
running_acc = 0.0
classifier.train()
for batch_index, batch_dict in enumerate(batch_generator):
# 5个步骤的训练例程:
# 1. 梯度清零
optimizer.zero_grad()
# 2. 计算输出
y_pred = classifier(batch_dict['x_surname'])
# 3. 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# 4. 反向传播计算梯度
loss.backward()
# 5. 使用优化器更新参数
optimizer.step()
# 计算准确率
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
# 更新进度条
train_bar.set_postfix(loss=running_loss, acc=running_acc,
epoch=epoch_index)
train_bar.update()
train_state['train_loss'].append(running_loss)
train_state['train_acc'].append(running_acc)
# 验证阶段
dataset.set_split('val')
batch_generator = generate_batches(dataset,
batch_size=args.batch_size,
device=args.device)
running_loss = 0.
running_acc = 0.
classifier.eval()
for batch_index, batch_dict in enumerate(batch_generator):
# 计算输出
y_pred = classifier(batch_dict['x_surname'])
# 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])
loss_t = loss.to("cpu").item()
running_loss += (loss_t - running_loss) / (batch_index + 1)
# 计算准确率
acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
running_acc += (acc_t - running_acc) / (batch_index + 1)
val_bar.set_postfix(loss=running_loss, acc=running_acc,
epoch=epoch_index)
val_bar.update()
train_state['val_loss'].append(running_loss)
train_state['val_acc'].append(running_acc)
# 更新训练状态
train_state = update_train_state(args=args, model=classifier,
train_state=train_state)
# 更新学习率
scheduler.step(train_state['val_loss'][-1])
# 如果满足提前停止条件, 则退出循环
if train_state['stop_early']:
break
# 重置进度条
train_bar.n = 0
val_bar.n = 0
epoch_bar.update()
except KeyboardInterrupt:
print("Exiting loop")
图4 模型训练过程示例
3.6 模型简单测试
调用我们先前训练好的模型,就可以实现简单的姓氏分类了。更进一步的,我们可以让模型输出该姓氏概率最可能得Top-N个国籍。
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数组
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: ")
# 将分类器模型移动到CPU设备上
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']))
图5 对于姓氏McMahan,MLP预测最可能的前十个国籍
图5 对于姓氏McMahan,CNN预测最可能的前五个国籍
4 小结
从本篇第三大节的排版也能看到,使用深度学习——不论是MLP还是CNN,其主要思路是相近的。不同点在于模型的构建,它决定着我们的输入向量与输出向量的大小。以本篇使用的MLP与CNN为例,MLP的输入是one-hot向量,而CNN的输入则是独热编码的矩阵,这就导致了我们需要严格考虑CNN卷积层数、卷积核大小以及步长,这也是最容易出问题的地方。
总的来说,本篇构建的MLP和CNN都相对简单,对于这两种神经网络的讲解也并未深入。感兴趣的朋友可以自行学习,这里仅做推荐(见6 推荐阅读)。
5 参考文献
CNN笔记:通俗理解卷积神经网络_cnn卷积神经网络-CSDN博客