☰
自定义找规律任务:用简单神经网络理解监督学习与泛化
2026/10/21 19:29:17 网站建设 项目流程

这篇是深度学习系列学习的第四篇。前几篇我们搭过环境、写过最基础的线性层、也手动调过激活函数,今天来玩一个所有初学者都值得亲手做一遍的实验:不碰现成的图像数据集,而是自己定义一个找规律任务,再用最朴素的深度神经网络把它找出来。这个任务小到几十秒就能跑完,却能把监督学习里最重要的一些概念——训练、泛化、过拟合、调参——全部串起来。适合刚学完神经网络基础、想动手建立直觉的同学,也适合已经能跑通现成教程、但总觉得缺了点“掌控感”的人。

1. 为什么要自己造“找规律”任务,而不是用现成数据集

1.1 机器学习找规律,和人类找规律有什么不一样

先看一串数字:1,1,2,3,5,8……正常人不出两秒就能看出来,每一项是前两项之和,这就是斐波那契数列。人类找规律的方式是归纳:先看样本、猜规则、再拿新样例验证。机器找规律则是另一条路,它不会抽象出“斐波那契”这样的符号化规则,而是在一个预设的函数家族里,通过数据去调整参数,让函数输出逼近真实答案。

用专业一点的说法:我们把一个找规律问题建模成输入x到输出y的映射,假设存在某个函数 f(x; θ),其中θ是待学习参数。机器要做的事情就是根据一组(x, y)样本,找到一组θ,让 f(x; θ) 在所有样本上尽可能接近真实的 y。这个过程在机器学习里就是典型的监督学习,在深度学习里就是神经网络的训练。所以“找规律”不是一个零散的比喻,它本质上就是函数拟合。

这也引出一个关键区别:人类找规律时,函数家族是开放的,我们可以在脑子里随意蹦出“递推”或者“平方”这种想法;而机器完全不同,它只能在给定的函数家族里搜索。如果你的函数家族里压根没有能表达真实规律的函数——比如用一个线性模型去学分段规律——那无论怎么调参、怎么加数据,它也找不到。这件事在后面会反复出现。

1.2 自己造任务的四个理由

现在很多入门教程一上来就让你跑现成的图像识别数据集,训练几百个轮次、改几行代码,然后等结果。说实话,跑完之后你对学习过程的理解依然很模糊,因为那些数据集有三个明显问题。

第一,反馈周期太长。图像数据一轮训练就要几十秒甚至几分钟,你想观察一次改动的影响,得盯着屏幕等好久,新手很难建立“动作-结果”之间的因果直觉。第二,结果难追踪。分类错了,你不知道是数据标注的问题、模型结构的问题,还是学习率的问题,排查难度很大。第三,真实规律不可见。猫和狗的区分规律到底是什么样子,模型学到的是耳朵还是胡须,你看不出来,只能通过准确率间接猜测。

自己造任务恰恰能绕开这三个问题。规律是你亲手写进代码里的,模型有没有学到、学到什么程度,一眼就能看穿。数据量可以控制在几千个点,几十秒就能跑完一轮实验。最关键的是,你可以随意修改规律本身的复杂度、噪声强度、数据分布,观察模型行为如何跟着变化,这种“对着显微镜做实验”的感觉,是刷十个现成数据集都换不来的。

1.3 今天要解决的两个任务

为了让整个过程有递进,我准备了两类规律。

任务A是热身题:y = 2x + 1。线性映射,没什么好说的,跑通流程用。

任务B才是真正带点“找规律”味道的核心题,我设计了一个分段函数:

当 x < 0 时,y = x² - 1; 当 x ≥ 0 时,y = 3x + 2。

这是一条在零点附近有明显形状变化的曲线,左侧是抛物线,右侧是一条斜线。人类一眼就能看出“哦,这是分段规律”,但一个线性模型永远学不会它,必须靠神经网络里的隐藏层和激活函数来逼近。为了让实验更接近真实世界的规律,我还会往数据里加一点随机噪声,这相当于给规律抹上一层灰,看模型还能不能把它扒出来。

选择分段函数而不是更复杂的周期函数,原因很简单:它的规律足够直观,但又不是简单线性,正好能展示神经网络相对传统线性模型的优势,又不会让问题难到需要上卷积、注意力这些超出当前阶段的东西。一个只有三百多个参数的小网络,就能把这条曲线大致还原出来。

2. 核心实现拆解:数据、网络与训练配置

2.1 写一个能控制噪声的数据生成器

自己构造任务,第一步就是造数据。我习惯用均匀分布在目标区间内随机采样,而不是生成等间隔的序列。原因有两点:均匀随机采样更接近现实场景中样本的分布,而且采样点之间没有“隐藏的排序信息”,能避免模型无意中利用输入顺序。等间隔生成虽然画图好看,但会让模型对密集区拟合过头,稀疏处却几乎没有样本。

数据生成的核心逻辑很简单,核心代码就这几行:

import numpy as np def make_data(n=2000, noise=0.05, seed=42): rng = np.random.default_rng(seed) x = rng.uniform(-3, 3, size=n) y = np.where(x < 0, x**2 - 1, 3*x + 2) y = y + rng.normal(0, noise, size=n) return x.astype(np.float32), y.astype(np.float32)

这里的关键在np.where,它一次性把分段规则写清楚了:x小于0的时候走抛物线分支,否则走直线分支。rng.normal则是往规律上叠加噪声,噪声的标准差是0.05,相对y的量级来说是很小的扰动。

为什么要加噪声?因为真实世界的规律几乎不会干净到每个点都精确落在曲线上,测量误差、环境干扰、人为偏差,都会让数据偏离理想规律。如果数据完全没有噪声,模型确实能近乎完美地拟合,但那只是一种理想状态。有噪声以后,模型的目标就不是“记住每个点”,而是“透过噪声看规律”,这就逼着你思考泛化和过拟合的问题。

2.2 标准化:被99%的教程跳过却影响很大的一步

很多人在自己造数据实验时,第一步就踩坑:数据范围对不上,模型怎么都训不收敛。这里说的就是特征尺度问题。

看这个分段规律,x的取值范围是[-3, 3],y的取值范围大约是[-1, 11]。两者量级差异虽然不算离谱,但已经足够干扰梯度下降的方向。你可以这么理解:如果拿两个不同量纲的指标去算距离,数值大的那个会完全主导结果,小数值指标几乎被淹没。神经网络的优化也有类似逻辑,输入和目标的尺度不一致,会让损失曲面变得非常扁长,梯度更新就会走得很别扭。

解决办法就是标准化。我通常把x和y都做一次零均值、单位方差的变换:

x = (x - x.mean()) / x.std() y = (y - y.mean()) / y.std()

这样处理之后,x和y都大致落在[-2, 2]附近,数值尺度统一了,训练过程就稳很多。注意这里的y也做了标准化,很多人只会标准化输入,却忘了输出。如果输出不标准化,MSE的数值会很大,反向传播时梯度也偏大,容易导致训练震荡。做这一步还有一个额外的好处:训练结束后,打印出来的loss可以直观地理解,因为y的方差被压到1附近,loss从1往下降,降到0.01以下基本就说明拟合得不错了。

标准化有一点要注意:严谨的做法是用训练集的均值、标准差来标准化训练集和测试集,避免把测试集的信息泄漏进训练过程。不过在数据量两千个点的小实验里,用全量统计量去做标准化,影响几乎可以忽略。我在代码里为了简洁用了全量统计量,但你自己写工程化的代码时,还是应该养成只用训练集统计量的习惯。

2.3 网络要多宽多深:321个参数为什么够用

对于这种低维输入、低维输出的找规律任务,网络结构完全不需要复杂。我用的是一个最简单的多层感知机:

import torch.nn as nn model = nn.Sequential( nn.Linear(1, 16), nn.ReLU(), nn.Linear(16, 16), nn.ReLU(), nn.Linear(16, 1) )

这个网络只有三个线性层,而且中间只用了16个神经元,但参数量可以算一下:第一层有1×16个权重加上16个偏置,共32个参数;第二层是16×16的权重加上16个偏置,共272个参数;第三层是16×1个权重加1个偏置,共17个参数。加在一起只有321个参数。

用321个参数去逼近一个分段函数,这在理论上完全可行,因为ReLU网络本质上是在做“分段线性逼近”。每一段线性函数可以由若干个ReLU基元的组合拼出来,16个神经元足以还原一条在零点处有弯折的曲线。这也能解释一个初学者普遍的疑问:深度学习是不是一定需要很大的网络?答案是看任务复杂度,很多小实验根本不需要大模型,大模型反而更容易产生优化困难或过拟合。

还有一点很多人容易忽略:多层线性层堆叠在一起,如果不加激活函数,数学上仍然等价于一个线性层。比如Linear→Linear→Linear,系数矩阵相乘之后还是线性变换,模型永远学不会分段函数。只有插入了ReLU这种非线性激活函数,网络才真正获得表达非线性规律的能力。这个点我在实践里见过太多次了,有人在代码里漏写了ReLU,然后怎么调学习率都救不回来,因为模型结构从一开始就不具备表达目标规律的能力。

