XGBoost

实验目的

本实验要求学习XGBoost模型的原理,并实现该模型,以及在数据集上完成训练和测试。

实验原理

XGBoost的基本原理就是以决策树作为基学习器的加法模型,每棵树根据前t个模型的输出计算节点的权重,最终的输出为:

本实验的问题模型为回归问题,即$𝑙𝑜𝑠𝑠(𝑦_𝑖,𝑦 _𝑖^{(𝑡)} )=(𝑦_𝑖−\hat y _𝑖^{(𝑡)} )^2$。

对于损失loss,记 $g_i$ 为一阶导数,$h_i$ 为二阶导数,即$g_i=\frac{\partial\ loss(y_i,y _i^{(t-1)})}{\partial\ y _i^{(t-1) } }=2(\hat y^{t-1}-y)$, $h_i=\frac{\partial^2 loss(y_i,y _i^{(t-1)} )}{\partial \ (y _i^{(t-1)} )^2 }=2\\ $。

在训练第t棵决策树时,目标函数为$Obj^{(t)}=∑_{i=1}^n[g_i f_t (x_i )+\frac12 h_i f_t^2 (x_i)]+penalty(f_t )$

对于一棵决策树,其惩罚为:

将分配到第 $j$ 个叶子节点的样本用 $I_j$ 表示,即 $I_j=\{i|q(x_i )=j\} (1≤j≤T)$,在树结构确定时可以进行如下优化:

求解$w$ :$w_j^*=-\frac{G_j}{H_j+\lambda}$,此时决策树的得分:$Obj^{(t)}=-\frac12\sum^{T}_{j=1}\frac{G_j^2}{H_j+\lambda}+\gamma T$

实验步骤

读取数据和数据处理

数据文件中的各项数据均不含有NULL项,无需进行特殊处理。

df = pd.read_csv('train.data')
df.shape[1]
df = pd.read_csv('train.data',names=[i for i in range(df.shape[1])])
#重新读取数据,防止将第一行的数据识别为索引
Data=np.array(df)
a=int(0.3*Data.shape[0])
Data=np.c_[Data,np.zeros(Data.shape[0]).T]
num=np.random.permutation(Data.shape[0])
Data=Data[num]
test_data=Data[0:a-1,:]
train_data=Data[a:,:]
#按照7:3的比例划分训练集和测试集

在数据集的基础上添加一列0,用来存储每轮训练结束后的预测值,即data=[:,-2]代表真实值,data[:,-1]代表预测值。

回归树模型

超参数:

maxdepth=4,minscore=0,minindex=3,lambda=800,gamma=1e-7

class RegressionTree:
    def __init__(self) :
       ...初始化
    
    def Leaf(self,data):#叶节点
        G=(2*np.sum(data[:,-1]-data[:,-2]))
        H=2*data.shape[0]
        return -G/(H+self.lam)
    
    def Obj(self,data):#计算得分
        G=(2*np.sum(data[:,-1]-data[:,-2]))
        H=2*data.shape[0]
        obj=-G**2*0.5/(H+self.lam)+self.gamma
        return obj

    def split(self,data,num,val):#根据num和val找到最佳划分
        lchild=data[np.nonzero(data[:,num]<=val)[0],:]
        rchild=data[np.nonzero(data[:,num]>val)[0],:]
        return lchild,rchild

    def ChooseSplit(self,data,depth):
        """
        对样本集进行划分,找到最佳划分
        """
        if data.shape[0]<self.minindex or depth>self.maxdepth:
            return -1,self.Leaf(data)
        if len(set(data[:,-2].T.tolist()))==1:
            return -1,self.Leaf(data)
        pobj=self.Obj(data)
        maxscore=-1
        maxnum=0
        maxval=0
        n,m=data.shape
        for i in range(m-2):
            n=data[:,i].T.tolist()
            for j in set(n):
                lchild,rchild=self.split(data,i,j)
                if len(lchild)==0 or len(rchild)==0:
                    continue
                score=-self.Obj(lchild)+self.Obj(rchild)+pobj
                if score>maxscore:
                    maxscore=score
                    maxnum=i
                    maxval=j
        if maxscore<=self.minscore:
            return -1,self.Leaf(data)
        return maxnum,maxval

    def CreateTree(self,data,depth):
        num,val=self.ChooseSplit(data,depth)
        if num==-1:
            return val
        lchild,rchild=self.split(data,num,val)
        Tree={}
        """
        如果不是叶节点,则以字典形式建树,否则节点只存储叶子的值
        """
        Tree['num']=num
        Tree['val']=val
        Tree['lchild']=self.CreateTree(lchild,depth+1)
        Tree['rchild']=self.CreateTree(rchild,depth+1)
        return Tree

