news 2026/10/26 16:58:15

纯numpy手写MLP实战:Iris三分类与反向传播推导

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯numpy手写MLP实战:Iris三分类与反向传播推导

简介:这份资源面向希望从零理解神经网络底层原理的 Python 学习者与入门开发者,用纯 numpy 手写全连接网络(MLP)完成 Iris 花卉数据集的图像分类任务,帮助读者摆脱框架黑盒,真正吃透前向计算与反向传播的数学细节。压缩包共 4 个文件,包含 1 个可直接运行的 py 脚本、1 份讲解 PDF 文档以及 2 张训练损失曲线图,整体约 765KB,体积轻量却覆盖完整实验流程。代码中不仅用 scikit-learn 加载 Iris 数据,还以计算图方式替代传统 softmax 函数,并采用 SGD 加 Momentum 优化器进行训练,配合损失曲线可直观观察收敛过程。目前已有 1853 人学习下载,适合想夯实深度学习基础、准备面试或课程作业的读者对照代码逐行复现,理解梯度回传与参数更新的每一步。

1. 从一份 numpy 手写 MLP 代码说起:Iris 分类到底能跑出什么

很多人第一次接触神经网络,都是从调sklearn或torch的现成接口开始的,几行代码就能出准确率,但反向传播到底怎么算、softmax 的梯度为什么是那个形式,心里其实没底。这份资源就是冲着这个痛点来的:它用纯numpy从零搭一个全连接网络(MLP),在 Iris 花卉数据集上做三分类,前向、反向、损失、优化器全部手写,不依赖任何深度学习框架。压缩包里除了iris_MLP.py主脚本,还配了loss.png、loss_comp_graph.png两张训练曲线和计算图,以及一份MLP神经网络图像分类.pdf说明文档,等于把「代码 + 推导 + 结果」三样东西凑齐了。它适合刚学完 BP 推导想找份能跑通的代码对照的人,也适合想复习底层矩阵运算的老手。下面我按「这份代码怎么落地、参数怎么调、哪里容易翻车」的顺序拆一遍。

2. 环境与数据:把 Iris 读进来并切成能训练的三分类样本

2.1 依赖选型:为什么是 numpy + scikit-learn 而不是 torch

这份代码的技术栈非常克制,核心只有两个库:numpy负责所有矩阵运算和梯度计算,scikit-learn只用来加载 Iris 数据集和做标签/划分处理。选这个组合的理由很实在——如果引入torch,autograd会自动帮你算梯度,那手写反向传播的意义就没了;而sklearn的load_iris是本地自带的小数据集,不需要联网下载,150 个样本、4 个特征、3 个类别,几分钟就能跑完一轮,非常适合验证推导是否正确。

常见做法是建一个干净的虚拟环境,避免和你机器上已有的科学计算库版本打架。我一般会固定numpy和scikit-learn的版本,因为不同版本里train_test_split的默认行为和随机种子处理偶有差异,会导致你复现不出图里的 loss 曲线。

# 建虚拟环境并装依赖,版本按你本地能装上的稳定版即可 python -m venv venv_mlp source venv_mlp/bin/activate # Windows 用 venv_mlp\Scripts\activate pip install numpy scikit-learn matplotlib

这里matplotlib不是训练必需,但包里带了loss.png,你想自己重画训练曲线就得用它。装完可以先python -c "import numpy, sklearn; print(numpy.__version__, sklearn.__version__)"确认环境通了。

2.2 数据加载与预处理:150 个样本怎么切才不浪费

Iris 只有 150 条,如果按常见的 8:2 切,测试集只剩 30 条,三分类每类平均 10 条,准确率波动会很大。这份代码的典型处理是先做特征标准化,再划分训练/测试集。标准化这一步不能省,因为 Iris 的四个特征量纲不同,花瓣宽度在 0.1~2.5 之间,萼片长度在 4.3~7.9 之间,不归一化的话梯度下降会被大量纲特征主导,收敛慢甚至震荡。

import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 加载 Iris,X 是 (150, 4),y 是 (150,) 的 0/1/2 标签 iris = load_iris() X, y = iris.data, iris.target # 标准化:按列减均值除标准差,避免量纲差异拖慢收敛 scaler = StandardScaler() X = scaler.fit_transform(X) # 固定 random_state,保证每次切分一致,方便对照 loss 曲线 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 标签转 one-hot,配合 softmax 交叉熵使用 def one_hot(y, num_classes=3): return np.eye(num_classes)[y] y_train_oh = one_hot(y_train) y_test_oh = one_hot(y_test) print(X_train.shape, y_train_oh.shape) # (120, 4) (120, 3)

