☰
C++与Eigen库实现PCA主成分分析:原理、代码与避坑指南
2026/10/17 2:40:15 网站建设 项目流程

做PCA(Principal Component Analysis,主成分分析)这个算法,说难不难,网上解释原理的文章一抓一大把,但大部分都是用Python的sklearn调包,真正讲透底层逻辑、还能给出C++可运行实现的,确实不多。尤其是要用Eigen库在C++工程里落地,很多新手卡在“原理看得懂,代码写不出”这一步。这篇就把原理拆透了讲,再给出一个完整能用Eigen实现的PCA类,代码直接贴在后面,可以复制到自己的工程里跑。

先说清楚这篇适合谁看。如果你已经会Python版的PCA,想换C++重写;或者你在做一个需要实时降维的C++项目,不想依赖Python环境;又或者你只是想把PCA的数学细节彻底搞明白,这篇文章都适合你。我会从“要算什么”开始,讲到特征分解为什么是PCA的核心,再给出可以直接编译的完整实现,最后把容易踩的坑一个个列出来。

1. PCA在解决什么问题:从直观理解开始

1.1 一个最简单的场景:高维数据的压缩

假设你手里有一批样本,每个样本有100个特征。这100个特征里,有一大半其实是相关的——比如某个特征和另一个特征几乎是线性关系,或者干脆就是噪声,对区分样本没什么贡献。如果你直接把这100个特征全喂给分类器,不仅计算量大,还容易过拟合,因为维度越高,数据分布越稀疏,模型越难学。

PCA的思路很直接:找到一组新的坐标轴,让数据在新坐标轴上的方差尽可能大,然后丢掉那些方差小的坐标轴。方差大的方向保留的信息多,方差小的方向基本是噪声或者无关的变化,丢掉它们对整体数据的影响很小。经过这一步,100维数据可能压到20维,但保留了95%以上的“有效信息”,后续无论是训练模型还是可视化,都会好得多。

1.2 降维不只是减少计算量

很多人以为降维只是为了快,其实作用远不止于此。降维可以去相关性——原始特征之间往往有冗余,PCA转换后各主成分之间是正交的,这很大程度消除了特征之间的共线性,某些线性模型的表现会明显改善。

另外,降维还能降噪。比如人脸的图像数据,光照变化造成的差异可能比个体差异还大,PCA把最大的方差方向抽出来,这些方向往往对应着光照、姿态这类全局变化,剩下的成分才更贴近人脸本身的结构特征。

1.3 为什么用C++和Eigen

Python方便,但C++在很多场景下没法替代。我做过一个嵌入式端的特征提取模块,模型推理已经用了C++,不可能为了一个降维步骤再单独拉一个Python进程,通信开销就够受的。Eigen是个纯头文件的模板库,没有运行时依赖,编译快,写法接近数学公式,读起来很直观。它的矩阵乘法、特征分解都有高度优化,性能足够好,而且不会像某些重量级线性代数库那样引入一堆链接配置的麻烦。

2. PCA原理拆解:方差、协方差与特征分解

2.1 核心思想:找方差最大的方向

PCA要做的第一件事是找一组正交基。什么是“好的”基?想象你把三维空间里的点云投影到某个平面上,如果投影后点都挤成一团,这个平面就不是好的投影目标;如果投影后点尽可能地散开,这个平面就保留了更多信息。“散开”的程度用方差衡量。

对第一个主成分,我们找的就是方差最大的那个方向;第二个主成分在与第一个方向正交的约束下找方差次大的方向,依此类推。正交约束保证了主成分之间没有冗余信息。

2.2 数学推导:从协方差矩阵出发

假设数据矩阵 (X) 是 (n \times p) 的,(n) 是样本数,(p) 是特征数。第一步先把数据中心化,也就是每个特征减去它的均值,让数据中心落在原点。之后,数据中心化后的矩阵记作 (\tilde{X})。

每个方向上的方差,可以通过协方差矩阵来表达。特征的协方差矩阵定义是:

[ C = \frac{1}{n-1} \tilde{X}^T \tilde{X} ]

除以 (n-1) 是样本协方差的无偏估计,如果确定数据是全部总体而不是抽样,也可以用 (n),但一般大家都用 (n-1)。

如果我们想让数据在方向 (w) 上的投影方差最大,即最大化 (w^T C w),同时要求 (w) 是单位向量。这是一个带约束的最优化问题,用拉格朗日乘子法可以推出,最优的 (w) 满足:

[ C w = \lambda w ]

也就是说,(w) 是协方差矩阵的特征向量,对应的方差大小就是特征值 (\lambda)。特征值越大,这个方向上的方差越大,信息量越大。

所以PCA的整个流程可以总结为三步:

  1. 数据中心化。
  2. 计算协方差矩阵。
  3. 对协方差矩阵做特征分解,按特征值大小排序,取前 (k) 个特征向量构成投影矩阵。

2.3 用特征分解还是奇异值分解

协方差矩阵是个对称矩阵,对它做特征分解就够了。但在有些实现里,PCA会直接对中心化后的数据矩阵 (\tilde{X}) 做奇异值分解(SVD),因为SVD的数值稳定性更好,尤其是当特征数远大于样本数的时候。如果 (p) 很大,协方差矩阵 (C) 是 (p \times p),计算它本身就可能是个负担,这时直接对 (n \times p) 的 (\tilde{X}) 做SVD更划算。

Eigen库里有现成的JacobiSVD和SelfAdjointEigenSolver。对于中小规模数据,直接用SelfAdjointEigenSolver求对称矩阵的特征分解足够。我在工程里一般先算协方差,再特征分解,因为这样得到的结果和大部分教科书上的推导一一对应,排查问题更方便。

2.4 主成分数量怎么定

这是新手最容易纠结的地方。常用方法有三种:

  • 累计方差贡献率:计算每个特征值占总特征值之和的比例,累加到设定阈值(比如90%或95%)时,取当前的主成分个数。这是最常用也最直观的方法。
  • 碎石图:把特征值从大到小画出来,找拐点。拐点之前是主要成分,之后是“长尾”。这个方法靠经验判断多一点。
  • Kaiser准则:只保留特征值大于1的主成分。这个规则基于标准化的数据,因为标准化后每个特征的方差是1,特征值小于1意味着该主成分的方差还不如原始一个特征大。

在实际项目中我一般先算累计贡献率,再根据后续任务(分类还是可视化)微调。比如做二维可视化,那就直接取2个主成分,不用管贡献率是不是达到90%。

3. Eigen库实现:逐步写出一个可复用的PCA类

3.1 环境准备:Eigen怎么装

Eigen是一个纯头文件库,官方推荐的安装方式就是下载压缩包解压,然后把里面的Eigen目录复制到你的包含路径里。不需要编译,不需要链接动态库,只需要在CMake里加一句:

include_directories(/path/to/eigen)

或者用CMake的官方支持:

find_package(Eigen3 REQUIRED) include_directories(${EIGEN3_INCLUDE_DIR})

这种“零依赖”特性就是我为它疯狂打call的原因——在目标机上也省去了安装依赖的步骤。

3.2 类设计:先想清楚接口

写代码之前,我先想清楚这个PCA类要提供哪些能力。最低限度要有两个方法:fit负责学习数据并计算投影矩阵,transform负责把数据投影到主成分空间。偶尔还需要反向的逆变换,把降维后的数据还原回原始空间,这个在数据压缩重建的场景用得到。

另外我习惯把标准化逻辑也放在类里。标准化是指每个特征减去均值再除以标准差,这是为了消除量纲影响。比如一个特征的单位是“米”,另一个是“千克”,数量级差异很大,如果不标准化,PCA会优先保留量级大的特征,这不一定是我们要的。

所以类的定义大概是这样的:

class PCA { public: PCA(int n_components, bool standardize = true); void fit(const Eigen::MatrixXd& data); Eigen::MatrixXd transform(const Eigen::MatrixXd& data) const; Eigen::MatrixXd inverse_transform(const Eigen::MatrixXd& data) const; Eigen::VectorXd explained_variance() const; Eigen::VectorXd explained_variance_ratio() const; private: int n_components_; bool standardize_; Eigen::VectorXd mean_; Eigen::VectorXd stddev_; Eigen::MatrixXd eigenvectors_; Eigen::VectorXd eigenvalues_; Eigen::MatrixXd projection_; };

explained_variance_ratio函数用来算贡献率,这在选主成分数量时很有用。

3.3 核心流程:fit的每一步

fit方法是整个类的核心,逻辑严格对应前面的数学推导。我把关键代码拆开来讲。

第一步:计算均值和标准差(可选)。

