多层感知机

多层感知机即MLP神经网络的定义已经在神经网络:表述所详细描述过了,这一章主要描述在Pytorch中的实现

激活函数

激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。 大多数激活函数都是非线性的。 由于激活函数是深度学习的基础,下面简要介绍一些常见的激活函数

ReLU函数

最受欢迎的激活函数是修正线性单元(Rectified linear unit,ReLU), 因为它实现简单,同时在各种预测任务中表现良好。 ReLU提供了一种非常简单的非线性变换。 给定元素$x$,ReLU函数被定义为该元素与0的最大值

通俗地说,ReLU函数通过将相应的活性值设为0,仅保留正元素并丢弃所有负元素。 为了直观感受一下,我们可以画出函数的曲线图。 正如从图中所看到,激活函数是分段线性的。

1
2
3
x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.relu(x)
d2l.plot(x.detach(), y.detach(), 'x', 'relu(x)', figsize=(5, 2.5))

../_images/output_mlp_76f463_21_0.svg

当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。 注意,当输入值精确等于0时,ReLU函数不可导。 在此时,我们默认使用左侧的导数,即当输入为0时导数为0。 我们可以忽略这种情况,因为输入可能永远都不会是0

使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题

其他的激活函数,比如sigmoid函数之前已经介绍过了,所以略过


从零实现

1
2
3
4
5
6
import torch
from torch import nn
from d2l import torch as d2l

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

初始化模型参数

Fashion-MNIST中的每个图像由 28×28=784个灰度像素值组成。 所有图像共分为10个类别。 忽略像素之间的空间结构, 我们可以将每个图像视为具有784个输入特征 和10个类的简单分类数据集。 首先,我们将实现一个具有单隐藏层的多层感知机, 它包含256个隐藏单元。 注意,我们可以将这两个变量都视为超参数。 通常,我们选择2的若干次幂作为层的宽度。 因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。

我们用几个张量来表示我们的参数。 注意,对于每一层我们都要记录一个权重矩阵和一个偏置向量。 跟以前一样,我们要为损失关于这些参数的梯度分配内存。

1
2
3
4
5
6
7
8
9
10
11
num_inputs, num_outputs, num_hiddens = 784, 10, 256

W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens, requires_grad=True) * 0.01)

b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))

W2 = nn.Parameter(torch.randn(num_hiddens, num_outputs, requires_grad=True) * 0.01)

b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))

params = [W1, b1, W2, b2]

激活函数

RELU函数

1
2
3
def relu(X):
a = torch.zeros_like(X)
return torch.max(X, a)

模型

因为我们忽略了空间结构, 所以我们使用reshape将每个二维图像转换为一个长度为num_inputs的向量。 只需几行代码就可以实现我们的模型。

1
2
3
4
def net(X):
X = X.reshape((-1, num_inputs))
H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法
return (H@W2 + b2)

损失函数

1
loss = nn.CrossEntropyLoss(reduction='none')

训练

直接调用d2l包的train_ch3函数

1
2
3
num_epochs, lr = 10, 0.1
updater = torch.optim.SGD(params, lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, updater)

../_images/output_mlp-scratch_106d07_81_0.svg


简洁实现

1
2
3
import torch
from torch import nn
from d2l import torch as d2l

与softmax回归的简洁实现相比, 唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。 第一层是隐藏层,它包含256个隐藏单元,并使用了ReLU激活函数。 第二层是输出层。

1
2
3
4
5
6
7
8
9
10
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10))

def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);
  • nn.Sequential是一个容器,按照在其中添加模块的顺序执行这些模块。这允许你快速地堆叠不同的层来构建神经网络。
  • nn.Flatten()层将输入的二维图像张量自动展平成一维张量,以便后续的全连接层(nn.Linear)可以处理它们。
  • nn.Linear(784, 256)定义了一个全连接层,它接受784个输入特征并输出256个特征。这些特征数对应于隐藏层的神经元数量。
  • nn.ReLU()是一个非线性激活函数,用于引入非线性,使得网络可以学习复杂的模式。
  • nn.Linear(256, 10)定义了第二个全连接层,它将隐藏层的256个特征映射到10个输出特征,这10个特征对应于分类任务的类别数量。
