Sklearn-源码解析-书-v1-0-二十三-

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

54.4 元数据路由核心机制 —— 「请求‑声明‑路由」三层协同的智能物流系统

54.4.1 关键点

  • MetadataRequest:每个 consumer(如 LinearRegressionSVC)都有一个 _metadata_request,内部为每个公开方法(fit, predict …)保存 MethodMetadataRequest,记录该方法是否 请求拒绝错误或使用 别名

  • MetadataRouterrouter(如 PipelineSelectFromModel)拥有一个 _route_mappings 表,键是子估计器名,值是 RouterMappingPair(mapping, router)mappingMethodMapping)描述父‑子方法对应关系(caller → callee),router 可以是另一个 MetadataRouterMetadataRequest,实现递归嵌套。

  • 路由过程process_routing(self, "fit", sample_weight=sw, **fit_params) 先拿到路由对象 (MetadataRouter),调用 validate_metadata 检查非法键,再通过 route_params 把父对象请求的元数据分配给对应子对象的对应方法。返回的 Bunch 结构形如 {'estimator': {'fit': {'sample_weight': array(...)}}},可直接 ** 传入子对象。

  • 全局开关enable_metadata_routingsklearn.set_config)控制是否激活此体系;未激活时,process_routing 直接返回空结构,保持向后兼容。

54.4.1.1 源码路径:sklearn/utils/_metadata_requests.py - MetadataRequest(第 300‑500 行)

# 第 54 章 —— file: sklearn/utils/_metadata_requests.py
# 第 54 章 —— line: 300-500
class MetadataRequest:
    """Container for storing metadata request info and an associated consumer (`owner`)."""

    # this is here for us to use this attribute's value instead of doing
    # `isinstance` in our checks, so that we avoid issues when people vendor
    # this file instead of using it directly from scikit-learn.
    _type = "metadata_request"

    def __init__(self, owner):
        # owner: 拥有此 MetadataRequest 的 consumer 对象
        self.owner = owner
        # 为 SIMPLE_METHODS 中每个公开方法(fit, predict, ...)生成一个 MethodMetadataRequest
        for method in SIMPLE_METHODS:
            setattr(
                self,
                method,
                MethodMetadataRequest(owner=owner, method=method),
            )

    def consumes(self, method, params):
        # 返回指定方法实际消费的元数据子集
        return getattr(self, method)._consumes(params=params)

    def __getattr__(self, name):
        # 处理 COMPOSITE_METHODS(如 fit_transform):组合底层方法的请求
        if name not in COMPOSITE_METHODS:
            raise AttributeError(
                f"'{self.__class__.__name__}' object has no attribute '{name}'"
            )

        requests = {}
        for method in COMPOSITE_METHODS[name]:
            mmr = getattr(self, method)
            existing = set(requests.keys())
            upcoming = set(mmr.requests.keys())
            common = existing & upcoming
            # 若两个底层方法对同一元数据的请求冲突,抛出错误
            conflicts = [key for key in common if requests[key] != mmr._requests[key]]
            if conflicts:
                raise ValueError(
                    f"Conflicting metadata requests for {', '.join(conflicts)} while"
                    f" composing the requests for {name}."
                )
            requests.update(mmr._requests)
        return MethodMetadataRequest(owner=self.owner, method=name, requests=requests)

    def _get_param_names(self, method, return_alias, ignore_self_request=None):
        # 获取某方法可消费或可路由的元数据名集合
        return getattr(self, method)._get_param_names(return_alias=return_alias)

    def _route_params(self, *, params, method, parent, caller):
        # 将外部传入的 params 按本对象的请求映射后,返回可 **kwargs 传入底层方法的 dict
        return getattr(self, method)._route_params(
            params=params, parent=parent, caller=caller
        )

54.4.1.2 源码路径:sklearn/utils/_metadata_requests.py - MetadataRouter.route_params(第 800‑950 行)

# 第 54 章 —— file: sklearn/utils/_metadata_requests.py
# 第 54 章 —— line: 800-950
class MetadataRouter:
    """Coordinates metadata routing for a :term:`router` object."""

    _type = "metadata_router"

    def __init__(self, owner):
        # owner: 拥有此路由器的 router 对象(如 Pipeline)
        self._route_mappings = dict()  # 子对象名 → RouterMappingPair
        # 若 router 自身也需要消费元数据(如 SelectFromModel),则记录在 _self_request
        self._self_request = None
        self.owner = owner

    def add_self_request(self, obj):
        # 当 router 本身也是 consumer 时调用此方法添加自身请求
        if getattr(obj, "_type", None) == "metadata_request":
            self._self_request = deepcopy(obj)
        elif hasattr(obj, "_get_metadata_request"):
            self._self_request = deepcopy(obj._get_metadata_request())
        else:
            raise ValueError(
                "Given `obj` is neither a `MetadataRequest` nor does it implement the"
                " required API."
            )
        return self

    def add(self, *, method_mapping, **objs):
        # 注册所有子 consumer 的路由信息
        method_mapping = deepcopy(method_mapping)
        for name, obj in objs.items():
            self._route_mappings[name] = RouterMappingPair(
                mapping=method_mapping,
                router=get_routing_for_object(obj),
            )
        return self

    def route_params(self, *, caller, params):
        """根据 caller 方法名分发 params 到各子 consumer 的对应方法。"""
        # 若 router 自身需要消费元数据,先检查是否有 WARN 状态
        if self._self_request:
            self._self_request._check_warnings(params=params, method=caller)

        res = Bunch()
        for name, route_mapping in self._route_mappings.items():
            router, mapping = route_mapping.router, route_mapping.mapping
            res[name] = Bunch()
            # 遍历子对象的 method mapping(如 caller="fit" → callee="fit")
            for _caller, _callee in mapping:
                if _caller == caller:
                    # 调用子对象(可能是 MetadataRequest 或嵌套的 MetadataRouter)
                    # 的 _route_params 取出实际需要的参数
                    res[name][_callee] = router._route_params(
                        params=params,
                        method=_callee,
                        parent=self.owner,
                        caller=caller,
                    )
        return res

    def validate_metadata(self, *, method, params):
        # 检查 params 中的键是否在 router 已知的元数据范围内
        param_names = self._get_param_names(
            method=method, return_alias=False, ignore_self_request=False
        )
        if self._self_request:
            self_params = self._self_request._get_param_names(
                method=method, return_alias=False
            )
        else:
            self_params = set()
        extra_keys = set(params.keys()) - param_names - self_params
        if extra_keys:
            raise TypeError(
                f"{_routing_repr(self.owner)}.{method} got unexpected argument(s)"
                f" {extra_keys}, which are not routed to any object."
            )

这段代码展示了 MetadataRouter 如何把父对象的 caller(如 fit)映射到子对象的 callee(如 fit),并利用子对象的 MetadataRequest 完成实际的参数过滤与别名处理。

54.4.1.3 源码路径:sklearn/utils/metadata_routing.py - process_routing(全文)

# 第 54 章 —— file: sklearn/utils/metadata_routing.py
# 第 54 章 —— line: 1-50
from sklearn.utils._metadata_requests import (  # noqa: F401
    UNCHANGED, UNUSED, WARN,
    MetadataRequest, MetadataRouter, MethodMapping,
    _MetadataRequester, _raise_for_params,
    _raise_for_unsupported_routing, _routing_enabled,
    _RoutingNotSupportedMixin,
    get_routing_for_object, process_routing,
)

54.4.1.4 在 Pipeline 中的实际使用

# 第 54 章 —— 示例:在 Pipeline 的 fit 中路由 sample_weight
def fit(self, X, y, **fit_params):
    routed = process_routing(self, "fit", **fit_params)
    # routed['scaler']['fit'] 包含空 dict,'estimator' 包含真实 sample_weight
    X_t = self.named_steps["scaler"].fit_transform(X, **routed["scaler"]["fit"])
    self.named_steps["estimator"].fit(X_t, y, **routed["estimator"]["fit"])
    return self

此代码演示了 process_routing 如何在 Pipeline 中将 sample_weight 从父对象路由到底层估计器的 fit 方法。

54.5 设计中的取舍

为什么在 MetadataRouter.route_params 中先处理 _self_request 再处理子对象? 这样可以保证路由器(router)自身若同时是 consumer,其元数据需求会优先被满足,避免子对象的路由逻辑意外覆盖或干扰路由器自身的请求。这在嵌套结构(如 Pipeline 中的 SelectFromModel 既消费又路由 sample_weight)时至关重要,确保自身的 WARN 状态能被先检查与提示。

enable_metadata_routing 上下文管理器如何实现路由系统的全局启停而不影响线程安全? 它依赖于 sklearn.set_config 将全局配置写入线程局部 config_context_routing_enabled() 每次调用读取线程局部配置;因其为线程局部,多线程环境下每个线程可独立开启或关闭路由,互不互扰,无需加锁即可保证安全。

54.5.1.1 思考题

  • 如果子估计器既未声明 request_sample_weight 也未声明 request_groups,父元估计器传递这些参数会发生什么?

    答案MethodMetadataRequest._route_params 在遍历 self._requests 时会发现对应键的请求状态是 FalseWARN,从而在返回的 Bunch 中不包含该键;如果用户强行传入且状态为 None,则会触发 UnsetMetadataPassedError,强制用户显式声明请求或放弃。

54.5.1.2 架构图

graph TD A[父对象 fit] --> B[process_routing] B --> C[MetadataRouter] C --> D[validate_metadata] C --> E[route_params] E --> F[_self_request._check_warnings] E --> G[子对象1._route_params] E --> H[子对象2._route_params] G --> I[MetadataRequest] H --> J[MetadataRequest] I --> K[Bunch sample_weight] J --> L[Bunch groups]

54.6 多分类与多标签目标处理 —— 目标类型识别的「决策树」与 OvR 决策函数

54.6.1 关键点

  • type_of_target:依据数据维度、稀疏性、数值类型以及是否为标签指示矩阵,逐层判定为 binarymulticlassmulticlass-multioutputmultilabel-indicatorcontinuouscontinuous-multioutputunknown

  • unique_labels:在保持顺序的前提下,对任意输入(NumPy、list、稀疏矩阵、Array API)统一抽取唯一标签;对多标签指示矩阵返回列索引序列。

  • is_multilabel:判断 2D 数组是否为标签指示矩阵(每列表示一个标签,每行最多一个 1),是 type_of_target 的关键子函数。

  • _check_partial_fit_first_call:为支持 partial_fit 的分类器检查首次调用一致性,验证传入的 classes 是否与上次一致。

  • _ovr_decision_function:把二分类 OvO 的预测结果与置信度汇聚为 n_samples × n_classes 的连续决策矩阵,使用 投票 + 置信度加权sum_of_confidencesvotes)实现平滑的多分类决策,避免仅靠投票产生的离散边界。

54.6.1.1 源码路径:sklearn/utils/multiclass.py - type_of_target(第 130‑280 行)

# 第 54 章 —— file: sklearn/utils/multiclass.py
# 第 54 章 —— line: 130-280
def type_of_target(y, input_name="", raise_unknown=False):
    """Determine the type of data indicated by the target."""
    xp, is_array_api_compliant = get_namespace(y)

    # 内部辅助函数:根据 raise_unknown 决定抛错或返回 'unknown'
    def _raise_or_return():
        if raise_unknown:
            input = input_name if input_name else "data"
            raise ValueError(f"Unknown label type for {input}: {y!r}")
        else:
            return "unknown"

    # 合法性校验:必须是 array-like 且非字符串
    valid = (
        (isinstance(y, Sequence) or issparse(y) or hasattr(y, "__array__"))
        and not isinstance(y, str)
    ) or is_array_api_compliant
    if not valid:
        raise ValueError("Expected array-like (array or non-string sequence), got %r" % y)

    # 排除 pandas 稀疏类型
    sparse_pandas = y.__class__.__name__ in ["SparseSeries", "SparseArray"]
    if sparse_pandas:
        raise ValueError("y cannot be class 'SparseSeries' or 'SparseArray'")

    # 第一优先级:是否为 multilabel-indicator(2D 且列数 > 1,标签数为 1-2)
    if is_multilabel(y):
        return "multilabel-indicator"

    # 借助 check_array 完成 dtype/ndim 推断,捕获 deprecation warning
    check_y_kwargs = dict(
        accept_sparse=True, allow_nd=True,
        ensure_all_finite=False, ensure_2d=False,
        ensure_min_samples=0, ensure_min_features=0,
    )
    with warnings.catch_warnings():
        warnings.simplefilter("error", VisibleDeprecationWarning)
        if not issparse(y):
            try:
                y = check_array(y, dtype=None, **check_y_kwargs)
            except (VisibleDeprecationWarning, ValueError) as e:
                if str(e).startswith("Complex data not supported"):
                    raise
                y = check_array(y, dtype=object, **check_y_kwargs)

    # 取首行/首值用于类型推断(稀疏与稠密不同)
    try:
        first_row_or_val = y[[0], :] if issparse(y) else y[0]
        if isinstance(first_row_or_val, bytes):
            raise TypeError("Support for labels represented as bytes is not supported.")
    except IndexError:
        pass

    # 无效维度
    if y.ndim not in (1, 2):
        return _raise_or_return()
    if not min(y.shape):
        # 空数组
        if y.ndim == 1:
            return "binary"  # []
        return _raise_or_return()  # [[]]

    # dtype 为 object 时排除非字符串情况
    if not issparse(y) and y.dtype == object and not isinstance(y.flat[0], str):
        return _raise_or_return()

    # 是否为 multioutput(2D 且列数 > 1)
    if y.ndim == 2 and y.shape[1] > 1:
        suffix = "-multioutput"
    else:
        suffix = ""

    # 检查浮点但非常整数的情况 → continuous
    if xp.isdtype(y.dtype, "real floating"):
        data = y.data if issparse(y) else y
        integral_data = xp.astype(data, xp.int64)
        # 若转 int64 后再转回原 dtype 不相等,则含非整数浮点
        if xp.any(data != xp.astype(integral_data, y.dtype)):
            _assert_all_finite(data, input_name=input_name)
            return "continuous" + suffix

    # 最终判断:唯一标签数 > 2 则为 multiclass,否则 binary
    if cached_unique(y).shape[0] > 2 or (y.ndim == 2 and len(first_row_or_val) > 1):
        return "multiclass" + suffix
    else:
        return "binary"

54.6.1.2 源码路径:sklearn/utils/multiclass.py - unique_labels(第 50‑130 行)

# 第 54 章 —— file: sklearn/utils/multiclass.py
# 第 54 章 —— line: 50-130
def unique_labels(*ys):
    """Extract an ordered array of unique labels."""
    # attach_unique 将 array-like 转为 ndarray,并保留传入顺序
    ys = attach_unique(*ys, return_tuple=True)
    xp, is_array_api_compliant = get_namespace(*ys)
    if len(ys) == 0:
        raise ValueError("No argument has been passed.")

    # 检查所有 y 的类型是否一致(binary/multiclass 可混,其他不可混)
    ys_types = set(type_of_target(x) for x in ys)
    if ys_types == {"binary", "multiclass"}:
        ys_types = {"multiclass"}
    if len(ys_types) > 1:
        raise ValueError("Mix type of y not allowed, got types %s" % ys_types)

    label_type = ys_types.pop()

    # multilabel-indicator 必须保持列数一致
    if (
        label_type == "multilabel-indicator"
        and len(set(check_array(y, accept_sparse=["csr", "csc", "coo"]).shape[1] for y in ys)) > 1
    ):
        raise ValueError(
            "Multi-label binary indicator input with different numbers of labels"
        )

    # 根据类型选择 _unique_multiclass 或 _unique_indicator
    _unique_labels = _FN_UNIQUE_LABELS.get(label_type, None)
    if not _unique_labels:
        raise ValueError("Unknown label type: %s" % repr(ys))

    if is_array_api_compliant:
        unique_ys = xp.concat([_unique_labels(y, xp=xp) for y in ys])
        return xp.unique_values(unique_ys)

    ys_labels = set(
        chain.from_iterable((i for i in _unique_labels(y, xp=xp)) for y in ys)
    )
    # 字符串与数字类型不可混
    if len(set(isinstance(label, str) for label in ys_labels)) > 1:
        raise ValueError("Mix of label input types (string and number)")

    return xp.asarray(sorted(ys_labels))

54.6.1.3 源码路径:sklearn/utils/multiclass.py - is_multilabel(第 200‑280 行)

