金融 AI 评测中召回率与精确率的代价权衡比通用场景更严苛

0 阅读

为什么金融 AI 不能只看 F1

在通用 NLP 或图像分类任务里,大家习惯用 F1 Score 来平衡精确率(Precision)和召回率(Recall)。F1 高,模型就好——这套逻辑在多数场景跑得通。

但一到金融领域,这套就容易翻车。

举个真实例子:一个反欺诈模型如果漏掉一笔 50 万的交易(FN,漏报),直接损失就是 50 万。但如果它把 100 笔正常交易误判为欺诈(FP,误报),客户投诉、客服人力、甚至监管问询的成本加起来,可能远超 50 万。反过来,在信用评分中,拒绝一个优质客户(FP)只损失一笔利息,但放款给一个坏客户(FN)却可能损失整笔本金。

同样的指标,不同的代价函数。

关键在于:金融场景中,FP 和 FN 的经济影响不对称,且差异巨大。这时候再用 F1 最大化来选阈值,很可能选出一个“统计上好看、业务上亏钱”的方案。

代价敏感评测:把钱算进模型里

要解决这个问题,得引入“代价敏感评测”(Cost-Sensitive Evaluation)。核心思想很简单:不追求指标最大,而追求总经济损失最小。

不同金融场景的代价比

我们整理了几个典型金融场景的 FP/FN 代价估算(基于行业经验值):

场景 FP 代价(误报) FN 代价(漏报) 代价比(FN/FP) 优化倾向
反欺诈检测 中等(客户投诉处理) 高(资金损失) 5:1 – 10:1 偏向高召回
信用评估 低(失去利息收入) 高(坏账) 3:1 – 8:1 偏向高召回
合规监控 极高(监管罚款) 中等(漏过可疑) 0.2:1 – 0.5:1 偏向高精确
高风险客户识别 中等 极高(合规风险) 10:1 – 50:1 强偏向召回

注意合规监控这个特例:误报(把正常交易标成可疑)可能触发监管报送,一旦出错就是百万级罚款;而漏报(没发现可疑)反而风险较低。所以这里反而要压低召回率、提高精确率。

总经济代价公式

设每个 FP 的代价为 $C_{FP}$,每个 FN 的代价为 $C_{FN}$,则总代价为:

$$ \text{TotalCost}(\tau) = \text{FP}(\tau) \times C_{FP} + \text{FN}(\tau) \times C_{FN} $$

最优阈值 $\tau^*$ 应使 TotalCost 最小,而不是让 F1 最大。

代码实践:自动找金融最优阈值

下面是一个轻量级的 Python 实现,支持按场景自动加载默认代价矩阵,并对比不同业务下的最优阈值。

import numpy as np
from typing import List, Tuple, Dict
from dataclasses import dataclass

@dataclass
class CostMatrix:
    fp_cost: float     # 误报代价(元)
    fn_cost: float     # 漏报代价(元)
    tp_benefit: float = 0
    tn_benefit: float = 0
    
    @property
    def cost_ratio(self) -> float:
        return self.fn_cost / max(self.fp_cost, 1e-8)

