☰
深度前馈神经网络:从反向传播推导到Python实现
2026/10/7 10:58:48 网站建设 项目流程

简介:这是一份机器学习入门学习资料,完整讲解深度前馈神经网络(DFNN)的原理与Python实现。资料从网络定义、变量约束讲起,逐步展开前向传播、反向传播、梯度下降等核心机制,并附有详细的公式推导过程,包括交叉熵损失与链式法则求导,读者可对照推导理解底层逻辑。包内为1个pdf文档,大小约1.38MB,内容结构紧凑,覆盖从原理解释到代码实现的完整链路,适合正在学习神经网络基础、希望弄懂反向传播数学推导的初学者或开发者。目前已有340人学习浏览,是快速建立深度前馈网络知识框架的实用参考。

1. 深度前馈神经网络:一份能把推导和 Python 实现串起来的学习资源

做分类任务做到第二个隐藏层的深度前馈神经网络时,我发现大部分教程缺一个环节:公式推导和代码实现是两拨人各自完成的。这次拆的这份资源,主线是深度前馈神经网络,前半部分把前向传播、反向传播的数学推导按步骤写清楚,后半部分把 Andrew Ng 深度学习课程里的作业函数框架用 Python 补全,跑得动、改得动、检查起来也方便。适合正在学机器学习推导又急需落地代码的人,也适合准备面试前快速复习一遍网络参数更新流程的从业者。它不是包打天下的框架,而是帮你在“看懂公式”和“写出代码”之间搭一座桥。

2. 先把模型定义说清楚:变量约束、前向传播和“深度”的边界

2.1 前馈、深度、网络三个词分别约束了什么

前馈神经网络也叫多层感知机,它的目标很直白:用一个参数化函数拟合输入和输出之间的映射。这个参数化函数记为 f(x; θ),θ 就是各层的 W 和 b。网络在结构上分为输入层、隐藏层、输出层,权重只负责相邻两层之间的连接。资源里反复强调“前馈”“深度”“网络”分别指什么:

  • “前馈”指数据从输入到输出单向流动,网络里没有反馈连接,没有循环结构,不是 RNN。
  • “深度”指除去输入层之后的层数 L,并不把输入层算进去。
  • “网络”指它由多个函数复合而成,每一层就是在做一个线性变换加一个非线性激活。

我一开始比较困惑的是“L”和“隐藏层数”的关系。资源里定义了 n_x 是输入层特征数,n[1] 到 n[L-1] 是隐藏层单元数,n[L] 是输出层单元数。所以一个 3 层网络指的是 2 个隐藏层加 1 个输出层,输入层不参与计数。很多文章把输入层也算进去,导致阅读代码的时候对不齐层数,这一点建议先固定下来再往下看。

对从业者来说,这个定义直接决定了后面初始化数组的长度。如果 layer_dims 是 [12288, 20, 7, 5, 1],那 L 等于 4,len(layer_dims) 减去 1 才是网络层数。我总是先在纸上写下每一层的 n[l] 再写代码,不然维度推导全是乱的。

2.2 变量约束:先把每一层的维度列明白

资源里定义了一套完整符号,读代码前最好先过一遍:

符号含义常用形状
n[0] 或 n_x输入层特征数(n_x, m)
n[l]第 l 层单元个数标量
z[l]_i第 l 层第 i 个线性输出(n[l], m)
a[l]_i第 l 层第 i 个激活输出(n[l], m)
W[l]第 l 层权重矩阵(n[l], n[l-1])
b[l]第 l 层偏置向量(n[l], 1)
m 或 M样本数量标量

这里最需要注意的是 W[l] 的行列顺序。资源里的定义是 W[l] 的形状为 (n[l], n[l-1]),也就是当前层单元数在前,上一层单元数在后。这样写前向传播的时候 Z[l] = W[l]·A[l-1] + b[l] 直接能算,不需要转置。如果哪篇资料用的是 (n[l-1], n[l]),整个推导都要跟着改,所以抄公式前先看行列定义。

