知识图谱自然语言处理

深入理解XGBoost

2022-07-06  本文已影响0人  晓柒NLP与药物设计

深入理解XGBoost

1. XGBOOST简介

XGBoost的全称是eXtreme Gradient Boosting,它是经过优化的分布式梯度提升库,旨在高效、灵活且可移植。XGBoost是大规模并行boosting tree的工具,它是目前最快最好的开源 boosting tree工具包,比常见的工具包快10倍以上。在数据科学方面,有大量的Kaggle选手选用XGBoost进行数据挖掘比赛,是各大数据科学比赛的必杀武器;在工业界大规模数据方面,XGBoost的分布式版本有广泛的可移植性,支持在Kubernetes、Hadoop、SGE、MPI、 Dask等各个分布式环境上运行,使得它可以很好地解决工业界大规模数据的问题。本文将从XGBoost的数学原理和工程实现上进行介绍,然后介绍XGBoost的优缺点,并在最后给出面试中经常遇到的关于XGBoost的问题。

2. XGBoost的原理推导

2.1 从目标函数开始,生成一棵树

XGBoost和GBDT两者都是boosting方法,除了工程实现、解决问题上的一些差异外,最大的不同就是目标函数的定义。因此,本文从目标函数开始探究XGBoost的基本原理。

2.1.1 学习第t棵树

XGBoost是由k个基模型组成的一个加法模型,假设我们第t次迭代要训练的树模型是f_t(x) ,则有:
\hat{y_i}^{(t)}=\sum_{k=1}^tf_k(x_i)=\hat{y_i}^{(t-1)}+f_t(x_i)
其中,\hat{y_i}^{(t)}为第t次迭代后样本i的预测结果,\hat{y_i}^{(t-1)}为前t-1棵树的预测结果,f_t(x_i)为第t棵树的模型。

2.1.2 XGBoost的目标函数

损失函数可由预测值\hat{y_i}与真实值进{y_i}行表示:
L=\sum_{i=1}^nl(y_i,\hat{y}_i)
其中,n为样本的数量

模型的预测精度由模型的偏差和方差共同决定,损失函数代表了模型的偏差,想要方差小则需要在目标函数中添加正则项,用于防止过拟合。所以目标函数由模型的损失函数L与抑制模型复杂度的正则项\Omega组成,目标函数的定义如下:
Obj=\sum_{i=1}^nl(y_i,\hat{y}_i)+\sum_{i=1}^t\Omega(f_i)
其中,\sum_{i=1}^t\Omega(f_i)是将全部t棵树的复杂度进行求和,添加到目标函数中作为正则化项,用于防止模型过度拟合

由于XGBoost是boosting族中的算法,所以遵从前向分步加法,以第t步的模型为例,模型对第i个样本的x_i预测值为:
\hat{y_i}^{(t)} = \hat{y_i}^{(t-1)}+f_t(x_i)
其中,\hat{y_i}^{(t-1)}是由第t-1步的模型给出的预测值,是已知常数, f_t(x_i)是这次需要加入的新模型的预测值。此时,目标函数就可以写成:
\begin{align} Obj^{(t)}&=\sum_{i=1}^nl(y_i,\hat{y}_i^{(t)})+\sum_{i=1}^t\Omega(f_i)\\ &=\sum_{i=1}^nl(y_i,\hat{y}_i^{(t-1)}+f_t(x_i))+\sum_{i=1}^t\Omega(f_i)\\ &=\sum_{i=1}^nl(y_i,\hat{y}_i^{(t-1)}+f_t(x_i))+\Omega(f_i)+constant\\ \end{align}
注意上式中,只有一个变量,那就是第t棵树f_t(x_i),其余都是已知量或可通过已知量可以计算出来的。由于前t-1棵树的结构已经确定,因此前棵t-1树的复杂度之和可以用一个常量表示,如下所示:
\begin{align} \sum_{i=1}^t\Omega(f_i)&=\Omega(f_i)+\sum_{i=1}^{t-1}\Omega(f_i)\\ &=\Omega(f_i)+constant \end{align}

2.1.3 泰勒公式展开

