【NLP】基于MLP与CNN实现姓氏分类

一、数据集处理

1.1数据集介绍

姓氏数据集(surname.csv),它收集了来自18个不同国家的10,000个姓氏。

链接:https://pan.baidu.com/s/1E-8FMoXIMqfc_raDDwtSjA 
提取码:TAEL

这个数据集有两个主要特点。首先,数据集非常不平衡,前三个课程占据了数据的60%以上,其中27%是英语,21%是俄语,14%是阿拉伯语。其余15个民族的频率呈下降趋势,这也是语言特有的特点。
其次,国籍和姓氏的拼写之间存在着有效而直观的关系。一些姓氏拼写变体与其原籍国家紧密相关,比如“O‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”。

1.2数据集划分

原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为减少这种不平衡,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。

# 导入collections模块,提供了一些扩展的容器数据类型
import collections
# 导入numpy模块,用于进行科学计算和数据处理
import numpy as np
# 导入pandas模块,用于数据分析和处理
import pandas as pd
# 导入re模块,用于正则表达式操作
import re
# 从argparse模块中导入Namespace类,用于创建命名空间对象
from argparse import Namespace

# 创建一个命名空间对象,用于存储参数
args = Namespace(
    # 原始数据集的CSV文件路径
    raw_dataset_csv="surnames.csv",
    # 训练集所占比例
    train_proportion=0.7,
    # 验证集所占比例
    val_proportion=0.15,
    # 测试集所占比例
    test_proportion=0.15,
    # 输出带有分割信息的CSV文件路径
    output_munged_csv="surnames_with_splits.csv",
    # 随机数种子
    seed=1337
)

# 读取原始数据文件,将其存储在名为surnames的DataFrame中
surnames = pd.read_csv(args.raw_dataset_csv, header=0)

# 使用pandas库的head()方法,获取surnames DataFrame的前5行数据
surnames.head()

# 使用set()函数和surnames.nationality列,获取该列中所有唯一(不重复)的国家名称
set(surnames.nationality)

# 创建一个名为by_nationality的字典,其中每个键对应一个空列表
by_nationality = collections.defaultdict(list)

# 遍历surnames数据框的每一行
for _, row in surnames.iterrows():
    # 将当前行的国籍作为键,将当前行转换为字典后添加到对应的列表中
    by_nationality[row.nationality].append(row.to_dict())

# 创建一个空列表,用于存储最终的数据
final_list = []

# 设置随机数种子,保证每次运行结果一致
np.random.seed(args.seed)

# 遍历按国籍分组后的数据字典
for _, item_list in sorted(by_nationality.items()):
    # 对当前国籍的样本进行随机打乱
    np.random.shuffle(item_list)
    # 计算当前国籍的样本总数
    n = len(item_list)
    # 计算训练集的数量
    n_train = int(args.train_proportion*n)
    # 计算验证集的数量
    n_val = int(args.val_proportion*n)
    # 计算测试集的数量
    n_test = int(args.test_proportion*n)
    
    # 为训练集的样本添加split属性,值为'train'
    for item in item_list[:n_train]:
        item['split'] = 'train'
    # 为验证集的样本添加split属性,值为'val'
    for item in item_list[n_train:n_train+n_val]:
        item['split'] = 'val'
    # 为测试集的样本添加split属性,值为'test'
    for item in item_list[n_train+n_val:]:
        item['split'] = 'test'  
    
    # 将当前国籍的所有样本添加到最终列表中
    final_list.extend(item_list)

# 将最终的姓氏列表转换为DataFrame格式
final_surnames = pd.DataFrame(final_list)

# 统计final_surnames中split列的值出现的次数,并返回一个Series对象
final_surnames.split.value_counts()

# 使用DataFrame的head()方法,展示final_surnames的前5行数据
final_surnames.head()

# 将处理后的数据(final_surnames)写入CSV文件,文件名为args.output_munged_csv,不包含索引列
final_surnames.to_csv(args.output_munged_csv, index=False)

展示划分后的前5行数据

展示训练集、验证集、测试集数量

二、基于MLP实现姓氏分类

