boost_then_convolve_gradient_b

当GBDT遇上GNN,故事会怎样发生


一、写在前面

emmm 这其实是一篇 ICLR 2021 的论文 的阅读笔记,原文见:https://arxiv.org/abs/2101.08543

如果你曾经思考过以下几点中的任意一点,相信你会对本文有一丝兴趣:

相信所有偏Data Mining的算法工程师(非偏CV/NLP)或参加过结构化数据竞赛的盆友们,普遍都遇到过这个问题:针对宽表类的Tabular数据,为什么神经网络模型通常情况下都battle不赢树模型,即使某些场景能赢,大多数也只是略胜一筹?而在CV或NLP领域,却可以呈现出传统模型被深度学习模型吊打的局面?

在金融风控领域,黑产团伙挖掘是非常关键的风控手段。近年来,图神经网络在这块有着较佳的表现,究其原因,是因为其能够聚合邻居信息来更好的产出节点表征。但最终落地的时候,我们往往是将图模型产出的embedding作为树模型特征的补充,进而给最终的性能带来增益。但感觉就...很不优雅,有没有更优雅的方式呢?

接上面一点,由于树模型和神经网络本身的原理和训练方式不同,树模型和神经网络的结合,往往是 Two Stage 的,有没有办法做到 End to End 呢?

我也是抱着这样的疑问,尝试着去检索相关的前沿论文,后来发现了这篇文章,感觉还挺让我眼前一亮,因此有了这篇BGNN论文及其源码的阅读笔记。接下来让我们一起看下,针对以上几点,这篇文章给出的答案是如何的吧~

二、Abstract

In this work, we propose a novel architecture that trains GBDT and GNN jointly to get the best of both worlds: the GBDT model deals with heterogeneous features, while GNN accounts for the graph structure. Our model benefits from end- to-end optimization by allowing new trees to fit the gradient updates of GNN.

简而言之,这篇文章提出了一种新的网络结构可以将GBDT和GNN以端到端的形式融合到一块进行模型训练,并且结合了各自的优点,使得模型达到了最优的表现。让我们继续往下看,看看作者是怎样产出这样的idea并且加以实现的。

三、Observations: 树模型和图模型各有优劣

此前关于GNN的研究,其节点的特征大多都是同质且稀疏的,如one-hot编码、embedding或词袋表征。然而,在实际工业应用的数据集中,大多都是异质的tabular data,其在数据类型、量纲、缺失值等方面有着巨大的不同。

目前在该类异质数据上应用最为广泛的是GBDT模型,GBDT模型有着以下优势:

a) they efficiently learn decision space with hyperplane-like boundaries that are common in tabular data

b) they are well-suited for working with variables of high cardinality, features with missing values and of different scale

c) they provide qualitative interpretation for decision trees (e.g., by computing decrease in node impurity for every feature) or for ensembles via post-hoc analysis stage

d) in practical applications they mostly converage faster even for large amounts of data

而图模型不仅考虑了节点本身的特征外,还考虑了与节点相关的其他关联节点的信息。不像GBDT模型只能在单点基于节点自身特征上做预测,图模型可利用节点本身特征和拓扑结构作出更为精准的预测。GNN有着以下优势:

a) relational inductive bias imposed in GNNs alleviates the need to manually engineer features that capture the topology of the network

b) the end-to-end nature of training neural networks allows multi-stage or multi-component integration of GNNs in application-dependent solutions

c) pretaining representations with graph networks enriches transfer learning for many valuable tasks

除文章中说的这些优缺点之外,个人觉得,从数据/特征/模型/应用角度来看的话,树模型和图模型(或者说是神经网络!)各有优劣:

1)数据角度

数据集大小:针对中小数据集,树模型往往能够取得更优的效果,神经网络容易陷入过拟合;而随着数据集规模的增大,对模型的表征能力要求提高,神经网络的优势也逐步体现,此时其实二者孰优孰劣不好说;

数据类型:这里的数据类型,是专指原始建模数据的类型,如文本、图像、表格类数据等。一般说来,对于文本和图像这类数据,近年来随着算力的提高,已经是各种Transformer、CNN网络结构变种的天下了,而表格类数据方面,则仍然是树模型占据着主导地位:

针对图像/文本这类数据,人工特征往往只是基于人的认知或专家经验得到,总有所局限,无法较好的捕捉原始数据形式中(像素或文本embedding)的局部相关性或语义信息。而神经网络基于大规模数据训练,可以自动学习捕捉到原始数据与标签之间的关联,相对人工特征往往可以更好的对数据进行表征,如:BERT可以学到文本的语义和语法,CNN可以通过卷积结构刻画图像的局部信息等。

针对表格类数据,每列数据本身已经是具有一定含义的特征了,而且这类特征往往与目标之间已经有较好的相关性了,此时树模型(如XGB/LGB等)已经可以很好的拟合和泛化了。

2)特征角度

特征类型:这里的特征类型,是指其是dense的统计特征还是sparse的类别特征,如果是统计类的特征,那么树模型可以做得很好,但如果是针对ID/Category类的高维稀疏特征,树模型就不及NN,NN可以通过Embedding层将高维稀疏的ID类特征转化为低维稠密且具有语义的向量。

特征异质性:NN之所以可以在CV/NLP领域取得较好性能的原因就是其特征都可以理解为是同质的(如CV的话都是基于图像像素,NLP的话都是针对词向量),而Tabular数据是异质的,在量纲、分布、缺失值等方面有着较大的不同,如果用NN建模,则需要进行预处理(如归一化、标准化等),而树模型则可以极好的针对异质特征进行建模。

3)模型角度

调参难度:树模型的调参更为简单。对于NN来说,调参是非常考验算法工程师的能力的。同样的NN结构,在不同的人手里,可能就是截然不同的性能表现。而树模型,不需要花费太多心思,有时候用一套祖传参数就可以得到较好的表现。

训练时间:这个就不用说了,树模型完胜。

4)应用角度

可解释性:其实是非常多人都在研究的一块领域,但从可解释性的角度来说,个人感觉还是树模型更胜一筹。毕竟其可以将每棵树扒出来告诉你,这棵树用到的特征和阈值,以及为什么得到了这样的结论。而NN,当你告诉我NN的这一层Layer的参数值是这个的时候,简单的全连接网络倒还好,我可以知道它对不同特征赋予了不同的权重大小,但要是复杂一点的xDeepFM等,我真的也不知道其到底代表了什么含义。

四、Contributions: Boost+GNN=BGNN

是否有模型可以同时拥有树模型和GNN各自的优点呢?本篇文章给出了它的答案:BGNN,该模型集成了GBDT和GNN,使得模型即拥有GBDT对tabular特征表征的能力,又具备GNN对拓扑结构刻画和邻居节点聚合的能力。

BGNN,that combines GBDT's learning on tabular node features with GNN that refines predictions utilizing the graph's topology. This allows BGNN to inherit the advantages of gradient boosting methods (heterogeneous learning and interpretability) and graph networks (representation learning and end-to- end training).

相信每个人读到这里,都会心生疑问,树模型这样的加法模型,是怎样和依托于反向传播进行训练的GNN进行结合的,二者一来模型原理和学习目标不同,二来模型参数和更新机制也完全不一致,到底BGNN是如何将二者统一并做到端到端的呢?

五、Method: GBDT Meets GNN

最直接的做法是将整个模型训练过程变成two stage的,即先用原始数据训练得到GBDT,然后将GBDT的预测结果和原始特征进行concat之后作为GNN的输入,用来训练GNN。这种做法可以让 graph-insensitive 的GBDT的结果经过GNN的 refine 后得到更为准确的结果。这种做法在文章中被称之为Res-GNN,已经可以在很多任务中提升GNN的效果。

然而,这里的GBDT结果是基于单节点信息得到的,忽略了节点本身的拓扑信息,很可能GBDT的结果是有所偏颇并影响GNN的训练。有没有办法实现GBDT和GNN的端到端训练呢?这里BGNN给出了他们的答案:

具体而言,整个训练过程虽然是端到端的,但每一轮的epoch大抵分为两个阶段:

阶段一:先训练GBDT,如果是第1个epoch,则以最终拟合目标Y为标签,预测结果直接作为GNN的输入;如果不是第1个epoch,则以当轮GNN处训练前后特征的残差作为标签,而后将该轮预测结果加此前历史轮的预测结果作为下一轮epoch中GNN的输入;

阶段二:训练GNN,将GBDT的输入特征也作为参数的一部分参与GNN训练的反向传播,然后将输入特征在该轮GNN训练结束前后的残差作为下一个epoch的GBDT的标签。

总体的思路其实非常的直观,关于GBDT和GNN在BGNN的角色谁是主谁是次,我觉得两种理解都可以:

六、我认为该方法存在的不足

看完源码之后,可以发现,图模型的输入其实有两种,一种是树模型直接的输出结果,另一种是 原始特征和树模型预测结果 的concat,这两种方法我觉得都有其不足之处,首先如果直接用树模型的输出结果作为特征的话,图模型计算时候的特征就非常少,回归任务只有1维,分类任务,分k类的话就是有k维,这样就得通过简化图模型的网络结构等方式防止图模型过拟合现象;而将原始特征和树模型预测结果进行concat的话,虽说相比第一种是更优的实现方案,但神经网络可能会极其依赖代表树模型预测结果的那几维特征,使得最后其他原始特征也不能发挥太大的作用。这里直接用树模型结果输出当作GNN输入是好还是不好,我感觉需要打个问号?或许用叶子结点是更优的方式,但是叶子结点的数量会随着树的新增而动态变化,这时候又该如何处理呢?

