最小二乘问题详解3:线性最小二乘实例

1. 引言

在上一篇文章《最小二乘问题详解2:线性最小二乘求解》中笔者详细介绍了如何求解线性最小二乘问题,一般使用QR分解或者SVD分解法,这里笔者就实现一个具体的案例来验证一下。

2. 案例

总是举拟合直线的例子实在太简单了,这里就使用一个更加复杂一点问题模型:双线性变换。具体来说,假设存在两幅地图需要配置,并且找到了各自地图上的同名点,可以使用双线性变换模型来进行快速、初步的校正。也就是说一组同名点满足如下关系:

\[\begin{cases} x_1 = a_0 x_0 + b_0 y_0 + c_0 x_0 y_0 + d_0 \\ y_1 = a_1 x_0 + b_1 y_0 + c_1 x_0 y_0 + d_1 \end{cases} \]

其中,\((x_0,y_0)\)和\((x_1,y_1)\)是对应的同名点,也就是观测值。而\(a_0,b_0,c_0,d_0\)和\(a_1,b_1,c_1,d_1\)则是X和Y方向上的双线性变换系数,也就是待定值。对于观测值来说,这个函数是非线性的,但是对于待定值来说这个问题模型则是线性的。这也是笔者在《最小二乘问题详解1:线性最小二乘》中强调的一点:最小二乘问题是线性还是非线性,需要通过待定值来判断。

要实现这个案例,那么就需要准备一组同名点,\((x_0,y_0)\)可以通过随机数生成,\((x_1,y_1)\)则可以通过双线性变换公式加上一点噪声值来得到。另外,也不需要从头实现矩阵运算的,一定规模的矩阵运算库就可以实现线性方程组的求解,比如这里笔者使用的Eigen。具体的案例代码实现如下:

cpp 复制代码
#include <Eigen/Dense>
#include <random>
#include <vector>

using namespace std;
using namespace Eigen;

