动手学深度学习 Part2

多层感知机#

多层感知机#

多层感知机 (MLP) 由多层神经元组成, 每一层与它的上一层相连, 从中接收输入; 同时每一层也与它的下一层相连, 影响当前层的神经元.

需要激活函数, 否则仍然是线性.

激活函数#

ReLU 线性整流函数:

ReLU(x)=max(0,x)\text{ReLU}(x) = \max(0, x)

Sigmoid 函数:

σ(x)=11+ex\sigma(x) = \frac{1}{1 + e^{-x}}

tanh 双曲正切函数:

tanh(x)=exexex+ex\tanh(x) = \frac{e^{x} - e^{-x}}{e^{x} + e^{-x}}

通用近似定理.

多层感知机的实现#

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)

print("使用设备: ", device)

transform = transforms.ToTensor()

train_dataset = datasets.FashionMNIST(
root="./data",
train=True,
transform=transform,
download=True
)

test_dataset = datasets.FashionMNIST(
root="./data",
train=False,
transform=transform,
download=True
)

batch_size = 256

train_loader = DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=0
)

test_loader = DataLoader(
test_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=0
)

model = nn.Sequential(
nn.Flatten(),
nn.Linear(28*28, 64),
nn.ReLU(),
nn.Linear(64, 10)
)

model = model.to(device)

print(model)

def init_weights(module):
if isinstance(module, nn.Linear):
nn.init.normal_(
module.weight,
mean=0,
std=0.01
)
nn.init.zeros_(module.bias)

model.apply(init_weights)

loss_fn = nn.CrossEntropyLoss()

optimizer = torch.optim.SGD(
model.parameters(),
lr=0.1
)

def evaluate_accuracy(model, data_loader, device):
model.eval()

correct = 0
total = 0

with torch.no_grad():
for X, y in data_loader:
X = X.to(device)
y = y.to(device)

logits = model(X)
predictions = logits.argmax(dim=1)

correct += (predictions == y).sum().item()
total += y.numel()

return correct / total

num_epochs = 10

for epoch in range(num_epochs):
model.train()
total_loss = 0.0
correct = 0
total = 0

for x, y in train_loader:
x = x.to(device)
y = y.to(device)

logits = model(x)

loss = loss_fn(logits, y)

optimizer.zero_grad()
loss.backward()
optimizer.step()

total_loss += loss.item() * x.shape[0]

predictions = logits.argmax(dim=1)
correct += (predictions == y).sum().item()
total += y.numel()

train_loss = total_loss / total
train_acc = correct / total
test_acc = evaluate_accuracy(model, test_loader, device)

print(
f"epoch {epoch + 1:2d}, "
f"loss = {train_loss:.4f}, "
f"train acc = {train_acc:.4f}, "
f"test acc = {test_acc:.4f}"
)

模型选择, 欠拟合与过拟合#

训练误差: 模型在训练数据集上计算得到的误差.

泛化误差: 模型应用在同样从原始样本的分布中抽取的无限多数据样本时, 模型误差的期望.

显然我们无法计算泛化误差, 因此我们将模型应用于一个独立的测试集来估计泛化误差.

欠拟合: 训练误差和验证误差都很严重, 但它们之间仅有一点差距. 如果模型不能降低训练误差, 这可能意味着模型过于简单 (即表达能力不足), 无法捕获试图学习的模式. 此外, 由于我们的训练和验证误差之间的泛化误差很小, 我们有理由相信可以用一个更复杂的模型降低训练误差.

过拟合: 训练误差明显低于验证误差.

对抗过拟合的技术称为正则化.

权重衰减#

权重衰减会鼓励模型使用较小的权重.

原本我们只最小化训练损失 L(w)L (\mathbf{w}) , 加入权重衰减后, 目标函数变为:

Ltotal=L(w)+λ2w22L_{\text{total}} = L(\mathbf{w}) + \frac{\lambda}{2} \|\mathbf{w}\|_2^2

其中:

w22=w12+w22++wn2\|\mathbf{w}\|_2^2 = w_1^2 + w_2^2 + \cdots + w_n^2

λ\lambda 控制惩罚强度.

  • λ=0\lambda = 0: 不使用权重衰减;
  • λ\lambda 较小: 轻微限制权重;
  • λ\lambda 很大: 强迫权重接近 0, 可能欠拟合.

这通常也称为 L2L_2 正则化.

权重较小时, 模型通常变化得更加平滑, 不容易为了少数训练样本做出极端调整.

所以可以粗略理解为: 大权重倾向于复杂, 敏感的模型, 小权重倾向于平滑, 稳定的模型.

对总损失求梯度:

wLtotal=wL+λw\nabla_{\mathbf{w}} L_{\text{total}} = \nabla_{\mathbf{w}} L + \lambda \mathbf{w}

使用梯度下降:

wwη(wL+λw)\mathbf{w} \leftarrow \mathbf{w} - \eta (\nabla_{\mathbf{w}} L + \lambda \mathbf{w})

展开:

w(1ηλ)wηwL\mathbf{w} \leftarrow (1 - \eta \lambda)\mathbf{w} - \eta \nabla_{\mathbf{w}} L

可以看到每次更新时, 原来的权重都会乘上一个略小于 1 的数, 所以叫权重衰减.

偏置通常不做权重衰减, 因为参数量很少, 且主要控制整体平移.

实现如下:

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
# 偏置参数没有衰减
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()
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范数: ', net[0].weight.norm().item())

暂退法#

Dropout 暂退法会会在训练过程中, 随机将一部分隐藏层神经元的输出设为 0. , 随机将一部分隐藏层神经元的输出设为 0, 以减少神经元之间的过度协同适应, 迫使模型在部分信息缺失时也能完成任务, 模型会学习更分散, 更稳健的特征.

丢弃概率为 pp , 则输出时需要除以 1p1-p , 保证输出期望不变. 这种实现称为反向 Dropout, PyTorch 使用的就是这种方法.

当然是用在隐藏层不是输出层.

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);

前向传播, 反向传播和计算图#

计算图示例:

w ──┐
× ── a ──┐
x ──┘ + ── z ── 平方 ── y
b ──┘

前向传播: 从输入开始, 按照计算图从左向右依次计算, 最后得到模型输出和损失.

作用:

  • 根据输入得到预测结果
  • 根据预测结果计算损失
  • 保存中间结果, 供反向传播使用

反向传播: 从最终的损失开始, 沿计算图反方向, 利用链式法则逐层计算梯度.

import torch

x = torch.tensor(3.0)
w = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(1.0, requires_grad=True)

z = w * x + b
y = z ** 2

y.backward()

print(y)
print(w.grad)
print(b.grad)

数值稳定性和模型初始化#

数值稳定性: 在有限精度的计算机上进行计算时, 尽量避免溢出, 下溢, 除零, 精度丢失等问题, 使结果可靠.

梯度消失: 参数几乎不更新, 前面的层学不到东西, 训练非常慢.

梯度爆炸: loss 剧烈波动, 参数变成 inf, loss 变成 nan 等.

解决方法:

  • 合理初始化参数;
  • 使用 ReLU, GELU 等激活函数 (sigmoid 容易导致梯度消失)
  • 使用 BatchNorm, LayerNorm
  • 使用残差连接
  • 梯度裁剪
  • 调整学习率

模型初始化: 在训练开始前为权重和偏置设置初始数值.

初始化可以打破对称性, 防止两个神经元一模一样.

如果初始化权重的方差固定不变, 则随着输入维度增大, 输出的方差会增大, 因此为保证输出方差和输入方差大致相同, 我们一般使权重的标准差大致为 1n\frac{1}{\sqrt{n}} .

Xavier 初始化希望同时考虑前向传播和反向传播, 因此令权重方差大约为

Var(W)=2fan_in+fan_outVar(W) = \frac{2}{fan\_in + fan\_out}

标准差即开根号.