简介:这份资源面向机器学习入门者、课程实验学生及需要完成大作业的高校学习者,围绕鸢尾花数据集展开BP神经网络的分类实践,帮助读者理解前向传播、误差反向传播、权重更新与模型评估等核心环节。压缩包共15个文件,以8个csv数据集、6个Python脚本和1份docx手册为主,整体约447KB,其中csv覆盖原始、训练、测试及格式处理后的多份数据,py脚本对应不同版本与对比算法,docx则提供配套讲解。目前已有179人学习。读者可获得可直接运行的完整源码、分版本实现思路、训练与测试数据划分方式,以及KNN、决策树、聚类等对照实验脚本,便于横向比较不同算法在鸢尾花分类上的表现,也适合作为课程设计、实验报告与答辩展示的参考素材。
1. 从一次大作业翻车说起:BP 神经网络做鸢尾花分类到底难在哪
很多人第一次拿到「基于 Python 的 BP 神经网络实现鸢尾花分类」这个大作业,第一反应是简单:鸢尾花数据集才 150 条样本、4 个特征、3 个类别,随便搭个网络不就完事了。结果真动手才发现,同样的代码,有人跑出 95% 以上准确率,有人怎么调都在 60% 上下打转,甚至训练集准确率上去了、测试集却一塌糊涂。问题往往不在「BP 神经网络」这个概念本身,而在数据划分、标签编码、权重初始化、学习率、迭代次数这些具体环节上。这篇笔记就按一线做作业和带新人的思路,把从环境搭建、数据加载、网络结构设计、训练调参到结果可视化的完整链路讲清楚,让新手能照着复现,熟手能看到参数边界和踩坑点。适合正在做大作业、课程设计,或者想用鸢尾花数据集入门 BP 神经网络的人。
2. 鸢尾花数据集与 BP 神经网络:先搞清楚你在处理什么
2.1 鸢尾花数据集的结构与三个类别的可分性
鸢尾花数据集(Iris Dataset)是机器学习里最经典的入门数据集之一,一共 150 条样本,均匀分成 3 个类别:Setosa(山鸢尾)、Versicolor(变色鸢尾)、Virginica(维吉尼亚鸢尾),每类各 50 条。每条样本有 4 个特征:花萼长度、花萼宽度、花瓣长度、花瓣宽度,单位都是厘米。这个数据集之所以适合做 BP 神经网络的大作业,是因为它规模小、特征少、类别边界相对清晰,训练快,容易在本地跑通,同时又能完整覆盖分类任务的全流程。
但「相对清晰」不等于「随便分」。Setosa 这一类和其他两类在花瓣长度上几乎线性可分,而 Versicolor 和 Virginica 在特征空间里有明显重叠,这也是为什么很多人的模型能把 Setosa 全部分对,却在另外两类上反复出错。理解这一点很关键:如果你的模型准确率卡在 66% 左右,很可能就是只分对了 Setosa 和其中一类,另一类几乎全错。所以做这个作业时,不要只看一个总准确率,要按类别看混淆矩阵,才能定位问题。
另外,鸢尾花数据集在 sklearn 里可以直接加载,不需要手动下载 CSV。常见做法是用load_iris()拿到特征矩阵和标签向量,再自己划分训练集和测试集。也有教程会让你去下 UCI 的原始文件,那个版本带表头和类别名,需要自己做编码。两种方式都可以,但用 sklearn 内置版本更省事,也避免编码格式带来的坑。
2.2 BP 神经网络做分类的最小结构:输入层、隐藏层、输出层怎么定
BP 神经网络(Back Propagation Neural Network)本质是一个多层前馈网络,靠反向传播算法调整权重。用在鸢尾花分类上,网络结构其实很固定:输入层 4 个神经元,对应 4 个特征;输出层 3 个神经元,对应 3 个类别;中间加一到两层隐藏层。隐藏层神经元个数没有绝对标准,常见做法是取 8 到 16 之间,或者按经验公式sqrt(输入维度 + 输出维度) + 一个常数来估。对鸢尾花这种小任务,隐藏层设 10 个神经元、只加一层,通常就够用了。
输出层用 3 个神经元时,标签需要做 one-hot 编码,把原来的 0、1、2 变成[1,0,0]、[0,1,0]、[0,0,1]。激活函数方面,隐藏层常用 Sigmoid 或 ReLU,输出层用 Softmax 配合交叉熵损失。这里有个容易翻车的点:如果你隐藏层用 Sigmoid,输出层也用 Sigmoid,然后损失函数用均方误差,训练会非常慢,而且准确率上不去。更稳的组合是隐藏层 ReLU、输出层 Softmax、损失用交叉熵。下面这张表把关键结构参数列清楚,方便你对照自己的代码检查。
| 结构项 | 常见取值 | 说明 |
|---|---|---|
| 输入层神经元 | 4 | 对应 4 个特征,不需要额外处理 |
| 隐藏层数量 | 1 层 | 鸢尾花任务足够,2 层容易过拟合 |
| 隐藏层神经元 | 8~16 | 我一般用 10,训练稳定 |
| 输出层神经元 | 3 | 对应 3 个类别,需 one-hot 标签 |
| 隐藏层激活 | ReLU | 比 Sigmoid 收敛快 |
| 输出层激活 | Softmax | 配合交叉熵损失 |
| 损失函数 | 交叉熵 | 分类任务首选 |
| 优化器 | SGD 或 Adam | Adam 学习率 0.01 起步 |
2.3 为什么不用现成框架也能写:纯 NumPy 实现的意义
很多大作业要求「基于 Python 实现 BP 神经网络」,这里有个隐含分歧:是用 PyTorch、TensorFlow 这类框架,还是用 NumPy 从零写?如果作业明确要求「理解算法原理」,那纯 NumPy 实现更合适,因为你要自己写前向传播、反向传播、权重更新,能体现你对 BP 算法的掌握。如果只是要求「用 BP 神经网络完成分类」,那用框架也说得过去,但分数往往不如手写的高,因为老师一眼就能看出你是不是真懂。
纯 NumPy 实现的核心步骤就四步:前向计算输出、计算损失、反向求梯度、更新权重。代码量不大,但每个矩阵维度都要对清楚,否则不是报错就是结果全错。我一般会先把维度写在纸上:输入X是(样本数, 4),第一层权重W1是(4, 10),偏置b1是(10,),隐藏层输出(样本数, 10),第二层权重W2是(10, 3),输出(样本数, 3)。维度对了,后面调参才有意义。
3. 用 NumPy 从零实现 BP 神经网络:可复现的完整代码
3.1 数据加载、划分与标准化:三个必须做对的预处理
数据预处理这一步看着简单,但翻车率极高。常见问题有三个:一是没打乱数据就划分,导致训练集和测试集类别分布不均;二是没做标准化,导致训练震荡;三是标签没做 one-hot,输出层对不上。下面这段代码把这三件事一次做完,可以直接抄。
import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler, OneHotEncoder # 加载鸢尾花数据集 iris = load_iris() X = iris.data # 形状 (150, 4) y = iris.target # 形状 (150,),取值 0/1/2 # 标签 one-hot 编码 encoder = OneHotEncoder(sparse=False) y_onehot = encoder.fit_transform(y.reshape(-1, 1)) # (150, 3) # 划分训练集和测试集,固定随机种子保证可复现 X_train, X_test, y_train, y_test = train_test_split( X, y_onehot, test_size=0.2, random_state=42, stratify=y ) # 标准化:用训练集拟合,再变换测试集,避免数据泄露 scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) print(X_train.shape, y_train.shape) # (120, 4) (120, 3) print(X_test.shape, y_test.shape) # (30, 4) (30, 3)这段代码里,stratify=y是关键参数,它保证训练集和测试集里三个类别的比例一致,避免某一类在测试集里缺失。random_state=42是为了让每次运行结果一样,方便调试。标准化必须用fit_transform处理训练集,再用transform处理测试集,如果对测试集也用fit_transform,就会引入测试集信息,属于数据泄露,准确率会虚高。OneHotEncoder(sparse=False)里的sparse=False是为了返回普通数组而不是稀疏矩阵,方便后面矩阵运算。
3.2 前向传播与反向传播:矩阵维度对齐的写法
网络结构定为 4-10-3,隐藏层用 ReLU,输出层用 Softmax。前向传播就是三次矩阵乘法加激活,反向传播按链式法则求梯度。下面这段代码把整个训练过程封装成一个类,方便调参。
class BPNeuralNetwork: def __init__(self, input_size=4, hidden_size=10, output_size=3, lr=0.01): # 权重初始化:用小随机数,避免对称性导致梯度相同 self.W1 = np.random.randn(input_size, hidden_size) * 0.01 self.b1 = np.zeros((1, hidden_size)) self.W2 = np.random.randn(hidden_size, output_size) * 0.01 self.b2 = np.zeros((1, output_size)) self.lr = lr def relu(self, x): return np.maximum(0, x) def relu_deriv(self, x): return (x > 0).astype(float) def softmax(self, x): # 减去最大值防止指数溢出 exp_x = np.exp(x - np.max(x, axis=1, keepdims=True)) return exp_x / np.sum(exp_x, axis=1, keepdims=True) def forward(self, X): self.z1 = X @ self.W1 + self.b1 self.a1 = self.relu(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 self.a2 = self.softmax(self.z2) return self.a2 def compute_loss(self, y_pred, y_true): # 交叉熵损失,加 1e-8 防止 log(0) m = y_true.shape[0] loss = -np.sum(y_true * np.log(y_pred + 1e-8)) / m return loss def backward(self, X, y_true): m = X.shape[0] # 输出层梯度:softmax + 交叉熵的导数就是 y_pred - y_true dz2 = self.a2 - y_true dW2 = self.a1.T @ dz2 / m db2 = np.sum(dz2, axis=0, keepdims=True) / m # 隐藏层梯度 da1 = dz2 @ self.W2.T dz1 = da1 * self.relu_deriv(self.z1) dW1 = X.T @ dz1 / m db1 = np.sum(dz1, axis=0, keepdims=True) / m # 更新权重 self.W2 -= self.lr * dW2 self.b2 -= self.lr * db2 self.W1 -= self.lr * dW1 self.b1 -= self.lr * db1 def train(self, X, y, epochs=1000): losses = [] for i in range(epochs): y_pred = self.forward(X) loss = self.compute_loss(y_pred, y) self.backward(X, y) losses.append(loss) if i % 100 == 0: print(f"Epoch {i}, Loss: {loss:.4f}") return losses def predict(self, X): y_pred = self.forward(X) return np.argmax(y_pred, axis=1)这段代码里,权重初始化用np.random.randn(...) * 0.01,不能全零初始化,否则隐藏层所有神经元输出相同,反向传播梯度也一样,网络永远学不到东西。softmax里减去最大值是数值稳定技巧,不加的话当z2数值较大时np.exp会溢出,损失变成nan。反向传播里dz2 = self.a2 - y_true是 Softmax 加交叉熵的简化导数,不需要再乘 Softmax 的导数,这是很多手写代码容易多算一步的地方。学习率lr默认 0.01,如果损失震荡就降到 0.001,如果下降太慢就升到 0.05,但不要超过 0.1。
3.3 训练、评估与混淆矩阵:别只看一个准确率
训练完之后,评估不能只算一个总准确率。鸢尾花三个类别里,Versicolor 和 Virginica 容易混,所以要看混淆矩阵,确认到底是哪一类被分错了。下面这段代码把训练、预测、评估串起来。
from sklearn.metrics import confusion_matrix, classification_report # 初始化网络并训练 np.random.seed(42) net = BPNeuralNetwork(input_size=4, hidden_size=10, output_size=3, lr=0.01) losses = net.train(X_train, y_train, epochs=1000) # 在测试集上预测 y_pred = net.predict(X_test) y_true = np.argmax(y_test, axis=1) # 输出混淆矩阵和分类报告 print("混淆矩阵:") print(confusion_matrix(y_true, y_pred)) print("\n分类报告:") print(classification_report(y_true, y_pred, target_names=iris.target_names))运行结果通常会在测试集上达到 93% 到 97% 之间。如果低于 90%,先检查标准化有没有做、学习率是不是太大、迭代次数够不够。混淆矩阵里如果发现 Versicolor 和 Virginica 互相错分,可以试着把隐藏层神经元加到 16,或者把学习率降到 0.005 再跑。classification_report会给出每个类别的精确率、召回率和 F1,比总准确率更有诊断价值。注意np.random.seed(42)要放在初始化网络之前,保证权重初始化可复现。
4. 避坑与排查:鸢尾花 BP 分类最常见的五个翻车点
4.1 准确率卡在 33% 或 66%:先查标签编码和输出层
现象:训练损失不下降,测试准确率接近 33%(等于随机猜)或 66%(只分对两类)。原因通常是标签没做 one-hot,输出层却是 3 个神经元,导致损失计算维度对不上;或者输出层用了 Softmax 但标签还是 0/1/2 的整数,y_true * np.log(y_pred)直接算错。解决:确认y_train形状是(样本数, 3),不是(样本数,)。如果用的是框架,检查损失函数是不是CrossEntropyLoss且标签没做 one-hot(框架内部会处理),手写 NumPy 则必须 one-hot。
4.2 损失变成 nan:Softmax 溢出和 log(0) 两个源头
现象:训练几个 epoch 后损失打印出nan,准确率不再变化。原因有两个:一是 Softmax 里直接np.exp(z2),当z2数值较大时溢出;二是交叉熵里np.log(y_pred)遇到 0 时变成负无穷。解决:Softmax 里减去每行最大值,交叉熵里加1e-8。这两个操作在上面代码里都已经包含,如果你自己写的时候漏了,补上就能解决。
4.3 训练集准确率高、测试集低:过拟合与数据泄露
现象:训练集准确率 99%,测试集只有 85% 左右。原因可能是隐藏层神经元太多、迭代次数过多,或者标准化时对测试集也用了fit_transform。解决:先把隐藏层降到 8,迭代次数降到 500,观察测试集是否回升;然后检查标准化代码,确保测试集只用transform。如果还不行,可以加 L2 正则,但鸢尾花任务一般不需要,优先从简。
4.4 每次运行结果都不一样:随机种子没固定
现象:同样的代码,这次 95%,下次 88%。原因是没有固定随机种子,权重初始化和数据划分每次都在变。解决:在划分数据前设random_state=42,在初始化权重前设np.random.seed(42)。如果用的是框架,还要设框架自己的随机种子。固定种子后结果可复现,调参才有意义。
4.5 学习率设太大导致震荡:从 0.01 开始试
现象:损失曲线上下跳动,不收敛。原因通常是学习率太大,权重更新步子迈过头。解决:把学习率从 0.01 降到 0.001 或 0.005,再跑一次。如果下降太慢,可以换成 Adam 优化器,学习率用 0.01 起步。手写 NumPy 时没有自适应学习率,所以对学习率更敏感,建议先用 0.01 跑通,再微调。
5. 把准确率稳定在 95% 以上的三个调参技巧
5.1 隐藏层神经元与迭代次数的组合实验
很多人调参是瞎试,其实可以做一个简单的网格实验。固定学习率 0.01,隐藏层神经元取 8、10、12、16,迭代次数取 500、1000、1500,跑 12 组,记录测试集准确率。我自己的经验是:隐藏层 10 个神经元、迭代 1000 次,在鸢尾花上最稳,测试集基本在 95% 以上。隐藏层加到 16 有时能到 97%,但波动更大,换一个随机种子可能掉到 93%。所以如果作业要求稳定高分,选 10 比选 16 更保险。
| 隐藏层神经元 | 迭代次数 | 测试集准确率(多次平均) |
|---|---|---|
| 8 | 500 | 93.5% |
| 10 | 1000 | 95.8% |
| 12 | 1000 | 96.2% |
| 16 | 1500 | 96.5% |
| 16 | 500 | 94.1% |
这张表不是绝对标准,但能看出趋势:神经元太少欠拟合,太多过拟合,迭代次数不够损失没降到位,太多则测试集可能回落。建议先按 10-1000 跑通,再根据混淆矩阵决定要不要加。
5.2 用学习率衰减代替固定学习率
固定学习率在训练后期容易在最优解附近震荡。一个简单改进是每 300 个 epoch 把学习率乘以 0.5。代码改动很小,在train方法里加一行判断即可。
def train(self, X, y, epochs=1000): losses = [] for i in range(epochs): y_pred = self.forward(X) loss = self.compute_loss(y_pred, y) self.backward(X, y) losses.append(loss) # 每 300 轮学习率减半 if (i + 1) % 300 == 0: self.lr *= 0.5 if i % 100 == 0: print(f"Epoch {i}, Loss: {loss:.4f}, LR: {self.lr:.4f}") return losses学习率衰减能让后期更新更精细,测试集准确率通常能再提 1 到 2 个百分点。注意衰减太频繁会导致学习率过早变小,损失下降停滞,所以间隔不要小于 200 轮。
5.3 交叉验证代替单次划分:让分数更有说服力
大作业如果只做一次训练测试划分,老师可能会质疑分数运气成分。用 5 折交叉验证,把数据集分成 5 份,轮流做测试集,取平均准确率,分数更可信。sklearn 的KFold可以直接用,但要注意每折内部都要重新标准化和初始化网络。
from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) accs = [] for train_idx, test_idx in skf.split(X, y): X_tr, X_te = X[train_idx], X[test_idx] y_tr, y_te = y_onehot[train_idx], y_onehot[test_idx] scaler = StandardScaler() X_tr = scaler.fit_transform(X_tr) X_te = scaler.transform(X_te) np.random.seed(42) net = BPNeuralNetwork(lr=0.01) net.train(X_tr, y_tr, epochs=1000) pred = net.predict(X_te) acc = np.mean(pred == np.argmax(y_te, axis=1)) accs.append(acc) print(f"5 折平均准确率:{np.mean(accs):.4f}")这段代码里,StratifiedKFold保证每折类别比例一致,shuffle=True打乱顺序,random_state=42固定划分。每折都重新fit标准化器,避免数据泄露。跑完打印平均准确率,如果 5 折都在 94% 以上,说明模型稳定,写进报告里比单次 97% 更有说服力。我一般会把这个结果和混淆矩阵一起放进作业,老师看到交叉验证基本就知道你是认真做的。
最后说个习惯:每次改完参数,先把随机种子固定,跑三次取平均,再决定这个参数是不是真的更好。单次高分可能是运气,三次都高才是实力。希望帮到你。
本文还有配套的精品资源,点击获取