python神经网络编程入门(十三)——CNN纯 NumPy 实现 LeNet-5 全流程拆解与 MNIST 前向验证(上)
📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏
上一篇:python神经网络编程入门(十二)——CNN池化层(Pooling)—— 为什么要“压缩“图像?
下一篇:python神经网络编程入门(十三)——CNN纯 NumPy 实现LeNet-5 反向传播串联与 MNIST 完整训练实战(下)完整目录 & 更新记录:
《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》
引言
在前两篇文章中,我们分别实现了卷积层(Conv2D)的前向与反向传播,以及池化层(Pooling)的降维机制。至此,卷积神经网络(CNN)所需的三大基础模块——卷积层、池化层、全连接层——均已准备就绪。
本文的核心任务是将这些模块按照经典架构 LeNet-5 进行组装,构建一个完整的、可运行的卷积神经网络模型。我们将严格遵循以下步骤:
-
明确拓扑结构:详细解析 LeNet-5 的网络架构与张量尺寸变化,用公式逐层验证输出维度。
-
模块化组装:基于先前定义的类,在
LeNet5中实例化各层,并实现标准化的前向传播流程。 -
数据预处理:加载 MNIST 数据集,并通过零填充将 28×28 输入扩展至 32×32,以匹配网络输入要求。
-
前向传播验证:取一个 Mini-Batch 执行推理,计算随机初始化状态下的交叉熵损失,确认输出形状与理论一致,并建立训练基线。
-
性能增益分析:从理论层面对比 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 尺寸变化推导(含计算公式)
对于卷积或池化操作,输出空间尺寸由下式决定:
我们逐层验证:
| 层级 | 操作参数 | 输入形状 | 输出形状 | 计算过程 |
|---|---|---|---|---|
| 输入层 | — | — | 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=W1x+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 的参数效率显著更高。
三、模块化代码实现
我们直接复用此前已实现的 Conv2D、MaxPooling 和 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 在处理图像类数据时,不仅在参数效率上大幅领先,更能提取具有语义意义的高阶特征。
七、总结与后续工作规划
本文完成了以下关键工作:
-
架构解析:详细推导了 LeNet-5 各层输入输出张量尺寸,并通过公式验证了每一维度的变化逻辑。
-
模块组装:基于先前实现的卷积、池化、全连接类,构建了完整的
LeNet5模型,并实现了标准化的前向传播流程。 -
数据对齐:对 MNIST 数据进行了必要的零填充预处理,使其符合网络输入要求。
-
前向验证:通过实际计算随机初始化的损失值,确认了数据流通路径的正确性和概率分布的特性。
调优心得:
在实现 LeNet-5 前向传播时,我遇到了随机初始化导致的损失偏离理论值的现象。经过排查,发现权重初始化策略与激活函数(Sigmoid)的匹配至关重要。
由于 Sigmoid 具有饱和性,若使用为 ReLU 设计的 He 初始化,神经元极易进入饱和区导致输出坍缩。最终我采用 标准差为 0.01 的高斯初始化,并对输入图像进行 零均值化(减去 0.5),使得各层激活前的输入落在 Sigmoid 的非饱和区。修正后,网络在随机状态下的输出概率分布均匀,损失稳定在理论值 −ln(0.1)≈2.3026−ln(0.1)≈2.3026 附近。
这一过程验证了:优秀的初始化是梯度传播的起点,数据预处理应与激活函数特性协同设计。
更多推荐



所有评论(0)