还有一个容易忽略的点:a[l] 和 z[l] 都用下标 i 区分神经元,上标区分层数;样本序号 m 放在另一个位置。资源里把样本序号写成 z l ,其实是强调每个样本都会走一遍同样计算。在向量化实现里,我们不需要 for m 循环,直接把所有样本放进矩阵的列里。所以 A[l-1] 的形状是 (n[l-1], m),不是 (n[l-1], 1)。很多人第一次写 np.dot(W, A) 时把 A 传成单样本的 (n,1),结果 m 个样本只能写循环,速度慢一个量级。

2.3 前向传播:一个先线性后非线性的嵌套过程

前向传播不神秘,就是把输入经过每一层的线性变换和激活函数,最后得到输出。计算公式:

Z[l] = W[l]·A[l-1] + b[l] A[l] = g(Z[l])

其中 g 是激活函数,资源里主要用到 sigmoid、tanh、ReLU。输入层特殊处理为 A[0] = X,输出层写成 A[L]。

这套写法的意思是:每一层先做一次线性变换,再做一次非线性激活。如果没有激活函数,多层线性变换最终等价于一层线性变换,网络再深也没意义。所以在深度前馈神经网络这个标题下,重要的不是层数多,而是每一层都夹入非线性。

为了直观,我一般会把前向传播对应的代码先写成最朴素的版本:

def linear_forward(A, W, b): Z = np.dot(W, A) + b cache = (A, W, b) return Z, cache

这里 np.dot(W, A) 完成矩阵乘法,要求 W 的形状是 (n[l], n[l-1]),A 的形状是 (n[l-1], m)。b 的形状是 (n[l], 1),numpy 的广播机制会自动加到每一列上,也就是所有样本共享同一个偏置。

cache 里存下 A、W、b 三个量,目的是给反向传播用。反向求 dW 时需要 A[l-1],求 dA[l-1] 时需要 W[l] 和 dZ[l],这些中间量如果不在前向阶段缓存,反向阶段就只能重算,白白浪费一遍计算。资源里把所有 forward 函数都设计成“返回结果加 cache”,这是模仿工程化实现的做法,也方便逐层检查中间状态。

简单来说,前向传播在资源的整体结构里是这样的路径:INPUT -> [LINEAR -> RELU] 重复 L-1 次 -> LINEAR -> SIGMOID -> OUTPUT。隐藏层统一用 ReLU,输出层用 sigmoid。这个结构会在后续 Python 实现里原样出现。

3. 反向传播推导:从交叉熵到梯度下降的四个关键式子

3.1 为什么交叉熵取代均方误差

反向传播的前提是有一个可以求导的代价函数。资源里介绍损失函数时特别提醒:均方误差在逻辑回归场景下会出问题。单个样本的交叉熵损失定义为:

L(yhat, y) = -[ y·log(yhat) + (1-y)·log(1-yhat) ]

这里的 yhat 就是网络的最终输出 A[L],取值范围在 0 到 1 之间。对整个数据集,代价函数写成所有样本损失的平均:

J(X, Y; W, b) = -(1/M) · Σ [ y(m)·log(a L ) + (1-y(m))·log(1-a L ) ]

为什么不用均方误差?两个原因。第一,均方误差对 sigmoid 输出的梯度在输出接近 0 或 1 时会变得很小,因为 sigmoid 导数是 s(1-s),两端趋近 0,梯度被压住后参数更新极慢。第二,均方误差对应的代价函数不一定是凸函数,直接用梯度下降可能收敛到某个局部极小点,而不是全局最小。交叉熵配合 sigmoid 时,输出层的梯度形式更干净,这也让反向传播实现起来更简单。

这里要顺手记一笔:交叉熵最小化在概率意义上等效于极大似然估计。分类问题本身就是在估计条件概率 P(y|x),所以用交叉熵比用均方误差更贴合模型输出的含义。

3.2 链式法则:把 dA 传回 dW、db 的推导

反向传播的目标是求出代价函数 J 对每一层 W[l] 和 b[l] 的偏导数。因为前向传播是逐层嵌套的复合函数,求导必须用链式法则从输出层往回推。

定义 dZ[l] = ∂J/∂Z[l],dA[l] = ∂J/∂A[l]。根据链式法则:

dZ[l] = dA[l] ⊙ g'(Z[l])

