4、实时特征计算:滑动窗口聚合与时间衰减统计

实时特征计算,说白了就是「边收数据边算特征」。跟离线批处理不一样,你没法等一天的数据攒齐了再跑一次 Spark 任务。数据是一条一条来的,特征得一条一条出。我刚开始做实时特征的时候,踩过最大的坑就是——窗口没选对,特征全白算。

这一章,咱们就聚焦在三个核心操作上:滑动窗口聚合、时间衰减统计、实时比率与差值计算。这三板斧用好了,80% 的实时特征场景你都能搞定。

4.1 滑动窗口的三种形态

窗口是实时特征的基本单位。你想想看,用户过去 5 分钟点击了多少次?过去 1 小时消费了多少金额?这些「过去 X 时间」就是窗口。但窗口怎么滑、怎么跳,有讲究。

4.1.1 Tumbling Window(滚动窗口)

滚动窗口最简单。窗口之间不重叠,首尾相接。比如每 5 分钟一个窗口,0:00-0:05 算一次,0:05-0:10 再算一次。数据只属于一个窗口。

适用场景:按固定时间粒度统计的指标,比如每 5 分钟的平均响应时间、每小时的 UV。

-- Flink SQL 示例:Tumbling Window
SELECT
  userId,
  TUMBLE_END(eventTime, INTERVAL '5' MINUTE) AS windowEnd,
  COUNT(*) AS clickCount,
  AVG(dwellTime) AS avgDwellTime
FROM clickEvents
GROUP BY
  userId,
  TUMBLE(eventTime, INTERVAL '5' MINUTE)

我个人的习惯是,滚动窗口用在「报表型特征」上。比如给下游模型喂一个「过去 1 小时用户活跃度」,用滚动窗口就够了,因为模型不需要那么细的粒度。

4.1.2 Hopping Window(跳跃窗口)

跳跃窗口有重叠。窗口大小和滑动步长是两个独立参数。比如窗口大小 10 分钟,滑动步长 5 分钟。那 0:00-0:10 算一次,0:05-0:15 再算一次。数据可能同时属于多个窗口。

经验之谈:跳跃窗口的步长决定了特征更新的频率。步长越小,更新越频繁,计算开销也越大。我在做实时风控时,步长设成了 1 分钟,窗口大小 10 分钟——既能捕捉短期波动,又不会让 Flink 集群扛不住。

-- Flink SQL 示例:Hopping Window
SELECT
  userId,
  HOP_END(eventTime, INTERVAL '5' MINUTE, INTERVAL '10' MINUTE) AS windowEnd,
  SUM(amount) AS totalAmount
FROM transactionEvents
GROUP BY
  userId,
  HOP(eventTime, INTERVAL '5' MINUTE, INTERVAL '10' MINUTE)

4.1.3 Sliding Window(滑动窗口)

滑动窗口是跳跃窗口的极端情况——步长小到每条记录都触发一次窗口计算。每次来一条新数据,窗口就往前滑一下。这是最实时、也最贵的窗口模式。

注意:滑动窗口在数据量大的场景下,计算压力极大。我曾经在一个日活千万的推荐系统里用过滑动窗口,结果 Flink 的 Checkpoint 频繁超时。后来改成了跳跃窗口,步长 10 秒,效果几乎一样,压力降了一个数量级。

// Flink DataStream API 示例:Sliding Window
DataStream<ClickEvent> clicks = ...;

clicks
  .keyBy(event -> event.getUserId())
  .window(SlidingEventTimeWindows.of(
      Time.minutes(5),   // 窗口大小
      Time.seconds(1)    // 滑动步长
  ))
  .aggregate(new ClickCountAggregate())
  .map(new FeatureToVector());

4.2 三种窗口的对比

窗口类型 重叠 计算频率 资源消耗 典型场景
Tumbling 无 每窗口一次 低 报表、离线对齐
Hopping 有 每步长一次 中 实时监控、风控
Sliding 连续 每条记录 高 高频交易、实时推荐

4.3 时间衰减统计

窗口有个天然缺陷:窗口边界是硬的。0:00 的数据和 0:05 的数据,在滚动窗口里被分到了两个不同的桶。但真实世界里,时间的影响是连续的。昨天的行为应该比上周的行为更重要——这就是时间衰减要解决的问题。

