V · 8月2日

深入浅出:可视化理解揭示决策树与梯度提升背后的数学原理

决策树是一种非参数的监督学习算法,可用于分类和回归。它使用类似树的结构来表示决策及其潜在结果。决策树易于理解和解释,并且可以轻松地进行可视化。但是当决策树模型变得过于复杂时,它不能很好地从训练数据中泛化,会导致过拟合。

梯度提升是一种集成学习模型,在其中结合许多弱学习器从而得到一个强学习器。这些弱学习器是各个决策树,每个学习器都试图关注前一个学习器的错误。与单独的深层决策树相比,梯度提升通常不太容易过拟合。

本文将通过视觉方式解释用于分类和回归问题的决策树的理论基础。我们将看到这个模型是如何工作的,以及为什么它可能会导致过拟合。首先将介绍梯度提升以及它是如何改善单个决策树的性能的。然后将用Python从头实现梯度提升回归器和分类器。最后详细解释梯度提升背后的数学原理。

image.png

决策树分类器

决策树模型可用于分类和回归问题。让我们看看决策树分类器是如何工作的。首先,创建一个示例的数据集。这个数据集是一个二元分类问题。有1160个数据点,包含两个特征(x₁, x₂)和一个二元目标,有两个标签(y=0, y=1)。数据点是从均匀分布中随机选取的,但是标签为y=0和y=1的数据点之间有一个类似弧形的边界。下面创建了这个数据集,并在下图中绘制了它,我们可以可视化看到数据的点。

 import pandas as pd
 import numpy as np
 import matplotlib.pyplot as plt
 
 from sklearn.tree import DecisionTreeClassifier
 from sklearn.tree import DecisionTreeRegressor
 from sklearn import tree
 from matplotlib.colors import ListedColormap
 
 np.random.seed(7)  
 low_r = 10  
 high_r = 15
 n = 1550
 X = np.random.uniform(low=[0, 0], high=[4, 4], size=(n,2))
 drop = (X[:, 0]**2 + X[:, 1]**2 > low_r) & (X[:, 0]**2 + X[:, 1]**2 < high_r)
 X = X[~drop]
 y = (X[:, 0]**2 + X[:, 1]**2 >= high_r).astype(int) 
 colors = ['red', 'blue']
 plt.figure(figsize=(6, 6))
 for i in np.unique(y):
     plt.scatter(X[y==i, 0], X[y==i, 1], label = "y="+str(i), 
                 color=colors[i], edgecolor="white", s=50)
 circle = plt.Circle((0, 0), 3.5, color='black', fill=False,
                     linestyle="--", label="Actual boundary")
 plt.xlim([-0.1, 4.2])
 plt.ylim([-0.1, 5])
 ax = plt.gca()  
 ax.set_aspect('equal')
 ax.add_patch(circle)
 plt.xlabel('$x_1$', fontsize=16)
 plt.ylabel('$x_2$', fontsize=16)
 plt.legend(loc='best', fontsize=11)
 
 plt.show()

image.png

接下来使用 Scikit-learn 在这个数据集上创建并训练一个决策树分类器。模型拟合后,可以使用 plot_tree() 函数可视化决策树。

 tree_clf = DecisionTreeClassifier(random_state=0)  
 tree_clf.fit(X, y)
 plt.figure(figsize=(17,12))
 tree.plot_tree(tree_clf, fontsize=17, feature_names=["x1", "x2"])
 plt.show()

image.png

现在我们更深入地了解这棵树。决策树是一个层级结构,它是由边连接的节点的集合。决策树最顶端的节点称为根节点,它是决策树的起点。根节点连接着两个位于较低层级的节点。这两个节点称为根节点的子节点,即左子节点和右子节点。这些节点也各自拥有两个子节点。最底层没有子节点的节点称为树的叶子节点。一个节点的深度是指从该节点到树根节点的边的数量。决策树的最大深度是其最深叶子的深度。

这棵树用来对数据点进行分类。我们将一个数据点(来自训练数据集或一个未见过的数据点)传递给它,它将决定其标签(y=0 或 y=1)。这是通过将数据点的特征值传递给决策树来完成的。假设我们想用上图中的决策树确定数据点(x₁, x₂)的标签,根节点将特征x₂的值与这里的阈值2.73进行比较。如果x₂≤2.73,转向左子节点。否则,转向右子节点。根节点通过直线x₂=2.73将特征空间分为两个区域,这条直线是根据阈值定义的。在一个区域中有x₂≤2.73,在另一个区域中有x₂>2.73。所以如果我们将根节点视为一个简单的分类器,这条线就充当决策边界。

image.png

理想情况下,决策边界应该将具有不同标签的所有数据点分开。在由决策边界产生的每个区域中,我们应该只有具有相同标签的数据点。如上图所示,根节点线对于我们的数据集来说不是一个好的分类器,因为在每个区域中我们都有混合了标签y=0和y=1的数据点。所以决策树应该增加更多节点以提高分类的准确性。

那么添加更多节点时会发生什么呢?如果在树中添加根的左右子节点,那么它看起来就像下图。根节点将原始空间分成两个区域。每个区域传递给一个子节点。每个子节点有一个阈值,将相应的区域分成两个新区域。

