4. 使用Spark进行数据探索与特征工程

在 Databricks 平台上,Spark 是数据探索与特征工程的核心执行引擎。无论数据存放在 Delta Lake、Parquet 还是外部存储中,我们都可以通过 DataFrame API 或 Spark SQL 对数据进行清洗、聚合、变换和编码,最终生成适合机器学习模型训练的特征表。本节围绕 DataFrame API、SQL、窗口函数、聚合、缺失值处理、特征编码与缩放这几项关键能力,说明它们在实际流程中的位置与用法。

DataFrame API 与 SQL:两种等价入口

DataFrame API 以编程方式表达变换,适合在 Python 中做链式处理;Spark SQL 则以声明式语句描述同一套逻辑。二者最终都会经过 Catalyst 优化器生成执行计划,因此性能上没有本质差异,选择取决于可读性与团队习惯。在 Databricks 中,可以先用 SQL 快速探索,再用 DataFrame API 固化到特征工程函数中。

from pyspark.sql import functions as F

df = spark.table("silver.user_events")

# DataFrame API:按用户聚合行为指标
user_feat = (df
    .groupBy("user_id")
    .agg(
        F.count("*").alias("event_cnt"),
        F.sum(F.when(F.col("event_type") == "click", 1).otherwise(0)).alias("click_cnt"),
        F.avg("stay_seconds").alias("avg_stay"),
        F.max("event_time").alias("last_event_time")
    ))

等价的 SQL 写法如下,结果完全一致:

SELECT user_id,
       COUNT(*) AS event_cnt,
       SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS click_cnt,
       AVG(stay_seconds) AS avg_stay,
       MAX(event_time) AS last_event_time
FROM silver.user_events
GROUP BY user_id;

聚合与窗口函数:从明细到特征

聚合把多行压缩为一行,适合生成用户级、商品级的统计特征。窗口函数则在不减少行数的前提下,为每一行附加分组内的排名、累计或前后值,是构造时序特征和对比特征的关键工具。例如计算每个用户最近一次行为距今天数、按时间排序的行为序号、以及用户消费金额的累计值:

from pyspark.sql import Window

w_user = Window.partitionBy("user_id").orderBy(F.col("event_time"))

feat = (df
    .withColumn("rn", F.row_number().over(w_user))
    .withColumn("cum_amount", F.sum("amount").over(w_user))
    .withColumn("prev_stay", F.lag("stay_seconds", 1).over(w_user))
    .withColumn("days_since_last",
                F.datediff(F.current_date(), F.max("event_time").over(Window.partitionBy("user_id")))))

这里需要注意窗口定义的两个部分:partitionBy 决定分组边界,orderBy 决定组内顺序。若省略 orderBy,row_number 的结果将不确定;若把 orderBy 加在聚合窗口上,则 sum 会变成累计求和而非全组求和。这是窗口函数最常见的语义陷阱。

缺失值处理

真实数据中缺失值普遍存在。处理前应先量化缺失比例,再决定删除、填充还是保留为单独类别。Spark 提供 dropna、fillna 以及 Imputer 等工具。对于数值特征,常用中位数填充以降低异常值影响;对于类别特征,可填充为 "unknown" 并作为一个独立取值参与编码。

# 统计缺失比例
null_ratio = df.select([
    (F.sum(F.col(c).isNull().cast("int")) / F.count("*")).alias(c)
    for c in ["age", "income", "city"]
])
null_ratio.show()

# 数值列中位数填充,类别列填充 unknown
num_cols = ["age", "income"]
cat_cols = ["city"]

df_filled = (df
    .fillna("unknown", subset=cat_cols)
    .fillna({c: df.approxQuantile(c, [0.5], 0.01)[0] for c in num_cols}))

判断依据:若某列缺失比例超过约 60% 且业务上难以推断,通常直接删除该列;若缺失比例较低且分布随机,填充是更稳妥的选择。填充后应再次统计缺失比例,确认没有遗漏。

特征编码与缩放

类别特征需要转换为数值形式。低基数类别可用 StringIndexer 加 OneHotEncoder;高基数类别可考虑目标编码或哈希编码。数值特征若量纲差异大,应使用 StandardScaler 或 MinMaxScaler 统一尺度,避免距离类模型被大数值特征主导。

from pyspark.ml.feature import StringIndexer, OneHotEncoder, VectorAssembler, StandardScaler

idx = StringIndexer(inputCol="city", outputCol="city_idx", handleInvalid="keep")
ohe = OneHotEncoder(inputCol="city_idx", outputCol="city_vec")

assembler = VectorAssembler(
    inputCols=["age", "income", "city_vec"],
    outputCol="features_raw")

scaler = StandardScaler(inputCol="features_raw", outputCol="features",
                        withMean=True, withStd=True)

需要强调的是,编码器和缩放器的统计量(如均值、标准差、类别字典)必须只在训练集上拟合,再应用到验证集和测试集,否则会造成数据泄漏。在 Databricks 中通常把这一步封装进 Pipeline,由 Pipeline.fit(train_df) 统一完成拟合,再用 transform 处理其他数据集。

Spark 特征工程数据流 原始明细表 user_events 探索与统计 缺失率 / 分布 缺失值处理 填充 / 删除 聚合 + 窗口函数 groupBy / over(Window) 特征编码 StringIndexer + OneHot 特征缩放 StandardScaler 特征向量 VectorAssembler Pipeline 封装:fit(train_df) → transform(valid_df / test_df) 编码字典、均值、标准差仅在训练集拟合,防止数据泄漏 训练 / 验证 / 测试特征表 Delta Table 持久化

实践要点与判断依据

在 Databricks 中执行上述流程时,有几个值得注意的细节。第一,探索阶段应先用 display() 或 summary() 观察数据分布,再决定聚合粒度;第二,窗口函数的分区键应与最终特征表的粒度一致,例如用户级特征就按 user_id 分区;第三,编码与缩放必须放入 Pipeline,避免训练与推理阶段处理不一致;第四,生成的特征表建议写入 Delta Table,便于版本管理和后续复用。

在 Databricks 中,可以用 %sql 与 Python 单元格交替探索,SQL 结果可直接转为 DataFrame 继续处理,无需中间落盘。
窗口函数中若同时使用 partitionBy 和 orderBy,聚合函数会变为累计计算。若需要全组聚合,应去掉 orderBy 或使用单独的窗口定义。

综合来看,Spark 特征工程的核心是把原始明细逐步转换为模型可用的数值向量:先探索与清洗,再聚合与窗口构造统计特征,最后编码与缩放。每一步的输入、变换和输出都应可追溯,这样才能保证训练与线上推理的一致性。