使用前馈神经网络进行姓氏分类

一、理论基础与模型介绍

1.深度学习基础概念

1.1 感知器及多层感知机

1.1.1 感知机

1.1.2 多层感知机

1.2 激活函数

1.2.1 sigmoid激活函数

1.2.2 tanh激活函数

1.2.3 ReLU激活函数

2.卷积神经网络基础

2.1 卷积操作原理

2.1.1 卷积核(Filter)

2.1.2 步长(Stride)

2.1.3 填充(Padding)

2.1.4 通道(Channels)

2.1.5 膨胀/空洞卷积(Dilation)

2.2 卷积神经网络结构及特点

2.2.1 卷积神经网络结构

2.2.1.1 卷积层(Convolutional Layer)

2.2.1.2 池化层(Pooling Layer)

2.2.1.3 激活函数(Activation Function)

2.2.1.4 全连接层(Fully Connected Layer)

2.2.1.5 输出层(Output Layer)

2.2.2 卷积神经网络特点

2.2.2.1 参数共享(Parameter Sharing)

2.2.2.2 局部感受野(Local Receptive Fields)

2.2.2.3平移不变性(Translation Invariance)

2.2.2.4逐级抽象(Hierarchical Feature Learning)

2.2.2.5 数据增强(Data Augmentation)

二、基于多层感知机的姓氏分类实验

 2.1 姓氏数据集

2.2 词汇表和向量化器

2.2.1 词汇表

2.2.2 向量化器

2.3 姓氏分类器模型

2.4 训练过程

2.5 模型评估和预测

 2.5.1 在测试数据集上评估

2.5.2 对新姓氏进行分类

2.5.3 检索新姓氏的前k个预测结果

三、基于卷积神经网络的姓氏分类实验

3.1 姓氏数据集

 3.2 词汇表和向量化器

3.2.1 词汇表

3.2.2 向量化器

3.3 使用卷积网络重新实现姓氏分类器

 3.4 训练过程

3.5 模型评估和预测

3.5.1 在测试数据集上评估

3.5.2 对新姓氏进行分类或检索前k个预测结果

3.5.3检索新姓氏的前k个预测结果

四、实验总结


一、理论基础与模型介绍

1.深度学习基础概念

1.1 感知器及多层感知机

1.1.1 感知机

最基本的神经网络单元是感知器。感知器最初模仿了生物神经元的结构。类似于生物神经元,感知器有输入和输出,信息从输入传递到输出,如下图所示。

每个感知器单元包括一个输入 ( x ),一个输出 ( y ),以及三个调节参数(knobs):一组权重 ( w ),偏置 ( b ),和一个激活函数 ( f )。这些权重和偏置通过数据学习而来,激活函数的选择通常基于网络设计师的直觉和目标输出。在数学上,我们可以表示为:

                                                                y=f(\omega x+b)

通常情况下,感知器具有多个输入。我们可以用向量表示这种一般情况;即,( x ) 和 ( w ) 是向量,( w ) 和 ( x ) 的乘积被替换为点积:

                                                               y=f( \underset{\omega }{\rightarrow}T\underset{x}{\rightarrow} +b)

在这里,激活函数 ( f ) 通常是一个非线性函数。

1.1.2 多层感知机

MLP是神经网络中最基本的构建模块之一,它由多个感知器组成的层组合而成。这些感知器接受数据向量作为输入,并输出值。在PyTorch中,通过设置线性层的输出特性数量来实现。MLP还结合了多个层之间的非线性。下图展示了一个简单的MLP,包括三个表示阶段和两个线性层。输入向量是Yelp评论的压缩one-hot表示。第一个线性层计算隐藏向量,即第二阶段的表示。隐藏向量的值由构成该层的不同感知器的输出组成。第二个线性层计算输出向量。在二进制任务中,输出向量可能是一个值;在多类别设置中,维度将等于类别的数量。尽管只展示了一个隐藏向量,但实际上可能有多个中间阶段,每个阶段都产生自己的隐藏向量。最终的隐藏向量通过线性层和非线性函数的组合映射到输出向量。

1.2 激活函数

1.2.1 sigmoid激活函数

sigmoid 是神经网络历史上最早使用的激活函数之一。它取任何实值并将其压缩在0和1之间。数学上,sigmoid 的表达式如下:

   y=\frac{1}{1+e^{-x}}

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1)   #生成了一个范围在-5到5之间、步长为0.1的一维张量x
y = torch.sigmoid(x)  #通过Sigmoid函数计算了每个x值对应的y值
plt.plot(x.numpy(), y.numpy())
plt.show()

该函数具有如下的特性:当x趋近于负无穷时,y趋近于0;当x趋近于正无穷时,y趋近于1;当x=0时,y=1/2.

1.2.2 tanh激活函数

