🏠 总目录📚 本教程 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:上线前 —— 逐样本比对

操作步骤

  1. 取 1000 条真实线上请求
  2. 记录线上服务实际算出的特征向量
  3. 用离线训练管道,对同一批样本再算一遍
  4. 逐字段比对
  5. 通过标准:【完全一致】,不是「差不多」
  6. 浮点特征允许 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 契约(列顺序校验)能挡住它;另外依赖版本本身就是特征定义的一部分,训练和推理必须锁死同一个镜像。

🛑 可以停在这里

⚡ 走神救援

⭐ 训练一套代码算特征、线上另一套,一点不一样模型就悄悄变差——⭐⭐ 不报错、不崩溃、延迟正常,只是答案不对。 很多团队复盘里,它是「线上不及预期」的头号原因。

九种不一致里,真正该记的是它们各自为什么发现不了:特征穿越用了预测时拿不到的字段——⚠️ 离线交叉验证完全发现不了,因为两边都有那个字段;新类别静默映射成 0——把所有没见过的取值当成同一个;💀 特征顺序错位最隐蔽——形状对、类型对、不报错,只是每个权重乘错了特征,表现得就像「模型不行」。

⭐⭐ 只做一件事的话就做这件:上线前取一千条真实请求,线上特征和离线重算逐字段比对,标准是完全一致、不是差不多。 这也正是影子流量最有价值的用法。

根治三条路里最彻底的是「日志即训练数据」:线上算完特征原样落日志,训练直接读、不重算——⭐ 因为只有一套代码。⚠️ 代价是日志量大、改特征要等攒数据、历史数据用不上。

两种教科书不提的:⭐ 线上有「降级样本」而训练数据里没有——特征服务超时返回默认值,占比不低,⚠️ 而高峰期占比更高,那正是收入最集中的时候;处方是如实打标记保留,并且用哨兵值而不是 0。另一种是统计口径不同,⭐ 它对高频类别影响小、长尾影响大,所以整体指标看不出来。

⭐ 工程解法是特征 schema 契约:列顺序、类型、类别词表、归一化参数,并把指纹写进预测日志,校验不过就拒绝服务。

💀 那次事故的形状:升了一个依赖的小版本,编码器的列顺序变了——不报错、分数分布几乎没变、监控全绿,而标签延迟让它两个月后才以坏账率翻倍的形式暴露。⭐ 教训两条:依赖版本本身就是特征定义的一部分,而且只有列顺序校验能挡住它。

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

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