Sklearn-源码解析-书-v1-0-十九-

Sklearn 源码解析(书)v1.0(十九)

这一段代码实现了:跨所有交叉验证器的统一 split 接口,使得不同实现方式(掩码或索引)能够互操作,降低子类实现的重复工作。

架构图

flowchart TD split[split方法] --> indexable[X, y, groups -> 可索引对象] indexable --> indices[生成样本索引数组] indices --> _iter_test_masks[调用掩码迭代器] _iter_test_masks -->|默认实现| _iter_test_indices[调用索引迭代器] _iter_test_indices -->|子类必须实现| test_index[生成测试集索引/掩码] test_index -->|若是掩码| test_mask[布尔掩码] test_mask --> train_mask[训练掩码 = 非测试掩码] test_mask --> test_indices[转换为测试索引] train_mask --> train_indices[转换为训练索引] test_indices --> yield_test[输出测试索引] train_indices --> yield_train[输出训练索引] yield_train & yield_test --> yield_pair[成对输出(train, test)] yield_pair --> generator[生成器持续yield]

39.6 KFold 交叉验证器 —— 按顺序划分“折叠的数据流”

KFold 继承自 _BaseKFold(已实现通用检查)并实现 _iter_test_indices,负责在不打乱的情况下把样本顺序划分为若干折。

源码路径:sklearn/model_selection/_split.py - KFold(150-200行)

class KFold(_UnsupportedGroupCVMixin, _BaseKFold):
    """K‑Fold cross‑validator."""

    def __init__(self, n_splits=5, *, shuffle=False, random_state=None):
        super().__init__(n_splits=n_splits, shuffle=shuffle, random_state=random_state)

    def _iter_test_indices(self, X, y=None, groups=None):
        n_samples = _num_samples(X)
        indices = np.arange(n_samples)

        # 若 shuffle=True,则先随机打乱索引
        if self.shuffle:
            check_random_state(self.random_state).shuffle(indices)

        n_splits = self.n_splits
        # 计算每折的大小:前 (n_samples % n_splits) 折多一个样本
        fold_sizes = np.full(n_splits, n_samples // n_splits, dtype=int)
        fold_sizes[: n_samples % n_splits] += 1

        current = 0
        for fold_size in fold_sizes:
            start, stop = current, current + fold_size
            # 直接切片返回当前折的测试索引
            yield indices[start:stop]
            current = stop

这一段代码实现了:在保持原始顺序或随机顺序的情况下,将数据划分为若干等大小(或近似等大小)的折,用于交叉验证。

架构图

flowchart TD X[输入特征] --> n_samples[_num_samples(X)] n_samples --> indices[生成顺序索引 0..n-1] indices --> shuffle{是否打乱?} shuffle -->|是| shuffle_indices[随机打乱索引] shuffle -->|否| keep_order[保持原始顺序] shuffle_indices & keep_order --> shuffled_indices[最终用于划分的索引] shuffled_indices --> fold_sizes[计算每折大小] fold_sizes -->|基于 n_samples // n_splits + 余数分配| fold_list[折大小列表] fold_list --> current_pos[当前累计位置] current_pos --> fold_loop[遍历每折] fold_loop --> slice_indices[切片获取测试索引] slice_indices --> yield_indices[输出当前折测试索引] yield_indices --> update_pos[更新当前位置] update_pos --> fold_loop

39.7 GroupKFold 组感知切分器 —— 按组织单元划分“非重叠组”

GroupKFold 通过 组标签 (groups) 确保同一组的样本只能出现在一个折的测试集中。实现分两种路径:shuffle=True 时随机分配组,shuffle=False 时采用 贪心,把样本数最多的组逐个放入当前样本最少的折,以实现折间样本数的平衡。

源码路径:sklearn/model_selection/_split.py - GroupKFold(220-290行)

class GroupKFold(GroupsConsumerMixin, _BaseKFold):
    """K‑fold iterator variant with non‑overlapping groups."""

    def __init__(self, n_splits=5, *, shuffle=False, random_state=None):
        super().__init__(n_splits, shuffle=shuffle, random_state=random_state)

    def _iter_test_indices(self, X, y, groups):
        if groups is None:
            raise ValueError("The 'groups' parameter should not be None.")
        # 确保 groups 为一维 ndarray
        groups = check_array(groups, input_name="groups", ensure_2d=False, dtype=None)

        # 唯一组及其对应的整数映射
        unique_groups, group_idx = np.unique(groups, return_inverse=True)
        n_groups = len(unique_groups)

        if self.n_splits > n_groups:
            raise ValueError(
                "Cannot have number of splits n_splits=%d greater"
                " than the number of groups: %d." % (self.n_splits, n_groups)
            )

        if self.shuffle:
            # 随机打乱唯一组后均匀划分
            rng = check_random_state(self.random_state)
            unique_groups = rng.permutation(unique_groups)
            split_groups = np.array_split(unique_groups, self.n_splits)

            for test_group_ids in split_groups:
                test_mask = np.isin(groups, test_group_ids)
                yield np.where(test_mask)[0]

        else:
            # ---- 非随机情况下的贪心分配 ----
            # 统计每个组出现的次数(即该组的样本数)
            n_samples_per_group = np.bincount(group_idx)

            # 按出现次数降序排列组(最频繁的组先处索)
            indices = np.argsort(n_samples_per_group)[::-1]
            n_samples_per_group = n_samples_per_group[indices]

            # 每折当前累计的样本数(用于寻找最轻的折)
            n_samples_per_fold = np.zeros(self.n_splits)

            # 记录每个组最终所属的折
            group_to_fold = np.zeros(len(unique_groups))

            # 将权重最大的组分配到当前样本最少的折
            for group_index, weight in enumerate(n_samples_per_group):
                lightest_fold = np.argmin(n_samples_per_fold)
                n_samples_per_fold[lightest_fold] += weight
                group_to_fold[indices[group_index]] = lightest_fold

            # 根据分配结果生成每折的测试索引
            indices = group_to_fold[group_idx]

            for f in range(self.n_splits):
                yield np.where(indices == f)[0]

这一段代码实现了:在保持组不交叉的前提下,以最小化每折样本数差异为目标的划分策略,兼顾随机与确定性两种模式。

架构图

flowchart TD groups[组标签] --> unique_groups[唯一组标识] unique_groups --> group_idx[样本到组的映射] group_idx --> n_samples_per_group[计算每组样本数] n_samples_per_group --> shuffle{是否打乱?} shuffle -->|是| permute_groups[随机排列唯一组] permute_groups --> array_split[均匀分组到n_splits折] array_split --> test_group_ids[每折的组ID集合] test_group_ids --> isin[检查样本所属组] isin --> test_mask[生成测试布尔掩码] test_mask --> where[转换为测试索引] where --> yield_indices[输出折测试索引] shuffle -->|否| sort_groups[按样本数降序排序组] sort_groups --> greedy_loop[贪心分配循环] greedy_loop --> lightest_fold[找到当前样本最少的折] lightest_fold --> assign_group[将当前组分配到该折] assign_group --> update_fold_load[更新折样本计数] update_fold_load --> record_group_fold[记录组所属折] record_group_fold --> greedy_loop greedy_loop --> build_group_to_fold[构建组到折的映射] build_group_to_fold --> map_samples[将样本通过group_idx映射到折] map_samples --> indices_per_fold[每折的组归属索引] indices_per_fold --> yield_by_fold[按折输出测试索引] yield_by_fold --> where[np.where(indices == f)] where --> yield_indices[输出当前折测试索引]

39.8 设计中的取舍

在阈值调优与交叉验证的设计中,为什么不直接在单次训练后手动搜索阈值?一次训练无法评估阈值的泛化能力:若只在训练集上搜索最佳阈值,可能会过拟合噪声,导致在未见数据上表现下降。交叉验证提供了对阈值的外部验证,确保找到的阈值在不同子样本上保持稳健。计算成本 VS 精度:虽然交叉验证会多次训练模型(尤其在 refit=True 时会额外一次全数据训练),但 _CurveScorer 的缓存机制极大降低了每折的前向预测开销,只需要一次预测即可评估所有阈值。相比之下,若每个阈值都重新训练模型,成本将呈指数级增长。这种设计的 trade‑off 是什么?优点:通过交叉验证获得可靠的阈值,能够在不同数据划分上保持一致的评估指标。_CurveScorer 的缓存让阈值搜索的额外计算代价接近于仅一次前向预测。缺点:仍需在每折上训练一次模型(除非使用 prefit),在大模型或大量折时仍会产生显著的时间开销。对极端不平衡或稀疏标签的任务,阈值曲线可能非常平坦,导致搜索空间的有效信息有限,仍需要人工干预或自定义评估指标。

39.9 动手练习

  1. 阅读阈值调优分类器与数据切分器实现

    • 阅读 sklearn/model_selection/_classification_threshold.pyFixedThresholdClassifier(约第 50‑120 行),理解其如何处理不同的响应方法。

    • 阅读 TunedThresholdClassifierCV(约第 150‑250 行),重点关注 fit 方法如何利用 _CurveScorer 和交叉验证搜索最优阈值。

    • 阅读 sklearn/model_selection/_split.pyBaseCrossValidator(约第 50‑80 行),理解其 split 方法如何调用 _iter_test_masks_iter_test_indices

    • 阅读 KFold 类(约第 150‑200 行),重点理解 _iter_test_indices 方法如何通过折大小分配生成测试索引。

    • 阅读 GroupKFold 类(约第 220‑280 行),理解其 _iter_test_indices 如何处理组信息并实现划分。

    • 回答问题:

      • FixedThresholdClassifierpredict 方法中如何将 decision_functionpredict_proba 的输出转换为类别标签?

      • TunedThresholdClassifierCV 在交叉验证循环中是如何使用 _CurveScorer 评估不同阈值的性能的?

      • BaseCrossValidatorsplit 方法是如何将掩码或索引转换为训练/测试索引对的?

      • KFold_iter_test_indices 方法在不打乱时如何计算每折的起始和结束索引?

      • GroupKFoldshuffle=False 时如何通过组大小进行贪心分配以实现折的大小均衡?

  2. 探索 _CurveScorer 的缓存机制

    • 阅读 _CurveScorer 类(约第 20‑80 行),理解其如何通过缓存预测结果来避免在交叉验证中重复计算。

    • 研究 _CurveScorer__call__ 方法如何根据不同的 response_method 获取连续响应。

    • 回答问题:

      • _CurveScorer_cache_key 方法生成的缓存键包含哪些信息?

      • _CurveScorer 如何处理不同的 response_method(如 'predict_proba''decision_function')?

      • 为什么在交叉验证中缓存预测结果对于效率至关重要?

  3. 动手实现一个简易的阈值调优器与自定义切分器

    • 参考 TunedThresholdClassifierCV 的实现,尝试自己实现一个简化版的 SimpleThresholdTuner,仅支持单次 train/test 分割且不存储 CV 结果。

      • 实现 __init__(self, estimator=None, scoring=None, cv=None, threshold_grid="auto")

      • 实现 fit(self, X, y, **fit_params) 方法,在给定的 CV 分割器上搜索最优阈值

      • 实现 predict(self, X) 方法,使用学到的最优阈值进行预测

      • 不需要实现 predict_probadecision_function 方法。

    • 参考 BaseCrossValidator 的实现,尝试自己实现一个简化版的 SimpleSplitter,仅实现固定比例的数据划分。

      • 继承 BaseCrossValidator

      • 实现 _iter_test_indices 方法,返回固定比例(如 0.3)的测试集索引

      • 确保 get_n_splits 返回 1。

    • 测试你的实现

      • 在二分类问题上,使用 roc_auc_score 作为评分,验证你的调优器是否能找到最大化 AUC 的阈值。

      • 将你的实现与 TunedThresholdClassifierCV 在相同数据上的性能进行对比。

      • 验证 SimpleSplitter 是否能正确生成单个 train/test 分割。

39.10 本章小结

这一章中我们学习/了解/讨论了阈值调优分类器与数据切分器的核心机制。首先,FixedThresholdClassifier 通过固定阈值把连续响应转为离散标签;随后 _CurveScorer 引入缓存,显著提升阈值搜索的效率;接着 TunedThresholdClassifierCV 将交叉验证与阈值搜索结合,实现了在验证集上寻找最优决策点的完整流程;随后我们深入 BaseCrossValidator 的抽象设计,了解了 split_iter_test_masks_iter_test_indices 的相互补全机制;之后 KFold 展示了基于折大小的顺序划分方式,并解释了余数折的处理逻辑;最后 GroupKFold 在保持组不交叉的前提下,利用贪心策略实现了折之间样本数的均衡分配。通过这些源码的逐行剖析,你已经掌握了阈值调优与交叉验证的底层实现细节,并能够自行实现简化版的调优器与切分器。

这一章我们一起学习了以下概念:

| 概念 | 解释 |

|----------------------|------|

| FixedThresholdClassifier | 将二分类模型的连续输出(概率或决策分数)通过固定阈值映射为离散类别标签 |

| TunedThresholdClassifierCV | 在交叉验证框架下搜索最佳阈值,利用 _CurveScorer 评估阈值曲线并可选在全数据上重新拟合 |

| _CurveScorer | 包装评分器以在多个阈值上评估模型,内部实现预测结果缓存来避免重复前向计算 |

| BaseCrossValidator | 所有交叉验证器的抽象基类,统一 split 接口并提供掩码/索引两种迭代方式 |

| KFold | 标准 K 折划分器,通过余数分配确保折大小尽可能均衡,支持可选打乱 |

| GroupKFold | 组感知的 K 折划分器,确保同一组只出现在一个测试折中,支持随机或贪心均衡划分 |

下一章中,我们将深入探讨交叉验证执行引擎—— cross_validatecross_val_score 与学习曲线等核心函数,了解它们如何在内部调度模型训练、评分与并行计算,进一步提升模型评估的效率与可靠性。

39.11 架构与数据流图

graph TD A[调用者] --> B[公开 API] B --> C[核心实现] C --> D[结果]
sequenceDiagram participant U as 调用者 participant A as 公开 API participant C as 核心实现 U->>A: 调用 A->>C: 委托 C-->>U: 返回结果
graph LR I[输入] --> P[校验] P --> T[处理] T --> O[输出]
graph TD L1[API 层] --> L2[核心逻辑层] L2 --> L3[数据结构层]

39.12 模块地图/架构图

sklearn/model_selection/_classification_threshold.py
├── FixedThresholdClassifier
│   ├── __init__(self, threshold=0.5)
│   ├── fit(self, X, y, **fit_params)
│   ├── predict(self, X)
│   ├── predict_proba(self, X)
│   ├── decision_function(self, X)
│   └── _more_tags()
├── TunedThresholdClassifierCV
│   ├── __init__(self, estimator=None, *, scoring=None, cv=None, refit=True,
│   │    threshold_grid="auto", store_cv_results=False, n_jobs=None,
│   │    verbose=0, random_state=None)
│   ├── fit(self, X, y, **fit_params)
│   ├── predict(self, X)
│   ├── predict_proba(self, X)
│   ├── decision_function(self, X)
│   ├── score(self, X, y=None)
│   └── _more_tags()
└── _CurveScorer
    ├── __init__(self, scorer, response_method)
    ├── _cache_key(self, X, y, sample_weight=None)
    ├── __call__(self, estimator, X, y=None, sample_weight=None)
    └── _wrap_score(self, score, y_weight=None)
sklearn/model_selection/_split.py
├── BaseCrossValidator
│   ├── split(self, X, y=None, groups=None)
│   ├── _iter_test_masks(self, X=None, y=None, groups=None)
│   └── _iter_test_indices(self, X=None, y=None, groups=None)
├── KFold
│   ├── __init__(self, n_splits=5, *, shuffle=False, random_state=None)
│   └── _iter_test_indices(self, X, y=None, groups=None)
├── GroupKFold
│   ├── __init__(self, n_splits=5, *, shuffle=False, random_state=None)
│   ├── _iter_test_indices(self, X, y, groups)
│   └── split(self, X, y=None, groups=None)

第 40 章 —— 交叉验证引擎 —— 驱动“模型评估的流水线车间”

40.1 学习目标

  • 理解交叉验证引擎 cross_validate 的核心流程:参数校验、评分器构建、元数据路由、并行执行、错误容忍与结果聚合

  • 掌握 cross_val_score 如何基于 cross_validate 实现单指标评估的简化管线

  • 深入 cross_val_predict 的预测收集与顺序保障机制,理解 _enforce_prediction_order 如何处理缺失类别

  • 剖析 learning_curve 的双路径设计:普通批量学习与增量学习(partial_fit)的并行评估策略

  • 理解 permutation_test_score 通过目标置换生成经验零分布并计算 p 值的统计显著性检验原理

  • 掌握 validation_curve 单参数网格搜索的内部工作流与分数聚合逻辑

  • 熟悉统一分数处理工具 _score 与错误安全机制 _warn_or_raise_about_fit_failures 的容错设计

  • 了解可视化类 LearningCurveDisplayValidationCurveDisplay 如何复用验证曲线计算并自动推断坐标轴缩放

  • 难度:★★★☆☆(3/5)

  • 预备知识:Python 基础、面向对象编程与代码阅读基础

40.2 生活类比

想象一家大型工厂的生产线:原材料(Xyestimator)被送进总调度中心cross_validate),它负责把材料拆分成若干子工位cv.split),每个子工位再交给并行加工车间Parallel + _fit_and_score)完成组装、质检和计时。若有某些子工位出现故障,调度中心会记录错误、决定是立刻停产还是用预设的“次品”分数继续生产,最终将所有工位的产出汇总成一张完整的 质量报告(返回字典)。这种层层分解、并行调度与容错策略正是 cross_validate 在模型评估流水线中的真实写照。

