ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

纯Python+NumPy手写多层感知机:从反向传播到决策边界实战

纯Python+NumPy手写多层感知机:从反向传播到决策边界实战 前两天有个读者问我“我已经会用 sklearn 调 MLPClassifier 了还有必要自己用 Python 从零写一个多层感知机吗”我的回答是如果你只是想交作业那没必要但如果你想真的搞懂神经网络在干什么这一趟手动实现比你看十篇教程都值。这篇博文就是围绕这个目标写的从多层感知机的数学原理开始不绕弯子直接把前向传播、反向传播这些概念拆开然后用纯 Python NumPy 把它们一行一行写出来最后在一份环形数据集上训练并画出决策边界。整个过程不依赖任何深度学习框架代码可以直接复制运行。适合已经会 Python 基础语法、但还没系统接触过神经网络的人也适合那些“调包调得飞起、但一问权重怎么更新就卡壳”的朋友。1. 为什么我坚持让你手写一次MLP1.1 框架把数学藏起来了这是好事也是坏事现在用 PyTorch、TensorFlow 搭一个 MLP 实在太简单了几行代码就能跑起来。但问题也出在这里nn.Linear、loss.backward()、optimizer.step()这三个 API 几乎把整个神经网络的灵魂都封装没了。很多初学者写完训练循环脑子里对“梯度到底在往哪传”依然没有图像。我见过不少朋友能调参但说不出w2在第几个 epoch 应该往哪个方向更新也见过有人把学习率从 0.01 改成 0.001loss 曲线突然崩了根本不知道从哪里排查。这些问题的根源都一样缺少对底层机制的手感。手写一个 MLP你被迫面对这几件平时被框架掩盖的事情矩阵相乘的维度是怎么变化的(batch_size, input_dim)怎么一步步变成(batch_size, num_classes)反向传播时分母上的那个“梯度信号”到底经过了哪些矩阵和激活函数权重初始化的细微差异会在训练早期造成多大的影响学习率为什么这么大居然能让 loss 直接变NaN这些点你用model.fit()永远不会真正感受到。1.2 手写MLP到底在练什么能力手写 MLP 并不是让你以后不用框架而是通过一次“慢速重走发明之路”的训练建立对神经网络的敏感度。这种敏感度包括几个层次维度敏感度写代码时每一行都清楚张量的 shape报错了不用靠猜。数值敏感度看到 loss 从 0.69 缓慢下降知道 sigmoid 刚开始在做什么看到 loss 变成 nan第一反应是去查梯度爆炸。结构敏感度知道隐藏层宽度、深度、激活函数分别作用于模型的什么能力而不是盲目抄网络结构。从求职角度说很多算法岗位面试都会让你手推反向传播、手写一个简单的两层网络。与其临时刷题不如直接自己做一遍。从实际项目角度说当你需要魔改网络结构、或者实现某些框架里没有的损失函数时你会感谢自己当初理解过这些基础代码。2. 动手写代码前先把前向传播和反向传播的运行机理搞清楚2.1 一条样本是怎么从输入变成预测的多层感知机的“前向传播”说白了就是一句话把输入数据一层一层做线性变换再套一个非线性激活函数反复进行最后得到输出。假设我们有一个隐藏层的 MLP输入维度是d隐藏层神经元数是h输出维度是c二分类就是 1多分类就是类别数。对单个样本x来说前向过程可以写成隐藏层线性变换z1 x W1 b1隐藏层激活a1 relu(z1)或者 sigmoid/tanh输出层线性变换z2 a1 W2 b2输出层激活y_hat sigmoid(z2)这里的是矩阵乘法W1的形状是(d, h)W2的形状是(h, c)。所有偏置b1、b2的维度分别和输出维度一致。如果你之前只见过单个神经元的图可以把每一层理解成一个“二次加工车间”前面的车间负责把粗糙特征组合成更高级的特征最后的车间负责把特征压缩成你想要的答案。隐藏层宽度越大每个车间能保留的中间特征越多隐藏层数量越多特征组合的层级越深。2.2 反向传播其实就是链式法则的“账单”反向传播经常被讲得很玄但它本质上就是求导的链式法则只不过神经网络足够深手算不现实于是用一种“从后往前逐层回传”的方式自动计算。核心思路是我们想知道每个权重对最终损失函数的影响程度也就是梯度。如果损失函数是L某个权重是W2那根据链式法则先算损失对输出y_hat的偏导再算输出y_hat对z2的偏导取决于输出层激活函数的导数再算z2对W2的偏导这个其实等于a1.T。三个乘起来就得到了L对W2的梯度。更关键的是中间层的梯度z2对a1的梯度是W2.Ta1对z1的梯度是激活函数的导数于是L对z1的梯度可以继续回传。你看整个过程就变成了一个接力赛上游的梯度信号乘以本地的导数然后再传给下游。这个过程和线性代数的转置操作紧密结合所以写代码的时候W.T出现的频率会特别高。2.3 梯度消失的直觉理解为什么激活函数不能乱选我在学 MLP 时最困惑的问题就是为什么老强调不要用 sigmoid 当隐藏层激活函数直观理解其实很简单。sigmoid 的函数图像是 S 形它在中间区域斜率最大往两边迅速变平。也就是说当输入值比较大或比较小时sigmoid 的导数会趋近于 0。在反向传播的乘法链路里只要有一个因子接近 0整个乘积就很接近 0。多层网络里每层都有一个这样的“小斜率”乘上几次后传到浅层的梯度就几乎没有了浅层权重几乎学不动整个网络训练非常缓慢。所以现在隐藏层普遍用 ReLU 或其变体。ReLU 在正半轴的导数是 1不会对梯度做额外缩小负半轴导数为 0又会带来一定的稀疏性。虽然 ReLU 也有“神经元死亡”的问题但比起 sigmoid 的梯度消失它要好用得多。3. 搭建MLP之前必须想清楚的四个设计决策3.1 网络结构层数和宽度怎么定经典结论是单隐藏层的 MLP 已经可以逼近任意连续函数这就是万能逼近定理。但理论上能逼近不代表用梯度下降在实际训练中就一定能学到。一般来说层数先浅后深从 1 个隐藏层开始效果不够再增加宽度和输入规模相关输入维度高、样本量大的时候隐藏层宽度可以大一些不要一上来就造一个很深的网络深度增加会带来更明显的梯度问题、过拟合问题和训练不稳定问题。我自己做实验的习惯是先来一个(输入维度, 16, 1)的两层网络跑通再根据表现决定是否扩到 32、64或者加一层。与其盲目堆参数不如先验证训练流程有没有问题。3.2 激活函数与权重初始化要一起选这里想特别提醒激活函数和权重初始化是配套的。用 ReLU 做隐藏层激活时权重初始值不能太大否则很多神经元输入落在负数区域ReLU 直接输出 0梯度也为 0神经元就“死”了。推荐使用 He 初始化也就是让权重的标准差约为sqrt(2 / fan_in)其中fan_in是输入神经元个数。如果用 tanh则推荐 Xavier 初始化标准差大概是sqrt(1 / fan_in)。如果用 sigmoid初始化同样要小而且通常倾向于用更保守的均匀分布。我之前见过太多新手初始化的时候直接np.random.randn结果 loss 在一开始就非常不稳定。不是网络结构错了而是初始权重范围太大深层输出的值爆炸进入饱和区梯度消失或梯度爆炸就开始了。3.3 损失函数与学习率的选择二分类问题用二元交叉熵损失这个公式本身很简单L -[y * log(y_hat) (1 - y) * log(1 - y_hat)]但要注意如果y_hat被 sigmoid 压到了非常接近 0 或 1 的值log的结果会非常大造成 loss 波动甚至溢出。更稳定的做法是在数值实现时把 sigmoid 和对数损失合并计算不要分成两步算。学习率方面我建议从一个基准值开始比如 0.1、0.05观察 loss 曲线如果 loss 震荡严重就减小如果下降太慢就适当增大。没有绝对正确的学习率因为最优值和网络结构、数据分布、初始化方式都有关。4. 基于NumPy的MLP完整实现与逐段解析4.1 整体结构与初始化现在开始进入实战。我以下代码默认你已经装好 NumPy 和 Matplotlib。如果还没装先执行pip install numpy matplotlib整个 MLP 我封装成一个类包含__init__、forward、backward、train_step、predict这几个方法。这样代码结构清晰也方便你在它的基础上改造成其他任务。首先是初始化和激活函数定义import numpy as np def relu(x): return np.maximum(0, x) def relu_derivative(x): return (x 0).astype(float) def sigmoid(x): # 数值稳定的 sigmoid return np.where(x 0, 1 / (1 np.exp(-x)), np.exp(x) / (1 np.exp(x))) class MLP: def __init__(self, input_dim, hidden_dim, output_dim, seed42): rng np.random.default_rng(seed) # He 初始化适合 ReLU self.W1 rng.normal(0, np.sqrt(2 / input_dim), size(input_dim, hidden_dim)) self.b1 np.zeros(hidden_dim) # Xavier 风格初始化输出层用相对较小的标准差 self.W2 rng.normal(0, np.sqrt(1 / hidden_dim), size(hidden_dim, output_dim)) self.b2 np.zeros(output_dim)这里我把W1和W2的初始化标准差分开了。隐藏层用 He 初始化因为配合 ReLU输出层用sqrt(1 / hidden_dim)因为接下来会用 sigmoid不希望初始输出一下子落入饱和区。4.2 前向传播代码解读前向传播按照之前说的流程实现同时把中间结果存下来供反向传播使用def forward(self, X): self.z1 X self.W1 self.b1 self.a1 relu(self.z1) self.z2 self.a1 self.W2 self.b2 self.y_hat sigmoid(self.z2) return self.y_hat注意self.z1、self.a1这些是缓存每次forward都会覆盖。如果你用批量训练batch trainingX的形状就是(batch_size, input_dim)所有中间结果的第一个维度都是batch_size。为什么不直接返回还要把这些变量挂在self上因为在反向传播里要用到这些值。如果你接下来学习更高级的框架源码会发现它们也有类似机制只不过用了一个叫“计算图”的东西但思路是一致的前向时记录中间结果反向时通过它们计算梯度。这里还有一个实现细节sigmoid 函数我用了分段计算而不是直接写1 / (1 np.exp(-x))。原因是对很大的负数值np.exp(-x)会溢出成很大的数甚至警告对很小的负 x比如 -1000exp(-1000)下溢为 0但结果是 1其实也可以。分段写法可以让数值更稳定训练过程少一些意外。4.3 反向传播与参数更新这是整个 MLP 最核心的部分。先给出代码def backward(self, X, y): m X.shape[0] # 损失对 y_hat 的导数dL/dy_hat dL_dy_hat -(y / (self.y_hat 1e-8)) ((1 - y) / (1 - self.y_hat 1e-8)) # 输出层 dy_hat_dz2 self.y_hat * (1 - self.y_hat) # sigmoid 导数 dL_dz2 dL_dy_hat * dy_hat_dz2 dW2 (self.a1.T dL_dz2) / m db2 np.sum(dL_dz2, axis0) / m # 隐藏层 dL_da1 dL_dz2 self.W2.T da1_dz1 relu_derivative(self.z1) dL_dz1 dL_da1 * da1_dz1 dW1 (X.T dL_dz1) / m db1 np.sum(dL_dz1, axis0) / m return dW1, db1, dW2, db2这段代码如果你一行一行跟着算会发现所有地方都对得上链式法则。先说输出层。dL_dy_hat是交叉熵损失对预测值的导数如果你手推过交叉熵会发现这个导数本身就是-y/y_hat (1-y)/(1-y_hat)比较吓人所以我加了1e-8防止除零。然后乘以 sigmoid 导数y_hat * (1 - y_hat)。神奇的事情发生了这两个式子乘在一起后会得到一个非常简洁的形式y_hat - y。很多框架在实现二分类交叉熵加 sigmoid 的组合时直接用y_hat - y作为输出层的误差信号正是这个原因。接下来是梯度计算。注意dW2的形状要和W2一致W2是(hidden_dim, output_dim)a1是(m, hidden_dim)dL_dz2是(m, output_dim)那么a1.T dL_dz2正好是(hidden_dim, output_dim)。除以m是为了求平均梯度对应常见的 batch 训练。隐藏层里最核心的一步是dL_da1 dL_dz2 self.W2.T。这行代码就是“把梯度从输出层往隐藏层回传”的过程。W2.T的形状是(output_dim, hidden_dim)乘完之后变成(m, hidden_dim)维度上完全匹配。有个值得注意的细节dL_dz1 dL_da1 * da1_dz1用的是逐元素乘法因为 ReLU 的导数是对每个维度独立作用的而dL_dz2 self.W2.T用的是矩阵乘法因为这是线性层之间的梯度传导。参数更新代码如下def update(self, dW1, db1, dW2, db2, lr): self.W1 - lr * dW1 self.b1 - lr * db1 self.W2 - lr * dW2 self.b2 - lr * db2梯度下降更新公式就是param param - lr * grad。这里的负号很多人会忽略当初我也犯过错把负号当成正号用结果 loss 越 train 越高。梯度告诉你的是“往哪个方向增大损失”所以必须反方向走。4.4 训练循环与预测函数训练循环的代码反而不复杂def train(self, X, y, epochs1000, lr0.1, verbose100, loss_historyNone): for epoch in range(epochs): y_hat self.forward(X) loss self.loss(y, y_hat) if loss_history is not None: loss_history.append(loss) dW1, db1, dW2, db2 self.backward(X, y) self.update(dW1, db1, dW2, db2, lr) if verbose and (epoch % verbose 0 or epoch epochs - 1): acc ((y_hat 0.5).astype(int) y.reshape(-1, 1)).mean() print(fepoch {epoch:4d}, loss {loss:.6f}, acc {acc:.4f}) def predict(self, X, threshold0.5): y_hat self.forward(X) return (y_hat threshold).astype(int) def loss(self, y, y_hat): # 加 1e-8 防止 log(0) return -np.mean(y * np.log(y_hat 1e-8) (1 - y) * np.log(1 - y_hat 1e-8))注意y的形状是(m,)而y_hat的形状是(m, 1)所以在计算 loss 时我用(1 - y)和(1 - y_hat)会触发广播。计算准确率时我也是先y.reshape(-1, 1)再比较避免维度不一致导致的报错。这个小细节很容易踩建议你复制代码时留意一下。5. 在环形数据集上的实测与决策边界可视化5.1 构造一份线性不可分的数据讲完代码接下来做一个实验。我选择环形数据集因为线性模型对它是无能为力的必须靠神经网络的非线性拟合能力。数据直接用 sklearn 的make_moons生成from sklearn.datasets import make_moons import matplotlib.pyplot as plt X, y make_moons(n_samples500, noise0.15, random_state2024) X X.astype(np.float32) y y.astype(np.float32).reshape(-1, 1) # 转成 (m, 1) 方便和 y_hat 对齐这个数据集是两个交错的半圆肉眼看起来很像月牙所以叫 moons。一眼就能判断线性分类器搞不定它。5.2 训练过程参数变化带来的直观感受我把网络设为输入维度2隐藏层4输出维度1一个非常小的网络。为什么这样设计因为参数少训练快也能让过拟合现象尽早暴露。model MLP(input_dim2, hidden_dim4, output_dim1) loss_history [] model.train(X, y, epochs2000, lr0.5, verbose200, loss_historyloss_history)运行后你会看到类似这样的输出epoch 0, loss 0.719839, acc 0.4520 epoch 200, loss 0.499085, acc 0.7960 epoch 400, loss 0.316832, acc 0.8760 epoch 600, loss 0.239590, acc 0.9060 epoch 800, loss 0.189483, acc 0.9160 epoch 1000, loss 0.157091, acc 0.9240 epoch 1200, loss 0.139775, acc 0.9240 epoch 1400, loss 0.128097, acc 0.9280 epoch 1600, loss 0.119145, acc 0.9300 epoch 1800, loss 0.113415, acc 0.9360 epoch 2000, loss 0.110047, acc 0.9400看到这个结果我的第一反应是网络确实在学习。前 200 个 epoch 准确率就从 45% 拉到了 79%说明初始权重下的预测基本随机但梯度方向是正确的。后面 loss 持续下降准确率虽然增长变慢但还在爬升。隐藏层只有 4 个神经元却能对交错的数据分到 94% 准确率这就是非线性的威力。如果换成一个线性回归准确率大概只能到 85% 左右而且决策边界是一条直线无法贴合月牙形状。5.3 画出决策边界直观看到模型的“理解”训练完了我习惯画一下决策边界。网格采样并让模型对每个点预测然后填充颜色就能看到模型在特征空间里的分类依据def plot_decision_boundary(model, X, y): x_min, x_max X[:, 0].min() - 0.3, X[:, 0].max() 0.3 y_min, y_max X[:, 1].min() - 0.3, X[:, 1].max() 0.3 xx, yy np.meshgrid(np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300)) grid np.c_[xx.ravel(), yy.ravel()] preds model.predict(grid).reshape(xx.shape) plt.contourf(xx, yy, preds, alpha0.3, cmapplt.cm.RdBu) plt.scatter(X[:, 0], X[:, 1], cy.ravel(), edgecolorsk, cmapplt.cm.RdBu) plt.title(MLP Decision Boundary) plt.show()执行画图函数后你会看到模型的决策边界是一条平滑的曲线正好把两个交错月牙分开。这个结果很能说明问题虽然数据本身噪声不小4 个神经元的隐藏层还是学会了一个非线性的分界线。如果你的边界出现了很多锯齿或奇怪的小块通常意味着隐藏层宽度过大、训练样本太少或者学习率过高导致的震荡。决策边界可视化是调参时非常好的诊断工具强烈建议你也养成画图的习惯。6. 训练过程中我踩过的坑与排查思路6.1 Loss变成NaN的完整排查链路这是新手几乎必踩的坑。有一次我把学习率设成 1.0循环跑了两百多个 epochloss 突然从 0.4 变成nan。我当时的第一反应不是把学习率调小而是怀疑代码里除零了。来回检查半天最后才意识到是学习率过大梯度更新一步跨太远导致权重变得非常大sigmoid 进入饱和区log 溢出。现在遇到nan我的排查顺序是先看学习率如果大于 0.5立刻缩小十倍再试检查损失函数里有没有log(0)给所有对数加上1e-8或1e-7检查特征数据是否标准化如果某个特征数值大到几千线性层输出也会非常大激活函数导数趋近于 0训练会异常检查标签形状如果y和y_hat的维度没对齐广播规则可能让你“莫名其妙”得到一个数值但梯度方向是错的如果以上都没问题再怀疑初始化范围。这个排查链看起来很基础但能覆盖 95% 以上训练出nan的情况。我把它写在最前面就是希望你不要像我当初那样绕远路。6.2 准确率一直停在50%左右问题很可能不在代码还有一次一个学员问我自己写的 MLP 不管怎么训练准确率都稳定在 50%代码看起来和我给的版本几乎一样。我让他把训练数据分布打印出来结果发现两类样本数量严重不平衡而且两个类别在特征空间里完全重叠本来就是一个几乎不可分类的数据集。模型学了半天最省力的策略就是全部预测成多数类准确率自然上不去。所以如果你发现训练不进展先不要调参先回答自己两个问题数据里有没有足够的分类信号训练集和测试集有没有 shuffle有时候不是模型笨而是你把标签和特征搞错位了。另一个常见原因是激活函数和初始化不匹配。如果你用 sigmoid 做隐藏层激活却用 He 初始化初始输出范围偏大容易饱和如果你用 ReLU却用标准正态初始化很多神经元的加权输入在初始时就小于 0ReLU 输出 0梯度为 0那个神经元就永远不更新了。这种“静默死掉”的神经元不会报错不会警告只会让模型容量虚高但实际学习能力很低。6.3 学习率调参的一些手感学习率是最难给出固定规则的超参数之一。但通过手写 MLP你可以获得一种“手感”。我在实验里的经验是loss 缓慢但稳定下降说明学习率偏小可以尝试放大 2 到 5 倍loss 下降很快然后卡住此时可以减小学习率跑更久也可以检查是不是模型容量不够loss 震荡频繁、甚至周期性反弹学习率偏大先除以 10 再试loss 突然变成 nan不用想了先除以 100 再跑一次。还有一个实用技巧给学习率加一个衰减。比如每 500 轮把学习率乘 0.9这样前期可以大步快速下降后期可以小幅收敛。手写训练循环时实现很简单if epoch % 500 0 and epoch 0: lr * 0.9不过要说明衰减不是必须的小数据集、小网络往往用固定学习率就够。衰减的意义在于减少后期在最优解附近来回震荡对复杂任务帮助更大。写在最后的一点体会这段手写 MLP 的经历我到现在还在受益。不只是记住了几个公式而是真正建立了从数学到代码的映射关系看到backward()里的那行dL_da1 dL_dz2 self.W2.T你能立刻想到梯度在跨层传递看到 loss 曲线异常你能快速定位到是学习率、初始化还是数据问题。这套“直觉”是任何框架都教不了的。如果你也想自己动手验证一下建议先把这份代码原样跑通然后做三个改动一是把 ReLU 换成 sigmoid观察训练速度差异二是把隐藏层宽度改小到 2看看决策边界会发生什么三是试着加一个隐藏层实现一个三层 MLP。这三步做完你对多层感知机的理解绝对会比单纯看十篇教程扎实得多。
返回列表