1
2
3
4
5
6
batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)

train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

K折交叉验证

原则上,在我们确定所有的超参数之前,我们不希望用到测试集。 如果我们在模型选择过程中使用测试数据,可能会有过拟合测试数据的风险,那就麻烦大了。 如果我们过拟合了训练数据,还可以在测试数据上的评估来判断过拟合。 但是如果我们过拟合了测试数据,我们又该怎么知道呢?

因此,我们决不能依靠测试数据进行模型选择。 然而,我们也不能仅仅依靠训练数据来选择模型,因为我们无法估计训练数据的泛化误差。

在实际应用中,情况变得更加复杂。 虽然理想情况下我们只会使用测试数据一次, 以评估最好的模型或比较一些模型效果,但现实是测试数据很少在使用一次后被丢弃。 我们很少能有充足的数据来对每一轮实验采用全新测试集。

解决此问题的常见做法是将我们的数据分成三份, 除了训练和测试数据集之外,还增加一个验证数据集(validation dataset), 也叫验证集(validation set)。

当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。 这个问题的一个流行的解决方案是采用K折交叉验证。 这里,原始训练数据被分成$K$个不重叠的子集。 然后执行$K$次模型训练和验证,每次在$K−1$个子集上进行训练, 并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。 最后,通过对$K$次实验的结果取平均来估计训练和验证误差。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#获取K折验证数据集
def get_k_fold_data(k, i, X, y):

"""
参数:
- k (int): K折交叉验证中的“折”(fold)数,表示将数据集分割成多少份。
- i (int): 指定当前迭代使用哪一折作为验证集,范围从0到k-1。
- X (Tensor): 完整的特征数据集,假设其形状为 (样本数, 特征数)。
- y (Tensor): 与X对应的标签数据集,假设其形状为 (样本数,) 或 (样本数, 标签数)。
"""
# 确保k大于1,因为至少需要分成两折才能进行交叉验证
assert k > 1
# 计算每一折的大小,整数除法
fold_size = X.shape[0] // k

# 初始化训练集和验证集
X_train, y_train = None, None

# 遍历每一折
for j in range(k):
# 为当前折生成索引,使用切片(slice)来获取数据的一个子集
idx = slice(j * fold_size, (j + 1) * fold_size)

# 根据索引提取当前折的数据部分
X_part, y_part = X[idx, :], y[idx]

# 如果当前折是验证集
if j == i:
# 直接将当前折设为验证集
X_valid, y_valid = X_part, y_part
# 如果是第一个训练折
elif X_train is None:
# 初始化训练集
X_train, y_train = X_part, y_part
else:
# 如果已经有训练数据,将当前折的数据添加到训练集中
X_train = torch.cat([X_train, X_part], 0)
y_train = torch.cat([y_train, y_part], 0)

# 返回训练数据集、训练标签、验证数据集、验证标签
return X_train, y_train, X_valid, y_valid

$K$折交叉验证过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def k_fold(k, X_train, y_train, num_epochs, learning_rate, weight_decay, batch_size):
"""
执行K折交叉验证。
参数:
- k (int): 折数,即将数据集分成多少份。
- X_train (Tensor): 完整的训练数据特征。
- y_train (Tensor): 完整的训练数据标签。
- num_epochs (int): 每次训练时的迭代轮数。
- learning_rate (float): 学习率。
- weight_decay (float): 权重衰减(L2惩罚)。
- batch_size (int): 批量大小。

返回:
- train_l_sum / k (float): K折交叉验证的平均训练损失。
- valid_l_sum / k (float): K折交叉验证的平均验证损失。
"""

# 初始化总的训练和验证损失
train_l_sum, valid_l_sum = 0, 0

# 对每一折进行迭代
for i in range(k):
# 获取当前折的训练和验证数据
data = get_k_fold_data(k, i, X_train, y_train)

# 初始化模型
net = get_net()

# 训练模型,并获取训练和验证损失列表
train_ls, valid_ls = train(net, *data, num_epochs, learning_rate, weight_decay, batch_size)

