🏠 总目录📚 本教程 04 · 训练/推理一致性
📑 本页目录(点开跳转)

04 · 训练/推理一致性

44 分钟 | ⭐⭐ 线上掉点最常见、也最难查的原因


🎯 一句话

训练时算特征用的是一套代码,线上算特征用的是另一套。 两套只要有一点点不一样,模型就会悄悄变差 —— 而且它不报错、不崩溃、延迟正常,只是答案不对。


💀 一、为什么这类 bug 最贵

   普通 bug:            这类 bug:
   报错 / 崩溃            静默
   一眼看见               要几周才被发现
   栈信息指向问题          没有任何线索
   修完就好               修完还要重训、还要评估影响范围

🔑 业界给它起了个名字:training-serving skew(训练-服务偏斜)。 在很多团队的事故复盘里,它是线上效果不及预期的第一大原因 —— 超过模型选型、超过调参、超过数据量。


🕳️ 二、七种最常见的不一致(按出现频率排)

① 同一个特征,两套代码

   训练:Spark SQL 里写的     AVG(price) OVER (last 7 days)
   线上:Java 服务里写的       sum/count 最近7天

   看起来一样,但:
   · 时区不同(UTC vs 本地)→ 「7 天」的边界差 8 小时
   · 空值处理不同(跳过 vs 当 0)
   · 「最近 7 天」是否含当天

这是最根本的一类,解法在第五节。

② 时间窗口对不齐

   训练时:用整天的数据算「用户当天点击数」
   线上时:请求发生在下午 3 点,只能拿到当天前 15 小时的数据

   → 训练时这个特征的均值是 20,线上是 8  💀
   → 模型学到的「>15 算活跃」在线上几乎永远不成立

③ 特征穿越(训练时用了未来)

   训练:用「订单最终状态」算特征 —— 但那是下单 3 天后才确定的
   线上:预测时订单刚创建,状态是「待支付」

   → 训练时模型学会了依赖一个线上根本拿不到的信息

🔗 这是ML基础 05 数据泄漏在生产环境的形态。 离线交叉验证完全发现不了它 —— 因为离线两边都有那个未来信息。

④ 类别编码不一致

   训练时:city 字段做 LabelEncoder,fit 在训练集上
           北京→0, 上海→1, 广州→2

   线上时:来了个「深圳」(训练集里没有)
   → 编码器行为不确定:报错 / 给个默认值 / 映射到 0(=北京)💀

   ⭐ 最坏的情况是映射到 0:模型把所有新城市当成北京,毫无察觉

⑤ 归一化参数不一致

   训练:用训练集的 mean/std 做标准化
   线上:忘了保存,用了【当前批次】的 mean/std  💀

   → 每个 batch 的归一化基准都不一样
   → batch=1 时更是灾难(std=0)

⑥ 默认值 / 缺失值处理不同

   训练:pandas 里 NaN 被 fillna(0)
   线上:Java 里 null 被当成 -1 或抛异常

⑦ 特征顺序错位

   训练时特征顺序:[age, income, city_id, ...]
   线上拼装时顺序:[income, age, city_id, ...]

   ⭐ 模型不会报错 —— 维度是对的,它照样算
   → 但每个权重都乘错了特征  💀💀

💀 第 ⑦ 条是所有里面最隐蔽的:形状对、类型对、没有异常, 但结果完全是垃圾。而且它的表现常常是「效果比随机好一点」, 容易被误判成「模型本身不行」。


🔬 三、怎么发现它(三个层次)

层次 1:上线前 —— 逐样本比对 ⭐⭐

   ① 取 1000 条真实线上请求
   ② 记录线上服务实际算出的特征向量
   ③ 用离线训练管道,对同一批样本再算一遍
   ④ 逐字段比对

   通过标准:【完全一致】,不是「差不多」
   浮点特征允许 1e-6 的误差,其余必须逐位相同
