Skip to content

第 31 章 · 从清单到流程卡点 ​

这一章要解决什么:第 22 章给了一份能跑的防泄漏清单。 但一份没人跑的清单等于没有。这一章讲怎么让它变成流程卡点。

最容易踩的坑:把清单贴在墙上 / 写进 wiki。 纪律只在"不通过就发不出去"的时候才生效。

本章你需要完成的代码:demos/ch31-checklist-to-process.py

31.1 第 22 章给了"检查",这一章讲"怎么让它被跑到" ​

先分清两件事:

第 22 章本章
内容技术卡点:五道检查(C1~C5)组织卡点:怎么让它们每次都被执行
形式代码(能跑,非黑即白)流程(谁、何时、拦什么)
失败时抛 AssertionError不让代码合并 / 不让模型上线
归属技术团队协作

★ 这一章不会再写一遍检查代码 —— 检查已经在第 22 章了。 本章回答的问题是:怎么让它不被绕过。

31.2 四种"防线"及其真实强度 ​

真实输出:

text
=== ① 四种防线的强度对比 ===

  防线                    谁执行        会被绕过吗        拦截时机
  --------------------------------------------------------------
  ① 写在 wiki 里          靠人想起来      ★★★★★ 极易       从不
  ② 写在 PR 模板里        靠人填          ★★★☆☆ 容易       合并前
  ③ 接进 CI(失败即红)    机器             ★☆☆☆☆ 很难       合并前
  ④ 写进发布脚本(硬断言) 机器             ☆☆☆☆☆ 不可能     上线前

  ★ 结论:防线强度 = "绕过它需要多大的决心"
     前两种靠【自觉】,后两种靠【机制】。

★ 这条阶梯的关键不是"哪个更好",而是"代价与收益"

防线建立成本维护成本能挡住什么
wiki10 分钟会过期什么都挡不住
PR 模板30 分钟低提醒(挡不住故意绕过)
CI2 小时低能挡(红了就合不了)
发布硬断言半天低最强(但已经到上线前了)

★ 最划算的是第 ③ 条(CI):

2 小时的投入,换来"每一次改动都被检查"
    ↓
而且它的失败是【客观的】(不是"张三觉得不行")
    ↓
【这是投入产出比最高的一条防线】

31.3 把清单接进 CI ​

真实输出:

text
=== ② 接进 CI:三个文件就够 ===

  ① .github/workflows/leak-check.yml      每次 PR 自动跑
  ② tests/test_leak_checklist.py          把第 22 章的检查包成 pytest
  ③ .github/pull_request_template.md      提醒(第二道)

  最小可用的 CI 配置(完整内容见 demo 输出):

    - name: 数据指纹一致
      run: python -c "…SHA-256 比对…"

    - name: 防泄漏检查
      run: pytest -q tests/test_leak_checklist.py

    - name: 特征账本是最新的
      run: python demos/ch16-feature-ledger.py && git diff --exit-code outputs/ch16-feature-ledger.md

★ 第三个检查特别值得注意

yaml
- name: 特征账本是最新的
  run: |
    python demos/ch16-feature-ledger.py
    git diff --exit-code outputs/ch16-feature-ledger.md

它的作用:

有人改了特征构造代码
    ↓
账本会变(因为账本是从代码 + 数据算出来的)
    ↓
如果没提交账本的更新 → git diff 非空 → CI 红
    ↓
【逼着人把账本一起更新】
    ↓
这就解决了第 16 章那个坑:"账本写完就不维护,比没有更危险"

★ "让文档跟着代码走"的通用模式

bash
# 任何"由脚本生成的文档",都可以这样防过期:
python <生成脚本>
git diff --exit-code <生成的文件>

适用对象:特征账本、精度报告、API 文档、配置清单。

点开:把第 22 章的检查包成 pytest(完整示例)
python
# tests/test_leak_checklist.py
"""把第 22 章的五道检查变成 CI 可执行的测试。"""
import numpy as np
import pandas as pd
import pytest
from sklearn.model_selection import GroupShuffleSplit

