基于MLP和CNN实现姓氏分类

基于MLP和CNN实现姓氏分类

一、基于多层感知机(MLP)实现姓氏分类

什么是多层感知机

img

多层感知机(Multilayer Perceptron,MLP)是一种人工神经网络(Artificial Neural Network,ANN)的基本类型,通常由多个全连接的神经元层组成,每个神经元层与下一层完全连接,神经元层之间没有同层连接。MLP 是一种前向反馈神经网络,意味着数据在网络中从输入层流向输出层,不会形成环路或循环连接。

MLP 通常由三种类型的神经元层组成:

  1. 输入层(Input Layer):接收并传输原始输入特征。
  2. 隐藏层(Hidden Layers):每个隐藏层接收前一层的输出,并通过加权和激活函数生成输出,提取和学习数据的特征。
  3. 输出层(Output Layer):最终生成网络的输出,可以是分类标签、数值预测或者其他形式的结果。

MLP 通过反向传播算法(Backpropagation)进行训练,优化网络参数以最小化预测输出与实际标签之间的误差。它被广泛应用于分类和回归问题,可以处理非线性关系并适应各种类型的数据。

前向传播

这里写图片描述

前向传播(Forward Propagation)是神经网络中的一个过程,用于计算从输入数据到输出预测的过程。它描述了数据如何通过神经网络中的各层进行传递和转换,直到产生最终的输出。

在一个典型的前向传播过程中,数据从输入层开始,经过一系列的隐藏层(如果有的话),最终到达输出层。每个神经元层接收来自上一层的输入,并应用权重和偏置,然后通过激活函数(Activation Function)生成输出,这个输出成为下一层的输入。这个过程一直持续到输出层,输出层产生网络的最终预测结果或输出。

具体步骤如下:

  1. 输入数据传递:将原始数据输入神经网络的输入层。
  2. 加权和计算:每个神经元将其输入与相应的权重相乘,并加上偏置项。
  3. 激活函数应用:对加权和的结果应用激活函数,以产生每个神经元的输出。
  4. 传递到下一层:将每个神经元的输出作为下一层神经元的输入,依次进行,直到达到输出层。

在训练过程中,前向传播用于计算神经网络的预测值,然后与实际值进行比较以计算误差。误差通过反向传播算法(Backpropagation)传递回网络,以调整权重和偏置,优化网络模型,从而使预测结果更接近实际目标。

反向传播

反向传播示意图

反向传播(Backpropagation)是神经网络中用于训练和优化模型参数的核心算法。它通过计算损失函数关于每个参数的梯度,并沿着梯度的反方向调整参数,以最小化预测输出与实际目标之间的差距。

具体来说,反向传播包括以下步骤:

  1. 前向传播

    从输入数据开始,通过神经网络的各层计算并生成输出。

    在前向传播过程中,记录每层神经元的输入和输出,以便后续计算梯度。

  2. 损失函数计算

    使用损失函数(例如均方误差、交叉熵等)比较神经网络的输出与真实标签之间的差距,得到损失值。

  3. 反向传播误差

    从输出层开始,计算损失函数对每个参数的梯度。

    根据链式法则(Chain Rule),将梯度从输出层向后传播到隐藏层和输入层。

  4. 参数更新

    使用梯度下降或其变种的优化算法,如随机梯度下降(SGD)、Adam 等,更新每个参数以减小损失函数。

反向传播是深度学习中的基础技术,使得神经网络能够通过大量数据进行训练,并学习复杂的非线性关系,适用于各种任务,如分类、回归和生成模型等。

多层感知机的训练学习过程

多层感知机(MLP)的训练学习过程涉及到前向传播和反向传播两个主要步骤,这些步骤在神经网络中被重复执行以优化模型参数。以下是多层感知机训练的基本步骤:

  1. 初始化网络参数

    首先,需要初始化神经网络的权重和偏置。通常,权重可以初始化为随机小数值,偏置初始化为零或小的随机数。

  2. 前向传播

    输入数据通过输入层进入神经网络。

    在每一层,计算每个神经元的加权和,然后应用激活函数产生输出。

    将每层的输出作为下一层的输入,直到到达输出层,生成网络的预测输出。

  3. 计算损失函数

    使用损失函数(如均方误差、交叉熵等)比较神经网络的预测输出与真实标签之间的差距。这个差距越小,表示网络预测的效果越好。

  4. 反向传播

    根据损失函数,计算每个参数(权重和偏置)的梯度。这通过链式法则来实现,从输出层向后传播到每一层隐藏层和输入层。

    更新每个参数以减小损失函数。通常使用梯度下降法或其变种(如随机梯度下降、Adam等)来更新参数。更新参数的大小由学习率(learning rate)控制。

  5. 重复迭代

    重复执行前向传播和反向传播步骤,直到损失函数收敛或达到预设的训练轮次。

    训练过程中通常会将数据集分批(batch)输入,每个批次完成一次前向传播和反向传播称为一个训练步骤(epoch)。

  6. 模型评估

    使用验证集或测试集评估训练后的模型性能。这可以通过计算准确率、精确度、召回率等指标来完成。

  7. 模型应用

    当模型经过训练和验证后,可以用来进行预测新数据的输出或分类。