其中,决策树的非叶子节点有属性:

  • num,val:在该结点处选取第num个属性值进行划分,最佳划分点为val
  • lchild,rchild:分别指向第num个属性小于等于val和大于val的子树

对于单棵决策树的停止策略:

  • maxdepth:每次划分记录树的高度,深度为maxdepth时直接当作叶子节点
  • minindex:每次划分时,如果当前节点的样本数小于minindex,则直接作为叶子节点
  • minscore:若每次划分的最佳划分的得分小于等于minscore时,直接作为叶子节点

优化ChooseSpilt函数。为了减少每次对得分Obj的计算时存在的重复计算,将函数的核心部分做以下修改:(将数据按照当前属性进行排序,每次选取的属性值依次增大,因此左右两孩子的g,h线性增/减,对于左孩子:$\Delta g=\sum _{data[j,i]=val}(data1[j,-1]-data1[j,-2])2.0$,$\Delta h=2\sum_{data[j,i]=val}$)

gl=0
hl=0
gr=(2*np.sum(data[:,-1]-data[:,-2]))
hr=2*n
data1=data[data[:,i].argsort()]
for j in range(n-1):
    gl+=(data1[j,-1]-data1[j,-2])*2.0
    gr-=(data1[j,-1]-data1[j,-2])*2.0
    hl+=2
    hr-=2
    if data1[j,i]==data1[j+1,i]: 
        continue
    obj1=-gl**2*0.5/(hl+self.lam)+self.gamma
    obj2=-gr**2*0.5/(hr+self.lam)+self.gamma
    score=pobj-obj1-obj2
    if score>maxscore:
        maxscore=score
        maxnum=i
        maxval=data1[j,i]

XGBoost模型

超参数:treenum=30

class XGBoost:
    def __init__(self,data,treenum=30):
        ...
    def isTree(self,tree):#判断节点的类型,非叶子节点为字典类型,叶子节点为float类型
        return type(tree).__name__=='dict'
        
    def find(self,data,i,tree):#对于单个样本,找到所在决策树的位置
        if self.isTree(tree):
            if data[i,tree["num"]]<=tree['val']:
                return self.find(data,i,tree['lchild'])
            else:
                return self.find(data,i,tree['rchild'])
        else:
            return tree
    
    def fit(self):
        """
        训练函数,其中共训练了treenum棵树,调用RegressionTree,并计算损失和相关系数
        """
        loss=[]
        r2=[]
        va=(np.var(self.data[:,-2]))
        for i in range(self.treenum):
            print(i)
            tree=Rtree.CreateTree(self.data,1)
            for j in range(self.length):
                self.data[j,-1]+=self.find(self.data,j,tree)
            r=np.sqrt(np.sum((self.data[:,-2]-self.data[:,-1])**2/self.length))
            r2.append(1-r**2/va)
            lo=np.sum((self.data[:,-2]-self.data[:,-1])**2)
            loss.append(lo)
            self.forest.append(tree)
        return loss,r2
        
    def predict(self,data):
        m,n=data.shape
        for i in range(self.treenum):
            for j in range(m):
                test_data[j,-1]+=self.find(data,j,self.forest[i])

训练模型并实现损失函数可视化

Rtree=RegressionTree()
boost=XGBoost(train_data)
loss,r2=boost.fit()
boost.predict(test_data)
x = np.arange(1,len(loss)+1)
plt.plot(x,loss)
plt.xlabel(u"times")
plt.ylabel(u"loss-value")
plt.show()

模型预测并评估结果

结果评估指标:

  • $RMSE=\sqrt{\frac1n\sum_{i=1}^n{(y-\hat y)^2}}$
  • $R2=1-\frac{RMSE}{var(y)}$
  • 误差分布直方图
r=np.sqrt(np.sum((test_data[:,-2]-test_data[:,-1])**2/test_data.shape[0]))
r2=(1-r**2/np.var(test_data[:,-2]))
res=test_data[:,-1]-test_data[:,-2]
res=np.sort(res)
plt.hist(res, bins=30, rwidth=0.9, density=True)
plt.xlabel(u"data")
plt.ylabel(u"num")
plt.show()
print(test_data[:,-1]-test_data[:,-2])
print(r,r2)

实验结果与分析

(调参过程略)