【人工智能】使用MLP训练模型拟合相应函数

使用MLP训练函数拟合

MLP 的万能近似能力——通用近似定理

在开始实验之前,我们先回答一个根本问题:为什么区区几层全连接网络就能拟合任意函数?

1989年,Cybenko 等人证明了一个深刻的结论——通用近似定理(Universal Approximation Theorem)

一个只包含单个隐藏层、但拥有足够多神经元的前馈神经网络,配合非线性的激活函数(如 Sigmoid、ReLU),就可以以任意精度逼近任何一个定义在紧致集合上的连续函数。

这个定理有几个值得注意的关键点:

  1. “单个隐藏层"就够了——深度不是必须的。理论上,一层隐藏层配合足够多的神经元就能逼近任何连续函数。深度网络的威力更多来自"参数效率”——深层网络可以用更少的神经元表达更复杂的模式,但在理论上单隐藏层已经够"万能"。

  2. “足够多神经元"是关键——如果隐藏层神经元太少,表达能力会受限。本文后面的实验也会看到,区区 4~8 个隐藏层神经元就足以拟合相当复杂的函数,这正说明 MLP 的表达能力非常强。

  3. “非线性激活函数"不可或缺——如果没有 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()

具体效果:

fda6284e21fc09e183c237aa64060bfd.png

噪声的正则化意义——为什么"干扰"是好事?

在进入第二个实验之前,有一个乍看起来反直觉的设计值得仔细解释:为什么要故意往训练数据里加噪声?

第二个实验的目标函数是:

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 已经很强了

5a2bae0574ffc42f178e10d933161efa.png

3efce38eebb9c706ea7cd839a8dcbbbc.png


两个实验的对比与总结

让我们把两个实验放在一起对比,可以得出一个非常重要的洞察:

实验一:无噪声 实验二:有噪声
目标函数 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 的预测输出。可以看到红色曲线"穿过"了散点带的中心——模型学到了真实函数,而不是记住了每一个散点。

  • 第三张(实验二补充):展示了真实函数曲线(不含噪声的红色曲线)与带噪散点的对比,帮助读者直观感受噪声的"干扰程度"以及模型去噪拟合的效果。