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 ...._compat import installed_version, needs_logistic_regression_parallel_compat
from ....utils.parallel import resolve_n_jobs
from ....utils.serialization import ArtifactSerializableMixin
from sklearn.base import clone
from sklearn.model_selection import ParameterGrid, StratifiedKFold
from sklearn.metrics import get_scorer, log_loss, roc_curve
from sklearn.linear_model import LogisticRegression as SklearnLogisticRegression
from ...metrics import auc as auc_metric
from .._contracts import (
    extract_target,
    positive_probability,
    split_sample_params,
    take_rows,
    validate_labels,
    validate_sample_weight,
)
from ..losses.base import BaseMetric
from ._retention import compact_fold, load_fold, resolve_retention, retain_fold, trial_path

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


from .space_adapter import SearchSpaceAdapter, normalize_search_space


def _normalize_space_param(name, spec):
    """旧私有入口委托统一空间校验,避免两套实现逐渐产生不同语义。"""
    return normalize_search_space({name: spec})[name]


# 保留既有私有导入路径,但只维护一套真实解析实现。
_space_param_from_dict = _normalize_space_param
_space_param_from_tuple = _normalize_space_param
_space_param_from_scipy = _normalize_space_param
_space_param_from_optuna = _normalize_space_param
_legacy_normalize_search_space = normalize_search_space


def _check_bounds(name, spec, integer):
    """兼容旧参数边界检查。"""
    _normalize_space_param(name, {**spec, "type": "int" if integer else "float"})


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:
        """校验采样器参数后实例化;仅对不支持随机种子的采样器移除 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:
            unknown = set(kwargs) - accepted - {"seed"}
            if unknown:
                raise ValueError(f"采样器 {sampler_cls.__name__} 不支持参数 {sorted(unknown)},请检查 sampler_kwargs")
            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: 透传给采样器构造函数的额外参数;拼错或不支持的参数立即报错。
            传入已创建的采样器实例时不能再传 kwargs,实例自身管理随机种子。
        :return: optuna 采样器实例

        **参考样例**

        >>> sampler = TuningSampler.create("tpe", seed=42, n_startup_trials=5)
        >>> tuner = ModelTuner(LogisticRegression, sampler=sampler)
        """
        if not OPTUNA_AVAILABLE:
            raise ImportError("Optuna未安装,请使用 pip install optuna 安装")

        # 已是采样器实例,直接返回
        if sampler is None:
            sampler = "tpe"
        if not isinstance(sampler, str):
            if not isinstance(sampler, optuna.samplers.BaseSampler):
                raise TypeError("sampler 必须是支持的名称或已创建的 Optuna BaseSampler 实例")
            if kwargs:
                raise ValueError("传入采样器实例时不能再传 sampler_kwargs,请在创建实例时配置参数")
            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], None)
            if sampler_cls is None:
                raise ImportError(f"当前Optuna版本不提供 {cls.BUILTIN_SAMPLERS[key]},请升级Optuna或选择其它采样器")
            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}")


# 旧模块路径继续导出,兼容历史导入和序列化记录。
from ._metrics import Metric, TuningObjective, _calc_ks, _calc_ks_with_diff  # noqa: E402,F401


def _safe_index(data: Any, indices: np.ndarray) -> Any:
    """按行位置切分 pandas、NumPy 或列表。"""
    return take_rows(data, indices)


