如果让我给刚接触 PyTorch 的朋友推荐一个练手项目,我大概率会先说:别急着上图像分类,也不用一上来就啃 Transformer,先拿一个非线性函数拟合任务把整个训练流程跑通再说。这个项目标题看起来很朴素——基于 PyTorch 实现的非线性函数拟合,目标函数是 y = x³ + 2x²。但越是这种看似简单的任务,越能逼你把 PyTorch 里最核心的骨架全部过一遍:数据怎么生成、张量怎么处理、网络层怎么搭、损失函数怎么选、优化器怎么调、训练迭代怎么写、结果怎么评估。
更妙的是,这类任务非常适合自查——公式答案明明白白摆在那里,拟合得不对,一眼就能看出问题。它适合所有想搞懂神经网络到底如何逼近函数、想亲手验证“万能逼近定理”、以及工作中要接自定义回归任务但还没把基础过一遍的工程师。文章里我会按照我自己实际做这个项目时的思路,把每个环节拆开讲清楚,顺便把踩过的坑也都交代出来。
1. 为什么拿神经网络去拟合一个已经写好的公式?
1.1 拟合已知函数的真实价值在哪里
第一次看到“拟合 y=x³+2x²”这个需求,很多人会愣一下:公式都有了,直接代入计算不就行了?拟合它图什么?
这里要理清一个概念:神经网络拟合已知函数,不是为了替代公式计算,而是为了验证神经网络对函数的逼近能力。实际业务里,绝大多数非线性关系都是黑盒——你不知道它是三次函数、指数函数还是某种更复杂的混合形态。神经网络的价值,恰恰是在这种未知关系里找到一个可用的逼近函数。而用一个已知公式做实验,等于给你一个带标准答案的测试场:网络能不能学到这个映射,学到什么程度,哪里有偏差,全都一目了然。
换句话说,这是一个“带着参考答案做测试”的经典场景。你后续做真实数据拟合时遇到的所有问题——欠拟合、过拟合、收敛慢、loss 降不下去——都会在这个小项目里提前遇到一遍。提前踩一次坑,好过在真实项目里被坑。
1.2 从线性到非线性:为什么 y=kx+b 肯定不够用
直接拿最简单的线性模型 y = kx + b 去拟合 y = x³ + 2x²,结果一定惨不忍睹,不管你怎么调 k 和 b。原因并不复杂:线性函数无论怎么组合,佛说还是线性。你把几个线性层叠在一起,展开之后依然等价于一个线性变换——多层线性网络根本没有增加表达能力。
那非线性能力从哪里来?答案只有一个:激活函数。这也是这个练手项目最值得关注的知识点之一。像 ReLU 这类激活函数,本质上是把输入空间切成了若干线性片段,每个片段由不同的权重支配,众多片段组合起来,就能逼近一条光滑的曲线。隐藏层越多、每层神经元越多,能切出的线性片段就越密,逼近精度就越高。
你可以这样理解:单个神经元是一个简单的“开关”,只负责一小段输入范围的响应;成千上万个“开关”配合起来,就能拼出任意复杂的曲线。这就是“深度网络可以逼近任意连续函数”这件事的直观来源。
2. 数据生成与预处理:拟合任务里最容易被低估的环节
这个项目很多人一上来就写模型,我反而建议先把数据环节想清楚。数据范围、采样密度、归一化方式,直接决定了训练能不能收敛、拟合效果好不好。真实的业务数据没法挑三拣四,但自己生成数据时如果连最优条件都搞不明白,后面遇到脏数据就更没法处理了。
2.1 采样范围与采样密度怎么定才合适
以 y = x³ + 2x² 为例,如果 x 只取 [-1, 1],曲线长得很像一条直线,网络轻轻松松就能逼近,体现不出“非线性拟合”的难度,也没有训练价值。但如果 x 取到 [-10, 10],输入大了之后,y 的取值范围会膨胀到几百甚至上千,梯度很容易爆炸,收敛难度陡增。
我在实测里比较推荐的采样范围是 [-3, 3]。这个区间内函数有明确的凹凸变化:x² 项让曲线在负半轴有上行趋势,x³ 项在正半轴开始发力,整条曲线既不是纯单调,也不是毫无规律,非常适合观察网络的学习过程。样本数量方面,500 到 1000 个点完全够用,不需要更多。这是一个单输入单输出的低维问题,数据太多了纯粹浪费训练时间,数据太少又覆盖不全关键区间。
生成数据用 PyTorch 的话,直接 linspace 就够了:
import torch x = torch.linspace(-3, 3, 1000).reshape(-1, 1) y = x ** 3 + 2 * x ** 2这里有一处新手经常忽略的细节:linspace生成的是 1 维张量,而nn.Linear要求输入是 (样本数, 特征数) 的二维形状。所以千万别忘了reshape(-1, 1),不然第一层就会报维度错误。
2.2 归一化到底有没有必要
很多教程在这个任务里会直接跳过归一化,因为 x 的范围是 [-3, 3],y 的最大值约 45,输入输出量纲差异不算致命。但我想说的是:虽然跳过也能跑通,你还是应该养成归一化的习惯,尤其是在做真实项目的时候。
如果 x 的范围变成 [-100, 100],y 的量级就会冲到百万级别。这种情况下不归一化,损失函数的值动辄几十万起步,梯度变化剧烈,优化器很容易在早期震荡。把输入标准化到均值 0、方差 1,或者缩放到 [-1, 1],会让损失曲面变得“平坦”很多,梯度下降方向稳定,收敛速度明显加快。
我自己的习惯是做标准化:
x_mean, x_std = x.mean(), x.std() y_mean, y_std = y.mean(), y.std() x_norm = (x - x_mean) / x_std y_norm = (y - y_mean) / y_std训练完之后,预测结果再反归一化还原回原尺度即可。注意:归一化参数只能用训练集的统计量计算,不能在测试时重新算,否则相当于把测试集信息泄露给了模型。
2.3 训练集、验证集、测试集还是得正经划分
你可能觉得这是个玩具项目,随便划个训练集就完事了。但正因为是玩具项目,才更适合练出好习惯。我一般把 1000 个样本按 7:2:1 划分成训练集、验证集和测试集,训练过程中持续观察验证集 loss,防止过拟合。最后再用测试集做一个“盲测”,看模型在没见过的 x 值上的预测效果。
更值得玩的是留出一段“外推区间”。比如只在 [-3, 3] 内训练,但测试时去看 x=4 或者 x=-4 的预测值。这一步能非常直观地展示神经网络的一个关键特性:它是插值工具,不是外推工具。后面我会单独讲这个测试结果。
3. 网络结构设计与激活函数的搭配逻辑
神经网络结构怎么定?这是整个项目里最需要动脑子的部分。这个任务没有标准答案,选不同结构就是为了对比观察。
3.1 单隐藏层到底能不能拟合 x³+2x²
先说结论:能,但效果受宽度影响很大。
万能逼近定理告诉我们,只要一个隐藏层里神经元数量足够多,就能逼近任意连续函数。但“理论上能”和“实际上好用”是两回事。我实际测过:一个隐藏层、宽度为 8 的 ReLU 网络,经过充分训练之后能大致拟合出曲线形状,但仔细观察会发现曲线有明显的折痕,像是用几段直线拼出来的,不够平滑。把宽度加到 64 之后,拟合出来的曲线明显光滑了很多,在训练区间内几乎看不出和真实曲线的差别。
这是为什么?因为 ReLU 的本质就是分段线性函数。你每增加一个神经元,相当于多了一个“折点”,用更多线段去逼近一条曲线,逼近精度自然就上去了。这也能直观解释为什么网络宽度很重要——它决定了你手里有多少块“积木”可以拼曲线。
3.2 换一个激活函数,拟合效果会发生什么变化
既然提到 ReLU 的分段直线特性,那不妨试试其他激活函数。我在这项目里对比过 ReLU、Tanh 和 SiLU 三种。
ReLU 训练速度最快,计算简单,但导数不连续,输出曲线会有肉眼可见的折角。Tanh 的输出是连续且光滑的曲线,用它拟合光滑函数时,视觉效果通常比 ReLU 自然很多,肉眼几乎看不到拼接痕迹。SiLU(也叫 Swish)介于两者之间,既有非线性表达能力,曲线也比较光滑,是目前很多模型的默认选择。
针对 y = x³ + 2x² 这种本身就光滑的多项式函数,我个人更推荐 Tanh 或者 SiLU 作为隐藏层激活函数。但要注意,Tanh 在负半轴输出被压缩到 (-1, 0),如果初始化不好,前期梯度更新会比较慢,所以配合稍大一点的学习率或者更好的初始化策略会更稳。
还有一个小知识:输出层千万别加激活函数。因为是回归任务,输出层需要输出任意实数,加了 Tanh 或 Sigmoid 会把预测值强行限制在一个区间内,直接导致拟合失败。这个坑我见过不止一次。
3.3 损失函数和优化器的实际选择逻辑
这个任务本质上是一个回归问题,最自然的损失函数就是均方误差(MSE):
loss_fn = nn.MSELoss()它衡量的是预测值与真实值之间差的平方的平均值。平方操作会让大的误差被放大,所以网络训练时会优先把那些偏离比较大的点拉回来,这符合我们想要的拟合效果。
优化器我会直接推荐 Adam。用带动量的 SGD 也不是不行,但学习率和动量参数都要手动调,对新手来说很容易卡住。Adam 的好处是自适应学习率,前期参数无论大小都能保持稳定更新,收敛速度快。实测下来,学习率设 0.01 通常能稳定收敛。如果 loss 一直震荡,优先调低到 0.001;如果收敛得像蜗牛一样慢,可以试试 0.03。
model = MLP(hidden=64, act='tanh') optimizer = torch.optim.Adam(model.parameters(), lr=0.01)在低维问题上,Adam 基本可以做到“不怎么调参也能出结果”,把精力留给更重要的结构设计和数据分析。
4. 训练过程的完整细节:从损失曲线读懂网络状态
很多新手训练完只看一个最终 loss 数值,其实训练过程中的曲线信息量巨大。它像个仪表盘,告诉你网络现在的状态:是欠拟合、过拟合、收敛稳定,还是在震荡失稳。
4.1 训练循环的代码骨架与监控指标
训练循环的写法基本固定,但有几个细节值得注意。我建议每个 epoch 都记录训练集 loss,每个固定间隔看一下验证集 loss。如果验证集 loss 不再下降甚至回升,说明开始过拟合了,要么加正则化,要么用早停。
from torch.utils.data import TensorDataset, DataLoader dataset = TensorDataset(x_train_norm, y_train_norm) dataloader = DataLoader(dataset, batch_size=128, shuffle=True) for epoch in range(3000): model.train() epoch_loss = 0.0 for xb, yb in dataloader: optimizer.zero_grad() pred = model(xb) loss = loss_fn(pred, yb) loss.backward() optimizer.step() epoch_loss += loss.item() * len(xb) if epoch % 200 == 0: val_pred = model(x_val_norm) val_loss = loss_fn(val_pred, y_val_norm).item() print(f"epoch {epoch:4d} train_loss {epoch_loss / len(x_train):.6f} val_loss {val_loss:.6f}")你会在打印结果里看到典型的三阶段变化:前期 loss 快速下降,下降幅度肉眼可见;中期开始缓慢下降;后期基本进入平台期。这时候再做更多训练对精度提升的意义已经不大,不如去调结构。
4.2 欠拟合、过拟合与“看着完美但外推就崩”的区别
如果 loss 始终降不下来,最常见的原因有四个:第一,数据没做归一化,输入输出量纲差太远;第二,激活函数死区,比如 ReLU 在负数区间大量神经元输出为 0,梯度传不回去;第三,学习率太大,loss 曲线来回震荡、根本不收敛;第四,网络容量不足,比如单层宽度只有 4 个神经元,那确实很难表达这种非单调曲线。
如果训练 loss 降到非常低,验证集 loss 也不差,但把边界外的 x 值输入进去预测,结果完全不对呢?这是最值得关注的一种现象。比如只在 [-3, 3] 上训练,拿去预测 x=4,真实值是 96,但模型很可能只给你一个几十甚至更小的数,方向对但幅度差得远。这说明模型只是在训练区间内完成了插值,并没有学到“这个函数往右还会继续往上飙”的外推规律。
我常常跟朋友说:别因为训练集上的完美拟合就高兴太早,真正的考验是测试集,尤其是训练范围之外的表现。很多商业项目翻车,都翻在“分布外预测”。
4.3 学习率与 Batch Size 的小实验
这个项目是低维问题,样本只有 1000 个,Batch Size 的影响其实不算大,但值得做个小实验感受一下。
我试过全量梯度下降,也就是 Batch Size 等于全部样本,这时候梯度方向最稳定,收敛曲线很平滑。也试过 Batch Size 32 的小批量训练,配合 shuffle,收敛速度快一些,但 loss 曲线会有轻微波动。两者最终效果差别不大,这反而印证了一个规律:数据量少、维度低的时候,全量梯度下降更稳,小批量优势不明显;只有在数据量大、GPU 并行能力能充分利用时,小批量的优势才真正体现。
学习率这块,0.1 太大,loss 会震荡甚至发散;0.001 能收敛但速度慢;0.01 一般是合适的起点。如果你发现训练到一半 loss 开始震荡,大概率就是学习率偏大,及时调小即可。
5. 完整可复现代码与结果分析
讲完原理,给一份可以直接跑通的完整代码。我用了一个双层隐藏层网络加 Tanh 激活函数,用 Adam 优化器训练 3000 轮。整个脚本不依赖任何外部数据集,复制就能跑。
5.1 从数据生成到可视化的端到端代码
import torch import torch.nn as nn import matplotlib.pyplot as plt # 数据生成 torch.manual_seed(42) x = torch.linspace(-3, 3, 1000).reshape(-1, 1) y = x ** 3 + 2 * x ** 2 # 归一化 x_mean, x_std = x.mean(), x.std() y_mean, y_std = y.mean(), y.std() x_norm = (x - x_mean) / x_std y_norm = (y - y_mean) / y_std # 打乱并划分 idx = torch.randperm(1000) x_norm, y_norm = x_norm[idx], y_norm[idx] x_train, y_train = x_norm[:700], y_norm[:700] x_val, y_val = x_norm[700:900], y_norm[700:900] x_test, y_test = x_norm[900:], y_norm[900:] # 网络定义 class MLP(nn.Module): def __init__(self, hidden=64): super().__init__() self.net = nn.Sequential( nn.Linear(1, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 1) ) def forward(self, x): return self.net(x) model = MLP(hidden=64) loss_fn = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.01) # 训练 for epoch in range(3000): model.train() optimizer.zero_grad() pred = model(x_train) loss = loss_fn(pred, y_train) loss.backward() optimizer.step() if epoch % 500 == 0: model.eval() with torch.no_grad(): val_loss = loss_fn(model(x_val), y_val).item() print(f"epoch {epoch:4d} train_loss {loss.item():.6f} val_loss {val_loss:.6f}") # 测试 model.eval() with torch.no_grad(): y_pred_norm = model(x_norm) y_pred = y_pred_norm * y_std + y_mean # 可视化 plt.scatter(x.numpy(), y.numpy(), s=4, label="true") plt.scatter(x.numpy(), y_pred.numpy(), s=4, label="pred", marker="x") plt.legend() plt.show()这段代码跑完之后,你会看到两条曲线几乎重合在一起——这就是最理想的拟合结果。如果重合度不够,检查一下是不是隐藏层宽度太窄或者训练轮数不够。
5.2 不同网络容量下的效果对比
我专门做了一个小规模对照实验,结果很有参考价值。需要说明的是,这是基于我个人本机的实测观察,不同机器、不同随机种子下具体数值会有浮动,但趋势是一致的。
| 网络配置 | 参数量级 | 训练集 MSE | 可见的曲线质量 | 对应外推预测 |
|---|---|---|---|---|
| 单层隐藏层,宽度 8,ReLU | 约百级 | 1e-3 左右 | 有明显折痕,近似折线 | 偏差明显 |
| 单层隐藏层,宽度 64,ReLU | 约千级 | 1e-4 左右 | 折痕减轻但仍有棱角 | 偏差略减 |
| 双层隐藏层,宽度 64,Tanh | 几千级 | 1e-5 到 1e-6 | 曲线光滑,肉眼几乎无差别 | 依然偏小,但稳定性好 |
这个表格透露两个信息:第一,宽度和深度确实能提升拟合精度;第二,即使拟合得再好,外推依然是个难题。后者的根源我在 5.3 里展开讲。
5.3 训练范围之外的预测:外推测试到底告诉了我们什么
拿训练好的模型预测 x=4 之前,我心里大概有个预判:结果不会好。实测也确实如此——真实值是 96,如果用的是 Tanh 网络,预测值可能只有四五十;如果用 ReLU 网络,预测斜率大概率逼近最右侧那片线性片段的斜率,量级能对上一些,但仍然不是真实值。
原因在于神经网络的本质是插值。模型把训练数据分布的区域学得再好,也只是在那个范围内准确,出了这个范围,它没有足够的信息建立正确趋势。Tanh 网络尤其吃亏,因为 Tanh 的输出被限制在 (-1, 1) 之间,即使后面接了一个线性输出层,组合起来的外推能力也远不如多项式函数真实的那条曲线。
所以如果你的真实需求里有外推预测,必须把能覆盖到的范围尽量扩充进训练数据,或者使用更加结构化的模型。如果只是纯插值任务,比如根据已有测量数据补齐中间空缺,那神经网络完全够用。
6. 实操中踩过的坑与排查清单
这个项目看上去简单,但我自己玩了好几轮,也帮别人排查过不少问题。很多坑不是因为代码复杂,而是因为细节没注意。
6.1 明明 loss 很低,画出来的图却对不上
这是我最常遇到的问题之一。训练 loss 已经降到了 1e-6,看起来完美,但画出来的预测曲线变成了“毛线团”,怎么都跟真实曲线对不上。
排查下来,最常见的原因是绘图时数据顺序乱掉了。如果训练前对数据做了 shuffle,而验证和画图时使用的 x 没有同步排序,预测点和真实点错位,连线就会扭成一团。解决办法很简单:画图时不要用散点连线,直接用散点展示;或者统一把索引恢复原序。上面代码里我用的是散点图,就是为了避免这个坑。
还有一个可能被忽略的原因是:模型在训练结束后没有切回 eval 模式。如果网络里有 Dropout 或者 BatchNorm,训练模式下的随机性会让预测结果变得不稳定,这时候就需要调用model.eval(),并且把梯度计算关掉以省内存。
6.2 复现不出别人效果时的排查顺序参考
如果你照着一个教程写,结果却不如预期,别急着怀疑代码抄错了,按下面的顺序排查:
- 先看数据有没有归一化,尤其注意特征和标签的量级是否差太多。
- 再确认激活函数有没有加在输出层,回归任务输出层加激活函数是大忌。
- 接着检查学习率,过大过小都常见,优先调到 0.01 再往下试。
- 然后看网络容量,隐藏层宽度低于 16 时,拟合这种曲线会非常吃力。
- 最后确认训练轮数是否足够——3000 轮是这个项目的合适范围,100 轮连热身都不够。
按照这个顺序排查,绝大多数问题都能解决。
6.3 这个项目还能怎么往下扩展
跑通这个基础项目之后,我强烈建议你再做几个变形实验,把知识内化得更深。比如把目标函数换成 y = sin(x) + cos(x),用周期函数的拟合结果和多项式函数做对比,观察神经网络如何逼近振荡特性;或者给 y 加上均值不为零的噪声,训练一个真实带噪声数据的拟合模型,这能让你理解噪声对 loss 的影响;你也可以把单输入单输出扩展成多输入多输出,比如拟合 z = x² + y²,这对理解网络输入输出维度的灵活性很有帮助。
我个人练这个项目练了不止一遍,每换一种激活函数、每改一次宽度,观察到的现象都不同。尤其是当你把网络“喂大”之后,从 loss 曲线和可视化结果里看到模型从挣扎到贴合的过程,你会对神经网络的“脾气”有一个非常直接的感觉。等你再回去面对手里真正要拟合的业务数据,心里会踏实很多。