基于多层感知器的姓氏分类

目录

一、实验内容

1、实验内容

2、实验要点

3、实验环境

二、实验原理

1、多层感知机MLP

2、卷积神经网络CNN

三、实验步骤

1、基于多层感知机姓氏分类

1.1 构建数据集

1.2 处理文本并提取词汇表

1.3 词汇表、向量化和数据加载

1.4 模型构建

1.5 模型训练

1.5.1 辅助函数

1.5.2 准备工作

1.5.3 初始化

1.5.4 训练循环

1.6 模型评估

1.6.1 测试集上的评估

1.6.2 分类新姓氏

1.6.3 检索新姓氏的前k个预测

1.7 正则化多层感知器(Dropout)

1.7.1 什么是Dropout层

1.7.2 模型改进

2、基于卷积神经网络的姓氏分类

2.1 模型原理

2.2 构建数据集

2.3 词汇表、向量化和数据加载

2.4 模型构建

2.5 模型训练

2.6 模型评估

四、实验总结

1、多层感知机MLP

2、卷积神经网络CNN

3、MLP和CNN对比

五、完整代码

1、基于多层向量机(MLP)的姓氏分模型

2、基于卷积神经网络(CNN)的姓氏分类

一、实验内容

1、实验内容

感知器是现存最简单的神经网络。感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如,查看图4-1中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(也称为线性可分)。在这个例子中,感知器失败了。

在本次实验中,我们将探索传统上称为前馈网络的神经网络模型,以及两种前馈神经网络:多层感知器和卷积神经网络。多层感知器在结构上扩展了我们在实验3中研究的简单感知器,将多个感知器分组在一个单层,并将多个层叠加在一起。我们稍后将介绍多层感知器,并在“基于多层感知器的姓氏分类”中展示它们在多层分类中的应用。

本实验研究的第二种前馈神经网络,卷积神经网络,在处理数字信号时深受窗口滤波器的启发。通过这种窗口特性,卷积神经网络能够在输入中学习局部化模式,这不仅使其成为计算机视觉的主轴,而且是检测单词和句子等序列数据中的子结构的理想候选。我们在“卷积神经网络”中概述了卷积神经网络,并在“示例:使用CNN对姓氏进行分类”中演示了它们的使用。

在本实验中,多层感知器和卷积神经网络被分组在一起,因为它们都是前馈神经网络,并且与另一类神经网络——递归神经网络(RNNs)形成对比,递归神经网络(RNNs)允许反馈(或循环),这样每次计算都可以从之前的计算中获得信息。在实验6和实验7中,我们将介绍RNNs以及为什么允许网络结构中的循环是有益的。

在我们介绍这些不同的模型时,需要理解事物如何工作的一个有用方法是在计算数据张量时注意它们的大小和形状。每种类型的神经网络层对它所计算的数据张量的大小和形状都有特定的影响,理解这种影响可以极大地有助于对这些模型的深入理解。

2、实验要点

  • 通过“基于多层感知器的姓氏分类”,掌握多层感知器在多层分类中的应用
  • 掌握每种类型的神经网络层对它所计算的数据张量的大小和形状的影响

3、实验环境

  • Python 3.6.7

二、实验原理

1、多层感知机MLP

多层感知机(Multilayer Perceptron,MLP)是一种基本的前馈人工神经网络,被认为是最基本的神经网络构建模块之一。由多个神经元组成的多层结构。它包括输入层、至少一个或多个隐藏层以及一个输出层。每个隐藏层和输出层都由多个神经元(也称为节点)组成,每个节点都与前一层的每个节点相连,并具有一组权重和偏置。MLP的基本工作原理如下:

  1. 输入层:接受原始输入数据的层。每个输入特征被表示为一个节点,并传递给下一层。

  2. 隐藏层:通过对输入层的加权组合和应用激活函数来生成新的特征表示。每个隐藏层都有一组权重,用于加权输入,然后将加权输入传递给激活函数。常用的激活函数包括ReLU(Rectified Linear Unit)、Sigmoid和Tanh等。

  3. 输出层:生成最终的输出。输出层的节点数取决于任务的性质,例如分类问题中可能有多个类别,回归问题中可能只有一个输出节点。输出层也会应用一个激活函数,例如对于二分类问题,常用的激活函数是Sigmoid函数,对于多分类问题,常用的是Softmax函数。

  4. 反向传播算法:用于训练MLP的主要方法之一。通过反向传播算法,MLP可以根据预测结果与真实标签之间的误差来调整权重和偏置,从而逐步提高模型的性能。

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

MLP可以用于解决各种机器学习任务,包括分类、回归和聚类等。通过增加隐藏层的数量和神经元的数量,MLP可以表示更复杂的函数关系,从而提高模型的灵活性和性能。

2、卷积神经网络CNN

在神经网络中,不同类型的层对输入数据张量的大小和形状都有影响。例如:

  • 全连接层(Dense):全连接层将输入数据张量展平,并将其与权重矩阵相乘,输出一个新的张量。这会改变张量的形状和大小。
  • 卷积层(Convolutional):卷积层通过滑动卷积核在输入数据上提取特征。卷积操作会改变数据张量的大小,通常会减小数据的空间维度。
  • 池化层(Pooling):池化层用于减小特征图的空间维度,通常通过取最大值或平均值来实现。池化操作会改变数据张量的大小,但不会改变其深度。

假设我们有一个输入图像,尺寸为32x32像素,有3个颜色通道(例如RGB图像),那么我们的输入张量形状为(32, 32, 3)。

(1)卷积层 (Convolutional Layer)

假设我们使用一个卷积层,参数如下:

  • 过滤器数量(输出通道数):8
  • 过滤器大小(核大小):3x3
  • 填充(padding):1(same padding)
  • 步幅(stride):1

计算过程:

  • 输入张量形状:(32, 32, 3)
  • 卷积核大小:3x3
  • 填充:1
  • 步幅:1

计算公式:

[ \text{输出高度} = \left\lfloor \frac{\text{输入高度} - \text{卷积核高度} + 2 \times \text{填充}}{\text{步幅}} \right\rfloor + 1 ]

[ \text{输出宽度} = \left\lfloor \frac{\text{输入宽度} - \text{卷积核宽度} + 2 \times \text{填充}}{\text{步幅}} \right\rfloor + 1 ]

输出张量形状:(32, 32, 8)

(2)池化层 (Pooling Layer)

假设我们使用一个最大池化层(Max Pooling),参数如下:

  • 池化窗口大小:2x2
  • 步幅:2

计算过程:

  • 输入张量形状:(32, 32, 8)

计算公式:

[ \text{输出高度} = \left\lfloor \frac{\text{输入高度}}{\text{步幅}} \right\rfloor ]

[ \text{输出宽度} = \left\lfloor \frac{\text{输入宽度}}{\text{步幅}} \right\rfloor ]

输出张量形状:(16, 16, 8)

(3)全连接层 (Fully Connected Layer)

假设我们使用一个全连接层,将前一层的输出展平并连接到一个具有10个神经元的输出层。

计算过程:

  • 输入张量形状:(16, 16, 8)
  • 展平操作:16 x 16 x 8 = 2048

输出张量形状:(2048,)

经过全连接层:

  • 输出神经元数量:10

输出张量形状:(10,)

(4)总结

通过以上步骤,我们可以看到每种神经网络层如何影响输入张量的形状:

神经网络层输入输出
卷积层(32,32,3)(32,32,8)
池化层(32,32,8)(16,16,8)
全连接层(16,16,8)(2048,)到(10,)

三、实验步骤

1、基于多层感知机姓氏分类

我们将MLP应用于将姓氏分类到其原籍国的任务。从公开观察到的数据推断人口统计信息(如国籍)具有从产品推荐到确保不同人口统计用户获得公平结果的应用。人口统计和其他自我识别信息统称为“受保护属性”。在建模和产品中使用这些属性时,必须小心。我们使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的管道。我们将通过描述姓氏分类器模型及其设计背后的思想过程来继续本节。我们还引入了多类输出及其对应的损失函数。在描述了模型之后,我们完成了训练例程。

1.1 构建数据集

我们导入给定的数据集surnames.csv(姓氏数据集)。

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

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

首先,我们需要导入本次代码运行所需要的库

代码如下:

from argparse import Namespace
from collections import Counter
import json
import os
import string