image.png

我们可以添加更多的节点,使树更深入。添加新节点时,它会接收来自其父节点的区域,并使用垂直或水平线(这条线代表该节点的决策边界)将其分成两个新区域。可以继续添加节点,直到结果区域变得纯净,即所有区域只包含具有相同标签的数据点。我们用下面的递归函数绘制决策树中所有节点的决策边界。

 def plot_boundary_lines(tree_model):
     def helper(node, x1_min, x1_max, x2_min, x2_max):
         if feature[node] == 0:
             plt.plot([threshold[node], threshold[node]],
                      [x2_min, x2_max], color="black")
         if feature[node] == 1:
             plt.plot([x1_min, x1_max], [threshold[node],
                                         threshold[node]], color="black")
         if children_left[node] != children_right[node]:
             if feature[node] == 0:
                     helper(children_left[node], x1_min,
                            threshold[node], x2_min, x2_max)
                     helper(children_right[node], threshold[node],
                            x1_max, x2_min, x2_max)
             else:
                     helper(children_left[node], x1_min, x1_max,
                            x2_min, threshold[node])
                     helper(children_right[node], x1_min, x1_max,
                            threshold[node], x2_max)
     feature = tree_model.tree_.feature
     threshold = tree_model.tree_.threshold
     children_left = tree_model.tree_.children_left
     children_right = tree_model.tree_.children_right
 
     x1_min = x2_min = -1
     x1_max = x2_max = 5
     helper(0, x1_min, x1_max, x2_min, x2_max)

我们还定义了另一个函数,该函数在二维空间上创建一个网格,并获取训练后的决策树对该网格上每个点的预测。它将预测标签为1(y^=1) 的点指定为浅蓝色,将预测标签为0(y^=0)的点指定为橙色。使用这个函数可以在二维图中看到决策树对所有点的预测。

 def plot_boundary(X, y, clf, lims):
     gx1, gx2 = np.meshgrid(np.arange(lims[0], lims[1],
                                      (lims[1]-lims[0])/300.0),
                            np.arange(lims[2], lims[3],
                                      (lims[3]-lims[2])/300.0))
     cmap_light = ListedColormap(['lightsalmon', 'aqua'])
     gx1l = gx1.flatten()
     gx2l = gx2.flatten()
     gx = np.vstack((gx1l,gx2l)).T
     gyhat = clf.predict(gx)
     gyhat = gyhat.reshape(gx1.shape)
 
     plt.pcolormesh(gx1, gx2, gyhat, cmap=cmap_light)
     plt.scatter(X[y==0, 0], X[y==0,1], label="y=0", alpha=0.7,
                 color="red", edgecolor="white", s=50)
     plt.scatter(X[y==1, 0], X[y==1,1], label="y=1", alpha=0.7,
                 color="blue", edgecolor="white", s=50)
     plt.legend(loc='upper left')

现在就可以使用这个方法来展示数据集上训练的决策树

 plt.figure(figsize=(6,6))
 plot_boundary(X, y, tree_clf, lims=[-1, 5, -1, 5])
 plot_boundary_lines(tree_clf)
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.xlim([-1, 5])
 plt.ylim([-1, 5])
 plt.xlabel('$x_1$', fontsize=16)
 plt.ylabel('$x_2$', fontsize=16)
 plt.show()

image.png

可以看到原始数据集被分割成了9个矩形。这是因为我们的决策树有9个叶子节点。每个矩形代表树中的一个叶子节点。这些矩形创造了纯净的区域。每个区域内的数据点具有相同的标签,这个标签被分配给相应的叶子节点。当我们给这个训练好的决策树一个测试点时,它首先确定这个点属于哪个叶子,然后将叶子的标签分配给该点。换句话说,数据点的预测标签简单地就是点所在的区域(或矩形)的标签。

每条水平或垂直线代表这棵树中一个节点的阈值。这些线可以组合起来,创建树的总决策边界

 plt.figure(figsize=(6,6))
 plot_boundary(X, y, tree_clf, lims=[-1, 5, -1, 5])
 circle = plt.Circle((0, 0), 3.5, color='black', fill=False,
                     linestyle="--", label="Actual boundary")
 plt.text(3.5, 4.5, r"$\hat{y}=1$", fontsize=13)
 plt.text(2.35, 2.1, r"$\hat{y}=0$", fontsize=13)
 ax = plt.gca()  
 ax.set_aspect('equal')
 ax.add_patch(circle)
 plt.xlabel('$x_1$', fontsize=16)
 plt.ylabel('$x_2$', fontsize=16)
 plt.xlim([-0.1, 4.2])
 plt.ylim([-0.1, 5])
 plt.legend(loc='upper left')
 
 plt.show()

image.png
上图还显示了训练数据集的实际边界,这是一个弧形。值得注意的是,决策树分类器如何使用一系列垂直和水平线的组合来估计这个弧形。

