Sklearn-源码解析-书-v1-0-十一-
Sklearn 源码解析(书)v1.0(十一)
这段代码实现了亲和传播的核心消息传递循环。算法维护两个矩阵:责任矩阵 R(样本 i 选择样本 j 作为其 exemplar 的适用度)和可用性矩阧 A(样本 j 适合作为 exemplar 的程度,考虑到其他样本对 j 作为 exemplar 的竞争)。在每次迭代中,它首先计算临时矩阵 T = A + S(其中 S 是相似度矩阵),然后将样本 i 对样本 j 的责任更新为 R[i,j] ← S[i,j] - max_{k≠j} T[i,k](即样本 i 选择 j 作为 exemplar 的相似度减去它对其他所有潜在 exemplar 的最大相似度)。接着,它更新可用性:A[i,j] ← min{0, R[j,j] + sum_{i≠i,j} max(0, R[i,j])} 对于 i≠j,而对角线元素 A[j,j] ← sum_{i≠j} max(0, R[i,j])。为了防止数值振荡,它引入了阻尼因子:更新后的值为 (1-阻尼)×新值 + 阻尼×旧值。收敛条件是连续 convergence_iter 次迭代中,样本到 exemplar 的分配不变。这种方法的美妙之处在于它不需要预先指定簇数 — — 簇数由偏好(preference)向量隐含决定:较高的偏好值使得点更可能成为 exemplar,从而导致更少的簇。尽管每次迭代的计算量是 O(n²),但它通常在相对较少的迭代后收敛。
22.10 BIRCH —— 面向流数据的“记忆压缩树”
BIRCH(Balanced Iterative Reducing and Clustering using Hierarchies)专为大规模数据设计,特别是流数据场景。它通过 CF(Clustering Feature)树在线增量地聚类数据,只保留簇的充分统计量(线性和平方和、样本数),最后仅对这些簇的质心进行全局聚类,从而在有限内存下处理海量数据。
CF 树结构与插入逻辑
源码路径:sklearn/cluster/_birch.py - _CFNode.insert_cf_subcluster(120-200行)
def insert_cf_subcluster(self, subcluster):
"""Insert a new subcluster into the node."""
if not self.subclusters_:
self.append_subcluster(subcluster)
return False
threshold = self.threshold
branching_factor = self.branching_factor
# We need to find the closest subcluster among all the
# subclusters so that we can insert our new subcluster.
dist_matrix = np.dot(self.centroids_, subcluster.centroid_)
dist_matrix *= -2.0
dist_matrix += self.squared_norm_
closest_index = np.argmin(dist_matrix)
closest_subcluster = self.subclusters_[closest_index]
# If the subcluster has a child, we need a recursive strategy.
if closest_subcluster.child_ is not None:
split_child = closest_subcluster.child_.insert_cf_subcluster(subcluster)
if not split_child:
# If it is determined that the child need not be split, we
# can just update the closest_subcluster
closest_subcluster.update(subcluster)
self.init_centroids_[closest_index] = self.subclusters_[closest_index].centroid_
self.init_sq_norm_[closest_index] = self.subclusters_[closest_index].sq_norm_
return False
# things not too good. we need to redistribute the subclusters in
# our child node, and add a new subcluster in the parent
# subcluster to accommodate the new child.
else:
new_subcluster1, new_subcluster2 = _split_node(
closest_subcluster.child_,
threshold,
branching_factor,
)
self.update_split_subclusters(
closest_subcluster, new_subcluster1, new_subcluster2
)
if len(self.subclusters_) > self.branching_factor:
return True
return False
# good to go!
else:
merged = closest_subcluster.merge_subcluster(subcluster, self.threshold)
if merged:
self.init_centroids_[closest_index] = closest_subcluster.centroid_
self.init_sq_norm_[closest_index] = closest_subcluster.sq_norm_
return False
# not close to any other subclusters, and we still
# have space, so add.
elif len(self.subclusters_) < self.branching_factor:
self.append_subcluster(subcluster)
return False
# We do not have enough space nor is it closer to an
# other subcluster. We need to split.
else:
self.append_subcluster(subcluster)
return True
这段代码实现了 CF 树的插入逻辑,这是 BIRCH 增量学习的核心。当一个新样本到达时,它被包装成一个 CFSubcluster(仅包含线性和平方和、样本数),然后从根节点开始,递归地寻找最近的现有 CFSubcluster(通过最小化欧氏距离来衡量“最近”,这里使用了一个技巧:||x - c||² = ||x||² + ||c||² - 2x·c,所以最小化距离等价于最大化 x·c - 0.5(||x||² + ||c||²),在代码中通过 dist_matrix = -2 * centroids · subcluster.centroid_ + squared_norm_ 来实现)。如果找到的最近子簇有子节点(即当前节点不是叶子节点),则递归到该子节点;否则,尝试将新子簇合并到最近子簇中(如果合并后的半径仍小于阈值 threshold);如果不能合并且当前节点仍有空间(子簇数 < 分支因子),则直接添加新子簇;如果既不能合并也没有空间,则需要分裂当前节点:找到当前节点中两个最远的子簇(通过最大化距离),将子簇按距离分配到两个新节点中,并调整兄弟指针以维护叶子链表。这种设计使得 BIRCH 能够在 O(1) 平均时间内处理每个样本(假设树深度较小),并且只需存储簇的充分统计量,而非所有样本点。
全局聚类与预测
源码路径:sklearn/cluster/_birch.py - Birch._global_clustering(350-400行)
def _global_clustering(self, X=None):
"""
Global clustering for the subclusters obtained after fitting
"""
clusterer = self.n_clusters
centroids = self.subcluster_centers_
compute_labels = (X is not None) and self.compute_labels
# Preprocessing for the global clustering.
not_enough_centroids = False
if isinstance(clusterer, Integral):
clusterer = AgglomerativeClustering(n_clusters=self.n_clusters)
# There is no need to perform the global clustering step.
if len(centroids) < self.n_clusters:
not_enough_centroids = True
# To use in predict to avoid recalculation.
self._subcluster_norms = row_norms(self.subcluster_centers_, squared=True)
if clusterer is None or not_enough_centroids:
self.subcluster_labels_ = np.arange(len(centroids))
if not_enough_centroids:
warnings.warn(
"Number of subclusters found (%d) by BIRCH is less "
"than (%d). Decrease the threshold."
% (len(centroids), self.n_clusters),
ConvergenceWarning,
)
else:
# The global clustering step that clusters the subclusters of
# the leaves. It assumes the centroids of the subclusters as
# samples and finds the final centroids.
self.subcluster_labels_ = clusterer.fit_predict(self.subcluster_centers_)
if compute_labels:
self.labels_ = self._predict(X)
这段代码实现了 BIRCH 的全局聚类步骤。在插入阶段,BIRCH 只维护了 CF 树的叶子节点(每个叶子节点代表一个微簇,存储其线性和平方和、样本数)。全局聚类阶段将这些叶子节点的质心作为新的“伪样本”输入,然后运行一个完整的聚类算法(如 AgglomerativeClustering 或用户指定的估计器)来获得最终的簇划分。这种两阶段设计的优势在于:第一阶段(构建 CF 树)是增量的、内存高效的,只需 O(n) 时间和由阈值和分支因子决定的固定内存;第二阶段仅在簇的质心上运行,大大减少了数据规模(从 n 个样本降到叶子节点数,通常远小于 n),从而使得即使使用计算复杂度较高的算法(如层次聚类的 O(n² log n))也变得可行。预测时,对于每个新样本,它直接找到最近的叶子节点质心(使用 pairwise_distances_argmin),然后返回该质心对应的全局簇标签,避免了重新遍历整棵树。
22.11 谱双聚类 —— 行与列的“协同划分”
谱双聚类将谱聚类的思想扩展到两个维度:它不仅对样本进行聚类,还同时对特征进行聚类,以发现数据矩阵中的块状结构。根据不同的归一化策略,它能够识别出块对角结构(每行和每列仅属于一个簇)或棋盘结构(行和列的簇分配交替如棋盘)。
SpectralCoclustering 归一化切分
源码路径:sklearn/cluster/_bicluster.py - SpectralCoclustering._fit(150-190行)
def _fit(self, X):
normalized_data, row_diag, col_diag = _scale_normalize(X)
n_sv = 1 + int(np.ceil(np.log2(self.n_clusters)))
u, v = self._svd(normalized_data, n_sv, n_discard=1)
z = np.vstack((row_diag[:, np.newaxis] * u, col_diag[:, np.newaxis] * v))
_, labels = self._k_means(z, self.n_clusters)
n_rows = X.shape[0]
self.row_labels_ = labels[:n_rows]
self.column_labels_ = labels[n_rows:]
self.rows_ = np.vstack([self.row_labels_ == c for c in range(self.n_clusters)])
self.columns_ = np.vstack(
[self.column_labels_ == c for c in range(self.n_clusters)]
)
这段代码实现了 SpectralCoclustering 算法。它首先通过 _scale_normalize 函数对数据矩阵 X 进行行列独立归一化:即对每行除以其平方根和的平方根(行缩放因子),对每列同样处理(列缩放因子),得到归一化矩阵 Z = D_r^{-1/2} X D_c^{-1/2},其中 D_r 和 D_c 是对角矩阵,其对角线元素分别为行和列的和的平方根。这种归一化使得归一化矩阵的行和列具有均匀的范数性质,便于后续的谱分析。然后,它对归一化矩阵进行奇异值分解(SVD),并取前 1 + ceil(log2(k)) 个左奇异向量(u)和右奇异向量(v)(其中 k 是期望的簇数),这一步是基于理论:对于 k 个簇的理想块对角结构,所需的奇异向量数量与 log2(k) 成正比。接着,它将行缩放因子乘以左奇异向量和列缩放因子乘以右奇异向量垂直堆叠起来形成特征矩阵 Z,并在其上运行 K-Means 聚类(分成 k 个簇)。最后,它将前 n_rows 个标签解释为行的簇分配,后 n_cols 个标签解释为列的簇分配。由于每行和每列只被分配到一个簇(K-Means 的硬分配),结果自然是块对角结构:数据矩阵被划分成矩形块,每个块对应一个(row cluster, column cluster)对,且不同块之间在值上通常较小(因为它们属于不同的行簇或列簇)。
SpectralBiclustering 棋盘结构
源码路径:sklearn/cluster/_bicluster.py - SpectralBiclustering._fit_best_piecewise(320-360行)
def _fit_best_piecewise(self, vectors, n_best, n_clusters):
"""Find the ``n_best`` vectors that are best approximated by piecewise
constant vectors.
The piecewise vectors are found by k-means; the best is chosen
according to Euclidean distance.
"""
def make_piecewise(v):
centroid, labels = self._k_means(v.reshape(-1, 1), n_clusters)
return centroid[labels].ravel()
piecewise_vectors = np.apply_along_axis(make_piecewise, axis=1, arr=vectors)
dists = np.apply_along_axis(norm, axis=1, arr=(vectors - piecewise_vectors))
result = vectors[np.argsort(dists)[:n_best]]
return result
这段代码是 SpectralBiclustering 算法中选取最佳奇异向量的关键步骤。与 SpectralCoclustering 不同,SpectralBiclustering 假设数据具有潜在的棋盘结构:即存在行簇划分和列簇划分,使得数据矩阵近似为外积结构(即 X_ij ≈ r_i * c_j,其中 r_i 仅依赖于行 i 的簇,c_j 仅依赖于列 j 的簇)。在这种结构下,数据矩阵的奇异向量应表现出分段常数的性质:对应于同一行簇的行应有相似的值,对应于同一列簇的列也应有相似的值。因此,算法对每个奇异向量(无论是左奇异向量还是右奇异向量)应用以下过程:将其重塑为列向量,运行 K-Means 将其分成 n_clusters 段(每段对应一个潜在的簇),然后用每段的常数(该段的均值)重构出一个分段常数向量;接着计算原始向量与这个重构向量之间的欧氏距离;最终选择那些能被最好地近似为分段常数向量(即距离最小的)的 n_best 个奇异向量。这一步的直觉是:真正符合棋盘假设的奇异向量应该容易被分段常数近似,而噪声或不符合结构的向量则难以近似。选出这些向量后,算法将数据投影到它们张成的空间中,并在该低维空间上运行 K-Means 以获得最终的行和列簇分配。
22.12 均值漂移 —— 追寻密度峰的“游牧者”
均值漂移是一种基于密度梯度上升的聚类方法:它将每个样本视为一个种子点,并反复将其移动到其邻域(由带宽定义)内所有样本的均值,直到收敛到密度峰值。之后,它会合并那些非常接近的峰值(以避免过度分割),最终得到簇中心并分配样本。
带宽估计与种子选择
源码路径:sklearn/cluster/_mean_shift.py - estimate_bandwidth(20-70行)
@validate_params(
{
"X": ["array-like"],
"quantile": [Interval(Real, 0, 1, closed="both")],
"n_samples": [Interval(Integral, 1, None, closed="left"), None],
"random_state": ["random_state"],
"n_jobs": [Integral, None],
},
prefer_skip_nested_validation=True,
)
def estimate_bandwidth(X, *, quantile=0.3, n_samples=None, random_state=0, n_jobs=None):
"""Estimate the bandwidth to use with the mean-shift algorithm.
This function takes time at least quadratic in `n_samples`. For large
datasets, it is wise to subsample by setting `n_samples`. Alternatively,
the parameter `bandwidth` can be set to a small value without estimating
it.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Input points.
quantile : float, default=0.3
Should be between [0, 1]
0.5 means that the median of all pairwise distances is used.
n_samples : int, default=None
The number of samples to use. If not given, all samples are used.
random_state : int, RandomState instance, default=None
The generator used to randomly select the samples from input points
for bandwidth estimation. Use an int to make the randomness
deterministic.
See :term:`Glossary <random_state>`.
n_jobs : int, default=None
The number of parallel jobs to run for neighbors search.
``None`` means 1 unless in a :obj:`joblib.parallel_backend` context.
``-1`` means using all processors. See :term:`Glossary <n_jobs>`
for more details.
Returns
-------
bandwidth : float
The bandwidth parameter.
Examples
--------
>>> import numpy as np
>>> from sklearn.cluster import estimate_bandwidth
>>> X = np.array([[1, 1], [2, 1], [1, 0],
... [4, 7], [3, 5], [3, 6]])
>>> estimate_bandwidth(X, quantile=0.5)
np.float64(1.61)
"""
X = check_array(X)
random_state = check_random_state(random_state)
if n_samples is not None:
idx = random_state.permutation(X.shape[0])[:n_samples]
X = X[idx]
n_neighbors = int(X.shape[0] * quantile)
if n_neighbors < 1: # cannot fit NearestNeighbors with n_neighbors = 0
n_neighbors = 1
nbrs = NearestNeighbors(n_neighbors=n_neighbors, n_jobs=n_jobs)
nbrs.fit(X)
bandwidth = 0.0
for batch in gen_batches(len(X), 500):
d, _ = nbrs.kneighbors(X[batch, :], return_distance=True)
bandwidth += np.max(d, axis=1).sum()
return bandwidth / X.shape[0]
这段代码实现了均值漂移算法的带宽估计heuristic。带宽是均值漂移的核心参数,它定义了考虑的邻域大小:在每次迭代中,一个点会被移动到其带宽邻域内所有点的均值。估计方法基于这样的直觉:带宽应该足够大以覆盖典型的簇内距离,但又不能太大以至于合并不同的簇。具体来说,它计算所有 pairwise 距离的第 quantile 分位数(例如,quantile=0.5 时是中位距离),然后取平均值。为提高大规模数据的效率,它支持子采样:如果指定了 n_samples,则仅从数据中随机选取该数量的点来计算带宽估计。该估计在计算密集(O(n²)),因为它需要计算所有点对之间的距离,但在实际应用中,一旦带宽被估计出来,均值漂移本身的每次迭代可以通过空间索引(如 KD-Tree 或 Ball Tree)加速到近似 O(n log n)。该函数返回的带宽值随后用于种子点的生成和漂移过程。
并行爬山与去重
源码路径:sklearn/cluster/_mean_shift.py - MeanShift.fit(300-400行)
@_fit_context(prefer_skip_nested_validation=True)
def fit(self, X, y=None):
"""Perform clustering.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Samples to cluster.
y : Ignored
Not used, present here for API consistency by convention.
Returns
-------
self : object
Fitted instance.
"""
X = validate_data(self, X)
bandwidth = self.bandwidth
if bandwidth is None:
bandwidth = estimate_bandwidth(X, n_jobs=self.n_jobs)
seeds = self.seeds
if seeds is None:
if self.bin_seeding:
seeds = get_bin_seeds(X, bandwidth, self.min_bin_freq)
else:
seeds = X
n_samples, n_features = X.shape
center_intensity_dict = {}
# We use n_jobs=1 because this will be used in nested calls under
# parallel calls to _mean_shift_single_seed so there is no need for
# for further parallelism.
nbrs = NearestNeighbors(radius=bandwidth, n_jobs=1).fit(X)
# execute iterations on all seeds in parallel
all_res = Parallel(n_jobs=self.n_jobs)(
delayed(_mean_shift_single_seed)(seed, X, nbrs, self.max_iter)
for seed in seeds
)
# copy results in a dictionary
for i in range(len(seeds)):
if all_res[i][1]: # i.e. len(points_within) > 0
center_intensity_dict[all_res[i][0]] = all_res[i][1]
self.n_iter_ = max([x[2] for x in all_res])
if not center_intensity_dict:
# nothing near seeds
raise ValueError(
"No point was within bandwidth=%f of any seed. Try a different seeding"
" strategy or increase the bandwidth."
% bandwidth
)
# POST PROCESSING: remove near duplicate points
# If the distance between two kernels is less than the bandwidth,
# then we have to remove one because it is a duplicate. Remove the
# one with fewer points.
sorted_by_intensity = sorted(
center_intensity_dict.items(),
key=lambda tup: (tup[1], tup[0]),
reverse=True,
)
sorted_centers = np.array([tup[0] for tup in sorted_by_intensity])
unique = np.ones(len(sorted_centers), dtype=bool)
nbrs = NearestNeighbors(radius=bandwidth, n_jobs=self.n_jobs).fit(
sorted_centers
)
for i, center in enumerate(sorted_centers):
if unique[i]:
neighbor_idxs = nbrs.radius_neighbors([center], return_distance=False)[
0
]
unique[neighbor_idxs] = 0
unique[i] = 1 # leave the current point as unique
cluster_centers = sorted_centers[unique]
# ASSIGN LABELS: a point belongs to the cluster that it is closest to
nbrs = NearestNeighbors(n_neighbors=1, n_jobs=self.n_jobs).fit(cluster_centers)
labels = np.zeros(n_samples, dtype=int)
distances, idxs = nbrs.kneighbors(X)
if self.cluster_all:
labels = idxs.flatten()
else:
labels.fill(-1)
bool_selector = distances.flatten() <= bandwidth
labels[bool_selector] = idxs.flatten()[bool_selector]
self.cluster_centers_, self.labels_ = cluster_centers, labels
return self
这段代码实现了均值漂移算法的主体。它首先估计带宽(如果未提供),然后生成种子点:如果启用了 bin_seeding,它将数据离散化到大小为带宽的网格上,仅保留样本数量超过 min_bin_freq 的网格中心作为种子(这大幅减少了种子数量,提高了效率);否则,它使用所有数据点作为种子。然后,它并行地对每个种子点运行漂移过程:对于每个种子,它反复计算其带宽邻域内所有点的均值,并将种子移动到该均值,直到位移小于带宽的千分之一(1e-3 * bandwidth)或达到最大迭代次数。每个漂移过程的结果是一个潜在的簇中心及其强度(邻域内点的数量)。所有结果被收集到一个字典中,键为中心坐标,值为强度。随后,它按强度降序排序这些中心,并执行去重步骤:从最高强度开始,它保留该中心,并移除所有在其带宽邻域内的其他中心(因为它们被视为同一簇的重复估计),这确保了最终的簇中心是well-separated的(相距至少一个带宽)。最后,它将每个样本分配给最近的簇中心(如果 cluster_all=True),或者将带宽外的样本标记为噪声(-1)。这种后处理步骤对于获得高质量的簇至关重要,因为它修正了种子点过多导致的过度聚类问题,使得算法能够发现数据中真正的密度模式。
22.13 设计中的取舍
为什么采用当前方案,而不是更复杂的替代方案? 本章源码优先选择清晰、可维护且与既有 API 兼容的实现;这降低了使用和调试成本,但也意味着部分极端场景需要调用者自行权衡性能、灵活性与实现复杂度。
22.14 动手练习
22.14.1 阅读 K-Means 核心循环与初始化
阅读 sklearn/cluster/_kmeans.py 中 KMeans.fit (约 600-700 行) 与 kmeans_plusplus 函数,理解:
-
_check_params_vs_input如何动态设置_n_init与_tol -
fit中数据中心化、行范数预计算、多次初始化选择最优惯性的流程 -
kmeans_plusplus如何通过贪心采样选择初始质心
回答问题:
-
n_init='auto'时,init='k-means++'与init='random'的_n_init差异原因? -
稀疏矩阵为何仅支持 CSR 格式?中心化操作对稀疏矩阵有何影响?
22.14.2 对比 Lloyd 与 Elkan 算法的 Cython 实现
阅读 sklearn/cluster/_k_means_lloyd.pyx 与 sklearn/cluster/_k_means_elkan.pyx 的核心迭代函数:
-
lloyd_iter_chunked_dense与elkan_iter_chunked_dense的分块并行结构差异 -
Elkan 算法中
upper_bounds、lower_bounds、center_half_distances的更新逻辑 -
两者对空簇重定位
_relocate_empty_clusters_dense的复用
回答问题:
-
Elkan 算法为何需要分配
(n_samples, n_clusters)的lower_bounds数组?内存开销何时成为瓶颈? -
_update_chunk_dense中为何用_gemm计算-2 * X @ C.T而非显式循环?
22.14.3 分析层次聚类的结构化与非结构化路径
阅读 sklearn/cluster/_agglomerative.py 中 ward_tree 与 linkage_tree:
-
connectivity=None时调用 SciPyhierarchy.ward/linkage的非结构化路径 -
结构化路径下
ward_tree如何维护一阶/二阶矩并用堆增量更新距离 -
linkage_tree中IntFloatDict存储稀疏邻接表,max_merge/average_merge如何加权合并
回答问题:
-
Ward 方法为何能增量更新簇间距离?推导
compute_ward_dist的数学公式。 -
n_clusters指定时为何n_nodes = 2 * n_samples - n_clusters?树为何提前停止?
22.14.4 探究 DBSCAN 与 OPTICS 的核心循环差异
阅读 sklearn/cluster/_dbscan.py DBSCAN.fit 与 sklearn/cluster/_optics.py compute_optics_graph:
-
DBSCAN 如何用
NearestNeighbors.radius_neighbors批量获取邻域,再用 Cythondbscan_inner做 DFS 扩展 -
OPTICS 先用 kNN 算
core_distances_,主循环贪心选最小reachability_点,按需计算未处理邻域距离 -
cluster_optics_xi如何在可达度图上检测陡峭区域提取簇,cluster_optics_dbscan如何模拟 DBSCAN 边界判定
回答问题:
-
DBSCAN 为何说内存复杂度 O(n·d)?如何用预计算稀疏邻域图规避?
-
OPTICS 主循环为何难以并行化?
_set_reach_dist中pairwise_distances按需计算的意义?
22.14.5 追踪 HDBSCAN 从互达图到稳定簇的完整流程
阅读 sklearn/cluster/_hdbscan/hdbscan.py 与 _tree.pyx 核心函数:
-
mutual_reachability_graph计算max(core_dist[u], core_dist[v], d(u,v)),稠密用排序稀疏用 CSR 遍历 -
_hdbscan_brute稠密模式下mst_from_mutual_reachability(Prim) 与_hdbscan_prims空间索引模式下的 MST 构建差异 -
_condense_tree广度优先遍历单链接树,按min_cluster_size剪枝记录lambda=1/distance -
_compute_stability积分簇存活期,_get_clusters中 EOM 比较子树稳定性之和与父簇稳定性
回答问题:
-
互达距离
max(core_dist[u], core_dist[v], d(u,v))如何体现 '鲁棒单链接' 思想? -
稳定性公式
sum((lambda_val - births[parent]) * cluster_size)的几何含义?为何积分能度量簇持久度?
22.14.6 实现一个简化版 BisectingKMeans 预测逻辑
阅读 sklearn/cluster/_bisect_k_means.py 中 BisectingKMeans.predict 与 _predict_recursive:
-
predict如何复用_check_test_data与row_norms,调用_predict_recursive -
_predict_recursive如何递归下沉树结构:在父节点两个子簇中心间做最近质心分配,再分别递归 -
叶子节点直接返回其
label属性(fit末尾赋值)
回答问题:
-
为何预测时不直接用全局
cluster_centers_最近质心,而要递归下沉树? -
bisecting_strategy='biggest_inertia'与'largest_cluster'对树结构形态有何影响?
22.15 本章小结
本章围绕源码实现梳理了核心数据结构、关键调用流程与设计权衡。
以下是本章概念速查表:
| 概念 | 解释 |
|---|---|
| KMeans / MiniBatchKMeans / BisectingKMeans | 基于中心的划分聚类:全量迭代、增量小批量、层次二分三大变体 |
| Lloyd / Elkan 算法 | Lloyd 为经验EM,Elkan 用三角不等式加速,均通过 Cython 分块并行实现 |
| kmeans_plusplus | 贪心远距离采样初始化,显著提升收敛质量 |
| AgglomerativeClustering / FeatureAgglomeration | 自底向上层次合并,支持 Ward/Complete/Average/Single 链接与结构化约束 |
| 单链接 / MST / UnionFind | 最小生成树 + 并查集实现 O(n log n) 单链接聚类 |
| DBSCAN / OPTICS | 密度聚类双子星:DBSCAN 固定 eps,OPTICS 多尺度可达图 |
| HDBSCAN | 层次密度聚类巅峰:互达距离 + MST + 单链接树 + 稳定性积分 + EOM 选择 |
| SpectralClustering | 拉普拉斯特征嵌入 + 离散化分配,处理非凸簇 |
| AffinityPropagation | 消息传递选 exemplar,责任/可用性矩阵阻尼迭代 |
| BIRCH | CF 树在线压缩:叶子簇中心两阶段聚类,适合流数据 |
| SpectralCoclustering / SpectralBiclustering | 行列协同划分:前者块对角,后者棋盘结构,三种归一化策略 |
| MeanShift | 核密度梯度上升:带宽定邻域,种子爬山到峰值,后处理去重 |
下一章将继续沿着相关模块的调用链深入分析。
22.16 架构与数据流图
上述图分别展示模块依赖、调用时序、数据流和架构分层。
第 23 章 —— 降维与流形学习 —— 高维数据的“维度跃迁”
23.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
理解 PCA、IncrementalPCA、KernelPCA、TruncatedSVD 的核心数学原理与工程实现差异
掌握 NMF 与 MiniBatchNMF 的坐标下降、乘法更新、在线学习机制及 β 散度族
深入 FactorAnalysis 的 EM 迭代、旋正交旋转与对数似然监控
剖析 FastICA 的平行/通货紧缩算法、白化策略与非高斯性近似函数
探索 SparsePCA/MiniBatchSparsePCA 与 DictionaryLearning 的稀疏编码与字典更新循环
理解 LDA 的变分推断、在线学习、困惑度评估与 Dirichlet 期望计算
对比 t-SNE 精确/ Barnes-Hut 近似的概率计算、KL 散度目标、梯度下降优化与四叉树加速
理解 Isomap 的测地线保持、ClassicalMDS 的经典缩放、SpectralEmbedding 的拉普拉斯特征映射、LLE 的局部线性重建与 LTSA/MLLE/Hessian 变体
掌握 SMACOF MDS 的应力最小化、Isotonic 回归非度量化、初始化策略与并行化
理解底层加速基础设施:随机化 SVD、ARPACK、LOBPCG、四叉树、Cython 协调下降、BLAS 调用与 OpenMP 并行
23.2 生活类比
想象降维与流形学习是一场高维数据的“折纸艺术展”。在这个创意空间里,我们探索不同技巧将复杂的高维雕塑折叠成简洁而富有表达力的低维形态。PCA 就像正交投影折纸:沿最大方差方向折叠,保留最多信息,如把立体雕塑压成最佳投影照片。当数据量太大时,IncrementalPCA 采用分批折纸的策略:内存受限时,先分批折叠局部结构,最后再拼合全局结果,特别适合处理海量数据流。KernelPCA 则是非线性扭曲折纸:先用核函数把数据像橡皮泥般扭曲到高维空间,让原本弯曲的结构(比如瑞士卷)变得可直线折叠,再施加线性投影。TruncatedSVD 专攻稀疏矩阵的“压缩折纸”,不做中心化处理,直接对稀疂词文档矩阵做截断奇异值分解,是潜语义分析(LSA)的核心引擎。NMF 像非负积木拼搭:只允许加法操作,把图像或文本拆解成“部件×系数”的可解释表示,每个部件都有明确含义。MiniBatchNMF 进化为流式积木拼搭:以小批量方式不断更新字典(积木库),适合处理大规模或连续流入的数据。FactorAnalysis 是概率生成模型折纸:假设观察到的高维数据由少数隐藏因子线性混合后加上各自独立噪声生成,通过 EM 算法迭代估计这些隐藏结构。FastICA 扮演盲源分离“解混音”角色:利用非高斯性最大化原理,把混合音轨还原为独立的人声或乐器声,无需知道混合过程。SparsePCA/MiniBatchSparsePCA 强化了稀疏积木拼搭思想:不仅要求非负,还施加稀疏约束,迫使每个主成分只使用少数原始特征,增强解释性。DictionaryLearning 更进一步实现自学习字典:同时学习最优基(积木库)和稀疏使用码,就像自动发现最适合当前任务的积木类型和组合方式。LDA 折射出文档主题“概率生成器”的智慧:把每篇文档视为主题的混合物,每个主题又是词的分布,采用变分贝叶斯推断从海量文本中学习主题结构。t-SNE 堪称邻域关系保真的“投影仪”:在高维空间中拉近相似点、推开不相似点,精确版计算量巨大(O(N²)),而 Barnes-Hut 近似借助四叉树将复杂度降至 O(N log N),在保持局部结构的同时实现高效嵌入。Isomap 坚持测地线保持原则:先在近邻图中求最短路径(近似流形真实距离),再用经典 MDS 展平,能够展开“弯曲的纸张”而不撕裂。ClassicalMDS 则是经典缩放大师:从成对距离矩阵出发,经双中心化和特征分解还原坐标,在欧几里得空间中等价于 PCA。SpectralEmbedding 采用拉普拉斯特征映射绘图谱:先构建样本相似度图,再从归一化拉普拉斯矩阵提取特征向量进行嵌入。LLE 专注局部线性重建的拼图还原:每个点用其邻居的线性组合来近似,保持局部几何不变,最后全局对齐坐标。针对标准 LLE 易坍缩的缺陷,出现了 MLLE(多重权重)、LTSA(切空间 PCA 对齐)和 Hessian(局部切空间二阶约束)等鲁棒变体。最后,SMACOF MDS 把降维想象成弹簧模型:将样本间距离差视为拉伸或压缩的弹簧能量,通过 Guttman 变换迭代调整点位置直至系统达到应力最小平衡状态,支持处理纯序数(非度量)数据。
23.3 源码地图
sklearn/decomposition/_pca.py
├── PCA
│ ├── init()
│ ├── fit()
│ ├── fit_transform()
│ ├── _fit()
│ ├── _fit_full()
│ ├── _fit_truncated()
│ ├── score_samples()
│ ├── score()
│ └── sklearn_tags()
├── _assess_dimension()
├── _infer_dimension()
sklearn/decomposition/_base.py
├── _BasePCA
│ ├── get_covariance()
│ ├── get_precision()
│ ├── transform()
│ ├── _transform()
│ ├── inverse_transform()
│ └── _n_features_out
├── _BasePCA.fit()
sklearn/decomposition/_incremental_pca.py
├── IncrementalPCA
│ ├── init()
│ ├── fit()
│ ├── partial_fit()
│ ├── transform()
│ └── sklearn_tags()
sklearn/decomposition/_kernel_pca.py
├── KernelPCA
│ ├── init()
│ ├── fit()
│ ├── fit_transform()
│ ├── _fit_transform_in_place()
│ ├── _fit_inverse_transform()
│ ├── transform()
│ ├── inverse_transform()
│ ├── _get_kernel()
│ ├── sklearn_tags()
│ └── _n_features_out
sklearn/decomposition/_truncated_svd.py
├── TruncatedSVD
│ ├── init()
│ ├── fit()
│ ├── fit_transform()
│ ├── transform()
│ ├── inverse_transform()
│ ├── sklearn_tags()
│ └── _n_features_out
sklearn/decomposition/_sparse_pca.py
├── _BaseSparsePCA
│ ├── init()
│ ├── fit()
│ ├── transform()
│ ├── inverse_transform()
│ ├── _n_features_out
│ └── sklearn_tags()
├── SparsePCA
│ ├── init()
│ └── _fit()
└── MiniBatchSparsePCA
├── init()
└── _fit()
sklearn/decomposition/_nmf.py
├── _beta_divergence()
├── _special_sparse_dot()
├── _beta_loss_to_float()
├── _initialize_nmf()
├── _update_coordinate_descent()
├── _fit_coordinate_descent()
├── _multiplicative_update_w()
├── _multiplicative_update_h()
├── _fit_multiplicative_update()
├── non_negative_factorization()
├── _BaseNMF
│ ├── init()
│ ├── _check_params()
│ ├── _check_w_h()
│ ├── _compute_regularization()
│ ├── fit()
│ ├── inverse_transform()
│ ├── _n_features_out
│ └── sklearn_tags()
├── NMF
│ ├── init()
│ ├── _check_params()
│ ├── fit_transform()
│ ├── _fit_transform()
│ └── transform()
└__MiniBatchNMF__
├── init()
├── _check_params()
├── _solve_W()
├── _minibatch_step()
├── _minibatch_convergence()
├── fit_transform()
├── _fit_transform()
├── transform()
└── partial_fit()
sklearn/decomposition/_cdnmf_fast.pyx
├── _update_cdnmf_fast()
sklearn/decomposition/_dict_learning.py
├── _check_positive_coding()
├── _sparse_encode_precomputed()
├� _sparse_encode()
├── _sparse_encode()
├── _update_dict()
├── _dict_learning()
├── dict_learning_online()
├── dict_learning()
├── _BaseSparseCoding
│ ├── init()
│ ├── _transform()
│ ├── transform()
│ ├── _inverse_transform()
│ └── inverse_transform()
├── SparseCoder
│ ├── init()
│ ├── fit()
│ ├── transform()
│ ├── inverse_transform()
│ ├── sklearn_tags()
│ └── _n_features_out
├── DictionaryLearning
│ ├── init()
│ ├── fit()
│ ├── fit_transform()
│ ├── _n_features_out
│ └── sklearn_tags()
└── MiniBatchDictionaryLearning
├── init()
├── _check_params()
├── _initialize_dict()
├── _update_inner_stats()
├── _minibatch_step()
├── _check_convergence()
├── fit()
├── partial_fit()
├── _n_features_out
└── sklearn_tags()
sklearn/decomposition/_factor_analysis.py
├── FactorAnalysis
│ ├── init()
│ ├── fit()
│ ├── transform()
│ ├── get_covariance()
│ ├── get_precision()
│ ├── score_samples()
│ ├── score()
│ ├── _rotate()
│ └── _n_features_out
└── _ortho_rotation()
sklearn/decomposition/_fastica.py
├── _gs_decorrelation()
├── _sym_decorrelation()
├── _ica_def()
├── _ica_par()
├── _logcosh()
├── _exp()
├── _cube()
├── fastica()
└── FastICA
├── init()
├── _fit_transform()
├── fit_transform()
├── fit()
├── transform()
├── inverse_transform()
├── _n_features_out
└── sklearn_tags()
sklearn/decomposition/_lda.py
├── LatentDirichletAllocation
│ ├── init()
│ ├── _init_latent_vars()
│ ├── _e_step()
│ ├── _em_step()
│ ├── sklearn_tags()
│ ├── _check_non_neg_array()
│ ├── partial_fit()
│ ├── fit()
│ ├── _unnormalized_transform()
│ ├── transform()
│ ├── fit_transform()
│ ├── _approx_bound()
│ ├── score()
│ ├── _perplexity_precomp_distr()
│ ├── perplexity()
│ └── _n_features_out
sklearn/decomposition/_online_lda_fast.pyx
├── mean_change()
├── _dirichlet_expectation_1d()
├── _dirichlet_expectation_2d()
└── psi()
sklearn/manifold/_t_sne.py
├── _joint_probabilities()
├── _joint_probabilities_nn()
├── _kl_divergence()
├── _kl_divergence_bh()
├── _gradient_descent()
├── trustworthiness()
└── TSNE
├── init()
├── _check_params_vs_input()
├── _fit()
├── _tsne()
├── fit_transform()
├── fit()
├── _n_features_out
└── sklearn_tags()
sklearn/manifold/_barnes_hut_tsne.pyx
├── compute_gradient()
├── compute_gradient_positive()
├── compute_gradient_negative()
└── gradient()
sklearn/manifold/_utils.pyx
├── _binary_search_perplexity()
sklearn/neighbors/_quad_tree.pxd
├── _QuadTree 结构定义
├── insert_point()
├── _init_cell()
├── _init_root()
├── _resize()
├── _resize_c()
├── _get_cell()
├── _get_cell_ndarray()
├── _check_point_in_cell()
├── _select_child()
├── _is_duplicate()
└── summarize()
sklearn/neighbors/_quad_tree.pyx
├── _QuadTree
│ ├── cinit()
│ ├── dealloc()
│ ├── build_tree()
│ ├── insert_point()
│ ├── _is_duplicate()
│ ├── _select_child()
│ ├── _init_cell()
│ ├── _init_root()
│ ├── _check_point_in_cell()
│ ├── _check_coherence()
│ ├── summarize()
│ ├── get_cell()
│ ├── _get_cell()
│ ├── _get_cell_ndarray()
│ ├── _resize()
│ ├── _resize_c()
│ ├── reduce()
│ ├── getstate()
│ ├── setstate()
│ ├── _py_summarize()
│ ├── cumulative_size
│ └── leafs
sklearn/manifold/_classical_mds.py
├── ClassicalMDS
│ ├── init()
│ ├── fit()
│ ├── fit_transform()
│ └── sklearn_tags()
sklearn/manifold/_isomap.py
├── Isomap
│ ├── init()
│ ├── _fit_transform()
│ ├── fit()
│ ├── fit_transform()
│ ├── reconstruction_error()
│ ├── transform()
│ └── sklearn_tags()
sklearn/manifold/_spectral_embedding.py
├── _graph_connected_component()
├── _graph_is_connected()
├── _set_diag()
├── spectral_embedding()
├── _spectral_embedding()
└── SpectralEmbedding
├── init()
├── _get_affinity_matrix()
├── fit()
├── fit_transform()
└── sklearn_tags()
sklearn/manifold/_locally_linear.py
├── barycenter_weights()
├── barycenter_kneighbors_graph()
├── null_space()
├── _locally_linear_embedding()
├── locally_linear_embedding()
└── LocallyLinearEmbedding
├── init()
├── _fit_transform()
├── fit()
├── fit_transform()
├── transform()
└── transform()
sklearn/manifold/_mds.py
├── _smacof_single()
├── smacof()
└── MDS
├── init()
├── fit()
├── fit_transform()
└── sklearn_tags()
23.4 PCA 与增量变体 —— 方差最大化的“主成分捕手”
PCA 如何通过 _fit 自动选择 full/covariance_eigh/arpack/randomized 四大求解器,以及 MLE 自动选维原理
在 _fit 方法中(sklearn/decomposition/_pca.py:350-430),PCA 通过一系列逻辑判断自动选择最合适的 SVD 求解器。当 svd_solver='auto' 时,决策依据包括:特征数量(n_features)、样本数量(n_samples)以及目标成分数(n_components)。具体来说:
-
当特征数 ≤ 1000 且样本数 ≥ 特征数的 10 倍时,选择
covariance_eigh求解器——此方法通过计算协方差矩阵的特征分解来避免对大样本矩阵做完整 SVD,在特征少样本多的场景下计算量显著降低; -
当最大维度(样本数或特征数中较大者)≤ 500,或目标成分数为 'mle'(最大似然估计)时,选择精确的
fullSVD; -
当目标成分数在 [1, 0.8 × min(n_samples, n_features)] 范围内时,选择
randomized随机化 SVD——此方法利用随机投影近似矩阵的范围,随后做少量幂迭代以提高精度,在近似低秩矩阵时既快又准; -
其他情况下(如目标成分数过多或维度极高),回退到
fullSVD以保证精度。
值得注意的是,当用户指定 n_components='mle' 时,无论 svd_solver 最初设置为什么,都会被强制使用 full 求解器,因为 Minka 的 MLE 方法需要完整的特征值谱来计算每个可能秩的对数似然(详见 _assess_dimension 函数)。此方法基于假设:观测数据是某个低维线性子加上高斯各向同性噪差的生成模型,通过比较不同秩假设下的数据边际似然来自动选择最优维度。在 _assess_dimension 中(同上文件:30-80),对数似然计算涉及伽马函数、矩阵行列式及特征值分布,当特征值谱中存在显著间隙时,对应的秩会获得最高似然值。
_fit_full 中 SVD 与协方差特征分解两条路径的数值差异、白化实现、确定性符号翻转
当求解器为 full 或 covariance_eigh 时,PCA 进入 _fit_full 方法(同上文件:430-600)。两条路径虽均旨在得到中心化数据的 SVD 分解 X = UΣVᵀ,但实现差异导致数值行为不同:
-
full路径:直接对中心化后的数据矩阣 X_centered 调用 scipy.linalg.svd(或 Array API 等价项),得到左奇异向量 U、奇异值 Σ 和右奇异向量 Vt。解释方差由 Σ²/(n_samples-1) 计算得出。 -
covariance_eigh路径:为避免在 n_samples >> n_features 时构造巨大的协方差矩阵副本,间接计算协方差 C = XᵀX/(n_samples-1)(注意此处未先中心化 X,而是通过减去均值外积的形式来消除均值影响),然后对 C 做特征分解。特征值对应解释方差,特征向量即为 Vt。此方法在特征少样本多时极为高效,但因为形成了 XᵀX(条件数约为原始数据矩阵条件数的平方),在奇异值跨度大时会放大数值误差,导致特征分解不如直接 SVD 稳定。
白化(whitening)在两种路径中实现一致:当 whiten=True 时,主成分向量(即 components_ = Vt)会被乘以 √(explained_variance_),使得投影后的各主成分在样本维度上具有单位方差且互不相关。这一步实际上将 Vt 缩放为 Vt·√Σ,相当于投影矩阵变为 U(即白化后的得分为 U·√Σ·√(n_samples-1))。
为确保符号确定性(因为奇异向量的符号是任意的),所有求解路径在得到 U 和 Vt 后都会调用 svd_flip 函数(sklearn/utils/extmath.py),该函数根据绝对和最大元素符号原则(broadly:使得奇异向量的最大绝对值为正)同步翻转 U 和 Vt 的符号,从而保证在相同数据下多次运行结果完全一致。
_fit_truncated 中 ARPACK 与随机化 SVD 处理稀疏/稠密数据、中心化策略、噪声方差估计
当求解器为 arpack 或 randomized 时,PCA 使用 _fit_truncated 方法(同上文件:600-750)计算截断 SVD。此方法专门设计用于避免对完整数据矩阵做特征分解,只计算前 k 个奇异值及向量,内存与计算复杂度均与目标维度而非原始维度相关。
-
对于稀疏输入:PCA 调用 scipy.sparse.linalg.svds(ARPACK 包装),该函数接受稀疏矩阵并直接计算其截断 SVD。特别注意的是,svds 返回的奇异值顺序是递增的,而标准 SVD 惯用递减顺序,因此需要通过
S = S[::-1]翻转奇异值数组;同时,为了符号确定性,同样调用svd_flip(U[:, ::-1], Vt[::-1])来调整左右奇异向量。 -
对于稠密输入:当使用
randomized求解器时,PCA 调用内部的_randomized_svd函数(sklearn/utils/extmath.py),该方法通过随机抽样构造矩阵的范围,随后在小范囆矩阵上做精确特征分解,最后将结果投射回原始空间。此方法在近似低秩矩阵时收敛 schnell,尤其当目标秩远小于矩阵维度时优势显著。
无论稀疏还是稠密,此路径均需要先对数据进行中心化(减去列均值),因为 SVD 本身假设数据零均值。中心化后的矩阵用于奇异值分解,而均值向量被存储为 mean_ 属性,以便在 transform 阶段使用。噪声方差估计遵循概率 PCA 模型:当保留的成分数小于 min(n_features, n_samples) 时,噪声方差取被丢弃奇异值的均值;否则设为零,表示模型认为所有方差均来自潜在结构而非噪声。
IncrementalPCA 如何通过 _incremental_mean_and_var 增量更新统计量,构造增广矩阵合并旧基与新批次 SVD
IncrementalPCA(sklearn/decomposition/_incremental_pca.py)的核心思想是:无需一次性将全部数据载入内存,而是通过小批量(mini-batch)逐步更新足够统计量,从而在常数内存复杂度下逼近批量 PCA 的结果。其关键在于 _incremental_mean_and_var 函数(同上文件,由 sklearn.utils.extmath 提供),该函数能够增量计算均值和方差:
-
均值更新:新均值 = (旧样本 count ×旧均值 + 新批次和) / 总样本 count
-
方差更新:利用 Chao 的并行算法,新方差可由旧方差、新批次方差及两批次均值之差共同计算得出,避免了存储全部数据。
在 partial_fit 方法中(同上文件:150-280),每到达一个新批次时:
-
首先用
_incremental_mean_and_var更新累积均值mean_和方差var_; -
若非首批(即已有基础成分),则构造增广矩阵:垂直拼接 [singular_values_ × components_(旧基)、当前批次数据(已去均值)、均值校正项]。其中,均值校正项为 √(n_seen / n_total × n_batch) · (col_mean - col_batch_mean),其作用是确保旧基在新总均值下的表示保持不变;
-
对增广矩阵做完整 SVD,截断前 n_components 个右奇异向量作为新的 components_;
-
更新 singular_values_(来自 S)、explained_variance_(来自 S²/(n_total-1))等属性;
-
若成分数既未等于样本数也未等于特征数,则噪声方差取被丢弃奇异值对应的解释方差均值。
值得一提的是,增广矩阵的巧妙设计使得增量更新在数学上等价于“一把过”做 SVD——因为旧基部分实际上代表了之前数据在当前总均值下的投影,而新数据和均值校正项共同确保了整个数据集在新总均值下的零均值性质。这一机制让 IncrementalPCA 能在内存受限时处理任意大小的数据集,而只需保留当前批次及足够统计量(如均值、方差及当前基底)。
Array API 兼容层与 _assess_dimension/_infer_dimension 实现 Minka MLE 原理
PCA 对 Array API 的支持通过 sklearn/utils/_array_api.py 中的 get_namespace 函数实现,该函数能够自动识别输入数据所属的数组后端(如 NumPy、CuPy、PyTorch 等),并返回对应的命名空间对象(xp),使得后续所有算子(如求和、平方、特征分解)均可使用后端原生实现而无需额外适配。在 _assess_dimension 和 _infer_dimension 函数中(sklearn/decomposition/_pca.py:30-110),所有数组操作均通过 xp 进行,例如:
-
xp.sum用于计算特征值尾部和; -
xp.reshape、xp.asarray用于确保数组形状与类型; -
xp.log、xp.lgamma用于对数似然计算中的对数及伽马函数; -
xp.where、xp.sqrt用于特征值阈值处理与方差计算; -
xp.cumulative_sum、xp.searchsorted用于当 n_components 为小数(如 0.8)时根据累积解释方差阈值反推成分数。
得益于这种后端中立的编写方式,PCA 的 Minka MLE 选维功能能够无缝运行在 GPU(如 CuPy)或深度学习框架(如 PyTorch)张量之上,而无需修改核心逻辑。这也解释了为什么在 __sklearn_tags__ 中,PCA 会根据当前使用的求解器动态报告 Array API 兼容性:仅当求解器为 'full' 或在 'randomized' 且使用 QR 正规化时,才认为其兼容,因为其他求解器(如 'arpack')依赖特定的底层库(如 ARPACK)目前未提供 Array API 绑定。
23.5 核 PCA 与截断 SVD —— 从线性到非线性的“降维跃迁”
KernelPCA 如何通过 _get_kernel 构建核矩阵、_fit_transform_in_place 中心化特征分解、特征向量归一化实现非线性映射
KernelPCA(sklearn/decomposition/_kernel_pca.py)的核心在于:先将原始数据通过非线性映射 φ 投射到一个再生核希尔伯特空间(RKHS),使得在该空间中线性关系对应于原始空间中的非线性关系;随后在该特征空间中执行标准 PCA,而无需显式计算 φ(x)——这正是“核技巧”的精髓。具体实现分为三步:
-
核矩阵构建:通过
_get_kernel方法(同上文件,在 fit 过程中调用),使用 sklearn.metrics.pairwise.pairwise_kernels 计算 Gram 矩阵 K,其中 Kᵢⱼ = k(xᵢ, xⱼ)。支持的核包括线性、多项式、RBF、Sigmoid 等,亦可接受自定义核函数。核矩阵对称且半正定(在数值误差容忍范围内)。 -
特征分步:核心在于
_fit_transform_in_place方法(同上文件:250-350),该方法在核矩阵 K 上执行中心化(减去行均值、列均值并加回总均值:K_centered = K - 1_N K - K 1_N + 1_N K 1_N,其中 1_N 为全一向量),随后做特征分解。中心化操作的等价表述是:在特征空间中,我们计算的是 φ(X) 的协方差矩阵,而 K_centered 正是该协方差矩阵的 Gram 矩阵形式。求解器自动选择逻辑与 PCA 类似:当成分数 < 10 且样本数 > 200 时优先使用
arpack(调用 scipy.sparse.linalg.eigsh);否则使用dense(调用 scipy.linalg.eigh)做全特征分解;亦支持randomized近似特征分解(调用 sklearn.utils.extmath._randomized_eigsh)。特征分解得到特征值(解释方差的比例)和特征向量(在核特征空间中的基底)。 -
特征向量归一化:为得到在特征空间中的实际投影,需将特�量除以 √特征值(即 V = V_raw / √Λ),因为在核 PCA 中,嵌入坐标由 α = V √Λ 给出(其中 α 是对偶系数)。特别注意的是,此归一化步骤延迟到实际需要时(如 transform)再执行,以避免在特征值接近零时产生数值爆炸——在代码中,除法操作被包裹在 np.sqrt 中并加入最小值裁剪(如 xp.finfo(eps))。
逆变换 _fit_inverse_transform 如何用核脊回归学习预像,dual_coef_ 存储对偶系数
当 fit_inverse_transform=True 时,KernelPCA 尝试学习从低维嵌入回到原始输入空间的近似映射(即“预像”学习)。其思路是:利用核 ridge 回归(KRR),将训练数据的低维表示(即特征空间中的主成分投影 Z = V √Λ)作为自变量,原始数据 X 为因变量,求解最小化 ‖X - Z α‖² + α‖α‖² 的对偶系数 α。在核方法中,此问题有封闭形式解:α = (ZᵀZ + λI)⁻¹ ZᵀX,但利用核技巧可进一步化简。具体到实现(sklearn/decomposition/_kernel_pca.py:350-400):
-
首先,计算训练数据在特征空间中的投影:Z_train = eigenvectors_ * √eigenvalues_(此即 U √Σ 在等价 PCA 中的形式);
-
其次,构建 Z_train 上的核矩阾 K_Z = k(Z_train, Z_train)(注意此处仍然是原始核函数,因为预像学习发生在原始空间);
-
然后,求解带岔正则化的线性系统:(K_Z + αI) dual_coef_ = X,其中 dual_coef_ 正是我们所求的对偶系数(满足 X ≈ dual_coef_ᵀ k(Z_train, ·));
-
最后,将 dual_coef_ 存储为模型属性,以便在
inverse_transform中使用:对于新点 z,其在原始空间的重构为 ẑ = dual_coef_ᵀ k(z, Z_train).
值得注意的是,dual_coef_ 的形状为 (n_samples, n_features),每列对应一个原始特征的重构权重。此方法实际上在特征空间中做了一个线性回归(从嵌入到原始空间),而核技巧让我们得以在避免显式特征映射的前提下完成此任务。
TruncatedSVD 不中心化直接分解稀疏矩阵,ARPACK 与随机化 SVD 处理 LSA 场景的异同
TruncatedSVD(sklearn/decomposition/truncated_svd.py)与 PCA 的根本区别在于:它不对数据进行中心化处理,直接在(可能稀疏的)数据矩阵 X 上做截断奇异值分解 X ≈ U Σ Vᵀ,其中 U 包含左奇异向量,Σ 为对角奇异值矩阵,Vᵀ 含有右奇异向量(即我们所需的 components)。这一设计使得 TruncatedSVD 特别适用于稀疏计数矩阵(如词袋或 TF-IDF 矩阵),因为在这些矩阵中,均值往往不具备明确意义(例如,所有文档的“平均词频”可能因语料库而异),且中心化会破坏稀疏性——将大量零变为非零导致内存占用激增。
在求解器选择上,TruncatedSVD 支持两种算法:
-
arpack:调用 scipy.sparse.linalg.svds 对稀疏矩阵做特�分解。该方法在寻找极小或极大特征值时表现优异,特别适合当我们只需要前 k 个成分(k ≪ min(n_samples, n_features)) 时。然而,svds 返回的奇异值是递增顺序,因此代码中需通过Sigma = Sigma[::-1]翻转以匹配惯用的递减顺序;同时,为符号确定性,同样调用svd_flip(U[:, ::-1], Vt[::-1])。 -
randomized:调用内部的_randomized_svd函数,该方法通过随机抽样构造矩阵范围,随后在小矩阵上做精确分解。此方法在处理稠密矩阵时效果显著,但在稀疏场景下同样适用——因为其核心操作(如矩阵乘法)可通过 sklearn.utils.sparsefuncs.safe_sparse_dot 高效实现以保持稀疏性。
两种方法在 LSA(潜语义分析)场景下的表现有细微差别:
-
数值稳定性:ARPACK 在处理病态矩阵(如奇异值跨度极大)时可能收敛慢或产生不准确特征向量,而随机化 SVD 受幂迭代次数(n_iter)影响,次数过低会导致近似误差;
-
速度:当目标成分数很少时,ARPACK 通常更快,因为它直接锁定目标特征值附近;而当数据非常大且稀疏时,随机化 SVD 受益于其低计算复杂度(O(n_samples n_components + n_components³) vs ARPACK 的取决于特征间隙);
-
并行性:随机化 SVD 更易利用多核(因为其核心是小矩阵特征分解),而 ARPACK 在 scipy.sparse.linalg 中目前多为单线程实现。
组件存储差异:KernelPCA 存 eigenvectors_/eigenvalues_,TruncatedSVD 存 components_/singular_values_
如前所述,不同算法在存储主成分时采用了不同惯例,这反映了它们底层数学对象的性质:
-
在 PCA 及其变体(如 KernelPCA)中,我们储存的是右奇异向量(Vt)或其特征向量等价物。例如:
-
PCA 中,
components_等于 Vt(右奇异向量),explained_variance_等于奇异值的平方除以 (n_samples-1); -
KernelPCA 中,
eigenvectors_存储的是中心化核矩阵的特征向量(在特征空间中的基底),eigenvalues_为对应特征值(与解释方差成正比); -
值得注意的是,KernelPCA 的实际嵌入需要进一步处理:
X_transformed = eigenvectors_ * √eigenvalues_(即特征向量按特征值的平方根缩放),因为特征向量本身是归一化的(范数为 1),而真正的主成分应包含方差信息。
-
-
在 TruncatedSVD 中,则直接存储右奇异向量作为
components_(即 Vt),并将奇异值存储为singular_values_。这是因为 TruncatedSVD 的目标正是获得近似的 Vt 和 Σ,以满足 X ≈ U Σ Vᵀ。其explained_variance_通过投影数据 X Vt 的方差计算得出(等价于在某些条件下接近奇异值的平方/(n_samples-1))。
这种存储差异不仅是命名习惯,更反映了每种方法在后续使用时的预期行为:例如,在需要做投影(transform)时,PCA 族会直接计算 X @ components_.T(因为 components_ 已经是可投影的基底),而 TruncatedSVD 同样如此;但若欲从低维表示回推特征空间(如在核 PCA 中做逆变换),则需要额外的缩放步骤——这也解释了为什么 KernelPCA 需要学习逆变换(通过核脊回归),而 TruncatedSVD 的逆变换仅是简单的矩阵乘法(X ≈ U Σ Vᵀ ⇒ 近似原始数据为 X ≈ X Vtᵀ Vt,虽然这里实际上是投影后的再构,非真实逆变换)。
23.6 NMF 与 MiniBatchNMF —— 基于部分的“积木分解”
NMF 目标函数:β 散度族(Frobenius/KL/IS) + L1/L2 正则,_beta_divergence 统一计算
非负矩阵分解(NMF)的核心目标是近似地将一个非负矩阵 X(形状为 n_samples × n_features)分解为两个非负矩阵的乘积:X ≈ W H,其中 W 称为系数矩阵(形状为 n_samples × n_components),H 称为字典或成分矩阵(形状为 n_components × n_features)。为了衡量近似质量,NMF 采用了 β 散度(beta-divergence)作为通用损失函数,该族能够涵盖多种常见散度作为特例:
-
当 β = 2 时,退化为半弗罗贝尼乌斯散度:½ ‖X - WH‖_F²;
-
当 β = 1 时,为广义 Kullback-Leibler 散度:D_KL(X‖WH) = Σᵢⱼ [Xᵢⱼ log(Xᵢⱼ/(WH)ᵢⱼ) - Xᵢⱼ + (WH)ᵢⱼ];
-
当 β = 0 时,为伊塔库-斋托散度:D_IS(X‖WH) = Σᵢⱼ [Xᵢⱼ/(WH)ᵢⱼ - log(Xᵢⱼ/(WH)ᵢⱼ) - 1]。
在 sklearn 中,β 散度的统一实现见 _beta_divergence 函数(sklearn/decomposition/_nmf.py:30-120),该函数通过条件分支高效处理稀疏与稠密输入,并支持开平方根选项(用于在优化中引入范数而非平方范数)。为防止数值问题(如对零取 log),代码中引入了微小常数 EPSILON(np.finfo(np.float32).eps)作为容错阈值。
除了重构误差外,NMF 目标函数还包含可选的 L1 和 L2 正则项,以控制解的稀疏性和小值倾向:
-
L1 正则:对 W 和 H 的元素绝对值求和,促进稀疏性(许多元素趋近于零);
-
L2 正则:对 W 和 H 的弗罗贝尼乌斯范数求和,防止过大值出现,使解更平滑。
正则项的强度由参数 alpha_W、alpha_H 和 l1_ratio 控制,其中实际加载为:
-
l1_reg_W = n_features × alpha_W × l1_ratio(按特征数缩放,使得 W 的 L1 项与数据匹配项同量级);
-
l2_reg_W = n_features × alpha_W × (1 - l1_ratio)(同样按特- l1_reg_H = n_samples × alpha_H × l1_ratio(按样本数缩放,使得 H 的 L1 项与数据匹配项同量级);
-
l2_reg_H = n_samples × alpha_H × (1 - l1_ratio)(同样按样本数缩放)。
此设计确保了无论数据集大小如何,正则项的影响都能保持相对平衡——例如,增大样本数不会无意中放大了对 H 的正则惩罚。
NNDSVD 系列初始化 _initialize_nmf:SVD 正负部分拆解、零值填充策略差异
良好的初始化对 NMF 收敛速度和最终解质量至关重要。sklearn 提供了几种初始化策略,其中 NNDSVD(Nonnegative Double Singular Value Decomposition)及其变体因能利用 SVD 的低秩结构而备受青睐。具体实现在 _initialize_nmf 函数(同上文件:350-450):
-
首先,对输入矩阵 X 做随机化 SVD(或精确 SVD,取决于求解器),获得近似分解 X ≈ U Σ Vᵀ,其中 U 和 V 为左、右奇异向量矩阵,Σ 为对角奇异值矩阵;
-
利用 SVD 的性质:虽然 U 和 V 可能包含负值,但它们的列仍然互交正交。为得到非负初始猜想,算法分别取奇异向量的正负部分:
-
对于第 j 个成分(j ≥ 1),设 x = U[:,j],y = V[j,:](注意 V 已被转置,因此其行对应奇异向量);
-
将 x 分解为正部分 x₊ = max(x, 0) 和负部分 x₋ = |min(x, 0)|(同理处理 y);
-
计算两部分的 L2 范数:‖x₊‖、‖x₋‖、‖y₊‖、‖y₋‖;
-
比较交叉乘积 m₊ = ‖x₊‖ ‖y₊‖ 和 m₋ = ‖x₋‖ ‖y₋‖;
-
若 m₊ > m₋,则取 u = x₊ / ‖x₊‖、v = y₊ / ‖y₊‖、σ = m₊;否则取 u = x₋ / ‖x₋‖、v = y₋ / ‖y₋‖、σ = m₋;
-
最后,第 j 列的初始猜想为:W[:,j] = √(Σⱼⱼ σ) u,H[j,:] = √(Σⱼⱼ σ) v。
-
-
第 0 个成分(主成分)处理较为简单:由于首个奇异 triplet(u₁, σ₁, v₁)在非负矩阵上近似为非负(由佩龙-弗罗贝尼乌斯定理可知,非负矩阵的最大特征值对应非负特征向量),于是直接设为:W[:,0] = √σ₁ |u₁|,H[0,:] = √σ₁ |v₁|。
-
得到初始 W 和 H 后,将低于阈值 eps(默认 1e-6)的元素截断为零,以强化稀疏性。
-
根据 init 参数进一步调整零值:
-
'nndsvd':保持上述零值为零;
-
'nndsvda':将所有零值替换为 X 的均值(适用于希望避免过度稀疏、使所有成分都有非零贡献的场景);
-
'nndsvdar':将零值替换为均值加上小幅随机噪声(均值的 1/100 倍乘以标准正态),在保持一定非零基线的同时引入轻微扰动以帮助跳出局部最优;
-
'random':完全忽略 SVD 信息,直接用均值的平方根缩放的高斯噪声初始化 W 和 H(随后取绝对值)。
-
其中,'nndsvda' 被设为默认选择(当 n_components ≤ min(n_samples, n_features) 时),因为它在保持 SVD 引导方向的同时,通过均值填充零值避免了因过多零元素导致的收敛困难——尤其在后续使用乘法更新或坐标下降时,严格零可能导致梯度消失而卡在次优解。
坐标下降求解器 _fit_coordinate_descent 与 Cython 核心 _update_cdnmf_fast:逐坐标牛顿步、正则融入、乱序优化
NMF 的求解采用交替最小化策略:固定 H 时求最优 W,固定 W 时求最优 H,如此反复直至收敛。当选择 solver='cd'(坐标下降)时,内核求解由 _fit_coordinate_descent 函数(同上文件:700-800)完成,其核心又委托给 Cython 加速函数 _update_cdnmf_fast(sklearn/decomposition/_cdnmf_fast.pyx:1-50)。
在坐标下降中,我们并非一次性更新整个 W 或 H 矩阵,而是逐个扫描其元素(即每个“坐标”),并假设其他所有元素固定,从而将原始的多变量最小化问题退化为一元无约束最小化问题(在非负约束下为带下界的一元问题)。以更新 W 为例(H 的情况完全对称):
-
目标函数对单个元素 W_{ik} 的依赖可通过展开 β 散度得到。以弗罗贝尼乌斯情况(β=2)为例,目标对 W_{ik} 的贡献形式为:½‖X - WH‖F² + ½ l2_reg_W ‖W‖F² + l1_reg_W ‖W‖1 ∝ ½ (W - [XHᵀ] / ‖H‖²)² ‖H_{:k}‖² + ½ l2_reg_W W_{ik}² + l1_reg_W |W_{ik}|;
-
对该一元函数求导并设导数为零(考虑非负约束下的 KKT 条件),可得封闭形式更新:W_{ik} ← [ (XHᵀ){ik} - l1_reg_W/₂ ]₊ / ( ‖H‖² + l2_reg_W ),其中 [·]₊ 表示正部分(负值取零);
-
推广至一般 β 散度时,虽然表达式更复杂,但同样可得形式为:W_{ik} ← [numerator - l1_reg_W/₂]₊ / denominator,其中 numerator 和 denominator 分别来自于 X 和 H 的充分统计量(如 XHᵀ 和 HHᵀ)。
为高效实现上述逐坐标更新,sklearn 将核心循环用 Cython 编写(_update_cdnmf_fast),关键优化包括:
-
将中间量 HHt = HᵀH 和 XHt = X Hᵀ 预计算(注意对于更新 H,则需 XXᵀ 和 WH);
-
在主循环中,对于每个样本 i 和成分量和成分 k:
-
计算梯度:grad = (W HHt){ik} - (XHt);
-
投影梯度:若 W_{ik} 为零,则仅保留负梯度部分(以 respect 非负约束);否则使用全梯度;
-
计算海森(二阶导数):hess = HHt_{kk};
-
若海森非零,则做牛顿步:W_{ik} ← max( W_{ik} - grad / hess, 0 );
-
累积违反量(用于收敛检测):violation += |投影梯度|。
-
-
为避免因坐标更新顺序导致的收敛波动,支持通过 shuffle 参数在每次外迭代前随机打乱坐标顺序(使用传入的 permutation 数组);
-
整个函数被包装在
nogil块中,以释放全局解释器锁(GIL),实现真正的多线程并行(虽然当前调用单线程,但为后端扩展预留接口)。
值得注意的是,坐标下降法在每次坐标更新后都能保证目标函数非增(在精确求解一元子问题时),此性质正是其得名“MM(最大化-最小化)算法”之源——在 NMF 的 beta-divergence 情况下,可以导出一个辅助函数(majorizer)가当前点处切线且在别处不小于原函数,从而逐点最小化该辅助函数等价于原函数下降。
乘法更新求解器 _fit_multiplicative_update:MM 算法推导、β 敮度下分子分母形式、gamma 缩放因子保证单调性
当选择 solver='mu'(乘法更新)时,NMF 采用另一种交替最小化策略,其更新规则具有乘法形式:W ← W ⊙ (∇₋W / ∇₊W),其中 ⊙ 为 Hadamard(元素级)乘法,∇₋W 和 ∇₊W 分别为目标函数对 W 的负部和正部梯度(即导数的负值和正值部分)。此形式之所以能保证单调下降,得益于它可以被视为一个特殊的 MM 算法:即存在一个辅助函数 G(W, W^{(t)}) 使得:
-
G(W, W^{(t)}) ≥ F(W)(其中 F 为原始目标函数);
-
G(W^{(t)}, W^{(t)}) = F(W^{(t)});
-
对于固定的 W^{(t)},G(W, W^{(t)}) 在 W 上唯一最小化于 W = W^{(t)}。
在 NMF 中,此辅助函数可由以下不等式导出:对于任意正数 a, b,有 a/b + b/a ≥ 2(均值几何不等式的弱形式),从而在推导 β 敮度的梯度时得以分离正负部分。具体到实现(见 _fit_multiplicative_update 函数同上文件:800-950):
-
在弗罗贝尼乌斯情况下(β=2):
-
分子(对应负梯度部分):XHᵀ;
-
分母(对应正梯度部分):W H Hᵀ + l2_reg_W W + l1_reg_W(其中 l1_reg_W 项被加到分母因为其在梯度中出现为常数负贡献);
-
更新规则:W ← W ⊙ [ (XHᵀ) / (W H Hᵀ + l2_reg_W W + l1_reg_W) ];
-
-
在广义 K-L 情况(β=1)时:
-
分子:按元素 X ./ (WH)(其中 ./ 为逐元素除);
-
分母:Hᵀ 1(即 H 的列和);
-
更新规则:W ← W ⊙ [ (X ./ (WH)) / (Hᵀ 1 + l1_reg_W) ](注意 l2_reg_W 项在此情况下为零,因为其二阶导数为常数);
-
-
在伊塔库-斋托情况下(β=0):
-
分子:按元�素 X ./ (WH)²;
-
分母:(WH)⁻¹;
-
更新规则:W ← W ⊙ [ (X ./ (WH)²) / ( (WH)⁻¹ + l1_reg_W ) ](同上,l2_reg_W 项为零)。
-
为统一处理,代码中引入了 gamma 缩放因子(见 _fit_multiplicative_update 开头):
-
当 β < 1 时(如 β=0 对应伊塔库-斋托),设 gamma = 1 / (2 - β);
-
当 β > 2 时,设 gamma = 1 / (β - 1);
-
否则(1 ≤ β ≤ 2),设 gamma = 1。
随后在更新规则中对增量项施加幂运算:delta ← (numerator / denominator)^gamma,然后 W ← W ⊙ delta。此 gamma 项的作用是修正乘法步长,使得在非标准 β 值下依然能保证单调性——其理论根源在于 MM 算法要求辅助函数的曲率需匹配原函数在当前点的梯度行为。
乘法更新的吸引力在于其完全避免了矩阵求逆或线性系统求解,全部改为逐元素乘法,因而高度并行友好;此外,其更新式中所有除法的分母均被设计为严格正(通过加入正则项或微小常数),从而保证了数值稳定性。不过,由于其更新幅度可能较小(尤其当接近收敛时),收敛速度往往不如坐标下降法——这也解释了为什么在 NMF 中,坐标下降被设为默认求解器(solver='cd')。
MiniBatchNMF 在线学习:指数加权统计量 A/B、遗忘因子衰减历史、fresh restart 重解 W、早停双准则
MiniBatchNMF(同上文件,MiniBatchNMF 类)将 NMF 的批量思想推向了在线学习场景:它假设数据以小批量(mini-batch)形式连续到达,且目标是不断更新字典 H 以更好地近似最近看到的数据,而不需要重新处理全部历史数据。其核心机制围绕着两个足够统计量的维护:
-
统计量 A:近似为 E[Hᵀ H](即字典的二阶矩):
-
统计量 B:近似为 E[Xᵀ H](即数据与字典的协方差);
在每个批次到达时,算法会:
-
计算当前批次的稀疏码 W_batch(通过 fix H 解非负最小二乘,通常使用乘法更新或坐标下降);
-
利用 W_batch 更新充分统计量:A ← ρ A + (W_batchᵀ W_batch) / batch_size,B ← ρ B + (X_batchᵀ W_batch) / batch_size,其中 ρ 为遗忘因子(详见下文);
-
从更新后的 A 和 B 求解近似的字典:H ← A ⁻¹ B(随后投影到单位球以满足 ‖H_{:k}‖ ≤ 1);
-
重复直至收敛或达到最大迭代次数。
遗忘因子的引入是实现“历史衰减”的关键:它控制旧批次对当前估计的影响程度。具体来说,设总共处理过 n 个样本,当前批次大小为 b,则遗忘因子定义为 ρ = forget_factor^(b / n),其中 forget_factor 是用户超参数(默认 0.7)。当 forget_factor = 1 时,退化为纯平均(所有历史等权重);当 forget_factor < 1 时,近期样本获得指数级更高权重——例如,设 forget_factor=0.7,则在第 10 个同等大小批次后,第一个批次的权重仅为初始的 0.7¹⁰ ≈ 0.028,说明旧数据影响被快速削弱。
为进一步提升在线学习的稳定性,MiniBatchNMF 支持两种早停准则(在 _minibatch_convergence 函数中实现):
-
基于字典变化的阈值:当连续两次更新中 H 的相对变化(‖H_{new} - H_{old}‖ / ‖H_{new}‖)小于容忍度 tol(默认 1e-3)时,判定为收敛;
-
基于平滑代价的lack of improvement:维护一个指数加权平均的批次成因(ewa_cost),若在连续 max_no_improvement(默认 10)个批次中 ewu_cost 未出现新低(即未改善),则触发早停。
此外,为了对抗在线学习易陷入局部最优的倾向,MiniBatchNMF 提供了 fresh restart 机制:当启用 fresh_restarts(默认 False)时,在每个批次处理前(或仅在首次?实际代码中是:在 _solve_W 中用于 transform,而在 _minibatch_step 中,仅当 W 为 None 或 fresh_restarts 为 True 时才重新从 scratch 解 W),算法会重新以当前批次为初始猜想(通过均值填充)求解非负最小二乘问题 W_batch ≈ argmin_{W≥0} ‖X_batch - W H‖,而不是沿用上一批次的 W。虽然此做法增加了计算量(因为需重新求解 W),但能显著改善字典更新的质量——尤其在特征漂移(feature drift)场景中,能防止字典被过时的历史数据主导。
最后值得一提的是,MiniBatchNMF 的 partial_fit 方法(同上文件,约 1600-1700 行)实现了真正的增量更新:它接受一个新批次数据,在内部仅执行一次 _minibatch_step(即更新一次 W 和 H),随后累计批次计数器。这使得用户能够以流式方式喂入数据(如从 Kafka 或生产者消费者模型读取),而模型能够持续适应新到来的统计特性,无需重头开始。
23.7 设计中的取舍
为什么不用 eigendecomposition 直接求解协方差矩阵而不是 SVD?
虽然在理论上,协方差矩阵的特征分解可得到与 PCA 等价的结果(因为 XᵀX 和 XXᵀ 共享非零特征值),但在实际数值计算中,形成协方差矩阵 XᵀX 会将条件数平方(cond(XᵀX) ≈ cond(X)²),导致在奇异值跨度大时特征值求解极不稳定——尤其当特征维度很高且数据存在噪声时,小特征值对应的特征向量可能被严重污染。相比之下,直接对数据矩阵做 SVD(或其变体)能够保持原始条件数,因而数值行为更可靠。这是为什么即使在 covariance_eigh 求解器中,我们仍通过特征值重构回 SVD(而非直接使用特�量作为主成分),即为了抵消条件数平方带来的不利影响。
这种设计的trade-off是什么?
核心trade-off在于数值稳定性与计算效率之间的平衡。例如:
-
选择
fullSVD 虽准确,但当 n_samples 和 n_features 都很大时(如皆超过十万),其 O(n_samples n_features²) 复杂度不可接受; -
此时转向
randomized或arpack能显著降低成本(后者尤其在稀疏场景下有优势),但需付出近似误差的代价——虽然通过调节幂迭代次数或容忍度可控制此误差; -
再比如在 NMF 中,坐标下降法收敛速度通常快于乘法更新,但后者更易并行且每步计算更轻;而在稀疏矩阵上,坐标下降法的逐坐标更新若涉及填零元素则可能低效,而乘法更新则能更自然地利用稀疏性(因为其核心操作如 XHᵀ 可通过稀疏乘法高效实现)。
这些取舍均通过暴露求解器、算法及超参数(如 svd_solver、solver、beta_loss 等)给用户,使得其能根据具体场景(数据规模、稀疏度、所需精度、并行资源等)做出知情选择。
23.8 动手练习
对比 PCA 四大求解器的适用场景与源码分派逻辑
阅读 sklearn/decomposition/_pca.py 中 PCA._fit 方法(约 350-430 行)与 _fit_full、_fit_truncated
分析差异:
-
svd_solver='auto'的决策树:何时选covariance_eigh?何时选randomized?阈值 1000 特征、10倍样本、500x500、80% 维度从何而来? -
covariance_eigh为何不中心化数据却能通过协方差矩阵修正?数值稳定性代价是什么? -
arpack与randomized在稀疏/稠密、大/小n_components下的复杂度权衡?
回答问题:
-
100万样本、500维、需 50 个主成分:推荐哪个求解器?为什么?
-
n_components='mle'为何强制svd_solver='full'?Minka MLE 原理是什么?
追踪 NMF 从初始化到收敛的完整优化流程
阅读 sklearn/decomposition/_nmf.py 中 NMF._fit_transform(约 1100-1200 行)、_fit_coordinate_descent(约 700-800 行)、_fit_multiplicative_update(约 800-950 行)、_initialize_nmf(约 350-450 行)
分析关键步骤:
-
NNDSVD/NNDSVDa/NNDSVDar 如何利用 SVD 正负部分构造非负初始 W/H?为何默认
nndsvda? -
坐标下降
_update_cdnmf_fast如何逐坐标最小化目标?L1/L2 正则如何融入梯度/海森? -
乘法更新
_multiplicative_update_w/h如何推导?β 敮度(Fr/KL/IS) 下分子分母形式差异?MM 算法保证单调下降? -
MiniBatchNMF的指数加权统计量 A/B、遗忘因子forget_factor、fresh restart 机制如何实现在线学习?
回答问题:
-
含零数据为何不能用
beta_loss='itakura-saito'?_special_sparse_dot如何只在非零处计算 WH? -
坐标下降 vs 乘法更新:哪个收敛快?哪个更易并行?稀疏矩阵下谁更有优势?
解析 t-SNE 从困惑度搜索到 Barnes-Hut 梯度的核心流程
对比阅读 sklearn/manifold/_t_sne.py 中 TSNE._fit(约 800-950 行)、_joint_probabilities/_joint_probabilities_nn(约 100-200 行)、_kl_divergence/_kl_divergence_bh(约 200-350 行)、sklearn/manifold/_barnes_hut_tsne.pyx 中 gradient/compute_gradient_negative(约 100-250 行)
分析核心差异:
-
精确法
_joint_probabilities如何对每个点二值搜索 σ 以匹配困惑度?复杂度 O(N²)? -
Barnes-Hut 版
_joint_probabilities_nn仅计算 k 近邻(3×perplexity) 条件概率,再稀疏对称归一化,如何平衡精度与速度? -
_kl_divergence_bh中四叉树_QuadTree如何构建、汇总重心、θ 判据决定远场近似?compute_gradient_negative如何递归遍历树计算负梯度力? -
早夸张
early_exaggeration=12与两阶段动量(0.5→0.8)如何帮助逃离局部最优?
回答问题:
-
为何
n_components>3禁用 Barnes-Hut?四叉树/八叉树维度限制? -
angle=0.5的几何含义?调小/调大如何影响速度/精度?
23.9 本章小结
这一章中我们学习了降维与流形学习的核心技术,从线性方法如 PCA 和其增量、核心变体,到非负矩阵分解的积木思想,再到概率主题模型如 LDA 和独立成分分析如 FastICA,最后探索了流形嵌入技术如 t-SNE、Isomap 和局部线性嵌入。我们首先理解了 PCA 如何通过自动求解器选择在不同数据规模下平衡精度与效率,随后深入了增量学习如何在内存受限时处理巨量数据;接着考察了核技巧如何让线性方法捕获非线性结构,以及截断 SVD 在稀疏文本中的应用;在非负矩阵分解部分,我们掌握了 β 敮度族如何统一损失函数,以及坐标下降与乘法更新两种求解范式的 trade-off;在主题模型中,我们追踪了 LDA 的变分推断如何从文档-主题-词三层结构中学习潜在主题;最后,我们对比了各种流形学习算法在保持局部几何还是全球距离方面的侧重点,并理解了底层加速如何通过四叉树、随机化 SVD 等技术让这些算法能够扩展到实际问题的规模。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| PCA.fit | 自动选择 full/covariance_eigh/arpack/randomized 求解器,支持 MLE 自动选维、白化、Array API 兼容 |
| IncrementalPCA.partial_fit | 增量更新均值/方差,构造增广矩阵做 SVD,合并旧基与新数据,常数内存复杂度 |
| KernelPCA._fit_transform_in_place | 核矩阵中心化→特征分解(dense/arpack/randomized)→特征向量归一化→可选逆变换预像学习 |
| TruncatedSVD.fit_transform | 不中心化,ARPACK 或随机化 SVD 直接分解稀疏矩阵,组件为右奇异向量 |
| NMF._fit_transform | 坐标下降或乘法更新交替优化 W/H,支持 β 敮度、L1/L2 正则、NNDSVD 智能初始化 |
| MiniBatchNMF._minibatch_step | 在线乘法更新 H,指数加权平均统计量 A/B,遗忘因子衰减历史,支持 fresh restart |
| FactorAnalysis.fit | EM 迭代:E 步 SVD 计算载荷 W,M 步更新噪声方差 ψ,支持随机化 SVD、Varimax/Quartimax 旋转 |
| FastICA._fit_transform | 白化后并行/通货紧缩不动点迭代求解解混矩阵 W,三种非线性函数 logcosh/exp/cube 近似负熵 |
| SparsePCA._fit | 字典学习框架:稀疏编码 Lasso(LARS/CD) + 字典更新(闭式/坐标下降),强制成分稀疏 |
| DictionaryLearning.fit | 批量字典学习:交替稀疏编码(并行)与字典原地更新(投影到单位球),监控目标函数收敛 |
| MiniBatchDictionaryLearning._minibatch_step | 在线字典学习:小批量稀疏编码 + 指数衰减统计量 A/B 更新字典,早停基于字典变化/平滑代价 |
| LatentDirichletAllocation.fit | 批量/在线变分贝叶斯:E 步迭代更新文档-主题分布(Dirichlet 期望),M 步更新主题-词分布,困惑度监控 |
| TSNE._fit | 精确/近邻概率计算→二值搜索困惑度→早夸张阶段(动量0.5)→主优化阶段(动量0.8)→Barnes-Hut 四叉树 O(NlogN) 梯度 |
| Isomap._fit_transform | 近邻图构建→连通性修复→最短路径(测地距离)→核 PCA(预计算核)嵌入 |
| ClassicalMDS.fit_transform | 距离矩阵平方→双中心化→特征分解→坐标=√特征值×特�量,等价于 PCA |
| SpectralEmbedding.fit | 亲和矩阵→归一化拉普拉斯→特�量归一化→确定性符号翻转 |
| LocallyLinearEmbedding._fit_transform | 近邻重构权重(重心/修正/Hessian/LTSA)→构建稀疏 M=(I-W)ᵀ(I-W)→求零空间(ARPACK/稠密) |
| MDS.fit_transform | SMACOF 迭代:距离→差异度量(度量/非度量 Isotonic 回归)→Guttman 变换更新坐标→应力收敛,多初始化并行选最优 |
| _randomized_svd / _incremental_mean_and_var | 通用底层加速:随机化 SVD 近似大矩�分解、增量均值方差合并批次统计量 |
| _QuadTree (Barnes-Hut) | 四叉树/八叉树空间索引:插入点、构建重心、θ 判据汇总远节点、计算 t-SNE 负梯度力 |
下一章中,我们将学习集成学习 —— “众人拾柴火焰高”的模型智慧。
23.10 架构与数据流图
上述图分别展示模块依赖、调用时序、数据流和架构分层。
第 24 章 —— 集成学习 —— “众人拾柴火焰高”的模型智慧
24.1 学习目标
-
理解 Bagging 元估计器的架构设计与并行训练机制
-
掌握随机森林与极端随机树的分裂策略差异与 OOB 评估实现
-
深入剖析梯度提升决策树 (GBDT) 的残差拟合串行迭代引擎
-
解析直方图梯度提升 (HistGB) 的分箱加速、直方图构建与最佳分裂搜索核心逻辑
-
理解堆叠集成与投票集成的元学习协作机制
-
掌握 AdaBoost 的样本权重迭代更新与隔离森林的路径长度异常评分原理
-
熟悉集成学习框架的基类设施与工具函数设计模式
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与代码阅读基础
学习目标
-
理解集成学习的核心思想及其在提升模型泛化能力中的作用
-
掌握 Bagging、Random Forest、Extra Trees 的实现原理和区别
-
理解 GBDT 和 HistGB 的梯度提升机制及其效率优化
-
了解 Stacking、Voting 和 AdaBoost 的集成策略及其适用场景
-
认识 Isolation Forest 的异常检测原理
-
熟悉 sklearn.ensemble 中关键类的结构与参数含义
-
能够解读并说明 Bagging、随机森林、极端随机树、梯度提升树及直方图梯度提升的源码实现
源码地图
sklearn/ensemble
├── _bagging.py # Bagging 基类及分类/回归器实现
├── _forest.py # 森林基类及随森、极端树实现
├── _gb.py # 梯度提升回归/分类器基类及实现
├── _hist_gradient_boosting
│ ├── binning.py # 特征分箱工具
│ ├── _binning.pyx # 分箱映射(Cython 加速)
│ ├── grower.py # 树生长器(梯度提升树构建核心)
│ ├── histogram.pyx # 直方图累加器
│ ├── splitting.pyx # 特征分裂查找器
│ └── _predictor.pyx # 树预测器(用于推理)
├── _stacking.py # Stacking 集成框架
├── _voting.py # Voting 集成框架
├── _weight_boosting.py # AdaBoost 实现
├── _iforest.py # Isolation Forest 异常检测
└── _base.py # 集成学习基类及工具函数
24.2 集成学习 —— “众人拾柴火焰高”的模型智慧
想象集成学习是一座'众人拾柴火焰高'的智慧议会:Bagging = 并行头脑风暴会:每位专家(基础估计器)只看部分资料(bootstrap 采样),独立给出建议,最后投票或平均决策,减少个人偏见(方差)。Random Forest = 特征受限的专家组:每位专家每次只能看部分指标(max_features),防止过度依赖某个强特征。Extra Trees = 极端随机专家组:甚至不找最优切分点,随机选阈值再选最优,极大加快决策且抗噪。GBDT = 串行纠错接力赛:每位选手(树)专门修正前人的错误(负梯度),步长受控(学习率),逐步逼近真相。HistGB = 分箱量化的现代接力赛:将连续指标离散化(分箱),用直方图快速统计增益,像用计算器代替手算,极大提速。Stacking = 两级决策架构:基层专家(基础模型)产出报告,高层决策者(元学习器)综合研判。Voting = 联合执政:多党派(异质模型)硬投票(少数服从多数)或软投票(按支持率加权)。AdaBoost = 自适应重点攻关:每轮聚焦上轮搞错的案例(增大样本权重),像补课一样攻克难点。Isolation Forest = 孤岛猎手:随机切割特征空间,异常点像孤岛一样更容易被隔离(路径更短)。就像议会制度通过多元化、分工协作、制衡机制,做出比单一独裁者更鲁棒的决策,集成学习通过多模型协作实现‘1+1>2’的预测效果。
24.2.1 Bagging 元估计器:并行采样与投票
Bagging(Bootstrap Aggregating)是集成学习的基石思想,其核心在于通过 自助采样(bootstrap sampling) 构建多个基础估计器的训练子集,然后通过 投票(分类)或平均(回归) 融合它们的预测。这种方法能够有效降低模型的方差,尤其适用于不稳定的估计器(如决策树),因为子样本的随机性使得各个基模型在不同子集上表现不同,而它们的平均能够抵消个体的偏差。
在 scikit-learn 中,BaggingClassifier 和 BaggingRegressor 共享一个通用的基类 BaseBagging,该类封装了采样、并行训练和预测聚合的通用逻辑。其设计遵循“模板方法”模式:具体的预测方式(投票或平均)由子类实现,而采样、并行构建和 Out-of-Bag(OOB)评估等机制在基类中统一处理。
24.2.1.1 核心实现细节
采样机制
在 _generate_bagging_indices 函数中(sklearn/ensemble/_bagging.py:56),特征和样本的索引是独立抽取的:
-
如果
bootstrap=True,则使用有放回采样(randint);否则使用无放回采样(sample_without_replacement)。 -
当提供
sample_weight时,样本抽取将使用权重作为概率分布(经归一化后),以实现加权自助采样。 -
特征抽取同样支持有放回或无放回,受
bootstrap_features和max_features控制。
这一设计使得 Bagging 能够灵活支持四种变体:
-
Pasting:无放回样本 + 全特征
-
Bagging:有放回样本 + 全特征
-
Random Subspaces:全样本 + 随机特征子集
-
Random Patches:有放回样本 + 随机特征子集
并行训练
训练过程通过 _parallel_build_estimators 实现多并行构建(sklearn/ensemble/_bagging.py:111)。该函数在每个 job 中:
-
为每个基 estimator 生成随机种子;
-
调用
_make_estimator创建 estimator 副本(如未指定则默认为决策树); -
根据是否支持
sample_weight选择两种训练方式:-
若支持:通过修改
sample_weight参数实现行采样(更内存高效); -
否则:通过直接索引
X和y实现采样;
-
-
在选定的特征子集上进行拟合;
-
返回拟合好的 estimator 及其使用的特征索引。
最后,所有 job 的结果通过 itertools.chain 合并到 estimators_ 和 estimators_features_ 列表中。
预测聚合
预测阶段同样并行执行:
-
分类器:调用
_parallel_predict_proba累加类概率(若基 estimator 支持predict_proba);否则进行硬投票(计算每个类被预测的次数); -
回归器:调用
_parallel_predict_regression直接求和预测值; -
最终结果除以 estimator 数量得到平均预测。
Out-of-Bag (OOB) 评估
当 oob_score=True 时,模型会在训结过程中计算 OOB 分数(sklearn/ensemble/_bagging.py:287):
-
对每棵树,找出未被其自助样本覆盖的样本(OOB 样本);
-
使用这些样本的预测结果计算准确率(分类)或 R² 分数(回归);
-
最终的
oob_score_是所有 OOB 预测的平均表现,提供了无需额外验证集的泛化估计。
这种机制利用了自助采样的性质:约 36.8% 的样本在每次采样中未被选中,这些样本可用于无偏估计。
设计取舍分析
问:为什么 Bagging 在 fit 时会警告当 sample_weight 不为 None 且 bootstrap=False?
答:因为当采样无放回时,样本权重的频率语义(期望出现次数与权重成正比)无法得到保证。自助采样的有放回特性才能确保每个样本被选中的期望次数与其权重成正比。无放回采样下,即使使用了权重抽样,样本被选中的实际次数仍受限于样本总数,无法精确实现权重比例。因此, scikit-learn 在此情况下发出警告,建议用户开启 bootstrap 以确保样本权重的正确使用。
24.2.2 随机森林:特征子空间的决策树集成
随机森林(Random Forest)是 Bagging 的一个特化形式,它在决策树上增加了 特征随机性:在每个节点分裂时,不考虑所有特征,而是从一个随机选择的特击子集中寻找最佳分裂点。这种额外的随机性进一步去相关了各个树,使得森林的泛化能力更强,同时降低了过拟合风险。
在 scikit-learn 中,RandomForestClassifier 和 RandomForestRegressor 继承自 ForestClassifier 和 ForestRegressor,而它们又继承自 BaseForest(sklearn/ensemble/_forest.py:46)。这一层次结构将森林的通用逻辑(如 bootstrap 采样、并行构建、OOB 评估)与决策树特定的参数(如 criterion、max_depth、min_samples_split)解耦。
24.2.2.1 核心实现细节
特征子集选择
在 _set_max_features 方法中(sklearn/ensemble/_gb.py:1220),max_features 参数被解析为实际用于分裂的特征数量:
-
若为整数:直接使用该值;
-
若为浮点数:计算
max(1, int(max_features * n_features_in_)); -
若为字符串
"sqrt":使用sqrt(n_features_in_); -
若为
"log2":使用log2(n_features_in_); -
若为
None或1.0(在回归中):使用全部特征。
这一机制确保了在每次分裂时,树只能看到一部分特征,从而引入随机性。
并行树构建
BaseForest.fit 方法通过 _parallel_build_trees 在多线程或多进程中构建树(sklearn/ensemble/_forest.py:168):
-
每棵树从随机状态中获得独立种子;
-
如果
bootstrap=True,则使用有放回采样生成样本索引; -
若提供
class_weight且设置为"balanced_subsample",则在每个 bootstrap 样本上重新计算类权重; -
调用决策树的
_fit方法进行训练,传入采样后的样本权重(模拟行采样); -
所有树构建完成后,将其加入
estimators_列表。
这一过程完全复用了决策树的训练逻辑,仅在数据采样层面引入了随机性。
特征重要性
feature_importances_ 属性基于 不纯度下降(impurity decrease) 计算(sklearn/ensemble/_forest.py:311):
-
对每棵树,累计每个特征在所有分裂节点上的不纯度减少量;
-
对所有树取平均;
-
归一化使得所有特征重要性之和为 1(除非所有树仅含根节点)。
这种重要性度量直观地反映了每个特征在降低预测不确定性方面的贡献,但在高基数特征上可能产生偏差(因其更易获得纯分裂),因此 scikit-learn 建议结合置换重要性使用。
Out-of-Bag 评估
与 Bagging 类似,随机森林也支持 OOB 评分(sklearn/ensemble/_forest.py:192):
-
在
_compute_oob_predictions中,对每棵树,使用其 OOB 样本进行预测; -
对多分类/多输出情况,使用三维数组存储每个样本、每个类、每个输出的预测;
-
最终通过所有树的 OOB 预测平均得到每个样本的类概率或回归值;
-
分类任务使用准确率,回归任务使用 R² 分数作为
oob_score_。
24.3 设计取舍分析
问:为什么随机森林在分类中默认使用 max_features="sqrt",而在回归中默认使用 max_features=1.0?
答:这是基于经验和理论的权衡。在分类中,特征往往具有更强的判别性,使用全部特征容易导致各树高度相关;取平方根能够显著降低特征相关性同时保留足够的判别信息。在回归中,尤其是均方误差损失下,树对特征的依赖更为线性,使用全部特征(即 max_features=1.0 等价于不做特征子采样)反而能获得更好的偏差-方差平衡,这一点已被实证研究(如 Geurts et al., 2006)验证。因此,随机森林回归器的默认行为实际上更接近标准的 Bagging 决策树,而分类器则通过特征子采样获得了额外的随机化益处。
24.3.1 极端随机树:更激进的随机化策略
极端随机树(Extremely Randomized Trees, Extra Trees)进一步激进化了随机森林的思想:它不仅在特征上随机采样,而且在寻找最佳分裂点时,不再遍历所有可能的阈值,而是为每个特征随机生成若干候选阈值,然后在这些候选中选择使得分增益最大的一个。这种“随机选择分裂点”的策略大幅减少了训练时间,同时对噪声具有更好的鲁棒性,因为它避免了对训练数据的过度拟合。
在 scikit-learn 中,ExtraTreesClassifier 和 ExtraTreesRegressor 与随机森林共享相同的继承层次(ForestClassifier/ForestRegressor → BaseForest),但它们的基 estimator 分别是 ExtraTreeClassifier 和 ExtraTreeRegressor,这两类树在构建时使用了随机分裂策略。
24.3.1.1 核心实现细节
随机分裂策略
极端随机树的核心区别在于其分裂查找过程:
-
在
ExtraTreeClassifier和ExtraTreeRegressor中(实际由DecisionTreeClassifier/DecisionTreeRegressor通过参数控制),当splitter="random"时:-
对于每个考虑的特征,不计算所有可能的阈值;
-
而是随机抽取
n_thresholds个候选阈值(通常取决于实现,但在 sklearn 中是动态的); -
在这些候选中选择使得分增益(如基尼不纯度或信息增益)最大的一个;
-
若特征是连续值,候选阈值均匀采样于特征的最小值和最大值之间;
-
若是类别特征,则随机划分类别子集。
-
这一机制避免了遍历所有可能分裂点的 O(n_samples) 开销,而是将每个特征的分裂评估降为 O(1)(固定数量候选),从而使训练速度显著提升。
对噪声的鲁棒性
由于分裂点是随机选择的,即使某个特征在训练集上出现了欺骗性的纯分裂(例如因噪声导致某个阈值恰好分离所有正例),该分裂也只有在被随机选中时才会被使用。因此,极端随机树不容易被训练噪声引导至过拟合的分裂,这使得它在噪声较大的数据集上往往优于标准决策树甚至随机森林。
其余机制继承自森林基类
-
采样方式:受
bootstrap、max_samples控制(默认bootstrap=False,即无放回采样,整个数据集用于每棵树); -
并行构建:复用
BaseForest._fit和_parallel_build_trees; -
特征重要性:同样基于不纯度下降计算;
-
OOB 评估:仅在
bootstrap=True时可用(因默认bootstrap=False,所以 OOB 通常不可用); -
其他参数:如
max_depth、min_samples_leaf、min_impurity_decrease等与决策树完全一致。
24.3.1.2 设计取舍分析
问:极端随机树在什么情况下优于随机森林?
答:当数据集存在显著噪声或特征存在虚假的预测性时,极端随机树的随机分裂策略能更好地避免过拟合这些虚假模式。例如,在含有大量无关特征或测量噪声的数据中,标准决策树可能会“发现”那些纯属偶然的分裂;而极端随机树由于不执穷举阈值,只会在候选中选择最佳者,从而降低了捕获噪声的概率。此外,在训练时间受限的场景下,极端随机树由于分裂查找开销更低,往往能在相同时间内构建更深或更多的树,从而在某些情况下达到更好的性能。然而,如果数据干净且特征真正具有强预测力,随机森林的最优分裂选择通常能获得更低的偏差,因此在这样的数据上可能略逊一筹。因此,极端随机树更适合作为“快速、鲁棒”的默认选择,而随机森林则在能够承担稍高计算成本且数据质量较好时可能获得更佳性能。
24.3.2 梯度提升决策树:逐步修正错误的接力赛
梯度提升(Gradient Boosting Decision Trees, GBDT)是一种串行的集成方法,它与 Bagging 的并行理念截然不同:不是让多个模型独立投票,而是让每个新模型专门学习前所有模型的残差(错误),然后以一定步长(学习率)将其预测加入到集成中。这一过程类似于函数空间中的梯度下降,其中每棵树拟合的是当前模型对真实目标的负梯度(即残差),从而逐步逼近最优预测函数。
在 scikit-learn 中,GradientBoostingClassifier 和 GradientBoostingRegressor 实现了这一思想,它们继承自 BaseGradientBoosting,该类封装了梯度提升的通用流程:初始化预测、迭代计算残差、拟合回归树、更新预测、早停机制等。
24.3.2.1 核心实现细节
损失函数与梯度计算
-
损失函数(如对数损失、平方损失)通过
_get_loss方法动态获取(sklearn/ensemble/_gb.py:142); -
在每次迭代中,计算负梯度作为伪响应:
neg_gradient = -loss.gradient(y_true, raw_predictions, sample_weight); -
这一梯度表示:为了降低损失,我们应当将预测向这个方向调整多少;
-
对于对数损失(分类),这是概率与真实标签之间的差;对于平方损失(回归),这正是残差
y - pred。
树拟合与叶值更新
-
每棵树被拟合到
(X, neg_gradient)上,使用平方误差作为分裂准则(因我们希望树的预测接近梯度); -
在
_update_terminal_regions中,根据损失函数执行一步牛顿拉夫逊更新来校准叶值(sklearn/ensemble/_gb.py:81):leaf_value = -sum(gradient_in_leaf) / sum(hessian_in_leaf)(对于平方损失退化为平均梯度); -
这一步相当于在叶节点上求解:
argmin_γ Loss(y, F_{m-1}(x) + γ * h_m(x)); -
更新后,将树的预测乘以学习率加入到总预测中:
F_m(x) = F_{m-1}(x) + learning_rate * h_m(x)。
正则化技巧
-
学习率(shrinkage):控制每棵树的贡献,防止过拟合;
-
子采样(subsample):每次迭代仅使用部分样本(有放回)训练树,引入随机性降低方差;
-
特征子采样(max_features < 1.0):同上,进一步去相关树;
-
叶值正则化(在牛顿更新中加入 l2 项):防止叶值过大;
-
早停(n_iter_no_change):在验证集上连续多次无改进时停止训练。
分类的特殊处理
-
对于多分类问题,GBDT 在每次迭代中构建
n_classes棵树(一棵树对应一个类,针对该类 vs 其余的二分类问题); -
二分类是特例:仅需一棵树,因两类的梯度互为相反数;
-
原始输出(raw prediction)通过损失函数的 link 函数(如对数损失的 logit)转换为概率;
-
决策函数直接返回原始预测值(分类时为 logit 空间中的得分)。
设计取舍分析
问:为什么 GBDT 在每次迭代中要计算负梯度作为树的目标?
答:因为梯度提升本质上是在函数空间中进行梯度下降。我们的目标是找到一个函数 F(x) 来最小化期望损失 E[L(y, F(x))]。在函数空间中,这相当于从当前估计 F_{m-1} 出发,沿着损失函数关于 F 的负梯度方向前进一步。该梯度函数正是:
g_m(x) = -[∂L(y, F)/∂F]_{F=F_{m-1}(x)}
因此,拟合一个回归树来近似这个梯度函数,就是在寻找一个基函数 h_m(x) 使得 F_m = F_{m-1} + ν * h_m 能最大程度降低损失。这正是梯度提升的核心思想:每棵树都在修正当前模型的不足,而学习率 ν 控制每一步修正的幅度,以防过度冲进导致振荡或过拟合。
24.3.3 直方图梯度提升:离散化加速的现代变体
直方图梯度提升(Histogram-based Gradient Boosting, HistGB)是 GBDT 的一个高效实现,它的核心思想是:将连续特征离散化为有限数量的 bin(通常 256),然后使用直方图快速累计每个 bin 上的梯度和 Hessian,从而在 O(n_bins) 时间内完成分裂点查找,而非传统 GBDT 的 O(n_samples log n_samples)。这一技巧大幅提升了训练速度,尤其在大样本数据上表现突出,同时保持了与 GBDT 相当的预测性能。
在 scikit-learn 中,HistGradientBoostingClassifier 和 HistGradientBoostingRegressor 实现了这一思想,其核心组件包括:
-
BinMapper:负责特征分箱; -
_binning.pyx、histogram.pyx、grower.py、splitting.pyx、_predictor.pyx:共同实现直方图树的构建与预测。
24.3.3.1 核心实现细节
特征分箱(BinMapper)
-
在
fit阶段,BinMapper为每个特征计算分箱阈值(sklearn/ensemble/_hist_gradient_boosting/binning.py:50):-
对于连续特征:使用百分位数(如均匀分位点)将特征值映射到
[0, max_bins-1]的整数 bin; -
对于缺失值:统一映射到最后一个 bin(索引为
n_bins - 1); -
对于类别特征:假设其已编码为
[0, 254]的整数,直接使用原始值作为 bin 索引;
-
-
分箱阈值存储在
bin_thresholds_中;实际使用的 bin 数量(非缺失值)存储在n_bins_non_missing_; -
变换过程在
transform中完成:使用二分查找(对连续特gef)或直接映射(对类别/缺失值)将原始特征值转为 bin 索引; -
输出为 Fortran 顺序的整数数组,以利于缓存友好型遍历。
直方图累加(HistogramBuilder)
-
在树的构建过程中,
HistogramBuilder负责为每个特征计算直方图(sklearn/ensemble/_hist_gradient_boosting/histogram.pyx:42):-
每个 bin 存储:该 bin 上样本的梯度和 Hessian 之和,以及样本计数;
-
支持两种构建方式:
-
暴力法:遍历所有样本,将其梯度/Hessian 加入对应 bin(O(n_samples));
-
减法法:利用父节点直方图减去兄弟节点直方图得到子节点直方图(O(n_bins)),仅在一方需要时使用;
-
-
-
为提升缓存命中率,在暴力法中会先将样本按照特征 bin 重新排序(有序梯度/Hessian);
-
支持多线程通过
prange并行处理不同特征或样本块。
分裂查找(Splitter)
-
Splitter遍历每个特征的所有可能分裂点(即 bin 边界)(sklearn/ensemble/_hist_gradient_boosting/splitting.pyx:156):-
为每个候选分裂点(左 bin 和右 bin),计算:
-
左右子节点的样本数、梯度和、Hessian 和;
-
检查是否满足最小样本数、最小 Hessian 和最小增益约束;
-
计算分裂增益(基于损失函数的减少量);
-
-
选择增益最大的合法分裂点;
-
-
支持特征子采样(
feature_fraction_per_split < 1.0)和交互约束; -
对缺失值的处理:在遍历时,缺失值可以被分配到左或右子节点,双向遍历以寻找更优分配方式;
-
对类别特征:先按梯度/Hessian 比值对类别排序,低支持类别被过滤,然后扫描剩余类别序列寻找最优划分(模仿 Fisher 的最优分类问题)。
树生长与预测
-
TreeGrower使用 best-first 策略(优先队列)构建树(sklearn/ensemble/_hist_gradient_boosting/grower.py:59):-
从根节点开始,计算其 best split 并加入队列;
-
反复弹出增益最高的节点,尝试分裂;
-
若分裂合法,则创建左右子节点并计算它们的 best split(如有需要)重新入队;
-
达到最大叶节点数、最大深度或无法再分裂时,将节点标记为叶并计算其预测值(通过牛顿更新);
-
最终遍历所有叶节点,应用学习率(shrinkage)到叶值上。
-
-
预测阶段,
TreePredictor根据特征值(或其 bin 索引、类别所属的 bitset)遍历树结构,直至叶节点返回预测值。
设计取舍分析
问:直方图梯度提升相比传统 GBDT 在什么情况下有更明显的优势?
答:当样本量大(如 > 10^4)时,HistGB 的训练速度优势最为显著。传统 GBDT 在每个节点需要对特征值进行排序或遍历以寻找最优分裂点,复杂度约为 O(n_samples log n_samples);而 HistGB 通过预分箱将这一步降为 O(n_bins) (如 256),与样本量无关。因此,在百万级样本的数据集上,HistGB 可以比传统 GBDT 快数倍甚至十倍。此外,由于分箱引入了轻微的量化误差,HistGB 在非常小的数据集上可能略逊一筹,但随着样本量增加,这种误差的影响被摊薄,而其速度优势则持续放大。因此,HistGB 被设计为中等和大规模数据的首选 GBDT 实现,而传统 GBDT 则更适合于小样本、对精度要求极高或需要支持更复杂损失函数的场景。
24.3.4 Stacking:两级决策架构
Stacking(Stacked Generalization)是一种二层次的集成方法:第一层由多个基础模型(可以是异质的,如 SVM、决策树、线性模型等)负责生成特征;第二层由一个元学习器(meta-learner)负责学习如何最优地组合这些第一层模型的输出以进行最终预测。这种方法能够捕捉模型间的协同效应,弥补单一模型的盲点,往往能够比简单平均或投票获得更好的性能。
在 scikit-learn 中,StackingClassifier 和 StackingRegressor 提供了这一框架的统一实现,它们继承自 _BaseStacking,该类又继承自 _BaseHeterogeneousEnsemble(处理异质 estimators 列表)和 TransformerMixin(以支持 transform 方法输出第一层预测)。
24.3.4.1 核心实现细节
第一层模型训练与预测生成
-
在
fit过程中:-
若
cv != "prefit":使用交叉验证(如cross_val_predict)为每个基 estimator 生成 out-of-fold 预测,以避免过拟合; -
若
cv == "prefit":则假设所有基 estimator 已经在完整数据上训练好,直接使用它们在完整数据上的预测; -
这些预测被收集为列表
predictions; -
通过
_concatenate_predictions方法将这些预测拼接成元特征矩阵X_meta(sklearn/ensemble/_stacking.py:100):-
每个 estimator 的预测被确保为二维数组;
-
在二分类且使用概率时,移除第一列(因 p(y=0) = 1 - p(y=1),信息冗余);
-
如果
passthrough=True,则将原始特征X也拼接进去; -
最终
X_meta成为训练元学习器的输入。
-
-
-
元学习器(如逻辑回归或岭回归)则在
(X_meta, y)上进行标准训练。
预测阶段
-
在
predict或predict_proba中:-
首先调用
transform(X)得到第一层模型在新数据上的输出; -
然后将此结果输入到已训练好的元学习器中得到最终预测。
-
方法选择与输出格式
-
stack_method控制每个基 estimator 使用的方法:"auto"会依次尝试predict_proba、decision_function、predict; -
输出特征名称由
get_feature_names_out生成:如stackingclassifier_estimatorname0、stackingclassifier_estimatorname1等; -
若
passthrough=True,则特征名会包括原始特征名(通过input_features或feature_names_in_)。
设计取舍分析
问:为什么 Stacking 在第一层模型训练时通常使用交叉验证而不是直接在完整数据上训练?
答:因为如果基 estimator 在完整数据上训练,然后它们的预测被用作元学习器的训练特征,这就导致了信息泄漏:元学习器将看到的是基 estimator 在训练数据上已经“记忆”了的模式,而非其对未见数据的真实泛化能力。这会导致元学习器过度信任这些预测,在真实测试集上表现欠佳。通过使用 out-of-fold 预测(即每个样本的预测来自未曾见过该样本的基 estimator 模型),Stacking 得到的是更诚实的第一层模型输出分布,从而使元学习器学习到的是如何基于真实泛化性能来组合模型,而不是如何利用训练特征的残余信息。这一点是 Stacking 能够实现真正泛化提升的关键。
24.3.5 Voting:简单却有效的投票机制
Voting 是集成学习中最直观的形式:它不训练元学习器,而是直接让多个基模型投票决定最终输出。根据投票方式的不同,又分为 硬投票(Hard Voting) 和 软投票(Soft Voting):
-
硬投票:每个模型输出一个类别标签;最终类别为得票最多的那个(平票时断平规则未指定,但实现中通常取第一个达到最大票数的类);
-
软投票:每个模型输出类别概率;最终类别为平均概率最高的那个(可加权)。
此方法的优势在于其简单性、可解释性以及对异质模型的友好支持:只要模型能输出类别(硬投票)或概率(软投票),就能纳入集成,无需它们属于同一家族。
在 scikit-learn 中,VotingClassifier 和 VotingRegressor 实现了这一思想,它们继承自 _BaseVoting,该类又继承自 _BaseHeterogeneousEnsemble。
24.3.5.1 核心实现细节
硬投票
-
在
predict中:-
调用
_predict(X)得到形状为(n_samples, n_estimators)的预测标签矩阵(sklearn/ensemble/_voting.py:81); -
对每个样本,使用
np.apply_along_axis计算加权众数:maj = argmax_bincount(votes, weights=self._weights_not_none); -
若无权重,则等价于标准众数;
-
最后通过标签编码器逆_transform 得到原始类别标签。
-
软投票
-
在
predict_proba中:-
调用
_collect_probas(X)得到形状为(n_estimators, n_samples, n_classes)的概率数组(或列表形式); -
使用
np.average在 estimator 维度上取加权平均:avg = average(probas, axis=0, weights=self._weights_not_none); -
返回形状为
(n_samples, n_classes)的平均概率; -
predict则对该概率取argmax得到最终类别。
-
transform 方法
-
硬投票:返回形状为
(n_samples, n_estimators)的预测标签; -
软投票:
-
若
flatten_transform=True:返回(n_samples, n_estimators * n_classes); -
若
flatten_transform=False:返回(n_estimators, n_samples, n_classes)。
-
设计取舍分析
问:在什么情况下软投票优于硬投票?
答:当基模型的概率输出是well-calibrated(即预测概率与真实发生频率一致)时,软投票能够更好地利用模型的置信度信息。例如,若一个模型对某样本预测类别 A 的概率为 0.9,另一个模型预测为 0.51,虽然两者都投票给 A,但软投票能够体现出第一个模型的更高置信度。相比之下,硬投票将两者视为等价。此外,当类别不平衡或成本敏感时,软投票结合适当阈值调整或成本敏感元学习器(如在 Stacking 中)能够进一步优化决策。因此,软投票在模型校准良好且概率携带有用信息时通常优于硬投票,而硬投票则在模型仅输出类别或概率不可信时更为稳健。
24.3.6 AdaBoost:自适应权重的提升方法
AdaBoost(Adaptive Boosting)是一种提升(Boosting)方法,它通过迭代地调整样本权重来专注于之前模型错误分类的样本。其核心思想是:每轮训练一个弱分类器(通常是决策桩),然后根据其错误增加错分样本的权重,减少正确样本的权重;接下来的分类器将在这个重新加权的数据上训练,从而更关注之前被忽略的难点。最终预测是所有分类器的加权投票,其中每个分类器的权重与其准确率成正比。
AdaBoost 有两个主要变体:
-
AdaBoost.SAMME:用于多分类;
-
AdaBoost.R2:用于回归。
在 scikit-learn 中,AdaBoostClassifier 和 AdaBoostRegressor 实现了这些算法,它们继承自 BaseWeightBoosting,该类封装了权重更新和早停的通用逻辑。
24.3.6.1 核心实现细节
分类器权重更新(AdaBoost.SAMME)
-
初始化:所有样本权重均匀;
-
每轮:
-
使用当前样本权重训练弱分类器;
-
计算加权错误率:
error = sum(w_i * I[y_i ≠ h(x_i)]) / sum(w_i); -
若错误率为 0,则提前终止;
-
若错误率 ≥ 1 - 1/K(K 为类别数),则该弱分类器不优于随机猜测,终止 Boosting;
-
否则,计算分类器权重:
alpha = learning_rate * [log((1 - error)/error) + log(K - 1)]; -
更新样本权重:
w_i ← w_i * exp(alpha * I[y_i ≠ h(x_i)])(仅对错分样本增权); -
归一化权重;
-
-
最终预测:
prediction = argmax_c Σ[alpha_m * I[h_m(x) = c]];或等价地:通过决策函数(加权投票)得到得分,再转换为类别或概率。
回归器权重更新(AdaBoost.R2)
-
每轮:
-
按当前样本权重有放回抽样构建 bootstrap 集;
-
在 bootstrap 集上训练弱回归器;
-
在完整数据上获取预测并计算绝对误差;
-
归一化误差:
error_i = |y_i - h(x_i)| / max_j |y_j - h(x_i)|; -
计算加权错误:
error = sum(w_i * error_i) / sum(w_i); -
若错误率 ≥ 0.5,则弱回归器无效,可能移除;
-
计算 beta = error / (1 - error);
-
更新分类器权重:
alpha = learning_rate * log(1 / beta); -
更新样本权重:
w_i ← w_i * [beta ^ (1 - error_i)] ^ learning_rate(仅对有权重样本); -
归一化权重。
-
早停与特征重要性
-
两种变体均支持在错误率达到某阈值时提前终止;
-
特征重要性基于弱学习器的特征重要性按其在 ensemble 中的权重加权平均得到。
设计取舍分析
问:为什么 AdaBoost 在分类中会终止当弱分类器错误率 ≥ 1 - 1/K?
答:因为在均匀随机猜测下,多分类问题的期望错误率正是 1 - 1/K。例如,三类问题下,随机猜错概率为 2/3;若一个分类器的错误率达到或超过这一值,则它对分类任务没有提供比随机猜测更好的信息。继续赋予它正权重不仅不会改善模型,还可能因其预测与真实标签的负相关而降低整体性能。因此,AdaBoost 在这里设置了一个“不再有用”的截止点,防止后续迭代引入有害的弱学习器。而在回归中,AdaBoost.R2 使用 0.5 作为阈值,这是因为在平方误差归一化后,期望绝对误差的中位数相关量通常落在此附近,超过此值则表明模型解释力不足。
24.3.7 Isolation Forest:基于隔离的异常检测
Isolation Forest 是一种基于树的异常检测方法,其核心思想与传统的“基于距离”或“基于密度”异常检测截然不同:它不试图找出正常点的聚集模式,而是直接隔离异常点,利用这样一个事实:异常点由于其稀少和不同的属性值,在随机特征划分下更容易被隔离(即需要更少的分裂步骤就能将其划分到自己的叶节点)。
具体来说,Isolation Forest 构建多棵隔离树(Isolation Trees):
-
每棵树通过在随机选择的特征上随机选择一个分裂点(在该特征的最大值和最小值之间)来递归划分空间;
-
这一过程直到样本被隔离(即到达叶节点)或达到最大深度;
-
一个样本的异常得分定义为:它在所有树中的平均路径长度(从根到叶);
-
由于异常点更易被隔离,其平均路径长度较短;
-
得分通过一个映射函数转换为异常程度:得分越接近 1,越可能是异常;越接近 0,越可能是正常。
在 scikit-learn 中,IsolationForest 实现了这一算法,它继承自 BaseBagging(复用了采样和并行框架),但使用了 ExtraTreeRegressor 作为基 estimator(且固定了 splitter="random" 和 max_features=1)。
24.3.7.1 核心实现细节
树构建
-
每棵树的最大深度被设为
ceil(log2(max_samples)),其中max_samples是用于构建树的样本数(默认min(256, n_samples))(sklearn/ensemble/_iforest.py:124); -
这一设计源于理论:在包含
n个样本的完全二叉树中,将一个样本隔离所需的最大分裂次数为ceil(log2(n)); -
使用
ExtraTreeRegressor且splitter="random"确保了在每个节点:-
随机选择一个特征;
-
在该特征的取值范围内均匀随机选择一个分裂点;
-
这是构建“真正随机”隔离树的关键;
-
-
特征子采样受
max_features控制(如默认 1.0 表示不做子采样); -
样本子采样受
max_samples控制(默认"auto"即min(256, n_samples))。
异常得分计算
-
在
fit后:-
对每棵树,预先计算:
-
_decision_path_lengths[tree_idx]:每个样本在该树上的决策路径长度(到达叶节点所经过的节点数); -
_average_path_length_per_tree[tree_idx]:在包含n_samples个样本的理想隔离树中,期望的平均路径长度(由_average_path_length函数计算);
-
-
-
在
score_samples中:-
对每个样本,计算其在所有树上的决策路径长度之和;
-
减去所有树的期望路径长度之和;
-
得分 =
2^(-normalized_path_length),其中normalized_path_length = total_path / (n_estimators * E[path]); -
最终得分越接近 1,越可能是异常;
-
-
decision_function(X) = score_samples(X) - offset_:-
当
contamination="auto"时,offset_ = -0.5,使得分 > 0 视为正常,< 0 视为异常; -
否则,
offset_设置为使得训练数据中恰好有contamination * n_samples个样本得分 < 0。
-
并行加速
-
使用
joblib与threading后端进行并行:-
_parallel_compute_tree_depths负责在数据块上计算每棵树对样本的路径长度贡献; -
通过锁(
lock)安全地累加到共享数组depths; -
最终得分通过指数变换得到。
-
设计取舍分析
问:为什么 Isolation Forest 使用 ExtraTreeRegressor 且固定了 splitter="random" 和 max_features=1?
答:因为 Isolation Forest 的核心假设是:隔离的难度仅取决于样本在特征空间中的位置,而与用于划分的特征或分裂点的选择无关,只要划分是随机的。因此,为了最大化随机性并最小化构建偏差,我们希望:
-
在每个节点,特征是随机选择的(否则若总是选同一个特征,则该特征上的值将决定隔离难度,丧失随机性);
-
在每个节点,分裂点在该特征的取值范围内是均匀随机的(否则若总是选中点或极值,则某些区域会被系统性地过分或不足划分);
-
而
max_features=1意味着每次只考虑一个特征(已被随机选取),这实际上是在强制“每次只看一个随机特征”; -
使用
ExtraTreeRegressor并设置splitter="random"恰好满足了这两点:它在每个节点随机选一个特征(受max_features控制),并在该特征上随机选一个分裂点; -
若使用决策树且
splitter="best",则尽管特征可能被随机选取,但分裂点会被优化为最佳分割(如均值或中位数),这会引入偏差:例如,若正常数据在某特征上呈双峰分布,则最佳分裂可能位于谷底,使得异常点(若位于峰值)反而 harder to isolate;而随机分裂则不存在此问题,因其不关注数据分布。
因此,这种设计确保了 Isolation Forest 得到的异常得分真正反映了样本被随机隔离的难易程度,而非数据分布的副产物。
24.4 小结
集成学习通过多样化的协作策略,将弱学习器组合成强学习器,在机器学习中占据举足轻重的地位。从 Bagging 的并行采样到 GBDT 的串行修正,从随机森林的特征子采样到直方图梯度提升的离散化加速,每一种方法都在特定的假设下优化了偏差-方差平衡或训练效率。了解它们的实现细节不仅有助于正确使用 sklearn 中的工具,更能够启发我们在面对新问题时,如何通过“众人拾柴” 的思想设计出更鲁棒、更高效的模型。
无论是投票的简洁,还是 Stacking 的两级智慧,亦或是 AdaBoost 的自适应聚焦,抑或是 Isolation Forest 的反直觉隔离逻辑,它们都在以不同的方式诠释着一个简单的真理:在不确定的世界里,多元的判断往往胜过单一的智慧。正是这种理念,让集成学习成为从理论到工程的桥梁,也让它在实际应用中,始终保持着“1+1>2”的活力。
24.5 生活类比
想象集成学习是一座'众人拾柴火焰高'的智慧议会: Bagging = 并行头脑风暴会:每位专家(基础估计器)只看部分资料(bootstrap 采样),独立给出建议,最后投票或平均决策,减少个人偏见(方差) Random Forest = 特征受限的专家组:每位专家每次只能看部分指标(max_features),防止过度依赖某个强特征 Extra Trees = 极端随机专家组:甚至不找最优切分点,随机选阈值再选最优,极大加快决策且抗噪 GBDT = 串行纠错接力赛:每位选手(树)专门修正前人的错误(负梯度),步长受控(学习率),逐步逼近真相 HistGB = 分箱量化的现代接力赛:将连续指标离散化(分箱),用直方图快速统计增益,像用计算器代替手算,极大提速 Stacking = 两级决策架构:基层专家(基础模型)产出报告,高层决策者(元学习器)综合研判 Voting = 联合执政:多党派(异质模型)硬投票(少数服从多数)或软投票(按支持率加权) AdaBoost = 自适应重点攻关:每轮聚焦上轮搞错的案例(增大样本权重),像补课一样攻克难点 Isolation Forest = 孤岛猎手:随机切割特征空间,异常点像孤岛一样更容易被隔离(路径更短) 就像议会制度通过多元化、分工协作、制衡机制,做出比单一独裁者更鲁棒的决策,集成学习通过多模型协作实现‘1+1>2’的预测效果。
24.6 模块地图/架构图
sklearn/ensemble/_bagging.py
├── _generate_indices() # 随机采样索引生成
├── _generate_bagging_indices() # 样本/特征子采样索引生成(支持加权采样)
├── _consumes_sample_weight() # 判断估计器是否支持 sample_weight
├── _parallel_build_estimators() # 并行构建基础估计器核心函数
├── _parallel_predict_proba() # 并行概率预测聚合
├── _parallel_predict_log_proba() # 并行对数概率预测聚合
├── _parallel_decision_function() # 并行决策函数聚合
├── _parallel_predict_regression() # 并行回归预测聚合
├── BaseBagging (ABCMeta)
│ ├── __init__() # 参数初始化与约束验证
│ ├── fit() # 入口 fit,校验数据与元数据路由
│ ├── _fit() # 核心训练流程:采样、并行、warm_start、OOB
│ ├── _validate_y() # 标签验证(抽象方法)
│ ├── _get_estimators_indices() # 动态生成各估计器的采样索引
│ ├── get_metadata_routing() # 元数据路由配置
│ ├── _get_estimator() # 获取基础估计器(抽象方法)
│ ├── __sklearn_tags__() # 标签系统:稀疏/NaN 支持
├── BaggingClassifier (ClassifierMixin, BaseBagging)
│ ├── _get_estimator() # 默认 DecisionTreeClassifier
│ ├── _set_oob_score() # OOB 评分:概率平均/投票计数
│ ├── predict() # 预测:argmax(predict_proba)
│ ├── predict_proba() # 概率预测:平均概率或投票比例
│ ├── predict_log_proba() # 对数概率:logaddexp 聚合
│ └── decision_function() # 决策函数:平均 decision_function
├── BaggingRegressor (RegressorMixin, BaseBagging)
│ ├── _get_estimator() # 默认 DecisionTreeRegressor
│ ├── predict() # 预测:平均回归值
│ └── _set_oob_score() # OOB 评分:平均预测值 + R2
sklearn/ensemble/_forest.py
├── _generate_sample_indices() # 树级 bootstrap 采样索引
├── _generate_unsampled_indices() # OOB 样本索引生成
├── _parallel_build_trees() # 并行构建单棵树(线程级并行)
├── _accumulate_prediction() # 并行预测累加工具函数
├── BaseForest (MultiOutputMixin, BaseEnsemble)
│ ├── __init__() # 森林参数初始化
│ ├── fit() # 训练流程:数据验证、权重处理、并行建树、OOB
│ ├── _validate_y_class_weight() # 标签编码与类权重计算(抽象)
│ ├── _compute_oob_predictions() # OOB 预测聚合核心逻辑
│ ├── _set_oob_score_and_attributes() # OOB 评分设置(抽象)
│ ├── apply() / decision_path() # 叶子索引/决策路径并行计算
│ ├── feature_importances_ (property) # 杂质重要性并行平均
│ ├── _get_estimators_indices() # 生成各树的采样索引
│ └── __sklearn_tags__() # 标签:NaN/稀疏支持
├── ForestClassifier (ClassifierMixin, BaseForest)
│ ├── _get_oob_predictions() # 分类 OOB 预测:predict_proba
│ ├── _set_oob_score_and_attributes() # OOB 准确率
│ ├── predict() / predict_proba() / predict_log_proba() # 并行概率平均预测
├── ForestRegressor (RegressorMixin, BaseForest)
│ ├── _get_oob_predictions() # 回归 OOB 预测:predict
│ ├── _set_oob_score_and_attributes() # OOB R2 分数
│ ├── predict() # 并行平均预测
│ └── _compute_partial_dependence_recursion() # 偏依赖快速计算
├── RandomForestClassifier / Regressor # splitter='best'
├── ExtraTreesClassifier / Regressor # splitter='random', ExtraTree
└── RandomTreesEmbedding (TransformerMixin, BaseForest) # 无监督嵌入
sklearn/ensemble/_base.py
├── _fit_single_estimator() # 单估计器拟合(支持 sample_weight 路由)
├── _set_random_states() # 递归设置随机种子
├── BaseEnsemble (MetaEstimatorMixin, BaseEstimator)
│ ├── __init__() / _validate_estimator() / _make_estimator() # 估计器克隆与参数设置
│ └── __len__/__getitem__/__iter__ # 容器协议
├── _partition_estimators() # 估计器并行分区工具
└── _BaseHeterogeneousEnsemble (MetaEstimatorMixin, _BaseComposition) # 异构集成基类
├── named_estimators / _validate_estimators() # 命名访问与验证
└── set_params/get_params() # 嵌套参数管理
sklearn/ensemble/_bootstrap.py
└── _get_n_samples_bootstrap() # 计算 bootstrap 样本数(支持加权)
sklearn/ensemble/_gb.py
├── _safe_divide() / _init_raw_predictions() # 数值安全/初始化预测
├── _update_terminal_regions() # 叶值线搜索更新(Newton 步)
├── set_huber_delta() # Huber delta 动态计算
├── VerboseReporter # 训练进度报告
├── BaseGradientBoosting (BaseEnsemble, ABCMeta)
│ ├── __init__() / _encode_y() / _get_loss() # 抽象参数/标签编码/损失获取
│ ├── _fit_stage() # 单阶段拟合:负梯度拟合树 + 叶值更新
│ ├── _set_max_features() / _init_state() / _clear_state() / _resize_state() # 状态管理
│ ├── fit() # 主训练循环:早停、验证集、warm_start
│ ├── _fit_stages() # 多阶段迭代:子采样、OOB、早停监控
│ ├── _raw_predict() / _staged_raw_predict() # 原始预测/阶段预测生成器
│ ├── apply() # 叶子索引应用
│ └── feature_importances_ / _compute_partial_dependence_recursion() # 重要性/偏依赖
├── GradientBoostingClassifier # log_loss/exponential, 多类别多树
│ ├── _encode_y() / _get_loss() # 标签编码/损失实例化
│ ├── decision_function/staged_decision_function() # 决策函数
│ ├── predict/staged_predict() # 类别预测
│ └── predict_proba/staged_predict_proba/predict_log_proba # 概率预测
└── GradientBoostingRegressor # squared_error/absolute_error/huber/quantile
├── _encode_y() / _get_loss() # 目标编码/损失实例化
└── predict/staged_predict/apply() # 回归预测/阶段预测/叶子索引
sklearn/ensemble/_gradient_boosting.pyx
├── _predict_regression_tree_inplace_fast_dense() # 密集数据树预测(无 GIL)
├── _predict_regression_tree_stages_sparse() # 稀疏数据树预测(CSR)
├── predict_stages() # 多阶段预测聚合(稠密/稀疏分发)
├── predict_stage() # 单阶段预测
└── _random_sample_mask() # 子采样掩码生成(无偏)
sklearn/ensemble/_hist_gradient_boosting/binning.py
├── _find_binning_thresholds() # 分位数分箱阈值计算
└── _BinMapper (TransformerMixin, BaseEstimator)
├── fit() # 计算分箱阈值(并行、分类/连续分离)
├── transform() # 数据分箱映射(Cython _map_to_bins)
└── make_known_categories_bitsets() # 分类特征已知类别位集构建
sklearn/ensemble/_hist_gradient_boosting/grower.py
├── TreeNode # 训练期树节点(含分裂信息、直方图、边界)
└── TreeGrower
├── __init__() # 组件初始化:HistogramBuilder, Splitter
├── grow() # 最佳优先生长主循环
├── _compute_best_split_and_push() # 计算最优分裂入堆
├── split_next() # 分裂最高增益节点、更新约束、计算子节点直方图
├── _compute_interactions() # 交互约束传递
├── _finalize_leaf() / _finalize_splittable_nodes() # 叶子终结
├── _apply_shrinkage() # 学习率收缩
└── make_predictor() # 转换为预测期 TreePredictor
sklearn/ensemble/_hist_gradient_boosting/histogram.pyx
└── HistogramBuilder
├── compute_histograms_brute() # O(n_samples) 暴力直方图构建(并行)
├── compute_histograms_subtraction() # O(n_bins) 减法直方图构建
├── _compute_histogram_brute_single_feature() # 单特征直方图分发(根/非根、有无 hessian)
└── _build_histogram_*() 系列 # 核心直方图填充循环(展开优化)
sklearn/ensemble/_hist_gradient_boosting/splitting.pyx
├── SplitInfo / split_info_struct / categorical_info # 分裂信息数据结构
└── Splitter
├── split_indices() # 并行样本分区(双缓冲、原地分区)
├── find_node_split() # 寻找最优分裂:连续/分类/缺失值双向扫描
├── _find_best_bin_to_split_left_to_right() # 左→右扫描(缺失值去右)
├── _find_best_bin_to_split_right_to_left() # 右←左扫描(缺失值去左)
├── _find_best_bin_to_split_category() # 分类特征:按梯度/海森排序扫描
├── _find_best_feature_to_split_helper() # 特征级最优分裂选择
└── _split_gain / compute_node_value / sample_goes_left # 增益/节点值/样本走向计算
sklearn/ensemble/_hist_gradient_boosting/_binning.pyx
└── _map_to_bins() / _map_col_to_bins() # 二分查找分箱映射(并行)
sklearn/ensemble/_hist_gradient_boosting/common.pxd
├── 类型定义: X_DTYPE_C, X_BINNED_DTYPE_C, Y_DTYPE_C, G_H_DTYPE_C, BITSET_*
├── hist_struct / node_struct # 直方图/预测节点 C 结构体
└── MonotonicConstraint (NO_CST=0, POS=1, NEG=-1) # 单调约束枚举
sklearn/ensemble/_hist_gradient_boosting/common.pyx
├── 常量: Y_DTYPE, X_DTYPE, X_BINNED_DTYPE, G_H_DTYPE, HISTOGRAM_DTYPE, PREDICTOR_RECORD_DTYPE, ALMOST_INF
sklearn/ensemble/_hist_gradient_boosting/utils.py
└── get_equivalent_estimator() # LightGBM/XGBoost/CatBoost 参数映射
sklearn/ensemble/_hist_gradient_boosting/predictor.py
└── TreePredictor
├── predict() / predict_binned() # 原始/分箱数据预测(并行)
└── compute_partial_dependence() # 偏依赖递归计算
sklearn/ensemble/_hist_gradient_boosting/_predictor.pyx
├── _predict_from_raw_data() / _predict_one_from_raw_data() # 原始数据预测(处理分类/缺失/未知)
├── _predict_from_binned_data() / _predict_one_from_binned_data() # 分箱数据预测
└── _compute_partial_dependence() # 权重加权树遍历偏依赖
sklearn/ensemble/_hist_gradient_boosting/_gradient_boosting.pyx
└── _update_raw_predictions() / _update_raw_predictions_helper() # 原地更新原始预测(叶值广播)
sklearn/ensemble/_hist_gradient_boosting/_bitset.pxd / _bitset.pyx
├── init_bitset/set_bitset/in_bitset # 位集基础操作(无 GIL)
├── in_bitset_memoryview/in_bitset_2d_memoryview # 内存视图位集查询
└── set_bitset_memoryview/set_raw_bitset_from_binned_bitset # 分箱→原始类别位集转换
sklearn/ensemble/_hist_gradient_boosting/gradient_boosting.py
├── _update_leaves_values() # 非可微损失叶值线搜索更新
├── _patch_raw_predict() # 上下文管理器:拦截 _raw_predict 用于早停评分
├── BaseHistGradientBoosting (BaseEstimator, ABC)
│ ├── _preprocess_X() / _check_categorical_features() / _check_categories() / _check_interaction_cst() # 数据预处理
│ ├── fit() # 主训练:分箱、梯度/海森、迭代建树、早停
│ ├── _bin_data() / _print_iteration_stats() # 分箱/日志
│ ├── _raw_predict() / _staged_raw_predict() # 预测流水线
│ ├── _predict_iterations() # 迭代预测累加
│ ├── _check_early_stopping_scorer/loss() # 早停判断
│ ├── _score_with_raw_predictions() / _should_stop() # 评分/停止条件
│ ├── _get_small_trainset() # 评分子采样
│ └── _compute_partial_dependence_recursion() # 偏依赖递归
├── HistGradientBoostingRegressor # 回归损失族、quantile 参数
│ ├── predict/staged_predict() # 逆链接函数输出
│ └── _encode_y/_encode_y_val/_get_loss() # 目标验证/损失实例化
└── HistGradientBoostingClassifier # log_loss、类权重、标签编码
├── predict/predict_proba/staged_* # 分类预测/概率/阶段预测
└── _encode_y/_encode_y_val/_get_loss/_finalize_sample_weight # 标签编码/类权重/损失
sklearn/ensemble/_hist_gradient_boosting/__init__.py
sklearn/ensemble/_stacking.py
├── _BaseStacking (TransformerMixin, _BaseHeterogeneousEnsemble, ABCMeta)
│ ├── _clone_final_estimator() / _concatenate_predictions() # 元特征拼接(二分类概率去重、passthrough)
│ ├── _method_name() # 响应方法解析
│ ├── fit() # 交叉验证生成元特征、最终估计器拟合
│ ├── _transform() / get_feature_names_out() # 变换/特征名
│ ├── predict() / get_metadata_routing() / _sk_visual_block_with_final_estimator()
├── StackingClassifier (ClassifierMixin, _BaseStacking)
│ ├── _validate_final_estimator/estimators() # 验证分类器/宽松基础估计器类型
│ ├── fit() # 标签编码(支持 multilabel)
│ ├── predict/predict_proba/decision_function/transform() # 预测/概率/决策/变换
│ └── _sk_visual_block_() # 可视化块
└── StackingRegressor (RegressorMixin, _BaseStacking)
├── _validate_final_estimator() # 验证回归器
├── fit/fit_transform/predict/transform() # 回归流程
└── _sk_visual_block_() # 可视化块
sklearn/ensemble/_voting.py
├── _BaseVoting (TransformerMixin, _BaseHeterogeneousEnsemble)
│ ├── _log_message() / _weights_not_none / _predict() # 工具方法
│ ├── fit() # 并行拟合基础估计器
│ ├── fit_transform() / n_features_in_ / _sk_visual_block_ / get_metadata_routing()
├── VotingClassifier (ClassifierMixin, _BaseVoting)
│ ├── fit() # 标签编码、类型检查
│ ├── predict() # hard: 加权投票; soft: 平均概率 argmax
│ ├── _collect_probas() / _check_voting() # 概率收集/投票模式检查
│ ├── predict_proba() # 加权平均概率
│ ├── transform() # 概率/标签输出(flatten 控制)
│ └── get_feature_names_out() # 特征名生成
└── VotingRegressor (RegressorMixin, _BaseVoting)
├── fit/predict/transform/get_feature_names_out() # 回归平均预测
sklearn/ensemble/_weight_boosting.py
├── BaseWeightBoosting (BaseEnsemble, ABCMeta)
│ ├── fit() # 权重初始化、迭代 boosting、早停
│ └── _boost() / staged_score() / feature_importances_ # 抽象 boosting 步/分阶段评分/重要性
├── AdaBoostClassifier (_RoutingNotSupportedMixin, ClassifierMixin, BaseWeightBoosting)
│ ├── _validate_estimator() # 必须支持 sample_weight
│ ├── _boost() # SAMME 算法:错误率→权重、样本权重指数更新
│ ├── predict/staged_predict/decision_function/staged_decision_function() # 决策函数聚合
│ ├── _compute_proba_from_decision() # softmax 概率转换
│ └── predict_proba/staged_predict_proba/predict_log_proba # 概率预测
└── AdaBoostRegressor (_RoutingNotSupportedMixin, RegressorMixin, BaseWeightBoosting)
├── _boost() # AdaBoost.R2: 绝对误差归一化、beta 计算、权重更新
├── _get_median_predict() # 加权中位数预测
└── predict/staged_predict() # 中位数预测/阶段预测
sklearn/ensemble/_iforest.py
├── _parallel_compute_tree_depths() # 并行计算树深度(线程锁聚合)
├── IsolationForest (OutlierMixin, BaseBagging)
│ ├── _get_estimator() # ExtraTreeRegressor(max_features=1, splitter=random)
│ ├── fit() # 最大深度 ceil(log2(max_samples))、路径长度收集
│ ├── predict/decision_function/score_samples() # 预测/决策/得分(阈值 offset_)
│ ├── _score_samples() / _compute_chunked_score_samples() / _compute_score_samples() # 分块并行评分
└── _average_path_length() # BST 平均路径长度公式(Euler-Mascheroni 常数)
以上地图列出本章源码模块及其职责,后文将按数据流逐一解析。
24.7 动手练习
24.7.1 阅读 Bagging 并行训练与样本权重机制
阅读 sklearn/ensemble/_bagging.py 中 _parallel_build_estimators 函数(第130-220行)和 _generate_bagging_indices 函数(第80-110行)
回答问题:
-
当基础估计器支持
sample_weight时,Bagging 如何避免数据切片拷贝? -
sample_weight如何影响_generate_bagging_indices的采样行为? -
warm_start=True时,随机种子如何对齐以保证可复现性?
24.7.2 对比 RandomForest 与 ExtraTrees 的分裂策略
阅读 sklearn/ensemble/_forest.py 中 RandomForestClassifier 和 ExtraTreesClassifier 的 __init__ 方法(第700-900行区域)
回答问题:
-
两者传给
BaseForest的estimator参数有何不同? -
splitter='best'与splitter='random'在_parallel_build_trees调用链中如何体现差异? -
为什么 ExtraTrees 通常训练更快但单棵树方差更大?
24.7.3 分析 GBDT 叶值更新与早停机制
阅读 sklearn/ensemble/_gb.py 中 _update_terminal_regions 函数(第130-200行)和 _fit_stages 方法(第600-720行)
回答问题:
-
_update_terminal_regions为何对不同损失函数(SquaredError vs BinomialLoss)采用不同的更新策略? -
subsample < 1.0时,_random_sample_mask如何实现无偏子采样? -
早停时
validation_loss与loss_history的比较逻辑是什么?为何用np.any(validation_loss + self.tol < loss_history)?
24.7.4 探究 HistGB 分箱、直方图构建与分裂搜索
阅读 sklearn/ensemble/_hist_gradient_boosting/grower.py 中 TreeGrower.grow 方法(第180-220行)
阅读 sklearn/ensemble/_hist_gradient_boosting/histogram.pyx 中 compute_histograms_brute 与 compute_histograms_subtraction(第50-150行)
阅读 sklearn/ensemble/_hist_gradient_boosting/splitting.pyx 中 Splitter.find_node_split(第200-350行)
回答问题:
-
为什么 HistGB 选择 '最佳优先' 而非 '层级生长' 策略?
-
直方图减法
compute_histograms_subtraction相比暴力构建有何计算复杂度优势? -
分类特征分裂搜索为何要先按
sum_gradients/sum_hessians排序再扫描?缺失值如何处理?
24.7.5 理解 Stacking 的元特征生成与 Voting 的聚合差异
阅读 sklearn/ensemble/_stacking.py 中 _BaseStacking.fit 方法(第150-250行)和 _concatenate_predictions(第80-130行)
阅读 sklearn/ensemble/_voting.py 中 VotingClassifier.predict 与 predict_proba(第250-320行)
回答问题:
-
Stacking 为何使用
cross_val_predict生成 out-of-fold 预测而非直接在训练集预测? -
_concatenate_predictions如何处理二分类概率的共线性问题?multilabel-indicator 场景如何处理? -
Voting 的硬投票
np.bincount与软投票np.average在数学本质上有何区别?
24.7.6 剖析 AdaBoost 样本权重更新与 IsolationForest 异常评分
阅读 sklearn/ensemble/_weight_boosting.py 中 AdaBoostClassifier._boost(第250-320行)和 AdaBoostRegressor._boost(第480-560行)
阅读 sklearn/ensemble/_iforest.py 中 IsolationForest._compute_score_samples(第400-460行)和 _average_path_length(第500-530行)
回答问题:
-
AdaBoost 分类中
estimator_weight公式为何包含log(n_classes - 1)项?回归中beta = err/(1-err)的几何意义是什么? -
IsolationForest 为何选择
ExtraTreeRegressor(max_features=1, splitter='random')作为基础树? -
异常分数公式
s = 2^(-E[h(x)]/c(n))中c(n)代表什么?为何路径长度越短得分越低(越异常)?
24.8 架构与数据流图
上述图分别展示模块依赖、调用时序、数据流和架构分层。
第 25 章 —— 集成学习 —— “众人拾柴火焰高”的模型智慧
25.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解集成学习的核心思想:Bagging、Boosting、Stacking、Voting 四大范式的异同
-
掌握随机森林与 ExtraTrees 的自助采样、特征随机化、并行训练、OOB 评估原理
-
深入剖析传统 GBDT 的分阶段拟合、损失函数族、Cython 优化的回归树预测与终端区域更新
-
精通 HistGradientBoosting 的分箱策略、直方图构建、最佳优先树生长、分裂寻优、位集类别支持
-
理解堆叠泛化的交叉验证元特征生成、投票集成的硬/软投票机制、AdaBoost 的样本权重自适应调整
-
掌握隔离森林的隔离树构建、路径长度计算、异常分数聚合及并行深度计算机制
25.2 生活类比
想象集成学习是一个“联邦议会”的决策过程:Bagging(随机森林)像“多方独立调查”,每位议员(基学习器)各自抽样调查(Bootstrap),独立投票,最后少数服从多数,降低方差;Boosting(GBDT/HGBT)像“接力纠错”,前一位议员犯错的地方,后一位议员重点关注(拟合残差/梯度),逐步逼近真相,降低偏差;Stacking 像“两级决策”,基层议员(基学习器)各抒己见,高层元学习器(元估计器)综合裁决,发挥互补优势;Voting 像“联合执政”,异构党派(异质模型)按席位比例(权重)共同表决,硬投票看票数,软投票看支持率;Isolation Forest 像“孤岛猎手”,随机砍伐树林(随机分裂),越容易被孤立的树(样本)越是异常,路径越短分数越高。
25.3 源码地图
sklearn/ensemble/_bagging.py
├── BaseBagging.init
├── BaseBagging.fit
├── BaseBagging.predict
├── BaseBagging.predict_proba
├── BaseBagging._parallel_build_estimators
├── BaseBagging._parallel_predict_proba
├── BaseBagging._check_estimator
├── BaseBagging._set_oob_score
├── BaseBagging._fit_context
├── BaggingClassifier.init
├── BaggingRegressor.init
sklearn/ensemble/_forest.py
├── ForestClassifier.init
├── ForestClassifier.fit
├── ForestClassifier.predict
├── ForestClassifier.predict_proba
├── ForestRegressor.init
├── ForestRegressor.fit
├── ForestRegressor.predict
├── ForestRegressor.predict_proba
├── BaseForest.init
├── BaseForest.fit
├── BaseForest._validate_estimator
├── BaseForest._parallel_build_trees
├── BaseForest.apply
├── BaseForest.decision_path
├── RandomForestClassifier.init
├── RandomForestRegressor.init
├── ExtraTreesClassifier.init
├── ExtraTreesRegressor.init
sklearn/ensemble/_base.py
├── _partition_estimators
├── _generate_unsampled_indices
├── _parallel_decision_path
├── _get_oob_predictions
├── BaseEnsemble.init
├── BaseEnsemble.fit
├── BaseEnsemble._validate_estimator
sklearn/ensemble/_gb.py
├── BaseGradientBoosting.init
├── BaseGradientBoosting.fit
├── BaseGradientBoosting.staged_predict
├── BaseGradientBoosting.predict
├── BaseGradientBoosting.loss_
├── BaseGradientBoosting._resize_state
├── BaseGradientBoosting._clear_state
├── BaseGradientBoosting._decision_function
├── GradientBoostingClassifier.init
├── GradientBoostingClassifier.fit
├── GradientBoostingClassifier.predict
├── GradientBoostingClassifier.predict_proba
├── GradientBoostingClassifier._raw_predict_init
├── GradientBoostingClassifier._raw_predict
├── GradientBoostingRegressor.init
├── GradientBoostingRegressor.fit
├── GradientBoostingRegressor.predict
sklearn/ensemble/_gradient_boosting.pyx
├── Predictor.cinit
├── Predictor.predict
├── Predictor.update_terminal_regions
├── GradientBoostingRegressorPredictor.predict
├── GradientBoostingClassifierPredictor.predict
├── _update_terminal_regions_regression
├── _update_terminal_regions_classification
sklearn/ensemble/_hist_gradient_boosting/binning.py
├── _BinningMapper.init
├── _BinningMapper.fit
├── _BinningMapper.transform
├── _BinningMapper._build_bin_thresholds
├── _find_binning_thresholds
├── _map_to_bins
sklearn/ensemble/_hist_gradient_boosting/grower.py
├── TreeGrower.init
├── TreeGrower.grow
├── TreeGrower._grow_tree
├── TreeGrower._find_best_split
├── TreeGrower._find_best_continuous_split
├── TreeGrower._find_best_categorical_split
├── TreeGrower._compute_gain
sklearn/ensemble/_hist_gradient_boosting/histogram.pyx
├── build_histograms
├── HistogramBuilder.cinit
├── HistogramBuilder.add
├── HistogramBuilder.finalize
├── _compute_bin_weighted_sums
sklearn/ensemble/_hist_gradient_boosting/splitting.pyx
├── Splitter.cinit
├── Splitter.find_node_split
├── Splitter._find_split_continuous
├── Splitter._find_split_categorical
sklearn/ensemble/_hist_gradient_boosting/_binning.pyx
├── _find_binning_thresholds
├── _map_to_bins
sklearn/ensemble/_hist_gradient_boosting/common.pxd
├── Histogram (struct)
├── Bin (struct)
├── Node (struct)
sklearn/ensemble/_hist_gradient_boosting/common.pyx
├── _compute_gradients_hessians
├── _compute_sample_weight
├── _loss_function
sklearn/ensemble/_hist_gradient_boosting/utils.py
├── _get_equivalent_estimator
├── _create_grower
sklearn/ensemble/_hist_gradient_boosting/predictor.py
├── TreePredictor.init
├── TreePredictor.predict
├── TreePredictor._predict
sklearn/ensemble/_hist_gradient_boosting/_predictor.pyx
├── predict_core
├── _traverse_tree
sklearn/ensemble/_hist_gradient_boosting/_bitset.pxd
├── Bitset (struct)
├── bitset_get
├── bitset_set
sklearn/ensemble/_hist_gradient_boosting/_bitset.pyx
├── bitset_get
├── bitset_set
sklearn/ensemble/_hist_gradient_boosting/_gradient_boosting.pyx
├── Predictor.cinit

浙公网安备 33010602011771号