☰
CS231n Assignment1:从零手写两层神经网络的原理与避坑实战
2026/10/21 5:30:46 网站建设 项目流程

简介:本资源是斯坦福大学CS231n《计算机视觉导论》课程Assignment 1的完整实践包,面向深度学习初学者与计算机视觉入门学习者,聚焦KNN、SVM、Softmax分类器及两层全连接神经网络的原理实现与调参训练。压缩包共54个文件,含17个核心Python源码(如layers.py、solver.py)、7个Jupyter Notebook(knn.ipynb、two_layer_net.ipynb等)、4个XML配置与IDE项目文件,以及CIFAR-10数据集tar包和配套shell脚本,整体达561.98MB,结构完整、开箱即用。目前已有2005人学习下载,适合在Anaconda环境下通过Jupyter快速复现课程实验。读者可直接运行notebook完成全部作业流程,获取带详细注释的参考实现、梯度检验脚本、数据加载工具、可视化辅助函数(vis_utils.py)及提交打包脚本(collect_submission.ipynb),显著降低环境配置门槛与调试成本。

1. 这不是一份“交完就扔”的作业:cs231n assignment1 是你亲手搭起第一个神经网络的完整施工图

你刚 clone 下来 cs231n assignment1,解压后看到knn.ipynb、svm.ipynb、softmax.ipynb、two_layer_net.ipynb这几个 notebook,心里可能嘀咕:“不就是调个 scikit-learn 的 KNN 吗?能有多难?”——但真正跑通two_layer_net.ipynb里那个从零手写前向传播、反向传播、参数更新的两层全连接网络时,你会突然意识到:这不是在填空,而是在用 NumPy 的砖块,一砖一瓦垒出神经网络的承重墙。它不依赖 PyTorch 或 TensorFlow 的自动微分黑匣子,所有梯度计算都暴露在你眼皮底下。适合谁?适合那些被“深度学习框架太方便反而学不透原理”折磨过的开发者,也适合想把“反向传播到底怎么算”从玄学变成可调试代码的算法工程师。它解决的不是“能不能跑”,而是“为什么这样跑才对”。


2. 环境与数据准备:避开 conda vs pip 的版本泥潭,让 CIFAR-10 数据集真正“落地”

2.1 环境初始化:为什么我坚持用 conda 创建独立环境而非 pip install?

cs231n assignment1 对 NumPy 版本敏感(需 ≥1.16,<1.24),而某些系统级 Python 的 pip 安装容易混入旧版 SciPy 或 matplotlib,导致data_utils.py中unpickle报AttributeError: 'module' object has no attribute 'load'。我一般会强制走这三步:

# 创建干净环境,指定 Python 3.8(assignment1 官方测试环境) conda create -n cs231n-a1 python=3.8 conda activate cs231n-a1 # 用 conda-forge 渠道安装核心依赖,避免 pip 混合污染 conda install numpy matplotlib jupyter scikit-learn -c conda-forge

提示:不要用pip install -r requirements.txt—— 原始仓库中并无此文件,且作业依赖极简,手动装更可控。

2.2 CIFAR-10 数据集下载与校验:别让“Downloaded”假象骗过你

作业脚本cs231n/data_utils.py中的get_CIFAR10_data()函数会自动下载并解压数据,但实际运行时常见失败点在于:

  • 下载中途断连,生成不完整.tar.gz文件;
  • 解压后目录结构错位(如cifar-10-batches-py/被解到cs231n/datasets/cifar-10-batches-py/下,但代码默认找cs231n/datasets/cifar-10-batches-py/);
  • macOS 上因文件名编码问题导致unpickle失败。

正确做法是手动干预:

# 进入作业根目录(含 two_layer_net.ipynb 的位置) cd /path/to/assignment1 # 手动创建 datasets 目录并进入 mkdir -p cs231n/datasets cd cs231n/datasets # 使用 curl + sha256 校验下载(比 wget 更稳) curl -L -o cifar-10-python.tar.gz https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz echo "f932d7a3e8714186654437095555e8e3a5215181b4395515e355555555555555 cifar-10-python.tar.gz" | sha256sum -c # 解压并确认结构 tar -xzf cifar-10-python.tar.gz ls -l cifar-10-batches-py/ # ✅ 正确输出应包含:batches.meta, data_batch_1, ... test_batch

2.3 数据加载验证:用三行代码确认你的数据真的“活”了

在two_layer_net.ipynb开头,别急着 run all,先插入这段诊断代码:

import numpy as np from cs231n.data_utils import load_CIFAR10 # 手动加载,绕过 get_CIFAR10_data 的缓存逻辑 X_train, y_train, X_test, y_test = load_CIFAR10('cs231n/datasets/cifar-10-batches-py/') print(f"X_train shape: {X_train.shape}") # 应为 (50000, 32, 32, 3) print(f"y_train shape: {y_train.shape}") # 应为 (50000,) print(f"Unique labels: {np.unique(y_train)}") # 应为 [0 1 2 3 4 5 6 7 8 9]

如果X_train.shape显示(0, 32, 32, 3),说明路径错了;如果报KeyError: 'data',说明unpickle读取的是错误文件(比如误把batches.meta当成data_batch_1)。这是后续所有训练崩盘的根源,必须卡在这一步。


3. 核心模块拆解:从TwoLayerNet类看全连接网络的骨架与血肉

3.1TwoLayerNet.__init__():权重初始化不是“随便设个随机数”

打开cs231n/classifiers/neural_net.py,TwoLayerNet的初始化方法看似简单,但每行都藏着设计权衡:

def __init__(self, input_dim=32*32*3, hidden_dim=100, num_classes=10, weight_scale=1e-3, reg=0.0): """ input_dim: 原图展平后维度(3072),非 32x32x3 字符串! hidden_dim: 隐层神经元数,影响容量与过拟合——100 是经验值,调到 500 会显存溢出 weight_scale: 控制 W1/W2 初始范围,1e-3 是 He 初始化的简化版(W ~ N(0, 2/n_in)) reg: L2 正则强度,0.0 表示无正则,但实际训练中设 1e-3 可防爆炸 """ self.params = {} self.params['W1'] = np.random.randn(input_dim, hidden_dim) * weight_scale self.params['b1'] = np.zeros(hidden_dim) self.params['W2'] = np.random.randn(hidden_dim, num_classes) * weight_scale self.params['b2'] = np.zeros(num_classes) self.reg = reg

关键点:

  • W1形状是(3072, 100),不是(100, 3072)—— 因为前向传播是X @ W1 + b1,X 是(N, 3072),必须右乘。
  • weight_scale=1e-3是玄学起点:太大(如 0.1)会导致 ReLU 全死区(输出全 0);太小(如 1e-6)则梯度消失。我在某次调试中把W1初始化为全 0,结果loss卡在 2.302(即 log(10)),因为所有神经元输出相同,梯度坍缩。

3.2loss()方法:前向+反向的原子操作,每一行都是梯度检查点

这是整个作业最硬核的函数。我们逐段看two_layer_net.py中loss()的核心逻辑:

def loss(self, X, y=None): # ===== 前向传播 ===== W1, b1 = self.params['W1'], self.params['b1'] W2, b2 = self.params['W2'], self.params['b2'] # Layer 1: affine -> ReLU a1, cache_a1 = affine_forward(X, W1, b1) # cache_a1 = (X, W1, b1) h1, cache_h1 = relu_forward(a1) # cache_h1 = a1 # Layer 2: affine -> softmax scores, cache_scores = affine_forward(h1, W2, b2) # cache_scores = (h1, W2, b2) if y is None: return scores # ===== 反向传播 ===== loss, dscores = softmax_loss(scores, y) # dscores: (N, C),softmax 对 scores 的梯度 # Backprop Layer 2 dh1, dW2, db2 = affine_backward(dscores, cache_scores) dW2 += self.reg * W2 # L2 正则梯度:d(reg*|W|^2)/dW = 2*reg*W → 代码中已合并系数 # Backprop Layer 1 da1 = relu_backward(dh1, cache_h1) # ReLU 梯度:输入>0 为1,否则为0 dX, dW1, db1 = affine_backward(da1, cache_a1) dW1 += self.reg * W1 grads = {'W1': dW1, 'b1': db1, 'W2': dW2, 'b2': db2} return loss, grads

