简介:基于Python和BP神经网络实现鸢尾花分类的完整项目,包含源码与文档说明,专为人工智能课程设计、期末大作业以及刚接触神经网络的开发者准备。项目实现了从数据预处理、模型训练到分类预测的完整流程,代码注释详细,逻辑清晰,新手也能快速上手并部署使用。
包内共15个文件,包括8个CSV格式的数据集(原始数据与训练/测试集)、6个Python源码文件(覆盖BPNN、KNN、决策树及聚类等不同算法实现)、1个Markdown说明文档,压缩包仅24KB,轻量易下载。
目前已有244人学习过该资源,适合用于Python实践、机器学习入门及期末项目参考。通过该项目,读者可以掌握BP神经网络的构建方法、数据划分与精度评估技巧,同时还能对比BPNN、KNN等不同分类算法在鸢尾花数据集上的效果,是一份高性价比的实战资源。
1. 人工智能项目实践里的第一个“坑”:鸢尾花分类为什么都拿BP神经网络练手
先给结论:鸢尾花分类几乎是BP神经网络入门里“性价比”最高的一个项目。数据集只有150条样本、4个特征、3个类别,不用折腾图像预处理,不用设计复杂的网络结构,CPU上几秒钟就能迭代完一轮。但它把BP神经网络最关键的东西全包含了:输入层怎么定、输出层怎么编码、隐藏层层数和节点数怎么拍、激活函数和损失函数怎么配、训练集和测试集怎么划分才不算作弊。很多人在人工智能项目实践里第一次翻车,不是死在模型理论上,而是死在数据没洗干净、标签编码错了、归一化忘了做这类基础操作上。这篇就按我自己做这套项目的顺序,把BP神经网络实现鸢尾花分类的完整思路、可复现代码和几个必踩的坑讲清楚。
2. 从鸢尾花数据集到BP神经网络:先搞懂这四层映射关系
2.1 鸢尾花数据集的结构:150行数据到底在表达什么
鸢尾花数据集是机器学习里最经典的“玩具数据集”,但玩具不等于没用。它包含3个品种:Setosa、Versicolour、Virginica,每个品种50条样本,总共150条。每条样本有4个特征:花萼长度、花萼宽度、花瓣长度、花瓣宽度,单位是厘米。这四个特征都是连续数值,范围大致在0.1到7.9之间,量纲不同,所以后面必须做归一化。
在动手写BP神经网络之前,先要建立“数据长什么样”的直觉。常见做法是直接用sklearn自带的load_iris()接口加载,不需要自己下载CSV。但为了看清内部结构,我会先把数据转成DataFrame看一眼:
from sklearn.datasets import load_iris import pandas as pd iris = load_iris() df = pd.DataFrame(iris.data, columns=iris.feature_names) df['target'] = iris.target # 看前5行和前5条target print(df.head()) print(iris.target[:5])这里iris.data是形状为(150, 4)的二维数组,iris.target是形状为(150,)的一维数组,取值0、1、2分别对应三个品种。iris.target_names可以拿到品种名字:['setosa', 'versicolor', 'virginica']。这个细节很重要:BP神经网络的输出层如果是3个节点,那么标签必须编码成“一个节点为1、其余为0”的形式,而不是直接用0、1、2当目标值。
提示:很多人刚开始会把target直接当回归值丢进网络,比如让输出层输出一个0到2之间的数,这是错的。分类任务的输出层必须用one-hot编码,配合softmax激活函数输出概率分布。
2.2 输入层、隐藏层、输出层的维度是怎么定下来的
BP神经网络的层数设计是这个项目里第一个需要拍脑袋的地方。输入层节点数由特征维度决定,鸢尾花是4个特征,所以输入层就是4个节点。输出层节点数由类别数决定,3个品种就是3个节点。中间隐藏层没有唯一正确答案,常见做法是先定1层隐藏层、每层4到8个节点,跑通了再调。
为什么这么定?因为150条样本、4个特征的数据量级,网络容量不需要太大。隐藏层节点数太多容易过拟合,太少拟合能力不足。我一般会用“输入节点数 + 输出节点数”除以2再取整,作为隐藏层节点数的起点,也就是(4 + 3) / 2 ≈ 3到4个。但是经验上,鸢尾花数据集用4到8个隐藏节点都能得到不错的结果,这个项目里网络结构不是瓶颈,数据预处理和训练参数才是。
从数学角度看,BP神经网络的每一层做的事情是:output = activation(input @ W + b)。输入层到隐藏层是一个线性变换加非线性激活,隐藏层到输出层是另一个线性变换加激活。鸢尾花数据集本身是线性可分的吗?不是。Setosa这个品种和另外两个品种在特征空间里分得很开,但Versicolour和Virginica在花瓣长度、花瓣宽度上有明显重叠,所以需要隐藏层的非线性变换把特征空间扭曲一下,才能分得开。
2.3 正向传播和反向传播在这个项目里各自承担什么角色
正向传播就是数据从输入层流到输出层的过程,每一步计算中间结果。反向传播是根据输出层的误差,从后往前逐层更新权重。对于新手来说,不需要自己从零推导矩阵求导,但至少要明白:每次迭代分两步,前向算预测、反向算梯度、然后用梯度下降更新权重。
在鸢尾花项目里,一次完整的前向传播是这样:输入(1, 4)的样本,经过隐藏层权重矩阵(4, 4)得到(1, 4)的中间结果,再过激活函数;然后经过输出层权重矩阵(4, 3)得到(1, 3)的原始分数,再过softmax得到三个类别的概率。反向传播时,损失函数用交叉熵,梯度从输出层往隐藏层传。整个过程如果用手写循环实现,代码量大约60到100行;如果用框架,十几行就够。
这个项目用哪种方式做?我的建议是:如果你是在做人工智能项目实践、需要交源码和文档说明,那手写一个简单的BP神经网络类,比直接调sklearn的MLPClassifier更有展示价值。因为手写代码能清楚看到权重更新公式、学习率、迭代次数这些核心参数,答辩时也讲得出东西。如果只是自己验证效果,那就直接用框架或sklearn,快很多。
3. 手写BP神经网络实现鸢尾花分类:源码逐段拆解与参数说明
3.1 数据预处理:归一化、one-hot编码、划分训练测试集
预处理这一步决定了后面训练能不能收敛。鸢尾花四个特征的单位都是厘米,但花萼长度范围是4.3到7.9,花瓣宽度范围是0.1到2.5,如果不做归一化,梯度下降时数值范围大的特征会主导权重更新,导致收敛慢甚至震荡。常见做法是使用MinMax归一化,把每个特征缩放到0到1之间。
import numpy as np def minmax_normalize(X): # X: (n_samples, n_features) min_vals = X.min(axis=0) max_vals = X.max(axis=0) return (X - min_vals) / (max_vals - min_vals) def one_hot_encode(y, num_classes): # y: (n_samples,), 每个元素是0/1/2 n = y.shape[0] one_hot = np.zeros((n, num_classes)) one_hot[np.arange(n), y] = 1 return one_hot iris = load_iris() X = iris.data y = iris.target X_norm = minmax_normalize(X) y_onehot = one_hot_encode(y, 3)这里minmax_normalize返回的数组形状保持(150, 4),one_hot_encode返回(150, 3)。如果某个特征的最大值等于最小值,除零会出问题,但鸢尾花数据里没有这种情况。划分训练集和测试集时,我建议用sklearn的train_test_split,设置stratify=y保证三个类别在训练集和测试集里的比例一样。150条样本,常见比例是7:3或8:2,105条训练、45条测试。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X_norm, y_onehot, test_size=0.3, stratify=y, random_state=42 )random_state=42不是玄学,是为了让结果可复现。没有这个参数,每次运行划分结果都不一样,后面对比实验就说不清楚是模型变好了还是数据划分变了。
3.2 BP神经网络类的实现:初始化、前向、反向、训练循环
手写BP网络时,最清晰的写法是封装一个类。初始化阶段随机初始化权重,备份一份中间变量用于反向传播。下面这版是我的常用模板,结构简单,适合展示也适合改参数。
class BPNeuralNetwork: def __init__(self, n_input, n_hidden, n_output, learning_rate=0.1): # 权重和偏置:小随机数初始化,避免对称性问题 self.W1 = np.random.randn(n_input, n_hidden) * 0.5 self.b1 = np.zeros((1, n_hidden)) self.W2 = np.random.randn(n_hidden, n_output) * 0.5 self.b2 = np.zeros((1, n_output)) self.lr = learning_rate def sigmoid(self, x): return 1 / (1 + np.exp(-x)) def softmax(self, x): exp_x = np.exp(x - np.max(x, axis=1, keepdims=True)) return exp_x / exp_x.sum(axis=1, keepdims=True) def forward(self, X): # 隐藏层:线性变换 + sigmoid self.z1 = X @ self.W1 + self.b1 self.a1 = self.sigmoid(self.z1) # 输出层:线性变换 + softmax self.z2 = self.a1 @ self.W2 + self.b2 self.a2 = self.softmax(self.z2) return self.a2 def backward(self, X, y_true, y_pred): m = X.shape[0] # 输出层误差:交叉熵 + softmax 的梯度化简结果 delta2 = (y_pred - y_true) / m # 隐藏层误差 delta1 = delta2 @ self.W2.T * self.a1 * (1 - self.a1) # 更新参数 self.W2 -= self.lr * (self.a1.T @ delta2) self.b2 -= self.lr * delta2.sum(axis=0, keepdims=True) self.W1 -= self.lr * (X.T @ delta1) self.b1 -= self.lr * delta1.sum(axis=0, keepdims=True) def train(self, X, y_true, epochs): for epoch in range(epochs): y_pred = self.forward(X) self.backward(X, y_true, y_pred) if (epoch + 1) % 50 == 0: loss = -np.sum(y_true * np.log(y_pred + 1e-8)) / X.shape[0] print(f"epoch {epoch+1}, loss: {loss:.4f}") def predict(self, X): prob = self.forward(X) return np.argmax(prob, axis=1)这段代码里几个关键点:权重初始化为np.random.randn(...) * 0.5,是让初始值保持在0附近但打破对称性,对称初始化会导致每层节点学成一样的东西;隐藏层激活函数用sigmoid,输出层用softmax;反向传播里delta2 = (y_pred - y_true) / m这一行是交叉熵损失对输出层线性输出求梯度的化简结果,不需要手动算sigmoid导数,这是数学上的化简;1e-8加在log里防止出现log(0)。
训练时有个细节:m是样本数,因为用全量梯度下降,所以梯度要除以m。如果改用小批量,m就是batch size。学习率0.1起步,loss下降太慢就调大到0.3或0.5,震荡就调小到0.01。
3.3 训练与评估:准确率、混淆矩阵、可视化验证
训练完直接看loss和准确率。准确率的计算要先把one-hot标签转回类别索引,再和预测结果对比:
# 训练 model = BPNeuralNetwork(n_input=4, n_hidden=5, n_output=3, learning_rate=0.1) model.train(X_train, y_train, epochs=500) # 评估 y_pred_train = model.predict(X_train) y_pred_test = model.predict(X_test) y_true_train = np.argmax(y_train, axis=1) y_true_test = np.argmax(y_test, axis=1) train_acc = np.mean(y_pred_train == y_true_train) test_acc = np.mean(y_pred_test == y_true_test) print(f"训练集准确率: {train_acc:.4f}") print(f"测试集准确率: {test_acc:.4f}")我跑这组参数时,训练集准确率通常在0.96以上,测试集在0.93到0.98之间。如果测试集掉到0.9以下,优先检查三件事:归一化有没有对测试集单独做、学习率是不是太大导致震荡、隐藏层节点数是不是太多导致过拟合。
混淆矩阵可以更细地看错在哪两个类别:
from sklearn.metrics import confusion_matrix, classification_report cm = confusion_matrix(y_true_test, y_pred_test) print(cm) print(classification_report(y_true_test, y_pred_test, target_names=iris.target_names))鸢尾花数据集最容易混淆的是versicolor和virginica,setosa几乎100%分对。所以如果你看到混淆矩阵里这两个品种互相分错,说明模型已经达到了这个数据集上“合理”的表现水平,不要纠结:这两个品种本来在特征空间里就是重叠的,150条样本里即使人类专家也有分不清的边界。
4. 用PyTorch快速复现同一套方案:对比确认手写版本的正确性
4.1 搭建一个两层神经网络:nn.Linear + 激活函数
手写版本的好处是看得见梯度流动,但坏处是数值稳定性要自己操心。为了验证手写代码没有写错,我会用PyTorch写一个结构完全相同的网络来对照。两个版本在相同数据划分下的准确率应该接近,如果手写版本差很多,那一定是反向传播里某个公式写错了。
import torch import torch.nn as nn import torch.optim as optim class IrisNet(nn.Module): def __init__(self, n_hidden=5): super().__init__() self.fc1 = nn.Linear(4, n_hidden) self.fc2 = nn.Linear(n_hidden, 3) def forward(self, x): x = torch.sigmoid(self.fc1(x)) x = self.fc2(x) # 交叉熵损失内部自带softmax return x model_torch = IrisNet(n_hidden=5) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model_torch.parameters(), lr=0.1)这里nn.CrossEntropyLoss内部已经把softmax和交叉熵合并了,所以最后一层直接输出原始分数即可。注意一个关键差异:手写版本里我们自己做one-hot编码,但PyTorch的分类损失函数期望输入是类别索引,也就是0、1、2这样的整数标签,所以训练数据要用y_train_idx而不是y_train_onehot。
4.2 训练循环与结果对照
训练循环就是标准PyTorch三板斧:前向、算loss、反向、更新。
X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(np.argmax(y_train, axis=1), dtype=torch.long) X_test_t = torch.tensor(X_test, dtype=torch.float32) y_test_t = torch.tensor(np.argmax(y_test, axis=1), dtype=torch.long) epochs = 500 for epoch in range(epochs): optimizer.zero_grad() outputs = model_torch(X_train_t) loss = criterion(outputs, y_train_t) loss.backward() optimizer.step() if (epoch + 1) % 50 == 0: print(f"epoch {epoch+1}, loss: {loss.item():.4f}") with torch.no_grad(): pred_test = torch.argmax(model_torch(X_test_t), dim=1).numpy() test_acc_torch = np.mean(pred_test == y_true_test) print(f"PyTorch测试集准确率: {test_acc_torch:.4f}")我跑下来的对照结果是:手写版本和PyTorch版本在相同初始化种子下准确率几乎一样,手写版本loss收敛曲线略慢一点,原因是手写的梯度更新是原始的SGD,没有带动量。如果手写版本用learning_rate=0.1训练500轮后loss还在0.3以上,最可能的错误是反向传播时矩阵乘法的方向搞反了:self.a1.T @ delta2得到的是(隐藏层节点数, 输出层节点数)的形状,如果转置写反,权重更新的维度就对不上,程序会直接报错或者loss乱跳。遇到这种情况,用print(self.W2.shape)核对每一步输出形状是最快的排查方式。
5. 避坑指南:BP神经网络实现鸢尾花分类的5个常见问题与排查
5.1 归一化作用到测试集:信息泄露的隐蔽翻车点
现象:训练集准确率很高,测试集准确率却低得离谱,而且每次跑结果波动很大。
原因:最常见的做法是在train_test_split之前对整个X做归一化,这本身没错。错的是有人先切分,再分别对训练集和测试集独立调用minmax_normalize,导致训练集的min/max和测试集的min/max不同,测试数据的分布被人为改变了。更隐蔽的错误是手动实现归一化时拿整个数据集算min/max,切分后测试集里其实已经包含了训练集的分布信息,这属于轻微的数据泄露。
解决:先切分,再用训练集的min和max去变换测试集。写成代码就是:
min_vals = X_train.min(axis=0) max_vals = X_train.max(axis=0) X_train_norm = (X_train - min_vals) / (max_vals - min_vals) X_test_norm = (X_test - min_vals) / (max_vals - min_vals)sklearn里的MinMaxScaler封装了这一步:fit在训练集上调用,transform在训练和测试集上分别调用。
5.2 权重全零初始化的害处:为什么loss几乎不动
现象:神经网络训练了几百轮,loss一直在0.7到1.1附近,准确率在33%上下浮动,和随机猜没区别。
原因:如果把W1和W2初始化为np.zeros,那么前向传播时隐藏层所有节点的输出完全一样,反向传播时这些节点收到的梯度也完全一样,于是它们始终在学同一个东西,网络退化成一个只有单个隐藏节点的模型。这就是对称性问题。
解决:用np.random.randn乘一个小系数,比如0.1或0.5,保证初始权重不同。如果用了全零初始化,不用怀疑代码逻辑,直接改成随机初始化,loss马上就会开始下降。
5.3 学习率设置不当:loss值变成nan
现象:训练到某个epoch,loss突然变成nan,或者直接从第一轮就开始跳。
原因:学习率太大,比如设成1.0或更大,权重一步更新跨越太多,导致某些中间值经过sigmoid时进入饱和区,梯度趋近于0,或者softmax的指数运算溢出,计算得到inf,之后所有梯度都变成nan。
解决:先把学习率设为0.01,如果loss下降太慢,再按0.1、0.3这样往上试。同时可以在softmax里减掉输入最大值提升数值稳定性。代码里np.exp(x - np.max(x, axis=1, keepdims=True))这一行不是可选的,是防止溢出的必需品。
5.4 训练集和测试集的标签顺序没对齐
现象:训练时loss正常下降,但测试准确率始终在50%到60%之间,怎么调参都没用。
原因:我遇到过最隐蔽的一次,是数据处理时把X做了shuffle,但y没有跟着一起shuffle,导致训练时特征和标签是错配的。还有一次是one-hot编码时用了循环赋值,数组索引写错,把第2类的标签写成了第1类。
解决:训练之前用print(X_train.shape, y_train.shape, y_train[:5])核对形状和内容。稳妥做法是始终用np.random.seed(0)固定随机种子,或者直接用sklearn的train_test_split同时切分X和y,不要自己写shuffle。
5.5 隐藏层节点数拍脑袋:太少了欠拟合,太多了过拟合
现象:隐藏层设成2个节点时,训练准确率只有85%左右,测试准确率也上不去;设成50个节点时,训练准确率能到99%,测试准确率反而降到93%以下。
原因:2个节点对非线性变换的表达能力不够,鸢尾花数据的决策边界需要一定容量的网络才能拟合;50个节点则是把150条样本的特征细节全背下来了,包括噪声,泛化能力下降。
解决:在这个数据集上,从4到8个节点开始调,重点看测试集准确率而不是训练集准确率。找到一个“训练集和测试集准确率都不错且差距不大”的点,就是合适的容量。
6. 把项目做成能交的作业:从源码到文档说明的进阶整理
6.1 结果再多展示一步:绘制决策边界和loss曲线
只给准确率数字,作业和演示都很单薄。多画两张图,一张是损失曲线,一张是决策边界,整个项目的完整性立刻上一个台阶。
import matplotlib.pyplot as plt # 记录训练过程中的loss losses = [] for epoch in range(epochs): y_pred = model.forward(X_train) model.backward(X_train, y_train, y_pred) loss = -np.sum(y_train * np.log(y_pred + 1e-8)) / X_train.shape[0] losses.append(loss) plt.plot(range(1, epochs + 1), losses) plt.xlabel('epoch') plt.ylabel('loss') plt.title('BP Neural Network Training Loss') plt.show()画决策边界时,因为鸢尾花有4个特征,不能直接画二维图,所以通常固定另外两个特征为平均值,只取两个特征做可视化。比如固定花萼宽度和花瓣宽度为均值,用花萼长度和花瓣长度画网格。
def plot_decision_boundary(model, X_data, y_data, feature_idx=(0, 2)): x_min, x_max = X_data[:, feature_idx[0]].min() - 0.1, X_data[:, feature_idx[0]].max() + 0.1 y_min, y_max = X_data[:, feature_idx[1]].min() - 0.1, X_data[:, feature_idx[1]].max() + 0.1 xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200)) # 构造完整特征网格:其余特征用训练集均值填充 grid = np.zeros((xx.size, 4)) grid[:, feature_idx[0]] = xx.ravel() grid[:, feature_idx[1]] = yy.ravel() for i in range(4): if i not in feature_idx: grid[:, i] = X_train[:, i].mean() Z = model.predict(grid) Z = Z.reshape(xx.shape) plt.contourf(xx, yy, Z, alpha=0.6, cmap='coolwarm') plt.scatter(X_data[:, feature_idx[0]], X_data[:, feature_idx[1]], c=y_data, edgecolor='k') plt.xlabel(iris.feature_names[feature_idx[0]]) plt.ylabel(iris.feature_names[feature_idx[1]]) plt.title('BP Neural Network Decision Boundary') plt.show()这个函数能直观展示网络学到的决策边界是曲线而不是直线,这正是BP神经网络相对线性模型的核心价值。
6.2 文档说明里最该写清楚的三个问题
交源码和文档时,导师或评审最常问的三个问题:为什么隐藏层选这个节点数、为什么用sigmoid不用ReLU、为什么用交叉熵不用均方误差。这三个问题如果能在文档里提前回答,就是加分项。
隐藏层节点数:鸢尾花4个输入特征,类别3个,用5个隐藏节点时模型已经有足够能力拟合非线性边界。节点数再多,在150条样本上容易过拟合。
激活函数:sigmoid是BP神经网络最初设计时的标准选择,输出值在0到1之间,适合概率语义。ReLU在现代深度网络里更常用,但在这种小规模全连接网络上优势不明显,而且ReLU在反向传播时可能出现“死亡神经元”,对新手排查不友好。
损失函数:交叉熵适合分类任务,因为它的梯度在预测错误时更大,预测正确时更小,配合softmax是标准组合。均方误差也可以用于分类,但训练速度明显更慢,因为它的梯度会在softmax饱和区变平。想验证这一点,可以对比两组训练loss曲线,一组用交叉熵、一组用MSE,epoch数相同,MSE的收敛速度肉眼可见地慢。
6.3 给后续做人工智能项目实践的几点习惯
这个项目做完,最大的收获不是“我会跑BP神经网络了”,而是建立一套排查流程:数据先看形状和分布、归一化只fit训练集、权重避免全零初始化、学习率从0.01开始调、loss不降先查前向传播再查反向传播。这套流程在之后做图像分类、文本分类时同样用得上。我自己后来做其他分类项目,遇到模型不收敛,第一反应永远是先回头检查数据处理逻辑,而不是调模型结构——八成问题都出在前半段。希望这些经验对你有用,照着代码跑一遍,再自己改一改隐藏层节点数、学习率和epoch数,体会会更深,也祝你这次人工智能项目实践顺利。
本文还有配套的精品资源,点击获取