手写L层前馈神经网络:前向传播、反向传播与梯度检验

发布时间:2026/10/31 16:21:11
手写L层前馈神经网络:前向传播、反向传播与梯度检验

简介:面向机器学习初学者与需要夯实神经网络基础的开发者,这份PDF围绕深度前馈神经网络展开,从模型定义、变量约束讲起,系统梳理前向传播、反向传播、损失函数与代价函数的关系,并给出较完整的公式推导和梯度下降更新过程,帮助读者理解多层感知机如何完成分类与回归任务。包内共1个PDF文件,压缩包约1.38MB,内容以原理讲解、公式推导和Python实现思路为主线,适合作为课程笔记或项目复现前的理论参考。资源已有341人学习,读者可据此对照网络结构、激活函数选择、参数初始化与训练收敛等关键环节,结合文中代码实现入口完成从公式到实践的串联,亦可作为查漏补缺的速查材料。

1. 前馈网络:训练过程里没有任何一步在“学习”

训练一个深度前馈神经网络时,最反直觉的一点是:整个流程里没有任何一步在做拟人化的“推理”或“学习”。机器学习里的这类模型,前向传播就是一次W·A + b的矩阵乘法再套一个非线性函数,反向传播则是把同样的矩阵乘法倒着算一遍、再乘一个激活函数的导数。用 Python 写出来核心不过百来行,真正的难点全在张量形状对齐和链式法则的索引上。

搞懂这两步的机制之后,再看任何框架的nn.Module,它都只是一个把这两个动作封装起来的黑匣子。更实际的价值在于:只要这套骨架你自己推对过一次、跑通过一次,后面所有网络结构的变化——换激活函数、换损失、加残差连接、换成注意力——都只是在这套骨架上做局部替换,而不是重新理解一门新东西。下面从记号、形状、前向缓存、反向递推一路拆到底。

2. 记号与形状:为什么 W 是「当前层 × 上一层」而不是反过来

绝大多数人第一次手写 L 层网络时卡住的地方不是公式,而是矩阵乘不出来。原因几乎永远是同一个:W[l]的行列写反了,而numpy的广播又恰好没报错。所以先把记号系统和形状约束钉死,后面所有公式才有落脚点。

2.1 上标 [l]、上标 (i)、下标 i 各管什么

同一套公式里混着三种上标下标,看起来像排版洁癖,实际各自解决一类歧义。方括号[l]表示第l层,与样本无关;圆括号(i)表示第i个样本,与层无关;纯下标i表示某一层内部的第i个单元。这三者如果混用,链式法则一展开就会彻底乱套——你会分不清a_i^[l]求导是对层求还是对单元求。

按原料里的定义:z[l]_i是第l层第i个线性单元的输出(激活函数的输入),a[l]_i是第l层第i个激活单元的输出,n[l]是第l层的单元数,m是样本序号。L定义为除去输入层之后的网络层数,所以隐藏层数是L-1,总层数是L+1。这里有个极其常见的误解:L不是“总层数”,输入层不参与计数。写代码时layer_dims = [5, 4, 3]表示输入 5 维、一个 4 单元的隐藏层、输出 3 维,L = len(layer_dims) - 1 = 2,不是 3。

“前馈”这个词也不是修饰语:指网络中没有反馈连接,信息只沿1 → 2 → … → L单向流动,第l层的输出绝不回连到第l-1层。“网络”指整体是若干个函数g的复合。这三个词合起来描述的其实就是一个复合函数,没有更多玄学。

2.2 用 layer_dims 反推每一层的张量形状

形状推导只有一个锚点:每一层的输出维度由该层单元数决定,输入维度由上一层单元数决定。第l层的线性变换要吃掉一个n[l-1]维向量、吐出一个n[l]维向量,所以权重矩阵必须是(n[l], n[l-1])——行数是“我要输出多少”,列数是“我要接收多少”。反过来写成(n[l-1], n[l])也能算出结果,只是算出来的是转置,后面每一层都会跟着错。

对m个样本做向量化之后,所有激活值按列排列,形状变成(n[l], m)。于是W[l] · A[l-1]是(n[l], n[l-1]) × (n[l-1], m) = (n[l], m),恰好匹配。偏置b[l]的形状是(n[l], 1),靠广播沿m方向铺开。

