ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

主成分分析MATLAB源代码:带注释可运行的降维方案

主成分分析MATLAB源代码:带注释可运行的降维方案

简介:数据降维是机器学习和数据分析中常用的预处理手段,主成分分析(PCA)通过协方差矩阵的特征值分解,将高维数据映射到方差最大的低维空间,在保留核心信息的同时消除特征间的相关性。对于使用MATLAB的工程师和学生而言,理解PCA的原理与实现细节,远比直接调用内置函数更重要。本文从标准化、特征值分解到贡献率计算层层拆解,结合一份可直接运行的带注释MATLAB源码和模拟例题数据,帮助你清晰掌握每个步骤的数学含义与代码逻辑。同时介绍特征向量符号统一、重构误差验证等避坑技巧,并展示载荷图与双标图的可视化方法,适用于论文写作、特征压缩和数据可视化等场景。

1. 主成分分析MATLAB源代码:能直接跑通、能看懂每行含义的降维方案

如果你在找“主成分分析matlab源代码(带注释,带例题数据)”,大概率不是想看PCA的数学推导,而是手里有一批多维数据,想降维做可视化、去相关性、压缩特征,或者写论文时需要一张贡献率图和一套可复现的处理流程。这套代码的价值不在于“实现了PCA”,而在于把协方差矩阵、特征值分解、投影矩阵、贡献率计算这些环节拆成带注释的MATLAB脚本,并配上一份可以直接喂进去跑的例题数据——拿到手就能看到每一行在算什么,改哪一行就能换自己的数据。

我见过太多人卡在同一个地方:用MATLAB内置的pca函数三行跑完,但说不清输出的score、coeff、latent分别代表什么,也不知道为什么要对数据先做标准化。这套源代码就是用来解决这个问题的——它不追求极简,而是把每个中间量都打印出来、写在注释里。适合两类人:一是课程作业或论文需要给出计算过程的学生,二是做数据分析时想对PCA结果有完全控制权的工程师,而不是单纯拿黑匣子输出。

下面从原理落地、代码结构、例题数据、避坑、验证到可视化技巧,一层层把这条路径走完。

2. PCA在MATLAB中的落地:为什么先标准化、为什么用特征值分解

这一章不是讲数学推导,而是回答你在写代码时必须做的三个决定:数据要不要标准化、用eig还是svd、降成几维。这三个决定直接决定你的代码能不能复现、结果能不能解释。

2.1 标准化不是可选项:量纲差异会直接扭曲主方向

PCA的核心目标是找到方差最大的投影方向。问题在于,“方差”这个概念对量纲敏感。举个例子,如果一份数据里包含身高(单位厘米,数值在160-190)和体重(单位千克,数值在50-90),身高的方差天然比体重大很多,PCA算出来的第一主成分会被身高的数值尺度主导,而不是被数据的真实结构主导。这不是PCA的缺陷,而是输入数据没有标准化时的必然结果。

所以,凡是你看到一套PCA代码里没有做标准化这一步,只对原始数据直接算协方差矩阵,那这套代码处理量纲差异大的数据时基本是翻车的。正确的做法是:先对每列数据减去均值、除以标准差,把每一维变成零均值、单位方差的标准化数据,再在这个基础上计算协方差矩阵。标准化之后,协方差矩阵就变成了相关系数矩阵,每个特征对主方向的贡献才是等权重的。

% 标准化数据:每列减去均值,除以标准差 mu = mean(X); % 1 x p 均值向量 sigma = std(X); % 1 x p 标准差向量 X_std = (X - mu) ./ sigma; % n x p 标准化后的数据

代码逻辑说明:X是原始数据矩阵,每行一个样本,每列一个特征。mean(X)得到每个特征的均值,std(X)得到每个特征的标准差,两者做逐元素运算后,X_std的每一列都是均值为0、标准差为1的标准化数据。参数说明:这里的std默认使用N-1作为分母,对应样本标准差;如果你的数据是总体数据而非抽样样本,可以用std(X, 1)改用N作为分母,这会影响后续特征值的绝对大小,但不会改变主成分的方向。我一般保持默认的N-1,因为大部分场景下数据都是样本。

