写在前面
耗时近三个月的腾讯广告算法大赛终于是落下帷幕了(好吧,只是对于我们来说结束了,前10还有决赛答辩)。这是我第一次接触算法竞赛和数据挖掘类的题目,本来的目标只是进个前20,所以也算是完成任务了。不过依然是非常可惜,毕竟距离rank10只有0.0002分的差距,没办法去到决赛现场结识各位大佬。
这里也要感谢我的两位队友istar和wujie~正是我们一起的努力才能有现在的结果,同时这一段时间对赛题的互相讨论也让我学到了很多。
由于wujie已经分享了我们队伍的整体方案(链接),因此这篇博客主要讲我这一部分的工作,主要是网络结构的调整,以及一些其他的(失败的)尝试。
1.赛题和数据
这一部分我在之前的博客就有讲过啦,这里就不再重复,详情请参考链接。
2.特征与输入
对于这个赛题我们没有做出特别有效的特征,可能这是我们输的一个原因。我自己用到的特征只有Word2Vec,DeepWalk和TfidfVectorizer。Word2Vec为最主要的特征,构成每个时间点的输入向量;DeepWalk用于和Word2Vec拼接,有大概一个千的提升;TfidfVectorizer我感觉当模型到达一定程度之后提升已经很少,不过我还是保留了它。
特征训练部分我就不详细说了,有兴趣的可以参考我们的源码。总的来说,Word2Vec和DeepWalk将每个广告的ID(包括creative、ad等)映射到向量空间,TfidfVectorizer为弱模型根据Tfidf得到的用户初步分类结果,即每个用户一个特征向量。
根据给出的数据集,每个用户有长度不等的广告点击序列,经过统计可以知道95%的序列长度都在100以下,因此只截取用户后100的点击序列。我根据点击序列构建了两个输入,inputs1为将点击序列的每一次点击广告的[creative, ad, product, advertiser, industry]所对应的特征向量拼接起来,inputs2则每次点击只有[ad, product, advertiser, industry]的特征。因此,我的输入序列有inputs1和inputs2两个。第三个输入inputs3则是TfidfVectorizer得到的向量。
import torch.nn as nn
emb_creative = nn.Embedding.from_pretrained(torch.from_numpy(vec_creative))
emb_ad = nn.Embedding.from_pretrained(torch.from_numpy(vec_ad))
emb_pro = nn.Embedding.from_pretrained(torch.from_numpy(vec_pro))
emb_adver = nn.Embedding.from_pretrained(torch.from_numpy(vec_adver))
emb_industry = nn.Embedding.from_pretrained(torch.from_numpy(vec_industry))
def emb_feature(data):
data = torch.tensor(data)
feature3 = statistics[data[0][0] - 1]
feature1 = torch.cat((emb_creative(data[:,1]),emb_ad(data[:,2]),emb_pro(data[:,3]),emb_adver(data[:,5]),emb_industry(data[:,6])),-1)
feature2 = torch.cat((emb_ad(data[:,2]),emb_pro(data[:,3]),emb_adver(data[:,5]),emb_industry(data[:,6])),-1)
return feature1, feature2, feature3
3.模型
在搭模型之前,首先要思考任务的特点。首先,输入为用户的点击序列,因此模型能够对变长序列进行处理,在NLP里面所用到的基本是LSTM(或者GRU)、CNN和Transformer。
LSTM有3种用法,最为经典的是将序列输入之后,取最后一个时间点的隐层向量作为该序列提取的特征,这样LSTM可以捕捉序列内部的变化规律。但是这种用法适合于序列有强烈的先后因果性,并且目标集中于预测下一时刻,如输入人体运动的视频来预测下一帧的动作,DIEN模型输入用户点击的商品序列来预测他对接下来商品的兴趣。DIEN的场景看起来跟赛题有点像,实则差距较大,用户点击商品是主动行为,跟商品之间的联系和用户的购买兴趣变化关系非常大,而赛题的点击广告则受广告出价,用户历史记录,投放界面上下文等影响较大,广告点击之间的因果性很弱。并且赛题的目标为分析用户的性别年龄,而非预测下一次点击的广告,因此该用法不太适用。
还有两种用法是序列输入后,对所有时间点隐层向量取mean或者max来提取序列特征。在这种情况下LSTM捕捉的不是序列的变化规律,而是统计规律,比如序列中有多少部分与目标相关等。这种用法是符合赛题特点的。至于mean和max的区别,在于输入的序列中与目标的关系程度,比如,用户所点击的广告是基本都能反映他的性别年龄,还是只有一小部分能反映。这里基本是靠尝试来决定哪个效果好,最后的结果是max更好,也比较符合我们一般的认知。
另外LSTM + max这种方式与序列顺序关系不大,因此可以用打乱的方式来进行数据增强。但是我在实验中注意到,如果只用打乱数据训练,效果会下降,另外用逆序数据的效果跟正序差不多。这些说明序列内部相邻的点击之间还是有一定联系,只不过这个联系非常的弱。因此我最后的基础模型是打乱 + LST)+ max,并且打乱的时候保证有一定的概率输入为正序或者倒序。
还有CNN和Transformer两个模型。其中CNN适用于相邻时间点联系非常紧密的序列,而这个赛题的数据明显不是,因此pass。Transformer在NLP的任务里面可谓是一统江山,但是它的优点之一——对超长序列建模——在这个任务里面是体现不出来的,因为我们只截取了100长的序列,而LSTM在100以下的长度完全够用。另外,我个人认为Transformer的self-attention机制与NLP任务契合度很高,这是因为文字具有指代性和多义性,而self-attention可以很好地捕捉这种特性,但是这些特点在广告点击里面似乎不明显。最后在我们的实验中,直接使用Transformer的效果确实不理想。可能会有人问,Transformer可以用BERT的方式来预训练啊?但是BERT的成功一是因为无监督数据比有监督数据多得多,二是它的完型填空的预训练方法对于语言来说非常合理,而这两点都与我们的赛题不符,因此估计最后效果不会很好(当然我们也没有去试hhh)。
说了这么多,来看一下我的最终模型吧。这是一张特别草的草图~
大家如果熟悉Transformer的话,就会发现,欸你不是说Transformer不好的吗,怎么还用它一摸一样的结构,难道人类终究逃不过真香?其实我上面的观点是它的self-attention不好用,但是它的其他结构比如multi-head和ffn还是好用的呀。这里可以推荐大家一篇论文How Much Attention Do You Need? A Granular Analysis of Neural Machine Translation Architectures,它分析了Transformer里面各个结构的效果。最终我的模型包括开头的encoder部分,中间的LSTM部分和最后的fc部分,其中encoder里面的特征提取器可以换成LSTM或GRU等。这个模型如果只用Word2Vec特征的话age单折是0.517,加上DeepWalk特征可以到0.519(五折的结果不记得了~)。
4.对抗训练
除了模型以外,还有一个比较有用的点就是对抗训练(这个不是GAN)。主要的思路就是通过生成对抗样本来训练网络,达到网络更好的鲁棒性。详细的介绍网上有挺多,大家搜一下就有,这里就不展开讲了。我用的是FGM的方法,将梯度回传到输入的embedding上,标准化之后再通过直接相加的方式构建对抗样本。因此在一个样本的对抗训练中,包括两次前向和后向,训练时间为2倍。可以在模型训练的后面阶段才加入对抗训练来减少时间。
def FGM(inputs1, inputs2, inputs3, length, labels, net, criterion, epsilon=1.):
inputs1.requires_grad = True
inputs2.requires_grad = True
outputs = net(inputs1, inputs2, inputs3, length)
loss = criterion(outputs, labels)
loss.backward(retain_graph=True)
grad_1 = inputs1.grad.detach()
grad_2 = inputs2.grad.detach()
norm_1 = torch.norm(grad_1)
norm_2 = torch.norm(grad_2)
if norm_1 != 0:
r_at = epsilon * grad_1 / norm_1
inputs1 = inputs1 + r_at
if norm_2 != 0:
r_at = epsilon * grad_2 / norm_2
inputs2 = inputs2 + r_at
outputs_adv = net(inputs1, inputs2, inputs3, length)
loss_adv = criterion(outputs_adv, labels)
loss_adv.backward()
return outputs, loss
加入对抗训练之后,age的结果大概可以提几个万到一个千,也就是说我的最终单折是接近0.519。
5.其他的一些尝试
5.1动态学习率
我最后用的是周期学习率,并且每个周期结束之后max_lr下降一半。它的好处是非常稳定,基本每次涨都是在周期结束,并且在哪个周期之后就不再涨也比较稳定。不过缺点是周期长度的影响比较大,需要好好调。
5.2标签调整
观察到模型的输出结果里面有部分用户正确标签的概率非常的低,猜测可能是标签本身有错误,于是对这一部分的标签进行了调整,最后过拟合了。
5.3GNN
尝试过构建一个用户和广告的二部图,然后用GNN来提取特征。基本思路是GNN的信息传播,先由广告传到用户,用户再传回广告调整原来的特征,最后传回用户得到输出分类。我用的是dgl的框架来写的,可是最后爆内存了没法跑。。。如果我自己写消息传播的话应该可以节省内存,只不过没有时间去做。下面是我的GNN代码,有试过如果节点数量少点的话是可以跑的。
class GNN(nn.Module):
def __init__(self, hidden_size):
super(GNN, self).__init__()
user_emb = cfg.user_emb
ad_emb = cfg.input_emb1
num_classes = cfg.output_dim
self.conv_ad_1 = nn.Linear(ad_emb, hidden_size, bias=False)
self.conv_user_1 = nn.Linear(user_emb, hidden_size, bias=False)
self.conv_1 = nn.Linear(hidden_size * 2, hidden_size, bias=False)
self.conv_ad_2 = nn.Linear(ad_emb, hidden_size, bias=False)
self.conv_user_2 = nn.Linear(hidden_size, hidden_size, bias=False)
self.conv_2 = nn.Linear(hidden_size * 2, hidden_size, bias=False)
self.conv_ad_3 = nn.Linear(hidden_size, hidden_size, bias=False)
self.conv_user_3 = nn.Linear(hidden_size, hidden_size, bias=False)
self.conv_3 = nn.Linear(hidden_size * 2, hidden_size, bias=False)
self.fc = nn.Linear(hidden_size, num_classes)
def forward(self, g, user_i, ad_i):
g.nodes['user'].data['u'] = user_i
g.nodes['ad'].data['a'] = ad_i
g.apply_edges(lambda edges: {'a2u': self.conv_ad_1(edges.src['a'])}, etype='clicked_by')
g.update_all(fn.copy_e('a2u', 'm'), fn.mean('m', 'agg_u'), etype='clicked_by')
g.nodes['user'].data['u'] = torch.cat([self.conv_user_1(g.nodes['user'].data['u']), g.nodes['user'].data['agg_u']], dim=-1)
g.nodes['user'].data['u'] = F.relu(self.conv_1(g.nodes['user'].data['u']))
g.apply_edges(lambda edges: {'u2a': self.conv_user_2(edges.src['u'])}, etype='click_on')
g.update_all(fn.copy_e('u2a', 'm'), fn.mean('m', 'agg_a'), etype='click_on')
g.nodes['ad'].data['a'] = torch.cat([self.conv_ad_2(g.nodes['ad'].data['a']), g.nodes['ad'].data['agg_a']], dim=-1)
g.nodes['ad'].data['a'] = F.relu(self.conv_2(g.nodes['ad'].data['a']))
g.apply_edges(lambda edges: {'a2u': self.conv_ad_3(edges.src['a'])}, etype='clicked_by')
g.update_all(fn.copy_e('a2u', 'm'), fn.mean('m', 'agg_u'), etype='clicked_by')
g.nodes['user'].data['u'] = torch.cat([self.conv_user_3(g.nodes['user'].data['u']), g.nodes['user'].data['agg_u']], dim=-1)
g.nodes['user'].data['u'] = F.relu(self.conv_3(g.nodes['user'].data['u']))
user_i = self.fc(g.nodes['user'].data['u'])
return user_i
5.4加权W2V训练
这是我一开始比赛就有的想法,把多个不同的ID(creative,ad等)用同一个序列来训练,即Skip-Gram的输入为不同ID的embedding的加权和,输出仍为预测creative,具体可以参考这篇论文Billion-scale Commodity Embedding for E-commerce Recommendation in Alibaba。这个效果应该是不错的,比单独训W2V要好,只不过我没能成功把它实现出来(😞)
6.总结
这次比赛下来,学到的东西不少,比如对LSTM的理解更深了,之前也没有了解过对抗训练这一方面的东西。工程能力的提升也蛮大,之前科研目的只是实现就行,对于代码的简介,运行速度的优化和内存的管理都不够。特别是内存的管理,这个很大程度上决定你一台机器可以同时跑几个任务。当然还是挺遗憾的,有一些坑因为没有经验踩了很久,比如用gensim来训练W2V,我一开始是根据每个epoch结束的loss,如果loss不涨就主动截断,后面才发现它其实有bug,loss的显示有上限,所以很多时候我没训练完就截断了,导致效果不好。这个坑浪费了我非常多的时间(捂脸)。
当然,最后输了也算是技不如人。我们对于这个赛题挖出来的东西不多,亮点不够。等到决赛答辩,得好好总结学习一下前10的方案,这样才会有进步~
另外我们的代码已经开源,链接如下: