拿到一组横纵坐标,想补中间值,或者想从一堆乱糟糟的点里找趋势,到底该用插值还是曲线拟合?这个问题几乎每个做数据分析、数值计算的人都纠结过,也是我这套系列文章里容易被问到的点。今天这篇就专门把“插值”和“曲线拟合”放在一起拆开讲,包括手工可算的拉格朗日插值、基于最小二乘和梯度下降的拟合过程,以及我在实际项目里踩过的一些坑。适合刚入门数值分析的同学,也适合那些已经在用代码做拟合但说不出所以然的开发者。
先说结论:插值解决的是“必须经过所有已知点”的问题,拟合解决的是“误差尽量小、但不一定经过任何已知点”的问题。很多人在这两者之间反复横跳,是因为没想明白自己的数据到底从哪来、要拿曲线干什么。这篇文章会沿着判据、原理、实操、避坑四条线走,希望能让你下次看到数据时,第一眼就知道该上插值还是拟合。
1. 先分清插值和拟合,别一头扎进公式里
1.1 插值是“精确穿过每一个样本点”的确定性恢复
插值的中文说全一点是“插值法”,英文叫 interpolation。它的使用场景很经典:手里只有一张稀疏的数据表,中间位置也有值,但表里没写。比如实验测了室温随时间的五个采样点,现在想知道第 3.5 分钟是多少度,这就是插值问题。
插值的基本假设是:这些数据点高度可信,并且点之间确实存在某个连续函数。于是我们构造一个函数,让它严格通过所有已知点。最常见的是多项式插值,n+1 个点可以唯一确定一个不超过 n 次的多项式。因为次数可控、计算也成熟,所以教科书几乎都从多项式插值讲起。
但插值有个“隐含代价”:它是确定性恢复,不是统计推断。一旦某个点带有测量误差,插值就会把这个误差当成真实规律保留下来,相邻区间会受到感染。所以插值更适合表格化数据、标定数据、几何建模这类“已知点本身可信”的场景,而不是传感器采集后直接扔过来的原始数据。
1.2 拟合的目标是最小化误差,不是通过所有点
曲线拟合,英文叫 curve fitting。它关注的不是“过不过点”,而是“能不能代表整体规律”。数据通常来源于测量,本身就携带噪声;如果强行让一条高次多项式穿过每个带噪声的点,得到的往往是一条剧烈扭动的曲线,工程上完全没有预测价值。
拟合的做法是:从某个模型族中选一个函数,让它与所有样本点的整体误差最小。最常见指标是均方误差,也就是传说中的“最小二乘”。比如用一条直线 y = wx + b 去描述一百个点,求一组 w 和 b,让所有点到直线的垂直距离平方之和最小。
拟合的结果是模型加参数的组合。模型可以是一条直线,也可以是一条带有二次项、三次项的曲线,甚至可以是一个神经网络。不管模型多复杂,核心没变:误差最小化。这也决定了后续所有的更新算法,包括正规方程、梯度下降,全部围绕着“最小化损失”展开。
1.3 选型判断:看这三个关键词
实操里怎么选,我通常看三个关键词:噪声、点数、外推需求。
如果已知点非常少、精度也非常高,中间值必须严格落在某条连续规律上,插值是首选。比如机械加工的标准曲线,数据点是从图纸上精确读出来的,插值出来再顺着取值,完全没问题。
如果数据量多、来源是测量或者统计,中间有抖动,拟合就更合适。比如商品价格趋势、传感器校准曲线、用户增长曲线,谁也无法保证每个点都精确,我们要的是“趋势”。
第三个关键词是外推。所谓外推,就是在已知范围之外预测值。插值从数学定义上就禁止外推,超出采样区间,多项式会以你想不到的速度发散。拟合虽然能外推,但也只能依据模型假设,出了样本范围同样不可靠。所以无论选哪个,都要对“采样区间之外”保持足够的谨慎。
2. 拉格朗日插值:原理、手算与容易踩的坑
2.1 为什么用拉格朗日而不是解线性方程组
每个学过高等数学的人都知道,多项式插值可以列线性方程组去解系数。比如用三个点求二次多项式 ax² + bx + c,把三个点代进去,得到包含 a、b、c 的三个方程,解出来就行。这个方法抽象上没问题,但实际写代码时,矩阵条件数差、求解不稳定,尤其点数一多,手算几乎不可能。
拉格朗日插值就是绕开方程组的一种显式构造法。它的思路很聪明:为每个点构造一个“只在这一点上取 1,在其它所有点上取 0”的基函数,然后把所有基函数加权求和。权重就是对应点的纵坐标。
基函数的公式长这样:
L_i(x) = ∏_{j≠i} (x - x_j) / (x_i - x_j)
不要怕这个连乘符号。它实际就是在算:x 到其它所有点的距离的乘积,除以目标点到其它所有点的距离的乘积。分子保证当 x 取到任意一个其它点 x_j 时,连乘积中有 0 项,整个 L_i 变为 0;分母保证在 x_i 处,整体恰好等于 1。
最后插值多项式就是:
p(x) = Σ y_i · L_i(x)
这种构造的好处是逻辑非常直观,代码实现也简单:只需两层循环,第一层选哪个点作为“当前重点”,第二层做连乘。不需要解矩阵,也不容易出现大规模线性方程组的数值问题。
2.2 一个能自己算完的例子:三点插值
我讲个可以完全手算的经典例子,数据点取 (0, 2)、(1, 3)、(2, 6)。
先构造三个基函数。
第一个点的基函数需要满足在 x=0 时等于 1,在 x=1 和 x=2 时等于 0。于是:
L_0(x) = ((x-1)(x-2)) / ((0-1)(0-2)) = ((x-1)(x-2)) / 2
第二个点的基函数是:
L_1(x) = ((x-0)(x-2)) / ((1-0)(1-2)) = -x(x-2)
第三个点的基函数是:
L_2(x) = ((x-0)(x-1)) / ((2-0)(2-1)) = x(x-1)/2
把它们组合起来:
p(x) = 2L_0(x) + 3L_1(x) + 6L_2(x)
展开合并同类项:
p(x) = (x² - 3x + 2) + (-3x² + 6x) + (3x² - 3x)
整理后得到:
p(x) = x² + 2
你可以验证一下:x=0 时等于 2,x=1 时等于 3,x=2 时等于 6,完全吻合。这个例子说明,拉格朗日公式虽然看起来是一个一个分式项,但合并后会变成非常普通的多项式,后续取值只需计算这个多项式即可,不需要再动那些分式。
这里有一个我特别想强调的点:拉格朗日插值的结果是唯一的,不会因为构造方式不同而改变。只要数据点相同,高斯插值、牛顿插值、拉格朗日插值,最终得到的是同一个多项式,只是表达式形式不同。这意味着你用任何方法手算,最后都应该归到同一个答案。如果结果对不上,要不就是点代错,要不就是函数写错。
2.3 高阶插值不是越多越好:Runge 现象
我见过不少刚接触数值分析的人,听到“多项式次数越高越精确”,于是拿着几十个数据点去构造高次多项式。结果是中间或许正常,可两端像发了疯一样上下乱跳,越接近边界振荡越离谱。
这个现象在数值分析里叫 Runge 现象,最典型的例子是函数 f(x) = 1/(1+25x²),在区间 [-1, 1] 上用等距节点做高次插值。节点数越多、次数越高,插值结果在边界附近不但不收敛,反而剧烈振荡。原因不复杂:多项式虽然光滑,但本质上是“全局函数”,任何一个数据点的变化都会影响整条曲线的形状,而等距节点在两端的信息不足,边界误差就一路飙升。
所以我对所有新人的建议都是:能不用高次多项式插值就不用。如果只是要补中间值,优先考虑分段插值或三次样条。拉格朗日插值适合用来理解插值思想,适合在点很少、点数不超过十个左右时手算或在程序里做一个快速实现,但玻璃后面要同时装着数据表:一旦点数变多,就改用其它插值方案。
2.4 更稳的替代方案:分段插值和三次样条
日常项目里,我更常用分段线性插值和三次样条插值。
分段线性插值最朴实,每两个相邻点之间用一条直线连接。它的优点是不会振荡、计算量小、代码一行就能写完;缺点是整体曲线不光滑,在已知点的位置会出现折角。如果对光滑性要求不高,这足够了。
三次样条插值则更精细,在每个区间使用一个三次多项式,同时保证节点处数值、一阶导数、二阶导数连续。最终得到一条光滑、连续、局部可控的曲线。它不会像全局高次多项式那样一点变动拖累全身,也不会像分段直线那样留一堆棱角。实际工程里,如果你确定要做插值,我建议默认先选三次样条,不建议直接上手一个 n 次多项式。
3. 曲线拟合:从最小二乘到梯度下降拟合
3.1 为什么损失函数用平方误差
拟合的第一步是定义一个损失函数。把平方误差和作为优化目标,数学上写为:
J = (1/m) Σ (y_i - f(x_i, θ))²
其中 f(x_i, θ) 是模型在 x_i 处的预测值,θ 是模型参数。
为什么用平方而不是绝对值?因为平方误差处处可导,能直接套梯度下降等工具;而且它对大误差的惩罚比小误差更重,这符合“尽量压低明显偏离样本的预测”这一直觉。当然,平方误差对离群点敏感,如果数据里混了几个严重异常的点,它们会主导整个拟合过程。这是代价,不是 bug。数据清洗不干净就做最小二乘,结果很容易被离群点带走。
3.2 正规方程:小规模线性拟合最快的解法
如果模型是关于参数线性的,最小二乘损失是二次函数,有全局唯一最优解。这种情况下可以不解,也可以用解析解。以直线拟合为例,设模型 y = wx + b,要最小化:
J = (1/m) Σ (y_i - wx_i - b)²
最优参数为:
w = (m·Σx_iy_i - Σx_i·Σy_i) / (m·Σx_i² - (Σx_i)²)
b = (Σy_i - w·Σx_i) / m
我第一次看这个公式觉得记不住,后来发现它和“协方差比方差”是同一个意思。分子是 x 和 y 的协方差,分母是 x 的方差,w 就是两个变量之间的线性关联强度。这样理解之后,再也不需要死背。
用一组数据来试:已知点为 (1, 2.3)、(2, 1.8)、(3, 3.5)、(4, 4.2)。先算出几个关键求和:
Σx = 10,Σy = 11.8,Σxy = 33.2,Σx² = 30,m = 4。
代入公式:
w = (4×33.2 - 10×11.8) / (4×30 - 10²) = (132.8 - 118) / (120 - 100) = 14.8 / 20 = 0.74
b = (11.8 - 0.74×10) / 4 = (11.8 - 7.4) / 4 = 1.1
所以最优拟合直线是:
y = 0.74x + 1.1
验证一下:x=1 时预测 1.84,实际 2.3,误差 0.46;x=4 时预测 4.06,实际 4.2,误差只有 0.14。这条直线诚实地反映了整体线性趋势,但并没有强制经过任何已知点。
3.3 梯度下降拟合:从零开始更新参数
正规方程虽然快,但数据特征一多、样本量一上来,求矩阵逆的成本和稳定性都会出问题。这时候迭代式优化就成了主要手段,其中最基础也最通用的是梯度下降拟合。
目标还是最小化损失 J。对直线模型求偏导:
∂J/∂w = -(2/m) Σ x_i(y_i - wx_i - b)
∂J/∂b = -(2/m) Σ (y_i - wx_i - b)
梯度告诉了我们参数该往哪个方向调整。梯度是负向误差增大的方向,所以更新时减去梯度:
w = w - α·∂J/∂w
b = b - α·∂J/∂b
α 是学习率,控制每一步迈多大。
还是用刚才那组数据验证。初始设 w = 0,b = 0,学习率 α = 0.01。第一轮迭代:
∂J/∂w = -(2/4)×33.2 = -16.6
∂J/∂b = -(2/4)×11.8 = -5.9
更新后:
w = 0 - 0.01×(-16.6) = 0.166
b = 0 - 0.01×(-5.9) = 0.059
一步之后,预测值是 0.166x + 0.059。初始损失大约是 9.605,一步之后损失降到约 6.76,明显下降。继续迭代,参数会慢慢逼近正规方程得到的 0.74 和 1.1。
如果你习惯写代码,一个最基本的梯度下降拟合逻辑是这样的:
def fit_gradient_descent(x, y, lr=0.01, epochs=200): w = 0.0 b = 0.0 n = len(x) for epoch in range(epochs): pred = [w * xi + b for xi in x] dw = -2.0 / n * sum(xi * (yi - pred_i) for xi, yi, pred_i in zip(x, y, pred)) db = -2.0 / n * sum(yi - pred_i for yi, pred_i in zip(y, pred)) w -= lr * dw b -= lr * db if epoch % 40 == 0: loss = sum((yi - pred_i) ** 2 for yi, pred_i in zip(y, pred)) / n print(epoch, "w =", w, "b =", b, "loss =", loss) return w, b这个代码里没有用到任何第三方库,纯粹展示梯度下降的更新过程。实际项目中你可以用 numpy 向量化实现,但迭代原理是完全一样的。
3.4 什么时候必须用梯度下降,而不是解公式
有人会问:既然直线拟合有解析解,为什么还要学梯度下降?
这个问题我年轻时候也想不通。直到后来开始做稍微复杂的模型才明白:只要模型不是参数线性的,比如拟合一个带指数项、对数项甚至神经网络激活函数的模型,偏导方程解不出一个闭合公式,正规方程根本没法写。这时唯一普适的路就是梯度下降。
另外,就算模型是线性可解析的,特征维度高到几十万甚至百万级别,构造和求逆一个巨大的矩阵也极不划算。迭代式优化可以配合随机样本在线更新,每轮只要一小批数据,内存压力小,训练进度能看得见。
梯度下降最大的变量是学习率 α。太大会让参数来回弹跳,损失不降反升;太小则收敛慢到让人失去耐心。我的习惯是先设 0.01,看前几十轮损失是不是稳步下降。如果抖得厉害,明显发散,就把学习率降到 0.001;如果下降太慢,再适当调大。数据量级差异大的时候,先把 x 归一化到差不多的范围,可以避免梯度更新被某一维特征带偏。
4. 同一份数据,两种方式会如何
4.1 带噪声的两点对比
假设你有一组真实数据,围绕二次曲线 y = 0.8x² + 0.3x + 2 产生,每个点叠加了一点随机噪声。用插值做,会得到一条必须经过每个噪声点的光滑曲线;用二次多项式拟合,会得到一条贴近真实曲线的平滑抛物线。前者完全尊重数据,却把噪声当成了信号;后者不尊重单个点,却逼近了真实规律。
我做实验时经常拿这种对照来提醒自己和同事:如果项目目标是从数据里提炼规律,插值就是在画蛇添足。反之,如果项目目标是补全一张精确的密钥表,那个值需要按标准精确复现,你却用拟合把标称值噪声化,那是自找麻烦。
4.2 拟合效果评估:残差与 R²
拟合不等于“算完参数就收工”。算完直线或多项式后,停一下,画残差图。残差是每个点的实际值减去预测值。一个合格的拟合,残差应该围绕 0 随机分布,看不出一眼可识别的规律。
还可以用决定系数 R² 来量化拟合质量:
R² = 1 - Σ(y_i - ŷ_i)² / Σ(y_i - ȳ)²
其中 ȳ 是样本均值。R² 等于 1,说明预测完美;等于 0,说明模型和“直接用均值预测”差不多。但 R² 高并不能说明模型正确。数据本来非线性,你硬塞直线,R² 可能很低;数据恰好形态对称,直线拟合的 R² 也可能不错,可残差图明显弯曲。所以我在项目里始终把残差图放在 R² 前面,数值是辅助,图形才是证据。
4.3 一个容易被忽略的边界:不要外推
无论是插值还是拟合,都要小心“边界之外”。插值多项式在采样区间之外会大幅振荡,这不是程序 bug,而是多项式本身的特性。拟合模型即使内部误差很小,到了数据范围之外也只能纯粹依赖模型假设,假设一变,预测全错。
有次我看到一个同学用三次多项式对某变量做预测,数据范围是 1 到 10,他直接预测 x=50 时的值,得到的结果大得离谱,还以为是公式写错了。其实公式没问题,问题在于他把“采样区间内部的拟合能力”误解成了“任意范围都成立”。这一点值得单独写进任何一份数据工作规范里:外推可以探讨趋势,但绝不能当确定结论用。
5. 常见问题与避坑清单
5.1 这个表格我建议你存在项目文档里
我把自己这些年总结的高频问题整理成了一张速查表,每次建模前翻一眼,能省不少试错时间。
| 现象 | 常见原因 | 优先处理方式 |
|---|---|---|
| 插值曲线在两端剧烈振荡 | 使用高阶多项式插值,出现 Runge 现象 | 改用分段插值或三次样条 |
| 插值曲线形状扭曲,毫无物理意义 | 数据噪声较大,被插值保留了 | 放弃插值,改做曲线拟合 |
| 梯度下降误差越来越大 | 学习率太大,参数在极值附近震荡 | 降低学习率,或对特征做归一化 |
| 梯度下降收敛极慢 | 学习率太小或数据尺度差异大 | 适当调大学习率,先标准化再训练 |
| 拟合残差图有明显弯曲 | 模型形式太简单,遗漏非线性项 | 增加二次多项式、对数项或交叉项 |
| R² 很高但预测值严重偏离实际 | 数据范围外外推,或模型过拟合 | 禁止外推,用交叉验证检查模型复杂度 |
注意,这里我在最后一项写了“模型过拟合”。过拟合和“R² 高”经常同时出现。多项式次数越高,越容易把训练样本磨得很完美,但换一份新数据就露馅。所以做多项式拟合时,先试二次或三次,再往上升,千万不要一上来就用十次多项式。
5.2 我在实操中的一套固定流程
拿到拟合任务,我基本按照这个流程走:
- 先画散点图,看大致形态。是直线、曲线还是指数型,直接决定模型族。
- 用低阶多项式开始拟合,低阶不是“简单”,是“可解释”。先看低阶残差图。
- 如果残差存在明显弯曲,增加模型复杂度,比如从一次升到二次;每升一阶都检查 R² 和残差图。
- 用交叉验证或留出验证集,确认不是过拟合。
- 最后一步才是输出结论,并明确标注预测范围。
这套流程看起来非常简单,但我在无数项目里靠它躲掉了大坑。尤其是第三步,很多人会直接选一个高次多项式让训练集 R² 逼近 1,结果验证集惨不忍睹。低阶模型加上残差诊断,反而能看见数据最需要的形态。
5.3 关于拉格朗日插值代码实现,我多说一句
拉格朗日插值实现起来不像拟合那样有现成梯度,但代码逻辑并不复杂。写的时候核心是两层循环:
def lagrange_poly(x_data, y_data, x): total = 0.0 for i in range(len(x_data)): term = y_data[i] for j in range(len(x_data)): if i == j: continue term *= (x - x_data[j]) / (x_data[i] - x_data[j]) total += term return total这段代码直接对应公式,没有额外优化,适合小规模数据。我在教学和快速验证时经常写它,帮助理解公式非常有效。真到工程里,点一多我就会换样条插值,所以我上面专门强调过“应用前先确认点数”。
写到这里,我脑子里转了一圈这些年处理数据的场景。一个小习惯我很想分享:无论你是要补中间值,还是从噪声里提炼趋势,先把“数据点到底可不可信”这个问题搞清楚。标定表、标准曲线、几何离散点,插值就够了;测量值、采集值、带有波动的业务数据,拟合更合适。拉格朗日让我理解插值的本质,梯度下降让我理解拟合的本质,而两者之间的选择,最终取决于数据本身,而不是某个公式更好看。