到目前为止,我们只研究了具有两个特征的数据集,但相同的思路可以应用到具有更高维度的数据集上。如果我们有3个特征,每个节点都会接收从其父节点传递来的区域,并使用与其中一个轴(x₁、x₂或x₃)平行的平面将其分割成两个新区域。当这些平面结合起来时,它们创造了树的决策边界。一般来说,对于具有n个特征的数据集,每个节点都使用其阈值制作一个超平面,这些超平面结合起来形成决策树分类器的决策边界。

在决策树分类器中的过拟合

在二元分类问题中,我们可以假设不同标签的数据点由一个假设的边界分开。这个边界是由生成数据的过程创建的。比如在我们的上面的数据集中,这个边界是一个弧形。决策树分类器是一个强大的机器学习模型,理论上它可以添加尽可能多的节点来解决任何非线性分类问题。在二维空间中,无论实际边界有多复杂,总是可以通过添加更多的水平和垂直线来近似。

image.png

同样的原理也适用于n维空间,我们可以添加越来越多的超平面来模拟边界。但是这种强大的模型有一个显著的缺点:过拟合。过拟合发生在机器学习模型变得过于复杂,开始学习训练数据的噪声时,它将无法很好地推广到新的未见数据。

过拟合发生在决策树的决策边界变得比原始数据集的实际边界复杂得多时。这里有一个例子。假设我们有一个噪声数据集,其中不同标签的数据点之间的边界是一条直线。

 np.random.seed(1) 
 n = 550
 X1 = np.random.uniform(low=[0, 0], high=[4, 4], size=(n,2))
 drop = (X1[:, 0] > 1.8) & (X1[:, 0] < 1.9)
 X1 = X1[~drop]
 y1 = (X1[:, 0] > 1.9).astype(int) 
 X2 = np.random.uniform(low=[1.7, 0], high=[1.9, 4], size=(15,2))  
 y2 = np.ones(15).astype(int)
 X = np.concatenate((X1, X2), axis=0)
 y = np.concatenate((y1, y2))
 colors = ['red', 'blue']
 for i in np.unique(y):
     plt.scatter(X[y==i, 0], X[y==i, 1], label = "y="+str(i),
                 color=colors[i], edgecolor="white", s=50)
 plt.axvline(x=1.8, color="black", linestyle="--")
 plt.legend(loc='best')
 plt.xlim([-0.5, 4.5])
 plt.ylim([-0.2, 5])
 ax = plt.gca()  
 ax.set_aspect('equal')
 
 plt.xlabel('$x_1$', fontsize=16)
 plt.ylabel('$x_2$', fontsize=16)
 
 plt.show()

image.png

我们在这个数据集上训练一个决策树分类器。

 #Listing 8
 
 tree_clf = DecisionTreeClassifier(random_state=1) 
 tree_clf.fit(X, y)
 plt.figure(figsize=(13,10))
 tree.plot_tree(tree_clf, fontsize=9, feature_names=["x1", "x2"])
 plt.show()

image.png

最后绘制了树分类器的决策边界。

 #Listing 9
 
 plt.figure(figsize=(6,6))
 plot_boundary(X, y, tree_clf, lims=[-1, 5, -1, 5])
 plt.axvline(x=1.8, color="black", linestyle="--", label="Actual boundary")
 plt.text(0, -0.3, r"$\hat{y}=0$", fontsize=13)
 plt.text(3, -0.3, r"$\hat{y}=1$", fontsize=13)
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.xlim([-0.5, 4.5])
 plt.ylim([-0.5, 4.5])
 plt.xlabel('$x_1$', fontsize=16)
 plt.ylabel('$x_2$', fontsize=16)
 plt.legend(loc="best")
 
 plt.show()

image.png

实际边界是一条直线,但树分类器在这个数据集上拟合的决策边界是一条曲折的线。这是因为模型试图学习噪声数据点,所以它通过添加更多的节点来扩展决策边界以包含这些点。为了解决这个问题,可以通过限制树的最大深度来减少过拟合的风险。比如使用

DecisionTreeClassifier()

max_depth

参数。

 tree_clf1 = DecisionTreeClassifier(random_state=1, max_depth=1) 
 tree_clf1.fit(X, y)
 plt.figure(figsize=(10,5))
 tree.plot_tree(tree_clf1, fontsize=9, feature_names=["x1", "x2"])
 plt.show()

image.png

现在决策树的叶子节点最大深度为1,所以只有根节点和两个叶子节点。这棵新树的决策边界如下

 #Listing 11
 
 plt.figure(figsize=(6,6))
 plot_boundary(X, y, tree_clf1, lims=[-1, 5, -1, 5])
 plt.axvline(x=1.8, color="black", linestyle="--", label="Actual boundary")
 plt.text(0, -0.3, r"$\hat{y}=0$", fontsize=13)
 plt.text(3, -0.3, r"$\hat{y}=1$", fontsize=13)
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.xlim([-0.5, 4.5])
 plt.ylim([-0.5, 4.5])
 plt.xlabel('$x_1$', fontsize=16)
 plt.ylabel('$x_2$', fontsize=16)
 plt.legend(loc="best")
 
 plt.show()

image.png
由于只有一个节点,决策边界是一条直线,并且没有过拟合现象。当我们限制决策树的深度时,可能无法得到纯净区域的叶子节点。但在这种情况下,每个叶子的标签是通过对该叶子对应区域内的数据点进行投票来确定的。

