☰
C#直线拟合实战:最小二乘、数值稳定与边界处理
2026/10/26 18:50:51 网站建设 项目流程

简介:这份面向C#开发者与数据分析初学者的线性拟合示例包,围绕最小二乘法求解直线方程展开,帮助读者理解斜率、截距的计算过程,并在.NET项目中复现与Excel相近的拟合结果。压缩包共45个文件,以14个cs源码、3个exe可执行程序、3个dll程序集、3个config配置、2个csproj项目文件及2个resx资源等为主,整体约87KB,兼顾可运行示例与工程结构参考。目前已有413人学习下载,适合需要快速上手线性回归实现、对照验证公式或补齐数据处理技能的读者。代码中可看到数据点均值、偏差、斜率与截距的分步求解,也可延伸到多项式、指数等曲线拟合思路;配套工程还涉及数据读取、结果展示与异常处理等环节,便于将拟合能力集成到实际数据处理程序中,并作为理解预测未知数据方法的入门案例。

1. 一个只有名字的压缩包:C# 拟合直线到底在算什么

《线性拟合公式求解.zip》解压后干的事很朴素——把"给一堆采样点,求出 y = kx + b"这件事,从推导到代码到结果判定走完整条链路。多数人觉得直线方程没什么好讲的,中学就写过;真把它放进 C# 项目里跑一遍,问题全冒出来了:斜率跟电子表格软件对不上、x 取值大一点结果直接飘到天上去、点全是竖着排的时候算出 NaN、两个点拟合出 R²=1 还自以为拟合完美。这些都不是公式错,是数值实现和边界处理没做。

C# 拟合直线在工控、传感器标定、报表趋势线这类场景里出现频率极高,特点是数据量不大(几十到几千点)、但要求随时能算、结果要能解释。这份资源适合两类人:手上有一批采样点,需要在线算出斜率截距并判断拟合好坏的一线开发者;以及刚看完最小二乘推导、想找一个能直接跑的参考实现的人。下面按"公式怎么摊开—代码怎么写—数值怎么稳—坑在哪"的顺序拆,参数和边界都会给到能抄的程度。

2. 最小二乘的四个求和项:先把公式摊开再写代码

2.1 残差平方和为什么是唯一好解的形式

给定 n 个点 (xᵢ, yᵢ),直线 ŷ = kx + b 在每点的残差是 rᵢ = yᵢ - (kxᵢ + b)。要"拟合得好",直觉上是让所有残差尽量小,但"小"有三种度量:残差绝对值之和、残差最大绝对值、残差平方和。前两种在数学上不好处理——绝对值函数在 0 点不可导,最小值可能出现在某个折点上,没有闭式解,只能迭代;最大绝对值更极端,一点点噪声就能把整条线拽偏。

平方和的好处是可导、凸、有唯一极小值,对 k 和 b 分别求偏导令其为零,两个方程两个未知数,直接解出来。这就是最小二乘这个名字的来历,也是它能被写成几行 C# 的原因。代价是它对离群点极其敏感:一个偏差 10 的点,在平方和里贡献 100,等于 100 个偏差 1 的点。这个特性在后面讲避坑时会反复出现,现在先记住——最小二乘优化的不是"看起来最合理",是"平方误差最小"。

偏导推下来会得到两个方程:Σy = kΣx + nb 和 Σxy = kΣx² + bΣx。这两个式子用四个累加量就能凑齐:Σx、Σy、Σx²、Σxy。也就是说,一次遍历把四个和攒出来,剩下的全是加减乘除。

2.2 四个累加量的闭式解与中心化等价形式

直接解上面那个二元一次方程组,得到最常见的那组公式:

k = (n·Σxy − Σx·Σy) / (n·Σx² − (Σx)²) b = (Σy − k·Σx) / n

分母 n·Σx² − (Σx)² 有个恒等变形:它正好等于 n·Σ(xᵢ − x̄)²,其中 x̄ 是 x 的均值。同理分子 n·Σxy − Σx·Σy = n·Σ(xᵢ − x̄)(yᵢ − ȳ)。于是有第二种写法,通常叫中心化形式或偏差形式:

