简介:面向正在学习深度学习与Python图像分类的初、中级开发者,以Iris花卉数据集为对象,演示如何用numpy从零搭建全连接神经网络(MLP),并完成前向计算、反向传播、使用计算图代替softmax函数,以及SGD+Momentum优化训练等关键环节,适合用来理解神经网络底层原理与手写实现细节。压缩包内共4个文件,包含1个Python主程序、2张展示训练损失曲线与计算图结构的PNG图片,以及1份PDF说明文档,整体大小仅765KB,结构紧凑,便于对照代码和图表快速梳理模型训练流程。目前已有1852人学习,代码基于scikit-learn加载Iris数据,并在MLP内部以计算图方式实现softmax层,有助于摆脱高级框架依赖、深入掌握梯度传播与优化器机制。对于希望夯实MLP基础、准备面试或完成课程实验的读者,是一份可直接运行的动手练习资源,尤其适合在本地环境逐行调试并观察反向传播过程。
1. 用numpy手写MLP分类Iris:核心不是调参,是看懂梯度怎么流
很多人以为图像分类必须上卷积网络,其实把150条样本、4个特征的Iris数据集,送进一个用纯numpy搭出来的全连接神经网络(MLP),照样能把分类任务跑通。这份资源要解决的问题很具体:用Python手写全连接网络,完成初学阶段最该做的一次反向传播实验。它同时覆盖了数据加载、前向计算、反向传播、计算图替代softmax、SGD+Momentum训练这几条主线,适合两类人——被Keras和PyTorch的封装惯到看不清梯度流向的初学者,以及想花二十分钟复习一遍BP细节的工程师。代码只有一份iris_MLP.py,配上两张loss图和一个PDF说明,拆完之后我对MLP的实现边界有了更清楚的认识。
2. 准备Iris数据:加载、划分、归一化与独热编码
2.1 为什么选Iris:特征少、边界清楚的小型benchmark
Iris数据集是scikit-learn里最经典的分类数据集,150条样本,每个样本有花萼长度、花萼宽度、花瓣长度、花瓣宽度四个数值特征,对应Setosa、Versicolor、Virginica三个品种。跟真正的图像分类任务相比,Iris更像一个被压缩过的“单像素图像”——每个样本就是一个4维行向量,等价于把一张4像素的灰度图拉平之后的样子。MLP在这里能学到非线性决策边界,是因为三个品种在花瓣长度和宽度上有明显的分层倾向,但Versicolor和Virginica之间有一个不容易切分的交错带,这正是激活函数和隐藏层发挥作用的地方。
从工程角度看,选Iris还有两个现实原因。第一,数据量小,纯numpy实现的网络在一秒内就能跑完几十个epoch,调试成本极低,适合反复改学习率和动量系数做对比实验。第二,它是sklearn内置的公开数据,不需要自己下载、清洗、整理图像文件,代码里三行就能完成加载。我一般会把Iris当作“网络能不能正确收敛”的探针数据集,跑通了它再上CIFAR-10或者自己的业务数据,节省的时间非常可观。
表:Iris数据集中四个特征的取值范围与判别力
| 特征 | 取值范围(cm) | 主要作用 |
|---|---|---|
| 花萼长度 | 4.3 ~ 7.9 | 区分能力弱,单独看重叠明显 |
| 花萼宽度 | 2.0 ~ 4.4 | 单独看区分度较差 |
| 花瓣长度 | 1.0 ~ 6.9 | 区分能力强,主要分类依据 |
| 花瓣宽度 | 0.1 ~ 2.5 | 区分能力强,与花瓣长度配合 |
2.2 从sklearn加载并划分数据集
加载和划分这一步决定了后面所有训练的有效性。常见做法是把数据集按比例拆成训练集和测试集,比例选在7:3到8:2之间,同时要固定随机种子,保证实验可复现,不然每次跑出来的准确率都不一样,很难判断代码改动到底有没有用。
import numpy as np from sklearn.datasets import load_iris iris = load_iris() X = iris.data # 形状 (150, 4),四列分别是四个特征 y = iris.target # 形状 (150,),取值 0、1、2,对应三个品种 # 固定随机种子,让每次运行结果一致,调试时不受随机划分抖动影响 np.random.seed(42) # 先打乱索引再切分,避免原始数据按品种顺序排列导致的类别不均衡 idx = np.random.permutation(len(X)) X_shuffled, y_shuffled = X[idx], y[idx] # 前 105 条做训练,后 45 条做测试 X_train, X_test = X_shuffled[:105], X_shuffled[105:] y_train, y_test = y_shuffled[:105], y_shuffled[105:]这里有三点必须注意。第一,load_iris返回的X是二维数组,特征值全是浮点数,不需要额外做类型转换。第二,打乱顺序是必要的,因为Iris数据集里的样本是按品种顺序排列的,前50条全是Setosa,不打乱的话训练集和测试集里的类别比例会严重失衡。第三,划分比例选105/45而不是直接写0.7,是因为150乘以0.7得到105,整数切分在后续索引操作时更干净,不会出现数组边界问题。
2.3 数据标准化与标签独热编码
Iris的四个特征取值范围差异很大,花萼长度在4.3到7.9之间浮动,花瓣宽度却只有0.1到2.5。如果不做归一化,数值大的特征在矩阵乘法中会主导梯度更新方向,从而影响训练收敛。标准化的常见做法是各列减去均值再除以标准差。
# 重点:均值与方差只在训练集上计算,再同时应用到训练集和测试集 mean = X_train.mean(axis=0) std = X_train.std(axis=0) X_train = (X_train - mean) / std X_test = (X_test - mean) / std # 标签转 one-hot:0 -> [1, 0, 0],1 -> [0, 1, 0],2 -> [0, 0, 1] def one_hot(labels, num_classes): n = len(labels) out = np.zeros((n, num_classes)) out[np.arange(n), labels] = 1 return out y_train_oh = one_hot(y_train, 3) y_test_oh = one_hot(y_test, 3)这里有一个新手很容易踩的坑:如果直接用全部150条数据的均值和方法做归一化,相当于测试集信息泄漏到了训练过程里,模型评估结果会虚高。我一般会先切分数据,再在训练集上算mean和std,测试集只负责被变换。独热编码的输出形状是(105, 3)和(45, 3),因为网络最后一层有3个神经元,对应三个品种的得分,而[1, 2, 0]这样的稀疏整数没法直接和三维输出计算交叉熵损失。
2.4 为什么不直接用train_test_split
你可能想问:sklearn.model_selection.train_test_split一行就能完成划分,为什么要手动写?原因有两个。第一是训练过程的可视化——手动打乱索引后,你能直接看到X_train和X_test的具体内容,清楚知道数据流经了哪些操作;第二是这个资源的核心目的是理解实现细节,手动写一步,就对数据状态多一分把握。train_test_split当然可用,它内部同样做了shuffle,代码长这样:
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y )test_size=0.3表示45条测试样本,stratify=y按类别比例抽样,保证训练集和测试集中三类花的比例一致。这条代码更简洁,适合快速验证;但如果你跟着这份资源学习,建议先手动打乱一遍,再把两种写法对比着看,理解stratify到底做了什么。
3. 前向传播与反向传播:手写MLP的核心逻辑
3.1 网络结构设计与参数初始化
这份资源使用的网络结构很直接:输入层4个神经元、隐藏层10个神经元、输出层3个神经元。隐藏层选10个是经验值,Iris样本太少,隐藏层过大会过拟合,过小又拟合不了非线性边界。我通常会从10开始,如果验证集表现不稳定,再按8、12、16的顺序微调。
初始化这里有一个新手经常忽视的细节:权重不能全设为0,否则同一层内的所有神经元会得到完全相同的梯度,网络退化成单神经元模型。常见做法是用标准正态分布取样,再乘一个缩放因子。
class MLP: def __init__(self, input_dim, hidden_dim, output_dim, init_scale=0.01): # 用标准差为 0.01 的高斯分布初始化权重 self.W1 = np.random.randn(input_dim, hidden_dim) * init_scale self.b1 = np.zeros(hidden_dim) self.W2 = np.random.randn(hidden_dim, output_dim) * init_scale self.b2 = np.zeros(output_dim) self.cache = {}init_scale=0.01这个值很关键。Iris数据标准化后特征集中在0附近,如果初始权重太大,隐藏层的线性输出会落到较大的数值区间,经过ReLU后传到输出层,梯度在一开始就会异常大,导致loss不稳定。0.01的低缩放把初始输出压在一个较小的范围内,让网络能平稳起步。
3.2 前向计算:矩阵乘法与ReLU激活
前向计算的公式是:z1 = X·W1 + b1,a1 = relu(z1),z2 = a1·W2 + b2。输出层的z2是三个类别的原始得分,也叫logits。激活函数选ReLU是因为它计算简单、梯度要么是1要么是0,不会像sigmoid那样在两端饱和导致梯度消失。
def forward(self, X): z1 = X.dot(self.W1) + self.b1 # 线性变换 a1 = np.maximum(0, z1) # ReLU 激活,负数部分归零 z2 = a1.dot(self.W2) + self.b2 # 输出层线性变换,得到 logits self.cache = {'X': X, 'z1': z1, 'a1': a1, 'z2': z2} return z2把中间结果z1、a1、z2存进cache,是为了在反向传播时复用它们,避免重复计算。np.maximum(0, z1)是ReLU的numpy实现,负数全部截断为0,正数保持不变。这里如果对ReLU不熟,可以这么理解:它给网络注入了非线性能力,让两层线性变换真正叠加成非线性函数,否则堆再多层都等价于一层线性变换。
3.3 反向传播:链式法则的实现
反向传播是整个网络的发动机。它的目标是对每个参数计算损失函数的梯度,然后用梯度去更新参数。推导过程用链式法则从输出层往回逐层展开。先说结论:输出层梯度是(probs - y_onehot) / N,隐藏层梯度要经过W2转置传回,再乘上ReLU的导数。
def backward(self, probs, y_onehot): X = self.cache['X'] a1 = self.cache['a1'] z1 = self.cache['z1'] # 输出层梯度:softmax 输出与 one-hot 标签之差,再除以样本数 delta2 = probs - y_onehot # 形状 (batch_size, 3) delta2 /= y_onehot.shape[0] # 输出层权重和偏置的梯度 grad_W2 = a1.T.dot(delta2) grad_b2 = delta2.sum(axis=0) # 隐藏层误差:delta2 经过 W2 转置传回,再乘以 ReLU 的导数 # ReLU 导数是 1(z1 > 0 时),0(z1 <= 0 时) delta1 = delta2.dot(self.W2.T) * (z1 > 0) grad_W1 = X.T.dot(delta1) grad_b1 = delta1.sum(axis=0) return {'W1': grad_W1, 'b1': grad_b1, 'W2': grad_W2, 'b2': grad_b2}关于偏置梯度为什么要用sum(axis=0):反向传播时,偏置b是对一个batch内所有样本共享的,每个样本都会产生一个梯度分量,所以要把所有样本的梯度累加在一起。delta1的计算乘了(z1 > 0),这是一个布尔掩码,正好实现ReLU求导。这里我遇到过不少新手把z1 > 0写成z1 > 1,梯度瞬间全错,loss曲线直接变成一条水平线。
3.4 梯度形状验证:一个实用的自检方法
手写反向传播最怕维度对不上,运行时报ValueError: shapes not aligned。我一般会在调试阶段用print检查每个梯度矩阵的形状:
grads = net.backward(probs, y_train_oh) for name, grad in grads.items(): print(name, grad.shape)期望的输出是:
- W1: (4, 10),与X.T.dot(delta1)形状一致
- b1: (10,),与delta1.sum(axis=0)形状一致
- W2: (10, 3),与a1.T.dot(delta2)形状一致
- b2: (3,),与delta2.sum(axis=0)形状一致
如果W2打印出(10, 10)或者(10,)这类不匹配的形状,说明forward里的cache传错了参数。这个习惯挽救过我好几次调试时间,比盯着数学公式空想高效得多。
4. 训练策略:计算图替代softmax与SGD+Momentum优化器
4.1 为什么用计算图替代softmax
softmax单独拿出来做前向计算很容易写,但直接和交叉熵配合时有两个问题。第一是数值溢出风险:exp(z)在z很大时会产生巨大的中间值,等于正无穷的概率就直接变成NaN;第二是反向传播时,需要分别求softmax的雅可比矩阵和交叉熵的梯度,再链式相乘,不仅冗余还容易写错。
把softmax和交叉熵合并成一个计算图节点,正是这份资源的代码里“计算图”的含义。合并之后的损失函数对logits的梯度,恰好化简为(probs - y_onehot) / batch_size,这个形式非常简洁,一份代码同时拿到了前向的loss值和反向的梯度,不再需要单独维护一个softmax层。
4.2 SoftmaxWithLoss合并实现
def softmax_crossentropy_loss(logits, y_onehot): # 稳定版 softmax:每行先减去最大值,防止 exp 溢出为 inf z = logits - logits.max(axis=1, keepdims=True) exp_z = np.exp(z) probs = exp_z / exp_z.sum(axis=1, keepdims=True) # 交叉熵:取正确类别的负对数概率,加 1e-12 防止 log(0) batch_size = y_onehot.shape[0] correct_log_probs = -np.log(probs[np.arange(batch_size), y_onehot.argmax(axis=1)] + 1e-12) loss = correct_log_probs.sum() / batch_size # 计算图反向结果:softmax 输出与 one-hot 标签之差 grads = (probs - y_onehot) / batch_size return loss, probs, grads这里最重要的操作是logits - logits.max(axis=1, keepdims=True),它把每行logits整体平移,因为softmax的分子分母同时缩放,概率值不变,但exp的输入从可能上百的数变成最大为0的数,彻底规避溢出。grads = (probs - one_hot) / batch_size就是合并计算图的精髓,它同时包含了softmax层和交叉熵层的反向逻辑。1e-12是一个很小的常数,防止probs里出现0导致log(0)。
4.3 为什么用SGD+Momentum而不是普通SGD
普通SGD的更新公式是w = w - lr * grad,问题在于loss曲面狭长时,参数更新会来回震荡,收敛速度慢。加上Momentum后,更新方向不再只看当前梯度,还参考了历史梯度的累积,相当于给参数更新加了惯性。如果梯度方向一致,它会加速前进;如果梯度方向频繁改变,它会抵消一部分震荡。
def sgd_momentum_update(params, grads, lr=0.01, momentum=0.9): # 用字典保存每个参数的动量缓存,首次调用时初始化为全零 if not hasattr(sgd_momentum_update, "velocities"): sgd_momentum_update.velocities = {k: np.zeros_like(v) for k, v in params.items()} vel = sgd_momentum_update.velocities for key in params: # 动量更新:速度累积历史梯度方向,再叠加当前梯度 vel[key] = momentum * vel[key] - lr * grads[key] # 参数沿速度方向移动 params[key] += vel[key] return paramsmomentum=0.9是常用默认值,表示新速度中旧速度占90%,当前梯度占10%。如果增大到0.99,累积效应过强,参数会在最优点附近冲过头;如果减小到0.5,动量作用不明显,优化器接近普通SGD。lr=0.01配合标准化后的Iris特征是一个比较安全的起点——这个组合在多数情况下能让loss在50个epoch内稳定下降。
4.4 完整训练循环:组合所有模块
把数据加载、前向、loss计算、反向、参数更新拼起来,就是一份可运行的训练代码骨架:
net = MLP(input_dim=4, hidden_dim=10, output_dim=3) params = {'W1': net.W1, 'b1': net.b1, 'W2': net.W2, 'b2': net.b2} epochs = 200 for epoch in range(epochs): # 前向 + loss logits = net.forward(X_train) loss, probs, grads = softmax_crossentropy_loss(logits, y_train_oh) # 反向 + 更新 grads = net.backward(probs, y_train_oh) params = sgd_momentum_update(params, grads, lr=0.01, momentum=0.9) # 每 20 个 epoch 打印一次 loss,观察收敛趋势 if epoch % 20 == 0: print(f"epoch {epoch}, loss {loss:.4f}")输出形态大致是epoch 0, loss 1.09逐步下降到epoch 180, loss 0.08。如果你看到loss在0.3附近反复横跳超过50轮不再下降,优先检查学习率是否偏大,其次看数据标准化有没有做对。
5. 避坑排查:Iris MLP常见的五个翻车现场
5.1 现象:loss在第一次迭代后变成NaN
原因:学习率设得过大(比如0.5),或者初始化权重标准差过大,导致梯度爆炸。还有一种情况是输入数据里混入了缺失值,X中出现了np.nan。
解决:把学习率降到0.01~0.1之间,初始化缩放因子从0.01改到0.001进行重试。同时检查输入数据:np.isnan(X).any()返回False才说明数据干净。这三个操作一般能解决90%的NaN问题。如果还不行,打印logits和probs,看是哪一层先出错。
5.2 现象:准确率一直卡在33%左右
原因:网络“学习”了,但学习方向是错的。最常见的情况是输出层没有用softmax,直接用线性输出计算交叉熵,梯度方向与实际优化目标不一致。另一个常见错误是把反向传播里的(z1 > 0)写成了(z1 > 1),梯度被错误截断。
解决:回到源码,确认loss计算用的是softmax_crossentropy_loss合并函数,确认backward里用的是(z1 > 0)掩码。从打印的loss值也能判断:如果loss一直在1.0上下不下降,基本可以断定梯度方向有误。
5.3 现象:训练集准确率很高,但测试集只有一半
原因:过拟合。Iris只有150条样本,如果隐藏层神经元从10改成100,网络会直接把训练样本的特征模式“背下来”,而不是学到一个可泛化的决策边界。
解决:减少隐藏层神经元数量,回到10附近。也可以加入L2正则化,在梯度更新时额外减去一小部分权重衰减项。还有一个思路是增加训练轮数并用早停——验证集loss连续20轮不下降就停止训练,避免模型在训练集上越拟合越偏。
5.4 现象:每次运行准确率波动很大,从80%到95%不等
原因:随机种子没固定。数据划分和权重初始化各自引入了随机性,两次运行之间无法复现结果,这是手写网络最常见的不稳定因素。
解决:在代码开头设置np.random.seed(42),并尽量把数据划分种子和参数初始化种子分开,比如划分用42,初始化用0。这样同一个代码包在任何机器上跑出的结果都一致,排查问题时有据可查。我一般会在跑实验前先问自己一句:这次的结果是可复现的吗?如果不是,先修随机种子再谈调参。
5.5 现象:loss.png曲线呈锯齿状反弹,收敛不光滑
原因:全批量梯度更新时,整个训练集的梯度方向如果存在噪声,更新步长就会来回摇摆;学习率过大也会产生同样的效果。学习率下降太快又会提前停止在次优位置。
解决:先确认学习率在0.01~0.1之间,再把momentum从0.5逐步提高到0.95,观察曲线震荡幅度是否收敛。另外可以每10个epoch打印一次loss并手动记录,曲线锯齿未必是bug,小幅度起伏是正常的,如果锯齿太大就按上面两项微调。
6. 验证模型:混淆矩阵与两张loss图的真实读法
代码跑完,最后一个关键动作是验证模型到底学到了什么。只盯训练集loss是不够的,我习惯把测试集预测结果落成混淆矩阵,一眼看好几个信息:每一类的正确率、哪些类别之间容易被混淆、模型的错误是均匀分布还是集中于某两类。
from sklearn.metrics import accuracy_score, confusion_matrix logits = net.forward(X_test) probs = np.exp(logits) / np.exp(logits).sum(axis=1, keepdims=True) y_pred = probs.argmax(axis=1) acc = accuracy_score(y_test, y_pred) conf = confusion_matrix(y_test, y_pred) print(f"测试集准确率: {acc:.2%}") print("混淆矩阵:") print(conf)结合这份资源里自带的两张图来对照:loss.png是训练过程中的loss下降曲线,记录每个epoch的loss值,用来判断训练是否收敛;loss_comp_graph.png是计算图结构图,把SoftmaxWithLoss节点画成了计算图形式,用于理解梯度数据的流向。我建议顺序是:先看loss.png判断收敛状态,再看loss_comp_graph.png理解loss和梯度如何联动,最后用混淆矩阵确认测试集表现。三样对一遍,模型的健康状况就清楚了。
在Iris这个任务上,一个收敛正常的MLP测试集准确率通常落在90%~97%区间,混淆矩阵里最常出现的错误是Versicolor被误判成Virginica,这符合两类样本在花瓣特征上天然交叠的事实。如果你看到Setosa被误判成其他类别,那多半是数据划分或代码有bug,因为Setosa在四个特征上都高度可分。
动手做的话,建议你把训练epoch从200调到2000,观察loss在低于0.1之后还能否继续下降;再把隐藏层神经元改成4和64,对比验证集准确率。三组实验跑完,你对“网络容量与过拟合”的体会比看十遍理论都要深。从那以后,我每次写完手写BP网络,都强制自己先看loss曲线、再看混淆矩阵,最后才动超参——这个顺序帮我省掉了大量无效调参时间。希望帮到你。
本文还有配套的精品资源,点击获取