class FinancialModelEvaluator:
    DEFAULT_COST_MATRICES = {
        "fraud_detection": CostMatrix(fp_cost=500, fn_cost=50000),
        "credit_scoring": CostMatrix(fp_cost=2000, fn_cost=80000),
        "compliance": CostMatrix(fp_cost=100000, fn_cost=20000),
    }
    
    def __init__(self, scenario: str = "fraud_detection"):
        self.scenario = scenario
        self.cost_matrix = self.DEFAULT_COST_MATRICES.get(
            scenario, CostMatrix(fp_cost=1, fn_cost=1)
        )
    
    def compute_confusion_matrix(self, y_true, y_score, threshold):
        y_pred = (y_score >= threshold).astype(int)
        tp = int(np.sum((y_true == 1) & (y_pred == 1)))
        fp = int(np.sum((y_true == 0) & (y_pred == 1)))
        fn = int(np.sum((y_true == 1) & (y_pred == 0)))
        tn = int(np.sum((y_true == 0) & (y_pred == 0)))
        return tp, fp, fn, tn
    
    def compute_total_cost(self, fp, fn, tp, tn):
        loss = fp * self.cost_matrix.fp_cost + fn * self.cost_matrix.fn_cost
        benefit = tp * self.cost_matrix.tp_benefit + tn * self.cost_matrix.tn_benefit
        return loss - benefit
    
    def find_optimal_threshold(self, y_true, y_score, n_thresholds=100):
        thresholds = np.linspace(0, 1, n_thresholds)
        results = []
        
        for tau in thresholds:
            tp, fp, fn, tn = self.compute_confusion_matrix(y_true, y_score, tau)
            total_cost = self.compute_total_cost(fp, fn, tp, tn)
            
            precision = tp / max(tp + fp, 1)
            recall = tp / max(tp + fn, 1)
            f1 = 2 * precision * recall / max(precision + recall, 1e-8)
            
            results.append({
                "threshold": tau,
                "precision": precision,
                "recall": recall,
                "f1": f1,
                "total_cost": total_cost,
            })
        
        best_cost = min(results, key=lambda r: r["total_cost"])
        best_f1 = max(results, key=lambda r: r["f1"])
        
        return {
            "optimal_threshold": best_cost["threshold"],
            "f1_at_optimal": best_cost["f1"],
            "precision_at_optimal": best_cost["precision"],
            "recall_at_optimal": best_cost["recall"],
            "f1_optimal_threshold": best_f1["threshold"],
            "threshold_gap": abs(best_cost["threshold"] - best_f1["threshold"]),
        }
    
    def compare_scenarios(self, y_true, y_score):
        comparisons = {}
        for name in self.DEFAULT_COST_MATRICES:
            original = self.scenario
            self.scenario = name
            self.cost_matrix = self.DEFAULT_COST_MATRICES[name]
            
            res = self.find_optimal_threshold(y_true, y_score)
            comparisons[name] = {
                "cost_ratio": self.cost_matrix.cost_ratio,
                "optimal_threshold": res["optimal_threshold"],
                "precision": res["precision_at_optimal"],
                "recall": res["recall_at_optimal"],
            }
            
            self.scenario = original
            self.cost_matrix = self.DEFAULT_COST_MATRICES[original]
        return comparisons

用模拟数据跑一遍,结果可能长这样:

场景             代价比      最优阈值    精确率    召回率
-------------------------------------------------------
fraud_detection   100.0     0.2121    0.3421    0.9231
credit_scoring    40.0      0.2828    0.4102    0.8765
compliance        0.2       0.7879    0.9654    0.3210

可以看到,反欺诈和信用评分的最优阈值都很低(0.2–0.3),以换取高召回;而合规监控的阈值高达 0.78,宁可漏掉也不愿误报。

更重要的是,这些最优阈值与 F1 最优解的差距可能超过 0.3——这意味着如果你只调 F1,模型上线后可能每天都在亏钱。

统计指标 vs 业务指标:别被 AUC 骗了

很多人迷信 AUC-ROC,觉得 0.95 就是好模型。但在金融场景,AUC 高不代表业务表现好。

AUC 衡量的是模型排序能力,但它完全不关心你最终用哪个阈值做决策。一个 AUC 0.85 的模型,如果在代价敏感阈值下总损失更低,那它就比 AUC 0.95 的模型更值得上线。

同样,PR-AUC 虽然对不平衡数据更友好,但它仍是纯统计指标,无法反映真实经济损失。

真正该盯的,是“总经济代价”或“净收益”。

实施建议

  1. 建立场景化代价矩阵:不要用一套 FP/FN 代价打天下。反欺诈、信贷、合规、反洗钱,每个子场景都应有独立的代价估算。
  2. 代价不必精确,但要有范围:FP 成本可能是 500–2000 元,FN 可能是 5 万–10 万。用区间做敏感性分析,看最优阈值是否稳定。
  3. 把阈值差距写进报告:每次模型上线前,必须报告“代价最优阈值”与“F1 最优阈值”的差距。如果差距大,说明传统评测严重误导。
  4. 多目标场景用 Pareto 前沿:如果既要控制漏报又要控制误报(比如 VIP 客户反欺诈),可以用多目标优化找折中解,而不是硬塞一个单一阈值。

结语

金融 AI 的评测,本质上是一道经济学题,不是统计学题。召回率和精确率的博弈在这里格外残酷,因为每一次误判都直接对应真金白银的损失或风险。放弃对 F1 和 AUC 的盲目崇拜,把业务代价嵌入评测流程,才是金融 AI 落地的关键一步。