基于MLP与CNN的前馈神经网络处理姓氏分类问题

前言

   前馈神经网络(feedforward neural network,FNN),简称前馈网络,是人工神经网络的一种。前馈神经网络采用一种单向多层结构。其中每一层包含若干个神经元。在此种神经网络中,各神经元可以接收前一层神经元的信号,并产生输出到下一层。第0层叫输入层,最后一层叫输出层,其他中间层叫做隐含层(或隐藏层、隐层)。隐层可以是一层。也可以是多层。

  整个网络中无反馈,信号从输入层向输出层单向传播,可用一个有向无环图表示。

                         一个典型的单层前馈神经网络

 自然语言处理与前馈神经网络的关系

         自然语言处理(NLP)与前馈神经网络(Feedforward Neural Network)有很密切的关系。在NLP中,前馈神经网络经常被用于处理文本数据,实现诸如情感分析、文本分类、命名实体识别等任务。

         前馈神经网络是一种深度学习模型,它由输入层、隐藏层和输出层组成。在NLP中,输入可以是单词、句子或文档的表示,输出可以是分类标签、情感倾向等。隐藏层则扮演了特征提取的角色,它可以学习到文本数据中的抽象特征,比如语义信息、上下文关系等。

        前馈神经网络在NLP中的应用场景非常丰富,例如在机器翻译中,可以使用编码-解码结构的前馈神经网络来实现源语言到目标语言的翻译;在问答系统中,可以使用前馈神经网络来理解问题并生成回答。总之,前馈神经网络在NLP中发挥着重要的作用,帮助人们更好地理解和处理自然语言数据。

一、The Multilayer Perceptron(多层感知器)

多层感知器(MLP)被认为是最基本的神经网络构建模块之一。多层感知器将数据向量作为输入,计算出一个输出值。在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。同时,它可以将多个层与每个层之间的非线性结合在一起。

  最简单的MLP,如图1-1所示,由三个表示阶段和两个线性层组成。第一阶段是输入向量。这是给定给模型的向量。给定输入向量,第一个线性层计算一个隐藏向量——表示的第二阶段。隐藏向量之所以这样被调用,是因为它是位于输入和输出之间的层的输出。使用这个隐藏的向量,第二个线性层计算一个输出向量。虽然在这个例子中,只展示了一个隐藏的向量,但是有可能有多个中间阶段,每个阶段产生自己的隐藏向量。最终的隐藏向量总是通过线性层和非线性的组合映射到输出向量。

mlp的力量来自于添加第二个线性层和允许模型学习一个线性分割的的中间表示——该属性的能表示一个直线(或更一般的,一个超平面)可以用来区分数据点落在线(或超平面)的哪一边的。学习具有特定属性的中间表示,如分类任务是线性可分的,这是使用神经网络的最深刻后果之一,也是其建模能力的精髓。在下一节中,我们将更深入地研究这意味着什么。

1.1 A Simple Example: XOR

   让我们看一下前面描述的XOR示例,看看感知器与MLP之间会发生什么。在这个例子中,我们在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如图4-3所示。在这个图中,错误分类的数据点用黑色填充,而正确分类的数据点没有填充。在左边的面板中,从填充的形状可以看出,感知器在学习一个可以将星星和圆分开的决策边界方面有困难。然而,MLP(右面板)学习了一个更精确地对恒星和圆进行分类的决策边界。


  图4-3中,每个数据点的真正类是该点的形状:星形或圆形。错误的分类用块填充,正确的分类没有填充。这些线是每个模型的决策边界。在边的面板中,感知器学习一个不能正确地将圆与星分开的决策边界。事实上,没有一条线可以。在右动的面板中,MLP学会了从圆中分离星。

  虽然在图中显示MLP有两个决策边界,这是它的优点,但它实际上只是一个决策边界。决策边界是因为中间表示法改变了空间,使一个超平面同时出现在这两个位置上。在图1-3中,我们可以看到MLP计算的中间值。这些点的形状表示类(星形或圆形)。我们所看到的是,MLP已经学会了“扭曲”数据所处的空间,以便在数据通过最后一层时,用一线来分割它们。


  相反,如图4-5所示,感知器没有额外的一层来处理数据的形状,直到数据变成线性可分的。


