简介:这份机器学习系列PDF面向希望系统理解深度前馈神经网络的学习者与入门开发者,围绕分类与回归任务,梳理从模型定义、变量约束到前向传播、反向传播的完整知识链条。内容以公式推导为主线,涵盖线性输出与激活函数、交叉熵损失与整体代价函数、链式法则求梯度、梯度下降更新及向量化表达,并结合Python实现展示网络构建、训练与评估思路。压缩包共1个PDF文件,大小约1.38MB,适合作为专题笔记随查随用。已有340人学习或下载,说明其在神经网络基础学习中具有一定参考价值。读者可借助其中的推导过程厘清参数更新细节,也可对照代码实现理解前向与反向计算如何落地,并进一步关注正则化、学习率调度和早停等提升模型性能的实践方向。
1. 深度前馈神经网络:为什么“前馈”和“深度”要分开理解
很多人第一次听到深度前馈神经网络,会下意识把“深度”和“前馈”当成对同一个网络的两种形容词。实际上这两个词描述的是两件正交的事:前馈说的是信息流方向——数据从输入层出发,沿着单向路径穿过隐藏层到达输出层,中间没有任何环或反馈连接;深度说的是网络的纵向结构——除去输入层之后还剩多少层线性变换与激活函数交替复合。把这两件事拆开看,这个模型就褪去了玄学外壳:它本质上是一串复合函数 $f(x;\theta)$,训练就是在参数空间里找一个让这串复合函数逼近数据映射的点,而 Python 实现则是把这串复合函数和它的梯度用矩阵运算写出来。搞懂“复合为什么能拟合任意映射”“梯度为什么能一层层回传”,比记住“多层感知机”这个别名有用得多,也能让后面看任何深度学习框架的源码时都不至于黑匣子。
2. 参数初始化与前向传播:矩阵形状如何决定网络能否跑起来
2.1 为什么权重矩阵必须随机初始化
先把一个反直觉的结论摆在前面:如果你把网络所有权重都初始化成 0,这个网络训练起来和只有一个神经元没有本质区别。原因在于对称性。对于某一层,如果 $W^{[l]}$ 的所有元素都是 0,那么该层每个神经元收到的线性输出 $z^{[l]}_i=0$,经过激活后 $a^{[l]}_i$ 也完全相同;反向传播时,每个神经元算出来的梯度也完全对称,更新之后权重依旧全部相等。不管这一层放多少个单元,它们学到的东西永远是一模一样的,模型容量被白白浪费。
所以权重必须用随机数打破对称性。常见的实现思路是用一个很小的正态分布来初始化:
import numpy as np def initialize_parameters_deep(layer_dims, seed=3): """ layer_dims: 列表,元素为每一层的单元数,例如 [2, 4, 1] 表示输入层 2 个特征、隐藏层 4 个单元、输出层 1 个单元 返回 parameters: 字典,包含 W1/b1 ... WL/bL """ np.random.seed(seed) parameters = {} L = len(layer_dims) # 层数(含输入层) for l in range(1, L): # 权重形状 (当前层单元数, 前一层单元数),小随机数打破对称 parameters['W' + str(l)] = np.random.randn( layer_dims[l], layer_dims[l-1]) * 0.01 # 偏置全零,形状 (当前层单元数, 1) parameters['b' + str(l)] = np.zeros((layer_dims[l], 1)) return parameters这里有两个参数值得单独说。layer_dims[l]和layer_dims[l-1]决定了权重矩阵 $W^{[l]}$ 的形状是 $(n^{[l]}, n^{[l-1]})$,这个形状是整个前向传播能否跑通的地基,后面所有矩阵乘法的合法性都建立在它上面。乘0.01是另一层取舍:如果初始化太大,激活值很容易落进 sigmoid 或 tanh 的饱和区,梯度接近 0,训练一开始就卡住;如果初始化太小,信号逐层相乘后会指数级衰减,深层网络几乎学不到东西。0.01是这版手写实现里的经验值,工程中更常用的是按 fan-in/fan-out 缩放的 Xavier 或 He 初始化。
2.2 偏置全零初始化与对称性
和权重不同,偏置 $b^{[l]}$ 可以放心地全零初始化。原因很直接:对称性已经被随机权重打破了。每个神经元拿到的权重不同,它们算出的线性输出就不同,偏置再保持一致也不会让神经元退化成同一个东西。偏置在这里的角色是平移,把激活函数的输入整体挪一挪,全零是一个中性且安全的起点。
这个细节常被新手忽略,以为“权重和偏置都不能为零”,结果连偏置也随机初始化,凭空引入一层噪声,反而拖慢收敛。对称性这件事只跟权重有关,偏置不在其列。
2.3 线性前向与激活函数叠加
单个线性单元做的事情可以写成 $z^{[l]}_i = w^{[l]}_i{}^\top a^{[l-1]} + b^{[l]}_i$,把一层所有单元向量化之后就是 $Z^{[l]} = W^{[l]}A^{[l-1]} + b^{[l]}$。这一步里矩阵的形状是 $(n^{[l]}, n^{[l-1]}) \times (n^{[l-1]}, m)$,乘完得到 $(n^{[l]}, m)$,再加形状为 $(n^{[l]}, 1)$ 的偏置,靠 NumPy 的广播机制自动扩展到 $(n^{[l]}, m)$,对每个样本都加上同一个偏置。理解这个广播链,是后面排查维度错误的前提。
线性输出之后必须过激活函数,否则多层线性变换叠起来还是一次线性变换,深度就失去了意义。隐藏层常用 ReLU,因为它的导数在正半轴恒为 1,反向传播时不会像 sigmoid 那样层层连乘后指数衰减;输出层用 sigmoid,把值压到 0 到 1 之间,直接对应二分类的概率。把这两步封装起来就是:
def linear_forward(A_prev, W, b): """线性前向:Z = W @ A_prev + b,并把中间量存入 cache 供反向使用""" Z = np.dot(W, A_prev) + b cache = (A_prev, W, b) # 反向传播时要拿 A_prev 和 W 算梯度 return Z, cache def linear_activation_forward(A_prev, W, b, activation): """线性 + 激活:返回激活后的 A 和用于反向的 (linear_cache, activation_cache)""" Z, linear_cache = linear_forward(A_prev, W, b) if activation == "sigmoid": A, activation_cache = sigmoid(Z) # activation_cache 里存 Z else: A, activation_cache = relu(Z) cache = (linear_cache, activation_cache) return A, cachecache的设计动机很明确:反向传播算 $dW^{[l]}$ 需要前向时的 $A^{[l-1]}$,算 $dZ^{[l]}$ 的激活导数需要前向时的 $Z^{[l]}$,如果前向时不存,反向时要么重算前向、要么根本拿不到。用空间换时间是这一整套实现的骨架。整个 L 层前向的堆叠方式是:前 L−1 层用[LINEAR -> RELU],最后一层用[LINEAR -> SIGMOID],逐层把上一层的输出喂给下一层,同时把每层的 cache 收进一个列表,留给反向传播按逆序取用。
3. 反向传播的链式法则:梯度是怎么一层层“漏”回去的
3.1 代价函数与输出层误差
反向传播的起点是输出层的误差。二分类常用交叉熵作为代价函数,单个样本的损失是 $L(\hat y, y) = -[y\log\hat y + (1-y)\log(1-\hat y)]$,整个训练集取均值就是 $J = -\frac{1}{m}\sum_{i=1}^{m}[y^{(i)}\log a^{ L } + (1-y^{(i)})\log(1-a^{ L })]$。之所以选交叉熵而不是均方误差,因为它对输出层误差的导数形式干净:配合 sigmoid,$dA^{[L]}$ 化简后是 $-\left(\frac{Y}{A^{[L]}} - \frac{1-Y}{1-A^{[L]}}\right)$,没有额外的激活导数因子,起步梯度不会被 sigmoid 的饱和区压扁。
这一步不要死记公式,用链式法则自己推一遍会更踏实:$J$ 对 $a^{[L]}$ 求导,得到上面那一项;再乘上 sigmoid 的导数,两者结合后多项恰好约掉,最终剩余形式非常简洁。这个“恰好约掉”就是交叉熵与 sigmoid 搭配被称为标准组合的原因。
3.2 层间误差递推与缓存机制
从输出层往前走,每一层的梯度递推依赖三条关系。第一条是误差回传:第 $l$ 层的 $dA^{[l]}$ 等于下一层权重矩阵的转置乘下一层的 $dZ^{[l+1]}$,即 $dA^{[l]} = W^{[l+1]\top}dZ^{[l+1]}$。第二条是激活反向:$dZ^{[l]} = dA^{[l]} \ast g'(Z^{[l]})$,其中 $\ast$ 表示逐元素相乘,$g'$ 要用前向缓存的 $Z^{[l]}$ 来算。第三条是参数梯度:$dW^{[l]} = \frac{1}{m}dZ^{[l]}A^{[l-1]\top}$,$db^{[l]} = \frac{1}{m}\sum_{i=1}^{m}dZ^{ l }$。
把这三条串起来看,梯度之所以能从输出层一路“漏”回第一层,靠的完全是链式法则的复合。每一层在回传时只需要两样东西:上一层传来的 $dA^{[l]}$,以及自己前向时缓存的 $Z^{[l]}$、$A^{[l-1]}$、$W^{[l]}$。这也是为什么每一层的前向和反向必须严格配对——缺一个 cache,这一层的梯度就算不出来。常见的实现思路是把前向的 cache 当成“后悔药”存起来,反向时再按逆序一条条取出来用。
3.3 向量化实现细节
反向传播的向量化版本把所有样本的梯度一次性算完,避免了显式写 m 次循环。核心代码是三层嵌套函数,从最细的线性反向开始组合:
def linear_backward(dZ, cache): """根据 dZ 和线性前向的 cache 求 dA_prev, dW, db""" A_prev, W, b = cache m = A_prev.shape[1] # 样本数 dW = np.dot(dZ, A_prev.T) / m # (n_l, n_{l-1}) db = np.sum(dZ, axis=1, keepdims=True) / m # (n_l, 1) dA_prev = np.dot(W.T, dZ) # (n_{l-1}, m) return dA_prev, dW, db def linear_activation_backward(dA, cache, activation): """先过激活函数反传,再过线性反传""" linear_cache, activation_cache = cache if activation == "relu": dZ = relu_backward(dA, activation_cache) # dA * (Z > 0) else: dZ = sigmoid_backward(dA, activation_cache) # dA * sigmoid'(Z) return linear_backward(dZ, linear_cache) def L_model_backward(AL, Y, caches): """从输出层开始,逆序回传每一层""" grads = {} L = len(caches) Y = Y.reshape(AL.shape) # 交叉熵 + sigmoid 的输出层误差 dAL = -(np.divide(Y, AL) - np.divide(1 - Y, 1 - AL)) current_cache = caches[L - 1] grads["dA" + str(L)], grads["dW" + str(L)], grads["db" + str(L)] = \ linear_activation_backward(dAL, current_cache, "sigmoid") # 逆序回传,注意 dA 的下标是 l+2、参数下标是 l+1 for l in reversed(range(L - 1)): current_cache = caches[l] dA_prev_temp, dW_temp, db_temp = \ linear_activation_backward(grads["dA" + str(l + 2)], current_cache, "relu") grads["dA" + str(l + 1)] = dA_prev_temp grads["dW" + str(l + 1)] = dW_temp grads["db" + str(l + 1)] = db_temp return gradslinear_backward里的三行对应三条递推关系:dW是 $dZ$ 和 $A^{[l-1]}$ 的内积再除以样本数,db是把 $dZ$ 沿样本维求和再除以样本数,dA_prev是 $W^\top dZ$。除以m是因为交叉熵代价函数取的是均值,平均化让梯度量级和样本数解耦,学习率不用随样本数反复调。L_model_backward里那个l+2和l+1的下标错位是新手最常见的翻车点:grads["dA" + str(l + 2)]指的是第 $l+2$ 层的 dA(因为循环从L-1开始,反向传给第 $l+1$ 层时,dA 已经存到了l+2的位置),而参数存的是当前第 $l+1$ 层。搞清这个下标,反向传播的代码就不再像一团乱麻。
4. 代价函数选择:为什么分类任务用交叉熵而不是均方误差
4.1 交叉熵与最大似然准则的等价性
一个常见疑问是:均方误差看起来更直观,为什么分类网络普遍不用它?答案有两层。第一层是收敛速度。均方误差配 sigmoid 时,输出层误差项会额外乘上一个 sigmoid 的导数 $a(1-a)$,当预测值接近 0 或 1 时这个因子趋近 0,梯度被压得很小,训练几乎停滞;交叉熵配 sigmoid 时那个因子被约掉,梯度形式干净,收敛明显更快。第二层是凸性。均方误差配 sigmoid 的代价函数整体未必是凸函数,梯度下降可能收敛到非最优的局部极小点;而交叉熵形式下,整个优化地形更友好。
更本质的解释是最大似然。最小化交叉熵等价于最大化训练集在模型分布下的似然,也就是让网络输出的概率分布尽量贴近真实标签分布。从信息论角度看,交叉熵度量的是两个分布的差异,最小化它就是在缩小预测分布和真实分布的距离。这个视角比“算误差”更接近分类任务的目标——我们本来要的就是概率,不是回归数值。
4.2 梯度下降更新规则与学习率
梯度算出来之后,参数更新规则是 $W^{[l]} := W^{[l]} - \alpha, dW^{[l]}$,$b^{[l]} := b^{[l]} - \alpha, db^{[l]}$。负号的原因值得说清楚:梯度指向函数值上升最快的方向,要最小化代价,就得往梯度的反方向走。$\alpha$ 是学习率,控制每一步走多远。步子太小,收敛慢得要命;步子太大,会在山谷两侧来回震荡甚至发散。
def update_parameters(parameters, grads, learning_rate): """按梯度下降规则更新所有 W 和 b""" L = len(parameters) // 2 # 参数字典里 W 和 b 各占一半 for l in range(L): parameters["W" + str(l + 1)] -= learning_rate * grads["dW" + str(l + 1)] parameters["b" + str(l + 1)] -= learning_rate * grads["db" + str(l + 1)] return parameterslen(parameters) // 2是因为字典里每层有W和b两个键,总键数除以 2 才是层数。更新用-=原地修改,避免多占内存。把初始化、前向、代价、反向、更新这五步套进一个循环,网络就开始训练了。
4.3 过拟合与欠拟合的应对
训练集上的代价降下来,不代表网络学到了东西。如果训练集准确率很高但测试集明显掉队,就是过拟合,模型把训练样本的噪声也背了下来;如果训练集本身就上不去,就是欠拟合,模型容量不够或特征不足。这两种情况的处理方向完全相反。
过拟合时,可以增加训练数据、加 L2 正则化、用 dropout 随机丢弃一部分神经元、或者适当减小网络层数和隐藏单元数,让模型别那么“能背”。欠拟合时,反过来要增加特征、增加网络深度或隐藏层宽度,给模型更多容量。这个判断本身不神秘,难的是先能稳定地把训练跑起来——如果代价函数本身震荡不降,谈过拟合还为时过早,得先回头排查学习率和初始化。
5. 常见问题与排查:梯度消失、维度不匹配与初始化异常
5.1 损失不降反升:学习率过大导致震荡
现象:训练几个 epoch 之后,代价函数不但不降,反而上下跳动甚至越来越大。原因通常是学习率取得太大,每一步更新跨过了代价函数谷底,在两侧来回弹,越弹越远。解决方式是把学习率调小一个量级再跑,或者用学习率衰减策略让 $\alpha$ 随训练轮次逐步降低。判断方法很直接:把 $\alpha$ 缩小 10 倍,如果损失开始稳定下降,基本可以确认是步子太大。
5.2 梯度消失:sigmoid 饱和区的链式乘积衰减
现象:损失缓慢下降或几乎不动,打印各层梯度发现靠前的层 $dW$ 接近 0。原因是隐藏层用了 sigmoid,它的导数最大只有 0.25,反向传播时每过一层就乘一次小于 1 的因子,层数一多,梯度指数级衰减,浅层几乎收不到更新信号。解决思路是把隐藏层激活换成 ReLU,它在正半轴导数恒为 1,不衰减;或者引入归一化层、残差连接来给梯度提供更短的回传路径。
5.3 维度不匹配报错:矩阵形状与样本数
现象:运行到np.dot时报shapes not aligned。原因几乎总是权重矩阵和激活矩阵的转置关系搞反了。正确的约定是 $W^{[l]}$ 形状 $(n^{[l]}, n^{[l-1]})$、$A^{[l-1]}$ 形状 $(n^{[l-1]}, m)$,前向是W @ A_prev;反向算dA_prev时是W.T @ dZ,算dW时是dZ @ A_prev.T。每次报错先把三个矩阵的.shape打出来,对照这个约定逐一核对,比盯着报错猜快得多。
5.4 权重全零初始化:对称性导致的训练停滞
现象:训练一开始损失就卡在一个值不动,梯度打印出来始终是 0 或全相等。原因是权重全零初始化,同层神经元完全对称,前向输出一样、反向梯度一样,更新后还是全相等,网络退化成单神经元。解决方式就是回到 2.1 节,用小的随机正态分布初始化权重,偏置才保持全零。这个坑在自定义初始化时最容易踩,因为代码不报错,只是默默不学。
6. 设计取舍:为什么缓存要留、为什么初始化要小
整套手写实现里最值得回味的两个设计取舍,恰好对应空间和尺度的权衡。
第一个是 cache 的取舍。前向传播时把每层的 $Z^{[l]}$、$A^{[l-1]}$、$W^{[l]}$ 全存下来,会占用可观内存,尤其是深网络加大 batch 的时候。但如果不存,反向传播要么重算前向(时间翻倍),要么根本拿不到激活导数需要的 $Z$。所以主流的实现思路一律是“存下来换时间”,这是深度学习框架里 autograd 引擎保存计算图中间节点的同一个动机。我一般会这么理解:cache 就是前向计算留下的后悔药,反向传播每一步都在翻旧账,没账本就没法对账。
第二个是初始化尺度的取舍。0.01这个系数不是随手写的。它要让初始激活值离激活函数的饱和区足够远,保证起步梯度不太小;但也不能太小,否则逐层相乘后信号衰减到几乎为零。这个矛盾在深网络里尤其尖锐,所以工程上发展出了按每层输入输出维度缩放的初始化方法,把尺度问题从“拍一个常数”变成“按结构自适应”。手写这版用固定 0.01,是为了让公式和代码的对应关系更清爽,不是因为它对所有网络都最优。
验证你的理解是否到位,有个很朴素的办法:故意把初始化改成全零、把学习率调大 100 倍、把某一层W转置一下,看损失和梯度的表现是否和上面 5.1 到 5.4 描述的一致。能稳定复现这些异常并定位原因,说明前向、反向、更新这三条链路是真的打通了,而不是只把代码跑通。从那以后我每次手写一版网络,都会先做这三个破坏性测试再去看准确率,因为一个能在正常数据上跑出高分的实现,未必经得起一次全零初始化的拷问。这份资源里把原理解释、公式推导和逐函数补全的 Python 实现放在一起,正好适合拿来做这种对照式深挖——把公式和代码一行行对上,比读十篇概述都管用。希望帮到你。
本文还有配套的精品资源,点击获取