11 卷积神经网络 CNN 入门:计算机是怎样看图片的
2026/8/12 17:48:11 网站建设 项目流程

前言

前 10 篇建立了神经网络的通用基础:Tensor、自动求导、多层感知机、损失函数、优化器、训练与验证流程,以及过拟合和正则化。从这一篇开始,我们把这些知识带到图像任务中。

普通多层感知机(MLP)已经能拟合非线性规律,为什么处理图片时还要专门设计卷积神经网络(Convolutional Neural Network,CNN)?答案不在于“图片必须使用某个 API”,而在于图片本身具有特殊的数据结构:

  • 像素按照高度和宽度排列;
  • 相邻像素往往共同形成边缘、纹理和轮廓;
  • 同一种局部模式可能出现在图片的不同位置;
  • 颜色图片还包含彼此对应的多个通道。

CNN 通过局部连接和权重共享,把这些特点直接放进网络结构。本篇将从一个小矩阵出发,手算一次卷积,再使用 PyTorch 的nn.Conv2dnn.ReLUnn.MaxPool2d搭建一个只做前向计算的基础 CNN。

本篇不会训练 CNN,也不会使用 torchvision、真实图像数据集、数据增强或迁移学习。这些内容留给后续文章。

一、前置知识与学习目标

阅读本文前,建议已经掌握:

  • Tensor 的 shape、维度索引和批次维度;
  • MLP、nn.Linear、ReLU 和可训练参数;
  • nn.Moduleforward()nn.Sequential
  • 训练、验证和测试的职责边界。

学完本文后,你应该能够:

  • 看懂灰度图、RGB 图片以及批量图片的[C,H,W][N,C,H,W]
  • 解释 MLP 可以处理图片,但直接展平没有显式利用二维空间结构;
  • 手算局部卷积,并用固定权重的Conv2d验证;
  • 理解卷积核、特征图、局部连接和权重共享;
  • 区分输入通道、输出通道和原始颜色通道;
  • 看懂Conv2d.weight[out_channels,in_channels,kH,kW]
  • 根据 kernel size、stride 和 padding 计算输出尺寸;
  • 理解 ReLU、Max Pooling 和感受野的基本作用;
  • 逐层追踪一个两层 CNN 的 shape,并得到分类 logits。

本文代码在 Python 3.9.25、PyTorch 2.2.2+cu121 环境中运行。

二、图片首先是具有空间结构的 Tensor

图片与普通表格数据有什么不同

一行表格可能包含年龄、收入、房间数等不同含义的特征。图片中的数值则按照二维位置排列,像素(10, 10)(10, 11)通常比相距很远的两个像素具有更直接的局部关系。

一条边缘并不是某个像素单独形成的,而是邻近像素发生明暗变化的结果;纹理和轮廓也依赖局部区域中的组合。与此同时,同样的竖直边缘可能出现在左侧、中间或右侧。理想的模型应该学会“这种局部变化是什么”,而不是在每个绝对位置分别学习一套互不相关的规则。

灰度图、RGB 图与 NCHW

一张高度为 (H)、宽度为 (W) 的灰度图片可以先表示成[H,W]。进入卷积层时,还要明确它有一个灰度通道,因此常写成:

[1,H,W]

RGB 图片在同一空间位置记录红、绿、蓝三个值,所以常写成:

[3,H,W]

把多张图片组成一个批次后,最前面再增加样本数量N

[N,C,H,W]

四个字母分别表示:

  • N:批次中的样本数量(Batch Size);
  • C:通道数(Channel);
  • H:高度(Height);
  • W:宽度(Width)。

PyTorch 的Conv2d常用这种 NCHW 顺序。下面的代码没有加载图片文件,只用全零 Tensor 验证 shape:

import torch gray_hw = torch.zeros(28, 28) gray_chw = gray_hw.unsqueeze(0) rgb_chw = torch.zeros(3, 32, 32) rgb_batch_nchw = torch.zeros(8, 3, 32, 32) print("gray [H,W]:", gray_hw.shape) print("gray [C,H,W]:", gray_chw.shape) print("RGB [C,H,W]:", rgb_chw.shape) print("batch [N,C,H,W]:", rgb_batch_nchw.shape)

实际输出:

gray [H,W]: torch.Size([28, 28]) gray [C,H,W]: torch.Size([1, 28, 28]) RGB [C,H,W]: torch.Size([3, 32, 32]) batch [N,C,H,W]: torch.Size([8, 3, 32, 32])