# 累加最后一次迭代的训练和验证损失
train_l_sum += train_ls[-1]
valid_l_sum += valid_ls[-1]

# 如果是第一折,绘制训练和验证损失的变化曲线
if i == 0:
d2l.plot(list(range(1, num_epochs + 1)), [train_ls, valid_ls],
xlabel='epoch', ylabel='rmse', xlim=[1, num_epochs],
legend=['train', 'valid'], yscale='log')

# 打印当前折的训练和验证损失
print(f'折{i + 1},训练log rmse{float(train_ls[-1]):f}, '
f'验证log rmse{float(valid_ls[-1]):f}')

# 计算并返回K折交叉验证的平均训练和验证损失
return train_l_sum / k, valid_l_sum / k

正则化

在训练参数化机器学习模型时, 权重衰减(weight decay)是最广泛使用的正则化的技术之一, 它通常也被称为$L_2$正则化。 这项技术通过函数与零的距离来衡量函数的复杂度, 因为在所有函数$f$中,函数$f=0$(所有输入都得到值0) 在某种意义上是最简单的

首先,我们将定义一个函数来随机初始化模型参数。

1
2
3
4
def init_params():
w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)
return [w, b]

定义$L_2$范数惩罚

1
2
def l2_penalty(w):
return torch.sum(w.pow(2)) / 2

定义训练代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def train(lambd):
w, b = init_params()
net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
num_epochs, lr = 100, 0.003
animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
for epoch in range(num_epochs):
for X, y in train_iter:
# 增加了L2范数惩罚项,
# 广播机制使l2_penalty(w)成为一个长度为batch_size的向量
l = loss(net(X), y) + lambd * l2_penalty(w)
l.sum().backward()
d2l.sgd([w, b], lr, batch_size)
if (epoch + 1) % 5 == 0:
animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss),
d2l.evaluate_loss(net, test_iter, loss)))
print('w的L2范数是:', torch.norm(w).item())

先采用lambd=0禁用权重衰减

1
train(lambd=0)

w的L2范数是:12.963241577148438

../_images/output_weight-decay_ec9cc0_81_1.svg

然后使用lambd=3来运行代码

1
train(lambd=3)

w的L2范数是: 0.3556520938873291

../_images/output_weight-decay_ec9cc0_96_1.svg


简洁代码实现

由于权重衰减在神经网络优化中很常用, 深度学习框架为了便于我们使用权重衰减, 将权重衰减集成到优化算法中,以便与任何损失函数结合使用。 此外,这种集成还有计算上的好处, 允许在不增加任何额外的计算开销的情况下向算法中添加权重衰减。 由于更新的权重衰减部分仅依赖于每个参数的当前值, 因此优化器必须至少接触每个参数一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def train_concise(wd):
# 创建一个简单的线性模型
net = nn.Sequential(nn.Linear(num_inputs, 1))
# 初始化模型参数
for param in net.parameters():
param.data.normal_()
# 使用均方误差作为损失函数
loss = nn.MSELoss(reduction='none')
# 设置训练的轮数和学习率
num_epochs, lr = 100, 0.003
# 配置优化器,为权重参数应用L2正则化,但偏置参数不应用衰减
trainer = torch.optim.SGD([
{"params":net[0].weight,'weight_decay': wd}, # 为权重参数应用权重衰减
{"params":net[0].bias}], lr=lr) # 偏置参数没有衰减
# 用于绘图的实用工具
animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
# 训练循环
for epoch in range(num_epochs):
for X, y in train_iter:
trainer.zero_grad() # 清除之前的梯度
l = loss(net(X), y) # 计算当前批次的损失
l.mean().backward() # 反向传播
trainer.step() # 更新模型参数
# 每5个epoch评估一次模型的训练和测试损失,并更新绘图
if (epoch + 1) % 5 == 0:
animator.add(epoch + 1,
(d2l.evaluate_loss(net, train_iter, loss),
d2l.evaluate_loss(net, test_iter, loss)))
# 打印权重的L2范数,以观察权重大小
print('w的L2范数:', net[0].weight.norm().item())

