多层感知器与卷积神经网络在姓氏分类任务中的应用与实践

在本文中,我们将探索传统上称为前馈网络的神经网络模型,以及两种前馈神经网络:多层感知器和卷积神经网络。

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

在本文中,多层感知器和卷积神经网络被分组在一起,因为它们都是前馈神经网络。

一、实验原理

1.1多层感知器(MLP)

1.1.1多层感知机介绍

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

MLP的简单模型,如下图所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。我们所说的“层的输出”是什么意思?理解这个的一种方法是隐藏向量中的值是组成该层的不同感知器的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。我们只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。第三个阶段就是最后的输出层,输出最后的预测结果。

1.1.2多层感知机的原理

MLP的训练是一个迭代优化过程,目标是最小化损失函数,从而提高模型的预测准确性。通过调整网络结构、激活函数、损失函数和优化算法,可以针对不同的任务优化MLP的性能。

MLP的训练过程:

  1. 初始化权重:随机初始化网络中的权重。
  2. 前向传播:将输入数据通过网络进行前向传播,得到预测输出。
  3. 计算损失:使用损失函数计算预测输出与实际值之间的差异。
  4. 反向传播:根据损失计算梯度,并反向传播这些梯度。
  5. 更新权重:使用梯度和学习率更新网络中的权重。
  6. 迭代训练:重复步骤2-5,直到满足停止条件(如达到预定的迭代次数或损失值降低到某个阈值以下)。
1、前向传播(Forward Propagation)
  1. 输入层:MLP的输入层接收原始数据特征,这些特征被直接传递到下一层。
  2. 隐藏层:输入数据通过隐藏层的每个神经元进行处理。每个神经元对输入数据进行加权求和,然后通过激活函数进行非线性变换。激活函数可以是Sigmoid、ReLU、Tanh等。
  3. 输出层:隐藏层的输出被传递到输出层,输出层的神经元数量通常与目标变量的数量相同。输出层也可能使用激活函数,例如在二分类问题中使用Sigmoid函数。 

激活函数 

激活函数是MLP中的关键组成部分,它为网络引入非线性,使得网络能够学习复杂的函数映射。常见的激活函数包括:

  • Sigmoid函数:\sigma \left ( x \right )=\frac{1}{1+e_{}^{-x}}
  • ReLU函数:ReLU\left ( x \right )=max\left ( 0,x \right )
  • Tanh函数:tanh\left ( x \right )=\frac{e^{x}-e^{-x}}{e^{x}+e^{-x}}
2、反向传播(Backpropagation)

  1. 损失函数:首先定义一个损失函数(如均方误差、交叉熵等),用于衡量模型预测与实际值之间的差异。
  2. 计算梯度:通过损失函数对输出层的预测值进行求导,计算损失函数对输出层权重的梯度。
  3. 链式法则:使用链式法则将梯度从输出层反向传播到隐藏层,计算每一层的权重梯度。
  4. 更新权重:根据梯度和学习率更新每一层的权重。
3、权重更新

权重更新是MLP学习过程中的关键步骤,通常使用梯度下降或其变体(如随机梯度下降SGD、Adam等)进行更新。权重更新的公式可以表示为:\omega _{new}=\omega _{old}-\eta \cdot \frac{\partial\iota }{\partial \omega } 其中,\omega _{new}是更新后的权重, \omega _{old}是当前权重,\eta 是学习率,\frac{\partial \iota }{\partial \eta } 是损失函数对权重的偏导数。

1.2卷积神经网络(CNN)

1.2.1卷积神经网络介绍

卷积神经网络是一种深度学习架构,特别适用于处理具有网格结构的数据,如图像(2D网格)和视频(3D网格)。它们通过模拟人类视觉系统的处理方式,自动学习数据中的层次结构特征,无需手动特征工程。CNN在图像识别、分类和其他视觉任务中表现出色,已成为计算机视觉领域的标准工具。

CNN通常包括这几层:输入层(input layer)、卷积层(convolutional layer)、激活层(activation Layer)、池化层(pooling layer)以及全连接层(fully Connected Layer)。

结构如下图所示。

 1.2.2卷积神经网络的原理

1、输入层(Input layer)

输入层的作用主要是接收并传递原始数据到网络的第一层卷积层,输入层负责将数据格式化为适合网络处理的格式。对于图像数据,这通常意味着将图像的像素值转换成一个三维数组,其维度为(高度,宽度,通道数),对于彩色图像,输入层会处理三个颜色通道(通常是RGB)。对于灰度图像,只有一个通道。

2、卷积层(Convolutional layer)

卷积层的原理基于卷积操作,这是一种数学运算,用于提取多维数据(如图像)的局部特征。

卷积核(Convolutional Kernel)是一个小的矩阵,用于在输入数据上滑动以提取特征。每个卷积核学习检测图像中的特定模式,如水平边缘、垂直边缘或颜色变化。卷积核在输入数据上以一定的步长(Stride)滑动,对每个位置计算点积(元素对应相乘后求和),生成特征图的一个元素。卷积过程如下图所示。

 卷积层可以包含多个卷积核,每个卷积核学习检测不同的特征。并且每个卷积神经元只与输入数据的一个局部区域相连接,减少了模型的计算量和参数数量。这些特征图的集合形成了卷积层的输出。

3、激活层(Activation Layer)

激活层的原理基于引入非线性变换,以增强神经网络的表达能力。

激活层的主要作用:

引入非线性:激活层使网络能够学习复杂的数据模式,因为非线性是模拟现实世界问题的关键。

帮助收敛:某些激活函数如ReLU可以加速网络训练过程中的收敛速度。

防止过拟合:通过引入非线性,激活层增加了模型的复杂度,有助于防止过拟合。

控制激活:激活层决定了哪些神经元应该被激活,有助于网络专注于学习最重要的特征。

常见的激活函数包括ReLU、Sigmoid、Tanh、Leaky ReLU、Parametric ReLU(PReLU)、Exponential Linear Unit(ELU)等。每种激活函数都有其特点和适用场景,选择哪种激活函数通常取决于具体的应用和网络架构。

4、池化层(Pooling layer)

池化层的原理基于一个简单的概念:通过在特征图上应用一个固定大小的窗口,并对这个窗口内的值进行某种形式的聚合操作,来降低特征图的空间尺寸。

首先在特征图上定义一个窗口(或称为池化核),这个窗口在特征图上滑动。选择一个聚合函数来处理窗口内的值。最常见的聚合函数有两种:最大池化(Max Pooling):选择窗口内的最大值作为输出。平均池化(Average Pooling):计算窗口内所有值的平均作为输出。还有随机池化(Stochastic Pooling):一定的概率随机选择窗口内的值。

5、全连接层(Fully Connected Layer) 

全连接层是神经网络中的关键组件,尤其是在处理非结构化数据(如图像和文本)时,它允许网络在经过特征提取层(如卷积层)之后进行高级特征整合和决策。

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

 所有过程可以用下图来表示:

二、实验介绍

2.1实验目的

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

2.2实验环境

  • Python 3.6.7

2.3实验步骤

1、数据预处理

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

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

2、MLP训练和可视化主要步骤

设计MLP架构:确定MLP的层数、每层的神经元数量以及激活函数。

选择损失函数和优化器:根据任务类型选择合适的损失函数(如交叉熵损失)和优化器(如Adam或SGD)。

训练MLP模型:使用训练数据训练MLP模型,并在验证集上进行调整,以避免过拟合。

模型评估:在测试集上评估MLP模型的性能,使用适当的评估指标(如准确率、召回率和F1分数)。

结果可视化:绘制训练和验证损失以及准确率曲线,以可视化模型训练过程和性能。

 3、MLP和CNN在姓氏分类预测

 定义函数,训练模型,应用于姓氏分类。

三、实验代码

3.1数据预处理

首先导入预先给定的数据(surnames.csv)和必要的库。

import collections 
import numpy as np 
import pandas as pd 
import re
from argparse import Namespace 

使用Namespace创建一个名为args的对象存储数据集路径、数据集分割比例、输出路径和随机数种子等参数。 

# 定义一个Namespace对象,用于存储实验参数
args = Namespace(
    # 指定原始姓氏数据集的CSV文件路径,该数据集包含来自不同国家的姓氏
    raw_dataset_csv="data/surnames/surnames.csv",
    # 指定训练集占原始数据集的比例,用于机器学习模型的训练阶段
    train_proportion=0.7,
    # 指定验证集占原始数据集的比例,用于模型在训练过程中的调优
    val_proportion=0.15,
    # 指定测试集占原始数据集的比例,用于评估模型的最终性能
    test_proportion=0.15,
    # 指定处理后的数据集的CSV文件输出路径,该文件将包含数据集的分割信息
    output_munged_csv="data/surnames/surnames_with_splits.csv",
    # 指定随机数生成器的种子,用于确保数据集分割过程的可重复性,方便实验结果的复现
    seed=1337
)

