☰
cs231n作业1实战:从KNN到两层神经网络的图像分类全解析
2026/10/10 19:52:23 网站建设 项目流程

简介:斯坦福大学计算机视觉公开课CS231n的assignment1作业完整资料包,专为正在入门深度学习、计算机视觉的学习者准备,能够有效解决课程作业中环境搭建困难、数据集获取繁琐以及核心算法代码实现缺乏参照等常见问题。整个压缩包共包含54个文件,主要文件类型为Python脚本、Jupyter Notebook笔记以及Shell脚本,同时包含若干辅助配置和元数据文件,压缩包整体大小约为561.98MB,其中所需的cifar-10数据集已经完整内置,用户可以免去自行下载和转换数据的步骤,直接基于Anaconda自带的Jupyter环境打开使用。作业答案按照课程要求拆分为多个Notebook,分别覆盖K近邻分类器、支持向量机、Softmax分类器以及两层神经网络等经典模型,代码中细致展示了特征提取、梯度计算、损失函数优化和超参数调节的完整流程。此外还提供数据预处理脚本、提交脚本、gitignore等工程化辅助文件,便于系统复盘作业思路或在此基础上做扩展实验。目前该资源已有2003人学习下载,非常适合需要对照参考答案验证自我实现、深入理解基础模型原理并提升代码调试能力的读者。

1. cs231n作业assignment1:不只是课程作业,更是你上手CV的第一个硬骨头

如果你正在跟斯坦福的cs231n课程,那assignment1就是你绕不开的第一道坎。这门课的全称是CS231n: Convolutional Neural Networks for Visual Recognition,虽然课程主体讲的是卷积神经网络,但作业1偏偏让你先不用CNN,而是用最基础的KNN、SVM、Softmax和两层神经网络把图像分类这件事从头到尾捋一遍。第一次打开assignment1的代码框架时,很多人会被里面的numpy操作和反向传播推导劝退,但正是这份作业,帮你把分类器、损失函数、梯度下降这些概念从“听过”变成“能写”。

这份作业的实际价值在于:你会在IPython Notebook里一步步填空,用CIFAR-10数据集跑通图像分类的完整流程。你不需要有GPU,不需要装PyTorch或TensorFlow,只需要numpy和Python就能把所有模型训练到能用的程度。它是你理解深度学习底层逻辑的最短路径,也是后面assignment2、assignment3的地基。这篇笔记我按自己当年做完、以及后来带新人做这份作业的经验,把每个部分怎么实现、参数怎么调、坑在哪里一次讲清楚。

2. 环境准备与数据加载:先把k近邻和numpy的底子打好

2.1 本地复现cs231n作业的最小环境

你不需要一台多好的机器,assignment1对算力的要求极低,CPU就能扛住所有实验。我建议你直接用Anaconda创建一个干净的Python 3.8环境,装好numpy、matplotlib、scikit-learn这几个依赖就够了。版本上不要追求最新,numpy 1.19到1.21这个区间比较稳,太新的numpy有些时候会因为API变更让作业里自带的老代码报一些小警告。

conda create -n cs231n python=3.8 conda activate cs231n pip install numpy matplotlib scikit-learn jupyter

这段命令的逻辑很直接:创建独立环境是为了不污染你机器上其他的Python项目,jupyter用来打开作业自带的.ipynb文件。装完之后,把课程官网下载的assignment1压缩包解压到本地,用jupyter notebook打开根目录下的knn.ipynb就能开始了。

数据加载这部分,作业框架里已经给你写好了data_utils.py,它会自动去CIFAR-10官网下载数据集。由于这个数据集在国内直连下载经常因为网络原因中断,我一般会提前手动把cifar-10-batches-py.tar.gz下载好,放到assignment1/cs231n/datasets目录下解压,然后在load_CIFAR10函数里把路径改成你本地解压后的文件夹。这样能省掉很多重试等待的时间。

2.2 理解CIFAR-10的数据形态是后续所有步骤的前提

