概述
图像是二维空间结构数据。一个像素和周围像素之间有强关系。如果把 [3, 224, 224] 的图片直接拉平成一个长向量,再接 MLP,会丢掉很多空间结构信息,也会产生大量参数。
卷积神经网络(CNN)更适合图像,因为它利用了三个重要思想:
- 局部感受野:卷积核只看局部区域。
- 参数共享:同一个卷积核在整张图上滑动。
- 层级特征:浅层学边缘纹理,深层学更抽象模式。
Paddle 中构建 CNN 的基础层包括:
paddle.nn.Conv2Dpaddle.nn.ReLUpaddle.nn.MaxPool2Dpaddle.nn.Flattenpaddle.nn.Linear
读完本文,你应该能理解 CNN 的基本结构,并用 Paddle 写出一个可训练的 MNIST 卷积分类模型。
图像输入:CNN 常用 NCHW
Paddle 图像模型常用输入 shape:
text
[batch_size, channels, height, width]
例如 MNIST:
text
[32, 1, 28, 28]
彩色图片:
text
[32, 3, 224, 224]
CNN 的卷积层会在高度和宽度维度上提取局部特征。
Conv2D:卷积层做什么
Conv2D 常见写法:
python
conv = paddle.nn.Conv2D(
in_channels=1,
out_channels=16,
kernel_size=3,
stride=1,
padding=1,
)
参数含义:
| 参数 | 含义 |
|---|---|
in_channels |
输入通道数 |
out_channels |
输出通道数,也可理解为卷积核个数 |
kernel_size |
卷积核大小 |
stride |
卷积滑动步长 |
padding |
边界填充 |
输入:
text
[N, 1, 28, 28]
经过上面的卷积:
text
[N, 16, 28, 28]
因为 kernel_size=3、padding=1、stride=1 会保持空间尺寸不变。
输出尺寸如何计算
二维卷积输出高宽可以按公式理解:
Hout=⌊Hin+2P−KS⌋+1 H_{out} = \left\lfloor \frac{H_{in} + 2P - K}{S} \right\rfloor + 1 Hout=⌊SHin+2P−K⌋+1
其中:
H_in:输入高度。P:padding。K:kernel_size。S:stride。
示例:
text
H_in = 28
P = 1
K = 3
S = 1
H_out = floor((28 + 2 - 3) / 1) + 1 = 28
宽度同理。
如果 stride=2:
text
H_out = floor((28 + 2 - 3) / 2) + 1 = 14
理解输出尺寸,是写 CNN 时最重要的基础能力之一。
MaxPool2D:降低空间尺寸
池化层常用于降低特征图尺寸。
python
pool = paddle.nn.MaxPool2D(kernel_size=2, stride=2)
输入:
text
[N, 16, 28, 28]
输出:
text
[N, 16, 14, 14]
池化不会改变通道数,只改变空间尺寸。
作用:
- 降低计算量。
- 扩大后续特征的感受野。
- 提供一定平移鲁棒性。
一个基础 CNN 的结构
用于 MNIST 的小 CNN:
text
输入 [N, 1, 28, 28]
|
Conv2D(1, 16, 3, padding=1)
|
ReLU
|
MaxPool2D(2)
|
Conv2D(16, 32, 3, padding=1)
|
ReLU
|
MaxPool2D(2)
|
Flatten
|
Linear(32 * 7 * 7, 10)
shape 变化:
text
[N, 1, 28, 28]
[N, 16, 28, 28]
[N, 16, 14, 14]
[N, 32, 14, 14]
[N, 32, 7, 7]
[N, 1568]
[N, 10]
最后输出 10 个类别 logits,对应数字 0 到 9。
用 Paddle 定义 CNN
python
import paddle
import paddle.nn as nn
import paddle.nn.functional as F
class SimpleCNN(nn.Layer):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2D(in_channels=1, out_channels=16, kernel_size=3, padding=1)
self.conv2 = nn.Conv2D(in_channels=16, out_channels=32, kernel_size=3, padding=1)
self.pool = nn.MaxPool2D(kernel_size=2, stride=2)
self.flatten = nn.Flatten()
self.fc = nn.Linear(32 * 7 * 7, 10)
def forward(self, x):
x = self.conv1(x)
x = F.relu(x)
x = self.pool(x)
x = self.conv2(x)
x = F.relu(x)
x = self.pool(x)
x = self.flatten(x)
logits = self.fc(x)
return logits
这里 pool 被调用了两次,但没有可学习参数,因此复用同一个对象没有问题。
用随机输入检查 shape
训练前先做一次前向:
python
model = SimpleCNN()
x = paddle.randn([4, 1, 28, 28], dtype="float32")
logits = model(x)
print(logits.shape)
应该输出:
text
[4, 10]
打印每层 shape 调试 CNN
可以临时在 forward 中打印:
python
def forward(self, x):
print("input:", x.shape)
x = self.conv1(x)
print("after conv1:", x.shape)
x = F.relu(x)
x = self.pool(x)
print("after pool1:", x.shape)
x = self.conv2(x)
print("after conv2:", x.shape)
x = F.relu(x)
x = self.pool(x)
print("after pool2:", x.shape)
x = self.flatten(x)
print("after flatten:", x.shape)
return self.fc(x)
确认 shape 后再删掉打印。
使用 MNIST 训练 CNN
准备数据:
python
from paddle.vision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=[0.5], std=[0.5]),
])
train_dataset = datasets.MNIST(mode="train", transform=transform)
test_dataset = datasets.MNIST(mode="test", transform=transform)
train_loader = paddle.io.DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = paddle.io.DataLoader(test_dataset, batch_size=128, shuffle=False)
定义训练和评估:
python
def accuracy(logits, labels):
pred = paddle.argmax(logits, axis=1)
return paddle.mean((pred == labels).astype("float32"))
def train_one_epoch(model, loader, optimizer):
model.train()
total_loss = 0.0
total_acc = 0.0
count = 0
for images, labels in loader:
logits = model(images)
loss = F.cross_entropy(logits, labels)
acc = accuracy(logits, labels)
loss.backward()
optimizer.step()
optimizer.clear_grad()
total_loss += float(loss.numpy())
total_acc += float(acc.numpy())
count += 1
return total_loss / count, total_acc / count
def evaluate(model, loader):
model.eval()
total_acc = 0.0
count = 0
with paddle.no_grad():
for images, labels in loader:
logits = model(images)
total_acc += float(accuracy(logits, labels).numpy())
count += 1
return total_acc / count
训练:
python
model = SimpleCNN()
optimizer = paddle.optimizer.Adam(
learning_rate=0.001,
parameters=model.parameters(),
)
for epoch in range(5):
train_loss, train_acc = train_one_epoch(model, train_loader, optimizer)
test_acc = evaluate(model, test_loader)
print("epoch:", epoch, "train_loss:", train_loss, "train_acc:", train_acc, "test_acc:", test_acc)
完整脚本:MNIST CNN 分类
python
import paddle
import paddle.nn as nn
import paddle.nn.functional as F
from paddle.vision import datasets, transforms
class SimpleCNN(nn.Layer):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2D(1, 16, kernel_size=3, padding=1)
self.conv2 = nn.Conv2D(16, 32, kernel_size=3, padding=1)
self.pool = nn.MaxPool2D(kernel_size=2, stride=2)
self.flatten = nn.Flatten()
self.fc = nn.Linear(32 * 7 * 7, 10)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = self.flatten(x)
return self.fc(x)
def accuracy(logits, labels):
pred = paddle.argmax(logits, axis=1)
return paddle.mean((pred == labels).astype("float32"))
def train_one_epoch(model, loader, optimizer):
model.train()
total_loss = 0.0
total_acc = 0.0
count = 0
for images, labels in loader:
logits = model(images)
loss = F.cross_entropy(logits, labels)
acc = accuracy(logits, labels)
loss.backward()
optimizer.step()
optimizer.clear_grad()
total_loss += float(loss.numpy())
total_acc += float(acc.numpy())
count += 1
return total_loss / count, total_acc / count
def evaluate(model, loader):
model.eval()
total_acc = 0.0
count = 0
with paddle.no_grad():
for images, labels in loader:
logits = model(images)
total_acc += float(accuracy(logits, labels).numpy())
count += 1
return total_acc / count
def main():
paddle.seed(2026)
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=[0.5], std=[0.5]),
])
train_dataset = datasets.MNIST(mode="train", transform=transform)
test_dataset = datasets.MNIST(mode="test", transform=transform)
train_loader = paddle.io.DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = paddle.io.DataLoader(test_dataset, batch_size=128, shuffle=False)
model = SimpleCNN()
sample_images, sample_labels = next(iter(train_loader))
print("sample_images:", sample_images.shape)
print("sample_labels:", sample_labels.shape)
print("sample_logits:", model(sample_images[:4]).shape)
optimizer = paddle.optimizer.Adam(
learning_rate=0.001,
parameters=model.parameters(),
)
for epoch in range(5):
train_loss, train_acc = train_one_epoch(model, train_loader, optimizer)
test_acc = evaluate(model, test_loader)
print(
"epoch:", epoch,
"train_loss:", train_loss,
"train_acc:", train_acc,
"test_acc:", test_acc,
)
paddle.save(model.state_dict(), "simple_cnn_mnist.pdparams")
if __name__ == "__main__":
main()
CNN 和 MLP 的参数量差异
假设输入是 MNIST:
text
[1, 28, 28]
如果拉平成 MLP 输入:
text
784 -> 128 -> 10
第一层参数量:
text
784 * 128 + 128 = 100480
CNN 第一层:
text
Conv2D(1, 16, kernel_size=3)
参数量:
text
16 * 1 * 3 * 3 + 16 = 160
卷积层通过局部连接和参数共享显著减少参数,同时保留空间结构。
常见错误
错误一:输入不是 NCHW
CNN 通常期待:
text
[N, C, H, W]
如果输入是 [N, H, W, C],需要转换。
错误二:in_channels 写错
灰度图:
python
nn.Conv2D(1, 16, 3)
RGB 图:
python
nn.Conv2D(3, 16, 3)
错误三:Flatten 后 Linear 输入维度写错
先打印:
python
print(x.shape)
确认 flatten 后维度,再写 nn.Linear(...)。
错误四:分类训练最后手动 softmax
训练时:
python
loss = F.cross_entropy(logits, labels)
不要先 softmax 再交叉熵。
错误五:验证阶段忘记 eval 和 no_grad
应写:
python
model.eval()
with paddle.no_grad():
logits = model(images)
建议练习:把 CNN shape 推导写熟
- 修改
conv1的out_channels,观察参数量变化。 - 把
padding=1改成padding=0,推导输出尺寸。 - 打印每层输出 shape。
- 增加第三个卷积层。
- 把 MNIST 的
batch_size改成 32 和 128。 - 保存训练好的 CNN 参数,再重新加载评估。
总结
这一篇讲了 CNN 的核心基础:
- 图像输入通常是
[N, C, H, W]。 Conv2D通过卷积核提取局部特征。out_channels决定输出特征图数量。kernel_size、stride、padding决定空间尺寸。MaxPool2D降低空间尺寸。Flatten把特征图展平成向量。Linear输出分类 logits。- 训练分类模型时用
cross_entropy(logits, labels)。
如果只能记住一句话,那就是:
CNN 不是把图片拉平成向量硬学,而是用卷积在空间结构上提取可共享的局部特征。