深度学习课后作业四层结构:从向量化到卷积与序列模型

发布时间:2026/10/25 3:32:14
深度学习课后作业四层结构:从向量化到卷积与序列模型

简介:面向系统学习深度学习、希望借助课后编程作业巩固理论的学习者,这份资料围绕吴恩达课程各周作业提供答案与总结,适合需要对照思路、查漏补缺的初学者和进阶者。包内共70个文件,以55个ipynb交互式笔记本为主,配合7个py脚本、6张png图示和2个htm页面,便于边运行代码边理解模型搭建、训练与测试流程,压缩包约32.82MB。内容覆盖神经网络基础、深层神经网络、超参数调试与正则化、优化算法、卷积神经网络、目标检测、循环序列模型、词嵌入及注意力机制等模块,并对前向传播、反向传播、梯度下降、损失函数与优化器等核心知识点作出归纳。已有1009人学习,可作为课程作业复盘、实验参数调整和模型评估的参考,也能帮助读者把图像识别、语音识别等应用场景中的问题拆解到具体实现步骤。

1. 知识地图:一份深度学习课后作业的答案其实有四层结构

很多人拿到一份深度学习的课后作业答案与配套素材,第一反应是对着抄一遍、跑通就关掉页面。这套作业真正的价值不在答案本身,而在于它把整个方向拆成了四条互相衔接的主线:向量化与矩阵运算打底,前向反向推导撑起网络,初始化、正则化、优化器把网络训得动,卷积与序列结构把网络用到图像和时间序列上。答案只是每条主线的验收标准,拿它当模板之前,先弄清自己站在哪一层。基础薄弱的人适合顺着做;卡在调参阶段的人,可以从第三层往回检查。

2. 基础层:向量化、损失函数与反向传播的推导重点

这一层是整套作业里最容易被轻视的部分。很多人觉得逻辑回归太简单,直接跳到深层网络,结果在后面所有编程题里反复犯同一个错误:矩阵形状对不上,或者对上了但语义是错的。基础层的作业题看着朴素,实际在训练三件事:把循环写成矩阵、把公式写成代码、把维度写成习惯。这三件事没过关,后面每一周都要付出代价。

2.1 向量化:把 for 循环换成矩阵之后,形状就是一切

作业里第一批编程题几乎都会给一个“用 for 循环逐样本计算”的版本,然后要求改成向量化实现。改造的核心不是语法,而是约定:样本按列排,一个 $n_x \times m$ 的矩阵 $X$ 表示 $m$ 个样本、每个样本 $n_x$ 维特征。这个约定一旦定下来,后面所有公式的形状都能推出来,不用死记。

import numpy as np def propagate(w, b, X, Y): """ w: (n_x, 1) 权重,列向量 b: 标量偏置 X: (n_x, m) 每个样本占一列 Y: (1, m) 标签,同样按列排 """ m = X.shape[1] # 广播:b 是标量,会自动加到 (1, m) 的每一列上 Z = w.T @ X + b # (1, n_x) @ (n_x, m) -> (1, m) A = 1 / (1 + np.exp(-Z)) # sigmoid,逐元素 # 交叉熵,注意先乘再取均值,不要先取均值 cost = -(Y * np.log(A) + (1 - Y) * np.log(1 - A)).mean() # 反向:dZ 的形状必须和 Z 一致 dZ = A - Y # (1, m) dw = (X @ dZ.T) / m # (n_x, m) @ (m, 1) -> (n_x, 1) db = np.sum(dZ) / m # 标量 return {"dw": dw, "db": db}, float(cost)

这段代码有三个值得盯住的细节。第一,dw的形状必须和w完全一致,(n_x, 1)对(n_x, 1),因为后面要用w = w - lr * dw直接更新;如果写成(1, n_x),numpy 的广播不会报错,只会把结果悄悄加错,这是最典型的看不清来源的错误。第二,cost用的是.mean()而不是.sum(),两者差一个 $m$ 倍,梯度里也会跟着差,学习率看起来“莫名其妙不对”往往就是这里。第三,db是标量,求和后除 $m$,不要保留成(1,1)的数组,否则后面累加时会引入多余的维度。

