hscredit.core.models.tuning.tuning 源代码

"""Optuna超参数调优接口 - 基于内部建模经验优化.

提供统一的超参数调优功能,支持所有风控模型。
搜索空间基于内部建模经验优化,适配不同样本量和特征数。

支持功能:
1. 单目标优化(如KS、AUC)
2. 多目标优化(如同时优化KS和稳定性)
3. 自定义评估指标
4. 指定trials_point评估超参数空间内特定点的模型效果

**依赖**
pip install optuna

**参考样例**
>>> from hscredit.core.models import XGBoost, ModelTuner
>>>
>>> # 定义搜索空间
>>> search_space = {
...     'max_depth': {'type': 'int', 'low': 3, 'high': 10},
...     'learning_rate': {'type': 'float', 'low': 0.01, 'high': 0.3, 'log': True},
...     'n_estimators': {'type': 'int', 'low': 50, 'high': 500},
... }
>>>
>>> # sklearn风格
>>> tuner = ModelTuner(
...     model_class=XGBoost,
...     search_space=search_space,
...     metric='ks',
...     direction='maximize'
... )
>>> best_params = tuner.fit(X_train, y_train, n_trials=100)
>>>
>>> # scorecardpipeline风格
>>> tuner = ModelTuner(
...     model_class=XGBoost,
...     search_space=search_space,
...     metric='ks',
...     target='label'
... )
>>> best_params = tuner.fit(df, n_trials=100)
>>>
>>> # 多目标调优(KS + 稳定性)
>>> tuner = ModelTuner(
...     model_class=XGBoost,
...     search_space=search_space,
...     metric=['ks', 'ks_diff'],
...     direction=['maximize', 'minimize'],
... )
>>> best_params = tuner.fit(X_train, y_train, n_trials=100)

>>> # 自定义metric
>>> def custom_metric(y_true, y_pred):
...     return some_score(y_true, y_pred)
>>> 
>>> tuner = ModelTuner(
...     model_class=XGBoost,
...     search_space=search_space,
...     metric=custom_metric,
...     direction='maximize'
... )
>>> best_params = tuner.fit(X_train, y_train, n_trials=100)

>>> # 评估特定超参数点(sklearn风格)
    >>> trial_points = [
    ...     {'max_depth': 3, 'learning_rate': 0.1, 'n_estimators': 100},
    ...     {'max_depth': 5, 'learning_rate': 0.05, 'n_estimators': 200},
    ... ]
    >>> results = tuner.evaluate_trials(X_train, y_train, trial_points=trial_points)

    >>> # 评估特定超参数点(scorecardpipeline风格)
    >>> results = tuner.evaluate_trials(df, trial_points=trial_points)
"""

import copy
import inspect
import logging
import warnings
from typing import Any, Callable, Dict, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type, Union
import numpy as np
import pandas as pd
from ....utils.parallel import resolve_n_jobs
from sklearn.base import clone
from sklearn.model_selection import ParameterGrid, StratifiedKFold
from sklearn.metrics import get_scorer, roc_auc_score, roc_curve

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from optuna.trial import Trial
else:
    # 类型标注在运行时无需加载 Optuna 的 Trial 类;保留该名称可支持
    # inspect/get_type_hints 等运行时注解读取,同时避免 Pylance 将 optuna 变量当类型命名空间。
    Trial = Any

try:
    import optuna
    from optuna.samplers import TPESampler
    from optuna.study import StudyDirection

    OPTUNA_AVAILABLE = True
except ImportError:
    OPTUNA_AVAILABLE = False
    optuna = None
    TPESampler = None
    StudyDirection = None


def _normalize_space_param(name: str, spec: Any) -> Dict[str, Any]:
    """将单个超参数定义统一为内部 DSL(optuna 风格字典).

    支持的输入格式(按识别顺序):

    - dict(hscredit/optuna 风格):
      ``{'type': 'int'/'float'/'categorical', 'low':, 'high':, 'step':, 'log':, 'choices':}``
    - dict(hyperopt 风格 type):
      ``{'type': 'uniform'/'loguniform'/'quniform'/'randint'/'choice', ...}``
    - tuple ``(low, high)``(bayesian-optimization / skopt 简写):
      两端均为整数时为 int,否则为 float
    - tuple ``(low, high, prior)``(skopt 简写):
      prior 为 ``'log-uniform'`` 时 float(log=True),``'uniform'`` 时 float
    - list(sklearn 网格 / skopt Categorical / hyperopt choice 简写):
      视为 categorical 的 choices
    - scipy.stats 冻结分布(sklearn RandomizedSearchCV 风格):
      ``randint`` / ``loguniform``(reciprocal) / ``uniform``
    - optuna.distributions 分布对象(安装 optuna 时):
      ``IntDistribution`` / ``FloatDistribution`` / ``CategoricalDistribution``
      及 optuna 旧版 ``IntUniformDistribution`` 等

    :param name: 参数名(用于错误提示)
    :param spec: 参数定义
    :return: 内部 DSL 字典,含 ``type`` 及相应键
    :raises ValueError: 无法识别的格式或取值非法时抛出
    """
    # 0. hscredit 维度对象(search_space 模块的 Real/Integer/Categorical/suggest_*/hp.* 返回值)
    # 对象自带 to_spec 方法时优先委托,再走统一字典解析路径
    if hasattr(spec, "to_spec") and callable(getattr(spec, "to_spec")):
        return _normalize_space_param(name, spec.to_spec())

    # 1. optuna 原生分布对象
    if OPTUNA_AVAILABLE and isinstance(spec, optuna.distributions.BaseDistribution):
        return _space_param_from_optuna(name, spec)

    # 2. scipy 冻结分布(sklearn RandomizedSearchCV 风格)
    # 鸭子类型识别 rv_frozen(rv_frozen 基类在 scipy 各版本中的暴露路径不稳定)
    if (
        hasattr(spec, "dist")
        and hasattr(spec, "args")
        and callable(getattr(spec, "rvs", None))
        and getattr(getattr(spec, "dist", None), "name", None) is not None
    ):
        return _space_param_from_scipy(name, spec)

    # 3. tuple:bayesian-optimization / skopt 简写
    if isinstance(spec, tuple):
        return _space_param_from_tuple(name, spec)

    # 4. list:sklearn 网格 / skopt Categorical / hyperopt choice 简写
    if isinstance(spec, list):
        if not spec:
            raise ValueError(f"参数 {name!r} 的 choices 不能为空列表")
        return {"type": "categorical", "choices": list(spec)}

    # 5. dict:hscredit DSL 或 hyperopt 风格
    if isinstance(spec, dict):
        return _space_param_from_dict(name, spec)

    raise ValueError(
        f"参数 {name!r} 的搜索空间定义无法识别: {spec!r}。"
        "支持 dict(optuna/hyperopt 风格)、tuple(bayesian-optimization/skopt 风格)、"
        "list(categorical 简写)、scipy.stats 分布或 optuna.distributions 分布对象"
    )


def _space_param_from_dict(name: str, spec: Dict[str, Any]) -> Dict[str, Any]:
    """解析字典形式(hscredit DSL 或 hyperopt 风格 type)的参数定义。"""
    param_type = spec.get("type")
    if param_type is None:
        raise ValueError(f"参数 {name!r} 的搜索空间字典缺少 'type' 键: {spec!r}")
    param_type = str(param_type).strip().lower()

    if param_type == "int":
        _check_bounds(name, spec, integer=True)
        result = {"type": "int", "low": int(spec["low"]), "high": int(spec["high"])}
        if spec.get("log", False):
            result["log"] = True
        elif "step" in spec:
            result["step"] = int(spec["step"])
        return result
    if param_type == "float":
        _check_bounds(name, spec, integer=False)
        result = {"type": "float", "low": float(spec["low"]), "high": float(spec["high"])}
        if spec.get("log", False):
            result["log"] = True
        if "step" in spec and spec["step"] is not None:
            result["step"] = float(spec["step"])
        return result
    if param_type == "categorical":
        choices = spec.get("choices", spec.get("options"))
        if not choices:
            raise ValueError(f"参数 {name!r} 的 categorical 类型必须提供非空 choices")
        return {"type": "categorical", "choices": list(choices)}

    # hyperopt 风格
    if param_type == "uniform":
        _check_bounds(name, spec, integer=False)
        return {"type": "float", "low": float(spec["low"]), "high": float(spec["high"])}
    if param_type == "loguniform":
        _check_bounds(name, spec, integer=False)
        return {"type": "float", "low": float(spec["low"]), "high": float(spec["high"]), "log": True}
    if param_type == "quniform":
        _check_bounds(name, spec, integer=False)
        if "q" not in spec:
            raise ValueError(f"参数 {name!r} 的 quniform 类型必须提供步长 'q'")
        return {
            "type": "float",
            "low": float(spec["low"]),
            "high": float(spec["high"]),
            "step": float(spec["q"]),
        }
    if param_type == "randint":
        low = int(spec.get("low", 0))
        if "high" not in spec:
            raise ValueError(f"参数 {name!r} 的 randint 类型必须提供 'high'")
        high = int(spec["high"])
        if low > high:
            raise ValueError(f"参数 {name!r} 的 low({low}) 不能大于 high({high})")
        return {"type": "int", "low": low, "high": high}
    if param_type == "choice":
        choices = spec.get("choices", spec.get("options"))
        if not choices:
            raise ValueError(f"参数 {name!r} 的 choice 类型必须提供非空 choices")
        return {"type": "categorical", "choices": list(choices)}

    # hyperopt 正态族(normal/lognormal/qnormal/qlognormal)
    # optuna 无原生正态采样,归一化为 'normal' DSL,由 _sample_params 逆 CDF 变换实现
    if param_type in ("normal", "qnormal", "lognormal", "qlognormal"):
        if "mu" not in spec or "sigma" not in spec:
            raise ValueError(f"参数 {name!r}{param_type} 类型必须提供 'mu' 和 'sigma'")
        mu = float(spec["mu"])
        sigma = float(spec["sigma"])
        if sigma <= 0:
            raise ValueError(f"参数 {name!r} 的 sigma 必须 > 0")
        log = param_type in ("lognormal", "qlognormal")
        q = None
        if param_type in ("qnormal", "qlognormal"):
            if "q" not in spec:
                raise ValueError(f"参数 {name!r}{param_type} 类型必须提供步长 'q'")
            q = float(spec["q"])
        # 截断区间 [mu-4σ, mu+4σ],log 时映射到 exp
        lo, hi = mu - 4 * sigma, mu + 4 * sigma
        low = float(np.exp(lo)) if log else float(lo)
        high = float(np.exp(hi)) if log else float(hi)
        result = {"type": "normal", "mu": mu, "sigma": sigma, "low": low, "high": high}
        if q is not None:
            result["q"] = q
        if log:
            result["log"] = True
        return result

    raise ValueError(
        f"参数 {name!r} 的搜索空间类型未知: {param_type!r},"
        "可选: 'int'/'float'/'categorical'(optuna 风格)或 "
        "'uniform'/'loguniform'/'quniform'/'randint'/'choice'(hyperopt 风格)或 "
        "'normal'/'qnormal'/'lognormal'/'qlognormal'(hyperopt 正态族)"
    )


def _space_param_from_tuple(name: str, spec: tuple) -> Dict[str, Any]:
    """解析元组形式(bayesian-optimization / skopt 简写)的参数定义。"""
    if len(spec) == 2:
        low, high = spec
        if low > high:
            raise ValueError(f"参数 {name!r} 的下界({low})不能大于上界({high})")
        # 两端均为整数(排除 bool)时视为整数参数,否则为浮点参数
        if (
            isinstance(low, (int, np.integer))
            and isinstance(high, (int, np.integer))
            and not isinstance(low, bool)
            and not isinstance(high, bool)
        ):
            return {"type": "int", "low": int(low), "high": int(high)}
        return {"type": "float", "low": float(low), "high": float(high)}
    if len(spec) == 3:
        low, high, prior = spec
        if low > high:
            raise ValueError(f"参数 {name!r} 的下界({low})不能大于上界({high})")
        prior_norm = str(prior).strip().lower()
        if prior_norm == "log-uniform":
            return {"type": "float", "low": float(low), "high": float(high), "log": True}
        if prior_norm == "uniform":
            return {"type": "float", "low": float(low), "high": float(high)}
        raise ValueError(f"参数 {name!r} 的元组第三元素(prior)仅支持 'uniform'/'log-uniform'," f"当前为 {prior!r}")
    raise ValueError(f"参数 {name!r} 的元组形式仅支持 (low, high) 或 (low, high, prior)," f"当前长度: {len(spec)}")


