📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏

上一篇:python神经网络编程入门(十二)——CNN池化层(Pooling)—— 为什么要“压缩“图像?
下一篇:​​​​​​​python神经网络编程入门(十三)——CNN纯 NumPy 实现LeNet-5 反向传播串联与 MNIST 完整训练实战(下)

完整目录 & 更新记录:
《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》

引言

在前两篇文章中,我们分别实现了卷积层(Conv2D)的前向与反向传播,以及池化层(Pooling)的降维机制。至此,卷积神经网络(CNN)所需的三大基础模块——卷积层、池化层、全连接层——均已准备就绪。

本文的核心任务是将这些模块按照经典架构 LeNet-5 进行组装,构建一个完整的、可运行的卷积神经网络模型。我们将严格遵循以下步骤:

  1. 明确拓扑结构:详细解析 LeNet-5 的网络架构与张量尺寸变化,用公式逐层验证输出维度。

  2. 模块化组装:基于先前定义的类,在 LeNet5 中实例化各层,并实现标准化的前向传播流程。

  3. 数据预处理:加载 MNIST 数据集,并通过零填充将 28×28 输入扩展至 32×32,以匹配网络输入要求。

  4. 前向传播验证:取一个 Mini-Batch 执行推理,计算随机初始化状态下的交叉熵损失,确认输出形状与理论一致,并建立训练基线。

  5. 性能增益分析:从理论层面对比 CNN 与全连接网络,阐明卷积架构在参数效率与特征提取上的优势。


一、术语与符号约定

为避免歧义,先统一本文使用的符号体系:

符号表示 专业含义
H×W×C 张量的空间维度与通道数。H 为高度,W 为宽度,C 为通道数(灰度图 C=1,RGB 图 C=3)。
N@H×W 表示该层输出的特征图集合。@左侧的 N 代表输出通道数(即卷积核个数),右侧为每个特征图的空间分辨率。
感受野 卷积层中,输出特征图上的一个像素对应输入图像上的区域大小(如 5×5 卷积核的感受野即为 5×5)。
步长(Stride) 卷积核或池化窗口在输入张量上滑动的像素间隔数。
零填充(Zero Padding) 在输入张量外围填充值为 0 的行或列,用于控制输出尺寸的缩减速度,并保护边缘信息。
展平(Flatten) 将多维张量(如C×H×W)转换为一维向量的操作。这是全连接层的前置必要条件。

二、LeNet-5 网络架构解析

LeNet-5 由 Yann LeCun 于 1998 年提出,是卷积神经网络在字符识别领域的里程碑式工作。其核心结构包含 2 个卷积层、2 个池化层和 3 个全连接层(含输出层)。

2.1 完整拓扑结构

数据流经网络的张量形状变化路径如下:

Input (32×32×1)
  → Conv1 (6@28×28)
  → Pool1 (6@14×14)
  → Conv2 (16@10×10)
  → Pool2 (16@5×5)
  → Flatten (400)
  → FC1 (120)
  → FC2 (84)
  → Output (10)

2.2 尺寸变化推导(含计算公式)

对于卷积或池化操作,输出空间尺寸由下式决定:

\text{out\_size} = \left\lfloor \frac{\text{in\_size} + 2 \times \text{padding} - \text{kernel\_size}}{\text{stride}} \right\rfloor + 1

我们逐层验证:

层级 操作参数 输入形状 输出形状 计算过程
输入层 32×32×1 MNIST 原生为 28×28,通过零填充扩展到 32×32。
Conv1 6 个 5×5 核,步长 1,无填充 32×32×1 6@28×28 (32−5)/1+1=28(32−5)/1+1=28,通道数变为 6。
Pool1 2×2 窗口,步长 2(最大池化) 6@28×28 6@14×14 28/2=1428/2=14(步长与窗口相等时,尺寸直接减半)。
Conv2 16 个 5×5 核,步长 1,无填充 6@14×14 16@10×10 (14−5)/1+1=10(14−5)/1+1=10,通道数变为 16。
Pool2 2×2 窗口,步长 2(最大池化) 16@10×10 16@5×5 10/2=510/2=5。
Flatten 张量重塑 16@5×5 400 16×5×5=40016×5×5=400。
FC1 全连接 + Sigmoid 400 120 线性变换 y=W1x+b1y=W1​x+b1​,输出维度 120。
FC2 全连接 + Sigmoid 120 84 线性变换,输出维度 84。
Output 全连接 + Softmax 84 10 线性变换后经 Softmax 归一化为概率分布。

