← 返回文章列表

特征工程实战从原始数据到模型入模的完整链路

特征工程实战:从原始数据到模型入模的完整链路

特征工程占了风控建模 80% 的工作量。不是你模型调得不好,是你的特征根本就没把信息榨干。这篇文章不讲"特征工程很重要"这种废话,只讲怎么做。


1. 时间窗口:风控特征的生命线

风控场景下,所有特征必须带时间窗口。没带窗口的特征 = 数据泄露。

1.1 标签窗口(Label Window)

观测期(OBS)     表现期(PER)
├───────────┼─────────────┤
  12个月          6个月

申请时间点 T=0,往前看 12 个月做特征,往后看 6 个月看是否逾期
def define_label(row, obs_end, perf_end):
    """
    标签必须在观测期之后才能用,否则是未来信息
    """
    if row["loan_date"] < obs_end:
        return "exclude"          # 观测期内的样本不能用
    if row.get("overdue_days_after_perf", 0) >= 30:
        return 1
    return 0

铁律:样本的 X 只能来自该样本的申请时间之前,Y 只能来自申请时间之后。违反这一点,所有 AUC 都是假的。

1.2 多粒度时间窗口

WINDOWS = [7, 14, 30, 90, 180, 365]  # 天

def build_temporal_features(user_id, base_date):
    features = {}
    for w in WINDOWS:
        start = base_date - timedelta(days=w)
        window_data = query(f"""SELECT
            COUNT(*) AS cnt,
            SUM(amount) AS total_amt,
            AVG(amount) AS avg_amt
            FROM transactions
            WHERE user_id = {user_id}
              AND trans_date BETWEEN '{start}' AND '{base_date}'""")

        features[f"tx_cnt_{w}d"]      = window_data["cnt"]
        features[f"tx_total_{w}d"]    = window_data["total_amt"]
        features[f"tx_avg_{w}d"]      = window_data["avg_amt"]

    # 窗口间比率(比绝对值更稳定)
    features["tx_cnt_ratio_7_30"]   = features["tx_cnt_7d"]  / max(features["tx_cnt_30d"], 1)
    features["tx_cnt_ratio_7_90"]   = features["tx_cnt_7d"]  / max(features["tx_cnt_90d"], 1)
    features["tx_amount_ratio_7_30"]= features["tx_total_7d"]/ max(features["tx_total_30d"], 0.01)

    return features

为什么非要多个窗口

  • 短窗口(7d/14d)捕捉短期异常——黑产不会等你三个月
  • 长窗口(90d/180d)刻画长期行为基线
  • 窗口间比率(7d/30d/90d)反映行为突变,比绝对值更有区分力

2. 衍生特征:不造就是浪费

2.1 比率特征

原始值受基数影响,换成比率立即消除"不同用户体量不同"的噪声:

# ❌ 差
features["avg_balance"] = avg_balance(user_id)

# ✅ 好:负债收入比、额度使用率
features["dti"] = total_debt / max(income, 1)
features["utilization"] = current_balance / max(credit_limit, 1)
features["installment_ratio"] = installment_cnt / max(total_loan_cnt, 1)

2.2 时间间隔特征

事件之间的时间间隔往往比事件本身更有信息量:

def build_interval_features(user_id):
    events = query(f"SELECT trans_date, trans_type FROM transactions WHERE user_id={user_id} ORDER BY trans_date")

    features = {}
    for label, event_type in [("loan", "借款"), ("repay", "还款"), ("apply", "申请")]:
        type_events = [e for e in events if e["trans_type"] == event_type]
        if len(type_events) >= 2:
            intervals = [(type_events[i+1]["trans_date"] - type_events[i]["trans_date"]).days
                         for i in range(len(type_events) - 1)]
            features[f"{label}_avg_interval"] = sum(intervals) / len(intervals)
            features[f"{label}_min_interval"] = min(intervals)
            features[f"{label}_interval_std"] = stdev(intervals) if len(intervals) > 1 else 0

    # 最后一次事件距今(最近一次行为决定了意图)
    for label, event_type in [("loan", "借款"), ("apply", "申请")]:
        type_events = [e for e in events if e["trans_type"] == event_type]
        if type_events:
            features[f"days_since_last_{label}"] = (base_date - type_events[-1]["trans_date"]).days

    return features

最近一次借款距今 3 天 vs 3 个月——这两个人的行为模式完全不同,但"总借款次数"这个特征表现可能一样。


3. 分箱:连续变量到离散特征的转化

3.1 WOE 编码(Weight of Evidence)

import numpy as np

