Skip to content

第 12 章 · 分组交叉验证:KFold vs GroupKFold ​

这一章要解决什么:上一章的 CV 已经用 GroupKFold 了。 这一章回答"如果我(或同事)顺手写成 cv=5 会怎样" —— 答案是:你会得到一个又高又稳的数字,而它是错的。

最容易踩的坑: ① 用 cv=5 / KFold 在有分组结构的数据上做 CV(不报错); ② 看到 ±0.00055 就认为"模型非常稳定" —— 方差小恰恰是泄漏的特征; ③ 折数选得比实体数还多。

本章你需要完成的代码:demos/ch12-group-cross-validation.py

12.1 同一个模型,只换「怎么切」 ​

这一节的两个数字,是整本书最值得记住的一组对照。

python
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")

真实输出:

text
  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 倍

★ 请把这两行并排看三秒钟:

text
KFold      :  0.98716 ± 0.00055      ← 又准,又稳
GroupKFold :  0.95825 ± 0.01147      ← 又低,又"不稳"

第一眼你会选前者。这是这一章的全部陷阱。

分组交叉验证:普通 KFold 是双重欺骗

★ 左图:每折的成绩

红柱(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 的每折为什么几乎一样 ​

上一节讲了机制,这一节把它变成一张可以打印的表。

真实输出:

text
    折      测试行数      测试设备   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.00150.0351

★ 这张表的读法是"看第 3 列":

"测试设备 12" 这个数字在两列里都有 —— 但它表示的东西完全不同:

KFold 那一列:  虽然只"数出"12 支(因为抽样后有些行落在别的折),
                但每一折的测试集都【包含了全部 60 支设备的一部分行】

GroupKFold 列: 每一折的测试集【就是】12 支设备,其余 48 支一行都没有

★ 这里有一个非常容易混淆的点,值得说清楚:

python
# 打印 KFold 某一折测试集里的设备数
tr, te = list(KFold(5, shuffle=True, random_state=42).split(X, y))[0]
print(df.iloc[te]["cell_id"].nunique())     # 60   ← 全部 60 支!

真实输出(如果你自己跑一下):

text
KFold 第 1 折的测试集包含 60 支设备(每支约 70 行)
GroupKFold 第 1 折的测试集包含 12 支设备(每支约 350 行)

★ 所以真正的判据是"设备数":

KFold      → 测试集里有 60 支设备的【一部分行】
GroupKFold → 测试集里有 12 支设备的【全部行】
     ↓
行数差不多,设备数差 5 倍
     ↓
这和第 09 章那个"test_size 切的是设备数不是行数"是同一件事

★ 一个可以直接抄的"折体检"函数:

python
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) 的输出:

text
第 1 折:测试  4,248 行 / 60 支设备,跨折设备 60 支
第 2 折:测试  4,248 行 / 60 支设备,跨折设备 60 支
...

对 GroupKFold(5) 的输出:

text
第 1 折:测试  4,248 行 / 12 支设备,跨折设备  0 支
第 2 折:测试  4,255 行 / 12 支设备,跨折设备  0 支
...

★ 三列里最值钱的是最后一列:

"跨折设备"必须是 0。它不为 0,你报出的 CV 分数就同时包含了"泄漏量"和"假性稳定"两样东西。

点开:KFold(shuffle=False) 更糟 —— 它制造"系统性差异"而不是"随机差异"

很多人会写 cv=KFold(5)(不洗牌),理由是"可复现"。

但这份表是按 cell_id 排序的(第 01 章的数据体检里能看到), 所以不洗牌的 KFold 会把数据切成"连续的一段段":

text
第 1 折测试集 = 表的第 1~20% 行 = C001~C012 的全部行 + C013 的一部分
第 2 折测试集 = 第 20%~40% 行   = C013 剩下的 + C014~C024 + C025 的一部分
...

★ 这造成两个后果:

后果一:折与折之间的"难度"不同(因为设备的状态不同)
后果二:中间几折的测试集会把【同一支设备劈成两半】

实测一下(线性和不洗牌的 KFold):

python
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!)

代码:

python
logo = LeaveOneGroupOut()
s_logo = cross_val_score(model(), X, y, groups=groups, cv=logo, scoring="r2")

真实输出:

text
  留一设备: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% 分位见练习 35% 的情况下会低于多少

★ 一个真实的决策差异:

如果只看均值 0.958:
    "模型很好,可以上线"
     ↓
如果看最小值 0.816:
    "模型在部分设备上不可用,需要设计降级策略"
     ↓
★ 这两个结论的差别,就是"报不报最小值"带来的

「稳定」有两种:一种是真好,一种是折叠之间沾了亲