这里的 ⊙ 表示矩阵对应元素相乘。g'(Z[l]) 是激活函数在 Z[l] 处的导数,对 sigmoid 是 g(Z)·(1 - g(Z)),对 ReLU 是 Z 大于等于 0 时取 1,否则取 0。

得到 dZ[l] 之后,同一层的参数梯度可以写出来:

dW[l] = (1/M) · dZ[l] · A[l-1]^T db[l] = (1/M) · Σ_m dZ[l] 按样本维度求平均

再把梯度传到上一层:

dA[l-1] = W[l]^T · dZ[l]

从推导上看,资源里给出的四个式子正好对应 dZ、dW、db、dA_prev 的更新路径。需要强调的是,输出层的 dA[L] 不以这四式为起点,而是直接由损失函数求导得到。对单个样本的交叉熵配合 sigmoid,简化之后 dZ[L] = A[L] - y;对 M 个样本,最后是 dZ[L] = A[L] - Y。这个简化式在实践中非常常见,但前提是前向输出层的激活函数确实是 sigmoid,损失函数确实是交叉熵。

3.3 梯度下降更新:方向、学习率与停止条件

拿到全部偏导数,目标是最小化 J。正规方程法虽然在数学上可以一步求出解析解,但矩阵不一定可逆,而且对多层网络几乎不适用,所以资源里选择梯度下降:

W[l] = W[l] - α · dW[l] b[l] = b[l] - α · db[l]

这里 α 是学习率。梯度的方向是函数值上升最快的方向,取负号沿梯度负方向更新,是确定的下降方向。实际执行时,通常先算前向传播得到损失,再算反向传播得到所有梯度,最后统一更新参数。这个顺序不能反:更新后的参数必须用于下一轮前向传播,而不是继续用旧参数计算梯度。

停止条件也不是只有损失为零一条路。资源里写到“当损失为 0 或者达到目标值时停止”,实际操作中多数情况是损失下降到平台期、验证集指标不再变好,或者达到预设迭代次数。要求损失严格等于 0 很容易走到过拟合,这一点放到后面的排查章节细说。

4. Python 实现:初始化、前向模块和反向模块怎么组合

这套代码的框架来自 Andrew Ng 在 Coursera 深度学习课程的作业,由资源作者补全。如果你想对照完整函数和测试用例,GitHub 仓库里就是全套:https://github.com/LSayhi/DeepLearning/tree/master/Coursera-deeplearning深度学习。学习用途没问题,但如果还在写同一门课的作业,不建议直接复制提交,自己敲一遍收获更大。

4.1 参数初始化:randn×0.01 与 zeros 的搭配

实现的第一步是初始化参数。资源里对不同网络结构写了好几个版本,但核心逻辑只有一条:

def initialize_parameters_deep(layer_dims): np.random.seed(3) 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 parameters['b' + str(l)] = np.zeros((layer_dims[l], 1)) return parameters

np.random.seed(3) 固定随机种子,确保每次运行得到同样的初始权重。排错时这一点很有用,因为你能复现上一次的结果,不用怀疑是随机性带来的波动。

W 用 np.random.randn 生成标准正态分布随机数,再统一乘 0.01。乘 0.01 是为了让初始权重集中在零附近。如果初始权重太大,线性输出 Z 的值会很大,sigmoid 进入饱和区,梯度直接消失;ReLU 层也可能输出大片负数,神经元死亡,反向传播传不回有效梯度。

b 用 np.zeros 初始化为零向量。因为即使所有偏置都是零,各层仍然能通过不同的 W 打破对称性。如果把 W 也全部初始化为零,所有神经元就会同步更新,网络退化成一个线性模型。

这里建议保留维度断言:

assert(parameters['W' + str(l)].shape == (layer_dims[l], layer_dims[l-1]))

初始化代码的维度错误通常要等到前向传播 np.dot 才暴露,报错信息追到具体哪一层会比较费时间,不如在这里先拦住。

4.2 前向模块:LINEAR->RELU 和 LINEAR->SIGMOID 的串接方式

前向传播的实现思路是先把“线性计算”和“激活计算”拆成两个函数,再组合。最底层是 linear_forward:

def linear_forward(A, W, b): Z = np.dot(W, A) + b cache = (A, W, b) return Z, cache

组合层根据激活函数类型,把 linear_forward 与 relu 或 sigmoid 接起来:

def linear_activation_forward(A_prev, W, b, activation): if activation == "relu": Z, linear_cache = linear_forward(A_prev, W, b) A, activation_cache = relu(Z) elif activation == "sigmoid": Z, linear_cache = linear_forward(A_prev, W, b) A, activation_cache = sigmoid(Z) cache = (linear_cache, activation_cache) return A, cache

cache 里第一个元素 linear_cache 存 A_prev、W、b,第二个元素 activation_cache 存 Z,反向传播时两者都要用,所以必须分开存,不能合并成一个元组。

把隐藏层和输出层串起来,就是 L_model_forward:

def L_model_forward(X, parameters): caches = [] A = X L = len(parameters) // 2 for l in range(1, L): A_prev = A A, cache = linear_activation_forward(A_prev, parameters['W' + str(l)], parameters['b' + str(l)], "relu") caches.append(cache) AL, cache = linear_activation_forward(A, parameters['W' + str(L)], parameters['b' + str(L)], "sigmoid") caches.append(cache) return AL, caches

L = len(parameters) // 2 是因为 parameters 里每个层有一对 W 和 b,字典数量是 2L。这个写法比单独传一个 layer_dims 更省事,也不容易写错层数。

前 L-1 层统一用 ReLU,最后一层用 sigmoid。隐藏层用 ReLU 是为了缓解梯度消失,同时计算速度快;输出层用 sigmoid 是为了把输出压缩到 0 到 1 之间,直接当作二分类的概率。如果你的任务不是二分类,最后一层的 sigmoid 要换掉,这个问题留到第 6 章展开。

4.3 反向模块:用 cache 把梯度一层层算回来

反向传播实现刚好是前向的逆过程。先看单层线性反向:

def linear_backward(dZ, cache): A_prev, W, b = cache m = A_prev.shape[1] dW = np.dot(dZ, A_prev.T) / m db = np.sum(dZ, axis=1, keepdims=True) / m dA_prev = np.dot(W.T, dZ) return dA_prev, dW, db

dW 的公式是 dZ·A_prev^T 再除以样本数 m,这样得到的是整个数据集的平均梯度。db 要对所有样本的 dZ 求和再平均,axis=1 表示按特征维度求和,keepdims=True 保留列向量形状,方便后续广播。dA_prev 传给上一层继续算梯度。

组合层反向:

def linear_activation_backward(dA, cache, activation): linear_cache, activation_cache = cache if activation == "relu": dZ = relu_backward(dA, activation_cache) elif activation == "sigmoid": dZ = sigmoid_backward(dA, activation_cache) dA_prev, dW, db = linear_backward(dZ, linear_cache) return dA_prev, dW, db

这里先调用 relu_backward 或 sigmoid_backward 把 dA 转成 dZ,再交给 linear_backward 算参数梯度。最终汇总成 L_model_backward,从输出层开始,按 L-1 层到第 1 层的顺序循环调用,并把每个 dW、db 存进 grads 字典。

反向传播的起点是输出层 dAL。交叉熵配 sigmoid 时,dAL 可以写成:

dAL = - (np.divide(Y, AL) - np.divide(1 - Y, 1 - AL))

然后用 sigmoid_backward 得到 dZ[L],这个式子在数学上会简化为 AL - Y,但为了保留损失函数的可解释性,很多实现仍然从 dAL 开始,而不是直接写 AL - Y。两种写法训练结果是等价的,只是前者在你换成其他损失函数时改动更小。

5. 常见问题与避坑:训练不收敛时的五个排查点

5.1 loss 输出 NaN,训练直接崩掉

现象:训练十几轮后 loss 变成 nan,后面重新运行也可能在同一个位置复现。

原因:最常见的是梯度爆炸。深层网络每一层反向传播都乘一个 W 和激活函数导数,如果初始化权重偏大或学习率偏大,梯度从输出层往输入层传,数值不断放大,权重更新后前向输出越界,log 或者除法里出现非法值。

解决:把初始化乘的系数从 0.01 再调小,或者改用 He 初始化;学习率从 0.001 开始试。如果输出层是交叉熵,还要检查 1 - AL 是否出现过接近 0 的值,给 log 套一个数值下限,比如 np.clip(..., 1e-8, 1 - 1e-8),这也是工程里的常见做法。

