GBDT、XGBoost 与 LightGBM
本章目录
学完后,你应能独立完成
- 手写多轮平方损失树桩 boosting
- 区分收益残差、分类伪残差与 logit
- 推导二阶叶权重与分裂增益
前置建议:决策树与随机森林 · Logistic 与分类决策
改变一个条件,观察发生什么
浏览器内即时教学计算;Python 实验结果另列于代码区。图形与数值一起核对。
从一个常数开始逐步修正
平方损失下 F₀取训练目标均值。每轮不重新替换旧模型,而是增加一棵修正树。学习率 η控制步幅,轮数控制路径长度,树容量决定每轮能表达什么。三者共同决定复杂度。
- 先算旧预测 F,求风险对训练点预测的负梯度。
- 用允许容量的树拟合这些伪残差。
- 叶值可以对原损失做线搜索或二阶求解,不总是残差均值。
- 把树乘 η 后加回旧预测,保存模型与验证结果。
平方残差是一种特例
若 L=(y−F)²/2,负梯度为 y−F。y=[0,0,2,2]、F₀=1,树桩叶值−1/+1,η=.1后预测[.9,.9,1.1,1.1],MSE从1到.81。下一轮重新算残差,不能一直拟合第一轮残差。
二元交叉熵用 F作为 logit,p=σ(F),负梯度为 y−p,不是 y−F或错分标记。分类累加的是 logit,再经过 sigmoid得到概率;不能把一个叶输出当概率直接往旧概率上加。
二阶展开把叶值变成二次优化
XGBoost 的简化推导采用损失求和。令 g_i、h_i为旧预测处的一阶与二阶导数,新树叶值 w_j,G_j=Σg_i、H_j=Σh_i。合并每叶样本项,再加入 λw_j²/2 与 γJ。
- 二阶 Taylor 忽略与新树无关的常数。
- 在叶内把 g_i、h_i求和,因为同叶共享 w_j。
- 对 w_j求导,令 G_j+(H_j+λ)w_j=0。
- 要求 H_j+λ>0;额外 L1、单调约束或步幅约束会改变简式。
分裂增益来自优化后的目标差
把父叶分为左右叶,用父评分减子评分并扣掉新增一片叶子的惩罚。注意1/2、父项的负号和 γ。学习率在更新时另外乘,不能随意塞进这个简化的叶评分。
- G_L=−2,H_L=2,G_R=2,H_R=2,λ=1。
- 左右叶权重为 +2/3、−2/3;父 G=0。
- γ=.1时 Gain=4/3−.1≈1.2333。
- min_child_weight约束 Hessian和,不无条件等于最少行数。
库优化与算法原理分开学
直方图把连续特征映射到 bins,累计梯度与曲率,减少候选扫描。Leaf-wise优先分裂全树当前收益最大的叶,可能形成不平衡深路径。num_leaves、max_depth与叶最小样本分别控制不同方面。
XGBoost也可以使用直方图和不同生长策略,不能把品牌名当作唯一算法配置。GOSS对梯度采样,EFB针对稀疏互斥特征;CatBoost的ordered机制也不能替代金融时间边界。这些作为扩展理解,先掌握负梯度与合法验证。
怎样比较 RF、Ridge 与 boosting
RF基树可独立训练,boosting后轮依赖前轮;两者表达和随机化不同,但不能据此宣布任何市场中的固定赢家。使用同标签、同信息集、同开发折、同评价集合,记录各自容量和搜索预算。
页面演示固定训练点和平方损失树桩,用于理解算法更新。训练 MSE下降不是未来增量证据。真实实验在综合案例里做日期选择;内部随机早停关闭,轮数由开发折选择。
从算法到一次真实运行
代码在本地 Python 中执行,HTML 内展示参考实现和本轮运行结果。请先独立完成练习,再展开完整参考文件。
def best_split(x, y, min_leaf=1):
"""One-feature squared-error stump. None means no positive valid gain."""
x, y = np.asarray(x,float), np.asarray(y,float)
if x.ndim != 1 or y.shape != x.shape or min_leaf < 1:
raise ValueError("Expected matching 1D arrays and min_leaf>=1")
if len(x)==0 or not np.isfinite(x).all() or not np.isfinite(y).all():
raise ValueError("Expected nonempty finite data")
order = np.argsort(x,kind="stable"); xs, ys = x[order], y[order]
parent = np.sum((ys-ys.mean())**2)
best = None; best_sse = parent
for i in range(min_leaf, len(x)-min_leaf+1):
if xs[i-1] == xs[i]:
continue
left, right = ys[:i].mean(), ys[i:].mean()
sse = np.sum((ys[:i]-left)**2)+np.sum((ys[i:]-right)**2)
if sse < best_sse-1e-12:
best_sse = sse
best = (float((xs[i-1]+xs[i])/2), float(left), float(right), float(sse))
return best
def fit_boosting(x, y, rounds=10, rate=.2, min_leaf=1):
"""Square-loss additive stumps. Return initial value, trees, train path."""
if rounds < 0 or rate < 0 or not np.isfinite(rate):
raise ValueError("Invalid rounds or learning rate")
x, y = np.asarray(x,float), np.asarray(y,float)
initial = float(y.mean()); prediction = np.full(len(y),initial)
trees = []; history = [prediction.copy()]
for _ in range(rounds):
residual = y-prediction
split = best_split(x,residual,min_leaf)
if split is None:
tree = (None,float(residual.mean()),float(residual.mean()))
update = np.full(len(y),residual.mean())
else:
threshold,left,right,_ = split
tree = (threshold,left,right)
update = np.where(x<=threshold,left,right)
prediction += rate*update
trees.append(tree); history.append(prediction.copy())
return initial, trees, np.asarray(history)
def predict_boosting(x, initial, trees, rate):
prediction = np.full(len(x),initial,dtype=float)
for threshold,left,right in trees:
prediction += rate*(left if threshold is None else np.where(x<=threshold,left,right))
return prediction"""Standalone synthetic teaching example. Run locally with Python, not in the HTML."""
import json
import numpy as np
import pandas as pd
from scipy.special import expit
def clean(value):
if isinstance(value, dict): return {str(k):clean(v) for k,v in value.items()}
if isinstance(value,(list,tuple,np.ndarray)): return [clean(v) for v in value]
if isinstance(value,(np.integer,)): return int(value)
if isinstance(value,(float,np.floating)): return float(value) if np.isfinite(value) else None
if isinstance(value,(np.bool_,)): return bool(value)
return value
def print_result(result):
print(json.dumps(clean(result),ensure_ascii=False,allow_nan=False))
def best_split(x, y, min_leaf=1):
"""One-feature squared-error stump. None means no positive valid gain."""
x, y = np.asarray(x,float), np.asarray(y,float)
if x.ndim != 1 or y.shape != x.shape or min_leaf < 1:
raise ValueError("Expected matching 1D arrays and min_leaf>=1")
if len(x)==0 or not np.isfinite(x).all() or not np.isfinite(y).all():
raise ValueError("Expected nonempty finite data")
order = np.argsort(x,kind="stable"); xs, ys = x[order], y[order]
parent = np.sum((ys-ys.mean())**2)
best = None; best_sse = parent
for i in range(min_leaf, len(x)-min_leaf+1):
if xs[i-1] == xs[i]:
continue
left, right = ys[:i].mean(), ys[i:].mean()
sse = np.sum((ys[:i]-left)**2)+np.sum((ys[i:]-right)**2)
if sse < best_sse-1e-12:
best_sse = sse
best = (float((xs[i-1]+xs[i])/2), float(left), float(right), float(sse))
return best
def fit_boosting(x, y, rounds=10, rate=.2, min_leaf=1):
"""Square-loss additive stumps. Return initial value, trees, train path."""
if rounds < 0 or rate < 0 or not np.isfinite(rate):
raise ValueError("Invalid rounds or learning rate")
x, y = np.asarray(x,float), np.asarray(y,float)
initial = float(y.mean()); prediction = np.full(len(y),initial)
trees = []; history = [prediction.copy()]
for _ in range(rounds):
residual = y-prediction
split = best_split(x,residual,min_leaf)
if split is None:
tree = (None,float(residual.mean()),float(residual.mean()))
update = np.full(len(y),residual.mean())
else:
threshold,left,right,_ = split
tree = (threshold,left,right)
update = np.where(x<=threshold,left,right)
prediction += rate*update
trees.append(tree); history.append(prediction.copy())
return initial, trees, np.asarray(history)
def predict_boosting(x, initial, trees, rate):
prediction = np.full(len(x),initial,dtype=float)
for threshold,left,right in trees:
prediction += rate*(left if threshold is None else np.where(x<=threshold,left,right))
return prediction
if __name__ == "__main__":
x=np.arange(1,9,dtype=float); y=np.array([0.,0.,1.,2.,5.,5.,6.,9.])
initial,trees,history=fit_boosting(x,y,rounds=12,rate=.2)
final=predict_boosting(x,initial,trees,.2)
np.testing.assert_allclose(final,history[-1])
mse=np.mean((history-y)**2,axis=1)
assert np.all(np.diff(mse)<=1e-10)
constant,_,path=fit_boosting(np.ones(3),np.array([2.,3.,4.]),rounds=2,rate=.2)
print_result({"initial":initial,"first_tree":trees[0],"mse_by_round":mse,
"final_prediction":final,"constant_feature_prediction":path[-1]})
查看本轮本地运行记录 · 2026-10-12
{
"initial": 3.5,
"first_tree": [
4.5,
-2.75,
2.75
],
"mse_by_round": [
9.25,
6.527500000000001,
4.7851,
3.6699640000000002,
2.78081728,
2.1451337727999995,
1.6245676048383992,
1.2552452499044056,
0.9618954167732037,
0.7553603517911276,
0.580139255192635,
0.45205912245523483,
0.3523161641586665
],
"final_prediction": [
0.792553593565472,
0.792553593565472,
1.114787551909744,
1.9844843857383154,
4.87903177633569,
4.87903177633569,
5.768290121010318,
7.789267201539297
],
"constant_feature_prediction": [
3.0,
3.0,
3.0
]
}先回答,再看提示和答案
所有 GBDT 都拟合 y−F 吗?
一级提示
先对所用预测参数求导。
参考答案与条件
只有相应平方损失是这个普通残差。交叉熵对 logit 的伪残差是 y−p,其他损失按负导数求。
λ=1,G=−2,H=2 的叶权重多少?
一级提示
先写出目标、信息集和所需条件。
参考答案与条件
−G/(H+λ)=2/3;最终更新还要乘学习率。
把 learning_rate减半,不加轮数,一定泛化更好吗?
一级提示
先写出目标、信息集和所需条件。
参考答案与条件
不一定,可能总修正不足而欠拟合。需要共同评价轮数、容量与预算。
把知识迁移到一个新任务
不调用库,写多轮树桩 boosting,保存每轮预测并在新 x上重新累加。检查无合法分裂时常数叶、未排序输入和 η=0。再手推一组分类负梯度与二阶 Gain。
这里保存学习笔记,不在浏览器执行 Python。A/B/C 由你按独立完成程度填写。
参考与进一步阅读
在线文档可能比本地固定版本更新;API 行为以已验证代码和版本记录为准。
掌握状态与复习
关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。
记录尽量保存在当前浏览器,建议定期导出备份。