unsqueeze(0)在最前面增加一个长度为 1 的通道维,没有复制或删除像素。一张 RGB 图片若只有[3,32,32],送入通常按批次处理的代码前还可能需要再增加 batch 维,变成[1,3,32,32]

三、MLP 能处理图片,但没有显式利用空间结构

MLP 通常先把原图展平

一张28×28的灰度图片共有 784 个数。交给 MLP 时,常见做法是:

[N,1,28,28] → Flatten → [N,784] → Linear

这种方法完全可以训练。在 MNIST 等较简单任务上,MLP 也可能得到不错结果。因此,“MLP 不能处理图片”是错误说法。

更准确的表述是:直接展平把二维坐标压进一个长特征维,后续Linear看见的是 784 个位置,没有显式规定相邻像素应该一起处理。图片稍微平移后,同一个局部形状会落到另一组输入位置,全连接层需要用大量独立参数重新学习相应关系。

CNN 引入了适合图像的结构偏好

CNN 主要使用两种设计:

  1. 局部连接(Local Connectivity):一次卷积只查看一个局部窗口;
  2. 权重共享(Weight Sharing):同一组卷积核权重在不同空间位置重复使用。

它们不是“证明所有图像任务必须用 CNN”的定理,而是与常见图像规律非常匹配的结构偏好。

四、从一次局部乘加理解卷积

卷积核是什么

卷积核(Kernel,也常称 Filter)是一个较小的权重张量。先看单通道3×3情况:卷积核覆盖输入的一个3×3局部区域,对应位置相乘,再把九个乘积相加。如果卷积层带偏置,还要加上偏置值。

一个输出位置可以写成:

这个公式要解决的问题是:如何把一个局部窗口压缩成一个响应值。各符号含义如下:

手算一个输出位置

局部输入为:

1 2 3 4 5 6 7 8 9

固定卷积核为:

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

不使用偏置时,对应位置乘加为:

1×1+2×0+3×(−1)+4×1+5×0+6×(−1)+7×1+8×0+9×(−1)=−6

-6表示这个固定 kernel 对当前局部区域的响应。这个经典 kernel 可以帮助观察左右方向的数值差异,但不能据此声称真实 CNN 的某一层一定会学习出完全相同的“边缘检测器”。真实网络中的 kernel 通常从初始化值出发,通过反向传播和优化器自动学习。

用固定权重的 Conv2d 验证手算

下面同时验证单个位置和完整5×5 → 3×3滑动结果。Conv2d的输入必须包含[N,C,H,W]四个维度,所以小矩阵要变成[1,1,H,W]

import torch from torch import nn local_patch = torch.tensor( [ [1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], ] ) kernel = torch.tensor( [ [1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, -1.0], ] ) manual_one_position = (local_patch * kernel).sum() image = torch.tensor( [ [1.0, 1.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0, 1.0], ] ) # 用循环完成同样的滑动乘加,输出为 3×3。 manual_feature_map = torch.empty(3, 3) for row in range(3): for column in range(3): patch = image[row:row + 3, column:column + 3] manual_feature_map[row, column] = (patch * kernel).sum() conv = nn.Conv2d( in_channels=1, out_channels=1, kernel_size=3, bias=False, ) # copy_ 会修改参数值,因此放进 no_grad,避免记录无用的梯度操作。 with torch.no_grad(): conv.weight.copy_(kernel.reshape(1, 1, 3, 3)) torch_one_position = conv(local_patch.reshape(1, 1, 3, 3)).squeeze() torch_feature_map_4d = conv(image.reshape(1, 1, 5, 5)) torch_feature_map = torch_feature_map_4d.squeeze() print("manual one position:", manual_one_position.item()) print("PyTorch one position:", torch_one_position.item()) print("one position equal:", torch.allclose(manual_one_position, torch_one_position)) print("manual feature map:") print(manual_feature_map) print("PyTorch feature map:") print(torch_feature_map) print("full feature map equal:", torch.allclose(manual_feature_map, torch_feature_map)) print( "Conv2d input/output:", (1, 1, 5, 5), tuple(torch_feature_map_4d.shape), )

实际输出:

manual one position: -6.0 PyTorch one position: -6.0 one position equal: True manual feature map: tensor([[ 0., 3., 3.], [ 0., 1., 1.], [ 0., -1., -1.]]) PyTorch feature map: tensor([[ 0., 3., 3.], [ 0., 1., 1.], [ 0., -1., -1.]]) full feature map equal: True Conv2d input/output: (1, 1, 5, 5) (1, 1, 3, 3)

