← 返回文章列表

时间窗口特征设计风控实战

时间窗口特征设计:风控模型中最容易被低估的工程问题

模型跑出 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()
)

八、总结

时间窗口特征设计不是什么高深的算法问题,但它是风控模型从"能跑"到"能用"的关键一步。记住五条:

  1. 每个窗口特征都要对应一个业务假设——不是从配置表里批量生成出来的
  2. 时间泄露是头号杀手——训练集 AUC 虚高 0.3 的常见元凶,每个样本用自己的 observation_date
  3. 长短窗搭配 + 变化率交叉——比单一窗口聚合值的区分力高得多
  4. 生产环境走预计算 + KV 存储——别在线上做实时 groupby
  5. 新用户窗口为空不是 bug——用 -1 标记 + is_new_user 特征,让模型自己学

下次模型上线后 KS 掉得比预期多,别急着调参——先检查你的时间窗口有没有时间泄露、缺失值处理对不对、长短窗搭配合不合理。十个模型翻车,六个死在特征工程,四个里的三个又死在了时间窗口上。