从零手写BP神经网络:鸢尾花与红酒数据集分类实战

📅 发布时间:2026/9/26 12:26:12
从零手写BP神经网络:鸢尾花与红酒数据集分类实战
简介这份资源面向机器学习入门与课程实验场景围绕BP神经网络模型展开帮助读者完成鸢尾花与红酒数据集的多分类任务适合正在学习神经网络基础、需要提交课程作业或复现实验流程的学生与自学者。压缩包共18个文件约630KB包含py与ipynb源码脚本、xls与xlsx数据集、doc与pptx实验文档、xml与iml工程配置等覆盖从数据读取、模型搭建到训练评估的完整环节。资源中提供了鸢尾花与红酒两个经典数据集的分类实现并配有实验说明文档与演示课件便于对照理解网络结构、参数设置与结果分析。目前已有1034人学习下载可作为课程实验的参考模板也可用于快速上手BP算法、调试代码与整理实验报告。1. 从零手写 BP 神经网络鸢尾花与红酒数据集分类的完整落地路径很多人第一次接触 BP 神经网络都是从一个课程作业开始的给定鸢尾花和红酒两个经典数据集要求不调库、手写前向传播和反向传播最后跑出分类准确率。听起来像是走个过场但真正动手才会发现坑远比想象中多——梯度消失、学习率震荡、标签编码搞错、特征量纲不统一任何一个都能让准确率卡在 33% 不动。这篇笔记就围绕「BP神经网络模型实现对鸢尾花、红酒数据集分类」这个具体任务把网络结构设计、参数初始化、训练循环、评估指标这几件事拆开讲清楚。适合正在做实验课作业的学生也适合想从调包侠回到原理层面、亲手推一遍反向传播的从业者。读完你至少能拿到一份可复现的代码骨架知道每个超参数为什么这样设以及出问题时该往哪个方向排查。2. BP 神经网络的核心原理与两个数据集的选型理由2.1 反向传播到底在算什么链式法则的工程化表达BP 神经网络的核心就一句话用链式法则把损失函数对每个权重的偏导数算出来然后沿负梯度方向更新权重。但落到代码里这件事被拆成了前向缓存、反向求导、参数更新三个步骤。前向传播时每一层的输入 $z^{(l)} W^{(l)} a^{(l-1)} b^{(l)}$激活后得到 $a^{(l)} \sigma(z^{(l)})$。反向传播时先算输出层误差 $\delta^{(L)} \nabla_a C \odot \sigma(z^{(L)})$再逐层往前推 $\delta^{(l)} ((W^{(l1)})^T \delta^{(l1)}) \odot \sigma(z^{(l)})$。最后权重梯度就是 $\partial C / \partial W^{(l)} \delta^{(l)} (a^{(l-1)})^T$。这套公式看着吓人但工程实现时只需要记住一个原则前向传播时把每层的激活值缓存下来反向传播时直接拿来用。很多人手写时翻车就是因为前向算完没存中间结果反向时又重新算一遍不仅慢还容易在维度上对不齐。我一般会在每层里维护一个 cache 字典存input、z、activation三个值反向时按逆序取出来用。另一个容易忽略的点是激活函数的选择。输出层用 Softmax 做多分类隐藏层用 ReLU 或 Tanh。ReLU 计算快、不容易饱和但要注意死亡神经元问题Tanh 输出零中心收敛更稳但深层网络容易梯度消失。对于鸢尾花和红酒这种小数据集两层隐藏层加 Tanh 就够用没必要上很深的结构。2.2 鸢尾花和红酒数据集一个练手一个验真鸢尾花数据集Iris一共 150 个样本4 个特征3 个类别每类 50 个分布极其均衡。它的好处是干净、小、跑得快适合验证网络结构是否正确。红酒数据集Wine有 178 个样本13 个特征3 个类别特征量纲差异大——有的特征在 0 到 1 之间有的能到几百。这就逼着你必须做标准化否则梯度更新会被大量纲特征主导准确率上不去。选这两个数据集组合本质上是在做对照实验鸢尾花验证「网络能不能学」红酒验证「预处理做没做对」。很多人只跑鸢尾花觉得万事大吉一换红酒就崩问题基本都出在特征缩放上。常见做法是用 Z-score 标准化把每个特征变成均值 0、方差 1 的分布。注意标准化参数必须只在训练集上拟合再应用到测试集否则就是数据泄露。提示红酒数据集的类别标签是 1、2、3不是 0、1、2。做 One-hot 编码前记得先减 1否则会多出一个全零列导致输出层维度对不上。3. 手写 BP 网络的完整实现从数据加载到训练循环3.1 数据预处理与 One-hot 编码的代码实现先解决数据入口问题。鸢尾花和红酒在 scikit-learn 里都有内置加载器但实验课通常要求自己写预处理流程。下面这段代码做了四件事加载数据、划分训练测试集、Z-score 标准化、标签 One-hot 编码。import numpy as np from sklearn.datasets import load_iris, load_wine from sklearn.model_selection import train_test_split def load_and_preprocess(dataset_nameiris, test_size0.2, seed42): if dataset_name iris: data load_iris() elif dataset_name wine: data load_wine() else: raise ValueError(dataset_name must be iris or wine) X, y data.data, data.target # 标签从0开始确保One-hot维度正确 y y - y.min() X_train, X_test, y_train, y_test train_test_split( X, y, test_sizetest_size, random_stateseed, stratifyy ) # Z-score标准化只在训练集上拟合 mean X_train.mean(axis0) std X_train.std(axis0) 1e-8 # 防止除零 X_train (X_train - mean) / std X_test (X_test - mean) / std # One-hot编码 n_classes len(np.unique(y)) y_train_onehot np.eye(n_classes)[y_train] y_test_onehot np.eye(n_classes)[y_test] return X_train, X_test, y_train, y_test, y_train_onehot, y_test_onehot, n_classes逻辑说明stratifyy保证训练集和测试集的类别比例一致小数据集上这点很重要。标准化时std加一个极小值防止某特征方差为零导致除零。One-hot 用np.eye直接索引比循环写更简洁。参数方面test_size0.2是常规选择150 个样本里留 30 个做测试seed固定后结果可复现方便调试。3.2 网络结构定义与前向传播的矩阵化写法网络结构我一般设成[4, 16, 8, 3]对应鸢尾花[13, 32, 16, 3]对应红酒。隐藏层用 Tanh输出层用 Softmax。权重初始化用 Xavier 方法即 $W \sim \mathcal{N}(0, \sqrt{2/(n_{in}n_{out})})$这样能缓解梯度消失。class BPNeuralNetwork: def __init__(self, layer_sizes, seed42): self.layer_sizes layer_sizes self.num_layers len(layer_sizes) - 1 self.params {} np.random.seed(seed) for l in range(1, len(layer_sizes)): n_in, n_out layer_sizes[l-1], layer_sizes[l] # Xavier初始化 limit np.sqrt(2.0 / (n_in n_out)) self.params[fW{l}] np.random.randn(n_in, n_out) * limit self.params[fb{l}] np.zeros((1, n_out)) def forward(self, X): cache {A0: X} A X for l in range(1, self.num_layers 1): W self.params[fW{l}] b self.params[fb{l}] Z A W b if l self.num_layers: A np.tanh(Z) # 隐藏层Tanh else: A self.softmax(Z) # 输出层Softmax cache[fZ{l}] Z cache[fA{l}] A return A, cache def softmax(self, Z): Z_shift Z - np.max(Z, axis1, keepdimsTrue) # 数值稳定 exp_Z np.exp(Z_shift) return exp_Z / np.sum(exp_Z, axis1, keepdimsTrue)逻辑说明forward里把每层的Z和A都存进cache反向传播时直接取。Softmax 里减最大值是标准操作防止exp溢出。参数方面layer_sizes第一个元素是特征数最后一个是类别数中间是隐藏层宽度。Xavier 初始化的limit控制权重范围比全零初始化好比随机大数稳定。3.3 反向传播与参数更新的逐行拆解反向传播是整个实现里最容易写错的部分。核心是先把输出层误差算出来再逐层往前推。交叉熵损失配合 Softmax 时输出层误差直接是A_L - Y省去了求导的麻烦。def backward(self, cache, Y_onehot): grads {} m Y_onehot.shape[0] L self.num_layers # 输出层误差Softmax 交叉熵的导数 dZ cache[fA{L}] - Y_onehot for l in range(L, 0, -1): A_prev cache[fA{l-1}] grads[fdW{l}] (A_prev.T dZ) / m grads[fdb{l}] np.sum(dZ, axis0, keepdimsTrue) / m if l 1: W self.params[fW{l}] dA_prev dZ W.T # Tanh导数1 - A^2 dZ dA_prev * (1 - cache[fA{l-1}] ** 2) return grads def update_params(self, grads, lr0.1): for l in range(1, self.num_layers 1): self.params[fW{l}] - lr * grads[fdW{l}] self.params[fb{l}] - lr * grads[fdb{l}]逻辑说明dZ初始为输出层误差循环里先算当前层梯度再往前传。Tanh 的导数用1 - A^2因为A tanh(Z)。除以m是做批量平均避免梯度随样本数增大。参数方面lr是学习率小数据集上 0.1 到 0.5 都常见太大震荡太小收敛慢。3.4 训练循环与准确率评估的完整脚本把上面几块拼起来加上迭代循环和评估逻辑就是一份可跑的完整代码。def train(model, X_train, Y_train_onehot, X_test, y_test, epochs2000, lr0.1, print_every200): losses [] for epoch in range(epochs): # 前向 A_out, cache model.forward(X_train) # 交叉熵损失 m X_train.shape[0] loss -np.sum(Y_train_onehot * np.log(A_out 1e-8)) / m losses.append(loss) # 反向 grads model.backward(cache, Y_train_onehot) model.update_params(grads, lr) if epoch % print_every 0: pred np.argmax(A_out, axis1) acc np.mean(pred np.argmax(Y_train_onehot, axis1)) print(fEpoch {epoch}, Loss: {loss:.4f}, Train Acc: {acc:.4f}) # 测试集评估 A_test, _ model.forward(X_test) test_pred np.argmax(A_test, axis1) test_acc np.mean(test_pred y_test) print(fTest Accuracy: {test_acc:.4f}) return losses, test_acc # 跑鸢尾花 X_train, X_test, y_train, y_test, Y_train_oh, Y_test_oh, n_cls load_and_preprocess(iris) model BPNeuralNetwork([4, 16, 8, 3]) losses, acc train(model, X_train, Y_train_oh, X_test, y_test, epochs2000, lr0.1)逻辑说明损失函数加1e-8防止log(0)。每 200 轮打印一次训练准确率方便观察收敛。测试集只在最后评估一次避免中途调参导致过拟合。参数方面epochs2000对小数据集足够lr0.1是稳妥起点。红酒数据集把layer_sizes换成[13, 32, 16, 3]lr降到 0.05 更稳。4. 避坑与排查手写 BP 网络最常见的五个翻车现场4.1 损失不下降准确率卡在基线现象训练几轮后损失几乎不变准确率等于随机猜测鸢尾花约 33%红酒约 33%。原因通常是学习率太大导致震荡或者权重初始化全部为零导致对称性无法打破。解决先把学习率降到 0.01 试一轮如果损失开始下降说明是学习率问题如果仍然不动检查权重初始化是否用了零矩阵。我一般直接用 Xavier 初始化避免这个玄学问题。4.2 红酒数据集准确率远低于鸢尾花现象鸢尾花能到 95% 以上红酒只有 60% 左右。原因基本是特征没做标准化大量纲特征主导了梯度方向。解决确认标准化只在训练集上拟合再应用到测试集。检查std是否加了极小值防止某特征方差为零。另外红酒的类别标签是 1、2、3记得减 1 再做 One-hot。4.3 训练损失下降但测试准确率不升现象训练集准确率冲到 99%测试集只有 70%。原因是过拟合网络容量相对数据量太大。解决减少隐藏层神经元数量比如从[13, 32, 16, 3]降到[13, 16, 8, 3]或者加 L2 正则化在损失里加0.001 * sum(W**2)。小数据集上正则化效果很明显。4.4 反向传播维度对不上报错现象ValueError: shapes not aligned。原因是矩阵乘法顺序或转置搞错。解决记住前向是A W反向梯度是A_prev.T dZ往前传是dZ W.T。把每层维度打印出来对一遍W是(n_in, n_out)dZ是(m, n_out)dW是(n_in, n_out)。4.5 Softmax 溢出导致 loss 变成 nan现象训练几轮后损失突然变成nan。原因是exp计算时数值过大溢出。解决Softmax 里先减每行最大值即Z - np.max(Z, axis1, keepdimsTrue)。这个操作不改变输出结果但能保证数值稳定。另外学习率太大也会导致权重爆炸配合降低学习率一起用。5. 进阶技巧用早停和混淆矩阵把实验做扎实训练循环跑通只是第一步要让实验课报告有说服力还得加上早停和混淆矩阵。早停的逻辑很简单每轮在验证集上算一次损失如果连续 N 轮不下降就停止训练并回滚到最佳参数。这样能防止过拟合也能省时间。def train_with_early_stopping(model, X_train, Y_train_oh, X_val, y_val, epochs5000, lr0.1, patience200): best_loss float(inf) best_params None wait 0 for epoch in range(epochs): A_out, cache model.forward(X_train) m X_train.shape[0] loss -np.sum(Y_train_oh * np.log(A_out 1e-8)) / m grads model.backward(cache, Y_train_oh) model.update_params(grads, lr) # 验证集损失 A_val, _ model.forward(X_val) val_loss -np.sum(np.eye(3)[y_val] * np.log(A_val 1e-8)) / len(y_val) if val_loss best_loss: best_loss val_loss best_params {k: v.copy() for k, v in model.params.items()} wait 0 else: wait 1 if wait patience: print(fEarly stop at epoch {epoch}) break model.params best_params return model逻辑说明patience200表示验证损失连续 200 轮不降就停。best_params用深拷贝保存避免后续更新覆盖。参数方面验证集可以从训练集里再切 20%或者直接用测试集但只用于早停不用于调参。混淆矩阵用sklearn.metrics.confusion_matrix一行就能出但手写也不难。重点看每一类的召回率如果某一类特别低说明网络对该类特征学得不好可能需要增加该类样本或调整类别权重。我习惯在实验报告里同时放准确率和混淆矩阵准确率看整体混淆矩阵看短板。最后说个血泪经验随机种子一定要固定否则每次跑出来的结果都不一样调参时根本分不清是参数起作用还是运气好。我一般会在代码开头写np.random.seed(42)和random.seed(42)保证结果可复现。另外别一上来就堆很深网络小数据集上两层隐藏层足够层数多了反而难调。希望帮到你。本文还有配套的精品资源点击获取