多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SPXY样本集划分:让光谱建模验证集不再翻车的实战指南

SPXY样本集划分:让光谱建模验证集不再翻车的实战指南 简介围绕SXPY方法进行样本集划分的实用资源包面向机器学习、数据挖掘及化学计量学领域的初学者和进阶者专门解决不均衡或复杂数据集下训练集、验证集与测试集划分不合理导致的模型评估偏差问题。包内含SPXY样本划分法结合蒙特卡罗交叉验证的MATLAB实现脚本涵盖样本划分、KS检验、随机抽样等核心功能可自动生成代表性更强的数据子集同时附有方法说明文献PDF系统阐述SPXY划分原理与蒙特卡罗交叉验证的优化流程。资源共4个文件以3个可运行脚本为主体搭配1篇学术文献压缩包仅291KB轻量简洁适合快速部署实验。目前已有1825人下载学习便于直接修改参数并复现近红外光谱、定量分析等场景的建模流程。通过脚本调用与文献对照读者既能掌握样本划分的具体实现也能理解KS检验对分布一致性的评估作用从而提升模型泛化能力与预测精度。1. sxpy方法进行样本集划分为什么随机划分让模型“看着准、一验证就翻车”sxpy方法进行样本集划分是很多做光谱建模、环境监测建模的工程师每天都在用的一步“选样本”操作。sxpy业内更常见的写法是SPXY全称Sample set Partitioning based on joint X-Y distances解决的核心问题是手里只有80个样本该把哪55个放进训练集、哪25个留出来做验证才能让模型不“看着准、一用就崩”。随机划分在小样本场景里基本靠运气分层抽样只保类别比例sxpy则同时把X空间和Y空间的距离都算进去让训练集尽量覆盖整个值域。这篇就按“原理→流程→代码→踩坑→验证”把这条链讲透适合做近红外、拉曼、土壤养分、水质预测这类样本量几十到几百、特征维度很高但样本数很少的项目也适合被划分比例和随机种子折磨过的建模新手。2. sxpy的核心逻辑从KS到联合X-Y距离它强在哪2.1 KS法只看X空间sxpy把Y空间也纳了进来在做样本集划分时大部分人的第一反应是随机抽样第二反应是分层抽样。但如果你接触过化学计量学肯定听过KS法Kennard-Stone。KS法做的事情很简单在X空间里计算所有样本两两之间的欧氏距离先把距离最远的一对样本选进训练集然后每一步找一个离“已经选中的训练集”距离最远的样本加入直到凑够训练集数量。这个策略的出发点是训练集的样本越分散模型见过的X空间范围越大外推时就越有底气。但KS法有一个明显的盲区——它完全不看Y值。比如近红外光谱里两个样本的X吸光度非常接近但Y浓度可能一个在低值区、一个在高值区。这种情况并不罕见因为光谱响应和待测组分之间往往不是单纯的线性关系干扰物、温度、基线漂移都会让“X相近但Y不同”的样本成对出现。如果只用X距离选训练集很可能把低浓度样本选进去一堆高浓度样本一个没选上模型一遇到高浓度验证样本就直接外推失准。sxpy方法正是冲着这个缺陷去的。它在KS的迭代框架不变的前提下把“距离”从单纯的X空间欧氏距离改成了X距离和Y距离的联合距离。这样一来一个样本要被选进训练集不光得在X空间上离已有训练样本远还要求在Y空间上也离得远。也就是说它主动去“捞”那些Y值位于边界区域的样本让训练集在自变量和因变量两个方向上都有足够的覆盖度。提示sxpy并不是一个完全不同的划分算法它可以理解为KS法的增强版。如果你手头已经写了KS的实现改造为sxpy只需要把距离矩阵的计算部分换掉即可。2.2 联合距离的计算公式dx、dy与归一化sxpy的联合距离定义很直接。设样本i和样本j的X空间欧氏距离为dx(i,j)Y空间距离为dy(i,j)那么联合距离为d_xy(i,j) dx(i,j) / max(dx) dy(i,j) / max(dy)这里max(dx)和max(dy)分别是全样本X距离矩阵和Y距离矩阵中的最大值。为什么要先除以最大值原因很简单X和Y往往不在一个量纲上。如果X是200个波段的吸光度数值在0到2之间Y是浓度数值在100到500之间不做归一化直接相加dy会完全主导联合距离sxpy就变成了“按Y值差异划分”X空间的分布覆盖等于放弃。两边都除以各自最大值后dx和dy都落在0到1之间加在一起时各自贡献的权重是一样的。dy在Y是一维连续值的时候就是两个样本Y值差值的绝对值如果Y是多维输出比如同时预测多个组分浓度dy就取两样本在多维Y空间中的欧氏距离。这一点在实现时要注意很多人把sxpy代码写好换到多输出任务时发现划分结果不对劲多半是dy还停留在“取绝对值”那一行。迭代选点的具体规则和KS一致初始化阶段选择联合距离最大的一对样本作为训练集的前两个点之后每一步对每个未选样本计算它到“当前训练集”的距离这里用的是min形式的最近邻距离即取它到训练集里所有样本联合距离的最小值最后在所有未选样本中挑这个最小值最大的那个样本加入训练集。这个“最小值的最大值”逻辑本质是不断寻找离现有训练集最远的样本把边界往外推。2.3 与随机/分层抽样的对比小样本场景的差距随机划分、分层抽样、KS和sxpy这四种方式在实际项目里的差距在小样本场景下会放大得非常明显。下表列出它们的核心差异可以作为方案选型的参考划分方式利用的信息对Y值域的覆盖保证分类任务适配适用样本量随机划分无无靠运气可配合分层大样本300分层抽样类别比例只保证比例好中KSX空间距离无一般中小sxpyXY联合距离显式覆盖Y值域需改造距离公式中小30~300以80个样本为例随机划分一次训练集里Y最大值是180、最小值是20验证集里Y最大值高达350这种情况非常多见。模型在训练集上拟合得再好验证集一上来就是训练集没见过的值域RMSE立刻失控。而sxpy因为每一步都在往Y空间的边界走训练集的Y范围基本能和全样本一致验证集更像是在“内插”而不是“外推”。这也是为什么在近红外、土壤、水质这类“样本数少、特征数多”的场景里sxpy几乎成了默认的划分工具。它不需要额外的标签信息不依赖复杂的优化器计算量在几百个样本范围内也就是一次性的事。当然它也有自己的问题比如Y是分类标签时不适用、样本太少时容易把离群点选进来、对标准化顺序敏感。这些坑在后面的章节里逐一展开。3. 跑通sxpy划分的操作流程标准化、距离矩阵与迭代选点3.1 数据标准化为什么必须先做这一步sxpy对数据标准化的依赖比多数人想象中要严重得多。前面提到联合距离需要分别归一化但那只解决了dx和dy之间的量纲问题。X内部各列之间的量纲如果不统一同样会出问题。假设X有200个光谱波段吸光度都在0到2之间但如果数据里混入了一个温度列数值是20到60那么这个温度列在欧氏距离计算中会占据绝对主导地位光谱的几百个波段全部沦为陪衬。我一般会在计算距离矩阵之前先对X做一遍z-score标准化也就是每列减去均值再除以标准差。Y的标准化稍微灵活一点如果Y是单一连续值直接用z-score或max归一化都可以关键是要在计算dy之前做而不是算完距离再改。标准化这一步要是漏了后面所有距离都是歪的划分结果只会是一堆看起来合理但实际毫无意义的索引。这里还要提一个常见误用有些人习惯把标准化放到划分完成之后、建模之前做认为“反正模型里也要标准化”。但sxpy是在原始数据上计算距离的如果划分前不标准化距离矩阵本身就已经被量纲污染了后面建模阶段再标准化也救不回来。正确顺序是先标准化X和y再算距离做划分划分出来的训练集和验证集分别都保持这个标准化状态后续建模可以直接用。3.2 距离矩阵与迭代选点三个关键步骤sxpy的整个计算过程不复杂拆开就是三个步骤。第一步计算标准化后的X空间两两欧氏距离矩阵Dx以及Y空间的两两距离矩阵Dy。这里要注意样本数不大时直接用两层循环逐个算就行不需要引入复杂的加速手段样本数超过5000时才值得考虑用批量计算或者近似最近邻。第二步构造联合距离矩阵D Dx / Dx.max() Dy / Dy.max()。这一步是sxpy区别于KS的关键。许多博客代码里会在这一行把公式写成D Dx Dy如果你的两个距离矩阵量纲恰好相似结果可能看不出问题一旦量纲差异大sxpy就退化成“只按Y分”或者“只按X分”前功尽弃。第三步也是最核心的一步迭代选点。具体执行顺序如下步骤操作要点1找出联合距离矩阵D中最大值的下标(i, j)把这两个样本加入训练集这是初始化保证起点就在边界上2对每个未选样本u计算它到训练集所有样本的距离取最小值min形式表示u离当前训练集的最近距离3在所有未选样本中取第2步最小值最大的样本加入训练集等价于找离训练集“最远”的样本4重复第2、3步直到训练集数量达到设定值每步只加一个样本训练集越扩越大这个流程看起来简单但有一个容易被忽略的细节初始化选联合距离最大的一对样本实际上是选了“X差异和Y差异综合起来最大”的两个点它们可能并不是X空间最远的那一对。这是sxpy和KS在初始化上的细微差别也是sxpy能兼顾Y值域的原因。3.3 划分比例、初始样本和类别约束怎么设sxpy最常见的划分比例是2:1也就是训练集占三分之二、验证集占三分之一。在70个样本的项目里这个比例大约是47个训练、23个验证。如果样本数量很少比如只有40个我会把比例调到7:3并且建议配合后面第6章的交叉验证来评估稳定性。训练集占比太高验证集会失去评估意义太低模型学不到足够信息这个平衡在小样本场景里很敏感。另一个要主动控制的是Y值域的极值样本。sxpy虽然倾向于把边界样本选进训练集但它的初始化只选了一对最远样本如果Y分布严重偏斜比如90%的样本浓度在10到50之间10%的样本浓度在100到200之间迭代过程里的边界样本不一定能把高浓度端都覆盖全。我一般会在划分完成后主动检查训练集里是否包含了Y的最大值和最小值样本如果没有就从验证集里把它们手动交换进训练集。这个操作比较粗暴但能有效避免最坏情况。至于类别约束如果Y本身是分类标签比如“合格/不合格”直接套用sxpy的距离公式会失效因为dy的差值只有0和1两种取值距离信息量太低。这种情况我会改用分层随机抽样的思路或者对sxpy做改造把dy替换成“类别中心距离”或者“0/1标签距离加上类内X距离”。具体做法在第5章里细讲这里先记住结论分类任务不要直接裸用sxpy。4. 用Python实现sxpy不需要重型依赖的完整代码4.1 核心函数spxy_partition下面是一份不依赖任何专用库的sxpy实现用numpy就能跑。代码里保留了每一步的注释方便对照第3章的流程理解。import numpy as np def spxy_partition(X, y, n_train): 基于联合X-Y欧氏距离的sxpy样本集划分。 参数 X : ndarray, shape (n_samples, n_features) 已经标准化的自变量矩阵 y : ndarray, shape (n_samples,) 因变量一维连续值 n_train : int 训练集样本数量 返回 train_idx, test_idx : 训练集和验证集索引 n X.shape[0] # 1. 计算X空间两两欧氏距离矩阵 Dx np.zeros((n, n)) for i in range(n): Dx[i] np.linalg.norm(X - X[i], axis1) # 2. 计算y空间两两距离一维时取绝对差 y np.asarray(y, dtypefloat).reshape(-1, 1) # 利用numpy广播y是(n,1)y.T是(1,n)相减得到(n,n)矩阵 Dy np.abs(y - y.T) # 3. 分别归一化后相加得到联合距离矩阵 dx Dx / Dx.max() dy Dy / Dy.max() D dx dy # 4. 初始化取联合距离最大的一对样本进训练集 i, j np.unravel_index(np.argmax(D), D.shape) selected [int(i), int(j)] # 5. 迭代每一步选离训练集最近距离最大的样本 while len(selected) n_train: unselected [u for u in range(n) if u not in selected] min_dist [] for u in unselected: # 样本u到训练集的距离取u与每个已选样本联合距离的最小值 dist_to_set np.min(D[u, selected]) min_dist.append(dist_to_set) # 在所有未选样本中取最小距离最大的那个 next_idx unselected[int(np.argmax(min_dist))] selected.append(next_idx) test_idx [u for u in range(n) if u not in selected] return np.array(selected), np.array(test_idx)这里有几个实现细节值得说明。Dx用循环逐行计算欧氏距离在样本量几百的情况下性能足够如果样本上万建议改成scipy的cdist或者torch的矩阵运算。Dy这里用到了numpy的广播机制y是(n,1)形状y.T是(1,n)两者相减自动扩展成(n,n)矩阵省掉一层循环。迭代部分每步都对未选样本重新计算最近距离时间复杂度是O(n_train * n * n_train)80个样本、55个训练集规模下完全无感。关键参数是n_train。如果设置得太小比如只占总样本的40%训练集很可能把Y空间的边界覆盖不全设置得太大验证集样本太少评估结果方差会很大。常见做法是取总样本数的60%到75%并且在跑完函数后检查y[train_idx]的min和max是否覆盖全样本范围。4.2 在模拟光谱数据上跑通的最小示例rng np.random.default_rng(42) # 模拟80个样本、200个波段的光谱数据 X rng.normal(size(80, 200)) # 构造一个与第一波段线性相关的响应值 y 50 30 * X[:, 0] rng.normal(0, 1, 80) # 标准化这一步必须在划分之前完成 from sklearn.preprocessing import StandardScaler scaler_X StandardScaler().fit(X) X_s scaler_X.transform(X) # y 用 z-score 标准化 y_s (y - y.mean()) / y.std() # sxpy划分训练集取55个约68.75% train_idx, test_idx spxy_partition(X_s, y_s, n_train55) print(f训练集样本数: {len(train_idx)}, 验证集样本数: {len(test_idx)}) print(f训练集y范围: {y[train_idx].min():.2f} ~ {y[train_idx].max():.2f}) print(f验证集y范围: {y[test_idx].min():.2f} ~ {y[test_idx].max():.2f}) print(f全样本y范围: {y.min():.2f} ~ {y.max():.2f})跑完之后你会发现训练集的y范围通常和全样本y范围几乎一致而验证集的极值点大概率都落在训练集范围之内。这个“极值覆盖”正是sxpy相比随机划分最直观的优势。对照实验可以这么做用np.random.shuffle随机打乱索引后取前55个同样的打印语句跑一遍看看验证集y范围和训练集y范围的重叠度有多低。4.3 用PCA可视化验证划分效果from sklearn.decomposition import PCA import matplotlib.pyplot as plt pca PCA(n_components2) X_2d pca.fit_transform(X_s) plt.figure(figsize(6, 6)) plt.scatter(X_2d[train_idx, 0], X_2d[train_idx, 1], csteelblue, labeltrain, alpha0.7) plt.scatter(X_2d[test_idx, 0], X_2d[test_idx, 1], csalmon, markerx, labeltest, alpha0.7) plt.xlabel(PC1) plt.ylabel(PC2) plt.legend() plt.tight_layout() plt.show()看PCA散点图时重点看两点第一训练集样本是否分布在整个散点图的外缘验证集是否大致落在训练集的“包围内部”第二是否存在某个区域只有验证集样本而没有训练集样本如果存在说明模型在这个区域完全是外推。需要说明的是PCA只保留X方向的前两维主成分Y方向的信息没有体现在图里所以这张图只能反映X空间覆盖Y覆盖还得靠第4.2节里的范围打印来判断。更稳妥的做法是在图上用颜色映射Y值直观检查训练集的颜色带是否覆盖了全量程。5. sxpy常见问题与避坑量纲、分类标签、小样本和过拟合5.1 量纲不统一sxpy退化成KS现象划分之后检查y范围发现训练集和验证集的y分布和随机划分差不多X空间的PCA散点图也没有体现出明显的边界覆盖优势。原因联合距离公式里dx和dy没有分别除以最大值或者X内部列之间量纲差异过大。比如X里既有光谱波段0~2又有温度20~60温度列在欧氏距离计算中主导一切dx实际上变成只衡量温度差异联合距离完全不敏感。解决在算Dx之前对X做z-score标准化对y做z-score或max归一化。如果遇到特征本身量级跨度极大例如微量物质和常量物质混在一个矩阵里可以考虑对每列做min-max缩放把X全部压到0到1区间。标准化完成后打印dx.max()和dy.max()确认两者量级都是1再进入迭代选点。5.2 Y是分类标签时距离公式直接失效现象Y是“合格/不合格”这类0/1标签用sxpy划分后训练集里某一类样本占比严重失衡模型对少数类完全失效。原因Dy在0/1标签下只有0和1两个取值大部分样本对的dy相等联合距离的信息量几乎全部来自dxsxpy退化成KS而KS本身不关心类别平衡。解决分类任务一般不建议直接用sxpy。如果确实想用X空间覆盖的思路可以换成“分层KS”的组合策略先按Y类别分层保证训练集各类比例和全样本一致然后在每一层内部用KS选样本。如果一定要保留联合距离思路可以把dy改成“类别中心距离”的变体先计算各类别在X空间的质心样本对距离等于类别质心距离加上类内X距离但这已经不太算标准的sxpy了我一般只在做多输出分类的少数场景里用。5.3 样本数太少时sxpy不是最优解现象只有25个样本sxpy划分一次训练集18个、验证集7个。模型在验证集上的RMSE时好时坏换一个随机种子结果差异极大。原因样本量在30以下时距离矩阵本身的信息量就不足初始化阶段选到哪对最远样本带有偶然性迭代选点很容易把离群点或测量噪声样本当成边界样本选进训练集导致训练集里混入坏点。解决样本量小于30的项目我建议优先考虑“留一交叉验证”或者“重复划分取平均”来评估模型而不是一次性留出固定验证集。如果必须做固定划分先在X和y上做一次离群点筛查比如用Z-score、箱线图判断把明显异常样本剔除后再跑sxpy。不要把sxpy当成小样本的万能药它更适合训练集样本数在40以上的场景。5.4 迭代贪心导致“边界聚集”要检查覆盖度现象训练集样本几乎全部围在X空间边缘验证集样本集中在中心区域看起来“验证集很安全”但模型在同类新数据上泛化一般。原因sxpy的每一步都选离训练集“最近距离最大”的样本本质是贪心地把边界往外推。当数据分布比较均匀时训练集最终会形成一个包裹验证集的壳这没问题但当X空间存在多个稀疏角落时训练集可能被这些角落的样本占据太多名额中心区域的代表性样本反而没选够。解决划分完后不要只看y范围还要看训练集内部的距离分布。一个实用做法是计算验证集每个样本到训练集最近邻的距离画出直方图。如果直方图右尾特别长说明验证集里有样本落在训练集覆盖范围之外如果验证集中的所有样本都能在训练集里找到距离很近的近邻说明覆盖是充分的模型面对验证集时基本处在一个“插值”状态而不是“外推”状态。5.5 划分完之后必须先做的验证现象sxpy划分完成建模后验证集RMSE很低以为万事大吉结果部署到新批次数据上效果崩塌。原因验证集本身是从同一批样本里分出来的和训练集同源。sxpy把验证集设计成落在训练集覆盖范围内所以模型在验证集上的表现天然会偏向乐观这并不代表它对未来真正的外部样本有同等的能力。解决任何sxpy划分项目都应该保留一个“从未参与划分流程”的外部测试集。常见做法是先把全样本按时间或采集批次切出一部分作为外部测试集剩下的再交给sxpy进行训练集和验证集划分。外部测试集的存在才是衡量模型是否真正可用的最后一把尺子。如果不方便按时间切至少要用多次随机划分交叉验证来估计模型表现的方差别被一次性划分的漂亮指标忽悠。6. 用外部测试集闭环验证sxpy交叉验证与分布可视化验证sxpy划分是否真的有效我的标准程序分三步。第一步在sxpy划分出的训练集上做5折交叉验证看模型内部的稳定性第二步计算验证集样本到训练集最近邻的距离分布和训练集内部的近邻距离做对比量化覆盖程度第三步把训练集和验证集合并后用同样的流程对原始全样本建模再在外部测试集上比较随机划分和sxpy的泛化差距。from sklearn.linear_model import Ridge from sklearn.model_selection import cross_val_predict ridge Ridge(alpha1.0) # 在sxpy训练集上做5折交叉验证 y_pred_cv cross_val_predict(ridge, X_s[train_idx], y[train_idx], cv5) rmse_cv np.sqrt(np.mean((y[train_idx] - y_pred_cv) ** 2)) print(fsxpy训练集5折交叉验证RMSE: {rmse_cv:.3f}) # 验证集最近邻距离分布 from scipy.spatial.distance import cdist d_test_train cdist(X_s[test_idx], X_s[train_idx], euclidean).min(axis1) d_train_in cdist(X_s[train_idx], X_s[train_idx], euclidean).max(axis1) cover_ratio np.median(d_test_train) / np.median(d_train_in) print(f验证集到训练集近邻距离中位数/训练集内部距离中位数比值: {cover_ratio:.3f})这个比值如果小于1说明验证集样本离训练集的距离普遍小于训练集内部样本之间的距离验证集处在训练集的“腹地”如果大于2就要警惕验证集中存在训练集没覆盖到的区域。我自己的习惯是把这个比值作为每次建模的固定输出项阈值设定为1.5超过就重新审视标准化和离群点处理。最后说一个我踩过的坑有一次做土壤有机质建模样本量120个y浓度跨度很大我用sxpy划分后训练集y范围覆盖得完美交叉验证RMSE也漂亮。结果一上外部测试集就翻了车原因是那次数据里y值最大的几个样本本身测量误差就大sxpy把极值样本主动选进训练集反而把噪声当成了信号。从那以后我再也不会只看y覆盖范围而是先做一遍离群点筛查再让sxpy上场。样本集划分不是终点它只是建模流程里最容易出错也最值得认真对待的一环希望这套方法能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表