简介:这份资源围绕SPXY样本划分法展开,面向从事近红外光谱分析、化学计量学建模及机器学习数据预处理的研究生与技术人员,用于解决复杂或不均衡数据集中训练集与验证集代表性不足、模型泛化能力难以评估的问题。压缩包共4个文件,以3个m脚本和1份pdf文献为主,脚本可直接在MATLAB中运行,分别实现SPXY样本划分、KS检验与随机抽样划分,pdf则结合蒙特卡罗交叉验证,展示橘叶中橙皮苷含量测定的完整建模思路,包体约291KB,轻量便于快速复现。目前已有1825人学习下载,读者可借此掌握样本集划分与交叉验证的衔接方式,理解KS检验在分布一致性评估中的作用,并迁移到自身光谱或回归建模任务中,提升预测精度与结果稳定性。
1. 样本集划分的隐形陷阱:为什么随机切分常常让模型“虚高”
很多人做回归或分类建模时,习惯性地把数据一股脑丢进train_test_split,看着验证集上的 R² 或准确率还不错,就以为模型稳了。但真正把模型推到新样本上跑,性能往往断崖式下跌。问题常常不在模型本身,而在于样本集划分这一步就已经埋了雷。随机划分只保证“数量上”的训练测试比例,却完全不关心样本在特征空间或目标值空间里的分布。如果某些极端样本恰好全被分进训练集,测试集就变成了“简单模式”;反过来,测试集里出现训练集从未见过的目标值区间,模型只能瞎猜。SPXY 方法要解决的就是这个痛点:它让划分后的训练集和测试集在特征空间与目标值空间上都尽可能覆盖全局,从而让验证结果真正可信。搞懂它,你才能判断自己模型报告里的那些指标到底是实力还是运气。
2. SPXY 的底层机制:从 KS 到联合距离的演进逻辑
2.1 为什么单纯的 KS 方法不够用
KS(Kennard-Stone)方法是样本集划分里的经典算法,思路很直接:先选距离最远的两个样本进入训练集,然后每次选一个“到当前训练集最近距离”最大的样本加入,直到训练集达到目标数量。它的核心是“最大化训练集在特征空间中的覆盖范围”。但 KS 只看了特征矩阵 X,完全忽略了目标值 y。这在分类任务里问题不大,因为类别标签本身是离散的,特征空间覆盖好了,类别分布通常也差不到哪去。但在回归任务里,目标值 y 是连续的,如果只按 X 选样本,完全可能出现训练集 y 的范围很窄、测试集 y 的范围很宽的情况。模型在训练时根本没“见过”那些目标值区间的样本,测试时自然抓瞎。常见的翻车场景是:光谱数据里,某些浓度区间的样本恰好特征相似,KS 选来选去都在同一个浓度带里打转,测试集却包含高浓度样本,模型直接失效。
2.2 SPXY 的联合距离定义
SPXY(Sample set Partitioning based on joint X-Y distances)在 KS 的基础上做了一个关键改进:它不再只用特征空间的距离,而是把特征距离和目标值距离联合起来。具体来说,对于任意两个样本 i 和 j,分别计算它们在特征空间的欧氏距离 d_x(i,j) 和在目标值空间的欧氏距离 d_y(i,j),然后做归一化后相加,得到联合距离:
d_xy(i,j) = d_x(i,j) / max(d_x) + d_y(i,j) / max(d_y)
其中 max(d_x) 和 max(d_y) 分别是所有样本对中特征距离和目标值距离的最大值。这个归一化步骤很关键,因为特征维度和目标值维度的量纲通常不同,不归一化的话,量级大的那个会主导距离计算。归一化之后,两个空间的距离贡献被拉到同一尺度,联合距离才能真正反映样本在“特征-目标”联合空间中的差异。这个设计动机很朴素:训练集不仅要覆盖特征空间的多样性,还要覆盖目标值空间的多样性,两者缺一不可。
2.3 选样过程的贪心策略
SPXY 的选样流程和 KS 几乎一样,只是距离度量换成了联合距离。具体步骤是:
- 计算所有样本对之间的联合距离矩阵。
- 选出联合距离最大的两个样本,放入训练集。
- 对于剩余每个样本,计算它到当前训练集所有样本的联合距离,取最小值。
- 选出这些最小值中最大的那个样本,加入训练集。
- 重复步骤 3-4,直到训练集样本数达到预设值。
这个贪心策略的本质是:每次都在“离当前训练集最远”的样本里挑一个,保证训练集在联合空间里尽可能分散。它不保证全局最优,但计算复杂度可控,效果在大多数场景下足够好。常见的实现思路是用一个距离矩阵缓存所有样本对的联合距离,然后维护一个“每个样本到训练集的最小距离”数组,每次选完新样本后更新这个数组。这样整个选样过程的复杂度是 O(n²),对于几千个样本的数据集完全够用。
3. 源码级实现:联合距离矩阵与贪心选样的代码拆解
3.1 距离矩阵的向量化计算
直接写双重循环计算距离矩阵在 Python 里会很慢,尤其是样本量上千的时候。常见的做法是用向量化操作。下面是一个基于 NumPy 的实现片段:
import numpy as np def spxy_split(X, y, train_size, random_state=None): """ SPXY 样本集划分 X: 特征矩阵, shape (n_samples, n_features) y: 目标值向量, shape (n_samples,) train_size: 训练集样本数 返回: 训练集索引, 测试集索引 """ n = X.shape[0] # 特征空间距离矩阵: 利用 (a-b)^2 = a^2 + b^2 - 2ab 向量化 X_sq = np.sum(X ** 2, axis=1, keepdims=True) d_x = np.sqrt(np.maximum(X_sq + X_sq.T - 2 * X @ X.T, 0)) # 目标值空间距离矩阵 y_sq = y ** 2 d_y = np.sqrt(np.maximum(y_sq[:, None] + y_sq[None, :] - 2 * y[:, None] * y[None, :], 0)) # 归一化后相加 d_xy = d_x / d_x.max() + d_y / d_y.max() # 贪心选样 selected = [] # 先选联合距离最大的两个样本 idx_max = np.unravel_index(np.argmax(d_xy), d_xy.shape) selected.extend(idx_max) # 维护每个样本到已选集合的最小距离 min_dist = np.minimum(d_xy[selected[0]], d_xy[selected[1]]) min_dist[selected] = -1 # 已选样本标记为负,避免重复选中 while len(selected) < train_size: next_idx = np.argmax(min_dist) selected.append(next_idx) min_dist = np.minimum(min_dist, d_xy[next_idx]) min_dist[selected] = -1 train_idx = np.array(selected) test_idx = np.setdiff1d(np.arange(n), train_idx) return train_idx, test_idx这段代码有几个关键点值得展开。第一,距离矩阵的向量化计算利用了欧氏距离的平方展开式,避免了显式的双重循环,在样本量几千时速度差异非常明显。第二,np.maximum(..., 0)是为了处理浮点误差导致的微小负数,防止sqrt报错。第三,min_dist数组的维护是贪心策略的核心:每次选完新样本后,用np.minimum更新所有样本到训练集的最小距离,然后把已选样本置为 -1,保证下一轮不会重复选中。这个实现的时间复杂度主要在距离矩阵计算上,是 O(n²),选样过程也是 O(n²),整体对于 n=5000 左右的数据集,在普通笔记本上几秒内能跑完。
3.2 参数选择与边界情况
train_size的设定直接影响划分效果。如果训练集太小,联合空间的覆盖会不足;如果太大,测试集又太少,验证结果方差大。常见的经验是训练集占 70%~80%,但具体要看样本总量和任务难度。对于样本量本身就不大的数据集(比如 n<100),SPXY 的优势可能不明显,因为贪心选样的随机性会放大。另外,如果目标值 y 的分布极度偏斜,比如大部分样本集中在一个窄区间,少数样本在极端值,联合距离会被极端值主导,选样时可能过度偏向那些极端样本。这种情况下,可以考虑先对 y 做变换(比如对数变换)再计算距离,或者对 d_y 做截断处理。还有一个容易忽略的点:如果 X 里包含类别型特征,直接算欧氏距离是不合理的,需要先做独热编码或者用 Gower 距离替代。这些边界情况在实际项目里经常遇到,提前想清楚能省很多调试时间。
4. 避坑与排查:SPXY 落地时的常见问题
4.1 现象:划分后训练集和测试集的目标值分布仍然重叠严重
原因:联合距离里 d_y 的归一化被 d_x 的量级压制了。虽然代码里做了归一化,但如果特征维度很高(比如几百维),d_x 的分布会很集中,归一化后大部分值都在 0.8~1.0 之间,而 d_y 可能分布很散,归一化后区分度反而更大。但反过来,如果特征维度低且目标值范围小,d_y 的归一化可能让所有样本的目标值距离都接近,联合距离退化成纯特征距离。
解决:在计算联合距离前,先检查 d_x 和 d_y 的分布。可以画个直方图,或者直接打印两者的均值和方差。如果发现某一项几乎没区分度,可以调整归一化方式,比如用标准差归一化替代最大值归一化,或者给 d_x 和 d_y 加权重系数,手动调节两者的贡献比例。
4.2 现象:选样结果每次运行都不一样
原因:SPXY 本身是确定性算法,只要输入数据不变,结果应该一致。但如果你的代码里在计算距离矩阵前对数据做了随机打乱,或者用了随机初始化的模型做特征提取,那结果就会变。另一个常见原因是浮点误差导致np.argmax在距离相近的样本间随机选择。
解决:固定随机种子,确保特征提取过程可复现。对于浮点误差,可以在argmax前对min_dist做四舍五入到小数点后几位,或者用np.isclose处理近似相等的距离。如果确实需要多次运行取平均,那就要在划分前固定数据顺序,只对模型训练过程引入随机性。
4.3 现象:训练集选出来的样本在特征空间里仍然很集中
原因:贪心策略的早期选择影响很大。如果前两个样本恰好来自同一个密集区域(虽然联合距离最大,但可能只是目标值差异大),后续选样会围绕这个区域展开,导致整体覆盖不均。
解决:可以改用“最远点采样”的变体,比如先随机选一个样本,然后每次选距离当前集合最远的样本,重复多次取平均。或者直接用 K-means 聚类,在每个簇里按比例选样本,再在簇内用 SPXY 微调。这些方法计算量更大,但覆盖效果更稳。
4.4 现象:测试集里出现了训练集完全没有的目标值区间
原因:联合距离虽然考虑了 y,但贪心选样是逐步进行的,如果某个极端 y 值的样本在早期没被选中,后期可能因为“到训练集的最小距离”被其他样本拉低而落选。
解决:在选样前先做一次“强制覆盖”:把 y 值最大和最小的样本直接放入训练集,然后再跑 SPXY。这样能保证训练集的 y 范围至少覆盖全局的极值。对于分类任务,可以先把每个类别的样本各选一个放入训练集,再跑 SPXY。
4.5 现象:样本量很大时 SPXY 跑得特别慢
原因:距离矩阵是 O(n²) 的内存和计算复杂度。n=10000 时,距离矩阵就是 1 亿个浮点数,内存占用约 800MB,计算时间也会显著增加。
解决:如果样本量超过 5000,可以考虑先用随机采样或聚类做一次粗筛,把样本量降到 2000 左右再跑 SPXY。或者用近似最近邻方法(比如 KD-Tree、Ball-Tree)加速距离计算,但要注意这些方法在高维空间里效果会退化。另一个思路是分块计算距离矩阵,每次只算一部分,但实现复杂度会高不少。
5. 设计取舍与验证:SPXY 不是万能药
SPXY 的核心价值在于它把“样本代表性”这个模糊的概念拆解成了可计算的距离度量。但它也有明显的取舍。首先,联合距离的归一化方式没有理论最优解,最大值归一化、标准差归一化、秩归一化各有适用场景,选哪个往往要靠实验。其次,贪心策略不保证全局最优,对于某些特殊分布的数据,它可能不如分层抽样或聚类抽样。再者,SPXY 假设特征空间和目标值空间的欧氏距离是有意义的,如果特征里混有类别型变量,或者目标值是周期性的,直接算欧氏距离就会出问题。
我一般会这么理解:SPXY 适合那种“特征和目标值都连续、且样本量在几百到几千之间”的场景,比如光谱分析、材料性能预测、传感器标定。对于分类任务,如果类别不平衡严重,SPXY 需要配合分层策略一起用。对于超高维数据(比如基因表达谱),先做降维再跑 SPXY 往往比直接跑效果更好。
验证 SPXY 划分是否合理,最直接的方法是看训练集和测试集在目标值上的分布对比。可以画两个箱线图或者核密度估计图,如果两个分布的均值、方差、极值都接近,说明划分比较均衡。另一个方法是跑交叉验证:用 SPXY 划分后的训练集做 k 折交叉验证,看每折的验证误差是否稳定。如果某折误差明显偏高,说明训练集内部还有覆盖盲区。我自己的习惯是,每次用 SPXY 划分完,都强制走一遍“目标值分布对比 + 训练集内部交叉验证”这两步,确认没问题再往下跑模型。这个习惯帮我省了很多事后返工的时间。
希望帮到你。
本文还有配套的精品资源,点击获取