import numpy as np, pandas as pd

def compare_features(online: pd.DataFrame, offline: pd.DataFrame, tol=1e-6):
    """online / offline 必须是同样的样本、同样的列"""
    assert list(online.columns) == list(offline.columns), \
        f"列顺序不一致!{set(online.columns) ^ set(offline.columns)}"   # ⭐ 先查第⑦条
    report = []
    for col in online.columns:
        a, b = online[col], offline[col]
        if np.issubdtype(a.dtype, np.number):
            diff = (a - b).abs()
            bad = (diff > tol).sum()
        else:
            bad = (a.astype(str) != b.astype(str)).sum()
        if bad:
            report.append((col, bad, len(a), bad / len(a)))
    return pd.DataFrame(report, columns=["特征", "不一致数", "总数", "占比"]) \
             .sort_values("占比", ascending=False)

这一步就是第 3 章影子流量最有价值的用法。 它能把这七类问题一次全查出来。

层次 2:上线后 —— 持续监控特征分布

   对每个重要特征,同时记录:
   · 训练集上的分布(均值/分位数/缺失率/类别占比)
   · 线上实时的分布

   ⭐ 任何一个特征的分布突然偏离训练集 → 告警

🔗 具体怎么做在第 5 章第 6 章

层次 3:兜底 —— 定期回放

   每周取一批线上请求,用当时的特征重新跑一遍离线模型
   → 对比线上当时的预测值
   → 不一致说明两边又飘了

🛡️ 四、怎么根治:三种方案

方案 做法 代价 适合
① 一套代码两处跑 特征逻辑写成一个库,训练和线上都调它 需要统一技术栈 首选
② 特征平台 / Feature Store 特征统一计算存储,两边都从它取 基础设施投入大 团队规模够时
③ 日志即训练数据 ⭐⭐ 线上算完特征后原样落日志,直接用它训练 需要改造日志 最彻底

🔑 方案 ③ 值得单独说

   传统做法:
   线上算特征 → 预测 → 只记录结果
   训练时     → 用离线管道【重新算一遍】特征   ← 不一致就在这产生

   日志即训练数据:
   线上算特征 → 预测 → 【把特征向量原样落日志】⭐
   训练时     → 直接读日志里的特征向量,不重算

   ⭐ 从根本上消除了「两套代码」——因为只有一套

代价与限制(要诚实面对):

问题 说明
日志量大 每条请求要存完整特征向量 → 存储成本上升
改特征要等 新特征要先上线跑一段时间攒数据,才能用来训练 ⭐
历史数据没法用 改造之前的数据没有特征日志

💡 实践中常用混合方案核心的实时特征走日志,稳定的统计特征走离线。 关键是明确哪些特征归哪条路,别混着来。


✅ 五、可以照抄的检查清单

   代码层
   [ ] 特征逻辑只有一份实现(或有自动化的一致性测试)
   [ ] 编码器 / 归一化参数【和模型一起保存和加载】⭐
   [ ] 特征顺序由 schema 定义,不靠人工拼装 ⭐⭐
   [ ] 未见过的类别有明确、可观测的处理方式(不是静默映射到 0)

   数据层
   [ ] 每个特征都确认过:预测时点真的能拿到吗(防穿越)
   [ ] 时间窗口的定义两边一致(含不含当天、时区)

   验证层
   [ ] 上线前做过 1000 条样本的逐字段比对 ⭐
   [ ] 影子流量跑过至少一个业务周期
   [ ] 线上有特征分布监控

如果只能做一件事:做「逐字段比对」那一条。 它花半天,能挡掉这一章里绝大部分问题。


🕳️ 六、还有两种,教科书里几乎不提