Sxx = Σ(xᵢ − x̄)² Sxy = Σ(xᵢ − x̄)(yᵢ − ȳ) Syy = Σ(yᵢ − ȳ)² k = Sxy / Sxx b = ȳ − k·x̄

两套公式在实数域上完全等价,在浮点数域上差别巨大。原因在于第一套公式里有 n·Σx² 和 (Σx)² 两个量级相近的大数相减,x 的绝对值一大,相减的有效位就没了。第二套只对偏差做累加,偏差本身量级远小于原始值,精度保得住。我一般直接用第二套,第一套只在给别人讲推导时写一遍。

顺带把决定系数也算出来:R² = Sxy² / (Sxx·Syy)。注意这个式子和教科书上的 R² = 1 − SSE/SST 等价(针对带截距的一元线性回归),但少算一遍残差,代码更短。Syy 就是总平方和 SST,Sxy²/Sxx 就是回归平方和 SSR。

符号含义代码里的角色
n有效点数至少为 2,否则无解
Sxxx 的偏差平方和分母,为 0 说明 x 全相同
Sxy偏差交叉积斜率的分子
Syyy 的偏差平方和计算 R² 用
k斜率灵敏度 / 增益
b截距零点偏移
R²决定系数拟合优度,0~1

2.3 手算一小组数据,给代码留一个固定参照

不先手算一遍就对代码,是后面所有"结果差一点点"类问题的根源。找五个点,故意让它们不完全共线,但整体贴近一条直线:

ixy
112.1
223.9
336.2
447.8
5510.1

n = 5,Σx = 15,Σy = 30.1,Σx² = 55,Σxy = 1×2.1 + 2×3.9 + 3×6.2 + 4×7.8 + 5×10.1 = 110.2。代入第一套公式:k = (5×110.2 − 15×30.1) / (5×55 − 225) = (551 − 451.5) / 50 = 1.99;b = (30.1 − 1.99×15) / 5 = 0.05。所以直线是 y = 1.99x + 0.05。

再算 R² 做参照:x̄ = 3,ȳ = 6.02,Sxx = 4 + 1 + 0 + 1 + 4 = 10,Sxy = (-2)(-3.92) + (-1)(-2.12) + 0 + 1×(1.78) + 2×(4.08) = 7.84 + 2.12 + 0 + 1.78 + 8.16 = 19.9,Syy = 15.3664 + 4.4944 + 0.0324 + 3.1684 + 16.6464 = 39.708。R² = 19.9² / (10 × 39.708) = 396.01 / 397.08 ≈ 0.9973。

这一组数就是后面所有代码的验收标准:斜率 1.99±1e-12、截距 0.05±1e-12、R² 0.997305±1e-9。写不出这个结果,代码就是错的,别急着怀疑数据。

3. C# 实现三条路:双循环、法方程、结构体返回

3.1 最直白的中心化双循环版本

先不追求通用性,把 2.2 的公式一字不差翻译成 C#。这个版本是后面所有封装的基线,也是出问题时用来对照的"干净实现"。

