"""搜索指标的数值、方向与业务口径;不依赖 Optuna。"""
import inspect
from functools import partial
import numpy as np
from sklearn.metrics import accuracy_score, f1_score, log_loss, precision_score, recall_score, roc_curve
from ...metrics import auc as auc_metric
from ..losses.base import BaseLoss, BaseMetric, LossMetric, _validate_binary_inputs
from ..losses.functional import make_metric
from ..losses.business_metrics import BadDebtMetric, TopKLiftMetric, _bounded_parameter, _selection_fraction
def _calc_ks(y_true, y_pred, sample_weight=None):
y, p, weight = _validate_binary_inputs(y_true, y_pred, sample_weight)
effective = np.ones(len(y)) if weight is None else weight
if any(effective[y == label].sum() <= 0 for label in (0, 1)):
raise ValueError("计算KS需要有效权重下同时存在好样本和坏样本")
fpr, tpr, _ = roc_curve(y, p, pos_label=1, sample_weight=weight)
return float(np.max(np.abs(tpr - fpr)))
def _calc_ks_with_diff(y_train, y_train_pred, y_val, y_val_pred, sample_weight=None, train_sample_weight=None):
ks_train = _calc_ks(y_train, y_train_pred, train_sample_weight)
ks_val = _calc_ks(y_val, y_val_pred, sample_weight)
return ks_val, abs(ks_train - ks_val)
def _probability_inputs(y_true, y_prob, kwargs):
unknown = set(kwargs) - {"sample_weight"}
if unknown:
raise TypeError(f"不支持的指标参数: {sorted(unknown)}")
y, p, weight = _validate_binary_inputs(y_true, y_prob, kwargs.get("sample_weight"))
return y, p, np.ones(len(y)) if weight is None else weight
[文档]
class TuningObjective:
"""兼容既有字符串目标的业务评分集合,返回值均越大越好。
**参数**
本类无需实例化。各方法接收0/1标签和坏样本概率;``get`` 可绑定业务参数。
**属性**
``BUILTIN_OBJECTIVES`` 列出名称。降低坏账率使用的评分函数与原始坏账率不同;
要直接最小化坏账率,推荐 losses.BadDebtMetric。
**参考样例**
>>> metric = TuningObjective.get("lift_head", ratio=0.1)
>>> metric.direction
'maximize'
>>> metric([0, 0, 1, 1], [0.1, 0.2, 0.8, 0.9])
2.0
"""
BUILTIN_OBJECTIVES = [
"ks",
"auc",
"lift_head",
"lift_tail",
"lift_head_monotonic",
"ks_with_lift_constraint",
"head_ks",
"ks_lift_combined",
"tail_purity_ks",
"approval_bad_rate",
"expected_profit",
]
[文档]
@staticmethod
def ks(y_true, y_prob, **kwargs):
"""计算 KS,越大越好。
:param y_true: 0/1标签。
:param y_prob: 一维坏样本概率。
:param kwargs: 可传 sample_weight;两类的有效权重都须大于0。
:return: [0, 1] 内的KS。
"""
y, p, weight = _probability_inputs(y_true, y_prob, kwargs)
return _calc_ks(y, p, weight)
[文档]
@staticmethod
def auc(y_true, y_prob, score_direction="auto", **kwargs):
"""按统一评分方向计算 AUC,兼容既有字符串目标的自动方向。
:param y_true: 0/1标签;必须同时包含有效好、坏样本。
:param y_prob: [0, 1] 内的预测值。
:param score_direction: auto(默认,兼容原有评分方向)、higher_risk(越大风险越高)
或 higher_safe(越大风险越低)。固定坏样本概率评价可显式使用 higher_risk,
或直接传入不会自动翻向的 AUCMetric 指标对象。
:param kwargs: 可传 sample_weight。
:return: [0, 1] 内的AUC;非法输入抛异常,不伪装成0分。
"""
y, p, weight = _probability_inputs(y_true, y_prob, kwargs)
return auc_metric(y, p, sample_weight=weight, score_direction=score_direction)
[文档]
@staticmethod
def lift_head(y_true, y_prob, ratio=0.10, **kwargs):
"""计算高风险头部坏率 / 整体坏率,人数向上取整,同分边界等比例计入。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率。
:param ratio: 头部人数占比,(0, 1],默认0.1。
:param kwargs: 可传 sample_weight,只加权统计、不改变名单人数。
:return: 头部提升倍数,越大越好;没有坏样本时返回0以兼容补充诊断。
"""
y, p, weight = _probability_inputs(y_true, y_prob, kwargs)
metric = TopKLiftMetric(top_ratio=ratio)
return 0.0 if np.dot(weight, y) == 0 else metric(y, p, weight)
[文档]
@staticmethod
def lift_tail(y_true, y_prob, ratio=0.10, **kwargs):
"""计算低风险尾部好率 / 整体好率,越大越好。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率。
:param ratio: 尾部人数占比,(0, 1],默认0.1。
:param kwargs: 可传 sample_weight。
:return: 纯净度提升倍数;没有好样本时返回0。
"""
y, p, weight = _probability_inputs(y_true, y_prob, kwargs)
metric = TopKLiftMetric(top_ratio=ratio)
return 0.0 if np.dot(weight, 1 - y) == 0 else metric(1 - y, 1 - p, weight)
[文档]
@staticmethod
def lift_head_monotonic(y_true, y_prob, n_bins=10, penalty=0.5, **kwargs):
"""按概率等频分箱,以 KS × (1 - 违反坏率单调的比例 × penalty) 评分。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率;同分概率放在同一箱内。
:param n_bins: 期望分箱数,至少2;实际箱数可因同分值减少。
:param penalty: 违反单调性的惩罚系数,[0, 1]。
:param kwargs: 本指标暂不支持 sample_weight。
:return: 越大越好的惩罚后KS;使用统一分箱器和 compute_bin_stats。
"""
y, p, _ = _probability_inputs(y_true, y_prob, kwargs)
if kwargs.get("sample_weight") is not None:
raise ValueError("分箱单调性指标暂不支持评估权重")
if isinstance(n_bins, (bool, np.bool_)) or not isinstance(n_bins, (int, np.integer)) or n_bins < 2:
raise ValueError("n_bins 必须是至少2的整数")
_bounded_parameter(penalty, "单调性惩罚", 0, 1)
ks = _calc_ks(y, p)
if np.unique(p).size < 2:
return ks
import pandas as pd
from ...binning import QuantileBinning
from ...metrics import compute_bin_stats
binner = QuantileBinning(max_n_bins=min(n_bins, len(y)), force_numerical=True, n_jobs=1)
codes = binner.fit_transform(pd.DataFrame({"风险概率": p}), y)["风险概率"].to_numpy()
table = compute_bin_stats(codes, y, round_digits=False).sort_values("分箱")
bad_rates = table["坏样本率"].to_numpy()[::-1]
violations = np.mean(np.diff(bad_rates) > 1e-8) if len(bad_rates) > 1 else 0.0
return float(ks * (1 - violations * penalty))
[文档]
@staticmethod
def ks_with_lift_constraint(y_true, y_prob, min_lift_ratio=0.05, min_lift_value=2.0, **kwargs):
"""满足头部提升约束时返回KS,否则返回0。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率。
:param min_lift_ratio: 头部人数占比,(0, 1]。
:param min_lift_value: 最低提升倍数,非负有限数。
:param kwargs: 可传 sample_weight。
:return: 越大越好的KS评分;0仅表示不满足约束,不表示试验失败。
"""
_bounded_parameter(min_lift_value, "最低提升倍数", 0, np.inf)
head = TuningObjective.lift_head(y_true, y_prob, ratio=min_lift_ratio, **kwargs)
return TuningObjective.ks(y_true, y_prob, **kwargs) if head >= min_lift_value else 0.0
[文档]
@staticmethod
def head_ks(y_true, y_prob, ratio=0.30, **kwargs):
"""计算高风险头部内的KS,边界同分样本按比例加权。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率。
:param ratio: 头部人数占比,(0, 1]。
:param kwargs: 可传 sample_weight。
:return: [0, 1] 内的KS;头部只有单类时返回0。
"""
y, p, weight = _probability_inputs(y_true, y_prob, kwargs)
_bounded_parameter(ratio, "头部人数占比", 0, 1, include_lower=False)
selected = weight * _selection_fraction(p, ratio, True)
if any(selected[y == label].sum() <= 0 for label in (0, 1)):
return 0.0
return _calc_ks(y, p, selected)
[文档]
@staticmethod
def ks_lift_combined(y_true, y_prob, ks_weight=0.5, lift_ratio=0.05, **kwargs):
"""计算 ks_weight × KS + (1-ks_weight) × min(头部LIFT/10, 1)。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率。
:param ks_weight: KS占比,[0, 1],默认0.5。
:param lift_ratio: 头部人数占比,(0, 1]。
:param kwargs: 可传 sample_weight。
:return: 越大越好的联合评分;LIFT除以10为沿用的业务缩放约定。
"""
_bounded_parameter(ks_weight, "KS占比", 0, 1)
ks = TuningObjective.ks(y_true, y_prob, **kwargs)
lift = TuningObjective.lift_head(y_true, y_prob, ratio=lift_ratio, **kwargs)
return float(ks_weight * ks + (1 - ks_weight) * min(lift / 10, 1))
[文档]
@staticmethod
def tail_purity_ks(y_true, y_prob, tail_ratio=0.30, **kwargs):
"""计算 0.5 × KS + 0.5 × min(尾部好率提升, 1),保留旧业务缩放约定。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率。
:param tail_ratio: 低风险尾部人数占比,(0, 1]。
:param kwargs: 可传 sample_weight。
:return: 越大越好的联合评分。若需要完整尾部提升差异,请单用 lift_tail。
"""
ks = TuningObjective.ks(y_true, y_prob, **kwargs)
purity = TuningObjective.lift_tail(y_true, y_prob, ratio=tail_ratio, **kwargs)
return float(0.5 * ks + 0.5 * min(purity, 1))
[文档]
@staticmethod
def approval_bad_rate(y_true, y_prob, approval_rate=0.30, bad_rate_weight=1.0, **kwargs):
"""固定人数通过率下计算 approval_rate × (1 - 通过坏率 × bad_rate_weight)。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率;优先通过低风险客户。
:param approval_rate: 通过人数占比,(0, 1],默认0.3。
:param bad_rate_weight: 坏率惩罚权重,非负有限数。
:param kwargs: 可传 sample_weight。
:return: 越大越好的审批评分;原始坏率应使用 BadDebtMetric 最小化。
"""
y, p, weight = _probability_inputs(y_true, y_prob, kwargs)
_bounded_parameter(bad_rate_weight, "坏率惩罚权重", 0, np.inf)
bad_rate = BadDebtMetric(approval_rate)(y, p, weight)
return float(approval_rate * (1 - bad_rate * bad_rate_weight))
[文档]
@staticmethod
def expected_profit(y_true, y_prob, approval_rate=0.30, good_profit=1.0, bad_loss=5.0, **kwargs):
"""固定通过人数占比时的人均已实现利润,全体申请客户为分母。
:param y_true: 0/1标签。
:param y_prob: 坏样本概率;优先通过低风险客户,同分边界等比例计入。
:param approval_rate: 通过人数占比,(0, 1],默认0.3。
:param good_profit: 通过好客户的收益,非负有限数。
:param bad_loss: 通过坏客户的损失,非负有限数。
:param kwargs: 可传 sample_weight。
:return: 越大越好的利润;与使用固定概率阈值的 ProfitMetric 口径不同。
"""
y, p, weight = _probability_inputs(y_true, y_prob, kwargs)
_bounded_parameter(approval_rate, "通过人数占比", 0, 1, include_lower=False)
_bounded_parameter(good_profit, "通过好客户收益", 0, np.inf)
_bounded_parameter(bad_loss, "通过坏客户损失", 0, np.inf)
selected = _selection_fraction(p, approval_rate, False)
return float(np.average(selected * ((1 - y) * good_profit - y * bad_loss), weights=weight))
[文档]
@classmethod
def get(cls, name, **kwargs):
"""按名称创建可在离线、训练和调参中复用的指标。
:param name: BUILTIN_OBJECTIVES 中的名称,不区分大小写。
:param kwargs: 绑定对应方法的业务参数;未知参数立即报错。
:return: BaseMetric 对象,其 direction 为 maximize。
>>> metric = TuningObjective.get("lift_head", ratio=0.05)
>>> metric.name
'LIFT_HEAD'
"""
if not isinstance(name, str) or name.lower() not in cls.BUILTIN_OBJECTIVES:
raise ValueError(f"未知目标函数 {name!r},可选: {cls.BUILTIN_OBJECTIVES}")
key = name.lower()
function = getattr(cls, key)
allowed = set(inspect.signature(function).parameters) - {"y_true", "y_prob", "kwargs"}
if set(kwargs) - allowed:
raise ValueError(f"指标 {key} 不支持参数: {sorted(set(kwargs)-allowed)}")
metric = make_metric(function, name=key.upper(), greater_is_better=True, **kwargs)
if key == "lift_head_monotonic":
metric.supports_sample_weight = False
return metric
[文档]
class Metric:
"""统一管理搜索目标的名称、数值及优化方向。
**参数**
:param metric: 内置名称、BaseMetric、BaseLoss,或 ``(y_true, probability)->float`` 函数。
BaseLoss 自动转换为配套指标。sklearn scorer 与框架回调需先还原为概率指标。
:param name: 展示名称;多目标搜索中不可重名。
:param direction: maximize 或 minimize;默认内置/指标对象自行声明。
裸函数必须显式传方向,也可先用 make_metric 包装。
**属性**
name、direction、scorer;``log_loss`` 为正交叉熵,默认最小化。
旧名 ``logloss`` 保留负交叉熵最大化语义,便于旧 Study 继续使用。
字符串 ``auc`` 沿用 ``score_direction='auto'``;传入 ``AUCMetric()``
或绑定 ``score_direction='higher_risk'`` 的目标时,保持显式风险方向。
**参考样例**
>>> Metric("log_loss").direction
'minimize'
>>> Metric("ks_diff").direction
'minimize'
"""
BUILTIN_METRICS = {
"auc": {"direction": "maximize"},
"ks": {"direction": "maximize"},
"ks_diff": {"direction": "minimize"},
"log_loss": {"direction": "minimize"},
"logloss": {"direction": "maximize"},
"neg_log_loss": {"direction": "maximize"},
"brier": {"direction": "minimize"},
**{key: {"direction": "maximize"} for key in ("accuracy", "precision", "recall", "f1")},
**{key: {"direction": "maximize"} for key in TuningObjective.BUILTIN_OBJECTIVES},
}
def __init__(self, metric, name=None, direction=None):
if direction not in (None, "maximize", "minimize"):
raise ValueError("direction 必须为 'maximize' 或 'minimize'")
if isinstance(metric, BaseLoss):
metric = metric.metric()
self.metric = metric
self._is_builtin = isinstance(metric, str)
if self._is_builtin:
key = metric.lower()
if key not in self.BUILTIN_METRICS:
raise ValueError(f"未知的内置指标: {metric},可用指标: {list(self.BUILTIN_METRICS)}")
self.name = name or key.upper()
self.direction = direction or self.BUILTIN_METRICS[key]["direction"]
self.scorer = None
elif isinstance(metric, BaseMetric):
if direction is not None and direction != metric.direction:
raise ValueError(f"direction 与指标 {metric.name} 自身声明的方向不一致")
self.name = name or metric.name
self.direction = direction or metric.direction
self.scorer = metric.evaluate
elif callable(metric):
if hasattr(metric, "_score_func"):
raise TypeError("metric接收(y_true, probability)函数;sklearn scorer只能用于cross_val_score")
if direction is None:
raise ValueError("自定义metric必须指定direction;也可用make_metric(..., greater_is_better=False/True)")
self.name = name or getattr(metric, "__name__", "自定义指标")
self.direction = direction
self.scorer = metric
else:
raise TypeError("metric 必须是内置名称、BaseMetric、BaseLoss或可调用函数")
if not isinstance(self.name, str) or not self.name.strip():
raise ValueError("指标名称必须是非空字符串")
[文档]
def validate_evaluation_weight(self):
"""在训练开始前拒绝不支持 sample_weight 的评估函数。
:return: None;不支持时抛出中文 ValueError。
"""
if self._is_builtin:
accepted = self.metric.lower() != "lift_head_monotonic"
elif isinstance(self.metric, LossMetric):
accepted = type(self.metric.loss).loss_values is not BaseLoss.loss_values
elif hasattr(self.metric, "supports_sample_weight"):
accepted = self.metric.supports_sample_weight
else:
target = self.metric.__call__ if isinstance(self.metric, BaseMetric) else self.scorer
unwrapped = target
while isinstance(unwrapped, partial):
unwrapped = unwrapped.func
parameters = inspect.signature(target).parameters
accepted = "sample_weight" in parameters or any(p.kind == p.VAR_KEYWORD for p in parameters.values())
if unwrapped is TuningObjective.lift_head_monotonic:
accepted = False
if not accepted:
raise ValueError(
f"指标 {self.name} 暂不支持评估权重;请移除 evaluation_weight 或使用支持 sample_weight 的指标"
)
def __call__(self, y_true, y_pred, y_train=None, y_train_pred=None, sample_weight=None, train_sample_weight=None):
"""评价一个验证折,返回原始指标口径的有限标量。
:param y_true: 验证标签,0为好样本、1为坏样本。
:param y_pred: 验证集坏样本概率,不接受评分或类别预测。
:param y_train: ks_diff 所需的训练标签。
:param y_train_pred: ks_diff 所需的训练概率。
:param sample_weight: 显式验证权重;默认不加权。
:param train_sample_weight: ks_diff 所需训练评估权重。
:return: float;优化方向只决定选优,不改变数值符号,旧 logloss 除外。
>>> round(Metric("log_loss")([0, 1], [0.1, 0.9]), 3)
0.105
"""
y, p, weight = _validate_binary_inputs(y_true, y_pred, sample_weight)
if weight is not None:
self.validate_evaluation_weight()
key = self.metric.lower() if self._is_builtin else None
if key == "ks_diff":
if y_train is None or y_train_pred is None:
raise ValueError("计算ks_diff需要提供训练集预测结果")
value = _calc_ks_with_diff(y_train, y_train_pred, y, p, weight, train_sample_weight)[1]
elif key in TuningObjective.BUILTIN_OBJECTIVES:
value = getattr(TuningObjective, key)(y, p, **({"sample_weight": weight} if weight is not None else {}))
elif key in {"logloss", "log_loss", "neg_log_loss"}:
value = float(log_loss(y, p, sample_weight=weight, labels=[0, 1]))
if key != "log_loss":
value = -value
elif key == "brier":
value = float(np.average((y - p) ** 2, weights=weight))
elif self._is_builtin:
functions = {
"accuracy": accuracy_score,
"precision": precision_score,
"recall": recall_score,
"f1": f1_score,
}
kwargs = {} if key == "accuracy" else {"zero_division": 0}
value = functions[key](y, p >= 0.5, sample_weight=weight, **kwargs)
else:
value = self.scorer(y, p, sample_weight=weight) if weight is not None else self.scorer(y, p)
if isinstance(value, (bool, np.bool_)) or np.ndim(value) != 0:
raise ValueError(f"指标 {self.name} 必须返回单个有限数值,不能返回框架回调元组或数组")
try:
value = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError(f"指标 {self.name} 必须返回单个有限数值") from exc
if not np.isfinite(value):
raise ValueError(f"指标 {self.name} 返回了非有限数值")
return value
def __repr__(self):
return f"Metric(name={self.name!r}, direction={self.direction!r})"