2.3 参数量统计(可训练参数)

CNN 的核心优势之一在于权值共享(一个卷积核共享一套参数扫遍全图),极大减少了参数量。下表详细统计:

层级 参数量计算 数量
Conv1 (5×5×1+1)×6(5×5×1+1)×6 156
Pool1 无可训练参数 0
Conv2 (5×5×6+1)×16(5×5×6+1)×16(输入通道为 6) 2,416
Pool2 无可训练参数 0
FC1 400×120+120400×120+120 48,120
FC2 120×84+84120×84+84 10,164
Output 84×10+1084×10+10 850
合计 约 6.17 万

对比基准:若采用全连接网络直接处理 784(28×28)像素输入,仅第一层隐藏层(256 个神经元)就包含 784×256+256≈20784×256+256≈20 万个参数。LeNet-5 的参数效率显著更高。

三、模块化代码实现

我们直接复用此前已实现的 Conv2DMaxPooling 和 FullyConnected 类。为了保持文章可读性,正文仅展示类定义的骨架和核心逻辑,完整实现请参见文末附录。

3.1 基础层定义(关键片段)

卷积层 Conv2D:核心操作是在输入上滑动卷积核,计算内积并加上偏置。为便于理解,我们采用显式四重循环实现(实际工程可用 im2col 加速)。

class Conv2D:
    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
        # 初始化权重(He 初始化)和偏置
        self.weights = np.random.randn(...) * scale
        self.bias = np.zeros(out_channels)

    def forward(self, x):
        # 对输入进行零填充
        # 计算输出尺寸
        # 四重循环:batch, out_channels, out_h, out_w
        #   提取窗口 window = x_pad[b, :, i*s:i*s+k, j*s:j*s+k]
        #   out[b, oc, i, j] = np.sum(window * self.weights[oc]) + self.bias[oc]
        return out

池化层 MaxPooling:采用固定窗口(如 2×2)进行下采样,取局部最大值。

class MaxPooling:
    def __init__(self, pool_size=2, stride=2):
        self.pool_size = pool_size
        self.stride = stride

    def forward(self, x):
        # 计算输出尺寸
        # 三重循环:batch, channels, 每个窗口位置
        #   取窗口最大值
        return out

全连接层 FullyConnected:执行矩阵乘法并加偏置。

class FullyConnected:
    def __init__(self, in_size, out_size):
        self.weights = np.random.randn(in_size, out_size) * scale
        self.bias = np.zeros(out_size)

    def forward(self, x):
        return np.dot(x, self.weights) + self.bias

激活函数:采用 Sigmoid 作为中间层激活,Softmax 用于输出层概率归一化。

def sigmoid(x):
    return 1 / (1 + np.exp(-np.clip(x, -500, 500)))

def softmax(x):
    exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
    return exp_x / np.sum(exp_x, axis=1, keepdims=True)

3.2 组装 LeNet-5 模型类

在 LeNet5 的 __init__ 中依次实例化各层,forward 方法按拓扑顺序串联。注意在第二个池化层后执行 reshape 操作将多维特征图展平为一维向量,作为全连接层的输入。

class LeNet5:
    def __init__(self):
        self.conv1 = Conv2D(1, 6, kernel_size=5, stride=1, padding=0)
        self.pool1 = MaxPooling(2, 2)
        self.conv2 = Conv2D(6, 16, kernel_size=5, stride=1, padding=0)
        self.pool2 = MaxPooling(2, 2)
        self.fc1 = FullyConnected(16*5*5, 120)
        self.fc2 = FullyConnected(120, 84)
        self.fc3 = FullyConnected(84, 10)

    def forward(self, x):
        x = self.conv1.forward(x)
        x = sigmoid(x)
        x = self.pool1.forward(x)

        x = self.conv2.forward(x)
        x = sigmoid(x)
        x = self.pool2.forward(x)

        # 展平:从 (batch, 16, 5, 5) -> (batch, 400)
        batch_size = x.shape[0]
        x = x.reshape(batch_size, -1)

        x = self.fc1.forward(x)
        x = sigmoid(x)
        x = self.fc2.forward(x)
        x = sigmoid(x)
        x = self.fc3.forward(x)
        x = softmax(x)
        return x

