资讯动态

NumPy手写BP神经网络:Iris分类PyCharm工程与实验报告

发布时间:2026/11/6 5:32:31 来源:尧图企业网站定制
简介这份面向机器学习初学者与课程实验学习者的BP神经网络实践包围绕Iris鸢尾花数据集的三分类任务展开提供可直接在PyCharm中运行的Python实现与配套实验文档。压缩包共4个文件、约342KB包含2个py脚本、1份docx实验报告和1个csv数据文件分别对应网络代码、实验过程记录与原始数据便于对照阅读和复现。已有671人学习下载。内容覆盖数据预处理、输入/隐藏/输出层结构设定、权重初始化、前向传播、误差计算、反向传播、循环训练及准确率等指标评估并讨论学习率、迭代次数和网络结构对分类性能的影响。读者可据此理解BP神经网络处理非线性分类问题的完整流程也能借助实验报告梳理参数设置、结果分析与优化思路适合课程设计、实验复现和入门练手。1. 从一份 rar 说起BP 神经网络做 iris 分类到底能练到什么把 iris 三分类当成 BP 神经网络的练手题是很多人绕不开的一站数据干净、特征只有四维、标签三类一个 4-8-3 的全连接网络跑上几百轮测试集准确率通常能到 95% 以上。这份 PyCharm 工程把整条链路装进了一个 rar——数据加载、标准化、one-hot 编码、前向传播、反向传播、训练循环、损失曲线、混淆矩阵再加一份结构完整的实验报告。它适合两类人刚学完链式求导、想看看公式怎么落成 NumPy 矩阵运算的新手以及要给课程设计或实验课找一套能跑通、能改、结构清楚的基线代码的开发者。网络是自己写的梯度是自己推的正因为没有调包改起来才知道每一步到底在动什么。2. 工程骨架一份 PyCharm 工程里每个文件到底管什么2.1 解压之后的目录结构压缩包解开第一眼看到的往往是一个带.idea的 PyCharm 工程目录。.idea是编辑器自己的配置解释器路径、运行配置、代码风格跟程序逻辑无关换个机器打开会重新生成删掉也不影响运行。真正要关心的是下面这几层bp-iris/ ├── data/ │ └── iris.csv # 150 行 × 5 列前 4 列特征第 5 列是类别名 ├── src/ │ ├── config.py # 超参集中管理隐藏层宽度、学习率、轮数、batch │ ├── dataset.py # 加载 / 标签编码 / one-hot / 标准化 / 划分 / batch │ ├── model.py # BPNet 类初始化、forward、backward、step │ ├── train.py # 训练循环、日志打印、loss 记录 │ └── evaluate.py # 准确率、混淆矩阵、precision/recall/F1 ├── outputs/ │ ├── loss_curve.png # 训练损失与验证准确率曲线 │ ├── confusion_matrix.png # 3×3 混淆矩阵热图 │ └── metrics.txt # 最终指标落盘方便抄进报告 ├── main.py # 唯一入口把上面这些串成一条流水线 ├── report/ │ └── 实验报告.docx # 网络结构、公式推导、超参表、结果分析 └── requirements.txt这个拆法不是唯一的但方向是对的把「数据怎么来」「模型算什么」「训练怎么跑」「结果怎么评」切成四块而不是全部塞进一个main.py。对新手来说单文件看着省事可一旦实验报告要求你对比三组超参、换两种初始化单文件就会迅速膨胀成五百行改一个变量要在里面翻三遍。分层之后超参对比只需要复制config.py改几行其余不动。outputs/这一层容易被忽略但它其实是实验报告的弹药库。跑完一次训练曲线图、混淆矩阵、指标文本自动落盘写报告时直接拖进 Word不用再手动截图。2.2 环境怎么搭解释器、依赖与运行配置Python 版本建议 3.9 到 3.11太老的版本np.random.default_rng的某些用法会有差异太新的版本又可能遇到 matplotlib 后端的问题。依赖不多requirements.txt里常见的是这四行numpy1.23 pandas1.5 matplotlib3.6 scikit-learn1.2 # 仅用于 train_test_split 和作为对照基线不参与网络实现注意最后一行注释——这个工程里 sklearn 的定位是「对照组」不是「实现体」。网络的前向反向全部由 NumPy 完成sklearn 只负责切分数据以及在报告里跑一个MLPClassifier做性能参照。把这条边界划清楚报告的「自实现 vs 调包」对比才有意义。PyCharm 里最容易翻车的一步是解释器配置。新建工程时如果选了「New environment using Virtualenv」会在工程目录下生成.venv如果选了「Previously configured interpreter」用的是系统全局 Python。后者看着省事装依赖时容易污染全局环境换台机器复现就麻烦了。我一般强制走虚拟环境装完依赖后确认一下python -c import numpy, pandas, matplotlib, sklearn; print(numpy.__version__)能打印出版本号说明解释器选对了。如果 PyCharm 右下角显示的解释器和终端里which python的结果不一致那就是典型的「编辑器里能跑、终端里报 ModuleNotFoundError」血泪经验是先在 PyCharm 的 Settings → Project → Python Interpreter 里确认路径再把这个路径下的 python 直接拿去跑脚本。还有一个运行配置的细节main.py里读data/iris.csv用的是相对路径PyCharm 默认的 Working directory 是工程根目录所以能读到。但如果你在终端里cd src python ../main.py工作目录变了相对路径就失效。稳妥的写法是用pathlib基于脚本位置拼绝对路径这个后面在避坑那章会展开。2.3 为什么不用 sklearn 一把梭sklearn.neural_network.MLPClassifier三行代码就能出结果为什么还要手写因为这个项目的核心产出是实验报告报告里需要网络结构图、需要前向反向的公式推导、需要说清楚「梯度是怎么算出来的」。调包的话这一段只能写「调用了 MLPClassifier」评分和答辩都撑不住。手写带来的另一个好处是可改。隐藏层宽度、激活函数、损失函数、优化器、初始化方式全都暴露在你自己的代码里想换 ReLU 为 tanh 就是改一行想加动量就是多存一个速度项。这种「可拆解性」是调包给不了的。代价是要自己处理形状对齐、数值稳定性、梯度检查这些琐碎事而这恰恰是这个工程最值得读的部分。3. 数据管线从 iris.csv 到可以喂进网络的矩阵3.1 加载与标签编码字符串标签怎么变成 0/1/2原始iris.csv长这样四列浮点特征花萼长度、花萼宽度、花瓣长度、花瓣宽度最后一列是字符串类别名三种各 50 条。网络只认数字所以第一步是把类别名映射成整数再转 one-hot。# src/dataset.py import numpy as np import pandas as pd from sklearn.model_selection import train_test_split CLASS_NAMES [setosa, versicolor, virginica] def load_iris_csv(path): df pd.read_csv(path, headerNone) X df.iloc[:, :4].to_numpy(dtypenp.float64) y_str df.iloc[:, 4].astype(str).str.strip() # 显式映射不用 factorize保证类别序号和 CLASS_NAMES 严格对应 mapping {name: i for i, name in enumerate(CLASS_NAMES)} y y_str.map(mapping).to_numpy(dtypenp.int64) if np.isnan(y).any(): raise ValueError(f存在未知类别标签: {set(y_str) - set(mapping)}) y_onehot np.eye(len(CLASS_NAMES))[y] # (150, 3) return X, y, y_onehot这里有个关键选择用显式的mapping而不是pd.factorize或astype(category).cat.codes。后两者是按字符串字典序编码的setosa / versicolor / virginica恰好字典序和语义序一致看起来没问题但一旦换成别的数据集、或者 CSV 里类别名拼写有大小写差异编码顺序就会悄悄变化。混淆矩阵的行列顺序依赖这个映射错位之后所有指标都是错的而代码不会报错——这是最难查的一类坑。y保留整数形式用于最后的argmax对比和混淆矩阵y_onehot用于计算交叉熵两份都留着避免在不同地方反复转换。3.2 标准化均值方差只能从训练集算iris 的四个特征量纲差异明显花萼长度在 4.3~7.9花瓣宽度在 0.1~2.5。不标准化直接送进网络梯度下降会在不同方向上被拉伸收敛慢且对学习率极其敏感。标准化的做法很朴素——减均值除标准差但均值方差必须只从训练集统计def standardize(train_X, test_X): mu train_X.mean(axis0, keepdimsTrue) # (1, 4) sigma train_X.std(axis0, keepdimsTrue) # (1, 4) sigma[sigma 0] 1.0 # 防除零 return (train_X - mu) / sigma, (test_X - mu) / sigma, mu, sigma参数说明keepdimsTrue让均值保持(1, 4)形状可以直接和(n, 4)广播省掉一次reshape。sigma的除零保护是防御性写法iris 里用不上但换成常数列特征时能救命。用测试集统计量、或者全量数据统计量做标准化属于数据泄露——测试集的分布信息提前泄露进了训练过程验证准确率会虚高几个点而且这种虚高在换数据集时不会复现非常具有欺骗性。正确顺序永远是先切分再只用训练集拟合标准化参数然后 transform 测试集。3.3 划分与小批量迭代器def prepare(path, seed42, test_size0.2): X, y, y_onehot load_iris_csv(path) X_tr, X_te, y_tr, y_te, oh_tr, oh_te train_test_split( X, y, y_onehot, test_sizetest_size, random_stateseed, stratifyy ) X_tr, X_te, mu, sigma standardize(X_tr, X_te) return dict(X_trX_tr, y_try_tr, oh_troh_tr, X_teX_te, y_tey_te, oh_teoh_te, mumu, sigmasigma)stratifyy保证训练集和测试集里三类比例都是 1:1:1。iris 每类只有 50 条不分层的话随机切分出 40% 测试集时某一类可能只进来十几条指标波动会很大。150 条样本的规模下这个参数属于必开。小批量迭代器不难写但写不好会埋雷def iterate_batches(n_samples, batch_size, rng): idx rng.permutation(n_samples) # 每轮重新打乱 for start in range(0, n_samples, batch_size): yield idx[start:start batch_size]rng是外部传进来的np.random.default_rng(seed)实例而不是全局的np.random。用独立的 Generator 对象好处是打乱顺序只受这个 seed 控制不会因为别处调用了np.random.rand()而改变——这一点直接决定了实验能不能复现后面避坑那章会细说。最后一批不足batch_size时直接切到末尾形状是(余数, 4)矩阵运算天然支持不需要额外补零。4. 网络核心前向、反向、训练循环的矩阵实现4.1 参数初始化与前向传播的形状推演网络结构定成 4-8-3输入 4 维特征隐藏层 8 个神经元配 ReLU输出 3 类配 softmax。参数一共四个矩阵/向量形状必须一次想清楚否则后面反向传播会连环报错。# src/model.py import numpy as np class BPNet: def __init__(self, n_in4, n_hidden8, n_out3, seed42): rng np.random.default_rng(seed) # He 初始化适配 ReLU方差 2/fan_in self.W1 rng.normal(0.0, np.sqrt(2.0 / n_in), (n_in, n_hidden)) self.b1 np.zeros((1, n_hidden)) self.W2 rng.normal(0.0, np.sqrt(2.0 / n_hidden), (n_hidden, n_out)) self.b2 np.zeros((1, n_out)) def forward(self, X): z1 X self.W1 self.b1 # (m,4)(4,8)(1,8) - (m,8) a1 np.maximum(0.0, z1) # ReLU z2 a1 self.W2 self.b2 # (m,8)(8,3)(1,3) - (m,3) z2 z2 - z2.max(axis1, keepdimsTrue) # softmax 数值稳定 exp np.exp(z2) probs exp / exp.sum(axis1, keepdimsTrue) # (m,3) return z1, a1, probs形状推演X是(m, 4)W1是(4, 8)乘完是(m, 8)加偏置(1, 8)靠广播对齐a1 W2得到(m, 3)正好每个样本三个类别的分数。偏置用(1, n)而不是(n,)是为了让广播规则永远成立减少一类「为什么加出来维度不对」的困惑。两个数值细节值得说初始化用 He 而不是全零或标准正态。全零初始化会让同一层所有神经元梯度完全相同训练完还是对称的等于只有 1 个神经元在干活标准正态方差 1在 4 维输入下会让z1的方差偏大ReLU 打死一片。He 初始化按sqrt(2/fan_in)缩放正好把前向方差稳定在 1 附近。softmax 前的减最大值操作是纯数值技巧。exp在输入较大时会上溢成infinf/inf得到nanloss 直接变 NaN。减去每行最大值不改变 softmax 结果分子分母同乘一个常数但把指数范围压到 0 以下杜绝上溢。4.2 反向传播从 softmax 交叉熵 到 dW1反向这一段是很多人的卡点其实推导清楚了就是一个链式法则的机械展开。softmax 配交叉熵的联合梯度有一个非常干净的结果dL/dz2 (probs - y_onehot) / m。这个结论值得记住它省掉了对 softmax 雅可比矩阵的显式求导。def loss(self, probs, y_onehot): m probs.shape[0] eps 1e-12 ce -np.sum(y_onehot * np.log(probs eps)) / m return ce def backward(self, X, y_onehot, cache): z1, a1, probs cache m X.shape[0] dz2 (probs - y_onehot) / m # (m,3) softmaxCE 的联合梯度 dW2 a1.T dz2 # (8,m)(m,3) - (8,3) db2 dz2.sum(axis0, keepdimsTrue) # (1,3) da1 dz2 self.W2.T # (m,3)(3,8) - (m,8) dz1 da1 * (z1 0.0) # ReLU 导数正区间为 1 dW1 X.T dz1 # (4,m)(m,8) - (4,8) db1 dz1.sum(axis0, keepdimsTrue) # (1,8) return {W1: dW1, b1: db1, W2: dW2, b2: db2} def step(self, grads, lr): self.W1 - lr * grads[W1] self.b1 - lr * grads[b1] self.W2 - lr * grads[W2] self.b2 - lr * grads[b2]几个参数的含义要交代清楚m是当前 batch 的样本数除以它意味着损失取的是 batch 内的平均值这样学习率不会随 batch 大小漂移——batch 从 16 改成 32梯度尺度不变。db2用sum(axis0)而不是mean是因为/m已经在dz2里做过了再平均一次会让梯度缩小m倍表现为「学习率明明设的 0.1 却几乎不更新」这个坑很隐蔽。dz1 da1 * (z1 0.0)里的(z1 0.0)是布尔数组和浮点相乘时自动转成 0/1正好是 ReLU 的导数。注意必须用z1而不是a1判断虽然在这两个都大于 0 时结果一样但语义上导数是在激活前的值上取的。形状自检可以写成一个断言放在backward开头出问题时第一时间定位def _check_shapes(self, grads): assert grads[W1].shape self.W1.shape, dW1 形状不匹配 assert grads[W2].shape self.W2.shape, dW2 形状不匹配 assert grads[b1].shape self.b1.shape, db1 形状不匹配 assert grads[b2].shape self.b2.shape, db2 形状不匹配4.3 训练循环与评估loss 曲线、混淆矩阵与指标落盘训练循环的骨架很标准每轮打乱、按 batch 前向反向更新、轮末在验证集上算一次指标。# src/train.py import numpy as np def train(model, data, epochs1000, lr0.1, batch_size16, seed42): rng np.random.default_rng(seed) X_tr, oh_tr data[X_tr], data[oh_tr] X_te, y_te data[X_te], data[y_te] history {loss: [], train_acc: [], val_acc: []} for epoch in range(epochs): idx rng.permutation(X_tr.shape[0]) epoch_loss 0.0 for start in range(0, len(idx), batch_size): b idx[start:start batch_size] cache model.forward(X_tr[b]) epoch_loss model.loss(cache[2], oh_tr[b]) * len(b) grads model.backward(X_tr[b], oh_tr[b], cache) model.step(grads, lr) history[loss].append(epoch_loss / len(idx)) history[train_acc].append(accuracy(model, X_tr, data[y_tr])) history[val_acc].append(accuracy(model, X_te, y_te)) if (epoch 1) % 100 0: print(fepoch {epoch1:4d} | loss {history[loss][-1]:.4f} f| train {history[train_acc][-1]:.4f} f| val {history[val_acc][-1]:.4f}) return historyepoch_loss累加时乘了len(b)最后除以总样本数这样最后一个不满 batch 的小批不会因为权重过大而扭曲本轮平均损失。日志每 100 轮打一次太密会刷屏太疏又看不出收敛趋势。评估部分给一个手写的混淆矩阵不依赖 sklearn 的confusion_matrixdef confusion_matrix(y_true, y_pred, n_class3): cm np.zeros((n_class, n_class), dtypenp.int64) for t, p in zip(y_true, y_pred): cm[t, p] 1 return cm def accuracy(model, X, y): probs model.forward(X)[2] return float((probs.argmax(axis1) y).mean())混淆矩阵的行是真实类别、列是预测类别cm[i][j]表示「真实是 i、被预测成 j」的样本数。对角线是分对的非对角线看错到哪儿去了——iris 里常见的错法是 versicolor 和 virginica 互相串因为这两个类在特征空间里本来就有重叠setosa 基本是线性可分的几乎不会错。把这个观察写进实验报告比只贴一个 96.7% 的准确率有说服力得多。指标文件metrics.txt里建议落这几项最终训练/测试准确率、每个类别的 precision/recall/F1、混淆矩阵、总训练耗时。用np.savetxt或者直接f.write都行关键是格式固定多组实验之间能直接对比。5. 避坑与常见问题排查五个把准确率按在地上摩擦的细节5.1 随机性与数据泄露相关的坑现象一同一份代码连跑三次准确率分别是 93.3%、96.7%、90.0%实验报告不知道该写哪个数。原因是工程里混用了全局随机源。train_test_split有自己的random_state参数初始化用了np.random.randnbatch 打乱用了np.random.shuffle——三处都受全局状态影响只要有一处没固定种子整条链路就不可复现。更麻烦的是np.random.seed(42)在 import 了别的库之后调用某些库在 import 时已经消耗过随机状态结果依然会变。解决办法是把随机源全部显式化。切分用random_stateseed初始化和打乱各自用独立的np.random.default_rng(seed)并且每个需要随机的地方传不同的 seed 派生值比如default_rng(seed)和default_rng(seed 1)避免两条流产生相同的序列。固定之后跑三次结果必须完全一致不一致就说明还有漏网的全局随机调用。现象二测试集准确率冲到 99%换一组数据或者多跑几轮就掉回 95%报告里的结论站不住。按经验八成是标准化或者特征选择把测试集信息泄露进来了。典型写法是先对全部 150 条样本算均值和标准差再切分训练测试——测试集的分布信息参与了训练前的预处理模型在测试集上表现虚高。类别不平衡时重采样过采样/欠采样做在切分之前也是同样的错误。正确的顺序是「先切分再拟合预处理参数最后 transform」。标准化参数只用训练集算测试集调用同一个mu/sigma如果做交叉验证每一折内部都要重新拟合一次不能全量拟合后复用。现象三混淆矩阵看着不对劲某一类的召回率是 0但模型明明在训练集上准确率很高。标签编码顺序和CLASS_NAMES列表不一致。用astype(category).cat.codes时编码按字典序来用pd.factorize时按出现顺序来手动map时按你写的字典来。三种方式在 iris 上恰好都能对但换一份类别名不同的 CSV、或者某一行末尾多了个空格顺序就变了。变完之后 one-hot 的顺序、混淆矩阵的轴、argmax的结果三者不同步指标全乱而代码不会抛异常。解决办法是显式映射加校验就是 3.1 里那段代码先定义CLASS_NAMES常量用字典映射映射后检查有没有 NaN发现未知类别直接抛错。同时把CLASS_NAMES传给画混淆矩阵的函数当轴标签保证图和编码同源。5.2 训练过程与工程配置相关的坑现象四loss 在前几十轮剧烈震荡甚至出现 NaN训练完全跑不下去。三个常见诱因按排查顺序来学习率太大0.1 在标准化之后通常没问题不标准化时 0.1 就可能炸softmax 没做减最大值导致exp上溢标签里混进了越界值导致np.eye(3)[y]报 IndexError或者 loss 计算出log(0)。处理方式先确认输入已经标准化均值为 0、方差为 1softmax 里加上z2 - z2.max(axis1, keepdimsTrue)loss 里给log加1e-12的平滑项。如果还是 NaN把学习率降到 0.01 观察同时打印第一个 batch 的probs和grads的范数——梯度范数在 1e3 以上就是明显偏大问题在尺度上。现象五PyCharm 里点运行一切正常命令行跑或者换台机器就报FileNotFoundError: data/iris.csv。相对路径依赖工作目录。PyCharm 的运行配置默认把 Working directory 设成工程根目录所以pd.read_csv(data/iris.csv)能找到终端里从src/目录启动工作目录变成了src/路径就断了。换机器时如果目录层级不同同样会挂。解决办法是用pathlib基于文件位置拼路径让读数据不依赖启动目录from pathlib import Path ROOT Path(__file__).resolve().parent.parent # 从 src/ 回到工程根 IRIS_PATH ROOT / data / iris.csv OUTPUT_DIR ROOT / outputs OUTPUT_DIR.mkdir(exist_okTrue) # 输出目录不存在就建省得手动 mkdir这个写法在 PyCharm、终端、Jupyter 里行为一致是我现在写任何脚本的默认姿势。顺带把outputs/的创建也放进来避免第一次跑的时候因为目录不存在而savefig失败——这种错误通常出现在深夜赶报告的时候代价是一个小时。6. 二次开发把这套基线改成能写进简历的实验平台跑通只是起点这个工程真正的价值在于它足够小小到你可以放心地拆。第一步我会把超参从config.py里彻底外置成命令行参数用argparse或者一个experiment.yaml这样可以写一个 shell 循环跑网格for lr in 0.01 0.05 0.1; do for h in 4 8 16; do python main.py --lr $lr --hidden $h --epochs 1000 --tag lr${lr}_h${h} done done每个 tag 对应一个独立的outputs/{tag}/目录loss 曲线和指标各存一份最后一并读进 pandas 画对比图。这一步做完报告里的「超参对收敛速度的影响」就是真数据而不是编的。第二步是加早停和学习率衰减这两个改动小、收益直观。早停盯验证集 loss连续 30 轮不降就break同时把验证 loss 最低那一轮的参数存下来当最终模型——别用最后一轮的参数震荡时最后一步未必最好。学习率衰减简单点就用lr * 0.95 ** (epoch // 100)复杂点换成余弦退火效果差异在 iris 这种小数据上不明显但写进报告里是个加分项。改造方向改动位置预期收益早停 最优权重保存train.py训练循环避免过拟合末期掉点Adam 优化器model.py新增step_adam对学习率不敏感收敛更快K 折交叉验证main.py外层150 条样本下指标更可信特征标准化方式对比dataset.py报告里多一组分析维度第三步是拿sklearn.MLPClassifier做严格对照同样的划分、同样的隐藏层、同样的轮数把两边的准确率和训练耗时放进一张表。常见的结果是自实现版本慢一些但可控调包版本快但黑盒。这个对比写进报告比单方面吹手写实现客观得多。改的时候有个习惯我一直保持任何一次结构调整先在不改逻辑的前提下跑一遍原版记下基线指标改完再跑一遍两次的数字必须能对上。对不上就是引入 bug 了而不是「模型效果变好了」。在那之前我吃过亏——调了半天超参发现准确率提升是因为数据划分的随机种子变了白忙一个下午。从那以后我每次动代码前都强制走一遍基线复现把数字贴在便签上。希望这套东西能帮你少走两段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