# 第 54 章 —— file: sklearn/utils/multiclass.py
# 第 54 章 —— line: 200-280
def is_multilabel(y):
    """Check if ``y`` is in a multilabel format."""
    xp, is_array_api_compliant = get_namespace(y)
    if hasattr(y, "__array__") or isinstance(y, Sequence) or is_array_api_compliant:
        # 使用 check_array 推断 dtype;非 array 序列视为 object
        check_y_kwargs = dict(
            accept_sparse=True, allow_nd=True,
            ensure_all_finite=False, ensure_2d=False,
            ensure_min_samples=0, ensure_min_features=0,
        )
        with warnings.catch_warnings():
            warnings.simplefilter("error", VisibleDeprecationWarning)
            try:
                y = check_array(y, dtype=None, **check_y_kwargs)
            except (VisibleDeprecationWarning, ValueError) as e:
                if str(e).startswith("Complex data not supported"):
                    raise
                y = check_array(y, dtype=object, **check_y_kwargs)

    # 必须是 2D 且列数 > 1
    if not (hasattr(y, "shape") and y.ndim == 2 and y.shape[1] > 1):
        return False

    if issparse(y):
        # dok/lil 需转 csr 以便高效访问 data
        if y.format in ("dok", "lil"):
            y = y.tocsr()
        labels = xp.unique_values(y.data)
        # 标签数为 1 或 2 且包含 0,且 dtype 为整型/无符号型/浮点整数
        return len(y.data) == 0 or (
            (labels.size == 1 or ((labels.size == 2) and (0 in labels)))
            and (y.dtype.kind in "biu" or _is_integral_float(labels))
        )
    else:
        labels = cached_unique(y, xp=xp)
        # 唯一标签数 < 3 且 dtype 是 bool/整型/浮点整数
        return labels.shape[0] < 3 and (
            xp.isdtype(y.dtype, ("bool", "signed integer", "unsigned integer"))
            or _is_integral_float(labels)
        )

54.6.1.4 源码路径:sklearn/utils/multiclass.py - _check_partial_fit_first_call(第 350‑380 行)

# 第 54 章 —— file: sklearn/utils/multiclass.py
# 第 54 章 —— line: 350-380
def _check_partial_fit_first_call(clf, classes=None):
    """Private helper for verifying `partial_fit` first-call consistency."""
    if getattr(clf, "classes_", None) is None and classes is None:
        # 首次调用必须显式提供 classes
        raise ValueError("classes must be passed on the first call to partial_fit.")

    elif classes is not None:
        if getattr(clf, "classes_", None) is not None:
            # 非首次调用:classes 必须与上次完全一致
            if not np.array_equal(clf.classes_, unique_labels(classes)):
                raise ValueError(
                    "`classes=%r` is not the same as on last call "
                    "to partial_fit, was: %r" % (classes, clf.classes_)
                )
        else:
            # 首次调用:记录 classes_
            clf.classes_ = unique_labels(classes)
            return True

    return False

54.6.1.5 源码路径:sklearn/utils/multiclass.py - _ovr_decision_function(第 400‑500 行)

# 第 54 章 —— file: sklearn/utils/multiclass.py
# 第 54 章 —— line: 400-500
def _ovr_decision_function(predictions, confidences, n_classes):
    """Compute a continuous, tie-breaking OvR decision function from OvO."""
    n_samples = predictions.shape[0]
    votes = np.zeros((n_samples, n_classes))
    sum_of_confidences = np.zeros((n_samples, n_classes))

    k = 0
    # 遍历所有 OvO 二分类对 (i, j),共 n_classes*(n_classes-1)/2 个
    for i in range(n_classes):
        for j in range(i + 1, n_classes):
            # 正方向置信度累积到 j,负方向累积到 i
            sum_of_confidences[:, i] -= confidences[:, k]
            sum_of_confidences[:, j] += confidences[:, k]
            # 投票:预测为 0 的样本投 i,预测为 1 的投 j
            votes[predictions[:, k] == 0, i] += 1
            votes[predictions[:, k] == 1, j] += 1
            k += 1

    # 将累计置信度单调压缩到 (-1/3, 1/3) 区间,避免压过投票
    # f(x) = x / (3 * (|x| + 1))
    transformed_confidences = sum_of_confidences / (
        3 * (np.abs(sum_of_confidences) + 1)
    )
    return votes + transformed_confidences

type_of_target 为后续的 算法选择(如 LogisticRegression 自动切换到 binary/multiclass)提供统一入口;_ovr_decision_function一对多(OvO → OvR)提供连续输出,支撑 roc_auc_scoreCalibratedClassifierCV 等对概率敏感的评估。

54.6.1.6 设计中的取舍

为什么在 _ovr_decision_function 中使用 f(x)=x/(3*(|x|+1)) 而不是更简单的线性缩放? 这种单调函数可以将置信度压缩到 (-1/3, 1/3) 的有限范围,确保置信度项的幅度始终小于 1(投票项的整数增量),从而在保持投票顺序不变的前提下引入细微差别来平滑决策边界。如果用线性缩放,过大的置信度可能会翻盘投票,破坏 OvO 决策的一致性。

is_multilabel 为什么要特别检查 dtype.kind in "biu"_is_integral_float(labels) 因为标签指示矩阵的值只能是 0 或 1(合法整型),而用户的浮点数据(如 0.0/1.0)需要通过 _is_integral_float 严格验证,否则浮点矩阵会被误判为连续目标,导致 type_of_target 给出错误的类型。

54.6.1.7 思考题

  • type_of_target 在处理稀疏矩阵时为何需要特别判断 is_integral_float

    答案:因为稀疏矩阵的数据可能是浮点型但其实代表整数标签(如 0.0、1.0),直接根据 dtype 判断会误将其视为连续目标;is_integral_float 通过将浮点数据转 int64 再转回原 dtype 并比较,确保只有真正的非整数浮点才被判为 continuous

54.6.1.8 架构图

flowchart TD A[目标数据 y] --> B{is_multilabel?} B -->|是| C[multilabel-indicator] B -->|否| D[维度检查] D -->|ndim=1或shape[1]=1| E{浮点?} D -->|ndim=2且shape[1]>1| F{浮点?} E -->|是| G{转 int64 后相等?} G -->|否| H[continuous] G -->|是| I[唯一标签数 > 2?] I -->|是| J[multiclass] I -->|否| K[binary] F -->|是| L[continuous-multioutput] F -->|否| M[multiclass-multioutput]

54.7 类权重与样本加权 —— 类别不平衡的「砝码校正」数学原理

54.7.1 关键点

  • compute_class_weight:在 class_weight='balanced' 时,权重公式为

    [

    w_c = \frac{n_{\text{samples}}}{n_{\text{classes}} ; \times ; \text{count}(c)}

    ]

    若提供 sample_weight,则在计数时使用加权计数 (_bincount 支持权重)。

  • compute_sample_weight:把 class‑level 权重 广播 到每个样本;在多输出情形下,对每列独立计算并取几何平均,确保 每个输出 的权重都被考虑。

54.7.1.1 源码路径:sklearn/utils/class_weight.py - compute_class_weight(第 50‑130 行)

# 第 54 章 —— file: sklearn/utils/class_weight.py
# 第 54 章 —— line: 50-130
def compute_class_weight(class_weight, *, classes, y, sample_weight=None):
    """Estimate class weights for unbalanced datasets."""
    from sklearn.preprocessing import LabelEncoder

    xp, _, device_ = get_namespace_and_device(y, classes)
    unique_y = xp.unique_values(y)
    # classes 必须包含 y 中所有合法标签
    if set(_convert_to_numpy(unique_y, xp)) - set(_convert_to_numpy(classes, xp)):
        raise ValueError("classes should include all valid labels that can be in y")

    if class_weight is None or len(class_weight) == 0:
        # 均匀权重
        weight = xp.ones(classes.shape[0], device=device_)
    elif class_weight == "balanced":
        # 使用 LabelEncoder 将 y 编码为整数索引
        le = LabelEncoder()
        y_ind = le.fit_transform(y)
        if not all(_isin(classes, xp.astype(le.classes_, classes.dtype), xp=xp)):
            raise ValueError("classes should have valid labels that are in y")

        # numpy 命名空间下 sample_weight 需转回 numpy
        if _is_numpy_namespace(xp) and sample_weight is not None:
            xp_sw, _ = get_namespace(sample_weight)
            sample_weight = _convert_to_numpy(sample_weight, xp_sw)

        sample_weight = _check_sample_weight(sample_weight, y)
        # 加权计数:每个类别的样本加权和
        weighted_class_counts = _bincount(y_ind, weights=sample_weight, xp=xp)
        # recip_freq = n_samples / (n_classes * count(c))
        recip_freq = xp.sum(weighted_class_counts) / (
            size(le.classes_) * weighted_class_counts
        )
        # 按 le.classes_ 的顺序映射回原始 classes
        weight = recip_freq[le.transform(classes)]
    else:
        # 用户自定义字典
        weight = xp.ones(size(classes), device=device_)
        unweighted_classes = []
        for i, c in enumerate(classes):
            try:
                c = int(c)
            except ValueError:  # 字符串类标签
                c = str(c)
            if c in class_weight:
                weight[i] = class_weight[c]
            else:
                unweighted_classes.append(c)

        n_weighted_classes = size(classes) - len(unweighted_classes)
        if unweighted_classes and n_weighted_classes != len(class_weight):
            raise ValueError(
                f"The classes, {unweighted_classes}, are not in class_weight"
            )

    return weight

54.7.1.2 源码路径:sklearn/utils/class_weight.py - compute_sample_weight(第 150‑270 行)

# 第 54 章 —— file: sklearn/utils/class_weight.py
# 第 54 章 —— line: 150-270
def compute_sample_weight(class_weight, y, *, indices=None):
    """Estimate sample weights by class for unbalanced datasets."""

    # 将 y 转换为 2D;稀疏矩阵本身就是 2D
    if not sparse.issparse(y):
        y = np.atleast_1d(y)
        if y.ndim == 1:
            y = np.reshape(y, (-1, 1))
    n_outputs = y.shape[1]

    if indices is not None and class_weight != "balanced":
        raise ValueError(
            "The only valid class_weight for subsampling is 'balanced'."
        )
    elif n_outputs > 1:
        if class_weight is None or isinstance(class_weight, dict):
            raise ValueError(
                "For multi-output, class_weight should be a list of dicts, or 'balanced'."
            )
        elif isinstance(class_weight, list) and len(class_weight) != n_outputs:
            raise ValueError(
                f"For multi-output, number of elements in class_weight should match "
                f"number of outputs. Got {len(class_weight)} vs {n_outputs}."
            )

    expanded_class_weight = []
    for k in range(n_outputs):
        # 对每个输出列独立计算类权重
        if sparse.issparse(y):
            y_full = y[:, [k]].toarray().flatten()
        else:
            y_full = y[:, k]
        classes_full = np.unique(y_full)
        classes_missing = None

        if class_weight == "balanced" or n_outputs == 1:
            class_weight_k = class_weight
        else:
            class_weight_k = class_weight[k]

        if indices is not None:
            # 子采样时计算每个类在子样本上的权重
            y_subsample = y_full[indices]
            classes_subsample = np.unique(y_subsample)
            weight_k = np.take(
                compute_class_weight(
                    class_weight_k, classes=classes_subsample, y=y_subsample
                ),
                np.searchsorted(classes_subsample, classes_full),
                mode="clip",
            )
            classes_missing = set(classes_full) - set(classes_subsample)
        else:
            weight_k = compute_class_weight(
                class_weight_k, classes=classes_full, y=y_full
            )

        # 将每类的权重广播到每个样本
        weight_k = weight_k[np.searchsorted(classes_full, y_full)]

        if classes_missing:
            # 子样本中缺失的类,其样本权重设为 0
            weight_k[np.isin(y_full, list(classes_missing))] = 0.0

        expanded_class_weight.append(weight_k)

    # 多输出情况下取各列权重的乘积(几何平均等价)
    expanded_class_weight = np.prod(expanded_class_weight, axis=0, dtype=np.float64)
    return expanded_class_weight

通过 Array APIxp)抽象,实现对 NumPy、CuPy、Torch 等后端的统一加权计算,且在 稀疏矩阵 环境下自动回退到 NumPy 实现。

54.7.1.3 设计中的取舍

为什么在多输出情况下使用几何平均(即乘积)而不是算术平均来合并各列的样本权重? 几何平均能够保留多输出任务中各输出的相对量级信息,对极不平衡的输出给予显著放大;若用算术平均,一个极度不平衡的输出可能因其他均衡输出而被稀释,导致模型对该输出欠拟合。此外,乘积在数值上等价于对各权重取对数后求平均,语义上更贴合"每个输出都重要"的加权思想。

为什么在 compute_sample_weight 中对每列分别计算 class_weight_k,而不是直接在多输出标签上计算一次? 多输出任务中每列可能具有完全不同的类别分布,统一计算会忽略每列的特定不平衡情况;分别计算后再取乘积,能够精确地将每列的类别不平衡信息融合到最终样本权重中。

54.7.1.4 思考题

  • 在子采样(indices is not None)时为什么使用 searchsorted + np.take(..., mode="clip") 而不是直接用 dict 映射?

    答案searchsorted 在有序数组上的二分查找时间复杂度为 O(log n),比 dict 的哈希开销更低,且 np.take 在跨 NumPy 后端时更通用;mode="clip" 保证超出范围的索引返回最后一个有效值,从而对子样本中不存在的类返回 0 权重,后续再通过 classes_missing 显式置零。

54.7.1.5 架构图

flowchart LR A[输入 y] --> B{n_outputs == 1?} B -->|是| C[class_weight_k = class_weight] B -->|否| D[class_weight_k = class_weight[k]] C --> E[compute_class_weight] D --> E E --> F[weight_k = class 权重广播到样本] F --> G[收集到 expanded_class_weight] G --> H{n_outputs > 1?} H -->|是| I[各列权重取乘积] H -->|否| J[直接返回] I --> K[输出样本权重] J --> K

54.8 Cython 与 BLAS 高性能内核 —— 矩阵运算的「隐形引擎」

54.8.1 关键点

  • 融合类型 (floating):在 .pyx 中声明 cdef floating,编译时会生成两套实现(floatdouble),避免手写两遍代码。

  • GIL 释放 & nogil:所有底层 BLAS 调用都标记为 nogil,确保在多线程环境下不阻塞 Python 解释器。

  • 行/列主序自适应:通过检查 A.strides[0] == A.itemsize 判别内存布局;若为 RowMajor,在调用 BLAS 前交换维度并修改 ta/tb,让 BLAS(列主序)直接使用原始内存,零拷贝完成转置。

54.8.1.1 源码路径:sklearn/utils/_cython_blas.pyx - _dot(第 15‑30 行)

# 第 54 章 —— file: sklearn/utils/_cython_blas.pyx
# 第 54 章 —— line: 15-30
# 第 54 章 —— 融合类型 floating 在编译期会被替换为 float 与 double 两套代码
cdef floating _dot(int n, const floating *x, int incx,
                   const floating *y, int incy) noexcept nogil:
    """Compute the dot product x·y."""
    if floating is float:
        # 编译期分支:floating=float 时调用 sdot
        return sdot(&n, <float *> x, &incx, <float *> y, &incy)
    else:
        # 否则调用 ddot
        return ddot(&n, <double *> x, &incx, <double *> y, &incy)


# 第 54 章 —— 内存视图版本:接受 Python memoryview,自动提取指针与 strides
cpdef _dot_memview(const floating[::1] x, const floating[::1] y):
    return _dot(x.shape[0], &x[0], 1, &y[0], 1)

54.8.1.2 源码路径:sklearn/utils/_cython_blas.pyx - _gemv(第 100‑135 行)

# 第 54 章 —— file: sklearn/utils/_cython_blas.pyx
# 第 54 章 —— line: 100-135
cdef void _gemv(BLAS_Order order, BLAS_Trans ta, int m, int n,
                floating alpha, const floating *A, int lda,
                const floating *x, int incx, floating beta,
                floating *y, int incy) noexcept nogil:
    """Compute y := alpha * op(A).x + beta * y."""
    cdef char ta_ = ta
    if order == BLAS_Order.RowMajor:
        # RowMajor 矩阵在 BLAS 视角下需交换 m↔n,并翻转转置标记
        ta_ = BLAS_Trans.NoTrans if ta == BLAS_Trans.Trans else BLAS_Trans.Trans
        if floating is float:
            sgemv(&ta_, &n, &m, &alpha, <float *> A, &lda,
                  <float *> x, &incx, &beta, y, &incy)
        else:
            dgemv(&ta_, &n, &m, &alpha, <double *> A, &lda,
                  <double *> x, &incx, &beta, y, &incy)
    else:
        # ColMajor 直接使用
        if floating is float:
            sgemv(&ta_, &m, &n, &alpha, <float *> A, &lda,
                  <float *> x, &incx, &beta, y, &incy)
        else:
            dgemv(&ta_, &m, &n, &alpha, <double *> A, &lda,
                  <double *> x, &incx, &beta, y, &incy)