四、数据加载与预处理

为确保数据与网络输入尺寸(32×32)匹配,我们需要对 MNIST 原生 28×28 图像进行零填充:上下左右各补 2 个像素,使总尺寸变为 32×32。

def load_mnist_csv(filename, limit=None):
    # 读取 CSV,归一化像素值,重塑为 (N, 1, 28, 28)
    return images, labels

def pad_to_32x32(images):
    return np.pad(images, ((0,0), (0,0), (2,2), (2,2)), mode='constant')

train_images, train_labels = load_mnist_csv('mnist_train.csv', limit=1000)
test_images, test_labels = load_mnist_csv('mnist_test.csv', limit=200)
train_images = pad_to_32x32(train_images)
test_images = pad_to_32x32(test_images)

执行后,训练集形状为 (1000, 1, 32, 32),测试集形状为 (200, 1, 32, 32)

五、前向传播验证与损失基线

在训练之前,必须验证前向传播的数据流动是否通畅,且输出尺寸是否符合设计预期。我们取一个 Mini-Batch(32 张图像)执行推理,并计算随机初始化状态下的交叉熵损失,作为后续训练的基线参考。

model = LeNet5()
batch_x = train_images[:32]
batch_y = train_labels[:32]
pred_probs = model.forward(batch_x)

print(pred_probs.shape)  # 应为 (32, 10)

def cross_entropy_loss(pred_probs, true_labels):
    N = pred_probs.shape[0]
    correct_probs = pred_probs[np.arange(N), true_labels]
    return -np.mean(np.log(correct_probs + 1e-8))

initial_loss = cross_entropy_loss(pred_probs, batch_y)
print(f"初始损失: {initial_loss:.4f}")

预期输出

  • pred_probs.shape 为 (32, 10),每行是一个概率分布,和为 1。

  • initial_loss 应接近 −ln⁡(0.1)≈2.3026−ln(0.1)≈2.3026。若偏差过大,说明前向逻辑存在错误(如 Softmax 未正确归一化或数据未填充)。

这一步骤不仅验证了网络构建的正确性,还为我们后续训练提供了一个 损失基线——训练过程中,损失应持续低于该值。
这里完整代码实际输出只有如下损失值,但可以通过优化达到预期损失值。
优化前:


优化前完整代码:

import numpy as np
import pandas as pd

# ---------- 1. 基础层定义 ----------
class Conv2D:
    def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0):
        self.in_channels = in_channels
        self.out_channels = out_channels
        self.kernel_size = kernel_size
        self.stride = stride
        self.padding = padding
        scale = np.sqrt(2.0 / (in_channels * kernel_size * kernel_size))
        self.weights = np.random.randn(out_channels, in_channels, kernel_size, kernel_size) * scale
        self.bias = np.zeros(out_channels)

    def forward(self, x):
        batch, _, h, w = x.shape
        k, s, p = self.kernel_size, self.stride, self.padding
        x_pad = np.pad(x, ((0, 0), (0, 0), (p, p), (p, p)), mode='constant')
        out_h = (h + 2 * p - k) // s + 1
        out_w = (w + 2 * p - k) // s + 1
        out = np.zeros((batch, self.out_channels, out_h, out_w))
        for b in range(batch):
            for oc in range(self.out_channels):
                for i in range(out_h):
                    for j in range(out_w):
                        window = x_pad[b, :, i*s:i*s+k, j*s:j*s+k]
                        out[b, oc, i, j] = np.sum(window * self.weights[oc]) + self.bias[oc]
        return out

class MaxPooling:
    def __init__(self, pool_size=2, stride=2):
        self.pool_size = pool_size
        self.stride = stride

    def forward(self, x):
        batch, channels, h, w = x.shape
        p, s = self.pool_size, self.stride
        out_h = (h - p) // s + 1
        out_w = (w - p) // s + 1
        out = np.zeros((batch, channels, out_h, out_w))
        for b in range(batch):
            for c in range(channels):
                for i in range(out_h):
                    for j in range(out_w):
                        window = x[b, c, i*s:i*s+p, j*s:j*s+p]
                        out[b, c, i, j] = np.max(window)
        return out