提示:每写完一个函数,先print(w.shape, dw.shape, X.shape),三个形状对不上就先别跑训练。这个习惯能省掉大量调试时间。

向量化真正带来的收益在深层网络里才显现。作业里通常会要求对比 for 循环版本和向量化版本的耗时,这个对比不是走形式,它解释了一个事实:深度学习能跑起来,靠的是把计算压进 BLAS 这类底层矩阵库,而不是靠更快的解释器。理解这一点,后面看到mini-batch、看到卷积的im2col,就不会觉得是凭空冒出来的技巧。

2.2 损失函数与梯度:作业里必须自己推一遍的三个式子

作业的推导题往往被跳过,因为答案就摆在那里。但反向传播的调试能力,几乎完全来自手推过一遍。梳理下来,有三个式子是绕不开的。

式子作用容易写错的地方
逻辑回归的 $dZ = A - Y$单层输出层的梯度起点把它当成通用公式套到隐藏层
交叉熵对 $Z$ 的导数与 sigmoid 求导抵消忘了这两个推导是绑在一起的
第 $l$ 层 $dW^{[l]} = dZ^{[l]} A^{[l-1]T}/m$参数梯度转置方向反了、漏除 $m$

第一处的意义在于:$dZ = A - Y$ 这个极简形式,是 sigmoid 配交叉熵互相抵消的结果,换成别的激活函数或别的损失就不成立。很多人在深层网络里对输出层直接写dZ = A - Y,恰好是对的,因为输出层通常就是这套组合;但对隐藏层也这么写就错了,隐藏层要走链式法则,dZ[l] = W[l+1].T @ dZ[l+1] * g'(Z[l])。

第二处的关键在于求导顺序。作业里一般要求先算dZ,再由dZ推dW、db、dA_prev,这个顺序保证了每一步只依赖已知量,不会出现“要算 A 得先知道 B”的死循环。手推一遍之后,看到代码里的四行反向传播,脑子里能自动补出对应的公式,而不是把它当成一段黑匣子。

第三处是维度校验的落点。dW[l]的形状必须等于W[l],db[l]必须等于b[l],这两个等式在每一层都成立。作业里如果某一层训练不收敛,先查这两个形状,比查学习率快得多。

注意:推导时把 $m$ 显式写在分母上,不要图省事最后统一除。中间量的形状和量纲一旦模糊,后面梯度检验的误差会大到看不出是公式错还是实现错。

2.3 深层网络:缓存、激活函数与维度检查表

深层网络的作业题一般会给一个固定的骨架:初始化参数、前向传播(逐层缓存A_prev、W、b、Z)、计算损失、反向传播、更新参数。骨架不难,难在缓存变量用错。反向传播时要用到前向的中间结果,如果缓存的是激活后的A而公式需要激活前的Z,或者层号错位一位,代码能跑,梯度却是错的。

下面这张检查表可以贴在旁边,逐层对一遍再提交。

检查项正确形态出错表现
缓存键名caches[l] = (A_prev, W, b, Z)键名顺序混乱导致取错
层号范围前向从 1 到 L,反向从 L 到 1反向写成 1 到 L,梯度全错
激活函数隐藏层 ReLU,输出层 sigmoid输出层用 ReLU,损失算不出
参数字典W1..WL、b1..bL循环里拼接字符串时索引错位

激活函数的选择在作业里通常有明确要求,但背后的理由值得写下来:ReLU 在正区间的导数恒为 1,避免了 sigmoid 在深处把梯度压到接近零;而输出层用 sigmoid 是因为要做二分类,输出要落在 0 到 1 之间并能直接进交叉熵。这两个选择不是惯例,是被损失函数和任务形式决定的。

维度检查有一个便宜的做法:给每一层打印A.shape,正常应该是(n[l], m),其中 $m$ 是样本数,全程不变。如果某一层 $m$ 变了,多半是哪里用了sum而不是sum(axis=1, keepdims=True),把样本维度压掉了。这个错误在深层网络里特别隐蔽,因为 numpy 会自动补维度,直到最后算损失时才报形状不匹配,而那时已经离出错点很远了。