DATA = "data/battery/soh_features_v2.csv"
SAFE = [...]


@pytest.fixture(scope="module")
def df():
    return pd.read_csv(DATA)


def test_c1_split_has_no_entity_overlap(df):
    """C1:按实体划分后,训练/测试的实体交集必须为 0。"""
    tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
                  .split(df[SAFE], df["soh"], groups=df["cell_id"]))
    inter = set(df["cell_id"].iloc[tr]) & set(df["cell_id"].iloc[te])
    assert not inter, f"划分泄漏:{len(inter)} 支设备跨集"


def test_c2_no_leaky_columns(df):
    """C2:模型用的列里不许出现已知的泄漏钩子。"""
    LEAKY = {"cycle_frac", "soh_margin"}
    assert not (LEAKY & set(SAFE)), f"泄漏列混进了特征集:{LEAKY & set(SAFE)}"


def test_c3_lag_is_history_only(df):
    """C3:soh_lag1 必须恒等于上一行,且不等于本行(正证 + 反证)。"""
    g = df.sort_values(["cell_id", "cycle"]).groupby("cell_id")["soh"]
    lag1 = g.shift(1)
    d = df.sort_values(["cell_id", "cycle"])
    assert (d["soh_lag1"].fillna(-1) - lag1.fillna(-1)).abs().max() < 1e-3
    # 反证:不能等于本行
    gap = (d["soh"] - d["soh_lag1"]).abs().max()
    assert gap > 1e-4, "soh_lag1 与本行标签太接近 —— 可能漏写了 shift(1)"

def test_c4_data_fingerprint():
    """C4:数据文件的 SHA-256 必须与书里公布的一致。"""
    import hashlib
    h = hashlib.sha256(open(DATA, "rb").read()).hexdigest()
    assert h == "552e2cdb…", "数据变了!同步更新数据说明、账本与打包脚本"


def test_c5_report_is_fresh():
    """C5:精度报告必须是最新的(由 CI 重跑并比对)。"""
    # 实际做法交给 CI 的 git diff --exit-code(见 yml)
    pass

★ 这五个测试的设计要点

测试类型关键
C1结构实体交集 = 0(硬事实)
C2结构已知泄漏列不在特征集里
C3正证 + 反证等于历史 且 不等于当前
C4指纹数据没被悄悄换掉
C5新鲜度报告没过期

★ C3 的"正证 + 反证"是第 16 章那个模式的复用:

一个断言只能证明一半
    ↓
"等于上一行"通过,不代表"shift 写对了"
(如果数据相邻两行恰好相等,写错 shift 也能过)
    ↓
必须同时断言"与本行有明确差距"

★ 一个实用建议:CI 里跑得快一点

python
# 泄漏检查不需要全量数据、不需要重训模型
# → 用 1,000 行子集,让 CI 在 30 秒内跑完
@pytest.fixture(scope="module")
def df_small(df):
    return df.sample(n=1000, random_state=0)

为什么:

CI 超过 5 分钟,人就会开始"先合了再说"
    ↓
那防线就形同虚设
    ↓
【防线的速度决定它的存活率】

31.4 谁来负责:卡点必须有人 ​

真实输出:

text
=== ③ 卡点的责任表 ===

  卡点                    谁负责        何时检查      不通过怎么办
  ------------------------------------------------------------------
  数据指纹一致            数据平台      每次改动数据   拒绝合并
  划分无实体交集          建模同学      PR            拒绝合并
  无泄漏列                建模同学      PR            拒绝合并
  账本已更新              建模同学      PR            拒绝合并
  报告可复现              建模同学      发版前        拒绝发版
  上线环境有历史标签       数据平台      立项时        【改设计】

  ★ 最后一行最特殊:它不是"检查",是【前提确认】
     它的结论可能是"没有" —— 那要改的是方案,不是代码。

★ 最后一行值得单独讲

"上线环境能不能拿到历史实测 SOH"
    ↓
