深度前馈神经网络:从零实现反向传播与训练

发布时间:2026/10/13 19:19:33
深度前馈神经网络:从零实现反向传播与训练
简介这是一份面向机器学习初学者和神经网络入门者的PDF讲义围绕深度前馈神经网络DFNN展开系统讲解从原理解释、公式推导到Python实现的完整链路。内容从网络定义和变量约束出发详细梳理输入层、隐藏层与输出层的设定并逐步推导前向传播、反向传播以及基于链式法则的梯度计算过程同时对比sigmoid、tanh、ReLU等常见激活函数说明交叉熵损失与均方误差的差异以及训练集、测试集划分对模型泛化能力的影响。除了基础推导文档还补充了正则化、学习率调度、早停等实用技术要点帮助读者在训练中避免过拟合并提升模型性能。PDF文件仅1个大小约1.38MB整体结构清晰适合读者对照公式和后续代码实现反复查阅。资源已有340人学习浏览对想弄清神经网络参数更新底层逻辑并动手实践的Python开发者尤其适用。1. 深度前馈神经网络最朴素也最容易被人低估一个表格里躺着十几个数值特征目标是从里边预测一个二分类结果。很多人第一反应是先套逻辑回归但线性边界在某些分布上就是怎么调都欠拟合——决策边界是弯的模型画不出那条弧线。这时最直接的升级路径不是马上换复杂模型而是搭一个带隐藏层的前馈神经网络。深度前馈神经网络正是机器学习系列开篇要讲的东西它是所有深度模型的骨架。卷积、循环、注意力结构都可以看作在不同数据形态下对前馈层组合方式的改造而梯度反向传播和参数更新的逻辑是一脉相承的。这一篇把网络结构、前向传播、反向传播的公式推导以及从零开始的Python实现串起来适合已经掌握Python和基础微积分、线性代数的读者目标是让你闭着眼也能把一套全连接网络写出来、训起来并知道每一步代码在算什么。搞清楚这个骨架后面再碰其它网络结构时你看见的不会是黑匣子而是一层层熟悉的矩阵运算。2. 网络结构与前向传播模型如何从输入算出输出2.1 神经元、权重与激活函数多层叠加为什么能拟合非线性前馈神经网络的基本单元是一个线性变换加一个非线性函数。单个神经元做的事是将输入向量与权重向量做内积加上偏置再扔进激活函数z wᵀx b, a g(z)这里的 g 就是激活函数。它存在的意义怎么强调都不过分——如果去掉它多层网络无论叠多少层本质上仍然是一个线性变换的复合还是线性函数拟合能力不会有任何提升。激活函数引入了非线性深层网络才有能力逼近任意复杂的连续函数。常见的激活函数有几类。Sigmoid 把输出压缩到 (0,1)导数最大只有 0.25层数一深梯度连乘后迅速趋近于零这就是后人常说的梯度消失源头之一。Tanh 是 Sigmoid 的平移缩放版本输出在 (-1,1)均值为 0实际效果通常比 Sigmoid 好一点但两端饱和的问题还在。ReLU 在正半轴导数为 1、负半轴为 0计算简单梯度传播远好于前两者是目前隐藏层最常用的选择但它也有自己的毛病后面会在避坑章节单独说。选择激活函数时记住一个大方向隐藏层优先 ReLU 或者它的变体二分类输出层用 Sigmoid多分类输出层用 Softmax。回归任务输出层干脆不用激活函数输出层就是一个线性神经元。2.2 矩阵化前向传播维度约定与形状检查把单个神经元扩张到一整层前向传播就变成了矩阵乘法。假设输入矩阵 X 的形状是 (m, d)m 是样本数d 是特征数。第 l 层的权重矩阵 W_l 的形状是 (n_{l-1}, n_l)前一个数是上一层神经元数后一个数是本层神经元数偏置 b_l 的形状是 (1, n_l)。那么Z_l X W_l b_lA_l g_l(Z_l)这里 是矩阵乘法。每一层的输出 A_l 是 (m, n_l)既作为下一层的输入也是本层计算结果。维度混乱是手写网络最常翻车的地方。写代码之前先在纸上画一条数据流的维度链比如一个 2-4-1 的三层网络输入 2 维、隐藏层 4 个神经元、输出 1 维X (m, 2) → W1 (2, 4) → Z1 (m, 4) → A1 (m, 4)W2 (4, 1) → Z2 (m, 1) → A2 (m, 1)注意 W 的行数必须等于上一层特征数列数等于本层神经元数这个约定从头到尾统一起来代码里的转置、点乘才不容易错。前向传播阶段真正难住新手的地方不在公式而在矩阵形状的心里建模。2.3 前向传播的最小代码骨架先把参数初始化和前向传播两个函数写出来只依赖 NumPyimport numpy as np def init_network(layer_dims): 初始化所有层的权重和偏置。 layer_dims: 例如 [2, 4, 1]依次是输入维度、隐藏层维度、输出维度 params {} for l in range(1, len(layer_dims)): # 后一层参数的行数取决于前一层的神经元数 params[fW{l}] np.random.randn(layer_dims[l-1], layer_dims[l]) * 0.01 params[fb{l}] np.zeros((1, layer_dims[l])) return params def forward_propagation(X, params): 两层前向传播隐藏层用 tanh输出层用 sigmoid W1, b1 params[W1], params[b1] W2, b2 params[W2], params[b2] Z1 X W1 b1 A1 np.tanh(Z1) Z2 A1 W2 b2 A2 1 / (1 np.exp(-Z2)) # sigmoid输出为 (m, 1) return A1, A2初始化为什么用 0.01 乘以标准正态随机数这里先卖个关子第 3 章推导完梯度分布之后再回头看这个缩放倍数的意义会更清楚。偏置初始化为零是常见做法权重却不建议全零初始化——全零会让同一层所有神经元算出完全相同的梯度对称性打破了网络就退化成只有一个神经元的模型。forward_propagation 的返回值同时给出隐藏层输出和最终输出隐藏层输出在反向传播时要复用。再强调一次维度X 是 (m, 2) 时W1 必须是 (2, 4)A1 是 (m, 4)W2 是 (4, 1)最后 A2 是 (m, 1)和 y 的形状对齐。3. 反向传播与公式推导梯度如何逐层回传3.1 从损失函数出发模型的差距如何量化训练网络的目标是让预测值尽量接近真实值这件事靠损失函数量化。二分类常用交叉熵损失单个样本的损失是L -y ln(a) - (1-y) ln(1-a)其中 a 是网络输出层的预测值y 是真实标签。网络整体损失是所有样本损失的平均后面看到的梯度也会除以样本数 m这是求“平均梯度”。梯度下降的核心思路是算清楚损失对每个参数的偏导数然后朝梯度的反方向迈一小步。直接对深层网络的权重求偏导非常繁琐因为 W1 先影响 Z1Z1 影响 A1A1 又影响 Z2最后才影响损失中间隔着一整条链。链式法则告诉我们这些偏导数可以逐层相乘每加一层隐藏层梯度表达式里就多出一组对激活函数的导数因子。这串乘积就是反向传播算法要计算的量。3.2 三层网络反向传播的完整推导以 2-4-1 的三层网络为例记号统一为Z1、A1 是隐藏层的线性输出和激活输出Z2、A2 是输出层的对应值输出层激活函数为 Sigmoid隐藏层为 Tanh。损失采用交叉熵。第一步算输出层误差。交叉熵加 Sigmoid 的好处在这里显现对 Z2 求导经过链式法则后结果恰好是预测值与真实值的差δ2 A2 - y形状为 (m, 1)这个结论干净利落是很多实现里直接写 output_error y_pred - y 的原因其实背后就是这个推导。第二步把这个误差传到隐藏层。根据链式法则δ1 (δ2 W2ᵀ) ⊙ g(Z1)其中 ⊙ 是逐元素乘法g(Z1) 1 - A1² 是 Tanh 的导数。展开解释一下误差先通过 W2ᵀ 加权回传到隐藏层每个神经元再乘上该神经元激活函数的局部导数表示“这一层输出变化一点对最终损失影响有多大”。第三步由各层的误差求参数梯度注意每个梯度的矩阵形状要和对应权重一致∂L/∂W2 A1ᵀ δ2 / m∂L/∂b2 mean(δ2, axis0)∂L/∂W1 Xᵀ δ1 / m∂L/∂b1 mean(δ1, axis0)除以 m 是因为前面损失定义成了平均损失。A1ᵀ δ2 的含义是对每个样本隐藏层激活向量与输出误差做外积然后求和平均。观察这个结构会发现一个规律——任一层的梯度等于“上一层激活值的转置”乘以“本层误差”。这个规律对任意深度的网络都成立写通用反向传播时就是靠它循环的。3.3 反向传播的实现细节与数值梯度验证有了公式反向传播代码就只是照抄def backward_propagation(X, y, A1, A2, params): m X.shape[0] W2 params[W2] delta2 A2 - y # 输出层误差 (m, 1) dW2 A1.T delta2 / m # 梯度与 W2 同形状 db2 np.mean(delta2, axis0, keepdimsTrue) delta1 (delta2 W2.T) * (1 - A1**2) # 隐藏层误差 dW1 X.T delta1 / m db1 np.mean(delta1, axis0, keepdimsTrue) return {dW1: dW1, db1: db1, dW2: dW2, db2: db2}这里有个肉眼容易漏掉的细节delta1 的计算用的是 delta2 而不是 A2 - y 之外的东西W2.T 的转置方向也要对否则矩阵形状会直接报错。数值梯度是检验反向传播写没写错的黄金标准。它的原理是用导数的定义式近似对某个参数 w给它加一个极小量 ε 和减一个极小量 ε分别计算损失然后取差分dL/dw ≈ (L(wε) - L(w-ε)) / (2ε)把所有参数都这样算一遍和反向传播的结果对比相对误差。相对误差在 1e-7 左右说明实现正确1e-3 甚至更大就说明反向传播有 bug。第一次写网络的人这个方法能省下大半天的玄学调参时间。4. 从零实现完整训练流程一个小型网络的前向、反向与迭代更新4.1 组装网络类参数更新与预测接口把前面的初始化、前向、反向拼成一个完整的网络类核心代码不长但结构值得反复看class NeuralNetwork: 两层前馈网络输入层 - 隐藏层(tanh) - 输出层(sigmoid) def __init__(self, input_size, hidden_size, output_size, lr0.1): self.lr lr self.params init_network([input_size, hidden_size, output_size]) def train_step(self, X, y): A1, A2 forward_propagation(X, self.params) grads backward_propagation(X, y, A1, A2, self.params) # 梯度下降更新参数沿负梯度方向移动 for key in self.params: self.params[key] - self.lr * grads[d key.upper()] def predict(self, X): _, A2 forward_propagation(X, self.params) return (A2 0.5).astype(int)训练时每次传入一个批次的 X 和 ytrain_step 完成一次前向、反向、更新的完整闭环。学习率 lr 是每一步迈多大的关键超参数设定为 0.1 只是起步值过大会震荡过小则收敛极慢这个我们下一章细说。4.2 训练循环与数据批次的划分单步更新有了训练循环就是反复喂数据、更新参数def train(model, X, y, epochs3000, batch_size32, verboseTrue): m X.shape[0] for epoch in range(epochs): # 每个 epoch 打乱数据顺序减少批次间的相关性 perm np.random.permutation(m) X_shuffled, y_shuffled X[perm], y[perm] for i in range(0, m, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] model.train_step(X_batch, y_batch) if verbose and epoch % 500 0: A1, A2 forward_propagation(X, model.params) loss -np.mean(y * np.log(A2 1e-8) (1 - y) * np.log(1 - A2 1e-8)) print(fepoch {epoch}, loss {loss:.4f})打印损失时加 1e-8 是为了防止 log(0)。训练循环里两个变量需要解释epochs 是数据全集被遍历的次数batch_size 是每次更新权重使用的样本数全数据集塞进去更新叫全批次学习一次取一个样本叫随机梯度下降折中方案就是小批次。4.3 用一个小数据集验证实现是否工作用一个简单的人工二分类数据来验证代码数据是经典的异或分布变体逻辑回归在这种数据上只能画出一条直线而带隐藏层的前馈网络能够学出弯的边界# 手工构造 200 个样本两个特征标签呈非线性分布 rng np.random.default_rng(42) X1 rng.normal(loc[1.5, 1.5], scale0.6, size(100, 2)) X2 rng.normal(loc[-1.5, -1.5], scale0.6, size(100, 2)) X np.vstack([X1, X2]) y np.vstack([np.zeros((100, 1)), np.ones((100, 1))]) model NeuralNetwork(input_size2, hidden_size4, output_size1, lr0.3) train(model, X, y, epochs2000, batch_size16) acc np.mean(model.predict(X).ravel() y.ravel()) print(ftrain accuracy {acc:.3f})跑完之后训练集准确率应当接近 1.0损失曲线在训练初期显著下降。隐藏层神经元数量选 4 纯粹是一个“足够拟合异或”的最小规模神经元再多会导致这个简单任务过拟合。观察 loss 打印结果时记住一个判断标准前几百轮如果 loss 快速从 0.7 级别往下掉后边下降趋缓说明梯度流动正常如果 loss 一开始就卡住不动或者反而上升优先检查学习率和初始化这两个是最常见的翻车点。5. 深度前馈网络训练中的常见问题与排查手记5.1 初始化不当损失完全不动或直接变成 NaN现象网络训练几十轮后损失纹丝不动或者第一轮前向计算就出现 NaN。原因初始化权重过小比如全是 0.01 量级会让梯度信号逐层衰减相反权重过大比如直接用 randn 不缩放深层网络极容易在矩阵乘法后产生巨大数值经过指数运算直接溢出。这两个方向都导致训练失效。解决根据激活函数选择合适的初始化方差。隐藏层用 ReLU 时权重按 He 初始化缩放标准差是 sqrt(2 / n_in)用 Tanh 或 Sigmoid 时按 Xavier 初始化标准差是 sqrt(1 / n_in)。我一般把这一行写进 init_network 而不是手动调每个数字。既然提到了 Xavier 初始化就顺便说一句当初把 0.01 作为默认缩放倍数仅仅是为了在小网络上先跑通代码真正干活时这个值太保守会拖慢收敛速度。初始化不是玄学它是在控制前向传播时每层输出的方差尺度目标是让信号在层层传播后仍然保持合理范围。5.2 学习率过大导致震荡过小导致训练三天没反应现象损失曲线在某个值附近反复横跳不下降甚至某次更新后直接飞升到无穷大反之学习率设成 0.001训练了 2000 轮损失才勉强降了一点点。原因学习率太大参数更新时直接跨过了损失函数的低谷区在两侧来回弹跳学习率太小梯度乘以一个小数后几乎不动同等 epoch 下模型收敛不足。解决先把学习率从 0.01、0.1、0.5 三档分别快速试探 100 轮观察损失下降趋势再在收敛最快的那档附近细化。更好的做法是搭配学习率衰减——每经过若干个 epoch将学习率乘以一个 0.95 左右的系数让模型早期大步探索、后期小步精调。5.3 ReLU 死亡导致网络容量静默下降现象损失在训练一段时间后突然停止下降模型效果明显变差而且你检查各层输出时发现某个隐藏层的激活值大量为 0。原因ReLU 在负半轴导数恒为 0如果某个神经元的加权输入一直落在负区间它的梯度就永远是 0权重再也无法更新。这个神经元就“死亡”了而且它死了不会自动复活。学习率偏大时一次较大的梯度更新容易把许多神经元同时推向负区间。解决改用 Leaky ReLU给负半轴一个很小的斜率让神经元即使落入负区间也还有梯度可以逃出去或者把学习率调小。排查时不要盯着损失肉眼看直接把隐藏层输出打印出来统计一下零的比例是一个直观有效的诊断手段。5.4 Softmax 与交叉熵分开算导致数值溢出现象多分类任务里网络输出层用 Softmax 转概率再单独算交叉熵训练过程中 loss 突然出现 inf 或 NaN。原因Softmax 的公式里有指数运算 e^z当某个 logits 数值比较大时指数会直接溢出成 inf除出来的结果变成 NaN。这通常发生在训练初期权重过大或学习率过大导致前向输出失控。解决把 Softmax 和交叉熵合并成一个数值稳定函数利用 log(softmax(z)) 的化简形式直接计算避开显式的指数中间值。实现层面无论是自定义类还是调用现成框架都要找合并之后的损失函数接口而不是分开调用 Softmax 再算日志。5.5 数据预处理不一致导致模型“训练好、上线废”现象训练阶段准确率很高但换到新数据上预测结果一塌糊涂返回值和训练集分布对不上。原因训练前对特征做了标准化比如 z-score 归一化但预测时忘了用同一组均值和标准差处理新样本。训练和推理走的实际上是两个不同的特征空间模型在推理阶段接收的数据分布完全走形。解决把标准化参数看作模型的一部分训练时算出 mean 和 std 后随模型一起保存推理时严格复用。写预测接口时先做归一化再进网络这个步骤要作为硬性规范写进代码结构。6. 从梯度检查开始给手写网络加一重可靠的安全网反向传播公式看着都对代码一行行核对也没发现毛病但训练就是不收敛这种时候最需要的不是调参而是一套能定位错误的工具。梯度检查就是给反向传播做单元测试——它不依赖任何外部库只靠损失函数本身就能验证梯度是否正确。写一个通用的数值梯度检查函数对网络里的每个参数分别扰动并计算近似梯度def grad_check(model, X, y, epsilon1e-7): 对模型所有参数做有限差分梯度检查 def compute_loss(X, y): _, A2 forward_propagation(X, model.params) return -np.mean(y * np.log(A2 1e-8) (1 - y) * np.log(1 - A2 1e-8)) for key in model.params: param model.params[key].copy() grad_analytic backward_propagation(X, y, *forward_propagation(X, model.params)[::-1], model.params)[d key.upper()] # 遍历参数矩阵中的每个位置逐元素扰动 grad_numeric np.zeros_like(param) it np.nditer(param, flags[multi_index]) while not it.finished: idx it.multi_index old_val param[idx] param[idx] old_val epsilon model.params[key] param loss_plus compute_loss(X, y) param[idx] old_val - epsilon model.params[key] param loss_minus compute_loss(X, y) grad_numeric[idx] (loss_plus - loss_minus) / (2 * epsilon) param[idx] old_val it.iternext() model.params[key] param diff np.linalg.norm(grad_analytic - grad_numeric) / ( np.linalg.norm(grad_analytic) np.linalg.norm(grad_numeric) 1e-8) print(f{key} relative diff {diff:.2e})函数里有限差分公式用的是中心差分误差精度比单侧差分高一个数量级。diff 的判定标准低于 1e-7 量级说明反向传播正确在 1e-4 到 1e-5 之间多半是 epsilon 或者损失实现的问题超过 1e-3反向传播一定有 bug。执行梯度检查时不要用全部样本选 3 到 5 个样本就够否则逐元素扰动会非常漫长。我现在的习惯是每次给网络加新结构——不管是换激活函数、加深层数还是改损失函数——都先跑一遍梯度检查再开始长训练。这个习惯帮我挡住了好几次隐藏层里转置方向和批量维度上的错误那些错误靠肉眼根本看不出来。手写网络本来就是一个极好的学习工具可一旦代码出错损失曲线不会告诉你错在第几层梯度检查可以。希望这篇笔记能帮你在前馈神经网络这条路上少绕几个弯把最核心的矩阵计算和梯度流动真正吃透。本文还有配套的精品资源点击获取