public readonly struct LineFitResult { public double Slope { get; init; } // 斜率 k public double Intercept { get; init; } // 截距 b public double RSquared { get; init; } // 决定系数 R² public int Count { get; init; } // 有效点数 public bool IsValid => Count >= 2 && !double.IsNaN(Slope) && !double.IsInfinity(Slope) && !double.IsNaN(Intercept); } public static class LineFitter { public static LineFitResult Fit(IReadOnlyList<double> xs, IReadOnlyList<double> ys) { if (xs is null || ys is null) throw new ArgumentNullException(nameof(xs)); if (xs.Count != ys.Count) throw new ArgumentException("x 与 y 长度必须一致", nameof(ys)); int n = xs.Count; if (n < 2) return new LineFitResult { Count = n, Slope = double.NaN, Intercept = double.NaN, RSquared = double.NaN }; // 第一遍:只求和,拿均值 double sumX = 0, sumY = 0; for (int i = 0; i < n; i++) { sumX += xs[i]; sumY += ys[i]; } double meanX = sumX / n, meanY = sumY / n; // 第二遍:围绕均值累加三个偏差量,避免大数相减 double sxx = 0, sxy = 0, syy = 0; for (int i = 0; i < n; i++) { double dx = xs[i] - meanX; double dy = ys[i] - meanY; sxx += dx * dx; sxy += dx * dy; syy += dy * dy; } // 所有 x 相同:Sxx 为 0,斜率数学上无定义 if (sxx <= double.Epsilon) return new LineFitResult { Count = n, Slope = double.NaN, Intercept = double.NaN, RSquared = double.NaN }; double k = sxy / sxx; double b = meanY - k * meanX; // Syy 为 0 说明 y 全相同,此时 R² 没有意义 double r2 = syy <= double.Epsilon ? double.NaN : (sxy * sxy) / (sxx * syy); return new LineFitResult { Count = n, Slope = k, Intercept = b, RSquared = r2 }; } }

两个循环的划分不是凑数。第一遍求均值,第二遍才能算偏差;强行合成一个循环,就得先累加 Σx、Σx²、Σxy,等于绕回第一套公式,把中心化的好处全丢了。多一次遍历对几十到几千个点的规模来说完全可以忽略,这个取舍很划算。

参数上要注意 sxx 的判断阈值。这里用 double.Epsilon 只挡绝对的 0;实际工程里如果 x 是电压、时间这类有量纲的量,x 之间差 1e-15 也照样会算出天文数字的斜率。稳妥写法是给一个有量纲的阈值,比如相对判断sxx <= n * Math.Pow(1e-9 * Math.Max(1, Math.Abs(meanX)), 2),具体数字按你的数据分辨率定。我通常的做法是:先看原始数据的 x 分辨率是多少,阈值就取它的百分之一再平方。

返回值用 readonly struct 而不是 out 参数,一是调用点干净,二是可以顺手带上 Count 和 IsValid,让上层不用再判断一次"这结果是不是垃圾"。IsValid 里同时挡 NaN 和 Infinity,比让 NaN 悄悄传下去、最后在界面上显示成"NaN"字符串要好。

3.2 泛化到多项式:法方程加列主元消去

直线拟合是一次的特例。手上的场景如果出现轻微弯曲(传感器在量程两端非线性、温度补偿曲线),一次拟合的 R² 会掉到 0.95 以下,这时候要么换模型,要么升阶。升阶的通用做法是构造法方程 (XᵀX)a = Xᵀy,其中 X 是范德蒙矩阵。法方程的元素有规律:第 (r, c) 项就是 Σx^(r+c),右端项是 Σy·x^r,所以整个矩阵可以靠一组幂和填满。

// 最小二乘多项式拟合,返回 coef[0] + coef[1]x + ... + coef[degree]x^degree public static double[] FitPolynomial(double[] xs, double[] ys, int degree) { if (degree < 1) throw new ArgumentOutOfRangeException(nameof(degree)); if (xs.Length != ys.Length) throw new ArgumentException("x 与 y 长度必须一致"); if (xs.Length <= degree) throw new ArgumentException("点数必须大于阶数,否则方程欠定"); int m = degree + 1; // 未知数个数 var power = new double[2 * degree + 1]; // power[p] = Σ x^p // 一次遍历攒齐 0..2*degree 次幂的和,避免反复调用 Math.Pow foreach (double x in xs) { double p = 1.0; for (int i = 0; i < power.Length; i++) { power[i] += p; p *= x; } } var a = new double[m, m + 1]; // 增广矩阵,最后一列是右端项 for (int r = 0; r < m; r++) { for (int c = 0; c < m; c++) a[r, c] = power[r + c]; double s = 0; for (int i = 0; i < xs.Length; i++) s += ys[i] * Math.Pow(xs[i], r); a[r, m] = s; } // 列主元高斯消去,主元过小说明法方程病态 for (int col = 0; col < m; col++) { int pivot = col; for (int r = col + 1; r < m; r++) if (Math.Abs(a[r, col]) > Math.Abs(a[pivot, col])) pivot = r; if (Math.Abs(a[pivot, col]) < 1e-12) throw new InvalidOperationException("法方程病态:降低阶数,或先把 x 归一化到 [-1,1]"); if (pivot != col) for (int c = col; c <= m; c++) (a[col, c], a[pivot, c]) = (a[pivot, c], a[col, c]); for (int r = col + 1; r < m; r++) { double f = a[r, col] / a[col, col]; for (int c = col; c <= m; c++) a[r, c] -= f * a[col, c]; } } // 回代 var coef = new double[m]; for (int r = m - 1; r >= 0; r--) { double s = a[r, m]; for (int c = r + 1; c < m; c++) s -= a[r, c] * coef[c]; coef[r] = s / a[r, r]; } return coef; }

degree = 1 时,coef[1] 就是斜率、coef[0] 就是截距,可以直接和 3.1 的结果对照,这是验证通用实现最快的方法。三个参数里最需要克制的是 degree:法方程的系数矩阵条件数大约是按阶数指数增长的,同时它还等于原始范德蒙矩阵条件数的平方。经验值是阶数超过 6~8 之后,即使数据本身干净,消元过程也会开始丢有效位;超过 10 基本不可用,得换正交多项式或者 QR 分解。

另一个必须做的动作是尺度归一化。如果 x 的取值范围是 0~10000,x⁸ 会到 1e32,法方程的量级跨度太大,double 也扛不住。做法很简单:拟合前把 x 线性映射到 [-1, 1],拟合完再把系数变换回去。这一步不是可选项,是所有实际使用的多项式拟合代码里都有的一段。

3.3 把残差和单点预测一起带出来

只有 k、b、R² 三个数,排查问题时往往不够用。现场最常被问的是"哪几个点拉低了拟合",所以我在结果结构里再加两个东西:逐点残差数组,以及一个预测方法。

// 在 LineFitResult 上补两个成员:残差数组与预测 public double[] Residuals { get; init; } public double Predict(double x) => Slope * x + Intercept; // 调用侧:拟合并把残差超过 3 倍标准差的点标出来 var fit = LineFitter.Fit(xs, ys); double sigma = Math.Sqrt(fit.Residuals.Sum(r => r * r) / Math.Max(1, fit.Count - 2)); for (int i = 0; i < fit.Count; i++) if (Math.Abs(fit.Residuals[i]) > 3 * sigma) Console.WriteLine($"第 {i} 点残差 {fit.Residuals[i]:F4},超出 3σ = {3 * sigma:F4}");

残差数组要在 Fit 内部顺手填上,代价是第三遍遍历,但换来的是排错时不用再算一遍。分母用 Count − 2 而不是 Count,是因为一元线性回归消耗了两个自由度(k 和 b),这是残差标准差的经典定义;点数少的时候这个差别很明显,n = 3 时用 n 会把 sigma 低估 40% 以上。3σ 这个门槛只是工程惯例,数据本身波动大的场合放宽到 4σ 也正常,别当成硬标准。

Predict 方法看着只有一行,作用是把"拟合结果"和"业务使用"分开:模型对象只管给系数,业务代码调 Predict 拿预测值,将来换成多项式或者分段模型时,调用点不用改。

4. 数值稳定性:平移、量纲、精度这三件事

4.1 x 的绝对值和精度损失的定量关系

前面说中心化能救精度,这里给一个能自己复现的例子。取 x = 10⁹、10⁹+1、10⁹+2、10⁹+3、10⁹+4,y 随便给一组接近直线的数。用第一套公式算分母 n·Σx² − (Σx)²:

Σx = 5×10⁹ + 10,Σx² = 5×10¹⁸ + 2×10¹⁰ + 30。n·Σx² 约 2.5×10¹⁹,(Σx)² 也约 2.5×10¹⁹,两者相减的真实结果是 50。

问题就在这里:double 在 10¹⁹ 这个量级上的最小间隔约为 2048,累加几次之后绝对误差轻松到几千,而被减数只有 50。结果可能算成负的、可能算成 3000,斜率的符号都可能翻。换成中心化形式,dx 分别是 −2、−1、0、1、2,每一步都是精确的(10⁹ 量级内的整数在 double 里能精确表示),Sxx 直接得到 10,一点误差都没有。

结论很明确:只要 x 的均值不为零且量级远大于它的波动范围,就必须用中心化形式。判断标准很土但好用——如果 x̄ 除以 (x 的极差) 大于 10³,我连想都不想,一律走中心化。时间戳、累计脉冲数、绝对坐标这类数据全都符合这个特征。

4.2 float、double、decimal 怎么选

float 只有约 7 位十进制有效数字,累加几十个点之后就撑不住了。用它算斜率,结果在第 4~5 位就开始抖,只适合做图形渲染里的临时量。

decimal 是 28~29 位有效数字、十进制定点,看起来很香,但三个问题挡在前面:范围只有 ±7.9×10²⁸,做 Σx² 时 x 稍大就溢出;运算速度比 double 慢一到两个数量级;没有原生的 Sqrt,算残差标准差还得转回 double。所以只在一种场景下考虑它——金额、计价类数据,且点数很少,且中间不做平方累加。

我的默认策略是全程 double,只在最终展示时格式化。另外有个特别容易忽略的点:如果输入数据是 int 数组,Σx² 在 int 上算会直接溢出。n = 1000、x 到 100000 时,Σx² 就是 10¹³ 量级,早就超出 int 的 21 亿。所有累加变量必须一开始就声明成 double,或者至少 long,别指望编译器帮你。

4.3 R² 高不等于模型对

R² 的含义是"y 的方差中被线性模型解释掉的比例",它在 0 到 1 之间(对带截距的一元线性回归而言),越接近 1 说明点越贴近直线。但它有个致命弱点:对数据的整体尺度不敏感,只对相对散布敏感。

几个必须知道的边界:只有两个点时,任何直线都过这两点,R² 恒等于 1,这个数完全没有信息量;数据点全部落在一条水平线上时 Syy = 0,R² 是 0/0,代码里必须返回 NaN 而不是 0 或 1;如果强行让直线过原点(b 固定为 0),R² 的公式要换,用原来那个式子会算出负数,别以为是程序错了。

更要紧的是,R² 高只能说明"线性关系强",不能说明"线性模型合适"。经典的反例是四组数据:x 相同、y 的均值、方差、相关系数几乎一样,但一组是标准直线、一组是明显弯曲的抛物线、一组有一个极端离群点、一组有一个点把斜率完全拽偏。四组的 R² 都可能到 0.99。唯一可靠的判别方式是画残差图——把残差对 x 或者对预测值作散点,看有没有残留结构。残差里还能看出 U 形,就说明该升阶或者换模型;残差呈现喇叭口,说明方差不齐,该考虑加权。

5. C# 拟合直线的五处翻车现场

5.1 现象:斜率算出 NaN 或 Infinity,界面直接显示乱码

原因:三种情况最常见。一是所有 x 相同(竖直排列的点),Sxx = 0,除法直接出 Infinity;二是有效点数为 1 或 0,返回值没做保护;三是数据里混进了 NaN(比如上游采集丢包填了 double.NaN),一个 NaN 污染整个累加,最后全是 NaN。

解决:入口处先过滤一遍,把 x 或 y 为 NaN/Infinity 的点剔除并记数,别静默丢弃。Sxx 的判断不要用== 0,用阈值(见 3.1)。返回值统一走 IsValid,界面上显示"点数不足,无法拟合"而不是把 NaN 抛给用户。我在清洗函数里固定返回"有效点数"和"剔除点数"两个值,出问题时一眼就知道是数据的问题还是算法的问题。

5.2 现象:结果和电子表格软件算的差一点点,第 8 位开始不一样

原因:多半不是谁错了,而是三件事之一。其一,电子表格里那些"空单元格"或文本被当成了 0 参与计算,实际参与拟合的点数和程序里不一样;其二,显示精度只给了两位小数,看起来"差一点点"其实是四舍五入;其三,对方可能用了不同的算法路径(比如先做了一次平滑或去掉首尾点)。

解决:先对齐点数,把 n 打出来比。再把两边的中间量(Sxx、Sxy)都打出来,差在第几位一目了然。真到 1e-12 量级的差异就别追了,那是浮点运算顺序不同导致的正常现象,只要远小于你的物理分辨率就没意义。我一般会在验收时把 2.3 那组固定数据写进单测,跑出来的值对得上,剩下的差异就不慌。

5.3 现象:x 换成时间戳之后,斜率变得离谱甚至符号相反

原因:时间戳(毫秒或 ticks)动辄 1.7×10¹² 量级,直接塞进第一套公式,就撞上 4.1 描述的灾难性抵消。这不是"算法不准",是浮点表示能力的硬边界。

解决:两条一起做。第一,换成中心化实现;第二,预处理时减去一个基准时刻(比如这批数据的第一条时间),把 x 变成从 0 开始的相对秒数。第二条不仅防精度问题,还让斜率有了物理意义——单位就是"每秒变化多少",不用再乘 1000 去解释。同理,累计脉冲数、绝对坐标、大编号设备号,全都先减基准再拟合。

5.4 现象:R² = 1,但业务上明显不合理

原因:最常见是只有两个点(必过两点,R² 恒为 1);其次是数据本身来自同一条直线的仿真或者重复采样,确实完全共线;还有一种是代码里把 y 也当成 x 传进去了,等于拟合 y = x,R² 当然漂亮。

解决:把 Count 一起返回并显示出来,少于 4 个点时 R² 就别展示了,标注"点数不足,仅供参考"。参数传递顺序用命名参数或者强类型结构包一层,避免 xs / ys 传反这种低级错误在运行期才暴露。另外,如果数据是重复测量同一批点,先做一次去重或者取均值,否则 R² 会虚高。

5.5 现象:读 CSV 之后所有点的 y 都变成了整数,小数部分凭空消失

原因:double.Parse 受当前区域设置影响。在某些区域配置下小数点符号是逗号,遇到 "3.14" 这种用点号的数据,解析会在点号处停下,只取到 3。有些实现不抛异常,返回一个被截断的值,数据看着"有值",其实全是错的。

解决:解析时显式指定不变区域,double.Parse(s, CultureInfo.InvariantCulture),或者直接用double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)。同样的坑也出现在 ToString 输出和序列化里,凡是跨机器传输的数据,格式化一律走不变区域。这一条不属于拟合算法本身,但它是这类离线工具包里最高频的"数据全错"来源。