40.3 源码地图

sklearn/model_selection/_validation.py
│   ├─ cross_validate
│   ├─ _fit_and_score
│   ├─ _score
│   ├─ _warn_or_raise_about_fit_failures
│   ├─ _insert_error_scores
│   ├─ _normalize_score_results
│   ├─ _aggregate_score_dicts
│   ├─ cross_val_score   ← 仅包装 cross_validate
│   ├─ cross_val_predict ← 预测收集与 _enforce_prediction_order
│   ├─ _fit_and_predict
│   ├─ _enforce_prediction_order
│   ├─ _shuffle
│   ├─ permutation_test_score
│   ├─ _permutation_test_score
│   ├─ learning_curve
│   ├─ _incremental_fit_estimator
│   ├─ validation_curve
│   └─ …(其他辅助工具函数)

40.4 源码解析单元

40.4.1 cross_validatesklearn/model_selection/_validation.py 第 90‑155 行)

def cross_validate(
    estimator,
    X,
    y=None,
    *,
    groups=None,
    scoring=None,
    cv=None,
    n_jobs=None,
    verbose=0,
    params=None,
    pre_dispatch="2*n_jobs",
    return_train_score=False,
    return_estimator=False,
    return_indices=False,
    error_score=np.nan,
):
    """Evaluate metric(s) by cross-validation and also record fit/score times."""
    # 1️⃣ 参数合法性检查
    _check_groups_routing_disabled(groups)

    # 把 X、y 包装成统一的索引接口
    X, y = indexable(X, y)
    params = {} if params is None else params

    # 2️⃣ 交叉验证划分器
    cv = check_cv(cv, y, classifier=is_classifier(estimator))

    # 3️⃣ 评分器统一构建(多/单指标均返回统一对象)
    scorers = check_scoring(
        estimator, scoring=scoring, raise_exc=(error_score == "raise")
    )

    # 4️⃣ 元数据路由(仅在开启时使用)
    if _routing_enabled():
        router = (
            MetadataRouter(owner="cross_validate")
            .add(
                splitter=cv,
                method_mapping=MethodMapping().add(caller="fit", callee="split"),
            )
            .add(
                estimator=estimator,
                method_mapping=MethodMapping().add(caller="fit", callee="fit"),
            )
            .add(
                scorer=scorers,
                method_mapping=MethodMapping().add(caller="fit", callee="score"),
            )
        )
        routed_params = process_routing(router, "fit", **params)
    else:
        routed_params = Bunch()
        routed_params.splitter = Bunch(split={"groups": groups})
        routed_params.estimator = Bunch(fit=params)
        routed_params.scorer = Bunch(score={})

    # 5️⃣ 生成每折的 train / test 索引
    indices = cv.split(X, y, **routed_params.splitter.split)
    if return_indices:
        indices = list(indices)  # materialize 供返回使用

    # 6️⃣ 并行调度每折的 fit & score
    parallel = Parallel(n_jobs=n_jobs, verbose=verbose, pre_dispatch=pre_dispatch)
    results = parallel(
        delayed(_fit_and_score)(
            clone(estimator),          # 克隆,保证折间互不影响
            X,
            y,
            scorer=scorers,
            train=train,
            test=test,
            verbose=verbose,
            parameters=None,
            fit_params=routed_params.estimator.fit,
            score_params=routed_params.scorer.score,
            return_train_score=return_train_score,
            return_times=True,
            return_estimator=return_estimator,
            error_score=error_score,
        )
        for train, test in indices
    )

    # 7️⃣ 错误汇总 / 警告(全部失败抛异常,部分失败发 FitFailedWarning)
    _warn_or_raise_about_fit_failures(results, error_score)

    # 8️⃣ 多指标场景下填充 error_score(单指标已在 _fit_and_score 处理)
    if callable(scoring):
        _insert_error_scores(results, error_score)

    # 9️⃣ 聚合所有折的分数、时间等信息
    results = _aggregate_score_dicts(results)

    # 1️⃣0️⃣ 构造返回字典(仅包含用户请求的键)
    ret = {"fit_time": results["fit_time"], "score_time": results["score_time"]}
    if return_estimator:
        ret["estimator"] = results["estimator"]
    if return_indices:
        ret["indices"] = {"train": tuple(zip(*indices))[0],
                         "test":  tuple(zip(*indices))[1]}

    test_scores_dict = _normalize_score_results(results["test_scores"])
    if return_train_score:
        train_scores_dict = _normalize_score_results(results["train_scores"])

    for name in test_scores_dict:
        ret[f"test_{name}"] = test_scores_dict[name]
        if return_train_score:
            ret[f"train_{name}] = train_scores_dict[name]

    return ret

关键要点

  • 参数检查:通过 _check_groups_routing_disabled 防止在开启元数据路由时误用 groups

  • 索引化indexable 把可能的 pandas、列表或稀疏矩阵统一为可切片对象。

  • 评分器check_scoring 将用户提供的 scoring(字符串、callable、列表或 dict)统一为 _MultimetricScorer 或单指标 scorer。

  • 元数据路由:若启用,则使用 MetadataRouterfit_paramssplit_paramsscore_params 按照调用者‑被调者映射安全传递;否则使用默认 Bunch

  • 并行Parallel 调度每折的 _fit_and_score,保证每折使用 克隆 的 estimator,防止状态泄漏。

  • 错误容忍_warn_or_raise_about_fit_failures 负责整体错误评估,依据 error_score 决定是抛异常还是填充默认分数。

  • 结果聚合_aggregate_score_dicts 把列表形式的字典转为键→np.ndarray 结构,便于后续统计和绘图。

流程图(交叉验证整体调度)

flowchart TD A[开始] --> B[参数合法性检查] B --> C[索引化 X、y] C --> D[构建 cv 划分器] D --> E[统一构建 scorer] E --> F{元数据路由} F -->|启用| G[MetadataRouter + process_routing] F -->|关闭| H[使用默认 Bunch 参数] G --> I[生成 train/test 索引] H --> I I --> J[Parallel 调度 _fit_and_score] J --> K[汇总 fit_error → 警告/异常] K --> L[多指标填充 error_score] L --> M[聚合结果数组] M --> N[构造返回字典] N --> O[结束]

40.4.2 _fit_and_scoresklearn/model_selection/_validation.py 第 684‑814 行)

def _fit_and_score(
    estimator,
    X,
    y,
    *,
    scorer,
    train,
    test,
    verbose,
    parameters,
    fit_params,
    score_params,
    return_train_score=False,
    return_parameters=False,
    return_n_test_samples=False,
    return_times=False,
    return_estimator=False,
    split_progress=None,
    candidate_progress=None,
    error_score=np.nan,
):
    """Fit estimator and compute scores for a given dataset split."""
    # 1️⃣ 把索引转换为对应后端数组(NumPy / CuPy 兼容)
    xp, _ = get_namespace(X)
    X_device = device(X)
    train, test = xp.asarray(train, device=X_device), xp.asarray(test, device=X_device)

    # 2️⃣ 参数合法性检查(error_score 必须是数值或 'raise')
    if not isinstance(error_score, numbers.Number) and error_score != "raise":
        raise ValueError("error_score must be 'raise' or numeric.")

    # 3️⃣ 对 fit_params / score_params 按索引切片(支持 sample_weight、class_weight 等)
    fit_params = fit_params if fit_params is not None else {}
    fit_params = _check_method_params(X, params=fit_params, indices=train)

    score_params = score_params if score_params is not None else {}
    score_params_train = _check_method_params(X, params=score_params, indices=train)
    score_params_test = _check_method_params(X, params=score_params, indices=test)

    # 4️⃣ 若 parameters 包含估计器本身(管道搜索),先 clone 再 set_params,防止副作用
    if parameters is not None:
        estimator = estimator.set_params(**clone(parameters, safe=False))

    start_time = time.time()

    # 5️⃣ 切分训练/测试子集
    X_train, y_train = _safe_split(estimator, X, y, train)
    X_test, y_test = _safe_split(estimator, X, y, test, train)

    result = {}
    try:
        # 6️⃣ 拟合(支持无监督 y 为 None)
        if y_train is None:
            estimator.fit(X_train, **fit_params)
        else:
            estimator.fit(X_train, y_train, **fit_params)
    except Exception:                     # 🎯 错误捕获点
        fit_time = time.time() - start_time
        score_time = 0.0
        if error_score == "raise":
            raise
        elif isinstance(error_score, numbers.Number):
            # 多指标 → 为每个 scorer 填充 error_score;单指标 → 直接使用数值
            if isinstance(scorer, _MultimetricScorer):
                test_scores = {name: error_score for name in scorer._scorers}
                if return_train_score:
                    train_scores = test_scores.copy()
            else:
                test_scores = error_score
                if return_train_score:
                    train_scores = error_score
        result["fit_error"] = format_exc()
    else:
        result["fit_error"] = None
        fit_time = time.time() - start_time

        # 7️⃣ 评分(统一调用 _score,统一异常处理)
        test_scores = _score(
            estimator, X_test, y_test, scorer, score_params_test, error_score
        )
        score_time = time.time() - start_time - fit_time
        if return_train_score:
            train_scores = _score(
                estimator, X_train, y_train, scorer, score_params_train, error_score
            )

    # 8️⃣ 可选日志(verbose 控制)
    if verbose > 1:
        # (略去细节日志,保持代码简洁)

    # 9️⃣ 填充返回结构
    result["test_scores"] = test_scores
    if return_train_score:
        result["train_scores"] = train_scores
    if return_n_test_samples:
        result["n_test_samples"] = _num_samples(X_test)
    if return_times:
        result["fit_time"] = fit_time
        result["score_time"] = score_time
    if return_parameters:
        result["parameters"] = parameters
    if return_estimator:
        result["estimator"] = estimator
    return result

细节解释

  • 后端抽象xp, _ = get_namespace(X) 使代码兼容 NumPy、CuPy、Dask 等多种数组后端。

  • 参数切片_check_method_params 根据提供的 train / test 索引对 fit_paramsscore_params 执行安全切片,保证每个样本的权重、组信息等精准对齐。

  • 安全克隆:当 parameters 本身可能是另一个 estimator(如管道搜索中的子 estimator)时,使用 clone(parameters, safe=False) 防止在并行环境中出现不可序列化对象。

  • 异常捕获:若拟合抛异常,error_score 决定是直接向上抛出('raise')还是用统一的数值填充 test_scores(多指标时为字典)。此时 fit_time 已记录,score_time 设为 0,以便后续统计。

  • 返回控制:通过一系列布尔标志灵活决定是否返回训练分数、样本数、计时信息或已拟合的 estimator,满足不同调试与分析需求。

40.4.3 _scoresklearn/model_selection/_validation.py 第 828‑903 行)

def _score(estimator, X_test, y_test, scorer, score_params, error_score="raise"):
    """Compute the score(s) of an estimator on a given test set."""
    score_params = {} if score_params is None else score_params
    try:
        # 调用 scorer,支持有监督/无监督两种签名
        if y_test is None:
            scores = scorer(estimator, X_test, **score_params)
        else:
            scores = scorer(estimator, X_test, y_test, **score_params)
    except Exception:
        # 多指标 scorer 必须在 error_score='raise' 时冒泡
        if isinstance(scorer, _MultimetricScorer):
            raise
        else:
            if error_score == "raise":
                raise
            else:
                scores = error_score
                warnings.warn(
                    f"Scoring failed. The score on this train-test partition for "
                    f"these parameters will be set to {error_score}. Details: \n"
                    f"{format_exc()}",
                    UserWarning,
                )

    # 处理多指标 scorer 中局部异常(部分指标报错、其余正常)
    if isinstance(scorer, _MultimetricScorer):
        exception_messages = [(name, str_e) for name, str_e in scores.items()
                              if isinstance(str_e, str)]
        if exception_messages:
            for name, str_e in exception_messages:
                scores[name] = error_score
                warnings.warn(
                    f"Scoring failed. The score on this train-test partition for "
                    f"these parameters will be set to {error_score}. Details: \n{str_e}",
                    UserWarning,
                )

    # 📏 类型检查:必须返回数值
    error_msg = "scoring must return a number, got %s (%s) instead. (scorer=%s)"
    if isinstance(scores, dict):
        for name, score in scores.items():
            if hasattr(score, "item"):
                with suppress(ValueError):
                    score = score.item()
            if not isinstance(score, numbers.Number):
                raise ValueError(error_msg % (score, type(score), name))
            scores[name] = score
    else:
        if hasattr(scores, "item"):
            with suppress(ValueError):
                scores = scores.item()
        if not isinstance(scores, numbers.Number):
            raise ValueError(error_msg % (scores, type(scores), scorer))
    return scores

核心逻辑

  • 异常策略:单指标 scorer 在 error_score 为数值时捕获异常并返回该数值;多指标 scorer 必须在 error_score='raise' 时冒泡(因为返回结构必须是 dict),否则在外层由 _fit_and_score 统一填充。

  • 局部异常:当多指标 scorer 返回的字典中混入字符串(表示局部错误)时,遍历并将对应项替换为 error_score,同时发出 UserWarning

  • 数值校验:通过 np.ndarray.item()float 等方式把可能的 NumPy 标量转为 Python 原生数值,若仍不是 numbers.Number 则抛出 ValueError,确保后续聚合不出现类型不匹配。

