ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从《Math for Deep Learning》到NumPy实战:手推反向传播与梯度检查

从《Math for Deep Learning》到NumPy实战:手推反向传播与梯度检查 简介《Math for Deep Learning》是2022年由No Starch Press出版、Ronald T. Kneusel撰写的深度学习数学指南面向希望补齐数学短板、真正理解神经网络原理的开发者与自学者。全书围绕线性代数、概率论与统计学、微积分及优化算法展开并延伸到CNN、RNN、LSTM等模型背后的数学推导帮助读者把梯度下降、参数更新等抽象概念落到具体计算上。资源为单个PDF文件压缩包约7.71MB内容完整、结构清晰便于按章节循序渐进地阅读。作者拥有机器学习工业界与学术背景书中结合Python生态中的NumPy、SciPy、TensorFlow、PyTorch等工具将理论转化为可验证的代码实践。目前已有83人学习适合数学基础薄弱但希望系统掌握深度学习核心数学、并借助编程加深理解的读者作为长期案头参考。1. 从《Math for Deep Learning》说起为什么反向传播的每一步都能在纸上算出来很多人第一次翻开 Kneusel 这本 2022 年的《Math for Deep Learning》是冲着「补数学」去的结果翻到第三章就卡住了——不是公式太难而是不知道这些矩阵、导数、概率到底对应代码里的哪一行。我见过太多人把这本书当字典查却从没在 Jupyter 里把书上的公式跑成可验证的数字。这本书真正的价值不在于罗列数学知识而在于它试图把 deep learning 里那些被框架封装成黑匣子的运算重新摊开成可以手算、可以验证的步骤。如果你正在用 PyTorch 或 TensorFlow 训模型却说不清loss.backward()到底做了什么或者看到 neural networks 里的梯度消失只能凭感觉调参那这本书对应的技术方向就值得你花两周时间认真走一遍。它适合两类人一是刚入门、想搞懂框架背后在算什么的新手二是做了几年调包侠、想回头补上「为什么这样设计」的老手。接下来的内容我会按这本书的组织逻辑把数学推导、代码复现和踩坑经验串成一条能直接照着做的路径。2. 把书里的矩阵运算翻译成 NumPy从手算到代码验证2.1 为什么先啃矩阵乘法而不是直接上神经网络Kneusel 在书里把矩阵和向量运算放在最前面不是凑篇幅。deep learning 里一个全连接层的前向传播本质就是y Wx b反向传播求梯度时又要反复用到转置、逐元素乘和求和。如果你对「矩阵乘法不满足交换律」「转置的导数怎么来的」没有肌肉记忆后面推导反向传播时每一步都会卡住。我一般建议读者先别碰框架用 NumPy 把书里第二章的矩阵运算全部手写一遍包括矩阵乘、转置、广播、Hadamard 积。这一步看起来笨但它是后面所有内容的地基。具体做法是打开书看到任何一个矩阵公式先自己在纸上算一个 2×2 或 2×3 的小例子然后用 NumPy 验证结果是否一致。不一致就回去查是维度搞错了还是运算顺序反了。import numpy as np # 定义两个小矩阵模拟书中的例子 A np.array([[1, 2], [3, 4]]) B np.array([[5, 6], [7, 8]]) # 矩阵乘法注意顺序A B 和 B A 结果不同 C A B D B A print(A B \n, C) print(B A \n, D) # 转置 A_T A.T print(A 的转置 \n, A_T) # Hadamard 积逐元素乘 H A * B print(逐元素乘 \n, H) # 广播向量加矩阵 v np.array([10, 20]) broadcast_sum A v print(广播加法 \n, broadcast_sum)这段代码的关键在于把书上的抽象符号变成具体数字。A B和B A结果不同是为了让你记住矩阵乘法没有交换律A * B是逐元素乘对应书里说的 Hadamard 积在反向传播里计算门控或注意力权重时会反复出现广播机制则是为了理解为什么偏置b可以是一个向量却能和整个 batch 的矩阵相加。参数上矩阵维度必须匹配A是 2×2B是 2×2所以A B合法如果A是 2×3B是 2×2直接乘就会报错需要先调整维度或转置。我建议每学一个运算就自己改一下矩阵形状看 NumPy 报什么错把错误信息记下来这比死记规则有用得多。2.2 用 NumPy 实现一个最小反向传播把链式法则写成代码书里讲微积分和链式法则的章节很多人读完觉得懂了但一到自己写梯度就懵。我的做法是抛开框架用 NumPy 手写一个两层全连接网络的前向和反向只处理一个样本把每一层的梯度都打印出来。这样你能亲眼看到链式法则怎么把输出层的误差一层层传回去。具体步骤是先定义输入x、权重W1、W2用 sigmoid 做激活损失用均方误差然后手动推导dL/dW2、dL/dW1写成代码最后用数值梯度验证解析梯度是否正确。数值梯度就是给参数加一个极小扰动看损失变化多少虽然慢但能验证你的推导有没有错。import numpy as np def sigmoid(z): return 1 / (1 np.exp(-z)) def sigmoid_derivative(a): # a 是 sigmoid 的输出 return a * (1 - a) # 单个样本输入维度 3隐藏层 4输出 1 x np.array([[0.5, 0.2, 0.1]]) # shape (1, 3) y_true np.array([[1.0]]) # shape (1, 1) np.random.seed(42) W1 np.random.randn(3, 4) * 0.1 # shape (3, 4) b1 np.zeros((1, 4)) W2 np.random.randn(4, 1) * 0.1 # shape (4, 1) b2 np.zeros((1, 1)) # 前向传播 z1 x W1 b1 # (1, 4) a1 sigmoid(z1) # (1, 4) z2 a1 W2 b2 # (1, 1) y_pred sigmoid(z2) # (1, 1) # 损失均方误差 loss 0.5 * (y_pred - y_true) ** 2 print(前向输出:, y_pred, 损失:, loss) # 反向传播手动链式法则 dL_dy y_pred - y_true # (1, 1) dy_dz2 sigmoid_derivative(y_pred) # (1, 1) dL_dz2 dL_dy * dy_dz2 # (1, 1) dL_dW2 a1.T dL_dz2 # (4, 1) dL_db2 dL_dz2 # (1, 1) dL_da1 dL_dz2 W2.T # (1, 4) da1_dz1 sigmoid_derivative(a1) # (1, 4) dL_dz1 dL_da1 * da1_dz1 # (1, 4) dL_dW1 x.T dL_dz1 # (3, 4) dL_db1 dL_dz1 # (1, 4) print(dL/dW2:\n, dL_dW2) print(dL/dW1:\n, dL_dW1) # 数值梯度验证 dL/dW1 的第一个元素 epsilon 1e-5 W1_plus W1.copy() W1_plus[0, 0] epsilon z1_plus x W1_plus b1 a1_plus sigmoid(z1_plus) z2_plus a1_plus W2 b2 y_pred_plus sigmoid(z2_plus) loss_plus 0.5 * (y_pred_plus - y_true) ** 2 W1_minus W1.copy() W1_minus[0, 0] - epsilon z1_minus x W1_minus b1 a1_minus sigmoid(z1_minus) z2_minus a1_minus W2 b2 y_pred_minus sigmoid(z2_minus) loss_minus 0.5 * (y_pred_minus - y_true) ** 2 numeric_grad (loss_plus - loss_minus) / (2 * epsilon) print(数值梯度:, numeric_grad, 解析梯度:, dL_dW1[0, 0])这段代码的核心是让你看到链式法则不是玄学而是一系列矩阵乘法和逐元素乘法的组合。dL_dz2是损失对第二层线性输出的梯度dL_dW2用a1.T dL_dz2得到维度从 (1,4) 转置成 (4,1) 再乘 (1,1)结果正好是 (4,1)和W2形状一致。dL_da1是误差传回隐藏层再乘 sigmoid 的导数得到dL_dz1。数值梯度验证那一段是给你一个后悔药以后自己推导任何层的梯度都可以用这种方法检查。参数上epsilon一般取 1e-5 到 1e-7太小会受浮点误差影响太大会导致近似不准。如果你发现解析梯度和数值梯度差很多先检查维度有没有对齐再检查激活函数的导数有没有写错。3. 概率与信息论从交叉熵到 KL 散度的手动计算3.1 为什么分类问题都用交叉熵而不是均方误差书里讲概率和信息论的章节会花不少篇幅在熵、交叉熵和 KL 散度上。很多人知道分类要用交叉熵但说不清为什么。我一般会带读者做一个对比实验同一个二分类任务分别用均方误差和交叉熵做损失看梯度有什么不同。结论是当输出层用 sigmoid 时均方误差的梯度里会多一个sigmoid_derivative因子当预测值接近 0 或 1 时这个因子接近 0导致梯度消失而交叉熵的梯度直接是y_pred - y_true没有这个衰减项。这就是为什么 deep learning 里分类任务几乎都用交叉熵。理解这一点你调学习率的时候心里就有数了。import numpy as np def sigmoid(z): return 1 / (1 np.exp(-z)) # 假设真实标签 y1预测值从 0.01 到 0.99 y_true 1.0 y_preds np.array([0.01, 0.1, 0.5, 0.9, 0.99]) # 均方误差损失 mse_loss 0.5 * (y_preds - y_true) ** 2 # 均方误差对 z 的梯度z 是 sigmoid 输入 mse_grad (y_preds - y_true) * y_preds * (1 - y_preds) # 交叉熵损失 ce_loss - (y_true * np.log(y_preds) (1 - y_true) * np.log(1 - y_preds)) # 交叉熵对 z 的梯度 ce_grad y_preds - y_true print(预测值:, y_preds) print(MSE 损失:, mse_loss) print(MSE 梯度:, mse_grad) print(交叉熵损失:, ce_loss) print(交叉熵梯度:, ce_grad)运行后你会看到当预测值为 0.01 时MSE 梯度只有 0.0099而交叉熵梯度是 -0.99差了 100 倍。这就是梯度消失的直接证据。参数上交叉熵里的log在预测值接近 0 时会趋向负无穷所以实际代码里会用clip或者用logits加softmax的稳定实现避免数值溢出。书里讲 KL 散度时会把它和交叉熵的关系写出来KL(p||q) 交叉熵(p,q) - 熵(p)。熵是真实分布的固有不确定性和模型参数无关所以最小化交叉熵等价于最小化 KL 散度。这个关系在知识蒸馏、变分自编码器里都会用到值得自己推一遍。3.2 用信息论解释为什么深度模型需要更多数据书里有一节讲熵和数据的关系我把它延伸成一个实操判断当你增加网络深度时模型要拟合的函数复杂度上升需要的样本量也按某种方式增长。虽然书里没有给出精确公式但你可以用信息论的角度去估算如果输入是 256 维的向量每个维度有 256 种取值那么输入空间的大小是 256^256要覆盖这个空间需要的数据量是天文数字。实际中我们靠的是数据本身的低维流形结构但深度模型层数越多要学的映射越复杂对数据量的要求就越高。我一般会建议如果你在做一个新任务先别急着加深网络用一个小模型加足够的数据跑一个 baseline再逐步加深同时观察验证集损失。如果加深后训练损失下降但验证损失上升说明数据不够或者正则化不够这时候加数据比加层数更有效。4. 梯度下降的变体从 SGD 到 Adam 的参数手调4.1 书里讲的梯度下降和框架里的 optimizer 怎么对应Kneusel 在书里会从最基础的批量梯度下降讲起然后引入随机梯度下降SGD、动量、RMSProp 和 Adam。很多人用torch.optim.Adam时只知道填lr1e-3却不知道betas和eps是干什么的。我的做法是用 NumPy 手写一个简单的 Adam 更新在一个二次函数上优化观察每一步参数怎么变。这样你就能理解beta1控制一阶矩梯度的指数移动平均beta2控制二阶矩梯度平方的指数移动平均eps是防止除零的小常数。书里可能没有给出所有实现细节但你可以根据公式自己补全。import numpy as np # 目标函数f(x) (x - 3)^2最小值在 x3 def f(x): return (x - 3) ** 2 def grad_f(x): return 2 * (x - 3) # Adam 参数 lr 0.1 beta1 0.9 beta2 0.999 eps 1e-8 x 0.0 # 初始值 m 0.0 # 一阶矩 v 0.0 # 二阶矩 t 0 for step in range(50): g grad_f(x) t 1 m beta1 * m (1 - beta1) * g v beta2 * v (1 - beta2) * (g ** 2) m_hat m / (1 - beta1 ** t) v_hat v / (1 - beta2 ** t) x x - lr * m_hat / (np.sqrt(v_hat) eps) if step % 10 0: print(fstep {step}, x{x:.4f}, f(x){f(x):.6f})这段代码里m和v分别是一阶和二阶矩的估计m_hat和v_hat是偏差修正后的值因为初始时m和v都是 0不修正的话前几步会偏小。beta1一般取 0.9beta2取 0.999eps取 1e-8。你可以试着改这些参数看收敛速度怎么变。比如把beta1改成 0.5会发现更新方向更依赖当前梯度震荡会变大把lr改成 1.0可能会直接发散。书里讲这些变体时重点在公式推导而手写一遍能让你记住每个参数的作用。实际用框架时如果你发现 Adam 收敛不好可以先检查lr是不是太大再检查betas是不是需要调整一般默认值在大多数任务上都能用。4.2 学习率调度什么时候该降降多少书里可能没有专门讲学习率调度但这是实操中绕不开的。我一般会先用一个较大的学习率跑几百步观察损失曲线如果损失震荡很厉害说明学习率太大如果损失下降很慢说明学习率太小。然后根据任务类型选择调度策略。常见的有步进衰减、余弦退火和指数衰减。以余弦退火为例它让学习率按余弦曲线从初始值降到 0适合训练周期较长的任务。你可以用 PyTorch 的CosineAnnealingLR也可以自己写。关键参数是T_max它决定多少个 epoch 后学习率降到最低。如果T_max设得太小模型还没收敛学习率就没了设得太大后期学习率还是很高损失下不去。我一般会设成总 epoch 数的 1/2 到 2/3然后观察验证集损失如果后期还在下降可以适当增大T_max。5. 避坑与排查手推公式和写代码时最容易翻车的五个地方5.1 维度不匹配却怪公式错现象手写反向传播时矩阵乘法报错或者结果形状不对第一反应是公式推错了。原因NumPy 的广播机制会让一些错误悄悄通过比如 (1,4) 和 (4,1) 相加会得到 (4,4)而不是报错。解决每一步都打印形状用assert检查关键维度。比如dL_dW2的形状必须和W2一致如果不一致先检查转置有没有漏。我习惯在代码里加一行print(shape)虽然看起来笨但能省很多调试时间。5.2 数值梯度验证时 epsilon 选得不对现象数值梯度和解析梯度差很多以为推导错了。原因epsilon太大导致近似误差大太小导致浮点误差大。解决一般取 1e-5 到 1e-7并且用中心差分(f(xeps) - f(x-eps)) / (2*eps)而不是前向差分。如果还是差很多检查损失函数里有没有不可导的点比如 ReLU 在 0 处不可导数值梯度会不稳定。5.3 交叉熵损失里 log(0) 导致 NaN现象训练一开始损失就是 NaN。原因预测值经过 sigmoid 后可能正好是 0 或 1log(0)是负无穷。解决用clip把预测值限制在[1e-7, 1-1e-7]或者直接用logits加softmax的稳定实现。PyTorch 的CrossEntropyLoss内部已经做了处理但如果你手写一定要加这个保护。5.4 学习率太大导致损失震荡不下降现象损失曲线上下跳动不收敛。原因学习率超过了损失曲面的曲率允许的范围。解决先把学习率降 10 倍看损失是否稳定下降。如果稳定了再慢慢往上调。我一般会用一个简单的学习率扫描从 1e-5 到 1e-1 每个量级跑 100 步看哪个学习率下损失下降最快且不震荡。5.5 把书里的公式直接搬到代码里却忘了 batch 维度现象书里推导时用的是单个样本代码里却要处理 batch导致形状对不上。原因书里为了简化通常省略 batch 维度但实际代码里输入是(batch_size, features)。解决在推导时先假设 batch_size1写代码时再扩展到 batch。矩阵乘法里x.T dL_dz会自动处理 batch 维度但你要清楚每一步的形状变化。我一般会先用 batch_size1 验证代码正确再改成 batch_size32 看是否还能跑通。6. 把书里的数学变成自己的工具一个可复用的梯度检查脚本学完这本书的数学部分最终目的是让你在遇到新模型时能自己推导梯度并验证。我习惯在项目里放一个通用的梯度检查脚本任何自定义层写完后先用它跑一遍。下面这个脚本可以检查任意函数的解析梯度和数值梯度是否一致你只需要传入函数和参数即可。import numpy as np def check_gradient(f, x, analytic_grad, epsilon1e-5): f: 函数输入 x返回标量损失 x: 参数向量 analytic_grad: 解析梯度形状和 x 相同 numeric_grad np.zeros_like(x) it np.nditer(x, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index old_value x[idx] x[idx] old_value epsilon f_plus f(x) x[idx] old_value - epsilon f_minus f(x) numeric_grad[idx] (f_plus - f_minus) / (2 * epsilon) x[idx] old_value it.iternext() diff np.abs(analytic_grad - numeric_grad) rel_error diff / (np.abs(analytic_grad) np.abs(numeric_grad) 1e-8) print(最大相对误差:, np.max(rel_error)) if np.max(rel_error) 1e-6: print(梯度检查通过) else: print(梯度检查失败检查解析梯度) return rel_error # 示例f(x) sum(x^2)解析梯度是 2x def f(x): return np.sum(x ** 2) x np.array([1.0, 2.0, 3.0]) analytic_grad 2 * x check_gradient(f, x, analytic_grad)这个脚本的核心是中心差分和相对误差。epsilon取 1e-5相对误差小于 1e-6 就认为通过。如果失败先检查解析梯度有没有漏掉某一项再检查函数里有没有不可导的点。我一般会在写完一个自定义损失函数后立刻跑这个脚本确认无误再接入训练循环。这个习惯帮我省了很多次通宵调试。书里的数学不是用来考试的是用来让你在遇到新问题时能自己推导、自己验证。希望帮到你。本文还有配套的精品资源点击获取
返回列表