cpdef _gemv_memview(BLAS_Trans ta, floating alpha, const floating[:, :] A,
                    const floating[::1] x, floating beta, floating[::1] y):
    cdef:
        int m = A.shape[0]
        int n = A.shape[1]
        # strides[0] == itemsize 表示行连续(C-contiguous / RowMajor)
        BLAS_Order order = (
            BLAS_Order.ColMajor if A.strides[0] == A.itemsize else BLAS_Order.RowMajor
        )
        int lda = m if order == BLAS_Order.ColMajor else n

    _gemv(order, ta, m, n, alpha, &A[0, 0], lda, &x[0], 1, beta, &y[0], 1)

通过上述逻辑,RowMajor(C‑style)矩阵只需要切换 ta/tblda 也随之调换,无需显式拷贝或转置即可使用高效的 Fortran‑style BLAS。

54.8.1.3 源码路径:sklearn/utils/_cython_blas.pxd - BLAS 声明(第 1‑50 行)

# 第 54 章 —— file: sklearn/utils/_cython_blas.pxd
# 第 54 章 —— line: 1-50
from cython cimport floating


# 第 54 章 —— 枚举:BLAS 矩阵存储顺序
cpdef enum BLAS_Order:
    RowMajor  # C contiguous
    ColMajor  # Fortran contiguous


# 第 54 章 —— 枚举:转置标记(对应 BLAS 的字符 'n' 与 't')
cpdef enum BLAS_Trans:
    NoTrans = 110  # 'n'
    Trans = 116    # 't'


# 第 54 章 —— BLAS Level 1 函数签名(cimport 接口)
cdef floating _dot(int, const floating*, int, const floating*, int) noexcept nogil
cdef floating _asum(int, const floating*, int) noexcept nogil
cdef void _axpy(int, floating, const floating*, int, floating*, int) noexcept nogil
cdef floating _nrm2(int, const floating*, int) noexcept nogil
cdef void _copy(int, const floating*, int, const floating*, int) noexcept nogil
cdef void _scal(int, floating, const floating*, int) noexcept nogil
cdef void _rotg(floating*, floating*, floating*, floating*) noexcept nogil
cdef void _rot(int, floating*, int, floating*, int, floating, floating) noexcept nogil

# 第 54 章 —— BLAS Level 2
cdef void _gemv(BLAS_Order, BLAS_Trans, int, int, floating, const floating*, int,
                const floating*, int, floating, floating*, int) noexcept nogil
cdef void _ger(BLAS_Order, int, int, floating, const floating*, int, const floating*,
               int, floating*, int) noexcept nogil

# 第 54 章 —— BLAS Level 3
cdef void _gemm(BLAS_Order, BLAS_Trans, BLAS_Trans, int, int, int, floating,
                const floating*, int, const floating*, int, floating, floating*,
                int) noexcept nogil

54.8.1.4 设计中的取舍

为什么在 _gemv 中不直接调用 sgemmdgemm 来处理向量-矩阵乘法,而是使用专门的 _gemv BLAS Level 2 的 _gemv 专门针对矩阵-向量乘法进行了优化,其内存访问模式和计算强度与 Level 3 的 _gemm 不同;使用 _gemv 能避免将向量视为 n×1 矩阵时的额外遍历与不连续访开销,并在实际硬件上获得更好的 cache 命中率。

在检测矩阵布局时,为什么使用 A.strides[0] == A.itemsize 而不是 A.flags['C_CONTIGUOUS'] Cython 内存视图不依赖 NumPy 的 flags 属性,直接比较 strides[0]itemsize 能在不引入 NumPy 头文件的情况下高效判断行连续性;同时该判断在 Array API 后端(如 CuPy、torch)也保持一致语义,不依赖具体数组库的属性。

54.8.1.5 思考题

  • 为什么 _gemm_memview 中需要根据转置标记动态计算 m, n, klda, ldb, ldc

    答案:BLAS 的 gemm 期望操作数 op(A) 的形状为 m × kop(B)k × n;当 A 被转置时其逻辑形状变为 (A.shape[1], A.shape[0]),相应地 leading dimension 也需调整以反映正确的内存步幅,否则 BLAS 会按错误步幅访问内存导致越界或结果错误。

54.8.1.6 架构图

flowchart TD A[输入矩阵 A 向量 x/y] --> B{A.strides[0] == A.itemsize?} B -->|是 RowMajor| C[交换 m↔n, 翻转转置标记] B -->|否 ColMajor| D[直接使用] C --> E[根据 floating 类型选择 sgemv/dgemv] D --> E E --> F[y := alpha * op(A).x + beta * y] F --> G[结果返回]

54.9 OpenMP 并行与线程池控制 —— 硬件资源的「智能调度员」

54.9.1 关键点

  • 编译期宏 SKLEARN_OPENMP_PARALLELISM_ENABLED_openmp_helpers.pxd 中由 #ifdef _OPENMP 决定,统一在运行时通过 _openmp_parallelism_enabled() 查询。

  • 动态线程数_openmp_effective_n_threads 按以下优先级决定实际线程数

    1. 用户显式 n_threads(正数直接使用)

    2. 环境变量 OMP_NUM_THREADS(若设定则使用 omp_get_max_threads()

    3. min(omp_get_max_threads(), cpu_count(only_physical_cores)),其中 cpu_count 会读取 Docker cgroups 配额。

  • 负数 n_threads-1 → 使用所有可用核心;-2 → 留一个核心给主进程,依此类推。

  • 线程池装饰器 @_threadpool_controller_decorator(limits=1, user_api="blas") 在函数入口创建 ThreadpoolController 实例,仅在第一次调用时加载共享库,随后在嵌套并行(如 GridSearchCV 中的 RandomForest)时通过 上下文管理器 限制每个子调用的线程数,防止 线程过度订阅

54.9.1.1 源码路径:sklearn/utils/_openmp_helpers.pyx - _openmp_parallelism_enabled_openmp_effective_n_threads(第 30‑90 行)

# 第 54 章 —— file: sklearn/utils/_openmp_helpers.pyx
# 第 54 章 —— line: 30-90
def _openmp_parallelism_enabled():
    """Returns whether scikit-learn was built with OpenMP support."""
    # SKLEARN_OPENMP_PARALLELISM_ENABLED 由 .pxd 中的宏根据 _OPENMP 定义
    return SKLEARN_OPENMP_PARALLELISM_ENABLED


cpdef _openmp_effective_n_threads(n_threads=None, only_physical_cores=True):
    """Determine the effective number of threads for OpenMP calls."""
    if n_threads == 0:
        raise ValueError("n_threads = 0 is invalid")

    if not SKLEARN_OPENMP_PARALLELISM_ENABLED:
        # 未启用 OpenMP 时强制串行
        return 1

    if os.getenv("OMP_NUM_THREADS"):
        # 用户通过环境变量显式设置,直接采用 OpenMP 报告的上限
        max_n_threads = omp_get_max_threads()
    else:
        try:
            # 优先从模块缓存中获取,避免重复系统调用
            n_cpus = _CPU_COUNTS[only_physical_cores]
        except KeyError:
            # joblib.cpu_count 会自动检测 cgroups 配额
            n_cpus = cpu_count(only_physical_cores=only_physical_cores)
            _CPU_COUNTS[only_physical_cores] = n_cpus
        # 取 OpenMP 上限与 CPU 核心数的较小值
        max_n_threads = min(omp_get_max_threads(), n_cpus)

    if n_threads is None:
        return max_n_threads
    elif n_threads < 0:
        # n_threads = -k → max_n_threads - (k-1)
        return max(1, max_n_threads + n_threads + 1)

    return n_threads

54.9.1.2 源码路径:sklearn/utils/_openmp_helpers.pxd - OpenMP 声明(第 1‑30 行)

# 第 54 章 —— file: sklearn/utils/_openmp_helpers.pxd
# 第 54 章 —— line: 1-30
cdef extern from *:
    """
    #ifdef _OPENMP
        #include <omp.h>
        #define SKLEARN_OPENMP_PARALLELISM_ENABLED 1
    #else
        #define SKLEARN_OPENMP_PARALLELISM_ENABLED 0
        #define omp_lock_t int
        #define omp_init_lock(l) (void)0
        #define omp_destroy_lock(l) (void)0
        #define omp_set_lock(l) (void)0
        #define omp_unset_lock(l) (void)0
        #define omp_get_thread_num() 0
        #define omp_get_max_threads() 1
    #endif
    """
    bint SKLEARN_OPENMP_PARALLELISM_ENABLED

    ctypedef struct omp_lock_t:
        pass

    void omp_init_lock(omp_lock_t*) noexcept nogil
    void omp_destroy_lock(omp_lock_t*) noexcept nogil
    void omp_set_lock(omp_lock_t*) noexcept nogil
    void omp_unset_lock(omp_lock_t*) noexcept nogil
    int omp_get_thread_num() noexcept nogil
    int omp_get_max_threads() noexcept nogil

54.9.1.3 源码路径:sklearn/utils/parallel.py - _threadpool_controller_decoratorParallel.__call__(第 135‑200 行)

# 第 54 章 —— file: sklearn/utils/parallel.py
# 第 54 章 —— line: 135-200
def _threadpool_controller_decorator(limits=1, user_api="blas"):
    """Decorator to limit the number of threads used at the function level."""

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # 懒加载:仅在调用时获取全局控制器,加载各 threadpool 共享库
            controller = _get_threadpool_controller()
            # controller.limit 通过 threadpoolctl 动态修改 BLAS/OpenMP 线程数
            with controller.limit(limits=limits, user_api=user_api):
                return func(*args, **kwargs)

        return wrapper

    return decorator


class Parallel(joblibParallel):
    """Tweak of :class:`joblib.Parallel` that propagates the scikit-learn configuration."""

    def __call__(self, iterable):
        """Dispatch the tasks and return the results."""
        # 捕获线程本地的 sklearn 配置,使子进程 / 子线程能继承
        config = get_config()
        filters_func = getattr(warnings, "_get_filters", None)
        warning_filters = (
            filters_func() if filters_func is not None else warnings.filters
        )

        iterable_with_config_and_warning_filters = (
            (
                _with_config_and_warning_filters(delayed_func, config, warning_filters),
                args,
                kwargs,
            )
            for delayed_func, args, kwargs in iterable
        )
        return super().__call__(iterable_with_config_and_warning_filters)


def delayed(function):
    """Decorator used to capture the arguments of a function."""

    @functools.wraps(function)
    def delayed_function(*args, **kwargs):
        # 返回元组形式,供 Parallel 调度
        return _FuncWrapper(function), args, kwargs

    return delayed_function


class _FuncWrapper:
    """Load the global configuration before calling the function."""

    def __init__(self, function):
        self.function = function
        update_wrapper(self, self.function)

    def with_config_and_warning_filters(self, config, warning_filters):
        # 在 Parallel.__call__ 中被调用,注入配置与警告过滤器
        self.config = config
        self.warning_filters = warning_filters
        return self

    def __call__(self, *args, **kwargs):
        config = getattr(self, "config", {})
        warning_filters = getattr(self, "warning_filters", [])
        if not config or not warning_filters:
            warnings.warn(
                (
                    "`sklearn.utils.parallel.delayed` should be used with"
                    " `sklearn.utils.parallel.Parallel` to make it possible to"
                    " propagate the scikit-learn configuration of the current thread to"
                    " the joblib workers."
                ),
                UserWarning,
            )

        with config_context(**config), warnings.catch_warnings():
            warnings.resetwarnings()
            warning_filter_keys = ["action", "message", "category", "module", "lineno"]
            for filter_args in warning_filters:
                this_warning_filter_dict = {
                    k: v for k, v in zip(warning_filter_keys, filter_args) if v is not None
                }
                if (
                    "message" not in this_warning_filter_dict
                    and "module" not in this_warning_filter_dict
                ):
                    warnings.simplefilter(**this_warning_filter_dict, append=True)
                else:
                    for special_key in ["message", "module"]:
                        this_value = this_warning_filter_dict.get(special_key)
                        if this_value is not None and not isinstance(this_value, str):
                            this_warning_filter_dict[special_key] = this_value.pattern
                    warnings.filterwarnings(**this_warning_filter_dict, append=True)

            return self.function(*args, **kwargs)

sklearn.neighbors._kd_tree 等内部实现里会使用 @_threadpool_controller_decorator(limits=1, user_api="blas"),确保 每颗树 在并行构建时只使用 1 条 OpenMP 线程,从而让外部 n_jobs 的并行控制真正生效。

54.9.1.4 设计中的取舍

为什么在 _threadpool_controller_decorator 中将 limits 默认值设为1而不是使用 user_api 参数决定的默认线程数? 这样可以在不显式指定 limits 的情况下,保守地限制线程使用,防止在不知道内部实际 BLAS 调用情况的函数上意外启用过多线程;显式设置 limits 时,用户可以根据特定 BLAS 调用的并行性需求进行调优。

Parallel.__call__ 在分发任务前为什么先捕获 get_config()warnings.filters Joblib 在多进程/多线程模式下会在子进程或子线程中执行任务,这些子上下文不继承主线程的 sklearn 配置与警告过滤器;显式捕获并在 worker 入口通过 config_contextwarnings.catch_warnings 重新注入,保证行为一致。

54.9.1.5 思考题

  • 为何模块级缓存 _CPU_COUNTS 可以假设硬件拓扑在进程生命周期内不变?

    答案:在容器化环境(Docker、Kubernetes)中,cgroups 配额通常在容器启动时固定,不会在运行时动态变化;即使在裸机系统中,物理核心数也不会在进程运行过程中改变,因此缓存是安全且高效的,避免每次调用都重新读取 /proc/cpuinfo 或 cgroup 文件。

54.9.1.6 架构图

flowchart TD A[输入 n_threads] --> B{n_threads == 0?} B -->|是| C[抛出 ValueError] B -->|否| D{OpenMP 是否启用?} D -->|否| E[返回 1] D -->|是| F{OMP_NUM_THREADS 是否设置?} F -->|是| G[返回 omp_get_max_threads()] F -->|否| H[获取 CPU 核心数 含 cgroup] H --> I[取 omp_get_max_threads 和 CPU 的最小值] I --> J{n_threads 是否为 None?} J -->|是| K[返回 I] J -->|否| L{n_threads < 0?} L -->|是| M[返回 max 1 max_n_threads + n_threads + 1] L -->|否| N[返回 n_threads]

54.10 快速哈希与排序内核 —— 特征工程与邻居搜索的「毫秒级基石」

54.10.1 关键点

  • MurmurHash3 C++ 实现:核心函数 MurmurHash3_x86_32 包含 块读取getblock)、混合循环k1 *= c1; k1 = ROTL32(k1,15); …)以及 雪崩终结 fmix,保证每个位的影响均匀分布。

  • Cython 多态入口murmurhash3_32 根据输入类型分流到对应的 C++ 调用或批量数组循环;批处理函数 _murmurhash3_bytes_array_u32 采用 nogil 循环,以 GIL 释放 实现向量化吞吐。

  • simultaneous_sort:使用 median‑of‑three 选枢、单向分区递归/尾递归 结合硬编码的 2、3 元素排序网络,避免递归开销;dual_swap 保障 值‑索引 同步交换,保持 Structure‑of Arrays (SoA) 布局,利于 SIMD 与缓存局部性。

  • heap_push:实现 固定大小最大堆size 为堆容量),当新值更小时进行 下沉,在 KNN k 最近邻查询中保持 k 最小距离,使用 SoAvalues + indices)避免 Python 元组创建开销。

54.10.1.1 源码路径:sklearn/utils/src/MurmurHash3.cpp - MurmurHash3_x86_32(第 80‑170 行)

// file: sklearn/utils/src/MurmurHash3.cpp
// line: 80-170
// 32 位 MurmurHash3 主体(x86 优化版)
void MurmurHash3_x86_32 ( const void * key, int len,
                          uint32_t seed, void * out )
{
  const uint8_t * data = (const uint8_t*)key;
  const int nblocks = len / 4;  // 每个块 4 字节

  uint32_t h1 = seed;

  uint32_t c1 = 0xcc9e2d51;
  uint32_t c2 = 0x1b873593;

  //---------- body: 处理 4 字节对齐的整块 ----------
  const uint32_t * blocks = (const uint32_t *)(data + nblocks*4);
  for(int i = -nblocks; i; i++)
  {
    uint32_t k1 = getblock(blocks,i);   // 平台相关:可能需要字节序转换
    k1 *= c1;
    k1 = ROTL32(k1,15);
    k1 *= c2;
    h1 ^= k1;
    h1 = ROTL32(h1,13);
    h1 = h1*5+0xe6546b64;
  }

  //---------- tail: 处理剩余 0-3 字节 ----------
  const uint8_t * tail = (const uint8_t*)(data + nblocks*4);
  uint32_t k1 = 0;
  switch(len & 3)
  {
  case 3: k1 ^= tail[2] << 16;
  case 2: k1 ^= tail[1] << 8;
  case 1: k1 ^= tail[0];
          k1 *= c1; k1 = ROTL32(k1,15); k1 *= c2; h1 ^= k1;
  }

  //---------- finalization: 混入长度并执行雪崩 ----------
  h1 ^= len;
  h1 = fmix(h1);
  *(uint32_t*)out = h1;
}