读取原始数据,使用head函数查看数据集的前几行后,使用collections.defaultdict创建一个按国籍分组的字典,并将数据集拆分构建最终数据列表

# 读取原始姓氏数据集CSV文件
surnames = pd.read_csv(args.raw_dataset_csv, header=0)
# 显示数据集的前5行数据,用于快速检查数据格式和内容
print(surnames.head())

# 获取数据集中所有不同国籍的唯一集合,以分析数据的多样性
unique_nationalities = set(surnames.nationality)

# 根据国籍对数据进行分组,为后续的数据集拆分做准备
by_nationality = collections.defaultdict(list)
for _, row in surnames.iterrows():
    # 将每个姓氏及其属性按国籍分组存储在字典中
    by_nationality[row.nationality].append(row.to_dict())

# 初始化一个列表,用于存储最终划分好的数据点
final_list = []

# 设置随机数生成器的种子,以确保数据划分的可重复性
np.random.seed(args.seed)

# 遍历按国籍排序的字典项,逐个处理每个国籍的数据
for nationality, item_list in sorted(by_nationality.items()):
    # 打乱当前国籍的数据顺序,确保随机性
    np.random.shuffle(item_list)
    
    # 计算当前国籍数据点总数
    n = len(item_list)
    
    # 根据指定比例计算当前国籍的每个数据集的大小
    n_train = int(args.train_proportion * n)
    n_val = int(args.val_proportion * n)
    n_test = int(args.test_proportion * n)
    
    # 对当前国籍的数据点进行划分,并标记它们属于哪个数据集
    for item in item_list[:n_train]:
        item['split'] = 'train'
    for item in item_list[n_train:n_train + n_val]:
        item['split'] = 'val'
    for item in item_list[n_train + n_val:]:
        item['split'] = 'test'
    
    # 将划分好的当前国籍的数据点添加到最终列表中
    final_list.extend(item_list)

final_list转换为pandasDataFrame对象,将处理后的数据写入到指定的CSV文件中,使用to_csv函数,并设置index=False以避免写入索引。

# 将最终的数据列表转换为pandas的DataFrame对象,以便于进行数据操作和分析
final_surnames = pd.DataFrame(final_list)

# 将处理和划分后的数据集写入CSV文件,这将保存每个姓氏数据点的国籍和所属数据集(训练集、验证集或测试集)
final_surnames.to_csv(args.output_munged_csv, index=False) 

最后展示处理前后的数据

处理前:

 

 处理后:

数据的排列变得更加整齐了。

3.2MLP的训练与可视化

首先导入必要的库。并定义个全局变量标签集合LABELS和数据点的中心位置CENTERS。

import torch  # 导入PyTorch库,一个用于深度学习研究和生产的强大开源库
import torch.nn as nn  # 导入PyTorch中的神经网络模块,包含构建模型所需的层和损失函数
import torch.nn.functional as F  # 导入PyTorch中的函数式接口,提供如ReLU激活和卷积等操作
import torch.optim as optim  # 导入PyTorch中的优化器模块,包含多种优化算法,用于模型训练
import numpy as np  # 导入NumPy库,一个用于进行高效数值计算的Python库
import matplotlib.pyplot as plt  # 导入matplotlib的pyplot模块,用于绘图
%matplotlib inline  # 确保在Jupyter Notebook中可以内联显示matplotlib生成的图像
# 定义模拟数据的标签,用于演示分类问题
LABELS = [0, 0, 1, 1]
# 定义模拟数据的中心点,可能用于KMeans聚类或其他算法的演示,其中每个中心点代表一个类别的质心
CENTERS = [(-3, -3), (3, 3), (3, -3), (-3, 3)]

 定义多层感知器类(MultilayerPerceptron),用于构建多层感知器模型

class MultilayerPerceptron(nn.Module):
    """
    多层感知机(MLP)的PyTorch实现,用于分类或其他任务。
    """

    def __init__(self, input_size, hidden_size=2, output_size=3, 
                 num_hidden_layers=1, hidden_activation=nn.Sigmoid):
        """
        初始化多层感知机模型的构造函数。
        参数:
            input_size (int): 输入特征的数量。
            hidden_size (int): 每个隐藏层神经元的数量。
            output_size (int): 输出层神经元的数量,通常与类别数相同。
            num_hidden_layers (int): 模型中隐藏层的层数。
            hidden_activation (torch.nn.Module): 应用于隐藏层的激活函数。
        """
        super(MultilayerPerceptron, self).__init__()  # 调用基类的构造函数
        self.module_list = nn.ModuleList()  # 创建一个模块列表用于存储层

        # 初始化层的大小
        interim_input_size = input_size
        interim_output_size = hidden_size

        # 为每个隐藏层创建线性层和激活层,并添加到模块列表
        for _ in range(num_hidden_layers):
            self.module_list.append(nn.Linear(interim_input_size, interim_output_size))
            self.module_list.append(hidden_activation())
            interim_input_size = interim_output_size  # 更新输入大小为下一层的输出大小

        # 创建最终的全连接层,将最后一个隐藏层的输出映射到输出层
        self.fc_final = nn.Linear(interim_input_size, output_size)

    def forward(self, x, apply_softmax=False):
        """
        MLP的前向传播。
        
        参数:
            x (torch.Tensor): 输入数据张量。
            apply_softmax (bool): 是否在最后一层应用softmax函数。
            
        返回:
            torch.Tensor: 模型的输出张量。
        """
        # 存储前向传播过程中的中间数据
        self.last_forward_cache = [x.to("cpu").numpy()]

        # 应用每个层的计算并存储中间结果
        for module in self.module_list:
            x = module(x)
            self.last_forward_cache.append(x.to("cpu").detach().numpy())
        
        # 通过最终的全连接层获取原始输出
        output = self.fc_final(x)
        self.last_forward_cache.append(output.to("cpu").detach().numpy())
        
        # 如果指定,应用softmax函数到输出上
        if apply_softmax:
            output = F.softmax(output, dim=1)
        
        return output

  定义数据生成函数(get_toy_data),用于训练和测试多层感知器。

def get_toy_data(batch_size):
    """
    生成一个简单数据集,用于演示和测试机器学习模型。
    
    参数:
        batch_size (int): 要生成的数据批次的大小,即数据集中样本的数量。
    
    返回:
        x_data (torch.Tensor): 输入数据张量,包含模拟的特征。
           - 形状为 (batch_size, num_features)。
        y_targets (torch.Tensor): 目标张量,包含每个输入数据点的标签。
           - 形状为 (batch_size,)。
    """
    
    # 确保中心点的数量与标签的数量相同
    assert len(CENTERS) == len(LABELS), '中心点的数量应该与标签的数量相等'
    
    # 初始化存储输入数据的列表,用于收集生成的数据点
    x_data = []
    # 初始化目标标签数组,大小为batch_size,初始值为0,表示未分配的标签
    y_targets = np.zeros(batch_size, dtype=np.int64)
    # 获取中心点的数量,用于确定有多少个不同的类别或标签
    n_centers = len(CENTERS)
    
    # 遍历批次中的每个样本,生成数据点和标签
    for batch_i in range(batch_size):
        # 随机选择一个中心点的索引,确定样本属于哪个类别
        center_idx = np.random.randint(0, n_centers)
        # 从以中心点为中心的正态分布中生成一个数据点,并添加到x_data列表
        # 这模拟了围绕每个类别中心的数据点分布
        x_data.append(np.random.normal(CENTERS[center_idx]))
        # 将当前样本的标签设置为对应的中心点标签
        y_targets[batch_i] = LABELS[center_idx]
        
    # 将输入数据列表转换为PyTorch张量,数据类型为float32
    x_data = torch.tensor(x_data, dtype=torch.float32)
    # 确保目标标签数组转换为PyTorch张量,数据类型为int64
    y_targets = torch.tensor(y_targets)
    
    # 返回生成的数据和标签张量,用于模型的训练或评估
    return x_data, y_targets

定义结果可视化函数(visualize_results),用于可视化训练后的模型分类结果和决策边界。

