简介:面向近红外光谱分析与化学计量学入门者,这份资源聚焦偏最小二乘法(PLS)建模的完整流程,也适用于食品、制药、农业等领域的光谱数据分析人员。近红外光谱中每个波长响应可视为自变量,目标物理或化学性质为因变量,PLS通过降维提取主成分,能有效应对高维、多重共线性问题;资料对此作了清晰讲解,并重点阐述主成分数确定与交互验证策略。压缩包共2个文件,均为MATLAB脚本(.m):pls.m为PLS主程序,plscvfold.m用于执行k折交叉验证,直接运行即可复现建模过程。包大小仅2KB,轻量聚焦,便于快速掌握算法骨架;已有1766人学习下载,是上手PLS建模的实用参考。结合代码与说明,读者可理解数据预处理、主成分提取、模型验证等关键步骤,并能自行调整参数,迁移至定性分类、定量预测等场景。
1. 近红外光谱定量分析,为什么偏偏是 PLS
拿到一台近红外光谱仪,你测出来的往往不是一条干净的吸收曲线,而是几十个样品、几千个波长点堆成的大矩阵。很多从业者第一步就卡在这里:波长变量比样品还多,谱峰之间高度重叠,常规回归一跑就报奇异矩阵。偏最小二乘法建模方法(PLS)就是为这种情况准备的——它在自变量和因变量两边同时做降维,把上千个波长压成十几个主因子,再用这些主因子做线性回归。本文不聊教科书里的公式推导,而是按实际建模的顺序,把数据组织、预处理、主因子数选择、模型评估到落地上线整个链路走一遍。适合刚接手近红外数据、想用 PLS 做定量分析(比如水分、含量、酸价)的工程师和数据人员;如果你已经建过几个模型,参数上的坑和最后的迁移校准也值得看。
2. 光谱矩阵的组织方式与 PLS 的建模前提
2.1 光谱数据的标准结构:一行一个样本,一列一个波长
近红外光谱建模的第一步,是把仪器输出的原始文件整理成可计算的结构。常见的做法是:行代表样本(样品),列代表波长变量。假设你有 120 个玉米粉末样本,光谱范围从 900 nm 到 1700 nm,采样间隔 2 nm,那么光谱矩阵 X 的形状就是 120 × 401。浓度或理化值(水分百分比、蛋白质含量)单独放一列作为 y。
样本ID, 900.0nm, 902.0nm, ..., 1700.0nm, 水分% S01, 1.2341, 1.2322, ..., 1.1987, 12.31 S02, 1.2103, 1.2088, ..., 1.1812, 11.97用 pandas 读进来之后,常见错误是把样本放成了列。近红外谱区变量本身是连续波长,列方向必须对应波长,这个约定影响后面所有矩阵运算。读入后建议先打印 X.shape 确认维度:第一个数字是样本数,第二个数字是波长数。如果样本数远小于波长数(比如 120 对 401),这是正常的,但这恰恰是普通最小二乘法(OLS)崩溃的根源。
2.2 为什么 OLS 在近红外光谱上不可行:共线性的数学后果
很多人会用过的建模套路是多元线性回归,但放在近红外光谱上会直接出问题。原因不是样本量不够大,而是波长变量之间存在严重共线性——900 nm 和 902 nm 处的吸光度可能相差不到 0.001,两个变量几乎线性相关。用最小二乘求解回归系数 β = (XᵀX)⁻¹Xᵀy 时,XᵀX 奇异或接近奇异,直接求逆得到的 β 数值极大、符号无意义。这就是为什么你偶尔听到老工程师说「回归系数长得像锯齿,模型根本不能用」。
PLS 回避了这个问题的思路是投影。它在 X 和 y 两侧各找一组潜变量(主因子),要求新的投影方向不仅能描述 X 本身的方差,还要与 y 有最大协方差。每提取一个主因子,就对 X 和 y 做一次残差更新,接着提取下一个因子。最终模型在潜变量空间里做回归,原始回归系数可以通过载荷矩阵投影回波长空间。这句话背后的实操含义是:你不必手动去掉任何波长,即使两个变量完全一样,PLS 也不会报错——它会自动处理冗余信息。
2.3 分子振动机制与潜变量的对应关系
近红外光谱记录的是分子中含氢基团(O-H、C-H、N-H)的倍频与合频振动。一个样本中不同成分的浓度差异,会在特定波段以吸收峰的高度和形状变化呈现出来。理论上,有多少种主要化学组分,就可能对应多少个独立的分数方向——水分、蛋白质、淀粉、油,可能各需要一个或两个潜变量。实际数据里,物理散射效应(颗粒大小、装样密度)会贡献额外的光谱差异,因此最优主因子数往往比化学组分数多几个。这也是后面选因子数时要留意的一个判断依据:如果交叉验证指示 20 个因子,那大概率不是纯化学信号,而是散射或者仪器漂移也被建模进去了。
2.4 样品集划分:建模方法的第一个分水岭
模型建得好不好,有一半在样品集划分时就已经定了。近红外模型的评估要求:训练集用来拟合,验证集不参与任何参数选择,只在最后做一次预测。常见的划分方式有随机划分和 Kennard-Stone(KS)算法。随机划分只能保证统计意义上的随机性,却无法控制两个集合的光谱分布范围一致。KS 算法则按欧氏距离逐步挑选最有代表性的样本——先找距离最远的两个样本,再把离已选集合最远的样本依次选入,直到达到目标数量。
from scipy.spatial.distance import pdist, squareform def ks_split(X, test_ratio=0.3, random_state=42): n_samples = X.shape[0] n_test = int(n_samples * test_ratio) all_idx = np.arange(n_samples) dist = squareform(pdist(X, metric='euclidean')) first = np.argmax(dist.sum(axis=0)) selected = [first] while len(selected) < n_test: remaining = np.setdiff1d(all_idx, selected) min_dist_sel = dist[remaining][:, selected].min(axis=1) new_idx = remaining[np.argmax(min_dist_sel)] selected.append(int(new_idx)) test_idx = np.array(selected) train_idx = np.setdiff1d(all_idx, test_idx) return train_idx, test_idx这段代码的核心行为是不断选择「离已选集合最远」的样本,保证测试集覆盖整个光谱空间的外围。注意min_dist_sel取的是每个候选样本到所有已选样本的最小距离,而不是平均距离;用最小距离可以避免选到某个方向上的重复样本。KS 方式的缺点也很明显——它只基于光谱距离,完全不看浓度 y 的范围。如果某些样本光谱类似但浓度差异很大,KS 可能会把它们都放进训练集,导致验证集浓度范围偏窄。所以我在实际项目中习惯先用 KS 划分,再手动检查两个集合中 y 的均值与标准差,差距超过 15% 就换一次随机种子重新跑。
3. 光谱预处理与主因子数选择,把 PLS 模型养稳
3.1 SNV、MSC、Savitzky-Golay 的参数选择
近红外光谱里的物理信息(颗粒度、装样松紧、光程变化)经常盖过化学信息。预处理的目的不是「美化曲线」,而是把这些非目标变异从光谱里去掉。三种最常见的方法参数必须会设。
标准正态变换(SNV)的做法是逐样本操作:对一条光谱的所有波长点做一次标准化,即减去该光谱的均值再除以该光谱的标准差。它对消除颗粒大小引起的乘性散射效果明显。实现只需要一行 numpy:X_snv = (X - X.mean(axis=1, keepdims=True)) / X.std(axis=1, keepdims=True)。注意是按行(样本)减均值,不是按列(波长)减,按列减就变成了普通的 z-score 标准化,那只会破坏光谱幅值信息。
多元散射校正(MSC)的思路不同:先用全部训练样本计算平均光谱作为参考谱,再把每条光谱与参考谱做一元线性回归,用回归的斜率和截距校正原始光谱。它比 SNV 更依赖样本总体的均匀性,在样本量小(少于 50)时容易过拟合。我的做法是小样本用 SNV,大样本且散射明显时才用 MSC。
Savitzky-Golay 平滑是对光谱做卷积滤波,有两个参数:窗口宽度(window length)和多项式阶数(polyorder)。窗口太大会抹平真实吸收峰,太小去噪能力不足。近红外光谱常用窗口 9-21 个点、多项式阶数 2 或 3。平滑通常与一阶导或二阶导结合使用,可以同时放大峰形变化并消除基线漂移。
3.2 预处理方法的组合策略
存在一个比较常见的问题:是否预处理组合得越多越好?并不是。近红外建模的预处理组合序列一般建议为:
| 组合方式 | 适用场景 | 典型参数 |
|---|---|---|
| 原始光谱 + PLS | 谱峰干净、基线稳定、装样重复性好 | 无 |
| SNV + 一阶导 | 粉末颗粒度差异大、基线倾斜 | SG 窗口 11,阶数 2 |
| MSC + 二阶导 | 液体或颗粒样品信号弱、背景干扰多 | SG 窗口 15,阶数 3 |
| 标准正态变换 + 去趋势 | 光纤探头采集,光程不稳定 | 窗口 5 |
| 预处理选择顺序没有绝对正确,我曾遇到“MSC 之后模型变差”的情况——因为那批样品本身颗粒分布均匀,MSC 强行将光谱对齐后反而把真实浓度差异抹掉了。建议不要把所有方法都跑一遍后选验证集效果最好的,那样会产生选择偏差。正确做法是先在训练集内做交叉验证选择预处理方法,然后用固定下来的一套预处理参数去跑最终验证集。 |
3.3 主因子数选择的交叉验证曲线
预处理决定模型的上限,主因子数决定模型是否真正达到这个上限。因子数太少,光谱中与浓度相关的信息没有被充分提取;因子数太多,噪声被当作信号建模。标准的选法是用交叉验证计算每个因子数下的预测误差(RMSECV),画一条曲线找谷底。
from sklearn.cross_decomposition import PLSRegression from sklearn.model_selection import cross_val_predict from sklearn.metrics import mean_squared_error import numpy as np def choose_n_components(X, y, max_comp=20): rmscv = [] for n in range(1, max_comp + 1): pls = PLSRegression(n_components=n, scale=False) y_pred = cross_val_predict(pls, X, y, cv=10) rmsecv = np.sqrt(mean_squared_error(y, y_pred)) rmscv.append(rmsecv) return np.array(rmscv), int(np.argmin(rmscv)) + 1上面代码里的scale=False是容易被忽略的参数:近红外光谱的各个波长本身处于同一物理量纲(吸光度)和相近幅值,不需要再对每个波长做标准化。若设成默认的scale=True,每个波长会被缩放到方差为 1,原本幅值更大的有效波段(比如水分吸收峰)会被削弱。本案例中应设scale=False。交叉验证折数为 10,样本少于 100 时建议改用留一法或 5 折。
实际选择因子数时,看曲线不用死抠最小值。我的习惯是取「谷底前一个因子数」——当 RMSECV 从 14 降到 15 只减少了 0.02,但 15 到 16 又开始上升时,我通常选 15。原因:交叉验证在最小点附近存在随机波动,偏小的因子数泛化更稳定。另一方面,如果谷底出现在最大候选值附近,说明你给的上限太低了,需要把范围扩到 30 以上再看看。 |
4. 近红外 PLS 建模方法的完整代码实现与性能评估
4.1 从 CSV 到模型的完整流程
数据读入后,建议先做一次异常样本筛查。最简单的办法是计算每条光谱的平均值和标准差,如果某个样本的光谱均值偏离整体均值 3 个标准差,多半是装样异常或者光谱仪镜头脏了。这类样本直接删除,不需要等到建模后才发现它变成了离群点。
import pandas as pd import numpy as np from sklearn.cross_decomposition import PLSRegression from sklearn.model_selection import train_test_split from sklearn.metrics import r2_score, mean_squared_error from scipy.signal import savgol_filter data = pd.read_csv('nir_sample.csv') y = data['moisture'].values wavelength_cols = [c for c in data.columns if c not in ['sample_id', 'moisture']] X_raw = data[wavelength_cols].values.astype(float) X_snv = (X_raw - X_raw.mean(axis=1, keepdims=True)) / X_raw.std(axis=1, keepdims=True) X_sg = savgol_filter(X_snv, window_length=11, polyorder=2, deriv=1, axis=1) X_train, X_test, y_train, y_test = train_test_split( X_sg, y, test_size=0.25, random_state=42) pls = PLSRegression(n_components=12, scale=False) pls.fit(X_train, y_train) y_pred_train = pls.predict(X_train).ravel() y_pred_test = pls.predict(X_test).ravel() rmsec = np.sqrt(mean_squared_error(y_train, y_pred_train)) rmsep = np.sqrt(mean_squared_error(y_test, y_pred_test)) r2c = r2_score(y_train, y_pred_train) r2p = r2_score(y_test, y_pred_test) print(f"RMSEC: {rmsec:.4f} R2C: {r2c:.4f}") print(f"RMSEP: {rmsep:.4f} R2P: {r2p:.4f}")这段代码把前面所有步骤串起来了:读入、SNV 标准化、SG 一阶导平滑、划分数据集、训练 PLS、输出训练和验证指标。逻辑说明:SNV 按行标准化去散射,SG 一阶导窗口 11 能保留较窄的吸收峰特征,deriv=1会同时消除基线偏移。
4.2 模型评估的四个关键数字
近红外模型验收时,看的不是 R² 一个数字,而是四个数字的组合。R² 接近 1 只说明线性拟合程度好,样本浓度范围大时 R² 也会虚高。真正重要的两个指标是 RMSEC 和 RMSEP——前者是训练集误差,后者是独立验证集误差。两者差值超过 20%,说明过拟合,需要减少主因子数或检查训练集是否有重复样本。
验证集浓度范围和训练集的匹配是另一个容易出问题的点。如果训练集 y 范围是 10-14,验证集落在 10.5-13.5 之间,即使 RMSEP 很小也不代表外推能力好。建议打印验证集的最小最大值,确认它基本落在训练集范围内。常见工程标准中,RMSEP 与参考方法标准偏差的比值(RPD)大于 3 可以用于定量放行,2-3 之间只能做半定量筛查。 |
4.3 残差分析该看什么
模型不是跑完就算完。验证集预测值与真实值的残差应该围绕 0 随机分布,如果残差随真值增大而增大,说明模型存在系统偏差,这通常与样品散射粒度过大有关,处理手段是改用 MSC 加二阶导组合。如果残差在某一个浓度区间整体偏正,则要考虑这个区间样本太少,训练时被整体拉偏。
residuals = y_test - y_pred_test sorted_idx = np.argsort(y_test) residuals_sorted = residuals[sorted_idx] trend = np.correlate(residuals_sorted[1:], residuals_sorted[:-1], mode='full')自相关系数明显大于 0.3 时,残差存在趋势性。这种情况我会回去检查是不是某一批样品在采集时温度不一致——温度对近红外光谱水峰位置的影响极大,恒温时间不足时,残差会呈现明显的分段模式。若确实如此,建模前应增加温度补偿。 |
5. 建模之后的三个落地技巧:离群检测、模型转移与部署
5.1 用杠杆值和残差同时筛离群样本
建模完成后,预测新样品时先别急着把结果放进 LIMS。你建好的模型是基于训练集光谱空间的,新样本离训练集越远,预测越不可靠。杠杆值 H 是衡量新样本与训练集中心的马氏距离标准化值,计算方式为:对新光谱 x_new,H = x_newᵀ (XᵀX)⁻¹ x_new。实用判据是 H 大于 3 倍训练集平均杠杆值(即 3 × n_components / n_samples)时,提示该样本超出模型适用范围。
值得注意的问题:离群不一定只由光谱引起。如果 H 值正常但预测残差大于 2 倍 RMSEP,那多半是参考值(实验室理化值)标注错误,而不是仪器问题。我处理这类样本的操作是:先重测光谱,再重送实验室化验,两者都做同样复现才考虑是否将此样本加入训练集并重新建模。
5.2 模型转移的最简方案:斜率截距校正
同一个 PLS 模型搬到另一台仪器上,预测值经常系统性偏移,根源是两台仪器波长准确度和响应灵敏度不同。没有标准样品无法做复杂的小波变换、DS,我会直接做一元线性校正:把原有模型预测的结果按y_new = a * y_old + b做映射。取 5-8 个覆盖浓度范围的样本,在主机上预测得到 y_old,在从机上化验得到真实值 y_true,用最小二乘拟合 a、b。这样做虽然损失了一些非线性细节,但几分钟内就能把 RMSEP 拉回可接受范围。工程上建议每月用 2-3 个盲样复核一次 a、b 的漂移量。 |
5.3 部署时的模型文件组织
把训练好的 PLS 模型连同预处理参数一起打包是关键。仅保存 PLSRegression 对象是不够的,因为预测新样本时你还需要模型在训练时刻用的 SNV 均值、标准差、SG 窗口和求导阶数。我习惯把预处理参数封装进一个模型字典,连同回归系数整体序列化:
import joblib model_bundle = { 'pls': pls, 'preprocess': { 'method': 'snv+sg1', 'sg_window': 11, 'sg_polyorder': 2, 'wavelengths': wavelength_cols }, 'metrics': {'rmsec': rmsec, 'rmsep': rmsep, 'r2p': r2p} } joblib.dump(model_bundle, 'pls_model_v3.joblib')部署端加载这个 bundle 后,预测前先调用savgol_filter做同样的平滑与导数,再调用 SNV 参数做标准化,最后才能送进pls.predict。部署时最恶劣的问题是有人换了一台电脑装包不齐就运行,连 scikit-learn 都从不同的环境打开,模型反序列化后预测数值漂掉好几个点——所以不要拆散依赖装包,把joblib和当前 sklearn 版本号一起写进 requirements。
本文还有配套的精品资源,点击获取