整个训练过程的关键是通过反向传播调整网络参数,使得神经网络能够从数据中学习并逐渐优化其预测能力。

导入相关的库

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

import numpy as np
import pandas as pd

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

定义数据向量化的类

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

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        Args:
            token_to_idx (dict): a pre-existing map of tokens to indices
            add_unk (bool): a flag that indicates whether to add the UNK token
            unk_token (str): the UNK token to add into the Vocabulary
        """
        if token_to_idx is None:
            token_to_idx = {}  # 初始化 token_to_idx 为一个空字典
        self._token_to_idx = token_to_idx  # 存储 token 到索引的映射关系

        self._idx_to_token = {idx: token 
                              for token, idx in self._token_to_idx.items()}  # 创建索引到 token 的映射
        
        self._add_unk = add_unk  # 是否添加 UNK token 的标志
        self._unk_token = unk_token  # UNK token 的值
        
        self.unk_index = -1  # UNK token 的索引,初始化为 -1
        if add_unk:
            self.unk_index = self.add_token(unk_token)  # 如果添加 UNK token,则将其添加到词汇表中
        
    def to_serializable(self):
        """Returns a dictionary that can be serialized."""
        return {'token_to_idx': self._token_to_idx, 
                'add_unk': self._add_unk, 
                'unk_token': self._unk_token}

    @classmethod
    def from_serializable(cls, contents):
        """Instantiates the Vocabulary from a serialized dictionary."""
        return cls(**contents)

    def add_token(self, token):
        """Update mapping dicts based on the token.

        Args:
            token (str): the item to add into the Vocabulary
        Returns:
            index (int): the integer corresponding to the token
        """
        try:
            index = self._token_to_idx[token]  # 获取 token 对应的索引
        except KeyError:
            index = len(self._token_to_idx)  # 如果 token 不在词汇表中,则分配一个新的索引
            self._token_to_idx[token] = index  # 将 token 添加到词汇表
            self._idx_to_token[index] = token  # 更新索引到 token 的映射
        return index
    
    def add_many(self, tokens):
        """Add a list of tokens into the Vocabulary.
        
        Args:
            tokens (list): a list of string tokens
        Returns:
            indices (list): a list of indices corresponding to the tokens
        """
        return [self.add_token(token) for token in tokens]  # 添加多个 token,并返回它们的索引列表

    def lookup_token(self, token):
        """Retrieve the index associated with the token 
          or the UNK index if token isn't present.
        
        Args:
            token (str): the token to look up 
        Returns:
            index (int): the index corresponding to the token
        Notes:
            `unk_index` needs to be >=0 (having been added into the Vocabulary) 
              for the UNK functionality 
        """
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)  # 返回 token 的索引,如果不存在则返回 UNK 索引
        else:
            return self._token_to_idx[token]  # 如果没有 UNK token,则直接返回 token 的索引

    def lookup_index(self, index):
        """Return the token associated with the index.
        
        Args: 
            index (int): the index to look up
        Returns:
            token (str): the token corresponding to the index
        Raises:
            KeyError: if the index is not in the Vocabulary
        """
        if index not in self._idx_to_token:
            raise KeyError("the index (%d) is not in the Vocabulary" % index)  # 如果索引不在词汇表中,抛出 KeyError
        return self._idx_to_token[index]  # 返回索引对应的 token

    def __str__(self):
        return "<Vocabulary(size=%d)>" % len(self)  # 返回词汇表的大小

    def __len__(self):
        return len(self._token_to_idx)  # 返回词汇表中 token 的数量

向量化器
class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""
    def __init__(self, surname_vocab, nationality_vocab):
        """
        Args:
            surname_vocab (Vocabulary): maps characters to integers
            nationality_vocab (Vocabulary): maps nationalities to integers
        """
        self.surname_vocab = surname_vocab# 存储姓氏词汇表
        self.nationality_vocab = nationality_vocab# 存储国籍词汇表

    def vectorize(self, surname):
        """
        Args:
            surname (str): the surname

        Returns:
            one_hot (np.ndarray): a collapsed one-hot encoding 
        """
        vocab = self.surname_vocab# 获取姓氏词汇表
        one_hot = np.zeros(len(vocab), dtype=np.float32)# 初始化一个零数组作为 one-hot 编码
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1# 将对应的索引位置置为 1

        return one_hot# 返回 one-hot 编码

    @classmethod
    def from_dataframe(cls, surname_df):
        """Instantiate the vectorizer from the dataset dataframe
        
        Args:
            surname_df (pandas.DataFrame): the surnames dataset
        Returns:
            an instance of the SurnameVectorizer
        """
        surname_vocab = Vocabulary(unk_token="@")# 创建姓氏词汇表,并使用 "@" 作为 UNK token
        nationality_vocab = Vocabulary(add_unk=False)# 创建国籍词汇表,不使用 UNK token

        for index, row in surname_df.iterrows():
            for letter in row.surname:
                surname_vocab.add_token(letter)# 将姓氏中的字母添加到词汇表
            nationality_vocab.add_token(row.nationality)# 将国籍添加到词汇表

        return cls(surname_vocab, nationality_vocab)# 返回 SurnameVectorizer 的实例

    @classmethod
    def from_serializable(cls, contents):
        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)# 返回 SurnameVectorizer 的实例

    def to_serializable(self):
        return {'surname_vocab': self.surname_vocab.to_serializable(),# 序列化姓氏词汇表
                'nationality_vocab': self.nationality_vocab.to_serializable()}# 序列化国籍词汇表
数据集
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))  # 返回 SurnameDataset 实例

    @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)  # 返回 SurnameDataset 实例

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of SurnameVectorizer
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))  # 从文件中加载序列化的向量化器

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)  # 将向量化器保存到文件

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

    def set_split(self, split="train"):
        """selects the splits in the dataset using a column in the dataframe"""
        self._target_split = split  # 设置目标数据集
        self._target_df, self._target_size = self._lookup_dict[split]  # 获取目标数据集的数据和大小

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

    def __getitem__(self, index):
        """the primary entry point method for PyTorch datasets
        
        Args:
            index (int): the index to the data point 
        Returns:
            a dictionary holding the data point's:
                features (x_surname)
                label (y_nationality)
        """
        row = self._target_df.iloc[index]  # 获取目标数据集中的一行

        surname_vector = self._vectorizer.vectorize(row.surname)  # 向量化姓氏

        nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)  # 获取国籍的索引

        return {'x_surname': surname_vector,
                'y_nationality': nationality_index}  # 返回包含特征和标签的字典

    def get_num_batches(self, batch_size):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size  # 计算数据集中批次数量

    
def generate_batches(dataset, batch_size, shuffle=True, drop_last=True, device="cpu"): 
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)  # 创建数据加载器

    for data_dict in dataloader:
        out_data_dict = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)  # 将每个张量移动到目标设备
        yield out_data_dict  # 生成包含批次数据的字典

姓氏分类模型

class 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)# 第一层线性层,输入维度为 input_dim,输出维度为 hidden_dim
        self.fc2 = nn.Linear(hidden_dim, output_dim)# 第二层线性层,输入维度为 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)) # 应用第一层并经过ReLU激活函数
        prediction_vector = self.fc2(intermediate_vector)# 应用第二层

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

        return prediction_vector# 返回预测向量

训练与模型评估

辅助函数定义
def make_train_state(args):
    return {'stop_early': False,  # 标志是否提早停止训练
            'early_stopping_step': 0,  # 提早停止计数器
            'early_stopping_best_val': 1e8,  # 最佳验证损失
            'learning_rate': args.learning_rate,  # 学习率
            'epoch_index': 0,  # 纪元索引
            'train_loss': [],  # 训练损失列表
            'train_acc': [],  # 训练准确率列表
            'val_loss': [],  # 验证损失列表
            'val_acc': [],  # 验证准确率列表
            'test_loss': -1,  # 测试损失
            'test_acc': -1,  # 测试准确率
            'model_filename': args.model_state_file}  # 模型文件名

def update_train_state(args, model, train_state):
    """Handle the training state updates.

    Components:
     - Early Stopping: Prevent overfitting.
     - Model Checkpoint: Model is saved if the model is better

    :param args: main arguments
    :param model: model to train
    :param train_state: a dictionary representing the training state values
    :returns:
        a new train_state
    """

    # Save one model at least
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])  # 保存模型状态字典
        train_state['stop_early'] = False# 不提早停止

    # Save model if performance improved
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:] # 获取前一个纪元和当前纪元的验证损失

        # If loss worsened
        if loss_t >= train_state['early_stopping_best_val']:
            # Update step
            train_state['early_stopping_step'] += 1# 增加提早停止计数器
        # Loss decreased
        else:
            # Save the best model
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])# 保存最佳模型

            # Reset early stopping step
            train_state['early_stopping_step'] = 0# 重置提早停止计数器

        # Stop early ?
        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):
    np.random.seed(seed)  # 设置numpy的随机种子
    torch.manual_seed(seed)  # 设置torch的随机种子
    if cuda:
        torch.cuda.manual_seed_all(seed)  # 如果使用cuda,设置cuda的随机种子

def handle_dirs(dirpath):
    if not os.path.exists(dirpath):  # 如果目录不存在
        os.makedirs(dirpath)  # 创建目录
迭代训练
# 定义命名空间 Namespace,包含了各种参数和路径信息
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,  # 训练的总epoch数
    early_stopping_criteria=5,  # 提前停止的标准
    learning_rate=0.001,  # 学习率
    batch_size=64,  # 批量大小

    # 运行时选项
    cuda=False,  # 是否使用CUDA加速
    reload_from_files=False,  # 是否从文件中重新加载模型和向量化器
    expand_filepaths_to_save_dir=True,  # 是否将文件路径扩展到保存目录下
)

# 如果 expand_filepaths_to_save_dir 为 True,则扩展文件路径到保存目录下
if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)
    args.model_state_file = os.path.join(args.save_dir, args.model_state_file)
    
    print("Expanded filepaths: ")
    print("\t{}".format(args.vectorizer_file))  # 打印扩展后的向量化器文件路径
    print("\t{}".format(args.model_state_file))  # 打印扩展后的模型状态文件路径

# 检查是否有CUDA可用
if not torch.cuda.is_available():
    args.cuda = False

# 根据是否使用CUDA设置设备
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)

Expanded filepaths: 
	model_storage/ch4/surname_mlp/vectorizer.json
	model_storage/ch4/surname_mlp/model.pth
Using CUDA: False
# 如果 args.reload_from_files 为 True,则从文件中重新加载数据集和向量化器
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()

# 创建姓氏分类器,输入维度为姓氏词汇表的长度,隐藏层维度为 args.hidden_dim,输出维度为国籍词汇表的长度
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab), 
                               hidden_dim=args.hidden_dim, 
                               output_dim=len(vectorizer.nationality_vocab))


Creating fresh!
# 将分类器移动到指定设备上(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)

# 定义学习率调度器,当验证集损失不再减小时,减少学习率
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                                 mode='min', factor=0.5,
                                                 patience=1)

# 创建训练状态字典,用于跟踪训练过程中的指标
train_state = make_train_state(args)

# 初始化 tqdm 进度条,用于显示训练过程中的进度
epoch_bar = tqdm_notebook(desc='training routine', 
                          total=args.num_epochs,
                          position=0)

# 设置数据集的训练集和验证集分割,并初始化对应的 tqdm 进度条
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

        # 设置训练模式
        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()

        # 保存每个epoch的训练损失和准确率
        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # 设置验证模式
        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()

        # 保存每个epoch的验证损失和准确率
        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")


/opt/conda/lib/python3.6/site-packages/ipykernel_launcher.py:15: TqdmDeprecationWarning: This function will be removed in tqdm==5.0.0
Please use `tqdm.notebook.tqdm` instead of `tqdm.tqdm_notebook`
  from ipykernel import kernelapp as app
# 加载训练过程中保存的最佳模型状态字典
classifier.load_state_dict(torch.load(train_state['model_filename']))

# 将模型移动到指定的设备(GPU或CPU)
classifier = classifier.to(args.device)

# 将数据集的类权重也移动到指定的设备
dataset.class_weights = dataset.class_weights.to(args.device)

# 定义损失函数为交叉熵损失,传入类权重
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 设置数据集的当前分割为测试集
dataset.set_split('test')

# 生成测试集的批处理数据生成器
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)

# 初始化运行损失和准确率
running_loss = 0.
running_acc = 0.

# 将模型设置为评估模式
classifier.eval()

# 遍历测试集的每个批次数据
for batch_index, batch_dict in enumerate(batch_generator):
    # 计算模型的输出
    y_pred = classifier(batch_dict['x_surname'])
    
    # 计算当前批次的损失
    loss = loss_func(y_pred, batch_dict['y_nationality'])
    loss_t = loss.item()
    running_loss += (loss_t - running_loss) / (batch_index + 1)

    # 计算当前批次的准确率
    acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
    running_acc += (acc_t - running_acc) / (batch_index + 1)

# 将测试集的损失和准确率保存到训练状态字典中
train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc
print("Test loss: {};".format(train_state['test_loss']))
print("Test Accuracy: {}".format(train_state['test_acc']))
Test loss: 1.819154896736145;
Test Accuracy: 46.68749999999999

模型预测

def predict_nationality(surname, classifier, vectorizer):
    """Predict the nationality from a new surname
    
    Args:
        surname (str): 待分类的姓氏
        classifier (SurnameClassifer): 已经训练好的分类器实例
        vectorizer (SurnameVectorizer): 对应的向量化器
    Returns:
        dict: 包含最可能的国籍及其概率的字典
    """
    # 使用向量化器将姓氏转换为向量表示,并转换为 PyTorch 张量
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)
    
    # 使用分类器预测姓氏的国籍概率分布,apply_softmax=True 表示在预测结果上应用 softmax 函数
    result = classifier(vectorized_surname, apply_softmax=True)

    # 获取预测结果中概率最高的国籍及其索引
    probability_values, indices = result.max(dim=1)
    index = indices.item()

    # 根据向量化器中的国籍词汇表,查找预测国籍的名称
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    probability_value = probability_values.item()

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


# 提示用户输入要分类的姓氏
new_surname = input("Enter a surname to classify: ")

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

# 使用定义好的函数预测输入姓氏的国籍及其概率
prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印预测结果:输入姓氏 -> 预测的国籍 (概率)
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))
Enter a surname to classify:  Rahal
Rahal -> Irish (p=0.48)

top-k预测

vectorizer.nationality_vocab.lookup_index(8)
'Irish'
def predict_topk_nationality(name, classifier, vectorizer, k=5):
    # 使用向量化器将输入的姓名转换为向量表示,并转换为 PyTorch 张量
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    
    # 使用分类器预测姓名的国籍概率分布,apply_softmax=True 表示在预测结果上应用 softmax 函数
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    
    # 获取概率最高的前 k 个国籍及其概率
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # 将张量转换为 numpy 数组,并且因为结果是 1 维数组,所以取第一个元素
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]
    
    # 初始化结果列表
    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})
    
    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? "))

# 如果用户要求的前 k 个预测大于国籍词汇表的总数,则设置 k 为国籍词汇表的总数
if k > len(vectorizer.nationality_vocab):
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
    k = len(vectorizer.nationality_vocab)
    
# 使用定义好的函数预测输入姓氏的前 k 个可能的国籍及其概率
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

# 打印预测结果标题
print("Top {} predictions:".format(k))
print("===================")

# 遍历每个预测结果,并将其格式化输出
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))
Enter a surname to classify:  Rahal
How many of the top predictions to see?  5
Top 5 predictions:
===================
Rahal -> Irish (p=0.48)
Rahal -> Arabic (p=0.13)
Rahal -> German (p=0.08)
Rahal -> English (p=0.06)
Rahal -> Dutch (p=0.06)

二、基于卷积神经网络(CNN)实现姓氏分类

什么是卷积神经网络

在这里插入图片描述

卷积神经网络(Convolutional Neural Network,CNN)是一种专门用于处理具有网格结构数据(如图像和视频)的深度学习模型。它在计算机视觉任务(如图像分类、物体检测、语义分割等)中表现优异。CNN 的核心思想是通过局部连接和共享权重的方式来提取输入数据的局部特征,逐层提取更高层次的特征。

卷积神经网络的基本结构

卷积层(convolutional layer)

img

卷积层是 CNN 的核心层,负责提取输入数据的特征。

卷积操作使用多个滤波器(卷积核)在输入数据上滑动(卷积),每个滤波器在输入的局部区域上进行加权求和,生成一个特征图。

卷积层的输出是多个特征图,每个特征图对应一个滤波器。

池化层(pooling layer)

img

  • 池化层用于降低特征图的维度,同时保留重要特征,减小计算复杂度,防止过拟合。
  • 常用的池化操作包括最大池化(Max Pooling)和平均池化(Average Pooling),通过在特征图上滑动窗口取最大值或平均值。
全连接层(fully-connected layer)

在这里插入图片描述

全连接层类似于多层感知机(MLP),每个神经元与前一层的所有神经元相连。

全连接层通常用于卷积层和池化层之后,将提取的高层次特征映射到最终的输出类别。

CNN 的三个核心特点

局部感受野(Local Receptive Field)

在传统的全连接神经网络中,每个神经元与上一层的所有神经元相连接,而在 CNN 中,卷积层的每个神经元只连接到输入数据的局部区域。这种局部连接方式称为局部感受野。

局部感受野使得神经元能够捕捉局部特征,如边缘、角点等,从而大大减少了参数的数量和计算复杂度。

权重共享(Weight Sharing)

在卷积层中,同一个卷积核(滤波器)在整个输入图像上滑动,并应用相同的权重进行卷积运算。这个过程称为权重共享。

权重共享减少了模型参数的数量,因为一个滤波器在整个图像上使用相同的参数。这样不仅降低了计算和存储的要求,还提高了模型的泛化能力,因为共享的权重使模型在图像的不同位置上检测相同的特征。

池化(Pooling)

池化层用于下采样特征图,从而降低特征图的尺寸并减少计算量。常见的池化方法包括最大池化(Max Pooling)和平均池化(Average Pooling)。

最大池化取池化窗口中的最大值,平均池化取窗口中的平均值。池化操作有助于减少特征图的尺寸,保留重要特征,同时减小模型的计算复杂度和防止过拟合。

导入相关的库

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

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm_notebook

向量化数据

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

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

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

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

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

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

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

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

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

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

class SurnameVectorizer(object):
    """向量化器,协调词汇表并将其用于操作"""

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

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

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

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据框实例化向量化器
        
        Args:
            surname_df (pandas.DataFrame): 姓氏数据集
        Returns:
            SurnameVectorizer 的一个实例
        """
        # 创建姓氏和国籍的词汇表,并确定最长姓氏长度
        surname_vocab = Vocabulary(unk_token="@")
        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)

        return cls(surname_vocab, nationality_vocab, max_surname_length)

    @classmethod
    def from_serializable(cls, contents):
        """从序列化的字典实例化向量化器"""
        surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])
        nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab'])
        return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab, 
                   max_surname_length=contents['max_surname_length'])

    def to_serializable(self):
        """返回一个可以序列化的字典"""
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable(), 
                'max_surname_length': self._max_surname_length}

