简介:面向机器学习入门者与课程实验需求,这份BP神经网络实现Iris鸢尾花分类的配套资料,围绕反向传播网络的数据预处理、结构搭建、权重初始化、前向传播、误差计算、反向传播与模型评估展开,适合在PyCharm中对照复现分类实验并撰写实验报告。压缩包共4个文件,约342KB,含2个Python脚本、1份Word实验报告和1份CSV数据集,分别承载网络代码、过程记录与训练数据,体量轻便,便于直接运行与修改。已有669人学习下载,可作为课堂作业、算法练习或小项目参考。资料侧重完整实验链路与结果分析,读者可从中了解隐藏层设置、学习率与迭代次数对分类性能的影响,并借鉴参数调优、指标评估和报告整理思路。
1. 从一份能直接跑通的 BP 网络说起:iris 三分类的最小闭环
拿到这份 BP神经网络 iris分类 python 实验报告 资源时,我先翻的是训练脚本,而不是那份报告,因为 iris 三分类这种题目,网上抄来的代码十有八九跑不出稳定结果:要么损失卡在 0.6 附近下不去,要么准确率在 0.33 上下浮动,相当于瞎猜一个类。这份包好在能直接开跑:一份 csv 数据、一套手写的正向和反向传播、一个能复现的训练入口,外加一份把每个超参为什么这么设写清楚了的报告。它适合两类人:刚学完反向传播公式、想找个能跑的最小闭环把公式和代码对应起来的;以及要交课程设计、需要在 PyCharm 里改几行就能讲清楚每一步的。下面按我拆包的顺序,把结构、核心代码、跑法和改法一次讲透。
2. 项目骨架与技术选型:为什么这份代码坚持手写反向传播
拆开压缩包第一眼能看出作者的意图:这不是一份“调个库交差”的作业,而是一份把梯度推导落到矩阵乘法上的教学型实现。目录层级基本围绕“数据、模型、训练、评估、报告”五块切开,入口脚本只做编排,不塞逻辑。这个划分方式对新手特别友好——你想改哪块,直接定位到对应文件,不用在一千行的 main.py 里翻。
2.1 目录长什么样:入口、数据、模型、报告各归各位
不同版本细节略有出入,但骨架基本是这几块:
bp_iris/ ├── data/ │ └── iris.csv # 150 行 × 5 列,前 4 列特征,最后一列类别名 ├── src/ │ ├── dataset.py # 读取、标签编码、标准化、划分 │ ├── model.py # 参数初始化、前向、反向、损失 │ ├── train.py # 训练循环 + mini-batch │ └── evaluate.py # 准确率、混淆矩阵 ├── config.py # 超参集中在这里 ├── main.py # 唯一入口 └── report/ └── 实验报告.docx # 推导过程 + 结果图表 + 参数说明这个分法的好处是职责边界清楚。model.py里只有数学,不碰文件 IO;dataset.py里只有数据搬运,不碰梯度;config.py是个纯字典,改超参不用动任何逻辑代码。我见过太多学生把一切塞进一个文件,最后调参时改错行,把评估集当成训练集喂进去,这种血泪经验在这份结构里基本不会发生。
顺带说一个容易被忽略的点:iris.csv放独立目录、用相对路径引用,是为了在 PyCharm 里右键运行和命令行python main.py两种方式都能找到文件。很多新手写死绝对路径D:/xxx/iris.csv,换台机器直接崩,这种错误在项目里从一开始就规避掉了。
2.2 技术栈取舍:为什么不用 sklearn 或 PyTorch
技术栈非常克制:NumPy 做矩阵运算,Pandas 可选(读 csv 用np.loadtxt就够),Matplotlib 画损失曲线。没有 TensorFlow,没有 PyTorch,也没用sklearn.neural_network.MLPClassifier。
这不是技术落后,而是目标决定的。实验报告的核心价值在于“把链式法则推导出来并验证”,一旦调用MLPClassifier.fit(),整个反向传播就变成黑匣子,报告里那几页公式推导和代码就对不上了。用 NumPy 手写,dW2 = a1.T @ dz2这一行能直接在报告里对应到 ∂L/∂W² 的矩阵形式,答辩时被问到“你这个梯度怎么来的”,能指着代码讲。
代价也要说清楚:手写版本没有自动微分、没有 GPU 加速、没有数值稳定性优化。所以我在实际使用时一般会做一件事——用 sklearn 的MLPClassifier跑一遍作为对照基线,两边准确率差在 1~2 个百分点内,就说明手写实现没写错。这个对照技巧在第 6 章会展开。
依赖清单大致是:
pip install numpy pandas matplotlib版本上 NumPy 1.20 以上都行,代码里用的np.random.default_rng需要 1.17+。如果你机器上是很老的 1.16,把default_rng(seed)换成np.random.RandomState(seed)即可,接口略有差异但语义一致。
2.3 数据流:从 iris.csv 到 150×4 的输入矩阵
数据这一层做了四件事,顺序不能乱:读 csv、字符串标签转整数、整数转 one-hot、特征标准化。标准化必须放在划分训练/测试集之前用全局均值方差算,或者更严谨地用训练集统计量去变换测试集——这份代码用的是前一种简化写法,150 条样本下差异可以忽略,但你在第 6 章换成几千条的数据集时,就得改成后者,否则会有轻微的数据泄漏。
import numpy as np def load_iris(path="data/iris.csv"): """读取 csv,返回特征矩阵、one-hot 标签、整数标签""" raw = np.loadtxt(path, delimiter=",", skiprows=1, dtype=str) X = raw[:, :-1].astype(np.float32) # 前 4 列:花萼长宽、花瓣长宽 label = raw[:, -1] # 最后一列:类别字符串 classes = np.unique(label) # np.unique 自带排序,结果稳定 y_idx = np.searchsorted(classes, label) # 字符串 -> 0/1/2 y_onehot = np.eye(len(classes))[y_idx] # 0/1/2 -> 3 维 one-hot return X, y_onehot, y_idx def standardize(X): """按列做 z-score,避免花瓣长度量纲压制花萼宽度""" mu = X.mean(axis=0) sigma = X.std(axis=0) return (X - mu) / (sigma + 1e-8) # 加极小值防除零几个参数值得说。skiprows=1是因为 iris.csv 带表头,如果你的文件没表头就改成 0,这是最常见的翻车点之一,报错信息通常是could not convert string to float: 'sepal_length',看到这个就知道表头没跳过。np.searchsorted要求classes有序,np.unique返回的正好是排序后的结果,所以这里安全;如果你手动写了个['virginica','setosa','versicolor']的列表去 searchsorted,标签就会错位,训练出来的模型准确率永远是 0.33 左右,第 5 章会专门讲这个坑。
1e-8这个小量不是玄学,iris 里有特征的方差很小,不加保护的话极端情况下会出现除零警告。astype(np.float32)而不是 float64,是为了让后面的矩阵乘法在内存上更省一点,150×4 这种规模其实无所谓,但养成习惯没坏处。
3. 核心模块拆解:一次前向到反向的完整矩阵链路
这一章是整份资源最值钱的部分。网络结构本身很朴素:4 个输入、一个 10 维的 ReLU 隐藏层、3 个输出的 softmax。但把这条链路完整走一遍,比看十篇讲“神经网络原理”的文章都管用,因为每一步都有具体的形状可以对着数。
3.1 参数初始化与形状约定
手写网络最容易出问题的地方不是公式,是形状。所以第一步先约定清楚:X是(batch, 4),W1是(4, hidden),b1是(1, hidden),W2是(hidden, 3),b2是(1, 3)。偏置用(1, n)而不是(n,),是为了让广播在 batch 维度上自动展开,少写一次np.newaxis。
def init_params(n_in=4, n_hidden=10, n_out=3, seed=42): """He 初始化:适配 ReLU,方差控制在 2/n_in""" rng = np.random.default_rng(seed) W1 = rng.normal(0, np.sqrt(2.0 / n_in), size=(n_in, n_hidden)) b1 = np.zeros((1, n_hidden)) W2 = rng.normal(0, np.sqrt(2.0 / n_hidden), size=(n_hidden, n_out)) b2 = np.zeros((1, n_out)) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}为什么用 He 而不是全零或0.01 * randn?全零初始化会让同一层所有神经元学到完全相同的梯度,等价于只有一个神经元,损失会卡住不降;0.01 * randn方差太小,ReLU 层容易出现激活值逐层衰减,深层网络直接学不动。sqrt(2/n_in)是 ReLU 的经验方差,比 Xavier 的sqrt(1/n_in)稍大,收敛更快。
seed=42固定随机源,保证你和我跑出来的是同一条损失曲线。做实验报告时这一点很关键——不固定种子,每次跑出来的准确率都不一样,报告里写“准确率 96%”,老师一跑是 93%,解释起来就麻烦。
3.2 前向传播:一次矩阵乘法加激活
前向只有三行核心运算,但两个细节决定成败:softmax 要减最大值防溢出,cache 要把中间量存下来给反向用。
def relu(z): return np.maximum(0, z) def softmax(z): z = z - z.max(axis=1, keepdims=True) # 每行减自己的最大值,防 exp 溢出 e = np.exp(z) return e / e.sum(axis=1, keepdims=True) def forward(params, X): z1 = X @ params["W1"] + params["b1"] # (B,4)@(4,10) -> (B,10) a1 = relu(z1) z2 = a1 @ params["W2"] + params["b2"] # (B,10)@(10,3) -> (B,3) a2 = softmax(z2) cache = {"X": X, "z1": z1, "a1": a1, "a2": a2} return a2, cachez.max(axis=1, keepdims=True)里的keepdims不能省,否则广播方向会变成按列减,输出全乱。e.sum(axis=1, keepdims=True)同理,分子分母必须沿同一维度归一化。
cache里存z1而不是a1 > 0的布尔结果,是为了让反向阶段自己判断——这样代码可读性更好,虽然多存了一份内存,150 条样本完全无感。如果是几万条数据,我一般会改成直接存 mask,省一半。
3.3 反向传播:链式法则落到代码上
这一块是整份代码的心脏。softmax 加交叉熵的梯度有一个非常漂亮的化简结果:dz2 = (a2 - y_onehot) / n,不需要单独对 softmax 求导再乘交叉熵导数,那一步会自动约掉。很多人不知道这个化简,硬推一遍再写代码,容易漏掉/n。
def cross_entropy(probs, y_onehot): """平均交叉熵,1e-12 防 log(0)""" n = probs.shape[0] idx = y_onehot.argmax(axis=1) # one-hot 转回类别下标 return -np.log(probs[np.arange(n), idx] + 1e-12).mean() def backward(params, cache, y_onehot): X, a1, a2, z1 = cache["X"], cache["a1"], cache["a2"], cache["z1"] n = X.shape[0] dz2 = (a2 - y_onehot) / n # softmax+CE 合并梯度,(B,3) dW2 = a1.T @ dz2 # (10,B)@(B,3) -> (10,3) db2 = dz2.sum(axis=0, keepdims=True) # (1,3),沿 batch 求和 da1 = dz2 @ params["W2"].T # (B,3)@(3,10) -> (B,10) dz1 = da1 * (z1 > 0) # ReLU 导数,逐元素乘 dW1 = X.T @ dz1 # (4,B)@(B,10) -> (4,10) db1 = dz1.sum(axis=0, keepdims=True) # (1,10) return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}逐行说。dz2除以n是取平均,因为cross_entropy用的是.mean();如果你把损失改成.sum(),这里就不能除,两处必须一致,这是最隐蔽的一类 bug——损失在降但梯度幅度不对,学习率得重新调。
a1.T @ dz2里a1.T的形状是(hidden, B),乘(B, 3)得到(hidden, 3),正好等于W2的形状,这是形状自检的第一个关卡。da1 = dz2 @ W2.T意思是把输出层的误差按权重比例反传回隐藏层,W2.T的转置在这里不是装饰。
dz1 = da1 * (z1 > 0)是 ReLU 的导数,z1 > 0产生布尔矩阵,乘的时候会自动转成 0/1。注意这里用的是z1而不是a1——a1 = relu(z1),a1 > 0和z1 > 0在 ReLU 下等价,但语义上z1 > 0更贴近定义。用a1也可以,只要别写成a1 * (a1 > 0)这种既乘了值又乘了 mask 的形式,那是把激活值和导数混在一起了。
3.4 训练循环与 mini-batch 采样
训练循环做三件事:每个 epoch 打乱索引、按 batch 切分、逐 batch 前向反向更新。打乱这一步别省,iris 的 csv 是按类别排序的,不打乱直接切 batch,每个 batch 里只有一个类,梯度方向会剧烈震荡。
def train(X, y_onehot, epochs=500, lr=0.1, batch_size=16, seed=42): params = init_params(seed=seed) rng = np.random.default_rng(seed) n = X.shape[0] history = [] for ep in range(epochs): idx = rng.permutation(n) # 每个 epoch 重新打乱 for i in range(0, n, batch_size): batch = idx[i:i + batch_size] xb, yb = X[batch], y_onehot[batch] probs, cache = forward(params, xb) grads = backward(params, cache, yb) for k in params: params[k] -= lr * grads[k] # 最朴素的 SGD probs_all, _ = forward(params, X) # 每个 epoch 末尾记一次全量损失 history.append(cross_entropy(probs_all, y_onehot)) return params, historylr=0.1配合 batch_size 16 和标准化的输入,500 个 epoch 内基本能收敛到 95% 以上。如果换成未标准化的原始特征,同样的学习率会出现损失先降后炸的现象,因为花瓣长度的量纲比花萼宽度大,梯度尺度失衡。
params[k] -= lr * grads[k]这里是原地更新字典里的数组。注意-=对 NumPy 数组是原地操作,不会产生新对象,所以字典里的引用一直有效。如果你写成params[k] = params[k] - lr * grads[k],效果一样但多一次内存分配,小数据无所谓。
history存的是每轮全量损失,画曲线时直接plt.plot(history)就行。想更细一点,可以再存一份训练准确率,报告里配两张图更好看。
3.5 评估与混淆矩阵
准确率谁都会算,但实验报告里只放一个数字太单薄。加一个 3×3 的混淆矩阵,能看出模型到底在哪两个类之间犯迷糊——iris 里 versicolor 和 virginica 本来就线性不可分,混淆矩阵能把这个现象量化出来。
def evaluate(params, X, y_idx): probs, _ = forward(params, X) pred = probs.argmax(axis=1) # 取概率最大的类 acc = (pred == y_idx).mean() cm = np.zeros((3, 3), dtype=int) for t, p in zip(y_idx, pred): cm[t, p] += 1 # 行是真值,列是预测 return acc, cmargmax(axis=1)的axis=1不能漏,默认是展平后取全局最大,返回的是一个标量,pred == y_idx会广播成错误结果。这个坑我在第 5 章还会提一次,因为它太常见了。
混淆矩阵的行列约定也要统一:行是真值、列是预测,还是反过来。这份代码用的是前者,画图时记得转置,否则报告里解释起来对不上。如果测试集只有 30 条,某个格子是 0 很正常,别看到 0 就以为代码错了。
4. 在 PyCharm 里跑通:环境、入口与结果的三种看法
代码看懂了,接下来是把它跑起来。这一步看着简单,实际上新手卡住的地方大多在这里:解释器选错、工作目录不对、图表弹不出来。
4.1 环境准备与依赖安装
建议建一个干净的虚拟环境,别用系统 Python。PyCharm 里的路径是File → Settings → Project → Python Interpreter → Add Interpreter → Virtualenv Environment,位置选在项目根目录下的.venv,这样压缩包解压到哪都能用。
# 在项目根目录执行 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install numpy pandas matplotlib装完之后在 PyCharm 里确认解释器指向.venv/python。有个细节:PyCharm 默认会新建一个venv目录(不带点),和命令行的.venv不是同一个,两个环境混用会出现“命令行能跑、IDE 报 ModuleNotFoundError”的情况。统一成一个就行,我一般直接用命令行建,再让 IDE 指过来。
Matplotlib 在部分 Linux 环境下没有图形后端,plt.show()会卡住或报no display name。脚本里加两行:
import matplotlib matplotlib.use("Agg") # 无界面环境用非交互后端 import matplotlib.pyplot as pltAgg后端不弹窗,只负责savefig,跑完去看report/loss_curve.png就行。本地开发机不需要这行,删掉即可。
4.2 入口脚本与配置项
main.py只做编排:加载、标准化、划分、训练、评估、打印。把这五步分开看,每一行都对得上前面讲过的模块。
from src.dataset import load_iris, standardize from src.train import train from src.evaluate import evaluate from config import CONFIG if __name__ == "__main__": X, y_onehot, y_idx = load_iris("data/iris.csv") X = standardize(X) rng = np.random.default_rng(CONFIG["seed"]) idx = rng.permutation(len(X)) split = int(len(X) * (1 - CONFIG["test_ratio"])) tr, te = idx[:split], idx[split:] params, hist = train( X[tr], y_onehot[tr], epochs=CONFIG["epochs"], lr=CONFIG["lr"], batch_size=CONFIG["batch_size"], seed=CONFIG["seed"], ) acc, cm = evaluate(params, X[te], y_idx[te]) print(f"test acc = {acc:.4f}") print(cm)config.py集中放超参,改实验不用动逻辑代码:
CONFIG = { "hidden": 10, # 隐藏层宽度,改成 4/16/32 对比看效果 "lr": 0.1, # 学习率,0.5 以上容易震荡,0.01 以下收敛慢 "epochs": 500, # 训练轮数 "batch_size": 16, # 批大小,150 条样本下 8/16/32 都试得动 "seed": 42, # 随机种子,报告里必须固定 "test_ratio": 0.2, # 测试集比例,30 条测试样本 }跑法有三种,我一般按这个顺序验证:
# 1. 命令行,最直接 python main.py # 2. 模块方式,验证包结构没问题 python -m src.train # 3. PyCharm 里右键 main.py -> Run 'main'第一种能跑通说明依赖和工作目录都对;第二种能跑通说明src/下的相对导入没写错;第三种能跑通说明 IDE 解释器配置正确。三种都过一遍,换机器复现基本不出问题。
4.3 结果怎么看:准确率、损失曲线、混淆矩阵
正常跑完,输出大概是这个形态:
test acc = 0.9667 [[10 0 0] [ 0 10 0] [ 0 1 9]]测试集 30 条,错 1 条,是一条 virginica 被判成 versicolor,这是 iris 的经典难点——这两个类在花瓣宽度上有重叠,线性模型本来就分不干净。如果你的混淆矩阵是 setosa 那一行出错,那反而要警惕,setosa 是最容易分的类,出错通常意味着标签编码那一步出问题了。
损失曲线用hist画:
import matplotlib.pyplot as plt plt.plot(hist) plt.xlabel("epoch") plt.ylabel("cross entropy") plt.title("training loss") plt.savefig("report/loss_curve.png", dpi=150)健康的曲线是前 50 轮快速下降,200 轮之后趋于平缓,最终值在 0.1 附近。如果曲线一直贴在 1.0986(也就是ln(3))不动,说明模型在均匀预测三个类,等价于没学;如果曲线先降后陡然上升,那是学习率太大导致发散。这两种情况第 5 章都有对应解法。
报告里还可以补一张隐藏层输出的散点图,把 10 维降不下来,但取前两维画一下也能看出类间分离趋势,答辩时比纯数字有说服力。
5. 常见问题排查:损失不降、准确率 0.33 与形状对不上
这一章是踩坑记录本。下面五条是我在复现这类手写网络时反复遇到的,每条按现象、原因、解决三段写,照着对号入座基本能定位。
5.1 损失卡在 1.0986 不动,准确率恒定 0.33
现象:跑完 500 个 epoch,损失曲线是一条几乎水平的直线,测试准确率稳定在 0.33 左右,混淆矩阵三行分布均匀。
原因:ln(3) ≈ 1.0986是三类均匀分布下的交叉熵,说明模型输出接近[0.33, 0.33, 0.33],等于什么都没学。常见的三个来源:参数初始化成全零导致对称性没打破;学习率小到 1e-6,500 轮走不动;或者backward里某个梯度算成了 0,比如把dz1 = da1 * (z1 > 0)错写成dz1 = da1 * (z1 < 0),ReLU 导数全反了。
解决:先打印第一轮更新前后的W1范数,如果完全没变,说明梯度是 0,回头查backward;如果变了但很小,调大学习率到 0.1 试一次;初始化改用init_params里的 He 方案,别用np.zeros。我排查这类问题时习惯在backward末尾加一行print({k: np.abs(v).mean() for k, v in grads.items()}),五个梯度的均值应该都在 1e-3 到 1e-1 量级,哪个是 0 一眼就看出来。
5.2 训练集准确率很高,测试集只有 0.33
现象:训练时损失降到 0.02,训练集准确率 100%,一切到测试集立刻掉到 0.33,混淆矩阵乱七八糟。
原因:标签错位。load_iris里如果classes的顺序和y_onehot的列顺序对不上,训练时模型学的是“列 0 对应 setosa”,评估时y_idx却按另一个顺序解释,两边语义不一致。典型触发点是手动写了classes = ['virginica', 'setosa', 'versicolor']这种未排序列表,np.searchsorted在无序数组上返回的下标是错的。
解决:把classes打印出来确认是['setosa', 'versicolor', 'virginica'](字典序),或者干脆不用searchsorted,改成{c: i for i, c in enumerate(classes)}的字典映射,语义更直白。另外检查y_onehot和y_idx是不是同一次load_iris调用返回的,有人为了省事分别调了两次,中间又改了classes,两边就对不上了。
5.3 反向传播报形状错误,或者结果莫名其妙
现象:ValueError: operands could not be broadcast together with shapes (16,10) (10,),或者代码能跑但损失一直震荡。
原因:矩阵乘顺序或转置写反。a1.T @ dz2写成a1 @ dz2,形状从(10,3)变成不合法;dz2 @ W2.T漏掉转置,变成(B,3)@(10,3)直接报错。还有一种隐蔽情况:db2 = dz2.sum(axis=0)漏了keepdims=True,得到(3,),减去(1,3)的b2时广播成(1,3),数值上恰好正确,但如果你后面又把b2拿去和(B,3)相加,就会静默出错。
解决:在backward开头把每个中间量的.shape打出来一次,和 3.1 节的形状约定逐个对。我的习惯是每个.T后面都跟一句注释写形状,比如dW2 = a1.T @ dz2 # (10,B)@(B,3) -> (10,3),写的时候多敲几个字,调的时候省半小时。
5.4 每次运行结果都不一样,报告数字对不上
现象:同样的代码连跑三次,测试准确率分别是 0.93、0.97、0.90,报告里不知道该写哪个。
原因:随机源没有完全固定。init_params固定了,但train里的rng.permutation用的是另一个没设种子的生成器;或者用了np.random.shuffle这种全局状态的函数,被别处的调用干扰。
解决:全流程只用一个种子入口,config.py里定义seed,init_params和train都从这个值派生,permutation也用它。固定之后连跑三次应该完全一致,不一致说明还有遗漏的随机调用。另外注意np.random.default_rng(seed)和np.random.seed(seed)是两套体系,别混用,前者是局部生成器,后者是全局状态。
5.5 测试集波动大,换个划分准确率差 5 个点
现象:test_ratio=0.2时测试集只有 30 条,随机划分换一次种子,准确率在 0.90 到 0.97 之间跳。
原因:样本量太小,单次划分的方差大。150 条数据切出 30 条测试,错 1 条就是 3.3 个百分点,这个波动是统计性的,不是模型问题。
解决:报告里别只写单次结果,改成 5 折交叉验证取均值±标准差,或者固定种子跑 5 次不同划分取平均。如果只是课堂作业,至少要在报告里注明“单次划分,测试集 30 条”,把方差来源讲清楚,比硬凑一个好看的数字更站得住。真要提升稳定性,去第 6 章看怎么加数据增强或者换更大的数据集。
6. 二次开发:把这套网络改成你自己的分类任务
代码跑通只是起点,这套骨架真正的价值在于它足够小,小到你可以在一个下午之内把它改成任意一个表格分类任务。下面是我实际改过几次之后总结的路径,按改动量从小到大排。
第一步,换数据集。只需要重写load_iris,其余全部不动。比如你手上有份客户流失的 csv,几十个特征、二分类,改法是把n_in从 4 改成实际特征数,n_out从 3 改成 2,标签那几行从字符串映射改成0/1直接读。改动点集中在dataset.py和config.py,model.py、train.py、evaluate.py一行不用碰,这就是分层带来的好处。
第二步,加网络深度。现在的params是个扁平字典,加一层就得手工加W3/b3,很啰嗦。我一般会重构成列表:
def init_mlp(sizes, seed=42): """sizes = [4, 16, 8, 3],相邻两个数构成一层的形状""" rng = np.random.default_rng(seed) params = {} for i in range(len(sizes) - 1): params[f"W{i+1}"] = rng.normal(0, np.sqrt(2.0 / sizes[i]), size=(sizes[i], sizes[i+1])) params[f"b{i+1}"] = np.zeros((1, sizes[i+1])) return params前向和反向改成循环遍历层数,cache里按层号存z和a。这个重构不难,但要注意最后一层不加 ReLU、直接接 softmax,循环里得用if i == len(sizes) - 2判断,这是最容易写错的地方。改完拿 iris 验一遍,准确率应该和单隐藏层版本持平或略高,如果掉到 0.33,多半是最后一层的激活接错了。
第三步,加训练技巧。这份代码是最朴素的 SGD,没有动量、没有学习率衰减、没有早停。加动量只要改一行:
# 在 train 里维护一个 velocity 字典 v = {k: np.zeros_like(val) for k, val in params.items()} ... for k in params: v[k] = 0.9 * v[k] - lr * grads[k] params[k] += v[k]0.9是动量的经验值,配上原来的lr=0.1,收敛轮数大概能减三分之一。早停更简单,history里连着 20 轮损失不降就break,防止过拟合。这两个技巧加起来不到 15 行,但对小数据集的稳定性提升很明显。
第四步,也是最值得做的一步:拿 sklearn 做对照。同一个数据集,MLPClassifier(hidden_layer_sizes=(10,), max_iter=500, random_state=42)跑一遍,两边准确率应该在同一水平。差太多就说明手写实现有问题,这时候再回去逐行对梯度,比盲调超参高效得多。
| 改造项 | 改动文件 | 预期耗时 | 验证方式 |
|---|---|---|---|
| 换数据集 | dataset.py、config.py | 30 分钟 | 训练集准确率 > 0.8 |
| 加隐藏层 | model.py、train.py | 1 小时 | 与单层版本持平 |
| 加动量/早停 | train.py | 20 分钟 | 收敛轮数下降 |
| sklearn 对照 | 新增 compare.py | 15 分钟 | 两者差值 < 2 个点 |
有一点必须提醒:这套代码是为教学和实验报告设计的,不是生产级实现。它没有梯度裁剪,没有数值稳定性处理,也没有 batch norm,拿去跑几千维特征、几十万样本的数据会很难受。真要做工程,还是老老实实用成熟框架。
我印象最深的一次翻车,是把test_ratio从 0.2 改到 0.5 想看看极端情况,结果测试集里 setosa 只有 12 条,准确率直接掉到 0.86,我以为是代码坏了,查了半天梯度,最后发现是划分之后某一类样本太少,纯统计波动。从那以后我每次改数据划分比例,都会先np.bincount看一眼每类还剩多少条,再决定要不要信这个数字。希望帮到你。
本文还有配套的精品资源,点击获取