4. 特征工程与特征存储工程化

在 Databricks 机器学习平台上,特征工程如果只停留在 Notebook 里“算完即用”,模型上线后很快会遇到三类问题:训练时用的特征和线上推理时算出来的特征对不上;同一个特征被多个团队重复实现,口径不一致;特征逻辑变更后无法追溯哪一版模型用了哪一版特征。特征存储(Feature Store)工程化的目标,就是把特征从“临时计算产物”变成“可注册、可复用、可追溯、可在线服务”的数据资产。本节围绕特征表设计、特征计算管道、在线离线一致性、特征版本与血缘四条主线展开。

一、特征表设计:主键、时间戳与实体

Databricks Feature Store 的核心对象是特征表(Feature Table),它本质上是一张带元数据的 Delta 表。设计特征表时,最关键的是确定三件事:实体主键(primary key)、时间戳列(timestamp key)和特征列。主键决定“这个特征属于谁”,例如用户 ID、商品 ID、门店 ID;时间戳列决定“这个特征值在什么时刻有效”,用于点时间(point-in-time)正确连接,避免训练时把未来信息泄漏进历史样本。

一个典型的用户行为特征表可以这样定义:主键为 user_id,时间戳为 event_ts,特征列包括 click_cnt_7d、order_cnt_30d、avg_order_amt_30d、last_login_days。在 Databricks 中通过 FeatureEngineeringClient.create_table 注册,并显式声明主键与时间戳:

from databricks.feature_engineering import FeatureEngineeringClient
fe = FeatureEngineeringClient()

fe.create_table(
    name="ml.features.user_behavior",
    primary_keys=["user_id"],
    timestamp_keys=["event_ts"],
    schema="user_id STRING, event_ts TIMESTAMP, click_cnt_7d INT, order_cnt_30d INT, avg_order_amt_30d DOUBLE, last_login_days INT",
    description="用户近7/30天行为特征"
)

设计要点有三:其一,主键与时间戳组合应尽量唯一,避免同一实体同一时刻出现多条记录导致连接歧义;其二,特征列命名要带窗口语义(如 _7d、_30d),让口径自解释;其三,宽表按实体聚合,避免把不同粒度(用户级、订单级)混在一张表里。对于需要多实体关联的场景,可以拆成多张特征表,在训练时按主键分别连接。

二、特征计算管道:从原始事件到特征表

特征计算管道负责把原始数据加工成特征表。工程化做法是用 Databricks 作业或 Delta Live Tables 把计算逻辑固化下来,按调度周期增量刷新,而不是每次训练时临时跑。以用户行为特征为例,管道分三步:从事件表按天聚合出中间指标,再滚动窗口计算 7 天和 30 天指标,最后按 user_id + event_ts 写入特征表。

from pyspark.sql import functions as F

daily = (spark.read.table("ml.raw.user_events")
    .withColumn("dt", F.to_date("event_ts"))
    .groupBy("user_id", "dt")
    .agg(F.count("*").alias("click_cnt"),
         F.sum("order_amt").alias("order_amt"),
         F.countDistinct("order_id").alias("order_cnt")))

windowed = (daily
    .withColumn("click_cnt_7d", F.sum("click_cnt").over(
        Window.partitionBy("user_id").orderBy("dt").rowsBetween(-6, 0)))
    .withColumn("order_cnt_30d", F.sum("order_cnt").over(
        Window.partitionBy("user_id").orderBy("dt").rowsBetween(-29, 0)))
    .withColumn("event_ts", F.col("dt").cast("timestamp")))

windowed.select("user_id","event_ts","click_cnt_7d","order_cnt_30d") \
    .write.mode("merge").saveAsTable("ml.features.user_behavior")

管道设计要关注三点:增量与幂等,按分区覆盖或 merge 保证重跑不产生重复;窗口边界,明确“近 7 天”是否包含当天,全文与图示保持一致;计算与写入分离,先算好再写特征表,便于失败重试。管道产出后,特征表就具备了可被训练和在线服务共同消费的基础。

三、在线离线一致性:同一份逻辑,两种读取路径

在线离线一致性是特征存储最核心的价值。离线训练时,通过 FeatureLookup 按主键和时间戳从特征表读取历史值;在线推理时,通过在线特征存储(Online Feature Store,底层通常是 Databricks Online Tables 或外部 KV 存储)按主键读取最新值。两者读取的是同一张特征表的同一份计算逻辑,只是物化路径不同:离线读 Delta 历史快照,在线读同步后的最新值。

一致性风险主要来自三处:一是训练用批处理逻辑、线上用流式逻辑,两套代码口径漂移;二是时间戳对齐错误,训练时用了特征值晚于标签时刻的记录;三是同步延迟,在线表未及时刷新导致读到旧值。工程上的应对是:计算逻辑只写一份,离线与在线共用;训练时严格按 event_ts <= label_ts 做点时间连接;在线表设置合理的同步频率并监控延迟。

特征表:离线训练与在线推理的一致性读取路径 原始事件表 user_events 特征计算管道 窗口聚合 + 增量刷新 同一份逻辑 特征表(Delta) 主键 user_id 时间戳 event_ts 特征列 click_cnt_7d 等 离线读取 FeatureLookup 点时间连接 label_ts 在线读取 Online Feature Store 按主键取最新值 同一特征表 → 离线读历史快照 / 在线读最新值,逻辑同源,路径不同

四、特征版本与血缘管理

特征逻辑一旦变更,必须可追溯。Databricks 的做法是:特征表本身是 Delta 表,天然具备版本历史,可通过 DESCRIBE HISTORY 查看每次写入;同时用 MLflow 记录模型训练时引用的特征表名与版本,形成“模型—特征—数据”的血缘链。当某个特征口径调整时,可以沿血缘快速定位受影响的模型,决定是否重训。

-- 查看特征表版本历史
DESCRIBE HISTORY ml.features.user_behavior;

-- 训练时记录特征血缘
with mlflow.start_run():
    mlflow.log_param("feature_table", "ml.features.user_behavior")
    mlflow.log_param("feature_version", 3)
    fe.log_model(model=model, artifact_path="model",
                 flavor=mlflow.sklearn,
                 training_set=training_set)

版本管理建议遵循三条规则:特征表结构变更(增删列、改类型)必须升版本并通知下游;特征口径变更(窗口、过滤条件)要新建特征列或新表,不要原地覆盖;每次训练都记录特征表版本号,使模型可复现。血缘不仅用于排障,也用于合规审计——当需要解释“这个预测用了哪些数据”时,血缘链就是答案。

实践建议:把特征表当作“有契约的数据产品”来管理。主键、时间戳、特征列口径、刷新频率、负责人写进表描述;下游只通过 FeatureLookup 消费,不直接读原始事件表,这样在线离线一致性才有工程保障。

综合来看,特征存储工程化不是单一工具问题,而是设计、计算、一致性、版本四条线协同的结果:特征表设计决定数据能否被正确连接,计算管道决定特征能否稳定产出,在线离线一致性决定模型上线后是否可信,版本与血缘决定系统能否长期演进。把这四点落到 Databricks 的具体对象和操作上,特征才真正从脚本变成资产。