光谱建模校正集与验证集划分:SPXY、KS、RS算法对比与Python实现

发布时间:2026/10/25 10:29:33
光谱建模校正集与验证集划分:SPXY、KS、RS算法对比与Python实现

简介:这份资源聚焦机器学习建模中校正集与验证集的样本划分问题,面向本科、硕士及科研人员进行教研学习使用。包内提供SPXY算法、KS算法与RS算法三种经典划分策略的Matlab实现,可用于光谱分析、近红外建模、回归预测等需要合理拆分训练与测试样本的场景,帮助使用者对比不同划分方法对模型精度与泛化能力的影响。资源共5个文件,以4个m脚本文件和1个txt说明文件为主,m文件承载各划分算法的核心函数与调用示例,txt则提示需自行带入数据,压缩包整体约6KB,轻量易读。目前已有332人学习下载。读者可借此快速理解三种算法的原理差异与代码结构,将其嵌入自己的建模流程,完成样本集划分与后续验证实验。

1. 光谱建模第一步:为什么你的校正集和验证集划分总让模型翻车

做近红外、拉曼或者高光谱定量建模的人,大概率都遇到过这种玄学:同一批样本、同一个预处理、同一个 PLS 或 SVR,换一次随机种子,R² 能从 0.95 掉到 0.72。很多人第一反应是调模型参数,其实根子常常在数据划分上——校正集(训练用)和验证集(评估用)分得不合理,验证集里混进了校正集里没有的浓度区间或异常样本,模型自然一测就崩。

这个标题讲的正是这件事:机器学习里校正集和验证集的划分方法,重点落在三种经典算法——SPXY、KS、RS。它们解决的是同一个问题:手里有一堆带标签的样本(比如 200 个小麦样品的近红外光谱 + 对应蛋白含量),怎么切出一部分当校正集去建模,剩下的当验证集去检验。RS 是随机划分,KS 是 Kennard-Stone 基于距离的均匀划分,SPXY 是 KS 的升级版,同时考虑光谱距离和浓度距离。适合做光谱、色谱、传感器阵列等小样本定量建模的人,尤其是样本量在几十到几百、又不想靠随机种子碰运气的那批人。

2. 三种划分算法到底在切什么:RS、KS、SPXY 的选型逻辑

2.1 RS 随机划分:最省事,也最容易埋雷

RS(Random Sampling)就是随机抽样。用sklearn的train_test_split或者numpy.random.permutation都能做。它的假设是样本独立同分布,随机切出来的校正集和验证集在统计上应该差不多。但小样本场景下这个假设经常不成立:200 个样本里如果浓度范围是 8% 到 18%,随机切可能让验证集集中在 12% 到 14%,校正集却覆盖了 8% 到 18%,模型在验证集上表现虚高,换一批真实样本就露馅。

RS 不是不能用,而是要用对场景。样本量大(比如上千条)、分布本身均匀、或者你只是做快速基线对比,RS 够用。但如果你要做的是最终交付的定量模型,RS 只能当对照组,不能当主力。

2.2 KS 算法:让校正集在特征空间里铺得最开

KS(Kennard-Stone)的核心思想很朴素:先选距离最远的两个样本进校正集,然后每次选那个“离已选校正集最近距离最大”的样本,直到校正集数量达标。这样做的结果是校正集在光谱空间里分布最均匀,验证集自然落在校正集包围的范围内,外推风险小。

KS 的距离通常是欧氏距离,直接算光谱矩阵的样本间距离。它的优点是确定性——同样的数据,跑多少次结果都一样,不用跟随机种子较劲。缺点是只看了 X(光谱),没看 Y(浓度)。如果两个样本光谱几乎一样但浓度差很多,KS 可能只选其中一个,另一个进验证集,模型就学不到这个浓度点。

2.3 SPXY 算法:同时管住光谱和浓度的双距离划分

SPXY(Sample set Partitioning based on joint X-Y distances)是 KS 的改进版。它把样本间距离定义成两部分:光谱距离和浓度距离,各自归一化后相加。公式常见写法是:

[ d_{xy}(i,j) = \frac{d_x(i,j)}{\max_{i,j} d_x(i,j)} + \frac{d_y(i,j)}{\max_{i,j} d_y(i,j)} ]

