【从零开始的NLP】多层感知机实现姓氏分类(代码解读向)

首先申明:

1.本次实验原代码来源为希冀平台,非笔者完全原创,笔者只在其基础上进行了一些修改和优化。如构成侵权,请联系本人删除。

2.笔者水平有限,文章内容口语化比较严重,且存在个人理解,欢迎大佬们在评论区批评指正。

3.本文面向的是,在自然语言处理 python 编程两个方面仅仅入门的学习者,且比起讲解,更偏向于笔者的学习思路分享。大佬可能会觉得索然无味,见谅。

另:这是笔者的第一篇博客,文风比较生涩,望各位海涵。

​​​​​​​

目录

一、问题分析

1.1 什么是姓氏分类

1.2 姓氏分类能够实现吗

二、原理部分

2.1 词袋模型

2.2 MLP(多层感知机)

什么是MLP?

MLP 如何工作?

为什么要加入激活函数?

如何训练权重?

2.3 解决方案

词袋模型做了什么?

MLP 做了什么?

三、代码讲解

3.1 环境介绍

3.2 数据预处理部分

3.3 词袋模型部分

「词汇表」

「姓氏向量化器」

「姓氏数据集」

3.4 MLP 部分

MLP 网络结构

3.5 功能函数部分

3.6 模型训练部分

训练准备

训练过程

3.7 查询测试部分

四、小结


一、问题分析

1.1 什么是姓氏分类

不同国家由于语言不同、文化不同,人们的名字存在各自的特征,国与国的名字之间甚至可能存在相当程度的差异。如果能够仅仅通过姓氏进行国籍的初步判断,就能够在保护顾客隐私的同时,为服务等行业提供更多的可能性。

(例如:餐厅如果只需要知晓顾客的姓氏,就能够推测对方的国籍,也就能进一步判断对方的饮食习惯,从而达到更好的服务以及餐品推销。这无疑在提供了隐私性的同时提升了顾客的用餐体验。)

既然如此,有没有一种方法,能够让我们仅仅利用姓氏,就能够分辨一个人的国籍呢?姓氏分类由此而生。

1.2 姓氏分类能够实现吗

相较于表面上的复杂,姓氏分类具有两点研究者们与学习者们最喜欢的特性:

  • 原始数据的可读性(再复杂的姓氏都能够统一为字符串的形式)
  • 结果的确定性(一个人不可能同时拥有多个姓氏或者多个国籍)

这也就决定了,这一问题如果存在解决方法,一定是比较容易实现的。因此,这一问题在提出之后,研究者们提供了多种多样的解决办法,如处理姓氏可以使用的词袋模型等,处理分类可以使用的 MLP(多层感知机)、CNN(卷积神经网络)等。本文主要讲的就是最为简单的组合:词袋模型+MLP

二、原理部分

2.1 词袋模型

所谓词袋模型,就是将文本库中出现的所有单词记录在一个词汇表中。那之后,可以用词的频率集合来表示原本的句子,就像下面这样:

文本库:

  1. 狗不是猫。
  2. 狗就是狗。
  3. 狗和猫不一样。

词汇表:​​​​​​​【“狗”,“不”,“是”,“猫”,“就”,“和”,“一样”】

词袋模型表示三个句子:

  1. [1,1,1,1,0,0,0]
  2. [2,0,1,0,1,0,0]
  3. [1,1,0,1,0,1,1]

词袋模型中,我们最终能够用一个向量表示一个句子。向量的维度表示词汇表的大小,每个维度的数值表示对应的词在句子中出现的次数,除此之外,没有别的信息。

(例如,上面的例子第 2 句中“狗”出现了两次,“不”没有出现,因此第 2 句对应向量中“狗”的位置【 index=0 】表示为 2,“不”的位置【 index=1 】表示为 0 )

也就是说,如果使用词袋模型,注定会无法记录词的顺序信息,这或许会影响解决方案的准确率,但是换来的是相对简单的模型复杂程度这也是本文选择词袋模型的主要原因。

不过,在这一任务中,我们并没有句子的概念,但是我们可以将问题进行抽象。如果将姓氏类比“句子”,字母类比“词”,我们就可以发现,虽然有些抽象,但词袋模型在这个任务是可以使用的。词袋模型最终能够将姓氏转化为一个向量。这对于后面使用 MLP 有比较大的帮助,因为相较于字符串类型,向量对于机器学习的模型而言,是一个非常方便调用的输入格式

即便你没有了解过自然语言处理的相关方法,你只需要知道,按照上述方法,你能够将姓氏表示成向量,就足够了。词袋模型在本次任务中的作用只这一点即可。

虽然变成了向量,但是向量基本没有任何意义,相近的向量可能也有非常不同的特征。这样的解决办法真的有效吗?别急,后面得到结果的时候自见分晓。

2.2 MLP(多层感知机)

什么是MLP?

多层感知机,又名人工神经网络,或者你也可以从结构的角度叫它线性全连接神经网络。这一网络结构的来源就是生物神经网络,通过模拟生物神经网络中神经元相互连接的特点,从原理方面进行模仿,从而构建的单元简单但是体积庞大的网络结构。

形状如图所示。每个圆圈对应一个神经元,每一条剪头都对应着一个权重w_{ij},因此,庞大的参数量也就支持我们实现一些比较复杂的功能。

MLP 如何工作?

通过训练好的权重,我们只需要通过线性的加权求和再经过一个激活函数即可完成一层数据的前向传播

y_k=f( \sum\limits_{j=0}^{q}w_{jk}\cdot h_j) \\ h_j= f(\sum\limits_{i=0}^{n}w_{ij}\cdot x_i )\\

其中,f表示激活函数。

不过这么说还是有些干巴,下面举个例子:

设定激活函数为 ReLU 函数:

f(x)=\left\{ \begin{array}{ll} 0, & x \leq 0 \\ x, & x > 0 \end{array}\right.

下面我们尝试进行网络的前向传播:

a = f(x_1\cdot w_{1a} + x_2\cdot w_{2a})=f(2 * 1 + 3 * 3) = f(11) = 11 \\ b = f(x_1\cdot w_{1b} + x_2\cdot w_{2b})=f(2 * 3 + 3 * (-1)) = f(3) = 3 \\ c = f(x_1\cdot w_{1c} + x_2\cdot w_{2c})=f(2 * (-2) + 3 * (-3)) = f(-13) = 0 \\ d = f(h_a\cdot w_{ad} + h_b\cdot w_{bd}+h_c\cdot w_{cd})=a * 2 + b * 1 + c * 6 = 25\\

其实前向传播的过程就是这种简单的计算过程,很好理解。

对于多分类任务而言,最终我们能够通过前向传播得到多个最终的输出 y_k。一般会根据y_k的大小决定对应分类的预测概率。y_k越大一般就说明预测对象分为 k 的概率越大。

为什么要加入激活函数?

学过些许数学知识的同学应该都能够想明白:线性模型如果仅仅是通过加权求和,最终得到的仍然只能是线性模型,例如,对于一个上图所示的模型,如果不存在激活函数,模型公式如下:

y_k= \sum\limits_{j=0}^{q}w_{jk}\cdot h_j \\ h_j= \sum\limits_{i=0}^{n}w_{ij}\cdot x_i \\

观察可得:一层的线性全连接网络只是线性的。再将两者合起来,也就是:

y_k= \sum\limits_{j=0}^{q}(w_{jk}\cdot \sum\limits_{i=0}^{n}w_{ij}\cdot x_i )\\ =\sum\limits_{j=0}^{q} \sum\limits_{i=0}^{n}(w_{jk}\cdot w_{ij}\cdot x_i )

观察可得:线性的模型再经过一层线性全连接层仍然是线性的。也就是说,在不添加激活函数的情况下,再深的MLP 都只能得到线性的模型,这并不是我们希望的情况。

那么要怎么解决呢?

根据数学中拟合相关的知识,我们可以知道,非线性函数的线性组合是能够进行函数的拟合的,如果我们能够添加非线性的部分,结合 MLP 本身大量的、交叉的线性组合,就能够实现大量非线性函数的拟合,这也就大大提升了模型对于非线性分类任务的处理能力。

那么非线性部分从哪里来呢?于是就有了激活函数。

不论是 Sigmoid 也好,ReLU 也罢,激活函数一定要保证非线性,以保证能够完成更复杂的任务。

如何训练权重?

目前主要的训练方法为反向传播算法。这一算法的主要思想为:我们希望权重能够向着令模型损失更小的方向更新。于是,反向传播算法尝试将损失关于权重求导,再将权重向着求到导数的反方向进行更新,是不是就能够让损失变得更小呢?最终的结果证明,答案无疑是肯定的。

如图,显而易见红点处导数是大于零的。沿着导数反方向(紫色箭头所示)更新理论上就能够不断靠近函数的最低点。

当然这里我们并不进行深究具体原理,只需要有一个大概的了解,毕竟代码中这一步骤只需要一句loss.backward(). 你只需要理解,向着导数反方向不断的更新,总是能够逼近极小值点的,因此我们可以使用这样的权重更新方式进行迭代训练,就可以了。

2.3 解决方案

我们已经完成了知识点方面必要的了解,下面就是将问题抽象。或者说,为什么能够使用词袋模型+MLP 尝试解决姓氏分类问题。

词袋模型做了什么?

词袋模型的目的就是把分类器难以处理的原始文本数据,转化为一个分类器乐于处理的向量(或者张量,这两者在数学原理方面相差不大)。

这一步骤中,我们一般需要从文本库出发,构建三个结构:「词汇表」、「向量化器」以及「向量化数据集」

「词汇表」的作用为:记录所有文本库中出现的「标记」(token)种类,并为每个「标记」添加对应的「索引」(index)。使用「索引」传递数据是分类模型非常喜欢看到的,这样做能够使得分类模型的泛用性得到保障。比如同一个分类模型可以同时胜任猫狗分类和服装分类任务的训练这种。

这里笔者倾向于将 token 翻译成「标记」而不是「令牌」,这是由于笔者认为,相较于认证方面的功能,这里 token 的意义更类似于编译原理的词法分析中的「标记」(或者译为「词法记号」,笔者使用「标记」是为了更为简洁,也是为了和词法分析区别开来。

在不同的环境中,「标记」所代表的含义可能不尽相同。在本任务中,我们既可以为姓氏创建「词汇表」,同样可以为国籍创建「词汇表」,看具体需要。

「向量化器」的作用为:将每个「标记」的集合体转化为一个向量,用于分类器调用。

「标记」的集合体:例如,当「标记」指代的是字母的时候,「标记」的集合体指的就是单词,也就是将单词向量化;当「标记」指代的是单词的时候,「标记」的集合体指的就是句子,也就是将句子向量化。

「向量化数据集」的作用为:将原本数据集中每个样本由原本的“姓氏-国籍”的形式改变为“向量-标签”的形式,以便训练过程中调用。

MLP 做了什么?

MLP 的作用为:训练一个网络,将不同样本的「标记」特点和对应的标签建立联系,最终使得结果会倾向于将更接近的标签赋予更大的概率(输出结果)

例如:如果某一样本得到预测输出为[0.1,0.4,0.3,0.2],我们就认为,这一样本有 0.1 的概率为第一类标签,有 0.4 的概率为第二类标签,有 0.3 的概率为第三类标签,有 0.2 的概率为第四类标签。

所以在这一任务中,MLP 的作用就是分类。由于原本的输入难以直接输入 MLP,需要词袋模型进行初步处理之后再输入进行训练和预测。

综上,我们能够将问题抽象为:

  1. 姓氏通过词袋模型转化为向量
  2. 向量通过 MLP 进行分类

这样一个过程,最终就能够实现姓氏分类。

至此,我们完成了所有的准备工作,下面就是代码的讲解部分了。

如果你觉得还是有一些疑惑,请不要心急,部分问题在代码部分会进一步讲解。

三、代码讲解

在开始讲解之前,笔者先进行一些说明。本文是按照编写代码的运行顺序进行讲解的。实际的思考过程应该是从数据集生成以及训练过程开始,根据需要返回来对于数据集以及功能进行定义。但是这样的叙述顺序会显得文章杂乱无章。因此,如果在阅读的过程中对函数或者方法的使用出现了困惑,请带着困惑继续看下去。你或许会在后续的代码部分想明白。

3.1 环境介绍

本文使用 python=3.8 进行代码的编写,使用 PyTorch 作为深度学习框架。下面是一些相关包的版本问题:

torch==1.11.0

torchvision==0.12.0

一些相关的包为了避免出现版本冲突,这里选择的是直接使用 d2l 包进行版本的控制

d2l==0.17.5

剩下的包直接选择 python 版本对应的版本即可(让 pip 替你决定)

笔者使用 PyTorch 的体验是比较好的,其学习成本对于掌握 Python 的学习者而言会比其他的部分模型更小(个人感觉),代码的风格也可以延续 python主体编写的习惯。

下面是本次代码需要引入的包部分:

# 文件相关操作
import json
import os
import collections


# 数据处理与数据读取
import numpy as np
import pandas as pd

# PyTorch 相关
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader

# 参数集合用
from argparse import Namespace

# 进度条显示
from tqdm import tqdm

3.2 数据预处理部分

实验提供的原始数据保存在一个 csv 文件中,这一文件仅有两列:surname(姓氏)以及 nationality(国籍)。这样的数据直接导入进行训练看似没有问题,但是在观察了数据组成成分之后,我们发现,不同国籍的样本数量出现了不同程度的不对等,也就是说,如果直接进行随机划分,可能会导致有些国籍的样本在训练集中严重缺失,这一点严重不利于模型的训练结果。因此,首先需要按照比例,对不同国籍的样本的进行训练集划分。

args = Namespace(
    raw_dataset_csv="../data/surnames.csv", # 需要将原本的内容转变为下载之后的文件路径
    train_proportion=0.7, # 训练集比例为 70%
    val_proportion=0.15, # 验证集比例为 15%
    test_proportion=0.15, # 测试集比例为 15%
    output_munged_csv="../data/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():
    by_nationality[row.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)

    # 划分数据集,并给予划分好的数据集一个划分的标志位
    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)

# 将划分好的数据集转化为 DataFrame格式,方便后续写入文件操作
final_surnames = pd.DataFrame(final_list)

print(final_surnames.split.value_counts())

# 将 DataFrame 写入 csv 文件
final_surnames.to_csv(args.output_munged_csv, index=False)

# 输出结果如下:
# train    7680
# test     1660
# val      1640
# Name: split, dtype: int64

划分好的数据集会存放在指定的文件中,这一文件中的数据相较于之前的数据集多了一列split(划分),这一属性在后续生成数据集的时候能够直接被调用,这样运行时就不需要进行划分了,可以减少后续步骤的代码量。(把能独立出来的部分就独立出来,可读性和纠错的便利性都能增加。)

3.3 词袋模型部分

在 2.3 节中我们已经介绍到,使用词袋模型,需要构建三个概念,也就是 python 中的三个类,分别为:「词汇表」「向量化器」「向量化数据集」,下面依次讲解三个部分。

「词汇表」

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()}

        # 是否添加「UNK 标记」
        self._add_unk = add_unk
        # 「UNK 标记」具体内容
        self._unk_token = unk_token

        # 不使用则索引为-1
        # 使用则在初始化最后添加一个「索引」作为「UNK 标记」的「索引」
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token)