泰勒公式是将一个在x=x_0处具有n阶导数的函数n利用关于(x=x_0)的次多项式n来逼近函数的方法。若函数f(x)包含x_0的某个闭区间[a,b]上具有n阶导数,且在开区间(a,b)上具有n+1阶导数,则对闭区间[a,b]上任意一点x有:
f(x)=\sum_{i=0}^n\frac{f^{(i)}(x_0)}{i!}(x-x_0)^i+R_n(x)
其中的多项式称为函数在x_0处的泰勒展开式,R_n(x)是泰勒公式的余项且是的(x=x_0)^n高阶无穷小

根据泰勒公式,把函数f(x+\Delta x)在点x处进行泰勒的二阶展开,可得如下等式:
f(x+\Delta x)≈f(x)+f\prime(x)\Delta x+\frac12f\prime\prime(x)\Delta x^2

回到XGBoost的目标函数上来,f(x)对应损失函数l(y_i,\hat{y}_i^{(t-1)}+f_t(x_i))x对应前棵t-1树的预测值\hat{y}_i^{(t-1)}\Delta x对应于我们正在训练的第t棵树f_t(x_i),则可以将损失函数写为:
l(y_i,\hat{y}_i^{(t-1)}+f_t(x_i))=l (y_i,\hat{y}_i^{(t-1)})+g_if_t(x_i)+\frac12h_if_t^2(x_i)
其中,g_i为损失函数的一阶导,h_i为损失函数的二阶导,注意这里的求导是对 \hat{y}_i^{(t-1)}求导

以平方损失函数为例:
l (y_i,\hat{y}_i^{(t-1)})=(y_i,\hat{y}_i^{(t-1)})^2

则:
g_i=\frac{\partial l(y_i,\hat{y}_i^{(t-1)})}{\partial\hat{y}_i^{(t-1)}}=-2(y_i-\hat{y}_i^{(t-1)})\\ h_i=\frac{\partial^2 l(y_i,\hat{y}_i^{(t-1)})}{\partial(\hat{y}_i^{(t-1)})^2}=2
将上述的二阶展开式,带入到XGBoost的目标函数中,可以得到目标函数的近似值:
Obj^{(t)}\simeq \sum_{i=1}^n[l(y,\hat{y}_i^{t-1})+g_if_t(x_i)+\frac12h_if_i^2(x_i)]+\Omega(f_t)+constant
由于在第t步时其\hat{y}_i^{(t-1)}其实是一个已知的值,所以l(y,\hat{y}_i^{t-1})是一个常数,其对函数的优化不会产生影响。因此去掉全部的常数项,得到目标函数为:

Obj^{(t)}\simeq \sum_{i=1}^n[g_if_t(x_i)+\frac12h_if_i^2(x_i)]+\Omega(f_t)
所以只需要求出每一步损失函数的一阶导和二阶导的值(由于前一步的\hat{y}_i^{(t-1)}是已知的,所以这两个值就是常数),然后最优化目标函数,就可以得到每一步的f(x) ,最后根据加法模型得到一个整体模型。

2.1.4 定义一棵树

XGBoost的基模型不仅支持决策树,还支持线性模型,此处主要介绍基于决策树的目标函数。可以重新定义一棵决策树,其包括两个部分:

2.1.5 定义树的复杂度

决策树的复杂度\Omega可由叶子数T组成,叶子节点越少模型越简单,此外叶子节点也不应该含有过高的权重\omega(类比 LR 的每个变量的权重),所以目标函数的正则项由生成的所有决策树的叶子节点数量,和所有节点权重所组成的向量的L_2范式共同决定

2.1.6 叶子结点归组

