算法可视化与交互学习平台
用 MLP 拟合非线性关系MLP: Fitting Nonlinear Functions
从最小两层感知机出发,理解隐藏层、ReLU、反向传播和 autograd,并通过交互实验观察 MLP 如何拟合非线性关系。
先抓住的目标
不是一上来背很多术语,而是先建立一个非常具体的认知:MLP 就是在输入和输出之间,插入一层可以学到中间表示的神经元。
你会依次看到:
- 为什么线性模型不够
- 隐藏层到底在做什么
ReLU为什么能引入非线性forward -> loss -> backward -> step这条训练链路如何串起来autograd为什么能帮我们自动求梯度
学完后,你至少应该能看懂这段最小 PyTorch 代码,不再把神经网络当成“黑盒魔法”。
前向传播:先算隐藏层,再算输出层
第一层把输入 x 线性变换后送入激活函数,得到隐藏表示 h;第二层再把 h 组合成最终输出 y。
训练时到底发生了什么
训练并不神秘:先做前向传播得到预测,再计算损失 L,然后通过梯度更新所有参数 theta。
为什么需要 MLP,而不是继续用一条直线
1. 线性模型能做什么?
如果模型写成 \hat{y} = wx + b,那么它的表达能力本质上就是一条直线。
这类模型很适合处理“输入变大,输出大致按固定斜率变化”的关系,但当数据本身是弯曲的、分段的、带拐点的,它就会明显吃力。
2. MLP 的核心改动在哪里?
MLP(Multi-Layer Perceptron,多层感知机)不是直接把 x 变成 y,而是先把输入送进一层隐藏层,得到一个中间表示 h,再从 h 生成输出。
你可以先把它粗略理解成:
- 第一层:把原始输入重新“加工”成一组更有用的特征。
- 第二层:再根据这些新特征组合出最终输出。
3. 为什么这一步会让模型更强?
因为隐藏层中的每个神经元都在看输入的不同侧面。它们不会都学成一样的东西:
- 有的神经元更关注输入偏小的区域。
- 有的更关注输入偏大的区域。
- 有的会在某个范围内“激活”,范围外几乎不起作用。
这样一来,模型就不再只是“一条直线”,而是可以把多个局部模式拼起来,形成更复杂的函数形状。
4. 初学者最重要的一句话
神经网络并不是神秘地“直接学会答案”,而是先学一组中间特征,再把这些特征组合成输出。
后面你看到的隐藏层、ReLU、反向传播,都是在服务这件事。
PyTorch MLP 训练过程观察
把 PyTorch MLP 训练拆成可调参数实验。你可以修改隐藏层宽度、学习率、总 epoch、观察步长和随机种子,逐 epoch 观察预测曲线如何逼近训练数据,并同时查看损失曲线与隐藏层激活图。
逐行理解这段代码:多层网络、反向传播、autograd
1. 先抓住这段代码真正做了什么
PyTorch MLP 训练过程观察 区块,本质上是在做一件事:用一个一层隐藏层的 MLP 去拟合一组非线性数据,然后把训练过程拆成可以观察的几个步骤。
不要把它理解成“神经网络神奇地学会了一条曲线”,而要理解成:先把输入变成一组隐藏特征,再把这些隐藏特征组合成输出;训练只是不断调整参数,让这个组合越来越贴近目标数据。
2. 第一层 Linear(1, 16):把一个输入变成 16 个观察角度
代码里第一层是:
self.fc1 = nn.Linear(1, hiddenDim)当默认参数是 hiddenDim = 16 时,它的意思就是:把一个输入 x 映射成 16 个隐藏单元的中间结果。对第 i 个神经元来说,它做的计算是:
也就是说,每个神经元都先做一次线性变换。因为每个神经元的权重 w_i 和偏置 b_i 都不同,所以它们相当于从不同角度观察同一个输入 x。这就是“隐藏层不是复制输入,而是在构造多种中间特征”的第一步。
代码里对应的是:
h = self.fc1(x)这里的 h 还不是最后要用的激活值,而是 16 个线性变换结果组成的向量,可以把它看成公式里的 。
3. ReLU:把线性变换变成分段线性响应
接下来代码是:
self.act1 = nn.ReLU()
h_act = self.act1(h)ReLU 的公式非常简单:
它的作用是:如果某个神经元当前输出是负的,就把它截成 0;如果是正的,就保留下来。这样一来,原来单纯的线性变换就变成了“在某些区间响应,在某些区间不响应”的分段线性函数。
这一步非常关键,因为如果没有激活函数,两层线性层叠加后,本质上仍然还是一个线性模型;而有了 ReLU,模型才开始具备拟合非线性曲线的能力。
所以这里要建立一个核心认知:MLP 之所以能拟合弯曲关系,不是因为网络很神秘,而是因为 ReLU 让每个隐藏单元都变成了一个局部响应器。
4. 第二层 Linear(16, 1):把隐藏特征重新组合成输出
代码里的输出层是:
self.fc2 = nn.Linear(hiddenDim, 1)
out = self.fc2(h_act)这一层做的事是把 16 个激活后的隐藏特征重新加权求和,形成最终预测值。公式写成:
这里:
- 是第
i个隐藏神经元经过 ReLU 后的激活值。 - 是输出层给这个隐藏特征分配的权重。
- 是输出层偏置。
因此,这个模型并不是直接从输入画出一条曲线,而是先得到很多局部特征,再把这些特征拼起来,合成最终预测。
这就是为什么当你把隐藏层宽度从 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()它对应的公式是均方误差:
这里:
- 是第
i个样本的预测值。 - 是第
i个样本的真实值。 - 平方的作用是让正负误差都变成正值,而且大误差会被放大。
- 最后取平均,是为了得到整体样本上的平均误差水平。
所以 loss 越小,就说明预测曲线整体上越接近训练数据。
7. 反向传播和 autograd:梯度是怎么来的
训练循环里紧接着是三行:
optimizer.zero_grad()
loss.backward()
optimizer.step()这三行分别对应训练公式里的三个动作。
首先,参数更新公式可以写成:
其中:
- 表示模型所有参数,也就是第一层和第二层中的权重、偏置。
- 是学习率,对应代码里的
learningRate。 - 是损失对参数的梯度,表示“如果参数往某个方向动,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 的工作方式,先从更少的神经元开始观察更有效。
为什么 MLP 能拟合弯曲关系
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 交互演示,用于观察数据集、特征、隐藏层和训练过程。