class FullyConnected:
    def __init__(self, in_size, out_size):
        scale = np.sqrt(2.0 / in_size)
        self.weights = np.random.randn(in_size, out_size) * scale
        self.bias = np.zeros(out_size)

    def forward(self, x):
        return np.dot(x, self.weights) + self.bias

def sigmoid(x):
    return 1 / (1 + np.exp(-np.clip(x, -500, 500)))

def softmax(x):
    exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
    return exp_x / np.sum(exp_x, axis=1, keepdims=True)

# ---------- 2. LeNet-5 模型 ----------
class LeNet5:
    def __init__(self):
        self.conv1 = Conv2D(1, 6, 5, stride=1, padding=0)
        self.pool1 = MaxPooling(2, 2)
        self.conv2 = Conv2D(6, 16, 5, stride=1, padding=0)
        self.pool2 = MaxPooling(2, 2)
        self.fc1 = FullyConnected(16 * 5 * 5, 120)
        self.fc2 = FullyConnected(120, 84)
        self.fc3 = FullyConnected(84, 10)

    def forward(self, x):
        x = self.conv1.forward(x)
        x = sigmoid(x)
        x = self.pool1.forward(x)
        x = self.conv2.forward(x)
        x = sigmoid(x)
        x = self.pool2.forward(x)
        batch_size = x.shape[0]
        x = x.reshape(batch_size, -1)
        x = self.fc1.forward(x)
        x = sigmoid(x)
        x = self.fc2.forward(x)
        x = sigmoid(x)
        x = self.fc3.forward(x)
        x = softmax(x)
        return x

# ---------- 3. 数据加载(使用本地 CSV) ----------
def load_mnist_csv(filename, limit=None):
    data = pd.read_csv(filename).values
    if limit:
        data = data[:limit]
    labels = data[:, 0].astype(np.int32)
    images = data[:, 1:].astype(np.float32)
    images = images / 255.0
    images = images.reshape(-1, 1, 28, 28)
    return images, labels

def pad_to_32x32(images):
    return np.pad(images, ((0, 0), (0, 0), (2, 2), (2, 2)), mode='constant')

# ---------- 4. 主程序 ----------
if __name__ == "__main__":
    np.random.seed(42)  # 固定随机种子,便于复现

    # 加载数据(只取前 1000 训练和 200 测试,加快验证速度)
    print("正在加载 mnist_train.csv ...")
    train_images, train_labels = load_mnist_csv('mnist_train.csv', limit=1000)
    print("正在加载 mnist_test.csv ...")
    test_images, test_labels = load_mnist_csv('mnist_test.csv', limit=200)

    # 填充到 32x32
    train_images = pad_to_32x32(train_images)
    test_images = pad_to_32x32(test_images)

    print(f"训练集形状: {train_images.shape}")   # (1000, 1, 32, 32)
    print(f"测试集形状: {test_images.shape}")     # (200, 1, 32, 32)

    # 实例化模型
    model = LeNet5()

    # 取前 32 个作为 batch
    batch_x = train_images[:32]
    batch_y = train_labels[:32]

    # 前向传播
    pred_probs = model.forward(batch_x)
    print(f"输出张量形状: {pred_probs.shape}")  # (32, 10)

    # 显示第一个样本的概率分布(前 5 个值)
    print(f"样本 0 概率分布 (前5个): {pred_probs[0][:5]}...")

    # 交叉熵损失
    def cross_entropy_loss(pred_probs, true_labels):
        N = pred_probs.shape[0]
        correct_probs = pred_probs[np.arange(N), true_labels]
        return -np.mean(np.log(correct_probs + 1e-8))

    loss = cross_entropy_loss(pred_probs, batch_y)
    print(f"随机初始化状态下的损失值: {loss:.4f}")

    # 验证结论
    if 2.20 < loss < 2.40:
        print("✅ 验证通过!损失值符合随机猜测基线 (≈2.3026)。")
    else:
        print("⚠️ 损失值异常,请检查代码或数据。")

优化后:

优化后操作(大家可以自己试着改代码):

🛠️ 优化 1:权重初始化策略(核心优化)