import numpy as np
import pandas as pd

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 tqdm import tqdm_notebook

我们先定义一个自定义的PyTorch数据集类SurnameDataset,它是PyTorch中Dataset类的子类,因此需要我们实现两个函数:__getitem方法,它在给定索引时返回一个数据点;以及len方法,该方法返回数据集的长度。在本次实验中,__getitem方法返回一个向量化的姓氏和与其国籍相对应的索引。我们用这个类加载和处理姓氏数据集。

代码如下:

class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        Args:
            surname_df (pandas.DataFrame): the dataset
            vectorizer (SurnameVectorizer): vectorizer instatiated from dataset
        """
        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 weights
        class_counts = surname_df.nationality.value_counts().to_dict()
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)

    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            surname_csv (str): location of the dataset
        Returns:
            an instance of 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):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            surname_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(surname_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of SurnameVectorizer
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """ selects the splits in the dataset using a column in the dataframe """
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dictionary holding the data point's:
                features (x_surname)
                label (y_nationality)
        """
        # 获取指定索引的数据行
        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):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size

    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"): 
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    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

1.2 处理文本并提取词汇表

接下来我们定义一个名为Vocabulary的类,用于处理文本并提取词汇表以进行映射。该类具有以下功能:

  • 初始化方法:初始化词汇表对象,可以传入预先存在的单词到索引的映射,也可以选择是否添加未知单词标记。
  • to_serializable方法:返回一个可以序列化的字典表示词汇表,将词汇表对象转换成可序列化的形式。
  • from_serializable方法:从一个序列化的字典实例化词汇表,将序列化的内容转换成词汇表对象。
  • add_token方法:基于单词更新映射字典,将单词添加到词汇表中,并返回单词对应的整数索引。
  • add_many方法:将单词列表添加到词汇表中,接受一个字符串单词列表,返回与单词对应的索引列表。
  • lookup_token方法:检索与单词关联的索引,如果单词不存在则返回未知单词的索引。该方法支持处理未知单词的功能。
  • lookup_index方法:返回与索引相关联的单词,如果索引不在词汇表中则会引发KeyError异常。
  • __str__和__len__方法:分别用于返回词汇表对象的字符串表示和返回词汇表中的单词数量。

代码如下:

class Vocabulary(object):
    """Class to process text and extract vocabulary for mapping"""

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        初始化方法

        Args:
            token_to_idx (dict): 一个预先存在的单词到索引的映射
            add_unk (bool): 一个标志,指示是否添加未知单词标记
            unk_token (str): 要添加到词汇表中的未知单词标记
        """

        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        self._idx_to_token = {idx: token 
                              for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk
        self._unk_token = unk_token
        
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token) 

    def to_serializable(self):
        """返回一个可以序列化的字典表示词汇表"""
        return {'token_to_idx': self._token_to_idx, 
                'add_unk': self._add_unk, 
                'unk_token': self._unk_token}

    @classmethod
    def from_serializable(cls, contents):
        """从一个序列化的字典实例化词汇表"""
        return cls(**contents)

    def add_token(self, token):
        """基于单词更新映射字典

        Args:
            token (str): 要添加到词汇表中的单词
        Returns:
            index (int): 单词对应的整数
        """
        try:
            index = self._token_to_idx[token]
        except KeyError:
            index = len(self._token_to_idx)
            self._token_to_idx[token] = index
            self._idx_to_token[index] = token
        return index
    
    def add_many(self, tokens):
        """将单词列表添加到词汇表中
        
        Args:
            tokens (list): 一个字符串单词列表
        Returns:
            indices (list): 与单词对应的索引列表
        """
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        """检索与单词关联的索引或者如果单词不存在则返回未知单词的索引
        
        Args:
            token (str): 要查找的单词
        Returns:
            index (int): 单词对应的索引
        Notes:
            `unk_index` 需要 >=0 (已经被添加到词汇表中)以支持未知单词的功能
        """
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        """返回与索引相关联的单词
        
        Args: 
            index (int): 要查找的索引
        Returns:
            token (str): 索引对应的单词
        Raises:
            KeyError: 如果索引不在词汇表中
        """
        if index not in self._idx_to_token:
            raise KeyError("索引 (%d) 不在词汇表中" % index)
        return self._idx_to_token[index]

    def __str__(self):
        return "<Vocabulary(size=%d)>" % len(self)

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

1.3 词汇表、向量化和数据加载

向量化器

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

虽然我们在这个示例中使用了收缩的one-hot,但是在后面的实验中,将了解其他向量化方法,它们是one-hot编码的替代方法,有时甚至更好。具体来说,在“基于CNN放入姓氏进行分类”中,将看到一个热门矩阵,其中每个字符都是矩阵中的一个位置,并具有自己的热门向量。

接下来,我们定义一个姓氏向量化器的类SurnameVectorizer。该类用于将姓氏转换为独热编码表示,并提供了从数据框实例化向量化器和序列化/反序列化的方法。这个类主要有以下几个重要方法:

  • __init__: 初始化方法,接受两个参数,分别是姓氏词汇表和国籍词汇表。
  • vectorize: 将姓氏转换为独热编码表示的方法。
  • from_dataframe: 从数据框中实例化向量化器的方法,会遍历数据框中的每一行,将姓氏和国籍添加到对应的词汇表中。
  • from_serializable: 从序列化内容实例化向量化器的方法。
  • to_serializable: 返回一个包含可序列化内容的字典,其中包括姓氏词汇表和国籍词汇表的可序列化表示。

代码如下:

class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""

    def __init__(self, surname_vocab, nationality_vocab):
        """
        初始化方法

        Args:
            surname_vocab (Vocabulary): 一个将字符映射到整数的词汇表
            nationality_vocab (Vocabulary): 一个将国籍映射到整数的词汇表
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

    def vectorize(self, surname):
        """
        将姓氏转换为独热编码表示

        Args:
            surname (str): 姓氏

        Returns:
            one_hot (np.ndarray): 压缩后的独热编码
        """
        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1
        return one_hot

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

        Args:
            surname_df (pandas.DataFrame): 姓氏数据集
        Returns:
            SurnameVectorizer的实例
        """
        surname_vocab = Vocabulary(unk_token="@")  # 创建一个新的姓氏词汇表,使用"@"作为未知标记
        nationality_vocab = Vocabulary(add_unk=False)  # 创建一个新的国籍词汇表,不添加未知标记

        # 遍历数据框中的每一行,将姓氏和国籍添加到对应的词汇表中
        for index, row in surname_df.iterrows():
            for letter in row.surname:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(surname_vocab, nationality_vocab)

    @classmethod
    def from_serializable(cls, contents):
        """从序列化内容实例化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()  # 获取国籍词汇表的可序列化表示
        }

1.4 模型构建

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

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

接下来我们定义一个用于分类姓氏的两层多层感知器模型,使用 PyTorch 构建的神经网络模型。SurnameClassifier 类继承自 nn.Module,这是 PyTorch 中定义神经网络模型的标准做法。该类主要有以下两个方法:

  • __init__ 方法用于初始化模型的结构。它接受三个参数:input_dim 表示输入向量的大小,hidden_dim 表示第一个线性层的输出大小,output_dim 表示第二个线性层的输出大小。在初始化方法中,两个线性层 (fc1fc2) 被创建,分别将输入数据映射到隐藏层和将隐藏层映射到输出层。
  • forward 方法定义了模型的前向传播过程。它接受输入张量 x_in 和一个布尔值 apply_softmax 作为参数。在前向传播过程中,输入数据首先经过第一个线性层,然后应用 ReLU 激活函数得到中间向量,最后经过第二个线性层得到预测向量。如果 apply_softmax 为 True,则对输出进行 softmax 处理,否则直接返回未经处理的输出。
输入输出
第一个线性层fc1(batch_size, input_dim)(batch_size, hidden_dim)
ReLu激活函数(batch_size, hidden_dim)(batch_size, hidden_dim)
第二个线性层fc2(batch_size, hidden_dim)(batch_size, output_dim)
Softmax激活函数(batch_size, output_dim)(batch_size, output_dim)

通过上表可以了解每种类型的神经网络层对它所计算的数据张量的大小和形状的影响。