我常用的衰减函数有两种:

  • 指数衰减:权重 = e-λ·Δt,λ 是衰减系数,Δt 是距离当前的时间差。
  • 线性衰减:权重 = max(0, 1 - Δt / T),T 是衰减半衰期。

指数衰减用得更多。为什么?因为它有「无记忆性」——你不需要维护一个固定窗口,每条数据来了算一下权重,累加到全局状态里就行。

// 指数衰减的实时统计实现
public class ExponentialDecayCounter {
    private double decayFactor;  // λ
    private double sum;          // 加权和
    private long lastUpdateTime; // 上次更新时间戳

    public ExponentialDecayCounter(double lambda) {
        this.decayFactor = lambda;
        this.sum = 0.0;
        this.lastUpdateTime = System.currentTimeMillis();
    }

    public void add(double value, long eventTime) {
        // 先衰减旧值
        double elapsed = (eventTime - lastUpdateTime) / 1000.0;
        sum *= Math.exp(-decayFactor * elapsed);
        // 再加新值
        sum += value;
        lastUpdateTime = eventTime;
    }

    public double getValue(long currentTime) {
        double elapsed = (currentTime - lastUpdateTime) / 1000.0;
        return sum * Math.exp(-decayFactor * elapsed);
    }
}

避坑指南:我曾经在实现指数衰减时,忘了处理「长时间没有数据」的情况。结果用户三天没来,衰减后的值几乎归零,模型直接判为异常。后来我加了一个「最小活跃值」兜底,低于阈值就重置为默认值。

4.4 实时比率与差值计算

比率和差值,是特征工程里最常用的「组合特征」。单独看一个指标往往不够,得跟历史比、跟另一个指标比,才有意义。

4.4.1 实时比率

比如「点击率 = 点击次数 / 曝光次数」。听起来简单,但实时场景下有个坑——分母可能为 0。我一般会加一个平滑项:

double ctr = (clicks + 1.0) / (impressions + 2.0);  // 贝叶斯平滑

另一个常见比率是「转化率 = 下单数 / 加购数」。这个比率在实时场景下波动很大,尤其是刚开播或刚上线活动时。我习惯再加一个「滑动窗口比率」,用过去 10 分钟的聚合值来算,而不是用当前这一秒的值。

4.4.2 实时差值

差值分两种:

  • 时间差值:当前值减去上一时刻的值。比如「当前 PV - 上一分钟 PV」,反映变化趋势。
  • 对比差值:当前值减去基线值。比如「当前响应时间 - 历史平均响应时间」,反映异常程度。
// 实时差值计算示例
public class DeltaCalculator {
    private double previousValue;
    private boolean initialized = false;

    public double calculateDelta(double currentValue) {
        if (!initialized) {
            previousValue = currentValue;
            initialized = true;
            return 0.0;
        }
        double delta = currentValue - previousValue;
        previousValue = currentValue;
        return delta;
    }
}

注意:差值计算对数据乱序非常敏感。如果数据到达顺序跟事件时间不一致,差值会算错。我建议差值计算一定要基于事件时间,而不是处理时间。Flink 里用 assignTimestampsAndWatermarks 来保证。

4.5 本章知识体系

下面这张图,把本章的核心逻辑串起来了。从数据源到窗口选择,再到衰减和比率计算,最后输出特征向量。

实时特征计算知识体系 实时特征计算核心流程 实时数据流 窗口类型选择 Tumbling 滚动窗口 Hopping 跳跃窗口 Sliding 滑动窗口 聚合计算:SUM / AVG / COUNT / MAX / MIN 时间衰减统计 实时比率计算 实时差值计算 特征向量输出 窗口选择决定计算开销与实时性

嗯,这张图基本把本章的脉络讲清楚了。从数据源进来,先选窗口类型,再做聚合,最后根据业务需求叠加衰减、比率或差值。每一步的选择,都直接影响特征的质量和系统的开销。

我个人建议,刚开始做实时特征时,先从 Tumbling Window 入手,把基础聚合跑通。然后再根据业务反馈,逐步引入 Hopping Window 和时间衰减。别一上来就上 Sliding Window——你想想看,每条数据都触发一次全量计算,集群不崩才怪。