2.2 eig和svd的选择:结果等价,但代码逻辑不同

计算主成分有两条路:直接对协方差矩阵做特征值分解(eig),或者对数据矩阵本身做奇异值分解(svd)。两条路在数学上等价,但代码写起来差别很大。简单说:eig的路径更直观,适合教学和调试,因为协方差矩阵是实实在在的p×p方阵,特征值就是各主成分解释的方差,直接对应贡献率;svd的路径更数值稳定,适合高维数据,但需要多一步把奇异值和特征值做换算。

这套带注释的源代码用的是eig路径,原因很简单:每一行代码都能对应到一个数学概念,协方差矩阵、特征向量、特征值、投影,读者调试时心里有数。如果你数据维度很高(比如p > 1000),我建议自己改成svd路径,否则eig在极端情况下的数值稳定性会让你想摔键盘。

% 计算协方差矩阵 C = cov(X_std); % p x p 协方差矩阵 % 特征值分解 [eigvec, eigval] = eig(C); % eigvec的每列是一个特征向量,eigval对角线为特征值 % 提取对角线特征值并按降序排序 latent = diag(eigval); [latent_sorted, idx] = sort(latent, 'descend'); eigvec_sorted = eigvec(:, idx);

代码逻辑说明:cov(X_std)算的是标准化数据的协方差矩阵,由于数据已标准化,这个矩阵实际上就是相关系数矩阵。eig(C)返回两个矩阵,eigvec的每一列是一个特征向量,eigval是一个对角矩阵、对角线上的元素就是特征值。注意,eig返回的特征值不保证按大小排序,所以需要sort做一个降序排列,同时把特征向量的列顺序也对应调整,否则后面投影时主成分的顺序是乱的。参数说明:sort的第二个参数'descend'表示降序,idx保留了原始列位置到排序后位置的映射关系,这一步遗漏的话,贡献率图和投影坐标全都会对不上。

2.3 降维维度怎么定:累计贡献率还是业务解释力

代码写完之后,下一个实际问题就是保留几个主成分。最常见的做法是看累计贡献率,一般保留到累计贡献率达到80%或85%。但纯看百分比有个坑:如果你的业务场景需要后续做回归、聚类,或者你需要在某个主成分上做物理意义的解读,那么即使第3个主成分贡献率只有10%,只要它和某个业务标签有强相关性,你也应该把它保留下来。

% 计算每个主成分的贡献率和累计贡献率 explained = latent_sorted / sum(latent_sorted) * 100; % 各主成分贡献率(%) cum_explained = cumsum(explained); % 累计贡献率(%)

代码逻辑说明:因为特征值的大小直接代表该方向解释的方差大小,所以把每个特征值除以特征值总和,乘以100就是百分比贡献率。cumsum做的是累计求和,得到第1个主成分到前k个主成分的累计贡献率。参数说明:这里没有手动指定保留几个,而是把贡献率全部算出来,后续可以自己设定阈值去截取。注意,特征值总和等于标准化后数据的总体方差(等于p,因为每个特征标准化后方差为1),所以sum(latent_sorted)实际上等于p,这个关系可以用来验证你的标准化和分解步骤是否正确。

3. 带注释的MATLAB源代码:完整脚本、调用方式与例题数据说明

到了这一章,直接给出一份能跑通的完整代码。这份代码的设计原则是:每个中间结果都有变量输出,每个关键步骤有注释说明为什么这样做,而不是只注释“这一步计算协方差矩阵”这种废话。例题数据放在代码下方,可以直接复制进MATLAB跑。

3.1 主函数脚本:从数据读入到投影输出的完整流程