40.4.4 _warn_or_raise_about_fit_failuressklearn/model_selection/_validation.py 第 736‑776 行)

def _warn_or_raise_about_fit_failures(results, error_score):
    """Summarize fit errors and either warn or raise."""
    fit_errors = [result["fit_error"] for result in results
                  if result["fit_error"] is not None]
    if fit_errors:
        num_failed_fits = len(fit_errors)
        num_fits = len(results)
        fit_errors_counter = Counter(fit_errors)
        delimiter = "-" * 80 + "\n"
        fit_errors_summary = "\n".join(
            f"{delimiter}{n} fits failed with the following error:\n{error}"
            for error, n in fit_errors_counter.items()
        )
        if num_failed_fits == num_fits:
            # 所有折都失败 → 抛出致命错误
            all_fits_failed_message = (
                f"\nAll the {num_fits} fits failed.\n"
                "It is very likely that your model is misconfigured.\n"
                "You can try to debug the error by setting error_score='raise'.\n\n"
                f"Below are more details about the failures:\n{fit_errors_summary}"
            )
            raise ValueError(all_fits_failed_message)
        else:
            # 部分折失败 → 发出 FitFailedWarning,继续返回成功折的结果
            some_fits_failed_message = (
                f"\n{num_failed_fits} fits failed out of a total of {num_fits}.\n"
                f"The score on these train-test partitions for these parameters will be set to {error_score}.\n"
                "If these failures are not expected, you can try to debug them "
                "by setting error_score='raise'.\n\n"
                f"Below are more details about the failures:\n{fit_errors_summary}"
            )
            warnings.warn(some_fits_failed_message, FitFailedWarning)

设计取舍

  • 全失效:若全部折出现 fit_error,意味着模型或数据根本无法完成一次拟合,直接抛 ValueError,帮助用户快速定位根本性错误。

  • 部分失效:在实际生产环境中,偶尔因单个折数据异常(如极端类别不平衡)导致拟合失败是可以接受的。此时发出 FitFailedWarning 并在后续聚合时使用 error_score 填充缺失分数,使评估仍能产出可视化结果。

40.4.5 _incremental_fit_estimatorsklearn/model_selection/_validation.py 第 1157‑1199 行)

def _incremental_fit_estimator(
    estimator,
    X,
    y,
    classes,
    train,
    test,
    train_sizes,
    scorer,
    return_times,
    error_score,
    fit_params,
    score_params,
):
    """Train estimator on training subsets incrementally and compute scores."""
    train_scores, test_scores, fit_times, score_times = [], [], [], []
    partitions = zip(train_sizes, np.split(train, train_sizes)[:-1])
    if fit_params is None:
        fit_params = {}
    # 根据是否是分类任务决定是否需要传递 classes 参数
    if classes is None:
        partial_fit_func = partial(estimator.partial_fit, **fit_params)
    else:
        partial_fit_func = partial(estimator.partial_fit, classes=classes, **fit_params)

    score_params = score_params if score_params is not None else {}
    score_params_train = _check_method_params(X, params=score_params, indices=train)
    score_params_test = _check_method_params(X, params=score_params, indices=test)

    for n_train_samples, partial_train in partitions:
        train_subset = train[:n_train_samples]
        X_train, y_train = _safe_split(estimator, X, y, train_subset)
        X_test, y_test = _safe_split(estimator, X, y, test, train_subset)

        # ① 增量拟合
        start_fit = time.time()
        if y_train is None:
            partial_fit_func(X_train)
        else:
            partial_fit_func(X_train, y_train)
        fit_time = time.time() - start_fit
        fit_times.append(fit_time)

        # ② 评分
        start_score = time.time()
        test_scores.append(_score(estimator, X_test, y_test, scorer,
                                  score_params_test, error_score))
        train_scores.append(_score(estimator, X_train, y_train, scorer,
                                   score_params_train, error_score))
        score_time = time.time() - start_score
        score_times.append(score_time)

    # 根据是否需要返回时间信息,挑选返回结构
    ret = (train_scores, test_scores, fit_times, score_times) if return_times \
          else (train_scores, test_scores)
    return np.array(ret).T

要点

  • 通过 partial_fit 对模型进行 增量学习,在每个 train_sizes 阶段仅使用新增样本继续训练,避免从头重新拟合。

  • 对于分类任务,需要提供 classes 参数以保证增量学习过程中类别信息的完整。

  • score_paramsfit_params 同样通过 _check_method_params 按索引切片,保持每个子集的权重或样本特异参数一致。

  • 最终返回形状为 (n_sizes, n_folds, ...) 的数组,供 learning_curve 进一步 reshape。

40.4.6 learning_curvesklearn/model_selection/_validation.py 第 1260‑1370 行)

def learning_curve(
    estimator,
    X,
    y,
    *,
    groups=None,
    train_sizes=np.linspace(0.1, 1.0, 5),
    cv=None,
    scoring=None,
    exploit_incremental_learning=False,
    n_jobs=None,
    pre_dispatch="all",
    verbose=0,
    shuffle=False,
    random_state=None,
    error_score=np.nan,
    return_times=False,
    params=None,
):
    """Learning curve."""
    # 1️⃣ 参数与增量学习检查
    if exploit_incremental_learning and not hasattr(estimator, "partial_fit"):
        raise ValueError("An estimator must support the partial_fit interface to exploit incremental learning")
    _check_groups_routing_disabled(groups)
    params = {} if params is None else params

    # 2️⃣ 索引化 & cv / scorer 构建
    X, y, groups = indexable(X, y, groups)
    cv = check_cv(cv, y, classifier=is_classifier(estimator))
    scorer = check_scoring(estimator, scoring=scoring)

    # 3️⃣ 元数据路由(同前)
    if _routing_enabled():
        # router 细节省略,process_routing 会返回 routed_params
        routed_params = process_routing(router, "fit", **params)
    else:
        routed_params = Bunch()
        routed_params.estimator = Bunch(fit=params, partial_fit=params)
        routed_params.splitter = Bunch(split={"groups": groups})
        routed_params.scorer = Bunch(score={})

    # 4️⃣ 将 cv 划分结果 materialize 为列表(后续多次遍历)
    cv_iter = list(cv.split(X, y, **routed_params.splitter.split))

    # 5️⃣ 把相对的 train_sizes 转为绝对样本数
    n_max_training_samples = len(cv_iter[0][0])
    train_sizes_abs = _translate_train_sizes(train_sizes, n_max_training_samples)
    if verbose > 0:
        print("[learning_curve] Training set sizes: " + str(train_sizes_abs))

    parallel = Parallel(n_jobs=n_jobs, pre_dispatch=pre_dispatch, verbose=verbose)

    # 6️⃣ 是否需要在每折内部洗牌训练集
    if shuffle:
        rng = check_random_state(random_state)
        cv_iter = ((rng.permutation(train), test) for train, test in cv_iter)

    # 7️⃣ 两条执行路径
    if exploit_incremental_learning:
        # 7️⃣① 增量学习路径
        classes = np.unique(y) if is_classifier(estimator) else None
        out = parallel(
            delayed(_incremental_fit_estimator)(
                clone(estimator),
                X,
                y,
                classes,
                train,
                test,
                train_sizes_abs,
                scorer,
                return_times,
                error_score=error_score,
                fit_params=routed_params.estimator.partial_fit,
                score_params=routed_params.scorer.score,
            )
            for train, test in cv_iter
        )
        out = np.asarray(out).transpose((2, 1, 0))  # (n_folds, n_sizes, metrics)
    else:
        # 7️⃣② 普通批量学习路径:为每个 (train, test) 与每个 train_size 生成组合
        train_test_proportions = []
        for train, test in cv_iter:
            for n_train_samples in train_sizes_abs:
                train_test_proportions.append((train[:n_train_samples], test))

        results = parallel(
            delayed(_fit_and_score)(
                clone(estimator), X, y,
                scorer=scorer,
                train=train,
                test=test,
                verbose=verbose,
                parameters=None,
                fit_params=routed_params.estimator.fit,
                score_params=routed_params.scorer.score,
                return_train_score=True,
                error_score=error_score,
                return_times=return_times,
            )
            for train, test in train_test_proportions
        )
        _warn_or_raise_about_fit_failures(results, error_score)
        results = _aggregate_score_dicts(results)

        # 重新 reshape 为 (n_folds, n_sizes) 结构
        train_scores = results["train_scores"].reshape(-1, train_sizes_abs.shape[0]).T
        test_scores = results["test_scores"].reshape(-1, train_sizes_abs.shape[0]).T
        out = [train_scores, test_scores]

        if return_times:
            fit_times = results["fit_time"].reshape(-1, train_sizes_abs.shape[0]).T
            score_times = results["score_time"].reshape(-1, train_sizes_abs.shape[0]).T
            out.extend([fit_times, score_times])

    # 8️⃣ 统一返回结构
    ret = (train_sizes_abs, out[0], out[1])
    if return_times:
        ret = ret + (out[2], out[3])
    return ret

两条路径对比

| 特性 | 普通批量学习路径 | 增量学习路径 |

|------|-------------------|-------------|

| 每个 train_size 是否重新 从头 拟合 | 是(clone(estimator)) | 否(partial_fit 累计) |

| 适用模型 | 任意实现 fit 的 estimator | 必须实现 partial_fit(如 SGDClassifierPassiveAggressive) |

| 计算成本 | 随 train_size 指数增长 | 线性增长(增量更新) |

| 结果差异 | 在非凸、随机梯度、学习率衰减情况下可能有差异 | 当学习率恒定且无随机性时,结果趋于一致 |

流程图(learning_curve)

flowchart TD A[开始] --> B{增量学习?} B -->|是| C[准备 classes] B -->|否| D[准备批量组合] C --> E[并行调用 _incremental_fit_estimator] D --> F[生成 (train, test) × train_sizes 组合] F --> G[并行调用 _fit_and_score] E --> H[转置数组 → (n_folds, n_sizes, …)] G --> I[聚合 & reshape 成 (n_folds, n_sizes) 结构] H --> J[返回 train_sizes, train_scores, test_scores] I --> J J --> K[结束]

40.4.7 permutation_test_scoresklearn/model_selection/_validation.py 第 1398‑1499 行)

def permutation_test_score(
    estimator,
    X,
    y,
    *,
    groups=None,
    cv=None,
    n_permutations=100,
    n_jobs=None,
    random_state=0,
    verbose=0,
    scoring=None,
    params=None,
):
    """Evaluate the significance of a cross-validated score with permutations."""
    _check_groups_routing_disabled(groups)
    params = {} if params is None else params

    X, y, groups = indexable(X, y, groups)

    cv = check_cv(cv, y, classifier=is_classifier(estimator))
    scorer = check_scoring(estimator, scoring=scoring)
    random_state = check_random_state(random_state)

    # 路由(同前)
    if _routing_enabled():
        router = (MetadataRouter(owner="permutation_test_score")
            .add(estimator=estimator,
                 method_mapping=MethodMapping().add(caller="fit", callee="fit"))
            .add(splitter=cv,
                 method_mapping=MethodMapping().add(caller="fit", callee="split"))
            .add(scorer=scorer,
                 method_mapping=MethodMapping().add(caller="fit", callee="score"))
        )
        routed_params = process_routing(router, "fit", **params)
    else:
        routed_params = Bunch()
        routed_params.estimator = Bunch(fit=params)
        routed_params.splitter = Bunch(split={"groups": groups})
        routed_params.scorer = Bunch(score={})

    # 1️⃣ 基准分数(未置换)
    score = _permutation_test_score(
        clone(estimator), X, y, cv, scorer,
        split_params=routed_params.splitter.split,
        fit_params=routed_params.estimator.fit,
        score_params=routed_params.scorer.score,
    )
    # 2️⃣ 多次置换 → 并行计算
    permutation_scores = Parallel(n_jobs=n_jobs, verbose=verbose)(
        delayed(_permutation_test_score)(
            clone(estimator),
            X,
            _shuffle(y, groups, random_state),
            cv,
            scorer,
            split_params=routed_params.splitter.split,
            fit_params=routed_params.estimator.fit,
            score_params=routed_params.scorer.score,
        )
        for _ in range(n_permutations)
    )
    permutation_scores = np.array(permutation_scores)

    # 3️⃣ 计算 p‑值 (C+1)/(n_permutations+1)
    pvalue = (np.sum(permutation_scores >= score) + 1.0) / (n_permutations + 1)
    return score, permutation_scores, pvalue

关键步骤

  1. 基准分数:在原始标签上执行完整交叉验证并取平均。

  2. 置换_shuffle 在保留 groups 结构的前提下随机重新排列目标变量,实现 条件置换

  3. 并行:每一次置换都调用 _permutation_test_score,该函数内部遍历 cv.split,对每折进行拟合与评分,最后返回该置换的平均得分。

  4. p‑值:采用 加一法 (C+1)/(n_permutations+1) 避免出现 0 或 1 的极端 p‑值,保证统计稳健。

流程图(置换检验)

flowchart TD A[开始] --> B[参数合法性检查 & 索引化] B --> C[构建 cv、scorer、随机状态] C --> D{元数据路由} D -->|是| E[MetadataRouter + process_routing] D -->|否| F[默认参数字典] E --> G[计算基准分数] F --> G G --> H[循环 n_permutations 次] H --> I[_shuffle(y, groups, rs)] I --> J[并行调用 _permutation_test_score] J --> K[收集置换得分数组] K --> L[计算 p‑值] L --> M[返回 (score, permutation_scores, pvalue)] M --> N[结束]

40.4.8 validation_curvesklearn/model_selection/_validation.py 第 1553‑1632 行)

def validation_curve(
    estimator,
    X,
    y,
    *,
    param_name,
    param_range,
    groups=None,
    cv=None,
    scoring=None,
    n_jobs=None,
    pre_dispatch="all",
    verbose=0,
    error_score=np.nan,
    params=None,
):
    """Validation curve."""
    _check_groups_routing_disabled(groups)
    params = {} if params is None else params

    X, y, groups = indexable(X, y, groups)
    cv = check_cv(cv, y, classifier=is_classifier(estimator))
    scorer = check_scoring(estimator, scoring=scoring)

    if _routing_enabled():
        router = (MetadataRouter(owner="validation_curve")
            .add(estimator=estimator,
                 method_mapping=MethodMapping().add(caller="fit", callee="fit"))
            .add(splitter=cv,
                 method_mapping=MethodMapping().add(caller="fit", callee="split"))
            .add(scorer=scorer,
                 method_mapping=MethodMapping().add(caller="fit", callee="score"))
        )
        routed_params = process_routing(router, "fit", **params)
    else:
        routed_params = Bunch()
        routed_params.estimator = Bunch(fit=params)
        routed_params.splitter = Bunch(split={"groups": groups})
        routed_params.scorer = Bunch(score={})

    parallel = Parallel(n_jobs=n_jobs, pre_dispatch=pre_dispatch, verbose=verbose)
    results = parallel(
        delayed(_fit_and_score)(
            clone(estimator),
            X,
            y,
            scorer=scorer,
            train=train,
            test=test,
            verbose=verbose,
            parameters={param_name: v},
            fit_params=routed_params.estimator.fit,
            score_params=routed_params.scorer.score,
            return_train_score=True,
            error_score=error_score,
        )
        for train, test in cv.split(X, y, **routed_params.splitter.split)
        for v in param_range
    )
    n_params = len(param_range)

    # 聚合并 reshape 为 (n_params, n_folds)
    results = _aggregate_score_dicts(results)
    train_scores = results["train_scores"].reshape(-1, n_params).T
    test_scores = results["test_scores"].reshape(-1, n_params).T
    return train_scores, test_scores