⑧ 线上有"降级样本",训练数据里没有 ⭐⭐

   线上:特征服务 30ms 超时 → 返回默认值 → 模型照常预测
        这类请求占 2~5%(高峰期可能到 15%)

   训练:离线管道慢慢跑,【每个特征都拿得到】
        → 训练集里【一条降级样本都没有】  💀

   → 模型从来没见过「一半特征是默认值」的输入
   → 它在这些样本上的表现完全不可控
你以为 实际
降级样本只占 3%,影响不大 高峰期占 15%,而高峰期正是流量和收入最集中的时候
默认值填 0 就行 0 在很多特征上是有意义的取值,模型会当真信号用
训练时随便补一点就行 补的分布和线上不一样,等于制造了新的不一致

两个正确做法: 1. 在线上日志里打一个 is_degraded 标记,训练时把这类样本如实保留, 并分开评估这部分的指标。 2. 用一个明确的哨兵值(如 -999 或专门的 missing 类别)而不是 0, 让模型能学到"这是缺失"而不是"这个值是 0"。

⑨ 统计类特征的口径:全量 vs 滚动 ⭐

   训练:target encoding 用【整个训练集】算类别均值
        「北京」的历史转化率 = 3.2%(用了全部 180 天数据)

   线上:只能用【截止到此刻】的滚动统计
        「北京」的转化率 = 2.9%(用了最近 30 天)

   → 同一个类别,两边的编码值不一样
   → 而且【差多少取决于类别】:新类别差得最多  💀

⚠️ 这类不一致有个特别讨厌的性质它对高频类别影响小、对长尾类别影响大, 所以整体指标看不出来,只有长尾人群在悄悄变差。 对策:训练时也用滚动窗口算(time-aware target encoding),两边窗口长度一致。


🧾 七、把它变成工程约束:特征 schema 契约

前面所有问题的共同解法是一句话:别让人去"记住"特征该长什么样,让代码去检查。

import hashlib, json, numpy as np

# ⭐ 训练结束时,把 schema 和模型一起保存
def make_contract(columns, dtypes, encoders, scaler):
    spec = {
        "columns": list(columns),                       # ⭐ 顺序就是契约(挡第⑦条)
        "dtypes": {c: str(d) for c, d in zip(columns, dtypes)},
        "cat_vocab": {c: sorted(e.classes_.tolist()) for c, e in encoders.items()},
        "scaler_mean": scaler.mean_.tolist(),           # ⭐ 归一化参数必须一起存(挡第⑤条)
        "scaler_std": scaler.scale_.tolist(),
    }
    spec["fingerprint"] = hashlib.md5(
        json.dumps(spec, sort_keys=True).encode()).hexdigest()[:12]
    return spec


# ⭐ 线上每次构造特征向量时校验,不通过就【拒绝服务】而不是继续算
def enforce(vec_df, spec, strict=True):
    if list(vec_df.columns) != spec["columns"]:
        raise ValueError(f"特征顺序/集合不符:{set(vec_df.columns) ^ set(spec['columns'])}")
    bad = []
    for c, vocab in spec["cat_vocab"].items():
        unseen = set(vec_df[c].astype(str)) - set(vocab)
        if unseen:
            bad.append((c, list(unseen)[:5]))           # ⭐ 未见类别要显式暴露(挡第④条)
    if bad and strict:
        raise ValueError(f"出现未见过的类别:{bad}")
    return bad

fingerprint 这一行值得单独说把 schema 指纹写进预测日志。 事后排查时,你能立刻回答"这条预测是用哪个版本的特征定义算的" —— 而这个问题在没有指纹时,通常要查半天代码提交记录。 🔗 第 17 章会把它扩展成完整的版本体系。

⚠️ "不通过就拒绝服务"这个选择要想清楚

策略 好处 坏处 适合
抛异常,走降级 错误不会静默扩散 可用性下降 风控、金融、医疗
记日志 + 继续算 可用性高 回到了静默失效 💀 只在纯排序场景可考虑
抛异常但只抽样校验 折中,开销低 有漏网 高 QPS 场景 ⭐

