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

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

  • 学会使用离散化技术为线性模型引入非线性能力

  • 比较过滤式、包裹式和嵌入式特征选择方法的优缺点

  • 理解目标编码的机制及其交叉拟合策略如何防止过拟合

  • 能够使用 Pipeline 和 ColumnTransformer 构建无数据泄露的预处理流水线

74.2 生活类比

想象数据预处理是一场“食材标准化”的厨房革命:特征缩放就像调料称量器,StandardScaler 是万能电子秤(去均值 + 单位方差),对极端值敏感;RobustScaler 是带防误触的台秤(中位数 + IQR),离群点按不动指针;MinMaxScaler/MaxAbsScaler 是固定量程的量杯,把食材压进 [0,1];QuantileTransformer 则是全自动料理机,把任意食材打成均匀或高斯糊。离散化分箱是食材切丁机:uniform 等宽切块、quantile 等数量切丁、kmeans 按天然纹理切块,把连续调料颗粒切成有限粒度,让线性厨师也能做出非线性风味。特征选择是菜单精简术:F 检验/MI 按营养成分打分,剔除垃圾食品;RFE 不断试菜让顾客淘汰最难吃的(逐步剔除最弱特征);SelectFromModel 则是主厨投票:保留获票最多的招牌菜。目标编码是隐藏菜谱的秘密配料:把高频类别翻译成“对目标贡献度”,避免一锅端造成过拟合;交叉拟合 = 试菜时不让主厨偷看最后一道菜的评价。

74.3 源码地图

examples/preprocessing/

├── plot_all_scaling.py

│ ├── main

│ │ ├── 加载 California Housing 数据并提取 MedInc/AveOccup 两条特征

│ │ ├── 构造 10 种缩放变换结果 (Unscaled/Standard/MinMax/MaxAbs/Robust/Power-YJ/Power-BC/Quantile-Uniform/Quantile-Normal/Normalizer)

│ │ ├── 调用 make_plot(item_idx) 循环绘制各变换效果

│ ├── create_axes(title, figsize)

│ │ ├── 构建散点图 + 顶部/右侧直方图 + 颜色条的复合 Axes 布局

│ ├── plot_distribution(axes, X, y, ...)

│ │ ├── 绘制散点图(颜色映射 y)与特征边缘直方图

│ ├── make_plot(item_idx)

│ │ ├── 从 distributions 列表中取数据,调用 create_axes 与 plot_distribution 绘制全局与缩放后的对比图

├── plot_scaling_importance.py

│ ├── main

│ │ ├── 加载 Wine 数据集并划分训练/测试集

│ │ ├── 调用 fit_and_plot_model 对比 KNN 在原始/缩放特征上的决策边界

│ │ ├── 训练两套 PCA(缩放/未缩放),对比第一主成分的权重分布

│ │ ├── 通过 LogisticRegressionCV 评估缩放对正则化强度与准确率的影响

│ ├── fit_and_plot_model(X_plot, y, clf, ax)

│ │ ├── 拟合 KNN 并通过 DecisionBoundaryDisplay 绘制决策区域

├── plot_discretization.py

│ ├── main

│ │ ├── 构造 sin(X) + 噪声的回归数据

│ │ ├── 使用 KBinsDiscretizer(n_bins=10, encode='onehot') 进行分箱

│ │ ├── 对比 LinearRegression 与 DecisionTreeRegressor 在分箱前/后的预测曲线

├── plot_discretization_strategies.py

│ ├── main

│ │ ├── 生成均匀/团簇/双团簇三种 2D 合成数据集

│ │ ├── 遍历 uniform/quantile/kmeans 三种 strategy 绘制分箱等高线

│ │ ├── get_name(estimator)

│ │ │ ├── 递归解析 Pipeline 结构生成可读名称

├── plot_discretization_classification.py

│ ├── main

│ │ ├── 构造 moons/circles/线性可分三种二分类数据集

│ │ ├── 定义 6 个分类器(含 KBins+LR/LinearSVC)并用 GridSearchCV 调参

│ │ ├── 在网格上绘制决策边界并打印测试准确率

examples/feature_selection/

├── plot_feature_selection.py

│ ├── main

│ │ ├── 向 Iris 数据追加 20 维噪声特征

│ │ ├── 使用 SelectKBest(f_classif, k=4) 选择显著特征

│ │ ├── 对比 MinMaxScaler+LinearSVC 在选择前/后的分类准确率与系数分布

├── plot_feature_selection_pipeline.py

│ ├── main

│ │ ├── 构造 20 维含 3 个信息特征的二分类数据

│ │ ├── 构建 SelectKBest + LinearSVC 的 Pipeline

│ │ ├── 通过 inverse_transform 将分类器系数映射回原始特征空间

├── plot_f_test_vs_mi.py

│ ├── main

│ │ ├── 构造 y = x1 + sin(6πx2) + 噪声 的回归任务

│ │ ├── 对比 f_regression 与 mutual_info_regression 的归一化得分

├── plot_rfe_digits.py

│ ├── main

│ │ ├── 加载 Digits 数据集并展平为 64 维

│ │ ├── 使用 Pipeline(MinMaxScaler + RFE(LogisticRegression, n_features_to_select=1))

│ │ ├── 将特征排名矩阵可视化为热力图

├── plot_rfe_with_cross_validation.py

│ ├── main

│ │ ├── 构造含 3 个信息特征、2 个冗余特征的 8 分类数据

│ │ ├── 使用 RFECV(estimator=LogisticRegression, cv=StratifiedKFold(5))

│ │ ├── 绘制 n_features vs mean_test_score 误差棒曲线

│ │ ├── 逐折打印被选特征的稳定性

├── plot_select_from_model_diabetes.py

│ ├── main

│ │ ├── 加载 Diabetes 数据集并用 RidgeCV 拟合得到系数重要性

│ │ ├── 使用 SelectFromModel 按阈值选择 Top-2 特征

│ │ ├── 对比 SequentialFeatureSelector 的 forward/backward 搜索结果

│ │ ├── 在 Breast Cancer 数据上扫描 negative tol 对选择特征数的影响

examples/preprocessing/

├── plot_target_encoder.py

│ ├── evaluate_model_and_store(name, pipe)

│ │ ├── cross_validate 评估管道并存储 RMSE 均值/方差

│ ├── main

│ │ ├── 加载 OpenML 红酒评分数据

│ │ ├── 对比 drop/Ordinal/OneHot/Target/Mixed 五种编码方案在 HistGBR 上的表现

│ │ ├── 绘制测试/训练 RMSE 对比柱状图

├── plot_target_encoder_cross_val.py

│ ├── main

│ │ ├── 构造含 informative/shuffled/near_unique 三类特征的合成回归数据

│ │ ├── 对比 Ridge 在原始数据、TargetEncoder+CF、TargetEncoder-(无 CF)三种输入下的泛化能力

│ │ ├── 绘制系数水平柱状图揭示无 CF 时高基数特征的过拟合

└── plot_map_data_to_normal.py

├── main

│ ├── 生成 Lognormal/Chi-squared/Weibull/Gaussian/Uniform/Bimodal 六种分布

│ ├── 应用 PowerTransformer(box-cox/yeo-johnson) 与 QuantileTransformer(normal)

│ ├── 绘制原始与三种变换后的直方图网格

74.4 特征缩放全景 —— 数据分布的“标尺变换器”

先来看看最基础的预处理手段——特征缩放。不同的缩放器在数值上虽然看似简单(无非是平移+伸缩),但它们对数据分布的敏感度却大不相同,这直接影响后续模型能否学到有用的模式。

74.4.1 线性仿射族:均值/极值/绝对值的“标尺切换”

先看 StandardScaler。它的职责非常明确:去均值、除以标准差,让每个特征落在零均值、单位方差的状态。下面是它在 examples/preprocessing/plot_all_scaling.py 中的实现片段(第 60-130 行会循环展示十种变换,这里我们聚焦标准缩放):

源码路径:examples/preprocessing/plot_all_scaling.py - 标准缩放数据构造(第60-130行)

# 第 74 章 —— examples/preprocessing/plot_all_scaling.py (60-130 摘录)
distributions = [
    ("Unscaled data", X),
    ("Data after standard scaling", StandardScaler().fit_transform(X)),
    ("Data after min-max scaling", MinMaxScaler().fit_transform(X)),
    # ... 其余省略
]

这段代码定义了展示用的数据列表:第一项是原始数据,第二项就是 StandardScaler 处理后的结果。我们可以看到,它直接调用了 fit_transform,内部会先计算均值和标准差,再做 (X - mean) / std。StandardScaler 因此对均值和方差极为敏感——如果数据里有极端离群点,它们会拉大标准差,导致大多数普通点被压缩到一个很小的区间内。

相比之下,MinMaxScaler 和 MaxAbsScaler 的思路是把数据线性压入一个固定区间。前者是 [0, 1],后者则是 [-1, 1](看符号而定):

源码路径:examples/preprocessing/plot_all_scaling.py - MinMaxScaler 和 MaxAbsScaler 应用(第60-130行)

# 第 74 章 —— 同上文件
("Data after min-max scaling", MinMaxScaler().fit_transform(X)),
("Data after max-abs scaling", MaxAbsScaler().fit_transform(X)),

它们同样会把离群点当作“尺子”的两端,导致绝大多数正常点被挤到区间的一端。例如在 California Housing 数据集中,AveOccup 特征因为存在少数平均户籍超过 1200 的离群点,被 MinMaxScaler 压缩后,绝大多数样本都集中在 [0, 0.005] 这个极窄的范围内——这显然不是我们想要的“尺度”。

于是登场的就是 RobustScaler。它不用均值和标准差,而是用中位数和四分位距(IQR):

源码路径:examples/preprocessing/plot_all_scaling.py - RobustScaler 应用(第60-130行)

("Data after robust scaling", RobustScaler(quantile_range=(25, 75)).fit_transform(X)),

这使得它对离群点天生“不感兴趣”:即使 AveOccup 有几个值飙到 1200,中位数和 IQR 仍然由绝大多数样本决定,因此变换后的两个特征(MedInc 和 AveOccup)终于能落在类似的 [-2, 3] 区间里,为后续模型提供了公平的竞技场。

74.4.1.1 生活类比(续)

想象你在称量菜谱中的糖和盐:如果用普通电子秤(StandardScaler),一粒巨大的糖晶(离群点)会让秤误以为整袋糖都很重,从而把正常的盐称得太轻;但如果换成防误触台秤(RobustScaler),它只看中间那堆糖和盐的中间值,一粒巨糖晶翻不过来,称得就准多了。

74.4.2 非线性映射族:从高斯化到分位数强制分布

线性缩放解决了尺度不一致的问题,但如果数据本身是严重偏态的(比如收入总是长尾分布),单纯去均值除以方差可能仍然让模型难以捕捉模式。此时我们就需要更激进的手段——让数据本身改变形状。

PowerTransformer 做的事情是通过参数化的幂变换(Box-Cox 或 Yeo-Johnson)寻找一个 λ 参数,使变换后的数据尽可能接近高斯分布。它会使用最大似然估计(MLE)来自动选 λ:

源码路径:examples/preprocessing/plot_all_scaling.py - PowerTransformer 应用(第60-130行)

(
    "Data after power transformation (Yeo-Johnson)",
    PowerTransformer(method="yeo-johnson").fit_transform(X),
),
(
    "Data after power transformation (Box-Cox)",
    PowerTransformer(method="box-cox").fit_transform(X),
),

这里需要注意的是,Box-Cox 只能处理严格正数,而 Yeo-Johnson 则能容纳零和负数——这也是为什么在后面的脚本中我们经常看到 yeo-johnson 被作为默认选择。

再进一步,如果我们不仅想要“接近高斯”,而是强烈希望数据恰好落在均匀分布或正态分布上,那就要用 QuantileTransformer 了。它的思路非常直接:先计算经验累积分布函数(ECDF),再把它映射到目标分布上。如果目标是 uniform,那就相当于做了百分位排名;如果是 normal,那就相当于做了逆正态变换:

源码路径:examples/preprocessing/plot_all_scaling.py - QuantileTransformer 应用(第60-130行)

(
    "Data after quantile transformation (uniform pdf)",
    QuantileTransformer(
        output_distribution="uniform", random_state=42
    ).fit_transform(X),
),
(
    "Data after quantile transformation (gaussian pdf)",
    QuantileTransformer(
        output_distribution="normal", random_state=42
    ).fit_transform(X),
),

因为它是基于排序的非参数方法,QuantileTransformer 天生对离群点不敏感——无论多极端的值,都会被压到 [0,1] 或对应的正态分布尾部。但正是这一点,也让它在小样本上容易过拟合:如果样本太少,分位数估计会不稳定,反而可能把噪声当成真实分布。

最后补充一下 Normalizer。它和前面那些完全不一样:不是按特征缩放,而是按样本(即每一行)做 L2 归一化,让每个样本向量的长度变为 1。这在文本或图像向量场景很常见,因为我们往往更关心样本之间的方向而非长度:

源码路径:examples/preprocessing/plot_all_scaling.py - Normalizer 应用(第60-130行)

("Data after sample-wise L2 normalizing", Normalizer().fit_transform(X)),

74.4.2.1 生活类比(续)

设想你有一堆奇形怪状的蔬菜:有些像胡萝卜又长又细,有些像土豆又圆又胖。PowerTransformer 就像一个智能切菜机,它能自动调整刀角和力度,把胡萝卜切成圆柱、土豆切成丁,让它们看起来更“均匀”;QuantileTransformer 则像一个严格的分丁机——不管输入是细长的豆角还是圆滚滚的洋葱,它都强制把每根蔬菜切成完全一样长度的小段,哪怕要牺牲一些真实形状;而 Normalizer 则像一个定长串肉机:不管蔬菜块多胖或多瘦,每串肉的总长度必须恰好是 20 厘米,只调整方向不改总长。

74.4.3 缩放对下游模型的关键影生

光看变换前后的散点图可能还不够直观。我们接着看 examples/preprocessing/plot_scaling_importance.py,它用 KNN 和 PCA 来说明缩放为何如此重要。

在 KNN 部分,脚本故意挑选了两个量级相差悬殊的特征:proline(范围 0~1000)和 hue(范围 1~10)。未缩放时,KNN 基于欧氏距离的邻居判断几乎完全被 proline 主导,因为它的数值差异能轻易盖过 hue 的贡献。决策边界因此变得扭曲——模子里其实只有 proline 在起作用。但一旦用 StandardScaler 把两个特征都标准化后,它们的数值都落在了大约 [-3, 3] 的范围,距离计算才能真正兼顾两个特征的信息。

源码路径:examples/preprocessing/plot_scaling_importance.py - KNN 决策边界绘制函数

# 第 74 章 —— examples/preprocessing/plot_scaling_importance.py
X_plot = X[["proline", "hue"]]
X_plot_scaled = scaler.fit_transform(X_plot)
clf = KNeighborsClassifier(n_neighbors=20)

def fit_and_plot_model(X_plot, y, clf, ax):
    clf.fit(X_plot, y)
    disp = DecisionBoundaryDisplay.from_estimator(
        clf,
        X_plot,
        response_method="predict",
        alpha=0.5,
        ax=ax,
    )
    disp.ax_.scatter(X_plot["proline"], X_plot["hue"], c=y, s=20, edgecolor="k")
    return disp.ax_

fit_and_plot_model(X_plot, y, clf, ax1)  # 未缩放
fit_and_plot_model(X_plot_scaled, y, clf, ax2)  # 已缩放

PCA 的情况则更能说明缩放对无监督学习的影响。PCA 寻找的是方差最大的方向——如果某个特征只是因为数值大而方差大,PCA 就会误以为它是主成分。在未缩放的数据中,proline 几乎独占了第一主成分;但缩放后,所有特征的贡献才趋于均衡,这也直接导致了后续 LogisticRegression 在 PCA 降维后的表现提升:因为特征不再被某一维“暴政”,正则化能更均匀地发挥作用,测试准确率和对数损失都随之改善。

74.4.3.1 生活类比(续)

想象你在做菜时需要同时尝甜度(proline,范围0-1000)和辣度(hue,范围1-10)。如果不做标准化,你的一勺糖(比如500)会完全盖过一滴辣椒油(比如5),你尝出来基本只有甜;但如果把两者都映射到 [-3,3] 区间,那么糖和辣的贡献才能被公平比较——这时候你才真能尝出菜是否“甜辣平衡”。PCA 就像一个自动配料机:如果不标准化,它会认为甜度才是主要成分,把辣度当噪声忽略;标准化后,它才能平均分配注意力,真正捕捉到甜和辣的平衡点。

74.4.4 可视化与自定义绘图工具

这些结论离不开示例中精心设计的可视化代码。比如 create_axes 函数不仅画了散点图,还在两侧加了直方图、右侧加了颜色条,形成“一图多看”的布局:

源码路径:examples/preprocessing/plot_all_scaling.py - create_axes 函数定义

# 第 74 章 —— examples/preprocessing/plot_all_scaling.py
def create_axes(title, figsize=(16, 6)):
    fig = plt.figure(figsize=figsize)
    fig.suptitle(title)

    # 左图:全量数据
    left, width = 0.1, 0.22
    bottom, height = 0.1, 0.7
    bottom_h = height + 0.15
    left_h = left + width + 0.02
    rect_scatter = [left, bottom, width, height]
    rect_histx = [left, bottom_h, width, 0.1]
    rect_histy = [left_h, bottom, 0.05, height]
    ax_scatter = plt.axes(rect_scatter)
    ax_histx = plt.axes(rect_histx)
    ax_histy = plt.axes(rect_histy)

    # 右图:缩放后的局部放大(剔除极端值)
    # ... 省略
    # 底部:颜色条
    left, width = width + left + 0.13, 0.01
    rect_colorbar = [left, bottom, width, height]
    ax_colorbar = plt.axes(rect_colorbar)

    return (
        (ax_scatter, ax_histy, ax_histx),
        (ax_scatter_zoom, ax_histy_zoom, ax_histx_zoom),
        ax_colorbar,
    )

make_plot 才是真正的“调度员”:它根据索引取出对应的变换数据,调用 create_axes 布局画布,再分别绘制全量数据和去除极值后的局部放大图(通过 99% 分位数掩码实现),这样我们就能既看到整体趋势,又不被少数离群点干扰判断。

源码路径:examples/preprocessing/plot_all_scaling.py - make_plot 函数定义

def make_plot(item_idx):
    title, X = distributions[item_idx]
    ax_zoom_out, ax_zoom_in, ax_colorbar = create_axes(title)
    axarr = (ax_zoom_out, ax_zoom_in)
    # 全量图
    plot_distribution(
        axarr[0],
        X,
        y,
        hist_nbins=200,
        x0_label=feature_mapping[features[0]],
        x1_label=feature_mapping[features[1]],
        title="Full data",
    )
    # 局部放大图(去除顶部 1% 极值)
    zoom_in_percentile_range = (0, 99)
    cutoffs_X0 = np.percentile(X[:, 0], zoom_in_percentile_range)
    cutoffs_X1 = np.percentile(X[:, 1], zoom_in_percentile_range)
    non_outliers_mask = np.all(X > [cutoffs_X0[0], cutoffs_X1[0]], axis=1) & np.all(
        X < [cutoffs_X0[1], cutoffs_X1[1]], axis=1
    )
    plot_distribution(
        axarr[1],
        X[non_outliers_mask],
        y[non_outliers_mask],
        hist_nbins=50,
        x0_label=feature_mapping[features[0]],
        x1_label=feature_mapping[features[1]],
        title="Zoom-in",
    )
    # 颜色条
    # ... 省略

74.4.4.1 生活类比(续)

厨房里的可视化工具就像一个多功能料理展台:左边是大锅菜的全景(全量图),你能看到整体的颜色和浓度;右边是放大镜下的局部(去离群点后的 Zoom-in),让你聚焦在主要成分的细节上;两侧还有小碟子分别展示主料和辅料的单独分布(直方图),底部还有一个彩虹色条(颜色条) telling you 哪种颜色对应什么味道强度。这样你就能既看到锅的整体情况,又不会被少数巨块胡萝卜(离群点)骗了眼。

