☰
cs231n assignment1 从零实现:kNN、SVM、Softmax与两层神经网络
2026/10/15 18:48:42 网站建设 项目流程

简介:这份资源是斯坦福CS231N计算机视觉公开课assignment1的完整作业包,面向正在入门深度学习与计算机视觉的学生和自学者,帮助解决从环境搭建到代码实现、再到结果验证的全流程问题。包内共54个文件,以ipynb交互式笔记本、py源码、pyc编译文件为主,另含tar、gz、zip等数据集压缩包及xml、sh、md等配置与说明文件,整体约561.98MB,覆盖knn、svm、softmax、two_layer_net、features等核心作业模块,并附带CIFAR-10数据集,可直接在Anaconda的Jupyter环境中打开运行。目前已有2003人学习下载。资源同时提供作业题目与参考答案,配合solver、layers、optim等模块代码,便于读者对照理解梯度计算、分类器实现与网络训练细节,适合边做边查、快速定位报错原因,是入门计算机视觉实践的一份实用参考。

1. 从一份作业代码说起:为什么它值得你花时间跑一遍

如果你正在找一份能真正把深度学习基础打穿的材料,cs231n 的 assignment1 大概率会出现在推荐列表里。它不像那些调包跑通 MNIST 就结束的教程,而是要求你从零实现 kNN、SVM、Softmax 分类器,再手写两层神经网络,最后还要推导反向传播。整套作业覆盖了数据预处理、损失函数、梯度计算、超参数调优、模型评估的完整链路。适合谁?刚学完机器学习课程但没动手写过梯度下降的人,或者想从 PyTorch 调包侠回炉重造、搞清楚 autograd 背后到底在算什么的人。这份资源就是完整的作业代码包,包含所有 notebook、数据集加载脚本和工具函数,拿到手就能在本地或服务器上跑起来。

2. 环境搭建与数据准备:从零把作业跑起来

2.1 依赖安装与目录结构确认

拿到代码包后第一件事不是急着打开 notebook,而是先把环境对齐。cs231n assignment1 的代码结构很清晰,根目录下通常有cs231n/文件夹存放分类器实现,datasets/放 CIFAR-10 数据,assignment1/下是各个任务的 notebook。常见做法是建一个 Python 3.8+ 的虚拟环境,然后安装 numpy、matplotlib、jupyter 这几个核心依赖。注意不要用太新的 numpy 版本,某些老代码里np.float这类别名已经被移除,会直接报错。

# 创建虚拟环境并安装依赖 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install numpy matplotlib jupyter scipy # 下载 CIFAR-10 数据集(如果代码包没自带) cd cs231n/datasets bash get_datasets.sh

get_datasets.sh做的事情就是下载 CIFAR-10 的 Python 版本并解压到当前目录。如果你网络环境不稳定,也可以手动下载后放到datasets/cifar-10-batches-py/下。验证数据是否就位的方法很简单,进入 Python 交互环境执行from cs231n.data_utils import load_CIFAR10,不报错就说明路径对了。

2.2 数据加载与预处理的关键参数

数据加载这块,data_utils.py里的load_CIFAR10函数会把 50000 张训练图和 10000 张测试图读成 numpy 数组。每张图是 32x32x3,拉平后就是 3072 维向量。这里有个容易翻车的点:很多人直接拿原始像素去训练,结果 kNN 的准确率惨不忍睹。正确做法是先做归一化,把像素值从 0-255 映射到均值为 0、方差为 1 的分布。

# 数据加载与预处理示例 from cs231n.data_utils import load_CIFAR10 import numpy as np X_train, y_train, X_test, y_test = load_CIFAR10('cs231n/datasets/cifar-10-batches-py') # 拉平图像:N x 32 x 32 x 3 -> N x 3072 X_train = np.reshape(X_train, (X_train.shape[0], -1)) X_test = np.reshape(X_test, (X_test.shape[0], -1)) # 关键一步:减均值 mean_image = np.mean(X_train, axis=0) X_train -= mean_image X_test -= mean_image # 验证集划分:从训练集里切 1000 张出来调超参 X_val = X_train[:1000] y_val = y_train[:1000] X_train = X_train[1000:] y_train = y_train[1000:]

减均值的逻辑说明:CIFAR-10 图像各通道的均值差异很大,不减掉的话,某些像素维度会主导距离计算。参数上,axis=0表示按样本维度求平均,得到的是一个 3072 维的均值向量。验证集划分是为了后续调 kNN 的 k 值和 SVM 的 learning rate,不要用测试集去调参,这是血泪经验。

3. 核心分类器实现:kNN、SVM 与 Softmax 的代码拆解

3.1 kNN:向量化距离计算与交叉验证

