尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

python神经网络编程入门(十三)——CNN纯 NumPy 实现 LeNet-5 全流程拆解与 MNIST 前向验证(上)

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 为通道数灰度图 C1RGB 图 C3。NH×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 (628×28) → Pool1 (614×14) → Conv2 (1610×10) → Pool2 (165×5) → Flatten (400) → FC1 (120) → FC2 (84) → Output (10)2.2 尺寸变化推导含计算公式对于卷积或池化操作输出空间尺寸由下式决定我们逐层验证层级操作参数输入形状输出形状计算过程输入层——32×32×1MNIST 原生为 28×28通过零填充扩展到 32×32。Conv16 个 5×5 核步长 1无填充32×32×1628×28(32−5)/1128(32−5)/1128通道数变为 6。Pool12×2 窗口步长 2最大池化628×28614×1428/21428/214步长与窗口相等时尺寸直接减半。Conv216 个 5×5 核步长 1无填充614×141610×10(14−5)/1110(14−5)/1110通道数变为 16。Pool22×2 窗口步长 2最大池化1610×10165×510/2510/25。Flatten张量重塑165×540016×5×540016×5×5400。FC1全连接 Sigmoid400120线性变换 yW1xb1yW1​xb1​输出维度 120。FC2全连接 Sigmoid12084线性变换输出维度 84。Output全连接 Softmax8410线性变换后经 Softmax 归一化为概率分布。2.3 参数量统计可训练参数CNN 的核心优势之一在于权值共享一个卷积核共享一套参数扫遍全图极大减少了参数量。下表详细统计层级参数量计算数量Conv1(5×5×11)×6(5×5×11)×6156Pool1无可训练参数0Conv2(5×5×61)×16(5×5×61)×16输入通道为 62,416Pool2无可训练参数0FC1400×120120400×12012048,120FC2120×8484120×848410,164Output84×101084×1010850合计—约 6.17 万对比基准若采用全连接网络直接处理 78428×28像素输入仅第一层隐藏层256 个神经元就包含 784×256256≈20784×256256≈20 万个参数。LeNet-5 的参数效率显著更高。三、模块化代码实现我们直接复用此前已实现的Conv2D、MaxPooling和FullyConnected类。为了保持文章可读性正文仅展示类定义的骨架和核心逻辑完整实现请参见文末附录。3.1 基础层定义关键片段卷积层Conv2D核心操作是在输入上滑动卷积核计算内积并加上偏置。为便于理解我们采用显式四重循环实现实际工程可用im2col加速。class Conv2D: def __init__(self, in_channels, out_channels, kernel_size, stride1, padding0): # 初始化权重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*sk, j*s:j*sk] # 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_size2, stride2): 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, axis1, keepdimsTrue)) return exp_x / np.sum(exp_x, axis1, keepdimsTrue)3.2 组装 LeNet-5 模型类在LeNet5的__init__中依次实例化各层forward方法按拓扑顺序串联。注意在第二个池化层后执行reshape操作将多维特征图展平为一维向量作为全连接层的输入。class LeNet5: def __init__(self): self.conv1 Conv2D(1, 6, kernel_size5, stride1, padding0) self.pool1 MaxPooling(2, 2) self.conv2 Conv2D(6, 16, kernel_size5, stride1, padding0) 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, limitNone): # 读取 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)), modeconstant) train_images, train_labels load_mnist_csv(mnist_train.csv, limit1000) test_images, test_labels load_mnist_csv(mnist_test.csv, limit200) train_images pad_to_32x32(train_images) test_images pad_to_32x32(test_images)执行后训练集形状为(1000, 1, 32, 32)测试集形状为(200, 1, 32, 32)。五、前向传播验证与损失基线在训练之前必须验证前向传播的数据流动是否通畅且输出尺寸是否符合设计预期。我们取一个 Mini-Batch32 张图像执行推理并计算随机初始化状态下的交叉熵损失作为后续训练的基线参考。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, stride1, padding0): 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)), modeconstant) 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*sk, j*s:j*sk] out[b, oc, i, j] np.sum(window * self.weights[oc]) self.bias[oc] return out class MaxPooling: def __init__(self, pool_size2, stride2): 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*sp, j*s:j*sp] 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, axis1, keepdimsTrue)) return exp_x / np.sum(exp_x, axis1, keepdimsTrue) # ---------- 2. LeNet-5 模型 ---------- class LeNet5: def __init__(self): self.conv1 Conv2D(1, 6, 5, stride1, padding0) self.pool1 MaxPooling(2, 2) self.conv2 Conv2D(6, 16, 5, stride1, padding0) 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, limitNone): 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)), modeconstant) # ---------- 4. 主程序 ---------- if __name__ __main__: np.random.seed(42) # 固定随机种子便于复现 # 加载数据只取前 1000 训练和 200 测试加快验证速度 print(正在加载 mnist_train.csv ...) train_images, train_labels load_mnist_csv(mnist_train.csv, limit1000) print(正在加载 mnist_test.csv ...) test_images, test_labels load_mnist_csv(mnist_test.csv, limit200) # 填充到 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 对输入范围极其敏感方差过大的权重会导致卷积输出值如 55 或 −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 附近。这一过程验证了优秀的初始化是梯度传播的起点数据预处理应与激活函数特性协同设计。
返回列表