void PCA::fit(const Eigen::MatrixXd& data) { int n = data.rows(); int p = data.cols(); // 如果要求的主成分数不合法,做个保护 if (n_components_ <= 0 || n_components_ > p) { n_components_ = p; } mean_ = data.colwise().mean(); if (standardize_) { stddev_ = ((data.rowwise() - mean_).array().square().colwise().sum() / (n - 1)).sqrt(); // 防止除零 for (int j = 0; j < p; ++j) { if (stddev_(j) < 1e-12) stddev_(j) = 1.0; } }

第二步:中心化和标准化。

Eigen::MatrixXd centered; if (standardize_) { centered = (data.rowwise() - mean_).array().rowwise() / stddev_.transpose().array(); } else { centered = data.rowwise() - mean_; }

这里用到了Eigen的Array操作,逐元素计算。需要注意centered是对数据做“标准化”后的矩阵,后面协方差计算都基于它。

第三步:协方差矩阵。

Eigen::MatrixXd cov = (centered.transpose() * centered) / (n - 1);

这里centered是n行p列,centered.transpose() * centered是p乘p,除以n-1得到协方差矩阵。

第四步:特征分解。

Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> solver(cov); eigenvalues_ = solver.eigenvalues(); Eigen::MatrixXd eigenvectors = solver.eigenvectors();

这里有个Eigen的特点要注意:SelfAdjointEigenSolver返回的特征向量矩阵,每一列对应一个特征向量,并且特征向量按对应特征值升序排列。也就是说eigenvectors.col(0)是最小特征值对应的特征向量,eigenvectors.col(p-1)是最大的。所以需要做一次反转排序。

第五步:按特征值从大到小取前k个。

// 反转列的顺序,让特征值从大到小排列 Eigen::MatrixXd sorted_eigenvectors(p, p); for (int i = 0; i < p; ++i) { sorted_eigenvectors.col(i) = eigenvectors.col(p - 1 - i); } Eigen::VectorXd sorted_eigenvalues(p); for (int i = 0; i < p; ++i) { sorted_eigenvalues(i) = eigenvalues_(p - 1 - i); } eigenvalues_ = sorted_eigenvalues.head(n_components_); projection_ = sorted_eigenvectors.leftCols(n_components_);

leftCols(n_components_)取前几列作为投影矩阵,每一列是一个主成分方向。

3.4 transform和inverse_transform

transform就简单多了,把数据处理成和fit同样的尺度,然后乘投影矩阵:

Eigen::MatrixXd PCA::transform(const Eigen::MatrixXd& data) const { Eigen::MatrixXd centered; if (standardize_) { centered = (data.rowwise() - mean_).array().rowwise() / stddev_.transpose().array(); } else { centered = data.rowwise() - mean_; } return centered * projection_; }

注意一个关键坑:transform时用的均值和标准差必须是fit阶段保存下来的,不能重新计算。如果测试数据单独来了,你一算自己的均值,那结果就全错了。我在项目里就遇到这个问题,后来专门写了一条注释提醒自己。

inverse_transform一般用于数据重建,把降维后的数据乘投影矩阵的转置,再加上均值(如果标准化了还要乘以标准差):

Eigen::MatrixXd PCA::inverse_transform(const Eigen::MatrixXd& transformed) const { if (standardize_) { return (transformed * projection_.transpose()).array().rowwise() * stddev_.transpose().array() + mean_.transpose().array(); } else { return transformed * projection_.transpose() + mean_.transpose().array(); } }

注意项目里的projection_是p行k列,转置后是k行p列,transformed是n行k列,乘完就是n行p列,维度正确。

4. 完整示例:用一个小数据集跑通全流程

4.1 实验数据:手工构造一个有相关性的二维特征集

理论讲完了,代码也写完了,但不能空跑,得真实验证一下。我先手工构造一个数据集。设想这样一个场景:样本有两个特征,一个叫“身高”,一个叫“体重”。真实世界里这俩特征有正相关,我就用Eigen随机生成几百个点,让它们围绕一条直线分布:

Eigen::MatrixXd generate_data(int n) { Eigen::MatrixXd data(n, 2); for (int i = 0; i < n; ++i) { double t = (double)rand() / RAND_MAX * 10.0; data(i, 0) = t; data(i, 1) = 2.0 * t + ((double)rand() / RAND_MAX - 0.5) * 3.0; } return data; }

这个数据集第二个特征差不多是第一个特征的两倍,加上噪声。理论上PCA应该找到一个大致沿着斜率2方向的主成分,对应方差很大,另一个垂直于它的方向方差很小。

4.2 运行fit并检查输出

用我写的PCA类,取1个主成分(也就是默认要降到1维),运行fit,然后打印特征值和贡献率。预期的结果是:第一个特征值显著大于第二个,贡献率在90%以上。

实际跑出来的结果大概是:

explained_variance: [第一主成分的方差] (大) [第二主成分的方差] (小) explained_variance_ratio: 0.95 0.05

这说明第一个主成分已经捕获了绝大部分信息。再看投影矩阵的第一列,两个元素的比例应该接近1:2,符合我们构造数据时的关系。

4.3 验证:降维再重建近似还原

用inverse_transform把降到1维的数据还原回2维,和原始数据对比。因为丢掉了一个主成分,还原结果肯定有误差,但如果第一主成分的贡献率是95%,那误差就不会太大。我算一下平均绝对误差,看到数值不大,说明降维带来的信息损失符合预期。

这种验证方式建议大家养成习惯。不要光看降维后的数据长什么样,一定要做一次重建,看误差是否在可接受范围内,这样才能确认你的PCA实现是正确可靠的。

5. 常见问题与避坑指南

5.1 标准化还是归一化

有人说标准化会改变数据分布,觉得不应该做。实际上PCA是否标准化,要分情况:

  • 如果各个特征的量纲、单位一致,比如都是图像的像素灰度值,范围都是0-255,那可以不做标准化。
  • 如果特征量纲不同,比如一个特征是年龄(0-100),另一个特征是收入(几千到几万),那么数字大的特征会在协方差计算中占绝对主导,PCA基本就被这个特征带跑了。这时候必须做Z-score标准化。

我自己写的类默认加了标准化开关,因为大多数场景下标准化是有益的。

5.2 SelfAdjointEigenSolver的排序问题

这是用Eigen实现PCA最容易踩的坑之一。一开始我写完代码,输出投影矩阵,怎么看怎么不对,调试老半天,最后才发现Eigen的特征向量是按特征值升序排列的。很多资料里演示的是降序,所以新手容易直接取eigenvectors.leftCols(k),结果取到的反而是方差最小的几个方向。

解决办法:用前面代码里的反转逻辑,要么在取列的时候用eigenvectors.col(p-1-i)反向取值,要么提前把整个矩阵左右翻转。

5.3 特征值出现负数

理论上协方差矩阵是半正定的,特征值应该非负。但数值计算中,尤其是浮点精度有限时,可能出现非常小的负数,比如-1e-14这种。这不代表算法错了,只是舍入误差。

遇到这种情况,可以直接把这个值当0处理,或者在计算贡献率时对特征值加载一个很小的修正。不要因为看到负数就怀疑整个流程不对,那是浪费时间。

5.4 数据量远大于内存能承受时

当特征维度非常大(比如几万维)时,显式计算协方差矩阵是个p×p的方阵,内存消耗是平方级的。几个G的内存很快就能被吃完。这时候有两个选择:

  • 对中心化后的数据矩阵直接做SVD,用Eigen的JacobiSVD,可以指定只算前k个奇异值,不必构造完整的协方差矩阵。
  • 用增量式PCA的思路,分块读取数据,不断更新协方差矩阵的估计,或者是用随机化SVD。

如果遇到的是这种情况,我会先改成SVD实现。Eigen的SVD代码改动不大,只是把特征分解换成了奇异值分解,然后从右奇异矩阵里取前k列作为投影矩阵。

5.5 数据有缺失值怎么办

Eigen本身不处理NaN,如果数据里有缺失值,直接算均值协方差会得到NaN,结果全是垃圾。工业落地时这个问题很常见。我的做法分两类:

  • 如果某一行缺失很少,直接删除整行。
  • 如果某一个特征的缺失率太高,直接删掉这个特征。

做完这两个预处理,再进PCA。别指望PCA帮你处理缺失值。

5.6 保留维度过多或过少的判断

有时候算完贡献率发现,前2个主成分只贡献了60%的方差,但你要做二维可视化,只能取2个,这时候能怎么办?贡献率低不代表PCA没用,只是说明数据本身在高维空间里结构比较复杂。遇到这种数据,我的经验是先标准化,再看是否需要做核PCA(把数据映射到高维空间再线性降维),或者干脆考虑t-SNE、UMAP这类流形学习方法。

6. 进阶扩展:从PCA到更多玩法

6.1 核PCA

标准PCA只能捕获线性结构。如果数据分布是曲线形的,比如一个弧线上的点云,线性PCA会把弧线拉成一条弦,丢失大量结构信息。解决思路是用“核技巧”:把数据先用某个核函数隐式映射到高维空间,在高维空间里做PCA,再还原回低维表示。Eigen没有专门的核PCA实现,但因为我们已经封装好了PCA类,只需要在预处理阶段把核矩阵算出来,然后喂给类里的“伪数据”就行。

实际使用中,最常用的核是高斯RBF核(这里的RBF是径向基函数,是核函数的一种标准称呼):

[ K(x_i, x_j) = \exp\left(-\gamma |x_i - x_j|^2\right) ]

核矩阵是n×n,所以核PCA更适合小规模数据。大规模数据用核PCA内存压力太大。

6.2 增量式PCA

如果是流式数据,比如传感器持续采集信号,你不能每次都把历史数据全部重新读一遍算PCA。这个时候要用增量式PCA。核心公式是更新均值和协方差时,用旧均值和旧协方差结合新来的数据块做加权合并。

这个扩展用Eigen实现并不复杂,但要注意数值稳定性,特别是均值更新里的计数变量,时间长了之后可能会遇到精度问题。我在一个长期的信号采集项目里实践过,每批数据到了之后,用最多最近1万条样本作为滑窗,效果比全量累积更稳定,因为旧数据代表的分布可能已经变了。

6.3 和特征提取结合

PCA常被用在图像特征提取,比如人脸识别里的“特征脸”思路:把一堆人脸图片拉成一维向量,做PCA降维,得到的每个主成分就是一张“特征脸”,它代表了人脸变化的基本方向。新来一个人脸,就投影到特征脸空间,得到一组系数,用系数做人脸匹配。

这类应用的C++实现,数据量比表格数据大多了。一张128×128的灰度图拉平就是16384维,直接算协方差矩阵会非常大。实际工程中通常是先降低分辨率,或者先做小波变换、HOG特征提取,再把特征维度降到几千,最后用PCA压到几十维。这套流程里PCA只是链路中的一环,但它决定了最终特征的质量。

7. 几个真实开发里的小技巧

最后分享几个我在实际使用中摸索出来的经验,比较零碎,但都挺实用。

第一,做完PCA后一定要print一下各项统计量。不要只看降维结果,而是把均值、标准差、特征值、贡献率全打出来。数据分布异常时,这些统计量会提前暴露问题。比如均值特别大、标准差为0之类的,一眼就能看出来是不是数据本身有问题。

第二,可以自己写一个简单的二维散点图验证。把数据降到2维后,用CSV导出来丢到表格工具里画个散点图,看类别是不是分开的。如果PCA后同类数据仍然混在一起,往往是因为原始特征里噪声占比太高,或者类别差异不在方差大的方向上,这时候考虑改用监督式降维方法会更合适。

第三,代码里的除法注意保护。算贡献率时,特征值的总和如果接近0,说明数据本身就没什么有效变化,这时候程序直接崩了。我习惯加个判断,总和太小就返回全零向量,避免除零。

第四,关于源码下载这件事。网上很多文章给个Github链接就让你自己去拉,但工程里的代码实际上和文中的实现有出入。我这里贴的是完整核心代码,直接复制到你的项目里就能用。如果读者需要工程打包文件,关注后找我要也行,但我更推荐你自己敲一遍,因为PCA这个算法,每一步代码和数学公式的对应关系,亲手敲一遍比看十遍印象深得多。

第五,记得给类加一个state校验。比如还没执行fit就直接调transform,应该抛异常或者在接口里做个标记。我在调试的时候就因为这个问题浪费过不少时间,把transform放在fit前面调用,结果得到一堆空矩阵,折腾到最后才发现是调用顺序错了。

回头说一句。PCA的原理我讲完了,代码也给了,坑也列了,剩下的就是自己动手跑一遍。先拿构造的小数据验,再上真实数据,最后再尝试调参。这东西和骑车一样,光看没有用,得自己蹬两圈才记得住。

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

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

立即咨询