📑 本页目录(点开跳转)
18 · 特征存储
⏱ 62 分钟 | ⭐ 大多数团队不需要它,但所有人都需要它解决的那一个问题
🎯 一句话
Feature Store 解决的是「同一个特征在四个地方被算了四遍」,不是「你的特征不够好」。 它是一个纪律工具,不是一个能力工具 —— ⭐ 而且它最值钱的那条能力(point-in-time 正确性),你可以用 30 行代码单独拿到,不用上整套系统。
🏗️ 一、它到底是什么
先看没有它的时候,一个特征是怎么被算两遍的:
离线路径:数仓 ──► Spark SQL ──► 训练样本 (算第一遍)
↑ 数据工程师写,Python/SQL,每天凌晨跑
在线路径:请求 ──► 特征服务 ──► 推理输入 (算第二遍)
↑ 后端工程师写,Java/Go,毫秒级
💀 两条路径由【不同的人】、用【不同的语言】、在【不同的时间】写成,
而且要一直保持语义完全一致。
⭐ 这在工程上不是"很难",是"不可能长期成立" ——
今天一致,下个季度有人改了其中一条,就不一致了。
Feature Store 做的四件事:
① 特征定义只写一次(一份 YAML / 一段 Python)
② 离线管道和在线服务【都从这一份定义生成】,不再各写各的
③ 离线存历史全量(offline store),在线存最新值(online store)
④ ⭐⭐ 训练取数时按【事件时间】回溯,而不是取"现在的值"
—— 这一条叫 point-in-time 正确性,是全章最值钱的东西
⭐⭐ 二、它解决什么(1):point-in-time 正确性
这条直接连着第 14 章的数据泄漏,而且它是那一章里最难自查的一种。
要预测:用户在 03-10 14:23 的这次下单会不会退货
用到的特征:user_return_rate_90d(近 90 天退货率)
❌ 99% 的手写 SQL 是这么写的:
SELECT uid, ... FROM orders WHERE order_time >= date_sub(now(), 90)
—— 这段 SQL 的执行时刻是【今天】,
算出来的是"截至今天"的退货率,
💀 对于 6 个月前的训练样本,它包含了那之后 6 个月的退货记录
—— 标签本身漏进了特征
✅ point-in-time join:
对每一行训练样本,取【该样本事件时间之前】最后一次可见的特征值
画成时间轴就非常清楚:
user A 的 return_rate_90d 取值变化:
t1 = 03-01 04:00 值 = 0.03
t2 = 03-08 04:00 值 = 0.05
t3 = 03-12 04:00 值 = 0.19 ← 这次退货发生在样本之后
↑
样本事件时间 03-10 14:23
✅ 正确取值 = 0.05 (t2 那一版,t3 那时还不存在)
❌ 常见错误 = 0.19 (直接取"最新值")—— 这就是穿越
⭐⭐ 还有一个更隐蔽的坑:时间戳有两个,不是一个。
event_time= 这个值描述的时刻("03-08 的退货率")available_time= 这个值在线上真正能被读到的时刻(凌晨批任务跑完,比如 03-09 04:00) point-in-time join 必须按available_time对齐。 💀 按event_time对齐,你复现的是一个线上根本不存在的世界 —— 离线模型能看到"03-08 当天的值",线上模型要等到第二天凌晨才看得到。 这个偏差通常有 4–24 小时,足以让离线指标虚高好几个点。
一个可以直接抄的实现:
import pandas as pd
def point_in_time_join(samples, features, entity="uid",
sample_ts="event_time", feat_ts="available_time"):
"""samples: 训练样本(实体 + 事件时间 + 标签)
features: 特征快照流(实体 + available_time + 各特征列)
⭐ features 的时间列必须是【可用时间】,不是【业务时间】"""
s = samples.sort_values(sample_ts)
f = features.sort_values(feat_ts)
out = pd.merge_asof(
s, f,
left_on=sample_ts, right_on=feat_ts,
by=entity,
direction="backward", # ⭐ 只向过去找,绝不向未来找
allow_exact_matches=False, # ⭐⭐ 同一时刻不算"已可用",宁可保守
)
out["_feature_age_h"] = (
(out[sample_ts] - out[feat_ts]).dt.total_seconds() / 3600
) # ⭐⭐ 特征新鲜度:这一列是免费的体检报告,见下面的判读
return out
# ⭐⭐ 判读 _feature_age_h:
# · 中位数应该 ≈ 你的批任务周期的一半(日批 → 约 12 小时)
# · 出现【负数】 → 未来信息穿越,立刻停
# · 大量 NaN → 这些样本在事件时刻根本没有特征,线上会走默认值,
# ⭐ 训练时也必须让它们走默认值,否则又是一种训推不一致
⭐
_feature_age_h这一列的价值被严重低估: 它把「有没有穿越」从一个需要推理的问题,变成一个看直方图就能回答的问题。 有负数 = 穿越;分布比预期窄太多 = 你用了线上拿不到的新鲜度。
🧩 三、它解决什么(2–4)
| 它解决的 | 具体是什么 | 没有它的时候什么样 |
|---|---|---|
| 训练/推理口径统一 | 一份定义生成两条路径 | 💀 "近 30 天"离线含当天、在线不含当天,差一天没人发现 |
| 特征复用 | 别人定义好的特征直接引用 | 三个团队各自算了一遍"用户 7 日活跃度",三个值都不一样 |
| 在线离线物理一致 | 在线库的值由离线管道单向派生,不双写 | 双写系统必然会漂,且漂了不报错 |
关于"特征复用",先算一个数再说:
特征复用率 = 被 ≥ 2 个模型使用的特征数 / 特征总数
< 20% → ⚠️ 复用不是你的问题,别拿它当上 Feature Store 的理由
20%~50% → 有价值,但一份共享的 SQL 库 + 代码 review 也能解决
> 50% → ⭐ 这时候统一存储才真的开始省人力
⚠️ "特征复用"是 Feature Store 最常被拿来当理由、也最常落空的一条。 现实里大多数团队的复用率不到 15% —— 因为不同模型的实体粒度、时间窗、口径本来就不一样。 ⭐ 别为一个你还没有的问题建系统。
🚫 四、它不解决什么
这一节比上面两节更重要,因为期望错了,上了也会失败。
| 它不会 | 为什么 |
|---|---|
| 不会让你的特征变好 ⭐ | 它只保证"你定义的东西被正确、一致地算出来"。垃圾特征会被更快、更一致地送到两条路径上 |
| 不会替你定义口径 ⭐⭐ | "活跃用户"到底是什么?这是特征工程里最难也最贵的一步,它一个字都帮不上 |
| 不会自动发现漂移 | 它存值,不判断值。漂移监控是完全独立的一套东西 |
| 不会解决数据质量 | 上游的空值、重复、延迟,它原样传递 —— 前面 04–08 章的工作一件都省不掉 |
而且它会带来四个新问题:
① 多一层 = 多一个故障点
⭐ 在线特征服务挂了,你的模型服务跟着挂,
而它的 SLA 要求比模型服务【更高】
② 在线存储成本是真金白银
1000 万实体 × 200 个特征 × 8 字节 ≈ 16 GB 原始
加 key 开销、索引、副本 → ⭐ 实际需要约 50 GB Redis 内存
③ 特征"死不掉" 💀
一个特征定义被 3 个模型引用之后,就没有人敢删了
⭐ 上线第一天就要有"引用计数 + 下线流程",否则两年后你有 4000 个特征,
其中 3000 个没人知道是干什么的
④ 调试链路变长
原来"查一下这个特征怎么算的"是打开一个 SQL 文件,
现在是:定义仓库 → 物化任务 → 离线表 → 同步任务 → 在线库 → SDK
🛑 读到这里可以停 —— 前半章讲完了(约 17 分钟)。 后半章还有:成本账 · ⭐ 该不该上:一张判断清单 · 一次真实的事故复盘 · 如果你决定不上:三条轻量替代 回来的时候不用重读,直接从下一节接着看就行。
💰 五、成本账
| 方案 | 一次性投入 | 长期投入 | 什么时候合适 |
|---|---|---|---|
| 不上,写规范 + 库函数 | 3–5 人日 ⭐ | 几乎为零 | ⭐⭐ 绝大多数团队 |
| 开源自建(Feast 一类) | 1–2 人 × 2–3 个月 | 0.5 人长期 | 模型 ≥ 10 个、有在线服务 |
| 完全自研 | 2–3 人 × 6 个月起 | 1–2 人长期 | 有非常特殊的延迟或规模要求 |
| 云托管 | 1 人 × 1 个月 | 按量付费 | 已经全家桶在某朵云上 |
在线取数的延迟预算,是最容易被低估的一项:
模型服务的 P99 预算 100 ms
├ 网络 + 反序列化 15 ms
├ ⭐ 特征取数 ??
├ 模型推理 25 ms
└ 后处理 + 序列化 10 ms
⭐ 特征取数的 P99 必须压在 10~20 ms 以内,
而这是【一次取 200 个特征】的 P99,不是单 key 的 P99。
⚠️ 单 key 1 ms 不代表 200 key 200 ms —— 但也绝不是 1 ms,
必须按真实批量大小压测。这一项没压测过就上线,是最常见的翻车点。
🧭 六、⭐ 该不该上:一张判断清单
每条符合记 1 分。
[ ] 线上同时跑着 ≥ 10 个模型
[ ] 有【在线实时】推理服务(不是只有离线批量打分)
[ ] 特征复用率 > 50%
[ ] 过去半年出过 ≥ 2 次训练/推理不一致的线上事故 ⭐ 权重最高的一条
[ ] 有 ≥ 2 个团队在共享特征
[ ] 需要严格的 point-in-time 回溯(金融、风控、受监管场景)
[ ] 有至少 1 个人可以长期负责它的运维
[ ] 特征总数 > 300 且还在快速增长
| 得分 | 结论 |
|---|---|
| 0–2 分 | ⭐⭐ 不要上。 用第七节的三条轻量替代,一周搞定 |
| 3–5 分 | ⚠️ 先只做 point-in-time join + 特征定义 YAML,半年后再评估 |
| 6–8 分 | ✅ 可以上,优先开源方案;但要先把"最后一条"落实(没人维护就别上) |
⭐⭐ 注意清单里权重最高的是"出过几次训推不一致事故": Feature Store 是一个为已经发生过的疼痛买的保险。 没疼过就买,你买到的只是一个新的运维负担。 💀 而只有 3 个模型、还没有在线服务的团队上 Feature Store, 通常会在半年后把它悄悄下掉 —— 因为维护它的人力,超过了它省下的人力。
💀 七、一次真实的事故复盘
系统:某电商的「退货风险」模型,决定是否给用户【极速退款】资格
特征:37 个用户侧聚合特征,用 Spark SQL 从数仓算
训练样本:跨 6 个月,约 480 万条
发生了什么
| 阶段 | 事件 |
|---|---|
| 离线 | AUC 0.83,团队非常满意,直接全量上线 |
| 上线 | 线上实测 AUC 只有 0.68 ⭐ 差了 0.15 |
| 排查 | 3 个人查了 2 周 |
| 根因 | user_return_rate_90d 在训练时用的是"截至跑数当天"的值,而训练样本跨了 6 个月 💀 |
| 也就是 | 对 6 个月前的样本,这个特征包含了那之后 6 个月的退货记录 —— 标签漏进了特征 |
为什么没被发现
① 离线【测试集】也是同一份表算出来的
→ 测试集带着完全一样的穿越
⭐ 于是离线评估不但看不出问题,还会【奖励】这个穿越特征
(它的特征重要性排第 2)
② 特征名叫 "user_return_rate_90d",所有人都以为
"90d" 是【相对样本时间】的 90 天
💀 实际上是【相对跑数时间】的 90 天 —— 名字没说清 as_of 语义
③ ⭐⭐ 最反直觉的一点:在线服务是对的
在线是另一个团队用 Java 从 Redis 读【当前值】,
逻辑上恰好等价于正确的 point-in-time 取值。
→ 所以这不是"线上错了",是【离线错了、线上对了】
→ 表现为"线上比离线差",团队一开始一直往
"线上环境有问题"的方向查,浪费了大半时间
代价
离线 AUC 0.83 线上 AUC 0.68
修复后重训 离线 0.71(掉了 0.12,这 0.12 全是穿越)
线上 0.70 ⭐ 离线线上终于对上了
⭐ 也就是说:那个"很好的模型"从来不存在,
真实能力一直是 0.70 左右
业务损失:
错误发放极速退款资格 → 6 周内欺诈性退货
直接损失 ≈ 210 万元
排查成本 3 人 × 2 周
全量重训 + 灰度重来 3 周
该补什么
| 缺失 | 补上之后 |
|---|---|
| 手写聚合 SQL 💀 | ⭐ point-in-time join 收敛成一个库函数,禁止在训练管道里手写时间窗聚合 |
| 特征命名没有 as_of 语义 | 命名规范强制带口径:return_rate_90d_asof_event / _asof_batch,看名字就知道对齐到哪个时间 |
| 没有穿越自检 ⭐⭐ | 上线前必跑:把样本按日期排序,算每个特征与"样本日期"的相关性。一个"近 90 天"特征如果和样本日期强相关(这里是 −0.61),几乎必然是穿越 |
没有 _feature_age_h |
每次生成训练集都输出特征新鲜度直方图,有负数直接卡住管道 |
| 训练/推理没有逐字段比对 | 取 1000 条线上请求,离线重算同样的特征,逐字段比对,不一致率 > 0.1% 报警 |
⭐ 改造之后的收获:同一套自检跑遍团队里另外 6 个模型, 又发现了 2 个有同类穿越(一个是"用户等级"取了当前值,一个是"商家评分")。 一次事故的价值,在于它让你顺手查出了那些还没爆的。
💀 这个案例和第 14 章的关系:它是穿越型泄漏里最难自查的一种, 因为离线训练集和离线测试集共享同一个穿越 —— 任何离线手段都发现不了它,只有"离线 vs 线上"的对比才会暴露。 ⭐ 这也解释了为什么它值 0.15 的 AUC:穿越特征的重要性排第 2。
🪶 八、如果你决定不上:三条轻量替代
这三条加起来大约一周,能拿到 Feature Store 八成的价值。
| 做什么 | 怎么做 | 拿到了什么 |
|---|---|---|
| ① point-in-time join 库函数 ⭐⭐ | 就是第二节那 20 行;全团队只准用它 | 最值钱的那条能力,成本几乎为零 |
| ② 特征定义 YAML + 两个生成器 | 一份 YAML 描述实体、时间窗、聚合逻辑;分别生成离线 SQL 和在线取数配置 | 训练/推理口径统一 |
| ③ 一张 online 特征表 + 单向同步 | 离线算完直接同步进 Redis/KV,绝不双写 | 在线离线物理一致 |
⭐⭐ ① 单独拿出来做,是本章投入产出比最高的一件事。 它不需要任何新系统、不需要新人力、不改变任何架构, 但它挡住的正是本章那个价值 210 万的事故。 💀 Feature Store 是个大工程;point-in-time 正确性是个小函数。 别因为买不起前者,就连后者也不做。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 模型上线之后 04 训练推理一致性 | ⭐⭐ 本章的另一半:这边讲怎么从源头上避免不一致,那边讲不一致发生后怎么逐字段查出来。事故复盘里的"逐字段比对"就是那一章的做法 |
| 推荐算法 07 特征工程 | ⭐ 特征本身怎么设计。Feature Store 不会替你做那一章的任何一件事 |
| 机器学习与深度学习基础 16 特征工程基础 | 更基础的一层:编码、分箱、交叉 |
| 模型上线之后 06 数据漂移与概念漂移 | 「它不会自动发现漂移」—— 漂移监控是完全独立的一套,在那一章 |
| 数据泄漏的七种来源 | point-in-time 违规就是那一章的穿越型泄漏,本章给了它的工程解法 |
| 数据版本与血缘 | 特征定义本身也要版本化 —— 改了一个特征的口径,等于换了一份训练数据 |
✅ 检查点
- Feature Store 做的四件事是什么?为什么说"两条路径长期保持一致"在工程上不可能成立?
- 什么是 point-in-time 正确性?举例说明"取最新值"错在哪。
event_time和available_time有什么区别、按错的那个对齐会发生什么、偏差通常多大? _feature_age_h这一列怎么判读?出现负数说明什么?大量 NaN 该怎么处理?- 特征复用率怎么算?低于多少就不该拿它当上 Feature Store 的理由?现实中大多数团队是多少?
- Feature Store 不解决哪四件事?它又会带来哪四个新问题?
- 在线取数的延迟预算大概是多少?压测时最容易犯的错是什么?
- 判断清单里权重最高的是哪一条?为什么?得 0–2 分的团队该怎么做?
- 退货模型那个事故的根因是什么?为什么离线完全看不出来?为什么团队一开始查错了方向?离线、线上、修复后的三个 AUC 分别是多少、直接损失多少?
- 上线前那个"穿越自检"具体怎么做?案例里的相关性是多少?
- 三条轻量替代里哪一条最值得单独做、为什么?
👀 答案
- ①特征定义只写一次 ②离线管道和在线服务都从这份定义生成 ③离线存历史全量、在线存最新值 ④⭐⭐训练取数按事件时间回溯。不可能成立是因为两条路径由不同的人、不同的语言、不同的时间写成 —— ⭐ 今天一致,下个季度有人改了其中一条就不一致了,这不是"很难",是"不可能长期成立"。
- 对每一行训练样本,取该样本事件时间之前最后一次可见的特征值。例子:样本在 03-10 14:23,特征在 t2(03-08) 是 0.05、t3(03-12) 是 0.19。✅ 正确取 0.05,❌ 取"最新值" 0.19 —— 0.19 里包含了样本之后发生的退货,标签漏进了特征。
event_time是这个值描述的时刻,available_time是它线上真正能读到的时刻(批任务跑完之后)。⭐ 必须按available_time对齐;💀 按event_time对齐,你复现的是一个线上根本不存在的世界(离线看得到当天值,线上要等第二天凌晨)。偏差通常 4–24 小时,足以让离线指标虚高好几个点。 - 中位数应该 ≈ 批任务周期的一半(日批 → 约 12 小时)。出现负数 = 未来信息穿越,立刻停。大量 NaN 说明这些样本在事件时刻根本没有特征、线上会走默认值,⭐ 训练时也必须让它们走默认值,否则又是一种训推不一致。⭐ 这一列的价值是把"有没有穿越"从需要推理的问题变成看直方图就能回答的问题。
- 复用率 = 被 ≥2 个模型使用的特征数 / 特征总数。< 20% 就别拿它当理由;20–50% 一份共享 SQL 库 + code review 也能解决;>50% 才真的开始省人力。⚠️ 现实里大多数团队不到 15%,因为不同模型的实体粒度、时间窗、口径本来就不一样 —— ⭐ 别为一个你还没有的问题建系统。
- 不解决:不会让特征变好(垃圾特征只会被更快更一致地送出去)、不会替你定义口径("活跃用户是什么"它一个字都帮不上)、不会自动发现漂移(它存值不判断值)、不解决数据质量。新问题:多一个故障点(而且它的 SLA 要求比模型服务更高)、在线存储成本(1000 万实体 × 200 特征 ≈ 16 GB 原始、实际约 50 GB Redis)、💀特征死不掉(被 3 个模型引用后没人敢删,两年后 4000 个特征 3000 个没人知道干什么的)、调试链路变长。
- 模型服务 P99 预算约 100 ms,其中特征取数的 P99 必须压在 10–20 ms。⚠️ 最容易犯的错是按单 key 压测 —— 那是"一次取 200 个特征"的 P99,不是单 key 的 P99;必须按真实批量大小压测,没压测过就上线是最常见的翻车点。
- ⭐⭐ 「过去半年出过 ≥2 次训推不一致的线上事故」。因为 Feature Store 是为已经发生过的疼痛买的保险,没疼过就买,买到的只是一个新的运维负担。0–2 分的团队不要上,用三条轻量替代(point-in-time 库函数 / 特征定义 YAML / 单向同步的在线表),一周搞定。
- 根因:
user_return_rate_90d用的是"截至跑数当天"的值,而训练样本跨了 6 个月 —— 对 6 个月前的样本它包含了之后 6 个月的退货记录,标签漏进了特征。离线看不出来是因为⭐ 离线测试集也是同一份表算的,带着完全一样的穿越,评估不但看不出问题还会奖励这个特征(它重要性排第 2)。查错方向是因为 ⭐⭐ 在线服务其实是对的(Java 从 Redis 读当前值恰好等价于正确的 point-in-time),所以表现成"线上比离线差",团队一直往"线上环境有问题"查,浪费了大半时间。三个 AUC:离线 0.83、线上 0.68、修复后离线 0.71 / 线上 0.70 —— ⭐ 那个"很好的模型"从来不存在,真实能力一直是 0.70 左右,中间那 0.12 全是穿越。直接损失 ≈ 210 万元(6 周欺诈性退货),外加 3 人 × 2 周排查和 3 周重训灰度。 - 把训练样本按日期排序,算每个特征与"样本日期"的相关性 —— 一个"近 90 天"的特征如果和样本日期强相关,几乎必然是穿越。案例里这个相关性是 −0.61。⭐ 同一套自检跑遍另外 6 个模型,又查出 2 个同类穿越(用户等级取了当前值、商家评分)。
- ⭐⭐ ① point-in-time join 库函数。因为它不需要新系统、不需要新人力、不改变任何架构,但它挡住的正是那个价值 210 万的事故。💀 Feature Store 是个大工程,point-in-time 正确性是个小函数 —— 别因为买不起前者就连后者也不做。
🛑 可以停在这里
⚡ 走神救援
⭐⭐核心:Feature Store 解决的是"同一个特征在四个地方被算了四遍",不是"你的特征不够好"。它是纪律工具不是能力工具,而且它最值钱的那条能力你可以用 30 行代码单独拿到。 没有它的时候,一个特征会被算两遍:离线由数据工程师用 Spark SQL 每天凌晨跑,在线由后端工程师用 Java/Go 毫秒级跑——⭐两条路径不同的人、不同的语言、不同的时间写成,长期保持一致在工程上不是"很难"是"不可能",今天一致下季度有人改了就不一致了。它做四件事:定义只写一次 / 两条路径都从这份定义生成 / 离线存历史全量在线存最新值 / ⭐⭐训练取数按事件时间回溯。最值钱的是 point-in-time 正确性,它直接连着第 14 章的泄漏:99% 的手写 SQL 写成
WHERE order_time >= date_sub(now(), 90),执行时刻是今天,于是对 6 个月前的样本它包含了之后 6 个月的记录——标签漏进了特征。正确做法是对每一行样本取该样本事件时间之前最后一次可见的值(时间轴上 t2=0.05 而不是 t3=0.19)。⭐⭐更隐蔽的坑是时间戳有两个:event_time(值描述的时刻)和available_time(线上真正能读到的时刻,批任务跑完之后);💀必须按 available_time 对齐,按 event_time 对齐你复现的是一个线上根本不存在的世界,偏差通常 4–24 小时,足以让离线指标虚高好几个点。实现就是pd.merge_asof(direction="backward", allow_exact_matches=False),再顺手输出 ⭐⭐_feature_age_h:中位数应约等于批周期的一半(日批约 12 小时),出现负数=穿越立刻停,大量 NaN 说明这些样本线上会走默认值、训练时也必须走默认值——这一列把"有没有穿越"从推理题变成看直方图的题。🧩它还解决训练推理口径统一、特征复用、在线离线物理一致(单向派生,绝不双写)。⚠️但"特征复用"最常被拿来当理由也最常落空:复用率 = 被 ≥2 个模型用的特征 / 总特征,<20% 就别拿它当理由,>50% 才真省人力,而现实中大多数团队不到 15%——⭐别为一个你还没有的问题建系统。🚫它不解决四件事:不会让特征变好(垃圾会被更快更一致地送出去)、⭐⭐不会替你定义口径("活跃用户是什么"这最难最贵的一步它一个字都帮不上)、不会自动发现漂移(它存值不判断值)、不解决数据质量。而且新增四个问题:多一个故障点(它的 SLA 要求比模型服务更高)、在线存储成本(1000 万实体 × 200 特征 ≈ 16 GB 原始、实际约 50 GB Redis)、💀特征死不掉(被 3 个模型引用后没人敢删,两年后 4000 个特征里 3000 个没人知道干什么,上线第一天就要有引用计数和下线流程)、调试链路变长。💰延迟预算:模型服务 P99 约 100 ms,特征取数必须压在 10–20 ms,⚠️而这是"一次取 200 个特征"的 P99 不是单 key 的,必须按真实批量压测,没压测就上线是最常见的翻车点。🧭该不该上的清单(每条 1 分):≥10 个线上模型 / 有在线实时推理 / 复用率 >50% / ⭐过去半年 ≥2 次训推不一致事故(权重最高) / ≥2 个团队共享特征 / 需要严格 point-in-time 回溯 / 有人能长期运维 / 特征 >300 个。0–2 分不要上(用轻量替代,一周搞定),3–5 分只做 point-in-time join + 定义 YAML,6–8 分可以上但先落实"有人维护"。⭐⭐Feature Store 是为已经发生过的疼痛买的保险,没疼过就买,买到的只是新的运维负担;💀 只有 3 个模型、没有在线服务的团队上了它,通常半年后会悄悄下掉。💀退货模型事故:37 个聚合特征、样本跨 6 个月,离线 AUC 0.83、线上只有 0.68,3 个人查了 2 周。根因是user_return_rate_90d用的是"截至跑数当天"的值。没被发现的三条:①离线测试集也是同一份表算的,带着一样的穿越,评估不但看不出还会奖励它(重要性排第 2);②名字里的 "90d" 大家都以为是相对样本时间,实际是相对跑数时间,⭐名字没说清 as_of 语义;③⭐⭐最反直觉的是在线服务其实是对的(Java 读 Redis 当前值恰好等价于正确取值),所以表现成"线上比离线差",团队一直往"线上环境有问题"查,浪费了大半时间。三个 AUC:离线 0.83 / 线上 0.68 / 修复后离线 0.71、线上 0.70——⭐那个"很好的模型"从来不存在,真实能力一直是 0.70,中间 0.12 全是穿越。损失 ≈210 万元欺诈退款 + 3 人×2 周排查 + 3 周重训。补救:point-in-time 收敛成库函数、禁止手写时间窗聚合;命名强制带_asof_event/_asof_batch;⭐⭐上线前跑穿越自检——把样本按日期排序算每个特征与样本日期的相关性,案例里是 −0.61;输出_feature_age_h直方图有负数就卡住管道;取 1000 条线上请求逐字段比对,不一致率 >0.1% 报警。⭐同一套自检跑遍另外 6 个模型,又查出 2 个同类穿越——一次事故的价值在于顺手查出那些还没爆的。 🪶如果不上,三条轻量替代一周搞定:①point-in-time join 库函数 ②特征定义 YAML + 两个生成器 ③一张 online 表 + 单向同步。⭐⭐①单独做是全章投入产出比最高的一件事:不需要新系统、新人力、不改架构,但它挡住的正是那个 210 万的事故。💀 Feature Store 是个大工程,point-in-time 正确性是个小函数——别因为买不起前者,就连后者也不做。
下一节 👉 19-隐私脱敏与留存.md