☰
连续投影算法SPA:近红外光谱变量筛选与波长选择的原理及Python实现
2026/10/12 12:45:37 网站建设 项目流程

简介:本资源是一份面向数据科学初学者与光谱分析实践者的SPA(连续投影算法)入门教程,聚焦高维数据降维这一核心问题,特别适用于生物信息、化学计量学及机器学习预处理场景。压缩包共14个文件(6.55MB),包含11个MATLAB函数脚本(如spa.m、spa_train.m、projections_qr.m等,实现算法主流程、种群初始化、验证评估与误差统计)、2个光谱数据集(.mat格式,含jasperRidge2_R198.mat等真实实验数据)以及1份详尽的中文教程文档(.docx),系统覆盖原理推导、代码调试要点、迭代机制说明与光谱应用实例。已有2413人学习下载,读者可直接运行调试后的完整工程,复现从数据加载、投影迭代、维度裁剪到效果评估的全流程,并获得针对大规模光谱数据优化的内存管理与计算效率改进思路。 做近红外光谱建模这些年,我最早接触SPA(连续投影算法)时,其实并没太当回事。当时手上有个玉米水分定量项目,光谱变量七百多个,模型始终绕不开多重共线性,PCR、PLS、正则化一层层加,效果只能算差强人意。一个老同事甩了句“你先把变量筛了”,我才开始认真看这个算法,结果一用就是好几年。SPA这名字在化学计量学圈子里没人陌生,但圈外人更容易联想到“单页应用”,实际上在光谱建模里它是Successive Projections Algorithm的缩写,核心就一件事:从高维光谱变量里,用投影的方式挑出少数彼此冗余最小、代表性最强的波长,供后续MLR、PLS这类线性模型使用。这篇文章,我想把连续投影算法的原理、完整Python实现、参数调节经验和各种翻车教训一次讲透,适合正在做近红外/中红外光谱定量建模、搞特征波长筛选,或者被变量共线性折磨的同行参考。如果你只想快点跑通结果,直接从第三部分抄代码也行。

1. 全谱进模型的代价:变量筛选不只是“为了图快”

1.1 一个700变量的模型为什么越调越虚

很多刚入门的同学有个直觉:模型预测不准,就加特征。光谱数据恰恰相反,波长点越加,模型未必越准,甚至可能更虚。因为近红外光谱的相邻波长之间相关性极高,1098 nm和1100 nm两个点的吸光度往往相差极其微小,放进模型之后,它们携带的信息高度重叠,结果就是设计矩阵近似奇异,最小二乘解变得极不稳定。数值上稍微动一下,回归系数就剧烈震荡,训练集表现好得吓人,验证集直接垮掉。

这还不只是数值问题。全谱建模往往让模型变成“记忆光谱形状”而不是“学习化学信息”。你拿样本A的平滑基线、样本B的噪声毛刺都可能被当成有效特征。变量一旦多到几百上千,模型解释性也大打折扣,品控人员问你“到底哪个波数段对应什么基团”,你看着三百个系数根本答不上来。

所以变量筛选对光谱建模不是可选项,而是必要步骤。它的目标很直接:保留与目标性质相关的有效信息,去掉冗余波长,让模型更稳、更简、更可解释。

1.2 SPA解决的是哪一类问题

连续投影算法解决的核心问题,叫做“最小化变量间的共线性”。它不关心某个波长和性质之间的相关性大小,也不依赖回归系数绝对值的高低,而是用一套几何规则,从候选波长里选出相互之间夹角尽可能大、冗余尽可能小的那一组。这种思路特别适合线性模型,因为线性模型的稳定性强烈依赖自变量矩阵的“良态程度”,变量之间越接近正交,最小二乘解越稳定。

用一句话概括:它做的事情是“从一群长得很像的波长里,挑出几个长得最不一样,但又不至于完全无关的”,为后续回归提供一组干净、低冗余的特征组合。我见过不少场景里,SPA筛完变量后,即便退回到普通多元线性回归(MLR),预测精度也能和全谱PLS打平甚至更好,但模型变量只剩个位数,这在实际产线落地时价值非常大。

1.3 别把变量筛选等同于降维

