一、问题简介
解析深度学习中的卷积层的神经元结构不仅是为了理解卷积层的工作原理,更是为了揭示生物视觉的数学模拟、理解层次化特征学习工作机制,从而设计更好的模型、调试网络。
即使科学家们发现并设计了多个传统图像处理(如Sobel, Gabor)那样“可解释含义”的卷积核,用于检测图像中的边缘、角点等特征,但现代深度学习框架的卷积层中,并没有一个预设的、固定的卷积核列表,这是深度学习神奇的地方:模型自动发现了对识别任务有用的图像特征。
二、卷积神经网络结构
以基于MNIST数据集进行0~9手写图像识别为例,采用一个类似于 LeNet-5 的卷积神经网络,这个网络能够有效提取 MNIST 数字的局部特征,例如边缘、角点等,通过卷积和池化逐步抽象特征,最后通过全连接层进行分类。
网络结构
1)输入层 (1×28×28) (MNIST 灰度图像)
↓
2)卷积层1: Conv2d(输入通道1→输出通道6, 卷积核大小5×5) → 输出尺寸:6×24×24((28-5+1)=24)
↓
ReLU1
↓
3)最大池化1: MaxPool2d(2×2) → 输出尺寸:6×12×12 (24/2=12)
↓
4)卷积层2: Conv2d(输入通道6→输出通道16, 卷积核大小5×5) → 输出尺寸:16×8×8((12-5+1)=8)
↓
ReLU2
↓
5)最大池化2: MaxPool2d(2×2) → 输出尺寸:16×4×4(8/2=4)
↓
展平 → 256维
↓
6)全连接层1: Linear(输入16×4×4=256维→输出120维)
↓
ReLU3
↓
Dropout1 (p=0.5)
↓
7)全连接层2: Linear(120→84)
↓
ReLU4
↓
Dropout2 (p=0.5) [可选]
↓
8)全连接层3: Linear(84→10) → 输出10类分数
模型源码
model.py
from torch.nn import Module
from torch import nn
class Model(Module):
def __init__(self):
super(Model, self).__init__()
self.conv1 = nn.Conv2d(1, 6, 5) # 输入通道1→输出通道6, 卷积核大小5×5
self.relu1 = nn.ReLU()
self.pool1 = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(6, 16, 5) # 输入通道6→输出通道16, 卷积核大小5×5
self.relu2 = nn.ReLU()
self.pool2 = nn.MaxPool2d(2)
self.fc1 = nn.Linear(256, 120)
self.relu3 = nn.ReLU()
# 对于 MNIST 这种相对简单的数据集,Dropout 率不宜太高(0.3-0.5即可)
self.dropout1 = nn.Dropout(0.5) # Dropout层1
self.fc2 = nn.Linear(120, 84)
self.relu4 = nn.ReLU()
self.dropout2 = nn.Dropout(0.5) # Dropout层2(可选)
self.fc3 = nn.Linear(84, 10)
# self.relu5 = nn.ReLU() # 因为输出层不需要ReLU
def forward(self, x):
y = self.conv1(x)
y = self.relu1(y)
y = self.pool1(y)
y = self.conv2(y)
y = self.relu2(y)
y = self.pool2(y)
y = y.view(y.shape[0], -1)
y = self.fc1(y)
y = self.relu3(y)
y = self.dropout1(y) # 应用Dropout
y = self.fc2(y)
y = self.relu4(y)
y = self.dropout2(y) # 应用第二个Dropout(可选)
y = self.fc3(y)
return y
二、卷积核
在PyTorch以及TensorFlow等现代深度学习框架的卷积层中,并没有一个预设的、固定的、像传统图像处理(如Sobel, Gabor)那样“可解释含义”的卷积核列表。这与PyTorch的核心工作原理有关:
- 随机初始化:当你创建一个 Conv2d(in_channels=1, out_channels=6, kernel_size=5)时,PyTorch会随机初始化这6个卷积核。通常使用Kaiming均匀分布、正态分布等方法。这些初始化的值看起来就像随机的小数矩阵(例如,范围在-0.1到0.1之间),它们本身没有任何具体的视觉意义(比如边缘检测、角点检测)。
- 从数据中学习:这些随机卷积核的“作用”是在模型训练过程中,通过反向传播和梯度下降算法,根据训练数据(MNIST手写数字)和任务目标(正确分类数字)自动学习和演化出来的。它们是模型的可学习参数。
- 不可预知性:因此,在你运行代码之前,你无法知道这6个卷积核最终会变成什么样。即使是同一个模型,两次不同的训练也可能产生最终形态不同的卷积核。它们是被“学习”出来的,而不是被“选择”或“设计”出来的。
虽然初始化时是随机的,但经过在MNIST这样的数据集上训练后,第一层卷积核通常会学习到一些有意义的、可解释的模式。这是深度学习神奇的地方:模型自动发现了对识别任务有用的图像特征。
在训练过程中,每个卷积核会学习并检测不同的特征:
| 卷积核编号 | 可能学习到的特征 | 可视化示例 | 功能描述 |
|---|---|---|---|
| 核0 | 垂直边缘检测器 | [[-1,-1,-1,-1,-1], [0,0,0,0,0], [1,1,1,1,1], ...] | 检测数字中的垂直笔画 |
| 核1 | 水平边缘检测器 | [[-1,0,1,0,-1], [-1,0,1,0,-1], ...] | 检测数字中的水平笔画 |
| 核2 | 45°对角线检测器 | 左上到右下的边缘 | 检测对角线特征 |
| 核3 | 135°对角线检测器 | 右上到左下的边缘 | 检测反向对角线 |
| 核4 | 中心-周围检测器 | 中心正,周围负 | 检测斑点或端点 |
| 核5 | 特定形状检测器 | 训练后确定 | 检测更复杂的局部模式 |
三、卷积层神经元结构
以卷积层1为例,输入为MNIST 灰度图像,尺寸为 (1×28×28) ,卷积操作Conv2d(输入通道1→输出通道6, 卷积核大小5×5) ,输出尺寸为6×24×24((28-5+1)=24)。
self.conv1 = nn.Conv2d(1, 6, 5) # 输入1通道,输出6通道,卷积核5×5
神经元结构
| 组件 | PyTorch对象 | 形状 | 物理意义 |
|---|---|---|---|
| 输入特征图 | 前向传播的输入x | (batch, 1, 28, 28) | 批量的灰度图像 |
| 卷积核权重 | self.conv1.weight | (6, 1, 5, 5) | 6个特征检测器 |
| 偏置 | self.conv1.bias | (6,) | 每个检测器的阈值 |
| 卷积计算 | F.conv2d()操作 | – | 滑动点积 |
| 原始输出 | 卷积后的中间结果 | (batch, 6, 24, 24) | 未激活的特征响应 |
| 激活函数 | self.relu1 | – | 非线性变换 |
| 输出特征图 | 前向传播的输出 | (batch, 6, 24, 24) | 激活后的特征图 |
卷积层1输出尺寸为6个特征图 × 24行 × 24列,神经元总数:6 × 24 × 24 = 3,456个神经元。对应的神经元层次结构如下所示:
层结构:Conv1(6通道卷积层)
├── 特征图0(24×24神经元矩阵)
│ ├── 行0:[神经元(0,0,0), 神经元(0,0,1), ..., 神经元(0,0,23)]
│ ├── 行1:[神经元(0,1,0), 神经元(0,1,1), ..., 神经元(0,1,23)]
│ ├── ...
│ └── 行23:[神经元(0,23,0), ..., 神经元(0,23,23)]
│
├── 特征图1(24×24神经元矩阵)
│ ├── 行0:[神经元(1,0,0), 神经元(1,0,1), ..., 神经元(1,0,23)]
│ ├── ...
│
├── 特征图2(24×24神经元矩阵)
├── 特征图3(24×24神经元矩阵)
├── 特征图4(24×24神经元矩阵)
└── 特征图5(24×24神经元矩阵)
以第一层神经网络为例,若采用全连接网络,则每个神经元与上一层的所有神经元连接,输入层一共有28×28=784个输入,都需要连接到第一层神经网络中的24×24×6=3456个输出神经元。
而采用卷积网络,只需局部连接,可大大减少参数数量。对于每个输出神经元(如神经元_k_i_j),其连接范围只连接到输入图像的5×5局部区域,等同于卷积核大小5×5。且对应于卷积核、总共6个特征图中的同一个特征图的所有神经元共享同一组权重,对应于一个卷积核。
神经元_k_i_j的连接:
- 输入位置:[i:i+5, j:j+5] 的25个像素(输入神经元)
- 权重:卷积核k的25个权重值
- 偏置:特征图k的1个偏置值
输出神经元(k,i,j)的输入计算数学表示为:
- z_k[i,j] = b_k + ∑{dx=0}^{4}∑{dy=0}^{4} (输入[i+dx, j+dy] × W_k[dx, dy])
- 激活值:a_k[i,j] = ReLU(z_k[i,j])
神经元计算
def compute_single_neuron(input_patch, kernel_weights, bias):
"""
计算单个神经元的激活值
input_patch: 5×5的输入区域
kernel_weights: 5×5的卷积核权重
bias: 标量偏置
"""
# 卷积计算(点乘求和)
conv_result = torch.sum(input_patch * kernel_weights)
# 加偏置
z = conv_result + bias
# ReLU激活
a = torch.relu(z)
return a
# 示例:计算神经元(0,0,0)
input_patch = input_neurons[0, 0, 0:5, 0:5] # 5×5输入区域
kernel_weights = model.conv1.weight[0, 0] # 第一个卷积核的权重
bias = model.conv1.bias[0] # 第一个偏置
neuron_activation = compute_single_neuron(input_patch, kernel_weights, bias)
神经元的感受野
采用大小为5×5的卷积核,神经元(k,i,j)的感受野 = 输入图像中[i:i+5, j:j+5]的区域。
感受野分布图如下:
输入图像 (28×28)
┌─────────────────────────────┐
│ ██████ │ ← 神经元(0,0,0)的感受野
│ ██████ │
│ ██████ │
│ ██████ │
│ ██████ │
│ │
│ ██████ │ ← 神经元(0,12,12)的感受野
│ ██████ │
│ ██████ │
│ ██████ │
│ ██████ │
│ │
│ ██████ │ ← 神经元(0,23,23)的感受野
│ ██████ │
│ ██████ │
│ ██████ │
│ ██████ │
└─────────────────────────────┘
参数数量分析
卷积层神经网络的参数数量主要取决于卷积核个数和大小。原因:
- 卷积核个数决定有多少种不同的特征检测器,每个检测器需要独立的一组权重。
- 卷积核大小决定每个检测器的”视野”和精细度,更大的卷积核需要更多的权重参数来描述。
- 输入通道数是乘性因子,但不是主导因素。
卷积层神经网络的参数数量计算:
# 计算公式
参数总数 = (卷积核权重 + 偏置) × 输出通道数
# Conv1的具体计算
每个卷积核的权重数 = 输入通道 × 核高 × 核宽 = 1 × 5 × 5 = 25
每个卷积核的偏置数 = 1
每个卷积核总参数 = 25 + 1 = 26
# 6个卷积核总参数
总参数 = 26 × 6 = 156个可训练参数
若采用全连接网络,实现同样功能需要多少参数?
输入神经元数 = 28 × 28 = 784
输出神经元数 = 6 × 24 × 24 = 3,456
# 全连接参数数量
全连接参数 = 输入神经元 × 输出神经元 + 输出神经元
= 784 × 3,456 + 3,456 = 2,711,040 + 3,456 = 2,714,496
全连接网络参数数量是卷积层神经网络参数数量的17,400倍。
特征存储
训练过程中的中间结果:
- 内存中(RAM/显存):在前向传播时,所有中间特征图(卷积输出、池化输出)都存储在内存/显存中
- 自动梯度计算需要:PyTorch 的
autograd会保留这些中间结果用于反向传播
持久化存储:
- 模型参数:训练完成后,整个模型(包括权重和结构)保存到
models/mnist_xxx.pkl文件 - 卷积核权重:存储在:
model.conv1.weight:形状为(6,1,5,5)model.conv2.weight:形状为(16,6,5,5)
- 偏置:
model.conv1.bias、model.conv2.bias
四、训练和推理样例
源码
train.py
from model import Model
import numpy as np
import os
import torch
from torchvision.datasets import mnist
from torch.nn import CrossEntropyLoss
from torch.optim import SGD
from torch.utils.data import DataLoader
from torchvision.transforms import ToTensor
from torch.optim.lr_scheduler import StepLR
if __name__ == '__main__':
device = 'cuda' if torch.cuda.is_available() else 'cpu'
batch_size = 256
train_dataset = mnist.MNIST(root='./train', train=True, transform=ToTensor())
test_dataset = mnist.MNIST(root='./test', train=False, transform=ToTensor())
train_loader = DataLoader(train_dataset, batch_size=batch_size)
test_loader = DataLoader(test_dataset, batch_size=batch_size)
model = Model().to(device)
sgd = SGD(model.parameters(), lr=1e-1) # 初始学习率0.1
loss_fn = CrossEntropyLoss()
# 方案1:每隔一定epoch减少学习率(推荐)
scheduler = StepLR(sgd, step_size=30, gamma=0.1) # 每30个epoch学习率乘以0.1
all_epoch = 100
prev_acc = 0
for current_epoch in range(all_epoch):
model.train()
for idx, (train_x, train_label) in enumerate(train_loader):
train_x = train_x.to(device)
train_label = train_label.to(device)
sgd.zero_grad()
predict_y = model(train_x.float())
loss = loss_fn(predict_y, train_label.long())
loss.backward()
sgd.step()
# ========== 更新学习率 ==========
# 对于StepLR,在每个epoch后更新
scheduler.step()
all_correct_num = 0
all_sample_num = 0
model.eval()
for idx, (test_x, test_label) in enumerate(test_loader):
test_x = test_x.to(device)
test_label = test_label.to(device)
predict_y = model(test_x.float()).detach()
predict_y =torch.argmax(predict_y, dim=-1)
current_correct_num = predict_y == test_label
all_correct_num += np.sum(current_correct_num.to('cpu').numpy(), axis=-1)
all_sample_num += current_correct_num.shape[0]
acc = all_correct_num / all_sample_num
# print('accuracy: {:.3f}'.format(acc), flush=True)
print(f'Epoch {current_epoch+1}/{all_epoch}, accuracy: {acc:.3f}, LR: {sgd.param_groups[0]["lr"]:.6f}', flush=True)
if not os.path.isdir("models"):
os.mkdir("models")
torch.save(model, 'models/mnist_{:.3f}.pkl'.format(acc))
# 当连续两个epoch的验证集准确率变化小于0.01%时停止。
if np.abs(acc - prev_acc) < 1e-4:
break
prev_acc = acc
print("Model finished training")
执行结果展示
python train.py
Epoch 1/100, accuracy: 0.822, LR: 0.100000
Epoch 2/100, accuracy: 0.908, LR: 0.100000
Epoch 3/100, accuracy: 0.949, LR: 0.100000
Epoch 4/100, accuracy: 0.959, LR: 0.100000
Epoch 5/100, accuracy: 0.968, LR: 0.100000
Epoch 6/100, accuracy: 0.973, LR: 0.100000
Epoch 7/100, accuracy: 0.976, LR: 0.100000
Epoch 8/100, accuracy: 0.980, LR: 0.100000
Epoch 9/100, accuracy: 0.974, LR: 0.100000
Epoch 10/100, accuracy: 0.982, LR: 0.100000
Epoch 11/100, accuracy: 0.983, LR: 0.100000
Epoch 12/100, accuracy: 0.982, LR: 0.100000
Epoch 13/100, accuracy: 0.984, LR: 0.100000
Epoch 14/100, accuracy: 0.985, LR: 0.100000
Epoch 15/100, accuracy: 0.986, LR: 0.100000
Epoch 16/100, accuracy: 0.985, LR: 0.100000
Epoch 17/100, accuracy: 0.985, LR: 0.100000
Model finished training