简介这是一份机器学习系列第一讲对应的深度前馈神经网络讲解文档面向正在学习神经网络基础、需要理清前向传播与反向传播细节的读者。内容从神经元定义、变量约束入手逐步给出前向传播公式、交叉熵损失与代价函数并基于链式法则推导反向传播梯度配有Python实现思路适合作为课堂笔记之外的结构化补充材料。压缩包内共1个PDF文件体积约1.38MB核心内容集中在一份电子文档中便于离线阅读和速查。目前已有340人学习下载。文档不仅覆盖分类与回归场景下的网络构建逻辑还强调了梯度下降、参数更新等训练环节结合实际数据集分析训练集与测试集的用途可帮助读者系统地建立从公式到代码的完整认知。1. 深度前馈神经网络到底在解决什么问题从线性模型到万能逼近把一份 5000 条二手房价数据丢给线性回归欠拟合换成决策树又总觉得解释性不够。这时候很多人会想起机器学习里那个“理论上可以逼近任意函数”的家伙——深度前馈神经网络。它不依赖手工特征靠堆神经元和层数把输入到输出的映射硬拟合出来是当前深度学习里最常见的基础结构。这篇文章把原理、反向传播公式推导和 Python 实现一次讲完适合刚学完线性模型、正要把神经网络落地的初学者也适合期末复习到这块想理清公式的同学。你可以按章节直接复现出能训练、能验证的完整代码并且学会一套排查训练问题的方法。2. 前向传播与公式推导把反向传播四公式手推一遍很多人直接调框架把网络跑起来感觉一切正常但一旦遇到 loss 不降、梯度为 NaN 这类问题就无从下手。原因很简单你不清楚数据在每一层经历了什么。深度前馈神经网络的计算链路其实很短——每一层做一次线性变换再过一次非线性激活输出层算损失然后误差从后往前传。这一章就把这条链路拆开并且用和后续 NumPy 代码完全一致的符号来推导避免“公式一套、代码一套”的割裂感。2.1 为什么堆两层线性层等于一层非线性激活的必要性先做一个反直觉的推导假设我们不用激活函数两层线性变换叠加会变成什么样第一层输出 h xW1 b1第二层输出 y hW2 b2把 h 代入得到 y x(W1W2) (b1W2 b2)。换句话说两个线性层合并之后仍然是一个线性变换参数可以被压缩成一层。那不管网络堆多深表达能力和单层线性模型没有任何区别拟合非线性关系就无从谈起。所以“深度”真正依赖的是非线性激活函数。sigmoid、tanh、ReLU 这些激活函数把线性输出压成非线性信号让每一层都能学习到输入的不同抽象。西瓜书里把这一章讲得很透感知机只能处理线性可分问题而多层网络配合非线性激活后理论上可以逼近任意连续函数这被称为万能逼近定理。工程上有个重要的结论单隐层加足够多的神经元确实能拟合任意函数但“深而窄”的网络往往比“宽而浅”的网络参数效率更高也更容易泛化。2.2 前向传播的矩阵形式从输入到损失的完整链路为了和后面代码统一这里采用“行是样本、列是特征”的布局约定。假设一个批量输入 X 的形状是 (m, n_in)第 l 层的权重矩阵 Wl 形状是 (n_{l-1}, n_l)偏置 bl 形状是 (1, n_l)。前向传播的每一步写成矩阵式Zl A_{l-1} Wl bl Al σ(Zl)A0 就是原始输入 X最后一层 AL 是预测输出。以“3-4-4-1”网络为例输入有 3 个特征第一层有 4 个神经元第二层也是 4 个输出层 1 个。每一步的形状变化是 (m,3)→(m,4)→(m,4)→(m,1)权重维度分别是 (3,4)、(4,4)、(4,1)。这个维度自检习惯非常重要后面代码里几乎一半的 bug 都是矩阵形状对不上。损失函数的选择由任务决定回归任务常用均方误差二分类常用交叉熵。下面推导统一用二分类交叉熵因为它在和 sigmoid 组合时能推导出一个非常漂亮的结果。2.3 反向传播公式推导三行链式法则导出全部梯度反向传播本质上就是链式法则。先看输出层设真实标签为 y网络输出 a σ(z)。二分类交叉熵损失是 L -[y ln(1-a) (1-y) ln(1-a)]。先算 dL/da再算 da/dzdL/da (a-y) / (a(1-a)) da/dz a(1-a)两者相乘dL/dz a - y。所有中间项全部消掉了这就是为什么在实践中二分类任务输出层的梯度可以直接写成“预测值减真实值”。隐藏层的误差传播稍微绕一点。记 δl 为第 l 层线性输出 Zl 的梯度则 δ_l (δ_{l1} W_{l1}^T) ⊙ σ(Z_l)其中 ⊙ 是逐元素相乘。含义很直观把后一层的误差乘上权重矩阵的转置“映射”回当前层再乘上当前层激活函数的导数得到这一层线性输出的梯度。有了 δ参数梯度就非常直接∂L/∂Wl A_{l-1}^T δ_l ∂L/∂bl 每行累加 δ_l批量训练时把所有样本的梯度求平均再除以样本数 m 即可。整个递推从输出层开始逐层往前算写成一个循环就是反向传播的全部内容。2.4 参数更新与学习率SGD 的朴素形态梯度告诉了我们损失函数上升最快的方向所以要往反方向走。最常见的裸写法就是随机梯度下降W W - η · gradientη 是学习率。学习率几乎可以说是整个训练过程里最“玄学”的超参数设太大loss 会震荡甚至变成 NaN设太小训练几百轮都没明显变化。因为是梯度方向所以初学时最容易犯的错误就是更新时少写负号loss 不降反升。一个低成本改进是动量法维护一个速度变量 v βv gradient然后参数更新改为 W W - ηv。β 通常取 0.9。它能让梯度方向在连续几个 batch 里保持一致的维度加速前进同时抑制震荡。到这里网络的前向、反向、更新三个闭环已经齐了接下来就可以用 NumPy 把它们一一落地。3. 用 NumPy 从零实现三层前馈网络完整可跑的 Python 代码这一章的目标是写出一个不需要任何深度学习框架、只依赖 NumPy 就能训练的分类器。为了保证代码自包含我不用 scikit-learn 造数据而是用纯 NumPy 生成一个环形二分类数据集线性分类器拿它没办法但两三层前馈网络可以轻松分开。这种造数据方式还有一个好处——你完全清楚数据的结构后面排查问题时能排除“数据本身有问题”这个变量。3.1 造一个有区分度的环形二分类数据先定义数据生成函数总共三块逻辑采样半径和角度、转成平面坐标、按半径阈值打标签。最后做一步标准化把特征压到均值 0、方差 1 的分布上。import numpy as np def make_ring_data(n800, seed42): rng np.random.default_rng(seed) r rng.uniform(0.5, 3.0, sizen) theta rng.uniform(0, 2 * np.pi, sizen) x1 r * np.cos(theta) x2 r * np.sin(theta) y (r 1.8).astype(int) # 半径大于 1.8 的为正类 X np.stack([x1, x2], axis1) X (X - X.mean(axis0)) / X.std(axis0) return X, y这里用rng np.random.default_rng(seed)而不是老式的np.random.seed是 NumPy 1.17 之后的推荐做法过程确定性更好。标准化不是可选项半径和角度生成出的坐标虽然量纲一致但分布尺度仍然影响初始梯度的稳定性提前标准化能让后面训练更省心。标签生成用(r 1.8).astype(int)把布尔数组直接转成 0/1干净利落。3.2 网络初始化与激活函数选择网络结构定为 [2, 8, 8, 1]2 个输入特征、两层各 8 个隐藏神经元、1 个输出。隐藏层激活函数用 ReLU输出层用 sigmoid因为我们要做二分类。初始化方式采用 Xavier 初始化权重方差取2 / (fan_in fan_out)比标准正态分布小得多能有效避免激活值在深层网络里被放大或消散。def init_layers(layer_dims, seed42): rng np.random.default_rng(seed) params {} for l in range(1, len(layer_dims)): in_dim, out_dim layer_dims[l - 1], layer_dims[l] scale np.sqrt(2.0 / (in_dim out_dim)) params[W str(l)] rng.normal(0, scale, size(in_dim, out_dim)) params[b str(l)] np.zeros((1, out_dim)) return params def relu(z): return np.maximum(0, z) def relu_prime(z): return (z 0).astype(float) def sigmoid(z): return 1.0 / (1.0 np.exp(-z))偏置全部初始化为 0权重用小方差正态分布。np.maximum(0, z)是 ReLU 的最简实现relu_prime返回一个 0/1 掩码负区间梯度为 0。注意这里刻意没有用np.where之类的高阶写法因为反向传播里这个导数掩码本身就够用。3.3 前向与反向的 NumPy 实现前向传播按层迭代即可关键是把每一步的 Z 和 A 存进 caches反向传播要用。反向传播严格按照上一章推出的四行递推来写。def forward(X, params): caches {A0: X} L len(params) // 2 for l in range(1, L 1): W params[W str(l)] b params[b str(l)] Z caches[A str(l - 1)] W b if l L: A sigmoid(Z) else: A relu(Z) caches[Z str(l)] Z caches[A str(l)] A return A, caches def backward(y, caches, params): grads {} L len(params) // 2 m y.size A_L caches[A str(L)] dZ A_L - y.reshape(-1, 1) # 交叉熵 sigmoid 的合并梯度 for l in range(L, 0, -1): A_prev caches[A str(l - 1)] grads[dW str(l)] A_prev.T dZ / m grads[db str(l)] dZ.sum(axis0, keepdimsTrue) / m if l 1: dA dZ params[W str(l)].T dZ dA * relu_prime(caches[Z str(l - 1)]) return gradsforward的返回值有两个预测值 A 和中间缓存 caches。caches 必须同时存 Z 和 A因为反向传播里既需要激活值算权重梯度也需要 Z 算激活函数导数。backward里最值得解释的是第一行dZ A_L - y这是从交叉熵损失和 sigmoid 激活函数的导数约简后的结果并不是所有损失函数都这么简单。循环从最后一层往前推先算当前层的 dW、db再根据当前层的权重矩阵反推前一层的 dZ直到输入层为止。维度自检非常关键。对这个 [2, 8, 8, 1] 网络批大小取 800各缓存形状如下变量形状说明A0(800, 2)原始输入Z1 / A1(800, 8)第一隐藏层Z2 / A2(800, 8)第二隐藏层Z3 / A3(800, 1)输出层dW1(2, 8)第一层权重梯度dW2(8, 8)第二层权重梯度dW3(8, 1)输出层权重梯度如果训练时报矩阵乘法维度不匹配优先检查 A_prev dZ 的写法A_prev是 (m, n_in)dZ是 (m, n_out)结果正好是 (n_in, n_out)和权重的形状一一对应。3.4 训练循环与损失监控有了前向和反向训练循环就是一个三行循环算损失、算梯度、更新参数。这里用最简单的裸梯度下降没有加动量方便对照公式。def binary_cross_entropy(y, A): eps 1e-12 return -np.mean(y * np.log(A eps) (1 - y) * np.log(1 - A eps)) def train(X, y, layer_dims[2, 8, 8, 1], epochs2000, lr0.8): params init_layers(layer_dims) for i in range(1, epochs 1): A, caches forward(X, params) loss binary_cross_entropy(y, A) grads backward(y, caches, params) for l in range(1, len(layer_dims)): params[W str(l)] - lr * grads[dW str(l)] params[b str(l)] - lr * grads[db str(l)] if i % 200 0: print(fepoch {i:4d}, loss {loss:.6f}) return params损失函数里加eps 1e-12是为了防止 A 恰好取到 0 或 1 时log(0)报错这是交叉熵实现里的标准防坑写法。学习率 0.8 对这个数据规模和网络容量是合适的既不会发散又能在一千轮内把 loss 压到 0.1 以下。如果你把学习率改成 3.0大概率会看到 loss 直接变 NaN这是非常好用的一次“实验验证”——下章会专门讲这背后发生了什么。4. 深度前馈神经网络避坑指南训练不收敛的五类常见原因从手写代码切换到真实数据最痛苦的阶段不是写不出前向传播而是训练过程出现各种诡异现象loss 卡在某个值不动、直接变 NaN、训练集准但测试集不行。这里把五类最高频的问题按“现象 → 原因 → 解决”拆开每条都是能直接照做的排查经验。4.1 loss 变 nan学习率过大与初始化不匹配现象是训练跑到几百轮后 loss 突然变成nan或者第一轮就是inf。最常见的原因是学习率设置过大导致参数更新跨度过大权重被推到数值溢出区域其次是初始化方差太大网络一启动就进入饱和区梯度爆炸把数值冲垮。解决分三步先把学习率降到 0.010.1 量级重跑再把初始化方差改成 Xavier 或 He 初始化最后还不稳就在参数更新后加一行梯度裁剪比如np.clip(grad, -1.0, 1.0)把异常梯度限制在可控范围。梯度裁剪是工程里最直接的“后悔药”虽然不够优雅但能立刻止血。4.2 损失下降极慢sigmoid 饱和把梯度“抹平”现象是 loss 从初始值 0.69 降到 0.60 就极其缓慢几千轮都没什么起色。第一次遇到这个现象的人会怀疑是不是学习率太小实际上问题可能出在隐藏层用了 sigmoid 激活。sigmoid 在两端的导数接近 0一旦某层输出落在 ±2 之外梯度经过它就会被“抹平”这一层几乎学不动。解决方法是把隐藏层激活函数换成 ReLU正区间导数恒为 1梯度不会进入深度饱和状态。这件事在深层网络里尤为关键也是为什么深度学习框架默认 ReLU 族而不是 sigmoid 族的原因之一。4.3 训练准测试差过拟合的三个信号训练集 loss 降到 0.01验证集 loss 却在 0.4 附近徘徊这是过拟合的典型特征。判断过拟合有三个信号训练 loss 和验证 loss 开始反向走验证 loss 在训练过程中上下震荡训练集准确率无限接近 100%。原因很简单——模型容量大数据量小网络把训练样本的噪声也背下来了。解决思路有两个方向一是降低容量减少隐藏层神经元数二是加正则化比如早停、权重衰减。对初学者来说先做早停最划算每轮记录验证 loss连续 1020 轮不改善就停止训练恢复历史最佳参数。4.4 特征量纲差 100 倍没归一化导致的“窄峡谷”当输入特征里某个特征取值在 01 之间另一个在 010000 之间损失函数等高线会被拉成一个偏长的椭圆梯度下降会在峡谷壁上反复震荡收敛非常慢。这个现象和网络结构没任何关系纯粹是数据预处理问题。标准做法是把每个特征标准化成均值 0、方差 1。这里有一条血泪经验标准化用的均值和方差必须只从训练集计算然后把同一组数值套到验证集和测试集上不能把全部数据混在一起算否则会造成数据泄露测试指标虚高。4.5 ReLU 死神经元Dying ReLU 现象与应对现象更隐蔽loss 卡住不再下降打印权重发现某些隐藏层权重长期是 0 或接近 0。原因是 ReLU 在输入为负时导数恒为 0一旦神经元落入负区间梯度永远无法让它“复活”整个神经元就死了。学习率过大或初始化不当会加速这个过程。应对办法有三种把学习率调小一个量级把 ReLU 换成 LeakyReLU让负区间有很小的梯度或者在网络层间加批量归一化把每层输出重新拉回有效区间。实际工程里 Dying ReLU 很常见所以现代框架默认用 ReLU 时都会配合 BN 层。5. 训练之外的工程问题评估指标、调参顺序与正则化模型能收敛只是第一步离“能交付”还差得很远。一个常见误区是所有人都在调网络结构却没人认真定义“好”的标准。这一章讲清楚三件事怎么切数据、怎么选评估指标、怎么按顺序调参这些直接决定了投入的时间值不值。5.1 训练 / 验证 / 测试三切分指标别选错数据切分默认按 6:2:2 分成训练、验证、测试三份。训练集用来更新参数验证集用来选超参数和做早停测试集只在最后评估一次。很多人只切训练、测试两份然后把测试集反复用来调参结果测试集沦为验证集最终指标是不可信的。sklearn 里一行代码就能完成切分from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, random_state42 )指标选择要匹配任务二分类且类别均衡时准确率足够直观类别不均衡时看 precision、recall 和 AUC 更可靠。回归任务则用 MAE、MSE 或 R²不要拿准确率硬套。很多人跑完分类任务只看准确率不知道在 99% 负样本的数据集上全预测为负类也有 99% 准确率——这是机器学习入门时最容易踩的坑。5.2 调参顺序先修不收敛再谈提精度调参最忌讳一次性改三个超参数出现问题根本定位不到原因。我常用的顺序是先固定一个“肯定能训练”的配置——小学习率、中等等容量网络、ReLU 激活确认 loss 能稳定下降然后逐个调整网络结构例如宽度 8 → 16 → 32接着调学习率最后才考虑正则化和优化器。吴恩达在机器学习课程里反复强调同一个原则一次只改一个变量并用训练曲线看效果。调整网络宽度时观察训练 loss 和验证 loss 的差距如果训练和验证 loss 都高是欠拟合需要增大容量或降低正则化如果训练低验证高是过拟合需要减小容量或增加正则化。这个“差距诊断法”比盯着准确率数值管用得多。5.3 正则化三板斧权重衰减、早停、Dropout权重衰减就是 L2 正则化在损失函数里加一项 λ/(2m) Σ||W||²参数更新时把当前权重的一部分“拉向原点”。在手写实现里改动很小更新循环里加一行即可lam 0.01 params[W str(l)] - lr * ( grads[dW str(l)] lam * params[W str(l)] / m )早停是监控验证 loss连续 N 轮不下降就停止并回滚到历史最优参数。Dropout 则在训练阶段以概率 p 随机丢弃一部分神经元预测阶段不丢弃相当于在训练时隐式地训练了很多个共享参数的子网络。手写 Dropout 需要额外维护掩码工程上一般直接用现成框架的 Dropout 层原理清楚就够了。三种方法可以叠加但优先级有讲究先做早停因为它基本没有副作用再考虑权重衰减最后上 Dropout。不要一上来就全套正则化否则很难判断哪个手段真正起了作用。5.4 学习率粗扫用最小实现成本找合理区间学习率是最难拍脑袋定的超参数与其猜不如跑一组短训练直接看结果。常见的做法是对一组候选学习率各训练 200300 轮记录最终 loss选最小的那个进入精调阶段。代码只需要在前面 train 函数外面包一层循环。def lr_scan(X, y, lrs[0.01, 0.05, 0.1, 0.5, 1.0], epochs300): for lr in lrs: params init_layers([2, 8, 8, 1]) for i in range(epochs): A, caches forward(X, params) grads backward(y, caches, params) for l in range(1, 4): params[W str(l)] - lr * grads[dW str(l)] params[b str(l)] - lr * grads[db str(l)] loss binary_cross_entropy(y, forward(X, params)[0]) print(flr{lr:.2f}, final loss{loss:.6f})粗扫的目的是找量级不是精确值。如果 0.01 和 0.1 结果差距很大就再补一组 0.03、0.06 细化如果 0.5 和 1.0 已经发散说明上界清楚了。优化器的选择也会影响结果柱式 SGD 慢但稳动量收敛快一点Adam 在大多数任务上最省心但很多调参老手最后会切回 SGD 精调。它们没有绝对的优劣只有“当前阶段适不适合”的区别。6. 梯度检查给反向传播实现装一个错误探测器反向传播是最容易写错又最难发现错误的部分——loss 在下降你无法判断这是算法的功劳还是代码碰巧跑对。这里有一个几乎零成本的验证手法用数值差分近似梯度再和反向传播算出的解析梯度对比。如果两者一致反向传播基本可以放心。数值梯度的原理来自导数的定义f(θ) ≈ (f(θε) - f(θ-ε)) / 2εε 取 1e-5 比较合适。实现时把网络所有参数拼成一个一维向量对每个位置单独扰动算两次 loss 取差分。def numerical_gradient(f, theta, eps1e-5): grads np.zeros_like(theta) it np.nditer(theta, flags[multi_index]) while not it.finished: idx it.multi_index old theta[idx] theta[idx] old eps loss_plus f(theta) theta[idx] old - eps loss_minus f(theta) theta[idx] old grads[idx] (loss_plus - loss_minus) / (2 * eps) it.iternext() return grads对比时不能用绝对误差因为量纲不同一般用相对误差err np.linalg.norm(grad_analytic - grad_numeric) / ( np.linalg.norm(grad_analytic) np.linalg.norm(grad_numeric) )阈值的经验值是小于 1e-7实现非常精确小于 1e-5通常可接受大于 1e-3必然有 bug。调试时要用极小数据集比如 510 个样本并把网络宽度调小否则数值差分会慢到让你怀疑人生。如果网络里有 Dropout 这类随机层梯度检查时先关掉。我印象最深的一次事故是输出层忘了乘 sigmoid 的导数loss 卡在 0.34 怎么都降不下去梯度检查一跑误差到了 1e-1问题立刻暴露。反向传播对很多人来说像个黑匣子但梯度检查能把这个黑匣子点亮希望这个手法能帮你也少走一次弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?