hscredit.core.models.losses.business_metrics 源代码

"""与风控损失对应的真实业务评估指标。

统一约定 ``y=1`` 为坏样本、预测概率越高风险越高;低概率客户通过审批。
头部名单和固定通过率采用人数口径,边界同分样本等比例计入,避免结果受行顺序影响。
样本权重仅用于指标统计,不改变名单人数;通过率上限指标则按权重计算累计通过率。
"""

from numbers import Real
import numpy as np
from sklearn.metrics import roc_auc_score

from .base import BaseLoss, BaseMetric, _validate_binary_inputs


def _bounded_parameter(value, name: str, lower: float, upper: float, include_lower: bool = True) -> float:
    """校验有限实数参数并返回浮点值。"""
    if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real) or not np.isfinite(value):
        raise ValueError(f"{name}必须是有限实数")
    valid_lower = value >= lower if include_lower else value > lower
    if not valid_lower or value > upper:
        bracket = "[" if include_lower else "("
        raise ValueError(f"{name}必须在{bracket}{lower}, {upper}]范围内")
    return float(value)


def _weighted_inputs(y_true, y_pred, sample_weight=None):
    """使用统一二分类输入契约,并补齐未提供的样本权重。"""
    y_true, y_pred, sample_weight = _validate_binary_inputs(y_true, y_pred, sample_weight=sample_weight)
    if sample_weight is None:
        sample_weight = np.ones(len(y_true), dtype=float)
    return y_true, y_pred, sample_weight


def _selection_fraction(probability: np.ndarray, ratio: float, higher: bool) -> np.ndarray:
    """按人数选取一端的样本,同分边界等比例占用剩余名额。"""
    count = min(len(probability), int(np.ceil(len(probability) * ratio)))
    rank_value = -probability if higher else probability
    threshold = np.partition(rank_value, count - 1)[count - 1]
    selected = rank_value < threshold
    tied = rank_value == threshold
    fractions = selected.astype(float)
    fractions[tied] = (count - np.count_nonzero(selected)) / np.count_nonzero(tied)
    return fractions


