CS231n作业1实战:手写KNN、SVM与两层网络全解析 简介面向深度学习与计算机视觉入门者斯坦福CS231n课程第一个作业assignment1的完整配套材料涵盖作业原题、参考答案以及网上下载的CIFAR-10数据集。该作业基于Anaconda的Jupyter Notebook环境完成覆盖KNN、SVM、Softmax分类器与两层神经网络的实现通过运行各notebook可以逐步理解从数据预处理到模型训练的全过程适合正在自学CS231n并希望对照调试的读者。压缩包为7z格式共54个文件以Python脚本、Jupyter笔记、数据集与shell脚本为主其中ipynb笔记可直接运行py脚本便于查看算法细节另有少量配置与元数据文件解压后约561.98MB。包内目录结构完整作业说明、数据目录、提交脚本等一应俱全可直接用于本地运行或二次修改。已有2003人学习此资源对想系统梳理作业思路、节省数据集下载与预处理时间的初学者尤其有价值。1. cs231n assignment1为什么说这份作业是深度视觉的“体检单”很多想切入计算机视觉的人第一次接触 CS231n assignment1 时都会愣住这真的是斯坦福的作业吗没有深度学习框架没有 GPU靠 NumPy 手写 KNN、SVM、Softmax 和一个两层神经网络跑完 CIFAR-10 准确率才 38% 出头怎么看都不像“主流技术”。但恰恰是这份 assignment1把视觉任务里最核心的几件事——距离度量、损失函数、梯度推导、反向传播、超参数搜索——全部逼着你手推一遍。它的价值不在于那点准确率数字而在于让你亲手拆开模型的每一个零件。拿作业里最折磨人的 SVM 梯度来说你抄十遍框架里的loss.backward()不如自己在纸上把dW的求和式推一遍再看着 NumPy 代码逐行算出来。我见过太多人后面调 ResNet 调得飞起但问一句“Softmax 的梯度为什么是p - y的矩阵形式”就卡壳基本就是在 assignment1 抄了代码没走心。这篇笔记适合谁两类人。其一正在做 assignment1、卡在某个 loss 是 NaN 或者 KNN 慢到怀疑人生的在校生其二想转 CV 但没系统补过基础、直接上手框架后总觉得哪里虚的从业者。下面按我的实操顺序来讲先搭环境跑通数据管线再逐个模型从朴素实现到向量化重写最后给出调参顺序和避坑清单。这套路径我自己带过几轮人只要不跳过中间的推导环节一周内能稳稳跑完。2. 从零把环境、数据和可视化的坑一次填平跑不动的 KNN 八成是前置没做好2.1 环境选型为什么我坚持 NumPy 裸跑而不用现成框架assignment1 唯一的硬性依赖是 NumPy但很多人一上来就用 Anaconda 装了一堆包结果 KNN 跑 20 分钟都出不来。先说环境结论Python 3.7 以上、NumPy 1.19 到 1.23 之间、六到八核 CPU跑完整份作业在半小时到一小时。我一般不装 PyTorch 或 TensorFlow不是因为不能用而是作业里要求实现的内容如果框架顺手代劳了梯度检查那一步就失去了意义。真正的坑在 NumPy 版本。新版本 NumPy 对np.float这类别名做了严格处理用老版本作业代码可能报AttributeError: module numpy has no attribute float。遇到这种情况别急着改代码先在终端确认版本python -c import numpy as np; print(np.__version__) # 如果报 float 属性错误通常是 NumPy 1.24建议降级 pip install numpy1.23.5提示作业自带的classifiers/里有一套老代码跑knn.ipynb之前建议先新建一个干净的assignment1/子目录把下载的cs231n/数据集工具包放进去避免后续setup.py路径混乱。2.2 数据下载与预处理CIFAR-10 加载和第一次可视化踩坑CIFAR-10 数据集放在斯坦福服务器上下载脚本在cs231n/datasets/get_datasets.sh里。国内网络偶尔会拉到一半断掉导致cifar-10-batches-py目录里缺文件报的错往往很隐晦。最稳的做法是手动下载压缩包再放到指定目录cd cs231n/datasets # 下载 CIFAR-10 Python 版本压缩包 wget https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz tar -zxvf cifar-10-python.tar.gz ls cifar-10-batches-py/解压后你会看到五个data_batch_*文件和一个test_batch每个 batch 里有 10000 张 32×32×3 的图片。写作业前第一步不是直接调用load_CIFAR10()而是先手动读一个 batch 检查维度与数值范围防止后续 KNN 算距离时出现 weird 结果import pickle import numpy as np def load_one_batch(path): with open(path, rb) as f: data pickle.load(f, encodingbytes) X data[bdata].reshape(-1, 3, 32, 32).transpose(0, 2, 3, 1) y np.array(data[blabels]) return X, y X, y load_one_batch(cifar-10-batches-py/data_batch_1) print(X.shape, y.shape, X.min(), X.max()) # 输出 (10000, 32, 32, 3) (10000,) 0 255这段代码的逻辑是先按[N, 3, 32, 32]的通道前置结构 reshape再用transpose转成[N, 32, 32, 3]的通道后置格式这个转换和 PyTorch 里CHW转HWC是同一套逻辑。最后打印X.min()和X.max()用来确认数值在 0 到 255 之间灰度值不会超出区间否则后面归一化时均值会偏。到这一步你还会遇到一个不影响得分但影响心情的视觉问题用 matplotlib 显示图片时偏色或全黑。原因在于 CIFAR-10 的通道顺序和数值类型。可视化时我给两个强制习惯第一imshow之前必须除以 255.0 把数值归一化到 [0,1] 区间第二如果图片变成彩色噪点检查是不是用了plt.imshow(X[i])而 X 的 dtype 是 uint8 且 ndim 是 4需要先索引出单张并转成 float。import matplotlib.pyplot as plt classes [plane, car, bird, cat, deer, dog, frog, horse, ship, truck] plt.figure(figsize(10, 2)) for i in range(10): plt.subplot(1, 10, i1) plt.imshow(X[i].astype(float32) / 255.0) plt.title(classes[y[i]], fontsize8) plt.axis(off) plt.show()可视化一旦跑通下面 KNN 的测试就能直观看到预测结果对应的是哪类物体。很多人在 KNN 运行时间上翻车根本原因是没意识到 CIFAR-10 训练集有 50000 张图如果预处理只有归一化而没有降采样两层循环算 L2 距离复杂度是 50000×500 维度的乘积基本要把电脑卡死。3. 实现 KNN 与 SVM 的向量化改造把两层循环缩到一行矩阵运算3.1 KNN 的三种距离写法从双层循环到广播机制KNN 模块在 assignment1 里有四个步骤加载数据并划分训练/验证集、实现compute_distances_two_loops、实现compute_distances_one_loop、实现compute_distances_no_loop。作业要求写三种方式最终目的是让你感受向量化的性能差距。先看最朴素的双层循环def compute_distances_two_loops(self, X): num_test X.shape[0] num_train self.X_train.shape[0] dists np.zeros((num_test, num_train)) for i in range(num_test): for j in range(num_train): diff X[i] - self.X_train[j] dists[i, j] np.sqrt(np.sum(diff**2)) return dists这段代码逻辑直白对每个测试样本和每个训练样本逐元素做差、平方、求和、开根号结果填进dists矩阵。问题在于 500 个测试样本 vs 5000 个训练样本时时间飙到几十秒。而作业要求的no_loop版本用广播机制一行搞定def compute_distances_no_loop(self, X): num_test X.shape[0] num_train self.X_train.shape[0] # 利用 (a-b)^2 a^2 b^2 - 2ab 展开避免显式构造差值矩阵 X_sq np.sum(X**2, axis1, keepdimsTrue) # (num_test, 1) X_train_sq np.sum(self.X_train**2, axis1) # (num_train,) cross np.dot(X, self.X_train.T) # (num_test, num_train) dists np.sqrt(X_sq X_train_sq - 2 * cross) return dists这里的数学原理是把欧氏距离平方展开成三项交叉项正好是矩阵乘法X X_train.T一次性算出所有测试样本与训练样本的点积矩阵。三个关键点keepdimsTrue让X_sq保持列向量形状(num_test, 1)才能触发广播cross项必须提前算好否则每轮循环都会重复矩阵乘最后开根号时如果出现很小的负值浮点误差可以用np.clip(dists, 0, None)压制避免 NaN。跑完三种实现后做一个计时对比你会看到 no_loop 比 two_loops 快至少两个数量级。这份速度差异的根源就是 BLAS 库对矩阵乘法的优化程度远超 Python 循环后续 SVM 的梯度计算也是同一个道理——能矩阵化计算就绝不显式遍历。3.2 SVM 的损失函数与梯度推导从求和公式到代码一一对应SVM 部分是 assignment1 里最劝退也最关键的环节。损失函数公式是L_i Σ max(0, s_j - s_y_i 1)对第 j 个错误类别的分数和正确类别分数做差值加 1小于 0 则不计损失。别看公式短梯度推导时很多人分不清两种情况。先写损失函数部分def svm_loss_vectorized(W, X, y, reg): num_train X.shape[0] scores X.dot(W) # (num_train, C) correct_class_scores scores[np.arange(num_train), y] margins np.maximum(0, scores - correct_class_scores[:, np.newaxis] 1) margins[np.arange(num_train), y] 0 # 正确类别那一项不计算损失 loss np.sum(margins) / num_train loss reg * np.sum(W * W) # 正则化项 return loss, gradientscorrect_class_scores[:, np.newaxis]这一步把正确分数从(num_train,)扩展成(num_train, 1)和每个类别的分数做广播相减得到所有样本每个类别的 margin。然后强制把正确类别位置的 margin 置零因为公式里正确类别的得分差是s_y - s_y 1 1这会造成恒定的正则损失不算有效分类误差。梯度推导是本段核心自己在纸上画一遍比看十遍教程都管用。对正确类别y_i的梯度是负的计数累加对错误类别是正的累加。写成向量化代码时有一个非常容易错的地方怎么把条件掩码和计数结合起来。def svm_loss_vectorized(W, X, y, reg): num_train X.shape[0] scores X.dot(W) correct_class_scores scores[np.arange(num_train), y] margins np.maximum(0, scores - correct_class_scores[:, np.newaxis] 1) margins[np.arange(num_train), y] 0 loss np.sum(margins) / num_train reg * np.sum(W * W) binary np.zeros_like(margins) binary[margins 0] 1 # 命中 margin 的标记为 1 row_sum np.sum(binary, axis1) # 每行有多少个错误类别贡献了梯度 binary[np.arange(num_train), y] -row_sum # 正确类别位置减去该计数 dW X.T.dot(binary) # (D, C) dW / num_train dW 2 * reg * W return loss, dW梯度的本质是「对每个样本正确类别处的梯度是负的因为损失随正确分数增加而减小所有满足 margin 0 的错误类别处梯度是正的」。binary矩阵先标记所有 margin 0 的位置为 1再把正确类别所在位置写成负的每行总数最后X.T.dot(binary)一次性完成所有样本梯度的累加。这个技巧在后续两个层神经网络的反向传播里也会反复用到。3.3 验证梯度写对了数值梯度和解析梯度的比较方法梯度写完不要急着训练先做梯度检查。作业代码里给了一套数值梯度方法原理是(f(xh) - f(x-h)) / 2h用中心差分近似导数。我自己验证时习惯直接从cs231n/gradient_check.py里调用eval_numerical_gradientfrom cs231n.gradient_check import eval_numerical_gradient def svm_loss_naive(W, X, y, reg): loss, dW svm_loss_vectorized(W, X, y, reg) return loss, dW W np.random.randn(3073, 10) * 0.001 X_val_sample X_train[:100] y_val_sample y_train[:100] f lambda W: svm_loss_naive(W, X_val_sample, y_val_sample, reg0.5)[0] numeric_grad eval_numerical_gradient(f, W) analytic_grad svm_loss_naive(W, X_val_sample, y_val_sample, reg0.5)[1] diff np.linalg.norm(numeric_grad - analytic_grad) / (np.linalg.norm(numeric_grad) np.linalg.norm(analytic_grad)) print(diff)diff小于 1e-7 说明梯度验证通过在 1e-5 左右也还能接受但如果到了 1e-2 就说明代码里有 bug。最常见的 bug 出现在「正确类别位置被错误地计入了正梯度」或「正则化梯度漏了归一的除法」。这时候我通常会把binary矩阵打印出来抽查几个样本的手算值核对一遍。另外初始化权重时用np.random.randn(3073, 10) * 0.001是一个非常重要的玄学参数。CIFAR-10 每张图的原始像素是 32×32×33072 维加上偏置项后 W 的行数是 3073。乘以 0.001 是为了让初始分数都聚集在 0 附近这样 softmax 的梯度不会一开始就进入饱和区。如果初始权重过大SVM 的 margin 几乎全部大于 0导致 loss 非常大且梯度方向失真训练直接翻车。4. Softmax 与两层神经网络从交叉熵到反向传播的手写全流程4.1 Softmax 的数值稳定性先减最大值再算指数Softmax 分类器在作业里的角色是 SVM 的对照实验同样要求实现朴素版和向量化版但它的梯度形式更干净理解它之后再写两层网络会顺畅很多。损失函数是交叉熵L_i -log(p_y_i)其中p_y_i是正确类别的 softmax 概率。第一件事是把 softmax 的数值稳定性处理掉。scores里如果有较大的值比如 100np.exp(100)直接溢出为infloss 瞬间变成 NaN。标准做法是每个样本的分数先减去该样本分数的最大值再算指数和归一化因为减去常数后 softmax 的分布不变但数值范围被控制在安全区间。对作业来说还要注意keepdims参数避免广播维度不匹配def softmax_loss_vectorized(W, X, y, reg): num_train X.shape[0] scores X.dot(W) # (num_train, C) scores - np.max(scores, axis1, keepdimsTrue) # 数值稳定性 exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) correct_log_probs -np.log(probs[np.arange(num_train), y]) loss np.sum(correct_log_probs) / num_train reg * np.sum(W * W) dscores probs.copy() dscores[np.arange(num_train), y] - 1 # 梯度公式 p - y_onehot dscores / num_train dW X.T.dot(dscores) 2 * reg * W return loss, dW梯度的结论很漂亮softmax 的解析梯度就是概率矩阵减去 one-hot 标签矩阵再除以 N。原理是交叉熵对 logits 的偏导展开后恰好等于 softmax 概率与真实分布的差值。代码里先复制probs再对正确类别位置减 1就完成了整个反向传播里最关键的这一步。当正确类别的概率越接近 1这一项的差值越接近 0参数更新幅度越小当错误分类时差值大梯度猛更新。4.2 两层神经网络的前向与反向激活函数和链式法则怎么组织两层网络是 assignment1 的重头戏结构是输入 3072 维 - 隐藏层比如 100 个神经元- ReLU - 输出 10 类分数。前向传播很简单难点在反向传播要把每一层的梯度按链式法则逐层回传作业要求手写affine_forward、relu_forward、affine_backward、relu_backward这些函数。用一个经验把每一层的数据形状写在注释里否则维度推导会让你疯掉。def affine_forward(x, w, b): out x.reshape(x.shape[0], -1).dot(w) b # (N, D) - (N, M) cache (x, w, b) return out, cache def affine_backward(dout, cache): x, w, b cache dx dout.dot(w.T).reshape(x.shape) dw x.reshape(x.shape[0], -1).T.dot(dout) db np.sum(dout, axis0) return dx, dw, db def relu_forward(x): out np.maximum(0, x) return out, x def relu_backward(dout, x): dx dout * (x 0) # ReLU 的梯度输入大于 0 的地方保留梯度否则置 0 return dxaffine_forward里reshape(x.shape[0], -1)的作用是无论输入是图片还是向量都压成(N, D)的矩阵参与矩阵乘。反向的dx需要把梯度还原成输入的原始形状所以用reshape(x.shape)。db是dout在样本维度上求和因为偏置是广播加到每个样本上的回传时梯度要累加。整个两层网络的loss函数逻辑是第一层 affine 得到 hiddenReLU 激活第二次 affine 得到 scores套 softmax 交叉熵损失再把损失对第二层权重和偏置的梯度通过affine_backward回传到第一层。写这个的时候把backprop的步骤和上面softmax_loss_vectorized中的dscores连接起来我习惯用列表把每层名字记下来调试时看dw的形状是(3073, 100)还是(100, 10)一眼就能定位哪层写错了。4.3 训练循环里的三个固定动作学习率衰减、梯度裁剪、随机失活作业里默认用 SGD 更新参数但有两个容易被忽略的小部件直接决定训练曲线好不好看。第一个是学习率两层网络通常从1e-3起步但不能固定不变。常见做法是每经过一个 epoch 将学习率乘以一个衰减系数比如0.95让训练后期步长变小损失在小范围内震荡收敛。第二个是梯度裁剪如果某次迭代梯度爆炸数值超过 1e5直接把梯度截到阈值避免更新后权重飞出合理区间。第三个是随机失活dropout作业 main 代码默认不启用但如果你自己想加只用在第一层 ReLU 之后训练时乘以随机二值掩码并除以保留概率测试时不做任何操作。for it in range(num_iters): idx np.random.choice(num_train, batch_size, replaceTrue) x_batch X_train[idx] y_batch y_train[idx] loss, grads two_layer_net.loss(x_batch, y_batch, reg0.25) for key in grads: grads[key] np.clip(grads[key], -5.0, 5.0) # 梯度裁剪 two_layer_net.params[W1] - learning_rate * grads[W1] two_layer_net.params[b1] - learning_rate * grads[b1] two_layer_net.params[W2] - learning_rate * grads[W2] two_layer_net.params[b2] - learning_rate * grads[b2]这里np.random.choice用的是有放回抽样batch 里同一张图可能出现多次这是 mini-batch 的标准做法因为随机采样噪声有助于逃离局部极小。梯度裁剪的阈值 -5 到 5 不是写死的我看 loss 曲线如果频繁出现剧烈跳变就收紧到 1.0如果训练平稳就放宽到 10。输出 layer 的W1梯度维度是(D, H)W2梯度维度是(H, C)每次更新后可以打印一下权重范数如果范数在 1e-3 量级说明初始化合理如果是 NaN 说明学习率过大或 loss 里有除零问题。5. 调参与避坑超参数搜索的完整顺序和五个典型翻车现场5.1 先定损失再定正则交叉验证里最容易忽略的步骤顺序assignment1 里每个模型都要求做超参数调优而且要用验证集而不是测试集。这部分如果不按顺序来时间会被浪费在无效搜索上。我验证下来最稳的顺序是第一固定正则强度为 0用一组候选学习率做粗筛画出 train/val loss 曲线第二在效果最好的学习率附近把正则强度从 1e-7 到 1 按数量级网格搜索第三固定学习率和正则调整隐藏层大小和 batch size。先调学习率的原因是它对收敛的影响最敏感正则强度只在模型能收敛的前提下才发挥作用。如果一上来同时搜三个参数组合爆炸且每个组合只跑大概一二百次迭代根本看不出谁优谁劣。作业官方给了一个three_loop_grid_search的雏形里面嵌套三层循环遍历learning_rates、regularization_strengths和hidden_sizes。我一般会把每个组合的训练轮数压到 500 到 800 次用验证集准确率做排序记录前五名然后在前五名附近再细搜一轮。不要只盯着最大验证准确率同时要关注 val 与 train 之间的差距如果 val 比 train 低 15 个百分点以上优先加正则如果两者都低优先调学习率。5.2 五个每次都会遇到的坑现象、原因、处理顺序第一个坑是 loss 输出为 NaN现象出现在训练迭代 0 或迭代 1。原因基本是数据里有 NaN 传给网络或者 softmax 里np.exp溢出。解决方法是先检查输入X是否包含np.nan再确认 softmax 减最大值有没有做。如果都不是把学习率降到 1e-6 跑一遍看 loss 是否恢复正常以此区分是数据问题还是梯度爆炸。第二个坑是 loss 曲线直接发散成inf。原因通常是学习率过大SGD 一步跨出了损失函数的合理范围。解决方法是把学习率除以 10 到 100 重跑同时加上梯度裁剪。我之前见过一个 caselearning_rate1e-1时 loss 前 100 次迭代都在减小到第 150 次突然翻到 1e8就是因为某个 batch 里出现了一张异常亮度的图片梯度瞬间爆掉裁剪后曲线就稳了。第三个坑是训练完成但 val 准确率永远在 30% 上下和猜差不多。原因往往是标签切分错误比如验证集和训练集的标签不对齐。CIFAR-10 的 batch 文件标签是 0 到 9 之间的整数如果你把y做了 one-hot 编码但没有转回 original label模型会一直学错目标。解决方法是训练前抽 10 个样本检查预测概率对应的类名和图是否匹配就像第 2 章可视化那段做的那样。第四个坑是 KNN 跑了 10 分钟还没出结果。原因是你还在用双层循环跑全部 50000 张训练图。解决方法是先用 5000 张子集验证代码正确性再切到no_loop版本或者用sklearn.neighbors.KNeighborsClassifier先算一个基准准确率。作业要求自己实现但调试阶段借助现成库定位问题是合理的。第五个坑是梯度检查总失败diff在 1e-3 左右徘徊。原因几乎都在正则项梯度漏了2 * reg * W里的 2或者损失里没有做除法。解决方法是拆开检查把reg0代入看解析梯度和数值梯度是否对齐如果对齐了说明问题只出在正则项上。这个排查思路对 SVM 和 softmax 都适用。5.3 隐层大小和 batch 的边界感不是越大越好隐藏层大小从 50 加到 200验证集准确率会从 46% 涨到接近 52%但再往上收益骤减。原因有两层一是 CIFAR-10 本身只有 50000 张图模型容量过大会过拟合二是隐藏层 200 以上时W1的参数量达到 614400反向传播的计算负担直线上升而准确率提升不到一个点。batch size 的影响更微妙batch 越小梯度噪声越大需要更大学习率来对冲batch 从 200 减到 50收敛 epoch 数几乎翻倍。我一般将 batch 固定在 200只在 loss 不下降时把它调成 100 试试同时学习率减半。调参期间把每次实验的(learning_rate, reg, hidden_size, iteration, val_acc, train_acc)记成一个 CSV方便后面做对比。这一步看起来与作业得分无关但它让你真正明白每个参数的效应边界而不是靠玄学拍脑袋。这比把作业跑满分更值钱。6. 用图像特征实验收尾验证方向比追求准确率更重要assignment1 的最后一部分是图像特征实验用 HOG 和颜色直方图替代原始像素作为输入再套一个线性分类器。官方提示里说这一步实际上能达到比两层网络更高的验证准确率大约 55% 到 60%而两层网络在原始像素上只有 50% 出头。这个反直觉的结论恰好说明了一个最重要的教训手工特征在数据量小的时候就是比端到端学习更稳。跑特征实验时我习惯的做法是先把提取特征的函数单独封装确保 train 和 val 走同一套预处理避免数据泄露。颜色直方图按 RGB 三个通道每通道分 8 个 binHOG 特征从cs231n/features.py里直接调用即可。然后构造(X_train_feats, y_train)和(X_val_feats, y_val)对再复用前面写好的 softmax 分类器训练。如果发现 val_acc 比两层网络还高不用怀疑代码写错了这正是小数据集上特征工程的胜利。做完特征实验后我建议你做一次完整的验证测试分别用 KNN、SVM、Softmax、两层网络、特征提取五个模型的验证准确率填一张表再对比它们的训练时间和推理时间。这张表比任何代码都有说服力——它能帮你建立“模型复杂度与数据规模匹配”的直觉知道什么时候该上深度学习什么时候用简单模型更划算。整份作业跑完之后我最大的教训其实是不要为了把 val_acc 调高一个点而盲目堆迭代次数。assignment1 的评分标准里有一个维度是“你从数据中看到了哪些失败模式”我第一遍跑的时候只顾着看准确率数字完全没分析错误分类的样本长什么样。后来把预测错的图按类别列出来才发现 KNN 特别容易把深色背景的鹿误判成马这是训练集不均衡导致的先验偏差而不是模型本身有问题。第二遍做时我把这部分踩坑记录写进实验报告反而让我真正理解了每个模型的边界在哪而不是对着一个数字自嗨。如果你能在作业里保持这个节奏——先复现、再调参、最后分析失败案例——那这套流程的收获会一直延续到后面用 PyTorch 搭 ResNet 的时候。希望这篇笔记能帮你把第一遍 assignment1 的血泪路走短一点。本文还有配套的精品资源点击获取