"""损失、离线评估和训练回调的统一接口。"""
from abc import ABC, abstractmethod
from copy import deepcopy
from inspect import signature
from typing import Optional, Tuple
import numpy as np
from scipy.special import expit
def _validate_binary_inputs(y_true, y_pred, sample_weight=None, raw_score=False):
"""校验二分类标签、概率和权重;显式转换原始分数。"""
try:
y = np.asarray(y_true, dtype=float)
p = np.asarray(y_pred, dtype=float)
except (TypeError, ValueError) as exc:
raise ValueError("标签和预测值必须是数值数组") from exc
if y.ndim == 2 and y.shape[1] == 1:
y = y[:, 0]
if p.ndim == 2 and p.shape[1] == 1:
p = p[:, 0]
elif p.ndim == 2 and p.shape[1] == 2 and not raw_score:
if not np.isfinite(p).all() or np.any((p < 0) | (p > 1)) or not np.allclose(p.sum(axis=1), 1):
raise ValueError("两列概率必须对应类别[0, 1],每行非负且合计为1")
p = p[:, 1]
if y.ndim != 1 or p.ndim != 1 or len(y) != len(p) or not len(y):
raise ValueError("标签和预测值必须是一维、非空且等长的数组")
if not np.isfinite(y).all() or not np.isin(y, [0, 1]).all():
raise ValueError("标签必须是0(好样本)或1(坏样本)")
if not np.isfinite(p).all():
raise ValueError("预测值必须为有限数,不能包含缺失值或无穷大")
if raw_score:
p = expit(p)
elif np.any((p < 0) | (p > 1)):
raise ValueError("预测概率必须在[0, 1]内;原始分数请指定 raw_score=True")
weight = None
if sample_weight is not None:
weight = np.asarray(sample_weight, dtype=float)
if weight.ndim != 1 or len(weight) != len(y):
raise ValueError("样本权重必须是一维且与标签等长")
if not np.isfinite(weight).all() or np.any(weight < 0) or not np.isfinite(weight.sum()) or weight.sum() <= 0:
raise ValueError("样本权重必须有限、非负且总和大于0")
return y, p, weight
def _dataset_weight(data):
"""读取原生数据集权重;空数组表示未设置。"""
weight = data.get_weight() if hasattr(data, "get_weight") else None
return None if weight is None or len(weight) == 0 else weight
def _margin_derivatives(loss, y_true, probability) -> Tuple[np.ndarray, np.ndarray]:
"""将概率导数转换为原始分数导数;不在这里修改真实 Hessian。"""
probability = np.clip(np.asarray(probability, dtype=float), 1e-7, 1 - 1e-7)
grad_probability = np.asarray(loss.gradient(y_true, probability), dtype=float)
hess_probability = loss.hessian(y_true, probability)
link_grad = probability * (1.0 - probability)
grad_margin = grad_probability * link_grad
if hess_probability is None:
hess_margin = np.maximum(link_grad, np.finfo(float).eps)
else:
link_hess = link_grad * (1.0 - 2.0 * probability)
hess_margin = np.asarray(hess_probability, dtype=float) * link_grad**2 + grad_probability * link_hess
return grad_margin, hess_margin
def _objective_derivatives(loss, y_true, margin, sample_weight=None):
"""训练入口:验证输入并为树模型提供正对角曲率近似。"""
y, p, weight = _validate_binary_inputs(y_true, margin, sample_weight, raw_score=True)
if weight is not None and type(loss).loss_values is BaseLoss.loss_values:
raise ValueError("该损失依赖整体样本分布,尚不支持额外样本权重;请使用未加权目标或逐样本损失")
grad, hess = _margin_derivatives(loss, y, p)
if grad.shape != y.shape or hess.shape != y.shape or not np.isfinite(grad).all() or not np.isfinite(hess).all():
raise ValueError("损失函数的梯度和二阶导必须与标签等长且为有限数")
# 非凸损失使用正对角近似,不对真实曲率取绝对值。
hess = np.maximum(hess, 1e-6)
if weight is not None:
normalizer = loss._external_weight_normalizer(y, weight)
grad, hess = grad * weight / normalizer, hess * weight / normalizer
return grad, hess
[文档]
class BaseLoss(ABC):
"""二分类损失基类。
**参数**
:param name: 损失的标识名称。
**属性**
``gradient`` / ``hessian`` 对概率求导,标度为 ``样本数 × 平均损失``;
树框架适配器统一转换到原始分数。``is_additive`` 表示可安全分批计算。
**参考样例**
>>> from hscredit.core.models.losses import FocalLoss
>>> loss = FocalLoss(alpha=0.75)
>>> metric = loss.metric()
>>> value = metric([0, 1], [0.2, 0.8])
>>> scorer = metric.to_scorer()
"""
is_additive = False
def __init__(self, name: str = "custom_loss"):
self.name = name
@abstractmethod
def __call__(self, y_true: np.ndarray, y_pred: np.ndarray) -> float:
"""计算未加额外样本权重的平均损失。
:param y_true: 一维 0/1 标签,1 表示坏样本,与预测等长。
:param y_pred: 一维坏样本概率,取值在 [0, 1];不接收原始分数。
:return: float,损失越小越好;不对结果取负。
原始分数、两列概率或额外样本权重请用 ``evaluate``。
子类必须实现本方法,并让 ``gradient`` / ``hessian`` 与其数学定义一致。
>>> from hscredit.core.models.losses import FocalLoss
>>> value = FocalLoss()([0, 1], [0.2, 0.8])
"""
[文档]
@abstractmethod
def gradient(self, y_true: np.ndarray, y_pred: np.ndarray) -> np.ndarray:
"""返回对坏样本概率的一阶导数,用于实现训练目标。
:param y_true: 形状 (n_samples,) 的 0/1 标签。
:param y_pred: 同形状的坏样本概率,通常在数值计算前裁剪至 (0, 1)。
:return: 同形状有限数组,定义为 ``d(n_samples * loss) / dp``;
逐样本可加损失等于各行损失的导数,不再除以样本数。
此处对概率 p 求导。框架适配器负责 sigmoid 链式求导与样本权重,
自定义子类不应重复转换为原始分数导数。
>>> from hscredit.core.models.losses import FocalLoss
>>> gradient = FocalLoss().gradient([0, 1], [0.2, 0.8])
"""
[文档]
def hessian(self, y_true: np.ndarray, y_pred: np.ndarray) -> Optional[np.ndarray]:
"""返回对概率的二阶导数,或 None 以请求训练曲率近似。
:param y_true: 一维 0/1 标签。
:param y_pred: 同形状坏样本概率。
:return: ``d²(n_samples * loss) / dp²`` 的对角项数组,或 None。
原始数学曲率可为负;树框架适配器转换后会采用正对角近似。
>>> from hscredit.core.models.losses import FocalLoss
>>> curvature = FocalLoss().hessian([0, 1], [0.2, 0.8])
"""
return None
[文档]
def loss_values(self, y_true, y_pred):
"""计算逐样本损失贡献,用于加权评估或可安全分批的训练。
:param y_true: 一维 0/1 标签。
:param y_pred: 同形状坏样本概率。
:return: 一维数组,其普通均值应等于 ``self(y_true, y_pred)``。
:raises NotImplementedError: 损失依赖完整排序或整体样本分布,无法逐行分解。
自定义损失只有在每行计算不依赖其他行时才应设置 ``is_additive=True``。
>>> from hscredit.core.models.losses import FocalLoss
>>> values = FocalLoss().loss_values([0, 1], [0.2, 0.8])
"""
raise NotImplementedError("该损失依赖整体样本分布,不支持逐样本损失或额外样本权重")
def _external_weight_normalizer(self, y_true, sample_weight):
"""额外权重与损失内部归一化权重相乘时的校正量。"""
return 1.0
[文档]
def metric(self, name=None, **sample_params):
"""生成与本损失公式一致的评估指标,越小越好。
:param name: 可选的中文指标名称,省略时使用内置名称。
:param sample_params: 替换指标副本中的逐行参数,例如
``AmountWeightedLoss.metric(amounts=valid_amounts)``。
仅支持提供 ``set_sample_params`` 的损失,行顺序须与评估数据一致。
:return: 独立的 ``LossMetric`` 参数快照,可离线调用、传入 ModelTuner,
或转换为 sklearn 评分器、框架监控回调。
>>> from hscredit.core.models.losses import FocalLoss
>>> loss = FocalLoss(alpha=0.75)
>>> metric = loss.metric(name='验证集聚焦损失')
>>> metric.direction
'minimize'
>>> value = metric([0, 1], [0.2, 0.8])
"""
return LossMetric(self, name=name, **sample_params)
[文档]
def to_metric(self, name=None, **sample_params):
"""``metric`` 的同义方法,参数、快照与返回语义完全相同。
:param name: 可选的中文指标名称。
:param sample_params: 评估数据对应的样本金额或金融参数。
:return: ``LossMetric`` 实例。
>>> from hscredit.core.models.losses import FocalLoss
>>> metric = FocalLoss().to_metric()
"""
return self.metric(name=name, **sample_params)
[文档]
def business_metric(self):
"""获取与损失目标对应的实际业务指标。
:return: ``BaseMetric`` 实例,继承本损失的头部比例、成本等业务参数。
排序目标对应 AUC,分离目标对应 KS,审批目标对应坏账率、通过率
或利润等。没有专用映射时返回本损失的 ``LossMetric``,
例如 FocalLoss 与 AmountWeightedLoss。详见 ``business_metric_for_loss``。
损失值用于衡量代理优化目标,业务指标用于衡量实际模型表现,
两者可能有不同的最优模型及优化方向,应通过 ``metric.direction`` 判断。
>>> from hscredit.core.models.losses import RankingAUCProxyLoss
>>> metric = RankingAUCProxyLoss().business_metric()
>>> metric([0, 1], [0.2, 0.8])
1.0
"""
from .business_metrics import business_metric_for_loss
return business_metric_for_loss(self)
[文档]
def evaluate(self, y_true, y_pred, sample_weight=None, *, raw_score=False, **sample_params):
"""校验输入并计算离线损失,建议作为统一评估入口。
:param y_true: 一维 0/1 标签,1 为坏样本。
:param y_pred: 一维坏样本概率,或类别顺序 [0, 1] 的两列概率。
``raw_score=True`` 时改为一维原始分数(logit)。
:param sample_weight: 可选的一维非负样本权重,须有限、等长且总和大于 0。
只有可逐行分解的损失支持额外权重;不得用金额数组替代样本行对齐。
:param raw_score: 是否先将一维原始分数转换为坏样本概率,默认 False。
:param sample_params: 当前评估数据的金额或金融参数,语义同 ``metric``。
:return: float,原始损失值,越小越好。
>>> from hscredit.core.models.losses import FocalLoss
>>> loss = FocalLoss()
>>> value = loss.evaluate([0, 1], [[0.8, 0.2], [0.2, 0.8]])
>>> weighted = loss.evaluate([0, 1], [0.2, 0.8], sample_weight=[1, 2])
"""
return self.metric(**sample_params).evaluate(y_true, y_pred, sample_weight=sample_weight, raw_score=raw_score)
[文档]
def to_scorer(self):
"""生成 sklearn 评分器,自动对损失取负以符合越大越好的约定。
:return: ``scorer(estimator, X, y_true, sample_weight=None)`` 可调用对象。
模型须有 ``predict_proba``,类别为 0 和 1。
:raises ValueError: 已绑定逐行金额等数组,无法在 sklearn CV 中自动切分。
>>> from hscredit.core.models.losses import FocalLoss
>>> from sklearn.model_selection import cross_val_score
>>> scorer = FocalLoss().to_scorer()
>>> scores = cross_val_score(model, X, y, scoring=scorer, cv=3) # doctest: +SKIP
"""
return self.metric().to_scorer()
[文档]
def to_xgboost(self, api="native"):
"""生成 XGBoost 自定义目标,自动处理原始分数、链接函数和权重。
:param api: ``'native'`` 返回 ``objective(preds, DMatrix)``,用于
``xgboost.train(obj=...)``;``'sklearn'`` 返回
``objective(y_true, y_pred, sample_weight=None)``,用于 XGBClassifier。
:return: 返回 ``(grad, hess)`` 两个等长数组的目标回调。
输入预测始终为原始分数;非凸目标采用正对角 Hessian 近似。
:raises ValueError: api 无效,或跨样本损失被传入额外权重。
>>> from hscredit.core.models.losses import FocalLoss
>>> from xgboost import XGBClassifier
>>> loss = FocalLoss(alpha=0.75)
>>> model = XGBClassifier(objective=loss.to_xgboost(api='sklearn'))
"""
if api == "native":
def objective(preds, data):
return _objective_derivatives(self, data.get_label(), preds, _dataset_weight(data))
elif api == "sklearn":
def objective(y_true, y_pred, sample_weight=None):
return _objective_derivatives(self, y_true, y_pred, sample_weight)
else:
raise ValueError("api 必须是 'native' 或 'sklearn'")
return objective
[文档]
def to_lightgbm(self, api="sklearn"):
"""生成 LightGBM 自定义目标。
:param api: 默认 ``'sklearn'``,用于 ``LGBMClassifier(objective=...)``;
``'native'`` 用于 ``lightgbm.train`` 的 params['objective']。
:return: 返回 (grad, hess) 的回调;原始分数、权重与曲率语义同 ``to_xgboost``。
>>> from hscredit.core.models.losses import FocalLoss
>>> from lightgbm import LGBMClassifier
>>> model = LGBMClassifier(objective=FocalLoss().to_lightgbm())
"""
return self.to_xgboost(api=api)
[文档]
def to_catboost(self):
"""生成 CatBoost 自定义目标,自动提供 CatBoost 约定的负导数。
:return: 带 ``calc_ders_range(approxes, targets, weights)`` 的目标对象。
接收原始分数,返回逐行 (-grad, -hess)。
:raises ValueError: 损失不能安全分批计算,例如全量排序或绑定金额数组。
>>> from hscredit.core.models.losses import FocalLoss
>>> from catboost import CatBoostClassifier
>>> model = CatBoostClassifier(loss_function=FocalLoss().to_catboost(), eval_metric='AUC')
"""
from .adapters import CatBoostLossAdapter
return CatBoostLossAdapter(self).objective()
[文档]
def to_ngboost(self):
"""生成 NGBoost Score;必须同时使用返回类的 distribution。
:return: NGBoost Score 子类,``score_class.distribution`` 是配套的
Bernoulli 分布子类;训练接收标签,内部自行读取坏样本概率。
:raises ImportError: NGBoost 未安装。
:raises ValueError: 损失不能安全地逐行、分批计算。
推荐 ``ngboost_params`` 一并配置 Score 和 Dist,避免遗漏。
>>> from hscredit.core.models.losses import FocalLoss
>>> from ngboost import NGBClassifier
>>> score_class = FocalLoss().to_ngboost()
>>> model = NGBClassifier(Score=score_class, Dist=score_class.distribution)
"""
if not self.is_additive:
raise ValueError("NGBoost仅支持可独立逐样本计算的损失;请使用固定权重的 FocalLoss 或 WeightedBCELoss")
try:
from ngboost.scores import Score
from ngboost.distns import Bernoulli
except ImportError as exc:
raise ImportError("NGBoost未安装,请使用 pip install ngboost 安装") from exc
loss = deepcopy(self)
class CustomScore(Score):
def score(self, y):
return loss.loss_values(np.asarray(y), np.clip(self.probs[1], 1e-7, 1 - 1e-7))
def d_score(self, y):
p = np.clip(self.probs[1], 1e-7, 1 - 1e-7)
return (loss.gradient(np.asarray(y), p) * p * (1 - p))[:, None]
def metric(self):
# Bernoulli 分布的 Fisher 信息用于自然梯度预条件。
p = np.clip(self.probs[1], 1e-7, 1 - 1e-7)
return (p * (1 - p))[:, None, None]
class CustomBernoulli(Bernoulli):
scores = [CustomScore]
@classmethod
def implementation(cls, score, scores=None):
if score is CustomScore:
return CustomScore
return Bernoulli.implementation(score, scores=scores)
CustomScore.__name__ = f"NGBoost_{self.name}"
CustomScore.distribution = CustomBernoulli
return CustomScore
[文档]
def ngboost_params(self):
"""生成可直接解包给 NGBClassifier 的配套参数。
:return: 字典,包含相互兼容的 ``Score`` 和 ``Dist`` 类。
:raises ImportError: NGBoost 未安装。
:raises ValueError: 损失不支持独立逐行计算。
>>> from hscredit.core.models.losses import FocalLoss
>>> from ngboost import NGBClassifier
>>> model = NGBClassifier(**FocalLoss().ngboost_params())
"""
score = self.to_ngboost()
return {"Score": score, "Dist": score.distribution}
[文档]
class BaseMetric(ABC):
"""可用于离线评估、训练监控及调参的统一指标。
**参数**
:param name: 指标名称。
:param greater_is_better: True 表示越大越好。
**属性**
direction 可直接用于 Optuna 的 maximize / minimize。
**参考样例**
>>> from hscredit.core.models.losses import FocalLoss
>>> metric = FocalLoss().metric()
>>> value = metric.evaluate([0, 1], [0.1, 0.9])
>>> scorer = metric.to_scorer()
自定义指标只需实现 ``__call__(y_true, y_pred, sample_weight=None)`` 并
指定优化方向;不支持权重时可省略该参数,传权重会显式报错。
离线场景优先调用 ``evaluate`` 以获取输入和返回值校验。
"""
def __init__(self, name="自定义指标", greater_is_better=True):
self.name = name
self.greater_is_better = greater_is_better
@property
def direction(self):
"""返回 ``'maximize'`` 或 ``'minimize'``,可直接传给 Optuna。
>>> from hscredit.core.models.losses import FocalLoss
>>> FocalLoss().metric().direction
'minimize'
"""
return "maximize" if self.greater_is_better else "minimize"
@abstractmethod
def __call__(self, y_true, y_pred) -> float:
"""计算未改变符号的指标值,子类必须实现。
:param y_true: 形状 (n_samples,) 的 0/1 标签,1 表示坏样本。
:param y_pred: 同形状的坏样本概率,范围 [0, 1]。
:return: 有限 float;优劣方向由 ``greater_is_better`` 指定。
若子类支持权重,应增加 ``sample_weight=None`` 参数;输入原始分数
或两列概率时统一使用 ``evaluate``,避免回调协议与离线协议混淆。
"""
[文档]
def evaluate(self, y_true, y_pred, sample_weight=None, *, raw_score=False):
"""离线评估统一入口,校验标签、概率、权重和返回值。
:param y_true: 一维、非空 0/1 标签。
:param y_pred: 一维坏样本概率,或列顺序 [0, 1] 的两列概率。
``raw_score=True`` 时接收一维 logit,并先做 sigmoid。
:param sample_weight: 可选一维非负有限数组,等长且总和大于 0;
指标子类必须声明支持此参数。
:param raw_score: 是否将一维原始分数转为坏样本概率,默认 False。
:return: float,原始指标值,不根据优化方向改变符号。
:raises ValueError: 输入无效、权重不支持,或指标返回非有限值。
>>> from hscredit.core.models.losses import AUCMetric
>>> AUCMetric().evaluate([0, 1], [[0.8, 0.2], [0.2, 0.8]])
1.0
>>> AUCMetric().evaluate([0, 1], [-2.0, 2.0], raw_score=True)
1.0
"""
y, p, weight = _validate_binary_inputs(y_true, y_pred, sample_weight, raw_score)
if weight is None:
value = self(y, p)
elif "sample_weight" in signature(self.__call__).parameters:
value = self(y, p, sample_weight=weight)
else:
raise ValueError(f"指标 {self.name} 不支持样本权重")
if not np.isfinite(value):
raise ValueError(f"指标 {self.name} 返回了非有限值,请检查样本和参数")
return float(value)
[文档]
def to_scorer(self):
"""生成 sklearn CV 或 GridSearchCV 使用的评分器。
:return: 独立参数快照,签名为
``scorer(estimator, X, y_true, sample_weight=None)``。
根据 ``estimator.classes_`` 选择类别 1 的预测概率;仅在此处
对越小越好的指标取负,保持 sklearn 越大越好的约定。
>>> from hscredit.core.models.losses import AUCMetric
>>> from sklearn.model_selection import GridSearchCV
>>> search = GridSearchCV(model, {'C': [0.1, 1]}, scoring=AUCMetric().to_scorer()) # doctest: +SKIP
"""
return _MetricScorer(deepcopy(self))
[文档]
def to_xgboost(self, api="native", *, raw_score=False):
"""生成 XGBoost 指标回调。
:param api: ``'native'`` 用于 ``xgboost.train(custom_metric=...)``;
``'sklearn'`` 用于 ``XGBClassifier(eval_metric=...)``。
:param raw_score: 使用自定义训练目标时设为 True;内置 logistic 目标
通常传来概率,保持 False。转换只做一次,不能同时外部 sigmoid。
:return: native 回调 ``metric(preds, DMatrix) -> (name, value)``;
sklearn 回调 ``metric(y_true, y_pred, sample_weight=None) -> value``。
原生 train 还需 maximize=metric.greater_is_better;sklearn 早停需配置
相同方向的 EarlyStopping 回调。HSCredit 模型可自动完成这些配置。
>>> from hscredit.core.models.losses import FocalLoss
>>> callback = FocalLoss().metric().to_xgboost(raw_score=True)
>>> sklearn_callback = FocalLoss().metric().to_xgboost(api='sklearn', raw_score=True)
"""
if api == "native":
def metric(preds, data):
return self.name, self.evaluate(data.get_label(), preds, _dataset_weight(data), raw_score=raw_score)
elif api == "sklearn":
def metric(y_true, y_pred, sample_weight=None):
return self.evaluate(y_true, y_pred, sample_weight, raw_score=raw_score)
else:
raise ValueError("api 必须是 'native' 或 'sklearn'")
metric.__name__ = self.name
return metric
[文档]
def to_lightgbm(self, api="sklearn", *, raw_score=False):
"""生成 LightGBM 指标回调,返回值自动携带优化方向。
:param api: ``'sklearn'`` 用于 ``LGBMClassifier.fit(eval_metric=...)``;
``'native'`` 用于 ``lightgbm.train(feval=...)``。
:param raw_score: 与自定义目标一起使用时设 True,内置概率目标用 False。
:return: 返回 ``(name, value, greater_is_better)`` 的回调。
native 参数为 (preds, Dataset),sklearn 为 (y_true, y_pred, weight=None)。
>>> from hscredit.core.models.losses import FocalLoss
>>> callback = FocalLoss().metric().to_lightgbm(raw_score=True)
>>> native_callback = FocalLoss().metric().to_lightgbm(api='native', raw_score=True)
"""
if api == "native":
def metric(preds, data):
value = self.evaluate(data.get_label(), preds, _dataset_weight(data), raw_score=raw_score)
return self.name, value, self.greater_is_better
elif api == "sklearn":
def metric(y_true, y_pred, weight=None):
value = self.evaluate(y_true, y_pred, weight, raw_score=raw_score)
return self.name, value, self.greater_is_better
else:
raise ValueError("api 必须是 'native' 或 'sklearn'")
metric.__name__ = self.name
return metric
[文档]
def to_catboost(self):
"""生成 CatBoost 指标对象,自动将原始分数转换为概率。
:return: 提供 evaluate、get_final_error、is_max_optimal 的对象。
evaluate 返回 (指标值 × 权重和, 权重和),最终结果还原为指标值。
标记为不可加,以保留 AUC、KS、排序和逐行金额指标的全量评估语义。
>>> from hscredit.core.models.losses import FocalLoss
>>> callback = FocalLoss().metric().to_catboost()
>>> callback.is_max_optimal()
False
"""
metric = self
class CatBoostMetric:
def is_additive(self):
# 全量评价才能保持 AUC、KS、排序和绑定金额数组的正确语义。
return False
def evaluate(self, approxes, target, weight):
if len(approxes) != 1:
raise ValueError("当前指标仅支持CatBoost二分类预测")
value = metric.evaluate(target, approxes[0], weight, raw_score=True)
mass = float(len(target) if weight is None else np.sum(weight))
return value * mass, mass
def get_final_error(self, error, weight):
return error / weight if weight > 0 else 0.0
def is_max_optimal(self):
return metric.greater_is_better
return CatBoostMetric()
_LOSS_LABELS = {
"FocalLoss": "聚焦损失",
"AsymmetricFocalLoss": "非对称聚焦损失",
"BalancedFocalLoss": "平衡聚焦损失",
"WeightedBCELoss": "加权交叉熵",
"CostSensitiveLoss": "成本敏感损失",
"BadDebtLoss": "坏账代理损失",
"ApprovalRateLoss": "通过率代理损失",
"ProfitMaxLoss": "利润代理损失",
"ExpectedProfitLoss": "期望利润损失",
"OrdinalRankLoss": "序数排序损失",
"LiftFocusedLoss": "头部提升损失",
"RankingAUCProxyLoss": "排序面积代理损失",
"KSFocusedLoss": "分布分离损失",
"TopKBadCaptureLoss": "头部捕获损失",
"AmountWeightedLoss": "金额加权损失",
"ExpectedValueLoss": "期望价值损失",
}
[文档]
class LossMetric(BaseMetric):
"""每一种 BaseLoss 的配套评估指标。
**参数**
:param loss: 损失实例;创建时复制其参数,评估不会改变训练对象。
:param name: 自定义指标名,默认采用中文名称。
:param sample_params: 替换副本的样本参数,例如 ``amounts=valid_amounts``;
须与验证集逐行对应,支持范围由损失的 ``set_sample_params`` 决定。
**属性**
loss 保存参数快照;greater_is_better=False。
**参考样例**
>>> from hscredit.core.models.losses import FocalLoss
>>> loss = FocalLoss(alpha=0.75)
>>> metric = loss.metric()
>>> metric([0, 1], [0.2, 0.8]) == loss.evaluate([0, 1], [0.2, 0.8])
True
"""
def __init__(self, loss, name=None, **sample_params):
if not isinstance(loss, BaseLoss):
raise TypeError("loss 必须是 BaseLoss 实例")
super().__init__(name or _LOSS_LABELS.get(type(loss).__name__, "自定义损失"), greater_is_better=False)
self.loss = deepcopy(loss)
if sample_params:
if not hasattr(self.loss, "set_sample_params"):
raise ValueError("该损失不支持样本参数;请在构造损失时设置模型参数")
self.loss.set_sample_params(**deepcopy(sample_params))
def __call__(self, y_true, y_pred, sample_weight=None):
"""计算与损失公式一致的离线指标,保持越小越好的原始数值。
:param y_true: 一维 0/1 标签,1 表示坏样本。
:param y_pred: 坏样本概率,或类别顺序 [0, 1] 的两列概率。
:param sample_weight: 可选非负样本权重;跨样本损失不支持额外权重。
:return: float,损失的普通或加权平均值,含内部权重的归一化校正。
>>> from hscredit.core.models.losses import FocalLoss
>>> value = FocalLoss().metric()([0, 1], [0.2, 0.8], sample_weight=[1, 2])
"""
y, p, weight = _validate_binary_inputs(y_true, y_pred, sample_weight)
if weight is None:
value = self.loss(y, p)
else:
values = np.asarray(self.loss.loss_values(y, p), dtype=float)
if values.shape != y.shape:
raise ValueError("逐样本损失必须与标签等长")
value = np.average(values, weights=weight) / self.loss._external_weight_normalizer(y, weight)
if not np.isfinite(value):
raise ValueError(f"指标 {self.name} 返回了非有限值,请检查样本和参数")
return float(value)
[文档]
def to_scorer(self):
"""生成 sklearn 评分器,并检查样本参数能否安全用于交叉验证。
:return: ``scorer(estimator, X, y_true, sample_weight=None)``,输出负损失。
:raises ValueError: 指标绑定 amounts、lgd、ead、rate 或 cost 的逐行数组;
应在每折内切分对应数组并重新构造指标,或用 ModelTuner 的折内适配。
>>> from hscredit.core.models.losses import FocalLoss
>>> scorer = FocalLoss().metric().to_scorer()
"""
for key in ("amounts_", "lgd", "ead", "rate", "cost"):
value = getattr(self.loss, key, None)
if value is not None and np.asarray(value).ndim > 0:
raise ValueError("绑定样本金额或金融参数数组的指标不能直接用于交叉验证;请在每一折内按行切分并构造指标")
return super().to_scorer()
class _MetricScorer:
"""可序列化、显式选择坏样本概率的 sklearn 评分器。"""
def __init__(self, metric):
self.metric = metric
def __call__(self, estimator, X, y_true, sample_weight=None):
classes = np.asarray(getattr(estimator, "classes_", []))
if classes.shape != (2,) or set(classes.tolist()) != {0, 1}:
raise ValueError("评分器要求模型类别为0(好样本)和1(坏样本)")
prediction = np.asarray(estimator.predict_proba(X))
if prediction.ndim != 2 or prediction.shape[1] != 2:
raise ValueError("评分器要求 predict_proba 返回与类别对应的两列概率")
prediction = prediction[:, np.argsort(classes)]
value = self.metric.evaluate(y_true, prediction, sample_weight)
return value if self.metric.greater_is_better else -value