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

一、多层感知机(MLP)

1.原理简介

   多层感知机(MLP,Multilayer Perceptron)也叫人工神经网络(ANN,Artificial Neural Network),除了输入输出层,它中间可以有多个隐层,最简单的MLP只含一个隐层,即三层的结构,如下图:

从上图可以看到,多层感知机层与层之间是全连接的。多层感知机最底层是输入层,中间是隐藏层,最后是输出层。  

其实隐藏层到输出层可以看成是一个多类别的逻辑回归,也即softmax回归,所以输出层的输出就是softmax(W2X1+b2),X1表示隐藏层的输出f(W1X+b1)。W1是权重(也叫连接系数),b1是偏置,激活函数f 可以是常用的sigmoid函数或者tanh函数。

2.激活函数

2.1 sigmoid函数

y = 1/(1+e^(-x))

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1)#这一行创建了一个张量 x,其中包含从 -5 到 5(不包括 5)之间以 0.1 为步长的值
y = torch.sigmoid(x)#计算了 x 张量中每个元素的 sigmoid 函数值,并将结果保存在 y 张量中
plt.plot(x.numpy(), y.numpy())
plt.show()

2.2 tanh函数

y = (e^x-e^(-x))/(e^x+e^(-x))

import torch
import matplotlib.pyplot as plt

x = torch.range(-5., 5., 0.1)#这一行创建了一个张量 x,其中包含从 -5 到 5(不包括 5)之间以 0.1 为步长的值
y = torch.tanh(x)#计算了 x 张量中每个元素的 tanh 函数值,并将结果保存在 y 张量中
plt.plot(x.numpy(), y.numpy())
plt.show()

二、卷积神经网络(CNN)

1.卷积神经网络介绍

CNN,即卷积神经网络(Convolutional Neural Network),是一种常用于图像和视频处理的深度学习模型。与传统神经网络相比,CNN 有着更好的处理图像和序列数据的能力,因为它能够自动学习图像中的特征,并提取出最有用的信息。

CNN 的一个核心特点是卷积操作,它可以在图像上进行滑动窗口的计算,通过滤波器(又称卷积核)和池化层(Max Pooling)来提取出图像的特征。卷积操作可以有效地减少权重数量,降低计算量,同时也能够保留图像的空间结构信息。池化层则可以在不改变特征图维度的前提下,减少计算量,提高模型的鲁棒性。

CNN 的典型结构包括卷积层、池化层、全连接层等。同时,为了防止过拟合,CNN 还会加入一些正则化的技术,如 Dropout 和 L2 正则等。

2.输入层

输入层接收原始图像数据。图像通常由三个颜色通道(红、绿、蓝)组成,形成一个二维矩阵,表示像素的强度值。

3.卷积层和激活层

卷积层将输入图像与卷积核进行卷积操作。然后,通过应用激活函数(如ReLU)来引入非线性。这一步使网络能够学习复杂的特征。

4.池化层

池化层通过减小特征图的大小来减少计算复杂性。它通过选择池化窗口内的最大值或平均值来实现。这有助于提取最重要的特征。

5.多层堆叠

CNN通常由多个卷积和池化层的堆叠组成,以逐渐提取更高级别的特征。深层次的特征可以表示更复杂的模式。

6.全连接和输出

最后,全连接层将提取的特征映射转化为网络的最终输出。这可以是一个分类标签、回归值或其他任务的结果。

三、实验步骤

1.基于多层感知机处理姓氏分类

在本节中,我们将MLP应用于将姓氏分类到其原籍国的任务。从公开观察到的数据推断人口统计信息(如国籍)具有从产品推荐到确保不同人口统计用户获得公平结果的应用。人口统计和其他自我识别信息统称为“受保护属性”。在建模和产品中使用这些属性时,必须小心。我们首先对每个姓氏的字符进行拆分,并像对待“示例:将餐馆评论的情绪分类”中的单词一样对待它们。除了数据上的差异,字符层模型在结构和实现上与基于单词的模型基本相似。