def visualize_results(perceptron, x_data, y_truth, n_samples=1000, ax=None, epoch=None, 
                      title='MLP Classification Result', levels=[0.3, 0.4, 0.5], linestyles=['--', '-', '--']):
    """
    可视化多层感知机的分类结果。

    参数:
        perceptron: 训练好的多层感知机模型。
        x_data: 输入特征的数据张量,应为二维数据,形状为 (n_samples, n_features)。
        y_truth: 实际标签的数据张量,形状为 (n_samples,)。
        n_samples: 用于绘制的样本数量,此参数在函数体内未被使用。
        ax: 绘图的轴对象,如果为None则创建新的轴对象。
        epoch: 当前训练世代,如果提供则在图上显示。默认为None。
        title: 图的标题,默认为'MLP Classification Result'。
        levels: 用于绘制分类边界的阈值列表。
        linestyles: 用于分类边界的线条样式列表。
    """
    # 前向传播模型并获取预测的最大概率和对应的标签
    _, y_pred = torch.max(perceptron(x_data, apply_softmax=True), dim=1)
    y_pred = y_pred.cpu().numpy()  # 将预测的标签转换为NumPy数组

    x_data = x_data.cpu().numpy()  # 将输入特征转换为NumPy数组
    y_truth = y_truth.cpu().numpy()  # 将实际标签转换为NumPy数组

    n_classes = len(set(y_truth))  # 获取类别数量

    # 初始化每个类别的数据点列表和颜色列表
    all_x = [[] for _ in range(n_classes)]
    all_colors = [[] for _ in range(n_classes)]

    # 设置颜色和标记
    colors = ['black', 'white']
    markers = ['o', '*']

    # 根据预测和真实标签填充颜色和x数据列表
    for x_i, y_pred_i, y_true_i in zip(x_data, y_pred, y_truth):
        all_x[y_true_i].append(x_i)
        # 根据预测是否正确选择颜色
        all_colors[y_true_i].append("white" if y_pred_i == y_true_i else "black")

    # 将列表转换为NumPy数组
    all_x = [np.array(x_list) for x_list in all_x]

    # 如果没有提供ax,则创建一个新的绘图轴
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 10))

    # 为每个类别绘制散点图
    for x_list, color_list, marker in zip(all_x, all_colors, markers):
        ax.scatter(x_list[:, 0], x_list[:, 1], edgecolor='black', marker=marker,
                   facecolors=color_list, s=100)

    # 设置x和y轴的限制
    ax.set_xlim(min([x_list[:, 0].min() for x_list in all_x]), max([x_list[:, 0].max() for x_list in all_x]))
    ax.set_ylim(min([x_list[:, 1].min() for x_list in all_x]), max([x_list[:, 1].max() for x_list in all_x]))

    # 绘制分类超平面
    xx, yy = np.meshgrid(np.linspace(ax.get_xlim()[0], ax.get_xlim()[1], 100),
                         np.linspace(ax.get_ylim()[0], ax.get_ylim()[1], 100))
    xy = np.vstack([xx.ravel(), yy.ravel()]).T
    for i, level, linestyle in zip(range(n_classes), levels, linestyles):
        Z = perceptron(torch.tensor(xy, dtype=torch.float32)).argmax(dim=1).cpu().numpy().reshape(xx.shape)
        ax.contour(xx, yy, Z, levels=[level], colors=colors[i], linestyles=linestyle)

    # 添加标题和epoch信息
    plt.title(title)
    if epoch is not None:
        plt.text(0.5, 0.5, f'Epoch = {epoch}', horizontalalignment='center', verticalalignment='center',
                 transform=ax.transAxes)

    # 隐藏坐标轴
    plt.xticks([])
    plt.yticks([])

 生成模拟数据集并得到初始化数据结果

# 设置随机种子以确保结果的可复现性
seed = 24
torch.manual_seed(seed)  # 设置PyTorch的随机种子,确保操作的可重复性
torch.cuda.manual_seed_all(seed)  # 如果使用CUDA,也设置其随机种子
np.random.seed(seed)  # 设置NumPy的随机种子

# 生成模拟数据集,这里指定了生成1000个样本
x_data, y_truth = get_toy_data(batch_size=1000)

# 将PyTorch张量转换为NumPy数组,以便进行后续处理
x_data = x_data.data.numpy()
y_truth = y_truth.data.numpy().astype(np.int64)  # 确保标签数据类型为64位整数

# 计算类别总数,通过提取标签集合的唯一元素来确定
n_classes = len(set(LABELS))

# 初始化列表以存储每个类别的数据点和颜色
all_x = [[] for _ in range(n_classes)]
all_colors = [[] for _ in range(n_classes)]

# 定义用于区分不同类别的数据点的颜色和标记样式
colors = ['black', 'white']
markers = ['o', '*']

# 根据真实标签分组数据点,并为每个类别分配颜色
for x_i, y_true_i in zip(x_data, y_truth):
    all_x[y_true_i].append(x_i)  # 将数据点添加到对应类别的列表
    all_colors[y_true_i].append(colors[y_true_i])  # 根据类别索引分配颜色

# 将数据点列表转换为NumPy数组,以便于绘图
all_x = [np.stack(x_list) for x_list in all_x]

# 创建绘图轴,并设置图像大小
_, ax = plt.subplots(1, 1, figsize=(10,5))

# 绘制每个类别的数据点,使用不同的颜色和标记样式
for x_list, color_list, marker in zip(all_x, all_colors, markers):
    ax.scatter(x_list[:, 0], x_list[:, 1], edgecolor='black', marker=marker, 
               facecolor="white", s=100)  # 绘制散点图

# 自动调整子图参数,使之填充整个图像区域
plt.tight_layout()

# 隐藏图像的坐标轴
plt.axis('off')

# 设置图表的标题为空字符串,避免显示默认标题
plt.title("")

# 将图像保存为PNG和PDF格式,指定存储路径和文件名
plt.savefig("images/data.png")
plt.savefig("images/data.pdf")

根据不同的配置初始化多个多层感知器模型,逐步增加网络的复杂性(隐藏层数量从没有到有两个)这里只写了隐藏层数量为0的模型,其他模型只需改动隐藏层数量即可。

# 设置输入层的大小为2,对应于二维数据的特征数量
input_size = 2
# 根据标签集合计算输出层的大小,即类别的总数
output_size = len(set(LABELS))

# 通过注释和取消注释来控制隐藏层的数量
# 如果需要,可以通过取消注释以下行来设置隐藏层的数量
# num_hidden_layers = 1  # 设置隐藏层的数量为1
# num_hidden_layers = 2  # 设置隐藏层的数量为2
# 在本例中,我们使用0隐藏层,即没有隐藏层
num_hidden_layers = 0

# 虽然当前设置的隐藏层大小未被使用,但我们依然设置它以备后用
hidden_size = 2 

# 设置随机种子以确保结果的可复现性
seed = 24

# 设置PyTorch的随机种子,确保操作的可重复性
torch.manual_seed(seed)
# 如果使用CUDA,也设置其随机种子以保证结果一致性
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed)
# 设置NumPy的随机种子
np.random.seed(seed)

# 初始化一个多层感知机,根据设置的参数配置网络结构
mlp1 = MultilayerPerceptron(
    input_size=input_size,  # 输入层的大小
    hidden_size=hidden_size,  # 隐藏层的大小
    num_hidden_layers=num_hidden_layers,  # 隐藏层的数量
    output_size=output_size  # 输出层的大小
)

# 打印多层感知机的模型结构,以便查看网络配置
print(mlp1)

# 设置批量大小,用于生成模拟数据集
batch_size = 1000

# 生成模拟数据集,用于演示和测试
x_data_static, y_truth_static = get_toy_data(batch_size)

# 创建绘图轴,用于可视化多层感知机的分类结果
fig, ax = plt.subplots(1, 1, figsize=(10, 5))

# 使用visualize_results函数可视化初始多层感知机的状态
# 绘制初始状态的分类边界和数据点分布
visualize_results(
    mlp1,  # 多层感知机模型
    x_data_static,  # 输入数据
    y_truth_static,  # 真实标签
    ax=ax,  # 绘图轴
    title='Initial Perceptron State',  # 图像标题
    levels=[0.5]  # 分类边界的阈值,设置为0.5
)

# 隐藏图像的坐标轴,使图像更干净
plt.axis('off')

# 保存图像为PNG格式,记录初始感知机的状态
plt.savefig('images/perceptron_initial.png')

 通过多个训练周期,使用Adam优化器和交叉熵损失函数训练模型。训练完成后,保存最终的模型分类结果和决策边界的可视化图像。 

# 初始化用于记录训练过程中损失的列表,这将帮助我们监控训练进度。
losses = []

# 设置批量大小,这决定了每次迭代中用于训练的样本数量。
batch_size = 10000
# 设置批量数量,这将决定我们对数据集进行多少次迭代。
n_batches = 10
# 设置最大训练轮数,这是训练过程中迭代的最大次数。
max_epochs = 10