5.2 维度不匹配,forward 一直报错

现象:np.dot 报 shape not aligned,或者矩阵相乘结果形状异常。

原因:W 和 A 的维度定义不一致。资源里把 W[l] 定义成 (n[l], n[l-1]),但有些资料写成 (n[l-1], n[l]),混着看就会写反。

解决:在初始化函数里加 assert,再检查 cache 里的 A_prev 和 W 当前形状。调试时打印一下 W.shape 和 A.shape,左右两边的列数必须相等。这个坑我自己踩过,问题通常出在 layer_dims 传参顺序上,比如把 [4, 3, 1] 传成 [1, 3, 4],前向传播一步都走不了。

5.3 权重全部置零导致对称失效

现象:训练过程 loss 下降正常,但各层权重更新后完全一样,网络退化成线性模型。

原因:如果所有 W 初始为 0,反向传播会让同一隐藏层内所有神经元收到相同梯度,对称性永远打不破。无论迭代多少轮,每个神经元都在做同样的事,网络容量等于一个神经元。

解决:W 保持随机初始化,b 保持零初始化。资源里 randn×0.01 和 zeros 的组合就是标准答案。注意固定 random seed 可以复现,但不能因此不换随机数,否则每次实验都是同一组对称结构。

5.4 ReLU 层出现大片 0 值,梯度传不下去

现象:隐藏层激活值大量为 0,越靠近输入层的参数梯度接近 0,训练几乎没有进展。

原因:ReLU 在 Z 小于等于 0 时梯度为 0。初始权重过大或学习率过大,使得大量线性输出落在负数区间,这些神经元再也没有梯度,变相死亡,且不会自行恢复。

解决:减小初始化 scale,降低学习率,必要时换 leaky ReLU。如果你观察到 A 里 0 的占比超过一半,先检查初始化方式,不要盲目加深网络,加层不会救回已经死亡的特征通道。

5.5 训练集好测试集差,或两层都差

现象:训练集上的损失降得很低,测试集准确率却上不去;另一种情况是训练集和测试集都很差。

原因:前者是过拟合,网络容量大于数据承载能力;后者是欠拟合,说明模型容量本身不足。

解决:过拟合时优先考虑增加数据集、正则化、dropout、适当减小网络层数 L;欠拟合时增加特征数量、增加层数或隐藏层神经元数目。资源最后的“网络优化”部分已经给出了这两条分支,实操的时候先判断属于哪种,再动超参,不要同时调多个参数,否则出了问题定位不到是谁引起的。

6. 把这份代码改成自己的网络:从二分类到多分类的改动点

6.1 改输出激活、损失与反向起点,三处要同步

如果手头任务不是二分类,只改 forward 最后一层是不行的。三处必须同步:输出层由 sigmoid 换成 softmax;损失函数由原来的交叉熵形式换成正对多分类的交叉熵;反向起点 dAL 改成 softmax 与交叉熵合并后的简化梯度形式,也就是输出概率矩阵减去 one-hot 标签矩阵。若改成回归任务,输出层去掉激活函数,损失换成均方误差,反向起点变为 (A - Y) / m。这个“三处同步”的原则,适用于几乎所有基础网络的二次开发。

6.2 用梯度检查验证 cache 与公式一致

完成改动后,在完整训练前跑一遍梯度检查。对每个参数 theta,用数值差分估算梯度:

epsilon = 1e-7 gradapprox = (J(theta + epsilon) - J(theta - epsilon)) / (2 * epsilon)

把数值梯度与反向传播得到的梯度做比较,相对误差小于 1e-6 说明实现没问题;如果大于 1e-3,优先检查反向模块的 cache 是否对上了。这套资源里的框架很适合做梯度检查,因为每个 forward 函数都把中间量缓存下来,检查阶段只需要把网络改成单样本加小参数集,跑一次就能定位是哪一层公式写错。从那以后,我每次改完网络结构,第一件事就是跑一遍梯度检查,哪怕只是改了一个激活函数,也强制走一遍流程,确认没有隐藏的维度问题才交给训练脚本。这个习惯帮我省下了大量返工时间,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询