基于前馈神经网络的姓氏分类(多层感知机、卷积神经网络)

一、引言

自然语言处理(NLP)是人工智能的一个分支,它致力于使计算机能够理解、解释和生成人类语言。随着深度学习技术的飞速发展,NLP领域也取得了巨大的进步,特别是在文本分类、机器翻译、情感分析等任务上。前馈神经网络(FNN),尤其是深度前馈网络,已经成为了解决这些任务的强有力工具之一。

在众多NLP任务中,文本分类是最基本也是最常见的一种形式,它涉及到将文本数据分配到一个或多个预定义的类别中。一个简单的例子就是姓氏分类,即将不同的姓名正确地归类到相应的姓氏类别中。这个任务虽然听起来简单,但实际上对于机器学习模型来说却是一个挑战,因为姓氏的分布可能非常广泛且不均匀,而且还存在多音字、生僻字等问题。

本篇博客将介绍如何使用基于前馈神经网络的模型(多层感知机和CNN)来处理姓氏分类问题。我们将从数据的预处理开始,逐步构建一个能够识别并分类中国姓氏的前馈神经网络模型,并探讨如何通过调整网络结构和参数来优化模型的性能。通过这个案例,我们不仅能够学习到如何使用前馈神经网络解决具体的NLP问题,还能够对神经网络在处理文本数据时的一些细节有更深入的了解。让我们一起探索前馈神经网络在姓氏分类任务中的应用吧!

二、感知器和多层感知器

2.1感知器

  感知器是最简单的神经网络单元。感知器在历史上是非常松散地模仿生物神经元的。就像生物神经元一样,有输入和输出,“信号”从输入流向输出,如图所示。

Image

每个感知器单元有一个输入(x),一个输出(y),和三个“旋钮”(knobs):一组权重(w),偏量(b),和一个激活函数(f)。权重和偏量都从数据学习,激活函数是精心挑选的取决于网络的网络设计师的直觉和目标输出。数学上,我们可以这样表示:

𝑦=𝑓(𝑤𝑥+𝑏)𝑦=𝑓(𝑤𝑥+𝑏)

通常情况下感知器有不止一个输入。我们可以用向量表示这个一般情况;即,x和w是向量,w和x的乘积替换为点积:

𝑦=𝑓(𝑤⃗ 𝑇𝑥⃗ +𝑏)

2.2多层感知器 MLP

2.2.1MLP的基本概念

多层感知器(MLP)是人工神经网络的一种基本形式。MLP由输入层、隐藏层和输出层组成,其中隐藏层可以有多个。每个节点都具有一个权重和偏置,节点之间的连接采用全连接方式。MLP的输入层接收原始数据,输出层产生最终结果。隐藏层的作用是提取输入数据的特征,并进行非线性变换。

Image

 一种具有两个线性层和三个表示阶段(输入向量、隐藏向量和输出向量)的MLP的可视化表示

2.2.2 MLP的工作原理

MLP的工作原理可以概括为以下几个步骤:

1. 输入层接收输入数据,将其传递给隐藏层。
2. 隐藏层对输入数据进行处理,通过激活函数产生输出。
3. 输出层对隐藏层的输出进行处理,通过激活函数产生最终结果。

激活函数是MLP中的关键组成部分,它决定了神经元的激活状态。常见的激活函数有Sigmoid、ReLU、Tanh等。激活函数的引入使得MLP具备了学习非线性关系的能力。

2.2.3激活函数

激活函数是神经网络中引入的非线性函数,用于捕获数据中的复杂关系。

 sigmoid激活函数

sigmoid 是神经网络历史上最早使用的激活函数之一。它取任何实值并将其压缩在0和1之间。数学上,sigmoid 的表达式如下:

    y=1/1+e^-x

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1) #创建一个从-5到5,步长为0.1的张量x
y = torch.sigmoid(x) #对x应用sigmoid函数,得到y
plt.plot(x.numpy(), y.numpy()) #绘制x y的图像
plt.show()

 

 tanh激活函数

tanh 激活函数是 sigmoid 在外观上的不同变体。

f(x)=tanhx={sinhx/coshx}{}=e^x-e^-x/e^x+e^-x 

tanh 只是 sigmoid 的一个线性变换。

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1)#创建一个从-5到5,步长为0.1的张量x
y = torch.tanh(x)#对x应用tanh函数,得到y
plt.plot(x.numpy(), y.numpy())#绘制x y的图像
plt.show()

三、卷积神经网络

3.1 CNN简介

