动手学深度学习 Part3

卷积神经网络#

本章介绍的卷积神经网络 (convolutional neural network, CNN) 是一类强大的, 为处理图像数据而设计的神经网络. 基于卷积神经网络架构的模型在计算机视觉领域中已经占主导地位, 当今几乎所有的图像识别, 目标检测或语义分割相关的学术竞赛和商业应用都以这种方法为基础.

从全连接层到卷积#

平移不变性: 识别某种局部模式的方法, 不应该依赖它出现在图像中的绝对位置.

因此, 输出位置 (i,j)(i,j) 对输入位置 (k,l)(k,l) 的权重, 应该主要取决于它们之间的相对位置, 而不是绝对位置.

Yi,j=a,bVi,j,a,bXi+a,j+b+bi,jY_{i,j} = \sum_{a,b} V_{i,j,a,b} X_{i+a,j+b} + b_{i,j}

权重共享: 同一个局部模式, 无论出现在图像的哪个位置, 都应该用同一组参数检测. 因此, 让权重不再依赖输出位置 (i,j)(i, j) , 且偏置也可以共享, 得到:

Yi,j=a,bKa,bXi+a,j+b+bY_{i,j} = \sum_{a,b} K_{a,b} X_{i+a,j+b} + b

其中的 K 被称为卷积核, 核, 滤波器, kernel, filter.

局部连接: 图像中很多特征是局部的, 所以需要限制 a 和 b 只观察附近区域, 例如

a,b{1,0,1}a, b \in \{-1, 0, 1\}

则得到一个 3×33\times 3 卷积核.

平移等变性, 而非平移不变性, 会在输入移动时输出跟着移动.

多通道输入: 实际彩色图像不是二维矩阵, 而是三维张量, 此时卷积核也必须覆盖三个通道.

卷积层仍然是线性层, 卷积后也仍需要激活函数.

图像卷积#

例如, 一个这样的图像:

tensor([[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.]])

我们可以构造一个高度为 1, 宽度为 2 的卷积核, 用于寻找竖直边缘.

K = torch.tensor([[1.0, -1.0]])

我们也可以让程序自己学习卷积层的参数, 例如:

# 构造一个二维卷积层, 它具有1个输出通道和形状为(1, 2)的卷积核
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)

# 这个二维卷积层使用四维输入和输出格式(批量大小, 通道, 高度, 宽度),
# 其中批量大小和通道数都为1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
lr = 3e-2 # 学习率

for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad()
l.sum().backward()
# 迭代卷积核
conv2d.weight.data[:] -= lr * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'epoch {i+1}, loss {l.sum():.3f}')

特征映射: 经卷积核提取后的数据, 只保留了该卷积核关心的空间分布.

感受野: 输出特征映射上某个元素在原始输入数据中能看到的区域大小.

  • 单层卷积: 输出点能看到的输入区域就是卷积核尺寸, 例如 1×2.
  • 深层卷积: 经过多层堆叠, 输出点会间接地看到输入图像上更大的范围.
  • 翻译这个词的人是不是智力障碍.

填充和步幅#

靠近边缘时, 卷积核可能超出图像范围. 如果不填充 (valid 卷积), 输出尺寸会不断缩小, 边缘像素也会被利用得更少.

解决方案就是填充, 一般补 0, 也有反射填充, 复制填充, 循环填充等.

注意, 填充只是在输入周围补值, 并不会改变卷积核的数量和大小.

输入高度为 HH, 输入宽度为 WW, 卷积核高度为 KhK_h, 卷积核宽度为 KwK_w, 高度方向填充为 PhP_h, 宽度方向填充为 PwP_w, 高度方向步幅为 ShS_h, 宽度方向步幅为 SwS_w.

那么输出尺寸为:

Hout=H+2PhKhSh+1H_{\text{out}} = \left\lfloor \frac{H + 2P_h - K_h}{S_h} \right\rfloor + 1

Wout=W+2PwKwSw+1W_{\text{out}} = \left\lfloor \frac{W + 2P_w - K_w}{S_w} \right\rfloor + 1

如果高和宽使用相同参数, 可以简写为:

Hout=H+2PKS+1H_{\text{out}} = \left\lfloor \frac{H + 2P - K}{S} \right\rfloor + 1

步幅 (stride), 卷积核一次移动多少格.

较大的步幅主要用于下采样.

