Sklearn-源码解析-书-v1-0-三十四-
Sklearn 源码解析(书)v1.0(三十四)
这个例子展示了多输出回归的两种典型结果:ExtraTrees 和 KNN 能捕捉像素级的非线性依赖,而 Linear/Ridge 只能生成模糊的平均脸。源码在第 75-95 行用 4 列子图并排展示"真实下半脸 + 4 种方法的预测下半脸"。
76.8 高斯混合模型 —— 概率聚类的"自适应拼图"
高斯混合模型(GMM)是软聚类的代表,它假设数据由若干个高斯分布混合生成,用 EM 算法求解。
76.8.1 EM vs 变分推断:组件数自适应
源码路径:examples/mixture/plot_gmm.py - plot_results(25-52行)与 __main__(1-80行)
def plot_results(X, Y_, means, covariances, index, title):
"""GMM/DPGMM 椭圆可视化"""
splot = plt.subplot(2, 1, 1 + index)
for i, (mean, covar, color) in enumerate(zip(means, covariances, color_iter)):
# 计算协方差矩阵的特征值与特征向量,用于绘制椭圆
v, w = linalg.eigh(covar) # eigh 返回升序特征值
v = 2.0 * np.sqrt(2.0) * np.sqrt(v) # 2σ 边界(2*sqrt(2) ≈ 2.83σ)
u = w[0] / linalg.norm(w[0]) # 主轴方向(最小特征值对应特征向量)
# as the DP will not use every component it has access to
# unless it needs it, we shouldn't plot the redundant
# components.
if not np.any(Y_ == i): # 没有样本分配的组件不画
continue
plt.scatter(X[Y_ == i, 0], X[Y_ == i, 1], 0.8, color=color)
# Plot an ellipse to show the Gaussian component
angle = np.arctan(u[1] / u[0]) # 主轴与 x 轴夹角
angle = 180.0 * angle / np.pi # 转角度
ell = mpl.patches.Ellipse(mean, v[0], v[1], angle=180.0 + angle, color=color)
ell.set_clip_box(splot.bbox)
ell.set_alpha(0.5) # 半透明便于叠加
splot.add_artist(ell)
plt.xlim(-9.0, 5.0)
plt.ylim(-3.0, 6.0)
plt.xticks(())
plt.yticks(())
plt.title(title)
# 第 76 章 —— EM 必须指定组件数,强行指定 5 个
gmm = mixture.GaussianMixture(n_components=5, covariance_type="full").fit(X)
# 第 76 章 —— BayesianGaussianMixture 用 Dirichlet 过程先验,自适应选择有效组件数
dpgmm = mixture.BayesianGaussianMixture(n_components=5, covariance_type="full").fit(X)
上方 GMM 把 5 个组件全部激活,椭圆被强行切成 5 块;下方 DPGMM 只激活 2-3 个椭圆,让"DP 自动选组件数"与"EM 强行凑组件数"形成对比。
76.8.2 GMM 协方差类型对比
源码路径:examples/mixture/plot_gmm_covariances.py - make_ellipses(33-50行)与 __main__(1-120行)
def make_ellipses(gmm, ax):
"""不同协方差类型椭圆绘制"""
for n, color in enumerate(colors):
if gmm.covariance_type == "full":
covariances = gmm.covariances_[n][:2, :2] # 每个组件独立的 2×2 矩阵
elif gmm.covariance_type == "tied":
covariances = gmm.covariances_[:2, :2] # 所有组件共享同一矩阵
elif gmm.covariance_type == "diag":
covariances = np.diag(gmm.covariances_[n][:2]) # 对角矩阵
elif gmm.covariance_type == "spherical":
covariances = np.eye(gmm.means_.shape[1]) * gmm.covariances_[n] # 各向同性的标量×单位阵
v, w = np.linalg.eigh(covariances) # 特征分解
u = w[0] / np.linalg.norm(w[0]) # 主轴
angle = np.arctan2(u[1], u[0]) # 椭圆旋转角
angle = 180 * angle / np.pi
v = 2.0 * np.sqrt(2.0) * np.sqrt(v) # 2σ 边界
ell = mpl.patches.Ellipse(
gmm.means_[n, :2], v[0], v[1], angle=180 + angle, color=color
)
ell.set_clip_box(ax.bbox)
ell.set_alpha(0.5)
ax.add_artist(ell)
ax.set_aspect("equal", "datalim")
spherical 训练 96.7% / 测试 80.0%、diag 训练 96.7% / 测试 86.0%、tied 训练 98.3% / 测试 92.0%、full 训练 98.3% / 测试 100.0%。协方差越复杂,拟合越精细,但越容易过拟合小数据。
76.8.3 GMM 初始化方法对比
源码路径:examples/mixture/plot_gmm_init.py - get_initial_means(41-47行)与 __main__(1-100行)
def get_initial_means(X, init_params, r):
"""零迭代 GMM 获取初始化中心"""
gmm = GaussianMixture(
n_components=4, init_params=init_params, tol=1e-9, max_iter=0, random_state=r
).fit(X)
return gmm.means_
methods = ["kmeans", "random_from_data", "k-means++", "random"]
k-means++ 通常 4-6 次迭代就收敛,random_from_data 和 random 需要 10+ 次迭代才能收敛。
76.8.4 密度估计等高线
源码路径:examples/mixture/plot_gmm_pdf.py - __main__(1-80行)
# 第 76 章 —— 拟合 GMM(2 个 full 协方差分量)
clf = mixture.GaussianMixture(n_components=2, covariance_type="full")
clf.fit(X_train)
# 第 76 章 —— 用对数色阶绘制负对数似然等高线
Z = -clf.score_samples(XX)
# 第 76 章 —— LogNorm 让中心区域 vs 边缘区域的差异在对数尺度上清晰可见
CS = plt.contour(X, Y, Z, norm=LogNorm(vmin=1.0, vmax=1000.0), levels=np.logspace(0, 3, 10))
LogNorm 让"中心区域 vs 边缘区域"的差异在对数尺度上清晰可见。
76.8.5 模型选择:BIC 网格搜索
源码路径:examples/mixture/plot_gmm_selection.py - gmm_bic_score(94-97行)与 __main__(1-150行)
def gmm_bic_score(estimator, X):
"""Callable to pass to GridSearchCV that will use the BIC score."""
return -estimator.bic(X) # BIC 越小越好 → 取负
param_grid = {
"n_components": range(1, 7),
"covariance_type": ["spherical", "tied", "diag", "full"],
}
grid_search = GridSearchCV(
GaussianMixture(), param_grid=param_grid, scoring=gmm_bic_score
)
grid_search.fit(X)
BIC(贝叶斯信息准则)在 GMM 组件数选择上比 AIC 更倾向于简约模型,因为它对参数数量施加了更强的惩罚(log(n) 倍)。
76.8.6 正弦曲线上的非高斯建模
源码路径:examples/mixture/plot_gmm_sin.py - plot_results(25-52行)、plot_samples(55-68行)与 __main__(1-200行)
def plot_results(X, Y, means, covariances, index, title):
"""正弦曲线拟合:GMM/DPGMM 组件椭圆、浓度先验影响"""
splot = plt.subplot(5, 1, 1 + index)
for i, (mean, covar, color) in enumerate(zip(means, covariances, color_iter)):
# 特征值分解画椭圆(与 plot_gmm.py 中相同)
v, w = linalg.eigh(covar)
v = 2.0 * np.sqrt(2.0) * np.sqrt(v)
u = w[0] / linalg.norm(w[0])
if not np.any(Y == i): # DP 不画的组件就跳过
continue
plt.scatter(X[Y == i, 0], X[Y == i, 1], 0.8, color=color)
angle = np.arctan(u[1] / u[0])
angle = 180.0 * angle / np.pi
ell = mpl.patches.Ellipse(mean, v[0], v[1], angle=180.0 + angle, color=color)
ell.set_clip_box(splot.bbox)
ell.set_alpha(0.5)
splot.add_artist(ell)
def plot_samples(X, Y, n_components, index, title):
"""采样可视化:从拟合模型生成新样本"""
plt.subplot(5, 1, 4 + index)
for i, color in zip(range(n_components), color_iter):
if not np.any(Y == i):
continue
plt.scatter(X[Y == i, 0], X[Y == i, 1], 0.8, color=color)
低浓度 DPGMM 用 3-4 个大椭圆概括正弦的"主旋律",高浓度 DPGMM 用 10+ 个小椭圆描绘细节。
76.8.7 浓度先验:Dirichlet 分布 vs 过程
源码路径:examples/mixture/plot_concentration_prior.py - plot_ellipses(42-56行)、plot_results(59-85行)与 __main__(1-150行)
def plot_ellipses(ax, weights, means, covars):
"""高斯分量椭圆绘制:权重透明度、协方差特征值"""
for n in range(means.shape[0]):
eig_vals, eig_vecs = np.linalg.eigh(covars[n]) # 特征值分解
unit_eig_vec = eig_vecs[0] / np.linalg.norm(eig_vecs[0])
angle = np.arctan2(unit_eig_vec[1], unit_eig_vec[0])
# Ellipse needs degrees
angle = 180 * angle / np.pi
# eigenvector normalization
eig_vals = 2 * np.sqrt(2) * np.sqrt(eig_vals) # 2σ 边界
ell = mpl.patches.Ellipse(
means[n], eig_vals[0], eig_vals[1], angle=180 + angle, edgecolor="black"
)
ell.set_clip_box(ax.bbox)
ell.set_alpha(weights[n]) # 椭圆透明度 = 权重
ell.set_facecolor("#56B4E9")
ax.add_artist(ell)
# 第 76 章 —— 有限混合:Dirichlet 分布先验
BayesianGaussianMixture(
weight_concentration_prior_type="dirichlet_distribution",
n_components=2 * n_components, ...
)
# 第 76 章 —— 无限混合:Dirichlet 过程先验
BayesianGaussianMixture(
weight_concentration_prior_type="dirichlet_process",
n_components=2 * n_components, ...
)
weight_concentration_prior(γ₀)直接控制激活组件数:值越小,模型越倾向于把所有权重集中在少数组件;值越大,越倾向于均匀分配权重。
76.9 跨分解与组合流水线 —— 多视角融合的"装配线"
最后一节聚焦于两个互补的主题:跨分解方法(CCA、PLS)寻找两个数据集共享的隐空间,以及 sklearn 的组合流水线(ColumnTransformer、FeatureUnion、Pipeline)。
76.9.1 PLSCanonical/PLSRegression/CCA 三方法对比
源码路径:examples/cross_decomposition/plot_compare_cross_decomposition.py - __main__(1-150行)
# 第 76 章 —— PLSCanonical(源码 38-42 行):对称 PLS,找两个空间最大协方差方向
plsca = PLSCanonical(n_components=2)
plsca.fit(X_train, Y_train)
X_train_r, Y_train_r = plsca.transform(X_train, Y_train)
# 第 76 章 —— PLSRegression 多变量响应 (PLS2)(源码 76-89 行)
pls2 = PLSRegression(n_components=3).fit(X, Y)
print("True B (such that: Y = XB + Err)"); print(B)
print("Estimated B"); print(np.round(pls2.coef_, 1))
# 第 76 章 —— PLSRegression 单变量响应 (PLS1)(源码 96-103 行)
pls1 = PLSRegression(n_components=3).fit(X, y)
# 第 76 章 —— CCA(源码 109-112 行):PLS mode B 的对称消歧
cca = CCA(n_components=2).fit(X_train, Y_train)
PLS 的核心承诺"找到两个空间最大共享方差"用散点矩阵图形语言精准表达:对角线展示"X 得分 vs Y 得分"(高度相关),非对角线展示"X 第 1 组件 vs X 第 2 组件"(不相关)。
76.9.2 PLS vs PCR:监督降维的力量
源码路径:examples/cross_decomposition/plot_pcr_vs_pls.py - __main__(1-150行)
# 第 76 章 —— PCR:PCA(无监督)+ 线性回归
pcr = make_pipeline(StandardScaler(), PCA(n_components=1), LinearRegression())
pcr.fit(X_train, y_train)
# 第 76 章 —— PLS:监督降维,能找到预测力最强的方向
pls = PLSRegression(n_components=1)
pls.fit(X_train, y_train)
print(f"PCR r-squared {pcr.score(X_test, y_test):.3f}") # 可能为负
print(f"PLS r-squared {pls.score(X_test, y_test):.3f}") # 显著为正
PCR 是"无监督 PCA + 有监督回归"的拼接,无法利用标签信息;PLS 把标签信息融入降维过程,因此能捕获"预测力强但方差小"的方向。
76.9.3 ColumnTransformer:异构特征的"分拣中心"
源码路径:examples/compose/plot_column_transformer_mixed_types.py - __main__(1-150行)
numeric_features = ["age", "fare"]
numeric_transformer = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")), # 中位数填补
("scaler", StandardScaler()), # 标准化
])
categorical_features = ["embarked", "sex", "pclass"]
categorical_transformer = Pipeline(steps=[
("encoder", OneHotEncoder(handle_unknown="ignore")),
("selector", SelectPercentile(chi2, percentile=50)), # 卡方筛选保留 50% 特征
])
preprocessor = ColumnTransformer(
transformers=[
("num", numeric_transformer, numeric_features),
("cat", categorical_transformer, categorical_features),
]
)
clf = Pipeline(steps=[("preprocessor", preprocessor), ("classifier", LogisticRegression())])
ColumnTransformer 把"按列分发的预处理"封装成一个统一的转换器。preprocessor__num__imputer__strategy 这种双下划线语法能精确定位嵌套在 Pipeline 中的 ColumnTransformer 内的特定步骤参数。
76.9.4 ColumnTransformer 异构数据:邮件主题与正文
源码路径:examples/compose/plot_column_transformer.py - subject_body_extractor(50-68行)、text_stats(71-73行)与 __main__(1-150行)
def subject_body_extractor(posts):
"""邮件主题/正文提取函数"""
# 构造一个 (n_samples, 2) 的对象数组
features = np.empty(shape=(len(posts), 2), dtype=object)
for i, text in enumerate(posts):
# 按空行把邮件分成头部和正文
headers, _, body = text.partition("\n\n") # partition 用 \n\n 分割
features[i, 1] = body # 第二列存正文
prefix = "Subject:"
sub = ""
# 在头部逐行找 Subject: 前缀,取主题文本
for line in headers.split("\n"):
if line.startswith(prefix):
sub = line[len(prefix):] # 去掉前缀
break
features[i, 0] = sub # 第一列存主题
return features
def text_stats(posts):
"""文本统计特征提取:长度/句数"""
return [{"length": len(text), "num_sentences": text.count(".")} for text in posts]
源码把这两个函数用 FunctionTransformer 包成 scikit-learn 兼容的 transformer,再用 ColumnTransformer 拼接三种异构特征:TF-IDF(subject 文本)、TF-IDF + PCA(body 文本)、DictVectorizer(body 统计特征)。
76.9.5 降维选择管线:PCA/NMF/SelectKBest 网格搜索
源码路径:examples/compose/plot_compare_reduction.py - __main__(1-120行)
pipe = Pipeline([
("scaling", MinMaxScaler()),
("reduce_dim", "passthrough"), # 占位符,待网格搜索填充
("classify", LinearSVC(dual=False, max_iter=10000)),
])
param_grid = [
{
"reduce_dim": [PCA(iterated_power=7), NMF(max_iter=1_000)],
"reduce_dim__n_components": N_FEATURES_OPTIONS,
"classify__C": C_OPTIONS,
},
{
"reduce_dim": [SelectKBest(mutual_info_classif)],
"reduce_dim__k": N_FEATURES_OPTIONS,
"classify__C": C_OPTIONS,
},
]
三种降维方法共享同一个网格搜索。第二段用 Memory(location="cachedir") 给 Pipeline 加上缓存,让 GridSearchCV 在多次 CV 中复用 PCA 的拟合结果。
76.9.6 PCA + LogisticRegression 管线
源码路径:examples/compose/plot_digits_pipe.py - __main__(1-100行)
pipe = Pipeline(steps=[
("scaler", StandardScaler()),
("pca", PCA()),
("logistic", LogisticRegression(max_iter=10000, tol=0.1)),
])
param_grid = {
"pca__n_components": [5, 15, 30, 45, 60],
"logistic__C": np.logspace(-4, 4, 4),
}
search = GridSearchCV(pipe, param_grid, n_jobs=2)
search.fit(X_digits, y_digits)
源码用 polars 解析 cv_results_——筛选"每个 n_components 下 mean_test_score 最高"的行,再绘制"分类准确率 vs n_components"误差条图,同时叠加 PCA 的"解释方差比"谱图。
76.9.7 FeatureUnion:并行特征拼接
源码路径:examples/compose/plot_feature_union.py - __main__(1-80行)
# 第 76 章 —— PCA 降维 + 单变量选择,并联后拼接
combined_features = FeatureUnion([("pca", PCA(n_components=2)), ("univ_select", SelectKBest(k=1))])
X_features = combined_features.fit(X, y).transform(X)
print("Combined space has", X_features.shape[1], "features") # 2+1=3 维
# 第 76 章 —— 套进 Pipeline + GridSearchCV 联合搜索
pipeline = Pipeline([("features", combined_features), ("svm", SVC(kernel="linear"))])
param_grid = dict(
features__pca__n_components=[1, 2, 3],
features__univ_select__k=[1, 2],
svm__C=[0.1, 1, 10],
)
FeatureUnion 把 PCA 与 SelectKBest 水平拼接,输出特征数为两者之和。这与 ColumnTransformer 的关键区别在于:ColumnTransformer 按列分发,FeatureUnion 按行分发(每个 transformer 接收完整特征矩阵)。
76.9.8 目标变换:让线性模型学会非线性
源码路径:examples/compose/plot_transformed_target.py - compute_score(94-97行)与 __main__(1-200行)
def compute_score(y_true, y_pred):
return {
"R2": f"{r2_score(y_true, y_pred):.3f}",
"MedAE": f"{median_absolute_error(y_true, y_pred):.3f}",
}
# 第 76 章 —— 合成指数目标
X, y = make_regression(n_samples=10_000, noise=100, random_state=0)
y = np.expm1((y + abs(y.min())) / 200)
# 第 76 章 —— 包一层 TransformedTargetRegressor:训练时 log1p,预测时 expm1
ridge_cv_with_trans_target = TransformedTargetRegressor(
regressor=RidgeCV(), func=np.log1p, inverse_func=np.expm1
).fit(X_train, y_train)
TransformedTargetRegressor 让任何回归器都能在变换空间建模,然后把预测逆变换回原空间。QuantileTransformer(output_distribution="normal") 可以把任意分布强制变为正态。
76.10 设计中的取舍
为什么半监督学习中 LabelSpreading 需要保留全部训练数据,而 SelfTraining 只需最终基分类器?LabelSpreading 的 predict_proba 依赖查询点与所有训练点的 RBF 相似度加权投票,因此必须存储训练数据用于推理——查询时需要遍历所有训练点计算权重并聚合 label_distributions_。SelfTraining 在迭代过程中把高置信预测当作伪标签扩充基分类器的训练集,最终只保留训练好的基分类器,推理时不再需要回查任何训练样本,因此推理成本与监督学习无异。
为什么 GPC 优化超参数反而会恶化概率校准?GPC 的概率输出依赖拉普拉斯近似,把非高斯的后验用高斯逼近。优化超参数让 LML 最大化,模型在训练点附近学得过于"自信"——exp(-x²/γ²) 的陡峭衰减让决策边界附近的概率接近 0 或 1,但远离训练点时拉普拉斯近似退化——预测概率被推向 0.5 的默认值(后验在远离数据时趋于均匀先验),与真实后验严重偏离。
为什么 GraphicalLasso 能恢复稀疏逆协方差,而 LedoitWolf 不行?GraphicalLasso 在精度矩阵上加 L1 惩罚,强制非对角系数稀疏化——这种稀疏性正是变量间条件独立结构(高斯图模型边)的体现,提供了变量间条件依赖关系的直接可读性。LedoitWolf 的 L2 收缩只让精度矩阵整体向对角矩阵靠拢,所有非对角系数被等比例衰减但永远不为零,无法产生精确的零元素,因此无法恢复条件独立图结构。
为什么 ClassifierChain 用集成才能稳定超越 OvR?ClassifierChain 的性能对标签顺序高度敏感——不同顺序会让前序预测的"信息流"以不同方式辅助后续分类器。比如在 [A, B, C] 链中,B 的预测以 A 的预测为输入特征;但在 [C, A, B] 链中,A 的预测会以 C 为输入。order='random' 加多链集成通过投票抹平单链的顺序偏差——10 条随机链各有不同顺序,对集成预测概率取平均后,单链的极端偏差被中和。
为什么 ColumnTransformer 比手动构建特征管道更稳健?手动 np.hstack 拼接不同列的处理结果需要手动维护列索引、对齐稀疏矩阵、处理 train_test_split 后的列顺序偏移,且容易在 CV 拆分过程中引入数据泄露(先拆分再预处理会泄露验证集信息)。ColumnTransformer 把所有对齐逻辑(稀疏矩阵拼接、列索引重映射、fit 与 transform 的一致性保证、嵌套 Pipeline 的参数寻址如 preprocessor__num__imputer__strategy)由 scikit-learn 统一处理。
为什么 PLS 能找到预测力强但方差小的方向?PCR 用无监督 PCA 找最大方差方向,因此低方差方向会被直接丢弃——即使它们与目标强相关。PLS 把目标信息融入降维过程:在每个迭代步骤中,PLS 同时最大化"X 得分与 Y 得分"的协方差,让 X 的投影方向根据对 Y 的预测能力加权,因此即使 X 在某个方向上方差很小,只要它与 Y 强相关,PLS 就会把那个方向捕获为第一组件。
76.11 动手练习
-
半监督学习实战:标签传播与自训练对比
阅读
examples/semi_supervised/plot_label_propagation_digits.py与plot_self_training_varying_threshold.py。-
修改
plot_label_propagation_digits.py,将LabelSpreading(kernel='knn', alpha=0.8)替换为kernel='rbf'并调整gamma,观察label_distributions_熵分布变化。 -
在
plot_self_training_varying_threshold.py基础上,将基分类器从CalibratedClassifierCV(SVC())换为RandomForestClassifier(),对比不同阈值下labeled_iter_分布差异。
回答问题:
LabelSpreading为什么需要存储完整训练数据用于predict_proba?alpha参数在LabelSpreading中如何平衡"标签保真"与"平滑传播"? -
-
高斯过程核工程与不确定性量化
阅读
examples/gaussian_process/plot_gpr_co2.py与plot_gpr_noisy.py。-
设计一个组合核
RBF(length_scale=100) + WhiteKernel() + ExpSineSquared(periodicity=1),在plot_gpr_co2.py框架下拟合 CO2 数据,对比原复合核的预测区间宽度。 -
在
plot_gpr_noisy.py中,固定RBF(length_scale=0.5)仅优化WhiteKernel(noise_level),绘制噪声水平估计值 vs 真实噪声 (0.3) 的偏差曲线。
回答问题:
plot_gpr_co2.py中periodicity_bounds='fixed'固定周期性为 1 年的几何意义是什么?若设为可优化会发生什么?为什么n_restarts_optimizer能帮助逃离WhiteKernel与RBF交易导致的 LML 局部极小值? -
-
协方差估计与鲁棒马氏距离异常检测
阅读
examples/covariance/plot_mahalanobis_distances.py与plot_sparse_cov.py。-
构造 50 维数据,注入 10% 方差膨胀 100 倍的异常值,对比
EmpiricalCovariance、MinCovDet、LedoitWolf三者马氏距离对异常值的分离度(用 AUC 评估)。 -
使用
GraphicalLassoCV拟合稀疏精度矩阵,可视化precision_非零模式热力图,解释其对应的条件独立图结构。
回答问题:
MinCovDet的崩溃点推导直觉是什么?为何高维小样本下 MCD 失效?GraphicalLasso的alpha如何控制稀疏性? -
-
多任务策略与组合流水线综合实验
阅读
examples/multiclass/plot_multiclass_overview.py、examples/compose/plot_column_transformer_mixed_types.py。-
在
plot_multiclass_overview.py中,将基分类器从DecisionTreeClassifier换为SGDClassifier(loss='log_loss'),并对比OneVsOneClassifier与OneVsRestClassifier在alpha正则化参数下的表现。 -
基于
plot_column_transformer_mixed_types.py,构建一个包含文本列(TF-IDF)、数值列(标准化)、类别列(TargetEncoder)的ColumnTransformer,接入HistGradientBoostingClassifier,用RandomizedSearchCV调优预处理与模型参数。
回答问题:为什么
OneVsRestClassifier在决策树基分类器上表现较差,但在优化超参数后追平OneVsOneClassifier?ColumnTransformer中remainder='passthrough'与remainder='drop'对未指定列的处理差异是什么? -
-
高斯混合模型与跨分解方法的模型选择
阅读
examples/mixture/plot_gmm_selection.py与examples/cross_decomposition/plot_pcr_vs_pls.py。-
修改
plot_gmm_selection.py,使用AIC替代BIC作为GridSearchCV评分函数,对比选出的n_components与covariance_type差异。 -
在
plot_pcr_vs_pls.py中,人为构造目标y与第 3 主成分(方差极小)强相关的数据,验证PLSRegression(n_components=1)能捕获该方向而PCA(n_components=1)+LinearRegression失败。
回答问题:BIC 与 AIC 在 GMM 组件数选择上的理论差异是什么?为何 BIC 更倾向于简约模型?PLS 的监督降维为何能找到"预测力强但方差小"的方向?
-
76.12 本章小结
本章深入探索了 scikit-learn 五大高级主题,每一主题都对应一类现实机器学习难题。为了帮助读者在脑海中形成完整的概念图谱,下面用表格系统梳理本章涉及的核心理论与工具:
| 概念 | 解释 |
|------|------|
| LabelSpreading | 基于相似度图的标签传播,kernel='rbf/knn',alpha 钳制因子,label_distributions_ 存软标签 |
| SelfTrainingClassifier | 自训练元估计器,threshold 置信度筛选伪标签,estimator_ 最终基分类器 |
| ActiveLearning 循环 | LabelSpreading 迭代查询最不确定样本:熵排序 → 取 top-5 → 移到标注集 → 重训 |
| SequenceKernel | 自定义卷积核:_f 计算字符级相似度、_g 计算对 baseline_similarity 解析梯度、支持 eval_gradient |
| GaussianProcessRegressor | 核工程组合(+/*),优化 log-marginal-likelihood,predict(return_std=True) 给出不确定性 |
| GaussianProcessClassifier | 拉普拉斯近似二分类/OneVsRest 多分类,predict_proba 概率输出,LML 景观可视化超参数 |
| RBF/ExpSineSquared/Matern/RationalQuadratic/DotProduct | 核函数食谱:平稳 vs 非平稳、平滑 vs 粗糙、单尺度 vs 多尺度 |
| LedoitWolf / OAS | 收缩协方差估计,解析式计算最优收缩系数,OAS 假设高斯分布小样本更优 |
| MinCovDet (MCD) | 最小协方差行列式鲁棒估计,高崩溃点,mahalanobis() 计算鲁棒马氏距离用于异常检测 |
| Wilson-Hilferty 变换 | d^(1/3) 把马氏距离的偏态分布近似为正态,便于箱线图可视化 |
| GraphicalLassoCV | L1 稀疏逆协方差估计,CV 选择 alpha,precision_ 稀疏精度矩阵揭示条件独立图结构 |
| OneVsOneClassifier / OneVsRestClassifier | 多类别分解元估计器,OvO 训练 K(K-1)/2 分类器投票,OvR 训练 K 个分类器取 argmax |
| OutputCodeClassifier (ECOC) | 纠错编码多类别分解,随机码本 code_size 控制码字长度 |
| ClassifierChain | 多标签链式建模,前序预测作为后序特征,order='random' 集成投票缓解顺序敏感性 |
| ColumnTransformer | 异构列分发预处理,transformers 列表指定 (name, transformer, columns),transformer_weights 加权 |
| FeatureUnion | 并行特征拼接,transformer_list 多路变换水平堆叠,n_jobs 并行,支持 GridSearchCV |
| Pipeline / RandomizedSearchCV | 串联预处理与模型,memory 缓存中间结果,preprocessor__num__imputer__strategy 双下划线语法跨步调参 |
| TransformedTargetRegressor | 目标变换回归,func/inverse_func 或 transformer 管道,在变换空间线性建模再逆变换 |
| GaussianMixture | EM 求解 GMM,n_components 必指定,covariance_type full/tied/diag/spherical |
| BayesianGaussianMixture | 变分推断 + Dirichlet 过程先验,weight_concentration_prior 控制激活组件数 |
| BIC / AIC 模型选择 | GridSearchCV 评分函数 + n_components/covariance_type 网格,gmm_bic_score 取负 BIC |
| PLSCanonical / PLSRegression / CCA | 跨分解方法,监督降维找到预测力强但方差小的方向 |
感谢你读到了这里,恭喜你,你已经完成了第 75 章"半探索、多标签与高斯过程的进阶秘境"的学习旅程。从半监督学习的涟漪扩散到高斯过程的水晶球占卜,从协方差矩阵的鲁棒整形到多任务策略的集团军作战,从 GMM 的自适应拼图到异构特征的装配线,这一章为你揭开了 scikit-learn 高级主题的全貌。希望这些知识能让你在面对现实世界复杂问题时,多一份从容与底气。
第 77 章 —— 基准测试基础设施 —— 构建你的"性能裁判所"
77.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 ASV 基准测试框架的整体架构与设计哲学(约定优于配置、组合优于继承)
-
掌握 Benchmark 抽象基类如何通过 config.json 与环境变量实现多档位(fast/regular/large_scale)运行时调度
-
深入 Estimator 基类的 setup_cache/setup 双阶段机制,理解估计器序列化与缓存复用策略
-
理解 Predictor 与 Transformer 的条件式混入(conditional mixin)模式如何避免无效基准
-
掌握 track_same_prediction / track_same_transform 的跨版本回归检测原理
-
能阅读并基于 Benchmark 体系编写自定义基准测试类
77.2 生活类比
想象 ASV 基准测试套件是一座机器学习界的「性能法庭」。Benchmark 基类如同法庭的总章程,它规定了审判流程、计时规则与裁判准则,确保所有案件按统一标准审理。ASV_COMMIT 短哈希则像是案件卷宗的档案编号,法庭根据当前提交的版本号(取前 8 位哈希字符)为每一次庭审创建独立的证据保管室,让不同版本的案件物证互不混淆,避免旧版模型的状态污染新版的检测结论。Estimator 基类则扮演陪审团训练官的角色,负责安排数据出庭(make_data)、陪审员就位(make_estimator),并预先培养陪审员的判断能力(setup_cache),把训练好的模型冷冻保存到档案馆。Predictor 与 Transformer 基类专注于庭审现场,只关心陪审员出庭后的发言速度(time_predict)与判断稳定性(track_same_prediction),不对训练过程指手画脚。
setup_cache 与 pickle 的组合构成了陪审员集训档案馆:把训练好的陪审员(拟合好的估计器)序列化冷冻保存,下次庭审直接解冻上场,避免重复训练浪费时间。这就像法官提前阅读案件卷宗,开庭时直接进入辩论环节。条件式混入(if Benchmark.bench_predict)实现了案件分流机制:民商事案件走速裁庭(预测基准),刑事案件走专业庭(变换基准),不同类型的案件分配给不同的专业法官,互不干扰。track_same_prediction 则如同判例对比库,把新法官的判决与历史判例比对,确保不会偏离先例,从而实现跨版本回归检测。
get_from_config 是法庭调度中心,根据案件复杂度(profile = fast/regular/large_scale)安排法庭规模、庭审次数与休庭时长(warmup_time/repeat/number)。环境变量(SKLBENCH_*)则相当于院长临时批示,在不修改总章程的前提下,临时调整庭审安排,应对突发状况。整个套件的入口处挂着「asv_benchmarks/benchmarks/init.py」这块不起眼的门牌,它没有华丽装潢,仅以一行文档字符串「Benchmark suite for scikit-learn using ASV」昭示着这座法庭的管辖范围——所有 scikit-learn 的性能争议都将在这里接受审判。
就像法庭追求公正与效率的平衡,ASV 基准测试追求性能可复现与跨版本一致性的双重保障,确保 scikit-learn 的每次优化都有数据支撑,每次回归都能被及时发现。
77.3 源码地图
asv_benchmarks/benchmarks/common.py
├── get_from_config() # 配置中枢(8-47行)
│ ├── 读取 config.json 并剥离注释
│ ├── 解析环境变量 SKLBENCH_PROFILE / NJOBS / SAVE_ESTIMATORS / PREDICT / TRANSFORM / BASE_COMMIT
│ ├── 创建 cache/estimators/ 与 cache/tmp/ 目录
│ └── 返回 7 元组 (profile, n_jobs_vals, save_estimators, save_dir, base_commit, bench_predict, bench_transform)
├── get_estimator_path() # 序列化路径构造(50-63行)
│ ├── 命名规则:<BenchmarkClass>_estimator_<param1>_<param2>....pkl
│ ├── save=True → cache/estimators/<commit>/
│ └── save=False → cache/tmp/
├── clear_tmp() # 临时目录清理(66-69行)
├── Benchmark (ABC) # 所有基准的顶层抽象基类
│ ├── 类属性 timer/processes/timeout
│ ├── 类属性由 get_from_config() 解包注入(80-88行)
│ ├── if-elif profile 分支决定 warmup_time/repeat/number/min_run_count/data_size
│ └── @property @abstractmethod params # 强制子类声明参数网格
├── Estimator (ABC) # 训练性能评测基类
│ ├── @abstractmethod make_data(params) # 数据准备(93-95行)
│ ├── @abstractmethod make_estimator(params) # 模型构造(98-100行)
│ ├── skip(params) # 参数组合跳过钩子(103-105行)
│ ├── setup_cache() # 类级一次性缓存(108-128行)
│ │ ├── clear_tmp() 清空临时目录
│ │ ├── itertools.product 遍历全量参数网格
│ │ ├── estimator.fit(X, y) 拟合全部估计器
│ │ └── pickle.dump 序列化到磁盘
│ ├── setup(*params) # 每组合/每 repeat 前加载(131-148行)
│ │ ├── self.X/X_val/y/y_val = make_data(params)
│ │ ├── pickle.load 加载预拟合估计器
│ │ └── self.make_scorers()
│ ├── time_fit(*args) # 训练耗时(151-152行)
│ ├── peakmem_fit(*args) # 训练峰值内存(155-156行)
│ ├── track_train_score(*args) # 训练集评分(159-165行)
│ └── track_test_score(*args) # 验证集评分(168-174行)
├── Predictor (ABC) # 推理性能评测基类
│ ├── if Benchmark.bench_predict: # 条件式注入
│ │ ├── time_predict(*args) # 预测耗时(181-182行)
│ │ └── peakmem_predict(*args) # 预测峰值内存(185-186行)
│ ├── if Benchmark.base_commit is not None:
│ │ └── track_same_prediction(*args) # 跨版本回归检测(189-199行)
│ └── @property @abstractmethod params # 抽象参数网格(202行)
└── Transformer (ABC) # 变换性能评测基类
├── if Benchmark.bench_transform:
│ ├── time_transform(*args) # 变换耗时(208-209行)
│ └── peakmem_transform(*args) # 变换峰值内存(212-213行)
├── if Benchmark.base_commit is not None:
│ └── track_same_transform(*args) # 跨版本回归检测(216-226行)
└── @property @abstractmethod params # 抽象参数网格(229行)
asv_benchmarks/benchmarks/__init__.py
└── 模块级文档字符串 """Benchmark suite for scikit-learn using ASV""" # 包入口标识(1-1行)
77.4 源码解析单元
77.4.1 配置与缓存工具 —— 基准测试的"后勤保障"
基准测试框架的后勤系统由三个关键函数构成:get_from_config() 负责从配置文件和环境中收集运行时参数,get_estimator_path() 构造序列化的估计器存储路径,clear_tmp() 清理临时产物保证基准运行的幂等性。这三者构成了一个闭环系统:配置中枢读取外部设定并准备缓存目录,路径构造函数为每个估计器分配唯一的磁盘地址,临时目录清理器则在每次基准运行前后重置 tmp 区域。它们的协同工作贯穿基准测试的整个生命周期——从 setup_cache 的批量预拟合、setup 的反序列化加载,到 run 结束后的状态清理——为整个基准测试套件提供了稳定、可配置的运行环境。下图展示了它们之间的调用关系与数据流向:
77.4.1.1 配置中枢:get_from_config()
源码路径:asv_benchmarks/benchmarks/common.py - get_from_config()(8-47行)
def get_from_config():
"""Get benchmarks configuration from the config.json file"""
# ① 解析配置文件路径(使用绝对路径避免工作目录影响)
current_path = Path(__file__).resolve().parent
config_path = current_path / "config.json"
# ② 读取配置并剥离注释(JSON 不支持注释,手动过滤)
with open(config_path, "r") as config_file:
config_file_content = "".join(
line for line in config_file if line and "//" not in line
)
config = json.loads(config_file_content)
# ③ 解析 profile:环境变量优先,默认值兜底
profile = os.getenv("SKLBENCH_PROFILE", config["profile"])
# ④ 解析 n_jobs:支持 JSON 列表格式的环境变量覆盖
n_jobs_vals_env = os.getenv("SKLBENCH_NJOBS")
if n_jobs_vals_env:
n_jobs_vals = json.loads(n_jobs_vals_env) # 用户可传入 [1, 4, -1]
else:
n_jobs_vals = config["n_jobs_vals"]
if not n_jobs_vals: # 空列表时自动填充为 [1, 2, ..., cpu_count()]
n_jobs_vals = list(range(1, 1 + cpu_count()))
# ⑤ 创建缓存目录结构
cache_path = current_path / "cache"
cache_path.mkdir(exist_ok=True)
(cache_path / "estimators").mkdir(exist_ok=True) # 永久存储
(cache_path / "tmp").mkdir(exist_ok=True) # 临时存储
# ⑥ 解析保存策略与 commit 标识(取前 8 位短哈希作为子目录名)
save_estimators = os.getenv("SKLBENCH_SAVE_ESTIMATORS", config["save_estimators"])
save_dir = os.getenv("ASV_COMMIT", "new")[:8] # 短哈希隔离不同提交
# ⑦ 为永久模式创建 commit 隔离目录
if save_estimators:
(cache_path / "estimators" / save_dir).mkdir(exist_ok=True)
# ⑧ 解析回归检测基准提交
base_commit = os.getenv("SKLBENCH_BASE_COMMIT", config["base_commit"])
# ⑨ 解析预测/变换基准开关
bench_predict = os.getenv("SKLBENCH_PREDICT", config["bench_predict"])
bench_transform = os.getenv("SKLBENCH_TRANSFORM", config["bench_transform"])
# ⑩ 返回 7 元组供 Benchmark 类解包
return (
profile, # "fast" | "regular" | "large_scale"
n_jobs_vals, # [1, 2, 4, ...] 或 []
save_estimators, # True | False
save_dir, # "a1b2c3d4" | "new"
base_commit, # commit hash | None
bench_predict, # True | False
bench_transform, # True | False
)
为什么 JSON 配置要手动剥离注释?JSON 标准不支持注释,但许多开发者习惯在配置文件中添加 // 开头的说明文字。scikit-learn 的解决方案是用生成器表达式逐行过滤包含 // 的行,再交给 json.loads() 解析。这种"读取-过滤-解析"模式在配置文件需要人工友好注释时非常实用。整个配置中枢体现了"环境变量覆盖配置文件"的灵活性:每次调用都会重新读取,让用户在不改代码的前提下临时调整运行策略;而 7 元组的返回形式则为 Benchmark 类体内部的元组解包提供了恰好匹配的形状。
77.4.1.2 序列化路径构造:get_estimator_path()
源码路径:asv_benchmarks/benchmarks/common.py - get_estimator_path()(50-63行)
def get_estimator_path(benchmark, directory, params, save=False):
"""Get path of pickled fitted estimator"""
# ① 构造基础路径(基于源码绝对路径,避免 cwd 影响)
path = Path(__file__).resolve().parent / "cache"
# ② 根据 save 标志选择子目录
if save:
path = path / "estimators" / directory # 永久存储:cache/estimators/<commit>/
else:
path = path / "tmp" # 临时存储:cache/tmp/
# ③ 构造文件名:<类名>_estimator_<参数1>_<参数2>... .pkl
filename = (
benchmark.__class__.__name__ # 如 "KMeansBenchmark"
+ "_estimator_"
+ "_".join(list(map(str, params))) # 如 "3_lloyd"
+ ".pkl"
)
return path / filename # 返回完整路径对象
save=True 与 save=False 的区别是什么?save=True 时路径指向 cache/estimators/Path(__file__).resolve(),这种设计使得无论用户在哪个工作目录下启动 ASV,序列化文件都会被一致地写入基准套件内的 cache 目录,避免相对路径在不同 cwd 下指向不同位置的风险。
77.4.1.3 临时目录清理:clear_tmp()
源码路径:asv_benchmarks/benchmarks/common.py - clear_tmp()(66-69行)
def clear_tmp():
"""Clean the tmp directory"""
# ① 使用绝对路径定位 tmp 目录,避免受 cwd 影响
path = Path(__file__).resolve().parent / "cache" / "tmp"
# ② 遍历并逐一删除所有子文件
for child in path.iterdir():
child.unlink() # 删除所有子文件
这段代码在 setup_cache() 运行前被调用,确保每次基准运行都从干净状态开始。如果跳过这一步,残留的 .pkl 文件可能导致加载错误的估计器版本。此外,第一行使用 resolve() 强制使用绝对路径,这与 get_estimator_path 的构造策略保持一致——所有基准相关文件都严格限定在 asv_benchmarks/benchmarks/cache/ 之下,不受终端当前工作目录的影响。
77.4.2 Benchmark 抽象基类 —— 所有基准测试的"宪法"
Benchmark 是整个 ASV 基准测试框架的总章程。它统一管理 ASV 计时引擎的配置参数,通过解包 get_from_config() 的返回值,定义了所有子类共享的类属性。子类只需声明 params 抽象属性,ASV 便会自动生成参数组合矩阵。下面的架构图展示了 Benchmark 类的内部结构与配置流程:
下表汇总了三个档位在关键计时参数上的差异,便于读者在选择 profile 时快速定位适用场景:
| 档位 | warmup_time | repeat | number | min_run_count | data_size | 适用场景 |
|------|-------------|--------|--------|---------------|-----------|----------|
| fast | 0 | 1 | 1 | 1 | small | 快速迭代验证,CI 环境 |
| regular | 1 | (3, 100, 30) | 自动 | 默认 | small | 常规性能评测,PR 检查 |
| large_scale | 1 | 3 | 1 | 默认 | large | 大规模数据,深度分析 |
源码路径:asv_benchmarks/benchmarks/common.py - Benchmark(72-91行)
class Benchmark(ABC):
"""Abstract base class for all the benchmarks"""
timer = timeit.default_timer # ① 墙钟时间(可替换为 process_time)
processes = 1 # ② 单进程(避免多进程干扰计时)
timeout = 500 # ③ 单用例超时阈值(秒)
# ④ 解包配置元组为类属性(类体级别赋值,80-88行)
(
profile,
n_jobs_vals,
save_estimators,
save_dir,
base_commit,
bench_predict,
bench_transform,
) = get_from_config()
# ⑤ 根据 profile 派生 ASV 计时参数(类体级别 if-elif)
if profile == "fast":
warmup_time = 0 # 无预热
repeat = 1 # 单次运行
number = 1 # 无重复
min_run_count = 1
data_size = "small"
elif profile == "regular":
warmup_time = 1 # 1秒预热
repeat = (3, 100, 30) # 3次最小,100次最大,30秒超时
data_size = "small"
elif profile == "large_scale":
warmup_time = 1
repeat = 3 # 固定3次
number = 1
data_size = "large"
# ⑥ 抽象属性:强制子类声明参数网格
@property
@abstractmethod
def params(self):
pass
为什么要在类体内部使用 if-elif 分支?传统的做法是在 init 中设置实例属性。但 ASV 框架直接读取类属性来配置计时引擎,类体级别的赋值确保所有子类实例共享相同的配置。这种设计体现了"约定优于配置":子类无需重写 init,只需声明 params 即可。
77.4.2.1 抽象契约:params 属性
源码路径:asv_benchmarks/benchmarks/common.py - Benchmark.params(88-91行)
@property
@abstractmethod
def params(self):
pass
为什么 params 必须声明为抽象属性?params 是 ASV 生成参数组合矩阵的唯一入口。子类必须返回二维列表(如 [[3, 8, 32], ['lloyd', 'elkan']]),ASV 通过 itertools.product 自动展开为所有笛卡尔积组合。若子类未实现该属性,Python 在实例化时会立即抛出 TypeError,从而在开发阶段就暴露未完成的基准类。
77.4.3 Estimator 基类 —— 训练性能评测的"心脏"
Estimator 抽象基类定义了训练性能评测的完整生命周期。它强制子类实现 make_data 与 make_estimator 两个契约方法,通过 setup_cache 与 setup 的双阶段机制,实现了"训练一次,计时多次"的效率优化。其双阶段执行流程如下:
77.4.3.1 数据与模型契约:make_data / make_estimator / skip
源码路径:asv_benchmarks/benchmarks/common.py - Estimator.make_data / Estimator.make_estimator / Estimator.skip(93-105行)
class Estimator(ABC):
"""Abstract base class for all benchmarks of estimators"""
@abstractmethod
def make_data(self, params):
"""Return the dataset for a combination of parameters"""
# 数据集使用 joblib.Memory 缓存,因此每次调用都很快
# 子类应返回 (X_train, X_val, y_train, y_val) 四元组
pass
@abstractmethod
def make_estimator(self, params):
"""Return an instance of the estimator for a combination of parameters"""
# 仅构造估计器实例,不执行 fit
pass
def skip(self, params):
"""Return True if the benchmark should be skipped for these params"""
# 默认不跳过,子类可覆盖以排除非法参数组合
return False
这段契约代码以简洁的抽象方法勾勒出 Estimator 子类必须履行的职责:make_data 负责数据准备(返回 (X_train, X_val, y_train, y_val) 四元组,并依赖 joblib.Memory 实现透明缓存),make_estimator 仅负责构造未拟合的估计器实例,而 skip 则提供一个声明式钩子让子类排除非法参数组合。三个方法共同构成了一个最小契约面:子类必须实现前两者,必要时重写第三者;基类则在此之上构建 setup_cache、setup 与各种计时与评分方法。
77.4.3.2 类级缓存机制:setup_cache
源码路径:asv_benchmarks/benchmarks/common.py - Estimator.setup_cache(108-128行)
def setup_cache(self):
"""Pickle a fitted estimator for all combinations of parameters"""
# 此方法在每个基准类上只运行一次
clear_tmp() # ① 清空临时目录
param_grid = list(itertools.product(*self.params)) # ② 生成全量参数网格
for params in param_grid:
if self.skip(params): # ③ 跳过非法组合
continue
# ④ 构造并拟合估计器:先 make_estimator 再 make_data
# 先构造模型是为了在 skip 判断后尽早暴露非法估计器构造错误
# 后加载数据是因为 make_data 依赖 params,与拟合顺序无副作用耦合
estimator = self.make_estimator(params)
X, _, y, _ = self.make_data(params)
estimator.fit(X, y)
# ⑤ 序列化到磁盘(save_estimators 决定永久/临时目录)
est_path = get_estimator_path(
self, Benchmark.save_dir, params, Benchmark.save_estimators
)
with est_path.open(mode="wb") as f:
pickle.dump(estimator, f)
setup_cache 的设计哲学是什么?它将"拟合所有参数组合的估计器"这一一次性开销与"计时测量"分离。ASV 运行基准测试时,只需从磁盘加载预拟合的估计器,避免每次 repeat 都重新训练。这对于训练耗时的算法(如 GBDT、深度模型)尤为重要。
77.4.3.3 每轮计时上下文:setup
源码路径:asv_benchmarks/benchmarks/common.py - Estimator.setup(131-148行)
def setup(self, *params):
"""Generate dataset and load the fitted estimator"""
# 此方法在每个参数组合和每次 repeat 前运行
if self.skip(params): # ① 再次检查跳过条件
raise NotImplementedError
# ② 加载数据
self.X, self.X_val, self.y, self.y_val = self.make_data(params)
# ③ 从磁盘反序列化预拟合估计器
est_path = get_estimator_path(
self, Benchmark.save_dir, params, Benchmark.save_estimators
)
with est_path.open(mode="rb") as f:
self.estimator = pickle.load(f)
# ④ 构建评分器(由子类实现)
self.make_scorers()
为什么 setup_cache 和 setup 都要检查 skip?setup_cache 在类级别遍历所有参数组合;setup 在每个参数组合运行前再次检查。这提供了双重保障,确保非法组合在任何阶段都不会被执行。
77.4.3.4 四大标准度量
源码路径:asv_benchmarks/benchmarks/common.py - Estimator.time_fit / Estimator.peakmem_fit / Estimator.track_train_score / Estimator.track_test_score(151-174行)
def time_fit(self, *args):
"""训练耗时:墙钟时间(被 ASV time 跟踪器包裹)"""
self.estimator.fit(self.X, self.y)
def peakmem_fit(self, *args):
"""训练峰值内存:独立进程内存追踪(被 ASV 内存跟踪器包裹)"""
self.estimator.fit(self.X, self.y)
def track_train_score(self, *args):
"""训练集评分回归检测:确保新版本在训练集上输出一致"""
if hasattr(self.estimator, "predict"):
y_pred = self.estimator.predict(self.X)
else:
y_pred = None
return float(self.train_scorer(self.y, y_pred))
def track_test_score(self, *args):
"""验证集评分回归检测:衡量泛化能力的稳定性"""
if hasattr(self.estimator, "predict"):
y_val_pred = self.estimator.predict(self.X_val)
else:
y_val_pred = None
return float(self.test_scorer(self.y_val, y_val_pred))
为什么 time_fit 和 peakmem_fit 函数体相同?ASV 框架通过不同的追踪器(tracker)区分度量类型:墙钟时间追踪器测量 time_fit 的执行时间,内存追踪器测量 peakmem_fit 的峰值内存。两者调用相同的 fit 方法,但 ASV 在底层使用不同的监控机制——time 系直接调用函数体并用 perf_counter 计时,peakmem 系则 fork 出一个独立子进程并在子进程中通过 psutil 或 resource 模块周期性采样 RSS 峰值。track_train_score 与 track_test_score 的设计意图则截然不同:前者监测模型对训练数据的拟合能力是否保持稳定(防止欠拟合回归),后者监测泛化能力是否保持稳定(防止过拟合回归);二者共同覆盖了 scikit-learn 估计器的功能正确性维度。需要特别注意的是,Estimator 同样依赖 self.params 属性驱动参数网格的展开。在多重继承场景下(如 class KMeansBenchmark(Estimator, Predictor)),params 由 Estimator 与 Predictor 共同声明,子类只需实现一次即可同时满足两侧的抽象契约。
77.4.4 Predictor 与 Transformer —— 预测/变换性能的"双翼"
Predictor 和 Transformer 是两个条件式混入类,它们只在 Benchmark.bench_predict 或 Benchmark.bench_transform 为真时才会向子类注入方法。这种设计避免了向不支持 predict 或 transform 的估计器(如聚类算法)生成无效基准。下图展示了条件式混入的工作机制:
77.4.4.1 Predictor 条件式混入
源码路径:asv_benchmarks/benchmarks/common.py - Predictor(177-202行)
class Predictor(ABC):
"""Abstract base class for benchmarks of estimators implementing predict"""
if Benchmark.bench_predict: # ① 条件式注入
def time_predict(self, *args):
"""预测耗时:墙钟时间"""
self.estimator.predict(self.X)
def peakmem_predict(self, *args):
"""预测峰值内存"""
self.estimator.predict(self.X)
if Benchmark.base_commit is not None: # ② 回归检测条件
def track_same_prediction(self, *args):
"""跨版本预测一致性检测"""
# ① 加载基准提交版本估计器
est_path = get_estimator_path(self, Benchmark.base_commit, args, True)
with est_path.open(mode="rb") as f:
estimator_base = pickle.load(f)
# ② 在验证集上对比预测结果
y_val_pred_base = estimator_base.predict(self.X_val)
y_val_pred = self.estimator.predict(self.X_val)
# ③ 使用 np.allclose 判断数值一致性
return np.allclose(y_val_pred_base, y_val_pred)
@property
@abstractmethod
def params(self):
pass
Predictor.params 抽象属性位于第 202 行,作用与 Benchmark.params 一致:强制具体基准类声明参数网格,使 ASV 能为 time_predict、peakmem_predict、track_same_prediction 等方法自动生成参数组合矩阵。这一行抽象属性与基类 Benchmark 的 params 形成呼应:尽管 Predictor 通过条件式注入灵活控制方法的存在性,但参数网格声明这一契约始终不可绕过——子类必须明确告知 ASV「我要测试哪些参数组合」,否则 ASV 无法生成任何一条 time_predict 用例。
77.4.4.2 Transformer 条件式混入
源码路径:asv_benchmarks/benchmarks/common.py - Transformer(205-229行)
class Transformer(ABC):
"""Abstract base class for benchmarks of estimators implementing transform"""
if Benchmark.bench_transform: # ① 条件式注入
def time_transform(self, *args):
"""变换耗时:墙钟时间"""
self.estimator.transform(self.X)
def peakmem_transform(self, *args):
"""变换峰值内存"""
self.estimator.transform(self.X)
if Benchmark.base_commit is not None: # ② 回归检测条件
def track_same_transform(self, *args):
"""跨版本变换一致性检测"""
est_path = get_estimator_path(self, Benchmark.base_commit, args, True)
with est_path.open(mode="rb") as f:
estimator_base = pickle.load(f)
X_val_t_base = estimator_base.transform(self.X_val)
X_val_t = self.estimator.transform(self.X_val)
return np.allclose(X_val_t_base, X_val_t)
@property
@abstractmethod
def params(self):
pass
Transformer.params 抽象属性位于第 229 行,与 Predictor.params 共同构成了"无论混入哪些方法,参数网格声明都是基线契约"的设计原则。它的存在保证了当用户启用 bench_transform 时,ASV 能立即为新注入的 time_transform、peakmem_transform、track_same_transform 派生出对应的参数化用例,而不是空跑。
为什么使用 np.allclose 而不是 array_equal?浮点数计算存在精度误差,np.array_equal 对微小差异过于严格。np.allclose(a, b) 默认容差为 rtol=1e-05, atol=1e-08,能够容忍舍入误差同时仍能检测实质性的回归。
77.4.5 基准模块初始化 —— 套件的"名片"与"入口"
源码路径:asv_benchmarks/benchmarks/__init__.py(1-1行)
"""Benchmark suite for scikit-learn using ASV"""
为什么包入口只有一行文档字符串?ASV 通过自动发现机制检测 Benchmark 的子类,无需显式注册。这个文档字符串作为包级标识,明确告知使用者这是 scikit-learn 的 ASV 基准测试套件。下图展示了 ASV 自动发现机制与包入口的关系:
从架构图可见,ASV 不依赖 __init__.py 的任何注册逻辑,它只通过 import 副作用触发 Python 模块加载机制,让所有继承自 Benchmark 的类自动进入 ASV 的运行时清单。__init__.py 的唯一职责便是以文档字符串形式宣告「这片命名空间属于 ASV 基准测试」,作为对人类读者的指引,而非对 ASV 的程序化输入。
77.5 设计中的取舍
为什么不用普通继承而用条件式混入?如果 Predictor 的 time_predict 是常规方法,那么对聚类算法(如 KMeans)的基准测试也会拥有这个方法,虽然它没有意义。条件式混入通过 if Benchmark.bench_predict: 在类体内部动态绑定方法,确保只有在 config.json 或环境变量中启用预测基准时,才会向子类注入 time_predict。这避免了无效基准的生成,同时保持了代码的简洁性。
为什么 Estimator 和 Predictor/Transformer 分属不同的类?这体现了"组合优于继承"的设计哲学。Estimator 负责训练生命周期,Predictor/Transformer 仅关注推理/变换。具体基准类通过多重继承按需组装,例如 KMeansBenchmark(Estimator, Predictor) 用于 KMeans 训练 + 预测基准,PCAFeatureBenchmark(Estimator, Transformer) 用于 PCA 训练 + 变换基准,ClusterOnlyBenchmark(Estimator) 则用于纯训练基准(无预测)。这种设计允许灵活组合,避免了单一继承树带来的"类型爆炸"。
为什么选择 setup_cache + pickle 序列化方案而非其他缓存机制?pickle 是 Python 原生序列化方案,能完整保留估计器内部状态(包括 NumPy 数组、scipy 稀疏矩阵乃至 fitted attributes),无需估计器作者额外实现自定义序列化接口;与之相比,joblib.Memory 虽然适合数据集这种函数输入输出可哈希的对象,但对于估计器这种状态不可哈希、依赖类实例 dict 的对象就显得力不从心;而不缓存(每次 setup 都重新 fit)则会让训练耗时主导基准测量,淹没推理/预测的真实成本。pickle 方案的代价是文件体积较大且对类版本敏感,但 ASV 用 ASV_COMMIT 短哈希子目录隔离了不同提交的缓存,恰好抵消了版本敏感的缺陷。
为什么 if-elif 在类体级别赋值而非 init?两种方案在功能上等价,但语义截然不同:类体级别赋值是「类级别的全局配置」,在类定义时执行一次,所有实例共享同一份计时参数,ASV 引擎无需实例化即可读取;init 赋值则是「实例级别的局部配置」,需要在每次实例化时执行,且 ASV 必须先创建实例才能获取配置。考虑到 ASV 直接以类属性方式读取计时参数,类体赋值是唯一能「零成本」满足 ASV 契约的方案,而 init 方案则会引入不必要的实例化开销并破坏 ASV 的探测协议。
77.6 动手练习
- 阅读 Benchmark 基类的档位调度逻辑
阅读 asv_benchmarks/benchmarks/common.py 第 8-47 行(get_from_config)与第 73-91 行(Benchmark 类体),理解以下机制:
-
profile = os.getenv('SKLBENCH_PROFILE', config['profile'])这一行的双重作用是什么?为什么采用「环境变量优先,默认值兜底」的模式? -
n_jobs_vals在环境变量为空、配置为空、配置为非空三种情况下的默认值分别是什么? -
为什么要在类体内部使用
if-elif根据profile派生warmup_time/repeat/number?这种设计与在__init__中设置有何本质区别?
回答问题:
-
当用户在终端执行
SKLBENCH_PROFILE=large_scale asv run时,哪些 Benchmark 类属性会被覆盖? -
如果 config.json 中
base_commit设置为某个历史 commit 哈希,会对Predictor类产生什么副作用?
- 追踪 Estimator 的双阶段执行流程
阅读 asv_benchmarks/benchmarks/common.py 第 88-174 行(Estimator 完整定义),理解以下机制:
-
setup_cache()与setup()各自在什么时机被 ASV 调用?为什么需要这两个分离的阶段? -
setup_cache中调用clear_tmp()的目的是什么?如果去掉这一步会有什么风险? -
make_data(params)与make_estimator(params)都被声明为@abstractmethod,但time_fit不是——这种设计体现了什么 SOLID 原则? -
time_fit与peakmem_fit的函数体几乎相同(都是self.estimator.fit(self.X, self.y)),ASV 如何区分这两个度量?
回答问题:
-
当
save_estimators=True时,get_estimator_path第三个参数是Benchmark.save_dir,它由什么决定? -
track_train_score中hasattr(self.estimator, 'predict')检查的意义是什么?为什么要区分对待 predict 与 transform 类估计器?
- 剖析 Predictor 的条件式混入与回归检测
阅读 asv_benchmarks/benchmarks/common.py 第 172-229 行(Predictor 与 Transformer 类),理解以下机制:
-
if Benchmark.bench_predict:在类体内部的作用是什么?为什么time_predict等方法不直接定义为常规方法? -
track_same_prediction的工作流程是怎样的?它如何从Benchmark.base_commit目录加载历史估计器? -
为什么回归检测使用
np.allclose而不是np.array_equal?这与浮点数计算有什么关系? -
如果一个子类同时继承
Estimator与Predictor(class MyBench(Estimator, Predictor)),方法解析顺序(MRO)会是怎样的?
回答问题:
-
当
Benchmark.base_commit=None时,track_same_prediction是否存在?为什么? -
如果 sklearn 新版本修改了某个估计器的内部状态导致
predict输出在数值上有微小差异,track_same_prediction会触发警报吗?默认容差是多少?
- 设计自定义基准测试类
基于本章学到的模式,尝试编写一个针对 KMeans(n_clusters=k, algorithm=algo) 的基准测试类骨架:
-
定义继承
Estimator与Predictor的KMeansBenchmark类 -
声明
params属性,包含n_clusters ∈ {3, 8, 32}与algorithm ∈ {'lloyd', 'elkan'} -
实现
make_data(params)使用make_blobs生成样本 -
实现
make_estimator(params)构造 KMeans 实例 -
实现
make_scorers()为train_scorer/test_scorer赋值(使用neg_mean_inertia或自定义轮廓系数)
回答问题:
-
你的
params属性返回什么数据结构?ASV 如何解析它? -
当
algorithm='elkan'但数据是稀疏矩阵时,应如何编写skip(params)钩子? -
如果想同时追踪
inertia与silhouette_score,应如何扩展基类(提示:可参考track_*模式自行添加)?
77.7 本章小结
这一章中我们学习了 scikit-learn ASV 基准测试框架的核心设计。我们首先了解了 Benchmark 作为"总章程"如何统一管理配置参数与计时档位;随后学习了配置中枢 get_from_config() 如何解析 config.json 与环境变量并返回 7 元组运行时参数;接着深入 Estimator 的双阶段缓存机制,认识到 setup_cache 与 setup 协作实现"训练一次,计时多次"的效率优化;然后掌握了 Predictor 与 Transformer 的条件式混入模式,理解了它如何避免无效基准的生成;最后我们认识了 track_same_prediction 与 track_same_transform 是如何借助 np.allclose 实现跨版本的功能回归检测。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| Benchmark (ABC) | 所有基准测试的顶层抽象基类,统一管理 ASV 计时参数与配置 |
| get_from_config() | 配置中枢:解析 config.json 与环境变量,返回 7 元组运行时参数 |
| get_estimator_path() | 构造序列化估计器的标准磁盘路径,区分 save 与 tmp 两种模式 |
| clear_tmp() | 清理 cache/tmp 临时目录,保障基准运行的幂等性 |
| profile (fast/regular/large_scale) | ASV 计时档位,决定 warmup_time/repeat/number/data_size 等关键参数 |
| Estimator (ABC) | 训练性能评测基类,强制 make_data/make_estimator 契约,提供 fit 计时与评分追踪 |
| setup_cache() | 类级一次性缓存:遍历参数网格拟合所有估计器并 pickle 到磁盘 |
| setup(*params) | 每参数组合/每 repeat 前加载预拟合估计器,构建计时上下文 |
| time_fit / peakmem_fit | 标准训练性能度量:墙钟耗时与峰值内存追踪 |
| track_train_score / track_test_score | 训练集与验证集评分回归检测,确保功能正确性 |
| Predictor (ABC) | 推理性能评测基类,条件式注入 time_predict/peakmem_predict |
| Transformer (ABC) | 变换性能评测基类,条件式注入 time_transform/peakmem_transform |
| track_same_prediction | 跨版本预测一致性检测:与 base_commit 的输出做 np.allclose 比对 |
| track_same_transform | 跨版本变换一致性检测:与 base_commit 的变换结果做 np.allclose 比对 |
| SKLBENCH_* 环境变量 | 运行时覆盖 profile/n_jobs/save/predict/transform/base_commit 配置 |
| ASV_COMMIT 短哈希 | 用于隔离不同代码提交的估计器缓存目录,防止版本间缓存污染 |
| skip(params) 钩子 | 声明式跳过非法参数组合(如稀疏数据不支持的求解器) |
| params 抽象属性 | 参数网格的声明式入口,驱动 ASV 自动生成参数组合矩阵 |
| 条件式混入模式 | 通过 if Benchmark.xxx: 在类体内动态绑定方法,避免无效基准的生成 |
| asv_benchmarks/benchmarks/__init__.py | ASV 套件的根命名空间标识,仅含文档字符串 |
感谢你读到了这里,恭喜你,你已经完成了 ASV 基准测试基础设施章节的学习。
第 78 章 —— 数据集与评分器 —— 备好"弹药"与"标尺"
78.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解基准测试中数据集工厂的设计模式与统一契约
-
掌握 joblib.Memory 缓存机制在跨运行复用昂贵数据预处理中的应用
-
熟悉真实世界数据集(20Newsgroups、MNIST、Olivetti Faces)的标准化预处理流水线
-
掌握合成数据生成中分布保真度、稀疏结构构造与参数化设计的工程细节
-
理解评分函数集的依赖注入模式与闭包捕获上下文的函数式编程技巧
-
掌握针对聚类、分类、回归、字典学习、PCA 等不同任务的度量学适配策略
-
了解数值稳定性考量(如方差比计算避免协方差矩阵)在基准测试中的工程实践
78.2 生活类比
想象基准测试是一场标准化的"奥林匹克运动会"。作为组织方,你需要为不同比赛项目(算法)准备统一的器材、统一的规则和专业的裁判,三者缺一不可。数据集工厂(datasets.py)扮演的角色就是"统一配发的标准器材库"——合成数据如同精密制造的标准哑铃、标准跑道,每个参数(重量、长度、弹性)都被精确控制,让所有运动员(算法)在完全相同的条件下竞技;真实世界数据则如同从真实赛场收集来的器材,可能沾染灰尘、形状各异,但通过 TF-IDF 向量化、归一化缩放、双重中心化等"打磨工序",变得标准化、可测量。更妙的是,joblib.Memory 缓存机制如同"器材存放仓库",一旦制造或整理完毕就会被锁进仓库,后续比赛直接取用,避免了"每次开赛都要重新制造哑铃"的低效。统一返回契约 (X, X_val, y, y_val) 则是"统一的比赛规则手册",所有项目——无论是短跑(聚类)、射击(分类)还是体操(回归)——都必须遵守相同的入场标准:四件套缺一不可,10% 留出验证集,无标签任务用 None 占位。这份手册让裁判组(基准框架)可以用同一套流程调度所有比赛,无需为每项运动重新编写规则。评分函数集(utils.py)则是"裁判评分规则手册"——聚类惯性是体操"完成度打分"(动作是否归位到正确位置),Balanced Accuracy 是赛艇"公平计时"(不受水流和风向影响),R² 是射箭"环数占比"(射中多少环相对满分的比例),重构误差是拼图"还原度"(拼回去与原图的差异),方差保留率是压缩"保真度"(信息保留比例)。更精妙的是闭包捕获上下文的设计:裁判(评分器)出厂时就绑定好了自己的测试场地(X_val)和器材(estimator),比赛时无需再传递任何参数,拿起就判分。正如奥运会需要统一器材、统一规则、专业裁判,基准测试框架通过数据集工厂与评分函数集,为算法"运动员"搭建了公平、可复现、工程级的竞技场。
78.3 源码地图
asv_benchmarks/benchmarks/datasets.py
├── _blobs_dataset() # 高斯团簇合成数据(聚类基准)
├── _20newsgroups_highdim_dataset() # TF-IDF 高维稀疏文本向量化
├── _20newsgroups_lowdim_dataset() # TF-IDF + TruncatedSVD 低维稠密文本
├── _mnist_dataset() # MNIST 手写数字:OpenML 获取 + MaxAbsScaler
├── _digits_dataset() # sklearn 内置 digits 数据集 + 归一化
├── _synth_regression_dataset() # 稠密回归数据:make_regression + StandardScaler
├── _synth_regression_sparse_dataset() # 稀疏回归数据:CSR 矩阵手工构造 + 稀疏系数
├── _synth_classification_dataset() # 稠密分类数据:make_classification 全信息特征
├── _olivetti_faces_dataset() # 人脸数据:双重中心化(全局+局部)
├── _random_dataset() # 纯随机稠密/稀疏数据(基线对照)
asv_benchmarks/benchmarks/utils.py
├── neg_mean_inertia() # 聚类惯性负值(ASV 越大越好约定)
├── make_gen_classif_scorers() # 分类通用评分器:balanced_accuracy_score
├── make_gen_reg_scorers() # 回归通用评分器:r2_score
├── neg_mean_data_error() # 字典学习重构误差 RMSE 负值
├── make_dict_learning_scorers() # 字典学习评分器:闭包捕获 X/X_val/estimator
├── explained_variance_ratio() # 方差保留率:流式友好的 var 求和实现
├── make_pca_scorers() # PCA 双轨评分:训练看 explained_variance_ratio_,验证看显式投影方差
78.4 数据集工厂 —— 从合成到真实世界的"数据调色板"
基准测试的可信度首先取决于数据的标准化程度。datasets.py 文件设计了 10 个数据工厂函数,每个都遵循同一套契约:返回 (X, X_val, y, y_val) 四元组,并使用 joblib.Memory 磁盘缓存实现跨进程的数据复用。数据工厂的整体处理流程遵循"生成→类型转换→预处理→切分→缓存"的统一链路:无论是合成数据(Blobs、make_regression)还是真实数据(20NG、MNIST),都先经过 astype(dtype) 统一精度,再叠加归一化(StandardScaler/MaxAbsScaler)或降维(TruncatedSVD),最后由 train_test_split 以固定 90/10 比例切分出训练集与验证集,并通过 @M.cache 装饰器将最终结果落盘;无监督任务用 None 占位标签,有监督任务返回完整四元组。这种"一站式"流水线让所有数据集在交付到基准类时都已经过完整打磨,避免在计时循环内引入额外的 IO 与预处理开销。
78.4.1 缓存基础设施
源码路径:asv_benchmarks/benchmarks/datasets.py - Memory(第13行)
from pathlib import Path # 路径处理
from joblib import Memory # joblib 内存/磁盘缓存工具
# 第 78 章 —— memory location for caching datasets
M = Memory(location=str(Path(__file__).resolve().parent / "cache"))
这段代码建立了基准测试的全局缓存基础设施。核心是 joblib.Memory 实例 M 的创建与位置绑定:Memory 类提供基于磁盘的函数结果缓存能力,所有被 @M.cache 装饰的函数都会把返回值序列化到磁盘,下次调用时只要参数哈希匹配就直接从磁盘读取。location 参数被设置为 Path(__file__).resolve().parent / "cache",其中 __file__ 指向 datasets.py 自身的路径,.resolve() 将其解析为绝对路径消除符号链接歧义,.parent 取其所在目录(即 benchmarks/),最后拼接 cache 子目录——确保无论从哪个工作目录运行脚本,缓存目录始终相对于源码文件,保证缓存路径的可复现性。
78.4.2 Blobs 数据集(聚类基准)
源码路径:asv_benchmarks/benchmarks/datasets.py - _blobs_dataset()(第14-24行)
@M.cache # 磁盘缓存装饰器
def _blobs_dataset(n_samples=500000, n_features=3, n_clusters=100, dtype=np.float32):
X, _ = make_blobs( # ① 调用 make_blobs 生成高斯团簇
n_samples=n_samples, n_features=n_features, centers=n_clusters, random_state=0
)
X = X.astype(dtype, copy=False) # ② 类型转换(默认 float32)
X, X_val = train_test_split(X, test_size=0.1, random_state=0) # ③ 90/10 划分
return X, X_val, None, None # ④ 无标签任务:y/y_val 用 None 占位
这段代码定义了聚类基准专用的高斯团簇合成数据生成器。make_blobs 是 sklearn 的合成团簇生成器,生成各向同性高斯球形簇,是 KMeans 等算法的"完美匹配"——因为 KMeans 的核心假设就是簇呈球形、各向同性分布。默认参数:50 万样本、3 维特征、100 个簇——足以撑起大规模聚类基准。10% 留出验证集(test_size=0.1),无监督任务用 None 占位标签。@M.cache 装饰器确保首次生成后落盘,后续调用直接读取缓存。
78.4.3 20 Newsgroups 文本数据(高维稀疏 vs 低维稠密)
源码路径:asv_benchmarks/benchmarks/datasets.py - _20newsgroups_highdim_dataset()(第26-37行)
@M.cache
def _20newsgroups_highdim_dataset(n_samples=None, ngrams=(1, 1), dtype=np.float32):
newsgroups = fetch_20newsgroups(random_state=0) # ① 下载 20 Newsgroups 语料库
vectorizer = TfidfVectorizer(ngram_range=ngrams, dtype=dtype) # ② TF-IDF 向量化器
X = vectorizer.fit_transform(newsgroups.data[:n_samples]) # ③ 文本→稀疏 TF-IDF 矩阵
y = newsgroups.target[:n_samples] # ④ 对应类别标签
X, X_val, y, y_val = train_test_split(X, y, test_size=0.1, random_state=0) # ⑤ 切分
return X, X_val, y, y_val
这段代码实现了 NLP 基准的高维稀疏文本向量化流水线。fetch_20newsgroups 从 sklearn 内置资源加载新闻组文本(约 18000 篇),TfidfVectorizer 将原始文本转换为稀疏 TF-IDF 矩阵。ngrams=(1, 1) 控制 n 元语法窗口,可调为 (1, 2) 加入二元语法以捕获词组语义。返回的 X 是稀疏矩阵(CSR 格式),维数通常上万,专门考验算法(如 SVM、Logistic Regression)在高维稀疏场景下的性能。
源码路径:asv_benchmarks/benchmarks/datasets.py - _20newsgroups_lowdim_dataset()(第39-52行)
@M.cache
def _20newsgroups_lowdim_dataset(n_components=100, ngrams=(1, 1), dtype=np.float32):
newsgroups = fetch_20newsgroups() # ① 获取全文语料
vectorizer = TfidfVectorizer(ngram_range=ngrams) # ② TF-IDF 向量化
X = vectorizer.fit_transform(newsgroups.data)
X = X.astype(dtype, copy=False) # ③ 转目标精度
svd = TruncatedSVD(n_components=n_components) # ④ 截断 SVD 降维
X = svd.fit_transform(X) # ⑤ 投影到 n_components 维
y = newsgroups.target
X, X_val, y, y_val = train_test_split(X, y, test_size=0.1, random_state=0)
return X, X_val, y, y_val
这段代码构建了 NLP 基准的低维稠密文本降维流水线。在 TF-IDF 稀疏矩阵之上叠加 TruncatedSVD(截断奇异值分解),将数万维稀疏特征压缩到 n_components=100 维稠密向量,模拟"降维后下游任务"的典型场景。两种规格(高维稀疏、低维稠密)让基准类可以在同一语料上对比不同表示下的算法效率。
78.4.4 MNIST 与 Digits(图像分类基准)
源码路径:asv_benchmarks/benchmarks/datasets.py - _mnist_dataset()(第54-63行)
@M.cache
def _mnist_dataset(dtype=np.float32):
X, y = fetch_openml("mnist_784", version=1, return_X_y=True, as_frame=False) # ① OpenML 获取 MNIST
X = X.astype(dtype, copy=False) # ② 转单精度
X = MaxAbsScaler().fit_transform(X) # ③ MaxAbs 缩放到 [0, 1]
X, X_val, y, y_val = train_test_split(X, y, test_size=0.1, random_state=0) # ④ 切分
return X, X_val, y, y_val
这段代码是 MNIST 手写数字的标准化加载流水线。fetch_openml("mnist_784", version=1) 从 OpenML 平台下载经典的 784 维(28×28 像素展平)手写数字数据集。MaxAbsScaler 将像素值缩放到 [0, 1](除以每列的最大绝对值),而非 StandardScaler,是因为图像像素本身就是非负的(0-255 整数),归一化到单位区间 [0, 1] 更符合视觉语义,避免负值像素破坏可解释性。
源码路径:asv_benchmarks/benchmarks/datasets.py - _digits_dataset()(第65-75行)
@M.cache
def _digits_dataset(n_samples=None, dtype=np.float32):
X, y = load_digits(return_X_y=True) # ① sklearn 内置 8x8 数字
X = X.astype(dtype, copy=False)
X = MaxAbsScaler().fit_transform(X) # ② 归一化
X = X[:n_samples] # ③ 子采样
y = y[:n_samples]
X, X_val, y, y_val = train_test_split(X, y, test_size=0.1, random_state=0)
return X, X_val, y, y_val
这段代码是轻量级 8×8 数字数据集的快速加载器。load_digits 是 sklearn 内置的小型数字数据集(1797 个样本,64 维),无需网络下载、规模小,适合作为快速 smoke test。与 MNIST 形成"小/大、快/慢"的对照,基准类可以先用 digits 做功能验证,再用 MNIST 做大规模压力测试。
78.4.5 合成回归数据(稠密与稀疏)
源码路径:asv_benchmarks/benchmarks/datasets.py - _synth_regression_dataset()(第77-89行)
@M.cache
def _synth_regression_dataset(n_samples=100000, n_features=100, dtype=np.float32):
X, y = make_regression( # ① sklearn 合成回归数据
n_samples=n_samples,
n_features=n_features,
n_informative=n_features // 10, # ② 仅 10% 特征有效
noise=50, # ③ 高斯噪声强度
random_state=0,
)
X = X.astype(dtype, copy=False)
X = StandardScaler().fit_transform(X) # ④ 标准化
X, X_val, y, y_val = train_test_split(X, y, test_size=0.1, random_state=0)
return X, X_val, y, y_val
这段代码定义了稠密回归基准数据的标准生成流程。make_regression 生成 y = X @ w + noise 形式的数据,其中只有 n_informative(10%)个权重非零——天然适合测试岭回归、Lasso 等带正则化的方法。noise=50 是相对噪声强度。标准化保证数据零均值单位方差,规避某些求解器对输入尺度的敏感性。
源码路径:asv_benchmarks/benchmarks/datasets.py - _synth_regression_sparse_dataset()(第91-105行)
@M.cache
def _synth_regression_sparse_dataset(
n_samples=10000, n_features=10000, density=0.01, dtype=np.float32
):
X = sp.random( # ① CSR 稀疏矩阵
m=n_samples, n=n_features, density=density, format="csr", random_state=0
)
X.data = np.random.RandomState(0).randn(X.getnnz()) # ② 填充非零位置为高斯随机数
X = X.astype(dtype, copy=False)
coefs = sp.random(m=n_features, n=1, density=0.5, random_state=0) # ③ 稀疏系数向量
coefs.data = np.random.RandomState(0).randn(coefs.getnnz()) # ④ 系数也用高斯分布
y = X.dot(coefs.toarray()).reshape(-1) # ⑤ y = X @ coefs(稠密输出)
y += 0.2 * y.std() * np.random.randn(n_samples) # ⑥ 加 20% 噪声
X, X_val, y, y_val = train_test_split(X, y, test_size=0.1, random_state=0)
return X, X_val, y, y_val
这段代码是手工构造的稀疏回归基准数据。sp.random 生成稀疏矩阵骨架(CSR 格式),随后用 X.data = ... 直接覆写非零值(默认是均匀分布),使其变为高斯分布——这是绕过 scipy 接口限制的小技巧。双重稀疏(X 稀疏 + coefs 稀疏)是关键特征:模拟文本、基因组等真实高维稀疏场景,测试 Lasso、SGD、CoordinateDescent 等稀疏线性模型的性能。y.reshape(-1) 把矩阵乘法输出的二维结果展平为一维目标向量,保证稠密格式便于后续切分与评估。
78.4.6 合成分类数据
源码路径:asv_benchmarks/benchmarks/datasets.py - _synth_classification_dataset()(第107-120行)
@M.cache
def _synth_classification_dataset(
n_samples=1000, n_features=10000, n_classes=2, dtype=np.float32
):
X, y = make_classification( # ① 合成分类数据
n_samples=n_samples,
n_features=n_features,
n_classes=n_classes,
random_state=0,
n_informative=n_features, # ② 全部特征都有效
n_redundant=0, # ③ 无冗余特征
)
X = X.astype(dtype, copy=False)
X = StandardScaler().fit_transform(X) # ④ 标准化
X, X_val, y, y_val = train_test_split(X, y, test_size=0.1, random_state=0)
return X, X_val, y, y_val
这段代码是"硬核"分类基准数据的生成器。n_informative=n_features, n_redundant=0 表示所有特征都是信息特征,没有任何冗余维度——这是对分类器在高维稠密特征下"硬功夫"的考验。默认 1000 样本 × 10000 特征,样本数远小于特征数,模拟小样本高维场景,考验模型的泛化能力。
78.4.7 Olivetti Faces(人脸数据 + 双重中心化)
源码路径:asv_benchmarks/benchmarks/datasets.py - _olivetti_faces_dataset()(第122-134行)
@M.cache
def _olivetti_faces_dataset():
dataset = fetch_olivetti_faces(shuffle=True, random_state=42) # ① 获取打乱的 Olivetti 人脸
faces = dataset.data
n_samples, n_features = faces.shape
faces_centered = faces - faces.mean(axis=0) # ② 全局中心化(减特征均值)
# local centering
faces_centered -= faces_centered.mean(axis=1).reshape(n_samples, -1) # ③ 局部中心化(减样本均值)
X = faces_centered
X, X_val = train_test_split(X, test_size=0.1, random_state=0)
return X, X_val, None, None
这段代码是人脸基准数据的双重中心化预处理。全局中心化沿特征轴(axis=0)减均值,去除不同像素位置的整体亮度差异(如光照变化);局部中心化沿样本轴(axis=1)减均值,再减去每张人脸的平均亮度,去除个体间明暗对比。两者叠加是 PCA/ICA 处理人脸数据的经典流程。为什么不用 StandardScaler(with_mean=True, with_std=False)?因为该函数对稀疏矩阵有限制,而这里虽然数据是稠密的,显式两步减法更直观、更可控、避免了潜在的类型转换开销。
78.4.8 随机基线数据
源码路径:asv_benchmarks/benchmarks/datasets.py - _random_dataset()(第136-152行)
@M.cache
def _random_dataset(
n_samples=1000, n_features=1000, representation="dense", dtype=np.float32
):
if representation == "dense":
X = np.random.RandomState(0).random_sample((n_samples, n_features)) # ① 稠密均匀随机
X = X.astype(dtype, copy=False)
else:
X = sp.random( # ② CSR 稀疏随机
n_samples,
n_features,
density=0.05,
format="csr",
dtype=dtype,
random_state=0,
)
X, X_val = train_test_split(X, test_size=0.1, random_state=0)
return X, X_val, None, None
这段代码是纯随机数据的基线对照。稠密版用 [0,1) 均匀分布,稀疏版用 5% 密度的 CSR 随机矩阵(默认非零值为均匀分布)。纯随机数据作为基线对照,让算法在"无信号"的数据上跑一遍,得到的性能是噪声下限——任何正经算法都不应显著低于这个数字。
78.4.9 数据工厂流程概览
下面是所有数据集工厂的统一处理流程图:
从流程图可以提炼出五个核心概念:第一,任务类型决定标签占位——无监督任务(聚类、字典学习、PCA)返回 y=None, y_val=None,有监督任务(分类、回归)返回真实标签,统一的四元组让基准框架可以用同一套调用协议处理所有任务。第二,数据生成/加载是入口——合成数据通过 make_blobs/make_regression 等 sklearn 工厂生成,真实数据通过 fetch_20newsgroups/fetch_openml 等下载器加载,入口的多样性不影响后续处理的一致性。第三,类型转换保证精度统一——astype(dtype, copy=False) 统一转为 float32,既节省内存又避免不同精度数据导致的性能差异。第四,预处理适配数据语义——图像用 MaxAbsScaler 保持非负性,连续特征用 StandardScaler 零均值化,文本先 TF-IDF 化再可选 SVD 降维。第五,切分比例固定为 10%——test_size=0.1 是统一约定,保证所有数据集有相同比例的验证集,便于跨数据集比较泛化性能。
78.5 合成数据生成 —— 精确控制实验变量的"造物主"
所有合成数据工厂都遵循"参数化正交实验"的设计哲学:暴露 n_samples、n_features、density 等关键参数,让基准类可以通过 params 网格化配置,测试"同一分布、不同规模/维度/稀疏度"下的算法性能曲线。下面先用一张流水线示意图展示稀疏回归数据集的构造过程,再展开分析。
78.5.1 参数化与正交实验设计
每个工厂函数的参数签名本身就是为基准类服务的。下面以 _blobs_dataset 为例,列出其参数与对应的基准维度映射关系:
下表展示了 _blobs_dataset 三个核心参数与基准测试维度的对应关系,以及它们在实验中的物理含义:
| 参数 | 默认值 | 基准维度 | 物理含义 |
|------|--------|----------|----------|
| n_samples | 500000 | 数据规模 | 样本量 |
| n_features | 3 | 特征维度 | 特征数 |
| n_clusters | 100 | 簇数量 | 任务复杂度 |
基准类可以在 params 字典中定义 n_samples=[50000, 500000]、n_features=[3, 10, 100] 等组合,ASV 会自动展开为笛卡尔积,每个组合单独计时一次,实现"规模扩展性"与"维度扩展性"的正交实验。
78.5.2 分布保真度与算法适配
合成数据的分布设计直接决定了它适合评测哪类算法。Blobs 数据采用各向同性高斯球形簇,这与 KMeans 的"球形簇假设"完美契合,因此它专门服务于聚类基准——任何偏离球形的真实数据都会让 KMeans 性能下降,而 Blobs 则能测出 KMeans 在"理想条件"下的极限性能。make_regression 生成 y = X @ w + noise 形式的数据,其中只有 n_informative 个权重非零——这模拟了真实场景中"少数特征驱动响应"的稀疏信号结构,专门服务于带正则化的线性回归基准(Lasso、ElasticNet)。make_classification 设置 n_informative=n_features, n_redundant=0,意味着所有特征都是有效的、没有冗余维度——这是对 SVM、神经网络等强分类器在高维稠密特征下"硬功夫"的考验。可以看到,每种合成数据都对应一种算法特性:分布保真度与算法假设一一对齐,让评测既能反映"算法在最优场景下的速度",又能反映"算法对特定结构假设的适配性"。
78.5.3 稀疏回归的工程细节
稀疏回归数据集 _synth_regression_sparse_dataset 的构造堪称工程典范,其流水线可以从下图中看出全貌:
这张图揭示了几个关键的工程细节。第一,稀疏 X 的构造:sp.random(...) 生成 CSR 骨架,但默认填充的是均匀分布。源码用 X.data = np.random.RandomState(0).randn(X.getnnz()) 直接覆写 data 数组,绕过了 scipy 的接口限制,让非零值变为高斯分布。稀疏 coefs 的构造使用同样的模式:先生成 50% 密度的稀疏骨架,再用高斯分布覆写 data。第二,Ground Truth 与观测双稀疏:X 稀疏(观测)+ coefs 稀疏(真值)是稀疏线性模型的两个核心特征,完美还原 Lasso、OMP 等算法的真实应用场景。第三,y = X.dot(coefs.toarray()).reshape(-1) 将稀疏系数转为稠密后做乘法,保证 y 是稠密一维向量;后续 train_test_split 对稠密数据更高效。第四,y += 0.2 * y.std() * np.random.randn(n_samples) 引入 20% 标准差的相对噪声,既不过强也不过弱,保证信号可被检测但又考验鲁棒性。整套流水线在"双稀疏结构 + 噪声可调"的正交维度上构造数据,为稀疏线性模型基准提供了高度可复现的测试场景。
78.6 评分函数集 —— 衡量模型优劣的"裁判准则"
utils.py 是基准测试的"裁判评分规则手册"。它定义了 7 个评分器工厂,每个都遵循依赖注入模式:接收 caller(基准类实例),通过 setattr 动态绑定 train_scorer 与 test_scorer。这种设计模式的核心思想是"配置时绑定上下文,评测时零参数调用":评分器工厂在基准类初始化阶段被调用,根据算法类型(分类/回归/聚类/字典学习/PCA)选择合适的度量函数,并通过 caller.train_scorer = ... 注入到基准实例;之后在 time_* 方法执行时,基准框架只需调用 caller.train_scorer(y_true, y_pred) 即可获得评分结果,无需在每次调用时重复传递 X、X_val、estimator 等上下文。这种"出厂绑定"模式让评分逻辑与基准类解耦——同一份基准代码可以通过注入不同的评分器适配完全不同的算法族,而评分器本身只关注"如何算分",不关心"何时被调用、谁调用我"。
78.6.1 聚类惯性负值(基准约定)
源码路径:asv_benchmarks/benchmarks/utils.py - neg_mean_inertia()(第6-7行)
def neg_mean_inertia(X, labels, centers):
return -(np.asarray(X - centers[labels]) ** 2).sum(axis=1).mean()
这段代码实现了聚类惯性的负值版本。centers[labels] 利用 NumPy 高级索引——labels 形状 (n_samples,),centers 形状 (n_clusters, n_features),结果形状 (n_samples, n_features):每个样本与其所属簇中心的差值。平方求和再按样本求均值是惯性的标准定义。取负值的原因是 ASV 约定所有指标"越大越好",而惯性本身是"越小越好",所以加负号翻转符号。这是约定驱动的设计,而非任意选择。
78.6.2 分类与回归的通用评分器
源码路径:asv_benchmarks/benchmarks/utils.py - make_gen_classif_scorers()(第9-12行)
def make_gen_classif_scorers(caller):
caller.train_scorer = balanced_accuracy_score # ① 直接赋值:无需上下文
caller.test_scorer = balanced_accuracy_score
这段代码是通用分类评分器的注入函数。balanced_accuracy_score 宏平均各类别的召回率,天然适配类别不平衡——避免准确率在偏态分布下被多数类淹没。直接赋值函数引用而非 lambda 包装,是因为该函数只接收 (y_true, y_pred),无需任何额外上下文。
源码路径:asv_benchmarks/benchmarks/utils.py - make_gen_reg_scorers()(第14-17行)
def make_gen_reg_scorers(caller):
caller.test_scorer = r2_score # ① 直接赋值
caller.train_scorer = r2_score
这段代码是通用回归评分器的注入函数。r2_score(决定系数)是无量纲、可解释性强的回归度量,作为通用基准的"金标准"。同样直接赋值,无需上下文。
78.6.3 字典学习的重构误差
源码路径:asv_benchmarks/benchmarks/utils.py - neg_mean_data_error()(第19-20行)
def neg_mean_data_error(X, U, V):
return -np.sqrt(((X - U.dot(V)) ** 2).mean())
这段代码实现了字典学习重构误差的 RMSE 负值版本。U.dot(V) 是字典学习模型的预测:U = transform(X)(稀疏编码系数),V = components_(字典原子)。(X - U.dot(V)) 是重构残差,整体公式等价于 ||X - UV||_F 的平方均值开根号——即 RMSE。取负值同样遵循 ASV 的"越大越好"约定。
78.6.4 闭包捕获上下文的精妙技巧
源码路径:asv_benchmarks/benchmarks/utils.py - make_dict_learning_scorers()(第22-32行)
def make_dict_learning_scorers(caller):
caller.train_scorer = lambda _, __: ( # ① lambda 包装,忽略 y_true/y_pred
neg_mean_data_error(
caller.X, # ② 闭包捕获训练集
caller.estimator.transform(caller.X), # ③ 闭包捕获 estimator 变换
caller.estimator.components_ # ④ 闭包捕获字典原子
)
)
caller.test_scorer = lambda _, __: ( # ⑤ 验证集同样模式
neg_mean_data_error(
caller.X_val, # ⑥ 闭包捕获验证集
caller.estimator.transform(caller.X_val), # ⑦ 闭包捕获 estimator 变换
caller.estimator.components_, # ⑧ 闭包捕获字典原子
)
)
这段代码实现了字典学习评分器的闭包注入。关键设计:使用 lambda _, __: 接收任意两个位置参数(由评分器调用协议传入的 y_true, y_pred),但在内部直接引用 caller.X、caller.X_val、caller.estimator——这些变量在 lambda 定义时就已经被闭包捕获,无需通过参数传递。
这种设计实现了"配置时绑定上下文,评测时零参数调用":当基准框架执行 train_scorer(y_true, y_pred) 时,lambda 内部已经知道 X 是谁、estimator 是谁,直接算出重构误差并丢弃传入的 y_true/y_pred。这种延迟求值模式优雅地解决了"评分函数需要额外上下文数据"的难题。
78.6.5 PCA 双轨评分的哲学
源码路径:asv_benchmarks/benchmarks/utils.py - explained_variance_ratio()(第34-35行)
def explained_variance_ratio(Xt, X):
return np.var(Xt, axis=0).sum() / np.var(X, axis=0).sum()
这段代码计算验证集投影方差保留率。np.var(Xt, axis=0).sum() 是投影数据 Xt 的总方差(每个成分方差求和),np.var(X, axis=0).sum() 是原始数据 X 的总方差。为什么不用协方差矩阵?对 m × n 的数据,协方差矩阵是 n × n,当 n 很大时(如 MNIST 784 维、文本上万维)会引发内存爆炸和数值不稳定。直接对各列计算 np.var 并求和,等价于"总方差等于各特征方差之和"(假设零均值后),既避免了矩阵运算又流式友好。
源码路径:asv_benchmarks/benchmarks/utils.py - make_pca_scorers()(第37-43行)
def make_pca_scorers(caller):
caller.train_scorer = lambda _, __: caller.estimator.explained_variance_ratio_.sum() # ① 训练集:零开销
caller.test_scorer = lambda _, __: ( # ② 验证集:显式计算
explained_variance_ratio(caller.estimator.transform(caller.X_val), caller.X_val)
)
这段代码实现了 PCA 评分器的双轨设计。训练集评分直接读取 estimator.explained_variance_ratio_.sum()——这是 PCA 模型 fit 时已经计算好的内在属性,零开销地反映训练时的方差保留。验证集评分显式调用 estimator.transform(X_val) 将验证数据投影到主成分空间,再计算投影方差与原始方差的比值——反映 PCA 在新数据上的泛化方差保留。
这种双轨制体现了"拟合优度 vs 泛化能力"的双视角评测哲学:前者看模型在训练数据上学到了多少方差,后者看模型在新数据上保留了多少方差。两者结合才能全面评估 PCA 的有效性。
78.7 设计中的取舍
为什么将数据集与验证集一次性打包成 (X, X_val, y, y_val) 四元组返回,而不是分两次返回 X, y 和 X_val, y_val? 这种"一次准备、多次使用"的模式可以让基准类在 setup 方法里一次性完成所有数据准备,避免在 time_* 方法里重复 IO 和切分,大幅降低了计时误差。同时,四元组契约让无监督算法也能复用同一套调用框架(用 None 占位),体现了接口设计的正交性——基准框架可以用同一套 time_* 调度逻辑处理聚类、分类、回归等不同任务,无需为每种任务单独编写数据准备分支。
为什么 joblib.Memory 选择磁盘缓存而非内存缓存? 主要权衡的是生命周期范围。ASV 每次跑基准都可能开新进程(甚至在多台机器上分布式运行),内存缓存在进程结束后失效,必须重建;磁盘缓存则可以一直保留,下次启动直接复用。代价是首次运行时需要序列化/反序列化,但这只在第一次发生,后续命中缓存后只读取磁盘,速度依然远快于重新下载、向量化、降维。
为什么字典学习与 PCA 评分器需要 lambda 闭包,而分类与回归直接赋值函数? 根本原因在于上下文依赖的差异。分类与回归的评分函数(balanced_accuracy_score、r2_score)是 sklearn 标准评分协议,只需要 y_true, y_pred 两个参数;而字典学习需要原始数据 X 来计算重构误差 ||X - UV||_F,PCA 的验证集评分需要原始数据 X_val 来计算投影方差比——这些上下文无法通过标准评分协议传递,只能用闭包捕获。lambda 的 _, __ 参数明确表达"忽略标准评分参数,使用闭包内的上下文",是一种类型安全的"故意忽略"声明。
为什么稀疏回归中 X 与 coefs 都保持稀疏,而 y 要 .reshape(-1) 转稠密? 这背后是矩阵形状与下游兼容性的双重考量。X.dot(coefs.toarray()) 的结果是一个二维数组(因为 coefs.toarray() 形状 (n_features, 1)),需要 .reshape(-1) 展平成一维目标向量。一维 y 既符合 sklearn 所有回归器的输入约定,又便于 train_test_split 切分(不需要关心列数)。另一方面,目标向量通常稠密度较高(绝大多数元素非零),强行稀疏化反而增加存储与计算开销,因此保持稠密是更优选择。
78.8 动手练习
-
阅读数据集工厂缓存与契约设计
阅读
asv_benchmarks/benchmarks/datasets.py全文,重点关注:-
_blobs_dataset与_synth_regression_sparse_dataset的参数签名差异 -
所有
@M.cache函数的返回值结构一致性 -
_olivetti_faces_dataset中两次减均值的顺序与维度
回答问题:
-
为什么所有工厂函数都要返回四元组,且验证集固定 10%?这如何服务于基准测试框架的统一调度?
-
joblib.Memory的缓存键包含哪些因素?如果修改n_samples会发生什么? -
稀疏回归数据集中,
X与coefs都是稀疏的,为什么y计算后要.reshape(-1)转稠密?
-
-
剖析评分器闭包与任务适配度量
阅读
asv_benchmarks/benchmarks/utils.py全文,重点关注:-
make_dict_learning_scorers与make_pca_scorers中 lambda 闭包捕获的变量 -
neg_mean_inertia与neg_mean_data_error的数学公式对应关系 -
explained_variance_ratio使用np.var(...).sum()而非协方差迹的原因
回答问题:
-
为什么分类/回归通用评分器直接赋值
caller.train_scorer = balanced_accuracy_score,而字典学习/PCA 需要用 lambda 闭包包装? -
make_pca_scorers中训练集评分用estimator.explained_variance_ratio_.sum()(零开销),验证集却用显式transform计算,这体现了什么评测哲学? -
neg_mean_inertia中centers[labels]的广播机制是怎样的?若labels形状为(n_samples,),centers为(n_clusters, n_features),结果形状是什么?
-
-
设计新基准数据集与评分器
动手实践:模仿现有模式,为假设的"异常检测基准"设计数据集工厂与评分器:
-
编写
_anomaly_detection_dataset(n_samples=10000, n_features=20, contamination=0.01, dtype=np.float32)工厂函数:-
使用
make_blobs生成正常簇,随机均匀噪声生成异常点 -
标签
y:0=正常,1=异常(仅用于验证集评分,训练集无监督) -
返回
(X_train, X_val, None, y_val)契约
-
-
编写
make_anomaly_scorers(caller)评分器工厂:-
训练集:无监督,
train_scorer可设为lambda *args: 0或基于重构误差 -
验证集:
test_scorer使用roc_auc_score(y_val, -estimator.decision_function(X_val))(决策函数越小越异常) -
利用闭包捕获
caller.X_val、caller.y_val、caller.estimator
-
-
思考:若异常检测算法只有
fit_predict无decision_function,如何适配评分器?
-
78.9 本章小结
这一章中我们学习/了解/讨论了 scikit-learn 基准测试框架的两大基础设施:数据集工厂与评分函数集。首先,我们看到 datasets.py 如何通过 @M.cache 装饰器将昂贵的预处理(TF-IDF 向量化、MNIST 下载、SVD 降维)持久化到磁盘缓存;其次,我们剖析了真实世界数据集(20 Newsgroups、MNIST、Olivetti Faces)的标准化预处理流水线;接着,我们深入理解了合成数据的参数化设计思想与稀疏回归数据集的手工构造技巧;然后,我们讨论了评分函数集的依赖注入模式——make_*_scorers(caller) 通过 setattr 动态绑定 train_scorer/test_scorer,实现评分逻辑与基准类的解耦;最后,我们探索了 lambda 闭包捕获上下文的函数式编程技巧,以及 PCA 双轨评分哲学如何体现"拟合优度 vs 泛化能力"的评测视角。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| 数据集工厂函数 | 统一返回 (X, X_val, y, y_val) 四元组,10% 留出验证集,无监督任务 y 为 None |
| @M.cache 装饰器 | 基于 joblib.Memory 的磁盘缓存,键由函数参数自动哈希,实现跨进程/运行的数据复用 |
| 真实数据预处理流水线 | 20NG: TF-IDF → (可选 SVD) → Split;MNIST: fetch_openml → MaxAbsScaler → Split;Olivetti: 双重中心化 |
| 合成数据参数化设计 | 暴露 n_samples/n_features/density 等参数,支撑基准类 params 网格化正交实验 |
| 稀疏回归数据构造 | 显式 CSR 矩阵 X 与稀疏系数 coefs,y = X @ coefs + noise,保证 Ground Truth 与观测双稀疏 |
| 双重中心化 | Olivetti Faces: 减特征均值(全局)+ 减样本均值(局部),去除光照/背景,规避稀疏矩阵不支持 with_mean=True |
| 评分器依赖注入 | make_*_scorers(caller) 通过 setattr 动态绑定 train_scorer/test_scorer,解耦评分逻辑与基准类 |
| 闭包捕获上下文 | lambda _, __: ... 捕获 caller.X/X_val/estimator,实现配置时绑定、评测时零参数调用 |
| 任务适配度量 | 聚类: -inertia;分类: balanced_acc;回归: R²;字典学习: -RMSE 重构误差;PCA: 训练看内在属性/验证看显式投影方差比 |
| explained_variance_ratio 实现 | np.var(Xt, axis=0).sum() / np.var(X, axis=0).sum(),避免协方差矩阵内存/数值问题,流式友好 |
下一章中,我们将进入下一阶段的基准类构建,继续探索 sklearn 算法族的工程级评测框架设计。
第 79 章 —— 聚类与分解算法基准 —— 量化"无监督学习引擎"
79.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解无监督学习算法基准测试的核心设计模式:数据生成、估计器构建与评分器定义的三位一体结构
-
掌握 KMeans 与 MiniBatchKMeans 在稀疏/稠密数据、不同初始化与算法策略下的性能对比维度
-
理解 PCA 不同 SVD 求解器在典型图像数据集上的性能权衡测试设计
-
掌握字典学习与小批量字典学习在求解算法与并行度维度上的基准测试配置差异
-
理解 t-SNE 精确法与 Barnes-Hut 近似法在计算复杂度与样本量自适应策略上的基准设计
-
能够阅读并扩展 ASV 基准测试类,为新的无监督算法添加性能评测
79.2 生活类比
想象这是一场无监督学习算法的"奥运会选拔赛"。在这场盛大的选拔赛中,每一个数据集就像一条标准化赛道:合成的 Blob 数据集是平整的 400 米标准跑道,宽阔均匀,适合考验选手的基础速度;20 Newsgroups 是崎岖的越野赛道,高维稀疏的特征如同遍布的碎石和沟壑,考验选手的适应能力;MNIST 是技术性障碍跑道,七万张手写数字图像如同层层叠叠的栏架,要求选手在降维的同时保持数据结构的清晰可辨;Olivetti Faces 是艺术体操垫,400 张 64×64 像素的人脸图像是柔软而富有表现力的舞台,考验算法在稀疏编码中的细腻表达;Digits 是短跑直道,1797 个 8×8 的小样本如同短距离冲刺,要求算法在简洁中见真章。每一种估计器的配置就像是运动员的装备与策略:KMeans 的 lloyd 算法是稳健的竞走姿态,每一步都精确计算;elkan 算法是利用三角不等式减少计算量的跑步技巧;k-means++ 初始化则是专业的起跑器,确保从一开始就占据有利位置。当面对稀疏数据时,强制降低 max_iter 就像越野赛缩短圈数,避免在高维稀疏中陷入无尽迭代的泥潭。评分器则是公平的计时裁判:neg_mean_inertia 用精密秒表记录"聚类紧凑度",数值越小代表聚类越紧致,取负后符合"越大越好"的比赛评分约定;kl_divergence_ 用体能消耗记录"流形保真度",衡量 t-SNE 在低维空间保留高维结构的程度;PCA 与字典学习的专用评分器则是各项目的专业裁判长,分别从方差最大化和稀疏编码质量两个维度给出权威评分。基准类继承则像运动员报名参赛的项目组别:继承 Estimator 等于报名"训练速度赛",继承 Transformer 同时报名"推理变换赛",继承 Predictor 还要参加"预测分配赛"。t-SNE 只继承 Estimator,因为它只参加训练赛,不支持推理——一旦训练完成便没有 transform 或 predict 接口可用,就像马拉松选手不参加百米冲刺。param_names 与 params 定义的参数网格则是分组规则,ASV 自动生成笛卡尔积分组,让每种算法配置都在同一赛道上公平竞争;而父类 setup_cache 如同统一集训营,所有选手共享同一套数据预处理成果,避免重复劳动,确保起跑线一致。
79.3 源码地图
asv_benchmarks/benchmarks/cluster.py
├── KMeansBenchmark
│ ├── setup_cache() # 调用父类缓存初始化,复用数据加载
│ ├── make_data() # 根据稀疏/稠密表示加载 20 Newsgroups 或合成 Blob 数据
│ ├── make_estimator() # 配置 KMeans:算法、初始化、迭代次数、收敛容忍度
│ └── make_scorers() # 定义基于负平均惯性的训练/测试评分器
├── MiniBatchKMeansBenchmark
│ ├── setup_cache() # 调用父类缓存初始化,复用数据加载
│ ├── make_data() # 数据加载策略同 KMeans,但无算法参数维度
│ ├── make_estimator() # 配置 MiniBatchKMeans:批次大小、最大迭代、禁用早停与标签计算
│ └── make_scorers() # 同 KMeans,使用负平均惯性评分
asv_benchmarks/benchmarks/decomposition.py
├── PCABenchmark
│ ├── setup_cache() # 调用父类缓存初始化,复用数据加载
│ ├── make_data() # 加载 MNIST 数据集
│ ├── make_estimator() # 配置 PCA:固定 32 维,参数化 svd_solver (full/arpack/randomized)
│ └── make_scorers() # 委托 utils.make_pca_scorers 生成评分器
├── DictionaryLearningBenchmark
│ ├── setup_cache() # 调用父类缓存初始化,复用数据加载
│ ├── make_data() # 加载 Olivetti Faces 数据集
│ ├── make_estimator() # 配置批量字典学习:lars/cd 算法、精确收敛参数、并行度
│ └── make_scorers() # 委托 utils.make_dict_learning_scorers
├── MiniBatchDictionaryLearningBenchmark
│ ├── setup_cache() # 调用父类缓存初始化,复用数据加载
│ ├── make_data() # 加载 Olivetti Faces 数据集
│ ├── make_estimator() # 配置小批量字典学习:小批次、禁用早停、并行度
│ └── make_scorers() # 委托 utils.make_dict_learning_scorers
asv_benchmarks/benchmarks/manifold.py
├── TSNEBenchmark
│ ├── setup_cache() # 调用父类缓存初始化,复用数据加载
│ ├── make_data() # 自适应样本量:精确法限制 500 样本,近似法使用全量 Digits
│ ├── make_estimator() # 配置 TSNE:固定随机种子,参数化 method (exact/barnes_hut)
│ └── make_scorers() # 直接使用 estimator.kl_divergence_ 作为评分指标
asv_benchmarks/benchmarks/common.py
├── Benchmark # 根基类:配置加载、缓存管理
├── Estimator # 训练计时抽象:time_fit
├── Predictor # 预测计时抽象:time_predict (依赖 time_fit 缓存)
└── Transformer # 变换计时抽象:time_transform (依赖 time_fit 缓存)
asv_benchmarks/benchmarks/datasets.py
├── _blobs_dataset() # 生成合成聚类数据
├── _20newsgroups_highdim_dataset() # 加载高维稀疏文本数据
├── _mnist_dataset() # 加载 MNIST 手写数字数据
├── _olivetti_faces_dataset() # 加载 Olivetti 人脸图像
└── _digits_dataset() # 加载 Digits 手写数字数据 (小规模)
asv_benchmarks/benchmarks/utils.py
├── neg_mean_inertia() # KMeans 负惯性评分函数
├── make_pca_scorers() # PCA 评分器工厂 (解释方差比等)
└── make_dict_learning_scorers() # 字典学习评分器工厂 (重构误差等)
79.4 KMeans 家族基准 —— 聚类速度与质量的"双面镜"
KMeans 是无监督学习中最经典的聚类算法,scikit-learn 的基准测试从三个正交维度对它进行全方位体检:数据表示(稠密/稀疏)、算法策略(Lloyd/Elkan)、初始化方式(随机/k-means++)。三个维度两两组合,共生成 8 种基准配置,确保每一种算法变体都在同一赛道上接受公平检验。
核心对比维度:数据表示与算法选择
param_names 定义三个正交维度,params 为每个维度提供具体选项。稀疏数据强制使用 20 Newsgroups 高维数据集(_20newsgroups_highdim_dataset),稠密数据使用合成 Blob 数据集(_blobs_dataset)。max_iter 针对稀疏/稠密差异化设置(30 vs 100),tol=0 确保完全收敛以对比算法极限性能。
源码路径:asv_benchmarks/benchmarks/cluster.py - KMeansBenchmark(18-57行)
class KMeansBenchmark(Predictor, Transformer, Estimator, Benchmark): # ① 四重继承:训练+预测+变换+基础
"""
Benchmarks for KMeans.
"""
param_names = ["representation", "algorithm", "init"] # ② 三个基准维度
params = (["dense", "sparse"], ["lloyd", "elkan"], ["random", "k-means++"]) # ③ 各维度取值
def setup_cache(self): # ④ 复用父类缓存
super().setup_cache()
def make_data(self, params): # ⑤ 根据参数选择数据集
representation, algorithm, init = params
if representation == "sparse": # ⑥ 稀疏数据用高维文本
data = _20newsgroups_highdim_dataset(n_samples=8000)
else: # ⑦ 稠密数据用合成 Blob
data = _blobs_dataset(n_clusters=20)
return data
def make_estimator(self, params): # ⑧ 构造 KMeans 估计器
representation, algorithm, init = params
max_iter = 30 if representation == "sparse" else 100 # ⑨ 稀疏数据迭代数减半
estimator = KMeans(
n_clusters=20, # ⑩ 固定 20 个簇
algorithm=algorithm, # ⑪ lloyd 或 elkan
init=init, # ⑫ random 或 k-means++
n_init=1, # ⑬ 固定初始化次数消除随机性
max_iter=max_iter, # ⑭ 自适应最大迭代
tol=0, # ⑮ 完全收敛,不容忍任何误差
random_state=0, # ⑯ 固定随机种子
)
return estimator
这段代码定义了 KMeansBenchmark 类,它完整覆盖了 KMeans 在不同数据表示、算法实现和初始化策略下的性能对比。setup_cache 仅一行调用父类方法,体现了基类缓存的统一治理模式;make_data 根据 representation 参数分流到两个不同的数据集工厂;make_estimator 中的 tol=0 是一个刻意设计——它禁止任何早停机制,确保算法跑完所有迭代以揭示极限性能。
KMeansBenchmark 主体三方法(setup_cache、make_data、make_estimator)的执行关系可由下图刻画:
评分机制:负平均惯性 (Negative Mean Inertia)
make_scorers 使用 neg_mean_inertia 计算训练集与验证集上的聚类质量。惯性定义为样本到最近簇中心距离平方和,数值越小越好,取负值符合 ASV"越大越好"的评分约定。
源码路径:asv_benchmarks/benchmarks/cluster.py - KMeansBenchmark.make_scorers(50-57行)
def make_scorers(self): # ① 定义训练与测试评分器
self.train_scorer = lambda _, __: neg_mean_inertia( # ② lambda 包装评分函数
self.X, # ③ 训练集特征
self.estimator.predict(self.X), # ④ 预测的簇归属
self.estimator.cluster_centers_ # ⑤ 簇中心点
)
self.test_scorer = lambda _, __: neg_mean_inertia(
self.X_val, # ⑥ 验证集特征
self.estimator.predict(self.X_val),
self.estimator.cluster_centers_,
)
这段代码定义了两个 lambda 评分器,分别评估训练集和验证集上的负平均惯性。注意 lambda 接收两个参数但都忽略(_, __),因为评分所需的全部状态已经通过 self.estimator 与数据集属性 self.X、self.X_val 在闭包中捕获。neg_mean_inertia 计算的是样本到其簇中心的距离平方均值,这一指标直观反映了聚类的"紧凑度"——簇内方差越小,聚类质量越高。
KMeans 评分数据流可由下图刻画:fit 阶段在闭包内捕获 self.estimator.predict 与 self.estimator.cluster_centers_,lambda 调用时即时计算训练/验证集上的负平均惯性。
MiniBatchKMeans 的差异化配置
MiniBatchKMeansBenchmark 与 KMeansBenchmark 在结构上保持一致,但在参数维度上简化(仅 representation 与 init),且在配置上追求"流式场景"的模拟:迭代数更小(5 vs 2),禁用早停(max_no_improvement=None),禁用标签计算(compute_labels=False),如同把运动员放进了一场永不停歇的马拉松,看其在连续小批量更新中的收敛轨迹。
源码路径:asv_benchmarks/benchmarks/cluster.py - MiniBatchKMeansBenchmark.setup_cache(63-64行)
def setup_cache(self): # ① 一行父类委托
super().setup_cache() # ② 调用 Benchmark 基类建立缓存
setup_cache 仍是单行 super().setup_cache(),延续 KMeansBenchmark 的缓存复用模式,依赖基类统一加载并预处理数据集。
源码路径:asv_benchmarks/benchmarks/cluster.py - MiniBatchKMeansBenchmark.make_data(65-75行)
def make_data(self, params): # ① 数据集选择入口
representation, init = params # ② 解包两元素参数元组
if representation == "sparse": # ③ 稀疏分支
data = _20newsgroups_highdim_dataset() # ④ 默认采样量
else: # ⑤ 稠密分支
data = _blobs_dataset(n_clusters=20) # ⑥ 20 个高斯簇
return data # ⑦ 返回数据集对象
make_data 与 KMeans 类似但未指定 n_samples,让稀疏数据沿用数据集工厂的默认值;稠密分支仍生成 20 簇 Blob 数据,与 KMeans 保持一致以确保两类算法的可比性。
源码路径:asv_benchmarks/benchmarks/cluster.py - MiniBatchKMeansBenchmark.make_estimator(77-92行)
def make_estimator(self, params): # ① 估计器构造入口
representation, init = params # ② 解包参数元组
max_iter = 5 if representation == "sparse" else 2 # ③ 小批量迭代数极少
estimator = MiniBatchKMeans(
n_clusters=20, # ④ 与 KMeans 一致的 20 簇
init=init, # ⑤ random 或 k-means++
n_init=1, # ⑥ 单次初始化
max_iter=max_iter, # ⑦ 仅跑 2-5 轮迭代
batch_size=1000, # ⑧ 每批 1000 样本
max_no_improvement=None, # ⑨ 禁用早停
compute_labels=False, # ⑩ 不计算完整标签(加速)
random_state=0, # ⑪ 固定随机种子
)
return estimator
max_iter 降至 2-5 轮,因为小批量算法的设计哲学是用频繁的微调代替少量的大步迭代;batch_size=1000 模拟中等规模批次的在线学习;compute_labels=False 是关键优化——它跳过对全部训练样本的标签分配计算,因为基准测试只关心惯性指标而非具体的聚类归属。
源码路径:asv_benchmarks/benchmarks/cluster.py - MiniBatchKMeansBenchmark.make_scorers(94-101行)
def make_scorers(self): # ① 评分器定义入口
self.train_scorer = lambda _, __: neg_mean_inertia( # ② 训练集负惯性评分
self.X, self.estimator.predict(self.X), self.estimator.cluster_centers_
)
self.test_scorer = lambda _, __: neg_mean_inertia( # ③ 验证集负惯性评分
self.X_val,
self.estimator.predict(self.X_val),
self.estimator.cluster_centers_,
)
make_scorers 与 KMeans 完全相同,共享同一套负平均惯性评分逻辑——尽管 MiniBatchKMeans 设置了 compute_labels=False,但在评分阶段会显式调用 predict 触发完整标签分配,这一开销被计入评分而非训练计时,遵循"按需计算"的工程哲学。
这些代码块完整呈现了 MiniBatchKMeansBenchmark。setup_cache 同样是简单的父类委托;make_estimator 的关键配置体现流式学习理念:极小迭代、显式批次大小、禁用早停。
MiniBatchKMeans 整体执行流程可由下图刻画:
以下是 KMeans 家族基准的完整数据流图:
79.5 PCA 基准 —— 降维的"速度与激情"
PCA 的基准设计哲学与 KMeans 截然不同:它只关注一个维度——svd_solver。三种求解器代表三种不同的数学路径:full 调用完整的 SVD 分解,保证数值精度但计算昂贵;arpack 使用隐式重启 Lanczos 算法求解截断 SVD,适合中等规模数据;randomized 采用随机投影近似,适合大规模数据但牺牲少许精度。这种"单维度深耕"的设计让性能差异一目了然。
核心对比维度:SVD 求解器策略
param_names 仅包含 svd_solver,三选项对应全量 SVD (full)、截断 SVD (arpack)、随机近似 SVD (randomized)。数据集固定使用 MNIST (_mnist_dataset),n_components=32 模拟典型降维场景。
setup_cache 是父类缓存委托,由基类统一加载并缓存 MNIST,确保三种 SVD 求解器均在同一份数据上计时。make_data 总是返回相同的 MNIST 数据集,无论 params 取何值,这体现了"控制变量"的基准测试设计原则——基准的唯一变量是 svd_solver。make_estimator 通过单元素元组解包获取求解器参数,构造 n_components=32 的 PCA 实例,random_state=0 保证随机化结果可复现。
源码路径:asv_benchmarks/benchmarks/decomposition.py - PCABenchmark(11-33行)
class PCABenchmark(Transformer, Estimator, Benchmark): # ① 三重继承:无 Predictor
"""
Benchmarks for PCA.
"""
param_names = ["svd_solver"] # ② 单一对比维度
params = (["full", "arpack", "randomized"],) # ③ 三种 SVD 求解器
def setup_cache(self):
super().setup_cache()
def make_data(self, params): # ④ 固定 MNIST 数据集
return _mnist_dataset()
def make_estimator(self, params):
(svd_solver,) = params # ⑤ 单元素元组解包
estimator = PCA(
n_components=32, # ⑥ 固定目标维度 32
svd_solver=svd_solver, # ⑦ 参数化求解器
random_state=0, # ⑧ 保证随机化结果可复现
)
return estimator
def make_scorers(self): # ⑨ 评分器入口
make_pca_scorers(self) # ⑩ 委托工厂函数注入评分器
这段代码完整定义了 PCABenchmark 类。值得注意的是它没有继承 Predictor——因为 PCA 是无监督变换器,它没有 predict 方法,只有 fit 和 transform。三种求解器在相同的 MNIST 数据上计时,唯一变量是 svd_solver,体现了基准测试的"控制变量"原则。
PCA 估计器构造与求解路径可由下图刻画:
评分机制:PCA 专用评分器工厂
make_scorers 委托 utils.make_pca_scorers 生成评分器,封装了解释方差比等 PCA 特有指标。这种"工厂注入"模式的好处是:当 PCA 引入新的评价指标时,只需修改工厂函数而无需修改基准类。PCA 特有的评价指标包括解释方差比(explained variance ratio,衡量每个主成分保留的信息比例)、累积解释方差比(cumulative explained variance,反映总信息保留率)以及重构误差(reconstruction error,衡量降维带来的信息损失)。
PCA 基准的数据流图:
79.6 字典学习基准 —— 稀疏编码的"效率竞技场"
字典学习是比 PCA 更复杂的降维方法:它同时学习字典(基向量集合)和稀疏编码(每个样本的稀疏表示)。scikit-learn 提供了两种训练范式——批量字典学习(DictionaryLearning)和小批量字典学习(MiniBatchDictionaryLearning)。前者追求精确收敛,后者模拟在线学习。基准测试从两个维度对比它们:求解算法(LARS/坐标下降)和并行度。
缓存复用:统一的 setup_cache 模式
本单元涉及 DictionaryLearningBenchmark 与 MiniBatchDictionaryLearningBenchmark 两个基准类,它们在 setup_cache 上保持完全一致——均通过单行 super().setup_cache() 委托给 Benchmark 基类。这一统一模式确保了 Olivetti Faces 这一相对昂贵的图像数据集仅被加载一次,所有 fit_algorithm × n_jobs 参数组合共享同一缓存,从而消除数据加载作为基准测试的干扰项。setup_cache 由 ASV 在所有计时方法之前调用一次,其结果以 pickle 文件形式缓存到 ~/.asv/ 目录;后续每次 make_data 调用均从磁盘反序列化,避免重复 I/O。
核心对比维度:求解算法与并行度
param_names 包含 fit_algorithm(lars/cd)和 n_jobs(继承自 Benchmark.n_jobs_vals),形成算法×并行度的笛卡尔积参数网格。数据集使用 Olivetti Faces(_olivetti_faces_dataset),典型的小样本高维图像数据。DictionaryLearning 与 MiniBatchDictionaryLearning 共享超参数:n_components=15、alpha=0.1、random_state=0。
setup_cache 委托基类建立 Olivetti Faces 缓存;make_data 固定加载 Olivetti Faces,通过"固定数据集 + 参数化算法"实现控制变量。make_estimator 中的 tol=1e-16 是几乎不可能达到的精度,强制算法跑完所有 20 轮迭代以进行公平对比;transform_alpha=1 控制变换阶段稀疏编码的稀疏度——值越大,编码越稀疏。
源码路径:asv_benchmarks/benchmarks/decomposition.py - DictionaryLearningBenchmark.setup_cache(39-40行)
def setup_cache(self): # ① 缓存初始化入口
super().setup_cache() # ② 委托基类建立 Olivetti Faces 缓存
源码路径:asv_benchmarks/benchmarks/decomposition.py - DictionaryLearningBenchmark.make_data(41-43行)
def make_data(self, params): # ① 数据集入口
return _olivetti_faces_dataset() # ② 固定返回 Olivetti Faces 数据
源码路径:asv_benchmarks/benchmarks/decomposition.py - DictionaryLearningBenchmark.make_estimator(45-58行)
def make_estimator(self, params): # ① 估计器构造入口
fit_algorithm, n_jobs = params # ① 双维度解包
estimator = DictionaryLearning(
n_components=15, # ② 15 个字典原子
fit_algorithm=fit_algorithm, # ③ lars 或 cd
alpha=0.1, # ④ 稀疏惩罚系数
transform_alpha=1, # ⑤ 变换阶段稀疏度
max_iter=20, # ⑥ 固定 20 轮迭代
tol=1e-16, # ⑦ 极严格收敛容忍度
random_state=0,
n_jobs=n_jobs, # ⑧ 并行度参数化
)
return estimator
源码路径:asv_benchmarks/benchmarks/decomposition.py - DictionaryLearningBenchmark.make_scorers(60-62行)
def make_scorers(self): # ① 评分器入口
make_dict_learning_scorers(self) # ② 委托工厂函数注入评分器
make_scorers 委托给工厂函数,统一管理重构误差与稀疏编码质量等字典学习特有指标。
批量字典学习的整体执行流可由下图刻画:
两种训练范式的差异化配置
小批量字典学习则走向另一个极端:迭代数隐式由 batch_size=3 和数据量决定,max_no_improvement=None 禁用早停,如同让运动员进行永无止境的耐力训练。
源码路径:asv_benchmarks/benchmarks/decomposition.py - MiniBatchDictionaryLearningBenchmark.setup_cache(68-69行)
def setup_cache(self): # ① 缓存初始化入口
super().setup_cache() # ② 复用父类缓存,与批量版共享同一份数据
setup_cache 与批量版完全一致——同样一行 super().setup_cache(),复用同一份 Olivetti Faces 缓存,确保两种训练范式在同一数据上对比。
源码路径:asv_benchmarks/benchmarks/decomposition.py - MiniBatchDictionaryLearningBenchmark.make_data(70-72行)
def make_data(self, params): # ① 数据集入口
return _olivetti_faces_dataset() # ② 与批量版使用同一数据集
make_data 与批量版相同,固定使用 Olivetti Faces。
源码路径:asv_benchmarks/benchmarks/decomposition.py - MiniBatchDictionaryLearningBenchmark.make_estimator(74-86行)
def make_estimator(self, params): # ① 估计器构造入口
fit_algorithm, n_jobs = params # ② 双维度解包
estimator = MiniBatchDictionaryLearning(
n_components=15, # ③ 15 个字典原子(与批量版一致)
fit_algorithm=fit_algorithm, # ④ lars 或 cd
alpha=0.1, # ⑤ 稀疏惩罚系数(与批量版一致)
batch_size=3, # ⑥ 每批仅 3 张人脸
random_state=0, # ⑦ 固定随机种子
n_jobs=n_jobs, # ⑧ 并行度参数化
)
return estimator
batch_size=3 是一个极小的批次——意味着每轮迭代仅用 3 张人脸图像更新字典,这真实模拟了在线学习场景中数据流式到达的情形;与批量版相比,它没有 max_iter、tol、transform_alpha 参数,因为小批量算法依赖早停和批次遍历而非固定迭代。
源码路径:asv_benchmarks/benchmarks/decomposition.py - MiniBatchDictionaryLearningBenchmark.make_scorers(88-90行)
def make_scorers(self): # ① 评分器入口
make_dict_learning_scorers(self) # ② 复用同一工厂函数
make_scorers 同样委托给工厂函数,与批量版共享评分指标体系,确保对比维度的一致性。
小批量字典学习的执行流可由下图刻画:
评分机制:字典学习专用评分器工厂
为了揭示批量与小批量字典学习在不同算法与并行度下的训练效率差异,两者均委托 utils.make_dict_learning_scorers 统一管理评分指标。字典学习的评价指标包括重构误差(衡量字典与编码共同还原原始信号的能力)、稀疏度(衡量编码的稀疏程度,反映 transform_alpha 的效果)以及学习收敛速度——这些指标均需在训练完成后基于 estimator.components_(字典矩阵)与 transform(X)(稀疏编码)联合计算,因此被封装在工厂函数中而非内联在基准类内。
字典学习评分器工厂的数据流可由下图刻画:
为帮助读者更系统地理解两种字典学习范式的核心差异,下面通过表格对比其基准配置的关键维度。
下表系统对比批量字典学习与小批量字典学习的基准配置差异:
| 维度 | DictionaryLearning | MiniBatchDictionaryLearning |
|------|--------------------|-----------------------------|
| 收敛策略 | 固定迭代 (max_iter=20) | 数据流式遍历 (batch_size=3) |
| 早停 | 禁用 (tol=1e-16) | 禁用 (无 max_no_improvement) |
| 变换稀疏度 | transform_alpha=1 | 默认 |
| 典型场景 | 离线精确学习 | 在线流式学习 |
79.7 t-SNE 基准 —— 流形学习的"计算马拉松"
t-SNE 是计算密集型流形学习算法的典型代表。它有两种实现方式:精确法(exact)的时间复杂度是 O(N²),适用于小数据集;Barnes-Hut 近似法将复杂度降至 O(N log N),可处理较大数据集但牺牲少许精度。基准测试的自适应样本量策略是这一章最精妙的设计——它不让两种算法"硬碰硬",而是给每种算法最适合的赛道。
核心对比维度:近似算法选择
param_names 仅 method(exact/barnes_hut),对比精确 O(N²) 算法与 Barnes-Hut O(N log N) 近似算法。自适应样本量策略:精确法限制 n_samples=500 避免爆炸,Barnes-Hut 使用完整 Digits 数据集(_digits_dataset)。
setup_cache 仍是父类委托,复用基类缓存机制,避免重复加载 Digits。make_data 中 500 这个数字的选择是工程权衡的结果:精确法的距离矩阵计算为 N² 次,500 样本对应约 25 万次距离计算——这是一个能在合理时间内完成但又能体现算法特征的数量。1797 样本(Digits 全量)则对应约 322 万次距离计算,精确法无法承受。n_samples=None 表示使用完整数据集。make_estimator 通过 random_state=0 固定随机初始化,确保收敛轨迹可复现。
t-SNE 估计器构造与样本量决策可由下图刻画:
评分机制:KL 散度作为内在质量指标
make_scorers 直接使用 estimator.kl_divergence_ 属性,即 t-SNE 优化目标函数值。训练/测试评分器指向同一属性,体现 t-SNE 无监督、无外推能力的特性。
def make_scorers(self): # ① 评分器定义入口
self.train_scorer = lambda _, __: self.estimator.kl_divergence_ # ② 训练评分同源
self.test_scorer = lambda _, __: self.estimator.kl_divergence_ # ③ 测试评分同源
这段代码定义了两个完全相同的评分器。kl_divergence_ 是 t-SNE 训练过程中优化的 KL 散度值,它衡量高维分布与低维分布之间的信息损失。训练和测试评分指向同一指标反映了 t-SNE 的一个根本特性:它没有 transform 方法——训练完成后无法将新数据映射到嵌入空间,因此不存在"测试集外推质量"的概念。
t-SNE 评分数据流可由下图刻画:fit 阶段 t-SNE 将高维分布拟合到低维空间并计算 KL 散度存储为 kl_divergence_,评分器仅返回该属性值。
工程细节:仅继承 Estimator,极简设计
TSNEBenchmark 仅继承 Estimator 与 Benchmark 两个基类,不继承 Transformer 和 Predictor。这一精简源于 TSNE 的接口约束——它只提供 fit_transform,没有 transform(无法对新数据映射)和 predict(无监督学习无标签预测)。如果强制继承 Transformer 或 Predictor,基类会自动生成 time_transform 和 time_predict 计时方法,但调用时 TSNE 会抛出 AttributeError 或 NotImplementedError,导致基准测试失败。random_state=0 在 make_estimator 中固定,确保两种 method 配置下的嵌入轨迹可复现——t-SNE 对随机初始化极其敏感,不固定种子则无法对比 exact 与 barnes_hut 的算法差异。setup_cache 同样是一行父类委托 super().setup_cache(),复用基类加载的 Digits 数据集缓存,与前述所有基准类保持统一的缓存治理模式。
class TSNEBenchmark(Estimator, Benchmark): # ① 仅继承 Estimator(接口约束)
"""
Benchmarks for TSNE.
"""
param_names = ["method"] # ② 单一对比维度
params = (["exact", "barnes_hut"],) # ③ 精确法 vs 近似法
def setup_cache(self): # ④ 父类缓存委托
super().setup_cache() # ⑤ 复用基类缓存
def make_data(self, params): # ⑥ 数据集入口
(method,) = params # ⑦ 单元素元组解包
n_samples = 500 if method == "exact" else None # ⑧ 精确法限制 500 样本
return _digits_dataset(n_samples=n_samples) # ⑨ 近似法使用全量
def make_estimator(self, params): # ⑩ 估计器构造入口
(method,) = params # ⑪ 单元素元组解包
estimator = TSNE(random_state=0, method=method) # ⑫ 固定随机种子与方法
return estimator # ⑬ 返回 TSNE 实例
def make_scorers(self): # ⑭ 评分器定义入口
self.train_scorer = lambda _, __: self.estimator.kl_divergence_ # ⑮ 训练评分同源
self.test_scorer = lambda _, __: self.estimator.kl_divergence_ # ⑯ 测试评分同源
这段代码完整呈现了 TSNEBenchmark。TSNE 没有 transform 和 predict 方法,因此继承 Transformer 和 Predictor 没有意义——基类中的 time_transform 与 time_predict 方法会因找不到对应接口而失败。
TSNEBenchmark 的继承层次与接口约束可由下图刻画:
t-SNE 基准的设计哲学可以总结为下图:
79.8 设计中的取舍
为什么 KMeans 基准同时继承 Predictor、Transformer 和 Estimator,而 t-SNE 只继承 Estimator?KMeans 拥有完整的预测接口(predict 给新样本分配簇)、变换接口(transform 计算到簇中心的距离)和训练接口(fit),因此它可以同时参加"训练赛"、"预测赛"和"变换赛"三个项目。t-SNE 则是一个纯粹的训练型算法,它只有 fit_transform,没有 transform(无法对新数据映射)和 predict(无监督学习无标签预测)。如果强制继承 Transformer 或 Predictor,基类会自动生成 time_transform 和 time_predict 计时方法,但调用时 TSNE 会抛出 AttributeError 或 NotImplementedError,导致基准测试失败。这种"按能力参赛"的设计体现了接口一致性的严格性。
为什么 MiniBatchKMeans 设置 compute_labels=False,而 KMeans 不需要这个参数?KMeans 的 predict 方法必须返回完整的簇标签数组,因此 compute_labels=True 是默认行为且无法关闭。MiniBatchKMeans 作为在线学习算法,其典型应用场景是流式聚类——数据不断到来,模型不断更新,但很少需要回溯查询全部历史样本的标签。compute_labels=False 是一个性能优化:它跳过对训练集的最终标签分配计算,因为基准测试只关心惯性指标而非具体聚类归属。如果未来 MiniBatchKMeans 需要查询全部标签,可以手动调用 compute_labels=True 的版本。这体现了"按需计算"的工程哲学。
为什么 t-SNE 基准的自适应样本量策略是工程上的精妙设计?精确法 t-SNE 的距离矩阵计算复杂度为 O(N²),500 样本对应约 25 万次计算(合理时间),而 1797 样本(Digits 全量)对应约 322 万次计算(不可承受)。Barnes-Hut 近似法则将复杂度降至 O(N log N),1797 样本的计算量约 1.5 万次,完全在合理范围内。如果不采用自适应策略,两种算法要么让精确法跑得太慢(几小时),要么让 Barnes-Hut 跑得太快(无法体现近似价值)。这种"给每种算法最适合的赛道"的策略,是公平基准测试的关键智慧。
为什么字典学习同时测试 LARS 和坐标下降(CD)两种 fit_algorithm?LARS(Least Angle Regression)和坐标下降是两种经典的稀疏编码求解路径。LARS 通过最小角回归逐步逼近最优解,适合高维稀疏问题但计算较慢;坐标下降逐坐标优化,收敛快但对病态数据敏感。在 Olivetti Faces 这种 400×4096 的小样本高维数据上,两种算法的性能差异显著——这是字典学习算法选型的核心问题。基准测试通过笛卡尔积参数网格(fit_algorithm × n_jobs)揭示两种算法在并行化下的扩展性差异。
79.9 动手练习
-
分析 KMeans 与 MiniBatchKMeans 的参数配置差异
对比
cluster.py中两个基准类的make_estimator方法:-
为什么 KMeans 稀疏数据
max_iter=30而稠密数据max_iter=100?MiniBatchKMeans 为何更小 (5 vs 2)? -
KMeans 设置
tol=0强制完全收敛,而 MiniBatchKMeans 设置max_no_improvement=None禁用早停,二者意图有何异同? -
compute_labels=False在 MiniBatchKMeans 中的作用是什么?为何 KMeans 无此参数?
-
-
理解 PCA 与字典学习的评分器工厂设计
阅读
utils.py中make_pca_scorers与make_dict_learning_scorers的实现(源码未给出,需自行查阅):-
为什么 PCA 和字典学习需要专用的评分器工厂,而不是像 KMeans 那样直接在类中定义 lambda?
-
make_pca_scorers可能包含哪些 PCA 特有的评价指标?(提示:解释方差、重构误差) -
字典学习的
transform_alpha参数在训练与评分阶段如何影响稀疏编码结果?
-
-
设计一个新的无监督算法基准测试类
参考
TSNEBenchmark的设计,为sklearn.manifold.Isomap编写一个IsomapBenchmark类:-
确定
param_names和params:至少包含n_neighbors和n_components两个维度 -
选择合适的数据集(可复用
_digits_dataset或_swiss_roll_dataset需自行添加) -
确定继承哪些基类(
Estimator、Transformer、Predictor?),并解释理由 -
设计
make_scorers:Isomap 重构误差如何计算?(提示:reconstruction_error_属性)
-
-
探究 t-SNE 样本量自适应策略的工程考量
分析
manifold.py中TSNEBenchmark.make_data的n_samples = 500 if method == 'exact' else None逻辑:-
精确法复杂度 O(N²) 在 N=500 时约 25 万距离计算,N=1797 (Digits 全量) 时约 322 万,为什么选择 500 这个阈值?
-
如果要在基准测试中加入
method='fft'(假设未来支持),你会如何修改样本量策略? -
为什么
train_scorer和test_scorer都返回kl_divergence_?这反映了 t-SNE 什么特性?
-
-
剖析 setup_cache 在基准测试继承链中的作用
阅读
common.py中Benchmark.setup_cache与各子类setup_cache的实现:-
为什么所有子类的
setup_cache都只有一行super().setup_cache()?这体现了什么设计模式? -
如果某个基准测试需要额外的缓存预处理(如预计算核矩阵),应该在哪个方法中实现?如何避免破坏缓存复用?
-
setup_cache与make_data的调用时机有何不同?这对基准测试的可重复性有何保障?
-
79.10 本章小结
这一章我们深入剖析了 scikit-learn 无监督学习算法的基准测试设计。首先,我们以"奥运会选拔赛"的生活类比建立了对基准测试框架的直觉认知,理解了数据集如同标准化赛道、估计器配置如同运动员装备、参数网格作为分组规则。然后,我们依次解构了 KMeans 家族(三维度参数化:数据表示、算法、初始化)、PCA(单维度深耕:SVD 求解器)、字典学习(双维度对比:求解算法、并行度)和 t-SNE(自适应样本量策略)的基准实现。接着,我们探讨了设计中的工程取舍,理解了多重继承、参数配置、评分器工厂等核心模式背后的权衡智慧。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| KMeansBenchmark | 对比 Lloyd/Elkan 算法、k-means++/随机初始化在稠密/稀疏数据上的训练与推理性能,评分指标为负平均惯性 |
| MiniBatchKMeansBenchmark | 测试小批量 KMeans 在不同初始化策略下的收敛速度,配置小批次、少迭代、禁用早停以模拟流式场景 |
| PCABenchmark | 横向对比 full/arpack/randomized 三种 SVD 求解器在 MNIST 上的降维速度与精度,固定 32 目标维度 |
| DictionaryLearningBenchmark | 评估批量字典学习中 LARS 与坐标下降算法及并行度对稀疏编码质量与训练时间的影响 |
| MiniBatchDictionaryLearningBenchmark | 测试小批量字典学习在在线学习设定下的效率,小批次模拟增量数据流 |
| TSNEBenchmark | 对比精确 O(N²) 与 Barnes-Hut O(N log N) 两种 t-SNE 算法,自适应限制精确法样本量避免计算爆炸,以 KL 散度为核心指标 |
| Benchmark 基类体系 | 多重继承组合模式:Estimator(训练) + Predictor(预测) + Transformer(变换) 定义计时接口,Benchmark 提供配置与缓存 |
| setup_cache 缓存复用 | 所有基准类均调用 super().setup_cache(),利用基类缓存机制避免重复数据加载,确保实验一致性与效率 |
下一章中,我们将学习线性模型与集成学习基准,剖析逻辑回归、岭回归、SGD、随机森林、梯度提升等主流监督学习算法的基准测试,揭示不同求解器与数据表示下的性能规律。
第 80 章 —— 无监督学习基准 —— 聚类、维度降低与流形学习的全面评测
80.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 KMeansBenchmark 与 MiniBatchKMeansBenchmark 通过 lloyd/elkan 算法、初始化方式与 batch_size 设置的差异,评估聚类速度与质量的权衡
-
掌握 PCABenchmark 中 svd_solver 参数化测试(full/arpack/randomized)在 MNIST 数据上的性能表现及其适用场景
-
了解 DictionaryLearningBenchmark 与 MiniBatchDictionaryLearningBenchmark 如何通过 fit_algorithm(lars/cd)与 n_jobs 参数控制字典学习的训练效率与稀疏度
-
理解 TSNEBenchmark 对 exact 与 barnes_hut 方法的性能对比设计,及 KL 散度作为评分指标在流形学习中的深层含义
-
掌握 setup_cache、make_data、make_estimator、make_scorers 五个钩子在无监督基准中的协同工作模式,以及 timeout 护栏的工程意义(如 t-SNE 基准需防超时)
-
能阅读 ASV 配置下的基准类继承体系,区分继承 Benchmark(直接测函数)与继承 Predictor/Estimator(完整 fit/transform 流程)的设计差异
80.2 生活类比
想象 asv_benchmarks 是一场机器学习算法的全运会,所有算法都在统一的赛场上同台竞技。Benchmark 基类就像比赛组委会,它制定统一规则、提供精确计时设备,并通过全局配置 data_size/n_jobs_vals 控制比赛的整体节奏。Estimator/Predictor 混入类则扮演着项目裁判委员会的角色,把完整的比赛拆解成 fit(赛前训练)、track(赛中追踪)和 transform(赛后投射)多个独立评分环节,确保每个算法都能在公平、可拆解的框架下被评测。Transformer 混入类则专门为有"投射"环节的算法(如 PCA、字典学习等降维方法)服务,让它们在 fit 之后还能被独立地评估 transform 阶段的性能。
参数网格 param_names + params 则相当于比赛项目表,它通过笛卡尔积的方式组合出"百米跨栏 × 沙地/硬地 × 逆风/顺风"这样的复合比赛项目,让每个算法在不同的难度组合下接受考验。make_data() 就像食材采购部,会根据项目需求准备稠密或稀疏、合成或真实的数据集;make_estimator() 则是装备配置师,按参数组合为每位选手组装专属的"武器装备";make_scorers() 则是评分裁判团,用准确率、F1、R² 等多维度指标为选手打分。setup_cache() 是备餐间,借助 joblib.Memory 把重复使用的食材缓存起来,避免每次比赛都从零开始"重新做饭"。skip() 则像体检筛查员,会识别选手身体不兼容的项目组合(如稀疏数据 + 不支持稀疏的求解器),提前劝退避免无效比赛。timeout 是比赛时间上限,防止某些马拉松选手跑到天黑都完不了赛。track_* 方法除了计时还能记录精度等"含金量指标",如同赛场的记分牌。
就像全运会需要分项(径赛、田赛、游泳)、分级(成年组、青年组)、分赛道(室内、室外),scikit-learn 的无监督基准也是通过 param_names 多个维度的笛卡尔积——数据表示 × 算法 × 初始化方式,把每个算法"掰开揉碎"地量化比较。
80.3 源码地图
本节我们通过源码地图梳理本章涉及的三大无监督基准模块及其核心类结构,帮助读者快速定位关键代码位置。
asv_benchmarks/benchmarks/cluster.py
├── class KMeansBenchmark(Predictor, Transformer, Estimator, Benchmark)
│ ├── param_names = ["representation", "algorithm", "init"]
│ ├── params = (["dense", "sparse"], ["lloyd", "elkan"], ["random", "k-means++"])
│ ├── setup_cache() # 调用父类方法,保持缓存机制
│ ├── make_data() # sparse 用 20newsgroups_highdim,dense 用 _blobs_dataset(n_clusters=20)
│ ├── make_estimator() # n_clusters=20, n_init=1, max_iter 动态调整(sparse:30, dense:100)
│ └── make_scorers() # 使用 neg_mean_inertia 评估聚类惯性
asv_benchmarks/benchmarks/cluster.py
├── class MiniBatchKMeansBenchmark(Predictor, Transformer, Estimator, Benchmark)
│ ├── param_names = ["representation", "init"]
│ ├── params = (["dense", "sparse"], ["random", "k-means++"])
│ ├── setup_cache() # 调用父类方法,保持缓存机制
│ ├── make_data() # 同 KMeansBenchmark 的数据选择策略
│ ├── make_estimator() # batch_size=1000, max_iter 动态调整(sparse:5, dense:2)
│ └── make_scorers() # 使用 neg_mean_inertia 评估聚类惯性
asv_benchmarks/benchmarks/decomposition.py
├── class PCABenchmark(Transformer, Estimator, Benchmark)
│ ├── param_names = ["svd_solver"]
│ ├── params = (["full", "arpack", "randomized"],)
│ ├── setup_cache() # 调用父类方法,保持缓存机制
│ ├── make_data() # MNIST 数据集
│ ├── make_estimator() # PCA(n_components=32, svd_solver=svd_solver, random_state=0)
│ └── make_scorers() # 调用 make_pca_scorers(用于重构误差)
asv_benchmarks/benchmarks/decomposition.py
├── class DictionaryLearningBenchmark(Transformer, Estimator, Benchmark)
│ ├── param_names = ["fit_algorithm", "n_jobs"]
│ ├── params = (["lars", "cd"], Benchmark.n_jobs_vals)
│ ├── setup_cache() # 调用父类方法,保持缓存机制
│ ├── make_data() # Olivetti 人脸数据集
│ ├── make_estimator() # DictionaryLearning(n_components=15, alpha=0.1, max_iter=20)
│ └── make_scorers() # 调用 make_dict_learning_scorers(重构误差)
asv_benchmarks/benchmarks/decomposition.py
├── class MiniBatchDictionaryLearningBenchmark(Transformer, Estimator, Benchmark)
│ ├── param_names = ["fit_algorithm", "n_jobs"]
│ ├── params = (["lars", "cd"], Benchmark.n_jobs_vals)
│ ├── setup_cache() # 调用父类方法,保持缓存机制
│ ├── make_data() # Olivetti 人脸数据集
│ ├── make_estimator() # MiniBatchDictionaryLearning(batch_size=3)
│ └── make_scorers() # 调用 make_dict_learning_scorers(重构误差)
asv_benchmarks/benchmarks/manifold.py
├── class TSNEBenchmark(Estimator, Benchmark)
│ ├── timeout = 180000 # 防止 t-SNE 大数据集超时(3分钟)
│ ├── param_names = ["method"]
│ ├── params = (["exact", "barnes_hut"],)
│ ├── setup_cache() # 调用父类方法,保持缓存机制
│ ├── make_data() # exact 用 500 样本 digits,barnes_hut 用完整 digits
│ ├── make_estimator() # TSNE(method=method)
│ └── make_scorers() # 通过 kl_divergence_ 内部属性获取 KL 散度
80.4 聚类基准 —— 无监督学习的"速度与质量双面镜"
聚类是无监督学习的代表性任务,KMeans 及其 MiniBatch 变体是 scikit-learn 中应用最广的聚类算法。本节我们聚焦 cluster.py 中的两个核心基准类,它们通过参数网格的设计把 KMeans 的核心可调维度(数据表示、初始化策略、求解算法、批大小)一网打尽。
80.4.1 KMeansBenchmark:经典聚类的多维性能评估
KMeansBenchmark 是聚类基准的开篇之作。它通过三维参数网格(representation × algorithm × init),把 KMeans 在不同数据表示、底层算法与初始化策略下的性能差异完整呈现。我们先看它的核心结构与数据准备逻辑,理解它如何通过参数网格控制变量,让聚类性能的比较尽可能公平。
源码路径:asv_benchmarks/benchmarks/cluster.py - KMeansBenchmark
class KMeansBenchmark(Predictor, Transformer, Estimator, Benchmark):
"""
Benchmarks for KMeans.
"""
# 参数网格:数据表示 × 求解算法 × 初始化方式
param_names = ["representation", "algorithm", "init"]
params = (
["dense", "sparse"], # 数据表示:稠密 vs 稀疏
["lloyd", "elkan"], # 求解算法:Lloyd 迭代 vs Elkan 加速
["random", "k-means++"], # 初始化方式:随机 vs K-means++ 智能初始化
)
# 调用父类 setup_cache,确保数据准备与估计器创建被 joblib 缓存
def setup_cache(self):
super().setup_cache()
# 根据 representation 选择数据集(稀疏用高维文本,稠密用合成团簇)
def make_data(self, params):
representation, algorithm, init = params
if representation == "sparse":
# 稀疏路径:用 20 Newsgroups 高维稀疏文本(8000 样本)
data = _20newsgroups_highdim_dataset(n_samples=8000)
else:
# 稠密路径:用合成团簇数据(20 个聚类中心)
data = _blobs_dataset(n_clusters=20)
return data
# 根据数据表示动态调整 max_iter(稀疏数据需要更少迭代)
def make_estimator(self, params):
representation, algorithm, init = params
# 稀疏数据维度高、收敛慢,给 30 次迭代;稠密数据给 100 次跑满
max_iter = 30 if representation == "sparse" else 100
estimator = KMeans(
n_clusters=20, # 与 _blobs_dataset 的 n_clusters 对齐
algorithm=algorithm, # 'lloyd' 或 'elkan'
init=init, # 'random' 或 'k-means++'
n_init=1, # 关闭多次初始化,确保可比性
max_iter=max_iter,
tol=0, # 关闭收敛容忍度,强制跑满 max_iter
random_state=0, # 固定随机种子,保证可复现
)
return estimator
这段代码定义/展示了 KMeansBenchmark 的核心结构。它通过 param_names 与 params 的三维笛卡尔积,把数据表示(dense/sparse)、求解算法(lloyd/elkan)、初始化方式(random/k-means++)共 2×2×2=8 种参数组合一网打尽。make_data 根据 representation 动态选择数据集,让 benchmark 既能考察稀疏文本场景也能考察稠密团簇场景——稀疏路径调用 _20newsgroups_highdim_dataset 加载 8000 个高维文本样本,稠密路径调用 _blobs_dataset 生成 20 个聚类中心的合成数据。make_estimator 动态调整 max_iter(稀疏 30 次、稠密 100 次),并固定 n_init=1 与 random_state=0,确保每次运行都是可控、可复现的"单次独立实验"。tol=0 关闭收敛容忍度,强制算法跑满 max_iter,让计时结果反映完整的迭代开销。
接下来看 make_scorers 方法,它为聚类定义了专门的评估指标——惯性(inertia),从而把"速度"与"质量"两个维度都纳入评估:
源码路径:asv_benchmarks/benchmarks/cluster.py - KMeansBenchmark.make_scorers()
def make_scorers(self):
# 训练集评分:用训练数据预测并计算负惯性(越小越好)
self.train_scorer = lambda _, __: neg_mean_inertia(
self.X, # 训练特征
self.estimator.predict(self.X), # 聚类预测标签
self.estimator.cluster_centers_, # 聚类中心
)
# 验证集评分:评估泛化能力
self.test_scorer = lambda _, __: neg_mean_inertia(
self.X_val,
self.estimator.predict(self.X_val),
self.estimator.cluster_centers_,
)
这段代码定义/展示了 KMeans 的双维度评分机制。惯性(inertia)是样本到其最近聚类中心的距离平方和,是 KMeans 的原生目标函数;通过取负值,scikit-learn 把"越小越好"的惯性转化为"越大越好"的分数,与 ASV 的 track_* 方法保持一致的语义方向。train_scorer 与 test_scorer 分别评估训练集与验证集上的聚类质量,让基准不仅测量速度,还能诊断过拟合。这里的 neg_mean_inertia 是一个工具函数(在 utils 中定义),它接收特征、预测标签与聚类中心三个参数,返回负的平均惯性。
80.4.2 MiniBatchKMeansBenchmark:批次聚类的扩展性能验证
MiniBatchKMeans 是 KMeans 的大数据扩展版本,它的核心可调参数是 batch_size。下面我们看它的参数网格与配置:
源码路径:asv_benchmarks/benchmarks/cluster.py - MiniBatchKMeansBenchmark
class MiniBatchKMeansBenchmark(Predictor, Transformer, Estimator, Benchmark):
"""
Benchmarks for MiniBatchKMeans.
"""
# 参数网格:数据表示 × 初始化方式(不含 algorithm,因为 MiniBatch 算法内部固定)
param_names = ["representation", "init"]
params = (
["dense", "sparse"],
["random", "k-means++"],
)
def setup_cache(self):
super().setup_cache()
# 与 KMeansBenchmark 保持一致的数据选择策略
def make_data(self, params):
representation, init = params
if representation == "sparse":
data = _20newsgroups_highdim_dataset()
else:
data = _blobs_dataset(n_clusters=20)
return data
# 关键区别:batch_size=1000,且 max_iter 更小(2 或 5)
def make_estimator(self, params):
representation, init = params
# 稀疏数据维度高,需要更多次迭代来收敛
max_iter = 5 if representation == "sparse" else 2
estimator = MiniBatchKMeans(
n_clusters=20,
init=init,
n_init=1,
max_iter=max_iter,
batch_size=1000, # 每批 1000 样本
max_no_improvement=None, # 禁用早停
compute_labels=False, # 关闭标签计算,加速训练
random_state=0,
)
return estimator
def make_scorers(self):
# 与 KMeansBenchmark 一致的评估指标
self.train_scorer = lambda _, __: neg_mean_inertia(
self.X, self.estimator.predict(self.X), self.estimator.cluster_centers_
)
self.test_scorer = lambda _, __: neg_mean_inertia(
self.X_val,
self.estimator.predict(self.X_val),
self.estimator.cluster_centers_,
)
这段代码定义/展示了 MiniBatchKMeansBenchmark 的核心差异点。它与 KMeansBenchmark 共享相同的数据选择与评分策略,但引入了 batch_size=1000 的批次训练机制,并通过 max_no_improvement=None 禁用早停、compute_labels=False 关闭标签计算,确保每个 max_iter 周期都是"完整跑完"的状态,从而让计时更加公平可比。max_iter 仅设置为 2(稠密)或 5(稀疏),是因为 MiniBatch 一次迭代已经相当于 KMeans 的多步更新。注意这里去掉了 algorithm 参数维度,因为 MiniBatchKMeans 内部固定使用 MiniBatch 增量算法,不存在 lloyd/elkan 之分。
80.4.3 聚类基准的数据流图
下图展示了 KMeansBenchmark 在 ASV 框架下的完整执行流程,从配置读取到计时与评分的每一步都清晰可见:
80.5 维度降低基准 —— 特征提取的"效率竞技场"
降维是无监督学习的另一支柱。本节聚焦 decomposition.py 中的三个核心基准类:PCABenchmark、DictionaryLearningBenchmark 与 MiniBatchDictionaryLearningBenchmark。它们通过参数网格分别考察 SVD 求解器、字典学习算法与并行度对训练效率的影响。
80.5.1 PCABenchmark:主成分分析的求解器对比评估
PCA 是最经典的线性降维方法,其核心可调参数是 svd_solver。我们看 PCABenchmark 如何通过参数网格让三种求解器同台竞技:
源码路径:asv_benchmarks/benchmarks/decomposition.py - PCABenchmark
class PCABenchmark(Transformer, Estimator, Benchmark):
"""
Benchmarks for PCA.
"""
# 单一参数:SVD 求解器
param_names = ["svd_solver"]
params = (["full", "arpack", "randomized"],)
def setup_cache(self):
super().setup_cache()
# 加载 MNIST 数据集(70000×784 像素图像)
def make_data(self, params):
return _mnist_dataset()
# 实例化 PCA,固定 n_components=32
def make_estimator(self, params):
(svd_solver,) = params
estimator = PCA(
n_components=32, # 降到 32 维
svd_solver=svd_solver, # full/arpack/randomized
random_state=0,
)
return estimator
# 使用 PCA 专用评分器(重构误差)
def make_scorers(self):
make_pca_scorers(self)
这段代码定义/展示了 PCABenchmark 的极简参数网格设计。它只考察一个维度——svd_solver 在 {full, arpack, randomized} 三种取值下的性能差异。full 通过完整 SVD 分解得到精确解,arpack 通过截断迭代求解少量奇异值,randomized 通过随机投影加速大规模数据。三种求解器在 MNIST(70000×784)上的性能差异恰好体现了"精度-速度"权衡的不同档位。固定 n_components=32 让所有求解器都聚焦于相同的降维目标,避免降维维度本身成为干扰变量。make_pca_scorers 是一个外部工具函数(在 utils 中定义),它会自动给 self 注入 train_scorer 与 test_scorer,用于衡量降维后重构原数据的能力——通常通过计算 PCA.inverse_transform 后与原始数据的均方误差来量化信息损失程度。
80.5.2 DictionaryLearningBenchmark:字典学习的训练效率与稀疏度控制
字典学习是稀疏表示学习的代表,它通过学习一组"基向量"(atoms)来重建输入信号。我们看它如何通过 fit_algorithm 与 n_jobs 控制训练过程:
源码路径:asv_benchmarks/benchmarks/decomposition.py - DictionaryLearningBenchmark
class DictionaryLearningBenchmark(Transformer, Estimator, Benchmark):
"""
Benchmarks for DictionaryLearning.
"""
# 参数网格:字典学习算法 × 并行度
param_names = ["fit_algorithm", "n_jobs"]
params = (
["lars", "cd"], # 最小角回归 vs 坐标下降
Benchmark.n_jobs_vals, # 并行度(1, 2, 4, 8 等)
)
def setup_cache(self):
super().setup_cache()
# 使用 Olivetti 人脸数据集(适合字典学习的稀疏结构)
def make_data(self, params):
return _olivetti_faces_dataset()
# 实例化字典学习,固定字典大小=15
def make_estimator(self, params):
fit_algorithm, n_jobs = params
estimator = DictionaryLearning(
n_components=15, # 字典大小:15 个原子
fit_algorithm=fit_algorithm, # 'lars' 或 'cd'
alpha=0.1, # 稀疏度惩罚系数
transform_alpha=1, # 编码阶段的稀疏度
max_iter=20, # 训练迭代次数
tol=1e-16, # 极严格收敛容忍度
random_state=0,
n_jobs=n_jobs, # 并行度
)
return estimator
# 使用字典学习专用评分器(重构误差)
def make_scorers(self):
make_dict_learning_scorers(self)
这段代码定义/展示了 DictionaryLearningBenchmark 的双重参数网格设计。它通过 fit_algorithm(lars/cd)× n_jobs 的笛卡尔积,同时考察求解算法与并行化两个维度。Olivetti 人脸数据集(400 张 64×64 灰度图像)天然适合字典学习任务,因为人脸图像本身就能用少量基向量稀疏重建。alpha=0.1 与 transform_alpha=1 控制了字典训练与编码两个阶段的稀疏度,tol=1e-16 则强制算法跑满 max_iter=20 个周期。make_dict_learning_scorers 同样是一个外部工具函数(在 utils 中定义),它会注入用于衡量字典重构能力的评分器——通过 transform 阶段对训练与验证数据的稀疏编码结果进行解码,计算其与原始信号之间的误差,从而评估学习到的字典对信号的重建质量。
80.5.3 MiniBatchDictionaryLearningBenchmark:批次字典学习的大规模训练验证
MiniBatch 版本引入了 batch_size 参数,让字典学习能扩展到更大规模数据。我们看它与全量版本的关键差异:
源码路径:asv_benchmarks/benchmarks/decomposition.py - MiniBatchDictionaryLearningBenchmark
class MiniBatchDictionaryLearningBenchmark(Transformer, Estimator, Benchmark):
"""
Benchmarks for MiniBatchDictionaryLearning
"""
# 与全量版本相同的参数网格
param_names = ["fit_algorithm", "n_jobs"]
params = (
["lars", "cd"],
Benchmark.n_jobs_vals,
)
def setup_cache(self):
super().setup_cache()
# 相同的数据集
def make_data(self, params):
return _olivetti_faces_dataset()
# 关键区别:使用 MiniBatch 变体 + batch_size=3
def make_estimator(self, params):
fit_algorithm, n_jobs = params
estimator = MiniBatchDictionaryLearning(
n_components=15,
fit_algorithm=fit_algorithm,
alpha=0.1,
batch_size=3, # 每批 3 个样本(极小,体现增量特性)
random_state=0,
n_jobs=n_jobs,
)
return estimator
def make_scorers(self):
make_dict_learning_scorers(self)
这段代码定义/展示了 MiniBatch 字典学习与全量版本的关键差异。它使用 MiniBatchDictionaryLearning 替代 DictionaryLearning,并新增 batch_size=3 参数。注意 batch_size=3 极小,这是因为 Olivetti 数据集只有 400 张图像;在大规模场景下 batch_size 会显著影响训练效率。它没有设置 max_iter,因为 MiniBatch 通过 batch 数量隐式控制训练时长。

浙公网安备 33010602011771号