3. 训练层:初始化、优化器与调参顺序的作业套路

基础层解决的是“算得对”,训练层解决的是“训得动”。这一层的作业题往往会给出几组对比实验:不同初始化、不同优化器、不同学习率,然后要求画出训练损失和验证准确率曲线并解释差异。这些对比如果只是跑完截个图,价值很有限;真正有价值的是能从曲线形状反推训练出了什么问题。

3.1 初始化:全零为什么必然出问题,He 初始化怎么选

把所有权重初始化为零,网络在数学上仍然能前向传播,但所有隐藏单元会算出完全相同的结果,反向传播时梯度也完全相同,更新之后依然完全相同。整个隐藏层退化成一个神经元,网络的表达能力被彻底浪费。这个结论在作业里通常会用一个对比实验验证:全零初始化跑出来的损失曲线几乎是一条平线,准确率停在随机猜测附近。

随机初始化解决了对称性问题,但尺度没解决。权重太小时,激活值逐层衰减,梯度也跟着衰减;权重太大时,激活值逐层放大,最后溢出。作业里常见的做法是按输入维度缩放:

def he_init(layer_dims, seed=3): """layer_dims: [n_x, n_1, ..., n_L],逐层宽度""" np.random.seed(seed) # 固定种子,保证对比实验可复现 params = {} for l in range(1, len(layer_dims)): fan_in = layer_dims[l - 1] # 除以 fan_in 的平方根:让每层输出的方差大致保持不变 params[f"W{l}"] = np.random.randn(layer_dims[l], fan_in) * np.sqrt(2.0 / fan_in) params[f"b{l}"] = np.zeros((layer_dims[l], 1)) return params

这里2.0 / fan_in里的 2 来自 ReLU 只保留正半轴、方差减半的补偿,作业里如果用的是 tanh,系数会换成1.0 / fan_in。b 初始化为零是安全的,因为对称性已经被 W 打破,偏置不需要再承担这个职责。种子固定的意义在于:对比初始化效果时,唯一变量必须只有初始化方式,否则随机性会把结论搅乱。

提示:如果换了初始化之后损失下降反而变慢,先确认学习率是否跟着改了。初始化和学习率是耦合的,权重量级变了,合适的学习率区间也会平移。

3.2 优化器:动量、RMSProp 到 Adam 各自在修什么

作业里会依次实现三种优化器,代码量都不大,但每一步在修什么问题值得写清楚。

优化器核心动作修的问题
动量梯度下降用梯度的指数加权平均更新鞍点附近来回震荡、下降方向不稳
RMSProp用梯度平方的指数加权平均缩放学习率不同参数方向的尺度差异过大
Adam两者结合并做偏差修正前几轮估计不准、冷启动震荡

动量做的事是给梯度加惯性:连续几轮方向一致的维度越走越快,来回震荡的维度互相抵消。RMSProp 做的是给每个参数单独调学习率:历史梯度大的参数步子收小,历史梯度小的参数步子放大。Adam 把两件事拼起来,再加上偏差修正,因为指数加权平均在最初几轮会严重偏小,不修正的话第一步几乎是原地不动。

作业里常见的坑是把beta1、beta2、epsilon的顺序记反。约定是beta1管一阶矩(梯度均值),beta2管二阶矩(梯度平方均值),epsilon只用来防止除零,一般取1e-8,不要为了“让训练稳一点”随手调到1e-3,那会把缩放效果直接抹掉。

def update_with_adam(params, grads, state, t, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8): """state 里存 v(一阶矩)和 s(二阶矩),t 是全局步数,从 1 开始""" for k in params: state.setdefault(k, {"v": 0, "s": 0}) v = beta1 * state[k]["v"] + (1 - beta1) * grads["d" + k] s = beta2 * state[k]["s"] + (1 - beta2) * (grads["d" + k] ** 2) # 偏差修正:t 很小时,v_hat 和 s_hat 才接近真实均值 v_hat = v / (1 - beta1 ** t) s_hat = s / (1 - beta2 ** t) params[k] -= lr * v_hat / (np.sqrt(s_hat) + eps) state[k]["v"], state[k]["s"] = v, s