将属于第j个叶子结点的所有样本划x_i入到一个叶子结点的样本集合中,数学表示为 :I_j={i|q(x_i)=j},那么XGBoost的目标函数可以写成:
\begin{align} Obj^{(t)}&\simeq \sum_{i=1}^n[g_if_t(x_i)+\frac12h_if_i^2(x_i)]+\Omega(f_t)\\ &=\sum_{i=1}^n[g_i\omega_{q(x_i)}+\frac12h_i\omega_{q(x_i)}^2]+\gamma T+\frac12\lambda\sum_{j=1}^T\omega^2_j\\ &=\sum_{j=1}^T[(\sum_{i\in I_j}g_i)\omega_j+\frac12(\sum_{i\in I_j}h_i+\lambda)\omega_j^2+\gamma T\\ \end{align}
上式中第二行是遍历所有的样本后求每个样本的损失函数,但样本最终会落在叶子节点上,所以也可以遍历叶子节点,然后获取叶子节点上的样本集合,最后再求损失函数。即之前是单个样本,现在都改写成叶子结点的集合,由于一个叶子结点有多个样本存在,因此才有了\sum_{i\in I_j}g_i\sum_{i\in I_j}h_i这两项,\omega_j为第j个叶子节点取值

为简化表达式,我们定G_j=\sum_{i\in I_j}g_i,,H_j=\sum_{i\in I_j}h_i,其具体含义如下:

Obj^{(t)}==\sum_{j=1}^T[G_j\omega_j+\frac12(H_i+\lambda)\omega_j^2]+\gamma T

这里我们要注意G_jH_j 是前t-1步得到的结果,其值已知可视为常数,只有最后一棵树的叶子节点\omega_j不确定

2.1.7 树结构打分

对于每个叶子结点j,可以将其从目标函数中拆解出来
G_j\omega_j=\frac 12(H_j+\lambda)\omega_j^2
在2.1.5中我们提到,G_jH_j 相对于第t棵树来说是可以计算出来的。那么,这个式子就是一个只包含一个变量叶子结点权重\omega_j一元二次函数,可以通过最值公式求出它的最值点。各个叶子结点的目标子式是相互独立的,也就是说,当每个叶子结点的子式都达到最值点时,整个目标函数Obj^{(t)}才达到最值点。

那么,假设目前树的结构已经固定,套用一元二次函数的最值公式,将目标函数对\omega_j求一阶导,并令其等于0 ,则可以求得叶子结点j应的权值:
\omega_j^*=-\frac{G_j}{H_j+\lambda}
所以目标函数可以化简为:
Obj=-\frac12\sum_{j=1}^T\frac{G^2_j}{H_j+\lambda}+\gamma T

上图给出目标函数计算的例子,求每个节点每个样本的一阶导数g_i和二阶导数h_i,然后针对每个节点对所含样本求和得到,G_jH_j 最后遍历决策树的节点即可得到目标函数。

2.2 一棵树的生成细节

2.2.1 最优切分点划分算法

在实际训练过程中,当建立第t棵树时,一个非常关键的问题是如何找到叶子节点的最优切分点,XGBoost支持两种分裂节点的方法——贪心算法和近似算法。

(1)贪心算法

从树的深度为0开始:

Obj_1=-\frac12[\frac{(G_L+G_R)^2}{H_L+H_R+\lambda}]+\gamma

分裂后的目标函数为:
Obj_2=-\frac12[\frac{G_L^2}{H_L+\lambda}+\frac{G_R^2}{H_R+\lambda}-\frac{(G_L+G_R)^2}{H_L+H_R+\lambda}]-\gamma
注意:该特征收益也可作为特征重要性输出的重要依据。

对于每次分裂,我们都需要枚举所有特征可能的分割方案,如何高效地枚举所有的分割呢?

假设我们要枚举某个特征所有x<a这样条件的样本,对于某个特定的分割点a,要计算a左边和右边的导数和

可以发现对于所有的分裂点a,只要做一遍从左到右的扫描就可以枚举出所有分割的梯度和 G_LG_R 。然后用上面的公式计算每个分割方案的收益就可以了

观察分裂后的收益,可以发现节点划分不一定会使得结果变好,因为有一个引入新叶子的惩罚项,也就是说引入的分割带来的增益如果小于一个阀值的时候,就可以剪掉这个分割

(2)近似算法

贪心算法可以得到最优解,但当数据量太大时则无法读入内存进行计算,近似算法主要针对贪心算法这一缺点给出了近似最优解。对于每个特征,只考察分位点可以减少计算复杂度

该算法首先根据特征分布的分位数提出候选划分点,然后将连续型特征映射到由这些候选点划分的桶中,然后聚合统计信息找到所有区间的最佳分裂点

在提出候选切分点时有两种策略:

从上图我们可以看到, Global 策略在候选点数多时(eps 小)可以和 Local 策略在候选点少时(eps 大)具有相似的精度。此外我们还发现,在eps取值合理的情况下,分位数策略可以获得与贪心算法相同的精度

近似算法简单来说,就是根据特征k的分布来确定l个候选切分点S_k={s_{k_1},s_{k_2},...,s_{k_l}} ,然后根据这些候选切分点把相应的样本放入对应的桶中,对每个桶的G,H进行累加。最后在候选切分点集合上贪心查找。该算法描述如下:

算法讲解:

2.2.2 加权分位数缩略图

实际上,XGBoost不是简单地按照样本个数进行分位,而是以二阶导数值h_i作为样本的权重进行划分。为了处理带权重的候选切分点的选取,作者提出了Weighted Quantile Sketch算法。加权分位数略图算法提出了一种数据结构,这种数据结构支持merge和prune操作。作者在论文中给出了该算法的详细描述和证明链接,现在链接已经失效,但是在arXiv的最新版XGBoost论文中APPENDIX部分有该算法详细的描述,地址:https://arxiv.org/abs/1603.02754 。现在简单介绍加权分位数略图侯选点的选取方式,如下:

已知模型的目标函数为:
Obj^{(t)}\simeq \sum_{i=1}^n[g_if_t(x_i)+\frac12h_if_i^2(x_i)]+\sum_{i=1}^t\Omega(f_t)
把目标函数配方整理成以下形式,便可以看出h_i有对 loss 加权的作用
\begin{align} Obj^{(t)}&\simeq \sum_{i=1}^n[g_if_t(x_i)+\frac12h_if_i^2(x_i)]+\Omega(f_t)\\ &= \sum_{i=1}^n\frac12h_i[\frac{2g_if_t(x_i)}{h_i}+f_i^2(x_i)]+\Omega(f_t)\\ &= \sum_{i=1}^n\frac12h_i[\frac{2g_if_t(x_i)}{h_i}+f_i^2(x_i)+(\frac{g_i}{h_i})^2-(\frac{g_i}{h_i})^2]+\Omega(f_t)\\ &= \sum_{i=1}^n\frac12h_i[f_t(x_i)-(-\frac{g_i}{h_i})]^2+\Omega(f_t)-\sum_{i=1}^n\frac12\frac{g_i^2}{h_i}\\ &= \sum_{i=1}^n\frac12h_i[f_t(x_i)-(-\frac{g_i}{h_i})]^2+\Omega(f_t)-constant \end{align}
其中,加入\frac12\frac{g_i^2}{h_i}是因为g_ih_i是上一轮的损失函数求导与皆constant为常数。可以看到h_i就是平方损失函数中样本的权重

2.2.3 稀疏感知算法

实际工程中一般会出现输入值稀疏的情况。比如数据的缺失、one-hot编码都会造成输入数据稀疏。XGBoost在构建树的节点过程中只考虑非缺失值的数据遍历,而为每个节点增加了一个缺省方向,当样本相应的特征值缺失时,可以被归类到缺省方向上,最优的缺省方向可以从数据中学到。至于如何学到缺省值的分支,其实很简单,分别枚举特征缺省的样本归为左右分支后的增益,选择增益最大的枚举项即为最优缺省方向。

在构建树的过程中需要枚举特征缺失的样本,乍一看这个算法会多出相当于一倍的计算量,但其实不是的。因为在算法的迭代中只考虑了非缺失值数据的遍历,缺失值数据直接被分配到左右节点,所需要遍历的样本量大大减小。作者通过在Allstate-10K数据集上进行了实验,从结果可以看到稀疏算法比普通算法在处理数据上快了超过50倍。


3. XGBoost的工程实现

3.1 列块并行学习

在树生成过程中,最耗时的一个步骤就是在每次寻找最佳分裂点时都需要对特征的值进行排序。而 XGBoost 在训练之前会根据特征对数据进行排序,然后保存到块结构中,并在每个块结构中都采用了稀疏矩阵存储格式(Compressed Sparse Columns Format,CSC)进行存储,后面的训练过程中会重复地使用块结构,可以大大减小计算量

作者提出通过按特征进行分块并排序,在块里面保存排序后的特征值及对应样本的引用,以便于获取样本的一阶、二阶导数值。具体方式如图:

通过顺序访问排序后的块遍历样本特征的特征值,方便进行切分点的查找。此外分块存储后多个特征之间互不干涉,可以使用多线程同时对不同的特征进行切分点查找,即特征的并行化处理。在对节点进行分裂时需要选择增益最大的特征作为分裂,这时各个特征的增益计算可以同时进行,这也是 XGBoost 能够实现分布式或者多线程计算的原因

3.2 缓存访问

列块并行学习的设计可以减少节点分裂时的计算量,在顺序访问特征值时,访问的是一块连续的内存空间,但通过特征值持有的索引(样本索引)访问样本获取一阶、二阶导数时,这个访问操作访问的内存空间并不连续,这样可能造成cpu缓存命中率低,影响算法效率

为了解决缓存命中率低的问题,XGBoost 提出了缓存访问算法:为每个线程分配一个连续的缓存区,将需要的梯度信息存放在缓冲区中,这样就实现了非连续空间到连续空间的转换,提高了算法效率。此外适当调整块大小,也可以有助于缓存优化

3.3 "核外"块计算

当数据量非常大时,不能把所有的数据都加载到内存中。那么就必须将一部分需要加载进内存的数据先存放在硬盘中,当需要时再加载进内存。这样操作具有很明显的瓶颈,即硬盘的IO操作速度远远低于内存的处理速度,肯定会存在大量等待硬盘IO操作的情况。针对这个问题作者提出了“核外”计算的优化方法。具体操作为,将数据集分成多个块存放在硬盘中,使用一个独立的线程专门从硬盘读取数据,加载到内存中,这样算法在内存中处理数据就可以和从硬盘读取数据同时进行。此外,XGBoost 还用了两种方法来降低硬盘读写的开销:

4. XGBoost的优缺点

4.1 优点

4.2 缺点

5. XGBoost实例

下文将给出部分代码案例

5.1 安装XGBoost依赖包

pip install xgboost

5.2 XGBoost分类和回归

XGBoost有两大类接口:XGBoost原生接口和scikit-learn接口 ,并且XGBoost能够实现分类和回归两种任务。

from sklearn.datasets import load_iris
import xgboost as xgb
from xgboost import plot_importance
from matplotlib import pyplot as plt
from sklearn.model_selection import train_test_split

# read in the iris data
iris = load_iris()
X = iris.data
y = iris.target
# split train data and test data
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1234565)
# set XGBoost's parameters
params = {
    'booster': 'gbtree',
    'objective': 'multi:softmax',   # 回归任务设置为:'objective': 'reg:gamma',
    'num_class': 3,      # 回归任务没有这个参数
    'gamma': 0.1,
    'max_depth': 6,
    'lambda': 2,
    'subsample': 0.7,
    'colsample_bytree': 0.7,
    'min_child_weight': 3,
    'silent': 1,
    'eta': 0.1,
    'seed': 1000,
    'nthread': 4,
}
plst = params.items()
dtrain = xgb.DMatrix(X_train, y_train)
num_rounds = 500
model = xgb.train(plst, dtrain, num_rounds)
# 对测试集进行预测
dtest = xgb.DMatrix(X_test)
ans = model.predict(dtest)
# 计算准确率
cnt1 = 0
cnt2 = 0
for i in range(len(y_test)):
    if ans[i] == y_test[i]:
        cnt1 += 1
    else:
        cnt2 += 1
