☰
SPXY样本集划分算法详解:原理、Python实现与交叉验证避坑指南
2026/10/10 17:54:28 网站建设 项目流程

简介:面向数据分析与机器学习初学者的样本集划分与交叉验证资源包,聚焦SPXY样本划分法、蒙特卡罗交叉验证及KS检验在近红外光谱建模中的应用。资源共4个文件,包含spxy.m、KS.m、RS.m三个MATLAB脚本和1篇PDF文献,提供可直接运行的划分代码与原理性参考文档,压缩包整体约291KB。这种划分策略结合了样本分布特征与随机多次抽样,能有效减少因单一划分方式带来的偏差,适用于需要优化训练与验证集分配、提升模型泛化能力的科研人员或竞赛选手。目前已有1825人学习下载。通过该资源,读者可理解SPXY与蒙特卡罗交叉验证的结合思路,掌握基于样本属性进行数据集划分的方法,并借助KS检验评估模型预测分布与真实分布的一致性,从而改进近红外光谱等场景下的建模流程,提高预测精度与稳定性。

1. SPXY样本集划分:为什么交叉验证结果总是差一口气

做近红外、拉曼或者光谱建模的朋友,应该都有过这种经历:模型在交叉验证里跑得挺漂亮,可一旦换成自己留的那批验证样本,评价指标就往下掉。多数时候问题不在算法,而在训练集和验证集的划分方式——随机划分和生产环境里样本分布根本对不上。SPXY(Sample set Partitioning based on joint X-Y distances)就是专门解决这个问题的划分算法,它在经典的Kennard-Stone(KS)基础上把y值的距离也纳入了选样逻辑,保证选出来的训练集在特征空间和响应空间两个维度上都覆盖到位。本文介绍它的原理、Python实现、与交叉验证配合的具体做法,以及我实际使用中踩过的几个坑。适合正在做化学计量学建模、需要稳定复现模型评估结果的工程师。

2. 算法原理与KS对比:SPXY到底改进了什么

2.1 从KS到SPXY:为什么单单看x不够

Kennard-Stone算法是样本划分的老牌方法,思路很直接:先把样本两两之间的距离矩阵算出来,找到距离最大的一对样本作为初始集合,然后迭代地在剩余样本中挑一个「到已选样本最小距离最大」的样本加入,直到选满数量。这个逻辑保证训练集样本在变量空间(x空间)里尽量分散,避免选出来的样本扎堆。

但问题在于:KS只管x,不管y。实际建模时,x相近的样本y可能差很远,反过来y相近的样本x也可能差异很大。如果目标变量的分布不均匀,单纯按x距离划分,很容易出现训练集里y值集中在低区、高值样本全跑进验证集的情况。面对这种情况,SPXY的改进很朴素——在距离计算中把y也拉进来,让选样同时考虑变量空间和目标变量空间的分布。

2.2 y距离的加入方式与归一化问题

SPXY与KS的核心区别体现在距离公式上。设样本i和样本j之间有d_x(i,j)和d_y(i,j),SPXY的联合距离定义为:

d_xy(i,j) = d_x(i,j) / max(d_x) + d_y(i,j) / max(d_y)

d_x用欧氏距离计算,d_y是一维或多维y值之间的欧氏距离。分母分别用各自距离矩阵中的最大值做归一化,让x和y的距离贡献量级相当。如果y是一维,这一步其实就是一个「把y差缩放到和x差同级别」的过程。要注意的是,这里的归一化是在距离层面上处理,不是对原始特征做标准化。但实际操作中,如果x各列量纲差异大,需要先对x做标准化再算距离,否则x距离矩阵会被量纲大的列主导,SPXY就退化成变相的单变量选样。

这个归一化细节非常关键。很多复现SPXY的脚本直接拿原始数据算欧氏距离,x距离和y距离的量级一旦失衡(比如x值在0到1之间,y值在几百到几千之间),d_y会被压缩成接近0的小数,联合距离几乎完全由d_x决定——这等于又回到了KS。所以我一般会在计算之前对x做z-score标准化或min-max标准化,对y做min-max或按标准差缩放,具体看目标变量的分布形态。

2.3 极值贪心策略与预期效果

SPXY的选样迭代逻辑和KS完全一致:第一步找全样本中d_xy最大的两个样本作为种子,之后每一步从未选样本中,逐个计算其与所有已选样本之间的d_xy,取最小值代表这个样本与当前训练集的距离,然后选择这个最小距离最大的样本加入训练集。这个策略是贪心的,目的很简单:每次加入的样本都是和当前训练集最不相似的,从而保证覆盖度。