# 初始化损失变化量,用于追踪连续两次迭代间的损失变化。
loss_change = 1.0
# 将上一次的损失初始化为一个较大的数,以确保首次迭代能检测到损失减少。
last_loss = 10.0
# 设置损失变化的阈值,当连续两次迭代的损失变化小于此值时,考虑停止训练。
change_threshold = 1e-3

# 初始化训练计数器,用于跟踪当前的训练进度。
epoch = 0

# 初始化用于存储图像文件名的列表,这将保存每个训练世代的可视化结果。
all_imagefiles = []

# 设置学习率,这决定了优化器在每次迭代中的步长。
lr = 0.01
# 初始化Adam优化器,它将用于更新模型的权重。
optimizer = optim.Adam(params=mlp1.parameters(), lr=lr)
# 初始化交叉熵损失函数,这是多分类问题常用的损失函数。
cross_ent_loss = nn.CrossEntropyLoss()

# 定义早期停止的函数,它将根据损失变化和当前世代数来决定是否提前终止训练。
def early_termination(loss_change, change_threshold, epoch, max_epochs):
    # 如果损失变化小于阈值或当前世代超过最大轮数,则返回True以停止训练。
    return (loss_change < change_threshold) or (epoch >= max_epochs)

# 训练循环,继续进行直到满足早期停止条件。
while not early_termination(loss_change, change_threshold, epoch, max_epochs):
    # 遍历每个批次的数据进行训练。
    for _ in range(n_batches):
        # 获取模拟数据集,每次迭代中重新生成新的数据以模拟随机性。
        x_data, y_target = get_toy_data(batch_size)
        
        # 在每次迭代前将模型参数的梯度置零,为新的迭代准备。
        mlp1.zero_grad()
        
        # 执行前向传播,计算给定数据的预测输出。
        y_pred = mlp1(x_data).squeeze()  # 去除单维度的批次维度以匹配损失函数的期望形状。
        
        # 计算损失,这是预测输出和真实标签之间的差异。
        loss = cross_ent_loss(y_pred, y_target.long())  # 确保目标标签是长整型,符合交叉熵损失的要求。
        
        # 执行反向传播,计算损失相对于模型参数的梯度。
        loss.backward()
        
        # 根据计算得到的梯度,使用优化器更新模型的权重。
        optimizer.step()
        
        # 记录当前损失,并计算与上一次损失的变化量,以监控训练动态。
        loss_value = loss.item()
        losses.append(loss_value)
        loss_change = abs(last_loss - loss_value)
        last_loss = loss_value

    # 可视化当前训练的模型性能,包括分类边界和数据点分布。
    fig, ax = plt.subplots(1, 1, figsize=(10,5))
    visualize_results(
        mlp1,  # 当前训练的多层感知机模型。
        x_data_static,  # 用于可视化的静态输入数据。
        y_truth_static,  # 用于可视化的静态真实标签。
        ax=ax,  # 当前绘图的轴对象。
        epoch=epoch,  # 当前训练轮次,用于记录和显示。
        title=f"Epoch {epoch}; Loss: {loss_value:0.2f}; ΔLoss: {loss_change:0.4f}"  # 图像标题,显示当前轮次、损失和损失变化。
    )
    plt.axis('off')  # 隐藏坐标轴,使图像更加清晰。
    
    # 保存当前的模型训练结果图像,记录训练过程中的可视化变化。
    all_imagefiles.append('images/perceptron_epoch' + str(epoch) + '_toylearning.png')
    plt.savefig(all_imagefiles[-1])
    epoch += 1  # 更新训练计数器。

可视化对比不同配置的多层感知器模型的分类结果  

_, axes = plt.subplots(1,3,figsize=(16,5))
# 对比
visualize_results(mlp1, x_data_static, y_truth_static, epoch=None, levels=[0.5], ax=axes[0])
visualize_results(mlp2, x_data_static, y_truth_static, epoch=None, levels=[0.5], ax=axes[1])
visualize_results(mlp3, x_data_static, y_truth_static, epoch=None, levels=[0.5], ax=axes[2])
plt.tight_layout()
axes[0].axis('off');
axes[1].axis('off');
axes[2].axis('off');
plt.savefig("images/perceptron_vs_mlp2_vs_mlp3.png")

 

 

通过对比我们发现,具有一个或多个隐藏层的MLP能够更好地拟合数据,并且可能在分类任务上获得更高的准确率。在多层感知机(MLP)中,隐藏层扮演着至关重要的角色。通过引入一个或多个隐藏层,模型能够更复杂地表示数据,从而提高其拟合能力和分类性能。但是过多的隐藏层或参数可能导致过拟合,即模型在训练数据上表现良好,但在未见过的数据上泛化能力差。

可视化模型中间层的激活值,展示不同层如何学习和转换数据。

# 设置批量大小,用于生成固定数量的模拟数据点。
batch_size = 100

# 定义一个函数,用于绘制多层感知机(MLP)模型的中间特征表示。
def plot_intermediate_representations(mlp_model, plot_title, figsize=(10, 2)):
    # 获取模拟数据集,使用当前设置的批量大小。
    x_data, y_target = get_toy_data(batch_size)

    # 使用传入的MLP模型进行预测,并将预测结果转换为NumPy数组以便绘图。
    y_pred = mlp_model(x_data, True).detach().numpy()

    # 将输入数据和目标标签转换为NumPy数组,以便于后续处理和绘图。
    x_data = x_data.numpy()
    y_target = y_target.numpy()

    # 定义用于区分不同类别的数据点的颜色和标记样式。
    colors = ['black', 'white']
    markers = ['o', '*']

    # 根据目标标签分组数据点的索引,以便在图中区分不同的类别。
    class_zero_indices = [i for i, target in enumerate(y_target) if target == 0]
    class_one_indices = [i for i, target in enumerate(y_target) if target != 0]

    # 创建图形和子图轴,子图数量等于模型中间表示的数量加1(包括输入层)。
    fig, axes = plt.subplots(1, len(mlp_model.last_forward_cache), figsize=figsize)

    # 对每个类别的数据点进行绘制,在输入层子图和所有中间表示层子图上展示。
    for class_index, data_indices in enumerate([class_zero_indices, class_one_indices]):
        # 在输入层子图上绘制数据点。
        axes[0].scatter(
            x_data[data_indices, 0],
            x_data[data_indices, 1],
            color=colors[class_index],
            marker=markers[class_index],
            s=200  # 设置数据点的大小。
        )
        # 为输入层子图关闭坐标轴显示。
        axes[0].axis('off')

        # 在后续的子图上绘制每一层的激活值。
        for i, activations in enumerate(mlp_model.last_forward_cache[1:], 1):
            axes[i].scatter(
                activations[data_indices, 0],
                activations[data_indices, 1],
                color=colors[class_index],
                marker=markers[class_index],
                s=200  # 设置数据点的大小。
            )
            # 为中间表示层子图关闭坐标轴显示。
            axes[i].axis('off')

    # 自动调整子图布局以避免重叠,并设置图形的总标题。
    plt.tight_layout()
    plt.suptitle(plot_title, size=15)
    # 调整子图之间的间距,为标题留出空间。
    plt.subplots_adjust(top=0.8)

# 调用函数绘制mlp1模型的中间表示,并设置绘图的标题和图形尺寸。
plot_intermediate_representations(
    mlp1,  # 传入mlp1模型。
    "The Perceptron's Input and Intermediate Representation",  # 设置绘图的标题。
    figsize=(9, 3)  # 设置图形的尺寸。
)

# 保存图形为PNG和PDF格式,记录模型的中间特征表示。
plt.savefig("images/perceptron_intermediate.png")
plt.savefig("images/figure_4_5.pdf")
# 调用函数绘制mlp2模型的中间表示
plot_intermediate_representations(mlp2,
                                  "A 2-layer MLP's Input and Intermediate Representation",
                                  figsize=(10, 3))
# 图形保存
plt.savefig("images/mlp2_intermediate.png")
plt.savefig("images/figure_4_4.pdf")
# 调用函数绘制mlp3模型的中间表示
plot_intermediate_representations(mlp3, 
                                  "The 3-layer Multilayer Perceptron's Input and Intermediate Representation",
                                  figsize=(13, 3))
plt.savefig("images/mlp3_intermediate.png")
plt.savefig("images/mlp3_intermediate.pdf")

 

 

3.3 MLP在姓氏分类中的应用

首先导入必要的库

from argparse import Namespace  # 导入Namespace类,用于配置命令行参数的容器
from collections import Counter  # 导入Counter类,用于快速统计元素出现频次
import json  # 导入json模块,用于处理JSON数据格式的编码和解码
import os  # 导入os模块,用于操作系统功能,如文件路径操作
import string  # 导入string模块,包含字符串处理常用的字符集合