CIFAR-10一共有60000张32x32的彩色图片,分成10个类别,每类6000张。训练集50000张,测试集10000张。加载完之后,你会拿到四个numpy数组:X_train是(50000, 32, 32, 3)的uint8数组,y_train是(50000,)的标签数组,测试集同理。

X_train, y_train, X_test, y_test = load_CIFAR10('cs231n/datasets/cifar-10-batches-py') print('Training data shape: ', X_train.shape) print('Training labels shape: ', y_train.shape) print('Test data shape: ', X_test.shape) print('Test labels shape: ', y_test.shape)

关键点在于:加载出来的是原始像素值,范围在0到255之间,而且每个像素是一个0到255的整数。后续你计算距离或者做梯度下降,用的都是这些原始值直接转成float后的样子。很多人在这一步会忽略一个细节——在作业的后续部分,你通常需要从50000张训练图里再切出一部分当作验证集,比如取前5000张做训练,剩下的当验证集,这样调参时不会把测试集“看脏”。这个习惯你最好从一开始就建立起来,后面每个分类器都沿用同一套划分。

这里的核心逻辑是:图像分类就是把一张32x32x3=3072维的向量映射到10个类别标签上。KNN之所以慢,就是因为它要拿测试图片和所有训练图片逐一比较距离;SVM和Softmax之所以快,是因为它们把分类规则压缩进了一个权重矩阵W里,预测时只需要做一次矩阵乘法。

3. KNN分类器:从双重循环到向量化的三次进化

3.1 为什么KNN是整个作业里最“反直觉”的模型

KNN(k近邻)的思路简单到令人发指:给你一张测试图片,你到训练集里找最像的k张图,然后让这k张图投票决定测试图的类别。它不需要训练过程,或者说训练过程就是记住所有训练数据。这在cs231n作业里是第一课,目的就是让你理解“基于距离的最近邻思想”有多么朴素,以及朴素背后的代价——预测时要计算所有训练样本的距离,时间和存储开销都极大。

作业knn.ipynb里,你需要实现compute_distances_two_loops、compute_distances_one_loop和compute_distances_no_loop三个函数。名字已经说明了实现方式:双重循环、单重循环、完全向量化。为什么同一个功能要写三遍?为了让你亲手感受到向量化带来的几十倍速度提升,这是整个课程反复强调的工程意识。

3.2 三个距离函数的具体实现与参数解析

我先给你看双重循环的版本,这是最符合直觉的写法,也是你检验后面两个版本正确性的基准。

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): dists[i, j] = np.sqrt(np.sum((X[i] - self.X_train[j])**2)) return dists

这段代码的逻辑就是逐行逐列地算欧氏距离:对每一张测试图,遍历所有训练图,计算像素差值的平方和再开方。参数上看,X是测试集,self.X_train是训练集,dists的第i行第j列存的是第i张测试图和第j张训练图的距离。这个实现能跑,但当测试集有500张、训练集有5000张时,双循环要执行250万次,非常慢。

完全向量化的版本长这样:

def compute_distances_no_loop(self, X): num_test = X.shape[0] num_train = self.X_train.shape[0] dists = np.zeros((num_test, num_train)) dists = np.sqrt( np.sum(X**2, axis=1, keepdims=True) + np.sum(self.X_train**2, axis=1) - 2 * X.dot(self.X_train.T) ) return dists

这里的数学推导是欧氏距离的展开公式:|a-b|^2 = |a|^2 + |b|^2 - 2ab。X的平方和按行求和后保持成(num_test, 1)的形状,训练集的平方和是(num_train,)的形状,两者相加时会触发numpy的广播机制,得到(num_test, num_train)的矩阵,再减去两倍的点积矩阵。最后开方就是所有距离。

参数层面需要注意:X.dot(self.X_train.T)这一步得到的是一个(num_test, num_train)的矩阵,表示每对样本的内积。把平方项拆开之后,经常会出现浮点误差导致极小的负数出现在根号里面,所以有些实现会先取绝对值或者用np.maximum(dists, 0)兜底。我建议你写完向量化版本之后,用np.linalg.norm或双重循环的结果对比检查,差异在1e-6以内都算正常。

