第五周完成了《机器学习实战》第 7 章 AdaBoost 的源码复现。从 5 个简单样本开始,我先寻找一棵只有一个判断条件的决策树桩,再调整样本权重、训练下一棵树,最后让多棵树按可靠程度加权投票。这个过程把“集成学习”从一句抽象定义,变成了一套可以逐行跟踪的纠错机制。

这篇复盘会讲清楚

  • 一棵只会问一个问题的树桩怎样分类
  • 错分样本为什么会在下一轮变得更重要
  • alpha 怎样决定每棵弱分类器的投票权
  • 病马预测、ROC 和 AUC 的结果应该怎样解读

一、本周技术主线:让错误成为下一轮的教材

AdaBoost 的全称是 Adaptive Boosting,可以翻译为“自适应提升”。它不要求一次训练出一个非常复杂、非常强的模型,而是反复训练多个能力一般的弱分类器

  1. 第一轮让所有训练样本同样重要。
  2. 找到当前加权错误率最低的弱分类器。
  3. 提高被分错样本的权重,降低被分对样本的相对权重。
  4. 下一轮重点处理上一轮的难题。
  5. 最终让所有弱分类器按可靠程度加权投票。
AdaBoost 三轮训练流程:第一轮样本同权,之后提高错分样本权重,最终三棵树桩按 alpha 加权投票
图 1:AdaBoost 的关键不是“堆很多模型”,而是后一轮会根据前一轮的错误改变学习重点。
一个弱分类器负责解决一部分问题;被它忽略的难题,会被下一位分类器放大并重新学习。

二、弱分类器有多弱:只问一个问题

本章使用的弱分类器是单层决策树,也叫决策树桩。它不像普通决策树那样连续分裂,而是只选择:

例如:

如果第 0 个特征 ≤ 1.3,就预测为 −1;否则预测为 +1。

实现只需要生成一列初始为 +1 的结果,再按条件把一部分改成 −1

def stump_classify(data, feature_index, threshold, inequality):
    predictions = np.ones((len(data), 1))

    if inequality == "lt":
        predictions[data[:, feature_index] <= threshold] = -1.0
    else:
        predictions[data[:, feature_index] > threshold] = -1.0

    return predictions

它表达能力有限,却有两个优点:训练快,而且每条规则都能直接读懂。AdaBoost 要做的,就是把多条这种简单规则组合起来。

三、第一棵树桩是怎样找到的

简单数据集只有 5 个二维样本:

样本特征 0特征 1标签
11.02.1+1
22.01.1+1
31.31.0−1
41.01.0−1
52.01.0+1

训练函数遍历每个特征、若干候选阈值以及 lt/gt 两种方向。第一轮所有样本权重相同,都是 1/5,因此分错任何一个样本都增加 0.2 的错误。

实际找到的第一棵最佳树桩为:

特征下标:0
阈值:1.3
判断方向:小于等于阈值时预测为 -1
加权错误率:0.2000
预测结果:[-1, +1, -1, -1, +1]

第一条样本真实标签为 +1,却被预测为 −1,所以这棵树桩分错了 1 个样本。AdaBoost 不会把它丢掉,而是把这次错误变成下一轮的训练重点。

四、普通错误率为什么不够

如果每轮都只计算“错了几个”,后面的分类器仍会用相同方式看待所有数据,就谈不上重点纠错。AdaBoost 为每个样本维护一个权重 Dᵢ

加权错误率 ε = Σ Dᵢ × [预测ᵢ ≠ 标签ᵢ]

方括号内的判断分错时为 1,分对时为 0。于是:

errors = np.ones((sample_count, 1))
errors[predictions == labels] = 0
weighted_error = float(D.T @ errors)

同一组数据在不同轮次拥有不同的 D,因此每轮选出的最佳树桩也可能不同。这就是“自适应”的具体含义。

五、alpha:分类器说话的分量

每棵树桩还有一个权重 alpha,它决定最终投票时有多少话语权:

alpha = 0.5 × ln[(1 − ε) ÷ ε]

先从结果理解公式:

