Day 21:卷积神经网络CNN — 深入理解卷积

今日目标 :掌握卷积的数学原理、从零实现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

五、今日作业

  1. 手写卷积 :实现 conv2d_naive,处理多通道输入多通道输出
  2. im2col:理解并跑通im2col实现,验证和PyTorch官方结果一致
  3. 卷积变体:对比标准卷积和深度可分离卷积的参数量/计算量(至少3组不同配置)
  4. 打卡 :评论区发你的卷积对比结果,格式:"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部分画一张矩阵变换示意图。

相关推荐
DevNo1 小时前
学生党课堂复习利器:三款音视频转文字工具实测对比
人工智能
谢白羽1 小时前
Mooncake在LLM的kv cache offload
人工智能·llm·论文·agent·mooncake
IT_陈寒1 小时前
Vue的v-for为啥把我的渲染顺序搞乱套了?
前端·人工智能·后端
Yangs_noerr1 小时前
Hermes Agent 打造私人 AI 助手
人工智能
慕容引刀1 小时前
或许你真的需要这个Git神器:让团队提交文案终于对齐了
人工智能·git·vscode·自然语言处理·github
Jaeger_1 小时前
MCP 配置到底要重复配几次?一次配置,Claude/Cursor/Codex 全通用
人工智能
天涯明月19931 小时前
ray深度研究报告
大数据·人工智能·分布式·ray
Python私教1 小时前
四个入口,一条流水线:如意智影的多入口架构取舍
人工智能·python·架构
软件聚导航2 小时前
软件聚导航-前端工程师的“小家庭”,工具与生活并存
人工智能·小程序·学习方法·图片压缩