第5章:构建 CNN 网络

🎉摘要:本文详细讲解卷积神经网络中卷积层(Conv2d)和池化层(MaxPool2d)的作用,并通过PyTorch搭建完整的CNN网络实现MNIST手写数字识别。内容包括卷积核原理、特征提取、池化降维、网络结构设计及代码实现,适合深度学习初学者。

这一章的目标

  • 理解卷积层(Conv2d) 做了什么 —— 为什么它比全连接层更适合处理图像

  • 理解池化层(MaxPool2d) 的作用 —— 为什么要把图片"缩小"

  • 搭一个完整的 CNN 网络 用于 MNIST 识别,并测试它的输出是否正常

为什么需要卷积层?

第 4 章我们用了全连接层(nn.Linear)搭了一个网络。但你知道输入 nn.Linear(784, ...) 之前要做什么吗?要把 28×28 的图 "拉平" 成 784 个像素。

这个操作有一个致命问题:空间信息丢失了

举例说明:下面这两张图在"拉平"之后,像素序列完全不一样,但图像内容其实是同一个字母 0 左右反转了一下(参考 show_mnist_flip.py 代码)。

对于人类来说,我们看一张图不会一个像素一个像素看,而是看局部特征:这里有边、那里有角、这里有个圈……

卷积层就是模拟这种 "局部感知" 的方式。它用一个小窗口(称为卷积核/filter)在图片上滑动,每次只看一小块区域,提取局部特征。如下图:

上图演示卷积神经网络里卷积层的核心计算过程,也就是卷积核(Filter/Kernel)在图像上滑动做计算,生成输出特征图(Output Array / Feature Map)。其中:

  • Input Image(左侧:输入图像矩阵):是原始图像像素矩阵。图中浅蓝色高亮框,代表卷积核当前覆盖住的3×3 局部图像窗口,取窗口内 9 个像素。

  • Filter(中间:卷积核 / 过滤器,3×3 权重矩阵):这是一组可学习的权重,用来检测图像特征(边缘、线条等)。卷积核会在整张图像上滑动。注意,PyTorch/Tensorflow 里面做的不是严格数学卷积,是互相关。卷积核不旋转翻转,直接对应位置相乘求和,工程上都叫“卷积”。

  • Output Array(右侧:输出特征图):每一个格子,是输入局部窗口和卷积核计算得到的结果。图中蓝色方块16就是输出的第一个值 Output[0][0]。

📌 术语:卷积(Convolution)

你不用管数学上的 "卷积" 是什么。在深度学习中,"卷积" 就是一个小窗口在图片上滑动,逐位置做加权求和的过程。

比如说一个 3×3 的卷积核,每次看 9 个像素,计算它们加权求和的结果作为输出。滑动完整个图片,就得到了 "特征图(feature map)"。

直观理解卷积核

假设有一个 3×3 的卷积核,专门用来检测竖线。它的权重大概长这样:

-1  0  1
-1  0  1
-1  0  1

把它在图片上滑动。当它遇到一条竖线(左边暗、右边亮)时,加权求和的结果会是一个很大的数。遇到其他地方,结果会接近于 0(运行 conv2d_demo.py 示例代码,演示卷积运算)。

这就叫 "这个卷积核激活了"。不同的卷积核能检测不同的特征:横线、斜线、拐角、圆等等。一层网络通常有多个卷积核(比如 32 个),每个学习检测不同的特征。

PyTorch 中的卷积层

用 nn.Conv2d 定义卷积层。它的参数:

nn.Conv2d(
    in_channels=1,     # 输入通道数(灰度图=1,RGB图=3)
    out_channels=32,   # 输出通道数 = 卷积核数量(学32种特征)
    kernel_size=3,     # 卷积核大小(3×3)
    stride=1,          # 滑动步长
    padding=1          # 边缘填充(保持输出尺寸不变)
)

我们自己验证一下:

import torch
import torch.nn as nn

# 构造二维卷积层对象
# in_channels=1:输入通道数,MNIST灰度图只有1个通道
# out_channels=32:输出32个卷积核,学习提取32种不同图像特征(边缘、纹理等)
# kernel_size=3:卷积核大小3×3
# padding=1:图像四周填充1圈0像素;目的:卷积之后高、宽保持和输入不变
conv = nn.Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1)