其中 (d_x) 是光谱欧氏距离,(d_y) 是浓度差的绝对值(多组分就取欧氏距离)。归一化是为了让两个距离量纲一致,不然浓度差 0.1 和光谱差 1000 根本没法加。

SPXY 的好处是校正集在光谱空间和浓度空间都铺得开,验证集不会出现“浓度盲区”。代价是计算量比 KS 大一点,样本量上千时距离矩阵会占内存,但几百个样本完全无压力。做近红外定量的人,如果只记一个算法,记 SPXY 就够了。

2.4 三种算法的选择对照

算法依据确定性适合场景主要风险
RS随机否大样本、快速对比验证集分布偏斜
KS光谱距离是光谱差异主导的定性/定量忽略浓度分布
SPXY光谱+浓度距离是小样本定量建模计算量略大

选型建议很直接:样本量小于 500、做定量、浓度范围宽,优先 SPXY;只做定性或者浓度分布很均匀,KS 够用;样本量上千、只想快速看模型上限,RS 可以当基线。常见做法是三个都跑一遍,比较验证集 RMSEP,但最终交付模型用 SPXY 或 KS。

3. 用 Python 把 SPXY、KS、RS 跑通:从距离矩阵到划分结果

3.1 数据准备:X 和 y 的维度要对齐

假设你有一个光谱矩阵X,形状是(n_samples, n_features),浓度向量y,形状是(n_samples,)或(n_samples, n_targets)。下面用 numpy 造一组模拟数据,实际替换成你自己的.csv或.mat读进来的数组即可。

import numpy as np from scipy.spatial.distance import cdist # 模拟 120 个样本,200 个波长点,浓度范围 8~18 np.random.seed(42) n_samples = 120 n_features = 200 X = np.random.randn(n_samples, n_features) * 0.5 + np.sin(np.linspace(0, 3, n_features)) y = np.random.uniform(8, 18, n_samples).reshape(-1, 1) print(X.shape, y.shape) # (120, 200) (120, 1)

这里X是光谱,y是浓度。注意y要 reshape 成二维,后面算距离时统一用二维数组,避免广播出错。如果你的y是多组分,比如同时测蛋白和水分,y就是(n_samples, 2),距离用欧氏距离即可。

3.2 RS 随机划分:一行代码,但别只跑一次

from sklearn.model_selection import train_test_split # 随机划分,校正集 80 个,验证集 40 个 X_cal, X_val, y_cal, y_val = train_test_split( X, y, test_size=40, random_state=0, shuffle=True ) print("RS 校正集浓度范围:", y_cal.min(), y_cal.max()) print("RS 验证集浓度范围:", y_val.min(), y_val.max())

test_size=40表示验证集 40 个,剩下 80 个进校正集。random_state固定后结果可复现,但换个种子分布就变。实际用的时候建议跑 20 次不同种子,看验证集 RMSEP 的均值和标准差,如果标准差很大,说明 RS 不适合你的数据。

3.3 KS 算法:手写距离矩阵,选最远点

def ks_partition(X, n_cal): """Kennard-Stone 划分,返回校正集索引和验证集索引""" 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_dists = [np.min(D[idx, cal_idx]) for idx in remaining] # 选最小距离最大的那个 next_idx = remaining[np.argmax(min_dists)] cal_idx.append(next_idx) remaining.remove(next_idx) val_idx = list(set(range(n)) - set(cal_idx)) return np.array(cal_idx), np.array(val_idx) cal_idx, val_idx = ks_partition(X, n_cal=80) X_cal_ks, y_cal_ks = X[cal_idx], y[cal_idx] X_val_ks, y_val_ks = X[val_idx], y[val_idx] print("KS 校正集浓度范围:", y_cal_ks.min(), y_cal_ks.max()) print("KS 验证集浓度范围:", y_val_ks.min(), y_val_ks.max())

cdist算的是(n, n)距离矩阵,120 个样本就是 14400 个距离,内存很小。np.argmax(D)找到全局最大距离的位置,np.unravel_index把它转成行列索引。循环里每次算剩余样本到已选集合的最小距离,取最大者加入。这个实现的时间复杂度是 O(n²),几百个样本秒出,上千个样本也能接受。

3.4 SPXY 算法:把浓度距离加进去