def calc_woe(df, feature, target, bins=10):
    """WOE = ln((坏样本占比) / (好样本占比))"""
    df = df.copy()
    df["bin"] = pd.qcut(df[feature], q=bins, duplicates="drop")

    grouped = df.groupby("bin")[target].agg(["count", "sum"])
    grouped["bad"] = grouped["sum"]
    grouped["good"] = grouped["count"] - grouped["sum"]
    total_bad = grouped["bad"].sum()
    total_good = grouped["good"].sum()

    grouped["bad_pct"] = grouped["bad"] / total_bad
    grouped["good_pct"] = grouped["good"] / total_good
    grouped["woe"] = np.log(grouped["bad_pct"] / grouped["good_pct"])
    grouped["iv"] = (grouped["bad_pct"] - grouped["good_pct"]) * grouped["woe"]

    # IV < 0.02: 无预测能力,该特征可以直接丢弃
    return grouped["iv"].sum(), grouped

iv, woe_table = calc_woe(train_df, "age", "is_bad")
print(f"IV = {iv:.4f}")  # < 0.02 考虑丢弃,> 0.3 强预测力

WOE 的本质:把每个分箱的违约率映射为一个数值。不是简单分段,而是让分段的数值含义与目标变量直接关联。

3.2 什么时候别用分箱

  • 样本量不够(< 1000):分箱后每个箱太少,WOE 不稳定
  • 时间序列特征:分箱丢掉顺序信息
  • 已经做了标准化的连续特征:再分箱是画蛇添足

4. 缺失值不是填个 0 就完了

def handle_missing(df, feature):
    """三个策略,按场景选"""
    # 策略 1:缺失本身是信号(如"收入"为空 = 无业/自由职业)
    df[f"{feature}_is_missing"] = df[feature].isna().astype(int)

    # 策略 2:业务合理默认值(如"逾期次数"为空 = 从未逾期 = 0)
    df[feature] = df[feature].fillna(0)

    # 策略 3:极大极小值标记缺失(树模型友好)
    df[feature] = df[feature].fillna(-9999)  # 树模型把 -9999 当独立分支

    return df

规则:策略 1 对所有特征统一做;策略 2 和 3 之间,连续变量优先策略 3(保留缺失信号),计数变量优先策略 2。


5. 特征筛选:不是越多越好

def select_features(X_train, y_train, max_features=50):
    # 1. 方差过滤(常数特征直接丢)
    constant_cols = [c for c in X_train.columns if X_train[c].nunique() == 1]
    X = X_train.drop(columns=constant_cols)

    # 2. 相关性去重(两两相关系数 > 0.9 的只留 IV 高的)
    corr = X.corr().abs()
    upper = corr.where(np.triu(np.ones(corr.shape), k=1).astype(bool))
    to_drop = [c for c in upper.columns if any(upper[c] > 0.9)]

    # 3. IV 值筛选(< 0.02 的特征没有预测力)
    iv_scores = {c: calc_woe_df(X, c, y_train)[0] for c in X.columns}
    low_iv = [c for c, iv in iv_scores.items() if iv < 0.02]

    # 4. 递归特征消除(贪心保留 Top-N)
    from sklearn.feature_selection import RFE
    from xgboost import XGBClassifier
    selector = RFE(XGBClassifier(), n_features_to_select=max_features)
    selector.fit(X.drop(columns=to_drop + low_iv), y_train)

    return selector.get_support(indices=True)

顺序不能错:先方差过滤 → 再相关性去重 → 再 IV 筛选 → 最后 RFE 选 Top-N。方差过滤最快,放第一步减少后续计算量。


6. 一条完整链路

原始数据
    ↓ 标签窗口定义(OBS 12M + PER 6M)
特征工程层
    ├─ 时间窗口聚合(7d/14d/30d/90d/180d)
    ├─ 窗口间比率(短期/长期 → 行为突变)
    ├─ 时间间隔特征(上次距今天数)
    ├─ 比率特征(DTI / 额度使用率)
    └─ 缺失值信号(缺失 = 特征)
    ↓
特征筛选
    ├─ 方差过滤(去常数)
    ├─ 相关性去重(r > 0.9)
    ├─ IV 筛选(IV < 0.02)
    └─ RFE Top-50
    ↓
WOE 分箱 → XGBoost / LR 入模

关键决策清单

  • 标签窗口是否严格分离了观测期和表现期?
  • 是否有至少 3 个不同粒度的时间窗口?(7d/30d/90d 起步)
  • 是否有短期/长期的窗口间比率特征?
  • 是否有"距最近一次行为的天数"间隔特征?
  • 连续变量缺失是否用 -9999 标记(而非填均值)?
  • 是否先做了方差过滤再进 IV 筛选(减少无效计算)?

下一篇预告:AI 应用方向——「Prompt Engineering 方法论:不是写提示词,是设计交互协议」