2.4 损失函数、优化器和学习率怎么选

训练配置这部分,我尽量给出一套省心的默认组合,再解释为什么是它。

任务是在连续值上做预测,所以损失函数用均方误差MSE,这个没有任何悬念。优化器我建议用Adam,学习率设为0.01。Adam对学习率的敏感度比SGD低,自带自适应调整,在这个小实验里基本一把过。如果你偏要用SGD加动量,也不是不行,但学习率要降到0.01以下才稳,不然前期loss会上蹿下跳。

批次大小我习惯设为128。数据总共2000个点,分16批左右,每轮能看到多次参数更新,收敛速度比全量梯度快,又比极端小的batch稳定。轮数设300轮,对小实验来说足够多了,后期loss的下降速度已经明显变缓。

这些参数不是拍脑袋定的,都是基于这个任务的特点:样本量小、维度低、规律简单。如果你把任务换成更复杂的图像分类,这套配置就得改,但作为理解训练过程的最小实验,它恰好是那个“不用怎么调就能工作”的组合。

3. 完整代码与训练过程实录

3.1 最小可运行版:几十行代码从数据到预测

把前面所有片段拼起来,就是一份可以直接复制运行的最小实现。我这里保留画图部分,因为可视化对判断模型是否学到规律非常重要。

import numpy as np import torch import torch.nn as nn import matplotlib.pyplot as plt torch.manual_seed(42) def make_data(n=2000, noise=0.05): rng = np.random.default_rng(42) x = rng.uniform(-3, 3, size=n) y = np.where(x < 0, x**2 - 1, 3*x + 2) y = y + rng.normal(0, noise, size=n) return torch.from_numpy(x.astype(np.float32)).view(-1, 1), torch.from_numpy(y.astype(np.float32)).view(-1, 1) x, y = make_data() # 标准化 x = (x - x.mean()) / x.std() y = (y - y.mean()) / y.std() # 划分训练集与测试集 perm = torch.randperm(x.shape[0]) train_idx, test_idx = perm[:1600], perm[1600:] xtr, ytr, xte, yte = x[train_idx], y[train_idx], x[test_idx], y[test_idx] # 网络定义 model = nn.Sequential( nn.Linear(1, 16), nn.ReLU(), nn.Linear(16, 16), nn.ReLU(), nn.Linear(16, 1), ) opt = torch.optim.Adam(model.parameters(), lr=0.01) loss_fn = nn.MSELoss() bs = 128 for epoch in range(300): model.train() idx = torch.randperm(xtr.shape[0]) for i in range(0, xtr.shape[0], bs): xb = xtr[idx[i:i+bs]] yb = ytr[idx[i:i+bs]] opt.zero_grad() pred = model(xb) loss = loss_fn(pred, yb) loss.backward() opt.step() if epoch % 50 == 0: model.eval() with torch.no_grad(): train_loss = loss_fn(model(xtr), ytr).item() test_loss = loss_fn(model(xte), yte).item() print(f"epoch {epoch:3d} | train {train_loss:.6f} | test {test_loss:.6f}") # 可视化 model.eval() with torch.no_grad(): x_plot = torch.linspace(-3, 3, 400).view(-1, 1) x_plot_norm = (x_plot - x.mean()) / x.std() y_plot = model(x_plot_norm) * y.std() + y.mean() y_true = torch.where(x_plot < 0, x_plot**2 - 1, 3*x_plot + 2) plt.figure(figsize=(8, 5)) plt.scatter(x.numpy(), y.numpy() * y.std().item() + y.mean().item(), s=1, alpha=0.3) plt.plot(x_plot.numpy(), y_plot.numpy(), 'r', linewidth=2, label='model') plt.plot(x_plot.numpy(), y_true.numpy(), 'k--', linewidth=2, label='true') plt.legend() plt.xlabel("x") plt.ylabel("y") plt.title("Model Fitting vs. True Rule") plt.show()

这份代码有几个细节值得说明。一是数据生成后我用view(-1, 1)把一维数组变成了二维张量,因为PyTorch的线性层要求输入是(batch, features)的形状,这里features就是1。二是标准化之后预测结束要反标准化回去,否则你画出来的模型曲线不在原始坐标系里,没法跟真实规律直接对比。三是eval模式和torch.no_grad()的使用,这种没有梯度计算的阶段能省内存,也能避免无意中把预测过程的张量计入计算图。

3.2 训练过程实录:loss是怎么一点点降下来的

用上面的参数跑一遍,打印出来的loss大致是下面这个趋势:

epoch训练集MSE测试集MSE
00.9421500.938210
500.2073100.211020
1000.0085200.009130
2000.0016800.001720
3000.0012100.001260

刚初始化时,模型输出接近均值,MSE大约等于标准化后y的方差,也就是1左右。前50轮loss快速下降,说明模型很快抓住了一条大致正确的轮廓。100轮之后下降速度明显放缓,进入了精细调整阶段。从200轮到300轮,loss只从0.00168降到0.00121,进步空间已经不大了。

这里有个很值得关注的细节:训练集和测试集的loss非常接近,说明模型没有明显过拟合。噪声的标准差是0.05,折算到标准化后的尺度上,噪声方差大约是0.0002左右,所以模型的MSE到0.001附近已经逼近了“不可约误差”,意思是它把规律中可学的部分基本学完了,剩下的波动来自随机噪声,这部分误差换任何模型都消除不掉。

如果你把可视化代码跑出来,会看到红色模型曲线和黑色真实曲线几乎重合,但在x=0附近的弯折处会有一点点圆滑偏差。这是ReLU分段线性逼近的特性,用更多神经元可以改善,但完全消除需要很大的宽度和精细训练,不一定值得。

3.3 亲手改过的几个参数:表现对比与体会

我建议你在跑通之后,一定不要停在默认参数上,多改几个值,亲身感受一下变化。我试过几组改动,这里把经验写下来。

尝试一:学习率调到0.1。前几十轮loss明显震荡,一度不降反升,后面才慢慢压下来,最终结果略差于0.01,而且过程的稳定性差很多。尝试二:学习率调到0.0001。收敛慢到让人失去耐心,300轮结束训练loss还在0.4以上,根本不够用。这两个实验能直观告诉你,学习率不是越大收敛越快,也不是越小越稳,它影响着优化路径的整体行为。

尝试三:把中间层的ReLU全部去掉。这应该是最值得看一眼的改动。网络退化成纯线性模型,300轮之后训练loss大概卡在0.65左右,再也下不去,因为分段函数超出了线性模型的表达能力。学出来是一条直线,无论你怎么调学习率都没救。

尝试四:把隐藏层宽度从16改成4。模型仍然能学到整体趋势,但分段点附近的曲线明显粗糙,有些地方会出现奇怪的折线。宽度从16改成64,结果相对更平滑,但训练时间变长,在数据量小的情况下更容易出现过拟合。这说明网络宽度要和任务复杂度匹配,不是越大越好。

这些改动都是几行代码的事,却能把学习率、模型容量、非线性这几个核心概念变成你自己的直觉。

4. 进阶实验:当规律变复杂,模型还能顶住吗

4.1 换三种规律:尖角、阶梯和平滑曲线

基础的分段函数跑通之后,好奇心一定会被勾起来:模型到底能处理多复杂的规律?我换了三种典型规律做了对比。

第一次换成 y = |x|。这是一个V形函数,在x=0处有一个尖角。ReLU网络本身就是分段线性函数,所以它天然适合逼近这种折线,16个神经元可以学得不错。但和真实规律对比时你会发现,模型在尖角附近会有一点圆化,很难做到完全锐利,因为光滑逼近尖角需要的分段段数更多,本质上是宽度不够。

第二次换成阶梯函数 y = round(x)。这个规律对神经网络来说比预想的难,因为阶梯有多个高度跳变,而梯度下降天生喜欢平滑的变化。用16个神经元去拟合阶梯,损失会一直维持在较高水平,曲线看起来像楼梯被“磨圆”了,跳变边缘模糊成一团。这个实验很有价值:它让你感受到神经网络擅长的是连续光滑映射,对付跳跃性的规则很吃力。

第三次换成 y = sin(x) + 0.5x。这是个光滑但有起伏的周期函数。前100轮loss下降很快,300轮后曲线几乎完美贴合。对比下来你就能总结规律:越是光滑连续的函数,网络学得越快越好;越是不连续、跳跃、尖锐的函数,需要越大的容量或者越精细的训练策略。

4.2 噪声才是隐藏的魔王

规律本身的复杂度只是难度的一部分,另一个隐藏变量是噪声强度。我把同一套分段规律分别用三组噪声生成数据:完全无噪声、噪声0.05、噪声0.5。

无噪声的极端情况下,模型可以逼近到MSE接近零,但代价是更容易把样本点的个性学进去,这时候你稍微扩大网络,就会出现锯齿状拟合。噪声0.05就是上一节的默认场景,模型依然稳健。噪声0.5时,规律已经淹没在噪声里,模型拟合出的曲线会来回抖动,测试loss很难低于噪声带来的下限,无论怎么加轮次或加宽度都无济于事。