74.4.5 架构图:缩放器选择流程

flowchart TD A[开始特征缩放] --> B{数据是否含离群点?} B -->|是| C{离群点影响程度?} B -->|否| D[使用 StandardScaler] C -->|轻度| E[使用 RobustScaler] C -->|严重| F[使用 QuantileTransformer] D --> G[完成缩放] E --> G F --> G G --> H[结束特征缩放]

74.5 离散化与分箱 —— 连续特征的“阶梯化改造”

如果特征缩放是“把尺子校准”,那么离散化就是“把尺子刻上刻度”——它把连续的数值变成离散的箱子,从而为线性模型引入非线性能力。这一类转换的核心是 KBinsDiscretizer,它提供了三种分箱策略:

  • uniform:等宽分箱, bin 内的范围相同,适合均匀分布数据;

  • quantile:等频分箱,每个箱子尽量包含相同数量的样本,对离群点具有鲁棒性;

  • kmeans:基于 K-Means 聚类中心作为 bin 边界,能够捕捉数据的天然簇结构。

我们先从最直观的回归例子开始——examples/preprocessing/plot_discretization.py。它构造了一个 sin(X) + 噪声的数据集,先看看线性模型和决策树在原始数据上的表现:

源码路径:examples/preprocessing/plot_discretization.py - 数据构造和基础模型拟合

# 第 74 章 —— examples/preprocessing/plot_discretization.py
X = rnd.uniform(-3, 3, size=100).reshape(-1, 1)
y = np.sin(X) + rnd.normal(size=len(X)) / 3
reg = LinearRegression().fit(X, y)
# 第 74 章 —— ... 画出直线
reg = DecisionTreeRegressor().fit(X, y)
# 第 74 章 —— ... 画出能捕捉曲线的树模型

可以看到,LinearRegression 只能拟合一条直线,而 DecisionTreeRegressor 已经能够逼近 sin 曲线。现在我们引入离散化:

源码路径:examples/preprocessing/plot_discretization.py - KBinsDiscretizer 应用和变换

enc = KBinsDiscretizer(
    n_bins=10, encode="onehot", quantile_method="averaged_inverted_cdf"
)
X_binned = enc.fit_transform(X)

这一步把原始的单特征 X 变成了 10 维的 one-hot 编码(每个箱子对应一维)。于是在预测时:

源码路径:examples/preprocessing/plot_discretization.py - 离散后数据的预测

line_binned = enc.transform(line)
reg = LinearRegression().fit(X_binned, y)
ax2.plot(line, reg.predict(line_binned), ...)  # 线性回归在离散后的数据上
reg = DecisionTreeRegressor().fit(X_binned, y)
ax2.plot(line, reg.predict(line_binned), ...)  # 决策树在离散后的数据上

有趣的是,离散后两个模型的预测曲线完全重合——并且都呈阶梯状。这是因为 one-hot 编码让每个箱子内部的特征值成为常数(全 0 或全 1),于是无论是线性模型还是树模型,在该箱子内部只能输出同一个预测值;不同箱子之间的跳变,就形成了我们看到的阶梯效应。

这一技巧对线性模型来说是革命性的:原本只能拟合超平面的 LinearRegression,如今通过分箱+one-hot,能够逼近任意分段常数函数。而对树模型来说,收益却微乎其微——因为树本来就能在任意位置做切分,离散化反而可能限制了它的灵活性(如果箱子太宽,它可能错过更好的分裂点)。

当然,分箱策略的选择会影响最终效果。examples/preprocessing/plot_discretization_strategies.py 在三种人造数据集(均匀、团簇、双团簇)上对比了 uniform、quantile 和 kmeans 三种策略的等高线图。我们可以看到:

  • 在均匀数据上,三种策略结果相近;

  • 在团簇数据上,kmeans 最能贴合簇的边界;

  • 在双团簇数据上,uniform 会出现“跨簇合并”——即一个箱子横跨两个密集区域,而 quantile 和 kmeans 则能更好地避免这种情况,因为它们会根据样本密度或聚类中心来放置边界。

这一 insight 在分类任务中也得到了验证。examples/preprocessing/plot_discretization_classification.py 在 moons 和 circles 这两个经典的非线性可分数据上测试了六种分类器(包括 LogisticRegression、LinearSVC 以及带分箱的管线)。结果显示,仅仅是添加了 KBinsDiscretizer + LogisticRegression,准确率就从大约 50% 提升到了 85%,这已经接近了带 RBF 核的 SVC 水平。

源码路径:examples/preprocessing/plot_discretization_classification.py - 分类器管道定义

# 第 74 章 —— examples/preprocessing/plot_discretization_classification.py
classifiers = [
    # ... 前两个是标准管线
    (
        make_pipeline(
            StandardScaler(),
            KBinsDiscretizer(
                encode="onehot", quantile_method="averaged_inverted_cdf", random_state=0
            ),
            LogisticRegression(random_state=0),
        ),
        {
            "kbinsdiscretizer__n_bins": np.arange(5, 8),
            "logisticregression__C": np.logspace(-1, 1, 3),
        },
    ),
    # ... 其余管线
]

这里的妙处在于,分箱本身是无监督的(它只看 X 不看 y),但一旦变成 one-hot 特征,线性模型就能为每个箱子分配不同的权重——相当于在每个区间上学了一个偏置。于是原本线性的决策函数,在分箱后变成了分段线性的函数,能够逼近更复杂的边界。

最后提一下命名工具。在画决策边界时,我们希望标题能够清晰显示是哪个模型在工作,但 Pipeline 会自动带上前置步骤的名字(如 "StandardScaler + LogisticRegression"),这会让标题变得冗长。于是 get_name 函数应运而生:

源码路径:examples/preprocessing/plot_discretization_strategies.py - get_name 函数定义

# 第 74 章 —— examples/preprocessing/plot_discretization_strategies.py
def get_name(estimator):
    name = estimator.__class__.__name__
    if name == "Pipeline":
        name = [get_name(est[1]) for est in estimator.steps]
        name = " + ".join(name)
    return name

它会递归拆开 Pipeline,只取每一步的估计器名字(跳过像 StandardScaler 这种无参数的转换器),并用 " + " 连接。于是我们看到的标题会是简洁的 "LogisticRegression" 或 "KBinsDiscretizer + LogisticRegression",而不是一长串前缀。

74.5.1.1 生活类比(续)

想象你有一把只能切直线的刀(线性模型),但你想做出波浪形的蔬菜泥(非线性目标)。直接切根本不行。于是你先把胡萝卜放进切丁机(离散化):不管它原本多弯,出来都是一粒粒立方块(one-hot 编码后每个箱子内部是常数)。现在用你的直线刀去切这些方块——虽然每刀还是直的,但因为你能控制走多少块、跳过哪些块,整体上就能拼出波浪形了。切丁机的策略就像不同的刀法:uniform 是等厚切片(不管胡萝卜粗细);quantile 是等数量切段(不管长短,每把出同样多段);kmeans 是看胡萝卜的天然弯曲点来切(比如在自然弯处下刀)。在月牙形和同心圆形的菜谱(moons/circles 数据)上,用切丁机+直线刀就能把本只能做直线的菜变得够复杂,接近以前只能用弯刀(非线性模型)才能做出的效果。而命名工具就像菜单上的说明:以前写“StandardScaler + LogisticRegression”太长,现在简写为“LogisticRegression” 或 “KBinsDiscretizer + LogisticRegression”,一眼就知道是哪套组合在上菜。

74.5.2 架构图:离散化工作流程

flowchart LR A[连续特征输入] --> B{KBinsDiscretizer} B -->|uniform 策略| C[等宽分箱] B -->|quantile 策略| D[等频分箱] B -->|kmeans 策略| E[基于聚类中心分箱] C --> F[one-hot/ordinal 编码] D --> F E --> F F --> G[离散特征输出] G --> H[输入线模型] H --> I[获得非线性能力]

74.6 特征选择 —— 维度压缩的“过滤器与包装器”

当数据维度过高时,即使我们做了缩放和离散化,噪声特征仍然可能掩盖真正的信号。此时我们就需要特征选择——通过去除无关或次要的特征,让模型把注意力集中在真正有预测力的维度上。特征选择大体可以分为三类:过滤式、包裹式和嵌入式,它们各有侧重点和适用场景。

我们从最直接的过滤式开始——examples/feature_selection/plot_feature_selection.py。它在 Iris 数据集上人为追加了 20 维均匀噪声特征(范围 0~0.1),然后用 SelectKBest + f_classif 来挑出得分最高的四个特征:

源码路径:examples/feature_selection/plot_feature_selection.py - 特征选择器构建和拟合

# 第 74 章 —— examples/feature_selection/plot_feature_selection.py
selector = SelectKBest(f_classif, k=4)
selector.fit(X_train, y_train)
scores = -np.log10(selector.pvalues_)
scores /= scores.max()

这里我们看到的不是原始的 p 值,而是它们的 -log10 转换后再归一化的得分,这样数值越高代表特征越显著。画出来的条形图会清晰地显示:只有前四个原始 Iris 特征(萼片长宽、花瓣长宽)得分居高,而后面 20 维噪声特征的得分几乎为零。

为了更直观地看出特征选择的影响,脚本接着训练了两个 LinearSVC 模型——一个用原始特征,一个用选出的四个特征,并对比了它们的系数权重分布:

源码路径:examples/feature_selection/plot_feature_selection.py - 未选特征模型训练和权重计算

# 第 74 章 —— 未选特征
clf = make_pipeline(MinMaxScaler(), LinearSVC())
clf.fit(X_train, y_train)
svm_weights = np.abs(clf[-1].coef_).sum(axis=0)
svm_weights /= svm_weights.sum()

# 第 74 章 —— 已选特征
clf_selected = make_pipeline(SelectKBest(f_classif, k=4), MinMaxScaler(), LinearSVC())
clf_selected.fit(X_train, y_train)
svm_weights_selected = np.abs(clf_selected[-1].coef_).sum(axis=0)
svm_weights_selected /= svm_weights_selected.sum()

可以看到,在未选特征时,虽然真正重要的四个特征还是获得了较高的权重,但有相当一部分权重被“稀释”到了噪声维度上;而在特征选择后,噪声特征的权重被彻底压制,而四个重要特征的权重占比显著提升——这也解释了为什么分类准确率能从未选特征的约 0.83 提升到选特征后的约 0.96。

不过,单变量检验如 f_classif 有一个局限:它只能捕捉线性关系。如果特征和目标之间是非线性依赖(比如 y = x₁ + sin(6πx₂)),f_classif 可能会完全忽略掉那个非线性维度。为了说明这一点,examples/feature_selection/plot_f_test_vs_mi.py 专门构造了这样一个数据集:

源码路径:examples/feature_selection/plot_f_test_vs_mi.py - 数据构造和特征得分计算

# 第 74 章 —— examples/feature_selection/plot_f_test_vs_mi.py
np.random.seed(0)
X = np.random.rand(1000, 3)
y = X[:, 0] + np.sin(6 * np.pi * X[:, 1]) + 0.1 * np.random.randn(1000)

f_test, _ = f_regression(X, y)
f_test /= np.max(f_test)
mi = mutual_info_regression(X, y)
mi /= np.max(mi)
# 第 74 章 —— ... 然后画出三个子图,显示 F-test 和 MI 的得分

这也解释了为什么在实践中,当我们怀疑特征可能有非线性作用时,互信息往往是一个更好的起点——虽然它的计算开销更大,但至少不会把重要的非线性信号漏掉。

过滤式虽然快,但它忽略了特征之间的交互作用。这时候我们就要看包裹式方法了,它们会实际地训练模型来评估特征子集的好坏。最经典的就是 RFE(递归特征消除):它从完整特征集开始,反复训练模型、排除最不重要的特征,直到剩下用户指定的数量。其核心思想很简单:重要特征 = 模型依赖程度高的特征。

examples/feature_selection/plot_rfe_digits.py 中,我们可以看到 RFE 是如何被用于手写数字识别的:

源码路径:examples/feature_selection/plot_rfe_digits.py - RFE 流水线构建和拟合

# 第 74 章 —— examples/feature_selection/plot_rfe_digits.py
pipe = Pipeline(
    [
        ("scaler", MinMaxScaler()),
        ("rfe", RFE(estimator=LogisticRegression(), n_features_to_select=1, step=1)),
    ]
)
pipe.fit(X, y)
ranking = pipe.named_steps["rfe"].ranking_.reshape(digits.images[0].shape)
# 第 74 章 —— ... 然后用热力图可视化排名

这里的设置是一步步剔除(step=1),直到只剩下一个特征(n_features_to_select=1)。于是 ranking_ 数组会给出每个像素的重要性编号:编号越小,说明该特征被保留得越晚(即越重要)。可视化结果非常直观:图像中心的像素普遍排名靠前(编号小),而边缘像素则经常被早早剔除(编号大)——这也符合我们的直觉:数字的笔画主要出现在中间区域。

不过,RFE 有一个问题:它需要用户自己指定要保留多少特征(n_features_to_select)。如果猜错了,要么特征太多带来噪声,要么特征太少导致欠拟合。为了自动化这个过程,scikit-learn 提供了 RFECV——它在 RFE 的基础上嵌入了交叉验证,自动搜索使得 CV 分数最高的特征数。

examples/feature_selection/plot_rfe_with_cross_validation.py 中,我们可以看到它是如何工作的:

源码路径:examples/feature_selection/plot_rfe_with_cross_validation.py - RFECV 构建和拟合

# 第 74 章 —— examples/feature_selection/plot_rfe_with_cross_validation.py
rfecv = RFECV(
    estimator=clf,
    step=1,
    cv=cv,
    scoring="accuracy",
    min_features_to_select=min_features_to_select,
    n_jobs=2,
)
rfecv.fit(X, y)
print(f"Optimal number of features: {rfecv.n_features_}")

它会在每一轮剔除特征后,用交叉验证评估当前特征子集的性能,并把所有折的结果存放在 cv_results_ 中。于是我们就能画出 n_features 与 mean_test_score 的关系曲线,并用误差棒表示波动:

源码路径:examples/feature_selection/plot_rfe_with_cross_validation.py - 交叉验证结果可视化

