📑 本页目录(点开跳转)
17 · 版本、回溯与可复现
⏱ 48 分钟 | 🎁 无聊但会在关键时刻救你
🎯 一句话
三个月后有人问「上个季度那个模型为什么给这个用户拒了」—— 你能答上来吗? 这一章讲的就是:让每一个历史决策都可以被还原。
🎬 一、什么时候你会需要它
| 场景 | 需要还原什么 |
|---|---|
| 用户申诉 / 客诉 | 当时那条预测的输入和依据 |
| 监管或审计问询 | 模型的训练数据、评估结果、决策逻辑 |
| 线上事故复盘 | 出问题时跑的是哪个版本、配置是什么 |
| 实验结果被质疑 | 当时的分流、数据、指标口径 |
| 「上个月还好好的」 | 两个时间点的差异到底在哪 ⭐ |
⭐ 最后一行是日常最高频的: 没有版本记录时,「上个月还好好的」这句话根本无法验证 —— 你连上个月跑的是什么都不知道。
📦 二、一次预测要能还原,需要记什么
⭐ 一个「发布单元」应该包含五样东西,版本号统一:
① 模型文件 (权重 + 结构)
② 特征逻辑 (代码版本 / 特征定义)
③ 预处理产物 (编码器、归一化参数、分桶边界)⭐
④ 配置 (阈值、截断、降级开关)
⑤ 依赖环境 (库版本,尤其是 sklearn/xgboost 大版本)
💀 只记①是最常见的错误 —— 回滚时会出第 3 章那个问题
线上每条预测要落的日志:
[ ] 请求 ID、时间戳、用户/实体 ID
[ ] 模型版本号 ⭐
[ ] 输入特征值(至少抽样落全量)⭐⭐
[ ] 预测输出(分数 + 最终决策)
[ ] 走了哪条链路(正常 / 降级 / 兜底)⭐
[ ] SHAP 值(抽样,第 10 章)
⭐ 「走了哪条链路」这一条极其有用: 用户投诉「推荐很差」时,第一件事就是看他当时是不是走了降级链路 —— 这能在 10 秒内排除掉一大类问题。
🔁 三、可复现的三个层次
层次 1:能【重新加载】那个模型,对同样输入给出同样输出
→ 存模型文件 + 环境即可 ✅ 最低要求
层次 2:能【重新训练】出一个等价的模型
→ 还要存:训练数据快照、代码版本、随机种子、超参 ⭐
层次 3:能【解释】当时为什么这么决策
→ 还要存:特征值、SHAP、当时的阈值配置
💡 大多数团队做到层次 1,需要的是层次 3。 层次 2 成本最高(要存数据快照),但监管场景常常强制要求。
⚠️ 关于「重新训练能得到同样的模型」
完全复现比想象的难,因为随机性来源很多:
· 随机种子(数据打乱、初始化、dropout)
· 多线程/分布式的【浮点累加顺序】⭐ —— 这条最难消除
· GPU 的非确定性算子
· 数据源本身在变(同一个 SQL 明天跑结果就不一样)⭐⭐
⭐ 务实的目标不是「逐位相同」,而是:
【效果等价】——重训出的模型在同一验证集上指标差异 < 容忍阈值
💡 数据快照是最有效的一招:把训练数据固化成一份不可变的文件(带哈希), 比试图让 SQL 可复现容易得多。
💀 四、一次基础镜像升级,悄悄多拒了 1.4 万人
这个案例说明了一件反直觉的事:模型文件能加载 ≠ 模型行为不变。
模型:信贷审批,2024 年训练,joblib 存的 sklearn Pipeline
2025 年 3 月:平台统一升级基础镜像(安全补丁)
sklearn 1.2 → 1.4,numpy 也跟着升了大版本
升级当天发生了什么:
| 现象 | 实际情况 |
|---|---|
| 模型加载 | ✅ 成功 |
| 预测 | ✅ 不报错,分数看起来也正常 |
| 日志 | ⚠️ 打了一条 InconsistentVersionWarning —— 级别是 WARNING,淹没在每秒几千条日志里 |
| 整体通过率 | 从 31.2% → 30.4%,在日常波动范围内 ⭐ |
真正变了的东西:
Pipeline 里的编码器,对「训练时没见过的类别」的处理方式变了
(原配置没有显式指定,吃了默认值的变化)
旧行为:抛异常 → 上游兜底走人工
新行为:静默编码成【全 0 行】 💀
→ 而恰好那段时间上了两个新渠道
→ 新渠道的 channel 值是训练时没见过的
→ 这批人的渠道特征全部变成全 0 → 分数系统性偏低 → 被拒
代价与发现方式:
持续 3 个月,多拒约 1.4 万人
事后回捞:其中约 63% 按老模型是该通过的优质客户
⭐ 最后是怎么被发现的?
一张申诉工单里客户写了一句:
「我从另一个渠道申请就通过了」
⭐⭐ 三条缺失,每一条都很便宜:
缺失 补法 没锁死依赖版本 发布单元里存 requirements.txt,加载时校验,不一致直接拒绝启动只有 WARNING,没有硬阻断 和模型正确性有关的检查,一律 raise,不要 warn ⭐ 通过率没有分渠道监控 新渠道上线时,渠道维度自动进监控(第 8 章)
🥇 最有效的一招:黄金样本(golden set)
发布时:把 200 条有代表性的样本 + 它们【当时的预测值】
一起冻进发布单元
每次启动 / 每次升级依赖 / 每次改代码:
→ 重跑这 200 条,逐条比对
→ 任何一条对不上,【拒绝启动】而不是告警 ⭐⭐
import json, numpy as np, pandas as pd
def freeze_golden(model, X_sample, out_path):
"""发布时跑一次:把样本和当时的预测冻起来"""
p = model.predict_proba(X_sample)[:, 1]
json.dump({"X": X_sample.to_dict("records"), "p": p.tolist()},
open(out_path, "w", encoding="utf-8"), ensure_ascii=False)
def verify_golden(model, path, atol=1e-6):
"""启动时跑一次:对不上就【拒绝启动】"""
g = json.load(open(path, encoding="utf-8"))
p = model.predict_proba(pd.DataFrame(g["X"]))[:, 1]
diff = np.abs(p - np.array(g["p"]))
if (diff > atol).any(): # ⭐ raise,不是 log.warning
raise RuntimeError(
f"黄金样本对不上:{(diff > atol).sum()}/{len(diff)} 条,最大偏差 {diff.max():.6f}")
⭐⭐ 这 20 行代码能挡住的东西比任何线上监控都多: 依赖升级、预处理产物丢失、特征顺序错位、模型文件损坏、加载了错版本 —— 全都在「启动那一刻」就暴露,而不是三个月后由客户告诉你。
💡 选样本的原则:不要随机取 200 条。要覆盖边界 —— 各个分数段各取一些、每个类别特征的每个取值至少一条、 再加上历史上出过问题的那些样本。
🗂️ 五、一个最小可用的版本方案
不需要上 MLflow 之类的平台也能做:
models/
2026-08-02_v17/
model.pkl 模型
preprocessor.pkl 编码器/归一化参数 ⭐
config.json 阈值等配置
feature_schema.json 特征名 + 顺序 + 类型 ⭐⭐
requirements.txt 环境
train_meta.json 数据范围、代码 commit、超参、随机种子
eval_report.json 验证集指标、分人群指标
MODEL_CARD.md 一页说明(第 19 章)
import json, hashlib, subprocess, joblib
from datetime import date
def save_release(model, preproc, config, schema, X_val, y_val, out_dir):
"""把一次发布的全部要素打包,版本号统一"""
import os; os.makedirs(out_dir, exist_ok=True)
joblib.dump(model, f"{out_dir}/model.pkl")
joblib.dump(preproc, f"{out_dir}/preprocessor.pkl") # ⭐ 别忘了它
json.dump(config, open(f"{out_dir}/config.json", "w"), ensure_ascii=False)
json.dump(schema, open(f"{out_dir}/feature_schema.json", "w"), ensure_ascii=False)
meta = {
"date": str(date.today()),
"git_commit": subprocess.run(["git", "rev-parse", "HEAD"],
capture_output=True, text=True).stdout.strip(),
"n_train": len(X_val), "seed": 42,
"data_hash": hashlib.md5(X_val.to_csv().encode()).hexdigest()[:12], # ⭐
}
json.dump(meta, open(f"{out_dir}/train_meta.json", "w"), ensure_ascii=False)
⭐
feature_schema.json是这里最容易被跳过、也最该有的一个文件: 它把特征名、顺序、类型固化下来, 推理时按它组装特征,就从根本上杜绝了第 4 章那个「顺序错位」的 bug。
它该怎么用(关键在最后那个 extra 检查):
import numpy as np
def assemble(raw: dict, schema: dict) -> np.ndarray:
"""⭐ 按 schema 组装特征,而不是按调用方传进来的顺序"""
missing = [c for c in schema["columns"] if c not in raw]
if missing:
raise KeyError(f"缺特征:{missing}")
extra = [c for c in raw if c not in schema["columns"]]
if extra:
raise KeyError(f"多了不认识的特征:{extra}") # ⭐⭐ 这一行最容易被省
row = []
for c, t in zip(schema["columns"], schema["dtypes"]):
v = raw[c]
if t == "float" and not isinstance(v, (int, float)):
raise TypeError(f"{c} 类型不对:{type(v).__name__}")
row.append(v)
return np.asarray(row, dtype=float).reshape(1, -1)
⚠️ 「多了不认识的特征」为什么必须报错: 静默忽略多余字段,看起来很宽容,实际上是第 4 章那类 bug 的温床 —— 上游把
user_age改名成age,你的代码会同时「缺 user_age」和「多 age」; 只查缺失的话,user_age会走缺失值填充,一切照常运行,只是全错了。
🕰️ 六、日志留多久、怎么落才不贵
| 数据 | 建议保留 | 理由 |
|---|---|---|
| 预测日志(含特征) | ≥ 标签延迟 × 3 ⭐ | 否则标签到了却没有对应的特征可用 |
| 模型文件 | ≥ 1 年 / 或按合规要求 | 回溯与审计 |
| 训练数据快照 | 按合规要求 | 通常最贵 |
| 聚合指标 | 尽量长期 | 便宜,且是趋势分析的基础 |
⭐ 第一行是个容易踩的坑: 标签延迟 3 个月,但日志只留 1 个月 —— 等标签到了,你已经没有当时的特征数据来做分析和重训了。 🔗 第 7 章量标签延迟,这里用它定日志保留期。
💰 「全量落特征」贵到什么程度
很多团队不落全量特征日志,理由是"太贵了"。先把账算出来再说:
一个 4000 QPS 的服务:
轻量版(版本号 + 分数 + 决策 + 链路 + 耗时)≈ 每条 200 字节
→ 4000 × 200B × 86400 ≈ 每天 69 GB ✅ 完全可以 100% 落
全量特征(假设 300 个特征)≈ 每条 4 KB
→ 100% 落 ≈ 每天 1.4 TB 💀 确实贵
→ 分层采样 3% 后 ≈ 每天 41 GB ⭐ 完全可以接受
⭐ 分层采样才是标准做法,不是"全落"或"不落"的二选一:
| 样本 | 落什么 | 比例 |
|---|---|---|
| 所有样本 | 轻量版(版本号、分数、决策、走了哪条链路、耗时) | 100% ⭐ |
| 异常样本(拒绝、高分、走了降级、命中规则) | 全量特征 | 100% ⭐⭐ |
| 普通样本 | 全量特征 | 1%–5% 随机 |
| 抽样样本 | SHAP 值(第 10 章) | 0.1%–1% |
💀 一个真实会犯的错:只对「异常样本」落全量特征。 结果做不了任何对比分析 —— 你有一堆"坏样本", 却没有同期的"好样本"当参照(第 11 章第 ⑤ 步要的就是这个对比)。 ⭐ 普通样本那 1%–5% 的随机采样,比异常样本的全量更重要。
⚠️ 七、一张坑表
| 坑 | 后果 | 怎么修 |
|---|---|---|
| 只把模型文件当发布单元 💀 | 回滚时特征逻辑还是新的,比不回滚还糟 | 发布单元包含五样,版本号统一 |
| 依赖版本没锁死 ⭐⭐ | 就是那个多拒 1.4 万人的案例 | 存 requirements.txt,加载时校验并拒绝启动 |
| 正确性检查只打 warning | 淹没在日志里,三个月没人看见 | 和正确性有关的,一律 raise ⭐ |
| 没有黄金样本自检 | 依赖升级、编码器丢失全都要靠线上指标去发现 | 20 行代码,启动时跑,对不上就不启动 ⭐⭐ |
| schema 只查缺失、不查多余 | 字段改名时会「静默走填充」,全错却不报错 | missing 和 extra 都要 raise |
| 不记「走了哪条链路」 | 投诉排查时无从下手 | 每条日志一个字段,10 秒排除一大类问题 |
| 日志保留期短于标签延迟 | 标签到了,特征没了,重训和分析都做不了 | ≥ 标签延迟 × 3 ⭐ |
| 只对异常样本落全量特征 | 有坏样本没好样本,做不了对比 | 普通样本也要按 1%–5% 随机落 ⭐ |
| 追求逐位复现 | 花几个月,做不到,还耽误正事 | 目标改成效果等价(指标差异在容忍阈值内) |
| 数据靠 SQL 复现 | 同一个 SQL 明天跑结果就不一样 | 数据快照 + 哈希 |
🔗 八、和站内其他章的关系
| 相关的地方 | 和这一章的关系 |
|---|---|
| 第 3 章整体回滚 | 「发布单元」是它的前提 |
| 第 4 章特征顺序 | feature_schema.json 是解法 |
| 第 7 章标签延迟 | 决定日志保留期 |
| 第 11 章排查 | 靠这些日志才查得动 |
| Kaggle 25实验记录 | 竞赛版的同一件事 |
| 数据这一关 17 · 数据版本与血缘 | ⭐⭐ 成对的另一半:本章管代码/参数/环境/模型权重,回答「这个预测是哪个模型版本给的」;那边只管数据,回答「这个模型是哪份数据训出来的」。四项缺一不可,是短板效应 —— ⚠️ 这条缝最容易两边都以为对方管了 |
✅ 检查点
- 日常最高频需要回溯的场景是哪个?没有版本记录会怎样?
- 「发布单元」包含哪五样?只记模型文件会出什么问题?
- 线上日志里「走了哪条链路」为什么极其有用?
- 可复现的三个层次是什么?大多数团队在哪层、需要的是哪层?为什么「重训出逐位相同的模型」很难?务实的目标是什么?
- 那个基础镜像升级的案例里,为什么升级当天什么都没暴露?代价是多少?最后是怎么被发现的?
- 黄金样本是什么?它能挡住哪些问题?选样本时有什么讲究?对不上时该 warn 还是 raise?
feature_schema.json解决了什么问题?为什么「多了不认识的特征」也必须报错?- 预测日志该留多久?依据是什么?
- 全量特征日志的成本大概是什么量级?分层采样怎么分?为什么说「普通样本的随机采样比异常样本的全量更重要」?
👀 答案
- 「上个月还好好的」。没有版本记录时这句话根本无法验证——你连上个月跑的是什么都不知道。
- 模型文件、特征逻辑、预处理产物(编码器/归一化参数/分桶边界)、配置、依赖环境。只记模型文件会出第 3 章那个问题:回滚时特征逻辑还是新的,旧模型拿到没见过的特征,比不回滚还糟。
- 用户投诉「推荐很差」时,第一件事就是看他当时是不是走了降级链路——10 秒内排除掉一大类问题。
- ①能重新加载给出同样输出 ②能重新训练出等价模型 ③能解释当时为什么这么决策。大多数团队做到层次 1,需要的是层次 3;层次 2 成本最高但监管场景常强制要求。逐位复现难是因为随机性来源多:随机种子、多线程/分布式的浮点累加顺序(最难消除)、GPU 非确定性算子、数据源本身在变。务实目标是效果等价(同一验证集上指标差异在容忍阈值以内),不是逐位相同;最有效的一招是数据快照——把训练数据固化成带哈希的不可变文件,比让 SQL 可复现容易得多。
- 因为模型加载成功、预测不报错、分数看起来也正常,整体通过率从 31.2% 到 30.4% 在日常波动范围内;唯一的信号是一条
InconsistentVersionWarning,级别只是 WARNING,淹没在每秒几千条日志里。真正变的是编码器对「训练时没见过的类别」从抛异常改成了静默编码成全 0,恰好那阵子上了两个新渠道 → 这批人渠道特征全 0 → 分数系统性偏低。代价:3 个月多拒约 1.4 万人,其中约 63% 是本该通过的优质客户。发现方式:一张申诉工单里客户说「我从另一个渠道申请就通过了」。教训是模型文件能加载 ≠ 模型行为不变。 - 发布时把 200 条有代表性的样本 + 它们当时的预测值冻进发布单元,之后每次启动/升级依赖/改代码都重跑并逐条比对。它能挡住依赖升级、预处理产物丢失、特征顺序错位、模型文件损坏、加载了错版本——全在启动那一刻暴露。选样本不要随机取:要覆盖边界(各分数段、每个类别特征的每个取值至少一条、加上历史上出过问题的样本)。对不上必须 raise 拒绝启动,而不是打 warning。
- 把特征名、顺序、类型固化,推理时按它组装,从根本上杜绝第 4 章的「特征顺序错位」bug。「多了不认识的特征」必须报错,是因为上游把
user_age改名成age时会同时缺一个、多一个;只查缺失的话user_age会走缺失值填充,一切照常运行,只是全错了。 - 不少于标签延迟的 3 倍。否则标签到了却没有对应的特征数据可用于分析和重训。
- 4000 QPS 下:轻量版每条约 200 字节 → 每天约 69 GB,可以 100% 落;全量特征每条约 4 KB → 100% 落是每天约 1.4 TB,采样 3% 后约 41 GB 就很可接受。分层:所有样本落轻量版 100%;异常样本(拒绝/高分/走降级/命中规则)落全量 100%;普通样本按 1%–5% 随机落全量;SHAP 再抽 0.1%–1%。普通样本的随机采样更重要,因为只有异常样本的话你有一堆"坏样本"却没有同期的"好样本"当参照,第 11 章第 ⑤ 步的对比分析根本做不了。
🛑 可以停在这里
⚡ 走神救援
⭐日常最高频的回溯需求是「上个月还好好的」——没版本记录时这句话根本无法验证。发布单元五样:模型文件、特征逻辑、⭐预处理产物(编码器/归一化/分桶边界)、配置、环境;只记模型文件回滚时会出第3章那个问题。线上日志要落:模型版本号、输入特征值、⭐走了哪条链路(正常/降级/兜底)——投诉时10秒排除一大类问题。可复现三层次:能重新加载 / 能重新训练出等价模型 / 能解释当时为什么这么决策;⭐大多数团队做到第1层,需要的是第3层。⚠️逐位复现很难(随机种子、多线程浮点累加顺序、GPU非确定性算子、数据源本身在变)→ ⭐务实目标是「效果等价」不是逐位相同,数据快照(带哈希的不可变文件)是最有效的一招。⭐⭐feature_schema.json 最容易跳过也最该有——固化特征名/顺序/类型,从根本杜绝第4章的顺序错位bug。⭐预测日志要留 ≥ 标签延迟×3,否则标签到了却没有当时的特征可用。💀真实案例:平台统一升级基础镜像(sklearn 1.2→1.4),模型加载成功、预测不报错、整体通过率 31.2%→30.4% 在正常波动内,唯一信号是一条
InconsistentVersionWarning——级别只是 WARNING,淹没在每秒几千条日志里;实际变化是编码器对「训练时没见过的类别」从抛异常改成静默编码成全 0,而那阵子刚上了两个新渠道 → 这批人分数系统性偏低 → 3 个月多拒约 1.4 万人,其中 63% 是本该通过的优质客户;最后是一张申诉工单里客户说"我从另一个渠道申请就通过了"才被发现。⭐⭐记住这句:模型文件能加载 ≠ 模型行为不变。 🥇最有效的一招是黄金样本:发布时把 200 条覆盖边界的样本 + 当时的预测值冻进发布单元,每次启动/升级依赖都重跑逐条比对,对不上就 raise 拒绝启动(不是 warning)——20 行代码挡住依赖升级、编码器丢失、特征顺序错位、模型损坏、加载错版本,全在启动那一刻暴露。⚠️schema 校验里「多了不认识的特征」也必须报错:上游把user_age改名成age会同时缺一个多一个,只查缺失的话就走缺失值填充,一切照常运行只是全错了。💰日志成本先算再说:4000 QPS 下轻量版每天约 69 GB(可 100% 落)、全量特征 100% 落是每天 1.4 TB、采样 3% 后只有 41 GB;⭐分层采样才是标准做法:全部样本落轻量版、异常样本落全量、普通样本按 1%–5% 随机落全量——最后这条最容易被砍掉,但没有同期的"好样本"当参照,第 11 章的对比分析就做不了。
下一节 👉 18-成本与容量.md