%% 主成分分析完整实现(带注释) % 输入:X为n行p列的数据矩阵,每行一个样本,每列一个特征 % 输出:score为投影后的主成分得分,coeff为特征向量矩阵,latent为特征值 function [score, coeff, latent, explained] = my_pca(X, num_components) % 步骤1:数据标准化 % 为什么:PCA对量纲敏感,标准化可以消除不同特征的尺度差异 mu = mean(X); sigma = std(X); X_std = (X - mu) ./ sigma; % 步骤2:计算协方差矩阵 % 为什么:标准化后协方差矩阵等于相关系数矩阵,体现了特征间的线性相关性 C = cov(X_std); % 步骤3:特征值分解 % 为什么:特征向量给出主方向,特征值给出该方向解释的方差大小 [eigvec, eigval] = eig(C); latent = diag(eigval); % 步骤4:按特征值降序排列 % 为什么:eig函数不保证顺序,必须先排序才能按重要性取前k个 [latent_sorted, idx] = sort(latent, 'descend'); coeff = eigvec(:, idx); % 步骤5:计算贡献率 explained = latent_sorted / sum(latent_sorted) * 100; % 步骤6:投影到主成分空间 % score的每一列是一个主成分,每一行对应一个样本在新坐标系下的坐标 score = X_std * coeff; % 步骤7:按指定维度截取 if nargin < 2 num_components = size(X, 2); end score = score(:, 1:num_components); coeff = coeff(:, 1:num_components); end

代码逻辑说明:这份代码把PCA拆成了7个步骤,每个步骤的变量都可以在MATLAB工作区里检查。score的维度是n×k,coeff的维度是p×k,其中k是保留的主成分个数。注意步骤6用的是矩阵乘法X_std * coeff,不是逐元素乘法,这个计算实际是把每个样本向量投影到特征向量张成的子空间。参数说明:num_components控制输出维度,默认保留全部分量。在调用时,如果你只想要前两维做可视化,就传2,比如[score, coeff, latent, explained] = my_pca(X, 2)。

3.2 例题数据:一组带业务含义的模拟数据及调用示例

例题数据我常用一份模拟的“学生考试成绩与学习行为”数据,它包含15个样本、4个特征:课堂出勤率、课后练习完成度、实验操作得分、期末笔试成绩。这份数据的特点是第一维和第四维高度正相关,第二维和第三维也有相关性——人造出相关结构,才能看出PCA压缩效果。

%% 例题数据:学生成绩与学习行为(15个样本,4个特征) % 每行:出勤率(%), 练习完成度(%), 实验得分(满分100), 笔试成绩(满分100) X = [... 92 85 78 88; ... 88 90 82 85; ... 95 92 88 94; ... 78 70 65 72; ... 84 88 80 86; ... 98 95 90 96; ... 65 60 58 62; ... 80 82 75 78; ... 90 91 85 92; ... 70 68 62 70; ... 86 84 79 83; ... 75 72 68 71; ... 96 93 87 95; ... 82 86 78 84; ... 60 55 50 58]; %% 调用主函数,保留前2个主成分 [score, coeff, latent, explained] = my_pca(X, 2); %% 输出结果 disp('贡献率(%):'); disp(explained); disp('累计贡献率(%):'); disp(cumsum(explained)); disp('前2个主成分的投影坐标(每行一个样本):'); disp(score);

代码逻辑说明:数据是4列,意味着最多有4个主成分。前2个主成分的累计贡献率通常超过85%,达到降到2维做可视化的条件。调用时传了2,所以score是一个15×2的矩阵,每一行对应一个学生的二维坐标,可以直接用scatter(score(:,1), score(:,2))画散点图观察样本分布。参数说明:X矩阵的分号是行间隔,空格是列间隔,注意保证每行数据个数一致。如果你要用自己的数据,只需要把X替换成你自己的n×p矩阵,不需要改任何后续代码。

4. PCA代码避坑:标准化顺序、特征向量符号、数据维度方向的常见错误

这一章专门写踩坑。PCA的代码看起来短,但实际操作中翻车点极其密集。每一条都是我见过真实发生的问题,按照“现象 → 原因 → 解决”来梳理。

4.1 特征向量符号方向不稳定:两次运行结果镜像翻转

现象:同一份数据,代码一行没改,第一次运行出来第一主成分的系数是正的,第二次运行出来变成了负的,投影坐标的散点图左右镜像翻转。这不是代码bug,但如果你没有意识到这一点,可能会得出完全相反的商业结论。

原因:特征值分解得到的特征向量,其符号方向本来就是不确定的。eig函数返回的特征向量乘以-1仍然是特征向量,特征值不变。MATLAB的线性代数库在某些版本或某些矩阵元素顺序下,可能随机返回符号方向不同的特征向量。