这段代码演示了如何使用PyTorch的高级API来实现带有权重衰减的模型训练过程。通过torch.optim.SGD配置中的weight_decay参数,我们可以为模型的权重参数添加L2正则化,而通过将偏置参数单独列出并不为其设置weight_decay,我们避免了对偏置参数应用权重衰减,这是因为偏置参数的正则化通常对控制模型复杂度的影响较小,而且可能会导致过度正则化的问题。整个训练过程中,我们定期评估模型在训练集和测试集上的损失,以监控模型的学习进度。


Dropout

原理

丢弃法(Dropout)是一种在深度学习中常用的正则化技术,由Hinton和他的学生在2012年提出。它被广泛应用于减少神经网络在训练过程中的过拟合。过拟合是机器学习模型面临的常见问题,发生在模型对训练数据学得太好,以至于失去了泛化到未见数据的能力。

丢弃法的工作原理相当直接:在训练过程中,通过随机选择忽略网络中的一部分神经元,即在每个训练批次中,每个神经元都有一定概率被暂时从网络中丢弃,不参与这次前向和后向传播过程。这个概率通常是一个超参数,由开发者设置。被丢弃的神经元不会在这次迭代中进行权重更新

标准暂退法正则化中,通过按保留(未丢弃)的节点的分数进行规范化来消除每一层的偏差。 换言之,每个中间活性值$ℎ$以暂退概率p由随机变量$ℎ’$替换,如下所示:

根据此模型的设计,其期望值保持不变

../_images/dropout2.svg


代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch
from torch import nn
from d2l import torch as d2l

def dropout_layer(X, dropout):
# 确保dropout概率值在0到1之间
assert 0 <= dropout <= 1
# 如果dropout概率为1,意味着丢弃所有元素,直接返回一个全0的张量
if dropout == 1:
return torch.zeros_like(X)
# 如果dropout概率为0,意味着保留所有元素,直接返回输入张量
if dropout == 0:
return X
# 生成一个随机张量,其形状与输入X相同,元素值介于0到1之间
mask = (torch.rand(X.shape) > dropout).float()
# 应用生成的mask来随机丢弃一部分元素,通过与mask相乘实现
# 然后对剩下的元素进行缩放,以保持其总体期望值不变
return mask * X / (1.0 - dropout)
  • mask = (torch.rand(X.shape) > dropout).float()

    这行生成一个随机张量,其形状与X相同,元素值在0到1之间。然后,将这个随机张量与dropout概率进行比较,得到一个布尔张量(True表示元素值大于dropout概率,即这个神经元应该保留;False表示元素值小于或等于dropout概率,即这个神经元应该丢弃)。通过调用.float()将布尔张量转换为浮点张量(1.0表示保留,0.0表示丢弃)。

    由于mask是随机生成的,所以并不能确定每一次的mask刚好满足dropout的概率

  • return mask * X / (1.0 - dropout)

    使用生成的mask与输入X进行元素乘法,实现了随机丢弃神经元的效果。丢弃的神经元对应的元素会变为0。此外,对剩余的元素进行了缩放,即除以(1.0 - dropout),这是为了在训练过程中保持输入的总体期望值不变,因为在测试时,我们不会应用丢弃法,所有神经元都参与计算。


定义模型参数

使用引入的Fashion-MNIST数据集。 我们定义具有两个隐藏层的多层感知机,每个隐藏层包含256个单元。

1
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

定义模型

我们可以将暂退法应用于每个隐藏层的输出(在激活函数之后), 并且可以为每一层分别设置暂退概率: 常见的技巧是在靠近输入层的地方设置较低的暂退概率。 下面的模型将第一个和第二个隐藏层的暂退概率分别设置为0.2和0.5, 并且暂退法只在训练期间有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 定义两个丢弃率:对于第一个隐藏层是20%,对于第二个隐藏层是50%
dropout1, dropout2 = 0.2, 0.5