代码如下:

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)  # 创建第二个线性层

    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:
            结果张量。张量形状应为 (batch, output_dim)
        """
        intermediate_vector = F.relu(self.fc1(x_in))  # 使用ReLU激活函数进行第一个线性层的计算
        prediction_vector = self.fc2(intermediate_vector)  # 计算第二个线性层的输出

        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)  # 如果需要应用softmax,则对输出进行softmax处理

        return prediction_vector

1.5 模型训练

1.5.1 辅助函数

我们先定义一些简单的神经网络训练过程中的辅助函数。

  • make_train_state(args): 这个函数用于创建训练状态的初始字典,其中包含了一些训练过程中需要跟踪的参数,比如早停标志、学习率、当前轮次等等。
  • update_train_state(args, model, train_state): 这个函数用于处理训练状态的更新。其中包含了早停策略的实现:如果验证集损失连续增加超过一定步数(由参数 args.early_stopping_criteria 控制),则停止训练。此外,如果验证集损失减小,则保存当前的模型参数。这个函数还会在每个轮次结束后保存模型参数,以便在训练过程中可以找到最好的模型。
  • compute_accuracy(y_pred, y_target): 这个函数用于计算模型的准确率。它接受模型的预测值和目标值作为输入,通过比较预测值的最大概率索引和目标值的一致性来计算准确率。

代码如下:

def make_train_state(args):
    """
    创建训练状态的初始字典

    Args:
        args: 主要参数

    Returns:
        代表训练状态值的字典
    """
    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):
    """处理训练状态更新的函数。

    组件:
     - 早停:防止过拟合。
     - 模型检查点:如果模型更好,则保存模型

    Args:
        args: 主要参数
        model: 要训练的模型
        train_state: 代表训练状态值的字典
    Returns:
        更新后的 train_state
    """
    
    # 至少保存一个模型
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # 如果当前轮次大于等于1
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]  # 获取前两个验证集损失值

        # 如果验证集损失变大
        if loss_t >= train_state['early_stopping_best_val']:
            # 更新早停步数
            train_state['early_stopping_step'] += 1
        # 如果验证集损失减小
        else:
            # 保存最佳模型
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

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

        # 是否早停?
        train_state['stop_early'] = \
            train_state['early_stopping_step'] >= args.early_stopping_criteria

    return train_state


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

    Args:
        y_pred: 预测值张量
        y_target: 目标值张量

    Returns:
        准确率
    """
    _, 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  # 返回准确率

然后定义通用工具,包含两个函数,用来设置随机种子和处理目录的辅助函数。

  • set_seed_everywhere(seed, cuda): 这个函数用于设置随机种子,其中seed是要设置的种子值,cuda是一个布尔值,表示是否使用CUDA。首先,它通过np.random.seed(seed)设置了NumPy库的随机种子,然后通过torch.manual_seed(seed)设置了PyTorch库的随机种子。如果cudaTrue,则通过torch.cuda.manual_seed_all(seed)设置所有CUDA设备的随机种子。

  • handle_dirs(dirpath): 这个函数用于处理目录。它接受一个目录路径作为输入,并检查该目录是否存在。如果目录不存在,则使用os.makedirs(dirpath)创建该目录。

代码如下:

def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)  # 设置NumPy的随机种子
    torch.manual_seed(seed)  # 设置PyTorch的随机种子
    if cuda:
        torch.cuda.manual_seed_all(seed)  # 设置所有CUDA设备的随机种子

def handle_dirs(dirpath):
    if not os.path.exists(dirpath):  # 如果目录不存在
        os.makedirs(dirpath)  # 创建目录
1.5.2 准备工作

它首先定义了一个包含各种参数的 args 对象,然后根据这些参数进行一些操作。

  1. 首先, args 对象包含了一系列的参数,例如数据文件路径、模型超参数、训练超参数等。
  2. 接着,如果 args.expand_filepaths_to_save_dir 为 True,则将 args.vectorizer_file 和 args.model_state_file 的文件路径扩展到保存目录中,并打印出扩展后的文件路径。
  3. 然后,检查是否有可用的 CUDA(用于 GPU 加速计算),如果没有,则将 args.cuda 设置为 False
  4. 接着,根据是否使用 CUDA 设置设备类型为 CPU 或者 CUDA。
  5. 最后,打印是否使用 CUDA,并设置全局随机种子以实现结果的可复现性。
  6. 最后一步是处理保存目录,如果目录不存在,则创建目录。

代码如下:

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
    cuda=False,  # 是否使用CUDA加速
    reload_from_files=False,  # 是否从文件重新加载模型
    expand_filepaths_to_save_dir=True,  # 是否将文件路径扩展到保存目录
)

if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)  # 向量化器文件完整路径
    args.model_state_file = os.path.join(args.save_dir, args.model_state_file)  # 模型状态文件完整路径
    
    print("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")  # 根据是否使用CUDA设置设备类型
    
print("Using CUDA: {}".format(args.cuda))  # 打印是否使用CUDA

# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda)  # 设置随机种子以实现可复现性

# handle dirs
handle_dirs(args.save_dir)  # 处理保存目录:如果不存在则创建

这段代码主要是对参数进行预处理,包括设置文件路径、检查 CUDA 是否可用、设置随机种子等。这些操作都是为了确保模型训练和保存的顺利进行。

运行结果:

1.5.3 初始化

接下来,我们加载或创建数据集和向量化器,并创建一个姓氏分类器。在训练或使用姓氏分类器时,加载数据集和向量化器,并根据需要重新加载或创建它们。

代码如下:

if args.reload_from_files:
    # 从文件重新加载数据集和向量化器
    print("Reloading!")
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv, args.vectorizer_file)
else:
    # 创建新的数据集和向量化器
    print("Creating fresh!")
    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))  # 设置输出维度为国籍词汇表大小

运行结果:

1.5.4 训练循环

对模型进行训练,主要包括以下几个步骤:

  1. 准备训练所需的设备、损失函数、优化器等设置。
  2. 使用循环来迭代每个训练周期(epoch)。
  3. 在每个训练周期中,分别对训练集和验证集进行迭代。
  4. 在每个迭代中,将输入数据传递给神经网络模型,计算输出并计算损失。
  5. 对损失进行反向传播(backpropagation)并更新模型的参数。
  6. 计算并记录训练和验证集上的损失和准确率。
  7. 使用学习率调度器(scheduler)来动态调整学习率。
  8. 当满足停止条件时,结束训练循环。

代码如下:

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

        # 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):
            # 训练过程包括以下5个步骤:

            # --------------------------------------
            # 步骤1. 将梯度归零
            optimizer.zero_grad()

            # 步骤2. 计算输出
            y_pred = classifier(batch_dict['x_surname'])

            # 步骤3. 计算损失
            loss = loss_func(y_pred, batch_dict['y_nationality'])
            loss_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

            # 步骤4. 使用损失计算梯度
            loss.backward()

            # 步骤5. 使用优化器更新参数
            optimizer.step()
            # -----------------------------------------
            # 计算准确率
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)

            # 更新进度条
            train_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            train_bar.update()
            
        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # 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.to("cpu").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")

运行结果:

这段代码的主要是训练一个神经网络模型来对姓氏进行分类,模型使用的是交叉熵损失函数,优化器是Adam,并在验证集上进行了模型性能的评估。

1.6 模型评估

1.6.1 测试集上的评估

接下来计算测试集上的损失和准确率。

要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。

  1. classifier.load_state_dict(torch.load(train_state['model_filename'])):加载之前保存的最佳模型参数,这可以确保我们在测试阶段使用的是在训练过程中表现最好的模型参数。

  2. classifier = classifier.to(args.device):将分类器移动到指定的设备(例如 CPU 或 GPU)。这是为了确保模型在指定的设备上运行。

  3. dataset.class_weights = dataset.class_weights.to(args.device):将类别权重也移动到相同的设备上。

  4. loss_func = nn.CrossEntropyLoss(dataset.class_weights):定义损失函数为交叉熵损失函数,并传入类别权重。这样可以在计算损失时考虑到类别不平衡的情况。

  5. dataset.set_split('test'):将数据集切换到测试集模式。

  6. batch_generator = generate_batches(dataset, batch_size=args.batch_size, device=args.device):生成测试集的批处理数据,以便对模型进行测试。

  7. running_loss = 0.running_acc = 0.:初始化总体损失和总体准确率为0。

  8. classifier.eval():设置模型为评估模式,这意味着模型将不会进行梯度计算,而是专注于输出预测结果。

  9. 使用一个循环遍历测试集的每个批次数据

  10. 最后,将测试集的平均损失和平均准确率保存到训练状态中,以便后续分析和记录。

