算法可视化与交互学习平台

用 MLP 拟合非线性关系MLP: Fitting Nonlinear Functions

从最小两层感知机出发,理解隐藏层、ReLU、反向传播和 autograd,并通过交互实验观察 MLP 如何拟合非线性关系。

ClassificationBeginnerFree
PyodideKernel
1

先抓住的目标

不是一上来背很多术语,而是先建立一个非常具体的认知:MLP 就是在输入和输出之间,插入一层可以学到中间表示的神经元。

你会依次看到:

  1. 为什么线性模型不够
  2. 隐藏层到底在做什么
  3. ReLU 为什么能引入非线性
  4. forward -> loss -> backward -> step 这条训练链路如何串起来
  5. autograd 为什么能帮我们自动求梯度

学完后,你至少应该能看懂这段最小 PyTorch 代码,不再把神经网络当成“黑盒魔法”。

3

前向传播:先算隐藏层,再算输出层

第一层把输入 x 线性变换后送入激活函数,得到隐藏表示 h;第二层再把 h 组合成最终输出 y。

h=σ(W1x+b1),y=W2h+b2h = \sigma(W_1x + b_1), \qquad y = W_2h + b_2
xx
W1,b1W_1, b_1
hh
σ\sigma
W2,b2W_2, b_2
xx
输入特征
input feature
W1,b1W_1, b_1
第一层权重与偏置
weights and bias of the first layer
hh
隐藏层表示
hidden representation
σ\sigma
激活函数,这里可理解为 ReLU
activation function, here ReLU
W2,b2W_2, b_2
输出层权重与偏置
weights and bias of the output layer
4

训练时到底发生了什么

训练并不神秘:先做前向传播得到预测,再计算损失 L,然后通过梯度更新所有参数 theta。

L=1ni=1n(y^iyi)2,θθηθLL = \frac{1}{n}\sum_{i=1}^{n}(\hat{y}_i - y_i)^2, \qquad \theta \leftarrow \theta - \eta \nabla_\theta L
LL
θ\theta
η\eta
θL\nabla_\theta L
LL
损失函数,这里使用均方误差
loss function, here mean squared error
θ\theta
模型的全部参数
all parameters of the model
η\eta
学习率
learning rate
θL\nabla_\theta L
损失对所有参数的梯度
gradient of the loss with respect to all parameters
2

为什么需要 MLP,而不是继续用一条直线

1. 线性模型能做什么?

如果模型写成 \hat{y} = wx + b,那么它的表达能力本质上就是一条直线。

这类模型很适合处理“输入变大,输出大致按固定斜率变化”的关系,但当数据本身是弯曲的、分段的、带拐点的,它就会明显吃力。

2. MLP 的核心改动在哪里?

MLP(Multi-Layer Perceptron,多层感知机)不是直接把 x 变成 y,而是先把输入送进一层隐藏层,得到一个中间表示 h,再从 h 生成输出。

你可以先把它粗略理解成:

  • 第一层:把原始输入重新“加工”成一组更有用的特征。
  • 第二层:再根据这些新特征组合出最终输出。

3. 为什么这一步会让模型更强?

因为隐藏层中的每个神经元都在看输入的不同侧面。它们不会都学成一样的东西:

  • 有的神经元更关注输入偏小的区域。
  • 有的更关注输入偏大的区域。
  • 有的会在某个范围内“激活”,范围外几乎不起作用。

这样一来,模型就不再只是“一条直线”,而是可以把多个局部模式拼起来,形成更复杂的函数形状。

4. 初学者最重要的一句话

神经网络并不是神秘地“直接学会答案”,而是先学一组中间特征,再把这些特征组合成输出。

后面你看到的隐藏层、ReLU、反向传播,都是在服务这件事。

5

PyTorch MLP 训练过程观察

把 PyTorch MLP 训练拆成可调参数实验。你可以修改隐藏层宽度、学习率、总 epoch、观察步长和随机种子,逐 epoch 观察预测曲线如何逼近训练数据,并同时查看损失曲线与隐藏层激活图。