import numpy as np  # 导入NumPy库,用于高效的数值计算
import pandas as pd  # 导入Pandas库,用于数据分析和操作,提供DataFrame等数据结构

import torch  # 导入PyTorch库,用于深度学习模型的构建和训练
import torch.nn as nn  # 导入PyTorch的神经网络模块,包含层和损失函数的定义
import torch.nn.functional as F  # 导入PyTorch的功能性函数模块,提供如ReLU等非线性激活函数
import torch.optim as optim  # 导入PyTorch的优化器模块,包含SGD、Adam等优化算法
from torch.utils.data import Dataset, DataLoader  # 导入Dataset和DataLoader类,用于数据封装、加载和管理
from tqdm import tqdm_notebook  # 导入tqdm_notebook,用于在Jupyter Notebook中显示迭代进度条

 定义词汇表类(Vocabulary),用于处理文本数据并建立词汇表映射

class Vocabulary(object):
    """用于处理文本并创建词汇表映射的类."""

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        初始化词汇表实例,可提供预先存在的标记到索引的映射字典,并设置处理未知标记的选项。

        参数:
            token_to_idx (dict, 可选): 如果存在,预先存在的标记到索引的映射字典。
            add_unk (bool, 可选): 一个标志,指示是否添加未知(UNK)标记。
            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(self._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):
        """
        向词汇表中添加标记,并更新映射。

        参数:
            token (str): 要添加的标记。

        返回:
            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):
        """向词汇表中添加一系列标记.

        参数:
            tokens (list): 要添加的标记列表。

        返回:
            list: 与添加的标记对应的索引列表。
        """
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        """
        检索与给定标记相关联的索引,如果标记不存在,则返回未知标记的索引。

        参数:
            token (str): 要查找的标记。

        返回:
            int: 与标记对应的索引或未知标记的索引。
        """
        return self._token_to_idx.get(token, self.unk_index)

    def lookup_index(self, index):
        """
        返回与给定索引相关联的标记。

        参数:
            index (int): 要查找的索引。

        返回:
            str: 与索引对应的标记。

        引发:
            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)

 定义向量化器类(SurnameVectorizer),用于将姓氏文本数据转换为数值向量

class SurnameVectorizer(object):
    """向量化器,它协调Vocabulary的使用,并将它们应用于数据."""

    def __init__(self, surname_vocab, nationality_vocab):
        """
        构造函数,初始化姓氏和国籍的Vocabulary。

        参数:
            surname_vocab (Vocabulary): 将字符映射到整数的Vocabulary对象。
            nationality_vocab (Vocabulary): 将国籍映射到整数的Vocabulary对象。
        """
        self.surname_vocab = surname_vocab  # 存储姓氏的词汇表
        self.nationality_vocab = nationality_vocab  # 存储国籍的词汇表

    def vectorize(self, surname):
        """
        将姓氏转换为折叠的一位有效编码(One-Hot Encoding)。

        参数:
            surname (str): 需要向量化的姓氏。

        返回:
            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  # 将对应的位置设为1

        return one_hot

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据集的DataFrame实例化向量化器
        
        参数:
            surname_df (pandas.DataFrame): 包含姓氏数据的DataFrame。

        返回:
            SurnameVectorizer的一个实例。
        """
        surname_vocab = Vocabulary(unk_token="@")  # 初始化姓氏词汇表,使用"@"作为未知字符标记
        nationality_vocab = Vocabulary(add_unk=False)  # 初始化国籍词汇表,不添加未知标记

        for index, row in surname_df.iterrows():  # 遍历DataFrame中的每一行
            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):
        """
        从可序列化的字典实例化向量化器。

        参数:
            contents (dict): 包含序列化信息的字典。

        返回:
            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):
        """
        将向量化器实例转换为可序列化的字典。

        返回:
            dict: 包含向量化器信息的可序列化字典。
        """
        return {
            'surname_vocab': self.surname_vocab.to_serializable(),
            'nationality_vocab': self.nationality_vocab.to_serializable()
        }

 定义数据集类(SurnameDataset),用于封装姓氏数据集

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

 定义模型类(SurnameClassifier):实现一个多层感知器模型,用于分类任务

 

class SurnameClassifier(nn.Module):
    """ A 2-layer Multilayer Perceptron for classifying surnames """
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        Args:
            input_dim (int): the size of the input vectors
            hidden_dim (int): the output size of the first Linear layer
            output_dim (int): the output size of the second Linear layer
        """
        super(SurnameClassifier, self).__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc2 = nn.Linear(hidden_dim, output_dim)
 
    def forward(self, x_in, apply_softmax=False):
        """The forward pass of the classifier
        
        Args:
            x_in (torch.Tensor): an input data tensor. 
                x_in.shape should be (batch, input_dim)
            apply_softmax (bool): a flag for the softmax activation
                should be false if used with the Cross Entropy losses
        Returns:
            the resulting tensor. tensor.shape should be (batch, output_dim)
        """
        intermediate_vector = F.relu(self.fc1(x_in))
        prediction_vector = self.fc2(intermediate_vector)
 
        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)
 
        return prediction_vector

定义初始化训练状态(make_train_state)、更新训练状态函数(update_train_state)、定义准确率计算函数(compute_accuracy)

def make_train_state(args):
    """创建训练状态字典,记录训练过程中的关键信息。

    参数:
        args: 包含训练参数的对象。

    返回:
        train_state: 包含训练状态信息的字典。
    """
    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: 包含训练参数的对象。
        model: 要训练的模型。
        train_state: 表示训练状态的字典。

    返回:
        train_state: 更新后的训练状态字典。
    """
    # 如果是第一个epoch,至少保存一次模型
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # 如果已经训练过至少一个epoch,根据性能决定是否保存模型
    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: 模型预测的类别概率。
        y_target: 真实的类别标签。

    返回:
        accuracy: 准确率的百分比形式。
    """
    _, y_pred_indices = torch.max(y_pred, dim=1)  # 获取预测最大概率的索引
    n_correct = (y_pred_indices == y_target).sum().item()  # 计算正确预测的数量
    return n_correct / len(y_pred_indices) * 100  # 计算准确率

定义随机种子设置函数(set_seed_everywhere)和目录处理函数(handle_dirs),分别确保可重复性和确保模型保存和其他文件操作的路径有效。

def set_seed_everywhere(seed, cuda):
    """设置随机种子,确保实验的可重复性。

    参数:
        seed (int): 随机种子的值。
        cuda (bool): 是否在CUDA环境中运行。
    """
    np.random.seed(seed)  # 为NumPy设置随机种子
    torch.manual_seed(seed)  # 为PyTorch设置随机种子
    if cuda:  # 如果指定在CUDA环境中运行
        torch.cuda.manual_seed_all(seed)  # 为所有CUDA设备设置随机种子

def handle_dirs(dirpath):
    """处理目录路径,确保所需的目录存在。

    参数:
        dirpath (str): 需要检查或创建的目录路径。
    """
    if not os.path.exists(dirpath):  # 检查目录是否存在
        os.makedirs(dirpath)  # 如果目录不存在,则创建它

使用argparse.Namespace定义和设置训练过程中的参数,并根据需要,更新文件路径以指向保存模型和向量化器的目录。检查CUDA可用性后,根据是否从文件重新加载数据,创建新的数据集和向量化器或从文件加载。

# 定义命令行参数对象args,包含程序运行所需的各种配置参数
args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",  # 指定姓氏数据集的CSV文件路径
    vectorizer_file="vectorizer.json",  # 指定向量化器文件的保存路径
    model_state_file="model.pth",  # 指定模型状态文件的保存路径
    save_dir="model_storage/ch4/surname_mlp",  # 指定存储模型和向量化器的目录路径

    # 模型超参数
    hidden_dim=300,  # 设置隐藏层的维度

    # 训练超参数
    seed=1337,  # 设置随机种子以确保结果可复现
    num_epochs=100,  # 设置训练迭代的最大轮数
    early_stopping_criteria=5,  # 设置早停法的标准,用于控制训练轮数
    learning_rate=0.001,  # 设置模型的学习率
    batch_size=64,  # 设置每个批次的样本数量

    # 运行时选项
    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))

# 检查CUDA是否可用,并据此设置args中的cuda选项
if not torch.cuda.is_available():
    args.cuda = False

# 根据是否使用CUDA设置设备为CPU或GPU
args.device = torch.device("cuda" if args.cuda else "cpu")

# 打印是否使用CUDA信息
print("Using CUDA: {}".format(args.cuda))

# 为了结果可复现,设置随机种子
set_seed_everywhere(args.seed, args.cuda)