层W 形状b 形状Z / A 形状
输入层l=0无无(n[0], m)
第 1 层(n[1], n[0])(n[1], 1)(n[1], m)
第 l 层(n[l], n[l-1])(n[l], 1)(n[l], m)
输出层l=L(n[L], n[L-1])(n[L], 1)(n[L], m)

按这个约束写初始化,代码几乎是照抄表格:

import numpy as np def initialize_parameters_deep(layer_dims): """ layer_dims: 列表,含输入层维度,如 [5, 4, 3] 返回 parameters: {"W1": ..., "b1": ..., "W2": ..., "b2": ...} """ np.random.seed(3) # 固定随机源,便于复现与对拍 parameters = {} L = len(layer_dims) # 含输入层的长度,故网络层数为 L-1 for l in range(1, L): # 行 = 当前层单元数,列 = 上一层单元数,写反必然乘不出来 parameters['W' + str(l)] = np.random.randn( layer_dims[l], layer_dims[l - 1]) * 0.01 # 偏置初始化为 0,形状必须是 (n[l], 1) 而非 (n[l],) parameters['b' + str(l)] = np.zeros((layer_dims[l], 1)) assert parameters['W' + str(l)].shape == (layer_dims[l], layer_dims[l - 1]) assert parameters['b' + str(l)].shape == (layer_dims[l], 1) return parameters

这段里有两个参数值得单独说。np.random.randn乘的0.01是初始化尺度:如果直接把标准正态的随机数塞进权重,深层网络的Z在第一次前向就会爆到几十上百,sigmoid 直接饱和、梯度归零,训练从第一步就死了。乘一个小系数把初始输出压在激活函数的线性区附近,是让梯度能传下去的前提。np.zeros而非随机,是因为偏置不存在“对称性破缺”问题——同一层里不同神经元的区分度由W提供,b全零不影响。

assert这两行不是摆设。手写网络的调试成本几乎全在形状上,把断言放在初始化处,错会在最早的位置炸出来,而不是等到第 300 次迭代 loss 变成nan才回头查。

2.3 广播的边界:b 为什么必须是 (n[l], 1)

numpy的广播规则是:从尾部维度开始逐维比较,相等或其中一个为 1 即可兼容。(n[l], m) + (n[l], 1)在第二维上m与1兼容,b会被沿列方向复制m份,语义正好是“每个样本加同一个偏置”。

问题出在有人图省事写成np.zeros(n[l]),形状(n[l],)。前向时(n[l], m) + (n[l],)也能算——广播会把它当成(1, n[l])去匹配最后一维m,只有在n[l] == m这种巧合下才不报错,报错时给出的形状提示还极具误导性。更隐蔽的是反向:db = np.sum(dZ, axis=1)得到的是(n[l],),如果你习惯性不写keepdims=True,这个一维数组会在更新时被广播成(n[l], n[l]),参数量悄悄翻了几十倍,训练照跑不误,只是永远不收敛。

提示:所有对axis=1的求和都带上keepdims=True,宁可多写四个字符,也不要让形状在某一步悄悄降维。

3. 前向传播:为什么每一层都要把中间结果存下来

前向传播的公式只有一行:A[l] = g(W[l]·A[l-1] + b[l])。如果只做推理,写好这一行循环L次就完事了。但训练需要反向传播,而反向传播要用的不是A[l],是Z[l]和A[l-1]——这些值在前向算完就被覆盖了。所以前向传播真正的设计任务不是“算对”,而是“算对的同时把该留的留下来”。

3.1 LINEAR 与 ACTIVATION 拆成两个函数的意义

原料里的实现把一层拆成两个函数:linear_forward只做W·A+b并缓存(A_prev, W, b),linear_activation_forward在这个基础上套激活函数并缓存Z。拆开的动机很实在:反向传播时,线性部分需要A_prev算dW、需要W算dA_prev;激活部分只需要Z算导数。两者的缓存内容不同,混在一个函数里会让 cache 的结构变成一锅粥。