data = {
    key: value
    for key, value in rfecv.cv_results_.items()
    if key in ["n_features", "mean_test_score", "std_test_score"]
}
cv_results = pd.DataFrame(data)
plt.errorbar(
    x=cv_results["n_features"],
    y=cv_results["mean_test_score"],
    yerr=cv_results["std_test_score"],

在该例中,真实生成模型只有三个信息特征(其余为冗余或噪声),于是曲线在 n_features=3 时达到峰值,之后虽然有轻微的平台(因为冗余特征之间有一定相关性),但总体趋势是:特征太少时欠拟合,特征太多时过拟合。

我们还能进一步检查选择的稳定性——即在每一折交叉验证中,究竟是哪些特征被一直保留下来。脚本就会逐折打印出被选特征名:

源码路径:examples/feature_selection/plot_rfe_with_cross_validation.py - 每折选中特征打印

for i in range(cv.n_splits):
    mask = rfecv.cv_results_[f"split{i}_support"][
        rfecv.n_features_ - 1
    ]
    features_selected = np.ma.compressed(np.ma.masked_array(feat_names, mask=1 - mask))
    print(f"Features selected in fold {i}: {features_selected}")

如果在所有折中被选特征高度一致,那就说明这个特征子集是稳定的;如果频繁变动,则可能说明要么特征之间高度相关(互换不影响性能),要么样本太小导致评估不靠谱。

最后来看嵌入式和贪心式选择。嵌入式选择的代表是 SelectFromModel:它直接使用一个已经训练好的模型(比如 RidgeCV),根据其系数或特征重要性来设定阈值,保留得分高于阈值的特征。这一方法的好处是快——只需要训练一次模型;但前提是底层估计器必须暴露出 coef_ 或 feature_importances_ 属性。

examples/feature_selection/plot_select_from_model_diabetes.py 中,我们可以看到它是如何被用于糖尿病数据集的:

源码路径:examples/feature_selection/plot_select_from_model_diabetes.py - SelectFromModel 构建和拟合

# 第 74 章 —— examples/feature_selection/plot_select_from_model_diabetes.py
ridge = RidgeCV(alphas=np.logspace(-6, 6, num=5)).fit(X, y)
importance = np.abs(ridge.coef_)
sfm = SelectFromModel(ridge, threshold=threshold).fit(X, y)
print(f"Features selected by SelectFromModel: {feature_names[sfm.get_support()]}"

这里的 threshold 被设定为“第三重要特征的系数加一个小 epsilon”,这样就能 точно 地保留前两个最重要的特征(这里是 s5 和 bmi)。相比于后面要介绍的 SFS,SelectFromModel 只用 fit 了一次模型,因此速度快得多。

而 SFS(SequentialFeatureSelector)则是一种贪心搜索:它要么从空集开始 forward 添加特征(每次选能带来最高 CV 提升的那个),要么从全集开始 backward 删除特征(每次删掉影响最小的那个)。由于每一步都需要训练一个新模型并做交叉验证,它的计算开销远高于 SelectFromModel——但好处是它不要求底层估计器有 coef_ 或 feature_importances_,只要能做 predict 和 score 就行。

源码路径:examples/feature_selection/plot_select_from_model_diabetes.py - 前向和后向 SFS 构建

# 第 74 章 —— 前向 SFS
sfs_forward = SequentialFeatureSelector(
    ridge, n_features_to_select=2, direction="forward"
).fit(X, y)
# 第 74 章 —— 后向 SFS
sfs_backward = SequentialFeatureSelector(
    ridge, n_features_to_select=2, direction="backward"
).fit(X, y)

在糖尿病数据上,我们可以看到 forward 和 backward SFS 实际上选出了同样的特征集(不过这并不总是成立);但更重要的是,SFS 选出的特征有时会和 SelectFromModel 不同——例如它可能会选 bmi 而不是 s1,这是因为 SFS 完全基于 CV 评分,而不管模型内部的系数是什么。

当我们想要更灵活地控制后向搜索时(比如允许在性能轻微下降时仍继续删除特征),可以把 tol 设为负值:

源码路径:examples/feature_selection/plot_select_from_model_diabetes.py - 负 tol 值的 SFS 使用示例

for tol in [-1e-2, -1e-3, -1e-4]:
    feature_selector = SequentialFeatureSelector(
        LogisticRegression(),
        n_features_to_select="auto",
        direction="backward",
        scoring="roc_auc",
        tol=tol,
        n_jobs=2,
    )

这里的意思是:只有当移除一个特征导致 ROC AUC 下降超过 |tol| 时才停止;否则就继续尝试删除下一个。于是我们可以看到,当 tol 接近零(如 -1e-4)时,算法愿意容忍更大的性能下降,因此会保留更多特征;而当 tol 为 -1e-2 时,它对性能下降更敏感,因而会更早停止,保留的特征也更少。

74.6.1.1 生活类比(续)

特征选择就像厨房里的菜单精简:过滤式(SelectKBest)就像快速看菜单营养表——只看每道菜的卡路里、蛋白质等单项指标,快但可能漏掉“味道是否协调”这个整体感;包裹式(RFE)则像反复试菜:每次端上一道菜,问服务员哪道最难吃,然后把它拿掉,反复直到只剩你觉得最好吃的几道;嵌入式(SelectFromModel)像主厨投票:做完菜后直接看哪些食材在盘子里剩得最少(系数大),认为它们最重要;贪心式(SFS)则像点餐过程:要么从空盘开始,每次加一道能让评分最高的菜(forward);要么从满桌开始,每次撤掉影响最小的一道菜(backward)。如果你发现撤掉一道菜只让评分掉一点点(比如 0.001),你可能想继续撤看看(tol=-1e-4);但如果掉一大块(比如 0.01),你就赶紧停下来(tol=-1e-2),因为这时候撤菜可能是在砍掉招牌。

74.6.2 架构图:特征选择方法对比

flowchart TD A[特征选择开始] --> B{选择策略} B -->|过滤式| C[SelectKBest + f_classif/MI] B -->|包裹式| D[RFE/RFECV] B -->|嵌入式| E[SelectFromModel] B -->|贪心式| F[SequentialFeatureSelector] C --> G[快速,单变量,忽略交互] D --> H[慢,基于模型性能,考虑交互] E --> I[中等速度,需模型重要性] F --> J[慢,基于CV评分,无需模型重要性] G --> K[输出特征子集] H --> K I --> K J --> K K --> L[特征选择结束]

74.7 目标编码与数据变换 —— 类别特征的“信息蒸馏器”

到目前为止,我们都在处理数值特征。但现实世界中还有大量的类别特征——比如城市名称、产品类别、用户职业等。这些特征如果直接用标筓编码(比如“北京”=0,“上海”=1……)喂给模型,会无意中引入一个假序数关系:模型可能会认为“上海”比“北京”大一倍,这显然是荒谬的。于是我们需要更谨慎的编码方式。

最朴素的做法是 one-hot 编码:把每个类别变成一个二维向量。但如果类别基数很高(比如有上千个不同的城市),one-hot 会导致特征维度爆炸,不仅增加计算开销,还容易因为稀疏性而导致过拟合——尤其在线性模型中,很容易为那些在训练集中只出现一次但碰巧与目标相关的罕见类别分配巨大权重。

于是出现了 TargetEncoder:它的思路非常直观——用该类别下目标变量的均值来编码该类别。例如,如果“北京”这100笔订单的平均评分是 8.2,“上海”200笔订单的平均评分是 7.5,那么我们就把“北京”编码为 8.2,“上海”编码为 7.5。这样做的好处是:编码后的特征直接反映了类别与目标之间的统计关联,维度也不再随基数线性增长(还是一维)。

然而,直接用全量训练集计算目标均值会引入一个严重问题:目标泄露。因为编码过程中用到了 y 的信息,如果我们在训练集上直接 fit 再 transform,那么编码值实际上已经偷看了整个训练集的目标均值——这会导致模型在训练集上表现异常好,但在未见数据上过拟合严重。

为了解决这个问题,TargetEncoder 在 fit_transform 时内置了交叉拟合(cross fitting)机制。它的思路是:把训练集划分成 K 折,对于每一折,用其余 K-1 折来学习编码映射,然后用这个映射去编码当前折——这样就能确保任意一个样本的编码值,是完全由它自己以外的数据决定的。

我们可以在 examples/preprocessing/plot_target_encoder_cross_val.py 中看到这一机制是如何被用来对抗过拟合的。脚本构造了一个合成数据集,包含三类特征:

  • informative:中等基数(100 类),但与目标 y 有真实线性关系(经过加噪和打乱后仍可恢复);

  • shuffled:中等基数(100 类),是 informative 特征的随机排列,因而与 y 完全无关;

  • near_unique:高基数(约 45,000 类),基本是样本的唯一标识符,与 y 完全独立。

于是我们就能对比两种用法:直接 fit+transform(无交叉拟合)和 fit_transform(带交叉拟合):

源码路径:examples/preprocessing/plot_target_encoder_cross_val.py - 无交叉拟合编码

# 第 74 章 —— 无交叉拟合:直接用全量训练集学编码
target_encoder = TargetEncoder(random_state=0)
target_encoder.fit(X_train, y_train)
X_train_no_cf_encoding = target_encoder.transform(X_train)
model_no_cf = ridge.fit(X_train_no_cf_encoding, y_train)
print("Model without CF on test set: ", model_no_cf.score(X_test, y_test))  # 往往很低

源码路径:examples/preprocessing/plot_target_encoder_cross_val.py - 有交叉拟合编码

# 第 74 章 —— 有交叉拟合:内部 K 折交叉学编码
model_with_cf = make_pipeline(TargetEncoder(random_state=0), ridge)
model_with_cf.fit(X_train, y_train)
print("Model with CF on test set: ", model_with_cf.score(X_test, y_test))  # 往往更高

在近唯一特征(near_unique)的情况下,差异尤为明显:无交叉拟合时,模型会把极大的权重分配给这个特征,因为它几乎完美地记住了训练集中每个样本的目标值(过拟合);而一旦启用交叉拟合,这个特征的编码值就变得和平庸无别——因为每个样本的编码只由其他样本决定,而近唯一特征之所以近唯一,恰恰是因为它与目标无关,于是它的编码会回落到全局均值附近。

系数可视化也能清晰地看到这一点:

源码路径:examples/preprocessing/plot_target_encoder_cross_val.py - 有交叉拟合系数可视化

# 第 74 章 —— 有交叉拟合
coefs_cf = pd.Series(
    model_with_cf[-1].coef_, index=model_with_cf[-1].feature_names_in_
).sort_values()
coefs_cf.plot(kind="barh")  # 近唯一特征系数接近零

# 第 74 章 —— 无交叉拟合
coefs_no_cf = pd.Series(
    model_no_cf.coef_, index=model_no_cf.feature_names_in_
).sort_values()
coefs_no_cf.plot(kind="barh")  # 近唯一特征系数远大于其他特征

TargetEncoder 并不是万能的。它同样会受到高基数类别的影响——虽然交叉拟合能缓解过拟合,但如果类别真的和目标强相关(比如某个用户ID确实对应着固定的消费模式),那么即使是交叉拟合后,它也可能获得较高的权重。不过好消息是,这种情况下它捕捉到的很可能是真实信号,而不是噪声。

在实际项目中,我们常常需要混合使用多种编码策略。例如在 examples/preprocessing/plot_target_encoder.py 中,作者就采用了这样的思路:对于基数低于等于 255 的类别特征(比如省份、酒庄等),直接用 HistGradientBoostingRegressor 的原生类别支持(它内部会做分箱);而对于基数高于 255 的特征(比如酒评中的 winery、region_1 等),则使用 TargetEncoder。这样既能利用树模型对类别的原生支持,又能为高基数特征引入目标感知的编码,同时避免 one-hot 导致的维度爆炸。

源码路径:examples/preprocessing/plot_target_encoder.py - 混合编码预处理器构建

high_cardinality_features = n_unique_categories[n_unique_categories > 255].index
low_cardinality_features = n_unique_categories[n_unique_categories <= 255].index
mixed_encoded_preprocessor = ColumnTransformer(
    [
        ("numerical", "passthrough", numerical_features),
        (
            "high_cardinality",
            TargetEncoder(target_type="continuous"),
            high_cardinality_features,
        ),
        (
            "low_cardinality",
            OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1),
            low_cardinality_features,
        ),
    ],
    verbose_feature_names_out=False,

最后提一下 PowerTransformer 和 QuantileTransformer 在目标变换中的使用。虽然它们主要用于特征,但在某些情况下(比如目标变量是严重偏态的收入或持续时间),我们也可能想要对 y 做相似的变换,以满足线性模型对残差正态性的假设。examples/preprocessing/plot_map_data_to_normal.py 给出了一个很好的示例:它在六种不同分布(对数正态、卡方、韦布尔、高斯、均匀、双峰)上分别应用了 Box-Cox、Yeo-Johnson 和 QuantileTransformer,并比较了变换前后的直方图。

我们可以看到:

  • Box-Cox 在对数正态和卡方分布上表现出色(因为它们在数学上就是这些分布的逆变换),但它无法处理负数;

  • Yeo-Johnson 则更为通用,能够处理任意实数,但在某些分布上可能需要更大的样本量才能收敛;

  • QuantileTransformer 虽然能强制任意分布变成高斯(只要样本足够多),但在小样本上容易过拟合——因为它会把经验分位数视为真实分布,而样本太少时,这些分位数本身就不稳定。

于是我们得到一个实用的经验法则:在样本量充足(比如超过几千)时,QuantileTransformer 是一个安全的“强制高斯化”手段;而在样本有限时,还是优先考虑参数化的 PowerTransformer(尤其是 yeo-johnson 变体),因为它至少带有一定的模型假设,不容易被噪声所左右。

74.7.1.1 生活类比(续)

想象你有一堆不同样料的袋子:有些标有城市名(类别特征)。如果你直接按袋子顺序编号(LabelEncoder)——比如“北京”=0,“上海”=1——你就暗示“上海”是“北京”的两倍,这显然荒谬。One-hot 编码就像给每个城市单独挂一个彩旗:有几座城市就挂几面旗,城市多时旗林密布(维度爆炸)。TargetEncoder 则更聪明:它去查每座城市过往订单的平均评分,“北京”平均8.2分就贴上8.2,“上海”7.5分贴7.5——这样数字直接反映了城市与好评的关联,而且不管城市多少,只用一列数字。但如果你直接用全部订单算平均,就会作弊:因为你在算“北京”评分时,其实已经偷看了所有北京订单的评分(包括要预测的那笔),导致模型在老菜上表现超好,但遇到新城市就眼瞎。交叉拟合就是防作弊的规矩:把城市分成几堆,算每堆的时候只用别堆的数据,这样每座城市的评分都是由“外行”公正评定的。就像美食评比:评委不能打自己家的菜,否则肯定会偏高。

74.7.2 架构图:目标编码工作流程

flowchart TD A[类别特征输入] --> B{是否启用交叉拟合?} B -->|否| C[直接计算全量目标均值] B -->|是| D[K折交叉拟合:训练于K-1折,应用于第K折] C --> E[编码值:目标均值] D --> E E --> F[目标编码特征输出] F --> G[输入模型] G --> H{是否过拟合?} H -->|是| I[无交叉拟合:高基数特征权重过大] H -->|否| J[有交叉拟合:权重回落至合理范围]

74.8 设计中的取舍

在预处理和特征工程的工具箱中,我们常常会面临这样的选择:这个看起来更简单的方法,为什么我不直接用它呢?接下来我们用一问一答的形式,来拆解其中的设计权衡。

为什么不用 LabelEncoder 处理类别特征?

LabelEncoder 看似简单:把每个字符串映射到一个整数。但它隐含了一个假设——这些整数之间有顺序和意义。例如,如果我们把“苹果”=0,“香蕉”=1,“橙子”=2,那么模型可能会学到“橙子”大约是“苹果”的两倍——这在多数情况下是毫无根据的。因此 LabelEncoder 只适用于目标变量(即我们想要预测的离散标签),而不适用于特征。对于特征,我们应该使用 OneHotEncoder、OrdinalEncoder 或 TargetEncoder 等方法,它们要么不引入顺序(one-hot),要么让顺序变得可控(ordinal),要么让编码直接反映与目标的统计关联(target)。

这种设计的 trade-off 是什么?(以 RFE 与 SelectFromModel 为例)

RFE 的优势在于它直接基于模型性能来淘汰特征——哪怕底层模型是黑箱,只要它能输出预测得分,RFE 就能工作。但它的代价是计算开销大:每移除一个特征,都需要重新训练一次模型(通常还要做交叉验证)。相比之下,SelectFromModel 只需要训练一次模型(假设它有 coef_ 或 feature_importances_),因此速度快得多——但它的前提是底层估计器必须暴露出可解释的特征重要性;如果你在用一个像 KNN 这样的模型(它没有 coef_),那么 SelectFromModel 就无法使用。于是我们得到一个经验法则:当模型支持特征重要性且速度是关注点时,用 SelectFromModel;当我们想要更直接地基于性能来选择特征,或者在用不提供重要性的模型时,则转而考虑 RFE 或 SFS。

74.9 动手练习

  1. 缩放策略对 KNN 决策边界的对比实验

    • 阅读 examples/preprocessing/plot_scaling_importance.py 完整源码

    • 在 make_classification 生成的二分类数据上,构造两条量级悬殊的特征 (一列 ×1000,另一列 ×1)

    • 对比 KNeighborsClassifier 在 5 种缩放方式下的决策边界形状

    • 记录每种缩放下的训练/测试准确率与最近邻距离方差

    • 回答问题:

      • 为什么未缩放时 KNN 几乎只受高量级特征主导?

      • QuantileTransformer 与 RobustScaler 在含极端离群点时各自的鲁棒性来源是什么?

      • 当数据本身已是高斯分布时,PowerTransformer 是否会引入不必要的扭曲?

  2. 离散化分箱对线性模型灵活度的影响

    • 阅读 examples/preprocessing/plot_discretization.pyplot_discretization_classification.py

    • 复现 moons 数据集上 LogisticRegression 在分箱前/后的决策边界

    • 扫描 KBinsDiscretizer 的 n_bins ∈ [5, 10, 20, 50],记录训练/测试准确率

    • 对比 uniform/quantile/kmeans 三种 strategy 在双峰分布上的分箱边界差异

    • 回答问题:

      • 分箱使线性模型获得非线性能力的代价是什么(特征维度、计算量)?

      • 为什么 KBinsDiscretizer + OneHot 后决策边界呈现阶梯状?

      • 在 100 样本的 small data 上 n_bins 过大为何容易过拟合?

  3. 特征选择方法在噪声特征上的稳健性测试

    • 阅读 examples/feature_selection/plot_feature_selection.pyplot_rfe_digits.pyplot_select_from_model_diabetes.py

    • 在 Iris (150×4) 数据上追加 50 维随机噪声特征,比较以下方法选出的特征集:

      • SelectKBest(f_classif, k=4)

      • SelectFromModel(RidgeCV, threshold='median')

      • RFE(LogisticRegression, n_features_to_select=4)

      • SequentialFeatureSelector(LogisticRegression, n_features_to_select=4, direction='forward')

    • 用交叉验证准确率评估各方法选出的特征子集质量

    • 回答问题:

      • 为何互信息在非线性关系下优于 F 检验?

      • RFE 在高度相关特征上的不稳定性来源是什么?

      • 当底层模型没有 coef_/feature_importances_ 时,SelectFromModel 与 SFS 谁更通用?

  4. TargetEncoder 交叉拟合机制的可视化验证

    • 阅读 examples/preprocessing/plot_target_encoder_cross_val.pyplot_target_encoder.py

    • 复现近唯一类别 (near_unique) 合成数据集

    • 分别使用 TargetEncoder.fit_transform (CF on) 与 fit+transform (CF off) 对训练集编码

    • 训练 Ridge 模型并对比:训练 R²、测试 R²、near_unique 特征的系数绝对值

    • 回答问题:

      • fit_transform 内部 K 折交叉拟合如何削弱高基数类别对训练集的过拟合?

      • 在 Pipeline 中为何 fit_transform 自动启用而单独 fit+transform 不会?

      • 当类别数 ≤ 10 时,是否仍需要交叉拟合?为什么?

74.10 本章小结

本章我们通过阅读和运行 scikit-learn 的实际示例代码,系统地梳理了预处理与特征工程的核心技术。我们从最基础的特征缩放开始,对比了 StandardScaler、MinMaxScaler、MaxAbsScaler 和 RobustScaler 在不同数据分布(尤其是含离群点时)下的行为;随后探索了非线性变换器如 PowerTransformer 和 QuantileTransformer 如何通过改变数据分布形状来满足模型假设;接着我们看到了离散化如何通过分箱+one-hot 为线性模型引入非线性能力,并了解到不同分箱策略(uniform、quantile、kmeans)在不同数据结构下的表现;在特征选择部分,我们对比了过滤式(SelectKBest)、包裹式(RFE/RFECV)与嵌入式(SelectFromModel/SFS)三大范式,理解了它们在计算开销、对模型的依赖以及稳健性方面的差异;最后我们重点分析了 TargetEncoder 如何通过目标均值编码类别特征,以及其内部交叉拟合机制如何在高基数场景中防止过拟合。这些技术不仅是孤立的变换器,更可以通过 Pipeline 和 ColumnTransformer 组合成一个干净、可复用的预处理流水线,从而确保特征工程只在训练集上进行,避免数据泄露。

下表总结了本章涉及的关键概念及其简要解释

| 概念 | 解释 |

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

| StandardScaler / MinMaxScaler / MaxAbsScaler | 线性仿射变换族,对均值/极值/绝对值敏感,离群点会扭曲缩放区间 |

| RobustScaler | 基于中位数与 IQR 的鲁棒缩放,在含离群点数据上保持特征可比尺度 |

| PowerTransformer (Yeo-Johnson/Box-Cox) | 参数化幂变换,通过 MLE 估计 lambda 将数据映射到高斯分布 |

| QuantileTransformer | 非参数分位数变换,可强制输出 uniform/normal 分布,内置截断抗离群点 |

| Normalizer | 样本级 L2 归一化,逐行缩放至单位范数,独立于特征分布 |

| KBinsDiscretizer | uniform/quantile/kmeans 三策略分箱,配合 onehot/ordinal 编码为线性模型引入非线性 |

| SelectKBest + f_classif / mutual_info_* | 过滤式选择:基于单变量统计量 (F 值/互信息) 排序截取 Top-k 特征 |

| RFE / RFECV | 包裹式选择:递归剔除最不重要特征,RFECV 通过 CV 自动确定最优特征数 |

| SelectFromModel / SequentialFeatureSelector | 嵌入式与前向/后向贪心选择,前者依赖 coef_/feature_importances_,后者依赖 CV 评分 |

| TargetEncoder + cross fitting | 按目标均值编码类别,fit_transform 内部 K 折交叉拟合防止高基数类别过拟合 |

| make_pipeline + ColumnTransformer | 预处理-建模流水线工具,确保 fit 仅接触训练集,杜绝数据泄露 |

在掌握了这些预处理与特�工程的基本手段后,我们已经能够将大多数“真实世界”的数据转化为模型能够消化的形式。但在机器学习的旅程中,真正的挑战往往在于:我们如何选择合适的模型,并且如何通过系统的实验来验证它的好坏?下一章中,我们将学习模型选择与评估 —— 调参寻优的“航海罗盘”,看看如何用交叉验证、网格搜索以及各种评估指标来为我们的模型保驾护航。

第 75 章 —— 分类与回归算法谱系 —— 监督学习的“兵器陈列馆”

75.1 学习目标

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

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

(略,保持原文)

75.2 生活类比

特征工程可以被想象成一座炼金工坊,原始数据如同未经提炼的粗矿石。特征缩放 相当于对矿石进行破碎与筛分,使得不同密度的矿粒统一到相同尺度,保证后续的冶炼过程基于一致的度量基准。离散化 则像是将连续的矿石品位划分为若干等级的分级工艺,它将连续的矿石品位转化为离散的区间,便于后续的工艺控制。特征选择 对应选矿厂的重介质或磁选环节,能够剔除无价值的脉石,仅保留高品位的精矿,从而让模型专注于真正有信息量的特征。目标编码幂变换 类似于在矿石配方中加入助熔剂或调整熔点的步骤,帮助模型在面对复杂的特征分布时获得更加平滑、符合正态假设的输入空间。决策边界可视化 如同工坊的透视镜,能够让我们直观看到不同加工工艺后矿石的形状与分布。自定义核函数 则是工坊中的秘密配方炉,通过特殊的数学加工方式改变矿石内部结构,使得原本难以分离的成分得到区分。One‑Class SVM 像是极端异常值过滤筛,只保留最纯净的矿石核心,将所有偏离常态的样本标记为噪声。正则化参数 C 的缩放 类比于根据矿石批量大小动态调节熔火温度,确保不同规模的炼制工艺都能达到相同的质量标准。 不同核函数的 SVM 如同比较多种熔炼技术:线性对应传统工艺,多项式对应多级梯度工艺,RBF 对应循环加热工艺,Sigmoid 对应脉冲加温工艺,每种都有其独特的适用场景。样本权重 SVM 在熔炼过程中为特定矿石颗粒赋予特殊重要性,改变整体熔融行为,使模型对某些样本格外关注。NCA 特征变换 则是矿石内部结构的原子级重新排列技术,使同类样本在变换后空间更聚集。MLP 正则化 如同调节神经网络中权重衰减的强度,防止模型过度记忆训练数据。概率校准 像是炼金师使用的试金石,校正模型置信度与真实发生频率的偏差,不同的校准方法对应不同精度的“金”。这种类比帮助我们将抽象的机器学习概念具象化,便于在实际工程中灵活运用。

75.3 源码地图

下面的表格列出本章节涉及的源码文件及其核心类/函数。

| 单元 | 示例文件 | 关键类/函数 |

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

| 1 | examples/classification/plot_classifier_comparison.py | DecisionBoundaryDisplay.from_estimatormake_pipelineStandardScaler |

| 2 | examples/classification/plot_classification_probability.py | DecisionBoundaryDisplay.from_estimatorresponse_method="predict_proba") |

| 3 | examples/preprocessing/plot_discretization.py | KBinsDiscretizer |

| 4 | examples/preprocessing/plot_discretization_strategies.py | KBinsDiscretizer(不同 n_binsstrategy) |

| 5 | examples/preprocessing/plot_discretization_classification.py | PipelineStandardScaler + KBinsDiscretizer + 分类器 |

| 6 | examples/feature_selection/plot_feature_selection.py | f_classifmutual_info_classifchi2 |

| 7 | examples/feature_selection/plot_feature_selection_pipeline.py | Pipeline 包含特征选择器 |

| 8 | examples/feature_selection/plot_f_test_vs_mi.py | make_classificationRFE |

| 9 | examples/feature_selection/plot_rfe_digits.py | RFEdigits 数据集 |

|10 | examples/feature_selection/plot_rfe_with_cross_validation.py | RFECV |

|11 | examples/feature_selection/plot_select_from_model_diabetes.py | SelectFromModelLassoRidgeRandomForestRegressor |

|12 | examples/preprocessing/plot_target_encoder.py | TargetEncoder |

|13 | examples/preprocessing/plot_target_encoder_cross_val.py | 交叉验证下的 TargetEncoder |

|14 | examples/preprocessing/plot_map_data_to_normal.py | PowerTransformermethod='box-cox'/'yeo-johnson') |