2.1The Multilayer Perceptron(多层感知器)

多层感知器(MLP)被认为是最基本的神经网络构建模块之一。它通常由三个表示阶段和两个线性层组成。
第一阶段是输入向量,即提供给模型的向量。通过第一个线性层,输入向量被转换成一个隐藏向量,它是位于输入和输出之间的层的输出。这个隐藏向量的值是由组成该层的不同感知器的输出所组成的。
利用隐藏向量,第二个线性层计算得到一个输出向量。在多类设置中,输出向量的大小对应于类别的数量。虽然这里只展示了一个隐藏向量,但是也可能存在多个中间阶段,每个阶段产生自己的隐藏向量。
最终,隐藏向量通过线性层和非线性函数的组合映射到输出向量。

2.2理解XOR问题:感知器与多层感知器的比较

在XOR示例中,感知器和MLP在二元分类任务中进行训练,即分辨星形和圆形数据点。感知器在学习决策边界方面遇到困难,而MLP学会了更精确的分类边界。虽然MLP显示了两个决策边界,但实际上只有一个决策边界,因为中间表示改变了空间,使超平面同时出现在两个位置上。MLP通过“扭曲”数据空间来实现这一点,从而在最后一层使用一条线来分隔数据。与MLP不同,感知器没有额外的一层来处理数据的形状,直到数据变得线性可分。

 2.3MLP模型数据预处理

导入库

# 导入PyTorch库
# 导入argparse模块中的Namespace类
from argparse import Namespace
# 导入collections模块中的Counter类
from collections import Counter
# 导入json模块
import json
# 导入os模块
import os
# 导入string模块
import string

# 导入numpy模块,并用np作为别名
import numpy as np
# 导入pandas模块,并用pd作为别名
import pandas as pd

# 导入torch模块
import torch
# 导入torch.nn模块,并用nn作为别名
import torch.nn as nn
# 导入torch.nn.functional模块,并用F作为别名
import torch.nn.functional as F
# 导入torch.optim模块,并用optim作为别名
import torch.optim as optim
# 导入torch.utils.data模块中的Dataset和DataLoader类
from torch.utils.data import Dataset, DataLoader
# 导入tqdm模块中的tqdm_notebook函数
from tqdm import tqdm_notebook

# 设置随机数种子,保证实验结果可复现
seed = 1337

# 为CPU设置随机数种子
torch.manual_seed(seed)
# 为所有GPU设置随机数种子
torch.cuda.manual_seed_all(seed)

Vocabulary类:用于处理文本并构建词汇表