54.10.1.2 源码路径:sklearn/utils/src/MurmurHash3.cpp - fmixgetblock(第 30‑70 行)

// file: sklearn/utils/src/MurmurHash3.cpp
// line: 30-70
// 平台无关的强制内联函数:32 位雪崩终结
FORCE_INLINE uint32_t fmix ( uint32_t h )
{
  h ^= h >> 16;
  h *= 0x85ebca6b;
  h ^= h >> 13;
  h *= 0xc2b2ae35;
  h ^= h >> 16;
  return h;
}

// 64 位雪崩终结
FORCE_INLINE uint64_t fmix ( uint64_t k )
{
  k ^= k >> 33;
  k *= BIG_CONSTANT(0xff51afd7ed558ccd);
  k ^= k >> 33;
  k *= BIG_CONSTANT(0xc4ceb9fe1a85ec53);
  k ^= k >> 33;
  return k;
}

// 平台无关的块读取(可直接访问对齐内存或显式处理字节序)
FORCE_INLINE uint32_t getblock ( const uint32_t * p, int i )
{
  return p[i];
}

FORCE_INLINE uint64_t getblock ( const uint64_t * p, int i )
{
  return p[i];
}

54.10.1.3 源码路径:sklearn/utils/murmurhash.pyx - murmurhash3_32(第 80‑140 行)

# 第 54 章 —— file: sklearn/utils/murmurhash.pyx
# 第 54 章 —— line: 80-140
def murmurhash3_32(key, seed=0, positive=False):
    """Compute the 32bit murmurhash3 of key at seed."""
    if isinstance(key, bytes):
        if positive:
            return murmurhash3_bytes_u32(key, seed)
        else:
            return murmurhash3_bytes_s32(key, seed)
    elif isinstance(key, unicode):
        if positive:
            return murmurhash3_bytes_u32(key.encode('utf-8'), seed)
        else:
            return murmurhash3_bytes_s32(key.encode('utf-8'), seed)
    elif isinstance(key, int) or isinstance(key, np.int32):
        if positive:
            return murmurhash3_int_u32(<int32_t>key, seed)
        else:
            return murmurhash3_int_s32(<int32_t>key, seed)
    elif isinstance(key, np.ndarray):
        if key.dtype != np.int32:
            raise TypeError("key.dtype should be int32, got %s" % key.dtype)
        if positive:
            return _murmurhash3_bytes_array_u32(key.ravel(), seed).reshape(key.shape)
        else:
            return _murmurhash3_bytes_array_s32(key.ravel(), seed).reshape(key.shape)
    else:
        raise TypeError(
            "key %r with type %s is not supported. "
            "Explicit conversion to bytes is required" % (key, type(key)))

54.10.1.4 源码路径:sklearn/utils/_sorting.pyx - dual_swapsimultaneous_sort(第 1‑100 行)

# 第 54 章 —— file: sklearn/utils/_sorting.pyx
# 第 54 章 —— line: 1-100
from cython cimport floating

# 第 54 章 —— 同时交换值数组与索引数组的对应位置
cdef inline void dual_swap(
    floating* darr,
    intp_t *iarr,
    intp_t a,
    intp_t b,
) noexcept nogil:
    """Swap values at index a and b in both darr and iarr."""
    cdef floating dtmp = darr[a]
    darr[a] = darr[b]
    darr[b] = dtmp

    cdef intp_t itmp = iarr[a]
    iarr[a] = iarr[b]
    iarr[b] = itmp


# 第 54 章 —— 同时对值数组与索引数组进行快速排序(升序)
cdef int simultaneous_sort(
    floating* values,
    intp_t* indices,
    intp_t size,
) noexcept nogil:
    """
    Perform a recursive quicksort on the values array as to sort them ascendingly.
    This simultaneously performs the swaps on both the values and the indices arrays.
    """
    cdef:
        intp_t pivot_idx, i, store_idx
        floating pivot_val

    # 小数组:硬编码排序网络,避免递归开销
    if size <= 1:
        pass
    elif size == 2:
        if values[0] > values[1]:
            dual_swap(values, indices, 0, 1)
    elif size == 3:
        if values[0] > values[1]:
            dual_swap(values, indices, 0, 1)
        if values[1] > values[2]:
            dual_swap(values, indices, 1, 2)
            if values[0] > values[1]:
                dual_swap(values, indices, 0, 1)
    else:
        # 中位数取三法(median-of-three)选择枢轴
        pivot_idx = size // 2
        if values[0] > values[size - 1]:
            dual_swap(values, indices, 0, size - 1)
        if values[size - 1] > values[pivot_idx]:
            dual_swap(values, indices, size - 1, pivot_idx)
            if values[0] > values[size - 1]:
                dual_swap(values, indices, 0, size - 1)
        pivot_val = values[size - 1]

        # 单向分区(Lomuto 变体):将小于枢轴的元素换到左侧
        store_idx = 0
        for i in range(size - 1):
            if values[i] < pivot_val:
                dual_swap(values, indices, i, store_idx)
                store_idx += 1
        # 将枢轴放到最终位置
        dual_swap(values, indices, store_idx, size - 1)
        pivot_idx = store_idx

        # 递归排序左右子数组
        if pivot_idx > 1:
            simultaneous_sort(values, indices, pivot_idx)
        if pivot_idx + 2 < size:
            simultaneous_sort(values + pivot_idx + 1,
                              indices + pivot_idx + 1,
                              size - pivot_idx - 1)
    return 0

54.10.1.5 源码路径:sklearn/utils/_heap.pyx - heap_push(第 1‑80 行)

# 第 54 章 —— file: sklearn/utils/_heap.pyx
# 第 54 章 —— line: 1-80
from cython cimport floating
from sklearn.utils._typedefs cimport intp_t


cdef inline int heap_push(
    floating* values,
    intp_t* indices,
    intp_t size,
    floating val,
    intp_t val_idx,
) noexcept nogil:
    """Push a tuple (val, val_idx) onto a fixed-size max-heap."""
    cdef:
        intp_t current_idx, left_child_idx, right_child_idx, swap_idx

    # 若新值比堆顶还大,则不入堆(固定大小最大堆保留最小 k 个值)
    if val >= values[0]:
        return 0

    # 将新值置于堆顶
    values[0] = val
    indices[0] = val_idx

    # 下沉:沿较大子节点向下交换,直至堆序恢复
    current_idx = 0
    while True:
        left_child_idx = 2 * current_idx + 1
        right_child_idx = left_child_idx + 1

        if left_child_idx >= size:
            break
        elif right_child_idx >= size:
            if values[left_child_idx] > val:
                swap_idx = left_child_idx
            else:
                break
        elif values[left_child_idx] >= values[right_child_idx]:
            if val < values[left_child_idx]:
                swap_idx = left_child_idx
            else:
                break
        else:
            if val < values[right_child_idx]:
                swap_idx = right_child_idx
            else:
                break

        values[current_idx] = values[swap_idx]
        indices[current_idx] = indices[swap_idx]

        current_idx = swap_idx

    values[current_idx] = val
    indices[current_idx] = val_idx

    return 0

54.10.1.6 源码路径:sklearn/utils/_sorting.pxd_heap.pxd - 声明(第 1‑20 行)

# 第 54 章 —— file: sklearn/utils/_sorting.pxd
# 第 54 章 —— line: 1-20
from sklearn.utils._typedefs cimport intp_t
from cython cimport floating

cdef int simultaneous_sort(
    floating *dist,
    intp_t *idx,
    intp_t size,
) noexcept nogil
# 第 54 章 —— file: sklearn/utils/_heap.pxd
# 第 54 章 —— line: 1-15
from cython cimport floating
from sklearn.utils._typedefs cimport intp_t

cdef int heap_push(
    floating* values,
    intp_t* indices,
    intp_t size,
    floating val,
    intp_t val_idx,
) noexcept nogil

这些实现均采用 Cython + nogil,在多线程环境下不会持有 GIL,从而在 Joblib / ThreadPool 并行中实现 线性扩展

54.10.1.7 设计中的取舍

为什么在 simultaneous_sort 中使用单向分区(Lomuto 分区变体)而不是经典的双向分区(Hoare 分区)? 单向分区在结合 SoA 布局时实现更简单:只需维护一个 store_idx 指针,且在值‑索引同步交换时不需要额外的判断来处理交叉指针;虽然理论上交换次数可能略多,但在实际内存访问模式和分支预测上更友好,尤其是在小数组或部分有序数据上表现更佳。

heap_push 中,为什么选择最大堆而不是最小堆来维护 KNN 的最近邻? 使用最大堆可以在常数时间内访问当前堆中的最大距离(即堆顶),当新来的距离小于该最大值时才进行替换;这样可以高效地保持堆中存储的是当前遇到的 k 最小距离,避免每次插入都需要遍历整个堆来找到最大值。

54.10.1.8 思考题

  • MurmurHash3 的 fmix 函数中,为何使用两次 16 位旋转异或和两次乘法常数?

    答案:这一序列(异或右移、乘常数、异或右移、乘常数、异或右移)构成了强的雪崩效应:每个输入位的变化都会通过多次非线性混合影响到输出的所有位,使得哈希值对输入的微小变化极为敏感,从而降低冲突概率。这是 MurmurHash3 保证分布均匀性的关键。

  • simultaneous_sort 的基例中,为什么对长度为 2 和 3 的数组使用硬编码的交换网络而不是直接进入递归?

    答案:硬编码的 2、3 元素排序网络可以在无分支、无循环开销的情况下完成排序,对于这些极小规模的子问题,避免函数调用和栈帧开销,从而在递归底层获得显著的性能提升。

54.10.1.9 架构图

flowchart TD A[输入数组] --> B{长度 <= 3?} B -->|是| C[硬编码排序网络] B -->|否| D[median-of-three 选枢] D --> E[单向分区 Lomuto 变体] E --> F[dual_swap 同步交换值与索引] F --> G[递归排序左右子数组] C --> H[返回排序结果] G --> H

54.11 快速字典与向量转换 —— C++ STL 与 NumPy 的「零拷贝桥梁」

54.11.1 关键点

  • IntFloatDict:底层使用 std::map<intp_t, float64_t>(红黑树)实现 O(log n) 查找、插入、遍历。

    • to_arrays / _to_arrays 将 C++ map 内容一次性拷贝到预分配好的 NumPy 数组,保证 Python 迭代 仍然是 NumPy‑level 快速。

    • argmin 在 C++ 层遍历一次完成最小值搜索,避免 Python 循环。

  • StdVectorSentinel:通过 PyArray_SimpleNewFromDatastd::vector 的内部指针直接包装成 NumPy ndarray,零拷贝

    • StdVectorSentinel 通过 Py_INCREF 成为 ndarray 的 base 对象,保证在 ndarray 被销毁前 StdVectorSentinel 的析构函数保持 vector 有效。

    • vector_to_nd_array 为统一入口,使用 fused type vector_typed 支持 float64_tintp_tint32_tint64_t 四种后端。

54.11.1.1 源码路径:sklearn/utils/_fast_dict.pyx - IntFloatDict.__init__to_arrays(第 30‑180 行)

# 第 54 章 —— file: sklearn/utils/_fast_dict.pyx
# 第 54 章 —— line: 30-180
cdef class IntFloatDict:
    """使用 std::map 作为底层存储的 int → float 字典。"""

    def __init__(
        self,
        intp_t[:] keys,
        float64_t[:] values,
    ):
        # 构造时遍历 keys/values 一次性写入 C++ map
        cdef int i
        cdef int size = values.size
        for i in range(size):
            self.my_map[keys[i]] = values[i]

    def __len__(self):
        return self.my_map.size()

    def __getitem__(self, int key):
        # O(log n) 查找
        cdef cpp_map[intp_t, float64_t].iterator it = self.my_map.find(key)
        if it == self.my_map.end():
            raise KeyError('%i' % key)
        return deref(it).second

    def __setitem__(self, int key, float value):
        self.my_map[key] = value

    def __iter__(self):
        # 通过 _to_arrays 将 map 内容拷贝到 NumPy 数组再迭代
        cdef int size = self.my_map.size()
        cdef intp_t [:] keys = np.empty(size, dtype=np.intp)
        cdef float64_t [:] values = np.empty(size, dtype=np.float64)
        self._to_arrays(keys, values)
        cdef int idx
        cdef intp_t key
        cdef float64_t value
        for idx in range(size):
            key = keys[idx]
            value = values[idx]
            yield key, value

    def to_arrays(self):
        """Return the key, value representation of the IntFloatDict object."""
        cdef int size = self.my_map.size()
        keys = np.empty(size, dtype=np.intp)
        values = np.empty(size, dtype=np.float64)
        self._to_arrays(keys, values)
        return keys, values

    cdef _to_arrays(self, intp_t [:] keys, float64_t [:] values):
        # 将 map 写入预分配的 NumPy 数组,避免 Python 端再 malloc
        cdef cpp_map[intp_t, float64_t].iterator it = self.my_map.begin()
        cdef cpp_map[intp_t, float64_t].iterator end = self.my_map.end()
        cdef int index = 0
        while it != end:
            keys[index] = deref(it).first
            values[index] = deref(it).second
            inc(it)
            index += 1

    def update(self, IntFloatDict other):
        # 将另一个 map 的所有元素合并进来
        cdef cpp_map[intp_t, float64_t].iterator it = other.my_map.begin()
        cdef cpp_map[intp_t, float64_t].iterator end = other.my_map.end()
        while it != end:
            self.my_map[deref(it).first] = deref(it).second
            inc(it)

    def copy(self):
        cdef IntFloatDict out_obj = IntFloatDict.__new__(IntFloatDict)
        # C++ map 的赋值运算符即为深拷贝
        out_obj.my_map = self.my_map
        return out_obj

    def append(self, intp_t key, float64_t value):
        cdef pair[intp_t, float64_t] args
        args.first = key
        args.second = value
        self.my_map.insert(args)


###############################################################################
# 第 54 章 —— 在 dict 上的 C++ 层快速操作

def argmin(IntFloatDict d):
    # 在 C++ 层一次遍历完成最小值搜索,避免 Python 循环开销
    cdef cpp_map[intp_t, float64_t].iterator it = d.my_map.begin()
    cdef cpp_map[intp_t, float64_t].iterator end = d.my_map.end()
    cdef intp_t min_key = -1
    cdef float64_t min_value = np.inf
    while it != end:
        if deref(it).second < min_value:
            min_value = deref(it).second
            min_key = deref(it).first
        inc(it)
    return min_key, min_value

54.11.1.2 源码路径:sklearn/utils/_fast_dict.pxd - 声明(第 1‑30 行)

# 第 54 章 —— file: sklearn/utils/_fast_dict.pxd
# 第 54 章 —— line: 1-30
from libcpp.map cimport map as cpp_map
from sklearn.utils._typedefs cimport float64_t, intp_t

# 第 54 章 —— 暴露给 Python 的 cdef 类
cdef class IntFloatDict:
    # 底层 C++ 容器
    cdef cpp_map[intp_t, float64_t] my_map
    # 内部方法:将 map 内容写入预分配 NumPy 数组
    cdef _to_arrays(self, intp_t [:] keys, float64_t [:] values)

54.11.1.3 源码路径:sklearn/utils/_vector_sentinel.pyx - vector_to_nd_array 与各 StdVectorSentinel*(第 1‑160 行)

# 第 54 章 —— file: sklearn/utils/_vector_sentinel.pyx
# 第 54 章 —— line: 1-160
from cython.operator cimport dereference as deref
from cpython.ref cimport Py_INCREF
cimport numpy as cnp

cnp.import_array()


# 第 54 章 —— 根据 fused 类型选择对应的 StdVectorSentinel 子类
cdef StdVectorSentinel _create_sentinel(vector_typed * vect_ptr):
    if vector_typed is vector[float64_t]:
        return StdVectorSentinelFloat64.create_for(vect_ptr)
    elif vector_typed is vector[int32_t]:
        return StdVectorSentinelInt32.create_for(vect_ptr)
    elif vector_typed is vector[int64_t]:
        return StdVectorSentinelInt64.create_for(vect_ptr)
    else:  # intp_t
        return StdVectorSentinelIntP.create_for(vect_ptr)