3.3 调k值和交叉验证的准确率参考

距离函数写完,predict_labels函数就好办了:对每张测试图,从dists里取出距离最小的k个索引,然后用np.bincount或手动统计这k个近邻里每个类别出现的次数,取最多的作为预测标签。训练准确率在k=1时基本接近100%,因为每张训练图和自己的距离是0,必被选中。但测试准确率才是关键——k=1时CIFAR-10测试集准确率大概在25%到30%之间,k=5左右通常能到28%到32%,再往上提升就非常有限了。

num_folds = 5 k_choices = [1, 3, 5, 10, 20, 50, 100] X_train_folds = np.array_split(X_train, num_folds) y_train_folds = np.array_split(y_train, num_folds) k_to_accuracies = {} for k in k_choices: accuracies = [] for fold in range(num_folds): X_train_cv = np.vstack(X_train_folds[:fold] + X_train_folds[fold+1:]) y_train_cv = np.hstack(y_train_folds[:fold] + y_train_folds[fold+1:]) X_val_cv = X_train_folds[fold] y_val_cv = y_train_folds[fold] classifier.train(X_train_cv, y_train_cv) y_pred = classifier.predict(X_val_cv, k=k) acc = np.mean(y_pred == y_val_cv) accuracies.append(acc) k_to_accuracies[k] = accuracies

这段交叉验证的核心逻辑是:把训练集切成5份,每次拿4份训练、1份验证,轮换5次,最后得到每个k下的5个准确率。这样做比单次划分更稳定,能看出哪个k值在不同数据子集下表现都稳定。CIFAR-10上,k=10左右往往是一个平衡点,k太大决策边界太平滑,会把噪声也“平均”掉,反而降低准确率。

4. 从SVM到Softmax:梯度推导与向量化实现全拆解

4.1 SVM铰链损失的意义与梯度公式的推导思路

作业的第二个大块是SVM分类器。这里的SVM不是传统意义上有间隔最大化的支持向量机,而是线性多分类SVM,用的是铰链损失(hinge loss)。它的核心思想是:正确类别的得分要比每个错误类别的得分至少高出一个边距(margin),这个边距在作业里默认设为1。

损失函数公式长这样:L_i = Σ_{j≠y_i} max(0, s_j - s_{y_i} + 1),其中s_j是第j类的得分,s_{y_i}是正确类别的得分。理解这个公式的关键在于:只有错误类别得分超过正确类别得分加1时,才会产生损失;否则损失为0。这跟Softmax的“所有类别都要往正确的概率上推”完全不同,SVM只关心正确类别和错误类别之间的差距是否足够大。

你需要实现的svm_loss_vectorized函数,核心就是求梯度。损失对W的梯度可以拆成两部分:对正确类别那一行的梯度,以及对错误类别那些行的梯度。具体来说,对每个错误类别j,如果max(0, s_j - s_{y_i} + 1)大于0,那么s_j对W_j行的梯度就是X_i,s_{y_i}对W_{y_i}行的梯度就是-X_i。把所有样本累加起来再除以样本数,最后加上正则化项对W的梯度。

def svm_loss_vectorized(W, X, y, reg): num_train = X.shape[0] scores = X.dot(W) margins = np.maximum(0, scores - scores[np.arange(num_train), y].reshape(-1, 1) + 1) margins[np.arange(num_train), y] = 0 loss = np.sum(margins) / num_train + 0.5 * reg * np.sum(W * W) binary = margins > 0 binary[np.arange(num_train), y] = -np.sum(binary, axis=1) dW = X.T.dot(binary) / num_train + reg * W return loss, dW

这段代码里的核心参数是reg,也就是正则化强度。作业里默认reg=0.1,但如果你只做基本要求,1e3这个量级也能用——准确率差不了太多。真正值得关注的是margin这个超参数设为1的原因:它和正则化强度一起控制模型的“自信程度”,margin越大,模型越倾向于让正确类别得分远高于错误类别,但也会让训练更难收敛。

调试建议:你可以先用很小的数据集(比如前200张训练图)跑梯度检查,用数值梯度和解析梯度对比,相对误差应该在1e-6以下。如果误差在1e-2这个量级,基本可以断定你的梯度实现有问题,最常见的是忘记把正确类别那一行的margin归零。

4.2 Softmax损失函数中的数值稳定性处理

Softmax分类器跟SVM的关键区别在于,它把得分转换成了概率分布,然后用交叉熵来衡量预测和真实标签之间的差距。损失函数公式为L_i = -log(p_{y_i}),其中p_{y_i} = e^{s_{y_i}} / Σ_j e^{s_j}。

直接算exp(s_j)有个数值稳定性的问题:如果得分很大(比如几百),e的几百次方会溢出成inf。作业里已经提示你要先把所有得分减去每行最大值再算exp,这就是标准的数值稳定技巧,但很多第一次写的人会漏掉这一步。

def softmax_loss_vectorized(W, X, y, reg): num_train = X.shape[0] scores = X.dot(W) scores -= np.max(scores, axis=1, keepdims=True) exp_scores = np.exp(scores) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) loss = -np.sum(np.log(probs[np.arange(num_train), y])) / num_train + 0.5 * reg * np.sum(W * W) dscores = probs dscores[np.arange(num_train), y] -= 1 dscores /= num_train dW = X.T.dot(dscores) + reg * W return loss, dW

减最大值这一步的原理是:e^{s_j - C} / Σe^{s_j - C} = e^{s_j} / Σe^{s_j},减任何常数都不会改变概率分布,却能避免溢出。梯度部分,关键操作是dscores = probs然后把正确类别的概率减1。为什么?因为交叉熵损失对scores的梯度就是p - y_onehot,这个结论你最好自己推一遍,不要死记。

在线性分类器的框架下,SVM和Softmax的预测代码完全一样,都是取得分最大的类别。两者的准确率在CIFAR-10上也差不多,都在35%到40%之间。但两者的行为有本质区别:Softmax永远不会给出“零损失”,因为概率永远不可能精确等于1;而SVM在间隔足够大时确实可以达到损失为0。这个区别在后续调试神经网络时会反复出现。

4.3 学习率与正则化强度的联动调整方法

训练SVM或Softmax时,学习率(learning rate)和正则化强度(reg)是两个必须一起调的超参数。它们的连动关系如下:学习率太大,损失曲线会震荡甚至发散;学习率太小,损失下降比蜗牛还慢。正则化太强,模型会把所有权重往0压,准确率崩盘;正则化太弱,模型过拟合训练集,测试准确率上不去。

我一般推荐的调试顺序是:先固定reg在1e-3附近,用几组学习率(比如1e-3、1e-4、1e-5)各跑50轮左右,画出损失曲线看哪个下降最平稳。选定学习率后,再固定它,尝试reg在1e-2、1e-3、1e-4这几个量级上的表现。最后做个简单的网格搜索,在[CIFAR-10上、线性SVM通常能拿到39%左右的测试准确率],Softmax也差不多。

5. 从线性到非线性:实现一个两层神经网络

5.1 反向传播手推:链式法则在两层网络中的具体展开

作业第四个部分是写一个两层神经网络。输入层到隐藏层用ReLU激活,隐藏层到输出层直接给出10个类别的得分。网络结构是3072 -> 100 -> 10,中间隐藏层大小你可以自己改。

手推反向传播是这个作业的灵魂。你需要先明确前向计算的链条:X -> W1 -> h -> ReLU -> h2 -> W2 -> scores -> softmax_loss。反向传播就是沿着这条链从后往前算偏导。最关键的是ReLU层的梯度:如果某个神经元的输出大于0,梯度就是1,否则是0。这个“开关”特性让反向传播变得极其简单——你只需要记住一个mask。

def loss(self, X, y=None, reg=0.0): scores = np.maximum(0, X.dot(self.params['W1'])) scores = scores.dot(self.params['W2']) if y is None: return scores num_train = X.shape[0] exp_scores = np.exp(scores - np.max(scores, axis=1, keepdims=True)) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) dscores = probs dscores[np.arange(num_train), y] -= 1 dscores /= num_train grads = {} grads['W2'] = h.dot(dscores) + reg * self.params['W2'] grads['b2'] = np.sum(dscores, axis=0) dhidden = dscores.dot(self.params['W2'].T) dhidden[h <= 0] = 0 grads['W1'] = X.T.dot(dhidden) + reg * self.params['W1'] grads['b1'] = np.sum(dhidden, axis=0)

注意dhidden[h <= 0] = 0这一行,这就是ReLU的反向传播。有个细节容易错:如果你在前向时把ReLU的输出存成了单独的变量(比如叫h),那反向传播要基于h > 0的mask来截断梯度;如果你复用scores这个变量名,很容易搞混。我建议你把前向的隐藏层输出单独命名,比如hidden_layer,避免和最后的scores混淆。

超参数上,隐藏层大小和正则化强度是两个主要旋钮。隐藏层从100改成200,通常会带来1到2个百分点的准确率提升,但训练时间也翻倍。reg在0.01到0.1这个范围里精度变化比较平稳,太小会过拟合,太大直接把准确率拉低到30%以下。常规的隐藏层大小是50到200之间,CIFAR-10上两层网络练得好的话测试准确率能到50%左右,这比线性分类器有了明显的跨越。

5.2 训练过程为什么不收敛时的三个排查方向

我见过太多人在这个环节卡住——损失曲线平得像条直线,或者准确率始终在10%附近徘徊(等于瞎猜)。排查方向按优先级排列:

第一,检查数据预处理是不是漏了归一化。在knn.ipynb的末尾,作业让你把图像像素值减去均值再除以标准差,也就是把数据零均值化。如果你跳过了这步直接用原始像素值喂网络,梯度下降很容易被困在局部最优或者收敛极慢。作业里给的预处理是把图像转成float然后减均值除以方差,这个操作能大幅提升收敛速度。

第二,检查学习率是否在一个合理的量级。两层网络的初始学习率从0.1附近开始试是安全的做法。如果损失在几十轮迭代后还在上升,大概率是学习率太高了;如果每轮只下降一点点,说明学习率太低。作业建议用SGD,迭代次数在2000轮左右,但你其实用adam之类的优化器也能更快收敛,只是作业框架没有内置,需要自己写几行。

第三,检查正则化强度有没有把网络压死。如果训练集的准确率都不高(低于80%),很可能是reg太大,直接把权重压到接近0。反过来,如果训练准确率接近100%但测试准确率只有三四十,那就是过拟合,需要加大reg或者减小隐藏层。

5.3 调参时的玄学与血泪经验:学习率才是王道

调神经网络超参数这件事,说没有玄学成分是假的。我在这个作业上最深的一点体会是:学习率的重要程度远超隐层大小和正则化强度。一个在0.01附近的学习率配合合适的预处理,能让网络在几百轮迭代内冲到50%的准确率;而如果你把学习率设成1e-3,同样的结构可能要跑几千轮才到40%。所以如果你时间有限,别纠结隐层是128还是256,先把学习率这个旋钮摸清楚。

有个小技巧:先拿一小部分训练数据(比如200张)做过拟合测试。如果网络在这一小批数据上怎么都过拟合不到90%以上的训练准确率,那说明实现里大概率有bug,或者学习率超出正常范围。这个方法能帮你把“模型问题”和“实现问题”快速区分开,省下大量反复跑全量训练的时间。

6. 避坑与常见问题排查:cs231n作业1最典型的五个翻车现场

6.1 现象:距离矩阵里出现NaN

原因:向量化计算距离时,平方项相加出现负值,开方后变成NaN。这在浮点运算中很常见,尤其是当数据没有做标准化、像素值在0到255之间时,矩阵乘法和平方和的数值都很大,浮点误差被放大。

解决:在开方之前,用np.maximum(dists, 0)把负值clip到0,或者对dists取绝对值后再开方。同时在写完向量化版本后,务必用双重循环版本交叉验证,两者最大误差超过1e-6就说明哪里写错了。

6.2 现象:SVM或Softmax的损失在训练初期不降反升