解决:在排序之后、投影之前,加一个符号统一处理逻辑——检查每个特征向量第一个绝对值最大的元素,强制让符号为正。这样无论底层库怎么变,输出结果都是一致的,论文里也能交代清楚。

% 符号统一:确保每个特征向量的最大绝对值分量为正 for i = 1:size(coeff, 2) [~, max_idx] = max(abs(coeff(:, i))); if coeff(max_idx, i) < 0 coeff(:, i) = -coeff(:, i); end end

代码逻辑说明:循环遍历每个特征向量,找到该向量中绝对值最大的那个元素的位置,如果这个元素是负的,就把整个特征向量取反。这样做的依据是:特征向量乘以-1不改变其作为子空间基的性质,但能让结果的符号方向固定下来。参数说明:max_idx是该特征向量中绝对值最大元素的行位置。注意这只影响方向和投影坐标的正负号,不影响特征值和贡献率。

4.2 忘记标准化导致贡献率虚高或主成分失真

现象:有同学直接对原始数据做协方差矩阵,跑出来第一主成分贡献率高达95%以上,看起来效果极好,但画出来的散点图所有的点基本都分布在一条对角线上,毫无结构可言。原因和解决:这就是量纲差异导致的。最大的那个特征列数值范围远大于其他特征,PCA的第一主成分几乎完全被它带走。解决方法是回到2.1节:所有特征都必须先经过标准化,再算协方差矩阵。事后检查技巧:如果第一主成分的特征向量系数里有一个绝对值接近1、其余接近0,那基本可以断定是没做标准化。

4.3 数据矩阵维度方向搞反:每行是样本还是特征

现象:写代码时矩阵维度方向弄反,PCA跑出来的结果看起来合理——贡献率正常、散点图能看,但投影坐标的含义完全错了,每一行对应的不是样本,而是特征。原因:很多人读数据时习惯把每列当作一个样本——尤其是从表格软件复制数据时,列方向更容易贴反。解决:在函数入口加一个维度检查,如果行数大于列数则按样本×特征处理,否则转置。

% 维度检查:确保每个样本占一行,每个特征占一列 if size(X, 1) < size(X, 2) disp('检测到数据维度可能反转,已自动转置'); X = X'; end

代码逻辑说明:这个检查逻辑简单,但极其有效。如果行数比列数少,多半是样本被放到了列上。转置之后继续执行后续代码即可。参数说明:size(X, 1)是行数,size(X, 2)是列数。这个判断适用于通常情况,如果你确实有“样本数少于特征数”的数据,也能落在这个判断里正确执行,因为它已经自动转置了。

4.4 特征值排序后特征向量没同步排序,投影结果错乱

现象:投影出来的散点图第一维方差确实最大,但第二维方差急剧缩小,画出来的图是一条线。原因:很多人排序只用sort(latent)形成了新的特征值数组,但coeff矩阵的列没有跟着同步重排。结果就是投影时用的特征向量顺序和特征值顺序对不上。解决:永远用[sorted_val, idx] = sort(latent, 'descend')这种带第二个输出参数的写法,然后用idx去索引特征向量矩阵。这一点在2.2节的代码里我已经显式处理过,但自己在改代码时极易遗漏。

4.5 用内置pca函数和这套脚本结果不一致时的排查思路

现象:两套代码跑出的score坐标一模一样,但coeff矩阵某些列的符号相反;或者贡献率完全一致,但coeff和score都不同。原因:内置pca函数内部还会对数据做中心化并默认使用SVD路径,数值上和你手写的eig路径会有微小差异;符号方向也不保证和你的统一逻辑一致。解决:对比时不要盯coeff的符号,要盯投影后数据的相对位置。运行一次内置pca,用gscatter画分组散点图,和你的手写代码散点图对比:只要每个样本点的相对位置一致(允许镜像翻转),就说明你的代码正确。如果相对位置不一致,再检查你的标准化和排序步骤。

5. 验证PCA结果正确性的三个方法:特征值之和、重构误差、与内置函数交叉比对

5.1 特征值总和等于数据总方差

