D2L学习笔记-多层感知机

  1. 1. 多层感知机的从零实现
  2. 2. 多层感知机的简洁实现
  3. 3. 模型选择、欠拟合和过拟合
  4. 4. 权重衰减
  5. 5. 暂退法(Dropout)
  6. 6. 数值稳定性和模型初始化

李沐动手学深度学习(PyTorch)课程学习笔记第三章:多层感知机。

1. 多层感知机的从零实现

FashionMNIST 数据集的读取与第二章第四节一样,此处不再放上代码。

初始化模型参数,我们将实现一个具有单隐藏层的多层感知机,它包含256个隐藏单元。注意,我们可以将这两个变量都视为超参数。通常,我们选择2的若干次幂作为层的宽度。因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。

1
2
3
4
5
6
7
8
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 激活函数,而不是直接调用内置的 relu 函数:

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

接下来定义模型:

1
2
3
def net(X):
H = relu(torch.matmul(X.reshape((-1, num_inputs)), W1) + b1)
return torch.matmul(H, W2) + b2

训练函数的代码也与2.4节基本一样,只需将 net.to(device)net.train() 等与 nn.Module 相关的代码去掉即可,因此不放出完整代码:

1
2
3
4
5
6
7
8
9
10
11
def train_classifier(net, train_iter, test_iter, num_epochs, lr):
print(f'---------- Training on cpu ----------')
loss_function = nn.CrossEntropyLoss() # reduction默认为'mean'
optimizer = torch.optim.SGD(params, lr=lr)

for epoch in range(num_epochs):
# train
# valid

lr, num_epochs = 0.1, 10
train_classifier(net, train_iter, train_iter, num_epochs, lr)

2. 多层感知机的简洁实现

与 Softmax 回归的简洁实现(第二章第四节)相比,唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。第一层是隐藏层,它包含256个隐藏单元,并使用了 ReLU 激活函数。第二层是输出层,因此我们只需要重点看一下模型即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import torch
from torch import nn
from util.functions import train_classifier

# 读取数据

num_inputs, num_outputs, num_hiddens = 784, 10, 256
net = nn.Sequential(nn.Flatten(),
nn.Linear(num_inputs, num_hiddens),
nn.ReLU(),
nn.Linear(num_hiddens, num_outputs))

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
lr, num_epochs = 0.1, 10
train_classifier(net, train_iter, test_iter, num_epochs, lr, device)
# [ Train | epoch: 010/010 ] loss = 0.35276, acc = 0.87549
# [ Valid | epoch: 010/010 ] loss = 0.38964, acc = 0.85898

3. 模型选择、欠拟合和过拟合

首先我们需要了解训练误差泛化误差

  • 训练误差(training error):模型在训练数据集上计算得到的误差。
  • 泛化误差(generalization error):模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望。

问题是,我们永远不能准确地计算出泛化误差。这是因为无限多的数据样本是一个虚构的对象。在实际中,我们只能通过将模型应用于一个独立的测试集来估计泛化误差,该测试集由随机选取的、未曾在训练集中出现的数据样本构成。

在机器学习中,我们通常在评估几个候选模型后选择最终的模型。这个过程叫做模型选择。有时,需要进行比较的模型在本质上是完全不同的(比如,决策树与线性模型)。又有时,我们需要比较不同的超参数设置下的同一类模型。

例如,训练多层感知机模型时,我们可能希望比较具有不同数量的隐藏层、不同数量的隐藏单元以及不同的激活函数组合的模型。为了确定候选模型中的最佳模型,我们通常会使用验证集

  • 验证数据集:一个用来评估模型好坏的数据集,训练数据集用来训练模型参数,验证数据集用来选择模型超参数。
    • 例如在数据集中拿出50%的训练数据作为验证数据集。
    • 不要跟训练数据混在一起(常犯错误)。
  • 测试数据集:只用一次的数据集。
    • 例如未来的考试。
    • 例如我出价的房子的实际成交价。

接下来我们需要了解一下模型容量的概念:

  • 模型容量表示模型拟合各种函数的能力。
  • 低容量的模型难以拟合复杂的训练数据。
  • 高容量的模型可以记住所有的训练数据。

是否过拟合或欠拟合主要取决于模型复杂性可用训练数据集的大小。当我们比较训练和验证误差时,我们要注意两种常见的情况:

  • 训练误差和验证误差都很差,但它们之间仅有一点差距。如果模型不能降低训练误差,这可能意味着模型过于简单(即表达能力不足),无法捕获试图学习的模式。此外,由于我们的训练和验证误差之间的泛化误差很小,我们有理由相信可以用一个更复杂的模型降低训练误差。这种现象被称为欠拟合(underfitting)。
  • 当我们的训练误差明显低于验证误差时要小心,这表明严重的过拟合(overfitting)。注意,过拟合并不总是一件坏事。特别是在深度学习领域,众所周知,最好的预测模型在训练数据上的表现往往比在保留(验证)数据上好得多。最终,我们通常更关心验证误差,而不是训练误差和验证误差之间的差距。

4. 权重衰减

在训练参数化机器学习模型时,权重衰减(weight decay)是最广泛使用的正则化的技术之一,它通常也被称为L2正则化。

通过限制参数值的选择范围来控制模型容量,通常不限制偏移 b

首先生成一些数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
from torch import nn
from torch.utils import data
from torch.utils.tensorboard import SummaryWriter
from d2l import torch as d2l

n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5 # 训练数据少,特征维度大,因此容易过拟合
true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05

