🏠 总目录📚 本教程 17 · 版本与血缘 ← →
📑 本页目录(点开跳转)

17 · 数据版本与血缘

⏱ 52 分钟 | ⭐ 三个月后,你能重现当时那份训练数据吗


🎯 一句话

只存代码不存数据,等于没有可复现。 模型是 代码 × 数据 × 参数 × 环境 的函数 —— 你把其中三项都版本化了,剩下那一项是一张每天被覆盖的上游表。


🔍 一、先花五分钟做个自测

随便挑一个三个月前上线的模型,回答这六个问题。

逐项核对

  • ① 训练用的是哪张表、哪个分区/快照?

  • ② 那份数据现在还在吗?内容和当时【逐行相同】吗? ⭐ 大多数人卡在这一条

  • ③ 是哪段 SQL 产出的?那段 SQL 从那天起改过吗?

  • ④ 过滤掉了哪些行?(去重规则、时间窗、黑名单)

  • ⑤ 当时的行数是多少?现在重跑是多少?

  • ⑥ 现在重跑一遍,能得到【指标相同】的模型吗?

为什么"我们代码都在 git 里"救不了你:

对照

模型 = f( 代码 , 参数 , 环境 , 数据 )

✅ ✅ ✅ ❓

commit yaml 镜像 ← 一张每天 INSERT OVERWRITE 的表

💀 这四项是【短板效应】,不是加权平均。

有一项不可回溯,整体就是不可复现 ——

前三项做到 100% 分,总分还是 0。

⭐ 本章和《模型上线之后》第 17 章的分工,就在这张图上: 那边负责左边三格(代码、参数、环境、模型权重),这边只负责最右边那一格。 两边都做齐了才叫可复现;而现实中挂掉的,九成是最右边这一格。


📦 二、四种版本化方案:成本、粒度、适用规模

假设一张 200 GB 的宽表,每天产出一版,日变化率约 5%(这是很典型的数值)。

方案 怎么做 一年存储 回溯粒度 适用规模 ⚠️ 主要痛点
全量快照 每次训练前整表拷一份,路径带日期 ≈ 73 TB 💀 完美 表 < 10 GB,或"只在正式发版时存" 贵得离谱,通常撑不过三个月就被清理
时间戳分区 按 dt= 分区,只写当天增量,永不覆盖 ≈ 1.1 TB 分区级 ⭐ 绝大多数团队的默认答案 💀 回填(backfill)会静默改写历史分区
内容寻址哈希 按内容块算 hash 存对象存储,重复块只存一次 ≈ 3.8 TB 文件/块级 大表 + 需要严格回溯(金融、医疗) 需要一层元数据服务,运维有成本
DVC 类工具 本质是内容寻址 + git 里只存指针文件 同上 文件级 ⭐ 中小团队、实验型项目 大文件性能一般;团队得真的会用 git

怎么选:先算你的日变化率。

算一算

日变化率 = 每天真正变了的字节 / 全表字节

< 2% → 内容寻址几乎免费(去重率极高) ⭐

2% ~ 20% → 时间戳分区 + 定期全量快照(比如每月 1 号)

> 20% → 数据本身就在剧烈变化,

⚠️ 先去问"为什么",这通常是口径不稳定,不是存储问题

⭐ 一条被反复验证的经验: 绝大多数团队不需要上 DVC / lakeFS / Delta Lake。 他们需要的只是两条纪律 —— ① 分区永不覆盖;② 每次训练把用到的分区名和指纹写进 manifest。 这两条不需要任何新工具,但它们能解决 80% 的复现问题。


🔑 三、指纹:用一行字符串锁住一份数据

版本化只解决"数据还在",指纹解决"它还是不是原来那份"。

import hashlib
import json
import numpy as np
import pandas as pd

