hscredit.core.models.base 源代码

"""风控模型基类.

提供统一的风控模型接口,支持:
- 统一fit/predict接口(sklearn + scorecardpipeline 双API)
- 统一特征重要性获取
- 统一模型导出/导入(pickle/joblib/json 多格式)
- 模型评估报告(支持多数据集/overdue/dpds)
- 自定义loss和评估目标
- Optuna超参数调优集成

设计原则:
1. 所有风控模型继承BaseRiskModel
2. 统一的API风格,参考sklearn和scorecardpipeline
3. 支持自定义损失函数和评估指标
4. 内置风控常用评估指标(KS、AUC、Gini、PSI等)
"""

from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
import warnings
import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.exceptions import NotFittedError
from sklearn.model_selection import train_test_split

from ..metrics.classification import ks, auc, gini
from ..metrics.finance import lift_monotonicity_check
from ...utils.serialization import ArtifactSerializableMixin
from ...utils.parallel import resolve_n_jobs
from .scorecard_support import _ProbabilityScoreCardMixin

if TYPE_CHECKING:
    import matplotlib
    from ...report import ModelReport
    from .explainability import ModelExplainer


def _lift_score(y_true, y_proba, top_ratio=0.1):
    """计算Lift值(内部辅助函数)."""
    y_true = np.asarray(y_true)
    y_proba = np.asarray(y_proba)
    n = len(y_true)
    if n == 0:
        raise ValueError("计算Lift时标签不能为空")
    if not 0 < top_ratio <= 1:
        raise ValueError("top_ratio必须在(0, 1]范围内")
    n_top = max(1, int(np.ceil(n * top_ratio)))

    # 按概率降序排序
    sorted_indices = np.argsort(-y_proba)
    y_sorted = y_true[sorted_indices]

    # 计算整体坏样本率和top_ratio的坏样本率
    overall_bad_rate = y_true.mean()
    top_bad_rate = y_sorted[:n_top].mean()

    if overall_bad_rate == 0:
        return 1.0

    return top_bad_rate / overall_bad_rate


