使用MLP训练函数拟合
MLP 的万能近似能力——通用近似定理
在开始实验之前,我们先回答一个根本问题:为什么区区几层全连接网络就能拟合任意函数?
1989年,Cybenko 等人证明了一个深刻的结论——通用近似定理(Universal Approximation Theorem):
一个只包含单个隐藏层、但拥有足够多神经元的前馈神经网络,配合非线性的激活函数(如 Sigmoid、ReLU),就可以以任意精度逼近任何一个定义在紧致集合上的连续函数。
这个定理有几个值得注意的关键点:
-
“单个隐藏层"就够了——深度不是必须的。理论上,一层隐藏层配合足够多的神经元就能逼近任何连续函数。深度网络的威力更多来自"参数效率”——深层网络可以用更少的神经元表达更复杂的模式,但在理论上单隐藏层已经够"万能"。
-
“足够多神经元"是关键——如果隐藏层神经元太少,表达能力会受限。本文后面的实验也会看到,区区 4~8 个隐藏层神经元就足以拟合相当复杂的函数,这正说明 MLP 的表达能力非常强。
-
“非线性激活函数"不可或缺——如果没有 ReLU 这类非线性函数,多层线性变换的叠加仍然是线性变换,那就退化成了线性模型,完全无法拟合像 x² 或 sin(2x) 这样的非线性函数。
本文的两个实验恰好是对通用近似定理的直观验证:首先用 MLP 拟合简单的二次函数,然后挑战带有噪声的三次+正弦复合函数——整个过程展示了神经网络如何一步步逼近目标函数。
“特征工程"与深度学习的本质区别
在阅读第一个实验的代码时,你可能会注意到一行关键代码:
X = torch.cat([x, x**2], dim=1)
这里我们显式地把 x 和 x² 拼在一起作为网络的输入特征。第二个实验也做了类似的事:
X = torch.cat([x, x**2, x**3, torch.sin(2*x)], dim=1)
这是典型的**特征工程(Feature Engineering)**思路——人工设计特征,把复杂函数拆成一组"基函数”:x, x², x³, sin(2x),然后让网络学习这些基函数的加权组合。
这种做法的教学意义非常大:
- 读者可以直观地理解"网络到底在拟合什么”——它本质上是在做这些已知基函数的线性/非线性组合。
- 将"深度学习"与传统机器学习(如多项式回归、样条回归)建立了桥梁——传统方法的核心就是手动挑选基函数,然后用线性模型做加权。
但这里有一个重要的"但是”:真正的深度学习不需要这一步。
如果把原始 x 直接喂给一个足够深的网络(比如 3~4 层、每层几十个神经元),网络会自动学到这些中间特征表示。第一层可能学会 x 的一次项,第二层可能学会 x²,更深层可能涌现出 sin(x) 等周期模式——整个过程不需要人工干预。这就是深度学习相较于传统机器学习的革命性优势:特征学习代替特征工程。
本文之所以选择显式构造特征,纯粹是为了降低理解门槛——读者可以清楚地看到"输入是什么、目标是什么、网络在学什么",而不需要去揣测隐藏层内部到底发生了什么。这是一个非常聪明的教学选择。
一、无噪声简单回归:y = x^2 + 2x + 1
import torch
import torch.nn as nn
ez = nn.Sequential(
nn.Linear(2, 8),
nn.ReLU(),
nn.Linear(8, 8),
nn.ReLU(),
nn.Linear(8, 1)
)
# y = w1 @ x + w2 @ x^2 + b
# 两个特征,第一个是x,第二个是x^2
criterion = nn.MSELoss()
x = torch.linspace(-10, 10, 10000).unsqueeze(1)
X = torch.cat([x, x**2], dim=1)
y = x**2 + 2 * x + 1
inx = torch.tensor([[2, 4], [1, 1], [3, 9], [4, 16]], dtype=torch.float32)
target = torch.tensor([[9], [4], [16], [25]], dtype=torch.float32)
lr = 0.00001
for i in range(10000):
#向前传播
out = ez(X)
loss = criterion(out, y)
#反向传播
loss.backward()
with torch.no_grad():
# 3. 手动更新参数
with torch.no_grad(): # 禁止 autograd 追踪
for param in ez.parameters():
param -= lr * param.grad # 梯度下降更新参数
#梯度清零
ez.zero_grad()
if i % 1000 == 0:
print(f"Epoch {i}: loss={loss.item():.4f}")
import matplotlib.pyplot as plt
y_pred = ez(X).detach()
plt.scatter(x.numpy(), x.numpy()**2 + 2*x.numpy() + 1, label='real y = x^2 + 2x + 1')
plt.plot(x.numpy(), y_pred.numpy(), color='r', label='Predicted y = x^2 + 2x + 1')
plt.legend()
plt.show()
具体效果:

噪声的正则化意义——为什么"干扰"是好事?
在进入第二个实验之前,有一个乍看起来反直觉的设计值得仔细解释:为什么要故意往训练数据里加噪声?
第二个实验的目标函数是:
y = 0.5x³ - 2x² + 3x + 5 + 4sin(2x) + noise
其中 noise ~ N(0, 3²),即均值为 0、标准差为 3 的高斯噪声。
加了噪声之后,训练数据不再是"光滑的曲线上的点",而是一堆散落在真实曲线周围的散点。初看之下,这像是在给学习任务制造麻烦。但恰恰相反——这是一种教科书级的正则化设计。
从过拟合到泛化
如果不用噪声而直接用精确的函数值训练,模型有可能只是机械地记住了每一个训练点的位置,本质上变成了一个"查表器"。当它面对从未见过的 x 时,表现会很差——这就是过拟合。
加入噪声之后,同一个 x 附近的不同样本点的 y 值不再完全相同。模型如果试图记住每一个点,loss 会变得非常大——因为"记住噪声"这件事本身就是矛盾的,噪声是随机的。于是模型被迫去寻找隐藏在所有噪声之下的真实规律——这正是我们希望模型学到的东西。
为什么 loss ≈ 6 是合理的?
作者在实验后观察到:在噪声标准差为 3 的情况下,loss 能收敛到大约 6,这已经很强了。
我们来拆解一下这个数字背后的数学直觉:
- 噪声的方差是 3² = 9。
- 如果模型完全学到了真实函数(即完美去除了噪声),预测值与带噪观测值之间的差异将完全来自噪声本身。
- 所以 MSE 的期望值应该约等于噪声的方差,即约 9。
- 但实际 loss 收敛到了约 6,低于 9——这说明模型的预测比单纯"取平均"做得更好,部分噪声已经被"平均掉"了。
更深层的数学原理是:MSE 损失最小化的等价目标是学习条件期望 E[y|x]。 由于噪声的期望为 0(即 E[noise | x] = 0),因此 E[y_noisy | x] 恰好等于真实函数值。换句话说,MSE 优化的"最优解"就是真实函数本身——噪声在期望意义上被自动消除了。
加噪声训练本身就是一种正则化
值得一提的是,“给输入或标签加噪声来防止过拟合"在深度学习中是一类通用的正则化策略,与更广为人知的 Dropout、权重衰减(L2 正则化)属于同一家族:
| 方法 | 核心思想 |
|---|---|
| 训练数据加噪声 | 强制模型学习"忽略无关扰动”,关注真实规律 |
| Dropout | 随机丢弃神经元,防止神经元之间形成共适应 |
| 权重衰减 (L2) | 惩罚过大的权重,让模型更"平滑" |
| 数据增强 | 通过对训练样本做微小变换来扩充数据集 |
所有这些技术的目标都是一致的:让模型在"拟合训练数据"和"保持泛化能力"之间找到最佳平衡。 本文的噪声实验正是对这一理念最直接的诠释——只用了一行 np.random.normal(0, 3, ...),就完成了一个完整的正则化示范。
二、有噪声的拟合复杂函数
import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt
from torch import optim
# x 张量
x = torch.linspace(-10, 10, 100).unsqueeze(1)
# 特征矩阵
X = torch.cat([x, x**2, x**3, torch.sin(2*x)], dim=1)
# 高斯噪声
noise = torch.from_numpy(np.random.normal(0, 3, size=x.shape)).float()
# 带噪声的 y
y_noisy = 0.5 * x**3 - 2 * x**2 + 3*x + 5 + 4 * torch.sin(2*x) + noise
# 可视化
'''
plt.scatter(x, y_noisy, label="Noisy data")
plt.plot(x, 0.5 * x**3 - 2 * x**2 + 3*x + 5 + 4 * torch.sin(2*x), color='red', label="Original function")
plt.legend()
plt.show()
'''
ez = nn.Sequential(
nn.Linear(4, 8),
nn.ReLU(),
nn.Linear(8, 4),
nn.ReLU(),
nn.Linear(4, 1),
)
criterion = nn.MSELoss()
optimizer = optim.Adam(ez.parameters(), lr=0.0065)
for i in range(10000):
#向前传播
out = ez(X)
loss = criterion(out, y_noisy)
#反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
if i % 100 == 0:
print("Step:", i, "loss =", loss.item())
with torch.no_grad():
y_pred = ez(X)
plt.scatter(x.numpy(), y_noisy.numpy(), alpha=0.3, label="Noisy data")
plt.plot(x.numpy(), y_pred.numpy(), color='red', label="NN prediction")
plt.legend()
plt.show()
效果:在噪声标准差为3的情况下能收敛到loss = 6 已经很强了