# 确保保存目录存在
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()

# 根据向量化器和命令行参数初始化分类器模型
# input_dim是姓氏词汇表的大小,hidden_dim是隐藏层维度,output_dim是国籍词汇表的大小
classifier = SurnameClassifier(
    input_dim=len(vectorizer.surname_vocab), 
    hidden_dim=args.hidden_dim, 
    output_dim=len(vectorizer.nationality_vocab)
)

初始化MLP模型,设置损失函数和优化器后,进行多个训练周期,每个周期包括: a. 清零梯度。 b. 计算模型输出和损失。 c. 反向传播和优化器步骤 。训练中根据验证集损失,实现早停来避免过拟合。

# 将分类器模型移动到指定的设备(CPU或GPU)以进行训练或评估
classifier = classifier.to(args.device)

# 将数据集的类别权重移动到指定的设备(CPU或GPU),以便在损失函数中使用
dataset.class_weights = dataset.class_weights.to(args.device)

# 使用数据集的类别权重初始化交叉熵损失函数,以处理不平衡数据集
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 使用Adam优化器初始化模型参数的优化过程,并设置学习率
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)

# 使用ReduceLROnPlateau策略初始化学习率调度器,以在验证集性能不再提升时降低学习率
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')  # 训练集
dataset.set_split('val')    # 验证集

# 初始化训练集和验证集的进度条,显示每个epoch中的批次数量
train_bar = tqdm_notebook(    # 训练集进度条
    desc='split=train', 
    total=dataset.get_num_batches(args.batch_size),
    position=1,
    leave=True
)
val_bar = tqdm_notebook(      # 验证集进度条
    desc='split=val', 
    total=dataset.get_num_batches(args.batch_size),
    position=1,
    leave=True
)

# 训练循环,遍历所有epoch进行训练和验证
try:
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index

        # 训练阶段:对训练集进行迭代,执行训练步骤(前向传播、损失计算、反向传播和优化器步骤)
        dataset.set_split('train')
        batch_generator = generate_batches(dataset, args.batch_size, device=args.device)
        running_loss, running_acc = 0.0, 0.0
        classifier.train()  # 设置模型为训练模式

        for batch_index, batch_dict in enumerate(batch_generator):
            optimizer.zero_grad()  # 清零梯度
            y_pred = classifier(batch_dict['x_surname'])  # 计算输出
            loss = loss_func(y_pred, batch_dict['y_nationality'])  # 计算损失
            loss.backward()  # 反向传播
            optimizer.step()  # 更新模型参数

            # 计算并更新训练进度条显示的平均损失和准确率
            running_loss += loss.item()
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += acc_t
            # ...

        # 验证阶段:对验证集进行迭代,评估模型性能
        dataset.set_split('val')
        batch_generator = generate_batches(dataset, args.batch_size, device=args.device)
        running_loss, running_acc = 0.0, 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'])
            # 计算并更新验证进度条显示的平均损失和准确率
            running_loss += loss.to("cpu").item()
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += acc_t
            # ...

        # 更新训练状态,可能包括保存模型、更新学习率等操作
        train_state = update_train_state(args, classifier, 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()

# 如果用户中断训练(例如通过Ctrl+C),则退出循环
except KeyboardInterrupt:
    print("Exiting loop")

 在验证集和测试集上评估模型性能

# 使用训练过程中保存的最佳模型状态来计算测试集上的损失和准确率
# 加载之前训练中保存的最佳模型状态
classifier.load_state_dict(torch.load(train_state['model_filename']))

# 将模型移动到指定的设备(CPU或GPU)以进行测试
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, args.batch_size, device=args.device)

# 初始化测试损失和准确率的累积变量
running_loss = 0.
running_acc = 0.

# 将模型设置为评估模式,关闭Dropout和Batch Normalization层的训练行为
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

测试损失与准确率如下: 

应用于预测姓氏分类:

定义预测函数(predict_nationality),用于预测新姓氏的国籍 

def predict_nationality(surname, classifier, vectorizer):
    """
    Predict the nationality from a new surname
    
    Args:
        surname (str): the surname to classifier - 输入要分类的姓氏字符串
        classifier (SurnameClassifier): an instance of the classifier - 一个分类器实例
        vectorizer (SurnameVectorizer): the corresponding vectorizer - 对应的向量化器实例
    
    Returns:
        a dictionary with the most likely nationality and its probability - 返回一个字典,包含最有可能的国籍和其概率
    """
    # 使用向量化器将姓氏字符串转换为向量化形式
    vectorized_surname = vectorizer.vectorize(surname)
    # 将向量化后的姓氏转换为PyTorch张量,并调整为模型需要的尺寸
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)
    # 使用分类器对向量化的姓氏进行预测,并应用softmax函数获取概率分布
    result = classifier(vectorized_surname, apply_softmax=True)

    # 从分类器的输出中获取概率最高的国籍索引和相应的概率值
    probability_values, indices = result.max(dim=1)
    # 获取概率最高的国籍索引(转换为Python的标量类型)
    index = indices.item()

    # 使用向量化器的国籍词汇表查找索引对应的国籍字符串
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    # 获取与最高概率索引对应的国籍概率值(转换为Python的标量类型)
    probability_value = probability_values.item()

    # 返回包含预测国籍和概率的字典
    return {'nationality': predicted_nationality, 'probability': probability_value}

 通过输入姓氏,来显示预测的国籍和概率

# 请求用户输入一个要分类的姓氏
new_surname = input("Enter a surname to classify: ")

# 确保分类器在CPU上运行,无论它之前是在CPU还是GPU上训练的
classifier = classifier.to("cpu")

# 使用用户输入的姓氏调用predict_nationality函数进行预测
prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印预测结果,包括输入的姓氏、预测的国籍和概率值
# {:.2f}格式化概率值,保留两位小数
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

结果:zhang是俄罗斯姓氏。

再定义一个函数(predict_topk_nationality),用于显示姓氏可能的前k个最可能的国籍 

# 定义一个函数,用于预测给定名字的前k个最可能的国籍
def predict_topk_nationality(name, classifier, vectorizer, k=5):
    # 使用向量化器将名字向量化
    vectorized_name = vectorizer.vectorize(name)
    # 将向量化的名字转换为PyTorch张量,并调整形状以匹配模型的输入要求
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    # 使用分类器进行预测,并应用softmax函数来获取概率分布
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    # 获取概率最高的k个国籍的索引和相应的概率值
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # 将PyTorch张量转换为NumPy数组,并提取第一维(因为我们只处理一个样本)
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]
    
    # 初始化一个空列表来存储预测结果
    results = []
    # 遍历概率值和索引,将索引转换为国籍名称,并存储结果
    for prob_value, index in zip(probability_values, indices):
        nationality = vectorizer.nationality_vocab.lookup_index(index)
        results.append({'nationality': nationality, 
                        'probability': prob_value})
    
    # 返回包含最可能的国籍及其概率的列表
    return results

# 请求用户输入一个要分类的姓氏
new_surname = input("Enter a surname to classify: ")
# 确保分类器在CPU上运行
classifier = classifier.to("cpu")

# 请求用户输入想要看到的预测数量
k = int(input("How many of the top predictions to see? "))
# 如果请求的预测数量超过了国籍词汇表的大小,则使用最大大小
if k > len(vectorizer.nationality_vocab):
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
    k = len(vectorizer.nationality_vocab)
    
# 调用函数进行预测,传入姓氏、分类器、向量化器和要显示的预测数量
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

# 打印预测结果的标题
print("Top {} predictions:".format(k))
print("===================")
# 遍历预测结果并打印每个预测的国籍和概率
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))

 结果:

3.4 CNN在姓氏分类中的应用 

下面用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

定义词汇表类(Vocabulary) 

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

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        构造函数参数:
            token_to_idx (dict): 预先存在的单词到索引的映射字典
            add_unk (bool): 是否添加未知(UNK)标记的标识符
            unk_token (str): 要添加到词汇表中的未知标记
        """

        # 如果没有提供token_to_idx,则初始化为空字典
        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
        
        # 初始化未知标记的索引,如果未添加则为-1
        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):
        """根据序列化的字典来实例化Vocabulary类"""
        return cls(**contents)

    # 向词汇表中添加一个标记的方法
    def add_token(self, token):
        """基于给定的标记更新映射字典

        参数:
            token (str): 要添加到词汇表中的项
        返回:
            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):
        """将一系列标记添加到词汇表中

        参数:
            tokens (list): 字符串标记的列表
        返回:
            indices (list): 对应于这些标记的索引列表
        """
        return [self.add_token(token) for token in tokens]

    # 查找标记对应的索引的方法
    def lookup_token(self, token):
        """检索与标记相关联的索引,如果标记不存在,则返回未知标记的索引

        参数:
            token (str): 要查找的标记
        返回:
            index (int): 对应于该标记的索引
        注意:
            如果启用了未知标记功能,则`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):
        """返回与索引相关联的标记

        参数: 
            index (int): 要查找的索引
        返回:
            token (str): 对应于该索引的标记
        异常:
            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)