逻辑上,stratify=y保证训练集和测试集里三个类别的比例一致,不然随机切可能某一类在测试集里只有两三条,评估结果没有参考价值。one_hot用np.eye做查表,比循环赋值快也更清晰。参数上,test_size=0.2是这份小数据集的常见折中,如果你想把测试集再放大到 0.3,记得同步看训练集是否还够 100 条以上,否则模型容易欠拟合。

提示:StandardScaler的fit只能在训练集上做,测试集要用训练集的均值和方差来transform。上面代码先对全体X做了标准化再切分,严格来说有轻微数据泄漏,样本量小时影响不大,但如果你要写进正式项目,正确顺序是先切分再分别 fit/transform。

3. 手写前向与反向:计算图替代 softmax 的推导和落地

3.1 网络结构定义:两层 MLP 的参数初始化

这份代码的网络结构是「输入 4 维 → 隐藏层 → 输出 3 类」,隐藏层神经元数量是唯一需要你手动调的架构参数。常见设置是 8 到 16 个隐藏单元,太少拟合不动,太多在 120 条训练样本上直接过拟合。权重初始化不能用全零,否则同一层所有神经元梯度相同,永远学不出差异,这是血泪经验里最经典的翻车点。

def init_params(input_dim=4, hidden_dim=10, output_dim=3, seed=42): rng = np.random.RandomState(seed) # 小随机数初始化,缩放因子控制初始输出方差 W1 = rng.randn(input_dim, hidden_dim) * 0.1 b1 = np.zeros((1, hidden_dim)) W2 = rng.randn(hidden_dim, output_dim) * 0.1 b2 = np.zeros((1, output_dim)) return {"W1": W1, "b1": b1, "W2": W2, "b2": b2}

W1形状是(4, hidden_dim),W2是(hidden_dim, 3),偏置用零初始化没问题,因为权重已经打破了对称性。缩放因子0.1是个经验值,如果你把隐藏层加到 64,这个值可以再小一点,否则第一层输出方差偏大,ReLU 或 sigmoid 容易进饱和区。

3.2 前向传播与计算图:为什么用计算图代替直接写 softmax

摘要里特别提到「使用计算图代替 softmax 函数」,这是这份代码最有价值的部分。直接写 softmax 再求交叉熵,数值上容易溢出,因为exp大数会炸;而用计算图把 softmax 和交叉熵合并成一个节点,可以推导出梯度就是(预测概率 - 真实标签),既稳定又简洁。所谓计算图,在这里就是把前向的每一步(线性变换、激活、softmax、交叉熵)都记录成可求导的节点,反向时按链式法则逐节点回传。

def forward(params, X): W1, b1, W2, b2 = params["W1"], params["b1"], params["W2"], params["b2"] # 第一层线性 + ReLU 激活 z1 = X @ W1 + b1 a1 = np.maximum(0, z1) # 第二层线性,输出 logits z2 = a1 @ W2 + b2 # 数值稳定版 softmax:先减每行最大值再取 exp z2_shift = z2 - np.max(z2, axis=1, keepdims=True) exp_z = np.exp(z2_shift) probs = exp_z / np.sum(exp_z, axis=1, keepdims=True) cache = {"X": X, "z1": z1, "a1": a1, "probs": probs} return probs, cache

z2_shift这一步就是数值稳定的关键,减去每行最大值不改变 softmax 结果,但把exp的输入压到 0 附近,避免上溢。cache把前向中间结果存下来,反向传播时直接用,不用重算。参数上,axis=1表示按样本行做归一化,keepdims=True保证广播维度正确,这两个地方写错是新手最常见的 shape 报错来源。

3.3 反向传播与 SGD+Momentum:梯度怎么回传、动量怎么加

反向传播的核心是把交叉熵和 softmax 合并后的梯度(probs - y_onehot) / N作为起点,逐层往回推。这份代码用 SGD 加 Momentum,动量项让参数更新方向平滑,能明显减少 loss 曲线上的锯齿,在 Iris 这种小数据上收敛更快。

def backward(params, cache, y_onehot): X, a1, probs = cache["X"], cache["a1"], cache["probs"] N = X.shape[0] # softmax + 交叉熵合并后的梯度,形状 (N, 3) dz2 = (probs - y_onehot) / N dW2 = a1.T @ dz2 db2 = np.sum(dz2, axis=0, keepdims=True) # 回传到隐藏层,ReLU 的导数在 z1>0 处为 1 da1 = dz2 @ params["W2"].T dz1 = da1 * (cache["z1"] > 0) dW1 = X.T @ dz1 db1 = np.sum(dz1, axis=0, keepdims=True) return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2} def sgd_momentum(params, grads, velocity, lr=0.1, momentum=0.9): for k in params: velocity[k] = momentum * velocity[k] - lr * grads[k] params[k] += velocity[k] return params, velocity

