
简介面向机器学习建模场景的资源包提供校正集与验证集划分的三种经典算法实现——SPXY、KS与RS用于解决样本划分时分布不均、代表性不足等问题适合本科、硕士及科研人员开展数据处理与模型验证实验。压缩包共5个文件整体仅6KB包含4个.m脚本和1个txt使用说明.m脚本分别对应三种划分算法及主程序txt文件则提示如何准备并代入样本数据结构轻简便于直接调用。已有332人学习下载兼容MATLAB 2014/2019a/2021a等常见版本。资源内附运行结果可直观对比三种划分方法的效果差异通过SPXY、KS和RS的源码结合说明文档读者能快速理解算法逻辑并自行代入数据完成划分为后续建模训练提供可靠的校正集与验证集构建思路从而提升模型评估的可靠性与泛化能力。1. 校正集和验证集一拆模型就“变脸”SPXY、KS、RS 到底在拆什么做机器学习建模时很多人盯着算法和调参却把样本划分这个前置步骤当成“随机一拆”就完事。尤其在近红外、拉曼等光谱定量建模里校正集和验证集怎么分直接决定模型是“真会”还是“背题”。随机划分看着公平但当样品分布不均时验证集很可能漏掉边界样本模型评估虚高换一批样品就翻车。SPXY、KS、RS 三种经典算法解决的就是“怎么拆才更有代表性”这个问题。下面从原理、Python 实现到避坑给出一套可以照着复用的方案。适合化学计量学、光谱建模以及机器学习课程设计与期末复习时想补上数据处理这一环的从业者。2. 先把三种划分算法讲透RS、KS、SPXY 各自的适用边界与选型理由2.1 校正集、验证集与测试集同一个“验证”在不同场景下含义不同在机器学习教科书里train/validation/test 是三个各司其职的集合训练集用来更新模型参数验证集用来比较超参数组合测试集只在最终评估时碰一次。但在化学计量学或实际光谱建模项目里样本量通常只有几十到几百个项目里往往只分两个集合校正集和验证集。这里的“验证集”实际上承担的是测试集职责——训练阶段完全不碰最后用它计算 RMSE、R² 等指标。如果还要做超参数搜索就应再从校正集里切一块做内部验证而不是把外部验证集拿进来一起调参。名字先想清楚后面才不容易数据泄漏。很多人误以为只要分出一个验证集就算完成了却忽略了“验证集应该代表未来会遇到的新样品”。如果验证集和校正集的分布严重不一致模型在验证集上的成绩就没有参考价值。所以划分算法的本质是尽量让两个集合都覆盖原始样本空间同时保证校正集足够有信息量。RS、KS、SPXY 就是三条不同的路径RS 靠概率KS 靠特征空间距离SPXY 在 KS 基础上再叠加响应变量 y 的距离。2.2 RS 随机划分实现最简单但分布最容易“偏科”RSRandom Sampling就是随机抽样把样本按固定比例随机分到校正集和验证集最常用的实现是 sklearn 的 train_test_split。为了不丢样本索引一般传索引数组而不是直接传 Ximport numpy as np from sklearn.model_selection import train_test_split n_samples 60 X_all np.random.rand(n_samples, 5) y_all np.random.rand(n_samples) idx np.arange(n_samples) cal_idx, val_idx train_test_split( idx, test_size0.3, random_state42 ) X_cal, X_val X_all[cal_idx], X_all[val_idx] y_cal, y_val y_all[cal_idx], y_all[val_idx]这里 test_size0.3 表示验证集占 30%random_state 固定随机种子。传索引的好处是后续保存划分结果、做复现都很方便不一定要把 X 本身切来切去。随机划分的优点是复杂度低、代码一行业缺点是它完全不看样本分布。极端案例100 个样本里只有 3 个是高浓度样品随机一拆这 3 个可能全进验证集校正集缺少高浓度段模型只能靠插值预测验证集分数反而“虚高”。当数据量很大且分布相对均匀时RS 是够用的数据量小或分布偏斜时它更像碰运气。2.3 KS 如何用距离把样本“铺”满整个特征空间KSKennard-Stone不依赖 y只用 X 之间的欧氏距离做贪心选择。它做的事情是从全样本里挑出一部分最有代表性的样本作为校正集让这些样本在特征空间里尽量分散、尽量“铺满”整个空间剩下的是验证集。具体步骤计算所有样本两两之间的欧氏距离得到 n×n 的距离矩阵。找出距离最远的两个样本放入校正集。对每个尚未入选的样本计算它与所有已入选样本之间的最小距离。从中取出最小距离最大的样本加入校正集。重复第 3、4 步直到校正集样本数达到设定值。这个逻辑很像“在空间里均匀插旗子”已选的点越密候选点到已选点集合的最近距离就越小于是算法总是优先拉进那些身处“空地”的样本。最终校正集的凸包基本覆盖原始数据范围验证集则落在被包围的位置这样模型在校正集上学到的边界不会和验证集差太远。KS 不需要随机种子是确定性算法它也不关心 y所以即使响应变量还没测出来也可以先用光谱 X 做划分。2.4 SPXY 在 KS 基础上多看了 y 一眼SPXYSample set Partitioning based on joint X-Y distances是 KS 的扩展名字直译就是“基于 X-Y 联合距离的样本集划分”。它在距离公式里加入响应变量 y 的差异避免校正集只覆盖了 X 空间却漏掉了 y 的极值。比如浓度从 0.1% 到 15%如果只用 KS低浓度和高浓度样本完全有可能因为 X 距离较近而集中在验证集导致校正集浓度范围很窄模型被迫外推。SPXY 把 y 的差异和 X 的差异联合起来校正集的浓度覆盖就会明显改善。SPXY 的距离定义为dxy(p,q) dx(p,q)/max(dx) dy(p,q)/max(dy)其中 dx(p,q) 是样本 p、q 在 X 空间的欧氏距离dy(p,q) 是它们在 y 空间的绝对距离。分别除以各自的最大值是为了把 X 距离和 y 距离都压到 0~1避免某一方因为量纲太大而主宰整个距离。如果 y 是常数dy 部分为 0SPXY 自动退化为 KS。如果 y 是多列输出常见做法是先把每列 min-max 归一化到 0~1再计算综合的欧氏距离。2.5 一张表看清选型原理、特点与计算量下表把三种方法放在一起对比算法距离依据是否需要 y主要特点适用场景计算量RS随机概率不需要实现简单分布不可控数据量大、分布均匀、快速基线O(n)KSX 的欧氏距离不需要确定性校正集覆盖 X 空间光谱特征空间、样本量不大O(n²)SPXYX y 联合距离需要连续 y同时覆盖 X 和 y 范围定量模型、y 跨度大O(n²)计算量的“O(n²)”主要来自距离矩阵的构造和每次贪心比较。n 在几百到几千时完全可接受如果样本量到几万全距离矩阵会很占内存需要先对 X 做聚类或随机抽样删减候选集再用 KS/SPXY 在代表点上划分。总的来说RS 适合做快速粗筛KS 是光谱建模的默认选择SPXY 适合那些 y 分布严重不均、担心验证集“超纲”的定量任务。3. 从零实现 Kennard-Stone用欧氏距离选出一个“均匀覆盖”的校正集3.1 算法步骤拆解先找最远两点再逐步“填空”打开编辑器之前先明确一点这个函数要返回的是“校正集索引”和“验证集索引”而不是把数据直接切好。因为后面还要做标准化、异常值处理换成索引操作最灵活。函数输入是 X 矩阵和校正集样本数 n_cal输出是两个索引数组。算法只有两个阶段初始化阶段先找欧氏距离最远的一对样本迭代阶段不断把“离当前校正集最近距离最大”的样本拉进来。这里“最近距离最大”听起来绕其实很好理解对每个未选样本先算出它到当前校正集里所有样本的距离取最小值表示它离校正集最近有多远再在所有未选样本里挑出这个最小值最大的那个。这就是所谓的“让下一个填进最空旷的位置”。当校正集数量接近目标时剩下的验证集自然就是那些离校正集并不太远、又能代表局部波动的样本。3.2 可直接复用的 Python 函数ks_splitimport numpy as np from scipy.spatial.distance import cdist def ks_split(X, n_cal, metriceuclidean): n X.shape[0] if not 1 n_cal n: raise ValueError(n_cal 必须在 2 和 n-1 之间) dist cdist(X, X, metricmetric) np.fill_diagonal(dist, 0.0) # 防止自环干扰 # 初始化挑距离最远的两个样本 flat_idx np.argmax(dist) i, j np.unravel_index(flat_idx, dist.shape) selected [int(i), int(j)] remaining [k for k in range(n) if k not in selected] # 贪心增加样本 while len(selected) n_cal: min_d dist[remaining][:, selected].min(axis1) add_pos int(np.argmax(min_d)) add_idx remaining[add_pos] selected.append(add_idx) remaining.pop(add_pos) cal_idx np.array(sorted(selected)) val_idx np.array(sorted(remaining)) return cal_idx, val_idx逻辑说明np.argmax(dist)返回的是距离矩阵展平后的最大位置np.unravel_index把它还原成“第几行、第几列”对应的两个样本就是全空间里相距最远的一对。dist[remaining][:, selected]先取出未选样本对应的行再取已选样本对应的列形成候选样本到已选样本的子矩阵.min(axis1)算出每个候选点离已选集合的最近距离np.argmax再挑最近距离最大的那个。selected是校正集remaining是验证集因为校正集负责覆盖空间如果你想把选出的样本当验证集只需要交换返回值但建模时要知道校正集将缺失边界一般不建议。参数说明metric默认欧氏距离光谱数据吸光度同量纲直接用没问题如果特征包含温度、浓度等不同量纲建议先对 X 做标准化或者改用标准化距离否则距离会被数值大的特征主导。n_cal一般设置为总样本的 60%~80%。样本数少于 50 时建议取 75%保证验证集至少有 10 个左右超过 200 个样本时可以取 70% 或更少因为验证集只需要足够反映分布不需要太大。一个优化技巧不必每次重新切矩阵。可以用一维数组保存所有样本到当前已选集合的最近距离每新增一个样本就把它与新样本的列距离做逐元素取小min_dists dist[:, selected].min(axis1) while len(selected) n_cal: cand min_dists[remaining] add_idx remaining[int(np.argmax(cand))] selected.append(add_idx) remaining.pop(remaining.index(add_idx)) min_dists np.minimum(min_dists, dist[:, add_idx])这个版本在 n 较大时更友好逻辑和上面完全一致。如果数据量超过 5000建议不要一次性存全距离矩阵而是分批计算候选样本到已选样本的距离或者先用 KMeans 对样本做粗聚类再从每个簇内选代表样本。3.3 参数设置与输出检查选多少样本、看什么指标划分完第一步是看数量第二步是看 y 范围。数量上直接打印集合长度即可cal_idx, val_idx ks_split(X, n_cal45) print(f校正集: {len(cal_idx)}, 验证集: {len(val_idx)}) print(f校正集 y: min{y[cal_idx].min():.3f}, max{y[cal_idx].max():.3f}) print(f验证集 y: min{y[val_idx].min():.3f}, max{y[val_idx].max():.3f})尽管 KS 不依赖 y打印这些值能帮你发现异常如果校正集 y 范围明显小于全体说明只靠 X 距离不足以把 y 边界选中这时候应该换 SPXY 或在 y 上加权重如果验证集 y 的最小值或最大值比校正集更极端说明验证集里有模型从未见过的“超纲”样本这种情况下验证集 RMSE 高不一定是模型不好而是划分过于苛刻。这里还要强调一个常见误用KS 选出的“最有代表性样本”到底当校正集还是验证集不同工具和资料给的返回值可能完全相反。在本方案里selected 是校正集remaining 是验证集。因为校正集应当包含更多边界信息模型才会学到完整的输入-输出关系。如果你用某个现成函数发现返回的集合范围特别小、剩余集合范围特别大那大概率是“选代表样本”的目的被用反了。4. SPXY 实现把 y 的差异也折算成距离验证集不再“超纲”4.1 距离联合公式与两种归一化SPXY 的关键改动只在距离矩阵的构造上后面的贪心选择流程和 KS 完全一样。先计算 X 空间的距离 dx再计算 y 空间的距离 dy最后把两部分按各自最大值归一后相加。如果希望 X 和 y 的权重不相等可以加一个权重系数 alphadxy alpha * dx / max(dx) (1 - alpha) * dy / max(dy)alpha 默认取 0.5表示 X 和 y 同样重要。alpha 越大越偏向 KS越小越偏向纯 y 距离。对于 y 分布特别偏的建模任务比如浓度跨越三个数量级把 alpha 调到 0.3~0.4 通常能让校正集覆盖更多高值样本。但 alpha 太小也会带来反效果校正集会像在 y 轴上均匀抽头忽略 X 空间的相关结构模型训练变得不稳定。y 的归一化方式也要说清楚。最稳妥的是对 y 做 min-max 缩放让每个响应变量的取值范围都变成 0~1。这样做不是因为统计性质更好而是为了让多个 y 列在欧氏距离里“公平参与”。如果直接拿原始浓度0~15和原始密度0.5~0.9拼在一起算距离浓度会完全覆盖密度的差异SPXY 就名存实亡了。4.2 Python 实现spxy_split 与 KS 的差异import numpy as np from scipy.spatial.distance import cdist def spxy_split(X, y, n_cal, alpha0.5): n X.shape[0] if not 1 n_cal n: raise ValueError(n_cal 必须在 2 和 n-1 之间) # X 空间距离 dx cdist(X, X, metriceuclidean) # y 空间距离先逐列 min-max 归一化到 [0,1] y np.asarray(y, dtypefloat) if y.ndim 1: y y.reshape(-1, 1) y_min y.min(axis0) y_range y.max(axis0) - y_min y_range[y_range 0] 1.0 # 避免常数列除零 y_norm (y - y_min) / y_range dy cdist(y_norm, y_norm, metriceuclidean) # 联合距离 dx_max dx.max() dy_max dy.max() if dx_max 0: raise ValueError(X 无差异无法划分) if dy_max 0: dxy alpha * dx / dx_max (1 - alpha) * dy / dy_max else: dxy dx / dx_max # y 无差异时退化为 KS # 以下贪心过程与 ks_split 相同 flat_idx np.argmax(dxy) i, j np.unravel_index(flat_idx, dxy.shape) selected [int(i), int(j)] remaining [k for k in range(n) if k not in selected] while len(selected) n_cal: min_d dxy[remaining][:, selected].min(axis1) add_pos int(np.argmax(min_d)) selected.append(remaining[add_pos]) remaining.pop(add_pos) cal_idx np.array(sorted(selected)) val_idx np.array(sorted(remaining)) return cal_idx, val_idx逻辑说明与ks_split相比只有距离矩阵变了贪心选择完全一样。y_range[y_range 0] 1.0是为了处理某个响应变量是常数的情况避免除零这种情况下该列距离全是 0不会影响综合距离。dy_max为 0 时说明 y 完全没有差异SPXY 自动退化为 KS因此不需要额外报错。关于“全局归一化会不会泄漏验证集信息”这里要澄清。SPXY 在划分时用全体 y 的 min-max 构造距离只是为了让索引选择更合理并不把 y 的数值传给后续模型。后续建模只使用校正集的 X 和 y 去拟合验证集的 y 只用于评估。所以这种全局归一化属于“划分策略”的一部分不是特征工程泄漏。真正需要警惕的是第 5 章说的先全局标准化再划分。使用示例与 KS 一样。多输出回归任务里只需把 y 传成二维数组函数会自动按列归一化再算综合距离如果不同 y 列对任务重要性不同可以在调用前自己给每一列乘一个权重权重列参与缩放后再传入。4.3 检查 y 覆盖范围验证 SPXY 是否真的“押中”了边界SPXY 的优势应该在结果里体现最直观的检查方式是计算校正集的 y 范围占全体 y 范围的比例。可以写一个极简函数def range_coverage(y_all, cal_idx): y_cal y_all[cal_idx] r_cal y_cal.max() - y_cal.min() r_all y_all.max() - y_all.min() return r_cal / r_all print(校正集 y 覆盖率:, range_coverage(y_all, cal_idx))如果覆盖率低于 0.9说明校正集没有覆盖到低端或高端模型有外推风险。发生这种情况时优先增大 n_cal其次把 alpha 调小到 0.3 左右让 y 距离在联合距离里占更大权重。但注意覆盖率不是越高越好验证集也需要保留一部分边界样本来检验模型的外推能力。一个比较健康的划分结果是校正集覆盖 90%~100%验证集的 y 最大值和最小值落在校正集范围内或略微超出但超出幅度不超过全体极差的 10%。在完成覆盖检查后还可以对比 KS 和 SPXY 在同一数据上的 RMSE。通常 SPXY 会让验证集的 RMSE 略高于 KS因为验证集里包含了更多 y 边界样本评估更苛刻但模型在新批次上的表现会更稳定。如果你的验证集 RMSE 反而更低了往往不是算法神奇而是重叠样本太多需要去重后再比较。5. 校正集划分避坑指南信息泄漏、重复样本与类别失衡不管用 RS、KS 还是 SPXY下面这些坑都值得对照排查。每一条都是实际项目中踩过的场景按“现象-原因-解决”来写方便你直接拿去用。5.1 先全局标准化再划分导致验证集信息泄漏现象模型在校正集和验证集上的 RMSE 都很好看但部署到新批次数据时误差明显变大换一台仪器甚至直接失效。原因很多人拿到数据后习惯先做一遍 StandardScaler 再去划分。这样一来验证集的均值和方差已经参与了全局均值和方差的计算特征变换后的验证集数据偷偷包含了全局统计信息等效于训练阶段“见过”验证集。光谱数据里常见的去基线、变量标准化SNV也同理只要是先在全样本上估计参数再做划分都存在这个泄漏风险。解决必须先划分再在校正集上 fit 预处理对象。代码上建议这样写from sklearn.preprocessing import StandardScaler cal_idx, val_idx spxy_split(X, y, n_cal45) scaler StandardScaler().fit(X[cal_idx]) X_cal_s scaler.transform(X[cal_idx]) X_val_s scaler.transform(X[val_idx])所有需要估计参数的预处理步骤都走这个流程不需要估计参数的步骤如简单一阶导数可以在划分前做但为了保险也建议先划分再统一处理。5.2 KS/SPXY 遇到相同或近似样本划分结果“看心情”现象同一份数据跑两次 KS得到的划分索引不一样或者验证集里有一批样本和校正集中的样本几乎完全重复导致验证集 RMSE 异常低。原因距离矩阵里重复样本之间的距离为 0在 argmax 和 argmin 阶段会出现大量并列。不同环境下的排序稳定性不同算法就会在不同并列项里随机挑一个。更关键的是重复样本如果被拆到两侧验证集等于在“背答案”指标自然虚高。解决在做距离划分前先检查并处理重复样本。最简单的方法是检查 X 的重复行X_round np.round(X, 6) unique_idx np.unique(X_round, axis0, return_indexTrue)[1]如果重复样本来自同一个物理样品的平行测样建议取均值后保留一条如果来自同批次的多个独立样品则应把整组样本放进同一侧避免被拆散。可以给样本增加一个“批次编号”先用批次编号分组再对组内代表做划分最后把整组索引映射回去。5.3 分类任务直接套 SPXY类别比例全乱现象二分类数据集用 SPXY 划分后校正集里某一类只剩十几个样本模型训练后把所有样本都预测成多数类验证集准确率却还说得过去。原因SPXY 的 dy 要求 y 是连续值类别标签 0/1 算出来的欧氏距离缺乏物理含义而且 SPXY 按空间均匀选点不会保类别比例。数据分布不均时少数类样本所在区域的点更容易被当作“边界点”分进验证集校正集少数类数量进一步缩水。解决分类任务优先用分层抽样cal_idx, val_idx train_test_split( idx, test_size0.3, stratifyy_all, random_state42 )如果还想用距离选代表样本可以按类别分别做 KS再把每个类选出的样本按原始比例合并成校正集。这种做法能同时保留类别比例和特征空间覆盖代价是代码量略增但对于不平衡分类任务非常值得。5.4 random_state 不固定复现实验像开盲盒现象RS 划分的结果每次运行都不同KS 和 SPXY 大多数时候稳定但要是一次脚本里混了去重、排序等随机操作划分结果也会漂移。换一台电脑重跑实验指标对不上排查半天发现是划分变了。原因RS 完全依赖随机数生成器不固定 random_state 就不可能有可复现结果KS/SPXY 本身确定性但筛选重复样本、打乱顺序等前置操作可能引入不确定性。很多人只把 seed 写在注释里没有真正传参等于没固定。解决所有带随机性的步骤都显式传入 random_state并把划分出来的索引数组保存下来作为后续建模的唯一入口np.savez(split_2025.npz, cal_idxcal_idx, val_idxval_idx) # 需要复现时 d np.load(split_2025.npz) cal_idx, val_idx d[cal_idx], d[val_idx]保存索引比保存切好的 X_train 更灵活因为改预处理、改模型时不需要重新划分。这也是项目进入中期后最实用的“后悔药”。5.5 样本量太少时怎么办现象总共 15 个样本校正集 10 个、验证集 5 个。换一种划分方式验证集 RMSE 从 0.2 涨到 0.8根本说不清是模型问题还是划分问题。原因样本量太小时单次划分的随机因素占比太大验证集只有 5 个点一个离群样本就能让 RMSE 翻倍。这时候讨论“SPXY 比 KS 好”没有意义因为统计波动远大于算法差异。解决先用交叉验证做模型探索不要一上来就固定单次划分。sklearn 的RepeatedKFold可以重复多次取样输出 RMSE 均值和标准差from sklearn.model_selection import RepeatedKFold from sklearn.cross_decomposition import PLSRegression from sklearn.metrics import mean_squared_error rkf RepeatedKFold(n_splits5, n_repeats10, random_state42) rmse_list [] for tr, te in rkf.split(X): pls PLSRegression(n_components5).fit(X[tr], y[tr]) yp pls.predict(X[te]) rmse_list.append(mean_squared_error(y[te], yp, squaredFalse)) print(np.mean(rmse_list), np.std(rmse_list))等预处理和超参数基本确定后再用 KS/SPXY 分一次作为最终报告用的划分。交叉验证用于选模型单次划分用于描述最终性能两者各司其职不要混用。6. 用“范围覆盖率 重复划分”验证划分质量一个 10 分钟的检查流程6.1 校正集范围覆盖率一张表看出是否覆盖了 y 的边界划分完成后我习惯先打印一张小表校正集和验证集的样本数、y 均值、y 最小值、y 最大值。最简单也最重要的指标是校正集范围覆盖率也就是第 4.3 节里的range_coverage。覆盖率低于 0.9 时建模前就要先决定是增删样本、调 alpha还是接受外推风险。不要等模型跑完再看这个数字那时候已经晚了。6.2 重复划分 RMSE 波动才能判断划分稳不稳对于 RS至少用 10 个不同种子各切一次每次固定种子后跑同一个模型记录验证集 RMSE。看均值和标准差而不只看一次结果。标准差太大说明数据量不足或分布不稳这时最有价值的信息不是 RMSE 均值而是“为什么划分一变结果就变”。对于 SPXY/KS 这种确定性算法可以通过对原始数据做轻微 bootstrap 扰动来观察索引是否大范围变化变化大通常意味着数据里有重复或近似样本需要回头做去重。6.3 把划分索引存进 npz模型结果的“后悔药”无论最后用哪种方法都把索引存好。.npz文件大小几乎可以忽略但能让你三天后、三个月后完整复现当时的实验。我现在的习惯是每次跑数据先检查是否存在 split.npz没有才重新划分有了就直接加载。这个习惯救过我一回一个光谱项目验证集 R² 做到 0.96换供应商数据后直接跌到 0.4。排查到最后才发现初始划分用的是全局标准化 随机抽样校正集根本没覆盖高浓度段。改成先划分、再用 SPXY 后验证集 RMSE 变高了但新批次预测反而稳定了。从那以后我不再把“划分”当预处理里的一行代码而是当成和模型一样重要的实验变量。希望帮到你。本文还有配套的精品资源点击获取