基尼指数

决策树是如何确定每个节点应该使用哪个特征和阈值的呢?它使用不纯度度量来做到这一点。在我们的例子中,使用的不纯度度量是基尼指数(Gini index)。基尼指数是一个样本不纯度的度量。当一个节点将给定空间分成两个区域时,我们可以使用该区域中数据点的标签来计算每个区域的基尼指数。基尼指数的定义如下:

image.png

基尼指数为0表示完美的纯净,意味着该区域内所有数据点都有相同的标签。这是基尼指数可以达到的最低值。基尼指数越高意味着不纯度越高。如果一个区域内不同标签的数据点数量相等,那么基尼指数为:

image.png

基尼指数0.5代表最高的不纯度,这是基尼指数的最高值。因此基尼指数总是在0和0.5之间。

当决策树中的一个节点将其空间分成两个区域时,可以计算每个结果区域的基尼指数。当决策树算法添加一个新节点时,它会评估每个特征在不同潜在阈值下的基尼指数。然后它选择导致该节点平均基尼指数最低的特征和阈值(这意味着由该节点得到的两个区域的平均纯度最高)。比如在上面一节的决策树中,原始数据集552个数据点发送到根节点,其中247个有标签0,305个有标签1。原始数据集的基尼指数如下计算:

image.png

因此,根节点的分裂将原始基尼指数0.494降低到了0.025的平均基尼指数(对两个区域而言)。根节点使用的特征(x₁)和阈值(1.801)是为了在分裂后得到最低的平均基尼指数0.025而选择的。由scikit-learn绘制的决策树显示了传递给每个节点的数据点数量,每个标签的计数和节点的基尼指数。

决策树回归器

我们也可以将决策树用于分类和回归问题。这一节展示了如何创建一个决策树来解决回归问题。我们创建了另一个数据集。

 np.random.seed(4)
 x = np.linspace(0, 3, 60)
 x1 = np.linspace(0, 1, 20)
 x2 = np.linspace(1, 3, 40)
 y = x.copy()
 y[x>=1] = 1 
 y = y + np.random.normal(scale=0.1, size=60)
 X = x.reshape(-1, 1)
 
 plt.figure(figsize=(8,8))
 plt.scatter(x, y, label="Noisy data points")
 plt.plot(x1, x1, color="blue", alpha=0.5, label="Trend")
 plt.plot(x2, len(x2)*[1], color="blue", alpha=0.5)
 plt.xlim([-0.1, 3.1])
 plt.ylim([-0.1, 2])
 plt.xlabel('$x$', fontsize=16)
 plt.ylabel('$y$', fontsize=16)
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.legend(loc="best", fontsize=14)
 
 plt.show()

image.png
这个数据集是通过在两条线段(y=x,y=1)上的点添加具有正态分布的噪声来创建的。这里x是这个数据集的唯一特征,y是目标。现在我们想使用一个决策树回归器来学习这个数据集。

 tree_regres = DecisionTreeRegressor(random_state=0, max_depth=3)
 tree_regres.fit(X, y)
 plt.figure(figsize=(17,8))
 tree.plot_tree(tree_regres, fontsize=10, feature_names=["x"])
 plt.show()

image.png

树回归器中的节点与树分类器中的节点类似。每个节点都有一个与阈值进行比较的特征。决策树分类器和决策树回归器之间的主要区别在于叶节点的值。在树分类器中,数据集的目标是一个具有一些标签的离散变量,每个叶节点代表其中一个标签。但是在树回归器中,目标是一个连续变量,每个叶节点代表这个目标的一个可能值。例如,在上图中绘制的树回归器中,从左边开始的第一个叶节点的值为0.036。当我们最终到达这个叶节点时,目标的预测值将是0.036。如果我们给这个训练好的决策树回归器一个测试点,它首先需要确定这个点属于哪个叶节点,然后将叶节点的值分配给该点。

我们从根节点开始。数据集有60个数据点。目标的平均值是:

 y.mean() #0.828

如果我们使用这个平均值作为整个数据集的简单预测器,它的均方误差(MSE)是:

 ((y.mean()-y)**2).mean() #0.102

这些信息显示在根节点上。在根节点开始拆分数据集之前,y的平均值是我们拥有的最佳估计器。根节点使用其阈值0.585来拆分数据,如下图所示,拆分后左侧区域有12个数据点(传递给左子节点),右侧区域有48个数据点(传递给右子节点)。

image.png

在每个区域中,y的平均值代表模型预测。左侧区域,我们只有x≤0.605的数据点。这些数据点的目标平均值是:

 y[(X <=0.585).flatten()].mean() #0.262

如果使用这些值作为这些数据点的模型预测,可以计算其均方误差(MSE):

 ((0.262 - y[(X <= 0.585).flatten()])**2).mean() #0.037

这些数字在上图的左子节点内显示为value和squared_error。类似地,右侧区域的预测和MSE分别为0.97和0.018。我们增加深度,当添加一个新节点时,它会接收来自其父节点的区域,并使用其阈值将其分割成两个新区域。随着树变得更深,节点的MSE减小,每个区域中的预测更接近实际数据点。