1.1 The Surname Dataset

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

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

部分代码:

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.val_df = self.surname_df[self.surname_df.split=='val']
        self.test_df = self.surname_df[self.surname_df.split=='test']
 
        self._lookup_dict = {
            'train': (self.train_df, len(self.train_df)),
            'val': (self.val_df, len(self.val_df)),
            'test': (self.test_df, len(self.test_df))
        }
 
        self.set_split('train')
        
        # 计算类别权重
        class_counts = surname_df.nationality.value_counts().to_dict()
        sorted_counts = sorted(class_counts.items(), key=lambda item: self._vectorizer.nationality_vocab.lookup_token(item[0]))
        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):
        """从头加载数据集并创建新的向量化器"""
        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):
        """加载数据集和已保存的向量化器"""
        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):
        """仅加载向量化器"""
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))
 
    def save_vectorizer(self, vectorizer_filepath):
        """保存向量化器"""
        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):
        """返回指定索引的数据点"""
        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):
        """返回数据集中批次数量"""
        return len(self) // batch_size
 
def generate_batches(dataset, batch_size, shuffle=True, drop_last=True, device="cpu"): 
    """
    生成批次数据的生成器函数,确保每个张量在正确的设备位置上
    """
    dataloader = DataLoader(dataset=dataset, batch_size=batch_size, shuffle=shuffle, drop_last=drop_last)
    for data_dict in dataloader:
        out_data_dict = {name: tensor.to(device) for name, tensor in data_dict.items()}
        yield out_data_dict

1.2 Vocabulary, Vectorizer, and DataLoader

为了使用字符对姓氏进行分类,我们使用词汇表、向量化器和DataLoader将姓氏字符串转换为向量化的minibatches。这些数据结构与“Example: Classifying Sentiment of Restaurant Reviews”中使用的数据结构相同,它们举例说明了一种多态性,这种多态性将姓氏的字符标记与Yelp评论的单词标记相同对待。数据不是通过将字令牌映射到整数来向量化的,而是通过将字符映射到整数来向量化的。

THE VOCABULARY CLASS

本例中使用的词汇类与“example: Classifying Sentiment of Restaurant Reviews”中的词汇完全相同,该词汇类将Yelp评论中的单词映射到对应的整数。