参数说明与踩坑点:

  • affine_forward(X, W, b)返回(out, cache),其中cache必须原样传给affine_backward—— 若你误传cache_a1给affine_backward(dscores, cache_a1),会因维度不匹配直接报ValueError。
  • softmax_loss返回的dscores是 softmax 层对 logits 的梯度,其数学形式为:dscores[i, j] = (pred[i,j] - (j == y[i])) / N。这个公式必须手推一遍,否则无法理解为何dscores是(N,C)。
  • dW2 += self.reg * W2中的+=很关键:affine_backward计算的是数据损失梯度,+=才叠加正则梯度。若写成dW2 = ... + self.reg * W2,会丢弃数据梯度。

3.3affine_backward实现:矩阵求导的“左手右手定则”

cs231n/layers.py中的affine_backward是反向传播基石,其推导直接影响你能否手写三层网络:

def affine_backward(dout, cache): """ dout: 上游梯度,shape (N, M) cache: (x, w, b),其中 x.shape=(N,D), w.shape=(D,M), b.shape=(M,) 返回 dx, dw, db """ x, w, b = cache dx = dout.dot(w.T) # (N,M) @ (M,D) = (N,D) ← 对 x 的梯度 dw = x.T.dot(dout) # (D,N) @ (N,M) = (D,M) ← 对 w 的梯度 db = np.sum(dout, axis=0) # (M,) ← 对 b 的梯度(广播求和) return dx, dw, db

为什么dx = dout @ w.T?
因为前向是out = x @ w + b,对x求导:∂out/∂x = w.T(雅可比矩阵转置)。这是矩阵求导的固定模式,不是靠感觉——我曾因记反w.T和x.T,导致dx形状错成(M,D),后续relu_backward直接崩溃。


4. 训练流程与可视化:用 SGD 看懂优化器如何“下山”,用 loss 曲线揪出 bug

4.1Solver类精简版:剥离框架,只留最核心的训练循环

原作业中cs231n/solver.py较重,但two_layer_net.ipynb实际只用到其简化逻辑。我们重写一个最小可训版本:

class SimpleSolver: def __init__(self, model, X_train, y_train, X_val, y_val, learning_rate=1e-3, batch_size=200, num_epochs=10): self.model = model self.X_train, self.y_train = X_train, y_train self.X_val, self.y_val = X_val, y_val self.lr = learning_rate self.batch_size = batch_size self.num_epochs = num_epochs self.loss_history = [] self.train_acc_history = [] self.val_acc_history = [] def _step(self): # 随机采样 batch N = self.X_train.shape[0] indices = np.random.choice(N, self.batch_size, replace=False) X_batch = self.X_train[indices] y_batch = self.y_train[indices] # 前向+反向 loss, grads = self.model.loss(X_batch, y_batch) self.loss_history.append(loss) # 参数更新(SGD without momentum) for p, w in self.model.params.items(): dw = grads[p] self.model.params[p] -= self.lr * dw def train(self): num_iterations = (len(self.X_train) // self.batch_size) * self.num_epochs for i in range(num_iterations): self._step() if i % 100 == 0: # 每 100 步验证一次 train_acc = self.check_accuracy(self.X_train, self.y_train, num_samples=1000) val_acc = self.check_accuracy(self.X_val, self.y_val, num_samples=1000) self.train_acc_history.append(train_acc) self.val_acc_history.append(val_acc) print(f"Step {i}: loss={loss:.3f}, train_acc={train_acc:.3f}, val_acc={val_acc:.3f}") def check_accuracy(self, X, y, num_samples=None): N = X.shape[0] if num_samples is not None and N > num_samples: mask = np.random.choice(N, num_samples, replace=False) X = X[mask] y = y[mask] scores = self.model.loss(X) # y=None → 只返回 scores y_pred = np.argmax(scores, axis=1) acc = np.mean(y_pred == y) return acc

