Sklearn-源码解析-书-v1-0-一-
Sklearn 源码解析(书)v1.0(一)
第 1 章 —— scikit-learn 概览 —— 认识这座“机器学习工具箱”
1.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 README 作为项目'品牌名片'的信息组织与版本常量替换机制
-
掌握 sklearn/init.py 中懒加载子模块与 getattr 的 PEP 562 实现原理
-
了解 OpenMP 环境变量在包初始化阶段设置的必要性与防御性编程思想
-
认识 _min_dependencies.py 如何通过标签系统统一管理多阶段依赖版本约束
-
理解 __check_build 的构建失败诊断机制与用户引导策略
-
能够解释 _distributor_init 在 Windows wheel 运行时预加载 OpenMP DLL 的时序要求
-
掌握 dir 方法如何配合 all 提供完整的属性发现体验
1.2 项目全景速览 —— 认识这座“机器学习工具箱”的品牌名片
scikit-learn 的 README.md 就像一座科技博物馆的入口大厅,它不仅展示了项目的基本信息,更通过精心设计的信息组织方式,让新手能够“一眼速览”项目状态,老手也能快速定位所需资源。本节我们将从实际文件出发,拆解它是如何通过徽章区、版本常量替换、项目定位等机制,成为连接用户与项目的“品牌名片”。
1.2.1 README.md 的整体结构
文件从顶部的徽章区开始,像博物馆门口的荣誉墙,一眼就能看出项目的构建状态、测试覆盖率、版本号等关键运维信息。紧接着是通过 RST replace 指令定义的版本常量区,这就像博物馆前台的电子屏,自动更新票价信息,改一处后台数据所有屏幕同步更新。随后是项目简介与安装指南,清晰地说明了项目定位、历史渊源以及用户应该如何安装。开发与贡献指南则像博物馆的志愿者手册,指引潜在贡献者如何参与项目。最后是社区与引用部分,提供了沟通渠道、社交媒体矩阵以及学术引用方式,构建了完整的社区参与生态。
让我们看看实际的文件内容:
源码路径:README.md - __main__(1-198行)
.. -*- mode: rst -*-
|Azure| |Codecov| |CircleCI| |Nightly wheels| |Ruff| |PythonVersion| |PyPI| |DOI| |Benchmark|
.. |Azure| image:: https://dev.azure.com/scikit-learn/scikit-learn/_apis/build/status/scikit-learn.scikit-learn?branchName=main
:target: https://dev.azure.com/scikit-learn/scikit-learn/_build/latest?definitionId=1&branchName=main
.. |CircleCI| image:: https://circleci.com/gh/scikit-learn/scikit-learn/tree/main.svg?style=shield
:target: https://circleci.com/gh/scikit-learn/scikit-learn
.. |Codecov| image:: https://codecov.io/gh/scikit-learn/scikit-learn/branch/main/graph/badge.svg?token=Pk8G9gg3y9
:target: https://codecov.io/gh/scikit-learn/scikit-learn
.. |Nightly wheels| image:: https://github.com/scikit-learn/scikit-learn/actions/workflows/wheels.yml/badge.svg?event=schedule
:target: https://github.com/scikit-learn/scikit-learn/actions?query=workflow%3A%22Wheel+builder%22+event%3Aschedule
.. |Ruff| image:: https://img.shields.io/badge/code%20style-ruff-000000.svg
:target: https://github.com/astral-sh/ruff
.. |PythonVersion| image:: https://img.shields.io/pypi/pyversions/scikit-learn.svg
:target: https://pypi.org/project/scikit-learn/
.. |PyPI| image:: https://img.shields.io/pypi/v/scikit-learn
:target: https://pypi.org/project/scikit-learn
.. |DOI| image:: https://zenodo.org/badge/21369/scikit-learn/scikit-learn.svg
:target: https://zenodo.org/badge/latestdoi/21369/scikit-learn/scikit-learn
.. |Benchmark| image:: https://img.shields.io/badge/Benchmarked%20by-asv-blue
:target: https://scikit-learn.org/scikit-learn-benchmarks
.. |PythonMinVersion| replace:: 3.11
.. |NumPyMinVersion| replace:: 1.24.1
.. |SciPyMinVersion| replace:: 1.10.0
.. |JoblibMinVersion| replace:: 1.3.0
.. |ThreadpoolctlMinVersion| replace:: 3.2.0
.. |MatplotlibMinVersion| replace:: 3.6.1
.. |Scikit-ImageMinVersion| replace:: 0.22.0
.. |PandasMinVersion| replace:: 1.5.0
.. |SeabornMinVersion| replace:: 0.13.0
.. |PytestMinVersion| replace:: 7.1.2
.. |PlotlyMinVersion| replace:: 5.18.0
.. image:: https://raw.githubusercontent.com/scikit-learn/scikit-learn/main/doc/logos/scikit-learn-logo.png
:target: https://scikit-learn.org/
**scikit-learn** is a Python module for machine learning built on top of
SciPy and is distributed under the 3-Clause BSD license.
The project was started in 2007 by David Cournapeau as a Google Summer
of Code project, and since then many volunteers have contributed. See
the `About us <https://scikit-learn.org/dev/about.html#authors>`__ page
for a list of core contributors.
It is currently maintained by a team of volunteers.
Website: https://scikit-learn.org
Installation
------------
Dependencies
~~~~~~~~~~~~
scikit-learn requires:
- Python (>= |PythonMinVersion|)
- NumPy (>= |NumPyMinVersion|)
- SciPy (>= |SciPyMinVersion|)
- joblib (>= |JoblibMinVersion|)
- threadpoolctl (>= |ThreadpoolctlMinVersion|)
=======
Scikit-learn plotting capabilities (i.e., functions start with ``plot_`` and
classes end with ``Display``) require Matplotlib (>= |MatplotlibMinVersion|).
For running the examples Matplotlib >= |MatplotlibMinVersion| is required.
A few examples require scikit-image >= |Scikit-ImageMinVersion|, a few examples
require pandas >= |PandasMinVersion|, some examples require seaborn >=
|SeabornMinVersion| and Plotly >= |PlotlyMinVersion|.
User installation
~~~~~~~~~~~~~~~~~
If you already have a working installation of NumPy and SciPy,
the easiest way to install scikit-learn is using ``pip``::
pip install -U scikit-learn
or ``conda``::
conda install -c conda-forge scikit-learn
The documentation includes more detailed `installation instructions <https://scikit-learn.org/stable/install.html>`_.
Changelog
---------
See the `changelog <https://scikit-learn.org/dev/whats_new.html>`__
for a history of notable changes to scikit-learn.
Development
-----------
We welcome new contributors of all experience levels. The scikit-learn
community goals are to be helpful, welcoming, and effective. The
`Development Guide <https://scikit-learn.org/stable/developers/index.html>`_
has detailed information about contributing code, documentation, tests, and
more. We've included some basic information in this README.
Important links
~~~~~~~~~~~~~~~
- Official source code repo: https://github.com/scikit-learn/scikit-learn
- Download releases: https://pypi.org/project/scikit-learn/
- Issue tracker: https://github.com/scikit-learn/scikit-learn/issues
Source code
~~~~~~~~~~~
You can check the latest sources with the command::
git clone https://github.com/scikit-learn/scikit-learn.git
Contributing
~~~~~~~~~~~~
To learn more about making a contribution to scikit-learn, please see our
`Contributing guide
<https://scikit-learn.org/dev/developers/contributing.html>`_.
Testing
~~~~~~~
After installation, you can launch the test suite from outside the source
directory (you will need to have ``pytest`` >= |PytestMinVersion| installed)::
pytest sklearn
See the web page https://scikit-learn.org/dev/developers/contributing.html#testing-and-improving-test-coverage
for more information.
Random number generation can be controlled during testing by setting
the ``SKLEARN_SEED`` environment variable.
Submitting a Pull Request
~~~~~~~~~~~~~~~~~~~~~~~~~
Before opening a Pull Request, have a look at the
full Contributing page to make sure your code complies
with our guidelines: https://scikit-learn.org/stable/developers/index.html
Project History
---------------
The project was started in 2007 by David Cournapeau as a Google Summer
of Code project, and since then many volunteers have contributed. See
the `About us <https://scikit-learn.org/dev/about.html#authors>`__ page
for a list of core contributors.
The project is currently maintained by a team of volunteers.
**Note**: `scikit-learn` was previously referred to as `scikits.learn`.
Help and Support
----------------
Documentation
~~~~~~~~~~~~~
- HTML documentation (stable release): https://scikit-learn.org
- HTML documentation (development version): https://scikit-learn.org/dev/
- FAQ: https://scikit-learn.org/stable/faq.html
Communication
~~~~~~~~~~~~~
Main Channels
^^^^^^^^^^^^^
- **Website**: https://scikit-learn.org
- **Blog**: https://blog.scikit-learn.org
- **Mailing list**: https://mail.python.org/mailman/listinfo/scikit-learn
Developer & Support
^^^^^^^^^^^^^^^^^^^^^^
- **GitHub Discussions**: https://github.com/scikit-learn/scikit-learn/discussions
- **Stack Overflow**: https://stackoverflow.com/questions/tagged/scikit-learn
- **Discord**: https://discord.gg/h9qyrK8Jc8
Social Media Platforms
^^^^^^^^^^^^^^^^^^^^^^
- **LinkedIn**: https://www.linkedin.com/company/scikit-learn
- **YouTube**: https://www.youtube.com/channel/UCJosFjYm0ZYVUARxuOZqnnw/playlists
- **Facebook**: https://www.facebook.com/scikitlearnofficial/
- **Instagram**: https://www.instagram.com/scikitlearnofficial/
- **TikTok**: https://www.tiktok.com/@scikit.learn
- **Bluesky**: https://bsky.app/profile/scikit-learn.org
- **Mastodon**: https://mastodon.social/@sklearn@fosstodon.org
Resources
^^^^^^^^^
- **Calendar**: https://blog.scikit-learn.org/calendar/
- **Logos & Branding**: https://github.com/scikit-learn/scikit-learn/tree/main/doc/logos
Citation
~~~~~~~~
If you use scikit-learn in a scientific publication, we would appreciate citations: https://scikit-learn.org/stable/about.html#citing-scikit-learn
这段代码定义了 README.md 的完整结构,通过徽章区提供一眼速览的运维信息,通过 replace 指令实现版本常量的集中管理,并详细说明了项目定位、安装方式、开发指南等关键信息。
1.3 包初始化的“欢迎大厅” —— sklearn/init.py 的模块编排术
当用户执行 import sklearn 时,实际上是在走进 scikit-learn 这座博物馆的欢迎大厅。这里不仅要快速告诉游客“有什么展览”,还要确保游客只在真正需要时才“点亮”对应展厅的灯——这就是懒加载的核心思想。本节我们将从实际源码出发,理解 __init__.py 如何通过 _submodules 列表统一管理子模块,如何使用 __getattr__ 实现按需加载,以及为什么这种设计能够显著改善冷启动性能。
1.3.1 为什么需要懒加载机制?
想象一下,如果博物馆开馆时就把所有展厅的灯都打开,不仅浪费能源,而且可能导致电路过载。scikit-learn 有 37 个子模块,如果在 __init__.py 中直接 import 所有子模块,那么即使用户只需要使用 sklearn.linear_model,也会加载所有不需要的模块,这会显著增加冷启动时间和内存占用。
__init__.py 的设计遵循“先声明目录,再按需加载”的原则:
-
首先通过
_submodules列表声明所有可用的子模块(就像博物馆导览手册的目录页) -
使用
__getattr__实现 PEP 562 风格的懒加载:只有当用户真正访问sklearn.linear_model时,才触发实际的importlib.import_module导入 -
通过
__dir__()返回__all__列表,让dir(sklearn)和 IDE 自动补全能够看到完整的可用属性
让我们看看实际的源码是如何实现这一机制的:
源码路径:sklearn/__init__.py - __main__(1-45行)
"""Configure global settings and get information about the working environment."""
# 第 1 章 —— Authors: The scikit-learn developers
# 第 1 章 —— SPDX-License-Identifier: BSD-3-Clause
# 第 1 章 —— Machine learning module for Python
# 第 1 章 —— ==================================
#
# 第 1 章 —— sklearn is a Python module integrating classical machine
# 第 1 章 —— learning algorithms in the tightly-knit world of scientific Python
# 第 1 章 —— packages (numpy, scipy, matplotlib).
#
# 第 1 章 —— It aims to provide simple and efficient solutions to learning problems
# 第 1 章 —— that are accessible to everybody and reusable in various contexts:
# 第 1 章 —— machine-learning as a versatile tool for science and engineering.
#
# 第 1 章 —— See https://scikit-learn.org for complete documentation.
import importlib as _importlib
import logging
import os
import random
from sklearn._config import config_context, get_config, set_config
logger = logging.getLogger(__name__)
# 第 1 章 —— PEP0440 compatible formatted version, see:
# 第 1 章 —— https://www.python.org/dev/peps/pep-0440/
#
# 第 1 章 —— Generic release markers:
# 第 1 章 —— X.Y.0 # For first release after an increment in Y
# 第 1 章 —— X.Y.Z # For bugfix releases
#
# 第 1 章 —— Admissible pre-release markers:
# 第 1 章 —— X.Y.ZaN # Alpha release
# 第 1 章 —— X.Y.ZbN # Beta release
# 第 1 章 —— X.Y.ZrcN # Release Candidate
# 第 1 章 —— X.Y.Z # Final release
#
# 第 1 章 —— Dev branch marker is: 'X.Y.dev' or 'X.Y.devN' where N is an integer.
# 第 1 章 —— 'X.Y.dev0' is the canonical version of 'X.Y.dev'
#
__version__ = "1.9.dev0"
这段代码定义了模块的基本信息,包括版本号(遵循 PEP 440 的开发分支标记 1.9.dev0),并导入了必要的标准库和内部模块。
源码路径:sklearn/__init__.py - __getattr__(114-121行)
def __getattr__(name):
if name in _submodules:
return _importlib.import_module(f"sklearn.{name}")
else:
try:
return globals()[name]
except KeyError:
raise AttributeError(f"Module 'sklearn' has no attribute '{name}'")
这个 __getattr__ 方法是懒加载的核心实现。当用户访问 sklearn.some_module 时:
-
如果
name在_submodules列表中(比如"linear_model"),则使用importlib.import_module动态导入对应的子模块 -
否则尝试从全局命名空间获取(如
__version__、show_versions等非模块属性) -
如果都找不到,则抛出
AttributeError
这段代码实现了按需导入子模块的机制,只有在真正需要时才加载具体的子模块,有效降低了冷启动延迟。
1.3.2 dir() 如何工作?
__dir__() 方法看似简单,但它在属性发现中扮演着关键角色。它直接返回 __all__ 列表,这意味着当用户在交互式环境中输入 dir(sklearn) 时,能够看到所有公开可用的属性和子模块,而不需要实际加载它们。
源码路径:sklearn/__init__.py - __dir__(110-111行)
def __dir__():
return __all__
这段代码定义了 __dir__ 方法,直接返回 __all__ 列表,确保 dir(sklearn) 和 IDE 自动补全能够列出所有公开属性,而不触发实际的模块导入。
1.4 OpenMP 运行时守护 —— 跨平台并行计算的“稳定器”
在现代机器学习库中,并行计算已经成为提升性能的重要手段。scikit-learn 利用 OpenMP 在底层实现了多种算法的并行加速(如决策树的特征分裂、K-Means 的聚类迭代等)。然而,不同操作系统上的 OpenMP 实现存在兼容性问题,特别是在 macOS 上,同时加载多个 OpenMP 库可能导致运行时崩溃。本节我们将从实际源码出发,理解为什么在包入口设置两个特定的环境变量是解决这一问题的关键,以及这种设计如何体现防御性编程的思想。
1.4.1 为什么需要这些环境变量设置?
想象你是在一座现代化图书馆里安装音响系统。如果不小心安装了两套独立的音响系统,并且它们都试图同时使用同一套扬声器,就会产生严重的干扰和噪声。在 scikit-learn 中,某些依赖(如 Intel 的 MKL 库)可能已经包含了自己的 OpenMP 实现,而 scikit-learn 通过 Cython 编译的扩展模块也可能链接到另一个 OpenMP 库。当这两套 OpenMP 实现同时尝试初始化时,就可能发生冲突。
这两行环境变量设置正是为了防止这种“双系统冲突”:
-
KMP_DUPLICATE_LIB_OK=True:告诉 Intel 的 OpenMP 运行时允许同时加载多个 OpenMP 库(否则会直接抛出错误) -
KMP_INIT_AT_FORK=FALSE:规避 intel-openmp 2019.5 版本中的一个已知Bug:在多进程场景下(如使用joblib进行并行计算时),在fork之后重新初始化 OpenMP 可能导致崩溃
这些设置必须在导入任何可能使用 OpenMP 的模块之前执行,这就是为什么它们放在 __init__.py 的顶部位置。
源码路径:sklearn/__init__.py - __main__(47-55行)
# 第 1 章 —— On OSX, we can get a runtime error due to multiple OpenMP libraries loaded
# 第 1 章 —— simultaneously. This can happen for instance when calling BLAS inside a
# 第 1 章 —— prange. Setting the following environment variable allows multiple OpenMP
# 第 1 章 —— libraries to be loaded. It should not degrade performances since we manually
# 第 1 章 —— take care of potential over-subcription performance issues, in sections of
# 第 1 章 —— the code where nested OpenMP loops can happen, by dynamically reconfiguring
# 第 1 章 —— the inner OpenMP runtime to temporarily disable it while under the scope of
# 第 1 章 —— the outer OpenMP parallel section.
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "True")
# 第 1 章 —— Workaround issue discovered in intel-openmp 2019.5:
# 第 1 章 —— https://github.com/ContinuumIO/anaconda-issues/issues/11294
os.environ.setdefault("KMP_INIT_AT_FORK", "FALSE")
这两行代码使用 os.environ.setdefault 设置环境变量,只有在变量未被用户预先设置时才生效。这种设计体现了防御性编程:我们尊重用户可能已经做出的配置选择,同时为大多数用户提供安全的默认行为。
1.5 全局测试随机种子 —— 可复现实验的“统一发令枪”
在机器学习实验中,可复现性是基石。如果同一段代码在不同运行时产生完全不同的结果,那么我们就无法信任实验结论,也无法在团队中有效协作。scikit-learn 通过 setup_module 函数实现了测试会话级别的随机种子控制,这就像在实验开始前统一发令枪的时间,确保所有随机过程都从同一个起点开始。
1.5.1 如何理解 setup_module 的设计意图?
想象你是在进行一个需要多次掷骰子的实验。如果每次掷骰子前都随机摇晃骰子杯,即使骰子本身是公平的,你也无法判断结果差异是来自真实效应还是初始摇晃方式的不同。scikit-learn 的测试涉及大量随机过程:数据划分、模型初始化、特征采样等。如果这些随机过程没有统一的种子控制,即使算法是确定性的,测试结果也可能因初始随机状态不同而波动。
setup_module 函数的工作原理如下:
-
检查环境变量
SKLEARN_SEED是否存在,如果存在则使用其值作为种子;否则生成一个随机种子 -
将这个种子同时应用于 NumPy 的全局随机状态和 Python 标准库的
random模块 -
打印出使用的种子值(以
I: Seeding RNGs with %r形式),便于在测试失败时追溯 -
由 pytest 自动作为模块级 fixture 调用,保证整个测试会话的随机性可控
源码路径:sklearn/__init__.py - setup_module()(124-138行)
def setup_module(module):
"""Fixture for the tests to assure globally controllable seeding of RNGs"""
import numpy as np
# Check if a random seed exists in the environment, if not create one.
_random_seed = os.environ.get("SKLEARN_SEED", None)
if _random_seed is None:
_random_seed = np.random.uniform() * np.iinfo(np.int32).max
_random_seed = int(_random_seed)
print("I: Seeding RNGs with %r" % _random_seed)
np.random.seed(_random_seed)
random.seed(_random_seed)
这段代码实现了测试会话级别的随机种子控制。它优先使用环境变量 SKLEARN_SEED(如果设置),否则生成一个随机种子;同时播种 NumPy 和 Python 标准库的随机数生成器;并打印诊断信息以便故障排除。这是 pytest 自动调用的模块级 fixture,保证整个测试会话的随机性可控。
1.6 最小依赖版本中心 —— 依赖管理的“版本尺子”
在一个复杂的软件项目中,依赖管理就像建造一座大楼时对各种建材的规格要求。如果用错了水泥标号或钢材规格,整个结构可能存在安全隐患。scikit-learn 通过 _min_dependencies.py 模块集中管理所有最小依赖版本,这不仅避免了版本号硬编码导致的维护噩梦,还通过标签系统实现了精细化的依赖阶段管理。
1.6.1 为什么需要集中管理最小依赖版本?
想象你是在管理一个大型建筑工地。如果水泥、钢材、木材等各种材料的规格要求分散在几十份不同的文件中,一旦需要更新标准(比如发现某种水泥牌号在某些条件下不够耐久),你就需要逐份检查和修改,极易出现遗漏或不一致。scikit-learn 有几十个依赖(从核心运行时依赖如 NumPy、SciPy,到构建依赖如 Cython、meson-python,再到可选依赖如 matplotlib、pandas 等),如果没有集中管理机制,版本号维护将变得异常脆弱。
_min_dependencies.py 的设计巧妙地使用了两级映射:
-
dependent_packages字典:将每个依赖映射为(版本号, 标签)二元组,标签如build/install/tests/docs表示该依赖在哪个构建或运行阶段需要 -
tag_to_packages反向映射:通过对dependent_packages的遍历构建,能够快速回答“某个构建阶段(比如tests)需要哪些依赖的版本约束”
这种设计不仅让版本号只在一处定义(避免硬编码),还让 CI 脚本和安装工具能够根据不同场景(构建、测试、文档生成等)精准查询所需依赖。
让我们看看实际的源码是如何实现这一机制的:
源码路径:sklearn/_min_dependencies.py - __main__(1-27行)
"""All minimum dependencies for scikit-learn."""
# 第 1 章 —— Authors: The scikit-learn developers
# 第 1 章 —— SPDX-License-Identifier: BSD-3-Clause
import argparse
from collections import defaultdict
# 第 1 章 —— scipy and cython should by in sync with pyproject.toml
NUMPY_MIN_VERSION = "1.24.1"
SCIPY_MIN_VERSION = "1.10.0"
JOBLIB_MIN_VERSION = "1.3.0"
THREADPOOLCTL_MIN_VERSION = "3.2.0"
PYTEST_MIN_VERSION = "7.1.2"
CYTHON_MIN_VERSION = "3.1.2"
# 第 1 章 —— 'build' and 'install' is included to have structured metadata for CI.
# 第 1 章 —— It will NOT be included in setup's extras_require
# 第 1 章 —— The values are (version_spec, comma separated tags)
dependent_packages = {
"numpy": (NUMPY_MIN_VERSION, "build, install"),
"scipy": (SCIPY_MIN_VERSION, "build, install"),
"joblib": (JOBLIB_MIN_VERSION, "install"),
"threadpoolctl": (THREADPOOLCTL_MIN_VERSION, "install"),
"cython": (CYTHON_MIN_VERSION, "build"),
"meson-python": ("0.17.1", "build"),
"matplotlib": ("3.6.1", "benchmark, docs, examples, tests"),
"scikit-image": ("0.22.0", "docs, examples"),
"pandas": ("1.5.0", "benchmark, docs, examples, tests"),
"seaborn": ("0.13.0", "docs, examples"),
"memory_profiler": ("0.57.0", "benchmark, docs"),
"pytest": (PYTEST_MIN_VERSION, "tests"),
"pytest-cov": ("2.9.0", "tests"),
"ruff": ("0.12.2", "tests"),
"mypy": ("1.15", "tests"),
"pyamg": ("5.0.0", "tests"),
"polars": ("0.20.30", "docs, tests"),
"pyarrow": ("12.0.0", "tests"),
"sphinx": ("7.3.7", "docs"),
"sphinx-copybutton": ("0.5.2", "docs"),
"sphinx-gallery": ("0.17.1", "docs"),
"numpydoc": ("1.20", "docs, tests"),
"Pillow": ("10.1.0", "docs"),
"pooch": ("1.8.0", "docs, examples, tests"),
"sphinx-prompt": ("1.4.0", "docs"),
"sphinxext-opengraph": ("0.9.1", "docs"),
"plotly": ("5.18.0", "docs, examples"),
"sphinxcontrib-sass": ("0.3.4", "docs"),
"sphinx-remove-toctrees": ("1.0.0.post1", "docs"),
"sphinx-design": ("0.6.0", "docs"),
"pydata-sphinx-theme": ("0.15.3", "docs"),
"towncrier": ("24.8.0", "docs"),
# XXX: Pin conda-lock to the latest released version (needs manual update
# from time to time)
"conda-lock": ("3.0.1", "maintenance"),
}
这段代码定义了所有最小依赖版本作为模块级常量,并构建了 dependent_packages 字典,其中每个键是包名,值是一个元组 (最低版本, 逗号分隔的标签字符串)。标签表示该依赖在哪些阶段需要(如 build、install、tests、docs 等)。
源码路径:sklearn/_min_dependencies.py - __main__(38-48行)
# 第 1 章 —— create inverse mapping for setuptools
tag_to_packages: dict = defaultdict(list)
for package, (min_version, extras) in dependent_packages.items():
for extra in extras.split(", "):
tag_to_packages[extra].append("{}>={}".format(package, min_version))
这段代码构建了 tag_to_packages 反向映射。它遍历 dependent_packages 字典,对于每个包及其标签,将格式化的版本要求(如 numpy>=1.24.1)添加到对应标签的列表中。这使得我们可以快速查询某个标签下需要哪些依赖。
源码路径:sklearn/_min_dependencies.py - __main__(51-58行)
# 第 1 章 —— Used by CI to get the min dependencies
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Get min dependencies for a package")
parser.add_argument("package", choices=dependent_packages)
args = parser.parse_args()
min_version = dependent_packages[args.package][0]
print(min_version)
这段代码提供了命令行接口。当作为脚本运行时,它接受一个包名作为参数(必须是 dependent_packages 中的键),并打印该包的最低版本要求。例如,执行 python -m sklearn._min_dependencies numpy 会输出 1.24.1。
1.7 构建失败诊断器 —— 安装故障的“急诊分诊台”
即使有最完善的文档和最清晰的安装指南,用户在安装 scikit-learn 时仍然可能遇到各种问题。特别是当用户尝试从源码构建时,如果忘记了编译步骤,直接尝试导入会导致令人困惑的错误。scikit-learn 通过 __check_build 模块实现了一个智能的“急诊分诊台”:它不仅能够检测构建是否成功,更重要的是,当检测到失败时,能够给出针对不同场景的、人类可读的修复建议,就像急诊室的护士不仅能测量体温,还能根据症状给出初步诊断和就医建议。
1.7.1 为什么需要专有的构建检测模块?
想象你去药店买药,如果买错了药,单纯地说“药不对”对你帮助不大;但如果药剂师能够说“你买的是感冒药,但你实际需要的是退烧药,而且你刚才吃了布洛芬,可能需要避免某些成分”,这就提供了真正有价值的指导。scikit-learn 的构建失败诊断器正是这种思想的体现:它不只是说“导入失败”,而是能够区分“你是在源码树中直接导入(需要先编译)”还是“你安装了发行包但安装损坏(需要重新安装)”,并给出相应的修复步骤。
这个机制的核心是 raise_build_error 函数,它通过检查 __file__ 所在的目录来判断当前是源码就地构建场景还是标准安装场景:
-
在源码树中导入时,
__file__可能是.../scikit-learn/sklearn/__check_build/__init__.py,所以local_dir(即__file__所在目录)等于"sklearn/__check_build" -
在标准安装(如通过 pip 安装的 wheel)中导入时,
__file__所在目录不会是这种特定路径
根据这个判断,函数会选择不同的错误消息模板(INPLACE_MSG 或 STANDARD_MSG),并附上目录内容列表和修复建议。
让我们看看实际的源码是如何实现这一机制的:
源码路径:sklearn/__check_build/__init__.py - raise_build_error()(15-42行)
def raise_build_error(e):
# Raise a comprehensible error and list the contents of the
# directory to help debugging on the mailing list.
local_dir = os.path.split(__file__)[0]
msg = STANDARD_MSG
if local_dir == "sklearn/__check_build":
# Picking up the local install: this will work only if the
# install is an 'inplace build'
msg = INPLACE_MSG
dir_content = list()
for i, filename in enumerate(os.listdir(local_dir)):
if (i + 1) % 3:
dir_content.append(filename.ljust(26))
else:
dir_content.append(filename + "\n")
raise ImportError(
"""%s
___________________________________________________________________________
Contents of %s:
%s
___________________________________________________________________________
It seems that scikit-learn has not been built correctly.
If you have installed scikit-learn from source, please do not forget
to build the package before using it. For detailed instructions, see:
https://scikit-learn.org/dev/developers/development_setup.html#install-editable-version-of-scikit-learn
%s"""
% (e, local_dir, "".join(dir_content).strip(), msg)
)
这段代码实现了构建失败的智能诊断。它根据 local_dir 是否等于 "sklearn/__check_build" 来区分两种场景:
-
若相等,使用
INPLACE_MSG(源码就地构建场景):提示用户需要 inplace 安装 -
若不相等,使用
STANDARD_MSG(标准安装场景):提示用户检查安装程序是否匹配
然后它列出当前目录的内容(每三个文件换行一次),并组装一个详细的 ImportError,包含原始异常、目录列表、修复建议以及开发文档链接。
源码路径:sklearn/__check_build/__init__.py - __main__(44-47行)
try:
from sklearn.__check_build._check_build import check_build # noqa: F401
except ImportError as e:
raise_build_error(e)
这段代码尝试导入 Cython 编译产物 _check_build.pyx 中的 check_build 函数。如果导入失败(意味着构建产物不存在或损坏),它会捕获 ImportError 并调用上面的 raise_build_error 函数生成用户友好的错误消息。
源码路径:sklearn/__check_build/_check_build.pyx - check_build()(1-2行)
def check_build():
return
这个极简的 Cython 函数实际上只是一个存在性检查:如果 .pyx 文件成功编译成了 .so 或 .pyd 扩展模块,那么这行导入就会成功;否则会触发 ImportError,从而进入错误诊断流程。这种“ éxito significa OK,失败触发诊断” 的设计既简单又有效。
1.8 发行商初始化钩子 —— Windows wheel 的“运行时检修员”
在软件分发的世界里,不同的发行商(如官方 PyPI wheel、conda-forge 包、各种 Linux 发行官方仓库)可能需要在包载入时执行一些特定的初始化操作。对于 scikit-learn 在 Windows 上的 wheel 分发来说,一个关键的需求是预加载 OpenMP 运行时库(如 vcomp140.dll),否则当用户第一次使用需要 OpenMP 的功能时,可能会遇到 DLL 未找到的运行时错误。_distributor_init 模块正是为这种需求而设计的“运行时检修员”:它为下游发行商提供了一个挂钩点,可以在 scikit-learn 主要API暴露之前执行自定义初始化逻辑。
1.8.1 为什么 _distributor_init 的导入顺序如此关键?
想象你是在为一场大型音乐会做舞台准备。如果音响设备还没有就位就让乐队开始排练,可能会因为设备不到位而产生各种问题。在 scikit-learn 的包初始化过程中,show_versions 函数(来自 sklearn.utils._show_versions)需要能够内省 OpenMP 运行时状态来显示版本信息。如果在这个时候 OpenMP DLL 还没有被正确加载,导入 show_versions 本身就可能失败。
因此,正确的导入顺序至关重要:
-
首先设置 OpenMP 相关的环境变量(如
KMP_DUPLICATE_LIB_OK、KMP_INIT_AT_FORK) -
然后导入
__check_build和_distributor_init—— 这里_distributor_init的作用是为 Windows wheel 预加载必要的 OpenMP DLL -
最后才导入
show_versions和其他可能依赖 OpenMP 运行时的模块
这种“先检测—再初始化—最后暴露API”的顺序体现了深思熟虑的防御性编程:我们在可能需要这些资源之前就确保它们就位,而不是等到出问题了再去补救。
源码路径:sklearn/__init__.py - __main__(56-63行)
# 第 1 章 —— `_distributor_init` allows distributors to run custom init code.
# 第 1 章 —— For instance, for the Windows wheel, this is used to pre-load the
# 第 1 章 —— vcomp shared library runtime for OpenMP embedded in the sklearn/.libs
# 第 1 章 —— sub-folder.
# 第 1 章 —— It is necessary to do this prior to importing show_versions as the
# 第 1 章 —— later is linked to the OpenMP runtime to make it possible to introspect
# 第 1 章 —— it and importing it first would fail if the OpenMP dll cannot be found.
from sklearn import __check_build, _distributor_init # noqa: E402 F401
from sklearn.base import clone # noqa: E402
from sklearn.utils._show_versions import show_versions # noqa: E402
这段代码按正确的顺序导入了必要的模块:
-
首先导入
__check_build(用于构建失败诊断)和_distributor_init(发行商初始化钩子) -
然后导入
clone(从sklearn.base) -
最后导入
show_versions(用于显示版本和依赖信息)
关键注释说明了为什么 _distributor_init 必须在 show_versions 之前导入:因为后者链接了 OpenMP 运行时,如果 DLL 未被预加载,导入它就会失败。
1.9 属性发现与导出控制 —— dir 与 all 的“目录服务台”
在一个功能丰富的库中,用户需要知道“有什么可用”的需求是基本的。scikit-learn 有 37 个子模块以及许多实用函数(如 clone、get_config 等),如果用户必须阅读源码才能发现这些功能,那么库的可用性就会大打折扣。通过 __all__ 和 __dir__ 的协作,scikit-learn 实现了一个智能的“目录服务台”:用户可以通过 dir(sklearn) 或在 IDE 中输入 sklearn. 看到自动补全列表,而不需要实际加载所有子模块;只有当用户真正选择一个属性时,才通过 __getattr__ 触发实际的导入。
1.9.1 为什么需要显式定义 __dir__ 和 __all__?
想象你走进一个巨大的图书馆位置。如果没有目录或书架标识,你可能需要挨个书架去查找才能知道有什么书可读。即使有目录,如果目录不准确(比如漏掉了某些书架)或更新不及时(新书到架但目录没更新),你也会感到困惑。scikit-learn 通过以下机制确保属性发现既完整又准确:
-
__all__明确定义了from sklearn import *应该导入什么(它将_submodules列表与clone、get_config等非模块符号合并) -
__dir__()直接返回__all__列表,确保dir(sklearn)和 IDE 自动补全能够看到所有公开属性 -
这种设计与
__getattr__的懒加载机制完美配合:dir()先展示全貌(不实际加载),实际访问时(如sklearn.linear_model)才触发导入
这种组合让用户无需阅读源码即可通过交互式环境发现 scikit-learn 的完整 API 表面,同时保持了冷启动的高效性。
源码路径:sklearn/__init__.py - __main__(99-107行)
__all__ = _submodules + [
# Non-modules:
"clone",
"get_config",
"set_config",
"config_context",
"show_versions",
]
这段代码组装了 __all__ 列表。它将 _submodules(包含所有37个子模块名的列表)与一些非模块公开符号(如 clone、get_config、set_config、config_context、show_versions)合并。这个列表控制着 from sklearn import * 的行为,以及作为 __dir__() 的返回值。
源码路径:sklearn/__init__.py - __dir__(110-111行)
def __dir__():
return __all__
这段代码定义了 __dir__ 方法,直接返回 __all__ 列表。这确保了 dir(sklearn) 和 IDE 自动补全能够列出所有公开属性,而不触发实际的模块导入(实际导入由 __getattr__ 在属性被访问时处理)。
1.10 设计中的取舍
在软件设计中,几乎没有完美的解决方案,只有在特定约束下的最佳权衡。scikit-learn 的包入口设计正是如此:它在性能、易用性、维护成本和跨平台兼容性之间进行了精心的平衡。让我们审视几个关键的设计决策,理解它们背后的考量以及所带来的 trade-off。
1.10.1 为什么不用在模块顶部直接导入所有子模块?
这是懒加载设计核心的问题。如果在 __init__.py 中直接写 import sklearn.linear_model, sklearn.ensemble, ...(共37个),会发生什么?
这样做的后果:
-
冷启动时间显著增加:即使用户只需要使用
sklearn.metrics.accuracy_score,也会加载所有不需要的子模块 -
内存占用增加:所有子模块的代码和数据结构都会被载入内存,即使永远不会被使用
-
启动失败风险增加:只要任意一个子模块有导入错误(例如缺少某个可选依赖),整个
import sklearn就会失败
这种设计的trade-off是什么?
-
获得:显著改善冷启动性能和内存效率;失败隔离(一个子模块问题不会阻止整个包导入)
-
失去:极少数情况下可能稍微增加首次访问某个子模块的延迟(但这种延迟通常可以忽略不计,特别是当使用标准文件系统时)
对于一个旨在通用使用的机器学习库来说,这个 trade-off 是极其值得的:大多数用户会受益于更快的导入时间和更低的基础内存占用,而首次访问子模块的微小延迟在实际使用中往往被忽略不计。
1.10.2 OpenMP 环境变量设置是否可能被用户配置覆盖?
我们使用 os.environ.setdefault 而不是直接赋值(如 os.environ["KMP_DUPLICATE_LIB_OK"] = "True"),这是有意图的。
如果用户已经手动设置了这些环境变量会怎样?
-
setdefault只在环境变量未存在时设置值 -
如果用户已经设置了
KMP_DUPLICATE_LIB_OK=False,这段代码不会覆盖它,用户的选择会被尊重 -
只有当环境变量真正未被设置时,才会应用我们的安全默认值
这种设计的trade-off是什么?
-
获得:尊重用户可能已经做出的高级配置;避免无意中覆盖用户特定的环境设置
-
失去:在极少数情况下,如果用户错误地设置了这些变量(比如设置了
KMP_DUPLICATE_LIB_OK=False而在 macOS 上遇到了 OpenMP 冲突),他们需要自己排查问题
这种设计体现了一种原则:库应该提供安全的默认行为,但不应该强行覆盖用户明确的意图。对于了解自己在做什么的高级用户来说,能够覆盖这些默认值是必要的;而对于大多数用户来说,安全的默认值能够防常见问题。
1.10.3 为什么 __check_build 需要区分源码树和标准安装场景?
构建失败的错误信息如果一刀切,对用户的帮助是有限的。想象你去修理店说“我的车没法启动了”,修理师只说“加点油吧”——这可能有用,但如果问题是电瓶没电或起动机故障,这个建议就毫无帮助了。
两种场景需要不同的修复建议:
-
源码就地构建场景:用户克隆了源码但忘记了执行
pip install -e .或等效的编译步骤。正确的建议是:“请先执行可编辑安装(例如pip install -e .)来构建扩展模块” -
标准安装场景:用户通过
pip install scikit-learn安装了发行版,但安装损坏或不完整。正确的建议是:“请检查您的安装是否完整,或者尝试重新安装包”
这种设计的trade-off是什么?
-
获得:提供精准、可操作的修复建议,大幅提升用户自我解决问题的能力;减少在邮件列表或社区中的重复求助
-
失去:实现略微复杂一些(需要路径判断和两套消息模板),但这种复杂性是值得的,因为它直接提升了用户体验
通过根据 local_dir == "sklearn/__check_build" 来判断场景,scikit-learn 能够给出恰当的修复指引,这比一个通用的“请检查您的安装”要有用得多。
1.11 动手练习
-
阅读 init.py 的懒加载机制
阅读
sklearn/__init__.py第 99-121 行,理解子模块懒加载与属性发现的完整流程:-
_submodules列表如何驱动__all__和__dir__ -
__getattr__中importlib.import_module的调用时机 -
__dir__()为什么直接返回__all__而不是调用globals().keys()
回答问题:
-
为什么要用
__getattr__而不是在模块顶部直接import所有子模块? -
如果
__getattr__收到的 name 不在_submodules中,会走什么分支? -
dir(sklearn)与sklearn.__all__的关系是什么?
-
-
分析最小依赖版本管理
阅读
sklearn/_min_dependencies.py全文,梳理依赖标签系统的设计:-
dependent_packages字典中每个条目的结构 -
tag_to_packages反向映射的构建过程 -
__main__命令行接口的参数解析方式
回答问题:
-
为什么
cython被标记为build标签而joblib是install? -
尝试在终端执行
python -m sklearn._min_dependencies numpy,观察输出结果
-
-
追踪 OpenMP 环境变量的设置时机
阅读
sklearn/__init__.py第 47-63 行,分析初始化顺序:-
OpenMP 环境变量设置(47-55行)
-
__check_build与_distributor_init的导入(56行) -
show_versions的导入(59行)
回答问题:
-
为什么
os.environ.setdefault必须出现在show_versions导入之前? -
如果用户已经手动设置了
KMP_DUPLICATE_LIB_OK=False,这段代码会覆盖吗?
-
-
模拟构建失败诊断流程
阅读
sklearn/__check_build/__init__.py的raise_build_error函数:-
local_dir是如何通过os.path.split(__file__)获得的 -
local_dir == 'sklearn/__check_build'这个判断为什么能区分两种场景 -
目录内容的格式化输出逻辑(每 3 个文件换行)
回答问题:
-
在源码树中直接运行
import sklearn(未编译)会触发哪条错误消息模板? -
错误消息中包含的文档链接指向什么页面?
-
-
分析 README 的版本常量替换
阅读
README.md第 41-51 行,理解 RST 的replace指令:-
|NumPyMinVersion| replace:: 1.24.1的语法结构 -
这些常量在文档正文中是如何被引用的
-
与
sklearn/_min_dependencies.py中常量的对应关系
回答问题:
-
为什么不在 README 中直接写版本号,而要使用
replace指令? -
如果要升级 NumPy 最低版本到 1.25.0,需要修改哪些文件?
-
1.12 本章小结
scikit-learn 的包入口就像一座精心设计的博物馆前厅,它通过多层次的机制确保用户从第一秒起就能获得流畅体验。我们从 README.md 的品牌展示开始,了解它如何通过徽章区提供一眼速览的运维信息,通过 RST replace 指令实现版本常量的集中管理;然后深入 __init__.py 的欢迎大厅,掌握它如何通过 _submodules 列表、__getattr__ 懒加载和 __dir__() 目录服务实现高效的属性发现;接着探讨了 OpenMP 环境变量如何作为跨平台并行计算的稳定器,以及 setup_module 如何为测试提供可复现的随机种子控制;我们进一步了解了 _min_dependencies.py 如何通过标签系统统一管理多阶段依赖版本,以及 __check_build 如何通过区分源码树和标准安装场景提供精准的构建失败诊断;最后,我们考察了 _distributor_init 在 Windows wheel 运行时预加载 OpenMP DLL 的关键时序要求。
这一章中我们学习/了解/讨论了 scikit-learn 包入口的设计智慧。首先我们理解了 README 作为项目'品牌名片'的信息组织与版本常量替换机制;其次我们掌握了 sklearn/init.py 中懒加载子模块与 getattr 的 PEP 562 实现原理;其次我们了解了 OpenMP 环境变量在包初始化阶段设置的必要性与防御性编程思想;接着我们认识了 _min_dependencies.py 如何通过标签系统统一管理多阶段依赖版本约束;然后我们理解了 __check_build 的构建失败诊断机制与用户引导策略;接着我们能够解释 _distributor_init 在 Windows wheel 运行时预加载 OpenMP DLL 的时序要求;最后我们掌握了 dir 方法如何配合 all 提供完整的属性发现体验。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| README 徽章区 | 通过 CI/CD 徽章提供构建状态、覆盖率、版本号等运维信息的一眼速览 |
| RST replace 指令 | 在文档中引用版本常量,避免硬编码导致的维护漂移 |
| version = '1.9.dev0' | 遵循 PEP 440 的开发分支标记,dev0 是 dev 的规范化形式 |
| KMP_DUPLICATE_LIB_OK | 允许 macOS 上同时加载多个 OpenMP 库,避免 BLAS 在 prange 中的运行时冲突 |
| KMP_INIT_AT_FORK | 规避 intel-openmp 2019.5 在 fork 时的已知崩溃问题 |
| getattr 懒加载 | PEP 562 模块级属性访问钩子,按需导入子模块降低冷启动延迟 |
| dir() | 返回 all 列表,为 dir(sklearn) 和 IDE 自动补全提供完整的公开属性清单 |
| _submodules 列表 | 集中注册 37 个子模块名,统一驱动 all、dir 与懒加载逻辑 |
| all 组装 | 将子模块名与非模块公开符号合并为单一导出清单,控制 from sklearn import * 行为 |
| setup_module | pytest 模块级 fixture,用 SKLEARN_SEED 环境变量控制测试随机性 |
| dependent_packages | 将依赖映射为 (版本, 标签) 二元组,支撑 CI 与安装脚本的结构化查询 |
| tag_to_packages | 反向索引,快速回答'某个构建阶段需要哪些依赖' |
| CYTHON_MIN_VERSION | 与 pyproject.toml 手动同步,注释中明确提醒维护者 |
| raise_build_error | 根据 local_dir 判断源码树或安装包场景,给出差异化修复建议 |
| _check_build.pyx | Cython 编译产物,导入成功即代表构建产物存在 |
| _distributor_init | 发行商自定义初始化钩子,Windows wheel 用它预加载 OpenMP DLL |
下一章中,我们将学习估算器基类架构 —— 理解“机器学习算法的统一骨架”。
1.13 生活类比
想象 scikit-learn 的包入口是一座精心设计的博物馆前厅: README 徽章区 = 博物馆门口的荣誉墙与开放时间牌,一眼扫过就知道当前状态 版本常量替换 = 前台电子屏上自动更新的票价信息,改一处后台数据所有屏同步更新 init.py 懒加载 = 博物馆的智能导览系统,游客走到哪个展厅才点亮哪个展厅的灯 dir() 属性目录 = 导览手册的完整目录页,让游客在踏入前就能总览所有可参观的展厅 OpenMP 环境变量 = 开馆前的设备安检,提前消除多套音响系统同时开机可能产生的干扰 _check_build 诊断器 = 入口处的智能闸机,发现门票异常时不仅拦截,还会打印清晰的指引卡片 _distributor_init = 特殊展览(Windows 展区)的专属预检通道,确保关键设备在观众入场前就位 就像博物馆运营者必须在开馆前完成设备检查、导览系统预热和应急指引准备,scikit-learn 的包入口也需要精心编排初始化顺序,才能让用户从 import sklearn 的第一秒起就获得流畅体验。
1.14 模块地图/架构图
README.md
├── 徽章区(1-40行)
│ ├── 构建状态徽章(Azure/CircleCI/Codecov)
│ ├── 版本徽章(PyPI/PythonVersion/Nightly wheels)
│ └── 学术标识(DOI/Benchmark/Ruff)
├── 版本常量定义(41-51行)
│ ├── PythonMinVersion / NumPyMinVersion / SciPyMinVersion
│ ├── JoblibMinVersion / ThreadpoolctlMinVersion
│ └── MatplotlibMinVersion 等绘图/示例依赖
├── 项目简介与安装指南(55-95行)
│ ├── 项目定位与历史(2007 GSoC 起源)
│ ├── 依赖说明(Dependencies + replace 常量引用)
│ └── 用户安装(pip/conda 双通道)
├── 开发与贡献指南(97-150行)
│ ├── 源码获取(git clone)
│ ├── 测试指引(pytest + SKLEARN_SEED)
│ └── PR 提交流程
└── 社区与引用(152-198行)
├── 沟通渠道(Discord/Stack Overflow/Mailing list)
├── 社交媒体(LinkedIn/YouTube/Bluesky/Mastodon 等)
└── 引用方式(Citation)
sklearn/__init__.py
├── 模块文档与版本号(1-29行)
│ ├── __version__ = "1.9.dev0"(PEP 440 开发分支标记)
│ └── OpenMP 环境变量设置(47-55行)
│ ├── KMP_DUPLICATE_LIB_OK=True
│ └── KMP_INIT_AT_FORK=FALSE
├── 核心导入区(56-63行)
│ ├── __check_build / _distributor_init 预加载
│ ├── clone / show_versions 的早期暴露
│ └── _submodules 列表(37 个子模块注册)
├── __all__ 组装(99-107行)
│ ├── _submodules + 非模块符号(clone/get_config/set_config/config_context/show_versions)
│ └── 驱动 dir() 与 __dir__() 的行为
├── __dir__()(110-111行)
│ └── 返回 __all__ 列表,控制属性补全与 dir(sklearn) 输出
├── __getattr__(name)(114-121行)
│ ├── 子模块懒加载(importlib.import_module)
│ └── 非模块属性的 globals() 回退与 AttributeError 抛出
└── setup_module(module)(124-138行)
├── SKLEARN_SEED 环境变量优先读取
├── np.random.seed / random.seed 双重播种
└── 种子值的诊断打印
sklearn/_min_dependencies.py
├── 模块级版本常量(8-14行)
│ ├── NUMPY_MIN_VERSION / SCIPY_MIN_VERSION
│ ├── JOBLIB_MIN_VERSION / THREADPOOLCTL_MIN_VERSION
│ └── CYTHON_MIN_VERSION(与 pyproject.toml 同步)
├── dependent_packages 字典(18-56行)
│ ├── 核心运行时依赖(numpy/scipy/joblib/threadpoolctl)
│ ├── 构建依赖(cython/meson-python)
│ └── 可选依赖(matplotlib/pandas/sphinx/polars 等)
├── tag_to_packages 反向映射(59-64行)
│ └── 从标签(build/install/tests/docs)查询依赖
└── __main__ 命令行接口(67-74行)
└── python -m sklearn._min_dependencies <package>
sklearn/__check_build/__init__.py
├── 错误消息模板(1-15行)
│ ├── INPLACE_MSG(源码就地构建场景)
│ └── STANDARD_MSG(标准安装场景)
├── raise_build_error(e)(15-42行)
│ ├── 目录内容列举与格式化
│ ├── local_dir 场景判断(源码树 vs 安装包)
│ └── ImportError 组装与开发文档链接
└── check_build 导入尝试(44-47行)
sklearn/__check_build/_check_build.pyx
└── check_build()(1-2行)
└── Cython 编译产物存在性验证
以上地图列出本章源码模块及其职责,后文将按数据流逐一解析。
1.15 架构与数据流图
上述图分别展示模块依赖、调用时序、数据流和架构分层。
第 2 章 —— 估算器基类架构 —— 理解“机器学习算法的统一骨架”
2.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 clone 函数如何在不复制拟合数据的前提下创建估计器的全新实例
-
掌握 BaseEstimator 中 get_params 与 set_params 的双下划线嵌套参数机制
-
了解 BaseEstimator 的 repr、HTML 表示与 pickle 序列化的版本追踪设计
-
熟悉 ClassifierMixin、RegressorMixin、ClusterMixin、OutlierMixin 等类型标签体系
-
掌握 TransformerMixin.fit_transform 的实现逻辑与元数据路由警告机制
-
理解 is_classifier 等类型判断函数与 _fit_context 装饰器的工作原理
-
熟悉 BiclusterMixin 双聚类索引提取与子矩阵获取的便捷方法
-
认识 BaseEstimator.sklearn_tags 默认标签的结构与全局代码的执行逻辑
2.2 生活类比
想象 sklearn 的估计器体系是一家高度标准化的连锁餐厅:BaseEstimator 就是餐厅的标准化操作手册,所有加盟店(算法)都必须遵守;get_params / set_params 像中央菜单系统,任何分店的配方调整都要通过统一渠道。双下划线参数则是菜单编号的层级结构(如「套餐A__饮料__去冰」),精准定位到子项。clone 相当于在另一座城市开设全新分店:配方完全相同,但厨房是全新的(没有已处理的食材)。Mixin 类型标签是餐厅门口的招牌(中餐厅/西餐厅/快餐厅),顾客一眼识别店铺类型。BiclusterMixin 像座位布局图:同时标注行列坐标,快速定位任意座位区域。_fit_context 装饰器是开业前的卫生检查,确保所有食材(参数)符合安全标准。sklearn_tags 则是餐厅的营业执照,标注经营类型与特色服务。正是因为这种统一、可复制的流程,scikit‑learn 能让数百种算法共享一致的使用方式。
2.3 源码地图
sklearn/base.py
├── __main__ # 模块入口,无自动执行代码
├── clone() # 克隆入口,优先调用 __sklearn_clone__
│ └── _clone_parametrized() # 默认克隆实现,递归处理容器与嵌套估计器
├── BaseEstimator
│ ├── __dir__() # 过滤条件方法,隐藏不可用属性
│ ├── _get_param_names() # 构造器签名自省,提取参名
│ ├── get_params() # 获取参数,支持双下划线嵌套展开
│ ├── _get_params_html() # HTML 参数展示,区分默认/非默认参数
│ ├── set_params() # 参数设置,解析双下划线语法
│ ├── __sklearn_clone__() # 克隆钩子,委托给 _clone_parametrized
│ ├── __repr__() # 紧凑字符串表示,超长自动省略
│ ├── __getstate__() # 序列化时附加 _sklearn_version
│ ├── __setstate__() # 反序列化时检测版本不一致
│ ├── __sklearn_tags__() # 默认标签,estimator_type 为 None
│ └── _validate_params() # 参数约束校验入口
├── ClassifierMixin
│ ├── __sklearn_tags__() # 设置 estimator_type='classifier'
│ └── score() # 默认 accuracy_score
├── RegressorMixin
│ ├── __sklearn_tags__() # 设置 estimator_type='regressor'
│ └── score() # 默认 r2_score
├── ClusterMixin
│ ├── __sklearn_tags__() # 设置 estimator_type='clusterer'
│ └── fit_predict() # fit 后返回 labels_
├── BiclusterMixin
│ ├── biclusters_ # rows_ 与 columns_ 属性组合
│ ├── get_indices() # 获取行/列索引
│ ├── get_shape() # 获取双聚类形状
│ └── get_submatrix() # 提取子矩阵
├── TransformerMixin(_SetOutputMixin)
│ ├── __sklearn_tags__() # 创建 TransformerTags
│ └── fit_transform() # fit + transform,含元数据路由警告
├── OneToOneFeatureMixin
│ └── get_feature_names_out() # 一对一特征名输出
├── ClassNamePrefixFeaturesOutMixin
│ └── get_feature_names_out() # 类名前缀生成特征名
├── DensityMixin
│ ├── __sklearn_tags__() # 设置 estimator_type='density_estimator'
│ └── score() # 默认 no-op
├── OutlierMixin
│ ├── __sklearn_tags__() # 设置 estimator_type='outlier_detector'
│ └── fit_predict() # fit + predict,含元数据路由警告
├── MetaEstimatorMixin # 标记元估计器,无额外实现
├── MultiOutputMixin
│ └── __sklearn_tags__() # 设置 multi_output=True
├── _UnstableArchMixin
│ └── __sklearn_tags__() # 标记 32 位或 PowerPC 上的非确定性
├── is_classifier() # 基于标签系统判断分类器
├── is_regressor() # 基于标签系统判断回归器
├── is_clusterer() # 基于标签系统判断聚类器
├── is_outlier_detector() # 基于标签系统判断离群点检测器
└── _fit_context() # fit 方法装饰器,参数验证与配置上下文
2.4 clone 函数 —— 估计器的“分身术”
核心概念
-
在交叉验证、网格搜索等场景需要反复创建同一配置的 未拟合 实例。
-
clone通过 参数重建 而非对象拷贝,确保新实例不携带classes_、coef_等拟合属性。 -
支持 递归克隆:列表、元组、集合、字典等容器内部的每个估计器都会被单独克隆。
2.4.1 流程图
代码块(sklearn/base.py - clone()(第48-72行))
def clone(estimator, *, safe=True):
"""Construct a new unfitted estimator with the same parameters."""
# 1. 检查对象是否实现了专属的 __sklearn_clone__ 钩子,且不是类本身
if hasattr(estimator, "__sklearn_clone__") and not inspect.isclass(estimator):
# 2. 若存在钩子,直接委托给对象自身的克隆实现
return estimator.__sklearn_clone__()
# 3. 否则进入通用克隆路径 _clone_parametrized
return _clone_parametrized(estimator, safe=safe)
这段代码首先检查对象是否提供专属的克隆钩子
__sklearn_clone__。如果有,就交给子类自行实现(如 Pipeline 可能需要特殊处理步骤列表);否则进入通用路径_clone_parametrized。
代码块(sklearn/base.py - _clone_parametrized()(第75-141行))
def _clone_parametrized(estimator, *, safe=True):
"""Default implementation of clone. See :func:`sklearn.base.clone` for details."""
# 1. 获取对象类型,用于后续分发
estimator_type = type(estimator)
# 2. 处理字典容器:递归克隆每个值,保持键不变
if estimator_type is dict:
return {k: clone(v, safe=safe) for k, v in estimator.items()}
# 3. 处理列表/元组/集合/冻结集合:递归克隆每个元素,保持原容器类型
elif estimator_type in (list, tuple, set, frozenset):
return estimator_type([clone(e, safe=safe) for e in estimator])
# 4. 非估计器对象(无 get_params 或是类对象)
elif not hasattr(estimator, "get_params") or isinstance(estimator, type):
if not safe:
# safe=False 时允许回退到 deepcopy
return copy.deepcopy(estimator)
else:
# safe=True 时抛出 TypeError,提示不是合法估计器
if isinstance(estimator, type):
raise TypeError(
"Cannot clone object. "
"You should provide an instance of "
"scikit-learn estimator instead of a class."
)
else:
raise TypeError(
"Cannot clone object '%s' (type %s): "
"it does not seem to be a scikit-learn "
"estimator as it does not implement a "
"'get_params' method." % (repr(estimator), type(estimator))
)
# 5. 真实估计器路径:获取其类
klass = estimator.__class__
# 6. 获取浅层参数(deep=False 只取直接属性,不展开子估计器)
new_object_params = estimator.get_params(deep=False)
# 7. 递归克隆每个参数值(safe=False 允许参数内部的非估计器对象 deepcopy)
for name, param in new_object_params.items():
new_object_params[name] = clone(param, safe=False)
# 8. 通过构造函数重建全新实例(此时无任何拟合属性)
new_object = klass(**new_object_params)
# 9. 尝试迁移 _metadata_request(元数据路由相关),失败则忽略
try:
new_object._metadata_request = copy.deepcopy(estimator._metadata_request)
except AttributeError:
pass
# 10. 再次获取新实例的浅层参数,用于一致性校验
params_set = new_object.get_params(deep=False)
# 11. 参数一致性校验:确保 __init__ 未对参数做隐藏修改或丢失
for name in new_object_params:
param1 = new_object_params[name]
param2 = params_set[name]
if param1 is not param2:
raise RuntimeError(
"Cannot clone object %s, as the constructor "
"either does not set or modifies parameter %s" % (estimator, name)
)
# 12. 迁移 _sklearn_output_config(set_output 配置),若存在
if hasattr(estimator, "_sklearn_output_config"):
new_object._sklearn_output_config = copy.deepcopy(
estimator._sklearn_output_config
)
return new_object
这段实现完成了 “参数重建 + 递归克隆” 的完整流程:先处理容器与非估计器回退,再对真实估计器提取参数、递归克隆子参数、调用构造函数、迁移可选元数据属性,最后通过
params_set检查确保构造函数没有意外改变参数值(如将列表参数转为元组)。
代码块(sklearn/base.py - BaseEstimator.__sklearn_clone__()(第383-385行))
def __sklearn_clone__(self):
# BaseEstimator 的默认克隆钩子实现,直接委托给通用函数
return _clone_parametrized(self)
BaseEstimator提供了默认的__sklearn_clone__实现,使得所有继承它的估计器无需额外代码即可支持clone()。
小结
clone 通过重建构造函数参数创建全新实例,避免了任何已拟合的状态泄漏,并且对容器、非估计器对象提供安全或强制的回退策略。
2.5 BaseEstimator 参数管理 —— 估计器的“中央登记处”
核心概念
-
_get_param_names用 inspect.signature 自动提取__init__的显式参数,禁止*args,保证所有参数都能被记录。 -
get_params(deep=True)支持 双下划线展开:子估计器的参数会以sub_estimator__param形式展平,供网格搜索等元估计器使用。 -
set_params通过key.partition("__")把双下划线切分成前缀(子对象)和子键,实现 递归分配。 -
__dir__过滤条件方法,仅返回当前实例实际拥有的属性。 -
_get_params_html基于默认值比较生成 HTML 参数表格,非默认参数排在前方。
2.5.1 流程图
代码块(BaseEstimator.__dir__()(第183-195行))
def __dir__(self):
# Filters conditional methods that should be hidden based
# on the `available_if` decorator
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=FutureWarning)
# 仅返回当前实例实际拥有的属性/方法,过滤掉条件不可用的
return [attr for attr in super().__dir__() if hasattr(self, attr)]
过滤掉条件方法,确保
dir()只返回当前实例可访问的属性和方法,避免 IDE 补全出不可用的 API。
代码块(BaseEstimator._get_param_names()(第197-225行))
@classmethod
def _get_param_names(cls):
"""Get parameter names for the estimator"""
# 1. 获取构造器对象
init = cls.__init__
# 2. 若未显式定义 __init__(即继承自 object),返回空列表
if init is object.__init__:
return []
# 3. 使用 inspect.signature 解析构造器签名
init_signature = inspect.signature(init)
# 4. 过滤掉 'self' 和 **kwargs (VAR_KEYWORD),保留其余参数
parameters = [
p
for p in init_signature.parameters.values()
if p.name != "self" and p.kind != p.VAR_KEYWORD
]
# 5. 禁止 *args (VAR_POSITIONAL),必须显式声明所有参数
for p in parameters:
if p.kind == p.VAR_POSITIONAL:
raise RuntimeError(
"scikit-learn estimators should always "
"specify their parameters in the signature"
" of their __init__ (no varargs)."
" %s with constructor %s doesn't "
" follow this convention." % (cls, init_signature)
)
# 6. 返回排序后的参数名列表
return sorted([p.name for p in parameters])
该函数确保构造器参数是明确声明的,任何使用
*args的类会在导入时直接抛出错误,防止隐藏参数无法被get_params/set_params管理。
代码块(BaseEstimator.get_params()(第227-248行))
def get_params(self, deep=True):
"""
Get parameters for this estimator.
"""
out = dict()
# 1. 遍历所有声明的参数名
for key in self._get_param_names():
# 2. 获取属性值
value = getattr(self, key)
# 3. 若 deep=True 且值是估计器(且非类),递归展开其参数
if deep and hasattr(value, "get_params") and not isinstance(value, type):
deep_items = value.get_params().items()
# 4. 将子参数键名加上前缀 "key__" 并合并到 out
out.update((key + "__" + k, val) for k, val in deep_items)
# 5. 保存当前层级的参数
out[key] = value
return out
当
deep=True时,子估计器的get_params被递归调用,返回的键会自动拼接双下划线,实现 层级展开,使得GridSearchCV等工具能以扁平字典形式访问嵌套参数。
代码块(BaseEstimator.set_params()(第307-342行))
def set_params(self, **params):
"""Set the parameters of this estimator."""
# 1. 快速路径:无参数直接返回,避免 inspect 开销
if not params:
return self
# 2. 获取所有合法参数(含嵌套展开),用于合法性校验
valid_params = self.get_params(deep=True)
nested_params = defaultdict(dict) # 按前缀分组嵌套参数
# 3. 遍历用户传入的参数
for key, value in params.items():
# 4. 以第一个 "__" 为界切分前缀与子键
key, delim, sub_key = key.partition("__")
# 5. 校验前缀是否为合法参数
if key not in valid_params:
local_valid_params = self._get_param_names()
raise ValueError(
f"Invalid parameter {key!r} for estimator {self}. "
f"Valid parameters are: {local_valid_params!r}."
)
if delim:
# 6. 有 "__" 说明是嵌套参数,放入 nested_params 稍后递归处理
nested_params[key][sub_key] = value
else:
# 7. 无 "__" 直接设置属性,并更新 valid_params 缓存
setattr(self, key, value)
valid_params[key] = value
# 8. 遍历每个子估计器前缀,递归调用其 set_params
for key, sub_params in nested_params.items():
valid_params[key].set_params(**sub_params)
return self
关键步骤:
-
快速路径:若
params为空直接返回self,避免不必要的inspect开销。 -
合法性检查:通过
valid_params确认每个键在当前估计器中存在。 -
分组:使用
partition("__")将key切分为前缀和子键,构建nested_params。 -
递归转发:对每个前缀(子估计器)调用其
set_params,完成嵌套参数更新。
代码块(BaseEstimator._get_params_html()(第250-305行))
def _get_params_html(self, deep=True, doc_link=""):
"""
Get parameters for this estimator with a specific HTML representation.
"""
# 1. 获取完整参数字典
out = self.get_params(deep=deep)
# 2. 获取构造器参数的默认值字典
init_default_params = inspect.signature(self.__init__).parameters
init_default_params = {
name: param.default for name, param in init_default_params.items()
}
# 3. 定义判断“非默认参数”的内部函数
def is_non_default(param_name, param_value):
"""Finds the parameters that have been set by the user."""
# 不在构造器签名中(如 **kwargs 捕获)视为非默认
if param_name not in init_default_params:
return True
# 构造器参数无默认值(必填)视为非默认
if init_default_params[param_name] == inspect._empty:
return True
# 嵌套估计器类型改变视为非默认
if isinstance(param_value, BaseEstimator) and type(param_value) is not type(
init_default_params[param_name]
):
return True
# pandas NA 与非 NA 对比视为非默认
if is_pandas_na(param_value) and not is_pandas_na(
init_default_params[param_name]
):
return True
# 数值不相等且非双方均为 NaN 视为非默认
if not np.array_equal(
param_value, init_default_params[param_name]
) and not (
is_scalar_nan(init_default_params[param_name])
and is_scalar_nan(param_value)
):
return True
return False
# 4. 将参数按构造器签名顺序排列,后接额外参数
unordered_params = {
name: out[name] for name in init_default_params if name in out
}
unordered_params.update(
{
name: value
for name, value in out.items()
if name not in init_default_params
}
)
# 5. 分离非默认与默认参数列表
non_default_params, default_params = [], []
for name, value in unordered_params.items():
if is_non_default(name, value):
non_default_params.append(name)
else:
default_params.append(name)
# 6. 组合最终顺序:非默认在前,默认在后
params = {name: out[name] for name in non_default_params + default_params}
# 7. 返回可渲染 HTML 表格的 ParamsDict 对象
return ParamsDict(
params=params,
non_default=tuple(non_default_params),
estimator_class=self.__class__,
doc_link=doc_link,
)
通过参数默认值比较,将非默认参数排在前方,生成可读的 HTML 参数表格,方便 Jupyter 等环境直观展示用户实际修改了哪些超参数。
小结
这套机制让 统一的 API 能够在深层管道(如 Pipeline、GridSearchCV)中无缝工作,用户只需使用 estimator__param 语法即可精准控制子组件。
2.6 BaseEstimator 表示与序列化 —— 估计器的“名片”与“存档”
核心概念
-
__repr__使用_EstimatorPrettyPrinter渲染,限定最大字符数(N_CHAR_MAX=700),超长时在两端保留字符并插入省略号。 -
HTML 表现通过
estimator_html_repr与ParamsDict,能够在 Jupyter 中呈现可折叠的交互式卡片。 -
__getstate__/__setstate__在 pickle 时自动附加_sklearn_version,反序列化时若版本不匹配会触发InconsistentVersionWarning。 -
_validate_params根据_parameter_constraints校验构造参数类型与取值。
2.6.1 流程图
代码块(BaseEstimator.__repr__()(第352-394行))
def __repr__(self, N_CHAR_MAX=700):
# N_CHAR_MAX 是非空白字符的近似最大渲染数,作为可选参数便于测试
from sklearn.utils._pprint import _EstimatorPrettyPrinter
N_MAX_ELEMENTS_TO_SHOW = 30 # 序列类参数最多显示的元素数
# 1. 创建美化打印器:紧凑模式、缩进 1、在名称处缩进
pp = _EstimatorPrettyPrinter(
compact=True,
indent=1,
indent_at_name=True,
n_max_elements_to_show=N_MAX_ELEMENTS_TO_SHOW,
)
# 2. 生成完整字符串表示
repr_ = pp.pformat(self)
# 3. 统计非空白字符数
n_nonblank = len("".join(repr_.split()))
if n_nonblank > N_CHAR_MAX:
# 4. 需要截断:保留两端各约一半字符
lim = N_CHAR_MAX // 2 # 每端保留的字符数
# 正则匹配从开头到第 lim 个非空白字符的位置
regex = r"^(\s*\S){%d}" % lim
left_lim = re.match(regex, repr_).end()
# 对反转字符串同理,得到右端保留长度
right_lim = re.match(regex, repr_[::-1]).end()
# 5. 若中间跨行,调整右端使其从行首开始,避免破坏行结构
if "\n" in repr_[left_lim:-right_lim]:
regex += r"[^\n]*\n"
right_lim = re.match(regex, repr_[::-1]).end()
ellipsis = "..."
# 6. 仅当省略后确实变短时才插入省略号
if left_lim + len(ellipsis) < len(repr_) - right_lim:
repr_ = repr_[:left_lim] + "..." + repr_[-right_lim:]
return repr_
通过正则快速定位左右的截断点,并在必要时考虑换行,确保省略号不会破坏语义完整性。
代码块(BaseEstimator.__getstate__() 与 __setstate__()(第396-428行))
def __getstate__(self):
# 1. 禁止使用 __slots__,因 BaseEstimator 依赖 __dict__ 序列化
if getattr(self, "__slots__", None):
raise TypeError(
"You cannot use `__slots__` in objects inheriting from "
"`sklearn.base.BaseEstimator`."
)
# 2. 尝试调用父类 __getstate__(如 ReprHTMLMixin)
try:
state = super().__getstate__()
if state is None:
# Python 3.11+ 空实例可能返回 None
state = self.__dict__.copy()
except AttributeError:
# Python < 3.11 无 __getstate__ 时直接复制 __dict__
state = self.__dict__.copy()
# 3. 若属于 sklearn 内部模块,注入版本号
if type(self).__module__.startswith("sklearn."):
return dict(state.items(), _sklearn_version=__version__)
else:
return state
def __setstate__(self, state):
# 1. sklearn 内部模块:检测版本不一致
if type(self).__module__.startswith("sklearn."):
pickle_version = state.pop("_sklearn_version", "pre-0.18")
if pickle_version != __version__:
warnings.warn(
InconsistentVersionWarning(
estimator_name=self.__class__.__name__,
current_sklearn_version=__version__,
original_sklearn_version=pickle_version,
),
)
# 2. 尝试父类 __setstate__,失败则直接更新 __dict__
try:
super().__setstate__(state)
except AttributeError:
self.__dict__.update(state)
这两段代码确保 模型持久化 时能够记录所使用的 scikit‑learn 版本;若加载时版本不匹配,给出显式警告帮助调试。
代码块(BaseEstimator._validate_params()(第447-459行))
def _validate_params(self):
"""Validate types and values of constructor parameters
The expected type and values must be defined in the `_parameter_constraints`
class attribute, which is a dictionary `param_name: list of constraints`. See
the docstring of `validate_parameter_constraints` for a description of the
accepted constraints.
"""
# 调用通用校验函数,传入类约束字典、当前参数值、调用者名称
validate_parameter_constraints(
self._parameter_constraints,
self.get_params(deep=False),
caller_name=self.__class__.__name__,
)
_validate_params在fit前(由_fit_context装饰器调用)执行,依据类属性_parameter_constraints对构造参数进行类型与取值校验,保证参数合法性。
小结
统一的 repr、HTML 与序列化实现,使得估计器在交互式环境、日志记录以及模型部署全过程中都有一致、可读、可追溯的表现。
2.7 Mixin 类型标签体系 —— 给算法贴上“身份标签”
核心概念
-
每个功能 Mixin(
ClassifierMixin、RegressorMixin、ClusterMixin、OutlierMixin等)在__sklearn_tags__中填充estimator_type与专属的 Tags 对象。 -
通过 标签系统(
Tags、ClassifierTags等)实现 类型判断,而非isinstance,从而兼容不直接继承BaseEstimator的第三方实现。
2.7.1 流程图
代码块(BaseEstimator.__sklearn_tags__()(第430-445行))
def __sklearn_tags__(self):
return Tags(
estimator_type=None,
target_tags=TargetTags(required=False),
transformer_tags=None,
regressor_tags=None,
classifier_tags=None,
)
BaseEstimator提供的默认标签:estimator_type为None,目标不强制要求,无特定功能标签。
代码块(ClassifierMixin.__sklearn_tags__()(第462-474行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.estimator_type = "classifier"
tags.classifier_tags = ClassifierTags()
tags.target_tags.required = True
return tags
设置类型为分类器,附带分类器专属标签,并声明
fit必须接收y。
代码块(ClassifierMixin.score()(第476-501行))
def score(self, X, y, sample_weight=None):
"""
Return :ref:`accuracy <accuracy_score>` on provided data and labels.
...
"""
from sklearn.metrics import accuracy_score
return accuracy_score(y, self.predict(X), sample_weight=sample_weight)
分类器默认
score使用accuracy_score,计算self.predict(X)与真实标签y的准确率。
代码块(RegressorMixin.__sklearn_tags__()(第538-550行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.estimator_type = "regressor"
tags.regressor_tags = RegressorTags()
tags.target_tags.required = True
return tags
设置类型为回归器,附带回归器专属标签,并声明
fit必须接收y。
代码块(RegressorMixin.score()(第565-602行))
def score(self, X, y, sample_weight=None):
"""Return :ref:`coefficient of determination <r2_score>` on test data.
...
"""
from sklearn.metrics import r2_score
y_pred = self.predict(X)
return r2_score(y, y_pred, sample_weight=sample_weight)
回归器默认
score使用r2_score,计算决定系数 R²。
代码块(ClusterMixin.__sklearn_tags__()(第623-629行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.estimator_type = "clusterer"
if tags.transformer_tags is not None:
tags.transformer_tags.preserves_dtype = []
return tags
设置类型为聚类器;若同时具备转换器标签,清空
preserves_dtype。
代码块(ClusterMixin.fit_predict()(第632-654行))
def fit_predict(self, X, y=None, **kwargs):
"""
Perform clustering on `X` and returns cluster labels.
...
"""
# non-optimized default implementation; override when a better
# method is possible for a given clustering algorithm
self.fit(X, **kwargs)
return self.labels_
默认实现:先
fit再返回labels_,子类可覆盖以提供更高效实现。
代码块(BiclusterMixin.biclusters_ 与 get_indices()(第669-694行))
@property
def biclusters_(self):
"""Convenient way to get row and column indicators together.
Returns the ``rows_`` and ``columns_`` members.
"""
return self.rows_, self.columns_
def get_indices(self, i):
"""Row and column indices of the `i`'th bicluster.
...
"""
rows = self.rows_[i]
columns = self.columns_[i]
return np.nonzero(rows)[0], np.nonzero(columns)[0]
biclusters_直接返回行/列指示矩阵元组;get_indices通过np.nonzero提取布尔矩阵中为 True 的行列索引。
代码块(BiclusterMixin.get_shape() 与 get_submatrix()(第696-730行))
def get_shape(self, i):
"""Shape of the `i`'th bicluster.
...
"""
indices = self.get_indices(i)
return tuple(len(i) for i in indices)
def get_submatrix(self, i, data):
"""Return the submatrix corresponding to bicluster `i`.
...
"""
data = check_array(data, accept_sparse="csr")
row_ind, col_ind = self.get_indices(i)
return data[row_ind[:, np.newaxis], col_ind]
get_shape返回行数与列数元组;get_submatrix先校验数据(支持 CSR 稀疏),再利用row_ind[:, np.newaxis]将行索引扩展为列向量,配合列索引实现二维切片,等价于np.ix_但更高效。
代码块(TransformerMixin.__sklearn_tags__()(第742-745行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.transformer_tags = TransformerTags()
return tags
创建并赋值
TransformerTags,标记为转换器。
代码块(TransformerMixin.fit_transform()(第718-765行))
def fit_transform(self, X, y=None, **fit_params):
"""
Fit to data, then transform it.
...
"""
# 1. 若启用元数据路由,检查 transform 是否消费元数据
if _routing_enabled():
transform_params = self.get_metadata_routing().consumes(
method="transform", params=fit_params.keys()
)
# 2. 若 transform 需要元数据但 fit_transform 未转发,发出警告
if transform_params:
warnings.warn(
(
f"This object ({self.__class__.__name__}) has a `transform`"
" method which consumes metadata, but `fit_transform` does not"
" forward metadata to `transform`. Please implement a custom"
" `fit_transform` method to forward metadata to `transform` as"
" well. Alternatively, you can explicitly do"
" `set_transform_request`and set all values to `False` to"
" disable metadata routed to `transform`, if that's an option."
),
UserWarning,
)
# 3. 根据 y 是否为 None 选择单参数或双参数 fit
if y is None:
# fit method of arity 1 (unsupervised transformation)
return self.fit(X, **fit_params).transform(X)
else:
# fit method of arity 2 (supervised transformation)
return self.fit(X, y, **fit_params).transform(X)
关键点:
-
元数据检查:只有当全局路由开启且
transform声明消费元数据时才发出警告。 -
两类调用:
y is None表示无监督转换,直接调用单参数fit;否则调用双参数fit。
代码块(OneToOneFeatureMixin.get_feature_names_out()(第790-819行))
def get_feature_names_out(self, input_features=None):
"""Get output feature names for transformation.
...
"""
# Note that passing attributes="n_features_in_" forces check_is_fitted
# to check if the attribute is present. Otherwise it will pass on
# stateless estimators (requires_fit=False)
check_is_fitted(self, attributes="n_features_in_")
return _check_feature_names_in(self, input_features)
强制检查
n_features_in_存在,再调用工具函数校验并返回输入特征名(一对一对应)。
代码块(ClassNamePrefixFeaturesOutMixin.get_feature_names_out()(第850-876行))
def get_feature_names_out(self, input_features=None):
"""Get output feature names for transformation.
...
"""
check_is_fitted(self, "_n_features_out")
return _generate_get_feature_names_out(
self, self._n_features_out, input_features=input_features
)
检查
_n_features_out存在,再调用工具函数生成带类名前缀(小写)的特征名,如pca0, pca1...。
代码块(DensityMixin.__sklearn_tags__()(第801-804行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.estimator_type = "density_estimator"
return tags
设置类型为密度估计器。
代码块(DensityMixin.score()(第806-821行))
def score(self, X, y=None):
"""Return the score of the model on the data `X`.
...
"""
pass
密度估计器默认
score为空实现(no-op),子类需自行实现(如对数似然)。
代码块(OutlierMixin.__sklearn_tags__()(第824-827行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.estimator_type = "outlier_detector"
return tags
设置类型为离群点检测器。
代码块(OutlierMixin.fit_predict()(第833-879行))
def fit_predict(self, X, y=None, **kwargs):
"""Perform fit on X and returns labels for X.
...
"""
# 1. 元数据路由检查:predict 若消费元数据且 fit_predict 未转发,警告
if _routing_enabled():
transform_params = self.get_metadata_routing().consumes(
method="predict", params=kwargs.keys()
)
if transform_params:
warnings.warn(
(
f"This object ({self.__class__.__name__}) has a `predict` "
"method which consumes metadata, but `fit_predict` does not "
"forward metadata to `predict`. Please implement a custom "
"`fit_predict` method to forward metadata to `predict` as well."
"Alternatively, you can explicitly do `set_predict_request`"
"and set all values to `False` to disable metadata routed to "
"`predict`, if that's an option."
),
UserWarning,
)
# 2. 默认实现:fit 后直接 predict(返回 1 为正常,-1 为异常)
return self.fit(X, **kwargs).predict(X)
与
TransformerMixin类似,提供元数据路由警告;默认实现为fit后predict,适用于归纳式异常检测器。
代码块(MultiOutputMixin.__sklearn_tags__()(第902-906行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.target_tags.multi_output = True
return tags
标记目标支持多输出(多标签/多任务)。
代码块(_UnstableArchMixin.__sklearn_tags__()(第909-917行))
def __sklearn_tags__(self):
tags = super().__sklearn_tags__()
tags.non_deterministic = _IS_32BIT or platform.machine().startswith(
("ppc", "powerpc")
)
return tags
在 32 位或 PowerPC 架构上标记为非确定性,提示结果可能不可复现。
小结
标签体系让 库内部 与 外部生态 都能够通过统一的查询 (is_classifier(estimator)) 判定功能,而不依赖具体的类继承关系,极大提升可扩展性。
2.8 类型判断函数与 _fit_context 装饰器 —— 估计器的“安检员”与“门禁”
核心概念
-
is_classifier、is_regressor、is_clusterer、is_outlier_detector通过get_tags(estimator).estimator_type判断类型,不依赖isinstance,因而能够识别 遵循 API 的外部实现。 -
_fit_context装饰器在每次fit前执行 参数验证(_validate_params),并在上下文中控制skip_parameter_validation配置,以避免对嵌套估计器的重复验证。
2.8.1 流程图
代码块(is_classifier()(第910-940行))
def is_classifier(estimator):
"""Return True if the given estimator is (probably) a classifier."""
return get_tags(estimator).estimator_type == "classifier"
代码块(is_regressor()(第943-973行))
def is_regressor(estimator):
"""Return True if the given estimator is (probably) a regressor."""
return get_tags(estimator).estimator_type == "regressor"
代码块(is_clusterer()(第976-1007行))
def is_clusterer(estimator):
"""Return True if the given estimator is (probably) a clusterer."""
return get_tags(estimator).estimator_type == "clusterer"
代码块(is_outlier_detector()(第1010-1023行))
def is_outlier_detector(estimator):
"""Return True if the given estimator is (probably) an outlier detector."""
return get_tags(estimator).estimator_type == "outlier_detector"
四个函数均通过
get_tags(estimator).estimator_type字符串比对,实现无侵入式类型识别。
代码块(_fit_context()(第1026-1067行))
def _fit_context(*, prefer_skip_nested_validation):
"""Decorator to run the fit methods of estimators within context managers."""
def decorator(fit_method):
@functools.wraps(fit_method)
def wrapper(estimator, *args, **kwargs):
# 1. 读取全局配置:是否跳过参数验证
global_skip_validation = get_config()["skip_parameter_validation"]
# 2. 判断是否为已拟合的 partial_fit(避免重复验证)
partial_fit_and_fitted = (
fit_method.__name__ == "partial_fit" and _is_fitted(estimator)
)
# 3. 若全局未关闭验证且非已拟合 partial_fit,执行参数校验
if not global_skip_validation and not partial_fit_and_fitted:
estimator._validate_params()
# 4. 进入配置上下文:根据 prefer_skip_nested_validation 决定是否在嵌套调用中关闭验证
with config_context(
skip_parameter_validation=(
prefer_skip_nested_validation or global_skip_validation
)
):
return fit_method(estimator, *args, **kwargs)
return wrapper
return decorator
关键流程:
-
读取全局配置
skip_parameter_validation。 -
对
partial_fit已经拟合的情况跳过二次检查。 -
根据
prefer_skip_nested_validation决定是否在内部 上下文 中关闭嵌套参数验证,从而提升元估计器(如Pipeline)的运行效率。
小结
类型判断函数通过统一标签系统实现精准识别,而 _fit_context 装饰器通过参数验证与上下文控制,确保估计器在训练过程中的安全与高效。
2.9 模块入口与全局代码执行逻辑
核心概念
-
sklearn/base.py顶部仅包含导入语句,无任何副作用代码(如打印、自动运行测试)。 -
模块末尾无
if __name__ == "__main__":块,导入时零开销,符合库设计最佳实践。
2.9.1 代码块(模块头部导入部分,第1-47行)
"""Base classes for all estimators and various utility functions."""
# 第 2 章 —— Authors: The scikit-learn developers
# 第 2 章 —— SPDX-License-Identifier: BSD-3-Clause
import copy
import functools
import inspect
import platform
import re
import warnings
from collections import defaultdict
import numpy as np
from sklearn import __version__
from sklearn._config import config_context, get_config
from sklearn.exceptions import InconsistentVersionWarning
from sklearn.utils._metadata_requests import _MetadataRequester, _routing_enabled
from sklearn.utils._missing import is_pandas_na, is_scalar_nan
from sklearn.utils._param_validation import validate_parameter_constraints
from sklearn.utils._repr_html.base import ReprHTMLMixin, _HTMLDocumentationLinkMixin
from sklearn.utils._repr_html.estimator import estimator_html_repr
from sklearn.utils._repr_html.params import ParamsDict
from sklearn.utils._set_output import _SetOutputMixin
from sklearn.utils._tags import (
ClassifierTags,
RegressorTags,
Tags,
TargetTags,
TransformerTags,
get_tags,
)
from sklearn.utils.fixes import _IS_32BIT
from sklearn.utils.validation import (
_check_feature_names_in,
_generate_get_feature_names_out,
_is_fitted,
check_array,
check_is_fitted,
)
模块仅进行标准库与内部工具的导入,定义类与函数,无全局执行代码,保证导入速度与无副作用。
小结
轻量化的模块入口设计,使得 sklearn.base 能够被快速、安全地导入,无任何运行时副作用,是大型库模块化设计的典范。
2.10 设计中的取舍
Q1:为什么不用 deepcopy 直接克隆?
A1:deepcopy 会复制所有属性,包括已经拟合的模型权重(classes_、coef_),导致克隆后仍然携带旧模型状态,破坏交叉验证和网格搜索的独立性。采用 参数重建 的方式可以确保新实例干净,且只复制必要的元数据(如元数据请求、输出配置),降低内存开销。
Q2:参数管理的双下划线机制有何取舍?
A2:通过 get_params/set_params 的递归展开与收敛,实现统一参数 API,使得网格搜索、管道等元估计器能够透明地访问嵌套估计器的参数。虽然增加了参数名的解析开销,但带来了极高的使用一致性和灵活性,是 scikit‑learn 能够支持复杂建模流程的基础。
Q3:标签体系为何不用 isinstance 而用标签系统?
A3:使用 __sklearn_tags__ 和 get_tags 而不是 isinstance 进行类型判断,使得第三方库只要遵循相同的标签协议,就能被 scikit-learn 的工具函数(如 is_classifier)正确识别。略微增加了标签对象的维护成本,但极大地提升了框架的可插拔性和生态兼容性。
Q4:TransformerMixin.fit_transform 的默认实现与警告机制如何权衡?
A4:提供通用的 fit_transform 实现,覆盖了大多数无元数据需求的转换器;在元数据路由场景下警告用户需要自定义以确保元数据正确转发。默认实现降低了使用门槛,警告机制则保护了有特殊需求的用户不会因框架限制而遇到静默错误。
Q5:_fit_context 如何平衡参数验证的安全性与性能?
A5:通过全局配置与局部偏好(prefer_skip_nested_validation)结合,避免在元估计器中对子估计器重复验证参数。在保证参数安全的前提下,提升了管道等复合估计器的运行效率,是性能与安全的平衡。
2.11 动手练习
-
阅读 clone 函数的递归克隆逻辑
-
阅读
sklearn/base.py第 48‑110 行,理解clone()与_clone_parametrized()的实现:-
当传入参数为
dict时,clone 如何处理? -
当传入参数为
list或tuple时,返回类型是什么? -
safe=False时对非估计器对象采用什么回退策略?
-
-
回答问题:
-
为什么克隆后的估计器没有
classes_等拟合属性? -
params_set一致性校验的目的是什么?
-
-
-
追踪 set_params 的双下划线解析
-
阅读
sklearn/base.py第 307‑342 行,理解set_params的参数解析过程:-
key.partition("__")如何拆分前缀与子键? -
nested_params如何按前缀分组? -
无效参数名时会抛出什么异常?错误信息包含什么内容?
-
-
回答问题:
-
为什么无参数时直接返回
self而不做任何检查? -
嵌套参数是如何最终传递给子估计器的?
-
-
-
理解 sklearn_tags 的类型声明机制
-
阅读
sklearn/base.py第 430‑445 行(BaseEstimator.__sklearn_tags__)、第 462‑474 行(ClassifierMixin)和第 824‑827 行(OutlierMixin),理解标签系统:-
BaseEstimator.__sklearn_tags__返回的Tags对象包含哪些字段?默认值是什么? -
ClassifierMixin.__sklearn_tags__设置了哪些标签字段? -
is_classifier函数是如何利用标签系统判断的?
-
-
回答问题:
-
为什么不使用
isinstance而使用标签系统进行类型判断? -
标签系统对第三方库的兼容性有何意义?
-
-
-
解读 _fit_context 装饰器的参数验证流程
-
阅读
sklearn/base.py第 1026‑1067 行,理解_fit_context装饰器:-
global_skip_validation从哪个配置读取? -
什么条件下会跳过
partial_fit的第二次参数验证? -
prefer_skip_nested_validation参数的作用是什么?
-
-
回答问题:
-
为什么元估计器的
fit通常设置prefer_skip_nested_validation=True? -
config_context在装饰器中扮演什么角色?
-
-
-
探索 BiclusterMixin 的双聚类索引提取
-
阅读
sklearn/base.py第 669‑730 行,理解BiclusterMixin的便捷方法:-
biclusters_属性如何组合rows_与columns_? -
get_indices如何使用np.nonzero获取索引? -
get_submatrix中data[row_ind[:, np.newaxis], col_ind]的索引技巧是什么?
-
-
回答问题:
-
为什么
get_shape返回的是元组而不是正整数? -
get_submatrix如何支持稀疏矩阵输入?
-
-
-
观察模块加载与全局代码执行
-
阅读
sklearn/base.py第 1‑47 行(模块导入部分)和末尾的全局代码区域:-
模块顶部导入了哪些关键依赖?
-
模块中是否存在直接执行的全局代码(如打印、运行测试等)?
-
__main__单元在模块加载时执行了什么?
-
-
回答问题:
-
为什么
sklearn/base.py的__main__单元没有自动执行代码? -
这种设计对模块的导入性能有何好处?
-
-
2.12 本章小结
本章我们系统地走遍了 scikit‑learn 基类 与 Mixin 的内部实现。从 clone 的 参数重建、BaseEstimator 的 双下划线参数体系、统一的 repr/HTML/序列化,到各类 标签 Mixin 与 类型判断函数,再到 TransformerMixin 的默认 fit_transform 与 BiclusterMixin 的双聚类工具,最后揭示了 模块入口 的轻量化设计。首先通过 clone 展示了无副作用克隆的必要性,其次 BaseEstimator 为所有估计器提供了统一的参数管理和安全检查,接着 Mixin 通过标签系统为每类算法贴上身份标识,随后 TransformerMixin 与元数据路由警告保证了管道的可组合性,最后 BiclusterMixin 为双聚类任务提供了简洁的索引与子矩阵提取接口。
本章小结
下表对本章核心概念进行了统一概览:
| 概念 | 解释 |
|------|------|
| clone() | 基于参数重建的未拟合实例创建,递归处理容器 |
| BaseEstimator.get_params/set_params | 双下划线展开与递归分配,实现统一参数 API |
| BaseEstimator.__repr__ | 紧凑可读的字符串表示,超长自动省略 |
| BaseEstimator.__getstate__/__setstate__ | pickle 时记录 sklearn 版本,反序列化检测不匹配 |
| Mixin __sklearn_tags__ | 标签体系,声明 estimator_type 与功能标签 |
| is_classifier 等 | 基于标签系统的类型判断,兼容第三方实现 |
| _fit_context | 参数验证装饰器,结合全局 config 控制验证开关 |
| TransformerMixin.fit_transform | 默认实现 fit→transform,元数据路由警告 |
| BiclusterMixin | 双聚类索引、形状、子矩阵快捷方法 |
下一章中,我们将深入 全局配置体系,学习 sklearn/_config.py 如何通过线程局部存储实现动态、线程安全的运行时配置管理。
第 3 章 —— 全局配置体系 —— 打造“可调谐的运行时引擎”
3.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 scikit-learn 全局配置字典的初始化逻辑与环境变量读取机制
-
掌握线程局部存储(threading.local)在配置隔离中的实现原理
-
掌握 set_config 选择性更新配置的 None 语义设计模式
-
理解 config_context 上下文管理器的快照-恢复机制与嵌套语义
-
了解全局配置如何影响估计器行为(repr、transform 输出、元数据路由等)
3.2 生活类比
想象 scikit-learn 的全局配置系统是一家高级餐厅的后厨调度中心:
-
_global_config = 餐厅的"标准菜谱"(默认配方,开业时确定)
-
环境变量 = 开业前的"装修参数"(只在装修时读一次,之后不再变动)
-
threading.local() = 每位厨师的"私人工作台"(各自独立,互不干扰)
-
_get_threadlocal_config() = 厨师上岗时"复印一份标准菜谱"放在自己台面上
-
get_config() = 客人要求看菜谱时,餐厅只提供"复印件"(防止客人涂改原件)
-
set_config() = 厨师长调整配方:只说"盐多放一点",其他调料保持不变
-
config_context() = "临时试菜":实验期间改用新配方,试菜结束立刻恢复原菜谱
就像餐厅需要保证每位厨师的独立工作空间和配方的一致性,scikit-learn 通过线程局部存储和快照-恢复机制,确保配置在多线程环境下"既隔离又可追溯"。
3.3 源码地图
sklearn/_config.py
├── 模块级全局状态(第14-27行)
│ ├── _global_config # 全局默认配置字典(10项配置)
│ │ ├── assume_finite # 从 SKLEARN_ASSUME_FINITE 环境变量读取
│ │ ├── working_memory # 从 SKLEARN_WORKING_MEMORY 环境变量读取(默认1024MiB)
│ │ ├── print_changed_only # 默认True:只打印非默认参数
│ │ ├── display # 默认'diagram':Jupyter中显示为图表
│ │ ├── pairwise_dist_chunk_size # 从环境变量读取(默认256)
│ │ ├── enable_cython_pairwise_dist # 默认True:启用Cython加速
│ │ ├── array_api_dispatch # 默认False:Array API分派开关
│ │ ├── transform_output # 默认'default':变换器输出格式
│ │ ├── enable_metadata_routing # 默认False:元数据路由开关
│ │ └── skip_parameter_validation # 默认False:跳过参数校验开关
│ └── _threadlocal # threading.local() 线程局部存储对象
├── _get_threadlocal_config() # 第28-36行:懒初始化线程本地配置
│ ├── hasattr 检查 # 判断线程是否已有配置副本
│ └── _global_config.copy() # 首次访问时创建基线快照
├── get_config() # 第38-67行:获取只读配置快照
│ └── .copy() # 返回浅拷贝防止外部篡改
├── set_config() # 第69-157行:选择性写入配置
│ ├── 获取 local_config # _get_threadlocal_config()
│ ├── 10个独立 if 判断 # None语义:不传不变更
│ └── array_api_dispatch 特例 # 调用 _check_array_api_dispatch 校验
└── config_context() # 第159-295行:临时配置上下文管理器
├── @contextmanager # 装饰器将生成器转为上下文管理器
├── old_config = get_config() # 进入时快照当前配置
├── set_config(**kwargs) # 应用目标配置
├── try: yield # 执行 with 块内代码
└── finally: set_config(**old_config) # 退出时恢复
3.3.1 全局配置架构总览
下图展示了全局配置体系的核心组件及其交互关系:
3.4 配置字典与默认值初始化 —— 全局配置的"出厂设置清单"
3.4.1 _global_config 字典:配置的"中央仓库"
当 Python 解释器首次导入 sklearn._config 模块时,模块级代码会立即执行,构建出 _global_config 字典。这个字典是全局配置的"中央仓库",包含了 scikit-learn 运行时可调节的 10 项核心设置。让我们直接阅读源码,看看这份"出厂设置清单"长什么样:
源码路径:sklearn/_config.py - 模块级代码(第14-27行)
# 第 3 章 —— 第14行:定义全局默认配置字典,作为所有线程配置的基线
_global_config = {
# 第15行:assume_finite 从环境变量 SKLEARN_ASSUME_FINITE 读取,默认 False
# bool() 转换确保环境变量字符串变为布尔值
"assume_finite": bool(os.environ.get("SKLEARN_ASSUME_FINITE", False)),
# 第16行:working_memory 从环境变量 SKLEARN_WORKING_MEMORY 读取,默认 1024 MiB
# int() 转换确保环境变量字符串变为整数
"working_memory": int(os.environ.get("SKLEARN_WORKING_MEMORY", 1024)),
# 第17行:print_changed_only 硬编码默认 True,控制估计器 __repr__ 精简显示
"print_changed_only": True,
# 第18行:display 硬编码默认 'diagram',控制 Jupyter 中估计器显示格式
"display": "diagram",
# 第19-21行:pairwise_dist_chunk_size 从环境变量读取,默认 256
# 控制成对距离计算的分块粒度
"pairwise_dist_chunk_size": int(
os.environ.get("SKLEARN_PAIRWISE_DIST_CHUNK_SIZE", 256)
),
# 第22行:enable_cython_pairwise_dist 硬编码默认 True,启用 Cython 加速路径
"enable_cython_pairwise_dist": True,
# 第23行:array_api_dispatch 硬编码默认 False,Array API 分派开关
"array_api_dispatch": False,
# 第24行:transform_output 硬编码默认 'default',Transformer 输出格式
"transform_output": "default",
# 第25行:enable_metadata_routing 硬编码默认 False,元数据路由开关
"enable_metadata_routing": False,
# 第26行:skip_parameter_validation 硬编码默认 False,跳过参数校验开关
"skip_parameter_validation": False,
}
# 第 3 章 —— 第27行:创建线程局部存储对象,为每个线程提供独立的配置命名空间
_threadlocal = threading.local()
这段代码定义了全局配置的"出厂设置"。_global_config 是一个普通字典,但在模块加载时就已冻结其初始值——注意它直接读取环境变量(os.environ.get),并通过 bool()、int() 进行强制类型转换。这意味着环境变量只在导入时读取一次,后续运行时修改环境变量不会自动生效,除非重新导入模块。_threadlocal = threading.local() 则创建了线程局部存储对象,为后续的线程隔离埋下伏笔。
3.4.2 环境变量驱动的"启动参数"
在这 10 项配置中,有 3 项直接由环境变量驱动,适合在容器部署、HPC 集群等场景下通过环境变量完成进程级全局调优:
| 配置项 | 环境变量 | 默认值 | 作用 |
|--------|----------|--------|------|
| assume_finite | SKLEARN_ASSUME_FINITE | False | 跳过有限性检查的"快速通道"开关 |
| working_memory | SKLEARN_WORKING_MEMORY | 1024 (MiB) | 限制临时数组大小的"内存节流阀" |
| pairwise_dist_chunk_size | SKLEARN_PAIRWISE_DIST_CHUNK_SIZE | 256 | 控制成对距离分块计算的"粒度旋钮" |
这种设计体现了"约定优于配置"的思想:大多数用户无需关心这些参数,但部署在容器、HPC 集群或内存受限环境的用户,可以通过环境变量在进程启动前完成全局调优,无需修改业务代码。
3.4.3 不可变默认保障
关键设计点在于:环境变量只在模块首次导入时读取一次。源码中没有任何机制在运行时重新轮询 os.environ。这带来了两个重要保证:
-
配置可追溯性:运行时的配置变更只能通过
set_config()或config_context()显式进行,审计时只需检查代码中的调用链。 -
确定性行为:同一进程内,配置不会因外部环境变量的悄悄变更而"幽灵般"改变。
3.4.4 配置初始化流程图
3.5 线程局部存储与配置获取 —— 每个线程的"私有配置保险柜"
3.5.1 为什么需要线程局部存储(Thread-Local Storage)?
scikit-learn 广泛用于 Web 服务(如 Flask、FastAPI)、数据管道(如 Airflow)、并行计算框架中。这些场景下,同一进程内可能并发运行多个训练/推理任务,每个任务可能需要不同的配置(如线程 A 想要 assume_finite=True 加速,线程 B 需要 assume_finite=False 保证安全)。如果所有线程共享同一个 _global_config 字典,set_config() 的写入就会造成"配置串扰"——线程 A 的修改会意外泄漏到线程 B。
Python 的 threading.local() 正是为解决此问题而生:它为每个线程提供独立的命名空间。访问同一个 _threadlocal 对象的属性时,不同线程看到的是完全不同的存储区域。
3.5.2 线程隔离架构图
3.5.3 _get_threadlocal_config():懒初始化的"保险柜"
源码路径:sklearn/_config.py - _get_threadlocal_config()(第28-36行)
# 第 3 章 —— 第28行:函数定义,获取线程本地的可变配置字典
def _get_threadlocal_config():
"""Get a threadlocal **mutable** configuration. If the configuration
does not exist, copy the default global configuration."""
# 第30行:检查当前线程是否已有 global_config 属性
# hasattr 避免直接访问不存在属性抛出 AttributeError
if not hasattr(_threadlocal, "global_config"):
# 第32行:首次访问时,从全局配置创建浅拷贝作为基线快照
# .copy() 关键:确保线程修改副本不污染 _global_config 原对象
_threadlocal.global_config = _global_config.copy()
# 第34行:返回当前线程的私有可变配置字典引用
return _threadlocal.global_config
这段代码实现了懒初始化模式:
-
hasattr 检查:判断当前线程是否已有
global_config属性。如果是首次访问,返回False。 -
创建基线快照:
_global_config.copy()创建浅拷贝,将"出厂设置"复制到当前线程的私有空间。注意是.copy()而非直接赋值——这确保线程对副本的修改不会回溯污染_global_config原对象。 -
返回可变引用:后续调用直接返回该线程的私有字典,这是可变的,允许
set_config()直接在其上写入。
关键洞察:_get_threadlocal_config() 返回的是线程私有的可变字典,而非只读视图。这为 set_config() 的原地修改提供了基础。
3.5.4 get_config():只读快照的"防篡改包装"
源码路径:sklearn/_config.py - get_config()(第38-67行)
# 第 3 章 —— 第38行:函数定义,获取当前 scikit-learn 配置
def get_config():
"""Retrieve the current scikit-learn configuration..."""
# 第48行:返回线程本地配置的浅拷贝
# 关键防御性编程:用户拿到的是副本,误写 config['key'] = val 不会影响真实配置
return _get_threadlocal_config().copy()
这段代码实现了防御式编程:用户调用 get_config() 拿到的是浅拷贝。即使用户误写 get_config()['working_memory'] = 9999,也只会修改副本,不会影响线程内部的真实配置。这是典型的"只读快照"模式——内部持有可变状态,对外暴露不可变视图。
3.5.5 配置获取时序图
3.6 set_config 配置写入机制 —— 选择性更新的"精准调节面板"
3.6.1 None 语义设计:不传就是"保持不变"
源码路径:sklearn/_config.py - set_config()(第69-157行)
# 第 3 章 —— 第69-85行:函数签名,10个参数全部默认 None,实现选择性更新
def set_config(
assume_finite=None,
working_memory=None,
print_changed_only=None,
display=None,
pairwise_dist_chunk_size=None,
enable_cython_pairwise_dist=None,
array_api_dispatch=None,
transform_output=None,
enable_metadata_routing=None,
skip_parameter_validation=None,
):
# 第87行:获取当前线程的可变配置字典
local_config = _get_threadlocal_config()
# 第89-90行:assume_finite 选择性更新
if assume_finite is not None:
local_config["assume_finite"] = assume_finite
# 第91-92行:working_memory 选择性更新
if working_memory is not None:
local_config["working_memory"] = working_memory
# 第93-94行:print_changed_only 选择性更新
if print_changed_only is not None:
local_config["print_changed_only"] = print_changed_only
# 第95-96行:display 选择性更新
if display is not None:
local_config["display"] = display
# 第97-98行:pairwise_dist_chunk_size 选择性更新
if pairwise_dist_chunk_size is not None:
local_config["pairwise_dist_chunk_size"] = pairwise_dist_chunk_size
# 第99-100行:enable_cython_pairwise_dist 选择性更新
if enable_cython_pairwise_dist is not None:
local_config["enable_cython_pairwise_dist"] = enable_cython_pairwise_dist
# 第101-106行:array_api_dispatch 特例:写入前校验
if array_api_dispatch is not None:
from sklearn.utils._array_api import _check_array_api_dispatch
_check_array_api_dispatch(array_api_dispatch) # 校验合法性
local_config["array_api_dispatch"] = array_api_dispatch
# 第107-108行:transform_output 选择性更新
if transform_output is not None:
local_config["transform_output"] = transform_output
# 第109-110行:enable_metadata_routing 选择性更新
if enable_metadata_routing is not None:
local_config["enable_metadata_routing"] = enable_metadata_routing
# 第111-112行:skip_parameter_validation 选择性更新
if skip_parameter_validation is not None:
local_config["skip_parameter_validation"] = skip_parameter_validation
这段代码展示了 scikit-learn 配置写入的核心哲学:选择性更新。所有 10 个参数默认值均为 None,函数体由 10 个独立的 if xxx is not None: 判断组成。这意味着:
-
调用
set_config(working_memory=2048)时,只有working_memory被更新,其余 9 项保持原样。 -
这种"微事务"设计避免了"全量覆盖"的副作用——用户无需先
get_config()再合并再写回,直接传想改的键即可。
3.6.2 set_config 写入流程图
3.6.3 逐键判断的"低耦合写入"
虽然写 10 个 if 显得冗长,但它的优势显而易见:
-
显式优于隐式:每个配置项的写入逻辑一目了然,无需追踪动态字典合并的逻辑。
-
易于审计与静态分析:工具可直接识别每个配置键的写入点。
-
避免
locals()陷阱:动态收集局部变量容易引入拼写错误或意外键。
3.6.4 array_api_dispatch 的"特殊礼遇"
注意第 101-106 行:array_api_dispatch 是唯一在写入前执行额外校验的参数:
if array_api_dispatch is not None:
from sklearn.utils._array_api import _check_array_api_dispatch
_check_array_api_dispatch(array_api_dispatch)
local_config["array_api_dispatch"] = array_api_dispatch
_check_array_api_dispatch() 验证传入值是否合法(必须为布尔值)。这种"特殊待遇"体现了对 Array API 兼容性的谨慎态度——它涉及跨后端(NumPy/CuPy/PyTorch/JAX)的分派逻辑,错误配置可能导致难以诊断的运行时故障。延迟导入(函数内部 from ... import)则避免了模块加载时的循环依赖风险。
3.7 config_context 上下文管理器 —— 临时配置的"时空胶囊"
3.7.1 contextmanager 装饰器的"魔法"
源码路径:sklearn/_config.py - config_context()(第159-295行)
# 第 3 章 —— 第159行:contextmanager 装饰器,将生成器函数转为上下文管理器
# 第 3 章 —— 省去手写 __enter__/__exit__ 样板代码
@contextmanager
def config_context(
# 第160-171行:仅关键字参数,10个配置项均默认 None
*,
assume_finite=None,
working_memory=None,
print_changed_only=None,
display=None,
pairwise_dist_chunk_size=None,
enable_cython_pairwise_dist=None,
array_api_dispatch=None,
transform_output=None,
enable_metadata_routing=None,
skip_parameter_validation=None,
):
# 第173行:进入上下文时,快照当前线程完整配置状态
# get_config() 返回浅拷贝,捕获所有 10 个键的当前值
old_config = get_config()
# 第174-185行:应用目标配置,复用 set_config 的 None 语义
# 仅更新显式传入的参数,其余保持原样
set_config(
assume_finite=assume_finite,
working_memory=working_memory,
print_changed_only=print_changed_only,
display=display,
pairwise_dist_chunk_size=pairwise_dist_chunk_size,
enable_cython_pairwise_dist=enable_cython_pairwise_dist,
array_api_dispatch=array_api_dispatch,
transform_output=transform_output,
enable_metadata_routing=enable_metadata_routing,
skip_parameter_validation=skip_parameter_validation,
)
# 第187行:try 块开始,yield 将控制权交给 with 块内代码
try:
# 第188行:yield 点,with 块内代码在此执行
yield
# 第189-190行:finally 保证无论正常退出、异常、break/return 都会执行
finally:
# 第191行:恢复旧配置,old_config 包含所有 10 个键的旧值
# **old_config 解包传入,完成完整状态回滚
set_config(**old_config)
@contextmanager 装饰器将生成器函数转换为上下文管理器,省去了手写 __enter__/__exit__ 样板代码。函数体在 yield 前是"进入阶段",yield 后(在 finally 中)是"退出阶段"。
3.7.2 旧配置快照的"冷冻保存"
进入上下文时,首先执行 old_config = get_config()。回顾 3.4 节,get_config() 返回的是线程本地配置的浅拷贝。这意味着 old_config 捕获了当前线程此时的完整配置状态(包含之前所有 set_config 修改的结果)。
随后调用 set_config(**kwargs) 应用目标配置——依然遵循"None 不改"语义,只更新显式传入的参数。
3.7.3 try/finally 的"铁律恢复"
无论 with 块内代码是正常返回、抛出异常、还是被 break/return 中断,finally 块必定执行。set_config(**old_config) 将 10 个键全部传回——注意 old_config 包含所有键的旧值,即使某些键在进入上下文前未被修改,它们也会被"重新设置"为原值。这保证了完整的状态回滚。
3.7.4 嵌套上下文的"俄罗斯套娃"语义
config_context 支持嵌套使用。文档中的示例演示了这一点:
with sklearn.config_context(assume_finite=True):
with sklearn.config_context(assume_finite=False):
assert_all_finite([float('nan')]) # 此处触发报错
# 第 3 章 —— 内层退出:恢复 assume_finite=True
# 第 3 章 —— 外层退出:恢复全局默认 False
时序图解析嵌套恢复流程:
内层退出时恢复到外层配置(True),而非直接跳回全局默认(False)。这就是"俄罗斯套娃"语义:每层只负责恢复它进入时看到的状态。
3.7.5 config_context 执行流程图
3.8 配置系统与估计器行为的联动 —— 运行时的"全局遥控器"
全局配置不是孤立存在的,它们直接驱动估计器的运行时行为。以下是主要联动点:
3.8.1 配置-估计器联动架构图
3.8.2 print_changed_only 与 __repr__ 的"精简显示"
当 print_changed_only=True(默认)时,BaseEstimator.__repr__ 只打印非默认参数:
# 第 3 章 —— print_changed_only=True (默认)
SVC()
# 第 3 章 —— print_changed_only=False
SVC(C=1.0, cache_size=200, class_weight=None, ...)
这由 sklearn/utils/_pprint.py 中的 _EstimatorPrettyPrinter 读取 get_config()['print_changed_only'] 决定。对于拥有大量参数的估计器(如 HistGradientBoostingClassifier),这能极大提升笔记本阅读体验。
3.8.3 transform_output 与 Transformer 的"输出变形"
transform_output 控制 TransformerMixin 子类的 transform/fit_transform 返回类型,实现了无缝的生态切换:
| 值 | 行为 |
|-----|------|
| 'default' | 保持估计器原生输出(通常是 ndarray) |
| 'pandas' | 返回 pandas.DataFrame,保留列名 |
| 'polars' | 返回 polars.DataFrame |
实现在 sklearn/utils/_set_output.py 的 _SetOutputMixin 中,通过 _get_output_config() 读取该配置。这让用户无需修改管道代码,仅通过全局开关即可在 NumPy/Pandas/Polars 生态间无缝切换。
3.8.4 working_memory 与分块算法的"内存预算"
working_memory(单位 MiB)限制临时数组大小。典型应用场景:
-
sklearn.metrics.pairwise_distances:大规模距离矩阵计算时分块处理 -
sklearn.cluster.KMeans:大数据集时避免一次性分配巨大中间数组 -
sklearn.decomposition.PCA:随机 SVD 求解器的内存控制
各算法通过 sklearn.utils._chunking.get_chunk_n_rows() 读取此配置,动态计算分块大小。
3.8.5 enable_metadata_routing 与 API 兼容性"开关"
enable_metadata_routing 为元数据路由(sample_weight、groups 等在 Pipeline 中的传递)提供渐进式迁移路径:
-
False(默认):保持旧 API 行为,元数据通过显式参数传递 -
True:启用新MetadataRouter机制,支持声明式路由
这体现了 scikit-learn 对向后兼容的极致重视:重大 API 变更默认关闭,用户可按需逐模块启用,完成迁移后再全局开启。
3.8.6 array_api_dispatch 与 Array API 标准分派
array_api_dispatch 启用后,scikit-learn 会对符合 Python Array API 标准 的输入(如 CuPy、PyTorch、JAX 数组)启用分派机制,将操作路由到相应后端。这由 sklearn/utils/_array_api.py 中的分派逻辑实现,_check_array_api_dispatch 在配置写入时校验参数合法性,确保运行时分派行为的确定性。
3.9 设计中的取舍
为什么用 threading.local() 而不用 contextvars(Python 3.7+ 上下文变量)?
-
contextvars设计用于异步任务(async/await)的上下文传播,支持任务间的嵌套与隔离。 -
scikit-learn 的主要并发模型是线程池(
joblib.Parallel、ThreadPoolExecutor),而非异步协程。 -
threading.local()对线程池模型天然适配,且在历史版本中已长期稳定运行,积累了成熟的工程实践。 -
迁移到
contextvars需处理同步/异步边界的上下文复制,收益不足以抵消重写风险。
为什么 set_config 不采用 **kwargs 动态分派,而要写 10 个显式 if?

浙公网安备 33010602011771号