图里三组箱线图,从左到右分布越来越宽:

KFold(5)         → 挤成一条线(假性稳定)
GroupKFold(5)    → 明显散开(真实的折间差异)
留一设备(60 折)   → 散得最开,还有几个远离箱体的异常点(C043 那类设备)

★ 记住这个视觉规律:

在同样的数据上,"箱体越宽"的那个评估口径,通常越接近真相。

点开:LeaveOneGroupOut 的开销与陷阱

开销:训练次数 = 实体数。

60 支设备 → 60 次训练
     ↓
如果模型训练要 30 秒 → 整个 CV 要 30 分钟
     ↓
如果实体数有 5,000(比如用户)→ 5,000 次训练 → 不可行

★ 所以留一设备的适用范围:

实体数建议
< 20留一(LeaveOneGroupOut),或 5 折
20 ~ 1005 折或 10 折(性价比最高)
> 1,000必须用 K 折(留一算不动)

陷阱一:单折测试集太小 → 那个折的成绩噪声很大

留一设备:测试集只有 255~464 行
     ↓
单折成绩的"抽样噪声"很大
     ↓
所以 60 折成绩的【标准差里包含了这个噪声】
     ↓
0.03672 里有多少是"设备之间真实差异"、多少是"测试集太小的噪声"?
    很难分清楚

★ 这就是"折数不是越多越好"的另一面:

折数 ↑ → 每折训练集更接近全量(均值更准)
       → 每折测试集更小(单折成绩更噪)
     ↓
两个效应方向相反
     ↓
实践上 5~10 折是甜点区

陷阱二:60 折的成绩之间有强相关性

每一折的训练集几乎一样(都重叠 59/60)
     ↓
所以 60 个成绩不是 60 个独立样本
     ↓
用它们算标准差时,要意识到这个数【偏小】(低估了真实的波动)

★ 一个务实的替代方案:重复分组 K 折

python
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 折数怎么选 ​

这个问题没有唯一答案,但有可以量化的依据。

真实输出(同一个模型、同一份数据,只换折数与折法):

text
  方案                           均值       标准差       极差       训练行数
  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 里的三条经验)已经说清了,这里再用一张表收束:

折数训练行数优点缺点适合
314,159快每次只见 2/3 数据,均值偏保守快速迭代、特征筛选
516,991性价比最高训练 5 次本书主线、日常报告
1019,115最接近"最终模型"训练 10 次最终成绩、小数据
留一实体20,885看清最坏情况训练 N 次,单折噪声大实体少(<20)、风险分析

★ 一个硬性约束:折数不能超过实体数。

python
# 60 支设备,5 折 → 每折 12 支  ✅
# 60 支设备,60 折 → 每折 1 支 ✅(= LeaveOneGroupOut)
# 60 支设备,100 折 → ❌ GroupKFold 会报错或产生空折

★ 还有一条更隐蔽的约束:每折至少要有"几支"设备,成绩才有意义。

每折 1 支设备  →  这个 R² 是"那一支的成绩"
每折 12 支设备 →  这个 R² 开始有"设备间平均"的含义
     ↓
经验:每折 ≥ 5 支实体,成绩才有概括性
     ↓
所以 60 支设备 → 折数 ≤ 12

★ 综合起来,这份数据的推荐配置是:

python
cv = GroupKFold(n_splits=5)      # 主线:每折 12 支设备,快且稳
# 辅以一次 LeaveOneGroupOut 看风险边界
点开:如果实体数很少(比如只有 8 支设备)怎么办?

这时候 5 折意味着每折只有 1.6 支设备 —— 太少。

三种做法,按推荐顺序:

① 留一实体(8 折)

python
cv = LeaveOneGroupOut()      # 8 次训练,每次留出 1 支
优点:训练集用到 7/8 的数据,最接近"最终模型"
缺点:单折测试集只有一支设备的几百行,噪声大
     ↓
所以:报【均值】和【最小值】,不要报单折成绩

② 少折 + 明确写出限制

python
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 换个模型再验一遍 ​

一个问题必须回答:这个现象是"随机森林的专利"吗?

真实输出:

text
  模型                             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 标准差比"变成一个自动判断

两个指标(分数差、标准差之比)可以组合成一个判断:

python
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

用本次数据跑一下:

text
[线性回归(+标准化)]
  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 行的极简版本(直接贴在评估脚本末尾):

python
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 项

真实输出(检查第 ③ 条的代码结果):

text
  检查项                                      KFold(5)          GroupKFold(5)
  同一设备跨折的折数                                     5/5                    0/5
  单折最多跨折设备数                                      60                      0

