今日目标 :掌握卷积的数学原理、从零实现2D卷积、理解各种卷积变体及端侧AI选型
预计阅读 :10分钟 | 动手操作:40分钟
一、卷积:CV的基石
全连接层处理图像的问题:
一张 224×224×3 的图 → 第一个全连接层输入 = 150,528 维
如果隐藏层1024维 → 参数量 = 150,528 × 1024 ≈ 1.54亿!
卷积的优势:
1. 参数共享:同一个卷积核滑过整张图
2. 局部连接:每个神经元只看一小块区域
3. 平移不变性:无论物体在哪,都能检测到
二、核心知识点
2.1 卷积的数学本质
python
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
"""
卷积 = 滑动窗口逐元素相乘再求和
用一个3×3的核在图像上滑动,每一步做:
output[i,j] = sum( kernel * image[i:i+3, j:j+3] )
这就是"互相关"(cross-correlation),深度学习里叫卷积
"""
def conv2d_naive(image, kernel):
"""从零实现2D卷积(单通道,valid模式)"""
H, W = image.shape
kH, kW = kernel.shape
out_H = H - kH + 1
out_W = W - kW + 1
output = np.zeros((out_H, out_W))
for i in range(out_H):
for j in range(out_W):
patch = image[i:i+kH, j:j+kW]
output[i, j] = np.sum(patch * kernel)
return output
# 演示:边缘检测
image = np.array([
[10, 10, 10, 10, 10],
[10, 50, 50, 50, 10],
[10, 50, 50, 50, 10],
[10, 50, 50, 50, 10],
[10, 10, 10, 10, 10],
], dtype=np.float32)
# Sobel垂直边缘检测核
kernel_x = np.array([
[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1],
], dtype=np.float32)
# Sobel水平边缘检测核
kernel_y = np.array([
[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1],
], dtype=np.float32)
edge_x = conv2d_naive(image, kernel_x)
edge_y = conv2d_naive(image, kernel_y)
print("原始图像(中间是亮块):")
print(image)
print(f"\n垂直边缘检测 (Sobel X):")
print(edge_x)
print(f"\n水平边缘检测 (Sobel Y):")
print(edge_y)
# 垂直边缘:在亮块的左右边界产生强响应
# 水平边缘:在亮块的上下边界产生强响应
2.2 卷积参数:kernel_size、stride、padding、dilation
python
def conv2d_params(image, kernel, stride=1, padding=0, dilation=1):
"""
带所有参数的卷积实现
参数:
- kernel_size: 卷积核大小
- stride: 步长,每一步移动几个像素
- padding: 边缘填充几圈
- dilation: 空洞卷积,核元素之间的间距
"""
H, W = image.shape
kH, kW = kernel.shape
# 计算输出尺寸
out_H = (H + 2 * padding - dilation * (kH - 1) - 1) // stride + 1
out_W = (W + 2 * padding - dilation * (kW - 1) - 1) // stride + 1
# Padding
if padding > 0:
image = np.pad(image, padding, mode='constant')
output = np.zeros((out_H, out_W))
for i in range(out_H):
for j in range(out_W):
# 考虑stride和dilation
h_start = i * stride
w_start = j * stride
patch_sum = 0
for ki in range(kH):
for kj in range(kW):
h_idx = h_start + ki * dilation
w_idx = w_start + kj * dilation
patch_sum += image[h_idx, w_idx] * kernel[ki, kj]
output[i, j] = patch_sum
return output
# 演示不同参数的效果
image = np.arange(25).reshape(5, 5).astype(np.float32)
kernel = np.ones((3, 3), dtype=np.float32)
print("原始图像 (5×5):")
print(image)
# stride=1, padding=0 → 输出 3×3
out = conv2d_params(image, kernel, stride=1, padding=0)
print(f"\nstride=1, padding=0 → {out.shape}:")
print(out)
# stride=2, padding=0 → 输出 2×2
out = conv2d_params(image, kernel, stride=2, padding=0)
print(f"\nstride=2, padding=0 → {out.shape}:")
print(out)
# stride=1, padding=1 → 输出 5×5 (same padding)
out = conv2d_params(image, kernel, stride=1, padding=1)
print(f"\nstride=1, padding=1 → {out.shape} (same padding):")
print(out)
# 输出尺寸公式总结
print("\n输出尺寸公式:")
print(" out = (H + 2*P - D*(K-1) - 1) / S + 1")
print(" H=输入高度, P=padding, D=dilation, K=核大小, S=stride")
print(" same padding: P = (K-1)/2 (当stride=1, dilation=1)")
2.3 感受野计算
python
def compute_receptive_field(layers):
"""
计算每层的感受野
感受野:输出特征图上一个像素对应输入图像上的区域大小
layers: [(kernel_size, stride), ...] 从浅到深
"""
rf = 1
jump = 1
print(f"{'层':<6s} {'核大小':<8s} {'步长':<8s} {'感受野':<8s} {'步幅':<8s}")
print('-' * 45)
print(f"{'输入':<6s} {'-':<8s} {'-':<8s} {rf:<8d} {jump:<8d}")
for i, (k, s) in enumerate(layers):
rf = rf + (k - 1) * jump
jump = jump * s
print(f"{i+1:<6d} {k:<8d} {s:<8d} {rf:<8d} {jump:<8d}")
return rf
# VGG16的感受野变化
vgg_layers = [
(3, 1), (3, 1), # block1
(3, 1), (3, 1), # block2 (pool stride=2)
(3, 1), (3, 1), (3, 1), # block3
(3, 1), (3, 1), (3, 1), # block4
(3, 1), (3, 1), (3, 1), # block5
]
print("\nVGG16感受野计算 (假设pool stride=2):")
rf = compute_receptive_field(vgg_layers)
print(f"\n最终感受野: {rf}×{rf}")
# 输出:感受野 = 212 --- 一个输出像素能"看到"212×212的输入区域
2.4 卷积变体全景
python
# ===== 1. 标准卷积 =====
standard_conv = nn.Conv2d(
in_channels=3, # 输入通道数
out_channels=64, # 输出通道数(卷积核数量)
kernel_size=3, # 卷积核大小
stride=1, # 步长
padding=1, # 填充
dilation=1, # 空洞率
groups=1, # 分组数
bias=True, # 是否加偏置
)
# 参数量: in_channels * out_channels * kH * kW + out_channels
# 标准卷积: 3 * 64 * 3 * 3 + 64 = 1,792
# 计算量 (FLOPs): 2 * H * W * in_channels * out_channels * kH * kW
# 输出 32×32: 2 * 32 * 32 * 3 * 64 * 9 ≈ 3.5M FLOPs
# ===== 2. 1×1卷积 (Pointwise Convolution) =====
pw_conv = nn.Conv2d(64, 128, kernel_size=1)
"""
1×1卷积的作用:
1. 改变通道数(升维/降维)
2. 跨通道信息融合
3. 减少参数量(作为bottleneck)
在端侧AI中特别重要!MobileNet大量使用
"""
# ===== 3. 深度可分离卷积 (Depthwise Separable Conv) =====
# 标准卷积分解为两步:Depthwise + Pointwise
# 参数量:从 C_in * C_out * K * K 降到 C_in * K * K + C_in * C_out * 1 * 1
class DepthwiseSeparableConv(nn.Module):
"""深度可分离卷积:MobileNet的核心"""
def __init__(self, in_channels, out_channels, kernel_size=3, stride=1):
super().__init__()
self.depthwise = nn.Conv2d(
in_channels, in_channels, kernel_size,
stride=stride, padding=kernel_size//2,
groups=in_channels # 每个通道独立卷积
)
self.pointwise = nn.Conv2d(
in_channels, out_channels, kernel_size=1
)
def forward(self, x):
return self.pointwise(self.depthwise(x))
# 参数量对比
in_c, out_c = 64, 128
k = 3
# 标准卷积参数
standard_params = in_c * out_c * k * k # 64 * 128 * 9 = 73,728
# 深度可分离卷积参数
ds_params = in_c * k * k + in_c * out_c # 64*9 + 64*128 = 8,768
print(f"标准卷积参数: {standard_params:,}")
print(f"深度可分离卷积参数: {ds_params:,}")
print(f"减少: {(1 - ds_params/standard_params)*100:.1f}% ← 端侧AI的秘诀!")
# ===== 4. 分组卷积 (Grouped Convolution) =====
grouped_conv = nn.Conv2d(64, 128, kernel_size=3, padding=1, groups=8)
"""
分组卷积:将输入通道分成g组,每组独立做卷积
- groups=1 → 标准卷积
- groups=in_channels → 深度卷积(Depthwise)
- 参数量减少到 1/groups
"""
# ===== 5. 空洞卷积 (Dilated/Atrous Convolution) =====
dilated_conv = nn.Conv2d(64, 64, kernel_size=3, padding=2, dilation=2)
"""
空洞卷积:在不增加参数的情况下扩大感受野
- dilation=1 → 标准卷积
- dilation=2 → 3×3的核实际覆盖 5×5 的区域
- 常用于语义分割(DeepLab系列)
"""
# ===== 6. 转置卷积 (Transposed Convolution) =====
transposed_conv = nn.ConvTranspose2d(
64, 32, kernel_size=4, stride=2, padding=1
)
"""
转置卷积:上采样,增大空间尺寸
- stride=2 → 尺寸翻倍
- 常用于语义分割的解码器、GAN的生成器
- 注意:会产生棋盘格效应(checkerboard artifacts)
"""
2.5 卷积变体对比表
python
"""
端侧AI卷积选型指南:
┌─────────────────────┬──────────────┬──────────────┬──────────────────────┐
│ 卷积类型 │ 参数量 │ 计算量 │ 端侧AI使用场景 │
├─────────────────────┼──────────────┼──────────────┼──────────────────────┤
│ 标准卷积 │ 高 │ 高 │ 特征提取主干 │
│ 1×1卷积 │ 低 │ 低 │ 通道变换/压缩 │
│ 深度可分离卷积 │ 极低(~1/9) │ 极低(~1/9) │ MobileNet/YOLO轻量版 │
│ 分组卷积 │ 中(1/g) │ 中(1/g) │ ResNeXt/ShuffleNet │
│ 空洞卷积 │ 同标准 │ 同标准 │ 语义分割扩大感受野 │
│ 转置卷积 │ 同标准 │ 同标准 │ 上采样(已不推荐) │
└─────────────────────┴──────────────┴──────────────┴──────────────────────┘
端侧AI最佳实践:
1. 第一层用标准卷积(保留空间信息)
2. 中间层用深度可分离卷积(省参数)
3. 最后一层用1×1卷积(分类头)
4. 上采样用插值+卷积(代替转置卷积,避免棋盘格)
"""
三、动手实践
3.1 实战:从零实现各种卷积
python
def im2col(x, kernel_size, stride=1, padding=0):
"""
im2col:将卷积转化为矩阵乘法
原理:把每个滑动窗口展开成一列
然后卷积 = kernel @ im2col_result
这是cuDNN底层实际使用的算法!
"""
N, C, H, W = x.shape
kH, kW = kernel_size if isinstance(kernel_size, tuple) else (kernel_size, kernel_size)
out_H = (H + 2 * padding - kH) // stride + 1
out_W = (W + 2 * padding - kW) // stride + 1
# Padding
if padding > 0:
x = F.pad(x, [padding]*4)
# 提取所有滑动窗口
cols = []
for i in range(out_H):
for j in range(out_W):
patch = x[:, :, i*stride:i*stride+kH, j*stride:j*stride+kW]
cols.append(patch.reshape(N, -1))
# cols: (out_H*out_W, N, C*kH*kW)
cols = torch.stack(cols, dim=0)
# 重排: (N*out_H*out_W, C*kH*kW)
cols = cols.permute(1, 0, 2).reshape(N * out_H * out_W, -1)
return cols, (out_H, out_W)
def conv2d_im2col(x, weight, bias=None, stride=1, padding=0):
"""用im2col实现卷积"""
N, C_in, H, W = x.shape
C_out, C_in, kH, kW = weight.shape
# im2col
cols, (out_H, out_W) = im2col(x, (kH, kW), stride, padding)
# 权重展平
weight_col = weight.reshape(C_out, -1) # (C_out, C_in*kH*kW)
# 矩阵乘法
out = weight_col @ cols.T # (C_out, N*out_H*out_W)
out = out.reshape(C_out, N, out_H, out_W).permute(1, 0, 2, 3)
if bias is not None:
out += bias.view(1, -1, 1, 1)
return out
# 验证im2col实现的正确性
x = torch.randn(2, 3, 16, 16)
weight = torch.randn(8, 3, 3, 3)
bias = torch.randn(8)
# PyTorch官方实现
out_official = F.conv2d(x, weight, bias, stride=1, padding=1)
# 我们的im2col实现
out_ours = conv2d_im2col(x, weight, bias, stride=1, padding=1)
print(f"PyTorch输出: {out_official[0, 0, 0, 0]:.6f}")
print(f"im2col输出: {out_ours[0, 0, 0, 0]:.6f}")
print(f"误差: {(out_official - out_ours).abs().max():.2e}")
3.2 实战:卷积核可视化
python
def apply_kernel(image, kernel):
"""
对图像应用一个卷积核并展示结果
用于理解不同卷积核在做什么
"""
# 转为tensor
if isinstance(image, np.ndarray):
image_t = torch.from_numpy(image).float().unsqueeze(0).unsqueeze(0)
else:
image_t = image
kernel_t = torch.from_numpy(kernel).float().unsqueeze(0).unsqueeze(0)
output = F.conv2d(image_t, kernel_t, padding=kernel.shape[0]//2)
return output.squeeze().numpy()
# 常见卷积核
kernels = {
'边缘检测(水平)': np.array([[-1,-2,-1], [0,0,0], [1,2,1]]),
'边缘检测(垂直)': np.array([[-1,0,1], [-2,0,2], [-1,0,1]]),
'锐化': np.array([[0,-1,0], [-1,5,-1], [0,-1,0]]),
'高斯模糊': np.array([[1,2,1], [2,4,2], [1,2,1]]) / 16,
'均值模糊': np.ones((5,5)) / 25,
'浮雕效果': np.array([[-2,-1,0], [-1,1,1], [0,1,2]]),
}
# 创建测试图像
test_img = np.zeros((32, 32), dtype=np.float32)
test_img[8:24, 8:24] = 1.0 # 白色方块
print("不同卷积核的效果:")
for name, kernel in kernels.items():
result = apply_kernel(test_img, kernel)
print(f" {name:<12s}: min={result.min():.2f}, max={result.max():.2f}, "
f"mean={result.mean():.2f}")
# 这说明:卷积核的数值决定了它在检测什么特征
# 网络学习的过程就是找到最优卷积核的过程
3.3 实战:对比不同卷积的计算量
python
def compute_flops_and_params():
"""对比不同卷积类型的参数量和计算量"""
H, W = 32, 32
in_c, out_c = 64, 128
k = 3
configs = {
'标准卷积': {
'params': in_c * out_c * k * k,
'flops': 2 * H * W * in_c * out_c * k * k,
},
'深度可分离卷积': {
'params': in_c * k * k + in_c * out_c,
'flops': 2 * H * W * in_c * k * k + 2 * H * W * in_c * out_c,
},
'1×1卷积': {
'params': in_c * out_c,
'flops': 2 * H * W * in_c * out_c,
},
'分组卷积(g=8)': {
'params': (in_c * out_c * k * k) // 8,
'flops': (2 * H * W * in_c * out_c * k * k) // 8,
},
}
print(f"输入: ({H}×{W}), 通道: {in_c}→{out_c}, 核大小: {k}×{k}\n")
print(f"{'卷积类型':<16s} {'参数量':>10s} {'FLOPs':>12s} {'相对标准':>10s}")
print('-' * 52)
baseline_flops = configs['标准卷积']['flops']
for name, cfg in configs.items():
ratio = cfg['flops'] / baseline_flops * 100
print(f"{name:<16s} {cfg['params']:>10,d} {cfg['flops']:>12,d} {ratio:>9.1f}%")
compute_flops_and_params()
四、常见坑点
坑1:padding='same'不是所有stride都支持
python
# ✅ stride=1, kernel=3 → padding=1 → 输出尺寸不变
conv = nn.Conv2d(3, 64, 3, stride=1, padding=1)
# ❌ stride=2, kernel=3, padding=1 → 输出尺寸不是输入的一半
# H_in=32, stride=2, kernel=3, padding=1
# H_out = (32 + 2*1 - 3) / 2 + 1 = 16.5 → 向下取整 = 16
# 不是严格的32/2=16
# ✅ 用padding公式:P = (S*(H_out-1) - H_in + K) / 2
# 或直接用公式算好再设
坑2:卷积核大小必须是奇数
python
# ✅ 3×3, 5×5, 7×7 → 有明确的中心点,方便padding
# ❌ 2×2, 4×4 → 没有中心点,padding不对称
# 几乎所有现代CNN都用3×3卷积核
坑3:深度可分离卷积不要用在第一层
python
# ❌ 第一层用深度可分离:3个通道独立卷积,学不到跨通道特征
# first_conv = DepthwiseSeparableConv(3, 32, 3)
# ✅ 第一层用标准卷积,后面再用深度可分离
# first_conv = nn.Conv2d(3, 32, 3, stride=2, padding=1)
# 这是MobileNet的设计原则
坑4:group=in_channels时的输出通道数
python
# 当groups=in_channels时,每个通道独立卷积
# 此时 out_channels 必须是 in_channels 的倍数
# 且 out_channels = in_channels 时就是 Depthwise Conv
# ✅ 标准depthwise
nn.Conv2d(64, 64, 3, padding=1, groups=64) # 输出=输入通道数
# ❌ 报错
# nn.Conv2d(64, 128, 3, padding=1, groups=64)
# RuntimeError: out_channels must be divisible by groups
五、今日作业
- 手写卷积 :实现
conv2d_naive,处理多通道输入多通道输出 - im2col:理解并跑通im2col实现,验证和PyTorch官方结果一致
- 卷积变体:对比标准卷积和深度可分离卷积的参数量/计算量(至少3组不同配置)
- 打卡 :评论区发你的卷积对比结果,格式:"Day 21/100 打卡:卷积原理已透彻!"
今日小结
今天你学会了:
✅ 卷积的数学本质:滑动窗口逐元素相乘再求和
✅ 卷积参数:kernel_size/stride/padding/dilation
✅ 输出尺寸公式和感受野计算
✅ 6种卷积变体:标准/1×1/深度可分离/分组/空洞/转置
✅ im2col:将卷积转化为矩阵乘法
✅ 卷积核可视化:边缘/锐化/模糊/浮雕
✅ 端侧AI卷积选型指南
✅ 4个经典坑点
明日预告
Day 22:经典CNN架构 --- LeNet到VGG
LeNet-5、AlexNet、VGGNet、GoogLeNet/Inception --- 从历史中理解CNN的进化
🔥 关注我,每天解锁一个端侧AI技能!
微信公众号:xxx | 小红书:xxx | CSDN:xxx
评论区打卡,一起坚持100天!
附:小红书图文版
封面标题建议:卷积到底在"卷"什么?一文讲透!🔍
P1 --- 封面
标题:深入理解卷积
副标题:原理 / 变体 / 端侧AI选型
关键词:卷积 / 深度可分离 / im2col
P2 --- 卷积 = 滑动窗口
核在图上滑动,每步做逐元素相乘再求和
参数共享:一个核检测整张图的同一种特征
局部连接:每个神经元只看一小块
P3 --- 四种参数
kernel_size:核多大(3×3最常用)
stride:步长(2=尺寸减半)
padding:填充(1=尺寸不变)
dilation:空洞(扩大感受野)
P4 --- 深度可分离卷积
标准卷积分解:Depthwise + Pointwise
参数量减少到 1/9
MobileNet/YOLO轻量版的秘诀
端侧AI必用!
P5 --- 卷积变体全家福
1×1卷积:通道变换
分组卷积:并行计算
空洞卷积:扩大感受野
转置卷积:上采样(已过时)
P6 --- 今日作业
手写卷积 + im2col + 卷积变体对比
评论区打卡 Day 21/100
标签:#卷积神经网络 #CNN #深度可分离卷积 #端侧AI
CSDN发布提示:CSDN版本建议在卷积原理部分放一张动图演示滑动窗口过程,在卷积变体部分用对比表格,在im2col部分画一张矩阵变换示意图。