第一棵树桩的错误率为 0.2:

alpha = 0.5 × ln(0.8 ÷ 0.2) = 0.6931

第二棵树桩的加权错误率为 0.125,因此 alpha = 0.9730,比第一棵更大。它在当前难题分布上更可靠,最终投票时声音也更响。

六、为什么错分样本会变重

样本权重的更新公式是本周最难理解的地方:

Dᵢ ← Dᵢ × exp(−alpha × yᵢ × hᵢ) ÷ Z

这里 yᵢ 是真实标签,hᵢ 是树桩预测,两者都只能是 +1−1。把情况拆开后,指数公式就不再神秘:

预测正确

yᵢ × hᵢ = +1,权重乘以 exp(−alpha),相对变小。

预测错误

yᵢ × hᵢ = −1,权重乘以 exp(+alpha),相对变大。

Z 是归一化因子,让更新后的所有权重重新加起来等于 1。它不会改变样本之间谁大谁小,只是把权重拉回统一尺度。

exponent = -alpha * true_labels * stump_predictions
D = D * np.exp(exponent)
D = D / D.sum()

指数函数带来对称效果:分对的样本按 e⁻ᵅ 缩小,分错的样本按 eᵅ 放大。alpha 越大的可靠分类器,它的对错对下一轮影响也越强。

七、三轮训练:弱分类器怎样变强

简单数据的真实训练过程为:

轮次树桩加权错误率alpha组合训练错误率
10.20000.69310.2000
20.12500.97300.2000
30.14290.89590.0000

这里有一个容易误读的地方:第二、三棵树桩的错误率是按照当轮样本权重计算的,不能简单理解成“5 个样本错了 12.5%”。权重已经不再平均。

单独看,每棵树桩都没有把所有样本分对;三棵树按 alpha 加权投票后,组合训练错误率降到 0。代码检测到全部训练样本分类正确,就提前结束,不再盲目训练到预设的 9 轮。

八、最终分类:不是少数服从多数

AdaBoost 不是每棵树一人一票,而是可靠者拥有更大的投票权:

F(x) = Σ alphaₜ × hₜ(x)

最终类别 = sign[F(x)]

aggregate_score = np.zeros((len(samples), 1))

for classifier in weak_classifiers:
    result = stump_classify(
        samples,
        classifier["dim"],
        classifier["thresh"],
        classifier["ineq"],
    )
    aggregate_score += classifier["alpha"] * result

predictions = np.sign(aggregate_score)

简单测试得到:

样本 [0, 0] 的预测类别:-1
样本 [5, 5] 的预测类别:+1

aggregate_score 不只是临时变量。它的正负决定类别,绝对值则反映当前加权投票离边界有多远,后面的 ROC 曲线正是利用这组预测强度进行排序。

九、病马数据:从 5 个样本走向真实测试集

小样本适合理解公式,但不能说明模型面对真实数据的表现。本周继续使用作者配套的病马数据:

训练样本数量299
测试样本数量67
模型弱分类器数量10
标签类别取值−1 / +1
train_data, train_labels = load_data("horseColicTraining2.txt")
test_data, test_labels = load_data("horseColicTest2.txt")

classifiers, train_scores = train_adaboost(
    train_data,
    train_labels,
    iterations=10,
)

train_predictions = classify(train_data, classifiers)
test_predictions = classify(test_data, classifiers)

实际运行结果:

训练样本数:299
测试样本数:67
弱分类器数量:10
训练集错误率:0.230769
测试集错误率:0.238806

测试错误率 23.8806%,也就是 67 条测试数据中约有 16 条分错。训练和测试错误率接近,至少这一次没有出现“训练集极好、测试集明显恶化”的巨大落差。

十、为什么还要画 ROC 曲线

错误率只观察最终阈值下“分对多少”。ROC 曲线不固定在一个阈值,而是让阈值从严格到宽松移动,观察两个比例:

模型先按 aggregate_score 从小到大排序,从坐标 (1,1) 开始移动:

AdaBoost 病马训练结果 ROC 曲线,蓝色曲线明显高于随机猜测对角线,AUC 为0.858668
图 2:本次 ROC 使用的是 299 条训练数据的组合预测强度;蓝线越靠近左上角,排序区分能力越强。

十一、AUC = 0.858668 应该怎样理解

AUC 是 ROC 曲线下的面积。它也可以直观理解为:随机抽一个正样本和一个负样本,模型把正样本排在负样本前面的概率。

但必须把边界说清楚:这张 ROC 使用的是训练集预测强度,不是独立测试集分数,所以它更适合验证绘图和理解算法流程,不能作为最终泛化能力的无偏估计。更正规的下一步是保留测试集组合分数,在测试集上单独计算 ROC-AUC。

另外,AUC 不是准确率。23.8806% 测试错误率回答“当前分类规则错了多少”,0.858668 的训练 AUC 回答“模型在不同阈值下区分正负样本的排序能力怎样”,两者观察角度不同。

十二、本周完成的不只是算法,还有兼容性整理

源码复现过程中还完成了几项工程性处理:

def test_roc_image_preserves_chinese_labels(self):
    plot_roc(scores, labels, save_path=output_path, show=False)

    self.assertTrue(output_path.exists())
    self.assertEqual("AdaBoost病马预测ROC曲线", axis.get_title())
    self.assertEqual("假阳性率", axis.get_xlabel())
    self.assertEqual("真正率", axis.get_ylabel())
    self.assertEqual([], missing_glyph_warnings)

实际测试通过。这个部分提醒我:算法得到正确数字只是第一步,路径、字体、图片输出和可重复验证同样属于一次完整实验。

十三、本周真正卡住的三个问题

  1. 样本权重更新公式为什么使用指数。现在已经能通过 y × h 的正负解释分对缩小、分错放大,但还需要从指数损失最小化的角度理解推导。
  2. alpha 公式中的 0.5 从哪里来。已经理解错误率越低投票权越大,但推导仍不熟,不能只停留在背公式。
  3. ROC 的移动和面积计算。现在能顺着排序代码解释正样本纵向、负样本横向,但仍需手算一个更小例子,把矩形面积累加与 AUC 对应起来。

把“会运行”和“会解释”分开记录,是这份周报很有价值的地方。代码跑通不代表概念完全掌握;明确写下知识缺口,比用一句“基本掌握”更容易指导下一周。

十四、从上周 SVM 到本周 AdaBoost

上周学习简化版 SMO 时,难点集中在为什么要同时选择并更新两个 alpha。本周重新按函数顺序梳理后,对那条训练主线比之前清楚了一些,但 RBF 核参数对比仍未完成。

上周的 SVM

寻找能最大化间隔的边界,重点是支持向量、alpha 配对更新和核函数。

本周的 AdaBoost

组合多条简单规则,重点是错分样本权重、弱分类器投票权和逐轮纠错。

两个算法路线不同,却都在说明同一件事:模型训练不是一次性“算出答案”,而是围绕目标函数不断调整哪些样本、参数或边界更重要。

十五、下周计划:从复现走向对比实验

  1. 01
    手工复盘三轮 AdaBoost

    记录每轮树桩、样本权重、alpha 和累计分数,真正算清更新过程。

  2. 02
    改变弱分类器数量

    比较 1、5、10、20、40 轮下的训练错误、测试错误和测试集 AUC。

  3. 03
    补做 SVM 参数实验

    完成 RBF 核参数对比,不让上周遗留问题继续悬空。

  4. 04
    进入第 8 章回归

    学习标准线性回归、局部加权线性回归和误差计算。

十六、第五周最值得保留的认识

弱规则错题权重投票复盘

一棵树桩只是一个弱规则;AdaBoost 把上一轮的错题放大,用样本权重改变下一轮学习重点,再让可靠的分类器拥有更大的投票权。最后的复盘则提醒我,实验结果、公式理解和工程复现是三条需要同时推进的线。

如果只用一句话总结第五周,那就是:我不再把集成学习理解成“多训练几个模型”,而是开始理解多个弱模型怎样围绕彼此的错误形成协作。