dz2除以N是做 batch 平均,避免梯度随样本数放大。dz1 = da1 * (z1 > 0)是 ReLU 的导数,布尔数组乘数值数组会自动转成 0/1。动量更新里velocity要初始化为和参数同形状的零字典,momentum=0.9、lr=0.1是这份代码能跑出图里那条下降曲线的常见组合。如果你把lr调到 1.0 以上,loss 很可能直接发散成nan,这是调参时第一个要盯的地方。

4. 训练循环与结果验证:loss 曲线、准确率和计算图怎么看

4.1 训练主循环:epoch、batch 与日志打印

把前面的模块串起来就是一个标准训练循环。Iris 数据量小,可以直接全量 batch 训练,不用 mini-batch,省去 shuffle 的复杂度。每个 epoch 做一次前向、一次反向、一次参数更新,并记录 loss 和训练准确率。

params = init_params() velocity = {k: np.zeros_like(v) for k, v in params.items()} loss_history = [] for epoch in range(1000): probs, cache = forward(params, X_train) # 交叉熵损失,加 1e-12 防止 log(0) loss = -np.mean(np.sum(y_train_oh * np.log(probs + 1e-12), axis=1)) grads = backward(params, cache, y_train_oh) params, velocity = sgd_momentum(params, grads, velocity, lr=0.1, momentum=0.9) loss_history.append(loss) if epoch % 100 == 0: pred = np.argmax(probs, axis=1) acc = np.mean(pred == y_train) print(f"epoch {epoch:4d} loss {loss:.4f} train_acc {acc:.3f}")

np.sum(y * log(p), axis=1)先按样本算交叉熵,再np.mean求平均。1e-12是防止某个概率被压到 0 导致log报-inf。打印频率设成 100 是为了看趋势,太密反而刷屏。跑完 1000 轮,训练准确率通常能到 0.97 以上,测试集在 0.93~1.0 之间浮动,具体取决于随机种子和隐藏层大小。

4.2 用 loss.png 和计算图对照自己的结果

包里那两张图不是装饰。loss.png是训练损失随 epoch 的下降曲线,正常形态是先快速下降然后逐渐平缓,如果它一直震荡或者中途翘头,说明学习率偏大或动量设置有问题。loss_comp_graph.png是计算图示意,把前向的线性、激活、softmax、损失节点和反向的梯度流向画出来了,对着它检查你的backward函数有没有漏掉某一层的梯度,比干看代码高效得多。

验证自己实现是否正确,有个很实用的办法:用sklearn的MLPClassifier在同样数据上跑一遍,对比测试准确率。如果两者差得离谱(比如你的只有 0.6,sklearn 有 0.95),那基本是反向传播或初始化写错了,而不是数据问题。这个对照步骤我每次手写网络都会做,相当于给自己留一颗后悔药。

注意:loss.png里的曲线是在特定超参下生成的,你改了隐藏层大小或学习率,曲线形状会变,不要拿它当唯一标准,重点看趋势是否单调下降并收敛。

5. 避坑与排查:手写 MLP 最容易翻车的五个地方

5.1 现象:loss 变成 nan 或一开始就爆炸

原因通常是学习率过大,或者 softmax 没做数值稳定处理,exp上溢。解决是把lr降到 0.01~0.1 区间,并确认前向里做了减最大值的操作。如果已经出现nan,先把学习率砍一个数量级再跑。

5.2 现象:准确率一直卡在 0.33 左右不动

三分类随机猜就是 0.33,说明模型没学到东西。最常见原因是权重全零初始化,或者反向传播里某一层梯度没回传(比如忘了乘 ReLU 导数)。检查init_params是否用了随机数,再对着计算图核对backward的每一行。

5.3 现象:训练准确率高但测试准确率低

这是过拟合的典型信号。Iris 只有 120 条训练样本,隐藏层开到 64 以上很容易过拟合。解决办法是减小隐藏层维度、加 L2 正则,或者干脆用交叉验证来选超参。别指望在小数据上堆大网络。

5.4 现象:矩阵乘法报 shape 不匹配

九成是转置写错或keepdims漏了。dW2 = a1.T @ dz2里a1是(N, hidden),dz2是(N, 3),转置后(hidden, N) @ (N, 3)才是(hidden, 3)。每次改网络结构后,先把每层 shape 打印出来对一遍,比盲改快。