代码如下:

# compute the loss & accuracy on the test set using the best available model

# 加载最佳模型的参数
classifier.load_state_dict(torch.load(train_state['model_filename']))  # 加载训练过程中保存的最佳模型参数

# 将分类器移动到指定的设备上
classifier = classifier.to(args.device)  # 将分类器移动到CPU或GPU上
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.  # 初始化总体损失为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.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']))

运行结果:

1.6.2 分类新姓氏

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

我们定义一个函数。函数接受一个姓氏、前面已经训练好的分类器实例和一个矢量化器实例作为输入。首先将输入的姓氏转换为矢量表示,然后使用分类器对其进行预测,并返回预测结果中概率最高的国籍及其概率值。

代码如下:

def predict_nationality(surname, classifier, vectorizer):
    """Predict the nationality from a new surname
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的矢量化器
    Returns:
        返回一个字典,包含最可能的国籍及其概率
    """
    # 将输入的姓氏进行矢量化
    vectorized_surname = vectorizer.vectorize(surname)  # 使用矢量化器将输入的姓氏转换为张量表示
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)  # 转换为张量并添加批处理维度

    # 使用分类器进行预测
    result = classifier(vectorized_surname, apply_softmax=True)  # 使用分类器进行预测,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}  # 返回预测的国籍及其概率值
new_surname = input("Enter a surname to classify: ")  # 获取用户输入的待分类姓氏

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

# 使用预定义的函数进行姓氏国籍分类预测
prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印预测结果
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))  # 打印预测的国籍及其概率值

运行结果:

1.6.3 检索新姓氏的前k个预测

不仅要看最好的预测,还要看更多的预测。例如,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)  # 转换为张量并添加批处理维度
    
    # 使用分类器进行预测并获取前k个最有可能的国籍及其概率值
    prediction_vector = classifier(vectorized_name, apply_softmax=True)  # 使用分类器进行预测,apply_softmax=True表示对输出应用softmax函数
    probability_values, indices = torch.topk(prediction_vector, k=k)  # 获取前k个最大的概率值和对应的索引
    
    # 将张量转换为numpy数组
    probability_values = probability_values.detach().numpy()[0]  # 将概率值张量转换为numpy数组
    indices = indices.detach().numpy()[0]  # 将索引张量转换为numpy数组
    
    results = []
    # 遍历前k个预测结果,将国籍及其概率值添加到results列表中
    for prob_value, index in zip(probability_values, indices):
        nationality = vectorizer.nationality_vocab.lookup_index(index)  # 根据索引查找对应的国籍
        results.append({'nationality': nationality, 'probability': prob_value})  # 将国籍及其概率值添加到results列表中
    
    return results  # 返回前k个最有可能的国籍及其概率值


new_surname = input("Enter a surname to classify: ")  # 获取用户输入的待分类姓氏
classifier = classifier.to("cpu")  # 将分类器移到CPU上进行推理

k = int(input("How many of the top predictions to see? "))  # 获取用户想要查看的前k个预测结果
if k > len(vectorizer.nationality_vocab):  # 如果用户输入的k大于支持的国籍数量
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")  # 打印提示消息
    k = len(vectorizer.nationality_vocab)  # 将k设置为支持的国籍数量

# 使用预定义的函数获取前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']))  # 打印姓氏、国

运行结果:

1.7 正则化多层感知器(Dropout)

1.7.1 什么是Dropout层

在神经网络的训练过程中,对于一次迭代中的某一层神经网络,先随机选择其中的一部分神经元并将其丢弃,然后再进行本次训练和优化。在下一次迭代中,继续随机丢弃一部分神经元,直至训练结束。由于是随机丢弃,所以不同批次都在训练不同的网络。

简单地说,在训练过程中,dropout有一定概率使属于两个相邻层的单元之间的连接减弱。这有什么用呢?我们从斯蒂芬•梅里蒂(Stephen Merity)的一段直观(且幽默)的解释开始:“Dropout,简单地说,是指如果你能在喝醉的时候反复学习如何做一件事,那么你应该能够在清醒的时候做得更好。这一见解产生了许多最先进的结果和一个新兴的领域。”

神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“Coadaptation”是神经科学中的一个术语,但在这里它只是指一种情况,即两个单元之间的联系变得过于紧密,而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接,我们可以确保没有一个单元总是依赖于另一个单元,从而产生健壮的模型。dropout不会向模型中添加额外的参数,但是需要一个超参数——“drop probability”。drop probability,它是单位之间的连接drop的概率。通常将下降概率设置为0.5。

1.7.2 模型改进

在本次实验中,我们采用Dropout进行结构正则化。在两层线性层中间加入了一层Dropout层,这样可以一定程度上防止过拟合。

