ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

R语言训练集、测试集、验证集划分全指南:分层抽样与防泄漏实操

R语言训练集、测试集、验证集划分全指南:分层抽样与防泄漏实操

训练集、测试集和验证集的划分,几乎每个用R做过建模的人都会写,但大多数项目里我看到的写法只有一个sample函数从头切到尾,完全没考虑过数据本身的特殊性。这篇文章把我自己在R里拆分训练集、测试集和验证集的经验完整梳理一遍,从最基础的随机抽样讲到分层抽样、按组切分和时间序列切分,顺便把数据泄漏、重复样本、类别不均衡几个真正会坑到你的细节也一起说清楚。内容偏向实操,适合刚接触R建模的初学者,也适合写了不少项目但仍然被“测试集调参调出幻觉”困扰的朋友参考。

1. 三个集合的职责要分清:训练、验证、测试不是随便叫的三个名字

1.1 通常我们说的验证集和测试集,地位完全不一样

训练集用来拟合模型参数,验证集用来在训练过程中做模型选择、超参数调整和早停判断,测试集只承担最终一次评估。我在实际项目里经常用一个比喻跟同事解释三者的区别:训练集是平时刷题,验证集是模拟考,测试集是真正的高考。模拟考可以反复做,做完了针对薄弱环节补课完全没问题;但如果整个备考期都在拿高考试卷当模拟考练,那最后考出来的分数就没什么参考意义了。

这个比喻背后是统计学上一个很实在的问题:每一次超参数调整如果都依赖测试集的结果来反馈,模型就会在不知不觉中“记住”测试集的信息。调整次数越多,模型在测试集上的表现就越偏向乐观,等到真正上线面对没见过的数据时,性能就会明显回落。我见过不止一个项目,交叉验证分数漂亮得不得了,上线之后指标打对折,回溯原因基本都是测试集被反复使用,而且整个流程里没有独立的验证集来拦截这种污染。

1.2 为什么我建议默认用三分法,而不是训练集加测试集两分法

两分法不是不能用。如果你的样本量非常大,并且你习惯用k折交叉验证在训练集内部完成模型选择,那么确实可以只留一个最终测试集。但大多数项目其实并没有那么大的数据量,也没有严格的交叉验证流程,这时候只有训练集和测试集就会非常危险。你每改一次特征、每换一次超参数都去看测试集上的指标,测试集实际上已经变成了验证集,最终报告里那个数字的可信度会大打折扣。

所以在R里动手写分割代码之前,我会先问自己三个问题:这个项目有多少样本?样本之间是否独立?类别分布是不是均衡?这三个问题的答案直接决定了后面用哪一种切分方式。比例上,我一般默认7:1.5:1.5,也就是训练集七成、验证集一成五、测试集一成五。如果样本总量很小,比如只有几千行,固定切出15%的验证集可能连某些小众类别都覆盖不到,这时候就要考虑分层抽样,甚至干脆放弃固定验证集,改用交叉验证。

1.3 数据划分决定的是整个评估体系的合法性

很多人觉得划分数据只是建模流程里最简单的一步,写两行代码就过去。但我在实际项目中越来越清楚地感受到,数据划分决策的质量会传导到后续每一个环节。划分没做好,后面的特征工程、模型调优、结果分析全都是在不稳定地基上盖楼。尤其是当你需要向业务方解释模型效果时,“这个测试集到底干净不干净”几乎是必被追问的问题。

所以下面几节的思路不是教你复制某一段代码,而是帮你建立一套选择划分方式的判断力:什么时候可以用简单随机抽样,什么时候必须做分层,什么时候必须按ID切组,什么时候只能按照时间顺序硬切。这些判断比代码本身重要得多。

2. R里做划分的三种主流姿势:base R、caret、rsample怎么选

2.1 base R的sample写法最直接,但漏洞也最明显

最基础的随机划分,大家应该都写过类似的代码:

set.seed(2024) n <- nrow(df) idx <- sample.int(n) n_train <- floor(n * 0.7) n_valid <- floor(n * 0.15) train <- df[idx[1:n_train], ] valid <- df[idx[(n_train + 1):(n_train + n_valid)], ] test <- df[idx[(n_train + n_valid + 1):n], ]