def linear_forward(A, W, b): """纯线性步骤:Z = W·A + b""" Z = np.dot(W, A) + b # (n[l], n[l-1]) · (n[l-1], m) -> (n[l], m) cache = (A, W, b) # 反向要用的三件套,原样存下 return Z, cache def linear_activation_forward(A_prev, W, b, activation): """LINEAR -> ACTIVATION 的组合,activation 取 'sigmoid' 或 'relu'""" if activation == "sigmoid": Z, linear_cache = linear_forward(A_prev, W, b) A, activation_cache = sigmoid(Z) # 激活函数内部把 Z 存下来 elif activation == "relu": Z, linear_cache = linear_forward(A_prev, W, b) A, activation_cache = relu(Z) cache = (linear_cache, activation_cache) # 嵌套元组,层次分明 return A, cache

sigmoid(Z)和relu(Z)这两个工具函数返回的是(A, Z)而不是只返回A,这是整个设计里最关键的一个约定。Z被原封不动地带回来,塞进activation_cache,反向时relu_backward(dA, Z)只需要判断Z > 0就能得到导数,完全不用重新算一遍前向。

3.2 激活函数选型:sigmoid 退居输出层的原因

原料提到的三种激活函数里,sigmoid和tanh都有饱和区:输入绝对值一大,导数就趋近 0。层数一多,反向传播每经过一层就乘一个小于 1 的导数,梯度以指数速度衰减,前面几层基本收不到有效信号——这就是所谓梯度消失,血泪经验都写在“加深层数反而更差”这件事上。

ReLU的导数只有两个取值:Z > 0时为 1,否则为 0。正半轴上是恒等映射,导数恒为 1,梯度可以无损地穿过很多层。代价是负半轴导数为 0,一旦某个单元的Z长期为负,它的梯度永远是 0,参数不再更新,这个单元相当于“死掉”了——后面排查章节会专门讲。

所以原料里的结构选择是[LINEAR -> RELU] × (L-1) -> LINEAR -> SIGMOID:隐藏层全部用 ReLU 保证梯度通路,输出层用 sigmoid 是因为要做二分类,需要把输出压到(0, 1)区间当概率用。这个“隐藏层 ReLU + 输出层 sigmoid”的组合不是随意搭配,而是两个约束各自取最优的结果。

3.3 cache:反向传播唯一能用的后悔药

把每层的 cache 收进一个列表,前向走完,整个网络的中间状态就全在手里了。这个列表的长度必须精确等于L,顺序必须是从第 1 层到第L层。一旦顺序错了,反向时用错层的Z去算导数,程序不会报任何错,只会安静地算出错误的梯度。

def L_model_forward(X, parameters): """完整前向:L-1 次 LINEAR->RELU,最后 1 次 LINEAR->SIGMOID""" caches = [] A = X # A[0] 就是输入 X L = len(parameters) // 2 # 每个层有 W 和 b 两个键 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) # 第 l 层的缓存压在第 l-1 个位置 AL, cache = linear_activation_forward( A, parameters['W' + str(L)], parameters['b' + str(L)], "sigmoid") caches.append(cache) # 输出层缓存压在第 L-1 个位置 return AL, caches

L = len(parameters) // 2这一行值得注意:参数字典里既有W1…WL也有b1…bL,键的总数是层数的两倍,整除 2 才得到真正的层数。用//而不是/是因为在 Python 3 里后者返回浮点数,range()会直接报错。这种细节不写出来,很多人第一次跑就会撞上。

caches的下标是 0-based,而参数的键名是 1-based。第l层的缓存躺在caches[l-1]里。这个错位是后面反向实现里最容易翻车的地方。

3.4 代价函数:把 m 个样本的损失压成一个标量

输出层拿到AL之后,与标签Y比对算损失。单个样本的交叉熵损失是-[y·log(a) + (1-y)·log(1-a)],整个训练集的代价是这M个损失的平均:

def compute_cost(AL, Y): """AL: (1, m) 预测概率;Y: (1, m) 真实标签""" m = Y.shape[1] # 加一个极小量 eps 防止 log(0) 产生 -inf eps = 1e-15 cost = -np.sum(Y * np.log(AL + eps) + (1 - Y) * np.log(1 - AL + eps)) / m return np.squeeze(cost) # 压成标量,方便打印和比较