# 第 54 章 —— 基类:所有具体类型的 sentinel 都继承自它
cdef class StdVectorSentinel:
    """Wraps a reference to a vector which will be deallocated with this object."""
    cdef void* get_data(self):
        """Return pointer to data."""

    cdef int get_typenum(self):
        """Get typenum for PyArray_SimpleNewFromData."""


# 第 54 章 —— 具体类型:float64
cdef class StdVectorSentinelFloat64(StdVectorSentinel):
    cdef vector[float64_t] vec

    @staticmethod
    cdef StdVectorSentinel create_for(vector[float64_t] * vect_ptr):
        cdef StdVectorSentinelFloat64 sentinel = StdVectorSentinelFloat64.__new__(StdVectorSentinelFloat64)
        # 转移所有权:swap 后原 vector 变空,sentinel 持有数据
        sentinel.vec.swap(deref(vect_ptr))
        return sentinel

    cdef void* get_data(self):
        return self.vec.data()

    cdef int get_typenum(self):
        return cnp.NPY_FLOAT64


# 第 54 章 —— 具体类型:intp
cdef class StdVectorSentinelIntP(StdVectorSentinel):
    cdef vector[intp_t] vec

    @staticmethod
    cdef StdVectorSentinel create_for(vector[intp_t] * vect_ptr):
        cdef StdVectorSentinelIntP sentinel = StdVectorSentinelIntP.__new__(StdVectorSentinelIntP)
        sentinel.vec.swap(deref(vect_ptr))
        return sentinel

    cdef void* get_data(self):
        return self.vec.data()

    cdef int get_typenum(self):
        return cnp.NPY_INTP


# 第 54 章 —— 具体类型:int32
cdef class StdVectorSentinelInt32(StdVectorSentinel):
    cdef vector[int32_t] vec

    @staticmethod
    cdef StdVectorSentinel create_for(vector[int32_t] * vect_ptr):
        cdef StdVectorSentinelInt32 sentinel = StdVectorSentinelInt32.__new__(StdVectorSentinelInt32)
        sentinel.vec.swap(deref(vect_ptr))
        return sentinel

    cdef void* get_data(self):
        return self.vec.data()

    cdef int get_typenum(self):
        return cnp.NPY_INT32


# 第 54 章 —— 具体类型:int64
cdef class StdVectorSentinelInt64(StdVectorSentinel):
    cdef vector[int64_t] vec

    @staticmethod
    cdef StdVectorSentinel create_for(vector[int64_t] * vect_ptr):
        cdef StdVectorSentinelInt64 sentinel = StdVectorSentinelInt64.__new__(StdVectorSentinelInt64)
        sentinel.vec.swap(deref(vect_ptr))
        return sentinel

    cdef void* get_data(self):
        return self.vec.data()

    cdef int get_typenum(self):
        return cnp.NPY_INT64


# 第 54 章 —— 零拷贝入口:将 std::vector* 包装为 NumPy ndarray
cdef cnp.ndarray vector_to_nd_array(vector_typed * vect_ptr):
    cdef:
        cnp.npy_intp size = deref(vect_ptr).size()
        # 创建一个对应类型的 sentinel 并接管 vector 所有权
        StdVectorSentinel sentinel = _create_sentinel(vect_ptr)
        cnp.ndarray arr = cnp.PyArray_SimpleNewFromData(
            1, &size, sentinel.get_typenum(), sentinel.get_data())

    # 让 ndarray 持有 sentinel 的引用,防止 vector 被提前析构
    # PyArray_SetBaseObject 会窃取引用,所以需先 Py_INCREF
    Py_INCREF(sentinel)
    cnp.PyArray_SetBaseObject(arr, sentinel)
    return arr

54.11.1.4 源码路径:sklearn/utils/_vector_sentinel.pxd - 声明(第 1‑20 行)

# 第 54 章 —— file: sklearn/utils/_vector_sentinel.pxd
# 第 54 章 —— line: 1-20
cimport numpy as cnp
from libcpp.vector cimport vector
from sklearn.utils._typedefs cimport intp_t, float64_t, int32_t, int64_t

# 第 54 章 —— 融合类型:支持四种元素类型的 std::vector
ctypedef fused vector_typed:
    vector[float64_t]
    vector[intp_t]
    vector[int32_t]
    vector[int64_t]

# 第 54 章 —— 零拷贝入口声明
cdef cnp.ndarray vector_to_nd_array(vector_typed * vect_ptr)

通过 StdVectorSentinelstd::vector 与 NumPy 实现 零拷贝共享,在特征选择(SelectFromModel)或稀疏矩阵 列抽取 等场景中可直接返回 np.ndarray 而无需拷贝。

54.11.1.5 设计中的取舍

为什么在 StdVectorSentinel 中使用 PyArray_SetBaseObject 而不是直接让 ndarray 持有 vector 的原始指针而不增加引用计数? 如果不增加引用计数,当 vector 在 C++ 端被析构时,ndarray 仍然持有悬空指针,导致未定义行为;通过 Py_INCREF 并设置 base,确保在 ndarray 被垃圾回收前 StdVectorSentinel 的生命周期被延长,从而保证内存安全。

_create_sentinel 中为何使用 if/elif 链而不是 matchtype() 判断? vector_typed 是 Cython fused 类型,编译期为每个具体类型生成一份 _create_sentinel,因此在生成的 C 代码中 vector_typed is vector[float64_t] 等判断会被常量折叠为 True/False,运行时开销为零;而 Python 层的 type()match 会带来不必要的运行时分支与开销。

54.11.1.6 思考题

  • std::vector 在 C++ 端发生扩容,NumPy 数组的指针会否失效?如何避免?

    答案:是的,扩容会导致内存重新分配,原始指针失效。为避免此问题,StdVectorSentinel 仅在 不发生扩容 的前提下才允许零拷贝共享;在需要动态增长的场景,应先预分配足够大小的 vector,或在扩容后重新创建 ndarray 视图。

  • IntFloatDict.argmin 中,为什么不直接返回最小值,而是同时返回键和值?

    答案:在实际使用场景(如 KNN 中查询最近邻的索引),往往需要同时知道最小距离对应的样本索引;返回键‑值对可以避免在 Python 层再次查找字典,从而提升效率。

54.11.1.7 架构图

flowchart TD A[C++ std::map] --> B[IntFloatDict] A --> C[StdVectorSentinel] B --> D[to_arrays → NumPy 数组] C --> E[vector_to_nd_array → NumPy 数组] D --> F[Python 迭代] E --> F

54.12 通用类型定义体系 —— 跨平台数值代码的「类型契约」

54.12.1 关键点

  • _typedefs.pxd 把常用 C 类型统一为 intp_tfloat64_tint32_t 等,屏蔽平台差异(如 Windows long 与 Linux long long 不同)。

  • 融合类型 (floating) 在 BLAS、排序、堆实现中使用,仅需一套源码即可生成 floatdouble 两套函数。

  • 测试桩 testing_make_array_from_typed_val_typedefs.pyx)通过 Cython 内存视图 把标量包装为 NumPy array,确保 类型映射intp_tnp.intp)在单元测试中得到验证。

54.12.1.1 源码路径:sklearn/utils/_typedefs.pxd - 类型别名声明(第 1‑50 行)

# 第 54 章 —— file: sklearn/utils/_typedefs.pxd
# 第 54 章 —— line: 1-50
ctypedef unsigned char uint8_t
ctypedef unsigned int uint32_t
ctypedef unsigned long long uint64_t
# 第 54 章 —— 与 numpy.intp 对齐的索引类型,跨平台安全
ctypedef Py_ssize_t intp_t
ctypedef float float32_t
ctypedef double float64_t
# 第 54 章 —— 稀疏矩阵与序列化场景优先使用固定宽度整型
ctypedef signed char int8_t
ctypedef signed int int32_t
ctypedef signed long long int64_t

54.12.1.2 源码路径:sklearn/utils/_typedefs.pyx - 测试桩实现(全文)

# 第 54 章 —— file: sklearn/utils/_typedefs.pyx
# 第 54 章 —— line: 1-30
import numpy as np

# 第 54 章 —— 测试桩融合类型:覆盖所有 _typedefs.pxd 中声明的数值类型
ctypedef fused testing_type_t:
    float32_t
    float64_t
    int8_t
    int32_t
    int64_t
    intp_t
    uint8_t
    uint32_t
    uint64_t


def testing_make_array_from_typed_val(testing_type_t val):
    """将标量包装为 NumPy ndarray,用于验证 Cython 类型与 numpy.dtype 映射正确性。"""
    # 利用内存视图将栈上标量暴露为 1 元素数组
    cdef testing_type_t[:] val_view = <testing_type_t[:1]>&val
    return np.asarray(val_view)

通过这种 "一次声明,多处复用" 的方式,scikit‑learn 在 Cython、C++、NumPy、Array API 四个层面保持类型一致性,极大降低跨平台 bug 的概率。

54.12.1.3 融合类型在其他模块中的复用示例

  • _cython_blas.pyx:所有 BLAS 调用都使用 cdef floating ...,编译时自动生成 float 与 double 双版本。

  • _sorting.pyxdual_swapsimultaneous_sort 均以 floating* 为参数类型,与 _typedefs.pyxtesting_type_t 同样属于 Cython fused type,但更轻量,只覆盖两种浮点精度。

  • _heap.pyxheap_push 使用 floating* valuesintp_t* indices,保证与 _sorting.pyx 在 KNN 查询中协同工作。

54.12.1.4 设计中的取舍

为什么选择 Py_ssize_t 作为 intp_t 的底层类型,而不是直接使用 int64_tint32_t Py_ssize_t 是 Python C API 中用于表示容器大小和索引的有符号整数类型,它在不同平台上与 intptr_t 对齐,能够安全地容纳任何 Python 对象的大小或索引;使用它可以确保在 32 位和 64 位平台上都不会因索引溢出而失败,同时在 64 位平台上仍能提供足够的范围。

在融合类型实现中,为何不使用 C++ 模板(templates)而选择 Cython 的 fused 类型? Cython 的 fused 类型在编译时生成特定类型的版本(如 floatdouble),但在使用上保持类似泛型的语法,避免了 C++ 模板可能导致的编译时间膨胀和代码体积增加(模板膨胀),同时仍能提供零运行时开销的多态性,且更易于与 NumPy 内存视图和 Cython 隔离编译流程集成。

54.12.1.5 思考题

  • 为什么 _typedefs.pxd 中同时存在 intp_t(平台依赖)与 int32_t/int64_t(固定宽度)?

    答案intp_t 与 Python 解释器和 NumPy 的索引语义对齐,适合临时索引和大小计算;而 int32_t/int64_t 适合作为 estimator 的持久属性,避免在跨平台 pickle/反序列化时因 bitness 差异导致不兼容;两者分工明确,按需选用。

54.12.1.6 架构图

flowchart TD A[平台差异] --> B[_typedefs.pxd] B --> C[统一类型别名 intp_t/float64_t 等] C --> D[Cython 融合类型 floating] D --> E[BLAS 排序 堆等] E --> F[跨平台复用]

54.13 动手练习

  1. 元数据路由调度逻辑实战。请打开 sklearn/utils/metadata_routing.pyMetadataRouter.route_params(约 200‑350 行)。思考:如果子估计器既未声明 request_sample_weight 也未声明 request_groups,父元估计器传递这些参数会怎样?同时请探索 enable_metadata_routing 上下文管理器如何实现全局开关而不破坏线程安全。

  2. 数组 API 跨后端迁移解析。阅读 sklearn/utils/_array_api.pymove_to(约 280‑350 行)与 _convert_to_numpy(约 620‑640 行)。请解释 DLPack 零拷贝的优势以及为何非 NumPy 后端之间必须经 NumPy 中转。在 _logsumexp(约 800‑860 行)中,说明非 NumPy 后端为何手动实现 log1p(s)+log(m)+shift

  3. OpenMP 线程数计算与嵌套并行控制。阅读 sklearn/utils/_openmp_helpers.pyx_openmp_effective_n_threads(30‑80 行)以及 sklearn/utils/parallel.py_threadpool_controller_decorator(135‑150 行)。请说明为何模块级缓存 _CPU_COUNTS 可以假设硬件拓扑在进程生命周期内不变。

  4. MurmurHash3 与同时排序的 SIMD 友好设计对比。检视 sklearn/utils/src/MurmurHash3.cppMurmurHash3_x86_32(100‑150 行)与 sklearn/utils/_sorting.pyxsimultaneous_sort(30‑100 行)。请解释 fmix 如何实现雪崩效应,dual_swap 如何保证值‑索引原子交换。

  5. C++ 容器到 NumPy 数组的零拷贝生命周期管理。阅读 sklearn/utils/_vector_sentinel.pyxvector_to_nd_array(130‑155 行)与 StdVectorSentinel*::create_for(40‑55 行)。请思考:若 std::vector 在 C++ 端发生扩容,NumPy 数组的指针会否失效?如何避免?

54.14 本章小结

这一章我们从元数据路由的请求‑声明‑路由机制说起,依次梳理了目标类型判定、类别权重与样本加权、数组 API 兼容层、Cython‑BLAS 高性能内核、OpenMP 动态线程调度、MurmurHash3 快速哈希、同时排序/堆、C++ STL 与 NumPy 零拷贝桥梁,以及跨平台类型定义体系。每个主题都从源码出发,逐行注释关键实现,并剖析了背后的设计动机与性能取舍。

以下表格对上述核心概念做了精炼总结。

| 概念 | 解释 |

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

| MetadataRequest / MetadataRouter | 实现「请求‑声明‑路由」三层协议,支持在 Pipeline、Meta‑Estimator 等复合模型中透明传递 sample_weightgroups 等元数据。 |

| type_of_target | 通过维度、稀疏性、dtype 检查,精确判定目标是 binarymulticlassmultilabel‑indicatorcontinuous 等七种类型。 |

| unique_labels | 对任意输入抽取有序唯一标签,统一跨 NumPy、稀疏、Array‑API,支撑标签编码、混淆矩阵等功能。 |

| is_multilabel | 判断 2D 数组是否为标签指示矩阵,是 type_of_target 判定 multilabel-indicator 的关键子函数。 |

| _check_partial_fit_first_call | 校验 partial_fit 首次调用的 classes 参数一致性,强制首次调用必须显式提供所有类别。 |

| _ovr_decision_function | 将 OvO 二分类决策平滑聚合为 OvR 决策矩阵,提供连续置信度,适用于 AUC、校准等评估。 |

| compute_class_weight / compute_sample_weight | "砝码校正"公式在平衡类不均时自动反比例加权,支持样本权重、子采样以及多输出场景。 |

| _cython_blas | 融合类型 floating + GIL 释放,实现单套源码支持 float/double,自动行/列主序转置,实现 BLAS 零拷贝。 |

| OpenMP 并行与线程池 | _openmp_effective_n_threads 动态决定线程数,_threadpool_controller_decorator 在函数层面安全限制并行度,防止嵌套过度订阅。 |

| MurmurHash3 | 高效非加密哈希,fmix 雪崩,Cython 多态入口支持标量、bytes、ndarray 批处理。 |

| simultaneous_sort / heap_push | 双数组原位快速排序 + 固定大小最大堆,实现 KNN 近邻快速筛选,使用 SoA 布局提升 SIMD 与缓存局部性。 |

| IntFloatDict / StdVectorSentinel | C++ std::mapstd::vector 与 NumPy 零拷贝桥梁,vector_to_nd_array 通过 PyArray_SetBaseObject 管理生命周期。 |

| _typedefs | 统一跨平台类型别名,融合类型在 BLAS、排序、堆中复用,testing_make_array_from_typed_val 确保映射正确。 |

本章我们以「请求‑声明‑路由」为主线,贯穿 scikit-learn utils 底层加速与兼容层的方方面面:从元数据路由,到目标类型判定,到权重校正,再到跨后端数组、BLAS、线程池、哈希、排序与零拷贝容器,最后统一到类型契约。希望读者在阅读源码时能够借助这套"通用机床",快速定位关键实现并理解其设计精髓。

54.14.1 下一章预告

在第 55 章 "utils 数据容器与输出适配 —— 搭建'多格式互通的立交桥'" 中,我们将继续探讨 is_pandas_dfis_polars_df 等容器检测,_SetOutputMixinContainerAdaptersManager 如何让 transformers 在 NumPy、pandas、polars 之间自由切换,并深入输出格式的序列化与兼容策略。敬请期待!

第 55 章 —— utils 索引、分块与随机采样 —— 驾驭“数据切片的精准手术刀”

55.1 学习目标

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

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

  • 理解安全索引在多种数据容器(NumPy、稀疏矩阵、pandas、Polars、PyArrow、列表、DataFrame 交换协议)上的统一实现机制

  • 掌握数据分块策略(固定大小、均匀切片、内存感知、流式聚合)及其在大规模数据处理中的应用

  • 深入掌握四种无放回随机采样算法的时空复杂度权衡与自动选择策略

  • 理解缺失值检测与布尔掩码在稠密/稀疏/对象数组中的安全转换原理

  • 能阅读并修改重采样工具以支持自定义分层、加权采样逻辑

  • 掌握底层 XorShift 随机数生成器的跨平台一致性设计