tanh激活函数是一种常用的非线性激活函数,通常用于神经网络中的隐藏层。其数学定义为:

                                                                   y=\frac{e^{x}-e^{-x}}{e^{x}+e^{-x}}

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1)   #生成了一个范围在-5到5之间、步长为0.1的一维张量x
y = torch.tanh(x)   #计算每个x值对应的双曲正切(tanh)函数的值。
plt.plot(x.numpy(), y.numpy())
plt.show()

tanh函数的输出范围在[-1, 1]之间,在输入为0时,输出值为0;在输入为正时,输出值趋近于1;在输入为负时,输出值趋近于-1。与sigmoid函数相比,tanh函数的输出以0为中心,这使得其在神经网络中更易于处理正负值。

1.2.3 ReLU激活函数

ReLU激活函数是一种非线性函数,能够使神经网络具备学习复杂模式的能力,其数学定义为:

f=max(0,x)

import torch
import matplotlib.pyplot as plt

relu = torch.nn.ReLU()  #首先创建一个ReLU对象
x = torch.range(-5., 5., 0.1)   #生成了一个范围在-5到5之间、步长为0.1的一维张量x
y = relu(x)  #将每个x值通过ReLU激活函数进行处理,得到对应的输出值y

plt.plot(x.numpy(), y.numpy())
plt.show()

简单来说,ReLU函数将所有负数的输入值映射为0,而将所有正数的输入值保持不变。这使得ReLU函数在计算上非常高效,同时在深度神经网络中表现良好。


2.卷积神经网络基础


2.1 卷积操作原理

卷积操作是深度学习中一种常用的操作,尤其是在处理图像数据时。它主要用于提取输入数据中的特征。下面是卷积操作的基本原理:

2.1.1 卷积核(Filter)

卷积操作的核心是卷积核,也称为滤波器或特征检测器。卷积核是一个小的窗口,它在输入数据上滑动并与其进行逐元素相乘和相加。卷积核的大小通常是一个正方形或矩形,其大小可以根据需要进行调整。卷积核中的权重参数会在训练过程中学习,以便从输入数据中提取出有用的特征。

2.1.2 步长(Stride)

在执行卷积操作时,卷积核以一定的步幅在输入数据上滑动。步幅定义了每次移动的距离,它可以是1、2或其他正整数。

2.1.3 填充(Padding)

在对输入数据进行卷积操作之前,通常会在输入数据的边界上添加一些额外的值(通常是0),这称为填充。填充可以帮助控制输出的大小,并且有时候可以改善特征提取的效果。

其作用为:保持输入和输出的尺寸一致

2.1.4 通道(Channels)

对于多通道的输入数据(例如彩色图像具有RGB三个通道),每个通道都有自己的卷积核,卷积核在每个通道上进行操作,最后的输出是所有通道输出的叠加。通过使用多个卷积核,可以在输入数据中检测到不同的特征。这些特征可以是边缘、纹理、颜色等。卷积操作的输出通常会经过激活函数(如ReLU)来引入非线性,然后再传递到下一层网络进行进一步处理,如池化、全连接等操作。

2.1.5 膨胀/空洞卷积(Dilation)

Dilation是一种控制卷积核如何在输入矩阵上应用的技术。将膨胀从1(默认值)增加到2意味着当卷积核应用于输入矩阵时,核的元素之间会相隔两个空格。另一种思考这个问题的方式是通过在核中跨步——在核的元素或核的应用之间存在一个步长,即存在“holes”。这种方法对于在不增加参数数量的情况下总结输入空间的更大区域很有用。当卷积层被堆叠时,扩张卷积被证明是非常有用的。连续扩张的卷积指数级地增大了“接受域”的大小,即在网络做出预测之前所观察到的输入空间范围。

2.2 卷积神经网络结构及特点

卷积神经网络(Convolutional Neural Network,CNN)是一种深度学习模型,特别适用于处理具有网格结构的数据,如图像和视频。CNN在图像识别、目标检测、语义分割等领域取得了巨大成功。以下是CNN的结构和特点:

2.2.1 卷积神经网络结构
2.2.1.1 卷积层(Convolutional Layer)

卷积层是CNN的核心部分,由多个卷积核组成。每个卷积核在输入数据上滑动,提取出不同的特征。卷积操作通过卷积核在输入数据上的滑动和相乘相加来实现。卷积层通常包含多个过滤器,每个过滤器可以学习不同的特征。下图为卷积层过滤器(filter)结构示意图:

卷积过程如下,其中Input矩阵是像素点矩阵,Kernel矩阵是过滤器(filter):

2.2.1.2 池化层(Pooling Layer)

池化层用于降低特征图的空间维度,减少参数数量,并提取最重要的特征。常用的池化操作包括最大池化(Max Pooling)和平均池化(Average Pooling)。

2.2.1.3 激活函数(Activation Function)

在卷积层后通常会添加激活函数,如ReLU、Leaky ReLU等,以引入非线性,增强模型的表达能力。