def spxy_partition(X, y, n_cal): """SPXY 划分,同时考虑光谱距离和浓度距离""" n = X.shape[0] # 光谱距离矩阵 Dx = cdist(X, X, metric='euclidean') # 浓度距离矩阵(多组分用欧氏距离) Dy = cdist(y, y, metric='euclidean') # 归一化后相加 Dxy = Dx / Dx.max() + Dy / Dy.max() cal_idx = [] i, j = np.unravel_index(np.argmax(Dxy), Dxy.shape) cal_idx.extend([i, j]) remaining = list(set(range(n)) - set(cal_idx)) while len(cal_idx) < n_cal: min_dists = [np.min(Dxy[idx, cal_idx]) for idx in remaining] next_idx = remaining[np.argmax(min_dists)] cal_idx.append(next_idx) remaining.remove(next_idx) val_idx = list(set(range(n)) - set(cal_idx)) return np.array(cal_idx), np.array(val_idx) cal_idx_spxy, val_idx_spxy = spxy_partition(X, y, n_cal=80) X_cal_spxy, y_cal_spxy = X[cal_idx_spxy], y[cal_idx_spxy] X_val_spxy, y_val_spxy = X[val_idx_spxy], y[val_idx_spxy] print("SPXY 校正集浓度范围:", y_cal_spxy.min(), y_cal_spxy.max()) print("SPXY 验证集浓度范围:", y_val_spxy.min(), y_val_spxy.max())

Dx / Dx.max()和Dy / Dy.max()把两个距离压到 0~1 之间,避免量纲差异。如果你的浓度范围很小(比如 0.01~0.05),不归一化的话浓度距离几乎不起作用,SPXY 就退化成 KS 了。归一化之后,光谱和浓度各占一半权重,这是最常见的做法。如果业务上更看重浓度覆盖,可以把浓度距离乘个系数,比如1.5 * Dy / Dy.max(),但一般不建议,除非你有明确理由。

3.5 划分结果怎么验证:看浓度分布和模型 RMSEP

from sklearn.cross_decomposition import PLSRegression from sklearn.metrics import mean_squared_error def evaluate_partition(X_cal, y_cal, X_val, y_val, n_components=10): pls = PLSRegression(n_components=n_components) pls.fit(X_cal, y_cal) y_pred = pls.predict(X_val) rmsep = np.sqrt(mean_squared_error(y_val, y_pred)) return rmsep print("RS RMSEP:", evaluate_partition(X_cal, y_cal, X_val, y_val)) print("KS RMSEP:", evaluate_partition(X_cal_ks, y_cal_ks, X_val_ks, y_val_ks)) print("SPXY RMSEP:", evaluate_partition(X_cal_spxy, y_cal_spxy, X_val_spxy, y_val_spxy))

同一组数据、同一个 PLS 模型,只换划分方式,RMSEP 就能差出不少。如果 SPXY 的 RMSEP 明显低于 RS,说明你的数据确实存在分布不均的问题,SPXY 值得用。如果三者差不多,说明样本本身很均匀,RS 也能凑合,但 SPXY 至少不会更差。

4. 避坑与排查:划分算法落地时最容易翻车的 5 个点

4.1 现象:SPXY 跑出来和 KS 一模一样

原因:浓度向量y没有归一化,或者y的数值范围远小于光谱距离,导致Dy / Dy.max()在Dxy里几乎可以忽略。解决:检查Dy.max()是否远小于Dx.max(),如果是,确认归一化代码写对了。另外,如果y是整数类别标签而不是连续浓度,SPXY 的浓度距离意义不大,应该用分层抽样而不是 SPXY。

4.2 现象:KS 选出来的校正集浓度范围比验证集还窄

原因:KS 只看光谱距离,如果光谱和浓度不是强相关,光谱上均匀的样本在浓度上可能不均匀。解决:换 SPXY,或者先对光谱做 PCA,用主成分得分算距离,再结合浓度分层。常见做法是先用 SPXY 跑一遍,如果验证集浓度覆盖不够,手动把边界浓度样本强制放进校正集。

4.3 现象:样本量 50 个,SPXY 跑完校正集 40 个,验证集 10 个,验证集 RMSEP 波动巨大

