20-卷积神经网络基础:用 Paddle 构建 CNN

概述

图像是二维空间结构数据。一个像素和周围像素之间有强关系。如果把 [3, 224, 224] 的图片直接拉平成一个长向量,再接 MLP,会丢掉很多空间结构信息,也会产生大量参数。

卷积神经网络(CNN)更适合图像,因为它利用了三个重要思想:

  • 局部感受野:卷积核只看局部区域。
  • 参数共享:同一个卷积核在整张图上滑动。
  • 层级特征:浅层学边缘纹理,深层学更抽象模式。

Paddle 中构建 CNN 的基础层包括:

  • paddle.nn.Conv2D
  • paddle.nn.ReLU
  • paddle.nn.MaxPool2D
  • paddle.nn.Flatten
  • paddle.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=3padding=1stride=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 推导写熟

  1. 修改 conv1out_channels,观察参数量变化。
  2. padding=1 改成 padding=0,推导输出尺寸。
  3. 打印每层输出 shape。
  4. 增加第三个卷积层。
  5. 把 MNIST 的 batch_size 改成 32 和 128。
  6. 保存训练好的 CNN 参数,再重新加载评估。

总结

这一篇讲了 CNN 的核心基础:

  • 图像输入通常是 [N, C, H, W]
  • Conv2D 通过卷积核提取局部特征。
  • out_channels 决定输出特征图数量。
  • kernel_sizestridepadding 决定空间尺寸。
  • MaxPool2D 降低空间尺寸。
  • Flatten 把特征图展平成向量。
  • Linear 输出分类 logits。
  • 训练分类模型时用 cross_entropy(logits, labels)

如果只能记住一句话,那就是:

CNN 不是把图片拉平成向量硬学,而是用卷积在空间结构上提取可共享的局部特征。

相关推荐
(轻舟已过万重山)1 小时前
第40章 Spring AI 实战:企业级 AI 应用架构
人工智能·spring·架构
zzm6281 小时前
WSDM 2018论文精读:基于多关系学习与路径约束的商品替代互补关系挖掘
人工智能·学习
aneasystone本尊1 小时前
学习 Headroom 的 CCR 可逆压缩
人工智能
大模型任我行1 小时前
谷歌:扩散模型实现极速文本生成
人工智能·语言模型·自然语言处理·论文笔记
IT_陈寒1 小时前
Vite热更新失效?我的几个犯傻操作害我debug两小时
前端·人工智能·后端
月光船幽幽1 小时前
锁死后干预有效性的关键突破
人工智能·python·算法
深念Y1 小时前
CPA-Auto池方案总结
人工智能·ai·自动化·路由·代理·账号·轮询
FellAveal1 小时前
【Transformer入门】从函数到Transformer
人工智能·深度学习·transformer
阳光开朗男孩1 小时前
Pytorch的安装与配置
人工智能·pytorch·python