定义了属性,要如何创建并赋值呢?这里笔者定义了两个方法,支持通过可串行化的字典进行「词汇表」的实例化,以及从已经实例化的「词汇表」生成可串行化的字典:

    def to_serializable(self):
        """
        返回一个可串行化的字典

        Returns:
            (dict): 代表「词汇表」的可串行化字典
        """
        return {'token_to_idx': self._token_to_idx,
                'add_unk': self._add_unk,
                'unk_token': self._unk_token}

    # 利用 classmethod 方法,支持通过这一函数完成对于类的定义
    # 例如在实际使用的时候,除了直接用 x = Vocabulary() 进行类的定义,
    # 还可以利用 x = Vocabulary.from_serializable() 进行
    @classmethod
    def from_serializable(cls, contents):
        """
        从可串行化的字典中实例化一个「词汇表」类

        Args:
            contents (dict): 一个能够用于生成「词汇表」的可串行化字典

        Returns:
            (Vocabulary): 一个「词汇表」类
        """
        return cls(**contents)

创建了「词汇表」,但是最开始内容肯定是空的,这就需要添加「标记」的方法。

首先查找「标记」在「词汇表」中是否已经存在:

  • 如果存在,则直接返回该「标记」对应的「索引」;
  • 如果不存在,则记录该「标记」,并顺延一个「索引」分配给该「标记」,那之后,返回该「索引」

需要注意的是,由于我们创建了「标记」和「索引」之间双向的两个映射关系,添加「标记」也一定是同时更新。

为了方便,这里同样提供了多个标记同时添加的方法。

    def add_token(self, token):
        """
        基于「标记」更新映射字典

        - 有则检索,无则添加

        Args:
            token (str): 加入「词汇表」的「标记」内容

        Returns:
            (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:
            (list): 一个内容为「标记」对应「索引」的列表
        """
        return [self.add_token(token) for token in tokens]

作为一个「词汇表」肯定不能只进不出,因此,需要提供检索功能。由于「标记」和「索引」是双向的映射关系,这里需要两个方法描述两个方向的检索功能。

    def lookup_token(self, token):
        """
        检索「标记」对应的「索引」
            如果「标记」不存在,则检索「UNK 标记」的索引

        Args:
            token (str): 需要检索的额「标记」内容

        Returns:
            (int): 「标记」对应的「索引」

        - 需要注意的是,如果希望「标记」不存在时检索「UNK 标记」对应的「索引」,
        - 一定要确保「词汇表」的 add_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: 如果「索引」不在「词汇表」中

        - 与前一个功能不一样的地方在于,如果「索引」不能找到「标记」不是返回「UNK 标记」
        - 这是由于这一方法仅仅出现在最终结果打印的过程,
        - 很明显,我们并不期望这一方法在不报错的情况下输出一个无效的内容
        """
        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]

「词汇表」的功能基本完善了,但是它作为 python 的类还不够完善。(甚至不能对它使用 len方法!)因此需要对其基本属性进行一些补充。

# 后两个是为了方便调用 python 中默认的一些方法而设定的
    def __str__(self):
        return "<Vocabulary(size=%d)>" % len(self)

    def __len__(self):
        return len(self._token_to_idx)

「姓氏向量化器」

按照理论,「姓氏向量化器」只需要提供将姓氏向量化的功能就可以了,但是,这里为了方便「向量化器」的存储,以及方便后续的「向量化数据集」的定义。后面介绍保存到文件的时候会进一步解释。

向量化其实是在词汇表的基础上进行的很简单的操作,因此「姓氏向量化器」的属性只需要记录姓氏和国籍两个「词汇表」就可以了,初始化内容如下:

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

然后是我们希望向量化器具备的功能,将姓氏向量化。这里也是词袋模型最经典的原理部分:

  1. 通过查找「词汇表」得到「标记」对应的「索引」;
  2. 通过「索引」将每个「标记」转化为独热编码;
  3. 将独热编码进行累加,最终得到姓氏对应的向量。

实现方式:

    def vectorize(self, surname):
        """
        将一个姓氏向量化

        Args:
            surname (str): 姓氏(字符串)

        Returns:
            (np.ndarray): 一个折叠的独热编码
        """
        vocab = self.surname_vocab
        one_hots = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            # one_hots[vocab.lookup_token(token)] = 1
            # 原版使用的是上面的句子
            # 但是上面的代码似乎和词袋模型原理不太一致
            # 于是试试下面的这种
            # 最终结果是两个都能跑,但是下面的最终结果精度要高上一些
            one_hots[vocab.lookup_token(token)] += 1
        return one_hots