class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        初始化方法
        
        Args:
            surname_df (pandas.DataFrame): 数据集
            vectorizer (SurnameVectorizer): 从数据集实例化的向量化器
        """
        self.surname_df = surname_df
        self._vectorizer = vectorizer
        self.train_df = self.surname_df[self.surname_df.split=='train']
        self.train_size = len(self.train_df)

        self.val_df = self.surname_df[self.surname_df.split=='val']
        self.validation_size = len(self.val_df)

        self.test_df = self.surname_df[self.surname_df.split=='test']
        self.test_size = len(self.test_df)

        self._lookup_dict = {'train': (self.train_df, self.train_size),
                             'val': (self.val_df, self.validation_size),
                             'test': (self.test_df, self.test_size)}

        self.set_split('train')
        
        # 类别权重
        class_counts = surname_df.nationality.value_counts().to_dict()
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])
        sorted_counts = sorted(class_counts.items(), key=sort_key)
        frequencies = [count for _, count in sorted_counts]
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)


    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """加载数据集并从头创建一个新的向量化器
        
        Args:
            surname_csv (str): 数据集的位置
        Returns:
            SurnameDataset 的一个实例
        """
        surname_df = pd.read_csv(surname_csv)
        train_surname_df = surname_df[surname_df.split=='train']
        return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
        """加载数据集和相应的向量化器。
        在向量化器已缓存以便复用的情况下使用
        
        Args:
            surname_csv (str): 数据集的位置
            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):
        """从文件加载向量化器的静态方法
        
        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)

    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):
        """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)

    for data_dict in dataloader:
        out_data_dict = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict

