目录
引言
在人工智能和机器学习的浪潮中,深度学习以其卓越的性能在众多领域中占据着重要地位。本次博客,我将与大家分享如何使用深度学习中的两种经典模型——多层感知机(MLP)和卷积神经网络(CNN),来解决一个有趣的问题:根据姓氏来预测其可能的原籍国家。
实验模型
1. 多层感知器 (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):
x = F.relu(self.fc1(x_in))
x = self.fc2(x)
return x
2. 卷积神经网络 (CNN)
卷积神经网络是一种深度学习模型,它使用卷积层来处理数据。CNN特别擅长处理图像数据,但也可以用于其他类型的数据。
代码示例:
class SurnameClassifierCNN(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super(SurnameClassifierCNN, self).__init__()
self.conv1 = nn.Conv1d(input_dim, hidden_dim, kernel_size=3)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x_in):
x = F.relu(self.conv1(x_in))
x = x.view(x.size(0), -1) # Flatten the output
x = self.fc(x)
return x
3. 循环神经网络 (RNN)
循环神经网络是一种具有循环连接的神经网络,可以处理序列数据。RNN能够记住之前处理过的序列信息,并用于当前的计算。
代码示例:
class RNNClassifier(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super(RNNClassifier, self).__init__()
self.rnn = nn.RNN(input_dim, hidden_dim)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x_in):
x, _ = self.rnn(x_in)
x = self.fc(x[-1]) # Take the output of the last time step
return x
4. 长短期记忆网络 (LSTM)
LSTM是RNN的一种特殊类型,它能够学习长期依赖关系。LSTM通过引入门控机制来避免传统RNN的梯度消失问题。
代码示例:
class LSTMClassifier(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super(LSTMClassifier, self).__init__()
self.lstm = nn.LSTM(input_dim, hidden_dim)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x_in):
lstm_out, _ = self.lstm(x_in)
x = self.fc(lstm_out[-1])
return x
5. 门控循环单元 (GRU)
GRU是另一种RNN的变体,它比LSTM更简单,但通常能够提供类似的性能。
代码示例:
class GRUClassifier(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super(GRUClassifier, self).__init__()
self.gru = nn.GRU(input_dim, hidden_dim)
self.fc = nn.Linear(hidden_dim, output_dim)
def forward(self, x_in):
gru_out, _ = self.gru(x_in)
x = self.fc(gru_out[-1])
return x
6. Transformer 和注意力机制
Transformer是一种基于自注意力机制的模型,它在处理序列数据时非常有效,特别是在自然语言处理领域。
代码示例:
class TransformerClassifier(nn.Module):
def __init__(self, input_dim, num_heads, num_encoder_layers, output_dim):
super(TransformerClassifier, self).__init__()
self.encoder = nn.TransformerEncoderLayer(
d_model=input_dim, nhead=num_heads)
self.transformer_encoder = nn.TransformerEncoder(self.encoder, num_encoder_layers)
self.fc = nn.Linear(input_dim, output_dim)
def forward(self, x_in):
x = self.transformer_encoder(x_in)
x = self.fc(x[-1])
return x
实验一:基于多层感知机的姓氏分类
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、词汇表和向量化器
在本次实验中,我们采用了一种与情感分类任务中相似的词汇表机制,但其应用场景和细节有所区别。不同于Yelp评论中基于单词的映射,我们的词汇表专注于将姓氏中的每个字符映射到一个唯一的整数标识符。
词汇表结构
词汇表本质上是一个映射工具,由两个互为逆过程的Python字典构成:
字符到索引:将每个独特的字符映射到一个整数索引。
索引到字符:允许我们根据索引找回原始字符,这在模型推断阶段尤其有用。
操作与方法
add_token:此方法用于将新的字符(令牌)加入到词汇表中,如果字符是首次出现,则会分配一个新的索引。
lookup_token:根据字符查找其对应的索引,如果字符不在词汇表中,则会返回一个特殊标记,如UNK(未知字符)。
lookup_index:这个方法做lookup_token的逆操作,根据索引找回对应的字符。
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)
向量化器:姓氏到向量的转换
在自然语言处理中,将文本数据转换为机器学习模型可以处理的格式是至关重要的步骤。在我们的姓氏分类任务中,SurnameVectorizer 扮演了这一关键角色,它负责将姓氏的文本信息转换成数值型向量。
向量化器的工作机制
与处理评论文本时使用的 ReviewVectorizer 类似,SurnameVectorizer 通过以下步骤将姓氏转换为向量形式:
字符序列处理:不同于评论文本的单词分割,SurnameVectorizer 将姓氏视为连续的字符序列,不对空格进行分割。
字符映射:每个字符在词汇表中对应一个唯一的标记,这些标记是连续的整数。
向量表示方法
One-Hot 编码:在创建向量表示时,我们采用了收缩的one-hot编码,这意味着每个字符的存在与否是通过一个长向量中的单一位置来表示的。
未知字符处理:对于训练数据中未出现过的字符,使用特殊标记 UNK(Unknown)来表示,确保模型能够处理未知字符。
向量化器的特点
忽略序列信息:在向量化过程中,原始字符的序列信息被忽略,模型通过学习字符的分布来识别姓氏的特征。
词汇表的应用:由于词汇表是基于训练数据构建的,SurnameVectorizer 保证了在验证或测试阶段,即使是数据中的新字符也能通过UNK标记得到处理。
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()}
3、姓氏分类器模型
SurnameClassifier 是我们为姓氏分类任务定制的多层感知器模型,由两个线性层构成,其中穿插着非线性激活函数,以增强模型对复杂特征的捕捉能力。第一个线性层负责将输入的姓氏向量映射到一个中间特征空间,而第二个线性层则将这些特征转换为最终的预测向量,表示各个国籍的类别得分。在输出层,我们采用softmax函数来转换得分为概率分布,这不仅使得输出易于解释,而且与我们使用的交叉熵损失函数相得益彰,后者在多类别分类问题中是理想的选择。尽管softmax的计算可能会带来数值稳定性和计算效率方面的挑战,但我们已经采取了相应的措施来确保模型训练的稳定性和效率。SurnameClassifier 的设计体现了我们在模型构建和训练过程中对性能和解释性的双重追求。
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
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, # 批量大小
# 省略了运行时选项以节省空间
}
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))
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()
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']))
5、模型评估和预测
定量评估:测试数据集上的表现
通过对测试数据集的误差进行定量测量,SurnameClassifier达到了大约50%的准确率。尽管这与训练数据集上的性能存在差距,这种下降在机器学习模型中是常见的,通常归因于模型对训练数据的过拟合。为了提升模型的泛化能力,我们可以考虑调整模型结构,例如增加或减少隐藏层的维度。然而,鉴于当前使用的简单向量化方法,我们可能面临性能提升的局限性。这种向量化方法虽然能够将姓氏转换为数值向量,但未能充分利用字符的顺序信息,而在识别姓氏起源时,这些信息可能至关重要。
定性评估:新姓氏的分类预测
为了进一步理解模型的预测行为,我们对新姓氏进行了分类预测。分类过程首先涉及将姓氏字符串转换为相应的向量表示,随后利用训练好的模型进行预测。在这一过程中,我们特别设置了apply_softmax标志,以确保模型输出的是概率分布,而非未经归一化的预测分数。在多分类任务中,这允许我们获取每个类别的概率估计,并使用PyTorch的max函数确定最可能的类别。
此外,我们还可以通过查看模型对于新姓氏的top-k预测来获得直观的理解,这有助于我们评估模型对于新数据的响应和预测能力。通过这种方法,我们不仅能够识别最有可能的预测结果,还能够探索其他可能的预测,从而获得对模型决策过程更深入的洞察。
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']))
实验二:基于卷积神经网络的姓氏分类
1、姓氏数据集
在探索卷积神经网络(CNN)于姓氏分类任务中的应用时,我们采取了与多层感知机(MLP)不同的方法,特别是在数据的向量化处理上。CNN模型的优势在于其能够捕捉数据的空间排列特征,因此我们对姓氏数据集进行了特别的改造,以发挥CNN的这一长处。
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):
"""加载数据集和相应的向量化器。
用途...
"""
2、词汇表和向量化器
在转向卷积神经网络(CNN)处理姓氏分类任务时,我们对Vectorizer的vectorize()方法进行了关键的调整,以满足CNN模型对输入数据格式的特殊要求。原先为MLP设计的向量化方法已更新,现将每个字符独立映射到一个整数,随后基于这些整数构建出一个one-hot向量矩阵。每一列代表一个特定的字符,整个矩阵则完整地表达了一个姓氏的字符序列。
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):
"""
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、使用卷积网络重新实现姓氏分类器
在本案例中,我们采用了卷积神经网络(CNN)模型来处理姓氏分类任务,该模型借鉴了传统CNN架构中的核心概念和技术。不同于以往的是,我们特别设计了模型以适应由我们的姓氏数据集向量化器生成的特定数据张量尺寸,确保了输入数据与卷积层的完美对接。
模型构建上,我们沿用了一维卷积层(Conv1d)的堆叠序列,这样的设计使得网络能够逐层抽象化输入特征,最终汇聚成代表整个姓氏的特征向量。在此基础上,我们引入了PyTorch的序列模块来组织线性层的顺序操作,以及ELU(指数线性单元)作为激活函数,以替代传统的ReLU。ELU的优势在于它对负值的处理更为温和,有助于缓解在训练深层网络时的梯度消失问题。
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
4、训练过程
在本实验中,我们遵循了一系列标准化的训练步骤来优化我们的卷积神经网络(CNN)模型。训练过程伊始,我们首先实例化了所需的数据集、模型架构、损失函数以及优化器。这一初始化步骤是模型训练的基础,为后续的参数更新和性能评估奠定了框架。
接下来,我们进入了模型训练的核心阶段,即通过迭代的方式对训练数据集进行处理。在每次迭代中,模型参数都会根据损失函数的梯度进行更新,以此来最小化预测误差。与此同时,我们利用验证数据集对模型进行了性能评估,确保了模型不仅在训练数据上表现良好,也能够在未见过的数据上保持稳定的泛化能力。
值得注意的是,尽管本次训练过程在结构上与我们之前多层感知机(MLP)的姓氏分类实验相似,但由于模型架构和输入参数的不同,我们对训练过程中的一些细节进行了调整。例如,我们针对CNN模型的特点,选择了适合的损失函数和优化器参数,以期达到最佳的训练效果。
最终,通过多次迭代这一训练过程,我们不断优化模型,直至达到预定的训练周期或性能指标。这种迭代训练的方法已被证明是提高深度学习模型性能的有效手段,它使我们能够逐步提升模型的复杂度和准确性,同时避免过拟合。
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}
5、模型评估和预测
为了全面理解我们CNN模型在姓氏分类任务上的性能,我们采取了一系列定量和定性的评估手段。以下是对模型评估过程的详细描述。
在这一阶段,我们通过调用模型的eval()方法进入评估模式,禁用了反向传播,并对测试数据集进行了遍历。这一过程与我们之前使用MLP模型的评估类似,但值得注意的是,CNN模型在测试集上的准确率提升到了56%,相较于MLP的50%准确率有了显著提高。这一结果表明,尽管模型架构相对简单,CNN在处理文本数据时仍具有其独特的优势和潜力。
在对新姓氏进行分类或检索其前k个预测结果时,我们对predict_nationality()函数进行了必要的调整。现在,我们不再使用view()方法来重塑张量以添加批处理维度,而是采用了PyTorch的unsqueeze()函数,在适当的位置插入大小为1的维度,以符合CNN模型的输入要求。这一改动同样应用于predict_topk_nationality()函数,确保了对新姓氏的预测更为准确和高效。
import collections
import numpy as np
import pandas as pd
import re
from argparse import Namespace
# 创建一个Namespace对象,用于存储命令行参数或配置选项
args = Namespace(
# 指定原始数据集的CSV文件路径
raw_dataset_csv="surnames.csv",
# 训练集占总数据集的比例
train_proportion=0.7,
# 验证集占总数据集的比例
val_proportion=0.15,
# 测试集占总数据集的比例
test_proportion=0.15,
# 输出处理后的数据集CSV文件路径
output_munged_csv="surnames_with_splits.csv",
# 设置随机数生成器的种子,以确保结果的可重复性
seed=1337
)
# 使用pandas库的read_csv函数读取原始数据集CSV文件
# 通过args.raw_dataset_csv获取CSV文件的路径
# header=0参数指定CSV文件的第一行是列名
surnames = pd.read_csv(args.raw_dataset_csv, header=0)
surnames.head()
# Unique classes
set(surnames.nationality)
# 根据# 根据国籍对数据进行分组
# 使用collections模块中的defaultdict来创建一个默认字典
# defaultdict在访问不存在的键时会自动创建一个空列表
by_nationality = collections.defaultdict(list)
# 遍历surnames DataFrame的每一行
for _, row in surnames.iterrows():
# 将每行数据转换为字典格式
# 然后根据国籍(row.nationality)作为键,将该行数据的字典追加到对应的列表中
by_nationality[row.nationality].append(row.to_dict())国籍对数据进行分组
# 使用collections模块中的defaultdict来创建一个默认字典
# defaultdict在访问不存在的键时会自动创建一个空列表
by_nationality = collections.defaultdict(list)
# 遍历surnames DataFrame的每一行
for _, row in surnames.iterrows():
# 将每行数据转换为字典格式
# 然后根据国籍(row.nationality)作为键,将该行数据的字典追加到对应的列表中
by_nationality[row.nationality].append(row.to_dict())
# 创建最终的数据列表,用于存储带有split标签的数据点
final_list = []
# 设置随机数生成器的种子,以确保结果的可重复性
np.random.seed(args.seed)
# 遍历按国籍排序的姓名列表
for _, item_list in sorted(by_nationality.items()):
# 打乱当前国籍的姓名列表,以实现随机分配
np.random.shuffle(item_list)
# 计算当前国籍的姓名列表中训练集、验证集和测试集的样本数量
n = len(item_list)
n_train = int(args.train_proportion * n)
n_val = int(args.val_proportion * n)
n_test = int(args.test_proportion * n)
# 为每个数据点添加一个split属性,表示它属于哪个数据集
for item in item_list[:n_train]: # 前n_train个数据点标记为训练集
item['split'] = 'train'
for item in item_list[n_train:n_train+n_val]: # 接下来的n_val个数据点标记为验证集
item['split'] = 'val'
for item in item_list[n_train+n_val:]: # 剩余的数据点标记为测试集
item['split'] = 'test'
# 将当前国籍的数据点添加到最终列表中
final_list.extend(item_list)
# Write split data to file
final_surnames = pd.DataFrame(final_list)
final_surnames.split.value_counts()
# 使用head方法显示final_surnames DataFrame的前几行数据,默认显示前5行
# 这有助于快速查看DataFrame的内容和结构
final_surnames.head()
# 将处理后的数据写入CSV文件
# 假设final_surnames是一个包含最终数据的pandas DataFrame
# 使用to_csv方法将final_surnames DataFrame写入到指定的CSV文件路径
# 通过args.output_munged_csv获取输出CSV文件的路径
# index=False参数指定在写入CSV文件时不包含DataFrame的索引列
final_surnames.to_csv(args.output_munged_csv, index=False)
总结
在本研究中,我们深入探讨并比较了多层感知机(MLP)与卷积神经网络(CNN)在处理姓氏分类任务时的表现。实验的起点是对两种模型结构的初始化,配备了适宜的损失函数和优化算法,以适配各自的模型特性。
对于MLP模型,我们设计了一套由多个全连接层构成的网络,并集成了恰当的激活函数,随后在训练集上执行了迭代训练。这一过程使得MLP能够学习从姓氏到国籍的复杂映射。相较之下,CNN模型则通过卷积层和池化层的巧妙堆叠,自动提取出姓氏字符串中的空间特征,以实现高效的特征学习和分类。
在完成模型的训练后,我们通过在独立的测试集上进行评估,对两种模型的性能进行了客观的比较。结果显示,MLP在准确率上略胜一筹,这可能归功于其在捕捉数据特征和分类上的能力。尽管在具体的预测任务中,两种模型的性能差异并不明显,但MLP在训练和评估阶段的整体表现更为稳定。
通过这一对比实验,我们获得了对MLP和CNN在特定文本分类任务中优缺点的深刻理解。这不仅为我们在类似任务中选择合适的模型提供了指导,也为深度学习模型的进一步研究和实际应用提供了宝贵的经验和见解。未来,我们将继续探索和优化模型结构,以期在姓氏分类乃至更广泛的自然语言处理任务上取得更好的成果。