第 12 章 · 分组交叉验证:KFold vs GroupKFold
这一章要解决什么:上一章的 CV 已经用
GroupKFold了。 这一章回答"如果我(或同事)顺手写成cv=5会怎样" —— 答案是:你会得到一个又高又稳的数字,而它是错的。最容易踩的坑: ① 用
cv=5/KFold在有分组结构的数据上做 CV(不报错); ② 看到±0.00055就认为"模型非常稳定" —— 方差小恰恰是泄漏的特征; ③ 折数选得比实体数还多。
本章你需要完成的代码:demos/ch12-group-cross-validation.py
12.1 同一个模型,只换「怎么切」
这一节的两个数字,是整本书最值得记住的一组对照。
from sklearn.model_selection import GroupKFold, KFold, cross_val_score
model = lambda: RandomForestRegressor(n_estimators=200, random_state=42)
# ① 普通 K 折(按行随机)
s_kf = cross_val_score(model(), X, y,
cv=KFold(n_splits=5, shuffle=True, random_state=42),
scoring="r2")
# ② 分组 K 折(按设备)
s_gk = cross_val_score(model(), X, y, groups=groups,
cv=GroupKFold(n_splits=5), scoring="r2")真实输出:
KFold(n_splits=5, shuffle=True, random_state=42)
每折 : [0.9878, 0.9863, 0.9869, 0.9877, 0.9872]
成绩 : 0.98716 ± 0.00055 极差 0.0015
GroupKFold(n_splits=5)
每折 : [0.9736, 0.9577, 0.9639, 0.9576, 0.9385]
成绩 : 0.95825 ± 0.01147 极差 0.0351
★ 分数差 : 0.98716 - 0.95825 = 0.02891
★ 标准差 : 0.00055 vs 0.01147 → 相差 21.0 倍★ 请把这两行并排看三秒钟:
KFold : 0.98716 ± 0.00055 ← 又准,又稳
GroupKFold : 0.95825 ± 0.01147 ← 又低,又"不稳"第一眼你会选前者。这是这一章的全部陷阱。