这是一个【项目立项阶段】就该问的问题
    ↓
如果答案是否 → 整个 0.999 的方案作废(上限掉到 0.959)
    ↓
【它不是一个"检查项",而是一个"设计前提"】

★ 所以卡点分两类

类型时机不通过的后果
检查类(C1~C5)每次 PR / 发版拒绝合并/发版
前提类立项时改设计

★ 一个真实的教训

某团队花三个月做出 R² = 0.999 的模型
    ↓
上线时发现"历史实测标签"在线上取不到
    ↓
紧急返工 → 上限 0.959 → 业务方不接受 → 项目重来
    ↓
【一个第 1 天就能问清的问题,浪费了三个月】

第 16 章的"待确认项"表就是为这件事设计的 —— 它把那个问题变成一个有负责人、有截止日期的工作项。

31.5 红线阈值:定多少 ​

真实输出:

text
=== ④ 红线阈值(建议值 + 依据)===

  指标                     建议红线      依据
  ------------------------------------------------------
  训练/测试实体交集          = 0          结构事实,不允许例外
  训练-测试 R² 差距           > 0.10 报警   第 23 章:0.081 已属"过拟合明显"
  测试集被 predict 的次数       = 1         第 22 章 C5
  无泄漏列的测试 R²            ≥ 0.95      本书的验收线
  折间标准差超过历史均值 3 倍    报警          方差突然变大 = 划分或数据变了
  相邻窗口标签自相关            > 0.99 报警   提示"可能需要按实体划分"

  ★ 阈值不是越严越好:太严会逼着人“想办法绕过它”。

★ 最后那句话很重要

如果 CI 天天红、而且红的原因都是"无关紧要"的
    ↓
人就会开始加 --no-verify、跳过检查
    ↓
【过严的卡点会催生"绕过文化",比没有卡点更糟】
    ↓
所以要区分【硬红线】与【报警】

★ 两类阈值的处理方式不同

类型例子行为
硬红线实体交集 ≠ 0、测试集被 predict 多次直接失败,不允许绕过
报警差距 > 0.10、方差超均值 3 倍提示 + 要求人工确认

★ "要求人工确认"的具体做法

python
# 报警不是失败,而是要求一个显式的确认
def check_gap(train_r2, test_r2, threshold=0.10):
    gap = train_r2 - test_r2
    if gap > threshold:
        confirm = os.environ.get("ACK_OVERFIT")   # 必须在 PR 里显式设置
        if confirm != "yes":
            pytest.fail(
                f"训练-测试差距 {gap:.3f} > {threshold}。"
                f"如果这是预期的,请在 PR 描述里说明原因,"
                f"并设置 ACK_OVERFIT=yes。"
            )

★ 这个设计的巧妙之处:

它不阻止你,但要求你【留下理由】
    ↓
半年后再看这条 PR,能看到"当时为什么接受这个差距"
    ↓
【把"技术判断"变成"可追溯的记录"】

31.6 常见坑 ​

text
① 清单只写在 wiki → 没人跑(防线强度最低)
② CI 跑太久(> 5 分钟)→ 人会想办法绕过
③ 阈值定太严、天天红 → 催生"绕过文化"
④ 硬红线与报警不分 → 要么不够严,要么太烦
⑤ 卡点没人负责 → 出问题时互相推
⑥ "前提类"问题(如上线拿不到历史标签)当成检查项 → 该在立项时问,却在发版前才发现
⑦ 生成物(账本/报告)不在 CI 里比对 → 会静默过期

★ ⑥ 是最贵的那个:

检查类问题 → 花 2 小时修
前提类问题 → 花 3 个月重做
    ↓
【越早问的问题,越值钱】

31.7 完整示例与输出 ​

bash
cd code
python demos/ch31-checklist-to-process.py

产物:outputs/ch31-ci-snippets/(可直接拷进项目的五个文件)

