资讯详情

cs231n assignment1图像分类流水线与易错点复盘

📅 2026/10/29 23:42:23 | 华诺云谱 👁 阅读
cs231n assignment1图像分类流水线与易错点复盘
简介面向深度学习与计算机视觉入门学习者的CS231n课程作业包聚焦assignment1阶段的分类器与神经网络基础实践适合希望借助Jupyter Notebook动手复现课堂实验、对照检查作业完成情况的学生与自学者。压缩包共54个文件约561.98MB以ipynb交互笔记、py源码脚本、pyc缓存、xml工程配置及tar/zip格式数据集为主覆盖KNN、SVM、Softmax、特征提取和两层神经网络等模块并附带CIFAR-10数据与提交脚本。已有2006人学习说明其在课程实践群体中具有一定参考价值。读者可据此梳理数据预处理、模型训练、梯度检查与结果可视化的完整流程理解作业答案的实现思路也能借助README和目录结构在Anaconda环境中快速定位各实验内容适合边学边练、查漏补缺。1. 一条被拆成五段的深度学习流水线深度学习入门里很少有哪份作业像 cs231n assignment1 这样把「图像分类」这条流水线从头到尾摊开数据怎么进来、分数怎么算出、损失怎么定义、梯度怎么求、参数怎么调五个作业文件恰好对应五个环节。它不训练大模型只要求手写 kNN、SVM、Softmax 和两层网络最后用 HOG 加颜色直方图收尾。真正从头跑一遍会发现卡住人的从来不是公式本身而是维度对不对得上、数值会不会溢出、调参有没有章法。这份笔记就按这条主线把每个环节的要点和踩坑处逐条理一遍。2. 数据与基线预处理、划分和 kNN 的三个版本这章对应 kNN 和数据处理那部分。看起来最没技术含量实际上后面所有模块的报错一大半能追到这里埋下的种子。2.1 一张图变成一行数reshape 的顺序问题CIFAR-10 的原始张量形状是(50000, 3, 32, 32)通道在最前面。展开成(50000, 3072)时用的是默认的 C order也就是每一行的前 1024 维全是 R 通道、接着 1024 维是 G、最后是 B。这个顺序本身没问题问题在于它必须和可视化、和特征提取、和后来所有基于索引的操作保持一致。一旦想把样本还原成图片看效果就得写成X.reshape(N, 3, 32, 32).transpose(0, 2, 3, 1)把通道挪到最后才能给绘图库用。如果偷懒直接X.reshape(N, 32, 32, 3)图片会花成一团彩色噪点而且不报错——这是最典型的「静默失败」代码全绿、结果全错。判断方法很简单随便挑一张训练图用两种方式各画一次肉眼看哪个是人脸哪个是雪花。内存也得心里有数。50000 × 3072的 float64 数组大约 1.17 GBfloat32 减半。distance 矩阵500 × 5000是 2.5×10⁷ 个元素float64 下 200 MB一次性开出来没问题但如果有人顺手算了整个训练集对训练集的距离50000 × 50000那就是 20 GB 起步机器会直接卡死。所以做 kNN 时永远拿测试集那 500 张去对训练集别贪心。2.2 预处理与数据划分验证集是唯一的裁判预处理在作业里只要求做最基础的一步减均值。但「用谁的均值」这个细节藏着数据泄漏的隐患——必须先用训练集算出均值向量再把这个均值应用到验证集和测试集上。如果在合并后的全量数据上算均值验证集的信息就提前漏进了训练过程交叉验证选出来的超参会偏乐观。这个错误在课程作业里不会扣分但在真实项目里会让人对模型效果产生系统性误判。常见的三件套是中心化每个特征减均值让数据云围绕原点、归一化再除以标准差让各维度尺度一致、以及 PCA 和白化去相关、除特征值。assignment1 主要用前两个白化在注释里提了一句做不做都不影响主流程。像素值本身已经在 0255 的同一量纲上所以归一化的收益不如在异质特征比如把 HOG 和颜色直方图拼起来上明显——这一点在最后一章还会回来。数据划分是train / val / test三分。训练集学参数验证集调超参测试集只在最后报告一次。交叉验证的写法是把训练集切成 5 折轮流留一折当验证5 次准确率取平均。代码框架大致是# 5 折交叉验证num_folds5 X_train_folds np.array_split(X_train, num_folds) y_train_folds np.array_split(y_train, num_folds) for k in k_choices: accuracies [] for i in range(num_folds): # 留出第 i 折做验证其余拼接成训练子集 X_val_fold X_train_folds[i] y_val_fold y_train_folds[i] X_tr np.concatenate([f for j, f in enumerate(X_train_folds) if j ! i]) y_tr np.concatenate([f for j, f in enumerate(y_train_folds) if j ! i]) classifier KNearestNeighbor() classifier.train(X_tr, y_tr) y_pred classifier.predict(X_val_fold, kk, num_loops0) accuracies.append(np.mean(y_pred y_val_fold)) print(fk{k}: 平均准确率 {np.mean(accuracies):.4f})这里有两个参数值得说num_folds越大每折验证集越小、评估方差越大5 折或 10 折是常规选择k的搜索范围一般取 1 到 20 之间的若干值太大会把整个训练集都算进邻居预测退化成「全局多数类」。跑完通常能看到一条先升后降的曲线峰值对应的 k 就是验证集给出的答案。注意np.array_split和np.split的区别——前者允许不能整除后者会直接抛异常。2.3 kNN两个循环、一个循环、零个循环kNN 在作业里要写三个版本这不是为了凑工作量而是让人亲手感受向量化带来的数量级差异。实现方式核心写法复杂度主要瓶颈两层循环对每个测试样本、每个训练样本单独算 L2O(N·M·D)Python 层循环500×5000 要跑几十秒一层循环固定一个测试样本对全体训练样本向量化O(N·M·D) 但常数小仍有一次 N 规模的外层循环零层循环用‖x−y‖² ‖x‖² ‖y‖² − 2x·y一次算完矩阵乘法走 BLAS内存占用 N×M零循环版本是重点也是梯度检验之外最能体现「会不会用 NumPy」的地方def compute_distances_no_loops(self, X): # 展开平方||x-y||^2 ||x||^2 ||y||^2 - 2 * x·y X_sq np.sum(X ** 2, axis1, keepdimsTrue) # (N, 1) T_sq np.sum(self.X_train ** 2, axis1)[None, :] # (1, M) cross X self.X_train.T # (N, M) dists X_sq T_sq - 2 * cross # 浮点误差可能算出 -1e-12直接开根会得到 nan return np.sqrt(np.maximum(dists, 0))keepdimsTrue是让(N,)保持成(N,1)否则广播方向会跑偏得到一个(N,N)的畸形矩阵。np.maximum(dists, 0)是必须加的保险三个大数相减时理论上相等的距离可能算出极小的负数np.sqrt遇到负数会返回nan而nan会沿着后续所有计算传播最后表现为「准确率莫名其妙是 0」排查起来极其费劲。这是我在这一块踩过的最深的坑。predict_labels里同样有细节。取 k 个最近邻用np.argsort(dists[i])[:k]数据量大时换成np.argpartition(dists[i], k)[:k]更快只做部分排序。投票用np.bincount(closest_labels, minlengthnum_classes).argmax()minlength不能省否则如果某一类恰好没出现在这 k 个邻居里数组长度会缩水索引就对不上了。平票时argmax返回最小的类别索引虽然有点随意但至少结果是确定的——用偶数 k 时更容易平票所以 k 一般取奇数。kNN 真正的价值不在精度。它在 CIFAR-10 上做到 0.28 左右就到顶了再怎么调 k 也上不去。它的价值在于提供一个可运行的基线并且用「训练几乎零成本、预测极慢」这个反直觉的特性让人第一次意识到「参数化模型」意味着什么线性分类器一次训练、永久快速预测而 kNN 每次预测都要翻一遍整个训练集。这条对照线是理解后面所有内容的前提。3. 线性分类器SVM、Softmax 与梯度的两种解法线性分类器的核心就一句话s W·x b把一个 3072 维的像素向量映射到 10 个分数上。难的是怎么定义「分得好」以及怎么把这个定义变成可优化的方向。3.1 多类 SVM 和 hinge loss只关心「赢多少」多类 SVM 的损失是逐样本算的$$L_i \sum_{j \neq y_i} \max(0,\ s_j - s_{y_i} \Delta)$$意思是正确类别的分数要比每个错误类别高出至少一个边界值 Δ作业里固定取 1超出的部分不奖励不足的部分按差额惩罚。这个「够了就不管」的设计就是 hinge 的名字来源也是它和 Softmax 最本质的区别——SVM 对「已经赢了很多」的样本不再施加梯度。朴素实现是两层循环外层遍历样本、内层遍历类别def svm_loss_naive(W, X, y, reg): loss 0.0 dW np.zeros_like(W) num_train X.shape[0] for i in range(num_train): scores X[i] W # (C,) correct scores[y[i]] for j in range(W.shape[1]): if j y[i]: continue margin scores[j] - correct 1.0 # Δ 1 if margin 0: # 只有越界的才贡献损失和梯度 loss margin dW[:, j] X[i] # 错误类分数上升 → 该列加 x dW[:, y[i]] - X[i] # 正确类分数被压 → 该列减 x loss loss / num_train 0.5 * reg * np.sum(W * W) dW dW / num_train reg * W # 正则项对梯度的贡献 return loss, dW手推一遍梯度比背公式有用得多。对W[:, j]求导s_j W[:, j]·xmargin 对s_j的偏导是 1所以只要 margin 越过 0就把x累加到第 j 列同时s_{y_i}在 margin 里带负号所以正确类别那一列要减去x且减去的是「越界次数」那么多份。写成向量化版本时用margins scores - correct[:, None] 1把正确位置置 0mask (margins 0)再对 mask 求和得到每行的越界计数num_train X.shape[0] scores X W correct scores[np.arange(num_train), y][:, None] margins np.maximum(0, scores - correct 1.0) margins[np.arange(num_train), y] 0.0 # 正确类不参与 loss np.sum(margins) / num_train 0.5 * reg * np.sum(W * W) mask (margins 0).astype(np.float64) mask[np.arange(num_train), y] -np.sum(mask, axis1) # 正确类列填负计数 dW X.T mask / num_train reg * W那个「正确类列填负计数」的技巧是整段代码里最妙的一笔把「每越界一次就减一份 x」压缩成一次性乘以计数省掉了内层循环。3.2 Softmax 和交叉熵把分数变成概率Softmax 走的是另一条路先把分数指数化再归一化成概率然后取正确类别概率的负对数。$$L_i -\log\frac{e^{s_{y_i}}}{\sum_j e^{s_j}}$$它和 SVM 的差别体现在梯度上SVM 只从「没赢够」的错误类拿梯度Softmax 则对所有类别都有梯度概率越高的错误类被压得越狠而且永远不会真正归零。实际训练里两者精度通常相差不大裸像素上都在 0.350.40 一带但 Softmax 的输出有概率含义做集成、做置信度阈值都更方便。实现上唯一不能忘的是数值稳定def softmax_loss_vectorized(W, X, y, reg): num_train X.shape[0] scores X W # 减去每行最大值exp 的结果不会溢出且数学上完全等价 scores - np.max(scores, axis1, keepdimsTrue) exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) loss -np.sum(np.log(probs[np.arange(num_train), y])) / num_train loss 0.5 * reg * np.sum(W * W) dscores probs.copy() dscores[np.arange(num_train), y] - 1.0 # 概率减 one-hot dW X.T dscores / num_train reg * W return loss, dW为什么不减最大值会翻车CIFAR-10 的分数在训练初期量级不大但学习率稍大就会出现几百上千的分数np.exp(1000)直接变成infinf / inf得到nanloss 变成nan之后再怎么调学习率都救不回来只能重启训练。减最大值这个操作不改变任何数学结果分子分母同乘一个常数纯粹是给浮点数留活路。同样的道理也适用于np.log概率理论上不会是 0但下溢之后会变成 0log(0)就是-inf所以有些实现会用logsumexp的写法绕开。3.3 数值梯度和解析梯度梯度检验怎么写才靠谱作业要求实现svm_loss_naive之后做一次梯度检验用中心差分近似$$\frac{\partial L}{\partial w} \approx \frac{L(wh) - L(w-h)}{2h}$$def grad_check_sparse(f, x, analytic_grad, num_checks10, h1e-5): for _ in range(num_checks): ix tuple([np.random.randint(m) for m in x.shape]) oldval x[ix] x[ix] oldval h fxph f(x) # f(x h) x[ix] oldval - h fxmh f(x) # f(x - h) x[ix] oldval # 还原别污染原数组 grad_numerical (fxph - fxmh) / (2 * h) grad_analytic analytic_grad[ix] rel_error abs(grad_numerical - grad_analytic) / \ max(1e-8, abs(grad_numerical) abs(grad_analytic)) print(f{ix}: 相对误差 {rel_error:.2e})几个必须知道的判断标准相对误差在 1e-7 以下说明实现正确1e-4 到 1e-7 之间要警惕通常是 h 选得不好或者某一项漏了大于 1e-2 基本可以确定写错了。h 的取值是权衡太大泰勒展开的高阶项截断误差明显太小fxph - fxmh两个相近的数相减会产生灾难性抵消。1e-5 附近是比较稳的区间前提是全程用 float64float32 在 1e-5 的步长下根本分辨不出差异。还有一个容易被跳过的步骤先设reg0做一次检验再把reg调大做一次。第一次只验证数据项第二次才验证正则项。很多人一直用默认的 reg 跑检验看到误差合格就收工结果忘记dW里的 reg * W正则化实际没生效模型在训练集上过拟合得一塌糊涂却找不到原因。这是典型的「梯度检验通过了但代码是错的」。3.4 SGD、学习率和正则化强度参数更新本身只有一行W - learning_rate * dW。真正的工作量在于找那两个数。学习率和正则化强度都跨越好几个数量级靠谱的做法是对数尺度网格搜索学习率试1e-7 / 3e-7 / 1e-6 / ... / 1e-2正则化试1e-5 / 1e-4 / ... / 1e-1每一组在训练集上跑若干轮、在验证集上记准确率画成热力图看哪片区域高。手调的话先固定 reg、只动学习率找到 loss 能稳定下降的最大值再回头调 reg。判断过拟合和欠拟合的看家本领是画两条曲线训练准确率和验证准确率。如果训练一路涨到 0.9 而验证卡在 0.35中间一条大缝就是过拟合加 reg如果两边都低、几乎贴在一起就是欠拟合得加模型容量或者调学习率。还有一个常见现象是 loss 曲线出现nan或者剧烈震荡那基本是学习率太大先除以 10 再看。最后补一个容易被忽略的开销SVM 和 Softmax 的损失函数里那个0.5系数。0.5 * reg * sum(W*W)求导正好得到reg * W系数是为了让梯度表达式干净。写成reg * sum(W*W)也不会错但有效正则强度就变成了两倍和别人的实验结果对不上会让人误以为是随机种子的问题白折腾半天。4. 表示的两条路隐层与手工特征到这一章前面搭好的东西开始合流。线性分类器的天花板很明显——它只能在像素空间里画超平面而像素空间的距离和语义几乎无关两张猫的 L2 距离可能比猫和狗还远。要突破这个天花板只有两条路让模型自己学表示两层神经网络或者人手工设计表示HOG、颜色直方图。这两条路在 assignment1 里同时出现是个很妙的对照。4.1 两层网络的维度对齐网络结构本身很简单输入 3072 → 隐层 H常用 100500→ ReLU → 输出 10。def loss(self, X, yNone, reg0.0): W1, b1 self.params[W1], self.params[b1] # (D, H), (H,) W2, b2 self.params[W2], self.params[b2] # (H, C), (C,) N, D X.shape # 前向每一层的输出形状都标在旁边 hidden X W1 b1 # (N, H) relu np.maximum(0, hidden) # (N, H) scores relu W2 b2 # (N, C) if y is None: return scores scores - np.max(scores, axis1, keepdimsTrue) exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) loss -np.sum(np.log(probs[np.arange(N), y])) / N loss 0.5 * reg * (np.sum(W1 * W1) np.sum(W2 * W2)) # 偏置不正则 dscores probs.copy() dscores[np.arange(N), y] - 1.0 dscores / N # 注意这里除了一次 grads[W2] relu.T dscores reg * W2 # (H,N)(N,C) grads[b2] np.sum(dscores, axis0) drelu dscores W2.T # (N,H) dhidden drelu * (hidden 0) # ReLU 的导数 grads[W1] X.T dhidden reg * W1 grads[b1] np.sum(dhidden, axis0)维度是这个模块最好的自检工具。X W1要求X的第二维等于W1的第一维relu.T dscores要求relu的第二维等于dscores的第一维——任何一步的 shape 对不上NumPy 会立刻报错比逻辑错误好查一万倍。建议在草稿纸上把每层的(N, D) → (N, H) → (N, C)写一遍再写代码。4.2 反向传播把链式法则拆成可检查的几步反向传播的公式推导不复杂但实现时有两处特别容易写错。第一处是dscores / N的位置。loss 里已经有了1/N但这个因子必须一路传回所有参数的梯度。写在dscores上最省事因为后面所有梯度都是通过dscores乘出来的一次除法就够如果分开写很容易在W1那边漏掉。第二处是 ReLU 的导数。dhidden drelu * (hidden 0)用hidden而不是relu做判断。两者在这里恰好等价relu 0和hidden 0同时成立但如果有人把激活函数换成 LeakyReLU 却还沿用relu 0就会静默地丢掉负半轴的梯度——训练仍然能跑只是效果差一截非常难查。验证整套反向传播是否正确的办法还是梯度检验只是这里要检验的参数有四个逐个来# 用一个很小的网络H5N3跑检验代价低、差异明显 net TwoLayerNet(input_dim4, hidden_dim5, num_classes3) loss, grads net.loss(X, y, reg0.1) for param_name in [W1, b1, W2, b2]: f lambda _: net.loss(X, y, reg0.1)[0] print(param_name) grad_check_sparse(f, net.params[param_name], grads[param_name], num_checks5)注意那个reg0.1同时出现在loss和检验函数里。如果两处的 reg 不一致比如检验时忘了传 reg用的是默认 0数值梯度和解析梯度必然对不上但这跟实现对不对一点关系都没有纯粹是自己给自己挖坑。4.3 训练循环、batch size 与那一行 sanity check训练循环的结构是固定的洗牌、切 batch、前向、反向、更新、记录。for it in range(num_iters): idx np.random.choice(num_train, batch_size, replaceTrue) X_batch, y_batch X[idx], y[idx] loss, grads self.model.loss(X_batch, y_batch, regreg) self.model.params[W1] - learning_rate * grads[W1] self.model.params[b1] - learning_rate * grads[b1] self.model.params[W2] - learning_rate * grads[W2] self.model.params[b2] - learning_rate * grads[b2] if it % 100 0: # 每 100 步衰减一次后期步长变小才能收敛到更细的谷底 learning_rate * 0.95几个参数的经验区间batch_size取 200 左右太小梯度噪声大、太大会拖慢单步num_iters几千步足够再多收益递减学习率典型值在 1e-3 到 1e-4 之间配合每轮衰减 0.95训练后期 loss 还能再往下压一截。隐层宽度从 100 加到 500准确率通常能涨几个百分点但训练时间也线性上涨作业里取 200300 是性价比最高的区间。最该养成习惯的是训练开始前的那一行自检打印初始 loss期望值应该接近ln(10) ≈ 2.30。因为随机初始化的网络对 10 个类别应该输出大致均匀的概率-log(0.1)就是 2.30。如果初始 loss 是 0.5说明初始化让某些类别天然占优可能是W初始化尺度不对如果是 30 以上说明分数尺度太大exp已经快到溢出边缘了。这一步花两秒钟能省掉后面几小时的重启调试属于性价比最高的后悔药。4.4 HOG 与颜色直方图手工特征为什么还能打手工特征这一节经常被当成附加题草草跳过但它是整份作业里对「表示」理解最深的一块。思路是与其把原始像素喂给线性分类器不如先人工构造一组更能反映语义方向的统计量。HOG方向梯度直方图描述局部形状颜色直方图描述全局色调两个拼起来一个线性分类器就能在 CIFAR-10 上明显超过裸像素。HOG 的计算步骤可以拆成四步。先把 32×32 的图像转灰度然后算每个像素的水平梯度和垂直梯度得到梯度幅值和方向接着把图像切成 8×8 的 cell共 4×416 个在每个 cell 内统计 9 个方向0°180° 均分的梯度幅值直方图最后做块归一化把相邻 2×2 个 cell 组成一个 block滑动步长 1 个 cell得到 3×39 个 block每个 block 内 4×936 维做一次 L2 归一化后拼接。算一下维度就是 9×36 324 维比 3072 维少了将近九成。这个压缩本身就是收益降维意味着线性分类器要拟合的参数少了过拟合风险随之下降。颜色直方图更直接对 R、G、B 三个通道各做一次分箱统计比如每通道 8 个 bin拼接成 24 维再做一次归一化让总和为 1保证不同亮度的图片可比。拼接时有个不能省的步骤# 两个特征的量纲和稀疏程度差异很大先各自标准化再拼 hog_feats np.array([hog_feature(x) for x in X]) color_feats np.array([color_histogram(x) for x in X]) hog_feats (hog_feats - hog_feats.mean(axis0)) / (hog_feats.std(axis0) 1e-8) color_feats (color_feats - color_feats.mean(axis0)) / (color_feats.std(axis0) 1e-8) X_feats np.concatenate([hog_feats, color_feats], axis1) # (N, 324 24)如果直接concatenate不做标准化HOG 因为维度多、数值分布宽会在梯度里彻底压倒颜色直方图那 24 维等于白算。这是特征工程里最经典的翻车方式特征没错权重失衡效果还不如只用其中一个。顺带一提std那里加1e-8是防某个维度在整批数据上恒为 0 时除零这类防御在特征代码里应该成为肌肉记忆。两条路走到最后会有个挺有意思的对照两层网络靠反向传播自己找出表示手工特征靠人的先验硬塞进去。当时的效果是手工特征略好或者打平但网络那边几乎没做任何特征设计。这个对比本身就是整门课想传达的东西——表示的质量决定上限而怎么得到表示是可以选择的。5. 易错点五个最容易翻车的地方这些点单独看都不复杂但它们的共同特征是不报错。代码能跑、loss 能降、准确率能出数只是数不对。所以每一条都按「错在哪 → 为什么会被漏掉 → 怎么补」来写。5.1 梯度只在 reg0 下检验过易漏点梯度检验全部通过但dW里漏了 reg * W这一项或者系数写成了2 * reg * W。为什么漏检验时很多人用默认reg0此时正则项对梯度的贡献恰好是 0数值梯度和解析梯度当然一致。检验通过得越顺利越不会怀疑这里。怎么补养成「两遍检验」的习惯——先reg0验证数据项再取reg0.5这种明显的大值验证一次。如果第二遍误差突然从 1e-9 跳到 1e-1问题一定在正则项。另外核对系数loss 写的是0.5 * reg * sum(W*W)梯度就必须是reg * W两个地方的 0.5 要对应上。5.2 数值溢出softmax 和距离计算的两个雷易漏点Softmax 没减最大值距离平方展开后出现微小负数直接开根。为什么漏小规模调试时分数都在个位数exp(9)完全没问题一切正常。等到学习率调大、训练轮数变多分数涨到几百inf才突然出现此时 loss 已经变成nan很难回溯到是哪一步引入的。距离那个负数更隐蔽-1e-12开根得到nannan参与比较永远是False最后表现为「准确率 0.0000」这种匪夷所思的结果。怎么补Softmax 的标准写法里scores - np.max(scores, axis1, keepdimsTrue)必须放在exp之前这是硬性要求而不是优化。距离那边固定写成np.sqrt(np.maximum(dists, 0))。再加一道防线在训练循环里定期assert not np.isnan(loss)让问题在出现的那一步就暴露而不是等到评估阶段才发现。5.3 向量化之后的 axis 和那个 1/N易漏点np.sum的 axis 写反1/N的归一化漏掉或者除了两次。为什么漏这两种错误都不会抛异常。axis 写反时形状可能刚好能广播结果是一个维度诡异的矩阵后续运算照样跑漏掉1/N时 loss 变成了总和的量级梯度也跟着放大 N 倍效果等价于把学习率乘了 50000训练直接崩但报错信息只会告诉你 loss 是nan。怎么补写任何np.sum之前先问一句「我要消掉哪个维度」。dscores的求和应该是axis0跨样本求和得到每个类别的梯度loss的求和也应该是把所有样本的损失加起来再除以 N。更稳的办法是写完立刻用assert卡住关键形状assert grads[W1].shape W1.shape四条断言一加形状类的错误基本绝迹。5.4 用验证集和测试集的姿势不对易漏点反复用测试集调超参预处理时在全量数据上算均值交叉验证的折内又做了一次标准化。为什么漏这三种做法都不会让代码报错甚至会让结果变好——因为信息泄漏了。人会本能地相信更好的数字于是越走越偏。怎么补把「测试集只碰一次」当成纪律。整个调参过程只盯验证集选定配置、训练完成、预测测试集、记录数字结束。预处理的所有统计量均值、标准差、直方图 bin 边界都必须在训练集上算出来再原样应用到验证和测试上。交叉验证时每一折的标准化参数要单独从该折的训练子集算不能在切分之前对全体做。5.5 跳过初始 loss 自检直接开训易漏点写完训练循环就设 5000 轮、按下运行然后盯着曲线猜哪里不对。为什么漏初始 loss 看起来只是个数字不像是「检查项」。而且训练确实会跑起来loss 也确实在降给人一种「没问题」的错觉。怎么补把初始 loss 当成开机自检。10 类分类的期望值是ln(10) ≈ 2.30明显偏离就先停下来查初始化和数据尺度W是不是用0.01 * np.random.randn(...)初始化的、偏置是不是全零、输入有没有做过预处理。再补一步「过拟合五张图」的测试把 batch size 设成 5、关掉正则、训练几百轮loss 应该能压到接近 0。如果压不下去说明模型的容量或者梯度传递有问题这时候再去调超参才有意义。6. 复习清单从默写公式到数值自检复习这份作业最忌讳的是重看一遍 notebook 的输出就以为掌握了。真正有效的检验是合上所有材料按下面四层往下走哪一层卡住就回到对应章节。层次检验方式通过标准公式层白纸默写 SVM 损失、Softmax 损失及其对 W 的梯度能写出dW[:, y] - X[i]的负号来源形状层只给(N, D) → (N, H) → (N, C)写出每步中间变量形状四个参数的梯度形状与参数完全一致数值层手写中心差分对W1/b1/W2/b2各抽 5 维检验相对误差 1e-7且 reg0 与 reg0.5 都过现象层说出 loss 变 nan、准确率 0、验证集不涨分别对应什么原因每种现象能给出两个以上候选原因公式层的关键在于「负号从哪来」。SVM 的dW[:, y_i]要减、Softmax 的dscores[y]要减 1两个减号本质是同一件事正确类别的分数出现在损失函数的分母或负号项里所以梯度方向相反。把这个想通了两个损失函数的梯度就不用背了。形状层建议用一张草稿纸从左到右写X (N,D) → W1 (D,H) → hidden (N,H) → relu (N,H) → W2 (H,C) → scores (N,C)反向再倒着写一遍。凡是反向过程中出现「某个矩阵需要转置」的地方一定是因为它在前向里是右乘的。这条规律能覆盖 assignment1 里全部的反向传播。数值层是唯一能自动化的一层也是最值得投入的。写一个小脚本把四个参数一次性全验一遍reg 分别取 0 和 0.5两轮都过再往下走# 全参数梯度检验reg 取两个值各跑一遍 for reg_val in [0.0, 0.5]: net TwoLayerNet(input_dim6, hidden_dim8, num_classes4) X_small np.random.randn(5, 6) y_small np.random.randint(4, size5) loss, grads net.loss(X_small, y_small, regreg_val) print(freg{reg_val}, 初始 loss{loss:.4f}) for name in [W1, b1, W2, b2]: f lambda _: net.loss(X_small, y_small, regreg_val)[0] grad_check_sparse(f, net.params[name], grads[name], num_checks5)input_dim和hidden_dim故意设得很小是为了让数值梯度和解析梯度的差异更容易暴露——网络越大误差越容易被平均掉反而看不出问题。现象层最考验「有没有真踩过」。loss 变nan优先怀疑学习率太大和 softmax 没减最大值准确率恒为 0优先怀疑距离矩阵里的nan和predict_labels的返回值类型验证集不涨但训练集飞涨优先怀疑 reg 太小或者数据泄漏。把这些对应关系记牢比记住任何一个超参的具体数值都有用。还有一个查漏补缺的小技巧把所有.py文件里的循环逐个找出来问自己「这个循环能不能用矩阵乘法替掉」。kNN 的三个版本、SVM 的朴素版和向量化版、Softmax 的两个版本作业本身就是按这个梯度设计的。能一眼看出哪个循环该消、消掉之后形状怎么变说明向量化这一关真正过了。说个自己的教训。第一次做两层网络时梯度检验全过、loss 曲线漂亮、验证准确率 0.52我信心满满地交了。后来重跑才发现dhidden drelu * (hidden 0)被我写成了drelu * (relu 0)而当时为了图快把激活换成了 LeakyReLU——梯度检验用的网络恰好没触发负半轴误差小到看不出来。从那以后我每次改激活函数、改损失、改初始化都强制走一遍「小网络 两个 reg 值 四参数全验」的流程宁可多花五分钟也不愿意再靠猜。希望这份清单能帮你在同样的地方少绕一圈。作业的五个 notebook 加几个待填的.py文件就是全部家当按上面的顺序过一遍、自己手推一遍梯度、把每个TODO后面的形状写清楚这套流程走完后面再看卷积和反向传播会顺很多。需要的话把整理好的作业目录连同这份笔记一起拿走对着跑一遍比重看视频快得多。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