ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言实现polyfit:最小二乘多项式拟合完整代码与避坑指南

C语言实现polyfit:最小二乘多项式拟合完整代码与避坑指南 简介多项式拟合是数值分析与数据处理中的常用技术这份C语言实现面向Linux环境下需要做曲线拟合、趋势预测的开发者与算法学习者。压缩包内共2个文件均为C源码整体仅9KB结构紧凑无需冗余配置即可阅读核心实现。文件分别承担多项式拟合主算法与驱动测试功能完整演示了基于GSL数值库的最小二乘拟合流程读取数据点、构造Vandermonde矩阵、求解正规方程得到多项式系数并可通过测试程序输出拟合结果与原始数据对比。已有665人学习下载适合有一定C语言基础、希望快速掌握或迁移多项式拟合算法到自有项目的读者。 第一次写 polyfit 是在一个温度传感器标定项目里。上位机用 Python 调 numpy.polyfit 三行搞定但下位机是 Cortex-M0 平台跑不了 Python只能老老实实用 C 把多项式拟合功能实现出来。后来这个函数我反复复用了很多次从激光测距模块的线性校正到电池 SOC 曲线的二阶拟合都用过它。C 语言里没有现成的 polyfit网上能找到的多半是 C 或 Python 版本纯 C 的完整实现少之又少所以我决定把实现思路和完整代码整理出来给同样需要在 C 环境下做曲线拟合的朋友一份能直接抄作业的参考。这篇内容适合嵌入式开发、需要离线数据处理或正在写相关课程作业的读者。整篇会按数学原理—C 实现—验证方法—避坑经验的顺序展开我不会堆太多公式尽量用工程语言把最小二乘拟合讲明白。1. 为什么我要在C里手写polyfit1.1 polyfit到底在干什么polyfit 这个名字最早来自 MATLAB后来 NumPy 也沿用了同样的命名。它的作用很简单给出一组二维点 (x, y)再指定多项式阶数返回一组多项式系数使多项式曲线尽量贴近这些点。一阶多项式就是直线拟合二阶就是抛物线拟合阶数越高曲线表达复杂趋势的能力越强。注意尽量这个词。真实数据点数量一定比系数个数多系统是过约束的几乎没有一组系数能同时穿过所有点。除非数据本身来自某个精确的多项式否则总会存在残差。所以我们需要一个量化贴近程度的标准而最小二乘就是最常用的标准。1.2 什么情况下你会在C里需要它我做过的项目里碰到 polyfit 需求的主要有三类。第一类是嵌入式传感器标定。传感器输出的是电压或者原始码值物理量和码值往往不是严格的线性关系需要在板端直接用拟合公式做换算。这时候 C 函数不需要依赖任何第三方库直接编进固件就能跑。第二类是采集系统没有 Python 环境。有些工控机上只部署了采集软件不方便装 NumPy或者出于稳定性的考虑不想引入一堆动态库。用 C 写一个函数编译进主程序是最稳妥的方案。第三类是课程设计或竞赛题目。很多题目明确要求用 C 实现最小二乘多项式拟合学生交一份能通过编译、结果正确的代码就很重要。我建议刚起步的人不要只满足于代码跑通。把拟合得到的曲线和原始数据一起打印出来用眼睛先看一遍贴合程度再谈误差指标。这个习惯会帮你省掉后面很多排查问题的时间。2. 最小二乘和正规方程半小时就能吃透的数学基础2.1 最小二乘想法的由来假设我们有 n 个数据点 (xᵢ, yᵢ)想找一条多项式曲线来代表整体趋势。多项式拟合把曲线限定为y c₀ c₁x c₂x² ... cₘxᵈᵉᵍ目标是让每个点残差的平方和最小S Σ(yᵢ - ŷᵢ)²为什么要用平方而不是绝对值两个原因。第一平方对偏差大的点惩罚更重拟合结果不容易被个别远点带偏第二平方和作为系数的函数是可导的可以直接用导数为零得到解析解绝对值处理起来非常麻烦。2.2 正规方程是怎么来的把所有数据点写成矩阵形式。设设计矩阵 X 的第 i 行为 [1, xᵢ, xᵢ², ..., xᵢᵈᵉᵍ]系数向量 c [c₀, c₁, ..., c_deg]ᵀ目标向量 y [y₁, ..., yₙ]ᵀ那么残差平方和就是S ||Xc - y||²对 c 求导并令导数为零就得到正规方程Xᵀ X c Xᵀ y这个方程组的维度是 (deg1) × (deg1)和数据点数量 n 无关。也就是说哪怕有几万个数据点最终要解的线性方程组还是很小计算量非常可控。这是多项式拟合最大的优势之一。2.3 为什么用高斯消元而不是求逆很多教程会在这里引入矩阵求逆写成 c (XᵀX)⁻¹Xᵀy。但在 C 里我不建议你去实现逆矩阵。一是求逆本身要经过高斯-约当消元运算量比单纯解方程组多出约一半二是中间步骤多额外引入的舍入误差也更多。直接用列主元高斯消元解 XᵀX c Xᵀy又快又稳还能顺带判断矩阵是否接近奇异。对入门和绝大多数工程场景正规方程加高斯消元是性价比最高的组合。只有到了高次拟合、矩阵严重病态的场合才需要考虑换成 QR 分解或者 SVD。3. 完整C实现代码逐段拆解完整可编译3.1 函数接口怎么设计我把函数设计成这样int polyfit(const double *x, const double *y, int n, int deg, double *c)返回 int 而不是 void是因为这个函数有好几种失败情况阶数超过上限、数据点数不够、矩阵数值奇异。返回状态码比让调用方去猜结果靠谱得多。c 指向的数组需要调用方提前分配长度至少为 deg1这个约定一定要在注释里写清楚。矩阵大小用 MAX_M 限制默认 16意味着最高支持 15 阶拟合。如果你的场景需要更高阶把宏改大一点即可但后面我会讲到阶数过高时数值稳定性会很差并不建议盲目提高这个上限。3.2 正规方程构造的循环怎么看下面是可以直接编译运行的完整实现#include stdio.h #include math.h #define MAX_M 16 /* * 功能: 多项式拟合 (最小二乘) * x, y : 输入数据点 * n : 数据点个数 * deg : 多项式阶数 * c : 输出系数, 长度 deg1, 由调用方分配 * 返回值: 0 成功, -1 参数错误, -2 矩阵奇异 */ int polyfit(const double *x, const double *y, int n, int deg, double *c) { int m deg 1; double a[MAX_M][MAX_M]; double b[MAX_M]; int i, j, k; if (deg MAX_M || n m || x NULL || y NULL || c NULL) return -1; /* 构造正规方程: a X^T * X, b X^T * y */ for (i 0; i m; i) { for (j 0; j m; j) { double s 0.0; for (k 0; k n; k) s pow(x[k], i j); a[i][j] s; } double s 0.0; for (k 0; k n; k) s y[k] * pow(x[k], i); b[i] s; } /* 列主元高斯消元 */ for (i 0; i m - 1; i) { int pivot i; for (j i 1; j m; j) { if (fabs(a[j][i]) fabs(a[pivot][i])) pivot j; } if (pivot ! i) { for (j i; j m; j) { double t a[i][j]; a[i][j] a[pivot][j]; a[pivot][j] t; } double t b[i]; b[i] b[pivot]; b[pivot] t; } if (fabs(a[i][i]) 1e-15) return -2; for (j i 1; j m; j) { double f a[j][i] / a[i][i]; for (k i; k m; k) a[j][k] - f * a[i][k]; b[j] - f * b[i]; } } /* 回代求解 */ for (i m - 1; i 0; i--) { double s b[i]; for (j i 1; j m; j) s - a[i][j] * c[j]; c[i] s / a[i][i]; } return 0; }3.3 列主元高斯消元的细节很多初学者会直接写朴素高斯消元也就是不做选主元一路向下消。这样在数据理想时没问题但工程数据经常不那么理想。当 x 的取值较大或者数据分布较差时矩阵对角线元素可能很小直接消元会出现除零或者放大舍入误差的问题。列主元的思路很简单消元到第 i 列时先在第 i 行往下所有行里找到绝对值最大的元素所在行把它换到第 i 行再继续消元。这样能保证每次除法的分母是当前列的最大值误差控制效果立刻提升一个档次。代码里主元小于 1e-15 时直接返回 -2这是为了防止矩阵奇异时产生无意义的结果。实际使用中如果你发现返回了 -2不要急着改阈值先检查数据本身是不是存在问题比如所有 x 都相同、或者阶数过高导致矩阵病态。4. 用几组现成数据验证拟合效果4.1 线性拟合肉眼验证先用一组接近直线但带一点噪声的数据测试。假设 x 取 1 到 8y 大致等于 2x叠加少量随机误差int main(void) { double x[] {1, 2, 3, 4, 5, 6, 7, 8}; double y[] {2.1, 4.2, 6.3, 7.9, 10.1, 12.2, 14.3, 15.8}; double c[2]; int i, ret; ret polyfit(x, y, 8, 1, c); if (ret ! 0) { printf(polyfit error: %d\n, ret); return 1; } printf(拟合直线: y %.6f %.6f * x\n, c[0], c[1]); for (i 0; i 8; i) { double fit c[0] c[1] * x[i]; printf(x%d, y%.2f, fit%.3f, residual%.3f\n, (int)x[i], y[i], fit, y[i] - fit); } return 0; }编译时记得链接数学库gcc polyfit_demo.c -o polyfit_demo -lm预期结果应该是 c[1] 接近 2c[0] 接近 0残差在正负 0.2 左右均匀分布。如果残差出现明显的系统性偏移比如前半段全是正、后半段全是负说明阶数选低了数据结构里还有未被提取的趋势。4.2 二阶拟合非线性场景再看抛物线趋势。把 y 换成接近二次函数的值比如 y ≈ 0.5x² 1.5x 0.8同样加一点噪声deg 设成 2c 数组长度设为 3。运行后应得到三项系数分别接近 0.5、1.5、0.8。二阶以上拟合时记得检查最后一个系数。如果最高阶系数非常接近 0说明数据实际更接近低阶曲线这时候你可以降低阶数重新拟合用更简单的模型表达同一组数据。4.3 用R²判断拟合质量只看系数还不够工程上要有一个量化指标判断拟合得好不好。决定系数 R² 是常用的指标SS_res Σ(yᵢ - ŷᵢ)²SS_tot Σ(yᵢ - ȳ)²R² 1 - SS_res / SS_totR² 越接近 1说明回归模型解释了绝大部分数据的方差接近 0 甚至为负说明模型还不如直接用平均值预测。我建议把这段残差统计逻辑封装成一个单独函数在调用 polyfit 之后顺手算一下double r_squared(const double *y, const double *fit, int n) { double ss_res 0.0, ss_tot 0.0, mean 0.0; int i; for (i 0; i n; i) mean y[i]; mean / n; for (i 0; i n; i) { ss_res (y[i] - fit[i]) * (y[i] - fit[i]); ss_tot (y[i] - mean) * (y[i] - mean); } return 1.0 - ss_res / ss_tot; }搞定了系数和 R²你的拟合模块才算是完整的。5. 实测最容易踩的坑和避坑技巧5.1 阶数不是越高越好阶数过高时拟合曲线会拼命穿过每一个数据点把噪声也当成有效信号拟合进去。典型表现是多项式曲线在数据区间波浪形剧烈抖动这就是所谓的过拟合。工程上我一般最多用到 3 到 5 阶很少超过 6 阶。如果一个问题需要 7 阶以上多项式才能描述你应该重新审视一下数据模型而不是继续加阶数。另外正规方程在阶数较高时数值稳定性下降明显矩阵元素间的数量级差距会非常大即使算法正确double 的精度也可能不够用。我在实测中超过 8 阶之后就经常遇到系数完全不可信的情况。5.2 数据x过大时的数值稳定性处理这个坑我踩得最深。如果 x 的量级在 1000 附近二阶项的 x² 就是 10⁶四阶项是 10¹²正规方程矩阵里的元素从 1 跨度到 10¹²double 的有效数字也就 15 到 16 位消元过程中这些相差巨大的数字一相减有效位数迅速丢失。一个简单可靠的办法是先把 x 做中心化和归一化x (x - mean) / range用 x 拟合出系数 c再换回原变量计算。这里要注意换算公式不是简单地把 x 代入而是把 x mean range * x 代回多项式。我建议把中心化逻辑封装在调用层保持 polyfit 函数本身只负责纯数学计算职责更清晰。5.3 从polyfit到完整回归工具还可以做的改进基础版功能完整但如果你要把它用在生产环境有几个方向可以继续完善。加权最小二乘可以处理各点信任度不同的场景比如某些测量点来自精度更高的仪器权重就应该更大。固定系数拟合可以强制曲线过原点适用于很多物理模型。异常点剔除可以先跑一次拟合剔除残差超过 3 倍标准差的点再重新拟合工程效果非常好。动态内存版本可以将 MAX_M 限制去掉换用 malloc 分配矩阵适合阶数不确定的场景。我个人最推荐先扩展异常点剔除。实测中一次简单的最小二乘拟合往往会被个别粗大误差点拉偏加一遍剔除循环之后结果肉眼可见地稳定下来。最后再分享一个心得我后来把 polyfit 加上中心化换算和 R² 统计封装成了一个工具包新项目直接整体搬过去用。回看整个实现真正值钱的不是那几十行矩阵代码而是对数值稳定性和拟合质量的判断经验。第一次写的话建议先用线性数据跑通再跑二次把每一步的中间矩阵打印出来和手算结果对比一下比看十遍公式都管用。本文还有配套的精品资源点击获取
返回列表