这段代码本身没有问题,它做的是纯随机抽样,对独立同分布的样本是合理的。但它有三个看不到的隐患:第一,没有做分层,如果df里的目标类别是九比一的不均衡分布,切完之后很可能出现某个子集里某个类别占比严重偏离原始分布的情况;第二,没有处理重复样本和同一ID多条记录的情况,同一个用户可能同时出现在训练集和测试集里;第三,sample.int对行数极多的大数据框会一次性生成全量随机索引,内存消耗没有必要那么大。

如果只是临时做个demo,base R的写法完全够用。但如果你用它来处理业务上的真实数据,我建议至少要把set.seed固定住,并在代码注释里写明这个种子对应的划分版本,方便后续复现。

2.2 caret::createDataPartition是分层切分的最短路径

如果你要保证训练集、验证集、测试集里每个类别的占比尽量接近原始数据集,高层级的做法就是使用caret包的createDataPartition。它的核心逻辑是按类别分组,再从每个类别内部按比例抽取样本,从而在全局划分时保持类别比例。

library(caret) set.seed(2024) train_idx <- createDataPartition(df$label, p = 0.7, list = FALSE) train <- df[train_idx, ] rest <- df[-train_idx, ] valid_idx <- createDataPartition(rest$label, p = 0.5, list = FALSE) valid <- rest[valid_idx, ] test <- rest[-valid_idx, ]

这里的一个细节是:第一次抽出70%作为训练集后,剩余数据占30%,我希望从中再切出一半作为验证集,一半作为测试集,所以createDataPartition里的p要写成0.5,而不是0.15。很多人在这一步直接写p = 0.15,切出来的验证集只会占全量数据的4.5%,而不是15%。这个小坑我在不同的项目里踩到过,也看同事踩过,值得专门提一下。

craateDataPartition返回的是行号索引而不是数据框本身,这对后续想要反复按索引取子集的操作很方便。另外它的默认参数里有一个`groups`概念,适用于某些连续型变量自动分组使用的场景,普通分类任务不需要关心。

2.3 rsample包的initial_split更适合tidyverse风格的流水线

习惯用dplyr和tidymodels生态的人通常更愿意用rsample包。initial_split的设计初衷就是配合tidyverse的建模流程,直接返回一个rset对象,再用training()和testing()提取数据。

library(rsample) set.seed(2024) split1 <- initial_split(df, prop = 0.7, strata = label) train <- training(split1) temp <- testing(split1) split2 <- initial_split(temp, prop = 0.5, strata = label) valid <- training(split2) test <- testing(split2)

rsample的优势在于它的扩展性做得很好。除了initial_split,还有做时间序列用的initial_time_split,做重采样用的vfold_cv、bootstraps等。如果你后续要接tidymodels的完整建模管线,用rsample做数据划分是衔接最顺的方案。它内部也封装了分层逻辑,strata参数可以指定分层变量,这一点跟caret是同一个思路。

2.4 三个方案的取舍对比

方案分层支持时间序列支持生态风格学习成本我的使用建议
base R sample不支持不支持基础R低简单demo、预期样本独立
caret createDataPartition支持不支持传统建模中分类任务、类别不均衡明显
rsample支持支持tidyverse中高完整建模流程、tidymodels用户

选择哪个方案并不代表优劣,真正的判断标准是你的数据长什么样。下面我就从“数据长什么样”这个角度,给出一套可以覆盖大部分需求的完整分割函数。

3. 手写一个split_dataset函数,一次覆盖随机、分层、按组三种切法

3.1 函数设计思路:先看清楚数据的结构再切

我在项目里最后沉淀下来的用法,是写一个统一的split_dataset函数。它接受原始数据框和几个关键参数,内部自动判断走哪条切分路径。这么做的好处是代码语义清晰,每个项目里只需要调用一行函数,后续维护也不用到处找散落的sample代码。

