基于前馈神经网络的姓氏分类任务(基础)

1、认识前馈神经网络

What is it

前馈神经网络 (英文:Feedforward Neural Network),为人工智能领域中,最早发明的简单人工神经网络类型。在它内部,参数从输入层向输出层单向传播。有异于循环神经网络,它的内部不会构成有向环

图1-1 前馈神经网络结构

人们大多使用多层感知机(英语:Multilayer Perceptron,缩写:MLP)作为前馈神经网络的代名词,但是除了MLP之外,卷积神经网络(英语:convolutional neural network,缩写:CNN)也是典型的前馈神经网络。

Why need it

感知机

先来简单了解一下感知机(perceptron),感知机是一种线性分类算法,通常用于二分类问题。它非常松散地模仿生物神经元,就像一条简单的神经元那样,有输入(刺激)和输出,并且用激活函数来模仿放电阈值,"信号"从输入流向输出,如图1-2所示

图1-2 感知机模型
数学上,感知机就是一个简单的线性函数:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> y = f ( w x + b ) y=f(wx+b) </math>y=f(wx+b)

日常问题中,通常有多个输入,用向量表示这个一般情况;即:x和w是向量,w和x的乘积替换为点积:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> y = f ( w ⃗ T x ⃗ + b ) y=f(\vec{w}^T\vec{x}+b) </math>y=f(w Tx +b)

激活函数用f表示,通常是一个非线性函数。

图 1-3 感知机工作原理
创建一个线性可分的数据集。数据集中的两个类绘制为圆形和星形。感知机要做的就是区分数据集上的点是圆形还是星型。感知机可以很好的处理线性可分的问题,例如经典的与(AND)或(OR)非(NOT)问题。

但是碰上非线性可分问题,例如异或(XOR)问题,感知机就束手无策了,由于没办法用线性函数分割数据,感知机模型无法收敛会导致其一直迭代。由于单层感知机不能处理异或问题,神经网络甚至一度陷入低潮。

多层感知机(MLP)

MLP是对感知机的扩展,感知机将数据向量作为输入,计算出一个输出值。在MLP中,许多感知机被分组成为各个网络层,每一层的输出是一个新的向量,而不是单个输出值。也就是说MLP把多个感知机的输出结果作为,最简单的MLP,如图4-2所示,由三个表示阶段和两个线性层组成。

一种具有两个线性层和三个表示阶段(输入向量、隐藏向量和输出向量)的MLP模型,如下图1-4所示:

图1-4 MLP可视化表示

图1-5 XOR(异或)数据集中的两个类绘制为圆形和星形。
在这个分类问题中,不存在任何一条直线可以分隔这两个类,这也就是线性不可分问题。 普通的感知机面对这种问题就捉襟见肘了,但是对多层感知机来说,这不是个事。

图1-6 从感知器(左)和MLP(右)学习的XOR问题的解决方案显示

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

图1-7 MLP的输入和中间表示是可视化的。从左到右:(1)网络的输入;(2)第一个线性模块的输出;(3)第一个非线性模块的输出;(4)第二个线性模块的输出。第一个线性模块的输出将圆和星分组,而第二个线性模块的输出将数据点重新组织为线性可分的。

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

图1-8 感知器的输入和输出表示。因为它没有像MLP那样的中间表示来分组和重新组织,所以它不能将圆和星分开。

How do achieve it

好了,既然我们已经知道MLP神通广大了,肯定迫不及待的想要实现它了。

2、在PyTorch中实现MLP

MLP除了由许多简单的感知器构成之外,还有一个额外的计算层。我们可以用PyTorch的两个线性模块表示。将线性模块称为"完全连接层",简称为"fc层"。除了这两个线性层外,还有一个修正的非线性单元(ReLU),它的输入是第一个线性层的输出,它的输出作为第二个线性层的输入。在两个线性层之间加入非线性单元是必要的,因为没有它,两个线性层在数学上等价于一个线性层。基于pytorch的MLP只实现反向传播的前向传递。因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新。

实例化MLP