手算与 PyTorch 在单个位置和完整特征图上都一致。这里特意设置bias=False,因为手算没有加偏置;若保留默认偏置,就必须把它也加入手算。

严格从数学术语看,PyTorchConv2d执行的是不翻转 kernel 的互相关(cross-correlation)。深度学习领域仍普遍把这种运算称为卷积;kernel 会通过训练学习,所以这项差异不影响本文的结构主线。

五、滑动之后得到 Feature Map

卷积核怎样移动

对于5×5输入、3×3kernel、stride=1padding=0,kernel 先覆盖左上角3×3区域,计算一个数;然后向右移动一个位置,再计算下一个数。一行结束后向下移动,直到所有合法位置处理完毕。

横向有 3 个合法位置,纵向也有 3 个合法位置,所以最终得到3×3输出。代码中的两个特征图正是完整滑动结果。

什么是特征图

一个卷积核在输入的所有位置完成计算后得到的输出矩阵,称为特征图(Feature Map)。它记录同一组权重在不同空间位置的响应。

响应为正、为负或接近零本身没有固定语义,要结合卷积核、后续激活函数和训练任务理解。真实 CNN 中,不同 kernel 会在训练中形成不同的特征表示。

六、局部连接和权重共享为什么重要

局部连接

如果把224×224×3图片展平,就会得到 150528 个输入。全连接层的每个输出神经元都连接全部输入。卷积层则先用3×35×5等局部窗口处理邻域,这就是局部连接。

边缘、角点和简单纹理通常首先由局部像素关系形成,所以这种连接方式与图片结构相符。随着网络加深,后层可以继续组合前层已经提取的局部信息。

权重共享

一个3×3kernel 在图片左侧和右侧滑动时,使用的是同一组权重,而不是为每个位置创建一套新参数。这就是权重共享。它让同一种局部模式出现在不同位置时,可以由同一个 filter 产生响应。

权重共享能减少参数,但不能被夸大成“CNN 对任意平移完全不变”。边缘处理、下采样、网络结构和训练数据都会影响最终的位置鲁棒性。

用真实参数量比较

28×28灰度图展平后连接 32 个 Linear 输出:

784×32+32=25120

使用 32 个3×3卷积核处理单通道输入:

32×1×3×3+32=320

实测结果也是 25120 和 320,卷积层只有前者约1/78.5的参数。这个比较说明参数共享的效果,但两个层的输出形式并不相同:Linear 得到 32 个全局数值,卷积得到 32 张空间特征图,因此它不是模型性能的一对一比较。

七、输入通道、输出通道与多个卷积核

一个输出 filter 必须覆盖所有输入通道

RGB 输入的单张图片 shape 是[3,H,W]。一个输出 filter 不能只看其中一个颜色通道,而是包含三个3×3权重切片:分别作用于 R、G、B 对应局部区域,然后把三个通道的结果相加,再加该输出通道的偏置。

因此,一个标准二维卷积层的 weight shape 是:

[out_channels,in_channels,kernel_height,kernel_width]

例如in_channels=3out_channels=16kernel_size=3时:

[16,3,3,3]

这表示有 16 个输出 filters,每个 filter 都跨越 3 个输入通道,并在每个通道上拥有3×3权重。每个输出 filter 产生一张特征图,所以输出通道数是 16。

深层通道不再等同于颜色

第一层输入的 3 个通道可以对应 RGB。第一层输出的 16 个通道则是模型学习到的 16 组特征表示,不是 16 种颜色。下一层若写Conv2d(16,32,...),它会把这 16 张输入特征图共同作为输入,再产生 32 张新特征图。

八、正式认识 nn.Conv2d

关键参数

一个基础卷积层可以写成:

import torch from torch import nn torch.manual_seed(42) conv = nn.Conv2d( in_channels=3, out_channels=16, kernel_size=3, stride=1, padding=0, bias=True, ) x = torch.randn(8, 3, 32, 32) y = conv(x) print("weight:", conv.weight.shape) print("bias:", conv.bias.shape) print("input:", x.shape) print("output:", y.shape) print("parameters:", sum(parameter.numel() for parameter in conv.parameters()))

这段代码可以独立运行,各参数含义如下:

  • in_channels=3:输入有 3 个通道;
  • out_channels=16:学习 16 个输出 filters,产生 16 个输出通道;
  • kernel_size=3:空间窗口为3×3
  • stride=1:每次沿高度或宽度移动 1 个位置;
  • padding=0:输入四周不补值;
  • bias=True:每个输出通道学习一个偏置,这是默认设置。

