首页 / 资讯中心 / 文章详情

从kNN到两层神经网络:cs231n assignment1入门实战

从kNN到两层神经网络:cs231n assignment1入门实战 ★ FEATURED ARTICLE
简介这份CS231N计算机视觉公开课assignment1配套资料面向深度学习与视觉方向入门学习者提供作业原题、参考答案与数据集的完整练习包便于在本地Jupyter环境复现实验并对照自查。压缩包共54个文件、约561.98MB以交互式笔记本和Python源码模块为主另含CIFAR-10压缩数据包及少量配置、脚本和说明文档覆盖从代码运行到结果提交的环节。已有2006人学习下载。内容围绕KNN、SVM、Softmax、两层神经网络与特征提取实验展开涉及损失函数、梯度推导、超参数交叉验证和图像特征工程并包含分类器、层实现、优化器与梯度检查代码。帮助读者建立图像分类任务的整体认识并积累可复用的实验经验适合按作业顺序调试、比对答案并理解输出也可用于课程复习、实验排错及衔接后续作业。1. 零基础也能读懂的 cs231n assignment1它到底在练什么假设你面前摊着六万张 32×32 的小图片有人问你这张糊成一团的图到底是猫还是狗你的第一反应大概是翻出几张长得最像的来对比。cs231n 这门深度学习公开课的 assignment1练的就是把“找最像的”这件直觉写成计算机能执行的代码。它不是什么神秘项目而是一份带骨架、带测试、带数据集的编程作业给你一堆小图让你从最笨的“最近邻”分类器开始一路写到线性分类器最后手写一个两层神经网络看着准确率从 0.28 一点点爬上去。整个过程不需要 GPU一台普通笔记本就能跑完。适合谁会一点点 Python 循环和数组、数学已经忘得差不多、但想真正搞懂“模型是怎么学会分类的”的人。如果你只是背过“交叉熵”“梯度下降”这些词却从没自己算过一次梯度那这份作业正好补上这块。2. 为什么值得动手从“看得懂公式”到“写得出代码”2.1 深度学习入门最缺的不是数学是手上有感觉我见过太多人卡在同一个地方视频看得懂公式推得动一到自己写就懵。原因很简单——看别人推导是被动接受自己动手写是主动重建中间隔着一道很宽的沟。举个具体例子。你大概听过“梯度就是损失函数对参数的偏导”这句话。听上去没什么难度但当你真的要在dW[:, y[i]] - X[i]这一行里决定该加还是该减、该对哪个类别操作时你会发现脑子里那点概念根本不够用。这时候唯一的办法就是写一遍跑一遍让数值梯度检查告诉你错在哪。assignment1 的设计恰好踩在这个点上。它不要求你先啃完一本优化教材而是先给你一个能跑的最小实现再让你用数值梯度去验证解析梯度。验证不过你就得回去一行行看这个过程比看十遍视频都管用。零基础的人最怕的是“不知道自己在干什么”而这份作业每一步都有测试文件告诉你对了或者错了。还有一点很关键它的反馈特别快。kNN 写完几秒钟就能看到准确率SVM 写完损失曲线立刻画出来。这种即时反馈对新手极其重要因为你能明确感觉到“我改的这行代码真的影响结果了”。深度学习里很多东西是玄学但这份作业里几乎没有玄学全是能对上账的加减乘除。2.2 assignment1 的五块内容按难度排一排这份作业不是让你一口气吃完的它分了五块难度大致递增。我按新手体感排了个顺序你可以照着走顺序练习核心要写的东西新手大概耗时1kNN距离矩阵 投票预测3~4 小时2SVM合页损失 梯度4~6 小时3Softmax交叉熵 数值稳定3~5 小时4两层神经网络前向传播 反向传播 训练循环6~8 小时5图像特征手工特征拼接 调参2~3 小时第一块 kNN 几乎不需要数学你只要会算两点之间的距离、会数票就行。它的作用不是让你学会一个有用的模型kNN 在实际里基本没人用而是让你熟悉整套流程数据怎么加载、训练集测试集怎么分、准确率怎么算。第二、三块的 SVM 和 Softmax 是真正的分水岭。从这里开始你要接触“损失函数”和“梯度”这两个词的实际含义。很多人在这卡住不是因为它难而是因为第一次见到“我要对每个错误类别都算一次惩罚”这种写法脑子转不过弯。第四块的两层神经网络是整份作业的重点。前面几块都是线性模型到这一步你才第一次加上隐藏层和 ReLU 激活函数第一次看到“非线性”带来的准确率跳变。第五块图像特征反而轻松属于给你一个“如果不死磕神经网络还能怎么提分”的备选答案。入门的话建议严格按这个顺序走别跳。我见过有人嫌 kNN 太简单直接去写神经网络结果连dists矩阵的维度都搞不清反而更慢。2.3 跑完它你手上会多出什么说点实在的。跑完这五块你会得到三样东西。第一样是一套自己写的 numpy 实现。不是调包是从头算矩阵乘法、从头算梯度。你会清楚地知道W的形状为什么是(3072, 10)为什么偏置b是 10 维而不是 3072 维。这些细节在调包的时候永远学不到。第二样是调参的肌肉记忆。学习率从 1e-7 试到 1e-2正则化强度从 0 试到 1e-2你会亲眼看到学习率太大会让损失炸成nan太大会让准确率卡在 0.1 不动。这种“手感”是看教程看不来的。第三样是对“过拟合”的直观理解。当你把训练集准确率调到 0.55、验证集只有 0.29 的时候你会瞬间明白为什么要有验证集、为什么要加正则化。这比背十遍定义有用得多。3. 动手之前环境、依赖和第一次跑通3.1 一份新手够用的环境清单先说结论不需要显卡不需要装深度学习框架一台能开浏览器的电脑就够了。整份作业只依赖 numpy 和 matplotlib外加一个 Jupyter 用来跑 notebook。我建议用虚拟环境把依赖隔离起来别直接装在系统 Python 里不然以后装别的包容易打架。下面是创建环境并装依赖的命令# 创建一个独立的 Python 环境名字随便取这里叫 cs231n python -m venv cs231n_env # 激活环境macOS / Linux 用下面这条 source cs231n_env/bin/activate # Windows 用下面这条 # cs231n_env\Scripts\activate # 装作业需要的最小依赖 pip install numpy matplotlib jupyter装完之后运行jupyter notebook就能在浏览器里打开交互式界面。这里解释一下每个包的作用numpy负责所有矩阵运算作业里几乎每一行都离不开它matplotlib用来画损失曲线和权重可视化图调参的时候全靠它jupyter提供一个能分块运行、随时看中间结果的编辑环境新手排查问题特别方便。版本方面不用太纠结Python 3.7 以上、numpy 1.x 以上就够。真要说有什么注意事项就是别用太老的 Python 2语法差异会让你在抄代码的时候莫名报错。3.2 把骨架跑起来的正确顺序作业的目录结构大概是这样的一个cs231n/文件夹放核心代码一个datasets/放数据几个.ipynb文件是每一步的练习入口。你不需要自己建项目拿到骨架之后按下面的顺序走就行。第一步先别急着写代码把数据加载跑通。打开 kNN 那个 notebook找到加载 CIFAR-10 数据集的那几行from cs231n.data_utils import load_CIFAR10 # 把图片读进来返回四个数组训练图、训练标签、测试图、测试标签 X_train, y_train, X_test, y_test load_CIFAR10(cs231n/datasets/cifar-10-batches-py) print(X_train.shape) # (50000, 32, 32, 3) print(y_train.shape) # (50000,) print(X_test.shape) # (10000, 32, 32, 3)这几行代码的意思是训练集有五万张 32×32 的彩色图测试集有一万张。注意图片的原始形状是三维的但后面所有计算都要把它拉平成一行 3072 个数字32×32×3所以紧接着会看到这样两行# 把每张图从 32x32x3 拉平成一行 3072 维的向量 X_train np.reshape(X_train, (X_train.shape[0], -1)) X_test np.reshape(X_test, (X_test.shape[0], -1)) print(X_train.shape) # (50000, 3072)-1在这里的意思是“这一维你自己算”numpy 会把 32×32×3 自动乘成 3072。这个技巧在作业里会反复出现看到别慌。第二步把数据切出一份验证集。这一步很多人会漏掉直接拿测试集调参后面会吃大亏。标准做法是从训练集里切出 1000 到 5000 张当验证集用来选超参数测试集留到最后只用一次。第三步跑一个“全预测成同一类”的傻瓜基线。听起来很蠢但它能告诉你随机猜的准确率大概是 0.1十个类别如果后面你写出来的模型还不如这个说明代码有 bug。3.3 用一段最小代码看懂整个流程在写任何分类器之前先在脑子里过一遍整条链路训练数据进来算它和测试样本的距离挑最近的几个看它们是什么类投票。就这么四步。我把它压成一段完整可跑的最小代码你可以先复制到一个新的 notebook 单元格里跑一下不用管它是不是用到了完整的类结构import numpy as np # 假装我们有 5 张测试图、100 张训练图每张图 3072 维 Xtr np.random.randn(100, 3072) ytr np.random.randint(0, 10, 100) Xte np.random.randn(5, 3072) # 第一步算距离用最朴素的广播写法 # Xte[:, None, :] 形状是 (5, 1, 3072)Xtr[None, :, :] 形状是 (1, 100, 3072) # 两者相减后求和开方得到 (5, 100) 的距离矩阵 dists np.sqrt(((Xte[:, None, :] - Xtr[None, :, :]) ** 2).sum(axis2)) print(dists.shape) # (5, 100) # 第二步每张测试图取最近的 k3 个训练样本 k 3 nearest np.argsort(dists, axis1)[:, :k] # (5, 3)存的是训练样本的下标 # 第三步投票选出出现次数最多的类别 for i in range(5): labels ytr[nearest[i]] pred np.bincount(labels, minlength10).argmax() print(f第 {i} 张图预测为类别 {pred})这段代码里最值得琢磨的是Xte[:, None, :] - Xtr[None, :, :]这一句。None的作用是在中间插一个长度为 1 的维度让两个数组的形状能对齐成(5, 100, 3072)然后 numpy 会自动把每一对样本相减。这就是所谓“广播”作业里向量化的核心技巧就是它。一开始看不懂没关系先把形状打印出来对着形状看几遍就通了。至于np.argsort它返回的是从小到大排序后的下标不是值本身。取前 k 个就是取最近邻。np.bincount则是数每个类别出现了几次argmax挑出次数最多的那个。这三行就是 kNN 的全部逻辑作业里你只是要把它写得更规范、支持任意 k、并且跑在真实数据上。跑通这段你就已经跨过了这道坎最难的一步知道自己在处理什么形状的数据。4. 五个练习逐个拆从最近邻到两层神经网络4.1 kNN先写一个最笨但能出分的分类器kNN 的作业要求你实现两个函数一个算距离一个做预测。距离有两种写法一种是双重循环一种是完全向量化。新手一定要先写循环版因为它最直白出错也最容易定位。class KNearestNeighbor(object): def __init__(self): pass def train(self, X, y): # kNN 没有真正的训练只是把训练数据原封不动存下来 self.X_train X self.y_train y 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] # 3072 维的差向量 dists[i, j] np.sqrt(np.sum(diff ** 2)) return dists这段代码的逻辑很直白对每一张测试图和每一张训练图算一次欧氏距离填进矩阵对应位置。两个参数X和self.X_train的形状分别是(num_test, 3072)和(num_train, 3072)返回的dists是(num_test, num_train)。这个循环版在真实数据上会很慢。500 张测试图对 5000 张训练图要跑将近一分钟。所以作业后面会要求你写向量化版本把两层循环干掉。我的建议是先让循环版跑出结果确认准确率在 0.28 左右再去优化速度。如果跳过了这步向量化写错的时候你都不知道是逻辑错了还是形状错了。预测函数的写法更简单def predict_labels(self, dists, k1): num_test dists.shape[0] y_pred np.zeros(num_test) for i in range(num_test): # argsort 拿到距离从小到大的下标取前 k 个 closest_y self.y_train[np.argsort(dists[i])[:k]] # bincount 数票argmax 挑出票数最多的类别 y_pred[i] np.argmax(np.bincount(closest_y)) return y_predk1就是最近邻k5就是五个邻居投票。你会发现 k 取值不同准确率会晃来晃去这就是超参数。作业会让你试几个不同的 k画出准确率曲线你会看到 k 太小容易受噪声影响k 太大又把远处的样本也算进来中间有个甜点。这里有个新手常犯的错np.bincount如果遇到某个类别一次都没出现返回数组长度会变短导致下标错位。加上minlength10就稳了。这个小细节在测试文件里不会直接告诉你但你如果发现准确率异常低八成是这里。4.2 SVM 与 Softmax把“打分”翻译成“损失”从这一节开始你要接触真正的“学习”了。核心思路只有一句话模型给每个类别打一个分我们希望正确类别的分比错误类别的分高出一截差多少就罚多少。SVM 用的是合页损失hinge loss。写成代码是这样的def svm_loss_naive(W, X, y, reg): # W: (3072, 10) 权重矩阵X: (N, 3072) 一批样本y: (N,) 正确标签 dW np.zeros(W.shape) num_classes W.shape[1] num_train X.shape[0] loss 0.0 for i in range(num_train): scores X[i].dot(W) # 这张图对 10 个类别的打分 correct scores[y[i]] # 正确类别的得分 for j in range(num_classes): if j y[i]: continue # 正确类别自己不用罚 margin scores[j] - correct 1.0 # 错误类得分 - 正确类得分 安全边距 if margin 0: # 只有没拉开差距才罚 loss margin dW[:, j] X[i] # 错误类的权重想让它降分 dW[:, y[i]] - X[i] # 正确类的权重想让它升分 loss / num_train dW / num_train loss reg * np.sum(W * W) # L2 正则防止权重长得太大 dW 2 * reg * W return loss, dW几个参数要说明白。W是权重矩阵3072 行对应每个像素位置10 列对应十个类别。reg是正则化强度取值越大模型越保守。margin那个1.0是安全边距意思是正确类别的分至少要比错误类别高 1 分高不到就按差距罚款。Softmax 换了个思路先把打分变成概率然后让正确类别的概率尽量接近 1用交叉熵衡量差距。写法上有个坑必须注意——直接对打分取exp会溢出成inf所以要先减去最大值def softmax_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].dot(W) scores - np.max(scores) # 关键减最大值防止 exp 溢出 exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores) # 归一化成概率 loss - np.log(probs[y[i]]) # 正确类别概率越低罚得越狠 # 反向传播概率向量里正确类减 1其余保持 probs[y[i]] - 1 dW np.outer(X[i], probs) loss / num_train dW / num_train loss reg * np.sum(W * W) dW 2 * reg * W return loss, dW这段代码里scores - np.max(scores)那一行是血泪经验换来的。如果不减当某个打分是 800 的时候exp(800)直接变成inf然后inf / inf得到nan整个损失就废了。减去最大值不改变概率分布但把数值压到了安全范围。这个技巧在后面写神经网络的时候还要用一次。写完这两块作业里会要求你做梯度检查。原理很简单把某个权重加一点点看损失变化多少跟解析梯度对比误差应该在 1e-8 以内。检查不过基本都是dW里少乘了、少除了或者正则项的导数忘了加。4.3 两层神经网络第一次看到“学习”发生前面三个模型全是线性的本质上是给每个类别学一个模板然后把图片往上套。这种方法的天花板大概在 0.4 左右。想再往上就得加非线性。两层网络的思路是先用第一层把 3072 维压到一个更小的隐藏维度比如 100过一下 ReLU 把负数清零再用第二层从这 100 维映射到 10 个类别。多了这么一层模型就能学到“边缘 组合”这种更复杂的模式。class TwoLayerNet(object): def __init__(self, input_size, hidden_size, num_classes, std1e-3): self.params {} # 第一层输入 3072 维压到 hidden_size 维 self.params[W1] std * np.random.randn(input_size, hidden_size) self.params[b1] np.zeros(hidden_size) # 第二层hidden_size 维映射到 10 个类别 self.params[W2] std * np.random.randn(hidden_size, num_classes) self.params[b2] np.zeros(num_classes) def loss(self, X, yNone, reg0.0): W1, b1 self.params[W1], self.params[b1] W2, b2 self.params[W2], self.params[b2] # 前向线性 ReLU hidden np.maximum(0, X.dot(W1) b1) scores hidden.dot(W2) b2 if y is None: return scores # softmax 交叉熵和上一节一样先减最大值 scores - np.max(scores, axis1, keepdimsTrue) exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) num_train X.shape[0] loss -np.sum(np.log(probs[np.arange(num_train), y])) / num_train loss reg * (np.sum(W1 * W1) np.sum(W2 * W2)) # 反向从 softmax 往回推 dscores probs.copy() dscores[np.arange(num_train), y] - 1 dscores / num_train grads {} grads[W2] hidden.T.dot(dscores) 2 * reg * W2 grads[b2] np.sum(dscores, axis0) dhidden dscores.dot(W2.T) dhidden[hidden 0] 0 # ReLU 的导数负数位置梯度归零 grads[W1] X.T.dot(dhidden) 2 * reg * W1 grads[b1] np.sum(dhidden, axis0) return loss, gradshidden_size是你自己定的100 到 500 都行越大模型越强但也越容易过拟合。std是初始化权重的标准差太小会导致所有神经元行为一样太大会让激活值爆炸1e-3 是个稳妥的起点。写完这个类作业会用一个小批量梯度下降的循环来训练它每次拿 200 张图算损失和梯度按W - learning_rate * dW更新重复几千次。你会看到损失从 2.3 慢慢降到 1.5 左右训练集准确率爬到 0.5 上下。第一次看到自己写的代码让准确率动起来感觉是很好的。这里最容易翻车的地方是dhidden[hidden 0] 0这一行。ReLU 的导数在正数区是 1、负数区是 0忘记清掉负数位置的梯度训练就会莫名其妙地失败——损失不降反升也不会报错。遇到这种情况先检查这一行。4.4 图像特征给不想死磕的一步捷径最后一块作业很有意思它让你在原始像素之外再试试手工设计的特征比如方向梯度直方图HOG加上颜色直方图。这些特征在上古时代是主流现在虽然被卷积网络取代了但对新手来说是个很好的对照实验。作业的做法是把图片切成小格子统计每个格子里梯度方向的分布再和颜色分布拼起来得到一个比 3072 更短的向量。用这个向量去训练 SVM 或 Softmax准确率能从 0.4 提到 0.5 左右。这一步不需要写太多新代码主要是理解和调参。它给你的启示是模型再笨特征好的话也能出成绩反过来特征不行模型再花哨也白搭。这个判断力在后面做真实项目的时候很关键。5. 新手最容易踩的五个坑从现象反推原因5.1 损失变成 nan 或者 inf现象训练刚开始几步损失值突然从 2.3 跳到nan后面所有准确率都是 0。原因九成是exp溢出。softmax 里如果没有减去每行的最大值当打分稍微大一点几十就够np.exp结果就会超出浮点数范围。另一种可能是学习率设太大一步更新把权重推到了极端值。解决先检查 softmax 里有没有scores - np.max(scores, axis1, keepdimsTrue)这一行。有的话把学习率从 1e-2 降到 1e-3 或 1e-4 再试。还不行就打印一下更新前的权重绝对值如果已经上万说明初始化标准差设太大了改回 1e-3。5.2 梯度检查永远过不了现象数值梯度和解析梯度的相对误差在 0.1 以上反复改都没用。原因通常是正则项的导数漏了。loss reg * np.sum(W * W)对应的导数应该是2 * reg * W很多人只写了reg * W或者干脆忘了这一项。另一种是忘了除以num_train损失除了但梯度没除。解决先把reg设成 0 再检查一次。如果误差立刻降到 1e-8那就是正则项导数的问题。如果还是不过就单独检查某一个权重元素的梯度用(loss_plus - loss_minus) / (2 * h)手算一个值和解析梯度对比看差在哪个量级。5.3 向量化之后准确率暴跌现象循环版跑出来 0.28改成向量化后掉到 0.1也就是随机猜的水平。原因向量化的广播写错了方向。比如算距离的时候忘记开方、或者对错了轴。axis参数写错是最高频的问题axis1和axis0的结果形状完全不同但代码不会报错只是悄悄算出错误答案。解决在向量化版本里加一句assert np.allclose(dists_vectorized, dists_loops, atol1e-6)拿小批量数据跟循环版逐元素对比。一旦对不上就打印几个具体位置的数值看是整体偏移还是个别位置错。这个方法看起来笨但比盯着代码看快十倍。5.4 拿测试集调参分数虚高现象你在测试集上试了十几种学习率挑出最好的那个报告准确率 0.52。过两天重跑一遍换个随机种子只有 0.46。原因测试集被当成了验证集用模型间接“见过”了测试数据。这种分数是虚的换个数据就崩。解决从训练集里切出 1000 到 5000 张当验证集所有调参都在验证集上做。测试集只在最后跑一次跑完就不要再改任何东西。这个习惯我现在每次做实验都强制走一遍因为血泪经验告诉我虚高的分数比低分更能骗人。5.5 Jupyter 里改了代码但结果不变现象明明改了cs231n/classifiers/k_nearest_neighbor.py重新运行单元格结果还是老样子。原因Python 的模块导入是带缓存的Jupyter 内核不会自动重新加载你改过的.py文件。第一次 import 之后后面调用的都是内存里那份旧代码。解决改完外部文件点一下内核的 Restart或者用importlib.reload手动重载。更省事的办法是在 notebook 开头放这几行import importlib import cs231n.classifiers.k_nearest_neighbor as knn_module importlib.reload(knn_module)养成改完外部文件就重启内核的习惯能省掉大量“我明明改了”的自我怀疑。6. 做完 assignment1 之后下一步学什么跑完这五块很多人会陷入一个空窗期知道 kNN、SVM、softmax、两层网络是怎么回事了但下一步该往哪走我给一条我实际走过的路径你可以照着排。第一步先把 assignment1 的向量化版本全部写干净。循环版能跑通只是及格向量化才是真正理解 numpy 广播的时候。特别是svm_loss_vectorized和softmax_loss_vectorized写完对着梯度检查跑一遍误差在 1e-9 以内再往下走。这一步大概花两三天但后面所有代码都靠这个基础。第二步往 assignment2 走重点看三样东西批量归一化、Dropout、卷积层。前两个是防止过拟合的常用手段你在 assignment1 里已经被“训练集 0.55、验证集 0.29”折磨过了正好有动机去理解它们。卷积层则是从全连接走向图像识别的关键一步理解了卷积核在滑动的过程中共享权重你才算真正入门计算机视觉。第三步把同样的模型用 PyTorch 重写一遍。为什么要重写因为你需要体会“框架帮你做了什么”。手写过一遍dhidden[hidden 0] 0之后再看到nn.ReLU()你会知道它在背后干了什么而不是把它当成魔法。这个对照过程对建立信心特别重要。第四步找一个自己的小数据集练手。不要一上来就做大的几百张图片、三五个类别就够。把数据加载、划分、训练、评估这条链路完整走一遍你会遇到 assignment1 里没出现过的问题图片尺寸不一致、类别不平衡、标签有噪声。这些问题才是真实项目的常态。我给个简单的路线对照表你按自己的节奏挑阶段重点建议投入巩固向量化 梯度检查2~3 天进阶批量归一化、Dropout、卷积1~2 周迁移用框架重写 assignment1 的模型3~5 天实战自选小数据集完整跑一遍1~2 周有个习惯我想单独说一句。我第一次做完这份作业的时候图快梯度检查全靠“看起来差不多”糊弄过去结果到 assignment2 写反向传播时彻底翻车一个axis写错调了整整两天才发现问题出在最早那层。从那以后我每次写完任何带梯度的代码都强制先跑一遍数值梯度检查误差不过 1e-7 绝不往下写。这份作业最大的价值不是教会你几个模型而是让你养成“先验证再推进”的习惯。慢慢来别跳步希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站