在自己的数据集上复现的过程中发现,GBDT训练需要全量数据集(因为每棵树的生成过程涉及到特征分裂点的选取,全量数据集才有意义),GNN的训练则可以通过batch来实现,因此这里在训练过程中,如果数据集比较小,则可以完全在GPU上进行训练,但数据集一大,就会发生显存OOM现象。这里能想到的一个可行的解决方案,是让树模型在CPU上训,让图模型在GPU上训,但这里就需要涉及到CPU和GPU的数据转换,较为繁琐。

七、Talk is cheap

Let us read the fucking source code :D

读完论文之后,我们再来读一下源码。主要讲一下BGNN.py吧:

git:https://github.com/dmlc/dgl/blob/master/examples/pytorch/bgnn/BGNN.py

其定义了一个BGNNPredictor类,用于Boost GNN的训练和预测:

Example

gnn_model=GAT(10,20,num_heads=5),bgnn=BGNNPredictor(gnn_model)metrics=bgnn.fit(graph,X,y,train_mask,val_mask,test_mask,cat_features)

可以看到,这个类已经封装的很好了,在使用的时候是很简单的。让我们看看这个类是怎么设计的:

init函数

def__init__(self,gnn_model,task='regression',loss_fn=None,trees_per_epoch=10,backprop_per_epoch=10,lr=0.01,append_gbdt_pred=True,train_input_features=False,gbdt_depth=6,gbdt_lr=0.1,gbdt_alpha=1,random_seed=0):self.device=torch.device('cuda:0'iftorch.cuda.is_available()else'cpu')self.model=gnn_model.to(self.device)self.task=taskself.loss_fn=loss_fnself.trees_per_epoch=trees_per_epochself.backprop_per_epoch=backprop_per_epochself.lr=lrself.append_gbdt_pred=append_gbdt_predself.train_input_features=train_input_featuresself.gbdt_depth=gbdt_depthself.gbdt_lr=gbdt_lrself.gbdt_alpha=gbdt_alphaself.random_seed=random_seedtorch.manual_seed(random_seed)np.random.seed(random_seed)

参数及含义如下:

树模型初始化和训练相关函数

definit_gbdt_model(self,num_epochs,epoch):ifself.task=='regression':catboost_model_obj=CatBoostRegressorcatboost_loss_fn='RMSE'else:ifepoch==0:# we predict multiclass probs at first epochcatboost_model_obj=CatBoostClassifiercatboost_loss_fn='MultiClass'else:# we predict the gradients for each class at epochs > 0catboost_model_obj=CatBoostRegressorcatboost_loss_fn='MultiRMSE'returncatboost_model_obj(iterations=num_epochs,depth=self.gbdt_depth,learning_rate=self.gbdt_lr,loss_function=catboost_loss_fn,random_seed=self.random_seed,nan_mode='Min')deffit_gbdt(self,pool,trees_per_epoch,epoch):gbdt_model=self.init_gbdt_model(trees_per_epoch,epoch)gbdt_model.fit(pool,verbose=False)returngbdt_modeldefappend_gbdt_model(self,new_gbdt_model,weights):ifself.gbdt_modelisNone:returnnew_gbdt_modelreturnsum_models([self.gbdt_model,new_gbdt_model],weights=weights)deftrain_gbdt(self,gbdt_X_train,gbdt_y_train,cat_features,epoch,gbdt_trees_per_epoch,gbdt_alpha):pool=Pool(gbdt_X_train,gbdt_y_train,cat_features=cat_features)epoch_gbdt_model=self.fit_gbdt(pool,gbdt_trees_per_epoch,epoch)ifepoch==0andself.task=='classification':self.base_gbdt=epoch_gbdt_modelelse:self.gbdt_model=self.append_gbdt_model(epoch_gbdt_model,weights=[1,gbdt_alpha])

这部分值得说的一点是,根据BGNN的原理,其在每轮epoch结束时,都会增加trees_per_epoch棵新树,因此,每轮epoch过后,都要执行下train_gbdt函数,则随之要执行fit_gbdt函数和init_gbdt_model 函数,这时候如果是回归函数,其实每次都用CatBoostRegressor即可,而如果是个分类任务的话,第一棵树,要执行的是个分类任务(以类别为标签),要用CatBoostClassifier,而后的树,所要拟合的是经过GNN结果得到的梯度,因此是回归任务,要用CatBoostRegressor,这里也要注意下self.base_gbdt属性只有在分类任务的时候才会有。