工作流

  • 双层循环:外层遍历 CV 折,内层遍历 param_range。每一次组合都克隆 estimator、设置 param_name=v 并调用 _fit_and_score

  • 并行:所有组合一次性提交给 Parallel,极大提升计算效率。

  • 聚合_aggregate_score_dicts 先把列表转为字典 → np.ndarray,随后 reshape(n_params, n_folds),便于绘图类直接使用。

流程图(验证曲线)

flowchart TD A[开始] --> B[参数检查 + 索引化] B --> C[构建 cv、scorer] C --> D{元数据路由} D -->|是| E[MetadataRouter + process_routing] D -->|否| F[默认 Bunch 参数] E --> G[Parallel 调度] F --> G G --> H[遍历 (train, test) × param_range,调用 _fit_and_score] H --> I[收集所有结果] I --> J[_aggregate_score_dicts] J --> K[reshape 为 (n_params, n_folds)] K --> L[返回 train_scores, test_scores] L --> M[结束]

40.4.9 _BaseCurveDisplay._plot_curvesklearn/model_selection/_plot.py 第 20‑80 行)

class _BaseCurveDisplay:
    def _plot_curve(
        self,
        x_data,
        *,
        ax=None,
        negate_score=False,
        score_name=None,
        score_type="test",
        std_display_style="fill_between",
        line_kw=None,
        fill_between_kw=None,
        errorbar_kw=None,
    ):
        check_matplotlib_support(f"{self.__class__.__name__}.plot")
        import matplotlib.pyplot as plt

        if ax is None:
            _, ax = plt.subplots()

        # 1️⃣ 取反(针对 neg_* 分数)
        if negate_score:
            train_scores, test_scores = -self.train_scores, -self.test_scores
        else:
            train_scores, test_scores = self.train_scores, self.test_scores

        # 2️⃣ 选择绘制的线(train / test / both)
        if score_type == "train":
            scores = {"Train": train_scores}
        elif score_type == "test":
            scores = {"Test": test_scores}
        else:
            scores = {"Train": train_scores, "Test": test_scores}

        # 3️⃣ 标准差展示风格
        if std_display_style in ("fill_between", None):
            # 只绘制均值线
            line_kw = {} if line_kw is None else line_kw
            self.lines_ = []
            for label, score in scores.items():
                self.lines_.append(*ax.plot(x_data, score.mean(axis=1), label=label, **line_kw))
            self.errorbar_, self.fill_between_ = None, None
        if std_display_style == "errorbar":
            errorbar_kw = {} if errorbar_kw is None else errorbar_kw
            self.errorbar_ = []
            for label, score in scores.items():
                self.errorbar_.append(ax.errorbar(x_data, score.mean(axis=1),
                                         score.std(axis=1), label=label, **errorbar_kw))
            self.lines_, self.fill_between_ = None, None
        elif std_display_style == "fill_between":
            fill_between_kw = {"alpha": 0.5, **(fill_between_kw or {})}
            self.fill_between_ = []
            for label, score in scores.items():
                self.fill_between_.append(
                    ax.fill_between(x_data,
                                    score.mean(axis=1) - score.std(axis=1),
                                    score.mean(axis=1) + score.std(axis=1),
                                    **fill_between_kw)
                )

        # 4️⃣ 自动推断坐标轴尺度
        score_name = self.score_name if score_name is None else score_name
        ax.legend()
        if _interval_max_min_ratio(x_data) > 5:
            xscale = "symlog" if x_data.min() <= 0 else "log"
        else:
            xscale = "linear"
        ax.set_xscale(xscale)
        ax.set_ylabel(f"{score_name}")

        self.ax_ = ax
        self.figure_ = ax.figure

设计亮点

  • 取负:对 neg_* 系数评分(如 neg_mean_squared_error)提供直接取负的选项,避免用户手动转换。

  • 绘制模式std_display_style 可控制只画均值线、误差棒或填充区间,兼顾简洁或信息丰富的可视化需求。

  • 坐标轴自适应:依据 _interval_max_min_ratio(最大间隔 / 最小间隔)是否大于 5 自动切换线性、对数或对称对数坐标轴,保证在跨度跨越数个数量级时图形仍可读。

40.4.10 LearningCurveDisplay.from_estimatorsklearn/model_selection/_plot.py 第 157‑260 行)

@classmethod
def from_estimator(
    cls,
    estimator,
    X,
    y,
    *,
    groups=None,
    train_sizes=np.linspace(0.1, 1.0, 5),
    cv=None,
    scoring=None,
    exploit_incremental_learning=False,
    n_jobs=None,
    pre_dispatch="all",
    verbose=0,
    shuffle=False,
    random_state=None,
    error_score=np.nan,
    fit_params=None,
    ax=None,
    negate_score=False,
    score_name=None,
    score_type="both",
    std_display_style="fill_between",
    line_kw=None,
    fill_between_kw=None,
    errorbar_kw=None,
):
    """Create a learning curve display from an estimator."""
    check_matplotlib_support(f"{cls.__name__}.from_estimator")
    # 自动推断 score_name
    score_name = _validate_score_name(score_name, scoring, negate_score)

    # 调用 learning_curve 计算数据
    train_sizes, train_scores, test_scores = learning_curve(
        estimator,
        X,
        y,
        groups=groups,
        train_sizes=train_sizes,
        cv=cv,
        scoring=scoring,
        exploit_incremental_learning=exploit_incremental_learning,
        n_jobs=n_jobs,
        pre_dispatch=pre_dispatch,
        verbose=verbose,
        shuffle=shuffle,
        random_state=random_state,
        error_score=error_score,
        return_times=False,
        params=fit_params,
    )
    # 实例化展示对象并绘图
    viz = cls(train_sizes=train_sizes, train_scores=train_scores,
              test_scores=test_scores, score_name=score_name)
    return viz.plot(ax=ax, negate_score=negate_score,
                    score_type=score_type,
                    std_display_style=std_display_style,
                    line_kw=line_kw,
                    fill_between_kw=fill_between_kw,
                    errorbar_kw=errorbar_kw)

关键点

  • 通过 _validate_score_name 统一生成 y 轴标签(支持 neg_* 自动去掉 “neg_” 并加前缀 “Negative”)。

  • 直接调用 learning_curve(内部已完成全部并行、增量或普通路径的计算),并将得到的 train_sizes、train_scores、test_scores 注入 LearningCurveDisplay 实例。

  • 最终调用基类的 _plot_curve 完成绘图,返回完整的可交互对象,用户可继续使用 ax_figure_lines_ 等属性进行二次定制。

40.4.11 ValidationCurveDisplay.from_estimatorsklearn/model_selection/_plot.py 第 337‑430 行)

@classmethod
def from_estimator(
    cls,
    estimator,
    X,
    y,
    *,
    param_name,
    param_range,
    groups=None,
    cv=None,
    scoring=None,
    n_jobs=None,
    pre_dispatch="all",
    verbose=0,
    error_score=np.nan,
    fit_params=None,
    ax=None,
    negate_score=False,
    score_name=None,
    score_type="both",
    std_display_style="fill_between",
    line_kw=None,
    fill_between_kw=None,
    errorbar_kw=None,
):
    """Create a validation curve display from an estimator."""
    check_matplotlib_support(f"{cls.__name__}.from_estimator")
    score_name = _validate_score_name(score_name, scoring, negate_score)

    # 计算 validation_curve
    train_scores, test_scores = validation_curve(
        estimator,
        X,
        y,
        param_name=param_name,
        param_range=param_range,
        groups=groups,
        cv=cv,
        scoring=scoring,
        n_jobs=n_jobs,
        pre_dispatch=pre_dispatch,
        verbose=verbose,
        error_score=error_score,
        params=fit_params,
    )
    # 实例化并绘图
    viz = cls(
        param_name=param_name,
        param_range=np.asarray(param_range),
        train_scores=train_scores,
        test_scores=test_scores,
        score_name=score_name,
    )
    return viz.plot(
        ax=ax,
        negate_score=negate_score,
        score_type=score_type,
        std_display_style=std_display_style,
        line_kw=line_kw,
        fill_between_kw=fill_between_kw,
        errorbar_kw=errorbar_kw,
    )

要点

  • LearningCurveDisplay 类似,只是把 x_data 换成参数取值范围 param_range,并使用 validation_curve 产生 (n_params, n_folds) 的训练/测试分数矩阵。

  • 同样通过 _validate_score_name 统一生成 y 轴标签,保证可视化风格一致。

40.5 设计中的取舍

40.5.1 为什么不在 cross_validate 中直接实现增量学习?

cross_validate 的核心目标是提供 无偏的交叉验证估计,即每一折的模型必须在 独立且相同的起点 上训练。如果在 cross_validate 中引入 partial_fit,后续折会复用前一折的模型状态,导致 信息泄漏(后面的折间接使用了之前折的训练样本),从而违背交叉验证的统计假设。learning_curve 则是探索模型在不同训练规模下的行为,它本身并不要求每个规模的模型相互独立,因而在 learning_curve 中提供 exploit_incremental_learning=True 选项是安全且高效的。

40.5.2 _enforce_prediction_order 的必要性与实现细节

分类预测predict_probadecision_functionpredict_log_proba)中,输出矩阵的列数对应 类别数。如果某个折的训练子集缺失了某些类别,模型在该折的预测输出将 缺少对应列,导致不同折的预测矩阵形状不统一,进而在 cross_val_predict 中合并时触发维度不匹配错误。

_enforce_prediction_order 通过以下步骤解决:

  1. 检测缺失:比较 n_classes(全局类别数) 与 classes_length(当前折实际看到的类别数)。

  2. 警告:若不匹配,给出建议使用更好的分层划分策略。

  3. 填充值:对缺失列使用 安全的默认值0 对于概率,float_min 对于决策函数/对数概率),并把已有列按照原始类别顺序搬入新矩阵。这样所有折的输出矩阵宽度统一,即可安全拼接。

40.6 动手练习

40.6.1 练习 1:阅读 _fit_and_score 的细粒度实现

  • 打开 sklearn/model_selection/_validation.py 第 684‑814 行,重点关注以下细节:

    • clone(parameters, safe=False) 在何种情形下避免副作用?(提示:管道搜索中的子 estimator 参数)

    • _check_method_params 如何对 fit_paramsscore_params 进行索引切片?(思考样本权重或分组信息的对齐)

    • error_score='raise' 与数值时的异常分支差异,特别是多指标 scorer 的字典填充逻辑。

    • return_train_scorereturn_timesreturn_estimator 对返回字典的影响。

思考:若你希望在调试阶段捕获每折的完整异常堆栈,请将 error_score 参数设为何值?

40.6.2 练习 2:对比学习曲线的两条路径

from sklearn.linear_model import SGDClassifier
from sklearn.datasets import make_classification
from sklearn.model_selection import learning_curve

X, y = make_classification(n_samples=5000, n_features=20, random_state=0)
est = SGDClassifier(max_iter=1, tol=None, shuffle=False,
                    learning_rate='constant', eta0=0.01)

# 第 40 章 —— 普通批量学习
train_sizes, train_scores, test_scores = learning_curve(
    est, X, y, train_sizes=np.linspace(0.1, 1.0, 5),
    exploit_incremental_learning=False, error_score=np.nan)

print("批量学习 test_scores:", test_scores.mean(axis=1))

# 第 40 章 —— 增量学习
train_sizes_i, train_scores_i, test_scores_i = learning_curve(
    est, X, y, train_sizes=np.linspace(0.1, 1.0, 5),
    exploit_incremental_learning=True, error_score=np.nan)

print("增量学习 test_scores:", test_scores_i.mean(axis=1))
  • 记录两次运行的 train_scorestest_scores 均值。

  • max_iter 改为 5,再次比较结果。

  • 分析:当学习率保持常数且未出现随机洗牌时,增量路径与批量路径会趋于一致;而如果使用默认学习率衰减或随机 shuffle=True,两者会出现显著差异。

40.6.3 练习 3:自定义置换检验的多指标 scorer(进阶)

  1. 编写一个返回字典的 scorer,例如:
def my_multi_scorer(estimator, X, y):
    from sklearn.metrics import accuracy_score, f1_score
    y_pred = estimator.predict(X)
    return {"accuracy": accuracy_score(y, y_pred),
            "f1": f1_score(y, y_pred, average="weighted")}
  1. 尝试将 my_multi_scorer 直接传入 permutation_test_score

  2. 观察报错信息,这正是 单标量返回限制 的体现。

思考:如果要支持多指标置换检验,需要在哪些函数(_permutation_test_scorepermutation_test_score_aggregate_score_dicts)引入对字典的聚合与 p‑值计算逻辑?

40.6.4 练习 4:可视化层的自动坐标轴

  • 使用 LearningCurveDisplay.from_estimator 计算一条学习曲线,分别将 train_sizes 设为 [0.01, 0.1, 0.5, 1.0](跨越两个数量级)与 [0.1, 0.2, 0.3, 0.4](仅线性区间)。

  • 对比两张图的 x 轴刻度,确认当 最大间隔 / 最小间隔 > 5 时,绘图库会自动切换为 对数(或 对称对数)坐标。

40.7 本章小结

下面的表格概括了本章涉及的关键概念与对应实现位置,帮助读者快速定位代码与文档。

关键概念概览

| 概念 | 解释 | 实现位置 |

|------|------|----------|

| cross_validate | 多指标交叉验证入口,支持并行、元数据路由、错误容忍 | sklearn/model_selection/_validation.py:cross_validate |

| cross_val_score | 单指标包装,内部调用 cross_validate 并抽取 test_score | 同上 cross_val_score |

| cross_val_predict | 交叉验证预测收集,利用 _fit_and_predict_enforce_prediction_order 保证列顺序 | cross_val_predict_fit_and_predict_enforce_prediction_order |

| _fit_and_score | 单折执行单元,负责克隆、切分、拟合、计时、评分与异常捕获 | sklearn/model_selection/_validation.py:_fit_and_score |

| _score | 统一调用 scorer,处理异常、局部错误信息并保证返回数值类型 | sklearn/model_selection/_validation.py:_score |

| _warn_or_raise_about_fit_failures | 汇总折间拟合错误,决定抛异常或发警告 | 同上 |

| learning_curve | 计算随训练集大小变化的学习曲线,提供批量与增量两条路径 | sklearn/model_selection/_validation.py:learning_curve |

| _incremental_fit_estimator | 增量学习路径,利用 partial_fit 逐步扩大训练子集 | 同上 |

| permutation_test_score | 置换检验入口,生成经验零分布并计算 p‑值 | sklearn/model_selection/_validation.py:permutation_test_score |

| validation_curve | 单参数网格搜索,返回 (n_params, n_folds) 结构的训练/测试分数 | 同上 |

| _BaseCurveDisplay._plot_curve | 可视化基类,统一绘图、误差展示、坐标轴自适应 | sklearn/model_selection/_plot.py:_BaseCurveDisplay._plot_curve |

| LearningCurveDisplay.from_estimator | 一键生成学习曲线并绘图的类方法 | sklearn/model_selection/_plot.py:LearningCurveDisplay.from_estimator |

