ARTICLE DETAIL

资讯详情

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

深度前馈神经网络:从反向传播推导到Python实现

深度前馈神经网络:从反向传播推导到Python实现 简介这是一份机器学习入门学习资料完整讲解深度前馈神经网络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 等于 4len(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 parametersnp.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_forwarddef 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, cachecache 里第一个元素 linear_cache 存 A_prev、W、b第二个元素 activation_cache 存 Z反向传播时两者都要用所以必须分开存不能合并成一个元组。把隐藏层和输出层串起来就是 L_model_forwarddef 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, cachesL 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, axis1, keepdimsTrue) / m dA_prev np.dot(W.T, dZ) return dA_prev, dW, dbdW 的公式是 dZ·A_prev^T 再除以样本数 m这样得到的是整个数据集的平均梯度。db 要对所有样本的 dZ 求和再平均axis1 表示按特征维度求和keepdimsTrue 保留列向量形状方便后续广播。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 函数都把中间量缓存下来检查阶段只需要把网络改成单样本加小参数集跑一次就能定位是哪一层公式写错。从那以后我每次改完网络结构第一件事就是跑一遍梯度检查哪怕只是改了一个激活函数也强制走一遍流程确认没有隐藏的维度问题才交给训练脚本。这个习惯帮我省下了大量返工时间希望帮到你。本文还有配套的精品资源点击获取
返回列表