PCA一个不太为人注意的数学事实是:协方差矩阵的迹(对角线元素之和)等于所有特征值之和,而标准化后的数据协方差矩阵的迹等于p,p是特征数量。这个关系是内建验证逻辑:如果你计算sum(latent_sorted),得到的结果应该非常接近p,误差来自浮点数精度。如果偏离很大,说明标准化或协方差计算出了问题。

% 验证1:特征值之和是否等于特征数量 fprintf('特征值之和: %.6f\n', sum(latent_sorted)); fprintf('特征数量: %d\n', size(X, 2));

代码逻辑说明:因为标准化使得每个特征的方差为1,总方差就是p。协方差矩阵对角线元素反映各个特征自身的方差,特征值之和等于迹,所以理论上两者相等。参数说明:%.6f是格式化输出,保留6位小数。正常误差应该在1e-10量级,如果出现明显偏差,bug大概率出在标准化步骤。

5.2 用重构误差检查降维损失

降维的本质是信息压缩,保留前k个主成分后再投影回原空间,和原始数据必然有误差。重构误差既能用来验证代码正确性——如果k=p时存在明显误差,说明实现有bug——也能用来帮你寻找合适的k值:随着k增加,重构误差单调下降,拐点处对应最佳维度。

% 重构并计算误差 score_all = X_std * coeff_all; % 使用全部分量投影 X_reconstructed = score_all * coeff_all'; % 投影回原空间 % 注意:重构后是标准化数据的近似,需要反标准化才能和原始X比较 X_recon_orig = X_reconstructed .* sigma + mu; % 逐列乘以标准差再加回均值 reconstruction_error = mean(sum((X - X_recon_orig).^2, 2)); fprintf('全分量重构误差: %.6e\n', reconstruction_error);

代码逻辑说明:score是样本在新空间的坐标,乘以coeff的转置就映射回原特征空间。由于我们做了标准化,映射回来的是标准化空间的值,必须逐列乘以标准差再加回均值才能与原数据对齐。参数说明:coeff_all是完整的特征向量矩阵,不截断。mean(sum(...))是先对每个样本求各维度误差平方和,再对所有样本取平均,得到均方重构误差。如果这个误差在k=p时不是1e-14量级,说明你的投影或反标准化步骤有bug。

5.3 与MATLAB内置pca函数交叉比对

这是最实用也最容易被接受的验证方式。手写代码总是对还是错?和MATLAB官方实现做对比,一分钟就能确认。

% 使用MATLAB内置pca函数 [coeff_builtin, score_builtin, latent_builtin] = pca(X); % 比较前两维得分(注意符号方向需要对齐) score1_norm = score(:,1) / norm(score(:,1)); score_builtin1_norm = score_builtin(:,1) / norm(score_builtin(:,1)); % 检查相关性接近1或-1 corr_check = corr(score1_norm, score_builtin1_norm); fprintf('与内置pca第一主成分相关系数: %.6f\n', abs(corr_check));

代码逻辑说明:由于符号翻转的存在,直接比较score数值没有意义。先对每个得分向量做归一化,再用相关系数的绝对值来判断方向是否一致——相关系数接近1或-1都代表方向对齐。参数说明:norm是向量的欧几里得范数,corr计算两个向量间的皮尔逊相关系数。这个验证方法也适用于后续你自己的修改版本,建议把这段验证脚本保留为固定测试代码。

6. 让PCA结果可解释的进阶技巧:载荷图、双标图与贡献率阈值的选择逻辑

代码跑通不是终点,能用PCA结果支撑业务判断才是。这一章分享两个在你写代码之后最常用的进阶技巧:怎么用载荷图看清原始特征与主成分的关系,以及怎么根据业务场景调整贡献率阈值,而不是机械地卡85%。

6.1 载荷图:把一个主成分拆回原始特征的贡献

coeff矩阵的每一列是一个特征向量,向量中的每个元素代表原始特征在这个主成分中的权重。权重绝对值越大,说明这个原始特征对该主成分的影响越强。把第一主成分和第二主成分的载荷权重直接画成散点图,就是所谓载荷图。每个原始特征在图上的坐标是它在两个主成分中的权重,方向相近的特征说明它们在降维空间中高度相关。