很多材料把SPA和PCA混在一起讲,这里要分清楚。PCA是把原始变量做线性组合,生成新的潜变量,得到的是“综合变量”,它的每个成分都是一堆原始波长的加权和,解释性弱一些;SPA则是直接从原始波长里挑子集,保留的仍然是真实波长点。两者用途不同,也不冲突。实际操作中有人会先用PCA去噪、再用SPA选波长,也有人直接对原始光谱做SPA,再进PLS,两条路我都试过,后者更常用,因为它保留了波长对应的化学意义。

接下来我们就从数学底层看,SPA这个“挑法”到底是怎么算出来的。

2. SPA到底在算什么:连续投影的几何直觉与数学细节

2.1 把每个波长当成一个向量

理解SPA的第一个关键动作:把每个波长在所有样本上的吸光度值,看作一个N维空间里的向量。这句话非常抽象,我拆开讲。假设你有80个样本,那么任意一个波长点(比如1100 nm)就有80个吸光度值,这80个值可以拼成一个80维向量。700个波长点,就对应700个这样的高维向量。

两个波长之间的相关性,反映到几何上就是两个向量的夹角:夹角越小,两个变量越“同向”,信息冗余越严重;夹角接近90度,两个变量几乎互不相关;夹角超过90度,两个变量是负相关,这在光谱变量的位置上偶尔会出现,但本质上也是一种低冗余关系。既然要找低冗余变量,直觉就是找彼此夹角尽量大的波长向量。

SPA做的事情,正是通过逐次投影,从原始变量集合里选出这样一组“彼此夹角尽量大”的波长。这里说个大白话类比:你站在一片树林里,想看完整片林子的全貌,只朝一个方向看肯定什么都看不清,需要找几个差异很大的视角,每个视角都尽量带有增量信息。SPA就是自动帮你找这些视角的算法。

2.2 连续投影的迭代规则:每次选“最不听话”的变量

连续投影的名字里“连续”两个字,指的是投影过程一轮接一轮。算法从某个初始波长开始,每一步都在剩余变量中寻找“当前已选波长向量方向上的正交投影残差最大”的那个变量。

具体公式我给你简化一下。假设当前选中的向量是x_selected,对任意候选变量x_j,它向x_selected方向做投影后,剩余的部分是:

residual_j = x_j - (x_j · x_selected) / (||x_selected||^2) · x_selected

这个残差向量,就是x_j中“与x_selected无关”的那部分信息。残差向量的长度(二范数)越大,说明x_j携带的独立信息越多。SPA每一轮就选这个长度最大的变量作为下一个入选者。

再解释一下直觉。如果x_j和x_selected长得几乎一样(夹角很小),那么投影项几乎能完整解释x_j,残差会很小,甚至接近0,意味着再选它进来纯属浪费名额。反过来,如果x_j和x_selected几乎垂直,投影项非常弱,残差保留得很大,说明它带来的是新信息。所以每一轮选残差最大,本质上就是选“现有选择尚未覆盖的信息量最大”的那个波长。

这个操作重复执行,直到选出预设数量h个变量。整个“链式选择”过程会从不同初始波长开始跑多遍,形成多条候选链,最终用交叉验证决定哪条链最靠谱。这后一步很多人容易忽略,但恰恰是SPA最后一步“定稿”的关键。

2.3 一个二维小实验,看SPA如何挑掉冗余

为了让你更直观地记住这个原理,我给你写个极小的模拟例子。假设只有3个样本,4个候选波长A、B、C、D,它们的向量分别是:

A = [1, 2, 3] B = [1.1, 2.1, 2.9] C = [5, -1, 0] D = [0, 3, -2]

人工看一下:B和A几乎平行,夹角只有几度,所以如果先选中A,那么B计算出来的投影残差会非常小,基本不具备入选资格。C、D与A夹角更大,残差相对大,进入下一轮候选。

假设第一步选中A,第二步分别算B、C、D对A的投影残差。B的残差长度大概率在0.2以下,C和D的残差可能都在6左右,选C或D,具体看哪个更长。等选入C之后,第三步计算剩余变量对“A和C张成平面”的正交投影,进一步选出在这个平面上最“独立”的变量。这就是SPA逐步挑选的核心过程:一旦选定一个波长,下一轮的剩余信息是排除掉所有已选变量解释过的部分之后,重新计算出来的。