词汇表是两个字典的集合,它们之间建立了令牌(在这里是字符)和整数之间的双向映射。一个字典将令牌映射到整数索引,另一个字典则将整数索引映射回令牌。add_token 方法用于添加新的令牌到词汇表中,lookup_token 方法用于检索令牌对应的索引,而 lookup_index 方法则用于检索给定索引的令牌,这在推断阶段非常有用。

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

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

        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):
        """根据token更新映射字典。

        参数:
            token (str): 要添加到词汇表中的项目
        返回:
            index (int): 与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):
        """将一组tokens添加到词汇表中
        
        参数:
            tokens (list): 一组字符串tokens
        返回:
            indices (list): 与tokens对应的一组索引
        """
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        """检索与token关联的索引,如果token不存在,则返回UNK索引。
        
        参数:
            token (str): 要查找的token
        返回:
            index (int): 与token对应的索引
        注意:
            `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):
        """返回与索引关联的token
        
        参数: 
            index (int): 要查找的索引
        返回:
            token (str): 与索引对应的token
        引发:
            KeyError: 如果索引不在词汇表中
        """
        if index not in self._idx_to_token:
            raise KeyError("the index (%d) is not in the Vocabulary" % index)
        return self._idx_to_token[index]

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

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

SurnameVectorizer类:负责应用词汇表并将姓氏转换为向量

SurnameVectorizer 类依赖于 Vocabulary 类来管理和操作姓氏和国籍的词汇表,从而实现对文本数据的向量化处理

class SurnameVectorizer(object):
    """ 将姓氏和国籍映射到整数的向量化器类 """
    def __init__(self, surname_vocab, nationality_vocab):
        """
        参数:
            surname_vocab (Vocabulary): 将字符映射到整数的词汇表
            nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

    def vectorize(self, surname):
        """
        参数:
            surname (str): 姓氏字符串

        返回:
            one_hot (np.ndarray): 一个折叠的独热编码
        """
        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1

        return one_hot

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

        for index, row in surname_df.iterrows():
            for letter in row.surname:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(surname_vocab, nationality_vocab)

    @classmethod
    def from_serializable(cls, contents):
        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()}

SurnameDataset 类:定义了一个用于处理姓氏数据集的 PyTorch 数据集类,并提供了加载数据集并创建向量化器的方法

# 导入torch.utils.data模块中的Dataset和DataLoader类
from torch.utils.data import Dataset, DataLoader
class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        Args:
            surname_df (pandas.DataFrame): the dataset
            vectorizer (SurnameVectorizer): vectorizer instatiated from dataset
        """
        self.surname_df = surname_df  # 姓氏数据集
        self._vectorizer = vectorizer  # 向量化器

        self.train_df = self.surname_df[self.surname_df.split=='train']  # 训练集
        self.train_size = len(self.train_df)  # 训练集大小

        self.val_df = self.surname_df[self.surname_df.split=='val']  # 验证集
        self.validation_size = len(self.val_df)  # 验证集大小

        self.test_df = self.surname_df[self.surname_df.split=='test']  # 测试集
        self.test_size = len(self.test_df)  # 测试集大小

        self._lookup_dict = {'train': (self.train_df, self.train_size),
                             'val': (self.val_df, self.validation_size),
                             'test': (self.test_df, self.test_size)}  # 数据集字典

        self.set_split('train')  # 设置默认分割为训练集
        
        # Class weights
        class_counts = surname_df.nationality.value_counts().to_dict()  # 类别计数
        def sort_key(item):
            return self._vectorizer.nationality_vocab.lookup_token(item[0])  # 排序键
        sorted_counts = sorted(class_counts.items(), key=sort_key)  # 排序后的计数
        frequencies = [count for _, count in sorted_counts]  # 频率
        self.class_weights = 1.0 / torch.tensor(frequencies, dtype=torch.float32)  # 类别权重

    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            surname_csv (str): location of the dataset
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)  # 读取姓氏数据集
        train_surname_df = surname_df[surname_df.split=='train']  # 训练集
        return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))  # 返回实例

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            surname_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)  # 读取姓氏数据集
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)  # 加载向量化器
        return cls(surname_df, vectorizer)  # 返回实例

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of SurnameVectorizer
        """
        with open(vectorizer_filepath) as fp:  # 打开文件
            return SurnameVectorizer.from_serializable(json.load(fp))  # 返回向量化器实例

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

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

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

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

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

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

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

        return {'x_surname': surname_vector,  # 返回特征和标签
                'y_nationality': nationality_index}

    def get_num_batches(self, batch_size):
        """Given a batch size, return the number of batches in the dataset
        
        Args:
            batch_size (int)
        Returns:
            number of batches in the dataset
        """
        return len(self) // batch_size  # 返回批次数量
    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"): 
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)  # 创建数据加载器

    for data_dict in dataloader:  # 遍历数据加载器
        out_data_dict = {}  # 输出数据字典
        for name, tensor in data_dict.items():  # 遍历数据字典
            out_data_dict[name] = data_dict[name].to(device)  # 将张量放到设备上
        yield out_data_dict  # 返回输出数据字典

2.4MLP模型实现

2.4.1MLP模型定义

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        初始化多层感知器模型,包括两个线性层和一个激活函数。
        
        参数:
            input_dim (int): 输入向量的大小
            hidden_dim (int): 第一个线性层的输出大小
            output_dim (int): 第二个线性层的输出大小
        """
        super(MultilayerPerceptron, self).__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)  # 定义第一个线性层
        self.fc2 = nn.Linear(hidden_dim, output_dim)  # 定义第二个线性层

    def forward(self, x_in, apply_softmax=False):
        """
        多层感知器的前向传播过程。
        
        参数:
            x_in (torch.Tensor): 输入数据张量。
                x_in.shape 应该是 (batch, input_dim)
            apply_softmax (bool): softmax激活函数的标志
                如果与交叉熵损失一起使用,应为false
        返回:
            结果张量。tensor.shape 应该是 (batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))  # 对第一个线性层的输出应用ReLU激活函数
        output = self.fc2(intermediate)  # 计算第二个线性层的输出
        
        if apply_softmax:
            output = F.softmax(output, dim=1)  # 如果需要,应用softmax激活函数
        return output

2.4.2MLP模型辅助函数

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

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

    :param args: 主参数
    :param model: 要训练的模型
    :param 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

    # 如果性能得到改善,则保存模型
    elif train_state['epoch_index'] >= 1:
        loss_tm1, loss_t = train_state['val_loss'][-2:]

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

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

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

    return train_state

def compute_accuracy(y_pred, y_target):
    # 计算预测准确率
    _, y_pred_indices = y_pred.max(dim=1)
    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的随机数种子
    np.random.seed(seed)
    # 设置PyTorch的随机数种子
    torch.manual_seed(seed)
    # 如果使用GPU,则设置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",
    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,
    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("扩展文件路径:")
    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("使用CUDA: {}".format(args.cuda))

# 设置随机种子以确保可重复性
set_seed_everywhere(args.seed, args.cuda)

# 处理目录
handle_dirs(args.save_dir)
if args.reload_from_files:
    # 如果需要从文件中重新加载模型
    print("Reloading!")
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                              args.vectorizer_file)
else:
    # 创建数据集和向量化器
    print("Creating fresh!")
    dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
    dataset.save_vectorizer(args.vectorizer_file)
    
vectorizer = dataset.get_vectorizer()
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab), 
                               hidden_dim=args.hidden_dim, 
                               output_dim=len(vectorizer.nationality_vocab))

2.4.3MLP模型训练

from tqdm.notebook import tqdm
classifier = classifier.to(args.device)  # 将分类器移动到指定设备(CPU或GPU)上
dataset.class_weights = dataset.class_weights.to(args.device)  # 将数据集的类别权重移动到指定设备上

loss_func = nn.CrossEntropyLoss(dataset.class_weights)  # 使用交叉熵损失函数,并传入类别权重
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)  # 使用Adam优化器,学习率由参数指定
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  # 更新训练状态字典中的当前轮次索引

        # Iterate over training dataset
        dataset.set_split('train')  # 设置数据集为训练集
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)  # 生成批次数据
        running_loss = 0.0  # 初始化运行损失
        running_acc = 0.0  # 初始化运行准确率
        classifier.train()  # 将分类器设置为训练模式

        for batch_index, batch_dict in enumerate(batch_generator):  # 遍历每个批次
            # the training routine is these 5 steps:

            # --------------------------------------
            # step 1. zero the gradients
            optimizer.zero_grad()  # 清空梯度

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

            # step 3. compute the loss
            loss = loss_func(y_pred, batch_dict['y_nationality'])  # 计算损失
            loss_t = loss.item()  # 获取损失值
            running_loss += (loss_t - running_loss) / (batch_index + 1)  # 更新运行损失

            # step 4. use loss to produce gradients
            loss.backward()  # 计算梯度

            # step 5. use optimizer to take gradient step
            optimizer.step()  # 更新模型参数
            # -----------------------------------------
            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])  # 计算准确率
            running_acc += (acc_t - running_acc) / (batch_index + 1)  # 更新运行准确率

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

        train_state['train_loss'].append(running_loss)  # 将训练损失添加到训练状态字典中
        train_state['train_acc'].append(running_acc)  # 将训练准确率添加到训练状态字典中

        # Iterate over val dataset

        # setup: batch generator, set loss and acc to 0; set eval mode on
        dataset.set_split('val')  # 设置数据集为验证集
        batch_generator = generate_batches(dataset, 
                                           batch_size=args.batch_size, 
                                           device=args.device)  # 生成批次数据
        running_loss = 0.  # 初始化运行损失
        running_acc = 0.  # 初始化运行准确率
        classifier.eval()  # 将分类器设置为评估模式

        for batch_index, batch_dict in enumerate(batch_generator):  # 遍历每个批次

            # compute the output
            y_pred = classifier(batch_dict['x_surname'])  # 计算输出

            # step 3. compute the loss
            loss = loss_func(y_pred, batch_dict['y_nationality'])  # 计算损失
            loss_t = loss.to("cpu").item()  # 获取损失值
            running_loss += (loss_t - running_loss) / (batch_index + 1)  # 更新运行损失

            # compute the accuracy
            acc_t = compute_accuracy(y_pred, batch_dict['y_nationality'])  # 计算准确率
            running_acc += (acc_t - running_acc) / (batch_index + 1)  # 更新运行准确率
            val_bar.set_postfix(loss=running_loss, acc=running_acc, 
                            epoch=epoch_index)  # 更新进度条信息
            val_bar.update()  # 更新进度条

        train_state['val_loss'].append(running_loss)  # 将验证损失添加到训练状态字典中
        train_state['val_acc'].append(running_acc)  # 将验证准确率添加到训练状态字典中

        train_state = update_train_state(args=args, model=classifier,
                                         train_state=train_state)  # 更新训练状态字典

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

        if train_state['stop_early']:  # 如果需要提前停止训练
            break

        train_bar.n = 0  # 重置训练进度条
        val_bar.n = 0  # 重置验证进度条
        epoch_bar.update()  # 更新训练轮数进度条
except KeyboardInterrupt:
    print("Exiting loop")  # 如果用户按下Ctrl+C,退出循环并打印提示信息

# 加载训练状态字典中的最佳模型参数
classifier.load_state_dict(torch.load(train_state['model_filename']))

# 将分类器移动到指定设备(CPU或GPU)上
classifier = classifier.to(args.device)
# 将数据集的类别权重移动到指定设备上
dataset.class_weights = dataset.class_weights.to(args.device)
# 使用交叉熵损失函数,并传入类别权重
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 设置数据集为测试集
dataset.set_split('test')
# 生成批次数据
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)
running_loss = 0.
running_acc = 0.

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

# 打印测试损失值,从train_state字典中获取'test_loss'键对应的值,并格式化输出
print("Test loss: {};".format(train_state['test_loss']))

# 打印测试准确率,从train_state字典中获取'test_acc'键对应的值,并格式化输出
print("Test Accuracy: {}".format(train_state['test_acc']))

训练损失与准确率

2.4.4MLP模型预测

def predict_nationality(surname, classifier, vectorizer):
    """预测姓氏所属国籍
    
    参数:
        surname (str): 需要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
    返回:
        一个字典,包含最可能的国籍及其概率
    """
    # 将姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    # 将向量转换为张量并调整形状
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)
    # 使用分类器对向量进行分类,并应用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("请输入一个姓氏进行分类: ")

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

# 使用分类器对输入的姓氏进行国籍预测
prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印预测结果,包括姓氏、预测的国籍和概率
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

在NLP等领域,通常不仅需要关注最佳预测结果,还需要考虑更多预测的情况。一个常见的做法是采用k-best预测,然后利用另一个模型对这些预测进行重新排序。PyTorch提供了一个便捷的函数torch.topk,可用于获取这些预测中的前k个最佳结果。

# 定义一个函数,用于预测姓氏的国籍,并返回前k个最可能的国籍及其概率
def predict_topk_nationality(name, classifier, vectorizer, k=5):
    # 将输入的姓名向量化
    vectorized_name = vectorizer.vectorize(name)
    # 将向量转换为张量并调整形状
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    # 使用分类器对向量进行分类,并应用softmax函数
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    # 获取前k个最大概率值和对应的索引
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # 返回的大小是1,k
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]
    
    results = []
    for prob_value, index in zip(probability_values, indices):
        # 根据索引查找对应的国籍
        nationality = vectorizer.nationality_vocab.lookup_index(index)
        # 将国籍和概率添加到结果列表中
        results.append({'nationality': nationality, 
                        'probability': prob_value})
    
    return results

# 获取用户输入的姓氏
new_surname = input("请输入一个姓氏进行分类: ")
# 将分类器移动到CPU上
classifier = classifier.to("cpu")

# 获取用户输入的前k个最可能的预测结果
k = int(input("想查看前几个最可能的预测结果? "))
if k > len(vectorizer.nationality_vocab):
    print("抱歉!您输入的数字超过了我们的国籍数量。默认设置为最大值 :)")
    k = len(vectorizer.nationality_vocab)
    
# 调用函数,获取预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

print("前 {} 个最可能的预测结果:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))

2.5添加dropout的MLP模型实现

在训练神经网络过程中,dropout被引入了一种幽默而直观的解释:“如果你能够在醉酒时反复学习如何做一件事,那么在清醒状态下你应该能够做得更好。”这个比喻揭示了dropout的基本思想:通过在训练过程中随机丢弃一些单元之间的连接,从而鼓励网络学习更加鲁棒和泛化的特征。

在深层神经网络中,特别是具有多个隐藏层的网络,单元之间可能会发生相互适应的情况。这种相互适应有时被称为“Coadaptation”,它会导致某些单元之间的联系过于紧密,而忽视了其他单元之间的联系,从而导致模型过拟合数据。通过以一定的概率随机丢弃单元之间的连接,dropout可以防止任何一个单元过于依赖于另一个单元,从而帮助建立更加健壮的模型。

需要注意的是,dropout不会增加模型中的额外参数,但需要一个超参数——“丢弃概率”,即单元之间连接被丢弃的概率。通常情况下,将丢弃概率设置为0.5。这种设置可以在一定程度上平衡网络的复杂性和训练的鲁棒性。

2.5.1MLP with dropout模型定义

import torch.nn as nn  # 导入PyTorch中的神经网络模块
import torch.nn.functional as F  # 导入PyTorch中的函数模块

class MultilayerPerceptron_withDropOut(nn.Module):  # 定义一个多层感知机的类,继承自nn.Module基类
    def __init__(self, input_dim, hidden_dim, output_dim):  # 初始化方法
        """
        Args:
            input_dim (int): 输入向量的大小
            hidden_dim (int): 第一个全连接层的输出大小
            output_dim (int): 第二个全连接层的输出大小
        """
        super(MultilayerPerceptron_withDropOut, 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):  # 前向传播方法
        """多层感知机的前向传播

        Args:
            x_in (torch.Tensor): 输入数据张量。
                x_in.shape 应该为 (batch, input_dim)
            apply_softmax (bool): 用于softmax激活的标志
                如果与交叉熵损失一起使用,则应为False
        Returns:
            结果张量。张量.shape 应该为 (batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))  # 使用ReLU激活函数进行第一个全连接层的计算
        output = self.fc2(F.dropout(intermediate, p=0.5))  # 对中间结果应用0.5的丢弃率后,计算第二个全连接层的输出

        if apply_softmax:  # 如果需要应用softmax激活
            output = F.softmax(output, dim=1)  # 在dim=1维度上应用softmax激活函数
        return output  # 返回输出结果

2.5.2MLP with dropout训练

添加dropout前: Test loss: 1.7587749719619752; Test Accuracy: 48.87500000000001

添加dropout后: Test loss: 1.9556703472137447; Test Accuracy: 41.5

可见,添加dropout后的模型预测效果并没有改进,甚至有略微退步

在某些情况下,添加dropout可能并不总是能够改进模型的性能,需要进行仔细的实验和调整才能找到最佳的配置。

三、基于CNN实现姓氏分类

在本实验的第一部分中,我们对MLP进行了深入研究,这些神经网络由一系列线性层和非线性函数构建而成。然而,MLP并不总是最佳的工具,特别是在处理顺序性数据时。举例来说,在姓氏数据集中,姓氏可能包含不同长度的片段,这些片段能够提供关于姓氏所属国家的丰富信息,比如在“O’Neill”中的“O”、“Antonopoulos”中的“opoulos”、“Nagasawa”中的“sawa”或“Zhu”中的“Zh”。这些片段的长度是可变的,挑战在于如何在不需要显式编码的情况下捕获这些信息。

在接下来的部分中,我们将介绍卷积神经网络(CNN),这是一种非常适合于检测空间子结构的神经网络,从而能够创建有意义的空间子结构。CNN通过使用少量的权重来扫描输入数据张量来实现这一目的。通过这种扫描,CNN产生表示子结构检测(或不检测)的输出张量。

3.1CNN(卷积神经网络)

卷积神经网络(CNN)的名称和基本功能源于数学运算中的卷积操作。在工程学科中,包括数字信号处理和计算机图形学,卷积早已被广泛应用。传统的卷积操作通常由程序员指定参数,这些参数用于匹配特定的功能设计,例如突出边缘或抑制高频信息。与此不同的是,深度学习中的CNN通过从数据中学习卷积核的参数来执行卷积运算,因此更适用于解决当前的任务。

3.1.1Dimension

在PyTorch中,卷积可以是一维、二维或三维的,分别由Conv1d、Conv2d和Conv3d模块实现。一维卷积对于每个时间步都有一个特征向量的时间序列非常有用。在这种情况下,我们可以在序列维度上学习模式。NLP中的卷积运算大多是一维的卷积。另一方面,二维卷积试图捕捉数据中沿两个方向的时空模式;例如,在图像中沿高度和宽度维度——为什么二维卷积在图像处理中很流行。类似地,在三维卷积中,模式是沿着数据中的三维捕获的。例如,在视频数据中,信息是三维的,二维表示图像的帧,时间维表示帧的序列。就本课程而言,我们主要使用Conv1d。

3.1.2Channels

在图像数据中,通道对应于RGB颜色通道,而在文本数据中,通道可以表示词汇表中的不同词或字符。在PyTorch中,通过参数in_channels来指定输入通道的数量,而输出通道的数量则可以通过参数out_channels来控制。卷积操作可以将输入特征维“映射”到输出特征维,从而实现特征的提取和转换。

3.1.3Kernel_size

核大小(kernel_size)是指卷积核的宽度,在PyTorch中也称为kernel_size。核的大小决定了卷积操作中局部特征的捕获范围,因此是一个关键的超参数。选择适当的核大小取决于具体的任务和数据特征,通常需要通过实验和调整来确定最佳的值。

3.1.4Stride

Stride控制卷积之间的步长。如果步长与核相同,则内核计算不会重叠。另一方面,如果跨度为1,则内核重叠最大。输出张量可以通过增加步幅的方式被有意的压缩来总结信息,如图4-10所示。

3.1.5Padding

即使在卷积神经网络中,通过调整步幅和卷积核大小,可以控制每个计算出的特征的范围,但这样做有一个潜在的负面影响,即缩小了特征映射的总大小(也就是卷积的输出)。为了弥补这一点,人们通常会采取人为增加输入数据张量尺寸的方法,无论是在长度(如果是一维)、高度(如果是二维或三维)、还是深度(如果是三维)上,这种方法是在每个维度上添加和前置零值。这样一来,虽然卷积操作会更多,但输出形状可以得到控制,而不必改变卷积核大小、步幅或者扩展操作。图4-11展示了填充操作的执行过程。

3.1.6Dilation

即使在卷积神经网络中,通过调整步幅和卷积核大小,可以控制每个计算出的特征的范围,但这样做有一个潜在的负面影响,即缩小了特征映射的总大小(也就是卷积的输出)。为了弥补这一点,人们通常会采取人为增加输入数据张量尺寸的方法,无论是在长度(如果是一维)、高度(如果是二维或三维)、还是深度(如果是三维)上,这种方法是在每个维度上添加和前置零值。这样一来,虽然卷积操作会更多,但输出形状可以得到控制,而不必改变卷积核大小、步幅或者扩展操作。图4-11展示了填充操作的执行过程。

 3.2CNN模型数据预处理

为了验证CNN的有效性,我们打算利用一个简单的CNN模型来进行姓氏分类任务。虽然该任务的大部分细节与之前介绍的MLP示例相似,但主要变化在于模型的构建和向量化过程。与之前采用的缩减的one-hot编码不同,这次我们将使用一个完整的one-hot矩阵作为模型的输入。这种设计能够让CNN更好地“观察”字符的排列,并编码在“示例:带有多层感知器的姓氏分类”中使用的缩减的one-hot编码中丢失的序列信息。

虽然姓氏数据集在“示例:带有多层感知器的姓氏分类”中已经有所描述,但建议参考“姓氏数据集”以了解更多细节。尽管我们使用的是来自前述示例的相同数据集,但在实现上有一个重要的区别:数据集现在由完整的one-hot向量矩阵组成,而不是缩减的one-hot向量。为此,我们构建了一个数据集类,它会跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给向量化器。矩阵的列数则等于one-hot向量的大小(即词汇表的大小)。
我们之所以选择使用数据集中最长的姓氏来控制one-hot矩阵的大小,有两个原因。首先,这样可以确保每个小批次的姓氏矩阵都具有相同的尺寸,从而便于进行批处理。其次,使用数据集中最长的姓氏意味着我们可以以统一的方式处理每个小批次。

SurnameVectorizer

class SurnameVectorizer(object):
    """ 姓氏向量化器,负责协调词汇表并将它们应用于向量化过程 """
    def __init__(self, surname_vocab, nationality_vocab, max_surname_length):
        """
        参数:
            surname_vocab (Vocabulary): 将字符映射到整数的词汇表
            nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
            max_surname_length (int): 最长姓氏的长度
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab
        self._max_surname_length = max_surname_length

    def vectorize(self, surname):
        """
        参数:
            surname (str): 姓氏字符串
        返回:
            one_hot_matrix (np.ndarray): 一个由独热向量组成的矩阵
        """
        one_hot_matrix_size = (len(self.surname_vocab), self._max_surname_length)
        one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
                               
        for position_index, character in enumerate(surname):
            character_index = self.surname_vocab.lookup_token(character)
            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="@")
        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}

SurnameDataset

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

3.3CNN模型实现

3.3.1CNN模型定义
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
3.3.2CNN模型辅助函数
args = Namespace(
    # 数据和路径信息
    surname_csv="surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/cnn",
    # 模型超参数
    hidden_dim=100,
    num_channels=256,
    # 训练超参数
    seed=1337,
    learning_rate=0.001,
    batch_size=128,
    num_epochs=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # 运行时选项
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
    catch_keyboard_interrupt=True
)

# 如果需要将文件路径扩展到保存目录,则执行以下操作
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

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)

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)

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

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

train_state = make_train_state(args)
3.3.3CNN模型训练
from tqdm.notebook import tqdm
epoch_bar = tqdm(desc='训练过程', 
                          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

        # 迭代训练数据集

        # 设置:批生成器,将损失和准确率设置为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()

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

        # 迭代验证集数据

        # 设置:批生成器,将损失和准确率设置为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'])

            # 步骤3. 计算损失
            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()

        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("退出循环")

# 加载训练好的模型参数
classifier.load_state_dict(torch.load(train_state['model_filename']))

# 将模型移动到指定设备(CPU或GPU)上
classifier = classifier.to(args.device)
# 将类别权重移动到指定设备(CPU或GPU)上
dataset.class_weights = dataset.class_weights.to(args.device)
# 使用类别权重创建交叉熵损失函数
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 设置数据集的分割方式为测试集
dataset.set_split('test')
# 生成测试集的批次数据
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)
running_loss = 0.
running_acc = 0.

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']))

可见,相比于MLP,CNN的测试准确率更高

3.3.4CNN模型预测
def predict_nationality(surname, classifier, vectorizer):
    """预测姓氏所属国籍
    
    参数:
        surname (str): 需要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
    返回:
        一个字典,包含最可能的国籍及其概率
    """
    # 将姓氏向量化
    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("请输入一个姓氏进行分类: ")

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

# 使用分类器对输入的姓氏进行国籍预测
prediction = predict_nationality(new_surname, classifier, vectorizer)

# 打印预测结果,包括姓氏、预测的国籍和概率
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

  • 12
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值