例如输入有三个通道, 希望输出有一个通道, 此时, 不能只使用一个二维卷积核, 而是需要为每个输入通道分别准备一个二维卷积核. 例如每个卷积核大小都是 3×33 \times 3:

K1,K2,K3R3×3K_1, K_2, K_3 \in \mathbb{R}^{3 \times 3}

分别对三个输入通道进行卷积, 结果相加再加上偏置, 得到:

Y=K1X1+K2X2+K3X3+bY = K_1 * X_1 + K_2 * X_2 + K_3 * X_3 + b

即一个输出通道. 此时, 完整的卷积核等价于一个 3×3×33 \times 3 \times 3 的立方体.

一个完整卷积核只能产生一个输出通道, 但是我们通常希望提取多种不同特征, 因此需要多个不同的卷积核.

Yo=c=1CinKo,cXc+boY_o = \sum_{c=1}^{C_{\mathrm{in}}} K_{o,c} * X_c + b_o

汇聚层#

也叫池化层, 下采样层, pooling layer.

在一个较小的局部区域内把多个数压缩成一个数, 从而减小特征图的空间尺寸.

普通汇聚层一般只缩小高度和宽度, 不会改变通道数.

作用:

  • 缩小特征图
  • 减少计算量和内存消耗
  • 扩大后续神经元的有效感受野
  • 保留局部区域中较重要的信息
  • 提高对小范围平移和形变的鲁棒性

最大汇聚 Max Pooling, 在每个局部窗口中选择最大值, 适合保留最显著的局部特征. 反向传播时只传播给保留的那个位置, 其余位置梯度为 0.

平均汇聚 Average Pooling, 输出什么不用我说了吧. 更平滑, 适用于降低局部噪声等. 反向传播时均匀传播给所有位置.

汇聚层对每个通道单独处理.

汇聚窗口和步幅.

输入高度为 HH, 汇聚核高度为 KhK_h, 步幅为 ShS_h, 填充为 PhP_h.

则输出高度为:

Hout=H+2PhKhSh+1H_{\mathrm{out}} = \left\lfloor \frac{H + 2P_h - K_h}{S_h} \right\rfloor + 1

输出宽度为:

Wout=W+2PwKwSw+1W_{\mathrm{out}} = \left\lfloor \frac{W + 2P_w - K_w}{S_w} \right\rfloor + 1

汇聚层会丢失一部分空间信息, 可以容忍小范围平移.

和带步幅的卷积不同, 汇聚层没有可学习参数.

LeNet#

结构:

输入图片, 卷积层, 平均汇聚层, 卷积层, 平均汇聚层, 展平, 全连接层, 全连接层, 输出.

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt

transform = transforms.ToTensor()

# transform = transforms.Compose([
# transforms.ToTensor(),
# transforms.Normalize((0.5,), (0.5,))
# ])

train_dataset = datasets.FashionMNIST(
root="./data",
train=True,
transform=transform,
download=True
)
test_dataset = datasets.FashionMNIST(
root="./data",
train=False,
transform=transform,
download=True
)
# print("训练集大小: ", len(train_dataset))
# print("测试集大小: ", len(test_dataset))

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
)

images, labels = next(iter(train_loader))
print("图片形状: ", images.shape)
print("标签形状: ", labels.shape)

class LeNet(nn.Module):
def __init__(self):
super().__init__()

self.network = nn.Sequential(
nn.Conv2d(
in_channels=1,
out_channels=6,
kernel_size=5,
padding=2
),
nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(
in_channels=6,
out_channels=16,
kernel_size=5
),
nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16*5*5, 120),
nn.ReLU(),
nn.Linear(120, 84),
nn.ReLU(),
nn.Linear(84, 10)
)

def forward(self, x):
return self.network(x)


model = LeNet()
print(model)


x = torch.randn(1, 1, 28, 28)
for layer in model.network:
x = layer(x)
print(f"{layer.__class__.__name__:15s} -> {x.shape}")


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

loss_fn = nn.CrossEntropyLoss()

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

# optimizer = torch.optim.Adam(
# model.parameters(),
# lr=1e-3
# )