★ 第 ③ 条是这一章的核心,它一行代码就能算出来:

python
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 和留出口径不一致

★ 所以最精简的版本只需要两条:

python
# ① 跨折实体必须是 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 的标准差 < 阈值 → 报警

用本次数据验算:

python
assert s_row.std() > 0.3 * s_gk.std()
# 0.00055 > 0.3 × 0.01147 = 0.00344  →  False  →  报警 ✅

★ 这是一个"自适应阈值",比写死 0.001 靠谱得多。

★ 最后一条经验(关于"什么时候可以不这么讲究"):

如果你的数据真的每行独立(比如"每套房一行"的房价数据):
    ① KFold 就够,不用 GroupKFold
    ② 标准差小是好消息
    ③ 这份检查清单可以跳过

     ↓
所以清单的第一条("先问分组")永远是第一步 ——
它决定了后面四条要不要执行。

★ 判断"每行是否独立"的最小代码(和第 03 章那段一样):

python
print(f"行数   : {len(df):,d}")
print(f"实体数 : {df[KEY].nunique():,d}")
print(f"每实体 : {len(df) / df[KEY].nunique():.1f} 行")
每实体 ≈ 1.0  →  行独立  →  KFold 可用
每实体 ≫ 1    →  有分组  →  必须 GroupKFold

12.7 常见坑 ​

text
① 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 完整示例与输出 ​

bash
cd code
MPLCONFIGDIR=/tmp/ex03-work/.mplconfig python demos/ch12-group-cross-validation.py

真实输出(节选):

text
==============================================================================
① 同一个模型、同一个 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):

  1. 用 KFold(n_splits=5, shuffle=True, random_state=42) 跑 cross_val_score, 打印每折成绩、均值、标准差;
  2. 用 GroupKFold(n_splits=5)(记得传 groups)再跑一遍,打印同样三个东西;
  3. 打印两次的分数差和标准差之比,并解释哪一个是"双重欺骗"。
参考答案
python
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")

期望输出:

text
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 只是"分数高",你可能会想"是不是有点问题"
但它还"很稳" → 你会觉得"这个结论很扎实"
     ↓
★ 一个错误的结论 + 一个错误的"高置信度" = 最危险的组合

★ 顺手验一下"测试集里的设备数":

python
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} 支")

期望输出:

text
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),对每个折输出: 测试行数、测试设备数、跨折设备数。然后:

  1. 对 KFold(5, shuffle=True, random_state=42) 调用它;
  2. 对 GroupKFold(5) 调用它;
  3. 对 KFold(5)(不洗牌)调用它,观察中间几折有什么特别。
参考答案
python
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(不洗牌)")

期望输出(节选):

text
--- 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(不洗牌) 的泄漏不是"面"上的(每支设备都有一部分行被见过),
  而是"点"上的(少数设备被精确地劈在相邻行之间)

★ 真正的麻烦在于"折难度的系统性差异":

text
第 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

★ 更关键的问题是"折难度的系统性差异":

text
第 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 的断言

python
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 折,然后输出下面四项,并写一段给非技术读者的结论:

  1. 均值 ± 标准差;
  2. 最小值(最差的那支设备)与它对应的设备编号;
  3. 5% 分位与 95% 分位;
  4. 一段结论,包含"平均预期"和"风险边界"两个数字。
参考答案
python
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} 做效果预期。
""")

期望输出:

text
① 成绩 : 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 的七成

★ 这不是"新人不认真",这是"默认值是陷阱"。

② 防护措施:把"正确的写法"变成"唯一的写法"

python
# 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 的机会被从源头消掉了

③ 一个"反例文档"的价值

在项目仓库里放一个文件,记录"我们踩过的坑 + 数字":

markdown
# 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 测试所有人(包括三个月后的你自己)

★ 只有第 ③ 层是"不需要人记得"的。

python
# 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 章这套评估方式上。

如果评估方式是错的 → 后面每一章的数字都不可信
     ↓
这就是为什么这四章是全书的重心
     ↓
它们不是"某个技巧",它们是"后面所有结论的地基"

⑦ 一个建议:现在回头重跑一次前几章的实验

如果你是按顺序读到这里的,建议做一件事:

bash
# 把第 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 ← 换模型的真实收益

★ 四个数字放在一起,你就能回答"我的模型到底行不行"这个问题的全部版本。


本书评估部分的最后一句话:

在这四章之后,你手里的任何分数都应该带着三个限定词 —— 按什么分组、几折、被评估过几次。 缺一个,那个数字就还不算证据。

下一章:第 13 章 · 多特征线性回归:从一个特征到七个特征