CNN卷积神经网络(Convolutional Neural Network),是一种常用于图像和视频处理的深度学习模型。与传统神经网络相比,CNN 有着更好的处理图像和序列数据的能力,因为它能够自动学习图像中的特征,并提取出最有用的信息。CNN 的一个核心特点是卷积操作,它可以在图像上进行滑动窗口的计算,通过滤波器(又称卷积核)和池化层(Max Pooling)来提取出图像的特征。卷积操作可以有效地减少权重数量,降低计算量,同时也能够保留图像的空间结构信息。池化层则可以在不改变特征图维度的前提下,减少计算量,提高模型的鲁棒性。在神经网络中,激活函数用于引入非线性,使网络能够学习复杂的映射关系。如果不用激活函数,每一层输出都是上层输入的线性函数,无论神经网络有多少层,输出都是输入的线性组合。以下是一些常用的激活函数。常用的有:

3.2 CNN的典型结构

输入层:接收原始图像数据。图像通常由三个颜色通道(红、绿、蓝)组成,形成一个二维矩阵,表示像素的强度值。

卷积层和激活层  卷积层将输入图像与卷积核进行卷积操作。然后通过应用激活函数引入非线性。

这个过程中,有这么几个参数:

   a.深度depth:神经元个数,决定输出的depth厚度。同时代表滤波器个数。

   b.步长stride:决定滑动多少步可以到边缘。

   c.填充值zero-padding:在外围边缘补充若干圈0,方便从初始位置以步长为单位可以刚好滑到末尾位置,通俗地讲就是为了总长能被步长整除。

池化层:通过减小特征图的大小来减少计算复杂性。它通过选择池化窗口内的最大值或平均值来实现。

多层堆叠:CNN通常由多个卷积和池化层的堆叠组成,以逐渐提取更高级别的特征。深层次的特征可以表示更复杂的模式。

全连接和输出:全连接层将提取的特征映射转化为网络的最终输出。这可以是一个分类标签、回归值或其他任务的结果。

四、实验步骤

4.1基于多层感知机的姓氏分类

在本节中,我们将MLP应用于将姓氏分类到其原籍国的任务。我们首先对每个姓氏的字符进行拆分然后,我们使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的管道。我们在这个例子中引入了多类输出及其对应的损失函数。在描述了模型之后,我们完成了训练例程。

4.1.1 MLP的姓氏数据集

姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。

为了创建最终的数据集,我们从一个比课程补充材料中包含的版本处理更少的版本开始,并执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为此,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。

from torch.utils.data import Dataset

class SurnameDataset(Dataset):
    # Implementation is nearly identical to Section 3.5

    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} 

4.1.2 MLP的词汇表向量器

为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。这些数据结构与“Example: Classifying Sentiment of Restaurant Reviews”中使用的数据结构相同,它们举例说明了一种多态性,这种多态性将姓氏的字符标记与Yelp评论的单词标记相同对待。数据不是通过将字令牌映射到整数来向量化的,而是通过将字符映射到整数来向量化的。

THE VOCABULARY CLASS

本例中使用的词汇类与“example: Classifying Sentiment of Restaurant Reviews”中的词汇完全相同,该词汇类将Yelp评论中的单词映射到对应的整数。简要概述一下,词汇表是两个Python字典的协调,这两个字典在令牌(在本例中是字符)和整数之间形成一个双射;也就是说,第一个字典将字符映射到整数索引,第二个字典将整数索引映射到字符。add_token方法用于向词汇表中添加新的令牌,lookup_token方法用于检索索引,lookup_index方法用于检索给定索引的令牌(在推断阶段很有用)。与Yelp评论的词汇表不同,我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。

THE SURNAMEVECTORIZER

虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。实例化和使用非常类似于“示例:对餐馆评论的情绪进行分类”中的ReviewVectorizer,但有一个关键区别:字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。

#这段代码定义了一个名为SurnameVectorizer的类,用于将姓氏和国籍进行向量化表示。类中包含两个方法:vectorize和from_dataframe。
class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""
    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
#将输入的姓氏字符串转换为one-hot编码。首先创建一个全零的numpy数组,然后遍历姓氏中的每个字符,将其在词汇表中的索引位置设为1。最后返回这个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)
#从数据框中实例化一个SurnameVectorizer对象。首先创建两个词汇表对象,分别用于存储姓氏和国籍。然后遍历数据框的每一行,将姓氏中的每个字符添加到姓氏词汇表中,将国籍添加到国籍词汇表中。最后返回一个SurnameVectorizer实例。
        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)

4.1.3 MLP的分类模型

SurnameClassifier是本实验前面介绍的MLP的实现(示例4-7)。第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。

在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。 

import torch.nn as nn
import torch.nn.functional as F

class SurnameClassifier(nn.Module):
    """ A 2-layer Multilayer Perceptron for classifying surnames """
    #定义一个名为SurnameClassifier的PyTorch神经网络模型,它是一个两层的多层感知器
    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)