print("Accuracy: %.2f %% " % (100 * cnt1 / (cnt1 + cnt2)))
# 显示重要特征
plot_importance(model)
plt.show()
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.impute import SimpleImputer
import xgboost as xgb
from sklearn.metrics import mean_absolute_error

# 1.读文件
data = pd.read_csv('./dataset/train.csv')
data.dropna(axis=0, subset=['SalePrice'], inplace=True)
# 2.切分数据输入:特征 输出:预测目标变量
y = data.SalePrice
X = data.drop(['SalePrice'], axis=1).select_dtypes(exclude=['object'])
# 3.切分训练集、测试集,切分比例7.5 : 2.5
train_X, test_X, train_y, test_y = train_test_split(X.values, y.values, test_size=0.25)
# 4.空值处理,默认方法:使用特征列的平均值进行填充
my_imputer = SimpleImputer()
train_X = my_imputer.fit_transform(train_X)
test_X = my_imputer.transform(test_X)
# 5.调用XGBoost模型,使用训练集数据进行训练(拟合)
# Add verbosity=2 to print messages while running boosting
my_model = xgb.XGBRegressor(objective='reg:squarederror', verbosity=2)  # xgb.XGBClassifier() XGBoost分类模型
my_model.fit(train_X, train_y, verbose=False)
# 6.使用模型对测试集数据进行预测
predictions = my_model.predict(test_X)
# 7.对模型的预测结果进行评判(平均绝对误差)
print("Mean Absolute Error : " + str(mean_absolute_error(predictions, test_y)))