分类器选择

class SurnameClassifier(nn.Module):
    def __init__(self, initial_num_channels, num_classes, num_channels):
        """
        初始化方法
        
        Args:
            initial_num_channels (int): 输入特征向量的大小
            num_classes (int): 输出预测向量的大小
            num_channels (int): 网络中使用的常量通道大小
        """
        super(SurnameClassifier, self).__init__()
        
        self.convnet = nn.Sequential(
            nn.Conv1d(in_channels=initial_num_channels, 
                      out_channels=num_channels, kernel_size=3),
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3, stride=2),
            nn.ELU(),
            nn.Conv1d(in_channels=num_channels, out_channels=num_channels, 
                      kernel_size=3),
            nn.ELU()
        )
        self.fc = nn.Linear(num_channels, num_classes)

    def forward(self, x_surname, apply_softmax=False):
        """分类器的前向传播
        
        Args:
            x_surname (torch.Tensor): 输入数据张量。x_surname.shape 应为 (batch, initial_num_channels, max_surname_length)
            apply_softmax (bool): 是否应用 softmax 激活
                如果与交叉熵损失一起使用,应设为 False
        Returns:
            结果张量。tensor.shape 应为 (batch, num_classes)
        """
        # 应用卷积网络
        features = self.convnet(x_surname).squeeze(dim=2)
       
        # 应用全连接层
        prediction_vector = self.fc(features)

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

        return prediction_vector