这个设计思路带来的直接好处是,已选变量之间不仅单个相关度低,整体组合的多重共线性也受到抑制。实际数据里常见的情况是,SPA选出的波长往往分布在光谱的不同区间,而不是扎堆在某个官能团吸收峰附近,这和它最大残差的选择逻辑完全吻合。

3. 手写Python实现:从伪代码到能直接跑通的函数

3.1 实现前的设计取舍

网上能找到不少SPA的封装工具,比如有的人用MATLAB版本,也有人用R包。但我在实际项目中还是更习惯自己写Python实现,原因有三个:一是方便和后续建模代码衔接;二是变量选择需要配合嵌套交叉验证,自己封装的函数可以灵活插入到不同验证流程里;三是纯numpy实现,在几百个变量这种规模下性能绰绰有余,没必要依赖外部软件。

实现SPA我建议拆成三个函数来写:

  • 投影步骤函数:负责输入当前已选向量集合、候选向量矩阵,输出每个候选向量的投影残差范数;
  • 候选链生成函数:从每个初始波长出发,生成一条包含h个波长的变量序链;
  • 最终优选函数:用交叉验证RMSECV比较不同链,返回最优变量下标。

这么拆的好处是代码可测性好。投影步骤单独拿出来,你可以用前面二维模拟数据验证结果,等确认投影步骤没问题,整个算法大概率就稳了。

3.2 核心代码逐段注释

下面是我平时在项目里实际使用的核心实现,做了简化,去除了一些项目专属的打印日志,但保留了最骨干的逻辑。

import numpy as np def calc_projection(Xcal, selected_idx): """ 计算 Xcal 中每个候选变量在已选变量张成空间上的正交投影残差范数。 Xcal: (n_samples, n_variables) selected_idx: list, 当前已选变量的列下标 返回: norm_array, 长度 n_variables 的数组,已选变量位置置0 """ n_samples, n_vars = Xcal.shape norm_array = np.zeros(n_vars) # 选出一个当前“代表向量”,这里取最后一个已选列 # 更严格的做法会使用整个已选列空间,但SPA最常用的是逐列投影近似 x_sel = Xcal[:, selected_idx[-1]].copy() denom = np.dot(x_sel, x_sel) for j in range(n_vars): if j in selected_idx: norm_array[j] = 0.0 continue x_j = Xcal[:, j] # 计算 x_j 在 x_sel 上的投影系数 coef = np.dot(x_j, x_sel) / denom # 投影残差 residual = x_j - coef * x_sel norm_array[j] = np.linalg.norm(residual) return norm_array def spa_chain(Xcal, start_idx, h): """ 从 start_idx 出发,选择 h 个变量,返回变量下标list。 Xcal: (n_samples, n_variables) """ n_samples, n_vars = Xcal.shape selected = [start_idx] for _ in range(1, h): norm_array = calc_projection(Xcal, selected) next_idx = int(np.argmax(norm_array)) # 避免重复选中同一个变量 if next_idx in selected: # 如果最大已经是已选变量,取第二大 norm_array[next_idx] = -1 next_idx = int(np.argmax(norm_array)) selected.append(next_idx) return selected

这里需要说明一点:上面代码逐列投影用的是最后一个已选向量,这是SPA原始论文中比较常见的一种简化处理方式。严格的做法,第二步应该计算候选变量对“所有已选变量张成的线性空间”的正交投影,而不是只针对最后一个已选变量。但对大量实际光谱数据来说,逐列投影已经能选出足够好的变量子集,而且计算复杂度低很多。如果你介意,可以把投影步骤改成:

# 严格版本:投影到整个已选列空间的正交补空间 U, S, Vt = np.linalg.svd(Xcal[:, selected_idx], full_matrices=False) projection = U @ U.T residual = x_j - projection @ x_j

不过这个严格版在变量数多时会频繁做SVD,速度不如逐列投影快,项目里我一般不用。作为教程版,逐列投影已经足够解释算法核心,效果上也不输太多。