代码如下:

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

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

        参数:
            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):
        """多层感知机的前向传播函数

        参数:
            x_in (torch.Tensor): 输入数据张量。
                x_in.shape 应为 (batch, input_dim)
            apply_softmax (bool): 是否应用 softmax 激活函数的标志,
                如果与交叉熵损失一起使用,应为 False

        返回:
            结果张量。tensor.shape 应为 (batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))  # 使用 ReLU 激活函数进行第一层线性层的计算
        output = self.fc2(F.dropout(intermediate, p=0.5))  # 使用 dropout 和第二层线性层进行计算

        if apply_softmax:
            output = F.softmax(output, dim=1)  # 应用 softmax 激活函数

        return output

请注意,dropout只适用于训练期间,不适用于评估期间。

我们用改进后的模型进行训练,输出其预测的平均损失和平均准确率,并与改进前的模型进行对比,再输出单个单词进行分类。为了保持统一,我们依旧选择Arabic输入。

输出结果:

通过对比,可以看到增加了Dropout层的MLP模型的平均准确率下降了,而平均损失也没有明显的下降。对于单个单词的分类,准确度明显下降,但是相比之下,预测概率其次高的几个分类概率得到提高,可以反映出增加Dropout层可以提高泛化能力,防止过拟合。

2、基于卷积神经网络的姓氏分类

2.1 模型原理

在本实验的第一部分中,我们深入研究了MLPs、由一系列线性层和非线性函数构建的神经网络。mlp不是利用顺序模式的最佳工具。例如,在姓氏数据集中,姓氏可以有(不同长度的)段,这些段可以显示出相当多关于其起源国家的信息(如“O’Neill”中的“O”、“Antonopoulos”中的“opoulos”、“Nagasawa”中的“sawa”或“Zhu”中的“Zh”)。这些段的长度可以是可变的,挑战是在不显式编码的情况下捕获它们。

为了证明CNN的有效性,让我们应用一个简单的CNN模型来分类姓氏。这项任务的许多细节与前面的MLP示例相同,但真正发生变化的是模型的构造和向量化过程。模型的输入,而不是我们在上一个例子中看到的收缩的onehot,将是一个onehot的矩阵。这种设计将使CNN能够更好地“view”字符的排列,并对在“示例:带有多层感知器的姓氏分类”中使用的收缩的onehot编码中丢失的序列信息进行编码。

2.2 构建数据集

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

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

先导入所需要的库

from argparse import Namespace
from collections import Counter
import json
import os
import string

import numpy as np
import pandas as pd

import torch
import torch.nn as nn
from torch.nn import Conv1d
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm_notebook

代码如下:

class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        Args:
            name_df (pandas.DataFrame): the dataset
            vectorizer (SurnameVectorizer): vectorizer instatiated from dataset
        """
        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 weights
        class_counts = surname_df.nationality.value_counts().to_dict()
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)


    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            surname_csv (str): location of the dataset
        Returns:
            an instance of 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):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            surname_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(surname_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of SurnameDataset
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """ selects the splits in the dataset using a column in the dataframe """
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dictionary holding the data point's features (x_data) and label (y_target)
        """
        row = self._target_df.iloc[index]

        surname_matrix = \
            self._vectorizer.vectorize(row.surname)

        nationality_index = \
            self._vectorizer.nationality_vocab.lookup_token(row.nationality)

        return {'x_surname': surname_matrix,
                'y_nationality': nationality_index}

    def get_num_batches(self, batch_size):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size

    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    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

2.3 词汇表、向量化和数据加载

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

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

代码如下:

class SurnameVectorizer(object):
    """用于协调词汇表并将其投入使用的向量化器"""

    def __init__(self, surname_vocab, nationality_vocab, max_surname_length):
        """
        Args:
            surname_vocab (Vocabulary): 将字符映射到整数的词汇表
            nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
            max_surname_length (int): 最长姓氏的长度
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab
        self._max_surname_length = max_surname_length

    def vectorize(self, surname):
        """
        Args:
            surname (str): 姓氏
        Returns:
            one_hot_matrix (np.ndarray): 一个独热向量矩阵
        """

        # 创建一个全零矩阵,形状为(词汇表大小,最长姓氏长度)
        one_hot_matrix_size = (len(self.surname_vocab), self._max_surname_length)
        one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
                               
        # 遍历姓氏中的每个字符,并将对应位置设置为1
        for position_index, character in enumerate(surname):
            character_index = self.surname_vocab.lookup_token(character)
            one_hot_matrix[character_index][position_index] = 1
        
        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据框实例化向量化器
        
        Args:
            surname_df (pandas.DataFrame): 姓氏数据集
        Returns:
            SurnameVectorizer的一个实例
        """
        surname_vocab = Vocabulary(unk_token="@")  # 实例化一个姓氏词汇表,指定UNK标记为@
        nationality_vocab = Vocabulary(add_unk=False)  # 实例化一个国籍词汇表,并禁用UNK标记
        max_surname_length = 0  # 初始化最长姓氏长度为0

        for index, row in surname_df.iterrows():
            max_surname_length = max(max_surname_length, len(row.surname))  # 更新最长姓氏长度
            for letter in row.surname:
                surname_vocab.add_token(letter)  # 将姓氏中的每个字符添加到姓氏词汇表中
            nationality_vocab.add_token(row.nationality)  # 将国籍添加到国籍词汇表中

        return cls(surname_vocab, nationality_vocab, max_surname_length)

    @classmethod
    def from_serializable(cls, contents):
        """从可序列化内容实例化向量化器"""
        surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])  # 从序列化内容中实例化姓氏词汇表
        nationality_vocab =  Vocabulary.from_serializable(contents['nationality_vocab'])  # 从序列化内容中实例化国籍词汇表
        return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab, 
                   max_surname_length=contents['max_surname_length'])

    def to_serializable(self):
        """返回一个可序列化的字典,用于保存向量化器的状态"""
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable(), 
                'max_surname_length': self._max_surname_length}

2.4 模型构建

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

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

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

代码如下:

class SurnameClassifier(nn.Module):
    def __init__(self, initial_num_channels, num_classes, num_channels):
        """
        Args:
            initial_num_channels (int): 输入特征向量的大小
            num_classes (int): 输出预测向量的大小
            num_channels (int): 网络中使用的常量通道大小
        """
        super(SurnameClassifier, self).__init__()
        
        # 定义卷积神经网络结构
        self.convnet = nn.Sequential(
            nn.Conv1d(in_channels=initial_num_channels, 
                      out_channels=num_channels, kernel_size=3),  # 第一层卷积层
            nn.ELU(),  # ELU激活函数
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),  # 第二层卷积层
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),  # 第三层卷积层
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3),  # 第四层卷积层
            nn.ELU()
        )
        self.fc = nn.Linear(num_channels, num_classes)  # 全连接层

    def forward(self, x_surname, apply_softmax=False):
        """分类器的前向传播
        
        Args:
            x_surname (torch.Tensor): 输入数据张量
                x_surname.shape 应为 (batch, initial_num_channels, max_surname_length)
            apply_softmax (bool): softmax激活标志
                如果与交叉熵损失一起使用,则应为false
        Returns:
            结果张量,张量形状应为 (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)  # 应用softmax激活函数得到概率分布

        return prediction_vector

2.5 模型训练

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

部分与前面相同的部分将不再重复展示,下面代码主要展示与mlp不同的部分。

代码如下:

args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/cnn",
    # 模型超参数
    hidden_dim=100,
    num_channels=256,
    # 训练超参数
    seed=1337,
    learning_rate=0.001,
    batch_size=128,
    num_epochs=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # 运行时选项
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
    catch_keyboard_interrupt=True
)

# 如果expand_filepaths_to_save_dir为True,则将文件路径扩展到保存目录中
if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)  # 将向量化器文件路径扩展到保存目录中
    args.model_state_file = os.path.join(args.save_dir, args.model_state_file)  # 将模型状态文件路径扩展到保存目录中
    
    print("Expanded filepaths: ")
    print("\t{}".format(args.vectorizer_file))  # 打印扩展后的向量化器文件路径
    print("\t{}".format(args.model_state_file))  # 打印扩展后的模型状态文件路径
    
# 检查CUDA
if not torch.cuda.is_available():  # 如果CUDA不可用
    args.cuda = False  # 设置args.cuda为False

args.device = torch.device("cuda" if args.cuda else "cpu")  # 设置args.device为cuda或者cpu
print("Using CUDA: {}".format(args.cuda))  # 打印是否使用CUDA

def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)  # 设置numpy随机种子
    torch.manual_seed(seed)  # 设置PyTorch随机种子
    if cuda:
        torch.cuda.manual_seed_all(seed)  # 设置所有可用GPU的随机种子
        
def handle_dirs(dirpath):
    if not os.path.exists(dirpath):  # 如果目录不存在
        os.makedirs(dirpath)  # 创建目录
        
# 为了可重现性设置随机种子
set_seed_everywhere(args.seed, args.cuda)  # 设置全局随机种子

# 处理目录
handle_dirs(args.save_dir)  # 确保保存目录存在,如果不存在则创建

运行结果:

通过模型循环训练,可以得到最终的训练结果

为了便于对比CNN和mlp,我们依旧选择输入Arabic进行预测

2.6 模型评估

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

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

代码如下:

def predict_nationality(surname, classifier, vectorizer):
    """预测姓氏的国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器实例
        vectorizer (SurnameVectorizer): 对应的数据向量化器
    Returns:
        包含最可能的国籍及其概率的字典
    """
    # 将输入的姓氏向量化
    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}

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

def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    """预测姓氏的前K个国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器实例
        vectorizer (SurnameVectorizer): 对应的数据向量化器
        k (int): 要返回的前K个国籍数
    Returns:
        包含前K个国籍及其概率的字典列表
    """
    
    # 将输入的姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)  # 将向量转为张量并增加维度以匹配模型输入
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)  # 使用分类器进行预测,并应用softmax获取概率分布
    probability_values, indices = torch.topk(prediction_vector, k=k)  # 获取前K个最大概率值及其对应的索引

    # 将张量转为numpy数组
    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

# 输入待分类的姓氏和要返回的前K个预测结果数
new_surname = input("Enter a surname to classify: ")
k = int(input("How many of the top predictions to see? "))

# 如果输入的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)

# 进行预测
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']))

运行结果:

四、实验总结

1、多层感知机MLP

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

这个示例的步骤包括以下几个方面:

  1. 数据准备:收集并准备用于训练和测试的姓氏数据集。数据集应包含姓氏及其对应的所属国家标签。

  2. 特征工程:将原始的姓氏数据转换为机器学习算法可以理解的特征表示形式。常见的特征工程方法包括独热编码、词袋模型等。

  3. 数据划分:将数据集划分为训练集和测试集。训练集用于模型的训练,测试集用于评估模型的性能。

  4. 模型构建:构建多层感知器模型。MLP 是一种前馈神经网络,由多个全连接层组成。每个层都由多个神经元组成,其中每个神经元与上一层的所有神经元相连接。

  5. 模型训练:使用训练集对 MLP 模型进行训练。训练过程通常包括定义损失函数、选择优化算法以及迭代更新模型参数。

  6. 模型评估:使用测试集评估训练好的 MLP 模型的性能。常用的评估指标包括准确率、精确率等。

  7. 预测应用:使用训练好的 MLP 模型对新的姓氏数据进行预测,即根据模型学习到的规律,将新的姓氏分类为对应的国家。

通过这个示例,我们可以学习到如何使用多层感知器作为一种多层分类模型,以自动从输入数据中学习特征,并进行分类任务。这是多层感知器在机器学习中的常见应用之一。

2、卷积神经网络CNN

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

以下是一个简要的步骤:

  1. 数据收集和准备:收集具有不同国家姓氏的数据集。确保数据集中包含足够数量的样本,并且每个样本都有其相应的标签(即所属的国家)。

  2. 数据预处理:将数据集分割成训练集、验证集和测试集。对姓氏进行标准化处理,例如转换为小写字母、移除特殊字符等。还要将文本转换为模型可处理的数字表示形式,例如使用单词嵌入或者单词索引。

  3. 构建CNN模型:设计一个适合姓氏分类的卷积神经网络模型。通常,这个模型包括几个卷积层、池化层和全连接层。卷积层用于提取特征,池化层用于减少特征图的大小,全连接层用于将提取的特征映射到输出类别。

  4. 模型训练:使用训练集来训练CNN模型。在训练过程中,通过反向传播算法更新模型的权重,以最小化预测错误。可以尝试不同的优化算法、学习率和正则化技术来提高模型的性能。

  5. 模型评估:使用验证集评估模型的性能。可以使用准确率、精确率、召回率等指标来评估模型的分类性能。根据评估结果对模型进行调整,以提高其性能。

  6. 模型测试:在测试集上对最终模型进行测试,评估其在未见过的数据上的泛化能力。确保模型在实际应用中表现良好。

  7. 模型部署:将训练好的模型部署到生产环境中,以便对新的姓氏进行分类预测。

通过这个示例,我们可以更好地掌握CNN在多层分类中的应用。

3、MLP和CNN对比

通过将这两个模型运用于姓氏分类,并输入同一姓氏Arabic进行分类,输出结果。

lossAccuracy分类1概率1分类2概率2分类3概率3
MLP1.80847.125Italian0.73French0.12Portuguese0.08
MLP改进1.79944.812Italian0.58French0.18Portuguese0.14
CNN1.99660.872Italian0.90French0.03Irish0.03

通过结果的对比,对于姓氏分类模型,卷积神经网络模型在测试损失值和准确率上都比多层向量机好很多。对于随机输入的姓氏Arabic,可以看到CNN在分类的准确率上明显优于MLP。因此,如果我们想要得到一个姓氏准确的国籍分类,我们应该选择CNN 用于姓氏分类。

五、完整代码

1、基于多层向量机(MLP)的姓氏分模型

代码如下:

# 导入所需要的库
from argparse import Namespace
from collections import Counter
import json
import os
import string

import numpy as np
import pandas as pd

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 tqdm import tqdm_notebook

# 定义字典词汇表
class Vocabulary(object):
    """Class to process text and extract vocabulary for mapping"""

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        初始化方法

        Args:
            token_to_idx (dict): 一个预先存在的单词到索引的映射
            add_unk (bool): 一个标志,指示是否添加未知单词标记
            unk_token (str): 要添加到词汇表中的未知单词标记
        """

        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        self._idx_to_token = {idx: token 
                              for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk
        self._unk_token = unk_token
        
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token) 

    def to_serializable(self):
        """返回一个可以序列化的字典表示词汇表"""
        return {'token_to_idx': self._token_to_idx, 
                'add_unk': self._add_unk, 
                'unk_token': self._unk_token}

    @classmethod
    def from_serializable(cls, contents):
        """从一个序列化的字典实例化词汇表"""
        return cls(**contents)

    def add_token(self, token):
        """基于单词更新映射字典

        Args:
            token (str): 要添加到词汇表中的单词
        Returns:
            index (int): 单词对应的整数
        """
        try:
            index = self._token_to_idx[token]
        except KeyError:
            index = len(self._token_to_idx)
            self._token_to_idx[token] = index
            self._idx_to_token[index] = token
        return index
    
    def add_many(self, tokens):
        """将单词列表添加到词汇表中
        
        Args:
            tokens (list): 一个字符串单词列表
        Returns:
            indices (list): 与单词对应的索引列表
        """
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        """检索与单词关联的索引或者如果单词不存在则返回未知单词的索引
        
        Args:
            token (str): 要查找的单词
        Returns:
            index (int): 单词对应的索引
        Notes:
            `unk_index` 需要 >=0 (已经被添加到词汇表中)以支持未知单词的功能
        """
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        """返回与索引相关联的单词
        
        Args: 
            index (int): 要查找的索引
        Returns:
            token (str): 索引对应的单词
        Raises:
            KeyError: 如果索引不在词汇表中
        """
        if index not in self._idx_to_token:
            raise KeyError("索引 (%d) 不在词汇表中" % index)
        return self._idx_to_token[index]

    def __str__(self):
        return "<Vocabulary(size=%d)>" % len(self)

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

# 构建数据集
class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        Args:
            surname_df (pandas.DataFrame): the dataset
            vectorizer (SurnameVectorizer): vectorizer instatiated from dataset
        """
        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 weights
        class_counts = surname_df.nationality.value_counts().to_dict()
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)

    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            surname_csv (str): location of the dataset
        Returns:
            an instance of 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):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            surname_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(surname_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of SurnameVectorizer
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """ selects the splits in the dataset using a column in the dataframe """
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dictionary holding the data point's:
                features (x_surname)
                label (y_nationality)
        """
        # 获取指定索引的数据行
        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):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size

    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"): 
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    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

class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""

    def __init__(self, surname_vocab, nationality_vocab):
        """
        初始化方法

        Args:
            surname_vocab (Vocabulary): 一个将字符映射到整数的词汇表
            nationality_vocab (Vocabulary): 一个将国籍映射到整数的词汇表
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

    def vectorize(self, surname):
        """
        将姓氏转换为独热编码表示

        Args:
            surname (str): 姓氏

        Returns:
            one_hot (np.ndarray): 压缩后的独热编码
        """
        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1
        return one_hot

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

        Args:
            surname_df (pandas.DataFrame): 姓氏数据集
        Returns:
            SurnameVectorizer的实例
        """
        surname_vocab = Vocabulary(unk_token="@")  # 创建一个新的姓氏词汇表,使用"@"作为未知标记
        nationality_vocab = Vocabulary(add_unk=False)  # 创建一个新的国籍词汇表,不添加未知标记

        # 遍历数据框中的每一行,将姓氏和国籍添加到对应的词汇表中
        for index, row in surname_df.iterrows():
            for letter in row.surname:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(surname_vocab, nationality_vocab)

    @classmethod
    def from_serializable(cls, contents):
        """从序列化内容实例化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()  # 获取国籍词汇表的可序列化表示
        }

# 模型构建
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)  # 创建第二个线性层

    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:
            结果张量。张量形状应为 (batch, output_dim)
        """
        intermediate_vector = F.relu(self.fc1(x_in))  # 使用ReLU激活函数进行第一个线性层的计算
        prediction_vector = self.fc2(intermediate_vector)  # 计算第二个线性层的输出

        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)  # 如果需要应用softmax,则对输出进行softmax处理

        return prediction_vector

def make_train_state(args):
    """
    创建训练状态的初始字典

    Args:
        args: 主要参数

    Returns:
        代表训练状态值的字典
    """
    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):
    """处理训练状态更新的函数。

    组件:
     - 早停:防止过拟合。
     - 模型检查点:如果模型更好,则保存模型

    Args:
        args: 主要参数
        model: 要训练的模型
        train_state: 代表训练状态值的字典
    Returns:
        更新后的 train_state
    """
    
    # 至少保存一个模型
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # 如果当前轮次大于等于1
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]  # 获取前两个验证集损失值

        # 如果验证集损失变大
        if loss_t >= train_state['early_stopping_best_val']:
            # 更新早停步数
            train_state['early_stopping_step'] += 1
        # 如果验证集损失减小
        else:
            # 保存最佳模型
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

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

        # 是否早停?
        train_state['stop_early'] = \
            train_state['early_stopping_step'] >= args.early_stopping_criteria

    return train_state


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

    Args:
        y_pred: 预测值张量
        y_target: 目标值张量

    Returns:
        准确率
    """
    _, 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):
    np.random.seed(seed)  # 设置NumPy的随机种子
    torch.manual_seed(seed)  # 设置PyTorch的随机种子
    if cuda:
        torch.cuda.manual_seed_all(seed)  # 设置所有CUDA设备的随机种子