实际效果可以从两个角度看。第一,训练集在x和y联合空间里的凸包尽量大,模型拟合时见过的x—y组合更多样,外推能力理论上更强。第二,验证集被「挤」到训练集覆盖范围之外的概率变小,验证误差更接近真实误差。在样本量几百到几千的谱学数据集上,SPXY带来的验证集指标改善经常比换一个模型还要明显。

3. 用Python写一个SPXY划分器:核心步骤与可复现代码

3.1 核心函数实现

用numpy实现SPXY并不复杂,核心就三步:计算x距离矩阵、计算y距离矩阵、按极值贪心迭代选样本。下面给出一个单文件实现,可以在Jupyter Notebook里直接复制运行。

import numpy as np def spxy_split(X, y, train_size, random_seed=None): """ SPXY样本集划分。 X: 特征矩阵,建议先做标准化再传入 y: 目标值,支持一列或多列,需为二维数组的形态 train_size: 训练集样本数 random_seed: 随机种子,仅用于样本数相同时的初始点备选逻辑 """ X = np.asarray(X, dtype=float) y = np.asarray(y, dtype=float) if y.ndim == 1: y = y.reshape(-1, 1) n = X.shape[0] if train_size >= n: raise ValueError("train_size 必须小于样本总数") # 计算x距离矩阵 dx = np.sqrt(((X[:, None, :] - X[None, :, :]) ** 2).sum(axis=-1)) # 计算y距离矩阵 dy = np.sqrt(((y[:, None, :] - y[None, :, :]) ** 2).sum(axis=-1)) # 距离归一化到 [0, 1] 区间 dx = dx / dx.max() if dx.max() > 0 else dx dy = dy / dy.max() if dy.max() > 0 else dy dxy = dx + dy # 选择距离最大的两个样本作为初始种子 i, j = np.unravel_index(np.argmax(dxy), dxy.shape) selected = [int(i), int(j)] remaining = [idx for idx in range(n) if idx not in selected] # 贪心迭代 while len(selected) < train_size: min_dist_to_selected = dxy[np.ix_(remaining, selected)].min(axis=1) next_idx_pos = np.argmax(min_dist_to_selected) next_idx = remaining[next_idx_pos] selected.append(next_idx) remaining.remove(next_idx) train_idx = np.array(selected) test_idx = np.array(remaining) return train_idx, test_idx

这段代码看起来简单,几个细节值得展开说。距离矩阵用numpy的广播机制一次性算完,复杂度O(n^2 * p),适合样本量几千以内的场景。dxy直接加和而不是求平均,因为dx和dy各自除过最大值后都落在0~1区间,加和前后的排名顺序不会变,求平均反而多一步无意义计算。初始种子的选择用的是全局最大距离那一对样本,这是SPXY论文里的标准做法,保证起点在联合空间的两端。

3.2 调用方式与参数说明

实际使用中,调用前建议先对特征做标准化。下面这段代码展示完整流程:加载数据、标准化、划分、建模对比。

from sklearn.preprocessing import StandardScaler from sklearn.cross_decomposition import PLSRegression from sklearn.metrics import r2_score, mean_squared_error # 假设 X_all 是特征矩阵,y_all 是目标值 scaler_x = StandardScaler() X_scaled = scaler_x.fit_transform(X_all) # 目标值也做一次缩放,避免y量纲影响距离 y_scaled = (y_all - y_all.min()) / (y_all.max() - y_all.min()) train_idx, test_idx = spxy_split( X_scaled, y_scaled, train_size=120, random_seed=42 ) X_train, X_test = X_scaled[train_idx], X_scaled[test_idx] y_train, y_test = y_all[train_idx], y_all[test_idx] # 建模对比 pls = PLSRegression(n_components=8) pls.fit(X_train, y_train) y_pred = pls.predict(X_test) print("R2:", r2_score(y_test, y_pred)) print("RMSE:", mean_squared_error(y_test, y_pred, squared=False))

参数方面,train_size需要根据总样本量定。经验上训练集占比70%~80%比较稳,但要注意训练集太小会削弱覆盖度优势,验证集太小又不够评估。random_seed在这里只在样本量不足时影响初始点选择,如果你的数据量本身就小,建议多次换种子看划分稳定性。

y_scaled这一步常被忽略。y的min-max缩放是为了让dy和dx在数值上可比较,如果你直接用原始y,且y的范围是几百到几千,dy会占据绝对主导,训练集选择会过度偏向y极值样本。实际建模时,我对y做缩放后的划分结果,和用原始y跑出来的结果,验证集RMSE可以差10%以上。

3.3 对标KS和随机划分的效果差异