|15 | examples/svm/plot_custom_kernel.py | 自定义核 my_kernelDecisionBoundaryDisplay |

|16 | examples/svm/plot_oneclass.py | OneClassSVMDecisionBoundaryDisplay |

|17 | examples/svm/plot_svm_scale_c.py | LinearSVC(L1/L2)+ validation_curve |

|18 | examples/svm/plot_svm_kernels.py | SVC(四种核)+ DecisionBoundaryDisplay |

|19 | examples/svm/plot_weighted_samples.py | SVCsample_weight 参数) |

|20 | examples/neighbors/plot_nca_illustration.py | NeighborhoodComponentsAnalysis |

|21 | examples/neural_networks/plot_mlp_alpha.py | MLPClassifieralpha 正则化) |

|22 | examples/calibration/plot_calibration.py | GaussianNBCalibratedClassifierCVbrier_score_loss |

|23 | examples/calibration/plot_calibration_curve.py | CalibrationDisplayLogisticRegressionGaussianNB |

|24 | examples/calibration/plot_compare_calibration.py | LogisticRegressionCVNaivelyCalibratedLinearSVCRandomForestClassifier |

|25 | 练习(动手实验) | 参考前述所有单元的组合实现 |


75.4 源码解析单元 1 – 多分类器决策边界对比 (plot_classifier_comparison.py)

flowchart TB A[生成三类合成数据集] --> B[统一 Pipeline (StandardScaler + 分类器)] B --> C[训练十种分类器] C --> D[DecisionBoundaryDisplay 绘制决策边界] D --> E[计算测试集准确率] E --> F[在子图中展示结果]

源码路径src/examples/classification/plot_classifier_comparison.py - main()(第 1‑120 行)

# 第 75 章 —— ① 导入绘图、数值与颜色库
import matplotlib.pyplot as plt               # 绘图主库
import numpy as np                           # 数值计算库
from matplotlib.colors import ListedColormap  # 自定义颜色映射

# 第 75 章 —— ② 导入数据生成函数和各种分类模型
from sklearn.datasets import make_circles, make_classification, make_moons
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis
from sklearn.ensemble import AdaBoostClassifier, RandomForestClassifier
from sklearn.gaussian_process import GaussianProcessClassifier
from sklearn.gaussian_process.kernels import RBF
from sklearn.inspection import DecisionBoundaryDisplay  # 决策边界可视化核心
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline   # 快速组装 Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier

# 第 75 章 —— ③ 定义十种分类器名称(用于子图标题)
names = [
    "Nearest Neighbors",
    "Linear SVM",
    "RBF SVM",
    "Gaussian Process",
    "Decision Tree",
    "Random Forest",
    "Neural Net",
    "AdaBoost",
    "Naive Bayes",
    "QDA",
]

# 第 75 章 —— ④ 实例化对应的分类器对象(顺序与 names 对齐)
classifiers = [
    KNeighborsClassifier(3),                         # K=3 最近邻
    SVC(kernel="linear", C=0.025, random_state=42),   # 线性 SVM,强正则化
    SVC(gamma=2, C=1, random_state=42),               # RBF SVM,γ=2
    GaussianProcessClassifier(1.0 * RBF(1.0), random_state=42),  # 高斯过程 + RBF
    DecisionTreeClassifier(max_depth=5, random_state=42),          # 深度受限的决策树
    RandomForestClassifier(max_depth=5, n_estimators=10,
                           max_features=1, random_state=42),    # 小型随机森林
    MLPClassifier(alpha=1, max_iter=1000, random_state=42),       # 多层感知机
    AdaBoostClassifier(random_state=42),                           # 自适应提升
    GaussianNB(),                                                   # 朴素贝叶斯
    QuadraticDiscriminantAnalysis(),                               # 二次判别分析
]

# 第 75 章 —— ⑤ 生成线性可分的二维合成数据并加入噪声
X, y = make_classification(
    n_features=2, n_redundant=0, n_informative=2,
    random_state=1, n_clusters_per_class=1
)
rng = np.random.RandomState(2)
X += 2 * rng.uniform(size=X.shape)  # 噪声放大,使分类更具挑战性
linearly_separable = (X, y)

# 第 75 章 —— ⑥ 构造三种数据集:月牙、同心圆、线性可分
datasets = [
    make_moons(noise=0.3, random_state=0),                     # 月牙形
    make_circles(noise=0.2, factor=0.5, random_state=1),      # 同心圆
    linearly_separable,                                       # 线性可分
]

# 第 75 章 —— ⑦ 创建绘图画布(3 行 × 10+1 列)
figure = plt.figure(figsize=(27, 9))
i = 1  # 子图计数器

# 第 75 章 —— ⑧ 遍历每个数据集
for ds_cnt, ds in enumerate(datasets):
    # ---- 数据切分 ----
    X, y = ds
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.4, random_state=42
    )
    # ---- 绘图范围 ----
    x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
    y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5

    # ---- 第 0 列子图:仅展示原始数据 ----
    cm = plt.cm.RdBu               # 红蓝颜色映射
    cm_bright = ListedColormap(["#FF0000", "#0000FF"])   # 训练/测试点的鲜明颜色
    ax = plt.subplot(len(datasets), len(classifiers) + 1, i)
    if ds_cnt == 0:
        ax.set_title("Input data")
    ax.scatter(X_train[:, 0], X_train[:, 1],
               c=y_train, cmap=cm_bright, edgecolors="k")          # 训练点(实心)
    ax.scatter(X_test[:, 0], X_test[:, 1],
               c=y_test, cmap=cm_bright, alpha=0.6, edgecolors="k")  # 测试点(半透明)
    ax.set_xlim(x_min, x_max)
    ax.set_ylim(y_min, y_max)
    ax.set_xticks(())
    ax.set_yticks(())
    i += 1

    # ---- 为每个分类器绘制决策边界 ----
    for name, clf in zip(names, classifiers):
        ax = plt.subplot(len(datasets), len(classifiers) + 1, i)

        # 统一加入 StandardScaler,消除尺度差异
        clf = make_pipeline(StandardScaler(), clf)

        clf.fit(X_train, y_train)               # 训练模型
        score = clf.score(X_test, y_test)        # 测试集准确率

        # 自动生成网格并绘制等高线/颜色块
        DecisionBoundaryDisplay.from_estimator(
            clf, X, cmap=cm, alpha=0.8, ax=ax, eps=0.5
        )

        # 再绘制训练/测试点,确保在颜色块之上
        ax.scatter(X_train[:, 0], X_train[:, 1],
                   c=y_train, cmap=cm_bright, edgecolors="k")
        ax.scatter(X_test[:, 0], X_test[:, 1],
                   c=y_test, cmap=cm_bright, edgecolors="k", alpha=0.6)

        ax.set_xlim(x_min, x_max)
        ax.set_ylim(y_min, y_max)
        ax.set_xticks(())
        ax.set_yticks(())
        if ds_cnt == 0:
            ax.set_title(name)                  # 第一行显示分类器名称
        # 在右下角标注模型在当前数据集上的准确率,保留两位小数
        ax.text(x_max - 0.3, y_min + 0.3,
                ("%.2f" % score).lstrip("0"),
                size=15, horizontalalignment="right")
        i += 1

# 第 75 章 —— ⑨ 调整子图布局,防止标签重叠
plt.tight_layout()
plt.show()

代码概述

本示例通过 make_circles / make_moons / make_classification 生成三类二维数据集,并使用 StandardScaler 对每个模型统一标准化。十种分类器在同一网格上绘制决策边界,右下角标注测试准确率,实现“视觉 + 数值”双重对比,帮助读者直观看到不同模型的学习行为和对数据分布的适应性。


75.5 源码解析单元 2 – 分类概率可视化 (plot_classification_probability.py)

flowchart TB A[加载鸢尾花前两维特征] --> B[构建七种支持 predict_proba 的分类器] B --> C[fit / predict / predict_proba] C --> D[逐类绘制概率热力图(response_method="predict_proba")] D --> E[最后一列展示最大概率类别的决策区域] E --> F[统计 Accuracy / ROC‑AUC / Log‑Loss 并汇总]

源码路径src/examples/classification/plot_classification_probability.py - main()(第 1‑130 行)

# 第 75 章 —— ① 基础库导入
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib import cm

# 第 75 章 —— ② 导入数据与模型
from sklearn import datasets
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.gaussian_process import GaussianProcessClassifier
from sklearn.gaussian_process.kernels import RBF
from sklearn.inspection import DecisionBoundaryDisplay
from sklearn.kernel_approximation import Nystroem
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, log_loss, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import KBinsDiscretizer, PolynomialFeatures, SplineTransformer

# 第 75 章 —— ③ 数据准备:只取前两维特征便于二维可视化
iris = datasets.load_iris()
X = iris.data[:, 0:2]          # 前两维
y = iris.target

# 第 75 章 —— ④ 划分训练/测试集(5:5)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.5, random_state=42
)

# 第 75 章 —— ⑤ 构建七种具备 predict_proba 的分类器
classifiers = {
    "Logistic regression\n(C=0.1)": LogisticRegression(C=0.1),                     # 强正则化
    "Logistic regression\n(C=100)": LogisticRegression(C=100),                    # 弱正则化
    "Gaussian Process": GaussianProcessClassifier(kernel=1.0 * RBF([1.0, 1.0])),
    "Logistic regression\n(RBF features)": make_pipeline(
        Nystroem(kernel="rbf", gamma=0.5, n_components=50, random_state=1),
        LogisticRegression(C=10),
    ),
    "Gradient Boosting": HistGradientBoostingClassifier(random_state=42),
    "Logistic regression\n(binned features)": make_pipeline(
        KBinsDiscretizer(n_bins=5, quantile_method="averaged_inverted_cdf"),
        PolynomialFeatures(interaction_only=True),
        LogisticRegression(C=10),
    ),
    "Logistic regression\n(spline features)": make_pipeline(
        SplineTransformer(n_knots=5),
        PolynomialFeatures(interaction_only=True),
        LogisticRegression(C=10),
    ),
}

# 第 75 章 —— ⑥ 可视化设置
n_classifiers = len(classifiers)
scatter_kwargs = {"s": 25, "marker": "o", "linewidths": 0.8,
                  "edgecolor": "k", "alpha": 0.7}
y_unique = np.unique(y)

mpl.rcParams["savefig.bbox"] = "tight"
fig, axes = plt.subplots(
    nrows=n_classifiers,
    ncols=len(iris.target_names) + 1,
    figsize=(4 * 2.2, n_classifiers * 2.2),
)

evaluation_results = []   # 用于后续指标表
levels = 100             # 等高线细分层数

# 第 75 章 —— ⑦ 主循环:遍历每个分类器
for classifier_idx, (name, classifier) in enumerate(classifiers.items()):
    # 训练并获取预测
    y_pred = classifier.fit(X_train, y_train).predict(X_test)
    y_pred_proba = classifier.predict_proba(X_test)

    # 计算指标
    accuracy_test = accuracy_score(y_test, y_pred)
    roc_auc_test = roc_auc_score(y_test, y_pred_proba, multi_class="ovr")
    log_loss_test = log_loss(y_test, y_pred_proba)

    evaluation_results.append({
        "name": name.replace("\n", " "),
        "accuracy": accuracy_test,
        "roc_auc": roc_auc_test,
        "log_loss": log_loss_test,
    })

    # 逐类绘制概率热力图
    for label in y_unique:
        disp = DecisionBoundaryDisplay.from_estimator(
            classifier,
            X_train,
            response_method="predict_proba",
            class_of_interest=label,
            ax=axes[classifier_idx, label],
            vmin=0, vmax=1,
            cmap="Blues",
            levels=levels,
        )
        axes[classifier_idx, label].set_title(
            f"Class {iris.target_names[label]}"
        )
        # 在热力图上绘制被预测为该类别的测试点
        mask_y_pred = y_pred == label
        axes[classifier_idx, label].scatter(
            X_test[mask_y_pred, 0], X_test[mask_y_pred, 1],
            c="w", **scatter_kwargs
        )
        axes[classifier_idx, label].set(xticks=(), yticks=())

    # 最后一列展示“最大概率类别”
    max_class_disp = DecisionBoundaryDisplay.from_estimator(
        classifier,
        X_train,
        response_method="predict_proba",
        class_of_interest=None,                # None → 绘制最大概率类别
        ax=axes[classifier_idx, len(y_unique)],
        vmin=0, vmax=1,
        levels=levels,
    )
    for label in y_unique:
        mask_label = y_test == label
        max_col = len(y_unique)
        axes[classifier_idx, max_col].scatter(
            X_test[mask_label, 0], X_test[mask_label, 1],
            c=max_class_disp.multiclass_colors_[[label], :],
            **scatter_kwargs,
        )
    axes[classifier_idx, 3].set(xticks=(), yticks=())
    axes[classifier_idx, 3].set_title("Max class")
    axes[classifier_idx, 0].set_ylabel(name)   # 行首标注分类器名称

# 第 75 章 —— ⑧ 绘制统一颜色条(对应概率颜色映射)
ax_single = fig.add_axes([0.15, 0.01, 0.5, 0.02])
plt.title("Probability")
_ = plt.colorbar(
    cm.ScalarMappable(norm=None, cmap=disp.surface_.cmap),
    cax=ax_single, orientation="horizontal"
)

# 第 75 章 —— ⑨ 为每个类别单独绘制颜色条
max_class_cmaps = [s.cmap for s in max_class_disp.surface_]
for label in y_unique:
    ax_max = fig.add_axes([0.73, (0.06 - (label * 0.04)), 0.16, 0.015])
    plt.title(f"Probability class {label}", fontsize=10)
    _ = plt.colorbar(
        cm.ScalarMappable(norm=None, cmap=max_class_cmaps[label]),
        cax=ax_max, orientation="horizontal",
    )
    if label in (0, 1):
        ax_max.set(xticks=(), yticks=())

# 第 75 章 —— ⑩ 展示量化指标表格(DataFrame)
pd.DataFrame(evaluation_results).round(2)

代码概述

  • 数据准备:使用鸢尾花的前两维特征形成二维可视化基准。

  • 分类器集合:七种模型均实现 predict_proba,包括线性、核化、梯度提升以及特征工程(离散化、样条)三种增强方式。

  • 概率可视化DecisionBoundaryDisplay.from_estimator(..., response_method="predict_proba") 将每个类别的预测概率绘制为蓝色热力图;class_of_interest=None 时展示最高概率类别的决策区域。

  • 指标统计:计算 accuracyroc_auclog_loss,并在下方表格中呈现,提供量化对比。


75.6 源码解析单元 20 – NCA 特征变换可视化 (plot_nca_illustration.py)

flowchart TB A[生成 9 条 3 类数据] --> B[在原始空间绘制相似度连线] B --> C[使用 NCA 学习线性嵌入] C --> D[在嵌入空间重新绘制相似度连线] D --> E[展示结构重排效果]

源码路径src/examples/neighbors/plot_nca_illustration.py - main()(第 1‑120 行)

# 第 75 章 —— ① 导入绘图与数值库
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import cm
from scipy.special import logsumexp

# 第 75 章 —— ② 导入数据生成与 NCA 类
from sklearn.datasets import make_classification
from sklearn.neighbors import NeighborhoodComponentsAnalysis

# 第 75 章 —— ③ 生成 9 条、3 类、二维特征的合成数据
X, y = make_classification(
    n_samples=9,
    n_features=2,
    n_informative=2,
    n_redundant=0,
    n_classes=3,
    n_clusters_per_class=1,
    class_sep=1.0,
    random_state=0,
)

# 第 75 章 —— ④ 原始空间可视化:绘制编号、颜色、透明度
plt.figure(1)
ax = plt.gca()
for i in range(X.shape[0]):
    ax.text(X[i, 0], X[i, 1], str(i), va="center", ha="center")
    ax.scatter(X[i, 0], X[i, 1], s=300, c=cm.Set1(y[[i]]), alpha=0.4)
ax.set_title("Original points")
ax.axes.get_xaxis().set_visible(False)
ax.axes.get_yaxis().set_visible(False)
ax.axis("equal")   # 保持比例,圆形距离可视化真实

# 第 75 章 —— ⑤ 计算点 i 与所有点的负指数距离(软最大) —— 用于连线宽度
def link_thickness_i(X, i):
    """
    对点 i 与所有其他点计算负指数距离的 softmax 权重,
    并返回每条连线的相对宽度(越近越宽)。
    """
    diff_embedded = X[i] - X                      # 向量差
    dist_embedded = np.einsum("ij,ij->i", diff_embedded,
                              diff_embedded)    # 欧氏平方距离
    dist_embedded[i] = np.inf                     # 排除自身
    exp_dist_embedded = np.exp(-dist_embedded -
                               logsumexp(-dist_embedded))
    return exp_dist_embedded

# 第 75 章 —— ⑥ 在原始空间绘制相似度连线(以点 3 为例)
def relate_point(X, i, ax):
    """在坐标轴 ax 上绘制点 i 与所有其它点的连线,宽度随相似度变化。"""
    pt_i = X[i]
    for j, pt_j in enumerate(X):
        if i != j:
            thickness = link_thickness_i(X, i)[j]
            line = ([pt_i[0], pt_j[0]], [pt_i[1], pt_j[1]])
            ax.plot(*line, c=cm.Set1(y[j]), linewidth=5 * thickness)

i = 3
relate_point(X, i, ax)
plt.show()

# 第 75 章 —— ⑦ 学习线性嵌入(NCA)
nca = NeighborhoodComponentsAnalysis(max_iter=30, random_state=0)
nca = nca.fit(X, y)               # 最大化最近邻准确率学习变换矩阵

# 第 75 章 —— ⑧ 在嵌入空间重新绘制相似度连线
plt.figure(2)
ax2 = plt.gca()
X_embedded = nca.transform(X)      # 线性映射到新空间
relate_point(X_embedded, i, ax2)   # 再次绘制连线

# 第 75 章 —— 绘制点本身(编号、颜色、透明度)
for i in range(len(X_embedded)):
    ax2.text(X_embedded[i, 0], X_embedded[i, 1],
             str(i), va="center", ha="center")
    ax2.scatter(X_embedded[i, 0], X_embedded[i, 1],
                s=300, c=cm.Set1(y[[i]]), alpha=0.4)