树模型更新特征和标签相关函数

defupdate_node_features(self,node_features,X,original_X):# get predictions from gbdt modelifself.task=='regression':predictions=np.expand_dims(self.gbdt_model.predict(original_X),axis=1)else:predictions=self.base_gbdt.predict_proba(original_X)ifself.gbdt_modelisnotNone:predictions_after_one=self.gbdt_model.predict(original_X)predictions+=predictions_after_one# update node features with predictionsifself.append_gbdt_pred:ifself.train_input_features:predictions=np.append(node_features.detach().cpu().data[:,:-self.out_dim],predictions,axis=1)# replace old predictions with new predictionselse:predictions=np.append(X,predictions,axis=1)# append original features with new predictionspredictions=torch.from_numpy(predictions).to(self.device)node_features.data=predictions.float().datadefupdate_gbdt_targets(self,node_features,node_features_before,train_mask):return(node_features-node_features_before).detach().cpu().numpy()[train_mask,-self.out_dim:]definit_node_features(self,X):node_features=torch.empty(X.shape[0],self.in_dim,requires_grad=True,device=self.device)ifself.append_gbdt_pred:node_features.data[:,:-self.out_dim]=torch.from_numpy(X.to_numpy(copy=True))returnnode_features

从这段代码可以体现出的是BGNN是如何将树模型的输出作为输入的,也就是文章中说的:

Possible update functions that we experiment with include concatenation with the original node features and their replacement by f1(X)

有两种方式:

直接将树模型的输出作为图模型的输入

将树模型的输出和原始的节点特征做拼接之后,再作为图模型的输入

使用哪种方式通过self.append_gbdt_pred来控制,并且要注意两个变量:self.in_dim和self.out_dim:

ifself.task=='regression':self.out_dim=y.shape[1]elifself.task=='classification':self.out_dim=len(set(y.iloc[test_mask,0]))self.in_dim=self.out_dim+X.shape[1]ifself.append_gbdt_predelseself.out_dim

然后,需要注意的就是,与普通的图模型不同,BGNN的GNN输入是可微的,即:

node_features=torch.empty(X.shape[0],self.in_dim,requires_grad=True,device=self.device)

因此,其优化器在定义时做了如下的操作:

definit_optimizer(self,node_features,optimize_node_features,learning_rate):params=[self.model.parameters()]ifoptimize_node_features:params.append([node_features])optimizer=torch.optim.Adam(itertools.chain(*params),lr=learning_rate)returnoptimizer

训练评估相关函数

deftrain_model(self,model_in,target_labels,train_mask,optimizer):y=target_labels[train_mask]self.model.train()logits=self.model(*model_in).squeeze()pred=logits[train_mask]ifself.loss_fnisnotNone:loss=self.loss_fn(pred,y)else:ifself.task=='regression':loss=torch.sqrt(F.mse_loss(pred,y))elifself.task=='classification':loss=F.cross_entropy(pred,y.long())else:raiseNotImplemented("Unknown task. Supported tasks: classification, regression.")optimizer.zero_grad()loss.backward()optimizer.step()returnlossdefevaluate_model(self,logits,target_labels,mask):metrics={}y=target_labels[mask]withtorch.no_grad():pred=logits[mask]ifself.task=='regression':metrics['loss']=torch.sqrt(F.mse_loss(pred,y).squeeze()+1e-8)metrics['rmsle']=torch.sqrt(F.mse_loss(torch.log(pred+1),torch.log(y+1)).squeeze()+1e-8)metrics['mae']=F.l1_loss(pred,y)metrics['r2']=torch.Tensor([r2_score(y.cpu().numpy(),pred.cpu().numpy())])elifself.task=='classification':metrics['loss']=F.cross_entropy(pred,y.long())metrics['accuracy']=torch.Tensor([(y==pred.max(1)[1]).sum().item()/y.shape[0]])returnmetricsdeftrain_and_evaluate(self,model_in,target_labels,train_mask,val_mask,test_mask,optimizer,metrics,gnn_passes_per_epoch):loss=Nonefor_inrange(gnn_passes_per_epoch):loss=self.train_model(model_in,target_labels,train_mask,optimizer)self.model.eval()logits=self.model(*model_in).squeeze()train_results=self.evaluate_model(logits,target_labels,train_mask)val_results=self.evaluate_model(logits,target_labels,val_mask)test_results=self.evaluate_model(logits,target_labels,test_mask)formetric_nameintrain_results:metrics[metric_name].append((train_results[metric_name].detach().item(),val_results[metric_name].detach().item(),test_results[metric_name].detach().item()))returnloss

这里就比较普通,相信大家看一下就会懂,没有太多好讲的了。

你可能感兴趣的:(boost_then_convolve_gradient_b)