#fc1将输入向量映射到隐藏层,fc2将隐藏层映射到输出层。
    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))#通过fc1层计算中间向量,然后应用ReLU激活函数
        prediction_vector = self.fc2(intermediate_vector)#将中间向量传递给fc2层以获得预测向量

        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)#apply_softmax为True时,对预测向量应用softmax激活函数

        return prediction_vector#返回预测向量

4.1.4 MLP的训练过程

虽然我们使用了不同的模型、数据集和损失函数,但是训练例程是相同的。

from argparse import Namespace
args = Namespace(
    # Data and path information    数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",# 姓氏数据文件路径
    vectorizer_file="vectorizer.json",# 向量化器文件路径
    model_state_file="model.pth",  # 模型状态文件路径
    save_dir="model_storage/ch4/surname_mlp",# 保存目录
    # Model hyper parameters
    hidden_dim=300,  # 隐藏层维度
    # Training  hyper parameters
   # 训练超参数
    seed=1337,  # 随机种子
    num_epochs=100,  # 训练轮数
    early_stopping_criteria=5,  # 早停标准
    learning_rate=0.001,  # 学习率
    batch_size=64,  # 批量大小
    # Runtime options omitted for space
)

 训练中最显著的差异与模型中输出的种类和使用的损失函数有关。在这个例子中,输出是一个多类预测向量,可以转换为概率。正如在模型描述中所描述的,这种输出的损失类型仅限于CrossEntropyLoss和NLLLoss。由于它的简化,我们使用了CrossEntropyLoss。 

数据集、模型、损失函数和优化器的实例化:

dataset = SurnameDataset.load_dataset_and_make_vectorizer("surnames_with_splits.csv")  # 加载数据集
vectorizer = dataset.get_vectorizer()  # 向量化处理


classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                               hidden_dim=args.hidden_dim,
                               output_dim=len(vectorizer.nationality_vocab))
#定义一个姓氏分类器(SurnameClassifier)
classifier = classifier.to(args.device)    
#将分类器转移到指定的设备上(如GPU或CPU)
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
#定义一个交叉熵损失函数nn.CrossEntropyLoss
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
#定义一个Adam优化器

 THE TRAINING LOOP

利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。

# 将模型和类权重移动到指定设备
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")

classifier.load_state_dict(torch.load(train_state['model_filename']))

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
print("Test loss: {};".format(train_state['test_loss']))
print("Test Accuracy: {}".format(train_state['test_acc']))

运行结果:

4.1.5 MLP的模型评估和预测

要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。

3.1.5.1 EVALUATING ON THE TEST DATASET
该模型对测试数据的准确性达到50%左右。如果在附带的notebook中运行训练例程,会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据,所以训练数据的性能并不代表新数据的性能。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。其主要原因是收缩的onehot向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量,但它丢弃了字符之间的顺序信息,这对于识别起源非常重要。 

3.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}

运行结果:

3.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):
    # 将名字向量化
    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)

    # returned size is 1,k
    # 将概率值和索引转换为numpy数组
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]

    results = []
    # 遍历概率值和索引,将结果添加到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

运行结果:

 4.2基于CNN的姓氏分类

在本节中,我们将介绍卷积神经网络(CNN),这是一种非常适合检测空间子结构(并因此创建有意义的空间子结构)的神经网络。CNNs通过使用少量的权重来扫描输入数据张量来实现这一点。通过这种扫描,它们产生表示子结构检测(或不检测)的输出张量。为了证明CNN的有效性,让我们应用一个简单的CNN模型来分类姓氏。

4.2.1 CNN的姓氏数据集

虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述,但建议参考“姓氏数据集”来了解它的描述。尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。示例4-17显示了对SurnameDataset.__getitem__的更改;我们显示对SurnameVectorizer的更改。在下一小节向量化。

我们使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因。首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。

class SurnameDataset(Dataset):
    # ... existing implementation from Section 4.2

    def __getitem__(self, index):
        # 获取索引为index的行数据
        row = self._target_df.iloc[index]

        # 将姓氏转换为向量表示,最大长度为_max_seq_length
        surname_matrix = self._vectorizer.vectorize(row.surname, self._max_seq_length)

        # 将国籍转换为对应的索引值
        nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)

        # 返回包含向量化的姓氏和对应国籍索引的字典
        return {'x_surname': surname_matrix,
                'y_nationality': nationality_index}

4.2.2 CNN的词汇表,向量器

在本例中,尽管词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。具体来说,正如我们在示例4-18中的代码中所示,该函数将字符串中的每个字符映射到一个整数,然后使用该整数构造一个由onehot向量组成的矩阵。重要的是,矩阵中的每一列都是不同的onehot向量。主要原因是,我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。

除了更改为使用onehot矩阵之外,我们还修改了矢量化器,以便计算姓氏的最大长度并将其保存为max_surname_length

