简介:这份机器学习系列(1)深度前馈神经网络(DFNN)学习资料,面向机器学习入门者与神经网络初学者,适用于分类、回归等典型任务。内容围绕网络定义、变量约束、前向传播、反向传播及数据集划分五大模块展开,系统阐释多层感知机的运作机制,并明确定义输入层特征数、网络层数 L 等符号体系。推导部分完整呈现交叉熵损失、梯度下降及反向传播链式法则,矩阵化的前向传播公式 z = W^T a + b 与激活函数均有详细说明。资源为单个PDF文档,大小1.38MB,除原理解释与公式外,还提供 Python 实现思路和文中附带的 GitHub 代码链接,方便边学边练。目前已有340人学习浏览,适合在搭建模型前夯实理论基础,或作为深度学习课程的复习参考。
1. 深度前馈神经网络:一份能把原理、推导与 Python 实现对齐的资源
深度前馈神经网络(Deep Feedforward Neural Network,DFNN)是神经网络里最基础也最容易被低估的一块:它不涉及卷积、循环或注意力机制,但前向传播、反向传播、梯度下降、参数初始化这一整套逻辑,后来几乎所有模型都在沿用。很多人学机器学习时卡住,不是卡在概念上,而是原理、公式、代码三个层面互相脱节——看懂了定义却推不动链式法则,推得动公式却写不出一份能跑的 Python 实现。这份资源把三件事合在一起:先讲网络结构里的变量约束,再逐层推导前向过程与 BP 算法,最后用 numpy 把两层网络和 L 层网络完整实现出来。适合刚入门神经网络的初学者,也适合需要一份可复现代码做对照训练的从业者。
2. 前向传播与代价函数:先把数据流过网络的路径拆清楚
2.1 变量约束与符号约定:不统一记号,后面推导必乱
分析一个网络,第一件事是定义变量。这份资源沿用了一套与后续实现完全对应的符号系统:输入层特征数记为 nx,输入层之后的网络层数记为 L,也就是隐藏层数为 L-1,输出层就是第 L 层。上标方括号表示层序号,z[l]_i 表示第 l 层第 i 个线性单元的输出,a[l]_i 表示第 l 层第 i 个激活单元的输出;上标圆括号表示样本序号,x[0]_m 表示第 m 个样本的输入,y_m 对应标签。训练样本总量记为 M,第 i 个特征的下标单独用 i 区分。
这里有一个容易被忽略的点:为什么要做这么细的约束?因为在向量化实现里,第 l 层权重矩阵 W[l] 的形状是 (n[l], n[l-1]),偏置 b[l] 的形状是 (n[l], 1),激活输出 A[l] 的形状是 (n[l], M)。只有当层序号、样本序号、特征序号三个维度全部对齐时,反向传播里的转置和点乘才不会频繁报错。实践里我会在拆解开始前,先把论文符号映射到代码变量上:z 对应代码里的 Z,a 对应 A,W 和 b 原样保留,这样后面每看一页推导都能立刻定位到对应函数。
除了符号,还需要理解“前馈”二字的含义。前馈指的是网络没有反馈连接,信息从输入层单向流到输出层;而“深度”指的就是模型除去输入层后的层数。整个网络可以看作由不同激活函数 g 复合出来的一个映射函数 f(x; θ),训练的目标就是学习参数 θ,让 f 拟合输入与因变量之间的映射关系,或者拟合分类问题里各类别之间的边界。这个定义听起来抽象,落到前向传播公式上就很直观了。
2.2 前向传播的逐层递推与向量化:从单个样本到整个数据集
前向传播的计算过程是层层叠叠的线性变换加非线性激活。对于一个 L 层网络,第 l 层的线性输出为:
Z[l] = W[l] · A[l-1] + b[l] A[l] = g(Z[l])其中 A[0] 就是输入矩阵 X,A[L] 是最终预测值。这个式子看起来简单,但展开以后要特别注意形状:W[l] 是 (n[l], n[l-1]),A[l-1] 是 (n[l-1], M),两者的点乘结果是 (n[l], M),再加上形状为 (n[l], 1) 的 b[l]。这里 numpy 会把 b[l] 自动广播到 M 列上,每一列都加上同一个偏置向量,这就是向量化写法与逐样本写法的核心差异。
举个具体例子:某图像分类任务里,输入是 64×64×3 的彩色图,展平后 nx=12288,训练样本 M=209。若第一层隐藏单元数 n[1]=4,则 W[1] 的形状是 (4, 12288),Z[1] 和 A[1] 的形状都是 (4, 209)。从第 1 层到第 L 层,按这个规则逐层算下去,任何一层形状对不上,报错都会发生在 np.dot 这一步,而且错误信息通常只提示维度不匹配,不会告诉你具体是哪一层逻辑写错了,所以建议每一层都打印或断言形状。
逐样本理解前向传播更容易看出本质:把上一层的输出 a[l-1] 当作本层输入,先做线性组合 z[l] = W[l]·a[l-1] + b[l],再经过激活函数 g 变成 a[l],然后传给下一层。网络的不同层可以看作不同函数 g 的复合,所以“深度前馈神经网络”本质上是把一组参数化函数嵌套在一起。这个视角在后向传播推导里同样成立——你只需要关心每条路径上的导数乘积。
2.3 交叉熵与均方误差:分类场景为什么选前者
前向传播走到输出层之后,需要定义一个损失函数来度量预测值与真实标签之间的差异。单样本的损失常用交叉熵:
L(y_hat, y) = -[ y·log(y_hat) + (1-y)·log(1-y_hat) ]对整个训练集,代价函数是所有样本损失的平均:
J(W, b) = -(1/M) · Σ[ y_m·log(a[L]_m) + (1-y_m)·log(1-a[L]_m) ]为什么不用均方误差?这是初学者最容易困惑的地方。从形式上看,均方误差度量的是预测值与标签之间的欧氏距离,语义上非常直观,但在二分类场景配合 sigmoid 输出时,均方误差对参数的梯度里会多乘一项 sigmoid 的导数值。当输出接近 0 或 1 时,sigmoid 导数趋近于 0,梯度被压得极小,参数几乎不更新,训练速度会非常慢。更麻烦的是,均方误差在逻辑回归这类问题上对应的代价函数不一定是凸函数,梯度下降有可能收敛到某个非最小的极小值点,而非全局最优。
交叉熵则不同。它等价于最大似然准则,最小化交叉熵直观上就是在减小预测分布与真实分布之间的差异。更关键的是,交叉熵与 sigmoid 组合之后,输出层梯度会化简成 a[L] - y,误差越大梯度越大,误差接近零时梯度也趋近零,收敛行为比均方误差健康得多。这份资源在理论部分特意强调了这个选型理由,并说明它替代均方误差的原因,这正是分类任务落地时值得反复确认的细节。
3. 反向传播与梯度下降:把偏导数一层层递推回去
3.1 链式法则与反向传播四件套公式
前向传播能算出代价 J,接下来需要求 J 对每一层参数 W[l] 和 b[l] 的偏导数。直接对复合函数求导会非常繁琐,所以反向传播采用链式法则,从输出层开始,把梯度逐层往输入方向递推。这个过程的核心可以压缩成四个式子:
dZ[l] = dA[l] * g'(Z[l]) dW[l] = (1/M) · dZ[l] · A[l-1]^T db[l] = (1/M) · Σ dZ[l] (按列求和) dA[l-1] = W[l]^T · dZ[l]其中“*”表示矩阵对应元素相乘,g'(Z[l]) 是激活函数的导数。输出层的起点 dA[L] 由代价函数直接求导得到,对于交叉熵加 sigmoid,dA[L] 可以化简为 a[L] - y。这里需要注意,dW 的公式里 A[l-1] 要转置,因为它要和 dZ[l] 的形状 (n[l], M) 做点乘,结果才是 (n[l], n[l-1]),正好与 W[l] 的形状一致。很多人在这一步翻车,转置写反之后形状对不上,或者对上了但数值完全错误。
递推的顺序是从第 L 层开始,用 dZ[L] 算出 dW[L]、db[L] 和 dA[L-1],再把 dA[L-1] 当作第 L-1 层的起点继续算,一直递推回第 1 层。每个式子都只需要当前层的缓存和上一层的激活输出,这也是为什么代码实现里前向传播要把每一层的 Z、A_prev、W、b 都存起来——反向传播要用这些中间值做链式求导。理解了这四个式子的来源,就等于理解了整份资源的公式推导部分。
3.2 梯度下降更新公式与收敛原理
有了所有偏导数,参数更新就是沿负梯度方向走一步。更新公式为:
W[l] = W[l] - α · dW[l] b[l] = b[l] - α · db[l]α 是学习率,控制每次更新的步长。为什么要沿着负梯度方向?因为梯度的方向是函数值上升最快的方向,负梯度方向就是下降最快的方向。理论上,从某个初始点出发,不断沿着最陡峭的下坡方向走,就能逐步逼近代价函数的最小值附近。正规方程通过令偏导数为零直接求解析解的方式只在小规模线性问题里可行,对于深度网络,矩阵求逆成本高且不一定可逆,梯度下降成了实际上的默认方案。
学习率的选择是实践里的玄学:太大会导致 loss 震荡甚至发散,太小则收敛极慢。这份资源在推导之后紧接着给了梯度下降的向量化形式,并把每一步的梯度和参数更新对应到代码变量上,方便读者在实现时将公式直接翻译成 numpy 语句。常见的做法是先选一个中等量级的学习率,比如 0.01 或 0.001,然后观察训练 loss 的曲线走势再调整。
3.3 参数初始化:0.01 缩放与维度断言
梯度下降的第一步是从某个初始参数出发,初始化直接影响收敛速度与最终效果。两层网络的初始化函数是理解整份代码的起点,它很短但包含两个关键设计:
def initialize_parameters(n_x, n_h, n_y): np.random.seed(1) W1 = np.random.randn(n_h, n_x) * 0.01 b1 = np.zeros((n_h, 1)) W2 = np.random.randn(n_y, n_h) * 0.01 b2 = np.zeros((n_y, 1)) parameters = {"W1": W1, "b1": b1, "W2": W2, "b2": b2} return parameters权重用 np.random.randn 乘以 0.01,偏置用全零。logits 里的 0.01 是缩放系数:randn 生成的随机数标准差为 1,如果直接用作初始权重,z = W·x + b 的数值可能很大,落在 sigmoid 或 tanh 的饱和区,激活导数趋近于零,反向传播一开始梯度就很小,训练几乎推不动。乘 0.01 之后初始输出集中在激活函数的线性区附近,梯度处于健康范围。偏置初始化为零是常见做法,因为即使偏置对称,权重矩阵的随机性已经打破了神经元的对称性。
这里还要注意 np.random.seed(1) 的位置。seed 的作用是固定随机数序列,保证每次运行初始化结果一致,方便复现和调试。但 seed 一旦固定,后续所有 np.random.randn 调用都会按同一个序列生成,如果你在初始化之后又调用了其他随机函数,序列就会被消耗。实践里我习惯把 seed 放在初始化函数内部且只调用一次,避免干扰后续的随机逻辑。另外,初始化函数里通常会带 assert 检查形状:W1.shape == (n_h, n_x)、b1.shape == (n_h, 1)、W2.shape == (n_y, n_h),这些断言能在第一时间暴露出维度传反之类的低级错误。
提示:0.01 缩放只适用于浅层网络。层数超过 5 层时,更建议用 He 初始化或 Xavier 初始化,它们会根据当前层神经元数量自适应缩放权重,能明显缓解梯度消失问题。
4. Python 实现:初始化、前向传播与反向传播的完整代码
4.1 两层与 L 层参数初始化:字典存储与断言检查
两层网络初始化在上一节已经给出,L 层网络的通用版本则是在这个基础上加一层循环。layer_dims 是一个列表,例如 [12288, 20, 7, 1],表示输入层 12288 个特征、第一个隐藏层 20 个单元、第二个隐藏层 7 个单元、输出层 1 个单元。所有参数统一存在一个字典里,键名为字符串 "W1"、"b1"……"WL"、"bL":
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)) # 断言:初始化完立刻核对形状 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这里的 L = len(layer_dims),以 [5, 4, 3] 为例,layer_dims 长度为 3,循环只执行 l=1 和 l=2 两轮,恰好生成 W1、b1、W2、b2。有人会问为什么不是 L-1 轮中的 l=1..L?其实 len 本身就等于输入层加所有隐藏层加输出层的总层数,循环从 1 开始避开了输入层的索引 0,这是 Python 数组索引从 0 开始带来的常见困惑。断言放在循环内是为了在每次生成参数后立即检查形状,避免问题累积到前向传播才暴露。
4.2 前向传播模块:线性层、激活层与缓存
前向传播被拆成两个小函数。第一个 linear_forward 只做线性变换 Z = W·A_prev + b,并把输入缓存起来:
def linear_forward(A, W, b): Z = np.dot(W, A) + b cache = (A, W, b) # 缓存输入,供反向传播使用 return Z, cache第二个函数把线性层和激活层组合在一起,根据 activation 参数选择 relu 或 sigmoid。在 L 层网络中,前 L-1 层用 [LINEAR->RELU],最后一层用 [LINEAR->SIGMOID],这样组合可以让输出层的数值落在 0 到 1 之间,配合交叉熵做二分类:
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,后者用于计算激活函数的导数 g'(Z)。很多人实现反向传播时重新计算一遍前向过程,虽然数值上没问题,但浪费了大量算力,深层次看是没理解缓存的意义——反向传播只依赖前向过程中已算出的中间值,不需要重新计算。
4.3 反向传播模块:从 dZ 到 dW、db、dA_prev
反向传播同样拆成 linear_backward 和 linear_activation_backward。先看 linear_backward,它接收 dZ 和缓存,返回 dA_prev、dW、db:
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三个式子和第三章的推导完全对应。除以 m 是因为代价函数定义成所有样本损失的平均值,梯度也要跟着平均。np.sum 的 axis=1 表示沿着样本维度求和,keepdims=True 保证输出形状是 (n[l], 1),与 b 的形状一致,避免广播时报错。这里我踩过一次坑:keepdims 漏写之后,db 变成 (n[l],) 一维数组,参数更新时 W 和 b 的形状错位,训练曲线直接崩掉,所以这个参数值得专门记住。
接着是 linear_activation_backward,它根据缓存里的 Z 计算激活函数的导数,得到 dZ 之后交给 linear_backward:
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, dbdA 是当前层的梯度起点。从输出层开始,dA[L] 由损失函数求导给出,然后逐层调用 linear_activation_backward,每层产生 dW、db 和下一层需要的 dA_prev。这份资源把整个过程封装成 L_model_backward,先用交叉熵的导数算出输出层 dA,再按 L 到 1 的逆序遍历,所有梯度统一存进 grads 字典。
4.4 参数更新与训练循环:把四件套拼起来
拿到所有梯度之后,参数更新函数非常简单:
def update_parameters(parameters, grads, learning_rate): L = len(parameters) // 2 # 有多少层 for l in range(1, L + 1): parameters["W" + str(l)] -= learning_rate * grads["dW" + str(l)] parameters["b" + str(l)] -= learning_rate * grads["db" + str(l)] return parameters这里 L 的计算方式是 parameters 字典长度除以 2,因为每层有 W 和 b 两个键。循环要从 1 到 L+1 而不是 1 到 L,否则最后一层参数永远不会更新。这种边界错误在训练时不易察觉,因为 loss 照样下降,只是效果差一截,非常隐蔽。整体训练循环就是把四件事串起来:初始化参数,执行 L_model_forward 得到预测值和缓存,用预测值算代价,再执行 L_model_backward 得到梯度,最后 update_parameters 更新参数。重复若干轮之后,代价函数逐步下降,再拿测试集做泛化评估。
如果泛化效果不理想,资源里给出了两个调整方向:过拟合时优先考虑增加数据集、正则化、dropout、适当减小网络层数;欠拟合时优先考虑增加特征数量、增加网络层数或隐藏层神经元数目。这两句看似简单,但实际操作里很多人一上来就加层数,结果训练集准确率上去了测试集反而更差,那正是没有先判断过拟合还是欠拟合。
5. 避坑与常见问题:维度、梯度与过拟合的三个排查方向
5.1 维度不匹配:矩阵形状报错的三种典型场景
现象:前向传播执行到 np.dot 时抛出 ValueError,提示 shapes 不对齐,例如 (5,4) 和 (3,1) 无法点乘;或者反向传播返回的 dW 形状与 W 不一致。
原因:大多数情况是权重矩阵的形状写反。W[l] 应该是 (n[l], n[l-1]),也就是用当前层单元数做行数、上一层单元数做列数;写反成 (n[l-1], n[l]) 后,前向传播第一步就与输入 A_prev 对不上。另一种常见原因是偏置 b 的 keepdims 没写,导致 db 变成一维数组,参数更新时广播行为改变。
解决:在每个函数入口加断言核对形状;单独写一个 shape_check 函数把 parameters 里每个 W、b 的形状打印出来与层配置比对。我一般会在第一次跑迭代之前先手动执行一次前向传播,打印每一层 A 的形状,确认无误再进训练循环。
5.2 梯度消失与爆炸:Loss 卡住或 NaN 的排查顺序
现象:训练几轮之后 loss 几乎不动,或者某一步突然变成 nan;层数越深越明显。
原因:反向传播的梯度是连乘结构。若每层权重矩阵的谱半径小于 1,越往前梯度越小,浅层参数几乎得不到更新,就是梯度消失;若大于 1,梯度指数级放大,参数更新一步就发散,溢出成 nan,这是梯度爆炸。深层网络搭配 sigmoid 时尤其明显,因为 sigmoid 导数最大值只有 0.25,多层连乘后梯度迅速衰减。
解决:排查顺序依次是:把初始化换成 He 初始化或 Xavier 初始化;隐藏层激活函数从 sigmoid 换成 ReLU 系列;检查学习率是否过大导致发散;最后再考虑梯度裁剪。这份资源里没有实现梯度裁剪,但控制层数和初始化已经能解决大部分浅层问题。另外,出现 nan 时先看 loss 是从第几步开始变坏的,再把学习率调小一个数量级重跑,往往比改网络结构更快定位问题。
5.3 过拟合与欠拟合:网络容量怎么调才不靠猜
现象:训练集 loss 持续下降,测试集准确率却停滞甚至恶化;反过来,训练集 loss 本身降不下去,预测结果接近随机猜测。
原因:前一种是过拟合,模型容量大于数据能支撑的范围,把训练集的噪声也学进去了;后一种是欠拟合,模型容量不足,连训练集的规律都没法表示。很多初学者一上来就堆层数和神经元,结果训练集表现好、测试集翻车,本质上是在没有区分这两类情况前盲目扩容量。
解决:过拟合时优先加数据(扩充样本或做数据增强),加 L2 正则化或 dropout,或者减小 L;欠拟合时增加特征、加深网络、加宽隐藏层。判断方法只有一个:先看训练集表现。训练集都学不好是欠拟合,训练集好而测试集差是过拟合。这个顺序不能反,否则调参方向完全跑偏。
5.4 初始化与激活函数搭配的隐性坑
现象:两层网络能收敛,改成四层之后训练极慢,或者输出层一直预测同一类。
原因:隐藏层用了 sigmoid 或 tanh,深层网络梯度连乘后逐步消失,浅层参数长期得不到有效更新,网络实际退化成只有输出层在学。另一种情况是全零初始化——如果所有 W 初始为 0,每一层的所有神经元在前向传播中输出完全相同,反向传播梯度也完全相同,网络对称性无法打破。
解决:隐藏层默认用 ReLU,输出层用 sigmoid;初始化权重用 randn 乘缩放系数,绝不用全零初始化权重。若必须使用 sigmoid,配合交叉熵把输出层梯度化简为 a-y,至少保证输出层能学到东西;隐藏层仍然建议用 ReLU 族激活函数。
6. 进阶验证技巧:梯度检验、维度断言与逐层排查习惯
如果一份资源能使网络在前向、反向都能跑通,往往会被直接拿去训练。但更稳妥的路线是先做一遍梯度检验。梯度检验的原理是:对任意参数 θ,用数值方法逼近偏导数,再与反向传播计算出的梯度对比。数值逼近公式为 dθ ≈ (J(θ+ε) - J(θ-ε)) / (2ε),ε 取 1e-7 量级,然后计算相对误差:
relative_error = ||dθ_numeric - dθ_backprop|| / (||dθ_numeric|| + ||dθ_backprop||)误差小于 1e-7 说明实现正确;在 1e-7 到 1e-3 之间说明可能某个层的实现有细微偏差;大于 1e-3 就要逐层排查反向传播。梯度检验只能在训练前执行一次,而且记得关闭 dropout,因为 dropout 每次前向都会随机丢弃神经元,数值梯度与反向传播梯度天然对不上。这个检验方法不在这份资源的代码包里,但它是验证 BP 实现正确与否最直接的后悔药。
另一个值得养成的习惯是维度断言。我自己的做法是在训练循环外写一个 verify_model 函数,构造一个随机小数据集,跑一次完整的前向和反向,打印每一层 A 的形状、dA 的形状、dW 与 W 的形状是否一致。如果这一步全对,再用真实数据训练,几乎所有低级错误都会在训练前暴露。
最后一个技巧是逐层堆叠:先用一层隐藏层确认 baseline,再逐步加层,每加一层记录训练集和测试集的准确率变化。这样可以清楚看到每一层带来的收益,而不是一次性搭一个五层网络,翻车之后不知道问题出在第几层。我在最初复现这份资源时,直接用 len(layer_dims) 做循环边界,结果参数更新漏掉了最后一层,loss 降得比预期慢很多;后来强制把维度断言和梯度检验写进常规流程,这类问题在第一次跑迭代前就能被拦住。从那以后我每次搭网络,都先走一遍 shape 检查再进训练循环。希望帮到你。
本文还有配套的精品资源,点击获取