风险模型构建实战:从特征工程到模型部署
风险模型是金融科技领域的核心技术之一。我在多个风控项目中积累了从数据准备到模型上线的完整经验,这篇文章分享风险模型构建的全流程,包括特征工程、模型选择、评估指标和生产部署。
业务场景与数据来源
我参与过的风控项目主要包括:
- 信用评分模型:基于用户行为数据预测违约概率,用于授信决策
- 欺诈检测模型:实时识别异常交易行为,防止资金损失
- 反洗钱模型:检测可疑交易模式,满足监管合规要求
数据来源通常包括:
- 用户基础信息:年龄、性别、职业、收入水平
- 历史行为数据:交易记录、还款记录、登录日志
- 外部数据:征信报告、运营商数据、电商行为数据
- 设备信息:手机型号、IP地址、地理位置
数据准备与预处理
风控数据的特点是不平衡——正常样本远多于异常样本。这一步的核心是处理缺失值、异常值和类别变量。
import pandas as pd
import numpy as np
data = pd.read_csv('risk_data.csv')
missing_ratio = data.isnull().mean()
cols_to_drop = missing_ratio[missing_ratio > 0.3].index.tolist()
data = data.drop(cols_to_drop, axis=1)
num_cols = data.select_dtypes(include=[np.number]).columns
for col in num_cols:
data[col] = data[col].fillna(data[col].median())
cat_cols = data.select_dtypes(include=['object']).columns
for col in cat_cols:
data[col] = data[col].fillna('missing')
特征工程:风控模型的灵魂
风控模型的效果80%取决于特征工程。我总结了一套可复用的特征工程流程:
1. 分箱处理(WOE编码)
连续变量分箱是风控特征工程的核心技术,将连续值转化为离散区间,然后计算每个区间的WOE(Weight of Evidence)值。
import pandas as pd
import numpy as np
def woe_binning(df, feature, target, n_bins=5):
df = df.copy()
df['bin'] = pd.qcut(df[feature], q=n_bins, duplicates='drop')
grouped = df.groupby('bin').agg(
bad=('target', 'sum'),
total=('target', 'count')
)
grouped['good'] = grouped['total'] - grouped['bad']
grouped['bad_rate'] = grouped['bad'] / grouped['bad'].sum()
grouped['good_rate'] = grouped['good'] / grouped['good'].sum()
grouped['woe'] = np.log(grouped['bad_rate'] / grouped['good_rate'])
grouped['iv'] = (grouped['bad_rate'] - grouped['good_rate']) * grouped['woe']
iv = grouped['iv'].sum()
bin_map = grouped['woe'].to_dict()
df['woe'] = df['bin'].map(bin_map)
return df, bin_map, iv
for feature in ['age', 'income', 'loan_amount']:
data, bin_map, iv = woe_binning(data, feature, 'target')
2. 时间窗口特征
基于时间窗口提取用户行为特征,这是风控模型最重要的特征来源之一。
def create_time_window_features(df):
df = df.copy()
df['transaction_date'] = pd.to_datetime(df['transaction_date'])
windows = [7, 14, 30, 90]
for window in windows:
df[f'tx_count_{window}d'] = df.groupby('user_id')['transaction_date'].transform(
lambda x: x.rolling(f'{window}D').count()
)
df[f'tx_amount_sum_{window}d'] = df.groupby('user_id')['amount'].transform(
lambda x: x.rolling(f'{window}D').sum()
)
df[f'tx_amount_std_{window}d'] = df.groupby('user_id')['amount'].transform(
lambda x: x.rolling(f'{window}D').std()
)
df[f'active_days_{window}d'] = df.groupby('user_id')['transaction_date'].transform(
lambda x: x.rolling(f'{window}D').apply(lambda y: y.nunique())
)
return df
3. 用户画像特征
def create_user_profile_features(df):
df = df.copy()
user_stats = df.groupby('user_id').agg(
total_transactions=('transaction_id', 'count'),
total_amount=('amount', 'sum'),
avg_transaction_amount=('amount', 'mean'),
max_transaction_amount=('amount', 'max'),
first_transaction_date=('transaction_date', 'min'),
last_transaction_date=('transaction_date', 'max'),
device_count=('device_id', 'nunique'),
ip_count=('ip_address', 'nunique'),
city_count=('city', 'nunique')
).reset_index()
user_stats['tenure_days'] = (user_stats['last_transaction_date'] -
user_stats['first_transaction_date']).dt.days
user_stats['transaction_frequency'] = user_stats['total_transactions'] / \
user_stats['tenure_days'].replace(0, 1)
return user_stats
模型选择与训练
风控模型的选择需要平衡模型效果和可解释性。以下是我常用的模型对比:
1. Logistic Regression(首选)
逻辑回归是风控领域的"瑞士军刀"——简单、稳定、可解释性强,适合作为基准模型。
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
X_train, X_test, y_train, y_test = train_test_split(
features, target, test_size=0.2, stratify=target, random_state=42
)
model = LogisticRegression(
penalty='l2',
C=1.0,
class_weight='balanced',
random_state=42
)
model.fit(X_train, y_train)
y_pred_proba = model.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, y_pred_proba)
2. XGBoost(进阶选择)
当逻辑回归的效果达不到业务要求时,XGBoost是首选的非线性模型。
import xgboost as xgb
dtrain = xgb.DMatrix(X_train, label=y_train)
dtest = xgb.DMatrix(X_test, label=y_test)
params = {
'objective': 'binary:logistic',
'eval_metric': 'auc',
'max_depth': 6,
'learning_rate': 0.05,
'subsample': 0.8,
'colsample_bytree': 0.8,
'scale_pos_weight': len(y_train[y_train == 0]) / len(y_train[y_train == 1]),
'seed': 42
}
model = xgb.train(
params,
dtrain,
num_boost_round=200,
evals=[(dtest, 'test')],
early_stopping_rounds=20,
verbose_eval=10
)
3. LightGBM(性能优化)
LightGBM在处理大规模数据时比XGBoost更快,内存占用更低。
import lightgbm as lgb
lgb_train = lgb.Dataset(X_train, y_train)
lgb_test = lgb.Dataset(X_test, y_test, reference=lgb_train)
params = {
'objective': 'binary',
'metric': 'auc',
'boosting_type': 'gbdt',
'num_leaves': 31,
'learning_rate': 0.05,
'feature_fraction': 0.9,
'bagging_fraction': 0.8,
'bagging_freq': 5,
'scale_pos_weight': len(y_train[y_train == 0]) / len(y_train[y_train == 1])
}
model = lgb.train(
params,
lgb_train,
num_boost_round=200,
valid_sets=[lgb_test],
early_stopping_rounds=20
)
模型评估:不止于AUC
风控模型的评估需要关注多个维度,不能只看AUC:
from sklearn.metrics import (
roc_auc_score,
precision_score,
recall_score,
f1_score,
confusion_matrix
)
def evaluate_model(y_true, y_pred, y_pred_proba):
metrics = {}
metrics['auc'] = roc_auc_score(y_true, y_pred_proba)
metrics['precision'] = precision_score(y_true, y_pred)
metrics['recall'] = recall_score(y_true, y_pred)
metrics['f1'] = f1_score(y_true, y_pred)
tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
metrics['accuracy'] = (tp + tn) / (tp + tn + fp + fn)
metrics['false_positive_rate'] = fp / (fp + tn)
metrics['false_negative_rate'] = fn / (fn + tp)
return metrics
业务视角的评估指标:
- KS值:评估模型区分能力,KS > 0.3表示模型有较好的区分度
- Lift值:top 10%样本的命中率是随机选择的倍数
- PSI值:群体稳定性指标,PSI < 0.1表示模型稳定
- 业务收益:模型上线后减少的损失金额
def calculate_ks(y_true, y_pred_proba):
df = pd.DataFrame({'y': y_true, 'score': y_pred_proba})
df = df.sort_values('score', ascending=False).reset_index(drop=True)
df['cum_bad'] = df['y'].cumsum()
df['cum_good'] = (1 - df['y']).cumsum()
df['bad_rate'] = df['cum_bad'] / df['y'].sum()
df['good_rate'] = df['cum_good'] / (1 - df['y']).sum()
df['ks'] = abs(df['bad_rate'] - df['good_rate'])
return df['ks'].max()
def calculate_psi(expected, actual):
bins = np.histogram_bin_edges(expected, bins=10)
expected_counts = np.histogram(expected, bins=bins)[0]
actual_counts = np.histogram(actual, bins=bins)[0]
expected_probs = expected_counts / len(expected)
actual_probs = actual_counts / len(actual)
psi = 0
for e, a in zip(expected_probs, actual_probs):
if e > 0 and a > 0:
psi += (a - e) * np.log(a / e)
return psi
模型部署与监控
模型上线不是终点,持续监控才是关键。
1. 模型打包与部署
import joblib
model = xgb.Booster()
model.load_model('risk_model.model')
joblib.dump({
'model': model,
'bin_maps': bin_maps,
'feature_names': feature_names,
'version': '1.0.0',
'deploy_date': pd.Timestamp.now()
}, 'risk_model_v1.joblib')
from flask import Flask, request, jsonify
import joblib
app = Flask(__name__)
model_dict = joblib.load('risk_model_v1.joblib')
model = model_dict['model']
@app.route('/predict', methods=['POST'])
def predict():
data = request.json
features = preprocess(data)
dmatrix = xgb.DMatrix(features)
probability = model.predict(dmatrix)[0]
return jsonify({
'risk_score': float(probability),
'risk_level': 'high' if probability > 0.7 else 'medium' if probability > 0.4 else 'low',
'model_version': model_dict['version']
})
2. 模型监控
import time
class ModelMonitor:
def __init__(self):
self.predictions = []
self.actuals = []
self.feature_distributions = {}
def log_prediction(self, features, prediction, timestamp=None):
if timestamp is None:
timestamp = time.time()
self.predictions.append({
'features': features,
'prediction': prediction,
'timestamp': timestamp
})
def log_actual(self, prediction_id, actual):
for pred in self.predictions:
if pred['prediction_id'] == prediction_id:
pred['actual'] = actual
self.actuals.append(pred)
break
def calculate_drift(self, feature_name):
if feature_name not in self.feature_distributions:
return None
recent_features = [p['features'][feature_name] for p in self.predictions[-1000:]]
baseline = self.feature_distributions[feature_name]
recent_mean = np.mean(recent_features)
baseline_mean = np.mean(baseline)
drift_score = abs(recent_mean - baseline_mean) / baseline_mean
return drift_score
monitor = ModelMonitor()
@app.route('/predict', methods=['POST'])
def predict():
data = request.json
features = preprocess(data)
monitor.log_prediction(features, prediction)
dmatrix = xgb.DMatrix(features)
probability = model.predict(dmatrix)[0]
return jsonify({'risk_score': float(probability)})
实际项目踩的坑
坑一:样本选择偏差。训练数据只包含已授信用户,而实际预测对象是未授信用户,两者分布不同。解决方案:使用时间切片验证,确保训练数据时间早于验证数据。
坑二:特征泄露。使用了预测时不可用的特征。比如用"是否逾期"预测"是否逾期",或者用未来的交易数据。解决方案:严格按照时间顺序划分训练集和测试集。
坑三:模型漂移。模型上线后效果逐渐下降,因为用户行为和市场环境在变化。解决方案:设置PSI监控阈值,超过阈值自动触发模型重新训练。
坑四:可解释性不足。业务方需要知道"为什么这个用户被拒绝"。解决方案:使用SHAP或LIME解释模型预测结果,给出特征贡献度。
import shap
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
shap.summary_plot(shap_values, X_test)
shap.force_plot(explainer.expected_value, shap_values[0], X_test.iloc[0])
总结
风险模型构建是一个"数据驱动 + 业务理解"的迭代过程。核心要点:
- 从简单模型开始:先用逻辑回归建立基准,验证特征有效性
- 特征工程是核心:好的特征比好的模型更重要
- 评估指标要全面:不止看AUC,还要关注KS、PSI、业务收益
- 监控不可少:模型上线后需要持续监控,及时发现漂移
记住:风控模型的目标不是追求最高的AUC,而是在控制误拒率的前提下最大化风险识别能力。一个完美的模型如果误拒率太高,业务方也不会接受。