定义向量化器类(SurnameVectorizer

class SurnameVectorizer(object):
    """协调各个词汇表并将它们用于向量化的类"""

    def __init__(self, surname_vocab, nationality_vocab, max_surname_length):
        """
        参数:
            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):
        """
        将姓氏字符串转换为一个独热编码矩阵

        参数:
            surname (str): 姓氏字符串
        返回:
            one_hot_matrix (np.ndarray): 独热编码矩阵
        """

        # 初始化独热编码矩阵的大小
        one_hot_matrix_size = (len(self.surname_vocab), self._max_surname_length)
        one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
        
        # 对姓氏中的每个字符进行遍历
        for position_index, character in enumerate(surname):
            # 获取字符对应的索引
            character_index = self.surname_vocab.lookup_token(character)
            # 在独热矩阵中设置对应位置的值为1
            one_hot_matrix[character_index][position_index] = 1
        
        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据集DataFrame实例化Vectorizer
        
        参数:
            surname_df (pandas.DataFrame): 姓氏数据集
        返回:
            SurnameVectorizer的一个实例
        """
        surname_vocab = Vocabulary(unk_token="@")
        nationality_vocab = Vocabulary(add_unk=False)
        max_surname_length = 0

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

        # 返回SurnameVectorizer类的实例
        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'])
        # 返回SurnameVectorizer类的实例
        return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab, 
                   max_surname_length=contents['max_surname_length'])

    def to_serializable(self):
        # 将SurnameVectorizer对象序列化为字典
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable(), 
                'max_surname_length': self._max_surname_length}

定义数据集类(SurnameDataset

class SurnameDataset(Dataset):
    """用于处理姓氏数据集的类,继承自PyTorch的Dataset类"""

    def __init__(self, surname_df, vectorizer):
        """
        初始化SurnameDataset类的实例,准备数据集和向量化器。

        参数:
            surname_df (pandas.DataFrame): 包含姓氏数据的DataFrame。
            vectorizer (SurnameVectorizer): 用于将姓氏向量化的向量化器实例。
        """
        self.surname_df = surname_df
        self._vectorizer = vectorizer

        # 根据split字段划分训练集、验证集和测试集
        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)

        # 创建一个查找字典,以便于根据split快速访问不同的数据集划分
        self._lookup_dict = {
            'train': (self.train_df, self.train_size),
            'val': (self.val_df, self.validation_size),
            'test': (self.test_df, self.test_size)
        }

        # 默认设置为训练集
        self.set_split('train')

        # 计算类别权重,用于处理不平衡数据集
        class_counts = surname_df.nationality.value_counts().to_dict()
        # 对类别进行排序,以保持一致性
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)

    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """
        类方法,从CSV加载数据集并创建新的向量化器。

        参数:
            surname_csv (str): 数据集CSV文件的位置。
        Returns:
            SurnameDataset的一个实例。
        """
        surname_df = pd.read_csv(surname_csv)
        train_surname_df = surname_df[surname_df.split == 'train']
        return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
        """
        类方法,加载数据集和对应的向量化器。用于向量化器已经被序列化并需要被重新使用的情况。

        参数:
            surname_csv (str): 数据集CSV文件的位置。
            vectorizer_filepath (str): 保存的向量化器文件的位置。
        Returns:
            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):
        """
        静态方法,从文件中加载序列化的向量化器。

        参数:
            vectorizer_filepath (str): 序列化向量化器文件的位置。
        Returns:
            SurnameVectorizer的实例。
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """
        将向量化器序列化并保存到磁盘。

        参数:
            vectorizer_filepath (str): 保存向量化器的文件路径。
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """返回当前使用的向量化器实例"""
        return self._vectorizer

    def set_split(self, split="train"):
        """设置数据集的split(划分),如训练集、验证集或测试集
        
        参数:
            split (str): 要设置的数据集划分,'train', 'val', 'test'中的一个。
        """
        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):
        """根据索引获取数据集中的一项,这是PyTorch Dataset类的主要接口

        参数:
            index (int): 数据点的索引。
        Returns:
            包含数据点特征和标签的字典。
        """
        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):
        """根据给定的批量大小返回数据集中的批次数量
        
        参数:
            batch_size (int): 批量大小。
        Returns:
            数据集中的批次数量。
        """
        return len(self) // batch_size

# 一个生成批次数据的函数,包装PyTorch DataLoader
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    """
    生成批次数据的函数,确保每个张量都在正确的设备上。

    参数:
        dataset (Dataset): 数据集对象。
        batch_size (int): 每个批次的大小。
        shuffle (bool): 是否在每个epoch后打乱数据。
        drop_last (bool): 如果最后一个批次不足batch_size,是否丢弃它。
        device (str): 数据将被移动到的设备,如"cpu"或"cuda"。
    生成:
        包含处理好的数据批次的字典。
    """
    # 创建一个PyTorch DataLoader
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)

    # 遍历DataLoader产生的数据批次
    for data_dict in dataloader:
        # 初始化输出数据字典
        out_data_dict = {}
        # 将批次中的每个张量移动到指定的设备上
        for name, tensor in data_dict.items():
            out_data_dict[name] = tensor.to(device)
        # 产生处理后的数据字典
        yield out_data_dict

定义模型类(SurnameClassifier

class SurnameClassifier(nn.Module):
    """用于分类姓氏的卷积神经网络模型"""

    def __init__(self, initial_num_channels, num_classes, num_channels):
        """
        初始化分类器。

        参数:
            initial_num_channels (int): 输入特征向量的大小
            num_classes (int): 输出预测向量的大小,即类别数
            num_channels (int): 网络中使用的恒定通道数
        """
        super(SurnameClassifier, self).__init__()
        
        # 定义卷积网络
        self.convnet = nn.Sequential(
            # 第一个一维卷积层,使用3个大小的卷积核
            nn.Conv1d(in_channels=initial_num_channels, 
                      out_channels=num_channels, kernel_size=3),
            # 激活函数,使用ELU
            nn.ELU(),
            # 第二个一维卷积层,使用3x3卷积核,步长为2,进行下采样
            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):
        """分类器的前向传播
        
        参数:
            x_surname (torch.Tensor): 输入数据张量。
                x_surname.shape 应该是 (batch, initial_num_channels, max_surname_length)
            apply_softmax (bool): 是否应用softmax激活函数的标识
                如果与交叉熵损失一起使用,则应为False
                
        返回:
            结果是预测的张量。预测张量shape应该是 (batch, num_classes)
        """
        # 通过卷积网络提取特征
        features = self.convnet(x_surname).squeeze(dim=2)
        
        # 通过全连接层得到预测向量
        prediction_vector = self.fc(features)

        # 如果需要应用softmax激活函数
        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)

        return prediction_vector

 

定义初始化训练状态(make_train_state)、更新训练状态函数(update_train_state)、定义准确率计算函数(compute_acuuracy)

def make_train_state(args):
    """
    创建并返回一个字典,用于在训练过程中保存模型的状态和指标。
    
    参数:
        args (Namespace): 包含训练参数的对象。
    
    返回:
        train_state (dict): 包含训练状态信息的字典。
    """
    return {
        # 控制是否提前停止训练的标志
        'stop_early': False,
        # 记录连续未改进的验证损失的步数
        'early_stopping_step': 0,
        # 记录目前为止最佳的验证损失
        'early_stopping_best_val': 1e8,
        # 当前学习率
        'learning_rate': args.learning_rate,
        # 当前训练轮数(epoch)
        'epoch_index': 0,
        # 记录每个epoch的训练损失
        'train_loss': [],
        # 记录每个epoch的训练准确率
        'train_acc': [],
        # 记录每个epoch的验证损失
        'val_loss': [],
        # 记录每个epoch的验证准确率
        'val_acc': [],
        # 测试损失(默认初始化为-1,直到测试完成)
        'test_loss': -1,
        # 测试准确率(默认初始化为-1,直到测试完成)
        'test_acc': -1,
        # 保存模型状态的文件名
        'model_filename': args.model_state_file
    }
def update_train_state(args, model, train_state):
    """
    处理训练状态的更新。

    组件:
     - 早停法(Early Stopping):防止过拟合。
     - 模型检查点(Model Checkpoint):如果模型性能提升,则保存模型。

    :param args: 主要参数
    :param model: 要训练的模型
    :param train_state: 代表训练状态值的字典
    :returns:
        新的 train_state
    """

    # 至少保存一个模型
    if train_state['epoch_index'] == 0:
        # 在第一个epoch结束时保存模型
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    # 如果已经训练过至少一个epoch,则进行以下操作
    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

        # 是否提前停止训练?
        # 如果连续args.early_stopping_criteria个epoch损失没有改善,则提前停止
        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 (torch.Tensor): 模型预测的输出,应该是一个批次的预测结果。
        y_target (torch.Tensor): 真实标签,应该是和预测输出相对应的真实类别。

    返回:
        accuracy (float): 模型预测的准确率百分比。
    """

    # 使用torch.max获取预测类别的索引
    y_pred_indices = y_pred.max(dim=1)[1]

    # 计算预测正确的数量,即预测类别和真实类别相同的数量
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()

    # 计算准确率,即预测正确的数量除以总数量,然后乘以100得到百分比
    return n_correct / len(y_pred_indices) * 100

