简介:这份PDF面向机器学习初学者与需要完成课程作业的学生,系统讲解如何用Python实现BP神经网络完成函数逼近任务,重点以逼近y=sin(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_input=1, n_hidden=10, n_output=1, lr=0.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, axis=0, keepdims=True) # 隐层梯度:链式法则穿过 tanh da1 = dz2 @ self.W2.T dz1 = da1 * (1 - self.a1 ** 2) dW1 = X.T @ dz1 db1 = np.sum(dz1, axis=0, keepdims=True) # 梯度下降更新 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_input=1, n_hidden=20, n_output=1, lr=0.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(f"epoch {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 点。
| 隐层节点 | 学习率 | 迭代次数 | 最终 MSE |
|---|---|---|---|
| 5 | 0.05 | 5000 | 0.012 |
| 10 | 0.05 | 5000 | 0.0031 |
| 20 | 0.05 | 5000 | 0.0008 |
| 20 | 0.5 | 5000 | 0.0047 |
| 50 | 0.05 | 5000 | 0.0006 |
从表里能看出,隐层节点从 5 加到 20,MSE 降了一个数量级;从 20 加到 50,收益就很小了。学习率从 0.05 提到 0.5,MSE 反而变大,说明步长太大跳过了最优点。这就是调参的边界感:不是越大越好,找到那个刚好够用的点就行。
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(f"epoch {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, label="true") plt.plot(X_test, y_pred, label="pred") 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 插件后,按 Ctrl+Shift+P 选解释器,指向你刚装的 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, n=300): 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, epochs=5000, verbose=True): 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(f"epoch {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, n=500) net = BPNet(n_input=1, n_hidden=40, n_output=1, lr=0.03) train(net, X_norm, y, epochs=8000)这个函数在 [-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_input=1, n_hidden=20, n_output=1, lr=0.05, l2=1e-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, axis=0, keepdims=True) da1 = dz2 @ self.W2.T dz1 = da1 * (1 - self.a1 ** 2) dW1 = X.T @ dz1 + self.l2 * self.W1 db1 = np.sum(dz1, axis=0, keepdims=True) 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 网络不是精度最高的方案,但它是理解神经网络训练过程最透明的入口,把这一套跑熟,换任何框架都是平移。希望帮到你。
本文还有配套的精品资源,点击获取