| ValidationCurveDisplay.from_estimator | 一键生成验证曲线并绘图的类方法 | sklearn/model_selection/_plot.py:ValidationCurveDisplay.from_estimator |

本章我们系统拆解了 交叉验证执行引擎 的完整链路:从参数检查、评分器统一、元数据路由、并行折调度、异常容忍、结果聚合,到学习曲线与验证曲线的两条计算路径,再到统一的可视化基类。通过分层结构和流程图,读者可以清晰了解每一步的职责与实现细节,为后续的 超参数搜索体系GridSearchCVRandomizedSearchCVHalving*)奠定坚实的技术基础。


本章节结束,后续请参阅 “超参数搜索体系 —— 探索‘模型配置的寻宝地图’”。

40.8 架构与数据流图

graph TD A[调用者] --> B[公开 API] B --> C[核心实现] C --> D[结果]
sequenceDiagram participant U as 调用者 participant A as 公开 API participant C as 核心实现 U->>A: 调用 A->>C: 委托 C-->>U: 返回结果
graph LR I[输入] --> P[校验] P --> T[处理] T --> O[输出]
graph TD L1[API 层] --> L2[核心逻辑层] L2 --> L3[数据结构层]

第 41 章 —— 📚 代码库概览与使用指南

41.1 学习目标

  • 理解交叉验证执行引擎的核心调度流程

  • 掌握 cross_validate 与 cross_val_score 的多指标并行评估机制

  • 深入理解 _fit_and_score 如何整合拟合、评分与计时

  • 掌握 cross_val_predict 的预测收集与顺序保障机制

  • 理解 learning_curve 与 validation_curve 的渐进式训练与参数扫描逻辑

  • 剖析 permutation_test_score 的置换检验与显著性评估原理

  • 了解元数据路由、Array API 兼容性在验证流程中的集成

  • 理解超参数搜索基类 BaseSearchCV 的核心架构与评分器管理

  • 掌握 GridSearchCV 与 RandomizedSearchCV 的参数空间遍历与采样策略

  • 剖析 HalvingGridSearchCV 与 HalvingRandomSearchCV 的逐次减半搜索机制

  • 理解元数据路由在搜索流程中的精准分发

  • 掌握 cv_results_ 结果聚合、掩码数组构建与排名并列处理

  • 难度:★★★☆☆(3/5)

  • 预备知识:Python 基础、面向对象编程与代码阅读基础

本篇文档针对 sklearn/model_selection 目录下的核心实现进行梳理,涵盖以下模块:

| 模块 | 关键功能 | 主要类/函数 |

|------|----------|--------------|

| _validation.py | 交叉验证、验证曲线、学习曲线、分数预测等 | cross_validate, cross_val_score, cross_val_predict, learning_curve, validation_curve, permutation_test_score |

| _plot.py | 可视化曲线(学习曲线、验证曲线) | LearningCurveDisplay, ValidationCurveDisplay |

| _search.py | 网格搜索、随机搜索(超参数调优) | GridSearchCV, RandomizedSearchCV, BaseSearchCV |

| _search_successive_halving.py | 采用 Successive Halving 策略的网格/随机搜索 | HalvingGridSearchCV, HalvingRandomSearchCV |

| tests/ | 单元测试,确保实现的正确性与兼容性 | test_validation.py, test_search.py, test_successive_halving.py 等 |

下面按模块逐一说明核心实现、常用调用方式以及注意事项,帮助你快速上手并自定义扩展。


41.2 1️⃣ _validation.py – 交叉验证与学习/验证曲线

41.2.1 cross_validate

cross_validate(
    estimator, X, y=None, *,
    groups=None, scoring=None, cv=None, n_jobs=None,
    verbose=0, params=None, pre_dispatch="2*n_jobs",
    return_train_score=False, return_estimator=False,
    return_indices=False, error_score=np.nan
)  # sklearn/model_selection/_validation.py
  • 返回值:dict,键包括 fit_time, score_time, test_score,以及可选的 train_score, estimator, indices

  • 元数据路由:在 enable_metadata_routing=True 时,groups 必须通过 params 传递;否则会抛 ValueError

  • 内部实现

    • 参数验证、CV 划分 (check_cv);

    • 通过 _fit_and_score 并行执行每个折;

    • 失败折的错误处理由 _warn_or_raise_about_fit_failures 完成。

41.2.2 cross_val_score

cross_val_score(
    estimator, X, y=None, *,
    groups=None, scoring=None, cv=None, n_jobs=None,
    verbose=0, params=None, pre_dispatch="2*n_jobs",
    error_score=np.nan
)  # sklearn/model_selection/_validation.py
  • 包装 cross_validate,仅返回 test_score(单指标)或 test_<name>(多指标)。

  • 支持 自定义 scorer(函数或 make_scorer)。

41.2.3 cross_val_predict

cross_val_predict(
    estimator, X, y=None, *,
    groups=None, cv=None, n_jobs=None,
    verbose=0, params=None, pre_dispatch="2*n_jobs",
    method="predict"
)  # sklearn/model_selection/_validation.py
  • 对每个样本返回在 仅一次 测试集上预测的结果,保证 没有信息泄露

  • 支持多种 methodpredict, predict_proba, decision_function, predict_log_proba),并在缺失类别时自动补齐(见 _enforce_prediction_order)。

41.2.4 learning_curve

learning_curve(
    estimator, X, y, *,
    groups=None, train_sizes=np.linspace(0.1, 1.0, 5),
    cv=None, scoring=None, exploit_incremental_learning=False,
    n_jobs=None, pre_dispatch="all", verbose=0,
    shuffle=False, random_state=None, error_score=np.nan,
    return_times=False, params=None
)  # sklearn/model_selection/_validation.py
  • 计算 不同训练集大小 下的训练/测试分数。

  • 增量学习exploit_incremental_learning=True)以加速。

  • train_sizes 支持相对比例或绝对样本数;内部通过 _translate_train_sizes 统一处理。

41.2.5 validation_curve

validation_curve(
    estimator, X, y, *,
    param_name, param_range, groups=None, cv=None,
    scoring=None, n_jobs=None, pre_dispatch="all",
    verbose=0, error_score=np.nan, params=None
)  # sklearn/model_selection/_validation.py
  • 评价 单个超参数 的不同取值对模型表现的影响。

  • learning_curve 类似,但遍历的是 参数网格 而非训练样本大小。

41.2.6 permutation_test_score

permutation_test_score(
    estimator, X, y, *,
    groups=None, cv=None, n_permutations=100,
    n_jobs=None, random_state=0, verbose=0,
    scoring=None, params=None
)  # sklearn/model_selection/_validation.py
  • 通过 标签置换 评估模型显著性,返回原始得分、置换得分数组以及 p‑value。

常见坑

  • 在启用 metadata routing 时,groups 必须通过 params 传递,否则会报错。
  • error_score'raise' 时,任何拟合失败会直接抛异常;使用数值时会捕获并记录为 FitFailedWarning

41.3 设计取舍分析

问:为什么 cross_validateerror_score 参数既可以是数值也可以是字符串 'raise',而不是只使用异常机制?

答: 这种设计允许用户在交叉验证过程中灵活控制错误处理策略。当 error_score='raise' 时,任何拟合失败会立即中断并抛出异常,这适用于调试或需要严格验证的场景;当 error_score 为数值时,失败的折会被赋予该分数值并继续执行,同时触发 FitFailedWarning 警告,这在批量实验或参数搜索中非常有用,因为它避免了单个故障模型导致整个搜索过程终止。权衡在于:前者提供更严格的错误检测,但牺牲了鲁棒性;后者提高了容错能力,但可能掩盖潜在的模型配置问题。


41.4 2️⃣ _plot.py – 曲线可视化

41.4.1 LearningCurveDisplay

class LearningCurveDisplay:  # sklearn/model_selection/_plot.py
    def __init__(self, *, train_sizes, train_scores, test_scores, score_name=None):
        # 初始化学习曲线显示对象
        self.train_sizes = train_sizes
        self.train_scores = train_scores
        self.test_scores = test_scores
        self.score_name = score_name

    def plot(
        self,
        ax=None, *,
        negate_score=False, score_name=None,
        score_type="both", std_display_style="fill_between",
        line_kw=None, fill_between_kw=None, errorbar_kw=None
    ) -> 'LearningCurveDisplay':  # sklearn/model_selection/_plot.py
        # 绘制学习曲线,支持多种标准差展示方式和坐标轴缩放
        self._plot_curve(
            self.train_sizes, ax=ax,
            negate_score=negate_score, score_name=score_name,
            score_type=score_type, std_display_style=std_display_style,
            line_kw=line_kw, fill_between_kw=fill_between_kw,
            errorbar_kw=errorbar_kw
        )
        self.ax_.set_xlabel("Number of samples in the training set")
        return self

    @classmethod
    def from_estimator(
        cls, estimator, X, y, *,
        groups=None, train_sizes=np.linspace(0.1, 1.0, 5),
        cv=None, scoring=None, exploit_incremental_learning=False,
        n_jobs=None, pre_dispatch="all", verbose=0,
        shuffle=False, random_state=None, error_score=np.nan,
        fit_params=None, ax=None, negate_score=False,
        score_name=None, score_type="both",
        std_display_style="fill_between", line_kw=None,
        fill_between_kw=None, errorbar_kw=None
    ) -> 'LearningCurveDisplay':  # sklearn/model_selection/_plot.py
        # 从估计器直接创建学习曲线显示对象
        check_matplotlib_support(f"{cls.__name__}.from_estimator")
        score_name = _validate_score_name(score_name, scoring, negate_score)
        train_sizes, train_scores, test_scores = learning_curve(
            estimator, X, y, groups=groups, train_sizes=train_sizes,
            cv=cv, scoring=scoring,
            exploit_incremental_learning=exploit_incremental_learning,
            n_jobs=n_jobs, pre_dispatch=pre_dispatch,
            verbose=verbose, shuffle=shuffle,
            random_state=random_state, error_score=error_score,
            return_times=False, params=fit_params
        )  # 调用 _validation.py 中的 learning_curve 函数
        viz = cls(
            train_sizes=train_sizes,
            train_scores=train_scores,
            test_scores=test_scores,
            score_name=score_name
        )
        return viz.plot(
            ax=ax, negate_score=negate_score,
            score_type=score_type, std_display_style=std_display_style,
            line_kw=line_kw, fill_between_kw=fill_between_kw,
            errorbar_kw=errorbar_kw
        )
  • 创建方式LearningCurveDisplay.from_estimator(...) 或手动实例化后调用 plot()

  • 参数 negate_score 用于绘制 neg_* 类评分(如 neg_mean_squared_error)。

  • 支持三种 标准差展示fill_between(默认)、errorbarNone(不展示)。

  • 自动选择 x 轴比例 (linear / log / symlog) 依据 _interval_max_min_ratio

41.4.1.1 架构图:学习曲线绘制流程

graph TD A[开始] --> B{调用 from_estimator 或直接实例化} B --> C[验证并处理 score_name] C --> D[调用 learning_curve 获取数据] D --> E[创建 LearningCurveDisplay 实例] E --> F[调用 plot 方法] F --> G{选择标准差展示样式} G -->|fill_between| H[绘制均值线 + 填充标准差区域] G -->|errorbar| I[绘制误差棒] G -->|None| J[仅绘制均值线] H & I & J --> K[设置 x/y 轴标签和比例] K --> L[返回显示对象] L --> M[结束]

41.4.1.2 使用示例

from sklearn.model_selection import LearningCurveDisplay
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier

X, y = load_iris(return_X_y=True)
est = DecisionTreeClassifier()
display = LearningCurveDisplay.from_estimator(
    est, X, y, cv=5, scoring="accuracy"
)
display.plot()

41.4.2 ValidationCurveDisplay

class ValidationCurveDisplay:  # sklearn/model_selection/_plot.py
    def __init__(
        self, *, param_name, param_range, train_scores, test_scores, score_name=None
    ):
        # 初始化验证曲线显示对象
        self.param_name = param_name
        self.param_range = param_range
        self.train_scores = train_scores
        self.test_scores = test_scores
        self.score_name = score_name

    def plot(
        self,
        ax=None, *,
        negate_score=False, score_name=None,
        score_type="both", std_display_style="fill_between",
        line_kw=None, fill_between_kw=None, errorbar_kw=None
    ) -> 'ValidationCurveDisplay':  # sklearn/model_selection/_plot.py
        # 绘制验证曲线,x轴为参数取值
        self._plot_curve(
            self.param_range, ax=ax,
            negate_score=negate_score, score_name=score_name,
            score_type=score_type, std_display_style=std_display_style,
            line_kw=line_kw, fill_between_kw=fill_between_kw,
            errorbar_kw=errorbar_kw
        )
        self.ax_.set_xlabel(f"{self.param_name}")
        return self

    @classmethod
    def from_estimator(
        cls, estimator, X, y, *,
        param_name, param_range, groups=None, cv=None,
        scoring=None, n_jobs=None, pre_dispatch="all",
        verbose=0, error_score=np.nan, fit_params=None,
        ax=None, negate_score=False, score_name=None,
        score_type="both", std_display_style="fill_between",
        line_kw=None, fill_between_kw=None, errorbar_kw=None
    ) -> 'ValidationCurveDisplay':  # sklearn/model_selection/_plot.py
        # 从估计器直接创建验证曲线显示对象
        check_matplotlib_support(f"{cls.__name__}.from_estimator")
        score_name = _validate_score_name(score_name, scoring, negate_score)
        train_scores, test_scores = validation_curve(
            estimator, X, y, param_name=param_name,
            param_range=param_range, groups=groups, cv=cv,
            scoring=scoring, n_jobs=n_jobs,
            pre_dispatch=pre_dispatch, verbose=verbose,
            error_score=error_score, params=fit_params
        )  # 调用 _validation.py 中的 validation_curve 函数
        viz = cls(
            param_name=param_name, param_range=np.asarray(param_range),
            train_scores=train_scores, test_scores=test_scores,
            score_name=score_name
        )
        return viz.plot(
            ax=ax, negate_score=negate_score,
            score_type=score_type, std_display_style=std_display_style,
            line_kw=line_kw, fill_between_kw=fill_between_kw,
            errorbar_kw=errorbar_kw
        )
  • 使用方式同 LearningCurveDisplay,但 x 轴为 参数取值

  • from_estimator 会内部调用 validation_curve 来获取分数。

使用示例

from sklearn.model_selection import ValidationCurveDisplay
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
import numpy as np

X, y = make_classification(n_samples=1_000, random_state=0)
logistic_regression = LogisticRegression()
param_name, param_range = "C", np.logspace(-8, 3, 10)
display = ValidationCurveDisplay.from_estimator(
    logistic_regression, X, y, param_name=param_name,
    param_range=param_range
)
display.plot()

41.5 3️⃣ _search.py – 参数搜索(网格 & 随机)

41.5.1 BaseSearchCV

