1. 实验定位与整体设计思路拆解
1.1 这个实验到底在练什么
“机器学习本科课程 实验2 神经网络”这个标题,放在本科教学体系里看,位置其实很关键。实验1通常是线性回归、逻辑回归这类“单层”模型,学生第一次接触损失函数、梯度下降、过拟合这些概念;到了实验2,直接跳到神经网络,跨度不小。我带过几轮这门课的实验辅导,也帮不少同学排查过代码,最大的感受是:这个实验表面上是让你“搭一个网络跑通MNIST”,实际上是在训练三件事——把数学公式翻译成矩阵运算的能力、把矩阵运算翻译成可调试代码的能力、以及在没有现成框架兜底时定位问题的能力。
所以这个实验的核心价值不在于最后准确率刷到多高,而在于你能不能亲手把一个前向传播、一个反向传播写对。很多同学一上来就import torch,三行代码跑出98%的准确率,实验报告写得漂漂亮亮,但问他“反向传播里那个转置为什么在那儿”,答不上来。这种就是典型的“跑通了但没学会”。我个人的建议是:这个实验至少要用纯NumPy手写一遍完整的前向+反向,再用框架对照验证一遍,两遍下来你对神经网络的理解会完全不一样。
适合参考这篇内容的人:正在做这个实验、卡在反向传播推导或者梯度检查上的本科生;想复习基础、准备面试的求职者;以及需要带实验课、想找一套可复现讲解思路的助教。下面我会按“设计思路—核心细节—实操实现—问题排查”的顺序展开,中间穿插我自己踩过的坑和带学生时高频出现的问题。
1.2 为什么实验设计成“手写+框架对照”两条线
课程组把实验2设计成神经网络,通常有两种路线:一种是纯NumPy从零实现,另一种是直接上框架。我观察下来,多数课程会采用“先手写一个两层网络,再用框架搭一个更深的网络做对比”这种组合。这个设计背后的逻辑很实在:
- 手写部分逼你面对每一个矩阵维度。神经网络的bug有八成是维度对不上或者转置搞反,框架会自动广播、自动求导,把这些错误全掩盖了,你跑出来的loss曲线看着正常,其实梯度早就算错了。
- 框架部分让你体会工程上的便利,同时建立“我手写的和框架算的是同一件事”的信心。用框架复现手写版本的结果,是一个非常好的自检手段。
从教学角度看,这个组合还解决了一个现实问题:纯手写跑MNIST太慢,纯框架又学不到东西。两条线并行,既保证理解深度,又不至于让实验变成纯粹的体力活。我在实际带实验时会把评分权重放在手写部分的反向传播正确性和梯度检查上,框架部分主要看你能不能解释清楚每个API在干什么。
1.3 数据集与任务选择的考量
绝大多数课程会选MNIST或者Fashion-MNIST做这个实验。为什么?我总结了几条很实际的理由:数据量适中(6万训练样本),单机CPU跑几分钟就能出结果,不需要GPU;输入维度固定(28×28=784),网络结构可以设计得很简单;类别平衡,准确率指标直观,学生容易获得正反馈。Fashion-MNIST比MNIST稍难一点,准确率天花板低几个点,但更能体现模型差异,有些课程会用它来避免“随便搭个网络就99%”的虚假成就感。
这里有个容易被忽略的细节:数据预处理的方式会直接影响你后面调参的难度。MNIST像素值是0-255,直接喂进网络会导致输入尺度过大,梯度爆炸或者收敛极慢。标准做法是归一化到[0,1]或者做标准化(减均值除标准差)。我见过有同学忘了归一化,训练loss直接飙到几千,还以为是学习率的问题,调了半天。这个坑后面在问题排查部分还会细说。
2. 核心细节解析与实操要点
2.1 网络结构怎么定:从“够用”出发而不是从“最强”出发
实验2的网络结构不需要复杂。我建议手写版本就用一个隐藏层,结构是784 → 隐藏层(128或256)→ 10,激活函数用ReLU,输出层接Softmax,损失用交叉熵。这个结构简单到可以在一张纸上把前向和反向的公式全推完,非常适合作为第一次手写神经网络的练手对象。
隐藏层单元数怎么选?128和256都行,差别不大。我的经验是:隐藏层太小(比如16)欠拟合明显,太大(比如1024)训练变慢且容易过拟合,128-256是性价比最高的区间。你不用在这个参数上纠结太久,它不是这个实验的重点。真正要关注的是激活函数的选择——为什么用ReLU而不是Sigmoid?因为Sigmoid在输入较大或较小时梯度接近0,深层网络里梯度会逐层衰减,这就是经典的梯度消失问题。ReLU在正区间梯度恒为1,缓解了这个问题,而且计算简单(就是max(0,x)),求导也简单(正区间为1,负区间为0)。这个“为什么”在实验报告里是要写清楚的,不能只写“我用了ReLU”。
输出层用Softmax而不是直接输出logits,是为了把输出解释成概率分布,配合交叉熵损失。这里有个数值稳定性的细节:Softmax实现时要先减去输入的最大值,否则exp可能溢出。这个技巧几乎所有教程都会提,但自己写的时候很容易忘,我在下面实操部分会给出带这个处理的代码。
2.2 前向传播:把公式翻译成矩阵运算的关键一步
前向传播的数学表达很简洁:第一层线性变换Z1 = X @ W1 + b1,激活A1 = ReLU(Z1),第二层线性变换Z2 = A1 @ W2 + b2,最后P = Softmax(Z2)。但真正写代码时,维度是最容易出错的地方,我建议你在动手前先把每个变量的shape写在纸上:
| 变量 | 含义 | shape |
|---|---|---|
| X | 一个batch的输入 | (batch_size, 784) |
| W1 | 第一层权重 | (784, hidden_size) |
| b1 | 第一层偏置 | (hidden_size,) |
| Z1 / A1 | 第一层输出 | (batch_size, hidden_size) |
| W2 | 第二层权重 | (hidden_size, 10) |
| b2 | 第二层偏置 | (10,) |
| Z2 / P | 网络输出 | (batch_size, 10) |
把这张表写清楚,后面反向传播的转置就不会搞反。我见过太多同学把W1定义成(hidden_size, 784),然后前向写成X @ W1直接报维度错误,改来改去把整个代码搞乱。约定俗成是“输入维度在前、输出维度在后”,即W的形状是(in, out),前向就是X @ W,反向求W的梯度就是A_prev.T @ dZ。这个约定一旦定下来,全程不要变。
权重初始化也有讲究。全零初始化是绝对不行的——所有神经元会学到相同的特征,对称性无法打破。标准做法是用小随机数,比如np.random.randn(in, out) * 0.01,或者用He初始化(针对ReLU):np.random.randn(in, out) * np.sqrt(2.0 / in)。He初始化能让前向传播时各层输出的方差保持稳定,训练初期收敛更快。这个细节很多同学不知道,用0.01的小随机数也能跑,但收敛会慢一些,而且隐藏层大了之后容易出问题。
2.3 反向传播:这个实验真正的分水岭
反向传播是整个实验的核心,也是区分“真懂了”和“抄代码”的地方。我建议的推导顺序是从损失函数往回推,每一步都写清楚链式法则:
交叉熵损失对Z2的梯度有一个非常漂亮的结果:dZ2 = P - Y_onehot,其中Y_onehot是标签的one-hot编码。这个结果不是巧合,是Softmax和交叉熵组合后的化简,推导过程在实验报告里值得写一写。有了dZ2,后面就顺了:dW2 = A1.T @ dZ2,db2 = dZ2.sum(axis=0),dA1 = dZ2 @ W2.T,然后过ReLU的反向:dZ1 = dA1 * (Z1 > 0),最后dW1 = X.T @ dZ1,db1 = dZ1.sum(axis=0)。
这里有几个高频错误点,我逐个说:
db的求和维度:dZ2的shape是(batch_size, 10),b2的shape是(10,),所以要对batch维度求和,即axis=0。写成axis=1或者忘了求和,梯度检查立刻报错。- ReLU的导数:
(Z1 > 0)得到的是布尔数组,乘的时候NumPy会自动转成0/1,但有些同学写成Z1 > 0直接当索引用,逻辑就错了。稳妥写法是(Z1 > 0).astype(float)。 - 转置的位置:
dW2 = A1.T @ dZ2里的转置是因为W2的形状是(hidden, 10),而A1是(batch, hidden),dZ2是(batch, 10),要让维度对上必须转置A1。这个“为什么”想明白了,就不会死记硬背。
2.4 梯度检查:手写代码的“验钞机”
梯度检查(gradient check)是手写神经网络时最值得投入时间的一步。原理很简单:用数值方法(中心差分)近似计算梯度,和你的解析梯度对比,相对误差在1e-7量级说明实现正确。具体做法是对每个参数θ,计算(J(θ+ε) - J(θ-ε)) / (2ε),ε取1e-4左右,然后和解析梯度算相对误差:|num - ana| / max(|num|, |ana|)。
实操上有几个要点:梯度检查要在小数据上做(比如batch_size=5,隐藏层=10),否则计算量太大;检查前要关掉正则化和dropout,否则数值梯度和解析梯度对不上;用双精度浮点,单精度下误差会大到无法判断。我带的实验里,凡是梯度检查通过的,后面训练基本不会出大问题;凡是跳过这一步直接训练的,十有八九在loss不下降时抓瞎。这个环节花20分钟,能省后面两小时。
3. 实操过程与核心环节实现
3.1 环境准备与数据加载
环境上,NumPy是必须的,matplotlib用来画loss曲线和样本图,框架部分按课程要求选(PyTorch或TensorFlow都行)。数据加载我建议先自己写一个简单的读取逻辑,把MNIST的idx格式文件读成NumPy数组,这样你对“数据长什么样”有直观认识,而不是直接datasets.MNIST一行搞定。读进来之后做三件事:reshape成(batch, 784)、归一化到[0,1]、标签转one-hot(手写版本需要,框架版本用整数标签即可)。
import numpy as np def load_mnist_images(path): with open(path, 'rb') as f: magic = int.from_bytes(f.read(4), 'big') num = int.from_bytes(f.read(4), 'big') rows = int.from_bytes(f.read(4), 'big') cols = int.from_bytes(f.read(4), 'big') data = np.frombuffer(f.read(), dtype=np.uint8) return data.reshape(num, rows * cols).astype(np.float32) / 255.0 def one_hot(labels, num_classes=10): out = np.zeros((labels.shape[0], num_classes)) out[np.arange(labels.shape[0]), labels] = 1 return out归一化那行/ 255.0看着不起眼,但它是后面一切调参的前提。我建议你在实验报告里明确写出这一步,并解释“为什么归一化能加速收敛”——输入尺度统一后,各维度的梯度量级接近,学习率不用为某个大尺度维度妥协。
3.2 手写两层网络的完整实现
下面这套代码是我带实验时反复打磨过的版本,结构清晰、维度注释完整,可以直接参考。参数初始化用He初始化,前向传播带Softmax数值稳定处理,反向传播严格按2.3节的推导来。
class TwoLayerNet: def __init__(self, input_dim=784, hidden_dim=128, num_classes=10): # He初始化:方差为2/input_dim,适配ReLU self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.b1 = np.zeros(hidden_dim) self.W2 = np.random.randn(hidden_dim, num_classes) * np.sqrt(2.0 / hidden_dim) self.b2 = np.zeros(num_classes) def forward(self, X): self.X = X self.Z1 = X @ self.W1 + self.b1 # (N, H) self.A1 = np.maximum(0, self.Z1) # ReLU Z2 = self.A1 @ self.W2 + self.b2 # (N, C) # Softmax数值稳定:减去每行最大值 Z2_shift = Z2 - Z2.max(axis=1, keepdims=True) exp_scores = np.exp(Z2_shift) self.P = exp_scores / exp_scores.sum(axis=1, keepdims=True) return self.P def compute_loss(self, P, Y_onehot): N = P.shape[0] # 交叉熵,加1e-12防止log(0) return -np.sum(Y_onehot * np.log(P + 1e-12)) / N def backward(self, Y_onehot): N = self.X.shape[0] dZ2 = (self.P - Y_onehot) / N # (N, C) self.dW2 = self.A1.T @ dZ2 # (H, C) self.db2 = dZ2.sum(axis=0) # (C,) dA1 = dZ2 @ self.W2.T # (N, H) dZ1 = dA1 * (self.Z1 > 0).astype(float) # ReLU反向 self.dW1 = self.X.T @ dZ1 # (D, H) self.db1 = dZ1.sum(axis=0) # (H,)注意dZ2那里除以了N,是因为loss取了batch平均,梯度也要对应平均。这个细节如果漏了,等效于学习率放大了N倍,训练会剧烈震荡。很多同学对不上梯度检查的误差,就是栽在这个/ N上。
3.3 训练循环与超参数设置
训练循环的骨架就是“前向—算loss—反向—更新”四步。超参数我给的参考值是:batch_size=64,学习率0.1(配合普通SGD),训练10-20个epoch。学习率0.1听起来大,但因为loss做了batch平均、输入归一化了,这个值在两层网络上很稳。如果你用Adam,学习率降到1e-3。
def train(model, X_train, Y_train_onehot, X_val, Y_val, epochs=15, batch_size=64, lr=0.1): N = X_train.shape[0] history = {'train_loss': [], 'val_acc': []} for epoch in range(epochs): idx = np.random.permutation(N) X_shuf, Y_shuf = X_train[idx], Y_train_onehot[idx] epoch_loss = 0.0 for start in range(0, N, batch_size): Xb = X_shuf[start:start+batch_size] Yb = Y_shuf[start:start+batch_size] P = model.forward(Xb) loss = model.compute_loss(P, Yb) model.backward(Yb) # 普通SGD更新 model.W1 -= lr * model.dW1 model.b1 -= lr * model.db1 model.W2 -= lr * model.dW2 model.b2 -= lr * model.db2 epoch_loss += loss * Xb.shape[0] val_pred = model.forward(X_val).argmax(axis=1) val_acc = (val_pred == Y_val).mean() history['train_loss'].append(epoch_loss / N) history['val_acc'].append(val_acc) print(f"epoch {epoch+1}: loss={epoch_loss/N:.4f}, val_acc={val_acc:.4f}") return history实测下来,这个配置在MNIST上10个epoch能到97%左右的验证准确率,15个epoch到97.5%上下。如果你跑出来只有90%出头,先别急着调网络结构,按第4节的排查顺序走一遍,大概率是某个实现细节的问题。
3.4 框架版本对照与结果验证
框架版本的作用是交叉验证。用PyTorch搭同样的784-128-10结构,同样的初始化策略(手动指定权重),同样的数据划分,跑出来的loss曲线应该和手写版本高度接近。如果差异很大,说明手写版本某处有问题。这个对照实验本身就是一个很好的实验报告素材——你可以把两条loss曲线画在同一张图上,讨论微小差异的来源(比如框架的Softmax实现、初始化细节)。
框架版本还有一个价值:让你体会自动求导省掉了什么。你可以试着在框架里打印某一层的梯度,和手写版本对应层的梯度对比,数值接近就说明你的反向传播推导是对的。这种“两条路走到同一个终点”的验证方式,比单纯看准确率有说服力得多。
4. 常见问题与排查技巧实录
4.1 loss不下降或剧烈震荡的排查顺序
这是实验里最高频的问题。我总结了一个排查顺序,按这个顺序走基本能定位:
| 排查项 | 检查方法 | 典型症状 |
|---|---|---|
| 数据归一化 | 打印X的min/max/mean | 输入在0-255,loss初始值极大 |
| 标签编码 | 确认one-hot和P的类别顺序一致 | loss在ln(10)附近不动 |
| 学习率 | 从0.01试到0.5 | loss震荡或直接NaN |
| 梯度检查 | 小数据上跑数值梯度对比 | 相对误差>1e-4说明实现有误 |
| 初始化 | 检查是否全零或过大 | 所有神经元输出相同,loss不降 |
| 除以N | 确认loss和梯度都做了batch平均 | 等效学习率被放大N倍 |
我遇到最多的两个原因:一是忘了归一化,二是dZ2忘了除以N。前者表现为第一个epoch的loss就是几百,后者表现为loss剧烈震荡、准确率卡在随机水平附近。这两个问题梯度检查都能查出来,所以再次强调:先做梯度检查,再开始训练。
4.2 准确率卡在某个值上不动的几种可能
如果loss在降但验证准确率卡在比如92%不动,通常是这几类原因:隐藏层太小导致欠拟合(把128改成256试试);训练epoch不够(两层网络收敛不算快,10个epoch是下限);学习率偏大导致在最优解附近震荡(降到0.05或0.02)。如果训练准确率很高但验证准确率明显低一截,那是过拟合,但这个实验的两层网络在MNIST上过拟合不严重,出现这种情况反而要怀疑数据划分是不是有问题,比如训练集和验证集有重叠。
还有一种隐蔽的情况:准确率计算时用了训练模式下的输出。手写版本没有dropout和BN,不存在这个问题,但框架版本如果忘了model.eval(),BN层会用batch统计量,验证结果会偏低且不稳定。这个坑在实验2里可能还遇不到,但提前知道没坏处。
4.3 梯度检查对不上时的定位技巧
梯度检查报错时,不要从头到尾瞎改,按“从后往前”的顺序逐层定位:先只检查b2(它只依赖dZ2),再检查W2,再检查b1、W1。哪一层的相对误差突然变大,问题就在那一层附近。常见原因:ReLU导数写错(用了Z1 >= 0还是Z1 > 0,在0点附近有差异,但影响很小,一般不是主因);求和维度写错;转置漏了。另外,梯度检查的ε不要取太小(比如1e-8),双精度下会有舍入误差,1e-4是常用值。
提示:梯度检查时把正则化项关掉,并且用固定的随机种子生成小批量数据,保证数值梯度和解析梯度面对的是同一批输入。这两点不做,误差永远对不上。
4.4 实验报告里值得写进去的几个讨论点
最后分享几个能让实验报告出彩的讨论角度,都是我在批改时觉得“这个学生真动脑子了”的点:对比ReLU和Sigmoid在同样结构下的收敛速度差异,用loss曲线说话;讨论隐藏层单元数从32到512对准确率的影响,找出收益递减的拐点;分析误分类样本的规律(比如某些数字的书写变体容易被混淆);以及把手写版本和框架版本的训练时间做个对比,讨论工程实现的取舍。这些讨论不需要多高深,但能体现你真的观察了实验结果,而不是跑完就交。
我个人在实际操作中的体会是,这个实验最值钱的部分不是最后那个准确率数字,而是你在梯度检查报错、逐层排查、最终定位到某个转置写反的那个过程。那个瞬间建立起来的对反向传播的直觉,是看十篇教程都换不来的。所以如果你现在正卡在某个报错上,别急着找现成代码覆盖,先把维度表画出来,从loss往回推一遍,大概率自己就能找到问题。