💡 实践建议:第三种每 100 条校验 1 条,开销可以忽略, 而任何系统性的 schema 问题在几秒内就会被抽到

💀 一个真实的 schema 事故

   某风控模型,训练环境 sklearn 0.24 → 生产镜像升级到 1.1
   OneHotEncoder 的 categories_ 排序规则在版本间有差异
   → 同样的原始数据,one-hot 后的【列顺序变了】

   现象:
   · 服务不报错                     ← 维度一样
   · 预测分数分布几乎没变            ← 监控全绿 💀
   · 通过率从 88% 变成 87.6%         ← 在正常波动内

   两个月后:坏账率从 1.8% 涨到 4.1%   ← 标签延迟 60 天才暴露
   损失:两个月的坏账增量

三个可以带走的点: 1. 依赖版本本身就是特征定义的一部分 —— 训练和推理的库版本必须锁死(requirements.lock / 同一个镜像)。 2. 这个事故里所有监控都是绿的 —— 因为分布真的没变,变的只是列的对应关系只有 schema 契约(列顺序校验)能挡住它。 3. 标签延迟 60 天,意味着它至少烧掉两个月(🔗 第 7 章)。


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

相关的地方 和这一章的关系
ML基础 05数据泄漏 / Pipeline 不一致 ③ 的离线形态
ML基础 16特征工程 这里讲它的生产形态
Kaggle 25 那里说「训练和推理预处理要共用一个函数」,同一条
推荐算法 19时效性 时间窗口对齐的实战
第 3 章影子流量 发现它的主要手段
数据这一关 18 · 特征存储 ⭐ 上面方案 ② 的展开:Feature Store 到底解决什么(point-in-time 正确性)、解决什么、成本账、以及「决定不上」时的三条轻量替代 —— 本章讲怎么发现和挡住不一致,那边讲怎么从源头不产生

✅ 检查点

  1. 为什么说这类 bug 最贵?它的业界名字是什么?
  2. 七种不一致里,哪一种最隐蔽?为什么?
  3. 特征穿越为什么离线交叉验证发现不了?
  4. 类别编码不一致最坏的情况是什么?
  5. 「逐样本比对」的通过标准是什么?
  6. 「日志即训练数据」为什么能根治问题?它的三个代价是什么?
  7. 如果只能做一件事,该做哪件?
  8. 什么是「降级样本」?为什么说"只占 3% 影响不大"是错的?两个正确做法是什么?
  9. 统计类特征的口径不一致有什么讨厌的性质?为什么整体指标看不出来?
  10. schema 契约里的 fingerprint 有什么用?
  11. 校验不通过时三种策略各适合什么场景?高 QPS 时推荐哪种?
  12. sklearn 版本升级那个事故里,为什么所有监控都是绿的?什么能挡住它?
