超详细的NLP实战案例解析——使用前馈神经网络进行姓氏分类

引言

在科技日新月异的今天,神经网络已成为人工智能领域的基石,其发展历程宛如一部智慧的进化史。从早期受到生物神经系统的启发,到如今支撑起复杂决策系统的背后力量,神经网络的概念逐渐深入人心。简单来说,神经网络是由大量简单单元(即神经元)相互连接而成的计算模型,通过模拟人脑的工作方式,能够学习并解决各种复杂的任务。

在这一发展过程中,有一个经典的难题尤为突出——XOR(异或)问题,它成为了区分传统逻辑运算与现代深度学习能力的一道分水岭。XOR逻辑要求输出与输入间存在非线性关系,这意味着简单的“是”或“否”规则不再适用。早期的单一神经元模型,比如感知器,受限于线性分类的能力,面对XOR这样的非线性问题时显得束手无策。

正是在这样的背景下,多层感知器(MLP)和卷积神经网络(CNN)应运而生,它们在处理非线性问题上展现出了强大的威力。MLP通过引入隐藏层,使得模型能够捕捉和表达数据中的复杂模式;而CNN则凭借其在图像识别和序列数据分析中的独特优势,成为视觉与语音等领域不可或缺的工具。

本文旨在深入浅出地探讨MLP与CNN的内在机制,揭示它们如何突破线性限制,巧妙地解决那些一度被认为不可能的非线性挑战。我们将通过实例解析、直观图示以及简化版的数学表述,带领读者一窥这些智能架构的奥秘,理解它们如何在现代技术的浪潮中,引领着机器学习和人工智能的新篇章。

一、XOR问题的挑战与感知器的局限

解释XOR逻辑问题的本质与线性不可分性

XOR(Exclusive OR,异或)逻辑运算的数学表达式为:
A\bigoplus B = \bar{A}B + A\bar{B} 

在二维空间中,XOR逻辑可以被可视化为四个点 (0,0), (0,1), (1,0), (1,1),其中前两者属于一类(设为类别1,标记为圆形),后两者属于另一类(设为类别2,标记为星形)。这些点的分布表明,不存在一个线性超平面(即直线在二维空间中)能够将两类完全正确地分开,这是所谓的线性不可分性。

下图给出了两个类别的异或数据集:

感知器模型的工作原理及局限

感知器模型的基本结构试图通过加权求和与激活函数来实现分类,其输出 y可以表示为:
y=f(w_{1}x + w_{2}x + b)

其中,w_{1}和 w_{2} 是输入特征x_{1}x_{2}的权重,b是偏置项,f是激活函数,通常选取阶跃函数或 sigmoid 函数等。

对于线性可分数据,通过适当的权重调整,感知器可以找到一个超平面(在二维中为直线)作为决策边界。然而,对于XOR问题,由于数据的线性不可分特性,即使不断调整权重 和偏置 ,也无法找到一个线性决策边界来正确分类所有的数据点。这是因为XOR问题的本质要求决策边界是一个非线性的曲线,而非直线,这超出了单层感知器的能力范围。

展示感知器为何无法解决XOR问题的数学解释

在尝试使用直线(即一维的决策边界)分离XOR数据集的两个类时,我们可以形式化地说明感知器的失败。考虑二维空间中的直线方程 y = mx + c,其中 m 是斜率,c 是截距。无论这个直线如何放置,总会至少有一个或两个点被错误分类,因为XOR的真值表本质上需要一个“弯曲”的决策边界,例如一个“X”形,这在几何上是直线所无法达到的。

感知器作为神经网络的先驱,其局限性在于仅能处理线性可分问题。XOR问题的出现暴露了这一限制,促进了神经网络理论的进一步发展,推动了多层结构(如多层感知器)和非线性变换(如通过引入激活函数)的应用,这些进步最终演化为现代深度学习的基础。

二、多层感知器(MLP):突破线性限制

多层感知机(MLP)是一种基础的人工神经网络结构,它在传统的感知器模型基础上扩展了功能,通过增加隐藏层来处理非线性问题。下面详细介绍MLP的工作原理、结构和关键组件。

 MLP基本结构与原理

MLP由输入层、一个或多个隐藏层以及输出层组成。输入层接收原始数据,每个隐藏层则对前一层的输出进行非线性转换,以提取更高级别的特征表示。输出层根据模型任务(如分类或回归)给出最终预测。博客中提到,一个典型的三层MLP包括输入层、一个隐藏层和输出层,其中隐藏层的输出是通过激活函数(如sigmoid或tanh)转换的加权输入和偏置项的和得到的,公式为f(W_{1}X + b_{1}) 。输出层通常采用softmax函数进行多类别分类,其输出形式为softmax(W_{2}X _{1}+ b_{2}) ,其中 X_{1}是隐藏层的输出。

MLP中的所有参数,即各层间的连接权重 W 和偏置项 b,需要通过训练过程确定。训练中最常用的方法是梯度下降法,它通过最小化损失函数来迭代更新参数,直到模型性能满足预设条件。此过程涉及计算梯度、设定学习率、正则化等策略,以避免过拟合等问题。对于小规模或简单数据集,一个较小的隐藏层(例如10个节点)通常已足够。然而,面对大规模或复杂数据集,可以采取增加隐藏层节点数量或添加更多隐藏层的策略。这正是“深度学习”中“深度”概念的体现,通过增加网络的深度,模型能够学习到更复杂的特征表示。

其结构如下图所示:

MLP与感知机对比

MLP解决XOR问题的关键在于其隐藏层的非线性转换能力。通过增加隐藏层,MLP能够将原本线性不可分的数据映射到一个新的特征空间,在这个空间里数据变得线性可分。想象一下,虽然原始XOR数据在二维平面上无法用直线分开,但通过隐藏层的转换,这些数据点在更高维空间中可能被一个超平面所分隔。下图直观对比了感知器与MLP在XOR问题上的表现差异,突显了MLP成功划分两类数据的能力。(左变为感知机决策边界,右边为MLP决策边界)

MLP隐藏层的两个关键作用是线性分组非线性转换。首先,它将输入数据通过线性组合分组,使得具有相似特征的数据点在隐藏表示中更接近。然后,通过非线性激活函数(如ReLU)引入非线性,这一步骤是至关重要的,因为它允许网络学习数据的复杂、非线性模式。下图形象展示了MLP如何通过隐藏层逐步“扭曲”和重新组织数据空间,使其变为线性可分。

MLP的主要优势在于它能够处理非线性问题。单层感知器只能划分线性可分的数据,而MLP通过引入多层结构与非线性激活函数,极大地扩展了其解决问题的范围,能够有效应对线性不可分数据,如XOR问题。此外,随着隐藏层数量的增加,MLP可以学习到更深层次、更抽象的数据特征,从而在复杂任务上表现出更优的性能。

 PyTorch中的MLP实现