# MNIST 图片张量格式标准:(batch, channel, height, width)
# dummy_input:构造虚拟测试输入张量
# batch=1:1个样本;channel=1灰度通道;H=28,W=28 MNIST图像原始分辨率
dummy_input = torch.randn(1, 1, 28, 28)    # 1张图 × 1通道 × 28×28

# 执行卷积前向计算,虚拟图片送入卷积层
output = conv(dummy_input)

# 打印输入张量维度
print(f"输入形状: {dummy_input.shape}")     # → (1, 1, 28, 28)
# batch不变;通道变为32(32个卷积核各输出一路特征图);高宽维持28×28,padding=1带来的效果
print(f"输出形状: {output.shape}")          # → (1, 32, 28, 28)

print(f"\n卷积核权重weight形状: {conv.weight.shape}") # [out_channels,in_channels,kH,kW] →(32,1,3,3)
print(f"偏置bias形状: {conv.bias.shape}")             # 每个输出通道配有一个偏置 (32,)

运行代码,输出如下:

输入形状: torch.Size([1, 1, 28, 28])
输出形状: torch.Size([1, 32, 28, 28])

卷积核权重weight形状: torch.Size([32, 1, 3, 3])
偏置bias形状: torch.Size([32])

📌 术语:通道(Channel)

  • 输入图片的 "通道":灰度图是 1,RGB 彩图是 3(红绿蓝各一个通道)

  • 卷积层输出的 "通道":等于卷积核数量,每个通道是一张 "特征图"

  • 下一层卷积的输入通道必须等于上一层卷积的输出通道

池化层 —— 把图片"缩小"

池化层(Pooling)的作用是减小特征图的尺寸,保留最重要的信息

最常用的是 最大池化(MaxPool2d):在一个小窗口(比如 2×2)里,只保留最大值,扔掉其他的。

这样做的三个好处:

  1. 减少计算量:图变小了,后面层的计算量就少了

  2. 防止过拟合:扔掉细节,模型被迫关注最重要的特征

  3. 获得平移不变性:数字往左移一点或往右移一点,池化后的结果差不多

示例代码:

import torch
import torch.nn as nn

# 定义最大池化层
# kernel_size=2:池化窗口大小为2×2
# 默认 stride=kernel_size 即 stride=2,窗口每次滑动2格,不重叠
# 计算逻辑:在每一个2×2小窗口内,只保留窗口中最大的那个数值,丢弃其余数值
pool = nn.MaxPool2d(kernel_size=2)  # 2×2 窗口,取最大值

# 输入:卷积层输出得到的特征图
# shape: [batch, channels, height, width]
# batch=1,通道32,高28,宽28,和上一节Conv2d输出结果保持一致
dummy_input = torch.randn(1, 32, 28, 28)

# 执行池化前向传播:特征图送入MaxPool2d层做下采样
output = pool(dummy_input)

# 打印池化之前张量维度
print(f"池化前形状: {dummy_input.shape}")  # → (1, 32, 28, 28)
# 经过2×2最大池化、stride=2:高宽两个维度都除以2;通道数、batch数量保持不变
print(f"池化后形状: {output.shape}")       # → (1, 32, 14, 14)
#                                     尺寸减半 ↑

注意,2×2 的最大池化会让图片的宽和高各减半

运行代码,输出如下:

池化前形状: torch.Size([1, 32, 28, 28])
池化后形状: torch.Size([1, 32, 14, 14])

搭一个完整的 MNIST CNN

现在我们把卷积层(Conv2d)、池化层(MaxPool2d)、全连接层(Linear)组合起来,搭一个真正能用的 MNIST 识别网络。

神经网络结构设计思路:

输入 (1, 28, 28)
   ↓
Conv1: 1→32 通道, 3×3卷积      → (32, 28, 28)     提取低级特征(边、角)
   ↓ ReLU
Pool1: 2×2 最大池化            → (32, 14, 14)     尺寸减半
   ↓
Conv2: 32→64 通道, 3×3卷积     → (64, 14, 14)     提取高级特征(形状、部件)
   ↓ ReLU
