资讯动态

手写全连接神经网络与反向传播:NumPy实现及避坑指南

发布时间:2026/10/14 12:15:00 来源:尧图企业网站定制
简介面向希望通过手写代码深入理解神经网络原理的Python学习者压缩包内代码以numpy从零实现了一个含隐藏层的全连接神经网络完整覆盖sigmoid激活函数、随机权重初始化、前向传播、均方误差损失、反向传播和迭代训练等核心环节并给出从数据构造、模型训练到结果预测的完整闭环流程。资源共含7个Python脚本大小仅4KB文件按功能拆分为主运行脚本run.py、网络结构network.py、数据生成make_data.py、数据处理commondata.py、自定义算法myalgorithm.py以及预测脚本predict.py模块划分清晰便于读者逐一对照学习、修改和测试。目前已有4211人学习该资源作为轻量级入门示例它能帮助初学者避开深度学习框架的黑盒封装直观感受多层感知器的计算细节与梯度更新逻辑同时为后续学习TensorFlow、Keras或PyTorch提供扎实的过渡基础。1. 简单全连接神经网络Python实现为什么我建议你先手写一遍反向传播任何用过深度学习框架的人都会告诉你全连接神经网络是理解一切复杂模型的地基。但你有没有想过当你调用一行model.fit()时框架背后到底做了什么我见过不少同学能熟练使用 PyTorch 或 TensorFlow却说不清反向传播里那个dW X.T.dot(dA)是怎么推出来的。这个标题要解决的就是这个问题用纯 Python 和 NumPy 把全连接神经网络从零实现一遍。它适合两类人——一类是刚入门深度学习、觉得框架封装像黑匣子的新手另一类是平时用框架但想真正理解梯度流动方向的从业者。当你亲手写完 forward 和 backward 各二十行代码你再看任何框架的报错信息都会觉得亲切得多。2. 全连接网络的正向传播两层网络的 NumPy 最小实现2.1 网络结构设计与参数初始化shape 先写对梯度才不会爆全连接网络的核心就一句话每一层的输出等于输入乘以权重矩阵再加上偏置然后过激活函数。用数学表达就是Z W·X bA activation(Z)。但真正动手写代码时第一个坑往往出现在矩阵的 shape 上。我一般习惯用一个具体例子来固定维度假设输入是 28×28 的图片展平后变成 784 维第一层隐藏层有 64 个神经元输出层有 10 个类别。那么输入矩阵 X 的 shape 是(m, 784)其中 m 是样本数第一层权重 W1 的 shape 是(784, 64)偏置 b1 是(64,)第二层权重 W2 是(64, 10)偏置 b2 是(10,)。注意这里用的是以样本数为第一维的约定和某些框架里以特征为第一维的约定不同后续所有矩阵运算都要保持一致否则 transpose 写起来会非常痛苦。参数初始化也不能随便来。如果初始权重都设成 0那么同一层所有神经元会学到完全相同的特征这个现象叫对称权重问题网络等于退化成只有一个神经元。如果我们用太大的随机数初始化经过多层传播后激活值会迅速饱和梯度直接消失。常见做法是用小随机数初始化比如np.random.randn(n_in, n_out) * 0.01或者更规范的 Xavier 初始化——按输入输出维度来缩放np.random.randn(n_in, n_out) * np.sqrt(1.0 / n_in)。我们后面在避坑章节还会详细展开初始化对训练的影响。这里先把结构定下来一个两层的全连接网络隐藏层用 ReLU输出层用 Softmax。2.2 手写 forward矩阵乘法的维度检查是第一步正向传播的代码逻辑非常直接但里面有一个细节值得注意。我先把核心代码贴出来然后逐行解释。import numpy as np def initialize_params(input_size, hidden_size, output_size): 初始化网络参数使用Xavier初始化 # 权重矩阵的shape: (输入维度, 输出维度) W1 np.random.randn(input_size, hidden_size) * np.sqrt(1.0 / input_size) b1 np.zeros(hidden_size) W2 np.random.randn(hidden_size, output_size) * np.sqrt(1.0 / hidden_size) b2 np.zeros(output_size) return {W1: W1, b1: b1, W2: W2, b2: b2} def relu(Z): ReLU激活函数注意负区间梯度为0 return np.maximum(0, Z) def softmax(Z): Softmax沿axis1做归一化防止数值溢出需要减最大值 exp_Z np.exp(Z - np.max(Z, axis1, keepdimsTrue)) return exp_Z / np.sum(exp_Z, axis1, keepdimsTrue) def forward(X, params): 正向传播返回缓存供反向传播使用 W1, b1, W2, b2 params[W1], params[b1], params[W2], params[b2] Z1 X.dot(W1) b1 # 第一层线性变换 A1 relu(Z1) # 第一层激活 Z2 A1.dot(W2) b2 # 第二层线性变换 A2 softmax(Z2) # 第二层Softmax得到概率分布 return A2, {Z1: Z1, A1: A1, Z2: Z2, A2: A2}代码逻辑说明X.dot(W1)是矩阵乘法X 的 shape 是(m, 784)W1 是(784, 64)结果的 shape 是(m, 64)每一行就是当前样本经过线性变换后的隐藏层表示。 b1利用了 NumPy 的广播机制b1 的 shape 是(64,)会自动沿样本维度扩展。ReLU 函数np.maximum(0, Z)会把所有负数置为 0这个选择非常关键——ReLU 在正区间的梯度是常数 1可以有效缓解梯度消失但后面也会带来 dead ReLU 问题。Softmax 的数值稳定性处理是一个常见易错点公式是exp(Z) / sum(exp(Z))但如果 Z 里的数值很大比如某个 logit 是 100exp(100)直接溢出变成 inf。所以我在实现里先减去每行的最大值np.max(Z, axis1, keepdimsTrue)这样指数里的数最大是 0exp(0)1分母最小是 1彻底避免溢出。注意减最大值不会改变 Softmax 的数学结果因为分子分母同时除了一个常数。2.3 激活函数与损失函数ReLU 和交叉熵的 NumPy 写法激活函数的选择直接影响梯度流动。Sigmoid 在 0 附近的梯度最大是 0.25多层叠加后梯度会指数级衰减这就是梯度消失的根源。ReLU 在正区间的梯度是 1能有效缓解这个问题但它也有自己的毛病如果一个神经元的输入一直是负数那么 ReLU 的梯度永远是 0这个神经元就永远不会更新了这叫 dead ReLU。后面避坑章节我会给出解决方案。损失函数这里用交叉熵。在全连接网络做分类任务时最后一层是 Softmax 输出概率损失是-log(正确类别的概率)。对应的 NumPy 实现如下。def cross_entropy_loss(A2, Y): 交叉熵损失 A2: shape (m, num_classes)Softmax输出 Y: shape (m,)标签取值0到num_classes-1 m Y.shape[0] # 用np.arange生成行索引取每个样本对应类别的概率 probs A2[np.arange(m), Y] # 加1e-8防止log(0) loss -np.log(probs 1e-8) return np.mean(loss)代码逻辑说明A2[np.arange(m), Y]是 NumPy 的花式索引取出每个样本在正确类别上的预测概率。比如 m3Y[2, 0, 1]就取第 0 行第 2 列、第 1 行第 0 列、第 2 行第 1 列的元素。np.log取对数1e-8是防止概率为 0 导致log(0)报错。最后取平均得到整个 batch 的损失值。这个损失值就是我们反向传播的起点。这里有个新手经常犯的错有人认为交叉熵前面还要加负号其实-np.log(p)本身就是交叉熵对单个样本的贡献p 越大损失越小完全符合直觉。如果 p1损失是 0如果 p0.1损失是 2.3。不需要额外处理。3. 反向传播从损失到梯度的四条链式法则3.1 计算图视角把 forward 拆成可微分的积木反向传播的本质是链式法则。我们的 forward 可以拆成四块积木Z1 X.dot(W1) b1A1 relu(Z1)Z2 A1.dot(W2) b2A2 softmax(Z2)Loss cross_entropy(A2, Y)。反向传播就是沿着这条链从 Loss 出发逐级计算每个中间变量对 Loss 的梯度。每一块积木只需要解决一个问题已知上游传来的梯度求对当前层参数的梯度以及传给下一层的梯度。这里要特别注意一个编程习惯在 forward 阶段把中间变量缓存下来也就是上节代码返回的 cache 字典。反向传播需要用到X、Z1、A1、Z2这些中间值不缓存的话就得重新 forward 一次浪费时间不说代码也更混乱。框架里也是这么做的——你在训练时经常听到的显存占用有一大半就是为了存这些中间激活值。3.2 反向传播的 NumPy 实现先写 dLoss再往回推这是整个手写神经网络的核心。我建议你按从后往前的顺序推导每一步都盯着 shape 检查。下面是我的完整实现。def backward(X, Y, params, cache): 反向传播返回每一层的梯度 X: shape (m, input_size) Y: shape (m,) 标签 m X.shape[0] W1, W2 params[W1], params[W2] A1, Z1, Z2 cache[A1], cache[Z1], cache[Z2] A2 cache[A2] # 第一步softmax 交叉熵的梯度结果是 A2 - one_hot(Y) Y_onehot np.eye(W2.shape[1])[Y] # 转one-hot编码 dZ2 A2 - Y_onehot # shape: (m, output_size) # 第二步对W2和b2的梯度 dW2 A1.T.dot(dZ2) # shape: (hidden_size, output_size) db2 np.sum(dZ2, axis0) # shape: (output_size,) # 第三步传播到隐藏层 dA1 dZ2.dot(W2.T) # shape: (m, hidden_size) dZ1 dA1 * (Z1 0) # ReLU的梯度正区间为1负区间为0 # 第四步对W1和b1的梯度 dW1 X.T.dot(dZ1) # shape: (input_size, hidden_size) db1 np.sum(dZ1, axis0) # shape: (hidden_size,) return {dW1: dW1, db1: db1, dW2: dW2, db2: db2}逻辑说明和参数解释第一步是全篇最精妙的地方——Softmax 加上交叉熵的组合梯度恰好是A2 - Y_onehot。为什么因为交叉熵对 Softmax 输入的偏导数经过链式法则后所有分母项恰好被抵消只留下预测概率减真实 one-hot 编码这一项。这个结论在数学上可以严格推导但实践中你只需要记住这个结果。np.eye(W2.shape[1])[Y]是构造 one-hot 编码的一种简洁写法np.eye(10)[3]就是第 3 位为 1 的 10 维向量。第二步对 W2 的梯度dW2 A1.T.dot(dZ2)注意 A1 的 shape 是(m, hidden_size)转置后是(hidden_size, m)与(m, output_size)的 dZ2 相乘得到(hidden_size, output_size)和 W2 的 shape 完全一致。b2 的梯度因为是广播加进去的所以对每个样本的梯度求和得到(output_size,)。第三步是梯度往上一层传播dA1 dZ2.dot(W2.T)这个公式的推导来自Z2 A1.dot(W2) b2对 A1 求导就是乘以 W2 的转置。注意这里是W2.T而不是W2很多新手在这里写反导致 shape 对不上报错。接着dZ1 dA1 * (Z1 0)是 ReLU 的梯度——(Z1 0)生成一个布尔掩码正区间是 True(1)负区间是 False(0)逐元素相乘实现正区间梯度为1负区间为0。用乘号而不是点乘是因为这里要的是逐元素操作。第四步和对 W2 的推导完全对称不再赘述。写完后建议自查一遍每个 dW 的 shape 必须和对应 W 的 shape 完全一致。这是最便宜、最有效的排错手段。3.3 参数更新与梯度检查用数值梯度验证手写梯度拿到梯度后参数更新本身极其简单。def update_params(params, grads, learning_rate0.01): SGD参数更新 params[W1] - learning_rate * grads[dW1] params[b1] - learning_rate * grads[db1] params[W2] - learning_rate * grads[dW2] params[b2] - learning_rate * grads[db2] return params但真正重要的问题是你怎么知道自己手写的 backward 是对的最靠谱的方法叫数值梯度检查。原理是直接用导数的定义(f(xh) - f(x-h)) / (2h)来近似梯度和手写梯度对比。如果相对误差小于 1e-6说明反向传播实现无误。def numerical_gradient_check(X, Y, params, epsilon1e-7): 数值梯度检查验证反向传播正确性 params_copy {k: v.copy() for k, v in params.items()} _, cache forward(X, params_copy) loss, _ cross_entropy_loss(cache[A2], Y), None grads_numerical {} for name in [W1, b1, W2, b2]: grads_numerical[name] np.zeros_like(params_copy[name]) # 对参数的每个元素逐一做数值梯度 for idx in np.ndindex(params_copy[name].shape): params_plus params_copy.copy() params_minus params_copy.copy() params_plus[name][idx] epsilon params_minus[name][idx] - epsilon # 重新计算forward和loss A2_plus, _ forward(X, params_plus) A2_minus, _ forward(X, params_minus) loss_plus cross_entropy_loss(A2_plus, Y) loss_minus cross_entropy_loss(A2_minus, Y) grads_numerical[name][idx] (loss_plus - loss_minus) / (2 * epsilon) return grads_numerical逻辑说明这段代码迭代参数中的每一个元素每次只扰动一个元素前后各偏一点点然后用中心差分公式算出该位置的数值梯度。因为要重新走 forward在大模型上做全量梯度检查会慢到无法接受但我们的两层网络参数量才几万个完全可行。执行完检查后计算np.linalg.norm(grads_analytical - grads_numerical) / np.linalg.norm(grads_analytical grads_numerical)结果小于 1e-6 就用放心了。我强烈建议每次修改了 backward 代码都要重跑一遍这个检查它比任何口头保证都可靠。4. 训练循环与数据准备让网络真正学起来4.1 数据预处理归一化和 train/test 划分网络写好了但数据不处理好训练一样会翻车。全连接网络对输入特征的尺度非常敏感。如果某个特征取值范围是 0 到 1000另一个是 0 到 1那么权重更新时大尺度特征对应的梯度会主导整个更新方向网络要花很长时间才能适应。常见做法是标准化X (X - mean) / std让每个特征的均值为 0、方差为 1。注意mean 和 std 只能用训练集的统计量计算然后同样应用到测试集上绝不能用测试集的统计量否则会引入数据泄漏导致测试结果虚高。对标签的处理也要留意。如果你的标签是中文类别名或者字符串需要先映射成整数。比如三个类别[猫, 狗, 鸟]映射为[0, 1, 2]。在计算 loss 时用整数索引取概率在输出可视化时再映射回字符串。这一步看起来简单但字符串标签直接进 NumPy 数组会报错的数据类型问题我见过不止一次。4.2 训练一个 epoch 的完整代码batch、shuffle、learning rate训练循环的完整结构如下每个 epoch 里把训练数据随机打乱分成多个 batch每个 batch 做一次 forward、backward、update。为什么要 batch 而不是一次喂全部数据因为全量数据计算出的梯度方向稳定但计算量大单个样本更新方向噪声大且训练不稳定。mini-batch 是两者的折中实践中 batch size 取 32 或 64 最常用。def train(X_train, Y_train, X_test, Y_test, hidden_size64, epochs200, batch_size64, learning_rate0.1): 完整训练流程 input_size X_train.shape[1] output_size np.max(Y_train) 1 params initialize_params(input_size, hidden_size, output_size) train_losses [] test_accs [] for epoch in range(epochs): # 每个epoch随机打乱数据 permutation np.random.permutation(X_train.shape[0]) X_shuffled X_train[permutation] Y_shuffled Y_train[permutation] epoch_loss 0 for i in range(0, X_train.shape[0], batch_size): X_batch X_shuffled[i:ibatch_size] Y_batch Y_shuffled[i:ibatch_size] # 前向传播 A2, cache forward(X_batch, params) # 计算损失 batch_loss cross_entropy_loss(A2, Y_batch) epoch_loss batch_loss # 反向传播 grads backward(X_batch, Y_batch, params, cache) # 参数更新 params update_params(params, grads, learning_rate) avg_loss epoch_loss / (X_train.shape[0] // batch_size) train_losses.append(avg_loss) # 每个epoch在测试集上评估一次准确率 if epoch % 10 0: A2_test, _ forward(X_test, params) predictions np.argmax(A2_test, axis1) acc np.mean(predictions Y_test) test_accs.append(acc) print(fEpoch {epoch}, Loss: {avg_loss:.4f}, Test Acc: {acc:.4f}) return params, train_losses, test_accs逻辑说明和参数说明np.random.permutation生成随机索引然后按索引重排数据和标签保证每个 epoch 内 batch 的内容不同。内层循环用range(0, m, batch_size)切片取 batch最后一批可能不足 batch_size直接截取即可。learning_rate0.1是当前这种小网络的常用起点后面避坑章节会讲学习率怎么调。每次在测试集上评估时用np.argmax(A2_test, axis1)取每行概率最大的下标作为预测类别然后和真实标签比较求准确率。打印频率每 10 个 epoch 一次避免输出刷屏。4.3 损失曲线怎么看欠拟合和过拟合的第一手判断训练完看损失曲线新手往往只看有没有下降。我来给一个更实用的判断标准。如果你的 train loss 前期下降很快说明梯度方向和大小基本正确如果 loss 一直横盘甚至上升先查学习率是不是太大再查数据预处理是不是忘了标准化最后用梯度检查排除 backward 错误。如果训练集 loss 降到很低但测试集准确率上不去这是典型的过拟合。体现在曲线上是 train loss 持续下降而 test loss 开始上升。这时候优先考虑正则化手段而不是换更大模型。常见手段有 L2 正则化在 loss 里加λ * sum(W^2)实践中 λ 取 1e-4 到 1e-2 之间、dropout训练时随机丢一部分神经元或者提前停止当 test loss 连续多个 epoch 不降就停。还有一个更隐蔽的情况train loss 和 test loss 都降不下去而且准确率接近随机猜测。这往往是数据 label 本身有噪声或者模型容量不足隐藏层神经元太少。排查顺序是先用少量训练样本比如 100 条试跑如果 loss 能降到 0说明模型容量足够、反向传播正确如果 100 条样本都学不动问题一定出在代码或数据上而不是模型不够大。5. 全连接网络排查避坑梯度消失、dead ReLU 与学习率翻车的 5 条记录5.1 现象loss 下降极慢训练几千轮损失还在 1.0 以上原因激活函数用了 Sigmoid 且网络层数超过两层。Sigmoid 的最大梯度是 0.25多层连乘之后梯度呈指数衰减到前面几层时梯度已经小到权重几乎不更新。解决换用 ReLU。在我维护的一个模拟项目X里把三层全连接网络的激活从 Sigmoid 换成 ReLU 后同样 200 轮训练准确率从 61% 直接提到 89%。这是性价比最高的修复手段。5.2 现象训练过程中部分神经元永远输出 0梯度检查正常但网络学不动原因dead ReLU。某个神经元的输入一直为负ReLU 输出恒为 0反向传播时(Z1 0)掩码全是 0该神经元的权重永远得不到更新。这在初始化值偏大或学习率偏大时特别容易发生。解决调小初始化权重* 0.01改成* 0.001或者把 ReLU 换成 Leaky ReLU——负区间给一个小的斜率比如 0.01让梯度有微弱通路。实践中我见过最有效的方法是先用较小的学习率跑 100 轮预热等参数进入合理区间后再调大学习率。5.3 现象loss 剧烈震荡不收敛有时直接变成 NaN原因学习率太大。梯度下降公式W - lr * dW如果 lr 过大一步跨过最优解来回震荡再大一点梯度爆炸数值溢出变成 NaN。解决调小学习率从 0.1 改成 0.01 或 0.001。判断方法打印每个 batch 的梯度范数np.linalg.norm(dW1)如果从最初的 0.1 级别涨到 10 以上基本就是学习率过大的前兆。更系统的做法是学习率衰减——每 20 轮把学习率乘 0.9。这也是为什么 Adam 优化器受欢迎的原因它自带对每个参数的自适应学习率能容忍更大的初始学习率。5.4 现象训练集准确率 100%测试集只有 60%原因过拟合模型把训练集的噪声也记住了。解决思路从三个方向同时入手增加数据——最简单的做法是对图像做随机平移、旋转、缩放加正则——L2 正则化要直接在 loss 上加0.5 * lam * np.sum(W1**2) 0.5 * lam * np.sum(W2**2)梯度和 backward 里的 dW 也要对应加上lam * W用早停——每轮记录测试集 loss连续 10 轮不降就终止训练。注意 L2 里的 0.5 是为了求导后系数好约掉的最后梯度是λ * W不是2λ * W。5.5 现象梯度检查通过但训练时 backward 报 shape mismatch原因中间某个变量的 shape 在代码执行过程中被广播或转置改变了。我遇到过一个典型的坑输入 X 是 (m, 1) 而不是 (m,)X.dot(W1)会得到 (m, hidden_size) 但逻辑语义全乱了。解决在 forward 开始加 assert 检查——assert X.shape[1] W1.shape[0]在 backward 开始时加assert dZ2.shape A2.shape。这些断言在生产环境可以删掉但调试期间能快速定位形状不匹配发生在哪一层。血泪经验shape 错误是新手写反向传播翻车概率最高的地方多一行 assert 少一小时排查。6. 进阶技巧用数值梯度检查作为反向传播的守门员当你的网络从两层扩到三层、四层时手写 backward 的错误率会急剧上升。层数越多链式法则连乘越长中间一个符号写错或者转置弄反整个梯度就废了。这时候再靠肉眼逐行检查就很吃力。我的习惯是把梯度检查封装成一个可复用的工具函数每次改动网络结构后跑一遍当成守门员来用。具体的做法是把 forward 和 backward 的输入输出接口固定下来任何新加的层都遵循同一个约束——forward 接收输入 X 和该层参数返回输出和缓存backward 接收缓存和上游梯度返回参数梯度和传给下一层的梯度。然后数值梯度检查脚本完全不用改直接对新的参数字典做遍历。这个工具在我做过的一个三层图像分类模拟项目中帮我排查出两处 bug一处是第三层的 dW 忘了乘上游梯度另一处是 dropout 在测试时忘了关——后者不会在梯度检查中暴露因为梯度检查默认不走 dropout。实践下来还有几个小技巧值得分享。第一梯度检查时参数维度选小的比如hidden_size3这样元素少、跑得快检查的是算法正确性而不是速度。第二检查通过后先用全量数据不用 batch跑一轮完整 forward-backward-update确认 loss 真的下降了再做正式训练。第三保存一份当时能跑通的完整代码现场我用某次重构前的旧版代码对比新版快速定位了参数更新后缓存没清的隐蔽 bug——更新完参数后 cache 里存的还是旧值下一轮 forward 直接用了过期缓存loss 曲线一整条都不对。最后提醒一句任何一次对 backward 的修改哪怕只是改了一行都要重跑梯度检查不要偷懒。这个习惯帮我省下的调试时间比我写检查脚本的时间多了一百倍希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