kNN 是 assignment1 里最直观但也最容易被写慢的部分。两层循环算距离能跑,但 50000 张图两两算欧氏距离,时间开销大到让人想砸键盘。作业要求用向量化方式实现,核心思路是把 L2 距离展开成||x-y||^2 = ||x||^2 + ||y||^2 - 2*x·y,这样就能用矩阵乘法一次性算完。

# kNN 向量化距离计算(cs231n/classifiers/k_nearest_neighbor.py) def compute_distances_no_loops(self, X): # X: (num_test, D), self.X_train: (num_train, D) # 利用 (x-y)^2 = x^2 + y^2 - 2xy X_sq = np.sum(X**2, axis=1, keepdims=True) # (num_test, 1) train_sq = np.sum(self.X_train**2, axis=1) # (num_train,) cross = X.dot(self.X_train.T) # (num_test, num_train) dists = X_sq + train_sq - 2 * cross return np.sqrt(np.maximum(dists, 0)) # 防止负数开根号

逻辑说明:X_sq和train_sq分别是测试样本和训练样本的模平方,cross是内积矩阵。np.maximum(dists, 0)是为了处理浮点误差导致的微小负值,不加这层保护偶尔会出 NaN。参数上,keepdims=True保证广播时维度对齐。交叉验证部分,作业要求把训练集分成 5 折,对每个 k 值算平均准确率。我一般会先在小样本上跑通再上全量,不然等结果要等很久。

3.2 SVM 与 Softmax:损失函数与梯度推导

SVM 和 Softmax 是线性分类器的两个经典代表。SVM 用 hinge loss,Softmax 用交叉熵。作业要求实现两者的loss和grad,而且梯度必须用向量化方式算。先看 SVM 的损失函数,核心是max(0, s_j - s_yi + delta),其中 delta 通常取 1.0。

# SVM 损失与梯度(cs231n/classifiers/linear_svm.py) def svm_loss_vectorized(W, X, y, reg): num_train = X.shape[0] scores = X.dot(W) # (N, C) correct_scores = scores[np.arange(num_train), y] # (N,) margins = np.maximum(0, scores - correct_scores[:, np.newaxis] + 1.0) margins[np.arange(num_train), y] = 0 # 正确类别不贡献损失 loss = np.sum(margins) / num_train + 0.5 * reg * np.sum(W**2) # 梯度计算 margins[margins > 0] = 1.0 row_sum = np.sum(margins, axis=1) margins[np.arange(num_train), y] = -row_sum dW = X.T.dot(margins) / num_train + reg * W return loss, dW

逻辑说明:margins先算每个样本对所有类别的 hinge 值,正确类别位置置零。梯度部分,对每个样本,非正确类别如果 margin 大于 0 就贡献X_i,正确类别贡献-X_i * (margin>0 的个数)。reg是 L2 正则化系数,常见取值 1e-5 到 1e-3 之间。Softmax 的梯度形式更简洁,dW = X.T.dot(probs - one_hot_y) / N + reg * W,其中probs是 softmax 后的概率矩阵。注意数值稳定性,算 exp 之前要先减去每行的最大值。

4. 两层神经网络:前向传播、反向传播与训练循环

4.1 网络结构定义与前向传播

两层神经网络的结构是输入 -> 全连接 -> ReLU -> 全连接 -> Softmax。作业要求手写前向和反向,不能用框架的 autograd。前向传播的代码在cs231n/classifiers/neural_net.py里,核心就是两次矩阵乘法和一次 ReLU 激活。

# 两层神经网络前向传播 def forward(self, X): # 第一层:线性变换 + ReLU hidden = X.dot(self.params['W1']) + self.params['b1'] relu_mask = hidden > 0 hidden = hidden * relu_mask # 第二层:线性变换得到 scores scores = hidden.dot(self.params['W2']) + self.params['b2'] # Softmax 计算概率 scores -= np.max(scores, axis=1, keepdims=True) # 数值稳定 exp_scores = np.exp(scores) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) # 缓存中间变量供反向传播使用 cache = (X, hidden, relu_mask, probs, self.params) return probs, cache

参数说明:W1形状是(D, H),b1是(H,),W2是(H, C),b2是(C,)。H 是隐藏层大小,常见取值 100 到 500。relu_mask缓存下来是为了反向传播时直接复用,不用重新算一遍。数值稳定那一步很关键,不减去最大值的话,exp 容易溢出成 inf。

4.2 反向传播与参数更新

反向传播是 assignment1 里最硬核的部分。作业提供了数值梯度检查函数,写完解析梯度后一定要跑一遍grad_check,误差在 1e-8 量级才算对。反向传播的核心是链式法则,从 softmax 的梯度一路回传到第一层的权重。

# 反向传播核心片段 def backward(self, cache, y): X, hidden, relu_mask, probs, params = cache N = X.shape[0] # Softmax 梯度:probs - one_hot dscores = probs.copy() dscores[np.arange(N), y] -= 1 dscores /= N # 第二层梯度 dW2 = hidden.T.dot(dscores) db2 = np.sum(dscores, axis=0) dhidden = dscores.dot(params['W2'].T) # ReLU 反向:小于等于 0 的位置梯度置零 dhidden[~relu_mask] = 0 # 第一层梯度 dW1 = X.T.dot(dhidden) db1 = np.sum(dhidden, axis=0) # 加上正则化项的梯度 dW2 += self.reg * params['W2'] dW1 += self.reg * params['W1'] return {'W1': dW1, 'b1': db1, 'W2': dW2, 'b2': db2}

逻辑说明:dscores是 softmax 交叉熵对 scores 的梯度,形式非常简洁。dhidden[~relu_mask] = 0是 ReLU 的反向,前向时被置零的位置反向梯度也是零。正则化项只加在权重上,偏置不加。参数更新用 SGD,学习率一般从 1e-3 开始试,配合学习率衰减效果更好。训练循环里每跑几个 epoch 就记录一次验证集准确率,方便早停。

5. 避坑与排查:跑 assignment1 时最容易翻车的几个地方

5.1 梯度检查不通过,误差量级在 1e-3 以上

现象是grad_check输出的相对误差远大于 1e-7。原因通常是反向传播里某个矩阵转置写反了,或者 ReLU 的 mask 用错。解决方法是把网络拆成单层逐个检查,先验证第一层线性变换的梯度,再验证 ReLU,最后验证第二层。数值梯度用(f(x+h) - f(x-h)) / (2h),h 取 1e-5 比较合适。

5.2 kNN 跑全量数据时内存爆掉

现象是compute_distances_no_loops里cross矩阵占了几 GB 内存。原因是 50000 x 5000 的浮点矩阵确实很大。解决办法是分批次计算距离,每次只算 500 个测试样本,算完就更新 top-k 结果。或者直接用scipy.spatial.distance.cdist,它内部做了内存优化。

5.3 Softmax 损失出现 NaN

现象是训练几个 iteration 后 loss 变成 nan。原因是 exp 溢出,scores 太大导致np.exp返回 inf。解决方法是减最大值那一步不能省,而且要在减完之后再算 exp。如果已经出现了 NaN,检查学习率是不是太大,SGD 更新时权重爆炸也会导致 scores 过大。

5.4 验证集准确率远低于训练集

现象是训练集准确率 40% 但验证集只有 25%。原因是过拟合,或者验证集划分时没有打乱顺序。CIFAR-10 的原始数据是按类别排序的,直接切前 1000 张会导致验证集里只有某几个类。解决方法是先np.random.shuffle再切分,或者用np.random.permutation生成索引。

5.5 notebook 里 import cs231n 报 ModuleNotFoundError

现象是在assignment1目录下打开 jupyter 却找不到cs231n包。原因是 Python 路径没包含当前目录。解决方法是在 notebook 开头加import sys; sys.path.append('..'),或者把工作目录设成项目根目录。我一般会在根目录启动 jupyter,然后用相对路径引用。

6. 二次开发与改造:从作业代码到自己的实验平台

跑通作业只是第一步,真正有价值的是把它改造成自己能用的实验平台。我一般会做三件事:把分类器封装成 sklearn 风格的fit/predict接口,加一个配置文件管理超参数,再写一个简单的训练日志记录器。这样后续换数据集或者加新层就不用改一堆 notebook。

# 封装成 sklearn 风格的分类器 class LinearClassifier: def __init__(self, model='svm', reg=1e-3, lr=1e-3, epochs=100): self.model = model self.reg = reg self.lr = lr self.epochs = epochs self.W = None def fit(self, X, y): D, C = X.shape[1], np.max(y) + 1 self.W = np.random.randn(D, C) * 0.001 for epoch in range(self.epochs): if self.model == 'svm': loss, grad = svm_loss_vectorized(self.W, X, y, self.reg) else: loss, grad = softmax_loss_vectorized(self.W, X, y, self.reg) self.W -= self.lr * grad if epoch % 10 == 0: print(f'epoch {epoch}, loss {loss:.4f}') return self def predict(self, X): return np.argmax(X.dot(self.W), axis=1)

改造思路说明:fit里把损失和梯度计算抽象成可切换的,加模型只需要加一个分支。predict直接取 scores 最大的类别。配置文件可以用 YAML 或 JSON,把reg、lr、epochs、hidden_size这些参数外置。训练日志记录器我习惯用logging模块,同时输出到控制台和文件,方便回溯。

验证改造是否成功的方法很简单:用同一份数据跑原始 notebook 和封装后的类,对比最终准确率是否一致。如果差了 1% 以上,大概率是随机种子或者数据划分没对齐。我一般会在fit里固定np.random.seed,保证可复现。

从那以后我每次改作业代码都强制走一遍梯度检查,哪怕只是改了个激活函数。希望帮到你。

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

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

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

立即咨询