% 绘制载荷图:每个特征在PC1-PC2平面上的权重坐标 figure; quiver(zeros(size(coeff,1),1), zeros(size(coeff,1),1), ... coeff(:,1), coeff(:,2), 0, 'r'); hold on; text(coeff(:,1)*1.1, coeff(:,2)*1.1, ... {'出勤率','练习完成度','实验得分','笔试成绩'}, 'FontSize', 10); xlabel('第一主成分载荷'); ylabel('第二主成分载荷'); axis equal; grid on;

代码逻辑说明:coeff的第1、2列分别是各特征在第一、第二主成分中的权重。quiver函数以原点为起点画有向箭头,0作为缩放参数表示不自动缩放宽高比,text用来给每个特征标注名称。轴设为equal保证箭头夹角看起来不失真。参数说明:coeff(:,1)*1.1的作用是在箭头末端偏移10%的位置写文字,避免文字压住箭头。axis equal对载荷图尤其重要,因为载荷权重的绝对值大小之比才是有意义的,不等比例缩放会扭曲夹角信息。

6.2 贡献率阈值选择:85%不是万能金标准

很多人从教材里记下一个“累计贡献率大于85%”的经验数字,但这个数字并非在所有场景都适用。如果你的数据本身噪声很大,比如传感器原始采集信号,85%的累计贡献率可能需要保留8个甚至10个主成分,这种情况下应该看特征值碎石图的拐点,而不是硬套85%。反之,如果数据有强结构,前两个主成分就已经达到90%以上,强行保留更多分量只会让你画出的散点图徒增噪声。

我的判断习惯是分场景:做可视化时,固定取前2或前3个主成分,不管贡献率是多少,因为图纸上的轴就那么多;做后续回归或聚类时,用碎石图拐点选k,再验证一下选出的主成分和业务标签的相关性;做特征压缩时,才用85%或90%的累计贡献率阈值。你自己的代码只负责输出——它已经给出了全部贡献率和累计贡献率数组,阈值怎么设取决于你要拿压缩后的数据做什么。

6.3 双标图(Biplot):把样本和特征画在同一张图里

最后一个值得掌握的技巧是把投影后的得分(样本位置)和载荷向量(特征方向)画在同一张图上,一目了然地看出每个样本在哪个特征方向上更突出。MATLAB里通常还是用散点图和quiver叠加实现。

% 绘制双标图 figure; scatter(score(:,1), score(:,2), 40, 'filled'); hold on; % 对载荷向量做缩放,使其和样本坐标区间匹配 scale = max(max(abs(score))) / max(max(abs(coeff))); h = quiver(zeros(size(coeff,1),1), zeros(size(coeff,1),1), ... coeff(:,1)*scale, coeff(:,2)*scale, 0, 'k', 'LineWidth', 1.5); text(coeff(:,1)*scale*1.1, coeff(:,2)*scale*1.1, ... {'出勤率','练习完成度','实验得分','笔试成绩'}, 'FontSize', 12); xlabel('第一主成分'); ylabel('第二主成分');

代码逻辑说明:关键参数是scale。coeff的载荷权重数值范围通常在-1到1之间,而score的坐标可能很大,两者不在同一尺度上。scale是通过样本坐标最大绝对值除以载荷最大绝对值得到的缩放因子,把载荷箭头放大到与样本点可比较的比例。参数说明:quiver最后一个参数0表示不自动缩放,LineWidth控制箭头粗细。观察双标图的技巧是:样本点沿着某个特征箭头方向分布得越远,说明该样本在这个特征上取值越极端,这个信息在原始数据里要翻很多行才能看出来,在双标图上一眼就能识别。

说实话,当初我第一次看到自己写的PCA脚本跑出的双标图时,被它的解释能力震撼到了——不是因为它复杂,而是因为它让一个四维数据表变得像一张地图,每个学生都成了图上的一个点。也是从那次之后,我意识到写PCA代码最大的陷阱不在数学也不在代码,在于你没有一套系统的方法去验证和理解输出结果。希望这套注释清晰、带例题数据的方案,能让你少走这些弯路。也希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进