5.5 现象:每次运行结果都不一样,复现不出图里的曲线

随机种子没固定。train_test_split、init_params里的RandomState都要设seed。另外numpy全局随机种子和RandomState实例是两套机制,别混用。固定种子后,同样的超参应该能跑出几乎一致的 loss 曲线。

6. 进阶技巧:把这份代码改成你自己的分类任务

跑通 Iris 只是起点,这份代码真正的价值是它的骨架可以迁移到别的表格分类任务上。我一般会按下面这个顺序改:先把load_iris换成自己的np.loadtxt或pandas.read_csv,确认特征矩阵是(N, D)的浮点数组;再把输出维度从 3 改成你的类别数,one_hot里的num_classes同步改;最后根据特征维度调整input_dim。隐藏层可以加一层变成三层 MLP,只要在forward和backward里对称地加一组W3/b3和对应的梯度回传就行。

验证迁移是否成功,有个具体技巧:先用一个你能一眼看出答案的玩具数据集,比如二维平面上两个明显分开的簇,看模型能不能在几十轮内把准确率拉到 1.0。如果能,说明前向反向链路是通的,再换真实数据。这个「先过玩具集再上真实集」的习惯,帮我省过很多次对着业务数据怀疑人生的时间。

另外,loss_comp_graph.png那张计算图值得你照着在纸上重画一遍,把每个节点的局部梯度和形状标出来。手推一遍之后再回头看backward函数,很多之前靠猜的地方会变得确定。等你哪天需要把这份 numpy 实现移植到某个嵌入式或受限环境里,这种不依赖框架的底层写法就是你的底牌。

从那以后我每次手写网络,都会先固定随机种子、先过玩具数据集、再对照 sklearn 结果,三步走完才敢往真实任务上放。希望这份拆解帮到你,代码包里的脚本和文档对着跑一遍,比看十篇推导都实在。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 16:58:00

Agent-Reach:面向智能体互操作的轻量级通信协议栈

1. “Agent-Reach”不是新模型,而是一套面向开发者的轻量级智能体通信协议栈你搜“Agent-Reach”,大概率会撞上一堆零散的GitHub仓库、CLI命令报错截图、API Key配置失败的提问,甚至混进“zcode cli”“diplay github”“lm studio model not…

作者头像 李华
网站建设 2026/10/26 16:57:58

Linux系统下Navicat的AppImage安装与优化指南

1. Navicat在Linux系统的安装价值与方案选型 Navicat作为数据库管理工具中的"瑞士军刀",其图形化操作界面能极大提升MySQL、PostgreSQL等数据库的管理效率。但在Linux环境下,官方并未提供传统deb/rpm安装包,这让不少开发者感到困扰…

作者头像 李华
网站建设 2026/10/26 16:54:49

Loop Engineering:循环性能优化与并发批处理实战手册

凌晨三点被电话叫醒,线上一个批处理任务跑了三个多小时还没结束,下游在催数据,老板在群里问怎么回事。我登录服务器看了一眼,发现一个十万级的遍历循环,在循环体里逐条调用远程接口,每条等几百毫秒&#xf…

作者头像 李华
网站建设 2026/10/26 16:54:21

计算机视觉实训软件选型指南:14大主题与70个实践的大班教学方案

计算机视觉这门课,最尴尬的从来不是讲原理。讲卷积、讲特征图、讲损失函数,PPT 上都能讲得头头是道。真正让人头疼的是实训环节:一个班几十上百号人,每人配一台带独显的机器,装环境、配依赖、下数据集、跑训练&#xf…

作者头像 李华
网站建设 2026/10/26 16:53:39

Muse开源SDK:用ESP32打造端侧AI Agent,从屏幕囚笼走向现实硬件

1. 从屏幕里爬出来的 AI:Muse 登顶背后到底发生了什么Muse 登顶 App Store 这件事,如果只看表面,好像只是又一个 AI 应用火了。但真正让我觉得值得聊的,是它同时开源了 SDK 这个动作。我做嵌入式开发快十年了,见过太多…

作者头像 李华
网站建设 2026/10/26 16:53:12

中小企业软件选型指南:轻量化SaaS与数据安全实践

1. 中小企业软件市场的现状与挑战最近几年,我走访了上百家中小企业的IT部门,发现一个有趣的现象:几乎每家企业都在使用某种形式的软件系统,但满意度普遍不高。特别是在资产管理和数据安全这两个关键领域,问题尤为突出。…

作者头像 李华