一、实验介绍
在本次实验中,通过“示例:带有多层感知器的姓氏分类”,学习多层感知器在多层分类中的应用。实验任务包括:
- 复习感知器及其局限性。
- 掌握每种类型的神经网络层对数据张量的大小和形状的影响。
- 尝试带有dropout的SurnameClassifier模型,观察其对结果的影响。
二、复习感知机的局限性
在上次实验中,我们通过观察感知器来介绍神经网络的基础,感知器是现存最简单的神经网络。感知器的一个历史性的缺点是它不能学习数据中存在的一些非常重要的模式。例如,查看下图中绘制的数据点。这相当于非此即彼(XOR)的情况,在这种情况下,决策边界不能是一条直线(也称为线性可分)。在这个例子中,感知器失败了。
三、掌握每种类型的神经网络层对数据张量的大小和形状的影响。
多层感知器(MLP)被认为是最基本的神经网络构建模块之一。感知器将数据向量作为输入,计算出一个输出值。在MLP中,许多感知器被分组,以便单个层的输出是一个新的向量,而不是单个输出值。在PyTorch中,正如您稍后将看到的,这只需设置线性层中的输出特性的数量即可完成。MLP的另一个方面是,它将多个层与每个层之间的非线性结合在一起。
1.A Simple Example: XOR
让我们看一下前面描述的XOR示例,看看感知器与MLP之间会发生什么。在这个例子中,我们在一个二元分类任务中训练感知器和MLP:星和圆。每个数据点是一个二维坐标。在不深入研究实现细节的情况下,最终的模型预测如下图所示。在这个图中,错误分类的数据点用黑色填充,而正确分类的数据点没有填充。在左边的面板中,从填充的形状可以看出,感知器在学习一个可以将星星和圆分开的决策边界方面有困难。然而,MLP(右面板)学习了一个更精确地对恒星和圆进行分类的决策边界。
图中,每个数据点的真正类是该点的形状:星形或圆形。错误的分类用块填充,正确的分类没有填充。这些线是每个模型的决策边界。在边的面板中,感知器学习—个不能正确地将圆与星分开的决策边界。事实上,没有一条线可以。在右动的面板中,MLP学会了从圆中分离星。
虽然在图中显示MLP有两个决策边界,这是它的优点,但它实际上只是一个决策边界!决策边界就是这样出现的,因为中间表示法改变了空间,使一个超平面同时出现在这两个位置上。在下图中,我们可以看到MLP计算的中间值。这些点的形状表示类(星形或圆形)。我们所看到的是,神经网络(本例中为MLP)已经学会了“扭曲”数据所处的空间,以便在数据通过最后一层时,用一线来分割它们。
相反,如下图所示,感知器没有额外的一层来处理数据的形状,直到数据变成线性可分的。
2.Implementing MLPs in PyTorch
在上一节中,我们概述了MLP的核心思想。在本节中,我们将介绍PyTorch中的一个实现。如前所述,MLP除了实验3中简单的感知器之外,还有一个额外的计算层。在我们在例4-1中给出的实现中,我们用PyTorch的两个线性模块实例化了这个想法。线性对象被命名为fc1和fc2,它们遵循一个通用约定,即将线性模块称为“完全连接层”,简称为“fc层”。除了这两个线性层外,还有一个修正的线性单元(ReLU)非线性(在实验3“激活函数”一节中介绍),它在被输入到第二个线性层之前应用于第一个线性层的输出。由于层的顺序性,必须确保层中的输出数量等于下一层的输入数量。使用两个线性层之间的非线性是必要的,因为没有它,两个线性层在数学上等价于一个线性层4,因此不能建模复杂的模式。MLP的实现只实现反向传播的前向传递。这是因为PyTorch根据模型的定义和向前传递的实现,自动计算出如何进行向后传递和梯度更新。
Example 4-1. Multilayer Perceptron
通过定义和实现了一个多层感知器(MLP)类 MultilayerPerceptron
,主要包括其初始化和前向传播逻辑。
def __init__(self, input_size, hidden_size=2, output_size=3,
num_hidden_layers=1, hidden_activation=nn.Sigmoid):
"""
初始化多层感知器(MLP)的结构
Args:
input_size (int): 输入层的大小
hidden_size (int): 隐藏层的大小
output_size (int): 输出层的大小
num_hidden_layers (int): 隐藏层的数量
hidden_activation (torch.nn.*): 隐藏层的激活函数类
"""
super(MultilayerPerceptron, self).__init__()
self.module_list = nn.ModuleList() # 初始化一个空的模块列表,用于存储层次结构
interim_input_size = input_size # 中间层的输入大小初始化为输入层大小
interim_output_size = hidden_size # 中间层的输出大小初始化为隐藏层大小
# 循环添加指定数量的隐藏层和激活函数
for _ in range(num_hidden_layers):
self.module_list.append(nn.Linear(interim_input_size, interim_output_size)) # 添加线性层
self.module_list.append(hidden_activation()) # 添加激活函数
interim_input_size = interim_output_size # 更新下一层的输入大小为当前层的输出大小
self.fc_final = nn.Linear(interim_input_size, output_size) # 添加最终的输出层
self.last_forward_cache = [] # 初始化一个列表,用于存储前向传播的中间结果
这一部分代码初始化了多层感知器的结构,包括指定隐藏层的数量和激活函数,并构建输入、隐藏和输出层。
def forward(self, x, apply_softmax=False):
"""
执行前向传播
Args:
x (torch.Tensor): 输入数据张量,形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用 softmax 激活函数(用于分类),
在使用交叉熵损失时应为 False
Returns:
torch.Tensor: 模型的输出张量,形状应为 (batch, output_dim)
"""
self.last_forward_cache = [] # 清空前向传播缓存
self.last_forward_cache.append(x.to("cpu").numpy()) # 存储输入数据
# 依次通过所有隐藏层和激活函数
for module in self.module_list:
x = module(x) # 数据通过当前模块(线性层或激活函数)
self.last_forward_cache.append(x.to("cpu").data.numpy()) # 存储中间结果
output = self.fc_final(x) # 数据通过最终的输出层
self.last_forward_cache.append(output.to("cpu").data.numpy()) # 存储输出结果
if apply_softmax:
output = F.softmax(output, dim=1) # 如果指定,应用 softmax 激活函数
return output # 返回最终输出
这一部分实现了前向传播过程,通过模型的层次结构对输入数据进行处理,并根据需要应用softmax激活函数。
Example 4-2. An example instantiation of an MLP
batch_size = 2 # number of samples input at once
input_dim = 3
hidden_dim = 100
output_dim = 4
# Initialize model
mlp = MultilayerPerceptron(input_dim, hidden_dim, output_dim)
print(mlp)
在例4-2中,我们实例化了MLP。由于MLP实现的通用性,可以为任何大小的输入建模。为了演示,我们使用大小为3的输入维度、大小为4的输出维度和大小为100的隐藏维度。请注意,在print语句的输出中,每个层中的单元数很好地排列在一起,以便为维度3的输入生成维度4的输出。
运行结果
Example 4-3. Testing the MLP with random inputs
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)
describe(x_input)
我们可以通过传递一些随机输入来快速测试模型的“连接”,如示例4-3所示。因为模型还没有经过训练,所以输出是随机的。在花费时间训练模型之前,这样做是一个有用的完整性检查。请注意PyTorch的交互性是如何让我们在开发过程中实时完成所有这些工作的,这与使用NumPy或panda没有太大区别。
运行结果
Example 4-4. MLP with apply_softmax=True
如果想将预测向量转换为概率,则需要额外的步骤。具体来说,需要softmax函数,它用于将一个值向量转换为概率。softmax有许多根。在物理学中,它被称为玻尔兹曼或吉布斯分布;在统计学中,它是多项式逻辑回归;在自然语言处理(NLP)社区,它是最大熵(MaxEnt)分类器。不管叫什么名字,这个函数背后的直觉是,大的正值会导致更高的概率,小的负值会导致更小的概率。在示例4-3中,apply_softmax参数应用了这个额外的步骤。在例4-4中,可以看到相同的输出,但是这次将apply_softmax标志设置为True:
y_output = mlp(x_input, apply_softmax=True)
describe(y_output)
运行结果
综上所述,mlp是将张量映射到其他张量的线性层。在每一对线性层之间使用非线性来打破线性关系,并允许模型扭曲向量空间。在分类设置中,这种扭曲应该导致类之间的线性可分性。另外,可以使用softmax函数将MLP输出解释为概率,但是不应该将softmax与特定的损失函数一起使用,因为底层实现可以利用高级数学/计算捷径。
四、实验步骤
1.The Surname Dataset
姓氏数据集,它收集了来自18个不同国家的10,000个姓氏,这些姓氏是作者从互联网上不同的姓名来源收集的。该数据集将在本课程实验的几个示例中重用,并具有一些使其有趣的属性。第一个性质是它是相当不平衡的。排名前三的课程占数据的60%以上:27%是英语,21%是俄语,14%是阿拉伯语。剩下的15个民族的频率也在下降——这也是语言特有的特性。第二个特点是,在国籍和姓氏正字法(拼写)之间有一种有效和直观的关系。有些拼写变体与原籍国联系非常紧密(比如“O ‘Neill”、“Antonopoulos”、“Nagasawa”或“Zhu”)。
为了创建最终的数据集,我们从一个比课程补充材料中包含的版本处理更少的版本开始,并执行了几个数据集修改操作。第一个目的是减少这种不平衡——原始数据集中70%以上是俄文,这可能是由于抽样偏差或俄文姓氏的增多。为此,我们通过选择标记为俄语的姓氏的随机子集对这个过度代表的类进行子样本。接下来,我们根据国籍对数据集进行分组,并将数据集分为三个部分:70%到训练数据集,15%到验证数据集,最后15%到测试数据集,以便跨这些部分的类标签分布具有可比性。
Example 4-5. Implementing SurnameDataset.__getitem__()
class SurnameDataset(Dataset):
def __init__(self, surname_df, vectorizer):
"""
初始化 SurnameDataset 类
参数:
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)
def __getitem__(self, index):
"""获取数据集中的一个样本
参数:
index (int): 样本索引
返回:
包含样本特征 (x_surname) 和标签 (y_nationality) 的字典
"""
row = self._target_df.iloc[index]
surname_matrix = self._vectorizer.vectorize(row.surname)
nationality_index = self._vectorizer.nationality_vocab.lookup_token(row.nationality)
return {'x_surname': surname_matrix,
'y_nationality': nationality_index}
def generate_batches(dataset, batch_size, shuffle=True,
drop_last=True, device="cpu"):
"""
包装 PyTorch DataLoader 的生成器函数
确保每个张量位于正确的设备位置
参数:
dataset (Dataset): 数据集实例
batch_size (int): 批量大小
shuffle (bool): 是否打乱数据
drop_last (bool): 是否丢弃最后一个不足 batch_size 的批次
device (str): 设备 ("cpu" 或 "cuda")
"""
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.Vocabulary, Vectorizer, and DataLoader
THE VOCABULARY CLASS
本例中使用的词汇类与“example: Classifying Sentiment of Restaurant Reviews”中的词汇完全相同,该词汇类将Yelp评论中的单词映射到对应的整数。简要概述一下,词汇表是两个Python字典的协调,这两个字典在令牌(在本例中是字符)和整数之间形成一个双射;也就是说,第一个字典将字符映射到整数索引,第二个字典将整数索引映射到字符。add_token方法用于向词汇表中添加新的令牌,lookup_token方法用于检索索引,lookup_index方法用于检索给定索引的令牌(在推断阶段很有用)。与Yelp评论的词汇表不同,我们使用的是one-hot词汇表,不计算字符出现的频率,只对频繁出现的条目进行限制。这主要是因为数据集很小,而且大多数字符足够频繁。
THE SURNAMEVECTORIZER
虽然词汇表将单个令牌(字符)转换为整数,但SurnameVectorizer负责应用词汇表并将姓氏转换为向量。实例化和使用非常类似于“示例:对餐馆评论的情绪进行分类”中的ReviewVectorizer,但有一个关键区别:字符串没有在空格上分割。姓氏是字符的序列,每个字符在我们的词汇表中是一个单独的标记。然而,在“卷积神经网络”出现之前,我们将忽略序列信息,通过迭代字符串输入中的每个字符来创建输入的收缩one-hot向量表示。我们为以前未遇到的字符指定一个特殊的令牌,即UNK。由于我们仅从训练数据实例化词汇表,而且验证或测试数据中可能有惟一的字符,所以在字符词汇表中仍然使用UNK符号。
Example 4-6. Implementing SurnameVectorizer
Vocabulary类
class Vocabulary(object):
"""处理文本并提取词汇表以进行映射的类"""
def __init__(self, token_to_idx=None, add_unk=True, unk_token="<UNK>"):
"""
初始化 Vocabulary 类
参数:
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):
""" 从序列化字典实例化 Vocabulary """
return cls(**contents)
def add_token(self, token):
"""根据标记更新映射字典
参数:
token (str): 要添加到词汇表中的项
返回:
index (int): 与标记对应的整数
"""
try:
index = self._token_to_idx[token]
except KeyError:
index = len(self._token_to_idx)
self._token_to_idx[token] = index
self._idx_to_token[index] = token
return index
def add_many(self, tokens):
"""将多个标记添加到词汇表
参数:
tokens (list): 一个字符串标记的列表
返回:
indices (list): 与标记对应的索引列表
"""
return [self.add_token(token) for token in tokens]
def lookup_token(self, token):
"""检索与标记关联的索引,如果标记不存在则返回 UNK 索引
参数:
token (str): 要查找的标记
返回:
index (int): 与标记对应的索引
注意:
`unk_index` 需要大于等于0(已添加到词汇表)以实现 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):
"""返回与索引关联的标记
参数:
index (int): 要查找的索引
返回:
token (str): 与索引对应的标记
抛出:
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)
SurnameVectorizer类
class SurnameVectorizer(object):
""" 协调词汇表并将其投入使用的向量化器 """
def __init__(self, surname_vocab, nationality_vocab, max_surname_length):
"""
初始化 SurnameVectorizer 类
参数:
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):
"""
参数:
surname (str): 姓氏
返回:
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):
"""从数据集的 dataframe 实例化向量化器
参数:
surname_df (pandas.DataFrame): 姓氏数据集
返回:
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)
@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,
max_surname_length=contents['max_surname_length'])
def to_serializable(self):
return {'surname_vocab': self.surname_vocab.to_serializable(),
'nationality_vocab': self.nationality_vocab.to_serializable(),
'max_surname_length': self._max_surname_length}
3.The Surname Classifier Model
SurnameClassifier是本实验前面介绍的MLP的实现(示例4-7)。第一个线性层将输入向量映射到中间向量,并对该向量应用非线性。第二线性层将中间向量映射到预测向量。
在最后一步中,可选地应用softmax操作,以确保输出和为1;这就是所谓的“概率”。它是可选的原因与我们使用的损失函数的数学公式有关——交叉熵损失。我们研究了“损失函数”中的交叉熵损失。回想一下,交叉熵损失对于多类分类是最理想的,但是在训练过程中软最大值的计算不仅浪费而且在很多情况下并不稳定。
Example 4-7. The SurnameClassifier as an MLP
class SurnameClassifier(nn.Module):
""" 用于分类姓氏的两层多层感知器 """
def __init__(self, input_dim, hidden_dim, output_dim):
"""
初始化分类器
参数:
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):
"""
分类器的前向传播
参数:
x_in (torch.Tensor): 输入数据张量,形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用 softmax 激活函数,
如果使用交叉熵损失,应为 False
返回:
结果张量,形状应为 (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 # 返回最终预测向量
-
初始化部分:
- 定义了两层线性层(
self.fc1
和self.fc2
),并通过__init__
函数进行初始化。 input_dim
是输入向量的大小,hidden_dim
是隐藏层的大小,output_dim
是输出层的大小。
- 定义了两层线性层(
-
前向传播部分:
- 输入数据
x_in
首先通过第一层线性层fc1
,并应用 ReLU 激活函数。 - 然后通过第二层线性层
fc2
。 - 根据
apply_softmax
参数决定是否应用 softmax 激活函数。 - 最终返回
prediction_vector
,即模型的输出。
- 输入数据
4.The Training Routine
虽然我们使用了不同的模型、数据集和损失函数,但是训练例程是相同的。因此,在例4-8中,我们只展示了args以及本例中的训练例程与“示例:餐厅评论情绪分类”中的示例之间的主要区别。
Example 4-8. The args for classifying surnames with an MLP
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,
# 添加设备选项
device=torch.device("cuda" if torch.cuda.is_available() and args.cuda else "cpu")
)
训练中最显著的差异与模型中输出的种类和使用的损失函数有关。在这个例子中,输出是一个多类预测向量,可以转换为概率。正如在模型描述中所描述的,这种输出的损失类型仅限于CrossEntropyLoss和NLLLoss。由于它的简化,我们使用了CrossEntropyLoss。
在例4-9中,我们展示了数据集、模型、损失函数和优化器的实例化。这些实例应该看起来与“示例:将餐馆评论的情绪分类”中的实例几乎相同。事实上,在本课程后面的实验中,这种模式将对每个示例进行重复。
5.THE TRAINING LOOP
与“Example: Classifying Sentiment of Restaurant Reviews”中的训练循环相比,本例的训练循环除了变量名以外几乎是相同的。具体来说,示例4-10显示了使用不同的key从batch_dict中获取数据。除了外观上的差异,训练循环的功能保持不变。利用训练数据,计算模型输出、损失和梯度。然后,使用梯度来更新模型。
Example 4-10. A snippet of the training loop
# 加载数据集并创建向量化器
dataset = SurnameDataset.load_dataset_and_make_vectorizer(args.surname_csv)
vectorizer = dataset.get_vectorizer()
# 实例化模型
classifier = SurnameClassifier(
input_dim=len(vectorizer.surname_vocab), # 输入维度为姓氏词汇表的长度
hidden_dim=args.hidden_dim, # 隐藏层维度从参数中获取
output_dim=len(vectorizer.nationality_vocab) # 输出维度为国籍词汇表的长度
)
# 将模型移到指定设备(如 CPU 或 GPU)
classifier = classifier.to(args.device)
# 使用类别权重实例化交叉熵损失函数
loss_func = nn.CrossEntropyLoss(weight=dataset.class_weights)
# 实例化 Adam 优化器
optimizer = optim.Adam(classifier.parameters(), lr=args.learning_rate) # 学习率从参数中获取
6.Regularizing MLPs: Weight Regularization and Structural Regularization (or Dropout)
DROPOUT
简单地说,在训练过程中,dropout有一定概率使属于两个相邻层的单元之间的连接减弱。这有什么用呢?我们从斯蒂芬•梅里蒂(Stephen Merity)的一段直观(且幽默)的解释开始:“Dropout,简单地说,是指如果你能在喝醉的时候反复学习如何做一件事,那么你应该能够在清醒的时候做得更好。这一见解产生了许多最先进的结果和一个新兴的领域。”
神经网络——尤其是具有大量分层的深层网络——可以在单元之间创建有趣的相互适应。“Coadaptation”是神经科学中的一个术语,但在这里它只是指一种情况,即两个单元之间的联系变得过于紧密,而牺牲了其他单元之间的联系。这通常会导致模型与数据过拟合。通过概率地丢弃单元之间的连接,我们可以确保没有一个单元总是依赖于另一个单元,从而产生健壮的模型。dropout不会向模型中添加额外的参数,但是需要一个超参数——“drop probability”。drop probability,它是单位之间的连接drop的概率。通常将下降概率设置为0.5。例4-13给出了一个带dropout的MLP的重新实现。
Example 4-13. MLP with dropout
class MultilayerPerceptron(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
"""
初始化多层感知器(MLP)
参数:
input_dim (int): 输入向量的大小
hidden_dim (int): 第一层线性层的输出大小
output_dim (int): 第二层线性层的输出大小
"""
super(MultilayerPerceptron, self).__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim) # 第一层线性层
self.fc2 = nn.Linear(hidden_dim, output_dim) # 第二层线性层
def forward(self, x_in, apply_softmax=False):
"""
MLP 的前向传播
参数:
x_in (torch.Tensor): 输入数据张量,形状应为 (batch, input_dim)
apply_softmax (bool): 是否应用 softmax 激活函数
如果使用交叉熵损失,应为 False
返回:
结果张量,形状应为 (batch, output_dim)
"""
intermediate = F.relu(self.fc1(x_in)) # 通过第一层并应用 ReLU 激活函数
output = self.fc2(F.dropout(intermediate, p=0.5)) # 通过第二层并应用 dropout
if apply_softmax:
output = F.softmax(output, dim=1) # 应用 softmax 激活函数
return output # 返回最终输出
运行结果
7. Convolutional Neural Networks
在本实验的第一部分中,我们深入研究了MLPs、由一系列线性层和非线性函数构建的神经网络。mlp不是利用顺序模式的最佳工具。例如,在姓氏数据集中,姓氏可以有(不同长度的)段,这些段可以显示出相当多关于其起源国家的信息(如“O’Neill”中的“O”、“Antonopoulos”中的“opoulos”、“Nagasawa”中的“sawa”或“Zhu”中的“Zh”)。这些段的长度可以是可变的,挑战是在不显式编码的情况下捕获它们。
在本节中,我们将介绍卷积神经网络(CNN),这是一种非常适合检测空间子结构(并因此创建有意义的空间子结构)的神经网络。CNNs通过使用少量的权重来扫描输入数据张量来实现这一点。通过这种扫描,它们产生表示子结构检测(或不检测)的输出张量。
在本节的其余部分中,我们首先描述CNN的工作方式,以及在设计CNN时应该考虑的问题。我们深入研究CNN超参数,目的是提供直观的行为和这些超参数对输出的影响。最后,我们通过几个简单的例子逐步说明CNNs的机制。在“示例:使用CNN对姓氏进行分类”中,我们将深入研究一个更广泛的示例。
CNN Hyperparameters
为了理解不同的设计决策对CNN意味着什么,我们在下图中展示了一个示例。在本例中,单个“核”应用于输入矩阵。卷积运算(线性算子)的精确数学表达式对于理解这一节并不重要,但是从这个图中可以直观地看出,核是一个小的方阵,它被系统地应用于输入矩阵的不同位置。
输入矩阵与单个产生输出矩阵的卷积核(也称为特征映射)在输入矩阵的每个位置应用内核。在每个应用程序中,内核乘以输入矩阵的值及其自身的值,然后将这些乘法相加kernel具有以下超参数配置:kernel_size=2,stride=1,padding=0,以及dilation=1。这些超参数解释如下:
虽然经典卷积是通过指定核的具体值来设计的,但是CNN是通过指定控制CNN行为的超参数来设计的,然后使用梯度下降来为给定数据集找到最佳参数。两个主要的超参数控制卷积的形状(称为kernel_size)和卷积将在输入数据张量(称为stride)中相乘的位置。还有一些额外的超参数控制输入数据张量被0填充了多少(称为padding),以及当应用到输入数据张量(称为dilation)时,乘法应该相隔多远。在下面的小节中,我们将更详细地介绍这些超参数。
8.Implementing CNNs in PyTorch
在本节中,我们将通过端到端示例来利用上一节中介绍的概念。一般来说,神经网络设计的目标是找到一个能够完成任务的超参数组态。我们再次考虑在“示例:带有多层感知器的姓氏分类”中引入的现在很熟悉的姓氏分类任务,但是我们将使用CNNs而不是MLP。我们仍然需要应用最后一个线性层,它将学会从一系列卷积层创建的特征向量创建预测向量。这意味着目标是确定卷积层的配置,从而得到所需的特征向量。所有CNN应用程序都是这样的:首先有一组卷积层,它们提取一个feature map,然后将其作为上游处理的输入。在分类中,上游处理几乎总是应用线性(或fc)层。
本课程中的实现遍历设计决策,以构建一个特征向量。我们首先构造一个人工数据张量,以反映实际数据的形状。数据张量的大小是三维的——这是向量化文本数据的最小批大小。如果你对一个字符序列中的每个字符使用onehot向量,那么onehot向量序列就是一个矩阵,而onehot矩阵的小批量就是一个三维张量。使用卷积的术语,每个onehot(通常是词汇表的大小)的大小是”input channels”的数量,字符序列的长度是“width”。
在例4-14中,构造特征向量的第一步是将PyTorch的Conv1d类的一个实例应用到三维数据张量。通过检查输出的大小,你可以知道张量减少了多少。建议参考图4-9来直观地解释为什么输出张量在收缩。
Example 4-14. Artificial data and using a Conv1d class
# 设置参数
batch_size = 2
one_hot_size = 10
sequence_width = 7
# 生成随机数据
data = torch.randn(batch_size, one_hot_size, sequence_width)
# 定义 Conv1d 层
conv1 = nn.Conv1d(in_channels=one_hot_size, out_channels=16, kernel_size=3)
# 进行前向传播
intermediate1 = conv1(data)
# 打印数据尺寸
print("Input size:", data.size())
print("Output size:", intermediate1.size())
运行结果
Example 4-15. The iterative application of convolutions to data
conv2 = nn.Conv1d(in_channels=16, out_channels=32, kernel_size=3)
conv3 = nn.Conv1d(in_channels=32, out_channels=64, kernel_size=3)
intermediate2 = conv2(intermediate1)
intermediate3 = conv3(intermediate2)
print(intermediate2.size())
print(intermediate3.size())
运行结果
y_output = intermediate3.squeeze()
print(y_output.size())
运行结果
The SurnameDataset
虽然姓氏数据集之前在“示例:带有多层感知器的姓氏分类”中进行了描述,但建议参考“姓氏数据集”来了解它的描述。尽管我们使用了来自“示例:带有多层感知器的姓氏分类”中的相同数据集,但在实现上有一个不同之处:数据集由onehot向量矩阵组成,而不是一个收缩的onehot向量。为此,我们实现了一个数据集类,它跟踪最长的姓氏,并将其作为矩阵中包含的行数提供给矢量化器。列的数量是onehot向量的大小(词汇表的大小)。示例4-17显示了对SurnameDataset.__getitem__
的更改;我们显示对SurnameVectorizer的更改。在下一小节向量化。
我们使用数据集中最长的姓氏来控制onehot矩阵的大小有两个原因。首先,将每一小批姓氏矩阵组合成一个三维张量,要求它们的大小相同。其次,使用数据集中最长的姓氏意味着可以以相同的方式处理每个小批处理。
Example 4-17. SurnameDataset modified for passing the maximum surname length
class SurnameDataset(Dataset):
def __getitem__(self, index):
row = self._target_df.iloc[index]
surname_matrix = \
self._vectorizer.vectorize(row.surname, self._max_seq_length)
nationality_index = \
self._vectorizer.nationality_vocab.lookup_token(row.nationality)
return {'x_surname': surname_matrix,
'y_nationality': nationality_index}
Example 4-18. Implementing the Surname Vectorizer for CNNs
class SurnameVectorizer(object):
""" 协调词汇表并加以利用的向量化器"""
def vectorize(self, surname):
"""
Args:
surname (str): 姓氏
Returns:
one_hot_matrix (np.ndarray): 一个独热向量矩阵
"""
one_hot_matrix_size = (len(self.character_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.character_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的一个实例
"""
character_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:
character_vocab.add_token(letter)
nationality_vocab.add_token(row.nationality)
return cls(character_vocab, nationality_vocab, max_surname_length)
Example 4-19. The CNN-based SurnameClassifier
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激活的标志
如果与交叉熵损失一起使用,则应为假
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)
return prediction_vector
Example 4-20. Input arguments to the CNN surname classifier
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,
# 省略运行时以节省空间...
)
9.Model Evaluation and Prediction
要理解模型的性能,需要对性能进行定量和定性的度量。下面将描述这两个度量的基本组件。建议你扩展它们,以探索该模型及其所学习到的内容。
Evaluating on the Test Dataset 正如“示例:带有多层感知器的姓氏分类”中的示例与本示例之间的训练例程没有变化一样,执行评估的代码也没有变化。总之,调用分类器的eval()
方法来防止反向传播,并迭代测试数据集。与 MLP 约 50% 的性能相比,该模型的测试集性能准确率约为56%。尽管这些性能数字绝不是这些特定架构的上限,但是通过一个相对简单的CNN模型获得的改进应该足以让您在文本数据上尝试CNNs。
Classifying or retrieving top predictions for a new surname
在本例中,predict_nationality()
函数的一部分发生了更改,如示例4-21所示:我们没有使用视图方法重塑新创建的数据张量以添加批处理维度,而是使用PyTorch的unsqueeze()
函数在批处理应该在的位置添加大小为1的维度。相同的更改反映在predict_topk_nationality()
函数中。
Example 4-21. Using the trained model to make predictions
def predict_nationality(surname, classifier, vectorizer):
"""从新的姓氏预测国籍
Args:
surname (str): 要分类的姓氏
classifier (SurnameClassifer): 分类器的实例
vectorizer (SurnameVectorizer): 相应的向量化器
Returns:
一个字典,包含最可能的国籍及其概率
"""
vectorized_surname = vectorizer.vectorize(surname)
vectorized_surname = torch.tensor(vectorized_surname).unsqueeze(0)
result = classifier(vectorized_surname, apply_softmax=True)
probability_values, indices = result.max(dim=1)
index = indices.item()
predicted_nationality = vectorizer.nationality_vocab.lookup_index(index)
probability_value = probability_values.item()
return {'nationality': predicted_nationality, 'probability': probability_value}
10. Batch Normalization (BatchNorm)
批处理标准化是设计网络时经常使用的一种工具。BatchNorm对CNN的输出进行转换,方法是将激活量缩放为零均值和单位方差。它用于Z-transform的平均值和方差值每批更新一次,这样任何单个批中的波动都不会太大地移动或影响它。BatchNorm允许模型对参数的初始化不那么敏感,并且简化了学习速率的调整(Ioffe and Szegedy, 2015)。在PyTorch中,批处理规范是在nn模块中定义的。例4-22展示了如何用卷积和线性层实例化和使用批处理规范。
Example 4-22. Using s Conv1D layer with batch normalization.
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
self.conv1 = nn.Conv1d(in_channels=1, out_channels=10, kernel_size=5, stride=1)
self.conv1_bn = nn.BatchNorm1d(num_features=10)
# 这里可以添加更多的层
def forward(self, x):
x = F.relu(self.conv1(x))
x = self.conv1_bn(x)
# 这里可以添加更多的前向传播逻辑
return x
# 示例数据
batch_size = 2
sequence_length = 20
data = torch.randn(batch_size, 1, sequence_length)
# 实例化模型
model = SimpleCNN()
# 前向传播
output = model(data)
print("Input size:", data.size())
print("Output size:", output.size())
运行结果
五、实验结果
通过实验,我们发现多层感知器模型在处理字符级别的分类任务时表现良好。实验结果显示,带有dropout的模型在测试集上的表现优于不带dropout的模型,说明dropout技术有效地减少了过拟合,提高了模型的泛化能力。
六、实验心得
本次实验让我深入理解了多层感知器的结构和训练过程,并掌握了数据预处理的基本方法。通过实践,我认识到数据预处理在模型训练中的重要性,以及选择合适的网络结构和训练策略对模型性能的影响。此外,通过比较不同模型的表现,我进一步理解了正则化技术(如dropout)的作用和意义。
总的来说,这次实验不仅加深了我对神经网络的理解,还提升了我的编程和调试能力,对今后从事相关研究工作有很大的帮助。