实际输出:

weight: torch.Size([16, 3, 3, 3]) bias: torch.Size([16]) input: torch.Size([8, 3, 32, 32]) output: torch.Size([8, 16, 30, 30]) parameters: 448

参数量为:

[ 16\times3\times3\times3+16=448 ]

前半部分是 weight 的 432 个参数,后面的 16 是 bias。这里使用默认groups=1;分组卷积属于后续内容,本篇不展开。

九、Kernel、Stride、Padding 共同决定输出尺寸

先理解三个参数

Kernel Size决定一次查看多大的局部区域。本篇主要使用3×3,但 CNN 并非只能使用 3,也存在1×15×57×7等大小。

Stride(步幅)决定 kernel 每次移动多少位置。stride=1是逐格移动;stride=2是每次跨两个位置,输出空间尺寸通常缩小得更快,计算量也会下降,但可能损失更多细节。

Padding(填充)在输入边缘补充数值。基础Conv2d的默认padding_mode="zeros"可以理解为补 0。kernel_size=3stride=1padding=1时,常能保持 H/W 不变。Padding 可以让边缘附近参与更多窗口计算,但不能保证完全消除边缘信息损失。

常用输出尺寸公式

在本篇默认dilation=1的情况下,单个空间维度的输出大小为:

公式用于分别计算输出高度和宽度,其中:

  • (I):输入高度或宽度;
  • (K):对应方向的 kernel size;
  • (P):对应方向两侧使用的 padding 数量;
  • (S):stride;
  • (O):输出高度或宽度;

例如I=32K=3P=0S=1

所以前面的32×32经过卷积后变成30×30,因为不允许3×3kernel 超出输入边缘。

若考虑 dilation(空洞间隔),更完整的单维公式是:

(D) 是 dilation。本篇所有实算都使用默认 (D=1),只保留完整公式帮助以后查阅,不展开空洞卷积。

四组手算与 PyTorch 验证

输入 (I)Kernel (K)Stride (S)Padding (P)公式输出PyTorch 输出 H/W
323103030×30
323113232×32
323211616×16
285102424×24

第三行的计算为:

具体 PyTorch 验证包含在后面的综合基础层代码中。

十、Pooling:汇总局部信息并缩小 H/W

Max Pooling 怎样计算

池化(Pooling)会在局部区域中按照固定规则汇总信息。最常见的入门例子是最大池化(Max Pooling)。对于:

1 3 2 4

2×2最大池化取出最大值 4。若使用kernel_size=2, stride=2,窗口通常不会重叠,H/W 会大致减半。

nn.MaxPool2d对每个通道分别处理,因此基础设置下不改变NC,只缩小HW

[8,16,32,32] → MaxPool2d(2,2) → [8,16,16,16]

Pooling 有什么作用和限制

最大池化可以:

  • 缩小后续特征图,降低计算量;
  • 汇总局部区域中的强响应;
  • 可能增强对一定局部位置变化的鲁棒性。

“可能增强局部鲁棒性”不等于“保证完全平移不变”。过度池化还会快速丢失空间细节。Pooling 也不是所有 CNN 必须使用的固定组件,一些网络会用stride>1的卷积完成下采样。

MaxPool 按固定规则取最大值,没有需要训练的 weight 或 bias,因此参数量为 0。

十一、ReLU 与经典 CNN Block

卷积对输入执行的仍然是线性或仿射运算。如果只连续堆叠卷积层而没有非线性激活,多层结构的表达能力会受到限制。ReLU 对每个元素执行max(0,x),为网络加入非线性,并保持输入 shape 不变。

一个经典但并非唯一的基础模块是:

Conv2d → ReLU → MaxPool2d

下面的完整代码集中验证参数量、四组输出尺寸、Pooling、ReLU 和一个 CNN Block:

import torch from torch import nn torch.manual_seed(42) def count_parameters(module: nn.Module) -> int: return sum(parameter.numel() for parameter in module.parameters()) # Linear 与卷积层的参数数量对比 linear = nn.Linear(28 * 28, 32) conv_gray = nn.Conv2d(1, 32, kernel_size=3) print("Linear parameters:", count_parameters(linear)) print("Conv parameters:", count_parameters(conv_gray)) # RGB Conv2d 的 weight、bias、输入和输出 conv_rgb = nn.Conv2d(3, 16, kernel_size=3) conv_input = torch.randn(8, 3, 32, 32) conv_output = conv_rgb(conv_input) print("Conv weight:", conv_rgb.weight.shape) print("Conv bias:", conv_rgb.bias.shape) print("Conv parameters:", count_parameters(conv_rgb)) print("Conv input/output:", conv_input.shape, conv_output.shape) # 用公式与 PyTorch 同时验证 K/S/P 对输出尺寸的影响 cases = [ (32, 3, 1, 0), (32, 3, 1, 1), (32, 3, 2, 1), (28, 5, 1, 0), ] for input_size, kernel, stride, padding in cases: layer = nn.Conv2d( 3, 4, kernel_size=kernel, stride=stride, padding=padding, ) inputs = torch.randn(2, 3, input_size, input_size) outputs = layer(inputs) formula_size = (input_size + 2 * padding - kernel) // stride + 1 print( "KSP:", (input_size, kernel, stride, padding), "formula:", formula_size, "output:", outputs.shape, ) # MaxPool、ReLU 与一个完整 Block pool = nn.MaxPool2d(kernel_size=2, stride=2) pool_input = torch.randn(8, 16, 32, 32) pool_output = pool(pool_input) print("Pool input/output:", pool_input.shape, pool_output.shape) print("Pool parameters:", count_parameters(pool)) relu = nn.ReLU() relu_output = relu(pool_input) print("ReLU input/output:", pool_input.shape, relu_output.shape) print("ReLU parameters:", count_parameters(relu)) block = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2), ) block_input = torch.randn(4, 3, 32, 32) block_output = block(block_input) print("Block input/output:", block_input.shape, block_output.shape)

实际输出:

Linear parameters: 25120 Conv parameters: 320 Conv weight: torch.Size([16, 3, 3, 3]) Conv bias: torch.Size([16]) Conv parameters: 448 Conv input/output: torch.Size([8, 3, 32, 32]) torch.Size([8, 16, 30, 30]) KSP: (32, 3, 1, 0) formula: 30 output: torch.Size([2, 4, 30, 30]) KSP: (32, 3, 1, 1) formula: 32 output: torch.Size([2, 4, 32, 32]) KSP: (32, 3, 2, 1) formula: 16 output: torch.Size([2, 4, 16, 16]) KSP: (28, 5, 1, 0) formula: 24 output: torch.Size([2, 4, 24, 24]) Pool input/output: torch.Size([8, 16, 32, 32]) torch.Size([8, 16, 16, 16]) Pool parameters: 0 ReLU input/output: torch.Size([8, 16, 32, 32]) torch.Size([8, 16, 32, 32]) ReLU parameters: 0 Block input/output: torch.Size([4, 3, 32, 32]) torch.Size([4, 16, 16, 16])

这组结果验证了三个容易混淆的事实:Conv 通常改变通道和空间尺寸,ReLU 不改变 shape,基础 MaxPool 不改变通道但会缩小 H/W。ReLU 和 MaxPool 都没有可训练参数。

十二、多层卷积与感受野

感受野的直觉

感受野(Receptive Field)表示某个特征位置会受到原输入多大区域的影响。第一层3×3卷积的一个输出位置直接查看原图3×3区域。再叠加一个3×3stride=1、无 dilation 的卷积,第二层一个位置会通过第一层间接依赖原图5×5区域。

原因是第二层查看第一层相邻的3×3输出,而这些第一层位置各自又查看原图3×3区域;相邻区域重叠后,覆盖范围扩大到5×5。更深层、stride 和 pooling 都会继续影响感受野。本篇只建立这个直觉,不展开通用递推公式。

层级特征不等于每层都有固定名字

浅层可能对颜色变化、边缘和简单纹理产生响应;后层可以继续组合前层信息,形成更复杂的表示。可以用下面的概念链帮助理解:

像素 → 局部变化 → 纹理或简单形状 → 更复杂组合 → 分类依据

但不能机械断言“第一层一定检测边缘、第二层一定检测眼睛、第三层一定检测猫脸”。真实网络学习到的表示受数据、结构和训练目标影响,未必能为每个通道赋予清晰的人类名称。

为什么常见 H/W 变小而通道变多

基础 CNN 经常让空间尺寸逐步减小,例如32×32 → 16×16 → 8×8,同时让通道数从3 → 16 → 32增加。直觉上,空间下采样减少后续计算,而更多通道允许模型保留更多种特征表示。这是常见设计模式,不是所有 CNN 必须遵守的固定规则。

