跳到主要内容

卷积操作原理 🔧

本节定位

如果说前面的神经元、MLP 让你学会了“神经网络会算什么”,那卷积这节就是在回答另一个更关键的问题:

神经网络怎样高效地看图?

这一节是整条 CV 主线的起点。后面你学分类、检测、分割,都会反复用到这里的直觉。

学习目标

  • 理解为什么图像任务不能直接粗暴地用全连接层解决
  • 直觉理解卷积核、局部连接、参数共享
  • 手算一个最小卷积示例,真正看懂每个输出值怎么来的
  • 掌握 stridepadding 和输出尺寸计算
  • 理解多通道卷积和感受野
  • 能看懂 PyTorch 中最基础的 Conv2d

一、为什么图像任务需要卷积?

1.1 先看一个“直接展平”的问题

假设你有一张 32 x 32 的灰度图。

如果把它直接展平成一个向量,再送进全连接层:

  • 输入维度是 32 * 32 = 1024
  • 如果下一层有 512 个神经元,就需要 1024 * 512 = 524288 个权重

如果图片再大一点,比如 224 x 224 x 3

  • 输入维度变成 150528
  • 参数量会瞬间爆炸

更糟糕的是,展平以后,图像原本最重要的东西被破坏了:

  • 邻近像素之间的关系
  • 边缘、纹理、局部图案
  • 空间结构

也就是说:

图片不是普通表格数据。

它最值钱的不是“有多少数字”,而是“这些数字怎样在空间上挨在一起”。

1.2 卷积到底解决了什么?

卷积做了两件特别重要的事:

  1. 只看局部区域,而不是一次看整张图
  2. 同一组参数在整张图上滑动复用

这两个设计分别对应:

  • 局部连接
  • 参数共享

你可以把卷积理解成:

拿一个小模板,在图片上滑来滑去,专门找某种局部模式。

比如:

  • 竖线
  • 横线
  • 边缘
  • 角点
  • 纹理

二、卷积核是什么?

2.1 一个最容易理解的类比

卷积核(kernel / filter)就像一张很小的“透明模板”。

你把它盖在图片的一小块区域上:

  • 对应位置相乘
  • 再把结果加起来

得到一个分数。

这个分数可以理解成:

这块区域有多像卷积核正在寻找的模式。

2.2 最小可运行示例:手工做一次卷积

import numpy as np

# 4x4 图像
image = np.array([
[1, 2, 0, 0],
[5, 3, 0, 4],
[2, 1, 3, 1],
[0, 2, 1, 2]
], dtype=np.float32)

# 2x2 卷积核
kernel = np.array([
[1, 0],
[0, -1]
], dtype=np.float32)

out = np.zeros((3, 3), dtype=np.float32)

for i in range(3):
for j in range(3):
patch = image[i:i + 2, j:j + 2]
out[i, j] = np.sum(patch * kernel)

print("image =\n", image)
print("kernel =\n", kernel)
print("output =\n", out)

2.3 第一个输出值是怎么来的?

左上角的 2x2 patch 是:

[[1, 2],
[5, 3]]

卷积核是:

[[ 1, 0],
[ 0,-1]]

逐元素相乘:

[[ 1*1, 2*0],
[ 5*0, 3*(-1)]]

求和:

1 + 0 + 0 - 3 = -2

所以输出左上角就是 -2

这就是卷积最核心的计算。


三、卷积为什么能检测边缘?

3.1 因为它本质上在比较局部差异

如果一个卷积核专门设计成“左边减右边”或“上边减下边”,它就会对边界特别敏感。

例如下面这个核:

[[ 1,  0],
[ 0, -1]]

它会对“左上亮、右下暗”这类局部结构有反应。

如果图像某块区域很平滑、像素差不多,卷积结果往往接近 0。
如果局部变化很剧烈,卷积结果就会比较大。

3.2 再看一个边缘核

import numpy as np

image = np.array([
[0, 0, 0, 0, 0],
[0, 0, 1, 1, 1],
[0, 0, 1, 1, 1],
[0, 0, 1, 1, 1],
[0, 0, 0, 0, 0]
], dtype=np.float32)

kernel = np.array([
[-1, 1]
], dtype=np.float32)

out = np.zeros((5, 4), dtype=np.float32)
for i in range(5):
for j in range(4):
patch = image[i:i + 1, j:j + 2]
out[i, j] = np.sum(patch * kernel)

print("output =\n", out)

你会看到在“0 变 1”的边界附近,输出最明显。


四、Stride 和 Padding 到底是什么?

4.1 Stride:每次滑几步

stride 可以理解成卷积核在图片上移动的步长。

  • stride = 1:每次挪 1 格
  • stride = 2:每次挪 2 格

步长越大:

  • 输出更小
  • 计算更快
  • 细节丢失更多

4.2 Padding:先在图像边缘补一圈

如果不做 padding,卷积核滑到边缘时就会停下来,输出尺寸会变小。

padding 的作用是:

  • 保留边缘信息
  • 控制输出尺寸

最常见的是补 0,也叫 zero padding。

