机器学习——打开集成方法的大门,手把手带你实现AdaBoost模型

2020-06-17 17:42:49 浏览数 (1)

今天是机器学习专题的第25篇文章,我们一起来聊聊AdaBoost。

我们目前为止已经学过了好几个模型,光决策树的生成算法就有三种。但是我们每次进行分类的时候,每次都是采用一个模型进行训练和预测。我们日常在做一个决策的时候,往往会咨询好几个人,综合采纳他们的意见。那么有没有可能把这个思路照搬到机器学习领域当中,创建多个模型来综合得出结果呢?

这当然是可以的,这样的思路就叫做集成方法(ensemble method)。

集成方法

集成方法本身并不是某种具体的方法或者是算法,只是一种训练机器学习模型的思路。它的含义只有一点,就是训练多个模型,然后将它们的结果汇聚在一起。

根据这个思路,业内又衍生出了三种特定的方法,分别是Bagging、Boosting和Stacking。

Bagging

Bagging是bootstrap aggregating的缩写,我们从字面上很难理解它的含义。我们记住这个名字即可,在Bagging方法当中,我们会通过有放回随机采样的方式创建K个数据集。对于每一个数据集来说,可能有一些单个的样本重复出现,也可能有一些样本从没有出现过,但整体而言,每个样本出现的概率是相同的。

之后,我们用抽样出来的K个数据集训练K个模型,这里的模型没有做限制,我们可以使用任何机器学习方模型。K个模型自然会得到K个结果,那么我们采取民主投票的方式对这K个模型进行聚合。

举个例子说,假设K=25,在一个二分类问题当中。有10个模型预测结果是0,15个模型预测结果是1。那么最终整个模型的预测结果就是1,相当于K个模型民主投票,每个模型投票权一样。大名鼎鼎的随机森林就是采取的这种方式。

Boosting

Boosting的思路和Bagging非常相似,它们对于样本的采样逻辑是一致的。不同的是,在Boosting当中,这K个模型并不是同时训练的,而是串行训练的。每一个模型在训练的时候都会基于之前模型的结果,更加关注于被之前模型判断错误的样本。同样,样本也会有一个权值,错误判断率越大的样本拥有越大的权值。

并且每一个模型根据它能力的不同,会被赋予不同的权重,最后会对所有模型进行加权求和,而不是公平投票。由于这个机制,使得模型在训练的时候的效率也有差异。因为Bagging所有模型之间是完全独立的,我们是可以采取分布式训练的。而Boosting中每一个模型会依赖之前模型的效果,所以只能串行训练。

Stacking

Stacking是Kaggle比赛当中经常使用的方法,它的思路也非常简单。我们选择K种不同的模型,然后通过交叉验证的方式,在训练集上进行训练和预测。保证每个模型都对所有的训练样本产出一个预测结果。那么对于每一条训练样本,我们都能得到K个结果。

之后,我们再创建一个第二层的模型,它的训练特征就是这K个结果。也就是说Stacking方法当中会用到多层模型的结构,最后一层模型的训练特征是上层模型预测的结果。由模型自己去训练究竟哪一个模型的结果更值得采纳,以及如何组合模型之间的特长。

我们今天介绍的AdaBoost顾名思义,是一个经典的Boosting算法。

模型思路

AdaBoost的核心思路是通过使用Boosting的方法,通过一些弱分类器构建出强分类器来。

强分类器我们都很好理解,就是性能很强的模型,那么弱分类器应该怎么理解呢?模型的强弱其实是相对于随机结果来定义的,比随机结果越好的模型,它的性能越强。从这点出发,弱分类器也就是只比随机结果略强的分类器。我们的目的是通过设计样本和模型的权重,使得可以做出最佳决策,将这些弱分类器的结果综合出强分类器的效果来。

首先我们会给训练样本赋予一个权重,一开始的时候,每一条样本的权重均相等。根据训练样本训练出一个弱分类器并计算这个分类器的错误率。然后在同一个数据集上再次训练弱分类器,在第二次的训练当中,我们将会调整每个样本的权重。其中正确的样本权重会降低,错误的样本权重会升高

同样每一个分类器也会分配到一个权重值,权重越高说明它的话语权越大。这些是根据模型的错误率来计算的。错误率定义为:

这里的D表示数据集表示分类错误的集合,它也就等于错误分类的样本数除以总样本数。

有了错误率之后,我们可以根据下面这个公式得到。

得到了之后,我们利用它对样本的权重进行更新,其中分类正确的权重更改为:

分类错误的样本权重更改为:

这样,我们所有的权重都更新完了,这也就完成了一轮迭代。AdaBoost会反复进行迭代和调整权重,直到训练错误率为0或者是弱分类器的数量达到阈值。

代码实现

首先,我们来获取数据,这里我们选择了sklearn数据集中的乳腺癌预测数据。和之前的例子一样,我们可以直接import进来使用,非常方便:

代码语言:javascript复制
import numpy as np
import pandas as pd
from sklearn.datasets import load_breast_cancer

breast = load_breast_cancer()
X, y = breast.data, breast.target
# reshape,将一维向量转成二维
y = y.reshape((-1, 1))

接着,我们将数据拆分成训练数据和测试数据,这个也是常规做法了,没有难度:

代码语言:javascript复制
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=23)

