这篇教程GBDT分类的原理及Python实现写得很实用,希望能帮到您。完整实现代码请参考本人的p...哦不是...github:
gbdt_base.py github.com
gbdt_classifier.py github.com
gbdt_classifier_example.py github.com
1. 原理篇
我们用人话而不是大段的数学公式来讲讲GBDT分类是怎么一回事。
1.1 温故知新
GBDT分类只是在GBDT回归上做了一点点改造,而GBDT分类又是建立在回归树的基础上的。 之前写过一篇GBDT回归的文章,链接如下:
tushushu/imylu github.com
之前写过一篇回归树的文章,链接如下:
tushushu/imylu github.com
1.2 Sigmoid函数
如果对逻辑回归或者神经网络有所了解的话,那么对Sigmoid函数应该不会感到陌生,它的函数表达式是:
不难得出:
所以,Sigmoid函数的值域是(0, 1),导数为y * (1 - y)
1.3 改造GBDT回归
根据《GBDT回归》可知,假设要做m轮预测,预测函数为Fm,初始常量或每一轮的回归树为fm,输入变量为X,有:
由于是回归问题,函数F的值域在(-∞, +∞),而二分类问题要求预测的函数值在(0, 1),所以我们可以用Sigmoid函数将最终的预测值的值域控制在(0, 1)之间,其函数表达式如下:
1.3 预测见面
以预测相亲是否见面来举例,见面用1表示,不见面用0表示。从《回归树》那篇文章中我们可以知道,如果需要通过一个常量来预测同事的年龄,平均值是最佳选择之一。那么预测二分类问题,这个常量该如何计算呢?我们简单证明一下:
z为我们要计算的常量:
GBDT分类器的函数表达式:
二分类问题的似然函数:
对式3两边求对数并乘以-1,得到损失函数:
对式4的p求导,得:
对式2的z求导,得:
根据式5和式6,得:
化简式7,得:
令式8等于0,最小化损失函数,那么:
将式2代入式9,得到:
结论,如果要用一个常量来预测y,用log(sum(y)/sum(1-y))是一个最佳的选择。
1.4 见面的残差
我们不妨假设三个相亲对象是否见面分别为[1, 0, 1],那么预测是否见面的初始值z = log((1+0+1)/(0+1+0)) = 0.693,所以我们用0.693这个常量来预测同事的年龄,即Sigmoid([0.693, 0.693, 0.693]) = [0.667, 0.667, 0.667]。每个相亲对象是否见面的残差 = 是否见面 - 预测值 = [1, 0, 1] - [0.667, 0.667, 0.667],所以残差为[0.333, -0.667, 0.333]
1.5 预测见面的残差
为了让模型更加准确,其中一个思路是让残差变小。如何减少残差呢?我们不妨对残差建立一颗回归树,然后预测出准确的残差。假设这棵树预测的残差是[1, -0.5, 1],将上一轮的预测值和这一轮的预测值求和,之后再求Sigmoid值,每个相亲对象是否见面 = Sigmoid([0.693, 0.693, 0.693] + [1, -0.5, 1]) = [0.845, 0.548, 0.845],显然与真实值[1, 0, 1]更加接近了, 每个相亲对象是否见面的残差此时变为[0.155, -0.548, 0.155],预测的准确性得到了提升。
1.6 GBDT
重新整理一下思路,假设我们的预测一共迭代3轮 是否见面:[1, 0, 1]
第1轮预测:Sigmoid([0.693, 0.693, 0.693] (初始值)) = [0.667, 0.667, 0.667]
第1轮残差:[0.333, -0.667, 0.333]
第2轮预测:Sigmoid([0.693, 0.693, 0.693] (初始值) + [1, -0.5, 1]) (第1颗回归树)) = Sigmoid([1.693, 0.193, 1.693]) = [0.845, 0.548, 0.845]
第2轮残差:[0.155, -0.548, 0.155]
第3轮预测:Sigmoid([0.693, 0.693, 0.693] (初始值) + [1, -0.5, 1] (第1颗回归树) + [2, -1, 2] (第2颗回归树)) = Sigmoid([3.693, -0.807, 3.693]) = [0.976, 0.309, 0.976]
第3轮残差:[0.024, -0.309, 0.024]
看上去残差越来越小,而这种预测方式就是GBDT算法。
1.7 公式推导
看到这里,相信您对GBDT已经有了直观的认识。这么做有什么科学依据么,为什么残差可以越来越小呢?前方小段数学公式低能预警。
假设要做m轮预测,预测函数为Fm,初始常量或每一轮的回归树为fm,输入变量为X,有:
设要预测的变量为y,采用极大似然函数的负对数作为损失函数(经知乎网友"又耳莫木"提醒,这里少个负号但不影响结果):
我们知道泰勒公式的一阶展开式是长成这个样子滴:
如果:
那么,根据式3和式4可以得出:
根据式2可以知道,损失函数的一阶偏导数为:
根据式6可以知道,损失函数的二阶偏导数为:
蓄力结束,开始放大招。根据式1,损失函数的一阶导数为:
根据式5,将式8进一步展开为:
令式9,即损失函数的一阶导数为0,那么:
将式6,式7代入式9得到:
因此,我们需要通过用第m-1轮的预测值和残差来得到函数fm,进而优化函数Fm。而回归树的原理就是通过最佳划分区域的均值来进行预测,与GBDT回归不同,要把这个均值改为1.7式11。所以fm可以选用回归树作为基础模型,将初始值,m-1颗回归树的预测值相加再求Sigmoid值便可以预测y。
2. 实现篇
本人用全宇宙最简单的编程语言——Python实现了GBDT分类算法,便于学习和使用。简单说明一下实现过程,更详细的注释请参考本人github上的代码。
2.1 导入节点类、回归树类
回归树是我之前已经写好的一个类,在之前的文章详细介绍过,代码请参考:
regression_tree.py github.com
from ..tree.regression_tree import Node , RegressionTree
2.2 创建GradientBoostingBase类
初始化,存储回归树、学习率、初始预测值和变换函数。
class GradientBoostingBase :
def __init__ ( self ):
self . trees = None
self . learning_rate = None
self . init_val = None
2.3 计算初始预测值
初始预测值,见1.7式10。
def _get_init_val ( self , label : ndarray ):
n_rows = len ( label )
tot = label . sum ()
return np . log ( tot / ( n_rows - tot ))
2.4 匹配叶结点
计算训练样本属于回归树的哪个叶子结点。
@staticmethod
def _match_node ( row : ndarray , tree : RegressionTree ) -> Node :
node = tree . root
while node . left and node . right :
if row [ node . feature ] < node . split :
node = node . left
else :
node = node . right
return node
2.5 获取叶节点
获取一颗回归树的所有叶子结点。
@staticmethod
def _get_leaves ( tree : RegressionTree ) -> List [ Node ]:
nodes = []
que = [ tree . root ]
while que :
node = que . pop ( 0 )
if node . left is None or node . right is None :
nodes . append ( node )
continue
que . append ( node . left )
que . append ( node . right )
return nodes
2.6 划分区域
将回归树的叶子结点,其对应的所有训练样本存入字典。
def _divide_regions ( self , tree : RegressionTree , nodes : List [ Node ],
data : ndarray ) -> Dict [ Node , List [ int ]]:
regions = { node : [] for node in nodes }
for i , row in enumerate ( data ):
node = self . _match_node ( row , tree )
regions [ node ] . append ( i )
return regions
2.7 计算预测值
见1.7式11。
@staticmethod
def _get_score ( idxs : List [ int ], prediction : ndarray , residuals : ndarray ) -> float :
numerator = residuals [ idxs ] . sum ()
denominator = ( prediction [ idxs ] * ( 1 - prediction [ idxs ])) . sum ()
return numerator / denominator
2.8 更新预测值
更新回归树各个叶节点的预测值。
def _update_score ( self , tree : RegressionTree , data : ndarray ,
prediction : ndarray , residuals : ndarray ):
nodes = self . _get_leaves ( tree )
regions = self . _divide_regions ( tree , nodes , data )
for node , idxs in regions . items ():
node . avg = self . _get_score ( idxs , prediction , residuals )
tree . get_rules ()
2.9 计算残差
@staticmethod
def _get_residuals ( label : ndarray , prediction : ndarray ) -> ndarray :
return label - prediction
2.10 训练模型
训练模型的时候需要注意以下几点:
1. 控制树的最大深度max_depth;
2. 控制分裂时最少的样本量min_samples_split;
3. 训练每一棵回归树的时候要乘以一个学习率lr,防止模型过拟合;
4. 对样本进行抽样的时候要采用有放回的抽样方式。
def fit ( self , data : ndarray , label : ndarray , n_estimators : int , learning_rate : float ,
max_depth : int , min_samples_split : int , subsample = None ):
self . init_val = self . _get_init_val ( label )
n_rows = len ( label )
prediction = np . full ( label . shape , self . init_val )
residuals = self . _get_residuals ( label , prediction )
self . trees = []
self . learning_rate = learning_rate
for _ in range ( n_estimators ):
idx = range ( n_rows )
if subsample is not None :
k = int ( subsample * n_rows )
idx = choice ( idx , k , replace = True )
data_sub = data [ idx ]
residuals_sub = residuals [ idx ]
prediction_sub = prediction [ idx ]
tree = RegressionTree ()
tree . fit ( data_sub , residuals_sub , max_depth , min_samples_split )
self . _update_score ( tree , data_sub , prediction_sub , residuals_sub )
prediction = prediction + learning_rate * tree . predict ( data )
residuals = self . _get_residuals ( label , prediction )
self . trees . append ( tree )
2.11 预测一个样本的分数
def predict_one ( self , row : ndarray ) -> float :
residual = np . sum ([ self . learning_rate * tree . predict_one ( row )
for tree in self . trees ])
return self . init_val + residual
2.12 预测一个样本的概率
def predict_one_prob ( self , row : ndarray ) -> float :
return sigmoid ( self . predict_one ( row ))
2.13 预测多个样本的概率
def predict_prob ( self , data : ndarray ) -> ndarray :
return np . apply_along_axis ( self . predict_one_prob , axis = 1 , arr = data )
2.14 预测多个样本
def predict ( self , data : ndarray , threshold = 0.5 ) -> ndarray :
prob = self . predict_prob ( data )
return ( prob >= threshold ) . astype ( int )
3 效果评估
3.1 main函数
使用著名的乳腺癌数据集,按照7:3的比例拆分为训练集和测试集,训练模型,并统计准确度。
@run_time
def main ():
print ( "Tesing the performance of GBDT classifier..." )
data , label = load_breast_cancer ()
data_train , data_test , label_train , label_test = train_test_split ( data , label , random_state = 20 )
clf = GradientBoostingClassifier ()
clf . fit ( data_train , label_train , n_estimators = 2 ,
learning_rate = 0.8 , max_depth = 3 , min_samples_split = 2 )
model_evaluation ( clf , data_test , label_test )
3.2 效果展示
最终准确度91.2%,运行时间14.9秒,效果还算不错~
3.3 工具函数
本人自定义了一些工具函数,可以在github上查看:utils 1. run_time - 测试函数运行时间 2. load_breast_cancer - 加载乳腺癌数据 3. train_test_split - 拆分训练集、测试集 4. model_evaluation - 计算准确率、精确率、召回率和AUC
总结
GBDT分类的原理:GBDT回归加Sigmoid
GBDT分类的实现:一言难尽[哭]
GBDT回归的原理及Python实现 大顶堆的原理及Python实现