部分代码:

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): 与令牌对应的索引
        """
        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]
 
    def lookup_index(self, index):
        """返回与索引关联的令牌
        
        Args: 
            index (int): 要查找的索引
        Returns:
            token (str): 与索引对应的令牌
        Raises:
            KeyError: 如果索引不在词汇表中
        """
        if index not in self._idx_to_token:
            raise KeyError(f"索引({index})不在词汇表中")
        return self._idx_to_token[index]
 
    def __str__(self):
        return f"<Vocabulary(size={len(self)})>"
 
    def __len__(self):
        return len(self._token_to_idx)

THE SURNAMEVECTORIZER

虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。实例化和使用非常类似于“示例:对餐馆评论的情绪进行分类”中的ReviewVectorizer,但有一个关键区别:字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。

部分代码:

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):
        """
        将姓氏向量化为一维one-hot编码
        Args:
            surname (str): 姓氏
        Returns:
            one_hot (np.ndarray): 一维one-hot编码
        """
        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):
        """
        从数据集数据框实例化向量化器
        Args:
            surname_df (pandas.DataFrame): 姓氏数据集
        Returns:
            SurnameVectorizer 的一个实例
        """
        surname_vocab = Vocabulary(unk_token="@")
        nationality_vocab = Vocabulary(add_unk=False)
 
        for _, 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):
        """
        将向量化器转换为可序列化的字典
        Returns:
            dict: 可序列化的词汇表字典
        """
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable()}

1.3 The Surname Classifier Model

第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。

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

部分代码:

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

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

部分代码:

# 设置参数
args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/surname_mlp",
    # 模型超参数
    hidden_dim=300,
    # 训练超参数
    seed=1337,
    num_epochs=100,
    early_stopping_criteria=5,
    learning_rate=0.001,
    batch_size=64,
    # 运行时选项
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
)
 
# 扩展文件路径
if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)
    args.model_state_file = os.path.join(args.save_dir, args.model_state_file)
    
    print("扩展的文件路径: ")
    print("\t{}".format(args.vectorizer_file))
    print("\t{}".format(args.model_state_file))
    
# 检查 CUDA 可用性
if not torch.cuda.is_available():
    args.cuda = False
 
args.device = torch.device("cuda" if args.cuda else "cpu")
print("使用 CUDA: {}".format(args.cuda))
 
# 设置随机种子以保证可重复性
def set_seed_everywhere(seed, cuda):
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)
 
set_seed_everywhere(args.seed, args.cuda)
 
# 处理目录
def handle_dirs(dirpath):
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)
 
handle_dirs(args.save_dir)

 运行结果:

 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)
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.0
        running_acc = 0.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()
            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("Exiting loop")

1.5 Model Evaluation and Prediction

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

1.5.1 EVALUATING ON THE TEST DATASET

该模型对测试数据的准确性达到50%左右。如果在附带的notebook中运行训练例程,会注意到在训练数据上的性能更高。这是因为模型总是更适合它所训练的数据,所以训练数据的性能并不代表新数据的性能。如果遵循代码,你可以尝试隐藏维度的不同大小,应该注意到性能的提高。

1.5.2 CLASSIFYING A NEW SURNAME

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

def predict_nationality(name, classifier, vectorizer):
    vectorized_name = vectorizer.vectorize(name)# 将姓氏矢量化
    vectorized_name = torch.tensor(vectorized_name).view(1, -1) # 将矢量化后的姓氏转换为张量,并调整形状为 (1, -1)
    result = classifier(vectorized_name, 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("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']))

预测结果:

1.5.3 RETRIEVING THE TOP-K PREDICTIONS FOR A NEW SURNAME

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

def predict_topk_nationality(name, classifier, vectorizer, k=5):
    """
    预测姓氏的前k个可能国籍
    
    Args:
        name (str): 姓氏
        classifier (nn.Module): 训练好的分类器
        vectorizer (SurnameVectorizer): 向量化工具
        k (int): 返回的前k个预测
    
    Returns:
        results (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)
    
    # 转换为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))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname,
                                        prediction['nationality'],
                                        prediction['probability']))

预测结果:

 2.基于卷积神经网络处理姓氏分类

这项任务的许多细节与前面的MLP示例相同,但真正发生变化的是模型的构造和向量化过程。模型的输入,而不是我们在上一个例子中看到的收缩的onehot,将是一个onehot的矩阵。这种设计将使CNN能够更好地“view”字符的排列,并对在“示例:带有多层感知器的姓氏分类”中使用的收缩的onehot编码中丢失的序列信息进行编码。

2.1 The SurnameDataset

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

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

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.val_df = self.surname_df[self.surname_df.split == 'val']
        self.test_df = self.surname_df[self.surname_df.split == 'test']
 
        self._lookup_dict = {
            'train': (self.train_df, len(self.train_df)),
            'val': (self.val_df, len(self.val_df)),
            'test': (self.test_df, len(self.test_df))
        }
 
        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:
            SurnameVectorizer 的一个实例
        """
        with open(vectorizer_filepath) as fp:
            return SurnameVectorizer.from_serializable(json.load(fp))
 
    def save_vectorizer(self, vectorizer_filepath):
        """将向量化器保存到磁盘
        
        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):
        """返回指定索引的数据点
        
        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 的生成器函数,确保每个张量在正确的设备位置上
    
    Args:
        dataset (Dataset): PyTorch 数据集
        batch_size (int): 批次大小
        shuffle (bool): 是否随机打乱数据
        drop_last (bool): 是否丢弃最后一个不完整的批次
        device (str): 设备(如 "cpu" 或 "cuda")
    
    Yields:
        每个批次的数据字典
    """
    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] = tensor.to(device)
        yield out_data_dict