3.3 用交叉验证定稿:不是所有链都合适

前面提到SPA对每个起始变量都会生成一条变量链,那最后到底选哪条链?答案是交叉验证。以一个近红外数据集为例,流程是这样的:

def spa_select(Xcal, ycal, h): """ 完整SPA变量选择流程。 遍历所有候选初始变量,根据MLR交叉验证RMSECV决定最优变量链。 """ n_samples, n_vars = Xcal.shape best_rmse = np.inf best_indices = None for start in range(n_vars): indices = spa_chain(Xcal, start, h) Xsel = Xcal[:, indices] # 用多元线性回归做留一交叉验证(LOO-CV) rmse = loo_mlr_cv(Xsel, ycal) if rmse < best_rmse: best_rmse = rmse best_indices = indices print(f"start={start}, selected={indices}, RMSECV={rmse:.4f}") return best_indices, best_rmse def loo_mlr_cv(Xsel, y): """ 留一交叉验证:每次留一个样本,用其余样本拟合多元线性回归, 然后预测留出样本,计算均方根误差。 """ n = len(y) preds = np.zeros(n) for i in range(n): train_idx = [j for j in range(n) if j != i] X_tr, y_tr = Xsel[train_idx], y[train_idx] X_te = Xsel[i:i+1] # 加一列截距 X_tr_b = np.column_stack([np.ones(len(train_idx)), X_tr]) X_te_b = np.column_stack([np.ones(1), X_te]) beta, _, _, _ = np.linalg.lstsq(X_tr_b, y_tr, rcond=None) preds[i] = X_te_b @ beta rmse = np.sqrt(np.mean((y - preds) ** 2)) return rmse

这里我用了留一交叉验证配合MLR。留一法在小样本光谱数据里很常见,虽然计算量不小,但变量筛选阶段的样本量通常就几十到一两百,还可以接受。如果你觉得慢,也可以改成5折交叉验证,速度会快不少,选择结果差异通常不大。

跑完之后,best_indices就是最终选出的波长下标。整个流程里我个人最喜欢的部分就是盯着print输出看:一开始RMSECV波动很剧烈,到后面逐渐收敛,最后选出的一组变量通常稳定在某个数值附近,那种感觉非常踏实。

4. 参数调节和翻车现场:SPA最容易出错的几个环节

4.1 h选多大:别拍脑袋,画一条RMSECV曲线

SPA最关键的参数是h,也就是要选多少个波长。h太小,信息量不足,模型欠拟合;h太大,冗余信息又进来了,模型重新变得不稳。我建议的做法是:先设定一个h的上限,比如min(20, 样本数-3),然后从1到上限逐个计算对应的最低RMSECV,画一条“变量数-RMSECV”曲线,看拐点。

在多数近红外项目里,拐点往往出现在6到12个变量之间。你看到RMSECV开始下降到平台期、再增加变量只能带来微小改善甚至变差时,就该收手。别贪,变量少一点点,模型的可维护性和可解释性都完全不同。

4.2 起始变量遍历的代价和优化

我在3.3节的代码里用了最笨的办法:对所有变量都作为起始变量跑一遍。如果变量数有700个,h取8,每次留一交叉验证要做几百次最小二乘,总时间可能高达几十秒甚至几分钟。实际项目里我通常会做两个调整。

  • 第一,对原始光谱做一次简单的排序筛选,剔除明显噪声区间(比如光谱两端基线漂移严重的区域),从剩下的候选变量里再挑起始点;
  • 第二,限制起始变量候选数,比如随机从700个变量里抽80个作为起始变量池,或者按方差从大到小取前100个。这不会明显降低精度,却能节省大量时间。

SPA对初始变量确实敏感,所以原始算法才用“全变量遍历+交叉验证”来规避随机性。如果你做随机抽样,记得多抽几次,或者把每次结果交叉验证对比一下,选最稳的。

4.3 数据预处理的顺序,坑过我一次

这个坑我必须单独拿出来说。SPA是对光谱变量做几何计算,所以数据的尺度会影响投影结果。如果先做了标准正态变换(SNV)或者多元散射校正(MSC),算出来的残差范数和直接在原始光谱上算,选出的波长很可能是不同的。问题来了:到底该在预处理前选,还是预处理后选?

