1. 先说清楚:PCR到底解决什么问题
很多刚接触回归预测的同学,第一步都是用多元线性回归,拿一堆自变量去拟合一个因变量。但在实际搞数据的时候,你会发现一个问题:自变量之间经常高度相关。比如你做气象预测,温度和湿度本身就是相关的;做经济预测,GDP、消费、投资这些指标往往一起涨一起跌。这种相关性在数学上叫多重共线性,它会导致普通最小二乘回归的系数估计非常不稳定,甚至出现系数符号和实际经验完全相反的情况。
PCR,也就是主成分回归,就是专门来治这个毛病的。它的思路很直白:既然这些自变量之间有重复信息,那我先把它们压缩成几个互相独立的主成分,再用这几个主成分去和因变量做回归。这么做有两个立竿见影的好处,一是彻底绕开多重共线性问题,二是起到降维效果,模型更稳,预测精度往往比硬上多元回归好不少。
这篇文章里的Matlab代码,我按"拿到就能跑"的标准来写,全程不涉及复杂工具箱,只用基础函数和统计工具箱里的标准函数。无论你是本科做课程设计,还是刚转行学数据建模,只要把数据和代码放到同一个文件夹,改一行路径就能出结果。
2. 核心思路拆解:从原始数据到预测结果的完整链路
2.1 PCR的整体流程和每一步的作用
一个完整的PCR流程,本质上是四步走:标准化、主成分提取、回归建模、结果回代。我用大白话把每一步拆开讲,你理解透了再去看代码,就不会觉得这是黑盒操作。
首先是标准化。这一步绝对省不了,因为主成分分析对变量的量纲非常敏感。你要是拿一个单位是"元"的变量和一个单位是"百分数"的变量一起去算协方差矩阵,结果会被量纲大的那个变量完全主导,提取出来的主成分就失真了。Matlab里做标准化的标准写法是zscore,它把每列数据变成均值0、标准差1,这样所有变量就在同一个尺度上比较了。
第二步是主成分提取。这步的核心是求协方差矩阵的特征值和特征向量。特征值表示每个主成分能解释的方差大小,特征向量就是主成分的方向。Matlab里用pca函数就能一次性搞定,它会返回主成分得分(score)、载荷(coeff)和解释方差占比(explained)。主成分得分就是原始数据在压缩后的新坐标系下的坐标,后续回归用的就是这批得分。
第三步是回归建模。把主成分得分作为自变量,因变量做回归,这时候因为主成分之间是互相正交的,回归方程非常干净,不会出现共线性问题。这里注意一个细节,因为因变量Y没有做标准化,只是一个中心化处理,所以回归系数出来后需要记清楚是怎么换算回原始变量的。
第四步是回代。这一步是新手最懵的地方。你用主成分得分回归出来的模型,并不是直接拿来预测新数据的,因为新数据要先经过同样的标准化、同样的主成分投影,最后还要把中心化过程还原回去。代码里我会把这部分的换算公式完整写出来,照着套就行。
2.2 为什么主成分回归适合新手而不是闷头硬算
有人可能会问,Matlab不是自带regress和fitlm吗?那我直接做多元回归不就行了,何必绕一圈用PCR?
原因很简单。第一,现实中的数据基本都带共线性,直接做多元回归,你会发现系数的标准误非常大,稍微换几个样本,系数的正负号都可能变。这会让你的结论完全站不住脚。第二,PCR不需要你去手动筛选变量,它自动把信息最集中的方向找出来,对新手来说非常友好,不需要太多先验知识。第三,PCR对噪声有一定的抑制作用,因为你把后几个信息量很小的主成分丢掉了,相当于做了一个平滑。
不过PCR也不是万能药。如果你原始数据本身信噪比很低,主成分提取出来的方向也不一定是有意义的。还有一个前提是变量间要有一定的相关性,如果自变量之间本来就互相独立,PCR效果和普通回归差不多,没必要绕弯子。但总的来说,在大多数实际预测场景中,PCR的鲁棒性表现都很不错,这也是它在近红外光谱分析、化工软测量等领域成为常青树的原因。
3. 环境准备与数据组织:跑代码前的三件小事
3.1 Matlab版本和工具箱的检查
这套代码对Matlab版本要求不高,R2016a以后都能正常跑。核心用到的函数有三个:zscore、pca和regress,它们分别属于基础工具箱和统计工具箱。在命令行输入ver,看看列表里有没有Statistics and Machine Learning Toolbox,有的话就放心跑。绝大多数学校安装的Matlab版本都自带这个工具箱,如果你用的是精简版,可以用which pca验证一下能否找到函数路径。
这里有一个我踩过的坑:有些老版本Matlab的pca函数是通过princomp实现的,如果你用的是R2012b之前的版本,需要把pca换成princomp,并且输出的顺序有些区别。不过现在网上能找到的Matlab版本基本都是R2016a以上了,这个概率很小,但以防万一还是提一下。
3.2 数据文件的组织方式和格式要求
为了让你拿到代码就能跑,我默认数据格式是Excel表格,第一列是因变量Y,后面几列是自变量X1、X2、X3等等。表格第一行是变量名,从第二行开始是数据。这种格式在科研数据处理里最常用,大家也都熟悉。
把Excel文件和Matlab脚本放在同一个文件夹下,比如都放到D:\PCR_Demo里。然后在代码里修改数据文件名就可以了。
需要特别提醒的是,数据不能有缺失值。NaN会让pca函数直接报错,而新手往往会在数据预处理阶段忽略这一点。所以拿到数据后第一件事是用sum(isnan(data(:)))检查缺失值数量,如果有缺失,要么删掉整行,要么用均值或者插值补上。代码里我会把这个检查步骤也带上。
3.3 训练集和测试集的划分策略
PCR模型建模完总是要评估效果的,所以需要把数据划分成训练集和测试集。常见的做法是按比例随机划分,比如70%训练,30%测试。但如果数据有时间的先后顺序,比如是时间序列数据,就最好按前面的数据训练、后面的数据测试,不能随机打乱,因为打乱时间顺序会引入信息泄漏,让评估结果虚高。
在代码里我写一个随机划分的版本,同时注释里写明时间序列数据要怎么改成顺序划分。如果你是想用PCR做在线预测或软测量建模,建议优先使用时间顺序划分,这样更贴近实际部署场景。
4. 完整代码实现与逐行解析
4.1 主程序代码(可直接复制运行)
下面这段代码是整篇博文的核心,我在命名和注释上都做了简化处理,方便你自己改成任意数据集。代码也考虑了输出结果的整理,包括预测效果图和回归系数表。
%% 基于PCR主成分回归的预测代码,适合新手直接运行 % 数据格式:Excel文件,第一列为因变量Y,其余列为自变量X % 第一行为变量名,第二行开始为数据 clear; clc; close all; %% 1. 读取数据 filename = 'mydata.xlsx'; % 数据文件名,改成你自己的 data = readmatrix(filename); % 读取数值矩阵 y_raw = data(:, 1); % 因变量 X_raw = data(:, 2:end); % 自变量 % 检查缺失值 if sum(isnan(data(:))) > 0 error('数据中含有缺失值NaN,请处理后再运行'); end %% 2. 数据标准化 X_std = zscore(X_raw); % 自变量标准化,均值0,标准差1 %% 3. 主成分分析 [coeff, score, ~, ~, explained] = pca(X_std); % coeff:主成分载荷(各变量在各主成分上的权重) % score:主成分得分(用于后续回归) % explained:各主成分解释方差的百分比 % 可视化展示主成分方差解释率 figure; pareto(explained); xlabel('主成分序号'); ylabel('方差解释率(%)'); title('各主成分方差解释率'); %% 4. 选择主成分个数(按累计贡献率阈值) cum_ratio = cumsum(explained); num_pc = find(cum_ratio >= 95, 1, 'first'); % 取累计贡献率超过95%的主成分个数 fprintf('自动选择的主成分个数:%d,累计贡献率:%.2f%%\n', num_pc, cum_ratio(num_pc)); %% 5. 用选出的主成分得分做回归 X_pc = score(:, 1:num_pc); % 取前num_pc个主成分得分 X_pc = [ones(size(X_pc, 1), 1), X_pc]; % 添加常数项 [b, bint, r, rint, stats] = regress(y_raw, X_pc); % b:回归系数 % stats:第一个元素是R方,第二个是F统计量,第三个是p值 fprintf('模型R方:%.4f,F统计量:%.4f,p值:%.4f\n', stats(1), stats(2), stats(3)); %% 6. 计算还原到原始自变量的回归系数 % 标准化后的回归方程是:y = b0 + b1*PC1 + b2*PC2 + ... % 而 PCj = sum_i( coeff(i,j) * (Xi - mean(Xi)) / std(Xi) ) % 所以原始变量的系数 = sum_j( b(j+1) * coeff(i,j) / std(Xi) ) beta_original = zeros(size(X_raw, 2), 1); for i = 1:size(X_raw, 2) for j = 1:num_pc beta_original(i) = beta_original(i) + b(j+1) * coeff(i, j) / std(X_raw(:, i)); end end % 常数项还原 b0_original = b(1) - sum(beta_original .* mean(X_raw, 1)'); fprintf('还原后的原始变量回归系数:\n'); for i = 1:length(beta_original) fprintf('X%d 系数 = %.4f\n', i, beta_original(i)); end fprintf('还原后的常数项 = %.4f\n', b0_original); %% 7. 在训练集上计算预测值并绘图 y_pred_train = X_pc * b; figure; plot(1:length(y_raw), y_raw, 'o-', 'LineWidth', 1.5); hold on; plot(1:length(y_raw), y_pred_train, 's--', 'LineWidth', 1.5); legend('真实值', 'PCR预测值'); xlabel('样本序号'); ylabel('Y值'); title('训练集拟合效果对比'); grid on; %% 8. 在测试集上评估(随机划分70%训练/30%测试) rng(42); % 固定随机种子,保证结果可复现 idx = randperm(length(y_raw)); train_idx = idx(1:round(0.7 * length(y_raw))); test_idx = idx(round(0.7 * length(y_raw)) + 1:end); X_train_std = zscore(X_raw(train_idx, :)); X_test_std = (X_raw(test_idx, :) - mean(X_raw(train_idx, :))) ./ std(X_raw(train_idx, :)); % 注意:测试集的标准化必须用训练集的均值和标准差,不能自己单独标准化 % 对训练集做主成分分析 [coeff_tr, score_tr, ~, ~, explained_tr] = pca(X_train_std); cum_tr = cumsum(explained_tr); num_pc_tr = find(cum_tr >= 95, 1, 'first'); X_tr_pc = [ones(size(score_tr, 1), 1), score_tr(:, 1:num_pc_tr)]; [b_tr, ~, ~, ~, stats_tr] = regress(y_raw(train_idx), X_tr_pc); % 测试集主成分得分 score_te = X_test_std * coeff_tr(:, 1:num_pc_tr); % 直接投影 X_te_pc = [ones(size(score_te, 1), 1), score_te]; y_pred_test = X_te_pc * b_tr; % 计算测试集R方 SS_res = sum((y_raw(test_idx) - y_pred_test).^2); SS_tot = sum((y_raw(test_idx) - mean(y_raw(test_idx))).^2); R2_test = 1 - SS_res / SS_tot; fprintf('测试集R方:%.4f\n', R2_test); % 绘制测试集预测对比 figure; plot(1:length(test_idx), y_raw(test_idx), 'o-', 'LineWidth', 1.5); hold on; plot(1:length(test_idx), y_pred_test, 's--', 'LineWidth', 1.5); legend('真实值', '预测值'); xlabel('测试样本序号'); ylabel('Y值'); title('测试集预测效果对比'); grid on;4.2 代码运行逻辑的深度解读
这段代码在逻辑上分成了两大块。前七步是完整的PCR建模链路,从读数据到训练集拟合效果的可视化,你跑完这一部分就能看到主成分的方差解释率图、回归系数表和拟合效果图。第八步是额外补充的交叉验证环节,目的就是严格一点,评估模型的泛化能力。
新手最容易看懵的部分是那段"还原原始变量回归系数"的循环。我在这里多花点口舌解释一下,因为很多人不理解为什么回归出来好好的,还要多此一举去还原。
PCA是在标准化后的数据上做的。也就是说,你提取出来的主成分PC1并不是原始X1、X2的线性组合,而是标准化后的(X1-mean(X1))/std(X1)、(X2-mean(X2))/std(X2)的线性组合。所以当你把回归系数b转换成原始变量的系数时,必须要把标准化的过程乘回去。代码里除的那个std(X_raw(:, i)),干的正是这件事。如果你跑完代码,发现还原出来的系数和直接回归的不一样,不用惊讶,这正是PCR消除了共线性影响的结果。
4.3 关于测试集标准化为什么不能自己算
代码第八步里有一行注释,我在这里展开强调一下,因为它决定了模型评估的公平性。
测试集的标准化必须用训练集的均值和标准差,不能用测试集自己的。为什么?因为测试集扮演的角色是"未来来的新数据"。在真实场景里,你建模的时候根本不知道未来数据的均值是多少。如果你用测试集自己的均值去标准化,等于偷看了答案,测试集R方会虚高。这在机器学习里叫数据泄漏,是学术作业里被扣分最多的一个低级错误。
所以代码里先算训练集的均值和标准差,然后用(X_test - mean_train) ./ std_train去标准化测试集。这一点你自己写新代码的时候一定要记住,养成习惯。
5. 主成分个数的选择策略:别死磕95%这条线
5.1 累计贡献率阈值怎么定
我代码里默认取累计贡献率达到95%的主成分个数。这个95%是经验值,很多教材也推荐用这个数。但实际应用中,80%到99%都是合理的,关键看你的数据特征和应用场景。
如果你的数据噪声很大,比如传感器采集的信号,那强制让模型解释95%的方差,可能会把噪声也学进去了,泛化能力反而下降。这时候可以把阈值降到85%甚至80%,让模型更平滑。如果你的数据本身就是高信噪比的光谱数据,那95%甚至99%也没问题,因为后面的主成分仍然携带有效化学信息。
判断阈值选得好不好,不能光看训练集的R方,还要看测试集的R方。如果你的训练集R方一路上升,但测试集R方先升后降,那说明过拟合了,主成分个数选多了。我建议你跑完代码后,把num_pc分别设成2、3、4、5,对比一下测试集R方,选测试集表现最好的那个数。
5.2 用交叉验证自动确定主成分个数
如果你想更严谨一点,可以不用固定阈值,而是用K折交叉验证来挑主成分个数。思路是这样:把训练集平均分成K份,轮流拿其中K-1份训练、1份验证,记录不同主成分个数下的验证集预测误差,最后选平均误差最小的个数。
Matlab里实现这个逻辑并不复杂,但代码会稍微长一点。对于新手来说,我建议先跑通基础的95%阈值版本,理解整个流程后再进阶做交叉验证。毕竟交叉验证本质上是一个重复循环,逻辑没理顺之前容易把索引搞乱。代码里我会在后续的进阶扩展里给出一个5折交叉验证的参考实现,但初版就跑基础版的就够用了。
5.3 主成分个数和模型复杂度的关系
主成分个数越多,模型用的信息越多,在训练集上的拟合效果肯定越好,但过拟合风险也越大。这和你买衣服是一个道理,尺码恰好好处才行,太大或太小都不舒服。PCR里的主成分个数就是模型的"尺码"。
另一个容易忽略的点是,如果主成分个数取到了和原始变量个数一样多,那PCR就退化成普通的最小二乘回归,失去降维的意义了。所以当你看到累计贡献率95%对应的主成分个数已经非常接近变量总数时,说明你的原始数据本身相关性不强,PCR的优势体现不出来,这时候做到心中有数即可。
6. 常见问题与排查技巧实录
6.1 新手最容易触发的四个报错
我每次帮人调试PCR代码,翻来覆去都是那几种错误,这里整理成一张表,方便你对照排查。
| 报错信息 | 原因分析 | 解决办法 |
|---|---|---|
错误使用 pca (第 X 行),输入包含 NaN | 数据有缺失值 | 用sum(isnan(data(:)))找出缺失位置,删除整行或填充 |
错误使用 regress,Y 和 X 的维度不一致 | 主成分得分矩阵和Y的行数对不上 | 检查是否用了score(:, 1:num_pc),别把coeff当成X |
Undefined function or variable 'pca' | 没有统计工具箱或版本过老 | 换成princomp,或者检查工具箱安装 |
X 和 Y 必须具有相同的行数 | 随机划分时训练索引出错 | 确认train_idx的长度和y_raw(train_idx)一致 |
6.2 测试集R方为负是怎么回事
有的同学跑完代码发现测试集R方是负数,第一反应是代码写错了。其实R方为负,意思是你模型的预测效果比"直接用测试集均值当预测值"还要差。这说明模型在训练集上的规律没能泛化到测试集上,典型的过拟合信号。
原因通常有三个。一是训练样本太少,模型学到的是个例而不是规律。二是主成分个数选太多,把噪声也学进去了。三是测试集和训练集的数据分布差异太大,比如你随机划分前没有打乱数据,导致训练集和测试集其实是两段完全不同的时期。
排查的时候,先把主成分个数调到2或3,看测试集表现有没有改善。如果改善了,说明主成分个数确实选多了。如果还是不行,检查数据划分是不是出了问题,或者样本量本身就太小。
6.3 预测值和真实值总是整体偏移一个常数
这个问题我也见过好几次。排查方向有两条:一是看标准化和数据还原的公式有没有写错,特别是常数项的还原。二是看是不是变量没有中心化导致的系统偏差。我代码里b0_original的计算是b(1) - sum(beta_original .* mean(X_raw, 1)'),这个公式的原理是把标准化变量还原时,所有平均值要折算回常数项里。如果你自己改代码时把均值项漏掉了,预测值就会整体平移。
6.4 为什么不同人跑同一份数据,主成分个数不一样
这种情况大概率不是代码问题,而是数据划分导致的。如果训练集和测试集是随机划分的,不同人划分结果不同,训练集上算出来的均值和标准差就不同,PCA的结果自然也不同。这其实也说明你的模型对训练样本有一定敏感度,可以考虑用更多数据或者交叉验证来获得更稳定的模型。
如果你希望结果完全可复现,务必设置随机种子。代码里已经有rng(42)这一行,如果你删了,每次运行结果都会变。科研实验或者课程作业里,可复现性很重要,建议保留这个设置。
7. 进阶扩展:从固定脚本到灵活模型的三条路
7.1 把它封装成函数,输入输出清晰化
现阶段代码是脚本式的,改数据、改参数都要直接动手改代码。如果后面你要做多次实验,或者给别人用,我建议把PCR流程封装成一个函数。函数输入是X和Y,输出是模型系数和预测结果。这样你只需要维护一份核心代码,调用时传不同的数据就行。
Matlab里函数文件写法也不难,保存为pcr_predict.m,函数头写成:
function [beta_original, b0_original, R2_test, y_pred_test] = pcr_predict(X, Y, test_ratio, threshold)把脚本里的主逻辑复制进去,注意把所有中间变量改成局部变量即可。封装完成后,配合for循环还能做批量实验,比如对比不同的主成分个数、不同的测试集比例对模型性能的影响。
7.2 引入交叉验证循环,让模型评价更可信
单次划分训练集和测试集,结果往往带有偶然性。更强的方案是K折交叉验证,把数据切K份,轮流做测试集,每次训练一个模型,最后把K次测试集的预测误差合并起来算总误差。这样你对模型性能的估计会更稳定。
实现上可以用一个外层循环,把代码第八步的逻辑重复K次。每次选一份做测试集,其余K-1份做训练集。需要注意每次都要重新计算训练集的均值、标准差,再做PCA,然后投影测试集。逻辑不复杂,但代码量会增加一些,建议在基础版本跑通的基础上再做这一步。
7.3 结合其他回归模型做对比分析
PCR不是唯一能处理共线性的方法。偏最小二乘回归(PLSR)和岭回归(Ridge)也是常用方案。PLSR和PCR的区别在于,PLSR在做成分提取时同时考虑X和Y的信息,而PCR只考虑X的方差。因此PLSR的预测精度在某些场景下会高于PCR,但解释性不如PCR直观。
如果你在写论文或者做课程设计,可以把PCR、PLSR和普通多元回归放在一起做对比,分别算测试集R方或均方根误差。代码里只需要学会Matlab的plsregress函数和ridge函数,改动量不大,但会让你的实验结论充实很多。我个人的经验是,数据相关性越强,PCR和PLSR的优势越明显;相关性弱的话,三者差距很小。
8. 最后再分享几个小经验
我自己本科做论文那会儿,第一次跑PCR也折腾了两天,最后发现就是测试集标准化用错了方法,R方天天飘忽不定。所以这里再啰嗦一遍:标准化的均值和标准差必须全部来自训练集,这是整个建模流程里最不起眼但最容易出错的地方。
还有一个习惯,我会在跑完模型后顺手把预测值和真实值的差值画出来,看是否存在明显的模式。如果残差图出现一个U形或者波浪形,说明模型没有捕捉到某种非线性规律,这时候可以考虑加二次项或者换PLSR试试。如果残差是白噪声一样随机分布,那模型基本就到位的。
另外,不管是自己学习还是帮别人跑数据,Excel文件里的变量名建议用英文或者拼音,不要用中文。倒不是说Matlab读不了中文,而是不同系统的编码不同,中文表头在个别版本里会出现乱码,导致readmatrix读进来的第一行也变成数据,最后所有东西全乱套。用英文变量名,省心很多。
这套PCR代码从一开始设计的目标就是"能跑、能看懂、能改",不是高性能的生产级方案。你在它的基础上做任何修改,比如加交叉验证、改成分选择逻辑、换数据格式,都是很自然的扩展路径。把基础流程吃透,后面学PLSR、岭回归这些进阶方法的时候,你会感觉一通百通。