6. 把拟合包成能长期用的组件:接口、自检与标定场景

零散调用几次之后,我一般会把拟合收成一个接口,好处是将来换成多项式、加权最小二乘或者分段拟合,业务代码不用动。接口只留两个方法:Fit 拿结果,Predict 拿预测值;结果对象固定带 Slope、Intercept、RSquared、Count、Residuals、IsValid 六个成员。别小看这一层,很多项目里拟合逻辑散落在三四处,每处的边界处理都不一样,最后一处返回 NaN、一处返回 0,问题排查成本比写这个接口高得多。

封装完必须配自检用例,否则改了实现根本不知道有没有改坏。下面三条是我固定的回归集:

用例输入期望输出验证点
基准组2.3 节的五个点k=1.99, b=0.05, R²=0.997305主路径正确性
大数偏移x 全加 10⁹k、b 与基准组一致(b 相应平移)中心化是否生效
竖直退化x 全为同一值IsValid=false,Slope=NaN除零保护

第二条用例特别值得跑,它是唯一能证明中心化实现没被"优化"掉的测试——有人重构时会觉得两个循环太啰嗦,合成一个,功能测试全过,只有这条会挂。

落到具体场景,传感器标定是最典型的用法:给一组标准量(x)和对应的仪表读数(y),拟合出 y = kx + b,k 就是增益校准系数,b 就是零点偏移。这时候有两个细节值得多花十分钟:一是标定点要覆盖量程两端而不是集中在中间,否则截距的置信区间会非常宽;二是别只看 R²,把残差对标准量画出来,如果残差在两端同号、中间反号,说明存在系统性非线性,硬用直线拟合虽然 R² 也能到 0.999,但端点误差会明显超出允许范围。这种时候升到二次或者分段线性,比反复调标定系数靠谱。

血泪经验是有一条:不要用拟合结果去反推输入数据是否合理。拟合是"给定数据求最优参数",它不负责判断数据对不对。曾经有一批点里混进了一个明显的跳变,最小二乘被它拽得斜率偏了 8%,而 R² 还有 0.98,看数字完全看不出问题,是残差图上一个孤零零的 6σ 点暴露的。所以从那以后我每次拿到拟合结果,都强制走一遍残差检查——看最大残差、看残差是否随机分布、看有没有点超过 3σ,三个动作加起来不到二十行代码,省下的返工时间远不止这些。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询