使用多层感知机处理不同姓氏的国籍分类问题

1 前馈神经网络

前馈神经网络(Feedforward Neural Network, FFNN)是最简单的一种人工神经网络,也是深度学习中最基础的一种网络结构。它可以用来解决一些复杂分类问题,是好用且经典的神经网络。

以下是一个多层前馈网络的图解:

1.1 单层感知器(Perceptron)

感知器是最基本的神经网络单元,类似于单个神经元。它接收多个输入,每个输入有一个权重值,通过加权和计算,经过一个激活函数,输出一个结果。感知器是二分类任务的基础单元,可以看作是一个简单的线性分类器。

单层的感知器可以处理简单的分类任务。

1.2 多层感知器(MLP)

多层感知器(MLP)是前馈神经网络的一种,包含一个或多个隐藏层。每层的神经元都与前一层的每个神经元连接。MLP的特点是:

  1. 完全连接:每一层的神经元与下一层的每个神经元都连接。
  2. 激活函数:常用ReLU、Sigmoid或Tanh。
  3. 反向传播:通过计算损失函数的梯度,调整权重和偏置。

1.3 卷积神经网络(CNN)

卷积神经网络(CNN)是一种特殊的前馈神经网络,特别适合处理图像数据。它利用卷积层和池化层来提取图像特征,特点如下:

  1. 卷积层:通过卷积核滑动窗口操作提取局部特征。
  2. 池化层:降低特征图的维度,常用的有最大池化和平均池化。
  3. 全连接层:通常在网络末端,用于输出分类结果。 

2 多层感知器

多层感知机由输入层、一个或多个隐藏层和输出层组成。MLP通过对输入数据进行多次线性变换和非线性激活,可以学习复杂的函数映射关系,广泛应用于分类、回归等任务中。以下是MLP的示意图,它将带我们了解MLP的运行机制。

多层感知机相比于单层感知机,优点是可以处理更加复杂的分类情形。

左为感知器的分类,可以看到它并没有很好的处理图像;右为MLP的处理,相比左边进步了太多。

知道了原理后,我们来定义一个多层感知机:

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

class MultilayerPerceptron(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        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):
        intermediate = F.relu(self.fc1(x_in))  
        # 对第一个全连接层的输出应用ReLU激活函数
        output = self.fc2(intermediate)  
        # 应用第二个全连接层

        if apply_softmax:
            output = F.softmax(output, dim=1)  
        # 可选地对输出应用softmax函数
        return output

使用以下代码来初始化感知器:

# 设置参数
batch_size = 2  # 一次输入的样本数量
input_dim = 3
hidden_dim = 100
output_dim = 4

# 初始化模型
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
print(mlp)

运行结果:

由此我们可以看到定义的各项参数,第一层是从输入维度3到隐藏层维度100的全连接层,第二层是从隐藏层维度100到输出维度4的全连接层。 

我们可以使用随机生成的输入来快速测试此模型:

import torch
def describe(x):
    print("Type: {}".format(x.type()))
    print("Shape/size: {}".format(x.shape))
    print("Values: \n{}".format(x))

x_input = torch.rand(batch_size, input_dim) # Testing the MLP with random inputs
describe(x_input)

结果:

 

3 使用MLP判断不同姓氏的国籍

3.1 导入数据集、数据预处理

姓氏数据集收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。因为原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多,所以我们修改了此数据集,目的是为了减少这种不平衡。

我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。

首先,我们初始化姓氏数据集,然后,在向量化后按照固定大小返回批次。

class SurnameDataset(Dataset):
    def __init__(self, surname_df, vectorizer):
        # 初始化姓氏数据集
        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):
        # 加载数据集并从头开始制作新的向量化器
        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):
        # 使用 json 将向量化器保存到磁盘
        with open(vectorizer_filepath, "w") as fp:
            json.dump(self._vectorizer.to_serializable(), fp)

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

    def set_split(self, split="train"):
       # 使用数据框中的列选择数据集中的拆分
        self._target_split = split
        self._target_df, self._target_size = self._lookup_dict[split]

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

    def __getitem__(self, index):
        # PyTorch 数据集的主要入口方法
        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 = {}
        for name, tensor in data_dict.items():
            out_data_dict[name] = data_dict[name].to(device)
        yield out_data_dict

 3.2 生成词汇表、向量化

要生成词典,先处理文本,提取词汇,然后创建索引并指向该词典。

class Vocabulary(object):
    # 处理文本并提取词汇用于映射的类

    def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
        # 初始化词汇表对象

        if token_to_idx is None:
            token_to_idx = {}
        self._token_to_idx = token_to_idx

        # 创建从索引到token的映射字典
        self._idx_to_token = {idx: token 
                              for token, idx in self._token_to_idx.items()}
        
        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):
        # 根据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):
        # 将多个token添加到词汇表中

        return [self.add_token(token) for token in tokens]

    def lookup_token(self, token):
        # 检索与token相关联的索引,如果token不存在则返回UNK索引

        if self.unk_index >= 0:
            return self._token_to_idx.get(token, self.unk_index)
        else:
            return self._token_to_idx[token]

    def lookup_index(self, index):
        # 返回与索引相关联的token
        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)

向量化时,将姓氏压缩为one-hot编码,并将所有序列拼接成一份词典。