def handle_dirs(dirpath):
    if not os.path.exists(dirpath):  # 如果目录不存在
        os.makedirs(dirpath)  # 创建目录

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
    cuda=False,  # 是否使用CUDA加速
    reload_from_files=False,  # 是否从文件重新加载模型
    expand_filepaths_to_save_dir=True,  # 是否将文件路径扩展到保存目录
)

if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)  # 向量化器文件完整路径
    args.model_state_file = os.path.join(args.save_dir, args.model_state_file)  # 模型状态文件完整路径
    
    print("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")  # 根据是否使用CUDA设置设备类型
    
print("Using CUDA: {}".format(args.cuda))  # 打印是否使用CUDA

# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda)  # 设置随机种子以实现可复现性

# handle dirs
handle_dirs(args.save_dir)  # 处理保存目录:如果不存在则创建

if args.reload_from_files:
    # 从文件重新加载数据集和向量化器
    print("Reloading!")
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv, args.vectorizer_file)
else:
    # 创建新的数据集和向量化器
    print("Creating fresh!")
    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))  # 设置输出维度为国籍词汇表大小

# 从文件加载姓氏数据集并创建向量化器
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
vectorizer = dataset.get_vectorizer()

# 初始化姓氏分类器
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                               hidden_dim=args.hidden_dim,
                               output_dim=len(vectorizer.nationality_vocab))

# 将分类器移至指定的设备(如GPU)
classifier = classifier.to(args.device)

# 定义损失函数为交叉熵损失函数,使用数据集的类别权重
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 定义优化器为Adam优化器,使用给定的学习率
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)


# 模型训练
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

        # 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.to("cpu").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)  # 将分类器移动到CPU或GPU上
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.  # 初始化总体损失为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.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']))

# 训练过程包括以下 5 个步骤:

# --------------------------------------
# 步骤 1. 清零梯度
optimizer.zero_grad()  # 清空上一次迭代中的梯度信息。

# 步骤 2. 计算输出
y_pred = classifier(batch_dict['x_surname'])  # 前向传播:使用模型计算预测输出。

# 步骤 3. 计算损失
loss = loss_func(y_pred, batch_dict['y_nationality'])  # 计算预测输出与实际标签之间的损失。
loss_batch = loss.to("cpu").item()  # 将损失转移到 CPU 并提取标量值。
running_loss += (loss_batch - running_loss) / (batch_index + 1)  # 更新损失的滑动平均值。

# 步骤 4. 使用损失计算梯度
loss.backward()  # 反向传播:计算损失相对于模型参数的梯度。

# 步骤 5. 使用优化器更新参数
optimizer.step()  # 使用计算得到的梯度和优化器的更新规则来更新模型参数。

# 单个单词的分类
def predict_nationality(surname, classifier, vectorizer):
    """Predict the nationality from a new surname
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的矢量化器
    Returns:
        返回一个字典,包含最可能的国籍及其概率
    """
    # 将输入的姓氏进行矢量化
    vectorized_surname = vectorizer.vectorize(surname)  # 使用矢量化器将输入的姓氏转换为张量表示
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)  # 转换为张量并添加批处理维度

    # 使用分类器进行预测
    result = classifier(vectorized_surname, apply_softmax=True)  # 使用分类器进行预测,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}  # 返回预测的国籍及其概率值

new_surname = input("Enter a surname to classify: ")  # 获取用户输入的待分类姓氏

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

# 使用预定义的函数进行姓氏国籍分类预测
prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印预测结果
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))  # 打印预测的国籍及其概率值

# 展示预测概率最高的前几个
def predict_topk_nationality(name, classifier, vectorizer, k=5):
    # 将姓名转换为矢量表示
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)  # 转换为张量并添加批处理维度
    
    # 使用分类器进行预测并获取前k个最有可能的国籍及其概率值
    prediction_vector = classifier(vectorized_name, apply_softmax=True)  # 使用分类器进行预测,apply_softmax=True表示对输出应用softmax函数
    probability_values, indices = torch.topk(prediction_vector, k=k)  # 获取前k个最大的概率值和对应的索引
    
    # 将张量转换为numpy数组
    probability_values = probability_values.detach().numpy()[0]  # 将概率值张量转换为numpy数组
    indices = indices.detach().numpy()[0]  # 将索引张量转换为numpy数组
    
    results = []
    # 遍历前k个预测结果,将国籍及其概率值添加到results列表中
    for prob_value, index in zip(probability_values, indices):
        nationality = vectorizer.nationality_vocab.lookup_index(index)  # 根据索引查找对应的国籍
        results.append({'nationality': nationality, 'probability': prob_value})  # 将国籍及其概率值添加到results列表中
    
    return results  # 返回前k个最有可能的国籍及其概率值


new_surname = input("Enter a surname to classify: ")  # 获取用户输入的待分类姓氏
classifier = classifier.to("cpu")  # 将分类器移到CPU上进行推理

k = int(input("How many of the top predictions to see? "))  # 获取用户想要查看的前k个预测结果
if k > len(vectorizer.nationality_vocab):  # 如果用户输入的k大于支持的国籍数量
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")  # 打印提示消息
    k = len(vectorizer.nationality_vocab)  # 将k设置为支持的国籍数量

# 使用预定义的函数获取前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']))  # 打印姓氏、国籍及其概率值

2、基于卷积神经网络(CNN)的姓氏分类

代码如下:

# 导入所需要的库
from argparse import Namespace
from collections import Counter
import json
import os
import string

import numpy as np
import pandas as pd
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 tqdm import tqdm_notebook