np.squeeze是为了把这个(1,1)的数组压成纯标量。不压也不影响计算,但打印出来会带两层方括号,跟阈值比较时偶尔会产生形状上的意外。eps那一项同理:理论上AL是 sigmoid 输出,取值严格在开区间内,但浮点下溢会让它等于精确的 0 或 1,log立刻返回-inf,整个 cost 变成nan,后面所有梯度跟着一起烂掉。

4. 反向传播:链式法则如何从 AL 一路递推回第 1 层

反向传播这个名字容易让人以为存在某种“反向的信息流”。它实际做的事只有一件:对复合函数求导,并且利用链式法则的结构,把求导顺序反过来算,从而复用中间结果。如果每层都独立地从损失函数对整个网络求一遍导,计算量是层数的平方级;反过来递推,每层只算一次,总代价和前向传播同阶。这才是它被称为“算法”而不是“公式”的原因。

4.1 损失函数的选择:交叉熵约掉了一个 σ'(z)

原料里专门解释了为什么用交叉熵而不是均方误差。把这件事的机制推到底会更清楚。

设a = σ(z),单样本交叉熵L = -[y·log(a) + (1-y)·log(1-a)]。对a求导:

∂L/∂a = -y/a + (1-y)/(1-a)

而 sigmoid 的导数有个漂亮的性质:σ'(z) = a(1-a)。链式相乘:

∂L/∂z = ∂L/∂a · a(1-a) = -y(1-a) + (1-y)a = a - y

sigmoid的导数被完整约掉了,dZ直接等于“预测减标签”。这个结果的工程价值极大:梯度大小只取决于预测误差,误差大梯度就大,误差趋近 0 梯度也趋近 0,收敛行为天然平滑。

换成均方误差L = (a - y)²/2,∂L/∂z = (a - y)·a(1-a),多出来的a(1-a)就是灾难:当预测严重错误时,a接近 0 或 1,a(1-a)接近 0,梯度反而被压没了——错得越离谱,学得越慢。原料里说“很可能会导致收敛速度缓慢”“代价函数不一定是凸函数”,指的就是这个。

交叉熵还有一层解释:最小化交叉熵等价于最大似然估计。把标签看作伯努利分布,a就是该分布参数的估计值,最大化对数似然写出来正好是负的交叉熵。所以这个损失函数不是拍脑袋选的,它是概率假设推出来的必然结果。

4.2 四个偏导式子:从 dZ[l] 到 dW[l]、db[l]、dA[l-1]

反向传播的核心是四个式子,全部来自链式法则:

dZ[l] = dA[l] * g'(Z[l]) ① dW[l] = (1/m) · dZ[l] · A[l-1]ᵀ ② db[l] = (1/m) · sum(dZ[l], axis=1) ③ dA[l-1] = W[l]ᵀ · dZ[l] ④

第一式是“穿过激活函数”:dA是损失对本层激活输出的偏导,要往下走必须乘上激活函数对自己的输入的导数。*表示对应元素相乘,因为激活函数是逐元素作用的,不存在跨单元的耦合。

第二、三式是“对本层参数求导”。Z[l] = W[l]A[l-1] + b[l],所以∂Z/∂W = A[l-1],∂Z/∂b = 1。写成矩阵形式时,dZ[l]形状(n[l], m)、A[l-1]ᵀ形状(m, n[l-1]),乘出来(n[l], n[l-1]),正好是W[l]的形状。除以m是因为代价函数取的是平均,求导时这个系数要跟着下来。b的导数要把m个样本上的贡献加起来,所以沿样本维度求和。

第四式是“把误差传回上一层”,也就是递推的关键。Z[l] = W[l]A[l-1] + b[l]对A[l-1]求导得到W[l],再乘上dZ[l]就是dA[l-1]。注意这里用的是W[l]ᵀ而不是W[l]:dZ[l]形状(n[l], m),要变回(n[l-1], m)必须左乘(n[l-1], n[l])的矩阵,而W[l]是(n[l], n[l-1]),转置之后刚好。

注意:四个式子里出现的转置不是约定的装饰,每一个都是由形状倒推出来的唯一选择。手推时如果转置位置记不清,用形状反推比背公式可靠得多。

4.3 递推的起点与终点:dAL 的解析式

递推要有个起点。最后一层的dA[L]是损失对输出层激活值的导数,对交叉熵 + sigmoid 的组合,由 4.1 的推导可以直接写出解析式:

dAL = -Y/AL + (1-Y)/(1-AL)

这行代码是整个反向传播的入口。它看起来很怪,但把 4.1 的中间步骤∂L/∂a抄下来就是它。写完这一行,剩下的全部交给循环。

4.4 向量化与索引对齐:一次算完 m 个样本

把上面四个式子写成代码,最容易出错的是索引。参数用W1…WL(1-based),caches用caches[0…L-1](0-based),grads里的dA又是从dA0到dAL。三套编号混在一起,必须逐行核对。

def linear_backward(dZ, cache): """由 dZ 反推 dA_prev、dW、db""" A_prev, W, b = cache m = A_prev.shape[1] dW = np.dot(dZ, A_prev.T) / m # ② (n[l], m)·(m, n[l-1]) db = np.sum(dZ, axis=1, keepdims=True) / m # ③ keepdims 保住 (n[l], 1) dA_prev = np.dot(W.T, dZ) # ④ (n[l-1], n[l])·(n[l], m) return dA_prev, dW, db def relu_backward(dA, Z): """ReLU 的导数:正半轴 1,负半轴 0""" dZ = np.array(dA, copy=True) # 必须 copy,否则会改掉上游传进来的 dA dZ[Z <= 0] = 0 return dZ def L_model_backward(AL, Y, caches): """从输出层反向递推到第 1 层""" grads = {} L = len(caches) # caches 长度就是层数 Y = Y.reshape(AL.shape) # 防止 Y 的形状是 (m,) 而非 (1, m) dAL = -(np.divide(Y, AL) - np.divide(1 - Y, 1 - AL)) # 输出层:caches[L-1] 对应第 L 层,用 sigmoid 的反向 current_cache = caches[L - 1] grads["dA" + str(L - 1)], grads["dW" + str(L)], grads["db" + str(L)] = \ linear_activation_backward(dAL, current_cache, "sigmoid") # 隐藏层:从 L-1 倒着走到 1,全部用 relu 的反向 for l in reversed(range(L - 1)): current_cache = caches[l] # 第 l+1 层 dA_prev_temp, dW_temp, db_temp = \ linear_activation_backward(grads["dA" + str(l + 1)], current_cache, "relu") grads["dA" + str(l)] = dA_prev_temp grads["dW" + str(l + 1)] = dW_temp grads["db" + str(l + 1)] = db_temp return grads

reversed(range(L - 1))产生L-2, L-3, …, 0。循环变量l是 cache 的下标,所以caches[l]对应的是第l+1层;而grads的键要用 1-based 的l+1。这就是那个错位:dA的编号跟 cache 对齐(0-based),dW、db的编号跟参数对齐(1-based)。写反了不会报错,因为字典的键是字符串,取不到只会KeyError,但如果两边都错得一致,程序会一路跑完并给出错误的梯度。

relu_backward里那句np.array(dA, copy=True)也是踩过坑才会记得的。dA是上游传下来的数组,如果直接dZ = dA然后原地改dZ[Z <= 0] = 0,改的是同一个内存,上游那份dA也被污染了。在单层测试里看不出来,串成完整网络后表现为“第 L-1 层梯度对、再往前全错”。

4.5 梯度下降:负梯度方向与学习率

所有偏导到手之后,更新就是一行减法:

def update_parameters(parameters, grads, learning_rate): """沿负梯度方向走一步""" L = len(parameters) // 2 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 parameters

梯度的方向是函数值上升最快的方向,所以取负号走的是下降最快的方向。learning_rate控制每步走多远:太大,会在最优解附近反复横跳甚至直接发散;太小,收敛慢到看不出变化。

原料里提到了正规方程法——直接令偏导为零解出闭式解。这个方法在神经网络里走不通有两个原因:一是W矩阵不一定可逆,闭式解不存在;二是即便存在,参数量到百万级时求逆的计算和存储都不可接受。梯度下降用迭代换掉了求逆,每一步只要一次前向加一次反向,代价固定且可扩展。这就是它成为默认选择的机制层面的原因。

5. 排查:前向能跑、反向也跑通,但 loss 就是不降的几种情况