Parameter Panel
5 Params
6

逐行理解这段代码:多层网络、反向传播、autograd

1. 先抓住这段代码真正做了什么

PyTorch MLP 训练过程观察 区块,本质上是在做一件事:用一个一层隐藏层的 MLP 去拟合一组非线性数据,然后把训练过程拆成可以观察的几个步骤。

不要把它理解成“神经网络神奇地学会了一条曲线”,而要理解成:先把输入变成一组隐藏特征,再把这些隐藏特征组合成输出;训练只是不断调整参数,让这个组合越来越贴近目标数据。

2. 第一层 Linear(1, 16):把一个输入变成 16 个观察角度

代码里第一层是:

self.fc1 = nn.Linear(1, hiddenDim)

当默认参数是 hiddenDim = 16 时,它的意思就是:把一个输入 x 映射成 16 个隐藏单元的中间结果。对第 i 个神经元来说,它做的计算是:

zi=wix+biz_i = w_i x + b_i

也就是说,每个神经元都先做一次线性变换。因为每个神经元的权重 w_i 和偏置 b_i 都不同,所以它们相当于从不同角度观察同一个输入 x。这就是“隐藏层不是复制输入,而是在构造多种中间特征”的第一步。

代码里对应的是:

h = self.fc1(x)

这里的 h 还不是最后要用的激活值,而是 16 个线性变换结果组成的向量,可以把它看成公式里的 zz

3. ReLU:把线性变换变成分段线性响应

接下来代码是:

self.act1 = nn.ReLU()
h_act = self.act1(h)

ReLU 的公式非常简单:

ai=max(0,zi)a_i = \max(0, z_i)

它的作用是:如果某个神经元当前输出是负的,就把它截成 0;如果是正的,就保留下来。这样一来,原来单纯的线性变换就变成了“在某些区间响应,在某些区间不响应”的分段线性函数。

这一步非常关键,因为如果没有激活函数,两层线性层叠加后,本质上仍然还是一个线性模型;而有了 ReLU,模型才开始具备拟合非线性曲线的能力。

所以这里要建立一个核心认知:MLP 之所以能拟合弯曲关系,不是因为网络很神秘,而是因为 ReLU 让每个隐藏单元都变成了一个局部响应器。

4. 第二层 Linear(16, 1):把隐藏特征重新组合成输出

代码里的输出层是:

self.fc2 = nn.Linear(hiddenDim, 1)
out = self.fc2(h_act)

这一层做的事是把 16 个激活后的隐藏特征重新加权求和,形成最终预测值。公式写成:

y^=i=116viai+c\hat{y} = \sum_{i=1}^{16} v_i a_i + c

这里:

  • aia_i 是第 i 个隐藏神经元经过 ReLU 后的激活值。
  • viv_i 是输出层给这个隐藏特征分配的权重。
  • cc 是输出层偏置。

因此,这个模型并不是直接从输入画出一条曲线,而是先得到很多局部特征,再把这些特征拼起来,合成最终预测。

这就是为什么当你把隐藏层宽度从 16 改成 4 时,模型往往还能工作,但表达能力会变弱,同时隐藏层图也会更容易看懂。

5. forward 为什么同时返回预测值和隐藏层激活

在第 5 节区块里,模型的前向传播写成:

def forward(self, x):
    h = self.fc1(x)
    h_act = self.act1(h)
    out = self.fc2(h_act)
    return out, h_act

这里不仅返回预测值 out,还返回隐藏层激活 h_act。原因很直接:预测值用于计算损失,而隐藏层激活要拿来画 Hidden Layer Activations 图。

如果只返回预测值,你能看到“拟合得好不好”;但把隐藏层也返回出来,你就能进一步看到“模型到底是靠哪些局部响应拼出这条曲线的”。

6. 损失函数:模型离目标还有多远

训练时最重要的一行之一是:

loss = ((pred - y_tensor) ** 2).mean()

它对应的公式是均方误差:

MSE=1ni=1n(y^iyi)2\mathrm{MSE} = \frac{1}{n} \sum_{i=1}^{n}(\hat{y}_i - y_i)^2

这里:

  • y^i\hat{y}_i 是第 i 个样本的预测值。
  • yiy_i 是第 i 个样本的真实值。
  • 平方的作用是让正负误差都变成正值,而且大误差会被放大。
  • 最后取平均,是为了得到整体样本上的平均误差水平。

所以 loss 越小,就说明预测曲线整体上越接近训练数据。

7. 反向传播和 autograd:梯度是怎么来的

训练循环里紧接着是三行:

optimizer.zero_grad()
loss.backward()
optimizer.step()

这三行分别对应训练公式里的三个动作。

首先,参数更新公式可以写成:

θθηθL\theta \leftarrow \theta - \eta \nabla_{\theta} L

其中:

  • θ\theta 表示模型所有参数,也就是第一层和第二层中的权重、偏置。
  • η\eta 是学习率,对应代码里的 learningRate
  • θL\nabla_{\theta} L 是损失对参数的梯度,表示“如果参数往某个方向动,loss 会怎样变化”。

三行代码分别是:

  • zero_grad():把上一轮残留的梯度清空。因为 PyTorch 默认会累积梯度,如果不清,会把多轮的梯度混在一起。
  • backward():让 autograd 根据当前计算图自动做链式法则求导,算出每个参数的梯度。
  • step():优化器根据梯度和学习率更新参数。

也就是说,你不用手工推导每个参数的偏导数,但你要知道:autograd 解决的是“怎么算梯度”,优化器解决的是“怎么用梯度改参数”。

8. 为什么要按关键 epoch 保存快照

不是每一轮都画图,而是把某些关键轮次存下来:

watch_epochs = list(range(0, epochs, watchEvery))
if (epochs - 1) not in watch_epochs:
    watch_epochs.append(epochs - 1)

然后只在这些 epoch 记录:

pred_snapshots[epoch] = pred_now.squeeze().detach().cpu().tolist()
hidden_snapshots[epoch] = hidden_now.detach().cpu().tolist()

这样做有两个目的:

  • 让你直接比较不同训练阶段的预测曲线,而不是只看最终答案。
  • 让你知道隐藏层激活也会随着训练同步变化,不是固定不变的。

所以这个实验强调的不是“最后 loss 有多小”,而是“模型怎样一步步从不会拟合,走到会拟合”。

9. 运行后最值得看的三类图

第一类图是 拟合过程图。你会看到初期预测曲线可能很乱,随着 epoch 增加,曲线越来越贴近散点。这说明参数在不断更新,模型正在逐步找到更合适的函数形状。

第二类图是 Loss Curve。它通常会逐渐下降。损失下降并不神秘,本质上表示平均平方误差在变小,也就是预测值和真实值越来越接近。

第三类图是 Hidden Layer Activations。这张图最适合理解 MLP 的内部机制。你会看到不同神经元对不同区间的响应不一样:有的只在左边区间明显激活,有的在中间变化更大,有的只在右边起作用。最后输出层就是把这些局部响应重新组合成总曲线。

10. 学这个实验时,最推荐的一个改法

如果你想更容易理解隐藏层图,最推荐先把参数里的 Hidden Dim 从 16 调小到 4。这样曲线数量更少,隐藏层的局部响应会更容易看出来。

当隐藏层宽度变小后,你通常会看到两件事:

  • 隐藏层激活图更清楚,因为神经元变少了。
  • 预测曲线的表达能力可能下降,因为可组合的特征变少了。

这正好能帮助你建立一个非常重要的直觉:隐藏层神经元越多,模型通常能表示更复杂的函数;但要想真正理解 MLP 的工作方式,先从更少的神经元开始观察更有效。

7

为什么 MLP 能拟合弯曲关系