image.png

我们绘制最终的预测图

 x1 = np.linspace(0, 1, 20)
 x2 = np.linspace(1, 3, 40)
 X_space = np.linspace(-0.3, 3.3, 1000).reshape(-1, 1)
 yhat = tree_regres.predict(X_space)
 plt.figure(figsize=(8,6))
 plt.scatter(x, y, label="Training data")
 plt.plot(X_space, yhat, color="red", label="prediction")
 plt.plot(x1, x1, color="blue", alpha=0.5, label="Trend")
 plt.plot(x2, len(x2)*[1], color="blue", alpha=0.5)
 plt.legend(loc="best", fontsize=14)
 plt.xlim([-0.3, 3.3])
 plt.ylim([-0.1, 2])
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.xlabel('$x$', fontsize=14)
 plt.ylabel('$y$', fontsize=14)
 plt.show()

image.png

我们的树有8个叶节点,而上图中包含8个水平红色线段,每个线段代表一个叶节点的预测。当这些线段组合在一起时,它们形成了一个阶梯线,代表在给定区间内整个决策树的预测。这个阶梯线是训练数据集实际趋势的估计,是决策树回归器的一个特征。

你可能会认为,通过增加树的最大深度和添加更多线段(更多节点),阶梯线将更接近实际趋势。但是情况并非如此,增加最大深度只会增加过拟合的风险。

外推问题

看到这里,你可能已经注意到决策树存在一个问题。数据集定义在区间[0, 3]内。由于预测是一条水平线,我们得到x小于零的所有值的恒定预测,也就是说对所有x大于3的值也得到一个恒定的预测。决策树回归器无法外推超出训练数据范围的数据。

image.png

每个区域中树的预测仅仅是该区域内数据点目标的平均值。这个预测由一个水平线段表示。树中具有最低阈值的节点,创建了位于训练数据集边缘的区域。

决策树分类器也存在同样的问题。决策边界总是以其边缘的垂直或水平线结束,不能呈现其他形式。外推问题与决策树的结构有关。在决策树中,每个节点都创建一个简单的预测。在训练数据集区间内,这些预测可以组合形成复杂的形态,在此区间外,我们只剩下覆盖那个区域的一个节点的简单预测。

在决策树回归器中的过拟合

我们将最大深度设置为3。让我们看看如果取消这一限制会发生什么。这次在没有最大深度限制的情况下,将一个新的决策树回归器拟合到玩具数据集上

 X_space = np.linspace(-0.3, 3.3, 1000).reshape(-1, 1)
 tree_regres = DecisionTreeRegressor(random_state=1)
 tree_regres.fit(X, y)
 yhat = tree_regres.predict(X_space)
 plt.figure(figsize=(8,6))
 plt.scatter(X, y, label="Training data")
 plt.plot(X_space, yhat, color="red", label="prediction")
 
 plt.xlim([-0.3, 3.3])
 plt.ylim([-0.1, 2])
 plt.legend(loc="best", fontsize=14)
 plt.xlabel('$x$', fontsize=14)
 plt.ylabel('$y$', fontsize=14)
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.show()

image.png

用下面的语句显示这个树的最大深度:

 tree_regres.tree_.max_depth #11

打印这棵树的叶子数:

 tree_regres.get_n_leaves() #60

这棵树肯定过拟合了,这里主要问题是树回归器无法看到实际趋势。它只看到原始数据点,当添加新节点时,它们试图更接近这些点。最终,叶子的数量将等于数据点的数量。在最后的分割之后,每个叶子只剩下一个数据点,其值就简单地被返回。因此在每个叶子中,MSE为零。训练数据集的预测误差为零,因此训练数据集的R²为1:

 tree_regres.score(X,y) #1.0

这棵树回归器对训练数据集给出了完美的预测,但是它未能很好地推广到新的未见数据,因为它无法学习训练数据集的实际趋势。过拟合导致模型记住了训练数据的每一个细节,包括数据中的随机噪声,而不是抓住潜在的、普遍适用的模式,这使得模型在面对新情况时表现不佳。

决策树是一种非参数模型

机器学习模型分为两大类:参数模型和非参数模型。参数模型假设一个特定形式的函数来映射特征到目标。这通常涉及假设数据遵循已知的分布。另一方面,非参数模型不假设映射函数的具体形式。它们也不对数据的分布作任何假设。因此它们更加灵活,能够适应数据结构。

决策树是非参数模型的一个例子。让我们以决策树回归器和线性回归模型为例进行比较。线性回归是一个参数模型,假设目标与特征之间存在线性关系。当数据集只有一个特征时,这种线性关系由以下方程给出:

image.png

其中 ( \theta_0 ) 和 ( \theta_1 ) 是在训练过程中确定的模型参数。基于这个方程,模型预测 y 位于一条直线上。下图比较了线性模型的预测和决策树回归器的预测。这里我们有两个不同的数据集,我们将两种模型都适用于它们。

image.png

线性模型对两个数据集的预测都是一条直线,如左图所示。当数据集改变时,直线的斜率和截距会改变,但它仍然是一条直线。这是因为该模型是参数模型,并假设特征与目标之间存在线性关系。树回归器的预测在右图中展示。它是一个非参数模型,因此它试图模仿数据的趋势,模型的预测会适应训练数据集的形状。因此当训练数据集的形状改变时,模型预测的形状也会随之改变。