原因:验证集太小,10 个样本的 RMSEP 本身就不稳定。解决:小样本场景下不要只切一次,用 KS 或 SPXY 做多次划分(比如每次校正集 35 个、验证集 15 个,重复 20 次),看 RMSEP 的分布。或者直接用交叉验证,但交叉验证的折数也要用 KS/SPXY 来分,不能随机分。

4.4 现象:距离矩阵内存爆了

原因:样本量上万时,cdist生成的(n, n)矩阵占内存。解决:样本量超过 5000 时,KS 和 SPXY 的全距离矩阵会吃几个 GB。可以改用分批计算,或者先用 K-means 聚类,每个簇里再跑 KS/SPXY。实际光谱建模样本量通常几百,这个问题不常见,但如果你处理的是高光谱图像像素级数据,就要注意。

4.5 现象:换了随机种子,RS 的 RMSEP 比 SPXY 还好

原因:RS 碰巧切到了一个“简单”的验证集,或者 SPXY 的校正集里混入了异常样本。解决:不要只看一次结果。RS 跑 20 次取平均,SPXY 跑一次但检查校正集里有没有光谱异常点(用马氏距离或 PCA 的 Q 残差判断)。如果 SPXY 校正集里有异常样本,先剔除再划分,否则异常样本会被优先选进校正集,因为它的距离往往很大。

5. 进阶技巧:用 SPXY 做分层交叉验证,把模型评估做扎实

单次划分的验证集 RMSEP 终究只是一个点估计,样本量小的时候置信区间很宽。更稳的做法是把 SPXY 和交叉验证结合起来:每次留出一部分样本做验证,但留出的方式不是随机,而是用 SPXY 的距离排序来分层。

具体操作:先用 SPXY 把全部样本按“代表性”排序,排在前面的样本距离远、代表性强,排在后面的样本距离近、代表性弱。然后做 5 折交叉验证时,每折的验证集从排序后的不同区间抽取,保证每折验证集的浓度范围和光谱多样性都差不多。这样得到的 5 个 RMSEP 的均值和标准差,比单次划分可靠得多。

def spxy_order(X, y): """返回 SPXY 选择顺序,越靠前代表性越强""" n = X.shape[0] Dx = cdist(X, X, metric='euclidean') Dy = cdist(y, y, metric='euclidean') Dxy = Dx / Dx.max() + Dy / Dy.max() order = [] i, j = np.unravel_index(np.argmax(Dxy), Dxy.shape) order.extend([i, j]) remaining = list(set(range(n)) - set(order)) while remaining: min_dists = [np.min(Dxy[idx, order]) for idx in remaining] next_idx = remaining[np.argmax(min_dists)] order.append(next_idx) remaining.remove(next_idx) return order order = spxy_order(X, y) # 按顺序分成 5 折,每折取排序中均匀间隔的样本 n_folds = 5 folds = [[] for _ in range(n_folds)] for rank, idx in enumerate(order): folds[rank % n_folds].append(idx) for k in range(n_folds): val_idx = folds[k] cal_idx = [i for i in range(n_samples) if i not in val_idx] rmsep = evaluate_partition(X[cal_idx], y[cal_idx], X[val_idx], y[val_idx]) print(f"Fold {k+1} RMSEP: {rmsep:.4f}")

这段代码先拿到 SPXY 的完整选择顺序,然后按rank % n_folds把样本轮流分到 5 折里。这样每折验证集都覆盖了从强代表性到弱代表性的样本,浓度分布也均匀。跑完 5 折,你会得到 5 个 RMSEP,报告的时候写“RMSEP = 均值 ± 标准差”,比单次划分有说服力得多。

我自己的习惯是:只要样本量低于 300,单次划分的结果只用来快速看模型有没有大问题,最终报告一定用 SPXY 分层交叉验证的均值。这个习惯帮我避开了好几次“单次划分看起来很好、实际预测一塌糊涂”的翻车。另外,SPXY 的距离矩阵算一次就能复用,不用每折重算,代码里把Dxy缓存下来,交叉验证跑起来很快。

如果你正在做光谱定量或者任何小样本回归,先把 SPXY 跑通,再对比 KS 和 RS,最后用分层交叉验证收口。这套流程不复杂,但能让你少走很多弯路。希望帮到你。

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