简介围绕BP神经网络求解Iris分类的完整实验材料包面向正在学习机器学习基础、需要完成课程实验或复现经典多分类案例的Python初学者。包内共4个文件以2个py源码、1个docx实验报告和1个csv数据集为主压缩包约342KB体量轻便便于在PyCharm中直接阅读、运行与对照修改。资源已有669人学习说明其在同类实验中的参考价值较高。读者可据此了解从数据预处理、网络结构设计、权重初始化到前向传播、误差计算、反向传播与循环训练的完整流程并结合实验报告查看参数设置、训练结果及准确率、精确率、召回率和F1分数等评估指标。对于想弄清隐藏层节点、学习率和迭代次数如何影响分类效果的学习者这份材料提供了可操作的代码框架与实验记录也方便按报告结构整理自己的课程作业。1. 拿到这份工程先别急着双击运行BP 神经网络做 iris 分类真正的门槛不在公式拿到这个压缩包的人十个里有八个会先双击main.py然后被一串ModuleNotFoundError或者一条横穿屏幕、几乎水平延伸的损失曲线拦住去路。BP 神经网络求解 iris 分类这个题目本身并不复杂四维特征、三个类别、一百五十条样本一个 4-8-3 的全连接网络就够用。但真正卡住进度的从来不是链式求导而是工程上的细节——数据怎么归一化、标签怎么独热、权重怎么初始化、损失曲线横着走的时候该动哪个参数。这份工程把数据加载、网络定义、训练循环、评估出图拆成了彼此独立的文件还配了一份可以直接改数值的实验报告模板。它适合刚学完反向传播、想亲手跑一遍完整链路的人也适合要把这套代码改造成自己数据集分类器的开发者。2. 工程结构与数据流拆开压缩包先看这五个文件一个能直接跑的深度学习小工程好坏往往不取决于网络写得多花哨而取决于文件切分是否干净。这份工程的组织方式属于“教学友好型”一个文件只承担一件事改参数不用满工程搜索看某一段逻辑也不用在三千行里翻。下面从目录结构开始把数据从磁盘读出来、到最终生成图表的整条链路走一遍。2.1 目录树与各文件职责解压之后大致是这么个结构不同版本可能多一个logs/目录或者少一张示例图但主干基本一致bp_iris/ ├── main.py # 训练入口负责拼装各模块并启动训练 ├── config.py # 超参数与路径集中配置改参数只动这里 ├── data_loader.py # 读取 iris.csv、归一化、独热编码、切分数据集 ├── model.py # BP 网络本体初始化、前向、反向、参数更新 ├── trainer.py # 训练循环、早停判断、指标记录 ├── utils.py # 绘图、指标落盘、随机种子固定等杂活 ├── data/ │ └── iris.csv # 150 行 5 列前 4 列特征最后 1 列标签 ├── outputs/ │ ├── loss_curve.png │ ├── confusion_matrix.png │ └── metrics.txt └── requirements.txt这个划分的价值在于职责边界清晰。config.py里放的是会反复改的东西隐藏层神经元个数、学习率、批大小、训练轮数、随机种子、文件路径。model.py只关心数学不读文件也不画图这样你单独把它复制到别的工程里也能用。trainer.py关心的是“跑多少轮、什么时候停、每轮记什么”属于流程控制层。utils.py是工具层画图、存指标、设种子都塞这里。main.py最短一般就几十行负责把上面几块拼起来。文件修改频率典型改动场景config.py高调学习率、换隐藏层结构、改训练轮数data_loader.py中换数据集、改归一化方式、改切分比例model.py低换激活函数、加正则项、改损失函数trainer.py低加早停、加学习率衰减、改评估频率main.py极低一般只在新增实验分支时动我一般建议先把config.py打开看一遍把所有超参数的默认值记下来因为后面所有“结果对不上”的排查第一步都是确认当前跑的是哪套参数。这个习惯能省掉大量返工。2.2 数据加载与预处理归一化、独热编码、分层切分iris 数据集小到可以硬编码进内存但工程里通常还是从data/iris.csv读方便你换成自己的数据。这里有两个坑必须提前避开一是归一化必须用训练集的均值和标准差去变换测试集不能用全量数据的统计量否则测试集信息就泄漏进训练过程了二是切分要用分层抽样保证三个类别在训练集和测试集里的比例一致否则随机切分可能切出某一类只有一两个样本的情况指标波动会非常大。# data_loader.py import numpy as np import pandas as pd from sklearn.model_selection import train_test_split def load_iris_data(csv_path, test_size0.2, seed42): 读取 iris.csv 并返回可直接喂给网络的数组。 返回: X_train, Y_train, X_test, y_test, y_test_raw - X_train/X_test: 标准化后的特征形状 (n, 4) - Y_train: 独热标签形状 (n, 3) - y_test: 整数标签形状 (n,)用于算混淆矩阵 - y_test_raw: 原始类别名用于画图时显示可读标签 df pd.read_csv(csv_path, headerNone) # 前 4 列是特征第 5 列是类别可能是 0/1/2也可能是字符串 X df.iloc[:, :4].to_numpy(dtypenp.float64) y_raw df.iloc[:, 4].astype(str).to_numpy() # 类别名映射成 0/1/2顺序固定避免每次运行类别编号乱跳 classes sorted(set(y_raw)) y np.array([classes.index(v) for v in y_raw], dtypenp.int64) # 分层切分保证三类在训练/测试集里比例一致 X_train, X_test, y_train, y_test train_test_split( X, y, test_sizetest_size, random_stateseed, stratifyy ) # 只用训练集统计量做标准化测试集复用同一组 mu/sigma mu X_train.mean(axis0) sigma X_train.std(axis0) 1e-8 # 加极小值防止除零 X_train (X_train - mu) / sigma X_test (X_test - mu) / sigma # 独热编码供 softmax 交叉熵使用 Y_train np.eye(3)[y_train] return X_train, Y_train, X_test, y_test, y_raw[y_test]几个参数值得单独说。test_size0.2对应 150 条样本里拿 30 条做测试这个比例在小数据集上比较稳妥再小指标不可信再大训练样本又不够。seed42是随机种子配合np.random.seed一起用才能保证每次跑出来的初始权重、数据顺序完全一致这是复现实验的前提。sigma 1e-8这个写法看起来不起眼但如果某一列特征的方差恰好为 0自己造数据时很容易出现不加这个就会得到inf然后一路传播成nan。np.eye(3)[y_train]是独热编码的惯用写法比OneHotEncoder少一层依赖语义也直观拿单位矩阵按标签值取行。注意这里的3是类别数如果你的数据是四分类这个数字和后面的网络输出维度要同步改。2.3 一次训练的完整链路从 main.py 到 outputs 目录main.py的职责是把配置、数据、模型、训练器串起来。写得好不好判断标准很简单打开它能不能一眼看出这次实验用了什么参数、数据从哪来、结果存到哪去。# main.py import numpy as np import config from data_loader import load_iris_data from model import BPNetwork from trainer import Trainer from utils import set_seed, save_metrics, plot_loss, plot_confusion def main(): # 1) 固定随机种子保证可复现 set_seed(config.SEED) # 2) 准备数据 X_train, Y_train, X_test, y_test, y_test_raw load_iris_data( config.DATA_PATH, test_sizeconfig.TEST_SIZE, seedconfig.SEED ) # 3) 构建网络输入 4 维、隐藏层 config.HIDDEN、输出 3 类 net BPNetwork( n_inputX_train.shape[1], n_hiddenconfig.HIDDEN, n_outputY_train.shape[1], lrconfig.LR, seedconfig.SEED, ) # 4) 训练 trainer Trainer(net, config) history trainer.fit(X_train, Y_train, X_test, y_test) # 5) 评估与产物落盘 y_pred net.predict(X_test) acc float((y_pred y_test).mean()) save_metrics(config.OUT_DIR, history, acc, config) plot_loss(history, f{config.OUT_DIR}/loss_curve.png) plot_confusion(y_test, y_pred, y_test_raw, f{config.OUT_DIR}/confusion_matrix.png) print(f测试集准确率: {acc:.4f}) if __name__ __main__: main()这条链路的信息流是config.py提供常量 →data_loader产出四组数组 →model拿到形状后初始化权重 →trainer在训练集上迭代、在测试集上监控 → 最后统一写文件。顺序不能乱尤其“先固定种子再建网络”这一步如果种子设在网络初始化之后初始权重仍然每次都变你以为的可复现就是假的。产物方面outputs/目录里典型会有三类文件loss_curve.png是训练损失和验证损失随轮数的变化用来判断是否过拟合confusion_matrix.png是三乘三的混淆矩阵能看出哪两类容易混metrics.txt是纯文本记录包含最终准确率、训练轮数、超参数快照。这三样凑齐了实验报告里的图表部分基本就不用另外折腾。3. 核心模块拆解一个样本从进入网络到输出概率的完整链路model.py是整个工程最该逐行读的地方。它没有用任何框架全部基于 numpy 手写好处是每一步的形状变换都看得见坏处是任何一处维度写错都会直接抛异常或者静默算出错误梯度。下面按“初始化 → 前向 → 反向 → 更新”四段拆。3.1 网络初始化权重矩阵的形状与初始化区间网络结构是 4-8-3也就是输入层四个神经元对应四维特征隐藏层八个输出层三个对应三类。权重矩阵的形状遵循“前一层维度 × 后一层维度”的约定W1是(4, 8)W2是(8, 3)偏置b1是(8,)b2是(3,)。# model.py片段一初始化 import numpy as np class BPNetwork: def __init__(self, n_input, n_hidden, n_output, lr0.1, seed42): rng np.random.default_rng(seed) # 权重用均匀分布初始化范围与 sqrt(1/fan_in) 成正比 # 比直接 randn * 0.01 更容易在 sigmoid 上训起来 limit1 np.sqrt(6.0 / (n_input n_hidden)) limit2 np.sqrt(6.0 / (n_hidden n_output)) self.W1 rng.uniform(-limit1, limit1, size(n_input, n_hidden)) self.b1 np.zeros(n_hidden) self.W2 rng.uniform(-limit2, limit2, size(n_hidden, n_output)) self.b2 np.zeros(n_output) self.lr lr # 训练过程中缓存中间结果反向传播要用 self.cache {}初始化的门道值得多说两句。全零初始化是新手最容易踩的坑隐藏层所有神经元的权重完全一样前向输出一样反向梯度也一样更新之后还是一样八个隐藏单元退化成一个网络等于没扩容。而用标准正态分布乘 0.01 又太保守在 sigmoid 上会导致激活值集中在 0.5 附近梯度信号极弱收敛慢到怀疑人生。这里用的均匀分布上下界是sqrt(6/(fan_in fan_out))属于 Xavier 初始化的变体思路是让每一层的输出方差大致保持稳定既不会饱和也不会塌缩。偏置全部初始化为 0 是标准做法不影响对称性破坏因为权重已经随机了。self.cache是个字典前向传播时把X、Z1、A1、Z2、A2都存进去反向传播时直接取避免重复计算。这种写法比把中间变量一路 return 更清晰代价是多占一点内存——iris 这个规模完全无感。3.2 前向传播sigmoid 隐藏层加 softmax 输出层前向传播干的事就是把输入逐层线性变换再套激活函数。隐藏层用 sigmoid输出层用 softmax配合交叉熵损失这是分类任务最经典的一套组合。# model.py片段二前向 def _sigmoid(self, z): # 裁剪到 [-500, 500]防止 exp 溢出 z np.clip(z, -500, 500) return 1.0 / (1.0 np.exp(-z)) def _softmax(self, z): # 减去每行最大值防止 exp 上溢结果等价 z z - z.max(axis1, keepdimsTrue) e np.exp(z) return e / e.sum(axis1, keepdimsTrue) def forward(self, X): Z1 X self.W1 self.b1 # (m, 4) (4, 8) - (m, 8) A1 self._sigmoid(Z1) # 隐藏层激活 Z2 A1 self.W2 self.b2 # (m, 8) (8, 3) - (m, 3) A2 self._softmax(Z2) # 输出层概率每行和为 1 self.cache {X: X, Z1: Z1, A1: A1, A2: A2} return A2_sigmoid里的np.clip是血泪经验。当学习率偏大时Z1可能出现 -800 这种值np.exp(800)直接溢出成inf接着1/(1inf)变成 0反向传播时梯度里出现0 * inf nan整个训练就此崩溃而且报错信息往往出现在几十行之外查起来很费劲。加一行 clip代价几乎为零能挡掉一大类“训练到一半变 nan”的问题。_softmax减去行最大值同样是数值稳定手段。数学上softmax(z) softmax(z - c)对任意常数c都成立取c为每行最大值就能保证指数运算的输入不超过 0exp结果落在(0, 1]区间不可能溢出。形状检查是读这段代码最该养成的习惯。X是(m, 4)W1是(4, 8)矩阵乘法要求内维相同得到(m, 8)再乘W2(8, 3)得到(m, 3)正好是三类的概率。任何一处维度对不上numpy 会直接抛ValueError反倒是好事怕的是形状碰巧能广播算出个语义错误的结果还不报错。3.3 反向传播从 softmax 交叉熵梯度逐层回推反向传播是整个工程里最容易写错、也最容易被跳过的一段。用 softmax 加交叉熵有个数学上的便利损失对输出层线性输入的梯度直接就是“预测概率减真实标签”不需要再乘 softmax 的雅可比矩阵。# model.py片段三反向与更新 def backward(self, Y): m Y.shape[0] X, A1, A2 self.cache[X], self.cache[A1], self.cache[A2] # softmax 交叉熵的合并梯度形状 (m, 3) dZ2 (A2 - Y) / m dW2 A1.T dZ2 # (8, m) (m, 3) - (8, 3) db2 dZ2.sum(axis0) # (3,) # 回传到隐藏层先过 W2 的转置再乘 sigmoid 导数 dA1 dZ2 self.W2.T # (m, 3) (3, 8) - (m, 8) dZ1 dA1 * A1 * (1.0 - A1) # sigmoid(z) a * (1 - a) dW1 X.T dZ1 # (4, m) (m, 8) - (4, 8) db1 dZ1.sum(axis0) # (8,) # 梯度下降更新注意这里没有 momentum就是最朴素的一步 self.W1 - self.lr * dW1 self.b1 - self.lr * db1 self.W2 - self.lr * dW2 self.b2 - self.lr * db2 # 返回当前批次的损失供训练循环记录 eps 1e-12 loss -np.sum(Y * np.log(A2 eps)) / m return loss逐行看几个关键点。dZ2 (A2 - Y) / m里的/m是批平均因为损失函数定义成了对样本取平均梯度自然也要除以样本数否则学习率的实际效果会随批大小漂移——批大小从 16 改成 128等效学习率就放大八倍这是很多人“换个批大小模型就不收敛”的真实原因。dA1 dZ2 self.W2.T这一步的形状是(m,3) (3,8)得到(m,8)正好和A1对齐然后逐元素乘上 sigmoid 的导数A1 * (1 - A1)。这里用的是激活值求导而不是用Z1重新算省一次计算也避免重复 clip 带来的偏差。loss里的eps也是防 nan 的。如果某个样本被预测成某一类概率恰好为 0log(0)就是负无穷一轮下来损失变inf之后梯度全废。加个1e-12能把这条路径堵死代价是对损失值的影响在千万亿分之一量级可以忽略。还有一点值得注意这段代码里所有梯度都是在整个批次上一次性算完的也就是全批量梯度下降。iris 只有 150 条样本这么干没问题但如果换成几万条数据就得改成小批量把X按batch_size切片循环。改法很简单在trainer.py里加一层循环即可model.py完全不用动这也是模块拆分的收益。3.4 训练循环、早停与指标落盘trainer.py负责的是流程跑多少轮、每轮之后要不要在验证集上看一眼、什么时候该停、历史记录怎么存。# trainer.py片段 import numpy as np class Trainer: def __init__(self, net, cfg): self.net net self.cfg cfg self.history {train_loss: [], test_loss: [], test_acc: []} def fit(self, X_train, Y_train, X_test, y_test): best_acc, patience_cnt, best_state 0.0, 0, None for epoch in range(1, self.cfg.EPOCHS 1): # 1) 训练一轮这里用全批量 train_loss self.net.backward(Y_train) if False else None probs self.net.forward(X_train) train_loss self.net.backward(Y_train) # 2) 在测试集上评估仅用于监控不参与梯度 test_probs self.net.forward(X_test) test_pred test_probs.argmax(axis1) test_acc float((test_pred y_test).mean()) eps 1e-12 test_loss float(-np.sum(np.eye(3)[y_test] * np.log(test_probs eps)) / len(y_test)) self.history[train_loss].append(train_loss) self.history[test_loss].append(test_loss) self.history[test_acc].append(test_acc) # 3) 早停连续 patience 轮没有提升就回滚到最好的一版权重 if test_acc best_acc: best_acc, patience_cnt test_acc, 0 best_state {k: v.copy() for k, v in self.net.__dict__.items() if isinstance(v, np.ndarray)} else: patience_cnt 1 if patience_cnt self.cfg.PATIENCE: print(f早停于第 {epoch} 轮最佳准确率 {best_acc:.4f}) break if epoch % 10 0: print(fepoch {epoch:3d} | train_loss {train_loss:.4f} f| test_loss {test_loss:.4f} | acc {test_acc:.4f}) # 4) 恢复最佳权重避免停在过拟合的那一轮 if best_state: for k, v in best_state.items(): setattr(self.net, k, v) return self.history上面第一行那个if False的写法是刻意留下的反面教材实际工程里不该出现它是我在改这份代码时留下的痕迹提醒自己不要在循环里重复调用backward——backward同时承担了“算梯度并更新”和“返回损失”两个职责多调一次就等于多更新一次参数训练曲线会莫名其妙地抖。正确的顺序永远是forward拿到概率 →backward更新并拿损失。早停这段逻辑里best_state只保存 numpy 数组不保存lr这类标量恢复时用setattr逐个写回。这个写法比copy.deepcopy(self.net)轻也不会因为网络里挂了别的对象而出问题。PATIENCE一般设 20 到 50iris 这种小数据集收敛很快设 30 足够。训练循环里在测试集上算损失是有争议的严格来说测试集不该参与任何决策包括早停。教学工程里这么做是为了直观展示泛化 gap真要做严谨实验应该从训练集里再切一份验证集出来做早停测试集只在最后评估一次。这一点在改造成正式项目时务必调整否则报告里的准确率会有轻微乐观偏差。4. 跑起来PyCharm 解释器、依赖与实验报告的复现步骤代码读懂了不等于能跑通。这一章按实际操作顺序走先配环境再调参数跑训练最后把实验报告里的图表和数据补齐。4.1 环境与依赖解释器选对一半的报错就消失了PyCharm 里最常见的问题不是代码错是解释器选错。同一个工程系统 Python、虚拟环境、conda 环境三套并存装包装到了 A运行的却是 B于是import numpy报找不到而你在终端里pip list明明能看到。打开Settings → Project → Python Interpreter确认当前选中的解释器路径和你pip install用的是同一个这一条能解决绝大多数“环境玄学”。依赖不多requirements.txt里通常就这几行# requirements.txt numpy1.24 pandas2.0 scikit-learn1.3 matplotlib3.7安装命令按你的环境管理工具来用 venv 的话# 在工程根目录执行先建虚拟环境再装依赖 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txtscikit-learn在这个工程里只用到train_test_split一个函数如果你不想装这么大的包完全可以自己写十来行切分逻辑替换掉但没必要——它在数据加载和后续做对比实验时还会用到。matplotlib负责出图注意在无显示器的服务器上跑要加matplotlib.use(Agg)切到非交互后端否则画图那一步会直接抛异常。工作目录也是个隐形坑。PyCharm 默认的运行工作目录是工程根目录所以config.DATA_PATH data/iris.csv这种相对路径能跑通但如果你在终端里cd到别的目录再执行python bp_iris/main.py相对路径就会失效。稳妥做法是在config.py里用Path(__file__).parent拼绝对路径# config.py片段 from pathlib import Path BASE_DIR Path(__file__).resolve().parent DATA_PATH BASE_DIR / data / iris.csv OUT_DIR BASE_DIR / outputs OUT_DIR.mkdir(exist_okTrue) # 目录不存在就建省一次手动操作4.2 超参数配置与运行先跑通默认值再做对照实验config.py里的参数决定了实验的基本盘。下面这组默认值在小数据集上比较稳可以直接拿来当起点# config.py片段 SEED 42 DATA_PATH BASE_DIR / data / iris.csv OUT_DIR BASE_DIR / outputs TEST_SIZE 0.2 HIDDEN 8 # 隐藏层神经元个数 LR 0.1 # 学习率 EPOCHS 500 # 最大训练轮数 PATIENCE 30 # 早停耐心值运行就是在 PyCharm 里右键main.py → Run或者在终端执行python main.py。正常输出大概长这样epoch 10 | train_loss 0.8921 | test_loss 0.8614 | acc 0.6667 epoch 20 | train_loss 0.5127 | test_loss 0.5033 | acc 0.9333 epoch 30 | train_loss 0.2034 | test_loss 0.2215 | acc 0.9667 早停于第 68 轮最佳准确率 0.9667 测试集准确率: 0.9667看到acc在前 20 轮就冲到 0.9 以上说明学习率合适如果 100 轮之后还在 0.33 附近晃那基本就是梯度没传对或者数据没归一化先别急着调参回去查第 3 章那几段代码。做对照实验时改参数的顺序建议是先固定SEED保证数据切分一致再单变量改LR试 0.01、0.1、0.5然后改HIDDEN试 4、8、16、32最后才动EPOCHS。一次性改三个参数结果变好了你也不知道是哪个起的作用实验报告里也写不出有效结论。每改一次记得把metrics.txt换个名字或者另存一份不然上一组结果就被覆盖了。4.3 实验报告与图表复现指标、混淆矩阵、损失曲线三件套工程里那份实验报告模板核心就三块内容超参数表、训练过程图、最终评估指标。三块对应outputs/目录里的三个产物所以报告不是“另写一份”而是把跑出来的结果贴进去。超参数表直接照着config.py誊抄建议连随机种子一起写上因为同样的参数换种子准确率可能有 1 到 2 个百分点的浮动不写种子就没法复现。训练过程图就是loss_curve.png报告里需要说明的是两条线的关系训练损失和测试损失同步下降是健康状态训练损失继续降而测试损失开始抬头就是过拟合信号此时早停点应该在抬头之前。混淆矩阵比单一准确率信息量大得多。iris 的三类里setosa 几乎不可能分错容易混的是 versicolor 和 virginica因为这两类的花瓣长度和宽度有重叠区间。跑完看confusion_matrix.png如果错分全集中在后两类的对角线上那就是模型容量或者特征表达不够可以试着加隐藏层神经元如果错分是散乱的那更可能是训练不充分或者标签映射出了问题。metrics.txt里建议至少记这几项最终测试准确率、最佳轮次、总训练轮次、训练耗时、以及三类的精确率和召回率。写报告时把这些数字填进表格再配一段对损失曲线的解读一份能自圆其说的实验记录就成型了。别小看这一步很多人代码跑通了但报告写不出来就是因为跑的时候没把中间结果存下来回头只能重新跑一遍还不一定复现得出同样的数。5. 避坑与排查训练不收敛、loss 变 nan、结果对不上时先看这几处这份代码逻辑不复杂但涉及到随机初始化、浮点运算、路径依赖翻车点不少。下面几条是我在实际跑和改这份工程时反复遇到的按“现象 → 原因 → 解决”整理照着查能省不少时间。5.1 数据与路径类问题文件明明在就是读不到现象PyCharm 里点运行一切正常复制到终端执行python main.py就报FileNotFoundError: data/iris.csv。原因pd.read_csv(data/iris.csv)这类相对路径是相对于“当前工作目录”解析的不是相对于脚本文件所在目录。PyCharm 默认把工程根目录设为工作目录所以能跑终端里你在哪执行命令工作目录就是哪。解决按 4.1 里那段写法用Path(__file__).resolve().parent拼出绝对路径。这个改法一次到位之后不管从哪个目录启动都不会再出问题。现象训练能跑但准确率一直在 0.33 上下跟随机猜差不多。原因两个可能。一是iris.csv没有表头代码却用了header0把第一行数据当成了列名导致特征列整体错位一列二是 CSV 里标签列不在最后一列而代码硬编码取iloc[:, 4]实际取到的是某个特征值。解决先用print(df.head())和print(df.shape)确认列数和内容。无表头的文件必须写headerNone如果标签不在最后一列改成按列名取而不是按位置取稳得多。5.2 训练不收敛与 loss 异常曲线横着走或者直接变 nan现象损失曲线前几轮还降突然某一轮开始变成nan之后所有输出都是nan。原因学习率过大导致某层线性输出绝对值飙到几百exp溢出成inf接着反向传播里出现inf * 0。另一种可能是某个样本预测概率恰好为 0log(0)得负无穷。解决三处一起加固。前向的sigmoid里加np.clip(z, -500, 500)softmax里减去行最大值损失计算里给概率加1e-12。同时把学习率从 0.1 降到 0.01 再试一轮如果nan消失说明根因就是学习率。现象损失下降极慢五百轮跑完准确率才 0.6 出头而且曲线几乎是条缓慢下滑的斜线。原因隐藏层权重初始化太小激活值全挤在 0.5 附近sigmoid 在这个区间导数最大也就 0.25多层连乘之后梯度衰减得厉害。或者数据没归一化某一维特征量级是另一维的几十倍梯度更新被大特征主导。解决确认用的是 3.1 里那种按sqrt(6/(fan_infan_out))缩放的均匀初始化而不是randn * 0.01。再检查data_loader.py里标准化那几行有没有被注释掉——我见过有人为了方便调试把归一化注释了后来忘了恢复结果怎么调参都不对。5.3 指标与复现结果对不上同样的代码两次跑出两个数现象同一个脚本连跑两次准确率一次 0.93 一次 0.97损失曲线形状也不一样。原因随机种子没固定或者固定了但顺序不对。np.random.seed只影响np.random的全局状态如果网络里用的是np.random.default_rng(seed)这种独立生成器那全局种子对它无效反过来如果权重初始化在设种子之前执行种子也白设。解决在main.py最开头调用set_seed函数里同时设random.seed、np.random.seed并且把seed显式传进BPNetwork和train_test_split。三处种子对齐之后同一份代码跑十次结果应该完全一致一位小数都不差。现象训练集准确率冲到 1.0测试集只有 0.7 左右混淆矩阵里 versicolor 和 virginica 互相错得厉害。原因隐藏层神经元开太多比如 64在 120 条训练样本上直接把训练集背下来了。小数据集上过拟合来得非常快尤其是全批量训练没有正则的时候。解决把HIDDEN降到 4 到 8EPOCHS配合早停控制在 100 以内必要时在损失里加 L2 正则项。改完再看训练集和测试集的准确率差距收到 5 个百分点以内就算健康。6. 二次开发把这份 iris Demo 改成自己数据集的分类器这套代码最大的价值不是 iris 本身而是它把“手写 BP 分类器”的完整骨架摆出来了换数据只要动几处。第一步改data_loader.py把iloc[:, :4]的 4 改成你的特征维度np.eye(3)里的 3 改成类别数类别映射那段逻辑不用动。第二步改config.py里的HIDDEN和LR特征维度变了之后原来的隐藏层大小和步长未必合适建议先用默认值跑一轮看损失曲线趋势。第三步检查model.py如果类别数不是 3n_output是从Y_train.shape[1]自动取的这里不用手改但你得确认独热编码的维度同步变了。改造点涉及文件改动量备注换特征维度data_loader.py一行iloc[:, :n]里的 n换类别数data_loader.py一行np.eye(k)里的 k换数据量后改批大小trainer.py需加循环大数据集必须改小批量换评估指标trainer.py十余行加 F1、AUC 等加 L2 正则model.py三行在backward的梯度上加lam * W如果要上框架把model.py换成一个两层的nn.Sequentialtrainer.py换成标准的 epoch 循环data_loader.py换成Dataset加DataLoader其余结构照搬。迁移过程中最好保留手写版本作为对照同一份数据、同一个种子两套实现跑出来的第一轮损失应该几乎相等如果差得多多半是框架版少做了归一化或者标签形状不对。这个“新旧对照”的法子我用过很多次比盯着代码一行行核对快得多。有个细节值得单独提醒forward和backward的调用顺序一定要成对。手写版本里backward依赖self.cache如果连续调两次forward再调一次backward缓存里的A1是最后一次的梯度算出来对应的是最后那批数据看起来能跑结果就是不对劲属于典型的黑匣子问题。我在trainer.py里加了个断言forward之后置一个标志位backward里检查标志位并清零多花两行代码能挡掉一整类静默错误。从那以后我每次改完网络结构都会先拿十条样本跑一遍单步前向和反向把dW1的数值和数值梯度对一下误差在1e-7量级才往下走。这套土办法救过我很多次比等训练跑完看指标靠谱得多。希望帮到你。本文还有配套的精品资源点击获取