Pool2: 2×2 最大池化            → (64, 7, 7)       尺寸再减半
   ↓
Flatten(拉平)                → (64×7×7=3136)    展成一维
   ↓
FC1: 3136 → 128                → (128)            全连接分类
   ↓ ReLU
FC2: 128 → 10                  → (10)             输出10个得分

说明:

  • Conv 卷积层:3×3+padding=1,不缩小尺寸;通道数逐步升高(1→32→64):浅层少通道抓简单边缘纹理,深层多通道捕捉数字局部形状。

  • ReLU 激活:引入非线性,网络可以学习复杂模式;去掉 ReLU 网络等价线性模型。

  • MaxPool 最大池化:2×2 stride=2,宽高直接折半。减少参数量、抑制过拟合,让特征对微小位置偏移有一定鲁棒性。

  • Flatten 展平:卷积输出是多维特征图,全连接只能接收一维向量,所以把通道、高、宽合并,batch 维度不动。

  • 两层全连接:第一层降维到 128 做特征融合,第二层输出 10 个类别得分;训练时搭配CrossEntropyLoss,内部自带 softmax,不用手动写 softmax。

代码实现如下:

import torch
import torch.nn as nn

class MNIST_CNN(nn.Module):
    """用于 MNIST 手写数字识别的简单 CNN 网络"""
    def __init__(self):
        super().__init__()

        # ── 特征提取部分(卷积层) ──
        # 第1组:卷积 + 池化
        self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)  # 输入1通道→32特征
        self.pool1 = nn.MaxPool2d(2, 2)                          # 28×28 → 14×14

        # 第2组:卷积 + 池化
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 32特征→64特征
        self.pool2 = nn.MaxPool2d(2, 2)                          # 14×14 → 7×7

        # ── 分类部分(全连接层) ──
        self.fc1 = nn.Linear(64 * 7 * 7, 128)   # 特征图展平 → 128维
        self.fc2 = nn.Linear(128, 10)            # 128维 → 10个类别

    def forward(self, x):
        """前向传播:数据在网络中流动的路径"""
        # 特征提取
        x = torch.relu(self.conv1(x))    # Conv1 + ReLU
        x = self.pool1(x)                # Pool1

        x = torch.relu(self.conv2(x))    # Conv2 + ReLU
        x = self.pool2(x)                # Pool2

        # 展平:把 (batch, 64, 7, 7) → (batch, 64*7*7)
        x = x.view(x.size(0), -1)

        # 分类
        x = torch.relu(self.fc1(x))      # FC1 + ReLU
        x = self.fc2(x)                  # FC2(输出 logits)

        return x

测试一下:

model = MNIST_CNN()
test_input = torch.randn(2, 1, 28, 28)   # 2张图片
output = model(test_input)
print(f"输入: {test_input.shape}")         # → (2, 1, 28, 28)
print(f"输出: {output.shape}")             # → (2, 10)

# 统计参数量
total = sum(p.numel() for p in model.parameters())
print(f"总参数: {total:,}")

应该输出 (2, 10) —— 2 张图各得到 10 个得分值。网络可以正常工作。

💡 CNN 的参数量远少于纯全连接网络

以 MNIST 任务为例,全连接网络第一层需要 784×128 ≈ 10 万参数(784 对应 28×28 图片的全部像素),每个像素都有独立权重、无法复用。而 CNN 采用卷积核权重共享机制,同一个卷积核遍历整张图片、全程复用同一组参数,无需为每个图像位置单独设置权重,因此参数量大幅降低。

本章小结

  • 卷积层(nn.Conv2d):用小窗口滑动扫描图片,提取局部特征

  • 卷积核(filter):每个核学一种特征模式(边、角、纹理等)

  • 池化层(nn.MaxPool2d):缩小特征图,减少计算量,加平移不变性

  • CNN = 卷积 + 池化(特征提取)→ 全连接(分类)

  • padding=1 + kernel_size=3 能保持图片尺寸不变

点击查看完整的 搭完整的 MNIST 识别网络 示例代码。

  

说说我的看法
全部评论(
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
其他应用
公众号