class SurnameVectorizer(object):
    # 协调词汇表并将其投入使用的向量化器
    def __init__(self, surname_vocab, nationality_vocab):
        # 初始化向量化器

        self.surname_vocab = surname_vocab
        self.nationality_vocab = nationality_vocab

    def vectorize(self, surname):
        # 将姓氏向量化

        vocab = self.surname_vocab
        one_hot = np.zeros(len(vocab), dtype=np.float32)
        for token in surname:
            one_hot[vocab.lookup_token(token)] = 1

        return one_hot

    @classmethod
    def from_dataframe(cls, surname_df):
        # 从数据集数据框实例化向量化器

        surname_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()}

3.3 姓氏分类模型

用MLP构建出模型。

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

class SurnameClassifier(nn.Module):
    # 用于姓氏分类的两层多层感知器 
    def __init__(self, input_dim, hidden_dim, output_dim):
        # 初始化分类器

        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):
        # 分类器的前向传递

        # 通过第一个全连接层,并应用 ReLU 激活函数
        intermediate_vector = F.relu(self.fc1(x_in))
        
        # 通过第二个全连接层
        prediction_vector = self.fc2(intermediate_vector)

        # 如果需要应用 softmax 激活函数
        if apply_softmax:
            prediction_vector = F.softmax(prediction_vector, dim=1)

        return prediction_vector

3.4 训练模型预案 

def make_train_state(args):
    # 初始化训练状态
    return {'stop_early': False,  # 提早停止标志
            'early_stopping_step': 0,  # 提早停止步骤计数
            'early_stopping_best_val': 1e8,  # 提早停止时的最佳验证损失
            'learning_rate': args.learning_rate,  # 学习率
            'epoch_index': 0,  # 纪元索引
            'train_loss': [],  # 训练损失列表
            'train_acc': [],  # 训练准确率列表
            'val_loss': [],  # 验证损失列表
            'val_acc': [],  # 验证准确率列表
            'test_loss': -1,  # 测试损失
            'test_acc': -1,  # 测试准确率
            'model_filename': args.model_state_file}  # 模型文件名

def update_train_state(args, model, train_state):
    # 更新训练状态
    # 至少保存一个模型
    if train_state['epoch_index'] == 0:
        torch.save(model.state_dict(), train_state['model_filename'])
        train_state['stop_early'] = False

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

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

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

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

    return train_state

def compute_accuracy(y_pred, y_target):
    # 计算预测准确率
    # 获取预测最大值的索引
    _, y_pred_indices = y_pred.max(dim=1)
    # 计算预测正确的数量
    n_correct = torch.eq(y_pred_indices, y_target).sum().item()
    # 返回准确率
    return n_correct / len(y_pred_indices) * 100

 

3.5  模型设置

为了结果的随机性,我们设定一个随机数生成器来确保结果不重复。

def set_seed_everywhere(seed, cuda):
    np.random.seed(seed)  # 设置 NumPy 的随机数种子
    torch.manual_seed(seed)  # 设置 PyTorch 的 CPU 随机数种子
    if cuda:
        torch.cuda.manual_seed_all(seed)  # 如果使用 CUDA,设置所有 GPU 的随机数种子

检查目标目录是否存在:

def handle_dirs(dirpath):
    # 创建目录,如果目录不存在
    if not os.path.exists(dirpath):  # 如果目录不存在
        os.makedirs(dirpath)  # 创建目录

 其他准备工作如下:

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,  # 是否使用 CUDA
    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():
    args.cuda = False

# 设置设备为 CUDA 或 CPU
args.device = torch.device("cuda" if args.cuda else "cpu")
    
print("Using CUDA: {}".format(args.cuda))

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

# 处理目录
handle_dirs(args.save_dir)

 执行后,可以看出系统没有CUDA,所以没用CUDA来运行程序。

3.6 实例化优化器,运行模型

实例化时,可以设置指定设备(arg.device)来运行模型。

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

        # 迭代训练数据集

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

        for batch_index, batch_dict in enumerate(batch_generator):
            # 训练过程的五个步骤:

            # --------------------------------------
            # 第一步: 清零梯度
            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)

        # 迭代验证数据集

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

        for batch_index, batch_dict in enumerate(batch_generator):
            # 计算输出
            y_pred =  classifier(batch_dict['x_surname'])

            # 计算损失
            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")

在这里,我们可以看到实时训练的进度条:

3.7 模型预测结果 

预测姓氏的前K个国籍:

def predict_topk_nationality(name, classifier, vectorizer, k=5):
    # 将姓氏矢量化
    vectorized_name = vectorizer.vectorize(name)
    vectorized_name = torch.tensor(vectorized_name).view(1, -1)
    
    # 使用分类器进行预测,并应用softmax
    prediction_vector = classifier(vectorized_name, apply_softmax=True)
    
    # 获取前k个概率值和索引
    probability_values, indices = torch.topk(prediction_vector, k=k)
    
    # 转换为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? "))
if k > len(vectorizer.nationality_vocab):
    print("Sorry! That's more than the # of nationalities we have.. defaulting you to max size :)")
    k = len(vectorizer.nationality_vocab)

# 获取预测结果
predictions = predict_topk_nationality(new_surname, classifier, vectorizer, k=k)

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

随机输入一个姓氏,按照前10个预测排序,并打印结果:

 

可见,此模型在预测“Zhuan”这个中文姓氏时正确率很高。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值