int main() {
  // ========================
  // 1. 设置真实参数(我们想要求解的目标)
  // ========================
  Vector4d params_x_true, params_y_true;
  params_x_true << 1.5, -0.3, 0.1, 2.0;    // a0, b0, c0, d0
  params_y_true << 0.4, 1.8, -0.05, -1.0;  // a1, b1, c1, d1

  cout << "真实参数 x: a0=" << params_x_true(0) << ", b0=" << params_x_true(1)
       << ", c0=" << params_x_true(2) << ", d0=" << params_x_true(3) << endl;

  cout << "真实参数 y: a1=" << params_y_true(0) << ", b1=" << params_y_true(1)
       << ", c1=" << params_y_true(2) << ", d1=" << params_y_true(3) << endl;

  // ========================
  // 2. 生成 N 个随机点 (x0, y0) 并计算 (x1, y1)
  // ========================
  int N = 100;  // 点的数量
  vector<double> x0(N), y0(N), x1(N), y1(N);

  // 随机数生成器
  random_device rd;  //真随机数生成器,生成不可预测的随机种子
  mt19937 gen(rd());                                   //伪随机数生成器
  uniform_real_distribution<double> dis(-10.0, 10.0);  // 均匀分布,模拟观测值
  normal_distribution<double> noise(0.0, 0.1);  // 正态分布,模拟噪声

  for (int i = 0; i < N; ++i) {
    x0[i] = dis(gen);
    y0[i] = dis(gen);

    // 应用双线性变换
    double x1_true = params_x_true(0) * x0[i] + params_x_true(1) * y0[i] +
                     params_x_true(2) * x0[i] * y0[i] + params_x_true(3);
    double y1_true = params_y_true(0) * x0[i] + params_y_true(1) * y0[i] +
                     params_y_true(2) * x0[i] * y0[i] + params_y_true(3);

    // 添加噪声
    x1[i] = x1_true + noise(gen);
    y1[i] = y1_true + noise(gen);
  }

  // ========================
  // 3. 构造设计矩阵 A 和观测向量 b
  // ========================
  // 对于 x 方向:x1 = a0*x0 + b0*y0 + c0*x0*y0 + d0
  MatrixXd A_x(N, 4);  // N 行,4 列(a0, b0, c0, d0)
  VectorXd b_x(N);

  // 对于 y 方向:y1 = a1*x0 + b1*y0 + c1*x0*y0 + d1
  MatrixXd A_y(N, 4);  // N 行,4 列(a1, b1, c1, d1)
  VectorXd b_y(N);

  for (int i = 0; i < N; ++i) {
    A_x(i, 0) = x0[i];          // a0
    A_x(i, 1) = y0[i];          // b0
    A_x(i, 2) = x0[i] * y0[i];  // c0
    A_x(i, 3) = 1.0;            // d0
    b_x(i) = x1[i];

    A_y(i, 0) = x0[i];          // a1
    A_y(i, 1) = y0[i];          // b1
    A_y(i, 2) = x0[i] * y0[i];  // c1
    A_y(i, 3) = 1.0;            // d1
    b_y(i) = y1[i];
  }

  // ========================
  // 4. 使用 Eigen 求解最小二乘
  // ========================
  Vector4d theta_x = A_x.colPivHouseholderQr().solve(b_x);
  Vector4d theta_y = A_y.colPivHouseholderQr().solve(b_y);

  // ========================
  // 5. 输出结果
  // ========================
  cout << "\n--- 拟合结果 ---" << endl;
  cout << "估计参数 x: a0=" << theta_x(0) << ", b0=" << theta_x(1)
       << ", c0=" << theta_x(2) << ", d0=" << theta_x(3) << endl;

  cout << "估计参数 y: a1=" << theta_y(0) << ", b1=" << theta_y(1)
       << ", c1=" << theta_y(2) << ", d1=" << theta_y(3) << endl;

  // ========================
  // 6. 验证:计算残差
  // ========================
  VectorXd residual_x = b_x - A_x * theta_x;
  VectorXd residual_y = b_y - A_y * theta_y;

  cout << "\n残差平方和 (x): " << residual_x.squaredNorm() << endl;
  cout << "残差平方和 (y): " << residual_y.squaredNorm() << endl;
  cout << "总残差平方和: "
       << (residual_x.squaredNorm() + residual_y.squaredNorm()) << endl;

  return 0;
}

有以下几点需要注意:

  1. 随机生成观测值数据的实现

    cpp 复制代码
    // 随机数生成器
    random_device rd;  //真随机数生成器,生成不可预测的随机种子
    mt19937 gen(rd());                                   //伪随机数生成器
    uniform_real_distribution<double> dis(-10.0, 10.0);  // 均匀分布,模拟观测值
    normal_distribution<double> noise(0.0, 0.1);  // 正态分布,模拟噪声

    中噪声的随机值不是随意生成的,而要符合正态分布(高斯分布)。这是因为偶然误差(随机误差)通常符合正态分布。

  2. 这里分成X方向和Y方向求解了两组线性方程组,因为有两组不相关的待定系数\((a_0,b_0,c_0,d_0)\)和\((a_1,b_1,c_1,d_1)\)。

  3. 本例使用的QR分解法求解的线性最小二乘问题,如果想使用SVD也很简单,可以将colPivHouseholderQr替换成如下接口:

    cpp 复制代码
    Vector4d theta_x = A_x.bdcSvd(Eigen::ComputeThinU | Eigen::ComputeThinV).solve(b_x);
    Vector4d theta_y = A_y.bdcSvd(Eigen::ComputeThinU | Eigen::ComputeThinV).solve(b_y);

最终运行结果如下:

bash 复制代码
真实参数 x: a0=1.5, b0=-0.3, c0=0.1, d0=2
真实参数 y: a1=0.4, b1=1.8, c1=-0.05, d1=-1

