ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

卷积神经网络入门:从卷积核到特征图,彻底搞懂卷积算法

卷积神经网络入门:从卷积核到特征图,彻底搞懂卷积算法

1. 卷积到底在卷什么:从一次图像分类翻车说起

很多人第一次接触卷积神经网络,都是从一行Conv2d(in_channels=3, out_channels=64, kernel_size=3)开始的。代码跑通了,模型训练了,准确率也上去了,但要是问一句“这个卷积核到底在图上干了什么”,能说清楚的人其实不多。我见过不少同学,调参调得飞起,可一旦遇到特征图尺寸对不上、通道数算错、感受野覆盖不了目标这些问题,就完全不知道从哪儿下手排查。

这篇内容就是想把卷积这件事从头到尾拆开讲一遍。核心关键词是卷积算法、卷积核、特征图、通道、步长、填充、感受野,围绕官方文档里关于卷积层的定义,把数学原理、工程实现、参数计算和实际踩坑经验串起来。它解决的不是“怎么调包”的问题,而是“为什么这么设计、参数怎么算、出问题怎么查”的问题。适合已经跑过几个Demo、想真正把卷积吃透的开发者,也适合刚入门、不想只停留在调库层面的学习者。

我个人的习惯是,学任何一层网络结构,都要能回答三个问题:输入输出是什么形状、中间做了哪些运算、每个参数控制什么。卷积层尤其如此,因为它是整个视觉模型里计算量最大、参数最多、也最容易出错的一层。下面我就按这个思路,把卷积从设计动机到实操细节完整过一遍。

2. 卷积层的整体设计与思路拆解

2.1 为什么全连接层处理图像会崩掉

要理解卷积为什么被设计出来,得先看它替代了什么。假设你有一张 224×224 的彩色图,展平之后是 224×224×3 = 150528 个输入值。如果接一个只有 1000 个神经元的全连接层,参数量就是 150528×1000 ≈ 1.5 亿个。这还只是一层,而且图像稍微大一点、层数稍微深一点,参数量直接爆炸。

更致命的问题不是参数量,而是全连接层丢掉了空间结构。把图像展平成一维向量之后,原本相邻的像素被拆散,模型根本不知道哪个像素挨着哪个像素。图像里最有价值的信息恰恰是局部的、空间相关的——边缘、角点、纹理,这些模式都依赖像素之间的相对位置。全连接层把这种结构彻底抹平了,学起来自然吃力。

卷积的思路完全不同。它不把图像展平,而是让一个小窗口在图像上滑动,每次只看一小块局部区域。这个设计带来两个直接好处:一是参数共享,同一个卷积核在整张图上重复使用,参数量从上亿降到几百;二是局部连接,每个输出只依赖输入的一小块区域,天然保留了空间关系。这就是卷积能处理图像的根本原因。

2.2 卷积核本质上是一个特征探测器

很多人把卷积核理解成一个“权重矩阵”,这没错,但不够直观。我更愿意把它看成一个特征探测器。一个 3×3 的卷积核,就是 9 个可学习的数字,它在图上滑动时,实际上是在问:“我关注的这个小区域,和我这 9 个数字描述的模式像不像?”

举个具体的例子。如果卷积核的数值是中间为 8、周围为 -1 的 3×3 矩阵,那它对什么敏感?对孤立的高亮像素敏感,对平坦区域不敏感。因为平坦区域里中心像素和周围像素差不多,加权求和后接近 0;而孤立亮点处中心远大于周围,加权求和后是个大正数。这就是经典的边缘检测核。模型在训练中会自动学出各种这样的核,有的检测水平边缘,有的检测垂直边缘,有的检测特定方向的纹理。

提示:理解这一点很关键——卷积核的数值不是人手工设计的,是反向传播学出来的。我们分析它,只是为了理解模型学到了什么,而不是去手动指定。

2.3 多通道卷积:为什么输出通道数可以自由指定

单通道卷积好理解,但真实图像是 3 通道的,中间层动辄 64、128、256 通道,这里就容易晕。核心规则只有一条:一个卷积核的通道数必须等于输入的通道数,一个卷积核产生一个输出通道。

假设输入是 3 通道,你想输出 64 通道,那就需要 64 个卷积核,每个卷积核的形状是 3×3×3(高×宽×输入通道)。每个卷积核在输入的 3 个通道上分别做卷积,然后把 3 个通道的结果相加,得到一个二维的特征图。64 个卷积核各自算一遍,就得到 64 张特征图,堆叠起来就是 64 通道的输出。

这里有个常见误区:有人以为输出通道数是“卷积核的数量”,这没错,但更准确的说法是“卷积核组的数量”。因为每个卷积核本身是多通道的,它和输入做的是跨通道的加权求和。参数量计算也要注意:一个 3×3×3 的卷积核有 27 个参数,64 个就是 1728 个参数,再加上 64 个偏置,总共 1792 个。这个数字比全连接层小了好几个数量级。