手写网络的调试体验很特殊:代码不报错,形状都过assert,训练循环也在正常输出,唯一的问题就是 loss 曲线像一条水平线。这时候靠加print是低效的,得知道该怀疑哪些位置。下面五条是按出现频率排的。

5.1 形状断言全过,参数却学不动

现象:initialize_parameters_deep里的两条assert全部通过,前向也能算出合理的AL,但训练几十轮后 cost 几乎不动,预测值全部挤在 0.5 附近。

原因:初始化尺度写错了。np.random.randn(...) * 0.01里的0.01如果漏掉,权重是标准正态分布,第一层的Z量级就在sqrt(n[0])附近。层数一深,Z迅速膨胀到几十,sigmoid 输出精确饱和到 0 或 1,a(1-a)变成 0,梯度全部归零。反过来,如果写成* 0.000001,Z全在 0 附近,每层几乎等同于线性变换,网络的表达能力退化成一个单层模型。

解决:先打印第一轮前向后各层Z的标准差,理想情况下应该随层数缓慢衰减而不是爆炸或归零。初始尺度与输入维度有关,常用的做法是按sqrt(2/n[l-1])(ReLU)或sqrt(1/n[l-1])(sigmoid/tanh)缩放,0.01是个粗糙但能跑的经验值。改了尺度之后先只跑 10 轮,看 cost 有没有明显下降,再决定要不要加层。

5.2 cache 索引差一位:梯度和层错配

现象:单层测试逐个通过,串成L_model_backward之后梯度检验不通过,误差在1e-2量级,而且越靠前的层误差越大。

原因:caches[l]与grads["dW" + str(l+1)]的 0-based 与 1-based 错位。典型写法错误是在循环里写current_cache = caches[l+1],或者把grads["dA" + str(l)]写成grads["dA" + str(l+1)]。因为相邻两层形状经常相同(比如都是 128 维),形状检查发现不了,只有数值对拍能暴露。

解决:写完后先构造一个layer_dims = [4, 3, 2]的小网络,每一层维度都不相同,这样任何错位都会立刻触发形状不匹配。再用梯度检验逐层比对,误差应当小于1e-7。维度全部相同的网络是调试陷阱,看着简洁,实际上把最有效的检测手段废掉了。

5.3 keepdims 丢掉维度后,db 悄悄变成一行

现象:训练能跑,cost 也在降,但降得非常慢,而且b相关的梯度形状和b不匹配,只是numpy广播把错误吞掉了。

原因:db = np.sum(dZ, axis=1) / m少了keepdims=True。dZ形状(n[l], m),沿axis=1求和后得到(n[l],),一维数组。执行b - learning_rate * db时,b是(n[l], 1),广播把(n[l],)当成(1, n[l]),只有在n[l] == 1时才碰巧正确。其他情况下b被更新成了一个(n[l], n[l])的矩阵,参数量无声膨胀。

解决:所有跨样本的归约都强制写keepdims=True,并且在更新参数前加一行assert grads["db"+str(l)].shape == parameters["b"+str(l)].shape。这条断言几乎零成本,但能拦住一整类静默错误。

5.4 学习率与初始化量级:cost 变 nan 的两种走法

现象:训练刚开始几轮还行,第 5 轮左右 cost 突然跳到nan,之后所有输出都是nan。

原因:两条路径都会导致这个结果。第一条是学习率过大,一次更新把权重推到极端值,Z溢出,sigmoid返回精确的 0 或 1,下一步log(0)得到-inf。第二条是数据本身存在极端值,前向时Z已经很大,exp溢出。这两种在日志里看起来一模一样。

解决:先在compute_cost里加eps兜底,让log不会直接炸;然后在每轮迭代后检查np.isnan(cost),一旦为真立刻打印当前层的Z的max/min,定位是爆炸还是溢出。学习率从0.0075这个量级起步,先跑通再往上调。如果必须用大学习率,就得先做输入归一化,把特征压到零均值单位方差。

5.5 ReLU 负半轴全灭

现象:训练到一定轮数后 cost 不再下降,检查隐藏层激活值发现大部分单元的输出恒为 0,对任何输入都不响应。

原因:ReLU 在Z <= 0时导数恒为 0。如果某次大梯度更新把某个单元的b推得很负,导致它对整个训练集的所有样本都有Z < 0,那么这个单元的梯度永远是 0,参数再也不会更新——这就是“死亡 ReLU”。学习率越大,死得越快。

