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 Window 入手,把基础聚合跑通。然后再根据业务反馈,逐步引入 Hopping Window 和时间衰减。别一上来就上 Sliding Window——你想想看,每条数据都触发一次全量计算,集群不崩才怪。