这是影响最大的改动。

  • 初始代码(He 初始化)scale = sqrt(2 / fan_in)。这是专门为 ReLU 激活函数设计的。由于 Sigmoid 对输入范围极其敏感,方差过大的权重会导致卷积输出值(如 >5>5 或 <−5<−5)进入 Sigmoid 的饱和区(梯度几乎为 0),使得激活值极端偏向 0 或 1,破坏了 Softmax 的均匀分布。

  • 最终代码(极小标准差初始化)np.random.randn(...) * 0.01。强制将权重视为极小的随机数,确保卷积输出的加权和在零附近(约 [−0.5,0.5][−0.5,0.5]),让 Sigmoid 工作在其线性敏感区(导数最大区域),从而保证激活值均匀分布在 0.5 附近。


🛠️ 优化 2:输入数据零均值化(Zero-centering)

  • 初始代码images = images / 255.0,将像素映射到 [0, 1](全为正数)。

  • 最终代码images = images / 255.0 - 0.5,将像素映射到 [-0.5, 0.5](以 0 为中心)。

  • 原因:神经网络(尤其是带 Sigmoid 的全连接层)偏爱零均值的输入数据。如果输入全为正数,经过线性变换(Wx + b)后,输出的正负号将严重依赖于 W 的正负,容易产生“Zigzag”锯齿状梯度。将数据拉到 0 两侧,使得后续的梯度更新更加平稳。


🛠️ 优化 3:固定随机种子(保证可复现性)

  • 操作np.random.seed(42)

  • 意义:深度学习实验讲究“可复现”。如果不固定种子,每次运行损失会在 2.28 ~ 2.35 之间随机浮动。我们固定种子后,您的读者运行您的代码,会得到完全一致的 2.3170,这体现了代码的严谨性。


六、CNN 相对于全连接网络的性能增益分析

在进入训练之前,有必要从理论层面明确 LeNet-5 相对传统多层感知机(MLP)的优势根源:

维度 多层感知机(MLP) 卷积神经网络(LeNet-5)
连接方式 全连接:每个输出神经元与所有输入像素相连。 局部连接:每个神经元仅连接输入图像的一个局部区域(感受野)。
参数共享 无。每个连接都有独立权重。 。同一卷积核的参数在整个输入空间上滑动复用。
空间结构 必须将图像展平为一维向量,丢失了像素间的空间邻接关系。 保持空间结构,利用二维拓扑信息提取局部特征。
平移等变性 不具备。输入平移会导致所有激活值发生剧烈变化。 卷积操作具备平移等变性(平移不变性由池化层进一步强化)。
参数量(同规模) 极大(易过拟合)。 较小(泛化能力更强)。
MNIST 基准准确率 约 92% ~ 95% 约 98% ~ 99%

这些优势使得 CNN 在处理图像类数据时,不仅在参数效率上大幅领先,更能提取具有语义意义的高阶特征。

七、总结与后续工作规划

本文完成了以下关键工作:

  1. 架构解析:详细推导了 LeNet-5 各层输入输出张量尺寸,并通过公式验证了每一维度的变化逻辑。

  2. 模块组装:基于先前实现的卷积、池化、全连接类,构建了完整的 LeNet5 模型,并实现了标准化的前向传播流程。

  3. 数据对齐:对 MNIST 数据进行了必要的零填充预处理,使其符合网络输入要求。

  4. 前向验证:通过实际计算随机初始化的损失值,确认了数据流通路径的正确性和概率分布的特性。


调优心得
在实现 LeNet-5 前向传播时,我遇到了随机初始化导致的损失偏离理论值的现象。经过排查,发现权重初始化策略激活函数(Sigmoid)的匹配至关重要。

由于 Sigmoid 具有饱和性,若使用为 ReLU 设计的 He 初始化,神经元极易进入饱和区导致输出坍缩。最终我采用 标准差为 0.01 的高斯初始化,并对输入图像进行 零均值化(减去 0.5),使得各层激活前的输入落在 Sigmoid 的非饱和区。修正后,网络在随机状态下的输出概率分布均匀,损失稳定在理论值 −ln⁡(0.1)≈2.3026−ln(0.1)≈2.3026 附近。

这一过程验证了:优秀的初始化是梯度传播的起点,数据预处理应与激活函数特性协同设计。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