尧图精选

BP神经网络实现函数逼近:Python从零手写与调参实战

🕒 发布时间:2026/10/2 11:07:49 📁 来源:尧图网络
简介这份PDF面向机器学习初学者与需要完成课程作业的学生系统讲解如何用Python实现BP神经网络完成函数逼近任务重点以逼近ysin(x)为例展开。内容从算法描述、数据描述、算法参数到实验流程逐层推进完整推导正向传播、反向传播与权重调整公式并给出sigmoid激活函数及其导函数的计算过程帮助读者理解梯度下降法如何通过误差逆传播不断修正权值与阈值。资源包内仅含1个PDF文件大小约345KB篇幅精炼适合作为理论推导与代码实现之间的桥梁。目前已有102人学习。读者可从中获得三层前馈网络的完整推导思路、以平均误差能量作为代价函数的经验风险定义以及关于固定学习率收敛慢、易陷入局部最小值、隐层结构依赖经验等局限性的讨论与改进方向便于对照复现实验并撰写实验报告。1. 用 BP 神经网络做函数逼近一份 Python 实现能解决什么问题很多人第一次接触神经网络不是为了分类也不是为了识别图片而是想让一条曲线去贴合另一条曲线。比如已知某个物理量随温度变化的采样点想拟合出一条光滑可导的连续函数比如控制系统里有个非线性环节想用一个可训练的黑匣子替代它做在线计算。这类任务在数学上叫函数逼近而 BP 神经网络是上手成本最低、最容易在 Python 里跑通的一条路。标题里的「BP 神经网络实现函数逼近 python 实现」核心就三件事用反向传播算法训练一个多层前馈网络让它的输出在给定区间上逼近目标函数再用 Python 把整个过程写成可复现的脚本。它适合两类人一类是刚学完 python 基础语法、想找一个能真正跑起来的神经网络练手的人另一类是做控制、信号处理、数值计算需要一个不依赖复杂框架的轻量拟合工具的工程师。下面从网络结构、数据构造、训练循环到踩坑排查一步步拆开讲。2. BP 网络逼近非线性函数的原理与结构选型2.1 为什么单隐层就能逼近大多数连续函数BP 神经网络的理论根基是万能逼近定理一个包含有限个神经元的单隐层前馈网络在激活函数满足一定条件时可以以任意精度逼近定义在紧集上的任意连续函数。这句话落到工程上意味着你不需要一上来就堆很多层一个输入层、一个隐层、一个输出层的三层结构往往就能把常见的非线性函数拟合得不错。结构上输入层负责接收自变量做函数逼近时通常只有一个输入节点对应 x。隐层做非线性变换节点数决定拟合能力的上限。输出层给出预测值 y。前向传播就是矩阵乘加激活反向传播用链式法则把损失对每个权重的梯度算出来再用梯度下降更新。整个过程没有卷积、没有注意力纯靠全连接层堆出来。选型时我一般会先确定三件事隐层节点数、激活函数、损失函数。隐层节点数太少会欠拟合曲线拐点处明显发钝太多会过拟合训练集上误差很小但区间外一塌糊涂。激活函数隐层用 tanh 或 ReLU输出层必须用线性因为函数值可能是任意实数套 sigmoid 会把输出压到 0 到 1 之间直接翻车。损失函数用均方误差它对回归任务友好梯度也好推。2.2 用 numpy 手写前向与反向传播不依赖 PyTorch 或 TensorFlow只用 numpy 手写一遍是理解 BP 最直接的方式。下面这段代码定义了一个三层网络输入 1 维、隐层 10 个神经元、输出 1 维激活函数用 tanh。import numpy as np class BPNet: def __init__(self, n_input1, n_hidden10, n_output1, lr0.01): # 权重用均匀分布初始化范围与输入尺度相关 self.W1 np.random.uniform(-1, 1, (n_input, n_hidden)) self.b1 np.zeros((1, n_hidden)) self.W2 np.random.uniform(-1, 1, (n_hidden, n_output)) self.b2 np.zeros((1, n_output)) self.lr lr def forward(self, X): # 隐层线性组合后过 tanh self.z1 X self.W1 self.b1 self.a1 np.tanh(self.z1) # 输出层线性保证值域不受限 self.z2 self.a1 self.W2 self.b2 return self.z2 def backward(self, X, y, y_pred): m X.shape[0] # 输出层梯度MSE 对 z2 的偏导 dz2 (y_pred - y) / m dW2 self.a1.T dz2 db2 np.sum(dz2, axis0, keepdimsTrue) # 隐层梯度链式法则穿过 tanh da1 dz2 self.W2.T dz1 da1 * (1 - self.a1 ** 2) dW1 X.T dz1 db1 np.sum(dz1, axis0, keepdimsTrue) # 梯度下降更新 self.W1 - self.lr * dW1 self.b1 - self.lr * db1 self.W2 - self.lr * dW2 self.b2 - self.lr * db2这段代码里几个参数需要说清楚。n_hidden控制拟合能力函数拐点多就往上加一般从 10 开始试。lr是学习率手写梯度下降时它对收敛影响极大0.01 到 0.1 之间比较稳太大直接震荡发散。np.random.uniform(-1, 1)的初始化范围不是随便定的如果输入 x 的尺度在 0 到 10权重太小会导致隐层输出全挤在 tanh 的线性区网络学不到非线性权重太大会让 tanh 饱和梯度接近零训练停滞。反向传播里dz2 (y_pred - y) / m这一项除以 m 是对 batch 求平均避免梯度随样本数线性放大。dz1 da1 * (1 - self.a1 ** 2)是 tanh 的导数如果换成 ReLU这里要改成对 z1 大于零的位置取 1。输出层没有加激活函数的导数因为它是线性的导数为 1。2.3 目标函数与训练数据的构造方式函数逼近要有明确的目标函数。常见的选择有 sin 函数、多项式、指数衰减或者从实际系统里采到的离散点。下面以 y sin(x) 在 [-2π, 2π] 上为例构造训练数据并跑训练循环。# 构造训练数据[-2π, 2π] 上均匀采样 200 个点 X np.linspace(-2 * np.pi, 2 * np.pi, 200).reshape(-1, 1) y np.sin(X) net BPNet(n_input1, n_hidden20, n_output1, lr0.05) for epoch in range(5000): y_pred net.forward(X) loss np.mean((y_pred - y) ** 2) net.backward(X, y, y_pred) if epoch % 500 0: print(fepoch {epoch}, loss {loss:.6f})数据构造有两个细节容易忽略。第一输入要归一化。如果 x 的范围是 [-2π, 2π]直接喂进去数值跨度约 12.5和权重初始化范围不匹配收敛会慢。我一般会把 x 线性映射到 [-1, 1]训练完再把预测结果反变换回去。第二采样点要覆盖整个定义域不能只在一段密集采样否则网络在没见过的区间上就是瞎猜。200 个点对 sin 这种光滑函数够用如果目标函数有高频振荡采样点要相应加密。训练循环里每 500 轮打印一次 loss是为了观察收敛趋势。正常情况 loss 会先快速下降然后缓慢逼近一个平台。如果 loss 一直不降先查学习率是不是太大再查数据有没有归一化。如果 loss 降到某个值就卡住不动多半是隐层节点不够或者目标函数在该区间上变化太剧烈网络容量撑不住。3. 训练参数调优与收敛判断的实操方法3.1 学习率、隐层节点与迭代次数的联动关系这三个参数不是独立的调一个往往要动另一个。学习率决定每步走多远隐层节点决定网络能表达多复杂的函数迭代次数决定给网络多少时间。我一般按这个顺序调先固定隐层节点 20、迭代 5000把学习率从 0.1 往下试找到 loss 下降最快且不震荡的值再固定学习率把隐层节点从 5 加到 50看 loss 平台有没有明显下降最后根据 loss 曲线决定迭代次数通常看到 loss 连续几百轮变化小于 1e-6 就可以停。下面这张表是我在 sin 函数逼近任务上的一组实测记录输入归一化到 [-1, 1]目标函数 y sin(πx)采样 300 点。隐层节点学习率迭代次数最终 MSE50.0550000.012100.0550000.0031200.0550000.0008200.550000.0047500.0550000.0006从表里能看出隐层节点从 5 加到 20MSE 降了一个数量级从 20 加到 50收益就很小了。学习率从 0.05 提到 0.5MSE 反而变大说明步长太大跳过了最优点。这就是调参的边界感不是越大越好找到那个刚好够用的点就行。3.2 用验证集判断过拟合与欠拟合只看训练 loss 容易被骗。网络可能把训练点背下来了但在点与点之间插值时乱跳。做法是从采样点里留出 20% 做验证集训练时只看训练集每轮结束后在验证集上算一次 loss。# 划分训练集和验证集 idx np.random.permutation(len(X)) train_idx, val_idx idx[:160], idx[160:] X_train, y_train X[train_idx], y[train_idx] X_val, y_val X[val_idx], y[val_idx] for epoch in range(5000): y_pred net.forward(X_train) net.backward(X_train, y_train, y_pred) if epoch % 500 0: train_loss np.mean((net.forward(X_train) - y_train) ** 2) val_loss np.mean((net.forward(X_val) - y_val) ** 2) print(fepoch {epoch}, train {train_loss:.6f}, val {val_loss:.6f})判断标准很简单训练 loss 和验证 loss 都在降说明网络还在学训练 loss 继续降但验证 loss 开始升就是过拟合该减隐层节点或加数据两个都不降是欠拟合该加节点或调学习率。函数逼近任务里过拟合不如分类任务那么常见因为目标函数本身是光滑的但如果采样点有噪声网络会把噪声也拟合进去验证集就能把这个暴露出来。3.3 预测结果的可视化与误差分布检查训练完不能只看一个 MSE 数字要把预测曲线和真实曲线画在一起再看误差在区间上的分布。下面用 matplotlib 画图。import matplotlib.pyplot as plt X_test np.linspace(-2 * np.pi, 2 * np.pi, 500).reshape(-1, 1) y_true np.sin(X_test) y_pred net.forward(X_test) plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.plot(X_test, y_true, labeltrue) plt.plot(X_test, y_pred, labelpred) plt.legend() plt.subplot(1, 2, 2) plt.plot(X_test, y_pred - y_true) plt.title(residual) plt.show()左图看整体贴合程度右图看残差。如果残差在区间两端明显变大说明边界处采样不够或者网络在边界外推能力差。如果残差呈现周期性波动说明隐层节点不够网络没能力表达目标函数的频率成分。如果残差里有个别尖峰检查那几个点是不是数据里有异常值。提示画图时横坐标太密集会导致标签重叠用plt.xticks手动指定几个刻度就行这是 python 画图横坐标太密集时的常规处理。4. 从零搭建环境到跑通脚本的完整步骤4.1 python 环境准备与依赖安装跑这个脚本只需要 numpy 和 matplotlib。如果你还没装 python去 python 官网下载安装包安装时勾选 add to PATH。装完在命令行输入python --version确认。然后装依赖pip install numpy matplotlib如果用的是 vscode装好 python 插件后按 CtrlShiftP 选解释器指向你刚装的 python。pycharm 用户新建项目时选好解释器在设置里装库也一样。国内网络下载慢的话可以临时指定镜像源但不要长期改全局配置避免以后装其他库时版本对不上。pip install numpy matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple装完在 python 里执行import numpy不报错环境就通了。这一步看着简单但很多人卡在 python 安装详细步骤上核心就是 PATH 和解释器选择这两处。4.2 把脚本拆成可复用的模块单文件跑通之后建议拆成三个部分网络定义、数据生成、训练与评估。这样换目标函数时只改数据生成部分换网络结构时只改网络定义部分。下面是一个最小可复用结构。# bp_approx.py import numpy as np class BPNet: # 网络定义同前 ... def generate_data(func, x_min, x_max, n300): X np.linspace(x_min, x_max, n).reshape(-1, 1) # 归一化到 [-1, 1] X_norm 2 * (X - x_min) / (x_max - x_min) - 1 y func(X) return X, X_norm, y def train(net, X, y, epochs5000, verboseTrue): for epoch in range(epochs): y_pred net.forward(X) loss np.mean((y_pred - y) ** 2) net.backward(X, y, y_pred) if verbose and epoch % 500 0: print(fepoch {epoch}, loss {loss:.6f}) return netgenerate_data里做了归一化返回原始 X 和归一化后的 X_norm画图时用原始 X 做横坐标训练时用 X_norm。这个区分很重要忘了反变换会导致预测曲线横坐标对不上。train函数把训练循环封装起来verbose 控制是否打印批量实验时可以关掉。4.3 换一个目标函数验证泛化能力跑通 sin 之后换一个更复杂的函数试试比如 y x^3 - x 或者 y exp(-x^2) * sin(5x)。后者有局部振荡对网络容量要求更高。def target(x): return np.exp(-x**2) * np.sin(5*x) X, X_norm, y generate_data(target, -3, 3, n500) net BPNet(n_input1, n_hidden40, n_output1, lr0.03) train(net, X_norm, y, epochs8000)这个函数在 [-3, 3] 上有多个波峰波谷隐层节点要加到 40 以上才能拟合出细节。如果还用 10 个节点预测曲线会变成一条平滑的弧线把所有振荡都抹掉。这就是网络容量和目标函数复杂度要匹配的直观体现。换函数时记得同步改采样范围和采样点数振荡越密采样点要越多。5. 函数逼近任务里最容易翻车的五个坑5.1 输出层误用 sigmoid 导致值域被锁死现象训练 loss 降到 0.25 左右就再也下不去预测曲线始终在 0 到 1 之间目标函数有负值或超过 1 的部分完全拟合不了。原因输出层加了 sigmoid 激活函数把输出压缩到 (0, 1)。函数逼近的输出应该是任意实数sigmoid 的值域天然不匹配。解决输出层保持线性不加任何激活函数。如果目标函数值域确实有界比如在 [0, 1] 内可以用 sigmoid但要在训练前把目标值也归一化到同一范围预测完再反变换。5.2 输入未归一化导致训练极慢或梯度消失现象loss 在前几百轮几乎不动或者下降非常缓慢隐层输出全部接近 0 或全部接近 1。原因输入 x 的数值范围远大于权重初始化范围线性组合后的值落在 tanh 的饱和区导数接近零梯度传不回去。解决把输入线性映射到 [-1, 1] 或 [0, 1]训练完在预测阶段做反变换。这一步在函数逼近里几乎必做尤其是 x 范围超过 [-5, 5] 的时候。5.3 学习率过大导致 loss 震荡不收敛现象loss 曲线上下跳动有时突然变成 nan预测结果完全乱掉。原因学习率太大每次更新跨过了最优点甚至把权重推到发散区域。解决把学习率降一个数量级再试。手写梯度下降时0.01 到 0.1 是安全区。如果降了还是震荡检查梯度计算里有没有漏掉除以 batch size导致梯度被放大。5.4 隐层节点过少导致欠拟合现象训练 loss 和验证 loss 都停在较高水平预测曲线过于平滑目标函数的拐点和振荡完全没体现。原因隐层节点数不够网络没有足够的参数去表达目标函数的复杂度。解决逐步增加隐层节点每次加 10 个观察 loss 平台是否下降。但也不要一次加太多节点过多会拖慢训练速度还可能引入过拟合。一般从 10 开始按 10、20、40 往上试。5.5 训练区间外推时预测完全失效现象在训练数据覆盖的区间内拟合得很好但稍微往外延伸一点预测值就飞到天上或掉到地下。原因神经网络本质上是插值器不是外推器。训练区间外的输入对网络来说是没见过的分布隐层的 tanh 在饱和区导数接近零输出由权重和偏置的极端组合决定没有物理约束。解决不要用 BP 网络做超出训练区间太远的外推。如果任务需要外推要么扩大训练区间要么在损失函数里加外推惩罚项要么换用有物理约束的模型。这是函数逼近的边界不是调参能解决的。6. 用早停和权重衰减把逼近精度再压一个量级训练到后期loss 下降会变慢继续跑既费时间又容易过拟合。我一般会加两个技巧早停和权重衰减。早停是每轮在验证集上算 loss如果连续 N 轮验证 loss 没有改善就停止训练保留验证 loss 最低时的权重。权重衰减是在损失函数里加一项 L2 正则让权重不要长得太大曲线更光滑。class BPNetWithReg: def __init__(self, n_input1, n_hidden20, n_output1, lr0.05, l21e-4): # 初始化同前 ... self.l2 l2 def backward(self, X, y, y_pred): m X.shape[0] dz2 (y_pred - y) / m dW2 self.a1.T dz2 self.l2 * self.W2 db2 np.sum(dz2, axis0, keepdimsTrue) da1 dz2 self.W2.T dz1 da1 * (1 - self.a1 ** 2) dW1 X.T dz1 self.l2 * self.W1 db1 np.sum(dz1, axis0, keepdimsTrue) self.W1 - self.lr * dW1 self.b1 - self.lr * db1 self.W2 - self.lr * dW2 self.b2 - self.lr * db2l2参数控制正则强度1e-4 到 1e-3 之间比较常用。加在梯度里而不是损失里效果一样但代码更简洁。早停的实现用一个计数器记录验证 loss 连续不降的轮数超过 patience 就 break。best_val float(inf) patience 500 wait 0 for epoch in range(20000): y_pred net.forward(X_train) net.backward(X_train, y_train, y_pred) val_loss np.mean((net.forward(X_val) - y_val) ** 2) if val_loss best_val: best_val val_loss best_W1, best_b1 net.W1.copy(), net.b1.copy() best_W2, best_b2 net.W2.copy(), net.b2.copy() wait 0 else: wait 1 if wait patience: net.W1, net.b1 best_W1, best_b1 net.W2, net.b2 best_W2, best_b2 break这套组合下来sin 函数逼近的 MSE 能从 8e-4 压到 2e-4 左右曲线在拐点处也更顺。我自己的习惯是先用小网络快速试学习率确定量级后再加节点和正则最后开早停跑一次长训练。这样比一上来就堆大网络省时间也更容易定位问题。函数逼近这个方向BP 网络不是精度最高的方案但它是理解神经网络训练过程最透明的入口把这一套跑熟换任何框架都是平移。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →