金融风控特征平台案例:实时性和可解释性要一起设计

0阅读13分钟

口径对不上,是风控系统最常见的暗病

做风控系统头两年,我以为最难的是模型——选 XGBoost 还是 LightGBM,调多少棵树,学习率设多少。后来发现模型只是整条链路里的一个环节,真正让人反复踩坑的,是特征。

具体来说,是离线训练用的特征和线上推理拿到的特征,口径对不上。

这个问题不会报错,不会抛异常,甚至不会影响通过率。它只会让模型效果在上线两三个月后慢慢下滑,PSI 悄悄爬过 0.1 的警戒线,等到业务方发现拒绝率异常,特征漂移已经发生了好几周。

这篇文章把我这几年在风控特征平台上踩过的坑、做过的调整整理出来,涉及特征口径一致性、实时与离线双链路、策略编排和可解释性设计。每个部分都有具体场景和代码示例,希望能帮你少走一些弯路。

理论基础:特征平台到底在解决什么问题

Feature Store 这个概念最早是 Uber 在 2017 年提出的,核心目标是解决机器学习工程中「特征定义-训练-服务」三者的口径一致性。在金融风控场景下,这个问题被进一步放大,因为监管合规要求每一次决策都可追溯、可解释。

根据 Databricks 对 Feature Store 的定义,它需要同时支持离线特征存储(用于模型训练和历史回溯)和在线特征服务(用于实时推理),两者使用同一套特征定义和计算逻辑。Trio 在分析金融科技反欺诈系统架构时,把整条链路拆成五层:事件流接入 → 特征服务 → 模型评分 → 决策编排 → 合规审计,整个决策链路需要在 100ms 内完成。

关于模型漂移,IBM 的定义框架把它分成两类:数据漂移(输入数据分布变化)和概念漂移(输入输出关系变化)。风控场景下两者经常同时出现。检测手段上,PSI(Population Stability Index)是金融行业最常用的指标,PSI > 0.2 意味着需要正式排查并考虑重新训练。

漂移类型触发原因检测方法风控场景举例
数据漂移输入特征分布变化PSI、KS 检验新用户群体行为与训练数据显著不同
概念漂移特征-标签关系变化分时间段 AUC 对比经济下行后「收入」对违约的预测力下降
突变漂移外部事件导致断崖变化时序异常检测政策调整导致某类交易骤增
渐进漂移缓慢演变滑动窗口 PSI 趋势消费习惯随季节逐步改变

Nubank 工程团队在生产实践中总结了一个关键经验:Feature Store 能缓解但不能消除训练-服务偏差。他们的应对方案是定期用离线逻辑重新计算近期样本的特征(排除目标变量),和在线推理时记录的值做自动对比。

案例一:离线训练和线上推理的口径偏差

场景

某消费信贷产品上线了一套反欺诈模型,离线 AUC 0.89,上线第一周表现不错,第三周开始 KS 值持续下降。排查发现是「近 7 天交易次数」特征的计算口径不一致。

问题

离线训练用 Spark 跑批处理,时间窗口是自然日。线上推理用 Flink 流计算,窗口是当前时刻往前推 7×24 小时。离线会把当天完整数据算进去,线上不会。当用户交易集中在深夜,差异就会被放大。

更隐蔽的是,离线数据源是经过 T+1 清洗的宽表,包含后到数据的修正。线上拿不到这些修正,只能基于实时流做近似计算。

# ❌ 坏做法:离线和线上各写一套逻辑
 
# 离线训练代码 (Spark)
def calc_txn_count_offline(user_id, date):
    """离线:自然日窗口,用清洗后宽表"""
    start = date - timedelta(days=7)
    end = date - timedelta(days=1)
    return spark.sql(f"""
        SELECT COUNT(*) as txn_count
        FROM cleaned_transaction_table
        WHERE user_id = '{user_id}'
          AND txn_date BETWEEN '{start}' AND '{end}'
    """).first()['txn_count']
 
# 线上推理代码 (Flink)
def calc_txn_count_online(user_id, current_time):
    """线上:滑动窗口,用实时流"""
    window_start = current_time - timedelta(hours=7*24)
    return flink_stream \
        .filter(lambda x: x.user_id == user_id) \
        .filter(lambda x: x.timestamp >= window_start) \
        .count()
# 问题:两套逻辑独立维护,口径偏差无法被自动发现
# ✅ 好做法:统一特征定义,自动生成离线和在线计算逻辑
 
# 特征注册中心:统一声明式定义
class TxnCountFeature(FeatureDefinition):
    """用一份定义同时生成离线和在线计算逻辑"""
    name = "user_txn_count_7d"
    description = "用户近7天交易次数"
    data_type = "INT"
    entity_key = "user_id"
 
    # 离线计算:批处理逻辑
    def batch_compute(self, context):
        return context.table("raw_transaction") \
            .filter(col("txn_time").between(
                context.point_in_time - interval("7 days"),
                context.point_in_time
            )) \
            .groupBy("user_id") \
            .agg(count("*").alias(self.name))
 
    # 在线计算:流处理逻辑
    def stream_compute(self, context):
        return context.stream("transaction_stream") \
            .key_by("user_id") \
            .window(sliding_window(size="7 days", slide="1 hour")) \
            .agg(count("*").alias(self.name))
 
    # 口径校验:离线和在线结果对比
    def consistency_check(self, offline_result, online_result):
        diff_rate = abs(offline_result - online_result) / max(offline_result, 1)
        return diff_rate < 0.05  # 允许5%以内偏差
# 好处:特征口径在一处定义,偏差有自动化检测

修复方案是引入特征注册中心,把特征的窗口定义、数据源、聚合逻辑都声明在一处。离线和在线各有计算适配器,核心逻辑来自同一份定义。同时增加口径校验任务,每天自动抽样对比离线和在线特征值,偏差超过 5% 就触发告警。

案例二:实时特征延迟导致决策超时

场景

一个交易反欺诈系统要求端到端延迟在 100ms 以内。上线初期运行良好,随着业务增长,「近 1 小时同设备交易次数」这个实时特征的 P99 延迟从 8ms 爬升到 45ms,决策超时率从 0.1% 涨到 2.3%。

问题

这个特征底层用的是 Redis Cluster 存储,按 device_id 做 hash slot 分布。早期设备量小,热点设备很少。业务量上来之后,某几个共享设备(比如公司的测试机、网吧的公共电脑)产生了大量写入,造成热点 slot,单个 Redis 节点的 CPU 被打满。

另一个问题是特征计算链路太长:实时流要经过 Kafka → Flink 窗口聚合 → 写入 Redis → 决策引擎读取。Flink 的 checkpoint 间隔是 30 秒,意味着窗口聚合结果最多延迟 30 秒才能被读到,而业务要的是「近 1 小时」的数据。

链路环节延迟预算实际 P50实际 P99风险等级
事件流接入(Kafka)≤10ms3ms8ms
特征查询(Redis)≤15ms4ms45ms
模型评分≤20ms8ms15ms
规则引擎执行≤15ms5ms12ms
审计日志写入≤5ms2ms4ms
合计≤65ms22ms84ms
// ❌ 坏做法:所有实时特征走同一个 Redis 集群,无隔离
 
// 决策引擎里的特征获取
public RiskDecision evaluate(TransactionEvent event) {
    String deviceId = event.getDeviceId();
    String userId = event.getUserId();
 
    // 所有特征查同一个 Redis,热点 key 互相影响
    int deviceTxnCount = redis.get("device:txn_count:1h:" + deviceId);
    int userTxnAmount = redis.get("user:txn_amount:24h:" + userId);
    int ipTxnCount = redis.get("ip:txn_count:5m:" + event.getIp());
    // ... 还有十几个特征
 
    double riskScore = model.predict(assembleFeatures(deviceTxnCount, userTxnAmount, ipTxnCount));
    return applyRules(riskScore, event);
}
// 问题:一个热点特征拖慢整条链路,无降级方案
// ✅ 好做法:特征分级存储 + 超时降级 + 预计算缓存
 
public RiskDecision evaluate(TransactionEvent event) {
    // 1. 特征分级:核心特征走高性能存储,辅助特征走普通存储
    FeatureSet coreFeatures = coreFeatureStore.batchGet(
        List.of("user_txn_count_24h", "user_txn_amount_24h"),
        event.getEntityKeys(),
        Duration.ofMillis(10)  // 核心特征超时10ms
    );
 
    // 2. 非核心特征异步获取,设超时降级
    FeatureSet auxFeatures = auxFeatureStore.batchGetAsync(
        List.of("device_txn_count_1h", "ip_txn_count_5m"),
        event.getEntityKeys(),
        Duration.ofMillis(5)   // 辅助特征只给5ms
    ).orElse(FeatureSet.DEFAULT_VALUES);  // 超时则用默认值
 
    // 3. 预计算的高频特征走本地缓存(1分钟刷新)
    int globalFraudRateToday = localCache.get("global_fraud_rate_today");
 
    // 4. 组装特征并评分,记录每个特征的获取耗时
    RiskScore score = modelService.predict(
        coreFeatures.merge(auxFeatures).add(globalFraudRateToday),
        event.getTraceId()
    );
 
    return ruleEngine.execute(score, event);
}
// 好处:特征隔离、超时降级、全链路延迟可观测

改造思路分三步。第一,特征分级存储——核心特征(用户维度)用独立 Redis 集群,辅助特征(设备、IP 维度)用另一个集群,物理隔离。第二,超时降级机制——每个特征查询都带超时参数,超时返回默认值,审计日志里标记降级特征。第三,全局统计类特征改用本地缓存 + 定时刷新,减少网络开销。

案例三:模型拒绝无法解释原因

场景

这家持牌消费金融公司接到监管检查,要求对近三个月的拒绝决策提供「拒绝原因说明」。客服收到的投诉里,40% 在问「为什么我的申请被拒了」。模型团队只给得出风险分数,无法说明具体原因。

问题

模型是 XGBoost 二分类,输出一个 0 到 1 的概率值。决策引擎在分数超过 0.7 时拒绝,0.5-0.7 进人工审核,低于 0.5 放行。但没有人记录每笔决策里哪些特征把分数拉高。

更麻烦的是,策略引擎里还有 20 多条硬规则,命中情况分散在不同日志系统里,要串起来需要大量人工工作。

# ❌ 坏做法:只记录最终分数,不记录决策依据
 
def make_decision(features, model, rules):
    risk_score = model.predict(features)
 
    if risk_score > 0.7:
        return Decision(status="REJECT")
    elif risk_score > 0.5:
        return Decision(status="REVIEW")
    else:
        return Decision(status="APPROVE")
 
    # 问题:没有任何解释信息,监管检查时无法回答
# ✅ 好做法:SHAP 值 + 规则命中 + 决策链路全记录
 
import shap
 
class ExplainableDecisionEngine:
    def __init__(self, model, rules, explainer=None):
        self.model = model
        self.rules = rules
        # 预计算 SHAP explainer,避免每次决策都初始化
        self.explainer = explainer or shap.TreeExplainer(model)
 
    def make_decision(self, features: dict, trace_id: str) -> Decision:
        risk_score = self.model.predict_proba([features])[0][1]
 
        # 1. 计算 SHAP 值:每个特征对预测结果的贡献
        shap_values = self.explainer.shap_values([features])[0]
        feature_contributions = self._rank_contributions(features, shap_values)
 
        # 2. 执行规则引擎,记录命中了哪些规则
        rule_hits = []
        for rule in self.rules:
            hit, reason = rule.evaluate(features)
            if hit:
                rule_hits.append(RuleHit(rule_id=rule.id, reason=reason))
 
        # 3. 综合决策
        final_status = self._determine_status(risk_score, rule_hits)
 
        # 4. 生成可读的拒绝/放行原因
        explanation = self._build_explanation(
            final_status, risk_score, feature_contributions, rule_hits
        )
 
        # 5. 全链路审计日志
        audit_log = AuditRecord(
            trace_id=trace_id,
            timestamp=datetime.utcnow(),
            status=final_status,
            risk_score=risk_score,
            top_contributors=feature_contributions[:5],  # 前5个最重要特征
            rule_hits=rule_hits,
            explanation=explanation,
            feature_snapshot=features,  # 完整特征快照,用于事后回溯
        )
        self.audit_store.save(audit_log)
 
        return Decision(status=final_status, explanation=explanation)
 
    def _rank_contributions(self, features, shap_values):
        """按 SHAP 绝对值排序,找出贡献最大的特征"""
        contributions = []
        for feat_name, shap_val in zip(features.keys(), shap_values):
            contributions.append(FeatureContribution(
                feature=feat_name,
                value=features[feat_name],
                shap_value=round(shap_val, 6),
                direction="positive" if shap_val > 0 else "negative"
            ))
        return sorted(contributions, key=lambda x: abs(x.shap_value), reverse=True)
 
    def _build_explanation(self, status, score, contributions, rule_hits):
        """生成人话版解释,给客服和监管看"""
        if status == "REJECT":
            if rule_hits:
                return f"命中规则拒绝:{rule_hits[0].reason}"
            top_reasons = [
                f"{c.feature}={c.value}{'推高风险' if c.direction == 'positive' else '拉低风险'})"
                for c in contributions[:3]
            ]
            return f"综合风险评分 {score:.3f},主要因素:{'、'.join(top_reasons)}"
        return "通过"
# 好处:每笔决策都有完整的解释链路,可应对监管审查和用户咨询

这套方案的关键点有三个。第一,SHAP 值不是每次决策都实时计算——TreeExplainer 初始化一次,推理开销很小,但深度学习模型需要考虑异步计算或采样近似。第二,规则命中和模型解释分开记录,最终合并到同一个审计记录。第三,_build_explanation 生成的解释面向非技术人员,客服可以直接念给用户听。

整体架构:从数据到决策的全链路

流程图画布 · 115%
Mermaid 流程图加载中...

这张图把整条链路串了起来。核心设计原则是「一份特征定义,两条计算链路,自动校验一致性」。特征注册中心是整个架构的枢纽——所有特征的口径定义、版本管理、数据来源都在这一层,离线 Spark 任务和在线 Flink 任务都从这里拉取计算逻辑。

实时特征 vs 离线特征的技术选型

维度实时特征离线特征混合特征
计算引擎Flink / Spark StreamingSpark Batch / Hive SQLFlink + Spark 双链路
存储介质Redis Cluster / HBaseHive / Iceberg / ParquetRedis(热)+ Hive(冷)
延迟要求P99 < 15ms分钟到小时级分级处理
数据完整性允许少量缺失,有降级要求完整,后到数据修正实时近似 + 离线修正
典型特征近 N 分钟交易次数/金额近 N 月逾期次数/信用历史近 7 天交易统计
口径风险窗口边界不精确、乱序T+1 修正导致与实时不一致双链路结果需对齐
存储方案适用场景优势劣势典型延迟
Redis Cluster高频点查、计数器低延迟、数据结构丰富内存成本高、无复杂查询P99 < 5ms
StarRocks / Doris多维分析、实时聚合SQL 查询、实时导入写入吞吐受限P99 < 20ms
HBase大宽表、历史快照写入吞吐高、存储成本低无事务、查询不灵活P99 < 30ms
Apache Paimon实时特征湖、流式更新流批一体、ACID 支持生态尚不成熟P99 < 50ms

策略编排:规则、模型、人工审核的组合方式

实际风控不会只靠一个模型做决策。常见模式是规则先过滤明显风险,模型处理复杂判断,人工审核兜住灰色地带。策略引擎需要支持这三种方式的灵活组合。

策略层级处理对象响应速度成本可解释性
硬规则(名单/阈值)已知风险模式毫秒级极低完全透明
模型评分复杂非线性判断毫秒级需要 SHAP 等工具
人工审核灰色地带、大额决策分钟到小时审核员主观判断
专家系统(决策树)监管硬性要求毫秒级完全透明

策略编排的一个关键原则是「可降级」:模型服务挂了,系统应退回到纯规则决策,而不是全部拒绝或放行。

# ✅ 策略编排配置示例:分层决策 + 降级兜底
 
decision_flow:
  name: "信贷申请风控决策"
  version: "2.1.0"
 
  stages:
    - name: "前置规则"
      type: "rule_engine"
      timeout_ms: 10
      rules:
        - id: "R001"
          condition: "user IN black_list"
          action: "REJECT"
          reason: "命中黑名单"
        - id: "R002"
          condition: "application_amount > 500000"
          action: "REVIEW"
          reason: "大额申请需人工审核"
 
    - name: "模型评分"
      type: "model_scoring"
      model_id: "xgb_fraud_v3"
      timeout_ms: 20
      fallback:  # 模型服务异常时的降级
        action: "USE_RULES_ONLY"
        default_score: 0.5
 
    - name: "综合决策"
      type: "decision_matrix"
      inputs: ["前置规则结果", "模型评分"]
      matrix:
        - condition: "前置规则 = REJECT"
          action: "REJECT"
          explanation_template: "命中规则:{rule_reason}"
        - condition: "模型评分 > 0.7"
          action: "REJECT"
          explanation_template: "风险评分 {score},主要因素:{top_shap_reasons}"
        - condition: "模型评分 > 0.5 AND 模型评分 <= 0.7"
          action: "REVIEW"
          explanation_template: "风险评分 {score},进入人工审核"
        - condition: "模型评分 <= 0.5"
          action: "APPROVE"
          explanation_template: "通过"

监控体系:漂移检测、误伤分析和特征健康度

风控系统的监控不是看看 QPS 和延迟就够了。更关键的是业务指标和模型指标。

监控类别具体指标告警阈值检测频率
模型性能PSI、AUC、KSPSI > 0.1 告警,> 0.2 回退每小时
业务指标通过率、拒绝率、误伤率通过率波动 > 5%每 15 分钟
特征健康度缺失率、零值率、分布偏移缺失率 > 3%每 5 分钟
系统指标特征查询延迟、超时率P99 > 预算的 80%实时
审核质量人工审核翻转率翻转率 > 30%每日
# ✅ 特征健康度监控示例
 
class FeatureHealthMonitor:
    """持续监控特征质量,发现异常自动告警"""
 
    def __init__(self, feature_registry, alert_manager):
        self.registry = feature_registry
        self.alerts = alert_manager
 
    def check_feature_drift(self, feature_name: str):
        """对比在线特征和离线基线的分布差异"""
        feature_def = self.registry.get(feature_name)
        online_dist = self._get_online_distribution(feature_name, window="1h")
        baseline_dist = self._get_baseline_distribution(feature_name)
 
        psi = self._calculate_psi(online_dist, baseline_dist)
 
        status = "HEALTHY"
        if psi > 0.2:
            status = "CRITICAL"
            self.alerts.send(
                level="P1",
                message=f"特征 {feature_name} PSI={psi:.4f},严重漂移",
                action_required="立即排查,考虑模型回退"
            )
        elif psi > 0.1:
            status = "WARNING"
            self.alerts.send(
                level="P2",
                message=f"特征 {feature_name} PSI={psi:.4f},轻微漂移",
                action_required="关注趋势,准备重训方案"
            )
 
        # 记录监控数据,用于趋势分析
        self._record_metric(feature_name, "psi", psi, status)
        return FeatureHealthReport(
            feature=feature_name,
            psi=psi,
            status=status,
            online_sample_count=len(online_dist),
            baseline_sample_count=len(baseline_dist),
        )
 
    def check_feature_completeness(self, feature_name: str):
        """监控特征缺失率和零值率"""
        recent_values = self._get_online_values(feature_name, window="5m")
        total = len(recent_values)
        missing = sum(1 for v in recent_values if v is None)
        zero_val = sum(1 for v in recent_values if v == 0)
 
        missing_rate = missing / total if total > 0 else 0
        zero_rate = zero_val / total if total > 0 else 0
 
        if missing_rate > 0.03:
            self.alerts.send(
                level="P1",
                message=f"特征 {feature_name} 缺失率 {missing_rate:.2%}",
                action_required="检查上游数据源和 Flink 任务状态"
            )
 
        return CompletenessReport(
            feature=feature_name,
            missing_rate=missing_rate,
            zero_rate=zero_rate,
            sample_count=total,
        )

