简介:基于C++11与Eigen实现的一份数学工具集合,涵盖散点数据插值、降维和数值优化等方向,面向需要把常用机器学习算法落地到C++项目的开发者,既可当作算法学习范例,也能直接嵌入现有工程。资源包共114个文件,约2.32MB,其中包含26个cpp源文件、18个hpp头文件,并配有20个Markdown说明、13个TXT文本、11个Python脚本、5个YAML配置及若干图片与视频,目录按算法模块划分,便于按需定位。算法层面完整实现了RBF插值、高斯过程回归、经典MDS、自组织映射(SOM)与L-BFGS优化等,同时提供核函数、采集函数等配套组件,并且Python绑定脚本可以快速完成算法效果验证,配合使用说明与可视化素材,读者能深入掌握每类工具的参数含义和调用方式、按需替换集成,覆盖从插值降维到优化求解的常规路径。已有620人学习下载,对于想在C++环境中获得轻量数学工具、避免重复造轮子的开发者来说,是一份简洁且具实用价值的参考。
1. Eigen 与 C++11 的组合:这套数学工具箱能直接派上用场
我拆这套工具箱的时候,第一眼就确认了它的身份:纯 C++11 实现、以 Eigen 为唯一矩阵引擎、功能范围克制在插值、降维、优化三条主线上。这个宽度卡得很准——你不用为一个拟合任务去拉一整套第三方数值库,它刚好把特征工程里最常用的那几件事做成独立模块。如果你手头的工程还停留在老工具链,没条件升到很高版本的 C++ 标准,这套东西的语法水平能帮你省掉大量移植麻烦。它适合两种人:把插值和 PCA 当高频原语的算法工程师,以及想把优化求解快速塞进 C++ 服务、不想重复造轮子的服务端开发。
2. 插值模块:三次样条与径向基的 Eigen 实现和参数取舍
2.1 为什么要用矩阵方式做插值
插值表面上是找一条穿过已知点的曲线,但工程里真正要关心的是曲线的光滑性和数值稳定性。高次多项式插值容易在端点附近出现 Runge 现象,震荡幅度往往超出可接受范围,所以实际项目里我更愿意用分段低次多项式。三次样条把相邻两个节点之间当成一段三次多项式,利用二阶导数连续的条件把整条曲线串起来,最后落成一个三对角或带状稀疏线性方程组。
Eigen 在这个场景下的价值在于:求解三对角矩阵不需要手写追赶法,直接用稀疏矩阵存储和求解器即可。节点数从几十到几千时,这个方案在代码量、可读性、性能之间最平衡。如果你只有几十个点,也可以直接用稠密 LU;但数据量一旦到几千,稠密求解的 O(n³) 复杂度就会让延迟变得不可接受。我一般会坚持用稀疏格式,把“以后数据涨了怎么办”这个问题提前消掉。
2.2 三次样条接口定义与核心代码
先定义一个配置结构体和样条类,把边界条件、端点斜率这些参数从算法里拆出去,调用方只需要关注自己关心什么。
// spline.hpp #include <Eigen/Dense> #include <Eigen/Sparse> #include <vector> struct SplineConfig { bool natural_boundary = true; // true: 自然边界,端点二阶导为 0 bool clamped_boundary = false; // true: 固定端点一阶导 double left_slope = 0.0; double right_slope = 0.0; }; class CubicSpline { public: using Matrix = Eigen::MatrixXd; using Vector = Eigen::VectorXd; explicit CubicSpline(const SplineConfig& cfg) : cfg_(cfg) {} bool fit(const std::vector<double>& x, const std::vector<double>& y); double eval(double xq) const; private: SplineConfig cfg_; std::vector<double> x_, y_; Vector a_, b_, c_, d_; // 每段三次多项式系数 };fit 部分的核心是构造三对角矩阵并求解二阶导数向量 M。代码里直接使用 Eigen 的 SparseMatrix,配合 SparseLU 求解,避免对稠密矩阵分配大量无意义的内存。
bool CubicSpline::fit(const std::vector<double>& x, const std::vector<double>& y) { const size_t n = x.size(); if (n < 3 || x.size() != y.size()) return false; x_ = x; y_ = y; Vector h(n - 1); for (size_t i = 0; i + 1 < n; ++i) { if (!(x[i + 1] > x[i])) return false; // 节点必须严格递增 h[i] = x[i + 1] - x[i]; } Eigen::SparseMatrix<double> A(n, n); Vector rhs(n); A.reserve(Eigen::VectorXi::Constant(n, 3)); // 自然边界:M[0] = 0,M[n-1] = 0 A.insert(0, 0) = 1.0; rhs[0] = 0.0; A.insert(n - 1, n - 1) = 1.0; rhs[n - 1] = 0.0; // 内部点方程: // h_{i-1}*M_{i-1} + 2*(h_{i-1}+h_i)*M_i + h_i*M_{i+1} // = 6*( (y_{i+1}-y_i)/h_i - (y_i-y_{i-1})/h_{i-1} ) for (size_t i = 1; i + 1 < n; ++i) { A.insert(i, i - 1) = h[i - 1]; A.insert(i, i) = 2.0 * (h[i - 1] + h[i]); A.insert(i, i + 1) = h[i]; rhs[i] = 6.0 * ((y[i + 1] - y[i]) / h[i] - (y[i] - y[i - 1]) / h[i - 1]); } Eigen::SparseLU<Eigen::SparseMatrix<double>> solver; solver.compute(A); Vector M = solver.solve(rhs); // 反解每段三次多项式系数 a_.resize(n - 1); b_.resize(n - 1); c_.resize(n - 1); d_.resize(n - 1); for (size_t i = 0; i + 1 < n; ++i) { a_[i] = (M[i + 1] - M[i]) / (6.0 * h[i]); b_[i] = M[i] / 2.0; c_[i] = (y[i + 1] - y[i]) / h[i] - h[i] * (2.0 * M[i] + M[i + 1]) / 6.0; d_[i] = y[i]; } return true; }这里倒数第二步的SparseLU是求解的关键:A 是稀疏三对角矩阵,求解代价远低于稠密 LU。reserve预先分配每行 3 个非零元,避免插入过程中反复扩容。边界条件的设置不为零,直接把自然边界写成方程,省去单独处理边界条件的分支。
求值时,先用二分查找定位 xq 所在区间,再用三次多项式求值。端点外的行为默认不做外推,直接用边界段延拓,避免曲线在端点外飞掉。
double CubicSpline::eval(double xq) const { if (xq <= x_.front()) xq = x_.front(); if (xq >= x_.back()) xq = x_.back(); size_t lo = 0, hi = x_.size() - 2; while (lo < hi) { size_t mid = lo + (hi - lo + 1) / 2; if (x_[mid] <= xq) lo = mid; else hi = mid - 1; } const double dx = xq - x_[lo]; // 三次多项式:a*dx^3 + b*dx^2 + c*dx + d return a_[lo] * dx * dx * dx + b_[lo] * dx * dx + c_[lo] * dx + d_[lo]; }二分查找部分用lo + (hi - lo + 1) / 2这种偏右的写法,保证区间向前推进,不会死循环。实际项目中如果评估次数很多,可以提前把区间索引数组缓存下来,但大多数场景下二分已经足够快。
2.3 参数与边界:三种边界模式怎么选
三次样条的坑大多出在边界条件上,而不是内部节点上。不同边界模式对应的矩阵端点行不一样,使用场景也完全不同。
| 边界类型 | 端点约束 | 适用场景 | 注意 |
|---|---|---|---|
| 自然边界 | M0=0,Mn-1=0 | 默认选择 | 端部二阶导为零,曲线在端点附近会自然变直 |
| 固定斜率 | 给定两端一阶导 | 已知物理量变化率 | 需要额外输入两个斜率值 |
| Not-a-Knot | 前两段和最后两段三阶导连续 | 没有边界信息时 | 矩阵带宽比三对角略宽 |
我一般默认用自然边界,它不需要额外参数,数值上也稳定。固定斜率只在你确实知道端点的导数趋势时用,比如速度测量数据在起点处速度为 0。Not-a-Knot 在实际工程里用得少,因为很多算法库的默认实现不提供,换成自然边界结果差异通常也在误差范围内。
2.4 径向基插值:散点不在网格上时的选择
三次样条要求节点严格递增,但实际业务数据经常是散点:同一 x 可能对应多个测量值,或者 x 本身就不排序。这种情况我优先用径向基函数(RBF)插值。RBF 的核心是把两两样本之间的距离放进核函数,得到一个 Gram 矩阵,然后解一个线性方程组。
Eigen::MatrixXd build_rbf_matrix(const std::vector<double>& x, double epsilon) { const size_t n = x.size(); Eigen::MatrixXd K(n, n); for (size_t i = 0; i < n; ++i) { K(i, i) = 1.0; for (size_t j = 0; j < i; ++j) { double r = std::abs(x[i] - x[j]); K(i, j) = K(j, i) = std::exp(-(r * r) / (epsilon * epsilon)); } } return K; }epsilon 是 RBF 的带宽参数:太小会振荡,太大会把曲线拉成一条近似直线。经验做法是先统计样本点之间距离的中位数,再取它的 0.5 到 1.5 倍。求解 Gram 矩阵用Eigen::ColPivHouseholderQR而不是LDLT,因为 Gram 矩阵在点密集时可能接近奇异,列主元 QR 能处理得更稳。
3. 降维模块:PCA 与 LDA 在 Eigen 里的落地与调参
3.1 从 SVD 视角看 PCA:Eigen 的 JacobiSVD 比自建协方差更稳
很多人在工程里实现 PCA 时,习惯先算协方差矩阵再做特征分解。这个思路在小维度下没问题,但一旦特征维度接近样本量,协方差矩阵会变得病态,特征分解的数值误差会被放大。更稳的做法是直接对中心化后的数据矩阵做奇异值分解,Eigen 的JacobiSVD在稠密矩阵场景下比SelfAdjointEigenSolver更不容易翻车。
PCA 的具体步骤是:先按列中心化,再对中心化矩阵做 SVD,右奇异向量就是主成分方向,奇异值的平方占总平方和的比例就是解释方差比。Eigen 的JacobiSVD支持 Thin U/V,适用于样本数大于特征数的高瘦矩阵。
struct PCAResult { Eigen::MatrixXd loadings; // 每列一个主成分方向 Eigen::MatrixXd scores; // 降维后的样本 Eigen::VectorXd explained; // 各主成分解释方差比 }; PCAResult pca(const Eigen::MatrixXd& X, double keep_ratio = 0.95, bool whiten = false) { const size_t d = X.cols(); Eigen::VectorXd mean = X.colwise().mean(); Eigen::MatrixXd Xc = X.rowwise() - mean.transpose(); Eigen::JacobiSVD<Eigen::MatrixXd> svd( Xc, Eigen::ComputeThinU | Eigen::ComputeThinV); Eigen::MatrixXd V = svd.matrixV(); Eigen::VectorXd s = svd.singularValues(); double total = s.array().square().sum(); Eigen::VectorXd explained = s.array().square() / total; // 按累计解释方差比截断维度 size_t k = d; double cum = 0.0; for (size_t i = 0; i < d; ++i) { cum += explained[i]; if (cum >= keep_ratio) { k = i + 1; break; } } PCAResult r; r.loadings = V.leftCols(k); r.explained = explained.head(k); if (whiten) { // 白化:把主成分方向缩放到相同方差 double scale_factor = std::sqrt(double(X.rows() - 1)); Eigen::MatrixXd scale = (s.head(k) / scale_factor).asDiagonal().inverse(); r.scores = Xc * r.loadings * scale; } else { r.scores = Xc * r.loadings; } return r; }这里keep_ratio是工程里最容易调的参数,一般取 0.95 到 0.99。数据噪声大就取低一点,需要保留完整结构的场景取高一点。whiten在大多数特征工程场景下不需要开,它主要用在后续算法对尺度敏感的场合,比如某些分类器要求各维方差一致。
代码里asDiagonal().inverse()是把奇异值向量转成对角矩阵再求逆,这里只涉及 k×k 的矩阵,代价可以忽略。SVD 分解本身是 O(N·d²) 的量级,在数据量很大时可以先用随机采样做一次预投影,但工具箱里没有做这层优化,适合单机中等规模数据。
3.2 LDA:用广义特征值解监督降维
PCA 是无监督的,它不考虑类别信息。如果样本有标签,LDA 通常能获得比 PCA 更好的分类区分度。LDA 的目标是最大化类间散度与类内散度的比值,落到数学上是一个广义特征值问题:Sb 乘以投影方向等于 lambda 乘以 Sw 乘以投影方向。
Eigen::MatrixXd lda_project(const Eigen::MatrixXd& X, const std::vector<int>& labels, size_t out_dim, double reg = 1e-6) { // 省略类均值与散度矩阵计算过程,这里直接给出求解部分 Eigen::MatrixXd Sw = compute_within_scatter(X, labels); Eigen::MatrixXd Sb = compute_between_scatter(X, labels); // 数值稳定性处理:Sw 加一个小的单位阵倍数 Sw += reg * Eigen::MatrixXd::Identity(Sw.rows(), Sw.cols()); Eigen::GeneralizedSelfAdjointEigenSolver<Eigen::MatrixXd> solver(Sb, Sw); return solver.eigenvectors().rightCols(out_dim); }reg是 LDA 的救命参数。样本数小于特征维度时,Sw 往往是奇异的,不加 reg 直接求解会得到 NaN。我一般把 reg 初始化为 1e-6,如果结果不稳定再调到 1e-4。rightCols(out_dim)取的是最大广义特征值对应的方向,也就是区分能力最强的方向。实际业务中 out_dim 通常取类别数减一,超过这个值 LDA 也没有更多有效方向。
3.3 降维前要不要标准化:先看列量纲
PCA 对特征尺度极其敏感。如果一列是“温度”,另一列是“压力”,数值范围差几百倍,SVD 会天然偏向数值范围大的那个特征,主成分方向几乎由它单独决定。这时需要先做 z-score 标准化,把每一列缩放到零均值、单位方差。
Eigen::VectorXd stddev = X.colwise().norm() / std::sqrt(double(X.rows())); Eigen::MatrixXd Xn = X.array().rowwise() / stddev.transpose().array();有几个细节要注意:如果某列接近常量,stddev 会接近 0,直接除法会产生 inf。我通常在除法前加一个下限保护,把小于 1e-12 的标准差强制设为 1。标准化后 PCA 得到的主成分是相关矩阵意义上的主成分,解释方差比反映的是相关性差异,而不是原始数值方差差异。这一点在报告里要写清楚,不然业务方会拿原始量纲去解释结果。
4. 优化模块:梯度下降到拟牛顿的选型与落地
4.1 目标函数与数值梯度:先用 Eigen 向量定义损失
优化模块首先要定一个统一的函数签名:输入是待优化的系数向量,输出是一个标量损失值。用std::function包装,算法内部只认这个接口。这样无论是曲线拟合、矩阵分解还是业务评分函数,都能接进同一个优化框架。
数值梯度不需要解析求导,用中心差分估算。中心差分的误差是 O(h²),比前向差分的 O(h) 稳定,代价是多一次函数求值。
using LossFn = std::function<double(const Eigen::VectorXd&)>; Eigen::VectorXd numerical_gradient(const LossFn& f, const Eigen::VectorXd& x, double step = 1e-6) { Eigen::VectorXd g(x.size()); for (int i = 0; i < x.size(); ++i) { Eigen::VectorXd xp = x; Eigen::VectorXd xm = x; xp[i] += step; xm[i] -= step; g[i] = (f(xp) - f(xm)) / (2.0 * step); } return g; }step 的选择是个小坑。太大,差分近似误差明显;太小,浮点舍入误差占主导。1e-6 适合大部分参数范围在 0.1 到 10 之间的问题。如果你的参数动辄上千,可以把 step 按1e-6 * (1.0 + std::abs(x[i]))放大,避免对小分量参数产生无效差分。
4.2 下降方向与回溯线搜索:尽快把 alpha 选出来
有了梯度,下一步是确定沿哪个方向走多远。最朴素的方向是负梯度,但纯粹用梯度下降在条件数大的问题上会非常慢。工程上更实用的是:算出方向后,用回溯线搜索找步长。Armijo 条件的含义是“损失必须按预期比例下降”,如果步长太大导致损失不降,就不断缩小步长。
double backtracking(const LossFn& f, const Eigen::VectorXd& x, const Eigen::VectorXd& d, const Eigen::VectorXd& g, double c = 1e-4, double rho = 0.5, double alpha = 1.0) { double f0 = f(x); double slope = g.dot(d); while (f(x + alpha * d) > f0 + c * alpha * slope) { alpha *= rho; if (alpha < 1e-12) break; } return alpha; }c 是 Armijo 条件的可接受下降比例,1e-4 是经典取值;rho 是步长衰减系数,0.5 意味着每次不满足条件就砍一半。slope 是方向导数,正常下降方向下它小于 0,所以c * alpha * slope是一个负值,相当于允许损失不降太多就接受这个步长。这个函数里没有检测“方向不是下降方向”的情况,调用方需要保证 d 与梯度内积为负。
4.3 BFGS 更新与 Hessian 重置策略
BFGS 用梯度差和参数差去逼近 Hessian 逆矩阵,每一轮迭代只需要 O(d²) 的矩阵更新,不需要真正计算和存储 Hessian。工程实现里我关注两件事:线搜索必须足够精确,否则 BFGS 更新可能产生非正定的逆 Hessian 近似;一旦发现非正定迹象,立即把逆 Hessian 矩阵重置为单位阵,这是最简单也最有效的后悔药。
Eigen::VectorXd bfgs(const LossFn& f, const Eigen::VectorXd& x0, int max_iter = 200, double gtol = 1e-5) { int n = x0.size(); Eigen::VectorXd x = x0; Eigen::VectorXd g = numerical_gradient(f, x); Eigen::MatrixXd H = Eigen::MatrixXd::Identity(n, n); for (int iter = 0; iter < max_iter; ++iter) { if (g.norm() < gtol) break; Eigen::VectorXd d = -H * g; double alpha = backtracking(f, x, d, g); Eigen::VectorXd x_new = x + alpha * d; Eigen::VectorXd g_new = numerical_gradient(f, x_new); Eigen::VectorXd s = x_new - x; Eigen::VectorXd y = g_new - g; if (s.dot(y) < 1e-12) { // 曲率条件不满足,重置近似逆 Hessian H = Eigen::MatrixXd::Identity(n, n); } else { double rho = 1.0 / y.dot(s); Eigen::MatrixXd I = Eigen::MatrixXd::Identity(n, n); Eigen::MatrixXd J = I - rho * s * y.transpose(); H = J * H * J.transpose() + rho * s * s.transpose(); } x = x_new; g = g_new; } return x; }gtol 表示梯度范数的绝对容忍度。如果业务方要求更精确的解,我一般调小到 1e-7,同时把 max_iter 提到 1000,但要注意数值梯度在接近最优点时本身误差较大,梯度范数到 1e-7 已经接近双精度浮点的极限。代码里的s.dot(y)是曲率条件的核心:如果它接近 0,说明线搜索质量差,继续更新 H 只会污染后续迭代,此时重置单位阵更安全。
4.4 收敛判据:看梯度还是看损失变化
实际项目中我同时看两个指标:梯度范数和目标函数相对变化。梯度范数反映“当前点离驻点有多远”,目标函数相对变化反映“继续迭代还有多少收益”。两者取一个到就停,能省不少时间。如果目标函数在两次迭代之间变化小于 1e-8,但梯度范数依然很大,通常说明数值差分或者数据本身存在噪声,继续迭代意义不大。参数建议:gtol=1e-5,损失变化阈值 1e-8,max_iter 控制在 200 到 500 之间。超过 500 次迭代还没收敛,先怀疑目标函数或者初值,而不是盲目加迭代上限。
5. 避坑日志:Eigen 与 C++11 下最常见的五个坑
5.1 固定大小矩阵放在栈上,一个不留神进程直接退出
现象:程序运行到某个函数内部直接段错误,没有任何异常信息。用调试器看调用栈,发现停在一次简单的矩阵赋值上。
原因:代码在函数内声明了类似Eigen::Matrix<double, 10000, 10000> A;的固定大小矩阵。10000×10000 的 double 矩阵需要 800MB 内存,而栈空间通常只有 8MB 左右,栈直接爆掉。
解决:矩阵维度在运行前无法确定时,一律用Eigen::MatrixXd而不是编译期固定大小的模板参数。需要作为类成员长期缓存的矩阵,也用动态大小。Eigen 的固定大小矩阵只是为了短小矩阵消除堆分配,不是用来承载大数据的。
// 错误做法:方法内部临时固定大矩阵 // Eigen::Matrix<double, 10000, 10000> tmp; // 正确做法:动态大小,数据放在堆上 Eigen::MatrixXd tmp(10000, 10000);5.2 稠密三对角矩阵当稠密阵求解,插值一卡就是半天
现象:插值节点数量上万后,fit 函数的耗时从几十毫秒涨到几十秒,内存占用也涨得离谱。
原因:把三对角矩阵存成了Eigen::MatrixXd,用FullPivLU求解。稠密 LU 的复杂度是 O(n³),n=10000 时运算量是万亿级别,不慢才怪。
解决:三对角或带状矩阵优先用Eigen::SparseMatrix配合SparseLU或SimplicialLDLT。第 2 章的样条代码就是标准做法。如果不想引Eigen/Sparse,也可以手写托马斯算法,但工程上直接用稀疏求解器更省事,且代码更接近数学定义。这个坑是最隐蔽的,因为 n 在几千以下时稠密求解也能跑完,只有数据规模上来才暴露。
5.3 优化结果每次差一点:浮点非确定性与线程数
现象:同一条数据、同一个初值,连续跑两次 BFGS,得到的参数最后几位不一样,偶尔相差到 1e-4。
原因:Eigen 的某些归约操作在多线程下会改变浮点加法顺序,而浮点加法不满足结合律。加上优化终止阈值是绝对阈值,几次细微的迭代路径差异就可能导致提前或推后终止。
解决:让算法行为可复现。在程序入口处固定 Eigen 线程数;把收敛判据改成相对判据;在回归测试里记录“求解器类型 + 线搜索参数 + 收敛阈值”,而不是只记录最终结果。
Eigen::setNbThreads(1); // 固定单线程,保证浮点求和顺序稳定5.4 Debug 与 Release 性能天差地别:这不是 Eigen 的锅
现象:同一套插值代码,Debug 构建慢 20 倍,Release 构建快到几乎测不出耗时。有人开始怀疑 Eigen 表达式模板有性能问题。
原因:Eigen 大量使用表达式模板,依赖编译器内联和向量化。Debug 构建通常关闭优化,表达式模板会变成大量临时对象的构造和赋值;加之 Eigen 默认开启边界检查断言,每个矩阵访问都在检查索引。
解决:性能敏感的构建必须开优化。Release 下加-O2或-O3,并且只在发布构建里定义EIGEN_NO_DEBUG。Debug 构建里不要跑大数据量回归,否则你会误判自己的算法瓶颈。
# Release 构建示例 c++ -O3 -march=native -DEIGEN_NO_DEBUG -std=c++11 main.cpp -o toolbox5.5 LDA 广义特征求解报“矩阵不正定”
现象:调用GeneralizedSelfAdjointEigenSolver时抛异常,或者返回的投影方向全是 NaN。
原因:LDA 里的类内散度矩阵 Sw 在样本数小于特征维度时一定奇异。广义特征求解器要求 Sw 正定,Sw 奇异时算法直接失败。
解决:对 Sw 加上一个小的单位阵倍数,正则化到正定。这个操作也等价于给投影方向加了一点噪声抑制,通常不会明显损失分类效果。
Sw += reg * Eigen::MatrixXd::Identity(Sw.rows(), Sw.cols());reg 建议从 1e-6 起调。如果加了 1e-6 还是 NaN,把 reg 涨到 1e-4;如果涨到 1e-2 才稳定,说明数据本身类内方差差异太大,考虑先用 PCA 降维再跑 LDA。
6. 把工具链锁死在自己工程里:回归、容差、发布配置
工具箱能跑通只是第一步,真正的麻烦在于“你改了一行插值代码,怎么知道其他模块没受影响”。我的习惯是给三种核心算法各留一个固定输入的回归用例,每次改完代码都跑一遍,结果和基线比较。插值用例用固定节点表和固定查询点,降维用例用固定协方差矩阵,优化用例用固定的初值和目标函数。三个用例的运行时间加起来不超过一秒钟。
回归误差的判定也统一:插值比较拟合值之间的最大绝对误差;PCA 比较重建误差;优化比较目标函数终值和参数终值。误差小于 1e-6 就认为改动不影响行为,超过 1e-4 就需要人工检查。数值代码对误差非常敏感,我一般不设 1e-9 这种过高的门槛,否则每次重构都会被浮点噪声干扰。
// 回归判定示例:PCA 重建误差 Eigen::MatrixXd recon = pca_result.scores * pca_result.loadings.transpose() + mean_vec.transpose(); double rel_err = (recon - X).norm() / X.norm(); bool pca_regression_ok = rel_err < 1e-10;发布侧的配置也要固定。我在每个算法模块的 Config 结构里都放三个公共字段:随机种子、线程数、容差。线程数默认 1,需要提速时再开放到外部配置;容差区分“计算级”和“工程级”,计算级用于回归验证,工程级用于实际业务输出。EIGEN_NO_DEBUG 只放在 Release 构建脚本里,Debug 构建保留断言,这样开发阶段能发现问题,发布阶段跑满性能。
从那以后,我每次改这套工具箱都先跑一遍那三个回归用例,再动算法代码。数值库最怕的从来不是慢,而是“你感觉应该没问题,结果线上结果悄悄变了”。希望帮到你。
本文还有配套的精品资源,点击获取