# 定义字典词汇表
class Vocabulary(object):
    """用于处理文本并提取词汇表以进行映射的类"""

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        Args:
            token_to_idx (dict): 一个预先存在的标记到索引的映射
            add_unk (bool): 一个指示是否添加UNK标记的标志
            unk_token (str): 要添加到词汇表中的UNK标记
        """

        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        self._idx_to_token = {idx: token 
                              for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk
        self._unk_token = unk_token
        
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token) 
        
        
    def to_serializable(self):
        """返回一个可序列化的字典"""
        return {'token_to_idx': self._token_to_idx, 
                'add_unk': self._add_unk, 
                'unk_token': self._unk_token}

    @classmethod
    def from_serializable(cls, contents):
        """从序列化的字典实例化词汇表"""
        return cls(**contents)

    def add_token(self, token):
        """根据标记更新映射字典。

        Args:
            token (str): 要添加到词汇表中的项目
        Returns:
            index (int): 与标记对应的整数
        """
        try:
            index = self._token_to_idx[token]
        except KeyError:
            index = len(self._token_to_idx)
            self._token_to_idx[token] = index
            self._idx_to_token[index] = token
        return index
    
    def add_many(self, tokens):
        """将标记列表添加到词汇表中
        
        Args:
            tokens (list): 一个字符串标记列表
        Returns:
            indices (list): 与标记对应的索引列表
        """
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        """检索与标记相关联的索引,如果标记不存在则返回UNK索引。
        
        Args:
            token (str): 要查找的标记 
        Returns:
            index (int): 与标记对应的索引
        Notes:
            对于UNK功能,unk_index需要>=0(已添加到词汇表中)
        """
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        """返回与索引相关联的标记
        
        Args: 
            index (int): 要查找的索引
        Returns:
            token (str): 与索引对应的标记
        Raises:
            KeyError: 如果索引不在词汇表中
        """
        if index not in self._idx_to_token:
            raise KeyError("索引(%d)不在词汇表中" % index)
        return self._idx_to_token[index]

    def __str__(self):
        return "<词汇表(大小=%d)>" % len(self)

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

class SurnameVectorizer(object):
    """用于协调词汇表并将其投入使用的向量化器"""

    def __init__(self, surname_vocab, nationality_vocab, max_surname_length):
        """
        Args:
            surname_vocab (Vocabulary): 将字符映射到整数的词汇表
            nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
            max_surname_length (int): 最长姓氏的长度
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab
        self._max_surname_length = max_surname_length

    def vectorize(self, surname):
        """
        Args:
            surname (str): 姓氏
        Returns:
            one_hot_matrix (np.ndarray): 一个独热向量矩阵
        """

        # 创建一个全零矩阵,形状为(词汇表大小,最长姓氏长度)
        one_hot_matrix_size = (len(self.surname_vocab), self._max_surname_length)
        one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
                               
        # 遍历姓氏中的每个字符,并将对应位置设置为1
        for position_index, character in enumerate(surname):
            character_index = self.surname_vocab.lookup_token(character)
            one_hot_matrix[character_index][position_index] = 1
        
        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据框实例化向量化器
        
        Args:
            surname_df (pandas.DataFrame): 姓氏数据集
        Returns:
            SurnameVectorizer的一个实例
        """
        surname_vocab = Vocabulary(unk_token="@")  # 实例化一个姓氏词汇表,指定UNK标记为@
        nationality_vocab = Vocabulary(add_unk=False)  # 实例化一个国籍词汇表,并禁用UNK标记
        max_surname_length = 0  # 初始化最长姓氏长度为0

        for index, row in surname_df.iterrows():
            max_surname_length = max(max_surname_length, len(row.surname))  # 更新最长姓氏长度
            for letter in row.surname:
                surname_vocab.add_token(letter)  # 将姓氏中的每个字符添加到姓氏词汇表中
            nationality_vocab.add_token(row.nationality)  # 将国籍添加到国籍词汇表中

        return cls(surname_vocab, nationality_vocab, max_surname_length)

    @classmethod
    def from_serializable(cls, contents):
        """从可序列化内容实例化向量化器"""
        surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])  # 从序列化内容中实例化姓氏词汇表
        nationality_vocab =  Vocabulary.from_serializable(contents['nationality_vocab'])  # 从序列化内容中实例化国籍词汇表
        return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab, 
                   max_surname_length=contents['max_surname_length'])

    def to_serializable(self):
        """返回一个可序列化的字典,用于保存向量化器的状态"""
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable(), 
                'max_surname_length': self._max_surname_length}

# 构建数据集
class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        Args:
            name_df (pandas.DataFrame): the dataset
            vectorizer (SurnameVectorizer): vectorizer instatiated from dataset
        """
        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 weights
        class_counts = surname_df.nationality.value_counts().to_dict()
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)


    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            surname_csv (str): location of the dataset
        Returns:
            an instance of 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):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            surname_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(surname_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of SurnameDataset
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """ selects the splits in the dataset using a column in the dataframe """
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dictionary holding the data point's features (x_data) and label (y_target)
        """
        row = self._target_df.iloc[index]

        surname_matrix = \
            self._vectorizer.vectorize(row.surname)

        nationality_index = \
            self._vectorizer.nationality_vocab.lookup_token(row.nationality)

        return {'x_surname': surname_matrix,
                'y_nationality': nationality_index}

    def get_num_batches(self, batch_size):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size

    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    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

class SurnameClassifier(nn.Module):
    def __init__(self, initial_num_channels, num_classes, num_channels):
        """
        Args:
            initial_num_channels (int): 输入特征向量的大小
            num_classes (int): 输出预测向量的大小
            num_channels (int): 网络中使用的常量通道大小
        """
        super(SurnameClassifier, self).__init__()
        
        # 定义卷积神经网络结构
        self.convnet = nn.Sequential(
            nn.Conv1d(in_channels=initial_num_channels, 
                      out_channels=num_channels, kernel_size=3),  # 第一层卷积层
            nn.ELU(),  # ELU激活函数
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),  # 第二层卷积层
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),  # 第三层卷积层
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3),  # 第四层卷积层
            nn.ELU()
        )
        self.fc = nn.Linear(num_channels, num_classes)  # 全连接层

    def forward(self, x_surname, apply_softmax=False):
        """分类器的前向传播
        
        Args:
            x_surname (torch.Tensor): 输入数据张量
                x_surname.shape 应为 (batch, initial_num_channels, max_surname_length)
            apply_softmax (bool): softmax激活标志
                如果与交叉熵损失一起使用,则应为false
        Returns:
            结果张量,张量形状应为 (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)  # 应用softmax激活函数得到概率分布

        return prediction_vector

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):
    """处理训练状态更新。

    组件:
     - 提前停止: 防止过拟合。
     - 模型检查点: 如果模型性能更好,则保存模型

    Args:
        args: 主要参数
        model: 要训练的模型
        train_state: 表示训练状态值的字典
    Returns:
        新的训练状态
    """

    # 至少保存一个模型
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])  # 保存模型参数
        train_state['stop_early'] = False  # 不提前停止

    # 如果性能有所提升则保存模型
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]

        # 如果损失变大
        if loss_t >= train_state['early_stopping_best_val']:
            # 更新步数
            train_state['early_stopping_step'] += 1
        # 损失减小
        else:
            # 保存最佳模型
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

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

        # 是否提前停止?
        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

args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/cnn",
    # 模型超参数
    hidden_dim=100,
    num_channels=256,
    # 训练超参数
    seed=1337,
    learning_rate=0.001,
    batch_size=128,
    num_epochs=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # 运行时选项
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
    catch_keyboard_interrupt=True
)

# 如果expand_filepaths_to_save_dir为True,则将文件路径扩展到保存目录中
if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)  # 将向量化器文件路径扩展到保存目录中
    args.model_state_file = os.path.join(args.save_dir, args.model_state_file)  # 将模型状态文件路径扩展到保存目录中
    
    print("Expanded filepaths: ")
    print("\t{}".format(args.vectorizer_file))  # 打印扩展后的向量化器文件路径
    print("\t{}".format(args.model_state_file))  # 打印扩展后的模型状态文件路径
    
# 检查CUDA
if not torch.cuda.is_available():  # 如果CUDA不可用
    args.cuda = False  # 设置args.cuda为False

args.device = torch.device("cuda" if args.cuda else "cpu")  # 设置args.device为cuda或者cpu
print("Using CUDA: {}".format(args.cuda))  # 打印是否使用CUDA

def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)  # 设置numpy随机种子
    torch.manual_seed(seed)  # 设置PyTorch随机种子
    if cuda:
        torch.cuda.manual_seed_all(seed)  # 设置所有可用GPU的随机种子
        
def handle_dirs(dirpath):
    if not os.path.exists(dirpath):  # 如果目录不存在
        os.makedirs(dirpath)  # 创建目录
        
# 为了可重现性设置随机种子
set_seed_everywhere(args.seed, args.cuda)  # 设置全局随机种子

# 处理目录
handle_dirs(args.save_dir)  # 确保保存目录存在,如果不存在则创建

if args.reload_from_files:  # 如果设置为从文件中重新加载
    # 从检查点训练
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv, args.vectorizer_file)  # 加载数据集和向量化器
else:
    # 创建数据集和向量化器
    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)  # 通道数为args.num_channels

classifier = 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)  # 使用Adam优化器进行参数优化
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,  # 设置学习率衰减策略
                                           mode='min', factor=0.5,  # 当指标不再减少时,将学习率缩小一半
                                           patience=1)  # 忍耐次数为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:
    # 循环遍历每个epoch
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index  # 更新当前epoch索引到训练状态中

        # Iterate over training dataset
        # 遍历训练数据集

        # 初始化参数,设置损失和准确率为0,将模型设为训练模式
        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:
            # 训练过程包括以下5个步骤:

            # --------------------------------------
            # 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
        # 遍历验证数据集

        # 初始化参数,设置损失和准确率为0,将模型设为评估模式
        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()  # 更新epoch进度条
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):
    # 计算模型输出
    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']))

def predict_nationality(surname, classifier, vectorizer):
    """预测姓氏的国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器实例
        vectorizer (SurnameVectorizer): 对应的数据向量化器
    Returns:
        包含最可能的国籍及其概率的字典
    """
    # 将输入的姓氏向量化
    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}

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

def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    """预测姓氏的前K个国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器实例
        vectorizer (SurnameVectorizer): 对应的数据向量化器
        k (int): 要返回的前K个国籍数
    Returns:
        包含前K个国籍及其概率的字典列表
    """
    
    # 将输入的姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)  # 将向量转为张量并增加维度以匹配模型输入
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)  # 使用分类器进行预测,并应用softmax获取概率分布
    probability_values, indices = torch.topk(prediction_vector, k=k)  # 获取前K个最大概率值及其对应的索引

    # 将张量转为numpy数组
    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

# 输入待分类的姓氏和要返回的前K个预测结果数
new_surname = input("Enter a surname to classify: ")
k = int(input("How many of the top predictions to see? "))

# 如果输入的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)

# 进行预测
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']))

  • 26
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值