ax2.set_title("NCA embedding")
ax2.axes.get_xaxis().set_visible(False)
ax2.axes.get_yaxis().set_visible(False)
ax2.axis("equal")
plt.show()

代码概述

  • 数据生成make_classification 创建 3 类、每类 3 条样本的二维数据。

  • 相似度可视化link_thickness_i 通过负指数距离的 softmax 计算相似度权重,并在 relate_point 中将权重映射为连线宽度,直观展示原始空间中点间相似度。

  • NCA 学习NeighborhoodComponentsAnalysis 只学习一个线性变换矩阵,使得同类点在嵌入空间中更靠近、异类点更远离。

  • 嵌入可视化:在学习得到的嵌入空间再次绘制相似度连线,清晰看到结构重排(同类点聚集、跨类连线变细)。


75.7 源码解析单元 21 – MLP 正则化与决策边界 (plot_mlp_alpha.py)

flowchart TB A[生成三种合成数据集] --> B[StandardScaler 标准化] B --> C[构建 5 种不同 alpha 的 MLP Pipeline] C --> D[在统一网格上绘制决策函数或概率] D --> E[在每个子图右下角标注测试集准确率]

源码路径src/examples/neural_networks/plot_mlp_alpha.py - main()(第 1‑130 行)

# 第 75 章 —— ① 导入必要库
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.colors import ListedColormap

from sklearn.datasets import make_circles, make_classification, make_moons
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

h = 0.02  # 网格步长,用于绘制平滑的决策边界

# 第 75 章 —— ② 定义 5 个不同的正则化强度(logspace)
alphas = np.logspace(-1, 1, 5)   # 0.1, 0.316, 1.0, 3.16, 10

classifiers = []   # 保存每个 Pipeline
names = []        # 对应的子图标题
for alpha in alphas:
    classifiers.append(
        make_pipeline(
            StandardScaler(),
            MLPClassifier(
                solver="lbfgs",
                alpha=alpha,               # L2 正则化强度
                random_state=1,
                max_iter=2000,
                early_stopping=True,
                hidden_layer_sizes=[10, 10],
            ),
        )
    )
    names.append(f"alpha {alpha:.2f}")

# 第 75 章 —— ③ 生成 3 种合成数据集:月牙、同心圆、线性可分
X, y = make_classification(
    n_features=2, n_redundant=0, n_informative=2,
    random_state=0, n_clusters_per_class=1
)
rng = np.random.RandomState(2)
X += 2 * rng.uniform(size=X.shape)   # 噪声
linearly_separable = (X, y)

datasets = [
    make_moons(noise=0.3, random_state=0),
    make_circles(noise=0.2, factor=0.5, random_state=1),
    linearly_separable,
]

# 第 75 章 —— ④ 创建绘图画布(每行对应一个数据集,每列对应一个 alpha)
figure = plt.figure(figsize=(17, 9))
i = 1  # 子图计数器

for X, y in datasets:
    # ---- 划分训练/测试 ----
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.4, random_state=42
    )
    # ---- 网格范围 ----
    x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
    y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
    xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                         np.arange(y_min, y_max, h))

    # ---- 第 0 列子图:仅绘制数据本身 ----
    cm = plt.cm.RdBu
    cm_bright = ListedColormap(["#FF0000", "#0000FF"])
    ax = plt.subplot(len(datasets), len(classifiers) + 1, i)
    ax.scatter(X_train[:, 0], X_train[:, 1],
               c=y_train, cmap=cm_bright)
    ax.scatter(X_test[:, 0], X_test[:, 1],
               c=y_test, cmap=cm_bright, alpha=0.6)
    ax.set_xlim(xx.min(), xx.max())
    ax.set_ylim(yy.min(), yy.max())
    ax.set_xticks(())
    ax.set_yticks(())
    i += 1

    # ---- 对每个 alpha 训练 MLP 并绘制决策边界 ----
    for name, clf in zip(names, classifiers):
        ax = plt.subplot(len(datasets), len(classifiers) + 1, i)
        clf.fit(X_train, y_train)          # 训练模型
        score = clf.score(X_test, y_test)  # 测试集准确率

        # 计算网格上每一点的输出
        if hasattr(clf, "decision_function"):
            Z = clf.decision_function(np.column_stack([xx.ravel(), yy.ravel()]))
        else:
            # MLP 没有 decision_function,使用 predict_proba 的正类概率
            Z = clf.predict_proba(np.column_stack([xx.ravel(), yy.ravel()]))[:, 1]

        Z = Z.reshape(xx.shape)   # 重塑为网格形状
        ax.contourf(xx, yy, Z, cmap=cm, alpha=0.8)   # 绘制等高线填充

        # 再绘制训练/测试点,确保在彩色背景之上
        ax.scatter(X_train[:, 0], X_train[:, 1],
                   c=y_train, cmap=cm_bright, edgecolors="black", s=25)
        ax.scatter(X_test[:, 0], X_test[:, 1],
                   c=y_test, cmap=cm_bright, alpha=0.6,
                   edgecolors="black", s=25)

        ax.set_xlim(xx.min(), xx.max())
        ax.set_ylim(yy.min(), yy.max())
        ax.set_xticks(())
        ax.set_yticks(())
        ax.set_title(name)   # 子图标题显示当前 alpha
        # 在右下角标注准确率
        ax.text(xx.max() - 0.3, yy.min() + 0.3,
                f"{score:.3f}".lstrip("0"),
                size=15, horizontalalignment="right")
        i += 1

figure.subplots_adjust(left=0.02, right=0.98)
plt.show()

代码概述

本示例通过 alphas = np.logspace(-1, 1, 5)MLPClassifier 设置五种 L2 正则化强度。每种 alpha 对三类合成数据(月牙、同心圆、线性可分)进行训练并绘制决策边界。左侧子图展示原始数据,随后每列展示对应 alpha 的决策曲面;右下角的数值标明测试集准确率,从而直观观察正则化从 过拟合(α=0.1)欠拟合(α=10) 的演变。


75.8 源码解析单元 22 – 概率校准基础 (plot_calibration.py)

flowchart TB A[生成三簇 50k 样本(中间簇真实概率 0.5)] --> B[划分 10% 训练 / 90% 测试] B --> C[训练 GaussianNB(无校准)] B --> D[使用 CalibratedClassifierCV(isotonic)] B --> E[使用 CalibratedClassifierCV(sigmoid)] C --> F[计算 Brier 分数] D --> F E --> F F --> G[绘制概率时序图 + 经验频率]

源码路径src/examples/calibration/plot_calibration.py - main()(第 1‑120 行)

# 第 75 章 —— ① 生成合成数据:三簇、两类,中心分别在 (-5,-5), (0,0), (5,5)
import numpy as np
from sklearn.datasets import make_blobs
from sklearn.model_selection import train_test_split

n_samples = 50000
centers = [(-5, -5), (0, 0), (5, 5)]
X, y = make_blobs(n_samples=n_samples, centers=centers,
                  shuffle=False, random_state=42)