text
产物:
  ✅ ch31-ci-snippets/leak-check.yml               1097 B
  ✅ ch31-ci-snippets/test_leak_checklist.py       2687 B
  ✅ ch31-ci-snippets/pull_request_template.md      874 B
  ✅ ch31-ci-snippets/RESPONSIBILITY.md            1884 B
  ✅ ch31-ci-snippets/check_fingerprint.py         1041 B

★ 第五个文件(指纹脚本)是把"数据没被换掉"变成可执行检查的实现 —— 它由 demo 现场算出三个数据的 SHA-256 并写进去。

★ 这个 demo 不产出"分析结果",产出的是"可落地的配置" —— 这是它和第 30 章(阶段目标报告)的区别。

本章练习 ​

练习 1:把你项目里的检查接进 CI

为你的项目写一个最小的 CI 配置,至少包含两项检查:

  1. 数据/文件的指纹校验(防止数据被悄悄换掉);
  2. 一项你自己项目的结构性检查(比如"划分后实体交集为 0")。

要求:CI 必须在 2 分钟内跑完。

参考答案
yaml
# .github/workflows/checks.yml
name: checks
on: [pull_request]

jobs:
  quick-checks:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: 装依赖
        run: pip install -r requirements.txt

      # ① 指纹校验:数据没被悄悄换掉
      - name: 数据指纹
        run: python scripts/check_fingerprint.py

      # ② 结构检查:划分后实体交集为 0(用子集,跑得快)
      - name: 划分检查
        run: pytest -q tests/test_leak_checklist.py -k "c1 or c2"

配套的指纹脚本:

python
# scripts/check_fingerprint.py
"""比对数据文件的 SHA-256 与登记值是否一致。"""
import hashlib
import sys
from pathlib import Path

EXPECTED = {
    "data/battery/soh_features_v2.csv":
        "552e2cdb7d0c866c",   # ← 前 16 位即可,够用于"防止误改"
}


def sha256(p):
    return hashlib.sha256(Path(p).read_bytes()).hexdigest()


def main():
    bad = []
    for path, expect in EXPECTED.items():
        if not Path(path).exists():
            bad.append(f"{path} 不存在")
            continue
        actual = sha256(path)
        if not actual.startswith(expect):
            bad.append(f"{path}\n    期望 {expect}…\n    实际 {actual[:16]}…")
    if bad:
        print("❌ 数据指纹不一致(数据变了?)")
        for b in bad:
            print("   ", b)
        print("\n如果确实要换数据,请同步更新:")
        print("   ① 本脚本的 EXPECTED")
        print("   ② 书/文档里公布的值")
        print("   ③ 打包脚本里的期望值")
        sys.exit(1)
    print("✅ 数据指纹一致")

★ 为什么用"前 16 位"而不是完整 64 位

前 16 位(64 bit)已经足够防【误改】
    ↓
如果有人重新生成了数据(内容变了),前 16 位必然会变
    ↓
【脚本里写短一点,可读性更好,也够用】

但如果要对外公布(给读者核对),就应该用完整的 64 位 —— 那是防恶意篡改。

★ 关于"2 分钟以内"这条硬要求

python
# 泄漏检查不需要全量数据
@pytest.fixture(scope="module")
def df_small(df):
    """CI 只用 1,000 行 —— 结构检查不需要全部数据。"""
    return df.sample(n=1000, random_state=0)

实测对比:

数据规模检查耗时人的耐心
19,979 行~8 s还行
1,000 行~0.4 s完全无感

★ 结论:

CI 的存活率与它的速度成正比。 超过 5 分钟的检查,迟早会被 --no-verify 绕过。

练习 2:给一条"报警"设计确认机制

假设你的模型训练-测试 R² 差距达到了 0.15(超过 0.10 的红线)。

请设计一个机制:允许它通过,但要求留下理由。

要求:说明为什么"允许但要理由"比"直接失败"更好。

参考答案
python
import os
import pytest


