hscredit.core.models.calibration.model 源代码

"""概率校准模型包装、便捷入口与兼容评估。"""

import copy
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, ClassifierMixin, clone
from sklearn.model_selection import train_test_split
from sklearn.utils.validation import check_is_fitted

from ....utils.serialization import ArtifactSerializableMixin
from ..base import _evaluate_binary_predictions
from .base import BaseCalibrator
from .methods import BetaCalibrator, HistogramCalibrator, IsotonicCalibrator, PlattCalibrator

if TYPE_CHECKING:
    import matplotlib


def _class_index(classes: np.ndarray, label: Any) -> int:
    """返回二分类标签的唯一概率列位置。"""
    matches = np.flatnonzero(np.asarray(classes) == label)
    if len(matches) != 1:
        raise ValueError(f"基础模型概率列中不存在唯一正类标签: {label!r}")
    return int(matches[0])


def _assemble_binary_probabilities(positive: np.ndarray, classes: np.ndarray, positive_class: Any) -> np.ndarray:
    """按 ``classes_`` 的真实顺序组装二分类概率矩阵。"""
    positive_index = _class_index(classes, positive_class)
    result = np.empty((len(positive), 2), dtype=float)
    result[:, positive_index] = positive
    result[:, 1 - positive_index] = 1.0 - positive
    return result


