简介面向机器学习初学者与需要夯实神经网络基础的开发者这份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总层数是L1。这里有个极其常见的误解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 形状输入层l0无无(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)输出层lL(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, axis1)得到的是(n[l],)如果你习惯性不写keepdimsTrue这个一维数组会在更新时被广播成(n[l], n[l])参数量悄悄翻了几十倍训练照跑不误只是永远不收敛。提示所有对axis1的求和都带上keepdimsTrue宁可多写四个字符也不要让形状在某一步悄悄降维。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·Ab并缓存(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, cachesigmoid(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, cachesL 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 或 1log立刻返回-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 - ysigmoid的导数被完整约掉了dZ直接等于“预测减标签”。这个结果的工程价值极大梯度大小只取决于预测误差误差大梯度就大误差趋近 0 梯度也趋近 0收敛行为天然平滑。换成均方误差L (a - y)²/2∂L/∂z (a - y)·a(1-a)多出来的a(1-a)就是灾难当预测严重错误时a接近 0 或 1a(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], axis1) ③ 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…WL1-basedcaches用caches[0…L-1]0-basedgrads里的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, axis1, keepdimsTrue) / 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, copyTrue) # 必须 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] # 第 l1 层 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 gradsreversed(range(L - 1))产生L-2, L-3, …, 0。循环变量l是 cache 的下标所以caches[l]对应的是第l1层而grads的键要用 1-based 的l1。这就是那个错位dA的编号跟 cache 对齐0-baseddW、db的编号跟参数对齐1-based。写反了不会报错因为字典的键是字符串取不到只会KeyError但如果两边都错得一致程序会一路跑完并给出错误的梯度。relu_backward里那句np.array(dA, copyTrue)也是踩过坑才会记得的。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 或 1a(1-a)变成 0梯度全部归零。反过来如果写成* 0.000001Z全在 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(l1)]的 0-based 与 1-based 错位。典型写法错误是在循环里写current_cache caches[l1]或者把grads[dA str(l)]写成grads[dA str(l1)]。因为相邻两层形状经常相同比如都是 128 维形状检查发现不了只有数值对拍能暴露。解决写完后先构造一个layer_dims [4, 3, 2]的小网络每一层维度都不相同这样任何错位都会立刻触发形状不匹配。再用梯度检验逐层比对误差应当小于1e-7。维度全部相同的网络是调试陷阱看着简洁实际上把最有效的检测手段废掉了。5.3 keepdims 丢掉维度后db 悄悄变成一行现象训练能跑cost 也在降但降得非常慢而且b相关的梯度形状和b不匹配只是numpy广播把错误吞掉了。原因db np.sum(dZ, axis1) / m少了keepdimsTrue。dZ形状(n[l], m)沿axis1求和后得到(n[l],)一维数组。执行b - learning_rate * db时b是(n[l], 1)广播把(n[l],)当成(1, n[l])只有在n[l] 1时才碰巧正确。其他情况下b被更新成了一个(n[l], n[l])的矩阵参数量无声膨胀。解决所有跨样本的归约都强制写keepdimsTrue并且在更新参数前加一行assert grads[dbstr(l)].shape parameters[bstr(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, epsilon1e-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·Ab、g(·)、dZ dA * g(Z)的骨架是所有后续结构的底座。把W·A换成卷积核的滑窗乘积就是卷积网络把每层的连接从全连接换成带门控的循环就是 RNN把一个大的层拆成多个并行的小层再加权求和就是注意力。这些结构的前向传播可以写得很复杂但反向传播依然是同一套链式法则依然依赖 cache依然需要keepdims和形状对齐。理解这一点之后再看任何新结构的论文第一遍可以只关注两个问题它的前向在做什么变换它的反向需要缓存什么。把这两个问题回答清楚剩下的实现细节都是工程问题。这份资源里的代码骨架就是按这个思路组织的——每一层都是forward和backward成对出现缓存的内容严格对应反向需要的东西。6.3 一个反复验证过的习惯提示改动任何一层结构之后先跑梯度检验再跑训练循环。顺序反了你会花几个小时怀疑优化器而问题往往在导数里。从那以后我每次手写或改动网络里的任何一层都强制走一遍“小形状 梯度检验 十轮 loss 观察”这三步从不跳过。跳过的那几次事后无一例外都在nan或者水平线上浪费了更多时间。这份资源的价值也在这里它把前向、反向、缓存、更新拆成可以逐段验证的独立函数你可以一段一段地对拍而不是面对一个整体黑盒猜哪里错了。照着这个顺序读一遍代码、亲手改几个参数跑一次比看十篇概念文章都管用。希望帮到你。本文还有配套的精品资源点击获取