时间窗口特征设计风控实战
时间窗口特征设计:风控模型中最容易被低估的工程问题
模型跑出 AUC 0.82,上线后 KS 跌到 0.18——不是你模型没训好,是你的时间窗口设计埋了雷。
一、什么是时间窗口特征
时间窗口特征,说白了就是在"过去 N 天/N 个月"这个滑动窗口内,对某个实体的行为做聚合统计。
举个例子:过去 30 天内,用户 A 的登录设备数、交易笔数、最大单笔金额。这些不是从用户注册那一刻算到现在的累计值,而是只看最近一段时间内的行为。
为什么不能直接用累计值?因为人的行为会变。三个月前天天剁手的用户,最近一个月突然沉寂——累计值看不出这个变化,但 30 天窗口能。
# 错误做法:累计特征,信息被历史稀释
df['total_txn_cnt'] = df.groupby('user_id')['txn_amt'].transform('count')
# 正确做法:滑动窗口特征,捕捉近期行为变化
df['txn_cnt_30d'] = df[df['event_date'] >= df['event_date'].max() - pd.Timedelta(days=30)] \
.groupby('user_id')['txn_amt'].transform('count')
说白了就是:你不关心用户这辈子干了什么,你只关心他最近一段时间干了什么。时间窗口就是那个"最近一段时间"的标尺。
二、时间窗口特征的三个关键维度
设计一个时间窗口特征,需要同时决定三件事:
| 维度 | 含义 | 典型取值 | 风控场景示例 |
|---|---|---|---|
| 窗口长度 | 往回看多久 | 7天/30天/90天/180天 | 贷前:90天行为;反欺诈:7天短窗 |
| 聚合函数 | 怎么算 | count/sum/avg/max/min/std/nunique | 交易笔数(count)、金额总和(sum)、设备数(nunique) |
| 分组维度 | 按什么分组 | 用户/设备/商户/IP/卡号 | 按设备聚合防设备农场,按商户聚合防商户套现 |
三维一乘,组合爆炸。一个用户实体配 5 个窗口长度 × 6 个聚合函数 × 3 个分组维度 = 90 个特征。但这 90 个不是乱造的,每一组都要回答一个业务问题。
关键原则:特征不是从笛卡尔积里批量生成的,是每个都带着业务假设设计的。
# 三维组合的工程实现——不是手工写 90 行,是用配置驱动
WINDOW_CONFIGS = [
# (窗口天数, 聚合列, 聚合函数, 分组列, 特征名)
(7, 'txn_amt', 'sum', 'user_id', 'txn_amt_sum_7d'),
(7, 'txn_amt', 'max', 'user_id', 'txn_amt_max_7d'),
(7, 'txn_amt', 'count', 'user_id', 'txn_cnt_7d'),
(7, 'device_id','nunique','user_id','device_nunique_7d'),
(30, 'txn_amt', 'sum', 'user_id', 'txn_amt_sum_30d'),
(30, 'txn_amt', 'avg', 'user_id', 'txn_amt_avg_30d'),
(30, 'merchant','nunique','user_id','merchant_nunique_30d'),
(90, 'txn_amt', 'sum', 'user_id', 'txn_amt_sum_90d'),
(90, 'txn_amt', 'std', 'user_id', 'txn_amt_std_90d'),
(180, 'txn_amt', 'sum', 'user_id', 'txn_amt_sum_180d'),
]
def build_window_features(df, configs):
features = pd.DataFrame()
max_date = df['event_date'].max()
for window_days, col, agg, group_col, feat_name in configs:
window_start = max_date - pd.Timedelta(days=window_days)
window_df = df[df['event_date'] >= window_start]
if agg == 'nunique':
feat = window_df.groupby(group_col)[col].nunique()
elif agg == 'std':
feat = window_df.groupby(group_col)[col].std()
elif agg == 'avg':
feat = window_df.groupby(group_col)[col].mean()
elif agg == 'max':
feat = window_df.groupby(group_col)[col].max()
elif agg == 'sum':
feat = window_df.groupby(group_col)[col].sum()
else: # count
feat = window_df.groupby(group_col)[col].count()
features[feat_name] = feat
return features.fillna(0)
三、长窗口 vs 短窗口:不是越长越好
很多新人以为窗口越长信息越多,实际恰恰相反。
短窗口(7天/14天)的优势:
- 对行为突变敏感——昨天突然换了 5 个设备登录,7 天窗立刻捕捉
- 数据稀疏风险小——短窗数据新,不容易缺字段
- 但噪声大——周末和节假日行为模式不同,容易误判
长窗口(90天/180天)的优势:
- 稳定性好——不会因为用户出差一周就判成高风险
- 但"历史包袱"重——180 天前的高频交易用户,最近一个月已经不活跃了,长窗均值仍然好看
正确的做法是长短窗搭配,用"变化率"类特征做交叉:
# 长短窗交叉特征:近期行为是否发生了突变
df['txn_cnt_change_ratio'] = df['txn_cnt_7d'] / (df['txn_cnt_90d'] / 13 + 1) # 防除零
# 这个比值 > 2 说明最近一周交易量远超过去三个月周均——要么被盗号了,要么在刷单
df['device_switch_flag'] = (df['device_nunique_7d'] >= 3).astype(int) # 7天内换设备≥3次
df['amt_volatility'] = df['txn_amt_std_7d'] / (df['txn_amt_avg_30d'] + 1) # 金额波动率
变化率特征往往比单一窗口的聚合值更有区分力。IV 值最高的那几个特征,十有八九是"比例"和"变化率"类的。
四、时间泄露:让模型作弊的元凶
这是窗口特征设计里最隐蔽也最致命的坑。
什么叫时间泄露? 你的训练样本标注的是"2025-03-15 是否逾期",但你的特征窗口的截止日期跑到 2025-03-15 之后去了——等于模型在"预测"时已经看到了"未来"的信息。
举个具体的反例:
# ❌ 时间泄露——窗口计算用到了样本日期之后的数据
max_date = df['event_date'].max() # 假设是 2025-06-01
window_start = max_date - pd.Timedelta(days=30) # 2025-05-02 到 2025-06-01
# 但你的样本标签是 2025-03-15 的——窗口包含了 3-15 之后两个月的数据!
# ✅ 正确做法:每个样本用自己的 observation_date 做窗口截止
def safe_window_features(df, obs_date_col='apply_date', label_date_col='label_date'):
features = []
for _, row in df.iterrows():
obs_end = row[obs_date_col] # 观测窗口终点 = 样本日期
window_30d = df[(df['user_id'] == row['user_id']) &
(df['event_date'] >= obs_end - pd.Timedelta(days=30)) &
(df['event_date'] < obs_end)] # 严格 < obs_end,不含当天
features.append({
'user_id': row['user_id'],
'txn_cnt_30d': len(window_30d),
'txn_amt_sum_30d': window_30d['txn_amt'].sum(),
})
return pd.DataFrame(features)
时间泄露的典型症状:训练集 AUC 0.95,测试集 AUC 0.65。差距超过 0.1,第一时间检查时间泄露。
根因:你窗口计算的 max_date 用了全局最大值而不是每个样本自己的 observation_date。
五、Java 生产环境的窗口特征计算
Python 离线训练跑得飞起,到了 Java 线上推理就傻眼了——没有 DataFrame,没有 groupby,没有 pd.Timedelta。
生产环境做窗口特征只有两条路:
路线 A:预计算 + 特征存储(推荐)
// 离线任务(Spark/Flink)每天凌晨跑一次,把窗口特征算好存进 Redis/特征平台
// 在线推理只做 KV 查询,不实时聚合
public class WindowFeatureLoader {
private final JedisPool jedisPool;
// key 格式: feat:user:{userId}:{windowName}
// value: 浮点数
public Map<String, Double> loadWindowFeatures(String userId) {
Map<String, Double> features = new HashMap<>();
try (Jedis jedis = jedisPool.getResource()) {
List<String> keys = Arrays.asList(
"feat:user:" + userId + ":txn_cnt_7d",
"feat:user:" + userId + ":txn_amt_sum_30d",
"feat:user:" + userId + ":device_nunique_7d",
"feat:user:" + userId + ":merchant_nunique_30d",
"feat:user:" + userId + ":txn_amt_std_90d"
);
List<String> values = jedis.mget(keys.toArray(new String[0]));
for (int i = 0; i < keys.size(); i++) {
if (values.get(i) != null) {
// key 去掉前缀保留特征名
String featName = keys.get(i).replace("feat:user:" + userId + ":", "");
features.put(featName, Double.parseDouble(values.get(i)));
}
}
}
return features; // 缺失特征返回 null,模型侧做默认值填充
}
}
路线 B:实时流计算(Flink/Kafka Streams)
适合对时效性要求极高的反欺诈场景——不能等 T+1 离线跑批,需要秒级更新。
// Flink 滑动窗口聚合示例(简化)
DataStream<TxnEvent> txnStream = env.addSource(kafkaSource);
txnStream
.keyBy(TxnEvent::getUserId)
.window(SlidingEventTimeWindows.of(Time.days(7), Time.hours(1))) // 7天窗,每小时滑动
.aggregate(new TxnCountAggregator()) // 自定义聚合:count/sum/max
.map(result -> new FeatureUpdate(result.getUserId(), "txn_cnt_7d", result.getCount()))
.addSink(redisSink); // 写入 Redis 供在线推理读取
绝大多数场景路线 A 就够了——T+1 的窗口特征对信用评分来说时效完全够用。反欺诈才需要路线 B。
六、缺失值:窗口特征的天生短板
窗口特征有一个天然问题:新用户没有历史行为,窗口里是空的。
这不是数据质量问题,是业务现实。但你不能直接填 0——因为"没有交易"和"交易金额为 0"在业务上完全不同。
# 缺失值处理策略——分场景选
# 策略1:新用户用全局中位数填充(保守)
df['txn_cnt_30d'] = df['txn_cnt_30d'].fillna(df['txn_cnt_30d'].median())
# 策略2:新用户用-1标记,让模型自己学(推荐)
df['txn_cnt_30d'] = df['txn_cnt_30d'].fillna(-1)
df['is_new_user'] = (df['txn_cnt_30d'] == -1).astype(int) # 加一个标识特征
# 策略3:分用户生命周期差异化填充
df.loc[df['user_age_days'] < 30, 'txn_cnt_30d'] = df.loc[
df['user_age_days'] < 30, 'txn_cnt_30d'
].fillna(df.loc[df['user_age_days'] < 30, 'txn_cnt_30d'].median())
推荐策略 2:用 -1 标记 + 加一个 is_new_user 二值特征。 树模型能自动学到"值为 -1 时往哪个方向分裂"。
七、常见坑与避坑指南
坑 1:窗口重叠导致特征高度相关
# 7天、14天、30天窗口的 sum 特征相关系数往往 > 0.95
# 没必要三个都保留,保留最短的和最长的,中间档删掉
df[['txn_amt_sum_7d', 'txn_amt_sum_14d', 'txn_amt_sum_30d']].corr()
# 只保留 7d 和 30d,14d 冗余
坑 2:周末效应
7 天窗口的周一数据和周五数据分布完全不同——因为周六周日交易量天然低。解决方案:用 14 天或 28 天(完整周)替代 7 天短窗。
坑 3:窗口计算性能
不要对每一条样本都跑一次完整 groupby——10 万用户 × 90 天流水 = 几千万行,O(n²) 会炸。
# ❌ 慢:逐行循环计算
# ✅ 快:一次 groupby + rolling + merge
df['event_date'] = pd.to_datetime(df['event_date'])
df = df.sort_values(['user_id', 'event_date'])
# rolling 只对已排序的 group 内生效
df['txn_cnt_7d'] = df.groupby('user_id')['txn_amt'].transform(
lambda x: x.rolling('7D', min_periods=1).count()
)
八、总结
时间窗口特征设计不是什么高深的算法问题,但它是风控模型从"能跑"到"能用"的关键一步。记住五条:
- 每个窗口特征都要对应一个业务假设——不是从配置表里批量生成出来的
- 时间泄露是头号杀手——训练集 AUC 虚高 0.3 的常见元凶,每个样本用自己的 observation_date
- 长短窗搭配 + 变化率交叉——比单一窗口聚合值的区分力高得多
- 生产环境走预计算 + KV 存储——别在线上做实时 groupby
- 新用户窗口为空不是 bug——用 -1 标记 +
is_new_user特征,让模型自己学
下次模型上线后 KS 掉得比预期多,别急着调参——先检查你的时间窗口有没有时间泄露、缺失值处理对不对、长短窗搭配合不合理。十个模型翻车,六个死在特征工程,四个里的三个又死在了时间窗口上。