第 31 章 · 从清单到流程卡点
这一章要解决什么:第 22 章给了一份能跑的防泄漏清单。 但一份没人跑的清单等于没有。这一章讲怎么让它变成流程卡点。
最容易踩的坑:把清单贴在墙上 / 写进 wiki。 纪律只在"不通过就发不出去"的时候才生效。
本章你需要完成的代码:demos/ch31-checklist-to-process.py
31.1 第 22 章给了"检查",这一章讲"怎么让它被跑到"
先分清两件事:
| 第 22 章 | 本章 | |
|---|---|---|
| 内容 | 技术卡点:五道检查(C1~C5) | 组织卡点:怎么让它们每次都被执行 |
| 形式 | 代码(能跑,非黑即白) | 流程(谁、何时、拦什么) |
| 失败时 | 抛 AssertionError | 不让代码合并 / 不让模型上线 |
| 归属 | 技术 | 团队协作 |
★ 这一章不会再写一遍检查代码 —— 检查已经在第 22 章了。 本章回答的问题是:怎么让它不被绕过。
31.2 四种"防线"及其真实强度
真实输出:
=== ① 四种防线的强度对比 ===
防线 谁执行 会被绕过吗 拦截时机
--------------------------------------------------------------
① 写在 wiki 里 靠人想起来 ★★★★★ 极易 从不
② 写在 PR 模板里 靠人填 ★★★☆☆ 容易 合并前
③ 接进 CI(失败即红) 机器 ★☆☆☆☆ 很难 合并前
④ 写进发布脚本(硬断言) 机器 ☆☆☆☆☆ 不可能 上线前
★ 结论:防线强度 = "绕过它需要多大的决心"
前两种靠【自觉】,后两种靠【机制】。★ 这条阶梯的关键不是"哪个更好",而是"代价与收益"
| 防线 | 建立成本 | 维护成本 | 能挡住什么 |
|---|---|---|---|
| wiki | 10 分钟 | 会过期 | 什么都挡不住 |
| PR 模板 | 30 分钟 | 低 | 提醒(挡不住故意绕过) |
| CI | 2 小时 | 低 | 能挡(红了就合不了) |
| 发布硬断言 | 半天 | 低 | 最强(但已经到上线前了) |
★ 最划算的是第 ③ 条(CI):
2 小时的投入,换来"每一次改动都被检查"
↓
而且它的失败是【客观的】(不是"张三觉得不行")
↓
【这是投入产出比最高的一条防线】31.3 把清单接进 CI
真实输出:
=== ② 接进 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★ 第三个检查特别值得注意
- name: 特征账本是最新的
run: |
python demos/ch16-feature-ledger.py
git diff --exit-code outputs/ch16-feature-ledger.md它的作用:
有人改了特征构造代码
↓
账本会变(因为账本是从代码 + 数据算出来的)
↓
如果没提交账本的更新 → git diff 非空 → CI 红
↓
【逼着人把账本一起更新】
↓
这就解决了第 16 章那个坑:"账本写完就不维护,比没有更危险"★ "让文档跟着代码走"的通用模式
# 任何"由脚本生成的文档",都可以这样防过期:
python <生成脚本>
git diff --exit-code <生成的文件>适用对象:特征账本、精度报告、API 文档、配置清单。
点开:把第 22 章的检查包成 pytest(完整示例)
# 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 里跑得快一点
# 泄漏检查不需要全量数据、不需要重训模型
# → 用 1,000 行子集,让 CI 在 30 秒内跑完
@pytest.fixture(scope="module")
def df_small(df):
return df.sample(n=1000, random_state=0)为什么:
CI 超过 5 分钟,人就会开始"先合了再说"
↓
那防线就形同虚设
↓
【防线的速度决定它的存活率】31.4 谁来负责:卡点必须有人
真实输出:
=== ③ 卡点的责任表 ===
卡点 谁负责 何时检查 不通过怎么办
------------------------------------------------------------------
数据指纹一致 数据平台 每次改动数据 拒绝合并
划分无实体交集 建模同学 PR 拒绝合并
无泄漏列 建模同学 PR 拒绝合并
账本已更新 建模同学 PR 拒绝合并
报告可复现 建模同学 发版前 拒绝发版
上线环境有历史标签 数据平台 立项时 【改设计】
★ 最后一行最特殊:它不是"检查",是【前提确认】
它的结论可能是"没有" —— 那要改的是方案,不是代码。★ 最后一行值得单独讲
"上线环境能不能拿到历史实测 SOH"
↓
这是一个【项目立项阶段】就该问的问题
↓
如果答案是否 → 整个 0.999 的方案作废(上限掉到 0.959)
↓
【它不是一个"检查项",而是一个"设计前提"】★ 所以卡点分两类
| 类型 | 时机 | 不通过的后果 |
|---|---|---|
| 检查类(C1~C5) | 每次 PR / 发版 | 拒绝合并/发版 |
| 前提类 | 立项时 | 改设计 |
★ 一个真实的教训
某团队花三个月做出 R² = 0.999 的模型
↓
上线时发现"历史实测标签"在线上取不到
↓
紧急返工 → 上限 0.959 → 业务方不接受 → 项目重来
↓
【一个第 1 天就能问清的问题,浪费了三个月】第 16 章的"待确认项"表就是为这件事设计的 —— 它把那个问题变成一个有负责人、有截止日期的工作项。
31.5 红线阈值:定多少
真实输出:
=== ④ 红线阈值(建议值 + 依据)===
指标 建议红线 依据
------------------------------------------------------
训练/测试实体交集 = 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 倍 | 提示 + 要求人工确认 |
★ "要求人工确认"的具体做法
# 报警不是失败,而是要求一个显式的确认
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 常见坑
① 清单只写在 wiki → 没人跑(防线强度最低)
② CI 跑太久(> 5 分钟)→ 人会想办法绕过
③ 阈值定太严、天天红 → 催生"绕过文化"
④ 硬红线与报警不分 → 要么不够严,要么太烦
⑤ 卡点没人负责 → 出问题时互相推
⑥ "前提类"问题(如上线拿不到历史标签)当成检查项 → 该在立项时问,却在发版前才发现
⑦ 生成物(账本/报告)不在 CI 里比对 → 会静默过期★ ⑥ 是最贵的那个:
检查类问题 → 花 2 小时修
前提类问题 → 花 3 个月重做
↓
【越早问的问题,越值钱】31.7 完整示例与输出
cd code
python demos/ch31-checklist-to-process.py产物:outputs/ch31-ci-snippets/(可直接拷进项目的五个文件)
产物:
✅ 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 配置,至少包含两项检查:
- 数据/文件的指纹校验(防止数据被悄悄换掉);
- 一项你自己项目的结构性检查(比如"划分后实体交集为 0")。
要求:CI 必须在 2 分钟内跑完。
参考答案
# .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"配套的指纹脚本:
# 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 分钟以内"这条硬要求
# 泄漏检查不需要全量数据
@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 的红线)。
请设计一个机制:允许它通过,但要求留下理由。
要求:说明为什么"允许但要理由"比"直接失败"更好。
参考答案
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}")用法:
# 普通情况:不需要设置任何东西
pytest -q tests/test_overfit.py
# 确需接受时:显式带上理由
ACK_OVERFIT_GAP="换了更细的窗口,训练方差变大属预期,已与评审确认" \
pytest -q tests/test_overfit.py★ 为什么"允许但要理由"比"直接失败"更好
理由一:不是所有超阈值都是错的
训练-测试差距大,可能是:
① 模型过拟合(坏)
② 数据里有真实的高噪声(无法避免)
③ 窗口变细 → 训练样本相关性增强(设计取舍)
↓
【直接失败会把 ②③ 也拦掉,逼着人去改阈值或跳过检查】理由二:它把"技术判断"变成"可追溯的记录"
半年后有人问:"这个模型差距 0.15,为什么上线了?"
↓
翻 CI 日志 → 有理由、有评审人 → 有答案
↓
【如果没有这个机制,答案只有"当时没人注意"】★ 三类卡点的正确设计
| 类型 | 例子 | 应该 | 为什么 |
|---|---|---|---|
| 结构事实 | 实体交集 ≠ 0 | 硬失败 | 没有"合理的例外" |
| 统计指标 | 差距 > 0.10 | 报警 + 理由 | 有合法的例外 |
| 风格问题 | 变量命名 | 只提示 | 不该阻塞 |
★★ 一个判断标准
"这个情况有没有合法的例外?" 没有 → 硬红线;有 → 报警 + 要求理由。
★ 补充:理由要能"被追问"
# ❌ 没信息量的理由
ACK_OVERFIT_GAP="ok"
# ✅ 可追溯的理由
ACK_OVERFIT_GAP="窗口从 100 圈改成 50 圈,训练集内相邻样本相关性上升,属预期;评审人 @张三"在代码评审里,一句"ok"和没有理由是一样的。
练习 3:找出你项目里的"前提类"问题
"前提类"问题 = 在立项时就该确认、但常常拖到上线前才发现的问题。
请为你的项目列出至少三个,并说明"如果答案是否,方案要怎么改"。
参考答案
以"设备健康度预测"为例:
| 前提问题 | 如果答案是否 | 影响 |
|---|---|---|
| 上线环境能拿到历史实测标签吗? | 上限从 0.999 掉到 0.959 | 改设计(去找替代信息源,或降低精度预期) |
| 预测时点能拿到"当前窗口"的完整读数吗? | 需要改成"用上一个窗口预测下一个" | 改建模口径(特征要整体前移一个窗口) |
| 新设备的"批次"信息在预测时可得吗? | 不能用批次特征 | 特征集缩减 |
| 设备的"额定容量"在数据里统一吗? | 标签不可比,需要按设备归一化 | 改标签定义 |
| 模型多久重训一次? | 不重训 → 必须考虑数据漂移 | 加监控,或改用在线学习 |
★ 其中第一条最贵
"上线能拿到历史实测标签吗?"
↓
是 → 0.999(本书的主线方案)
否 → 0.959(第 16 章账本里的 ⚠️ 4 列作废)
↓
差距 0.04 —— 而这是【一个问题的答案】决定的★ 一个实用的"前提清单"模板
# 立项前提确认(在写第一行模型代码之前)
| # | 前提 | 谁来确认 | 状态 | 如果是否,方案怎么改 |
|---|---|---|---|---|
| 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 周:加"报警 + 理由"机制(对统计类指标)
↓
一个月内,团队的防泄漏纪律就从"靠自觉"变成"靠机制"★ 为什么建议"渐进"而不是"一次全上"
一次全上 → 第一天就红一片 → 团队抵触 → 整体被推翻
↓
渐进 → 每次解决一类问题 → 大家看到"确实抓到了真问题" → 支持
↓
【推行纪律本身也是一个需要设计的工程问题】③ 一个"卡点健康度"的自检
每月问一次:
- [ ] CI 平均耗时 < 3 分钟?
- [ ] 过去一个月,CI 抓到过真问题吗?(如果一次都没有,可能检查太弱)
- [ ] 有没有人用过 --no-verify?(如果有,说明某个检查太烦或误报太多)
- [ ] 生成物(账本/报告)与代码同步吗?
- [ ] 卡点责任表上的人还在这个项目上吗?★ 第 2 条与第 3 条要一起看:
CI 从没抓到过问题 → 检查太弱(形同虚设)
CI 天天红 → 检查太严(会催生绕过)
↓
【健康的卡点应该"偶尔抓到真问题"】★ 一句话总结
好的流程不是"要求人做对",而是"让人做错时立刻知道"。 前者依赖自觉,后者依赖机制 —— 而机制是可以在你休假时继续工作的。
第 32 章会把视角再拉远一步:这套离线纪律,在"在线"世界里会遇到什么新问题。