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

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

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

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

决策树分类器

决策树模型可用于分类和回归问题。让我们看看决策树分类器是如何工作的。首先,创建一个示例的数据集。这个数据集是一个二元分类问题。有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()

接下来使用 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()

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

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

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

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

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

复制代码
 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()

可以看到原始数据集被分割成了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()

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

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

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

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

同样的原理也适用于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()

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

复制代码
 #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()

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

复制代码
 #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()

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

复制代码
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()

现在决策树的叶子节点最大深度为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()

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

基尼指数

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

其中 ( C ) 是所有标签(类别)的集合,( i ) 遍历每个标签。这里 ( p_i ) 是在该区域内选择一个带有标签 ( i ) 的数据点的概率。在二元分类问题中,如果一个区域内的所有数据点都有相同的标签(比如标签0):

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

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

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

这是根节点的基尼指数。根节点将初始数据集分成两个区域。在左侧区域,我们有254个数据点:247个点标签为y=0,7个数据点标签为y=1。因此,基尼指数为:

这是左子节点的基尼指数。在右侧区域,有298个数据点,所有的标签都是1。所以右子节点的基尼指数简单地为0。两个区域基尼指数的加权和:

因此,根节点的分裂将原始基尼指数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()

这个数据集是通过在两条线段(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()

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

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

复制代码
 y.mean() #0.828

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

复制代码
 ((y.mean()-y)**2).mean() #0.102

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

在每个区域中,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减小,每个区域中的预测更接近实际数据点。

我们绘制最终的预测图

复制代码
 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()

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

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

外推问题

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

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

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

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

我们将最大深度设置为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()

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

复制代码
 tree_regres.tree_.max_depth #11

打印这棵树的叶子数:

复制代码
 tree_regres.get_n_leaves() #60

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

复制代码
 tree_regres.score(X,y) #1.0

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

决策树是一种非参数模型

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

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

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

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

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

梯度提升

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

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

梯度提升回归

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

可以将 F_0(x)视为一个简单的基模型,用于预测 x 的目标。计算训练数据集中所有示例的这个基模型的残差如下:

接下来创建一个浅决策树回归器来预测训练数据集的残差。这个树回归器,表示为 ( h_1 ),以 x 为特征,以 ( y - F_0(x) ) 为目标。这个回归器的预测表示为 ( h_1(x) )。我们将这个模型的预测添加到基模型的预测中:

这里的 ( F_1(x) ) 相比原始预测 ( F_0(x) ) 是一个更好的目标预测。现在我们计算 ( F_1(x) ) 对训练数据集中所有示例的残差:

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

( F_2(x) ) 现在是一个相比 ( F_1(x) ) 更好的预测。这个过程可以重复 M 次。每次我们都为训练数据集中所有示例计算前一个模型的残差:

然后训练决策树 ( h_m ),以 x 为特征,以 ( y - F_{m-1}(x) ) 为目标。这棵树的预测被添加到前一个模型的预测中以改进它:

在实际使用时,还会在这个方程中添加一个称为学习率的参数,表示为 ( \eta ),其范围在 0 到 1 之间:

如果简化这个递归方程,可以得到:

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

最终模型是由 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()

模型的总预测 ( 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()

在 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棵树的梯度提升回归器没有遭受过拟合?要回答这个问题,我们需要更深入地了解这两种模型以及它们如何处理训练数据集。下图展示了决策树回归器中的不同节点是如何处理训练数据集的。

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

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

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

梯度提升分类

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

概率的对数(也就是logit函数)被定义为:

但为什么我们需要计算赔率的对数呢?如果我们将 ( p ) 设为0,赔率的对数为 ( -∞),如果将 ( p ) 设为1,赔率的对数为 ( +∞ )。概率 ( p ) 始终在0和1之间,但赔率的对数将其转换为 ( -∞ ) 到 ( +∞) 之间的实数。如果我们有了赔率的对数(log(odds)),我们可以使用以下公式来计算对应的概率:

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

梯度增强分类算法如下:

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

其中,N是训练数据集中的示例数量。函数 ( F_0(x) ) 是一个简单的基模型,它预测的是这个概率的赔率的对数:

对于训练数据集中的示例,基模型的残差计算如下:

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

其中,L 是落在该叶子节点中的所有训练集示例的集合。我们用 ( (x_i, y_i) ) 表示L中的每个示例,( p_i ) 是预测特征 ( x_i ) 的目标等于1的概率。因此,分子是L中所有示例残差的和,分母是L中所有示例的 ( p(1-p) ) 的和。使用修改后的树,我们现在可以预测训练数据集中所有示例的目标。

修改后的树回归器的预测记为 ( h_1(x) )。我们将这棵树的预测加到基模型的预测上:

这里的 ( F_1(x) ) 相比初始预测 ( F_0(x) ) 是一个更好的目标预测,预测的是特征 ( x ) 的目标的赔率的对数。现在,我们使用以下公式计算训练数据集中所有示例目标等于1的预测概率:

接下来,我们计算 ( F_1(x) ) 对训练数据集的残差:

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

这个过程可以重复 M 次。每次都从前一个模型中计算训练数据集的残差,并训练决策树 ( h_m ) 来预测这些残差。因此,( h_m ) 以 x 为特征,以 ( y - p(x) ) 为目标。然后将这棵树的预测添加到前一个模型的预测中以改进它:

简化这个递归方程,得到提升模型的最终预测如下:

这与梯度提升回归器的方程完全相同。这个方程给出了特征 ( x ) 的预测赔率的对数,它也可以用于不在训练数据集中的未见特征 ( x )。目标为1的预测概率由以下公式给出:

我们可以将这个概率与一个阈值进行比较,以获得二元目标的最终预测。这个阈值通常为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()

即使在集成中使用了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

作者:Reza Bagheri

相关推荐
有个人神神叨叨28 分钟前
OpenAI发布的《Addendum to GPT-4o System Card: Native image generation》文件的详尽笔记
人工智能·笔记
林九生1 小时前
【Python】Browser-Use:让 AI 替你掌控浏览器,开启智能自动化新时代!
人工智能·python·自动化
猿界零零七1 小时前
执行paddle.to_tensor得到全为0
python·paddle
liuyunshengsir2 小时前
AI Agent 实战:搭建个人在线旅游助手
人工智能·旅游
Shawn_Shawn2 小时前
大模型微调介绍
人工智能
TiAmo zhang2 小时前
DeepSeek-R1 模型现已在亚马逊云科技上提供
人工智能·云计算·aws
liruiqiang052 小时前
循环神经网络 - 简单循环网络
人工智能·rnn·深度学习·神经网络·机器学习
Erica_zhase2 小时前
GPT-4o推出的原生图像生成功能升级后有点东西!
人工智能
青花瓷2 小时前
智谱大模型(ChatGLM3)PyCharm的调试指南
人工智能·python·大模型·智谱大模型
说私域2 小时前
基于开源AI大模型与S2B2C模式的线下服务型门店增长策略研究——以AI智能名片与小程序源码技术为核心
大数据·人工智能·小程序·开源