摘要
lightGBM是数据科学常用的工具,让它不仅速度快而且精度高,让我们学会其中的原理,了解其中的奥秘!
1 LightGBM与XGBoost的区别
优劣比较:
- 在树的生长算法上:XGBoost按照层生长(类似BFS),不容易找到最优解;LightBGM按照叶子节点生长(类似DFS),但是容易造成树的深度太深,造成过拟合,所以需要限制树的深度。
- 在分裂搜索算法上:XGBoost选择了预排序;LightGBM选择了直方图算法,速度更快。
- 在内存消耗上:XGBoost消耗为 2 × 特 征 的 数 量 × 数 据 的 数 量 × 4 B y t e s 2\times特征的数量\times数据的数量\times 4Bytes 2×特征的数量×数据的数量×4Bytes;LightGBM消耗为 特 征 的 数 量 × 数 据 的 数 量 × 1 B y t e s 特征的数量\times数据的数量\times 1Bytes 特征的数量×数据的数量×1Bytes
- 计算生长增益的复杂度上:XGBoost为 O ( 特 征 的 数 量 × 数 据 的 数 量 ) O(特征的数量\times数据的数量) O(特征的数量×数据的数量);LightGBM为 O ( 特 征 的 数 量 × 直 方 图 b i n 个 数 ) O(特征的数量\times 直方图bin个数) O(特征的数量×直方图bin个数)。
- 分类功能支持上,和并行化速度上。LightGBM都更好。
2 目标函数
在目标函数方面,lightGBM和XGBoost都是相同的。
预测值
y
^
i
\hat{y}_i
y^i为之前
t
−
1
t-1
t−1次训练出来的模型得到的预测值
y
^
i
(
t
−
1
)
\hat{y}_i^{(t-1)}
y^i(t−1)加第
t
t
t次训练出来的弱分类器的预测值
f
t
(
x
i
)
f_t(x_i)
ft(xi),
x
i
x_i
xi是一个向量。
y
^
i
=
ϕ
(
x
i
)
=
∑
k
=
1
K
f
k
(
x
i
)
=
y
^
i
(
t
−
1
)
+
f
t
(
x
i
)
\hat{y}_i=\phi(x_i)=\sum_{k=1}^Kf_k(x_i)=\hat{y}_i^{(t-1)}+f_t(x_i)
y^i=ϕ(xi)=k=1∑Kfk(xi)=y^i(t−1)+ft(xi)树的复杂度表示为树的叶子节点个数
T
T
T和每棵树的叶子节点输出结果
W
W
W的平方和(相当于L2正则化)
Ω
(
f
t
)
=
γ
T
+
1
2
λ
∑
j
=
1
t
w
j
2
\Omega(f_t)=\gamma T+\frac{1}{2}\lambda \sum_{j=1}^tw^2_j
Ω(ft)=γT+21λj=1∑twj2损失函数的计算方式如下:
L
t
(
ϕ
)
=
∑
i
=
1
N
l
(
y
i
,
y
^
i
)
+
∑
j
=
1
t
Ω
(
f
i
)
其
中
l
代
表
预
测
值
与
真
实
值
差
距
,
Ω
代
表
关
于
树
正
则
化
项
。
=
∑
i
=
1
N
l
(
y
i
,
y
^
i
(
t
−
1
)
+
f
t
(
x
i
)
)
+
Ω
(
f
t
)
+
∑
j
=
1
(
t
−
1
)
Ω
(
f
i
)
最
小
化
损
失
函
数
极
值
点
与
常
量
无
关
,
可
以
舍
去
=
∑
i
=
1
N
l
(
y
i
,
y
^
i
(
t
−
1
)
+
f
t
(
x
i
)
)
+
Ω
(
f
t
)
利
用
二
阶
泰
勒
展
开
=
∑
i
=
1
N
(
l
(
y
i
,
y
^
i
(
t
−
1
)
)
+
g
i
f
t
(
x
i
)
+
1
2
h
i
f
t
2
(
x
i
)
)
+
Ω
(
f
t
)
其
中
,
g
i
=
∂
l
(
y
i
,
y
^
i
(
t
−
1
)
)
∂
y
^
i
(
t
−
1
)
,
h
i
=
∂
2
l
(
y
i
,
y
^
i
(
t
−
1
)
)
∂
(
y
^
i
(
t
−
1
)
)
2
=
∑
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
f
t
(
x
i
)
+
1
2
h
i
f
t
2
(
x
i
)
)
+
γ
T
+
1
2
λ
∑
j
=
1
t
w
j
2
\begin{aligned}L^t(\phi)&=\sum_{i=1}^Nl(y_i,\hat{y}_i)+\sum_{j=1}^t\Omega(f_i)\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ 其中l代表预测值与真实值差距,\Omega代表关于树正则化项。\\&=\sum_{i=1}^Nl(y_i,\hat{y}_i^{(t-1)}+f_t(x_i))+\Omega(f_t)+\sum_{j=1}^{(t-1)}\Omega(f_i)\ \ \ \ \ \ \ \ \ \ \ \ \ \ 最小化损失函数极值点与常量无关,可以舍去\\ &=\sum_{i=1}^Nl(y_i,\hat{y}_i^{(t-1)}+f_t(x_i))+\Omega(f_t) \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ 利用二阶泰勒展开\\ &=\sum_{i=1}^N\left(l(y_i,\hat{y}_i^{(t-1)})+g_if_t(x_i)+\frac{1}{2}h_if^2_t(x_i)\right)+\Omega(f_t)\ \ \ \ 其中, g_i=\frac{\partial l(y_i,\hat{y}_i^{(t-1)}) }{\partial\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}\\ &=\sum_{i=1}^N\left(g_if_t(x_i)+\frac{1}{2}h_if^2_t(x_i)\right)+\Omega(f_t)\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ 最小化损失函数极值点与常量无关,可以舍去\\ &=\sum_{i=1}^N\left(g_if_t(x_i)+\frac{1}{2}h_if^2_t(x_i)\right)+\gamma T+\frac{1}{2}\lambda \sum_{j=1}^tw^2_j\end{aligned}
Lt(ϕ)=i=1∑Nl(yi,y^i)+j=1∑tΩ(fi) 其中l代表预测值与真实值差距,Ω代表关于树正则化项。=i=1∑Nl(yi,y^i(t−1)+ft(xi))+Ω(ft)+j=1∑(t−1)Ω(fi) 最小化损失函数极值点与常量无关,可以舍去=i=1∑Nl(yi,y^i(t−1)+ft(xi))+Ω(ft) 利用二阶泰勒展开=i=1∑N(l(yi,y^i(t−1))+gift(xi)+21hift2(xi))+Ω(ft) 其中,gi=∂y^i(t−1)∂l(yi,y^i(t−1)),hi=∂(y^i(t−1))2∂2l(yi,y^i(t−1))=i=1∑N(gift(xi)+21hift2(xi))+Ω(ft) 最小化损失函数极值点与常量无关,可以舍去=i=1∑N(gift(xi)+21hift2(xi))+γT+21λj=1∑twj2因为我们需要的是对每一个叶子节点进行计算,所以我们需要得到每一个叶子节点的损失值。于是我们设一个函数
q
(
x
)
q(x)
q(x)代表
x
x
x样本对于的叶子节点。
f
t
(
x
)
=
w
q
(
x
)
f_t(x)=w_{q(x)}
ft(x)=wq(x)对
L
t
(
ϕ
)
L^t(\phi)
Lt(ϕ)进行变形可以得到(注意这里的
T
T
T代表的是叶节点的个数,
I
j
I_j
Ij代表属于
j
j
j叶子的样本集合):
L
t
(
ϕ
)
=
∑
j
=
1
T
(
∑
i
∈
I
j
g
i
w
j
+
1
2
∑
i
∈
I
j
h
i
w
j
2
)
+
γ
T
+
1
2
λ
∑
j
=
1
t
w
j
2
=
∑
j
=
1
T
(
G
j
w
j
+
1
2
(
H
j
+
λ
)
w
j
2
)
+
γ
T
其
中
G
j
=
∑
i
∈
I
j
g
i
,
H
j
=
∑
i
∈
I
j
h
i
\begin{aligned}L^t(\phi) &=\sum_{j=1}^T\left(\sum_{i\in I_j}g_iw_j+\frac{1}{2}\sum_{i\in I_j}h_iw_j^2\right)+\gamma T+\frac{1}{2}\lambda \sum_{j=1}^tw^2_j\\ &=\sum_{j=1}^T\left(G_jw_j+\frac{1}{2}(H_j+\lambda)w_j^2\right)+\gamma T \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ 其中G_j=\sum_{i\in I_j}g_i,H_j=\sum_{i\in I_j}h_i\end{aligned}
Lt(ϕ)=j=1∑T⎝⎛i∈Ij∑giwj+21i∈Ij∑hiwj2⎠⎞+γT+21λj=1∑twj2=j=1∑T(Gjwj+21(Hj+λ)wj2)+γT 其中Gj=i∈Ij∑gi,Hj=i∈Ij∑hi我们要对目标函数进行优化,就是计算第t轮时使目标函数最小的叶节点的输出分数
w
j
w_j
wj,直接对
w
j
w_j
wj求导,使得导数为0,得到使得损失函数最小的
w
j
w_j
wj:
w
j
=
−
G
j
H
j
+
λ
w_j=-\frac{G_j}{H_j+\lambda}
wj=−Hj+λGj将其带入损失函数中:
L
t
(
ϕ
)
=
∑
j
=
1
T
(
−
G
j
2
H
j
+
λ
+
1
2
G
j
2
H
j
+
λ
)
+
γ
T
=
∑
j
=
1
T
(
−
1
2
G
j
2
H
j
+
λ
)
+
γ
T
\begin{aligned}L^t(\phi)&=\sum_{j=1}^T\left(-\frac{G_j^2}{H_j+\lambda}+\frac{1}{2}\frac{G_j^2}{H_j+\lambda}\right)+\gamma T\\ &=\sum_{j=1}^T\left(-\frac{1}{2}\frac{G_j^2}{H_j+\lambda}\right)+\gamma T\end{aligned}
Lt(ϕ)=j=1∑T(−Hj+λGj2+21Hj+λGj2)+γT=j=1∑T(−21Hj+λGj2)+γT
使用贪心算法,每次尝试分裂一个叶节点,计算分裂后的增益,选择增益最大的。我们可以计算出增益函数为:
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=\frac{1}{2}[\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
Gain=21[HL+λGL2+HR+λGR2−HL+HR+λ(GL+GR)2]−γ
G
a
i
n
Gain
Gain值越大说明分裂后能使目标函数减小的越多,也就是越好。
3 树的分裂----从精确贪心算法到直方图算法
3.1 精确贪心算法
算法大致流程: 先遍历所有的
m
m
m个特征,让每个数据的第
k
k
k个特征按照大小进行排序(因为只有进行了排序,之后的分割才能把特征数值接近的分在一遍),再遍历对于特征中的所有数据。找到增益最大的分割点,进行分割。(补充:因为计算的时候左侧和右侧的导数和是定值,所以可以用右边减左边得到右侧的一阶和二阶导数值)
这个方法的问题是计算复杂度高,树有
H
H
H层,每次都需要遍历
d
d
d个特征,每个特征需要进行一次
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)的排序,所以整体的复杂度约为
O
(
H
d
n
l
o
g
n
)
O(Hdnlogn)
O(Hdnlogn)这个复杂度是相当高的,在实际应用中会导致训练的速度缓慢。
3.2 近似算法(XGBoost的算法)
近似算法根据特征
k
k
k的分布确定
l
l
l个候选切分点
S
k
=
{
s
k
1
,
s
k
2
,
⋯
,
s
k
l
}
S_k=\{s_{k1},s_{k2},\cdots,s_{kl}\}
Sk={sk1,sk2,⋯,skl}(切分点的选择利用了百分位数,比如前10%是
[
0
,
s
k
1
)
[0,s_{k1})
[0,sk1),以此类推)。然后根据候选切分点把相应的样本放入对应的桶中(比如
[
0
,
s
k
1
[0,s_{k1}
[0,sk1属于第一个桶,以此类推)。对每个桶的
G
,
H
G,H
G,H值进行累加,代替原来的
g
i
g_i
gi。之后,在候选切分点的集合上进行上述的精确贪心查找。这样原来的
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)就变成了
O
(
l
l
o
g
l
)
O(llogl)
O(llogl),其中
l
<
n
l<n
l<n。这样,就降低了我们算法的复杂度。
对于寻找分桶的切分点有两种策略
- 全局策略:开始分裂每棵树前,提出候选的切分点(注意这个是切分点,最后的分割点是从这里面选出来的),当切分点数足够多时,之后进行分裂时和精确的贪心算法性能相当。这个方法在最开始时选出切分点之后就不再变化,即使随着树的生长这个划分方式已经不是最优的,还是采取这样的切分方式。(这也是全局策略需要更多的切分点的原因)
- 局部策略:随着树节点分裂,重新提出候选切分点,切分点个数不需要那么多,性能与精确贪心算法差不多。这个方法更加适合树的生长更深的情况(树越深,用开始时计算出的值代替的效果越差)。
3.2.1切分点寻找方式
我们选定切分策略后,具体应该怎样实施呢?XGBoost并没有使用我们说的百分位数的方式进行切分。
XGBoost使用了以二阶梯度
h
h
h为权重的分位数方法
对特征
k
k
k构造数据集合
D
k
=
{
(
x
1
k
,
h
1
)
,
(
x
2
k
,
h
2
)
,
⋯
,
(
x
n
k
,
h
n
)
}
\mathcal{D}_k=\{(x_{1k},h_1),(x_{2k},h_2),\cdots,(x_{nk},h_n)\}
Dk={(x1k,h1),(x2k,h2),⋯,(xnk,hn)}
定义特征
k
k
k的阶为
r
k
r_k
rk,表示第
k
k
k个特征小于
z
z
z的样本比例:
r
k
(
z
)
=
1
∑
(
x
,
h
)
∈
D
k
h
∑
(
x
,
h
)
∈
D
k
,
x
<
z
h
r_k(z)=\frac{1}{\sum_{(x,h)\in \mathcal{D}_k}h}\sum_{(x,h)\in \mathcal{D}_k,x<z}h
rk(z)=∑(x,h)∈Dkh1(x,h)∈Dk,x<z∑h我们的目标是找的这样的切分集合
{
s
k
1
,
s
k
2
,
⋯
,
s
k
l
}
\{s_{k1},s_{k2},\cdots,s_{kl}\}
{sk1,sk2,⋯,skl}满足如下条件:
∣
r
k
(
s
k
,
j
)
−
r
k
(
s
k
,
j
+
1
)
∣
<
ε
,
s
k
1
=
m
i
n
i
x
i
k
,
s
k
l
=
m
a
x
i
x
i
k
|r_k(s_k,j)-r_k(s_k,j+1)|< \varepsilon,s_{k1}=\mathop{min}\limits_{i}x_{ik},s_{kl}=\mathop{max}\limits_{i}x_{ik}
∣rk(sk,j)−rk(sk,j+1)∣<ε,sk1=iminxik,skl=imaxxik这里的
ε
\varepsilon
ε大约等于需要选出的切分点的数量的倒数
∑
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
f
t
(
x
i
)
+
1
2
h
i
f
t
2
(
x
i
)
+
1
2
g
i
2
h
i
)
+
Ω
(
f
t
)
因
为
我
们
要
求
的
是
函
数
极
值
的
位
置
,
而
加
上
一
个
常
数
并
不
影
响
这
个
位
置
=
∑
i
=
1
N
1
2
h
i
(
f
t
(
x
i
)
−
(
−
g
i
h
i
)
)
2
+
Ω
(
f
t
)
\begin{aligned}&\sum_{i=1}^N\left(g_if_t(x_i)+\frac{1}{2}h_if^2_t(x_i)\right)+\Omega(f_t)\\ \to&\sum_{i=1}^N\left(g_if_t(x_i)+\frac{1}{2}h_if_t^2(x_i)+\frac{1}{2}\frac{g_i^2}{h_i}\right)+\Omega(f_t)\ \ \ \ \ \ \ \ \ \ \ 因为我们要求的是函数极值的位置,而加上一个常数并不影响这个位置\\ =&\sum_{i=1}^N\frac{1}{2}h_i\left(f_t(x_i)-(-\frac{g_i}{h_i})\right)^2+\Omega(f_t)\end{aligned}
→=i=1∑N(gift(xi)+21hift2(xi))+Ω(ft)i=1∑N(gift(xi)+21hift2(xi)+21higi2)+Ω(ft) 因为我们要求的是函数极值的位置,而加上一个常数并不影响这个位置i=1∑N21hi(ft(xi)−(−higi))2+Ω(ft)由上式可以看出,目标函数是真实值为
−
g
i
h
i
-\frac{g_i}{h_i}
−higi,权重为
h
i
h_i
hi的平方损失,因此,使用二阶梯度加权。
3.2.2稀疏值的处理
在实际的问题中,输入 x x x经常是稀疏的。有很多原因会造成数据稀疏:
- 数据的缺失值。
- 数据中经常出现的0值。
- 人工特征,比如独热码。
这样就会出现问题,例子如下:
这个例子里,在判断年龄是否小于20岁时,会出现有些数据没有年龄的情况。这时,我们就需要给这个数据划分一个默认的方向。但是默认的方向应该如下确定,如果全向左、向右或者随机划分,明显是不合理的。
XGBoost采用了这样的方式来求取缺失值的默认划分方向。遍历所有的特征,枚举缺失值都在左子树时的情况,再枚举缺失值都在右子树时的情况(缺失值是向量
x
i
x_i
xi中有缺失,所以就算有缺失值也不影响计算
h
i
,
g
i
h_i,g_i
hi,gi)。
3.3 直方图算法
3.3.1 算法简介
直方图算法(Histogram algorithm)把连续的浮点特征值离散化为
k
k
k个整数(也就是分桶bins的思想),比如
[
0
,
0.1
)
→
0
,
[
0.1
,
0.3
)
→
1
[0,0.1)\to 0,[0.1,0.3)\to 1
[0,0.1)→0,[0.1,0.3)→1。并根据特征所在的bin对其梯度累加和个数统计,然后根据直方图,寻找最优的切分点。
3.3.2 算法伪代码
直方图算法伪代码如下:
- 第一个for循环:遍历模型上的所有叶子节点。只有遍历所有节点的特征,才能找到增益最大的划分方式。
- 第二个for循环:遍历每一个叶子节点的所有特征,对于每一个特征,建立一个直方图。直方图包含两个信息,每个bin中的梯度之和(这里的梯度不止是一阶梯度,还有二阶梯度),以及每个bin中的样本数量。
- 第三个for循环:遍历所有样本,统计对应特征的直方图信息。
- 第四个for循环:遍历直方图, 以当前的桶作为分割点,累加左边的包括当前的桶的梯度以及样本数量,并利用直方图保存的总和信息做差求得右边的梯度和及样本数量。带入公式计算loss值找到找到获得最大增益的位置,以此时的特征和桶的特征值作为分裂节点的特征及取值。(这里的收益计算和XGBoost相同)
3.3.3 分桶方式
我们要如何判断哪些数据放到哪些桶中呢?
对于数值型特征:
- 对特征值去重后进行从大到小的排序,并统计每个值的出现次数
- 取min(max_bin(最大有几个桶,这个是超参数),特征值去重后的数量)作为桶的数量。
- 计算bins中的每个桶的平均样本个数,对不同的特征值进行累加,假如某个特征值里的样本数量大于平均值,那么这个特征值单独放置在一个桶中对于的特征值作为bins的上界,超出这个桶包含的上界的值作为下一代桶的下界;如果某个桶的样本数量小于平均值,则需要累计后再分组。
源代码如下: 源码地址在这里
std::vector<double> GreedyFindBin(const double* distinct_values, const int* counts,
int num_distinct_values, int max_bin, size_t total_cnt, int min_data_in_bin) {
// counts为特征取值计数,distinct_values是每个特征的数值(按照特征值从大到小排序),total_cnt 一共有多少个样本
//max_bin最大分桶数量这个参数可以由用户自己定义,默认值是255
//min_data_in_bin这个参数也可以由用户自定义,默认是3,这些都在lightgbm的IO Parameters 里面作为超参数存在
std::vector<double> bin_upper_bound;//这里面存了,每个桶的右侧的切分点。
CHECK(max_bin > 0);
if (num_distinct_values <= max_bin) {//如果不同的特征值的数量比max_bin还小,说明可以每个特征值单独分一个桶
bin_upper_bound.clear();
int cur_cnt_inbin = 0;
for (int i = 0; i < num_distinct_values - 1; ++i) {
cur_cnt_inbin += counts[i];//counts[i]表示的是每一个不同的特征值的计数
if (cur_cnt_inbin >= min_data_in_bin) {//达到分一个桶的最小样本数量
auto val = Common::GetDoubleUpperBound((distinct_values[i] + distinct_values[i + 1]) / 2.0);
//如果某个特征值的取值数大于min_data_in_bin(这个是为了防止过拟合而设置的),则得到一个切分点,切分点为对应的相邻的两个distinct_values的平均数
// CheckDoubleEqualOrdered返回真的条件:val<bin_upper_bound.back()。注意前面有个!。
if (bin_upper_bound.empty() || !Common::CheckDoubleEqualOrdered(bin_upper_bound.back(), val)) {
bin_upper_bound.push_back(val); //将切分点存储起来,进入下一轮循环
cur_cnt_inbin = 0;
}
}
}
cur_cnt_inbin += counts[num_distinct_values - 1];
bin_upper_bound.push_back(std::numeric_limits<double>::infinity()); //上限值是无穷
}
else { // 特征取值比max_bin来得大,说明多个不同特征取值要共用一个bin
if (min_data_in_bin > 0) { //算出max_bin的大小
max_bin = std::min(max_bin, static_cast<int>(total_cnt / min_data_in_bin));
max_bin = std::max(max_bin, 1);
}
double mean_bin_size = static_cast<double>(total_cnt) / max_bin;//计算平均每个bin的大小
int rest_bin_cnt = max_bin;//除去大样本单独占有的桶还有多少桶
int rest_sample_cnt = static_cast<int>(total_cnt);//除去大样本后所有的样本数
std::vector<bool> is_big_count_value(num_distinct_values, false);//标记某个值的样本是不是大样本
// 标记一个特征取值数超过mean,因为这些特征需要单独一个bin
for (int i = 0; i < num_distinct_values; ++i) {
if (counts[i] >= mean_bin_size) {
is_big_count_value[i] = true;
--rest_bin_cnt;
rest_sample_cnt -= counts[i];
}
}
//剩下的特征取值中平均每个bin的取值个数
mean_bin_size = static_cast<double>(rest_sample_cnt) / rest_bin_cnt;
std::vector<double> upper_bounds(max_bin, std::numeric_limits<double>::infinity());
std::vector<double> lower_bounds(max_bin, std::numeric_limits<double>::infinity());
int bin_cnt = 0;
lower_bounds[bin_cnt] = distinct_values[0];
int cur_cnt_inbin = 0;
for (int i = 0; i < num_distinct_values - 1; ++i) {
if (!is_big_count_value[i]) {
rest_sample_cnt -= counts[i];
}
cur_cnt_inbin += counts[i];
// 当前的特征如果是需要单独成一个bin,或者当前几个特征计数超过了mean_bin_size,或者下一个是需要单独成一个bin并且现在的桶已经装了一半以上了。
if (is_big_count_value[i] || cur_cnt_inbin >= mean_bin_size ||
(is_big_count_value[i + 1] && cur_cnt_inbin >= std::max(1.0, mean_bin_size * 0.5f))) {
upper_bounds[bin_cnt] = distinct_values[i]; // 第i个bin的最大就是 distinct_values[i]了
++bin_cnt;
lower_bounds[bin_cnt] = distinct_values[i + 1]; //下一个bin的最小就是distinct_values[i + 1],注意先++bin了
if (bin_cnt >= max_bin - 1) { break; }
cur_cnt_inbin = 0;
if (!is_big_count_value[i]) {//如果不是大样本,则需要占用一个剩余的桶
--rest_bin_cnt;
mean_bin_size = rest_sample_cnt / static_cast<double>(rest_bin_cnt);//更改剩下桶的平均值
}
}
}
++bin_cnt;
// 记录每个桶的切分点的上界
bin_upper_bound.clear();
for (int i = 0; i < bin_cnt - 1; ++i) {
auto val = Common::GetDoubleUpperBound((upper_bounds[i] + lower_bounds[i + 1]) / 2.0);
if (bin_upper_bound.empty() || !Common::CheckDoubleEqualOrdered(bin_upper_bound.back(), val)) {
bin_upper_bound.push_back(val);
}
}
// 最后一个桶的切分点的上界为无穷
bin_upper_bound.push_back(std::numeric_limits<double>::infinity());
}
return bin_upper_bound;
}
类别特征:
- 首先按照特征值出现的次数按照从大到小排序
- 取前min(max_bin(最大有几个桶,这个是超参数),特征值去重后的数量)个特征做下一步
- 将特征取值和bin一一对应起来,这样可以方便进行这两者之间的转换。
部分源代码如下 源码地址在这里
Common::SortForPair<int, int>(counts_int, distinct_values_int, 0, true);//按照出现次数进行排序
// 避免第一个bin代表的值是0
if (distinct_values_int[0] == 0) {
if (counts_int.size() == 1) {
counts_int.push_back(0);
distinct_values_int.push_back(distinct_values_int[0] + 1);
}
// 交换counts_int[0]和counts_int[1]的值(因为不同类别特征最多只有一个是0)
std::swap(counts_int[0], counts_int[1]);
std::swap(distinct_values_int[0], distinct_values_int[1]);
}
// 忽视出现次数少的类别(后1%)
int cut_cnt = static_cast<int>((total_sample_cnt - na_cnt) * 0.99f);
size_t cur_cat = 0;
// categorical_2_bin_ (unordered_map类型) 将特征取值到哪个bin和一一对应起来
categorical_2_bin_.clear();
// bin_2_categorical_(vector类型)记录bin对应的特征取值
bin_2_categorical_.clear();
int used_cnt = 0;
max_bin = std::min(static_cast<int>(distinct_values_int.size()), max_bin); // 最多分桶数
cnt_in_bin.clear();
// 类别特征值已经按数量从大到小排列,累积特征值的数目,放弃后1%的类别特征值,即忽略一些出现次数很少的特征取值
//cur_cat:当前所处的类别的位置,used_cnt:使用了多少个数据,num_bin_:现在所使用的桶
while (cur_cat < distinct_values_int.size()
&& (used_cnt < cut_cnt || num_bin_ < max_bin)) {
if (counts_int[cur_cat] < min_data_in_bin && cur_cat > 1) {
break;
}
//为bin_2_categorical_和categorical_2_bin_赋值
bin_2_categorical_.push_back(distinct_values_int[cur_cat]);//每个桶对应的特征值
categorical_2_bin_[distinct_values_int[cur_cat]] = static_cast<unsigned int>(num_bin_);//某个特征值对于的桶
used_cnt += counts_int[cur_cat];
cnt_in_bin.push_back(counts_int[cur_cat]);//每个桶里有多少个样本
++num_bin_;
++cur_cat;
}
3.3.4 优化方式
直方图作差:
一个叶子节点的直方图可以由它的父亲节点的直方图与其兄弟的直方图做差得到。使用这个方法,构建完一个叶子节点的直方图后,就可以用较小的代价得到兄弟节点的直方图,相当于速度提升了一倍。
3.3.5 直方图算法优点
- 减少内存占用(因为默认最多256个桶所以只需要一个比特),直方图算法不仅不需要额外存储预排序的结果,而且可以只保存特征离散化后的值。
- 缓存命中率提高,因为直方图中梯度存放是连续的。
- 计算效率提高,相对于XGBoost中预排序每个特征都要遍历数据,复杂度为 O ( 特 征 的 数 量 × 数 据 的 数 量 ) O(特征的数量\times数据的数量) O(特征的数量×数据的数量);直方图算法只需要遍历每个特征的直方图为 O ( 特 征 的 数 量 × 直 方 图 b i n 个 数 ) O(特征的数量\times 直方图bin个数) O(特征的数量×直方图bin个数)。
- 在进行数据并行时,可以大幅降低通信代价。
4 直方图算法改进
如果我们能够降低特征数或者降低样本数,训练的时间会大大减少。加入特征存在冗余时,我们可以使用PCA等算法进行降维,但是可能会影响训练精度。因此LightGBM提出了如下算法。
4.1 GOSS算法(Gradient-based One-Side Sampling 梯度单边采样)
样本计算所得的梯度越小,说明误差越小,表示样本已经被我们的模型拟合的很好了,所以这些样本对于我们来说已经不是那么重要了,但是也不能完全丢弃(因为会影响数据的分布)。所以采用了one-side采样方式来适配:GOSS采样策略,它保留大梯度样本,对小梯度样本进行随机采样(采样比例是
b
=
1
×
(
1
−
a
)
×
r
a
t
e
b=1\times(1-a)\times rate
b=1×(1−a)×rate)。
采样不同方式计算出的增益函数对比如下:
- 原始方法
- 使用了GOSS
4.2 EFB算法(Exclusive Feature Bundling 特征绑定技术))
在我们的实际应用中,高维数据通常是稀疏的,而且很多特征是互斥的(即两个或多个特征列不会同时为非0),lightGBM根据这一特点提出了EFB算法将互斥特征进行合并,能够合并的特征为一个束,从而将特征的维度降下来,相应的,构建直方图所耗费的时间复杂度也从 O ( 数 据 数 量 × 特 征 数 量 ) O(数据数量\times 特征数量) O(数据数量×特征数量)变成 O ( 数 据 数 量 × 束 数 量 ) O(数据数量\times束数量) O(数据数量×束数量),其中束的数量小于特征的数量。
- 那些特征可以合并为一个束(被证明是NP难问题)?
- 如何将特征合并为束,实现降维?
4.2.1 Greedy Bundling
那些特征可以合并为一个束?
Greedy Bundling的原理与图着色相同,给定一个图G,定点为V,表示特征,边的权重表示互斥关系的强弱,接着使用贪心算法求解束的划分方式(类似于图的着色)。
4.2.2 MEF
如何将特征合并为束,实现降维?
合并的关键关键在于如何让原有的不同特征值在构建的束中仍能够识别。由于基于直方图算法储存的是离散的bins的值,所以可以通过添加偏移的方法将不同特征的bin值设定为不同的区间。比如有两个特征,其中的一个特征A取值范围是
[
0
,
10
)
[0,10)
[0,10)特征B的取值范围是
[
0
,
20
)
[0,20)
[0,20)。这时,我们可以给特征B一个10大小的偏置,这时B的取值范围就变成了
[
10
,
30
)
[10, 30)
[10,30)。在这之后,对特征A和B的融合就可以实施了,我们就可以用一个取值范围是
[
0
,
30
)
[0,30)
[0,30)的特征C来代替原来的两个特征了。
EFB算法可以把许多互斥的稀疏特征聚集为更加密集的特征,这样就可以有效的避免计算特征为0的无用的情况。
5 LightGBM树的生长策略
在XGBoost中,树是按层生长(level-wise)的,同一层所有节点都作分裂,最后剪枝,它不加区分的对待同一层的叶子,带来了很多没必要的开销,因为很多叶子的分裂增益较低,没必要进行搜索和分裂。
lightGBM的生长策略是leaf-wise,以降低模型损失最大化为目的,对当前所有叶子节点中切分增益最大的叶子节点进行切分。leaf-wise的缺点是会生成比较深的决策树,为了防止过拟合,可以在模型参数中设置决策树的深度。
6 系统设计
LightGBM中有一些并行优化方法:
- 特征并行
特征并行是并行化决策树种寻找最优划分点的过程。特征并行是将对特征进行划分,每个worker找到局部的最佳切分点,使用点对点通信找到全局的最佳切分点。
传统算法: 不同的worker存储不同的特征集,在找到全局的最佳划分点后,具有该划分点的worker进行节点分裂,然后广播切分后的左右子树数据结果,其他worker收到结果后也进行划分。
LightGBM: 每个worker中保存了所有的特征集,在找到全局的最佳划分点后每个worker可自行进行划分,不再广播划分结果,减少了网络的通信量。但存储代价变高。
- 数据并行
数据并行的目标是并行化整个决策学习的过程。每个worker中拥有部分数据,独立的构架局部直方图,合并后得到全局直方图,在全局直方图中寻找最优切分点进行分裂。
- 投票并行
lightGBM采用一种称为PV-Tree的算法进行投票并行,其实这本质上也是一种数据并行。PV-tree和普通的决策树差不多,只是在寻找最优切分点上有所不同。
每个worker拥有部分数据,独自构建直方图并找到top-k最优的划分特征,中心worker聚合得到最优的2K个全局最优划分特征,再向每个worker收集top-2k特征的直方图,并进行合并得到最优划分,广播给所有worker进行本地划分。