ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MLP实现Iris花卉分类:从全连接网络原理到调参避坑实战

MLP实现Iris花卉分类:从全连接网络原理到调参避坑实战 简介这是一份面向机器学习入门者与Python开发者的全连接神经网络MLP图像分类实战代码包基于Iris花卉数据集使用numpy从零搭建多层全连接网络涵盖前向计算、反向传播、计算图替代softmax函数以及SGDMomentum优化训练代码结构清晰便于理解神经网络核心原理。压缩包共4个文件包括1个Python脚本、2张训练损失曲线图和1份PDF说明文档脚本可直接运行图像用于展示训练收敛过程PDF补充理论讲解与实现细节整体大小仅765KB轻量易用。已有1852人学习下载适合正在学习神经网络反向传播、或希望用纯numpy实现分类任务的读者参考。通过该资源可以快速掌握MLP的完整训练流程并能对照可视化曲线验证优化效果为进一步学习TensorFlow、PyTorch等深度学习框架打下扎实基础。1. Iris 不是图像MLP 做花卉分类的“反常理”起点看到“全连接神经网络(MLP)实现花卉图像分类 Iris 数据集”这个标题很多人的第一反应是:怎么 Iris 数据集也算图像了?严格来说Iris 是一份 150 行的表格数据——4 个数值特征(花萼长宽、花瓣长宽)、3 个类别(Setosa、Versicolor、Virginica),连一个像素都没有。但这个标题恰好点破了一件事:MLP 作为图像分类的“地基”,不一定非要先跑到图片上才能学明白。在 Iris 上把 MLP 的前向传播、反向传播、损失曲线、调参手感全部跑通,再迁移到 CIFAR-10 或你自己采集的花卉照片,才是从业者真正该走的路。这篇文章就是沿着这条路来的。第 2 章先把 MLP 和 Iris 数据结构的匹配关系讲清楚;第 3 章给出两套 Python 实现——sklearn 的 MLPClassifier 和 numpy 手写版;第 4 章讲透 5 个必调参数;第 5 章把我踩过的坑挨个列出来;最后一章教你怎么用交叉验证和学习曲线给模型“验货”。新手照着代码跑能拿到 95% 以上的准确率,熟手能在这里看到分类任务调参的边界条件。2. MLP 分类 Iris 的原理:为什么 4 个特征也能用全连接网络2.1 MLP 的三个组成:输入层、隐藏层、输出层全连接神经网络(MLP)的结构可以用一句话概括:每一层的每个神经元,都和上一层的所有神经元相连。这种“全连接”特性决定了它处理表格数据的天然优势——特征之间只要有线性组合和非线性激活的关系,MLP 就能拟合。以前向传播的视角看,第 l 层的输出 a_l 满足:z_l a_{l-1} · W_l b_la_l activation(z_l)最后一层如果是分类任务,通常接 softmax,输出每个类别的概率。Iris 是 3 分类,所以输出层是 3 个神经元。输入层是 4 个神经元,对应 4 个特征。中间的隐藏层,神经元数量可以自己定——我用(8,)或(8,4)都跑过,效果差异不大。这里的关键认知是:MLP 不需要你去手工设计特征组合。花萼长度和花瓣长度的比值这种人工特征,在隐藏层里会被自动组合出来。对 Iris 这种线性可分性很强的数据,一个隐藏层 8 个神经元就够用了。反而把隐藏层堆到 256 个神经元,会带来过拟合——150 条样本根本喂不饱那么多参数。2.2 Iris 数据集的结构:150 条样本、4 个特征、3 个类别Iris 数据集之所以成为入门标配,是因为它的“干净”程度几乎不需要预处理。我用 pandas 读进来之后,常规检查无非是看有没有缺失值、特征量纲差异大不大。特征含义取值范围sepal length花萼长度(cm)4.3 ~ 7.9sepal width花萼宽度(cm)2.0 ~ 4.4petal length花瓣长度(cm)1.0 ~ 6.9petal width花瓣宽度(cm)0.1 ~ 2.5标签一共 3 类,每类恰好 50 条。重点在于:花瓣长度和花瓣宽度这两个特征,对 Setosa 类几乎是“一刀切”——Setosa 的花瓣长度永远小于 2cm。所以即便不做任何标准化,直接喂给 MLP,准确率也能到 90% 以上。但如果不标准化就调参,loss 曲线会给你一种“模型在震荡”的错觉,这一点我放到第 5 章的避坑里细说。至于“图像分类”这个说法,我一般会跟读者澄清:Iris 跑通 MLP 之后,把这个代码里的输入维度从 4 改成图片的像素数,把标签从 3 类改成你自己的类别数,模型主体不需要动。真正的图像分类任务里,MLP 的短板会很快暴露——它参数量太大、没有平移不变性,但这不影响你用它先把分类流程跑通。3. 用 Python 跑通 Iris 分类:sklearn 和 numpy 两套代码3.1 环境准备:安装 numpy 与 scikit-learn开发环境这一步卡住的人比想象中多。我自己的习惯是:不要先装 Python 再配环境,直接用 Anaconda 或 Miniconda 建一个独立环境,避免把系统 Python 搞乱。vscode 或 pycharm 里配置 python 环境时,关键是让 IDE 选中你 conda 环境里的解释器,而不是系统自带的那个。conda create -n iris-mlp python3.10 conda activate iris-mlp pip install numpy scikit-learn matplotlib这里解释一下三个库的分工。numpy 负责数组运算,手写 MLP 时矩阵乘法、ReLU 激活、softmax 都要靠它;scikit-learn 提供数据集、切分函数、MLPClassifier 和交叉验证工具;matplotlib 用来画损失曲线和准确率对比图。装完之后在 Python 里执行import sklearn; print(sklearn.__version__)验证一次,能输出版本号就说明环境没问题。3.2 方案 A:sklearn 的 MLPClassifier 最小实现框架自带的实现最大的价值是省心——反向传播、梯度下降、学习率衰减全都封装好了。Iris 这种规模的数据,几十行代码就能跑出结果。from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neural_network import MLPClassifier from sklearn.metrics import accuracy_score, classification_report data load_iris() X, y data.data, data.target # 按类别比例切分,保证测试集里三类都齐全 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 标准化:让每个特征均值为0、方差为1 scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test) # 8个神经元的隐藏层 ReLU Adam优化器 mlp MLPClassifier( hidden_layer_sizes(8,), activationrelu, solveradam, max_iter500, random_state42 ) mlp.fit(X_train, y_train) y_pred mlp.predict(X_test) print(准确率:, accuracy_score(y_test, y_pred)) print(classification_report(y_test, y_pred, target_namesdata.target_names))这段代码里有两个细节值得注意。第一个是stratifyy,它保证切分后训练集和测试集里 Setosa、Versicolor、Virginica 的比例都是 1:1:1。如果不加这个参数,在 Iris 这种每类只有 50 条的小数据集上,有一定概率测试集里缺掉一整类,后面我会专门讲这个坑。第二个细节是scaler.fit_transform(X_train)和scaler.transform(X_test)分开写——fit 只能作用在训练集上,测试集用训练集的均值和方差做变换。如果你对测试集也调用 fit_transform,就相当于测试集的信息泄漏进了模型,交叉验证的分数会虚高。3.3 方案 B:用 numpy 手写一个前向传播与反向传播如果只用 sklearn,你对 MLP 的理解会停留在“调包”层面。手写一遍才能看到每个参数更新时的真实梯度长什么样。我给出了一个可运行的 numpy 版本,隐藏层 8 个神经元,ReLU 激活,softmax 输出,交叉熵损失。import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder data load_iris() X, y data.data, data.target X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) # 手写标准化 mean X_train.mean(axis0) std X_train.std(axis0) X_train (X_train - mean) / std X_test (X_test - mean) / std # 独热编码:标签变成 [1,0,0] 这种形式 enc OneHotEncoder(sparse_outputFalse) y_train_oh enc.fit_transform(y_train.reshape(-1, 1)) y_test_oh enc.fit_transform(y_test.reshape(-1, 1)) input_dim 4 hidden_dim 8 output_dim 3 lr 0.1 # 权重初始化:小随机数打破对称性 W1 np.random.randn(input_dim, hidden_dim) * 0.1 b1 np.zeros(hidden_dim) W2 np.random.randn(hidden_dim, output_dim) * 0.1 b2 np.zeros(output_dim) def relu(x): return np.maximum(0, x) def softmax(x): # 减去最大值防止指数溢出 exp_x np.exp(x - x.max(axis1, keepdimsTrue)) return exp_x / exp_x.sum(axis1, keepdimsTrue) for epoch in range(1000): # 前向传播 z1 X_train.dot(W1) b1 a1 relu(z1) z2 a1.dot(W2) b2 probs softmax(z2) # 交叉熵损失 loss -np.mean(np.log(probs[range(len(X_train)), y_train])) if epoch % 100 0: print(fepoch {epoch}, loss {loss:.4f}) # 反向传播 m len(X_train) dz2 probs - y_train_oh dW2 a1.T.dot(dz2) / m db2 dz2.sum(axis0) / m da1 dz2.dot(W2.T) dz1 da1 * (z1 0) # ReLU的导数:大于0时为1,否则为0 dW1 X_train.T.dot(dz1) / m db1 dz1.sum(axis0) / m # 参数更新 W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2 # 测试集评估 z1 X_test.dot(W1) b1 a1 relu(z1) z2 a1.dot(W2) b2 probs softmax(z2) y_pred np.argmax(probs, axis1) acc np.mean(y_pred y_test) print(f测试准确率: {acc:.4f})反向传播里最容易写错的是dz1 da1 * (z1 0)这一步。ReLU 的导数在输入大于 0 时是 1,小于等于 0 时是 0,所以直接用(z1 0)这个布尔矩阵做乘法就行。我第一次手写时忘了乘这个导数,结果 loss 确实在下降,但降到 0.6 左右就卡住不动了——因为梯度传不回去。损失函数用的交叉熵,配合 softmax 输出层有一个非常好的性质:反向传播时dz2 probs - y_train_oh,这个形式极其简洁。如果换成 MSE 损失,这个式子会变成dz2 (probs - y_train_oh) * probs * (1 - probs),多出一截导数项,训练速度会明显变慢,分类准确率也更低。这也是分类任务优先用交叉熵的原因。4. 必调的 5 个参数:从学习率到隐藏层神经元数4.1 学习率与 max_iter:不收敛先看这两个learning_rate_init默认是 0.001,max_iter默认是 200。这两个参数是连在一起看的:前者决定每一步走多远,后者决定走多少步。Iris 数据量小,200 步通常够,但如果你把隐藏层加深到(16, 8),收敛速度会变慢,200 步可能 loss 还没降到底。我一般会把max_iter调大到 500 或 1000,同时打开early_stoppingTrue,让模型在验证集上连续多次没有提升时自动停下来。这样既不会因为迭代次数不足而欠拟合,也不会因为迭代太久而过拟合。sklearn 的 MLPClassifier 里n_iter_no_change10控制“连续 10 次没有改善就停止”,这个默认值在 Iris 上表现稳定。至于学习率,0.001 偏保守,0.01 在 Iris 上可以接受,0.1 就容易出现 loss 震荡。如果你看到 loss 曲线像锯齿一样上上下下,优先把学习率降一个数量级,而不是去调整网络结构。4.2 隐藏层宽度与层数:8 个神经元为什么比 100 个更合适隐藏层设计是这个项目里最像“玄学”的部分,但它背后有清晰的边界条件。Iris 是 4 维输入、3 类输出、150 条样本。一个(8,)的隐藏层,参数量是 4×8 8 8×3 3 67 个参数。训练样本数 120,参数和样本的比例接近 1:2,这个模型是“瘦”的,不容易过拟合。如果把隐藏层改成(256,),参数量变成 4×256 256 256×3 3 1283 个参数。120 条训练样本去拟合 1283 个参数,测试准确率反而可能下降——模型把训练集的噪声也记下来了。我在 Iris 上做过对比:(8,)的测试准确率约 96%,(256,)反而降到 92% 左右。深层网络在这个数据集上也没有优势。(8, 4) 两层隐藏层比(8,) 单层略好一点,但差距在 1% 以内,不值得为此增加调参复杂度。如果你后续迁移到图像分类,那时候再考虑加深网络——图像数据量大,参数多不是问题。4.3 用 GridSearchCV 搜一组可用参数手动调参容易陷入“调了学习率忘了隐藏层”的混乱。我习惯用网格搜索把几个关键参数一次性跑完,让结果说话。from sklearn.model_selection import GridSearchCV, train_test_split from sklearn.neural_network import MLPClassifier from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.datasets import load_iris X, y load_iris(return_X_yTrue) param_grid { mlpclassifier__hidden_layer_sizes: [(4,), (8,), (16,), (8, 4)], mlpclassifier__activation: [relu, tanh], mlpclassifier__learning_rate_init: [0.001, 0.01, 0.1], mlpclassifier__alpha: [0.0001, 0.001] } pipe make_pipeline(StandardScaler(), MLPClassifier(max_iter1000, random_state42)) grid GridSearchCV(pipe, param_grid, cv5, scoringaccuracy, n_jobs-1) grid.fit(X, y) print(最佳参数:, grid.best_params_) print(最佳交叉验证准确率:, grid.best_score_)注意param_grid里的键名是mlpclassifier__hidden_layer_sizes——因为用了 make_pipeline,参数名前面要加mlpclassifier__前缀。这个细节漏掉的话,GridSearchCV 会直接报错说参数不存在。我跑出来的最佳组合通常是(8,)relu0.01alpha0.001,交叉验证准确率在 97% 左右。alpha 是 L2 正则化系数,默认 0.0001 在 Iris 上已经够用,强行调大反而会压制模型的表达能力。5. 避坑记录:Iris 分类最容易翻车的 5 个现场5.1 不标准化,loss 曲线一直震荡现象:loss 曲线不是平滑下降,而是上下跳动,1000 步之后准确率还在 85% 上下徘徊。原因:Iris 的 4 个特征量纲不同。花瓣长度范围是 1.0~6.9,花萼宽度范围是 2.0~4.4,数值大的特征在梯度计算中占主导地位,参数更新方向被它带着跑,模型在特征尺度不平衡的情况下很难稳定收敛。解决:在训练前用 StandardScaler 做标准化。这一行代码能顶得上你调半天的学习率。自己手写代码时,标准化要按训练集的均值和方差做,测试集不能重新计算,否则会造成数据泄漏。5.2 把 Iris 当成图像,硬上 CNN 和 Transformer现象:网上搜“图像分类模型”,看到 CNN、Vision Transformer 效果好,就把 Iris 数据 reshape 成 1×4 的“图像”塞给卷积网络,结果准确率还不如线性回归。原因:Iris 是 4 维表格数据,没有空间结构。CNN 的卷积核假设相邻像素有局部相关性,这在 4 个独立特征上不成立;Transformer 的位置编码和自注意力机制优势也发挥不出来。用复杂模型处理简单数据,只会引入大量无关参数。解决:表格分类用 MLP 或传统机器学习(逻辑回归、随机森林)就够了。等真正做花卉图像分类——比如自己拍照片、用公开的花卉图片数据集——再切到 CNN。MLP 在 Iris 上跑通的价值,是帮你理解分类任务的完整流程,而不是让你在 Iris 上比模型复杂度。5.3 random_state 不固定:同一份代码两次结果不同现象:同样一份代码,第一次跑准确率 97%,第二次变成 93%,以为是模型有 bug。原因:MLPClassifier 的权重是随机初始化的,如果没有固定随机种子,每次训练从不同的起点出发,最终收敛到的局部最优也不一样。Iris 数据量小,这种随机性带来的波动尤其明显——150 条样本里差 2 条,就是 1.3% 的准确率差异。解决:在 MLPClassifier 和 train_test_split 里都设置random_state42。这样别人复现你的结果时,跑出来的数字和你完全一致。注意切分和模型两处都要固定,只固定一个的话,切分不同或者初始权重不同,结果还是会对不上。5.4 测试集里缺了一整类现象:测试集上准确率看着挺高,但打印 classification_report 发现 Virginica 那一类的 precision 是 0,召回率也是 0。原因:train_test_split 默认是随机切分,不保证每类在训练集和测试集中的比例。Iris 每类只有 50 条,随机切 20% 当测试集时,有一定概率某一类在测试集里一条都没有。模型在训练时见过这一类,但测试时没机会预测它,报告里自然全是 0。解决:切分时加stratifyy,按类别比例分层采样。这个参数我几乎在所有的分类项目里都会带上,尤其是在类别不均衡的数据集上——它保证训练集和测试集的类别分布基本一致,评估结果才有意义。5.5 max_iter 用默认值,模型根本没收敛现象:训练结束后打印 loss 曲线,发现它还处于陡降阶段就被截断了。测试集准确率比交叉验证结果低 5 个百分点以上。原因:MLPClassifier 的max_iter默认是 200。Iris 数据简单,200 步接近收敛,但如果你改了激活函数为 tanh,或者加了隐藏层层数,收敛速度会变慢,200 步不够用。解决:把max_iter设成 1000,配合early_stoppingTrue。让模型自己判断什么时候该停,不要拍脑袋定迭代次数。看一眼mlp.loss_curve_的长度,如果恰好等于 max_iter,说明是撞到上限停下来的,不是自然收敛。6. 验证模型真实水平:交叉验证与学习曲线单次切分得到的准确率只能说明“这一次运气不错”。我习惯用StratifiedKFold交叉验证再验一遍,把 5 次的结果都打出来看均值。from sklearn.model_selection import StratifiedKFold, cross_val_score from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.neural_network import MLPClassifier from sklearn.datasets import load_iris X, y load_iris(return_X_yTrue) pipe make_pipeline( StandardScaler(), MLPClassifier(hidden_layer_sizes(8,), max_iter1000, random_state42) ) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(pipe, X, y, cvskf, scoringaccuracy) print(每折准确率:, scores) print(均值:, scores.mean(), 标准差:, scores.std())交叉验证的标准差是判断模型稳定性的关键指标。如果 5 折准确率分别是 0.97、0.93、0.97、0.97、0.97,标准差约 0.02,说明模型对数据划分不敏感。如果出现 0.87、1.00、0.93、0.97、0.87 这种大起大落,说明模型在某个子集上过拟合了,优先检查隐藏层宽度是不是太大。最后说一个我自己的习惯:每次训练完先把mlp.loss_curve_画出来,做一次“裸眼验收”。loss 曲线单调下降且末尾趋于平缓,说明学习率合适、迭代充分;曲线末端还在快速下降,说明欠拟合,加大训练轮数或增加隐藏层宽度;曲线训练 loss 很低但测试准确率上不去,说明过拟合,加正则化或减小网络。import matplotlib.pyplot as plt plt.plot(mlp.loss_curve_) plt.xlabel(iteration) plt.ylabel(loss) plt.title(MLP training loss curve) plt.show()这些技巧用在 Iris 上可能看起来“杀鸡用牛刀”,但你在真实图像分类任务里遇到的第一批问题——loss 震荡、精度波动、切分不平衡——几乎都是在这里先暴露的。把 Iris 这条线走完,再上手森林图像分类、花卉图片分类那些动辄上万张图片的数据集时,你至少知道先从哪看起。希望帮到你。本文还有配套的精品资源点击获取
返回列表