# 定义神经网络类Net,继承自nn.Module
class Net(nn.Module):
# 初始化网络结构
def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2,
is_training = True):
super(Net, self).__init__() # 调用父类的初始化方法
self.num_inputs = num_inputs # 输入层的维度
self.training = is_training # 标记是否为训练模式
# 定义第一个隐藏层,连接输入层和第一个隐藏层
self.lin1 = nn.Linear(num_inputs, num_hiddens1)
# 定义第二个隐藏层,连接第一个和第二个隐藏层
self.lin2 = nn.Linear(num_hiddens1, num_hiddens2)
# 定义输出层,连接第二个隐藏层和输出层
self.lin3 = nn.Linear(num_hiddens2, num_outputs)
# 定义ReLU激活函数
self.relu = nn.ReLU()

# 定义模型的前向传播路径
def forward(self, X):
# 将输入数据X通过第一个全连接层,然后应用ReLU激活函数
H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
# 如果是训练模式,对第一个隐藏层的输出应用丢弃法
if self.training == True:
H1 = dropout_layer(H1, dropout1)
# 将处理过的数据通过第二个全连接层,然后应用ReLU激活函数
H2 = self.relu(self.lin2(H1))
# 如果是训练模式,对第二个隐藏层的输出应用丢弃法
if self.training == True:
H2 = dropout_layer(H2, dropout2)
# 将处理过的数据通过输出层得到最终输出
out = self.lin3(H2)
return out

# 实例化网络模型,指定输入、输出和隐藏层的维度
net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)

这个网络模型通过在两个隐藏层后应用丢弃法来防止过拟合。在训练过程中,每一层的某些神经元输出会以一定概率(dropout1dropout2定义的)被随机丢弃


训练与测试

1
2
3
4
5
num_epochs, lr, batch_size = 10, 0.5, 256
loss = nn.CrossEntropyLoss(reduction='none')
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

../_images/output_dropout_1110bf_66_0.svg


简洁实现

对于深度学习框架的高级API,我们只需在每个全连接层之后添加一个Dropout层, 将暂退概率作为唯一的参数传递给它的构造函数。 在训练时,Dropout层将根据指定的暂退概率随机丢弃上一层的输出(相当于下一层的输入)。 在测试时,Dropout层仅传递数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
# 在第一个全连接层之后添加一个dropout层
nn.Dropout(dropout1),
nn.Linear(256, 256),
nn.ReLU(),
# 在第二个全连接层之后添加一个dropout层
nn.Dropout(dropout2),
nn.Linear(256, 10))

def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);

梯度爆炸和消失

梯度消失

梯度消失问题发生在深度网络的梯度在反向传播过程中变得非常小,几乎为零。这意味着网络权重的更新非常微小,甚至不会发生,使得网络很难学习和改进。梯度消失通常发生在使用了像SigmoidTanh这样的激活函数的深度网络中,因为这些激活函数的导数在输入值非常高或非常低时趋于0。

梯度爆炸

梯度爆炸问题是指在网络的反向传播过程中,梯度变得异常大。这会导致权重更新过大,使得网络权重变得非常不稳定,最终可能导致模型无法收敛,或者在训练过程中出现数值计算上的溢出。梯度爆炸通常发生在深度网络中,尤其是当网络架构很复杂,或者使用了不适当的权重初始化策略时。


权重初始化

Xavier初始化,是一种特定的参数初始化方法,旨在解决梯度消失和梯度爆炸的问题,特别是在使用$Sigmoid$或$Tanh$激活函数时。

Xavier初始化的基本思想是保持输入和输出的方差一致,以确保所有层中的梯度都有适当的尺度。这通过根据前一层的连接数自动调整权重的尺度来实现。

具体来说,对于任意给定的层,Xavier初始化方法将权重初始化为从均匀分布或正态分布中抽取的值,其中均匀分布的范围是$([-a, a])$,正态分布的标准差是($\sigma$)。这里的(a)和($\sigma$)计算如下:

  • 均匀分布的情况下,$(a = \sqrt{\frac{6}{n_{\text{in}} + n_{\text{out}}}})$,其中$(n_{\text{in}})$是层的输入单元数,$(n_{\text{out}})$是层的输出单元数。
  • 正态分布的情况下,$\sigma = \sqrt{\frac{2}{n_{\text{in}} + n_{\text{out}}}}$。

这样的初始化方法可以在训练初期防止梯度过小或过大,有助于加快收敛速度,提高模型训练的稳定性。