📑 本页目录(点开跳转)
17 · 数据版本与血缘
⏱ 62 分钟 | ⭐ 三个月后,你能重现当时那份训练数据吗
🎯 一句话
只存代码不存数据,等于没有可复现。
模型是 代码 × 数据 × 参数 × 环境 的函数 ——
你把其中三项都版本化了,剩下那一项是一张每天被覆盖的上游表。
🔍 一、先花五分钟做个自测
随便挑一个三个月前上线的模型,回答这六个问题。
[ ] ① 训练用的是哪张表、哪个分区/快照?
[ ] ② 那份数据现在还在吗?内容和当时【逐行相同】吗? ⭐ 大多数人卡在这一条
[ ] ③ 是哪段 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 的差异【小到不会触发任何告警】
⭐⭐ 但它足以让一个个体样本的决策翻转 ——
聚合指标的稳定,完全掩盖了个体决策的不稳定
代价
无法自证 → 监管现场检查升级
专项投入 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 任务运行时间」混淆,在那一章里是一种泄漏,在这一章里是一种不可复现 |
✅ 检查点
- 五分钟自测的六个问题是什么?大多数团队卡在哪一条?
- 为什么说"代码都在 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% 的团队自己知道自己是谁。
🛑 可以停在这里
⚡ 走神救援
⭐⭐核心:只存代码不存数据,等于没有可复现。
模型 = f(代码, 参数, 环境, 数据)——前三项有 git commit、yaml、镜像 digest,数据那一格是一张每天 INSERT OVERWRITE 的表;💀这四项是短板效应不是加权平均,前三项 100 分、有一项不可回溯,总分还是 0。先做五分钟自测:用了哪张表哪个分区 / 那份数据现在还在吗、内容逐行相同吗(⭐大多数人卡在这条)/ 哪段 SQL 产出的、改过吗 / 过滤掉了哪些行 / 行数多少 / 现在重跑指标一样吗。📦四种版本化方案(200 GB 表、日变化率 5%):全量快照 ≈ 73 TB/年(完美但贵到撑不过三个月)、时间戳分区 ≈ 1.1 TB/年(绝大多数团队的默认答案,💀痛点是 backfill 会静默改写历史分区)、内容寻址 ≈ 3.8 TB/年(大表 + 严格回溯场景,需要一层元数据服务)、DVC 类(本质是内容寻址 + git 存指针,中小团队/实验型项目)。⭐选型先算日变化率:<2% 内容寻址几乎免费;2–20% 分区 + 每月全量快照;>20% 说明口径不稳定,先去问为什么,这不是存储问题。⭐但真相是:绝大多数团队不需要上任何工具,只需要两条纪律——①分区永不覆盖 ②每次训练把用到的分区名和指纹写进 manifest。 🔑指纹解决"它还是不是原来那份":逻辑指纹要按列名排序(免疫SELECT *列序变化)、round 浮点(否则两台机器永远对不上)、用 XOR 这类可交换聚合(免疫分布式产出顺序)。⭐⭐一定要存per_column而不只存总指纹——总指纹只说"变了",per_column 说"哪一列变了",这是排查从两天缩到十分钟的差别。 四个坑:浮点精度、列顺序、时区(统一 UTC)、行顺序。🧾血缘要记:上游表+分区、每个上游的指纹、SQL 的 commit、参数(⭐随机种子最常漏)、过滤条件(写在 SQL 里但没人会读三个月前的 SQL)、⭐⭐数据截止时间而不是任务运行时间(任务重跑三次运行时间不同、截止时间相同,混淆会直接导致误判)、行数与空值率、触发方式。⭐manifest 要写在产出物旁边(_MANIFEST.json),不要写在另一个系统里——💀血缘系统的存活率比数据文件低得多,它可能换域名、换负责人、或已经下线。 ☠️两种死法:A 上游被覆盖(OVERWRITE 或保留期到期,连"当时是什么"都拿不到);💀B 上游还在但语义变了——有人改口径并回填了全部历史分区,分区名没变、行数没变、schema 没变,你重跑结果不同却不知道为什么;⭐没有指纹的话这个问题无法被发现也无法被证明。💀backfill 是数据侧最危险的动作,因为它长得像修 bug:"我们发现算错了,已修好并回填历史"——听起来完全正确,但它同时静默改写了所有历史模型的训练数据。⭐纪律:任何 backfill 必须写新分区或新版本号,永不原地改写。 💀信贷事故:授信模型 v37 训练用dt=2024-01-31分区、该表保留期 90 天;3 月有人把income_verified从"近 6 个月流水"改成"近 12 个月"并 backfill 全部历史分区;10 月监管要求复现 3 月 12 日的一次拒绝决策,原分区早已被清理。重跑 AUC 0.7412 vs 原始 0.7389(差 0.0023),Top-10 特征重要性 3 个换位,而那位申请人的分数从 0.31 变成 0.44、阈值 0.35——重跑出来的模型会批准他。没被发现的四条:保留期 90 天 < 追溯期 24 个月(这两个数字从没被放在一起看过)、backfill 没产生新分区也没发通知、⭐团队"有"血缘系统但它只记表名不记分区指纹和口径版本(💀有血缘 ≠ 可复现,这条最容易被忽悠过去)、⭐⭐AUC 波动本来就在 0.73–0.75,0.0023 小到不触发任何告警,但足以翻转个体决策——聚合指标的稳定完全掩盖了个体决策的不稳定。代价:6 周专项(2 工程师+1 合规)、咨询法务 ≈86 万、模型下线整改 5 周期间通过率 −11%、少放款 2.3 亿、利息损失 ≈340 万、23 个模型补快照 +41 TB——而💀当初存快照单模型每年约 5000 元,省下的 5000 元最后花掉 400 多万。⭐⭐这案例最值钱的一句:AUC 差 0.0023 是"没差别",同一个人的分数差 0.13 是"完全不同的决策"——可复现性不能用聚合指标验证,必须抽 1000 条个体样本逐条比分,|Δscore| > 0.02 的比例 > 1% 就算复现失败,因为你要向监管解释的从来不是平均值,是某一个人。 🤝和《模型上线之后 17》的分工:那边管代码/参数/环境/模型权重,回答"这个预测是哪个模型版本给的";这边只管数据,回答"这个模型是哪份数据训的"。⚠️边界特别容易出事,因为模型侧觉得数据是数仓的事、数据侧觉得训练集是算法自己拉的,两边都以为对方管了;⭐解法是把「上游分区指纹」列进模型卡必填项,谁发版谁填,填不出来不许发版。🪜分级实施:L0 分区永不覆盖 + 分区名打日志(成本几乎为零);⭐⭐L1 加一个_MANIFEST.json(分区名+指纹+行数+commit+参数),半天工程量,解决 80% 的"三个月后重现不出来"——这是绝大多数团队应该停下来的地方;L2 正式发版做内容寻址快照 + 保留期对齐追溯期 + 季度复现演练;L3 才上 DVC/lakeFS/Delta 和血缘平台。剩下 20% 才需要工具,而那 20% 的团队自己知道自己是谁。
下一节 👉 18-特征存储.md