2.2.1.4 全连接层(Fully Connected Layer)

全连接层将卷积层或池化层的输出展平为一维向量,并与全连接层连接。全连接层负责对特征进行组合和分类。

2.2.1.5 输出层(Output Layer)

输出层通常是一个全连接层,用于生成模型的输出,例如图像的类别标签或回归值。

2.2.2 卷积神经网络特点
2.2.2.1 参数共享(Parameter Sharing)

在CNN中,每个卷积核的参数都被多次使用,这样可以大大减少需要学习的参数数量,从而降低了过拟合的风险。

2.2.2.2 局部感受野(Local Receptive Fields)

每个神经元仅与输入数据的一个局部区域连接,这样使得网络对局部特征的变化更为敏感,有利于提取局部特征。

2.2.2.3平移不变性(Translation Invariance)

由于卷积操作是对输入数据的局部区域进行滑动,所以CNN具有一定程度上的平移不变性,即对于同一特征的不同位置,模型可以识别出相同的特征。

2.2.2.4逐级抽象(Hierarchical Feature Learning)

CNN通过堆叠多个卷积层和池化层来逐级提取数据的抽象特征,从低级特征(如边缘、纹理)到高级特征(如形状、物体部分),从而实现对数据的层次化表示和理解。

2.2.2.5 数据增强(Data Augmentation)

由于卷积层的局部感受野和参数共享特性,CNN对于数据的变化具有一定的鲁棒性,因此可以通过数据增强技术来扩充训练数据,提高模型的泛化能力。

二、基于多层感知机的姓氏分类实验

本实验我们将使用MLP来将姓氏分类到其原籍国家。通过公开数据,我们发现人口统计信息(如国籍)在各种应用中都很重要,从产品推荐到确保不同人口统计用户获得公平结果。我们首先对每个姓氏进行字符级拆分,类似于之前在情感分类示例中对待单词的方式。虽然模型是基于字符的,但在结构和实现上与基于单词的模型基本相似。
从这个例子中,我们将从描述姓氏数据集及其预处理步骤开始,然后逐步介绍使用词汇表、向量化器和DataLoader类完成姓氏字符串到向量化小批处理的过程。接下来,我们将描述姓氏分类器模型及其设计背后的思想过程。在这个例子中,我们引入了多类输出及其对应的损失函数。在描述了模型之后,我们将完成训练例程。训练程序与情感分类示例非常相似,但为了简洁起见,这里不会深入到每个细节。

 2.1 姓氏数据集

研究的初衷是为了探究姓氏与国籍之间的潜在联系,从而构建一个能够准确预测姓氏背后国家或地区的模型。我们收集了来自18个国家的10,000个姓氏数据,并对其进行了细致的调整,以消除可能存在的不平衡性,确保数据的客观性和准确性。这些数据被划分为训练、验证和测试数据集,以确保模型的可靠性和泛化能力。通过深入分析姓氏与国籍之间的关系,我们希望能够揭示不同国家间姓氏的共同特征和差异,从而为跨文化研究和社会科学领域提供新的视角和方法。
下面为该部分代码:

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.surname_df, len(self.surname_df)),  # 假设使用全部数据作为训练集

        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.2 词汇表和向量化器

为了对姓氏进行分类,我们采用了与“示例:对餐厅评论的情感进行分类”相同的数据结构,即词汇表和向量化器。这种方法展示了一种多态性,它将姓氏的字符标记与Yelp评论的单词标记同等对待。与将单词令牌映射到整数不同,我们将字符映射到整数来向量化数据。

2.2.1 词汇表

词汇表类在这个例子中与“示例:对餐厅评论的情感进行分类”中的词汇表完全相同。它将Yelp评论中的单词映射到相应的整数。简而言之,词汇表由两个Python字典组成,这两个字典之间形成了一个双射,将字符映射到整数索引。addtoken方法用于向词汇表中添加新的令牌,lookuptoken方法用于检索索引,lookup_index方法用于检索给定索引的令牌(在推断阶段非常有用)。与Yelp评论的词汇表不同,我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这是因为数据集很小,大多数字符都足够频繁。

下面为该部分代码:

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 = {}
        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):
        """ 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]
        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):
        """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]

    def lookup_token(self, token):
        """Retrieve the index associated with the token 
          or the UNK index if token isn't present.
        
        Args:
            token (str): the token to look up 
        Returns:
            index (int): the index corresponding to the token
        Notes:
            `unk_index` needs to be >=0 (having been added into the Vocabulary) 
              for the UNK functionality 
        """
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        """Return the token associated with the index
        
        Args: 
            index (int): the index to look up
        Returns:
            token (str): the token corresponding to the index
        Raises:
            KeyError: if the index is not in the Vocabulary
        """
        if index not in self._idx_to_token:
            raise KeyError("the index (%d) is not in the Vocabulary" % index)
        return self._idx_to_token[index]

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

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

