基于自然语言处理前馈网络的姓氏分类任务的复现

一、The Multilayer Perceptron(多层感知器)

多层感知器(MLP)被认为是最基本的神经网络构建模块之一。最简单的MLP是对第3章感知器的扩展。感知器将数据向量作为输入,计算出一个输出值。在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。在PyTorch中,正如您稍后将看到的,这只需设置线性层中的输出特性的数量即可完成。MLP的另一个方面是,它将多个层与每个层之间的非线性结合在一起。

最简单的MLP,如图4-2所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。在像Yelp评论分类这样的二进制任务中,输出向量仍然可以是1。在多类设置中,将在本实验后面的“示例:带有多层感知器的姓氏分类”一节中看到,输出向量是类数量的大小。虽然在这个例子中,我们只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。图4-2 一种具有两个线性层和三个表示阶段(输入向量、隐藏向量和输出向量)的MLP的可视化表示

1.Implementing MLPs in PyTorch

如前所述,MLP除了实验3中简单的感知器之外,还有一个额外的计算层。在我们在例4-1中给出的实现中,我们用PyTorch的两个线性模块实例化了这个想法。线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层”。除了这两个线性层外,还有一个修正的线性单元(ReLU)非线性(在实验3“激活函数”一节中介绍),它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层4,因此不能建模复杂的模式。MLP的实现只实现反向传播的前向传递。这是因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新。

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

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        多层感知机模型的类

        Args:
            input_dim (int): 输入向量的大小
            hidden_dim (int): 第一个全连接层的输出大小
            output_dim (int): 第二个全连接层的输出大小
        """
        super(MultilayerPerceptron, 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):
        """MLP的前向传播

        Args:
            x_in (torch.Tensor): 输入数据张量。
                x_in.shape应为(batch, input_dim)
            apply_softmax (bool): 是否应用softmax激活标志
                如果与交叉熵损失一起使用,应为false
        Returns:
            输出张量。张量.shape应为(batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))  # 第一全连接层后应用ReLU激活
        output = self.fc2(intermediate)  # 第二全连接层得到最终输出

        if apply_softmax:  # 如果应用softmax激活
            output = F.softmax(output, dim=1)  # 对输出进行softmax激活
        return output

我们实例化了MLP。由于MLP实现的通用性,可以为任何大小的输入建模。为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度。请注意,在print语句的输出中,每个层中的单元数很好地排列在一起,以便为维度3的输入生成维度4的输出。

batch_size = 2  # 一次输入的样本数
input_dim = 3  # 输入数据的维度
hidden_dim = 100  # 隐藏层的维度
output_dim = 4  # 输出的维度

# 初始化模型
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)  # 创建MultilayerPerceptron对象
print(mlp)  # 打印模型信息

结果:

我们可以通过传递一些随机输入来快速测试模型的“连接”,如示例4-3所示。因为模型还没有经过训练,所以输出是随机的。在花费时间训练模型之前,这样做是一个有用的完整性检查。

import torch

# 定义一个函数,用于描述张量的类型、形状和数值
def describe(x):
    print("Type: {}".format(x.type()))  # 打印张量类型
    print("Shape/size: {}".format(x.shape))  # 打印张量形状/大小
    print("Values: \n{}".format(x))  # 打印张量数值

# 创建一个(batch_size, input_dim)大小的随机张量
x_input = torch.rand(batch_size, input_dim)
describe(x_input) 

上述代码运行结果:

Type: torch.FloatTensor

Shape/size: torch.Size([2, 3])

Values:

tensor([[0.6193, 0.7045, 0.7812],

        [0.6345, 0.4476, 0.9909]])

学习如何读取PyTorch模型的输入和输出非常重要。在前面的例子中,MLP模型的输出是一个有两行四列的张量。这个张量中的行与批处理维数对应,批处理维数是小批处理中的数据点的数量。列是每个数据点的最终特征向量。在某些情况下,例如在分类设置中,特征向量是一个预测向量。名称为“预测向量”表示它对应于一个概率分布。预测向量会发生什么取决于我们当前是在进行训练还是在执行推理。在训练期间,输出按原样使用,带有一个损失函数和目标类标签的表示。我们将在“示例:带有多层感知器的姓氏分类”中对此进行深入介绍。

但是,如果想将预测向量转换为概率,则需要额外的步骤。具体来说,需要softmax函数,它用于将一个值向量转换为概率。softmax有许多根。在物理学中,它被称为玻尔兹曼或吉布斯分布;在统计学中,它是多项式逻辑回归;在自然语言处理(NLP)社区,它是最大熵(MaxEnt)分类器。不管叫什么名字,这个函数背后的直觉是,大的正值会导致更高的概率,小的负值会导致更小的概率。在示例4-3中,apply_softmax参数应用了这个额外的步骤。

