
如果让我给刚接触 PyTorch 的朋友推荐一个练手项目我大概率会先说别急着上图像分类也不用一上来就啃 Transformer先拿一个非线性函数拟合任务把整个训练流程跑通再说。这个项目标题看起来很朴素——基于 PyTorch 实现的非线性函数拟合目标函数是 y x³ 2x²。但越是这种看似简单的任务越能逼你把 PyTorch 里最核心的骨架全部过一遍数据怎么生成、张量怎么处理、网络层怎么搭、损失函数怎么选、优化器怎么调、训练迭代怎么写、结果怎么评估。更妙的是这类任务非常适合自查——公式答案明明白白摆在那里拟合得不对一眼就能看出问题。它适合所有想搞懂神经网络到底如何逼近函数、想亲手验证“万能逼近定理”、以及工作中要接自定义回归任务但还没把基础过一遍的工程师。文章里我会按照我自己实际做这个项目时的思路把每个环节拆开讲清楚顺便把踩过的坑也都交代出来。1. 为什么拿神经网络去拟合一个已经写好的公式1.1 拟合已知函数的真实价值在哪里第一次看到“拟合 yx³2x²”这个需求很多人会愣一下公式都有了直接代入计算不就行了拟合它图什么这里要理清一个概念神经网络拟合已知函数不是为了替代公式计算而是为了验证神经网络对函数的逼近能力。实际业务里绝大多数非线性关系都是黑盒——你不知道它是三次函数、指数函数还是某种更复杂的混合形态。神经网络的价值恰恰是在这种未知关系里找到一个可用的逼近函数。而用一个已知公式做实验等于给你一个带标准答案的测试场网络能不能学到这个映射学到什么程度哪里有偏差全都一目了然。换句话说这是一个“带着参考答案做测试”的经典场景。你后续做真实数据拟合时遇到的所有问题——欠拟合、过拟合、收敛慢、loss 降不下去——都会在这个小项目里提前遇到一遍。提前踩一次坑好过在真实项目里被坑。1.2 从线性到非线性为什么 ykxb 肯定不够用直接拿最简单的线性模型 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] 内训练但测试时去看 x4 或者 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 损失函数和优化器的实际选择逻辑这个任务本质上是一个回归问题最自然的损失函数就是均方误差MSEloss_fn nn.MSELoss()它衡量的是预测值与真实值之间差的平方的平均值。平方操作会让大的误差被放大所以网络训练时会优先把那些偏离比较大的点拉回来这符合我们想要的拟合效果。优化器我会直接推荐 Adam。用带动量的 SGD 也不是不行但学习率和动量参数都要手动调对新手来说很容易卡住。Adam 的好处是自适应学习率前期参数无论大小都能保持稳定更新收敛速度快。实测下来学习率设 0.01 通常能稳定收敛。如果 loss 一直震荡优先调低到 0.001如果收敛得像蜗牛一样慢可以试试 0.03。model MLP(hidden64, acttanh) optimizer torch.optim.Adam(model.parameters(), lr0.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_size128, shuffleTrue) 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(fepoch {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] 上训练拿去预测 x4真实值是 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, hidden64): 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(hidden64) loss_fn nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr0.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(fepoch {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(), s4, labeltrue) plt.scatter(x.numpy(), y_pred.numpy(), s4, labelpred, markerx) plt.legend() plt.show()这段代码跑完之后你会看到两条曲线几乎重合在一起——这就是最理想的拟合结果。如果重合度不够检查一下是不是隐藏层宽度太窄或者训练轮数不够。5.2 不同网络容量下的效果对比我专门做了一个小规模对照实验结果很有参考价值。需要说明的是这是基于我个人本机的实测观察不同机器、不同随机种子下具体数值会有浮动但趋势是一致的。网络配置参数量级训练集 MSE可见的曲线质量对应外推预测单层隐藏层宽度 8ReLU约百级1e-3 左右有明显折痕近似折线偏差明显单层隐藏层宽度 64ReLU约千级1e-4 左右折痕减轻但仍有棱角偏差略减双层隐藏层宽度 64Tanh几千级1e-5 到 1e-6曲线光滑肉眼几乎无差别依然偏小但稳定性好这个表格透露两个信息第一宽度和深度确实能提升拟合精度第二即使拟合得再好外推依然是个难题。后者的根源我在 5.3 里展开讲。5.3 训练范围之外的预测外推测试到底告诉了我们什么拿训练好的模型预测 x4 之前我心里大概有个预判结果不会好。实测也确实如此——真实值是 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 曲线和可视化结果里看到模型从挣扎到贴合的过程你会对神经网络的“脾气”有一个非常直接的感觉。等你再回去面对手里真正要拟合的业务数据心里会踏实很多。