简介面向近红外光谱数据分析与机器学习建模的样本集划分资源聚焦SPXY样本划分法、蒙特卡罗交叉验证MC-CV与KS检验的工程实现。资源包共4个文件含3个MATLAB脚本与1篇PDF文献压缩包仅291KB脚本分别实现基于样本距离的SPXY划分、Kolmogorov-Smirnov分布一致性检验以及随机划分与多次抽样为蒙特卡罗交叉验证提供迭代支撑PDF文献以橘叶中橙皮苷含量测定为例系统演示SPXY划分法与蒙特卡罗交叉验证结合近红外光谱的建模流程。已有1826人学习浏览尤其适合化学计量学、光谱分析及机器学习中需要精细划分训练/验证集的研究者。通过该资源读者可掌握一套可复现的MATLAB工具理解SPXY如何降低随机划分带来的抽样偏差利用蒙特卡罗交叉验证更稳健地评估模型平均性能并结合KS检验量化训练集与测试集差异为复杂数据集的模型优化与泛化能力评估提供明确路径。1. 为什么常规随机划分会让模型结果虚高——sxpy方法在解决什么问题在化学计量和光谱建模这类活儿里样本集划分长期被当成“小事”。很多人习惯直接调用train_test_split交差完事。可模型上线后泛化效果常和测试集结果差一大截。这通常不是模型参数出了问题而是划分方式埋了雷。sxpy方法也就是资料里常写的SPXYSample Set Partitioning based on joint X-Y distances是我在小样本回归场景里最常用的一招。它不再让随机数决定样本归属而是把特征X和目标Y的分布联合起来算距离从全部样本里选出一个能最大程度覆盖整个样本空间的训练集。这样一来模型在学习阶段就见过“远处”的样本测试集的表现才更接近真实部署。下面我把这套方法从原理到实现、参数和坑一次讲透。2. sxpy的分样原理X-Y联合距离与Kennard-Stone的差别2.1 从欧氏距离到X-Y联合距离SPXY是怎么算的SPXY脱胎于Kennard-StoneKS算法。KS的思路很直白在特征空间里先选出两个相距最远的样本然后每次从剩余样本里挑一个让这个样本到“已选样本集”的最近距离最大。反复迭代训练集就像摊大饼一样从两头往中间填充最终覆盖整个凸包。这个策略被称为Farthest Point Sampling很多主动学习算法也在用。SPXY唯一也是最重要的改动是把这个“距离”从单一的特征空间距离扩展成“特征距离 目标值距离”。假设有两个样本 i 和 j它们的特征向量为 xi 和 xj目标值为 yi 和 yj。先算普通欧氏距离dx(i,j) || xi - xj ||再算目标值之间的距离dy(i,j) || yi - yj ||如果把两个距离直接相加量纲问题会立刻出来。比如近红外光谱X的取值范围是吸光度0.2到1.5而目标Y是浓度0到100。这时dx被淹没在dy里算法实际上退化成只看YX的覆盖完全失控。所以SPXY先对两类距离各自归一化再做联合dx_norm dx(i,j) / max(dx) dy_norm dy(i,j) / max(dy) dxy(i,j) dx_norm dy_norm也就是说两边各自的最大值都被压到1联合距离在0到2之间。这是SPXY能同时兼顾X和Y的关键操作。如果目标值不止一个维度比如同时预测多个成分含量dy就用两个目标向量之间的欧氏距离分母也用整体dy矩阵的最大值。实现上并不复杂只是要把y先reshape成(n_samples, n_targets)再做pdist。更一般的形式还可以引入权重dxy(i,j) alpha * dx_norm(i,j) (1 - alpha) * dy_norm(i,j)alpha1时SPXY退化为KS只看特征空间alpha0时只看目标值分布。算法本身不限制这个alpha使用者可以根据业务需求调整。不过我实际用下来默认的等权版本在大多数光谱回归任务里已经够稳调alpha的收益往往没有把样本选好来得大。初始化选哪两个样本原始SPXY和KS一样取联合距离最大的两个样本作为训练集的起点。这两个点通常是样本空间的“两极”把它们先定下来后面迭代不会让训练集整体偏向某一侧。剩下就是重复max-min选择对每个剩余样本计算它到当前训练集所有样本的最小联合距离然后选择这个最小距离最大的那个样本。这个策略的妙处在于它永远在填补样本空间里最大的“空洞”而不是随机凑数。需要理解的是这套距离计算不是对每个样本“单独评分”而是样本与样本之间的相对位置。所以如果数据里有两个聚成一团的样本簇它们之间的簇内距离小簇间距离大SPXY会优先把两个簇的边界和中心样本都纳入训练集。随着已选样本数量增加每次迭代要计算的最小距离列表也越来越长整体复杂度接近O(n² * m)所以在样本量上千后要格外小心。2.2 它与Kennard-Stone、随机划分的本质区别为了说清SPXY的定位拿三种划分方式放在一起看划分方法距离依据X空间覆盖Y值覆盖适用场景主要缺点随机划分无不可控不可控大样本、分布均匀小样本易翻车Kennard-Stone仅X距离好无保证特征空间主导忽略Y外推风险SPXYXY联合距离好好小样本回归计算成本高、依赖Y质量随机划分只保证比例不保证分布。样本量大于几千甚至上万时中心极限定理发挥作用训练集和测试集统计上接近随机划分足够。但光谱建模通常样本量只有一两百这时随机划分极容易把某一小簇样本全部塞进训练集测试集变成另一个小簇模型跨簇预测自然稀烂。KS算法在X空间里做到了“全覆盖”但它完全不看Y。一个典型困境是特征X和成分Y往往不是简单的线性关系可能存在弱相关。X分布铺得再开Y值可能在训练集里只覆盖了低浓度区间高浓度样本全被留到测试集。模型在高浓度端只能外推误差感人。这正是KS最常被诟病的地方。SPXY用联合距离绕开了这个问题。它在选样本时同时惩罚“X没见过的区域”和“Y没见过的区域”。这样训练集不仅把特征空间包住也把目标范围包住。测试集与训练集的Y范围重叠度更高模型评估结果与真实部署之间的gap会小很多。代价是计算量翻倍除了要算X距离矩阵还要算Y距离矩阵。做回归建模时我给项目组最常说的一个观点是“测试集误差好看不是目的训练集覆盖足够广才是目的”。SPXY的本质是把样本空间的“边角料”留在训练集里让模型被迫学会极端情况。所以它的训练集内部误差往往比随机划分要“难看”但拿它去预测真正不属于训练集的样本可信度高得多。2.3 什么样的情况该用SPXY什么样的情况没必要SPXY不是万能药。它最适合的场景有三个特征样本量不大、参数是连续数值、目标是回归。这里的“样本量不大”一般指南到几百顶多一两千。近红外、中红外、拉曼光谱建模以及一部分化工过程分析数据都符合这些特征。如果是分类任务Y是离散标签欧氏距离在标签上没有意义SPXY就不该硬套这时候应该用分层抽样或者用基于特征空间的KS。如果样本量超过几千甚至上万我反而建议放弃SPXY。因为联合距离矩阵是n乘n的大小一万个样本就要算一亿对距离光是存double矩阵就要800MB迭代过程还涉及反复取最小值速度很难接受。这种规模下随机划分加外部验证已经能提供足够可靠的评估或者用聚类抽样、分箱抽样更实际。还有一种情况不要吹SPXY目标值Y本身质量很差测量噪声大。此时dy矩阵里全是噪声联合距离会把噪声当成有效结构最终选入训练集的样本反而偏离真实空间。那不如退回到仅看X的KS。我曾经在一个项目中就是因为标定值误差太大SPXY划分后模型验证集误差反而升高查了半天才发现Y数据来自不同批次量级不一致。把Y统一重新校准后SPXY才恢复正常。理论上SPXY也可以用于无监督划分但那时没有Y等于KS。所以如果你的数据没有“真值”别假装用SPXY直接用KS即可。选择方法的逻辑核心其实一句话你要模型能cover住哪个空间就把哪个空间纳入距离计算。3. 用Python实现sxpySPXY样本集划分从零写一个可复用的函数3.1 算法步骤拆解初始化、迭代选取、停止条件实现SPXY前先理清四个步骤。第一步计算X距离矩阵和Y距离矩阵。X距离是特征矩阵的成对欧氏距离Y距离是目标值的成对欧氏距离。两者可以分别用numpy广播或scipy.spatial.distance.cdist计算。注意Y要先reshape成二维即使是单列目标也要保持矩阵形式。第二步距离归一化。用X距离矩阵的最大值除以所有X距离用Y距离矩阵的最大值除以所有Y距离再将两者相加得到联合距离矩阵Dxy。这步是SPXY区别于KS的核心千万别省略。第三步初始化。在Dxy中找出值最大的两个样本下标作为训练集的初始两个点。如果最大值有多个可以直接取np.argmax返回的第一个或者随机挑一对。为了可复现一般只取字典序最小的一对。第四步迭代。循环直到训练集样本数量达到目标。每次循环里先取出所有剩余样本对每个剩余样本计算它与当前训练集中所有样本的最小距离然后在这些最小距离里挑最大的那个样本加入训练集。停止条件通常是训练集数量等于n减测试集数量也可以设成指定比例。这里有一个容易写错的细节训练集数量是用总样本数减去测试集数量而不是直接用比例乘训练集。因为四舍五入会影响最终测试集比例。我一般先计算n_test int(round(n * test_size))再训练集数量就是n - n_test。3.2 完整代码实现与参数说明下面是一段可以直接放进项目里的SPXY划分函数。为了避免依赖scipy我用numpy手动展开欧氏距离代码不复杂也方便你改成自己的距离定义。import numpy as np def spxy_split(X, y, test_size0.3): SPXY样本集划分 参数 ---------- X : 2D array, (n_samples, n_features) 特征矩阵 y : 1D or 2D array, (n_samples,) or (n_samples, n_targets) 目标变量回归任务专用 test_size : float or int 测试集比例0~1之间或具体样本数 返回 ------- train_idx, test_idx : 两个1D数组分别是训练集和测试集样本下标 n X.shape[0] # 1. X距离矩阵避免for循环用展开式计算 X np.asarray(X, dtypefloat) X_sq np.sum(X ** 2, axis1) Dx np.sqrt(np.abs(X_sq[:, None] - 2.0 * X X.T X_sq[None, :])) # 2. Y距离矩阵y统一转成二维 y np.asarray(y, dtypefloat).reshape(n, -1) Dy np.sqrt(np.sum((y[:, None] - y[None, :]) ** 2, axis-1)) # 3. 归一化并组合成联合距离 dx_max Dx.max() dy_max Dy.max() # 防止单样本或y为零向量导致除零 if dx_max 0: dx_max 1.0 if dy_max 0: dy_max 1.0 Dxy Dx / dx_max Dy / dy_max np.fill_diagonal(Dxy, 0) # 4. 初始化训练集联合距离最大的两个样本 i, j np.unravel_index(np.argmax(Dxy), Dxy.shape) selected [i, j] remaining list(set(range(n)) - set(selected)) # 计算测试集样本数 if isinstance(test_size, float): if not 0 test_size 1: raise ValueError(test_size比例必须在0到1之间) n_test max(1, int(round(n * test_size))) else: n_test int(test_size) train_size n - n_test # 5. 迭代选择直到训练集数量满足要求 while len(selected) train_size: min_distances [] for idx in remaining: # 到当前训练集的最近距离 d_min Dxy[idx, selected].min() min_distances.append((d_min, idx)) # 选择最近距离最大的样本 _, new_idx max(min_distances) selected.append(new_idx) remaining.remove(new_idx) selected np.array(selected, dtypeint) test_idx np.array(list(set(range(n)) - set(selected)), dtypeint) return selected, test_idx代码逻辑并不复杂但有几个细节值得单独说。第一X距离矩阵用公式 A - 2X^T X B 展开比双重循环快得多。X_sq[:, None]和X_sq[None, :]利用广播生成每一行到每一行的平方和再减去两倍点积最后开方。中间可能出现负的极小值所以套了np.abs防止根号报nan。第二Dxy归一化时特别注意dx_max和dy_max可能为0。如果X全是相同数值或者y全是同一个值比如无标签场景max会为0直接除就崩了。我给这两个分母加了兜底改成1.0。第三np.fill_diagonal(Dxy, 0)是必须的。因为一个样本到自己的距离为0虽然在argmax时不会选到对角线但迭代过程中Dxy对角线可能因为浮点误差出现极小值如果不置零min_distances里可能会误选自身导致同一个样本被重复加进训练集。第四如果test_size是具体整数n_test循环停止条件是训练集数量等于n-n_test。这样测试集数量严格等于你指定的数量。参数说明X和y固定test_size支持0到1之间比例或绝对数量建议用比例兼容常见sklearn习惯。这个函数没有random_state参数因为SPXY本身是确定性算法跑一百次结果都一样不需要种子。如果你想让结果有随机性可以自己随机打乱样本顺序或者把初始化那一步的平局随机化。3.3 实际调用示例光谱数据划分训练集/测试集拿一个典型的光谱数据文件来演示。import pandas as pd from sklearn.preprocessing import StandardScaler # 读取示例数据X是波长吸光度y是浓度 data pd.read_csv(spectra_demo.csv) X data.drop(columns[concentration]).values y data[concentration].values # 1. SPXY划分保持测试集30% train_idx, test_idx spxy_split(X, y, test_size0.3) print(训练集样本数:, len(train_idx), 测试集样本数:, len(test_idx)) # 2. 先划分后标准化避免数据泄漏 X_train, X_test X[train_idx], X[test_idx] y_train, y_test y[train_idx], y[test_idx] scaler StandardScaler() X_train_std scaler.fit_transform(X_train) X_test_std scaler.transform(X_test)调用时要注意两点。一是样本下标必须直接对齐原始数据不要在划分之后再根据索引拼接别的字段时错位。二是标准化必须在划分之后做scaler只能fit训练集再用同一套参数transform测试集。如果先对全部数据做标准化再划分测试集的均值、方差信息已经通过scaler泄露进训练过程模型评估结果会“看着很好实际翻车”。这个问题在SPXY使用中尤其隐蔽因为SPXY本身已经让训练测试分布接近数据泄露的效果会被放大。这段代码可以直接嵌入到sklearn的Pipeline里也可以作为独立步骤。我一般会先跑一遍SPXY拿到下标然后手动检查Y在训练集和测试集中的最大最小值是否有明显缺口再进入建模。下一章就讲怎样检查以及参数怎么定。4. sxpySPXY划分的3个关键参数与效果评估4.1 训练集比例怎么选不是越大约好划分比例是很多人第一个想调的东西。SPXY对比例不敏感但小样本场景下比例选得离谱会毁掉整个评估。常见比例设置有80/20、75/25、70/30。在样本量100左右的项目里我一般选80/20或75/25。训练集比例太高比如90/10测试集只有10个样本评估指标的方差会非常大一个异常点就足以把R²拉下来0.1训练集比例太低比如60/40测试集相对充分但训练集样本太少模型学不到足够的结构。下表是经验值样本总量建议测试集比例说明小于5020%或留一法测试集太小评估不稳定尽量多次重复划分取均值50-20025%-30%最常用区间兼顾训练和评估稳定性200-100020%-25%可适当减小测试集比例大于100020%左右计算成本和评估效果平衡注意SPXY有一个特点它的训练集会系统性偏向样本空间的边界和“稀疏区”。因此测试集往往是样本空间中间相对密集的区域。如果测试集比例过大测试集就可能变得太“好预测”模型评估结果虚高。我遇到过设置50%测试集后R²高达0.97压到30%后变成0.91那0.06的差距全是划分比例造成的。因此不要为了追求测试集样本数量而把比例拉太大20%-30%已经足够。4.2 距离归一化Z-score还是Min-Max归一化是SPXY里最容易做错但又不显眼的参数。原始论文和绝大多数实现都用“除以各自距离最大值”也就是Min-Max归一化。它保证dx和dy的最大距离都是1简单直接。有的同学会习惯性地用Z-score先去标准化特征再算欧氏距离。这样做其实改变了SPXY的距离语义。SPXY关心的是“样本间的相对距离”不是“每个特征的量纲”。Z-score会让所有特征方差变成1一定程度上等同给每个特征同样的权重而Min-Max归一化不改变距离的相对分布。对于光谱数据这种相邻波长高度相关的场景Z-score反而可能放大噪声波长的影响我一般不建议。但有个例外当Y是多列时列与列之间量纲可能差别巨大。比如预测浓度和温度两列目标浓度是0-100温度是20-1000直接算Dy时温度列主导。此时应该先对Y的每一列做StandardScaler或者Min-Max再算Dy。这不是SPXY的核心归一化而是变量维度之间的预处理。如果Y只有一列那不需要额外的Y标准化直接用原始Y算距离即可。因为SPXY的dy_max会把Y距离压缩到0-1不改变相对远近。如果你对Y做平方或取log改变的是Y距离的相对关系。比如浓度差的感知可能不是线性的此时可以先对Y取log或做Box-Cox变换再参与SPXY划分。这在某些非线性响应场景会有意外效果。但不要盲目变换做完之后要用第4.3节的检查方法确认训练集与测试集分布是否更合理。4.3 用分布统计和模型结果验证划分质量SPXY不是划分完就万事大吉。我每次划分完会跑一个分布检查脚本看训练集和测试集的Y覆盖情况。def check_split_distribution(y_train, y_test): 打印训练集与测试集目标值的基本统计量 import numpy as np print(训练集 y: min%.3f max%.3f mean%.3f std%.3f % (y_train.min(), y_train.max(), y_train.mean(), y_train.std())) print(测试集 y: min%.3f max%.3f mean%.3f std%.3f % (y_test.min(), y_test.max(), y_test.mean(), y_test.std())) # 调用 check_split_distribution(y_train, y_test)理想状态下测试集的Y最小值应不低于训练集Y最小值测试集Y最大值不应超过训练集Y最大值。注意方向是测试集落在训练集的范围内部。如果测试集出现明显超出训练集范围的Y值模型只能外推这种划分不合格。SPXY大概率不会出现这种问题但如果样本量太小或者Y中有离群值仍然可能。除了Y范围还要看X空间的覆盖。可以对X特征做PCA取前两个主成分把训练集和测试集画在同一张散点图上看训练集散点是否包住测试集散点。不需要画图也可以打印测试集样本到训练集最近邻距离的分位数下一章会给脚本。最后用模型结果做交叉验证对比。比如在训练集上做5折内部交叉验证记录内交叉验证RMSE再用SPXY划分出来的测试集算外部RMSE。如果外部RMSE远大于内部交叉验证RMSE说明划分没有模拟出真实部署场景需要回头检查。通常SPXY划分出来的内外指标应该比较接近如果差异大往往不是SPXY的问题而是预处理或特征选择泄漏。5. sxpySPXY样本集划分避坑指南5个常见问题与排查方法5.1 现象训练集和测试集的Y范围高度重叠但模型外部测试误差还是很大原因SPXY保证的是样本间距离不是“独立同分布”。如果目标变量本身和特征之间存在非线性关系即使Y范围覆盖得准模型也可能在某个局部子区域表现差因为样本仍然稀疏。解决不要只看Y范围。统计测试集附近训练集样本的密度如果某个测试样本周围1-2个近邻样本全是来自测试集说明该区域训练集覆盖不足。可以增加训练集比例或在SPXY之前对Y做分位数分层保证每个分位区间都有样本。5.2 现象不同电脑或不同numpy版本跑SPXY划分结果不一样原因算法本身是确定的但如果用了np.argmax选择最大距离当存在多对最大距离样本时numpy返回的是字典序最小的而字典序在不同数据排列下可能变化。如果代码中先随打乱了X顺序结果自然不同。解决在进入SPXY前固定样本顺序用np.arange(n)作为索引如果初始最大距离有并列可以显式用随机种子选一组并记录选法。我的习惯是把划分好的索引保存成.npy文件后续模型复现直接用索引加载不重新跑划分。5.3 现象测试集R²比训练集R²还高看似完美原因SPXY会让训练集包含边界和稀疏样本这些样本本身难度大而测试集集中在中心区域内插预测本来就容易准。所以“测试集R²高于训练集R²”不代表模型好只能说明测试集选得太“舒服”了。解决把测试集换成SPXY“不要”的那部分样本后模型如果仍保持稳定才能说明泛化能力好。更靠谱的做法是用独立采集的时间序列数据做外部验证。如果你只有一份数据集至少用多次SPXY划分并报告平均外部RMSE而不是只看一次。5.4 现象样本量只有几十个SPXY划分后训练集极值太多模型振荡原因小样本下SPXY会优先把样本空间边界的“极端样本”选进训练集这些样本一旦有噪声对回归模型的拉动力极强。模型为了拟合它们可能牺牲中心区域的精度。解决对Y进行去离群值处理或Winsorize把上下1%的极端值缩尾后再划分。另一种做法是降低训练集比例至60%把一些极端点让给测试集模型反而更稳。记住SPXY是“最大化覆盖”不是“最大化精度”小样本时需要在覆盖与稳定之间妥协。5.5 现象光谱预处理放在SPXY之后做模型效果变差原因如果先对全光谱做SG平滑、多元散射校正等预处理再划分预处理的统计量实际上在划分之前已经看过了全样本测试集信息通过预处理“泄露”进距离矩阵里面的X导致SPXY选出的训练集并不纯洁。预处理本身可以作用在划分后的训练集上但全局统计量不能用测试集。解决对于逐样本独立操作的无监督预处理如导数、SNV可以先用预处理后的X划分对于全局统计性的预处理如标准化、PCA、均值中心化必须先划分再在训练集上fit。一个血泪经验是我曾经用全局均值中心化后的光谱跑SPXY训练集测试集分布很漂亮但外部验证一塌糊涂最后发现均值中心化用了全样本均值等于把测试集信息装进了X。换成训练集拟合后问题消失。6. 一个实用技巧用SPXY划分后的最近邻覆盖检查脚本每次SPXY划分完我最在意的是测试集有没有落在训练集覆盖不到的地方。一个简单有效的检查是计算每个测试样本到训练集最近邻的距离再和训练集内部的最近邻距离分布比较。import numpy as np from scipy.spatial.distance import cdist def coverage_check(X_train, X_test): 返回每个测试样本到训练集最近邻距离 以及训练集内部最近邻距离的95分位数。 如果测试样本的最近邻距离超过该分位数说明它处在训练集的稀疏区域。 d_test_train cdist(X_test, X_train).min(axis1) # 每个测试样本到训练集最近邻 d_train cdist(X_train, X_train) np.fill_diagonal(d_train, np.inf) d_train_nn d_train.min(axis1) # 训练集内部最近邻距离 threshold np.percentile(d_train_nn, 95) risky np.where(d_test_train threshold)[0] print(风险测试样本数: %d / %d % (len(risky), len(X_test))) print(95% 训练集最近邻距离阈值: %.4f % threshold) for idx in risky[:10]: print(测试样本 %d - 最近训练邻距离 %.4f % (idx, d_test_train[idx])) return d_test_train这个脚本思路很简单如果训练集内部样本之间相距都不远说明它足够密集测试样本如果距离最近训练样本比这个距离还大很多意味着它在训练集覆盖的“空洞”里模型预测大概率不可靠。结合第4.3节的Y分布检查基本能判断一次SPXY划分是否合格。我在实际项目里会把这个检查和划分函数封装在一起每次跑完SPXY自动输出风险样本ID第一时间发现“某个样本被孤零零地丢在测试集里”的情况。这样也避免后续建模到一半才发现划分不合理省下不少返工时间。对我而言样本集划分是建模里最容易被当成玄学、实际最影响上线效果的一步。希望这套做法能帮到你。本文还有配套的精品资源点击获取