Sklearn-源码解析-书-v1-0-二十三-
Sklearn 源码解析(书)v1.0(二十三)
54.4 元数据路由核心机制 —— 「请求‑声明‑路由」三层协同的智能物流系统
54.4.1 关键点
-
MetadataRequest:每个 consumer(如
LinearRegression、SVC)都有一个_metadata_request,内部为每个公开方法(fit,predict…)保存MethodMetadataRequest,记录该方法是否 请求、拒绝、错误或使用 别名。 -
MetadataRouter:router(如
Pipeline、SelectFromModel)拥有一个_route_mappings表,键是子估计器名,值是RouterMappingPair(mapping, router)。mapping(MethodMapping)描述父‑子方法对应关系(caller → callee),router可以是另一个MetadataRouter或MetadataRequest,实现递归嵌套。 -
路由过程:
process_routing(self, "fit", sample_weight=sw, **fit_params)先拿到路由对象 (MetadataRouter),调用validate_metadata检查非法键,再通过route_params把父对象请求的元数据分配给对应子对象的对应方法。返回的Bunch结构形如{'estimator': {'fit': {'sample_weight': array(...)}}},可直接**传入子对象。 -
全局开关:
enable_metadata_routing(sklearn.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时会发现对应键的请求状态是 False 或 WARN,从而在返回的Bunch中不包含该键;如果用户强行传入且状态为 None,则会触发UnsetMetadataPassedError,强制用户显式声明请求或放弃。
54.5.1.2 架构图
54.6 多分类与多标签目标处理 —— 目标类型识别的「决策树」与 OvR 决策函数
54.6.1 关键点
-
type_of_target:依据数据维度、稀疏性、数值类型以及是否为标签指示矩阵,逐层判定为
binary、multiclass、multiclass-multioutput、multilabel-indicator、continuous、continuous-multioutput或unknown。 -
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_confidences与votes)实现平滑的多分类决策,避免仅靠投票产生的离散边界。
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_score、CalibratedClassifierCV等对概率敏感的评估。
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 架构图
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 API(
xp)抽象,实现对 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 架构图
54.8 Cython 与 BLAS 高性能内核 —— 矩阵运算的「隐形引擎」
54.8.1 关键点
-
融合类型 (
floating):在.pyx中声明cdef floating,编译时会生成两套实现(float与double),避免手写两遍代码。 -
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/tb,lda也随之调换,无需显式拷贝或转置即可使用高效的 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中不直接调用sgemm或dgemm来处理向量-矩阵乘法,而是使用专门的_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, k与lda, ldb, ldc?答案:BLAS 的
gemm期望操作数op(A)的形状为m × k、op(B)为k × n;当A被转置时其逻辑形状变为(A.shape[1], A.shape[0]),相应地 leading dimension 也需调整以反映正确的内存步幅,否则 BLAS 会按错误步幅访问内存导致越界或结果错误。
54.8.1.6 架构图
54.9 OpenMP 并行与线程池控制 —— 硬件资源的「智能调度员」
54.9.1 关键点
-
编译期宏
SKLEARN_OPENMP_PARALLELISM_ENABLED在_openmp_helpers.pxd中由#ifdef _OPENMP决定,统一在运行时通过_openmp_parallelism_enabled()查询。 -
动态线程数:
_openmp_effective_n_threads按以下优先级决定实际线程数-
用户显式
n_threads(正数直接使用) -
环境变量
OMP_NUM_THREADS(若设定则使用omp_get_max_threads()) -
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_decorator 与 Parallel.__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_context与warnings.catch_warnings重新注入,保证行为一致。
54.9.1.5 思考题
-
为何模块级缓存
_CPU_COUNTS可以假设硬件拓扑在进程生命周期内不变?答案:在容器化环境(Docker、Kubernetes)中,cgroups 配额通常在容器启动时固定,不会在运行时动态变化;即使在裸机系统中,物理核心数也不会在进程运行过程中改变,因此缓存是安全且高效的,避免每次调用都重新读取
/proc/cpuinfo或 cgroup 文件。
54.9.1.6 架构图
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为堆容量),当新值更小时进行 下沉,在 KNNk最近邻查询中保持k最小距离,使用 SoA(values+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 - fmix 与 getblock(第 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_swap 与 simultaneous_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 架构图
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_SimpleNewFromData将std::vector的内部指针直接包装成 NumPy ndarray,零拷贝。-
StdVectorSentinel通过Py_INCREF成为 ndarray 的base对象,保证在 ndarray 被销毁前StdVectorSentinel的析构函数保持vector有效。 -
vector_to_nd_array为统一入口,使用 fused typevector_typed支持float64_t、intp_t、int32_t、int64_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)
通过 StdVectorSentinel,
std::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链而不是match或type()判断?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 架构图
54.12 通用类型定义体系 —— 跨平台数值代码的「类型契约」
54.12.1 关键点
-
_typedefs.pxd把常用 C 类型统一为intp_t、float64_t、int32_t等,屏蔽平台差异(如 Windowslong与 Linuxlong long不同)。 -
融合类型 (
floating) 在 BLAS、排序、堆实现中使用,仅需一套源码即可生成 float 与 double 两套函数。 -
测试桩
testing_make_array_from_typed_val(_typedefs.pyx)通过 Cython 内存视图 把标量包装为 NumPy array,确保 类型映射(intp_t↔np.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.pyx:dual_swap与simultaneous_sort均以floating*为参数类型,与_typedefs.pyx的testing_type_t同样属于 Cython fused type,但更轻量,只覆盖两种浮点精度。 -
_heap.pyx:heap_push使用floating* values与intp_t* indices,保证与_sorting.pyx在 KNN 查询中协同工作。
54.12.1.4 设计中的取舍
为什么选择
Py_ssize_t作为intp_t的底层类型,而不是直接使用int64_t或int32_t?Py_ssize_t是 Python C API 中用于表示容器大小和索引的有符号整数类型,它在不同平台上与intptr_t对齐,能够安全地容纳任何 Python 对象的大小或索引;使用它可以确保在 32 位和 64 位平台上都不会因索引溢出而失败,同时在 64 位平台上仍能提供足够的范围。
在融合类型实现中,为何不使用 C++ 模板(templates)而选择 Cython 的
fused类型? Cython 的fused类型在编译时生成特定类型的版本(如float和double),但在使用上保持类似泛型的语法,避免了 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 架构图
54.13 动手练习
-
元数据路由调度逻辑实战。请打开
sklearn/utils/metadata_routing.py中MetadataRouter.route_params(约 200‑350 行)。思考:如果子估计器既未声明request_sample_weight也未声明request_groups,父元估计器传递这些参数会怎样?同时请探索enable_metadata_routing上下文管理器如何实现全局开关而不破坏线程安全。 -
数组 API 跨后端迁移解析。阅读
sklearn/utils/_array_api.py中move_to(约 280‑350 行)与_convert_to_numpy(约 620‑640 行)。请解释 DLPack 零拷贝的优势以及为何非 NumPy 后端之间必须经 NumPy 中转。在_logsumexp(约 800‑860 行)中,说明非 NumPy 后端为何手动实现log1p(s)+log(m)+shift。 -
OpenMP 线程数计算与嵌套并行控制。阅读
sklearn/utils/_openmp_helpers.pyx中_openmp_effective_n_threads(30‑80 行)以及sklearn/utils/parallel.py中_threadpool_controller_decorator(135‑150 行)。请说明为何模块级缓存_CPU_COUNTS可以假设硬件拓扑在进程生命周期内不变。 -
MurmurHash3 与同时排序的 SIMD 友好设计对比。检视
sklearn/utils/src/MurmurHash3.cpp中MurmurHash3_x86_32(100‑150 行)与sklearn/utils/_sorting.pyx中simultaneous_sort(30‑100 行)。请解释fmix如何实现雪崩效应,dual_swap如何保证值‑索引原子交换。 -
C++ 容器到 NumPy 数组的零拷贝生命周期管理。阅读
sklearn/utils/_vector_sentinel.pyx中vector_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_weight、groups 等元数据。 |
| type_of_target | 通过维度、稀疏性、dtype 检查,精确判定目标是 binary、multiclass、multilabel‑indicator、continuous 等七种类型。 |
| 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::map 与 std::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_df、is_polars_df 等容器检测,_SetOutputMixin 与 ContainerAdaptersManager 如何让 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 是整个索引体系的指挥官:它首先判断输入对象的类型,然后把索引任务分发给专属实现。核心流程如下:
-
容器类型判断:
-
若对象拥有
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。
-
-
索引键类型推断:调用
_determine_key_type,统一把整数、字符串、布尔、切片等转为内部标识'int'、'str'、'bool'、None,确保后续分支可以安全比较。 -
列索引统一解析:
_get_column_indices负责把 字符串列名 / 布尔掩码 / 整数列表 / 切片 统一转换为 整数列索引列表。-
对 DataFrame(pandas、Polars、PyArrow)通过
X.columns或对应协议的column_names()找到列位置。 -
对字符串切片,包含终点(
stop+1),保持与 pandas 行为一致。
-
-
数组/稀疏索引:
_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 数据流图(索引调度流程)
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 数据流图(分块过程)
55.6 随机采样与洗牌 —— 算法级的“公平抽签机”
sklearn.utils._random 实现了 四种无放回抽样算法,并通过 sample_without_replacement 的 method="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(可能混杂 None、np.nan、pd.NA)之间的实现细节各不相同。sklearn.utils._mask 与 sklearn.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(非零值)生成布尔掩码,然后保留原有稀疏结构(indices、indptr不变),避免在稀疏矩阵上产生全零的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生成掩码:因为稀疏矩阵的indices与indptr只描述结构,不参与数值比较。这样可以在保持稀疏格式的同时获得布尔掩码。
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_weight在replace=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 动手练习
-
阅读安全索引分发机制
-
查看
sklearn/utils/_indexing.py第 254‑348 行 (_safe_indexing) 及其调用的各类_*_indexing函数。 -
回答问题:
-
_safe_indexing如何判断输入X属于哪种容器类型?分发优先级顺序是什么? -
_pandas_indexing中为何对整数数组索引使用take()而非iloc[]? -
_pyarrow_indexing处理字符串切片start:stop时为何stop要+1?
-
-
-
分析无放回采样算法选择边界
-
阅读
sklearn/utils/_random.pyx第 239‑380 行 (_sample_without_replacement及sample_without_replacement)。 -
回答问题:
-
自动模式下,
ratio = n_samples / n_population的三个阈值区间分别对应哪种算法?各算法的时空复杂度特点是什么? -
为何
tracking_selection适合极小采样率,而reservoir_sampling适合极大采样率? -
our_rand_r为何要模RAND_R_MAX + 1(2^31)?这与 Windows MSVCRAND_MAX过小有何关系?
-
-
-
实现自定义分块策略
-
阅读
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)在并行任务分配场景下的优劣势。
-
-
-
探究缺失值掩码在稀疏矩阵上的处理
-
阅读
sklearn/utils/_mask.py第 16‑100 行 (_get_mask,safe_mask,axis0_safe_slice)。 -
回答问题:
-
_get_mask对 CSR/CSC 稀疏矩阵为何只对.data属性生成掩码,而保持.indices和.indptr不变? -
safe_mask中为何当X有toarray方法(稀疏矩阵)且掩码为布尔型时,要转为整数索引ind[mask]?这解决了什么旧版 SciPy 问题? -
axis0_safe_slice在len_mask == 0时为何返回np.zeros(shape=(0, X.shape[1]))而不是直接X[mask]?
-
-
-
解析重采样中的分层与加权策略
-
阅读
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_batches、gen_even_slices、chunk_generator与get_chunk_n_rows共同构成 按内存预算切分数据 的“切蛋糕”框架,支持固定批次、均匀切片和流式聚合,帮助在大规模数据处理时避免 OOM。 -
接着,
sample_without_replacement实现了四种 无放回抽样 算法(tracking、permutation、reservoir、pool),并通过 XorShift 的our_rand_r保证跨平台随机数一致性。 -
然后,
is_scalar_nan、safe_mask与axis0_safe_slice为 缺失值检测与布尔掩码转换 提供了稠密、稀疏、对象数组的统一处理路径。 -
最后,
resample与shuffle将 Bootstrap、分层、加权 抽样统一包装,为交叉验证与实验复现提供可靠的随机重排工具。
这些工具共同构建了 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实现 零依赖 的容器类型检测。 -
理解适配器模式在 输出容器 设置中的作用,并比较
PandasAdapter与PolarsAdapter的实现细节。 -
掌握
_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_container与create_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 流程图:数据容器类型检测流程
56.5 输出容器设置与包装 —— 让 Transformer “说人话”
56.5.1 适配器模式的核心
| 组件 | 作用 |
|------|------|
| ContainerAdapterProtocol | 定义四个抽象方法:create_container、is_supported_container、rename_columns、hstack,为所有适配器提供统一接口。 |
| 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_checkable让isinstance(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,
)
说明:该函数是 输出包装的核心入口,负责根据配置决定是否将原始数组转换为目标容器类型,并在稀疏输出时给出明确提示。

浙公网安备 33010602011771号