2026-07-27 · 机器学习
特征工程实战从原始数据到模型入模的完整链路
特征工程实战:从原始数据到模型入模的完整链路
特征工程占了风控建模 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 方法论:不是写提示词,是设计交互协议」