split_dataset <- function(data, train_ratio = 0.7, valid_ratio = 0.15, test_ratio = 0.15, seed = 2024, stratify_by = NULL, group_by = NULL, sort_by = NULL) { stopifnot(abs((train_ratio + valid_ratio + test_ratio) - 1) < 1e-6) if (!is.null(sort_by)) { # 时间顺序切分:适合时序场景 data <- data[order(data[[sort_by]]), , drop = FALSE] n <- nrow(data) idx_train <- 1:floor(n * train_ratio) idx_valid <- (floor(n * train_ratio) + 1):floor(n * (train_ratio + valid_ratio)) idx_test <- setdiff(seq_len(n), c(idx_train, idx_valid)) return(list( train = data[idx_train, , drop = FALSE], valid = data[idx_valid, , drop = FALSE], test = data[idx_test, , drop = FALSE] )) } set.seed(seed) if (!is.null(group_by)) { # 按组切分:保证同一个分组ID只出现在一个集合中 groups <- unique(data[[group_by]]) g_idx <- sample.int(length(groups)) n_train_g <- max(1, floor(length(groups) * train_ratio)) n_valid_g <- max(1, floor(length(groups) * valid_ratio)) train_groups <- groups[g_idx[1:n_train_g]] valid_groups <- groups[g_idx[(n_train_g + 1):(n_train_g + n_valid_g)]] test_groups <- setdiff(groups, c(train_groups, valid_groups)) return(list( train = data[data[[group_by]] %in% train_groups, , drop = FALSE], valid = data[data[[group_by]] %in% valid_groups, , drop = FALSE], test = data[data[[group_by]] %in% test_groups, , drop = FALSE] )) } if (!is.null(stratify_by)) { # 分层切分:按照类别比例抽样 if (!requireNamespace("caret", quietly = TRUE)) { stop("使用stratify_by参数需要安装caret包", call. = FALSE) } train_idx <- caret::createDataPartition( data[[stratify_by]], p = train_ratio, list = FALSE ) rest <- data[-train_idx, , drop = FALSE] valid_idx <- caret::createDataPartition( rest[[stratify_by]], p = valid_ratio / (1 - train_ratio), list = FALSE ) return(list( train = data[train_idx, , drop = FALSE], valid = rest[valid_idx, , drop = FALSE], test = rest[-valid_idx, , drop = FALSE] )) } # 默认:普通随机切分 n <- nrow(data) idx <- sample.int(n) n_train <- floor(n * train_ratio) n_valid <- floor(n * valid_ratio) list( train = data[idx[1:n_train], , drop = FALSE], valid = data[idx[(n_train + 1):(n_train + n_valid)], , drop = FALSE], test = data[idx[(n_train + n_valid + 1):n], , drop = FALSE] ) }

3.2 按组切分的逻辑:为什么有时候必须用group_by

业务场景里最典型的情况是一个用户对应多行行为记录。比如电商场景里,一个用户下过多张订单,每张订单一行数据;医疗场景里,一个患者可能多次就诊。如果按普通行号随机切分,同一个用户或同一个患者的记录大概率同时进入训练集和测试集,模型在测试集上会“见到”它曾经在训练集里学过的对象,分数自然虚高。真正上线后面对的是全新的用户,效果掉下来也就不奇怪了。

按组切分的思路是先对全部用户ID做随机洗牌,再把ID集合按比例分成三份,最后用%in%把对应行取出来。这样任何一个用户的所有记录只会落入其中一个集合。代价是划分的单位从行变成了组,每个集合的实际行数可能和理论比例有偏差,但这是为了评估可信度必须付出的代价。

3.3 使用分组切分时要注意分组数量太少的问题

如果数据里只有十几个用户ID,按组切分就会出现一个问题:训练集可能只分到几个组,测试集可能只分到一两个组,组和组之间的差异很可能远大于组内的自然波动。这种情况下模型训练不充分,评估也不稳定。我的经验是group_by路径更适合分组数量比较多、比如上百甚至上千组的场景。如果组数太少,可能的工作方式是考虑按组做交叉验证,或者用对组身份进行泛化的混合效应模型。

3.4 分层切分逻辑的说明

分层切分在函数里的实现是先用createDataPartition抽出训练集,再从剩余数据里按比例抽出验证集。这里有一个值得注意的地方:createDataPartition在处理非常大的连续值时,内部会按分位数自动分箱,因此除了分类目标列,它也可以用来对连续目标做分层,保证训练集和剩余集的取值范围分布接近。不过如果只是普通的数值预测任务,我更建议先用hist或者quantile看一下目标分布,再决定是否需要分层。

4. 数据划分最容易踩的坑:泄漏、重复样本和类别不均衡

4.1 数据处理顺序不对,从划分第一步就泄漏了

我见过最普遍的错误不是切分代码本身,而是切分之前就做了全局标准化或全局缺失值填充。比如有人用caret::preProcess对整个df先做了中心化和标准化,然后再划分训练集、测试集。这种做法看似没问题,实际上测试集的均值和标准差已经参与计算了,模型的输入中带着测试集的信息,这就是典型的数据泄漏。

正确做法是先划分,再把preProcess拟合到train上:

library(caret) # 正确:先划分,再只在train上拟合预处理器 preProc <- preProcess(train, method = c("center", "scale")) train_scaled <- predict(preProc, train) valid_scaled <- predict(preProc, valid) test_scaled <- predict(preProc, test)

注意,validation和test都必须使用从train上拟合好的变换参数,不能重新fit。如果你用的是tidymodels的recipe,同理,prep只针对训练集,bake再应用到测试集。这类问题很难被代码报错暴露出来,但会影响最终评估的准确性,我每次写数据管线时都会检查一遍代码的执行顺序。

4.2 重复样本和近似重复样本比想象中更常见

除了同一个ID对应多行记录,还有一种情况是数据集里有完全重复的行,或者只是个别字段不同的近似重复样本。完全重复的行如果被随机分割,可能同一行样本同时出现在训练集和测试集,测试集指标的乐观偏差会比较明显。

遇到这种情况,第一步先做去重和基于关键ID的重复性检查:

# 查看完全重复的行 sum(duplicated(df)) # 按业务ID去重后看保留行数 length(unique(df$user_id))

如果业务上允许,尽量在划分前把重复样本处理掉,或者使用按组切分。如果是图像、文本这种无法简单判断重复的近似重复样本,就需要使用相似度去重或至少保证测试集来源与训练集不交叠。我在某跨平台系统的项目里就遇到过文本样本在训练集和测试集高度相似的情况,最后靠按来源ID切分才勉强保证评估可信。

4.3 类别不均衡时,简单随机抽样会让你在验证集上看不到弱势类别

当目标类别很不均衡,比如正样本只占5%,简单随机抽样切出来的验证集可能只有几十个正样本,模型评估的波动性会非常大。这个时候必须用分层抽样。分层抽样会让每个子集里的正样本比例尽量保持5%左右,比纯随机稳定得多。

但分层抽样也不是万能的。如果一个类别只有十几个样本,分层后验证集里可能只剩两三个,再进行超参数调整时偶然性太大。这种极不均衡的场景,我通常会放弃固定验证集,改用交叉验证在训练集内部评估,测试集只在最终用一次。这里的核心思想是对小样本类别的评价次数越多,统计有效性才会越好,而不是把珍贵的样本机械地切出去一块。

4.4 采样不均衡对分层函数本身也会产生影响

createDataPartition在类别数量很少时虽然不会报错,但它按类别比例抽样的结果可能让某个子集的类别样本数变成0或者1。因此我建议在调用分层划分之前,先打印出每个类别的频数表,心里有数之后再决定是分层、过采样还是用交叉验证。总之,代码可以简单,决策不能简单。

5. 时序数据、多标签数据和分组数据的特殊切分方案

5.1 时间序列数据切分不能随机打乱,必须保持顺序

时间序列数据的核心特点是样本之间不是独立同分布的,相邻时间点的样本往往高度相关。如果随机打乱再切分,训练集里可能混入“未来”的信息,测试集里也可能出现“过去”的数据,模型评估会严重失真。正确的做法是按照时间先后顺序把前70%的数据用作训练,接下来的15%用作验证,最后15%用作测试。

用我上面的split_dataset函数,只需要指定sort_by参数:

res <- split_dataset(df, train_ratio = 0.7, valid_ratio = 0.15, test_ratio = 0.15, sort_by = "date")

如果要用rsample,就换成:

library(rsample) set.seed(2024) split <- initial_time_split(df, prop = 0.7)

5.2 时序数据还有一种更严谨的验证方式:滚动窗口验证

有些时序项目的验证集并不是一次性切出来的。比如你想预测未来7天的销量,只用一个固定验证集来调整模型裁剪窗口、滞后阶数等超参数,可能不够稳定。更常见的是做滚动窗口的交叉验证,每次训练集保持一个窗口长度,验证集不断前移。rsample里的rolling_origin可以做到这一点,它会生成多个训练和验证窗口。这类做法在负荷预测、金融风控里非常常见,我个人的建议是最终测试集依然保留最后一段未参与训练的数据来兜底。

5.3 多标签数据的划分策略

多标签分类任务里,每个样本可能同时属于多个标签。如果你只用某一个标签做分层,其他标签的分布在划分后可能失衡。处理方式一般有三种:第一,直接把样本当作独立单位做随机划分,适用于标签数量多但稀疏的场景;第二,如果某个主要标签对业务尤其重要,就用这个标签做分层;第三,如果数据带有用户ID,直接用user_id做分组切分,从来源上避免泄漏。

我在实践中的经验是,多标签数据优先考虑按业务主体分组切分,而不是为了严格保持标签比例做复杂的多维分层。多维分层在标签空间超过三四个维度时非常难以控制和验证,性价比很低。

5.4 不同场景下三个集合应该怎么分配

场景推荐切分方式备注
分类、类别不均衡明显分层抽样保持类别占比
同一用户多行记录按用户ID分组切分避免泄漏
时序预测按时间顺序切分禁止随机打乱
多标签分类按业务主体分组切分不做复杂多维分层
样本量极小交叉验证替代固定验证集测试集仍然只留一次

6. 划分之后不要直接冲进建模,先做这三件事

6.1 先用描述统计核对三个子集的分布一致性

切完之后,我会习惯性地先看一眼每个集合的规模,再检查关键特征和目标变量的分布是否接近。这一步不是为了走形式,而是为了抓住切分时可能出现的潜在偏差,尤其是分层抽样和按组切分都可能导致某个特征在某子集上分布异常。

# 查看三个子集的行数与类别比例 lapply(list(train = train, valid = valid, test = test), function(x) { table(x$label) })

如果是回归任务,可以比较目标变量的均值、标准差和分位数。差异如果超过可接受范围,我会回到分割函数里检查是否存在近似重复样本,或者考虑换一种切分方式。

6.2 把测试集单独归档,进入“最终评估只允许一次”状态

测试集的严肃性要实际体现在代码管理上。我的做法是把测试集单独的RDS文件存到一个固定目录,之后主工作流只引用训练集和验证集,测试集文件在建模调参阶段不读入内存。这样从流程层面避免了自己“不小心”又用测试集算了一次指标的可能。

saveRDS(train, "data_split/train.rds") saveRDS(valid, "data_split/valid.rds") saveRDS(test, "data_split/test.rds")

同时,我会把划分时用的种子和参数也存进去:

split_meta <- list( train_ratio = 0.7, valid_ratio = 0.15, test_ratio = 0.15, seed = 2024, stratify_by = "label", created_at = Sys.time() ) saveRDS(split_meta, "data_split/split_meta.rds")

这些元数据在写统计报告时非常有用,因为评审方往往需要知道你的测试集是怎么来的。

6.3 数据泄漏要在划分完成后立刻再查一遍

即便划分时已经足够注意,我仍然建议在建模前做一次“泄漏自检”。最简单的检查是看测试集里的业务ID是否在训练集中出现过。如果是分组切分,这个检查理论上是多余的;如果是随机切分或分层切分,这个检查常常能发现被忽略的重复ID问题。

# 检查test里的用户ID是否出现在train中 train_ids <- unique(train$user_id) overlap_ids <- unique(test$user_id[test$user_id %in% train_ids]) length(overlap_ids)

如果overlap_ids的数量不是0,就说明训练集和测试集存在交集,模型评估的可信度已经打了折扣。遇到这种情况,最简单的方法是升级为按用户ID分组切分,必要时从头跑一遍特征工程。

6.4 让划分代码成为项目里可复用的脚手架

我最后想强调的是,数据划分代码看起来简单,却值得当作项目基础设施对待。把它封装成小函数、放进公共代码目录、补上参数说明,后续新项目初始化时直接复制使用。这样做节省的时间虽然不多,但更重要的是它把“划分策略”这个曾经的隐性决策变成了显式代码,项目成员一看到函数就能理解这个项目是怎么划分数据的。

我在实际使用中发现,把split_dataset这类函数沉淀下来之后,我和同事之间关于数据划分的沟通成本明显降低,因为大家不用再各自解释自己用了什么样的切分方式。一份统一、清晰、经过验证的划分工具,比临场搜来的代码可靠得多。测试集一旦被提交进最终流程,整个模型评估的合法性就是从这段看似不起眼的划分代码开始的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进