2.4 步长与填充:控制特征图尺寸的两个旋钮

卷积输出的尺寸不是随便来的,它由输入尺寸、卷积核大小、步长、填充四个量共同决定。公式是:

输出尺寸 = (输入尺寸 + 2×填充 - 卷积核大小) / 步长 + 1

这个公式必须记牢,因为实际开发中尺寸对不上是最高频的报错来源。我见过太多人在拼接特征图、做残差连接时,因为两边尺寸差了一个像素而卡半天。

步长(stride)控制滑动的跨度。步长为 1 时,卷积核逐像素滑动,输出尺寸基本不变;步长为 2 时,每次跳两格,输出尺寸大约减半。步长大于 1 是一种下采样手段,能减少计算量、扩大感受野,但会丢失部分空间细节。

填充(padding)是在输入边缘补零,目的是控制输出尺寸、保护边缘信息。如果不填充,每次卷积都会让特征图缩小,几层之后边缘信息就丢得差不多了。常见的填充策略有两种:valid不填充,输出会缩小;same填充到输出和输入尺寸一致(步长为 1 时)。same填充的具体数量是(卷积核大小 - 1) / 2,前提是卷积核为奇数。

注意:卷积核大小通常取奇数(3、5、7),就是为了让same填充能对称进行,中心像素有明确的对应位置。用偶数核虽然也能算,但填充不对称,工程上很少这么干。

3. 核心细节解析与实操要点

3.1 参数量与计算量的手算方法

搞懂卷积,一定要能手算参数量和计算量,这是评估模型复杂度、判断能不能部署到端侧的基础。参数量公式很直接:

参数量 = 卷积核高 × 卷积核宽 × 输入通道 × 输出通道 + 输出通道(偏置)

以 3×3 卷积、输入 256 通道、输出 256 通道为例,参数量是 3×3×256×256 + 256 = 590080,接近 59 万。如果换成 1×1 卷积做同样的通道变换,参数量是 1×1×256×256 + 256 = 65792,只有前者的九分之一。这就是为什么很多轻量网络大量使用 1×1 卷积做通道压缩和升维。

计算量(FLOPs)的公式是:

FLOPs = 输出高 × 输出宽 × 输出通道 × 卷积核高 × 卷积核宽 × 输入通道

注意这里算的是乘加次数,实际浮点运算数还要乘 2(一次乘一次加)。还是上面的例子,假设输出特征图是 56×56,那 FLOPs = 56×56×256×3×3×256 ≈ 18.5 亿次乘加。这个量级在深层网络里会迅速累积,所以计算量优化是模型设计的核心议题之一。

3.2 感受野:为什么深层网络能看懂大物体

感受野指的是输出特征图上一个像素,对应到原始输入图上覆盖的区域大小。浅层卷积的感受野很小,可能只有 3×3,只能看到局部边缘;随着层数加深,感受野逐渐扩大,深层神经元能看到整张图的大部分区域,才能识别出“猫”“狗”这种大尺度目标。

感受野的递推计算方法是:从最后一层往前推,每经过一层卷积,感受野增加(卷积核大小 - 1) × 之前所有层的步长乘积。这个计算有点绕,但结论很重要:堆叠小卷积核比用一个大卷积核更划算。两个 3×3 卷积堆叠,感受野等于一个 5×5 卷积,但参数量是 2×3×3=18,而 5×5 是 25,而且两层之间多了一次非线性激活,表达能力更强。三个 3×3 堆叠等效于一个 7×7,参数量 27 对 49,优势更明显。这就是 VGG 系列全部用 3×3 小核的核心原因。

实操心得:设计网络时,如果发现模型对大目标识别效果差,先检查感受野够不够。感受野覆盖不了目标尺寸,再多的数据增强也救不回来。

3.3 空洞卷积:不增加参数扩大感受野的巧招

有时候我们需要大感受野,但又不想增加参数和计算量,这时候空洞卷积就派上用场了。它的做法是在卷积核的元素之间插入空洞,比如一个 3×3 的核,空洞率为 2 时,实际覆盖的区域是 5×5,但参与计算的还是 9 个权重。

空洞卷积在语义分割任务里几乎是标配,因为分割既要大感受野理解全局,又要保持分辨率不下降。它的代价是局部信息可能被跳过,网格状的空洞会导致某些像素永远不被采样到,所以实践中常用不同空洞率的卷积组合来缓解这个问题。

3.4 卷积核初始化与训练稳定性

卷积核的初始值不是随便设的。如果初始值太大,前向传播时激活值会爆炸;太小则信号逐层衰减,深层几乎学不到东西。常见的初始化方法有 Xavier 和 Kaiming 两种,选择依据是激活函数类型:Sigmoid、Tanh 这类对称激活用 Xavier,ReLU 及其变体用 Kaiming。

Kaiming 初始化的核心思想是让每一层输出的方差保持一致,具体做法是按输入神经元数量开根号来缩放。PyTorch 里nn.Conv2d默认用的就是 Kaiming 均匀初始化,所以大多数情况下不用手动设置。但如果你自己写了自定义卷积层,或者改了激活函数,就要留意初始化是否匹配,否则训练不收敛的锅可能就扣在这儿。

4. 实操过程与核心环节实现

4.1 用 PyTorch 手写一个卷积并验证尺寸

光看公式不够,动手算一遍才记得住。下面这段代码手动实现一个二维卷积,并和 PyTorch 官方Conv2d的结果对比,验证我们对尺寸和数值的理解是否正确。

import torch import torch.nn as nn # 构造一个 1×1×5×5 的输入 x = torch.arange(25, dtype=torch.float32).reshape(1, 1, 5, 5) # 官方卷积层:3×3 核,步长 1,无填充 conv = nn.Conv2d(1, 1, kernel_size=3, stride=1, padding=0, bias=False) # 手动把权重设成已知值,方便验证 with torch.no_grad(): conv.weight.copy_(torch.tensor([[[[1, 0, -1], [1, 0, -1], [1, 0, -1]]]], dtype=torch.float32)) out = conv(x) print("官方输出尺寸:", out.shape) # 应该是 1×1×3×3 print(out)

按公式算:输入 5,卷积核 3,填充 0,步长 1,输出 = (5 + 0 - 3) / 1 + 1 = 3,和打印结果一致。这个核是垂直边缘检测核,输出值大的位置对应输入中左右差异明显的地方。你可以把输入换成一张真实图片,可视化输出特征图,会直观看到边缘被高亮出来。

4.2 特征图尺寸计算的完整推演

实际项目里,尺寸计算往往涉及多层堆叠,我习惯用一张表把每层的输入输出列清楚,避免中途算错。下面是一个典型的小型网络配置:

层输入尺寸卷积核步长填充输出尺寸
Conv1224×224×33×321112×112×64
Conv2112×112×643×311112×112×128
Pool1112×112×1282×22056×56×128
Conv356×56×1283×31156×56×256

逐行验证:Conv1 输出 = (224 + 2×1 - 3) / 2 + 1 = 112,通道数由卷积核组数决定,设为 64。Conv2 步长 1、填充 1,尺寸不变。Pool1 是 2×2 最大池化,步长 2,尺寸减半。Conv3 同理尺寸不变。这张表建议每个项目都画一遍,尤其是做特征融合、跳跃连接的时候,两边尺寸必须严格对齐。

4.3 从零实现一个卷积层的前向传播

想彻底搞懂卷积,最好自己用循环实现一遍前向传播。虽然效率低,但能看清每一步在干什么。

import numpy as np def conv2d_naive(x, w, b, stride=1, padding=0): # x: (C_in, H, W), w: (C_out, C_in, KH, KW) C_in, H, W = x.shape C_out, _, KH, KW = w.shape # 填充 x_pad = np.pad(x, ((0,0),(padding,padding),(padding,padding)), mode='constant') H_out = (H + 2*padding - KH) // stride + 1 W_out = (W + 2*padding - KW) // stride + 1 out = np.zeros((C_out, H_out, W_out)) for oc in range(C_out): for i in range(H_out): for j in range(W_out): h_start = i * stride w_start = j * stride region = x_pad[:, h_start:h_start+KH, w_start:w_start+KW] out[oc, i, j] = np.sum(region * w[oc]) + b[oc] return out

这段代码把卷积的四个循环(输出通道、输出高、输出宽、输入通道)全部展开,配合前面的公式,能帮你把“卷积到底怎么算”这件事彻底钉死在脑子里。跑通之后再用im2col或者直接调Conv2d,就知道底层发生了什么。

4.4 卷积核可视化:看看模型到底学了什么

训练完一个模型,把第一层的卷积核拿出来可视化,是很有价值的调试手段。第一层核通常呈现明显的边缘、颜色对比模式,如果可视化出来是一片噪声,说明训练可能出了问题。

import matplotlib.pyplot as plt # 假设 conv1 是第一层卷积,权重形状 (64, 3, 3, 3) weights = conv1.weight.data.cpu().numpy() # 归一化到 0-1 便于显示 weights = (weights - weights.min()) / (weights.max() - weights.min()) # 拼成 8×8 的网格 fig, axes = plt.subplots(8, 8, figsize=(8, 8)) for i, ax in enumerate(axes.flat): if i < weights.shape[0]: # 转置成 H×W×C 便于显示 ax.imshow(weights[i].transpose(1, 2, 0)) ax.axis('off') plt.show()