原因:学习率设置过高,导致梯度下降一步跨太大,直接越过最优点。在损失函数是凸函数的SVM上,表现是损失来回震荡;在非凸的两层网络上,表现是损失直接发散。

解决:把学习率降一个数量级重新跑。如果你用的是作业自带的SGD,建议初始学习率从1e-2起步,每次翻倍或减半做网格搜索。同时检查数据是否归一化,原始像素值会让损失曲面非常崎岖,给学习率的选择带来很大困难。

6.3 现象:预测准确率始终在10%左右

原因:标签处理出错,或者模型输出和标签之间的对应关系错位。在CIFAR-10里,标签是0到9的整数,如果你的预测返回的是one-hot向量的索引,或是在取argmax时axis写错,都会导致大量预测结果错位。另一个可能是梯度实现有bug,导致模型根本没有在学习。

解决:先用极小数据集(比如10张训练图、10张测试图)跑一轮,打印出预测标签和真实标签,肉眼检查是否吻合。如果吻合,再用梯度检查脚本验证解析梯度的正确性。梯度检查永远是定位这类问题的第一工具。

6.4 现象:训练集准确率很高但测试集准确率上不去

原因:过拟合。在线性分类器上,过拟合通常表现为reg太小或训练轮数太多;在两层神经网络上,过拟合还可能是隐藏层太大、数据量相对不足。

解决:增大reg的值,从1e-3往1e-1或1e0方向调。在作业里,你也可以直接用交叉验证找最优reg。另外检查你是不是不小心把验证集当训练集用了——很多人因为偷懒直接拿全部50000张训练,导致对测试集的性能预估过于乐观。

6.5 现象:numpy广播导致维度不匹配报错

原因:在实现向量化操作时,把shape为(num_train,)的数组和shape为(num_train, 1)的数组直接相加,没有意识到它们在不同轴上广播。最常见的场景是在SVM损失里用scores[np.arange(num_train), y]取出正确类别的得分,结果得到一维数组,然后试图和一个二维矩阵做广播减法。

解决:用.reshape(-1, 1)把一维数组转成列向量,或者使用keepdims=True保持维度。养成写完就检查数组shape的习惯,特别是在dot操作之前把所有中间变量的维度在脑子里过一遍。numpy的广播规则是右对齐,两个数组从尾部比维度,相等或有一个为1才能广播。

7. 从作业到进阶:把assignment1的经验迁移到真实项目

做完整个assignment1之后,有价值的收尾工作是把每个分类器的准确率汇总成一张对比表,并且把你调参过程中的损失曲线图保存下来。这张表是你对“线性分类器的边界在哪里、神经网络为什么能赢”的最直观感知:KNN大概30%,SVM和Softmax在38%到40%,两层网络能到50%上下。记住这四个数字,你会对后面课程里CNN动不动90%以上的准确率有更实在的体感。

我建议你在收尾时做两件事:一是阅读作业配套的Hinton关于反向传播的论文,把SVM梯度推导和ReLU梯度mask的来龙去脉对照着过一遍,这能帮你把“会写代码”升级成“懂原理”;二是自己动手调整网络结构,比如把隐藏层改成两层、加入dropout、换用adam优化器,看看准确率曲线会发生什么变化。这些实验成本极低,但带来的理解深度远超多跑几轮网格搜索。

回到开头那个问题——cs231n的assignment1到底值不值得认真做?我的答案是:它不仅值得做,而且值得反复做。我后来在工业界做图像分类项目时,遇到的很多问题——数据预处理不当导致训练不收敛、梯度检查不过关就盲目调参、超参数搜索范围拍脑袋定——都能在assignment1里找到最初的影子。如果你只是照着博客抄代码跑一遍,那收获有限;如果你愿意在每次遇到NaN、每次损失不下降时,停下来想一想“为什么”,那这份作业带给你的东西会一直跟着你。

最后一句话是踩过坑的人才会信的:把梯度推导亲手写一遍,比调通十次代码都值。希望这篇笔记能帮你在cs231n作业assignment1的路上少走几步弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询