55.2 生活类比

想象 sklearn.utils 是一个数据手术室的精密器械盘

  • _safe_indexing = 万能手术钳:无论组织是玻璃切片、冰冻切片、石蜡包埋块,还是数字切片,都能精准夹取指定层/区域,从不碰碎组织。

  • gen_batches / get_chunk_n_rows = 智能切片机:按医生手术托盘大小(working_memory)自动将大块组织切成适合显微镜视野的薄片,最后一片太薄时自动并入前一片,保证手术顺畅。

  • sample_without_replacement = 公平抽签箱:四种抽签策略(少量抽签用集合查重、常规用全排列切片、接近全量用蓄水池算法、显式池交换)自动根据抽签比例切换,保证每个细胞核公平被选中。

  • our_rand_r (XorShift) = 一次性无菌随机数发生器:无需共享全局随机种子池,每个线程自带种子,跨平台(Windows/Linux)行为完全一致,无污染。

  • safe_mask / is_scalar_nan = 荧光探针与显微镜滤镜:精准标记出坏死细胞,且能区分真空泡、染色伪影与真正的缺失值,转换为整数坐标供下游激光切割使用。

  • resample / shuffle = 组织扩增与随机排布器:支持有放回扩增、无放回打乱、分层保比例、加权按概率,生成的新组织块保持原始统计特性,供交叉验证重复实验。

55.3 源码地图

sklearn/utils/_indexing.py
├── _safe_indexing()               # 统一行/列索引入口,多容器分发
├── _determine_key_type()          # 索引键类型推断 (int/str/bool/slice)
├── _get_column_indices()          # 列名/掩码/整数索引转整数列索引
├── _get_column_indices_interchange() # DataFrame 交换协议列索引
├── _get_column_indices_for_bool_or_int() # 布尔/整数键转列索引
├── _array_indexing()              # NumPy/稀疏/Array API 通用索引
├── _pandas_indexing()              # pandas DataFrame/Series 索引
├── _polars_indexing()              # Polars DataFrame/Series 索引
├── _pyarrow_indexing()             # PyArrow Table/RecordBatch/ChunkedArray 索引
├── _list_indexing()                # Python 列表索引
├── _safe_assign()                  # 安全原地赋值 (抑制 pandas FutureWarning)
├── resample()                      # 核心重采样 (Bootstrap/分层/加权)
└── shuffle()                       # 无放回随机排列便捷函数

sklearn/utils/_chunking.py
├── gen_batches()                   # 固定 batch_size 生成 slice (支持 min_batch_size)
├── gen_even_slices()               # 均匀切分 n_packs 片 (稀疏安全 n_samples 参数)
├── chunk_generator()               # 迭代器流式聚合为固定大小列表
└── get_chunk_n_rows()              # 基于 working_memory 计算可处理行数

sklearn/utils/_random.pyx
├── _sample_without_replacement()                # 自动模式选择四算法
├── _sample_without_replacement_check_input()    # 输入合法性校验
├── _sample_without_replacement_with_tracking_selection() # 集合去重法 (ratio<0.01)
├── _sample_without_replacement_with_pool()      # 池交换法 (method='pool')
├── _sample_without_replacement_with_reservoir_sampling() # 蓄水池算法 (ratio>0.99)
├── our_rand_r                                 # 线程安全 XorShift 生成器 (替代 rand_r)
└── _our_rand_r_py                              # Python 测试包装器

55.4 安全索引与列选择 —— 多容器数据的“万能切片器”

_safe_indexing 是整个索引体系的指挥官:它首先判断输入对象的类型,然后把索引任务分发给专属实现。核心流程如下:

  1. 容器类型判断

    • 若对象拥有 iloc 属性(pandas),走 _pandas_indexing

    • 若是 Polars DataFrame/Series,走 _polars_indexing

    • 若是 PyArrow Table/RecordBatch,走 _pyarrow_indexing

    • 若实现 DataFrame 交换协议 (__dataframe__) ,使用 _get_column_indices_interchange 再交给 _array_indexing

    • 否则若拥有 shape(NumPy、稀疏、Array API)走 _array_indexing

    • 其余情况走 _list_indexing

  2. 索引键类型推断:调用 _determine_key_type,统一把整数、字符串、布尔、切片等转为内部标识 'int'、'str'、'bool'、None,确保后续分支可以安全比较。

  3. 列索引统一解析_get_column_indices 负责把 字符串列名 / 布尔掩码 / 整数列表 / 切片 统一转换为 整数列索引列表

    • 对 DataFrame(pandas、Polars、PyArrow)通过 X.columns 或对应协议的 column_names() 找到列位置。

    • 对字符串切片,包含终点(stop+1),保持与 pandas 行为一致。

  4. 数组/稀疏索引_array_indexing 通过 Array API 的 take 实现整数索引;若键是布尔数组则先转为整数索引(Array API 暂不支持布尔索引)。稀疏矩阵在布尔键情况下先转为 NumPy 再索引,以兼容旧 SciPy 行为。

55.4.1 代码实现(sklearn/utils/_indexing.py 第 254‑348 行)

# 第 55 章 —— src/sklearn/utils/_indexing.py (第254-348行)
def _safe_indexing(X, indices, *, axis=0):
    """Return rows, items or columns of X using indices."""
    if indices is None:
        return X

    if axis not in (0, 1):
        raise ValueError(
            "'axis' should be either 0 (to index rows) or 1 (to index "
            " column). Got {} instead.".format(axis)
        )

    # 1️⃣ 推断索引键的数据类型
    indices_dtype = _determine_key_type(indices)

    # 2️⃣ 参数合法性检查(字符串只能用于列选择)
    if axis == 0 and indices_dtype == "str":
        raise ValueError(
            f"String indexing (indices={indices}) is not supported with 'axis=0'. "
            "Did you mean to use axis=1 for column selection?"
        )
    if axis == 1 and isinstance(X, list):
        raise ValueError("axis=1 is not supported for lists")

    # 3️⃣ 根据容器类型分发
    if hasattr(X, "iloc"):                     # 🟢 pandas
        return _pandas_indexing(X, indices, indices_dtype, axis=axis)
    elif is_polars_df_or_series(X):           # 🟢 Polars
        return _polars_indexing(X, indices, indices_dtype, axis=axis)
    elif is_pyarrow_data(X):                  # 🟢 PyArrow
        return _pyarrow_indexing(X, indices, indices_dtype, axis=axis)
    elif _use_interchange_protocol(X):        # 🟢 DataFrame 交换协议
        raise warnings.warn(
            message="A data object with support for the dataframe interchange protocol"
                    "was passed, but scikit-learn does currently not know how to handle this "
                    "kind of data. Some array/list indexing will be tried.",
            category=UserWarning,
        )
    # 4️⃣ 统一走数组或稀疏实现
    if hasattr(X, "shape"):
        return _array_indexing(X, indices, indices_dtype, axis=axis)
    else:
        return _list_indexing(X, indices, indices_dtype)

这段代码实现了 安全索引的调度:先根据 X 的特征挑选合适的子函数,再统一返回切片结果。所有子函数均保持 “不改变原始对象,只返回新视图” 的设计哲学,确保后续变换不产生意外副作用。

55.4.1.1 关键子函数概览

  • _determine_key_type(第170‑252 行):递归检查标量、切片、列表、NumPy/Array API 张量的 dtype,返回统一的字符串标识,确保后续逻辑能够对不同容器使用相同的分支判断。

  • _get_column_indices(第378‑453 行):处理字符串列名、切片、布尔掩码,内部调用 _get_column_indices_for_bool_or_int 完成布尔/整数转整数列表。

  • _array_indexing(第33‑57 行):兼容 NumPy、SciPy 稀疏、Array API;对布尔键做整数转化,以兼容当前 Array API 规范。

55.4.2 数据流图(索引调度流程)

graph TD A[用户调用 _safe_indexing] --> B{X 类型判定} B -->|pandas| C[_pandas_indexing] B -->|polars| D[_polars_indexing] B -->|pyarrow| E[_pyarrow_indexing] B -->|DataFrame 协议| F[_use_interchange_protocol] B -->|NumPy/稀疏/ArrayAPI| G[_array_indexing] B -->|list| H[_list_indexing] C --> I[返回切片 (安全拷贝)] D --> I E --> I G --> I H --> I

55.5 数据分块与批处理 —— 按内存预算的“切蛋糕”策略

在处理海量数据时,一次性装入内存往往不可行sklearn.utils._chunking 提供了四个核心工具:

| 函数 | 作用 | 关键实现 |

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

| gen_batches | 产生固定 batch_size 的 Slice,支持 min_batch_size 防止尾批过小 | 循环累加 start,若 end+min_batch_size > n 则跳过产生过小的切片 |

| gen_even_slices | 将 n 均匀切分为 n_packs 片,前 n % n_packs 片多 1 条目,支持稀疏安全 n_samples 参数 | 依据整数除法与余数分配每片大小 |

| chunk_generator | 把任意迭代器按 chunksize 聚合为列表,适合流式读取 | 使用 itertools.islice 持续取块 |