t从 1 开始还是从 0 开始,会在第一步产生明显差别:从 0 开始,1 - beta1 ** 0等于 0,直接除零。这个细节作业里不会强调,但它是很多“第一次更新就变成 NaN”的来源。

3.3 梯度检验与调参顺序:先动哪个旋钮

梯度检验是这里的“后悔药”。数值梯度和解析梯度的相对误差低于1e-7基本可以放心,1e-5量级需要留意,1e-3以上基本可以判定反向传播写错了。

def grad_check(forward, params, X, Y, eps=1e-7): """forward 返回 (grads, cost),只用于小规模抽查""" grads, _ = forward(params, X, Y) num = {k: np.zeros_like(v) for k, v in params.items()} for k, v in params.items(): for idx in np.ndindex(v.shape): old = v[idx] v[idx] = old + eps _, c_plus = forward(params, X, Y) v[idx] = old - eps _, c_minus = forward(params, X, Y) v[idx] = old # 用完必须还原 num[k][idx] = (c_plus - c_minus) / (2 * eps) # 分子分母都做归一化,避免量级差异干扰判断 diff = {k: np.linalg.norm(num[k] - grads["d" + k]) / (np.linalg.norm(num[k]) + np.linalg.norm(grads["d" + k]) + 1e-12) for k in params} return diff

这段代码的两个约定比代码本身重要。一是必须关掉随机的部分(dropout、数据增广),否则同一个参数在c_plus和c_minus两次计算里走的是不同的网络,差值没有意义。二是只在少量参数上抽查,全量跑一遍的代价是前向传播次数的平方级增长,作业里的网络规模撑不住。

调参的顺序,梳理下来应该固定成:先确认梯度检验通过,再调学习率,然后调网络宽度和深度,最后调正则化强度。理由很直接:梯度错了,后面所有旋钮都在错误的方向上白拧;学习率是最敏感的一维,它定下来之后其他参数的变化才有可比性;网络容量和正则化是一对,容量没定之前调正则化只是瞎猜。

4. 结构层:卷积网络的参数量与序列模型的梯度难题

结构层是这套作业里分量最重的部分,因为它开始处理真实形状的输入:图像是三阶张量,文本是变长序列,前面那套“样本按列排”的约定要重新推一遍。这一层的作业题有个共同特点——代码能跑通不等于理解,必须能手算出参数量和输出尺寸,才算真的过。

4.1 卷积与池化:输出尺寸、参数量必须能手算

卷积层的输出尺寸公式是这一层的基础:

$$ n_{out} = \left\lfloor \frac{n_{in} + 2p - f}{s} \right\rfloor + 1 $$

其中 $n_{in}$ 是输入边长,$f$ 是卷积核大小,$p$ 是填充,$s$ 是步长。作业里会给几组参数让人手算,然后和代码输出对比。这个练习的价值在于:很多形状不匹配的错误,其实在写代码之前用纸笔就能发现。

参数量是另一个必须手算的量。一个卷积层的参数量是 $f \times f \times c_{in} \times c_{out} + c_{out}$,前一项是权重,后一项是偏置。这里有个反直觉的地方:卷积层的参数量与输入图像的尺寸无关。一张 $64 \times 64$ 的图和一张 $256 \times 256$ 的图,只要通道数和卷积核一样,这一层的参数量完全相同。这个性质是卷积相对全连接的核心优势,作业里通常会要求对比两种结构的参数量,算完就明白为什么图像任务离不开卷积。

结构输入配置参数量
卷积层$64\times64\times3$$f=3, c_{out}=32, p=1, s=1$$3\times3\times3\times32+32=896$
同尺寸全连接$64\times64\times3$输出 32 维$12288\times32+32\approx39$ 万
池化层$64\times64\times32$$f=2, s=2$0

池化层没有可学习参数,它只做下采样,作用是压缩空间尺寸、扩大后续层的感受野。作业里容易写错的是池化的反向传播:最大池化只把梯度回传给取到最大值的位置,其余位置为零;平均池化则把梯度均分到窗口内每个位置。用错会让梯度检验的误差看起来像“小问题”,实际是梯度语义错了。

