hscredit.core.models.classical.sklearn_models 源代码

"""基于sklearn的风控模型.

提供 RandomForest、ExtraTrees、GradientBoosting、SVM 和 DecisionTreeClassifier 等模型的统一封装。

**参考样例**

>>> from hscredit.core.models import RandomForest, GradientBoosting
>>> model = RandomForest(n_estimators=100, max_depth=10)  # 随机森林模型
>>> model.fit(X_train, y_train)  # 训练模型
>>> proba = model.predict_proba(X_test)  # 预测概率
"""

import inspect
from typing import Any, Dict, List, Optional, Tuple, Union

import numpy as np
import pandas as pd
from sklearn.ensemble import (
    RandomForestClassifier,
    ExtraTreesClassifier,
    GradientBoostingClassifier,
)
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier as SklearnDecisionTreeClassifier

from ....exceptions import ValidationError
from ..base import BaseRiskModel


class SklearnRiskModel(BaseRiskModel):
    """基于 sklearn 集成分类器的风控模型基类。

    将任意 sklearn 分类器(通过 ``estimator_class`` 传入)封装为 hscredit 统一接口,
    继承 :class:`~hscredit.core.models.base.BaseRiskModel`,提供 ``fit`` /
    ``predict`` / ``predict_proba`` / ``get_feature_importances`` / ``evaluate`` /
    ``save_model`` / ``load_model`` 及 scorecardpipeline 风格(``fit(df)`` 自动提取
    ``target`` 列)。具体模型由子类 :class:`RandomForest` /
    :class:`ExtraTrees` / :class:`GradientBoosting` 指定。

    **参数**

    :param estimator_class: sklearn 分类器类(如 ``RandomForestClassifier``),由子类传入
    :param objective: 任务类型,默认 ``'binary'``(二分类)
    :param eval_metric: 评估指标名或列表,默认 ``None``
    :param validation_fraction: 验证集占比,默认 ``0.2``
    :param random_state: 随机种子,默认 ``None``
    :param n_jobs: 并行任务数,默认 ``-1``(用满 CPU;``GradientBoosting`` 不支持,自动忽略)
    :param verbose: 是否输出训练日志,默认 ``False``
    :param scorecard_params: 概率评分卡部分覆盖参数,默认 PDO=50、基准分=600、范围0-1000
    :param kwargs: 透传给底层 sklearn 分类器的其他超参数

    **属性**

    - ``feature_importances_``: 特征重要性数组(兼容 sklearn)
    - ``feature_names_in_`` / ``n_features_in_``: 输入特征名/数量
    - ``classes_``: 类别标签

    **参考样例**

    >>> from hscredit.core.models import RandomForest
    >>> model = RandomForest(n_estimators=200, max_depth=8)
    >>> model.fit(X_train, y_train)
    >>> proba = model.predict_proba(X_test)[:, 1]
    >>> model.get_feature_importances().head()

    **引用**

    底层实现见 sklearn ``ensemble`` 模块:
    https://scikit-learn.org/stable/modules/ensemble.html
    """

    def __init__(
        self,
        estimator_class,
        objective: str = "binary",
        eval_metric: Union[str, List[str], None] = 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,
    ):
        super().__init__(
            objective=objective,
            eval_metric=eval_metric,
            early_stopping_rounds=None,  # sklearn不支持早停
            validation_fraction=validation_fraction,
            random_state=random_state,
            n_jobs=n_jobs,
            verbose=verbose,
            scorecard_params=scorecard_params,
            **kwargs,
        )
        self._estimator_class = estimator_class

    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,
    ) -> "SklearnRiskModel":
        """训练模型.

        支持两种调用方式:
        1. 常规方式: fit(X, y)
        2. scorecardpipeline风格: fit(X) 在init中指定target
        """
        # 准备数据(支持从X中提取target)
        X, y, sample_weight = self._prepare_data(X, y, sample_weight, extract_target=True, training=True)
        self._validate_probability_scorecard_labels(y)

        # 保存特征信息
        self.n_features_in_ = X.shape[1]
        # _prepare_data 已在内部设置 feature_names_in_(DataFrame 或人工命名)
        self.classes_ = np.unique(y)

        # 只向底层模型传递其真实支持的统一参数,避免 SVC/决策树收到 n_jobs 等未知参数。
        params = self.kwargs.copy()
        supported = inspect.signature(self._estimator_class).parameters
        public_params = self.get_params(deep=False)
        for name in supported:
            if name in public_params:
                params[name] = public_params[name]

        # 创建模型
        self._model = self._estimator_class(**params)

        # 训练
        if sample_weight is not None:
            self._model.fit(X, y, sample_weight=sample_weight)
        else:
            self._model.fit(X, y)

        # 底层模型已经完成拟合;先提交状态,确保 eval_set 走统一评估入口时
        # 能通过严格的布尔训练状态检查。
        self._is_fitted = True
        self._fit_probability_scorecard(X, y)

        # 保存评估结果
        self._evals_result = {}
        if eval_set:
            for i, (X_val, y_val) in enumerate(eval_set):
                scores = self.evaluate(X_val, y_val)
                self._evals_result[f"validation_{i}"] = scores

        return self

    def predict(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray:
        """预测类别标签。

        :param X: 特征矩阵,DataFrame 或 ndarray
        :return: 预测类别数组(0/1)
        :raises NotFittedError: 模型尚未训练时
        """
        self._require_fitted()
        X = self._prepare_data(X)[0]
        return self._model.predict(X)

    def predict_proba(self, X: Union[np.ndarray, pd.DataFrame]) -> np.ndarray:
        """预测各类别概率。

        :param X: 特征矩阵,DataFrame 或 ndarray
        :return: 概率数组,shape ``(n_samples, 2)``,第 1 列为正类(坏样本)概率
        :raises NotFittedError: 模型尚未训练时
        """
        self._require_fitted()
        X = self._prepare_data(X)[0]
        return self._model.predict_proba(X)

    def get_feature_importances(self, importance_type: str = "gain") -> pd.Series:
        """获取特征重要性(基于底层模型的不纯度下降)。

        :param importance_type: 重要性类型,默认 ``'gain'``(sklearn 树模型仅支持基于
            不纯度的 ``feature_importances_``,该参数为与 boosting 模型接口对齐而保留)
        :return: 以特征名为索引、按重要性降序的 Series
        :raises NotFittedError: 模型尚未训练时
        """
        self._require_fitted()

        importances = self._native_feature_importances()

        # 创建Series
        importance_series = pd.Series(importances, index=self.feature_names_in_, name="importance").sort_values(
            ascending=False
        )

        self._feature_importances = importance_series

        return importance_series

    @property
    def feature_importances_(self) -> np.ndarray:
        """特征重要性属性 (兼容sklearn风格).

        直接在包装类上暴露重要性,兼容sklearn RFE/SFS等组件的 importance_getter。
        """
        self._require_fitted()
        return self._native_feature_importances()

    def _native_feature_importances(self) -> np.ndarray:
        """返回底层模型真实提供的重要性或线性系数绝对值。"""
        if hasattr(self._model, "feature_importances_"):
            return np.asarray(self._model.feature_importances_, dtype=float)
        if hasattr(self._model, "coef_"):
            coefficient = self._model.coef_
            if hasattr(coefficient, "toarray"):
                coefficient = coefficient.toarray()
            coefficient = np.abs(np.asarray(coefficient, dtype=float))
            return coefficient if coefficient.ndim == 1 else coefficient.mean(axis=0)
        raise ValidationError(
            "当前非线性模型没有原生逐字段特征重要性,请使用 permutation importance 或模型解释工具"
        )

    def save_model(self, path: str):
        """保存底层sklearn模型(pickle格式).

        :param path: 保存路径
        """
        from ....utils import save_pickle

        self._require_fitted()
        save_pickle(self._model, path)
        self._save_score_transformer_sidecar(path)

    def load_model(self, path: str) -> "SklearnRiskModel":
        """加载底层sklearn模型(pickle格式).

        :param path: 模型路径
        :return: self
        """
        from ....utils import load_pickle

        self._model = load_pickle(path)
        self._is_fitted = True
        self.classes_ = getattr(self._model, "classes_", np.array([0, 1]))
        if hasattr(self._model, "n_features_in_"):
            self.n_features_in_ = self._model.n_features_in_
        if not hasattr(self, "feature_names_in_"):
            n_feat = getattr(self, "n_features_in_", 0)
            self.feature_names_in_ = [f"feature_{i}" for i in range(n_feat)]
        self._load_score_transformer_sidecar(path)
        return self


[文档] class RandomForest(SklearnRiskModel): """随机森林风控模型. 基于sklearn的RandomForestClassifier封装。 **参数** :param n_estimators: 树的数量,默认100 :param max_depth: 树最大深度,默认None :param min_samples_split: 节点分裂最小样本数,默认2 :param min_samples_leaf: 叶子节点最小样本数,默认1 :param max_features: 最大特征数,默认'sqrt' :param bootstrap: 是否使用自助采样,默认True :param class_weight: 类别权重,默认None :param criterion: 分裂标准,默认'gini' :param random_state: 随机种子,默认None :param n_jobs: 并行任务数,默认-1 :param verbose: 是否输出详细信息,默认False """ def __init__( self, n_estimators: int = 100, max_depth: Optional[int] = None, min_samples_split: Union[int, float] = 2, min_samples_leaf: Union[int, float] = 1, max_features: Union[str, int, float] = "sqrt", bootstrap: bool = True, class_weight: Optional[Union[str, Dict]] = None, criterion: str = "gini", random_state: Optional[int] = None, n_jobs: int = -1, verbose: bool = False, scorecard_params: Optional[Dict[str, Any]] = None, **kwargs, ): super().__init__( estimator_class=RandomForestClassifier, random_state=random_state, n_jobs=n_jobs, verbose=verbose, scorecard_params=scorecard_params, **kwargs, ) self.n_estimators = n_estimators self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf self.max_features = max_features self.bootstrap = bootstrap self.class_weight = class_weight self.criterion = criterion # 更新kwargs self.kwargs.update( { "n_estimators": n_estimators, "max_depth": max_depth, "min_samples_split": min_samples_split, "min_samples_leaf": min_samples_leaf, "max_features": max_features, "bootstrap": bootstrap, "class_weight": class_weight, "criterion": criterion, } )
[文档] class ExtraTrees(SklearnRiskModel): """极端随机树风控模型. 基于sklearn的ExtraTreesClassifier封装。 比普通随机森林随机性更强,训练更快。 **参数** :param n_estimators: 树的数量,默认100 :param max_depth: 树最大深度,默认None :param min_samples_split: 节点分裂最小样本数,默认2 :param min_samples_leaf: 叶子节点最小样本数,默认1 :param max_features: 最大特征数,默认'sqrt' :param bootstrap: 是否使用自助采样,默认False :param class_weight: 类别权重,默认None :param criterion: 分裂标准,默认'gini' :param random_state: 随机种子,默认None :param n_jobs: 并行任务数,默认-1 :param verbose: 是否输出详细信息,默认False """ def __init__( self, n_estimators: int = 100, max_depth: Optional[int] = None, min_samples_split: Union[int, float] = 2, min_samples_leaf: Union[int, float] = 1, max_features: Union[str, int, float] = "sqrt", bootstrap: bool = False, class_weight: Optional[Union[str, Dict]] = None, criterion: str = "gini", random_state: Optional[int] = None, n_jobs: int = -1, verbose: bool = False, scorecard_params: Optional[Dict[str, Any]] = None, **kwargs, ): super().__init__( estimator_class=ExtraTreesClassifier, random_state=random_state, n_jobs=n_jobs, verbose=verbose, scorecard_params=scorecard_params, **kwargs, ) self.n_estimators = n_estimators self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf self.max_features = max_features self.bootstrap = bootstrap self.class_weight = class_weight self.criterion = criterion # 更新kwargs self.kwargs.update( { "n_estimators": n_estimators, "max_depth": max_depth, "min_samples_split": min_samples_split, "min_samples_leaf": min_samples_leaf, "max_features": max_features, "bootstrap": bootstrap, "class_weight": class_weight, "criterion": criterion, } )
[文档] class GradientBoosting(SklearnRiskModel): """梯度提升树风控模型. 基于sklearn的GradientBoostingClassifier封装。 **参数** :param n_estimators: 树的数量,默认100 :param learning_rate: 学习率,默认0.1 :param max_depth: 树最大深度,默认3 :param min_samples_split: 节点分裂最小样本数,默认2 :param min_samples_leaf: 叶子节点最小样本数,默认1 :param subsample: 样本采样比例,默认1.0 :param max_features: 最大特征数,默认None :param criterion: 分裂标准,默认'friedman_mse' :param random_state: 随机种子,默认None :param verbose: 是否输出详细信息,默认False """ def __init__( self, n_estimators: int = 100, learning_rate: float = 0.1, max_depth: int = 3, min_samples_split: Union[int, float] = 2, min_samples_leaf: Union[int, float] = 1, subsample: float = 1.0, max_features: Optional[Union[str, int, float]] = None, criterion: str = "friedman_mse", validation_fraction: float = 0.1, n_iter_no_change: Optional[int] = None, random_state: Optional[int] = None, verbose: bool = False, scorecard_params: Optional[Dict[str, Any]] = None, **kwargs, ): super().__init__( estimator_class=GradientBoostingClassifier, random_state=random_state, n_jobs=1, # GBT不支持n_jobs verbose=verbose, scorecard_params=scorecard_params, **kwargs, ) self.n_estimators = n_estimators self.learning_rate = learning_rate self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf self.subsample = subsample self.max_features = max_features self.criterion = criterion self.validation_fraction = validation_fraction self.n_iter_no_change = n_iter_no_change # 更新kwargs self.kwargs.update( { "n_estimators": n_estimators, "learning_rate": learning_rate, "max_depth": max_depth, "min_samples_split": min_samples_split, "min_samples_leaf": min_samples_leaf, "subsample": subsample, "max_features": max_features, "criterion": criterion, "validation_fraction": validation_fraction, "n_iter_no_change": n_iter_no_change, } )
[文档] 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, ) -> "GradientBoosting": """训练模型. 支持两种调用方式: 1. 常规方式: fit(X, y) 2. scorecardpipeline风格: fit(X) 在init中指定target """ result = super().fit(X, y, sample_weight, eval_set, **fit_params) # 保存训练过程中的损失 if hasattr(self._model, "train_score_"): self._evals_result["train"] = {"loss": self._model.train_score_} if hasattr(self._model, "validation_score_") and self._model.validation_score_: self._evals_result["validation"] = {"loss": self._model.validation_score_} # 最佳迭代次数 if hasattr(self._model, "n_estimators_"): self._best_iteration = self._model.n_estimators_ return result
[文档] class SVM(SklearnRiskModel): """基于 sklearn SVC 的概率型支持向量机模型。 **参数** :param C: 正则强度倒数,默认 ``1.0`` :param kernel: 核函数,默认 ``'rbf'`` :param degree: 多项式核次数,默认 ``3`` :param gamma: 核系数,默认 ``'scale'`` :param probability: 是否启用概率估计,只允许 ``True`` :param class_weight: 类别权重,默认 ``None`` :param random_state: 随机种子,默认 ``None`` :param n_jobs: hscredit 包装层并行预算,不传给 SVC,默认 ``1`` :param verbose: 是否输出训练日志,默认 ``False`` **属性** - ``classes_``: 训练类别标签 - ``feature_names_in_``: 训练字段名称 - ``tuner``: 最近一次调优使用的 ModelTuner **参考样例** >>> from hscredit.core.models import SVM >>> model = SVM(C=1.0, kernel="rbf", random_state=42) >>> model.fit(X_train, y_train) >>> probability = model.predict_proba(X_test)[:, 1] """ def __init__( self, C: float = 1.0, kernel: str = "rbf", degree: int = 3, gamma: Union[str, float] = "scale", coef0: float = 0.0, shrinking: bool = True, probability: bool = True, tol: float = 1e-3, class_weight: Optional[Union[str, Dict]] = None, max_iter: int = -1, random_state: Optional[int] = None, n_jobs: int = 1, verbose: bool = False, scorecard_params: Optional[Dict[str, Any]] = None, **kwargs, ): if probability is not True: raise ValidationError("SVM 必须设置 probability=True 以提供统一概率预测") super().__init__( estimator_class=SVC, random_state=random_state, n_jobs=n_jobs, verbose=verbose, scorecard_params=scorecard_params, **kwargs, ) self.C = C self.kernel = kernel self.degree = degree self.gamma = gamma self.coef0 = coef0 self.shrinking = shrinking self.probability = True self.tol = tol self.class_weight = class_weight self.max_iter = max_iter self.kwargs.update( { "C": C, "kernel": kernel, "degree": degree, "gamma": gamma, "coef0": coef0, "shrinking": shrinking, "probability": True, "tol": tol, "class_weight": class_weight, "max_iter": max_iter, } )
[文档] def set_params(self, **params): """设置 sklearn 参数,同时禁止关闭统一概率能力。""" if "probability" in params and params["probability"] is not True: raise ValidationError("SVM 必须设置 probability=True 以提供统一概率预测") return super().set_params(**params)
[文档] class DecisionTreeClassifier(SklearnRiskModel): """基于 sklearn DecisionTreeClassifier 的统一风控模型。 **参数** :param criterion: 节点划分质量指标,默认 ``'gini'`` :param splitter: 节点划分策略,默认 ``'best'`` :param max_depth: 最大树深,默认 ``None`` :param min_samples_split: 节点分裂最小样本数,默认 ``2`` :param min_samples_leaf: 叶节点最小样本数,默认 ``1`` :param max_features: 每次分裂考虑的最大特征数,默认 ``None`` :param class_weight: 类别权重,默认 ``None`` :param ccp_alpha: 最小代价复杂度剪枝系数,默认 ``0.0`` :param random_state: 随机种子,默认 ``None`` :param n_jobs: hscredit 包装层并行预算,不传给底层决策树,默认 ``1`` **属性** - ``feature_importances_``: 决策树原生特征重要性 - ``classes_``: 训练类别标签 - ``feature_names_in_``: 训练字段名称 **参考样例** >>> from hscredit.core.models import DecisionTreeClassifier >>> model = DecisionTreeClassifier(max_depth=4, min_samples_leaf=20, random_state=42) >>> model.fit(X_train, y_train) >>> probability = model.predict_proba(X_test)[:, 1] """ def __init__( self, criterion: str = "gini", splitter: str = "best", max_depth: Optional[int] = None, min_samples_split: Union[int, float] = 2, min_samples_leaf: Union[int, float] = 1, max_features: Optional[Union[str, int, float]] = None, class_weight: Optional[Union[str, Dict]] = None, ccp_alpha: float = 0.0, random_state: Optional[int] = None, n_jobs: int = 1, scorecard_params: Optional[Dict[str, Any]] = None, **kwargs, ): super().__init__( estimator_class=SklearnDecisionTreeClassifier, random_state=random_state, n_jobs=n_jobs, verbose=False, scorecard_params=scorecard_params, **kwargs, ) self.criterion = criterion self.splitter = splitter self.max_depth = max_depth self.min_samples_split = min_samples_split self.min_samples_leaf = min_samples_leaf self.max_features = max_features self.class_weight = class_weight self.ccp_alpha = ccp_alpha self.kwargs.update( { "criterion": criterion, "splitter": splitter, "max_depth": max_depth, "min_samples_split": min_samples_split, "min_samples_leaf": min_samples_leaf, "max_features": max_features, "class_weight": class_weight, "ccp_alpha": ccp_alpha, } )