在PyTorch中,实现一个简单的MLP模型涉及定义网络结构(包含线性层和激活函数),初始化模型参数,并执行前向传播。接着定义一个具有输入层、隐藏层和输出层的MLP类,实例化该模型并用随机数据测试其前向传播功能。最后,通过应用softmax函数将模型输出转化为概率分布,这对于分类任务。

定义MLP类

通过nn.Module类定义了MultilayerPerceptron类,该类构成了多层感知机的主体。该模型由两个线性层(nn.Linear)组成,分别命名为fc1fc2,它们代表了从输入层到隐藏层再到输出层的映射。

import torch.nn as nn
import torch.nn.functional as F
import torch
seed = 1337
torch.manual_seed(seed)  # 设置CPU中的随机种子以保证结果的可复现性
torch.cuda.manual_seed_all(seed)  # 如果使用GPU,也设置随机种子

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_size, input_dim)
            apply_softmax (bool): 是否应用softmax函数
                如果输出用于分类任务并且将与交叉熵损失函数一起使用,则应设置为False
        
        返回:
            torch.Tensor: 模型的输出张量
                output.shape 应为 (batch_size, output_dim)
        """
        # 通过第一个全连接层并应用ReLU激活函数
        intermediate = F.relu(self.fc1(x_in))
        # 通过第二个全连接层
        output = self.fc2(intermediate)
        # 如果需要应用softmax函数,则在此执行
        if apply_softmax:
            output = F.softmax(output, dim=1)  # 在特征维度上应用softmax激活函数
        return output  # 返回模型的输出张量
实例化MultilayerPerceptron

实例化了一个具有特定维度(输入维度为3,隐藏维度为100,输出维度为4)的MLP模型。这一实例化过程展示了模型的灵活性,可根据实际任务需求调整网络架构。通过传递随机生成的输入张量至模型,我们进行了初步的模型测试

# 批次大小,定义一次输入模型的样本数量
batch_size = 2 
# 输入维度,即每个样本的特征数量
input_dim = 3
# 隐藏层维度,这是模型中第一层全连接层的输出尺寸
hidden_dim = 100
# 输出维度,模型最终预测的类别数或输出值的数量
output_dim = 4
# 初始化模型,创建一个具有指定输入、隐藏和输出维度的多层感知机实例
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
# 打印模型的结构信息
print(mlp)

测试MLP

我们可以通过传递一些随机输入来快速测试模型的“连接”,如示例4-3所示。因为模型还没有经过训练,所以输出是随机的。在花费时间训练模型之前,这样做是一个有用的完整性检查。请注意PyTorch的交互性是如何让我们在开发过程中实时完成所有这些工作的,这与使用NumPy或panda没有太大区别。

import torch

def describe(x):
    print("Type: {}".format(x.type()))  # 输出张量类型
    print("Shape/size: {}".format(x.shape))  # 输出张量形状/大小
    print("Values: \n{}".format(x))  # 输出张量的值

x_input = torch.rand(batch_size, input_dim)  # 生成随机输入张量
describe(x_input)  # 输出张量信息

y_output = mlp(x_input, apply_softmax=False)
describe(y_output)

三、深入神经网络的内部运作

在这一节,我们将探讨如何运用多层感知机(MLP)解决一个实际问题:根据姓氏判断其所属的国籍。这项任务在众多领域有着广泛的应用,比如个性化推荐系统和确保服务的公平性,特别是在处理敏感信息如人口统计学特征时,必须谨慎行事,因为这些属于“受保护属性”。与先前的文本情感分类任务类似,我们将姓氏视为字符序列处理,与处理单词序列在方法上保持一致性,仅在数据预处理上有所差异。本节内容强调了MLP模型的构建和训练流程是对感知器知识的直接拓展,以此为基础加入多分类输出和相应的损失函数设计。

数据预处理与向量化

所使用的姓氏数据集涵盖了18个国家的10,000个姓氏,来源于网络上的多样数据源,该数据集将在后续实验中反复利用,具备两大特点:一是数据分布不均衡,英语、俄语和阿拉伯语姓氏占据了主导地位,其他语言姓氏则较少且分布更为稀疏;二是许多姓氏的拼写与其国家来源存在明显的关联性,易于识别。为了平衡数据集并消除潜在的抽样偏差,我们对数据进行了调整,特别是针对占比过高的俄语姓氏采取了随机子采样。最终,数据被分为训练集(70%)、验证集(15%)和测试集(15%),确保各类别在各集合中比例相近,利于模型的有效评估。

数据集的创建

SurnameDataset同样继承自PyTorch的Dataset类,主要负责数据的组织和预处理。该类实现了必要的__init____len____getitem__方法,分别用于初始化数据集、报告数据集大小及根据索引返回数据样本。与评论情感分类的差异在于,它处理的是姓氏字符串及其对应国籍索引,而非评论文本的向量化。此外,该类还包含了数据集分割、向量化器管理和类别权重计算等功能,确保数据处理的一致性和效率。

代码段总结

  1. 数据集初始化与管理:通过__init__方法,根据数据分割(训练、验证、测试)加载姓氏数据,并初始化向量化器。同时,计算类别权重以应对数据不平衡问题。

  2. 数据访问与向量化__getitem__方法根据索引返回单个样本,其中包括向量化的姓氏和国籍标签索引,为模型训练准备数据。

  3. 数据集分割与切换:通过set_split方法,用户可以灵活地在不同数据集分割(训练、验证、测试)间切换。

  4. 向量化器的加载与保存:提供了向量化器的加载、保存方法,便于数据预处理的一致性和模型的复现性。

  5. 数据批次生成:通过generate_batches函数,利用DataLoader生成批次数据,支持数据打乱、指定设备(CPU/GPU)加载,方便模型训练中的高效数据处理。

    # 导入所需的库和模块
    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的Dataset类
    class SurnameDataset(Dataset):
        def __init__(self, surname_df, vectorizer):
            # 初始化函数,设置数据集DataFrame和向量化器对象
            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)
            # 创建一个字典来快速索引不同分割的数据集
            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):
                # 根据nationality_vocab的顺序对类别计数进行排序
                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_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):
            # 类方法,用于加载CSV文件中的姓氏数据集并加载已存在的向量化器
            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):
            # 静态方法,仅从文件中加载向量化器
            with open(vectorizer_filepath) as fp:
                # 反序列化json对象为向量化器
                return SurnameVectorizer.from_serializable(json.load(fp))
    
        def save_vectorizer(self, vectorizer_filepath):
            # 将向量化器对象序列化并保存到文件
            with open(vectorizer_filepath, "w") as fp:
                json.dump(self._vectorizer.to_serializable(), fp)
    
        def get_vectorizer(self):
            # 获取向量化器对象
            return self._vectorizer
    
        def set_split(self, split="train"):
            # 设置当前的数据集分割为训练集、验证集或测试集
            self._target_split = split
            self._target_df, self._target_size = self._lookup_dict[split]
    
        def __len__(self):
            # 返回当前数据集分割的大小
            return self._target_size
    
        def __getitem__(self, index):
            # 根据索引获取数据集中的一个样本,并进行向量化处理
            row = self._target_df.iloc[index]
            surname_vector = self._vectorizer.vectorize(row.surname)
            nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)
            return {'x_surname': surname_vector, 'y_nationality': nationality_index}
    
        def get_num_batches(self, batch_size):
            # 计算并返回当前数据集分割可以组成的批次数量
            return len(self) // batch_size
    
    # 定义生成批次数据的生成器函数
    def generate_batches(dataset, batch_size, shuffle=True,
                         drop_last=True, device="cpu"):
        # 使用DataLoader来批量加载数据,并可选择是否打乱数据顺序
        dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                                shuffle=shuffle, drop_last=drop_last)
        for data_dict in dataloader:
            # 将每个批次的数据移动到指定的设备上(如GPU)
            out_data_dict = {}
            for name, tensor in data_dict.items():
                out_data_dict[name] = data_dict[name].to(device)
            yield out_data_dict  # 产生一个批次的数据字典

    词汇表、词汇向量化与数据加载器

    为了将姓氏分类任务中的文本数据转化为机器学习模型可处理的形式,我们采用了词汇表(Vocabulary)、向量化器(SurnameVectorizer)和数据加载器(DataLoader)这三个关键组件。这些组件的设计能够同时适用于不同类型的文本数据处理,如将姓氏的字符标记如同评论中的单词标记一样处理。

    词汇表类(Vocabulary Class)

    此词汇表类为字符到整数索引提供双向映射,确保了数据的高效编码与解码。它包括添加新令牌、查找索引或令牌的功能,并且可以配置是否包含一个特殊未知标记(UNK),用以表示未在训练集中出现的字符。在姓氏分类任务中,我们使用简化版的one-hot编码策略,不对字符出现频率做统计,直接为每个独特字符分配一个索引,保持了数据集较小规模下的处理简便性。

    姓氏向量化器(SurnameVectorizer)

    `SurnameVectorizer`类利用上述`Vocabulary`来将姓氏字符串转换成向量形式,其核心在于将每个字符映射到其在词汇表中的索引,并生成one-hot编码表示。与评论情感分类的向量化器不同之处在于,它不对姓氏进行单词级别的分割,而是直接处理字符序列。尽管当前实现使用了简单的one-hot编码,但后续章节会探索更高级的向量表示方法,如卷积神经网络(CNN)中的字符级矩阵表示以及嵌入层(Embedding Layer),后者能为每个字符生成更密集、更具语义信息的向量。

    代码段作用总结:

    1. Vocabulary类:定义了字符到索引的映射,支持UNK令牌处理,为文本数据的索引化提供基础工具。
       
    2. SurnameVectorizer类:利用Vocabulary将姓氏字符串转换为向量,特别地,它针对字符序列进行one-hot编码,为模型训练准备数据输入。
       
    3. 整体流程:通过结合词汇表和向量化器,数据被转换为适合神经网络模型输入的形式,并通过DataLoader进一步组织为mini-batches,为高效训练模型奠定基础。这些组件共同构成了处理文本数据、特别是处理特定于字符级别任务(如姓氏分类)的基础架构。

    # 定义词汇表类,用于处理词到索引以及索引到词的映射
    class Vocabulary(object):
    
        def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
             # 初始化词汇表,如果未提供token到index的映射,则创建空映射
            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
            # 如果要求,将UNK标记添加到词汇表中
            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):
            # 添加新token到词汇表中,如果已经存在则返回其现有索引,否则添加并返回新索引
            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):
            return [self.add_token(token) for token in tokens]
    
        def lookup_token(self, token):
          
            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):
           
            if index not in self._idx_to_token:
                raise KeyError("索引(%d)不在Vocabulary中" % index)
            return self._idx_to_token[index]
    
        def __str__(self):
             # 返回字符串表示的词汇表信息
            return "<Vocabulary(size=%d)>" % len(self)
    
        def __len__(self):
            # 返回词汇表中token的数量
            return len(self._token_to_idx)
    # 定义姓氏向量化器类,用于处理姓氏和国籍的向量化
    class SurnameVectorizer(object):
    
        def __init__(self, surname_vocab, nationality_vocab):
            # 初始化姓氏和国籍的词汇表
            self.surname_vocab = surname_vocab
            self.nationality_vocab = nationality_vocab
    
        def vectorize(self, surname):
            # 将姓氏转换为one-hot编码向量
            vocab = self.surname_vocab
            one_hot = np.zeros(len(vocab), dtype=np.float32)
            for token in surname:
                one_hot[vocab.lookup_token(token)] = 1
    
            return one_hot
    
        @classmethod
        def from_dataframe(cls, surname_df):
            # 从DataFrame构建姓氏和国籍的词汇表
            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):
            # 从序列化内容中重建姓氏和国籍的词汇表
            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()}
    
    

构建MLP姓氏分类器模型

本节实现了一个基于多层感知机(MLP)的姓氏分类器模型,称为`SurnameClassifier`(示例代码,它直接体现了前文所述的MLP架构。该模型旨在将通过向量化处理的姓氏数据分类到预定义的类别中,如不同国籍。

模型概述:

模型由两层组成,每层均为全连接层(`Linear`层)。第一层将输入的向量(字符或特征的one-hot表示等)映射到一个中间维度的向量空间,此过程伴随ReLU激活函数,旨在引入非线性,更好地拟合复杂的数据分布。第二层接着将这个中间向量映射到输出维度,对应于分类任务中的各个类别。

import torch.nn as nn
import torch.nn.functional as F
# 定义姓氏分类器模型,继承自nn.Module
class SurnameClassifier(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        # 调用父类构造函数进行初始化
        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):
        intermediate_vector = F.relu(self.fc1(x_in))  # # 输入数据通过第一层全连接层,并应用ReLU激活函数
        prediction_vector = self.fc2(intermediate_vector)  # 中间向量通过第二层全连接层
        # 如果apply_softmax为True,对预测向量应用softmax函数
        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)  # 如果需要,应用softmax激活函数
        # 返回最终的预测向量
        return prediction_vector

训练过程

该段代码构建了一个通用的训练框架,旨在支持不同模型、数据集和损失函数的机器学习任务,这里特指使用多层感知机(MLP)对姓氏分类的应用情景。训练流程的关键组成部分包括:

状态管理机制

代码片段共同构成了一个结构化机器学习项目中管理训练流程的关键部分,主要关注于训练状态的维护、模型的保存与早停策略、性能评估、环境配置初始化等方面。下面是对各个函数和主要操作的概述:

  1. make_train_state(args): 初始化一个包含训练过程状态信息的字典,如早停标志、早停计数器、最佳验证损失、学习率、当前训练轮次以及训练和验证的损失与准确率列表。此外,它还记录模型状态文件的路径,用于后续的模型保存。

  2. update_train_state(args, model, train_state): 动态更新训练状态。根据当前epoch的表现,此函数决定是否保存模型(当验证损失改善时)、更新早停计数器、设置早停标志,以及基于早停准则判断是否提前终止训练。

  3. compute_accuracy(y_pred, y_target): 计算并返回模型预测的准确率,通过比较预测类别与真实目标类别的匹配情况。

  4. set_seed_everywhere(seed, cuda): 确保实验的可复现性,通过固定NumPy和PyTorch的随机种子,如果使用CUDA,也会设置CUDA的随机种子。

  5. handle_dirs(dirpath): 自动创建所需的目录结构,确保模型和相关文件有恰当的保存位置。

  6. args Namespace对象: 定义了一个命名空间对象来集中管理训练过程中的所有参数,包括数据文件路径、模型配置、训练设置(如epoch数量、早停准则、学习率等)、硬件使用情况等。

  7. 路径扩展与目录处理: 根据参数配置,代码会自动将模型和向量化器的保存路径扩展到指定的保存目录下,并确保这些目录事先已创建。

  8. CUDA设置: 检查系统是否支持CUDA,并据此设置训练所用的设备(CPU或CUDA),同时应用随机种子设置以确保结果的可复现性。

    # 定义训练状态字典,包含训练过程中所需的各种状态变量
    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,  # 测试损失,初始值设为-1表示尚未计算
            'test_acc': -1,  # 测试准确率,初始值设为-1表示尚未计算
            'model_filename': args.model_state_file  # 模型状态文件的路径
        }
    
    # 更新训练状态,包括模型保存、早停检查等
    def update_train_state(args, model, 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_best_val'] = loss_t
    
                # 重置早停计数
                train_state['early_stopping_step'] = 0
    
            # 根据早停计数判断是否提前终止训练
            train_state['stop_early'] = train_state['early_stopping_step'] >= args.early_stopping_criteria
    
        return train_state
    
    # 计算预测准确率
    def compute_accuracy(y_pred, y_target):
        # 获取预测类别的索引
        _, 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):
        # 设置NumPy和PyTorch的随机种子
        np.random.seed(seed)
        torch.manual_seed(seed)
        # 如果使用CUDA,也设置CUDA的随机种子
        if cuda:
            torch.cuda.manual_seed_all(seed)
    
    # 确保目录存在
    def handle_dirs(dirpath):
        # 如果目录不存在,则创建它
        if not os.path.exists(dirpath):
            os.makedirs(dirpath)
    
    # 定义命名空间对象,存储各种参数
    args = Namespace(
        surname_csv="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是否可用
    if not torch.cuda.is_available():
        args.cuda = False
    
    # 设置设备为CUDA或CPU
    args.device = torch.device("cuda" if args.cuda else "cpu")
    
    print("Using CUDA: {}".format(args.cuda))
    # 设置随机种子
    set_seed_everywhere(args.seed, args.cuda)
    # 处理保存目录,确保存在
    handle_dirs(args.save_dir)

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

代码段实现了:

1. 数据集与向量化器的加载/创建 

2. 向量化器的获取

3. 模型初始化

4. 进度条引入:从`tqdm.notebook`导入`tqdm`模块,准备在Jupyter Notebook环境中使用进度条来可视化训练过程。

5. 模型与数据至指定设备的迁移:
   - 将分类器模型转移到用户指定的设备(CPU或GPU)上,以便执行加速计算。
   - 同时,也将数据集的类别权重转移至同一设备,确保模型训练过程中的运算可以在同一设备上完成,避免数据传输的开销。

6. 损失函数与优化器的配置:
   - 初始化损失函数为`nn.CrossEntropyLoss`,并使用从数据集中提取的类别权重来加权损失,这对于类别不平衡的数据集尤为重要。
   - 配置Adam优化器来更新模型参数,学习率由命令行参数`args.learning_rate`设定。

if args.reload_from_files:
    # 如果指定从文件重新加载,则从检查点重新加载模型和向量化器
    print("Reloading!")
    # 加载数据集和向量化器
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(
        args.surname_csv,  # 指定姓氏数据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)  # 输出维度设置为国籍词汇表的大小
)

# 从tqdm.notebook导入tqdm模块,用于在Jupyter Notebook中显示进度条
from tqdm.notebook import tqdm

# 将分类器模型移动到指定的设备上,如GPU或CPU
classifier = classifier.to(args.device)
# 将数据集的类别权重移动到指定的设备上
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)

训练循环

本训练循环部分旨在通过迭代多个训练周期(epochs)来优化`SurnameClassifier`模型,以实现对姓氏与国籍之间关系的有效预测。以下是关键步骤的概述:

1. 学习率调度器初始化:采用`ReduceLROnPlateau`调度器,根据验证集性能动态调整学习率,若验证损失停止下降,则学习率减半,以此促进模型持续学习。

2. 训练状态与进度条准备:初始化训练状态记录器,并创建三个进度条分别跟踪整个训练流程、训练阶段进度以及验证阶段进度,为用户提供直观的训练过程反馈。

3. 训练与验证循环:
   - 训练阶段:模型切换至训练模式,遍历训练数据批次,计算预测、损失,反向传播梯度以更新模型参数。同时,累积并更新平均损失与准确率,实时更新训练进度条。
   - 验证阶段:模型转为评估模式,不进行梯度计算,对验证集执行类似操作以评估模型性能。验证阶段的目的是在未见过的数据上测试模型泛化能力,据此调整学习率。

4. 训练状态更新与早停机制:每次epoch结束时,更新训练状态,记录训练与验证的损失和准确率。利用早停策略监控验证损失,若连续周期无明显改善,则提前终止训练以防止过拟合。

5. 异常处理:加入对`KeyboardInterrupt`的捕获,允许用户在训练过程中通过中断(Ctrl+C)优雅地退出循环。

综上所述,这段代码展示了模型训练的核心过程,结合了学习率动态调整、训练与验证的迭代、性能监控及早停策略,以高效且可控的方式驱动模型学习。

# 定义学习率调度器,当验证集的性能不再提升时降低学习率
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                                 mode='min', factor=0.5,
                                                patience=1)
# 初始化训练状态
train_state = make_train_state(args)
# 创建描述训练过程的进度条
epoch_bar = tqdm(desc='training routine',   
                 total=args.num_epochs,
                 position=0)
# 设置数据集分割为训练集,并创建进度条
dataset.set_split('train')
train_bar = tqdm(desc='split=train',
                 total=dataset.get_num_batches(args.batch_size), 
                 position=1, 
                 leave=True)
# 设置数据集分割为验证集,并创建进度条
dataset.set_split('val')
val_bar = tqdm(desc='split=val',
               total=dataset.get_num_batches(args.batch_size), 
               position=1, 
               leave=True)

try:
    # 开始训练循环
    for epoch_index in range(args.num_epochs):
        # 更新当前训练周期的索引
        train_state['epoch_index'] = epoch_index
         # 设置数据集分割为训练集,生成批次数据生成器
        dataset.set_split('train')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        # 初始化运行损失和准确率
        running_loss = 0.0
        running_acc = 0.0
        # 设置模型为训练模式
        classifier.train()
        # 遍历每个批次的数据
        for batch_index, batch_dict in enumerate(batch_generator):
              # 清零梯度
            optimizer.zero_grad()

         
            y_pred = classifier(batch_dict['x_surname'])

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

            loss.backward()

            optimizer.step()

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

            train_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)
            train_bar.update()
        # 记录训练损失和准确率
        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)
        # 设置数据集分割为验证集,生成批次数据生成器
        dataset.set_split('val')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        # 重置运行损失和准确率
        running_loss = 0.
        running_acc = 0.
        # 设置模型为评估模式
        classifier.eval()
        
        # 遍历每个批次的数据
        for batch_index, batch_dict in enumerate(batch_generator):


            y_pred =  classifier(batch_dict['x_surname'])

            loss = loss_func(y_pred, batch_dict['y_nationality'])
            loss_t = loss.to("cpu").item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

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

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

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

        scheduler.step(train_state['val_loss'][-1])
         # 如果满足早停条件,跳出循环
        if train_state['stop_early']:
            break
        # 重置进度条
        train_bar.n = 0
        val_bar.n = 0
        # 更新训练周期进度条
        epoch_bar.update()
except KeyboardInterrupt:
    # 如果用户中断训练,打印退出信息
    print("Exiting loop")

模型评估

评估`SurnameClassifier`在测试集上的表现沿用了标准文本分类任务的做法:配置模型为评估模式(`classifier.eval()`),以确保不对测试数据进行学习参数更新。模型在测试集上的准确率约为50%,这表明相对于训练数据的性能,模型在未见数据上的推广能力有限。训练数据上的高准确率反映的是过拟合现象,而非模型真正泛化能力的体现。实验通过调整向量化的隐藏维度可轻微提升性能,但因采用的one-hot稀疏编码忽略了字符序列信息,故提升效果不大,特别是与更复杂模型如CNN相比。

新姓氏的分类

为了预测新姓氏的国籍,示例展示了一个函数实现,该函数首先利用`vectorizer`将输入姓名转换成模型可处理的向量形式,接着通过`classifier`进行预测,并确保输出包含各类别的概率。预测过程通过查找概率最高的类别索引完成,并返回预测的国籍及其相应的概率值。

def predict_nationality(name, classifier, vectorizer):
    # 使用向量化器将名字转换为模型可以理解的向量形式
    vectorized_name = vectorizer.vectorize(name),
    # 将向量化的名字转换为 PyTorch 张量,并调整其形状以便输入到分类器中
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    result = classifier(vectorized_name, apply_softmax=True)

    probability_values, indices = result.max(dim=1)
    # 提取最大概率对应的索引值
    index = indices.item()

    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    probability_value = probability_values.item()

    return {'nationality': predicted_nationality,
            'probability': probability_value}

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

classifier = classifier.to("cpu")

prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印结果
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

Top-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)
    
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    probability_values, indices = torch.topk(prediction_vector, k=k)
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]
    # 创建一个空列表,用于存储前k个国籍的预测结果
    results = []
    # 遍历前k个国籍的概率值和索引
    for prob_value, index in zip(probability_values, indices):
        nationality = vectorizer.nationality_vocab.lookup_index(index)
        results.append({'nationality': nationality,
                        'probability': prob_value})
    # 返回包含前k个国籍预测结果的列表
    return results
# 用户输入待分类的姓氏
new_surname = input("Enter a surname to classify: ")
# 确保模型在 CPU 上运行
classifier = classifier.to("cpu")
# 用户输入想要查看的前k个预测结果的数量
k = int(input("How many of the top predictions to see? "))
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']))

结构正则化MLPs

正则化技术有助于减少模型对训练数据的过度拟合,提升其在未见数据上的泛化能力。本节主要讨论两种类型的正则化:权重正则化(如L1和L2正则化)和结构正则化,其中后者通过Dropout机制实现。

Dropout的工作原理

Dropout通过以一定概率临时“丢弃”神经网络中某些连接的方式来实现结构正则化。这种随机失活迫使网络在每次训练迭代中都使用不同的子网络进行学习,从而减少了单元间的过度依赖,增强了模型的鲁棒性。Stephen Merity的比喻形象地解释了这一点:就像人在酒醒后能更好地完成任务,因为他们在不清醒时学会了多种完成任务的方法。实践中,每个连接的丢弃概率(Drop Probability)通常设定为0.5,以平衡模型复杂度和泛化能力。

import torch.nn as nn
import torch.nn.functional as F
# 定义一个多层感知机(MLP)模型
class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
         # 调用基类的构造函数
        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):
        # 在输入数据上应用第一层全连接层,并使用 ReLU 激活函数
        intermediate = F.relu(self.fc1(x_in))

        output = self.fc2(F.dropout(intermediate, p=0.5))

        if apply_softmax:
            output = F.softmax(output, dim=1)
        # 返回模型的输出
        return output
    import torch.nn.functional as F
# 定义一个用于姓氏分类的卷积神经网络模型
class SurnameClassifier(nn.Module):
    def __init__(self, initial_num_channels, num_classes, num_channels, dropout_p=0.5):
        # 调用基类的构造函数
        super(SurnameClassifier, self).__init__()
        # 定义卷积神经网络的序列
        self.convnet = nn.Sequential(
            # 第一层卷积
            nn.Conv1d(in_channels=initial_num_channels, 
                      out_channels=num_channels, kernel_size=3),
            # ELU 激活函数
            nn.ELU(),
            # 第二层卷积
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),
            # ELU 激活函数
            nn.ELU(),
            # 第三层卷积
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),
             # ELU 激活函数
            nn.ELU(),
            # 第四层卷积
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3),
            nn.ELU()
        )
        self.dropout = nn.Dropout(p=dropout_p)
        # 全连接层
        self.fc = nn.Linear(num_channels, num_classes)

    def forward(self, x_surname, apply_softmax=False, training=False):
        # 通过卷积神经网络提取特征
        features = self.convnet(x_surname).squeeze(dim=2)
        if training:
            features = self.dropout(features)
         # 通过全连接层得到预测向量
        prediction_vector = self.fc(features)
        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)
         # 返回模型的输出
        return prediction_vector

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

四、卷积神经网络(CNN):图像与序列数据的处理专家

卷积神经网络(CNNs)是一种深度学习模型,专为处理具有网格结构的数据(如图像、序列数据)而设计,特别擅长捕捉数据中的空间和时间局部特征。相较于多层感知机(MLPs),CNN在处理如姓氏数据集这类包含变量长度的模式信息时更加高效,因为它能够识别并利用这些数据中的不变性和局部相关性,比如不同长度姓氏中的特定后缀或前缀。

CNN的概念源自传统的数学卷积运算,最初在信号处理和图像分析中有广泛应用。在深度学习领域,CNN通过学习卷积核参数,自动提取数据的特征,极大地推动了图像识别、语音识别、自然语言处理等多个领域的进步。

CNN的核心思想与优势:

CNN的关键在于其卷积层,该层使用一组称为“卷积核”或“过滤器”的可学习权重来扫描输入数据,寻找特定的特征或模式。这些卷积核通过与输入数据的局部区域相乘并求和,能够检测到图像中的边缘、纹理等低级特征,随着网络的加深,还能组合成更复杂的高级特征。与全连接网络相比,CNN通过参数共享和空间亚采样(下采样)大大降低了模型的复杂度,提高了训练效率,同时增强了模型的泛化能力。

结构示意图如下:

CNN的超参数与相关概念

1. 卷积核大小(Kernel Size)
决定了卷积核覆盖输入数据的区域大小,直接影响到模型能够检测的特征的范围和精细程度。较大的核尺寸能捕捉更宽泛的特征,但可能导致计算成本增加和感受野过广,而较小的核尺寸则相反。

2. 步幅(Stride)
控制着卷积核在输入数据上滑动的间隔,较大的步幅可以减少输出特征图的尺寸,实现下采样,但可能损失部分细节信息。

3. 填充(Padding)
通过在输入数据边缘添加零值,可以控制输出特征图的尺寸,保持与输入相同或减少尺寸缩小的程度,同时允许边缘特征的完整分析。

4. 膨胀(Dilation)
在卷积核中引入空洞,使得卷积核的元素在空间上分开,有效扩大了感受野,而不需要增加核大小,适用于捕获更广阔上下文信息,如在语义分割和序列建模中的应用。

5. 输入/输出通道数
输入通道反映了输入数据的维度,如图像的RGB通道;输出通道则表示卷积层产生的特征图数量,即模型学习到的特征种类。

6. 维度和通道

一维卷积:常用于处理时间序列数据,如自然语言处理中的词嵌入序列。
二维卷积:广泛应用于图像处理,捕捉像素空间中的模式。
三维卷积:则用于处理像视频这样的含有时间序列的三维数据,能够捕获时空特征。

卷积过程示意图:

CNN网络姓氏数据集定义

我们通过卷积神经网络对姓氏进行分类。这项任务虽然与之前多层感知器(MLP)示例中的目标相似,但在模型架构和数据向量化方面有着显著的不同。特别是,我们不再使用压缩的one-hot编码,而是采用one-hot矩阵来作为模型输入,这使得CNN能够捕捉到字符序列的排列信息,这是之前one-hot编码方案所缺失的。

 SurnameDataset的调整

为了适应这一新的向量化策略,我们对`SurnameDataset`类进行了修改,确保它能够处理基于最长姓氏长度构建的one-hot矩阵。这些调整包括在初始化时计算数据集中最长姓氏的长度,并在`__getitem__`方法中使用这个长度来生成相应大小的one-hot矩阵。这样做既确保了在批量处理时所有样本的尺寸一致性,又允许模型以一致的方式处理不同的批次数据。

代码概览:

- 修改后的SurnameDataset: 代码段展示了如何修改`SurnameDataset`类以支持动态生成基于最长姓氏长度的one-hot矩阵。这包括了如何在数据集中查找最长姓氏的长度,并据此尺寸向量化每一个姓氏样本。
- 数据集管理: 数据集被划分为训练集、验证集和测试集,并且每个部分都与一个特定的分割标签相关联,便于管理和加载。
- 类别权重: 引入了类别权重计算逻辑,用于应对类别不平衡问题,确保模型训练过程中对各类别给予适当的关注。
- 数据加载与批处理: 实现了数据加载器逻辑,利用`DataLoader`生成批次数据,支持数据的随机打乱、指定设备迁移等功能,提高了训练效率和灵活性。

class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        # 初始化数据集,包括加载数据和向量化器
        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)

        # 创建一个字典来快速访问不同数据集分割
        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):
            # 根据nationality_vocab的顺序对类别计数进行排序
            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):
        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_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):
        with open(vectorizer_filepath) as fp:
            # 反序列化json对象为向量化器
            return SurnameVectorizer.from_serializable(json.load(fp))

    # 保存向量化器到文件的方法
    def save_vectorizer(self, vectorizer_filepath):
        with open(vectorizer_filepath, "w") as fp:
            # 序列化向量化器并写入文件
            json.dump(self._vectorizer.to_serializable(), fp)

    # 获取向量化器的方法
    def get_vectorizer(self):
        return self._vectorizer

    # 设置当前数据集的分割类型,可以是训练集、验证集或测试集
    def set_split(self, split="train"):
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    # 返回当前数据集分割的大小
    def __len__(self):
        return self._target_size

    # 根据索引获取数据集中的一个样本,并进行向量化处理
    def __getitem__(self, index):
        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):
        return len(self) // batch_size

# 定义生成批次数据的函数
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    # 使用DataLoader来批量加载数据,并可选择是否打乱数据顺序
    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():
            # 将数据移动到指定的设备上,如GPU或CPU
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict  # 产生一个批次的数据字典

词汇表、词汇向量化与数据加载器

我们对文本数据的向量化处理进行了调整,以适应卷积神经网络(CNN)模型的需求。首先,我们定义了一个`Vocabulary`类,用于创建和管理词汇表,其中包括将文本标记映射到索引的功能。此外,`SurnameVectorizer`类负责将姓氏转换为onehot编码矩阵,这是为了满足`Conv1d`层对数据张量格式的要求。我们还增加了一个属性`max_surname_length`,用于存储姓氏的最大长度。

`SurnameVectorizer`类通过分析数据集中的姓氏,构建了两个词汇表:一个用于姓氏,一个用于国籍。它还提供了从数据框创建向量化器实例的方法,以及将向量化器序列化和反序列化的功能。

`SurnameDataset`类封装了PyTorch的`Dataset`类,用于管理姓氏数据集,并提供了加载数据集、设置数据集拆分、获取向量化器以及生成数据批次的方法。此外,它还计算了类别权重,以解决类别不平衡问题。

最后,`generate_batches`函数是一个生成器,它封装了PyTorch的`DataLoader`,确保数据张量被正确地移动到指定的设备上。这个函数可以根据指定的批量大小和是否打乱数据的选项,生成数据批次。

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  # 标记到索引的映射

        # 索引到标记的映射,基于token_to_idx创建
        self._idx_to_token = {idx: token for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk  # 是否添加UNK标记
        self._unk_token = unk_token  # 未知标记
        self.unk_index = -1  # 初始化未知标记的索引

        # 如果需要添加UNK标记,则添加到词汇表并更新索引
        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):
        """将单个标记添加到词汇表中,并返回对应的索引
        
        参数:
            token (str): 要添加的标记
            
        返回:
            int: 标记对应的索引
        """
        try:
            # 如果标记已存在,则返回其索引
            return 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):
        """根据标记查找索引,如果标记不存在则返回UNK索引
        
        参数:
            token (str): 要查找的标记
            
        返回:
            int: 标记对应的索引或UNK索引
        """
        # 如果添加了UNK标记,则使用get方法,如果标记不存在则返回UNK索引
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            # 如果没有添加UNK标记,则直接返回标记的索引
            return self._token_to_idx[token]

    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 "<词汇表(size=%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):
        """
        初始化向量化器。
        
        参数:
            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): 要向量化的姓氏
            
        返回:
            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)
            one_hot_matrix[character_index][position_index] = 1
        
        return one_hot_matrix

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据框创建向量化器实例
        
        参数:
            surname_df (pandas.DataFrame): 包含姓氏数据的数据框
            
        返回:
            SurnameVectorizer的实例
        """
        surname_vocab = Vocabulary(unk_token="@")  # 创建姓氏词汇表,使用"@"作为UNK标记
        nationality_vocab = Vocabulary(add_unk=False)  # 创建国籍词汇表,不添加UNK标记
        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)

        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):
        """
        初始化姓氏数据集。
        
        参数:
            surname_df (pandas.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)

        # 存储不同数据集拆分的字典
        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):
        """加载数据集并从头开始创建新的向量化器
        
        Args:
            surname_csv (str): 数据集的位置
        Returns:
            SurnameDataset的实例
        """
        surname_df = pd.read_csv(surname_csv)  # 从CSV文件加载数据集
        train_surname_df = surname_df[surname_df.split=='train']  # 获取训练集DataFrame
        return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))  # 实例化SurnameDataset对象

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
        """加载数据集和相应的向量化器。
        在向量化器已经缓存以便重用的情况下使用
        
        Args:
            surname_csv (str): 数据集的位置
            vectorizer_filepath (str): 保存的向量化器的位置
        Returns:
            SurnameDataset的实例
        """
        surname_df = pd.read_csv(surname_csv)  # 从CSV文件加载数据集
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)  # 加载向量化器
        return cls(surname_df, vectorizer)  # 实例化SurnameDataset对象

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """从文件加载向量化器的静态方法
        
        Args:
            vectorizer_filepath (str): 序列化向量化器的位置
        Returns:
            SurnameDataset的实例
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))  # 从文件加载向量化器

    def save_vectorizer(self, vectorizer_filepath):
        """使用json保存向量化器到磁盘
        
        Args:
            vectorizer_filepath (str): 保存向量化器的位置
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)  # 将向量化器序列化为JSON并保存到文件中

    def get_vectorizer(self):
        """返回向量化器"""
        return self._vectorizer  # 返回向量化器

    def set_split(self, split="train"):
        """使用数据框中的列选择数据集的拆分"""
        self._target_split = split  # 设置目标拆分
        self._target_df, self._target_size = self._lookup_dict[split]  # 获取目标拆分的DataFrame和大小

    def __len__(self):
        return self._target_size  # 返回数据集大小

    def __getitem__(self, index):
        """PyTorch数据集的主要入口方法
        
        Args:
            index (int): 数据点的索引 
        Returns:
            一个字典,包含数据点的特征(x_data)和标签(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):
        """给定批量大小,返回数据集中的批次数
        
        Args:
            batch_size (int)
        Returns:
            数据集中的批次数
        """
        return len(self) // batch_size  # 返回数据集中的批次数

    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"):
    """
    一个生成器函数,它封装了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] = data_dict[name].to(device)  # 将张量移到指定设备
        yield out_data_dict  # 生成数据字典