功能和属性都实现了,但是我们希望的是能够更简单地进行类的实例化。既然最终的「词汇表」是由数据集生成的,那就可以设计一种方法,从数据集中生成「姓氏向量化器」类:

    @classmethod
    def from_dataframe(cls, surname_df):
        """
        通过 pd.Dataframe 格式的数据集实例化「姓氏向量化器」

        Args:
            surname_df (pd.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):
        """
        通过可串行化的字典实例化「姓氏向量化器」

        Args:
            contents (dict): 一个可以用于生成「姓氏向量化器」的可串行化字典

        Returns:
            (SurnameVectorizer): 根据可串行化字典生成的「姓氏向量化器」
        """
        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()}

由于这一类型仅仅是为了提供向量化功能,实现过程中很多 Python 内置方法要么作用于下一层的「词汇表」,要么作用于上一层的「向量化数据集」,这里并不需要补充对应的方法。

「姓氏数据集」

「姓氏数据集」的定位就是前文所说的「向量化数据集」但是由于「姓氏向量化器」提供了向量化方法,在表现形式上,「姓氏数据集」存储的并不是向量化之后的样本,而是原样本,只是在需要调用的时候经过「姓氏向量化器」的向量化方法。当然,这也就意味着,除了原始的数据集,「姓氏数据集」还需要记录从该数据集生成的「姓氏向量化器」。

既然是从数据集生成的,为什么还需要单独保存「姓氏向量化器」?

  • 这里是为了增强代码的可读性。后面提供的实例化代码实际上还是包括了生成的过程的。
  • 提供了从外部引入现存的「姓氏向量化器」的可能性

​​​​​​​​​​​​​​除了基础的数据部分,为了方便后续调用,我们将训练/验证/测试集合并为一个类,在调用的时候仅展示所选择的集合。因此,在初始化部分,还需要按照标签对数据集进行分类存储。

另外,由于之前讨论过的样本分布不均匀的问题,数据集应当设置对应的权重以提升样本量较少的类别的训练效果。

值得一提的是,由于后续使用的是 pytorch 的框架,「姓氏数据集」选择了继承 Dataset 类,以方便后续进一步包装为数据加载器供训练过程中使用。

class SurnameDataset(Dataset):
    """
    「姓氏数据集」类:
        将姓氏向量数据和标签数据进行关联进而形成用于训练/验证/测试的数据集

    - 这里选择继承 Dataset 类是由于训练过程使用 torch 包,因为选择继承 Dataset 类能够避免很多 torch 包相关的功能的定义
    - 这样就可以减少代码量,便于修改和讲解
    """

    def __init__(self, surname_df, vectorizer):
        """
        Args:
            surname_df (pd.DataFrame): pd.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)
        # 计算权重并记录在 class_weights 属性中
        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):
        """
        加载数据集并根据数据集创建一个「姓氏向量化器」
        

        Args:
            surname_csv (str): 姓氏数据集 csv 文件的位置

        Returns:
            (SurnameDataset): 通过数据集生成的「姓氏数据集」类
        """
        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):
        """
        加载数据集并加载「姓氏向量化器」
            用于「姓氏向量化器」已经被保存过,可以直接调用的情况

        Args:
            surname_csv (str): 姓氏数据集 csv 文件的位置
            vectorizer_filepath (str): 记录「姓氏向量化器」的 json 文件位置

        Returns:
            (SurnameDataset): 通过数据集和已经定义的「姓氏向量化器」生成的「姓氏数据集」类
        """
        surname_df = pd.read_csv(surname_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(surname_df, vectorizer)

    # 使用@staticmethod装饰器可以将一个方法转换为静态方法,即使该方法定义在类中
    # 使用静态方法的主要优点是可以在不创建类实例的情况下调用该方法,从而提高代码的灵活性和可重用性
    # 例如,即使我没有实例化「姓氏数据集」类,我同样可以使用 SurnameDataset.load_vectorizer_only()方法
    # 获取一个「姓氏向量化器」,这样的操作是被允许的
    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """
        一个只从文件中调用「姓氏向量化器」而不调用数据集的方法

        Args:
            vectorizer_filepath (str): 记录「姓氏向量化器」的 json 文件位置

        Returns:
            (SurnameVectorizer): 根据 json 文件生成的「姓氏向量化器」类
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

既然能够从文件中读取得到可串行化的字典信息,也就应当需要定义写入文件的方法。

    def save_vectorizer(self, vectorizer_filepath):
        """
        将「姓氏向量化器」的参数保存为 json 文件

        Args:
            vectorizer_filepath (str): 记录「姓氏向量化器」的 json 文件位置
        """
        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]

同样由于三个集合定义在一起,就需要定义 len 属性,返回选择对应的长度而不是总长度,这样更符合逻辑。

    def __len__(self):
        """
        - 这里展示的是选择对应的长度而不是总长度,这样更符合逻辑
        """
        return self._target_size

最后,作为一个数据集类,应当定义取数据的方法。这里设计的是返回一个字典,包括姓氏对应的向量,以及姓氏国籍对应的「索引」。

    def __getitem__(self, index):
        """
        PyTorch 数据集的主要取元素方法

        Args:
            index (int): 取数据的索引         
            - 需要注意的是,这里的索引和「标记」的「索引」并非一个概念
            - 这里的索引指的是样本的索引,也就是第几个样本
            - 「标记」的「索引」指的是和「标记」一一对应的数字标识

        Returns:
            (dict): 一个包含以下特征的字典:
                ('x_surname': np.ndarray): 向量化的姓氏特征
                ('y_nationality': str): 姓氏对应的标签
        """
        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):
        """
        给定批次大小,返回对应的批次数量

        Args:
            batch_size (int): 批次大小

        Returns:
            (int): 批次数量

        - 这里将剩余数量不满一个批次大小的样本进行舍弃
        """
        return len(self) // batch_size

至此,词袋模型相关的定义已经全部完成了。可以看到,整个过程就是一个简单的功能拼凑,需要实现什么功能,就写出对应的方法,一步步完成的过程并不会感觉到困难。

3.4 MLP 部分

这一部分实际上很多功能都能够使用 PyTorch 框架中已经定义的各种方法和类,这里只需要定义最基本的网络结构即可。一些计算梯度、反向传播、各种优化器一类的在 PyTorch 框架中已经存在比较完备定义。

MLP 网络结构

这里选择继承 nn.Moudle 类,以便于调用前面所说的 PyTorch 框架中的各类功能。

本文使用的双层感知机模型(因为简单)。按照原理部分的结构划分就是两个线性全连接层。

线性全连接层:

  • 因为是加权求和,没有幂次运算,所以是线性
  • 因为从上一层到下一层每两个节点之间都赋予了权重,所以是全连接

​​​​​​​PyTorch框架下提供了定义好的线性全连接层,因此可以直接使用。

class SurnameClassifier(nn.Module):
    """ 姓氏分类 的 两层感知机模型 """

    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)

这里我并没有将激活函数之类的算作一层,因此并没有在初始化的部分将它们写进去。但是这是不会影响最终结果的,因为模型的计算是通过 forward 方法进行的。只需要将对应的激活函数加在 forward 方法中即可。

    def forward(self, x_in, apply_softmax=False):
        """
        模型的前向传播过程

        Args:
            x_in (torch.Tensor): 输入的数据张量
                - x_in.shape 应当为 (batch, input_dim)

            apply_softmax (bool): 是否使用 softmax 方法的标志位
                - 由于 torch.nn 中定义的交叉熵函数中设有 softmax 方法
                - 如果使用 torch.nn 中的交叉熵函数,这里需要置为 False
                - 如果使用自行编写的交叉熵函数,则以自己的交叉熵函数为准

        Returns:
            (torch.Tensor): 输出的张量.
                - 张量是 shape 属性应当为 (batch, output_dim)
        """
        intermediate_vector = F.relu(self.fc1(x_in))

        prediction_vector = self.fc2(intermediate_vector)

        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)

        return prediction_vector

这里提供了一个选项,即是否将最终结果经过一次 softmax 层。由于 PyTorch 中的交叉熵函数自带有 softmax 层,这里如果使用,则不开启;如果使用自己定义的交叉熵函数,则可以考虑开启。

什么是 softmax?

按照给定的模型运行,最终可能产生各种各样的结果,让结果直接等同于预测的概率更是异想天开,这个时候就可以使用 softmax,将结果转化为可以直接使用的概率值。

P(y_i) = {\rm softmax}(y_i) = \displaystyle\frac{e^{y_i}}{\sum\limits_{i=0}^ne^{y_i}}

晦涩难懂?上例子!

假如,经过模型最终输出为 [-20, 0.1, 32, 100],那么经过 softmax 之后的结果计算如下:

P(-2) = \displaystyle\frac{e^{-2}}{e^{-2} + e^{0} + e^{2} + e^{3}} = 0.0047\\ P(0) = \displaystyle\frac{e^{0}}{e^{-2} + e^{0} + e^{2} + e^{3}} = 0.0350\\ P(2) = \displaystyle\frac{e^{2}}{e^{-2} + e^{0} + e^{2} + e^{3}} = 0.2583\\ P(3) = \displaystyle\frac{e^{3}}{e^{-2} + e^{0} + e^{2} + e^{3}} = 0.7020\\ P(-2)+P(0)+P(2)+P(3)=1​​​​​​​

也就是说,经过了 softmax 之后输出变成了[0.0047,0.0350,0.2583,0.7020]所有的数值都在 0 到 1 之间,且和为 1.因此,softmax 之后的结果能够直接用于表示预测的概率。

什么是交叉熵函数?

一个表示预测结果和真实结果之间差距的函数。交叉熵越小,说明二者越相似。公式如下:

L(x) = -\sum\limits_{I=0}^n\left(p(x_i)\cdot\log q(x_i)\right )

其中,p(x_i)​​​​​​​表示实际为x_i的概率,q(x_i)表示预测为x_i的概率。

至此,我们已经完成了对于 MLP 网络结构的定义。

3.5 功能函数部分

这一部分就是最终读取数据、训练、以及模型的保存记录方面,需要定义的函数或者概念。下面依次进行讲解。

首先是数据加载器。由于我们定义的数据集在使用的状态下可能需要以批次为单位用于训练,因此需要将 DataSet 形式的数据集转化为 DataLoader 形式。

def generate_batches(dataset, batch_size, shuffle=True, drop_last=True, device="cpu"):
    """
    一个包装了 PyTorch 数据加载器的迭代器。
        它能够确保每个张量都在给定的设备上。

    
    - 最终的返回值类型为字典,包括"x_surname"和"y_nationality"两个键
    - 每个键对应的值为按照批次大小,增加了一个维度并将多个向量合并得到的一个张量
    """
    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

在训练过程中,可能会出现模型效果波动的情况,这就需要一个能够记录历史变化以及记录最佳状态的训练状态结构。

def make_train_state(args):
    """
    定义一个记录训练状态的字典

    Args:
        args (argparse.Namespace): 一个记录了基础的训练参数的参数空间

    Returns:
        (dict): 一个记录了训练状态的字典
    """
    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):
    """
    处理训练状态的更新相关问题

    包括:
     - Early Stopping: 早停止,防止过拟合
     - Model Checkpoint: 如果训练过程中某个模型效果更好,模型参数会被保存

    Args:
        args (argparse.Namespace): 模型的主要参数
        model (nn.Moudle): 训练用的模型
        train_state (dict): 记录模型训练状态的字典

    Returns:
        (dict): 更新之后的模型训练状态字典
    """
    # 原代码提供了虚假的早停功能,这一点需要进行一些改进:更新 early_stopping_best_val
    # 至少保存一个模型参数, 保存到设置好的文件中
    if train_state['epoch_index'] == 0:
        loss_t = train_state['val_loss'][-1:]
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['early_stopping_best_val'] = loss_t
        train_state['stop_early'] = False

    # 如果模型表现更好,将这一轮的权重保存在文件中
    elif train_state['epoch_index'] >= 1:
        loss_t = train_state['val_loss'][-1:]

        # 如果损失大于最好的值
        if loss_t >= train_state['early_stopping_best_val']:
            # 更新早停止步数
            train_state['early_stopping_step'] += 1

        # 如果损失小于最好的值
        else:
            # 保留此时的模型参数
            torch.save(model.state_dict(), train_state['model_filename'])

            # 更新最优损失
            train_state['early_stopping_best_val'] = loss_t

            # 重置早停止步数
            train_state['early_stopping_step'] = 0

        # 如果早停止步数达到设定的限度,早停止标志置为真,以便提前结束训练过程
        train_state['stop_early'] = train_state['early_stopping_step'] >= args.early_stopping_criteria

    return train_state

可以在外部设置早停止的最大步数,超过这一步数模型效果仍然没有变好,就认为模型已经不会精进了,因此需要进行早停止。如果不希望启用早停止也很简单,将早停止最大步数设置到超过最大训练轮数即可。

你可能会对上面代码中的 args 感到疑惑。

这个变量是什么?为什么有早停止属性?

其实这是我们开头环境中的一个包:

from argparse import Namespace

引入了这个 Namespace 函数之后,我们就可以定义这样的一个参数空间:

args = Namespace(
    c="唱",
    t="跳",
    r="rap",
    l="篮球",
    m="music"
)

然后我们就能用 args.c这样的方法调用对应的内容了。这样的结构方便参数传递,能够用一个变量传递所有需要的参数。因此,本次实验中主要的参数都集中记录在了一个 args 当中。

展示训练效果所需:计算准确率

def compute_accuracy(y_pred, y_target):
    """
    计算准确率

    Args:
        y_pred (torch.Tensor): 预测结果
        y_target (torch.Tensor): 目标结果

    Returns:
        (float) 准确率计算结果
    """
    _, 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 set_seed_everywhere(seed, cuda):
    """
    设定随机种子

    Args:
        seed (int): 设定的随机种子
        cuda (bool): 是否启用 cuda
    """
    np.random.seed(seed)
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)

文件操作所需:处理文件路径

def handle_dirs(dirpath):
    """
    处理路径
        - 如果路径存在则不变
        - 如果路径不存在则创建路径

    Args:
        dirpath (str): 给定路径
    """
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)

训练前的准备工作:

def prepare(args):
    """
    根据设定参数进行训练或者验证前的准备工作

    Args:
        args (argparse.Namespace): 模型的主要参数
    """
    # 路径拼接部分
    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))

    # 检查 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))

    # 设置随机种子部分
    set_seed_everywhere(args.seed, args.cuda)

    # 处理路径部分
    handle_dirs(args.save_dir)

根据选择创建或者加载数据集和向量化器:

def get_dataset(args):
    """
    根据设定参数返回对应的数据集

    Args:
        args (argparse.Namespace): 模型的主要参数

    Returns:
        (SurnameDataset): 所需要的「姓氏数据集」类
    """
    if args.reload_from_files:
        # training from a checkpoint
        print("Reloading!")
        dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                                  args.vectorizer_file)
    else:
        # create dataset and vectorizer
        print("Creating fresh!")
        dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
        dataset.save_vectorizer(args.vectorizer_file)

    return dataset

至此,所有功能准备完毕,下面开始训练。

3.6 模型训练部分

这一部分就是问题能够利用模型解决的最主要的部分。包括训练准备、训练过程、训练效果展示三个部分。当然在最最开始,先需要设定主要的参数:

args = Namespace(
    # 数据文件路径部分
    surname_csv="../data/surnames_with_splits.csv",  # 姓氏数据集文件位置
    vectorizer_file="vectorizer.json",  # 姓氏向量化器文件位置
    model_state_file="model.pth",  # 模型预训练权重文件位置
    save_dir="../data/surname_mlp",  # 上面两个文件所在文件夹

    # 模型参数部分
    hidden_dim=300,  # 模型隐藏层节点数(神经元个数)

    # 训练相关参数部分
    seed=1337,  # 随机种子
    num_epochs=100,  # 最大训练轮数
    early_stopping_criteria=5,  # 早停止最大步数
    learning_rate=0.001,  # 学习率
    batch_size=64,  # 批次大小

    # 运行相关选项
    cuda=False,  # 是否启用 cuda(或启用 GPU)
    reload_from_files=False,  # 是否从文件中加载现有向量化器
    expand_filepaths_to_save_dir=True,  # 是否启用文件夹存储向量化器和权重文件
)

训练准备

主要就是按照需求进行环境的准备,数据的读取,以及训练设备的转移等等。由于是训练,这里不需要加载训练好的模型参数,也没有已经创建的「姓氏向量化器」进行使用,一切都是从 3.2 数据预处理之后的数据集开始的。

# 环境准备
prepare(args)

# 获取数据集
dataset = get_dataset(args)

# 获取对应 向量化器 和 可训练模型
vectorizer = dataset.get_vectorizer()
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                               hidden_dim=args.hidden_dim,
                               output_dim=len(vectorizer.nationality_vocab))

# 如果启用 cuda 则需要将数据和模型转移到 GPU 上
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 = hp.make_train_state(args)

# 支持使用编译器的停止按键进行中途的停止,且最终的结果仍然会被保存进对应的文件
# 这样能够保证即便由于某些原因需要中途停止,本次训练结果仍然能够被保留
try:
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index

        # 在训练集上迭代训练

        # 创建训练集对应迭代器
        dataset.set_split('train')
        batch_generator = hp.generate_batches(dataset,
                                           batch_size=args.batch_size,
                                           device=args.device)
        # 包裹进度条
        train_bar = tqdm(enumerate(batch_generator),
                         desc=f'epoch {epoch_index}, split=train',
                         total=dataset.get_num_batches(args.batch_size),
                         position=0,
                         leave=True,
                         colour='green')

        # 由于这里是利用累加进行计算的,需要在每一遍循环的开始将准确率和损失置零
        running_loss = 0.0
        running_acc = 0.0
        i = 0
        # 打开训练模型,启用 BN 层和 dropout 层
        classifier.train()

        for batch_index, batch_dict in train_bar:
            # 训练一般也就是下面 5 个步骤

            # --------------------------------------
            # 步骤 1:上一轮次梯度置零
            # - 这是由于 PyTorch 是使用累加的方式进行梯度的计算和反向传播的,因此只能在每个轮次梯度更新之前进行置零
            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()

            # step 5. 优化器迭代
            optimizer.step()
            # -----------------------------------------
            # 计算准确率
            acc_t = hp.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)
            # train_bar.update()
            # 注意,这里原代码使用的是 update 方法
            # 这是由于原代码使用的是单独创建进度条的方式
            # 这里我将其改写为 用 tqdm 包裹迭代器
            # 两种方式只能选其一
            # 否则就会出现莫名的进度条比迭代还快的错误显示

        # 记录训练过程中的状态
        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # 在验证集上查看效果

        # 数据准备,这一部分和训练时候基本一致
        # 不同的是数据集选择为验证集,同时模式改成验证模式
        dataset.set_split('val')
        batch_generator = hp.generate_batches(dataset,
                                           batch_size=args.batch_size,
                                           device=args.device)
        val_bar = tqdm(enumerate(batch_generator),
                       desc=f'epoch {epoch_index}, split=val  ',
                       total=dataset.get_num_batches(args.batch_size),
                       position=0,
                       leave=True,
                       colour='blue')
        running_loss = 0.
        running_acc = 0.
        # 关闭 BN 层和 Dropout 层
        classifier.eval()

        # 由于不需要进行参数更新,也就没有了梯度置零、反向传播以及优化器迭代的部分
        for batch_index, batch_dict in val_bar:
            # 计算输出
            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 = hp.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)
            # val_bar.update()

        # 记录验证过程中的状态
        train_state['val_loss'].append(running_loss)
        train_state['val_acc'].append(running_acc)

        # 更新训练状态,包括更新早停止参数
        train_state = hp.update_train_state(args=args,
                                            model=classifier,
                                            train_state=train_state)

        scheduler.step(train_state['val_loss'][-1])

        if train_state['stop_early']:
            break

except KeyboardInterrupt:
    print("Exiting loop")

损失函数使用交叉熵损失;

优化器使用 Adam 优化器,这一优化器具有一定的跳出局部最小值点的能力,且能够比较好的处理波动的部分;

学习率下降使用的是 ReduceLROnPlateau 进行,没有什么别的意义,只是这一方法最契合早停止的训练方法。

为什么要进行加入学习率下降?

反向传播的思想是沿着导数的反方向走,但是计算机不能进行连续的计算,只能进行离散的。因此就一定会设定每一步的步长。而步长就是学习率的作用。因此,

  • 学习率越大,步长越大,走的越快,但是结果越不精确
  • 学习率越小,步长越小,结果越精确,但是走的越慢

​​​​​​​因此,如果把握一个平衡,控制好学习率,在前期快速接近最优点附近,然后降低学习率,使得结果更精确,这样能够有效提升训练的效率。

实例化一个训练状态字典,以便于早停止。

另外,为了让训练进度可视化,这里使用 tqdm 进行迭代器的封装,从而在过程中进行显示。

原代码使用的是 tqdm_notebook,但是由于本人使用的不是 ipynotebook,而是 pyCharm,这里使用了普通的 tqdm 进行。

然后就是机器学习的经典循环:

重复以下 5 个步骤:

  1. 历史梯度清零。这是由于 PyTorch 是使用累加的方式进行梯度计算和传播的,因此在每一遍迭代的开始,都需要进行清空,防止影响本轮的训练结果。
  2. 计算预测结果
  3. 计算损失函数
  4. 反向传播
  5. 优化器迭代

通过这样 5 个步骤的循环,就能够实现模型的训练。不同模型的训练,不同之处也大多是模型、损失函数、优化器的不同,流程基本一致。

利用迭代的方式计算准确率和损失函数。这里使用的是一个经过了数学推导的递推表达式。

假设这是第 k 批训练数据,用 acc(x) 表示第 x 批数据计算结束之后的准确率,n(x)表示第 x 批数据中预测正确的个数,N 表示批次大小,那么就有:

acc(x) = \frac{\sum\limits_{i=1}^x n(i)}{x\cdot N}​​​​​​​

那么这一轮前后的准确率分别为:

acc(k-1) = \frac{\sum\limits_{i=1}^{k-1} n(i)}{(k-1)\cdot N} , acc(k) = \frac{\sum\limits_{i=1}^{k} n(i)}{k\cdot N} \\ \Rightarrow k\cdot N \cdot acc(k) - (k-1)\cdot N \cdot acc(k-1) = n(k)\\ \Rightarrow acc(k) = \frac{(k-1)\cdot acc(k-1) }{k}+\frac{n(k)}{k \cdot N} = acc(k-1) + \frac{1}{k}(\frac{n(k)}{N}-acc(k-1))

化成代码中的形式就是:

acc = acc+\frac{1}{k}(\frac{n(k)}{N}-acc)

其他的部分就是一些必要的参数更新,比如参数状态更新等等。基本上,这些部分在前面 3.3~3.5 节已经介绍过了。这里只需要稍加理解就可以明白每一句话是在干什么。

值得一提的是,使用 try except KeyboardInterrupt 可以允许我们通过停止运行的方式中断训练,且即便如此后续代码依旧会执行,也就是模型已经会进行测试和保存。这一点对于模型调试阶段而言有着相当多的好处。例如,在不希望训练,而只是为了此时循环后面的代码有没有错的为阶段,我们不希望训练的循环耽误这一部分的时间,这时候就可以通过点击一次“停止运行”来跳过训练的循环。

训练之后就是在测试集上测试训练后模型的效果了。

由于在训练的过程中,我们已经将表现最好的模型对应的权重保存在文件中了,所以这里我们可以直接从文件中调取对应的权重进行测试工作。

训练循环的代码看了好几遍,什么时候写入文件了?

写入文件的功能写在 3.5 功能函数部分里的训练状态更新函数(update_train_state)中,不太确定的可以回去看一下。

# 用表现最好的模型权重在测试集上进行预测
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 = hp.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 = hp.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”部分进行调用。最终就能够显示训练后的模型在测试集上的损失以及准确率效果。

这里放一下笔者运行代码的结果:

Expanded filepaths: 
	../data/surname_mlp/vectorizer.json
	../data/surname_mlp/model.pth
Using CUDA: False
Creating fresh!
epoch 0, split=train: 100%|██████████| 120/120 [00:00<00:00, 135.87it/s, acc=31.1, loss=2.69]
epoch 0, split=val  : 100%|██████████| 25/25 [00:00<00:00, 155.12it/s, acc=40.4, loss=2.43]
epoch 1, split=train: 100%|██████████| 120/120 [00:00<00:00, 130.83it/s, acc=41.8, loss=2.17]
epoch 1, split=val  : 100%|██████████| 25/25 [00:00<00:00, 151.31it/s, acc=41.6, loss=2.08]
epoch 2, split=train: 100%|██████████| 120/120 [00:00<00:00, 129.37it/s, acc=44.8, loss=1.86]
epoch 2, split=val  : 100%|██████████| 25/25 [00:00<00:00, 152.42it/s, acc=45.1, loss=1.93]
epoch 3, split=train: 100%|██████████| 120/120 [00:01<00:00, 114.36it/s, acc=46.7, loss=1.7]
epoch 3, split=val  : 100%|██████████| 25/25 [00:00<00:00, 143.02it/s, acc=44.3, loss=1.85]
epoch 4, split=train: 100%|██████████| 120/120 [00:00<00:00, 130.89it/s, acc=47.9, loss=1.61]
epoch 4, split=val  : 100%|██████████| 25/25 [00:00<00:00, 124.05it/s, acc=44.9, loss=1.79]
epoch 5, split=train: 100%|██████████| 120/120 [00:00<00:00, 124.63it/s, acc=48.7, loss=1.54]
epoch 5, split=val  : 100%|██████████| 25/25 [00:00<00:00, 149.77it/s, acc=45.5, loss=1.8]
epoch 6, split=train: 100%|██████████| 120/120 [00:00<00:00, 139.63it/s, acc=49.4, loss=1.49]
epoch 6, split=val  : 100%|██████████| 25/25 [00:00<00:00, 157.60it/s, acc=44.9, loss=1.77]
epoch 7, split=train: 100%|██████████| 120/120 [00:00<00:00, 139.14it/s, acc=50.3, loss=1.45]
epoch 7, split=val  : 100%|██████████| 25/25 [00:00<00:00, 155.52it/s, acc=47.6, loss=1.76]
epoch 8, split=train: 100%|██████████| 120/120 [00:00<00:00, 138.10it/s, acc=51.2, loss=1.41]
epoch 8, split=val  : 100%|██████████| 25/25 [00:00<00:00, 153.13it/s, acc=46.4, loss=1.74]
epoch 9, split=train: 100%|██████████| 120/120 [00:00<00:00, 131.41it/s, acc=51.5, loss=1.37]
epoch 9, split=val  : 100%|██████████| 25/25 [00:00<00:00, 149.36it/s, acc=47.9, loss=1.74]
epoch 10, split=train: 100%|██████████| 120/120 [00:00<00:00, 124.68it/s, acc=53.3, loss=1.34]
epoch 10, split=val  : 100%|██████████| 25/25 [00:00<00:00, 153.09it/s, acc=46.9, loss=1.75]
epoch 11, split=train: 100%|██████████| 120/120 [00:00<00:00, 131.65it/s, acc=53.3, loss=1.3]
epoch 11, split=val  : 100%|██████████| 25/25 [00:00<00:00, 153.33it/s, acc=49.2, loss=1.72]
epoch 12, split=train: 100%|██████████| 120/120 [00:00<00:00, 133.55it/s, acc=54, loss=1.28]
epoch 12, split=val  : 100%|██████████| 25/25 [00:00<00:00, 147.23it/s, acc=49.9, loss=1.72]
epoch 13, split=train: 100%|██████████| 120/120 [00:00<00:00, 126.71it/s, acc=54.9, loss=1.26]
epoch 13, split=val  : 100%|██████████| 25/25 [00:00<00:00, 137.11it/s, acc=49.2, loss=1.74]
epoch 14, split=train: 100%|██████████| 120/120 [00:00<00:00, 135.91it/s, acc=55.8, loss=1.23]
epoch 14, split=val  : 100%|██████████| 25/25 [00:00<00:00, 155.07it/s, acc=48.5, loss=1.71]
epoch 15, split=train: 100%|██████████| 120/120 [00:00<00:00, 133.75it/s, acc=56.2, loss=1.2]
epoch 15, split=val  : 100%|██████████| 25/25 [00:00<00:00, 155.08it/s, acc=48.9, loss=1.72]
epoch 16, split=train: 100%|██████████| 120/120 [00:00<00:00, 140.08it/s, acc=57, loss=1.18]
epoch 16, split=val  : 100%|██████████| 25/25 [00:00<00:00, 153.08it/s, acc=51.6, loss=1.73]
epoch 17, split=train: 100%|██████████| 120/120 [00:00<00:00, 140.02it/s, acc=58.3, loss=1.13]
epoch 17, split=val  : 100%|██████████| 25/25 [00:00<00:00, 151.24it/s, acc=49.8, loss=1.71]
epoch 18, split=train: 100%|██████████| 120/120 [00:00<00:00, 135.96it/s, acc=57.7, loss=1.12]
epoch 18, split=val  : 100%|██████████| 25/25 [00:00<00:00, 155.59it/s, acc=51.5, loss=1.69]
epoch 19, split=train: 100%|██████████| 120/120 [00:00<00:00, 140.23it/s, acc=58.8, loss=1.1]
epoch 19, split=val  : 100%|██████████| 25/25 [00:00<00:00, 152.64it/s, acc=51.1, loss=1.7]
epoch 20, split=train: 100%|██████████| 120/120 [00:00<00:00, 140.81it/s, acc=59.3, loss=1.09]
epoch 20, split=val  : 100%|██████████| 25/25 [00:00<00:00, 155.92it/s, acc=51.4, loss=1.71]
epoch 21, split=train: 100%|██████████| 120/120 [00:00<00:00, 139.08it/s, acc=60, loss=1.08]
epoch 21, split=val  : 100%|██████████| 25/25 [00:00<00:00, 146.83it/s, acc=52.1, loss=1.71]
epoch 22, split=train: 100%|██████████| 120/120 [00:00<00:00, 139.92it/s, acc=59.9, loss=1.06]
epoch 22, split=val  : 100%|██████████| 25/25 [00:00<00:00, 148.10it/s, acc=52.1, loss=1.7]
epoch 23, split=train: 100%|██████████| 120/120 [00:00<00:00, 140.04it/s, acc=60.4, loss=1.06]
epoch 23, split=val  : 100%|██████████| 25/25 [00:00<00:00, 152.39it/s, acc=52.6, loss=1.7]
Test loss: 1.6827056598663328;
Test Accuracy: 53.75

结果自上而下分别对应:

处理路径部分(成功扩充两个路径)

设备检测部分(检测到没有 cuda 可用)

数据集读取部分(未加载现有向量化器,显示新创建)

训练过程部分(23 个 epoch,触发早停止)

测试部分(成功打印损失和准确率)

这样的结果看起来并不是很高,但是不要忘记,这是一个多分类任务,但是我们准确率的计算只认为类型和预测完全正确才算做准确。实际上预测结果对真实的分类给予了比较高的概率,只是恰好有一个分类预测值要高一些。这一情况在后面查询测试方面会进行举例。

实际上,用最为简单的词袋模型和双层感知机能够达到这个精度已经非常幸运了。实际上笔者使用 cpu 进行训练也只用了一分钟不到的时间,这样的时间成本对于最终的准确率而言是非常能够接受的。

3.7 查询测试部分

在模型训练的最后,我们得到了一组反映模型性能的指标,损失和准确率。但是对于使用者而言,这样的数据是苍白的。使用者在意的一般只是:模型最终能不能返回正确的国籍分类。因此,我们需要提供查询测试的功能,让模型能够有一个交互式的输入输出功能。

首先是定义一个预测函数。不同与验证和测试的过程,这一次我们所要的不在仅仅是「索引」,而是「索引」对应的「标记」。因此这里单独编写一个预测函数,用于显示预测结果。

def predict_nationality(surname, classifier, vectorizer):
    """
    从一个新的(或者给定的)姓氏预测它的国籍

    Args:
        surname (str): 想要预测的姓氏字符串
        classifier (SurnameClassifer): 一个姓氏分类模型的实例
        vectorizer (SurnameVectorizer): 对应的「姓氏向量化器」

    Returns:
        一个记录了最可能的国籍及其对应概率的字典
    """
    # 将姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    # 由于模型在传入参数的过程中,一般按照批次传入
    # 为了使用模型,需要将原本的向量转化为一个批次大小为 1 的批次传入
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)
    # 通过模型预测结果
    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}

之后可以仿照训练的环境准备进行。不过这次,我们能够调用已经生成的「姓氏向量化器」了,所以记得将 reload_from_files 标记为 True。

args = Namespace(
    # 数据文件的路径部分
    surname_csv="../data/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="../data/surname_mlp",

    # 模型参数部分
    hidden_dim=300,

    # 训练相关参数部分
    seed=1337,
    num_epochs=100,
    early_stopping_criteria=5,
    learning_rate=0.001,
    batch_size=64,

    # 运行相关参数
    cuda=False,
    reload_from_files=True,
    expand_filepaths_to_save_dir=True,
)
# 准环境备
prepare(args)

# 数据读取
dataset = get_dataset(args)

# 模型与「向量化器」实例化
vectorizer = dataset.get_vectorizer()
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                                  hidden_dim=args.hidden_dim,
                                  output_dim=len(vectorizer.nationality_vocab))

# 读取权重
classifier.load_state_dict(torch.load(args.model_state_file))

然后用交互式语句修饰一下函数调用:

new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

最终就能够使用键盘输入想要检测的姓氏了。

这里我首先用自己的姓氏试了一下:

不出所料,错了。(哈哈)

再试试同学的:

居然,对了?这是为什么呢?

就像笔者在 3,6 节中对于结果的评价中说过的那样,由于这是个多分类问题,准确率能够反映的内容相当之少。这里我们可以尝试概率由高到低多显示几个看一看情况。

由于加上 softmax 层之后, MLP 的输出实际上就是预测的概率值,我们只需要在原本的预测函数后面稍加修改即可:

def predict_topk_nationality(name, classifier, vectorizer, k=5):
    """
    从一个新的(或者给定的)姓氏预测它的国籍

    Args:
        surname (str): 想要预测的姓氏字符串
        classifier (SurnameClassifer): 一个姓氏分类模型的实例
        vectorizer (SurnameVectorizer): 对应的「姓氏向量化器」
        k (int): 打印的概率最高结果的个数(默认为 5)

    Returns:
        一个由 记录了最可能的国籍及其对应概率的字典 组成的列表
    """
    # 将姓氏向量化
    vectorized_name = vectorizer.vectorize(name)
    # 由于模型在传入参数的过程中,一般按照批次传入
    # 为了使用模型,需要将原本的向量转化为一个批次大小为 1 的批次传入
    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)

    # 由于返回值的形状是(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']))

再次尝试两个例子:

可以观察到:虽然姓氏 Li 的预测结果并不是希望的结果,但是实际上正确的国籍(Chinese)和目前的预测结果概率相差仅仅为 0.03,这样的差距对于一个相当简单的模型而言是可以接受的。

姓氏Wang 则是正确标签和第二名相差较大,并没有出现误判的情况。

四、小结

首先还是非常感谢各位能够读到这里的!

本文主要进行的是对于一个课程给定的代码进行微调以及讲解的过程,大部分代码并非笔者原创,对于这一点笔者还是比较愧疚的。

由于篇幅较大,读起来可能会费些时间,但是某些地方可能还是没有讲到小白的痛点上,依然看不懂的地方欢迎评论区或者私信留言。

如果遇到了文中哪些地方阅读有困难,觉得讲述的不够清晰的,或者觉得笔者哪里讲的存在问题的,欢迎在评论区讨论或批评指正,也欢迎私信交流讨论。笔者在这里谢过各位了!

另外,文章使用的所有源代码同样会附上,同样希望大佬们能够提出宝贵的修改意见,球球了!(QAQ)

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值