简介本资源是斯坦福大学CS231n《计算机视觉中的卷积神经网络》课程Assignment 1的完整实践包面向深度学习初学者、高校学生及自学研究者聚焦图像分类核心任务——KNN、SVM与两层全连接网络的实现与调优。压缩包共54个文件含7个Jupyter Notebook含knn.ipynb、svm.ipynb、two_layer_net.ipynb等主实验脚本、17个Python源码覆盖数据加载、损失计算、梯度检查、优化器与可视化工具、4个XML配置及2个Shell脚本辅以CIFAR-10数据集tar包与PDF生成工具整体达561.98MB结构完整、开箱即用。目前已有2007人学习下载资源已集成本地运行所需全部依赖模块如cs231n/目录下的layer_utils.py、solver.py等并包含提交打包脚本collect_submission.ipynb和README说明显著降低环境配置门槛助力读者高效复现经典CV实验、理解前向传播与反向传播的底层实现逻辑。1. 这不是一份“交完就扔”的作业cs231n assignment1 是你亲手搭起第一个神经网络的脚手架想象一下你刚写完softmax_loss_vectorized函数loss值从inf一路收敛到1.234grad的数值梯度和解析梯度误差稳定在1e-8量级——那一刻你才真正摸到深度学习的“骨感”没有框架黑盒没有自动微分魔法只有矩阵乘法、广播机制、链式法则和一行行手推的梯度更新。cs231n assignment1 就是这样一个“反直觉”的存在它不教你怎么调torch.nn.Linear而是逼你用纯 NumPy 实现 SVM 分类器、Softmax 分类器、两层全连接网络甚至手动完成反向传播。它适合所有想撕开 PyTorch/TensorFlow 外壳、看清权重如何被更新、损失如何被分解的开发者——无论你是刚学完《线性代数》的本科生还是在业务中天天调model.fit()却说不清optimizer.step()到底干了什么的工程师。这份资源不是课程讲义的复刻而是一套可运行、可调试、可逐行断点的“神经网络解剖套件”文件不多5个.py 3个.ipynb但每行代码都在回答一个根本问题参数是怎么动起来的2. 环境与结构从零启动前必须确认的三件事2.1 依赖版本与环境隔离别让 numpy 版本毁掉整个反向传播assignment1 对环境极其敏感。我见过太多人卡在numpy 1.24的np.linalg.norm行为变更上——旧版默认axisNone计算全局 L2 范数新版却要求显式声明导致正则项计算错误loss不下降。这不是 bug是教学设计的“压力测试”。提示官方推荐numpy1.19.5matplotlib3.3.4scipy1.5.4。用 conda 创建干净环境最稳妥conda create -n cs231n-env python3.7 conda activate cs231n-env pip install numpy1.19.5 matplotlib3.3.4 scipy1.5.4不要用pip install -r requirements.txt——原项目没提供该文件强行生成会引入不兼容依赖。2.2 目录结构即学习路径5个核心文件的职责分工项目根目录下共 8 个关键文件结构极简但逻辑严密assignment1/ ├── cs231n/ # 核心库非完整框架仅含作业所需模块 │ ├── __init__.py │ ├── data_utils.py # 加载 CIFAR-10 数据解压、归一化、划分 train/val/test │ ├── features.py # 提取 HOG/SIFT 特征assignment1 中仅作可选扩展 │ └── classifiers/ # 所有分类器实现所在目录 │ ├── __init__.py │ ├── linear_svm.py # SVM 损失函数 梯度含向量化/非向量化双版本 │ ├── softmax.py # Softmax 损失函数 梯度同上 │ └── neural_net.py # 两层全连接网络forward/backward SGD 更新 ├── datasets/ # CIFAR-10 数据集需手动下载约170MB │ └── cifar-10-batches-py/ ├── knn.ipynb # KNN 分类器纯距离计算无参数学习用于 baseline ├── svm.ipynb # SVM 分类器训练与超参调优调 learning_rate, reg ├── softmax.ipynb # Softmax 分类器训练与对比与 SVM 同构但损失函数不同 └── two_layer_net.ipynb # 两层神经网络数据预处理 → 初始化 → 训练 → 可视化 loss 曲线注意cs231n/classifiers/下的三个.py文件是唯一需要你动手修改的源码.ipynb是执行入口负责加载数据、调用分类器、绘制结果。这种分离让“算法实现”和“实验流程”彻底解耦——改梯度逻辑只动.py调学习率只改.ipynb。2.3 数据加载机制为什么get_CIFAR10_data()必须跑通data_utils.py中的get_CIFAR10_data()是整个作业的“心脏起搏器”。它做了四件事检查datasets/cifar-10-batches-py/是否存在不存在则抛出明确错误不自动下载用pickle读取 5 个训练 batch 和 1 个 test batch合并训练数据并按validation_ratio0.1划分 train/val对像素值做 zero-centering减均值和 normalization除标准差—— 这步至关重要不做则 SVM/Softmax 的loss会爆炸。关键代码段data_utils.py第 42 行起# X_train: (49000, 3072), 其中 3072 32*32*3 mean_image np.mean(X_train, axis0) # 按样本维度求均值 → (3072,) X_train - mean_image X_val - mean_image X_test - mean_image # 标准化避免不同通道量纲差异R/G/B 均值本就不同 std np.std(X_train, axis0) std[std 0] 1.0 # 防止除零 X_train / std X_val / std X_test / std这段代码决定了你后续所有模型能否收敛。如果跳过或写错比如axis1W的梯度会因输入尺度失衡而发散。我建议在svm.ipynb开头加一行验证print(X_train mean:, np.mean(X_train), std:, np.std(X_train)) # 应接近 0.0 和 1.03. 核心模块拆解SVM 损失函数的向量化实现与梯度推导3.1 SVM 损失函数从循环到向量化的三步跃迁linear_svm.py中的svm_loss_naive是教学起点用双重 for 循环遍历每个样本、每个类别计算 hinge loss。但它慢CIFAR-10 全量训练要 20 分钟且无法体现矩阵运算本质。真正的重点是svm_loss_vectorized—— 它用三行 NumPy 完成全部计算def svm_loss_vectorized(W, X, y, reg): num_train X.shape[0] scores X.dot(W) # (N, C) correct_class_scores scores[np.arange(num_train), y] # (N,) margins np.maximum(0, scores - correct_class_scores[:, None] 1) # (N, C) margins[np.arange(num_train), y] 0 # 把正确类别的 margin 置 0 loss np.sum(margins) / num_train reg * np.sum(W * W) # 正则项L2 return loss, None # 梯度暂略逐行说明scores X.dot(W)一次矩阵乘得到所有样本在所有类上的原始分logits替代 N×C 次点积correct_class_scores[:, None]将(N,)向量升维为(N, 1)触发广播机制使scores - ...自动对每行减去对应正确类分数margins[np.arange(num_train), y] 0用高级索引精准清零正确类位置避免循环。注意1是 hinge loss 的 margin 常数不可省略reg * np.sum(W * W)是 L2 正则系数reg在 notebook 中通过交叉验证选取通常1e-3 ~ 1e-1。3.2 解析梯度为什么dW要分两部分更新损失函数对W的梯度dW是 assignment1 最易出错的部分。svm_loss_vectorized返回的dW并非直接X.T.dot(margins_mask)而是分两步# 构建二值掩码margin 0 的位置为 1其余为 0 binary margins binary[margins 0] 1 binary[np.arange(num_train), y] -np.sum(binary, axis1) # 关键正确类梯度 -sum(其他类贡献) dW X.T.dot(binary) / num_train 2 * reg * W逻辑解释binary矩阵标记了哪些类别触发了 hinge loss即margin 0binary[np.arange(num_train), y] -np.sum(...)是数学本质hinge loss 对正确类jy[i]的偏导是-∑_{k≠j} I(s_k s_j Δ)即所有错误类梯度之和的负值X.T.dot(binary)完成“样本特征 × 类别贡献”的批量累积 2 * reg * W是 L2 正则项的解析梯度d/dW (λ||W||²) 2λW。这个dW推导是理解反向传播的基石。如果你在two_layer_net.ipynb中看到dW2 ...的链式法则其源头就在这里。3.3 梯度验证数值梯度不是“玄学”是必过的安全阀gradient_check.py提供了eval_numerical_gradient_array函数它用中心差分法验证解析梯度f lambda w: svm_loss_vectorized(w, X_dev, y_dev, reg)[0] grad_numerical eval_numerical_gradient_array(f, W, h1e-5) grad_analytic, _ svm_loss_vectorized(W, X_dev, y_dev, reg) rel_error np.max(np.abs(grad_numerical - grad_analytic) / (np.maximum(1e-8, np.abs(grad_numerical) np.abs(grad_analytic))))现象rel_error 1e-7原因常见于margins计算时未置零正确类或binary中正确类赋值错误如用了而非解决在svm_loss_vectorized中插入断点打印margins[0]和binary[0]比对理论值例如若y[0]3则binary[0,3]必须等于-sum(binary[0,:])4. 避坑指南五个让我重装三次环境的血泪经验4.1 现象svm.ipynb中train_accuracy一直为0.0val_accuracy波动剧烈原因data_utils.py中get_CIFAR10_data()的validation_ratio默认为0.1但X_train和y_train划分后未打乱顺序导致验证集集中于某几个类别CIFAR-10 的 batch 是按类别顺序存储的。解决在get_CIFAR10_data()划分后添加随机打乱# 在划分 train/val 后插入 mask np.random.choice(num_training, num_training, replaceFalse) X_train X_train[mask] y_train y_train[mask]4.2 现象two_layer_net.ipynb训练时loss初期下降随后震荡发散nan原因neural_net.py中W1,W2,b1,b2的初始化方式不当。原版用np.random.randn(D,H) * weight_scale若weight_scale过大如1e-1第一层输出z1 X.dot(W1) b1方差爆炸ReLU 后大量神经元死亡梯度消失。解决改用 He 初始化专为 ReLU 设计self.params[W1] np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.params[W2] np.random.randn(hidden_dim, num_classes) * np.sqrt(2.0 / hidden_dim)4.3 现象softmax.ipynb中loss收敛但val_accuracy卡在30%不提升原因Softmax 的numerical_stability处理缺失。scores X.dot(W)后若直接exp(scores)极大值会导致exp(1000)infsoftmax输出全为0/0。解决在softmax_loss_vectorized中加入稳定性修正scores - np.max(scores, axis1, keepdimsTrue) # 每行减去最大值 exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue)4.4 现象knn.ipynb运行compute_distances_no_loops时内存溢出OOM原因np.broadcast_arrays(X_train[:, None, :], X_test[None, :, :])会创建(Ntr, Nte, D)三维张量CIFAR-10 全量50000×10000×3072需1.5TB内存。解决改用平方展开公式||x-y||² ||x||² ||y||² - 2x·y用np.einsum避免中间大数组dist_sq np.sum(X_train**2, axis1, keepdimsTrue) \ np.sum(X_test**2, axis1) - \ 2 * np.einsum(id,jd-ij, X_train, X_test)4.5 现象two_layer_net.ipynb中plt.plot(loss_history)曲线锯齿严重不平滑原因NeuralNet.train()中batch_size过小如100导致单步梯度噪声大或learning_rate过高如1e-1引发震荡。解决将batch_size设为200~500learning_rate从1e-3开始搜索常用范围1e-4 ~ 5e-3并在train()中添加 loss 平滑# 在 train() 循环内 if verbose and it % 100 0: smooth_loss np.mean(loss_history[max(0, it-100):it1]) print(fiteration {it} / {num_iters}: loss {smooth_loss:.6f})5. 两层神经网络实战从初始化到可视化 loss 曲线的完整链路5.1NeuralNet类结构为什么__init__和loss是唯二必须看懂的方法neural_net.py中的TwoLayerNet类是 assignment1 的皇冠。它不继承任何基类所有逻辑内聚于两个方法__init__(self, input_dim3072, hidden_dim100, num_classes10, weight_scale1e-3, reg0.0)负责初始化W1,b1,W2,b2和正则系数reg。注意weight_scale不是学习率而是权重初值的缩放因子——它直接影响前向传播的数值稳定性。loss(self, X, yNone)这是整个网络的“心脏”当y is not None时执行 forward backward返回loss和gradients当y is None时只执行 forward返回scores用于预测。所有反向传播逻辑都藏在这里。关键 forward 流程精简版def loss(self, X, yNone): # Forward pass z1 X.dot(self.params[W1]) self.params[b1] # (N, H) a1 np.maximum(0, z1) # ReLU scores a1.dot(self.params[W2]) self.params[b2] # (N, C) if y is None: return scores # Compute loss scores - np.max(scores, axis1, keepdimsTrue) # stability exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) correct_logprobs -np.log(probs[range(X.shape[0]), y]) data_loss np.sum(correct_logprobs) / X.shape[0] reg_loss 0.5 * self.reg * (np.sum(self.params[W1]**2) np.sum(self.params[W2]**2)) loss data_loss reg_loss # Backward pass dscores probs.copy() dscores[range(X.shape[0]), y] - 1 dscores / X.shape[0] grads[W2] a1.T.dot(dscores) self.reg * self.params[W2] grads[b2] np.sum(dscores, axis0) da1 dscores.dot(self.params[W2].T) dz1 da1 * (z1 0) # ReLU derivative grads[W1] X.T.dot(dz1) self.reg * self.params[W1] grads[b1] np.sum(dz1, axis0) return loss, grads这段代码是 assignment1 的“珠峰”它把 SVM/Softmax 的梯度逻辑封装进多层结构dscores是上游梯度dz1是下游梯度da1是激活函数梯度——链式法则在此具象化。5.2 训练循环train()方法中的隐藏技巧train()方法表面简单实则暗藏工程细节def train(self, X, y, X_val, y_val, learning_rate1e-3, learning_rate_decay0.95, reg1e-5, num_iters100, batch_size200, verboseFalse): num_train X.shape[0] iterations_per_epoch max(num_train // batch_size, 1) for it in range(num_iters): # 采样 batch带替换提高效率 batch_idx np.random.choice(num_train, batch_size, replaceTrue) X_batch X[batch_idx] y_batch y[batch_idx] # 计算 loss 和梯度 loss, grads self.loss(X_batch, y_batch) self.loss_history.append(loss) # 参数更新SGD with momentum 可选原版为 vanilla SGD for param_name in self.params: self.params[param_name] - learning_rate * grads[param_name] # 学习率衰减每 epoch 降一次 if verbose and it % iterations_per_epoch 0: learning_rate * learning_rate_decay注意三点batch_idx np.random.choice(..., replaceTrue)CIFAR-10 训练集 49000 样本batch_size200时replaceTrue可避免choice报错且对收敛影响极小learning_rate_decay0.95是指数衰减不是 step decay每iterations_per_epoch步衰减一次防止后期震荡vanilla SGD无动量所以grads[param_name]必须绝对准确——这再次强调梯度验证的必要性。5.3 可视化与诊断一张图看懂你的网络是否“活”了two_layer_net.ipynb中的绘图代码是诊断利器plt.subplot(2, 1, 1) plt.plot(net.loss_history) # 原始 loss 曲线 plt.title(Loss history) plt.xlabel(Iteration) plt.ylabel(Loss) plt.subplot(2, 1, 2) plt.plot(train_acc_history, labeltrain) plt.plot(val_acc_history, labelval) plt.title(Classification accuracy history) plt.xlabel(Epoch) plt.ylabel(Clasification accuracy) plt.legend() plt.show()健康曲线的特征Loss 曲线初期快速下降1000 iter 内从2.3降到1.0随后缓慢收敛无剧烈震荡震荡说明lr过高Accuracy 曲线训练集准确率稳步上升至50%验证集准确率同步上升但略低gap 5%若验证集停滞而训练集继续涨说明过拟合此时应加大reg。我习惯在train()中额外记录grad_magnitude# 在参数更新前插入 grad_norm np.sum([np.linalg.norm(grads[k]) for k in grads]) self.grad_history.append(grad_norm)然后画plt.plot(net.grad_history)—— 健康训练中梯度模长应随 loss 下降而单调减小。若它先降后升说明学习率该调了。6. 二次开发从“跑通作业”到“改造为自己的模型”的三步跃迁6.1 第一步替换激活函数——不只是换ReLUassignment1 的TwoLayerNet固定使用ReLU但实际项目中常需对比LeakyReLU、ELU或Swish。改造只需两处Forward 中替换激活函数# 原版 ReLU a1 np.maximum(0, z1) # 改为 LeakyReLU (alpha0.01) a1 np.where(z1 0, z1, 0.01 * z1)Backward 中替换梯度# 原版 ReLU 导数 dz1 da1 * (z1 0) # 改为 LeakyReLU 导数 dz1 da1 * np.where(z1 0, 1, 0.01)注意LeakyReLU的alpha是超参应像learning_rate一样加入__init__并传入loss()。我一般把它设为self.alpha kwargs.get(alpha, 0.01)保持接口兼容。6.2 第二步增加 Batch Normalization 层——理解running_mean的意义BN 层的核心是forward中的x_norm (x - running_mean) / np.sqrt(running_var eps)但 assignment1 原版无 BN。手动添加需在__init__中初始化self.params[gamma1] np.ones(hidden_dim) self.params[beta1] np.zeros(hidden_dim) self.bn_params [{mode: train, eps: 1e-5, momentum: 0.9, running_mean: np.zeros(hidden_dim), running_var: np.ones(hidden_dim)}]loss()中插入 BN forward# 在 z1 后、a1 前 z1_bn, cache_bn batchnorm_forward(z1, self.params[gamma1], self.params[beta1], self.bn_params[0]) a1 np.maximum(0, z1_bn)这里的关键是running_mean/var训练时用 batch 统计更新momentum * running (1-momentum) * batch_stat测试时冻结。很多新手误以为 BN 只是归一化其实它的统计量持久化才是部署关键——running_mean必须在train()中随 epoch 更新否则测试时效果崩塌。6.3 第三步导出为 ONNX 模型——让 NumPy 模型走出 Jupyterassignment1 的终极价值是让你理解模型导出的本质。TwoLayerNet可转为 ONNX供生产环境调用import onnx from onnx import helper, TensorProto import numpy as np # 构建 ONNX 图简化版 inputs [helper.make_tensor_value_info(input, TensorProto.FLOAT, [None, 3072])] outputs [helper.make_tensor_value_info(output, TensorProto.FLOAT, [None, 10])] # 权重作为 initializer initializers [ helper.make_tensor(W1, TensorProto.FLOAT, [3072, 100], net.params[W1].flatten()), helper.make_tensor(b1, TensorProto.FLOAT, [100], net.params[b1]), helper.make_tensor(W2, TensorProto.FLOAT, [100, 10], net.params[W2].flatten()), helper.make_tensor(b2, TensorProto.FLOAT, [10], net.params[b2]), ] # 节点MatMul - Add - Relu - MatMul - Add nodes [ helper.make_node(MatMul, [input, W1], [z1]), helper.make_node(Add, [z1, b1], [a1_pre]), helper.make_node(Relu, [a1_pre], [a1]), helper.make_node(MatMul, [a1, W2], [z2]), helper.make_node(Add, [z2, b2], [output]), ] graph helper.make_graph(nodes, twolayer, inputs, outputs, initializers) model helper.make_model(graph, producer_namecs231n-assignment1) onnx.save(model, twolayer.onnx)这段代码揭示了框架的真相ONNX 不是魔法它只是把W1,b1,W2,b2打包再用MatMul/Add/Relu描述计算图。当你在 PyTorch 中写torch.onnx.export()背后就是这套逻辑。从那以后我每次做模型交付都强制走一遍NumPy → ONNX → runtime inference验证链——不依赖框架只信数据流。因为 assignment1 教给我的最硬核一课是所有深度学习框架都是对X.dot(W) b的语法糖封装而你亲手写的每一行 NumPy都是穿透糖衣的刀锋。希望帮到你。本文还有配套的精品资源点击获取