简介:这是一份面向机器学习初学者、高校学生及神经网络入门者的Python实践资源,围绕鸢尾花(Iris)花卉数据集,使用numpy从零搭建全连接神经网络(MLP)完成图像分类,全程不依赖高级深度学习框架。代码实现步骤清晰:包括前向计算与反向传播的逐行推导,使用计算图技巧替代softmax函数,并采用SGD+Momentum优化器进行训练,便于读者摆脱黑盒,真正理解神经网络的底层计算流程与梯度更新逻辑。资源压缩包内共4个文件,包括1个Python主程序、2张训练损失曲线图以及1份PDF图文笔记,整体仅765KB,轻量易用。目前已有1852人学习下载,代码效果经过验证。借助这份材料,读者可以对照损失曲线观察模型收敛过程,结合PDF笔记梳理网络结构与调参思路,并能将这套numpy实现灵活迁移到其他简单分类任务中。
1. MLP 做 Iris 花卉分类:先泼一盆冷水,这个数据集根本不是图像
先说结论:标题里的“花卉图像分类”和“Iris 数据集”放在一起,本身就有偏差——Iris(鸢尾花)数据集是表格数据,不是图像。它的每一行样本只有 4 个数值特征(花萼长、花萼宽、花瓣长、花瓣宽),目标是区分 3 种鸢尾花。用全连接神经网络(MLP)对它做分类,是入门神经网络最经典的“Hello World”之一,但这个过程不涉及任何图像处理。如果你手里真的有一批花卉照片,那要面对的是图像预处理、卷积神经网络(CNN)或迁移学习,不是 MLP 的主战场。
但话说回来,这个任务依然值得做。MLP 在 Iris 上能帮你把神经网络最核心的几件事全过一遍:数据怎么喂、层怎么搭、激活函数怎么选、损失怎么算、训练到什么程度算好。这篇文章会给你一套完整可跑的 Python 代码,从数据准备到训练评估全覆盖,再告诉你 5 个最常见的翻车点。读者水平定位在“会用 Python 但没正经训练过神经网络”的从业者——无论你是要做分类任务的技术选型,还是想补足 MLP 底层的直觉,这篇文章都够用。
2. 把 MLP 拆开看:两层全连接如何一步步学到 Iris 的分类边界
2.1 全连接层到底在算什么:一个 4 维输入到 3 类输出的映射
MLP(Multilayer Perceptron,多层感知机)的核心结构就是堆叠全连接层。每一层做的事情可以概括为一个线性变换加一个非线性激活函数:输出 = 激活函数(输入 × 权重 + 偏置)。如果去掉激活函数,多层线性变换叠加在一起仍然是一次线性变换,网络再深也只是一个线性模型,学不到非线性边界。Iris 的分类边界虽然不是特别复杂,但 3 类样本在特征空间里并不是严格线性可分的(山鸢尾线性可分,但另外两类有重叠),所以“深度”存在的意义就在这个激活函数上。
Iris 数据集的输入维度是 4(四个特征),输出维度是 3(三种鸢尾花)。一个单隐藏层 MLP 的典型结构就是4 → 隐藏层神经元数 → 3。隐藏层神经元数量的选择是个经验活:太少学不到边界,太多容易过拟合。对 Iris 这种只有 150 个样本的小数据集,8~16 个神经元通常是安全区间。你可以把它理解成一个“压缩再展开”的过程:4 维特征先被映射到一个中间表示,再从这个表示映射到 3 类得分。
正向传播的数学表达式长这样(记输入为 x,第一层权重为 W1,偏置 b1,第二层 W2、b2,激活函数为 σ):
h = σ(x · W1 + b1) 输出 = h · W2 + b2输出层通常不加激活函数,直接产出 3 个实数作为每个类别的“得分”。后续用 Softmax 把得分转成概率,或者直接用交叉熵损失计算误差。所谓“MLP 实现花卉图像分类”这个场景下,本质上就是让这个映射关系去拟合 150 条样本中的规律。
2.2 激活函数和初始化:为什么 ReLU 是默认选项,而 Sigmoid 会让你原地踏步
激活函数的选择直接影响训练速度和最终效果。常见选项有三:
- Sigmoid:输出范围 (0, 1),有饱和区。输入绝对值一大,梯度趋近于 0,网络几乎学不动。Iris 数据集特征值标准化之后还好,但一旦网络加深,梯度消失问题会很快显现。
- Tanh:输出范围 (-1, 1),零中心化,比 Sigmoid 好一些,但同样有饱和区。
- ReLU:正区间梯度恒为 1,负区间输出为 0。计算快、不易饱和,是当前 MLP 和 CNN 的默认选择。缺点是负区间梯度为 0,可能导致“神经元死亡”——学习率过大时,某些神经元权重更新后恒为负,梯度永远是 0,这个神经元就再也激活不了了。
Iris 这个任务上,我建议直接选 ReLU,学习率设在1e-3到1e-2之间,几乎不可能翻车。权重初始化同样关键。如果权重全初始化为 0,每一层所有神经元计算相同、梯度相同,等于只有一个神经元在工作;如果权重初始值太大,ReLU 输入会撞到负区,导致大量神经元死亡。常见做法是采用 He 初始化或 Xavier 初始化。PyTorch 里nn.Linear默认的初始化方式在实践中表现良好,但如果你从零手写反向传播,需要自己控制初始化策略——这也是后面代码里会贴出的一个细节。
注意:Iris 数据集的特征值范围不同(花瓣宽约 0.1~2.5,花萼长约 4.3~7.9),如果不标准化,大的特征会在梯度里占主导,训练会变得很不稳定。这一步不是可选项,是必选项。
2.3 损失函数与评估指标:训练集准确率 100% 不等于模型好
分类任务的默认损失函数是交叉熵(CrossEntropy Loss)。它做的事情是:模型输出 3 个得分,Softmax 转成概率分布,再计算真实标签分布与预测分布之间的差距。交叉熵的值越小,预测分布越接近真实分布。相比均方误差(MSE),交叉熵在分类任务上有两个优势:梯度更平滑、对“自信的错误”惩罚更大。
评估指标只看准确率是不够的,尤其是 Iris 这种每类只有 50 个样本的数据集。假设模型把所有样本都预测为最常见的那个类别,准确率也能有 33%,看起来“不是零”,但没有分类价值。所以评估时要同时看三类各自的精确率(Precision)、召回率(Recall)和 F1-Score。三者的关系展开来说:精确率是“预测为某类的样本中,有多少是真的属于该类”;召回率是“该类所有真实样本中,模型找回了多少”;F1 是二者的调和平均。如果某类样本在训练集中本身就少,准确率再高也不代表真实能力。
Iris 数据集的标签是字符串('setosa'、'versicolor'、'virginica'),训练前必须编码成整数 0、1、2。测试集和训练集的划分也要注意:必须随机划分。Iris 数据集的 150 条样本本身按类别排序了(前 50 条是 setosa,中间 50 条是 versicolor,后 50 条是 virginica),如果你直接拿前 100 条训练、后 50 条测试,那么测试集里只有 virginica 一个类别,模型在测试集上准确率可能直接掉到接近 0%。这个坑几乎每个刚接触 Iris 的人都会踩一次。
3. 用 PyTorch 实现 Iris 分类:数据准备、模型训练与评估的完整代码
3.1 环境准备与数据加载:从 pandas 到 DataLoader 的完整管线
开始前先确认环境:需要 Python 3.8+、PyTorch(CPU 版本即可满足这个任务)、scikit-learn、pandas、matplotlib。安装命令如下:
pip install torch pandas scikit-learn matplotlib如果你用 VSCode 或 PyCharm,建议先建一个虚拟环境再装依赖,避免和系统 Python 打架。Windows 上如果遇到torch安装缓慢,可以使用国内镜像源(如清华源)加-i参数指定。
数据加载的完整代码如下:
import pandas as pd import numpy as np import torch from torch.utils.data import DataLoader, TensorDataset from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.datasets import load_iris # 1. 加载 Iris 数据集 iris = load_iris() X = iris.data # 150x4 的数值矩阵 y = iris.target # 150 个整数标签:0, 1, 2 # 2. 划分训练集和测试集:80% 训练,20% 测试 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 3. 标准化:对象用训练集拟合,再分别转换训练集和测试集 scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) # 4. 转成 PyTorch 张量 X_train_t = torch.tensor(X_train, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.long) X_test_t = torch.tensor(X_test, dtype=torch.float32) y_test_t = torch.tensor(y_test, dtype=torch.long) # 5. 组装 DataLoader train_dataset = TensorDataset(X_train_t, y_train_t) test_dataset = TensorDataset(X_test_t, y_test_t) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=16, shuffle=False)这段代码里有几个细节需要解释。train_test_split为什么传stratify=y?因为 Iris 的样本本身按类别排序了,随机划分如果不做分层抽样,运气不好时某类样本可能全部落到训练集或测试集里,导致评估失真。stratify参数能保证切分后训练集和测试集中三个类别的比例和原始数据集一致。random_state=42固定随机种子,让每次运行得到同样划分,这是保证实验可复现的基本习惯。
标准化时为什么先用fit_transform在训练集上,再用transform在测试集上?核心原因是不允许测试集信息泄漏到训练过程中。如果对全部数据统一做fit_transform,测试集的均值方差已经被模型“偷看”了,评估结果会偏乐观。正确处理是用训练集的均值和方差去标准化测试集,这也是机器学习中的一条红线。
3.2 模型定义:继承 nn.Module 还是用 nn.Sequential
PyTorch 里搭 MLP 有两种常见方式:一种是nn.Sequential直接堆层,适合结构简单的网络;另一种是继承nn.Module自定义类,适合需要自定义 forward 逻辑的场景。Iris 分类用哪种都行,但为了展示更通用的写法,这里用继承方式:
import torch.nn as nn class MLP(nn.Module): def __init__(self, input_dim=4, hidden_dim=16, output_dim=3): super(MLP, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(hidden_dim, output_dim) def forward(self, x): out = self.fc1(x) out = self.relu(out) out = self.fc2(out) return out model = MLP(input_dim=4, hidden_dim=16, output_dim=3) print(model)hidden_dim=16是隐藏层神经元的数量。对这个任务来说,16 是一个合理的起点,后面可以尝试 8、32、64 做对比。隐藏层越多,模型容量越大,但样本只有 120 条(80% × 150),容量一大就容易过拟合——训练集准确率 100%,测试集一测只剩 85%,这就是典型信号。
nn.Linear(input_dim, hidden_dim)会自动初始化权重和偏置。PyTorch 默认的初始化方式是 Kaiming Uniform,对 ReLU 激活的层来说比较合适。你也可以手动初始化,比如:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias) model.apply(init_weights)xavier_uniform_适合 Sigmoid/Tanh 激活,kaiming_uniform_更适合 ReLU。对 Iris 这个规模的任务,默认初始化表现已经够好,手动初始化更多是为了理解原理时用。
3.3 训练循环:从损失计算到参数更新的完整闭环
训练循环是整个模型最核心的部分。损失函数用nn.CrossEntropyLoss(),优化器用 Adam 或 SGD。Iris 任务样本量小、梯度计算快,优化器选择对最终结果影响不大,但 Adam 有一个实际好处——学习率敏感性低。SGD 需要仔细调学习率和动量参数,Adam 在1e-3附近基本无脑可用。
import torch.optim as optim criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) epochs = 200 train_losses = [] train_accs = [] for epoch in range(epochs): model.train() # 训练模式 total_loss = 0.0 correct = 0 total = 0 for inputs, labels in train_loader: # 前向传播 outputs = model(inputs) loss = criterion(outputs, labels) # 反向传播与参数更新 optimizer.zero_grad() loss.backward() optimizer.step() # 记录统计信息 total_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = total_loss / len(train_loader) epoch_acc = correct / total train_losses.append(epoch_loss) train_accs.append(epoch_acc) if (epoch + 1) % 20 == 0: print(f"Epoch {epoch+1:3d}/{epochs}, Loss: {epoch_loss:.4f}, Acc: {epoch_acc:.4f}")代码里最容易漏掉的是optimizer.zero_grad()。PyTorch 的梯度默认是累积的,如果不清零,每次backward()会把新梯度累加到参数既有梯度上,导致参数更新步长越来越大又不稳定,最终 Loss 不降反升。新手一上来最容易犯的错误就是忘了这一步。
model.train()和model.eval()两个模式切换在 Iris 这个任务上没有太大影响(因为没有 Dropout 和 BatchNorm),但建议在每个训练和评估阶段都显式声明。养成这个习惯后,切换到更复杂网络时不会踩坑。
另一个细节是torch.max(outputs, 1)的用法——它返回两个张量:第一个是该维度上的最大值,第二个是最大值对应的索引。对于分类任务,最大值索引就是预测类别。如果需要输出模型“对每个类别的置信概率”,可以用torch.softmax(outputs, dim=1)得到 3 个概率值,便于人工检查。
3.4 测试评估与可视化:混淆矩阵和训练曲线两头看
训练完成后,用测试集评估的代码:
from sklearn.metrics import classification_report, confusion_matrix import matplotlib.pyplot as plt model.eval() # 切换为评估模式 correct = 0 total = 0 all_preds = [] all_labels = [] with torch.no_grad(): # 推理阶段不计算梯度,省内存且加速 for inputs, labels in test_loader: outputs = model(inputs) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() all_preds.extend(predicted.numpy()) all_labels.extend(labels.numpy()) test_acc = correct / total print(f"Test Accuracy: {test_acc:.4f}") # 分类报告:精确率、召回率、F1 target_names = iris.target_names print(classification_report(all_labels, all_preds, target_names=target_names)) # 混淆矩阵热力图 cm = confusion_matrix(all_labels, all_preds) plt.figure(figsize=(6, 5)) plt.imshow(cm, cmap="Blues") plt.colorbar() plt.xticks(range(3), target_names, rotation=45) plt.yticks(range(3), target_names) plt.xlabel("Predicted Label") plt.ylabel("True Label") plt.title("Confusion Matrix") for i in range(3): for j in range(3): plt.text(j, i, cm[i, j], ha="center", va="center", color="red") plt.tight_layout() plt.show()torch.no_grad()在评估阶段是必须的:模型在推理时不需要梯度,关闭梯度计算能显著减少内存占用,同时避免 PyTorch 构建反向传播图带来的额外计算。model.eval()和torch.no_grad()是两个概念——前者改变网络中某些层的行为(如 Dropout、BatchNorm),后者关闭梯度追踪。对无 Dropout 的 MLP 来说,torch.no_grad()的实际作用更大一些。
至此你已经拿到了一条完整可跑的 MLP 训练闭环。这张流程图在心里要有数:加载数据 → 标准化 → 划分训练/测试集 → 定义网络 → 计算损失 → 反向传播 → 更新参数 → 评估。后面所有更复杂的模型,不过是把中间某一步替换成更复杂的结构,流程本身不会变。
4. 避坑与常见问题排查:5 个让人头秃的典型案例
4.1 训练 Loss 不降反升:学习率过大,Loss 直接飞了
现象:训练开始后 Loss 不但没有下降,反而从 0.8 飙升到 5、10 甚至更大,打印出来的 Loss 数值在几个 epoch 内爆表。
原因:学习率设太大,梯度下降更新步长跨过了损失函数的谷底,甚至一路发散。如果用learning_rate=0.5配 Adam,大概率会遇到这个问题。另一个原因是权重初始化不当,初始输出值过大,Softmax 输出接近 one-hot,交叉熵一开始就给出极大 Loss。
解决:把学习率降到1e-3或1e-4,重新训练。或者先打印初始 Loss——如果初始 Loss 就远大于 ln(3)≈1.10,权重初始化可能出问题了,把网络重置再试。一个实际经验:Iris 任务上学习率从1e-4到1e-2都是安全区间,超出这个范围就要监控 Loss 变化。
4.2 测试集准确率极低,检查后发现测试集只有两个类别
现象:训练准确率 95% 以上,测试集准确率却不足 40%。检查混淆矩阵,发现某些类别的召回率是 0,再检查测试集样本,发现测试集中只有两个类别的数据。
原因:划分数据时没用train_test_split的stratify参数,或者直接手动切片。Iris 数据集的 150 条样本按setosa → versicolor → virginica顺序排列,前 50 条全是 setosa。如果取前 80% 做训练,训练集里没有 virginica;后 20% 全 is virginica,测试集里没有其他两类。模型根本没见过某个类别,测试时自然分不出来。
解决:使用train_test_split(..., stratify=y)或在划分前对数据按类别随机打乱。建议固定random_state,可复现的划分方式也会让后续调参的结果对比更有意义。
4.3 不标准化特征:花瓣宽度和花萼长度“打架”
现象:模型训练后准确率在 60%~70% 左右波动,怎么调参都上不去。Loss 下降很慢,训练和测试准确率都卡在一个不上不下的位置。
原因:四个特征的数值范围差异很大。花萼长度范围 4.3~7.9,花瓣宽度范围 0.1~2.5。在梯度下降时,数值范围大的特征对梯度贡献更大,模型会优先拟合它,而数值范围小的特征很难学到足够权重。这相当于让模型强行适应一个“偏心”的输入空间。
解决:用StandardScaler对每个特征做标准化,使均值 0、方差 1。标准化的拟合只用训练集数据,测试集用同一组均值和方差做变换,不要重新拟合。标准化之后,特征之间的权重调整变得均匀,Loss 下降速度会明显改善。这一步对 MLP、逻辑回归、SVM 这类模型影响很大,对决策树/随机森林几乎无影响——这也是常被忽略的选型差异。
4.4 训练时忘记 shuffle,每个 Batch 全是同一类样本
现象:训练 Loss 震荡剧烈,准确率忽高忽低,最终停在 70%~80% 不再上升。打印每个 Batch 的标签,发现标签几乎都是同一个值。
原因:DataLoader的shuffle参数被设成了False。在单轮遍历中,前面 3 个 Batch 全是 setosa,看到的所有样本都是一个类别,计算出的梯度方向是片面的,参数在各类偏好之间来回跳动,收敛困难。Iris 只有 120 条训练样本,Bach 大小 16 时,不 Shuffle 的影响比大数据集更明显。
解决:训练集DataLoader设置shuffle=True。测试集不需要 Shuffle,因为测试阶段只是前向传播,不存在梯度偏差问题。补充一个区分点:shuffle会在每个 Epoch 开始时重新打乱数据,意味着每个 Epoch 内 Batch 的组成都不一样,这本身就是一种简单的正则化手段。
4.5 PyTorch 报错 “Expected input batch_size to match target size”
现象:执行loss = criterion(outputs, labels)时抛异常,提示 batch_size 不匹配或维度错误。
原因:有几个可能,最常见的是labels的维度是(batch_size, 1)而不是(batch_size,)。比如用了类似DataFrame.values.reshape(-1, 1)的方式构造标签时会出现这种情况。nn.CrossEntropyLoss期望目标张量是一维整数 Tensor,形状为(batch_size,),每个元素是类别索引;而你给的是二维(batch_size, 1)。
解决:在构造y_tensor后用.squeeze()压缩维度,或者用y.reshape(-1,)展平。先打印labels.shape确认形状,这一步能节省大量排查时间。另一个隐蔽情况是输出维度是(batch_size, 3)没问题,但如果output_dim=3写成了2,最后的维度对不上,也会在计算 Loss 时提示维度错误——所以检查output_dim是否和y.max() + 1相等也值得做。
5. 进阶技巧:隐藏层宽度、超参数搜索与模型鲁棒性验证
5.1 用网格搜索找到适合 Iris 的最优隐藏层宽度
Iris 数据集只有 150 个样本,人工试隐藏层宽度并不高效。常见做法是写一个简单的网格搜索脚本,遍历hidden_dim和学习率的组合。我一般会把训练封装成一个函数,返回测试准确率,然后嵌套循环跑参数网格。最终关注的指标是多次随机种子下的平均准确率——单次运行偶然性太大,3 次独立运行取平均是一个比较可靠的习惯。
对hidden_dim ∈ [4, 8, 16, 32, 64]、lr ∈ [1e-4, 1e-3, 1e-2]做全遍历,实际跑下来你会发现:Iris 任务在hidden_dim=16、lr=1e-3附近普遍表现稳定,准确率 95% 以上;hidden_dim增大到 64 时,训练集准确率接近 100%,但测试集准确率反而有下降趋势——这就是过拟合的信号。模型能背下 120 条训练样本,却无法把规律泛化到没见过的数据上。
5.2 给训练过程加一点“后悔药”:保存最佳模型而非最后一个模型
最后一个 Epoch 的模型不一定是验证集上最好的模型。训练过程中 Loss 会有波动,出现过拟合或 loss spikes,把每个 Epoch 后的验证集准确率存下来,只保存最高点对应的模型权重,是一种简单有效的做法。
best_acc = 0.0 for epoch in range(epochs): # ... 训练代码同上 ... # 每个 epoch 后在测试集上评估 model.eval() correct = 0 with torch.no_grad(): for inputs, labels in test_loader: outputs = model(inputs) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() acc = correct / len(test_dataset) print(f"Epoch {epoch+1} Test Acc: {acc:.4f}") if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "best_model.pt") print(f"Saved new best model with acc {acc:.4f}") # 最终加载最佳模型 model.load_state_dict(torch.load("best_model.pt")) model.eval()torch.save(model.state_dict(), ...)只保存模型权重,不保存网络结构,加载时需要先有一个相同结构的模型实例。也可以直接用torch.save(model, ...)保存整个模型对象,但可移植性差,不推荐在生产环境或长时间实验中用。
5.3 交叉验证代替单次数据集划分:小数据集上更可靠的评估方式
150 个样本的训练集对单次划分的偶然性太敏感了。要评估模型和调参策略的真实水平,可以用 K-Fold 交叉验证:把训练集分成 K 折(常用 5),每次取 1 折做验证、其余 K-1 折做训练,轮流 K 次,取平均指标。交叉验证能充分用好每一条样本,在一轮完整的评估中,每个样本都会被用于验证一次。
这个做法在调参时特别重要:网格搜索本身就是在一个验证集上的多轮比较,再用固定的单次划分很容易“过拟合测试集”——参数选来选去都是为了你选定的那次划分服务的。用交叉验证评估,得到的结论更接近模型的真实泛化能力。
最后一句话留给习惯:我现在无论做什么规模的分类任务,第一件事永远是print(y[:10])看标签分布,然后检查 train/val/test 划分是否分层,最后再谈模型结构。这三个动作做扎实了,后面遇到的坑会少一大半。Iris 这个任务只是个起点,把 MLP 的血泪经验留下来,下次遇到真正的数据集就能少走弯路。希望帮到你。
本文还有配套的精品资源,点击获取