# 第 75 章 —— 前半标记为负,后半标记为正,确保中间簇真实概率恰为 0.5
y[: n_samples // 2] = 0
y[n_samples // 2 :] = 1

# 第 75 章 —— 随机生成样本权重(用于后续校准的加权)
sample_weight = np.random.RandomState(42).rand(y.shape[0])

# 第 75 章 —— ② 划分训练/测试(10% 训练,90% 测试)
X_train, X_test, y_train, y_test, sw_train, sw_test = train_test_split(
    X, y, sample_weight, test_size=0.9, random_state=42
)

# 第 75 章 —— ③ 未经校准的 GaussianNB
from sklearn.naive_bayes import GaussianNB
clf = GaussianNB()
clf.fit(X_train, y_train)                         # NB 本身不接受样本权重
prob_pos_clf = clf.predict_proba(X_test)[:, 1]    # 正类概率

# 第 75 章 —— ④ isotonic 校准(非参数单调回归)
from sklearn.calibration import CalibratedClassifierCV
clf_isotonic = CalibratedClassifierCV(clf, cv=2, method="isotonic")
clf_isotonic.fit(X_train, y_train, sample_weight=sw_train)
prob_pos_isotonic = clf_isotonic.predict_proba(X_test)[:, 1]

# 第 75 章 —— ⑤ sigmoid 校准(参数化 Platt scaling)
clf_sigmoid = CalibratedClassifierCV(clf, cv=2, method="sigmoid")
clf_sigmoid.fit(X_train, y_train, sample_weight=sw_train)
prob_pos_sigmoid = clf_sigmoid.predict_proba(X_test)[:, 1]

# 第 75 章 —— ⑥ 评估 Brier 分数(越小越好)
from sklearn.metrics import brier_score_loss
clf_score = brier_score_loss(y_test, prob_pos_clf, sample_weight=sw_test)
clf_isotonic_score = brier_score_loss(y_test, prob_pos_isotonic,
                                     sample_weight=sw_test)
clf_sigmoid_score = brier_score_loss(y_test, prob_pos_sigmoid,
                                    sample_weight=sw_test)

print("Brier score losses:")
print(f"No calibration: {clf_score:.3f}")
print(f"With isotonic calibration: {clf_isotonic_score:.3f}")
print(f"With sigmoid calibration: {clf_sigmoid_score:.3f}")

# 第 75 章 —— ⑦ 可视化:散点展示原始数据(颜色对应类别,大小对应权重)
import matplotlib.pyplot as plt
from matplotlib import cm

plt.figure()
y_unique = np.unique(y)
colors = cm.rainbow(np.linspace(0.0, 1.0, y_unique.size))
for this_y, color in zip(y_unique, colors):
    this_X = X_train[y_train == this_y]
    this_sw = sw_train[y_train == this_y]
    plt.scatter(this_X[:, 0], this_X[:, 1],
                s=this_sw * 50, c=color[np.newaxis, :],
                alpha=0.5, edgecolor="k",
                label=f"Class {this_y}")
plt.legend(loc="best")
plt.title("Data")

# 第 75 章 —— ⑧ 概率时序图(按照未校准概率排序绘制三条曲线)
plt.figure()
order = np.lexsort((prob_pos_clf,))   # 按未校准概率排序的索引
plt.plot(prob_pos_clf[order], "r",
         label=f"No calibration ({clf_score:.3f})")
plt.plot(prob_pos_isotonic[order], "g", linewidth=3,
         label=f"Isotonic calibration ({clf_isotonic_score:.3f})")
plt.plot(prob_pos_sigmoid[order], "b", linewidth=3,
         label=f"Sigmoid calibration ({clf_sigmoid_score:.3f})")
# 第 75 章 —— 经验频率(真实正类比例)作为基准
plt.plot(
    np.linspace(0, y_test.size, 51)[1::2],
    y_test[order].reshape(25, -1).mean(1),
    "k", linewidth=3, label=r"Empirical"
)
plt.ylim([-0.05, 1.05])
plt.xlabel("Instances sorted by uncalibrated GNB probability")
plt.ylabel("P(y=1)")
plt.legend(loc="upper left")
plt.title("Gaussian Naive Bayes probabilities")
plt.show()

代码概述

  • 数据构造:利用 make_blobs 生成三簇,其中中间簇标签混合,使其真实概率为 0.5,构造校准需求的 “金” 样本。

  • 模型与校准:分别训练未校准的 GaussianNB、使用 CalibratedClassifierCVisotonicsigmoid 两种校准方式。

  • 评估brier_score_loss 同时衡量校准误差与预测准确度,值越低表示校准越好。

  • 可视化:左图展示带权重的数据分布;右图为概率时序图,将三种模型的预测概率(按未校准概率排序)与真实正类比例(经验频率)对比,直观呈现校准前后的曲线收敛程度。


75.9 源码解析单元 23 – 校准曲线 (plot_calibration_curve.py)

flowchart TB A[生成 100k 二分类样本] --> B[极端划分 1% 训练 / 99% 测试] B --> C[训练 LogisticRegression、GaussianNB(原始+两种校准)] C --> D[使用 CalibrationDisplay 绘制可靠性图(平均概率 vs 真实频率)] D --> E[在下方绘制预测概率直方图] E --> F[定量分析 Brier、LogLoss、ROC‑AUC、Precision/Recall/F1]

源码路径src/examples/calibration/plot_calibration_curve.py - main()(第 1‑130 行)

# 第 75 章 —— ① 生成二分类合成数据(100k 样本,20 特征,仅 2 为信息特征)
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

X, y = make_classification(
    n_samples=100_000,
    n_features=20,
    n_informative=2,
    n_redundant=10,
    random_state=42
)

# 第 75 章 —— ② 仅使用 1%(1000 条)进行模型训练,其余用于评估
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.99, random_state=42
)

# 第 75 章 —— ③ 导入模型与校准工具
from sklearn.calibration import CalibratedClassifierCV, CalibrationDisplay
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import GaussianNB

lr = LogisticRegression(C=1.0)                     # 基线 Logistic
gnb = GaussianNB()                                 # 未校准 NB
gnb_isotonic = CalibratedClassifierCV(gnb, cv=2, method="isotonic")
gnb_sigmoid = CalibratedClassifierCV(gnb, cv=2, method="sigmoid")

clf_list = [
    (lr, "Logistic"),
    (gnb, "Naive Bayes"),
    (gnb_isotonic, "Naive Bayes + Isotonic"),
    (gnb_sigmoid, "Naive Bayes + Sigmoid"),
]

# 第 75 章 —— ④ 绘制校准曲线与直方图(使用 GridSpec 布局)
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec

fig = plt.figure(figsize=(10, 10))
gs = GridSpec(4, 2)   # 前两行用于校准曲线,后两行用于直方图
colors = plt.get_cmap("Dark2")

ax_calibration_curve = fig.add_subplot(gs[:2, :2])
calibration_displays = {}

for i, (clf, name) in enumerate(clf_list):
    clf.fit(X_train, y_train)    # 训练
    # CalibrationDisplay 自动计算每个概率 bin 的平均预测概率
    # 与对应的真实正类比例,绘制 reliability diagram
    display = CalibrationDisplay.from_estimator(
        clf, X_test, y_test,
        n_bins=10,
        name=name,
        ax=ax_calibration_curve,
        color=colors(i),
    )
    calibration_displays[name] = display

ax_calibration_curve.grid()
ax_calibration_curve.set_title("Calibration plots (Naive Bayes)")

# 第 75 章 —— ⑤ 在下方绘制预测概率直方图(每个模型一张)
grid_positions = [(2, 0), (2, 1), (3, 0), (3, 1)]
for i, (_, name) in enumerate(clf_list):
    row, col = grid_positions[i]
    ax = fig.add_subplot(gs[row, col])
    ax.hist(
        calibration_displays[name].y_prob,
        range=(0, 1), bins=10, label=name,
        color=colors(i),
    )
    ax.set(title=name, xlabel="Mean predicted probability", ylabel="Count")
    ax.legend()

plt.tight_layout()
plt.show()

代码概述

  • 极端小训练集:仅使用 1% 的样本训练模型,放大校准误差的表现。

  • 校准曲线CalibrationDisplay.from_estimator 自动完成概率分箱、计算每个 bin 的平均预测概率与真实正类比例,并在同一坐标系中绘制 Reliability Diagram。完美校准的模型应落在对角线 y = x 上。

  • 概率分布直方图:下方子图展示每个模型的预测概率分布,有助于解释校准曲线偏移的根本原因(如概率集中于 0/1 端导致的过度自信)。


75.10 源码解析单元 24 – 多模型校准比较 (plot_compare_calibration.py)

flowchart TB A[生成 100k 二分类样本] --> B[仅用 100 条训练(极端小样本)] B --> C[训练 LogisticRegressionCV、GaussianNB、NaivelyCalibratedLinearSVC、RandomForest] C --> D[绘制统一的 CalibrationDisplay 曲线] D --> E[下方直方图展示各模型概率分布] E --> F[定量分析 Brier、LogLoss、ROC‑AUC、Precision/Recall/F1]

源码路径src/examples/calibration/plot_compare_calibration.py - main()(第 1‑150 行)

# 第 75 章 —— ① 合成数据(100k 样本,20 特征,2 信息特征,2 冗余特征)
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split

X, y = make_classification(
    n_samples=100_000,
    n_features=20,
    n_informative=2,
    n_redundant=2,
    random_state=42,
)

train_samples = 100   # 极端小的训练集
X_train, X_test, y_train, y_test = train_test_split(
    X, y, shuffle=False, test_size=100_000 - train_samples
)

# 第 75 章 —— ② NaivelyCalibratedLinearSVC(将 decision_function 做 min‑max 缩放)
import numpy as np
from sklearn.svm import LinearSVC

class NaivelyCalibratedLinearSVC(LinearSVC):
    """为 LinearSVC 手动实现 predict_proba(最小‑最大缩放)。"""
    def fit(self, X, y):
        super().fit(X, y)
        df = self.decision_function(X)
        self.df_min_, self.df_max_ = df.min(), df.max()

    def predict_proba(self, X):
        df = self.decision_function(X)
        calibrated = (df - self.df_min_) / (self.df_max_ - self.df_min_)
        calibrated = np.clip(calibrated, 0, 1)
        return np.c_[1 - calibrated, calibrated]   # (n,2) 形式

# 第 75 章 —— ③ 初始化四个分类器
from sklearn.calibration import CalibrationDisplay
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegressionCV
from sklearn.naive_bayes import GaussianNB

lr = LogisticRegressionCV(
    Cs=np.logspace(-6, 6, 101), cv=10,
    l1_ratios=(0,), scoring="neg_log_loss",
    max_iter=1_000, use_legacy_attributes=False,
)
gnb = GaussianNB()
svc = NaivelyCalibratedLinearSVC(C=1.0)
rfc = RandomForestClassifier(random_state=42)

clf_list = [
    (lr, "Logistic Regression"),
    (gnb, "Naive Bayes"),
    (svc, "SVC"),
    (rfc, "Random forest"),
]

# 第 75 章 —— ④ 绘制校准曲线与概率直方图(使用 GridSpec)
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec

fig = plt.figure(figsize=(10, 10))
gs = GridSpec(4, 2)
colors = plt.get_cmap("Dark2")
markers = ["^", "v", "s", "o"]  # 便于图例区分

ax_calibration_curve = fig.add_subplot(gs[:2, :2])
calibration_displays = {}

for i, (clf, name) in enumerate(clf_list):
    clf.fit(X_train, y_train)   # 训练
    display = CalibrationDisplay.from_estimator(
        clf, X_test, y_test,
        n_bins=10, name=name,
        ax=ax_calibration_curve,
        color=colors(i),
        marker=markers[i],
    )
    calibration_displays[name] = display

ax_calibration_curve.grid()
ax_calibration_curve.set_title("Calibration plots")

# 第 75 章 —— ⑤ 直方图:展示每个模型的预测概率分布
grid_positions = [(2, 0), (2, 1), (3, 0), (3, 1)]
for i, (_, name) in enumerate(clf_list):
    row, col = grid_positions[i]
    ax = fig.add_subplot(gs[row, col])
    ax.hist(
        calibration_displays[name].y_prob,
        range=(0, 1), bins=10, label=name,
        color=colors(i),
    )
    ax.set(title=name, xlabel="Mean predicted probability", ylabel="Count")
    ax.legend()

plt.tight_layout()
plt.show()

代码概述

  • 极端小样本实验:只用 100 条 训练样本,放大模型在数据稀缺情况下的校准误差。

  • 四类模型:包括自带概率的 LogisticRegressionCV、原始 GaussianNB、经 NaivelyCalibratedLinearSVC 包装的线性 SVM、以及 RandomForestClassifier

  • 统一校准可视化CalibrationDisplay 在同一坐标轴绘制四条校准曲线,直观比较;下方直方图展示每个模型的概率分布特性(如 RandomForest 的双峰、GaussianNB 的极端 0/1)。


75.11 设计取舍(问答)

Q: 为何在多数实验中选择 StandardScaler 而非 MinMaxScaler

A: MinMaxScaler 将特征线性映射到固定区间(如 [0,1]),在存在离群值时会被强行拉伸,导致大多数正常样本的数值被压缩,信息损失明显。StandardScaler 则基于均值和标准差进行标准化,虽然同样受离群值影响,但对整体分布的扰动更温和,尤其适用于假设特征近似正态分布的模型(如线性回归、SVM)。因此在需要兼顾鲁棒性与模型假设匹配时,StandardScaler 更可靠。

Q: 特征缩放方法的权衡点在哪里?

A: StandardScaler 对均值和方差敏感,适用于线性模型和基于距离的算法;MinMaxScaler 绝对区间固定,对异常值极度敏感,但对神经网络等对输入尺度高度依赖的模型友好;RobustScaler 使用四分位距实现天然离群值鲁棒,适合噪声较大的工业数据;QuantileTransformer 通过分位数映射将任意分布转为均匀或正态分布,计算开销大但能显著提升线性模型在非正态特征上的表现。选择时需综合考虑模型假设、离群值情况以及计算资源。

Q: 在 SVM 中为何引入正则化参数 C 的缩放策略?

A: 在 SVM 的目标函数中,C 控制误差惩罚与模型复杂度的权衡。当训练样本量增加时,未缩放的 C 会导致误差项随样本数线性增长,而正则化项保持不变,导致模型在大样本下过度拟合。统计学习理论指出,正则化强度应与样本量的 平方根 成反比,以保持误差项与正则化项的相对尺度一致。通过 C_scaled = C * sqrt(train_size / n_samples) 的缩放,可实现跨数据规模的一致性比较,并在交叉验证中得到更稳健的最优 C


75.12 其它源码单元概述(单元 3‑19 补充)

(保持原表述,已在前文列出)


75.13 动手练习(示例)

练习 1 – 特征缩放对比

使用 StandardScalerMinMaxScalerRobustScalerQuantileTransformer 对含有离群值的合成数据(make_classification)进行预处理,分别训练 KNNPCA → KNNLogisticRegression,绘制每个组合的决策边界并报告测试准确率。

练习 2 – 离散化实验

make_moonsmake_circles 数据分别使用 KBinsDiscretizer 的三种策略(uniformquantilekmeans),观察离散化后对 LogisticRegressionDecisionTreeClassifier 决策边界的影响。

练习 3 – 特征选择实战

在乳腺癌数据集上,对 F 检验互信息RFESelectFromModel(Lasso)与 SequentialFeatureSelector(递归)进行对比,使用 Pipeline 包裹特征选择器 + RandomForestClassifier,通过 5‑折交叉验证评估每种方法的平均准确率与特征子集大小。

练习 4 – 概率校准综合实验

GaussianNBLogisticRegressionRandomForest 进行 未校准isotonicsigmoid 三种校准,计算 BrierLog‑LossROC‑AUC,并使用 CalibrationDisplay概率直方图 同时展示效果。

练习 5 – 极端小样本校准比较

采用 100 条 训练样本与 99,900 条 测试样本的划分,比较 LogisticRegressionCVGaussianNBNaivelyCalibratedLinearSVCRandomForest 的校准曲线与概率分布,分析每种模型在样本稀缺情况下的 过自信欠自信中性 表现。


75.14 本章小结

本章系统阐述了从 特征工程(缩放、离散化、选择、目标编码、幂变换)到 模型可视化(决策边界、概率热力图、校准曲线)的完整工作流,并深入探讨了 SVM 系列的高级技巧(自定义核、One‑Class、正则化 C 缩放、样本权重)。通过 DecisionBoundaryDisplayCalibrationDisplayNeighborhoodComponentsAnalysis 等高层可视化工具,读者能够快速获得模型行为的直观洞察,并依据 Brier、Log‑Loss、ROC‑AUC 等量化指标进行客观评估。后续章节将继续探索 聚类分析无监督学习 的实战技巧。

下一章中,我们将学习聚类算法及其在高维数据中的可视化方法。

75.15 设计取舍(问答)

为什么采用当前方案而不是更复杂的替代方案? 本章实现优先保证与既有 API 的一致性、可维护性与运行效率。这意味着在少数极端场景下,调用者需要自行在灵活性、内存与速度之间做取舍,换取默认路径的清晰与稳定。

第 76 章 —— 高级主题特辑 —— 半探索、多标签与高斯过程的"进阶秘境"

76.1 学习目标

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

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

  • 理解半监督学习中标签传播与自训练的核心机制与适用场景

  • 掌握高斯过程回归/分类中的核工程、超参数优化与不确定性量化

  • 了解协方差估计中鲁棒方法、收缩估计与稀疏逆协方差的原理

  • 对比多类别/多标签/多输出策略的分解机制与集成效果

  • 掌握管道、列变换器与特征联合构建异构特征工程流水线的方法

76.2 生活类比

想象你是一名部队教官,手里只有 40 名经过专业训练的"种子士兵",却要带领 300 名刚从乡下来的"新兵蛋子"完成一项识别敌机的任务。LabelSpreading 像"战友传帮带"——它根据士兵之间的相似度(谁跟谁是老乡、谁跟谁是同乡)编织出一张"关系网",让种子士兵的技能沿着这张网一层层扩散到所有新兵身上。SelfTraining 则像"自学成才的尖子生"——它先让种子士兵教出一批"班长",再让班长去辅导其他新兵,每一轮只把那些"班长觉得有十足把握"的新兵正式纳入队伍。Active Learning 则像"挑最笨的重点训"的教官——它每轮都去队列里找出最迷茫的新兵(预测概率最接近 0.5 的那些),单独给他们开小灶,把稀缺的教官资源用在刀刃上。当你把视角从"教新兵"转向"预测未来",高斯过程就像一位手持水晶球的"贝叶斯占卜师"——核函数是水晶球本身,它定义了"两个时间点的命运有多相似":ExpSineSquared 核是"季节占卜术",专管春去秋来的周期轮回;Matern 核是"粗糙占卜术",能捕捉那些不太平滑的转折;RationalQuadratic 核则是"多尺度占卜术",把不同长度的命运线编织在一起;GaussianProcessRegressor 在预测时不仅告诉你"均值是多少",还会坦诚地说"我不确定,区间这么大"——这就是 return_std=True 的"不确定性诚实";GaussianProcessClassifier 则更进一步,不仅给出胜负,还能告诉你"胜率有多高"。当你把多个核用 +* 组合起来,就好比把"趋势占卜""周期占卜""噪声占卜"三大法术熔于一炉,炼出最贴切的命运预测。进一步地,协方差矩阵就像一张描述"团队成员身高体重如何共同变化"的散布矩阵,EmpiricalCovariance 是"裸体扫描",对异常值毫无防备——一旦混入几个"长成姚明那么高"的极端样本,整个矩阵的形状就被严重扭曲;LedoitWolfOAS 是"微创手术医生",通过把矩阵往"安全的方向"(对角矩阵)轻轻收缩,避开极端值的干扰;MinCovDet (MCD) 则像"高切除率手术刀"——它先在团队里找出"身材最像彼此"的那一群人,只根据这群"纯净样本"估算散布矩阵;GraphicalLasso 是"稀疏精准切除"的专科医生,它不仅做估计,还能告诉你"哪些成员之间根本没有关联"(稀疏逆协方差为零的位置)。当任务从"二选一"升级到"多选多"时,我们需要一套"集团军作战编制":OneVsRestClassifier 是"各个击破"的独立师——为每个类别单独训练一支部队,最后看哪个部队的声音最大;OneVsOneClassifier 是"两两 PK"的擂台赛——让 K(K-1)/2 对部队在擂台上决出胜负,谁赢的场次多谁就是冠军;OutputCodeClassifier (ECOC) 是"纠错编码"的密码本作战——给每个类别发一本独特的"密码本",让部队根据密码破译归属;ClassifierChain 是"接力赛"——第 i 个标签的预测结果会作为第 i+1 个标签的额外情报,从而利用标签之间的相关性;MultiOutput 则是"并行分队"——多个独立小组同时开干,彼此互不干扰。最后,真实世界的特征往往五花八门——有数值、类别、文本、字典,它们需要完全不同的预处理方式:ColumnTransformer 像"分拣中心"——按列的类型把不同的预处理模块分发到对应的传送带上;FeatureUnion 像"并联增压泵"——把 PCA 和单变量选择等多源特征横向并联后合成一条强化特征流;Pipeline 则是"流水线装配车间"——把预处理和模型像零件一样串联起来,让数据从原料入口流向成品出口;TransformedTargetRegressor 像"单位换算器"——在线性模型无能为力的偏态目标上,先做对数变换把数据"熨平",训练完再逆变换回原始尺度。这五大主题——半监督标签传播、高斯过程不确定性、协方差矩阵的鲁棒整形、多任务策略的分解与协同、异构特征的装配流水线——共同构成了 scikit-learn 高级主题的完整图景。

76.3 源码地图

examples/semi_supervised/plot_label_propagation_digits.py
├── __main__ (1-80行)            # 标签传播演示:少量标签分类手写数字、熵不确定性可视化
examples/semi_supervised/plot_label_propagation_digits_active_learning.py
├── __main__ (1-100行)           # 主动学习循环:迭代查询最不确定样本标注、LabelSpreading 重复训练
examples/semi_supervised/plot_label_propagation_structure.py
├── __main__ (1-80行)            # 同心圆结构学习:KNN 核标签传播、流形假设验证
examples/semi_supervised/plot_self_training_varying_threshold.py
├── __main__ (1-120行)           # 自训练阈值敏感性:置信度阈值对伪标签数量/准确率/迭代数的影响
examples/semi_supervised/plot_semi_supervised_newsgroups.py
├── eval_and_get_f1 (80-90行)    # 模型评估辅助函数:计算 F1、打印训练样本数/未标注数
├── __main__ (1-150行)           # 文本半监督分类:TF-IDF + SGDClassifier/LabelSpreading/SelfTraining 对比
examples/semi_supervised/plot_semi_supervised_versus_svm_iris.py
├── __main__ (1-150行)           # 决策边界对比:LabelSpreading vs SelfTraining(SVC) 在不同标注比例下的表现
examples/gaussian_process/plot_compare_gpr_krr.py
├── __main__ (1-200行)           # GPR vs KernelRidge:周期性核超参数调优、预测不确定性、外推行为
examples/gaussian_process/plot_gpc.py
├── __main__ (1-80行)            # GPC 概率预测:固定 vs 优化超参数的 LML/准确率/对数损失对比
examples/gaussian_process/plot_gpc_iris.py
├── __main__ (1-80行)            # Iris 上 GPC:各向同性 vs 各向异性 RBF 核决策边界与 LML
examples/gaussian_process/plot_gpc_isoprobability.py
├── g (16-18行)                  # 真实决策边界函数:5 - x1 - 0.5*x0^2
├── __main__ (1-100行)           # 等概率线可视化:自定义核、真实决策边界 vs 预测概率等高线
examples/gaussian_process/plot_gpc_xor.py
├── __main__ (1-80行)            # XOR 问题:RBF vs DotProduct 核在非平稳边界上的表现差异
examples/gaussian_process/plot_gpr_co2.py
├── __main__ (1-150行)           # CO2 预测:复合核工程(长期趋势+季节性+噪声)、超参数解释
examples/gaussian_process/plot_gpr_noisy.py
├── target_generator (12-17行)   # 目标生成函数:0.5 + sin(3x) 可选加噪
├── __main__ (1-150行)           # 噪声水平估计:WhiteKernel 初始化敏感性、LML 局部极小值、重启优化
examples/gaussian_process/plot_gpr_noisy_targets.py
├── __main__ (1-100行)           # 基础 GPR:噪声自由 vs 已知噪声目标的置信区间对比
examples/gaussian_process/plot_gpr_on_structured_data.py
├── SequenceKernel.__init__ (41-44行)      # 自定义序列卷积核:基线相似度超参数
├── SequenceKernel._f (52-59行)             # 核函数:字符匹配计数 + 基线相似度
├── SequenceKernel._g (62-67行)             # 核梯度:不匹配字符对计数
├── SequenceKernel.__call__ (69-81行)       # 核矩阵计算:支持 eval_gradient
├── SequenceKernel.diag (83-84行)           # 对角元素:自相似度
├── SequenceKernel.is_stationary (86-87行) # 非平稳核标识
├── SequenceKernel.clone_with_theta (87-88行) # 超参数克隆
├── SequenceKernel.hyperparameter_baseline_similarity (47-50行) # 超参数属性:baseline_similarity
├── __main__ (1-150行)           # 基因序列回归/分类:自定义核在离散结构上的应用
examples/gaussian_process/plot_gpr_prior_posterior.py
├── plot_gpr_samples (16-50行)    # 先验/后验采样可视化辅助函数
├── __main__ (1-200行)           # 核食谱:RBF/RQ/ExpSineSquared/DotProduct/Matern 先验后验对比
examples/covariance/plot_covariance_estimation.py
├── __main__ (1-120行)           # 收缩协方差:LedoitWolf/OAS/CV 选择收缩系数、似然对比
examples/covariance/plot_lw_vs_oas.py
├── __main__ (1-100行)           # LW vs OAS:MSE 与收缩系数随样本量变化、高维设置
examples/covariance/plot_mahalanobis_distances.py
├── __main__ (1-120行)           # 鲁棒马氏距离:MCD vs MLE 在污染数据上的等高线与箱线图区分度
examples/covariance/plot_robust_vs_empirical_covariance.py
├── __main__ (1-120行)           # 鲁棒 vs 经验估计:位置/协方差误差随污染比例变化、MCD 崩溃点
examples/covariance/plot_sparse_cov.py
├── __main__ (1-100行)           # 稀疏逆协方差:GraphicalLassoCV 稀疏模式恢复、CV 曲线
examples/multiclass/plot_multiclass_overview.py
├── __main__ (1-120行)           # 多类别策略对比:内置 vs OvO/OvR/ECOC、决策树深度优化前后
examples/multioutput/plot_classifier_chain_yeast.py
├── __main__ (1-120行)           # 分类器链:酵母多标签数据、随机顺序链集成 vs OvR、Jaccard 评分
examples/miscellaneous/plot_multilabel.py
├── plot_hyperplane (27-36行)    # 线性 SVC 超平面绘制辅助
├── plot_subfigure (39-80行)     # PCA/CCA 降维后 OneVsRest 分类可视化
├── __main__ (1-100行)           # 多标签分类模拟:PCA vs CCA 降维、未标记样本处理
examples/miscellaneous/plot_multioutput_face_completion.py
├── __main__ (1-100行)           # 多输出回归补全人脸:ExtraTrees/KNN/Linear/Ridge 对比
examples/mixture/plot_concentration_prior.py
├── plot_ellipses (42-56行)      # 高斯分量椭圆绘制:权重透明度、协方差特征值
├── plot_results (59-85行)       # 拟合结果与组件权重条形图联合绘制
├── __main__ (1-150行)           # Dirichlet 分布 vs 过程先验:浓度参数对每个组件激活数量的控制
examples/mixture/plot_gmm.py
├── plot_results (25-52行)       # GMM/DPGMM 椭圆可视化:EM 必用全组件 vs DP 自动选择
├── __main__ (1-80行)            # 两高斯混合:EM 五分量强制拟合 vs Dirichlet 过程自适应
examples/mixture/plot_gmm_covariances.py
├── make_ellipses (33-50行)      # 不同协方差类型椭圆绘制:full/tied/diag/spherical
├── __main__ (1-120行)           # Iris 上 GMM 协方差类型对比:训练/测试准确率、椭圆形状
examples/mixture/plot_gmm_init.py
├── get_initial_means (41-47行)  # 零迭代 GMM 获取初始化中心
├── __main__ (1-100行)           # 初始化方法对比:kmeans/random/k-means++/random_from_data 迭代数与耗时
examples/mixture/plot_gmm_pdf.py
├── __main__ (1-80行)            # 密度估计等高线:负对数似然、LogNorm 可视化
examples/mixture/plot_gmm_selection.py
├── gmm_bic_score (94-97行)      # GridSearchCV 负 BIC 评分函数
├── __main__ (1-150行)           # 模型选择:GridSearchCV 搜索组件数/协方差类型、BIC 热力图、最佳模型椭圆
examples/mixture/plot_gmm_sin.py
├── plot_results (25-52行)       # 正弦曲线拟合:GMM/DPGMM 组件椭圆、浓度先验影响
├── plot_samples (55-68行)       # 采样可视化:从拟合模型生成新样本
├── __main__ (1-200行)           # 非高斯数据建模:EM 固定组件 vs DP 低/高浓度先验的结构捕获差异
examples/cross_decomposition/plot_compare_cross_decomposition.py
├── __main__ (1-150行)           # PLSCanonical/PLSRegression/CCA 对比:得分相关性、系数恢复
examples/cross_decomposition/plot_pcr_vs_pls.py
├── __main__ (1-150行)           # PCR vs PLS:目标与低方差主成分相关时 PLS 优势、R² 对比
examples/compose/plot_column_transformer.py
├── subject_body_extractor (50-68行) # 邮件主题/正文提取函数
├── text_stats (71-73行)              # 文本统计特征提取:长度/句数
├── __main__ (1-150行)           # 20 Newsgroups 异构特征:ColumnTransformer 组合 TF-IDF/PCA/DictVectorizer
examples/compose/plot_column_transformer_mixed_types.py
├── __main__ (1-150行)           # Titanic 混合类型:数值中位数填补+标准化、类别 OneHot+卡方筛选
examples/compose/plot_compare_reduction.py
├── __main__ (1-120行)           # 降维选择管线:PCA/NMF/SelectKBest + LinearSVC 网格搜索、缓存机制
examples/compose/plot_digits_pipe.py
├── __main__ (1-100行)           # PCA + LogisticRegression 管线:GridSearchCV 优化 n_components/C、方差谱
examples/compose/plot_feature_union.py
├── __main__ (1-80行)            # FeatureUnion:PCA + 单变量选择并行特征拼接、SVM 网格搜索
examples/compose/plot_transformed_target.py
├── compute_score (94-97行)      # R²/MedAE 计算辅助
├── __main__ (1-200行)           # 目标变换回归:合成指数目标 log1p/expm1、Ames 房价 QuantileTransformer

76.4 半监督学习 —— 少量标签撬动海量数据的"杠杆"

半监督学习解决的是"标注稀缺但未标注数据丰富"的现实场景。scikit-learn 提供两大流派:LabelSpreading 基于图传播,SelfTrainingClassifier 基于伪标签迭代自举。我们先从最经典的标签传播入手。

76.4.1 标签传播的核心实现

源码路径:examples/semi_supervised/plot_label_propagation_digits.py - __main__(1-80行)

# 第 76 章 —— ---------- 数据准备 ----------
digits = datasets.load_digits()              # 加载 1797 张 8x8 手写数字
rng = np.random.RandomState(2)
indices = np.arange(len(digits.data))        # 生成 0-1796 的索引
rng.shuffle(indices)                          # 打乱顺序,模拟随机采样

X = digits.data[indices[:340]]               # 选 340 个样本作为训练池
y = digits.target[indices[:340]]             # 对应的真实标签(仅用于评估)
images = digits.images[indices[:340]]        # 图像用于可视化

n_total_samples = 340                          # 总训练样本数
n_labeled_points = 40                          # 仅 40 个有标签,约 12%

unlabeled_set = indices[n_labeled_points:]    # 剩下 300 个的索引

y_train = np.copy(y)                          # 复制一份完整标签向量
y_train[unlabeled_set] = -1                   # 将未标注样本标签设为 -1(半监督约定)

未标注样本使用 -1 是 scikit-learn 半监督 API 的统一约定,模型通过这个"魔法数字"识别哪些样本需要传播预测。

# 第 76 章 —— ---------- 模型训练 ----------
from sklearn.semi_supervised import LabelSpreading

lp_model = LabelSpreading(gamma=0.25, max_iter=20)  # gamma 控制 RBF 核宽度
lp_model.fit(X, y_train)                            # 用 40 标签 + 300 未标注一起拟合
predicted_labels = lp_model.transduction_[unlabeled_set]  # 转导预测:未标注样本的预测标签
true_labels = y[unlabeled_set]                      # 真实标签(仅评估用)

# 第 76 章 —— ---------- 不确定性可视化 ----------
from scipy import stats

pred_entropies = stats.distributions.entropy(
    lp_model.label_distributions_.T                # 软标签分布矩阵 (n_samples, n_classes)
)
uncertainty_index = np.argsort(pred_entropies)[-10:]  # 熵最大的 10 个样本

这段代码展示了 LabelSpreading 的两大关键产出:transduction_(硬标签转导结果)与 label_distributions_(软标签概率分布)。gamma=0.25 控制相似度图的"视野宽度"——值越小,每个样本的影响力覆盖越远;值越大,传播越局部化。源码第 36-45 行调用 classification_report 打印分类指标,并在第 49-55 行用 ConfusionMatrixDisplay 可视化混淆矩阵;第 76-85 行通过循环把熵最大的 10 张手写数字绘制成子图,标题里同时展示"预测标签"与"真实标签",让"猜错的硬骨头"一目了然。

下面用流程图展示半监督学习的核心循环:

graph TD A[40个有标签 + 300未标注] --> B[LabelSpreading.fit] B --> C[构建相似度图 RBF/KNN] C --> D[迭代传播标签直到收敛] D --> E[label_distributions_ 软标签] E --> F[计算预测熵] F --> G{熵最大的样本} G -->|主动学习| H[人工标注后重新训练] G -->|SelfTraining| I[高概率预测作为伪标签] I --> J[扩充训练集重训]

76.4.2 主动学习循环

源码路径:examples/semi_supervised/plot_label_propagation_digits_active_learning.py - __main__(1-100行)

n_labeled_points = 40           # 初始标注数
max_iterations = 5              # 主动学习轮数

for i in range(max_iterations):
    y_train = np.copy(y)
    y_train[unlabeled_indices] = -1         # 每轮重置未标注集

    lp_model = LabelSpreading(gamma=0.25, max_iter=20)
    lp_model.fit(X, y_train)               # 训练当前模型

    # 计算预测熵,挑最不确定的 5 个样本
    pred_entropies = stats.distributions.entropy(lp_model.label_distributions_.T)
    uncertainty_index = np.argsort(pred_entropies)[::-1]
    uncertainty_index = uncertainty_index[np.isin(uncertainty_index, unlabeled_indices)][:5]

    # 把这 5 个样本从未标注集移到标注集
    unlabeled_indices = np.delete(unlabeled_indices, delete_indices)
    n_labeled_points += 5                  # 标注数从 40 增长到 65

主动学习的核心思想是把"标注预算"用在最迷茫的样本上——熵最高的样本往往是决策边界附近的样本,给它们打标签能带来最大的信息增益。源码同时打印每个迭代轮次的分类报告和混淆矩阵,并通过 plt.add_subplot 把每轮的"5 个最不确定样本"按 5×5 网格拼接成一幅大图,让"模型最想知道的样本"直观可见。

76.4.3 同心圆结构与流形学习

源码路径:examples/semi_supervised/plot_label_propagation_structure.py - __main__(1-80行)

from sklearn.datasets import make_circles

n_samples = 200
X, y = make_circles(n_samples=n_samples, shuffle=False)  # 生成同心圆数据
outer, inner = 0, 1
labels = np.full(n_samples, -1.0)              # 默认所有标签为 -1
labels[0] = outer                              # 仅 1 个外圈样本有标签
labels[-1] = inner                             # 仅 1 个内圈样本有标签

# 第 76 章 —— 使用 KNN 核:让相似度只在几何近邻上传播
label_spread = LabelSpreading(kernel="knn", alpha=0.8)
label_spread.fit(X, labels)
output_labels = label_spread.transduction_      # 转导结果:完整标签向量

关键参数 kernel='knn'alpha=0.8kernel='knn' 让相似度仅在 K 个最近邻上传播而非用全局 RBF,这正是"流形假设"的几何实现——相邻样本大概率属于同一类别;alpha=0.8 是钳制因子,控制"标签保留"与"邻居平滑"之间的平衡,越接近 1 越相信邻居,越接近 0 越固执己见。

76.4.4 自训练阈值敏感性

源码路径:examples/semi_supervised/plot_self_training_varying_threshold.py - __main__(1-120行)

base_classifier = CalibratedClassifierCV(SVC(gamma=0.001, random_state=42))
# 第 76 章 —— 注意 SVC 必须经过 CalibratedClassifierCV 才能输出 predict_proba

x_values = np.arange(0.4, 1.05, 0.05)
x_values = np.append(x_values, 0.99999)     # 包含接近 1 的极端值

for i, threshold in enumerate(x_values):
    self_training_clf = SelfTrainingClassifier(base_classifier, threshold=threshold)
    # 手动 3 折交叉验证(避免 -1 被当作类别)
    skfolds = StratifiedKFold(n_splits=n_splits)
    for fold, (train_index, test_index) in enumerate(skfolds.split(X, y)):
        self_training_clf.fit(X_train, y_train)

        # 记录最终被标注的样本数(排除原始标注)
        amount_labeled[i, fold] = (
            total_samples
            - np.unique(self_training_clf.labeled_iter_, return_counts=True)[1][0]
        )
        # 记录最后加入伪标签的迭代轮次
        amount_iterations[i, fold] = np.max(self_training_clf.labeled_iter_)

threshold 参数控制伪标签准入门槛:阈值越低,越多未标注样本被纳入训练,但也越容易引入错误标签;阈值越高,自训练越自保甚至退化为纯监督。labeled_iter_ 记录了每个样本被标注的迭代序号,amount_iterations 揭示了阈值对训练动态的影响。源码在第 79-100 行用 ax.twinx() 把"准确率"与"被标注样本数"绘制在同一张图的左右双 Y 轴上;在第 103-114 行用第二张子图绘制"最后加入伪标签的迭代轮次",共同揭示阈值敏感性的双重画像。

76.4.5 文本半监督分类对比

源码路径:examples/semi_supervised/plot_semi_supervised_newsgroups.py - eval_and_get_f1(80-90行)与 __main__(1-150行)

def eval_and_get_f1(clf, X_train, y_train, X_test, y_test):
    """统一的 F1 评估入口,打印训练集信息"""
    print(f"   Number of training samples: {len(X_train)}")
    print(f"   Unlabeled samples in training set: {sum(1 for x in y_train if x == -1)}")
    clf.fit(X_train, y_train)               # 自动处理 -1 作为未标注
    y_pred = clf.predict(X_test)
    f1 = f1_score(y_test, y_pred, average="micro")  # 微平均 F1
    print(f"   Micro-averaged F1 score on test set: {f1:.3f}")
    return f1

# 第 76 章 —— 四种策略对比
f1_scores["Supervised (100%)"] = eval_and_get_f1(pipeline, X_train, y_train, X_test, y_test)  # 全监督基线
f1_scores["Supervised (20%)"] = eval_and_get_f1(pipeline, X_20, y_20, X_test, y_test)         # 20% 监督

# 第 76 章 —— 20% 标签 + 80% 未标注,分别用两种半监督方法
y_train_semi = y_train.copy()
y_train_semi[~y_mask] = -1               # 把 80% 的标签遮蔽
f1_scores["SelfTraining"] = eval_and_get_f1(st_pipeline, X_train, y_train_semi, X_test, y_test)
f1_scores["LabelSpreading"] = eval_and_get_f1(ls_pipeline, X_train, y_train_semi, X_test, y_test)

这个例子揭示了一个反直觉的结论:在文本分类场景下,SelfTraining 比 LabelSpreading 更稳定——后者对核函数与图构建敏感,而前者只需基分类器能输出置信度即可工作。源码第 165-194 行通过 plt.bar 把四种策略的 F1 分数绘制成条形图,并标注 "SelfTraining classifier shows improved performance over supervised learning with limited data" 的结论。

76.4.6 决策边界对比与 predict_proba 解析

源码路径:examples/semi_supervised/plot_semi_supervised_versus_svm_iris.py - __main__(1-150行)

# 第 76 章 —— 三种标注比例下的对比
y_10 = np.copy(y); y_10[y_rand > 0.1] = -1  # 仅 10% 有标签
y_30 = np.copy(y); y_30[y_rand > 0.3] = -1  # 30% 有标签

ls10 = (LabelSpreading().fit(X, y_10), y_10, "LabelSpreading with 10% labeled data")
st10 = (SelfTrainingClassifier(base_classifier).fit(X, y_10), y_10, "Self-training with 10% labeled data")
# 第 76 章 —— ... 用 DecisionBoundaryDisplay 绘制概率等高线
for ax, (clf, y_train, title) in zip(axes, classifiers):
    DecisionBoundaryDisplay.from_estimator(
        clf,
        X,
        response_method="predict_proba",
        plot_method="contourf",
        ax=ax,
    )
    colors = [color_map[label] for label in y_train]
    ax.scatter(X[:, 0], X[:, 1], c=colors, edgecolor="black")
    ax.set_title(title)

文件后半部分揭秘了 predict_proba 的内部实现:

# 第 76 章 —— LabelSpreading 的 predict_proba:基于相似度的加权投票
W = rbf_kernel(x_query, X, gamma=ls.gamma)   # 查询点与所有训练点的相似度
probs = np.dot(W, ls.label_distributions_)   # 用相似度加权聚合软标签
probs /= probs.sum(axis=1, keepdims=True)    # 归一化为概率

# 第 76 章 —— SelfTraining 的 predict_proba:直接转发到最终基分类器
st = st10[0]
print(st.predict_proba(x_query))  # 等价于 st.estimator_.predict_proba(x_query)

这个差异点至关重要——LabelSpreading 必须存储全部训练数据用于推理(与 KNN 类似),而 SelfTrainingClassifier 一旦训练完成就只需保存最终基分类器,推理成本与监督学习无异。

76.5 高斯过程 —— 贝叶斯非参数的"不确定性量化师"

高斯过程是机器学习中最优雅的概率模型之一:它把"函数"视为无限维的高斯分布,用核函数编码函数相似度。scikit-learn 的 GP 模块既支持回归也支持分类,其核心武器是核工程。

graph LR A[核函数定义] --> B[RBF/ExpSineSquared/Matern/RQ/DotProduct] B --> C[+ 或 * 组合核] C --> D[GaussianProcessRegressor.fit] D --> E[LML 梯度上升优化超参数] E --> F[predict return_std=True] F --> G[均值 + 标准差] G --> H[不确定性量化]

76.5.1 周期性数据:GPR 与 KernelRidge 的对决

源码路径:examples/gaussian_process/plot_compare_gpr_krr.py - __main__(1-200行)

# 第 76 章 —— 构造周期性正弦信号
data = np.linspace(0, 30, num=1_000).reshape(-1, 1)
target = np.sin(data).ravel()              # 真实信号是 sin(x),周期 2π
training_sample_indices = rng.choice(np.arange(0, 400), size=40, replace=False)
training_noisy_target = target[training_sample_indices] + 0.5 * rng.randn(40)  # 加噪

训练样本只覆盖前 400 个点(约 0 到 2.5 个周期),但要外推到 30(约 4.8 个周期)。

# 第 76 章 —— KernelRidge:超参数靠 RandomizedSearchCV 暴力搜索
param_distributions = {
    "alpha": loguniform(1e0, 1e3),
    "kernel__length_scale": loguniform(1e-2, 1e2),
    "kernel__periodicity": loguniform(1e0, 1e1),
}
kernel_ridge_tuned = RandomizedSearchCV(
    kernel_ridge, param_distributions, n_iter=500, random_state=0
)

# 第 76 章 —— GaussianProcessRegressor:核超参数在 fit 时自动优化(LML 梯度上升)
kernel = 1.0 * ExpSineSquared(1.0, 5.0, periodicity_bounds=(1e-2, 1e1)) + WhiteKernel(1e-1)
gaussian_process = GaussianProcessRegressor(kernel=kernel)
gaussian_process.fit(training_data, training_noisy_target)  # 自动找到 periodicity≈2π

mean_predictions_gpr, std_predictions_gpr = gaussian_process.predict(
    data, return_std=True               # return_std=True 给出不确定性区间
)

GPR 的杀手锏是 return_std=True——它不仅给出均值预测,还给出每个点的标准差,这是 KernelRidge 永远无法提供的。

# 第 76 章 —— 用 RBF 抑制外推:乘积核让远处的周期波动衰减
kernel = 1.0 * ExpSineSquared(1.0, 5.0, periodicity_bounds=(1e-2, 1e1)) * RBF(
    length_scale=15, length_scale_bounds="fixed"   # length_scale=15 控制衰减速度
) + WhiteKernel(1e-1)

源码第 175-195 行通过 plt.fill_between±1.96*std 绘制成绿色半透明区间,让"预测的不确定性"在视觉上与"均值曲线"同步呈现。

76.5.2 GPC 概率预测:拉普拉斯近似的双刃剑

源码路径:examples/gaussian_process/plot_gpc.py - __main__(1-80行)

gp_fix = GaussianProcessClassifier(kernel=1.0 * RBF(length_scale=1.0), optimizer=None)
# 第 76 章 —— optimizer=None 冻结超参数,仅用初始化值
gp_fix.fit(X[:train_size], y[:train_size])

gp_opt = GaussianProcessClassifier(kernel=1.0 * RBF(length_scale=1.0))
# 第 76 章 —— optimizer 默认开启,通过 LML 优化超参数

print(
    "Log Marginal Likelihood (initial): %.3f"
    % gp_fix.log_marginal_likelihood(gp_fix.kernel_.theta)
)
print(
    "Log Marginal Likelihood (optimized): %.3f"
    % gp_opt.log_marginal_likelihood(gp_opt.kernel_.theta)
)
# 第 76 章 —— 优化后的 LML 更高(数学上更优),但测试对数损失反而更大!

这个例子揭示了 GPC 的微妙陷阱:优化超参数会提高 LML,但可能恶化概率校准。原因是拉普拉斯近似假设后验为高斯分布,优化后的超参数让决策边界变得陡峭,远离边界的位置概率趋近 0.5,这与真实后验偏离。

76.5.3 各向异性 RBF 核:在 Iris 上看 LML 提升

源码路径:examples/gaussian_process/plot_gpc_iris.py - __main__(1-80行)

kernel = 1.0 * RBF([1.0])                     # 各向同性:两个维度共用一个 length_scale
gpc_rbf_isotropic = GaussianProcessClassifier(kernel=kernel).fit(X, y)
kernel = 1.0 * RBF([1.0, 1.0])               # 各向异性:每个维度有独立的 length_scale
gpc_rbf_anisotropic = GaussianProcessClassifier(kernel=kernel).fit(X, y)

各向异性核为每个特征维度分配独立的 length_scale,让模型能捕捉不同方向上的相关性差异。

76.5.4 等概率线与自定义决策边界

源码路径:examples/gaussian_process/plot_gpc_isoprobability.py - g(16-18行)与 __main__(1-100行)

def g(x):
    """真实决策边界:5 - x1 - 0.5*x0^2"""
    return 5.0 - x[:, 1] - 0.5 * x[:, 0] ** 2.0

# 第 76 章 —— 构造 8 个手工设计点 + 标签
X = np.array([[-4.6, -6.0], [4.1, 5.3], ...])  # 8 个手工设计点
y = np.array(g(X) > 0, dtype=int)               # 标签由 g(x)>0 决定

# 第 76 章 —— 用 ConstantKernel * DotProduct**2 自定义核
kernel = C(0.1, (1e-5, np.inf)) * DotProduct(sigma_0=0.1) ** 2
gp = GaussianProcessClassifier(kernel=kernel)
gp.fit(X, y)

源码通过 plt.contour(..., [0.5], colors="k", linestyles="dashed") 绘制预测概率 0.5 的等高线(决策边界),与真实边界叠加对比。

76.5.5 XOR 数据:平稳 vs 非平稳核

源码路径:examples/gaussian_process/plot_gpc_xor.py - __main__(1-80行)

xx, yy = np.meshgrid(np.linspace(-3, 3, 50), np.linspace(-3, 3, 50))
rng = np.random.RandomState(0)
X = rng.randn(200, 2)
Y = np.logical_xor(X[:, 0] > 0, X[:, 1] > 0)   # XOR:坐标轴对齐的非线性

# 第 76 章 —— 对比 RBF(平稳)vs DotProduct(非平稳)
kernels = [1.0 * RBF(length_scale=1.15), 1.0 * DotProduct(sigma_0=1.0) ** 2]

XOR 数据的特殊之处在于"类别由坐标轴决定"——DotProduct 核能识别线性可分的子区域,而 RBF 核在这种场景下表现较差。

76.5.6 CO2 预测:复合核工程的巅峰之作

源码路径:examples/gaussian_process/plot_gpr_co2.py - __main__(1-150行)

# 第 76 章 —— 四个组件构成复合核
long_term_trend_kernel = 50.0**2 * RBF(length_scale=50.0)
seasonal_kernel = (
    2.0**2 * RBF(length_scale=100.0)
    * ExpSineSquared(length_scale=1.0, periodicity=1.0, periodicity_bounds="fixed")
)
irregularities_kernel = 0.5**2 * RationalQuadratic(length_scale=1.0, alpha=1.0)
noise_kernel = 0.1**2 * RBF(length_scale=0.1) + WhiteKernel(
    noise_level=0.1**2, noise_level_bounds=(1e-5, 1e5)
)
co2_kernel = (
    long_term_trend_kernel + seasonal_kernel + irregularities_kernel + noise_kernel
)

CO2 数据集是核工程的"巅峰练习场"——它包含长期上升趋势、年度周期、局部不规则、相关噪声、白噪声五大要素,必须用 + 把多个核拼接起来才能完整刻画。

76.5.7 噪声水平估计:WhiteKernel 的局部极小值陷阱

源码路径:examples/gaussian_process/plot_gpr_noisy.py - target_generator(12-17行)与 __main__(1-150行)

def target_generator(X, add_noise=False):
    """目标生成函数:0.5 + sin(3x) 可选加噪"""
    target = 0.5 + np.sin(3 * X)
    if add_noise:
        rng = np.random.RandomState(1)
        target += rng.normal(0, 0.3, size=target.shape)  # 噪声标准差 0.3
    return target.squeeze()  # 压缩为 1-D 数组

target_generator 提供了"无噪声基线信号"与"加噪观测"两种模式,方便控制实验变量。

# 第 76 章 —— 用错误的初始化让 WhiteKernel 与 RBF 互相"甩锅"
kernel = 1.0 * RBF(length_scale=1e1, length_scale_bounds=(1e-2, 1e3)) + WhiteKernel(
    noise_level=1, noise_level_bounds=(1e-10, 1e1)
)

源码绘制了"-LML 等高线景观图"——横轴是 length_scale,纵轴是 noise_level,颜色深浅代表负 LML。n_restarts_optimizer=10 随机重启可以让优化器跳出局部极小值。

76.5.8 基础 GPR:噪声自由 vs 已知噪声

源码路径:examples/gaussian_process/plot_gpr_noisy_targets.py - __main__(1-100行)

X = np.linspace(start=0, stop=10, num=1_000).reshape(-1, 1)
y = np.squeeze(X * np.sin(X))               # 真实信号:x * sin(x)

rng = np.random.RandomState(1)
training_indices = rng.choice(np.arange(y.size), size=6, replace=False)
X_train, y_train = X[training_indices], y[training_indices]   # 仅 6 个无噪声样本

# 第 76 章 —— 噪声自由:模型把训练点视为精确观测
gaussian_process = GaussianProcessRegressor(kernel=kernel, n_restarts_optimizer=9)
gaussian_process.fit(X_train, y_train)

# 第 76 章 —— 加噪版本:alpha=noise_std**2 把噪声的方差告知 GP
gaussian_process = GaussianProcessRegressor(
    kernel=kernel, alpha=noise_std**2, n_restarts_optimizer=9
)

alpha 参数可以理解为"训练目标值的 Tikhonov 正则化强度"——它显式告诉 GP"我对自己的观测有多自信"。

76.5.9 自定义序列核:在离散结构上做 GP

源码路径:examples/gaussian_process/plot_gpr_on_structured_data.py

SequenceKernel 是 GP 模块最强大的扩展点——只要实现五个方法,你的核函数就能享受 LML 自动优化、不确定性量化、回归/分类通用等所有 GP 红利:

# 第 76 章 —— __init__ (41-44行):初始化超参数及其边界
class SequenceKernel(GenericKernelMixin, Kernel):
    def __init__(self, baseline_similarity=0.5, baseline_similarity_bounds=(1e-5, 1)):
        # 保存用户传入的超参数值
        self.baseline_similarity = baseline_similarity
        # 保存优化器可调边界(搜索区间)
        self.baseline_similarity_bounds = baseline_similarity_bounds

    # hyperparameter_baseline_similarity (47-50行):告知优化器超参数的存在与范围
    @property
    def hyperparameter_baseline_similarity(self):
        # 把边界包装成 scikit-learn 的 Hyperparameter 对象
        return Hyperparameter("baseline_similarity", "numeric", self.baseline_similarity_bounds)

    # _f (52-59行):核函数值,匹配字符贡献 1.0,不匹配贡献 baseline_similarity
    def _f(self, s1, s2):
        # 双重循环遍历所有字符对:内层 c1 在 s1,外层 c2 在 s2
        return sum(
            [1.0 if c1 == c2 else self.baseline_similarity for c1 in s1 for c2 in s2]
        )

    # _g (62-67行):核值对 baseline_similarity 的偏导数
    def _g(self, s1, s2):
        # 同样双重遍历:不匹配字符对 = 导数贡献为 1.0
        return sum([0.0 if c1 == c2 else 1.0 for c1 in s1 for c2 in s2])

    # __call__ (69-81行):核矩阵计算,支持 eval_gradient
    def __call__(self, X, Y=None, eval_gradient=False):
        if Y is None:
            Y = X                                # 默认 Y=X(自核)
        if eval_gradient:
            # 双重列表推导构建核矩阵 K 与梯度矩阵 K_grad
            return (
                np.array([[self._f(x, y) for y in Y] for x in X]),  # K[i,j]=_f(X[i],Y[j])
                np.array([[[self._g(x, y)] for y in Y] for x in X]),  # K_grad[i,j,0]=_g(X[i],Y[j])
            )
        return np.array([[self._f(x, y) for y in Y] for x in X])

    # diag (83-84行):对角线元素 = 自相似度
    def diag(self, X):
        # 优化技巧:避免计算完整核矩阵只需 K(x_i, x_i)
        return np.array([self._f(x, x) for x in X])

    # is_stationary (86-87行):非平稳核标识
    def is_stationary(self):
        # 不匹配贡献固定值,不满足平稳性 → 返回 False
        return False

    # clone_with_theta (87-88行):超参数克隆
    def clone_with_theta(self, theta):
        # 用 joblib.clone 浅复制,再赋值新 theta(优化器调用)
        cloned = clone(self)
        cloned.theta = theta
        return cloned

_f_g 的协同是 GP 扩展的精髓:_f 给出核函数值,_g 给出对超参数的梯度——LML 优化器通过解析梯度在超参数空间爬升。双重列表推导 [c1 for c1 in s1 for c2 in s2] 把 R-卷积展开成"两两字符对"的完整笛卡尔积,是离散结构核的核心实现。

76.5.10 核食谱:先验与后验的可视化对比

源码路径:examples/gaussian_process/plot_gpr_prior_posterior.py - plot_gpr_samples(16-50行)与 __main__(1-200行)

def plot_gpr_samples(gpr_model, n_samples, ax):
    """绘制 GP 先验或后验样本"""
    x = np.linspace(0, 5, 100)
    X = x.reshape(-1, 1)

    y_mean, y_std = gpr_model.predict(X, return_std=True)
    y_samples = gpr_model.sample_y(X, n_samples)        # 从 GP 分布采样函数曲线

    for idx, single_prior in enumerate(y_samples.T):
        ax.plot(x, single_prior, linestyle="--", alpha=0.7)
    ax.plot(x, y_mean, color="black", label="Mean")
    ax.fill_between(x, y_mean - y_std, y_mean + y_std, alpha=0.1, color="black")

sample_y 是 GP 的"梦想生成器"——未训练时它从先验分布采样,训练后从后验采样(必须穿过训练点)。

源码在 5 个小节中依次展示了五种核(每个核都用相同的"先验 vs 后验"对比结构):

# 第 76 章 —— 1. Radial Basis Function kernel(各向同性、最平滑)
from sklearn.gaussian_process.kernels import RBF
kernel = 1.0 * RBF(length_scale=1.0, length_scale_bounds=(1e-1, 10.0))
gpr = GaussianProcessRegressor(kernel=kernel, random_state=0)

# 第 76 章 —— 2. Rational Quadratic kernel(RBF 的"无限混合",能更好拟合多尺度变化)
from sklearn.gaussian_process.kernels import RationalQuadratic
kernel = 1.0 * RationalQuadratic(length_scale=1.0, alpha=0.1, alpha_bounds=(1e-5, 1e15))
gpr = GaussianProcessRegressor(kernel=kernel, random_state=0)

# 第 76 章 —— 3. Exp-Sine-Squared kernel(周期性核,periodicity 决定周期)
from sklearn.gaussian_process.kernels import ExpSineSquared
kernel = 1.0 * ExpSineSquared(
    length_scale=1.0, periodicity=3.0,
    length_scale_bounds=(0.1, 10.0), periodicity_bounds=(1.0, 10.0)
)
gpr = GaussianProcessRegressor(kernel=kernel, random_state=0)

# 第 76 章 —— 4. Dot-product kernel(非平稳,捕捉线性趋势)
from sklearn.gaussian_process.kernels import ConstantKernel, DotProduct
kernel = ConstantKernel(0.1, (0.01, 10.0)) * (
    DotProduct(sigma_0=1.0, sigma_0_bounds=(0.1, 10.0)) ** 2
)
gpr = GaussianProcessRegressor(kernel=kernel, random_state=0, normalize_y=True)

# 第 76 章 —— 5. Matérn kernel(ν 参数控制粗糙程度,ν=1.5 比 RBF 粗糙)
from sklearn.gaussian_process.kernels import Matern
kernel = 1.0 * Matern(length_scale=1.0, length_scale_bounds=(1e-1, 10.0), nu=1.5)
gpr = GaussianProcessRegressor(kernel=kernel, random_state=0)

这五种核(RBF、RationalQuadratic、ExpSineSquared、DotProduct、Matern)覆盖了从平滑到粗糙、从平稳到非平稳的全谱函数假设:RBF 是无限可微的最平滑核;RationalQuadratic 把不同 length_scale 的 RBF 混合成多尺度核;ExpSineSquared 专门刻画周期性;DotProduct 是非平稳核(能识别线性趋势);Matern 通过参数 nu 在 RBF(nu→∞)和绝对指数核(nu=0.5)之间连续插值。

76.6 协方差估计 —— 多维散布矩阵的"鲁棒整形师"

协方差矩阵是统计学与机器学习的基石,但样本协方差(MLE)对异常值极其敏感。这一节探索如何用收缩、鲁棒、稀疏三大策略"整形"它。

graph TD A[协方差估计] --> B[收缩估计] A --> C[鲁棒估计] A --> D[稀疏估计] B --> B1[LedoitWolf 解析公式] B --> B2[OAS 高斯小样本优化] B --> B3[GridSearchCV 交叉验证] C --> C1[MinCovDet MCD] C --> C2[马氏距离异常检测] D --> D1[GraphicalLasso L1惩罚] D --> D2[精度矩阵稀疏化]

76.6.1 收缩估计:偏差-方差权衡

源码路径:examples/covariance/plot_covariance_estimation.py - __main__(1-120行)

# 第 76 章 —— 构造 20 样本 × 40 特征的高维小样本问题
n_features, n_samples = 40, 20
base_X_train = np.random.normal(size=(n_samples, n_features))
coloring_matrix = np.random.normal(size=(n_features, n_features))
X_train = np.dot(base_X_train, coloring_matrix)

# 第 76 章 —— 扫描不同收缩系数的负对数似然
shrinkages = np.logspace(-2, 0, 30)
negative_logliks = [
    -ShrunkCovariance(shrinkage=s).fit(X_train).score(X_test) for s in shrinkages
]

手工扫描 30 个收缩系数,寻找测试似然最优的 s

# 第 76 章 —— 1. GridSearchCV 在收缩系数网格上交叉验证
cv = GridSearchCV(ShrunkCovariance(), tuned_parameters)
cv.fit(X_train)

# 第 76 章 —— 2. LedoitWolf 解析公式(渐近最优)
lw = LedoitWolf()
loglik_lw = lw.fit(X_train).score(X_test)

# 第 76 章 —— 3. OAS 改进(高斯假设下小样本更优)
oa = OAS()
loglik_oa = oa.fit(X_train).score(X_test)

LedoitWolf 表现近乎最优,且计算成本远低于交叉验证。OAS 在高斯数据上的收敛速度比 LW 更快。

76.6.2 LW vs OAS:MSE 与样本量的关系

源码路径:examples/covariance/plot_lw_vs_oas.py - __main__(1-100行)

n_features = 100
real_cov = toeplitz(r ** np.arange(n_features))    # AR(1) 协方差矩阵
n_samples_range = np.arange(6, 31, 1)
repeat = 100

for i, n_samples in enumerate(n_samples_range):
    for j in range(repeat):
        X = np.dot(np.random.normal(size=(n_samples, n_features)), coloring_matrix.T)
        lw = LedoitWolf().fit(X)
        lw_mse[i, j] = lw.error_norm(real_cov, scaling=False)
        oa = OAS().fit(X)
        oa_mse[i, j] = oa.error_norm(real_cov, scaling=False)

OAS 假设高斯分布,在小样本下收敛更快——OAS 曲线始终位于 LW 曲线下方。

76.6.3 鲁棒估计:MCD 的崩溃点

源码路径:examples/covariance/plot_mahalanobis_distances.py - __main__(1-120行)

# 第 76 章 —— 构造 125 样本,混入 25 个方差膨胀 7 倍的异常值
n_outliers = 25
outliers_cov = np.eye(n_features)
outliers_cov[np.arange(1, n_features), np.arange(1, n_features)] = 7.0
X[-n_outliers:] = np.dot(np.random.randn(n_outliers, n_features), outliers_cov)

# 第 76 章 —— 拟合两种估计器
robust_cov = MinCovDet().fit(X)                        # MCD 鲁棒估计
emp_cov = EmpiricalCovariance().fit(X)                  # MLE 经验协方差

MCD(最小协方差行列式)的核心思想是:寻找 n_samples + n_features + 1 / 2 个"最纯净"的样本,让它们的经验协方差行列式最小。这样即使有高达 (n_samples - n_features - 1) / 2 个异常值(即"崩溃点"),估计依然稳健。

# 第 76 章 —— 计算网格上每个点的马氏距离
mahal_emp_cov = emp_cov.mahalanobis(zz).reshape(xx.shape)
mahal_robust_cov = robust_cov.mahalanobis(zz).reshape(xx.shape)

源码还演示了 Wilson-Hilferty 变换的实际代码实现——通过立方根把马氏距离的偏态分布近似为正态分布:

# 第 76 章 —— Wilson-Hilferty 变换:d^(1/3) ≈ 正态分布(用于箱线图可视化)
# 第 76 章 —— Calculate cubic root of MLE Mahalanobis distances for samples
emp_mahal = emp_cov.mahalanobis(X - np.mean(X, 0)) ** (0.33)  # **0.33 ≈ 开立方根
# 第 76 章 —— 同样对 MCD 估计的马氏距离做变换
robust_mahal = robust_cov.mahalanobis(X - robust_cov.location_) ** (0.33)
# 第 76 章 —— 然后用 ax.boxplot 绘制两组样本的箱线图
ax1.boxplot([emp_mahal[:-n_outliers], emp_mahal[-n_outliers:]], widths=0.25)
ax2.boxplot([robust_mahal[:-n_outliers], robust_mahal[-n_outliers:]], widths=0.25)

马氏距离的立方根近似正态分布(Wilson-Hilferty 变换),源码在第二张图中用 mahal ** 0.33 绘制箱线图,让"inlier 与 outlier 在 MCD 下能完全分离"形成视觉对比。

76.6.4 鲁棒 vs 经验:污染比例对估计误差的影响

源码路径:examples/covariance/plot_robust_vs_empirical_covariance.py - __main__(1-120行)

range_n_outliers = np.concatenate(
    (np.linspace(0, n_samples / 8, 5), np.linspace(n_samples / 8, n_samples / 2, 5)[1:-1])
).astype(int)

for i, n_outliers in enumerate(range_n_outliers):
    for j in range(repeat):
        X = rng.randn(n_samples, n_features)
        outliers_index = rng.permutation(n_samples)[:n_outliers]
        outliers_offset = 10.0 * (np.random.randint(2, size=(n_outliers, n_features)) - 0.5)
        X[outliers_index] += outliers_offset

        mcd = MinCovDet().fit(X)
        err_loc_mcd[i, j] = np.sum(mcd.location_**2)
        err_cov_mcd[i, j] = mcd.error_norm(np.eye(n_features))

MCD 曲线几乎与"完美基线"重合,证实"它能在不知情的情况下接近上帝视角"。

76.6.5 稀疏逆协方差:图结构学习

源码路径:examples/covariance/plot_sparse_cov.py - __main__(1-100行)

# 第 76 章 —— 生成带稀疏逆协方差的玩具数据
prec = make_sparse_spd_matrix(n_features, alpha=0.98, smallest_coef=0.4,
                              largest_coef=0.7, random_state=prng)
cov = linalg.inv(prec)                                # 真实协方差
X = prng.multivariate_normal(np.zeros(n_features), cov, size=n_samples)

# 第 76 章 —— 估计稀疏逆协方差
model = GraphicalLassoCV()
model.fit(X)
cov_ = model.covariance_
prec_ = model.precision_                               # 稀疏精度矩阵

GraphicalLasso 的 L1 惩罚让精度矩阵的非对角系数稀疏化——非零位置对应变量间的条件依赖关系(高斯图模型),零位置则对应条件独立。

76.7 多任务策略 —— 复杂输出空间的"分解与协同"

当问题从二分类扩展到多类别、多标签、多输出时,需要新的策略来组织多个子任务。

graph LR subgraph 多类别分类 A[OvO 两两PK] --> A1[训练K*(K-1)/2个二分类器] A2[OvR 一对多] --> A3[训练K个二分类器] A4[ECOC 纠错编码] --> A5[码本控制码字长度] end subgraph 多标签分类 B[ClassifierChain 接力赛] --> B1[第i个标签预测作为第i+1特征] B2[多链集成] --> B3[不同顺序链投票抹平偏差] B4[独立OvR] --> B5[忽略标签相关性] end subgraph 多输出回归 C[MultiOutput 并行分队] --> C1[每列独立模型同时训练] C2[独立并行] --> C3[彼此互不干扰] end A1 --> D[集团军作战编制] B3 --> D C1 --> D

76.7.1 OvO/OvR/ECOC 三大多类别分解器

源码路径:examples/multiclass/plot_multiclass_overview.py - __main__(1-120行)

tree = DecisionTreeClassifier(random_state=0)
ovo_tree = OneVsOneClassifier(tree)                   # 训练 K(K-1)/2 个二分类器
ovr_tree = OneVsRestClassifier(tree)                   # 训练 K 个二分类器
ecoc = OutputCodeClassifier(tree, code_size=2)        # ECOC 纠错编码

cv_results_tree = cross_validate(tree, X, y, cv=cv, n_jobs=2)
cv_results_ovo = cross_validate(ovo_tree, X, y, cv=cv, n_jobs=2)
cv_results_ovr = cross_validate(ovr_tree, X, y, cv=cv, n_jobs=2)
cv_results_ecoc = cross_validate(ecoc, X, y, cv=cv, n_jobs=2)

未优化超参数时,OvO 和 ECOC 因集成多个分类器而表现更好;但一旦用 GridSearchCV 优化决策树深度,所有策略趋同:

param_grid = {"max_depth": [3, 5, 8]}
tree_optimized = GridSearchCV(tree, param_grid=param_grid, cv=3)
ovo_tree = OneVsOneClassifier(tree_optimized)
ovr_tree = OneVsRestClassifier(tree_optimized)

76.7.2 链式多标签建模

源码路径:examples/multioutput/plot_classifier_chain_yeast.py - __main__(1-120行)

# 第 76 章 —— 酵母数据集:2417 样本 × 103 特征 × 14 个标签
X, Y = fetch_openml("yeast", version=4, return_X_y=True)
Y = Y == "TRUE"                                        # 布尔化标签

# 第 76 章 —— 独立 OvR(忽略标签相关性)
ovr = OneVsRestClassifier(LogisticRegression())
ovr.fit(X_train, Y_train)
ovr_jaccard_score = jaccard_score(Y_test, ovr.predict(X_test), average="samples")

# 第 76 章 —— 10 条随机顺序的 ClassifierChain
chains = [ClassifierChain(base_lr, order="random", random_state=i) for i in range(10)]
for chain in chains:
    chain.fit(X_train, Y_train)

# 第 76 章 —— 链集成:所有链的概率取平均,再阈值化为 0.5
Y_pred_chains = np.array([chain.predict_proba(X_test) for chain in chains])
Y_pred_ensemble = Y_pred_chains.mean(axis=0)
ensemble_jaccard_score = jaccard_score(Y_test, Y_pred_ensemble >= 0.5, average="samples")

ClassifierChain 把多标签问题转化为一条链——链中第 i 个分类器把前面所有分类器的预测当作额外特征。order='random' 加多链集成缓解了对链顺序的敏感性。

76.7.3 多标签模拟:PCA vs CCA 降维

源码路径:examples/miscellaneous/plot_multilabel.py - plot_hyperplane(27-36行)、plot_subfigure(39-80行)与 __main__(1-100行)

def plot_hyperplane(clf, min_x, max_x, linestyle, label):
    """线性 SVC 超平面绘制辅助"""
    # 取出线性分类器的法向量 w 与偏置
    w = clf.coef_[0]
    # 计算超平面斜率:y = a*x - intercept/w[1]
    a = -w[0] / w[1]
    xx = np.linspace(min_x - 5, max_x + 5)  # 延长线条超出数据范围
    yy = a * xx - (clf.intercept_[0]) / w[1]
    plt.plot(xx, yy, linestyle, label=label)

def plot_subfigure(X, Y, subplot, title, transform):
    """PCA/CCA 降维后 OneVsRest 分类可视化"""
    if transform == "pca":
        X = PCA(n_components=2).fit_transform(X)               # 无监督 PCA
    elif transform == "cca":
        X = CCA(n_components=2).fit(X, Y).transform(X)        # 有监督 CCA(用标签信息)
    # ... 用 OneVsRestClassifier(SVC(kernel="linear")) 训练两个分类器

CCA 因利用了标签信息,降维后的两个类别在二维平面上分离度更好,分类超平面更精准。

76.7.4 多输出人脸补全

源码路径:examples/miscellaneous/plot_multioutput_face_completion.py - __main__(1-100行)

data, targets = fetch_olivetti_faces(return_X_y=True)
train = data[targets < 30]                             # 前 30 个人的照片
test = data[targets >= 30]                             # 后 10 个人

n_pixels = data.shape[1]
X_train = train[:, : (n_pixels + 1) // 2]              # 上半脸作为输入
y_train = train[:, n_pixels // 2 :]                       # 下半脸作为输出

ESTIMATORS = {
    "Extra trees": ExtraTreesRegressor(n_estimators=10, max_features=32, random_state=0),
    "K-nn": KNeighborsRegressor(),
    "Linear regression": LinearRegression(),
    "Ridge": RidgeCV(),
}
posted @ 2026-09-04 04:09  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报