def _evaluate_binary_predictions(
    y_true,
    y_proba,
    y_pred,
    *,
    metrics,
    sample_weight=None,
) -> Dict[str, float]:
    """按统一模型评估契约计算二值标签指标。"""
    from sklearn.metrics import (
        accuracy_score,
        brier_score_loss,
        f1_score,
        log_loss,
        precision_score,
        recall_score,
        roc_auc_score,
    )

    y_true = np.asarray(y_true)
    y_proba = np.asarray(y_proba, dtype=float)
    y_pred = np.asarray(y_pred)
    if y_true.ndim != 1 or y_proba.ndim != 1 or y_pred.ndim != 1:
        raise ValueError("模型评估标签、概率和预测类别必须是一维数组")
    if not (len(y_true) == len(y_proba) == len(y_pred)) or len(y_true) == 0:
        raise ValueError("模型评估标签、概率和预测类别必须非空且等长")
    if not np.isfinite(y_proba).all() or np.any((y_proba < 0) | (y_proba > 1)):
        raise ValueError("模型评估概率必须是[0, 1]范围内的有限数")
    if not set(np.unique(y_true)).issubset({0, 1}):
        raise ValueError("模型评估内部标签必须是0/1")

    weights = None
    if sample_weight is not None:
        weights = np.asarray(sample_weight, dtype=float)
        if weights.ndim != 1 or len(weights) != len(y_true):
            raise ValueError("sample_weight必须是一维且与评估样本等长")
        if not np.isfinite(weights).all() or np.any(weights < 0) or weights.sum() <= 0:
            raise ValueError("sample_weight必须是有限非负数且总和大于0")

    aliases = {
        "lift": ("LIFT@10%", 0.10),
        "lift@1%": ("LIFT@1%", 0.01),
        "lift_1": ("LIFT@1%", 0.01),
        "lift@3%": ("LIFT@3%", 0.03),
        "lift_3": ("LIFT@3%", 0.03),
        "lift@5%": ("LIFT@5%", 0.05),
        "lift_5": ("LIFT@5%", 0.05),
        "lift@10%": ("LIFT@10%", 0.10),
        "lift_10": ("LIFT@10%", 0.10),
    }
    supported = {
        "auc",
        "ks",
        "gini",
        "logloss",
        "accuracy",
        "brier",
        "precision",
        "recall",
        "f1",
        "lift_monotonicity",
        *aliases,
    }
    normalized = [str(metric).lower() for metric in metrics]
    unknown = [metric for metric, name in zip(metrics, normalized) if name not in supported]
    if unknown:
        raise ValueError(f"不支持的评估指标: {unknown}")

    if weights is not None:
        unsupported = []
        for name in normalized:
            if name == "ks":
                label = "KS"
            elif name in aliases:
                label = aliases[name][0]
            elif name == "lift_monotonicity":
                label = "LIFT单调性"
            else:
                continue
            if label not in unsupported:
                unsupported.append(label)
        if unsupported:
            warnings.warn(
                f"sample_weight 不支持以下指标,已按未加权方式计算: {'、'.join(unsupported)}",
                UserWarning,
                stacklevel=2,
            )

    results = {}
    for name in normalized:
        try:
            if name == "auc":
                results["AUC"] = roc_auc_score(y_true, y_proba, sample_weight=weights)
            elif name == "ks":
                results["KS"] = ks(y_true, y_proba)
            elif name == "gini":
                results["Gini"] = 2 * roc_auc_score(y_true, y_proba, sample_weight=weights) - 1
            elif name in aliases:
                key, ratio = aliases[name]
                results[key] = _lift_score(y_true, y_proba, top_ratio=ratio)
            elif name == "logloss":
                results["LogLoss"] = log_loss(y_true, y_proba, sample_weight=weights, labels=[0, 1])
            elif name == "accuracy":
                results["Accuracy"] = accuracy_score(y_true, y_pred, sample_weight=weights)
            elif name == "brier":
                results["Brier"] = brier_score_loss(y_true, y_proba, sample_weight=weights)
            elif name == "precision":
                results["Precision"] = precision_score(y_true, y_pred, sample_weight=weights, zero_division=0)
            elif name == "recall":
                results["Recall"] = recall_score(y_true, y_pred, sample_weight=weights, zero_division=0)
            elif name == "f1":
                results["F1"] = f1_score(y_true, y_pred, sample_weight=weights, zero_division=0)
            elif name == "lift_monotonicity":
                monotonicity = lift_monotonicity_check(y_true, y_proba, n_bins=10, direction="both")
                results["头部LIFT单调"] = monotonicity["head_monotonic"]
                results["头部违反单调比例"] = monotonicity["head_violation_ratio"]
                results["尾部LIFT单调"] = monotonicity["tail_monotonic"]
        except Exception as exc:
            raise ValueError(f"计算指标 {name} 时出错: {exc}") from exc
    return results


def resolve_custom_objective(objective):
    """将自定义损失对象解析为各 boosting 框架 sklearn 包装器可用的目标函数.

    统一自定义 LOSS 入口:当用户直接传入 :class:`~hscredit.core.models.losses.BaseLoss`
    实例作为 ``objective``(或 CatBoost 的 ``loss_function``)时,自动转换为
    XGBoost/LightGBM sklearn 包装器所需的 ``(y_true, y_pred) -> (grad, hess)``
    可调用对象,并在内部完成 sigmoid 链接函数转换(``BaseLoss.gradient`` 的梯度
    定义在概率 p 上,而 boosting 框架回调传入的是原始分数 raw margin)。

    非 ``BaseLoss`` 对象(如内置字符串 'binary'、用户自行编写的可调用对象)原样返回。

    :param objective: 目标函数,可为字符串、可调用对象或 BaseLoss 实例
    :return: 解析后的目标函数(字符串/可调用对象)
    """
    try:
        from .losses.base import BaseLoss, _margin_derivatives
    except Exception:
        return objective

    if not isinstance(objective, BaseLoss):
        return objective

    loss = objective

    def _sklearn_obj(y_true: np.ndarray, y_pred: np.ndarray):
        # boosting 框架回调传入原始分数,先 sigmoid 转概率再求梯度
        prob = 1.0 / (1.0 + np.exp(-np.asarray(y_pred, dtype=float)))
        return _margin_derivatives(loss, y_true, prob)

    return _sklearn_obj