十三、搭建一个完整但不训练的 SimpleCNN

网络结构与逐层 shape

本篇的基础网络只完成前向计算:

[N,3,32,32] → Conv 3→16,padding=1 → [N,16,32,32] → ReLU → MaxPool → [N,16,16,16] → Conv 16→32,padding=1 → [N,32,16,16] → ReLU → MaxPool → [N,32,8,8] → Flatten → [N,2048] → Linear → [N,10]

为了让每一步可见,下面的教学版forward()接受trace=True。正式模型通常不在每次前向传播中打印这些内容。

import torch from torch import nn torch.manual_seed(42) class SimpleCNN(nn.Module): def __init__(self) -> None: super().__init__() self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1) self.relu1 = nn.ReLU() self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) self.relu2 = nn.ReLU() self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) self.classifier = nn.Linear(32 * 8 * 8, 10) def forward(self, inputs: torch.Tensor, trace: bool = False) -> torch.Tensor: if trace: print("input:", inputs.shape) outputs = self.conv1(inputs) if trace: print("conv1:", outputs.shape) outputs = self.relu1(outputs) if trace: print("relu1:", outputs.shape) outputs = self.pool1(outputs) if trace: print("pool1:", outputs.shape) outputs = self.conv2(outputs) if trace: print("conv2:", outputs.shape) outputs = self.relu2(outputs) if trace: print("relu2:", outputs.shape) outputs = self.pool2(outputs) if trace: print("pool2:", outputs.shape) outputs = torch.flatten(outputs, start_dim=1) if trace: print("flatten:", outputs.shape) logits = self.classifier(outputs) if trace: print("logits:", logits.shape) return logits model = SimpleCNN() example_images = torch.randn(4, 3, 32, 32) logits = model(example_images, trace=True) print("named parameters:") for name, parameter in model.named_parameters(): print(name, tuple(parameter.shape), parameter.numel()) total_parameters = sum(parameter.numel() for parameter in model.parameters()) print("total parameters:", total_parameters)

实际输出:

input: torch.Size([4, 3, 32, 32]) conv1: torch.Size([4, 16, 32, 32]) relu1: torch.Size([4, 16, 32, 32]) pool1: torch.Size([4, 16, 16, 16]) conv2: torch.Size([4, 32, 16, 16]) relu2: torch.Size([4, 32, 16, 16]) pool2: torch.Size([4, 32, 8, 8]) flatten: torch.Size([4, 2048]) logits: torch.Size([4, 10]) named parameters: conv1.weight (16, 3, 3, 3) 432 conv1.bias (16,) 16 conv2.weight (32, 16, 3, 3) 4608 conv2.bias (32,) 32 classifier.weight (10, 2048) 20480 classifier.bias (10,) 10 total parameters: 25578

输入 4 张 RGB32×32模拟图片,输出 shape 为[4,10]。每一行有 10 个 logits,它们是分类头对 10 个类别给出的原始分数。本篇没有定义损失函数,也没有调用backward()或优化器,所以网络没有训练。

十四、为什么卷积之后又可以 Flatten

文章开头说,直接展平原图没有显式利用二维结构。为什么现在又调用torch.flatten(outputs, start_dim=1)

两种位置的含义不同:

  • 一开始展平原图:空间特征还没有经过局部连接和权重共享处理;
  • 卷积特征提取后展平:前面的卷积、ReLU 和池化已经整合了局部空间信息,此时把最终特征图整理成一行,便于分类头汇总。

start_dim=1保留第 0 维 batch,把后面的[32,8,8]合并为32×8×8=2048,因此[4,32,8,8]变为[4,2048]。Flatten 不会删除元素,只改变组织 shape 的方式。

随后nn.Linear(2048,10)把每个样本的 2048 个特征汇总成 10 个 logits。CNN 并不排斥 Linear,许多经典分类网络都会在特征提取部分之后使用分类头;也存在全局平均池化等其他设计,本篇不展开。

十五、哪些层有参数,参数量怎样组成

基础情况下怎样改变 shape是否有可训练参数
Conv2d改变通道数,H/W 由 K/S/P 决定是,weight 和可选 bias
ReLU不改变 shape
MaxPool2d通常保持 N/C、缩小 H/W
Flatten合并指定维度,不删除元素
Linear把最后特征维变成out_features是,weight 和可选 bias