使用argparse.Namespace定义和设置训练过程中的参数,并根据需要,更新文件路径以指向保存模型和向量化器的目录。检查CUDA可用性后,根据是否从文件重新加载数据,创建新的数据集和向量化器或从文件加载。

args = Namespace(
    # Data and Path information
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/cnn",
    # Model hyper parameters
    hidden_dim=100,
    num_channels=256,
    # Training hyper parameters
    seed=1337,
    learning_rate=0.001,
    batch_size=128,
    num_epochs=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # Runtime options
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
    catch_keyboard_interrupt=True
)


if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir,
                                        args.vectorizer_file)

    args.model_state_file = os.path.join(args.save_dir,
                                         args.model_state_file)
    
    print("Expanded filepaths: ")
    print("\t{}".format(args.vectorizer_file))
    print("\t{}".format(args.model_state_file))
    
# Check CUDA
if not torch.cuda.is_available():
    args.cuda = False

args.device = torch.device("cuda" if args.cuda else "cpu")
print("Using CUDA: {}".format(args.cuda))

def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)
        
def handle_dirs(dirpath):
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)
        
# Set seed for reproducibility
set_seed_everywhere(args.seed, args.cuda)

# handle dirs
handle_dirs(args.save_dir)
if args.reload_from_files:
    # training from a checkpoint
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                              args.vectorizer_file)
else:
    # create dataset and vectorizer
    dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
    dataset.save_vectorizer(args.vectorizer_file)
    
vectorizer = dataset.get_vectorizer()

classifier = SurnameClassifier(initial_num_channels=len(vectorizer.surname_vocab), 
                               num_classes=len(vectorizer.nationality_vocab),
                               num_channels=args.num_channels)

classifer = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)

loss_func = nn.CrossEntropyLoss(weight=dataset.class_weights)
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                           mode='min', factor=0.5,
                                           patience=1)

train_state = make_train_state(args)

初始化分类器模型,设置损失函数和优化器,进行多个训练周期,每个周期包括前向传播、损失计算、反向传播和参数更新;在训练中根据验证损失决定是否提前终止训练以防止过拟合

epoch_bar = tqdm_notebook(desc='training routine', 
                          total=args.num_epochs,
                          position=0)

dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train',
                          total=dataset.get_num_batches(args.batch_size), 
                          position=1, 
                          leave=True)
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val',
                        total=dataset.get_num_batches(args.batch_size), 
                        position=1, 
                        leave=True)

try:
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index

        # Iterate over training dataset

        # setup: batch generator, set loss and acc to 0, set train mode on

        dataset.set_split('train')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.0
        running_acc = 0.0
        classifier.train()

        for batch_index, batch_dict in enumerate(batch_generator):
            # the training routine is these 5 steps:

            # --------------------------------------
            # step 1. zero the gradients
            optimizer.zero_grad()

            # step 2. compute the output
            y_pred = classifier(batch_dict['x_surname'])

            # step 3. compute the loss
            loss = loss_func(y_pred, batch_dict['y_nationality'])
            loss_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

            # step 4. use loss to produce gradients
            loss.backward()

            # step 5. use optimizer to take gradient step
            optimizer.step()
            # -----------------------------------------
            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)

            # update bar
            train_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            train_bar.update()

        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # Iterate over val dataset

        # setup: batch generator, set loss and acc to 0; set eval mode on
        dataset.set_split('val')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.
        running_acc = 0.
        classifier.eval()

        for batch_index, batch_dict in enumerate(batch_generator):

            # compute the output
            y_pred =  classifier(batch_dict['x_surname'])

            # step 3. compute the loss
            loss = loss_func(y_pred, batch_dict['y_nationality'])
            loss_t = loss.item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
            running_acc += (acc_t - running_acc) / (batch_index + 1)
            val_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            val_bar.update()

        train_state['val_loss'].append(running_loss)
        train_state['val_acc'].append(running_acc)

        train_state = update_train_state(args=args, model=classifier,
                                         train_state=train_state)

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

        if train_state['stop_early']:
            break

        train_bar.n = 0
        val_bar.n = 0
        epoch_bar.update()
except KeyboardInterrupt:
    print("Exiting loop")

 在验证集和测试集上评估模型性能

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

classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

dataset.set_split('test')
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)
running_loss = 0.
running_acc = 0.
classifier.eval()

for batch_index, batch_dict in enumerate(batch_generator):
    # compute the output
    y_pred =  classifier(batch_dict['x_surname'])
    
    # compute the loss
    loss = loss_func(y_pred, batch_dict['y_nationality'])
    loss_t = loss.item()
    running_loss += (loss_t - running_loss) / (batch_index + 1)

    # compute the accuracy
    acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
    running_acc += (acc_t - running_acc) / (batch_index + 1)

train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc

 结果如下:准确率比MLP要高。

在姓氏分类上进行应用

定义预测函数(predict_nationality),用于预测新姓氏的国籍 

def predict_nationality(surname, classifier, vectorizer):
    """Predict the nationality from a new surname
    
    Args:
        surname (str): the surname to classifier
        classifier (SurnameClassifer): an instance of the classifier
        vectorizer (SurnameVectorizer): the corresponding vectorizer
    Returns:
        a dictionary with the most likely nationality and its probability
    """
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
    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']))

 结果:zhang变成中国姓氏了

再定义一个函数(predict_topk_nationality),用于显示姓氏可能的前k个最可能的国籍

def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    """Predict the top K nationalities from a new surname
    
    Args:
        surname (str): the surname to classifier
        classifier (SurnameClassifer): an instance of the classifier
        vectorizer (SurnameVectorizer): the corresponding vectorizer
        k (int): the number of top nationalities to return
    Returns:
        list of dictionaries, each dictionary is a nationality and a probability
    """
    
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # returned size is 1,k
    probability_values = probability_values[0].detach().numpy()
    indices = indices[0].detach().numpy()
    
    results = []
    for kth_index in range(k):
        nationality = vectorizer.nationality_vocab.lookup_index(indices[kth_index])
        probability_value = probability_values[kth_index]
        results.append({'nationality': nationality, 
                        'probability': probability_value})
    return results

new_surname = input("Enter a surname to classify: ")

k = int(input("How many of the top predictions to see? "))
if k > len(vectorizer.nationality_vocab):
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
    k = len(vectorizer.nationality_vocab)
    
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))

 结果:

四、总结

 

在本次实验中,我们深入研究了多层感知器(MLP)和卷积神经网络(CNN)在姓氏分类任务中的应用,并探索了不同网络结构对分类性能的影响。

实验的核心目标是理解和比较MLP和CNN在处理分类任务时的性能和适用性。为了实现这一目标,我们设计了包含不同网络层配置的多层感知器,并构建了一个卷积神经网络模型。实验采用了姓氏数据集,通过对数据的预处理、模型训练、评估和可视化,深入分析了模型的表现。

在过程中我们发现:

  • MLP的层数影响:增加隐藏层可以提升模型的表达能力,但也增加了过拟合的风险。
  • CNN的优势:在测试集上,CNN模型相较于MLP展现出更高的准确率,证明了其在处理复杂视觉和序列数据上的强大能力。

多层感知器和卷积神经网络都是功能强大的分类工具。MLP通过增加隐藏层来提高模型的学习能力,而CNN通过其卷积层有效地捕捉局部特征和模式。实验结果表明,选择合适的网络结构对于优化分类性能至关重要。

总而言之,本次实验不仅加深了我们对MLP和CNN在分类任务中应用的理解,而且强调了模型选择和调整在实现最佳性能中的重要性。未来,我们期待这些模型能够在更广泛的领域中得到应用,并解决更多复杂的实际问题。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值