--- 拟合结果 ---
估计参数 x: a0=1.5006, b0=-0.299571, c0=0.100173, d0=1.98956
估计参数 y: a1=0.398688, b1=1.80047, c1=-0.0501635, d1=-0.994459

残差平方和 (x): 0.745402
残差平方和 (y): 0.945377
总残差平方和: 1.69078

3. 精度

3.1 引出

虽然把最小二乘解求出来了,不过笔者更加关心一个问题,那就是求解的精度是多少?如果我只需要求解一个大致的解,那么随便取四组点求解出来就可以了,反正不能精确求解,得到结果也大差不差------其实这就是最小二乘的意义:我不仅仅求解出来了,还可以明确计算出求解的精度误差,使得观测值与求解的符合度始终在这个误差范围之内,从而实现科学上从定性到定量的质变。

以这个例子来说,由于是先设置的真实参数再进行仿真求解,那么可以直接计算估计值与真实值的差值来确定误差。但是在真实场景中,肯定是不知道真实值的,那么就只能估计精度,具体来说就是计算待定参数的协方差矩阵

不过要说清楚协方差矩阵 不是一件容易的事情,因为这个概念的背景知识是《概率论与数理统计》和《误差理论》和这两门课程的内容。大概论述一下就是,协方差矩阵描述了待定参数的估值的不确定性有多大,它的对角线元素是每个参数的方差,开根号就是标准差,而标准差越小,说明估计越精确,所以标准差就是"精度"的量化。

3.2 协方差

从头来讲,可能我们大学学习的《概率论与数理统计》都忘光了,但是高中数学学习的概率与统计知识应该还是记得的。假设我们使用尺子测量一根棍子的长度,一共测了5次,得到:

\[x = [10.1, 9.9, 10.0, 10.2, 9.8] \text{ cm} \]

那么平均值(估计值)是:

\[\bar{x} = \frac{1}{5} \sum x_i = 10.0 \text{ cm} \]

这个值就是棍子"真实长度"的最佳估计 ,那么方差(不确定性)是:

\[s^2 = \frac{1}{n-1} \sum (x_i - \bar{x})^2 = 0.025 \]

标准差(精度)则是:

\[s = \sqrt{0.025} \approx 0.158 \text{ cm} \]

那么可以这样评估精度:"长度是 \(10.0 \pm 0.16\) cm"。

那么现在进行升级,不是估计一个数,而是在估多个参数,例如本例中的双线性变换中,需要估计:

\[\theta = [a_0, b_0, c_0, d_0] \]

这就好比需要同时测量四根棍子的长度 ,并且由于数据的相关性和估计过程的耦合,这些参数并不独立。为了更好的描述多个参数的估计,就引入了协方差矩阵(Covariance Matrix) :当有多个参数 \(\theta = [\theta_1, \theta_2, \dots, \theta_p]\),它们的不确定性用一个矩阵表示:

\[\mathrm{Cov}(\theta) = \begin{bmatrix} \mathrm{Var}(\theta_1) & \mathrm{Cov}(\theta_1,\theta_2) & \cdots \\ \mathrm{Cov}(\theta_2,\theta_1) & \mathrm{Var}(\theta_2) & \cdots \\ \vdots & \vdots & \ddots \end{bmatrix} \]

  • 对角线 :每个参数的方差 → 表示它自己的不确定性
  • 非对角线 :两个参数之间的协方差 → 表示它们的估计是否相互影响

方差我们还好理解,那么协方差 是什么的呢?这个概念也有点抽象,假设有两个随机变量 \(X\) 和 \(Y\)(比如身高和体重):

  • 如果 \(X\) 大时 \(Y\) 也大 → 正协方差
  • 如果 \(X\) 大时 \(Y\) 小 → 负协方差
  • 如果无关 → 协方差接近 0