SimpleCNN 的参数来自三个层:

  1. conv116×3×3×3+16=448
  2. conv232×16×3×3+32=4640
  3. classifier10×2048+10=20490

总参数量为:

448+4640+20490=25578

model.named_parameters()的实际输出与逐层手算一致。注意,ReLU、MaxPool 和 Flatten 虽然参与前向数据流,却不会出现在named_parameters()中,因为它们没有需要学习的 weight 或 bias。

十六、Shape 练习:先计算,再让 PyTorch 检查

练习 1

输入:[16,3,64,64] Conv2d(3,32,kernel_size=3,stride=1,padding=1)

H/W 保持 64,输出通道为 32,所以答案是:

[16,32,64,64]

练习 2

输入:[16,32,32,32] Conv2d(32,64,kernel_size=3,stride=2,padding=1)

公式得到 H/W 为 16,输出通道为 64,所以答案是:

[16,64,16,16]

练习 3

输入:[16,64,16,16] MaxPool2d(kernel_size=2,stride=2)

Batch 和 Channel 不变,H/W 减半,所以答案是:

[16,64,8,8]

下面用独立代码验证三道题:

import torch from torch import nn exercise_1 = nn.Conv2d( 3, 32, kernel_size=3, stride=1, padding=1, )(torch.randn(16, 3, 64, 64)) exercise_2 = nn.Conv2d( 32, 64, kernel_size=3, stride=2, padding=1, )(torch.randn(16, 32, 32, 32)) exercise_3 = nn.MaxPool2d( kernel_size=2, stride=2, )(torch.randn(16, 64, 16, 16)) print("exercise 1:", exercise_1.shape) print("exercise 2:", exercise_2.shape) print("exercise 3:", exercise_3.shape)

实际输出:

exercise 1: torch.Size([16, 32, 64, 64]) exercise 2: torch.Size([16, 64, 16, 16]) exercise 3: torch.Size([16, 64, 8, 8])

检查 CNN shape 时,可以固定按照N → C → H → W的顺序:先确认 Batch 是否保留,再看输出通道由谁决定,最后用公式计算 H/W。这样比凭直觉猜四个数字可靠得多。

十七、CNN 与 MLP 的核心区别

对比项MLPCNN
常见输入[N,F][N,C,H,W]
连接方式全连接局部连接
空间位置的权重不同位置通常有独立权重同一 kernel 在空间位置共享
空间结构常先展平在特征提取阶段保留 H/W
基础参数层LinearConv2d
常见应用表格或一般特征图像及其他空间数据

这张表描述的是常见使用方式,不是排他的边界。MLP 可以处理图片,CNN 也不只用于图片:一维卷积可以处理序列,三维卷积可以处理体数据。本系列当前只聚焦二维图像。

CNN 的优势也不是“参数越少就一定更准”。局部连接和权重共享加入了适合图像的结构假设,往往能用更高效的参数组织方式学习空间模式;实际效果仍取决于数据、任务、结构和训练过程。

十八、常见问题与排查

1. 为什么 Conv2d 提示输入维度不对?

批量输入通常需要[N,C,H,W]。如果单张图片只有[C,H,W],可以用x.unsqueeze(0)增加 batch 维,得到[1,C,H,W]。还要检查是否误用了[N,H,W,C]

2. 为什么 RGB 图片的in_channels是 3?

因为原始输入在同一空间位置包含 R、G、B 三个颜色值。卷积层的in_channels必须与输入 Tensor 的C一致。

3. 灰度图为什么通常使用in_channels=1

灰度图每个空间位置只有一个强度值。即使原始数据暂时是[H,W],进入 Conv2d 时也通常整理为[1,H,W],批量后为[N,1,H,W]

4.out_channels=16表示 16 种颜色吗?

不是。它表示这一层使用 16 个输出 filters,产生 16 张特征图。只有原始 RGB 输入的前三个通道有明确的红、绿、蓝含义。

5. 一个输出 filter 为什么要覆盖所有输入通道?

一个输出特征需要综合同一局部位置的全部输入表示。标准groups=1卷积中,每个输出 filter 的 shape 是[in_channels,kH,kW]

6. 卷积后为什么 Channel 变成out_channels

每个输出 filter 产生一个输出通道。16 个 filters 对同一输入执行各自的局部计算,就得到 16 张输出特征图。

7. Conv2d 的 H/W 为什么变小了?

检查kernel_sizestridepaddingdilation。最常见原因是kernel_size=3, stride=1, padding=0,此时每个空间维度减少 2。