[文档] class AUCMetric(BaseMetric): """保持风险方向的 AUC,不会把反向模型自动翻转为高分。 **参数** :param name: 指标名称,默认 ``"AUC曲线下面积"``。 **属性** ``greater_is_better=True``;指标越大越好,有效权重下必须同时存在好、坏样本。 **参考样例** >>> AUCMetric()([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9]) 1.0 """ def __init__(self, name: str = "AUC曲线下面积"): super().__init__(name, greater_is_better=True) def __call__(self, y_true, y_pred, sample_weight=None) -> float: """计算AUCMetric定义的实际业务指标。 :param y_true: 一维 0/1 标签,1 为坏样本,不能为空。 :param y_pred: 同形状坏样本概率,范围 [0, 1];低概率表示低风险。 :param sample_weight: 可选一维非负有限权重,须等长且总和大于 0。 权重用于统计,名单人数和阈值规则详见本类说明。 :return: float,单位和分母详见本类说明;优化方向见 direction 属性。 **参考样例** >>> from hscredit.core.models.losses import AUCMetric >>> metric = AUCMetric() >>> value = metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ y_true, y_pred, weight = _weighted_inputs(y_true, y_pred, sample_weight) if np.sum(weight[y_true == 0]) <= 0 or np.sum(weight[y_true == 1]) <= 0: raise ValueError("计算AUC需要有效权重下同时存在好样本和坏样本") return float(roc_auc_score(y_true, y_pred, sample_weight=weight))
[文档] class TopKCaptureMetric(BaseMetric): """高风险头部名单捕获的坏样本数占全部坏样本数的比例。 **参数** :param top_ratio: 头部人数占比,范围 ``(0, 1]``,默认 0.05;人数向上取整。 :param name: 指标名称,默认 ``"头部坏样本捕获率"``。 **属性** ``greater_is_better=True``;没有有效坏样本时无法定义捕获率,将抛出异常。 同分边界按相同比例计入,权重用于加权坏样本数。 **参考样例** >>> TopKCaptureMetric(top_ratio=0.5)([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9]) 1.0 """ def __init__(self, top_ratio: float = 0.05, name: str = "头部坏样本捕获率"): super().__init__(name, greater_is_better=True) self.top_ratio = _bounded_parameter(top_ratio, "头部人数占比", 0.0, 1.0, include_lower=False) def __call__(self, y_true, y_pred, sample_weight=None) -> float: """计算TopKCaptureMetric定义的实际业务指标。 :param y_true: 一维 0/1 标签,1 为坏样本,不能为空。 :param y_pred: 同形状坏样本概率,范围 [0, 1];低概率表示低风险。 :param sample_weight: 可选一维非负有限权重,须等长且总和大于 0。 权重用于统计,名单人数和阈值规则详见本类说明。 :return: float,单位和分母详见本类说明;优化方向见 direction 属性。 **参考样例** >>> from hscredit.core.models.losses import TopKCaptureMetric >>> metric = TopKCaptureMetric() >>> value = metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ y_true, y_pred, weight = _weighted_inputs(y_true, y_pred, sample_weight) bad_weight = weight * y_true total_bad = np.sum(bad_weight) if total_bad <= 0: raise ValueError("没有有效坏样本,无法计算头部坏样本捕获率") fraction = _selection_fraction(y_pred, self.top_ratio, higher=True) return float(np.sum(fraction * bad_weight) / total_bad)
[文档] class TopKLiftMetric(BaseMetric): """高风险头部名单坏样本率相对全量坏样本率的提升倍数。 **参数** :param top_ratio: 头部人数占比,范围 ``(0, 1]``,默认 0.1;人数向上取整。 :param name: 指标名称,默认 ``"头部提升倍数"``。 **属性** ``greater_is_better=True``;名单和总体均使用加权坏样本率。 名单权重为零或总体没有有效坏样本时抛出异常。 **参考样例** >>> TopKLiftMetric(top_ratio=0.5)([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9]) 2.0 """ def __init__(self, top_ratio: float = 0.1, name: str = "头部提升倍数"): super().__init__(name, greater_is_better=True) self.top_ratio = _bounded_parameter(top_ratio, "头部人数占比", 0.0, 1.0, include_lower=False) def __call__(self, y_true, y_pred, sample_weight=None) -> float: """计算TopKLiftMetric定义的实际业务指标。 :param y_true: 一维 0/1 标签,1 为坏样本,不能为空。 :param y_pred: 同形状坏样本概率,范围 [0, 1];低概率表示低风险。 :param sample_weight: 可选一维非负有限权重,须等长且总和大于 0。 权重用于统计,名单人数和阈值规则详见本类说明。 :return: float,单位和分母详见本类说明;优化方向见 direction 属性。 **参考样例** >>> from hscredit.core.models.losses import TopKLiftMetric >>> metric = TopKLiftMetric() >>> value = metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ y_true, y_pred, weight = _weighted_inputs(y_true, y_pred, sample_weight) total_bad = np.sum(weight * y_true) if total_bad <= 0: raise ValueError("没有有效坏样本,无法计算头部提升倍数") selected_weight = weight * _selection_fraction(y_pred, self.top_ratio, higher=True) selected_total = np.sum(selected_weight) if selected_total <= 0: raise ValueError("头部名单的样本权重总和必须大于零") head_bad_rate = np.sum(selected_weight * y_true) / selected_total return float(head_bad_rate / (total_bad / np.sum(weight)))
[文档] class BadDebtMetric(BaseMetric): """固定人数通过率下,低风险通过客户的观察坏账率。 **参数** :param approval_rate: 通过人数占比,范围 ``(0, 1]``,默认 0.3;人数向上取整。 :param name: 指标名称,默认 ``"通过客户坏账率"``。 **属性** ``greater_is_better=False``;越小越好。边界同分样本等比例通过, 坏账率使用通过样本权重作为分母;通过样本权重为零时抛出异常。 **参考样例** >>> BadDebtMetric(approval_rate=0.5)([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9]) 0.0 """ def __init__(self, approval_rate: float = 0.3, name: str = "通过客户坏账率"): super().__init__(name, greater_is_better=False) self.approval_rate = _bounded_parameter(approval_rate, "通过人数占比", 0.0, 1.0, include_lower=False) def __call__(self, y_true, y_pred, sample_weight=None) -> float: """计算BadDebtMetric定义的实际业务指标。 :param y_true: 一维 0/1 标签,1 为坏样本,不能为空。 :param y_pred: 同形状坏样本概率,范围 [0, 1];低概率表示低风险。 :param sample_weight: 可选一维非负有限权重,须等长且总和大于 0。 权重用于统计,名单人数和阈值规则详见本类说明。 :return: float,单位和分母详见本类说明;优化方向见 direction 属性。 **参考样例** >>> from hscredit.core.models.losses import BadDebtMetric >>> metric = BadDebtMetric() >>> value = metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ y_true, y_pred, weight = _weighted_inputs(y_true, y_pred, sample_weight) selected_weight = weight * _selection_fraction(y_pred, self.approval_rate, higher=False) selected_total = np.sum(selected_weight) if selected_total <= 0: raise ValueError("通过客户的样本权重总和必须大于零") return float(np.sum(selected_weight * y_true) / selected_total)
[文档] class ApprovalRateMetric(BaseMetric): """观察坏账率不超过上限时,所有风险阈值中的最大通过率。 **参数** :param target_bad_debt_rate: 观察坏账率上限,范围 ``[0, 1]``,默认 0.05。 :param name: 指标名称,默认 ``"坏账约束下最大通过率"``。 **属性** ``greater_is_better=True``;相同预测概率的客户必须同时通过。 样本权重同时用于累计通过率和累计坏账率。无可行阈值时返回 0。 此值通过标签搜索评估集上的阈值,不能作为未见数据的坏账率保证。 **参考样例** >>> ApprovalRateMetric(0.0)([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9]) 0.5 """ def __init__(self, target_bad_debt_rate: float = 0.05, name: str = "坏账约束下最大通过率"): super().__init__(name, greater_is_better=True) self.target_bad_debt_rate = _bounded_parameter(target_bad_debt_rate, "坏账率上限", 0.0, 1.0) def __call__(self, y_true, y_pred, sample_weight=None) -> float: """计算ApprovalRateMetric定义的实际业务指标。 :param y_true: 一维 0/1 标签,1 为坏样本,不能为空。 :param y_pred: 同形状坏样本概率,范围 [0, 1];低概率表示低风险。 :param sample_weight: 可选一维非负有限权重,须等长且总和大于 0。 权重用于统计,名单人数和阈值规则详见本类说明。 :return: float,单位和分母详见本类说明;优化方向见 direction 属性。 **参考样例** >>> from hscredit.core.models.losses import ApprovalRateMetric >>> metric = ApprovalRateMetric() >>> value = metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ y_true, y_pred, weight = _weighted_inputs(y_true, y_pred, sample_weight) order = np.argsort(y_pred, kind="stable") sorted_pred = y_pred[order] group_end = np.r_[sorted_pred[1:] != sorted_pred[:-1], True] cumulative_weight = np.cumsum(weight[order])[group_end] cumulative_bad = np.cumsum((weight * y_true)[order])[group_end] nonempty = cumulative_weight > 0 feasible = nonempty & (cumulative_bad <= self.target_bad_debt_rate * cumulative_weight) if not np.any(feasible): return 0.0 # 累计坏账率不一定单调:中途不可行的阈值之后仍可能再次满足上限。 return float(np.max(cumulative_weight[feasible]) / np.sum(weight))
[文档] class ClassificationCostMetric(BaseMetric): """固定分类阈值下,全量样本的人均误判成本。 **参数** :param fn_cost: 漏抓坏客户的单位成本,非负有限数,默认 1.0。 :param fp_cost: 误拒好客户的单位成本,非负有限数,默认 1.0。 :param threshold: 拒绝阈值,范围 ``[0, 1]``,默认 0.5;``p >= threshold`` 拒绝。 :param name: 指标名称,默认 ``"人均误判成本"``。 **属性** ``greater_is_better=False``;正确分类计零,错误分类按成本计入,并按样本权重平均。 **参考样例** >>> ClassificationCostMetric(fn_cost=100, fp_cost=1)([0, 1], [0.8, 0.2]) 50.5 """ def __init__(self, fn_cost: float = 1.0, fp_cost: float = 1.0, threshold: float = 0.5, name: str = "人均误判成本"): super().__init__(name, greater_is_better=False) self.fn_cost = _bounded_parameter(fn_cost, "漏抓坏客户成本", 0.0, np.inf) self.fp_cost = _bounded_parameter(fp_cost, "误拒好客户成本", 0.0, np.inf) self.threshold = _bounded_parameter(threshold, "拒绝阈值", 0.0, 1.0) def __call__(self, y_true, y_pred, sample_weight=None) -> float: """计算ClassificationCostMetric定义的实际业务指标。 :param y_true: 一维 0/1 标签,1 为坏样本,不能为空。 :param y_pred: 同形状坏样本概率,范围 [0, 1];低概率表示低风险。 :param sample_weight: 可选一维非负有限权重,须等长且总和大于 0。 权重用于统计,名单人数和阈值规则详见本类说明。 :return: float,单位和分母详见本类说明;优化方向见 direction 属性。 **参考样例** >>> from hscredit.core.models.losses import ClassificationCostMetric >>> metric = ClassificationCostMetric() >>> value = metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ y_true, y_pred, weight = _weighted_inputs(y_true, y_pred, sample_weight) rejected = y_pred >= self.threshold cost = (y_true == 0) * rejected * self.fp_cost + (y_true == 1) * (~rejected) * self.fn_cost return float(np.average(cost, weights=weight))
[文档] class ProfitMetric(BaseMetric): """给定固定审批阈值时,全量申请客户的人均已实现利润。 **参数** :param revenue: 好客户通过后的单位收益,非负有限数,默认 1.0。 :param default_cost: 坏客户通过后的单位损失,非负有限数,默认 10.0。 :param cutoff: 审批阈值,范围 ``[0, 1]``,默认 0.5;``p < cutoff`` 通过。 :param name: 指标名称,默认 ``"人均审批利润"``。 **属性** ``greater_is_better=True``;通过好客户计收益,通过坏客户计负损失,拒绝计零。 分母为全量样本权重,因此各模型在同一评估集上可直接比较。 **参考样例** >>> ProfitMetric(revenue=100, default_cost=1000)([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9]) 50.0 """ def __init__( self, revenue: float = 1.0, default_cost: float = 10.0, cutoff: float = 0.5, name: str = "人均审批利润", ): super().__init__(name, greater_is_better=True) self.revenue = _bounded_parameter(revenue, "好客户收益", 0.0, np.inf) self.default_cost = _bounded_parameter(default_cost, "坏客户损失", 0.0, np.inf) self.cutoff = _bounded_parameter(cutoff, "审批阈值", 0.0, 1.0) def __call__(self, y_true, y_pred, sample_weight=None) -> float: """计算ProfitMetric定义的实际业务指标。 :param y_true: 一维 0/1 标签,1 为坏样本,不能为空。 :param y_pred: 同形状坏样本概率,范围 [0, 1];低概率表示低风险。 :param sample_weight: 可选一维非负有限权重,须等长且总和大于 0。 权重用于统计,名单人数和阈值规则详见本类说明。 :return: float,单位和分母详见本类说明;优化方向见 direction 属性。 **参考样例** >>> from hscredit.core.models.losses import ProfitMetric >>> metric = ProfitMetric() >>> value = metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ y_true, y_pred, weight = _weighted_inputs(y_true, y_pred, sample_weight) profit = (1 - y_true) * self.revenue - y_true * self.default_cost return float(np.sum(weight * (y_pred < self.cutoff) * profit) / np.sum(weight))
def business_metric_for_loss(loss: BaseLoss) -> BaseMetric: """按损失类型构造业务指标;没有专用业务口径时使用该损失本身的评估指标。 基于类型而非名称识别损失,用户修改 ``loss.name`` 不影响对应关系。 指标复制构造时的业务参数,后续修改损失参数需要重新调用本函数。 :param loss: BaseLoss 实例。 :return: BaseMetric 实例。排序损失对应 AUC,KSFocusedLoss 对应 KS, 成本、头部名单和审批损失对应其真实业务口径;其他损失返回 LossMetric。 :raises TypeError: 参数不是 BaseLoss 实例。 **参考样例** >>> from hscredit.core.models.losses import TopKBadCaptureLoss >>> metric = business_metric_for_loss(TopKBadCaptureLoss(top_ratio=0.5)) >>> metric([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) 1.0 """ from .custom_metrics import KSMetric from .expected_profit_loss import ExpectedProfitLoss from .ks_focused_loss import KSFocusedLoss from .ranking_auc_proxy_loss import RankingAUCProxyLoss from .ranking_loss import LiftFocusedLoss, OrdinalRankLoss from .risk_loss import ApprovalRateLoss, BadDebtLoss, ProfitMaxLoss from .topk_bad_capture_loss import TopKBadCaptureLoss from .weighted_loss import CostSensitiveLoss if not isinstance(loss, BaseLoss): raise TypeError("损失函数必须是BaseLoss实例") if isinstance(loss, (OrdinalRankLoss, RankingAUCProxyLoss)): return AUCMetric() if isinstance(loss, KSFocusedLoss): return KSMetric(name="KS区分度") if isinstance(loss, CostSensitiveLoss): return ClassificationCostMetric(fn_cost=loss.fn_cost, fp_cost=loss.fp_cost) if isinstance(loss, LiftFocusedLoss): return TopKLiftMetric(top_ratio=loss.top_ratio) if isinstance(loss, TopKBadCaptureLoss): return TopKCaptureMetric(top_ratio=loss.top_ratio) if isinstance(loss, BadDebtLoss): return BadDebtMetric(approval_rate=loss.target_approval_rate) if isinstance(loss, ApprovalRateLoss): return ApprovalRateMetric(target_bad_debt_rate=loss.target_bad_debt_rate) if isinstance(loss, ExpectedProfitLoss): return ProfitMetric(revenue=loss.revenue, default_cost=loss.default_cost, cutoff=loss.cutoff) if isinstance(loss, ProfitMaxLoss): return ProfitMetric(revenue=loss.interest_income, default_cost=loss.bad_debt_loss) return loss.metric()