注意:padding在代码里通常是四元组或分通道补,手算时先确认是“四周各补 p”还是“总共补 p”,两种约定算出来的尺寸差一倍。

4.2 残差与 1×1 卷积:深度上去之后为什么还要它们

作业里会有一段把网络堆到几十层,然后观察训练损失的变化。纯堆叠的深层网络会出现一个奇怪现象:层数增加,训练损失反而更大。这不是过拟合,过拟合的表现是训练损失下降、验证损失上升;这里训练损失本身就下不去,说明优化难度上来了。

残差连接修的就是这个。它把某一层的输入直接加到输出上,让网络学习残差 $F(x) = H(x) - x$ 而不是完整的 $H(x)$。这样做的好处是:如果这一层什么都不学,输出等于输入,至少不会变差;梯度也能沿着跳跃连接直接回传,不必穿过一长串非线性变换。

1×1 卷积则解决通道维度的压缩与扩张。它的参数量是 $c_{in} \times c_{out}$,计算量小,作用却很大:先降维再卷积,能把 3×3 卷积的计算量压下来一个量级。作业里如果给出“瓶颈结构”的配置,一般是 1×1 降维、3×3 卷积、1×1 升维三步,参数量的账要分开算,不能笼统地乘在一起。

这两样结构在作业答案里往往只是一段现成代码,但它们的动机必须自己讲得出来:残差是为了让深层网络可优化,1×1 卷积是为了让深层网络算得起。两者一个管梯度,一个管算力,解决的问题完全不同。

4.3 序列模型:RNN 的梯度消失与注意力的动机

序列部分的作业从最朴素的 RNN 开始,前向传播按时间步展开,每个时间步复用同一组参数。这种参数共享让模型能处理任意长度的输入,但也埋下了梯度消失的根:反向传播要沿时间步连乘,连乘的因子是权重矩阵和激活函数导数的乘积,只要这个乘积长期小于 1,梯度传到十几步之前就接近零了。

作业里一般会用梯度裁剪来缓解,裁剪的做法是给梯度的范数设一个上限,超过就按比例缩回去:

def clip(gradients, max_value): """逐元素裁剪,防止单步更新把参数冲出合理区间""" clipped = {} for k, v in gradients.items(): clipped[k] = np.clip(v, -max_value, max_value) return clipped

裁剪解决的是梯度爆炸,对梯度消失帮助有限。真正针对性的是门控结构,用门来决定信息保留多少、丢弃多少,让梯度有一条更通畅的回传路径。

注意力机制的动机则更直接:RNN 把所有历史压进一个固定维度的隐状态,序列一长,早期信息必然被稀释。注意力让解码的每一步都能直接看到编码器所有时间步的输出,按相关性加权,路径短了,梯度也就没那么容易丢。作业里通常会给一个简化版的注意力计算,核心就是查询与键做点积、缩放、归一化、加权求和四步,参数不多,但每一步的形状要盯紧:注意力权重矩阵的形状是“目标长度 × 源长度”,转置方向反了不会报错,只会让对齐关系整体错乱。

5. 易错点:五处最容易出错、又最不容易报错的地方

调试这部分内容时,最难的从来不是报错,而是不报错。下面这五处,代码会安静地跑完,损失也在缓慢下降,但结果就是比预期差一截,而且找不到原因。

5.1 数学与形状类:广播、维度与正则化系数

易漏点一:numpy 广播把形状错误藏起来。为什么不容易发现:(n, 1)和(1, m)相加会广播成(n, m),不报错,值也有意义,只是语义悄悄变了。反向传播里这种错误会让某个梯度多出一维,更新时又广播回去,表现成“训练能跑但收敛慢”。怎么补:在所有涉及形状的赋值后加一行断言,例如assert dw.shape == w.shape,把错误拦在发生的那一行。

易漏点二:正则化项的系数与求导不一致。为什么不容易发现:代价函数里加了lambda / (2m) * sum(W**2),反向里却写成lambda / m * W或者干脆漏掉 2m 的分母,损失曲线依然下降,只是正则强度悄悄翻倍或减半。怎么补:把代价函数和梯度函数并排写,逐项对照,重点看分母;然后用梯度检验验证,正则项写错时相对误差会稳定在1e-5到1e-4这个不高不低的区间,正好卡在“看起来还行”的位置。