[文档] class BaseRiskModel(_ProbabilityScoreCardMixin, ArtifactSerializableMixin, BaseEstimator, ClassifierMixin, ABC): """风控模型基类. 所有风控模型的抽象基类,定义统一接口。 继承sklearn的BaseEstimator和ClassifierMixin。 支持scorecardpipeline风格的fit(可在init中指定target列)。 **参数** :param objective: 目标函数,可选: - 'binary': 二分类(默认) - 'binary:logistic': 二分类逻辑回归 - 'regression': 回归 - 自定义可调用对象 :param eval_metric: 评估指标,可选列表或单个指标: - 'auc': AUC - 'ks': KS统计量 - 'gini': Gini系数 - 'lift': Lift值 - 'logloss': 对数损失 - 自定义可调用对象 :param target: 目标列名,默认None - 如果指定,fit时只需传入X,会自动从X中提取target列作为y - 支持scorecardpipeline风格的有监督fit :param early_stopping_rounds: 早停轮数,默认None :param validation_fraction: 验证集比例,默认0.2 :param random_state: 随机种子,默认None :param n_jobs: 并行任务数,默认-1 :param verbose: 是否输出详细信息,默认False :param scorecard_params: 概率评分卡参数,可传部分配置覆盖默认值;默认使用 PDO=50、基准分=600、坏好比由训练标签计算、分数范围0-1000且分越高风险越低 :param kwargs: 模型特定参数 **属性** :ivar classes_: 类别标签 :ivar n_features_in_: 特征数量 :ivar feature_names_in_: 特征名称 :ivar feature_importances_: 特征重要性 :ivar evals_result_: 训练过程评估结果 :ivar best_iteration_: 最佳迭代次数 :ivar best_score_: 最佳得分 :ivar tuner: 最近一次通过 :meth:`tune` 创建的 ModelTuner,未调参时为 None :ivar bad_rate_: 训练集坏样本率 :ivar base_odds_: 训练集坏好比 :ivar scorecard_: 已拟合的概率评分卡 """ artifact_kind = "风险模型" # 支持的评估指标 SUPPORTED_METRICS = [ "auc", "ks", "gini", "lift", "lift@1%", "lift@3%", "lift@5%", "lift@10%", "logloss", "accuracy", "brier", "precision", "recall", "f1", "lift_monotonicity", ] # 默认评估指标(evaluate() 不传 metrics 时使用) DEFAULT_METRICS = ["auc", "ks", "gini", "lift@1%", "lift@3%", "lift@5%", "lift@10%"] def __init__( self, objective: Union[str, Callable] = "binary", eval_metric: Union[str, List[str], Callable, None] = None, target: Optional[str] = None, early_stopping_rounds: Optional[int] = None, validation_fraction: float = 0.2, random_state: Optional[int] = None, n_jobs: int = -1, verbose: bool = False, scorecard_params: Optional[Dict[str, Any]] = None, **kwargs, ): self.objective = objective self.eval_metric = eval_metric self.target = target self.early_stopping_rounds = early_stopping_rounds self.validation_fraction = validation_fraction self.random_state = random_state self.n_jobs = resolve_n_jobs(n_jobs) self.verbose = verbose self.kwargs = kwargs self._initialize_scorecard_params(scorecard_params) # 内部属性 self._model = None self._evals_result = {} self._best_iteration = None self._best_score = None self._feature_importances = None self._is_fitted = False self.tuner = None
[文档] @abstractmethod def fit( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, sample_weight: Optional[np.ndarray] = None, eval_set: Optional[List[Tuple]] = None, **fit_params, ) -> "BaseRiskModel": """训练模型. 支持两种调用方式: 1. 常规方式: fit(X, y) 2. scorecardpipeline风格: 在__init__中指定target,然后fit(X) :param X: 特征矩阵,支持numpy数组或pandas DataFrame :param y: 目标变量,可选。如果未提供且init中指定了target,则从X中提取 :param sample_weight: 样本权重,可选 :param eval_set: 验证集列表 [(X_val1, y_val1), ...],可选 :param fit_params: 其他fit参数 :return: self """ pass
[文档] @abstractmethod def predict(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray: """预测类别标签. :param X: 特征矩阵 :return: 预测类别 """ pass
[文档] @abstractmethod def predict_proba(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray: """预测概率. :param X: 特征矩阵 :return: 预测概率,形状 (n_samples, n_classes) """ pass
[文档] def predict_score(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray: """使用训练坏好比对应的标准概率评分卡预测风险评分. :param X: 特征矩阵 :return: 风险评分 (0-1000) """ return self._predict_probability_score(X)
@property def best_iteration_(self): """最佳迭代次数(早停后),未启用早停时为 None. 统一暴露给所有子类(XGBoost/LightGBM/CatBoost/NGBoost/sklearn 集成)。 """ return self._best_iteration @property def best_score_(self): """最佳得分(早停验证集上),未启用早停时为 None.""" return self._best_score @property def evals_result_(self) -> Dict[str, Any]: """返回训练期间记录的验证集指标。""" return self._evals_result
[文档] @abstractmethod def get_feature_importances(self, importance_type: str = "gain") -> pd.Series: """获取特征重要性. :param importance_type: 重要性类型,可选: - 'gain': 增益 (默认) - 'split': 分裂次数 - 'weight': 权重 - 'cover': 覆盖度 :return: 特征重要性Series """ pass
[文档] def get_model_info(self) -> Dict[str, Any]: """获取模型信息. :return: 包含模型信息的字典 """ self._require_fitted() info = { "model_type": self.__class__.__name__, "objective": self.objective, "eval_metric": self.eval_metric, "n_features": self.n_features_in_, "n_classes": len(self.classes_), "best_iteration": self._best_iteration, "best_score": self._best_score, "params": self.get_params(), } # 添加特征重要性统计 if self._feature_importances is not None: importances = self._feature_importances info["feature_importance_stats"] = { "top_feature": importances.index[0] if len(importances) > 0 else None, "top_importance": importances.iloc[0] if len(importances) > 0 else None, "mean_importance": importances.mean(), "std_importance": importances.std(), } return info
[文档] 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, positive_class: Optional[Any] = None, ) -> Dict[str, float]: """评估模型性能. :param X: 特征矩阵 :param y: 真实标签 :param sample_weight: 样本权重 :param metrics: 评估指标列表,默认全部 :param positive_class: 显式正类标签;None 时使用 ``classes_[1]`` :return: 评估结果字典 """ self._require_fitted() requested_metrics = list(self.DEFAULT_METRICS if metrics is None else metrics) probabilities = np.asarray(self.predict_proba(X), dtype=float) classes = np.asarray(getattr(self, "classes_", [])) if classes.shape != (2,) or probabilities.ndim != 2 or probabilities.shape[1] != 2: raise ValueError("模型评估目前仅支持提供两列概率和两个classes_的二分类模型") resolved_positive = classes[1] if positive_class is None else positive_class matches = np.flatnonzero(classes == resolved_positive) if len(matches) != 1: raise ValueError(f"positive_class={resolved_positive!r} 不在模型类别 {classes.tolist()!r} 中") labels = np.asarray(y) if labels.ndim != 1 or len(labels) != len(probabilities): raise ValueError("y必须是一维且与评估样本等长") unknown_labels = set(np.unique(labels)) - set(classes) if unknown_labels: raise ValueError(f"y包含模型未见过的标签: {sorted(unknown_labels, key=str)}") binary_labels = (labels == resolved_positive).astype(int) predicted_labels = np.asarray(self.predict(X)) binary_predictions = (predicted_labels == resolved_positive).astype(int) return _evaluate_binary_predictions( binary_labels, probabilities[:, int(matches[0])], binary_predictions, metrics=requested_metrics, sample_weight=sample_weight, )
[文档] def generate_report( self, X_train: Union[np.ndarray, pd.DataFrame], y_train: Union[np.ndarray, pd.Series], X_test: Optional[Union[np.ndarray, pd.DataFrame]] = None, y_test: Optional[Union[np.ndarray, pd.Series]] = None, feature_names: Optional[List[str]] = None, ) -> "ModelReport": """生成模型评估报告. 模型报告已统一由 :class:`hscredit.report.ModelReport` 生成,本方法为其 兼容入口,等价于直接构造 ``ModelReport``;如需多 Sheet Excel / 多标签等 完整能力,推荐使用 :meth:`report`。 :param X_train: 训练集特征 :param y_train: 训练集标签 :param X_test: 测试集特征,可选 :param y_test: 测试集标签,可选 :param feature_names: 特征名称列表,可选 :return: ModelReport 对象 """ from ...report import ModelReport return ModelReport( model=self, X_train=X_train, y_train=y_train, X_test=X_test, y_test=y_test, feature_names=feature_names )
[文档] def report( self, datasets: Optional[Union[List, Dict]] = None, X_train=None, y_train=None, X_test=None, y_test=None, feature_names: Optional[List[str]] = None, target: Optional[Union[str, Dict]] = None, overdue: Optional[Union[str, List[str]]] = None, dpds: Optional[Union[int, float, List[Union[int, float]]]] = None, excel_path: Optional[str] = None, verbose: bool = True, n_bins: int = 10, amount_col: Optional[str] = None, date_col: Optional[str] = None, group_col: Optional[str] = None, **kwargs, ) -> "ModelReport": """生成风控建模报告(支持多数据集/overdue/dpds). 委托给 hscredit.report.auto_model_report,生成包含多 Sheet 的 Excel / 控制台报告。 支持三种调用方式: 1. datasets API(推荐):: model.report(datasets={'训练集': train_df, '测试集': test_df}) model.report(datasets=[train_df, test_df]) 2. sklearn 风格:: model.report(X_train=X, y_train=y, X_test=X_val, y_test=y_val) 3. overdue/dpds 自动构建标签:: model.report(datasets={'训练集': df}, overdue='dpds', dpds=[15, 7, 0]) :param datasets: 数据集字典/列表 :param X_train: 训练集特征(兼容旧API) :param y_train: 训练集标签(兼容旧API) :param X_test: 测试集特征(兼容旧API) :param y_test: 测试集标签(兼容旧API) :param feature_names: 特征名称列表 :param target: 目标列配置 :param overdue: 逾期列名 :param dpds: 逾期天数阈值 :param excel_path: Excel 报告输出路径 :param verbose: 是否打印控制台报告 :param n_bins: 分箱数 :param amount_col: 金额字段 :param date_col: 日期字段 :param group_col: 分组字段 :param kwargs: 传递给 auto_model_report 的其他参数 :return: ModelReport 实例 """ self._require_fitted() from ...report import auto_model_report return auto_model_report( model=self, datasets=datasets, X_train=X_train, y_train=y_train, X_test=X_test, y_test=y_test, feature_names=feature_names, target=target, overdue=overdue, dpds=dpds, excel_path=excel_path, verbose=verbose, n_bins=n_bins, amount_col=amount_col, date_col=date_col, group_col=group_col, **kwargs, )
# ==================== 模型导出/导入 ====================
[文档] def save(self, path: str, engine: str = "auto", **kwargs) -> str: """保存模型到文件. 支持多种格式: - pickle/joblib: 保存完整模型对象(默认) - json: 保存模型参数和元数据(仅限支持的框架) :param path: 保存路径,支持 .pkl, .joblib, .pkl.gz, .json 等后缀 :param engine: 序列化引擎,可选 'auto', 'joblib', 'pickle', 'dill', 'cloudpickle' :param kwargs: 传递给 save_pickle 的其他参数(如 compression, compression_level) :return: 保存路径 **参考样例** >>> model.save('model.pkl') >>> model.save('model.joblib') >>> model.save('model.pkl.gz') >>> model.save('model.pkl', engine='dill') """ self._require_fitted() from ...utils import save_pickle path_str = str(path) if path_str.endswith(".json"): self._save_json(path_str) else: eng = engine if eng == "auto": path_lower = path_str.lower() if path_lower.endswith(".dill") or path_lower.endswith(".dill.gz"): eng = "dill" elif path_lower.endswith(".cloudpickle"): eng = "cloudpickle" else: eng = "joblib" save_pickle(self, path_str, engine=eng, **kwargs) return path_str
[文档] @classmethod def load(cls, path: str, engine: str = "auto", **kwargs) -> "BaseRiskModel": """从文件加载模型. :param path: 模型文件路径 :param engine: 序列化引擎,可选 'auto', 'joblib', 'pickle', 'dill', 'cloudpickle' :param kwargs: 传递给 load_pickle 的其他参数 :return: 加载的模型实例 **参考样例** >>> model = XGBoost.load('model.pkl') >>> model = LightGBM.load('model.joblib') >>> model = BaseRiskModel.load('model.pkl') # 自动推断模型类型 """ from ...utils import load_pickle path_str = str(path) if path_str.endswith(".json"): return cls._load_json(path_str) model = load_pickle(path_str, engine=engine, **kwargs) if not isinstance(model, BaseRiskModel): raise TypeError(f"加载的对象类型为 {type(model).__name__},不是 BaseRiskModel 子类") return model
def _save_json(self, path: str): """保存模型参数和元数据为JSON.""" import json if self._model is None or not hasattr(self._model, "save_model"): raise ValueError(f"{self.__class__.__name__}不支持完整的JSON模型序列化,请使用joblib或pickle格式") meta = { "model_class": f"{self.__class__.__module__}.{self.__class__.__name__}", "model_type": self.__class__.__name__, "params": {}, "n_features_in_": getattr(self, "n_features_in_", None), "feature_names_in_": getattr(self, "feature_names_in_", None), "classes_": getattr(self, "classes_", np.array([])).tolist(), "probability_scorecard": self._probability_scorecard_state(), } params = self.get_params(deep=False) for k, v in params.items(): if isinstance(v, (int, float, str, bool, type(None))): meta["params"][k] = v elif isinstance(v, np.integer): meta["params"][k] = int(v) elif isinstance(v, np.floating): meta["params"][k] = float(v) elif isinstance(v, (list, tuple)): meta["params"][k] = list(v) # 保存底层模型到同级目录 native_path = str(Path(path).with_suffix(".native")) self._model.save_model(native_path) meta["native_model_path"] = str(Path(native_path).name) transformer_path = self._save_score_transformer_sidecar(native_path) meta["score_transformer_path"] = str(Path(transformer_path).name) with open(path, "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) @classmethod def _load_json(cls, path: str) -> "BaseRiskModel": """从JSON加载模型参数(需配合native模型文件).""" import json import importlib with open(path, "r", encoding="utf-8") as f: meta = json.load(f) module_path, class_name = meta["model_class"].rsplit(".", 1) module = importlib.import_module(module_path) model_cls = getattr(module, class_name) model = model_cls(**meta.get("params", {})) if "native_model_path" not in meta: raise ValueError("JSON模型元数据缺少原生模型文件,无法恢复已训练模型") native_path = str(Path(path).parent / meta["native_model_path"]) if not Path(native_path).exists(): raise ValueError(f"JSON模型引用的原生模型文件不存在: {native_path}") if not hasattr(model, "load_model"): raise ValueError(f"{model.__class__.__name__}不支持从原生模型文件恢复") model.load_model(native_path) model.n_features_in_ = meta.get("n_features_in_") model.feature_names_in_ = meta.get("feature_names_in_") model.classes_ = np.array(meta.get("classes_", [0, 1])) transformer_path = meta.get("score_transformer_path") if transformer_path: native_transformer_base = str(Path(path).parent / transformer_path) suffix = ".score_transformer.joblib" if not native_transformer_base.endswith(suffix): raise ValueError("JSON模型元数据中的评分转换器路径无效") model._load_score_transformer_sidecar(native_transformer_base[: -len(suffix)], required=True) else: model._restore_probability_scorecard(meta.get("probability_scorecard")) return model # ==================== 超参数调优集成 ====================
[文档] def tune( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, search_space: Optional[Dict[str, Dict[str, Any]]] = None, fixed_params: Optional[Dict[str, Any]] = None, metric: Union[str, Callable, List] = "ks", direction: Union[str, List[str]] = "maximize", n_trials: int = 100, cv: int = 5, timeout: Optional[int] = None, verbose: Optional[bool] = None, **kwargs, ) -> "BaseRiskModel": """超参数调优并返回最佳模型. 集成 ModelTuner,一键完成超参数搜索、最佳模型训练。 :param X: 特征矩阵或包含target的DataFrame :param y: 目标变量,可选 :param search_space: 参数搜索空间,默认使用自适应空间 :param fixed_params: 固定参数 :param metric: 优化指标 :param direction: 优化方向 :param n_trials: 搜索次数 :param cv: 交叉验证折数 :param timeout: 超时时间(秒) :param verbose: 是否输出详细信息 :param kwargs: 其他传递给 ModelTuner 的参数 :return: 使用最佳参数训练好的模型实例 **参考样例** >>> model = XGBoost() >>> best_model = model.tune(X_train, y_train, n_trials=50) >>> proba = best_model.predict_proba(X_test) >>> # scorecardpipeline风格 >>> model = LightGBM(target='target') >>> best_model = model.tune(df, n_trials=50) """ from .tuning import ModelTuner if verbose is None: verbose = self.verbose effective_fixed_params = dict(fixed_params or {}) effective_fixed_params.setdefault("scorecard_params", self.scorecard_params) tuner = ModelTuner( model_class=self.__class__, search_space=search_space, fixed_params=effective_fixed_params, metric=metric, direction=direction, target=self.target or "target", cv=cv, random_state=self.random_state, verbose=verbose, **kwargs, ) self.tuner = tuner tuner.fit(X, y, n_trials=n_trials, timeout=timeout) best_model = tuner.get_best_model() best_model.tuner = tuner return best_model
def _prepare_data( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, sample_weight: Optional[np.ndarray] = None, extract_target: bool = False, training: bool = False, ) -> Tuple[np.ndarray, Optional[np.ndarray], Optional[np.ndarray]]: """准备数据. 支持从X中提取target列(scorecardpipeline风格)。 :param X: 特征矩阵 :param y: 目标变量 :param sample_weight: 样本权重 :param extract_target: 是否从X中提取target列 :param training: 是否为拟合阶段;仅拟合阶段记录输入字段契约 :return: 处理后的X, y, sample_weight """ # 处理DataFrame if isinstance(X, pd.DataFrame): # scorecardpipeline风格:从X中提取target列 if extract_target and self.target is not None and self.target in X.columns: if y is None: y = X[self.target].values X = X.drop(columns=[self.target]) if training or not hasattr(self, "feature_names_in_"): self.feature_names_in_ = X.columns.tolist() else: expected = list(self.feature_names_in_) missing = [column for column in expected if column not in X.columns] if missing: raise ValueError(f"输入数据缺少训练字段: {missing}") # 只使用训练字段并恢复训练顺序;额外业务字段按约定忽略。 X = X.loc[:, expected] X = X.values else: X = np.asarray(X) if X.ndim != 2: raise ValueError(f"输入特征必须是二维数组,当前维度为{X.ndim}") if training or not hasattr(self, "feature_names_in_"): self.feature_names_in_ = [f"feature_{i}" for i in range(X.shape[1])] elif hasattr(self, "n_features_in_") and X.shape[1] != self.n_features_in_: raise ValueError(f"输入特征数量不匹配:训练时为{self.n_features_in_},当前为{X.shape[1]}") # 处理y if y is not None: if isinstance(y, pd.Series): y = y.values # 处理样本权重 if sample_weight is not None: if isinstance(sample_weight, pd.Series): sample_weight = sample_weight.values return X, y, sample_weight def _create_eval_set( self, X: np.ndarray, y: np.ndarray, sample_weight: Optional[np.ndarray] = None ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray]]: """创建验证集. :param X: 特征矩阵 :param y: 目标变量 :param sample_weight: 样本权重 :return: X_train, X_val, y_train, y_val, sw_train, sw_val """ if 0 < self.validation_fraction < 1: indices = np.arange(len(y)) train_indices, val_indices = train_test_split( indices, test_size=self.validation_fraction, random_state=self.random_state, stratify=y, ) self._eval_train_indices_ = np.asarray(train_indices) self._eval_val_indices_ = np.asarray(val_indices) return ( self._take_rows(X, train_indices), self._take_rows(X, val_indices), self._take_rows(y, train_indices), self._take_rows(y, val_indices), self._take_rows(sample_weight, train_indices), self._take_rows(sample_weight, val_indices), ) self._eval_train_indices_ = np.arange(len(y)) self._eval_val_indices_ = np.asarray([], dtype=int) return X, None, y, None, sample_weight, None @staticmethod def _take_rows(values, indices): """按位置选取与样本逐行对齐的数据。""" if values is None: return None if hasattr(values, "iloc"): return values.iloc[indices] return np.asarray(values)[indices] def _split_row_aligned_value(self, values): """使用最近一次自动验证集索引切分样本级参数。""" if values is None: return None, None train_indices = getattr(self, "_eval_train_indices_", None) val_indices = getattr(self, "_eval_val_indices_", None) if train_indices is None or val_indices is None or len(val_indices) == 0: return values, None try: value_length = len(values) except TypeError: return values, None if value_length != len(train_indices) + len(val_indices): return values, None return self._take_rows(values, train_indices), self._take_rows(values, val_indices) def _split_row_aligned_fit_param(self, fit_kwargs, train_name: str, eval_name: str) -> None: """切分一个已知的样本级 fit 参数,并补充对应验证集参数。""" if train_name not in fit_kwargs: return train_values, val_values = self._split_row_aligned_value(fit_kwargs[train_name]) fit_kwargs[train_name] = train_values if val_values is not None: fit_kwargs.setdefault(eval_name, [val_values])
[文档] def get_native_model(self) -> Any: """获取底层原生模型对象. 用于需要访问底层模型特定功能的场景,如: - 获取叶子节点索引 - 绘制树结构 - 访问底层模型特有的方法 :return: 底层模型对象(如xgboost.Booster、lgb.Booster等) **参考样例** >>> model = XGBoost() >>> model.fit(X, y) >>> native_model = model.get_native_model() >>> leaf_indices = native_model.apply(X) """ self._require_fitted() return self._model
def _get_metric_func(self, metric: str) -> Callable: """获取评估指标函数. :param metric: 指标名称 :return: 评估函数 """ metric_map = { "auc": lambda y, p: auc(y, p), "ks": lambda y, p: ks(y, p), "gini": lambda y, p: gini(y, p), } return metric_map.get(metric.lower()) def __sklearn_is_fitted__(self): """用于sklearn的check_is_fitted检查.""" return hasattr(self, "_is_fitted") and self._is_fitted def _require_fitted(self) -> None: """按布尔训练状态校验模型,避免仅检查属性存在性。""" if not getattr(self, "_is_fitted", False): raise NotFittedError(f"该{self.__class__.__name__}实例尚未拟合,请先调用fit方法")
[文档] def plot_feature_importance( self, X: Optional[Union[np.ndarray, pd.DataFrame]] = None, y: Optional[Union[np.ndarray, pd.Series]] = None, top_n: int = 20, importance_type: str = "gain", method: str = "traditional", figsize: Tuple[int, int] = (10, 8), title: Optional[str] = None, show: bool = True, **kwargs, ) -> "matplotlib.figure.Figure": """绘制特征重要性图. 支持传统特征重要性和SHAP值两种方法。 :param X: 特征矩阵,SHAP方法必需 :param y: 目标变量,可选 :param top_n: 显示前N个特征,默认20 :param importance_type: 重要性类型(传统方法),默认'gain' :param method: 计算方法,默认'traditional' - 'traditional': 传统特征重要性 - 'shap': SHAP值重要性 - 'combined': 两者对比 :param figsize: 图表大小,默认(10, 8) :param title: 图表标题,可选 :param show: 是否显示图表,默认True :param kwargs: 其他绘图参数 :return: matplotlib Figure对象 **参考样例** >>> # 传统特征重要性 >>> fig = model.plot_feature_importance(top_n=15) >>> fig.savefig('importance.png') >>> # SHAP特征重要性 >>> fig = model.plot_feature_importance(X_test, method='shap', top_n=15) >>> # 组合对比图 >>> fig = model.plot_feature_importance(X_test, method='combined', top_n=10) """ from .explainability import ( plot_feature_importance, plot_shap_importance, plot_importance_comparison, ) if method == "traditional": return plot_feature_importance( self, X=X, top_n=top_n, importance_type=importance_type, figsize=figsize, title=title, show=show, **kwargs, ) elif method == "shap": if X is None: raise ValueError("SHAP方法需要提供X参数") return plot_shap_importance(self, X, top_n=top_n, figsize=figsize, title=title, show=show) elif method == "combined": if X is None: raise ValueError("组合方法需要提供X参数") return plot_importance_comparison( self, X, top_n=top_n, importance_type=importance_type, figsize=figsize, title=title, show=show ) else: raise ValueError(f"不支持的method: {method},可选: 'traditional', 'shap', 'combined'")
[文档] def get_shap_explainer(self, **kwargs) -> "ModelExplainer": """获取SHAP解释器. :param kwargs: ModelExplainer的初始化参数 :return: ModelExplainer对象 **参考样例** >>> explainer = model.get_shap_explainer() >>> shap_values = explainer.compute_shap_values(X_test) >>> explainer.plot_shap_summary(X_test) """ from .explainability import ModelExplainer return ModelExplainer(self, **kwargs)