SPXY不是所有场景都优于KS,但有两点优势在实践中很稳。第一是y分布不均匀时,训练集的y值范围覆盖比KS宽,高阶样本被选入训练集的概率明显增加。第二是当特征变量与目标值存在较强非线性关系时,联合距离选出来的训练集在模型评估上更稳定。

下面给出三种划分方式的对比代码,直接用划分后训练集的y值范围来验证。

from sklearn.model_selection import train_test_split from scipy.spatial.distance import cdist # 随机划分 train_r, test_r = train_test_split( np.arange(n), train_size=120, random_state=42 ) # KS划分:复用上面的逻辑,只用dx dx = np.sqrt(((X_scaled[:, None, :] - X_scaled[None, :, :]) ** 2).sum(-1)) dx = dx / dx.max() i, j = np.unravel_index(np.argmax(dx), dx.shape) selected_ks = [int(i), int(j)] remaining_ks = [idx for idx in range(n) if idx not in selected_ks] while len(selected_ks) < 120: min_d = dx[np.ix_(remaining_ks, selected_ks)].min(axis=1) selected_ks.append(remaining_ks.pop(np.argmax(min_d))) print("SPXY 训练集y范围:", y_all[train_idx].min(), y_all[train_idx].max()) print("KS 训练集y范围:", y_all[selected_ks].min(), y_all[selected_ks].max()) print("随机 训练集y范围:", y_all[train_r].min(), y_all[train_r].max())

这段代码输出三个训练集的y值最大最小值。在目标变量分布偏斜的数据集上,随机划分经常出现训练集y最大值达不到总范围80%的情况,KS稍好但仍可能在y端部丢样本,SPXY基本上能把y的覆盖范围拉到接近100%。这就是SPXY在实际项目中被称为「后悔药」的原因——在数据采集阶段样本不均时,它能从源头把训练集的代表性问题解决掉,而不是靠模型硬扛。

4. 与交叉验证配合:划分在模型评估流程中的正确位置

4.1 先划分再交叉验证,而不是先交叉验证再划分

交叉验证和SPXY划分是两件不同的事。SPXY负责把全集分成训练集和外部验证集,交叉验证负责在训练集内部评估模型稳定性。正确的流程是先SPXY划分,再把划分出来的训练集交给交叉验证。如果你先把全集丢进K-fold交叉验证,选出最优模型后再随机分验证集,那验证集很可能和训练集有样本重叠,评估结果虚高。

我实际工作中使用的流程长这样:全集SPXY划分 → 训练集内部再做5折交叉验证调超参数 → 用带最优参数的模型在SPXY验证集上做最终评估。这个流程下,交叉验证负责选模型,SPXY负责保证训练集和验证集不互相「渗透」。

4.2 折数与划分比例怎么定

交叉验证折数和SPXY训练集比例要一起考虑。训练集样本量在200~500时,5折交叉验证比较合适,每折验证集有40~100个样本,统计上够稳定。样本量超过1000可以上10折,但训练集比例建议别低于70%,否则SPXY的覆盖优势在训练集内部会被交叉验证的随机抽样削弱。

这里有个容易被忽略的点:交叉验证本身也是随机划分,如果训练集内部样本分布本身有偏斜,不同折的验证结果会波动很大。所以我会在SPXY划分之后,再给交叉验证固定随机种子,让同一份训练集在多次实验中使用完全相同的折划分,保证模型对比的公平性。所有种子值记录在实验配置里,方便回溯。

4.3 验证集应该测什么指标

SPXY验证集上的指标和交叉验证指标要分开看。交叉验证指标反映模型的内部稳定性,SPXY验证集指标反映真实泛化能力。具体到光谱建模任务,我一般记录R²、RMSE和RPD三个数。RPD是标准差除以RMSE,能反映模型相对均值的提升倍数,训练集和验证集两个RPD的差值超过30%就要警惕过拟合。SPXY的好处在于验证集覆盖范围广,RPD算出来比随机划分更保守也更可信。

5. 避坑:SPXY实操里我踩过的五个坑

5.1 特征未标准化导致距离矩阵失真

现象:划分结果与预期完全不符,训练集样本在原始特征空间里聚集在一起,验证集误差远高于交叉验证。

原因:特征列量纲差异大时,量纲大的列在欧氏距离中占比过高,SPXY实际上变成了按单变量排序选样本。

解决:计算距离矩阵前先对X做StandardScaler或MinMaxScaler。这个步骤必须在划分前完成,不能放到Pipeline里和模型一起拟合,因为划分时需要的是处理后的X,不是原始X。

