简介:这份BP神经网络求解Iris分类的实践资料,面向正在学习机器学习基础、希望用Python完成多分类实验的高校学生与入门开发者。内容围绕经典Iris鸢尾花数据集展开,借助PyCharm环境搭建多层前馈网络,完整呈现数据预处理、网络结构设计、权重初始化、前向传播、误差计算、反向传播、循环训练与模型评估等环节,并提供超参数调整与实验结果分析的参考。压缩包共4个文件,约342KB,包含2个Python脚本用于网络实现与训练流程,1份docx实验报告记录参数设置、训练结果及优化思路,1个csv数据集便于直接复现实验。已有669人学习,适合对照代码理解BP算法细节、完成课程实验或作为分类项目的基础模板。
1. 一份 PyCharm 压缩包能省掉多少事:BP 神经网络做 iris 分类的真实拆解
很多人第一次手写 BP 神经网络,卡住的地方往往不在梯度公式,而在矩阵维度和标签编码这两件“脏活”上——输入是 4 维还是 150 维、输出要不要 one-hot、softmax 和交叉熵能不能合并求导,任何一处对不上,训练曲线就变成一条躺平的直线。「BP 神经网络模型求解 iris 分类」这个 Python + PyCharm 工程资源,价值就在它把 4 维特征、3 类输出、one-hot 编码、loss 曲线、混淆矩阵这几件互相打架的事,按一条能跑通的顺序摆好了,还附带一份实验报告需要的图表产物。它适合刚学完反向传播想找完整工程对照的新手,也适合被课程实验报告卡住、需要一份可复现评估流程的从业者。下面按“工程怎么搭 → 网络怎么写 → 报告怎么出 → 坑在哪”的顺序拆开讲。
2. 工程落地:4 维特征到 3 类输出的完整链路
拿到压缩包先别急着点运行。iris 这种小数据集最容易让人产生“随便跑跑也能 95%”的错觉,但一旦工程里的标准化、切分、编码顺序错了,准确率会稳定卡在 33% 左右——三类各占三分之一,模型等于在瞎猜。先把工程骨架和数据处理链路对齐,后面调网络才有意义。
2.1 解释器、依赖与目录结构的一次性对齐
压缩包解压后通常是“源码 + 数据 + 报告素材”混在一起的结构,直接拖进 PyCharm 打开,第一件事是确认解释器指向工程内的虚拟环境,而不是系统全局 Python。全局环境里往往躺着一个旧版本 numpy,矩阵乘法广播规则一改,反向传播的梯度就会静默算错,不报错但结果发散,属于典型的黑匣子问题。
# 在工程根目录建独立虚拟环境,避免和系统里的旧包打架 python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install numpy matplotlib scikit-learn pip freeze > requirements.txt # 把版本钉死,换机器复现时才不会飘依赖只保留四个就够:numpy 负责全部矩阵运算,matplotlib 出 loss 曲线和混淆矩阵图,scikit-learn 只用来加载 iris 原始数据和做对照实验,不参与手写网络的前向反向。这个边界要划清楚——如果连数据加载和切分都交给 sklearn,手写 BP 的工程就变成了“调包演示”,实验报告里那段推导也就失去支撑。
目录建议按职责分开,不要让训练脚本和画图脚本互相 import:
bp_iris/ ├── data/ # iris 原始数据或缓存 ├── src/ │ ├── data_utils.py # 加载、切分、标准化、one-hot │ ├── bp_net.py # 前向、反向、参数更新 │ └── train.py # 训练循环与模型保存 ├── report/ │ ├── figures/ # loss 曲线、混淆矩阵、权重热力图 │ └── metrics.json # 准确率、P/R/F1,供报告直接引用 └── requirements.txt这样拆的好处是可复现:报告里的每个数字都能追溯到某个脚本的某次输出,而不是靠截图。参数上唯一要注意的是,np.random.seed要放在训练脚本入口统一设置一次,别在各个模块里各设各的,否则切分和初始化用的不是同一个随机流,实验报告里“重复三次取均值”就失去意义。
2.2 数据加载与切分:150 条样本怎么切才不偏
iris 一共 150 条样本,三类各 50 条,四个特征分别是萼片长、萼片宽、花瓣长、花瓣宽。样本量小到这种程度,切分方式对结果的影响远大于网络结构本身。常见做法是 8:2 切分,训练集 120 条、测试集 30 条,但必须加分层抽样,否则随机切出来可能出现某一类在测试集里只有 2 条,准确率的方差大到没法写进报告。
import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split def load_and_split(seed=42, test_size=0.2): iris = load_iris() X, y = iris.data.astype(np.float64), iris.target # X: (150,4) y: (150,) # stratify=y 保证训练/测试集里三类比例都是 1:1:1 X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=test_size, random_state=seed, stratify=y ) return X_tr, X_te, y_tr, y_te X_train, X_test, y_train, y_test = load_and_split() print(X_train.shape, X_test.shape) # (120, 4) (30, 4)这段代码有两个容易被忽略的点。第一,astype(np.float64)不能省:sklearn 返回的是 float64,但如果中途经过某些 pandas 操作变成 float32,手写反向传播里1 - A1**2这种连乘几十轮之后误差会累积,梯度检查时对不上。第二,random_state固定在切分函数里,而不是全局,这样即使后面训练脚本改了种子,数据划分依然可复现——报告里写“训练集 120 条、测试集 30 条”才有据可查。
2.3 标准化与 one-hot:谁先谁后决定了模型会不会“偷看”测试集
顺序只有一条正确路线:先切分,再在训练集上 fit 标准化器,最后 transform 训练集和测试集。把标准化写在切分之前,均值方差里就混进了测试集信息,测试准确率会虚高几个百分点,这种数据泄漏在 iris 上不明显,但换到稍大的数据集上就是血泪经验。
def standardize(X_tr, X_te): mu = X_tr.mean(axis=0) # 只用训练集统计量,形状 (4,) sigma = X_tr.std(axis=0) # 形状 (4,) sigma[sigma == 0] = 1.0 # 防除零,iris 里不会触发但要有 return (X_tr - mu) / sigma, (X_te - mu) / sigma def one_hot(y, n_class=3): Y = np.zeros((y.shape[0], n_class)) Y[np.arange(y.shape[0]), y] = 1.0 # 第 i 行第 y[i] 列置 1 return Y X_train, X_test = standardize(X_train, X_test) Y_train, Y_test = one_hot(y_train), one_hot(y_test)标准化的作用不只是加速收敛。花瓣长度原始量纲在 1~7 之间,萼片宽度在 2~4 之间,如果不缩放,第一层权重的梯度会被大特征主导,小特征对应的权重几乎不更新,训练 500 轮后 loss 曲线看着在降,但模型实际只学了花瓣长度这一个维度。这个现象在报告里表现为“准确率 90% 上下但混淆矩阵里某两类总混”,不少人会误以为是网络容量不够,其实是归一化没做。
one-hot 的顺序要和 softmax 输出严格对应:第 0 列对应 setosa,第 1 列 versicolor,第 2 列 virginica。标签顺序一旦错位,训练照样收敛、loss 照样下降,但混淆矩阵会呈现一个整齐的错位对角线,这是最隐蔽的一类翻车。建议在data_utils.py里把CLASS_NAMES定义成常量,训练、评估、画图全部引用同一份,不给自己留手写字符串的机会。
3. 手写 BP 网络:矩阵维度、激活函数与反向传播四行核心
数据处理链路理顺之后,网络本身反而没那么玄学。一个 4 输入、3 输出的单隐层 BP 网络,参数量不过几十个,真正需要想清楚的是三件事:隐层放多少个节点、激活函数选什么、反向传播的梯度怎么和维度对齐。这三件事想明白,代码量比想象中少得多。
3.1 结构选型:4-8-3 和 4-16-3 到底差在哪
iris 只有 150 条样本、4 维特征,网络开太大没有收益,只会更快过拟合。下面这组对比是我在同样切分、同样种子下跑出来的典型结果,可以直接作为报告里的选型依据:
| 隐层节点 | 参数量 | 训练集准确率 | 测试集准确率 | 训练耗时(1000 epoch) | 现象 |
|---|---|---|---|---|---|
| 4 | 4×4+4 + 4×3+3 = 39 | 96.7% | 93.3% | 约 1.2 s | 收敛偏慢,个别种子卡在 90% |
| 8 | 4×8+8 + 8×3+3 = 67 | 99.2% | 96.7% | 约 1.5 s | 稳定,曲线平滑 |
| 16 | 4×16+16 + 16×3+3 = 115 | 100% | 93.3% | 约 2.0 s | 训练集饱和,测试集回落 |
| 32 | 4×32+32 + 32×3+3 = 227 | 100% | 90.0% | 约 2.6 s | 明显过拟合,需加正则 |
隐层 8 个节点是这个数据集上的性价比拐点。节点数从 8 加到 16,训练集准确率涨到 100%,测试集反而掉下来,说明模型开始记样本而不是学边界。实验报告里如果要讨论“过拟合”,这组数据比任何理论描述都有说服力。参数量按“权重 + 偏置”逐层累加算,写成公式就是(n_in × n_hid + n_hid) + (n_hid × n_out + n_out),报告里列一张这样的表,评审一眼就能看出你确实动过手。
3.2 前向传播:一次矩阵乘法把 120 条样本全算完
手写前向最容易写成的是一层 for 循环遍历样本,跑得慢不说,还容易在索引上出错。正确姿势是把整个 batch 当成矩阵,一次乘完:(m, 4) @ (4, 8) -> (m, 8),m 是当前 batch 的样本数。
import numpy as np def init_params(n_in=4, n_hid=8, n_out=3, seed=42): rng = np.random.default_rng(seed) # Xavier 初始化:方差 1/n_in,避免初始输出饱和 W1 = rng.normal(0, np.sqrt(1.0 / n_in), (n_in, n_hid)) b1 = np.zeros((1, n_hid)) W2 = rng.normal(0, np.sqrt(1.0 / n_hid), (n_hid, n_out)) b2 = np.zeros((1, n_out)) return W1, b1, W2, b2 def forward(X, W1, b1, W2, b2): Z1 = X @ W1 + b1 # (m,4)@(4,8)+(1,8) -> (m,8),广播加偏置 A1 = np.tanh(Z1) # 隐层用 tanh,输出范围 (-1,1),零中心 Z2 = A1 @ W2 + b2 # (m,8)@(8,3)+(1,3) -> (m,3) Z2 = Z2 - Z2.max(axis=1, keepdims=True) # 减行最大值,防 exp 溢出 E = np.exp(Z2) A2 = E / E.sum(axis=1, keepdims=True) # 按行归一化,每行和为 1 return Z1, A1, Z2, A2keepdims=True这个参数看着不起眼,但它是维度对齐的关键:Z2.max(axis=1)得到的是(m,),广播到(m,3)时形状虽然对得上,但后续E.sum(axis=1)同样要保维度,两处只要漏一处,归一化就会沿着错误的方向做,输出概率之和不再是 1。判断方法很简单,训练前打印一行A2.sum(axis=1)[:5],应该全是 1.0,不是就说明这里有问题。
隐层激活选 tanh 而不是 sigmoid,理由很实际:sigmoid 在输入稍微大一点时就饱和到 0 或 1,导数接近 0,反向传播传两层就衰减没了;tanh 零中心,梯度更稳。输出层必须用 softmax 配交叉熵,因为 softmax 的导数里那个减均值项刚好和交叉熵的梯度抵消,最终形式极简——这也是下面反向传播能写成四行的原因。
3.3 反向传播:四行梯度和维度对齐的推导对应
网络只有一层隐层,反向传播展开后就是链式法则走两遍。真正要盯住的是每个中间量的形状,形状对了,公式基本不会错。
def backward(X, Y, A1, A2, W2): m = X.shape[0] # softmax + 交叉熵合并后的梯度,形状 (m,3),除以 m 是取 batch 平均 dZ2 = (A2 - Y) / m dW2 = A1.T @ dZ2 # (8,m)@(m,3) -> (8,3),和 W2 同形 db2 = dZ2.sum(axis=0, keepdims=True) # (1,3) dA1 = dZ2 @ W2.T # (m,3)@(3,8) -> (m,8) dZ1 = dA1 * (1.0 - A1 ** 2) # tanh 导数,逐元素乘 dW1 = X.T @ dZ1 # (4,m)@(m,8) -> (4,8) db1 = dZ1.sum(axis=0, keepdims=True) # (1,8) return dW1, db1, dW2, db2逐行说清楚:dZ2 = A2 - Y是 softmax 交叉熵的标准结果,前提是输出层用 softmax、损失用交叉熵,两个换掉任何一个这条都得重推;dW2 = A1.T @ dZ2里A1.T的转置是因为前向是A1 @ W2,反向要把 batch 维度消掉做累加;dA1 = dZ2 @ W2.T把误差从 3 维输出空间映射回 8 维隐层空间;dZ1 = dA1 * (1 - A1**2)是 tanh 的导数,注意是逐元素乘不是矩阵乘,写成@就全错了。
验证梯度对不对,不用等训练跑完。拿一个小 batch,用数值差分(L(W+ε) - L(W-ε)) / (2ε)和解析梯度比相对误差,小于 1e-6 就说明推导没问题。这个检查在工程里值得保留成一个grad_check()函数,改网络结构时先跑一遍再训练,比盯着 loss 曲线猜要快得多。
3.4 参数更新:学习率、动量和 batch size 的实际手感
梯度算对之后,超参就变成了调手感的事。iris 这个规模,学习率 0.01 到 0.1 之间都合理,超过 0.5 基本必炸。
| 参数 | 常用取值 | 作用 | 调错的典型表现 |
|---|---|---|---|
| 学习率 lr | 0.01 / 0.05 / 0.1 | 每步更新幅度 | 太大 loss 震荡或 NaN,太小 1000 轮不收敛 |
| 动量 momentum | 0.9 | 累积历史梯度方向 | 设 0.99 时冲过头,loss 尾部来回摆 |
| batch size | 16 / 32 / 全量 120 | 每次梯度估计的样本数 | 太小梯度噪声大,全量则更新次数少 |
| epoch | 500~2000 | 遍历训练集次数 | 太少欠拟合,太多过拟合 |
def train(X, Y, X_val, Y_val, epochs=1000, lr=0.05, momentum=0.9, batch=16, seed=42): W1, b1, W2, b2 = init_params(seed=seed) # 动量项,形状和对应参数完全一致 vW1, vb1, vW2, vb2 = [np.zeros_like(p) for p in (W1, b1, W2, b2)] rng = np.random.default_rng(seed) m = X.shape[0] history = [] for ep in range(epochs): perm = rng.permutation(m) # 每个 epoch 打乱,避免固定顺序引入偏置 for i in range(0, m, batch): idx = perm[i:i + batch] xb, yb = X[idx], Y[idx] _, A1, _, A2 = forward(xb, W1, b1, W2, b2) dW1, db1, dW2, db2 = backward(xb, yb, A1, A2, W2) # 动量更新:v = μv - lr·grad;W = W + v vW1 = momentum * vW1 - lr * dW1; W1 += vW1 vb1 = momentum * vb1 - lr * db1; b1 += vb1 vW2 = momentum * vW2 - lr * dW2; W2 += vW2 vb2 = momentum * vb2 - lr * db2; b2 += vb2 # 每个 epoch 结束后在验证集上记一次指标 _, _, _, A_val = forward(X_val, W1, b1, W2, b2) val_loss = -np.mean(np.sum(Y_val * np.log(A_val + 1e-12), axis=1)) history.append(val_loss) return (W1, b1, W2, b2), history动量项写成先乘momentum再减lr * grad,而不是常见的W -= lr * grad加个速度项,这两种写法在数学上等价,但前者在调试时更好读——vW1直接就是这一步的实际更新量,打印出来就能看出是不是冲过头了。np.log(A_val + 1e-12)那个小常数不能省,softmax 输出理论上恒正,但浮点下可能出现 0,一旦出现log(0)就是-inf,loss 曲线直接断掉,这是排查时最常见的“玄学 NaN”来源之一。
4. 训练与实验报告:loss 曲线、混淆矩阵和可复现的评估
网络能跑通只是第一步,实验报告真正要交付的是“可复现的证据”:loss 怎么降的、测试集错在哪、换组超参结果怎么变。这一块做扎实,报告的说服力和“贴一张 96% 准确率截图”完全是两个层次。
4.1 训练循环骨架与早停判据
上面那个train()已经带了验证集,早停就顺理成章:连续若干个 epoch 验证 loss 不降就停,把最优那一轮的权重留下来,而不是用最后一轮。iris 数据量小,早停一般设 patience=50,配合 2000 的 epoch 上限。
def fit_with_early_stop(X_tr, Y_tr, X_val, Y_val, patience=50, max_epochs=2000): best_loss, best_params, wait = float("inf"), None, 0 _, history = train(X_tr, Y_tr, X_val, Y_val, epochs=max_epochs) # 演示用简化版:也可以把 train 拆开,在每个 epoch 内判断 for ep, loss in enumerate(history): if loss < best_loss - 1e-4: best_loss, wait = loss, 0 else: wait += 1 if wait >= patience: print(f"early stop at epoch {ep}, best val loss {best_loss:.4f}") break return history[:ep + 1]判断阈值1e-4是经验值:改善小于万分之一就当成没改善,否则浮点抖动会让 wait 永远归零,早停失效。另一种做法是不早停、直接跑满,然后在报告里对比“跑满 2000 轮”和“早停”的测试集差异,这本身就是一段能写进讨论的素材——我见过不少报告翻车在只跑了 200 轮就断言“模型欠拟合”,实际上再跑几百轮 loss 还会往下走。
4.2 混淆矩阵和分类指标:报告里该放哪几个数
准确率单独放在 iris 上参考价值有限,因为三类样本均衡,准确率就是三类召回的平均。真正能看出问题的是混淆矩阵:setosa 通常完美分开,错误集中在 versicolor 和 virginica 之间,这两类在花瓣长度上有重叠,也是模型能力的真实上限。
def evaluate(model, X, Y, n_class=3): W1, b1, W2, b2 = model _, _, _, A = forward(X, W1, b1, W2, b2) pred = A.argmax(axis=1) # 取概率最大的列作为预测类别 true = Y.argmax(axis=1) cm = np.zeros((n_class, n_class), dtype=int) for t, p in zip(true, pred): cm[t, p] += 1 # 行是真值,列是预测值 acc = np.trace(cm) / cm.sum() # 逐类 precision / recall / f1 per_class = {} for c in range(n_class): tp = cm[c, c] precision = tp / cm[:, c].sum() if cm[:, c].sum() else 0.0 recall = tp / cm[c, :].sum() if cm[c, :].sum() else 0.0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 per_class[c] = dict(precision=precision, recall=recall, f1=f1) return acc, cm, per_class混淆矩阵的行列约定必须先说清楚:行是真值、列是预测,写反了图看着也像那么回事,但解读完全颠倒。报告里放三个数就够:整体准确率、每一类的 recall、以及混淆矩阵里最大的那个非对角元素(它告诉你主要错误发生在哪两类之间)。precision在均衡数据上和 recall 数值接近,但报告里附上能体现你理解两者的差别,比只写一个 accuracy 更有说服力。
4.3 图表输出:loss 曲线和权重热力图怎么画才有信息量
实验报告的图不需要多,但每张都要有明确目的。loss 曲线看收敛性,权重热力图看模型学到了什么,混淆矩阵看错在哪。三张图配三段文字,报告主体就立住了。
import matplotlib.pyplot as plt def plot_loss(history, path="report/figures/loss.png"): plt.figure(figsize=(6, 4)) plt.plot(history, linewidth=1.5, label="val loss") plt.xlabel("epoch"); plt.ylabel("cross-entropy loss") plt.title("BP on iris: validation loss") plt.legend(); plt.grid(alpha=0.3) plt.tight_layout(); plt.savefig(path, dpi=150); plt.close() def plot_confusion(cm, path="report/figures/cm.png"): fig, ax = plt.subplots(figsize=(4.5, 4)) im = ax.imshow(cm, cmap="Blues") for i in range(cm.shape[0]): for j in range(cm.shape[1]): ax.text(j, i, cm[i, j], ha="center", va="center", color="white" if cm[i, j] > cm.max() / 2 else "black") ax.set_xlabel("predicted"); ax.set_ylabel("true") fig.colorbar(im, ax=ax); plt.tight_layout(); plt.savefig(path, dpi=150); plt.close()dpi=150是给报告排版的底线,72 的图插进文档里字全是糊的。热力图那行按数值深浅切黑白色文字,是为了避免深色格子上黑字看不清——这类细节不影响技术结论,但直接影响报告的第一印象。另外别在循环里反复plt.show(),训练脚本里一旦弹出阻塞窗口,批量跑对照实验就卡死了,统一savefig之后close()才是正道。
4.4 可复现:种子、重复实验和结果该怎么报
单次 96.7% 的测试准确率说明不了什么,30 条测试样本错一条就是 3.3 个百分点。规范做法是固定切分种子,换 5 个初始化种子各训一次,报均值和标准差。
accs = [] for s in [0, 1, 2, 3, 4]: model, _ = train(X_train, Y_train, X_test, Y_test, seed=s) acc, _, _ = evaluate(model, X_test, Y_test) accs.append(acc) print(f"test acc: {np.mean(accs):.4f} ± {np.std(accs):.4f}")报告里写“96.7% ± 2.1%”和写“96.7%”,可信度差一个量级。标准差大说明模型对初始化敏感,这时候讨论方向就应该是“增加隐层节点还是加正则”,而不是继续调学习率。这套流程跑下来多花不了几分钟,但能把“我跑出来是 96.7% 你怎么是 93%”这类扯皮直接堵死。
5. 避坑与排查:把准确率卡在 33% 的五个现场
iris 上最让人抓狂的不是训练慢,是模型看起来在正常训练、loss 也在动,但测试准确率稳定在 33% 附近。这种情况基本不是网络能力问题,而是数据链路或维度对齐出了错。下面五条是我自己和身边人真踩过的,按“现象 → 原因 → 解决”记下来,出问题时对着排一遍,多数十分钟内能定位。
5.1 五条现场记录
第一条:准确率恒定 33.3%,loss 从 1.0986 缓慢降到 1.05 就不动了。现象是所有预测都落在同一个类别,混淆矩阵只有一列有数。原因是标签没做 one-hot,Y还是(m,)的一维整数,A2 - Y广播成了(m,3) - (m,)这种错位形状,梯度方向完全是乱的。解决办法是在数据入口就把one_hot()加上,并在backward()开头断言assert Y.shape == A2.shape。加一行断言,省两小时排查。
第二条:训练十几个 epoch 后 loss 变 NaN,曲线直接断掉。现象是 loss 先正常下降,然后某一轮突然nan,之后再也不恢复。原因通常有两个叠加:学习率设成 0.5 以上,加上没做标准化,某一步更新把权重推到极大,softmax 的exp溢出成inf。解决办法是把 lr 降回 0.05,标准化补上,同时在 softmax 里减行最大值(上一章代码里那行Z2 - Z2.max(...)),并对 loss 用np.clip(loss, 1e-12, None)兜底。
第三条:训练集 100%,测试集 60% 出头,且测试 loss 从某轮开始反弹。现象是两条曲线在某个 epoch 后分叉,训练 loss 继续降、验证 loss 抬头。原因是隐层节点开太大(32 以上)又没有正则,模型把 120 条训练样本记住了。解决办法是把隐层降回 8,或加 L2 正则loss += lam * (np.sum(W1**2) + np.sum(W2**2)),梯度相应加2*lam*W;再配合早停,取验证 loss 最低那一轮。别用“再跑久一点试试”,方向反了。
第四条:每次运行准确率都不一样,报告里的数对不上。现象是同一份代码连跑三次得到 93.3%、96.7%、90.0%。原因有三个随机源没锁:切分的random_state、权重初始化的种子、batch 打乱用的 rng。解决办法是入口统一seed,一路传下去,杜绝在模块里各自np.random.seed()—— 那样后设的会覆盖先设的,看似锁了其实没锁。锁完之后重复五次取均值报标准差,才是能写进报告的数。
第五条:混淆矩阵里 versicolor 和 virginica 大面积互串,setosa 完美。现象是准确率 90% 左右,但两类来回错。原因往往不是模型不够强,而是标准化只对训练集做了、测试集忘了 transform,或者 transform 时用了测试集自己的均值方差。另一种可能是特征顺序被调过(比如把花瓣宽和萼片宽对调),模型仍能学到东西但边界偏移。解决办法是检查X_test是否经过同一个(X - mu) / sigma,并在data_utils.py里把特征列名写死成常量,画图时按名字取列而不是按索引。
5.2 一份三十行的自检脚本
上面五条里有四条能在训练开始前用静态检查拦下来。我习惯在train.py最前面塞一段自检,跑通再开始训练:
def sanity_check(X, Y, W1, b1, W2, b2): assert X.ndim == 2 and Y.ndim == 2, "X/Y 必须是二维" assert Y.shape[1] == 3, "输出类别数应为 3" assert np.allclose(Y.sum(axis=1), 1.0), "标签没做 one-hot 或行和不为 1" assert X.shape[1] == W1.shape[0], "输入维度和 W1 不匹配" _, A1, _, A2 = forward(X, W1, b1, W2, b2) assert A2.shape == Y.shape, "输出形状和标签不一致" assert np.allclose(A2.sum(axis=1), 1.0, atol=1e-6), "softmax 行和不是 1" init_loss = -np.mean(np.sum(Y * np.log(A2 + 1e-12), axis=1)) # 三类均衡时,随机初始化的 loss 应接近 ln(3) ≈ 1.0986 assert 1.0 < init_loss < 1.2, f"初始 loss {init_loss:.4f} 异常,检查初始化或标签编码" # 关键一步:拿 8 条样本过拟合,loss 必须能降到 0.05 以下 xb, yb = X[:8], Y[:8] W1c, b1c, W2c, b2c = W1.copy(), b1.copy(), W2.copy(), b2.copy() for _ in range(300): _, a1, _, a2 = forward(xb, W1c, b1c, W2c, b2c) g = backward(xb, yb, a1, a2, W2c) W1c -= 0.5 * g[0]; b1c -= 0.5 * g[1]; W2c -= 0.5 * g[2]; b2c -= 0.5 * g[3] _, _, _, a2 = forward(xb, W1c, b1c, W2c, b2c) final = -np.mean(np.sum(yb * np.log(a2 + 1e-12), axis=1)) assert final < 0.05, f"小批量过拟合失败,loss={final:.4f},反向传播大概率写错了" print("sanity check passed, init loss =", round(init_loss, 4))最后那个“拿 8 条样本过拟合”的检查是整个脚本里最有价值的一段。一个正确的 BP 实现,在 8 条样本上跑 300 步必然能把 loss 压到接近 0;如果压不下去,说明反向传播、参数更新或激活函数导数中至少有一处错了,此时无论怎么调超参都是白费。这一步花不到一秒,能挡掉后面几小时的无效调试。
6. 进阶验证:用对照实验确认你的手写网络真的没写错
训练能收敛、准确率也上去了,仍然不能证明实现是对的——iris 太简单,很多半对的实现也能跑到 95%。想确认自己写的 BP 没毛病,我一般做两件事:一是和 scikit-learn 的MLPClassifier做同条件对照,二是做一次学习率扫描看曲线形状是否符合预期。
对照实验的关键是“同条件”:同样的切分、同样的标准化、同样的隐层节点数和激活函数。MLPClassifier默认用 relu,要显式改成 tanh 才可比。
from sklearn.neural_network import MLPClassifier clf = MLPClassifier(hidden_layer_sizes=(8,), activation="tanh", solver="sgd", learning_rate_init=0.05, momentum=0.9, batch_size=16, max_iter=2000, random_state=42) clf.fit(X_train, y_train) # 注意传原始整数标签,不是 one-hot sk_acc = clf.score(X_test, y_test) print(f"sklearn acc={sk_acc:.4f}, our acc={our_acc:.4f}")两者差 1~2 个百分点属正常,因为初始化方式和动量实现细节不同;差 5 个点以上就要回头看自己的实现,重点查初始化和动量更新方向。学习率扫描则更直观,固定其他参数,让 lr 从 0.001 扫到 1.0:
| 学习率 | 收敛所需 epoch | 最终验证 loss | 曲线特征 |
|---|---|---|---|
| 0.001 | > 2000 未收敛 | 0.62 | 缓慢下降,尾部仍在降 |
| 0.01 | 约 1200 | 0.21 | 平滑下降,无震荡 |
| 0.05 | 约 400 | 0.16 | 快速下降后平稳,推荐 |
| 0.2 | 约 250 | 0.19 | 前期陡,尾部小幅震荡 |
| 1.0 | 不收敛 | NaN | 前 20 轮内发散 |
这张表最值得看的是 1.0 那一行:如果自己实现里 lr=1.0 还能慢慢收敛,反而说明有问题,多半是梯度被某种隐式缩放压小了,比如 loss 忘了除以 m,或者dZ2少乘了 batch 平均。反过来,如果 lr=0.01 就开始震荡,检查动量项是不是累加成了v += lr * grad而不是v = μv - lr·grad,符号错了会变成发散加速器。
最后提醒一句容易忽略的:MLPClassifier的fit接受的是整数标签,而手写网络用的是 one-hot,对照时别把 one-hot 直接喂进去,否则 sklearn 会把它当成多输出回归任务,准确率直接掉到个位数,白白怀疑自己半天。我第一次做这个对照就栽在这,后来养成习惯,凡是对照实验,先把两边的输入形状打印出来对一眼再跑。从那以后我每次改完网络结构,都强制把自检脚本和对照实验走一遍,多花两分钟,省的是半天。希望帮到你。
本文还有配套的精品资源,点击获取