def _space_param_from_scipy(name: str, spec: Any) -> Dict[str, Any]:
    """解析 scipy 冻结分布(sklearn RandomizedSearchCV 风格)的参数定义。"""
    dist_name = getattr(getattr(spec, "dist", None), "name", None)
    args = getattr(spec, "args", ())

    if dist_name == "randint":
        # scipy randint 采样区间为 [low, high),转为闭区间 [low, high-1]
        low, high = int(args[0]), int(args[1]) - 1
        if low > high:
            raise ValueError(f"参数 {name!r} 的 randint 分布区间为空: {spec!r}")
        return {"type": "int", "low": low, "high": high}
    if dist_name in ("loguniform", "reciprocal"):
        low, high = float(args[0]), float(args[1])
        if low <= 0 or high <= 0:
            raise ValueError(f"参数 {name!r} 的 loguniform 分布要求区间为正数: {spec!r}")
        return {"type": "float", "low": low, "high": high, "log": True}
    if dist_name == "uniform":
        # scipy uniform 参数为 (loc, scale),采样区间 [loc, loc+scale]
        low, high = float(args[0]), float(args[0] + args[1])
        return {"type": "float", "low": low, "high": high}

    raise ValueError(f"参数 {name!r} 的 scipy 分布暂不支持: {dist_name!r}," "仅支持 randint / loguniform / uniform")


def _space_param_from_optuna(name: str, spec: Any) -> Dict[str, Any]:
    """解析 optuna 分布对象的参数定义(含旧版分布类的兼容映射)。"""
    # optuna >= 2.4 的统一分布类
    if isinstance(spec, optuna.distributions.IntDistribution):
        result = {"type": "int", "low": int(spec.low), "high": int(spec.high)}
        if getattr(spec, "log", False):
            result["log"] = True
        else:
            step = int(getattr(spec, "step", 1))
            if step != 1:
                result["step"] = step
        return result
    if isinstance(spec, optuna.distributions.FloatDistribution):
        result = {"type": "float", "low": float(spec.low), "high": float(spec.high)}
        if getattr(spec, "log", False):
            result["log"] = True
        step = getattr(spec, "step", None)
        if step is not None:
            result["step"] = float(step)
        return result
    if isinstance(spec, optuna.distributions.CategoricalDistribution):
        return {"type": "categorical", "choices": list(spec.choices)}

    # optuna < 2.4 的旧版分布类
    legacy = optuna.distributions
    if hasattr(legacy, "IntLogUniformDistribution") and isinstance(spec, legacy.IntLogUniformDistribution):
        return {"type": "int", "low": int(spec.low), "high": int(spec.high), "log": True}
    if hasattr(legacy, "IntUniformDistribution") and isinstance(spec, legacy.IntUniformDistribution):
        result = {"type": "int", "low": int(spec.low), "high": int(spec.high)}
        step = int(getattr(spec, "step", 1))
        if step != 1:
            result["step"] = step
        return result
    if hasattr(legacy, "LogUniformDistribution") and isinstance(spec, legacy.LogUniformDistribution):
        return {"type": "float", "low": float(spec.low), "high": float(spec.high), "log": True}
    if hasattr(legacy, "DiscreteUniformDistribution") and isinstance(spec, legacy.DiscreteUniformDistribution):
        return {
            "type": "float",
            "low": float(spec.low),
            "high": float(spec.high),
            "step": float(spec.q),
        }
    if hasattr(legacy, "UniformDistribution") and isinstance(spec, legacy.UniformDistribution):
        return {"type": "float", "low": float(spec.low), "high": float(spec.high)}

    raise ValueError(f"参数 {name!r} 的 optuna 分布类型不支持: {type(spec).__name__}")


def _check_bounds(name: str, spec: Dict[str, Any], integer: bool) -> None:
    """校验字典形式参数定义的 low/high 边界。"""
    if "low" not in spec or "high" not in spec:
        raise ValueError(f"参数 {name!r} 的搜索空间必须提供 'low' 和 'high': {spec!r}")
    low, high = spec["low"], spec["high"]
    if low > high:
        raise ValueError(f"参数 {name!r} 的下界({low})不能大于上界({high})")
    if integer:
        if (
            isinstance(low, bool)
            or isinstance(high, bool)
            or not isinstance(low, (int, np.integer))
            or not isinstance(high, (int, np.integer))
        ):
            raise ValueError(f"参数 {name!r} 的 int 类型要求整数边界: {spec!r}")


def _legacy_normalize_search_space(search_space: Optional[Dict[str, Any]]) -> Optional[Dict[str, Dict[str, Any]]]:
    """将多种超参数框架的搜索空间格式统一为内部 DSL(optuna 风格).

    每个参数单独定义,支持以下框架的入参格式(无需安装对应库,
    仅需按其风格以 dict/tuple/list/分布对象表达):

    - **hscredit/optuna 风格**:
      ``{'max_depth': {'type': 'int', 'low': 2, 'high': 4}}``
    - **optuna 分布对象**:``{'max_depth': optuna.distributions.IntDistribution(2, 4)}``
    - **bayesian-optimization 风格**:
      ``{'max_depth': (2, 4), 'learning_rate': (1e-3, 0.1)}``
    - **scikit-optimize 风格**:
      ``{'learning_rate': (1e-3, 0.1, 'log-uniform'), 'penalty': ['l1', 'l2']}``
    - **sklearn 风格**:
      ``{'C': [0.1, 1, 10]}`` 或 scipy 分布 ``{'C': scipy.stats.loguniform(1e-3, 1e1)}``
    - **hyperopt 风格**:
      ``{'learning_rate': {'type': 'loguniform', 'low': 1e-3, 'high': 0.1},
        'penalty': {'type': 'choice', 'choices': ['l1', 'l2']}}``

    :param search_space: 搜索空间字典,键为参数名;为 None 时原样返回 None
    :return: 统一为 ``{'type': 'int'/'float'/'categorical', ...}`` 形式的字典
    :raises ValueError: 格式无法识别或取值非法时抛出
    """
    if search_space is None:
        return None
    if not isinstance(search_space, dict):
        raise ValueError(f"search_space 必须是字典(参数名 -> 参数定义),当前类型: {type(search_space).__name__}")
    return {name: _normalize_space_param(name, spec) for name, spec in search_space.items()}


# 新适配器覆盖上方保留的旧解析实现;旧私有函数暂留用于兼容可能存在的内部导入,
# 所有公开入口与 ModelTuner 从这里开始统一走同一套格式、校验和采样语义。
from .space_adapter import SearchSpaceAdapter, normalize_search_space  # noqa: E402, F401


class TuningSampler:
    """采样器码表 - 统一管理 optuna 内置及 optunahub 提供的搜索器.

    通过字符串名称即可在 ``ModelTuner(sampler=...)`` 中选用不同搜索器,
    无需直接 import 对应的采样器类。

    **optuna 内置采样器** (``BUILTIN_SAMPLERS``):

    - ``'tpe'``        : TPESampler,树结构 Parzen 估计(默认)
    - ``'random'``     : RandomSampler,随机搜索
    - ``'cmaes'``      : CmaEsSampler,CMA-ES 进化策略(依赖 cmaes)
    - ``'grid'``       : GridSampler,网格搜索(需 sampler_kwargs 传入 search_space)
    - ``'nsgaii'``     : NSGAIISampler,多目标遗传算法
    - ``'nsgaiii'``    : NSGAIIISampler,多目标遗传算法(多目标场景)
    - ``'qmc'``        : QMCSampler,准蒙特卡洛
    - ``'gp'``         : GPSampler,高斯过程贝叶斯优化
    - ``'bruteforce'`` : BruteForceSampler,穷举搜索

    **optunahub 采样器** (``OPTUNAHUB_SAMPLERS``,依赖 optunahub,按需联网下载):

    - ``'auto'``       : AutoSampler,自动选择最优采样器
    - ``'hebo'``       : HEBOSampler,异方差贝叶斯优化
    - ``'smac'``       : SMACSampler,基于 SMAC3 的贝叶斯优化
    - ``'neldermead'`` : NelderMeadSampler,单纯形法

    **参考样例**

    >>> from hscredit.core.models import TuningSampler
    >>> TuningSampler.list_samplers()
    >>> sampler = TuningSampler.create('cmaes', seed=42)
    >>> sampler = TuningSampler.create('auto')   # optunahub
    """

    # optuna 内置采样器:name -> optuna.samplers 中的类名
    BUILTIN_SAMPLERS = {
        "tpe": "TPESampler",
        "random": "RandomSampler",
        "cmaes": "CmaEsSampler",
        "grid": "GridSampler",
        "nsgaii": "NSGAIISampler",
        "nsgaiii": "NSGAIIISampler",
        "qmc": "QMCSampler",
        "gp": "GPSampler",
        "bruteforce": "BruteForceSampler",
    }

    # optunahub 采样器:name -> (package 路径, 类名)
    OPTUNAHUB_SAMPLERS = {
        "auto": ("samplers/auto_sampler", "AutoSampler"),
        "hebo": ("samplers/hebo", "HEBOSampler"),
        "smac": ("samplers/smac_sampler", "SMACSampler"),
        "neldermead": ("samplers/nelder_mead", "NelderMeadSampler"),
    }

    @classmethod
    def list_samplers(cls) -> Dict[str, List[str]]:
        """列出所有支持的采样器名称.

        :return: {'内置': [...], 'optunahub': [...]}
        """
        return {
            "内置": list(cls.BUILTIN_SAMPLERS.keys()),
            "optunahub": list(cls.OPTUNAHUB_SAMPLERS.keys()),
        }

    @staticmethod
    def _instantiate(sampler_cls: Type, kwargs: Dict[str, Any]) -> Any:
        """按构造函数签名过滤 kwargs 后实例化采样器(如 seed 不被支持则丢弃)."""
        import inspect

        try:
            sig = inspect.signature(sampler_cls.__init__)
            accepted = set(sig.parameters)
            accepts_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
        except (TypeError, ValueError):
            accepted, accepts_var_kw = set(), True

        if not accepts_var_kw:
            kwargs = {k: v for k, v in kwargs.items() if k in accepted}
        return sampler_cls(**kwargs)

    @classmethod
    def create(
        cls,
        sampler: Union[str, Any, None] = "tpe",
        seed: Optional[int] = None,
        **kwargs,
    ) -> Any:
        """按名称创建采样器实例.

        :param sampler: 采样器名称(见 BUILTIN_SAMPLERS / OPTUNAHUB_SAMPLERS),
            或已实例化的采样器对象(直接返回),或 None(默认 TPE)
        :param seed: 随机种子,若采样器支持则注入
        :param kwargs: 透传给采样器构造函数的额外参数
        :return: optuna 采样器实例
        """
        if not OPTUNA_AVAILABLE:
            raise ImportError("Optuna未安装,请使用 pip install optuna 安装")

        # 已是采样器实例,直接返回
        if sampler is None:
            sampler = "tpe"
        if not isinstance(sampler, str):
            return sampler

        key = sampler.lower()
        if seed is not None and "seed" not in kwargs:
            kwargs["seed"] = seed

        if key in cls.BUILTIN_SAMPLERS:
            sampler_cls = getattr(optuna.samplers, cls.BUILTIN_SAMPLERS[key])
            return cls._instantiate(sampler_cls, kwargs)

        if key in cls.OPTUNAHUB_SAMPLERS:
            try:
                import optunahub
            except ImportError:
                raise ImportError(
                    f"使用 '{sampler}' 采样器需要 optunahub,请使用 " f"pip install optunahub 安装(或 pip install hscredit[tune])"
                )
            package, class_name = cls.OPTUNAHUB_SAMPLERS[key]
            module = optunahub.load_module(package)
            sampler_cls = getattr(module, class_name, None)
            if sampler_cls is None:
                # 类名兜底:查找模块中以 Sampler 结尾的类
                sampler_cls = next(
                    (getattr(module, n) for n in dir(module) if n.endswith("Sampler")),
                    None,
                )
            if sampler_cls is None:
                raise ValueError(f"无法从 optunahub 包 '{package}' 中找到采样器类")
            return cls._instantiate(sampler_cls, kwargs)

        all_names = list(cls.BUILTIN_SAMPLERS) + list(cls.OPTUNAHUB_SAMPLERS)
        raise ValueError(f"未知采样器 '{sampler}',可选: {all_names}")