5.3 XGBoost调参

在上一部分中,XGBoot模型的参数都使用了模型的默认参数,但默认参数并不是最好的。要想让XGBoost表现的更好,需要对XGBoost模型进行参数微调。下图展示的是分类模型需要调节的参数,回归模型需要调节的参数与此类似。

6. 关于XGBoost若干问题的思考

6.1 XGBoost与GBDT的联系和区别有哪些?

6.2 为什么XGBoost泰勒二阶展开后效果就比较好呢?

(1)从为什么会想到引入泰勒二阶的角度来说(可扩展性):XGBoost官网上有说,当目标函数是MSE时,展开是一阶项(残差)+二阶项的形式,而其它目标函数,如logistic loss的展开式就没有这样的形式。为了能有个统一的形式,所以采用泰勒展开来得到二阶项,这样就能把MSE推导的那套直接复用到其它自定义损失函数上。简短来说,就是为了统一损失函数求导的形式以支持自定义损失函数。至于为什么要在形式上与MSE统一?是因为MSE是最普遍且常用的损失函数,而且求导最容易,求导后的形式也十分简单。所以理论上只要损失函数形式与MSE统一了,那就只用推导MSE就好了。

(2)从二阶导本身的性质,也就是从为什么要用泰勒二阶展开的角度来说(精准性):二阶信息本身就能让梯度收敛更快更准确。这一点在优化算法里的牛顿法中已经证实。可以简单认为一阶导指引梯度方向,二阶导指引梯度方向如何变化。简单来说,相对于GBDT的一阶泰勒展开,XGBoost采用二阶泰勒展开,可以更为精准的逼近真实的损失函数。

6.3 XGBoost对缺失值是怎么处理的?

在普通的GBDT策略中,对于缺失值的方法是先手动对缺失值进行填充,然后当做有值的特征进行处理,但是这样人工填充不一定准确,而且没有什么理论依据。而XGBoost采取的策略是先不处理那些值缺失的样本,采用那些有值的样本搞出分裂点,在遍历每个有值特征的时候,尝试将缺失样本划入左子树和右子树,选择使损失最优的值作为分裂点。

6.4 XGBoost为什么可以并行训练?

(1)XGBoost的并行,并不是说每棵树可以并行训练,XGBoost本质上仍然采用boosting思想,每棵树训练前需要等前面的树训练完成才能开始训练。

(2)XGBoost的并行,指的是特征维度的并行:在训练之前,每个特征按特征值对样本进行预排序,并存储为Block结构,在后面查找特征分割点时可以重复使用,而且特征已经被存储为一个个block结构,那么在寻找每个特征的最佳分割点时,可以利用多线程对每个block并行计算。

上一篇 下一篇

猜你喜欢

热点阅读