这张图不是为了比较谁的数值更精确,而是为了回答一个更本质的问题:为什么一条直线做不到的事,一层隐藏层的 MLP 却能做到。图中一共有三条线。绿色实线表示真正想拟合的非线性目标函数,它本身带有弯曲、起伏和局部变化;橙色虚线表示线性模型,它只能用一条固定斜率的直线去逼近目标,所以无论怎么调参数,也只能在局部区间勉强贴近,无法整体跟随弯曲趋势;蓝色实线表示一层隐藏层 MLP 的近似效果,它不是直接画出一条曲线,而是把多个经过 ReLU 处理后的局部响应拼接在一起,因此能够形成分段变化、逐步转折的整体形状。这里最关键的观察不是“蓝线是不是完全重合绿色曲线”,而是看它为什么已经开始具备弯曲能力。原因在于,线性模型只有一种表达单元,而 MLP 的隐藏层会先构造多组中间特征,每个特征只在某些区间起作用,最后再组合成输出。你可以把蓝线理解成若干个局部线段被重新拼合后的结果。这样看图时要抓住三点:第一,橙色虚线始终是一条直线,所以表达能力受限;第二,蓝色曲线已经不是单一斜率,而是能在不同区间改变趋势;第三,这种改变趋势的能力正来自隐藏层加 ReLU,而不是来自“神秘的黑箱学习”。如果你再把第 5 节中的 Hidden Dim 调小,比如从 16 改成 4,再回过头看这里的蓝线,就会更容易建立直觉:MLP 本质上是在用多个局部响应单元去组合一个更复杂的函数。
当前 Python 代码尚未生成可显示的图表。
Chart Python
import math

xs = [-2.5 + 5.0 * i / 120 for i in range(121)]
target = [math.sin(1.4 * x) + 0.18 * x * x - 0.35 for x in xs]
linear_fit = [0.22 * x + 0.2 for x in xs]
mlp_like_fit = [0.55 * max(0, x + 1.6) - 0.72 * max(0, x - 0.2) + 0.35 * max(0, x - 1.4) - 0.55 for x in xs]

plot_data = {
    'data': [
        {
            'x': xs,
            'y': target,
            'type': 'scatter',
            'mode': 'lines',
            'name': 'nonlinear target',
            'line': {'color': '#0f766e', 'width': 3}
        },
        {
            'x': xs,
            'y': linear_fit,
            'type': 'scatter',
            'mode': 'lines',
            'name': 'linear model',
            'line': {'color': '#f59e0b', 'dash': 'dash'}
        },
        {
            'x': xs,
            'y': mlp_like_fit,
            'type': 'scatter',
            'mode': 'lines',
            'name': 'one-hidden-layer MLP',
            'line': {'color': '#2563eb', 'width': 3}
        }
    ],
    'layout': {
        'title': {'text': 'Linear model vs. MLP on a nonlinear function'},
        'xaxis': {'title': {'text': 'x'}},
        'yaxis': {'title': {'text': 'y'}},
        'legend': {'orientation': 'h'}
    }
}

print('如何看这张图:')
print('1) 绿色实线是目标函数,表示真正想拟合的非线性关系。')
print('2) 橙色虚线是线性模型,它只能保持单一斜率,因此无法整体跟随弯曲趋势。')
print('3) 蓝色实线是一层隐藏层 MLP 的近似效果,它能把多个 ReLU 局部响应组合起来,所以开始具备弯曲能力。')
print('4) 这里蓝线不必与绿线完全重合,重点是看它已经不再受限于一条直线。')
print('5) 这说明 MLP 的表达能力来自 隐藏层 + ReLU,而不是来自黑箱魔法。')
拓展
延伸提问

神经网络交互沙盒

嵌入 TensorFlow Playground 交互演示,用于观察数据集、特征、隐藏层和训练过程。

基于 TensorFlow Playground 开源项目嵌入。源码与许可证见 Apache-2.0 License