上线前检查清单

以下是我在实际项目中积累的检查清单,按阶段分组。每一项都是踩过坑之后加上去的。

特征定义阶段

  • 所有特征都有唯一的注册 ID 和版本号,命名遵循 &#123;业务域&#125;_&#123;实体&#125;_&#123;聚合方式&#125;_&#123;时间窗口&#125; 规范
  • 离线和在线使用同一份特征定义,计算逻辑由定义自动生成或经过口径对齐校验
  • 特征的 Point-in-Time 语义明确——训练时用的是历史快照,不是未来数据
  • 金融金额字段统一使用 DECIMAL 类型,不使用 FLOAT,避免精度丢失

开发与测试阶段

  • 特征覆盖率测试:用生产数据回放,确认没有大面积缺失的特征
  • 特征分布测试:对比训练集和验证集的特征分布,PSI < 0.1
  • 边界条件测试:空值、零值、极端值(如单笔交易 1 亿元)都有明确处理逻辑
  • 降级方案测试:模拟特征服务超时或不可用,确认决策引擎能降级到规则兜底

上线部署阶段

  • 特征查询延迟 P99 在预算范围内(参考:单特征 ≤ 15ms)
  • 审计日志能记录完整的决策链路:特征快照、SHAP 值、规则命中、最终决策
  • 监控告警已配置:PSI、缺失率、通过率、延迟等核心指标都有阈值和通知渠道
  • 回退方案就绪:如果新模型效果异常,能在 10 分钟内切回上一版本

持续运营阶段

  • 每日自动对比在线和离线特征值,偏差超阈值自动告警
  • 每周生成模型漂移报告,包含 PSI 趋势、特征重要性变化、分群表现差异
  • 每月做一次人工审核翻转率分析,翻转率持续偏高说明模型阈值需要调整
  • 每季度检查特征有效性,长期无贡献的特征要下线,减少系统复杂度

几个容易忽略的细节

做特征平台这几年,有几个教训值得单独提一下。

第一,Point-in-Time 正确性。如果用户在 T 时刻发起交易,训练数据里「近 7 天交易次数」应该只包含 T-7 到 T 之间的数据。但如果离线宽表是 T+1 生成的,宽表里的数据其实包含了 T 之后的交易——这就是数据穿越(data leakage),会导致离线 AUC 虚高,上线后效果大打折扣。

第二,特征的业务可解释性。即使某个组合特征与风险有很强的统计关联,如果无法解释其含义,在合规环境下不可用。「近 3 天深夜交易占比」是可解释的,「PCA 第 7 主成分」不可解释。

第三,特征版本的迁移成本。特征定义改了,所有引用这个特征的模型都要跟着改。Nubank 的经验是引入数据契约,特征 schema 变更要通知所有下游模型负责人。

写在最后

风控特征平台涉及模型、策略、数据、合规和运维多个团队的协作。口径一致性是地基,实时监控是安全网,可解释性是合规底线。先把这三件事做扎实,再考虑更高级的技术。

参考资料

评论 0

0 / 1000