def _calc_ks(y_true: np.ndarray, y_pred: np.ndarray) -> float:
    """计算KS值(内部辅助函数).

    :param y_true: 真实标签
    :param y_pred: 预测概率
    :return: KS值
    """
    fpr, tpr, _ = roc_curve(y_true, y_pred, pos_label=1)
    return abs(tpr - fpr).max()


def _calc_ks_with_diff(
    y_train: np.ndarray, y_train_pred: np.ndarray, y_val: np.ndarray, y_val_pred: np.ndarray
) -> Tuple[float, float]:
    """计算KS及训练/验证差异.

    :return: (验证集KS, KS差异)
    """
    ks_train = _calc_ks(y_train, y_train_pred)
    ks_val = _calc_ks(y_val, y_val_pred)
    ks_diff = abs(ks_train - ks_val)
    return ks_val, ks_diff


def _safe_index(data: Any, indices: np.ndarray) -> Any:
    """按行索引切分 pandas / numpy / list 数据."""
    if data is None:
        return None
    if hasattr(data, "iloc"):
        return data.iloc[indices]
    return np.asarray(data)[indices]


[文档] class TuningObjective: """内置调参目标函数集合. 所有静态方法签名均为 ``(y_true, y_prob, **kwargs) -> float``, 值越大越好(均已设计为 maximize 方向)。 可通过字符串名称传给 ``ModelTuner(objective=...)``: - ``'ks'`` : 标准 KS(默认) - ``'auc'`` : ROC-AUC - ``'lift_head'`` : 头部 LIFT(高概率前 ratio 比例) - ``'lift_tail'`` : 尾部 LIFT(低概率前 ratio 比例的纯净度) - ``'lift_head_monotonic'`` : KS × (1 - 违反单调比例 × penalty) - ``'ks_with_lift_constraint'`` : 满足头部 LIFT 约束下的 KS - ``'head_ks'`` : 仅头部 ratio 比例样本的 KS - ``'approval_bad_rate'`` : 固定通过率下优化低风险通过客群坏率 - ``'expected_profit'`` : 固定通过率下优化通过客群期望利润 Example: >>> from hscredit.core.models import ModelTuner, XGBoost >>> tuner = ModelTuner( ... model_class=XGBoost, ... objective='lift_head', ... objective_kwargs={'ratio': 0.05}, ... ) >>> tuner.fit(X_train, y_train, n_trials=50) """ # 支持的字符串名称 BUILTIN_OBJECTIVES = [ "ks", "auc", "lift_head", "lift_tail", "lift_head_monotonic", "ks_with_lift_constraint", "head_ks", "ks_lift_combined", "tail_purity_ks", "approval_bad_rate", "expected_profit", ]
[文档] @staticmethod def ks(y_true: np.ndarray, y_prob: np.ndarray, **kwargs) -> float: """标准 KS 目标.""" return _calc_ks(y_true, y_prob)
[文档] @staticmethod def auc(y_true: np.ndarray, y_prob: np.ndarray, **kwargs) -> float: """ROC-AUC 目标.""" try: from sklearn.metrics import roc_auc_score return float(roc_auc_score(y_true, y_prob)) except Exception: return 0.0
[文档] @staticmethod def lift_head( y_true: np.ndarray, y_prob: np.ndarray, ratio: float = 0.10, **kwargs, ) -> float: """头部 LIFT 目标:优化预测概率最高 ratio 比例样本的 LIFT 值. :param ratio: 覆盖率,默认 0.10(即 Top 10%) """ total = len(y_true) n_top = max(1, int(total * ratio)) sorted_idx = np.argsort(y_prob)[::-1] y_sorted = y_true[sorted_idx] overall_br = y_true.mean() if overall_br == 0: return 0.0 top_br = y_sorted[:n_top].mean() return float(top_br / overall_br)
[文档] @staticmethod def lift_tail( y_true: np.ndarray, y_prob: np.ndarray, ratio: float = 0.10, **kwargs, ) -> float: """尾部 LIFT 目标:优化预测概率最低 ratio 比例样本(低风险客群)的纯净度. 纯净度定义为:(1 - 尾部坏率) / (1 - 整体坏率),值越大表示尾部越纯净。 :param ratio: 尾部覆盖率,默认 0.10 """ total = len(y_true) n_tail = max(1, int(total * ratio)) sorted_idx = np.argsort(y_prob) # 升序,低概率在前 y_sorted = y_true[sorted_idx] overall_br = y_true.mean() if overall_br == 1.0: return 0.0 tail_br = y_sorted[:n_tail].mean() tail_purity = (1.0 - tail_br) / (1.0 - overall_br) if (1.0 - overall_br) > 0 else 0.0 return float(tail_purity)
[文档] @staticmethod def lift_head_monotonic( y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10, penalty: float = 0.5, **kwargs, ) -> float: """头部单调 LIFT 目标:KS × (1 - 违反单调性比例 × penalty). 单调性违反比例越低,目标越高;完全单调时等同于 KS 目标。 :param n_bins: 分箱数,默认 10 :param penalty: 单调性惩罚强度,默认 0.5 """ ks_val = _calc_ks(y_true, y_prob) try: total = len(y_true) n_bin = max(2, n_bins) bin_size = total // n_bin sorted_idx = np.argsort(y_prob)[::-1] y_sorted = y_true[sorted_idx] overall_br = y_true.mean() if overall_br == 0: return 0.0 brs = [] for i in range(n_bin): start = i * bin_size end = (i + 1) * bin_size if i < n_bin - 1 else total seg = y_sorted[start:end] brs.append(seg.mean() if len(seg) > 0 else 0.0) violations = sum(1 for i in range(1, len(brs)) if brs[i] > brs[i - 1] + 1e-8) n_pairs = n_bin - 1 violation_ratio = violations / n_pairs if n_pairs > 0 else 0.0 return float(ks_val * (1.0 - violation_ratio * penalty)) except Exception: return float(ks_val)
[文档] @staticmethod def ks_with_lift_constraint( y_true: np.ndarray, y_prob: np.ndarray, min_lift_ratio: float = 0.05, min_lift_value: float = 2.0, **kwargs, ) -> float: """KS + LIFT 约束:满足头部 LIFT >= min_lift_value 前提下最大化 KS. 若不满足约束,返回 0(惩罚)。 :param min_lift_ratio: 头部覆盖率,默认 0.05(Top 5%) :param min_lift_value: 最低 LIFT 要求,默认 2.0 """ head_lift = TuningObjective.lift_head(y_true, y_prob, ratio=min_lift_ratio) if head_lift < min_lift_value: return 0.0 # 不满足约束,惩罚为0 return _calc_ks(y_true, y_prob)
[文档] @staticmethod def head_ks( y_true: np.ndarray, y_prob: np.ndarray, ratio: float = 0.30, **kwargs, ) -> float: """头部 KS:仅计算预测概率最高 ratio 比例样本的 KS(头部区分能力). :param ratio: 头部覆盖率,默认 0.30 """ total = len(y_true) n_top = max(2, int(total * ratio)) sorted_idx = np.argsort(y_prob)[::-1] y_top = y_true[sorted_idx[:n_top]] prob_top = y_prob[sorted_idx[:n_top]] if y_top.sum() == 0 or y_top.sum() == n_top: return 0.0 try: return _calc_ks(y_top, prob_top) except Exception: return 0.0
[文档] @staticmethod def ks_lift_combined( y_true: np.ndarray, y_prob: np.ndarray, ks_weight: float = 0.5, lift_ratio: float = 0.05, **kwargs, ) -> float: """KS + LIFT 联合目标:加权组合 KS 和头部 LIFT. score = ks_weight × KS + (1 - ks_weight) × normalized_LIFT :param ks_weight: KS 权重,默认 0.5 :param lift_ratio: LIFT 覆盖率,默认 0.05 """ ks_val = _calc_ks(y_true, y_prob) lift_val = TuningObjective.lift_head(y_true, y_prob, ratio=lift_ratio) # 归一化 LIFT 到 [0, 1] 范围(假设最大合理 LIFT 为 10) norm_lift = min(lift_val / 10.0, 1.0) return float(ks_weight * ks_val + (1.0 - ks_weight) * norm_lift)
[文档] @staticmethod def tail_purity_ks( y_true: np.ndarray, y_prob: np.ndarray, tail_ratio: float = 0.30, **kwargs, ) -> float: """尾部纯净度 + 整体 KS 联合目标. 适用于「放量优先」场景:确保通过(低风险)部分的坏率尽量低,同时保持整体区分度. score = 0.5 × KS + 0.5 × tail_purity :param tail_ratio: 尾部覆盖率,默认 0.30(即通过的低风险比例) """ ks_val = _calc_ks(y_true, y_prob) purity = TuningObjective.lift_tail(y_true, y_prob, ratio=tail_ratio) # purity 已经在 [0, 1+] 范围 norm_purity = min(purity, 1.0) return float(0.5 * ks_val + 0.5 * norm_purity)
[文档] @staticmethod def approval_bad_rate( y_true: np.ndarray, y_prob: np.ndarray, approval_rate: float = 0.30, bad_rate_weight: float = 1.0, **kwargs, ) -> float: """通过率坏率目标:固定通过率下最大化通过收益、惩罚通过坏率. 默认把预测概率最低的 ``approval_rate`` 样本视为通过客群。 score = approval_rate × (1 - 通过坏率 × bad_rate_weight) :param approval_rate: 通过率,默认 0.30 :param bad_rate_weight: 坏率惩罚权重,默认 1.0 """ if not 0 < approval_rate <= 1: raise ValueError("approval_rate 必须在 (0, 1] 范围内") total = len(y_true) n_approved = max(1, int(total * approval_rate)) approved_idx = np.argsort(y_prob)[:n_approved] approved_br = np.asarray(y_true)[approved_idx].mean() return float(approval_rate * (1.0 - approved_br * bad_rate_weight))
[文档] @staticmethod def expected_profit( y_true: np.ndarray, y_prob: np.ndarray, approval_rate: float = 0.30, good_profit: float = 1.0, bad_loss: float = 5.0, **kwargs, ) -> float: """期望利润目标:固定通过率下最大化通过客群单位样本收益. 默认预测概率最低的样本为通过客群,好客户收益为 ``good_profit``, 坏客户损失为 ``bad_loss``,拒绝样本收益记为 0。 :param approval_rate: 通过率,默认 0.30 :param good_profit: 通过好客户收益,默认 1.0 :param bad_loss: 通过坏客户损失,默认 5.0 """ if not 0 < approval_rate <= 1: raise ValueError("approval_rate 必须在 (0, 1] 范围内") y_true = np.asarray(y_true) total = len(y_true) n_approved = max(1, int(total * approval_rate)) approved_idx = np.argsort(y_prob)[:n_approved] approved_y = y_true[approved_idx] profit = np.where(approved_y == 1, -bad_loss, good_profit).sum() return float(profit / total)
[文档] @classmethod def get( cls, name: str, **kwargs, ): """按名称获取目标函数(偏函数形式). :param name: 目标函数名称,见 BUILTIN_OBJECTIVES :param kwargs: 额外参数(如 ratio/penalty 等) :return: 可调用对象 (y_true, y_prob) -> float Example: >>> obj = TuningObjective.get('lift_head', ratio=0.05) >>> score = obj(y_true, y_prob) """ name_lower = name.lower() if name_lower not in cls.BUILTIN_OBJECTIVES: raise ValueError(f"未知目标函数 '{name}',可选: {cls.BUILTIN_OBJECTIVES}") func = getattr(cls, name_lower) if kwargs: import functools return functools.partial(func, **kwargs) return func
[文档] class Metric: """评估指标包装类. 用于统一管理内置指标和自定义指标。 :param metric: 指标名称(str)或自定义函数(Callable) :param name: 指标名称(用于显示) :param direction: 优化方向,'maximize'或'minimize' """ # 内置指标映射 BUILTIN_METRICS = { "auc": {"scorer": "roc_auc", "direction": "maximize"}, "accuracy": {"scorer": "accuracy", "direction": "maximize"}, "precision": {"scorer": "precision", "direction": "maximize"}, "recall": {"scorer": "recall", "direction": "maximize"}, "f1": {"scorer": "f1", "direction": "maximize"}, "logloss": {"scorer": "neg_log_loss", "direction": "maximize"}, "ks": {"scorer": None, "direction": "maximize"}, # 使用自定义计算 "ks_diff": {"scorer": None, "direction": "minimize"}, # KS差异,需要特殊处理 "lift_head": {"scorer": None, "direction": "maximize"}, "lift_tail": {"scorer": None, "direction": "maximize"}, "lift_head_monotonic": {"scorer": None, "direction": "maximize"}, "ks_with_lift_constraint": {"scorer": None, "direction": "maximize"}, "head_ks": {"scorer": None, "direction": "maximize"}, "ks_lift_combined": {"scorer": None, "direction": "maximize"}, "tail_purity_ks": {"scorer": None, "direction": "maximize"}, "approval_bad_rate": {"scorer": None, "direction": "maximize"}, "expected_profit": {"scorer": None, "direction": "maximize"}, } def __init__(self, metric: Union[str, Callable], name: Optional[str] = None, direction: Optional[str] = None): self.metric = metric self._is_builtin = isinstance(metric, str) if self._is_builtin: metric_key = metric.lower() if metric_key not in self.BUILTIN_METRICS: raise ValueError(f"未知的内置指标: {metric},可用指标: {list(self.BUILTIN_METRICS.keys())}") self.name = name or metric_key.upper() self.scorer = self.BUILTIN_METRICS[metric_key]["scorer"] self.direction = direction or self.BUILTIN_METRICS[metric_key]["direction"] else: # 自定义函数 if not callable(metric): raise ValueError("自定义metric必须是可调用的函数") self.name = name or getattr(metric, "__name__", "custom_metric") self.scorer = metric if direction is None: raise ValueError("使用自定义metric时必须指定direction") self.direction = direction def __call__( self, y_true: np.ndarray, y_pred: np.ndarray, y_train: Optional[np.ndarray] = None, y_train_pred: Optional[np.ndarray] = None, ) -> float: """计算指标值. :param y_true: 验证集真实标签 :param y_pred: 验证集预测概率 :param y_train: 训练集真实标签(用于ks_diff) :param y_train_pred: 训练集预测概率(用于ks_diff) :return: 指标值 """ if self._is_builtin and self.metric.lower() == "ks": return _calc_ks(y_true, y_pred) elif self._is_builtin and self.metric.lower() == "ks_diff": if y_train is None or y_train_pred is None: raise ValueError("计算ks_diff需要提供训练集预测结果") _, ks_diff = _calc_ks_with_diff(y_train, y_train_pred, y_true, y_pred) return ks_diff elif self._is_builtin and self.metric.lower() in TuningObjective.BUILTIN_OBJECTIVES: return TuningObjective.get(self.metric.lower())(y_true, y_pred) elif self._is_builtin: # 其他内置指标使用sklearn scorer if self.scorer is None: raise ValueError(f"指标 {self.metric} 没有对应的sklearn scorer") scorer = get_scorer(self.scorer) # sklearn scorer需要estimator,这里直接计算 from sklearn.metrics import get_scorer_names if self.scorer in get_scorer_names(): # 对于可以直接计算的指标 if self.scorer == "roc_auc": return roc_auc_score(y_true, y_pred) # 其他指标需要类别预测 # 这里简化处理,实际使用时可能需要调整 return scorer._score_func(y_true, y_pred > 0.5) return scorer._score_func(y_true, y_pred) else: # 自定义函数 return self.scorer(y_true, y_pred) def __repr__(self): return f"Metric(name='{self.name}', direction='{self.direction}')"
[文档] class ModelTuner: """模型超参数调优器 - 支持单/多目标优化. 基于Optuna实现贝叶斯优化超参数搜索。 支持单目标优化和多目标优化(帕累托最优)。 **参数** :param model_class: 模型类 (如XGBoost) :param search_space: 参数搜索空间,默认None则使用预定义空间 :param fixed_params: 固定参数,不参与搜索 :param metric: 优化指标(决定评估计算逻辑),可选: - 字符串: 'auc', 'ks', 'ks_diff', 'accuracy', 'precision', 'recall', 'f1', 'logloss' - 列表: 多个指标,用于多目标优化,如 ['ks', 'ks_diff'] - 函数: 自定义评估函数,接收(y_true, y_pred)返回float - 列表的函数: 多个自定义函数 :param direction: 优化方向,'maximize'或'minimize',或列表(多目标时) :param metric_names: 指标显示名称列表(仅用于日志/报告/可视化的展示标签, 不参与任何计算逻辑),默认 None 时从 metric 自动推断 (内置字符串取其大写形式,自定义函数取其 ``__name__``)。 与 metric 不重复:metric 决定"算什么",metric_names 只决定"叫什么" :param cv: 交叉验证折数,默认5 :param n_jobs: 当前 trial 中模型可使用的并行任务数,默认-1; trial 本身顺序执行,确保主动中断及时生效并让自适应采样器利用全部历史结果 :param random_state: 随机种子,默认None :param verbose: 是否逐 Trial 输出得分、参数、当前最佳结果及最终摘要,默认False :param early_stopping_rounds: 早停轮数,默认20 :param min_resource: 多目标优化时的最小资源,默认'auto' **搜索空间定义** 搜索空间可使用参数字典,或使用每个维度都设置 ``name`` 的 skopt 维度列表。 内部统一转换并由 Optuna Study 执行: - 整数参数: {'type': 'int', 'low': 1, 'high': 10, 'step': 1} - 浮点参数: {'type': 'float', 'low': 0.01, 'high': 1.0, 'log': True} - 类别参数: {'type': 'categorical', 'choices': ['a', 'b', 'c']} 同时兼容多种超参数框架的入参格式(无需安装对应库),传入后自动归一化: - bayesian-optimization 风格: {'max_depth': (2, 4, int), 'booster': ('gbtree', 'dart')} - scikit-optimize 风格: [Real(1e-3, 0.1, prior='log-uniform', name='learning_rate')] - sklearn 风格: {'C': [0.1, 1, 10]} 或 scipy 分布 {'C': scipy.stats.loguniform(1e-3, 1e1)} - hyperopt 风格: {'learning_rate': loguniform('learning_rate', log(1e-3), log(0.1)), 'penalty': choice('penalty', ['l1', 'l2'])} - optuna 分布对象: {'max_depth': optuna.distributions.IntDistribution(2, 4)} **内部建模经验** 1. XGBoost参数经验: - max_depth: 风控场景通常2-4,防止过拟合 - min_child_weight: 8-256(step 4),越大越保守 - subsample: 0.35-0.85,colsample_bytree: 0.4-0.9 - gamma: 0.0-32.0,reg_lambda: 32.0-128.0(强 L2 正则) - scale_pos_weight: 16.0-32.0(适配低坏率不平衡) - learning_rate: 0.0001-0.01,较小学习率更稳定 - n_estimators: 32-256(step 16) 2. LightGBM参数经验(与 XGBoost 对齐): - num_leaves: 与max_depth相关,受 2**max_depth 上界约束 - max_depth: 风控场景通常2-4,防止过拟合 - learning_rate: 0.0001-0.01,较小学习率更稳定 - min_child_samples: 8-256(step 4) 3. LogisticRegression参数经验: - C: 0.01-32 离散网格,越小正则越强 - penalty: 'l2',class_weight: None/'balanced'/自定义权重字典 - solver: liblinear/sag/lbfgs/newton-cg,max_iter: 16-256 4. 评估指标: - 主要用KS评估模型区分能力 - 同时考虑训练/测试KS差异防止过拟合 **参考样例** >>> from hscredit.core.models import XGBoost, ModelTuner >>> # 单目标:最大化 KS >>> tuner = ModelTuner(XGBoost, metric='ks', direction='maximize', cv=5) >>> tuner.fit(X_train, y_train, n_trials=50) # 返回最佳参数 best_params_ >>> best_model = tuner.get_best_model() # 已使用完整训练集重训 >>> >>> # 多目标:同时优化 KS 与训练/测试 KS 差异(帕累托最优) >>> tuner = ModelTuner( ... XGBoost, ... metric=['ks', 'ks_diff'], ... direction=['maximize', 'minimize'], ... sampler='nsgaii', ... ) >>> tuner.fit(X_train, y_train, n_trials=100) >>> >>> # 自定义搜索空间 >>> space = {'max_depth': {'type': 'int', 'low': 2, 'high': 4}, ... 'learning_rate': {'type': 'float', 'low': 1e-3, 'high': 0.1, 'log': True}} >>> tuner = ModelTuner(XGBoost, search_space=space, metric='auc') **引用** 基于 Optuna 超参数优化框架(默认 TPE 采样器),见 Akiba, T. et al. (2019). *Optuna: A Next-generation Hyperparameter Optimization Framework.* KDD;TPE 见 Bergstra, J. et al. (2011), *Algorithms for Hyper-Parameter Optimization*, NeurIPS。 文档:https://optuna.readthedocs.io/ """ def __init__( self, model_class: Type, search_space: Optional[Any] = None, fixed_params: Optional[Dict[str, Any]] = None, metric: Union[str, Callable, List[Union[str, Callable]]] = "ks", direction: Union[str, List[str]] = "maximize", metric_names: Optional[List[str]] = None, objective: Union[str, Callable, None] = None, objective_kwargs: Optional[Dict[str, Any]] = None, eval_ratios: List[float] = None, trial_points: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, sampler: Union[str, Any, None] = "tpe", sampler_kwargs: Optional[Dict[str, Any]] = None, storage: Optional[str] = None, study_name: Optional[str] = None, load_if_exists: bool = False, target: str = "target", cv: int = 5, n_jobs: int = -1, random_state: Optional[int] = None, verbose: bool = False, early_stopping_rounds: int = 20, points_to_evaluate: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, ): """初始化 ModelTuner. :param objective: 调参优化目标,支持字符串名称(见 TuningObjective.BUILTIN_OBJECTIVES) 或自定义函数 (y_true, y_prob) -> float。 若指定此参数,则覆盖 metric 参数。 支持:'ks' / 'auc' / 'lift_head' / 'lift_tail' / 'lift_head_monotonic' / 'ks_with_lift_constraint' / 'head_ks' :param objective_kwargs: 透传给 TuningObjective 目标函数的额外参数, 如 {'ratio': 0.05, 'penalty': 0.3} :param eval_ratios: 调参过程中额外追踪的 LIFT 覆盖率列表, 如 [0.01, 0.03, 0.05, 0.10],结果记录在 optimization_history_ 中 :param trial_points: 预指定的超参数搜索点,``dict`` 或 ``list[dict]``。 在 fit 创建 study 后通过 ``study.enqueue_trial`` 优先评估这些点 (例如已知的经验最优配置或上一轮调优结果),随后再进行常规采样。 每个 dict 的键应为搜索空间中的参数名,可只指定部分参数(其余由采样器补全)。 也可在实例化后通过 :meth:`enqueue_trials` 追加。 :param points_to_evaluate: Hyperopt 风格的初始搜索点,格式与 ``fmin`` 的 ``points_to_evaluate`` 一致;内部与 ``trial_points`` 一并转换并入队。 :param sampler: 搜索器,支持: - 字符串名称:见 :class:`TuningSampler`,如 'tpe'(默认)/'cmaes'/'random'/ 'gp'/'nsgaii' 等内置采样器,或 'auto'/'hebo'/'smac' 等 optunahub 采样器 - 已实例化的 optuna 采样器对象(直接使用) - None:等价于 'tpe' :param sampler_kwargs: 透传给采样器构造函数的额外参数,如 {'n_startup_trials': 10} :param storage: optuna 存储 URL,如 ``'sqlite:///hscredit_tuning.db'``。 指定后可配合 ``optuna-dashboard sqlite:///hscredit_tuning.db`` 实时查看 调优进度;不指定则使用内存存储(进程结束即丢失)。 :param study_name: study 名称,配合 storage 持久化时用于标识/复用同一 study。 :param load_if_exists: storage 中已存在同名 study 时是否加载续跑,默认False。 """ if not OPTUNA_AVAILABLE: raise ImportError("Optuna未安装,请使用 pip install optuna 安装") self.model_class = model_class self._space_adapter = SearchSpaceAdapter(search_space) self.search_space = self._space_adapter.space self.fixed_params = fixed_params or {} self._validate_lightgbm_leaf_point(self.fixed_params) self.objective = objective self.objective_kwargs = objective_kwargs or {} self.eval_ratios = eval_ratios or [0.01, 0.03, 0.05, 0.10] initial_points = self._normalize_trial_points(trial_points) initial_points.extend(self._normalize_trial_points(points_to_evaluate)) self.trial_points: List[Dict[str, Any]] = [] self._pending_trials: List[Tuple[Dict[str, Any], Optional[Dict[str, Any]], bool]] = [] self._pending_public_trials: List[Tuple[Dict[str, Any], Optional[Dict[str, Any]], bool]] = [] self.sampler = sampler self.sampler_kwargs = sampler_kwargs or {} self.storage = storage self.study_name = study_name self.load_if_exists = load_if_exists self.target = target self.cv = cv self.n_jobs = resolve_n_jobs(n_jobs) self.random_state = random_state self.verbose = verbose self.early_stopping_rounds = early_stopping_rounds # 若指定了 objective(TuningObjective 风格),将其转换为 metric callable if objective is not None: if isinstance(objective, str): objective_key = objective.lower() if objective_key in TuningObjective.BUILTIN_OBJECTIVES: _obj_func = TuningObjective.get(objective_key, **self.objective_kwargs) metric = _obj_func direction = "maximize" metric_names = metric_names or [objective_key] else: # 可能是旧式 metric 字符串,直接透传 metric = objective elif callable(objective): metric = objective direction = "maximize" # 处理metric和direction self._setup_metrics(metric, direction, metric_names) # 存储结果 self.study_ = None self.best_params_ = None self.best_score_ = None self.best_scores_ = None # 多目标时使用 self.optimization_history_ = None self.pareto_front_ = None # 多目标帕累托前沿 # 存储数据信息用于自适应搜索空间 self._n_samples = None self._n_features = None self._is_multi_objective = len(self.metrics) > 1 for point in initial_points: self.enqueue_trial(point) def _setup_metrics( self, metric: Union[str, Callable, List[Union[str, Callable]]], direction: Union[str, List[str]], metric_names: Optional[List[str]], ): """设置评估指标.""" # 统一转换为列表 if not isinstance(metric, list): metrics_list = [metric] else: metrics_list = metric # 处理direction if not isinstance(direction, list): directions_list = [direction] * len(metrics_list) else: if len(direction) != len(metrics_list): raise ValueError("direction列表长度必须与metric列表长度相同") directions_list = direction # 处理metric_names if metric_names is None: metric_names = [None] * len(metrics_list) elif len(metric_names) != len(metrics_list): raise ValueError("metric_names列表长度必须与metric列表长度相同") # 创建Metric对象列表 self.metrics = [] for m, d, name in zip(metrics_list, directions_list, metric_names): if d not in ("maximize", "minimize"): raise ValueError("direction 只能是 'maximize' 或 'minimize'") if isinstance(m, Metric): if m.direction not in ("maximize", "minimize"): raise ValueError("Metric.direction 只能是 'maximize' 或 'minimize'") self.metrics.append(m) else: self.metrics.append(Metric(m, name=name, direction=d)) # 方便访问 self.metric = self.metrics[0] if len(self.metrics) == 1 else self.metrics self.direction = directions_list[0] if len(directions_list) == 1 else directions_list self.directions = [m.direction for m in self.metrics] self.metric_names = [m.name for m in self.metrics] def _check_input( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None ) -> Tuple[Union[np.ndarray, pd.DataFrame], Union[np.ndarray, pd.Series]]: """检查并处理输入数据. 支持两种风格: 1. fit(X, y): sklearn风格,直接使用传入的y 2. fit(df): scorecardpipeline风格,从df中提取target列 :param X: 特征矩阵或包含target的DataFrame :param y: 目标变量,可选 :return: (X, y) 处理后的特征和目标 """ if y is None: # scorecardpipeline风格:从X中提取target if isinstance(X, pd.DataFrame): if self.target not in X.columns: raise ValueError(f"X中不存在目标列 '{self.target}',请检查target参数或传入y") y = X[self.target] X = X.drop(columns=[self.target]) else: raise ValueError("当y为None时,X必须是包含目标列的DataFrame") return X, y
[文档] def fit( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, n_trials: int = 100, timeout: Optional[int] = None, show_progress_bar: bool = True, sample_weight: Optional[np.ndarray] = None, ) -> Dict[str, Any]: """执行超参数调优. 支持两种调用风格: **sklearn风格**:: tuner.fit(X_train, y_train, n_trials=100) **scorecardpipeline风格** (在__init__中指定target):: tuner = ModelTuner(..., target='label') tuner.fit(df) # df包含'label'列 :param X: 特征矩阵,或包含目标列的DataFrame(scorecardpipeline风格) :param y: 目标变量,可选。如果为None,则从X中提取target列 :param n_trials: 搜索次数,默认100 :param timeout: 超时时间(秒),默认None :param show_progress_bar: 是否显示进度条,默认True :param sample_weight: 样本权重,可选 :return: 最佳参数字典 """ # 检查并处理输入 X, y = self._check_input(X, y) # 记录数据信息 self._n_samples = len(X) self._n_features = X.shape[1] if hasattr(X, "shape") else len(X[0]) self._X = X self._y = y self._sample_weight = sample_weight # 如果没有指定搜索空间,使用自适应搜索空间 if self.search_space is None: self.search_space = self._get_adaptive_search_space() self._space_adapter = SearchSpaceAdapter(self.search_space) self.search_space = self._space_adapter.space # 创建采样器(支持 optuna 内置及 optunahub 采样器,见 TuningSampler 码表) sampler = TuningSampler.create(self.sampler, seed=self.random_state, **self.sampler_kwargs) # 公共 study 参数(storage 指定后可用 optuna-dashboard 实时查看进度) common_kwargs = dict(sampler=sampler) if self.storage is not None: common_kwargs.update( storage=self.storage, study_name=self.study_name, load_if_exists=self.load_if_exists, ) if self._is_multi_objective: # 多目标优化 self.study_ = optuna.create_study(directions=self.directions, **common_kwargs) else: # 单目标优化 self.study_ = optuna.create_study(direction=self.directions[0], **common_kwargs) # 入队预指定的超参数搜索点(优先评估) self._enqueue_trial_points() # Optuna 的并行 trial 使用线程池。Jupyter 主线程收到中断后, # 线程池会等待正在训练的 trial 完成,导致主动中断不能立即返回。 # trial 顺序执行还可确保自适应采样器利用此前全部完成结果; # 调参总预算全部交给当前模型的原生并行参数。 model_workers = max(1, int(self.n_jobs or 1)) def objective(trial): # 从搜索空间采样参数 params = self._sample_params(trial) params.update(self.fixed_params) params = self._apply_model_param_constraints(params) # 添加早停参数(仅当模型构造函数支持时,逻辑回归等不支持) self._inject_fit_params(params) self._inject_model_parallel_budget(params, model_workers) # 创建模型 model = self.model_class(**params) # 评估模型 return self._evaluate_model(model, X, y, sample_weight) # 运行优化 self.study_.optimize( objective, n_trials=n_trials, timeout=timeout, show_progress_bar=show_progress_bar and self.verbose, n_jobs=1, callbacks=[self._print_trial_progress] if self.verbose else None, catch=(Exception,), ) completed_trials = [ trial for trial in self.study_.trials if trial.state == optuna.trial.TrialState.COMPLETE and trial.values is not None ] if not completed_trials: raise ValueError("所有Trial均失败,请检查模型参数、数据和训练异常") # 保存结果 self._save_results() self.optimization_history_ = self._build_public_history() if self.verbose: self._print_tuning_summary() return self.best_params_
def _format_scores(self, values: Optional[Sequence[float]]) -> str: """将单目标或多目标得分格式化为稳定、易读的日志文本。""" if values is None: return "不可用" formatted = [] for name, value in zip(self.metric_names, values): score = "不可用" if value is None else f"{float(value):.6f}" formatted.append(f"{name}={score}") return ", ".join(formatted) if formatted else "不可用" def _print_trial_progress(self, study: Any, trial: Any) -> None: """在 Trial 结束后立即输出本次结果和当前最佳结果。""" params = self._get_params_from_trial(trial) if trial.state != optuna.trial.TrialState.COMPLETE or trial.values is None: print(f"[调参] Trial {trial.number} {trial.state.name} | 参数: {params}", flush=True) return if self._is_multi_objective: best_trial = self._select_best_pareto_trial(study.best_trials) else: best_trial = study.best_trial print( f"[调参] Trial {trial.number} 完成 | 得分: {self._format_scores(trial.values)} | " f"参数: {params} | 当前最佳: {self._format_scores(best_trial.values)} " f"(Trial {best_trial.number})", flush=True, ) def _print_tuning_summary(self) -> None: """在调参正常完成并保存结果后输出最终摘要。""" completed_trials = sum(trial.state == optuna.trial.TrialState.COMPLETE for trial in self.study_.trials) print(f"[调参] 调参完成 | 完成 Trial: {completed_trials}", flush=True) if self._is_multi_objective: print(f"[调参] 帕累托最优解: {len(self.study_.best_trials)}", flush=True) print(f"[调参] 最佳得分: {self._format_scores(self.best_scores_)}", flush=True) print(f"[调参] 最佳参数: {self.best_params_}", flush=True) def _inject_model_parallel_budget(self, params: Dict[str, Any], workers: int) -> None: """把调参总预算写入当前模型公开的最外层原生并行参数。""" try: signature = inspect.signature(self.model_class.__init__) except (TypeError, ValueError, AttributeError): return for parameter_name in ("n_jobs", "thread_count", "num_workers"): if parameter_name not in signature.parameters: continue configured = params.get(parameter_name) if configured is None or configured == -1: params[parameter_name] = workers else: try: params[parameter_name] = min(max(1, int(configured)), workers) except (TypeError, ValueError): params[parameter_name] = workers return def _evaluate_model( self, model: Any, X: Union[np.ndarray, pd.DataFrame], y: Union[np.ndarray, pd.Series], sample_weight: Optional[np.ndarray] = None, ) -> Union[float, Tuple[float, ...]]: """评估模型,返回一个或多个指标值.""" kf = StratifiedKFold(n_splits=self.cv, shuffle=True, random_state=self.random_state) fold_results = {i: [] for i in range(len(self.metrics))} for train_idx, val_idx in kf.split(X, y): X_train_fold, X_val_fold = _safe_index(X, train_idx), _safe_index(X, val_idx) y_train_fold, y_val_fold = _safe_index(y, train_idx), _safe_index(y, val_idx) sample_weight_fold = _safe_index(sample_weight, train_idx) try: fold_model = clone(model) except Exception: fold_model = copy.deepcopy(model) if sample_weight_fold is None: fold_model.fit(X_train_fold, y_train_fold) else: fold_model.fit(X_train_fold, y_train_fold, sample_weight=sample_weight_fold) y_train_pred = fold_model.predict_proba(X_train_fold)[:, 1] y_val_pred = fold_model.predict_proba(X_val_fold)[:, 1] for i, metric in enumerate(self.metrics): y_val_arr = y_val_fold.values if hasattr(y_val_fold, "values") else np.asarray(y_val_fold) y_train_arr = y_train_fold.values if hasattr(y_train_fold, "values") else np.asarray(y_train_fold) value = metric(y_val_arr, y_val_pred, y_train=y_train_arr, y_train_pred=y_train_pred) fold_results[i].append(value) results = [np.mean(fold_results[i]) for i in range(len(self.metrics))] if self._is_multi_objective: return tuple(results) return results[0] def _save_results(self): """保存优化结果.""" if self._is_multi_objective: # 多目标优化 self.pareto_front_ = self.study_.best_trials # 在帕累托前沿中按指标顺序做确定性选择:优先第一个主指标, # 主指标相同时再按后续指标方向排序。 best_trial = self._select_best_pareto_trial(self.study_.best_trials) self.best_params_ = self._get_params_from_trial(best_trial) self.best_scores_ = list(best_trial.values) self.best_score_ = self.best_scores_[0] # 第一个指标作为主指标 else: # 单目标优化 self.best_params_ = self._get_params_from_trial(self.study_.best_trial) self.best_score_ = self.study_.best_value self.best_scores_ = [self.best_score_] self.best_params_.update(self.fixed_params) self.best_params_ = self._apply_model_param_constraints(self.best_params_) def _select_best_pareto_trial(self, trials: Sequence[Any]) -> Any: """从帕累托前沿按主指标优先规则选择一个默认最优 trial.""" if not trials: raise ValueError("没有可用的帕累托最优解") def sort_key(trial): values = trial.values or [] key = [] for value, direction in zip(values, self.directions): if value is None: adjusted = float("-inf") if direction == "maximize" else float("inf") else: adjusted = value if direction == "maximize" else -value key.append(adjusted) # trial.number 取负值,让完全同分时选择更早完成的 trial。 key.append(-trial.number) return tuple(key) return max(trials, key=sort_key)
[文档] def evaluate_trials( self, X: Union[np.ndarray, pd.DataFrame], y: Optional[Union[np.ndarray, pd.Series]] = None, trial_points: Optional[List[Dict[str, Any]]] = None, sample_weight: Optional[np.ndarray] = None, ) -> pd.DataFrame: """评估指定超参数点的模型效果. 无需运行完整调优,直接评估给定超参数配置的性能。 支持两种调用风格: **sklearn风格**:: results = tuner.evaluate_trials(X_train, y_train, trial_points) **scorecardpipeline风格** (在__init__中指定target):: tuner = ModelTuner(..., target='label') results = tuner.evaluate_trials(df, trial_points=trial_points) :param X: 特征矩阵,或包含目标列的DataFrame(scorecardpipeline风格) :param y: 目标变量,可选。如果为None,则从X中提取target列 :param trial_points: 超参数点列表,每个点是一个参数字典 :param sample_weight: 样本权重,可选 :return: 包含评估结果的DataFrame """ # 检查trial_points if trial_points is None: raise ValueError("trial_points不能为空,请提供要评估的超参数点列表") # 检查并处理输入 X, y = self._check_input(X, y) results = [] for i, params in enumerate(trial_points): if self.verbose: logger.info(f"评估 trial point {i+1}/{len(trial_points)}: {params}") # 合并固定参数 full_params = dict(params) full_params.update(self.fixed_params) full_params = self._apply_model_param_constraints(full_params) self._inject_fit_params(full_params) # 创建模型并评估 model = self.model_class(**full_params) metric_values = self._evaluate_model(model, X, y, sample_weight) if self._is_multi_objective: result = {"trial_id": i, **params, **{name: val for name, val in zip(self.metric_names, metric_values)}} else: result = {"trial_id": i, **params, self.metric_names[0]: metric_values} results.append(result) return pd.DataFrame(results)
[文档] def evaluate_study_trials( self, trial_indices: Optional[Union[int, Sequence[int]]] = None, X: Optional[Union[np.ndarray, pd.DataFrame]] = None, y: Optional[Union[np.ndarray, pd.Series]] = None, sample_weight: Optional[np.ndarray] = None, ) -> pd.DataFrame: """评估已完成 study 中指定 trial 的模型效果. 从 ``self.study_.trials[i]`` 取出对应超参数重新评估,便于复核某次 采样的稳定性、或在新数据集上对比若干历史 trial 的效果。 与 :meth:`evaluate_trials` 的区别:本方法的超参数来自已学习完成的 study(按 trial 索引取),而非外部传入的参数点;结果额外包含每个 trial 的索引、状态及 study 记录的原始得分(``study记录值`` 列),便于与重新 评估的得分对照。 :param trial_indices: 要评估的 trial 索引,可选: - None: 评估全部已完成(COMPLETE)的 trial - int: 评估单个 trial,如 ``0`` 或 ``tuner.study_.best_trial.number`` - 序列: 评估多个 trial,如 ``[0, 5, 10]`` :param X: 特征矩阵,或包含目标列的DataFrame;默认复用 fit 时的训练数据 :param y: 目标变量,可选;默认复用 fit 时的标签 :param sample_weight: 样本权重,可选;默认复用 fit 时的样本权重 :return: 包含评估结果的DataFrame,含 ``trial索引``/``trial状态``/超参数/ 重新评估指标/``study记录值`` 列 Example: >>> tuner.fit(X_train, y_train, n_trials=100) >>> # 评估最优 trial 与前两个 trial >>> tuner.evaluate_study_trials([tuner.study_.best_trial.number, 0, 1]) >>> # 在新数据集上复核全部 trial >>> tuner.evaluate_study_trials(X=X_oot, y=y_oot) """ if self.study_ is None: raise ValueError("请先调用fit()进行调优,再评估study中的trial") all_trials = self.study_.trials n_trials = len(all_trials) # 归一化 trial_indices if trial_indices is None: indices = [t.number for t in all_trials if t.state == optuna.trial.TrialState.COMPLETE] if not indices: raise ValueError("study中没有已完成(COMPLETE)的trial可供评估") elif isinstance(trial_indices, int): indices = [trial_indices] else: indices = list(trial_indices) # 校验索引合法性 for idx in indices: if not isinstance(idx, (int, np.integer)): raise ValueError(f"trial索引必须为整数,收到: {idx!r}") if idx < 0 or idx >= n_trials: raise ValueError(f"trial索引 {idx} 超出范围,study共有 {n_trials} 个trial(有效索引 0~{n_trials - 1})") # 默认复用 fit 时的数据 if X is None: if getattr(self, "_X", None) is None: raise ValueError("未提供X且fit时未缓存训练数据,请显式传入X/y") X, y = self._X, self._y if sample_weight is None: sample_weight = getattr(self, "_sample_weight", None) else: X, y = self._check_input(X, y) results = [] for idx in indices: trial = all_trials[idx] params = self._get_params_from_trial(trial) if self.verbose: logger.info(f"评估 study trial #{idx} (state={trial.state.name}): {params}") # 合并固定参数并按模型签名注入早停参数 full_params = dict(params) full_params.update(self.fixed_params) full_params = self._apply_model_param_constraints(full_params) self._inject_fit_params(full_params) # 创建模型并评估 model = self.model_class(**full_params) metric_values = self._evaluate_model(model, X, y, sample_weight) # study 记录的原始得分(用于与重新评估结果对照) recorded = list(trial.values) if trial.values is not None else None result = {"trial索引": idx, "trial状态": trial.state.name, **params} if self._is_multi_objective: result.update({name: val for name, val in zip(self.metric_names, metric_values)}) if recorded is not None: result["study记录值"] = recorded else: result[self.metric_names[0]] = metric_values if recorded is not None: result["study记录值"] = recorded[0] results.append(result) return pd.DataFrame(results)
def _get_params_from_trial(self, trial) -> Dict[str, Any]: """从trial中获取参数.""" return self._apply_model_param_constraints(self._space_adapter.public_params(trial)) def _build_public_history(self) -> pd.DataFrame: """生成只包含模型最终参数名和值的 Optuna 历史表。""" history = self.study_.trials_dataframe() latent_columns = [column for column in history if column.startswith("params___hscredit__")] history = history.drop(columns=latent_columns, errors="ignore") for name in self.search_space: column = f"params_{name}" values = [] for trial in self.study_.trials: params = self._space_adapter.public_params(trial) params.update(self.fixed_params) values.append(self._apply_model_param_constraints(params).get(name)) history[column] = values return history def _get_adaptive_search_space(self) -> Dict[str, Dict[str, Any]]: """根据数据特征获取自适应搜索空间. 基于内部建模经验,根据样本量和特征数调整搜索范围。 """ # 获取模型类型 model_name = self.model_class.__name__.lower() if "xgboost" in model_name or "xgb" in model_name: return self._get_xgboost_search_space() elif "lightgbm" in model_name or "lgb" in model_name: return self._get_lightgbm_search_space() elif "catboost" in model_name or "cat" in model_name: return self._get_catboost_search_space() elif "randomforest" in model_name or "extratrees" in model_name or "rf" in model_name: # ExtraTrees 与 RandomForest 参数一致,共用搜索空间 return self._get_randomforest_search_space() elif "gradientboosting" in model_name or "gbdt" in model_name: return self._get_gradientboosting_search_space() elif "ngboost" in model_name or "ngb" in model_name: return self._get_ngboost_search_space() elif "logistic" in model_name or model_name in ("lr",): return self._get_logisticregression_search_space() elif model_name == "svm": return self._get_svm_search_space() elif model_name == "decisiontreeclassifier": return self._get_decisiontree_search_space() else: # 默认使用XGBoost搜索空间 return self._get_xgboost_search_space() def _get_xgboost_search_space(self) -> Dict[str, Dict[str, Any]]: """XGBoost搜索空间 - 基于内部建模经验. 参考内部代码(强正则、浅树、小学习率以抑制风控样本过拟合): - max_depth: 风控场景通常2-4,防止过拟合 - min_child_weight: 8-256(step 4),叶子最小样本权重,越大越保守 - subsample: 0.35-0.85,行采样 - colsample_bytree: 0.4-0.9,列采样 - gamma: 0.0-32.0,分裂最小损失下降,越大越保守 - scale_pos_weight: 16.0-32.0,正样本权重(适配低坏率不平衡场景) - reg_alpha: 0.0-1.0(L1 正则) - reg_lambda: 32.0-128.0(L2 正则,强约束) - learning_rate: 0.0001-0.01,较小学习率更稳定 - n_estimators: 32-256(step 16) 固定项 ``objective='binary:logistic'`` / ``eval_metric='auc'`` / ``booster='gbtree'`` / ``importance_type='cover'`` 已是模型默认值, 如需覆盖可通过 ``ModelTuner(fixed_params=...)`` 传入。 """ return { "max_depth": {"type": "int", "low": 2, "high": 4}, "learning_rate": {"type": "float", "low": 0.0001, "high": 0.01}, "n_estimators": {"type": "int", "low": 32, "high": 256, "step": 16}, "min_child_weight": {"type": "int", "low": 8, "high": 256, "step": 4}, "subsample": {"type": "float", "low": 0.35, "high": 0.85}, "colsample_bytree": {"type": "float", "low": 0.4, "high": 0.9}, "gamma": {"type": "float", "low": 0.0, "high": 32.0}, "scale_pos_weight": {"type": "float", "low": 16.0, "high": 32.0}, "reg_alpha": {"type": "float", "low": 0.0, "high": 1.0}, "reg_lambda": {"type": "float", "low": 32.0, "high": 128.0}, } def _get_lightgbm_search_space(self) -> Dict[str, Dict[str, Any]]: """LightGBM搜索空间 - 与XGBoost搜索空间对齐. 参考内部代码(参数范围与 XGBoost 保持一致的建模经验): - num_leaves: 与max_depth相关,受 ``2**max_depth`` 上界约束(见 _sample_params) - max_depth: 风控场景通常2-4,防止过拟合 - min_child_samples: 8-256(step 4),叶子最小样本数,越大越保守 - subsample: 0.35-0.85,行采样 - colsample_bytree: 0.4-0.9,列采样 - min_split_gain: 0.0-32.0,分裂最小增益(对应 XGBoost 的 gamma) - scale_pos_weight: 16.0-32.0,正样本权重 - reg_alpha: 0.0-1.0(L1 正则) - reg_lambda: 32.0-128.0(L2 正则,强约束) - learning_rate: 0.0001-0.01,较小学习率更稳定 - n_estimators: 32-256(step 16) """ return { "num_leaves": {"type": "int", "low": 8, "high": 64}, "max_depth": {"type": "int", "low": 2, "high": 4}, "learning_rate": {"type": "float", "low": 0.0001, "high": 0.01}, "n_estimators": {"type": "int", "low": 32, "high": 256, "step": 16}, "min_child_samples": {"type": "int", "low": 8, "high": 256, "step": 4}, "subsample": {"type": "float", "low": 0.35, "high": 0.85}, "colsample_bytree": {"type": "float", "low": 0.4, "high": 0.9}, "min_split_gain": {"type": "float", "low": 0.0, "high": 32.0}, "scale_pos_weight": {"type": "float", "low": 16.0, "high": 32.0}, "reg_alpha": {"type": "float", "low": 0.0, "high": 1.0}, "reg_lambda": {"type": "float", "low": 32.0, "high": 128.0}, } def _get_logisticregression_search_space(self) -> Dict[str, Dict[str, Any]]: """逻辑回归搜索空间 - 基于内部建模经验. 参考内部代码: - C: 正则强度倒数,对数区间 0.01-32(越小正则越强) - penalty: 仅 'l2'(评分卡常用,兼容多数 solver) - class_weight: None / 'balanced' / 自定义正负样本权重字典(适配不平衡场景) - max_iter: 16-256(对数区间),迭代上限 - solver: liblinear / sag / lbfgs / newton-cg .. note:: ``class_weight`` 的字典候选会触发 optuna 关于非基础类型 categorical 的 提示(内存存储下可正常工作);若需持久化 study,可改用 None/'balanced'。 """ return { "C": {"type": "float", "low": 0.01, "high": 32.0, "log": True}, "penalty": {"type": "categorical", "choices": ["l2"]}, "class_weight": { "type": "categorical", "choices": [None, "balanced"] + [{1: i / 10.0, 0: 1 - i / 10.0} for i in range(1, 10, 2)], }, "max_iter": {"type": "int", "low": 16, "high": 256, "log": True}, "solver": { "type": "categorical", "choices": ["liblinear", "sag", "lbfgs", "newton-cg"], }, } def _get_svm_search_space(self) -> Dict[str, Dict[str, Any]]: """SVC 搜索空间,始终保留 probability=True 的模型固定契约。""" return { "C": {"type": "float", "low": 1e-3, "high": 1e3, "log": True}, "kernel": {"type": "categorical", "choices": ["rbf", "linear", "poly", "sigmoid"]}, "gamma": {"type": "categorical", "choices": ["scale", "auto"]}, "degree": {"type": "int", "low": 2, "high": 5}, "coef0": {"type": "float", "low": 0.0, "high": 1.0}, } def _get_decisiontree_search_space(self) -> Dict[str, Dict[str, Any]]: """sklearn 决策树搜索空间。""" return { "criterion": {"type": "categorical", "choices": ["gini", "entropy"]}, "max_depth": {"type": "int", "low": 2, "high": 12}, "min_samples_split": {"type": "int", "low": 2, "high": 30}, "min_samples_leaf": {"type": "int", "low": 1, "high": 20}, "max_features": {"type": "categorical", "choices": ["sqrt", "log2", None]}, "ccp_alpha": {"type": "float", "low": 0.0, "high": 0.05}, } def _get_catboost_search_space(self) -> Dict[str, Dict[str, Any]]: """CatBoost搜索空间 - 基于风控场景优化. 参考内部代码: - depth: 风控场景通常2-5,防止过拟合 - learning_rate: 0.005-0.1,较小学习率更稳定 - iterations: 50-500 - l2_leaf_reg: 1e-8到10 """ return { "depth": {"type": "int", "low": 2, "high": 5}, "learning_rate": {"type": "float", "low": 0.005, "high": 0.1, "log": True}, "iterations": {"type": "int", "low": 50, "high": 500}, "l2_leaf_reg": {"type": "float", "low": 1e-8, "high": 10.0, "log": True}, "border_count": {"type": "int", "low": 32, "high": 255}, "random_strength": {"type": "float", "low": 0.0, "high": 10.0}, } def _get_randomforest_search_space(self) -> Dict[str, Dict[str, Any]]: """RandomForest搜索空间 - 基于内部建模经验. 参考内部代码: - max_depth: 风控场景通常2-5,防止过拟合 - n_estimators: 根据样本量调整 """ n_samples = self._n_samples or 10000 # 根据样本量调整n_estimators if n_samples > 10000: n_estimators_high = 500 n_estimators_low = 100 else: n_estimators_high = 200 n_estimators_low = 50 return { "n_estimators": {"type": "int", "low": n_estimators_low, "high": n_estimators_high}, "max_depth": {"type": "int", "low": 2, "high": 5}, "min_samples_split": {"type": "int", "low": 2, "high": 20}, "min_samples_leaf": {"type": "int", "low": 1, "high": 10}, "max_features": {"type": "categorical", "choices": ["sqrt", "log2", None]}, } def _get_ngboost_search_space(self) -> Dict[str, Dict[str, Any]]: """NGBoost搜索空间 - 基于风控场景优化. NGBoost 使用 CART 作为基学习器,参数名与其他 boosting 不同: - n_estimators: 自然梯度提升轮数,较小学习率需更多轮 - learning_rate: 0.005-0.1,较小学习率更稳定 - base_max_depth: 基学习器(CART)最大深度,风控场景通常2-4 - minibatch_frac: 小批量采样比例(行采样) - col_sample: 特征采样比例 """ n_samples = self._n_samples or 10000 if n_samples > 10000: n_estimators_low, n_estimators_high = 200, 800 else: n_estimators_low, n_estimators_high = 100, 500 return { "n_estimators": {"type": "int", "low": n_estimators_low, "high": n_estimators_high}, "learning_rate": {"type": "float", "low": 0.005, "high": 0.1, "log": True}, "base_max_depth": {"type": "int", "low": 2, "high": 4}, "minibatch_frac": {"type": "float", "low": 0.5, "high": 1.0}, "col_sample": {"type": "float", "low": 0.5, "high": 1.0}, } def _get_gradientboosting_search_space(self) -> Dict[str, Dict[str, Any]]: """GradientBoosting搜索空间 - 基于风控场景优化. 参考内部代码: - max_depth: 风控场景通常2-5,防止过拟合 - learning_rate: 0.005-0.1,较小学习率更稳定 """ return { "n_estimators": {"type": "int", "low": 50, "high": 300}, "learning_rate": {"type": "float", "low": 0.005, "high": 0.1, "log": True}, "max_depth": {"type": "int", "low": 2, "high": 5}, "min_samples_split": {"type": "int", "low": 2, "high": 20}, "min_samples_leaf": {"type": "int", "low": 1, "high": 10}, "subsample": {"type": "float", "low": 0.6, "high": 1.0}, } @staticmethod def _normalize_trial_points( trial_points: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] ) -> List[Dict[str, Any]]: """将 trial_points 归一化为 list[dict]. :param trial_points: ``None`` / 单个 dict / list[dict] :return: 参数点列表(可能为空) """ if trial_points is None: return [] if isinstance(trial_points, dict): return [dict(trial_points)] if isinstance(trial_points, (list, tuple)): for p in trial_points: if not isinstance(p, dict): raise ValueError(f"trial_points 中每个元素必须为 dict,收到: {type(p).__name__}") return [dict(p) for p in trial_points] raise ValueError(f"trial_points 必须为 dict 或 list[dict],收到: {type(trial_points).__name__}")
[文档] def enqueue_trial( self, params: Dict[str, Any], user_attrs: Optional[Dict[str, Any]] = None, skip_if_exists: bool = False, ) -> "ModelTuner": """按 Optuna ``Study.enqueue_trial`` 风格追加一个手工搜索点。 ``params`` 使用模型最终参数名和值。若某一声明需要内部潜变量采样,本方法 会先完成逆变换,再把内部参数传给 Study;公开记录仍保留最终值。 """ public_point = dict(params) attrs = dict(user_attrs) if user_attrs is not None else None point_with_fixed = dict(public_point) point_with_fixed.update(self.fixed_params) self._validate_lightgbm_leaf_point(point_with_fixed) self.trial_points.append(public_point) if self.search_space is None: self._pending_public_trials.append((public_point, attrs, bool(skip_if_exists))) return self internal_point = self._space_adapter.to_internal_point(public_point) if self.study_ is not None: self.study_.enqueue_trial(internal_point, user_attrs=attrs, skip_if_exists=skip_if_exists) if self.verbose: logger.info(f"已入队手工搜索点: {public_point}") else: self._pending_trials.append((internal_point, attrs, bool(skip_if_exists))) return self
def _ordered_point(self, values: Sequence[Any], source: str) -> Dict[str, Any]: """按搜索空间声明顺序把序列点转换为参数字典。""" values = list(values) names = self._space_adapter.names if len(values) != len(names): raise ValueError(f"{source} 搜索点维度数量为 {len(values)},搜索空间要求 {len(names)}") return dict(zip(names, values))
[文档] def enqueue_trials( self, trial_points: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, *, param_grid: Optional[Union[Dict[str, Sequence[Any]], List[Dict[str, Sequence[Any]]]]] = None, x0: Optional[Sequence[Any]] = None, user_attrs: Optional[Dict[str, Any]] = None, skip_if_exists: bool = False, ) -> "ModelTuner": """按 Optuna、GridSearch 或 skopt 格式追加一个或多个搜索点。 若 study 已创建(已调用过 fit),则立即通过 ``study.enqueue_trial`` 入队, 在后续 ``fit`` 的采样中优先评估;否则缓存到 ``self.trial_points``, 在下次 ``fit`` 创建 study 后入队。 :param trial_points: Optuna/hscredit 格式,``dict`` 或 ``list[dict]`` :param param_grid: GridSearch 格式,由 ``ParameterGrid`` 展开 :param x0: skopt 格式,单个值序列或多个值序列,顺序与搜索空间一致 :return: self,便于链式调用 """ supplied = sum(value is not None for value in (trial_points, param_grid, x0)) if supplied != 1: raise ValueError("enqueue_trials 必须且只能提供 trial_points、param_grid 或 x0 中的一项") if param_grid is not None: points = [dict(point) for point in ParameterGrid(param_grid)] elif x0 is not None: raw = list(x0) if not raw: raise ValueError("x0 不能为空") first = raw[0] if isinstance(first, (list, tuple, np.ndarray)): points = [self._ordered_point(row, "x0") for row in raw] else: points = [self._ordered_point(raw, "x0")] else: points = self._normalize_trial_points(trial_points) for point in points: self.enqueue_trial(point, user_attrs=user_attrs, skip_if_exists=skip_if_exists) return self
[文档] def probe( self, params: Union[Dict[str, Any], Sequence[Any]], lazy: bool = True, ) -> "ModelTuner": """按 bayesian-optimization ``probe`` 风格追加一个搜索点。 ``lazy`` 为兼容原方法保留;Optuna 后端无立即执行单点的等价操作,因此 ``True`` 与 ``False`` 都会进入同一个 Study 队列,并在下一次 optimize 时执行。 """ del lazy point = dict(params) if isinstance(params, dict) else self._ordered_point(params, "probe") return self.enqueue_trial(point)
def _enqueue_trial_points(self) -> None: """将 self.trial_points 入队到当前 study(fit 内部调用).""" for public_point, user_attrs, skip_if_exists in self._pending_public_trials: internal_point = self._space_adapter.to_internal_point(public_point) self._pending_trials.append((internal_point, user_attrs, skip_if_exists)) self._pending_public_trials.clear() for point, user_attrs, skip_if_exists in self._pending_trials: self.study_.enqueue_trial(point, user_attrs=user_attrs, skip_if_exists=skip_if_exists) if self.verbose: logger.info(f"已入队预指定手工搜索点: {point}") self._pending_trials.clear() def _inject_fit_params(self, params: Dict[str, Any]) -> None: """按模型构造函数签名注入早停/验证集参数(原地修改 params). Boosting 模型(XGBoost/LightGBM/CatBoost 等)将 ``early_stopping_rounds`` 与 ``validation_fraction`` 声明为显式构造参数,注入可启用调参过程中的早停; 而逻辑回归、sklearn 集成模型(RandomForest/ExtraTrees)等不支持这些参数, 直接注入会触发 TypeError。 仅当参数是模型构造函数**显式声明**的命名参数时才注入:不依赖 ``**kwargs``, 因为 SklearnRiskModel 子类虽有 ``**kwargs`` 但会在内部硬编码 ``early_stopping_rounds=None`` 转发,经 ``**kwargs`` 再次传入会导致 "multiple values for keyword argument" 冲突。 :param params: 待注入的参数字典,将被原地更新 """ import inspect try: accepted = set(inspect.signature(self.model_class.__init__).parameters) except (TypeError, ValueError): accepted = set() fit_params = { "early_stopping_rounds": self.early_stopping_rounds, "validation_fraction": 0.2, } for name, value in fit_params.items(): if name in accepted: params.setdefault(name, value) def _sample_params(self, trial: "Trial") -> Dict[str, Any]: """从搜索空间采样参数. :param trial: Optuna trial对象 :return: 参数字典 """ params = self._space_adapter.sample(trial) return self._apply_model_param_constraints(params) def _uses_lightgbm_leaf_constraint(self) -> bool: """当前模型是否使用 LightGBM 的叶子数/深度约束。""" model_name = getattr(self.model_class, "__name__", "").lower() return "lightgbm" in model_name or "lgbm" in model_name def _leaf_limit(self, params: Dict[str, Any]) -> Optional[int]: """根据正的整数 max_depth 计算 LightGBM num_leaves 上限。""" if not self._uses_lightgbm_leaf_constraint(): return None max_depth = params.get("max_depth") if isinstance(max_depth, (bool, np.bool_)) or not isinstance(max_depth, (int, np.integer)): return None if max_depth <= 0: return None return 2 ** int(max_depth) def _apply_model_param_constraints(self, params: Dict[str, Any]) -> Dict[str, Any]: """把模型关联约束应用到最终模型参数,不改变 Optuna 的稳定搜索分布。""" constrained = dict(params) limit = self._leaf_limit(constrained) num_leaves = constrained.get("num_leaves") if ( limit is not None and isinstance(num_leaves, (int, np.integer)) and not isinstance(num_leaves, (bool, np.bool_)) ): constrained["num_leaves"] = min(int(num_leaves), limit) return constrained def _validate_lightgbm_leaf_point(self, params: Dict[str, Any]) -> None: """拒绝显式给出的无效 LightGBM 深度/叶子数组合。""" limit = self._leaf_limit(params) num_leaves = params.get("num_leaves") if limit is not None and isinstance(num_leaves, (int, np.integer)) and num_leaves > limit: raise ValueError(f"LightGBM 手工搜索点 num_leaves={num_leaves} 不能大于 " f"2**max_depth={limit}") def _sample_normal(self, trial: "Trial", param_name: str, param_config: Dict[str, Any]) -> float: """从截断正态/对数正态分布采样(hyperopt normal/lognormal 近似)。 optuna 无原生正态采样,在 [0,1] 均匀采样后经逆 CDF 变换为目标分布, 保证 optuna 可记录与复现。截断区间取 [mu-4σ, mu+4σ],log 时为对数空间。 :param trial: Optuna trial 对象 :param param_name: 参数名 :param param_config: 'normal' DSL 配置(含 mu/sigma/low/high/q/log) :return: 采样值 """ return self._space_adapter.sample_one(trial, param_name, param_config)
[文档] def get_best_model(self) -> Any: """获取使用最佳参数的模型实例. :return: 训练好的模型实例 """ if self.best_params_ is None: raise ValueError("请先调用fit()进行调优") model = self.model_class(**self.best_params_) if self._sample_weight is None: model.fit(self._X, self._y) else: model.fit(self._X, self._y, sample_weight=self._sample_weight) return model
[文档] def get_optimization_history(self) -> pd.DataFrame: """获取优化历史. :return: 优化历史DataFrame """ if self.optimization_history_ is None: raise ValueError("请先调用fit()进行调优") return self.optimization_history_
[文档] def get_pareto_front(self) -> Optional[List]: """获取帕累托前沿(多目标优化时). :return: 帕累托前沿上的trial列表 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") if not self._is_multi_objective: raise ValueError("单目标优化没有帕累托前沿") return self.study_.best_trials
def _resolve_multi_objective_target(self, target: Optional[int]) -> Optional[int]: """多目标分析图/重要性默认使用第一个指标,并校验索引范围.""" if not self._is_multi_objective: return target if target is None: return 0 if not isinstance(target, (int, np.integer)): raise ValueError("target 必须是指标索引整数") if target < 0 or target >= len(self.metric_names): raise ValueError(f"target 超出范围,多目标指标索引有效范围为 0~{len(self.metric_names) - 1}") return int(target)
[文档] def get_param_importance(self, target: Optional[int] = None) -> Optional[pd.Series]: """获取参数重要性. :param target: 多目标时指定要分析的指标索引,默认第一个 :return: 参数重要性Series """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") try: target = self._resolve_multi_objective_target(target) if self._is_multi_objective: # 多目标优化时,可以指定特定目标 importance = optuna.importance.get_param_importances(self.study_, target=lambda t: t.values[target]) else: importance = optuna.importance.get_param_importances(self.study_) return pd.Series(importance) except Exception as e: if self.verbose: warnings.warn(f"无法计算参数重要性: {e}") return None
# ==================== 可视化方法 ====================
[文档] def plot_optimization_history(self, target: Optional[int] = None, **kwargs): """绘制优化历史. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) if self._is_multi_objective: return optuna.visualization.plot_optimization_history( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) return optuna.visualization.plot_optimization_history(self.study_, **kwargs)
[文档] def plot_param_importances(self, target: Optional[int] = None, **kwargs): """绘制参数重要性. :param target: 多目标时指定要分析的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) if self._is_multi_objective: return optuna.visualization.plot_param_importances( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) return optuna.visualization.plot_param_importances(self.study_, **kwargs)
[文档] def plot_slice(self, target: Optional[int] = None, **kwargs): """绘制参数切片图. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) if self._is_multi_objective: return optuna.visualization.plot_slice( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) return optuna.visualization.plot_slice(self.study_, **kwargs)
[文档] def plot_pareto_front(self, **kwargs): """绘制帕累托前沿(多目标优化时). :param kwargs: 绘图参数 :return: plotly图形对象 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") if not self._is_multi_objective: raise ValueError("只有多目标优化才能绘制帕累托前沿") return optuna.visualization.plot_pareto_front(self.study_, target_names=self.metric_names, **kwargs)
[文档] def plot_contour(self, params: Optional[List[str]] = None, target: Optional[int] = None, **kwargs): """绘制参数等高线图. :param params: 要绘制的参数列表,默认前两个 :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") if params is None: params = list(self.search_space.keys())[:2] target = self._resolve_multi_objective_target(target) if self._is_multi_objective: return optuna.visualization.plot_contour( self.study_, params=params, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs, ) return optuna.visualization.plot_contour(self.study_, params=params, **kwargs)
[文档] def plot_parallel_coordinate(self, target: Optional[int] = None, **kwargs): """绘制平行坐标图. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) if self._is_multi_objective: return optuna.visualization.plot_parallel_coordinate( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) return optuna.visualization.plot_parallel_coordinate(self.study_, **kwargs)
[文档] def plot_edf(self, target: Optional[int] = None, **kwargs): """绘制经验分布函数图. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) if self._is_multi_objective: return optuna.visualization.plot_edf( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) return optuna.visualization.plot_edf(self.study_, **kwargs)
[文档] class AutoTuner: """自动调优器 - 基于内部建模经验. 为常见模型提供预定义的搜索空间,并根据数据特征自动调整。 **参考样例** >>> from hscredit.core.models import AutoTuner >>> >>> # 自动根据数据特征选择搜索空间 >>> tuner = AutoTuner.create('xgboost', metric='ks') >>> best_params = tuner.fit(X_train, y_train, n_trials=50) >>> >>> # 使用多目标优化(KS + 稳定性) >>> tuner = AutoTuner.create('lightgbm', metric=['ks', 'ks_diff']) >>> best_params = tuner.fit(X_train, y_train, n_trials=100) >>> >>> # 使用自定义指标 >>> def my_metric(y_true, y_pred): ... return custom_score(y_true, y_pred) >>> >>> tuner = AutoTuner.create('xgboost', metric=my_metric, direction='maximize') >>> best_params = tuner.fit(X_train, y_train, n_trials=100) """
[文档] @classmethod def create( cls, model_type: str, metric: Union[str, Callable, List[Union[str, Callable]]] = "ks", direction: Union[str, List[str]] = "maximize", metric_names: Optional[List[str]] = None, target: str = "target", cv: int = 5, random_state: Optional[int] = None, verbose: bool = False, early_stopping_rounds: int = 20, **kwargs, ) -> ModelTuner: """创建自动调优器. :param model_type: 模型类型,可选: - 'xgboost' / 'xgb' - 'lightgbm' / 'lgb' - 'catboost' / 'cat' - 'ngboost' / 'ngb' - 'randomforest' / 'rf' - 'gradientboosting' / 'gbdt' - 'logisticregression' / 'lr' - 'svm' / 'svc' - 'decisiontree' / 'dt' :param metric: 优化指标,可以是字符串、函数或列表 :param direction: 优化方向,单目标时str,多目标时list :param metric_names: 指标名称列表(多目标时用于显示) :param target: 目标列名,用于scorecardpipeline风格的fit,默认'target' :param cv: 交叉验证折数,默认5 :param random_state: 随机种子 :param verbose: 是否输出详细信息 :param early_stopping_rounds: 早停轮数,默认20 :param kwargs: 其他参数 :return: ModelTuner实例 """ from .. import ( XGBoost, LightGBM, CatBoost, NGBoost, RandomForest, ExtraTrees, GradientBoosting, LogisticRegression, SVM, DecisionTreeClassifier, ) model_map = { "xgboost": XGBoost, "xgb": XGBoost, "lightgbm": LightGBM, "lgb": LightGBM, "catboost": CatBoost, "cat": CatBoost, "ngboost": NGBoost, "ngb": NGBoost, "randomforest": RandomForest, "rf": RandomForest, "extratrees": ExtraTrees, "et": ExtraTrees, "gradientboosting": GradientBoosting, "gbdt": GradientBoosting, "logisticregression": LogisticRegression, "lr": LogisticRegression, "svm": SVM, "svc": SVM, "decisiontree": DecisionTreeClassifier, "dt": DecisionTreeClassifier, } model_type = model_type.lower() if model_type not in model_map: raise ValueError(f"未知模型类型: {model_type}") model_class = model_map[model_type] return ModelTuner( model_class=model_class, search_space=None, metric=metric, direction=direction, metric_names=metric_names, target=target, cv=cv, random_state=random_state, verbose=verbose, early_stopping_rounds=early_stopping_rounds, **kwargs, ) # 使用自适应搜索空间