GBDT、XGB、LGB原理、差异、面试
一. GBDT(Gradient Boost Decision Tree)
提一嘴AdaBoost
AdaBoost,是英文"Adaptive Boosting"(自适应增强),它的自适应在于:前一个基本分类器分错的样本会得到加强,加权后的全体样本再次被用来训练下一个基本分类器。同时,在每一轮中加入一个新的弱分类器,直到达到某个预定的足够小的错误率或达到预先指定的最大迭代次数。它在训练弱分类器之前,会给每个样本一个权重,训练完了一个分类器,就会调整样本的权重,前一个分类器分错的样本权重会加大,这样后面再训练分类器的时候,就会更加注重前面分错的样本, 然后一步一步的训练出很多个弱分类器,最后,根据弱分类器的表现给它们加上权重,组合成一个强大的分类器,就足可以应付整个数据集了。 这就是AdaBoost, 它强调自适应,不断修改样本权重, 不断加入弱分类器进行boosting。
GBDT原理
AdaBoost训练弱分类器关注的是那些被分错的样本,AdaBoost每一次训练都是为了减少错误分类的样本。 而GBDT训练弱分类器关注的是残差,GBDT每一次的计算是都为了减少上一次的残差,进而在残差减少(负梯度)的方向上建立一个新的模型。
GBDT 由三个概念组成:Regression Decision Tree(即 DT)、Gradient Boosting(即 GB),和 Shrinkage(一个重要演变)
如果认为 GBDT 由很多分类树那就大错特错了(虽然调整后也可以分类)。对于分类树而言,其值加减无意义(如性别),而对于回归树而言,其值加减才是有意义的(如说年龄)。GBDT 的核心在于累加所有树的结果作为最终结果,所以 GBDT 中的树都是回归树,不是分类树,这一点相当重要。
回归树在分枝时会穷举每一个特征的每个阈值以找到最好的分割点,衡量标准是最小化均方误差。
GBDT可以表示为决策树的加法模型:
f
M
(
x
)
=
∑
m
=
1
M
T
(
x
;
Θ
m
)
\mathrm{f}_{\mathrm{M}}(\mathrm{x})=\sum_{\mathrm{m}=1}^{\mathrm{M}} \mathrm{T}\left(\mathrm{x} ; \Theta_{\mathrm{m}}\right)
fM(x)=m=1∑MT(x;Θm)
T
(
x
;
Θ
m
)
T(x;\Theta_m)
T(x;Θm)表示决策树;
Θ
m
\Theta_m
Θm为决策树的参数,
M
M
M表示树的个数, 即
M
M
M棵树的结果相加
首先确定初始提升树
f
0
(
x
)
=
0
f_0(x)=0
f0(x)=0, 第
m
m
m步的模型是:
f
m
(
x
)
=
f
m
−
1
(
x
)
+
T
(
x
;
Θ
m
)
\mathrm{f}_{\mathrm{m}}(\mathrm{x})=\mathrm{f}_{\mathrm{m}-1}(\mathrm{x})+\mathrm{T}\left(\mathrm{x} ; \Theta_{\mathrm{m}}\right)
fm(x)=fm−1(x)+T(x;Θm)
找到最优划分点,
L
(
)
L()
L() 是损失函数
Θ
^
m
=
arg
min
Θ
m
∑
i
=
1
N
L
(
y
i
,
f
m
−
1
(
x
i
)
+
T
(
x
i
;
Θ
m
)
)
\hat{\Theta}_{\mathrm{m}}=\arg \min _{\Theta_{\mathrm{m}}} \sum_{\mathrm{i}=1}^{\mathrm{N}} \mathrm{L}\left(\mathrm{y}_{\mathrm{i}}, \mathrm{f}_{\mathrm{m}-1}\left(\mathrm{x}_{\mathrm{i}}\right)+\mathrm{T}\left(\mathrm{x}_{\mathrm{i}} ; \Theta_{\mathrm{m}}\right)\right)
Θ^m=argΘmmini=1∑NL(yi,fm−1(xi)+T(xi;Θm))
不同问题的提升树, 损失函数不同。如果我们解决的一个回归问题, 我们用平方损失函数的话, 第
m
m
m次迭代的损失函数为:
L
(
y
,
f
m
−
1
(
x
)
+
T
(
x
,
Θ
m
)
)
=
(
y
−
f
m
−
1
(
x
)
−
T
(
x
,
Θ
m
)
)
2
=
(
r
−
T
(
x
,
Θ
m
)
)
2
\mathrm{L}\left(\mathrm{y}, \mathrm{f}_{\mathrm{m}-1}(\mathrm{x})+\mathrm{T}\left(\mathrm{x}, \Theta_{\mathrm{m}}\right)\right)=\left(\mathrm{y}-\mathrm{f}_{\mathrm{m}-1}(\mathrm{x})-\mathrm{T}\left(\mathrm{x}, \Theta_{\mathrm{m}}\right)\right)^2=\left(\mathrm{r}-\mathrm{T}\left(\mathrm{x}, \Theta_{\mathrm{m}}\right)\right)^2
L(y,fm−1(x)+T(x,Θm))=(y−fm−1(x)−T(x,Θm))2=(r−T(x,Θm))2
这里的
r
r
r是残差,近似值也就是损失函数的负梯度
−
[
∂
L
(
y
i
,
F
(
x
i
)
)
∂
F
(
x
i
)
]
F
(
x
)
=
F
t
−
1
(
x
)
-\left[\frac{\partial \mathrm{L}\left(\mathrm{y}_{\mathrm{i}}, \mathrm{F}\left(\mathbf{x}_{\mathbf{i}}\right)\right)}{\partial \mathrm{F}\left(\mathbf{x}_{\mathbf{i}}\right)}\right]_{\mathrm{F}(\mathbf{x})=\mathrm{F}_{\mathrm{t}-1}(\mathbf{x})}
−[∂F(xi)∂L(yi,F(xi))]F(x)=Ft−1(x)
怎么来理解这个近似呢? 用平方损失函数举例:
L
(
y
i
,
F
(
x
i
)
)
=
1
2
(
y
i
−
F
(
x
i
)
)
2
\mathrm{L}\left(\mathrm{y}_{\mathrm{i}}, \mathrm{F}\left(\mathrm{x}_{\mathrm{i}}\right)\right)=\frac{1}{2}\left(\mathrm{y}_{\mathrm{i}}-\mathrm{F}\left(\mathbf{x}_{\mathrm{i}}\right)\right)^2
L(yi,F(xi))=21(yi−F(xi))2
求导 ∂ L ( y i , F ( x i ) ) ∂ F ( x i ) = F ( x i ) − y i {求导} \frac{\partial \mathrm{L}\left(\mathrm{y}_{\mathrm{i}}, \mathrm{F}\left(\mathbf{x}_{\mathrm{i}}\right)\right)}{\partial \mathrm{F}\left(\mathbf{x}_{\mathrm{i}}\right)}=\mathrm{F}\left(\mathbf{x}_{\mathrm{i}}\right)-\mathrm{y}_{\mathrm{i}} 求导∂F(xi)∂L(yi,F(xi))=F(xi)−yi
可以看出:
r
t
i
=
y
i
−
F
t
−
1
(
x
)
=
−
[
∂
L
(
y
i
,
F
(
x
i
)
)
∂
F
(
x
i
)
]
F
(
x
)
=
F
t
−
1
(
x
)
\mathrm{r}_{\mathrm{ti}}=\mathrm{y}_{\mathrm{i}}-\mathrm{F}_{\mathrm{t}-1}(\mathbf{x})=-\left[\frac{\partial \mathrm{L}\left(\mathrm{y}_{\mathrm{i}}, \mathrm{F}\left(\mathbf{x}_{\mathbf{i}}\right)\right)}{\partial \mathrm{F}\left(\mathbf{x}_{\mathbf{i}}\right)}\right]_{\mathrm{F}(\mathbf{x})=\mathrm{F}_{\mathrm{t}-1}(\mathrm{x})}
rti=yi−Ft−1(x)=−[∂F(xi)∂L(yi,F(xi))]F(x)=Ft−1(x)
当然这是平方损失函数, 其他损失的话, 也会得到近似的结论, 只不过不是完全相等, 放张图体会一下:
要注意的是,基于残差 GBDT 容易对异常值敏感,举例:
很明显后续的模型会对第 4 个值关注过多,这不是一种好的现象,所以一般回归类的损失函数会用绝对损失或者 Huber 损失函数来代替平方损失函数。
Shrinkage 的思想认为,每走一小步逐渐逼近结果的效果要比每次迈一大步很快逼近结果的方式更容易避免过拟合。即它并不是完全信任每一棵残差树。
F
i
(
x
)
=
F
i
−
1
(
x
)
+
μ
f
i
(
x
)
(
0
<
μ
≤
1
)
F_{i}(x)=\ F_{i-1}(x)+\mu f_{i}(x)\quad(0<\mu\le1)
Fi(x)= Fi−1(x)+μfi(x)(0<μ≤1)
GBDT与 Adaboost 的不同
- 迭代思路不同:Adaboost 是通过提升错分数据点的权重来弥补模型的不足(利用错分样本),而 GBDT 是通过算梯度来弥补模型的不足(利用残差);
- 损失函数不同:AdaBoost 采用的是指数损失,GBDT 使用的是绝对损失或者 Huber 损失函数;
RF和GBDT的区别
相同点:
- 都是由多棵树组成,最终的结果都是由多棵树一起决定。
不同点:
- 集成学习:RF属于bagging思想,而GBDT是boosting思想
- 偏差-方差权衡:RF不断的降低模型的方差,而GBDT不断的降低模型的偏差
- 训练样本:RF每次迭代的样本是从全部训练集中有放回抽样形成的,而GBDT每次使用全部样本
- 并行性:RF的树可以并行生成,而GBDT只能顺序生成(需要等上一棵树完全生成)
- 最终结果:RF最终是多棵树进行多数表决(回归问题是取平均),而GBDT是加权融合
- 数据敏感性:RF对异常值不敏感,而GBDT对异常值比较敏感
- 泛化能力:RF不易过拟合,而GBDT容易过拟合
二. XGBoost(eXtreme Gradient Boosting)
XGBoost 由华盛顿大学的陈天奇博士提出,是大规模并行 boosting tree 的工具,它是目前最快最好的开源 boosting tree 工具包,比常见的工具包快 10 倍以上。
目标函数
XGBoost 是由
k
k
k个基模型组成的一个加法运算式:
y
^
i
=
∑
t
=
1
k
f
t
(
x
i
)
\hat{y}_i=\sum_{t=1}^{k}\:f_t(x_i)
y^i=t=1∑kft(xi)
其中
f
k
f_k
fk为第
k
k
k 个基模型,
y
^
i
\hat{y}_i
y^i 为第
i
i
i个样本的预测值。
损失函数:
L
=
∑
i
=
1
n
l
(
y
i
,
y
^
i
)
L=\sum_{i=1}^n l( y_i, \hat{y}_i)
L=i=1∑nl(yi,y^i)
目标函数:
O
b
j
(
t
)
=
∑
i
=
1
n
1
(
y
1
,
y
^
1
t
)
+
Ω
(
f
t
)
=
∑
i
=
1
n
1
(
y
1
,
y
^
1
t
−
1
+
f
t
(
x
i
)
)
+
Ω
(
f
t
)
\begin{aligned}\mathrm{Obj}^{(t)}&=\sum_{i=1}^n1\left(y_1,\hat{y}_1^t\right)+\Omega\left(f_t\right)\\ &=\sum_{i=1}^n1\left(y_1,\hat{y}_1^{t-1}+f_t\left(x_i\right)\right)+\Omega\left(f_t\right)\end{aligned}
Obj(t)=i=1∑n1(y1,y^1t)+Ω(ft)=i=1∑n1(y1,y^1t−1+ft(xi))+Ω(ft)
其中
Ω
(
f
t
)
\Omega(f_t)
Ω(ft)是正则化项。
根据Taylor公式的二阶展开式:
f
(
x
)
≈
f
(
x
0
)
+
f
′
(
x
0
)
(
x
−
x
0
)
+
1
2
f
′
′
(
x
0
)
(
x
−
x
0
)
2
\mathrm{f}(\mathrm{x})\approx\mathrm{f}(\mathrm{x}_0)+\mathrm{f}'(\mathrm{x}_0)(\mathrm{x}-\mathrm{x}_0)+\dfrac{1}{2}\mathrm{f}''(\mathrm{x}_0)(\mathrm{x}-\mathrm{x}_0)^2
f(x)≈f(x0)+f′(x0)(x−x0)+21f′′(x0)(x−x0)2
目标函数就可以写成:
O
b
j
(
t
)
≈
∑
i
=
1
n
[
1
(
y
i
,
y
^
i
t
−
1
)
+
g
i
f
t
(
x
i
)
+
1
2
h
i
f
t
2
(
x
i
)
]
+
Ω
(
f
t
)
\mathrm{Obj}^{\left(\mathrm{t}\right)}\approx\sum\limits_{\mathrm{i}=1}^{\mathrm{n}}\:\left[1\left(\mathrm{y}_\mathrm{i},\hat{\mathrm{y}}_\mathrm{i}^{\mathrm{t}-1}\right)+\mathrm{g_\mathrm{i}}\mathrm{f}_\mathrm{t}\left(\mathrm{x}_\mathrm{i}\right)+\dfrac{1}{2}h_\mathrm{i}\mathrm{f}_\mathrm{t}^2\left(\mathrm{x}_\mathrm{i}\right)\right]+\Omega\left(\mathrm{f}_\mathrm{t}\right)
Obj(t)≈i=1∑n[1(yi,y^it−1)+gift(xi)+21hift2(xi)]+Ω(ft)
以平方损失函数为例:
1
(
y
i
,
y
^
1
(
t
−
1
)
)
=
∑
i
=
1
n
(
y
^
t
−
1
−
y
i
)
2
1\left(\mathbf{y}_\mathbf{i},\mathbf{\hat{y}_1}^{\left(\mathbf{t}-1\right)}\right)=\sum_{\mathbf{i}=1}^\mathbf{n}\left(\mathbf{\hat{y}^{\mathbf{t}-1}}-\mathbf{y_i}\right)^2
1(yi,y^1(t−1))=i=1∑n(y^t−1−yi)2
g i = ∂ ( y ^ t − 1 − y i ) 2 ∂ y ^ t − 1 = 2 ( y ^ t − 1 − y i ) \text{g}_\mathrm{i}=\dfrac{\partial\left(\hat{\mathrm{y}}^{\mathrm{t}-1}-\mathrm{y}_\mathrm{i}\right)^2}{\partial\hat{\mathrm{y}}^{\mathrm{t}-1}}=2\left(\hat{\mathrm{y}}^{\mathrm{t}-1}-\mathrm{y}_\mathrm{i}\right) gi=∂y^t−1∂(y^t−1−yi)2=2(y^t−1−yi)
h i = ∂ 2 ( y ^ t − 1 − y i ) 2 y ^ t − 1 = 2 \mathrm{h}_\mathrm{i}=\dfrac{\partial^2\left(\hat{\mathrm{y}}^{\mathrm{t}-1}-\mathrm{y}_\mathrm{i}\right)^2}{\hat{\mathrm{y}}^{\mathrm{t}-1}}=2 hi=y^t−1∂2(y^t−1−yi)2=2
由于在第
t
t
t步时
y
^
i
t
−
1
\hat{y}_i^{t-1}
y^it−1 其实是一个已知的值,所以
l
(
y
i
,
y
^
i
t
−
1
)
l(y_i, \hat{y}_i^{t-1})
l(yi,y^it−1)是一个常数,其对函数的优化不会产生影响,因此目标函数可以写成:
O
b
j
(
t
)
≈
∑
i
=
1
n
[
g
i
f
t
(
x
i
)
+
1
2
h
i
f
t
2
(
x
i
)
]
+
Ω
(
f
t
)
\mathrm{Obj}^{\left(\mathrm{t}\right)}\approx\sum_{\mathrm{i}=1}^{\mathrm{n}}\:\left[\text{g}_{\mathrm{i}}\:\mathrm{f}_{\mathrm{t}}\left(\mathrm{x}_{\mathrm{i}}\right)+\dfrac{1}{2}\:\mathrm{h}_{\mathrm{i}}\:\mathrm{f}_{\mathrm{t}}^{\mathrm{2}}\left(\mathrm{x}_{\mathrm{i}}\right)\right]+\Omega\left(\mathrm{f}_{\mathrm{t}}\right)
Obj(t)≈i=1∑n[gift(xi)+21hift2(xi)]+Ω(ft)
所以我们只需要求出每一步损失函数的一阶导和二阶导的值(由于前一步的
y
^
t
−
1
\hat{y}^{t-1}
y^t−1是已知的,所以这两个值就是常数),然后最优化目标函数,就可以得到每一步的
f
(
x
)
f(x)
f(x),最后根据加法模型得到一个整体模型。
决策树形式的目标函数
Xgboost 的基模型不仅支持决策树,还支持线性模型,这里的正则项为以树模型为基模型:
Ω
(
f
t
)
=
γ
T
t
+
1
2
λ
∑
j
=
1
T
w
j
2
\Omega\left(\mathrm{f}_{\mathrm{t}}\right)=\gamma\mathrm{T}_{\mathrm{t}}+\dfrac{1}{2}\lambda\sum_{\mathrm{j}=1}^{\mathrm{T}}\mathrm{w}_{\mathrm{j}}^2
Ω(ft)=γTt+21λj=1∑Twj2
T
t
T_t
Tt为叶子节点数,
w
j
w_j
wj表示
j
j
j叶子上的节点权重,
γ
\gamma
γ ,
λ
\lambda
λ是预先给定的超参数。
设
I
j
=
{
i
∣
q
(
x
i
)
=
j
}
I_j= \{ i \vert q(x_i)=j \}
Ij={i∣q(xi)=j} 为第
j
j
j 个叶子节点的样本集合,故我们的目标函数可以写成:
O
b
j
(
t
)
≈
∑
i
=
1
n
[
g
i
f
t
(
x
i
)
+
1
2
h
i
f
t
2
(
x
i
)
]
+
Ω
(
f
t
)
=
∑
i
=
1
n
[
g
i
w
q
(
x
i
)
+
1
2
h
i
w
q
(
x
i
)
2
]
+
γ
T
+
1
2
λ
∑
j
=
1
T
w
j
2
=
∑
j
=
1
T
[
(
∑
i
∈
I
j
g
i
)
w
j
+
1
2
(
∑
i
∈
I
j
h
i
+
λ
)
w
j
2
]
+
γ
T
\begin{aligned} O b j ^ { ( t ) } & \approx \sum _ { i = 1 } ^ { n } \left[ g _ { i } f _ { t } \left( x _ { i } \right) + \frac { 1 } { 2 } h _ { i } f _ { t } ^ { 2 } \left( x _ { i } \right) \right] + \Omega \left( f _ { t } \right) \\ & = \sum _ { i = 1 } ^ { n } \left[ g _ { i } w _ { q \left( x _ { i } \right) } + \frac { 1 } { 2 } h _ { i } w _ { q \left( x _ { i } \right) } ^ { 2 } \right] + \gamma T + \frac { 1 } { 2 } \lambda \sum _ { j = 1 } ^ { T } w _ { j } ^ { 2 } \\ & = \sum _ { j = 1 } ^ { T } \left[ \left( \sum _ { i \in I _ { j } } g _ { i } \right) w _ { j } + \frac { 1 } { 2 } \left( \sum _ { i \in I _ { j } } h _ { i } + \lambda \right) w _ { j } ^ { 2 } \right] + \gamma T \end{aligned}
Obj(t)≈i=1∑n[gift(xi)+21hift2(xi)]+Ω(ft)=i=1∑n[giwq(xi)+21hiwq(xi)2]+γT+21λj=1∑Twj2=j=1∑T
i∈Ij∑gi
wj+21
i∈Ij∑hi+λ
wj2
+γT
第二步是遍历所有的样本后求每个样本的损失函数,但样本最终会落在叶子节点上,所以我们也可以遍历叶子节点,然后获取叶子节点上的样本集合,最后在求损失函数。即我们之前样本的集合,现在都改写成叶子结点的集合,由于一个叶子结点有多个样本存在,因此才有了 ∑ i ∈ I j g i \sum_{i \in I_j}g_i ∑i∈Ijgi和 ∑ i ∈ I j h i \sum_{i \in I_j}h_i ∑i∈Ijhi这两项, w j w_j wj为第 j j j个叶子节点取值。
为简化表达式,我们定义
G
j
=
∑
i
∈
I
j
g
i
G_j=\sum_{i \in I_j}g_i
Gj=∑i∈Ijgi,
H
j
=
∑
i
∈
I
j
h
i
H_j=\sum_{i \in I_j}h_i
Hj=∑i∈Ijhi ,则目标函数为:
O
b
j
(
t
)
=
∑
j
=
1
T
[
G
j
w
j
+
1
2
(
H
j
+
λ
)
w
j
2
]
+
γ
T
Obj^{(t)} = \sum_{j=1}^T \left[G_jw_j + \frac12(H_j + \lambda)w_j^2 \right] + \gamma T \\
Obj(t)=j=1∑T[Gjwj+21(Hj+λ)wj2]+γT
这里我们要注意
G
j
G_j
Gj和
H
j
H_j
Hj是前
t
−
1
t-1
t−1步得到的结果,其值已知可视为常数,只有最后一棵树的叶子节点
w
j
w_j
wj不确定,那么将目标函数对
w
j
w_j
wj求一阶导,并令其等于
0
0
0,
∂
J
(
f
t
)
∂
w
j
=
G
j
+
(
H
j
+
λ
)
w
j
=
0
\frac{\partial\mathrm{J}\left(\mathrm{f_t}\right)}{\partial\mathrm{w_j}}=\mathrm{G_j}+\left(\mathrm{H_j}+\lambda\right)\mathrm{w_j}=0
∂wj∂J(ft)=Gj+(Hj+λ)wj=0则可以求得叶子结点
j
j
j对应的权值:
w
j
∗
=
−
G
j
H
j
+
λ
w_j^*=-\frac{G_j}{H_j+\lambda} \\
wj∗=−Hj+λGj
所以目标函数可以化简为:
O
b
j
=
−
1
2
∑
j
=
1
T
G
j
2
H
j
+
λ
+
γ
T
Obj = -\frac12 \sum_{j=1}^T \frac{G_j^2}{H_j+\lambda} + \gamma T \\
Obj=−21j=1∑THj+λGj2+γT
上图给出目标函数计算的例子,求每个节点每个样本的一阶导数 g i g_i gi 和二阶导数 h i h_i hi,然后针对每个节点对所含样本求和得到的 G j G_j Gj和 H j H_j Hj,最后遍历决策树的节点即可得到目标函数。
最优切分点划分算法
在决策树的生长过程中,一个非常关键的问题是如何找到叶子的节点的最优切分点,Xgboost 支持两种分裂节点的方法——贪心算法和近似算法。
1.贪心算法
假设我们在某一节点完成特征分裂,则分列前的目标函数可以写为:
O b j 1 = − 1 2 [ ( G L + G R ) 2 H L + H R + λ ] + γ Obj_{1} =-\frac12 [\frac{(G_L+G_R)^2}{H_L+H_R+\lambda}] + \gamma \\ Obj1=−21[HL+HR+λ(GL+GR)2]+γ
分裂后的目标函数为:
O b j 2 = − 1 2 [ G L 2 H L + λ + G R 2 H R + λ ] + 2 γ Obj_2 = -\frac12 [ \frac{G_L^2}{H_L+\lambda} + \frac{G_R^2}{H_R+\lambda}] +2\gamma \\ Obj2=−21[HL+λGL2+HR+λGR2]+2γ
则对于目标函数来说,分裂后的收益为(Obj1-Obj2):
G a i n = 1 2 [ G L 2 H L + λ + G R 2 H R + λ − ( G L + G R ) 2 H L + H R + λ ] − γ Gain=\frac12 \left[ \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}\right] - \gamma \\ Gain=21[HL+λGL2+HR+λGR2−HL+HR+λ(GL+GR)2]−γ
那么我们就可以来梳理一下最优切分点的划分算法了:
- 从深度为 0 的树开始,对每个叶节点枚举所有的可用特征;
- 针对每个特征,把属于该节点的训练样本根据该特征值进行升序排列,通过线性扫描的方式来决定该特征的最佳分裂点,并记录该特征的分裂收益;(这个过程每个特征的收益计算是可以并行计算的,xgboost之所以快,其中一个原因就是因为它支持并行计算,而这里的并行正是指的特征之间的并行计算,千万不要理解成各个模型之间的并行)
- 选择收益最大的特征作为分裂特征,用该特征的最佳分裂点作为分裂位置,在该节点上分裂出左右两个新的叶节点,并为每个新节点关联对应的样本集(这里稍微提一下,xgboost是可以处理空值的,也就是假如某个样本在这个最优分裂点上值为空的时候, 那么xgboost先把它放到左子树上计算一下收益,再放到右子树上计算收益,哪个大就把它放到哪棵树上。)
- 回到第 1 步,递归执行到满足特定条件为止
注意该特征收益也可作为特征重要性输出的重要依据。
对于每次分裂,我们都需要枚举所有特征可能的分割方案,如何高效地枚举所有的分割呢?
我假设我们要枚举所有 x < a x < a x<a这样的条件,对于某个特定的分割点 a a a 我们要计算 a a a 左边和右边的导数和。
按照这个图从左至右扫描,我们就可以找出所有的切分点a, 对于每一个切分点a,计算出分割的梯度和 G L G_L GL和 G R G_R GR 。然后用上面的公式计算出每个分割方案的分数。然后哪个最大,就是年龄特征的最优切分点,而最大值就是年龄这个特征的最大信息收益。这个遍历特征的过程其实可以并行运行的。
注意这里xgboost和gbdt不同的点在于xgboost已经在切分的时候就已经考虑了树的复杂度,所以无需单独的剪枝操作。
但是这种贪心的方法计算代价太大了,尤其是数据量很大,分割点很多的时候,计算起来非常复杂并且也无法读入内存进行计算。 所以作者想到了一种近似分割的方式。
2.近似算法
我们可以通过等频或等宽分桶的方式选择更少的候选点,但是这样是固定的划分方式,没有一个对样本的衡量标准。所以 作者这里采用了一种对loss的影响权重的等值percentiles(百分比分位数)划分算法(Weight Quantile Sketch)。见图:
-
h i h_i hi为啥能代表样本对降低loss的贡献程度?
- h i h_i hi是损失函数在样本 i i i处的二阶导数,从目标函数本身出发进行化简看下
后面的每一个分类器都是在拟合每个样本的一个残差 − g i h i -\frac{g_i}{h_i} −higi, 而前面的 h i h_i hi可以看做计算残差时某个样本的重要性,即每个样本对降低loss的贡献程度。PS:Xgboost引入了二阶导之后,相当于在模型降低残差的时候给各个样本根据贡献度不同加入了一个权重,这样就能更好的加速拟合和收敛,GBDT只用到了一阶导数,这样只知道梯度大的样本降低残差效果好,梯度小的样本降低残差不好,但是好与不好的这个程度,在GBDT中无法展现。而xgboost这里就通过二阶导可以展示出来,这样模型训练的时候就有数了。
工程实现
块结构设计
我们知道,决策树的学习最耗时的一个步骤就是在每次寻找最佳分裂点是都需要对特征的值进行排序。而 XGBoost 在训练之前对根据特征对数据进行了排序,然后保存到块结构中,并在每个块结构中都采用了稀疏矩阵存储格式(Compressed Sparse Columns Format,CSC)进行存储,后面的训练过程中会重复地使用块结构,可以大大减小计算量。
- 每一个块结构包括一个或多个已经排序好的特征;
- 缺失特征值将不进行排序;
- 每个特征会存储指向样本梯度统计值的索引,方便计算一阶导和二阶导数值;
这种块结构存储的特征之间相互独立,方便计算机进行并行计算。在对节点进行分裂时需要选择增益最大的特征作为分裂,这时各个特征的增益计算可以同时进行,这也是 Xgboost 能够实现分布式或者多线程计算的原因。
缓存访问优化算法
块结构的设计可以减少节点分裂时的计算量,但特征值通过索引访问样本梯度统计值的设计会导致访问操作的内存空间不连续,这样会造成缓存命中率低,从而影响到算法的效率。
为了解决缓存命中率低的问题,XGBoost 提出了缓存访问优化算法:为每个线程分配一个连续的缓存区,将需要的梯度信息存放在缓冲区中,这样就是实现了非连续空间到连续空间的转换,提高了算法效率。
此外适当调整块大小,也可以有助于缓存优化。
“核外”块计算
当数据量过大时无法将数据全部加载到内存中,只能先将无法加载到内存中的数据暂存到硬盘中,直到需要时再进行加载计算,而这种操作必然涉及到因内存与硬盘速度不同而造成的资源浪费和性能瓶颈。为了解决这个问题,XGBoost 独立一个线程专门用于从硬盘读入数据,以实现处理数据和读入数据同时进行。
此外,XGBoost 还用了两种方法来降低硬盘读写的开销:
- 块压缩:对 Block 进行按列压缩,并在读取时进行解压;
- 块拆分:将每个块存储到不同的磁盘中,从多个磁盘读取可以增加吞吐量。
优缺点
优点
- 精度更高:GBDT 只用到一阶泰勒展开,而 XGBoost 对损失函数进行了二阶泰勒展开。XGBoost 引入二阶导一方面是为了增加精度,另一方面也是为了能够自定义损失函数,二阶泰勒展开可以近似大量损失函数;
- 灵活性更强:GBDT 以 CART 作为基分类器,XGBoost 不仅支持 CART 还支持线性分类器,(使用线性分类器的 XGBoost 相当于带 L1 和 L2 正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题))。此外,XGBoost 工具支持自定义损失函数,只需函数支持一阶和二阶求导;
- 正则化:XGBoost 在目标函数中加入了正则项,用于控制模型的复杂度。正则项里包含了树的叶子节点个数、叶子节点权重的 L2 范式。正则项降低了模型的方差,使学习出来的模型更加简单,有助于防止过拟合;
- Shrinkage(缩减):相当于学习速率。XGBoost 在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削弱每棵树的影响,让后面有更大的学习空间;
- 列抽样:XGBoost 借鉴了随机森林的做法,支持列抽样(指训练每棵树时,不是使用所有特征,而是从中抽取一部分来训练这棵树),不仅能降低过拟合,还能减少计算;
- 缺失值处理XGBoost :采用的稀疏感知算法极大的加快了节点分裂的速度;
- 可以并行化操作:块结构可以很好的支持并行计算。
缺点
- 虽然利用预排序和近似算法可以降低寻找最佳分裂点的计算量,但在节点分裂过程中仍需要遍历数据集;
- 预排序过程的空间复杂度过高,不仅需要存储特征值,还需要存储特征对应样本的梯度统计值的索引,相当于消耗了两倍的内存。
常见面试题
XGBoost与GBDT有什么不同?
- 基分类器:XGBoost的基分类器不仅支持CART决策树,还支持线性分类器,此时XGBoost相当于带L1和L2正则化项的Logistic回归(分类问题)或者线性回归(回归问题)。
- 导数信息:XGBoost对损失函数做了二阶泰勒展开,GBDT只用了一阶导数信息,并且XGBoost还支持自定义损失函数,只要损失函数一阶、二阶可导。同时精度也更高。
- 正则项:XGBoost的目标函数加了正则项, 相当于预剪枝,使得学习出来的模型更加不容易过拟合。
- 列抽样:XGBoost支持列采样,与随机森林类似,用于防止过拟合。
- 缺失值处理:对树中的每个非叶子结点,XGBoost可以自动学习出它的默认分裂方向。如果某个样本该特征值缺失,会将其划入默认分支。
- 并行化:注意不是tree维度的并行,而是特征维度的并行。XGBoost预先将每个特征按特征值排好序,存储为块结构,分裂结点时可以采用多线程并行查找每个特征的最佳分割点,极大提升训练速度。
XGBoost为什么快?
- 分块并行:训练前每个特征按特征值进行排序并存储为Block结构,后面查找特征分割点时重复使用,并且支持并行查找每个特征的分割点
- 候选分位点:每个特征采用常数个分位点作为候选分割点
- CPU cache 命中优化: 使用缓存预取的方法,对每个线程分配一个连续的buffer,读取每个block中样本的梯度信息并存入连续的Buffer中。
- Block 处理优化:Block预先放入内存;Block按列进行解压缩;将Block划分到不同硬盘来提高吞吐
XGBoost防止过拟合采取了哪些方法?
XGBoost在设计时,为了防止过拟合做了很多优化,具体如下:
- 目标函数添加正则项:叶子节点个数+叶子节点权重的L2正则化
- 列抽样:训练的时候只用一部分特征(不考虑剩余的block块即可)
- 子采样:每轮计算可以不使用全部样本,使算法更加保守
- shrinkage: 学习率缩减,在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削弱每棵树的影响,让后面有更大的学习空间
XGBoost如何处理缺失值?
XGBoost模型的一个优点就是允许特征存在缺失值。对缺失值的处理方式如下:
- 在特征k上寻找最佳 split point 时,不会对该列特征 missing 的样本进行遍历,而只对该列特征值为 non-missing 的样本上对应的特征值进行遍历,通过这个技巧来减少了为稀疏离散特征寻找 split point 的时间开销。
- 在逻辑实现上,为了保证完备性,会将该特征值missing的样本分别分配到左叶子结点和右叶子结点,两种情形都计算一遍后,选择分裂后增益最大的那个方向(左分支或是右分支),作为预测时特征值缺失样本的默认分支方向。
- 如果在训练中没有缺失值而在预测中出现缺失,那么会自动将缺失值的划分方向放到右子结点。
三.LightGBM
LightGBM 由微软提出,主要用于解决 GDBT 在海量数据中遇到的问题,以便其可以更好更快地用于工业实践中。
从 LightGBM 名字我们可以看出其是轻量级(Light)的梯度提升机(GBM),其相对 XGBoost 具有训练速度快、内存占用低的特点。下图分别显示了 XGBoost、XGBoost_hist(利用梯度直方图的 XGBoost) 和 LightGBM 三者之间针对不同数据集情况下的内存和训练时间的对比:
xgboost寻找最优分裂点的复杂度可简化为如下:
xgboost寻找最优分裂点的复杂度=特征数量×分裂点的数量×样本的数量
Lightgbm里面的直方图算法就是为了减少分裂点的数量, Lightgbm里面的单边梯度抽样算法就是为了减少样本的数量, 而Lightgbm里面的互斥特征捆绑算法就是为了减少特征的数量。 并且后面两个是Lightgbm的亮点所在。
LightGBM 总共提出了以下几点解决方案:
- 单边梯度抽样算法;
- 直方图算法;
- 互斥特征捆绑算法;
- 基于最大深度的 Leaf-wise 的垂直生长算法;
- 类别特征最优分割;
- 工程优化:特征并行、数据并行、缓存优化。
本节将继续从数学原理和工程实现两个角度介绍 LightGBM。
1 数学原理
1 直方图算法
1) 直方图算法
直方图算法的基本思想是将连续的特征离散化为 k k k个离散特征,同时构造一个宽度为 k 的直方图用于统计信息(含有 k 个 bin)。利用直方图算法我们无需遍历数据,只需要遍历 k k k 个 b i n bin bin即可找到最佳分裂点。
对比下xgboost与排序算法和lightgbm的直方图算法:
我们知道特征离散化的具有很多优点,如存储方便、运算更快、鲁棒性强、模型更加稳定等等。对于直方图算法来说最直接的有以下两个优点(以 k=256 为例):
- 内存占用更小:XGBoost 需要用 32 位的浮点数去存储特征值,并用 32 位的整形去存储索引,而 LightGBM 只需要用 8 位去存储直方图,相当于减少了 1/8;
- 计算代价更小:计算特征分裂增益时,XGBoost 需要遍历一次数据找到最佳分裂点,而 LightGBM 只需要遍历一次 k k k 次,直接将时间复杂度从 O ( # d a t a ∗ # f e a t u r e ) O(\#data * \#feature) O(#data∗#feature) 降低到 O ( k ∗ # f e a t u r e ) O(k * \#feature) O(k∗#feature),而我们知道 # d a t a > > k \#data >> k #data>>k。
虽然将特征离散化后无法找到精确的分割点,可能会对模型的精度产生一定的影响,但较粗的分割也起到了正则化的效果,一定程度上降低了模型的方差。并且在实际的数据集上表明,离散化的分裂点对最终的精度影响并不大,甚至会好一些。原因在于decision tree本身就是一个弱学习器,分割点是不是精确并不是太重要。
2) 直方图加速
在构建叶节点的直方图时,我们还可以通过父节点的直方图与相邻叶节点的直方图相减的方式构建,从而减少了一半的计算量。在实际操作过程中,我们还可以先计算直方图小的叶子节点,然后利用直方图作差来获得直方图大的叶子节点。
这里有一点:xgboost不是后面的近似分割算法也进行了分桶吗? 为啥会比lightgbm的直方图算法慢这么多呢?xgboost那里的分桶算法叫做Weight Quantile Sketch算法,考虑的是对loss的影响权值,相当于基于 h i h_i hi的分布去找候选分割点,这样带来的一个问题就是每一层划分完了之后,下一次依然需要构建这样的直方图,毕竟样本被划分到了不同的节点中,二阶导分布也就变了。 所以xgboost在每一层都得动态构建直方图, 因为它这个直方图算法不是针对某个特定的feature的,而是所有feature共享一个直方图(每个样本权重的二阶导)。 而lightgbm对每个特征都有一个直方图,这样构建一次就OK, 并且分裂的时候还能通过直方图作差进行加速。 故xgboost的直方图算法是不如lightgbm的直方图算法快的。
2 单边梯度抽样算法
XGBoost 在进行预排序时只考虑非零值进行加速,而 LightGBM 也采用类似策略:只用非零特征构建直方图。
GBDT 算法的梯度大小可以反应样本的权重,梯度越小说明模型拟合的越好,单边梯度抽样算法(Gradient-based One-Side Sampling, GOSS)利用这一信息对样本进行抽样,减少了大量梯度小的样本,在接下来的计算锅中只需关注梯度高的样本,极大的减少了计算量。
为啥梯度小的样本对降低残差效果不大?
一阶导部分可以看到:
(
y
i
−
y
^
t
−
1
)
=
−
C
×
g
i
\left(y_i-\hat{y}^{t-1}\right)=−C×g _i
(yi−y^t−1)=−C×gi
常数不用管, 这样也就是说如果我新的模型想降低残差的效果好,那么样本的梯度应该越大越好。
GOSS 事先基于梯度的绝对值对样本进行排序(无需保存排序后结果),然后拿到前 a % a\% a% 的梯度大的样本,和总体样本的 b % b\% b%,在计算增益时,通过乘上 1 − a b \frac{1-a}{b} b1−a来放大梯度小的样本的权重。一方面算法将更多的注意力放在训练不足的样本上,另一方面通过乘上权重来防止采样对原始数据分布造成太大的影响。
3 互斥特征捆绑算法
高维特征往往是稀疏的,而且特征间可能是相互排斥的(如两个特征不同时取非零值),如果两个特征并不完全互斥(如只有一部分情况下是不同时取非零值),可以用互斥率表示互斥程度。互斥特征捆绑算法(Exclusive Feature Bundling, EFB)指出如果将一些特征进行融合绑定,则可以降低特征数量。这样在构建直方图的时候时间复杂度从 O ( # d a t a × # f e a t u r e ) O(\#data\times\#feature) O(#data×#feature)变成 O ( # d a t a × # b u n d l e ) O(\#data\times\#bundle) O(#data×#bundle), 这里的 # b u n d l e \#bundle #bundle指的特征融合后特征包的个数,且 # b u n d l e < < # f e a t u r e \# b u n d l e< <\# f e a t u r e #bundle<<#feature。
针对这种想法,我们会遇到两个问题:
- 哪些特征可以一起绑定?
- 特征绑定后,特征值如何确定?
对于问题一: EFB算法利用特征和特征间的关系构造一个加权无向图,并将其转换为图着色算法。我们知道图着色是个 NP-Hard 问题,故采用贪婪算法得到近似解,具体步骤如下:
- 首先将所有的特征看成图的各个顶点,将不相互独立的特征用一条边连起来,边的权重就是两个相连接的特征的总冲突值(也就是这两个特征上同时不为0的样本个数)。
- 然后按照节点的度对特征降序排序, 度越大,说明与其他特征的冲突越大
- 对于每一个特征, 遍历已有的特征簇,如果发现该特征加入到特征簇中的矛盾数不超过某一个阈值,则将该特征加入到该簇中。 如果该特征不能加入任何一个已有的特征簇,则新建一个簇,将该特征加入到新建的簇中。
算法允许两两特征并不完全互斥来增加特征捆绑的数量,通过设置最大互斥率 \gamma 来平衡算法的精度和效率。EFB 算法的伪代码如下所示:
上面这个过程的时间复杂度其实是 O ( # f e a t u r e s 2 ) O({\#features^2}) O(#features2)的,因为要遍历特征,每个特征还要遍历所有的簇, 在特征不多的情况下还行,但是如果特征维度很大,就不好使了。 所以为了改善效率,可以不建立图,而是将特征按照非零值个数进行排序,因为更多的非零值的特征会导致更多的冲突,所以跳过了上面的第一步,直接排序然后第三步分簇。
**对于问题二:**论文给出特征合并算法,其关键在于原始特征能从合并的特征中分离出来。假设 Bundle 中有两个特征值,A 取值为 [0, 10]、B 取值为 [0, 20],为了保证特征 A、B 的互斥性,我们可以给特征 B 添加一个偏移量转换为 [10, 30],Bundle 后的特征其取值为 [0, 30],这样便实现了特征合并。具体算法如下所示:
4 带深度限制的 Leaf-wise 算法
在建树的过程中有两种策略:
- Level-wise:基于层进行生长,直到达到停止条件;
- Leaf-wise:每次分裂增益最大的叶子节点,直到达到停止条件。
XGBoost 采用 Level-wise 的增长策略,方便并行计算每一层的分裂节点,提高了训练速度,但同时也因为节点增益过小增加了很多不必要的分裂,提高了计算量;LightGBM 采用 Leaf-wise 的增长策略减少了计算量,配合最大深度的限制防止过拟合,由于每次都需要计算增益最大的节点,所以无法并行分裂。在分裂次数相同的情况下,Leaf-wise 可以降低更多的误差,得到更好的精度,缺点是可能会长出比较深的决策树,产生过拟合。
5 类别特征最优分割
大部分的机器学习算法都不能直接支持类别特征,一般都会对类别特征进行编码,然后再输入到模型中。常见的处理类别特征的方法为 one-hot 编码,但我们知道对于决策树来说并不推荐使用 one-hot 编码:
- 会产生样本切分不平衡问题,切分增益会非常小。如,国籍切分后,会产生是否中国,是否美国等一系列特征,这一系列特征上只有少量样本为 1,大量样本为 0。这种划分的增益非常小:较小的那个拆分样本集,它占总样本的比例太小。无论增益多大,乘以该比例之后几乎可以忽略;较大的那个拆分样本集,它几乎就是原始的样本集,增益几乎为零;
- 影响决策树学习:决策树依赖的是数据的统计信息,而独热码编码会把数据切分到零散的小空间上。在这些零散的小空间上统计信息不准确的,学习效果变差。本质是因为独热码编码之后的特征的表达能力较差的,特征的预测能力被人为的拆分成多份,每一份与其他特征竞争最优划分点都失败,最终该特征得到的重要性会比实际值低。
LightGBM 原生支持类别特征,采用 many-vs-many 的切分方式将类别特征分为两个子集,实现类别特征的最优切分。假设有某维特征有 k k k个类别,则有 2 ( k − 1 ) − 1 2^{(k-1)} - 1 2(k−1)−1 中可能,时间复杂度为 O ( 2 k ) O(2^k) O(2k),LightGBM 基于 Fisher 大佬的 《On Grouping For Maximum Homogeneity》实现了 O ( k l o g 2 k ) O(klog_2k) O(klog2k)的时间复杂度。
上图为左边为基于 one-hot 编码进行分裂,后图为 LightGBM 基于 many-vs-many 进行分裂,在给定深度情况下,后者能学出更好的模型。
其基本思想在于每次分组时都会根据训练目标对类别特征进行分类,在枚举分割点之前,先把直方图按照每个类别对应的label均值进行排序;然后按照排序的结果依次枚举最优分割点。看下面这个图:
此外,LightGBM 还加了约束条件正则化,防止过拟合。这种处理类别特征的方式使得 AUC 提高了 1.5 个点,且时间仅仅多了 20%。
2 工程实现
特征并行
传统的特征并行算法在于对数据进行垂直划分,然后使用不同机器找到不同特征的最优分裂点,基于通信整合得到最佳划分点,然后基于通信告知其他机器划分结果。
传统的特征并行方法有个很大的缺点:需要告知每台机器最终划分结果,增加了额外的复杂度(因为对数据进行垂直划分,每台机器所含数据不同,划分结果需要通过通信告知)。
LightGBM 则不进行数据垂直划分,每台机器都有训练集完整数据,在得到最佳划分方案后可在本地执行划分而减少了不必要的通信。
数据并行
传统的数据并行策略主要为水平划分数据,然后本地构建直方图并整合成全局直方图,最后在全局直方图中找出最佳划分点。
这种数据划分有一个很大的缺点:通讯开销过大。如果使用点对点通信,一台机器的通讯开销大约为 O ( # m a c h i n e ∗ # f e a t u r e ∗ # b i n ) O(\#machine * \#feature *\#bin ) O(#machine∗#feature∗#bin);如果使用集成的通信,则通讯开销为 O ( 2 ∗ # f e a t u r e ∗ # b i n ) O(2 * \#feature *\#bin ) O(2∗#feature∗#bin),
LightGBM 采用分散规约(Reduce scatter)的方式将直方图整合的任务分摊到不同机器上,从而降低通信代价,并通过直方图做差进一步降低不同机器间的通信。
投票并行
针对数据量特别大特征也特别多的情况下,可以采用投票并行。投票并行主要针对数据并行时数据合并的通信代价比较大的瓶颈进行优化,其通过投票的方式只合并部分特征的直方图从而达到降低通信量的目的。
大致步骤为两步:
-
本地找出 Top K 特征,并基于投票筛选出可能是最优分割点的特征;
-
合并时只合并每个机器选出来的特征。
缓存优化
上边说到 XGBoost 的预排序后的特征是通过索引给出的样本梯度的统计值,因其索引访问的结果并不连续,XGBoost 提出缓存访问优化算法进行改进。
而 LightGBM 所使用直方图算法对 Cache 天生友好:
- 首先,所有的特征都采用相同的方法获得梯度(区别于不同特征通过不同的索引获得梯度),只需要对梯度进行排序并可实现连续访问,大大提高了缓存命中;
- 其次,因为不需要存储特征到样本的索引,降低了存储消耗,而且也不存在 Cache Miss的问题。
3. 与 XGBoost 的对比
本节主要总结下 LightGBM 相对于 XGBoost 的优点,从内存和速度两方面进行介绍。
内存更小
- XGBoost 使用预排序后需要记录特征值及其对应样本的统计值的索引,而 LightGBM 使用了直方图算法将特征值转变为 bin 值,且不需要记录特征到样本的索引,将空间复杂度从 O ( 2 ∗ # d a t a ) O(2*\#data) O(2∗#data) 降低为 O ( # b i n ) O(\#bin) O(#bin),极大的减少了内存消耗;
- LightGBM 采用了直方图算法将存储特征值转变为存储 bin 值,降低了内存消耗;
- LightGBM 在训练过程中采用互斥特征捆绑算法减少了特征数量,降低了内存消耗。
速度更快
- LightGBM 采用了直方图算法将遍历样本转变为遍历直方图,极大的降低了时间复杂度;
- LightGBM 在训练过程中采用单边梯度算法过滤掉梯度小的样本,减少了大量的计算;
- LightGBM 采用了基于 Leaf-wise 算法的增长策略构建树,减少了很多不必要的计算量;
- LightGBM 采用优化后的特征并行、数据并行方法加速计算,当数据量非常大的时候还可以采用投票并行的策略;
- LightGBM 对缓存也进行了优化,增加了 Cache hit 的命中率。
四. 手撸一个XGBoost?
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
class Node:
def __init__(self):
self.feature = None # 分裂特征
self.threshold = None # 分裂点
self.left = None # 左子节点
self.right = None # 右子节点
self.value = None # 叶子节点的输出值
def is_leaf(self):
return self.value is not None
class XGBoost:
def __init__(self, n_estimators=100, max_depth=3, learning_rate=0.1, gamma=0, min_child_weight=1, subsample=1, colsample_bytree=1, reg_lambda=1):
self.n_estimators = n_estimators # 迭代次数
self.max_depth = max_depth # 树的最大深度
self.learning_rate = learning_rate # 学习率
self.gamma = gamma # 分裂节点的最小损失减少量
self.min_child_weight = min_child_weight # 叶子节点的最小权重和
self.subsample = subsample # 训练集抽样比例
self.colsample_bytree = colsample_bytree # 特征抽样比例
self.reg_lambda = reg_lambda # 正则化系数
self.trees = [] # 保存所有的决策树
def fit(self, X, y):
"""
拟合xgboost模型
"""
self._build_tree(X, y)
def predict(self, X):
"""
预测
"""
y_pred = []
for x in X:
node = self.root
while not node.is_leaf():
if x[node.feature] < node.threshold:
node = node.left
else:
node = node.right
y_pred.append(node.value)
return y_pred
def _boost(self, grad, pred, X, y):
# 计算负梯度和Hessian矩阵
neg_grad = y - self._sigmoid(pred)
hessian = self._sigmoid(pred) * (1 - self._sigmoid(pred))
# 构建DMatrix
DMatrix = np.hstack((X, grad.reshape(-1, 1)))
DMatrix = np.hstack((DMatrix, hessian.reshape(-1, 1)))
# 构建决策树
tree = self._build_tree(DMatrix)
self.trees.append(tree)
# 计算每个样本的叶子节点编号
leaf_nodes = self._predict_leaf_nodes(X)
# 计算每个叶子节点的输出值
leaf_values = np.zeros((len(leaf_nodes), len(tree)))
for i, tree in enumerate(self.trees):
for j, node in enumerate(tree):
leaf_values[:, i][leaf_nodes[:, i] == j] = node.value
# 更新预测值
pred += self.learning_rate * leaf_values.sum(axis=1)
return neg_grad, pred
def _build_tree(self, X, y):
# 划分训练集和验证集
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2)
# 初始化梯度和预测值
grad = np.zeros_like(y_train)
pred = np.zeros_like(y_train)
# 构建根节点
self.root = Node()
self.root.value = -self.learning_rate * np.sum(y_train) / (np.sum(np.abs(y_train)) + self.reg_lambda)
# 递归构建决策树
self._split_node(self.root, X_train, y_train, grad, pred, 1)
def _split_node(self, node, X, y, grad, pred, depth):
if depth == self.max_depth or len(X) < 2:
return
# 计算负梯度和Hessian矩阵
neg_grad = y - self._sigmoid(pred)
hessian = self._sigmoid(pred) * (1 - self._sigmoid(pred))
## 判断是否需要分裂
if np.abs(neg_grad).sum() < self.gamma * (len(X) - 1) + 1e-8:
return
# 计算每个特征的增益
gains = []
for j in range(X.shape[1]):
gains.append(self._calc_gain(X[:, j], y, neg_grad, hessian))
# 找到最佳分裂点
best_feature = np.argmax(gains)
node.feature = best_feature
node.threshold = np.median(X[:, best_feature])
# 分裂节点
left_indices = X[:, best_feature] < node.threshold
right_indices = X[:, best_feature] >= node.threshold
left_node = Node()
right_node = Node()
node.left = left_node
node.right = right_node
# 递归构建左右子树
self._split_node(left_node, X[left_indices], y[left_indices], grad[left_indices], pred[left_indices], depth+1)
self._split_node(right_node, X[right_indices], y[right_indices], grad[right_indices], pred[right_indices], depth+1)
def _calc_gain(self, feature, y, neg_grad, hessian):
"""
计算单个特征的增益
"""
# 计算叶子节点的输出值
sum_grad = np.sum(neg_grad)
sum_hessian = np.sum(hessian)
leaf_value = - sum_grad / (sum_hessian + self.reg_lambda)
# 计算分裂前后的损失
loss_before = np.sum(self._log_loss(y, self._sigmoid(leaf_value * np.ones_like(y))))
left_indices = feature < np.median(feature)
right_indices = feature >= np.median(feature)
loss_after = self._calc_loss(y[left_indices], neg_grad[left_indices], hessian[left_indices], leaf_value) + self._calc_loss(y[right_indices], neg_grad[right_indices], hessian[right_indices], leaf_value)
# 计算增益
gain = loss_before - loss_after
return gain
def _calc_loss(self, y, neg_grad, hessian, leaf_value):
"""
计算叶子节点的损失
"""
sum_grad = np.sum(neg_grad)
sum_hessian = np.sum(hessian)
penalty = 0.5 * (sum_grad ** 2) / (sum_hessian + self.reg_lambda)
return penalty - self.learning_rate * leaf_value * sum_grad / (sum_hessian + self.reg_lambda)
def _log_loss(self, y_true, y_pred):
"""
计算对数损失
"""
eps = 1e-15
y_pred = np.clip(y_pred, eps, 1 - eps)
return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
def _sigmoid(self, x):
"""
计算sigmoid函数值
"""
return 1 / (1 + np.exp(-x))
def _predict_leaf_nodes(self, X):
"""
预测每个样本所在的叶子节点编号
"""
leaf_nodes = np.zeros((X.shape[0], len(self.trees)))
for i, tree in enumerate(self.trees):
for j, node in enumerate(tree):
indices = X[:, node.feature] < node.threshold
leaf_nodes[indices, i] = node.left.value
leaf_nodes[~indices, i] = node.right.value
return leaf_nodes.astype(int)
#下面是使用手写的xgboost对一个示例数据集进行训练和预测的代码:
data = load_breast_cancer()
X = data.data
y = data.target
model = XGBoost()
model.fit(X, y)
y_pred = model.predict(X)
print("Accuracy:", accuracy_score(y, np.round(y_pred)))
#运行结果如下:
#Accuracy: 0.37258347978910367