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