ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python手写全连接神经网络:从反向传播到梯度下降的实战解析

Python手写全连接神经网络:从反向传播到梯度下降的实战解析 简介一套完全基于 Python 与 NumPy 的简单全连接神经网络实现面向刚接触深度学习的读者旨在帮助摆脱高层框架依赖亲手搭建可运行的训练与预测流程。压缩包共包含 7 个 py 脚本整体仅 4KB从数据生成、公共参数配置、网络模型定义到训练、预测均有对应文件结构精简且便于逐文件学习。目前已有 4211 人学习下载代码的清晰度和易读性得到较多入门者认可。读者在其中可看到 sigmoid 激活函数、均方误差损失、随机权重初始化、前向传播、基于链式法则的反向传播及按轮次迭代更新权重的完整写法吃透后能更自然过渡到 TensorFlow、Keras 或 PyTorch 等主流框架为后续搭建深层网络打下扎实基础。1. 全连接神经网络为什么还值得用 Python 手写一遍我第一次把深度学习跑起来用的是别人封装好的模型真正遇到不收敛的时候完全不知道往哪儿看。后来我放下框架用 Python 从零实现了一个简单全连接神经网络把矩阵乘法、激活函数、梯度更新全变成自己能改的代码很多被我当“玄学”处理的现象突然变成了可以逐行算清楚的东西。这篇文章就沿着这条路径讲一遍先用最小代码实现前向传播和反向传播再组织成可复用的完整训练闭环用 sklearn 造的数据验证能不能学最后把最容易翻车的 5 个坑按现象、原因、解决方法列出来。适合刚入门深度学习、想搞懂梯度下降本质的新手也适合一直在调库、想补上模型内部“黑匣子”的工程师。2. 从感知机到三层全连接前向传播和反向传播的最小闭环2.1 全连接层到底在算什么线性加权加非线性的直觉一个全连接层做的事情压缩成一句话就是输入先做线性变换再过一次非线性激活函数。线性变换写成 y Wx bW 是权重矩阵b 是偏置向量激活函数常用 sigmoid、tanh 或 ReLU它们的作用是让多层叠加之后不会等效成一层线性函数。这句话听起来简单但很多人翻车就翻在没把“为什么需要非线性”想透。如果每一层都只做线性变换那么无论网络叠多少层都可以压缩成一个线性变换处理不了异或这类线性不可分的问题。加非线性的意义在于给模型一个“弯折空间”的能力隐藏层越多、越宽能弯折的复杂程度就越高。全连接网络本质上就是一个参数化的复合函数训练的过程就是在调整 W 和 b让这个函数在训练数据上的误差最小。先把这个直觉建立起来后面调试网络时才有方向可循。每个全连接层的维度关系也很固定输入向量长度是 input_size隐藏层神经元个数是 hidden_size输出个数是 output_size。W1 的形状是 input_size × hidden_sizeW2 的形状是 hidden_size × output_size。参数总量就是这两个矩阵加上两个偏置向量。手写实现的好处在于每一步内存形状都是可控的出错时能直接打印每个中间变量来核对不像框架那样抛出一大串堆栈信息。变量形状含义x(input_size,)单个样本的特征向量W1(input_size, hidden_size)输入层到隐藏层的权重b1(hidden_size,)隐藏层偏置z1(hidden_size,)隐藏层线性输出a1(hidden_size,)隐藏层激活输出W2(hidden_size, output_size)隐藏层到输出层的权重b2(output_size,)输出层偏置a2(output_size,)最终预测值2.2 前向传播的最小 Python 实现三层网络的一次计算以一个输入维度为 2、隐藏层 4 个神经元、输出 1 个值的网络为例。实现前先定义 sigmoid 激活函数。很多初学版本直接写1 / (1 np.exp(-x))一旦 x 是很大的负数exp 可能溢出。更稳的写法是先用 np.clip 把输入约束在一个合理区间再做指数运算。import numpy as np def sigmoid(x): # 数值稳定的 sigmoid防止 exp 溢出 return 1.0 / (1.0 np.exp(-np.clip(x, -500, 500))) np.random.seed(42) input_size 2 hidden_size 4 output_size 1 # 一个样本两个特征 x np.array([0.6, -0.3]) # 初始化权重乘 0.5把输出压到 sigmoid 的非饱和区 W1 np.random.randn(input_size, hidden_size) * 0.5 b1 np.zeros(hidden_size) W2 np.random.randn(hidden_size, output_size) * 0.5 b2 np.zeros(output_size) # 前向计算 z1 x.dot(W1) b1 a1 sigmoid(z1) z2 a1.dot(W2) b2 a2 sigmoid(z2) print(隐藏层输出 a1:, a1) print(网络输出 a2:, a2)前向传播的逻辑很直接x 与 W1 做点乘得到长度是 hidden_size 的中间向量 z1sigmoid 对其中每个元素独立压缩到 0 到 1 之间a1 再与 W2 点乘得到输出层的线性结果 z2最后再过一次 sigmoid 得到预测值 a2。这里为什么要乘 0.5因为如果不做缩放np.random.randn生成的权重方差是 1输入加权后很容易落到 sigmoid 两端。sigmoid 两端的导数几乎是 0反向传播时梯度传不回去网络第一次更新就可能走不动。注意这里 x.dot(W1) 是 numpy 的向量点乘等价于对 W1 每一列做加权求和。单样本时这种写法直观到了批量训练我们会换用二维数组做矩阵乘法一次算一整批样本代码结构基本不变但速度会快很多。2.3 反向传播的最小 Python 实现链式法则走一遍梯度更新前向算出了预测值 a2接下来要拿它和真实标签比。这里的损失函数用均方误差 L (a2 - y)^2 / 2除以 2 是为了让求导后消掉系数。反向传播要做的事情就是从输出层往回走用链式法则逐层求出 L 对每个参数的偏导数然后沿负梯度方向更新权重。def sigmoid_derivative(x): s sigmoid(x) return s * (1.0 - s) # 真实标签二分类任务用 0/1 y np.array([1.0]) # 沿用上面前向计算得到的 a1、z1、z2、a2 # 输出层误差 MSE 导数 * sigmoid 导数 dz2 (a2 - y) * sigmoid_derivative(z2) # 第二层权重梯度 dW2 np.outer(a1, dz2) db2 dz2 # 往隐藏层反传 da1 W2.dot(dz2) dz1 da1 * sigmoid_derivative(z1) # 第一层权重梯度 dW1 np.outer(x, dz1) db1 dz1 # 梯度下降更新 lr 0.5 W2 - lr * dW2 b2 - lr * db2 W1 - lr * dW1 b1 - lr * db1逐行看这几个梯度式子。dz2 是两个导数相乘的结果(a2 - y)来自均方误差对 a2 的导数sigmoid_derivative(z2)来自激活函数对 z2 的导数。dW2 用np.outer(a1, dz2)计算原因是 W2 的每个元素同时被 a1 的某个分量和 dz2 影响外积正好把这两部分组合成和 W2 同形状的梯度矩阵。隐藏层的 dz1 先把输出层误差通过 W2 投影回来再乘上当前层激活函数的导数。最后一层和第一层只是同样的规则反复套用。这段代码里学习率 lr 是 0.5。学习率越大每一步权重更新幅度越大收敛看起来更快但超过一定阈值后损失会在最优值附近来回震荡甚至直接发散。对 sigmoid 这类输出范围有限的激活函数0.1 到 0.5 是一个还算安全的起步区间。如果你把学习率改成 2.0 再跑大概几十次更新后就能看到损失开始变大这就是后面要详细讲的典型翻车场景。3. 把玩具变成工具用 Numpy 组织一个可复用的全连接神经网络3.1 代码结构怎么放网络定义、数据准备、训练循环三件事分开最小实现跑通后下一步是把代码组织成能重复使用的类。我一般会把网络定义、数据准备、训练循环三件事分开写。网络类只负责前向和反向数据部分单独准备训练循环负责调度。这样改隐藏层宽度时不用动数据代码换数据集时也不用碰网络代码排查问题的时候少走很多弯路。依赖方面只需要 numpy 和 scikit-learn。如果你环境里还没装过命令行执行pip install numpy scikit-learn就行。numpy 负责矩阵运算sklearn 用来生成数据集和做训练测试拆分后面画决策边界还会用到 matplotlib也可以一起装上。import numpy as np class SimpleNN: def __init__(self, input_size, hidden_size, output_size, seed42): np.random.seed(seed) # 采用 He 初始化按层宽度缩放避免梯度消失/爆炸 self.W1 np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) self.b1 np.zeros(hidden_size) self.W2 np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size) self.b2 np.zeros(output_size) def forward(self, x): self.z1 x.dot(self.W1) self.b1 self.a1 sigmoid(self.z1) self.z2 self.a1.dot(self.W2) self.b2 self.a2 sigmoid(self.z2) return self.a2 def backward(self, x, y, lr): dz2 (self.a2 - y) * sigmoid_derivative(self.z2) dW2 np.outer(self.a1, dz2) db2 dz2 da1 self.W2.dot(dz2) dz1 da1 * sigmoid_derivative(self.z1) dW1 np.outer(x, dz1) db1 dz1 # 梯度下降更新 self.W2 - lr * dW2 self.b2 - lr * db2 self.W1 - lr * dW1 self.b1 - lr * db1这个类和前面最小实现的区别主要有两点。一是把 W1、b1、W2、b2 变成了对象属性forward 和 backward 共享状态不需要每次手动传参。二是初始化改成了 He 初始化权重乘以sqrt(2.0 / input_size)。为什么用这个缩放系数因为如果输入维度很大Wx 累加的结果方差也会大z 容易进入 sigmoid 饱和区。按层宽度缩放后每层输出的方差保持在同一个量级反向传播时梯度能顺利传回第一层。参数说明hidden_size 控制网络的容量取 32 表示隐藏层有 32 个神经元。hidden_size 太小模型学不到复杂边界太大容易过拟合。后面我们会用同一个数据集对比不同 hidden_size 的效果。3.2 用 sklearn 造一份二分类数据并完成训练闭环验证网络不能只用线性可分的数据否则体现不出隐藏层的价值。这里用 sklearn 的 make_moons 生成一份非线性可分的二分类数据两个类别分别像两弯月亮交错分布线性分类器做不了但三层全连接网络可以逼近出一个弯曲的决策边界。from sklearn.datasets import make_moons from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler # 生成 800 条样本噪声 0.2固定随机种子保证可复现 X, y make_moons(n_samples800, noise0.2, random_state42) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) # 标准化每个特征均值归 0方差归 1 scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test)标准化这一步非常关键。make_moons 生成的特征范围大概在 -2 到 2 之间看起来数值不大但如果换一份真实数据有些特征动辄几百上千直接喂进网络会让梯度更新变得非常不稳定。用 StandardScaler 把训练集特征标准化再用同一个 scaler 去转换测试集是数据预处理的标准动作。注意这里 fit_transform 只能用在训练集上测试集只做 transform否则测试数据的分布信息会泄漏进预处理过程评估结果会偏乐观。接下来是完整的训练循环。每个 epoch 里先打乱样本顺序再逐个样本前向和反向更新。打乱顺序是为了避免数据本身的排列规律影响梯度估计比如前 100 条全是类别 0后 100 条全是类别 1固定顺序会让损失曲线出现周期性抖动。model SimpleNN(input_size2, hidden_size32, output_size1) lr 0.1 epochs 500 for epoch in range(epochs): # 每个 epoch 重新打乱样本顺序 indices np.random.permutation(len(X_train)) epoch_loss 0.0 for i in indices: x_i X_train[i] y_i np.array([y_train[i]]) pred model.forward(x_i) loss 0.5 * float((pred - y_i) ** 2) epoch_loss loss model.backward(x_i, y_i, lr) if epoch % 100 0: print(fepoch {epoch:3d}, avg_loss {epoch_loss / len(X_train):.4f}) # 测试集精度 correct 0 for i in range(len(X_test)): pred model.forward(X_test[i])[0] if int(pred 0.5) y_test[i]: correct 1 print(ftest accuracy {correct / len(X_test):.4f})正常情况下这个网络跑到 300 epoch 左右训练损失会降到 0.1 以下测试精度可以到 90% 上下。如果损失曲线一直平坦或者精度停留在 50% 左右那说明有地方出了问题大概率就在初始化、学习率或数据预处理这三件事上。关于阈值 0.5输出层的 a2 是 sigmoid 的结果落在 0 到 1 之间二分类里约定大于 0.5 判为类别 1否则判为类别 0。这个阈值也可以调整比如类别不平衡时把阈值移到 0.6 或 0.7能改善少数类别的召回率但一般先把 0.5 跑通再谈调阈值。3.3 三个必调参数学习率、隐藏层宽度、Epoch 轮数训练跑通之后最重要的三个可调参数是学习率、隐藏层宽度和 epoch 轮数。它们直接影响收敛速度和最终精度而且相互关联调一个不调另一个很容易得出错误结论。一个常见误区是看到损失没降第一时间把学习率调大。学习率调大的短期效果确实是损失降得快但如果踩到临界值损失会在某个数值附近反复横跳甚至直接变成 nan。另一个误区是追求测试精度把 hidden_size 从 32 加到 512。模型对训练集拟合得更好了但测试集上的表现可能反而变差这就是过拟合的典型信号。参数常见取值范围调大的后果调小的后果学习率 lr0.01 ~ 0.5收敛加快但易振荡过大直接 nan收敛变慢容易误判为模型不工作hidden_size16 ~ 128拟合能力变强过拟合风险增加欠拟合损失降不下去epochs200 ~ 1500训练更充分但可能过拟合欠拟合损失曲线仍在下降数据打乱每个 epoch 打乱训练更稳定固定顺序可能学到局部模式调参的顺序也有讲究。我一般先固定 hidden_size 32把学习率放到 0.1 跑一轮看损失有没有下降趋势然后用不同学习率对比找到不太振荡的最大值最后再动 hidden_size观察训练损失和测试精度的差距。一次只动一个参数否则多个变量同时变化出了问题根本定位不到原因。4. 训练翻车事件簿全连接神经网络最常见的 5 个坑与排查顺序4.1 损失变成 nan初始化方案和学习率谁背锅现象训练到几百个 epoch 后 avg_loss 突然打印出 nan有时第一个 epoch 就出问题。特征值一检查矩阵里全是 nan权重更新也全是 nan。原因先从代码找。最典型的是 sigmoid 的np.exp(-x)在 x 特别大的负数时溢出。另一个原因是学习率太大某一步更新后权重绝对值过大下一轮前向传播又继续放大最后数值直接溢出到无穷。此外初始化方差过大也会让 z1、z2 的基础数值过大从源头上就让激活函数进入危险区域。解决分三步第一把 sigmoid 改成数值稳定版本用 np.clip 限制指数运算的输入范围第二把学习率从 0.5 降到 0.01 或 0.1观察 loss 是否恢复第三把初始化换成 He 初始化也就是乘以sqrt(2.0 / input_size)。这三步做完绝大多数 nan 问题都能消除。如果还是 nan那就要检查输入数据里有没有 inf 或 nanStandardScaler 之后按行打印几个样本看看。4.2 损失陷在 0.5 附近不动归一化与梯度消失现象训练损失一直停留在 0.5 左右既不上升也不下降。二分类的均方误差最大是 1最小是 00.5 差不多相当于在随机猜。测试精度也稳定在 50% 附近。原因常常是两条。一是输入没有归一化特征数值大的维度在加权求和后直接让 z 落到 sigmoid 饱和区sigmoid 导数趋近 0梯度传不回去权重几乎不再更新。二是初始化权重太大导致刚起步网络就进入饱和区跟第一点的效果一样。也有可能是学习率太小比如设成 0.0001看起来损失不动但实际上是每一步的更新幅度肉眼不可见。解决方法是先确认数据是否做了标准化用X_train.min()和X_train.max()看一眼数值范围。接着把学习率调到 0.1 再跑同时检查初始化的缩放因子。验证一个网络是否真的在学习的通用技巧是拿少量样本比如 10 条去反复拟合。如果 10 条样本的损失都降不下去那一定是模型或优化本身有问题而不是数据量不够。4.3 全部分到同一个类输出层阈值的设置误区现象训练结束时损失已经降到 0.2 以下但测试精度只有 40% 或 60%打印预测结果发现模型把所有样本都判成了 0 或都判成了 1。原因有几个可能。最常见的是输出层把 sigmoid 丢了直接用线性输出而标签是 0/1阈值 0.5 的判定方式完全失效线性输出可能是任意值。还有一种情况是标签本身是 1/2 而不是 0/1网络输出的 0.5 阈值和标签体系对不上。另外如果隐藏层宽度太小模型拟合能力不足也可能出现把所有样本拉到同一个类的情况。解决方法是先检查输出层是否经过 sigmoid排除激活函数丢失再检查标签取值是 0/1 还是 1/2不一致就做转换。如果两者都正常再加大 hidden_size从 32 加到 64 或 128看预测分布是否出现变化。还有一种便宜的排查方式打印前 10 条测试样本的预测值和真实标签肉眼对比一下比盯着一个准确率数字有效得多。4.4 训练好测试差过拟合的三个预警信号现象训练损失一路降到 0.05 以下训练精度接近 100%但测试精度只有 80% 左右并且随着 epoch 增加测试精度开始下降。原因就是过拟合模型把训练数据的细节记住了而不是学到了背后的规律。全连接网络参数越多越容易过拟合尤其训练样本只有几百条时。hidden_size 从 32 加到 256参数量急剧增加模型有足够容量去记住每一个训练样本的噪声。解决思路有三个。第一降低 hidden_size让模型容量回到和数据量匹配的水平。第二用早停训练过程中记录测试精度如果测试精度连续几十个 epoch 没有提升就停止训练不要用满 epochs。第三给损失函数加 L2 正则项在每次权重更新时加一个很小的衰减系数比如 0.001让权重不要长到过分大的数值。对全连接网络来说先减容量再看正则是最稳的调整路径。4.5 排查顺序先看损失曲线还是先看精度很多同学一上来就盯着测试精度看损失还没降到位就开始评估模型好不好。这个习惯会误导调参方向因为训练初期的精度受初始化影响很大同一个学习率跑不同随机种子精度能差 10 个百分点。我自己的排查顺序是先看训练损失曲线再看测试精度。训练损失决定“模型有没有在学习”测试精度决定“学到的规律能不能泛化”。如果训练损失不降优化环节有问题如果训练损失降了但测试精度不升泛化环节有问题。两个问题不要混在一起调参。另外绘制损失曲线时横轴是 epoch纵轴是平均损失如果曲线是锯齿形的说明学习率偏大可以减半再试。如果曲线是平滑下降后出现平台说明已经接近当前容量的极限再训练下去收益有限。提示给训练循环加一个记录器每个 epoch 保存训练损失和测试精度训练结束后一起画图。有了这张图很多模型问题一眼就能看出是欠拟合、过拟合还是优化不稳比反复猜快得多。5. 手写网络能不能用三项验证和一个进阶方向5.1 和 sklearn 的 MLPClassifier 同场对比手写网络跑通后第一件事是验证它跟成熟库的差距有多大。sklearn 的 MLPClassifier 底层是优化过的全连接网络参数含义跟自己实现几乎一一对应。把激活函数设为 logistic对应我手写的 sigmoid学习率、隐藏层、最大迭代都保持一致。from sklearn.neural_network import MLPClassifier mlp MLPClassifier( hidden_layer_sizes(32,), activationlogistic, solversgd, learning_rate_init0.1, max_iter500, random_state42, ) mlp.fit(X_train, y_train) print(sklearn test accuracy:, mlp.score(X_test, y_test))跑完你会发现sklearn 版本在同样的超参数下测试精度一般比自己写的略高而且训练速度更快。原因在于它对权重初始化有额外优化并且使用了自适应学习率。这个对比给别人看就是你的实现是正确且有意义的给自己看则是知道了简单实现和工业实现之间的差距在哪里。5.2 画决策边界直接看见欠拟合和过拟合验证模型的第二个手段是可视化。把整个平面按网格切分让模型对每个网格点做预测再按预测类别着色画出来的就是决策边界。import matplotlib.pyplot as plt x_min, x_max X_test[:, 0].min() - 1, X_test[:, 0].max() 1 y_min, y_max X_test[:, 1].min() - 1, X_test[:, 1].max() 1 xx, yy np.meshgrid( np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200), ) grid np.c_[xx.ravel(), yy.ravel()] pred_grid np.array([model.forward(p)[0] for p in grid]) pred_grid (pred_grid 0.5).astype(int).reshape(xx.shape) plt.contourf(xx, yy, pred_grid, alpha0.4, levels[-0.5, 0.5, 1.5]) plt.scatter(X_test[:, 0], X_test[:, 1], cy_test, edgecolork) plt.title(decision boundary) plt.show()决策边界会直接告诉你很多损失函数看不出来的信息。如果边界是一条直线说明隐藏层没有发挥作用问题大概率出在激活函数或梯度传播。如果边界弯曲得极度夸张把单个样本都单独圈出来那就是过拟合。一个健康的模型边界应该平滑地把两类分开而不是跟着每个点走。5.3 进阶方向把逐样本 SGD 改成 Mini-batch 训练逐样本更新每个 epoch 要做很多次方向不一致的梯度更新训练效率低而且损失曲线抖动很大。进阶的一步是把每个样本单独更新改成一次处理一小批样本典型 batch_size 是 16 或 32。好处有两个矩阵运算能一次算完一批速度更快梯度经过多样本平均后更稳定学习率可以适当调大。batch_size 32 for epoch in range(epochs): indices np.random.permutation(len(X_train)) for start in range(0, len(X_train), batch_size): batch indices[start:start batch_size] X_batch X_train[batch] y_batch y_train[batch].reshape(-1, 1) # 前向整批一起算 z1 X_batch.dot(model.W1) model.b1 a1 sigmoid(z1) z2 a1.dot(model.W2) model.b2 a2 sigmoid(z2) # 反向 dz2 (a2 - y_batch) * sigmoid_derivative(z2) dW2 a1.T.dot(dz2) db2 dz2.mean(axis0) da1 dz2.dot(model.W2.T) dz1 da1 * sigmoid_derivative(z1) dW1 X_batch.T.dot(dz1) db1 dz1.mean(axis0) # 更新 model.W1 - lr * dW1 model.b1 - lr * db1 model.W2 - lr * dW2 model.b2 - lr * db2这段代码里唯一的形状变化是把单个样本换成了二维数组比如 X_batch 的形状是 (32, 2)。a1.T.dot(dz2) 得到的是 (32, 2) 的转置和 (32, 1) 的矩阵乘法结果形状恰好是 (2, 1)对应 dW1。偏置项用dz2.mean(axis0)取平均而不是 sum原因是平均后的偏置更新不随 batch_size 变化学习率可以跨 batch_size 复用自己的经验。我的习惯是第一次实现和调参都坚持用逐样本版本因为每个步骤的梯度形状都清晰可见跑通之后立刻换成 mini-batch再对比一次收敛曲线。这个对比本身就是很好的学习材料同一份数据同样学习率mini-batch 的损失曲线会更平滑训练时间更短。掌握这一步之后把这份实现改造成一个简单的因子打分器也算是量化交易策略代码里常见的第一步。这个方向背后还有正则化、动量、学习率调度等我踩过的坑留给你一步步去填。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进