def logical_fingerprint(df: pd.DataFrame, float_dp: int = 6) -> dict:
    """逻辑指纹:对文件切分方式、行顺序、分片数都不敏感。
    回答的是"这两份数据是不是同一份",而不是"这两个文件是不是同一个文件"。"""
    cols = sorted(df.columns)                                  # ⭐ 列顺序无关
    parts = []
    for c in cols:
        s = df[c]
        if pd.api.types.is_float_dtype(s):
            s = s.round(float_dp)                              # ⭐ 浮点必须先定精度,否则永远对不上
        h = pd.util.hash_pandas_object(s, index=False).to_numpy(dtype=np.uint64)
        parts.append((c, int(np.bitwise_xor.reduce(h))))       # ⭐ XOR 聚合 → 行顺序无关
    payload = {"n_rows": int(len(df)), "cols": parts}
    digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
    return {
        "fingerprint": digest[:16],          # ⭐ 16 位十六进制足够,写进 manifest 一眼能比
        "n_rows": len(df),
        "n_cols": len(cols),
        "per_column": dict(parts),           # ⭐ 出问题时,这一列能直接告诉你是【哪一列】变了
    }

# ⭐ 关键用法:不要只存总指纹,要存 per_column ——
#     总指纹只能告诉你"变了",per_column 能告诉你"哪一列变了",
#     这是排查时间从两天缩到十分钟的差别

四个让指纹永远对不上的坑:

坑 现象 解法
浮点精度 同样的数据,两台机器算出不同指纹 ⭐ 先 round 到固定小数位再 hash
列顺序 SELECT * 在 schema 变更后顺序变了 按列名排序后再 hash
时区 同一个时间戳存成 naive / aware 两种 统一转 UTC 再 hash
行顺序 分布式任务每次产出顺序不同 ⭐ 用 XOR / 求和这类可交换的聚合

🧾 四、血缘要记什么

血缘不是一张关系图,是一份「能让人照着重跑一遍」的记录。

必须记 为什么
上游表 + 分区/快照 ID 最基本的一条
每个上游的指纹 ⭐ 💀 只记表名是不够的 —— 分区名没变但内容变了,就是最常见的死法
产出这份数据的 SQL / 代码的 commit 逻辑本身也会变
参数(时间窗、阈值、采样率、随机种子) ⭐ 随机种子是最常被漏掉的一个
过滤条件 ⭐ "去掉了哪些行"往往写在 SQL 里,但没人会去读三个月前的 SQL
数据截止时间(不是任务运行时间)⭐ 💀 任务重跑了三次,运行时间不同但数据截止时间相同 —— 混淆这两个会直接导致误判
行数 / 各列空值率 便宜的健康检查,指纹对不上时先看这两个
触发方式与执行人 排查"这份数据是谁在什么情况下造出来的"

一份 run manifest 长这样(和产出物放在同一个目录,文件名 _MANIFEST.json):

{
  "run_id": "train_credit_v37_20240131_0412",
  "produced_at_utc": "2024-01-31T04:12:38Z",
  "data_cutoff_utc": "2024-01-31T00:00:00Z",
  "code": {"repo": "risk-models", "commit": "9f2c1ab", "entry": "jobs/build_train.py"},
  "params": {"window_days": 180, "neg_sample_rate": 0.15, "seed": 20240131},
  "filters": ["status != 'test'", "apply_time < data_cutoff", "dedup by (uid, apply_date)"],
  "inputs": [
    {"table": "dw.user_profile_wide", "partition": "dt=2024-01-31",
     "fingerprint": "3a91f0c7d2be4155", "n_rows": 8421339},
    {"table": "dw.repay_label", "partition": "dt=2024-01-31",
     "fingerprint": "77c0be14aa39d802", "n_rows": 3110284}
  ],
  "output": {"path": "s3://ds/train/credit_v37/", "fingerprint": "b5e2149c8f0a7731",
             "n_rows": 3110284, "n_cols": 214}
}

⭐ 把 manifest 写在产出物【旁边】,不要写在另一个系统里。 三个月后你能找到那份 parquet,就一定能找到 _MANIFEST.json; 但你不一定还能登进当年那个血缘平台 —— 它可能换了域名、换了负责人、或者根本已经下线了。 💀 血缘系统的存活率,比数据文件低得多。


