赞
踩
前面讲到提升树算法是逐步添加基本CART树,后一个添加的树是基于已有组合模型预测结果残差来构建的。相比于以往的CART树最根本的差别就是拥有多颗树进行综合预测,每棵树的预测结果仅仅是最终结果的累加量。
GB(梯度提升)算法就是基于提升算法,用目标函数的梯度作为下一轮的训练目标,这样适合多种采用不同损失函数的情况,而不仅仅局限于平方误差损失函数(梯度即为残差)。
GBDT即采用决策树(通常为CART回归树)作为基本分类器的GB算法。
GBDT算法是一种加法模型,即逐步添加树以使得目标函数(或称代价函数,即损失函数之和)值最大程度减小。此时的目标函数没有正则化项,仅仅是损失函数(通常是平方损失函数)值之和,这样容易造成过拟合,因为对于回归树一直细分下去损失函数一直减小,树的深度却越来越大。
XGBoost算法在GBDT算法的基础上给代价函数加一个正则化项(XGBoost又被称为正则化的GB),并且对目标函数进行二阶泰勒近似。然后,采用精确或近似方法贪心搜索出得分最高的切分点(维度+值),进行下一步切分并扩展叶节点。这样做,一方面保证最小化损失函数的过程中,树结构不会过于复杂而产生过拟合,另一方面加快计算速度。
n
n
n:样本的个数
m
m
m:特征的个数
i
i
i:样本索引
X
i
X_i
Xi:单个样本输入
j
j
j:基本树上叶节点索引
I
j
I_j
Ij:叶节点拥有的样本集合
l
l
l:损失函数,参数
y
i
y_i
yi是实际值(常量)
L
\mathcal{L}
L:目标函数(代价函数),其中
Ω
\Omega
Ω是正则化项
q
q
q:树结构,即样本到叶节点的映射
f
t
f_t
ft:新加入模型中的基本树,下标
t
t
t表示第
t
t
t次迭代
T
T
T:叶节点个数
g
i
g_i
gi:泰勒展开一阶导数,即损失函数对已有加法模型预测值
y
t
−
1
y_{t-1}
yt−1处的导数
h
i
h_i
hi:泰勒展开二阶导数
w
i
w_i
wi:新添加基本树在叶节点
i
i
i上的预测输出值
第t次迭代时总的目标函数(包含常数项)如下:
式中,
y
i
^
t
−
1
\hat{y_i}^{t-1}
yi^t−1为当前加法模型的预测值,为自变量,
f
t
(
X
i
)
f_t(X_i)
ft(Xi)为新添加基本树预测的值,相当于损失函数自变量增量。
对损失函数进行二阶泰勒展开如下:
由于损失函数中
l
(
y
i
,
y
i
^
t
−
1
)
l(y_i,\hat{y_i}^{t-1})
l(yi,yi^t−1)为常量,可将其去除,后续仅仅考虑新添加的基本树是否合理即可,得到第t次迭代添加的基本树对最终目标函数的贡献(前一部分为负,使预测误差减小;后一部分为正,使得树的复杂度增加):
再代入正则化项得
上文公式中叶节点的权重
w
j
w_j
wj即该叶节点的输出,目标函数中
1
2
(
∑
i
∈
I
j
h
i
+
λ
)
w
j
2
+
(
∑
i
∈
I
j
g
i
)
w
j
\frac{1}{2}(\sum_{i\in I_j}{h_i+\lambda})w_j^2+(\sum_{i\in I_j}{g_i})w_j
21(∑i∈Ijhi+λ)wj2+(∑i∈Ijgi)wj是一个二次函数,取得最小值时,
w
j
=
−
∑
i
∈
I
j
g
i
∑
i
∈
I
j
h
i
+
λ
w_j=-\frac{\sum_{i\in I_j}{g_i}}{\sum_{i\in I_j}{h_i+\lambda}}
wj=−∑i∈Ijhi+λ∑i∈Ijgi
上文推导出构建单棵基本树的标准,即使得
L
^
(
t
)
(
q
)
\hat{\mathcal{L}}^{(t)}(q)
L^(t)(q)最小的树结构 q 就是最优的基本树结构。基于这个标准,接下来需要通过逐步切分叶节点来扩展树。搜索切分点的方法可以分为精确搜索以及近似搜索。精确搜索充分考虑每个切分点,而近似搜索基于某些技巧,仅仅考虑部分切分点。
基本树的构建:贪心算法从根节点开始逐步添加分枝,针对一个叶节点,可以将其视为新的根节点进行分枝。分枝前后代价函数值之差就是该切分点得分(即切分后该基本树的目标函数值减小更多)。显然要寻找得分最大的切分点。
前面已经说完树的构建问题,即训练的过程,得到了一系列树,且每棵树的所有叶节点都有相应的权重 w j w_j wj。预测过程中,当某个样本从树根经过层层if-then判断到达叶节点时,该叶节点的权重也就是相应的输出值。对于回归问题,每棵树的输出权重之和即为预测值,对于分类问题,二分类用logistic转化为属于正类的概率值(权重为负意味着在该棵树中该节点输出负类),多分类用SoftMax转化为每个类的概率值即可。
Boosting算法可以分两个步骤进行。第一,基于上一轮模型的预测结果(如提升树算法选用预测残差)构建基本树;第二,将新基本树加入加法模型中。XGBoost相比GBDT方法的改进有如下三点:
Shrinkage(缩减): Shrinkage类似于学习率,即对每棵树的学习残差目标进行打折,从而降低当前树的影响程度。例如,第t次迭代的残差为
Δ
\Delta
Δ,缩减后为
η
Δ
\eta\Delta
ηΔ。
Feature Subsampling: 最先应用在随机森林中,即随机从所有特征中选取几个特征。
min_samples_split:对一个节点劈裂的条件之一就是该节点包含样本数大于min_samples_split。该值过高则可能过拟合,过小则欠拟合。
min_samples_leaf:叶节点包含的最少样本数。参数目的同min_samples_split。通常训练集中类不均衡时,该值需要设置小点,以防止小类被大类覆盖。
min_weight_fraction_leaf:同min_samples_leaf,只不过改成叶节点包含样本数量占总样本的比例下限。两者仅设其一。
max_depth: 树的最大深度,不容易调整,通常要采用CV(cross validate)方式。
max_leaf_nodes: 叶节点最大数量。作用同最大深度。两者仅设其一。
max_features: 寻找最优切分点时,考虑的最大特征的个数。随机选取。经验规则是设为总特征数量的开方,但在调参过程中考虑总数的30%-40%范围。该超参设置过高可能导致过拟合。
learning_rate:确定了每棵基本树的输出结果在最终结果中的影响权重。通常较小的值使得模型鲁棒性更好,但需要更多的基本树,导致计算量较大。
n_estimators:基本基本树的个数。
subsample:从总的训练数据集中挑选样本的比例,以构建某个基本树的专有的训练数据集。通常设置为0.8。
loss: 损失函数。通常默认的效果不错。
init: 将已有的决策树设为基本树。
warm_start:设置为True则表示可以借用前面已经训练好的基本树训练接下来添加的基本树。合理设置可以加快训练速度。
参考资料2中,Aarshay分享了他的调参经验,核心的思想是先调节影响最大的参数,再调节次要的参数。
使用何种基本模型。通常是决策树或线性模型。
基本树必需的超参数。
gamma: 正则化项T前系数。
reg_lambda: L2正则化项前系数。
reg_alpha:L1正则化项前系数。
min_child_weight :节点包含样本的权重之和的下限。即如果某个节点所有样本的权重之和低于该值,则不劈裂。其作用类似于GB的min_samples_leaf。
colsample_bytree:构建某棵基本树时,从所有特征中随机抽取特征数量比例。
colsample_bylevel: 选择最优切分点时,考虑的特征数量所占比例。其作用类似于GB中的max_features。
其他参数参考GB。
学习过程必须的超参数。目标函数形式、模型评价标准等。
参考GB的调参过程。对于XGBoost, max_depth、min_child_weight以及gamma是影响基本树预测准确率最为重要的三个因素。
以下用Python实现简单的XGBoost,损失函数为平方误差损失函数,采用精确搜索切分点。
# 实现XGBoost回归, 以MSE损失函数为例 import numpy as np class Node: def __init__(self, sp=None, left=None, right=None, w=None): self.sp = sp # 非叶节点的切分,特征以及对应的特征下的值组成的元组 self.left = left self.right = right self.w = w # 叶节点权重,也即叶节点输出值 def isLeaf(self): return self.w class Tree: def __init__(self, _gamma, _lambda, max_depth): self._gamma = _gamma # 正则化项中T前面的系数 self._lambda = _lambda # 正则化项w前面的系数 self.max_depth = max_depth self.root = None def _candSplits(self, X_data): # 计算候选切分点 splits = [] for fea in range(X_data.shape[1]): for val in X_data[fea]: splits.append((fea, val)) return splits def split(self, X_data, sp): # 劈裂数据集,返回左右子数据集索引 lind = np.where(X_data[:, sp[0]] <= sp[1])[0] rind = list(set(range(X_data.shape[0])) - set(lind)) return lind, rind def calWeight(self, garr, harr): # 计算叶节点权重,也即位于该节点上的样本预测值 return - sum(garr) / (sum(harr) + self._lambda) def calObj(self, garr, harr): # 计算某个叶节点的目标(损失)函数值 return (-1.0 / 2) * sum(garr) ** 2 / (sum(harr) + self._lambda) + self._gamma def getBestSplit(self, X_data, garr, harr, splits): # 搜索最优切分点 if not splits: return None else: bestSplit = None maxScore = -float('inf') score_pre = self.calObj(garr, harr) subinds = None for sp in splits: lind, rind = self.split(X_data, sp) if len(rind) < 2 or len(lind) < 2: continue gl = garr[lind] gr = garr[rind] hl = harr[lind] hr = harr[rind] score = score_pre - self.calObj(gl, hl) - self.calObj(gr, hr) # 切分后目标函数值下降量 if score > maxScore: maxScore = score bestSplit = sp subinds = (lind, rind) if maxScore < 0: # pre-stopping return None else: return bestSplit, subinds def buildTree(self, X_data, garr, harr, splits, depth): # 递归构建树 res = self.getBestSplit(X_data, garr, harr, splits) depth += 1 if not res or depth >= self.max_depth: return Node(w=self.calWeight(garr, harr)) bestSplit, subinds = res splits.remove(bestSplit) left = self.buildTree(X_data[subinds[0]], garr[subinds[0]], harr[subinds[0]], splits, depth) right = self.buildTree(X_data[subinds[1]], garr[subinds[1]], harr[subinds[1]], splits, depth) return Node(sp=bestSplit, right=right, left=left) def fit(self, X_data, garr, harr): splits = self._candSplits(X_data) self.root = self.buildTree(X_data, garr, harr, splits, 0) def predict(self, x): def helper(currentNode): if currentNode.isLeaf(): return currentNode.w fea, val = currentNode.sp if x[fea] <= val: return helper(currentNode.left) else: return helper(currentNode.right) return helper(self.root) def _display(self): def helper(currentNode): if currentNode.isLeaf(): print(currentNode.w) else: print(currentNode.sp) if currentNode.left: helper(currentNode.left) if currentNode.right: helper(currentNode.right) helper(self.root) class Forest: def __init__(self, n_iter, _gamma, _lambda, max_depth, eta=1.0): self.n_iter = n_iter # 迭代次数,即基本树的个数 self._gamma = _gamma self._lambda = _lambda self.max_depth = max_depth # 单颗基本树最大深度 self.eta = eta # 收缩系数, 默认1.0,即不收缩 self.trees = [] def calGrad(self, y_pred, y_data): # 计算一阶导数 return 2 * (y_pred - y_data) def calHess(self, y_pred, y_data): # 计算二阶导数 return 2 * np.ones_like(y_data) def fit(self, X_data, y_data): step = 0 while step < self.n_iter: tree = Tree(self._gamma, self._lambda, self.max_depth) y_pred = self.predict(X_data) garr, harr = self.calGrad(y_pred, y_data), self.calHess(y_pred, y_data) tree.fit(X_data, garr, harr) self.trees.append(tree) step += 1 def predict(self, X_data): if self.trees: y_pred = [] for x in X_data: y_pred.append(self.eta * sum([tree.predict(x) for tree in self.trees])) return np.array(y_pred) else: return np.zeros(X_data.shape[0]) if __name__ == '__main__': from sklearn.datasets import load_boston from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error import matplotlib.pyplot as plt boston = load_boston() y = boston['target'] X = boston['data'] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42) f = Forest(50, 0, 1.0, 4, eta=0.8) f.fit(X_train, y_train) y_pred = f.predict(X_test) print(mean_absolute_error(y_test, y_pred)) plt.scatter(np.arange(y_pred.shape[0]), y_test - y_pred) plt.show()
注:代码未经严格测试,如有不当之处,请指正。
我的GitHub
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。