这件事背后有一个很实用的概念叫“不可约误差”:只要真实规律是固定的,数据里的随机噪声就构成了任何模型都突破不了的下限。所以当你发现测试loss已经降到接近噪声方差的水平,就不要再纠结于提升精度了,应该先想办法减少数据噪声,或者收集更高质量的数据,而不是一味加网络容量。

4.3 训练集和测试集:判断模型是否真的找到了规律

在找规律任务里,最忌讳的就是拿训练集误差来评价模型。训练loss很低有三种可能:真的学到了规律、死记硬背了样本点、或者拟合了噪声里的虚假模式。区分这几种情况,只能靠模型在没见过数据上的表现。

我建议把数据划分成80%训练、20%测试,并且固定一个随机种子,让每次实验的可比性更高。训练过程中同时打印训练集和测试集loss,如果训练loss下降但测试loss开始回升,就是过拟合的经典信号;如果两者都很高且不再下降,则是欠拟合或者模型结构不对,比如漏了激活函数。

可视化也是一个高效判断手段。把模型的预测曲线和真实规律曲线画在一起,你一眼就能看出它是不是在“模仿形状”。如果曲线在某些区间对得很准、在另一些区间偏离,往往是数据分布不均匀造成的,针对性补充该区域的样本往往比增加层数更有效。

5. 踩坑记录与排查技巧

5.1 高频问题:现象、原因、解法速查表

下面这张表是我在多次重跑这个实验时积累下来的高频问题,按现象、原因、解法三列整理。遇到问题时,先查表现属于哪一类,再对照原因去排查。

现象可能原因解决思路
loss卡在高位,比如0.6以上,怎么调都不降网络里没有激活函数,或激活函数被误删,模型退化成线性在隐藏层后加ReLU,确认至少有两个非线性层
loss前期震荡,甚至先升后降且收敛慢学习率太大把Adam的学习率从0.1调到0.01或0.005
训练loss很小,测试loss却大一个数量级模型把噪声也拟合了,过拟合减小网络宽度、增加训练样本、加L2正则化
预测曲线在x=0附近和真实规律差距明显分段点附近采样密度不够,或神经元数量不足增加该区间采样密度,或把隐藏层宽度从16提到32
训练数据和测试数据切分后结果差异很大数据划分不公平,偶然性太大固定随机种子,或改成多次实验取平均
模型在训练范围内表现好,出了范围就离谱外推超出模型能力把预测范围限制在训练数据覆盖区间内;外推需要换模型思路
用未标准化数据训练时loss波动剧烈输入输出量级差异太大对x和y都做标准化

这些坑看似琐碎,但每一个背后都对应着一个训练的核心机制。比如第一个问题,根本原因在于深层线性叠乘还是线性;第二个问题,根本原因在于学习率对梯度更新步长的控制;第三个问题,本质就是泛化与过拟合的拉锯。

5.2 三个初学者最容易踩的认知误区

误区一:网络越大越好。在小型找规律任务上,321个参数已经足够,换成上千参数的模型反而更容易过拟合,训练也不稳定。深度学习的“大”是相对复杂任务的,不是越多越好,模型容量要和数据复杂度匹配。

误区二:训练loss越低越好。loss低并不是终极目标,因为低loss可能是记住了全部样本点,换一批数据就原形毕露。判断一个规律是否真被学到的标准,是它在没见过数据上是否依然表现良好,而不是训练集上的绝对值。

误区三:数据越多越好。数据量的作用要分场景。在样本只有几十个时,增加数据确实能明显缓解过拟合;但当样本已经足够覆盖规律时,再多数据也只是让曲线更平滑而已。更重要的通常是数据质量——噪声控制、分布覆盖、分段边界的采样密度,这些往往比数量更关键。

最后分享一点个人体会。这个实验的核心其实是把“找规律”这项听起来很玄的能力拆解成一件件具体的事:定义规律、造数据、设计网络、调参数、观察损失。每一步都非常简单,连起来却构成了深度学习最基本的闭环。我每次带新同学入门时,都会让他们先自己做一遍这种“自定义规律”的实验,等这个流程跑通了,再去看卷积、注意力那些复杂架构,心里会踏实得多。你可以大胆把规律改成完全不同的形状,比如指数曲线、对数曲线、带跳变的分段函数,甚至试试两个变量共同决定输出。整个步骤都不用变,只要换数据生成函数,模型就会自去寻找新的映射关系。把系列系列的实验做成你自己的试验台,比单纯跑十个教程都更能建立那种“我真正掌控了模型”的感觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询