简介围绕BP神经网络与Iris分类的课程实验包面向机器学习初学者、Python课程实验及需要完成分类项目实践的学生。包内共4个文件以2份Python源码、1份Word实验报告和1份CSV数据集组成压缩包约342KB代码可直接在PyCharm中运行报告便于整理实验过程与结果。内容覆盖数据预处理、网络结构搭建、权重初始化、前向传播、误差计算、反向传播、循环训练与准确率等指标评估并讨论隐藏层节点、学习率、迭代次数对分类性能的影响。实验报告记录参数设置、训练结果和优化思路可辅助读者理解BP神经网络处理多特征三分类问题的完整流程也方便对照源码复现实验、撰写课程报告或做进一步调参练习。目前已有669人学习。1. 从一份能跑通的 Iris 分类工程说起很多人做 Iris 分类的第一反应是调一个现成分类器三行代码出结果可一旦任务变成交一份带完整推导、对照实验与结果分析的 BP 神经网络实验报告难点就落在反向传播那几行梯度上了。这套基于 Python、在 PyCharm 里搭起来的 BP 神经网络 Iris 分类工程要解决的就是这件事150 条样本、4 个特征输入、3 类输出从权重初始化、前向传播、梯度回传、损失曲线到混淆矩阵全部摊开写清楚不给中间过程留黑匣子。它适合正在做机器学习课程设计、神经网络方向毕设或者需要交一份能讲明白“每一步为什么这么选”的 Python 实验报告的人也适合已经会调库、但说不清MLPClassifier里到底发生了什么想在答辩现场扛住追问的从业者。2. 总体设计四层数据流与 4-10-3 的网络结构定档一套能交上去的实验结构必须先定死再写代码。否则边写边改最后报告里的参数表和代码对不上答辩时就是自己给自己挖坑。这一章把数据流、网络规模、损失函数和工程目录四件事一次定下来。2.1 数据流150 条样本怎么变成网络的输入张量Iris 数据集总共 150 条记录4 个特征分别是花萼长度、花萼宽度、花瓣长度、花瓣宽度单位都是厘米标签是 3 个品种每个品种正好 50 条。这个规模小到可以全批量训练也小到任何一个错误划分都会被放大——测试集只有 30 条错 3 条准确率就掉 10 个点所以划分方式必须在报告里写明白。我一般按 8:2 分层抽样训练集 120 条、测试集 30 条每个类别在两边保持 1:1:1。分层这一步不能省否则某次随机划分可能让测试集里维吉尼亚鸢尾只剩 6 条指标波动会大到没法解释。标准化只在训练集上fit测试集只做transform这条规矩后面第 5 章还会专门拎出来讲它是数据泄漏最常见的入口。import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler RANDOM_SEED 42 def load_and_prepare(test_size0.2, seedRANDOM_SEED): iris load_iris() X, y iris.data, iris.target # X: (150, 4) y: (150,) # stratifyy 保证训练/测试集里三个类别比例一致 X_train, X_test, y_train, y_test train_test_split( X, y, test_sizetest_size, random_stateseed, stratifyy) scaler StandardScaler() X_train scaler.fit_transform(X_train) # 只在训练集上 fit统计量不含测试集 X_test scaler.transform(X_test) # 测试集只 transform y_train_oh np.eye(3)[y_train] # one-hot: (120, 3) y_test_oh np.eye(3)[y_test] # one-hot: (30, 3) return X_train, X_test, y_train_oh, y_test_oh, y_test, scaler这段的关键不在代码本身而在三个参数的语义。test_size0.2决定了评估的粒度样本量这么小的时候不建议再切验证集改成在训练集内部做 5 折交叉验证更稳。random_state固定后任何人拿到这份工程都能复现出同一组数字这在验收环节非常值钱。np.eye(3)[y]是把整数标签直接查表成独热向量比手写循环干净也避免了标签顺序和输出层神经元顺序错位。标准化之后的特征均值接近 0、方差接近 1这一步对 BP 网络不是可选项。原始数据里花瓣长度在 1.0 到 6.9 之间花萼宽度只有 2.0 到 4.4量纲差了一倍多不归一化的话第一层权重的梯度会被大数值特征主导收敛曲线会呈现出一种“前 100 轮几乎不动、之后突然下降”的玄学形态。2.2 结构定档4-10-3 的三层网络与激活函数选择网络结构定成 4-10-3输入层 4 个神经元对应 4 个特征一个隐层 10 个神经元输出层 3 个神经元对应 3 个类别。隐层数量上没有争议——Iris 是近似线性可分的数据两层非线性就足够堆到三四层只会让训练变慢、报告里多出一堆说不清的超参数。真正要解释的是隐层为什么是 10。常见的经验做法是取输入维度和输出维度之间的一个数再往上取一点余量4 和 3 都太小隐层容量不够拟合花瓣长度和品种之间那条非线性边界取 3 到 5 时训练集能到 97% 但测试集偶尔掉到 90%。我在这个项目里一般会跑一组 3、5、10、20 的对照把测试准确率列成表放进报告10 往往是准确率和训练成本之间的平衡点20 之后提升基本消失还更容易过拟合。激活函数的选择上隐层用 ReLU输出层用 Softmax。隐层不用 Sigmoid 的原因是它在输入绝对值偏大时导数趋近于 04 维特征经过标准化后仍有部分样本落在饱和区反向传播传两层就衰减得差不多了。ReLU 在正区间的导数恒为 1梯度能完整传回第一层代价是可能出现神经元“死亡”但 Iris 这个规模下几乎不会遇到。输出层配 Softmax 是和损失函数绑定的Softmax 加交叉熵合并后的梯度形式极其干净就是预测概率减去真实标签不需要再乘 Softmax 的雅可比矩阵。如果换成 Sigmoid 加均方误差梯度里会多出一项sigmoid(z)训练初期误差大的时候反而学得慢这是很多实验报告里 loss 曲线前段平得像一条直线的直接原因。设计项选定值备选与放弃理由输入维度4数据集固定无可调空间隐层数1 层数据近似线性可分加深无收益隐层神经元103/5 欠拟合20 以上过拟合且慢隐层激活ReLUSigmoid 在饱和区梯度消失输出激活Softmax与交叉熵配对梯度形式最简损失函数交叉熵 L2MSE 收敛慢梯度含饱和项优化方式全批量梯度下降120 条样本批次切分无意义2.3 工程组织PyCharm 项目里各文件的职责PyCharm 工程最容易写成一锅粥所有代码塞进一个main.py两百行往下拉改一个学习率要翻三屏。我把这份工程按职责拆成五个文件每个文件单独能跑通也方便报告里按章节对应贴代码。data_loader.py只负责读数据、划分、标准化、独热编码对外暴露一个load_and_prepare。model.py放初始化、前向、反向三个纯函数不碰数据和训练循环这样单元测试好写。train.py组织训练流程管学习率、轮数、早停和权重保存。evaluate.py负责加载权重、算指标、画图。report/目录存损失曲线、混淆矩阵图片和最终的实验数据表。把模型层和训练层拆开的好处是调超参数时只需要改train.py顶部的几个常量不用动模型代码写报告时前向和反向的公式可以直接对着model.py抄不会出现“报告里写的是 Softmax代码里其实是 Sigmoid”这种自己都圆不回来的情况。3. 核心模块实现从手写前向传播到反向梯度更新这一章是整份工程的核心。手写 BP 而不是调库最大的价值在于每一行梯度你都能指出来对应哪个公式答辩时被问到“梯度消失发生在哪一层”不会哑火。3.1 参数初始化与前向传播权重初始化不能用全零也不能用小的均匀随机。全零会让同一层的所有神经元在反向传播时拿到完全相同的梯度训练结束后它们仍然一模一样等于白养了 10 个神经元。我在这份工程里用 He 初始化标准差取sqrt(2 / fan_in)专门配 ReLU如果隐层想换成 tanh把系数改成sqrt(1 / fan_in)的 Xavier 形式更合适。import numpy as np def init_params(n_in4, n_hidden10, n_out3, seed42): rng np.random.default_rng(seed) # He 初始化适配 ReLU方差 2 / fan_in 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} def forward(X, params): Z1 X params[W1] params[b1] # (n, 10) A1 np.maximum(0, Z1) # ReLU Z2 A1 params[W2] params[b2] # (n, 3) Z2 Z2 - Z2.max(axis1, keepdimsTrue) # 减行最大值防 exp 溢出 expZ np.exp(Z2) A2 expZ / expZ.sum(axis1, keepdimsTrue) # Softmax每行和为 1 cache {X: X, Z1: Z1, A1: A1, A2: A2} return A2, cache形状是这段代码里最值得盯的地方。X是(n, 4)W1是(4, 10)乘完是(n, 10)W2是(10, 3)乘完是(n, 3)正好对上独热标签的形状。偏置用(1, h)而不是(h,)是为了让它能在批量维度上广播同时sum(axis0, keepdimsTrue)求梯度时形状能原样对上不用在反向传播里做额外 reshape。Softmax 前面那行减最大值不是可有可无的优化。np.exp(300)会直接溢出成inf然后inf / inf得到nan训练从第一个 batch 就崩报错信息还极不友好。标准化做得好时这个问题出现的概率低但只要某次实验忘了标准化它就会以“随机出现”的方式反复出现非常折磨人。3.2 反向传播的梯度推导与实现反向传播的核心结论只有一句Softmax 配交叉熵时输出层对Z2的梯度等于预测概率减真实标签再除以样本数。这一项推到dW2是A1的转置右乘它推到dA1是W2的转置左乘它。再往前穿过 ReLU导数是一个掩码Z1 0的位置保留其余置零。def backward(y_oh, params, cache): m y_oh.shape[0] A1, A2, X cache[A1], cache[A2], cache[X] # Softmax 交叉熵合并梯度形状 (m, 3) dZ2 (A2 - y_oh) / m dW2 A1.T dZ2 # (10, 3) db2 dZ2.sum(axis0, keepdimsTrue) # (1, 3) dA1 dZ2 params[W2].T # (m, 10) dZ1 dA1 * (cache[Z1] 0) # ReLU 导数掩码相乘 dW1 X.T dZ1 # (4, 10) db1 dZ1.sum(axis0, keepdimsTrue) # (1, 10) return {dW1: dW1, db1: db1, dW2: dW2, db2: db2}除以m是对批量取平均避免梯度大小随样本数变化这样学习率才能在不同 batch size 之间保持同一个量级。dW1和dW2的形状必须和对应的权重完全一致训练循环里才能统一用params[k] - lr * grads[d k]更新。我第一次写这段时把db1的keepdims漏了得到一个(10,)的向量减去(1, 10)的偏置之后广播成(1, 10)还恰好不报错但下一次前向时形状已经错位损失曲线呈现出一种缓慢发散的诡异趋势排查了半个晚上。ReLU 的掩码用的是cache[Z1] 0而不是A1 0。两者在数值上等价但直接用Z1语义更清晰——ReLU 的导数是“输入是否大于零”A1已经是输出读代码的人还得绕一道弯。3.3 训练循环批次、学习率与早停120 条训练样本用全批量梯度下降就够每轮一次前向一次反向几百轮的总计算量在一台普通笔记本上不到一秒。这种情况下去切 mini-batch 反而增加了噪声还会让损失曲线上多出一层锯齿报告里不好解释。def train(X, y_oh, epochs600, lr0.1, l21e-4, seed42): params init_params(seedseed) history {loss: [], acc: []} best_loss, best_params np.inf, None for epoch in range(epochs): A2, cache forward(X, params) # 交叉熵 L2 正则 ce -np.mean(np.sum(y_oh * np.log(A2 1e-12), axis1)) reg 0.5 * l2 * (np.sum(params[W1] ** 2) np.sum(params[W2] ** 2)) loss ce reg grads backward(y_oh, params, cache) for k in params: g grads[d k] if k.startswith(W): # 偏置不参与正则 g g l2 * params[k] params[k] - lr * g acc np.mean(np.argmax(A2, axis1) np.argmax(y_oh, axis1)) history[loss].append(loss) history[acc].append(acc) if loss best_loss: # 记录损失最低的一组权重 best_loss loss best_params {k: v.copy() for k, v in params.items()} return best_params, historynp.log(A2 1e-12)里的1e-12是后悔药。Softmax 输出理论上不会为 0但浮点下溢会让某个概率变成精确的 0log(0)是-inf一个样本就能让整条损失曲线变成nan。加一个极小数成本可以忽略但能挡住这类偶发崩溃。学习率 0.1 是在这个规模下的经验值。调到 1.0 时损失会来回震荡甚至发散调到 0.01 时 600 轮还停在 0.6 左右没收敛完报告里放出来会显得模型很差实际只是没训完。更稳的做法是加学习率衰减每 200 轮乘 0.5前期快、后期稳损失曲线会更平滑。L2 系数1e-4只对权重生效偏置不正则——偏置只影响平移正则它没有抑制过拟合的意义反而可能让输出层偏向某个类别。早停在这里用“记录最低损失”而不是“验证集连续 N 轮不降”原因还是样本量切出验证集后训练数据只剩 100 条左右验证指标波动比模型本身的提升还大早停很容易停在错误的点上。全批量训练下训练损失单调下降得足够稳直接取最低点反而更可靠。4. 实验与报告把准确率拆成可解释的图表4.1 评估指标不止一个准确率只报一个测试准确率是实验报告里最常见的减分项。Iris 三类样本均衡准确率不会产生误导但它掩盖了“哪一类容易错”这个关键信息。山鸢尾几乎线性可分任何模型都能全对错分基本都发生在变色鸢尾和维吉尼亚鸢尾之间这两类的花瓣长度区间有重叠。所以评估部分至少给四个数宏平均精确率、宏平均召回率、宏平均 F1以及混淆矩阵。宏平均是先算每个类别的指标再取算术平均对每一类同等看待微平均会被样本多的类别主导这里三类各 50 条两者数值接近但报告里写宏平均更能体现“我关注了每一类”。from sklearn.metrics import confusion_matrix, classification_report def evaluate(params, X, y_true): A2, _ forward(X, params) y_pred np.argmax(A2, axis1) # target_names 与标签 0/1/2 一一对应 print(classification_report( y_true, y_pred, target_names[山鸢尾, 变色鸢尾, 维吉尼亚鸢尾], digits4)) cm confusion_matrix(y_true, y_pred) return y_pred, cm正常收敛后测试集准确率在 93% 到 97% 之间波动也就是 30 条里错 1 到 2 条。如果跑出 100%先别高兴回头检查标准化是不是在全量数据上fit的那是最典型的泄漏。如果只有 80% 出头八成是学习率太大或者轮数不够看一眼损失曲线尾部的值就能判断尾部还在明显下降就是没训完尾部平在高位就是卡住了。4.2 图表损失曲线与混淆矩阵的画法损失曲线是训练过程唯一的过程性证据比任何文字描述都有说服力。一条健康的曲线是前 50 轮快速下降、100 轮后进入缓慢下降、300 轮后基本走平看不出明显震荡。训练准确率曲线通常会比损失曲线更早饱和这两条线画在一起时要用双 y 轴否则量纲差太多会把准确率压成一条直线。import matplotlib.pyplot as plt from matplotlib import rcParams rcParams[font.sans-serif] [SimHei] # 中文标签避免方块字 rcParams[axes.unicode_minus] False def plot_history(history, save_pathreport/loss_curve.png): fig, ax1 plt.subplots(figsize(7, 4)) ax1.plot(history[loss], color#2b6cb0, label训练损失) ax1.set_xlabel(Epoch) ax1.set_ylabel(交叉熵损失) ax2 ax1.twinx() # 双 y 轴量纲不同 ax2.plot(history[acc], color#c05621, linestyle--, label训练准确率) ax2.set_ylabel(准确率) fig.legend(locupper right, bbox_to_anchor(0.88, 0.86)) plt.tight_layout() plt.savefig(save_path, dpi200)dpi200是给报告用的屏幕上看 100 就够但插进 Word 再导出 PDF 时 100 会糊成一片。中文字体那两行在很多机器上必须加否则坐标轴标题会渲染成一排小方块报告打印出来很难看。混淆矩阵建议自己用imshow画不要直接用ConfusionMatrixDisplay的默认样式。对角线用深色、非对角线用浅色格子里标上数字三个类别名用中文一张图就能顶掉半页文字。看到非对角线上 2 和 1 之间互相串了 2 条就说明这两个品种在当前特征空间里边界模糊结论部分可以顺势讨论“仅用四个尺寸特征已经接近可分上限”。4.3 实验报告的结构与对照实验设计报告按七段组织问题描述、原理推导、数据与预处理、网络结构与超参数表、训练过程、结果与分析、结论与局限。中间三段是主体超参数表要和代码里的常量逐项对上训练过程配上损失曲线结果部分放混淆矩阵和分类报告分析部分解释每一个错分样本的可能原因。只跑一组实验是不够的答辩时一定会被问“为什么是 10 个隐层神经元”。准备一组对照隐层取 3、5、10、20 各跑一次固定随机种子记录测试准确率和收敛所需轮数做成一张表。你会发现 3 和 5 在 90% 上下10 到 95% 以上20 和 10 基本持平但训练慢了近一倍。这张表比任何口头解释都管用也说明你确实动手试过而不是拍脑袋写了个数。第二组对照可以做“有无标准化”这一组能直观展示收敛速度差多少——不标准化时损失曲线前 200 轮几乎不动标准化后 50 轮就到了 0.3 以下。第三组可以调学习率0.01、0.1、1.0 三条曲线画在一张图上发散、正常、震荡三种形态一目了然。这三组实验加起来不超过两分钟的运行时间但对报告的完整度提升是决定性的。5. 避坑与排查五处最容易翻车的细节5.1 数据处理环节的三个坑现象测试准确率异常高接近 100%但换个随机种子就掉到 90% 以下。原因基本锁定在标准化用了全量数据fit。scaler.fit_transform(X)之后再划分测试集的均值方差信息已经渗进了训练过程模型在测试集上的表现被系统性高估。解决方式就是 2.1 节那段代码先划分再fit_transform训练集测试集只transform。这条规则在所有需要拟合统计量的预处理上都成立包括归一化、PCA、特征选择。现象训练过程正常但混淆矩阵里某一类几乎全错另外两类接近全对。大多是不分层划分导致测试集类别严重失衡30 条里某一类只分到 3 条错一条就是 33% 的该类召回率。解决方式是train_test_split加stratifyy并在报告里明确写出训练集和测试集的类别分布。样本量小的数据集上这一步不是优化是必需。现象形状对不上报ValueError: operands could not be broadcast together或者更隐蔽地不报错但损失不下降。标签形态混用是最常见的来源y是(n,)的整数独热是(n, 3)两者在A2 - y时会被广播成(n, n)而不是(n, 3)numpy 不报错梯度全错。解决办法是在load_and_prepare的返回值处就明确区分y_test整数给评估用和y_train_oh独热给训练用命名上带_oh后缀用错了一眼能看出来。5.2 训练与评估环节的两个坑现象损失从第一轮开始就是nan或者训练几轮后突然变nan。两个来源log(0)和指数溢出。前者靠np.log(A2 1e-12)兜住后者靠 Softmax 前减行最大值兜住这两处防护在 3.1 和 3.3 的代码里都已经写了。第三个不太常见的来源是学习率过大某一轮权重更新后Z1变成极大的数ReLU 全开Softmax 输出退化成 one-hot交叉熵直接爆掉。排查顺序是先看损失在第几轮变nan是第 1 轮就查数据里有没有nan或未标准化的极端值是几十轮后就把学习率除以 10 再试。现象本地跑出 96.7%换台机器或者隔天再跑变成 93.3%报告里的数字对不上。随机种子没有全链路固定。train_test_split、init_params里的default_rng、以及任何用到随机的地方都要吃同一个种子matplotlib 画图顺序不影响结果但建议也固定。更彻底的做法是把最终权重np.savez存下来评估脚本直接加载从根上切断“每次跑都不一样”的可能。这个习惯在答辩现场价值极高——评委让你当场复现你打开工程跑一遍数字和报告里一模一样。提示这五个坑里数据泄漏和形状广播错误最容易被忽略因为它们都不报错只是让结果悄悄变差或者悄悄变好。写完预处理代码后单独打印一次训练集和测试集的均值方差能挡掉大部分问题。6. 验收与答辩用一组对照实验证明模型真的学到了东西验收的核心不是“准确率多高”而是“你能不能解释这个数字是怎么来的”。我给这份工程准备的验收材料是三样东西一张超参数表、一张对照实验表、一份可复现的权重文件。前两样放在报告里第三样放在工程目录里现场演示时直接加载。实验组隐层神经元学习率标准化测试准确率收敛轮数A基线100.1是96.7%约 180B30.1是90.0%约 120C200.1是96.7%约 260D101.0是86.7%不收敛震荡E100.1否93.3%约 500这张表能同时回答四个高频问题。问“为什么是 10 个隐层”看 B 和 C3 明显欠拟合20 没收益还慢。问“学习率怎么定的”看 D1.0 直接震荡说明 0.1 是在安全区内的选择。问“标准化有必要吗”看 E不标准化不仅准确率低收敛轮数还翻了两倍多。问“结果可信吗”看 A 那一行的 96.7% 和混淆矩阵里 30 条错 1 条能不能对上。现场演示的顺序我建议反过来先跑评估脚本加载已保存的权重十秒内出结果和混淆矩阵证明工程能跑再打开train.py从零训练一遍让大家看损失曲线的实时变化最后才翻报告讲推导。这个顺序的好处是即使推导部分被问住了前面“能跑、能复现、有对照”这三点已经立住了不至于全盘被动。如果时间只够讲一个技巧讲随机种子那条链路。从数据划分到权重初始化到最终保存一个RANDOM_SEED 42贯穿到底任何人拿到这份工程都能跑出报告里的每一个数字。这一点在答辩现场的说服力比多讲十分钟梯度推导要强。从那以后我每次交这类实验报告都强制自己先把种子固定、把测试集从头到尾只在最后碰一次、把每组对照实验的参数写进表里再动笔写正文。这三件事花不了半小时但能挡掉答辩现场百分之八十的追问。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?