[文档] class ProbabilityCalibrator(ArtifactSerializableMixin, BaseEstimator, ClassifierMixin): """概率校准器 - 统一入口. 提供统一的概率校准接口,支持多种校准方法。 **参数** :param method: 校准方法,默认'platt' - 'platt': Platt Scaling (Sigmoid校准) - 'isotonic': 保序回归校准 - 'beta': Beta分布校准 - 'histogram': 直方图分箱校准 :param calib_ratio: 用于校准的数据比例,默认0.2 - 从训练集中划分出calib_ratio的数据用于校准 - 如果为None,需要使用独立的校准集 :param n_bins: 可靠性曲线的分箱数,默认10 :param random_state: 随机种子,默认None :param target: 目标列名,默认'target' - 用于从DataFrame中提取目标变量 :param positive_class: 正类标签;None 时使用基础模型 ``classes_[1]``。 :param calibrator_params: 传递给具体校准算法的可克隆参数字典。 **属性** :ivar calibrator_: 底层校准器实例 :ivar is_fitted_: 是否已拟合 :ivar calib_metrics_: 校准前后的指标对比 **参考样例** >>> # 方式1:使用独立校准集 >>> calibrator = ProbabilityCalibrator(method='isotonic', model=model, calib_ratio=None) >>> calibrator.fit(X_calib, y_calib) >>> proba_calib = calibrator.predict_proba(X_test) >>> # 方式2:自动划分校准集 >>> calibrator = ProbabilityCalibrator(method='platt', model=model, calib_ratio=0.2) >>> calibrator.fit(X_train, y_train) # 自动划分20%用于校准 >>> proba_calib = calibrator.predict_proba(X_test) >>> # 方式3:scorecardpipeline风格 >>> calibrator = ProbabilityCalibrator(method='platt', model=model, calib_ratio=None) >>> calibrator.fit(df_calib) # df_calib包含target列 >>> proba_calib = calibrator.predict_proba(df_test) >>> # 评估校准效果 >>> calibrator.plot_reliability_diagram(X_test, y_test) >>> metrics = calibrator.get_calibration_metrics() """ CALIB_METHODS = { "platt": PlattCalibrator, "sigmoid": PlattCalibrator, "isotonic": IsotonicCalibrator, "beta": BetaCalibrator, "histogram": HistogramCalibrator, } artifact_kind = "概率校准模型" def __init__( self, method: str = "platt", calib_ratio: Optional[float] = 0.2, n_bins: int = 10, random_state: Optional[int] = None, target: str = "target", model: Optional[Any] = None, positive_class: Optional[Any] = None, calibrator_params: Optional[Dict[str, Any]] = None, ): self.method = method self.calib_ratio = calib_ratio self.n_bins = n_bins self.random_state = random_state self.target = target self.model = model self.positive_class = positive_class self.calibrator_params = calibrator_params self._validate_parameters() def _validate_parameters(self) -> None: """校验构造参数和经 ``set_params`` 修改后的状态。""" if self.method not in self.CALIB_METHODS: raise ValueError(f"不支持的校准方法: {self.method},可选: {list(self.CALIB_METHODS.keys())}") if not isinstance(self.n_bins, int) or isinstance(self.n_bins, bool) or self.n_bins < 1: raise ValueError("n_bins必须是大于等于1的整数") if self.calib_ratio is not None: if ( isinstance(self.calib_ratio, bool) or not np.isscalar(self.calib_ratio) or not np.isfinite(self.calib_ratio) or not 0 < float(self.calib_ratio) < 1 ): raise ValueError("calib_ratio必须在(0, 1)范围内,或设为None使用独立校准集") if not isinstance(self.target, str) or not self.target: raise ValueError("target必须是非空字符串") if self.calibrator_params is not None and not isinstance(self.calibrator_params, dict): raise ValueError("calibrator_params必须是字典或None") def _make_calibrator(self) -> BaseCalibrator: """根据当前参数创建全新的底层校准算法。""" calibrator_class = self.CALIB_METHODS[self.method] return calibrator_class(n_bins=self.n_bins, **dict(self.calibrator_params or {}))
[文档] def fit( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, model=None, target: Optional[str] = None, **fit_params, ) -> "ProbabilityCalibrator": """拟合校准器. 支持两种传参风格: **sklearn风格**:: calibrator = ProbabilityCalibrator(model=model, calib_ratio=None) calibrator.fit(X_calib, y_calib) **scorecardpipeline风格**:: calibrator = ProbabilityCalibrator(model=model, calib_ratio=None) calibrator.fit(df_calib) # df_calib包含target列 :param model: 已训练的基础模型 :param X: 特征矩阵或包含target的DataFrame :param y: 目标变量,可选 :param target: 目标列名,默认使用初始化时设置的target :param fit_params: 其他参数 :return: self """ self._validate_parameters() # 兼容旧调用 ``fit(model, X, y)``。在该调用中,参数会映射为 # X=模型、y=特征、model=标签。 if hasattr(X, "predict_proba"): legacy_model = X legacy_X = y legacy_y = model X, y, model = legacy_X, legacy_y, legacy_model model = model if model is not None else self.model if model is None or not hasattr(model, "predict_proba"): raise ValueError("必须提供实现 predict_proba 方法的基础模型") # 使用初始化时设置的target target = target or self.target self.target_ = target # 处理两种传参风格 X, y = self._prepare_data(X, y, target) self.classes_ = np.asarray(getattr(model, "classes_", np.unique(y))) if len(self.classes_) != 2: raise ValueError("概率校准目前仅支持二分类模型") self.positive_class_ = self.classes_[1] if self.positive_class is None else self.positive_class if not np.any(self.classes_ == self.positive_class_): raise ValueError(f"positive_class={self.positive_class_!r} 不在模型类别 {self.classes_.tolist()!r} 中") # 如果需要划分校准集 if self.calib_ratio is not None: indices = np.arange(len(y)) train_indices, calib_indices = train_test_split( indices, test_size=self.calib_ratio, random_state=self.random_state, stratify=y ) def take(values, idx): return values.iloc[idx] if hasattr(values, "iloc") else np.asarray(values)[idx] X_train, y_train = take(X, train_indices), take(y, train_indices) X, y = take(X, calib_indices), take(y, calib_indices) try: fitted_model = clone(model) except (TypeError, RuntimeError): fitted_model = copy.deepcopy(model) model_fit_params = {} for name, value in fit_params.items(): if hasattr(value, "__len__") and len(value) == len(indices): model_fit_params[name] = take(value, train_indices) else: model_fit_params[name] = value fitted_model.fit(X_train, y_train, **model_fit_params) self.model_ = fitted_model else: self.model_ = model y_binary = (np.asarray(y) == self.positive_class_).astype(int) # 获取模型预测概率 y_prob = self._get_model_proba(X) # 每次 fit 都按当前参数重建底层校准器,避免 set_params 后复用旧算法或拟合状态。 self.calibrator_ = self._make_calibrator() self.calibrator_.fit(y_prob, y_binary) # 计算校准前后的指标 self.calib_metrics_ = {} self.calib_metrics_["original"] = self.calibrator_.compute_calibration_metrics(y_binary, y_prob) y_prob_calib = self.calibrator_.calibrate(y_prob) self.calib_metrics_["calibrated"] = self.calibrator_.compute_calibration_metrics(y_binary, y_prob_calib) self.is_fitted_ = True if hasattr(X, "shape") and len(X.shape) == 2: self.n_features_in_ = X.shape[1] if isinstance(X, pd.DataFrame): self.feature_names_in_ = np.asarray(X.columns, dtype=object) return self
[文档] def predict_proba(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray: """预测校准后的概率. :param X: 特征矩阵 :return: 两列概率数组,shape ``(n_samples, 2)`` """ check_is_fitted(self, "classes_") # 获取原始概率 y_prob = self._get_model_proba(X) # 校准 positive = np.clip(self.calibrator_.calibrate(y_prob), 0.0, 1.0) return _assemble_binary_probabilities(positive, self.classes_, self.positive_class_)
[文档] def predict(self, X: Union[np.ndarray, pd.DataFrame], threshold: float = 0.5) -> np.ndarray: """预测类别标签. :param X: 特征矩阵 :param threshold: 分类阈值,默认0.5 :return: 预测类别 """ if not 0 <= threshold <= 1: raise ValueError("threshold必须在[0, 1]范围内") positive_index = _class_index(self.classes_, self.positive_class_) proba = self.predict_proba(X)[:, positive_index] negative_class = self.classes_[1 - positive_index] return np.where(proba >= threshold, self.positive_class_, negative_class)
def _get_model_proba(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray: """获取模型预测概率.""" if hasattr(self.model_, "predict_proba"): proba = np.asarray(self.model_.predict_proba(X)) if proba.ndim == 2 and proba.shape[1] == 2: classes = np.asarray(getattr(self.model_, "classes_", self.classes_)) matches = np.flatnonzero(classes == self.positive_class_) if len(matches) != 1: raise ValueError(f"基础模型概率列中不存在正类标签: {self.positive_class_!r}") positive_index = int(matches[0]) return proba[:, positive_index] return proba else: raise ValueError("模型必须实现predict_proba方法") def _prepare_data(self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]], target: str): """准备数据,支持两种传参风格.""" # scorecardpipeline风格:从X中提取target if y is None: if isinstance(X, pd.DataFrame) and target in X.columns: y = X[target].to_numpy() X = X.drop(columns=[target]) else: raise ValueError(f"y为None时,X必须是包含'{target}'列的DataFrame") if isinstance(y, pd.Series): y = y.to_numpy() else: y = np.asarray(y) return X, y
[文档] def get_calibration_metrics(self) -> Dict[str, Dict[str, float]]: """获取校准前后的指标对比. :return: 包含校准前后指标的字典 """ check_is_fitted(self, "classes_") return self.calib_metrics_
[文档] def calibration_report( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, target: Optional[str] = None, ) -> pd.DataFrame: """生成校准前后指标对比报告. :return: 包含指标、校准前、校准后、改善值和改善率的中文 DataFrame """ check_is_fitted(self, "classes_") X, y = self._prepare_data(X, y, target or self.target_) y_binary = (np.asarray(y) == self.positive_class_).astype(int) original = self.calibrator_.compute_calibration_metrics( y_binary, self._get_model_proba(X), ) calibrated = self.calibrator_.compute_calibration_metrics( y_binary, self.predict_proba(X)[:, _class_index(self.classes_, self.positive_class_)], ) labels = { "brier_score": "Brier分数", "expected_calibration_error": "期望校准误差(ECE)", "max_calibration_error": "最大校准误差(MCE)", } rows = [] for key, label in labels.items(): before = float(original[key]) after = float(calibrated[key]) improvement = before - after rows.append( { "指标": label, "校准前": before, "校准后": after, "改善值": improvement, "改善率": improvement / before if before != 0 else np.nan, } ) return pd.DataFrame(rows)
[文档] def report(self, X, y=None, target: Optional[str] = None) -> pd.DataFrame: """``calibration_report`` 的统一报告入口.""" return self.calibration_report(X, y=y, target=target)
[文档] def plot_reliability_diagram( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, target: Optional[str] = None, figsize: Tuple[int, int] = (10, 8), title: Optional[str] = None, show: bool = True, colors: Optional[List[str]] = None, ) -> "matplotlib.figure.Figure": """绘制可靠性曲线. :param X: 特征矩阵或包含target的DataFrame :param y: 目标变量,可选 :param target: 目标列名(scorecardpipeline风格使用) :param figsize: 图表大小,默认(10, 8) :param title: 图表标题,可选 :param show: 是否显示图表,默认True :return: matplotlib Figure对象 """ check_is_fitted(self, "classes_") # 处理两种传参风格 X, y = self._prepare_data(X, y, target or self.target_) y_binary = (np.asarray(y) == self.positive_class_).astype(int) # 获取原始和校准后的概率 y_prob_orig = self._get_model_proba(X) y_prob_calib = self.predict_proba(X)[:, _class_index(self.classes_, self.positive_class_)] return self.calibrator_.plot_reliability_diagram( y_binary, y_prob_orig, y_prob_calib, figsize=figsize, title=title, show=show, colors=colors )
[文档] def calibrate_proba(self, y_prob: Union[np.ndarray, pd.Series]) -> np.ndarray: """直接校准概率(不通过模型). :param y_prob: 原始概率 :return: 校准后的概率 """ check_is_fitted(self, "classes_") return self.calibrator_.calibrate(y_prob)
[文档] class CalibratedModel(ArtifactSerializableMixin, BaseEstimator, ClassifierMixin): """已校准模型包装器. 将基础模型和校准器组合在一起,提供统一的预测接口。 **参数** :param base_model: 基础模型 :param calibrator: 概率校准器实例 **参考样例** >>> from hscredit.core.models import XGBoost >>> from hscredit.core.models.calibration import ProbabilityCalibrator, CalibratedModel >>> >>> # 训练基础模型 >>> model = XGBoost() >>> model.fit(X_train, y_train) >>> >>> # 创建校准器并拟合 >>> calibrator = ProbabilityCalibrator(method='platt', model=model, calib_ratio=None) >>> calibrator.fit(X_calib, y_calib) >>> >>> # 包装为已校准模型 >>> calibrated_model = CalibratedModel(model, calibrator) >>> proba = calibrated_model.predict_proba(X_test) """ artifact_kind = "概率校准模型" def __init__(self, base_model, calibrator: ProbabilityCalibrator): self.base_model = base_model self.calibrator = calibrator check_is_fitted(calibrator, ["classes_", "positive_class_", "calibrator_"]) self.classes_ = np.asarray(getattr(base_model, "classes_", [])) if self.classes_.shape != (2,): raise ValueError("基础模型必须提供两个类别的classes_") if not np.any(self.classes_ == calibrator.positive_class_): raise ValueError(f"基础模型类别与校准器正类 {calibrator.positive_class_!r} 不兼容") self.positive_class_ = calibrator.positive_class_
[文档] def predict_proba(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray: """预测校准后的正类(坏样本)概率。 将基础模型输出的原始概率经已拟合的校准器映射为更准确的概率。 :param X: 特征矩阵,DataFrame 或 ndarray :return: 两列校准概率数组,shape ``(n_samples, 2)`` """ raw = np.asarray(self.base_model.predict_proba(X)) if raw.ndim != 2 or raw.shape[1] != 2: raise ValueError("基础模型必须返回两列二分类概率") base_classes = np.asarray(getattr(self.base_model, "classes_", self.classes_)) positive_index = _class_index(base_classes, self.positive_class_) positive = np.clip(self.calibrator.calibrate_proba(raw[:, positive_index]), 0.0, 1.0) return _assemble_binary_probabilities(positive, self.classes_, self.positive_class_)
[文档] def predict(self, X: Union[np.ndarray, pd.DataFrame], threshold: float = 0.5) -> np.ndarray: """基于校准后概率预测类别标签。 :param X: 特征矩阵,DataFrame 或 ndarray :param threshold: 判正阈值,校准概率 ``>= threshold`` 记为 1,默认为 ``0.5`` :return: 0/1 类别数组 """ if not 0 <= threshold <= 1: raise ValueError("threshold必须在[0, 1]范围内") positive_index = _class_index(self.classes_, self.positive_class_) positive = self.predict_proba(X)[:, positive_index] return np.where(positive >= threshold, self.positive_class_, self.classes_[1 - positive_index])
[文档] def predict_score(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray: """将校准后概率线性映射为 0–1000 的风险评分(概率越低分越高)。 采用 ``score = (1 - p) * 1000`` 的简易映射;若需标准 log-odds 评分卡刻度, 请改用 :class:`~hscredit.core.models.scorecard.ScoreCard` 或 :class:`~hscredit.core.models.scorecard.score_transformer.StandardScoreTransformer`。 :param X: 特征矩阵,DataFrame 或 ndarray :return: 0–1000 区间的风险评分数组 """ proba = self.predict_proba(X)[:, _class_index(self.classes_, self.positive_class_)] return (1 - proba) * 1000
[文档] def evaluate( self, X: Union[np.ndarray, pd.DataFrame], y: Union[np.ndarray, pd.Series], sample_weight: Optional[np.ndarray] = None, metrics: Optional[List[str]] = None, ) -> Dict[str, float]: """评估校准后模型的区分度与校准度。 :param X: 特征矩阵,DataFrame 或 ndarray :param y: 真实标签(0/1) :param sample_weight: 样本权重;KS 与 Lift 不支持时发出一次中文警告。 :param metrics: 指标名列表;None 时返回 AUC、KS 和 Brier。 :return: 含 ``AUC`` / ``KS`` (区分度)与 ``Brier`` (校准度)的指标字典 **参考样例** >>> calibrated.evaluate(X_test, y_test) {'AUC': ..., 'KS': ..., 'Brier': ...} """ probabilities = self.predict_proba(X) positive_index = _class_index(self.classes_, self.positive_class_) labels = np.asarray(y) if labels.ndim != 1 or len(labels) != len(probabilities): raise ValueError("y必须是一维且与评估样本等长") unknown_labels = set(np.unique(labels)) - set(self.classes_) if unknown_labels: raise ValueError(f"y包含模型未见过的标签: {sorted(unknown_labels, key=str)}") binary_labels = (labels == self.positive_class_).astype(int) binary_predictions = (self.predict(X) == self.positive_class_).astype(int) return _evaluate_binary_predictions( binary_labels, probabilities[:, positive_index], binary_predictions, metrics=["auc", "ks", "brier"] if metrics is None else list(metrics), sample_weight=sample_weight, )
from .plots import plot_calibration_comparison
[文档] def calibrate_model( model, X_calib: Union[np.ndarray, pd.DataFrame], y_calib: Optional[Union[np.ndarray, pd.Series]] = None, method: str = "platt", target: str = "target", **kwargs, ) -> ProbabilityCalibrator: """便捷函数:创建并拟合概率校准器. :param model: 已训练的基础模型 :param X_calib: 校准集特征或包含target的DataFrame :param y_calib: 校准集标签,可选 :param method: 校准方法,默认'platt' :param target: 目标列名(scorecardpipeline风格使用) :param kwargs: 其他参数 :return: 拟合好的ProbabilityCalibrator **参考样例** >>> calibrator = calibrate_model(model, X_calib, y_calib, method='isotonic') >>> proba_calib = calibrator.predict_proba(X_test) """ kwargs.setdefault("calib_ratio", None) calibrator = ProbabilityCalibrator(method=method, model=model, **kwargs) calibrator.fit(X_calib, y_calib, target=target) return calibrator