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

"""
金额加权损失函数

按授信金额、风险敞口或期望价值对样本加权,使模型更关注高金额/高敞口样本的预测准确性。
包含两个损失类:

- :class:`AmountWeightedLoss`: 按授信金额/风险敞口加权
- :class:`ExpectedValueLoss`: 结合 LGD / EAD / 利率 / 成本 的期望价值优化
"""

from __future__ import annotations

from typing import Optional, Union

import numpy as np

from .base import BaseLoss
from ._loss_math import binary_inputs, bce_terms, nonnegative, positive, unit_interval


[文档] class AmountWeightedLoss(BaseLoss): """按授信金额/风险敞口加权的损失函数。 在信贷场景中,不同客户的授信金额差异很大。一个 10 万元客户的 坏账和一个 1000 元客户的坏账,业务影响完全不同。本损失通过 金额加权使模型更关注高金额客户的预测准确性。 数学形式:: w_i = amount_i / mean(amount) # 归一化权重 Loss = weighted_mean(BCE_i, w_i) :param amounts: 样本级金额/敞口数组, shape (n_samples,)。 可在构造时传入,也可通过 :meth:`set_sample_params` 动态设置。 :param normalize: 是否对金额进行均值归一化,默认 True - 归一化后平均权重为 1.0,不影响学习率 :param floor_weight: 权重下限,防止低金额样本完全被忽略,默认 0.1 :param name: 损失函数名称,默认 "amount_weighted_loss" **参考样例** >>> import numpy as np >>> from hscredit.core.models.losses import AmountWeightedLoss >>> >>> amounts = np.array([50000, 100000, 200000, 10000]) >>> loss = AmountWeightedLoss(amounts=amounts) >>> >>> y_true = np.array([0, 0, 1, 1]) >>> y_pred = np.array([0.1, 0.3, 0.7, 0.9]) >>> loss_value = loss(y_true, y_pred) >>> >>> # 动态设置金额(适用于每轮训练数据不同的场景) >>> loss2 = AmountWeightedLoss() >>> loss2.set_sample_params(amounts=np.array([30000, 80000, 150000, 5000])) **引用** 按风险敞口/金额加权,等价于以"预期损失金额"为代价的样本级成本敏感学习,理论框架见 Elkan, C. (2001). *The Foundations of Cost-Sensitive Learning.* IJCAI 2001。属信贷 业务驱动设计。 """ def __init__( self, amounts: Optional[np.ndarray] = None, normalize: bool = True, floor_weight: float = 0.1, name: str = "amount_weighted_loss", ): super().__init__(name) self.amounts_ = np.asarray(amounts, dtype=float) if amounts is not None else None nonnegative(floor_weight=floor_weight) self.is_additive = amounts is None self.normalize = normalize self.floor_weight = floor_weight
[文档] def set_sample_params( self, amounts: np.ndarray, ) -> "AmountWeightedLoss": """设置样本级金额参数。 :param amounts: 金额数组, shape (n_samples,) 必须非负且有限,顺序与当前输入样本一致。 :return: self,原地更新;评估验证集时优先用 ``loss.metric(amounts=...)`` 创建副本,避免覆盖训练金额。 >>> loss = AmountWeightedLoss().set_sample_params(amounts=[1000, 2000]) >>> metric = loss.metric(amounts=[3000, 4000]) """ self.amounts_ = np.asarray(amounts, dtype=float) self.is_additive = False return self
def _get_weights(self, n_samples: int) -> np.ndarray: """获取归一化后的金额权重。""" if self.amounts_ is None: return np.ones(n_samples, dtype=float) w = self.amounts_.copy() if w.ndim != 1 or not np.all(np.isfinite(w)) or np.any(w < 0): raise ValueError("金额必须是一维非负有限数组。") if len(w) != n_samples: raise ValueError( f"金额数组长度 ({len(w)}) 与样本数 ({n_samples}) 不一致," f"请通过 set_sample_params() 更新金额数组。" ) # 下限截断 w = np.maximum(w, 0) if self.normalize: mean_w = np.mean(w) + 1e-12 w = w / mean_w # 权重下限 w = np.maximum(w, self.floor_weight) return w def __call__(self, y_true, y_pred) -> float: """计算本损失的平均值,越小越好。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: float;原始损失值,不根据调参方向改变符号。 **参考样例** >>> from hscredit.core.models.losses import AmountWeightedLoss >>> loss = AmountWeightedLoss() >>> result = loss([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return float(np.mean(self.loss_values(y_true, y_pred)))
[文档] def gradient(self, y_true, y_pred): """返回损失相对坏样本概率的一阶导数。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: 与输入等长的一阶导数组,标度为样本数乘以平均损失;详见 BaseLoss.gradient。 **参考样例** >>> from hscredit.core.models.losses import AmountWeightedLoss >>> loss = AmountWeightedLoss() >>> result = loss.gradient([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return self._terms(y_true, y_pred)[1]
[文档] def hessian(self, y_true, y_pred): """返回损失相对坏样本概率的二阶导数。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: 与输入等长的二阶导数组,标度同 BaseLoss.hessian;框架适配器再做链接函数转换。 **参考样例** >>> from hscredit.core.models.losses import AmountWeightedLoss >>> loss = AmountWeightedLoss() >>> result = loss.hessian([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return self._terms(y_true, y_pred)[2]
def _external_weight_normalizer(self, y_true, sample_weight): weights = self._get_weights(len(y_true)) normalizer = np.average(weights / np.mean(weights), weights=sample_weight) if not np.isfinite(normalizer) or normalizer <= 0: raise ValueError("金额与额外样本权重相乘后的权重总和必须为有限正数") return normalizer def _terms(self, y_true, y_pred): y, p = binary_inputs(y_true, y_pred) weights = self._get_weights(len(y)) mean_weight = np.mean(weights) if not np.isfinite(mean_weight) or mean_weight <= 0: raise ValueError("样本权重的平均值必须是有限正数。") weights = weights / mean_weight return tuple(weights * term for term in bce_terms(y, p))
[文档] def loss_values(self, y_true, y_pred): """逐样本贡献已按整体平均权重归一化;其均值等于加权平均 BCE。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: 与输入等长的数组,其均值等于本损失值;详见 BaseLoss.loss_values。 **参考样例** >>> from hscredit.core.models.losses import AmountWeightedLoss >>> loss = AmountWeightedLoss() >>> result = loss.loss_values([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return self._terms(y_true, y_pred)[0]
[文档] class ExpectedValueLoss(BaseLoss): """期望价值损失函数,结合 LGD / EAD / 利率 / 成本进行期望价值优化。 在信贷全生命周期管理中,不同客户的风险敞口(EAD)、违约损失率(LGD)、 收益率各不相同。本损失以这些金融参数构造成本加权 BCE 代理, 返回值不是实际货币利润;metric() 与 business_metric() 均按同一期望价值加权损失评估。 样本级权重:: 坏样本 (y=1): 权重 = LGD_i × EAD_i → 漏捕的经济损失 好样本 (y=0): 权重 = rate_i × EAD_i - cost_i → 误拒的机会成本 数学形式:: w_i = y_i × LGD_i × EAD_i + (1-y_i) × max(rate_i × EAD_i - cost_i, ε) Loss = weighted_mean(BCE_i, w_i) :param lgd: 违约损失率,标量或数组,默认 0.5 - 标量: 所有样本使用相同 LGD - 数组: 每个样本独立的 LGD, shape (n_samples,) :param ead: 违约风险敞口,标量或数组,默认 None(使用全 1) - 通常等于授信余额或授信额度 :param rate: 年化收益率,标量或数组,默认 0.08(8%) :param cost: 单客运营成本,标量或数组,默认 0.0 :param floor_weight: 权重下限,默认 0.1 :param name: 损失函数名称,默认 "expected_value_loss" **参考样例** >>> import numpy as np >>> from hscredit.core.models.losses import ExpectedValueLoss >>> >>> # 全局参数 >>> loss = ExpectedValueLoss(lgd=0.5, rate=0.12) >>> >>> # 样本级参数 >>> loss = ExpectedValueLoss( ... lgd=np.array([0.4, 0.6, 0.5, 0.3]), ... ead=np.array([50000, 100000, 200000, 30000]), ... rate=0.10, ... cost=500 ... ) >>> >>> y_true = np.array([0, 0, 1, 1]) >>> y_pred = np.array([0.1, 0.3, 0.7, 0.9]) >>> loss_value = loss(y_true, y_pred) >>> >>> # 在 XGBoost 中使用 >>> import xgboost as xgb >>> dtrain = xgb.DMatrix(X_train, label=y_train) >>> bst = xgb.train({}, dtrain, obj=loss.to_xgboost(), num_boost_round=100) """ def __init__( self, lgd: Union[float, np.ndarray] = 0.5, ead: Optional[Union[float, np.ndarray]] = None, rate: Union[float, np.ndarray] = 0.08, cost: Union[float, np.ndarray] = 0.0, floor_weight: float = 0.1, name: str = "expected_value_loss", ): super().__init__(name) nonnegative(floor_weight=floor_weight) self.lgd = lgd self.ead = ead self.rate = rate self.cost = cost self.floor_weight = floor_weight
[文档] def set_sample_params( self, lgd: Optional[Union[float, np.ndarray]] = None, ead: Optional[Union[float, np.ndarray]] = None, rate: Optional[Union[float, np.ndarray]] = None, cost: Optional[Union[float, np.ndarray]] = None, ) -> "ExpectedValueLoss": """设置样本级金融参数。 :param lgd: 违约损失率,标量或一维数组,范围 [0, 1];None 保持原值。 :param ead: 违约风险敞口,非负标量或一维数组;None 保持原值。 :param rate: 年化收益率,非负标量或一维数组;None 保持原值。 :param cost: 单客运营成本,非负标量或一维数组;None 保持原值。 :return: self,原地更新;数组须有限且与之后计算的样本顺序一致。 >>> loss = ExpectedValueLoss().set_sample_params(lgd=0.5, ead=[1000, 2000]) >>> metric = loss.metric(ead=[3000, 4000]) """ if lgd is not None: self.lgd = lgd if ead is not None: self.ead = ead if rate is not None: self.rate = rate if cost is not None: self.cost = cost return self
def _broadcast(self, value, n, default=1.0): """检查并广播标量/长度严格匹配的一维金融参数。""" arr = np.asarray(default if value is None else value, dtype=float) if arr.ndim == 0: arr = np.full(n, float(arr)) if arr.shape != (n,) or not np.all(np.isfinite(arr)) or np.any(arr < 0): raise ValueError(f"金融参数必须为非负有限标量或长度为 {n} 的一维数组。") return arr def _get_weights(self, y_true: np.ndarray) -> np.ndarray: """计算样本级期望价值权重。""" n = len(y_true) lgd = self._broadcast(self.lgd, n, 0.5) if np.any(lgd > 1): raise ValueError("违约损失率 lgd 必须在 [0, 1] 范围内。") ead = self._broadcast(self.ead, n, 1.0) rate = self._broadcast(self.rate, n, 0.08) cost = self._broadcast(self.cost, n, 0.0) # 坏样本权重: 违约损失 = LGD × EAD bad_weight = lgd * ead # 好样本权重: 机会收益 = rate × EAD - cost good_weight = np.maximum(rate * ead - cost, 1e-6) # 按标签混合 weights = y_true * bad_weight + (1 - y_true) * good_weight # 归一化 mean_w = np.mean(weights) + 1e-12 weights = weights / mean_w # 权重下限 weights = np.maximum(weights, self.floor_weight) return weights def __call__(self, y_true, y_pred) -> float: """计算本损失的平均值,越小越好。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: float;原始损失值,不根据调参方向改变符号。 **参考样例** >>> from hscredit.core.models.losses import ExpectedValueLoss >>> loss = ExpectedValueLoss() >>> result = loss([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return float(np.mean(self.loss_values(y_true, y_pred)))
[文档] def gradient(self, y_true, y_pred): """返回损失相对坏样本概率的一阶导数。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: 与输入等长的一阶导数组,标度为样本数乘以平均损失;详见 BaseLoss.gradient。 **参考样例** >>> from hscredit.core.models.losses import ExpectedValueLoss >>> loss = ExpectedValueLoss() >>> result = loss.gradient([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return self._terms(y_true, y_pred)[1]
[文档] def hessian(self, y_true, y_pred): """返回损失相对坏样本概率的二阶导数。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: 与输入等长的二阶导数组,标度同 BaseLoss.hessian;框架适配器再做链接函数转换。 **参考样例** >>> from hscredit.core.models.losses import ExpectedValueLoss >>> loss = ExpectedValueLoss() >>> result = loss.hessian([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return self._terms(y_true, y_pred)[2]
def _external_weight_normalizer(self, y_true, sample_weight): weights = self._get_weights(y_true) normalizer = np.average(weights / np.mean(weights), weights=sample_weight) if not np.isfinite(normalizer) or normalizer <= 0: raise ValueError("金融参数与额外样本权重相乘后的权重总和必须为有限正数") return normalizer def _terms(self, y_true, y_pred): y, p = binary_inputs(y_true, y_pred) weights = self._get_weights(y) mean_weight = np.mean(weights) if not np.isfinite(mean_weight) or mean_weight <= 0: raise ValueError("样本权重的平均值必须是有限正数。") weights = weights / mean_weight return tuple(weights * term for term in bce_terms(y, p))
[文档] def loss_values(self, y_true, y_pred): """逐样本贡献已按整体平均权重归一化;其均值等于加权平均 BCE。 :param y_true: 一维 0/1 标签,1 为坏样本。 :param y_pred: 同形状坏样本概率,范围 [0, 1];不是原始分数。 :return: 与输入等长的数组,其均值等于本损失值;详见 BaseLoss.loss_values。 **参考样例** >>> from hscredit.core.models.losses import ExpectedValueLoss >>> loss = ExpectedValueLoss() >>> result = loss.loss_values([0, 0, 1, 1], [0.1, 0.3, 0.7, 0.9]) """ return self._terms(y_true, y_pred)[0]