🛑 读到这里可以停 —— 前半章讲完了(约 19 分钟)。 后半章还有:只存代码不存数据的两种死法 · 一次真实的事故复盘 · 和《模型上线之后 17》的分工 · 按团队规模分级实施 回来的时候不用重读,直接从下一节接着看就行。


☠️ 五、只存代码不存数据的两种死法

关键信息

💀 回填(backfill)是数据侧最危险的一个动作,因为它长得像修 bug: "我们发现 income_verified 算错了,已经修好并回填了历史数据。" 听起来完全正确 —— 但它同时把所有历史模型的训练数据静默改写了。 ⭐ 纪律:任何 backfill 都必须写新分区(或新版本号),永不原地改写。 存储贵一点,比不可复现便宜一万倍。


💀 六、一次真实的事故复盘

对照

系统:某消费信贷的授信模型 v37,2 月上线

10 月,监管问询:

· 解释 3 月 12 日对某个申请人的【拒绝】决策

· 证明模型未使用某类禁用字段

团队手里有:git commit、Docker 镜像 digest、超参 yaml、模型权重文件

团队手里没有:训练数据快照 ⭐

发生了什么

时间 事件
1 月 31 日 用 dw.user_profile_wide 的 dt=2024-01-31 分区训练,该表保留期 90 天
3 月 数据团队修正 income_verified 的口径:从"近 6 个月流水"改成"近 12 个月",并 backfill 了全部历史分区。分区名没变、行数没变、schema 没变
10 月 监管要求复现。原始分区早已过期被清理,只能用当前口径重建一份"等价数据"
复现结果 重跑模型 AUC 0.7412,原始记录 0.7389;Top-10 特征重要性里 3 个换了位次
💀 关键 对那位申请人,重跑模型给出的分数是 0.44,原始记录是 0.31,审批阈值 0.35 —— 重跑出来的模型会批准他

为什么没被发现

操作步骤

  1. 数据保留期 90 天 < 监管追溯期 24 个月 ⭐ 这两个数字从来没有人放在一起看过
  2. backfill 没有产生新分区,也没有发出任何变更通知
  3. 团队【有】血缘系统 —— 但它只记了"表名",
  4. 没记【分区指纹】,也没记【口径版本】
  5. 💀 有血缘 ≠ 可复现,这是最容易被忽悠过去的一点
  6. 每次重训 AUC 本来就在 0.73~0.75 之间波动,
  7. 0.7412 vs 0.7389 的差异【小到不会触发任何告警】
  8. ⭐ 但它足以让一个个体样本的决策翻转 ——
  9. 聚合指标的稳定,完全掩盖了个体决策的不稳定

代价

因果链

无法自证→监管现场检查升级
专项投入 2 名工程师 + 1 名合规,6 周
外部咨询/法务 ≈ 86 万元
模型被要求下线整改 5 周
期间回退到旧规则引擎,通过率 −11%
少放款约 2.3 亿元,估算利息收入损失 ≈ 340 万元
全行 23 个模型补做数据快照
一次性新增存储 41 TB(年成本约 12 万元)
⭐ 对比一下:如果当初就存快照,
单模型每年存储成本约 5000 元。
💀 省下的 5000 元,最后花掉了 400 多万。

该补什么

缺失 补上之后
训练数据没有快照 💀 ⭐ 每次正式发版的训练集做一次内容寻址快照(不是每次实验都做)。日变化率 5%,增量存储可控
保留期短于追溯期 ⭐ 建一条硬规则:任何进过生产模型的数据,保留期 ≥ 监管追溯期,并把这条写进数据契约(第 03 章)
backfill 原地改写 💀 禁止原地 backfill。改口径必须写新分区或加版本后缀,旧分区冻结
血缘只记表名 manifest 里必须有每个上游的指纹 + 行数 + 数据截止时间,缺一项不许发版
只看聚合指标 ⭐ ⭐ 复现校验不看 AUC,看个体分数:抽 1000 条样本,比较新旧模型打分,|Δscore| > 0.02 的比例 > 1% 就算复现失败
没有复现演练 每季度随机抽一个线上模型做"复现演练",当作消防演习