train_data = d2l.synthetic_data(true_w, true_b, n_train)
train_iter = d2l.load_array(train_data, batch_size)
test_data = d2l.synthetic_data(true_w, true_b, n_test)
test_iter = d2l.load_array(test_data, batch_size, is_train=False)

定义网络模型与损失函数:

1
2
net = nn.Sequential(nn.Linear(num_inputs, 1))
loss_function = nn.MSELoss(reduction='none')

定义优化算法,我们在实例化优化器时直接通过 weight_decay 指定 weight decay 超参数。默认情况下,PyTorch 同时衰减权重和偏移。这里我们只为权重设置了 weight_decay,所以偏置参数不会衰减:

1
2
3
4
num_epochs, lr, wd = 100, 0.003, 0  # wd为0表示不使用权重衰减

# 偏置参数没有衰减,net[0]即为nn.Linear(num_inputs, 1)
optimizer = torch.optim.SGD([{'params':net[0].weight, 'weight_decay':wd}, {'params':net[0].bias}], lr=lr)

然后进行训练:

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
writer = SummaryWriter('../logs/WeightDecay_train_log')

for param in net.parameters():
param.data.normal_()

for epoch in range(num_epochs):
train_loss = 0.0
test_loss = 0.0

net.train()
for X, y in train_iter:
y_hat = net(X)
loss = loss_function(y_hat, y)
optimizer.zero_grad()
loss.mean().backward()
optimizer.step()
train_loss += loss.sum()
train_loss /= n_train

net.eval()
with torch.no_grad():
for X, y in test_iter:
y_hat = net(X)
loss = loss_function(y_hat, y)
test_loss += loss.sum()
test_loss /= n_test

writer.add_scalar('train_loss', train_loss, epoch + 1)
writer.add_scalar('test_loss', test_loss, epoch + 1)

print('w的L2范数:', net[0].weight.norm().item())

通过结果可以看到模型很快就过拟合了,可以通过修改超参数 wd 的值应用权重衰减的方式来缓解过拟合的现象。

5. 暂退法(Dropout)

一个好的模型需要对输入数据的扰动鲁棒。在训练过程中,建议在计算后续层之前向网络的每一层注入噪声,因为当训练一个有多层的深层网络时,注入噪声只会在输入-输出映射上增强平滑性。

这个想法被称为暂退法(dropout)。暂退法在前向传播过程中,计算每一内部层的同时注入噪声,这已经成为训练神经网络的常用技术。这种方法之所以被称为暂退法,因为我们从表面上看是在训练过程中丢弃(drop out)一些神经元。在整个训练过程的每一次迭代中,标准暂退法包括在计算下一层之前将当前层中的一些节点置零

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

Dropout 说的简单一点就是:我们在前向传播的时候,让某个神经元的激活值以一定的概率 p 停止工作(将一些输出项随机置0),这样可以使模型泛化性更强,因为它不会太依赖某些局部的特征。

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

Dropout 的从零实现核心代码如下:

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
def dropout_layer(X, p):
assert 0 <= p <= 1
# 在本情况中,所有元素都被丢弃
if p == 1:
return torch.zeros_like(X)
# 在本情况中,所有元素都被保留
if p == 0:
return X
mask = (torch.rand(X.shape) > p).float()
return mask * X / (1.0 - p)

num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256
p1, p2 = 0.2, 0.5

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)
self.relu = nn.ReLU()

def forward(self, X):
H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
# 只有在训练模型时才使用dropout
if self.training == True:
# 在第一个全连接层之后添加一个dropout层
H1 = dropout_layer(H1, p1)
H2 = self.relu(self.lin2(H1))
if self.training == True:
# 在第二个全连接层之后添加一个dropout层
H2 = dropout_layer(H2, p2)
out = self.lin3(H2)
return out

net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)

Dropout 的简洁实现及完整训练代码如下:

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
import torch
import torchvision
from torch import nn
from torch.utils import data
from torchvision import transforms
from util.functions import train_classifier

mnist_train = torchvision.datasets.FashionMNIST(root="../data", train=True, transform=transforms.ToTensor(), download=True)
mnist_test = torchvision.datasets.FashionMNIST(root="../data", train=False, transform=transforms.ToTensor(), download=True)
train_iter = data.DataLoader(mnist_train, batch_size=256, shuffle=True, num_workers=0)
test_iter = data.DataLoader(mnist_test, batch_size=256, shuffle=False, num_workers=0)

net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(256, 256),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(256, 10))

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
lr, num_epochs = 0.1, 10
writer_path = '../logs/Dropout_train_log'

train_classifier(net, train_iter, test_iter, num_epochs, lr, device, writer_path)
# [ Train | epoch: 010/010 ] loss = 0.37249, acc = 0.86702
# [ Valid | epoch: 010/010 ] loss = 0.37481, acc = 0.86348

6. 数值稳定性和模型初始化

我们可能面临一些问题,要么是梯度爆炸(gradient exploding)问题:参数更新过大,破坏了模型的稳定收敛;要么是梯度消失(gradient vanishing)问题:参数更新过小,在每次更新时几乎不会移动,导致模型无法学习。

例如当 Sigmoid 函数的输入很大或是很小时,它的梯度都会消失。此外,当反向传播通过许多层时,除非我们在刚刚好的地方,这些地方 Sigmoid 函数的输入接近于零,否则整个乘积的梯度可能会消失。

Xavier 初始化:从均值为零,方差为 2 / (n_in + n_out) 的高斯分布中采样权重(n_in 为输入的数量,n_out 为输出的数量)。

下一章:PyTorch 神经网络基础