📑 本页目录(点开跳转)
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:上线后 —— 持续监控特征分布
对每个重要特征,同时记录:
· 训练集上的分布(均值/分位数/缺失率/类别占比)
· 线上实时的分布
⭐ 任何一个特征的分布突然偏离训练集 → 告警
层次 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 正确性)、不解决什么、成本账、以及「决定不上」时的三条轻量替代 —— 本章讲怎么发现和挡住不一致,那边讲怎么从源头不产生它 |
✅ 检查点
- 为什么说这类 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 契约(列顺序校验)能挡住它;另外依赖版本本身就是特征定义的一部分,训练和推理必须锁死同一个镜像。
🛑 可以停在这里
⚡ 走神救援
⭐⭐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