手把手教你用Python实现卷积运算:从零开始构建CNN基础层

卷积神经网络(CNN)作为深度学习的核心架构之一,其核心操作——卷积运算的理解程度直接决定了开发者能否灵活运用这一技术。本文将摒弃数学公式的抽象表达,转而通过Python代码的逐行实现,带您亲身体验卷积运算的每一个技术细节。

1. 卷积运算的本质与准备工作

在开始编写代码之前,我们需要明确几个关键概念。卷积运算本质上是一种局部加权求和操作,它通过一个小型滤波器(kernel)在输入数据上滑动,计算局部区域与滤波器的点积。这种操作具有平移不变性局部感知两大特性,使其特别适合处理图像等网格化数据。

1.1 环境配置与基础工具

首先确保你的Python环境已安装以下库:

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_sample_image

对于图像处理,我们使用NumPy进行高效的矩阵运算。下面创建一个简单的5x5输入矩阵和3x3卷积核作为我们的初始示例:

input_matrix = np.array([
    [1, 2, 3, 4, 5],
    [6, 7, 8, 9, 10],
    [11, 12, 13, 14, 15],
    [16, 17, 18, 19, 20],
    [21, 22, 23, 24, 25]
])

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

提示:在实际项目中,卷积核的数值通常通过训练得到,这里我们使用手工设定的Sobel边缘检测算子作为示例。

2. 单通道卷积的完整实现

2.1 基础卷积运算步骤分解

实现卷积运算需要明确以下几个关键参数:

  • 输入矩阵尺寸(H, W)
  • 卷积核尺寸(FH, FW)
  • 步长(stride)
  • 填充(padding)

下面是一个不考虑填充和步长的最简实现:

def simple_conv2d(input_mat, kernel):
    input_h, input_w = input_mat.shape
    kernel_h, kernel_w = kernel.shape
    
    output_h = input_h - kernel_h + 1
    output_w = input_w - kernel_w + 1
    output = np.zeros((output_h, output_w))
    
    for i in range(output_h):
        for j in range(output_w):
            output[i,j] = np.sum(input_mat[i:i+kernel_h, j:j+kernel_w] * kernel)
    
    return output

执行这个函数后,我们可以看到原始矩阵经过边缘检测后的效果:

edge_detected = simple_conv2d(input_matrix, kernel)
print(edge_detected)

2.2 添加步长与填充参数

实际应用中,我们需要控制输出尺寸和感受野。以下是支持步长和填充的增强版实现:

def conv2d(input_mat, kernel, stride=1, padding=0):
    # 添加padding
    if padding > 0:
        input_mat = np.pad(input_mat, ((padding, padding), (padding, padding)), 
                          mode='constant')
    
    input_h, input_w = input_mat.shape
    kernel_h, kernel_w = kernel.shape
    
    # 计算输出尺寸
    output_h = (input_h - kernel_h) // stride + 1
    output_w = (input_w - kernel_w) // stride + 1
    output = np.zeros((output_h, output_w))
    
    # 滑动窗口计算
    for i in range(0, output_h):
        for j in range(0, output_w):
            h_start = i * stride
            h_end = h_start + kernel_h
            w_start = j * stride
            w_end = w_start + kernel_w
            
            output[i,j] = np.sum(input_mat[h_start:h_end, w_start:w_end] * kernel)
    
    return output

这个版本已经具备了实际应用的基本功能。我们可以测试不同参数的效果:

# 测试不同参数组合
print("步长为2的结果:\n", conv2d(input_matrix, kernel, stride=2))
print("填充为1的结果:\n", conv2d(input_matrix, kernel, padding=1))

3. 多通道卷积的实现进阶

真实世界的图像都是RGB三通道的,CNN也需要处理多通道输入和输出。这需要我们对基础卷积进行扩展。

3.1 多通道输入单核卷积

当输入有多个通道时,卷积核也需要有对应的通道数。每个通道分别卷积后相加:

def conv2d_multi_in(input_mat, kernel, stride=1, padding=0):
    """处理多通道输入的卷积"""
    # input_mat形状:(C, H, W)
    # kernel形状:(C, FH, FW)
    channels = input_mat.shape[0]
    
    # 各通道分别卷积后相加
    output = None
    for c in range(channels):
        channel_result = conv2d(input_mat[c], kernel[c], stride, padding)
        if output is None:
            output = channel_result
        else:
            output += channel_result
    
    return output