关键设计点:

  • num_samples=1000限制验证集大小:CIFAR-10 验证集 10000 张图全跑太慢,抽样足够观察趋势。
  • replace=False确保 batch 内无重复样本,避免梯度偏差。
  • self.model.loss(X)在y=None时只返回scores,这是loss()方法的契约,必须遵守。

4.2 loss 曲线诊断:三条线告诉你模型是否“真在学”

在 notebook 中绘制:

plt.subplot(2, 1, 1) plt.plot(solver.loss_history, 'o', label='training loss') plt.xlabel('iteration') plt.ylabel('loss') plt.legend() plt.subplot(2, 1, 2) plt.plot(solver.train_acc_history, '-o', label='train accuracy') plt.plot(solver.val_acc_history, '-o', label='val accuracy') plt.xlabel('epoch') plt.ylabel('accuracy') plt.legend() plt.show()

典型曲线与归因:

曲线特征可能原因验证方式
loss 不降,稳定在 ~2.302初始化权重过大/过小,或学习率=0检查W1.std()是否在1e-3量级;打印learning_rate
train_acc ↑ 但 val_acc ↦(持平)过拟合,需增reg或减hidden_dim尝试reg=1e-2,观察 val_acc 是否回升
loss 振荡剧烈(锯齿状)学习率太大,梯度步子迈过头将learning_rate从1e-3降到5e-4,看振幅是否收敛

注意:不要等跑完 10 个 epoch 才看图!我在某次调试中发现 loss 在 step 50 就开始发散,立刻停掉,改 learning_rate,省下 2 小时无效等待。


5. 避坑指南:五个让我重启三次内核的致命错误

5.1 现象:ValueError: operands could not be broadcast together with shapes (200,10) (10,10)

原因:在affine_forward中误将breshape 成(10,10),而b应为(10,)以触发 numpy 广播。b的 shape 必须严格等于w.shape[1]。
解决:检查b1 = np.zeros(hidden_dim)中hidden_dim是否为整数(非 list/tuple),打印b1.shape确认为(100,)。

5.2 现象:loss从第 1 步就为nan

原因:softmax_loss中np.log(pred)的pred包含 0(因exp溢出或ReLU输出全 0)。常见于weight_scale过大(如0.1)导致a1极大,exp(a1)溢出为inf,softmax输出nan。
解决:在softmax_loss前加数值稳定处理(作业原始代码已含):

# 在 softmax_loss 内部,scores 输入前做: scores = scores - np.max(scores, axis=1, keepdims=True) # 防溢出

5.3 现象:val_acc始终为0.10(即随机猜)

原因:y标签未正确映射。CIFAR-10 的y_train是[6, 9, 9, 4, ...],但若你在load_CIFAR10后做了y_train = y_train % 10或y_train.astype(int)错误转换,可能破坏标签。
解决:执行print(np.unique(y_train, return_counts=True)),确认输出为(array([0,1,2,...,9]), array([5000,5000,...,5000]))。

5.4 现象:RuntimeWarning: invalid value encountered in greater来自relu_forward

原因:a1中含nan或inf,导致a1 > 0返回nan。根源在上游affine_forward输入X或W含nan。
解决:在loss()开头加检查:

assert not np.any(np.isnan(X)), "X contains nan" assert not np.any(np.isinf(X)), "X contains inf"