解决:把学习率降下来是最直接的手段。结构上可以换用带泄漏的 ReLU(负半轴给一个小斜率而不是 0),保证梯度总有通路。另一个有效做法是检查初始化,如果初始b就已经让大量单元落在负区,说明初始化尺度不对。监控指标上,每轮统计一次各隐藏层激活值的非零比例,低于 30% 就该警觉。

6. 设计取舍:手写一遍 L 层网络,真正的收获在哪

把这套代码和框架里的三行model.fit()对比,很容易得出“手写没意义”的结论。但两者解决的问题不同:框架解决的是“怎么快速得到结果”,手写解决的是“为什么这个结果可信”。当 loss 不降、梯度爆炸、模型在测试集上崩掉时,能快速定位问题的前提是你知道每一步在算什么,而这个认知只能从亲手推过一遍公式、亲手对过一次梯度里来。

6.1 梯度检验:手推公式必须被数值验证

解析梯度写完,不能靠“看起来对”就信。数值梯度检验的思路很朴素:对某个参数加一个极小的扰动,用差分近似导数,和解析解比对。

def gradient_check(parameters, grads, X, Y, epsilon=1e-7): """对每个参数做双向扰动,比对数值梯度与解析梯度""" params_vec, grads_vec = [], [] keys = sorted(parameters.keys()) for k in keys: params_vec.append(parameters[k].ravel()) grads_vec.append(grads["d" + k].ravel()) theta = np.concatenate(params_vec) d_theta_approx = np.zeros_like(theta) for i in range(theta.size): theta_plus, theta_minus = theta.copy(), theta.copy() theta_plus[i] += epsilon theta_minus[i] -= epsilon # 分别塞回 parameters 跑一次前向,取两次 cost 的差 d_theta_approx[i] = (cost_of(theta_plus) - cost_of(theta_minus)) / (2 * epsilon) diff = np.linalg.norm(d_theta_approx - np.concatenate(grads_vec)) / \ (np.linalg.norm(d_theta_approx) + np.linalg.norm(np.concatenate(grads_vec))) return diff

判据是相对误差:小于1e-7基本可以放心,1e-5量级要留意,超过1e-3一定有地方写错了。epsilon不能太大也不能太小——太大差分近似失真,太小会被浮点精度淹没,1e-7是个常用值。

这个检验的计算代价与参数量成正比,只能在小网络上做。做法是先构造一个[4, 3, 2]的迷你网络,把每个函数单独验证通过,再放大到真实规模。如果在大网络上直接跑,等它算完的时间够你重写三遍了。

6.2 从这套骨架演变出的结构

这套W·A+b、g(·)、dZ = dA * g'(Z)的骨架,是所有后续结构的底座。把W·A换成卷积核的滑窗乘积,就是卷积网络;把每层的连接从全连接换成带门控的循环,就是 RNN;把一个大的层拆成多个并行的小层再加权求和,就是注意力。这些结构的前向传播可以写得很复杂,但反向传播依然是同一套链式法则,依然依赖 cache,依然需要keepdims和形状对齐。

理解这一点之后,再看任何新结构的论文,第一遍可以只关注两个问题:它的前向在做什么变换,它的反向需要缓存什么。把这两个问题回答清楚,剩下的实现细节都是工程问题。这份资源里的代码骨架就是按这个思路组织的——每一层都是forward和backward成对出现,缓存的内容严格对应反向需要的东西。

6.3 一个反复验证过的习惯

提示:改动任何一层结构之后,先跑梯度检验,再跑训练循环。顺序反了,你会花几个小时怀疑优化器,而问题往往在导数里。

从那以后我每次手写或改动网络里的任何一层,都强制走一遍“小形状 + 梯度检验 + 十轮 loss 观察”这三步,从不跳过。跳过的那几次,事后无一例外都在nan或者水平线上浪费了更多时间。这份资源的价值也在这里:它把前向、反向、缓存、更新拆成可以逐段验证的独立函数,你可以一段一段地对拍,而不是面对一个整体黑盒猜哪里错了。照着这个顺序读一遍代码、亲手改几个参数跑一次,比看十篇概念文章都管用。希望帮到你。

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