👀 答案
  1. 因为它静默——不报错不崩溃、延迟正常,只是答案不对;要几周才被发现,且没有任何线索。业界叫 training-serving skew(训练-服务偏斜),在很多团队复盘里是线上效果不及预期的第一大原因。
  2. 特征顺序错位。形状对、类型对、没有异常,模型照样算,但每个权重都乘错了特征;表现常是"比随机好一点",容易被误判成"模型本身不行"。
  3. 因为离线两边都有那个未来信息——训练集和验证集都包含了预测时点之后才产生的字段,交叉验证看不出差异。
  4. 静默映射到默认值 0(=训练集里的第一个类别)。模型把所有新城市当成北京,毫无察觉。
  5. 完全一致,不是"差不多"。浮点允许 1e-6 误差,其余必须逐位相同。
  6. 因为线上算完特征后原样落日志,训练直接读日志里的特征向量、不重算 —— 从根本上消除了"两套代码"。三个代价:日志量大改特征要等(新特征要先上线攒数据)、历史数据没法用
  7. 上线前做 1000 条样本的逐字段比对。花半天,能挡掉这一章绝大部分问题。
  8. 线上特征服务超时会返回默认值继续预测,这类样本占 2~5%,但训练集里一条都没有——模型从没见过"一半特征是默认值"的输入。"3% 影响不大"错在:高峰期能到 15%,而高峰期正是流量和收入最集中的时候。两个正确做法:①日志里打 is_degraded 标记,训练时如实保留并分开评估用明确的哨兵值(如 -999)而不是 0,因为 0 在很多特征上是有意义的取值。
  9. 训练用全量算 target encoding、线上只能用滚动窗口,差多少取决于类别,新类别差得最多。讨厌的性质是对高频类别影响小、对长尾影响大,所以整体指标看不出来,只有长尾人群在悄悄变差。对策:训练时也用滚动窗口,两边窗口长度一致。
  10. 把 schema 指纹写进预测日志,事后能立刻回答"这条预测是用哪个版本的特征定义算的"——没有它时这个问题通常要查半天提交记录。
  11. 抛异常走降级(风控/金融/医疗,错误不会静默扩散)、记日志继续算(回到静默失效,只在纯排序场景可考虑)、抽样校验 + 抛异常(高 QPS 推荐这种:每 100 条查 1 条,开销可忽略,任何系统性问题几秒内就会被抽到)。
  12. 因为分布真的没变,变的只是列的对应关系——维度一样、不报错、分数分布几乎不动、通过率只从 88% 到 87.6%(在正常波动内)。只有 schema 契约(列顺序校验)能挡住它;另外依赖版本本身就是特征定义的一部分,训练和推理必须锁死同一个镜像。

🛑 可以停在这里

走神救援

⭐⭐training-serving skew:训练一套代码算特征、线上另一套,一点不一样模型就悄悄变差——不报错不崩溃延迟正常,只是答案不对,很多团队复盘里是线上效果不及预期的第一大原因七种不一致:①同一特征两套代码(时区/空值/边界)②时间窗口对不齐(训练用整天、线上只有前15小时)③特征穿越(用了预测时拿不到的未来字段,离线CV完全发现不了因为两边都有)④类别编码(最坏是新类别静默映射到0,把所有新城市当成北京)⑤归一化参数没保存 ⑥缺失值处理不同 ⑦💀特征顺序错位——最隐蔽:形状类型都对、不报错,但每个权重乘错了特征,表现像"模型不行"。发现它:⭐⭐上线前取1000条真实请求,线上特征 vs 离线重算,逐字段比对,标准是完全一致不是差不多(这就是影子流量最有价值的用法)。根治三方案:一套代码两处跑 / 特征平台 / ⭐⭐日志即训练数据(线上算完特征原样落日志,训练直接读不重算——只有一套代码;代价:日志量大、改特征要等攒数据、历史数据没法用)。⭐只做一件事就做逐字段比对还有两种教科书不提的:⑧⭐⭐线上有"降级样本"训练数据里没有——特征服务超时返回默认值,占 2~5%(高峰期到 15%,而那正是收入最集中的时候),模型从没见过这种输入 → 日志打 is_degraded 标记如实保留 + 用哨兵值而不是 0;⑨统计特征口径(训练用全量 target encoding、线上用滚动窗口)——对高频类别影响小、长尾影响大,所以整体指标看不出来。⭐工程解法:特征 schema 契约(列顺序 + dtype + 类别词表 + 归一化参数 + fingerprint 写进预测日志),校验不通过就拒绝服务;高 QPS 用抽样校验(每 100 条查 1 条)。💀真实事故:sklearn 0.24→1.1,OneHotEncoder 列顺序变了 —— 不报错、分数分布几乎没变、监控全绿,两个月后(标签延迟)坏账率 1.8%→4.1%。教训:依赖版本本身就是特征定义的一部分,且只有列顺序校验能挡住它

下一节 👉 05-该监控什么.md

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