def check_overfit_gap(train_r2, test_r2, threshold=0.10):
    """差距超阈值时:要求显式确认,而不是直接失败。"""
    gap = train_r2 - test_r2
    if gap <= threshold:
        print(f"✅ 训练-测试差距 {gap:.4f} ≤ {threshold}")
        return

    ack = os.environ.get("ACK_OVERFIT_GAP", "").strip()
    if not ack:
        pytest.fail(
            f"⚠️ 训练-测试差距 {gap:.4f} > {threshold}。\n"
            f"\n"
            f"这不是错误 —— 但需要你确认这是预期的。做法:\n"
            f"  1. 在 PR 描述里写清【为什么接受这个差距】;\n"
            f"  2. 设置环境变量 ACK_OVERFIT_GAP=<一句话理由>;\n"
            f"  3. 在 PR 里 @ 一位评审人确认。\n"
        )

    # 理由会被记录在 CI 日志里,可追溯
    print(f"⚠️ 已确认接受差距 {gap:.4f}")
    print(f"   理由:{ack}")

用法:

bash
# 普通情况:不需要设置任何东西
pytest -q tests/test_overfit.py

# 确需接受时:显式带上理由
ACK_OVERFIT_GAP="换了更细的窗口,训练方差变大属预期,已与评审确认" \
  pytest -q tests/test_overfit.py

★ 为什么"允许但要理由"比"直接失败"更好

理由一:不是所有超阈值都是错的

训练-测试差距大,可能是:
   ① 模型过拟合(坏)
   ② 数据里有真实的高噪声(无法避免)
   ③ 窗口变细 → 训练样本相关性增强(设计取舍)
      ↓
   【直接失败会把 ②③ 也拦掉,逼着人去改阈值或跳过检查】

理由二:它把"技术判断"变成"可追溯的记录"

半年后有人问:"这个模型差距 0.15,为什么上线了?"
    ↓
翻 CI 日志 → 有理由、有评审人 → 有答案
    ↓
【如果没有这个机制,答案只有"当时没人注意"】

★ 三类卡点的正确设计

类型例子应该为什么
结构事实实体交集 ≠ 0硬失败没有"合理的例外"
统计指标差距 > 0.10报警 + 理由有合法的例外
风格问题变量命名只提示不该阻塞

★★ 一个判断标准

"这个情况有没有合法的例外?" 没有 → 硬红线;有 → 报警 + 要求理由。

★ 补充:理由要能"被追问"

python
# ❌ 没信息量的理由
ACK_OVERFIT_GAP="ok"

# ✅ 可追溯的理由
ACK_OVERFIT_GAP="窗口从 100 圈改成 50 圈,训练集内相邻样本相关性上升,属预期;评审人 @张三"

在代码评审里,一句"ok"和没有理由是一样的。

练习 3:找出你项目里的"前提类"问题

"前提类"问题 = 在立项时就该确认、但常常拖到上线前才发现的问题。

请为你的项目列出至少三个,并说明"如果答案是否,方案要怎么改"。

参考答案

以"设备健康度预测"为例:

前提问题如果答案是否影响
上线环境能拿到历史实测标签吗?上限从 0.999 掉到 0.959改设计(去找替代信息源,或降低精度预期)
预测时点能拿到"当前窗口"的完整读数吗?需要改成"用上一个窗口预测下一个"改建模口径(特征要整体前移一个窗口)
新设备的"批次"信息在预测时可得吗?不能用批次特征特征集缩减
设备的"额定容量"在数据里统一吗?标签不可比,需要按设备归一化改标签定义
模型多久重训一次?不重训 → 必须考虑数据漂移加监控,或改用在线学习

★ 其中第一条最贵

"上线能拿到历史实测标签吗?"
    ↓
是 → 0.999(本书的主线方案)
否 → 0.959(第 16 章账本里的 ⚠️ 4 列作废)
    ↓
差距 0.04 —— 而这是【一个问题的答案】决定的

★ 一个实用的"前提清单"模板

markdown
# 立项前提确认(在写第一行模型代码之前)