两个实验的对比与总结
让我们把两个实验放在一起对比,可以得出一个非常重要的洞察:
| 实验一:无噪声 | 实验二:有噪声 | |
|---|---|---|
| 目标函数 | y = x² + 2x + 1(简单二次) | y = 0.5x³ - 2x² + 3x + 5 + 4sin(2x)(复杂复合) |
| 数据噪声 | 无 | σ = 3 的高斯噪声 |
| 特征 | [x, x²] | [x, x², x³, sin(2x)] |
| 收敛 loss | 接近 0 | 约 6 |
| loss 的启示 | 无噪声时模型可以完美拟合 | 噪声存在时 loss 不可能降到 0,~6 已经是理论最优附近 |
核心结论:模型能力的边界由数据质量决定
这两个实验清楚地说明了机器学习中一条铁律:模型能力的上限不是由网络结构决定的,而是由数据质量决定的。
- 实验一中数据完美、没有噪声,MLP 可以轻松地把 loss 降到接近 0——模型的表达能力完全足以覆盖这个简单函数。
- 实验二中引入了噪声,即使网络结构更强(4 个输入特征、更复杂的真实函数),loss 也只能收敛到约 6。这不是因为网络"不够强",而是因为数据本身的噪声就决定了误差的下界。
换句话说——当你的模型 loss 降不下去时,先别急着加深网络或调大参数量;先检查数据,看看是否噪声本身就已经设置了天花板。
结尾图片说明
文章展示了三张关键的可视化图:
-
第一张(实验一):蓝色散点是真实函数 y = x² + 2x + 1 在 [-10, 10] 上的采样,红色曲线是 MLP 的预测输出。可以看到两条曲线几乎完美重合——无噪声场景下网络做到了精确拟合。
-
第二张(实验二):蓝色散点是带噪声的训练数据(散点分布呈现明显的"带状"分散,这是 σ=3 高斯噪声的效果),红色曲线是 MLP 的预测输出。可以看到红色曲线"穿过"了散点带的中心——模型学到了真实函数,而不是记住了每一个散点。
-
第三张(实验二补充):展示了真实函数曲线(不含噪声的红色曲线)与带噪散点的对比,帮助读者直观感受噪声的"干扰程度"以及模型去噪拟合的效果。