4.3 输出尺寸公式

对于二维卷积:

output = floor((input + 2*padding - kernel_size) / stride) + 1

例如:

  • 输入宽高:6
  • 卷积核:3
  • padding:1
  • stride:2

则输出尺寸:

floor((6 + 2*1 - 3) / 2) + 1 = floor(5/2) + 1 = 2 + 1 = 3

4.4 可运行示例:验证输出尺寸

import torch
from torch import nn

x = torch.randn(1, 1, 6, 6) # batch=1, channel=1, H=6, W=6

conv = nn.Conv2d(
in_channels=1,
out_channels=2,
kernel_size=3,
stride=2,
padding=1
)

y = conv(x)

print("input shape :", x.shape)
print("output shape:", y.shape)

输出里你会看到高宽都变成 3


五、多通道卷积:彩色图片怎么处理?

5.1 灰度图和 RGB 图的区别

灰度图 shape 往往是:

  • H x W

RGB 图在深度学习里常写成:

  • C x H x W

其中:

  • C = 3
  • 分别对应 R/G/B 三个通道

5.2 卷积核也会“长出通道”

如果输入是 RGB 图,那么一个卷积核不再只是 3 x 3,而是:

3 x 3 x 3

也就是:

  • 对红通道看一个 3x3
  • 对绿通道看一个 3x3
  • 对蓝通道看一个 3x3

最后把三个通道的结果加起来,再加偏置,得到一个输出值。

5.3 多个卷积核 = 多个输出通道

如果你有 16 个卷积核,就会得到 16 张特征图。
这就是为什么 Conv2d 里会写:

  • in_channels
  • out_channels
import torch
from torch import nn

x = torch.randn(2, 3, 32, 32) # batch=2, RGB 图像
conv = nn.Conv2d(in_channels=3, out_channels=8, kernel_size=3, padding=1)
y = conv(x)

print("input shape :", x.shape)
print("output shape:", y.shape)

这里输出 shape 会是:

  • [2, 8, 32, 32]

也就是:

  • 2 张图片
  • 每张图片提取出 8 个通道的特征图

六、感受野:为什么深层网络能看到更大范围?

6.1 感受野的直觉

感受野(receptive field)指的是:

输出中的一个位置,能“看到”原图多大范围。

一个 3x3 卷积层,看见的只是局部 3x3

但如果连续堆叠多层:

  • 第一层看 3x3
  • 第二层基于第一层结果再看 3x3

那么第二层实际就间接看到了更大的原图范围。

6.2 为什么这很重要?

因为图像理解通常有层级:

  • 浅层:边缘、纹理
  • 中层:角点、局部形状
  • 深层:物体部件、整体语义

卷积网络之所以强,不是因为“卷积这个动作神奇”,而是因为:

小局部特征可以一层层组合成更抽象的大模式。


七、PyTorch 里的卷积层到底在做什么?

7.1 最基础的 Conv2d

import torch
from torch import nn

x = torch.randn(1, 1, 8, 8)

conv = nn.Conv2d(
in_channels=1,
out_channels=4,
kernel_size=3,
stride=1,
padding=1
)

y = conv(x)

print("输入 shape :", x.shape)
print("输出 shape :", y.shape)
print("权重 shape :", conv.weight.shape)
print("偏置 shape :", conv.bias.shape)

这里:

  • out_channels=4 表示有 4 个卷积核
  • conv.weight.shape = [4, 1, 3, 3]
    • 4 个输出通道
    • 每个核看 1 个输入通道
    • 核大小 3x3

7.2 卷积层后面为什么常接激活函数?

和 MLP 一样:

  • 卷积先做线性变换
  • 激活函数再引入非线性

典型写法:

nn.Conv2d(...)
nn.ReLU()

八、初学者最常见的坑

8.1 把卷积当成“魔法特征提取器”

卷积不是魔法,本质上就是:

  • 小窗口
  • 逐元素乘法
  • 求和
  • 滑动

8.2 搞混 shape

最常见错误之一就是搞混:

  • H x W x C
  • C x H x W

在 PyTorch 里,通常是:

  • N x C x H x W

8.3 不知道输出尺寸怎么算

很多报错不是模型不会,而是尺寸不匹配。
所以 kernel_size / stride / padding 的尺寸计算一定要会。


小结

这一节最重要的不是记住“卷积”两个字,而是建立三个稳定直觉:

  1. 图像任务需要保留空间结构,所以不能简单展平后暴力全连接
  2. 卷积核是在整张图上重复寻找局部模式
  3. 多层卷积让模型从局部特征逐步组合出更高级的视觉理解

理解了这三点,你后面学 CNN 结构、经典架构、目标检测时,就不会把卷积层当黑盒。


练习

  1. 把本节的 2x2 卷积核改成别的数值,观察输出怎样变化。
  2. 自己手算一个输出位置,再和代码结果对比。
  3. 用 PyTorch 改写一个 kernel_size=5stride=2 的卷积层,验证输出尺寸。
  4. 想一想:如果图片中的物体整体平移一点点,卷积为什么通常比全连接更稳?