★ 左图:每折的成绩
红柱(KFold):五根柱子几乎一样高,都在 0.987 附近
蓝柱(GroupKFold):五根柱子起伏明显,最高的 0.9736,最低的 0.9385★ 右图:把"均值 ± 标准差"画成误差棒
KFold 的误差棒小到几乎看不见(±0.00055)
GroupKFold 的误差棒明显(±0.01147)
留一设备的误差棒更大(±0.03672)★ 一张图讲清这一章:
误差棒越短不等于模型越好 —— 它也可能意味着"每一折看到的是同一批东西"。
点开:为什么 KFold 的方差会假性变小?(这是本章的核心机制)
先问一个问题:K 折的"折与折之间的差异"是由什么决定的?
折与折的差异 = 每一折的测试集"有多不一样"
↓
测试集越相似 → 成绩越接近 → 标准差越小
测试集越不同 → 成绩差得越多 → 标准差越大★ 关键观察:KFold(5) 的每一折测试集,构成几乎完全相同。
KFold(5, shuffle=True) 的分法:
把 21,239 行洗牌,切成 5 份
↓
每一份都是"从 60 支设备里各抽约 20% 的行"
↓
第 1 折的测试集 = 每支设备的前 20% 行
第 2 折的测试集 = 每支设备的另 20% 行
...
↓
★ 五份测试集在"设备构成"上是【一模一样】的:每一份都含全部 60 支设备而每一折的训练集呢?
KFold 的每一折训练集,也都含全部 60 支设备的 80% 行
↓
所以每一折都在做同一件事:
"在见过这 60 支设备的前提下,预测它们的其他行"
↓
五折的同质化 → 成绩当然接近 → 标准差被人为压到 0.00055★ 再看 GroupKFold(5):
GroupKFold 的分法:
把 60 支设备分成 5 组,每组 12 支
↓
第 1 折的测试集 = 设备 1~12(训练时完全没见过它们)
第 2 折的测试集 = 设备 13~24
...
↓
★ 五份测试集在"设备构成"上是【完全不同】的所以标准差从哪来?
它来自"不同设备之间的真实难度差异"
↓
第 1 折那 12 支设备好预测 → 0.9736
第 5 折那 12 支设备难预测 → 0.9385
↓
0.0351 的极差不是噪声,它是这份数据【真实的个体差异】★ 一句话总结这个机制:
KFold 的"稳",来自每一折都在同一种泄漏条件下考试。 GroupKFold 的"不稳",来自每一折面对的是真实世界里的随机一批设备。
★ 这条机制有一个非常实用的推论(记住它,它能省很多事):
当你看到 CV 的标准差小于 0.001 时:
不要想"我的模型真稳"
要想"我的折之间是不是沾了亲"
↓
因为真实的"折与折之间"差异,通常至少是千分位
(本次 GroupKFold 的 0.01147 就是这份数据的真实量级)★ 反过来说:
如果你的数据真的【没有分组结构】(每行独立),
那 KFold 的 ±0.0005 就是真实的 —— 这时候小方差是好消息。
↓
所以判据不是"方差小就坏",而是:
"方差小 + 数据有分组结构" = 坏
"方差小 + 数据真的独立" = 好12.2 KFold 的每折为什么几乎一样
上一节讲了机制,这一节把它变成一张可以打印的表。
真实输出:
折 测试行数 测试设备 KFold R² GroupKFold R²
1 4,248 12 0.9878 0.9736
2 4,248 12 0.9863 0.9577
3 4,248 12 0.9869 0.9639
4 4,248 12 0.9877 0.9576
5 4,247 12 0.9872 0.9385
★ KFold 的每一折都是「从所有设备里各抽 20%」,所以每一折都长得一模一样。
★ GroupKFold 的每一折是「12 支没见过的设备」,
遇到状态好的一批就高、遇到差的一批就低 —— 这才是真实的波动。★ 注意这一列的对比:
KFold 的测试集 | GroupKFold 的测试集 | |
|---|---|---|
| 测试行数 | 4,248 / 4,248 / 4,248 / 4,248 / 4,247 | 差不多(表格里按行数对齐) |
| 测试设备数 | 都是"全部 60 支各抽 20%" | 12 支不同的、没见过的设备 |
| R² 的波动 | 0.0015 | 0.0351 |
★ 这张表的读法是"看第 3 列":
"测试设备 12" 这个数字在两列里都有 —— 但它表示的东西完全不同:
KFold 那一列: 虽然只"数出"12 支(因为抽样后有些行落在别的折),
但每一折的测试集都【包含了全部 60 支设备的一部分行】
GroupKFold 列: 每一折的测试集【就是】12 支设备,其余 48 支一行都没有★ 这里有一个非常容易混淆的点,值得说清楚:
# 打印 KFold 某一折测试集里的设备数
tr, te = list(KFold(5, shuffle=True, random_state=42).split(X, y))[0]
print(df.iloc[te]["cell_id"].nunique()) # 60 ← 全部 60 支!真实输出(如果你自己跑一下):
KFold 第 1 折的测试集包含 60 支设备(每支约 70 行)
GroupKFold 第 1 折的测试集包含 12 支设备(每支约 350 行)★ 所以真正的判据是"设备数":
KFold → 测试集里有 60 支设备的【一部分行】
GroupKFold → 测试集里有 12 支设备的【全部行】
↓
行数差不多,设备数差 5 倍
↓
这和第 09 章那个"test_size 切的是设备数不是行数"是同一件事★ 一个可以直接抄的"折体检"函数:
def audit_folds(df, group_col, cv):
for i, (tr, te) in enumerate(cv.split(df, groups=df[group_col]), 1):
tr_g = set(df.iloc[tr][group_col])
te_g = set(df.iloc[te][group_col])
overlap = tr_g & te_g
print(f"第 {i} 折:测试 {len(te):>6,d} 行 / {len(te_g):>2d} 支设备,"
f"跨折设备 {len(overlap):>2d} 支")对 KFold(5) 的输出:
第 1 折:测试 4,248 行 / 60 支设备,跨折设备 60 支
第 2 折:测试 4,248 行 / 60 支设备,跨折设备 60 支
...对 GroupKFold(5) 的输出:
第 1 折:测试 4,248 行 / 12 支设备,跨折设备 0 支
第 2 折:测试 4,255 行 / 12 支设备,跨折设备 0 支
...★ 三列里最值钱的是最后一列:
"跨折设备"必须是 0。它不为 0,你报出的 CV 分数就同时包含了"泄漏量"和"假性稳定"两样东西。
点开:KFold(shuffle=False) 更糟 —— 它制造"系统性差异"而不是"随机差异"
很多人会写 cv=KFold(5)(不洗牌),理由是"可复现"。
但这份表是按 cell_id 排序的(第 01 章的数据体检里能看到), 所以不洗牌的 KFold 会把数据切成"连续的一段段":
第 1 折测试集 = 表的第 1~20% 行 = C001~C012 的全部行 + C013 的一部分
第 2 折测试集 = 第 20%~40% 行 = C013 剩下的 + C014~C024 + C025 的一部分
...★ 这造成两个后果:
后果一:折与折之间的"难度"不同(因为设备的状态不同)
后果二:中间几折的测试集会把【同一支设备劈成两半】实测一下(线性和不洗牌的 KFold):
s = cross_val_score(LinearRegression(), X, y, cv=KFold(5))
# [0.9159, 0.9467, 0.9459, 0.9393, 0.9418] 均值 0.93792 标准差 0.01135★ 读法:
第 1 折 0.9159 明显偏低(比均值低 0.022)
↓
因为它的测试集是最早的那批设备(C001~C012)
这批设备的 SOH 还在 0.99 以上、衰减曲线最陡、模型最难拟合
↓
这不是"随机波动",这是【系统性的折难度差异】★ 所以 KFold(5) 不洗牌的问题不是"泄漏"(前几折反而接近按设备), 而是"折难度的系统性差异"。
三种写法的对照表:
| 写法 | 折间关系 | 主要问题 |
|---|---|---|
KFold(5)(不洗牌) | 前几折接近按设备,中间几折按行 | 折难度系统性差异 |
KFold(5, shuffle=True) | 每折都含全部设备的一部分行 | 泄漏 + 假性稳定 |
GroupKFold(5) | 每折是 12 支新设备 | 无(这才是可信的) |
★ 一个简单的判断规则:
只要你的数据"按实体排序"(也就是每支设备的行是连续的),
写法①和写法②都不能用
↓
唯一可用的就是写法③
↓
而检查方法就是上面那个"跨折设备数":
写法① → 前几折 0 支、中间几折几十支(不稳定)
写法② → 每折都是 60 支(稳定地泄漏)
写法③ → 每折都是 0 支(正确)12.3 更严的口径:留一设备
如果 CV 的折数等于实体数,会怎样?
60 支设备 → 60 折 → 每一折留出一支设备当测试集
↓
sklearn 里叫 LeaveOneGroupOut(留一组法,不是 LeaveOneOut!)代码:
logo = LeaveOneGroupOut()
s_logo = cross_val_score(model(), X, y, groups=groups, cv=logo, scoring="r2")真实输出:
留一设备:60 支设备,每支当一次测试集(共 60 次训练)
成绩 : 0.95802 ± 0.03672
最好 : C049 那一折 0.9922
最差 : C043 那一折 0.8159
极差 : 0.1764
→ 60 折把「不同设备之间的难度差异」完整暴露出来了。
±0.01 的标准差不是缺陷,是这份数据【真实存在】的个体差异。
如果你看到某个模型的 CV 标准差只有 ±0.0005,先别高兴 ——
先问一句:折与折之间,测试集里有没有同一支设备?★ 0.8159 ~ 0.9922 —— 极差 0.1764。
这个数字比之前所有的"虚高/分差"都大一个量级。
它说明什么?
同一份数据里,有些设备(C049)模型能到 0.99
另一些设备(C043)只能到 0.82
↓
如果你上线时"运气不好",第一批设备就是 C043 那样的
↓
你的用户看到的 R² 会是 0.82,而不是报告里的 0.958★ 这是"平均值"最危险的地方:它掩盖了最坏情况。
| 数字 | 值 | 它回答的问题 |
|---|---|---|
| 均值 | 0.95802 | 平均能到多少 |
| 最小值 | 0.8159 | 最坏能到多差 |
| 5% 分位 | 见练习 3 | 5% 的情况下会低于多少 |
★ 一个真实的决策差异:
如果只看均值 0.958:
"模型很好,可以上线"
↓
如果看最小值 0.816:
"模型在部分设备上不可用,需要设计降级策略"
↓
★ 这两个结论的差别,就是"报不报最小值"带来的
图里三组箱线图,从左到右分布越来越宽:
KFold(5) → 挤成一条线(假性稳定)
GroupKFold(5) → 明显散开(真实的折间差异)
留一设备(60 折) → 散得最开,还有几个远离箱体的异常点(C043 那类设备)★ 记住这个视觉规律:
在同样的数据上,"箱体越宽"的那个评估口径,通常越接近真相。
点开:LeaveOneGroupOut 的开销与陷阱
开销:训练次数 = 实体数。
60 支设备 → 60 次训练
↓
如果模型训练要 30 秒 → 整个 CV 要 30 分钟
↓
如果实体数有 5,000(比如用户)→ 5,000 次训练 → 不可行★ 所以留一设备的适用范围:
| 实体数 | 建议 |
|---|---|
| < 20 | 留一(LeaveOneGroupOut),或 5 折 |
| 20 ~ 100 | 5 折或 10 折(性价比最高) |
| > 1,000 | 必须用 K 折(留一算不动) |
陷阱一:单折测试集太小 → 那个折的成绩噪声很大
留一设备:测试集只有 255~464 行
↓
单折成绩的"抽样噪声"很大
↓
所以 60 折成绩的【标准差里包含了这个噪声】
↓
0.03672 里有多少是"设备之间真实差异"、多少是"测试集太小的噪声"?
很难分清楚★ 这就是"折数不是越多越好"的另一面:
折数 ↑ → 每折训练集更接近全量(均值更准)
→ 每折测试集更小(单折成绩更噪)
↓
两个效应方向相反
↓
实践上 5~10 折是甜点区陷阱二:60 折的成绩之间有强相关性
每一折的训练集几乎一样(都重叠 59/60)
↓
所以 60 个成绩不是 60 个独立样本
↓
用它们算标准差时,要意识到这个数【偏小】(低估了真实的波动)★ 一个务实的替代方案:重复分组 K 折
from sklearn.model_selection import RepeatedGroupKFold
cv = RepeatedGroupKFold(n_splits=5, n_repeats=3) # 相当于 15 折,重叠可控
s = cross_val_score(model(), X, y, groups=groups, cv=cv, scoring="r2")它把"5 折"这个流程重复 3 次(每次的分组分配不同),得到 15 个成绩。
优点:兼顾"每折测试集够大"和"重复多次"
缺点:报告变复杂(要说清 5×3),且训练次数 = 15★ 本书的做法:
主线用 GroupKFold(5)(简单、够用)
留一设备作为"压力测试"(看清最坏情况)
↓
两个数字一起报:
0.95825 ± 0.01147(5 折,每折 12 支设备)
0.95802,最差 0.8159(留一设备,60 折)
↓
第一行说"平均水平",第二行说"风险边界"★ 这两行一起,才是一份完整的评估。
12.4 折数怎么选
这个问题没有唯一答案,但有可以量化的依据。
真实输出(同一个模型、同一份数据,只换折数与折法):
方案 均值 标准差 极差 训练行数
GroupKFold(3) 0.95869 0.00951 0.0216 14,159
GroupKFold(5) 0.95825 0.01147 0.0351 16,991
GroupKFold(10) 0.96069 0.00927 0.0329 19,115
LeaveOneGroupOut(60) 0.95802 0.03672 0.1764 20,885★ 这张表有三条信息:
信息一:折数 ↑ → 训练行数 ↑ → 均值略微变好
3 折 → 训练 14,159 行 → 0.95869
5 折 → 训练 16,991 行 → 0.95825
10 折 → 训练 19,115 行 → 0.96069
↓
从 3 折到 10 折,均值只涨了 0.002 —— 收益很小信息二:折数 ↑ → 单折测试集 ↓ → 极差变宽(不是单调)
3 折 → 极差 0.0216
5 折 → 极差 0.0351
10 折 → 极差 0.0329
60 折 → 极差 0.1764 ← 明显变宽信息三:留一设备的标准差(0.03672)远大于其他
它不是"模型更不稳",而是"每一折只考一支设备"
↓
单支设备的难度差异被完整暴露
↓
0.03672 这个数字告诉你:这份数据的个体差异【确实有这么大】★ 关于"极差"的那条要注意:它不单调。
3 折(极差 0.0216)< 5 折(0.0351)> 10 折(0.0329)
↓
因为"每一折抽到哪 12/20 支设备"是随机的
↓
到 60 折才出现统计上的稳定趋势(极差变大)★ 原文(demo 里的三条经验)已经说清了,这里再用一张表收束:
| 折数 | 训练行数 | 优点 | 缺点 | 适合 |
|---|---|---|---|---|
| 3 | 14,159 | 快 | 每次只见 2/3 数据,均值偏保守 | 快速迭代、特征筛选 |
| 5 | 16,991 | 性价比最高 | 训练 5 次 | 本书主线、日常报告 |
| 10 | 19,115 | 最接近"最终模型" | 训练 10 次 | 最终成绩、小数据 |
| 留一实体 | 20,885 | 看清最坏情况 | 训练 N 次,单折噪声大 | 实体少(<20)、风险分析 |
★ 一个硬性约束:折数不能超过实体数。
# 60 支设备,5 折 → 每折 12 支 ✅
# 60 支设备,60 折 → 每折 1 支 ✅(= LeaveOneGroupOut)
# 60 支设备,100 折 → ❌ GroupKFold 会报错或产生空折★ 还有一条更隐蔽的约束:每折至少要有"几支"设备,成绩才有意义。
每折 1 支设备 → 这个 R² 是"那一支的成绩"
每折 12 支设备 → 这个 R² 开始有"设备间平均"的含义
↓
经验:每折 ≥ 5 支实体,成绩才有概括性
↓
所以 60 支设备 → 折数 ≤ 12★ 综合起来,这份数据的推荐配置是:
cv = GroupKFold(n_splits=5) # 主线:每折 12 支设备,快且稳
# 辅以一次 LeaveOneGroupOut 看风险边界点开:如果实体数很少(比如只有 8 支设备)怎么办?
这时候 5 折意味着每折只有 1.6 支设备 —— 太少。
三种做法,按推荐顺序:
① 留一实体(8 折)
cv = LeaveOneGroupOut() # 8 次训练,每次留出 1 支优点:训练集用到 7/8 的数据,最接近"最终模型"
缺点:单折测试集只有一支设备的几百行,噪声大
↓
所以:报【均值】和【最小值】,不要报单折成绩② 少折 + 明确写出限制
cv = GroupKFold(n_splits=4) # 每折 2 支设备
print("⚠️ 只有 8 支设备,每折仅 2 支,结论的置信区间很大")★ 关键不是"选哪个参数",而是"把限制写进结论"。
❌ "模型 R² = 0.94"
✅ "在 8 支设备上做留一评估,R² = 0.94 ± 0.05(最差一支 0.87)。
样本量很小,这个结论的不确定性主要来自『设备数太少』。"③ 如果可能,先收集更多实体
8 支设备的 CV 结论,不足以支撑"上线"这个决定
↓
最省钱的做法是:先多跑几批实验、把设备数补到 30+
↓
而不是在 8 支设备上反复调参(那只会让你过拟合这 8 支)★ 这一条是第 11 章"对验证集过拟合"的极端情形:
当实体数很少时,你的"验证集"其实只有几种可能
↓
反复调参 = 在这几种可能上做搜索
↓
过拟合速度极快
↓
★ 所以小实体数场景下,最该做的事是【收集数据】,不是【调参】★ 一个可以用的判断规则:
实体数 < 10 → 只能做"粗略的 sanity check",不能做模型选型
实体数 10~30 → 可以做模型选型,但要在报告里写清不确定性
实体数 > 30 → 可以做常规的 CV 报告★ 本书这份数据有 60 支设备 —— 刚好够做正经的 CV 报告。
12.5 换个模型再验一遍
一个问题必须回答:这个现象是"随机森林的专利"吗?
真实输出:
模型 KFold(5) GroupKFold(5) 分数差 标准差之比
线性回归(+标准化) 0.95819 ± 0.00116 0.95364 ± 0.00659 +0.00456 5.7x
随机森林(200棵) 0.98716 ± 0.00055 0.95825 ± 0.01147 +0.02891 21.0x★ 两个模型都出现了完全相同的模式:
① KFold 的分数更高(虚高)
② KFold 的标准差更小(假性稳定)
↓
说明这不是模型的问题,是【切法】的问题★ 但两个模型的"虚高量"差很多(0.00456 vs 0.02891)—— 这是第 10 章那条规律的复现:
线性模型:参数少、记不住 60 个偏置 → 虚高小(0.00456)
随机森林:200 棵树、记忆能力强 → 虚高大(0.02891)
↓
★ 和第 10 章"模型越灵活、虚高越多"完全一致★ 标准差之比也值得看:
线性:5.7 倍
森林:21.0 倍
↓
为什么森林的比例更极端?
↓
因为线性模型在 KFold 下的标准差本身就不小(0.00116)
它"没能力"把每一折都做成一模一样
↓
森林有足够容量,能让每一折都恰好落在泄漏的舒适区
↓
所以它的假性稳定【更彻底】★ 一个重要的操作结论:
越是强模型,越要用分组 CV 复核。 因为强模型的"假性稳定"最彻底,最容易骗过你的直觉。
点开:怎么把"分数差 vs 标准差比"变成一个自动判断
两个指标(分数差、标准差之比)可以组合成一个判断:
def cv_leak_check(s_row, s_grp, name="模型"):
gap = s_row.mean() - s_grp.mean()
ratio = s_grp.std() / s_row.std()
print(f"[{name}]")
print(f" KFold : {s_row.mean():.5f} ± {s_row.std():.5f}")
print(f" GroupKFold : {s_grp.mean():.5f} ± {s_grp.std():.5f}")
print(f" 分数差 {gap:+.5f} 标准差之比 {ratio:.1f}x")
if gap > 0.02 or ratio > 3:
print(" → ⚠️ 强烈怀疑泄漏:KFold 的结果不可用")
elif gap > 0.005 or ratio > 1.5:
print(" → ⚠️ 存在可疑差距:以 GroupKFold 为准,并在报告里说明")
else:
print(" → ✅ 两种口径一致,数据的分组结构可能很弱")
return gap, ratio用本次数据跑一下:
[线性回归(+标准化)]
KFold : 0.95819 ± 0.00116
GroupKFold : 0.95364 ± 0.00659
分数差 +0.00456 标准差之比 5.7x
→ ⚠️ 存在可疑差距:以 GroupKFold 为准,并在报告里说明
[随机森林(200棵)]
KFold : 0.98716 ± 0.00055
GroupKFold : 0.95825 ± 0.01147
分数差 +0.02891 标准差之比 21.0x
→ ⚠️ 强烈怀疑泄漏:KFold 的结果不可用★ 注意"标准差之比"这个指标的价值:
它比"分数差"更灵敏
↓
因为即使两个口径的均值碰巧相同,
泄漏仍然会让 KFold 的标准差假性变小
↓
★ 所以:分组结构越强,"标准差之比"越大★ 一个只有 3 行的极简版本(直接贴在评估脚本末尾):
s_row = cross_val_score(m, X, y, cv=KFold(5, shuffle=True, random_state=42), scoring="r2")
s_grp = cross_val_score(m, X, y, groups=g, cv=GroupKFold(5), scoring="r2")
print(f"泄漏体检:分数差 {s_row.mean() - s_grp.mean():+.5f},"
f"标准差比 {s_grp.std() / s_row.std():.1f}x")★ 这两行数字,会替你记着"要按分组切"这件事。
★ 什么情况下"标准差不该有差别"?
如果数据真的没有分组结构(每行独立):
KFold 和 GroupKFold 的分数和标准差都应该差不多
↓
这时候你甚至可以只跑 KFold(更快)
↓
但要注意:GroupKFold 需要"每组至少若干样本",
如果你的分组列每一行的值都不同(比如每行一个订单号),
GroupKFold 会退化成 LeaveOneOut(不可行)
↓
★ 所以判断顺序是:
① 先问"有没有分组结构"(第 03 章的三行代码)
② 有 → GroupKFold
③ 没有 → KFold 即可12.6 检查清单
每一次跑 CV,都过一遍这五条。
五条检查(可逐条确认)
已勾选 0 / 5 项
真实输出(检查第 ③ 条的代码结果):
检查项 KFold(5) GroupKFold(5)
同一设备跨折的折数 5/5 0/5
单折最多跨折设备数 60 0★ 第 ③ 条是这一章的核心,它一行代码就能算出来:
def cross_fold_overlap(df, group_col, cv):
"""返回 (有跨折的折数, 单折最多跨折的实体数)"""
g = df[group_col].to_numpy()
bad, worst = 0, 0
for tr, te in cv.split(df, groups=g):
overlap = set(g[tr]) & set(g[te])
if overlap:
bad += 1
worst = max(worst, len(overlap))
return bad, worst
bad, worst = cross_fold_overlap(df, "cell_id", KFold(5, shuffle=True, random_state=42))
print(f"KFold : {bad}/5 折有跨折设备,单折最多 {worst} 支")
bad, worst = cross_fold_overlap(df, "cell_id", GroupKFold(5))
print(f"GroupKFold : {bad}/5 折有跨折设备,单折最多 {worst} 支")★ 这段代码值得放进你的 tests/ 目录。
点开:五条检查背后的"一条原则"
五条检查看起来很杂,其实都在问同一件事:
"折与折之间,测试集里有没有训练集已经见过的东西?"
逐条对照:
| 检查 | 它在防什么 |
|---|---|
| ① 先问分组 | 不知道有分组结构 → 后面全错 |
| ② 传 groups | 知道了但没传给 splitter → 静默泄漏 |
| ③ 查跨折实体 | 传了但传错了(比如传了行号)→ 直接抓出来 |
| ④ 看 ± 的大小 | 泄漏的"指纹"(假性稳定) |
| ⑤ 留出集对账 | 防止 CV 和留出口径不一致 |
★ 所以最精简的版本只需要两条:
# ① 跨折实体必须是 0
assert cross_fold_overlap(df, "cell_id", cv)[0] == 0, "CV 泄漏"
# ② 标准差不能小得离谱
assert s.std() > 0.001, f"标准差 {s.std():.5f} 过小,疑似折间泄漏"★ 第二条断言有一个"坑"要注意:
0.001 这个阈值是针对"这份数据的规模"(21,239 行、60 支设备)的经验值
↓
如果你的数据很小(500 行),真实的标准差本来就可能小于 0.001
↓
所以:先用 GroupKFold 跑一遍【拿到你自己数据的真实量级】,
然后把这个量级写进断言
↓
★ 一个更好的写法:
阈值 = 0.3 × GroupKFold 的标准差
如果 KFold 的标准差 < 阈值 → 报警用本次数据验算:
assert s_row.std() > 0.3 * s_gk.std()
# 0.00055 > 0.3 × 0.01147 = 0.00344 → False → 报警 ✅★ 这是一个"自适应阈值",比写死 0.001 靠谱得多。
★ 最后一条经验(关于"什么时候可以不这么讲究"):
如果你的数据真的每行独立(比如"每套房一行"的房价数据):
① KFold 就够,不用 GroupKFold
② 标准差小是好消息
③ 这份检查清单可以跳过
↓
所以清单的第一条("先问分组")永远是第一步 ——
它决定了后面四条要不要执行。★ 判断"每行是否独立"的最小代码(和第 03 章那段一样):
print(f"行数 : {len(df):,d}")
print(f"实体数 : {df[KEY].nunique():,d}")
print(f"每实体 : {len(df) / df[KEY].nunique():.1f} 行")每实体 ≈ 1.0 → 行独立 → KFold 可用
每实体 ≫ 1 → 有分组 → 必须 GroupKFold12.7 常见坑
① cv=5(等价于 KFold,按行) → 本次实测虚高 0.02891 + 标准差假性小 21 倍
② KFold(5) 不洗牌 → 折难度系统性差异(第 1 折偏低 0.022)
③ 看到 ±0.00055 就认为"模型很稳" → 方差小是泄漏的指纹,不是优点的证据
④ 忘传 groups → GroupKFold 会报错(好事);KFold 不报错(坏事)
⑤ 只报折数不报"每折几支设备" → 60 支设备 5 折 ≠ 6000 支设备 5 折
⑥ 折数超过实体数 → 报错,或产生退化折
⑦ 只报均值不报最小值 → 留一设备实测:最差 0.8159,均值 0.95802★ 第 ⑦ 条特别值得展开:
均值 0.95802 告诉你"平均能到多少"
最小值 0.8159 告诉你"最坏能到多差"
↓
上线决策要用后者
↓
★ 因为上线后你无法选择"遇到哪一批设备"★ 七个坑的共同点和第 10、11 章完全一样:
它们都不会报错
↓
而且大多数让分数看起来【更好】(更高、更稳)
↓
★ 所以这一章的核心训练是:
看到"又高又稳"四个字,先怀疑,再高兴12.8 完整示例与输出
cd code
MPLCONFIGDIR=/tmp/ex03-work/.mplconfig python demos/ch12-group-cross-validation.py真实输出(节选):
==============================================================================
① 同一个模型、同一个 K,只换「怎么切」
==============================================================================
KFold(n_splits=5, shuffle=True, random_state=42)
成绩 : 0.98716 ± 0.00055 极差 0.0015
GroupKFold(n_splits=5)
成绩 : 0.95825 ± 0.01147 极差 0.0351
★ 分数差 : 0.98716 - 0.95825 = 0.02891
★ 标准差 : 0.00055 vs 0.01147 → 相差 21.0 倍
★★ 这一章最重要的一句话:
KFold 不只是分数高,它的标准差还【小了 21 倍】。
==============================================================================
③ 更严的口径:留一设备(LeaveOneGroupOut)
==============================================================================
留一设备:60 支设备,每支当一次测试集(共 60 次训练)
成绩 : 0.95802 ± 0.03672
最好 : C049 那一折 0.9922 最差 : C043 那一折 0.8159
极差 : 0.1764
分位 : 5% 0.8934 中位 0.9722 95% 0.9888
==============================================================================
④ 折数怎么选
==============================================================================
GroupKFold(3) 0.95869 0.00951 0.0216 14,159
GroupKFold(5) 0.95825 0.01147 0.0351 16,991
GroupKFold(10) 0.96069 0.00927 0.0329 19,115
LeaveOneGroupOut(60) 0.95802 0.03672 0.1764 20,885
==============================================================================
⑤ 换个模型再验一遍
==============================================================================
线性回归(+标准化) 0.95819 ± 0.00116 0.95364 ± 0.00659 +0.00456 5.7x
随机森林(200棵) 0.98716 ± 0.00055 0.95825 ± 0.01147 +0.02891 21.0x
==============================================================================
⑥ 检查清单:把每一次 CV 都过一遍这五条
==============================================================================
同一设备跨折的折数 5/5 0/5
单折最多跨折设备数 60 0本章练习
练习 1:亲手复制"分数更高 + 方差更小"这个双重欺骗
用随机森林(n_estimators=200, random_state=42):
- 用
KFold(n_splits=5, shuffle=True, random_state=42)跑cross_val_score, 打印每折成绩、均值、标准差; - 用
GroupKFold(n_splits=5)(记得传groups)再跑一遍,打印同样三个东西; - 打印两次的分数差和标准差之比,并解释哪一个是"双重欺骗"。
参考答案
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import GroupKFold, KFold, cross_val_score
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
df = pd.read_csv("data/battery/soh_features.csv")
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
model = lambda: RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1)
s_kf = cross_val_score(model(), X, y,
cv=KFold(n_splits=5, shuffle=True, random_state=42), scoring="r2")
s_gk = cross_val_score(model(), X, y, groups=groups,
cv=GroupKFold(n_splits=5), scoring="r2")
print(f"KFold : {np.round(s_kf, 4).tolist()}")
print(f" {s_kf.mean():.5f} ± {s_kf.std():.5f}")
print(f"GroupKFold : {np.round(s_gk, 4).tolist()}")
print(f" {s_gk.mean():.5f} ± {s_gk.std():.5f}")
print(f"\n分数差 : {s_kf.mean() - s_gk.mean():+.5f}")
print(f"标准差之比 : {s_gk.std() / s_kf.std():.1f}x")期望输出:
KFold : [0.9878, 0.9863, 0.9869, 0.9877, 0.9872]
0.98716 ± 0.00055
GroupKFold : [0.9736, 0.9577, 0.9639, 0.9576, 0.9385]
0.95825 ± 0.01147
分数差 : +0.02891
标准差之比 : 21.0x★ ③ 解释:哪一个是"双重欺骗"
KFold 是双重欺骗,它同时给出了两个错误的正面信号:
欺骗一(分数):0.98716 vs 0.95825 → 高了 0.02891
↓
"我的模型能解释 98.7% 的方差"
欺骗二(稳定性):±0.00055 vs ±0.01147 → 小了 21 倍
↓
"而且我的模型非常稳定,换个数据切法几乎不变"★ 为什么这两个信号是同一件事的两面?
KFold 的每一折测试集 = "从全部 60 支设备里各抽 20% 的行"
↓
① 每一折都含全部设备的邻居 → 每一折都能"回忆" → 分数高
② 每一折的设备构成完全相同 → 每一折的难度完全相同 → 分数接近
↓
★ 高分数和低方差【有同一个原因】:每一折都在同一种泄漏条件下考试★ 用一个类比说清:
KFold = 让同一个班的学生,每次考试都做同一套题的不同题号
→ 分数都高(他们见过这套题的风格)
→ 且分数都很接近(同一套题,难度一样)
GroupKFold = 每次换一个新班级来考
→ 分数更低(没见过这个班的水平)
→ 且分数有起伏(不同班级水平不同)"双重欺骗"的关键在于:第二个信号让第一个信号更难被怀疑。
如果 KFold 只是"分数高",你可能会想"是不是有点问题"
但它还"很稳" → 你会觉得"这个结论很扎实"
↓
★ 一个错误的结论 + 一个错误的"高置信度" = 最危险的组合★ 顺手验一下"测试集里的设备数":
folds = list(KFold(5, shuffle=True, random_state=42).split(X, y))
for i, (tr, te) in enumerate(folds, 1):
print(f"KFold 第 {i} 折:测试 {len(te):>6,d} 行 / "
f"{groups.iloc[te].nunique():>2d} 支设备 / "
f"跨折设备 {len(set(groups.iloc[tr]) & set(groups.iloc[te])):>2d} 支")
folds = list(GroupKFold(5).split(X, y, groups=groups))
for i, (tr, te) in enumerate(folds, 1):
print(f"GroupKFold 第 {i} 折:测试 {len(te):>6,d} 行 / "
f"{groups.iloc[te].nunique():>2d} 支设备 / "
f"跨折设备 {len(set(groups.iloc[tr]) & set(groups.iloc[te])):>2d} 支")期望输出:
KFold 第 1 折:测试 4,248 行 / 60 支设备 / 跨折设备 60 支
KFold 第 2 折:测试 4,248 行 / 60 支设备 / 跨折设备 60 支
...
GroupKFold 第 1 折:测试 4,248 行 / 12 支设备 / 跨折设备 0 支
GroupKFold 第 2 折:测试 4,255 行 / 12 支设备 / 跨折设备 0 支
...★ 三列里,"测试行数"几乎一样,"设备数"和"跨折设备"完全不同。
这就回到了第 09 章那句话:
行数看不出来,设备数才是分水岭。
练习 2:写一个"折体检"函数,并判断一个 CV 是否泄漏
写函数 audit_folds(df, group_col, cv),对每个折输出: 测试行数、测试设备数、跨折设备数。然后:
- 对
KFold(5, shuffle=True, random_state=42)调用它; - 对
GroupKFold(5)调用它; - 对
KFold(5)(不洗牌)调用它,观察中间几折有什么特别。
参考答案
import pandas as pd
from sklearn.model_selection import GroupKFold, KFold
df = pd.read_csv("data/battery/soh_features.csv")
def audit_folds(df, group_col, cv, name=""):
print(f"--- {name or cv.__class__.__name__} ---")
worst = 0
bad = 0
for i, (tr, te) in enumerate(cv.split(df, groups=df[group_col]), 1):
tr_g = set(df.iloc[tr][group_col])
te_g = set(df.iloc[te][group_col])
overlap = tr_g & te_g
worst = max(worst, len(overlap))
bad += 1 if overlap else 0
print(f" 第 {i} 折:测试 {len(te):>6,d} 行 / {len(te_g):>2d} 支设备 / "
f"跨折设备 {len(overlap):>2d} 支"
+ (f"(例如 {sorted(overlap)[:3]})" if overlap else ""))
print(f" → 有跨折的折数 {bad}/{cv.get_n_splits()},单折最多跨折 {worst} 支\n")
return bad, worst
audit_folds(df, "cell_id", KFold(5, shuffle=True, random_state=42), "KFold(shuffle=True)")
audit_folds(df, "cell_id", GroupKFold(5), "GroupKFold(5)")
audit_folds(df, "cell_id", KFold(5), "KFold(不洗牌)")期望输出(节选):
--- KFold(shuffle=True) ---
第 1 折:测试 4,248 行 / 60 支设备 / 跨折设备 60 支(例如 ['C001', 'C002', 'C003'])
第 2 折:测试 4,248 行 / 60 支设备 / 跨折设备 60 支(例如 ['C001', 'C002', 'C003'])
第 3 折:测试 4,248 行 / 60 支设备 / 跨折设备 60 支(例如 ['C001', 'C002', 'C003'])
第 4 折:测试 4,248 行 / 60 支设备 / 跨折设备 60 支(例如 ['C001', 'C002', 'C003'])
第 5 折:测试 4,247 行 / 60 支设备 / 跨折设备 60 支(例如 ['C001', 'C002', 'C003'])
→ 有跨折的折数 5/5,单折最多跨折 60 支
--- GroupKFold(5) ---
第 1 折:测试 4,248 行 / 12 支设备 / 跨折设备 0 支
第 2 折:测试 4,255 行 / 12 支设备 / 跨折设备 0 支
第 3 折:测试 4,252 行 / 12 支设备 / 跨折设备 0 支
第 4 折:测试 4,242 行 / 12 支设备 / 跨折设备 0 支
第 5 折:测试 4,242 行 / 12 支设备 / 跨折设备 0 支
→ 有跨折的折数 0/5,单折最多跨折 0 支
--- KFold(不洗牌) ---
第 1 折:测试 4,248 行 / 12 支设备 / 跨折设备 1 支(例如 ['C012'])
第 2 折:测试 4,248 行 / 14 支设备 / 跨折设备 2 支(例如 ['C012', 'C025'])
第 3 折:测试 4,248 行 / 13 支设备 / 跨折设备 2 支(例如 ['C025', 'C037'])
第 4 折:测试 4,248 行 / 13 支设备 / 跨折设备 2 支(例如 ['C037', 'C049'])
第 5 折:测试 4,247 行 / 12 支设备 / 跨折设备 1 支(例如 ['C049'])
→ 有跨折的折数 5/5,单折最多跨折 2 支★ ③ 观察中间几折有什么特别
这是本章最值得玩味的一张输出。
第 1 折:1 支跨折(C012) ← 起点在 C012 的中间
第 2 折:2 支跨折(C012、C025)← 两端各劈开一支设备
第 3 折:2 支跨折(C025、C037)
第 4 折:2 支跨折(C037、C049)
第 5 折:1 支跨折(C049) ← 终点在 C049 的中间为什么会这样?
表格是按 cell_id 排序的:
C001 的 235 行 → C002 的 415 行 → … → C060 的 464 行
↓
KFold(5) 不洗牌 = 按【行的顺序】切成 5 份
↓
每一份的边界都落在【某支设备的中间】
↓
于是这支设备被劈成两半:一半在训练集、一半在测试集
↓
★ 注意"跨折设备数"只有 1~2 支,
而不是 12 支 —— 因为它切的是"行数均分",
每一折大约覆盖 12~14 支设备,边界处才有 1~2 支被劈开★ 但要注意:这 1~2 支被劈开的设备,比"每折 60 支"更值得注意吗?
从"泄漏的设备数"看:1~2 支 ≪ 60 支 → 看起来好得多
↓
从"泄漏的性质"看:更糟 ——
被劈开的那支设备,它的相邻行【确实】跨了训练/测试两边
(比如 C012 的第 150 行在训练集,第 151 行在测试集)
↓
这正好是第 09 章说的"跨集相邻对"!
↓
★ 所以 KFold(不洗牌) 的泄漏不是"面"上的(每支设备都有一部分行被见过),
而是"点"上的(少数设备被精确地劈在相邻行之间)★ 真正的麻烦在于"折难度的系统性差异":
第 1 折 = C001~C012 → 这些设备的 SOH 还在 0.95 以上
第 5 折 = C049~C060 → 这些设备已经衰减到 0.85 附近
↓
两折的"预测难度"完全不同
↓
实测:线性和不洗牌的 KFold 成绩是 [0.9159, 0.9467, 0.9459, 0.9393, 0.9418]
第 1 折明显偏低 0.022★ 更关键的问题是"折难度的系统性差异":
第 1 折 = C001~C013 → 这些设备的 SOH 还在 0.95 以上
第 5 折 = C049~C060 → 这些设备已经衰减到 0.85 附近
↓
两折的"预测难度"完全不同
↓
实测:KFold(5) 不洗牌的线性模型成绩是 [0.9159, 0.9467, 0.9459, 0.9393, 0.9418]
第 1 折明显偏低 0.022★ 所以三种写法的问题可以总结成一张表:
| 写法 | 跨折设备 | 主要问题 | 能不能用 |
|---|---|---|---|
KFold(5, shuffle=True) | 5/5 折,每折 60 支 | 泄漏 + 假性稳定 | ❌ |
KFold(5) 不洗牌 | 5/5 折,最多 2 支 | 精确劈开设备 + 折难度系统差异 | ❌ |
GroupKFold(5) | 0/5 折 | 无 | ✅ |
★ 一个可以写进 CI 的断言
def assert_folds_clean(df, group_col, cv):
for i, (tr, te) in enumerate(cv.split(df, groups=df[group_col]), 1):
overlap = set(df.iloc[tr][group_col]) & set(df.iloc[te][group_col])
assert not overlap, f"第 {i} 折泄漏:{len(overlap)} 支设备跨折,例如 {sorted(overlap)[:3]}"
print(f"✅ {cv.__class__.__name__}: 所有折都干净")★ 这个断言的输出,就是你评估报告的第一行证据。
练习 3:报一份"带风险边界"的 CV 结论
用 LeaveOneGroupOut() 跑 60 折,然后输出下面四项,并写一段给非技术读者的结论:
- 均值 ± 标准差;
- 最小值(最差的那支设备)与它对应的设备编号;
- 5% 分位与 95% 分位;
- 一段结论,包含"平均预期"和"风险边界"两个数字。
参考答案
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import LeaveOneGroupOut, cross_val_score
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
df = pd.read_csv("data/battery/soh_features.csv")
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
logo = LeaveOneGroupOut()
s = cross_val_score(RandomForestRegressor(n_estimators=200, random_state=42, n_jobs=-1),
X, y, groups=groups, cv=logo, scoring="r2")
# ① 均值 ± 标准差
print(f"① 成绩 : {s.mean():.5f} ± {s.std():.5f} ({len(s)} 折,每折留出一支设备)")
# ② 最差的那支
cids = sorted(groups.unique())
worst, best = int(np.argmin(s)), int(np.argmax(s))
print(f"② 最好 : {cids[best]} {s[best]:.4f}")
print(f" 最差 : {cids[worst]} {s[worst]:.4f}")
# ③ 分位数
p5, p95 = np.percentile(s, 5), np.percentile(s, 95)
print(f"③ 5% 分位 {p5:.4f} 中位 {np.median(s):.4f} 95% 分位 {p95:.4f}")
# ④ 结论
print(f"""
④ 结论:
模型对『没见过的设备』平均能解释 {100 * s.mean():.1f}% 的方差
({s.mean():.3f} ± {s.std():.3f},60 折留一设备)。
但个体差异很大:最好的设备 {s[best]:.3f},最差的设备 {s[worst]:.3f}
({100 * s[worst]:.1f}%)。
建议按 {s[worst]:.2f} 做容量与阈值规划,按 {s.mean():.2f} 做效果预期。
""")期望输出:
① 成绩 : 0.95802 ± 0.03672 (60 折,每折留出一支设备)
② 最好 : C049 0.9922
最差 : C043 0.8159
③ 5% 分位 0.8934 中位 0.9722 95% 分位 0.9888
④ 结论:
模型对『没见过的设备』平均能解释 95.8% 的方差
(0.958 ± 0.037,60 折留一设备)。
但个体差异很大:最好的设备 0.992,最差的设备 0.816
(81.6%)。
建议按 0.82 做容量与阈值规划,按 0.96 做效果预期。★ 这段结论和"R² = 0.95802 ± 0.03672"的差别在哪
只报均值 ± 标准差:
读者会想"平均 0.958,还行"
↓
他们不知道"最坏情况只有 0.816"报均值 + 最差 + 分位数:
读者会想"平均 0.958,但有一支设备只有 0.816
→ 系统要能容忍 0.82"
↓
这才是一份【可以被用来做决定】的结论★ 关于 5% 分位(0.8934)的意义
5% 分位 = 0.8934
↓
意思是:随机抽一支设备,有 5% 的概率成绩低于 0.893
↓
如果你的业务"每 20 支设备里有 1 支不准就是事故"
→ 你就要按 0.893 设计,而不是按 0.958★ 为什么用 5% 分位而不是最小值?
最小值来自【单一样本】(C043 那一支)
↓
它可能是一个"特别极端"的设备,不代表一般风险
↓
分位数(5%)更稳,代表性更好
↓
★ 但两者都要报:最小值告诉你"最坏能多坏",
分位数告诉你"通常有多坏"★ 一个容易被忽略的对照:这里的最差 0.8159 vs 第 09 章的批次 R² 0.94377
留一设备最差 0.8159 → 单支设备的极端情况
按批次留出 0.94377 → 整批设备(15 支)一起测试
↓
为什么"整批"比"单支"好这么多?
↓
因为 15 支设备的平均,把个体差异抵消掉了
↓
★ 这解释了"为什么小批量评估远比单支评估乐观":
批量越大,个体差异越被平均掉★ 一条可以直接用在方案里的结论:
如果你的上线方式是"逐支决策",就要看单支的分布(最小值/分位数); 如果你的上线方式是"批量排序",看批量指标就够了。 这两种场景对同一个模型的要求,可能差 0.1 个 R²。
★ 这也是第 26 章"模型选型"会再次用到的判断依据。
面向工程实践:把"分组"变成默认动作
① 一个团队里最容易出的事:有人写了 cv=5
新人第一天:
cross_val_score(model, X, y, cv=5) ← 看起来很标准
↓
得到 0.98716 ± 0.00055
↓
写进周报,被表扬
↓
三个月后上线,效果只有 0.96 的七成★ 这不是"新人不认真",这是"默认值是陷阱"。
② 防护措施:把"正确的写法"变成"唯一的写法"
# src/evaluate.py
"""本项目唯一的评估入口。不要绕过它直接用 cross_val_score。"""
from sklearn.model_selection import GroupKFold, cross_val_score
GROUP_COL = "cell_id"
N_SPLITS = 5
SEED = 42
MIN_STD = 0.001 # 低于这个标准差就报警(见 12.6)
def cv_r2(model, df, feats, target="soh", group_col=GROUP_COL, n_splits=N_SPLITS):
X, y, g = df[feats], df[target], df[group_col]
# 防护一:groups 一定要传
assert g.notna().all(), f"{group_col} 有缺失值,无法分组"
cv = GroupKFold(n_splits=n_splits)
# 防护二:跨折实体必须是 0
for i, (tr, te) in enumerate(cv.split(X, y, groups=g), 1):
overlap = set(g.iloc[tr]) & set(g.iloc[te])
assert not overlap, f"第 {i} 折泄漏:{sorted(overlap)[:3]}"
scores = cross_val_score(model, X, y, groups=g, cv=cv, scoring="r2")
# 防护三:标准差过小要报警(不是报错 —— 它也可能是真的没问题)
if scores.std() < MIN_STD:
print(f"⚠️ 标准差 {scores.std():.5f} 小于 {MIN_STD},"
f"请确认折间没有同实体(本次每折设备数 "
f"{df.groupby(group_col).ngroups // n_splits})")
return scores★ 这个函数把三件事固化了:
① 一定是 GroupKFold(不可能写成 KFold)
② 一定检查跨折实体
③ 一定提示"标准差异常小"
↓
★ 新人写 cv=5 的机会被从源头消掉了③ 一个"反例文档"的价值
在项目仓库里放一个文件,记录"我们踩过的坑 + 数字":
# docs/evaluation-traps.md
## 坑 1:cv=5 在有分组结构的数据上会虚高
实测(60 支设备、21,239 行、随机森林 200 棵):
| 口径 | 成绩 |
|---|---|
| KFold(5, shuffle=True) | 0.98716 ± 0.00055 |
| GroupKFold(5) | 0.95825 ± 0.01147 |
**症状**:分数高、标准差小。
**判据**:跨折设备数 > 0,或标准差 < 0.3 × GroupKFold 的标准差。
**修法**:`cross_val_score(model, X, y, groups=df["cell_id"], cv=GroupKFold(5))`★ 为什么这个文档值钱:
一个新人的直觉是"分数高就是好"
↓
一份带数字的反例文档,能在 30 秒内纠正这个直觉
↓
比口头讲解有效得多④ 三个层次的"防泄漏",从弱到强
| 层次 | 做法 | 能挡住什么 |
|---|---|---|
| ① 意识层 | 团队规范、反例文档 | 明知道会犯错的人 |
| ② 工具层 | 统一的 cv_r2() 函数 | 忘记传 groups 的人 |
| ③ 断言层 | assert + CI 测试 | 所有人(包括三个月后的你自己) |
★ 只有第 ③ 层是"不需要人记得"的。
# tests/test_cv_no_leak.py
def test_groupkfold_is_clean():
df = load_features()
for i, (tr, te) in enumerate(GroupKFold(5).split(df, groups=df["cell_id"]), 1):
assert not (set(df.iloc[tr]["cell_id"]) & set(df.iloc[te]["cell_id"]))
def test_kfold_would_leak():
"""这是一个【反向测试】:证明用 KFold 会泄漏,防止有人改回去。"""
df = load_features()
folds = KFold(5, shuffle=True, random_state=42).split(df)
leaked = sum(1 for tr, te in folds
if set(df.iloc[tr]["cell_id"]) & set(df.iloc[te]["cell_id"]))
assert leaked > 0, "如果这个断言失败,说明数据的分组结构变了,需要重新评估"★ 第二个测试很特别,它是一个"记录为什么这么做"的测试:
它不是在检查代码正确性
↓
它是在检查"前提假设还成立吗"
↓
如果某天数据变了(分组结构消失了),它会失败并提醒你重新考虑⑤ 最后:这一章和第 10 章的关系
第 10 章:一次留出划分会泄漏 → 用 GroupShuffleSplit 修
第 11 章:一次划分不够稳 → 用 CV 修
第 12 章:CV 本身也会泄漏 → 用 GroupKFold 修
↓
三层修完,你才有一个可信的评估
↓
★ 而这三层的共同点只有一个:
【问一句"测试集里有没有训练集见过的东西"】⑥ 到这里,"评估的可信度"已经修完了。接下来的问题是别的
你已经能回答:
"这个模型有多准?" → 0.95825 ± 0.01147(按设备 5 折)
"最坏情况多差?" → 最差一支设备 0.8159
"这个数字可信吗?" → 跨折设备 0 支、留出集对账通过但你还没回答:
"为什么它到不了 0.999?" → 数据里的两层误差(第 18~21 章)
"哪个特征在起作用?" → 特征重要性(第 20 章)
"还能不能更好?" → 学习曲线、特征工程、正则化(第 21~22 章)
"最终该选哪个模型?" → 四路横评与选型(第 23~27 章)★ 而下面所有这些章节的数字,都建立在第 09~12 章这套评估方式上。
如果评估方式是错的 → 后面每一章的数字都不可信
↓
这就是为什么这四章是全书的重心
↓
它们不是"某个技巧",它们是"后面所有结论的地基"⑦ 一个建议:现在回头重跑一次前几章的实验
如果你是按顺序读到这里的,建议做一件事:
# 把第 04~08 章里所有"整表 R²"的结论,用本章的方式重跑一遍
python demos/ch09-train-test-split.py
python demos/ch10-leakage.py
python demos/ch11-cross-validation.py
python demos/ch12-group-cross-validation.py你会看到:
整表 R²(第 06 章)= 0.94321 ← 这是"拟合训练数据"的程度
按设备留出(第 09 章)= 0.94253 ← 这是"对新设备"的能力(线性模型)
按设备 CV(第 12 章)= 0.95364 ← 这是"线性模型的平均水平"(含标准化)
随机森林 CV(第 12 章)= 0.95825 ← 换模型的真实收益★ 四个数字放在一起,你就能回答"我的模型到底行不行"这个问题的全部版本。
本书评估部分的最后一句话:
在这四章之后,你手里的任何分数都应该带着三个限定词 —— 按什么分组、几折、被评估过几次。 缺一个,那个数字就还不算证据。