我的经验是,一定要在最终采用的预处理流程之后再做SPA。也就是说,先做基线校正、散射校正、平滑、中心化,再跑变量筛选。理由很简单:变量筛选是为后续建模服务的,后续模型输入的已经是预处理后的光谱,筛选也必须基于同样的数据分布。千万不要在原始数据上筛完变量,再把预处理后的数据喂进模型,那会导致筛选结果和模型输入不匹配。

4.4 重复波长和“抱团”现象

光谱分辨率高时,波长间隔很小,SPA偶尔会选出两个非常邻近的波长,比如1120 nm和1122 nm。从投影残差角度看,它们可能有细微差别,所以都被选中了,但这种“抱团”对模型稳定性没啥帮助,也可能说明h选大了。

我的处理办法是把SPA选出的变量按波长排序,算相邻变量间隔,如果发现有一对间隔小于仪器分辨率的波长,就手动剔除其中一个,重新做一次MLR或PLS交叉验证看看误差没有明显增大。若误差没变,就接受更紧凑的变量集。这种“算法先筛+人工微调”的组合,在品控场景下特别实用,因为你可以向非算法人员解释清楚为什么选这几个波长。

4.5 避免数据泄漏:SPA必须在交叉验证折内执行

接下来这个坑,处理不好会让后续所有评价结果虚高。如果你在训练集上跑SPA选出波长,然后用这些波长去预测验证集,这本身就是一种数据泄漏。因为变量选择过程“偷看”了整体数据分布,在验证集上的误差会被低估。

正确的做法是在嵌套交叉验证里面做:外层把数据分成训练和测试,内层只使用训练部分做SPA,然后再把选出的波长应用在外层验证集上。如果你不做嵌套验证,至少也要把SPA放在训练集内部做,测试集从始至终不能参与变量选择。这个道理和标准化参数只从训练集估算是一样的,但很多人会忽略。

5. 什么时候该选SPA:与CARS、UVE、VIP等方法对比

5.1 不同变量筛选方法的核心差异

化学计量学里变量筛选方法远不止SPA一种。我在项目里还常用到CARS、UVE和VIP,它们在原理上差异很大。

方法核心思路输出形式典型特点
SPA几何投影,选择低冗余波长波长子集稳定、可解释,适合线性模型
CARS基于回归系数的自适应收缩 + 随机采样波长子集擅长处理强相关变量,多次运行结果有一定随机性
UVE加入伪变量,比较回归系数的t统计量波长子集剔除不稳定变量,但阈值选择有主观性
VIP基于PLS投影重要性分数排序变量重要性排序保留重要性高的变量,需要自己卡阈值

SPA最突出的差异化优势是“完全不需要响应变量来做投影计算”,投影过程只依赖光谱矩阵本身,响应变量只出现在最终的交叉验证择优环节。这一点听着像优点,其实也是双刃剑:它不考虑波长与化学成分的相关性,选出来的变量可能是低冗余的,但不一定都和目标性质高度相关。好在后续回归会对目标性质建模,不相关的低冗余变量往往会被赋予接近零的系数,影响不会太大。

5.2 数据条件和应用场景匹不匹配

SPA最适合的数据场景是:波长变量数远大于样本数、变量之间强相关、目标性质和光谱之间基本呈线性关系。近红外光谱正好都满足。在这种条件下,SPA选出的低冗余变量组合能显著提升MLR的稳定性,甚至很多时候用MLR就能逼近PLS的效果。为什么强调MLR?因为产线部署时,MLR模型就是一个线性方程,单片机、PLC都能轻松实现,维护成本极低。这是SPA方案的落地价值所在。

如果数据存在明显的非线性效应,比如固体药片近红外光谱受颗粒粒径非线性散射影响很大,SPA配MLR就不够用了。此时可以先做SNV/MSC校正,仍然用SPA选变量,但是模型换成支持向量回归或者神经网络,效果会更好。SPA本身没有能力“修复非线性”,它只管给模型提供低共线性的输入。