我们可以将非参数模型想象为一条橡皮筋,它会改变其形状以模仿训练数据集的形状。相反线性模型就像一根刚性的杆,它可以改变位置但不能改变形状。

梯度提升

梯度提升是集成学习的一个例子。集成学习是一种通过结合多个模型的预测来提高预测性能的机器学习方法。提升是一种集成学习方法,它顺序地结合多个弱学习模型来提高预测性能。梯度提升可用于分类和回归问题。由于它基于提升的概念,它通过顺序结合多个弱学习者来创建一个强学习者。

理论上,梯度提升可以使用任何弱模型,但是决策树由于其捕捉复杂交互和非线性关系的能力而成为最常见的选择。这里我们首先解释用于回归的梯度提升,因为它更容易理解。之后,我们将讨论梯度提升分类器。

梯度提升回归

梯度提升回归包括几个步骤。我们还是使用上面的数据集来解释这些步骤。梯度提升首先通过使用训练数据集做一个初始预测来开始。初始预测是训练数据集中所有数据点的目标平均值。因此有:

image.png
image.png
这里的 ( F_m(x) ) 是提升模型的最终预测。它是将这个集成中所有模型的预测加到基模型 ( F_0 ) 上的结果。我们使用训练数据集的示例得到了 ( F_m(x) ),但现在它也可以用来预测未见特征 x 的目标,这个特征不在训练数据集中。

image.png
image.png

最终模型是由 M 个弱模型组成的集成,其预测为 ( F_M(x) )。每个模型学习前一个模型的错误,并尝试改进其预测。学习率是这个集成模型的超参数,其作用是通过缩小集成中每个模型的预测来抑制过拟合。在每一步中,我们都希望改进前一个模型的预测,但如果改进得太多,就可能开始学习训练数据集中的噪声,结果就是过拟合。所以学习率和集成模型中树的数量之间存在权衡。随着树的数量增加,应该降低学习率以减轻过拟合。

清单 16 实现了 Python 中的梯度提升算法。

 class GradBoostingRegressor():
     def __init__(self, num_estimators, learning_rate, max_depth=1):
         self.num_estimators = num_estimators
         self.learning_rate = learning_rate
         self.max_depth = max_depth
         self.tree_list = []
     def fit(self, X, y):
         self.F0 = y.mean() 
         Fm = self.F0
         for i in range(self.num_estimators):
             tree_reg = DecisionTreeRegressor(max_depth=self.max_depth,
                                              random_state=0)
             tree_reg.fit(X, y - Fm)
             Fm += self.learning_rate * tree_reg.predict(X)
             self.tree_list.append(tree_reg)
     def predict(self, X):
         y_hat = self.F0 + self.learning_rate * \
                 np.sum([t.predict(X) for t in self.tree_list], axis=0)
         return y_hat
GradientBoostingRegressor()

类有两个方法用于拟合数据集和预测目标。该类接收一个参数

num_estimators

,其中包括 ( F_0 )。如果我们有 M 棵树,那么

num_estimators

应为 ( M+1 )。我们可以使用这个类将梯度提升算法应用于我们定义的数据集。我们下面使用这个类来绘制梯度提升回归器的不同步骤。结果显示在下图中。这个回归器是由9棵深度为1的决策树组成的集成模型(M=9,

num_estimators

=10)。左侧绘制了 ( F_i(x) ),右侧显示了残差(( y-F_{m-1}(x) ))及其训练的浅层决策树的预测。

 M = 9
 X_space = np.linspace(-0.3, 3.3, 1000).reshape(-1, 1)
 gbm_reg = GradBoostingRegressor(num_estimators=M+1, learning_rate=0.3)
 gbm_reg.fit(X, y)
 
 fig, axs = plt.subplots(M+1, 2, figsize=(11, 45))
 plt.subplots_adjust(hspace=0.3)
 
 axs[0, 0].axis('off')
 axs[0, 1].scatter(X, y, label="y")
 axs[0, 1].axhline(y=gbm_reg.F0, color="red", label="$F_0(x)$")
 axs[0, 1].set_title("m=0", fontsize=14)
 axs[0, 1].set_xlim([-0.3, 3.3])
 axs[0, 1].set_ylim([-0.5, 2])
 axs[0, 1].legend(loc="best", fontsize=12)
 axs[0, 1].set_aspect('equal')
 axs[0, 1].set_xlabel("x", fontsize=13)
 axs[0, 1].set_ylabel("y", fontsize=13)
 
 for i in range(1, M+1):
     Fi_minus_1 = gbm_reg.F0 + gbm_reg.learning_rate * \
                  np.sum([t.predict(X) for t in gbm_reg.tree_list[:i-1]],
                         axis=0)
     axs[i, 0].scatter(X, y-Fi_minus_1, label=f"$y-F_{{{i-1}}}(x)$")
     axs[i, 0].plot(X_space, gbm_reg.tree_list[i-1].predict(X_space),
                    color="red",label=f"$h_{{{i}}}(x)$")
     axs[i, 0].set_title("m={}".format(i), fontsize=14)
     axs[i, 0].set_xlim([-0.3, 3.3])
     axs[i, 0].set_ylim([-1, 2])
     axs[i, 0].set_xlabel("x", fontsize=13)
     axs[i, 0].set_ylabel("residual", fontsize=13)
     axs[i, 0].legend(loc="best", fontsize=12)
     axs[i, 0].set_aspect('equal')
     
     axs[i, 1].scatter(X, y, label="y")
     Fi = gbm_reg.F0 + gbm_reg.learning_rate * \
          np.sum([t.predict(X_space) for t in gbm_reg.tree_list[:i]],
                 axis=0)
     axs[i, 1].plot(X_space, Fi, color="red", label=f"$F_{{{i}}}(x)$")
     axs[i, 1].set_title("m={}".format(i), fontsize=14)
     axs[i, 1].set_xlim([-0.3, 3.3])
     axs[i, 1].set_ylim([-0.5, 2])
     axs[i, 1].set_xlabel("x", fontsize=13)
     axs[i, 1].set_ylabel("y", fontsize=13)
     axs[i, 1].legend(loc="best", fontsize=13)
     axs[i, 1].set_aspect('equal')
 plt.show()