| # | 前提 | 谁来确认 | 状态 | 如果是否,方案怎么改 |
|---|---|---|---|---|
| 1 | 预测时点定义明确吗? | 业务方 | ⬜ | 无法定义 → 项目无法开始 |
| 2 | 上线能拿到历史实测标签吗? | 数据平台 | ⬜ | 上限降 0.04,改找替代信息源 |
| 3 | 每一条特征在预测时点都可得吗? | 建模 + 数据平台 | ⬜ | 逐列删除(用第 16 章账本) |
| 4 | 划分的实体是什么?(设备/用户/门店) | 建模 | ⬜ | 定不下来 → 无法评估 |
| 5 | 标签本身的定义稳定吗? | 业务方 | ⬜ | 不稳定 → 先解决标签 |
| 6 | 多久重训一次?谁来触发? | 工程 + 业务 | ⬜ | 不重训 → 必须加漂移监控 |

★★ 为什么"前提"比"检查"更值钱

检查类问题:花 2 小时修(改代码)
前提类问题:花 3 个月重做(改方案)
    ↓
而"前提类"问题在第 1 天就能问清
    ↓
【花 1 小时问 6 个问题,可能省下 3 个月】

★ 一个反面案例(真实发生过的模式)

第 1 个月:做特征工程,R² 从 0.96 提到 0.999,团队很兴奋
第 2 个月:调参、融合、写报告,稳定在 0.999
第 3 个月:准备上线 → 发现线上拿不到历史实测标签
第 4 个月:返工,上限 0.959,业务方不接受
    ↓
【如果第 1 天问了一句"线上有历史标签吗",这四个月可以省掉】

★ 把这条写进你的项目启动清单

在写第一行模型代码之前,先回答"前提清单"上的每一个问题。 尤其是那些"如果是否,方案要改"的问题。

面向工程实践:让纪律"不依赖人的记性" ​

① 这一章的核心思路

第 22 章:给出正确的做法
    ↓
本章:让【不这么做】变得做不到
    ↓
【纪律的强度 = 违反它的成本】

★ 三个层次的"违反成本"

低:写在文档里        → 违反成本 = 0(没人查)
中:写在 PR 模板里     → 违反成本 = 需要手动删掉几行
高:接进 CI 硬断言     → 违反成本 = 合不进去
    ↓
【只有第三层才是真正的"卡点"】

② 一个可以渐进落地的路径

第 1 周:把第 22 章的检查写成一个 pytest 文件(跑本地)
第 2 周:把 pytest 接进 CI(红了不能合)
第 3 周:把生成的文档(账本、报告)也接进 CI(git diff 比对)
第 4 周:加"报警 + 理由"机制(对统计类指标)
    ↓
一个月内,团队的防泄漏纪律就从"靠自觉"变成"靠机制"

★ 为什么建议"渐进"而不是"一次全上"

一次全上 → 第一天就红一片 → 团队抵触 → 整体被推翻
    ↓
渐进 → 每次解决一类问题 → 大家看到"确实抓到了真问题" → 支持
    ↓
【推行纪律本身也是一个需要设计的工程问题】

③ 一个"卡点健康度"的自检

markdown
每月问一次:
- [ ] CI 平均耗时 < 3 分钟?
- [ ] 过去一个月,CI 抓到过真问题吗?(如果一次都没有,可能检查太弱)
- [ ] 有没有人用过 --no-verify?(如果有,说明某个检查太烦或误报太多)
- [ ] 生成物(账本/报告)与代码同步吗?
- [ ] 卡点责任表上的人还在这个项目上吗?

★ 第 2 条与第 3 条要一起看:

CI 从没抓到过问题  →  检查太弱(形同虚设)
CI 天天红          →  检查太严(会催生绕过)
    ↓
【健康的卡点应该"偶尔抓到真问题"】

★ 一句话总结

好的流程不是"要求人做对",而是"让人做错时立刻知道"。 前者依赖自觉,后者依赖机制 —— 而机制是可以在你休假时继续工作的。

第 32 章会把视角再拉远一步:这套离线纪律,在"在线"世界里会遇到什么新问题。


下一章:第 32 章 · 下一本:从离线到在线