易漏点三:dropout 开着做梯度检验。为什么不容易发现:dropout 每轮随机丢弃单元,c_plus和c_minus走的是两个不同的子网络,数值梯度本身就是噪声,误差看起来只是“有点大”,很容易被当成精度问题放过去。怎么补:梯度检验前显式把keep_prob置为 1,把随机性关掉;训练时再打开,两套配置分开跑。

5.2 工程与调参类:模式切换、种子与学习率

易漏点四:训练模式和评估模式混用。为什么不容易发现:带 dropout 或批归一化的网络,训练时用随机丢弃和批统计量,评估时必须关掉丢弃、改用全局统计量。如果评估时忘了切换,验证准确率会明显偏低,但代码不报错,很容易被误读成“模型过拟合了”。怎么补:把模式切换封装成函数,训练循环开始和评估开始各调一次,不要靠记忆;验证集指标每次都在切换之后算。

易漏点五:随机种子只固定了一处。为什么不容易发现:初始化固定了种子,但数据打乱、dropout、参数初始化各用各的随机源,两次运行结果仍然不同,复现失败时以为是算法不稳定。怎么补:在训练入口统一设一次种子,并确认所有随机操作都走同一个全局随机源;对比实验时把种子当成一个必须记录的超参数写进实验日志。

提示:这五条里,前三条用三行断言和一次梯度检验就能拦住,后两条靠流程约束。经验上,调试时间的大部分花在“能跑但不对”上,而不是报错上。

6. 复习清单:按模块自查的六张表与一个自检脚本

复习到这一层,最有效的做法不是重读代码,而是拿一张清单逐项打勾。下面这张表按模块拆开,每一项后面都跟一个可验证的动作,不是“掌握了”这种模糊判断。

模块必须会做的事自查动作
向量化手写逻辑回归前向反向不看答案默写propagate,跑一次梯度检验
损失与梯度说出 $dZ = A - Y$ 的成立条件换成交叉熵 + softmax,重新推一遍
初始化解释全零失效的原因用全零和 He 各跑一次,对比损失曲线前 100 轮
优化器分清一阶矩和二阶矩故意把beta1和beta2对调,观察发散
正则化说清 L2 与 dropout 的区别固定其他变量,只改lambda,看训练与验证的间距
卷积手算输出尺寸和参数量随机给三组f/p/s,手算后与代码结果对照

配套的自检脚本不必复杂,核心是把“形状、数值、随机性”三件事变成可执行的断言:

def sanity_check(params, grads, forward, X, Y): # 1. 形状:每个梯度必须和对应参数同形 for k in params: assert grads["d" + k].shape == params[k].shape, f"{k} 形状不匹配" # 2. 数值:损失必须是有限值,不能是 nan 或 inf _, cost = forward(params, X, Y) assert np.isfinite(cost), "损失出现 nan/inf,先查学习率和 log(0)" # 3. 随机性:同一种子连跑两次,结果必须完全一致 a = forward(params, X, Y)[1] b = forward(params, X, Y)[1] assert a == b, "存在未固定的随机源"

这三条断言看起来过于基础,但它们对应的正是前面五处易错点里最难查的那几类。形状断言拦住广播错误,有限值断言拦住除零和溢出,一致性断言拦住种子没固定。把它挂在训练入口,每次改动之后自动跑一遍,比事后翻日志快得多。

复习的节奏建议按“一天一模块、三天一轮回”推进:第一轮只看表、只做自查动作,不重读代码;第二轮专攻打勾失败的那几项;第三轮把整套作业里最难的两道题从头默写一遍,卡住的地方就是真正的薄弱点。

我自己的习惯是:每次重跑任何一份作业答案之前,先把sanity_check跑一遍,不管上次是不是刚跑通过。有一次图省事跳过了,结果一个广播错误藏在第三层,损失曲线看着挺正常,白白调了两天学习率。从那以后我每次改动参数或换数据集都强制走一遍这三条断言,再决定要不要看曲线。希望帮到你。

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