python 复制代码
import torch.nn as nn  # 导入PyTorch中神经网络模块
import torch.nn.functional as F  # 导入PyTorch中函数式接口,用于激活函数等操作

class MultilayerPerceptron(nn.Module):  # 定义一个多层感知机类
    def __init__(self, input_dim, hidden_dim, output_dim):
        '''
        初始化函数,设置MLP的层和参数。
        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):
        """
        前向传播函数,定义了数据如何通过网络流动。
        Args:
            x_in (torch.Tensor): 输入数据张量,其形状应为(batch, input_dim)
            apply_softmax (bool): 是否应用softmax激活函数
                如果与交叉熵损失一起使用,应设置为False
        Returns:
            torch.Tensor: 结果张量,其形状应为(batch, output_dim)
        """
        intermediate = F.relu(self.fc1(x_in))  # 应用第一个线性层并使用ReLU激活函数
        output = self.fc2(intermediate)  # 应用第二个线性层

        if apply_softmax:  # 如果需要,应用softmax激活函数
            output = F.softmax(output, dim=1)  # softmax沿最后一个维度计算
        return output  # 返回最终的输出张量

由于MLP实现的通用性,可以为任何大小的输入建模。为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度。

python 复制代码
batch_size = 2  # 定义一次输入的样本数量
input_dim = 3   # 定义输入向量的维度,例如,每个样本有3个特征
hidden_dim = 100  # 定义隐藏层的神经元数量,这里是100个神经元
output_dim = 4  # 定义输出层的神经元数量,例如,对于4类分类问题

# 初始化模型
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
# 使用定义好的类构造函数创建一个多层感知机实例

print(mlp)  # 打印模型的结构
# 这将输出模型的层和参数信息,包括每个层的类型和参数数量

上述代码运行结果:

ini 复制代码
MultilayerPerceptron(
  (fc1): Linear(in_features=3, out_features=100, bias=True)
  (fc2): Linear(in_features=100, out_features=4, bias=True)
)

测试模型的连接

我们可以通过传递一些随机输入来快速测试模型的"连接",但是因为模型还没有经过训练,所以输出是随机的。在花费时间训练模型之前,这样做是一个有用的完整性检查。

python 复制代码
def describe(x):
    """
    打印张量的类型、形状和值。
    Args:
        x (torch.Tensor): 需要描述的张量
    """
    print("Type: {}".format(x.type()))  # 打印张量的类型,例如torch.float32
    print("Shape/size: {}".format(x.shape))  # 打印张量的形状,例如torch.Size([2, 3])
    print("Values: \n{}".format(x))  # 打印张量的值

x_input = torch.rand(batch_size, input_dim)  # 创建一个形状为(batch_size, input_dim)的随机张量
# torch.rand生成[0,1)区间内均匀分布的随机数

describe(x_input)  # 使用describe函数打印x_input张量的信息

上述代码运行结果:

lua 复制代码
Type: torch.FloatTensor
Shape/size: torch.Size([2, 3])
Values: 
tensor([[0.6193, 0.7045, 0.7812],
        [0.6345, 0.4476, 0.9909]])
python 复制代码
y_output = mlp(x_input, apply_softmax=False)  # 使用多层感知机模型对x_input进行前向传播
# 这里apply_softmax参数设置为False,表示不应用softmax激活函数,通常在模型训练时这样做,
# 因为PyTorch的交叉熵损失函数会内部应用softmax。

describe(y_output)  # 使用describe函数打印y_output张量的信息

上述代码运行结果:

ini 复制代码
Type: torch.FloatTensor
Shape/size: torch.Size([2, 4])
Values: 
tensor([[ 0.2356,  0.0983, -0.0111, -0.0156],
        [ 0.1604,  0.1586, -0.0642,  0.0010]], grad_fn=<AddmmBackward>)

读取模型的输入和输出

在前面的例子中,MLP模型的输出是一个有两行四列的张量。在某些情况下,例如在分类设置中,特征向量是一个预测向量。名称为"预测向量"表示它对应于一个概率分布。预测向量会发生什么取决于我们当前是在进行训练还是在执行推理。在训练期间,输出按原样使用,带有一个损失函数和目标类标签的表示。

但是,如果想将预测向量转换为概率,则需要额外的步骤。具体来说,需要softmax函数,它可以将输出值向量转换为概率。这个函数背后的直觉是,大的正值会导致更高的概率,小的负值会导致更小的概率。下面将apply_softmax标志设置为True:

python 复制代码
y_output = mlp(x_input, apply_softmax=True)  # 使用多层感知机模型对x_input进行前向传播
# 这里apply_softmax参数设置为True,表示在模型的输出上应用softmax激活函数,
# 这通常用于模型的预测阶段,将原始分数转换为概率分布。

describe(y_output)  # 使用describe函数打印y_output张量的信息

上述代码运行结果:

ini 复制代码
Type: torch.FloatTensor
Shape/size: torch.Size([2, 4])
Values: 
tensor([[0.2915, 0.2541, 0.2277, 0.2267],
        [0.2740, 0.2735, 0.2189, 0.2336]], grad_fn=<SoftmaxBackward>)

综上所述,mlp是将输入张量映射到输出张量的线性层。并且在每一对线性层之间使用非线性来打破线性关系,允许模型扭曲向量空间。在分类设置中,这种扭曲导致类之间的线性可分性。另外,可以使用softmax函数将MLP输出解释为概率。

3、MLP在姓氏分类中的应用

在本节中,我们将MLP应用于将姓氏分类到其原籍国的任务。我们首先对每个姓氏的字符进行拆分。除了数据上的差异,字符层模型在结构和实现上与基于单词的模型基本相似。我们在这个例子中引入了多类输出及其对应的损失函数。在建立了模型之后,我们完成了训练。 要先从从姓氏数据集及其预处理步骤的描述开始。然后,我们使用词汇表、向量化器和DataLoader类逐步完成从姓氏字符串到向量化小批处理的管道。

建立词汇表

python 复制代码
    #Vocabulary
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>"):
        """
        初始化 Vocabulary 类的一个实例。

        Args:
            token_to_idx (dict): 一个预先存在的标记到索引的映射字典。
            add_unk (bool): 一个标志,指示是否添加未知标记(UNK token)。
            unk_token (str): 要添加到词汇表中的未知标记。
        """
        # 如果没有提供 token_to_idx,则创建一个空字典
        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        # 创建索引到标记的反向映射
        self._idx_to_token = {idx: token for token, idx in self._token_to_idx.items()}

        # 设置是否添加未知标记和未知标记的值
        self._add_unk = add_unk
        self._unk_token = unk_token

        # 设置未知标记的索引,如果需要
        self.unk_index = -1
        if add_unk:
            self.unk_index = self.add_token(unk_token)

    def to_serializable(self):
        """返回一个可序列化的字典"""
        # 返回包含词汇表状态的字典,以便可以序列化
        return {
            'token_to_idx': self._token_to_idx,
            'add_unk': self._add_unk,
            'unk_token': self._unk_token
        }

    @classmethod
    def from_serializable(cls, contents):
        """从序列化的字典实例化 Vocabulary """
        # 根据序列化的内容(字典)创建 Vocabulary 类的实例
        return cls(**contents)

    def add_token(self, token):
        """根据标记更新映射字典。

        Args:
            token (str): 要添加到词汇表中的项

        Returns:
            index (int): 与标记对应的整数
        """
        # 尝试从现有映射中获取标记的索引,如果不存在则添加标记并创建新索引
        try:
            index = self._token_to_idx[token]
        except KeyError:
            index = len(self._token_to_idx)
            self._token_to_idx[token] = index
            self._idx_to_token[index] = token
        return index

    def add_many(self, tokens):
        """将一系列标记添加到词汇表中

        Args:
            tokens (list): 字符串标记的列表

        Returns:
            indices (list): 与标记对应的索引列表
        """
        # 对列表中的每个标记调用 add_token,并返回索引列表
        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        """检索与标记关联的索引,如果标记不存在则返回未知标记的索引。

        Args:
            token (str): 要查找的标记

        Returns:
            index (int): 与标记对应的索引
        """
        # 如果已添加未知标记,则返回标记的索引或未知标记的索引
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        """返回与索引关联的标记

        Args:
            index (int): 要查找的索引

        Returns:
            token (str): 与索引对应的标记

        Raises:
            KeyError: 如果索引不在词汇表中
        """
        # 如果索引在索引到标记的映射中,则返回对应的标记,否则抛出 KeyError
        if index not in self._idx_to_token:
            raise KeyError("the index (%d) is not in the Vocabulary" % index)
        return self._idx_to_token[index]

    def __str__(self):
        """返回 Vocabulary 对象的字符串表示形式"""
        return "<Vocabulary(size=%d)>" % len(self)

    def __len__(self):
        """返回词汇表的大小"""
        return len(self._token_to_idx)

定义姓氏数据集类

姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降------这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如"O 'Neill"、"Antonopoulos"、"Nagasawa"或"Zhu")。

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

python 复制代码
from torch.utils.data import Dataset  # 导入PyTorch的Dataset类
import pandas as pd
class SurnameDataset(Dataset):
    """
    自定义数据集类,用于处理姓氏和国籍的数据。
    实现与第3.5节几乎相同(假设这是参考某个教程或文档中的一个部分)。
    """
    def __init__(self, surname_df, vectorizer):
        """
        Args:
            surname_df (pandas.DataFrame): the dataset
            vectorizer (SurnameVectorizer): vectorizer instatiated from dataset
        """
        self.surname_df = surname_df
        self._vectorizer = vectorizer

        self.train_df = self.surname_df[self.surname_df.split=='train']
        self.train_size = len(self.train_df)

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

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

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

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

    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """Load dataset and make a new vectorizer from scratch
        
        Args:
            surname_csv (str): location of the dataset
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)
        train_surname_df = surname_df[surname_df.split=='train']
        return cls(surname_df, SurnameVectorizer.from_dataframe(train_surname_df))

    @classmethod
    def load_dataset_and_load_vectorizer(cls, surname_csv, vectorizer_filepath):
        """Load dataset and the corresponding vectorizer. 
        Used in the case in the vectorizer has been cached for re-use
        
        Args:
            surname_csv (str): location of the dataset
            vectorizer_filepath (str): location of the saved vectorizer
        Returns:
            an instance of SurnameDataset
        """
        surname_df = pd.read_csv(surname_csv)
        vectorizer = cls.load_vectorizer_only(vectorizer_filepath)
        return cls(surname_df, vectorizer)

    @staticmethod
    def load_vectorizer_only(vectorizer_filepath):
        """a static method for loading the vectorizer from file
        
        Args:
            vectorizer_filepath (str): the location of the serialized vectorizer
        Returns:
            an instance of SurnameVectorizer
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))

    def save_vectorizer(self, vectorizer_filepath):
        """saves the vectorizer to disk using json
        
        Args:
            vectorizer_filepath (str): the location to save the vectorizer
        """
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

    def get_vectorizer(self):
        """ returns the vectorizer """
        return self._vectorizer

    def set_split(self, split="train"):
        """ selects the splits in the dataset using a column in the dataframe """
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

    def __len__(self):
        return self._target_size
    
    def __getitem__(self, index):
        """
        根据索引index获取数据集中的一个样本。
        
        Args:
            index (int): 要获取的样本的索引
        
        Returns:
            dict: 包含一个样本的特征和标签的字典
        """
        row = self._target_df.iloc[index]  # 从数据集中获取第index行的数据
        # 这里假设self._target_df是一个pandas DataFrame对象,存储了数据集的所有行

        surname_vector = self._vectorizer.vectorize(row.surname)  # 使用向量化方法将姓氏转换为数值向量
        # 这里假设self._vectorizer是一个具有vectorize方法的对象,用于将文本数据转换为数值向量

        nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)  # 将国籍转换为索引
        # 这里假设self._vectorizer.nationality_vocab是一个Vocabulary对象,用于将文本标签转换为整数索引

        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

构建词汇表向量化的向量化器

虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。在"卷积神经网络"出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。

python 复制代码
class SurnameVectorizer(object):
    """协调姓氏和国籍词汇表,并将其用于向量化处理的向量化器"""

    def __init__(self, surname_vocab, nationality_vocab):
        """
        初始化向量化器,设置用于姓氏和国籍的词汇表。

        Args:
            surname_vocab (Vocabulary): 将字符映射到整数的词汇表
            nationality_vocab (Vocabulary): 将国籍映射到整数的词汇表
        """
        self.surname_vocab = surname_vocab  # 存储姓氏字符的词汇表
        self.nationality_vocab = nationality_vocab  # 存储国籍的词汇表

    def vectorize(self, surname):
        """
        将提供的姓氏进行向量化处理,生成独热编码。

        Args:
            surname (str): 姓氏字符串

        Returns:
            one_hot (np.ndarray): 折叠的独热编码数组
        """
        vocab = self.surname_vocab  # 获取姓氏的词汇表
        one_hot = np.zeros(len(vocab), dtype=np.float32)  # 创建一个零向量,长度为词汇表大小
        for token in surname:  # 遍历姓氏中的每个字符
            one_hot[vocab.lookup_token(token)] = 1  # 在独热编码向量中对应位置设为1

        return one_hot  # 返回独热编码数组

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据集的DataFrame创建向量化器实例
        
        Args:
            surname_df (pandas.DataFrame): 包含姓氏数据的数据集
        Returns:
            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):
        """从序列化的字典创建向量化器实例
        
        Args:
            contents (dict): 包含序列化信息的字典
        Returns:
            SurnameVectorizer的一个实例
        """
        surname_vocab = Vocabulary.from_serializable(contents['surname_vocab'])  # 从序列化数据创建姓氏词汇表
        nationality_vocab = Vocabulary.from_serializable(contents['nationality_vocab'])  # 从序列化数据创建国籍词汇表
        return cls(surname_vocab=surname_vocab, nationality_vocab=nationality_vocab)  # 使用创建的词汇表实例化向量化器

    def to_serializable(self):
        """返回向量化器的序列化形式"""
        return {
            'surname_vocab': self.surname_vocab.to_serializable(),  # 获取姓氏词汇表的序列化形式
            'nationality_vocab': self.nationality_vocab.to_serializable()  # 获取国籍词汇表的序列化形式
        }

构造姓氏分类的MLP模型

SurnameClassifier是前面介绍的MLP的实现。第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。

在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的"概率"。它是可选的原因与我们使用的损失函数的数学公式有关------交叉熵损失。我们研究了"损失函数"中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。

python 复制代码
class SurnameClassifier(nn.Module):
    """
    一个用于分类姓氏的两层多层感知机(MLP)。
    """

    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): 输入数据张量,其形状应为 (batch, input_dim)。
            apply_softmax (bool): 是否应用 softmax 激活函数的标志。
                如果与交叉熵损失函数一起使用,应设置为 False。

        Returns:
            torch.Tensor: 结果张量,其形状应为 (batch, output_dim)。
        """
        intermediate_vector = F.relu(self.fc1(x_in))  # 应用第一个线性层并使用 ReLU 激活函数
        prediction_vector = self.fc2(intermediate_vector)  # 应用第二个线性层

        if apply_softmax:  # 如果需要,应用 softmax 激活函数
            prediction_vector = F.softmax(prediction_vector, dim=1)  # 对最后一个维度应用 softmax

        return prediction_vector  # 返回预测结果张量

对模型进行训练吧

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

python 复制代码
import numpy as np
def set_seed_everywhere(seed, cuda):
    """
    设置随机种子,确保结果可重复。

    Args:
        seed (int): 随机种子值。
        cuda (bool): 是否使用 CUDA(GPU)。
    """
    np.random.seed(seed)  # 设置 NumPy 的随机种子
    torch.manual_seed(seed)  # 设置 PyTorch 的随机种子
    if cuda:
        torch.cuda.manual_seed_all(seed)  # 如果使用 CUDA,设置所有 GPU 的随机种子

def handle_dirs(dirpath):
    """
    处理目录,如果不存在则创建。

    Args:
        dirpath (str): 目录的路径。
    """
    if not os.path.exists(dirpath):  # 检查目录是否存在
        os.makedirs(dirpath)  # 如果目录不存在,则创建目录
from argparse import Namespace
import os
import torch

# 创建一个Namespace对象,用于存储命令行参数解析的结果
args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",  # 姓氏数据CSV文件的路径
    vectorizer_file="vectorizer.json",  # 向量化器文件的名称
    model_state_file="model.pth",  # 模型状态文件的名称
    save_dir="model_storage/ch4/surname_mlp",  # 模型和向量化器文件的保存目录

    # 模型超参数
    hidden_dim=300,  # 隐藏层的维度

    # 训练超参数
    seed=1337,  # 随机种子,用于确保实验的可重复性
    num_epochs=100,  # 训练的最大轮数
    early_stopping_criteria=5,  # 早期停止的标准
    learning_rate=0.001,  # 学习率
    batch_size=64,  # 每个批次的样本数量

    # 运行时选项
    cuda=False,  # 是否使用CUDA(GPU)进行训练
    reload_from_files=False,  # 是否从文件中重新加载数据
    expand_filepaths_to_save_dir=True,  # 是否将文件路径扩展到保存目录
)

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

# 检查CUDA是否可用
if not torch.cuda.is_available():  # 如果CUDA不可用
    args.cuda = False  # 设置args中的cuda选项为False

# 设置设备,根据是否使用CUDA决定使用CPU或GPU
args.device = torch.device("cuda" if args.cuda else "cpu")

# 打印是否使用CUDA的信息
print("Using CUDA: {}".format(args.cuda))

# 为了可重复性,设置随机种子
set_seed_everywhere(args.seed, args.cuda)

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

上述代码运行结果(不同设备的结果不一致):

bash 复制代码
Expanded filepaths: 
	model_storage/ch4/surname_mlp/vectorizer.json
	model_storage/ch4/surname_mlp/model.pth
Using CUDA: False
python 复制代码
# 使用 SurnameDataset 类的类方法 load_dataset_and_make_vectorizer 加载数据集
# 并创建一个向量化器,传入 args 中指定的 CSV 文件路径
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)

# 从 dataset 对象获取向量化器
vectorizer = dataset.get_vectorizer()

# 初始化 SurnameClassifier 类的实例,即我们的模型
# input_dim 设置为向量化后的姓氏特征的维度,即姓氏词汇表的大小
# hidden_dim 设置为 args 中定义的隐藏层维度
# output_dim 设置为向量化后的国籍特征的维度,即国籍词汇表的大小
classifier = SurnameClassifier(input_dim=len(vectorizer.surname_vocab),
                               hidden_dim=args.hidden_dim,
                               output_dim=len(vectorizer.nationality_vocab))

# 将模型 classifier 移动到 args 中指定的设备上,这通常是一个 GPU 或 CPU
classifier = classifier.to(args.device)

# 初始化损失函数,这里使用 PyTorch 的 CrossEntropyLoss
# dataset.class_weights 可能是一个由数据集类计算的权重,用于处理类别不平衡问题
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 初始化优化器,这里使用 PyTorch 的 Adam 算法
# 传入模型的参数 classifier.parameters() 和 args 中定义的学习率
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)
python 复制代码
    # 如果设置了从文件重新加载
if args.reload_from_files:
    # 打印信息,表明正在重新加载
    print("Reloading!")
    
    # 从指定的CSV和向量化器文件中加载数据集和向量化器
    dataset = SurnameDataset.load_dataset_and_load_vectorizer(
        args.surname_csv,  # 指定的CSV文件路径
        args.vectorizer_file  # 指定的向量化器文件路径
    )
else:
    # 如果没有设置从文件重新加载
    # 打印信息,表明正在从头开始创建
    print("Creating fresh!")
    
    # 从指定的CSV文件中加载数据集并创建新的向量化器
    dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
    
    # 保存新创建的向量化器到文件
    dataset.save_vectorizer(args.vectorizer_file)

# 获取数据集的向量化器
vectorizer = dataset.get_vectorizer()

# 根据向量化器中的信息初始化分类器
# 其中,input_dim 是姓氏词汇表的大小,output_dim 是国籍词汇表的大小
classifier = SurnameClassifier(
    input_dim=len(vectorizer.surname_vocab),  # 姓氏词汇表的大小
    hidden_dim=args.hidden_dim,  # 隐藏层维度
    output_dim=len(vectorizer.nationality_vocab)  # 国籍词汇表的大小
)
python 复制代码
 # 将模型和类别权重移动到指定的设备(CPU或GPU)
classifier = classifier.to(args.device)
dataset.class_weights = dataset.class_weights.to(args.device)

# 初始化损失函数,使用CrossEntropyLoss,并传入类别权重
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 初始化优化器,使用Adam算法
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate)

# 初始化学习率调度器,使用ReduceLROnPlateau根据验证损失减少学习率
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer=optimizer, mode='min', factor=0.5, patience=1)

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

# 初始化训练进度条
epoch_bar = tqdm_notebook(desc='training routine', total=args.num_epochs, position=0)

# 设置数据集为训练分划并初始化训练进度条
dataset.set_split('train')
train_bar = tqdm_notebook(desc='split=train', total=dataset.get_num_batches(args.batch_size), position=1, leave=True)

# 设置数据集为验证分划并初始化验证进度条
dataset.set_split('val')
val_bar = tqdm_notebook(desc='split=val', total=dataset.get_num_batches(args.batch_size), position=1, leave=True)

try:
    # 遍历指定轮数的训练循环
    for epoch_index in range(args.num_epochs):
        train_state['epoch_index'] = epoch_index

        # 训练阶段
        dataset.set_split('train')
        # 创建训练批次生成器
        batch_generator = generate_batches(dataset, batch_size=args.batch_size, device=args.device)
        running_loss = 0.0  # 初始化训练损失
        running_acc = 0.0  # 初始化训练准确率
        classifier.train()  # 将模型设置为训练模式

        # 遍历训练批次
        for batch_index, batch_dict in enumerate(batch_generator):
            # 清空梯度
            optimizer.zero_grad()

            # 计算模型输出
            y_pred = classifier(batch_dict['x_surname'])

            # 计算损失
            loss = loss_func(y_pred, batch_dict['y_nationality'])
            loss_t = loss.item()  # 获取损失的数值
            running_loss += (loss_t - running_loss) / (batch_index + 1)  # 计算指数加权平均损失

            # 反向传播
            loss.backward()

            # 更新模型参数
            optimizer.step()

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

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

        # 记录训练损失和准确率
        train_state['train_loss'].append(running_loss)
        train_state['train_acc'].append(running_acc)

        # 验证阶段
        dataset.set_split('val')
        # 创建验证批次生成器
        batch_generator = generate_batches(dataset, batch_size=args.batch_size, device=args.device)
        running_loss = 0.  # 初始化验证损失
        running_acc = 0.  # 初始化验证准确率
        classifier.eval()  # 将模型设置为评估模式

        # 遍历验证批次
        for batch_index, batch_dict in enumerate(batch_generator):
            # 计算模型输出
            y_pred = classifier(batch_dict['x_surname'])

            # 计算损失
            loss = loss_func(y_pred, batch_dict['y_nationality'])
            loss_t = loss.to("cpu").item()  # 将损失移动到CPU并获取数值
            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进度条
        epoch_bar.update()

except KeyboardInterrupt:
    # 如果用户中断训练(例如使用Ctrl+C),打印退出信息
    print("Exiting loop")

检查训练结果

python 复制代码
    # 使用训练过程中保存的最佳模型状态更新分类器
classifier.load_state_dict(torch.load(train_state['model_filename']))

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

# 初始化损失函数,使用带有类别权重的CrossEntropyLoss
loss_func = nn.CrossEntropyLoss(dataset.class_weights)

# 设置数据集为测试分划
dataset.set_split('test')

# 创建测试批次生成器
batch_generator = generate_batches(dataset, 
                                   batch_size=args.batch_size, 
                                   device=args.device)

# 初始化测试集的损失和准确率
running_loss = 0.
running_acc = 0.

# 将分类器设置为评估模式
classifier.eval()

# 遍历测试批次
for batch_index, batch_dict in enumerate(batch_generator):
    # 计算模型输出
    y_pred = classifier(batch_dict['x_surname'])

    # 计算损失
    loss = loss_func(y_pred, batch_dict['y_nationality'])
    loss_t = loss.item()  # 将损失转换为标量值
    running_loss += (loss_t - running_loss) / (batch_index + 1)  # 计算指数加权平均损失

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

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

输入自己的姓氏看看分类器表现如何吧

python 复制代码
def predict_topk_nationality(name, classifier, vectorizer, k=5):
    """
    预测名称的前k个最可能的国籍及其概率。
    
    Args:
        name (str): 要预测的名称。
        classifier (SurnameClassifier): 分类器实例。
        vectorizer (SurnameVectorizer): 向量化器实例。
        k (int): 要返回的前k个最可能的国籍。
    
    Returns:
        list: 包含最有可能的国籍和其概率的字典列表。
    """
    # 将名称向量化
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    
    # 获取分类器的预测输出,并应用softmax
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    
    # 获取概率最高的k个国籍的索引和对应的概率值
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # 将概率值和索引从PyTorch张量转换为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

# 请求用户输入一个要分类的姓氏
new_surname = input("Enter a surname to classify: ")

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

# 请求用户输入想要看到的前k个预测结果的数量
k = int(input("How many of the top predictions to see? "))

# 如果用户请求的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)

# 使用predict_topk_nationality函数获取前k个预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

# 打印预测结果
print("Top {} predictions:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))
python 复制代码
Enter a surname to classify:  Chang
How many of the top predictions to see?  3

上述代码运行结果:

ini 复制代码
Top 3 predictions:
===================
Chang -> Korean (p=0.40)
Chang -> Chinese (p=0.29)
Chang -> Irish (p=0.15)

把我预测成了韩国人,笑。

4、结束了

相关推荐
sp_fyf_20241 小时前
[大语言模型-论文精读] 更大且更可指导的语言模型变得不那么可靠
人工智能·深度学习·神经网络·搜索引擎·语言模型·自然语言处理
肖遥Janic1 小时前
Stable Diffusion绘画 | 插件-Deforum:商业LOGO广告视频
人工智能·ai·ai作画·stable diffusion
我就是全世界3 小时前
一起了解AI的发展历程和AGI的未来展望
人工智能·agi
colorknight3 小时前
1.2.3 HuggingFists安装说明-MacOS安装
人工智能·低代码·macos·huggingface·数据科学·ai agent
kuan_li_lyg3 小时前
MATLAB - 机械臂手眼标定(眼在手内) - 估计安装在机器人上的移动相机的姿态
开发语言·人工智能·matlab·机器人·ros·机械臂·手眼标定
山川而川-R3 小时前
Windows安装ollama和AnythingLLM
人工智能·python·语言模型·自然语言处理
Kuekua-seu3 小时前
diffusion vs GAN
人工智能·神经网络·生成对抗网络
电子科技圈4 小时前
IAR全面支持国科环宇AS32X系列RISC-V车规MCU
人工智能·嵌入式硬件·mcu·编辑器
大地之灯4 小时前
深度学习每周学习总结J1(ResNet-50算法实战与解析 - 鸟类识别)
人工智能·python·深度学习·学习·算法
OCR_wintone4214 小时前
翔云 OCR:发票识别与验真
人工智能·深度学习·ocr