class BaseSearchCV(MetaEstimatorMixin, BaseEstimator, metaclass=ABCMeta):  # sklearn/model_selection/_search.py
    """超参数搜索的抽象基类"""

    def __init__(
        self, estimator, *, scoring=None, n_jobs=None,
        refit=True, cv=None, verbose=0, pre_dispatch="2*n_jobs",
        error_score=np.nan, return_train_score=True
    ):
        # 初始化基础搜索类
        self.scoring = scoring
        self.estimator = estimator
        self.n_jobs = n_jobs
        self.refit = refit
        self.cv = cv
        self.verbose = verbose
        self.pre_dispatch = pre_dispatch
        self.error_score = error_score
        self.return_train_score = return_train_score

    def fit(self, X, y=None, **params):  # sklearn/model_selection/_search.py
        """执行参数搜索的主要入口"""
        estimator = self.estimator
        scorers, refit_metric = self._get_scorers()

        X, y = indexable(X, y)
        params = _check_method_params(X, params=params)

        routed_params = self._get_routed_params_for_fit(params)

        cv_orig = check_cv(self.cv, y, classifier=is_classifier(estimator))
        n_splits = cv_orig.get_n_splits(X, y, **routed_params.splitter.split)

        base_estimator = clone(self.estimator)

        parallel = Parallel(n_jobs=self.n_jobs, pre_dispatch=self.pre_dispatch)

        fit_and_score_kwargs = dict(
            scorer=scorers,
            fit_params=routed_params.estimator.fit,
            score_params=routed_params.scorer.score,
            return_train_score=self.return_train_score,
            return_n_test_samples=True,
            return_times=True,
            return_parameters=False,
            error_score=self.error_score,
            verbose=self.verbose,
        )
        results = {}
        with parallel:
            all_candidate_params = []
            all_out = []
            all_more_results = defaultdict(list)

            def evaluate_candidates(candidate_params, cv=None, more_results=None):
                # 核心评估函数:并行执行参数组合的 fit 和 score
                cv = cv or cv_orig
                candidate_params = list(candidate_params)
                n_candidates = len(candidate_params)

                if self.verbose > 0:
                    print(
                        "Fitting {0} folds for each of {1} candidates,"
                        " totalling {2} fits".format(
                            n_splits, n_candidates, n_candidates * n_splits
                        )
                    )

                out = parallel(
                    delayed(_fit_and_score)(
                        clone(base_estimator),
                        X,
                        y,
                        train=train,
                        test=test,
                        parameters=parameters,
                        split_progress=(split_idx, n_splits),
                        candidate_progress=(cand_idx, n_candidates),
                        **fit_and_score_kwargs,
                    )
                    for (cand_idx, parameters), (split_idx, (train, test)) in product(
                        enumerate(candidate_params),
                        enumerate(cv.split(X, y, **routed_params.splitter.split)),
                    )
                )

                if len(out) < 1:
                    raise ValueError(
                        "No fits were performed. "
                        "Was the CV iterator empty? "
                        "Were there no candidates?"
                    )
                elif len(out) != n_candidates * n_splits:
                    raise ValueError(
                        "cv.split and cv.get_n_splits returned "
                        "inconsistent results. Expected {} "
                        "splits, got {}".format(n_splits, len(out) // n_candidates)
                    )

                _warn_or_raise_about_fit_failures(out, self.error_score)

                if callable(self.scoring):
                    _insert_error_scores(out, self.error_score)

                all_candidate_params.extend(candidate_params)
                all_out.extend(out)

                if more_results is not None:
                    for key, value in more_results.items():
                        all_more_results[key].extend(value)

                nonlocal results
                results = self._format_results(
                    all_candidate_params, n_splits, all_out, all_more_results
                )

                return results

            self._run_search(evaluate_candidates)

            # 多指标评估的确定和 refit 检查
            first_test_score = all_out[0]["test_scores"]
            self.multimetric_ = isinstance(first_test_score, dict)

            if callable(self.scoring) and self.multimetric_:
                self._check_refit_for_multimetric(first_test_score)
                refit_metric = self.refit

        # 处理最佳模型的保存(取决于 refit 设置)
        if self.refit or not self.multimetric_:
            self.best_index_ = self._select_best_index(
                self.refit, refit_metric, results
            )
            if not callable(self.refit):
                self.best_score_ = results[f"mean_test_{refit_metric}"][self.best_index_]
            self.best_params_ = results["params"][self.best_index_]

        if self.refit:
            self.best_estimator_ = clone(base_estimator).set_params(
                **clone(self.best_params_, safe=False)
            )

            refit_start_time = time.time()
            if y is not None:
                self.best_estimator_.fit(X, y, **routed_params.estimator.fit)
            else:
                self.best_estimator_.fit(X, **routed_params.estimator.fit)
            refit_end_time = time.time()
            self.refit_time_ = refit_end_time - refit_start_time

            if hasattr(self.best_estimator_, "feature_names_in_"):
                self.feature_names_in_ = self.best_estimator_.feature_names_in_

        # 存储得分函数
        if isinstance(scorers, _MultimetricScorer):
            self.scorer_ = scorers._scorers
        else:
            self.scorer_ = scorers

        self.cv_results_ = results
        self.n_splits_ = n_splits

        return self

    def _get_routed_params_for_fit(self, params):  # sklearn/model_selection/_search.py
        """获取用于元数据路由的参数"""
        if _routing_enabled():
            routed_params = process_routing(self, "fit", **params)
        else:
            params = params.copy()
            groups = params.pop("groups", None)
            routed_params = Bunch(
                estimator=Bunch(fit=params),
                splitter=Bunch(split={"groups": groups}),
                scorer=Bunch(score={}),
            )
            if (
                params.get("sample_weight") is not None
                and self._check_scorers_accept_sample_weight()
            ):
                routed_params.scorer.score["sample_weight"] = params["sample_weight"]
        return routed_params
  • 抽象基类,实现 fitscorepredict* 等通用接口。

  • 关键属性:

    • cv_results_(完整的搜索记录)

    • best_estimator_, best_params_, best_score_, best_index_(仅当 refit=True

    • n_features_in_, feature_names_in_(通过 best_estimator_ 代理)。

  • 元数据路由get_metadata_routing 定义了 estimatorscorersplitter 的路由关系。

41.5.2 GridSearchCV

class GridSearchCV(BaseSearchCV):  # sklearn/model_selection/_search.py
    """穷举参数网格搜索"""

    def __init__(
        self, estimator, param_grid, *,
        scoring=None, n_jobs=None, refit=True,
        cv=None, verbose=0, pre_dispatch="2*n_jobs",
        error_score=np.nan, return_train_score=False
    ):
        super().__init__(
            estimator=estimator, scoring=scoring,
            n_jobs=n_jobs, refit=refit, cv=cv,
            verbose=verbose, pre_dispatch=pre_dispatch,
            error_score=error_score, return_train_score=return_train_score
        )
        self.param_grid = param_grid

    def _run_search(self, evaluate_candidates):  # sklearn/model_selection/_search.py
        """执行网格搜索:评估参数网格中的所有组合"""
        evaluate_candidates(ParameterGrid(self.param_grid))
  • 穷举 所有 param_grid 组合。

  • 支持 多指标scoring 为 dict / list),并通过 refit 指定用于最终模型的指标或自定义 callable。

  • 通过 _format_results 汇总每个折的得分、时间、排名等信息。

  • cv_results_ 中的 param_*MaskedArray,未出现的参数被掩码。

41.5.3 RandomizedSearchCV

class RandomizedSearchCV(BaseSearchCV):  # sklearn/model_selection/_search.py
    """随机参数搜索"""

    def __init__(
        self, estimator, param_distributions, *,
        n_iter=10, scoring=None, n_jobs=None,
        refit=True, cv=None, verbose=0, pre_dispatch="2*n_jobs",
        random_state=None, error_score=np.nan,
        return_train_score=False
    ):
        self.param_distributions = param_distributions
        self.n_iter = n_iter
        self.random_state = random_state
        super().__init__(
            estimator=estimator, scoring=scoring,
            n_jobs=n_jobs, refit=refit, cv=cv,
            verbose=verbose, pre_dispatch=pre_dispatch,
            error_score=error_score, return_train_score=return_train_score
        )

    def _run_search(self, evaluate_candidates):  # sklearn/model_selection/_search.py
        """执行随机搜索:从参数分布中抽样 n_iter 个组合"""
        evaluate_candidates(
            ParameterSampler(
                self.param_distributions, self.n_iter,
                random_state=self.random_state
            )
        )
  • 基于 ParameterSampler 随机抽样 n_iter 组参数。

  • 当所有参数均为列表时采用 不放回抽样(等价于 GridSearch),否则使用分布抽样。

41.5.4 常用技巧

  • 大数据集:使用 n_jobs 并行,或在 pre_dispatch 上限制内存占用

  • 自定义 scorer 不支持 sample_weight:通过 fit_params={'sample_weight': ...} 仍可使用,但会触发 UserWarning,建议改写 scorer 或显式禁用 sample_weight

  • 搜索过程需要记录额外信息:在 params 中加入自定义键,BaseSearchCV._get_routed_params_for_fit 会自动路由(需开启 metadata routing)

  • 需要在搜索后获取特征重要性search.best_estimator_.feature_importances_(若基模型提供)

41.5.4.1 设计取舍分析

问:为什么 GridSearchCVRandomizedSearchCV 使用 _yield_masked_array_for_each_param 来处理参数结果,而不是简单地存储完整参数字典?

答: 这种设计是为了高效处理参数网格中存在互斥参数的情况(例如,某些参数只在特定条件下出现)。在网格搜索中,不同的参数组合可能不共享所有参数(如 kernel='rbf' 使用 gamma,而 kernel='linear' 不使用)。使用 MaskedArray 允许我们:

  1. 为每个参数维度分配固定大小的数组(提高内存局部性和向量化操作效率)

  2. 通过掩码机制自然表示“该参数在此候选中不适用”

  3. 在后续分析中(如转换为 DataFrame)保持结构一致性

权衡在于:虽然这种方法增加了一点实现复杂度,但显著改善了结果的可用性和内存效率,特别是在大规模参数搜索中。替代方案如存储不完整的字典列表会导致后处理复杂度增加和内存使用不一致。


41.6 4️⃣ _search_successive_halving.py – Successive Halving 搜索

41.6.1 基本概念

  • Successive Halving:先用少量资源评估全部候选模型,逐轮 淘汰低分模型加大资源(样本数或迭代次数)继续评估。

  • 关键参数:

    • resource:可为 'n_samples'(默认)或基模型的任意整数参数(如 n_estimators)。

    • max_resources / min_resources:资源上限 & 下限,支持 'auto''exhaust''smallest'

    • factor:每轮保留的候选比例 1/factor

    • aggressive_elimination:若资源不足导致最后一轮仍保留过多候选,则重放前几轮以强制淘汰。

41.6.2 HalvingGridSearchCV vs HalvingRandomSearchCV

  • 前者基于 ParameterGrid(穷举),后者基于 ParameterSampler(随机)。

  • 两者共享 _SubsampleMetaSplitter(对样本做子抽样)以及 _top_k(挑选 top‑k 候选)实现。

41.6.3 关键内部流程

def _run_search(self, evaluate_candidates):  # sklearn/model_selection/_search_successive_halving.py
    # 1️⃣ 生成全部候选参数
    candidate_params = self._generate_candidate_params()
    # 2️⃣ 计算需要的迭代次数 (n_required_iterations)
    # 3️⃣ 循环每轮:
    #    - 根据资源比例创建 meta‑splitter (若resource=n_samples 则使用子抽样)
    #    - 调用 evaluate_candidates → 调用 BaseSearchCV._fit_and_score
    #    - 根据评分保留 top‑k,更新 candidate_params
    # 4️⃣ 记录 n_resources_, n_candidates_, n_iterations_, n_remaining_candidates_

41.6.3.1 架构图:Successive Halving 搜索流程

graph TD A[开始] --> B[生成全部候选参数] B --> C[计算所需迭代次数] C --> D{是否启用 aggressive_elimination?} D -->|是| E[设置迭代次数为 n_required_iterations] D -->|否| F[设置迭代次数为 min(n_possible, n_required)] E & F --> G[对于每个迭代轮次] G --> H[计算当前资源量] H --> I{resource 是否为 n_samples?} I -->|是| J[创建子采样分割器] I -->|否| K[将资源值注入到候选参数中] J & K --> L[使用评估函数评估候选] L --> M[根据得分选择 top-k 候选] M --> N[更新候选参数列表] N --> O{是否还有更多迭代?} O -->|是| G O -->|否| P[记录统计信息] P --> Q[结束]

41.6.4 结果结构

cv_results_ 包含额外字段:

  • iter:所在迭代编号

  • n_resources:该轮使用的资源量

  • 其余键与 GridSearchCV/RandomizedSearchCV 相同(mean_test_scoresplit*_test_score 等)。

41.6.5 使用示例

from sklearn.experimental import enable_halving_search_cv  # noqa
from sklearn.model_selection import HalvingGridSearchCV
from sklearn.ensemble import RandomForestClassifier

X, y = load_iris(return_X_y=True)
est = RandomForestClassifier(random_state=0)
param_grid = {'max_depth': [3, None], 'min_samples_split': [2, 5]}

search = HalvingGridSearchCV(
    est, param_grid,
    resource='n_estimators',
    max_resources=100,
    factor=3,
    cv=3,
    random_state=0
)
search.fit(X, y)
print(search.best_params_, search.best_score_)

41.6.5.1 设计取舍分析

问:为什么 Successive Halving 策略在资源分配上使用乘法因子(如 factor=3)而不是固定增量?

答: 乘法因子设计使得资源增长呈指数级,这在理论上能够近似最优的资源分配策略(参考 Karnin 等人的 multi-armed bandit 工作)。与固定增量相比:

  • 优势:在早期阶段快速淘汰表现差的候选(用少量资源),在后期聚焦资源于有希望的候选;能够处理跨几个数量级的资源范围;理论上提供更好的后悔界。

  • 劣势:可能导致资源跳跃过大(例如从 1 直接跳到 3 然后 9),在资源非常有限或候选表现平坦时可能不够精细。

  • 替代方案考虑:固定增量会导致在搜索后期资源利用率低下(早期浪费太多资源在显然不好的候选上),而指数增长则更均衡地分配了探索与开发。权衡在于理论最优性与实际细粒度控制之间的平衡,scikit-learn 选择了前者因为在大多数实际超参数搜索场景中,参数影响往往具有数量级差异(例如学习率、树的数量等)。


41.7 5️⃣ 单元测试精选(tests/

41.7.1 结构

  • test_validation.py:覆盖交叉验证 API、错误处理、稀疏数据、元数据路由等。

  • test_search.py:网格/随机搜索的完整功能检查,包括:

    • 参数验证、异常处理、fit_params 兼容性、sample_weight 等;

    • refitTrueFalse、或自定义 callable 的行为;

    • 多指标评分、score/score_samplespredict_* 可用性;

    • cv_results_ 数据类型、掩码行为以及对稀疏/多输出的兼容性。

  • test_successive_halving.py:专注 Successive Halving 的资源分配、aggressive_elimination、子抽样确定性、候选保留逻辑等。

41.7.2 关键测试点

| 模块 | 关键测试 | 目的 |

|------|----------|------|

| _validation | _check_is_permutationcross_val_score 对稀疏/Pandas 兼容性 | 确保输入多样性 |

| _search | test_grid_search_failing_classifiertest_refit_callabletest_multi_metric_search | 检验异常捕获与多指标 refit 逻辑 |

| _search_successive_halving | test_nan_handlingtest_aggressive_eliminationtest_top_k | 验证 NaN 处理、资源递增策略、候选筛选 |

调试技巧

  • 如需快速定位某一搜索轮的候选,可读取 search.cv_results_['params']search.cv_results_['iter']
  • 对于 自定义资源(如 n_estimators),确认基模型的 get_params() 包含该键,否则会在 fit 前抛 ValueError

41.8 生活类比:超参数搜索就像寻找最佳食谱

想象你是一位厨师,想要找到制作完美巧克力蛋糕的最佳配方。你有很多变量可以调整:可可粉的品牌(A、B、C)、糖的量(低、中、高)、烘焙时间(短、中、长)、烤箱温度(160℃、170℃、180℃)等等。

如果你尝试所有可能的组合(就像 GridSearchCV),你可能需要烤几十甚至上百个蛋糕才能找到最佳配方——这很彻底但非常耗时和耗材。

而如果你随机尝试一些组合(就像 RandomizedSearchCV),你可能只需要烤20个蛋糕就能接近最佳结果,尽管可能错过绝对最佳的那个。

但如果你时间和材料非常有限,你可能会采用淘汰赛策略(就像 Halving*SearchCV):

  1. 首先,你用很少的材料(比如只做一个小杯子蛋糕)快速测试所有30种基础配方组合;

  2. 你保留表现最好的10组(假设淘汰因子是3);

  3. 然后你给这10组每个双倍的材料(做普通大小的蛋糕)重新测试;

  4. 保留表现最好的3~4组;

  5. 最后,你用全部材料(做大蛋糕)对这几组进行最终比赛。

这种方式让你在用有限资源的情况下,仍能高效地淘汰明显糟糕的配方,并将精力集中在最有希望的候选上——就像在蛋糕烘焙比赛中,你不会一开始就给每个配方做一个巨蛋来测试,而是先用小样本快速筛选。

当然,如果你非常重视某些细微差别(比如某种可可粉在小样本上表现平平但其实在大批量中更稳定),你可能需要调整策略——这就对应了 aggressive_elimination 参数,它 pozwala na „powtórzenie“ wcześniejszych rund, gdy zasoby są niewystarczające, aby odpowiednio zmniejszyć liczbę kandydatów.

无论你选择哪种策略,最终目标都是一样的:用最少的尝试找到那个让你的蛋糕既湿润又浓郁、刚好不塌陷的“黄金配方”。在机器学习中,这个“黄金配方”就是能在未见数据上表现最佳的模型参数组合。

祝你烘焙愉快,模型调参顺利 🎂🔬


41.9 6️⃣ 小结 & 推荐实践

  1. 默认使用 cross_validate / cross_val_score 来快速评估模型。

  2. 当需要 可视化学习/验证曲线,使用 LearningCurveDisplayValidationCurveDisplay(兼容 Matplotlib 与 Array API)。

  3. 对于 超参数调优,首选 GridSearchCV(小搜索空间)或 RandomizedSearchCV(大空间或需随机采样)。

  4. 资源受限或要快速收敛 时,使用 Halving*SearchCV,配合 resource='n_samples' 或模型特有的迭代计数器。

  5. 开启元数据路由 (config_context(enable_metadata_routing=True)) 可以统一管理 sample_weightgroups、自定义元数据,避免在每个函数中单独传递。

  6. 通过阅读 tests/ 中的细粒度测试,可快速了解每个边缘 case 的处理方式,便于自行扩展或调试。

祝你玩得开心 🎉!

41.10 模块地图/架构图

sklearn/model_selection/_validation.py
├── cross_validate                 # 核心多指标交叉验证入口
├── cross_val_score                # 单指标评分快捷接口
├── cross_val_predict              # 交叉验证预测收集
│   └── _enforce_prediction_order  # 类别顺序一致性保障
├── learning_curve                 # 学习曲线:渐进式训练集大小
├── validation_curve               # 验证曲线:参数扫描
├── permutation_test_score         # 置换检验:经验零分布构建
├── _fit_and_score                 # 单折拟合评分原子操作
│   ├── clone                      # 估计器克隆
│   ├── _safe_split                # 数据安全切分
│   ├── _score                     # 评分计算
│   └── _warn_or_raise_about_fit_failures # 失败处理
├── _aggregate_score_dicts         # 多折分数聚合
├── _normalize_score_results       # 分数结果标准化
├── _insert_error_scores           # 错误分数插入
└── _warn_or_raise_about_fit_failures # 失败警告/抛出
sklearn/model_selection/_plot.py
├── plot_learning_curve            # 学习曲线可视化
└── plot_validation_curve          # 验证曲线可视化
sklearn/model_selection/_search.py
├── ParameterGrid                  # 网格参数空间迭代器
├── ParameterSampler               # 随机参数空间采样器
├── BaseSearchCV                   # 搜索基类:评分器管理、元数据路由、结果聚合
│   ├── __init__                   # 初始化核心参数
│   ├── _get_scorers               # 评分器获取与多指标校验
│   ├── _get_routed_params_for_fit # 元数据路由参数分发
│   ├── _check_scorers_accept_sample_weight # 样本权重兼容性检查
│   ├── _check_refit_for_multimetric # 多指标下 refit 合法性校验
│   ├── _select_best_index         # 最佳参数索引选择
│   ├── _format_results            # cv_results_ 构建与掩码数组生成
│   ├── fit                        # 并行调度入口,调用 _run_search
│   ├── _run_search                # 抽象搜索调度接口
│   ├── score / predict / predict_proba / decision_function / transform / inverse_transform / score_samples # 预测代理方法
│   ├── get_metadata_routing       # 元数据路由暴露
│   └── _sk_visual_block_          # HTML 可视化支持
├── GridSearchCV                   # 穷举网格搜索
│   └── _run_search                # 遍历 ParameterGrid
├── RandomizedSearchCV             # 随机采样搜索
│   └── _run_search                # 采样 ParameterSampler
├── _check_refit                   # refit 属性访问守卫
├── _search_estimator_has          # 预测方法可用性检查
├── _yield_masked_array_for_each_param # 掩码数组构建工具
└── BaseSearchCV.__sklearn_tags__  # 标签继承
sklearn/model_selection/_search_successive_halving.py
├── _SubsampleMetaSplitter         # 资源受限下的数据子采样切分器
├── _top_k                         # 候选者 Top-K 筛选
├── BaseSuccessiveHalving          # 逐次减半搜索基类
│   ├── __init__                   # 资源参数初始化
│   ├── _check_input_parameters    # 输入合法性与 CV 一致性校验
│   ├── _select_best_index         # 最后一轮最佳候选选择
│   ├── fit                        # 训练主流程:资源迭代 + evaluate_candidates
│   ├── _run_search                # 迭代调度:资源指数增长 + 候选减半
│   ├── _generate_candidate_params # 抽象方法:候选参数生成
│   └── __sklearn_tags__           # 禁用 Array API
├── HalvingGridSearchCV            # 网格逐次减半
│   └── _generate_candidate_params # 基于 ParameterGrid
└── HalvingRandomSearchCV          # 随机逐次减半
    └── _generate_candidate_params # 基于 ParameterSampler
sklearn/model_selection/tests/test_validation.py
├── test_cross_validate                # cross_validate 基础功能
├── test_cross_val_score               # cross_val_score 基础功能
├── test_cross_val_predict             # 预测收集与顺序验证
├── test_learning_curve                # 学习曲线形状与数值验证
├── test_validation_curve              # 验证曲线参数扫描验证
├── test_permutation_test_score        # 置换检验统计显著性验证
├── test_fit_and_score                 # _fit_and_score 原子操作验证
├── test_cv_results_rank_tie_breaking  # 排名并列处理
├── test_search_cv_timing              # 计时字段验证
└── test_array_api_validation          # Array API 兼容性验证
sklearn/model_selection/tests/test_search.py
├── test_parameter_grid                 # ParameterGrid 迭代与索引
├── test_param_sampler                  # ParameterSampler 采样策略
├── test_grid_search                    # GridSearchCV 基础流程
├── test_grid_search_pipeline_steps     # Pipeline 步骤参数搜索
├── test_grid_search_cv_results         # cv_results_ 结构与掩码验证
├── test_grid_search_cv_results_multimetric # 多指标结果对齐
├── test_random_search_cv_results       # RandomizedSearchCV 结果结构
├── test_random_search_cv_results_multimetric # 随机搜索多指标对齐
├── test_search_cv_results_rank_tie_breaking # 排名并列处理
├── test_search_cv_results_none_param   # None 参数值处理
├── test_search_cv_timing               # 计时字段验证
├── test_search_cv_score_samples_error  # score_samples 代理错误
├── test_search_cv_score_samples_method # score_samples 代理成功
├── test_unsupported_sample_weight_scorer # 样本权重不兼容警告
├── test_search_cv_sample_weight_equivalence # 样本权重等价性验证
├── test_search_cv_pairwise_property_delegated_to_base_estimator # pairwise 标签委托
├── test_search_cv_pairwise_property_equivalence_of_precomputed # 预计算核等价性
├── test_scalar_fit_param               # 标量 fit_params 容忍
├── test_scalar_fit_param_compat        # 标量 fit_params 兼容
├── test_search_cv_using_minimal_compatible_estimator # 最小估计器兼容
├── test_search_cv_verbose_3            # verbose=3 输出验证
├── test_search_estimator_param         # 估计器参数对象不变性
├── test_search_with_2d_array           # 二维数组输入支持
├── test_search_html_repr               # HTML 可视化呈现
├── test_multi_metric_search_forwards_metadata # 多指标元数据路由
├── test_score_rejects_params_with_no_routing_enabled # 路由禁用时拒绝参数
├── test_cv_results_dtype_issue_29074   # 复杂参数 dtype=object 处理
├── test_search_with_estimators_issue_29157 # 估计器参数搜索
├── test_cv_results_multi_size_array    # 不等长数组参数处理
├── test_array_api_search_cv_classifier # Array API 分类器搜索
├── test_yield_masked_array_for_each_param # 掩码数组构建单测
├── test_yield_masked_array_no_runtime_warning # 大规模掩码无警告
├── test_grid_search_groups             # groups 参数传递
├── test_grid_search_error              # 数据长度不匹配错误
├── test_grid_search_one_grid_point     # 单点网格搜索
├── test_grid_search_when_param_grid_includes_range # range 参数支持
├── test_grid_search_bad_param_grid     # 非法参数网格校验
├── test_grid_search_sparse             # 稀疏矩阵支持
├── test_grid_search_sparse_scoring     # 稀疏矩阵评分
├── test_grid_search_precomputed_kernel # 预计算核支持
├── test_grid_search_precomputed_kernel_error_nonsquare # 非方阵核报错
├── test_refit                          # refit=True 重拟合
├── test_refit_callable                 # refit=callable 自定义选择
├── test_refit_callable_invalid_type    # callable 返回非整数报错
├── test_refit_callable_out_bound       # callable 返回越界报错
├── test_refit_callable_multi_metric    # 多指标下 callable refit
├── test_no_refit                       # refit=False 行为
├── test_grid_search_failing_classifier # 失败处理与警告
├── test_grid_search_classifier_all_fits_fail # 全部失败报错
├── test_grid_search_failing_classifier_raise # error_score='raise'
├── test_parameters_sampler_replacement # 采样替换策略
├── test_stochastic_gradient_loss_param # loss 参数影响 predict_proba
├── test_search_train_scores_set_to_false # return_train_score=False
├── test_grid_search_cv_splits_consistency # CV 切分一致性
├── test_transform_inverse_transform_round_trip # transform 往返
├── test_custom_run_search              # 自定义 _run_search
├── test__custom_fit_no_run_search      # 未实现 _run_search 报错
├── test_empty_cv_iterator_error        # 空 CV 迭代器报错
├── test_random_search_bad_cv           # 不一致 CV 报错
├── test_predict_proba_disabled         # 无 predict_proba 禁用代理
├── test_grid_search_allows_nans        # 含 NaN 数据支持
├── test_unsupervised_grid_search       # 无监督搜索
├── test_gridsearch_no_predict          # 无 predict 估计器搜索
├── test_gridsearch_nd                  # 高维数组输入
├── test_X_as_list                      # X 为列表输入
├── test_y_as_list                      # y 为列表输入
├── test_pandas_input                   # pandas DataFrame/Series 输入
├── test_n_features_in                  # n_features_in_ 委托
├── test_classes__property              # classes_ 属性委托
├── test_trivial_cv_results_attr        # 单候选 cv_results_
├── test_callable_multimetric_confusion_matrix # 可调用多指标返回字典
├── test_callable_multimetric_same_as_list_of_strings # 可调用等价字符串列表
├── test_callable_single_metric_same_as_single_string # 可调用等价单字符串
├── test_callable_multimetric_error_on_invalid_key # 可调用多指标缺键报错
├── test_callable_multimetric_error_failing_clf # 可调用多指标失败处理
├── test_callable_multimetric_clf_all_fits_fail # 可调用多指标全失败
├── test_pickle                         # 序列化支持
├── test_grid_search_with_multioutput_data # 多输出数据搜索
├── test_grid_search_correct_score_results # 评分结果正确性
├── test_grid_search_score_method       # score 方法行为
├── test_grid_search_no_score           # 无 score 估计器
├── test_refit_callable                 # refit=callable 基础
├── test_search_default_iid             # 默认 IID 假设
├── test_custom_run_search              # 自定义搜索逻辑
├── test__custom_fit_no_run_search      # 缺失 _run_search
├── test_empty_cv_iterator_error        # 空 CV 迭代器
├── test_random_search_bad_cv           # 坏 CV 对象
├── test_search_with_2d_array           # 2D 数组输入
├── test_search_html_repr               # HTML 表示
├── test_multi_metric_search_forwards_metadata # 元数据路由多指标
├── test_score_rejects_params_with_no_routing_enabled # 禁用路由拒绝参数
├── test_cv_results_dtype_issue_29074   # dtype=object 问题
├── test_search_with_estimators_issue_29157 # 估计器参数搜索
├── test_cv_results_multi_size_array    # 多尺寸数组参数
├── test_array_api_search_cv_classifier # Array API 搜索
├── test_yield_masked_array_for_each_param # 掩码数组单测
└── test_yield_masked_array_no_runtime_warning # 无运行时警告
sklearn/model_selection/tests/test_successive_halving.py
├── test_nan_handling                    # NaN 分数处理与排名
├── test_aggressive_elimination          # 激进淘汰模式验证
├── test_min_max_resources               # 最小/最大资源参数影响
├── test_n_iterations                    # 迭代次数计算验证
├── test_resource_parameter              # resource 参数支持
├── test_input_errors                    # 输入参数错误捕获
├── test_input_errors_randomized         # 随机搜索特有错误
├── test_random_search                   # 随机搜索候选数验证
├── test_random_search_discrete_distributions # 离散分布采样数
├── test_cv_results                      # cv_results_ 逻辑一致性
├── test_base_estimator_inputs           # 基础估计器接收参数验证
├── test_groups_support                  # groups 参数传递
├── test_min_resources_null              # 空数据集 min_resources=0 报错
├── test_select_best_index               # _select_best_index 逻辑
├── test_halving_random_search_list_of_dicts # 列表字典参数分布
├── test_subsample_splitter_shapes       # 子采样切分形状
├── test_subsample_splitter_determinism  # 子采样切分确定性
└── test_top_k                           # Top-K 筛选逻辑

以上地图列出本章源码模块及其职责,后文将按数据流逐一解析。

41.11 动手练习

41.11.1 阅读 cross_validate 并行调度核心

阅读 sklearn/model_selection/_validation.pycross_validate (1700-1850行) 和 _fit_and_score (1850-2050行) 实现:

  1. cross_validate 如何构建 Parallel 调用?delayed(_fit_and_score) 捕获了哪些上下文变量?

  2. _fit_and_scoreclone(estimator) 为何必须在并行 worker 内部执行?若在外部克隆会有什么问题?

  3. fit_paramsscore_params 如何通过 _safe_split 按 CV 索引切分?

  4. error_score='raise' 与数值模式下,异常如何被 _warn_or_raise_about_fit_failures 处理?

41.11.2 分析 cross_val_predict 预测顺序保障机制

阅读 sklearn/model_selection/_validation.pycross_val_predict (2050-2180行) 和 _enforce_prediction_order (2180-2250行):

  1. cross_val_predict 如何逐折收集 predict/predict_proba/decision_function 结果?

  2. 为何需要 _enforce_prediction_order?当某折训练集缺失类别时,预测概率列会如何错位?

  3. _enforce_prediction_order 如何利用 classes_ 属性重排概率列?

  4. method='decision_function' 且为二分类 OvR 多类策略时,输出形状如何处理?

41.11.3 理解学习曲线与验证曲线的参数扫描逻辑

阅读 sklearn/model_selection/_validation.pylearning_curve (2250-2400行) 和 validation_curve (2400-2550行):

  1. learning_curve 如何生成 train_sizes 序列?np.linspace 与绝对/相对大小的转换逻辑是什么?

  2. learning_curve 中为何每个训练量级都要重新 clone 估计器?能否复用?

  3. validation_curve 如何通过 param_nameparam_range 构造候选参数字典列表?

  4. 两者返回的 train_scorestest_scores 形状分别是什么?如何计算均值/标准差绘图?

41.11.4 探索置换检验的统计显著性评估

阅读 sklearn/model_selection/_validation.pypermutation_test_score (2550-2700行):

  1. permutation_test_score 如何通过 rng.permutation(y) 生成打乱标签?为何要保持 X 不变?

  2. n_permutationsn_jobs 如何控制并行置换实验的数量?

  3. p-value 计算公式 (n_permutations_ >= score) / (n_permutations + 1) 的统计学含义是什么?为何分母加 1?

  4. scoring 为多指标字典时,置换检验如何聚合多指标结果?

41.11.5 对比测试用例中的边界场景设计

阅读 sklearn/model_selection/tests/test_validation.py 中的关键测试:

  1. test_cross_val_predict 如何构造多分类缺失类别场景验证 _enforce_prediction_order

  2. test_learning_curve 如何验证不同 train_sizes 下分数的单调性与合理性?

  3. test_permutation_test_score 如何模拟零效应模型验证 p-value 均匀分布?

  4. test_array_api_validation 如何在 CuPy/JAX 后端下验证交叉验证流程的数值一致性?

41.11.6 剖析 BaseSearchCV 核心搜索调度与结果聚合

阅读 sklearn/model_selection/_search.pyBaseSearchCV.fit (约 500-650行) 和 _format_results (约 650-750行):

  1. fit 如何构建 fit_and_score_kwargs 并通过 Parallel 调度 _fit_and_score

  2. _format_results 如何将扁平的 out 列表重塑为 (n_candidates, n_splits) 矩阵并计算加权均值/标准差?

  3. _yield_masked_array_for_each_param 如何处理不同候选参数键不一致的情况?为何使用 MaskedArray

  4. rank_test_score 如何处理 NaN 分数?rankdatanan_to_num 的配合细节是什么?

41.11.7 对比 GridSearchCV 与 RandomizedSearchCV 的参数空间遍历策略

阅读 sklearn/model_selection/_search.pyParameterGrid.__iter__ (约 100-130行) 与 ParameterSampler.__iter__ (约 200-250行):

  1. ParameterGrid 如何保证迭代顺序的确定性?sorted(p.items())product(*values) 的作用?

  2. ParameterSampler 如何判断是否所有参数均为列表?_is_all_lists 如何影响采样替换策略?

  3. 当参数空间包含分布对象时,rvs 方法如何被调用?随机状态如何传递?

  4. ParameterGrid.__getitem__ 如何实现 O(1) 索引访问?反向键序与 divmod 计算的原理?

41.11.8 深入 HalvingGridSearchCV 的逐次减半资源调度机制

阅读 sklearn/model_selection/_search_successive_halving.pyBaseSuccessiveHalving._run_search (约 250-350行) 与 BaseSuccessiveHalving.fit (约 200-250行):

  1. n_required_iterationsn_possible_iterations 的计算公式分别是什么?factor 如何影响淘汰轮数?

  2. aggressive_elimination=True 时,power 计算逻辑为何改变?如何实现“重播初赛”?

  3. _SubsampleMetaSplitter 如何在 resource='n_samples' 时按比例下采样训练/测试索引?

  4. _top_k 如何利用 argsortroll 处理 NaN 分数?为何将 NaN 移至数组前端?

41.11.9 验证元数据路由在搜索流程中的分发正确性

阅读 sklearn/model_selection/_search.pyBaseSearchCV._get_routed_params_for_fit (约 450-480行) 与 get_metadata_routing (约 800-830行):

  1. _get_routed_params_for_fit 如何区分 estimator.fitscorer.scoresplitter.split 的参数?

  2. sample_weight 何时被路由给 scorer?_check_scorers_accept_sample_weight 如何影响路由决策?

  3. process_routing 返回的 Bunch 结构如何被 _fit_and_score 消费?

  4. enable_metadata_routing=False 时,routed_params 的降级构造逻辑是什么?

41.11.10 实战 cv_results_ 掩码数组构建与复杂参数类型处理

阅读 sklearn/model_selection/_search.py_yield_masked_array_for_each_param (约 350-400行) 及测试 test_yield_masked_array_for_each_param

  1. 为何 param_Ckernel='poly' 的候选中被掩码?param_result 字典如何记录每个候选的参数值?

  2. 当参数值为列表的列表(如 knots)时,为何 dtype=objectnp.array 构造失败的回退逻辑?

  3. 当参数值为元组或估计器对象时,MaskedArraydtype 与赋值逻辑有何特殊处理?

  4. 大规模候选参数(>1000)构建掩码数组时,为何可能触发 RuntimeWarning?代码如何规避?

41.12 6️⃣ 小结 & 推荐实践

本章围绕源码梳理了核心数据结构、调用流程与设计权衡。

以下是本章概念速查表:

| 概念 | 解释 |

|---|---|

| cross_validate | 多指标交叉验证主入口,并行调度各折 fit/score,返回分数字典与计时 |

| cross_val_score | 单指标评分快捷接口,内部调用 cross_validate 并提取 test_score |

| _fit_and_score | 原子操作:克隆估计器、切分数据、拟合、评分、计时、异常处理,并行工作单元 |

| cross_val_predict | 逐折收集预测结果,enforce_prediction_order 保障类别顺序与 classes 一致 |

| learning_curve | 渐进式增加训练集大小,揭示模型偏差-方差随数据量变化的规律 |

| validation_curve | 固定数据扫描单一超参数,对比训练/测试分数定位欠拟合/过拟合区间 |

| permutation_test_score | 目标随机置换生成经验零分布,计算 p-value 评估模型得分统计显著性 |

| _aggregate_score_dicts | 将多折、多指标的原始分数字典聚合为统一结构,便于后续统计 |

| enforce_prediction_order | 处理多分类中部分折缺失类别导致的概率列错位,按 classes 补齐重排 |

| metadata routing | sample_weight/groups 等元数据在 fit/score/split 间精准分发 |

| Array API 支持 | 通过 array_namespace 统一调度,兼容 NumPy/CuPy/JAX 等后端 |

| BaseSearchCV | 超参数搜索基类,统一管理评分器、CV、元数据路由、结果聚合与最佳模型选拔 |

| GridSearchCV | 穷举参数网格的超参数搜索,配合交叉验证并行评估所有候选组合 |

| RandomizedSearchCV | 从参数分布随机采样候选组合的超参数搜索,适合高维参数空间 |

| ParameterGrid | 参数网格迭代器,有序生成所有离散参数组合,支持列表字典与嵌套网格 |

| ParameterSampler | 参数随机采样器,支持分布采样与列表均匀采样,自动切换有放回/无放回策略 |

| HalvingGridSearchCV | 基于逐次减半的网格搜索,小资源初筛、大资源精选,因子式淘汰低分候选 |

| HalvingRandomSearchCV | 基于逐次减半的随机搜索,结合随机采样与资源递增淘汰机制 |

| BaseSuccessiveHalving | 逐次减半搜索基类,实现资源迭代调度、候选 Top-K 筛选、激进淘汰等核心逻辑 |

| _SubsampleMetaSplitter | 资源受限下的 CV 包装器,按比例对训练/测试索引进行无放回重采样 |

| _top_k | 按当前迭代得分筛选前 k 个最佳候选参数组合 |

| yield_masked_array_for_each_param | 构建 cv_results 中参数列的掩码数组,处理不同候选参数键不一致的情况 |

| cv_results_ | 统一的搜索结果字典,包含分数、时间、参数、排名等掩码数组,可直接转 DataFrame |

| refit | 在全量数据上用最佳参数重新拟合,暴露 predict/transform 等代理方法 |

| error_score | 拟合失败时的分数处理策略:数值填充或抛出异常 |

| return_train_score | 是否在 cv_results_ 中包含训练集分数,用于诊断过拟合/欠拟合 |

下一章将继续沿相关模块的调用链深入分析。

第 42 章 —— 生活类比:模型选择的“总服务台”

42.1 学习目标

  • 理解 model_selection 模块公共 API 的组织结构与导出策略

  • 掌握 Python 模块级 __getattr__ 实现实验性特性延迟加载与访问控制的机制

  • 理解 typing.TYPE_CHECKING 在静态类型检查与运行时行为解耦中的作用

  • 掌握测试基础设施中 OneTimeSplitter 这种一次性迭代器的设计模式与应用场景

  • 了解模块内部子模块(_split, _search, _validation 等)的职责划分与依赖关系

  • 难度:★★★☆☆(3/5)

  • 预备知识:Python 基础、面向对象编程与代码阅读基础

想象 sklearn.model_selection 如同一家大型机器学习服务公司的总服务台。前台(__init__.py)展示所有可直接办理的业务,指引用户到对应窗口;实验性业务(如 Halving*)则设有“仅凭邀请函方可办理”的专属通道;而类型检查员(mypy)在巡检时,只会看到蓝图(TYPE_CHECKING)而不实际开启这些业务。一次性体检套餐(OneTimeSplitter)模拟只能走一遍的流式检验流程,确保模型在不可重置的数据流上仍能正常工作。

类比延伸:在后续章节中,__getattr__ 就像服务台的保安,只放有邀请函的用户才能进入实验性房间;__all__ 是前台的公开业务清单;OneTimeSplitter 是只能使用一次的检验器,防止用户误以为可以重复预约。


42.2 源码地图:模块结构概览

sklearn/model_selection/__init__.py
├─ 1‑52   导入核心子模块并构建统一命名空间
├─ 54‑61   TYPE_CHECKING 分支:为静态检查提供实验性类型
├─ 63‑96   __all__ 声明:公开的 30+ 符号
├─ 99‑108  __getattr__ 实现实验特性访问拦截
└─ 110+    其他模块级辅助代码(如 __dir__ 等,非本章节关注范围)

sklearn/model_selection/tests/common.py
└─ 1‑28   OneTimeSplitter 实现一次性切分器

42.3 公共 API 的组装与延迟加载

42.3.1 核心类型定义:FixedThresholdClassifier 等

from sklearn.model_selection._classification_threshold import (
    FixedThresholdClassifier,
    TunedThresholdClassifierCV,
)

这段代码完成 阈值调优相关类的导入,并把它们暴露到 sklearn.model_selection 的公共命名空间,使用户可以直接 from sklearn.model_selection import FixedThresholdClassifier

  • FixedThresholdClassifier:在模型输出概率后,使用固定阈值进行二分类。

  • TunedThresholdClassifierCV:通过交叉验证搜索最佳阈值,兼容所有实现 predict_proba 的估计器。

from sklearn.model_selection._plot import LearningCurveDisplay, ValidationCurveDisplay

这行代码把 可视化工具 导入到顶层命名空间,提供学习曲线与验证曲线的绘图 API,方便用户快速生成模型评估图形。

这两个导入实际上是“专业窗口”,专门负责阈值调节和结果可视化,业务逻辑已经在对应子模块中实现,这里仅做统一入口。

42.3.2 搜索与切分子模块的导入

from sklearn.model_selection._search import (
    GridSearchCV,
    ParameterGrid,
    ParameterSampler,
    RandomizedSearchCV,
)

上述代码把 超参数搜索 功能聚合到顶层。GridSearchCVRandomizedSearchCV 分别实现穷举搜索和随机抽样搜索;ParameterGridParameterSampler 为两者提供参数空间的迭代器。

from sklearn.model_selection._split import (
    BaseCrossValidator,
    BaseShuffleSplit,
    GroupKFold,
    GroupShuffleSplit,
    KFold,
    LeaveOneGroupOut,
    LeaveOneOut,
    LeavePGroupsOut,
    LeavePOut,
    PredefinedSplit,
    RepeatedKFold,
    RepeatedStratifiedKFold,
    ShuffleSplit,
    StratifiedGroupKFold,
    StratifiedKFold,
    StratifiedShuffleSplit,
    TimeSeriesSplit,
    check_cv,
    train_test_split,
)

这段代码把 交叉验证与数据划分 的实现全部导入,并统一暴露。每个类对应一种常用的划分策略,check_cvtrain_test_split 则提供便利的函数式接口。

这里的导入相当于“预约系统”,用户可以根据需求选取不同的划分方式或搜索策略。

42.3.3 TYPE_CHECKING 分支的独立解析

if typing.TYPE_CHECKING:
    # Avoid errors in type checkers (e.g. mypy) for experimental estimators.
    # TODO: remove this check once the estimator is no longer experimental.
    from sklearn.model_selection._search_successive_halving import (
        HalvingGridSearchCV,
        HalvingRandomSearchCV,
    )
  • 运行时typing.TYPE_CHECKINGFalse,块内代码不会执行,避免在普通运行环境中加载仍在实验阶段的实现。

  • 静态检查:当 mypypyright 等工具解析该文件时,TYPE_CHECKING 被视为 True,因此能够看到 Halving* 的类型定义,防止出现 “未定义名称” 的错误提示。

这相当于在蓝图上标记了实验室入口,只有审计(类型检查)阶段才会看到。

42.3.4 all 声明的逐项解析

__all__ = [
    "BaseCrossValidator",
    "BaseShuffleSplit",
    "FixedThresholdClassifier",
    "GridSearchCV",
    "GroupKFold",
    "GroupShuffleSplit",
    "HalvingGridSearchCV",
    "HalvingRandomSearchCV",
    "KFold",
    "LearningCurveDisplay",
    "LeaveOneGroupOut",
    "LeaveOneOut",
    "LeavePGroupsOut",
    "LeavePOut",
    "ParameterGrid",
    "ParameterSampler",
    "PredefinedSplit",
    "RandomizedSearchCV",
    "RepeatedKFold",
    "RepeatedStratifiedKFold",
    "ShuffleSplit",
    "StratifiedGroupKFold",
    "StratifiedKFold",
    "StratifiedShuffleSplit",
    "TimeSeriesSplit",
    "TunedThresholdClassifierCV",
    "ValidationCurveDisplay",
    "check_cv",
    "cross_val_predict",
    "cross_val_score",
    "cross_validate",
    "learning_curve",
    "permutation_test_score",
    "train_test_split",
    "validation_curve",
]
posted @ 2026-09-04 04:09  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报