CNN分类器定义:

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

class SurnameClassifier(nn.Module):
    def __init__(self, initial_num_channels, num_classes, num_channels):
        """
        初始化姓氏分类器。

        参数:
        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(),  # Exponential Linear Unit 激活函数
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),# 第二层卷积,步长为2
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),# 第三层卷积,步长为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 (Tensor): 输入的姓氏数据。
        apply_softmax (bool): 是否应用softmax函数。

        返回:
        prediction_vector (Tensor): 预测向量。
        """
        
        # 通过卷积网络提取特征,然后沿第2维挤压以移除多余的维度   
        features = self.convnet(x_surname).squeeze(dim=2)  
        # 特征通过全连接层得到预测向量
        prediction_vector = self.fc(features)  

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

        return prediction_vector  

模型训练

def make_train_state(args):
    """
    创建训练状态字典,用于跟踪训练过程中的重要变量。
    
    参数:
        args: 参数对象,包含模型训练相关的参数。
        
    返回:
        train_state (dict): 训练状态字典,包含以下键值对:
            'stop_early': 一个布尔值,指示是否提前终止训练。
            'early_stopping_step': 早停法的当前步数。
            'early_stopping_best_val': 迄今为止最佳的验证损失值。
            'learning_rate': 学习率。
            'epoch_index': 当前的epoch索引。
            'train_loss': 训练损失的历史列表。
            'train_acc': 训练准确率的历史列表。
            'val_loss': 验证损失的历史列表。
            'val_acc': 验证准确率的历史列表。
            'test_loss': 测试损失,初始值为-1。
            'test_acc': 测试准确率,初始值为-1。
            'model_filename': 模型状态文件的路径。
    """
    return {
        'stop_early': False,  # 是否提前终止训练
        'early_stopping_step': 0,  # 早停计数器
        'early_stopping_best_val': 1e8,  # 最佳验证损失
        'learning_rate': args.learning_rate,  # 学习率
        'epoch_index': 0,  # 当前epoch索引
        '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 (dict): 更新后的训练状态字典。
    """
    if train_state['epoch_index'] == 0:
        # 如果是第一个epoch,则保存模型并设置stop_early为False
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

    elif train_state['epoch_index'] >= 1:
        # 如果不是第一个epoch,根据验证损失更新早停计数器和模型保存
        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 = y_pred.max(dim=1)[1]
    # 计算正确预测的数量
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    # 计算准确率并返回
    accuracy = n_correct / len(y_pred_indices) * 100
    return accuracy

# 创建实验所需的文件路径和参数
args = Namespace(
    surname_csv="surnames_with_splits.csv",  # 姓氏数据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=10,  # 总epoch数
    early_stopping_criteria=5,  # 早停法标准
    dropout_p=0.1,  # dropout概率
    cuda=False,  # 是否使用CUDA
    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))
    
# 检查CUDA是否可用
if not torch.cuda.is_available():
    args.cuda = False

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

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

# 创建保存目录
handle_dirs(args.save_dir)

# 根据参数决定是重新加载数据集和向量化器还是从头开始创建
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()

# 创建分类器,这里假设SurnameClassifier是已经定义好的一个类
classifier = SurnameClassifier(initial_num_channels=len(vectorizer.surname_vocab), 
                               num_classes=len(vectorizer.nationality_vocab),
                               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)
# 定义学习率调度器,根据验证损失降低学习率
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                                 mode='min', factor=0.5,
                                                 patience=1)

# 创建训练状态
train_state = make_train_state(args)

模型预测

def predict_nationality(surname, classifier, vectorizer):
    """
    预测给定姓氏的国籍。

    参数:
    surname (str): 待分类的姓氏。
    classifier (nn.Module): 已训练的分类器模型。
    vectorizer (Vectorizer): 用于向量化数据的矢量化器对象。

    返回:
    dict: 包含预测的国籍和对应的概率值。
    """

    # 向量化输入的姓氏
    vectorized_surname = vectorizer.vectorize(surname)
    # 将向量化的姓氏转换为PyTorch张量,并增加一个维度以适应模型的输入要求
    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']))

五、结果与讨论

训练过程性能变化趋势分析:

初期收敛速度:比较MLP(多层感知器)和CNN(卷积神经网络)在训练初期的学习曲线,观察两者对于训练数据的适应速度。说明哪个模型更快达到较低的损失值或更高的准确率,以及这可能反映出的模型复杂度和学习效率差异。

损失与准确率随迭代次数的变化:详细描绘训练和验证集上的损失(loss)及准确率(accuracy)随时间或迭代次数的变化趋势。分析是否存在过早饱和(学习停滞)、波动剧烈或持续下降等现象,以及这些现象对于模型优化的意义。

学习率调整的影响:探讨不同学习率策略(如固定学习率、学习率衰减)对MLP和CNN训练过程的影响,及其对模型最终性能的贡献。

特定任务效果对比:

分类准确率:直接对比MLP与CNN在测试集上的分类准确率,以及在不同类别上的表现,分析哪种架构更适合当前任务特征(如序列信息的重要性)。

召回率与精确率:除了总体准确率外,深入分析每种模型的召回率(recall)和精确率(precision),特别是对于关注的少数类别,评估模型的类别平衡表现。

F1分数:综合考虑精确率和召回率,通过F1分数来全面评估模型在各类别上的性能,进一步明确哪种模型在综合性能上更优。

模型泛化能力与过拟合讨论:

过拟合迹象:识别和分析模型在训练集和验证集上性能差距增大的情况,这通常是过拟合的表现。讨论过拟合的迹象,如验证损失开始上升而训练损失继续下降。

正则化技术的效果:探讨在MLP和CNN中应用的不同正则化技术(如L1/L2正则化、Dropout、早停法等)对防止过拟合的有效性,以及这些技术如何影响模型的泛化能力。

数据增强与模型简化:分析数据增强(对CNN尤为重要)和模型简化(如减少神经网络层数或节点数)在提升模型泛化能力方面的效果,对比这两种策略在MLP和CNN上的应用差异。

泛化能力结论:基于以上分析,总结哪种模型在特定任务上展现出更好的泛化能力,以及背后的可能原因,比如CNN利用局部特征提取和权重共享机制较MLP更适合处理具有空间或序列结构的数据。

综上所述,通过对比MLP和CNN在训练过程中的性能变化、特定任务效果以及它们的泛化能力和对过拟合的抵抗能力,本节旨在提供一个全面的评估,指导未来在类似任务中选择最合适的模型架构和优化策略。

六、结论与未来展望

 MLP与CNN的关键贡献总结

在非线性问题的解决中,多层感知器(MLP)与卷积神经网络(CNN)展现出了显著的效能与灵活性。MLP作为一种全连接的神经网络,通过多个隐藏层能够学习到输入数据的复杂非线性关系,尤其适用于那些没有明显空间结构特征的问题。其在诸如函数逼近、分类任务中的应用广泛证明了其强大的表达能力。

相比之下,CNN凭借其独特的卷积层和池化层设计,在处理具有局部相关性和平移不变性的数据时表现卓越,如图像识别、自然语言处理等领域。CNN通过学习输入数据的局部特征,并逐渐构建更为抽象的表示,有效减少了参数量,提高了模型的泛化能力。

参考文献:
- [1] Rumelhart, D.E., Hinton, G.E., & Williams, R.J. (1986). Learning representations by back-propagating errors. Nature, 323(6088), 533-536.
- [2] Krizhevsky, A., Sutskever, I., & Hinton, G.E. (2012). ImageNet Classification with Deep Convolutional Neural Networks. In *Advances in Neural Information Processing Systems (pp. 1097-1105).

最新趋势与挑战:

随着深度学习技术的不断进步,当前的研究趋势聚焦于以下几个方面:
- 可解释性增强:提高模型决策过程的透明度,使得模型的预测结果更容易被理解和信任。
- 轻量化与效率:开发更轻量级的模型,减少计算资源消耗,同时保持或接近原有模型的性能水平。
- 自监督学习与无监督学习:在缺乏标注数据的情况下,利用自监督学习策略进行有效预训练,成为推动深度学习发展的重要方向。
- 跨模态学习:整合多种类型的数据(如图像、文本、声音),促进模型在多模态环境下的理解与推理能力。
- 对抗性攻击与防御:研究如何使模型更加鲁棒,以抵御对抗性样本的攻击,确保模型在实际应用中的安全性。

  • 53
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值