简介:面向深度学习与计算机视觉入门者,斯坦福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 numpy==1.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, encoding='bytes') X = data[b'data'].reshape(-1, 3, 32, 32).transpose(0, 2, 3, 1) y = np.array(data[b'labels']) 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, i+1) plt.imshow(X[i].astype('float32') / 255.0) plt.title(classes[y[i]], fontsize=8) 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, axis=1, keepdims=True) # (num_test, 1) X_train_sq = np.sum(self.X_train**2, axis=1) # (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,一次性算出所有测试样本与训练样本的点积矩阵。三个关键点:keepdims=True让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, axis=1) # 每行有多少个错误类别贡献了梯度 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(x+h) - f(x-h)) / 2h用中心差分近似导数。我自己验证时习惯直接从cs231n/gradient_check.py里调用eval_numerical_gradient:
from 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, reg=0.5)[0] numeric_grad = eval_numerical_gradient(f, W) analytic_grad = svm_loss_naive(W, X_val_sample, y_val_sample, reg=0.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×3=3072 维,加上偏置项后 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里如果有较大的值,比如 100,np.exp(100)直接溢出为inf,loss 瞬间变成 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, axis=1, keepdims=True) # 数值稳定性 exp_scores = np.exp(scores) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) 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, axis=0) 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 得到 hidden,ReLU 激活,第二次 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, replace=True) x_batch = X_train[idx] y_batch = y_train[idx] loss, grads = two_layer_net.loss(x_batch, y_batch, reg=0.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 重跑,同时加上梯度裁剪。我之前见过一个 case,learning_rate=1e-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,或者损失里没有做除法。解决方法是拆开检查:把reg=0代入,看解析梯度和数值梯度是否对齐;如果对齐了,说明问题只出在正则项上。这个排查思路对 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 个 bin,HOG 特征从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 的血泪路走短一点。
本文还有配套的精品资源,点击获取