class SurnameVectorizer(object):
    """ 将词汇表协调起来并使用的向量化器 """
    def vectorize(self, surname):
        """
        参数:
            surname (str): 姓氏
        返回:
            one_hot_matrix (np.ndarray): 一个由独热向量组成的矩阵
        """

        one_hot_matrix_size = (len(self.character_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.character_vocab.lookup_token(character)
            one_hot_matrix[character_index][position_index] = 1

        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据集数据框实例化向量化器

        参数:
            surname_df (pandas.DataFrame): 姓氏数据集
        返回:
            SurnameVectorizer的一个实例
        """
        character_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:
                character_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(character_vocab, nationality_vocab, max_surname_length)

4.2.3 CNN重新实现姓氏分类器

我们在本例中使用的模型是使用我们在“卷积神经网络”中介绍的方法构建的。实际上,我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。正如在示例4-19中所看到的,它与我们在“卷积神经网络”中引入的Conv1d序列既有相似之处,也有需要解释的新添加内容。具体来说,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。

然而,本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于实验3中介绍的ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性(Clevert et al., 2015)。

在本例中,我们将每个卷积的通道数与num_channels超参数绑定。我们可以选择不同数量的通道分别进行卷积运算。这样做需要优化更多的超参数。我们发现256足够大,可以使模型达到合理的性能。

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): size of the incoming feature vector
            num_classes (int): size of the output prediction vector
            num_channels (int): constant channel size to use throughout network
        """
        super(SurnameClassifier, self).__init__()
#定义卷积神经网络convnet和全连接层fc
        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)
#定义forward方法,实现前向传播过程
    def forward(self, x_surname, apply_softmax=False):
        """The forward pass of the classifier

        Args:
            x_surname (torch.Tensor): an input data tensor.
                x_surname.shape should be (batch, initial_num_channels,
                                           max_surname_length)
            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, 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)

        return prediction_vector

 4.2.4 CNN的训练过程

训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。此时,这是本书到目前为止的第三个训练例程实现,应该将这个操作序列内部化。对于这个例子,我们将不再详细描述具体的训练例程,因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同。但是,输入参数是不同的.

args = Namespace(
    # Data and Path information
    surname_csv="data/surnames/surnames_with_splits.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=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # Runtime omitted for space ...
)

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,
            'test_acc': -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)[1]
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    return n_correct / len(y_pred_indices) * 100
 
import os
import numpy as np
import pandas as pd
 
args = Namespace(
    # Data and Path information
    surname_csv="data/surnames/surnames_with_splits.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=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # Runtime options
    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("Expanded filepaths: ")
    print("\t{}".format(args.vectorizer_file))
    print("\t{}".format(args.model_state_file))
    
# Check 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))
 
def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)
        
def handle_dirs(dirpath):
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)
        
# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda)
 
# handle dirs
handle_dirs(args.save_dir)
 
if args.reload_from_files:
    # training from a checkpoint
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                              args.vectorizer_file)
else:
    # create dataset and vectorizer
    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)
 
classifer = 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)
 
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.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")
 
 
classifier.load_state_dict(torch.load(train_state['model_filename']))
 
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
print("Test loss: {};".format(train_state['test_loss']))
print("Test Accuracy: {}".format(train_state['test_acc']))

运行结果:

 

 

4.2.5 CNN的模型评估和预测

要理解模型的性能,需要对性能进行定量和定性的度量。下面将描述这两个度量的基本组件。

Evaluating on the Test Dataset 正如“示例:带有多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样,执行评估的代码也没有变化。总之,调用分类器的eval()方法来防止反向传播,并迭代测试数据集。与 MLP 约 50% 的性能相比,该模型的测试集性能准确率约为56%。尽管这些性能数字绝不是这些特定架构的上限,但是通过一个相对简单的CNN模型获得的改进应该足以让您在文本数据上尝试CNNs。

Classifying or retrieving top predictions for a new surname

在本例中,predict_nationality()函数的一部分发生了更改,如示例4-21所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在predict_topk_nationality()函数中。

def predict_nationality(surname, classifier, vectorizer):
    """预测姓氏所属国籍

    参数:
        surname (str): 需要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
    返回:
        一个字典,包含最可能的国籍及其概率
    """
    # 将姓氏向量化
    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()

    # 根据索引查找对应的国籍
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    # 获取最大概率值
    probability_value = probability_values.item()

    # 返回预测结果
    return {'nationality': predicted_nationality, 'probability': probability_value}

运行结果:

 

def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    """Predict the top K nationalities from a new surname
    
    Args:
        surname (str): the surname to classifier
        classifier (SurnameClassifer): an instance of the classifier
        vectorizer (SurnameVectorizer): the corresponding vectorizer
        k (int): the number of top nationalities to return
    Returns:
        list of dictionaries, each dictionary is a nationality and a probability
    """
    
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # returned size is 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("Enter a surname to classify: ")

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']))

运行结果:

  • 27
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值