⭐ 这个案例最值钱的一句话: AUC 差 0.0023 是"没差别",同一个人的分数差 0.13 是"完全不同的决策"。 可复现性不能用聚合指标验证,必须用个体样本逐条比对 —— 因为你要向监管解释的,从来不是平均值,是某一个人。


🤝 七、和《模型上线之后 17》的分工

两章讲的是同一件事的两半,边界很清晰:

模型上线之后 · 17 数据这一关 · 17(本章)
管什么 代码、参数、环境、模型权重、推理链路 ⭐ 数据本身
核心问题 「这个预测是哪个模型版本给出的?」 「这个模型是哪份数据训出来的?」
典型手段 模型注册表、镜像 digest、请求级版本打点 分区不覆盖、内容指纹、run manifest
典型死法 加载了错误的模型版本 💀 上游被覆盖 / 被静默 backfill
谁负责 通常是算法工程 / MLOps 通常是数据工程 —— ⚠️ 所以最容易两边都以为对方管了

⚠️ 这一行"两边都以为对方管了"是真实事故的高发区: 模型侧觉得"数据是数仓的事",数据侧觉得"训练集是算法自己拉的"。 ⭐ 解法很简单:把「上游分区指纹」列进模型卡的必填项 (落地处是本章第四节那份 run manifest 的 inputs[].fingerprint 字段, 以及《模型上线之后》第 19 章的模型卡模板)—— 谁发版谁填,填不出来就不许发版。


🪜 八、按团队规模分级实施

别一上来就上工具,按这个顺序做。

级别 做什么 成本 什么时候升级到下一级
L0(1–3 人) ⭐ 分区永不覆盖 + 训练脚本把上游分区名打进日志 几乎为零 有第二个人需要复现你的结果时
L1(3–10 人) 加 _MANIFEST.json:分区名 + 指纹 + 行数 + commit + 参数 半天工程量 ⭐ 性价比最高的一级 模型进了生产、有回溯要求时
L2(生产 / 受监管) 正式发版时做内容寻址快照;保留期对齐追溯期;季度复现演练 存储 + 一条流程 数据集数量多到人管不过来时
L3(数十个数据集) 上 DVC / lakeFS / Delta 之类的工具,接血缘平台 一个人长期维护 —

⭐ L1 是绝大多数团队应该停下来的地方。 一个 _MANIFEST.json 文件、半天工程量, 能解决你 80% 的"三个月后重现不出来"问题。 剩下 20% 才需要工具,而那 20% 的团队自己知道自己是谁。


🔗 这一章连到哪里

去哪 为什么
模型上线之后 17 版本回溯与可复现 ⭐ 成对的另一半:那边管代码/参数/环境/模型权重,这边只管数据。四项缺一不可,是短板效应
模型上线之后 16 重训练 每次重训都在产生一份新的训练数据版本 —— 重训频率越高,manifest 越重要
模型上线之后 19 合规审计与模型卡 ⭐ 案例里监管要的东西。把「上游分区指纹」列进模型卡必填项,就是这两章的接口
AI基础设施 22 数据管线与存储 分区、对象存储、内容寻址的工程实现细节
数据泄漏的七种来源 「数据截止时间 vs 任务运行时间」混淆,在那一章里是一种泄漏,在这一章里是一种不可复现
SQL 查询这一关 06 CTE 与递归查询 ⭐ 成对的另一半:本章 owns「血缘该记什么」,那一章 owns「记下来之后怎么查」——本章第四节那个 inputs 数组就是它第四节那张 edges 表。⚠️ 没有递归 CTE,「这份训练数据的全部上游是谁」在纯 SQL 里根本表达不出来