| get_chunk_n_rows | 根据 工作内存(MiB)和单行字节数估算可处理行数并返回,上限可由 max_n_rows 限制 | chunk_n_rows = int(working_memory * 2**20 // row_bytes),不足 1 行时抛警并强制返回 1 |

55.5.1 代码实现(gen_batches 第 32‑76 行)

# 第 55 章 —— src/sklearn/utils/_chunking.py (第32-76行)
def gen_batches(n, batch_size, *, min_batch_size=0):
    """Generator to create slices containing `batch_size` elements from 0 to `n`."""
    start = 0
    for _ in range(int(n // batch_size)):
        end = start + batch_size
        # 若剩余元素不足 min_batch_size,则直接跳过当前切片,合并到后面的 batch
        if end + min_batch_size > n:
            continue
        yield slice(start, end)
        start = end
    # 处理最后的残余切片
    if start < n:
        yield slice(start, n)

设计思路

  • min_batch_size 让我们可以避免产生只有 1‑2 条样本的微小 batch(在交叉验证或 SGD 中会导致梯度噪声剧增)。
  • min_batch_size=0 时表现为标准等距切片,保持向后兼容。

55.5.2 代码实现(gen_even_slices 第 78‑124 行)

# 第 55 章 —— src/sklearn/utils/_chunking.py (第78-124行)
def gen_even_slices(n, n_packs, *, n_samples=None):
    """Generator to create `n_packs` evenly spaced slices going up to `n`."""
    start = 0
    for pack_num in range(n_packs):
        this_n = n // n_packs
        if pack_num < n % n_packs:
            this_n += 1               # 前面几块多 1 条
        if this_n > 0:
            end = start + this_n
            if n_samples is not None:  # 稀疏矩阵安全截断
                end = min(n_samples, end)
            yield slice(start, end, None)
            start = end

55.5.3 内存感知分块(get_chunk_n_rows 第 126‑165 行)

# 第 55 章 —— src/sklearn/utils/_chunking.py (第126-165行)
def get_chunk_n_rows(row_bytes, *, max_n_rows=None, working_memory=None):
    """Calculate how many rows can be processed within `working_memory`."""
    if working_memory is None:
        working_memory = get_config()["working_memory"]  # 默认 1024 MiB

    # 计算每块最多能容纳的行数
    chunk_n_rows = int(working_memory * (2**20) // row_bytes)
    if max_n_rows is not None:
        chunk_n_rows = min(chunk_n_rows, max_n_rows)

    # 若单行所需内存已超预算,强制返回 1 并报警
    if chunk_n_rows < 1:
        warnings.warn(
            "Could not adhere to working_memory config. "
            f"Currently %.0fMiB, %.0fMiB required."
            % (working_memory, np.ceil(row_bytes * 2**-20))
        )
        chunk_n_rows = 1
    return chunk_n_rows

55.5.4 数据流图(分块过程)

graph TD A[用户提供 n, batch_size] --> B[gen_batches] A --> C[gen_even_slices] B --> D[产生 Slice 列表] C --> D D --> E[每个 Slice 交给算法 (e.g., SGD, KMeans) 逐块处理] E --> F[get_chunk_n_rows] --> G[依据 working_memory 计算每块行数] F --> H[若不足 1 行,发出警告并返回 1]

55.6 随机采样与洗牌 —— 算法级的“公平抽签机”

sklearn.utils._random 实现了 四种无放回抽样算法,并通过 sample_without_replacementmethod="auto" 自动选择最合适的实现。

| 方法 | 适用比例 | 时间复杂度 | 空间复杂度 | 备注 |

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

| tracking_selection (_sample_without_replacement_with_tracking_selection) | ratio < 0.01(极小抽样率) | O(n_samples * expected_trials),期望常数因 ratio 极小而接近 O(n_samples) | O(n_samples)(Python set) | 使用 Python set 检查冲突,适合抽样数远小于总体。 |

| numpy.permutation(默认路径) | 0.01 ≤ ratio ≤ 0.99 | O(n_population)(一次全排列) | O(n_population)(临时数组) | 最通用、实现最简。 |

| reservoir_sampling (_sample_without_replacement_with_reservoir_sampling) | ratio > 0.99(抽样几乎覆盖全体) | O((n_population - n_samples) + n_samples) | O(n_samples) | 只遍历一次整体,适合“大抽样”。 |

| pool (_sample_without_replacement_with_pool) | 显式 method='pool' | O(n_population + n_samples) | O(n_population + n_samples) | 预先构造完整池,内存占用大,但在 n_samples ≈ n_population 时最快。 |

55.6.1 自动选择逻辑(_sample_without_replacement 第 239‑298 行)

# 第 55 章 —— src/sklearn/utils/_random.pyx (第239-298行)
cdef _sample_without_replacement(default_int n_population,
                                 default_int n_samples,
                                 method="auto",
                                 random_state=None):
    """Sample integers without replacement."""
    _sample_without_replacement_check_input(n_population, n_samples)

    all_methods = ("auto", "tracking_selection", "reservoir_sampling", "pool")
    ratio = <double> n_samples / n_population if n_population != 0.0 else 1.0

    # 1️⃣ 自动模式下,ratio 位于 0.01‑0.99 区间时直接使用 permutation
    if method == "auto" and ratio > 0.01 and ratio < 0.99:
        rng = check_random_state(random_state)
        return rng.permutation(n_population)[:n_samples]

    # 2️⃣ 其余情况依赖 ratio 决定具体实现
    if method == "auto" or method == "tracking_selection":
        if ratio < 0.2:   # 经验阈值 0.2,来源于基准测试
            return _sample_without_replacement_with_tracking_selection(
                n_population, n_samples, random_state)
        else:
            return _sample_without_replacement_with_reservoir_sampling(
                n_population, n_samples, random_state)

    elif method == "reservoir_sampling":
        return _sample_without_replacement_with_reservoir_sampling(
            n_population, n_samples, random_state)

    elif method == "pool":
        return _sample_without_replacement_with_pool(n_population, n_samples,
                                                     random_state)
    else:
        raise ValueError('Expected a method name in %s, got %s. '
                         % (all_methods, method))

为什么 tracking_selection 适合极小采样率?

  • ratio 很小时,冲突概率极低,循环 while j in selected 很快结束,整体时间接近 O(n_samples)
  • 相比之下,permutation 必须生成长度为 n_population 的全排列,耗费不必要的 O(n_population) 时间与内存。

为什么 reservoir_sampling 适合极大采样率?

  • 当抽样数接近总体时,tracking_selection 的冲突概率急剧增大,导致大量重试。
  • Reservoir 方法只遍历一次整体,且只在后期对未抽中的部分进行随机替换,空间只需保存抽样结果 O(n_samples),因此在 ratio > 0.99 时更高效。

55.6.2 XorShift 随机数生成器(our_rand_r 第 14‑30 行)

# 第 55 章 —— src/sklearn/utils/_random.pxd (第14-30行)
cdef inline uint32_t our_rand_r(uint32_t* seed) nogil:
    """Generate a pseudo‑random np.uint32 from a np.uint32 seed"""
    if (seed[0] == 0):
        seed[0] = DEFAULT_SEED          # 防止种子为 0

    seed[0] ^= <uint32_t>(seed[0] << 13)
    seed[0] ^= <uint32_t>(seed[0] >> 17)
    seed[0] ^= <uint32_t>(seed[0] << 5)

    # 取模确保返回值不超过 2^31‑1(Windows 上 RAND_MAX 仅 32767,太小)
    return seed[0] % ((<uint32_t>RAND_R_MAX) + 1)
  • 跨平台一致性RAND_MAX 在 Windows MSVC 只有 32767,远不能满足抽样需求。our_rand_r 使用 31 位上限 (2^31‑1) 并在每次调用后取模,保证 所有平台均产生相同的 0‑2³¹‑1 区间整数

  • 无状态、线程安全:每个线程持有自己的 seed,不共享全局状态,避免竞争。

55.6.3 上层 Python 包装(sklearn/utils/random.py 第 20‑98 行)

# 第 55 章 —— src/sklearn/utils/random.py (第20-98行)
def _random_choice_csc(n_samples, classes, class_probability=None, random_state=None):
    """Generate a sparse random matrix given column class distributions"""
    data = array.array("i")
    indices = array.array("i")
    indptr = array.array("i", [0])

    for j in range(len(classes)):
        # ... (省略类型检查与概率归一化)

        if classes[j].shape[0] > 1:
            # 计算非零类的抽样数
            nnz = int(n_samples * p_nonzero)
            # 使用上面的无放回抽样函数抽取行索引
            ind_sample = sample_without_replacement(
                n_population=n_samples, n_samples=nnz, random_state=random_state
            )
            indices.extend(ind_sample)

            # 依据非零类的归一化概率随机生成具体类别
            classes_ind = np.searchsorted(
                class_probability_nz_norm.cumsum(), rng.uniform(size=nnz)
            )
            data.extend(classes[j][classes_j_nonzero][classes_ind])
        indptr.append(len(indices))

    return sp.csc_matrix((data, indices, indptr), (n_samples, len(classes)), dtype=int)

作用:在多标签/多输出稀疏场景(如 MultiOutputClassifier)中,需要在列方向上随机生成稀疏非零元素,此函数利用 sample_without_replacement 提供的高效无放回抽样,确保每列的非零分布符合预设概率。

55.7 掩码与缺失值检测 —— 数据清洗的“探照灯”

缺失值检测在稠密数组、稀疏矩阵以及对象 dtype(可能混杂 Nonenp.nanpd.NA)之间的实现细节各不相同。sklearn.utils._masksklearn.utils._missing 把这些差异统一为 布尔掩码安全整数索引

55.7.1 标量 NaN 判断(is_scalar_nan 第 12‑43 行)

# 第 55 章 —— src/sklearn/utils/_missing.py (第12-43行)
def is_scalar_nan(x):
    """Test if x is NaN."""
    return (
        not isinstance(x, numbers.Integral)   # 排除整数
        and isinstance(x, numbers.Real)       # 必须是实数(float、np.float64)
        and math.isnan(x)                     # 再使用 math.isnan 判断
    )
  • 排除整数:因为整数不可能是 NaN,直接返回 False,提升性能。

  • 单独处理 pandas.NA:交给 is_pandas_na(后者仅在 pandas 可用时返回 True),避免在 is_scalar_nan 中出现混淆。

55.7.2 稠密掩码生成(_get_dense_mask 第 16‑40 行)

# 第 55 章 —— src/sklearn/utils/_mask.py (第16-40行)
def _get_dense_mask(X, value_to_mask):
    with suppress(ImportError, AttributeError):
        import pandas
        if value_to_mask is pandas.NA:
            return pandas.isna(X)

    if is_scalar_nan(value_to_mask):
        if X.dtype.kind == "f":
            Xt = np.isnan(X)                         # 浮点直接 np.isnan
        elif X.dtype.kind in ("i", "u"):
            Xt = np.zeros(X.shape, dtype=bool)       # 整数不可能 NaN
        else:
            Xt = _object_dtype_isnan(X)               # 对象 dtype 专用实现
    else:
        Xt = X == value_to_mask
    return Xt
  • 兼容 pandas.NA:若要掩码 pandas.NA,直接调用 pandas.isna,保持 pandas 对缺失值的语义。

  • 稀疏矩阵处理:在 sp.issparse(X)True 时,只对 .data(非零值)生成布尔掩码,然后保留原有稀疏结构(indicesindptr 不变),避免在稀疏矩阵上产生全零的 bool 矩阵导致旧版 SciPy 错误。

55.7.3 稀疏掩码包装(_get_mask 第 42‑65 行)

# 第 55 章 —— src/sklearn/utils/_mask.py (第42-65行)
def _get_mask(X, value_to_mask):
    if not sp.issparse(X):
        return _get_dense_mask(X, value_to_mask)

    Xt = _get_dense_mask(X.data, value_to_mask)
    sparse_constructor = sp.csr_matrix if X.format == "csr" else sp.csc_matrix
    Xt_sparse = sparse_constructor(
        (Xt, X.indices.copy(), X.indptr.copy()), shape=X.shape, dtype=bool
    )
    return Xt_sparse
  • 只对 .data 生成掩码:因为稀疏矩阵的 indicesindptr 只描述结构,不参与数值比较。这样可以在保持稀疏格式的同时获得布尔掩码。

55.7.4 安全布尔掩码转整数索引(safe_mask 第 67‑100 行)

# 第 55 章 —— src/sklearn/utils/_mask.py (第67-100行)
def safe_mask(X, mask):
    """Return a mask which is safe to use on X."""
    mask = np.asarray(mask)
    if np.issubdtype(mask.dtype, np.signedinteger):
        return mask                     # 已是整数索引直接返回

    if hasattr(X, "toarray"):          # 稀疏矩阵具有 toarray 方法
        ind = np.arange(mask.shape[0])
        mask = ind[mask]               # 将布尔 mask 转为整数索引
    return mask
  • 稀疏矩阵场景:旧版 SciPy 在布尔索引全 False 时会抛异常。safe_mask 把布尔掩码先转为整数索引,保证即使全部 False 也能返回空切片(后端返回形状 (0, n_features)),避免错误。

55.7.5 行切片安全包装(axis0_safe_slice 第 102‑127 行)

# 第 55 章 —— src/sklearn/utils/_mask.py (第102-127行)
def axis0_safe_slice(X, mask, len_mask):
    """Return a mask which is safer to use on X than safe_mask."""
    if len_mask != 0:
        return X[safe_mask(X, mask), :]             # 正常索引
    return np.zeros(shape=(0, X.shape[1]))          # 空掩码返回 0 行矩阵
  • mask 完全为空(len_mask == 0)时,直接返回 形状匹配的空矩阵,避免 SciPy 旧版在稀疏矩阵上执行 X[mask] 报错。

55.8 Resample 与 Shuffle 重采样工厂 —— 统一入口的“数据增强器”

resample 提供 Bootstrap、分层、加权、同步多数组 的统一实现,可被 shuffle(等价于 replace=False)直接调用。

55.8.1 参数校验与分支(resample 第 455‑627 行)

# 第 55 章 —— src/sklearn/utils/_indexing.py (第455-627行)
@validate_params(
    {
        "replace": ["boolean"],
        "n_samples": [Interval(numbers.Integral, 1, None, closed="left"), None],
        "random_state": ["random_state"],
        "stratify": ["array-like", "sparse matrix", None],
        "sample_weight": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
def resample(
    *arrays,
    replace=True,
    n_samples=None,
    random_state=None,
    stratify=None,
    sample_weight=None,
):
    """Resample arrays or sparse matrices in a consistent way."""
    max_n_samples = n_samples
    random_state = check_random_state(random_state)

    # 1️⃣ 参数准备
    first = arrays[0]
    n_samples = first.shape[0] if hasattr(first, "shape") else len(first)

    if max_n_samples is None:
        max_n_samples = n_samples
    elif (max_n_samples > n_samples) and (not replace):
        raise ValueError(
            "Cannot sample %d out of arrays with dim %d when replace is False"
            % (max_n_samples, n_samples)
        )

    check_consistent_length(*arrays)

    # 2️⃣ 权重与分层互斥检查
    if sample_weight is not None and not replace:
        raise NotImplementedError(
            "Resampling with sample_weight is only implemented for replace=True."
        )
    if sample_weight is not None and stratify is not None:
        raise NotImplementedError(
            "Resampling with sample_weight is only implemented for stratify=None."
        )

    # 3️⃣ 采样策略分支
    if stratify is None:
        if replace:
            # 有放回:若提供 sample_weight 则归一化为概率 p
            if sample_weight is not None:
                sample_weight = _check_sample_weight(
                    sample_weight, first, dtype=np.float64
                )
                p = sample_weight / sample_weight.sum()
            else:
                p = None
            indices = random_state.choice(
                n_samples,
                size=max_n_samples,
                p=p,
                replace=True,
            )
        else:
            # 无放回:先全排列再切片
            indices = np.arange(n_samples)
            random_state.shuffle(indices)
            indices = indices[:max_n_samples]
    else:
        # ---------- 分层抽样 ----------
        y = check_array(stratify, ensure_2d=False, dtype=None)
        if y.ndim == 2:
            # 多标签场景:把每行转为唯一字符串键
            y = np.array([" ".join(row.astype("str")) for row in y])

        classes, y_indices = np.unique(y, return_inverse=True)
        n_classes = classes.shape[0]
        class_counts = np.bincount(y_indices)

        # 为每个类别分配抽样配额(可能不是整数,使用 _approximate_mode 近似)
        class_indices = np.split(
            np.argsort(y_indices, kind="mergesort"), np.cumsum(class_counts)[:-1]
        )
        n_i = _approximate_mode(class_counts, max_n_samples, random_state)

        indices = []
        for i in range(n_classes):
            indices_i = random_state.choice(class_indices[i], n_i[i], replace=replace)
            indices.extend(indices_i)
        indices = random_state.permutation(indices)

    # 4️⃣ 将所有稀疏矩阵转为 CSR 以支持行索引
    arrays = [a.tocsr() if issparse(a) else a for a in arrays]
    resampled_arrays = [_safe_indexing(a, indices) for a in arrays]
    return resampled_arrays[0] if len(resampled_arrays) == 1 else resampled_arrays
  • 分层抽样细节

    • 对多标签 y(二维)使用 " ".join(row.astype("str")) 把每行映射为唯一字符串键,实现 “复合类别” 的分层。

    • class_counts 记录各类样本数,_approximate_mode 根据目标总样本数 max_n_samples 近似分配每类的抽样配额 n_i

    • 每类内部使用 random_state.choice(..., replace=replace) 抽样,然后整体 permutation 打乱顺序,保持 整体随机性

  • 加权采样sample_weightreplace=True 时归一化为概率 p,交给 numpy.random.choice 完成有放回抽样,不支持加权的无放回(已显式抛出 NotImplementedError)。

55.8.2 shuffle 简单包装(第 629‑690 行)

def shuffle(*arrays, random_state=None, n_samples=None):
    """Shuffle arrays or sparse matrices in a consistent way."""
    return resample(
        *arrays, replace=False, n_samples=n_samples, random_state=random_state
    )

设计哲学shuffle 只是 resample(..., replace=False) 的别名,使 API 使用者能够明确表达 “仅做随机排列,不做复制”。

55.9 设计中的取舍

  • 为什么不用 numpy.random.permutation 直接实现所有抽样?

    • permutation 需要一次性生成长度为 n_population 的全排列数组,这在 极大总体(如千万级样本)下会消耗大量内存并导致 O(n_population) 的时间开销。

    • 当抽样比例非常小(ratio < 0.01)时,tracking_selection 只需维护一个大小为 n_samples 的集合,显著降低内存和时间成本。

  • 这种设计的 trade‑off 是什么?

    • 空间 vs. 时间

      • pool 方法最快,但占用 O(n_population) 内存,仅在内存充足且 n_samples ≈ n_population 时推荐。

      • tracking_selection 最省内存,但在 ratio 较大时冲突概率上升,导致潜在的 指数级重试

      • reservoir_sampling 兼顾 一次遍历线性空间,但返回的顺序未必随机,需要后续 shuffle

    • 实现复杂度:维护四套实现增加了代码体积和测试负担,但为不同使用场景提供了最优路径,体现了 scikit‑learn “在可接受的复杂度之内追求极致性能” 的工程哲学。

55.10 动手练习

  1. 阅读安全索引分发机制

    • 查看 sklearn/utils/_indexing.py 第 254‑348 行 (_safe_indexing) 及其调用的各类 _*_indexing 函数。

    • 回答问题:

      • _safe_indexing 如何判断输入 X 属于哪种容器类型?分发优先级顺序是什么?

      • _pandas_indexing 中为何对整数数组索引使用 take() 而非 iloc[]

      • _pyarrow_indexing 处理字符串切片 start:stop 时为何 stop+1

  2. 分析无放回采样算法选择边界

    • 阅读 sklearn/utils/_random.pyx 第 239‑380 行 (_sample_without_replacementsample_without_replacement)。

    • 回答问题:

      • 自动模式下,ratio = n_samples / n_population 的三个阈值区间分别对应哪种算法?各算法的时空复杂度特点是什么?

      • 为何 tracking_selection 适合极小采样率,而 reservoir_sampling 适合极大采样率?

      • our_rand_r 为何要模 RAND_R_MAX + 1 (2^31)?这与 Windows MSVC RAND_MAX 过小有何关系?

  3. 实现自定义分块策略

    • 阅读 sklearn/utils/_chunking.py 第 32‑165 行 (gen_batches, gen_even_slices, get_chunk_n_rows)。

    • 动手任务:

      • 编写 gen_adaptive_batches(n, target_mem_mib, row_bytes, min_batch=10, max_batch=10000),结合 get_chunk_n_rows 计算 batch_size,在该范围内 clamp,最后调用 gen_batches 生成 slice。

      • 验证:当 row_bytes 导致计算出的 batch_size < min_batch 时,函数是否报警并返回 1 行批次(或抛错),并解释设计选择。

      • 对比 gen_even_slices(n, n_packs)gen_batches(n, batch_size) 在并行任务分配场景下的优劣势。

  4. 探究缺失值掩码在稀疏矩阵上的处理

    • 阅读 sklearn/utils/_mask.py 第 16‑100 行 (_get_mask, safe_mask, axis0_safe_slice)。

    • 回答问题:

      • _get_mask 对 CSR/CSC 稀疏矩阵为何只对 .data 属性生成掩码,而保持 .indices.indptr 不变?

      • safe_mask 中为何当 Xtoarray 方法(稀疏矩阵)且掩码为布尔型时,要转为整数索引 ind[mask]?这解决了什么旧版 SciPy 问题?

      • axis0_safe_slicelen_mask == 0 时为何返回 np.zeros(shape=(0, X.shape[1])) 而不是直接 X[mask]

  5. 解析重采样中的分层与加权策略

    • 阅读 sklearn/utils/_indexing.py 第 455‑627 行 (resample 函数)。

    • 回答问题:

      • stratify 参数为多标签场景时,为何要将行转为字符串作为复合类别键?

      • _approximate_mode 在分层抽样中如何分配各类别的抽样配额 n_i

      • sample_weight 归一化为概率 p 后传入 rng.choice 时,为何要求 replace=True

55.11 本章小结

在本章中,我们系统地学习了 utils 模块 中四大核心能力:

  • 首先,_safe_indexing 通过类型检测与键类型推断,为 NumPy、稀疏矩阵、pandas、Polars、PyArrow、列表、DataFrame 交换协议 提供统一且安全的行/列索引入口。

  • 其次,gen_batchesgen_even_sliceschunk_generatorget_chunk_n_rows 共同构成 按内存预算切分数据 的“切蛋糕”框架,支持固定批次、均匀切片和流式聚合,帮助在大规模数据处理时避免 OOM。

  • 接着,sample_without_replacement 实现了四种 无放回抽样 算法(tracking、permutation、reservoir、pool),并通过 XorShiftour_rand_r 保证跨平台随机数一致性。

  • 然后,is_scalar_nansafe_maskaxis0_safe_slice缺失值检测与布尔掩码转换 提供了稠密、稀疏、对象数组的统一处理路径。

  • 最后,resampleshuffleBootstrap、分层、加权 抽样统一包装,为交叉验证与实验复现提供可靠的随机重排工具。

这些工具共同构建了 scikit‑learn 在 数据预处理、交叉验证、模型训练 等核心流程中对 可靠性、可复现性与高效性 的基石。

55.11.1 概念表

| 概念 | 解释 |

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

| _safe_indexing | 多容器统一索引入口,支持行/列、整数/布尔/字符串/切片,自动分发至专属实现。 |

| _determine_key_type | 索引键类型推断器,返回 'int'、'str'、'bool'、None,统一后续分支判断。 |

| _get_column_indices | 列索引统一解析器:字符串/布尔/整数/切片 → 整数列索引列表。 |

| gen_batches | 固定大小生成 slice,支持 min_batch_size 防止尾批过小。 |

| gen_even_slices | 均匀切分 n_packs 片,前 n % n_packs 片多 1 条目,适用于并行任务划分。 |

| chunk_generator | 迭代器流式聚合为固定大小列表,适合流式读取。 |

| get_chunk_n_rows | 基于 working_memory(MiB)计算可处理行数,若单行超预算强制返回 1 并报警。 |

| _sample_without_replacement | 自动选择四种无放回抽样算法的调度函数。 |

| our_rand_r | 线程安全的 XorShift 随机数生成器,跨平台返回 0‑2³¹‑1 的整数。 |

| is_scalar_nan | 排除整数后对实数使用 math.isnan,实现快速标量 NaN 检测。 |

| safe_mask | 将稀疏布尔掩码转换为整数索引,避免旧 SciPy 在全 False 时抛异常。 |

| axis0_safe_slice | 当掩码为空时返回形状匹配的空矩阵,防止维度错误。 |

| resample | 统一的重采样入口,支持 bootstrap、分层、加权、同步多数组。 |

| shuffle | resample(..., replace=False) 的语法糖,实现随机排列。 |

下一章:我们将进入第 56 章,探索 utils 随机状态与响应值,深入了解 check_random_state_get_response_values 以及加权百分位数计算的实现细节,帮助你在模型评估与预测阶段实现 可复现且精准的统计度量

第 56 章 —— utils 数据容器与输出适配 —— 搭建“多格式互通的立交桥”

56.1 学习目标

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

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

  • 了解 scikit‑learn 如何通过 sys.modules 实现 零依赖 的容器类型检测。

  • 理解适配器模式在 输出容器 设置中的作用,并比较 PandasAdapterPolarsAdapter 的实现细节。

  • 掌握 _SetOutputMixin 的元编程机制,它如何在 transform/fit_transform 时自动包装返回值。

  • 熟悉 _encode 的双轨实现(对象 dtype vs 数值 dtype)以及 NaN‑感知的去重逻辑。

  • 理解 dtype.metadata 中的唯一值缓存机制及其零拷贝优化原理与使用禁忌。

  • 学会为新数据生态(如 PySpark)编写自定义容器适配器并注册。

  • 理解 三层配置决策链(估计器级 > 全局级 > 默认值)的实现方式。

  • 熟悉 ContainerAdapterProtocol 协议接口以及核心工具函数(_get_adapter_from_container_get_container_adapter_auto_wrap_is_configured)的职责。

  • 了解 _is_supported_containercreate_container 在输入检测与输出构建中的协作机制。


56.2 生活类比

想象 scikit‑learn 的数据容器体系是一座 多模态物流转运中心

  • 容器类型检测 就像 身份证扫描仪:只要系统的 sys.modules 中能查到对应的库(如 pandas、polars、pyarrow),就能立刻识别旅客(数据)的身份,而不要求旅客事先办理会员卡(强制安装依赖)。

  • 适配器模式 则是 通用集装箱转换器

    • PandasAdapter 像一台保留行李标签(索引)的专用叉车,能够把原始货物直接装进已有的箱子而不拆包装。

    • PolarsAdapter 像一条预贴好目的地标签的自动化分拣线,在构造新箱子时一次性完成列名设置,避免重复列冲突。

  • 三层配置决策链 如同 分级调度指挥系统:站长(估计器级)先下达指令;若没有则交给区域调度中心(全局级);最后使用统一的标准流程(默认)。

  • _SetOutputMixin 的元编程相当于 列车自动挂钩系统:列车(Transformer)出厂时已装上智能挂钩,进站(调用 transform)时自动识别站台配置并挂接到正确的车厢(容器)上,无需人为干预。

  • 编码与唯一值缓存 则是 货物压缩打包与标签复用_encode 把杂乱的货物(类别)压缩成统一编号;attach_unique 把装箱清单(唯一值)贴在箱子(dtype.metadata)上随货物流转,cached_unique 直接读取箱上清单避免重新盘点。

  • 零拷贝视图机制 如同 贴标签不搬货:仅修改箱子元数据不搬运里面的货物,但若箱子在后续被改装(原地修改),标签就会失效,所以必须遵循“标签只读”原则。


56.3 源码地图

sklearn/utils/_dataframe.py
├─ is_df_or_series()            # 聚合三大生态判定
├─ is_pandas_df_or_series()     # Pandas 判定
├─ is_pandas_df()               # Pandas DataFrame 判定
├─ is_pyarrow_data()            # PyArrow 判定
├─ is_polars_df_or_series()     # Polars 判定
└─ is_polars_df()               # Polars DataFrame 判定

sklearn/utils/_set_output.py
├─ ContainerAdapterProtocol     # 适配器协议(PEP 544)
│  ├─ create_container()
│  ├─ is_supported_container()
│  ├─ rename_columns()
│  └─ hstack()
├─ check_library_installed()    # 延迟导入检查
├─ get_columns()                # 支持 callable 的列名获取
├─ PandasAdapter                # Pandas 适配器实现
│  ├─ create_container()
│  ├─ is_supported_container()
│  ├─ rename_columns()
│  └─ hstack()
├─ PolarsAdapter                # Polars 适配器实现
│  ├─ create_container()
│  ├─ is_supported_container()
│  ├─ rename_columns()
│  └─ hstack()
├─ ContainerAdaptersManager     # 适配器注册表(单例)
│  ├─ supported_outputs
│  └─ register()
├─ ADAPTERS_MANAGER             # 全局单例
├─ _get_adapter_from_container()
├─ _get_container_adapter()
├─ _get_output_config()
├─ _wrap_data_with_container()
├─ _wrap_method_output()
├─ _auto_wrap_is_configured()
├─ _SetOutputMixin
│  ├─ __init_subclass__()
│  └─ set_output()
└─ _safe_set_output()

56.4 数据容器类型检测 —— 识别“异构数据”的身份证

56.4.1 核心概念

Scikit‑learn 需要在 不强制安装 heavy 依赖 的前提下,判断输入对象是否属于某个数据生态(Pandas、Polars、PyArrow)。实现方式是 懒加载:仅检查 sys.modules 中是否存在对应的库模块名。如果库根本未被导入,函数立即返回 False,从而实现 零依赖检测

56.4.2 类型检测函数族谱(代码示例)

以下代码均带有 逐行注释,并在代码块后附有解释说明。

# 第 56 章 —— sklearn/utils/_dataframe.py - is_pandas_df_or_series (第 31‑44 行)

def is_pandas_df_or_series(X):
    """Return True if the X is a pandas dataframe or series."""
    try:
        # 通过 sys.modules 检查是否已经导入 pandas
        pd = sys.modules["pandas"]
    except KeyError:
        # pandas 未导入 → 直接返回 False,避免 ImportError
        return False
    # isinstance 支持元组,检查两种可能的 pandas 类型
    return isinstance(X, (pd.DataFrame, pd.Series))

解释:该函数实现了 基于 sys.modules 的零依赖懒加载检测。如果用户的环境没有 pandas,函数不会尝试导入,只会返回 False,从而保持 scikit‑learn 的轻量级。

# 第 56 章 —— sklearn/utils/_dataframe.py - is_polars_df_or_series (第 55‑68 行)

def is_polars_df_or_series(X):
    """Return True if the X is a polars dataframe or series."""
    try:
        # 同样通过 sys.modules 检查 polars 是否已加载
        pl = sys.modules["polars"]
    except KeyError:
        return False
    # 检查是否为 Polars DataFrame 或 Series
    return isinstance(X, (pl.DataFrame, pl.Series))

解释:与 pandas 检测完全相同的思路,只是针对 Polars 库。

# 第 56 章 —— sklearn/utils/_dataframe.py - is_df_or_series (第 14‑23 行)

def is_df_or_series(X):
    """Return True if the X is a dataframe or series."""
    # 统一入口:聚合三大生态判定
    return (
        is_pandas_df_or_series(X)
        or is_polars_df_or_series(X)
        or is_pyarrow_data(X)
    )

解释:该函数把三大生态的判定结果 逻辑或 合并,提供单一入口。

56.4.3 Mermaid 流程图:数据容器类型检测流程

flowchart TD A[输入 X] --> B{is_pandas_df_or_series(X)} B -->|True| C[返回 True] B -->|False| D{is_polars_df_or_series(X)} D -->|True| C D -->|False| E{is_pyarrow_data(X)} E -->|True| C E -->|False| F[返回 False]

56.5 输出容器设置与包装 —— 让 Transformer “说人话”

56.5.1 适配器模式的核心

| 组件 | 作用 |

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

| ContainerAdapterProtocol | 定义四个抽象方法:create_containeris_supported_containerrename_columnshstack,为所有适配器提供统一接口。 |

| ContainerAdaptersManager | 单例注册表,维护 {library_name: adapter_instance} 映射,提供 supported_outputs 集合供配置校验。 |

| PandasAdapter | 保留索引、inplace 优化、pd.concat 水平堆叠、直接赋值 columns 重命名。 |

| PolarsAdapter | 使用 schema/orient 一次性构建、pl.concat(how="horizontal") 堆叠、预重命名分片避免列冲突。 |

56.5.1.1 ContainerAdapterProtocol(代码带注释)

# 第 56 章 —— sklearn/utils/_set_output.py - ContainerAdapterProtocol (第 30‑70 行)

@runtime_checkable
class ContainerAdapterProtocol(Protocol):
    """PEP 544 Protocol defining a container adapter."""

    container_lib: str  # e.g. "pandas" or "polars"

    def create_container(self, X_output, X_original, columns, inplace=False):
        """Wrap raw ndarray into a container, preserving metadata such as index."""
        ...

    def is_supported_container(self, X):
        """Return True if X matches the library’s container type."""
        ...

    def rename_columns(self, X, columns):
        """Rename container columns in‑place."""
        ...

    def hstack(self, Xs, feature_names=None):
        """Concatenate a list of containers column‑wise."""
        ...

说明:使用 @runtime_checkableisinstance(obj, ContainerAdapterProtocol) 在运行时检查对象是否实现全部四个方法。

56.5.1.2 PandasAdapter create_container(逐行注释)

# 第 56 章 —— sklearn/utils/_set_output.py - PandasAdapter.create_container (第 78‑107 行)

def create_container(self, X_output, X_original, columns, inplace=True):
    # 1. 延迟导入 pandas,若未安装会在后续报错
    pd = check_library_installed("pandas")
    # 2. 支持 columns 为 callable 的情况
    columns = get_columns(columns)

    # 3. 判定是否需要新建 DataFrame
    if not inplace or not isinstance(X_output, pd.DataFrame):
        # 需要创建新对象——保留索引信息
        if isinstance(X_output, pd.DataFrame):
            index = X_output.index
        elif isinstance(X_original, (pd.DataFrame, pd.Series)):
            index = X_original.index
        else:
            index = None

        # copy=False 时复用原始 ndarray;copy=True 时强制拷贝
        X_output = pd.DataFrame(X_output, index=index, copy=not inplace)

    # 4. 若用户提供列名,则调用 rename_columns 完成重命名
    if columns is not None:
        return self.rename_columns(X_output, columns)
    return X_output

解释:该函数决定是否原地修改或复制新 DataFrame,并在需要时保留原始输入的索引,以确保输出容器的元数据完整。

56.5.1.3 PolarsAdapter create_container(逐行注释)

# 第 56 章 —— sklearn/utils/_set_output.py - PolarsAdapter.create_container (第 123‑148 行)

def create_container(self, X_output, X_original, columns, inplace=True):
    # 1. 延迟导入 polars
    pl = check_library_installed("polars")
    columns = get_columns(columns)
    # Polars 只接受 list‑like 列名;若为 ndarray 则转为 Python list
    columns = columns.tolist() if isinstance(columns, np.ndarray) else columns

    # 2. 若不允许原地修改或 X_output 不是 Polars DataFrame,需要新建
    if not inplace or not isinstance(X_output, pl.DataFrame):
        # 使用 schema+orient 一次性构建,避免后续列名冲突
        return pl.DataFrame(X_output, schema=columns, orient="row")

    # 3. 已有 Polars DataFrame 且需要改列名
    if columns is not None:
        return self.rename_columns(X_output, columns)
    return X_output

解释:一次性构建避免多次操作,预先将列名转为 list 以满足 Polars API 要求。

56.5.2 三层配置决策链

# 第 56 章 —— sklearn/utils/_set_output.py - _get_output_config (第 215‑245 行)

def _get_output_config(method, estimator=None):
    # 读取估计器级配置(若存在)或全局配置
    est_sklearn_output_config = getattr(estimator, "_sklearn_output_config", {})
    if method in est_sklearn_output_config:
        dense_config = est_sklearn_output_config[method]
    else:
        dense_config = get_config()[f"{method}_output"]

    # 校验配置是否合法
    supported_outputs = ADAPTERS_MANAGER.supported_outputs
    if dense_config not in supported_outputs:
        raise ValueError(
            f"output config must be in {sorted(supported_outputs)}, got {dense_config}"
        )
    return {"dense": dense_config}

说明:此函数实现了 “估计器 > 全局 > 默认” 的优先级合并,确保返回的配置必然是已注册的适配器或 "default"

56.5.3 自动包装机制:_SetOutputMixin

  • __init_subclass__ 在子类定义阶段检查 auto_wrap_output_keys(默认 ("transform",)),并为子类实现的 transform / fit_transform 方法注入包装器 _wrap_method_output

  • 包装器内部调用 _wrap_data_with_container,根据配置决定是否把 ndarray 包装为 pandas / polars。

# 第 56 章 —— sklearn/utils/_set_output.py - _auto_wrap_is_configured (第 315‑322 行)

def _auto_wrap_is_configured(estimator):
    auto_wrap_output_keys = getattr(estimator, "_sklearn_auto_wrap_output_keys", set())
    # 必须同时满足两条条件:
    # 1. 具备 get_feature_names_out 方法
    # 2. 在 auto_wrap_output_keys 中包含 "transform"
    return (
        hasattr(estimator, "get_feature_names_out")
        and "transform" in auto_wrap_output_keys
    )

56.5.3.1 方法包装器(逐行注释)

# 第 56 章 —— sklearn/utils/_set_output.py - _wrap_method_output (第 287‑313 行)

def _wrap_method_output(f, method):
    @wraps(f)
    def wrapped(self, X, *args, **kwargs):
        # 1. 调用原始方法得到原始输出
        data_to_wrap = f(self, X, *args, **kwargs)

        # 2. 若返回的是 tuple(如 CrossDecomposition),仅包装第一个元素
        if isinstance(data_to_wrap, tuple):
            return_tuple = (
                _wrap_data_with_container(method, data_to_wrap[0], X, self),
                *data_to_wrap[1:],
            )
            # 支持 namedtuple 的 _make 方法恢复命名
            if hasattr(type(data_to_wrap), "_make"):
                return type(data_to_wrap)._make(return_tuple)
            return return_tuple

        # 3. 普通 ndarray/DataFrame 直接包装
        return _wrap_data_with_container(method, data_to_wrap, X, self)

    return wrapped

解释:包装器确保 所有 transform/fit_transform 的返回值都会经过 _wrap_data_with_container,从而统一实现输出容器的转换。

56.5.4 核心包装流程 _wrap_data_with_container(逐行注释)

# 第 56 章 —— sklearn/utils/_set_output.py - _wrap_data_with_container (第 247‑285 行)

def _wrap_data_with_container(method, data_to_wrap, original_input, estimator):
    # 1. 获取当前方法的输出配置
    output_config = _get_output_config(method, estimator)

    # 2. 若配置为 "default" 或未满足自动包装条件,直接返回原始数据
    if output_config["dense"] == "default" or not _auto_wrap_is_configured(estimator):
        return data_to_wrap

    dense_config = output_config["dense"]
    # 3. 稀疏矩阵不支持 pandas/polars,抛出友好错误
    if issparse(data_to_wrap):
        raise ValueError(
            "The transformer outputs a scipy sparse matrix. "
            "Try to set the transformer output to a dense array or disable "
            f"{dense_config.capitalize()} output with set_output(transform='default')."
        )

    # 4. 根据配置取到对应适配器实例
    adapter = ADAPTERS_MANAGER.adapters[dense_config]
    # 5. 交给适配器完成包装,列名由 estimator.get_feature_names_out 提供
    return adapter.create_container(
        data_to_wrap,
        original_input,
        columns=estimator.get_feature_names_out,
    )

说明:该函数是 输出包装的核心入口,负责根据配置决定是否将原始数组转换为目标容器类型,并在稀疏输出时给出明确提示。

56.5.5 Mermaid 流程图:输出包装流程

flowchart TD A[调用 transformer.transform] --> B{_auto_wrap_is_configured?} B -->|False| C[返回原始 ndarray] B -->|True| D[_get_output_config] D --> E{dense == 'default'?} E -->|Yes| C E -->|No| F[检查稀疏矩阵] F -->|是稀疏| G[抛出 ValueError] F -->|否| H[获取适配器实例] H --> I[adapter.create_container] I --> J[返回包装后容器]
posted @ 2026-09-04 04:07  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报