我实测下来,正常训练的第一层核大多能看出方向性,有的像 Gabor 滤波器,有的对特定颜色敏感。如果全是随机噪声,先检查学习率是不是太大,或者数据归一化有没有做对。

5. 常见问题与排查技巧实录

5.1 尺寸对不上:最高频的报错及定位方法

尺寸不匹配是卷积网络里最常见的报错,典型信息是RuntimeError: Sizes of tensors must match except in dimension 1。遇到这种问题,不要盲目改参数,按下面的顺序排查:

  1. 打印每一层输出的 shape,定位到具体是哪一层开始对不上。
  2. 用公式重新算一遍该层的输出尺寸,检查步长、填充、卷积核是否和预期一致。
  3. 如果是跳跃连接,确认两边的通道数和空间尺寸都要一致,通道不一致用 1×1 卷积调整,尺寸不一致用填充或池化对齐。

我踩过的一个坑是:same填充在步长大于 1 时,输出尺寸是向上取整还是向下取整,不同框架的实现有细微差别。跨框架迁移模型时,这一点要特别验证,否则会出现差一个像素的诡异问题。

5.2 训练不收敛:从卷积层角度找原因

模型不收敛,很多人第一反应是调学习率,但卷积层本身也可能有问题。下面这张表是我整理的排查清单:

现象可能原因排查方法
loss 一直是 nan初始化过大或学习率过高检查初始化方法,降低学习率
loss 不下降卷积核全零或梯度消失打印权重统计,检查激活函数
准确率卡在随机水平通道数太少,特征表达不足增加通道数或层数
训练好测试差卷积核过拟合加正则化、数据增强、Dropout

注意:如果第一层卷积核可视化出来几乎全黑或全白,说明权重没有有效更新,优先检查梯度是否正常回传,以及输入数据有没有做归一化。

5.3 显存爆了:卷积层的显存都花在哪

卷积层的显存占用主要来自三部分:权重、激活值、梯度。权重通常不大,激活值才是大头,尤其是浅层的高分辨率特征图。一个 224×224×64 的特征图,float32 下就是 224×224×64×4 ≈ 12.8 MB,如果 batch size 是 32,一层就 400 MB 以上。

降低显存的常用手段有:减小 batch size、使用混合精度训练、用梯度检查点(用计算换显存)、把部分卷积换成深度可分离卷积。深度可分离卷积把标准卷积拆成逐通道卷积和 1×1 卷积两步,参数量和计算量都能降到原来的几分之一,是移动端模型的核心组件。

5.4 卷积核大小怎么选:3×3 不是万能答案

虽然 3×3 是主流,但不是所有场景都适用。第一层面对原始图像时,有时用 5×5 或 7×7 能更快捕捉大尺度模式,代价是参数多一点。而 1×1 卷积专门用于通道变换,不改变空间尺寸,在瓶颈结构里用来压缩和恢复通道数。大核卷积(如 7×7 以上)现在也有回归趋势,配合深度可分离实现,能在保持效率的同时扩大感受野。

选择的核心依据是:你需要多大的感受野,以及你能承受多少计算量。感受野需求由目标任务中物体的尺度决定,计算量由部署环境决定,两者一夹,卷积核大小基本就定下来了。

6. 卷积算法的延伸与个人实践体会

卷积发展到现在,已经衍生出很多变体:转置卷积用于上采样,在分割和生成任务里必不可少;可变形卷积让采样位置可以学习,适应不规则形状目标;分组卷积把通道分组独立卷积,是 ResNeXt 和 MobileNet 的基础。这些变体看起来花哨,但底层逻辑都是标准卷积的那套东西——局部连接、参数共享、滑动窗口。把标准卷积吃透,这些变体看文档就能上手。

我自己在实际项目里最大的体会是:卷积层的设计要服务于数据和任务,而不是反过来。曾经有个项目,输入是分辨率很低的工业检测图,我一开始照搬了常见网络的配置,第一层就下采样到很小,结果小缺陷全丢了。后来把第一层步长改成 1、去掉池化,保留高分辨率特征,效果立刻好转。这件事让我明白,官方文档给的是通用范式,真正落地时必须结合数据特点去调整。

最后分享一个实用习惯:每次设计完卷积网络,先别急着训练,用随机输入跑一遍前向传播,把每层的输入输出尺寸、参数量、计算量全部打印出来,确认无误再开训。这个习惯帮我省下了大量调试时间,也让我对每一层的开销心里有数。卷积这东西,算得清楚,才调得明白。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进