1.2Implementing MLPs in PyTorch

       

MLP除了简单的感知器之外,还有一个额外的计算层。在例1-1中给出的实现中,使用PyTorch的两个线性模块实例化了这个想法。线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层”。除了这两个线性层外,还有一个修正的线性单元(ReLU),它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层,因此不能建模复杂的模式。MLP只实现反向传播的前向传递。这是因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新。

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

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        Args:
            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):
        """MLP的前向传播

        Args:
            x_in (torch.Tensor):输入数据张量
                x_in.shape 应为 (batch, input_dim)
            apply_softmax (bool):一个标志位,用于softmax激活
                如果与交叉熵损失一起使用,则应为False
        Returns:
            结果张量。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)
        return output

1.3 Surname Classification with a Multilayer Perceptron
  将MLP应用于将姓氏分类到其原籍国的任务流程如下:

首先对每个姓氏的字符进行拆分。

然后,使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的管道。同时引入了多类输出及其对应的损失函数。

1.3.1 The Surname Dataset
  姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,该数据集具有以下属性——
1,它是相当不平衡的。排名前三的语言占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。
2,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。

Example 1-2. Implementing SurnameDataset

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 SurnameDataset(Dataset):
    # Implementation is nearly identical to Section 3.5

    def __getitem__(self, index):   # 获取指定索引处的数据项
        row = self._target_df.iloc[index]  # 从目标数据框中获取特定行数据
        surname_vector = \
            self._vectorizer.vectorize(row.surname)  # 使用vectorizer将姓氏转为向量
        nationality_index = \
            self._vectorizer.nationality_vocab.lookup_token(row.nationality)  # 查找国籍对应的索引

        return {'x_surname': surname_vector,   # 返回姓氏向量
                'y_nationality': nationality_index}   # 返回国籍索引
1.3.2 Vocabulary, Vectorizer, and DataLoader

  为了使用字符对姓氏进行分类,使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。

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为None,则初始化为空字典
            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标记
        self._unk_token = unk_token  # UNK标记
        
        self.unk_index = -1
        if add_unk:  # 如果需要添加UNK标记
            self.unk_index = self.add_token(unk_token)  # 添加UNK标记

    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)  # 如果不存在,将其长度作为新的索引
            self._token_to_idx[token] = index  # 将token添加到token到索引的映射中
            self._idx_to_token[index] = token  # 将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]  # 对tokens中的每个token调用add_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:  # 如果UNK标记已添加
            return self._token_to_idx.get(token, self.unk_index)  # 返回token对应的索引,如果不存在则返回UNK标记的索引
        else:
            return self._token_to_idx[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:  # 如果索引不在索引到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)  # 返回Vocabulary的大小

    def __len__(self):
        return len(self._token_to_idx)  # 返回token到索引的映射的长度
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))  # 返回SurnameDataset实例和矢量化器

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
        """加载数据集和相应的矢量化器。 
        在矢量化器已经被缓存以供再次使用的情况下使用
        
        Args:
            surname_csv (str): 数据集的位置
            vectorizer_filepath (str): 保存的矢量化器的位置
        Returns:
            SurnameDataset的一个实例
        """
        surname_df = pd.read_csv(surname_csv)  # 读取数据集
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)  # 加载矢量化器
        return cls(surname_df, vectorizer)  # 返回SurnameDataset实例和矢量化器

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """从文件中加载矢量化器的静态方法
        
        Args:
            vectorizer_filepath (str): 序列化矢量化器的位置
        Returns:
            SurnameVectorizer的一个实例
        """
        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_surname)
                标签 (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):
        """给定批量大小,返回数据集中的批次数
        
        Args:
            batch_size (int)
        Returns:
            数据集中的批次数
        """
        return len(self) // batch_size  # 返回批次数

    
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"): 
    """
    一个生成器函数,包装了PyTorch的DataLoader。它将确保每个张量位于正确的设备位置上。
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)  # 创建DataLoader

    for data_dict in dataloader:  # 遍历数据加载器
        out_data_dict = {}  # 初始化输出字典
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)  # 将张量移动到指定设备
        yield out_data_dict  # 生成输出字典