y_output = mlp(x_input, apply_softmax=True)#正向传播,用softmax处理
describe(y_output)

二、实验步骤

1 .Example: Surname Classification with a Multilayer Perceptron

在本节中,我们将MLP应用于将姓氏分类到其原籍国的任务。从公开观察到的数据推断人口统计信息(如国籍)具有从产品推荐到确保不同人口统计用户获得公平结果的应用。人口统计和其他自我识别信息统称为“受保护属性”。“在建模和产品中使用这些属性时,必须小心。”我们首先对每个姓氏的字符进行拆分,并像对待“示例:将餐馆评论的情绪分类”中的单词一样对待它们。除了数据上的差异,字符层模型在结构和实现上与基于单词的模型基本相似.

应该从这个例子中吸取的一个重要教训是,MLP的实现和训练是从我们在第3章中看到的感知器的实现和培训直接发展而来的。事实上,我们在实验3中提到了这个例子,以便更全面地了解这些组件。此外,我们不包括“例子:餐馆评论的情绪分类”中看到的代码。

本节的其余部分将从姓氏数据集及其预处理步骤的描述开始。然后,我们使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的管道。如果你通读了实验3,应该知道,这里只是做了一些小小的修改。

我们将通过描述姓氏分类器模型及其设计背后的思想过程来继续本节。MLP类似于我们在实验3中看到的感知器例子,但是除了模型的改变,我们在这个例子中引入了多类输出及其对应的损失函数。在描述了模型之后,我们完成了训练例程。训练程序与“示例:对餐馆评论的情绪进行分类”非常相似,因此为了简洁起见,我们在这里不像在该部分中那样深入,可以回顾这一节内容。

1.1 The Surname Dataset

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

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

SurnameDataset的实现与“Example: classification of Sentiment of Restaurant Reviews”中的ReviewDataset几乎相同,只是在getitem方法的实现方式上略有不同。回想一下,本课程中呈现的数据集类继承自PyTorch的数据集类,因此,我们需要实现两个函数:`__getitem`方法,它在给定索引时返回一个数据点;以及len方法,该方法返回数据集的长度。“示例:餐厅评论的情绪分类”中的示例与本示例的区别在getitem__中,如示例4-5所示。它不像“示例:将餐馆评论的情绪分类”那样返回一个向量化的评论,而是返回一个向量化的姓氏和与其国籍相对应的索引:

# Implementation is nearly identical to Section 3.5
from torch.utils.data import Dataset
import torch

class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        初始化SurnameDataset实例

        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.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)  # 计算类别权重

    """
    处理姓氏和国籍数据集的自定义数据集类
    """

    def __getitem__(self, index):
        """
        获取数据集中特定索引的样本

        Args:
            index (int): 样本的索引

        Returns:
            一个包含姓氏向量和国籍索引的字典
        """
        row = self._target_df.iloc[index]  # 从_target_df中获取特定索引的行
        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}# 返回包含姓氏向量和国籍索引的字典
    
    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """加载数据集并从头创建一个新的Vectorizer

        Args:
            surname_csv (str): 数据集文件的路径
        Returns:
            SurnameDataset的一个实例
        """
        surname_df = pd.read_csv(surname_csv)  # 从CSV文件加载数据集
        train_surname_df = surname_df[surname_df.split=='train']  # 获取训练数据

        # 创建一个新的Vectorizer,并使用训练数据集来进行初始化
        vectorizer = SurnameVectorizer.from_dataframe(train_surname_df)

        return cls(surname_df, vectorizer)  # 返回SurnameDataset的一个实例,其中包含加载的数据集和新创建的Vectorizer

1.2 Vocabulary, Vectorizer, and DataLoader

为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和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符号。

虽然我们在这个示例中使用了收缩的one-hot,但是在后面的实验中,将了解其他向量化方法,它们是one-hot编码的替代方法,有时甚至更好。具体来说,在“示例:使用CNN对姓氏进行分类”中,将看到一个热门矩阵,其中每个字符都是矩阵中的一个位置,并具有自己的热门向量。然后,在实验5中,将学习嵌入层,返回整数向量的向量化,以及如何使用它们创建密集向量矩阵。

class SurnameVectorizer(object):
    """协调词汇表并将其应用到实际数据上的向量化器类"""
    def __init__(self, surname_vocab, nationality_vocab):
        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  # 使用one-hot编码表示姓氏
        return one_hot

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

        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)  # 返回SurnameVectorizer的实例
    @classmethod
    def from_serializable(cls, contents):
        # 从可序列化内容中创建对象
        # 从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()}

1.3 The Surname Classifier Model

