光谱回归建模中校正集与验证集划分:RS、KS、SPXY算法对比与Python实现
简介:这份资源聚焦机器学习中校正集与验证集的划分方法,面向本科、硕士及科研人员进行实验设计与教研学习,帮助解决样本集合理拆分、模型评估可靠性等常见问题。包内包含SPXY算法、KS算法与RS算法三种典型划分策略的Matlab实现,可直接用于光谱建模、回归预测等需要代表性样本选择的场景。资源共5个文件,以4个m脚本文件和1个txt说明文件为主,m文件承载各划分算法的核心代码,txt则提示需自行带入数据,压缩包整体约6KB,轻量便于快速查阅与移植。目前已有332人学习下载,读者可借此对比不同划分算法的实现思路与适用条件,理解样本分布对建模结果的影响,并在此基础上替换自有数据完成校正集与验证集的构建,为后续模型训练与验证提供可复用的脚本参考。
1. 校正集和验证集怎么分:三个算法决定模型是骡子是马
做回归建模的人多半遇到过这种玄学:同一批光谱数据、同一个 PLSR 模型,换个人重新划分校正集和验证集,预测精度能从 R²=0.95 掉到 0.72。这不是模型不行,是划分方法在作祟。校正集(calibration set)用来训练模型,验证集(validation set)用来评估模型泛化能力,两者怎么切,直接决定了你看到的精度是真实水平还是运气。随机划分 RS 简单粗暴,但在样本分布不均时会把极端样本全塞进验证集;KS 算法按光谱距离排序后等间隔抽样,能保证校正集覆盖全浓度范围;SPXY 算法同时考虑光谱距离和浓度距离,是当前化学计量学里公认较稳的方案。这篇笔记把三种算法的原理、Python 实现、参数设置和踩坑记录一次讲透,适合做近红外、拉曼、高光谱定量建模的从业者直接抄作业。
2. RS、KS、SPXY 三种划分算法到底差在哪
2.1 随机划分 RS:最简单也最容易翻车
RS(Random Sampling)就是调numpy.random或sklearn的train_test_split,按比例随机抽。它的假设是样本独立同分布,但光谱数据往往存在浓度梯度,随机抽可能导致校正集里高浓度样本扎堆、验证集里全是低浓度,模型外推能力直接崩掉。
我一般会先跑 RS 作为基线,如果 RS 和 KS/SPXY 结果差距在 5% 以内,说明样本分布本身比较均匀,用 RS 省事;差距大就说明数据有结构,必须上 KS 或 SPXY。
import numpy as np from sklearn.model_selection import train_test_split def rs_split(X, y, test_size=0.3, seed=42): """ X: 光谱矩阵 (n_samples, n_wavelengths) y: 浓度向量 (n_samples,) test_size: 验证集比例 seed: 随机种子,固定后结果可复现 """ X_cal, X_val, y_cal, y_val = train_test_split( X, y, test_size=test_size, random_state=seed, shuffle=True ) return X_cal, X_val, y_cal, y_val逻辑说明:train_test_split默认 shuffle=True,random_state必须固定,否则每次跑结果都不一样,论文里的精度没法复现。参数上test_size=0.3是经验值,样本量小于 100 时建议降到 0.2,否则校正集样本太少模型欠拟合。
2.2 KS 算法:按光谱距离等间隔抽样
KS(Kennard-Stone)算法的核心思想是:先选距离最远的两个样本进校正集,然后每次选距离当前校正集最远的样本加入,直到达到目标数量。这样校正集在光谱空间里分布最均匀。
实现时用欧氏距离矩阵,逐步更新最小距离。计算复杂度 O(n²),样本量上千时要注意内存。
from scipy.spatial.distance import cdist def ks_split(X, y, n_cal): """ X: 光谱矩阵 y: 浓度向量 n_cal: 校正集样本数 """ n = X.shape[0] D = cdist(X, X, metric='euclidean') # 距离矩阵 cal_idx = [] # 选距离最远的两个样本 i, j = np.unravel_index(np.argmax(D), D.shape) cal_idx.extend([i, j]) # 剩余样本 remaining = list(set(range(n)) - set(cal_idx)) while len(cal_idx) < n_cal: # 每个剩余样本到校正集的最小距离 min_d = [np.min(D[k, cal_idx]) for k in remaining] next_idx = remaining[np.argmax(min_d)] cal_idx.append(next_idx) remaining.remove(next_idx) val_idx = list(set(range(n)) - set(cal_idx)) return X[cal_idx], X[val_idx], y[cal_idx], y[val_idx]逻辑说明:cdist算全距离矩阵,np.argmax(D)找全局最远点对作为种子。循环里min_d是每个候选样本到已选集合的最近距离,选最大的那个加入,保证新样本离现有校正集最远。参数n_cal一般取总样本的 70%~80%,样本量少于 50 时建议 80%。
注意:KS 只考虑光谱距离,如果浓度分布不均匀,可能出现校正集光谱覆盖好但浓度范围窄的情况,这时候要上 SPXY。
2.3 SPXY 算法:光谱距离和浓度距离一起算
SPXY(Sample set Partitioning based on joint X-Y distances)在 KS 基础上做了改进:距离度量同时包含光谱距离和浓度距离,归一化后相加。这样选出的校正集在光谱空间和浓度空间都均匀。
公式是 d_xy = d_x/d_x_max + d_y/d_y_max,其中 d_x 是光谱欧氏距离,d_y 是浓度欧氏距离。
def spxy_split(X, y, n_cal): """ X: 光谱矩阵 y: 浓度向量 n_cal: 校正集样本数 """ # 光谱距离 Dx = cdist(X, X, metric='euclidean') Dx = Dx / Dx.max() # 浓度距离 y_2d = y.reshape(-1, 1) Dy = cdist(y_2d, y_2d, metric='euclidean') Dy = Dy / Dy.max() # 联合距离 D = Dx + Dy n = X.shape[0] cal_idx = [] i, j = np.unravel_index(np.argmax(D), D.shape) cal_idx.extend([i, j]) remaining = list(set(range(n)) - set(cal_idx)) while len(cal_idx) < n_cal: min_d = [np.min(D[k, cal_idx]) for k in remaining] next_idx = remaining[np.argmax(min_d)] cal_idx.append(next_idx) remaining.remove(next_idx) val_idx = list(set(range(n)) - set(cal_idx)) return X[cal_idx], X[val_idx], y[cal_idx], y[val_idx]逻辑说明:Dx和Dy分别归一化到 [0,1] 再加权,避免量纲差异导致某一项主导。Dx.max()和Dy.max()是归一化因子,必须用当前数据集的最大值,不能写死。参数n_cal和 KS 一致,但 SPXY 对浓度梯度大的数据集更稳。
提示:SPXY 计算量比 KS 大,样本超过 2000 时建议先做 PCA 降维再算距离,否则
cdist会吃满内存。
3. 用 Python 把三种划分跑通:从数据到精度对比
3.1 数据准备和预处理
拿一份近红外光谱数据举例,假设X是 200×500 的矩阵,y是 200 个浓度值。预处理先做 SNV(标准正态变量变换)消除散射影响,再做一阶导数增强峰特征。
import numpy as np from scipy.signal import savgol_filter def snv(X): """标准正态变量变换""" mean = X.mean(axis=1, keepdims=True) std = X.std(axis=1, keepdims=True) return (X - mean) / std def first_derivative(X, window=11, poly=2): """Savitzky-Golay 一阶导数""" return savgol_filter(X, window_length=window, polyorder=poly, deriv=1, axis=1) # 假设 X_raw, y 已加载 X = snv(X_raw) X = first_derivative(X)逻辑说明:snv按行做标准化,消除样本间基线漂移。savgol_filter的window_length必须奇数,polyorder小于window_length,一阶导数用deriv=1。参数window=11是经验值,光谱分辨率高时可以调到 15 或 21。
3.2 三种划分的完整调用和建模
用 PLSR(偏最小二乘回归)做模型,统一用 10 折交叉验证选主成分数,然后看验证集 RMSEP 和 R²。
from sklearn.cross_decomposition import PLSRegression from sklearn.metrics import mean_squared_error, r2_score def evaluate_split(X_cal, X_val, y_cal, y_val, n_components=10): pls = PLSRegression(n_components=n_components) pls.fit(X_cal, y_cal) y_pred = pls.predict(X_val).ravel() rmsep = np.sqrt(mean_squared_error(y_val, y_pred)) r2 = r2_score(y_val, y_pred) return rmsep, r2 # RS X_cal_rs, X_val_rs, y_cal_rs, y_val_rs = rs_split(X, y, test_size=0.3, seed=42) rmsep_rs, r2_rs = evaluate_split(X_cal_rs, X_val_rs, y_cal_rs, y_val_rs) # KS X_cal_ks, X_val_ks, y_cal_ks, y_val_ks = ks_split(X, y, n_cal=140) rmsep_ks, r2_ks = evaluate_split(X_cal_ks, X_val_ks, y_cal_ks, y_val_ks) # SPXY X_cal_spxy, X_val_spxy, y_cal_spxy, y_val_spxy = spxy_split(X, y, n_cal=140) rmsep_spxy, r2_spxy = evaluate_split(X_cal_spxy, X_val_spxy, y_cal_spxy, y_val_spxy) print(f"RS: RMSEP={rmsep_rs:.4f}, R2={r2_rs:.4f}") print(f"KS: RMSEP={rmsep_ks:.4f}, R2={r2_ks:.4f}") print(f"SPXY: RMSEP={rmsep_spxy:.4f}, R2={r2_spxy:.4f}")逻辑说明:PLSRegression的n_components是主成分数,一般通过交叉验证确定,这里固定 10 方便对比。evaluate_split返回 RMSEP 和 R²,RMSEP 越小、R² 越接近 1 越好。实际跑下来,SPXY 通常比 RS 的 RMSEP 低 10%~30%,KS 介于两者之间。
3.3 参数怎么调:校正集比例和主成分数
校正集比例不是固定的。样本量 50~100 时,校正集占 80%;100~500 时,70%~75%;超过 500 时,60%~70% 就够。主成分数用交叉验证的 RMSECV 最小值确定,不要超过校正集样本数的 1/10。
| 样本量 | 校正集比例 | 主成分数上限 |
|---|---|---|
| <50 | 80% | 5 |
| 50-100 | 75%-80% | 8 |
| 100-500 | 70%-75% | 15 |
| >500 | 60%-70% | 20 |
注意:主成分数过多会过拟合,RMSEC 很低但 RMSEP 很高,这时候要回头检查划分方法是不是把异常样本漏进校正集了。
4. 避坑记录:划分校正集和验证集时最容易翻车的 5 个点
4.1 现象:RS 每次跑精度都不一样,论文没法复现
原因:train_test_split没固定random_state,或者用了shuffle=True但没设种子。解决:所有随机操作必须固定种子,seed=42是惯例,写进代码注释里。
4.2 现象:KS 选出的校正集浓度范围比验证集窄
原因:KS 只按光谱距离选样本,如果浓度和光谱不是强相关,会出现光谱覆盖好但浓度覆盖差的情况。解决:换 SPXY,或者先按浓度分层再做 KS。
4.3 现象:SPXY 跑得特别慢,内存爆了
原因:cdist算的是 n×n 全距离矩阵,样本 5000 时矩阵是 5000×5000,float64 占 200MB,加上中间变量直接吃满。解决:先做 PCA 降到 20~50 维再算距离,或者用scipy.spatial.distance.pdist算压缩距离。
4.4 现象:校正集 R²=0.99,验证集 R²=0.6
原因:校正集里混入了异常样本,或者校正集和验证集有重叠。解决:建模前用马氏距离或 PCA 的 Hotelling T² 筛异常样本,划分后检查两个集合的索引有没有交集。
4.5 现象:换了批次数据,SPXY 效果还不如 RS
原因:不同批次的光谱基线差异大,直接算欧氏距离会把批次差异当成样本差异。解决:先做批次校正(如 PDS、OSC),再跑 SPXY。如果批次效应太强,考虑按批次分层划分。
5. 进阶技巧:用 Kennard-Stone 的变体做分层 SPXY
标准 SPXY 有个隐患:如果浓度分布本身不均匀(比如低浓度样本占 80%),联合距离会被浓度项主导,选出的校正集在光谱空间反而稀疏。我一般会做分层 SPXY:先按浓度分箱,每个箱内按比例抽样本,再在抽出的样本上跑 SPXY。
def stratified_spxy_split(X, y, n_cal, n_bins=5): """ 分层 SPXY:先按浓度分箱,每箱按比例抽候选样本,再跑 SPXY """ bins = np.linspace(y.min(), y.max(), n_bins + 1) bin_idx = np.digitize(y, bins) - 1 bin_idx = np.clip(bin_idx, 0, n_bins - 1) candidate_idx = [] for b in range(n_bins): idx_b = np.where(bin_idx == b)[0] n_b = max(2, int(len(idx_b) * n_cal / len(y))) n_b = min(n_b, len(idx_b)) chosen = np.random.choice(idx_b, n_b, replace=False) candidate_idx.extend(chosen) candidate_idx = np.array(candidate_idx) X_sub, y_sub = X[candidate_idx], y[candidate_idx] X_cal_sub, X_val_sub, y_cal_sub, y_val_sub = spxy_split(X_sub, y_sub, n_cal=min(n_cal, len(candidate_idx))) # 映射回原始索引 return X_cal_sub, X_val_sub, y_cal_sub, y_val_sub逻辑说明:np.digitize按浓度分箱,每个箱按比例抽候选样本,保证低浓度和高浓度都有代表。n_b = max(2, ...)保证每箱至少 2 个样本,否则 SPXY 选不出距离最远点对。参数n_bins=5是经验值,浓度范围跨两个数量级时用 8~10 箱。
验证方法:跑 10 次不同随机种子的分层 SPXY,看 RMSEP 的均值和标准差。如果标准差小于 0.05,说明划分稳定;如果大于 0.1,说明数据本身有问题,先查异常样本和批次效应。
我自己的习惯是:拿到新数据先跑 RS 看基线,再跑 KS 和 SPXY 对比,如果 SPXY 比 RS 的 RMSEP 低不到 5%,说明数据分布均匀,直接用 RS 省时间;如果低 15% 以上,必须用 SPXY,并且把划分代码和随机种子一起存档,不然三个月后回头复现就是一场血泪史。希望帮到你。
本文还有配套的精品资源,点击获取