Scikit-learn交叉验证进阶:从cross_val_score到自定义分割器与嵌套验证 1. 为什么说cross_val_score只是及格线默认行为与信息损耗每次接到新的建模任务我都会先跑一版最朴素的交叉验证分数。cross_val_score的好处是快一行代码就能告诉我当前基线大概在什么水平。但如果你把它当成黑盒用上几个月迟早会碰到几个说不清的问题为什么同样的模型换一个 CV 策略结果差这么多为什么交叉验证分数很漂亮线上却翻车这些问题往往不是因为模型不行而是因为对交叉验证本身的理解还停在“调用”层面没有真正看懂它在做什么。这篇文章我假定你已经能熟练使用 Scikit-learn 的基础建模流程知道fit、predict是干什么的也清楚train_test_split的局限。在这个前提下我们把交叉验证从“及格线”往上拉一个层次重点讲清楚cross_validate、分组交叉验证、时间序列切分、自定义分割器以及嵌套交叉验证。适合两类人一类是已经能顺利建模、但总觉得交叉验证哪里不对的读者另一类是数据里带着用户 ID、时间戳或地理信息怕被数据泄漏坑到的人。1.1 传一个数字进去时Sklearn 到底选了什么很多人以为下面的代码只是“简单的 5 折 KFold”from sklearn.model_selection import cross_val_score scores cross_val_score(model, X, y, cv5)实际上Sklearn 内部会根据model的类型做一次判断。如果model是分类器并且y至少包含两类样本默认使用的其实是StratifiedKFold(n_splits5)如果是回归器才会用回KFold(n_splits5)。我们可以用check_cv亲眼确认from sklearn.model_selection import check_cv print(check_cv(cv5, yy, classifierTrue)) # StratifiedKFold(n_splits5) print(check_cv(cv5, yy, classifierFalse)) # KFold(n_splits5)这个细节说明两件事。第一Sklearn 默认帮你做了分层这对小数据集和分类任务通常是对的。第二这种“自动选择”很容易让人忽略一个重要问题无论 KFold 还是 StratifiedKFold默认都没有随机打乱顺序shuffleFalse。如果你的数据行是按照时间先后或录入顺序排列的那么“分层”只保证了类别比例一致并没有消除样本顺序带来的风险。对于不平衡分类StratifiedKFold 会尽量让每一折里各类别的比例和整体整体一致。这一点至关重要当正类只占 5% 时如果不分层某些折可能整个正类样本几乎为零模型训练出来就是一个“永远预测负类”的废物。默认帮你规避了这个问题但你得知道它在帮你做什么否则后面一旦换成回归任务或者自己传入 CV 迭代器就很容易踩坑。1.2 把五折循环拆开看cross_val_score的完整逻辑并不复杂本质上就是一层循环。手动展开后核心逻辑一目了然import numpy as np from sklearn.model_selection import StratifiedKFold from sklearn.metrics import accuracy_score from sklearn.ensemble import RandomForestClassifier model RandomForestClassifier(n_estimators100, random_state42) skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores [] for train_idx, test_idx in skf.split(X, y): X_train, X_test X[train_idx], X[test_idx] y_train, y_test y[train_idx], y[test_idx] model.fit(X_train, y_train) scores.append(accuracy_score(y_test, model.predict(X_test))) print(scores) print(np.mean(scores), np.std(scores))用代码把这层循环展开之后你会发现交叉验证真正做的事情是把全部样本切成多个“训练/测试”对每个样本有且仅有一次作为测试样本出现其余时候都在训练集里参与建模。最终报告的性能是所有测试集表现的平均值而不是某个具体模型的泛化指标。一个常见的误解是这 5 个折的模型最终会被合并成一个模型用于线上部署。实际上不会。交叉验证只是用来估计“在这份数据上训练模型大概能有多好”线上最终模型通常还是要单独用全量数据重新训练一次。明白这一点你才不会在部署阶段老实等待 5 倍的训练时间也不会误以为线上模型的性能和交叉验证分数完全等价。1.3 一个平均分丢掉的四个信息只输出一个浮点数的麻烦在于它把诊断模型所需要的信息几乎全抹掉了。这里我列四条接下来几节都会逐个补回来。你真正想知道的cross_val_score能告诉你吗用什么替代整体水平大致在哪能给一个均值cross_validate每折成绩波动多大只能自己算 stdcross_validate返回的数组模型是否过拟合不能训练分数被丢弃return_train_scoreTrue模型在不同指标上的表现不能只支持单指标scoring传字典表格里提到的cross_validate才是我们真正应该重点掌握的接口。它跟cross_val_score走的是同一条循环但把“只输出一个分数”变成了“输出一份诊断报告”。从这一节开始后面所有的讨论都会围绕它展开。2.cross_validate多指标、训练分、逐折模型一次交齐2.1 scoring 的三种传入方式cross_validate的scoring参数有三种典型传法。第一种直接传字符串比如scoringroc_auc。这种写法只适合快速看单个指标能解决的问题有限。第二种传一个字典一次算多个指标from sklearn.model_selection import cross_validate results cross_validate( model, X, y, cvskf, scoring{acc: accuracy, auc: roc_auc}, )这时候返回的 dict 里会自动生成test_acc、test_auc这样的键。第三种针对业务自定义打分函数用make_scorer包装后塞进字典from sklearn.metrics import make_scorer, fbeta_score custom_score make_scorer(fbeta_score, beta2, pos_label1) results cross_validate(model, X, y, cvskf, scoring{f2: custom_score})我建议在正式建模阶段尽量使用字典传入多个指标原因是不同指标看的面向不一样accuracy 看整体正确率roc_auc 看排序能力precision 和 recall 看正类覆盖。单独盯任何一个都容易得出片面结论。举个常见例子贷款风控里正类往往极少模型 accuracy 可能 0.97但正类召回几乎为 0线上用起来完全没法看。这种问题只有当你不只盯着 accuracy 时才看得见而多指标交叉验证恰恰是最早暴露它的手段。2.2 return_train_score最快的偏差-方差体检return_train_scoreTrue是我用得非常多的参数。它会在返回结果里加上train_accuracy、train_roc_auc这类训练集分数。有了训练分和测试分你可以直接算两者差值import pandas as pd from sklearn.ensemble import GradientBoostingClassifier results cross_validate( GradientBoostingClassifier(random_state42), X, y, cvskf, scoring{acc: accuracy, auc: roc_auc}, return_train_scoreTrue, ) df pd.DataFrame(results) df[gap_acc] df[train_acc] - df[test_acc] df[gap_auc] df[train_auc] - df[test_auc] print(df[[train_acc, test_acc, gap_acc, train_auc, test_auc, gap_auc]])gap 的含义可以这样理解如果训练分和测试分都高但 gap 特别大说明模型在训练集上记忆能力过强、测试集上泛化不足典型的高方差过拟合如果训练分本身就低测试分自然低两者差距反而不大说明模型容量不够属于欠拟合。我见过不少人拿着很高的交叉验证分数上线结果效果平平一查 gap训练分已经逼近 0.99测试分只有 0.82明显是过拟合被平均值“洗白”了。实际操作中我会给 gap 设一个宽泛的阈值比如 gap_acc 超过 0.1 就判定过拟合、需要降复杂度或者加正则。这当然不一定适合所有业务但它能在模型迭代早期快速筛掉一批注定要翻车的配置省下的时间远大于误判造成的损失。2.3 return_estimator 的进阶价值return_estimatorTrue会把每一折训练出来的模型对象也保存下来。这个参数常被忽略其实用处很大你可以逐折分析模型的稳定性。以随机森林为例每一折训练出来的森林feature_importances_都不一样。把 5 折的特征重要性拼在一起观察哪些特征在每一折都稳定靠前import numpy as np from sklearn.model_selection import StratifiedKFold from sklearn.ensemble import RandomForestClassifier # 注意这里单独跑一次加上 return_estimator rf_results cross_validate( RandomForestClassifier(random_state42), X, y, cvStratifiedKFold(5, shuffleTrue, random_state42), scoringroc_auc, return_estimatorTrue, ) fis np.array([est.feature_importances_ for est in rf_results[estimator]]) mean_fi fis.mean(axis0) std_fi fis.std(axis0) for i, (m, s) in enumerate(zip(mean_fi, std_fi)): print(ffeature_{i}: mean{m:.4f}, std{s:.4f})如果某个特征重要性均值不高但方差特别大说明它只在部分折里起作用依赖的数据结构不稳定。这种特征在做特征筛选时会优先被剔除。同样的思路也可以用在残差分析上逐折收集测试集预测错误的样本观察它们是不是集中在某些固定群体里这往往是脏数据或边界样本的信号。2.4 把 dict 整理成 DataFrame 的标准姿势cross_validate返回的是 Python dict键包括fit_time、score_time、test_acc、train_acc、estimator等。直接手动索引不太方便我习惯统一转成 DataFramedf pd.DataFrame(results)之后就可以随意切片、排序、计算均值标准差。我一般还会封装一个小函数把多个模型的交叉验证结果汇总成一张对比表方便在做模型选型时直接比较def summarize_cv(results_dict): records [] for name, res in results_dict.items(): for key in res: if key.startswith(test_): records.append({ model: name, metric: key, mean: np.mean(res[key]), std: np.std(res[key]), }) return pd.DataFrame(records)这个函数虽然简单但对团队协作很友好。每个人跑完自己负责的模型把结果丢进同一张表谁高谁低、谁稳谁飘一眼就能看到。3. 分组与时间序列当样本不再独立时的交叉验证姿势3.1 三种典型的数据泄漏来源前面聊的默认 CV 策略都建立在“样本之间相互独立”这个理想假设上。但现实里的数据大量不满足这个条件。最常见的泄漏来源有三种。第一种同一对象重复出现。同一个用户的多次点击、同一设备的多次上报、同一家门店的多条流水如果用户 A 的一部分样本在训练集、另一部分在测试集模型等于提前“见过”了这个用户交叉验证分数会被明显虚高。第二种空间相关的重复测量。同一个地点的多个传感器读数、同一批次的实验样本它们共享环境因素。一旦这些样本被切到不同折里模型就能通过环境线索“作弊”。第三种时间相关的样本。今天的数据预测明天看起来合理但如果你随机打乱再划分训练集里就可能混有“未来”的样本模型学到了时间模式之外的规律线上却永远拿不到未来数据。判断方法其实是一句话你的测试集在信息层面是否完全独立于训练集如果答案是否定的就需要分组交叉验证或时间序列交叉验证出场了。3.2 GroupKFold、StratifiedGroupKFold、LeaveOneGroupOut 怎么选GroupKFold的思路非常直接同一个 group 的所有样本全部放在同一折。测试集里出现的每一个 group在训练集里完全不出现。from sklearn.model_selection import GroupKFold, cross_validate # groups 是一个数组长度和 y 相同表示每个样本属于哪个用户/设备/门店 results cross_validate(model, X, y, groupsuser_ids, cvGroupKFold(n_splits5))要注意cross_validate里需要显式传groups这是很多人第一次用的时候会漏掉的地方。StratifiedGroupKFold是进阶版。它在保证组不跨折的前提下尽量让每一折的类别比例接近整体。但要明白它的边界它无法同时完美兼顾“组之间大小均衡”和“类别比例均衡”只是尽力而为。当组数偏少、类别严重不平衡时它仍然可能不够均衡这时需要你亲眼检查实际分布。LeaveOneGroupOut适合组数量很少的情况。比如只有 8 家门店你想评估模型在“完全没见过的门店”上的表现那就每次留一家门店当测试集循环 8 次。选择策略可以参考这张表CV 策略核心保证适用场景限制GroupKFold组不跨折用户/设备/门店分组每折样本量可能不均衡StratifiedGroupKFold组不跨折近似分层分类且类别不平衡组数太少时不稳定LeaveOneGroupOut每次只留一个组组数量少15折数等于组数开销大TimeSeriesSplit训练集始终早于测试集时间序列需配合 gap 防短期泄漏3.3 TimeSeriesSplitgap 与 max_train_size 的取舍对时间序列随机划分是完全错误的。TimeSeriesSplit的做法是第一折只用最早的一小段训练预测后面的验证段第二折把验证段纳入训练再预测更后面的一段以此类推。这是典型的“扩展窗口”。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5, gap7, max_train_size365)这里我专门讲gap因为几乎每次上课或答疑都会有人忽略它。gap指的是训练集末尾和验证集开头之间要空出来的样本数量它解决的是时间依赖问题如果特征里有滞后变量或者目标变量存在短期自相关那么紧挨着验证集的那几个训练样本和验证样本在时间上高度相关模型很容易“背答案”。空出 gap 个样本相当于强制模型不能依赖最后几天的记忆。但必须注意gap是按样本条数算的不是按时间长度算的。如果你的数据不是均匀采样比如周末没有记录那么gap7并不等于“跳过 7 天”。这种时候要么先把数据重采样成均匀的日粒度要么自己写一个按时间戳切分的迭代器逻辑上会更精确。max_train_size则可以限制训练集的长度上限把扩展窗口变成滚动窗口。这对长期存在概念漂移的数据很有用模型只依赖最近一年的数据而不是从 5 年前的所有历史开始学。3.4 一张表选对 CV 策略把决策路径收拢一下可以按下面这个顺序判断样本确实独立分类任务 →StratifiedKFold(n_splits5, shuffleTrue, random_state42)样本独立回归任务 →KFold(n_splits5, shuffleTrue, random_state42)样本有组结构 →GroupKFold或StratifiedGroupKFold样本有时间顺序 →TimeSeriesSplit样本既有组又有时间 → 优先考虑自定义分割器详见下一节如果你拿到的数据同时满足好几条别急着套内置类大概率需要写一个自己的分割器才能同时满足所有约束。4. 自定义分割器两个方法写给自己的 CV 迭代器4.1 迭代器协议其实只有两个方法Sklearn 的 CV 接口非常宽松。一个对象只要能提供两个方法就可以作为cv参数传给交叉验证和网格搜索split(X, yNone, groupsNone)返回一个生成器每次yield一对(train_idx, test_idx)get_n_splits(XNone, yNone, groupsNone)返回整数折数不继承任何基类也能用但继承BaseCrossValidator更省事因为你可以免费获得一些被其他工具识别的通用属性。我一般都会继承它。还有一个值得了解的细节cv参数其实可以直接传一个“可迭代对象”只要它迭代出来的元素是索引对就行。但要注意像GridSearchCV这类工具会调用get_n_splits来预判总任务量如果你只传一个普通生成器它可能需要先把生成器跑一遍来数折数容易把生成器耗尽。稳定起见还是实现成一个类更可靠。4.2 实战按“区域”划分的交叉验证器我举一个真实遇到的场景。有一份门店级数据需要评估模型在“从未见过的新门店”上的表现。GroupKFold可以做到门店不跨折但它把门店随机分到各折时大门店可能扎堆进同一折导致某一折的训练样本极少评估噪声非常大。于是我写了一个按门店规模排序后再轮流分配的迭代器尽量让大组均匀散到每一折import numpy as np from sklearn.model_selection import BaseCrossValidator class GroupSizeBalancedCV(BaseCrossValidator): def __init__(self, n_splits5): self.n_splits n_splits def get_n_splits(self, XNone, yNone, groupsNone): return self.n_splits def split(self, X, yNone, groupsNone): groups np.asarray(groups) regions np.unique(groups) sizes np.array([np.sum(groups r) for r in regions]) if len(regions) self.n_splits: raise ValueError(组数少于折数某些折会出现空测试集) # 大组优先轮流放进每一折尽量均衡各折训练量 sorted_regions regions[np.argsort(sizes)[::-1]] for i in range(self.n_splits): test_regions set(sorted_regions[i::self.n_splits]) test_mask np.isin(groups, np.array(list(test_regions))) train_idx np.where(~test_mask)[0] test_idx np.where(test_mask)[0] yield train_idx, test_idx代码里我加了一个防御性的校验如果组数少于折数有些折会拿到空测试集直接报错比跑半天再抛诡异异常强得多。这个方法牺牲了部分随机性换来了更均衡的每折样本量。封装成类之后调用方式和内置 CV 完全一致cv GroupSizeBalancedCV(n_splits5) results cross_validate( model, X, y, groupsstore_ids, cvcv, scoringroc_auc, return_train_scoreTrue, )自定义类的最大价值在于它把你的业务约束显式写进了验证逻辑。别人接手代码看一眼类名和注释就能理解这个验证策略的设计意图不用再靠嘴和文档传话。4.3 自定义迭代器接入 GridSearchCV自定义迭代器不仅能用在下游验证也能直接传给调参工具from sklearn.model_selection import GridSearchCV param_grid {n_estimators: [50, 100, 200]} grid GridSearchCV( RandomForestClassifier(random_state42), param_grid, cvcv, scoringroc_auc, ) grid.fit(X, y, groupsstore_ids)这里的关键是如果你的分割器依赖groups参数就需要在fit时把groups传进去这也是fit方法里那个看似多余的参数存在的意义。忘了传的话分割器内部groupsNone多半会直接报错。所以写自定义分割器时一定要在文档字符串里写清楚调用方需要传什么、不传会发生什么。另外自定义分割器上线前最好做一个不变量检查for tr_idx, te_idx in cv.split(X, y, groupsstore_ids): assert set(groups[tr_idx]).isdisjoint(set(groups[te_idx]))如果断言失败说明你的分割逻辑有漏洞组信息被切穿了整个交叉验证会作废。5. 嵌套交叉验证把调参和评估分开避免“用测试集调参”5.1 单层交叉验证为什么偏乐观一个非常隐蔽但常见的错误是用 GridSearchCV 跑半天拿到最优参数后直接把grid.best_score_当作模型最终表现写进报告。这个分数是有水分的因为那几折测试集已经参与过参数挑选——它们模拟的是“调参时的验证集”而不是“从未接触任何调参信息的干净测试集”。嵌套交叉验证的思路就是再加一层外壳外层循环留出一些样本内层循环在剩余样本里做参数搜索最后用外层留出的样本评估最优模型。这样一来每一个被评估的数据点都完全没有参与过训练和调参。本质上这跟“不要用测试集调参”是同一个原则只是把它贯彻到了每一折里。差别在于单层 GridSearchCV 的“测试集”实际上承担了验证集的角色而嵌套交叉验证里每一折的测试集都被严格隔离在外层循环中。5.2 嵌套 CV 的标准实现模板手写嵌套循环一点也不复杂import numpy as np from sklearn.model_selection import StratifiedKFold, GridSearchCV from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score X ... # 你的特征 y ... # 你的标签 outer_cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) inner_cv StratifiedKFold(n_splits3, shuffleTrue, random_state7) param_grid {n_estimators: [100, 200, 300], max_depth: [3, 5, None]} nested_scores [] for train_idx, test_idx in outer_cv.split(X, y): X_train, X_test X[train_idx], X[test_idx] y_train, y_test y[train_idx], y[test_idx] search GridSearchCV( RandomForestClassifier(random_state42), param_grid, cvinner_cv, scoringroc_auc, n_jobs-1, ) search.fit(X_train, y_train) best_model search.best_estimator_ y_pred_proba best_model.predict_proba(X_test)[:, 1] nested_scores.append(roc_auc_score(y_test, y_pred_proba)) print(fnested CV ROC-AUC: {np.mean(nested_scores):.4f} ± {np.std(nested_scores):.4f})注意两个细节。第一内层 CV 的折数我一般设 3因为内层只负责选参不需要太高的评估精度外层评估用 5 折用于最终给业务方报数稳定性更重要。第二GridSearchCV在得到最优参数后会默认用全部内层训练数据重新训练一次best_estimator_所以直接取出来做外层预测没有问题。5.3 嵌套验证的成本控制嵌套交叉验证的代价是外层折数 × 内层折数 × 参数组合数很容易把一个中等规模的训练任务放大到原来的几十倍。缓解手段有三个。第一个把内层的GridSearchCV换成RandomizedSearchCV把参数组合数从全笛卡尔积降为固定抽样数比如n_iter20。第二个内外层折数合理分配外层 5、内层 3 是常见配置先跑一个简化版估一下时长再决定要不要扩大。第三个控制并行度。外层循环里如果已经开了n_jobs-1内层也开并行会造成 CPU 资源争抢通常让内层并行、外层串行整体吞吐反而更好。我的习惯是嵌套交叉验证不是每次建模都跑它主要用在模型定稿阶段——当你准备给业务方一个“不掺水”的预期性能数字时。前期快速迭代参数单层交叉验证完全够用等模型快定了再跑一次嵌套验证成本可控结论也更可信。6. 交叉验证避坑笔记从报错与脏数据里学到的五点6.1 预处理泄漏Scaler 必须待在 Pipeline 里我在一个回归任务上吃过这个亏。代码长这样看起来没有任何问题from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled scaler.fit_transform(X) # 错误示范 scores cross_val_score(model, X_scaled, y, cv5)问题在于scaler在交叉验证开始前已经看到了全量数据每一折的训练集和测试集都共享了这份统计量。标准化不再是“只由训练数据推得”的模型在测试集上的表现会被系统性高估。当时我发现的线索是手写的独立测试集上模型表现明显低于交叉验证分数查了很久才意识到泄漏发生在预处理环节。正确做法是让 preprocessing 待在 Pipeline 里每一折都在训练子集上重新 fitfrom sklearn.pipeline import make_pipeline pipe make_pipeline(StandardScaler(), LogisticRegression(max_iter1000)) results cross_validate(pipe, X, y, cvskf, scoringroc_auc)这个原则同样适用于缺失值填充、One-Hot 编码、PCA 降维、特征选择。只要会用到数据统计信息就应该放进 Pipeline并且在每一折独立执行。6.2 不平衡数据StratifiedKFold 也别忘了检查每一折用StratifiedKFold是默认动作但它只保证类别比例近似一致。极端情况下比如正类样本数少于折数、或者正类比例只有 1%某些折里正类可能只剩一两个模型在那一折上根本学不到正类模式。我习惯在建模前先看一眼每一折的类别分布skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for fold, (tr_idx, te_idx) in enumerate(skf.split(X, y)): print(ffold {fold}: train pos rate{y[tr_idx].mean():.3f}, test pos rate{y[te_idx].mean():.3f})如果某个折的测试集正类比例明显偏离整体就需要增加折数、改用RepeatedStratifiedKFold做多轮重复验证或者配合采样策略调整。RepeatedStratifiedKFold可以指定n_repeats比如 5 折重复 10 次最终得到 50 个分数对不平衡数据的评估稳定性会好很多。6.3 折数选择不是越大越稳n_splits的默认值是 5不少任务还会习惯性调成 10。但折数越大单折训练集越小模型在每折上的训练充分度下降分数的方差反而可能变大尤其在几千条的小数据集上非常明显。我的经验是几千条样本用 5 折几万到几十万用 5 或 10 折百万级大样本完全可以用 3 折加多次重复。折数应当和样本量匹配而不是盲目堆高。还有一个隐蔽问题当折数超过某个类别的样本数时StratifiedKFold会直接报错。遇到这类报错第一反应应该是检查数据量是否撑得起这么多折而不是急着改代码。6.4 可复现性随机种子偏执狂的自我修养交叉验证里到处是随机性。KFold 的 shuffle、StratifiedKFold 的 shuffle、RandomizedSearchCV 的抽样这些都是默认随机的。如果不固定random_state同一个脚本今天跑和明天跑结果可能差不少这会让问题排查变得极其痛苦。我建议所有涉及随机切分的地方都显式设置random_state并且用同一个值统一管理。比如项目里固定SEED 42所有建模代码引用同一个常量。这样当你发现结果异常时可以毫无心理负担地排查特征和模型而不是怀疑随机种子在捣乱。6.5 并行参数省时间有时不如省内存n_jobs-1很爽但cross_validate会对 estimator 做克隆后再并行 fit。如果模型本身很重比如复杂集成模型并行会同时吃满多核 CPU 和大量内存机器小一点直接 OOM。这时候可以调pre_dispatch参数控制实际分发的任务数。比如设置为pre_dispatch4同一时间最多处理 4 个任务其余排队等待内存压力会小很多。另外如果发现 CPU 很忙但交叉验证反而更慢很可能是任务粒度太小进程间通信和调度开销超过了并行收益。这种情况可以先单进程跑一次看一下资源占用曲线再决定要不要开并行。我自己现在拿到新数据第一件事永远是先回答一个问题交叉验证里测试集和训练集之间的信息边界到底在哪里把这个问题想清楚了再谈模型选什么、参数怎么调才有意义。希望你也能把这些工具变成自己的常规动作而不是等到交叉验证分数和线上表现对不上时才开始怀疑验证策略出了问题。