5.3 哪些场景建议绕开SPA

样本量非常小、只有二三十个,且信噪比也很低时,SPA投影计算会放大噪声变量在残差中的比重,选出重复性差的光谱区间。这时候我倾向于用UVE或CARS,先剔除噪声变量再选代表性波长。另一个需要绕开的场景是,波长区间本来就很少,比如只有十几个特征变量时,SPA往往选不出明显优于全谱的组合,花哨算法不如老老实实做逐步回归或者直接看化学意义选峰。

在遇到“开头几分钟就能跑出结果,但结果每次运行都不一样”的情况,比如某些方法引入随机采样,如果做变量筛选是为了后续复现和品控,SPA的确定性反而是优点。同样的输入,SPA每次输出完全一致,这在GMP/GLP相关项目里特别好用。

6. 一次真实的近红外建模实验:700个波长压到6个

6.1 数据与实验设计

去年我帮一家饲料企业做玉米粗蛋白近红外定量模型,设备是国产光栅型近红外光谱仪,波长范围1100到2498 nm,分辨率2 nm,共700个波长点。样本一共86份,按4比1划分训练集和测试集,训练集69份,测试集17份。光谱预处理用了SNV加一阶导数,然后均值中心化。

初始尝试直接全谱PLS,主成分数用交叉验证选出8个,测试集R²为0.91,RMSEP为0.47。模型能用,但每次仪器漂移后需要重新标定,而且8个主成分对应的是全谱载荷,品控团队完全无法解释模型“在看什么”。于是我开始用SPA做变量筛选。

6.2 SPA实验过程和结果对比

用3.3节的代码跑一遍,h从1到12,RMSECV曲线显示h=6之后基本进入平台期,再往上走误差下降不超过0.01。取h=6,最优链选出的波长分别为:1128 nm、1342 nm、1466 nm、1698 nm、1934 nm、2188 nm。这些波长跨度非常大,分别对应C-H键的二倍频和组合频区域,刚好和蛋白质相关官能团有对应关系。

然后我用选出的6个波长做MLR,结果测试集R²为0.89,RMSEP为0.52,比全谱PLS略差,但差异在可接受范围,而且模型形式变成:

蛋白含量 = a0 + a1·A(1128) + a2·A(1342) + a3·A(1466) + a4·A(1698) + a5·A(1934) + a6·A(2188)

模型里只有6个波长点,设备测温漂移、样本粒度变化造成的干扰更容易追溯到具体波长上。后面我又加了一组对比实验:用SPA选出的6个波长放入PLS,R²升到0.90,RMSEP降到0.49,几乎追平全谱PLS。这进一步说明,光谱数据里真正有效的信息,确实可以用极少数波长承载。

6.3 落地时的几个小经验

实验做完之后,我把这套模型给产线测试了两个月,发现一个额外收益:模型稳定性比全谱模型好很多。因为变量少、波长位置固定,当仪器光源强度波动导致全谱基线整体平移时,对单个波长点做外部标准化校正比校正整条光谱容易得多。每次取一个样本用化验值校正截距b0就行。

另外,落地时还要考虑波长在仪器上的漂移问题。光栅光谱仪的温度变化会导致波长微小偏移,如果筛选出的几个波长恰好落在窄峰上,偏移对吸光度影响会很大。所以我建议SPA选完波长后,看看这些波长所在位置的峰型宽窄,如果某个波长落在非常尖锐且窄的吸收峰上,要特别谨慎,最好做一次波长稳定性测试,或者适当选择吸收峰附近较宽的肩部位置。

从那次项目之后,我的近红外建模流程基本固定了:预处理 → SPA或CARS筛变量 → MLR/PLS建模 → 交叉验证评估 → 部署。不同项目再根据实际情况微调,但SPA始终是我第一个会去尝试的工具。它在数学上不复杂、代码实现可控、结果确定可复现,对做定量模型的人来说,是性价比极高的一个选择。如果你现在正被全谱变量太多、模型不稳的问题困扰,不妨把你的数据集拿出来跑一遍SPA,看看只需要几个波长时模型会变成什么样——很多时候,那个最简单的模型,反而最耐用。

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

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

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

立即咨询