也就是说,协方差衡量的是两个变量一起变化的趋势 。如果还要说的更加具体一点,那么可以这样定义:对于两个参数 \(\hat{a}_0\) 和 \(\hat{b}_0\),它们的协方差是:

\[\mathrm{Cov}(\hat{a}_0, \hat{b}0) = \frac{1}{N-1} \sum{i=1}^N (\hat{a}_0^{(i)} - \bar{a}_0) (\hat{b}_0^{(i)} - \bar{b}_0) \]

其中:

  • \(N\):重复实验次数
  • \(\hat{a}_0^{(i)}\):第 \(i\) 次实验估计的 \(a_0\)
  • \(\bar{a}_0\):所有 \(\hat{a}_0^{(i)}\) 的平均值
  • \(\bar{b}_0\):所有 \(\hat{b}_0^{(i)}\) 的平均值

也即是两个参数的协方差就是两者误差乘积的平均,如果误差经常同号,乘积为正,那么协方差就大于0;如果误差经常异号,乘积为负,那么协方差就小于0。

3.3 计算

那么,如何得到这个协方差矩阵呢?这里进行推导说明有点复杂,先直接给出结论,以后有机会再进行详细说明:

\[\mathrm{Cov}(\hat{\theta}) = \sigma^2 (A^T A)^{-1} \]

其中 \(\sigma^2\) 是噪声方差的估计:

\[\hat{\sigma}^2 = \frac{\|r\|^2}{n - p} \]

  • \(r\): 残差
  • \(n\): 数据点数
  • \(p\): 参数个数

回到问题,要评价最小二乘解的精度,就可以取协方差矩阵对角线的值,开平方得到每个待定参数的标准差。

具体的代码实现如下:

cpp 复制代码
// 假设求解了 theta_x
VectorXd residual = b_x - A_x * theta_x;
int n = A_x.rows();  // 数据点数
int p = A_x.cols();  // 参数数 = 4
double sigma_squared = residual.squaredNorm() / (n - p);

// 计算 (A^T A)^{-1}
MatrixXd AtA_inv = (A_x.transpose() * A_x).inverse();

// 协方差矩阵
MatrixXd cov_theta = sigma_squared * AtA_inv;

// 每个参数的标准差(即"精度"的量化)
VectorXd std_error = cov_theta.diagonal().cwiseSqrt();

cout << "参数标准差 (std error):" << endl;
cout << "a0: ±" << std_error(0) << endl;
cout << "b0: ±" << std_error(1) << endl;
cout << "c0: ±" << std_error(2) << endl;
cout << "d0: ±" << std_error(3) << endl;

4. 其他

其实本文关于精度的内容没有完全展开说清楚,比如精度的指标不仅仅包含标准差,还包含置信区间等概念。不过写的太多就感觉有点偏题了,在后续的文章中再进一步论述清楚吧。

相关推荐
charlee444 天前
最小二乘问题详解2:线性最小二乘求解
qr分解·矩阵分解·最小二乘·正规方程·svd分解
charlee449 天前
最小二乘问题详解1:线性最小二乘
参数估计·正交投影·最小二乘·正规方程
我爱C编程4 个月前
基于FFT变换的雷达信号检测和参数估计matlab性能仿真
matlab·参数估计·fft变换·雷达信号检测
byxdaz5 个月前
Eigen线性代数求解器(分解类)
eigen
byxdaz5 个月前
Eigen核心矩阵/向量类 (Matrix, Vector, Array)
eigen
byxdaz5 个月前
Eigen矩阵操作类 (Map, Block, 视图类)
eigen
正义的彬彬侠1 年前
协方差矩阵及其计算方法
人工智能·机器学习·协方差·协方差矩阵
黄卷青灯771 年前
c++ vector类 和 eigen库 处理向量的区别 列出代码举例
开发语言·c++·vector·eigen
风与铃的约定1 年前
2.2 视觉SLAM 实践:Eigen
c++·机器人·slam·视觉slam·eigen·机器人运动学