🏠 总目录📚 本教程 17 · 版本、回溯与可复现
📑 本页目录(点开跳转)

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 只查缺失、不查多余 字段改名时会「静默走填充」,全错却不报错 missingextra 都要 raise
不记「走了哪条链路」 投诉排查时无从下手 每条日志一个字段,10 秒排除一大类问题
日志保留期短于标签延迟 标签到了,特征没了,重训和分析都做不了 ≥ 标签延迟 × 3
只对异常样本落全量特征 有坏样本没好样本,做不了对比 普通样本也要按 1%–5% 随机落
追求逐位复现 花几个月,做不到,还耽误正事 目标改成效果等价(指标差异在容忍阈值内)
数据靠 SQL 复现 同一个 SQL 明天跑结果就不一样 数据快照 + 哈希

🔗 八、和站内其他章的关系

相关的地方 和这一章的关系
第 3 章整体回滚 「发布单元」是它的前提
第 4 章特征顺序 feature_schema.json 是解法
第 7 章标签延迟 决定日志保留期
第 11 章排查 靠这些日志才查得动
Kaggle 25实验记录 竞赛版的同一件事
数据这一关 17 · 数据版本与血缘 ⭐⭐ 成对的另一半:本章管代码/参数/环境/模型权重,回答「这个预测是哪个模型版本给的」;那边只管数据,回答「这个模型是哪份数据训出来的」。四项缺一不可,是短板效应 —— ⚠️ 这条缝最容易两边都以为对方管了

✅ 检查点

  1. 日常最高频需要回溯的场景是哪个?没有版本记录会怎样?
  2. 「发布单元」包含哪五样?只记模型文件会出什么问题?
  3. 线上日志里「走了哪条链路」为什么极其有用?
  4. 可复现的三个层次是什么?大多数团队在哪层、需要的是哪层?为什么「重训出逐位相同的模型」很难?务实的目标是什么?
  5. 那个基础镜像升级的案例里,为什么升级当天什么都没暴露?代价是多少?最后是怎么被发现的?
  6. 黄金样本是什么?它能挡住哪些问题?选样本时有什么讲究?对不上时该 warn 还是 raise?
  7. feature_schema.json 解决了什么问题?为什么「多了不认识的特征」也必须报错?
  8. 预测日志该留多久?依据是什么?
  9. 全量特征日志的成本大概是什么量级?分层采样怎么分?为什么说「普通样本的随机采样比异常样本的全量更重要」?
👀 答案
  1. 「上个月还好好的」。没有版本记录时这句话根本无法验证——你连上个月跑的是什么都不知道。
  2. 模型文件、特征逻辑、预处理产物(编码器/归一化参数/分桶边界)、配置、依赖环境。只记模型文件会出第 3 章那个问题:回滚时特征逻辑还是新的,旧模型拿到没见过的特征,比不回滚还糟。
  3. 用户投诉「推荐很差」时,第一件事就是看他当时是不是走了降级链路——10 秒内排除掉一大类问题。
  4. ①能重新加载给出同样输出 ②能重新训练出等价模型 ③能解释当时为什么这么决策。大多数团队做到层次 1,需要的是层次 3;层次 2 成本最高但监管场景常强制要求。逐位复现难是因为随机性来源多:随机种子、多线程/分布式的浮点累加顺序(最难消除)、GPU 非确定性算子、数据源本身在变。务实目标是效果等价(同一验证集上指标差异在容忍阈值以内),不是逐位相同;最有效的一招是数据快照——把训练数据固化成带哈希的不可变文件,比让 SQL 可复现容易得多。
  5. 因为模型加载成功、预测不报错、分数看起来也正常,整体通过率从 31.2% 到 30.4% 在日常波动范围内;唯一的信号是一条 InconsistentVersionWarning级别只是 WARNING,淹没在每秒几千条日志里。真正变的是编码器对「训练时没见过的类别」从抛异常改成了静默编码成全 0,恰好那阵子上了两个新渠道 → 这批人渠道特征全 0 → 分数系统性偏低。代价:3 个月多拒约 1.4 万人,其中约 63% 是本该通过的优质客户。发现方式:一张申诉工单里客户说「我从另一个渠道申请就通过了」。教训是模型文件能加载 ≠ 模型行为不变
  6. 发布时把 200 条有代表性的样本 + 它们当时的预测值冻进发布单元,之后每次启动/升级依赖/改代码都重跑并逐条比对。它能挡住依赖升级、预处理产物丢失、特征顺序错位、模型文件损坏、加载了错版本——全在启动那一刻暴露。选样本不要随机取:要覆盖边界(各分数段、每个类别特征的每个取值至少一条、加上历史上出过问题的样本)。对不上必须 raise 拒绝启动,而不是打 warning
  7. 特征名、顺序、类型固化,推理时按它组装,从根本上杜绝第 4 章的「特征顺序错位」bug。「多了不认识的特征」必须报错,是因为上游把 user_age 改名成 age 时会同时缺一个、多一个;只查缺失的话 user_age 会走缺失值填充,一切照常运行,只是全错了
  8. 不少于标签延迟的 3 倍。否则标签到了却没有对应的特征数据可用于分析和重训。
  9. 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

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