8. 为什么padding=1能让 3×3 卷积保持尺寸?

stride=1, dilation=1时,输入两侧各补一层,抵消3×3kernel 带来的空间缩小。若 stride 或 dilation 改变,就不能继续套用这个结论。

9. Stride 越大越好吗?

不是。较大 stride 可以更快缩小特征图和减少计算,但也会更稀疏地采样空间信息,可能损失细节。

10. Kernel 越大越好吗?

不是。更大 kernel 查看范围更大,但参数量和计算量也更高。3×3很常见,不代表它在所有任务中都是唯一选择。

11. 卷积核里的数值是谁设置的?

训练开始前由初始化方法给出。正常训练时,它们与 Linear 权重一样,通过loss.backward()得到梯度,再由优化器更新。本文的固定 kernel 只用于核对手算。

12. Conv2d 在图片每个位置使用不同 kernel 吗?

同一个输出 filter 在所有空间位置共享同一组权重。不同输出通道拥有不同 filters。

13. Feature Map 的每个数都代表什么?

它表示某个 filter 在对应局部位置的响应。响应的具体语义由训练得到的权重和后续网络决定,不能脱离上下文强行命名。

14. MaxPool 会改变 Channel 吗?

基础MaxPool2d对各通道分别处理,通常保持 N/C 不变,只根据 kernel、stride 等设置改变 H/W。

15. Pooling 越多越好吗?

不是。过度下采样会快速丢失空间细节,而且现代网络也可能用 stride 卷积替代部分池化。

16. MaxPool 和 ReLU 为什么没有出现在named_parameters()中?

它们执行固定规则,没有需要训练的 weight 或 bias。没有参数不等于没有作用,它们仍然会改变数值或 shape。

17. ReLU 会改变 H/W 或 Channel 吗?

普通 ReLU 是逐元素运算,因此输入和输出 shape 相同。它改变负值,而不是维度。

18. Flatten 会不会删除数据?

不会。Flatten 只是合并维度。把[N,32,8,8]变成[N,2048]后,元素总数不变,但 H/W 不再作为独立维度存在。

19. 为什么开头不能随意 Flatten,后面却可以?

前者在空间特征提取前就压平原图;后者是在卷积已经利用局部结构后,为分类头重新组织高层特征。两者发生的位置和作用不同。

20. CNN 完全不需要 Linear 吗?

不是。许多经典 CNN 使用 Linear 分类头,也有网络使用全局平均池化等其他设计。本文采用最直观的基础结构。

21. CNN 能保证识别出现在任意位置的目标吗?

不能这样保证。权重共享让同一个局部模式可在不同位置被同一 filter 检测,但完整的位置鲁棒性还受到 padding、stride、pooling、数据和训练过程影响。

22. 为什么模型输入 32×32,换成 64×64 后 Linear 报 shape 错误?

本文分类器固定接收32×8×8=2048个特征,这是根据32×32输入计算的。输入尺寸改变后,最终 H/W 也可能改变,需要重新设计分类头或使用能适应尺寸变化的结构;本篇不提前展开这些方案。

本章小结

本文从图片的二维空间结构出发,完成了 CNN 的第一次前向实践:

  • MLP 可以处理图片,但直接 Flatten 没有显式利用像素的邻域与位置关系;
  • CNN 通过局部连接先观察局部区域,通过权重共享在不同位置复用同一组 kernel;
  • 卷积核中的数值通常是可训练参数,滑动后形成 Feature Map;
  • PyTorchConv2d的批量输入通常是[N,C,H,W]
  • 标准卷积的 weight shape 是[out_channels,in_channels,kH,kW]
  • 一个输出 filter 跨越全部输入通道,并产生一个输出通道;
  • Kernel Size、Stride 和 Padding 共同决定输出 H/W;
  • ReLU 提供非线性且保持 shape,MaxPool 可以汇总局部信息并缩小 H/W;
  • 多层卷积逐步扩大感受野,让后层能够整合更大范围的信息;
  • 卷积特征提取完成后,可以 Flatten 并通过 Linear 得到分类 logits;
  • 实测 SimpleCNN 把[4,3,32,32]转换为[4,10],共有 25578 个可训练参数。

现在,我们已经能解释 CNN 为什么适合图片,也能逐层检查一个基础网络的前向 shape,但还没有真正加载图片和训练分类器。下一篇将把图像 Dataset、Transform、数据增强、CNN 训练、验证与测试组合成第一个完整图像分类项目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询