5.2 y距离量纲与x距离量纲失衡

现象:训练集y值覆盖范围确实很窄,尤其在y本身数值范围大的回归任务里。

原因:dy没有归一化,直接和dx加和后,dy压过dx,算法退化为按y值极值选样本。

解决:对y做min-max缩放或者除标准差,再传入SPXY。实际做的时候,我倾向于用y的min-max缩放,因为缩放后的距离、归一化逻辑都和dx一致。特别注意:如果y是多列(多目标建模),缩放要对每一列分别做,不能用全局最大值。

5.3 数据集过大时内存爆炸

现象:样本数一万以上时,直接计算距离矩阵会导致内存占用达到几个GB,程序卡死或OOM。

原因:距离矩阵的复杂度是O(n^2),n=10000时dx和dy各需要800MB以上,两个矩阵加起来很容易超过2GB。

解决:一是先做粗筛,用KS在全体样本上选一个较大的候选集(比如总样本的50%),再用SPXY在候选集内精细划分;二是改用分批计算距离的方式,循环计算每个候选样本到已选样本的距离,但速度会慢一些。我的习惯是:样本数超过8000就走粗筛流程。

5.4 划分结果不可复现

现象:同一份数据、同一个train_size,两次运行得到不同的训练集索引,导致实验结果对不上。

原因:numpy的argmax在最大值重复时返回第一个索引,但如果代码里用到随机操作(比如用random.choice选初始点),结果就会漂移。

解决:在划分函数里固定随机种子,或者干脆不用随机操作,直接用全局最大距离那对样本做初始点。我的划分代码全流程不用随机数,输出确定性的索引数组。

5.5 把SPXY的验证集当成了测试集

现象:模型调参过程中反复在SPXY验证集上评测,验证集指标越来越高,但换到真正新采集的样本上效果大跌。

原因:SPXY验证集一旦被用于调参决策,它就变成了训练集的一部分,信息从验证集泄漏到了模型选择过程中。

解决:SPXY划分后的验证集只在最终模型确定后使用一次。如果调参需求多,就从训练集内部再切出一个校准集用于调参,SPXY验证集保持隔离。这个习惯我从翻车一次之后就再也没变过。

6. 进阶:把SPXY划分固化成可复现的流水线

单纯会调用SPXY还不够,实际项目里数据会更新、样本会增删,划分流程必须能重复执行。我把划分过程封装成了一个带配置的完整流程。关键点有三个:保存划分索引、固定随机状态、记录特征与目标值的预处理参数。

import json import numpy as np class SpxyPipeline: def __init__(self, train_ratio=0.75, y_scaler_type="minmax"): self.train_ratio = train_ratio self.y_scaler_type = y_scaler_type self.scaler_x = None self.y_min = None self.y_max = None def fit_split(self, X_all, y_all): # 特征标准化 self.scaler_x = StandardScaler() X_scaled = self.scaler_x.fit_transform(X_all) # y缩放 y_all = np.asarray(y_all).reshape(-1, 1) self.y_min = y_all.min() self.y_max = y_all.max() y_scaled = (y_all - self.y_min) / (self.y_max - self.y_min) n = X_all.shape[0] train_size = int(n * self.train_ratio) train_idx, test_idx = spxy_split(X_scaled, y_scaled, train_size) # 保存索引与预处理参数 result = { "train_idx": train_idx.tolist(), "test_idx": test_idx.tolist(), "y_min": float(self.y_min), "y_max": float(self.y_max), "scaler_mean": self.scaler_x.mean_.tolist(), "scaler_scale": self.scaler_x.scale_.tolist(), } with open("spxy_split_result.json", "w") as f: json.dump(result, f, indent=2) return train_idx, test_idx

这个封装把特征标准化的均值和标准差、y缩放的上下限、划分索引全部落盘。下次来了新样本,直接用同一个scaler和同一个划分规则判断它落在哪个集合。如果模型上线后需要增量验证,这份JSON就是唯一的划分依据,不会因为重新运行脚本而产生不同的分组。

验证划分可靠性的方法也很简单:对划分后的训练集和验证集分别计算x和y的覆盖范围,画一张并排的箱线图。如果验证集的y分布明显缺了高值或低值区,说明划分逻辑有问题,回查标准化和归一化那两步。我一直觉得,SPXY的价值不在于它一定比随机划分准,而在于它把「样本代表性」这个模糊的问题变成了一个可量化、可复现的流程。从那以后,我每次建模前都强制走一遍这个流水线,把划分结果存进实验记录再开始调参,省掉了大量无谓的重复劳动。希望帮到你。

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

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

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

立即咨询