image.png

image.png

image.png

image.png

image.png

模型的总预测 ( F_i(x) ) 在每一步中都有所改进。在这个例子中,我们只使用了9棵决策树,但如果使用更多的树会发生什么呢?

 X_space = np.linspace(-0.3, 3.3, 1000).reshape(-1, 1)
 gbm_reg = GradBoostingRegressor(num_estimators=50, learning_rate=0.3)
 gbm_reg.fit(X, y)
 y_hat = gbm_reg.predict(X_space)
 
 plt.figure(figsize=(8,6))
 plt.scatter(x, y, label="Training data")
 plt.plot(X_space, y_hat, color="red", label="prediction")
 
 plt.xlim([-0.3, 3.3])
 plt.ylim([-0.1, 2])
 plt.legend(loc="best", fontsize=14)
 plt.xlabel('$x$', fontsize=14)
 plt.ylabel('$y$', fontsize=14)
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.show()

image.png

在 scikit-learn 库中,

GradientBoostingRegressor

类也可以用于梯度提升回归。这里我们使用这个类来测试我们的实现:

 from sklearn.ensemble import GradientBoostingRegressor
 gbm_reg_sklrean = GradientBoostingRegressor(n_estimators=50,
                                             learning_rate=0.3,
                                             max_depth=1)
 gbm_reg_sklrean.fit(X, y)
 y_hat_sklrean = gbm_reg_sklrean.predict(X_space)
 np.allclose(y_hat, y_hat_sklrean)

让我们将其与上面的决策树回归器的预测进行比较。那棵树的深度是11,当在同一数据集上训练时出现了过拟合问题。那么为什么包含49棵树的梯度提升回归器没有遭受过拟合?要回答这个问题,我们需要更深入地了解这两种模型以及它们如何处理训练数据集。下图展示了决策树回归器中的不同节点是如何处理训练数据集的。

image.png

根节点作为最顶端的节点,能够看到整个数据集,并能够正确地检测到数据集的上升趋势。因为它能够看到整体情况,所以可以区分数据集的趋势与噪声。相反,较深的节点只能看到从其父节点传递给它的原始数据集的一小部分。这小部分的变化主要是由于噪声引起的,由于这是节点唯一能看到的东西,它将噪声视为趋势,并预测出一个下降趋势。随着树的加深,节点更容易受到噪声的影响,并试图学习这些噪声,结果就是过拟合。

下图则展示了梯度提升回归器中的不同模型(或节点)如何看待训练数据集。每个模型看到的是整个数据集的残差。因此它能够更可靠地检测出主要趋势,因此它对过拟合更具鲁棒性。

image.png

当模型的每个部分只能访问数据的局部时,它们可能会过度拟合局部的噪声,而忽略全局的真实模式。

梯度提升分类

在本节中,我们解释梯度提升分类算法,但我们只关注二元分类问题。在二元分类问题中,目标只能取两个标签,分别用0和1表示。设得到1的概率为 ( p )。事件发生的赔率是事件发生的概率与事件不发生的概率之比。因此,得到1的赔率为:

image.png
这个公式表示为逻辑回归中的sigmoid函数,用于将线性回归模型的输出转换为概率值。梯度提升分类器利用这一转换来预测类别概率,并通过最大化似然函数来优化模型。

梯度增强分类算法如下:

image.png
image.png

梯度提升从对训练数据集的预测做出初步猜测开始。在梯度提升回归器中,初始猜测是训练数据集中所有数据点的目标平均值。对于分类问题,这里的初始猜测是训练数据集中目标值为1的概率。这个概率由下式给出:

image.png

这里的残差是使用概率而不是赔率的对数计算的。接下来创建一个浅层决策树回归器来预测训练数据集的残差。这个回归器记为 ( h_1 ),以 x 为特征,以 ( y - p(x) ) 为目标。在训练完树回归器后,我们需要修改其叶节点的值。对于树中的每个叶节点(记为 ( l )),我们将叶节点的值 ( v_l ) 修改为:

image.png

然后我们训练另一个浅层决策树,以 x 为特征,以 ( y - p(x) ) 为目标。这个模型的预测记为 ( h_2(x) ),将其添加到 ( F_1(x) ) 中:

image.png
我们可以将这个概率与一个阈值进行比较,以获得二元目标的最终预测。这个阈值通常为0.5。如果 ( p(x) geq 0.5 ),则预测目标为1,否则为0。

下面就是算法的python实现

 class GradBoostingClassifier():  
     def __init__(self, num_estimators, learning_rate, max_depth=1):
         self.num_estimators = num_estimators
         self.learning_rate = learning_rate
         self.max_depth = max_depth
         self.tree_list = []
     def fit(self, X, y):   
         probability = y.mean()
         log_of_odds = np.log(probability / (1 - probability))
         self.F0 = log_of_odds
         Fm = np.array([log_of_odds]*len(y))
         probs = np.array([probability]*len(y))
         for i in range(self.num_estimators):
             residuals = y - probs
             tree_reg = DecisionTreeRegressor(max_depth=self.max_depth) 
             tree_reg.fit(X, residuals)
             # Correcting leaf vlaues
             h = probs * (1 - probs)
             leaf_nodes = np.nonzero(tree_reg.tree_ .children_left == -1)[0]
             leaf_node_for_each_sample = tree_reg.apply(X)
             for leaf in leaf_nodes:
                 leaf_samples = np.where(leaf_node_for_each_sample == leaf)[0]
                 residuals_in_leaf = residuals.take(leaf_samples, axis=0)
                 h_in_leaf = h.take(leaf_samples, axis=0)
                 value = np.sum(residuals_in_leaf) / np.sum(h_in_leaf)
                 tree_reg.tree_.value[leaf, 0, 0] = value
             
             self.tree_list.append(tree_reg)
             reg_pred = tree_reg.predict(X)
             Fm += self.learning_rate * reg_pred
             probs = np.exp(Fm) / (1+ np.exp(Fm))
     
     def predict_proba(self, X):
         FM = self.F0 + self.learning_rate * \
             np.sum([t.predict(X) for t in self.tree_list], axis=0)
         prob = np.exp(FM) / (1+ np.exp(FM))
         return prob 
     
     def predict(self, X):
         yhat = (self.predict_proba(X) >= 0.5).astype(int)
         return yhat

类中的函数

predict_proba()

返回 ( p(x) ),而函数

predict()

返回预测的二元目标。该类接收一个参数

num_estimators

,其中包括 ( F_0 )。如果我们有 M 棵树,那么

num_estimators

应为 ( M+1 )。现在我们将梯度提升分类器拟合到这个数据集上。

 gbm_clf = GradBoostingClassifier(num_estimators=30,
                                  learning_rate=0.1, max_depth=1)
 gbm_clf.fit(X, y)

结果如下:

 plt.figure(figsize=(8, 8))
 plot_boundary(X, y, gbm_clf, lims=[-1, 5, -1, 5])
 plt.axvline(x=1.8, color="black", linestyle="--", label="Actual boundary")
 plt.text(0, -0.3, r"$\hat{y}=0$", fontsize=15)
 plt.text(3, -0.3, r"$\hat{y}=1$", fontsize=15)
 ax = plt.gca()  
 ax.set_aspect('equal')
 plt.xlim([-0.5, 4.5])
 plt.ylim([-0.5, 4.6])
 plt.xlabel('$x_1$', fontsize=18)
 plt.ylabel('$x_2$', fontsize=18)
 plt.legend(loc="best", fontsize=14)
 
 plt.show()

image.png

即使在集成中使用了29棵树,模型仍然没有过拟合,并且正确地预测了边界。我们也可以使用scikit-learn库中的

GradientBoostingClassifier

类来进行梯度提升分类,并用它来测试我们的实现:

 from sklearn.ensemble import GradientBoostingClassifier
 gbm_clf_sklrean = GradientBoostingClassifier(n_estimators=30,
                                              learning_rate=0.1,
                                              max_depth=1)
 gbm_clf_sklrean.fit(X, y)
 phat_sklrean = gbm_clf_sklrean.predict_proba(X)[:,1]
 phat = gbm_clf.predict_proba(X)
 np.allclose(phat, phat_sklrean)

总结

在本文中,我们尝试对决策树的进行可视化解释。决策树是一种由若干节点组成的非参数模型。每个节点本质上是一个线性分类器,但当它们结合在一起时,可以学习数据集中的任何非线性模式。但这种灵活性以过拟合为代价,这意味着当树长得太大时,它开始学习数据点中的噪声。

梯度提升是一种集成方法,它由一系列弱决策树组成,每棵树都试图改进前一棵树的预测。梯度提升保留了决策树的灵活性,但对过拟合更具鲁棒性。

https://avoid.overfit.cn/post/4018f4fd09b44bfd8cd64abeb44ec10f

推荐阅读
关注数
4189
内容数
870
SegmentFault 思否旗下人工智能领域产业媒体,专注技术与产业,一起探索人工智能。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息