2.2 Vocabulary, Vectorizer, and DataLoader

在本例中,尽管词汇表和DataLoader的实现方式与“示例:带有多层感知器的姓氏分类”中的示例相同,但Vectorizer的vectorize()方法已经更改,以适应CNN模型的需要。

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

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)
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):
        """
        将姓氏向量化为one-hot编码矩阵
        
        Args:
            surname (str): 姓氏
        Returns:
            one_hot_matrix (np.ndarray): one-hot编码矩阵
        """
        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 _, row in surname_df.iterrows():
            max_surname_length = max(max_surname_length, len(row.surname))
            for letter in row.surname:
                surname_vocab.add_token(letter)
            nationality_vocab.add_token(row.nationality)
 
        return cls(surname_vocab, nationality_vocab, max_surname_length)
 
    @classmethod
    def from_serializable(cls, contents):
        """
        从序列化字典实例化向量化器
        
        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, 
                   max_surname_length=contents['max_surname_length'])
 
    def to_serializable(self):
        """
        将向量化器转换为可序列化的字典
        
        Returns:
            dict: 可序列化的词汇表字典
        """
        return {'surname_vocab': self.surname_vocab.to_serializable(),
                'nationality_vocab': self.nationality_vocab.to_serializable(), 
                'max_surname_length': self._max_surname_length}

2.3 Reimplementing the SurnameClassifier with Convolutional Networks

我们在本例中使用的模型是使用我们在“卷积神经网络”中介绍的方法构建的。实际上,我们在该部分中创建的用于测试卷积层的“人工”数据与姓氏数据集中使用本例中的矢量化器的数据张量的大小完全匹配。正如在示例4-19中所看到的,它与我们在“卷积神经网络”中引入的Conv1d序列既有相似之处,也有需要解释的新添加内容。具体来说,该模型类似于“卷积神经网络”,它使用一系列一维卷积来增量地计算更多的特征,从而得到一个单特征向量。

然而,本例中的新内容是使用sequence和ELU PyTorch模块。序列模块是封装线性操作序列的方便包装器。在这种情况下,我们使用它来封装Conv1d序列的应用程序。ELU是类似于实验3中介绍的ReLU的非线性函数,但是它不是将值裁剪到0以下,而是对它们求幂。ELU已经被证明是卷积层之间使用的一种很有前途的非线性(Clevert et al., 2015)。

在本例中,我们将每个卷积的通道数与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): 输入数据张量。形状应为 (batch, initial_num_channels, max_surname_length)
            apply_softmax (bool): 是否应用softmax激活的标志
                如果与交叉熵损失一起使用,应为False
        Returns:
            结果张量。形状应为 (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)  # 如果需要,应用softmax激活
        
        return prediction_vector

2.4 The Training Routine

训练程序包括以下似曾相识的的操作序列:实例化数据集,实例化模型,实例化损失函数,实例化优化器,遍历数据集的训练分区和更新模型参数,遍历数据集的验证分区和测量性能,然后重复数据集迭代一定次数。此时,这是本书到目前为止的第三个训练例程实现,应该将这个操作序列内部化。对于这个例子,我们将不再详细描述具体的训练例程,因为它与“示例:带有多层感知器的姓氏分类”中的例程完全相同。

# 设置参数
args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch4/cnn",
    # 模型超参数
    hidden_dim=100,
    num_channels=256,
    # 训练超参数
    seed=1337,
    learning_rate=0.001,
    batch_size=128,
    num_epochs=100,
    early_stopping_criteria=5,
    dropout_p=0.1,
    # 运行时选项
    cuda=False,
    reload_from_files=False,
    expand_filepaths_to_save_dir=True,
    catch_keyboard_interrupt=True
)
 
# 扩展文件路径
if args.expand_filepaths_to_save_dir:
    args.vectorizer_file = os.path.join(args.save_dir, args.vectorizer_file)
    args.model_state_file = os.path.join(args.save_dir, args.model_state_file)
    
    print("扩展的文件路径: ")
    print("\t{}".format(args.vectorizer_file))
    print("\t{}".format(args.model_state_file))
    
# 检查 CUDA 可用性
if not torch.cuda.is_available():
    args.cuda = False
 
args.device = torch.device("cuda" if args.cuda else "cpu")
print("使用 CUDA: {}".format(args.cuda))
 
def set_seed_everywhere(seed, cuda):
    """
    设置随机种子以确保结果的可重复性
    Args:
        seed (int): 随机种子
        cuda (bool): 是否使用 CUDA
    """
    np.random.seed(seed)
    torch.manual_seed(seed)
    if cuda:
        torch.cuda.manual_seed_all(seed)
        
def handle_dirs(dirpath):
    """
    确保目录存在
    Args:
        dirpath (str): 目录路径
    """
    if not os.path.exists(dirpath):
        os.makedirs(dirpath)
        
# 设置随机种子以确保可重复性
set_seed_everywhere(args.seed, args.cuda)
 
# 处理目录
handle_dirs(args.save_dir)

2.5 Model Evaluation and Prediction

要理解模型的性能,需要对性能进行定量和定性的度量。下面将描述这两个度量的基本组件。

2.5.1 Evaluating on the Test Dataset

正如“示例:带有多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样,执行评估的代码也没有变化。总之,调用分类器的eval()方法来防止反向传播,并迭代测试数据集。与 MLP 约 50% 的性能相比,该模型的测试集性能准确率约为56%。尽管这些性能数字绝不是这些特定架构的上限,但是通过一个相对简单的CNN模型获得的改进应该足以让您在文本数据上尝试CNNs。

2.5.2 Classifying or retrieving top predictions for a new surname

在本例中,predict_nationality()函数的一部分发生了更改,如示例4-21所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在predict_topk_nationality()函数中。

def predict_nationality(surname, classifier, vectorizer):
    """
    从新姓氏预测国籍
    Args:
        surname (str): 要分类的姓氏
        classifier (SurnameClassifier): 分类器实例
        vectorizer (SurnameVectorizer): 对应的向量化器
    Returns:
        包含最可能国籍及其概率的字典
    """
    # 将姓氏向量化
    vectorized_surname = vectorizer.vectorize(surname)
    # 将向量化后的姓氏转换为张量,并增加一个维度
    vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
    # 使用分类器进行预测,并应用 softmax 激活函数
    result = classifier(vectorized_surname, apply_softmax=True)
    
    # 获取最大概率值及其对应的索引
    probability_values, indices = result.max(dim=1)
    index = indices.item()
    probability_value = probability_values.item()
    
    # 根据索引查找预测的国籍
    predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
    
    # 返回包含预测国籍和概率的字典
    return {'nationality': predicted_nationality, 'probability': probability_value}
new_surname = input("Enter a surname to classify: ")
classifier = classifier.cpu()# 将分类器移到 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个国籍
    
    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)
    
    # 返回的尺寸是 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("请输入要分类的姓氏: ")
 
# 获取用户希望查看的前K个预测
k = int(input("您想查看多少个预测结果? "))
if k > len(vectorizer.nationality_vocab):
    print("抱歉!这个数字超过了我们拥有的国籍数量... 默认设置为最大值 :)")
    k = len(vectorizer.nationality_vocab)
    
# 获取预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)
 
# 打印预测结果
print("前 {} 个预测:".format(k))
print("===================")
for prediction in predictions:
    print("{} -> {} (p={:0.2f})".format(new_surname, prediction['nationality'], prediction['probability']))

预测结果:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值