1.3.3 The Surname Classifier Model
  SurnameClassifier是本实验的MLP的实现。第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。

  交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。

Example 1-5. The SurnameClassifier as an MLP

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

class SurnameClassifier(nn.Module):
    """用于分类姓氏的2层多层感知器"""
    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        参数:
            input_dim (int): 输入向量的大小
            hidden_dim (int): 第一个全连接层的输出大小
            output_dim (int): 第二个全连接层的输出大小
        """
        super(SurnameClassifier, self).__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)  # 第一个全连接层
        self.fc2 = nn.Linear(hidden_dim, output_dim)  # 第二个全连接层

    def forward(self, x_in, apply_softmax=False):
        """分类器的前向传播
        
        参数:
            x_in (torch.Tensor): 输入数据张量。x_in.shape应为(batch, input_dim)
            apply_softmax (bool): softmax激活的标志
                如果与交叉熵损失一起使用,应为假
        返回:
            结果张量。张量形状应为(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  # 返回预测向量
1.3.4 The Training Routine

Example 1-6. The Training

def make_train_state(args):
    return {'stop_early': False,  # 控制早停
            'early_stopping_step': 0,
            'early_stopping_best_val': 1e8,  # 最佳验证集损失
            'learning_rate': args.learning_rate,  # 学习率
            'epoch_index': 0,  # 当前轮数
            'train_loss': [],  # 训练损失
            'train_acc': [],  # 训练准确率
            'val_loss': [],  # 验证集损失
            'val_acc': [],  # 验证集准确率
            'test_loss': -1,  # 测试集损失
            'test_acc': -1,  # 测试集准确率
            'model_filename': args.model_state_file}  # 模型文件名

def update_train_state(args, model, train_state):
    """处理训练状态更新。
    
    组件:
     - 早停: 防止过拟合
     - 模型检查点: 如果模型更好则保存模型
    
    参数:
        args: 主要参数
        model: 要训练的模型
        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):
    np.random.seed(seed)  # 设置NumPy的随机种子
    torch.manual_seed(seed)  # 设置PyTorch的随机种子
    if cuda:  # 如果使用CUDA
        torch.cuda.manual_seed_all(seed)  # 设置所有GPU设备的随机种子

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():  # 如果CUDA不可用
    args.cuda = False  # 设置为不使用CUDA

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)  # 处理目录
扩展后的文件路径: 
	model_storage/ch4/surname_mlp/vectorizer.json
	model_storage/ch4/surname_mlp/model.pth
使用CUDA: False
if args.reload_from_files:
    # 从检查点加载和训练
    print("重新加载!")
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(args.surname_csv,
                                                              args.vectorizer_file)  # 从文件中加载数据集和矢量化器
else:
    # 创建数据集和矢量化器
    print("创建新的!")
    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))  # 创建分类器

THE TRAINING LOOP

与“Example: Classifying Sentiment of Restaurant Reviews”中的训练循环相比,本例的训练循环除了变量名以外几乎是相同的。具体来说,示例4-10显示了使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。

Example 4-10. A snippet of the training loop

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

loss_func = nn.CrossEntropyLoss(dataset.class_weights)  # 定义交叉熵损失函数
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)  # 定义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):  # 循环每个epoch
        train_state['epoch_index'] = epoch_index  # 设置当前epoch
        
        # 迭代训练数据集

        # 设置: 生成批次, 将损失和准确率设为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.to("cpu").item()
            running_loss += (loss_t - running_loss) / (batch_index + 1)

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

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

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

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

        if train_state['stop_early']:
            break

        train_bar.n = 0
        val_bar.n = 0
        epoch_bar.update()
except KeyboardInterrupt:
    print("退出循环")

# 使用最佳可用模型计算测试集上的损失和准确率

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("测试损失: {};".format(train_state['test_loss']))  # 打印测试集损失
print("测试准确率: {}".format(train_state['test_acc']))  # 打印测试集准确率

3.1.5 Model Evaluation and Prediction

要理解模型的性能,应该使用定量和定性方法分析模型。定量测量出的测试数据的误差,决定了分类器能否推广到不可见的例子。定性地说,可以通过查看分类器的top-k预测来为一个新示例开发模型所了解的内容的直觉。

3.1.5.1 EVALUATING ON THE TEST DATASET

评价SurnameClassifier测试数据,我们执行相同的常规的routine文本分类的例子“餐馆评论的例子:分类情绪”:我们将数据集设置为遍历测试数据,调用classifier.eval()方法,并遍历测试数据以同样的方式与其他数据。在这个例子中,调用classifier.eval()可以防止PyTorch在使用测试/评估数据时更新模型参数。

该模型对测试数据的准确性达到50%左右。如果在附带的notebook中运行训练例程,会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据,所以训练数据的性能并不代表新数据的性能。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。然而,这种增长不会很大(尤其是与“用CNN对姓氏进行分类的例子”中的模型相比)。其主要原因是收缩的onehot向量化方法是一种弱表示。虽然它确实简洁地将每个姓氏表示为单个向量,但它丢弃了字符之间的顺序信息,这对于识别起源非常重要。

3.1.5.2 CLASSIFYING A NEW SURNAME

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

Example 4-11. A function for performing nationality prediction

def predict_nationality(surname, classifier, vectorizer):
    """预测一个新姓氏的国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的矢量化器
    返回:
        一个包含最可能的国籍及其概率的字典
    """
    vectorized_surname = vectorizer.vectorize(surname)  # 矢量化新的姓氏
    vectorized_surname = torch.tensor(vectorized_surname).view(1, -1)  # 转为张量并reshape为合适形状 
    result = classifier(vectorized_surname, apply_softmax=True)  # 获取预测结果并应用softmax
    
    probability_values, indices = result.max(dim=1)  # 获取最大概率及索引
    index = indices.item()  # 获取索引的值

    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)  # 获取预测的国籍
    probability_value = probability_values.item()  # 获取概率值

    return {'nationality': predicted_nationality, 'probability': probability_value}  # 返回预测的国籍及概率
new_surname = input("输入要分类的姓氏: ")  # 接收要分类的姓氏输入
classifier = classifier.to("cpu")  # 将分类器移动到CPU设备
prediction = predict_nationality(new_surname, classifier, vectorizer)  # 进行国籍预测
print("{} -> {} (p={:0.2f})".format(new_surname,  # 打印预测结果
                                    prediction['nationality'],
                                    prediction['probability']))
李 -> Korean (p=0.36)

3.1.5.3 RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME

不仅要看最好的预测,还要看更多的预测。例如,NLP中的标准实践是采用k-best预测并使用另一个模型对它们重新排序。PyTorch提供了一个torch.topk函数,它提供了一种方便的方法来获得这些预测,如示例4-12所示。

Example 4-12. Predicting the top-k nationalities

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

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

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

print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))
Top 3 predictions:
===================
李 -> Korean (p=0.36)
李 -> Chinese (p=0.34)
李 -> Vietnamese (p=0.17)

3.1.6 Regularizing MLPs: Weight Regularization and Structural Regularization (or Dropout)

在实验3中,我们解释了正则化是如何解决过拟合问题的,并研究了两种重要的权重正则化类型——L1和L2。这些权值正则化方法也适用于MLPs和卷积神经网络,我们将在本实验后面介绍。除权值正则化外,对于深度模型(即例如本实验讨论的前馈网络,一种称为dropout的结构正则化方法变得非常重要。

DROPOUT

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

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

Example 4-13. MLP with dropout

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

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


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

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

print("Top {} predictions:".format(k))  # 打印前k个预测结果的标题
print("===================")
for prediction in predictions:  # 对每个预测结果进行遍历
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))  # 打印姓氏、国籍和概率值的格式化字符串
Top 3 predictions:
===================
李 -> Korean (p=0.36)
李 -> Chinese (p=0.34)
李 -> Vietnamese (p=0.17)

二、Convolutional Neural Networks(卷积神经网络)
  卷积神经网络(Convolutional Neural Networks, CNN)是一类包含卷积计算且具有深度结构的前馈神经网络(Feedforward Neural Networks),是深度学习(deep learning)的代表算法之一 。卷积神经网络具有表征学习(representation learning)能力,能够按其阶层结构对输入信息进行平移不变分类(shift-invariant classification),因此也被称为“平移不变人工神经网络(Shift-Invariant Artificial Neural Networks, SIANN)” 。

  为了理解不同的设计决策对CNN意味着什么,在本例中,单个“核”应用于输入矩阵。卷积运算(线性算子)的精确数学表达式对于理解这一节并不重要,但是从这个图中可以直观地看出,核是一个小的方阵,它被系统地应用于输入矩阵的不同位置。

输入矩阵与单个产生输出矩阵的卷积核(也称为特征映射)在输入矩阵的每个位置应用内核。在每个应用程序中,内核乘以输入矩阵的值及其自身的值,然后将这些乘法相加kernel具有以下超参数配置:kernel_size=2,stride=1,padding=0,以及dilation=1。

2.1 Implementing CNNs in PyTorch

Example 2-1. Artificial data and using a Conv1d class

batch_size = 2
one_hot_size = 10
sequence_width = 7
data = torch.randn(batch_size, one_hot_size, sequence_width)  # 生成随机数据张量

conv1 = nn.Conv1d(in_channels=one_hot_size, out_channels=16, kernel_size=3)  # 创建一维卷积层
intermediate1 = conv1(data)  # 对数据进行一维卷积操作

print(data.size())  # 打印生成的数据张量大小
print(intermediate1.size())  # 打印经过一维卷积后的张量大小
torch.Size([2, 10, 7])
torch.Size([2, 16, 5])

2.2 Classifying Surnames by Using a CNN
2.2.1 The SurnameDataset
    虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述,但建议参考“姓氏数据集”来了解它的描述。尽管使用了相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。示例2-2对SurnameDataset.__getitem__进行了更改;同时对SurnameVectorizer的进行了更改。

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

conv2 = nn.Conv1d(in_channels=16, out_channels=32, kernel_size=3)  # 创建第二个一维卷积层
conv3 = nn.Conv1d(in_channels=32, out_channels=64, kernel_size=3)  # 创建第三个一维卷积层

intermediate2 = conv2(intermediate1)  # 对第一次卷积后的结果进行第二次卷积操作
intermediate3 = conv3(intermediate2)  # 对第二次卷积后的结果进行第三次卷积操作

print(intermediate2.size())  # 打印第二次卷积后的张量大小
print(intermediate3.size())  # 打印第三次卷积后的张量大小

y_output = intermediate3.squeeze()  # 压缩张量维度,去除大小为1的维度
print(y_output.size())  # 打印压缩后的张量大小

intermediate2.mean(dim=0).mean(dim=1).sum()
torch.Size([2, 32, 3])
torch.Size([2, 64, 1])
torch.Size([2, 64])
tensor(-0.8476, grad_fn=<SumBackward0>)

2.2.2 Vocabulary, Vectorizer, and DataLoader
   尽管词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。我们将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。

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

Example 2-3. Implementing the Surname Vectorizer for CNNs

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>"):
        """
        参数:
            token_to_idx (dict): 一个预先存在的token到索引的映射字典
            add_unk (bool): 一个指示是否添加UNK标记的标志
            unk_token (str): 要添加到词汇表中的UNK标记
        """

        if token_to_idx is None:  # 如果token_to_idx为None,则初始化为空字典
            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标记
        self._unk_token = unk_token  # UNK标记
        
        self.unk_index = -1
        if add_unk:  # 如果需要添加UNK标记
            self.unk_index = self.add_token(unk_token)  # 添加UNK标记

    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]  # 尝试从token到索引的映射中获取索引
        except KeyError:
            index = len(self._token_to_idx)  # 如果不存在,将其长度作为新的索引
            self._token_to_idx[token] = index  # 将token添加到token到索引的映射中
            self._idx_to_token[index] = token  # 将token添加到索引到token的映射中
        return index  # 返回索引
    
    def add_many(self, tokens):
        """将一个token列表添加到词汇表中
        
        参数:
            tokens (list): 一个包含字符串token的列表
        返回:
            indices (list): 与tokens对应的索引列表
        """
        return [self.add_token(token) for token in tokens]  # 对tokens中的每个token调用add_token方法,并返回索引列表

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

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

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

    def __len__(self):
        return len(self._token_to_idx)
class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        """
        Args:
            surname_df (pandas.DataFrame): 数据集
            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

2.2.3 Reimplementing the SurnameClassifier with Convolutional Networks
  本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。

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

Example 2-3. The CNN-based SurnameClassifier

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

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

    def forward(self, x_surname, apply_softmax=False):
        """分类器的前向传播
        
        参数:
            x_surname (torch.Tensor): 输入数据张量。
                x_surname.shape 应为 (batch, initial_num_channels, max_surname_length)
            apply_softmax (bool): softmax激活的标志
                如果与交叉熵损失一起使用,应为false
        返回:
            结果张量。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
2.2.4 The Training Routine

Example 2-4. Input arguments to the CNN surname classifier

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)[1]
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    return n_correct / len(y_pred_indices) * 100
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
)

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

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)
扩展后的文件路径: 
	model_storage/ch4/cnn/vectorizer.json
	model_storage/ch4/cnn/model.pth
使用CUDA: False
2.2.5 Model Evaluation and Prediction

Example 2-5. Using the trained model to make predictions

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)
    # 使用分类器进行预测
    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(如果之前在GPU上)
classifier = classifier.cpu()
# 使用预测函数进行预测
prediction = predict_nationality(new_surname, classifier, vectorizer)
# 打印预测结果
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))
def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    """预测给定姓氏的前 K 个国籍
    
    参数:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
        k (int): 要返回的前 K 个国籍的数量
    返回:
        包含字典的列表,每个字典表示一个国籍和其概率
    """
    
    # 将姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    # 将向量化的姓氏转换为张量,并在第0维上添加一维
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)
    # 使用分类器进行预测
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)
    # 获取前K个概率值和对应的索引
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # 返回的大小是1,k
    probability_values = probability_values[0].detach().numpy()
    indices = indices[0].detach().numpy()
    
    results = []
    for kth_index in range(k):
        # 根据索引获取国籍
        nationality = vectorizer.nationality_vocab.lookup_index(indices[kth_index])
        # 获取概率值
        probability_value = probability_values[kth_index]
        results.append({'nationality': nationality, 
                        'probability': probability_value})
    return results

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

# 获取要查看的前K个预测的数量
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)
    
# 进行国籍预测
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']))
Top 3 predictions:
===================
李 -> Korean (p=0.36)
李 -> Chinese (p=0.34)
李 -> Vietnamese (p=0.17)

总结
  本文主要讨论了多层感知机(MLP)和卷积神经网络(CNN)在处理姓氏分类问题的应用与表现。

  感知机是一个单层神经网络,感知机作为最基本的神经网络模型,由输入层、权重和输出层组成;适用于线性可分的数据集。能够对线性可分的数据进行分类。其输出是输入特征的线性组合经过一个激活函数处理后的结果。

  相比于感知机,MLP包含一个或多个隐藏层,使用非线性激活函数,使其能够解决更复杂的分类问题。然而,对于具有空间结构的数据(如图像)效率不高。

  CNN是一种专门处理具有空间结构数据的神经网络,通过卷积操作提取局部特征,并通过池化操作减少特征维度,最终使用全连接层进行分类或回归。相比于MLP,能有效捕捉数据中的空间特征,参数共享和局部连接减少了模型的计算复杂度,在图像分类、目标检测等任务中表现优异。

  总的来看,感知机适合线性数据,MLP适合复杂但结构化数据,CNN适合具有空间结构的数据。希望本文的介绍能让读者对感知机、MLP和CNN有更深入的理解,并能够在实际项目中灵活应用这些模型,解决实际问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值