✅ 检查点

  1. 五分钟自测的六个问题是什么?大多数团队卡在哪一条?
  2. 为什么说"代码都在 git 里"救不了可复现?这四项是什么关系?
  3. 四种版本化方案在「200 GB 表、日变化率 5%」下一年各要多少存储?各自的主要痛点是什么?怎么用日变化率选方案、超过 20% 说明什么?
  4. 逻辑指纹为什么要按列名排序、要 round 浮点、要用 XOR 聚合?为什么一定要存 per_column 而不只存总指纹?
  5. 血缘里最容易漏的三项是什么?为什么 manifest 要放在产出物旁边而不是血缘平台里?
  6. 只存代码不存数据的两种死法是什么?为什么说 backfill "长得像修 bug"?纪律是什么?
  7. 信贷那个事故里,重跑的 AUC 和原始差多少?那位申请人的分数差多少?为什么说前者"没差别"而后者"完全不同"?
  8. 那个事故的四条"没被发现"的原因里,哪一条最容易被忽悠过去?代价一共多少、如果当初就存快照要花多少?
  9. 本章和《模型上线之后 17》怎么分工?为什么这个边界特别容易出事、怎么解决?
  10. 分级实施表里,哪一级性价比最高、要多少工程量、能解决多少问题?
👀 答案
  1. ①用了哪张表哪个分区 ②那份数据现在还在吗、内容逐行相同吗 ③是哪段 SQL 产出的、改过吗 ④过滤掉了哪些行 ⑤行数是多少 ⑥现在重跑能得到指标相同的模型吗。大多数人卡在第 ②条 —— 上游表是 INSERT OVERWRITE 的,或者保留期太短。
  2. 因为 模型 = f(代码, 参数, 环境, 数据),前三项 git/yaml/镜像都解决了,数据那一格是一张每天被覆盖的表。这四项是短板效应,不是加权平均 —— 前三项做到 100 分,有一项不可回溯,总分还是 0。
  3. 全量快照 ≈ 73 TB(贵到撑不过三个月就被清);时间戳分区 ≈ 1.1 TB(💀 痛点是 backfill 会静默改写历史分区);内容寻址 ≈ 3.8 TB(需要一层元数据服务);DVC 类同内容寻址(大文件性能一般,且团队得真的会用 git)。选型看 日变化率 = 每天真正变了的字节 / 全表字节:<2% 内容寻址几乎免费;2%–20% 时间戳分区 + 每月一次全量快照;>20% 说明数据本身在剧烈变化,通常是口径不稳定,先去问"为什么",这不是存储问题。
  4. 排序列名 → 对 SELECT * 的列顺序变化免疫;round 浮点 → 否则两台机器算出的指纹永远对不上;XOR 这类可交换聚合 → 对分布式任务的行产出顺序免疫。必须存 per_column,因为总指纹只能告诉你"变了",per_column 能告诉你"哪一列变了" —— 这是排查从两天缩到十分钟的差别。
  5. 最容易漏的三项:过滤条件(写在 SQL 里但没人会去读三个月前的 SQL)、数据截止时间(不是任务运行时间,任务重跑三次运行时间不同但截止时间相同)、随机种子。manifest 要放产出物旁边(_MANIFEST.json),因为💀 血缘系统的存活率比数据文件低得多 —— 它可能换域名、换负责人或已经下线。
  6. A:上游被覆盖(OVERWRITE 或保留期到期),连"当时是什么"都拿不到;B:上游还在但语义变了(口径改动 + 回填历史),分区名/行数/schema 全没变,没有指纹的话这个问题无法被发现也无法被证明。backfill 长得像修 bug 是因为它的说法是"我们发现算错了,已修好并回填历史" —— 听起来完全正确,但它同时静默改写了所有历史模型的训练数据。纪律:任何 backfill 必须写新分区或新版本号,永不原地改写。
  7. AUC 0.7412 vs 0.7389,差 0.0023;那位申请人的分数 0.31 → 0.44,阈值 0.35,差 0.13,决策直接翻转(重跑出的模型会批准他)。因为重训 AUC 本来就在 0.73–0.75 之间波动,0.0023 小到不会触发任何告警,但足以翻转个体决策 —— 聚合指标的稳定完全掩盖了个体决策的不稳定。所以复现校验要看个体分数:抽 1000 条比对,|Δscore| > 0.02 的比例 > 1% 就算复现失败。
  8. 最容易被忽悠过去的是第 ③条 —— 团队"有"血缘系统,但它只记表名,不记分区指纹和口径版本;💀 有血缘 ≠ 可复现。代价:6 周专项(2 工程师 + 1 合规)、外部咨询法务 ≈ 86 万、模型下线整改 5 周期间通过率 −11%、少放款 2.3 亿、利息损失 ≈ 340 万、23 个模型补快照新增 41 TB。而当初存快照单模型每年约 5000 元 —— 💀 省下的 5000 元最后花掉了 400 多万。
  9. 《模型上线之后 17》管代码、参数、环境、模型权重、推理链路,回答"这个预测是哪个模型版本给的";本章只管数据本身,回答"这个模型是哪份数据训出来的"。容易出事是因为⚠️ 模型侧觉得数据是数仓的事、数据侧觉得训练集是算法自己拉的,两边都以为对方管了。解法:把「上游分区指纹」列进模型卡的必填项,谁发版谁填,填不出来不许发版。
  10. L1 性价比最高:加一个 _MANIFEST.json(分区名 + 指纹 + 行数 + commit + 参数),半天工程量,能解决 80% 的"三个月后重现不出来"问题。L1 是绝大多数团队应该停下来的地方 —— 剩下 20% 才需要 DVC/lakeFS 这类工具,而那 20% 的团队自己知道自己是谁。