在AdaBoost模型当中,我们选择的弱分类器是决策树的树桩。所谓的树桩就是树深为1的决策树。树深为1显然不论我们怎么选择阈值,都不会得到特别好的结果,但是由于我们依然会选择阈值和特征,所以结果也不会太差,至少要比随机选择要好。所以这就保证了,我们可以得到一个比随机选择效果略好一些的弱分类器,并且它的实现非常简单。

在我们实现模型之前,我们先来实现几个辅助函数。

代码语言:javascript复制
def loss_error(y_pred, y, weight):
    return weight.T.dot((y_pred != y_train))

def stump_classify(X, idx, threshold, comparator):
    if comparator == 'lt':
        return X[:, idx] <= threshold
    else:
        return X[:, idx] > threshold
    
def get_thresholds(X, i):
    min_val, max_val = X[:, i].min(), X[:, i].max()
    return np.linspace(min_val, max_val, 10)

这三个函数应该都不难理解,第一个函数当中我们计算了模型的误差。由于我们每一个样本拥有一个自身的权重,所以我们对误差进行加权求和。第二个函数是树桩分类器的预测函数,逻辑非常简单,根据阈值比较大小。这里有两种情况,有可能小于阈值的样本是正例,也有可能大于阈值的样本是正例,所以我们还需要第三个参数记录这个信息。第三个函数是生成阈值的函数,由于我们并不需要树桩的性能特别好,所以我们也没有必要去遍历阈值的所有取值,简单地把特征的范围划分成10段即可。

接下来是单个树桩的生成函数,它等价于决策树当中选择特征进行数据拆分的函数,逻辑大同小异,只需要稍作修改即可。

代码语言:javascript复制
def build_stump(X, y, weight):
    m, n = X.shape
    ret_stump, ret_pred = None, []
    best_error = float('inf')

    # 枚举特征
    for i in range(n):
        # 枚举阈值
        for j in get_thresholds(X, i):
            # 枚举正例两种情况
            for c in ['lt', 'gt']:
                # 预测并且求误差
                pred = stump_classify(X, i, j, c).reshape((-1, 1))
                err = loss_error(pred, y, weight)
                # 记录下最好的树桩
                if err < best_error:
                    best_error, ret_pred = err, pred.copy()
                    ret_stump = {'idx': i, 'threshold': j, 'comparator': c} 
    return ret_stump, best_error, ret_pred

接下来要做的就是重复生成树桩的操作,计算和,并且更新每一条样本的权重。整个过程也没有太多的难点,基本上就是照着实现公式:

代码语言:javascript复制
def adaboost_train(X, y, num_stump):
    stumps = []
    m = X.shape[0]
    # 样本权重初始化,一开始全部相等
    weight = np.ones((y_train.shape[0], 1)) / y_train.shape[0]
    # 生成num_stump个树桩
    for i in range(num_stump):
        best_stump, err, pred = build_stump(X, y, weight)
        # 计算alpha
        alpha = 0.5 * np.log((1.0 - err) / max(err, 1e-10))
        best_stump['alpha'] = alpha
        stumps.append(best_stump)

        # 更新每一条样本的权重
        for j in range(m):
            weight[j] = weight[j] * (np.exp(-alpha) if pred[j] == y[j] else np.exp(alpha))
        weight = weight / weight.sum()
        # 如果当前的准确率已经非常高,则退出
        if err < 1e-8:
            break
    return stumps

树桩生成结束之后,最后就是预测的部分了。整个预测过程依然非常简单,就是一个加权求和的过程。这里要注意一下,我们在训练的时候为了突出错误预测的样本,让模型拥有更好的能力,维护了样本的权重。然而在预测的时候,我们是不知道预测样本的权重的,所以我们只需要对模型的结果进行加权即可。

代码语言:javascript复制
def adaboost_classify(X, stumps):
    m = X.shape[0]
    pred = np.ones((m, 1))
    alphs = 0.0
    for i, stump in enumerate(stumps):
        y_pred = stump_classify(X, stump['idx'], stump['threshold'], stump['comparator'])
        # 根据alpha加权求和
        pred = y_pred * stump['alpha']
        alphs  = stump['alpha']
    pred /= alphs
    # 根据0.5划分0和1类别
    return np.sign(pred).reshape((-1, 1))

到这里,我们整个模型就实现完了,我们先来看下单个树桩在训练集上的表现:

可以看到准确率只有0.54,只是比随机预测略好一点点而已。

然而当我们综合了20个树桩的结果之后,在训练集上我们可以得到0.9的准确率。在预测集上,它的表现更好,准确率有接近0.95!

这是因为AdaBoost当中,每一个分类器都是弱分类器,它根本没有过拟合的能力,毕竟在训练集的表现都很差,这就保证了分类器学到的都是实在的泛化能力,在训练集上适用,在测试集上很大概率也适用。这也是集成方法最大的优点之一。

总结

集成方法可以说是机器学习领域一个非常重要的飞跃,集成方法的出现,让设计出一个强分类器这件事的难度大大降低,并且还保证了模型的效果。

因为在一些领域当中,设计一个强分类器可能非常困难,然而设计一个弱一些的分类器则简单得多,再加上模型本身性能很好,不容易陷入过拟合。使得在深度学习模型流行之前,集成方法广泛使用,几乎所有机器学习领域的比赛的冠军,都使用了集成学习。

集成学习当中具体的思想或许各有不同,但是核心的思路是一致的。我们理解了AdaBoost之后,再去学习其他的集成模型就要容易多了。

0 人点赞