📑 本页目录(点开跳转)
04 · 训练/推理一致性
⏱ 44 分钟 | ⭐ 线上掉点最常见、也最难查的原因
🎯 一句话
训练时算特征用的是一套代码,线上算特征用的是另一套。 两套只要有一点点不一样,模型就会悄悄变差 —— 而且它不报错、不崩溃、延迟正常,只是答案不对。
💀 一、为什么这类 bug 最贵
对照
普通 bug: 这类 bug:
报错 / 崩溃 静默
一眼看见 要几周才被发现
栈信息指向问题 没有任何线索
修完就好 修完还要重训、还要评估影响范围
🔑 业界给它起了个名字:training-serving skew(训练-服务偏斜)。 在很多团队的事故复盘里,它是线上效果不及预期的第一大原因 —— 超过模型选型、超过调参、超过数据量。
🕳️ 二、七种最常见的不一致(按出现频率排)
① 同一个特征,两套代码
结果对照
⭐ 这是最根本的一类,解法在第五节。
② 时间窗口对不齐
关键信息
③ 特征穿越(训练时用了未来)
关键信息
🔗 这是ML基础 05 数据泄漏在生产环境的形态。 离线交叉验证完全发现不了它 —— 因为离线两边都有那个未来信息。
④ 类别编码不一致
信息关系
⑤ 归一化参数不一致
关键信息
⑥ 默认值 / 缺失值处理不同
对照
训练:pandas 里 NaN 被 fillna(0)
线上:Java 里 null 被当成 -1 或抛异常
⑦ 特征顺序错位
关键信息
💀 第 ⑦ 条是所有里面最隐蔽的:形状对、类型对、没有异常, 但结果完全是垃圾。而且它的表现常常是「效果比随机好一点」, 容易被误判成「模型本身不行」。
🔬 三、怎么发现它(三个层次)
层次 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:上线后 —— 持续监控特征分布
信息关系
层次 3:兜底 —— 定期回放
结果对照
🛡️ 四、怎么根治:三种方案
| 方案 | 做法 | 代价 | 适合 |
|---|---|---|---|
| ① 一套代码两处跑 ⭐ | 特征逻辑写成一个库,训练和线上都调它 | 需要统一技术栈 | 首选 |
| ② 特征平台 / Feature Store | 特征统一计算存储,两边都从它取 | 基础设施投入大 | 团队规模够时 |
| ③ 日志即训练数据 ⭐ | 线上算完特征后原样落日志,直接用它训练 | 需要改造日志 | 最彻底 |
🔑 方案 ③ 值得单独说
因果链
代价与限制(要诚实面对):
| 问题 | 说明 |
|---|---|
| 日志量大 | 每条请求要存完整特征向量 → 存储成本上升 |
| 改特征要等 | 新特征要先上线跑一段时间攒数据,才能用来训练 ⭐ |
| 历史数据没法用 | 改造之前的数据没有特征日志 |
💡 实践中常用混合方案:核心的实时特征走日志,稳定的统计特征走离线。 关键是明确哪些特征归哪条路,别混着来。
📋 五、可以照抄的检查清单
逐项核对
代码层
特征逻辑只有一份实现(或有自动化的一致性测试)
编码器 / 归一化参数【和模型一起保存和加载】⭐
特征顺序由 schema 定义,不靠人工拼装 ⭐
未见过的类别有明确、可观测的处理方式(不是静默映射到 0)
数据层
每个特征都确认过:预测时点真的能拿到吗(防穿越)
时间窗口的定义两边一致(含不含当天、时区)
验证层
上线前做过 1000 条样本的逐字段比对 ⭐
影子流量跑过至少一个业务周期
线上有特征分布监控
⭐ 如果只能做一件事:做「逐字段比对」那一条。 它花半天,能挡掉这一章里绝大部分问题。
🕳️ 六、还有两种,教科书里几乎不提
⑧ 线上有"降级样本",训练数据里没有
信息关系
| 你以为 | 实际 |
|---|---|
| 降级样本只占 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 事故
结果对照
⭐ 三个可以带走的点: 1. 依赖版本本身就是特征定义的一部分 —— 训练和推理的库版本必须锁死(
requirements.lock/ 同一个镜像)。 2. 这个事故里所有监控都是绿的 —— 因为分布真的没变,变的只是列的对应关系。 只有 schema 契约(列顺序校验)能挡住它。 3. 标签延迟 60 天,意味着它至少烧掉两个月(🔗 第 7 章)。
🔗 八、和站内其他章的关系
| 相关的地方 | 和这一章的关系 |
|---|---|
| ML基础 05数据泄漏 / Pipeline | 不一致 ③ 的离线形态 |
| ML基础 16特征工程 | 这里讲它的生产形态 |
| Kaggle 25 | 那里说「训练和推理预处理要共用一个函数」,同一条 |
| 推荐算法 19时效性 | 时间窗口对齐的实战 |
| 第 3 章影子流量 | 发现它的主要手段 |
| 数据这一关 18 · 特征存储 | ⭐ 上面方案 ② 的展开:Feature Store 到底解决什么(point-in-time 正确性)、不解决什么、成本账、以及「决定不上」时的三条轻量替代 —— 本章讲怎么发现和挡住不一致,那边讲怎么从源头不产生它 |
✅ 检查点
- 为什么说这类 bug 最贵?它的业界名字是什么?
- 七种不一致里,哪一种最隐蔽?为什么?
- 特征穿越为什么离线交叉验证发现不了?
- 类别编码不一致最坏的情况是什么?
- 「逐样本比对」的通过标准是什么?
- 「日志即训练数据」为什么能根治问题?它的三个代价是什么?
- 如果只能做一件事,该做哪件?
- 什么是「降级样本」?为什么说"只占 3% 影响不大"是错的?两个正确做法是什么?
- 统计类特征的口径不一致有什么讨厌的性质?为什么整体指标看不出来?
- schema 契约里的
fingerprint有什么用? - 校验不通过时三种策略各适合什么场景?高 QPS 时推荐哪种?
- sklearn 版本升级那个事故里,为什么所有监控都是绿的?什么能挡住它?
👀 答案
- 因为它静默——不报错不崩溃、延迟正常,只是答案不对;要几周才被发现,且没有任何线索。业界叫 training-serving skew(训练-服务偏斜),在很多团队复盘里是线上效果不及预期的第一大原因。
- 特征顺序错位。形状对、类型对、没有异常,模型照样算,但每个权重都乘错了特征;表现常是"比随机好一点",容易被误判成"模型本身不行"。
- 因为离线两边都有那个未来信息——训练集和验证集都包含了预测时点之后才产生的字段,交叉验证看不出差异。
- 静默映射到默认值 0(=训练集里的第一个类别)。模型把所有新城市当成北京,毫无察觉。
- 完全一致,不是"差不多"。浮点允许 1e-6 误差,其余必须逐位相同。
- 因为线上算完特征后原样落日志,训练直接读日志里的特征向量、不重算 —— 从根本上消除了"两套代码"。三个代价:日志量大、改特征要等(新特征要先上线攒数据)、历史数据没法用。
- 上线前做 1000 条样本的逐字段比对。花半天,能挡掉这一章绝大部分问题。
- 线上特征服务超时会返回默认值继续预测,这类样本占 2~5%,但训练集里一条都没有——模型从没见过"一半特征是默认值"的输入。"3% 影响不大"错在:高峰期能到 15%,而高峰期正是流量和收入最集中的时候。两个正确做法:①日志里打
is_degraded标记,训练时如实保留并分开评估 ②用明确的哨兵值(如 -999)而不是 0,因为 0 在很多特征上是有意义的取值。 - 训练用全量算 target encoding、线上只能用滚动窗口,差多少取决于类别,新类别差得最多。讨厌的性质是对高频类别影响小、对长尾影响大,所以整体指标看不出来,只有长尾人群在悄悄变差。对策:训练时也用滚动窗口,两边窗口长度一致。
- 把 schema 指纹写进预测日志,事后能立刻回答"这条预测是用哪个版本的特征定义算的"——没有它时这个问题通常要查半天提交记录。
- 抛异常走降级(风控/金融/医疗,错误不会静默扩散)、记日志继续算(回到静默失效,只在纯排序场景可考虑)、抽样校验 + 抛异常(高 QPS 推荐这种:每 100 条查 1 条,开销可忽略,任何系统性问题几秒内就会被抽到)。
- 因为分布真的没变,变的只是列的对应关系——维度一样、不报错、分数分布几乎不动、通过率只从 88% 到 87.6%(在正常波动内)。只有 schema 契约(列顺序校验)能挡住它;另外依赖版本本身就是特征定义的一部分,训练和推理必须锁死同一个镜像。
🛑 可以停在这里
⚡ 走神救援
⭐ 训练一套代码算特征、线上另一套,一点不一样模型就悄悄变差——⭐⭐ 不报错、不崩溃、延迟正常,只是答案不对。 很多团队复盘里,它是「线上不及预期」的头号原因。
九种不一致里,真正该记的是它们各自为什么发现不了:特征穿越用了预测时拿不到的字段——⚠️ 离线交叉验证完全发现不了,因为两边都有那个字段;新类别静默映射成 0——把所有没见过的取值当成同一个;💀 特征顺序错位最隐蔽——形状对、类型对、不报错,只是每个权重乘错了特征,表现得就像「模型不行」。
⭐⭐ 只做一件事的话就做这件:上线前取一千条真实请求,线上特征和离线重算逐字段比对,标准是完全一致、不是差不多。 这也正是影子流量最有价值的用法。
根治三条路里最彻底的是「日志即训练数据」:线上算完特征原样落日志,训练直接读、不重算——⭐ 因为只有一套代码。⚠️ 代价是日志量大、改特征要等攒数据、历史数据用不上。
两种教科书不提的:⭐ 线上有「降级样本」而训练数据里没有——特征服务超时返回默认值,占比不低,⚠️ 而高峰期占比更高,那正是收入最集中的时候;处方是如实打标记保留,并且用哨兵值而不是 0。另一种是统计口径不同,⭐ 它对高频类别影响小、长尾影响大,所以整体指标看不出来。
⭐ 工程解法是特征 schema 契约:列顺序、类型、类别词表、归一化参数,并把指纹写进预测日志,校验不过就拒绝服务。
💀 那次事故的形状:升了一个依赖的小版本,编码器的列顺序变了——不报错、分数分布几乎没变、监控全绿,而标签延迟让它两个月后才以坏账率翻倍的形式暴露。⭐ 教训两条:依赖版本本身就是特征定义的一部分,而且只有列顺序校验能挡住它。
下一节 👉 05-该监控什么.md