🛑 可以停在这里

⚡ 走神救援

⭐ 只存代码不存数据,等于没有可复现。 模型是代码、参数、环境、数据四样东西的函数——前三项都有版本号,而数据那一格是一张每天覆盖写的表。💀⭐ 这四项是短板效应不是加权平均:前三项满分、有一项不可回溯,总分还是零。

⭐ 绝大多数团队不需要上任何工具,只需要两条纪律:分区永不覆盖,以及每次训练把用到的分区名和指纹写进清单。⭐ 选型前先算日变化率——变化率高到一定程度说明口径不稳定,那不是存储问题,先去问为什么。

⭐ 一定要存每列的指纹,而不只是总指纹:总指纹只说「变了」,每列指纹说「哪一列变了」——这是排查从两天缩到十分钟的差别。⭐ 清单要写在产出物旁边,💀 因为血缘系统的存活率比数据文件低得多。

☠️ 两种死法:上游被覆盖或过期;⭐ 以及更隐蔽的——上游还在但语义变了:分区名没变、行数没变、结构没变,你重跑结果不同却不知道为什么。⭐ 没有指纹的话,这个问题无法被发现,也无法被证明。

💀⭐ 回填是数据侧最危险的动作,因为它长得像修 bug:「我们发现算错了,已修好并回填历史」听起来完全正确——⚠️ 但它同时静默改写了所有历史模型的训练数据。 ⭐ 纪律:任何回填必须写新分区,永不原地改写。

💀 那个信贷事故里最贵的一句:⭐⭐ 「整体指标只差一点点」是「没差别」,而「同一个人的分数差很多」是「完全不同的决策」。 ⭐ 可复现性不能用聚合指标验证——必须抽上千条个体样本逐条比对,因为你要向监管解释的从来不是平均值,是某一个人。

⚠️ 两条容易漏的:数据保留期短于合规追溯期;⭐ 「有血缘系统」不等于「可复现」——只记表名、不记分区指纹和口径版本,等于没记。

下一节 👉 18-特征存储.md

打卡记录保存在你的浏览器里,首页能看到总进度