训练

def make_train_state(args):
    """
    创建训练过程中的状态字典
    
    Args:
        args (argparse.Namespace): 包含训练参数的命名空间
    Returns:
        dict: 包含训练状态的字典
    """
    return {'stop_early': False,                 # 是否提前停止训练的标志
            'early_stopping_step': 0,            # 提前停止训练的步数
            'early_stopping_best_val': 1e8,      # 最好的验证集损失(用于提前停止)
            'learning_rate': args.learning_rate, # 学习率
            'epoch_index': 0,                    # 当前的训练轮数
            'train_loss': [],                    # 训练损失记录
            'train_acc': [],                     # 训练准确率记录
            'val_loss': [],                      # 验证损失记录
            'val_acc': [],                       # 验证准确率记录
            'test_loss': -1,                     # 测试损失(初始为 -1,表示未知)
            'test_acc': -1,                      # 测试准确率(初始为 -1,表示未知)
            'model_filename': args.model_state_file}  # 模型文件名(用于保存模型状态)
def update_train_state(args, model, train_state):
    """
    处理训练状态的更新。

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

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

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

    # 如果训练轮数大于等于1
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]

        # 如果损失变差
        if loss_t >= train_state['early_stopping_best_val']:
            # 更新步数
            train_state['early_stopping_step'] += 1
        # 损失减少
        else:
            # 如果损失比最佳损失好
            if loss_t < train_state['early_stopping_best_val']:
                torch.save(model.state_dict(), train_state['model_filename'])

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

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

    return train_state

def compute_accuracy(y_pred, y_target):
    """
    计算模型预测的准确率。

    Args:
        y_pred (torch.Tensor): 模型的预测输出张量,形状为 (batch_size, num_classes)
        y_target (torch.Tensor): 真实标签张量,形状为 (batch_size,)
    Returns:
        float: 准确率,以百分比表示
    """
    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="data/surnames/surnames_with_splits.csv",  # 姓氏数据集的路径
    vectorizer_file="vectorizer.json",  # 向量化器保存文件的名称
    model_state_file="model.pth",  # 模型状态保存文件的名称
    save_dir="model_storage/ch4/cnn",  # 模型保存的目录路径
    # 模型超参数
    hidden_dim=100,  # 隐藏层维度
    num_channels=256,  # 卷积通道数
    # 训练超参数
    seed=1337,  # 随机种子
    learning_rate=0.001,  # 学习率
    batch_size=128,  # 批量大小
    num_epochs=100,  # 训练轮数
    early_stopping_criteria=5,  # 提前停止的条件(验证集损失连续不减次数)
    dropout_p=0.1,  # 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("扩展后的文件路径:")
    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("使用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_everywhere(args.seed, args.cuda)

# 处理目录,确保保存模型的目录存在
handle_dirs(args.save_dir)

Expanded filepaths: 
	model_storage/ch4/cnn/vectorizer.json
	model_storage/ch4/cnn/model.pth
Using CUDA: False
if args.reload_from_files:
    # 从检查点重新加载训练
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                              args.vectorizer_file)
else:
    # 创建数据集和向量化器
    dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
    dataset.save_vectorizer(args.vectorizer_file)
    
vectorizer = dataset.get_vectorizer()  # 获取数据集的向量化器实例

# 根据向量化器的词汇大小初始化分类器模型
classifier = SurnameClassifier(initial_num_channels=len(vectorizer.surname_vocab), 
                               num_classes=len(vectorizer.nationality_vocab),
                               num_channels=args.num_channels)

classifier = classifier.to(args.device)  # 将分类器移动到指定设备上
dataset.class_weights = dataset.class_weights.to(args.device)  # 将类别权重移动到指定设备上

# 定义损失函数、优化器和学习率调度器
loss_func = nn.CrossEntropyLoss(weight=dataset.class_weights)  # 交叉熵损失函数
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)  # Adam优化器
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer=optimizer,
                                                 mode='min', factor=0.5,
                                                 patience=1)  # 学习率调度器

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

        # 设置:批处理生成器,将损失和准确率设为0,设置为训练模式
        dataset.set_split('train')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.0
        running_acc = 0.0
        classifier.train()  # 将模型设置为训练模式

        for batch_index, batch_dict in enumerate(batch_generator):
            # 训练过程分为5个步骤:

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

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

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

            # 步骤4. 计算梯度
            loss.backward()

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

            # 更新进度条
            train_bar.set_postfix(loss=running_loss, acc=running_acc, 
                                  epoch=epoch_index)
            train_bar.update()

        # 将当前epoch的损失和准确率记录到训练状态中
        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # Iterate over val dataset

        # 设置:批处理生成器,将损失和准确率设为0,设置为评估模式
        dataset.set_split('val')
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)
        running_loss = 0.
        running_acc = 0.
        classifier.eval()  # 将模型设置为评估模式

        for batch_index, batch_dict in enumerate(batch_generator):

            # 计算输出
            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)

            val_bar.set_postfix(loss=running_loss, acc=running_acc, 
                                epoch=epoch_index)
            val_bar.update()

        # 将当前epoch的损失和准确率记录到训练状态中
        train_state['val_loss'].append(running_loss)
        train_state['val_acc'].append(running_acc)

        # 更新训练状态,包括保存最佳模型和控制early stopping
        train_state = update_train_state(args=args, model=classifier,
                                         train_state=train_state)

        # 根据验证集损失调整学习率
        scheduler.step(train_state['val_loss'][-1])

        # 如果满足early stopping条件,提前结束训练循环
        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):
    # 计算模型的输出
    y_pred = classifier(batch_dict['x_surname'])
    
    # 计算损失
    loss = loss_func(y_pred, batch_dict['y_nationality'])
    loss_t = loss.item()
    running_loss += (loss_t - running_loss) / (batch_index + 1)

    # 计算准确率
    acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])
    running_acc += (acc_t - running_acc) / (batch_index + 1)

# 将测试集的损失和准确率保存到训练状态字典中
train_state['test_loss'] = running_loss
train_state['test_acc'] = running_acc

print("Test loss: {};".format(train_state['test_loss']))
print("Test Accuracy: {}".format(train_state['test_acc']))
Test loss: 1.6242567462971512
Test Accuracy: 56.866275763725356

预测

def predict_nationality(surname, classifier, vectorizer):
    """预测给定姓氏的国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
    Returns:
        包含最可能的国籍及其概率的字典
    """
    # 向量化输入的姓氏
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
    
    # 使用分类器进行预测,应用softmax以获取概率
    result = classifier(vectorized_surname, apply_softmax=True)

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

    # 根据索引查找预测的国籍
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    probability_value = probability_values.item()

    # 返回预测的国籍和其概率
    return {'nationality': predicted_nationality, 'probability': probability_value}
# 从用户输入获取一个姓氏
new_surname = input("Enter a surname to classify: ")

# 将分类器移回CPU上执行
classifier = classifier.cpu()

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

# 打印预测结果:姓氏 -> 国籍 (概率)
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))
Enter a surname to classify:  Gong
Yuan -> Chinese (p=0.64)
def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    """预测给定姓氏的前 K 个可能的国籍及其概率
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
        k (int): 返回的前 K 个国籍
    Returns:
        包含前 K 个国籍及其概率的字典列表
    """
    
    # 向量化输入的姓氏
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)
    
    # 使用分类器进行预测,应用softmax以获取概率
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)
    
    # 获取前 K 个概率最高的国籍及其对应的索引
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # 将张量转换为 numpy 数组,并去除梯度信息
    probability_values = probability_values[0].detach().numpy()
    indices = indices[0].detach().numpy()
    
    # 构建结果列表,每个元素是一个字典,包含国籍和概率
    results = []
    for kth_index in range(k):
        nationality = vectorizer.nationality_vocab.lookup_index(indices[kth_index])
        probability_value = probability_values[kth_index]
        results.append({'nationality': nationality, 
                        'probability': probability_value})
    
    return results

# 从用户输入获取一个姓氏
new_surname = input("Enter a surname to classify: ")

# 获取用户希望查看的前 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)
    
# 使用预测函数获取前 K 个国籍预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

# 打印前 K 个预测结果
print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))
Enter a surname to classify:  Gong
How many of the top predictions to see?  5
Top 5 predictions:
===================
Yuan -> Chinese (p=0.64)
Yuan -> Korean (p=0.12)
Yuan -> German (p=0.05)
Yuan -> English (p=0.04)
Yuan -> Irish (p=0.03)

三、总结

在姓氏分类任务中,MLP(多层感知机)和CNN(卷积神经网络)是两种常见的模型选择。MLP适合处理简单的特征提取和分类任务,尤其在数据集较小且特征维度不高时表现良好,但在处理具有空间结构的数据(如图像)时效果欠佳。相对而言,CNN通过卷积层的参数共享和对空间局部性的敏感性,能更有效地捕捉图像和序列数据中的特征和模式,因此在处理姓氏分类这类任务时,通常比MLP表现更优异,特别是在处理文字或字符序列数据时。选择合适的模型需考虑数据类型、任务复杂性以及可用的计算资源和数据量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值