ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

BP神经网络Python实现:从零手写回归预测代码与调参指南

BP神经网络Python实现:从零手写回归预测代码与调参指南 简介资源为bp.zip压缩包内含单个Python源码文件大小仅1KB却实现了完整的BP神经网络核心流程。这份代码基于numpy编写面向深度学习入门者及需要在Python环境中快速搭建预测模型的开发者可直接用于分类或回归任务比如股价走势、温度变化等数值预测。代码涵盖数据预处理、网络结构定义、权重初始化、前向传播、反向传播和训练循环等关键环节并支持通过调整迭代次数叠加次数与学习率步长来控制训练效果用户只需替换输入数据即可适配自己的业务场景。目前已有375人学习下载文件虽小但逻辑清晰适合对照理论逐行学习也为其后扩展多层隐藏层、引入可视化调试或优化算法提供了简洁的起点适合课程设计或毕业设计参考。1. bp.py 在预测任务里的定位一份从零写起的 BP 神经网络源码bp.zip 里的 bp.py是一个用 Python 手写实现的 BP 神经网络核心就一个文件干的事情是拿历史数据训练一个多层前馈网络训练完再对新的输入做预测。很多来下载这份源码的人并不是来研究 bp 神经网络结构图的而是手里有一批数据要做课程设计、实验或者论文里的一个环节——比如预测建材价格、股票收盘价这类数值预测。这份资源实际能解决的就是这类问题把影响因子整理成特征矩阵喂进去迭代几千轮代码里的叠加次数按步长学习率调权重得到一个能对未知数据输出预测值的模型。适合两类人一类是想读一遍 BP 神经网络 Python 代码、搞清楚梯度下降怎么落地的入门者另一类是拿它当基线模型、想快速出第一版预测结果再换复杂算法的。边界也很清楚只有 bp.py 一个源码文件没有配套数据集归一化、切分、调参这些都得自己补。2. 网络结构与权重初始化三层节点数和随机数里藏着的门道2.1 三层网络结构输入、隐藏、输出节点数怎么定BP 神经网络的标准结构是三层前馈输入层、隐藏层、输出层。bp.py 作为一份教学性质的基础实现默认就是这种结构隐藏层可以扩成多层但一份跑教学的代码通常先把单层隐藏层跑通就够了。输入层节点数没有悬念等于你的特征维度。比如你预测建材价格输入端用的是水泥价格指数、钢材期货价、人工成本、运输成本四个变量输入层就是 4 个节点。输出层节点数等于预测目标的数量单目标回归就是 1 个节点换成分类任务才需要多个节点。隐藏层节点数是整个网络里唯一需要人工试探的超参数。它的取值没有数学上的标准答案业内通常先用经验公式估算一个范围再在这个范围里试几个值对比效果。最常见的两个估法一个是取 sqrt(输入节点数 × 输出节点数)再加一个 1 到 10 的常数另一个更粗糙直接取输入节点数的 1.5 到 2 倍。我一般习惯先按输入节点数的 1.5 倍跑通整个流程然后在该值上下各取两个数字看验证集误差哪个小就用哪个不追求一次到位。按任务规模给一个起步参考表| 任务规模 | 输入节点 | 隐藏节点 | 输出节点 | 典型场景 | | 小 | 2-4 | 4-8 | 1 | 两三个特征预测一个目标回归入门 | | 中 | 5-10 | 8-20 | 1-3 | 多因子价格预测、简单分类 | | 大 | 10 | 16-40 | 1-N | 特征较多的回归或多分类 |要说明的是这个表不是硬约束。隐藏节点太少网络拟合能力不足训练集误差都压不下去隐藏节点太多可学习参数数量膨胀训练样本不够时极易过拟合。对 bp.py 这种基础实现从隐藏节点 输入节点 × 1.5起步是最省事的因为后续换数据、调学习率时你不用反复改结构。另外一个容易被忽略的点是为什么中间必须要这一层非线性激活如果三层全部是线性变换无论堆多少层在数学上都能等价成一层线性加权拟合不了任何弯折的映射关系。sigmoid 这类非线性函数引入后网络才有能力逼近非线性函数。这也是 BP 神经网络区别于普通线性回归的根本原因理解这一点再去看代码前向传播那两行矩阵乘法才不是死记。2.2 权重初始化numpy 里的小随机数藏着什么讲究打开 bp.py如果看过源码就会注意到导入 numpy 之后第一件事基本就是生成随机权重。这个随机数的取值范围不是随便定的它直接决定网络能不能正常进入训练状态。权重取太大输入经过线性加权后落入 sigmoid 的饱和区导数值接近 0反向传播传回来的梯度也随即消融表现就是训练好几轮 loss 纹丝不动。常见做法是生成 [-1, 1] 区间的小随机数再乘一个缩放系数让初始权重落在 [-0.5, 0.5] 附近。如果输入特征维度很高可以进一步缩到 1/sqrt(input_size)保证加权和的量级不随维度增大而膨胀。numpy 实现如下import numpy as np def init_network(input_size, hidden_size, output_size, seedNone): if seed is not None: np.random.seed(seed) # 输入层到隐藏层权重矩阵shape (input_size, hidden_size) w1 np.random.uniform(-1, 1, size(input_size, hidden_size)) * 0.5 # 隐藏层到输出层权重矩阵shape (hidden_size, output_size) w2 np.random.uniform(-1, 1, size(hidden_size, output_size)) * 0.5 # 每层各配一个偏置向量让激活函数可以左右平移 b1 np.zeros((1, hidden_size)) b2 np.zeros((1, output_size)) return w1, b1, w2, b2这段代码的逻辑是w1 负责把输入特征线性组合成隐藏层的加权输入每个隐藏节点其实就是一个加权求和器b1 为这组加权和加一个可偏移的常数。w2 再把隐藏层的激活输出组合成最终预测值。权重乘 0.5 是为了把初始加权输出尽量压在 sigmoid 的中间线性区间附近避免一开始就进入饱和区。seed 参数用于复现实验同样的随机种子每次跑结果完全一致而调参时如果没有固定 seed两次运行的初始权重不一样改一个参数得到的效果差异就分不清是参数造成的还是随机性造成的。参数说明input_size 必须和特征矩阵 X 的列数一致写错了 numpy 会直接报维度不匹配hidden_size 是唯一需要人工试的超参数output_size 在回归任务里通常填 1。偏置全部初始化为 0 而不是随机数这在实践中对收敛速度影响很小好处是第一次前向传播的输出值更容易反推计算过程便于新手验证自己对网络的理解。2.3 激活函数选择与整体超参数表bp.py 里的激活函数大概率是 sigmoid因为 BP 神经网络的教材推导基本都拿它讲。sigmoid 的输出区间是 (0, 1)天然适合处理归一化后的数据。不过如果你的数据预处理时归一化到了 [-1, 1]可以考虑换成 tanh它在 0 附近的梯度更大收敛往往更快代价是反向传播公式里导数项要跟着换。给一张超参数起步表拿到 bp.py 后先按这个底子跑一遍再逐步收紧| 参数 | 推荐初值 | 调整方向 | 说明 | | 学习率步长 | 0.01 ~ 0.1 | 大 → 小 | 过大 loss 震荡发散过小收敛太慢 | | 叠加次数epoch | 1000 ~ 10000 | 看 loss 曲线 | 教学场景 1000 次起步看曲线再决定加不加 | | 隐藏层节点 | 输入节点 × 1.5 | 上下各试 2 个 | 用验证集误差选别盯着训练集 | | 权重初始化范围 | [-0.5, 0.5] | 再小不过 1/sqrt(n) | 防止 sigmoid 饱和导致梯度消失 |这些参数在 bp.py 里对应的是叠加次数和步长两个变量改起来很直接但改完以后怎么判断改得对不对要看训练过程中的 loss 输出曲线这部分到第 6 章展开。3. 前向传播到反向传播训练循环里每一步都在算什么3.1 前向传播矩阵乘法和 sigmoid 激活前向传播是 bp.py 里最直观的部分本质就两行矩阵运算加一次激活函数。对输入 X 做线性加权过激活函数再线性加权再过一次输出激活得到预测值。实现如下def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) def forward(w1, b1, w2, b2, X): # 隐藏层加权输入 z1 np.dot(X, w1) b1 # 隐藏层激活输出 a1 sigmoid(z1) # 输出层加权输入 z2 np.dot(a1, w2) b2 # 输出层激活输出即预测值 y_pred sigmoid(z2) return y_pred, a1, z1, z2这段代码的逻辑是X 的形状是 (m, input_size)m 是样本条数。np.dot(X, w1) 得到 (m, hidden_size)加上 b1 后每个隐藏节点收到一个来自全部特征的加权和。sigmoid 把这个加权和压到 (0, 1) 区间作为非线性输出 a1。a1 再乘 w2得到 (m, output_size) 的 y_pred。返回 a1、z1、z2 是因为反向传播计算梯度时要反复用到这些中间值如果前向传播不返回它们反向传播阶段还得重新算一遍。回归任务里一个值得注意的点如果预测目标只有正数且量级集中在某个区间最后套一层 sigmoid 是可接受的预测完再还原量纲。但如果目标值可能跨正负或者范围明显超出 (0,1)输出层的 sigmoid 就帮了倒忙这时应该改成纯线性输出也就是 z2 直接作为 y_pred倒数第 5 章会专门讲这个坑。3.2 反向传播误差怎么摊回去反向传播是新手最容易懵的部分。它的核心思想一句话能说清把输出层的误差沿网络逐层往回分配每一层根据分配到的误差求出权重梯度。这里的依据就是链式法则——损失对某个权重的偏导等于损失对输出的导数乘以输出对该权重的导数一路乘下去。以回归任务最常用的均方误差损失为例def backward(X, y, y_pred, a1, z1, z2, w2): m X.shape[0] # 输出层误差MSE 对 z2 的偏导注意乘上 sigmoid 导数 d_z2 (y_pred - y) * sigmoid(z2) * (1 - sigmoid(z2)) # w2 梯度a1 转置乘 d_z2再除以样本数取平均 d_w2 np.dot(a1.T, d_z2) / m # b2 梯度误差求和取平均keepdims 保持矩阵形状 d_b2 np.sum(d_z2, axis0, keepdimsTrue) / m # 误差经 w2 传回隐藏层 d_a1 np.dot(d_z2, w2.T) # 隐藏层误差乘 sigmoid 在 z1 处的导数 d_z1 d_a1 * sigmoid(z1) * (1 - sigmoid(z1)) # w1 梯度与 b1 梯度 d_w1 np.dot(X.T, d_z1) / m d_b1 np.sum(d_z1, axis0, keepdimsTrue) / m return d_w1, d_b1, d_w2, d_b2逻辑说明d_z2 是损失对输出层加权输入 z2 的梯度sigmoid(z2)*(1-sigmoid(z2)) 是 sigmoid 的导数由链式法则自然出现。d_w2 的形状必须和 w2 完全一致用 a1.T 乘 d_z2 才对齐这个转置关系是新手最容易写错的地方。d_z1 的计算体现了反向传播这个名字的由来输出层误差经过 w2.T 传递回隐藏层再乘隐藏层激活函数导数。除以 m 是取平均梯度好处是学习率和样本量解耦——换了一批更多或更少的数据学习率不用重新调。如果漏掉这里同样的学习率在不同数据量下收敛速度会天差地别。3.3 训练循环叠加次数和步长的配合训练循环就是把前向传播、反向传播、权重更新三件事重复执行叠加次数epoch和前向反向执行的次数一一对应。bp.py 里的训练主逻辑大致如下def train(X, y, hidden_size8, epochs2000, lr0.05): input_size X.shape[1] output_size 1 w1, b1, w2, b2 init_network(input_size, hidden_size, output_size) losses [] for epoch in range(1, epochs 1): # 前向传播拿预测值 y_pred, a1, z1, z2 forward(w1, b1, w2, b2, X) # 反向传播拿各层梯度 d_w1, d_b1, d_w2, d_b2 backward(X, y, y_pred, a1, z1, z2, w2) # 权重更新步长 lr 决定每步走多远 w1 - lr * d_w1 b1 - lr * d_b1 w2 - lr * d_w2 b2 - lr * d_b2 if epoch % 100 0: loss np.mean((y_pred - y) ** 2) losses.append(loss) print(fepoch {epoch}, loss {loss:.6f}) return w1, b1, w2, b2, losses两个参数的含义在代码里很直观epoch 就是描述里的叠加次数设成 2000 意味着同一批数据被前向反向跑 2000 遍每次迭代都按当前梯度方向把权重挪一小步lr 就是步长控制这一步的大小。lr 取 0.05 属于保守起步值对大多数归一化后的回归数据都能稳定下降。loss 打印间隔设成 100每 100 次看一眼下降趋势不用每一轮都往终端刷。需要注意这个训练循环没有做任何学习率衰减也没有早停机制。bp.py 作为基础实现是够用的但当你发现 loss 在某个水平震荡不再下降时不要盲目加大 epochs那只会浪费时间正确做法先看是不是学习率太大再考虑是否遭遇了局部最优具体排查在第 5 章。4. 数据预处理与完整调用拿到 bp.py 后第一件事做什么4.1 归一化为什么是 BP 能不能收敛的前置条件bp.py 没有自带数据集所以到手第一件事是处理你自己的数据。sigmoid 的输出区间是 (0, 1)如果输入特征的量级是几千几万加权和会非常大sigmoid 直接饱和梯度消失训练等于白跑。归一化不是可选项是前置条件。min-max 归一化的 numpy 实现很简单但有一个细节容易忽略保存归一化用的最小值和跨度预测阶段要用同一组参数还原量纲。def minmax_normalize(data, feature_range(0, 1)): min_val np.min(data, axis0) max_val np.max(data, axis0) span max_val - min_val # 某个特征全是相同值时跨度会是 0加个保护防止除零 span[span 0] 1 normed (data - min_val) / span normed normed * (feature_range[1] - feature_range[0]) feature_range[0] return normed, min_val, span逻辑说明data 可以是特征矩阵也可以是目标列axis0 表示按列取最小值和最大值每一列独立归一化。返回值里 min_val 和 span 必须留下来因为训练完成后新来的预测数据要先用训练时保存的 min_val 和 span 做同样的变换否则训练用的是 0 到 1预测时喂的是原始量级输出必然错得离谱。参数说明feature_range 默认是 (0, 1)对应 sigmoid 输出区间。如果换用 tanh 激活可以改成 (-1, 1)效果通常更好。span[span 0] 1 这行是防御性写法特征全相同的情况在实际数据里不少见不加这行程序会直接报除零错误。4.2 训练集与测试集划分固定随机种子才能公平对比训练循环如果拿全部数据反复过模型对训练数据会拟合得非常好但看不出泛化能力。留出一部分数据做测试是 BP 神经网络实验的基本操作。常见比例是留出 20% 左右顺序要先打乱再切分不然按时间排序的数据会让训练集和测试集分布不一致。def split_dataset(X, y, test_ratio0.2, shuffleTrue, seed42): m X.shape[0] idx np.arange(m) if shuffle: # 固定 seed保证每次运行时切分结果一致 rng np.random.default_rng(seed) rng.shuffle(idx) n_test int(m * test_ratio) test_idx idx[:n_test] train_idx idx[n_test:] return X[train_idx], X[test_idx], y[train_idx], y[test_idx]逻辑说明先给每行样本生成索引数组打乱后按比例切出测试索引和训练索引。X 和 y 用同样的索引取值保证特征和标签的对应关系不乱。固定 seed42 是调参实验里一个特别实用的习惯。因为每次运行数据切分都一致你在调学习率、隐藏层节点时看到的误差变化就只来自参数本身而不是数据划分的随机性。不加这个 seed每次运行测试集都不同两次实验结果没法横向对比调参就成了玄学。4.3 完整调用特征矩阵到预测结果的四步流程把上面几个函数串起来主流程就是按固定顺序执行四件事归一化、切分、训练、反归一化预测。# X 是原始特征矩阵y 是原始目标值列进来先各自归一化 X_norm, X_min, X_span minmax_normalize(X) y_norm, y_min, y_span minmax_normalize(y.reshape(-1, 1)) # 切分训练集和测试集 X_train, X_test, y_train, y_test split_dataset(X_norm, y_norm) # 训练网络隐藏层 8 个节点3000 轮迭代学习率 0.05 w1, b1, w2, b2, losses train(X_train, y_train, hidden_size8, epochs3000, lr0.05) # 测试集前向传播得到归一化预测值 y_pred_norm, _, _, _ forward(w1, b1, w2, b2, X_test) # 关键一步还原到原始量纲否则预测结果没法解释 y_pred y_pred_norm * y_span y_min这段主流程里有几个容易出错的位置。y.reshape(-1, 1) 是因为 minmax_normalize 按列处理y 必须从一维数组变成 (m, 1) 的矩阵不然归一化出来的形状不对。训练结束后的 y_pred_norm 是 0 到 1 之间的数直接拿去向老板汇报没有意义必须用训练时保存的 y_min 和 y_span 反算回原始数值这一步漏了是新手最常见的翻车现场。测试集预测表现才是这个模型真实水平的反映。训练集 loss 再低都不能代表预测能力因为网络完全可能把训练数据背下来了。到这一步bp.py 的主流程就跑通了剩下的问题基本都集中在参数怎么调、结果怎么判断上。5. 避坑指南BP 神经网络跑不通的五个典型症状5.1 loss 震荡不降甚至越跑越大现象训练过程输出里 loss 不是稳定下降而是在某个值附近来回跳或者一路走高最终模型给出的预测值明显不合理。原因最常见的就是学习率步长设置过大。权重更新的每一步迈得太远直接跨过了损失函数的最低点在谷底两侧反复横跳。另一种可能是数据没有归一化特征量级差异巨大导致梯度方向不稳定表现为 loss 剧烈震荡。解决先看 loss 输出曲线震荡就先把学习率降一个数量级从 0.05 降到 0.01 或 0.005重新跑。同时确认数据已经做过分列归一化这一步比调学习率更基础没归一化时调学习率基本白费。5.2 预测结果全是一个常数根本拟合不了数据现象训练完成后对任意输入模型输出的预测值几乎相同比如始终是 0.5 附近误差一直下不去。原因权重初始化取值过大进入 sigmoid 饱和区梯度消失导致权重几乎不更新或者隐藏层节点太少网络拟合能力不足以表达数据里的映射关系。还有一个隐性原因训练时把输出层也套了 sigmoid而目标值归一化后的范围不在 (0,1) 有效区间内模型被迫永远输出一个接近常数的值。解决把初始权重范围改小到 [-0.25, 0.25] 重新初始化并检查隐藏层节点数是否过低按输入节点的 1.5 倍起步。如果输出层用了 sigmoid先打印归一化后 y 的最小值和最大值确认确实落在 (0,1) 内否则输出层改线性输出。5.3 训练集误差很低测试集误差很高现象训练完成打印最后一个 epoch 的 loss 已经到 0.01 以下但用测试集一算误差大得离谱。这就是俗称的过拟合。原因数据集太小而网络容量太大。bp.py 的默认结构是单个隐藏层但隐藏节点数设到几十上百时小数据集上的拟合能力绰绰有余网络开始记忆噪声而不是学习规律。解决优先减小隐藏层节点数回到输入节点 1 到 2 倍的区间其次增加数据量如果数据无法增加引入简单的早停机制——训练过程中每过一定轮数在测试集上算一次误差测试误差开始回升就立即停止训练。5.4 epoch 设了 10000 还是收敛很慢现象loss 一直在缓慢下降跑几千轮才降一点训练时间很长但最终效果还可以。原因学习率太小。新手容易把学习率设成 0.001 甚至更小以求稳这在归一化数据上是过度保守了。另一个因素是 sigmoid 在输入较大时梯度趋近于 0训练本身就会慢这是结构特性。解决把学习率先提到 0.05 到 0.1 区间跑几百轮观察下降斜率。如果一开始下降很快后期变慢这是正常的可以保留学习率并配合增大 epochs也可以在第 4 章训练循环基础上加一个简单的学习率衰减——每 500 轮把学习率乘以 0.9兼顾前期速度与后期稳定。5.5 输出层激活函数选错预测值超出正常范围现象预测建材价格这类本来是正数的任务模型却输出了负数或者预测值全部被压在 0 到 1 之间且还原后数值明显不对。原因输出层的 sigmoid 把输出限制在 (0, 1)如果归一化后目标值分布不均还原后的预测误差会被放大反过来如果输出层是纯线性而代码里误套了 sigmoid预测范围又被错误截断。这两种情况都是激活函数与目标分布不匹配。解决回归任务输出层优先用线性输出也就是 z2 直接作为预测值只保留隐藏层的激活函数。具体做法是在 forward 函数里去掉输出层那一次 sigmoid对应反向传播公式也要去掉 sigmoid 导数那一项只保留 (y_pred - y) 作为输出层误差。6. 误差曲线与验证指标判断模型是真的收敛还是假收敛6.1 误差曲线怎么读bp.py 训练过程里每 100 轮打印一次 loss把这些点画出来就是误差曲线。画曲线的代码很直接用训练时返回的 losses 列表就能出图import matplotlib.pyplot as plt plt.plot(losses) plt.xlabel(epoch (x100)) plt.ylabel(MSE loss) plt.title(BP Neural Network Training Loss) plt.show()读这条曲线有三个关键判断第一前几百轮是不是在快速下降如果一开始就平缓或者震荡问题基本出在学习率或归一化第二后期是否趋于平稳平稳意味着梯度已经很小网络进入收敛区问此时继续追加 epochs 收益有限第三最后收敛的 loss 值是否达到预期这个预期来自对数据本身噪声水平的判断数据本身信噪比低时 loss 不会降到 0。6.2 量化指标MAE 和 RMSE 怎么算曲线只能看趋势跟别人汇报或者论文里写效果得用数值指标。预测类任务最常用两个MAE 反映平均偏差的绝对值RMSE 对大误差更敏感两者结合能看出误差分布形态。mae np.mean(np.abs(y_pred - y_test)) rmse np.sqrt(np.mean((y_pred - y_test) ** 2)) print(fMAE {mae:.4f}, RMSE {rmse:.4f})两个指标配合着看如果 MAE 不大但 RMSE 明显偏大说明预测值里存在少数极端偏差大的样本这时可以考虑检查归一化过程是否有异常值如果两个指标都偏大优先回头调隐藏层节点数和学习率这通常是容量或收敛问题。指标计算时要用反归一化后的原始量纲 y_pred 和 y_test用归一化后的值算出来的数字没法跟业务实际对照。从那以后我每次跑 bp.py 都强制走一遍固定流程先归一化并保存参数再固定 seed 切分数据训练时把学习率和 epoch 从保守值起步最后在测试集上算 MAE 和 RMSE并且一定把还原量纲后的预测值打印出来核对量级有没有离谱。这一套动作做完BP 神经网络的实验结果基本不会出现第五章节那种翻车情况希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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