GBDT (Gradient Boosting Decision Tree)是机器学习中一个长盛不衰的模型,其主要思想是利用弱分类器(决策树)迭代训练以得到最优模型,该模型具有训练效果好、不易过拟合等优点。GBDT在工业界应用广泛,通常被用于点击率预测,搜索排序等任务。GBDT也是各种数据挖掘竞赛的致命武器,据统计Kaggle上的比赛有一半以上的冠军方案都是基于GBDT。
LightGBM (Light Gradient Boosting Machine)是一个实现GBDT算法的框架,支持高效率的并行训练,并且具有以下优点:
实验数据结果表明,Higgs数据集上LightGBM比XGBoost快将近10倍,内存占用率大约为XGBoost的1/6,并且准确率有所提升。在其他数据集上也可以观察相似的结论。
准确率:
内存消耗:
训练速度:
XGBoost的工作原理
目前已有的GBDT工具基本都是基于预排序的方法(Pre-sorted)的决策树算法(如xgboost)。这种构建决策树的算法基本思想是:
预排序算法的优点是能精确地找到分割点
缺点:
直方图算法的基本思想是先把连续的浮点特征值离散化成k个整数,同时构造一个宽度为k的直方图。在遍历数据的时候,根据离散化后的值作为索引在直方图中累积统计量,当遍历一次数据后,直方图累积了数据的统计量,然后根据直方图的离散值,遍历寻找最优的分割点。
Histogram算法的具体实现细节
优点:
Histogram算法并不是完美的。由于特征被离散化后,找到的分割点并不是很精确的分割点,所以会对结果产生影响。但在不同的数据集上的结果表明,离散化的分割点对最终的精度影响并不是很大,甚至有时候会更好一点。原因是决策树本来就是弱模型,分割点是不是精确并不是很重要;较粗的分割点也有正则化的效果,可以有效地防止过拟合;即使单颗树的训练误差比精确分割算法稍大,但在梯度提升(Gradient Boosting)的框架下没有太大的影响。
Alg.1算法中,Histogram算法基于feature直方图寻找最优分割点。构建Histogram的复杂度为 O ( d a t a × f e a t u r e ) O(data\times feature) O(data×feature),而寻找最优分割点的复杂度为 O ( b i n × f e a t u r e ) O(bin\times feature) O(bin×feature)。因为bin远远小于data,所以整个过程的复杂度为 O ( d a t a × f e a t u r e ) O(data\times feature) O(data×feature)。通过减少data或者feature可以加速GBDT的训练过程。
在AdaBoost算法中,样本权重作为数据实例重要性的指标。然而,GBDT没有这样的样本权重从而导致不能采用AdaBoost的取样方法。幸运的是,我们注意到GBDT中每个数据实例的梯度信息能为数据采样提供有价值的信息。换句话说,如果一个实例的梯度很小,说明这个实例得到很好的训练,其训练误差很小。一个简单的想法是丢弃这些小梯度的数据实例。然而,这样会改变数据分布从而降低训练模型的精确度。为了解决这个问题,我们提出了一个Gradient-based One-Side Sampling的新方法。以下Alg.2是GOSS算法的实现细节。
GOSS算法的描述
输入:训练数据,迭代次数d,大梯度数据的采样率a,小梯度数据的采样率b,损失函数和若干个弱学习器
输出:训练的强学习器
通过上述算法,在不过多的改变原数据分布的前提下,提高了模型的准确度和训练速度。
高维数据通常是非常稀疏的。由于特征空间的稀疏性,我们可以设计一个无损的方法减少特征的数量。在一个稀疏特征空间里,一些特征是相互独立的,我们可以把这些独立互斥特征组合成一个单一特征(exclusive feature bundle)。通过一个特征扫描算法,我们可以根据独立特征构建组合特征的特征直方图。构建的组合特征histogram复杂度从 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),而且bundle远远小于feature。这种方法在保证精确度的前提下加快了GBDT模型的训练速度。
首先,有两个需要解决的问题:
将高维特征划分为更小的独立特征组合是一个NP难问题。为了寻找一个近似算法,首先把特征优化组合问题看作是图着色问题,将特征看做顶点,如果两个特征不相互排斥,在图中为两个特征增加一条边,最后,通过贪婪算法来生成组合特征。允许特征之间存在少量的样本点不是互斥的(存在某些对应的样本点不同时为非零),如果算法允许存在一小部分冲突那么可以得到更小的特征组合,而且进一步地提高了计算效率。由于算法中存在一小部分的冲突特征,导致影响了训练模型的精确度,最多可达 O ( [ ( 1 − γ ) n ] − 2 / 3 O([(1-\gamma)n]^{-2/3} O([(1−γ)n]−2/3,这里 γ \gamma γ是每个组合的最大冲突率。因此,如果我们选择一个相当小的 γ \gamma γ,
我们能够在精确度和效率之间达到一个很好的平衡。Greedy Bundling算法如下:
上述算法的时间复杂度是 O ( f e a t u r e 2 ) O(feature^2) O(feature2),在训练之间仅仅被处理一次。当特征数量不是很大的时候,这个复杂度是可接受的,但是如果有百万个特征,这个复杂度太大。为了进一步的提高效率,我们提出了一个没有构建图的更有效的排序方法:根据特征的非零值数量排序,这和按照度来进行排序十分相似,因为特征的非零值越多,越有可能产生冲突。
对于第二个问题,为了减少相应的训练复杂度,需要一个将特征组合在一起的好方法。问题关键是通过特征组合能够识别出原始特征的值。因为Histogram算法存储了离散的bins,而不是连续特征值,我们可以通过将互斥特征驻留在不同的bins中构建一个特征组合,这可以通过增加特征原始值的偏移量来实现。例如,假如特征组合有两个特征,特征A的取值范围为[0,10),特征B的取值范围为[0,20),特征B的值增加偏移量10,使得特征B的取值范围为[10,30)。最后合并A和B,生成一个取值范围为[0,30]的特征组合来代替原始特征A和B,以下是算法4的实现细节:
在Histogram算法之上,LightGBM进行进一步的优化。首先它抛弃了大多数GBDT工具使用的按层生长(level-wise)的决策树生长策略,而使用带深度限制的按叶子生长(leaf-wise)算法。
Level-wise遍历一次数据可以同时分裂同一层的叶子,容易进行多线程优化,也好控制模型复杂度,不容易过拟合。但实际中Level-wise是一种低效的算法,因为它不加区分的对待同一层的叶子,带来了很多没必要的开销,因为实际上很多叶子的分裂增益较低,没必要进行搜索和分裂。
Leaf-wise是一种更为高效的策略,每次从当前所有叶子中,找到分裂增益最大的一个叶子,然后进行分裂,如此循环。同Level-wise相比,在分裂次数相同的情况下,Leaf-wise可以降低更多的误差,得到更好的精度。Leaf-wise的缺点是可能会长出较深的决策树,产生过拟合。因此,LightGBM在Leaf-wise之上增加了max_depth的限制,在保证高效率的同时防止过拟合。
LightGBM另一个优化是Histogram做差加速。一个叶子的直方图可以由父亲节点的直方图与它兄弟的直方图做差得到。通常构造直方图,需要遍历该叶子上的所有数据,但直方图做差仅需遍历直方图的k个桶。利用这个方法,LightGBM考虑先构造 O ( d a t a ) O(data) O(data)较小的叶节点的直方图后,再用非常微小的代价O(#bin)做差法计算兄弟叶子的直方图,在速度上可以提升一倍。
实际上大多数机器学习工具都无法直接支持类别特征,一般需要把类别特征,转化到多维的0/1特征,降低了空间和时间的效率。而类别特征的使用是在实践中很常用的。基于这个考虑,LightGBM优化了对类别特征的支持,可以直接输入类别特征,不需要额外的0/1展开。并在决策树算法上增加了类别特征的决策规则。在Expo数据集上的实验,相比0/1展开的方法,训练速度可以加速8倍,并且精度一致。据我们所知,LightGBM是第一个直接支持类别特征的GBDT工具。
通常将类别特征转化为one-hot编码。然而,对于学习树来说这不是一个很好的解决方案。原因是对于一个基数较大的类别特征,学习树会生长的非常不平衡,并且需要非常深的深度才能达到较好的准确率。
相比较one-hot编码,最好的解决方案是将类别特征划分为两个子集。如果某特征有k个离散值,一共有 2 ( k − 1 ) − 1 2^{(k-1)}-1 2(k−1)−1个可能的划分。但是对于回归树有一个有效的解决方案。为了寻找最优的划分需要大约 k ∗ l o g ( k ) k*log(k) k∗log(k)。其基本思想是根据训练目标的相关性对类别进行重排序。具体地说是根据累加值(sum_gradient/sum_hessian)重新对(类别特征)直方图进行排序,然后在排好序的直方图中寻找最好的分割点。
Pre-sorted算法导致cache miss的因素有以下两种:
这两个操作都是随机访问,会大幅度降低系统性能。当数据量很大时,顺序访问的速度比随机访问快4倍以上。
Histogram算法提高了cache命中率
传统算法
传统的特征并行算法旨在于并行哈决策树的"Find Best Split",主要流程如下:
垂直划分数据(不同的worker有不同的特征集)
在本地特征集寻找最佳划分点{特征,阈值}
将各个本地的最佳划分点通信整合,得到最终的最优划分
以最佳划分方法对数据进行划分,并将数据划分结果广播传递给其他worker
其他worker对接收到的数据做进一步的划分
传统的特征并行方法主要不足:
LightGBM的特征并行
当数据量很大时,传统数据并行方法无法有效的加速,LightGBM做一些改变:不再垂直划分数据,即每个worker都持有全部数据。因此,LightGBM没有数据划分结果之间的通信开销,各个worker都知道如何划分数据。而且,data不会变的更大,所以,使每个worker都持有全部数据是合理的。
LightGBM中特征并行的流程如下:
然而,该特征并行算法在数据量很大时仍然存在计算上的局限。因此,建议数据量很大时使用数据并行。
传统算法
数据并行旨在并行化整个决策学习过程。数据并行的主要流程如下:
传统数据划分的不足:
高通讯开销。如果使用点对点的通讯算法,一个机器的通讯开销大约为 O ( m a c h i n e × f e a t u r e × b i n ) O(machine\times feature\times bin) O(machine×feature×bin);如果使用集成通讯算法(All Reduce),通讯开销大约为 O ( 2 × f e a t u r e × b i n ) O(2\times feature\times bin) O(2×feature×bin)
LightGBM中的数据并行
LightGBM中采用以下方法减少数据并行的通讯开销:
基于投票机制的并行算法,在每个worker中投票选出top k个分裂特征,然后将每个worker选出的k个局部特征进行汇总再进行全局投票,选出全局的分裂特征,最终对全局投票的直方图进行整合并找到最优划分点。投票并行将数据并行的通讯开销减少至常数级别。在数据量很大的时候,使用投票并行可以得到非常好的加速效果。
Core Parameters | 含义 | 用法 |
---|---|---|
objective | 目标函数 | 回归:regression_l1,regression_l2,huber;二分类:binary;多分类:multiclass;排序:lambdarank |
boosting | 提升器的类型 | gbdt:梯度提升决策树;rf:Random Forest;dart:Dropouts meet Multiple Additive Regression Trees;goss: Gradient-based One-Side Sampling |
data | 训练数据 | LightGBM使用这个数据进行训练模型 |
valid | 验证/测试数据 | LightGBM将输出这些数据的度量 |
num_boost_round | boosting的迭代次数 | 默认100 |
learning_rate | 学习率 | 默认0.1 |
num_leaves | 树的叶子树 | 默认31 |
tree_learner | 树学习器的学习类型 | serial:单台机器的tree_learner;feature:特征并行的tree_learn;data:数据并行的tree_learner;voting:投票并行的tree_learner |
num_threads | LightGBM的线程数 | 为了更快的速度, 将此设置为真正的 CPU 内核数, 而不是线程的数量;对于并行学习, 不应该使用全部的 CPU 内核, 因为这会导致网络性能不佳 |
device | 树学习设备类型 | 默认cpu,可选cpu/gpu;使用 GPU 来获得更快的学习速度 |
Model Parameters | 含义 | 用途 |
---|---|---|
max_depth | 树模型的最大深度 | 默认为-1,限制max_depth,可以在#data 小的情况下防止过拟合. 树仍然可以通过 leaf-wise 生长. |
min_data_in_leaf | 一个叶子上数据的最小数量 | 默认为21,用来处理过拟合 |
min_sum_hessian_in_leaf | 一个叶子上的最小hessian和 | 默认为1e-3,用于处理过拟合 |
feature_fraction | 特征采样比例 | 默认为1.0,如果 feature_fraction 小于 1.0, LightGBM 将会在每次迭代中随机选择部分特征. 例如, 如果设置为 0.8, 将会在每棵树训练之前选择 80% 的特征,可以用来加速训练和处理过拟合 |
bagging_fraction | 数据采样比例 | 默认为1.0,类似于 feature_fraction, 但是它将在不进行重采样的情况下随机选择部分数据,可以用来加速训练和处理过拟合;Note: 为了启用 bagging, bagging_freq 应该设置为非零值 |
bagging_freq | bagging的频率 | 默认为0,0 意味着禁用 bagging. k 意味着每 k 次迭代执行bagging |
early_stopping_round | 迭代提前终止 | 如果一个验证集的度量在 early_stopping_round 循环中没有提升, 将停止训练 |
lambda_l1 | L1正则系数 | 用于处理过拟合 |
lambda_l2 | L2正则系数 | 用于处理过拟合 |
min_split_gain | 执行切分的最小增益 | 默认为0 |
IO Parameters | 含义 | 用途 |
---|---|---|
max_bin | 特征值 bins的最大数量 | 默认255,LightGBM 将根据 max_bin 自动压缩内存。 例如, 如果 maxbin=255, 那么 LightGBM 将使用 uint8t 的特性值 |
categorical_feature | 指定类别特征 | 用数字做索引, e.g. categorical_feature=0,1,2 意味着 column_0, column_1 和 column_2 是分类特征 |
Objective Parameters | 含义 | 用途 |
---|---|---|
sigmoid | sigmoid函数的参数 | 默认为1.0,用于binary分类和lambdarank |
alpha | Huber loss 和 Quantile regression 的参数 | 默认0.9,用于regression任务 |
scale_pos_weight | 正值的权重 | 默认1.0,用于binary分类任务 |
num_class | 类别数量 | 默认为1,只用于multiclass分类 |
leaf_wise树的参数优化
提升训练速度
提升准确率
处理过拟合
# -*- coding="utf-8" -*-
import lightgbm as lgb
import pandas as pd
from sklearn.metrics import mean_squared_error
print("Loading data...")
# load data
df_train = pd.read_csv('../regression/regression.train', sep='\t', header=None)
df_test = pd.read_csv('../regression/regression.test', sep='\t', header=None)
y_train = df_train[0]
X_train = df_train.drop(0, axis=1)
y_test = df_test[0]
X_test = df_test.drop(0, axis=1)
# convert dataset for lightgbm
lgb_train = lgb.Dataset(X_train, y_train)
lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)
# set the configurations for lightgbm
params = {'boosting_type': 'gbdt',
'objective': 'regression',
'metric': {'l1', 'l2'},
'learning_rate': 0.05,
'num_leaves': 31,
'feature_fraction': 0.9,
'bagging_fraction': 0.8,
'bagging_freq': 5,
'verbose': 0}
print('Starting train...')
# train
gbt = lgb.train(params,
lgb_train,
num_boost_round=20,
valid_sets=lgb_eval,
early_stopping_rounds=5)
print('Saving model...')
# save model to file
gbt.save_model('model.txt', num_iteration=gbt.best_iteration)
print('Starting predict... ')
# predict
y_pred = gbt.predict(X_test, num_iteration=gbt.best_iteration)
print('Starting evaluation...')
# eval
print('The rmse of prediction is:', mean_squared_error(y_test, y_pred) ** 0.5)
实验结果
Loading data...
Starting train...
[1] valid_0's l1: 0.492841 valid_0's l2: 0.243898
Training until validation scores don't improve for 5 rounds.
[2] valid_0's l1: 0.489327 valid_0's l2: 0.240605
[3] valid_0's l1: 0.484931 valid_0's l2: 0.236472
[4] valid_0's l1: 0.480567 valid_0's l2: 0.232586
[5] valid_0's l1: 0.475965 valid_0's l2: 0.22865
[6] valid_0's l1: 0.472861 valid_0's l2: 0.226187
[7] valid_0's l1: 0.469847 valid_0's l2: 0.223738
[8] valid_0's l1: 0.466258 valid_0's l2: 0.221012
[9] valid_0's l1: 0.462751 valid_0's l2: 0.218429
[10] valid_0's l1: 0.458755 valid_0's l2: 0.215505
[11] valid_0's l1: 0.455252 valid_0's l2: 0.213027
[12] valid_0's l1: 0.452051 valid_0's l2: 0.210809
[13] valid_0's l1: 0.448764 valid_0's l2: 0.208612
[14] valid_0's l1: 0.446667 valid_0's l2: 0.207468
[15] valid_0's l1: 0.444211 valid_0's l2: 0.206009
[16] valid_0's l1: 0.44186 valid_0's l2: 0.20465
[17] valid_0's l1: 0.438508 valid_0's l2: 0.202489
[18] valid_0's l1: 0.435919 valid_0's l2: 0.200668
[19] valid_0's l1: 0.433348 valid_0's l2: 0.19925
[20] valid_0's l1: 0.431211 valid_0's l2: 0.198136
Did not meet early stopping. Best iteration is:
[20] valid_0's l1: 0.431211 valid_0's l2: 0.198136
Saving model...
Starting predict...
Starting evaluation...
The rmse of prediction is: 0.445124349108