2.2.2 向量化器

SurnameVectorizer将姓氏转换为向量,与ReviewVectorizer类似,但不同之处在于它不会在空格上分割字符串。姓氏被视为字符序列,每个字符都在词汇表中有一个单独的标记。在创建输入的紧缩one-hot向量表示时,我们忽略了序列信息,而是通过迭代姓氏中的每个字符来完成。对于以前未见过的字符,我们使用特殊标记UNK。由于词汇表是从训练数据中实例化的,而验证或测试数据可能包含唯一的字符,因此在字符词汇表中仍然使用UNK标记。
在本示例中,我们使用了一种收缩的one-hot编码方法,但在后续实验中,我们将了解其他向量化方法,它们是one-hot编码的替代方法,有时效果更佳。例如,在“示例:使用CNN对姓氏进行分类”中,我们将介绍一种热门矩阵方法,其中每个字符都在矩阵中占据一个位置,并具有自己的热门向量。

下面为该部分代码:

class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""
    def __init__(self, surname_vocab, nationality_vocab):
        """
        Args:
            surname_vocab (Vocabulary): maps characters to integers
            nationality_vocab (Vocabulary): maps nationalities to integers
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

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

        Returns:
            one_hot (np.ndarray): a collapsed one-hot encoding 
        """
        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1

        return one_hot

    @classmethod
    def from_dataframe(cls, surname_df):
        """Instantiate the vectorizer from the dataset dataframe
        
        Args:
            surname_df (pandas.DataFrame): the surnames dataset
        Returns:
            an instance of the SurnameVectorizer
        """
        surname_vocab = Vocabulary(unk_token="@")
        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()}

2.3 姓氏分类器模型

SurnameClassifier是一个多层感知器的实现,其中包含两个线性层。第一个线性层将输入向量映射到中间向量,并应用非线性函数对其进行变换。第二个线性层将中间向量映射到预测向量。
在模型的最后一步,我们可以选择应用softmax操作,以确保输出的概率和为1。这个操作使得模型的输出可以被解释为类别的概率。应用softmax的原因与我们所使用的损失函数有关,即交叉熵损失。我们已经学习了交叉熵损失函数,它在多类别分类问题中是最理想的选择。然而,值得注意的是,在训练过程中,计算softmax的软最大值操作不仅会造成计算开销,而且在某些情况下可能不稳定。

下面为该部分代码:

class SurnameClassifier(nn.Module):
    """ 姓氏分类器的两层多层感知器 """

    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        Args:
            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):
        """分类器的前向传播

        Args:
            x_in (torch.Tensor): 输入数据张量。x_in.shape 应该是 (batch, input_dim)
            apply_softmax (bool): 是否应用 softmax 激活。如果与交叉熵损失一起使用,应为 False

        Returns:
            结果张量。tensor.shape 应该是 (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

2.4 训练过程

虽然我们使用了不同的模型、数据集和损失函数,但是训练例程是相同的。

下面为该部分代码:

args = {
    # 数据和路径信息
    'surname_csv': 'surnames.csv',  # 姓氏数据的CSV文件路径
    'vectorizer_file': 'vectorizer.json',  # 向量化器的保存路径
    'model_state_file': 'model.pth',  # 模型状态的保存路径
    'save_dir': 'model_storage/ch4/surname_mlp',  # 模型保存目录
    # 模型超参数
    'hidden_dim': 300,  # 隐藏层的维度
    # 训练超参数
    'seed': 1337,  # 随机种子
    'num_epochs': 100,  # 训练轮数
    'early_stopping_criteria': 5,  # 提前停止的条件
    'learning_rate': 0.001,  # 学习率
    'batch_size': 64,  # 批量大小
    # 省略了运行时选项以节省空间
}

训练中最显著的差异与模型中输出的种类和使用的损失函数有关。在这个例子中,输出是一个多类预测向量,可以转换为概率。正如在模型描述中所描述的,这种输出的损失类型仅限于CrossEntropyLoss和NLLLoss。由于它的简化,我们使用了CrossEntropyLoss。

下面展示了数据集、模型、损失函数和优化器的实例化,代码如下:

def create_surname_vocab(data):
    # 从数据中提取姓氏,并创建姓氏词汇表
    surname_vocab = set()
    for example in data:
        surname = example['surname']
        surname_vocab.add(surname)
    return list(surname_vocab)

# 现在 train_df, val_df, test_df 分别包含了训练集、验证集和测试集的数据

csv_file_path = 'surnames.csv'

# 使用 pandas 的 read_csv 函数加载数据
surname_df = pd.read_csv(csv_file_path)
# 使用 train_test_split 进行数据分割
train_df, test_df = train_test_split(surname_df, test_size=0.2, random_state=42)  # 80% 训练集,20% 测试集
val_df, test_df = train_test_split(test_df, test_size=0.5, random_state=42)  # 从测试集中分割出验证集和测试集
# 创建 SurnameVectorizer 实例
vectorizer = SurnameVectorizer.from_dataframe(train_df)  # 通常使用训练集来创建向量化器

# 创建 SurnameDataset 实例
train_dataset = SurnameDataset(train_df, vectorizer)
val_dataset = SurnameDataset(val_df, vectorizer)
test_dataset = SurnameDataset(test_df, vectorizer)

# 创建 DataLoader 对象
batch_size = 64
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

# 现在 surname_df 是一个包含 CSV 文件数据的 pandas DataFrame

vectorizer = SurnameVectorizer.from_dataframe(surname_df)

args = {
    # 数据和路径信息
    'surname_csv': 'surnames.csv',  # 姓氏数据的CSV文件路径
    'vectorizer_file': 'vectorizer.json',  # 向量化器的保存路径
    'model_state_file': 'model.pth',  # 模型状态的保存路径
    'save_dir': 'model_storage/ch4/surname_mlp',  # 模型保存目录
    # 模型超参数
    'hidden_dim': 300,  # 隐藏层的维度
    # 训练超参数
    'seed': 1337,  # 随机种子
    'num_epochs': 100,  # 训练轮数
    'early_stopping_criteria': 5,  # 提前停止的条件
    'learning_rate': 0.001,  # 学习率
    'batch_size': 64,  # 批量大小
    # 省略了运行时选项以节省空间
}


# 使用 vectorizer 对象创建 SurnameClassifier 实例
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                               hidden_dim=args['hidden_dim'],
                               output_dim=len(vectorizer.nationality_vocab))

这个训练循环和Example: Classifying Sentiment of Restaurant Reviews基本一样,只是变量名不同。它从批次数据中获取信息,计算模型的输出、损失和梯度,然后利用梯度来更新模型,该部分代码如下:

import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
import torch
from torch.utils.data import Dataset, DataLoader
class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        self.surname_df = surname_df
        self._vectorizer = vectorizer
        self.class_counts = self.surname_df.nationality.value_counts().to_dict()
        self.frequencies = [self.class_counts[item] for item in self._vectorizer.nationality_vocab._token_to_idx]

        # 计算类别权重
        self.class_weights = 1.0 / torch.tensor(self.frequencies, dtype=torch.float32)

    def __getitem__(self, index):
        row = self.surname_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 __len__(self):
        return len(self.surname_df) 
def generate_batches(dataset, batch_size, shuffle=True,
                     drop_last=True, device="cpu"): 
    """
    A generator function which wraps the PyTorch DataLoader. It will 
      ensure each tensor is on the write device location.
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size,
                            shuffle=shuffle, drop_last=drop_last)

    for data_dict in dataloader:
        out_data_dict = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict
class SurnameClassifier(nn.Module):
    """ 姓氏分类器的两层多层感知器 """

    def __init__(self, input_dim, hidden_dim, output_dim):
        """
        Args:
            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):
        """分类器的前向传播

        Args:
            x_in (torch.Tensor): 输入数据张量。x_in.shape 应该是 (batch, input_dim)
            apply_softmax (bool): 是否应用 softmax 激活。如果与交叉熵损失一起使用,应为 False

        Returns:
            结果张量。tensor.shape 应该是 (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

class SurnameVectorizer(object):
    """ The Vectorizer which coordinates the Vocabularies and puts them to use"""
    def __init__(self, surname_vocab, nationality_vocab):
        """
        Args:
            surname_vocab (Vocabulary): maps characters to integers
            nationality_vocab (Vocabulary): maps nationalities to integers
        """
        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

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

        Returns:
            one_hot (np.ndarray): a collapsed one-hot encoding 
        """
        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1

        return one_hot

    @classmethod
    def from_dataframe(cls, surname_df):
        """Instantiate the vectorizer from the dataset dataframe
        
        Args:
            surname_df (pandas.DataFrame): the surnames dataset
        Returns:
            an instance of the SurnameVectorizer
        """
        surname_vocab = Vocabulary(unk_token="@")
        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()}
# 定义损失函数,这里以交叉熵损失为例
loss_func = nn.CrossEntropyLoss()
number_of_nationalities = 18
classifier = SurnameClassifier(input_dim=82, hidden_dim=300, output_dim=number_of_nationalities)
csv_file_path = 'surnames.csv'

# 使用 pandas 的 read_csv 函数加载数据
surname_df = pd.read_csv(csv_file_path)
# 使用 train_test_split 进行数据分割
train_df, test_df = train_test_split(surname_df, test_size=0.2, random_state=42)  # 80% 训练集,20% 测试集
val_df, test_df = train_test_split(test_df, test_size=0.5, random_state=42)  # 从测试集中分割出验证集和测试集
# 创建 SurnameVectorizer 实例
vectorizer = SurnameVectorizer.from_dataframe(train_df)  # 通常使用训练集来创建向量化器

train_dataset = SurnameDataset(train_df, vectorizer)
val_dataset = SurnameDataset(val_df, vectorizer)
test_dataset = SurnameDataset(test_df, vectorizer)

# 创建 DataLoader 对象
batch_size = 64
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
# 定义训练轮数
num_epochs = 10  # 例如,这里设置训练轮数为 10

# 接下来,使用 num_epochs 执行训练循环
for epoch in range(num_epochs):
    for batch in train_loader:
        optimizer.zero_grad()
        x_surname = batch['x_surname']
        y_nationality = batch['y_nationality']
        
        # 前向传播
        y_pred = classifier(x_surname)
        
        # 计算损失
        loss = loss_func(y_pred, y_nationality)
        
        # 反向传播和优化
        loss.backward()
        optimizer.step()

2.5 模型评估和预测

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

 2.5.1 在测试数据集上评估

SurnameClassifier在测试数据上达到了约50%的准确性。与训练数据相比,测试数据的性能通常会略有下降,因为模型更倾向于适应训练数据。可以尝试调整隐藏维度的大小来改善性能,但由于使用的简单向量化方法,性能提升可能会有限。这种方法虽然能够将每个姓氏表示为单个向量,但忽略了字符顺序之间的关系,这在姓氏起源识别中是非常重要的。

2.5.2 对新姓氏进行分类

如下展示了分类新姓氏的代码,该函数接受一个字符串形式的姓氏作为输入,并首先将其进行向量化处理,然后使用模型进行预测。值得注意的是,设置了apply_softmax标志,以确保结果包含概率。在多分类情况下,模型的预测结果是一个类概率的列表。利用PyTorch张量的max函数来获取具有最高预测概率的类别。

def predict_nationality(name, classifier, vectorizer):
    # 将名称转换为向量表示
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)

    # 使用分类器进行预测
    result = classifier(vectorized_name, apply_softmax=True)

    # 获取预测结果的概率值和索引
    probability_values, indices = result.max(dim=1)
    index = indices.item()

    # 从向量化器中查找预测的国籍
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    probability_value = probability_values.item()

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

new_surname = input("Enter a surname to classify: ")
classifier = classifier.to("cpu")
prediction = predict_nationality(new_surname, classifier, vectorizer)
print("{} -> {} (p={:0.2f})".format(new_surname,
                                    prediction['nationality'],
                                    prediction['probability']))

输入、输出结果如下:

2.5.3 检索新姓氏的前k个预测结果

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

def predict_topk_nationality(name, classifier, vectorizer, k=5):
    # 将名称转换为向量表示
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)

    # 使用分类器进行预测并获取前 k 个预测结果的概率值和索引
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    probability_values, indices = torch.topk(prediction_vector, k=k)

    # 将概率值和索引转换为 numpy 数组
    probability_values = probability_values.detach().numpy()[0]
    indices = indices.detach().numpy()[0]

    results = []
    for prob_value, index in zip(probability_values, indices):
        # 从向量化器中查找预测的国籍
        nationality = vectorizer.nationality_vocab.lookup_index(index)
        results.append({'nationality': nationality,
                        'probability': prob_value})

    return results

# 输入新的姓氏并设置分类器为CPU
new_surname = input("输入一个姓氏进行分类: ")
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))
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))

输入、输出结果如下:

三、基于卷积神经网络的姓氏分类实验

为了展示CNN的有效性,我们将应用一个简单的CNN模型来进行姓氏分类。虽然该任务的许多细节与前面的MLP示例相似,但真正发生变化的是模型的构建和向量化过程。与在上一个例子中使用的压缩的one-hot表示不同,模型的输入将是一个one-hot编码的矩阵。这种设计将使CNN能够更好地“观察”字符的排列,并编码在“示例:带有多层感知器的姓氏分类”中使用的压缩的one-hot编码中丢失的序列信息。

3.1 姓氏数据集

虽然之前使用了“示例:带有多层感知器的姓氏分类”中描述的相同姓氏数据集,但是在实现上有一个重要区别:数据集现在由one-hot向量矩阵组成,而不是单个收缩的one-hot向量。我们创建了一个新的数据集类,它跟踪最长的姓氏,并将其行数作为矩阵的行数,列数是one-hot向量的大小(即词汇表的大小)。这种设计有两个原因:一是为了将每个姓氏矩阵组合成相同大小的三维张量,以便处理;二是通过使用数据集中最长的姓氏,可以以相同的方式处理每个小批次数据。

以下为该部分代码:

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):
        """加载数据集和相应的向量化器。 
        用途...
        """

 3.2 词汇表和向量化器

尽管词汇表的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。现在,该函数将字符串中的每个字符映射到一个整数,然后使用这些整数构造一个由one-hot向量组成的矩阵,矩阵中的每一列都是不同的one-hot向量。这样设计的主要原因是将使用的Conv1d层要求数据张量在第0维上具有批处理,在第1维上具有通道,在第2维上具有特性。除了更改为使用one-hot矩阵之外,本实验还修改了矢量化器,以便计算姓氏的最大长度并将其保存。

3.2.1 词汇表

以下为该部分代码:

class Vocabulary(object):
    """处理文本并提取用于映射的词汇的类"""
 
    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        """
        初始化词汇表
        
        Args:
            token_to_idx (dict): 预先存在的令牌到索引的映射字典
            add_unk (bool): 是否添加UNK令牌的标志
            unk_token (str): 要添加到词汇中的UNK令牌
        """
        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx
        self._idx_to_token = {idx: token for token, idx in self._token_to_idx.items()}
        
        self._add_unk = add_unk
        self._unk_token = unk_token
        
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token) 
 
    def to_serializable(self):
        """返回可序列化的字典"""
        return {'token_to_idx': self._token_to_idx, 
                'add_unk': self._add_unk, 
                'unk_token': self._unk_token}
 
    @classmethod
    def from_serializable(cls, contents):
        """从序列化字典实例化词汇表"""
        return cls(**contents)
 
    def add_token(self, token):
        """根据令牌更新映射字典
        
        Args:
            token (str): 要添加到词汇中的项
        Returns:
            index (int): 与令牌对应的整数索引
        """
        if token not in self._token_to_idx:
            index = len(self._token_to_idx)
            self._token_to_idx[token] = index
            self._idx_to_token[index] = token
        else:
            index = self._token_to_idx[token]
        return index
    
    def add_many(self, tokens):
        """将多个令牌添加到词汇表中
        
        Args:
            tokens (list): 字符串令牌列表
        Returns:
            indices (list): 与令牌对应的索引列表
        """
        return [self.add_token(token) for token in tokens]
 
    def lookup_token(self, token):
        """检索与令牌关联的索引,如果令牌不存在则返回UNK索引
        
        Args:
            token (str): 要查找的令牌
        Returns:
            index (int): 与令牌对应的索引
        """
        return self._token_to_idx.get(token, self.unk_index)
 
    def lookup_index(self, index):
        """返回与索引关联的令牌
        
        Args:
            index (int): 要查找的索引
        Returns:
            token (str): 与索引对应的令牌
        Raises:
            KeyError: 如果索引不在词汇表中
        """
        if index not in self._idx_to_token:
            raise KeyError("索引 (%d) 不在词汇表中" % index)
        return self._idx_to_token[index]
 
    def __str__(self):
        return "<Vocabulary(size=%d)>" % len(self)
 
    def __len__(self):
        return len(self._token_to_idx)

3.2.2 向量化器

以下为该部分代码:

class SurnameVectorizer(object):
    """姓氏向量化器,协调词汇表并将其应用到数据上"""

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

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

        # 初始化全零矩阵
        one_hot_matrix_size = (len(self.surname_vocab), self._max_surname_length)
        one_hot_matrix = np.zeros(one_hot_matrix_size, dtype=np.float32)
                               
        # 将姓氏中的每个字符转换为独热向量
        for position_index, character in enumerate(surname):
            character_index = self.surname_vocab.lookup_token(character)
            one_hot_matrix[character_index][position_index] = 1
        
        return one_hot_matrix

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

        # 遍历数据框中的每一行
        for index, row in surname_df.iterrows():
            max_surname_length = max(max_surname_length, len(row.surname))
            # 更新姓氏词汇表和国籍词汇表
            for letter in row.surname:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)

        return cls(surname_vocab, nationality_vocab, max_surname_length)

3.3 使用卷积网络重新实现姓氏分类器

本实验在这个案例中使用了卷积神经网络模型,该模型采用了在"卷积神经网络"中介绍的方法。生成的用于测试卷积层的"人工"数据与姓氏数据集中使用的矢量化器产生的数据张量大小完全匹配。与在"卷积神经网络"中介绍的Conv1d序列相似,但也有一些新的补充。该模型类似于卷积神经网络,通过一系列一维卷积逐步计算更多特征,最终得到单一特征向量。
新的内容包括使用PyTorch的sequence和ELU模块。序列模块是对线性操作序列的方便封装,而ELU是一种非线性函数,类似于在实验3中介绍的ReLU,但它对负值进行了不同处理。ELU已经被证明是卷积层之间有前景的非线性。
本实验将每个卷积层的通道数与num_channels参数绑定在一起。这种方法需要优化更多超参数,但我们发现使用256个通道可以使模型表现良好。

以下为该部分代码:

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.4 训练过程

程序的训练过程包括以下步骤:实例化数据集、模型、损失函数和优化器;然后对训练数据进行迭代更新模型参数,接着对验证数据进行迭代以评估性能,最后重复这个过程一定次数。这是迄今为止的第三个训练例程,这个操作序列应该被内部化。对于这个例子,不再详细描述训练过程,因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同,但输入参数不同。

以下为该部分代码:

def make_train_state(args):
    """
    创建训练状态字典

    Args:
        args: 命令行参数

    Returns:
        训练状态字典,包括以下键值对:
            - 'stop_early': 是否提前停止训练的标志
            - 'early_stopping_step': 提前停止的步数
            - 'early_stopping_best_val': 最佳验证集损失
            - 'learning_rate': 学习率
            - 'epoch_index': 当前迭代的epoch索引
            - 'train_loss': 训练集损失列表
            - 'train_acc': 训练集准确率列表
            - 'val_loss': 验证集损失列表
            - 'val_acc': 验证集准确率列表
            - 'test_loss': 测试集损失
            - 'test_acc': 测试集准确率
            - 'model_filename': 模型状态文件名
    """
    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}

3.5 模型评估和预测

为了了解模型的性能,需要对其进行定量和定性的评估。下面将描述这两个评估的基本组成部分。建议对其进行扩展,以进一步探索模型及其学习到的特征。

3.5.1 在测试数据集上评估

与“示例:使用多层感知器进行姓氏分类”中的示例相似,评估代码在当前示例中也没有变化。简要地说,调用分类器的eval()方法以防止反向传播,并遍历测试数据集。与多层感知器约50%的准确率相比,该模型在测试集上的准确率约为56%。虽然这些性能数字绝不是这些特定架构的上限,但使用相对简单的CNN模型获得的改进应该足以鼓励在文本数据上尝试CNN。

3.5.2 对新姓氏进行分类或检索前k个预测结果

在本实验中,predict_nationality()函数的部分已被修改,如下所示:不再使用view方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()函数在应该添加大小为1的维度的位置上添加批处理维度。相同的更改也反映在predict_topk_nationality()函数中。

以下为该部分代码:

def predict_nationality(surname, classifier, vectorizer):
    """预测一个新姓氏的国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifer): 分类器的实例
        vectorizer (SurnameVectorizer): 相应的向量化器
    
    Returns:
        dict: 包含最可能的国籍及其概率的字典
    """
    # 向量化姓氏
    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}


csv_file_path = 'surnames.csv'
surname_df = pd.read_csv(csv_file_path)


vectorizer = SurnameVectorizer.from_dataframe(surname_df)
initial_num_channels = 82  # 姓氏特征向量的大小
num_classes = 18  # 国籍的类别数
num_channels = 64  # 网络中使用的通道数
# 初始化分类器
classifier = SurnameClassifier(initial_num_channels, num_classes, num_channels)
# 将分类器移到 CPU 上进行推理
classifier = classifier.cpu()

# 获取用户输入并进行预测
new_surname = input("Enter a surname to classify: ")
prediction = predict_nationality(new_surname, classifier, vectorizer)

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

输入、输出结果如下:

3.5.3检索新姓氏的前k个预测结果

以下为该部分实验代码:

def predict_topk_nationality(surname, classifier, vectorizer, k=5):
    """
    预测一个新姓氏对应的前K个国籍
    
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifier): 分类器的实例
        vectorizer (SurnameVectorizer): 对应的向量化器
        k (int): 返回的前K个国籍
    Returns:
        list of dictionaries, 每个字典包含一个国籍和对应的概率
    """
    # 将姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(dim=0)
    # 使用分类器进行预测,并应用softmax激活函数
    prediction_vector = classifier(vectorized_surname, apply_softmax=True)
    # 获取前K个预测结果
    probability_values, indices = torch.topk(prediction_vector, k=k)
    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("请输入要分类的姓氏: ")
 
# 获取用户希望查看的前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))

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

输入、输出结果如下:

四、实验总结

在本次实验中,我们对多层感知机(MLP)模型和卷积神经网络(CNN)模型在姓氏分类任务上进行了比较和评估。
首先,我们对两种模型进行了初始化,并选择了合适的损失函数和优化器。针对MLP模型,我们构建了多个全连接层,以及相应的激活函数,并进行了训练数据的迭代训练。而对于CNN模型,我们引入了卷积层和池化层来从数据中提取特征,进而进行分类。在模型训练完成后,我们对它们进行了性能评估。通过在测试数据集上进行评估,我们发现MLP模型相较于CNN模型具有更高的准确率。这表明MLP模型能够更好地捕获姓氏数据中的特征,并更有效地进行分类。此外,我们还对两种模型进行了预测。尽管在预测过程中两种模型的性能差异不太显著,但在训练和评估阶段,MLP模型都表现出更好的性能。
综上所述,通过对MLP模型和CNN模型在姓氏分类任务上的对比,我们可以更好地理解不同模型的优劣势,并根据任务需求选择最合适的模型。这些结果为我们进一步研究和应用深度学习模型提供了重要参考。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值