[文档] class ModelTuner(ArtifactSerializableMixin): """模型超参数调优器 - 支持单/多目标优化. 基于Optuna实现贝叶斯优化超参数搜索。 支持单目标优化和多目标优化(帕累托最优)。 **参数** :param model_class: 模型类(如 XGBoost)或未拟合的模型/Pipeline 实例。 :param search_space: 参数搜索空间,默认None则使用预定义空间 :param fixed_params: 固定参数,不参与搜索 :param model_params: 来源模型实例的构造参数;搜索空间同名参数会覆盖它, ``fixed_params`` 的显式值具有最高优先级 :param metric: 优化指标(决定评估计算逻辑),可选: - 字符串: 'auc', 'ks', 'ks_diff', 'accuracy', 'precision', 'recall', 'f1', 'logloss' - 列表: 多个指标,用于多目标优化,如 ['ks', 'ks_diff'] - 函数: 自定义评估函数,接收(y_true, y_pred)返回float - 列表的函数: 多个自定义函数 :param direction: 默认 None,按内置指标或 BaseMetric 自动确定方向。 裸函数必须显式提供 'maximize'/'minimize';多目标可逐项提供方向列表。 :param metric_names: 指标显示名称列表(仅用于日志/报告/可视化的展示标签, 不参与任何计算逻辑),默认 None 时从 metric 自动推断 (内置字符串取其大写形式,自定义函数取其 ``__name__``)。 与 metric 不重复:metric 决定"算什么",metric_names 只决定"叫什么" :param cv: 交叉验证折数(默认5)、带 split 方法的分割器或 (训练位置, 验证位置) 序列。 :param n_jobs: 当前 trial 中模型可使用的并行任务数,默认-1; trial 本身顺序执行,确保主动中断及时生效并让自适应采样器利用全部历史结果 :param random_state: 随机种子,默认None :param verbose: 是否逐 Trial 输出得分、参数、当前最佳结果及最终摘要,默认False :param early_stopping_rounds: 早停轮数,默认20 :param loss: HSCredit 提升树训练所用 BaseLoss;metric 控制折外评分,彼此独立。 :param record_terminator_scores: 旧版 Optuna 终止分析兼容选项,默认None。 Optuna 4.9及以后默认不调用弃用接口;True显式启用,False关闭专用CV记录。 各折指标和常规搜索曲线不受影响。 **属性** ``best_params_`` / ``best_score_`` 是最佳参数与主指标均值, ``best_scores_`` 保存所有目标,``best_trial_`` 保存选中的 Optuna Trial。 ``study_`` 与 ``optimization_history_`` 可查看全过程;``best_model_`` 在第一次 调用 ``get_best_model()`` 后生成。多目标默认按指标顺序优先选择帕累托解。 **搜索空间定义** 搜索空间可使用参数字典,或使用每个维度都设置 ``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)} **内部建模经验** 预定义空间只是搜索起点,不保证适合每个数据集。实际空间在首次 fit 后保存在 ``search_space`` 中;用户提供明确空间时优先使用该空间。部分范围会按样本数、 特征数、坏样本比例调整;LightGBM 的叶子数受到 ``2**max_depth`` 上限约束。 自定义训练损失不使用原生 ``scale_pos_weight``,自动空间会去掉该无效维度; 类别权重应在损失参数或 ``sample_weight`` 中设置。 常用评估组合为最大化 KS/AUC,同时最小化训练/验证 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, model_params: Optional[Dict[str, Any]] = None, metric: Union[str, Callable, List[Union[str, Callable]]] = "ks", direction: Optional[Union[str, List[Optional[str]]]] = None, 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, study: Optional[Any] = None, pruner: Optional[Any] = None, callbacks: Optional[List[Callable]] = None, fit_params: Optional[Dict[str, Any]] = None, fit_params_factory: Optional[Callable] = None, trial_objective: Optional[Callable] = None, artifact_dir: Optional[str] = None, store_models: bool = True, retention: Optional[str] = None, loss: Optional[Any] = None, record_terminator_scores: Optional[bool] = None, ): """初始化 ModelTuner. :param model_class: 模型类或模型/Pipeline 实例;实例参数作为搜索的默认配置。 :param search_space: 参数字典、命名维度列表或函数 (trial) -> 模型参数字典;None 使用自动空间。 :param fixed_params: 明确固定的模型参数,优先级高于搜索参数和实例配置。 :param model_params: 模型默认构造参数;搜索结果会覆盖同名默认值。 :param metric: 字符串、BaseMetric、BaseLoss、带方向的 Metric 或函数;列表表示多目标。 None 时使用 loss.metric(),无 loss 时使用 KS。 :param direction: None 按指标对象/内置名称推断;裸函数须声明优化方向。 :param metric_names: 指标显示名称列表,需与指标数一致且名称不重复。 :param target: fit(df) 时提取的标签列名;显式传 y 时也会删除该列避免泄漏。 :param cv: 折数、分割器或 (训练索引, 验证索引) 序列,默认5。 :param n_jobs: 每个模型的并行上限,默认 -1;Trial 按顺序运行。 :param random_state: 交叉验证、采样器及未设置种子的模型共用的随机种子。 :param verbose: 是否逐 Trial 打印得分及最终摘要,默认 False。 :param early_stopping_rounds: 支持早停的模型使用此默认轮数;模型显式固定参数优先。 :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。 :param study: 已有 Optuna Study;使用该 Study 自身的存储、采样器和剪枝器。 :param pruner: 创建 Study 时传递的原生 Optuna 剪枝器。 :param callbacks: 原生 Study.optimize 的试验完成回调。 :param fit_params: 每次模型训练的原生参数,样本级权重等会随交叉验证切分。 :param fit_params_factory: 函数 (trial, fold_index) -> dict;最终重训传入 (None, None)。 :param trial_objective: 原生函数 (trial) -> 数值或多目标序列,完全接管试验内容。 :param artifact_dir: 逐折保存试验制品的目录;None 时只保留内存记录。 :param store_models: 是否保留各折模型,默认 True;False 仍保留预测和指标。 :param retention: 结果保留策略:full、predictions、summary、best、disk。 None 按 store_models 保持旧行为;disk 必须指定 artifact_dir,按需读取且不常驻折模型。 :param loss: 用于模型训练的 BaseLoss 实例,与决定搜索优劣的 metric 分开。 支持 HSCredit XGBoost、LightGBM、CatBoost;例如 ``ModelTuner(XGBoost, loss=FocalLoss(), metric='auc')``。 ``metric=None`` 时使用该损失的配套指标(越小越好)。 :param record_terminator_scores: 是否额外记录旧 Optuna terminator 的专用CV数据。 None(默认)仅在Optuna低于4.9且接口可用时记录;False关闭,True显式保留弃用兼容功能。 常规各折指标、中间值、搜索历史始终保留。Optuna 4.9起该模块弃用,显式开启仍会收到其提示。 **参考样例** >>> tuner = ModelTuner(XGBoost(n_estimators=100), search_space={'max_depth': [2, 3, 4]}, ... loss=FocalLoss(), metric='auc', cv=3, random_state=42) >>> params = tuner.fit(X_train, y_train, n_trials=10) >>> best = tuner.get_best_model() """ if not OPTUNA_AVAILABLE: raise ImportError("Optuna未安装,请使用 pip install optuna 安装") instance_params = {} if record_terminator_scores is not None and not isinstance(record_terminator_scores, (bool, np.bool_)): raise ValueError("record_terminator_scores 必须是 True、False 或 None") self.record_terminator_scores = record_terminator_scores if not isinstance(model_class, type) and hasattr(model_class, "get_params"): instance_params = model_class.get_params(deep=False) model_class = type(model_class) self.model_class = model_class self.search_space_function = search_space if callable(search_space) else None self._space_adapter = SearchSpaceAdapter({} if callable(search_space) else search_space) self.search_space = self._space_adapter.space self.model_params = {**instance_params, **dict(model_params or {})} # params 字典中的旧值不能在 Trial 构造模型时重新覆盖搜索结果。 native_params = self.model_params.pop("params", None) if isinstance(native_params, dict): self.model_params.update(native_params) self.model_params = self._canonical_params(self.model_params, prefer_alias=True) self.trial_objective = trial_objective self._explicit_fixed_params = dict(fixed_params or {}) self.loss = loss self._configure_training_loss() self.fixed_params: Dict[str, Any] = {} self._refresh_fixed_params() self._validate_custom_loss_parameters() self.objective = objective self.objective_kwargs = objective_kwargs or {} self.eval_ratios = [0.01, 0.03, 0.05, 0.10] if eval_ratios is None else list(eval_ratios) for ratio in self.eval_ratios: if ( isinstance(ratio, (bool, np.bool_)) or not isinstance(ratio, (int, float, np.integer, np.floating)) or not np.isfinite(float(ratio)) or not 0 < float(ratio) <= 1 ): raise ValueError("eval_ratios 中每个覆盖率必须是 (0, 1] 内的有限数值") 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 if isinstance(cv, (int, np.integer)) or hasattr(cv, "split") else tuple(cv) self.n_jobs = resolve_n_jobs(n_jobs) self.random_state = random_state self.verbose = verbose self.early_stopping_rounds = early_stopping_rounds self.pruner = pruner self.callbacks = list(callbacks or []) self.fit_params = dict(fit_params or {}) self.fit_params_factory = fit_params_factory self.trial_objective = trial_objective self.artifact_dir = artifact_dir self.store_models = store_models self.retention = retention self.retention_ = resolve_retention(retention, store_models, artifact_dir) self.trial_results_ = {} self.best_model_ = None from ..losses.base import BaseLoss if isinstance(objective, BaseLoss): raise TypeError("objective 是旧版搜索指标参数;训练损失请使用 loss=,评估损失请使用 metric=loss.metric()") if metric is None: metric = "ks" if loss is None else loss.metric() # 若指定了 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 if direction is None: direction = "maximize" metric_names = metric_names or [objective_key] else: # 可能是旧式 metric 字符串,直接透传 metric = objective elif callable(objective): metric = objective # 处理metric和direction self._setup_metrics(metric, direction, metric_names) # 存储结果 self.study_ = study 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._class_balance_ratio = None self._is_multi_objective = len(self.metrics) > 1 for point in initial_points: self.enqueue_trial(point) def _configure_training_loss(self): """训练损失只通过明确支持该契约的模型传入,避免误当作搜索目标。""" from ..losses.base import BaseLoss if self.loss is None: return if not isinstance(self.loss, BaseLoss): raise TypeError("loss 必须是 BaseLoss 实例;原生目标回调请通过模型 objective 配置") model_module = getattr(self.model_class, "__module__", "") model_name = getattr(self.model_class, "__name__", "") if not model_module.startswith("hscredit.") or model_name not in {"XGBoost", "LightGBM", "CatBoost"}: raise ValueError( "loss 便捷参数仅支持 HSCredit XGBoost、LightGBM、CatBoost;Pipeline 请预配置最终模型的 objective" ) if any(key in self._explicit_fixed_params for key in ("objective", "loss_function")): raise ValueError("loss 与 fixed_params 中的 objective/loss_function 不能同时设置") self._explicit_fixed_params["objective"] = self.loss @staticmethod def _validate_loss_sample_arrays(value): """拒绝未经过逐折对齐的金额等数组,防止长度恰好相同时发生静默错配。""" from ..losses.base import BaseLoss, LossMetric if isinstance(value, Metric): value = value.metric if isinstance(value, LossMetric): value = value.loss if isinstance(value, BaseLoss): for name in ("amounts_", "lgd", "ead", "rate", "cost"): item = getattr(value, name, None) if item is not None and np.asarray(item).ndim > 0: raise ValueError( "交叉验证不能直接使用绑定金额或金融参数数组的 loss/metric;" "金额加权请使用 fit(sample_weight=金额, evaluation_weight=金额)," "复杂逐折损失请使用 trial_objective 自行切分和评估" ) elif isinstance(value, dict): for item in value.values(): ModelTuner._validate_loss_sample_arrays(item) elif isinstance(value, (list, tuple)): for item in value: ModelTuner._validate_loss_sample_arrays(item) elif hasattr(value, "get_params") and not isinstance(value, type): ModelTuner._validate_loss_sample_arrays(value.get_params(deep=False)) @staticmethod def _configuration_fingerprint(configuration): """按参数语义散列,避免 Notebook 函数经 cloudpickle 恢复后字节表示变化。""" import functools import types from joblib import hash as stable_hash active = set() def normalize(value): if value is None or isinstance(value, (str, bytes, bool, int, float, np.ndarray, np.generic)): return value if isinstance(value, type): return ("类型", value.__module__, value.__qualname__) if isinstance(value, types.ModuleType): return ("模块", value.__name__) if id(value) in active: return ("循环引用", type(value).__module__, type(value).__qualname__) active.add(id(value)) try: if isinstance(value, dict): return {key: normalize(item) for key, item in value.items()} if isinstance(value, (tuple, list)): return tuple(normalize(item) for item in value) if isinstance(value, functools.partial): return ("偏函数", normalize(value.func), normalize(value.args), normalize(value.keywords)) if isinstance(value, types.CodeType): return ( value.co_code, normalize(value.co_consts), value.co_names, value.co_varnames, value.co_argcount, value.co_kwonlyargcount, ) if isinstance(value, types.FunctionType): closure = tuple(cell.cell_contents for cell in (value.__closure__ or ())) globals_used = { name: value.__globals__[name] for name in value.__code__.co_names if name in value.__globals__ } return ( "函数", normalize(value.__code__), normalize(value.__defaults__), normalize(value.__kwdefaults__), normalize(closure), normalize(globals_used), ) if isinstance(value, types.MethodType): return ("方法", normalize(value.__func__), normalize(value.__self__)) if hasattr(value, "__dict__"): return (type(value).__module__, type(value).__qualname__, normalize(vars(value))) return value finally: active.remove(id(value)) return stable_hash(normalize(configuration)) def _uses_custom_training_loss(self, params=None): """识别训练配置中的自定义损失,不把调参 objective 指标混入判断。""" from ..losses.base import BaseLoss params = {**self.model_params, **self._explicit_fixed_params} if params is None else params objective = params.get("objective", params.get("loss_function")) return isinstance(objective, BaseLoss) or callable(objective) def _validate_custom_loss_parameters(self): """自定义导数不消费内置正类权重,拒绝搜索没有效果的维度。""" if not self._uses_custom_training_loss(): return keys = {"scale_pos_weight", "is_unbalance", "unbalance"} if keys.intersection(self.search_space or {}): raise ValueError( "自定义 loss 不使用原生 scale_pos_weight/is_unbalance,不能搜索这些参数;请通过损失参数或 sample_weight 设置类别权重" ) params = {**self.model_params, **self._explicit_fixed_params} if ( params.get("scale_pos_weight") not in (None, 1, "auto") or params.get("is_unbalance") or params.get("unbalance") ): raise ValueError( "自定义 loss 不使用原生 scale_pos_weight/is_unbalance;请通过损失参数或 sample_weight 设置类别权重" ) def _refresh_fixed_params(self) -> None: """按实例默认值 < 搜索参数 < 显式固定参数合并模型配置。""" search_names = set(self.search_space or {}) for canonical, aliases in getattr(self.model_class, "_parameter_aliases", {}).items(): if search_names.intersection((canonical, *aliases)): search_names.update((canonical, *aliases)) if self.search_space_function is not None or self.trial_objective is not None: search_names.update(self.model_params) self.fixed_params = {name: value for name, value in self.model_params.items() if name not in search_names} self.fixed_params.update(self._explicit_fixed_params) self._validate_lightgbm_leaf_point(self.fixed_params) def _setup_metrics( self, metric: Union[str, Callable, List[Union[str, Callable]]], direction: Optional[Union[str, List[Optional[str]]]], metric_names: Optional[List[str]], ): """设置评估指标.""" # 统一转换为列表 if not isinstance(metric, (list, tuple)): metrics_list = [metric] else: metrics_list = metric # 处理direction if not metrics_list: raise ValueError("metric 不能为空列表") if not isinstance(direction, (list, tuple)): 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 = [] from ..losses.base import BaseLoss for m, d, name in zip(metrics_list, directions_list, metric_names): if d not in (None, "maximize", "minimize"): raise ValueError("direction 只能是 'maximize' 或 'minimize'") if isinstance(m, BaseLoss): m = m.metric() if isinstance(m, Metric): if m.direction not in ("maximize", "minimize"): raise ValueError("Metric.direction 只能是 'maximize' 或 'minimize'") if d is not None and d != m.direction: raise ValueError(f"direction 与指标 {m.name} 自身声明的方向不一致") wrapped = copy.copy(m) wrapped.name = name or m.name self.metrics.append(wrapped) elif isinstance(m, BaseMetric): # 与 Metric 对象一致,指标对象自身声明评估方向,避免损失被默认最大化。 if d is not None and d != m.direction: raise ValueError(f"direction 与指标 {m.name} 自身声明的方向不一致") self.metrics.append(Metric(m, name=name)) else: self.metrics.append(Metric(m, name=name, direction=d)) # 方便访问 self.metric = self.metrics[0] if len(self.metrics) == 1 else self.metrics self.directions = [m.direction for m in self.metrics] self.direction = self.directions[0] if len(self.directions) == 1 else self.directions self.metric_names = [m.name for m in self.metrics] if len(set(self.metric_names)) != len(self.metric_names): raise ValueError("评估指标名称不能重复;请通过 metric_names 设置不同名称") 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) 处理后的特征和目标 """ X, y = extract_target(X, y, self.target) validate_labels(y) 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, groups: Optional[Any] = None, callbacks: Optional[List[Callable]] = None, catch: Tuple[Type[Exception], ...] = (Exception,), gc_after_trial: bool = False, fit_params: Optional[Dict[str, Any]] = None, evaluation_weight: Optional[np.ndarray] = None, **optimize_kwargs, ) -> 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: 仅用于训练的样本权重,不自动改变验证目标。 :param evaluation_weight: 显式评估权重,按每折训练/验证位置切分;默认None保持未加权指标。 :param groups: 传给自定义交叉验证分割器的分组标签。 :param callbacks: 本次运行额外追加的 Study.optimize 回调。 :param catch: 允许 Optuna 标记失败后继续执行的异常类型;默认保留历史行为。 :param gc_after_trial: 是否在每次试验后执行垃圾回收。 :param fit_params: 本次训练参数,覆盖构造时的同名 fit_params。 :param optimize_kwargs: 透传 Optuna Study.optimize 的其他参数;n_jobs 由调参器管理。 :return: 最佳参数字典(保持旧接口;并非 self)。最佳模型需调用 get_best_model()。 相同数据再次 fit 会追加 Trial,并复用原交叉验证划分。数据、权重、固定参数 或指标变化时应新建调参器和 study_name,避免混用不可比较的历史得分。 **参考样例** >>> tuner = ModelTuner(XGBoost, metric=['auc', 'ks_diff'], random_state=42) >>> best_params = tuner.fit(X_train, y_train, n_trials=20) >>> best = tuner.get_best_model() >>> prediction = best.predict_proba(X_test)[:, 1] """ # 检查并处理输入 X, y = self._check_input(X, y) sample_weight = validate_sample_weight(sample_weight, len(y)) evaluation_weight = validate_sample_weight(evaluation_weight, len(y)) runtime_fit_params = {**self.fit_params, **dict(fit_params or {})} if self.trial_objective is None: self._validate_custom_loss_parameters() for value in (self.model_params, self.fixed_params, self.metrics, runtime_fit_params): self._validate_loss_sample_arrays(value) self._validate_native_loss({**self.model_params, **self.fixed_params}) from joblib import hash as stable_hash data_signature = stable_hash((X, y, sample_weight, evaluation_weight, groups)) configuration = ( self.model_params, self._explicit_fixed_params, self.early_stopping_rounds, self.random_state, [(item.metric, item.name, item.direction) for item in self.metrics], runtime_fit_params, ) configuration_signature = self._configuration_fingerprint(configuration) # 相同输入续跑时复用原始折,random_state=None 也不会改变验证口径。 old_signature = getattr(self, "_data_signature_", None) if old_signature is not None and old_signature != data_signature and self.study_ is not None: raise ValueError("续跑数据或权重发生变化,不能混用历史 Trial 得分;请新建 ModelTuner 和 study_name") if ( getattr(self, "_configuration_signature_", configuration_signature) != configuration_signature and self.study_ is not None ): raise ValueError("续跑固定参数、训练参数或指标发生变化;请新建 ModelTuner 和 study_name") cv_signature = stable_hash(self.cv) if getattr(self, "_cv_signature_", cv_signature) != cv_signature and self.study_ is not None: raise ValueError("续跑交叉验证配置发生变化;请新建 ModelTuner 和 study_name") existing_splits = getattr(self, "_cv_splits", None) cv_splits = ( existing_splits if old_signature == data_signature and existing_splits is not None else self._validated_splits(X, y, groups) ) self._validate_fold_weights(evaluation_weight, cv_splits) contract = { "数据": data_signature, "配置": configuration_signature, "交叉验证": cv_signature, "划分": stable_hash(cv_splits), "指标名称": self.metric_names, "指标方向": self.directions, "模型": f"{self.model_class.__module__}.{self.model_class.__qualname__}", } if self.study_ is not None: previous_contract = self.study_.user_attrs.get("HSCredit调参契约") if previous_contract is not None and previous_contract != contract: raise ValueError("Study 的数据、模型或评估口径与本次不同;请新建 ModelTuner 和 study_name") self.retention_ = resolve_retention(getattr(self, "retention", None), self.store_models, self.artifact_dir) # 记录数据信息 self._n_samples = X.shape[0] self._n_features = X.shape[1] if hasattr(X, "shape") else len(X[0]) y_array = y.to_numpy() if hasattr(y, "to_numpy") else np.asarray(y) positive_count = int(np.sum(y_array == 1)) negative_count = int(np.sum(y_array == 0)) self._class_balance_ratio = ( float(negative_count / positive_count) if positive_count > 0 and negative_count > 0 else 1.0 ) self._X = X self._y = y self._sample_weight = sample_weight self._evaluation_weight = evaluation_weight if self._evaluation_weight is not None: for metric in self.metrics: metric.validate_evaluation_weight() self._groups = groups self._training_data_released_ = False self._fit_params = runtime_fit_params self.best_model_ = None validate_sample_weight(sample_weight, len(y)) self._cv_splits = cv_splits self._data_signature_ = data_signature self._cv_signature_ = cv_signature self._configuration_signature_ = configuration_signature # 如果没有指定搜索空间,使用自适应搜索空间 if self.search_space is None: self.search_space = self._get_adaptive_search_space() if self._uses_custom_training_loss(): for name in ("scale_pos_weight", "is_unbalance", "unbalance"): self.search_space.pop(name, None) self._space_adapter = SearchSpaceAdapter(self.search_space) self.search_space = self._space_adapter.space self._refresh_fixed_params() if self.study_ is None: # 创建采样器(支持 optuna 内置及 optunahub 采样器,见 TuningSampler 码表) sampler = TuningSampler.create(self.sampler, seed=self.random_state, **self.sampler_kwargs) # 公共 study 参数(storage 指定后可用 optuna-dashboard 实时查看进度) common_kwargs = dict( sampler=sampler, pruner=self.pruner, 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) if [direction.name.lower() for direction in self.study_.directions] != self.directions: raise ValueError("传入 Study 的优化方向与 ModelTuner 的 direction 不一致") previous_contract = self.study_.user_attrs.get("HSCredit调参契约") if previous_contract is not None and previous_contract != contract: raise ValueError("Study 的数据、模型或评估口径与本次不同;请新建 ModelTuner 和 study_name") self.study_.set_user_attr("HSCredit调参契约", contract) if hasattr(self.study_, "set_metric_names") and getattr(self.study_, "metric_names", None) != self.metric_names: # 包装器明确支持这个公开实验接口;只接管它自身的稳定性提示, # 保留中文曲线名称,不屏蔽弃用提示、用户警告或其它实验功能提示。 with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=r"^optuna\.study\.study\.Study\.set_metric_names is experimental .*", category=optuna.exceptions.ExperimentalWarning, ) self.study_.set_metric_names(self.metric_names) # 入队预指定的超参数搜索点(优先评估) self._enqueue_trial_points() # Optuna 的并行 trial 使用线程池。Jupyter 主线程收到中断后, # 线程池会等待正在训练的 trial 完成,导致主动中断不能立即返回。 # trial 顺序执行还可确保自适应采样器利用此前全部完成结果; # 调参总预算全部交给当前模型的原生并行参数。 model_workers = max(1, int(self.n_jobs or 1)) def objective(trial): self.trial_results_[trial.number] = {"试验编号": trial.number, "各折": [], "保留策略": self.retention_} try: if self.trial_objective is not None: return self.trial_objective(trial) params = self._build_model_params(self._sample_params(trial), workers=model_workers) self.trial_results_[trial.number]["模型参数"] = params model = self._new_model(params) metric_values, diagnostics = self._evaluate_model( model, X, y, sample_weight, return_diagnostics=True, trial=trial, evaluation_weight=self._evaluation_weight, ) for name, value in diagnostics.items(): trial.set_user_attr(name, value) return metric_values except optuna.TrialPruned: raise except Exception as exc: trial.set_user_attr("错误类型", type(exc).__name__) trial.set_user_attr("错误信息", str(exc)) raise # 运行优化 optimize_callbacks = [self._finish_trial] + self.callbacks + list(callbacks or []) if self.verbose: optimize_callbacks.append(self._print_trial_progress) try: self.study_.optimize( objective, n_trials=n_trials, timeout=timeout, show_progress_bar=show_progress_bar, n_jobs=1, callbacks=optimize_callbacks, catch=catch, gc_after_trial=gc_after_trial, **optimize_kwargs, ) finally: # 中断、回调错误和全失败同样保留已完成的搜索过程。 # Optuna 在 KeyboardInterrupt 时可能不执行完成回调,仍需提交索引和状态。 import sys interrupted = isinstance(sys.exc_info()[1], KeyboardInterrupt) for trial in self.study_.trials: record = self.trial_results_.get(trial.number) if record is not None and "状态" not in record and trial.state.name in {"COMPLETE", "FAIL", "PRUNED"}: record.update( 状态=( "中断" if interrupted and trial.state.name == "FAIL" else {"COMPLETE": "完成", "FAIL": "失败", "PRUNED": "剪枝"}[trial.state.name] ), 得分=trial.values, 用户属性=dict(trial.user_attrs), ) self._persist_trial(trial.number) self.optimization_history_ = self._build_public_history() if any(t.state == optuna.trial.TrialState.COMPLETE for t in self.study_.trials): self._save_results() self._apply_retention() 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: failed_trials = [trial for trial in self.study_.trials if trial.state == optuna.trial.TrialState.FAIL] last_error = failed_trials[-1].user_attrs if failed_trials else {} detail = "" if last_error.get("错误信息"): detail = f";最后错误: {last_error.get('错误类型', 'Exception')}: {last_error['错误信息']}" raise ValueError(f"所有Trial均失败,请检查模型参数、数据和训练异常{detail}") # 保存结果 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 if ( parameter_name == "n_jobs" and inspect.isclass(self.model_class) and issubclass(self.model_class, SklearnLogisticRegression) and needs_logistic_regression_parallel_compat(installed_version("sklearn", "scikit-learn")) ): # 不为无效参数注入预算;显式设置仍交给模型处理并保留其警告。 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 _inject_model_random_state(self, params: Dict[str, Any]) -> None: """在模型显式支持且调用者未固定时注入调参随机种子。""" if self.random_state is None or "random_state" in self._explicit_fixed_params: return try: accepted = inspect.signature(self.model_class.__init__).parameters except (TypeError, ValueError, AttributeError): return if "random_state" in accepted and params.get("random_state") is None: params["random_state"] = self.random_state def _build_model_params(self, params: Dict[str, Any], workers: Optional[int] = None) -> Dict[str, Any]: """构建 Trial 评估和最终重训共用的完整模型参数。""" full_params = dict(self.model_params) full_params.update(self._canonical_params(params)) full_params.update(self._canonical_params(self.fixed_params)) if self._uses_custom_training_loss(full_params): if full_params.get("scale_pos_weight") not in (None, 1, "auto") or full_params.get("is_unbalance"): raise ValueError("自定义 loss 不使用原生类别权重参数;请改用损失参数或 sample_weight") if "scale_pos_weight" in full_params: full_params["scale_pos_weight"] = 1 full_params = self._apply_model_param_constraints(full_params) self._inject_fit_params(full_params) self._inject_model_random_state(full_params) self._inject_model_parallel_budget(full_params, workers or max(1, int(self.n_jobs or 1))) self._validate_loss_sample_arrays(full_params) self._validate_native_loss(full_params) return full_params def _validate_native_loss(self, params): """原生框架不能直接解释 BaseLoss 概率导数协议,须先显式使用适配器。""" from ..losses.base import BaseLoss if self.model_class.__module__.split(".")[0] in {"xgboost", "lightgbm", "catboost"}: if any(isinstance(params.get(name), BaseLoss) for name in ("objective", "loss_function")): raise ValueError("原生框架不能直接传 BaseLoss;请使用 HSCredit 模型和 loss=,或显式调用对应框架适配器") def _canonical_params(self, params, prefer_alias=False): """搜索前统一同义参数,避免旧别名重新覆盖本次采样值。""" result = dict(params) for canonical, aliases in getattr(self.model_class, "_parameter_aliases", {}).items(): for alias in aliases: if alias in result: value = result.pop(alias) if prefer_alias or canonical not in result: result[canonical] = value return result def _new_model(self, params): """按 sklearn 约定分别传递构造参数和 Pipeline 嵌套参数。""" direct = {name: value for name, value in params.items() if "__" not in name} nested = {name: value for name, value in params.items() if "__" in name} model = self.model_class(**copy.deepcopy(direct)) if nested: model.set_params(**nested) return model def _splitter(self, X, y, groups=None): if isinstance(self.cv, (int, np.integer)): splitter = StratifiedKFold(n_splits=int(self.cv), shuffle=True, random_state=self.random_state) else: splitter = self.cv if hasattr(splitter, "split"): return splitter.split(X, y, groups) return iter(splitter) def _validated_splits(self, X, y, groups=None): """所有调参与重评估入口共用位置索引校验。""" if isinstance(self.cv, (bool, np.bool_)): raise ValueError("cv 必须是大于等于2的折数、交叉验证分割器或索引对序列") if isinstance(self.cv, (int, np.integer)) and self.cv < 2: raise ValueError("交叉验证折数 cv 必须大于等于2") splits = list(self._splitter(X, y, groups)) if not splits: raise ValueError("交叉验证没有产生任何数据划分") normalized = [] for train, valid in splits: pair = [] for indices in (train, valid): indices = np.asarray(indices) if indices.ndim != 1 or not len(indices) or not np.issubdtype(indices.dtype, np.integer): raise ValueError("交叉验证索引必须是非空的一维整数数组") if np.any(indices < 0) or np.any(indices >= len(y)): raise ValueError("交叉验证索引超出训练数据范围") if len(np.unique(indices)) != len(indices): raise ValueError("同一交叉验证折的索引不能重复") pair.append(indices) if np.intersect1d(*pair).size: raise ValueError("交叉验证的训练集和验证集不能包含相同样本") normalized.append(tuple(pair)) return normalized @staticmethod def _final_estimator(model): """取得 Pipeline 最后一个估计器,保留其训练过程与早停配置。""" from sklearn.pipeline import Pipeline while isinstance(model, Pipeline): model = model.steps[-1][1] return model def _fold_fit_params(self, indices, trial=None, fold=None, n_samples=None, fit_params=None): # 先切分再复制,避免每折复制一份完整训练样本参数。 size = self._n_samples if n_samples is None else n_samples source = getattr(self, "_fit_params", self.fit_params) if fit_params is None else fit_params params = copy.deepcopy(split_sample_params(source, indices, size)) if self.fit_params_factory is not None: extra = self.fit_params_factory(trial, fold) if not isinstance(extra, dict): raise TypeError("fit_params_factory 必须返回训练参数字典") params.update(extra) return params @staticmethod def _route_sample_weight(model, params, sample_weight): """把显式训练权重传给最终估计器,兼容嵌套 Pipeline 的标准参数前缀。""" from sklearn.pipeline import Pipeline if sample_weight is None: return params prefix = "" while isinstance(model, Pipeline): name, model = model.steps[-1] prefix += name + "__" params[prefix + "sample_weight"] = sample_weight return params def _fit_fold(self, model, X, y, params): """原生提升器的早停使用训练折内部划分,外层验证折只用于评分。""" module = type(model).__module__.split(".")[0] if module not in {"xgboost", "lightgbm", "catboost", "ngboost"}: return model.fit(X, y, **params) configured = model.get_params(deep=False) rounds = params.get("early_stopping_rounds", configured.get("early_stopping_rounds")) if module not in {"xgboost", "lightgbm", "catboost", "ngboost"} or rounds is None: return model.fit(X, y, **params) if "eval_set" in params or "X_val" in params: return model.fit(X, y, **params) from sklearn.model_selection import train_test_split train_idx, valid_idx = train_test_split( np.arange(len(y)), test_size=0.2, stratify=y, random_state=self.random_state ) runtime = dict(params) validation_params = {} weight_names = { "xgboost": "sample_weight_eval_set", "lightgbm": "eval_sample_weight", "ngboost": "val_sample_weight", } for key in ("sample_weight", "base_margin", "init_score", "baseline"): if key in runtime: values = runtime[key] runtime[key] = _safe_index(values, train_idx) validation_params[key] = _safe_index(values, valid_idx) X_val, y_val = _safe_index(X, valid_idx), _safe_index(y, valid_idx) if module == "ngboost": runtime.update(X_val=X_val, Y_val=y_val) elif module == "catboost": from catboost import Pool pool_params = { "weight" if key == "sample_weight" else key: value for key, value in validation_params.items() } categorical = runtime.get("cat_features", configured.get("cat_features")) if categorical is not None: pool_params["cat_features"] = categorical runtime["eval_set"] = Pool(X_val, y_val, **pool_params) else: runtime["eval_set"] = [(X_val, y_val)] if "sample_weight" in validation_params and module in weight_names: value = validation_params["sample_weight"] runtime[weight_names[module]] = value if module == "ngboost" else [value] if "base_margin" in validation_params and module == "xgboost": runtime["base_margin_eval_set"] = [validation_params["base_margin"]] if "init_score" in validation_params and module == "lightgbm": runtime["eval_init_score"] = [validation_params["init_score"]] return model.fit(_safe_index(X, train_idx), _safe_index(y, train_idx), **runtime) def _validate_fold_weights(self, evaluation_weight, splits): """评估折不能只有零权样本;在拟合任何折模型之前拒绝。""" if evaluation_weight is None: return needs_training = any(metric._is_builtin and metric.metric.lower() == "ks_diff" for metric in self.metrics) for fold_index, (train_indices, validation_indices) in enumerate(splits): if np.sum(_safe_index(evaluation_weight, validation_indices)) <= 0: raise ValueError(f"第 {fold_index} 折验证集 evaluation_weight 总和必须大于0") if needs_training and np.sum(_safe_index(evaluation_weight, train_indices)) <= 0: raise ValueError(f"第 {fold_index} 折训练集评估权重总和为0,无法计算 ks_diff") def _evaluate_model( self, model, X, y, sample_weight=None, return_diagnostics=False, trial=None, evaluation_weight=None, cv_splits=None, fit_params=None, ): """交叉验证并保留逐折模型、预测、指标和学习曲线。""" evaluation_weight = validate_sample_weight(evaluation_weight, len(y)) if evaluation_weight is not None: for metric in self.metrics: metric.validate_evaluation_weight() fold_results = {i: [] for i in range(len(self.metrics))} fold_lifts = {float(ratio): [] for ratio in self.eval_ratios} splits = cv_splits if cv_splits is not None else getattr(self, "_cv_splits", None) if splits is None: splits = self._validated_splits(X, y, groups=getattr(self, "_groups", None)) if not splits: raise ValueError("交叉验证没有产生任何数据划分") self._validate_fold_weights(evaluation_weight, splits) for fold_index, (train_idx, val_idx) in enumerate(splits): 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) validation_weight = _safe_index(evaluation_weight, val_idx) train_evaluation_weight = _safe_index(evaluation_weight, train_idx) try: fold_model = clone(model) except Exception: # 兼容既有的非 sklearn 自定义模型;模板尚未训练,每折仍使用独立对象。 fold_model = copy.deepcopy(model) fit_kwargs = {} record = {"折编号": fold_index, "训练位置": np.asarray(train_idx), "验证位置": np.asarray(val_idx)} if trial is not None: self.trial_results_[trial.number]["各折"].append(record) try: fit_kwargs = self._fold_fit_params( train_idx, trial, fold_index, n_samples=len(y), fit_params=fit_params ) self._route_sample_weight(fold_model, fit_kwargs, sample_weight_fold) self._fit_fold(fold_model, X_train_fold, y_train_fold, fit_kwargs) y_train_pred = positive_probability(fold_model.predict_proba(X_train_fold), fold_model.classes_, 1) y_val_pred = positive_probability(fold_model.predict_proba(X_val_fold), fold_model.classes_, 1) y_val_arr, y_train_arr = np.asarray(y_val_fold), np.asarray(y_train_fold) record.update( 真实标签=y_val_arr, 预测概率=y_val_pred, 训练真实标签=y_train_arr, 训练预测概率=y_train_pred ) scores = {} for i, metric in enumerate(self.metrics): value = metric( y_val_arr, y_val_pred, y_train=y_train_arr, y_train_pred=y_train_pred, sample_weight=validation_weight, train_sample_weight=train_evaluation_weight, ) if not np.isfinite(value): raise ValueError(f"第 {fold_index} 折的 {metric.name} 指标不是有限数") fold_results[i].append(value) scores[metric.name] = float(value) record["指标"] = scores record["评估口径"] = "未加权" if evaluation_weight is None else "显式评估权重" record["补充LIFT口径"] = "按样本行,未应用评估权重" for ratio in fold_lifts: fold_lifts[ratio].append(TuningObjective.lift_head(y_val_arr, y_val_pred, ratio=ratio)) record["状态"] = "完成" except BaseException as exc: record.update( 状态="中断" if isinstance(exc, KeyboardInterrupt) else "失败", 错误类型=type(exc).__name__, 错误信息=str(exc), ) raise finally: trained = self._final_estimator(fold_model) record["训练记录"] = getattr(trained, "training_summary_", {}) record["评估曲线"] = getattr(trained, "evals_result_", {}) record["最佳迭代"] = getattr(trained, "best_iteration_", getattr(trained, "best_iteration", None)) trained_params = trained.get_params(deep=False) if hasattr(trained, "get_params") else {} record["启用早停"] = bool( fit_kwargs.get("early_stopping_rounds", trained_params.get("early_stopping_rounds")) ) if hasattr(trained, "get_best_iteration"): record["最佳迭代"] = trained.get_best_iteration() if self.retention_ in {"full", "best", "disk"}: record["模型"] = fold_model if trial is not None: retain_fold(self, trial.number, record) if trial is not None and self.artifact_dir: self._persist_trial(trial.number) if trial is not None: trial.set_user_attr( "各折指标", [item.get("指标", {}) for item in self.trial_results_[trial.number]["各折"]] ) if not self._is_multi_objective: trial.report(float(np.mean(fold_results[0])), step=fold_index) if trial.should_prune(): raise optuna.TrialPruned(f"第 {fold_index} 折后停止本次试验") results = [float(np.mean(fold_results[i])) for i in range(len(self.metrics))] if trial is not None and not self._is_multi_objective and len(fold_results[0]) > 1: self._report_terminator_scores(trial, fold_results[0]) metric_result = tuple(results) if self._is_multi_objective else results[0] if not return_diagnostics: return metric_result diagnostics = {self._lift_metric_name(ratio): float(np.mean(values)) for ratio, values in fold_lifts.items()} return metric_result, diagnostics def _report_terminator_scores(self, trial, scores): """只为未弃用的旧版本或显式兼容请求调用终止分析接口。""" from packaging.version import Version enabled = getattr(self, "record_terminator_scores", None) if enabled is None: enabled = Version(optuna.__version__).release[:2] < (4, 9) if not enabled: return import importlib try: terminator = importlib.import_module("optuna.terminator") except ModuleNotFoundError as exc: if exc.name != "optuna.terminator": raise return report_cv = getattr(terminator, "report_cross_validation_scores", None) if report_cv is not None: report_cv(trial, [float(value) for value in scores]) def _finish_trial(self, study, trial): record = self.trial_results_.setdefault(trial.number, {"试验编号": trial.number, "各折": []}) states = {"COMPLETE": "完成", "FAIL": "失败", "PRUNED": "剪枝", "RUNNING": "运行中", "WAITING": "等待"} record.update( 状态=states.get(trial.state.name, trial.state.name), 得分=trial.values, 用户属性=dict(trial.user_attrs) ) self._apply_retention() self._persist_trial(trial.number) def _apply_retention(self): """best 档仅保留当前最佳试验的折模型,其他试验仍保留指标和诊断。""" if getattr(self, "retention_", "full") != "best" or self.study_ is None: return completed = [trial for trial in self.study_.trials if trial.state == optuna.trial.TrialState.COMPLETE] best = ( self._select_best_pareto_trial(self.study_.best_trials) if completed and self._is_multi_objective else None ) if completed and not self._is_multi_objective: best = self.study_.best_trial for number, record in self.trial_results_.items(): if best is not None and number == best.number: continue if record.get("保留策略") == "summary": continue if record.get("状态") in {"完成", "失败", "剪枝", "中断"}: record["各折"] = [compact_fold(fold) for fold in record["各折"]] record["保留策略"] = "summary" self._persist_trial(number) def _persist_trial(self, number): if self.artifact_dir is None: return from .._lifecycle import atomic_save_pickle path = trial_path(self, number) atomic_save_pickle(self.trial_results_[number], path, engine="cloudpickle") self.trial_results_[number]["制品路径"] = str(path) self.study_.set_user_attr(f"试验制品_{number}", str(path))
[文档] def get_trial_result(self, number, *, load=True): """获取单次试验的逐折模型、预测、得分与训练记录。 :param number: Optuna Trial 编号,通常使用 best_trial_.number。 :param load: disk 策略是否读取完整折制品;False 返回轻量摘要。 :return: 中文键字典。能否包含模型/预测取决于 retention。 **参考样例** >>> result = tuner.get_trial_result(tuner.best_trial_.number) >>> result['各折'][0]['指标'] """ if number not in self.trial_results_: from ....utils import load_pickle path = self.study_.user_attrs.get(f"试验制品_{number}") if self.study_ is not None else None if self.artifact_dir is not None and self.study_ is not None: relocated = trial_path(self, number) if relocated.is_file(): path = relocated if not path: raise ValueError(f"试验 {number} 没有保存逐折结果;请配置 artifact_dir 或加载完整调参制品") self.trial_results_[number] = load_pickle(path, engine="cloudpickle") record = self.trial_results_[number] if load and any("制品路径" in fold for fold in record.get("各折", [])): return {**record, "各折": [load_fold(fold, self) for fold in record["各折"]]} return record
[文档] def get_oof_predictions(self, trial_number=None): """返回折外预测;重复验证取平均,未参加验证的位置保留缺失值。 :param trial_number: 试验编号;None 使用默认最佳试验。 :return: 包含样本位置、真实标签、预测概率、验证次数的 DataFrame。 :raises ValueError: 输入已释放或当前保留策略没有保存预测时抛出。 **参考样例** >>> oof = tuner.get_oof_predictions() >>> oof.loc[oof['验证次数'] > 0, ['真实标签', '预测概率']] """ if trial_number is None: if self.best_params_ is None: raise ValueError("请先完成至少一次成功试验") trial_number = self.best_trial_.number if getattr(self, "_training_data_released_", False): raise ValueError("训练数据已释放,无法组装完整标签的折外预测;请重新 fit") record = self.get_trial_result(trial_number, load=False) if not any("预测概率" in fold or "制品路径" in fold for fold in record.get("各折", [])): raise ValueError("当前保留策略未保留该试验的折外预测,请使用 full、predictions、disk 或最佳试验") sums, counts = np.zeros(self._n_samples), np.zeros(self._n_samples, dtype=int) for summary in record["各折"]: fold = load_fold(summary, self) if "预测概率" in fold: np.add.at(sums, fold["验证位置"], fold["预测概率"]) np.add.at(counts, fold["验证位置"], 1) probability = np.divide(sums, counts, out=np.full(self._n_samples, np.nan), where=counts > 0) return pd.DataFrame( { "样本位置": np.arange(self._n_samples), "真实标签": np.asarray(self._y), "预测概率": probability, "验证次数": counts, } )
[文档] def release_training_data(self): """显式释放续训输入和样本级 fit 参数;已缓存最佳模型仍可预测。 不删除已保留的折结果或磁盘制品,不清理用户函数闭包。 再次 fit 必须重新提供输入及所需的样本参数。 :return: self。释放后不能重新训练或重建完整 OOF 表。 **参考样例** >>> best_model = tuner.get_best_model() >>> tuner.release_training_data() >>> prediction = best_model.predict_proba(X_test) """ from .._contracts import SAMPLE_PARAMETER_NAMES for name in ("_X", "_y", "_sample_weight", "_evaluation_weight", "_groups", "_cv_splits"): setattr(self, name, None) row_names = SAMPLE_PARAMETER_NAMES | {"eval_set", "X_val", "Y_val"} for name in ("fit_params", "_fit_params"): params = getattr(self, name, {}) setattr( self, name, {key: value for key, value in params.items() if key.rsplit("__", 1)[-1] not in row_names} ) self._training_data_released_ = True return self
[文档] def save(self, path, **kwargs): """保存可继续调参的完整对象,包括 Study、输入、预测、模型和自定义函数。 :param path: 本地制品路径,例如 'output/tuner.pkl'。 :param kwargs: 传给保存函数的参数;默认 engine='cloudpickle'。 :return: 保存后的文件路径。 **参考样例** >>> path = tuner.save('output/tuner.pkl') >>> restored = ModelTuner.load(path) """ kwargs.setdefault("engine", "cloudpickle") from .._lifecycle import atomic_save_pickle return atomic_save_pickle({**self.get_artifact_metadata(), "object": self}, path, **kwargs)
[文档] @classmethod def load(cls, path, *, artifact_dir=None, **kwargs): """加载完整过程;移动折制品后可用 artifact_dir 指定新根目录。 :param path: save 生成的本地制品路径。 :param artifact_dir: 可选的折制品新目录;不修改 Trial 数据。 :param kwargs: 传给制品加载器的参数。 :return: 恢复后的 ModelTuner 实例。 **参考样例** >>> restored = ModelTuner.load('output/tuner.pkl') >>> restored.fit(X_train, y_train, n_trials=10) """ tuner = cls.load_artifact(path, **kwargs) if artifact_dir is not None: tuner.artifact_dir = str(artifact_dir) if not hasattr(tuner, "retention_"): tuner.retention_ = resolve_retention( getattr(tuner, "retention", None), getattr(tuner, "store_models", True), getattr(tuner, "artifact_dir", None), ) return tuner
@staticmethod def _lift_metric_name(ratio: float) -> str: """生成稳定的公开 LIFT 覆盖率名称。""" return f"LIFT@{float(ratio) * 100:g}%" 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_trial_ = ( self._select_best_pareto_trial(self.study_.best_trials) if self._is_multi_objective else self.study_.best_trial ) 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, evaluation_weight: Optional[np.ndarray] = None, groups: Optional[Any] = None, fit_params: Optional[Dict[str, Any]] = 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: 仅训练权重。 :param evaluation_weight: 显式验证指标权重,默认None,不继承之前fit的评估权重。 :param groups: 本次交叉验证的分组标签,按当前数据重新划分。 :param fit_params: 本次评估的训练参数。以构造时 fit_params 为基础,不继承旧 fit 的临时参数。 :return: 包含评估结果的DataFrame """ # 检查trial_points if trial_points is None: raise ValueError("trial_points不能为空,请提供要评估的超参数点列表") # 检查并处理输入 X, y = self._check_input(X, y) sample_weight = validate_sample_weight(sample_weight, len(y)) evaluation_weight = validate_sample_weight(evaluation_weight, len(y)) cv_splits = self._validated_splits(X, y, groups) runtime = {**self.fit_params, **dict(fit_params or {})} for value in (self.model_params, self.fixed_params, self.metrics, runtime): self._validate_loss_sample_arrays(value) results = [] for i, params in enumerate(trial_points): if self.verbose: logger.info(f"评估 trial point {i+1}/{len(trial_points)}: {params}") # 合并固定参数 full_params = self._build_model_params(params) # 创建模型并评估 model = self._new_model(full_params) metric_values = self._evaluate_model( model, X, y, sample_weight, evaluation_weight=evaluation_weight, cv_splits=cv_splits, fit_params=runtime ) 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, evaluation_weight: Optional[np.ndarray] = None, groups: Optional[Any] = None, fit_params: Optional[Dict[str, Any]] = 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 时的样本权重 :param evaluation_weight: 显式指标权重;仅X未传入时默认复用fit的评估权重。 :param groups: 传入新X时使用的分组标签;不复用旧数据的CV位置。 :param fit_params: 本次重评估训练参数;新数据仅继承构造参数,旧数据继承原 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 时的数据 cv_splits = None 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) if evaluation_weight is None: evaluation_weight = getattr(self, "_evaluation_weight", None) runtime = {**getattr(self, "_fit_params", self.fit_params), **dict(fit_params or {})} else: X, y = self._check_input(X, y) cv_splits = self._validated_splits(X, y, groups) runtime = {**self.fit_params, **dict(fit_params or {})} sample_weight = validate_sample_weight(sample_weight, len(y)) for value in (self.model_params, self.fixed_params, self.metrics, runtime): self._validate_loss_sample_arrays(value) 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 = self._build_model_params(params) # 创建模型并评估 model = self._new_model(full_params) metric_values = self._evaluate_model( model, X, y, sample_weight, evaluation_weight=evaluation_weight, cv_splits=cv_splits, fit_params=runtime ) # 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中获取参数.""" if self.search_space_function is not None or self.trial_objective is not None: record = self.trial_results_.get(trial.number, {}) if not record and trial.user_attrs.get("搜索参数需制品"): record = self.get_trial_result(trial.number) params = dict(record.get("搜索参数", trial.user_attrs.get("搜索参数", trial.params))) else: params = self._space_adapter.public_params(trial) return self._apply_model_param_constraints(params) 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") names = set(self.search_space or {}) if self.search_space_function is not None or self.trial_objective is not None: names.update(name for trial in self.study_.trials for name in self._get_params_from_trial(trial)) for name in sorted(names): column = f"params_{name}" values = [] for trial in self.study_.trials: params = self._get_params_from_trial(trial) params.update(self.fixed_params) values.append(self._apply_model_param_constraints(params).get(name)) history[column] = values for ratio in self.eval_ratios: public_name = self._lift_metric_name(float(ratio)) internal_column = f"user_attrs_{public_name}" if internal_column in history: history[public_name] = history.pop(internal_column) 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 in ("svm", "svc"): return self._get_svm_search_space() elif model_name == "decisiontreeclassifier": return self._get_decisiontree_search_space() else: raise ValueError(f"无法为模型 {self.model_class.__name__} 自动生成搜索空间,请显式传入 search_space") def _get_class_weight_range(self) -> Tuple[float, float]: """根据训练标签负正样本比生成正样本权重范围。""" ratio = float(np.clip(self._class_balance_ratio or 1.0, 0.1, 100.0)) return max(0.1, ratio * 0.5), min(100.0, max(ratio * 1.5, ratio * 0.5)) 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: 围绕训练集负正样本比的 0.5-1.5 倍自适应 - 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=...)`` 传入。 """ class_weight_low, class_weight_high = self._get_class_weight_range() 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": class_weight_low, "high": class_weight_high, }, "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搜索空间对齐. 参考风控小中型样本经验,并避免强正则导致所有样本预测相同: - num_leaves: 与max_depth相关,受 ``2**max_depth`` 上界约束(见 _sample_params) - max_depth: 风控场景通常2-4,防止过拟合 - min_child_samples: 8-128(step 4),叶子最小样本数 - subsample: 0.35-0.85,配合 subsample_freq=1 启用行采样 - colsample_bytree: 0.4-0.9,列采样 - min_split_gain: 0.0-1.0,保留小样本中的有效弱分裂 - scale_pos_weight: 围绕训练集负正样本比的 0.5-1.5 倍自适应 - reg_alpha: 0.0-1.0(L1 正则) - reg_lambda: 0.0-10.0(L2 正则) - learning_rate: 0.005-0.1,对数采样 - n_estimators: 64-512(step 32) """ class_weight_low, class_weight_high = self._get_class_weight_range() return { "num_leaves": {"type": "int", "low": 8, "high": 64}, "max_depth": {"type": "int", "low": 2, "high": 4}, "learning_rate": {"type": "float", "low": 0.005, "high": 0.1, "log": True}, "n_estimators": {"type": "int", "low": 64, "high": 512, "step": 32}, "min_child_samples": {"type": "int", "low": 8, "high": 128, "step": 4}, "subsample": {"type": "float", "low": 0.35, "high": 0.85}, "subsample_freq": {"type": "categorical", "choices": [1]}, "colsample_bytree": {"type": "float", "low": 0.4, "high": 0.9}, "min_split_gain": {"type": "float", "low": 0.0, "high": 1.0}, "scale_pos_weight": { "type": "float", "low": class_weight_low, "high": class_weight_high, }, "reg_alpha": {"type": "float", "low": 0.0, "high": 1.0}, "reg_lambda": {"type": "float", "low": 0.0, "high": 10.0}, } def _get_logisticregression_search_space(self) -> Dict[str, Dict[str, Any]]: """逻辑回归搜索空间 - 基于内部建模经验. 参考内部代码: - C: 正则强度倒数,对数区间 0.01-32(越小正则越强) - penalty: 仅 'l2'(评分卡常用,兼容多数 solver) - class_weight: None / 'balanced'(均可安全持久化到 Optuna storage) - max_iter: 16-256(对数区间),迭代上限 - solver: liblinear / sag / lbfgs / newton-cg """ return { "C": {"type": "float", "low": 0.01, "high": 32.0, "log": True}, "penalty": {"type": "categorical", "choices": ["l2"]}, "class_weight": { "type": "categorical", "choices": [None, "balanced"], }, "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.05,避免小样本下概率过快饱和 - base_max_depth: 基学习器(CART)最大深度,风控场景通常2-3 - base_min_samples_leaf: 叶节点最小样本数,抑制单样本叶节点导致的奇异自然梯度 - minibatch_frac: 小批量采样比例(行采样),至少保留70%样本 - 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.05, "log": True}, "base_max_depth": {"type": "int", "low": 2, "high": 3}, "base_min_samples_leaf": {"type": "int", "low": 5, "high": 20}, "minibatch_frac": {"type": "float", "low": 0.7, "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;公开记录仍保留最终值。 :param params: 搜索点,可仅指定部分搜索参数,其他由采样器补全。 :param user_attrs: 写入 Trial 的附加信息字典。 :param skip_if_exists: 是否跳过已经存在的相同搜索点。 :return: self;执行下一次 fit 时优先评估此点。 **参考样例** >>> tuner.enqueue_trial({'max_depth': 3}, user_attrs={'来源': '人工经验'}) >>> tuner.fit(X_train, y_train, n_trials=10) """ 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 = ( public_point if self.search_space_function is not None else 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 格式,单个值序列或多个值序列,顺序与搜索空间一致 :param user_attrs: 所有入队点共享的附加信息字典。 :param skip_if_exists: 是否跳过已存在的相同搜索点。 :return: self,便于链式调用 **参考样例** >>> tuner.enqueue_trials(param_grid={'max_depth': [2, 3], 'learning_rate': [0.01, 0.05]}) >>> tuner.fit(X_train, y_train, n_trials=4) """ 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 时执行。 :param params: 参数字典或按搜索空间声明顺序排列的值序列。 :param lazy: 兼容参数;均在下次 fit 执行。 :return: self。 **参考样例** >>> tuner.probe({'max_depth': 3}) >>> tuner.fit(X_train, y_train, n_trials=1) """ 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 = ( public_point if self.search_space_function is not None else 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 and name not in self._explicit_fixed_params and params.get(name) is None: params[name] = value def _sample_params(self, trial: "Trial") -> Dict[str, Any]: """从搜索空间采样参数. :param trial: Optuna trial对象 :return: 参数字典 """ if self.search_space_function is not None: params = self.search_space_function(trial) if not isinstance(params, dict): raise TypeError("search_space 函数必须返回模型参数字典") self.trial_results_.setdefault(trial.number, {})["搜索参数"] = params import json try: json.dumps(params, allow_nan=False) except (TypeError, ValueError): trial.set_user_attr("搜索参数需制品", True) else: trial.set_user_attr("搜索参数", params) else: 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, refit=False, full_data=True, **fit_params): """获取并缓存最佳模型;默认按各折最佳轮数使用全部输入重训。 :param refit: True 时强制重新训练;默认复用已训练的 best_model_ :param full_data: 是否关闭最终模型的内部验证划分,使用完整输入 :param fit_params: 最终训练的额外原生参数 :return: 已拟合模型。默认以各折早停最佳轮数中位数在完整输入重训。 full_data=False 保留模型早停设置;更改 full_data 会自动重新拟合。 已释放训练数据时仅允许复用此前缓存的相同模式模型。 **参考样例** >>> best_model = tuner.get_best_model() >>> probability = best_model.predict_proba(X_test)[:, 1] >>> fresh_model = tuner.get_best_model(refit=True) """ if self.best_params_ is None: raise ValueError("请先调用fit()进行调优") if ( self.best_model_ is not None and not refit and not fit_params and getattr(self, "_best_model_full_data_", True) == full_data ): return self.best_model_ if getattr(self, "_training_data_released_", False): raise ValueError("训练数据已释放,不能重训最佳模型;请重新 fit") params = self._build_model_params(self.best_params_) model = self._new_model(params) estimator = self._final_estimator(model) estimator_params = estimator.get_params(deep=False) if hasattr(estimator, "get_params") else dict(params) name = type(estimator).__name__.lower() boosting = any( key in name for key in ( "xgboost", "xgbclassifier", "lightgbm", "lgbmclassifier", "catboost", "ngboost", "ngbclassifier", ) ) if full_data and boosting and hasattr(estimator, "set_params"): try: folds = self.get_trial_result(self.best_trial_.number, load=False).get("各折", []) except ValueError: folds = [] iterations = [ fold["最佳迭代"] for fold in folds if fold.get("启用早停") and fold.get("最佳迭代") is not None and fold["最佳迭代"] >= 0 ] if iterations: offset = 0 if "lightgbm" in name or "lgbm" in name else 1 estimator_params["iterations" if "catboost" in name else "n_estimators"] = max( 1, int(np.ceil(np.median(iterations))) + offset ) if "early_stopping_rounds" in estimator_params: estimator_params["early_stopping_rounds"] = None if "validation_fraction" in estimator_params: estimator_params["validation_fraction"] = 0.0 estimator.set_params(**estimator_params) params = model.get_params(deep=False) runtime = self._fold_fit_params(None) runtime.update(fit_params) self._route_sample_weight(model, runtime, self._sample_weight) if full_data and boosting: runtime.pop("early_stopping_rounds", None) self.refit_model_ = model self.refit_params_ = dict(params) self._fit_fold(model, self._X, self._y, runtime) self.best_model_ = model self._best_model_full_data_ = full_data if hasattr(model, "tuner"): model.tuner = self return model
[文档] def get_optimization_history(self) -> pd.DataFrame: """获取优化历史. :return: 优化历史DataFrame **参考样例** >>> history = tuner.get_optimization_history() >>> history.head() """ if self.optimization_history_ is None: raise ValueError("请先调用fit()进行调优") return self.optimization_history_
[文档] def get_pareto_front(self) -> Optional[List]: """获取帕累托前沿(多目标优化时). :return: 帕累托前沿上的trial列表 **参考样例** >>> pareto = tuner.get_pareto_front() >>> [(trial.number, trial.values) for trial in pareto] """ 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 **参考样例** >>> importance = tuner.get_param_importance(target=0) """ 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_) public_importance = {self._space_adapter.to_public_name(name): value for name, value in importance.items()} return pd.Series(public_importance) except Exception as e: if self.verbose: warnings.warn(f"无法计算参数重要性: {e}") return None
# ==================== 可视化方法 ====================
[文档] def get_study(self) -> Any: """获取完整的原生 Optuna Study,不过滤试验、指标、属性或中间值。 两种训练入口使用同一契约:``model.tune(...)`` 之后可用 ``model.tuner.get_study()``;直接使用 ModelTuner 时用 ``tuner.get_study()``。 返回值与 ``study_`` 是同一对象,可交给任何 Optuna 原生分析和可视化函数。 :return: 原生 optuna.study.Study 对象。 **参考样例** >>> study = tuner.get_study() >>> study.trials_dataframe().head() """ if self.study_ is None: raise ValueError("尚未创建超参数搜索 Study,请先调用 fit()、model.tune() 或传入已有 study") return self.study_
@property def visualization(self): """当前 Optuna 版本的完整可视化入口,自动传入本次搜索的 Study。 例如 ``tuner.visualization.plot_timeline()``、``plot_intermediate_values()``、 ``plot_rank(params=[...])``;Matplotlib 后端使用 ``tuner.visualization.matplotlib.plot_optimization_history()``。 用 ``dir(tuner.visualization)`` 可查看当前版本的全部入口。 此入口保留 Optuna 原生语义:多目标的 target 使用函数,超体积图需要 reference_point;依赖、试验数量或指标条件不足时保留原生错误。 既有 ``tuner.plot_*`` 便捷方法和整数 target 用法继续保留。 :return: 自动绑定 Study 的 Optuna 可视化代理。 **参考样例** >>> tuner.visualization.plot_timeline().show() """ from .visualization import _OptunaVisualization return _OptunaVisualization(self) def _publicize_plot_figure(self, figure: Any) -> Any: """清理 Plotly 图对象中的 Optuna 内部潜变量名。""" if not hasattr(figure, "to_plotly_json"): return figure replacements = {self._space_adapter.latent_name(name): name for name in (self.search_space or {})} def rewrite(value: Any) -> Any: if isinstance(value, str): for internal_name, public_name in replacements.items(): value = value.replace(internal_name, public_name) return value if isinstance(value, dict): return {key: rewrite(item) for key, item in value.items()} if isinstance(value, list): return [rewrite(item) for item in value] if isinstance(value, tuple): return tuple(rewrite(item) for item in value) return value from plotly.graph_objects import Figure return Figure(rewrite(figure.to_plotly_json())) def _translate_plot_params(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: """把可视化方法 kwargs 中的公开 params 转为 Optuna 内部名。""" translated = dict(kwargs) if self.search_space_function is not None or self.trial_objective is not None: return translated if translated.get("params") is not None: translated["params"] = [self._space_adapter.to_internal_name(name) for name in translated["params"]] return translated
[文档] def plot_optimization_history(self, target: Optional[int] = None, **kwargs): """绘制优化历史. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 **参考样例** >>> tuner.plot_optimization_history(target=0).show() """ 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图形对象 **参考样例** >>> tuner.plot_param_importances(target=0).show() """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) kwargs = self._translate_plot_params(kwargs) if self._is_multi_objective: figure = optuna.visualization.plot_param_importances( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) else: figure = optuna.visualization.plot_param_importances(self.study_, **kwargs) return self._publicize_plot_figure(figure)
[文档] def plot_slice(self, target: Optional[int] = None, **kwargs): """绘制参数切片图. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 **参考样例** >>> tuner.plot_slice(params=['max_depth'], target=0).show() """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) kwargs = self._translate_plot_params(kwargs) if self._is_multi_objective: figure = optuna.visualization.plot_slice( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) else: figure = optuna.visualization.plot_slice(self.study_, **kwargs) return self._publicize_plot_figure(figure)
[文档] def plot_pareto_front(self, **kwargs): """绘制帕累托前沿(多目标优化时). :param kwargs: 绘图参数 :return: plotly图形对象 **参考样例** >>> tuner.plot_pareto_front().show() """ 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图形对象 **参考样例** >>> tuner.plot_contour(params=['max_depth', 'learning_rate']).show() """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") if params is None: params = list(self.search_space.keys())[:2] if self.search_space_function is not None or self.trial_objective is not None: params = sorted({name for trial in self.study_.trials for name in trial.params})[:2] internal_params = self._translate_plot_params({"params": params})["params"] target = self._resolve_multi_objective_target(target) if self._is_multi_objective: figure = optuna.visualization.plot_contour( self.study_, params=internal_params, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs, ) else: figure = optuna.visualization.plot_contour(self.study_, params=internal_params, **kwargs) return self._publicize_plot_figure(figure)
[文档] def plot_parallel_coordinate(self, target: Optional[int] = None, **kwargs): """绘制平行坐标图. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 **参考样例** >>> tuner.plot_parallel_coordinate(params=['max_depth', 'learning_rate']).show() """ if self.study_ is None: raise ValueError("请先调用fit()进行调优") target = self._resolve_multi_objective_target(target) kwargs = self._translate_plot_params(kwargs) if self._is_multi_objective: figure = optuna.visualization.plot_parallel_coordinate( self.study_, target=lambda t: t.values[target], target_name=self.metric_names[target], **kwargs ) else: figure = optuna.visualization.plot_parallel_coordinate(self.study_, **kwargs) return self._publicize_plot_figure(figure)
[文档] def plot_edf(self, target: Optional[int] = None, **kwargs): """绘制经验分布函数图. :param target: 多目标时指定要绘制的指标索引,默认第一个 :param kwargs: 绘图参数 :return: plotly图形对象 **参考样例** >>> tuner.plot_edf(target=0).show() """ 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: Optional[Union[str, List[Optional[str]]]] = None, 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: 默认 None 按各指标推断;裸函数须明确方向,多目标可给列表。 :param metric_names: 指标名称列表(多目标时用于显示) :param target: 目标列名,用于scorecardpipeline风格的fit,默认'target' :param cv: 折数、分割器或索引对序列,默认5折分层交叉验证。 :param random_state: 随机种子 :param verbose: 是否输出详细信息 :param early_stopping_rounds: 早停轮数,默认20 :param kwargs: 传给 ModelTuner 的其他参数,例如 search_space、fixed_params、 loss、fit_params、retention、n_jobs。search_space 可覆盖自动空间。 :return: ModelTuner实例 **参考样例** >>> tuner = AutoTuner.create('lr', metric=['auc', 'ks_diff'], random_state=42, ... search_space={'C': [0.1, 1.0, 10.0]}, n_jobs=1) >>> best_params = tuner.fit(X_train, y_train, n_trials=10) >>> model = tuner.get_best_model() """ import importlib 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 = getattr(importlib.import_module("hscredit.core.models"), model_map[model_type]) return ModelTuner( model_class=model_class, search_space=kwargs.pop("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, ) # 使用自适应搜索空间