ML模型能够被应用的两个重要因素:
- 使用有效的(统计)模型来捕获复杂的数据依赖性。
- 可伸缩的学习系统,该系统可从大型数据集中学习感兴趣的模型。
背景知识
假设有一组总数为
其中
举个例子,如下图所示。有一组数据,
这样,这组数集成模型就可以预测一个人是否喜欢电脑(或者预测喜欢的程度),输入一个男孩的特征得到预测结果为2.9(两棵树对应结点值相加)。
下面的CART和GBDT两个小节如果没学过,可以先略过,不影响后面的内容!
CART回归树
CART回归树是假设树为二叉树,通过不断将特征进行分裂。比如当前树结点是基于第
而CART回归树实质上就是在该特征维度对样本空间进行划分,而这种空间划分的优化是一种NP难问题,因此,在决策树模型中是使用启发式方法解决。典型CART回归树产生的目标函数为:
因此,当我们为了求解最优的切分特征
所以我们只要遍历所有特征的所有切分点,就能找到最优的切分特征和切分点,最终得到一颗回归树(对,就一棵树,所以缺点也很明显,后面的特征只能基于前面特征切分的结果再次切分)。
GBDT梯度提升树
和XGboost最接近的就是GBDT,其采用加法模型与前向分布算法,以决策树为基学习器,多个决策树集成提成的方法:
采用平方误差损失函数时,损失函数变为:
其中
GBDT/XGBoost VS. 随机森林:
- GBDT和XGBoost是逐个添加树,逐个提升,是一种Boosting算法,每一次树的添加必然会提升预测结果,最终得到最精确的预测结果。
- 随机森林是随机生成多个树,多个树共同进行决策,是一种Bagging算法。
XGBoost
XGBoost优化的目标如下:
- 损失函数
用于描述模型拟合数据的程度
- 正则向
用于控制模型的复杂度
损失函数
正则向则是让每棵树中叶子结点数
如果没有正则向,那么目标函数就变成了传统的gradient tree boosting。
Additive/Heuristic Training
**重点:**由于目标函数中包含不确定函数
...
其中,
指的是第几轮训练,个人理解应该等于树的棵树
寻找最优结点值
损失函数的二阶泰勒展开式
现在问题变成了怎么找到一个合适的
将
这里我们这样理解下,假设已经知道了
进一步,将
重点来了!!! 损失函数
其中
以Square Loss
这里补充一下,对于
def Square(x):
return x**2
def TaylorSquare(x, x0):
return Square(x0) + 2*x0*(x-x0) + (x-x0)**2
x = 10
print('x = %d:'%x, Square(x), TaylorSquare(x, 0), TaylorSquare(x, 100))
x = -5
print('x = %d:'%x, Square(x), TaylorSquare(x, 0), TaylorSquare(x, 100))
x = 10: 100 100 100
x = -5: 25 25 25
可以看出
二次项极值
整理一下现在的目标函数:
将不影响结果的常数项除去:
如果单独看
但是现在还不能这么做,因为有
- 叶子结点的数量
- 叶子结点值
范数
如下图中这棵树的的
可以看出
代入目标函数:
定义
我们想利用如下公式求得
现在我们得到了每个结点的权重值
最优分裂
树的结构是有无限种可能的,我们从第一个结点分裂开始使用贪心算法,遍历所有特征的所有划分点,选出增益
一个叶子结点分裂为左右两个子叶子结点,原叶子结点中的样本集将根据该结点的判断规则分散到左右两个叶子结点中,计算这次分裂是否会给损失函数带来增益,增益的定义如下:
Gain公式中减去了不分裂情况下的增益,并且引入
所以一个叶子结点分裂就是:遍历
例如我们对下面5个样本进行分裂,分别计算不同特征分别在不同位置处的增益,例如对年龄age在a处分裂,age小于a的放左边,大于等于a的放右边,然后分别计算出
注意:计算和时,和(只和上轮结果、标签值有关)是不用重复计算的,只需计算一次就行。可以参加下面的实验章节。
由于贪心算法每次进行分裂尝试都要遍历一遍全部候选分割点,也叫做全局扫描法。当数据量过大导致内存无法一次载入或者在分布式情况下,贪心算法的效率就会变得很低,全局扫描法不再适用。基于此,XGBoost提出了一系列加快寻找最佳分裂点的方案:
- 特征预排序+缓存:XGBoost在训练之前,预先对每个特征按照特征值大小进行排序,然后保存为block结构,后面的迭代中会重复地使用这个结构,使计算量大大减小。
- 分位点近似法:对每个特征按照特征值排序后,采用类似分位点选取的方式,仅仅选出常数个特征值作为该特征的候选分割点,在寻找该特征的最佳分割点时,从候选分割点中选出最优的一个。
- 并行查找:由于各个特性已预先存储为block结构,XGBoost支持利用多个线程并行地计算每个特征的最佳分割点,这不仅大大提升了结点的分裂速度,也极利于大规模训练集的适应性扩展。
至此,XGBoost主要思想全部讲完,还有一些训练上的细节留在下一章。
深入思考
这部分内容是对XGboos深入思考,大多是训练和优化中的一些细节,决定了XGBoost能否真正被广泛应用。由于精力有限,摘抄了一些其他博客的内容,可以看本文最后“深入分析”推荐的文章。
Shrinkage and Column Subsampling
XGBoost还提出了两种防止过拟合的方法:Shrinkage and Column Subsampling。Shrinkage方法就是在每次迭代中对树的每个叶子结点的分数乘上一个缩减权重η,这可以使得每一棵树的影响力不会太大,留下更大的空间给后面生成的树去优化模型。Column Subsampling类似于随机森林中的选取部分特征进行建树。其可分为两种,一种是按层随机采样,在对同一层内每个结点分裂之前,先随机选择一部分特征,然后只需要遍历这部分的特征,来确定最优的分割点。另一种是随机选择特征,则建树前随机选择一部分特征然后分裂就只遍历这些特征。一般情况下前者效果更好。
近似算法
对于连续型特征值,当样本数量非常大,该特征取值过多时,遍历所有取值会花费很多时间,且容易过拟合。因此XGBoost思想是对特征进行分桶,即找到l个划分点,将位于相邻分位点之间的样本分在一个桶中。在遍历该特征的时候,只需要遍历各个分位点,从而计算最优划分。从算法伪代码中该流程还可以分为两种,全局的近似是在新生成一棵树之前就对各个特征计算分位点并划分样本,之后在每次分裂过程中都采用近似划分,而局部近似就是在具体的某一次分裂节点的过程中采用近似算法。
针对稀疏数据的算法(缺失值处理)
当样本的第
XGBoost的优点
- 传统GBDT以CART作为基分类器,XGBoost还支持线性分类器,这个时候XGBoost相当于带L1和L2正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题)。
- 传统GBDT在优化时只用到一阶导数信息,XGBoost则对代价函数进行了二阶泰勒展开,同时用到了一阶和二阶导数。顺便提一下,XGBoost工具支持自定义代价函数,只要函数可一阶和二阶求导。
- XGBoost在代价函数里加入了正则项,用于控制模型的复杂度。正则项里包含了树的叶子节点个数、每个叶子节点上输出的score的L2模的平方和。从Bias-variance tradeoff角度来讲,正则项降低了模型的variance,使学习出来的模型更加简单,防止过拟合,这也是XGBoost优于传统GBDT的一个特性。
- Shrinkage(缩减),相当于学习速率(XGBoost中的eta)。XGBoost在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削弱每棵树的影响,让后面有更大的学习空间。实际应用中,一般把eta设置得小一点,然后迭代次数设置得大一点。(补充:传统GBDT的实现也有学习速率)
- 列抽样(column subsampling)即特征抽样。XGBoost借鉴了随机森林的做法,支持列抽样,不仅能降低过拟合,还能减少计算,这也是XGBoost异于传统gbdt的一个特性。
- 对缺失值的处理。对于特征的值有缺失的样本,XGBoost可以自动学习出它的分裂方向。
- XGBoost工具支持并行。boosting不是一种串行的结构吗?怎么并行的?注意XGBoost的并行不是tree粒度的并行,XGBoost也是一次迭代完才能进行下一次迭代的(第t次迭代的代价函数里包含了前面t-1次迭代的预测值)。XGBoost的并行是在特征粒度上的。我们知道,决策树的学习最耗时的一个步骤就是对特征的值进行排序(因为要确定最佳分割点),XGBoost在训练之前,预先对数据进行了排序,然后保存为block结构,后面的迭代中重复地使用这个结构,大大减小计算量。这个block结构也使得并行成为了可能,在进行节点的分裂时,需要计算每个特征的增益,最终选增益最大的那个特征去做分裂,那么各个特征的增益计算就可以开多线程进行。
- 可并行的近似直方图算法。树节点在进行分裂时,我们需要计算每个特征的每个分割点对应的增益,即用贪心法枚举所有可能的分割点。当数据无法一次载入内存或者在分布式情况下,贪心算法效率就会变得很低,所以XGBoost还提出了一种可并行的近似直方图算法,用于高效地生成候选的分割点。
One-hot encoding
在实际的机器学习的应用任务中,对于类别有序的类别型变量,比如 age 等,当成数值型变量处理可以的。特征有时候并不总是连续值,有可能是一些分类值,如性别可分为“male”和“female”。在机器学习任务中,对于这样的特征,通常我们需要对其进行特征数字化,比如有如下三个特征属性:
- 性别:[“male”,”female”]
- 地区:[“Europe”,”US”,”Asia”]
- 浏览器:[“Firefox”,”Chrome”,”Safari”,”Internet Explorer”]
对于某一个样本,如[“male”,”US”,”Internet Explorer”],我们需要将这个分类值的特征数字化,最直接的方法,我们可以采用序列化的方式:[0,1,3]。但是,即使转化为数字表示后,上述数据也不能直接用在我们的分类器中。因为,分类器往往默认数据是连续的,并且是有序的。按照上述的表示,数字并不是有序的,而是随机分配的。这样的特征处理并不能直接放入机器学习算法中。
为了解决上述问题,其中一种可能的解决方法是采用独热编码(One-Hot Encoding)。独热编码,又称为一位有效编码。其方法是使用N位状态寄存器来对N个状态进行编码,每个状态都由他独立的寄存器位,并且在任意时候,其中只有一位有效。可以这样理解,对于每一个特征,如果它有m个可能值,那么经过独热编码后,就变成了m个二元特征。并且,这些特征互斥,每次只有一个激活。因此,数据会变成稀疏的。
对于上述的问题,性别的属性是二维的,同理,地区是三维的,浏览器则是四维的,这样,我们可以采用One-Hot编码的方式对上述的样本“[“male”,”US”,”Internet Explorer”]”编码,“male”则对应着[1,0],同理“US”对应着[0,1,0],“Internet Explorer”对应着[0,0,0,1]。则完整的特征数字化的结果为:[1,0,0,1,0,0,0,0,1]。
为什么能使用One-Hot Encoding?
使用one-hot编码,将离散特征的取值扩展到了欧式空间,离散特征的某个取值就对应欧式空间的某个点。在回归,分类,聚类等机器学习算法中,特征之间距离的计算或相似度的计算是非常重要的,而我们常用的距离或相似度的计算都是在欧式空间的相似度计算,计算余弦相似性,也是基于的欧式空间。 将离散型特征使用one-hot编码,可以会让特征之间的距离计算更加合理。比如,有一个离散型特征,代表工作类型,该离散型特征,共有三个取值,不使用one-hot编码,计算出来的特征的距离是不合理。那如果使用one-hot编码,显得更合理。
独热编码优缺点
优点:独热编码解决了分类器不好处理属性数据的问题,在一定程度上也起到了扩充特征的作用。它的值只有0和1,不同的类型存储在垂直的空间。 缺点:当类别的数量很多时,特征空间会变得非常大。在这种情况下,一般可以用PCA(主成分分析)来减少维度。而且One-Hot Encoding+PCA这种组合在实际中也非常有用。
One-Hot Encoding的使用场景
独热编码用来解决类别型数据的离散值问题。将离散型特征进行one-hot编码的作用,是为了让距离计算更合理,但如果特征是离散的,并且不用one-hot编码就可以很合理的计算出距离,那么就没必要进行one-hot编码,比如,该离散特征共有1000个取值,我们分成两组,分别是400和600,两个小组之间的距离有合适的定义,组内的距离也有合适的定义,那就没必要用one-hot 编码。 基于树的方法是不需要进行特征的归一化,例如随机森林,bagging 和 boosting等。对于决策树来说,one-hot的本质是增加树的深度,决策树是没有特征大小的概念的,只有特征处于他分布的哪一部分的概念。
为什么不适合处理高维稀疏特征
本质XGBoost是一个贪婪算法 要从候选特征集合中选择一个使分裂后信息增益最大的特征来分裂。这就是他不适合处理稀疏数据的原因 ,会产生很多的子树,训练特别慢。同时id分裂的泛化性弱。这个时候LR就会比xgb强 ,但是LR没办法自动对特征进行组合。
XGBoost计算特征权重的方式有哪些 ?
一共五种,常用Gain:
- ‘weight’:权重形式,表示在所有树中,一个特征在分裂节点时被使用了多少次。
- ‘gain’:(平均)增益形式,表示在所有树中,一个特征作为分裂节点存在时,带来的增益的平均值。
- ‘cover’:(平均)覆盖度,表示在所有树中,一个特征作为分裂节点存在时,覆盖的样本数量的平均值。
- ‘total_gain’:相对于’gain’,这里表示的是带来的总增益大小。
- ‘total_cover’:相对于’cover’,这里表示的是覆盖的总样本数量。
动手实践
XGBoost优化的目标如下:
1. 损失函数用于描述模型拟合数据的程度
2. 正则想用于控制模型的复杂度
其中
e.g. 有一组
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import display
from graphviz import Digraph
X = np.array([[1, -5], [2, 5], [3, -2], [1, 2], [2, 0], [6, -5],
[7, 5], [6, -2], [7, 2], [6, 0], [8, -5], [9, 5],
[10, -2], [8, 2], [9, 0]]).transpose()
Y = np.array([[0], [0], [1], [1], [1], [1], [1], [0], [0],
[1], [1], [1], [0], [0], [1]]).reshape(-1)
pd.DataFrame(np.vstack([X, Y]), dtype='int', index=['x1', 'x2', 'y'])
我们的目标就是要让模型对15组数据预测的
Yhi = np.zeros_like(Y)
def ComputeGH(Yhi, Y):
t = 1/(1+np.exp(-Yhi))
G = t - Y
H = t*(1-t)
return G, H
G, H = ComputeGH(Yhi, Y)
pd.DataFrame(np.vstack([Yhi, Y, G, H]),
dtype='float', index=['y_hi', 'y', 'g', 'h'])
首先求出特征
def ComputeGain(G, H, Xc, lmd=1, gma=0., print_msg=False):
_Xu = np.sort(np.unique(Xc))
_gains = []
for x in _Xu:
GL = np.sum(G[Xc < x])
GR = np.sum(G[Xc >= x])
HL = np.sum(H[Xc < x])
HR = np.sum(H[Xc >= x])
gain = (GL**2/(HL+lmd) + GR**2/(HR+lmd) -
(GL+GR)**2/(HL+HR+lmd))/2 - gma
if print_msg:
print("x=%.2f, GL=%.2f, GR=%.2f, HL=%.2f, HR=%.2f)" %
(x, GL, GR, HL, HR))
_gains.append(gain)
return np.array(_gains), _Xu
def GSDataFrame(gains, splits):
"""以DataFrame格式输出gains、split points,并打上'max'标签
"""
col = ['-']*len(gains)
col[np.argmax(gains)] = 'max'
df = pd.DataFrame(np.round(np.vstack([splits, gains]), 3),
dtype='float', index=['split point', 'gain'], columns=col)
return df
XC1_gains, X1u = ComputeGain(G, H, X[0,:])
print('gains of x1:')
display(GSDataFrame(XC1_gains, X1u))
gains of x1:
s%%script false --no-raise-error
class Node():
def __init__(self):
self.h = []
self.g = []
def info():
首先求出特征
XC2_gains, X2u = ComputeGain(G, H, X[1,:])
print('gains of x2:')
display(GSDataFrame(XC2_gains, X2u))
第一次分裂
通过计算,
gz = Digraph()
gz.node("t1","x1 < 10")
gz.edge("t1","L")
gz.edge("t1","R")
gz
def Split(X, G, H, pos):
XGH = np.vstack([X, G, H])
return XGH[:, pos], XGH[:, ~pos]
sp = X1u[np.argmax(XC1_gains)] # split point
pos = X[0] < sp
L, R = Split(X, G, H, pos)
print('L info:')
display(pd.DataFrame(L, dtype='float', index=['x1L', 'x2L', 'hL', 'gL']))
print('R info:')
display(pd.DataFrame(R, dtype='float', index=['x1R', 'x2R', 'hR', 'gR']))
L info:
第二次分裂
因为树的深度(max_depth)设置为3,这时L和R成为了两个根节点,分别重复上面的步骤:
- L还有14个样本,所以此结点需要遍历x1L和x2L所有值,选取增益最大的点作为分裂点。
- R只有1个样本了,此结点不需要分裂了,成为一个叶子节结点,根据公式
计算其结点值(x1R)。
注意:
1.和之前已经计算过了,所以不需要重复计算。
2. 分裂是对样本而言的,所有样本其他特征(x2)也要在对应位置分裂。
Gj, Hj = R[2], R[3]
w = -Gj/(Hj+1)
print('leaf =', w[0])
leaf = -0.4
X1L_gains, X1Lu = ComputeGain(G=L[2], H=L[3], Xc=L[0])
print('gains of x1L at node L:')
display(GSDataFrame(X1L_gains, X1Lu))
gains of x1L at node L:
X2L_gains, X2Lu = ComputeGain(G=L[2], H=L[3], Xc=L[1])
print('gains of x2L at node L:')
display(GSDataFrame(X2L_gains, X2Lu))
计算结果显示应该以
sp = X2Lu[np.argmax(X2L_gains)] # split point
pos = L[1] < sp
L2, R2 = Split(L[0:2], L[2], L[3], pos)
print('L2 info:')
display(pd.DataFrame(L2, dtype='float', index=['x1L', 'x2L', 'hL', 'gL']))
print('R2 info:')
display(pd.DataFrame(R2, dtype='float', index=['x1R', 'x2R', 'hR', 'gR']))
第二次分裂完之后,我们画出第一棵树的情况(最后一层正圆是第三层分裂待求的):
gz = Digraph()
gz.node("t1", "x1 < 10")
gz.node("t2_L", "x2 < 2")
gz.node("t2_R", "leaf = -0.04", shape="box")
gz.edge("t1", "t2_L")
gz.edge("t1", "t2_R")
gz.edge("t2_L", "L2")
gz.edge("t2_L", "R2")
# 第三层分裂待求的
gz.node("t3_L1", "L3_1", shape='circle')
gz.node("t3_R1", "R3_1", shape='circle')
gz.node("t3_L2", "L3_2", shape='circle')
gz.node("t3_R2", "R3_2", shape='circle')
gz.edge("L2", "t3_L1")
gz.edge("L2", "t3_R1")
gz.edge("R2", "t3_L2")
gz.edge("R2", "t3_R2")
gz
第三层分裂
结点L2处的分裂
X1L_gains, X1Lu = ComputeGain(G=L2[2], H=L2[3], Xc=L2[0])
print('gains of x1L at node L2:')
display(GSDataFrame(X1L_gains, X1Lu))
X1R_gains, X1Ru = ComputeGain(G=L2[2], H=L2[3], Xc=L2[1])
print('gains of x1R at node L2:')
display(GSDataFrame(X1R_gains, X1Ru))
计算结果显示应该以
sp = X1Lu[np.argmax(X1L_gains)] # split point
pos = L2[0] < sp
L3_1, R3_1 = Split(L2[0:2], L2[2], L2[3], pos)
print('split point', sp)
print('L3_1 info:')
display(pd.DataFrame(L3_1, dtype='float', index=['x1L', 'x2L', 'hL', 'gL']))
print('R3_1 info:')
display(pd.DataFrame(R3_1, dtype='float', index=['x1R', 'x2R', 'hR', 'gR']))
split point 2.0
L3_1 info:
因为我们设置的最大深度为3,所以不需要再分裂了,计算叶子结点值:
def CalLeaf(G, H, lmd=1):
return -np.sum(G)/(np.sum(H)+lmd)
w_L3_1 = CalLeaf(L3_1[2], L3_1[3])
w_R3_1 = CalLeaf(R3_1[2], R3_1[3])
w_L3_1, w_R3_1
print("leaf value: L3_1=%.2f, R3_1=%.2f"%(w_L3_1, w_R3_1))
leaf value: L3_1=-0.40, R3_1=0.91
结点R2处的分裂
X1L_gains, X1Lu = ComputeGain(G=R2[2], H=R2[3], Xc=R2[0], print_msg=False)
print('gains of x1L at node R2:')
display(GSDataFrame(X1L_gains, X1Lu))
gains of x1L at node R2:
X1R_gains, X1Ru = ComputeGain(G=R2[2], H=R2[3], Xc=R2[1])
print('gains of x1R at node R2:')
display(GSDataFrame(X1R_gains, X1Ru))
sp = X1Lu[np.argmax(X1L_gains)]
sp # 分裂点
2.0
计算结果显示应该以
# sp = X1Lu[np.argmax(X1L_gains)] # split point
sp = 9
pos = R2[0] < sp
L3_2, R3_2 = Split(R2[0:2], R2[2], R2[3], pos)
print('split point', sp)
print('L3_2 info:')
display(pd.DataFrame(L3_2, dtype='float', index=['x1L', 'x2L', 'hL', 'gL']))
print('R3_2 info:')
display(pd.DataFrame(R3_2, dtype='float', index=['x1R', 'x2R', 'hR', 'gR']))
split point 9
L3_2 info:
计算叶子结点值:
w_L3_2 = CalLeaf(L3_2[2], L3_2[3])
w_R3_2 = CalLeaf(R3_2[2], R3_2[3])
print("leaf value: L3_2=%.2f, R3_2=%.2f"%(w_L3_2, w_R3_2))
leaf value: L3_2=-0.22, R3_2=0.40
推荐阅读
基本原理:
- Tianqi Chen 2014:Introduction to Boosted Tree
- XGBoost、GBDT超详细推导
- xgboost原理分析以及实践
- 网络课程:Xgboost提升算法
深入分析:
- 一文读懂机器学习大杀器XGBoost原理
- XGBoost之类别特征的处理
- 灵魂拷问,你看过Xgboost原文吗?
实践部分:
- Python Demo:XGBoost: How it works, with an example.
- Python One Hot Encoding with SciKit Learn
- 训练Fasion Minst:From Zero to Hero in XGBoost Tuning
附录:Logistic Regression
Xs