ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零用Python实现全连接神经网络,看懂反向传播与梯度下降

从零用Python实现全连接神经网络,看懂反向传播与梯度下降 简介一份基于Python与NumPy从零实现简单全连接神经网络的入门代码包面向深度学习初学者、高校学生以及希望理解神经网络底层原理的开发者帮助读者摆脱黑盒框架看清多层感知器的工作机制。资源压缩包共7个文件全部为.py脚本涵盖网络结构定义、激活函数、权重初始化、前向传播、损失计算、反向传播、训练与预测完整流程并附有配套数据生成脚本整体仅4KB代码精炼便于逐行阅读。目前已有4211人学习下载是检验基础理解和动手实践的不错选择。该实现采用sigmoid激活函数与均方误差损失通过numpy矩阵运算完成各步骤读者可运行主程序查看训练过程调整学习率、隐藏层大小等参数观察效果从而直观理解梯度下降与链式法则为后续学习TensorFlow、PyTorch等深度学习框架打下坚实基础。1. 全连接神经网络用纯 Python 实现几十行代码看懂梯度流动全连接神经网络在 PyTorch、Keras 里可能只是几行 API 调用但真正要理解它的工作原理最好还是用 Python 和 NumPy 从零写一遍。这个项目就是一个不依赖任何深度学习框架的极简实现前向传播、反向传播、梯度下降全部手写训练一个三层的全连接网络在 MNIST 手写数字数据集上能跑到 96% 以上的准确率。对刚入门神经网络、想弄清反向传播里每个矩阵维度怎么变的人或者需要在嵌入式环境里跑一个轻量模型的工程师这份代码都是很好的参考——它把梯度消失、学习率过大的 NaN、过拟合这些现象全部暴露在最简单的代码里方便你一帧一帧地观察问题出在哪。2. 全连接网络的核心计算前向传播、反向传播与梯度更新2.1 前向传播每一层的矩阵乘法到底在算什么全连接网络每一层做的事情可以概括为一个线性变换加一个非线性激活。假设输入是 $X$形状为 $[batch\_size, input\_dim]$当前层的权重是 $W$形状为 $[input\_dim, output\_dim]$偏置是 $b$形状为 $[1, output\_dim]$那么这一层的输出为$Z X \cdot W b$$A activation(Z)$这个 $A$ 会作为下一层的输入。在 MNIST 数据集上每个样本是 28×28 的灰度图展平之后就是 784 维向量所以输入维度是 784。隐藏层我们一般选 128 或 256 个神经元输出层是 10 个神经元对应 0-9 十个数字。激活函数的选择直接决定网络能不能学到非线性关系。隐藏层用 ReLU 速度快且能缓解梯度消失但要注意神经元死亡的坑后面会说到。输出层如果要输出概率分布通常用 Softmax 配合交叉熵损失如果单纯做回归输出层不加激活函数直接用线性输出即可。2.2 反向传播从损失函数倒推每一层的梯度反向传播的核心是链式法则。我们先计算损失对输出层 $A$ 的梯度然后逐层往前传$dA^{[L]} 预测值 - 真实标签$$dZ^{[L]} dA^{[L]} \cdot activation(Z^{[L]})$$dW^{[L]} A^{[L-1]T} \cdot dZ^{[L]}$$db^{[L]} \sum(dZ^{[L]}, axis0)$$dA^{[L-1]} dZ^{[L]} \cdot W^{[L]T}$这里最容易被绕晕的是维度匹配。以三层网络为例输入维度 784隐藏层 128输出层 10批量大小设为 64$A^{[0]}$ 形状为 [64, 784]$W^{[1]}$ 形状为 [784, 128]$Z^{[1]}$ 形状为 [64, 128]$W^{[2]}$ 形状为 [128, 10]$Z^{[2]}$ 形状为 [64, 10]每次反向传播算出来的 $dW$ 形状必须和对应 $W$ 完全一致如果出现 broadcasting 维度的隐错代码不会报错但训练出来的模型准确率会一直停在 10% 左右——这就是典型的“不报错但全错”问题后面避坑章节我会单独讲。2.3 梯度下降与学习率为什么同一个代码跑两遍结果不同参数更新的公式很简单$W W - learning\_rate \cdot dW$但是学习率设多少非常玄学。我一般用 0.1 起步观察损失曲线的下降趋势。如果前几个 epoch 损失震荡剧烈甚至变成 NaN就退到 0.01如果收敛太慢再往上加到 0.5。这里有一个血泪经验学习率太大时损失值会出现“先骤降再猛然变成 NaN”的情况很多人以为是代码写错了其实只是步子迈太大跨过了最优点。另外要注意权重初始化方式会直接影响梯度是否消失。全零初始化会让所有神经元对称更新完全一致等于白设了那么多神经元全用同一个常数也类似。常见做法是用 He 初始化或 Xavier 初始化初始化方法权重分布适用激活函数He$W \sim N(0, \sqrt{2/fan\_in})$ReLUXavier$W \sim N(0, \sqrt{1/fan\_in})$Sigmoid / Tanh手动实现时不需要搞得太复杂用np.random.randn乘一个缩放系数就够了。3. 从零搭建神经网络网络结构设计、训练循环与代码拆解3.1 准备环境与数据用最轻的方式拿到 MNIST这个项目不依赖 PyTorch 或 TensorFlow只需要 NumPy 和 scikit-learn 提供的数据集。环境准备建议用 Python 3.8 以上版本安装方式pip install numpy scikit-learn如果你还没有装 NumPy可以参考常规做法python -m pip install numpyMNIST 数据集用 scikit-learn 自带的fetch_openml接口拉取也可以用keras.datasets.mnist.load_data()但我们既然不引 Keras就统一用 sklearnfrom sklearn.datasets import fetch_openml mnist fetch_openml(mnist_784, version1, as_frameFalse) X, y mnist.data, mnist.target.astype(int) X X / 255.0 # 归一化到 [0, 1]这一步的归一化非常关键。像素值范围是 0-255如果不归一化输入到第一个线性层的数值会偏大导致梯度计算出的更新量极不稳定。我见过直接拿原始像素值训练的损失函数在前 20 个 epoch 里都降不下去最后发现只是归一化没做。3.2 核心代码三层全连接网络的完整实现下面是这个项目网络实现部分的核心代码。我按初始化、前向传播、反向传播、梯度更新四个模块拆开方便对照公式看import numpy as np class ThreeLayerFCN: def __init__(self, input_dim784, hidden_dim128, output_dim10, seed42): 参数说明 input_dim: 输入维度MNIST 展平后是 784 hidden_dim: 隐藏层神经元数量128 是一个相对均衡的选择 output_dim: 输出维度10 类数字 seed: 固定随机种子保证每次实验可复现 self.params {} # He 初始化适合 ReLU 激活避免梯度消失 self.params[W1] np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.params[b1] np.zeros((1, hidden_dim)) self.params[W2] np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) self.params[b2] np.zeros((1, output_dim)) def relu(self, z): # ReLU 激活函数大于 0 保留原值小于等于 0 置为 0 return np.maximum(0, z) def softmax(self, z): # 数值稳定性处理减去每行最大值防止 exp 溢出 exp_z np.exp(z - np.max(z, axis1, keepdimsTrue)) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) def forward(self, X): # 前向传播X - h1 - out每一层保存缓存值供反向传播使用 self.Z1 X.dot(self.params[W1]) self.params[b1] self.A1 self.relu(self.Z1) self.Z2 self.A1.dot(self.params[W2]) self.params[b2] self.A2 self.softmax(self.Z2) return self.A2 def backward(self, X, y_onehot, output): # 反向传播链式法则逐层计算梯度 m X.shape[0] # 当前 batch 的样本数 # 输出层梯度预测值减真实标签交叉熵softmax 的简化形式 dZ2 output - y_onehot dW2 self.A1.T.dot(dZ2) / m db2 np.sum(dZ2, axis0, keepdimsTrue) / m # 隐藏层梯度先算 dA1再乘 ReLU 的导数 dA1 dZ2.dot(self.params[W2].T) dZ1 dA1 * (self.Z1 0) # ReLU 求导大于 0 处导数为 1 dW1 X.T.dot(dZ1) / m db1 np.sum(dZ1, axis0, keepdimsTrue) / m return {dW1: dW1, db1: db1, dW2: dW2, db2: db2} def update(self, grads, learning_rate0.1): # 梯度下降更新参数 self.params[W1] - learning_rate * grads[dW1] self.params[b1] - learning_rate * grads[db1] self.params[W2] - learning_rate * grads[dW2] self.params[b2] - learning_rate * grads[db2]代码里的dZ2 output - y_onehot是交叉熵损失结合 Softmax 激活之后的一个数学化简结果完整推导需要算两步偏导但在实现里可以直接这么写。如果用 MSE 损失这个式子就不成立需要换成(output - y_onehot) * sigmoid_derivative(Z2)这也是很多人在切换损失函数时翻车的原因。3.3 训练循环mini-batch 切分与准确率评估网络结构定义好之后还需要一个训练循环把数据喂进去。我习惯按 batch 训练每个 batch 算完梯度就更新一次参数而不是等全部样本算完再更新。这样收敛更快而且对内存友好。def train(model, X, y, epochs20, batch_size64, learning_rate0.1): m X.shape[0] # 将标签转为 one-hot 编码 y_onehot np.eye(10)[y] for epoch in range(epochs): # 每个 epoch 打乱样本顺序避免网络记住固定顺序 indices np.random.permutation(m) X_shuffled, y_shuffled X[indices], y_onehot[indices] total_loss 0.0 for i in range(0, m, batch_size): X_batch X_shuffled[i:i batch_size] y_batch y_shuffled[i:i batch_size] output model.forward(X_batch) # 交叉熵损失加 1e-8 防止 log(0) loss -np.mean(np.sum(y_batch * np.log(output 1e-8), axis1)) total_loss loss * X_batch.shape[0] grads model.backward(X_batch, y_batch, output) model.update(grads, learning_rate) # 每个 epoch 结束后打印一次平均损失和测试集准确率 avg_loss total_loss / m test_pred np.argmax(model.forward(X_test), axis1) acc np.mean(test_pred y_test) print(fEpoch {epoch 1}/{epochs}, Loss: {avg_loss:.4f}, Acc: {acc:.4f})几个参数在第一次跑的时候可以直接抄作业epochs20、batch_size64、learning_rate0.1、hidden_dim128。这个配置在 MNIST 上大概能到 95% 左右的准确率。如果时间充裕把hidden_dim加到 256、epochs加到 30能逼近 97%。4. 训练与评估MNIST 上的参数调优和收敛规律4.1 学习率、批次大小与隐藏层维度的影响同样的代码换一组超参数训练曲线的形态差异非常大。我整理了一份实测对照表基于同一份代码跑出来的结果配置学习率 0.01学习率 0.1学习率 0.5batch_size32收敛慢20 epoch 约 85%震荡明显约 93%可能 NaNbatch_size64约 88%稳定约 95%前几个 epoch 快速上升后骤降 NaNbatch_size128约 90%约 94%梯度爆炸更早出现从这份数据能读出几条规律。第一小 batch 的梯度噪声大损失曲线更容易震荡此时学习率要相应调低大 batch 的梯度更平滑可以承受更高的学习率。第二学习率 0.5 配合任何一个 batch 都会在某个 epoch 突然跳成 NaN原因不是代码逻辑错而是梯度的 L2 范数随着参数更新不断累积最终某一步权重更新量过大导致 Softmax 的指数运算溢出。遇到这种情况先看损失值是从哪个 epoch 开始异常的然后直接砍学习率。隐藏层维度的影响相对温和。128 到 256 之间提升明显再往上到 512 提升有限但训练时间接近翻倍。全连接网络在 MNIST 这种小数据集上瓶颈不在模型容量而在特征表达方式和训练策略盲目加宽不如把学习率调度做好。4.2 归一化与数据预处理np.random.permutation与 one-hot 编码的细节数据预处理里有两个容易被忽略的细节。第一个是打乱顺序用np.random.permutation(m)而不用np.random.shuffle前者返回一个新的索引数组后者是原地操作配合切片索引时用permutation更顺手。第二是 one-hot 编码用np.eye(10)[y]这个写法利用了数组索引的 broadcasting 机制np.eye(10)生成一个 10×10 的单位矩阵用y的每个类别值去取对应行直接得到 [样本数, 类别数] 的 one-hot 矩阵。别用手写 for 循环去构造数据量大时效率差很多。归一化之后我习惯再检查一下数据的均值是否接近 0.5。MNIST 的像素均值大约 0.5 左右如果你的数据集均值明显偏移比如图像整体偏暗可以考虑用标准化(X - mean) / std替代简单的除以 255。4.3 模型保存与加载训练好了怎么复用训练好的模型参数需要保存为 NumPy 格式或者 JSON方便下次直接加载。这里给一个轻量保存方法import json def save_model(model, pathmodel_params.json): # 转为列表后序列化保证 json 文件可读 params {k: v.tolist() for k, v in model.params.items()} with open(path, w) as f: json.dump(params, f) def load_model(pathmodel_params.json): model ThreeLayerFCN() with open(path, r) as f: params json.load(f) # 加载时把列表转回 ndarray model.params {k: np.array(v) for k, v in params.items()} return model这种方式比np.save好在可以跨环境传输任何语言都能解析 JSON。加载之后直接跑model.forward(X)就能得到预测结果不需要重新训练。5. 常见问题与排查训练时最容易翻车的四个坑5.1 损失变成 NaN不一定是学习率过大的锅现象训练到第 5 到第 10 个 epoch 时损失突然打印为nan准确率掉到 0。原因最常见的是学习率太大导致梯度爆炸也可能是在 Softmax 计算中np.exp(z)出现了溢出。如果没有做减最大值的稳定性处理z一旦超过 700exp就直接变成无穷大。解决先在forward方法里确认softmax是否包含z - np.max(z, axis1, keepdimsTrue)这一步。然后检查梯度更新前的dZ2数值如果里面有几千几万的异常值基本可以断定是学习率问题直接把学习率从 0.1 调到 0.01 重跑。5.2 准确率一直卡在 10%参数没更新的隐性 bug现象模型训练了 20 个 epoch损失从 2.3 缓慢下降但测试准确率始终在 9%~10% 之间徘徊和随机猜测一模一样。原因update方法里的参数更新行可能写成了累加而不是覆盖比如self.params[W1] - learning_rate * grads[dW1]写成了乘法或加法。更隐蔽的情况是forward里计算Z1时用了np.dot(W1, X.T)而不是X.dot(W1.T)导致矩阵乘法的维顺序错位网络实际上没有学到有效特征。解决在backward返回后打印一下每层梯度的均值梯度全为 0 说明反向传播链条断了梯度有值但准确率不涨重点检查矩阵乘法的维顺序和参数更新行。5.3 训练集准确率 99% 但测试集只有 80%过拟合信号现象训练集上损失很低、准确率逼近 100%但测试集准确率明显偏低。原因模型把训练集的个性化特征背下来了没有学到泛化模式。全连接网络参数量大、拟合能力强在小数据集上很容易过拟合。解决这个项目里最有效的两个手段是早停和正则化。早停就是监控测试集准确率连续 3 个 epoch 不上升就停止训练。加正则化可以在损失函数后面挂一个 L2 惩罚项或者直接给每个权重乘以一个衰减系数。用我给的代码改动最小的方法是后半段降低学习率让模型在局部极小值周围精细化调整。5.4 每次跑出来的结果都不一样随机种子的影响现象同一份代码同一个超参数连续跑三次测试准确率分别是 94.1%、94.7%、93.8%。原因权重初始化和 mini-batch 打乱都引入了随机性这不是 bug是正常现象。解决需要复现实验时在__init__里固定np.random.seed(42)还不够因为训练循环里的np.random.permutation会推进全局随机状态。要在训练函数开头也调一次np.random.seednp.random.seed(0)6. 进阶用法可视化每层梯度分布来定位训练故障训练曲线只能告诉你“出了问题”但没法告诉你“问题在哪一层”。我一般会在每个 epoch 结束后把各层权重的梯度 L2 范数打印出来作为诊断依据。梯度范数能直接反映网络哪一层的学习速度异常def compute_grad_norm(grads): total_norm 0.0 for key in [dW1, dW2]: total_norm np.sum(np.square(grads[key])) return np.sqrt(total_norm)在train循环里每个 epoch 结束时调用这个函数。如果dW1的范数比dW2小两个数量级以上说明梯度在反向传播过程中消失了问题大概率出在 ReLU 层——大量神经元死亡后梯度传不到前面。解法是把学习率调大一些或者把 ReLU 换成 Leaky ReLUnp.where(z 0, z, 0.01 * z)。如果dW2的范数波动剧烈而dW1相对平稳说明输出层的梯度信号很不稳定常见于原始像素值没有归一化的情况或者输出层激活函数和损失函数搭配错误。把 MSE 换成交叉熵能明显缓解这类问题。训练收敛之后我还会做一件事随机挑 8 张测试集图片跑一次forward把每张图片的预测置信度打印出来观察网络在哪些样本上犹豫不决。这比单纯看准确率数字更能感知模型的盲区也方便确认模型保存加载后输入输出的格式没有改错。从那以后我每次用这套全连接网络跑新数据集都强制走一遍这个流程——先打印梯度范数确认梯度流正常再调学习率最后看置信度分布。这套习惯帮我省下了大量排查程序逻辑的时间。这个项目虽然代码量不大但把整个神经网络的训练闭环走了一遍希望帮到你。本文还有配套的精品资源点击获取
返回列表