64.1 特征平台
一句话
特征平台不是「把特征存进 Redis」,而是把特征定义、离线回填、在线服务、时间点正确性做成一套可复用契约;训练/推理一致性靠同一份特征定义,不靠两套代码「尽量对齐」。
与在线特征服务的分工
| 问题 | 主场 |
|---|---|
| 50 个特征怎么在 20ms 内取完、批量/并行/合并下游 | 63.3 特征与在线服务 |
| 特征怎么定义、怎么回填、训练集怎么按时间点拼接、离线/在线怎么共用一份逻辑 | 本节 |
| 特征的数据质量、血缘、权限 | 60.5 数据质量、60.6 数据安全与权限 |
🔑 在线特征服务解决「取得快」,特征平台解决「取得对」。只做前者,训练集里会混进未来数据;只做后者,线上延迟会失控。
平台的三件套
| 组件 | 存什么 | 关键契约 | 典型产品 |
|---|---|---|---|
| 特征注册表 | 特征名、实体、类型、时间语义、owner、版本、分级 | 同名特征只有一份定义;变更走版本 | Feast registry、Tecton、Databricks Feature Catalog |
| 离线 Store | 历史特征值(按事件时间) | 可按任意时间点回填、可重算 | 数仓 / 数据湖(Iceberg / Hudi / Delta) |
| 在线 Store | 每个实体最新特征值 | 低延迟、批量读、TTL、与离线同源 | Redis、HBase、DynamoDB、Cassandra |
⚠️ 离线与在线是两套存储、一份定义。如果两边各写一套计算逻辑(离线 SQL、在线 Java),它们迟早会分叉;平台的核心价值就是把「定义」抽出来,让两条路径从同一份逻辑/同一份数据派生。
特征定义:四个必须写清的字段
| 字段 | 回答 | 写错的后果 |
|---|---|---|
| 实体(entity) | 这个特征是「谁」的 | 用户特征错挂到商品,训练/服务全错 |
| 时间语义 | 特征值属于哪个时刻 | 用处理时间当事件时间,回填错位 |
| 聚合窗口 | 统计过去多久 | 7 天 vs 30 天混用,分布漂移 |
| 可用时点 | 这个值最早什么时候能拿到 | 用了未来才产生的值 → 特征穿越 |
# 特征定义(示意):和模型代码同仓,随 PR 评审
feature: user_churn_risk_7d
entity: user
dtype: float
owner: growth-ml
event_time: feature_ts # 特征值对应的业务时间
available_at: feature_ts # 最早可用的时刻(含上游延迟)
window: 7d
source: dws_user_behavior_1d
online_ttl: 7d
classification: L3
🔑 「可用时点」比「事件时间」更容易被忽略:一个特征可能事件时间是昨天,但要到今天凌晨 ETL 跑完才可用。训练集如果用
event_time <= label_time拼,仍可能把「今天才产出、但事件时间属于昨天」的值拼进昨天的样本——必须用available_at <= label_time。
Point-in-time Join:训练集不能看未来
训练样本是「在某个时刻,对某个实体做预测」,所以特征必须是那个时刻已经可见的最新值:
for each (entity, label_time):
feature = latest feature where entity = ? and available_at <= label_time
而不是:
feature = latest feature where entity = ? -- 错:用了 label 之后的值
下面的程序用 5 个用户演示两种拼法:as-of 正确,latest 会把 label 之后才发生的等级变化拼进训练集。
package main
import (
"fmt"
"sort"
)
type Feat struct {
User string
Level string
Day int
}
type Label struct {
User string
Day int
Churn bool
}
var feats = []Feat{
{"u1", "basic", 10}, {"u1", "gold", 120},
{"u2", "silver", 20}, {"u2", "platinum", 90},
{"u3", "basic", 130},
{"u4", "gold", 80},
{"u5", "basic", 5}, {"u5", "gold", 50}, {"u5", "platinum", 150},
}
var labels = []Label{{"u1", 100, true}, {"u2", 100, false}, {"u3", 100, true},
{"u4", 100, true}, {"u5", 100, false}}
// asOf 返回 day 时刻可见的最新特征(point-in-time 正确)。
func asOf(user string, day int) (Feat, bool) {
var best Feat
found := false
for _, f := range feats {
if f.User == user && f.Day <= day && (!found || f.Day > best.Day) {
best, found = f, true
}
}
return best, found
}
func latest(user string) (Feat, bool) {
var best Feat
found := false
for _, f := range feats {
if f.User == user && (!found || f.Day > best.Day) {
best, found = f, true
}
}
return best, found
}
func main() {
users := []string{"u1", "u2", "u3", "u4", "u5"}
sort.Strings(users)
fmt.Println("=== 训练集构造:as-of label_time vs 最新值 ===")
fmt.Printf("%-4s %-7s %-6s %-12s %-12s %s\n", "user", "label", "churn", "as-of(day100)", "latest", "leak?")
leaks := 0
for _, u := range users {
a, aok := asOf(u, 100)
l, lok := latest(u)
as, ls := "none", "none"
if aok {
as = fmt.Sprintf("%s(day%d)", a.Level, a.Day)
}
if lok {
ls = fmt.Sprintf("%s(day%d)", l.Level, l.Day)
}
leak := as != ls
if leak {
leaks++
}
fmt.Printf("%-4s %-7d %-6v %-12s %-12s %v\n", u, 100, labelOf(u), as, ls, leak)
}
fmt.Printf("\n5 个样本中 %d 个发生特征穿越(%.0f%%)\n", leaks, float64(leaks)/5*100)
fmt.Println("\n=== 在线特征(serving day=110)与训练 as-of(day100) 一致性 ===")
fmt.Printf("%-4s %-12s %-12s %-8s %s\n", "user", "train@100", "online@110", "match", "age(day)")
match := 0
for _, u := range users {
a, aok := asOf(u, 100)
o, ook := asOf(u, 110)
as, os := "none", "none"
if aok {
as = fmt.Sprintf("%s(day%d)", a.Level, a.Day)
}
if ook {
os = fmt.Sprintf("%s(day%d)", o.Level, o.Day)
}
ok := as == os
if ok {
match++
}
age := -1
if ook {
age = 110 - o.Day
}
fmt.Printf("%-4s %-12s %-12s %-8v %d\n", u, as, os, ok, age)
}
fmt.Printf("一致 %d/5;特征新鲜度最大 100 天\n", match)
fmt.Println("\n结论:训练必须按 label_time 做 as-of join;在线按 serving_time 取最新,两者用同一套事件时间语义。")
}
func labelOf(u string) bool {
for _, l := range labels {
if l.User == u {
return l.Churn
}
}
return false
}
本机实测(macOS,Apple M1,Go 1.27.1):
=== 训练集构造:as-of label_time vs 最新值 ===
user label churn as-of(day100) latest leak?
u1 100 true basic(day10) gold(day120) true
u2 100 false platinum(day90) platinum(day90) false
u3 100 true none basic(day130) true
u4 100 true gold(day80) gold(day80) false
u5 100 false gold(day50) platinum(day150) true
5 个样本中 3 个发生特征穿越(60%)
=== 在线特征(serving day=110)与训练 as-of(day100) 一致性 ===
user train@100 online@110 match age(day)
u1 basic(day10) basic(day10) true 100
u2 platinum(day90) platinum(day90) true 20
u3 none none true -1
u4 gold(day80) gold(day80) true 30
u5 gold(day50) gold(day50) true 60
一致 5/5;特征新鲜度最大 100 天
结论:训练必须按 label_time 做 as-of join;在线按 serving_time 取最新,两者用同一套事件时间语义。
读数:
| 现象 | 数字 | 说明 |
|---|---|---|
latest 拼法 | 3/5 穿越(60%) | u1、u3、u5 用了 label 之后才发生的等级变化 |
as-of 拼法 | 0/5 穿越 | 每个样本只看 day <= 100 的最新值 |
| 在线 vs 训练 | 5/5 一致 | 同一套 as-of 逻辑,serving 只是把时间点换成当前 |
| 最大特征年龄 | 100 天 | u1 从 day10 到 day100 没有新等级;新鲜度要监控 |
🔑 特征穿越不是「数据脏」,而是时间边界错。它不会报错,只会让离线 AUC 异常高;上线后拿不到未来值,效果立刻回落。判断方法:把特征按
available_at排序,任何available_at > label_time的拼接都是 bug。
离线回填与在线同步
| 任务 | 做什么 | 失败模式 |
|---|---|---|
| 离线物化 | 按天/小时把特征写入离线表,保留事件时间与版本 | 只写最新分区,历史无法重算 |
| 在线同步 | 把最新特征推到 Redis/在线 KV | 同步延迟、乱序覆盖、TTL 过期 |
| 回填(backfill) | 用原始事件重算某段历史特征 | 用新逻辑覆盖旧特征,训练/线上版本错位 |
| 版本切换 | 新特征版本双写,影子对比后再切 | 直接覆盖,旧模型读到新语义 |
回填的三条纪律:
- 原始事件必须留存(Kafka/Iceberg),否则无法重算;
- 回填写新版本/新分区,不要原地覆盖旧版本;
- 回填前后做分布对比(均值/分位数/缺失率),差异可解释才切换。
⚠️ 最常见的「特征平台事故」:在线 KV 的同步任务挂了 6 小时,训练侧还在用新特征,线上仍是旧值;离线指标正常,线上效果掉。监控必须同时看「离线产出时间」与「在线同步延迟」,而不是只看离线表。
训练/服务一致性怎么验收
| 检查 | 做法 | 通过标准 |
|---|---|---|
| 定义一致 | 离线/在线读同一份特征定义/同一份转换代码 | 同名特征只有一份实现 |
| 值一致 | 抽样请求,记录线上特征值,回放离线计算 | 差异率 < 0.1% |
| 分布一致 | 对比训练集与线上最近 7 天的均值/分位数/缺失率 | PSI < 0.1,缺失率差 < 1% |
| 时间一致 | 校验 available_at <= label_time / feature_ts <= serving_time | 穿越样本 = 0 |
| 缺失一致 | 离线/在线用同一套缺失填充 | 缺失语义可解释 |
🔑 最有效的验收不是「离线 AUC」,而是日志回放:线上每次请求记录
entity_id + feature_ts + feature_value + model_version,离线用同一批实体和时间点重算,逐条比对。对不上,就是平台契约破了。
生产落地:指名 → 可抄 → 可验 → 可退
指名:Feast(开源、离线/在线 store 可插拔);Tecton(托管、流批一体);Databricks Feature Store(与 Lakehouse 一体);在线 store 用 Redis / DynamoDB。
可抄(Feast 0.40+,未验证:本机未安装 Feast):
# feature_store.yaml
project: churn
registry: data/registry.db
provider: local
offline_store:
type: file
online_store:
type: redis
connection_string: localhost:6379
feast apply
# 回填最近 7 天到在线 store
feast materialize-incremental $(date -u +"%Y-%m-%dT%H:%M:%S")
可验:本节程序输出「3/5 穿越、5/5 在线一致」;生产上用「抽样日志回放」比对离线/在线特征值,差异率 < 0.1% 才算通过。可退:特征版本双写 + 影子读;新版本回填到新分区,出问题切回旧版本,不覆盖旧数据;在线 TTL 过期时回退到默认值要记录原因。
反模式
| 反模式 | 后果 |
|---|---|
训练集用 latest 拼特征 | 特征穿越(本节 60%),离线虚高、线上回落 |
| 离线/在线各写一套特征逻辑 | 分布漂移,训练/服务不一致 |
| 只存最新值、不存历史 | 无法按时间点回填,模型无法复现 |
| 回填直接覆盖旧特征 | 旧模型读到新语义,A/B 结论失效 |
| 在线同步延迟不监控 | 线上用旧值,离线指标正常 |
| 特征没有 owner/版本 | 出问题无法定位、无法回滚 |
| 缺失值离线填均值、在线填 0 | 模型学到错误信号 |
| 特征含 PII 不做分级 | 训练数据/在线日志泄露(见 60.6 数据安全与权限) |
自检
Q1 特征平台与「在线特征缓存」的区别是什么?
▸ 答案:在线缓存只解决「取得快」,它存的是最新值;特征平台解决「定义、回填、时间点正确、离线/在线一致」。平台包含注册表(定义/owner/版本)、离线 store(历史值)、在线 store(最新值)、回填与监控。只有缓存,训练集无法按历史时间点拼接,也无法重算。
Q2 为什么训练集必须按 label_time 做 as-of join,而不是取最新值?
▸ 答案:训练样本模拟的是「在 label_time 那一刻做预测」,模型只能看到那一刻已经可用的信息。取最新值会把 label 之后才发生的变化拼进特征(本节 u1/u3/u5),造成特征穿越:离线指标虚高,线上拿不到未来值,效果回落。正确条件是 available_at <= label_time。
Q3 「事件时间」和「可用时点」有什么区别?为什么后者更容易出错?
▸ 答案:事件时间是特征值对应的业务发生时间;可用时点是这个值最早能被训练/服务读取的时间(含上游 ETL、同步延迟)。一个事件时间属于昨天的特征,可能今天凌晨才产出;只用 event_time <= label_time 仍会把「昨天的事件、今天才可用」的值拼进昨天的样本。必须用 available_at <= label_time。
Q4 离线与在线特征一致性怎么验收?只看离线 AUC 行不行?
▸ 答案:不行。离线 AUC 只证明「训练集上拟合得好」,不证明线上拿到的特征一样。验收要做日志回放:线上记录 entity_id、feature_ts、feature_value、model_version;离线用同一实体与时间点重算,逐条比对差异率(目标 < 0.1%),并对比训练/线上分布的 PSI、缺失率。AUC 高但特征不一致,线上照样掉。
Q5 回填(backfill)为什么要写新版本/新分区,不能原地覆盖?
▸ 答案:原地覆盖会让旧模型在重训前读到新语义,实验结论无法复现;一旦新逻辑有 bug,旧数据也回不去。正确做法是回填到新版本/新分区,双写/影子对比,确认分布与指标后再切别名;旧版本保留到回滚窗口结束。
Q6 特征新鲜度(freshness)应该怎么定?
▸ 答案:由业务对「特征过期的容忍度」决定:实时行为分钟级、用户画像小时/天级、静态属性周级。技术上要同时监控「离线产出时间」与「在线同步延迟」,并设置 TTL;TTL 太短会频繁回退默认值,太长会读到过期特征。新鲜度是特征定义的一部分,不是监控的附属品。
Q7 什么团队/场景不该上完整特征平台?
▸ 答案:特征少于几十个、只有离线训练、没有在线服务、或模型迭代很慢的团队,用「数仓表 + 一份训练 SQL」更划算;上平台会引入注册表、在线 store、同步链路三套运维。当天级特征、在线服务多、训练/服务不一致已经造成事故时,再上平台;先用日志回放把一致性验收跑通。