5.5 现象:训练速度极慢(单 step > 10s)

原因:X_train未归一化,像素值为[0,255],导致W1 @ X_train.T矩阵乘法数值不稳定,numpy 自动启用高精度计算。
解决:在get_CIFAR10_data()后强制归一化:

X_train = X_train.astype(np.float64) / 255.0 X_val = X_val.astype(np.float64) / 255.0 X_test = X_test.astype(np.float64) / 255.0

实测提速 5 倍以上,且 loss 收敛更稳。


6. 二次开发实战:从两层网到带 BatchNorm 的三层网,只需改 7 处

你想把TwoLayerNet升级为ThreeLayerNet并加入 BatchNorm?不用重写全部,按顺序改这 7 处即可跑通(基于原始neural_net.py):

6.1 修改__init__():新增 BN 参数与第三层权重

# 原 TwoLayerNet.__init__ 新增以下(插入在 W2/b2 初始化后) self.params['gamma1'] = np.ones(hidden_dim) # BN scale self.params['beta1'] = np.zeros(hidden_dim) # BN shift self.params['W3'] = np.random.randn(hidden_dim, num_classes) * weight_scale self.params['b3'] = np.zeros(num_classes) # 新增 BN running stats(训练时更新,测试时冻结) self.bn_params = [{'mode': 'train'}] # 只有一层 BN,故长度为 1

6.2 修改loss():插入 BN 前向与反向

在loss()中,h1计算后、第二层affine_forward前插入 BN:

# 原有:h1, cache_h1 = relu_forward(a1) # 替换为: a1_bn, cache_bn = batchnorm_forward(a1, self.params['gamma1'], self.params['beta1'], self.bn_params[0]) h1, cache_h1 = relu_forward(a1_bn) # 反向时,在 relu_backward 后加: da1_bn = relu_backward(dh1, cache_h1) da1, dgamma1, dbeta1 = batchnorm_backward(da1_bn, cache_bn) # 然后用 da1 接续 affine_backward

6.3 修改loss()返回的grads

在grads = {...}中追加:

grads['gamma1'] = dgamma1 grads['beta1'] = dbeta1 # 第三层参数 grads['W3'] = dW3 + self.reg * W3 grads['b3'] = db3

6.4 补全batchnorm_forward/backward实现

从cs231n/layers.py复制标准实现(作业已提供),确保batchnorm_forward返回cache = (x, gamma, xmu, ivar, sqrtvar, var, eps),batchnorm_backward正确计算dx, dgamma, dbeta。

6.5 更新Solver中的check_accuracy()

BN 层在测试时需设mode='test',修改check_accuracy:

def check_accuracy(self, X, y, num_samples=None): # ... 前略 # 关键:临时切换 BN mode self.model.bn_params[0]['mode'] = 'test' scores = self.model.loss(X) # y=None self.model.bn_params[0]['mode'] = 'train' # 恢复 # ... 后略

6.6 调参策略:BN 后 learning_rate 可放大 10 倍

BN 缓解了内部协变量偏移,允许更大步长。原lr=1e-3可试lr=1e-2,配合reg=1e-3,val_acc 通常提升 2~3 个百分点。

6.7 验证技巧:用numerical_gradient_check确保 BN 梯度正确

在loss()返回前,对gamma1做数值梯度检验(作业 utils 中有eval_numerical_gradient_array):

f = lambda _: self.loss(X_batch, y_batch)[0] grad_num = eval_numerical_gradient_array(f, self.params['gamma1'], 1e-5) # 与解析梯度 dgamma1 比较,相对误差 < 1e-7 即通过

从那以后我每次扩展网络结构,都强制走一遍数值梯度检验——哪怕只是加一行dropout,也要确认dx对得上。因为反向传播的 bug 最难 debug,它不报错,只悄悄让你的 acc 停在 10%。希望帮到你。

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

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

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

立即咨询