SurnameClassifier的第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。

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

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

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:
            输出张量。张量.shape应为(batch, output_dim)
        """
        intermediate_vector = F.relu(self.fc1(x_in))  # 经过第一个全连接层并应用ReLU激活
        prediction_vector = self.fc2(intermediate_vector)  # 经过第二个全连接层得到预测向量

        if apply_softmax:  # 如果应用softmax激活
            prediction_vector = F.softmax(prediction_vector, dim=1)  # 对预测向量进行softmax激活

        return prediction_vector  # 返回预测向量

1.4 The Training Routine

虽然我们使用了不同的模型、数据集和损失函数,但是训练例程是相同的。因此,在例4-8中,我们只展示了args以及本例中的训练例程与“示例:餐厅评论情绪分类”中的示例之间的主要区别。

args = Namespace(
    # Data and path information
    surname_csv="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=10,
    early_stopping_criteria=5,
    learning_rate=0.001,
    batch_size=64,
    # Runtime options
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
)

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

import pandas as pd
import json
import torch.optim as optim

# 加载数据集并创建向量化器
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)  # 从文件加载数据集并创建向量化器
dataset.save_vectorizer(args.vectorizer_file)
vectorizer = dataset.get_vectorizer()  # 获取向量化器对象

# 初始化分类器模型
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),  # 输入维度为姓氏词汇表的大小
                               hidden_dim=args.hidden_dim,  # 隐藏层维度
                               output_dim=len(vectorizer.nationality_vocab))  # 输出维度为国籍词汇表的大小

#classifier = classifier.to(args.device)  # 将分类器模型移动到特定设备

# 定义损失函数和优化器
loss_func = nn.CrossEntropyLoss(dataset.class_weights)  # 使用交叉熵损失函数
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)  # 使用Adam优化器进行参数优化

显示了使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。

from tqdm import tqdm_notebook
from torch.utils.data import DataLoader
import numpy as np

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):  # 遍历每个epoch
        train_state['epoch_index'] = epoch_index  # 保存当前epoch的索引

        # Iterate over training dataset
        dataset.set_split('train')  # 设置数据集为训练集
        batch_generator = generate_batches(dataset, batch_size=args.batch_size)  # 生成批处理数据
        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:

            # --------------------------------------
            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)  # 保存训练集准确率值

        # Iterate over val dataset
        dataset.set_split('val')  # 设置数据集为验证集
        batch_generator = generate_batches(dataset, batch_size=args.batch_size)  # 生成验证集的批处理数据
        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()  # 更新epoch进度条
except KeyboardInterrupt:  # 如果收到键盘中断信号
    print("Exiting loop")  # 打印信息

1.5 Model Evaluation and Prediction

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

评价SurnameClassifier测试数据,我们执行相同的常规的routine文本分类的例子“餐馆评论的例子:分类情绪”:我们将数据集设置为遍历测试数据,调用`classifier.eval()`方法,并遍历测试数据以同样的方式与其他数据。在这个例子中,调用`classifier.eval()`可以防止PyTorch在使用测试/评估数据时更新模型参数。

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

给定一个姓氏作为字符串,该函数将首先应用向量化过程,然后获得模型预测。注意,我们包含了apply_softmax标志,所以结果包含概率。模型预测,在多项式的情况下,是类概率的列表。我们使用PyTorch张量最大函数来得到由最高预测概率表示的最优类。

def predict_nationality(name, classifier, vectorizer):
    # 将姓名转换为向量
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    
    # 使用分类器进行预测
    result = classifier(vectorized_name, 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}

不仅要看最好的预测,还要看更多的预测。例如,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)
    
    # 使用分类器进行预测
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    
    # 获取top 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

2.结果分析

在机器学习任务中,通常会将数据集分为训练集和测试集,用训练集训练模型,在测试集上评估模型的性能。这段代码的作用是针对测试集进行模型评估,计算模型在测试集上的损失值和准确率。在训练完毕后,对模型进行评估,了解模型在独立的测试集上的性能表现,以便做出进一步的决策,如调整模型参数、进行模型比较或选择最佳模型等。

# 加载最佳模型权重
classifier.load_state_dict(torch.load(train_state['model_filename']))

# 将分类器设置为评估模式并定义损失函数
loss_func = nn.CrossEntropyLoss(dataset.class_weights)
classifier.eval()

# 遍历测试集数据并计算损失和准确率
running_loss = 0.
running_acc = 0.

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.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)

# 保存测试集的损失和准确率结果
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']))
  • 测试集的平均损失(Test loss)为1.8734
  • 测试集的准确率(Test Accuracy)为42.84%

这意味着模型在测试集上的平均损失为1.8734,而准确率达到了42.84%。这些指标表示模型在独立的测试数据上的效果,从而评估模型的泛化能力和性能表现。通常来说,越低的损失和越高的准确率都代表着模型在测试集上的表现更好。 

Test loss: 1.8734154403209686;
Test Accuracy: 42.83854166666667

3.模型应用

接收用户输入的姓氏,并使用预训练的分类器对其进行国籍预测,然后输出预测结果和对应的概率值。

# 接收用户输入的姓氏
new_surname = input("Enter a surname to classify: ")

# 将分类器转移到CPU上进行推断
classifier = classifier.to("cpu")

# 使用预测函数进行预测
prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印预测结果
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))
Enter a surname to classify:  zhang
zhang -> Russian (p=0.38)

用于接收用户输入的姓氏,获取用户希望显示的前k个预测结果,并使用预训练的分类器在CPU上进行预测,然后输出前k个预测结果。

# 接收用户输入的姓氏
new_surname = input("Enter a surname to classify: ")

# 将分类器移至 CPU 进行推断
classifier = classifier.to("cpu")

# 接收用户输入的要显示的前 k 个预测
k = int(input("How many of the top predictions to see? "))

# 如果 k 超出国籍词汇表的索引范围,则将 k 设置为最大值
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)
    
# 使用预测函数获取前 k 个预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

# 打印前 k 个预测结果
print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))
Enter a surname to classify:  zhang
How many of the top predictions to see?  5
Top 5 predictions:
===================
zhang -> Russian (p=0.38)
zhang -> German (p=0.14)
zhang -> Arabic (p=0.08)
zhang -> Irish (p=0.08)
zhang -> Japanese (p=0.07)

三、实验总结

1.多层感知机MLP

“基于多层感知器的姓氏分类”展示了多层感知器(MLP)在多层分类任务中的应用。该示例的目标是通过训练一个 MLP 模型来自动将给定的姓氏分类为不同的国家。

数据准备:收集并准备用于训练和测试的姓氏数据集。数据集应包含姓氏及其对应的所属国家标签。
特征工程:将原始的姓氏数据转换为机器学习算法可以理解的特征表示形式。常见的特征工程方法包括独热编码、词袋模型等。
数据划分:将数据集划分为训练集和测试集。训练集用于模型的训练,测试集用于评估模型的性能。
模型构建:构建多层感知器模型。MLP 是一种前馈神经网络,由多个全连接层组成。每个层都由多个神经元组成,其中每个神经元与上一层的所有神经元相连接。
模型训练:使用训练集对 MLP 模型进行训练。训练过程通常包括定义损失函数、选择优化算法以及迭代更新模型参数。
模型评估:使用测试集评估训练好的 MLP 模型的性能。常用的评估指标包括准确率、精确率等。
预测应用:使用训练好的 MLP 模型对新的姓氏数据进行预测,即根据模型学习到的规律,将新的姓氏分类为对应的国家。
通过这个示例,我们可以学习到如何使用多层感知器作为一种多层分类模型,以自动从输入数据中学习特征,并进行分类任务。这是多层感知器在机器学习中的常见应用之一。

2.卷积神经网络CNN


“基于卷积神经网络的姓氏分类”使用卷积神经网络(CNN)来对姓氏进行分类。我们的目标是根据姓氏的拼写将它们归类到不同的国家或文化中。这是一个多层分类问题,因为我们有多个类别(即不同的国家)。

数据收集和准备:收集具有不同国家姓氏的数据集。确保数据集中包含足够数量的样本,并且每个样本都有其相应的标签(即所属的国家)。
数据预处理:将数据集分割成训练集、验证集和测试集。对姓氏进行标准化处理,例如转换为小写字母、移除特殊字符等。还要将文本转换为模型可处理的数字表示形式,例如使用单词嵌入或者单词索引。
构建CNN模型:设计一个适合姓氏分类的卷积神经网络模型。通常,这个模型包括几个卷积层、池化层和全连接层。卷积层用于提取特征,池化层用于减少特征图的大小,全连接层用于将提取的特征映射到输出类别。
模型训练:使用训练集来训练CNN模型。在训练过程中,通过反向传播算法更新模型的权重,以最小化预测错误。可以尝试不同的优化算法、学习率和正则化技术来提高模型的性能。
模型评估:使用验证集评估模型的性能。可以使用准确率、精确率、召回率等指标来评估模型的分类性能。根据评估结果对模型进行调整,以提高其性能。
模型测试:在测试集上对最终模型进行测试,评估其在未见过的数据上的泛化能力。确保模型在实际应用中表现良好。
模型部署:将训练好的模型部署到生产环境中,以便对新的姓氏进行分类预测。
通过这个示例,我们可以更好地掌握CNN在多层分类中的应用。

 四、参考文献

1 Natural-Language-Processing-with-PyTorch(四) | Learner (yifdu.github.io)

2 自然语言处理前馈网络 

  • 10
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值