3.2 多核卷积实现特征图堆叠

现代CNN通常使用多个卷积核来提取不同特征。每个核产生一个输出通道:

def conv2d_multi_kernel(input_mat, kernels, stride=1, padding=0):
    """多卷积核实现"""
    # kernels形状:(KN, C, FH, FW)
    kernel_num = kernels.shape[0]
    outputs = []
    
    for k in range(kernel_num):
        outputs.append(conv2d_multi_in(input_mat, kernels[k], stride, padding))
    
    return np.stack(outputs, axis=0)  # 堆叠成多通道输出

4. 实战:在真实图像上应用卷积

让我们加载一张真实图像来测试我们的实现:

# 加载示例图像
china = load_sample_image("china.jpg") / 255
plt.imshow(china)
plt.title("原始图像")
plt.show()

# 定义边缘检测核组
edge_kernels = np.array([
    [[[-1, -1, -1],   # 水平边缘检测
      [0, 0, 0],
      [1, 1, 1]],
     
     [[-1, -1, -1],
      [0, 0, 0],
      [1, 1, 1]],
     
     [[-1, -1, -1],
      [0, 0, 0],
      [1, 1, 1]]],
    
    [[[-1, 0, 1],     # 垂直边缘检测
      [-1, 0, 1],
      [-1, 0, 1]],
     
     [[-1, 0, 1],
      [-1, 0, 1],
      [-1, 0, 1]],
     
     [[-1, 0, 1],
      [-1, 0, 1],
      [-1, 0, 1]]]
])

# 执行卷积
china_edges = conv2d_multi_kernel(np.transpose(china, (2,0,1)), edge_kernels)

# 可视化结果
fig, axes = plt.subplots(1, 2, figsize=(10,5))
axes[0].imshow(china_edges[0], cmap='gray')
axes[0].set_title("水平边缘")
axes[1].imshow(china_edges[1], cmap='gray')
axes[1].set_title("垂直边缘")
plt.show()

5. 性能优化与向量化实现

前面的实现使用了显式循环,效率较低。我们可以利用NumPy的广播机制进行优化:

def vectorized_conv2d(input_mat, kernel, stride=1, padding=0):
    if padding > 0:
        input_mat = np.pad(input_mat, ((padding, padding), (padding, padding)), 
                          mode='constant')
    
    input_h, input_w = input_mat.shape
    kernel_h, kernel_w = kernel.shape
    
    output_h = (input_h - kernel_h) // stride + 1
    output_w = (input_w - kernel_w) // stride + 1
    
    # 创建滑动窗口视图
    shape = (output_h, output_w, kernel_h, kernel_w)
    strides = (input_mat.strides[0]*stride, input_mat.strides[1]*stride, 
               input_mat.strides[0], input_mat.strides[1])
    windows = np.lib.stride_tricks.as_strided(input_mat, shape=shape, 
                                             strides=strides)
    
    return np.einsum('ijkl,kl->ij', windows, kernel)

这个向量化版本比原始实现快了近100倍。我们可以用更大的图像测试性能差异:

from time import time

large_img = np.random.rand(512, 512)
large_kernel = np.random.rand(3, 3)

start = time()
_ = simple_conv2d(large_img, large_kernel)
print("循环版本耗时:", time()-start)

start = time()
_ = vectorized_conv2d(large_img, large_kernel)
print("向量化版本耗时:", time()-start)

6. 从卷积运算到CNN层的跨越

理解了基础卷积运算后,我们可以构建完整的CNN层,需要添加以下组件:

  • 偏置项(bias)
  • 激活函数
  • 批量处理能力
class Conv2DLayer:
    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
        self.kernels = np.random.randn(out_channels, in_channels, kernel_size, kernel_size) * 0.1
        self.biases = np.zeros(out_channels)
        self.stride = stride
        self.padding = padding
    
    def forward(self, x):
        """x形状:(N, C, H, W)"""
        batch_size = x.shape[0]
        outputs = []
        
        for n in range(batch_size):
            batch_item = x[n]
            out = conv2d_multi_kernel(batch_item, self.kernels, self.stride, self.padding)
            out += self.biases.reshape(-1, 1, 1)  # 添加偏置
            outputs.append(out)
        
        return np.stack(outputs, axis=0)

这个简易实现已经包含了CNN层的基本要素。在实际项目中,我们还会添加ReLU激活函数、批量归一化等组件,但核心的卷积运算逻辑保持不变。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