def train_one_epoch(model, data_loader, loss_fn, optimizer, device):
model.train()
total_loss = total_correct = total_samples = 0.0
for images, labels in data_loader:
images, labels = images.to(device), labels.to(device)
logits = model(images)
loss = loss_fn(logits, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()

batch_size = labels.size(0)
total_loss += loss.item() * batch_size

predictions = logits.argmax(dim=1)
total_correct += (predictions == labels).sum().item()
total_samples += batch_size

average_loss = total_loss / total_samples
acc = total_correct / total_samples

return average_loss, acc


def evaluate(model, data_loader, loss_fn, device):
model.eval()

total_loss = 0.0
total_correct = 0
total_samples = 0

with torch.no_grad():
for images, labels in data_loader:
images = images.to(device)
labels = labels.to(device)

logits = model(images)
loss = loss_fn(logits, labels)

batch_size = labels.size(0)
total_loss += loss.item() * batch_size

predictions = logits.argmax(dim=1)
total_correct += (predictions == labels).sum().item()

total_samples += batch_size

average_loss = total_loss / total_samples
accuracy = total_correct / total_samples

return average_loss, accuracy

num_epochs = 10
train_losses = []
train_accuracies = []
test_losses = []
test_accuracies = []

for epoch in range(num_epochs):
train_loss, train_accuracy = train_one_epoch(
model=model,
data_loader=train_loader,
loss_fn=loss_fn,
optimizer=optimizer,
device=device
)

test_loss, test_accuracy = evaluate(
model=model,
data_loader=test_loader,
loss_fn=loss_fn,
device=device
)

train_losses.append(train_loss)
train_accuracies.append(train_accuracy)
test_losses.append(test_loss)
test_accuracies.append(test_accuracy)

print(
f"Epoch [{epoch + 1:02d}/{num_epochs}] "
f"训练损失: {train_loss:.4f}, "
f"训练准确率: {train_accuracy * 100:.2f}%, "
f"测试损失: {test_loss:.4f}, "
f"测试准确率: {test_accuracy * 100:.2f}%"
)

plt.figure(figsize=(8, 5))

plt.plot(train_losses, label="Train Loss")
plt.plot(test_losses, label="Test Loss")

plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title("LeNet Loss")
plt.legend()
plt.show()

plt.figure(figsize=(8, 5))

plt.plot(train_accuracies, label="Train Accuracy")
plt.plot(test_accuracies, label="Test Accuracy")

plt.xlabel("Epoch")
plt.ylabel("Accuracy")
plt.title("LeNet Accuracy")
plt.legend()
plt.show()

最开始用的是 Sigmoid, 但 Sigmoid 导数最大只有:

σ(x)0.25\sigma'(x)\leq 0.25

四层 Sigmoid 导致梯度变得非常小, 最后模型啥也没学会, 准确率 10%, loss 差不多是 ln10\ln10 , 改成 ReLU 就正常了.

归一化#

例如输入两个特征的神经网络, 一个是年龄, 一个是组成其人的原子数量, 第二个特征的影响就很容易远大于第一个特征, 这会导致优化困难, 数据不稳定等, 因此要进行归一化.

作用: 控制激活值稳定, 让不同层的梯度尺度更稳定, 可以使用更大的学习率, 让深层网络更容易训练.

基础的归一化:

x^=xμσ2+ϵ\hat{x} = \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}}

ϵ\epsilon 是极小值, 防止除以零. 处理后理论上均值为 0, 方差为 1.

神经网络中通常会在归一化后增加两个参数:

y=γx^+βy = \gamma \hat{x} + \beta

分别负责缩放和平移, 因为神经网络归一化的目的不是让特征限定在某个尺度, 而是给一个稳定且容易优化的标准尺度, 然后让网络自己学习它需要的尺度.

BatchNorm#

跨样本归一化, 同一个 feature 在一个 batch 里的不同样本之间统计均值和方差.

在 CNN 中使用 BatchNorm2d, 对每个 channel 单独计算.

LayerNorm#

不管其他样本, 只在一个样本自己的特征维度上进行归一化, 适合 Transformer, 因为不能让 Transformer 依赖同一个 batch 中其他句子的 token.

RMSNorm#

不减去均值的 LayerNorm:

RMS(x)=1dixi2+ϵy=γxRMS(x)RMS(x) = \sqrt{\frac{1}{d} \sum_{i} x_i^2 + \epsilon} \\ y = \gamma \frac{x}{RMS(x)}

在现代 LLM 中比较常见.

GroupNorm#

把 channel 分成多个 group, 每个组内部做归一化, 不依赖 batch size, 适合显存有限的情况.