资讯详情

资讯详情

手写全连接神经网络:NumPy实现反向传播与训练详解

简介面向深度学习初学者这是一份基于NumPy从零搭建全连接神经网络的完整Python实现清晰演示了多层感知器从设计到训练的整个过程。资源深入讲解Sigmoid激活函数、随机权重初始化、前向传播、均方误差损失计算、反向传播链式求导及权重更新并封装了训练与预测函数帮助读者摆脱对TensorFlow、PyTorch等高层框架的依赖直观理解梯度下降在神经网络中的运作原理。压缩包共计7个纯Python脚本总大小仅4KB模块划分明确涵盖数据生成、网络构建、算法执行、结果预测等环节所有代码风格简洁可直接运行验证也可作为二次开发的基础模板。目前已有4213人学习下载适合希望夯实深度学习基础或快速搭建简易神经网络的开发者是理论到实践的优质入门参考。1. 手写全连接神经网络先跑通一次才能把反向传播的形状刻进脑子全连接神经网络入门门槛其实不在数学而在把矩阵形状和梯度流对上。很多人用框架几行代码就跑完一个分类任务但遇到 loss 不降、梯度爆炸这类问题往往只能对着报错瞎猜。这份“简单全连接神经网络 python 实现”源码包我拆过一遍它没有依赖任何深度学习框架只用 NumPy 完成了数据加载、网络定义、训练循环和模型保存特别适合刚啃完理论想验证理解的读者也适合想快速回顾底层逻辑的熟手。它能解决一个非常具体的问题让你在最小代码量下把每一层参数的形状变化、每一步梯度的来龙去脉看得清清楚楚。2. 全连接网络核心实现拆解前向传播、反向传播与参数更新的代码细节要复现这份实现先理解它的骨架一个两层全连接网络隐藏层 输出层隐藏层用 ReLU 激活输出层用 Softmax损失函数用交叉熵。完全不用框架所有计算落在 NumPy 矩阵运算上。下面按模块拆开讲。2.1 网络结构定义先把权重矩阵形状写对网络定义最容易被忽略的是权重矩阵维度。输入是 784 维像素向量隐藏层设 128 个神经元输出层对应 10 个类别。那么第一个权重矩阵形状必须是(784, 128)第二个是(128, 10)。形状错一个后面 dot 运算直接抛维度异常。import numpy as np class SimpleMLP: def __init__(self, input_dim784, hidden_dim128, output_dim10): # He初始化配合ReLU使用防止梯度消失或爆炸 self.w1 np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.b1 np.zeros(hidden_dim) self.w2 np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) self.b2 np.zeros(output_dim)这里初始化方式不是随便选的。如果用正态分布直接np.random.randn乘一个固定系数深层网络很容易陷入梯度消失。He 初始化的思路是根据输入维度缩放让激活值在前向传播时保持合理方差这对 ReLU 尤其重要。bias 初始化为 0 是安全的不会破坏对称性。2.2 前向传播的向量化实现为什么不用一层循环很多初写者会试图用 for 循环逐样本计算但向量化才是正道。整批数据一次性过矩阵乘法运算效率高代码也贴合数学表达式。前向传播只需要记录中间变量供反向传播使用。def forward(self, x): self.x x self.z1 x.dot(self.w1) self.b1 self.a1 np.maximum(self.z1, 0) # ReLU激活 self.z2 self.a1.dot(self.w2) self.b2 # 数值稳定的softmax先减最大值防止exp溢出 exp_z np.exp(self.z2 - np.max(self.z2, axis1, keepdimsTrue)) self.probs exp_z / np.sum(exp_z, axis1, keepdimsTrue) return self.probs输入x的形状是(batch_size, 784)x.dot(self.w1)得到(batch_size, 128)加上 bias 后通过np.maximum(z1, 0)完成 ReLU。注意np.maximum在负数区域导数为 0正数区域导数为 1这个特性会在反向传播里用到。Softmax 里先减去每行最大值这一步是实战必须的。如果不减z2里出现较大数值时np.exp可能溢出成 inf整个概率分布变成 NaN。减去最大值不会改变 Softmax 的结果但把指数函数的输入拉回到了安全区间。2.3 反向传播从损失到每一层参数的梯度反向传播是这个项目最值得细读的部分。交叉熵损失对 Softmax 输出求导的结果非常简洁probs - y_onehot。很多资料会推导一长串公式但代码里就这一行。理解了这一点后面每一层的梯度都有固定套路。def backward(self, y_onehot, lr0.1): batch_size y_onehot.shape[0] # 交叉熵 softmax的梯度化简结果 dz2 self.probs - y_onehot self.dw2 self.a1.T.dot(dz2) / batch_size self.db2 np.sum(dz2, axis0) / batch_size da1 dz2.dot(self.w2.T) dz1 da1 * (self.z1 0) # ReLU的导数掩码 self.dw1 self.x.T.dot(dz1) / batch_size self.db1 np.sum(dz1, axis0) / batch_size # 参数更新 self.w1 - lr * self.dw1 self.b1 - lr * self.db1 self.w2 - lr * self.dw2 self.b2 - lr * self.db2每个梯度计算都除以batch_size这是取平均梯度。如果直接累加不取平均梯度幅度会随批次大小线性增长换 batch_size 就要重新调学习率非常痛苦。除以 batch_size 后学习率的含义与 batch 大小基本解耦。dz1 da1 * (self.z1 0)这行是 ReLU 的反向传播。(self.z1 0)生成一个布尔掩码正数位置为 1负数位置为 0。注意self.z1 0返回的是布尔数组乘到 float 数组上会把 True 当 1、False 当 0这正是我们要求的 ReLU 导数。参数更新的写法是朴素 SGDw w - lr * grad的向量化表达。这段代码放在backward里而不是单独拆一个update方法对新手更直观每一步反向计算完立刻能看到参数变化。3. 训练流程与数据准备让网络在一个标准数据集上跑起来有了网络类下一步要把它接到真实数据上。训练流程里踩得最多的问题不在网络结构而是数据预处理和训练循环的组织方式。这一章用 MNIST 数据为例把所有必备环节补齐。3.1 数据加载与预处理归一化、划分验证集、构造 one-hot 标签直接用原始 0-255 像素值训练会非常痛苦。输入尺度太大权重更新一步就可能把激活值推出合理区间。我通常会把像素除以 255 压到[0,1]有必要再做一下均值平移让数据分布尽量居中。from sklearn.datasets import fetch_openml X, y fetch_openml(mnist_784, version1, return_X_yTrue, as_frameFalse) X X.astype(np.float32) / 255.0 y y.astype(np.int64) # 划分训练集和验证集前6万做训练后1万做验证 split 60000 X_train, X_val X[:split], X[split:] y_train, y_val y[:split], y[split:] # 均值平移让输入分布居中加速收敛 mean X_train.mean(axis0) X_train - mean X_val - meanfetch_openml会把数据一次性下载到本地缓存第一次跑会等一会儿后续就快了。这里划分验证集时直接切分固定位置不做随机抽样为的是结果可复现。如果这里随机打乱又没固定随机种子每次跑出来的验证集都不一样调整超参时很难对比效果。3.2 训练循环小批量 SGD 的参数选择与迭代逻辑训练循环是整份资源的骨架。核心是随机打乱训练数据拆成小批次依次前向计算和反向更新。一个 epoch 跑完全部数据然后记录验证集指标。from sklearn.utils import shuffle def train(model, X_train, y_train, X_val, y_val, epochs20, batch_size64, lr0.1): n X_train.shape[0] for epoch in range(epochs): Xs, ys shuffle(X_train, y_train, random_stateepoch) for i in range(0, n, batch_size): xb Xs[i:ibatch_size] yb ys[i:ibatch_size] y_onehot np.eye(10)[yb] model.forward(xb) model.backward(y_onehot, lr) val_loss, val_acc evaluate(model, X_val, y_val) print(fepoch {epoch1}/{epochs}, val_loss: {val_loss:.4f}, val_acc: {val_acc:.2%})shuffle(..., random_stateepoch)这里把 random_state 设为当前 epoch 值每次打乱顺序固定但不同 epoch 之间不同既保证可复现又避免每个 epoch 都用同一顺序。np.eye(10)[yb]是生成 one-hot 标签的经典写法yb是形状(batch_size,)的整数标签数组索引到单位矩阵对应的行得到(batch_size, 10)的 one-hot 矩阵。batch_size64 是 MNIST 上比较稳妥的选择。太小梯度噪声大太大收敛慢64 在准确率和训练速度之间比较平衡。学习率 lr0.1 对应朴素 SGD 是合理的起点但如果优化器换成 Adam学习率需要下调到 0.001 量级。3.3 评估函数loss 和 accuracy 分开计算训练时打印的是 loss但评估模型好坏要看准确率。这两个指标偶尔会给出矛盾的信息所以需要同时监控。def evaluate(model, X, y, batch_size256): loss 0.0 correct 0 for i in range(0, X.shape[0], batch_size): xb X[i:ibatch_size] yb y[i:ibatch_size] probs model.forward(xb) # 取每个样本真实类别对应的概率求交叉熵损失 loss -np.log(probs[np.arange(len(yb)), yb] 1e-12).sum() pred probs.argmax(axis1) correct (pred yb).sum() return loss / X.shape[0], correct / X.shape[0]probs[np.arange(len(yb)), yb]的作用是取出每个样本在真实类别上的预测概率。这个索引写法是 NumPy 的高阶用法熟悉后会觉得比循环优雅得多。把1e-12加进 log 里是防止概率被舍入成 0 导致log(0)报错。验证时用 batch_size256 是因为评估不需要梯度可以一次多放一些数据。这里没有要求梯度所以forward里记录的那些中间变量不会被用到但不影响正确性。4. 全连接网络避坑指南五个高频问题和排查思路手写网络最容易出问题的地方往往不在数学而在工程细节。下面这些坑是我在实际验证时翻过车的每一条都按现象、原因、解决整理。4.1 loss 完全不动甚至变成 NaN现象训练几个 epoch 后 loss 稳定在某个值不降低或者直接变成nan。原因最常见的是输入没有归一化0-255 的原始像素值乘上权重再加 bias激活值很容易落在 ReLU 的饱和区梯度趋近于 0。另一种可能是学习率设得过大参数更新一步就跳出了合理范围。解决先把像素除以 255并建议做均值平移。如果 loss 已经变成nan从lr0.01重新开始确认能稳定下降后再逐步增大。4.2 训练集准确率高验证集准确率却一般现象训练集上达到 98%验证集只有 91% 左右过拟合迹象明显。原因两层网络虽然容量有限但当隐藏层宽度设为 256 甚至更高时模型仍有能力记住训练样本的噪声。尤其是训练数据量少、没有做数据增强时更容易过拟合。解决先降低隐藏层宽度到 64 或 128观察验证集是否改善。更直接的办法是加正则化比如 L2 正则把权重的平方和加进损失。网格搜索参数时优先看验证集 loss 而不是训练集 loss。4.3 损失曲线剧烈震荡像心电图一样现象每个 epoch 内 loss 上下跳动厉害整体趋势模糊。原因batch_size 太小比如设为 1每个样本的梯度方向差异极大导致参数更新路径歪歪扭扭。学习率偏大也会放大这种震荡。解决batch_size 提到 64学习率降到 0.05 或 0.01。观察震荡是否收窄。如果 batch 加大后震荡依旧再检查数据是否混入了异常标签。4.4 权重全部初始化为 0网络永远学不动现象所有样本的输出预测概率完全一致loss 几乎不下降。原因如果w1、w2都是 0那么所有神经元在前向传播的输出完全相同反向传播的梯度也相同对称性永远无法打破等价于整个网络退化成单个神经元。解决改用 He 初始化或 Xavier 初始化bias 可以保持为 0。np.random.randn配合np.sqrt(2.0 / input_dim)这种写法已经够用。初始化完成后可以打印一下w1的统计值确认没有全零。4.5 固定了随机种子结果还是对不上现象明明设置了np.random.seed(0)但换一台机器或者换一次运行结果仍有细微差异。原因数据加载顺序、shuffle 时机、初始化顺序都会影响训练轨迹。只固定一处随机种子是不够的数据被shuffle时如果没有传random_state打乱顺序每次都不同训练轨迹自然对不上。解决在所有随机操作入口显式设置随机种子包括初始化、数据 shuffle、任何 dropout 或数据增强操作。我习惯在数据加载函数开头调用一次np.random.seed(42)同时在 shuffle 里传入random_state双保险。5. 超参调整与损失曲线判读用少量实验摆脱玄学调参训练跑通以后花最多时间的是调参。新手经常把学习率、batch_size、隐藏层大小混在一起乱试结果变量互相干扰模型表现不稳定。先固定结构再逐项扫描会高效得多。5.1 学习率搜索在对数坐标系里找数量级学习率的搜索范围应该按对数步进比如[1.0, 0.3, 0.1, 0.03, 0.01]而不是等间距的[0.05, 0.10, 0.15, 0.20]。因为不同学习率对收敛速度的影响是指数级的等间距扫描容易漏掉真正合适的量级。for lr in [1.0, 0.3, 0.1, 0.03, 0.01]: model SimpleMLP(hidden_dim128) train(model, X_train, y_train, X_val, y_val, epochs5, batch_size64, lrlr) print(flr{lr} done)只看前 5 个 epoch 就够了。如果某个学习率在前 5 个 epoch 内验证集 loss 稳定下降说明这个数量级可用。如果 loss 在前几个 epoch 就爆炸或完全不降直接跳过不需要跑完 20 个 epoch。这个方法可以帮你快速确定学习率的上下边界之后再在边界内细调。5.2 损失曲线形态和对应的动作我总结了五类常见曲线特征用表格列出判断方向和操作动作。曲线特征可能的根因优先尝试的动作loss 平坦不降输入未归一化、lr 过小、初始化不当检查数据预处理调大 lrloss 下降后反弹lr 过大参数跳过最优点降低 lr或加学习率衰减loss 持续降低但 accuracy 停滞类别不均衡或模型容量不够增加隐藏层宽度检查标签分布loss 震荡剧烈batch_size 过小调大 batch_size训练 loss 很低但验证 loss 高过拟合减小隐藏层尺寸加正则化5.3 隐藏层宽度实验容量和过拟合的权衡隐藏层宽度直接决定模型容量。宽度太小训练 loss 降不下去模型欠拟合宽度太大训练 loss 接近零但验证集可能变差。做一组宽度对比实验是最直观的判断方式。for hidden_dim in [32, 64, 128, 256]: model SimpleMLP(hidden_dimhidden_dim) train(model, X_train, y_train, X_val, y_val, epochs10, batch_size64, lr0.1)宽度从 32 到 256训练时间会递增。观察验证集准确率的变化通常 128 是个不错的平衡点256 在小数据集上容易过拟合。这是我个人在很多分类任务上的感受具体数据环境不同需要重新测。如果资源里没有自动保存实验结果的代码建议手动记录每组的验证集准确率方便横向比较。6. 梯度检查与模型保存把训练从黑匣子变成可验证的流水线手写网络最怕反向传播写错但 loss 仍在下降只是速度很慢或者结果差得离谱却找不到原因。数值梯度检查是唯一的后悔药可以独立验证反向传播是否计算正确。数值梯度的思想是用差分近似导数对每个权重theta计算(loss(theta eps) - loss(theta - eps)) / (2 * eps)与反向传播得到的梯度对比。实现上是把权重矩阵展平逐个遍历位置代价较高但作为验证手段完全值得。def gradient_check(model, x, y_onehot, eps1e-5): model.forward(x) model.backward(y_onehot, lr0) # 把参数和梯度展平成向量 w_flat model.w1.ravel() dw_flat model.dw1.ravel() num_grad np.zeros_like(w_flat) for i in range(w_flat.size): w_plus w_flat.copy() w_minus w_flat.copy() w_plus[i] eps w_minus[i] - eps # 重新计算loss这里需要临时替换参数并执行forward model.w1.flat[i] w_plus[i] probs_plus model.forward(x) loss_plus -np.log(probs_plus[range(len(y_onehot)), y_onehot.argmax(axis1)]).sum() model.w1.flat[i] w_minus[i] probs_minus model.forward(x) loss_minus -np.log(probs_minus[range(len(y_onehot)), y_onehot.argmax(axis1)]).sum() num_grad[i] (loss_plus - loss_minus) / (2 * eps) # 恢复原始参数 model.w1.flat[:] w_flat model.forward(x) model.backward(y_onehot, lr0) rel_error np.abs(num_grad - dw_flat) / (np.abs(num_grad) np.abs(dw_flat) 1e-8) print(f相对误差均值: {rel_error.mean():.6e}, 最大值: {rel_error.max():.6e})相对误差在1e-7到1e-4之间都属于正常范围。如果误差在1e-2量级说明反向传播公式大概率写错了不要急着调参先回头检查梯度计算。注意这里lr0是为了只算梯度、不更新参数避免数值梯度检查期间权重被改动。模型训练完成后参数保存用np.savez最方便不依赖任何框架。保存后再写一个加载逻辑就能脱离训练代码直接做预测。np.savez(mlp_params.npz, w1model.w1, b1model.b1, w2model.w2, b2model.b2) # 加载并预测 loaded np.load(mlp_params.npz) model.w1, model.b1 loaded[w1], loaded[b1] model.w2, model.b2 loaded[w2], loaded[b2] probs model.forward(X_val[:5]) print(probs.argmax(axis1))我一直保留的训练习惯是即使只是改了层宽或初始化方式也会先把梯度检查跑一遍再训练。这个习惯帮我挡住过好几次看似正常、实则梯度算错的改动。这一份手写全连接网络的实现代码量不大但前向、反向、训练、验证、保存都齐了复现完再改造成自己的分类任务应该能省不少走弯路的时间。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →