OGeek算法挑战赛的各种零碎总结

Posted by Yuna on January 4, 2019

本以为科大讯飞会是我们的比赛历程终点,结果那边还没打完,这边就听说天池出了个红红火火的OPPO比赛,总奖池更是高达100w,引得各路大佬蜂拥云集。再加上罕见的允许阿里系员工参赛,于是来凑热闹的大佬更多了,京东赛认识的那些前辈同辈基本全部在场。

曾经我们还是觉得自己有希望在这个比赛混到一杯羹的,然而复赛后期越打越绝望,最后连前20都没保住。赛后静下来仔细想想,其实这个比赛确实也没有玩出特别有亮点的东西,倒也不是说没有收获,只是这些收获对比长达2个多月的战线来说,似乎稀少了些。而且由于战线太长,关于初赛都经历了什么,也记不太清了。

所以这次博客怕是不能像以前那样来个“记忆回放”了,只能记录下大致的过程,然后着重记录下这次比赛琢磨出来的那些“骚操作”成果。

比赛历程

初赛:(约10月22号参与,共经历两周半)

  • 【第一周】:草草提了20来维的CTR统计特征,然后 研究了四五天的如何拟合空缺值分布的问题,最终选择采用五折交叉提特征的方案,线上成绩0.7384
  • 【第二周】:加入文本分词,tfidf等各种文本相似度计算的相关特征。重构特征工程代码,引入中间文件存储从而减少训练时间。线上成绩0.7511
  • 【最后半周】:尝试新旧数据分开选取阈值,修订文件读取方式防止误判空缺值。模型提升方面没什么进展,A榜最好0.7520,B榜成绩0.7504,排在了四五十名。队友的最好单模成绩是0.7547,排在17名。

复赛:

  • 【第一个周末】:再次开工研究数据分布,设计出一套新的样本抽取方案。复赛在未加入词向量的lgb上测试,采用新抽取方案后成绩从0.7155提升到0.7262
  • 【第一周】:队友最佳单模复现完毕,线上成绩0.7301,瞬间提升到榜上第2。随后又加入了官方词向量的相似度特征,成绩上升到0.7323。与此同时我负责 研究自定义F1评价指标,尝试解决F1训练时迭代次数抖动剧烈的问题
  • 【第二周】:自己的单模新增词向量相似度特征(即把初赛的tfidf向量替换为word2vec向量),发现提升只有几个万。同时 最佳单模由于训练会爆内存,一个队友负责代码优化,两天后我也开始帮手优化。负责高维特征模型的队友完成了NN模型,单模线下0.725,线上0.7172,与最佳lgb融合后线上0.7299,反而降了两个多千。后来发现在线下验证集上,融合后也是效果变差。于是 NN模型方案由原本的单纯用lgb特征做NN,改为尝试构建NLP语义模型
  • 【第二周周末】:本以外优化完代码准备再次上分,然而周末猛然发现 词向量训练复现失败,线上掉了4个千,最佳单模只剩0.7289的成绩。花两天时间解决词向量训练的随机问题(但成绩依旧回不到0.732)。重新分配工作后我负责协助测试最佳lgb单模的特征,然而准备在最佳单模上采用新抽取方案时,通过EDA发现了五折随机抽取与五折顺序抽取在分布上的重大区别!尽管最佳单模在应用新抽取方案后线下依旧能提升2个千,但线上反而降了6个万的成绩,新抽取方案失败。
  • 【第三周】:负责按部就班测试lgb模型的各种待融合特征,并补充重构了文本特征提取的部分代码,线上单模成绩爬回0.7319。队友的NN语义模型构建完成,线下比纯lgb特征的NN模型提升了1个百,达到0.735,然而线上却只有0.70的成绩(两天后发现是线上模型训练有严重bug),NN模型失败,A榜最后一天也没有尝试融合。
  • 【B榜三天】:原计划是第一天交lgb单模,第二天交lgb+nn融合,第三天见机行事。然而第一天由于我训练失误提交成了A榜结果,导致第一天机会浪费,于是变成了第二天测lgb单模,不过加上了 修改样本权重的分布方案。第二天成绩0.7409,排名18。对比A榜最后一天的分数排名,目测修改样本权重的方案能提升两三个千的成绩。同时第二天有队友 研究发现了一个小leak,加上对样本抽取分布进行EDA分析后 改进了线上模型训练集的特征提取方案。于是第三天提交的模型为:改进lgb的线上模型特征提取方案,与NN融合(用验证集确定融合比例及阈值),对预测结果进行后处理调整(leak)。提交后线上成绩0.7404,由于第三天提交的变量太多,也查不出到底是哪里出的问题。

后面会按知识点模块整理下这次比赛的研究内容。


Part 1: 数据分布

这部分应该是这次比赛花费精力最多的一部分内容,虽然最后没能很好的帮助到模型的提升,但是收获也是满满的。经过这次比赛的折腾,虽然在构造特征上依旧有些乱来,但对自己的EDA能力也算是比较满意了。由于历时较长,会按时间顺序交代整个方案的改进过程。

1. 队友初版lgb,发现线下验证时,新prefix的预测值极低

当初赛一开始队友告诉我这个发现时,我也就草草浏览过比赛的说明,连数据都还没下。不过这个问题一抛出来,我很快就意识到了为什么会这样:统计特征是针对整个训练集的统计,也就是训练集中的数据都有其对应的历史统计特征。然而验证集中却有一部分数据在训练集中从未出现过,但模型在训练时却并没有碰到过带有缺失值的统计数据,因此在验证集的新数据上的预测自然会出现偏差。

进一步EDA统计发现,对于prefix来说,无论新旧数据,点击率的均值都是0.37+;而对于title,旧数据的点击率是0.37,但新数据的点击率是0.32,有一定的区别。

对于这个问题,我跟我队友有着不同的解决方案:队友想到的是将新数据的预测值通过反sigmod调整到验证集统计出来的均值,而我想到的则是在训练集中模拟出新数据的样本,从而“教会模型”如何预测有缺失值特征的样本。

一开始的方案操作也很简单:

  1. 用训练集统计提取验证集特征,发现验证集上有特征空缺值的样本比例大约是0.3。将训练集跟验证集合并后统计提取测试集特征,发现测试集上有0.15的样本空缺。
  2. 在做特征工程时,将训练数据原样复制一份得到“copy”数据集,直接将prefix跟title置为np.nan,然后再用train训练集统计得出prefix跟title无历史样本匹配时的统计特征。此时的特征工程还只是用传统CTR题目的思路随便统计的20来维baseline特征。
  3. 线下模型训练时,训练数据为200w的train+随机抽样30%的copy,验证集为5w的valid(特征是用200w的train统计得到)。
    线上模型时,先将train跟valid合并为205w的“stat”数据集,“copy”数据集则是将prefix跟title置空后用stat数据集统计,testA数据集的特征也是用stat数据集统计。训练时,训练数据为205w的stat+随机抽样15%的copy,迭代次数和阈值则采用线下模型跑出来的最佳次数和阈值。

第一次提交成绩0.7208,比baseline高了2个百,比队友的调均值方案高了1个百。但其实这个方案还很粗糙,并不是很满意。

2. 发现prefix跟title并不一定是同时缺失的

进一步进行EDA分析,发现有些统计特征是只缺失prefix,有些是只缺失title,有些则是prefix跟title都缺失。具体分布情况如下:

数据集 只缺失prefix 只缺失title 同时缺失prefix和title
验证集 0.12596 0.01814 0.08342
测试集 0.03532 0.0286 0.05266

而之前方案构造的样本是直接假定了prefix跟title同时缺失,于是就想到了将这三种情况拆分开来构造数据集,步骤如下:

  1. 同样是对训练集复制一份“copy”数据集用来构造缺失特征
  2. 将样本打乱后,根据验证集统计的三种情况的比例,将42.33%的样本的prefix设置为空,11.7%的样本的title设置为空,剩下的样本prefix和title都设置为空
  3. 用训练集统计数据,构造copy数据集的样本特征
  4. 模型训练时根据缺失样本比例从copy中抽样并入训练集中,线下为30%,线上为15%

该方案线上成绩是0.7293。另外从上面的统计表格可以看出,训练集跟验证集在那三个统计维度上的缺失比例并不相同,也就是验证集跟测试集的分布也同样互不相同。于是又尝试了针对线下和线上模型(即验证集和测试集)设置不同的缺失比例,这个方案在线下测出来AUC是提升了一点点,F1无明显变化,但在线上却反而降了5个千,只有0.7243。

3. 模仿比赛数据集的抽样方式

虽然上面的方案看起来很有效果,但我觉得这种强行置空的数据集并不够“天然”。联想出题方构造数据集的场景,这三个测试集应该是从一个全集的数据库中随机抽样出来的。比如训练集抽200w,再抽5w训练集,这5w训练集自然而然会有一些样本是原先训练集没有的。之后再抽5w的测试集,同样也会有之前两个数据集没有的。而在分布特性上还有个最直观的的数据就是新旧prefix的平均样本量。

数据集 所有prefix的平均样本量 新prefix的平均样本量 所有title的平均样本量 新title的平均样本量
验证集 2.1457 1.8461 1.9735 1.1738
测试集 2.1247 1.1265 1.9511 1.0431

新数据的平均样本量要明显低于整个数据集的均值,正好符合抽样时小概率样本容易缺失的规律,这个特性是之前的构造方案所不能满足的。于是很自然的想到了第三种方案:将训练集进行五折交叉抽样,这样抽样产生的数据集分布正好能满足数据抽样特有的分布规律,而且还惊喜的发现,这种方案还连带解决掉了特征统计的穿越问题!5折交叉统计后的训练集特征分布如下:

--------5折交叉分布:---------
prefix的新样本数: 62007 prefix的新样本率: 0.0310035
prefix的新种类数: 53153 新种类率: 0.338375253847
title的新样本数: 147097 title的新样本率: 0.0735485
title的新种类数: 137264 新种类率: 0.546507090928
只缺失prefix的样本数: 12397
只缺失title的样本数: 97487
同时缺失prefix和title的样本数: 49610

--------验证集分布--------
prefix的新样本数: 10469 prefix的新样本率: 0.20938
prefix的新种类数: 5671 新种类率: 0.243359224134
title的新样本数: 5078 title的新样本率: 0.10156
title的新种类数: 4326 新种类率: 0.170745184717
只缺失prefix的样本数: 6298
只缺失title的样本数: 907
同时缺失prefix和title的样本数: 4171

--------测试集A分布(训练+验证统计)--------
prefix的新样本数: 4399 prefix的新样本率: 0.08798
prefix的新种类数: 3905 新种类率: 0.165937194578
title的新样本数: 4063 title的新样本率: 0.08126
title的新种类数: 3895 新种类率: 0.151988137511
只缺失prefix的样本数: 1766
只缺失title的样本数: 1430
同时缺失prefix和title的样本数: 2633

尽管在数值上不是完全吻合,但是在大小规律上是满足关系的,毕竟若要详细追究起来,验证集跟测试集本身分布也互不相同。这种方案诞生的新数据就很“天然”,线下提升明显,线上成绩7384。也因此确定了光靠统计特征(不加文本特征)模型应该是能达到0.74以上。

随后又做了一个实验:如果模型训练时不用验证集作为早停,而是从训练集中抽一部分数据作为早停的valid,再用迭代出的次数训练全部训练集来预测验证集,这种训练方案是否可行?

线下训练时很快就发现这种训练方案的迭代次数从原来的200多暴涨到了1000多,线上成绩也不例外的降了2个千。于是便意识到,抽样后的训练集分布到底并非跟验证集和测试集完全一致,因此官方才会提供了验证集以便我们用来验证模型在不同分布的数据上的鲁棒性。

至此初赛的数据抽样方案就确定了,于是心满意足的收工,开始研究特征工程。然而两天后队友发现,他们实验室的师兄同样也采用了这种5折交叉提特征的方式,问师兄为什么会这么做,师兄回答说“网上有这样的kernel啊~而且腾讯赛的数据集也是这种无时间信息的CTR问题,当时就有人开源了这样的方案,但凡打过腾讯赛的都会这么干吧?”,瞬间那点成就感就没了,而且还被师兄反嘲讽了一通“打了那么久比赛还不会去kernel上找别人的方案,怪不得你们老是拿不到tops。”。好吧,这确实是事实,虚心接受教训。

4. 为何五折是最好的

再次接触分布这个话题,已经是初赛结束的时候。此时复赛还没开始,在跟京东赛认识的武天老师聊天的过程中,他提起了这道题的数据分布。“为什么5折是最好的?我尝试过40折,但是发现结果爆跌。3折和10折的成绩也都不如5折的好。”,这点提起了我的兴趣,回头找刚组队不久的新队友们一交流,发现也确实有队友尝试过用10折,但发现效果变差的情况。从武天跟队友那里,我了解到了他们采用五折交叉的理由:“不然还能怎么提?你要是对整体提那不就穿越了吗?”,然后我才恍然大悟,确实从解决穿越的角度来考虑,五折交叉同样是一个简单易行的解决方案,只是我在比赛开始首先碰到的就是分布问题,而解决完分布后穿越也顺带解决了,所以没去细想。从这个角度方面考虑,也难怪大家会想着用10折乃至40折的抽样方案。而对于折数提升效果反而变差,我的猜测原因应该也是分布。也许五折的方式产生的分布正好是最接近测试集的?

--------3折交叉分布:---------
prefix的新样本数: 77198 prefix的新样本率: 0.038599
prefix的新种类数: 59686 新种类率: 0.379964732021
title的新样本数: 164117 title的新样本率: 0.0820585
title的新种类数: 144519 新种类率: 0.575392369986
只缺失prefix的样本数: 15043
只缺失title的样本数: 101962
同时缺失prefix和title的样本数: 62155
--------5折交叉分布:---------
prefix的新样本数: 62007 prefix的新样本率: 0.0310035
prefix的新种类数: 53153 新种类率: 0.338375253847
title的新样本数: 147097 title的新样本率: 0.0735485
title的新种类数: 137264 新种类率: 0.546507090928
只缺失prefix的样本数: 12397
只缺失title的样本数: 97487
同时缺失prefix和title的样本数: 49610
--------10折交叉分布:---------
prefix的新样本数: 52880 prefix的新样本率: 0.02644
prefix的新种类数: 48944 新种类率: 0.311580502028
title的新样本数: 136897 title的新样本率: 0.0684485
title的新种类数: 132577 新种类率: 0.52784612567
只缺失prefix的样本数: 10667
只缺失title的样本数: 94684
同时缺失prefix和title的样本数: 42213
--------测试集A分布(训练+验证统计)--------
prefix的新样本数: 4399 prefix的新样本率: 0.08798
prefix的新种类数: 3905 新种类率: 0.165937194578
title的新样本数: 4063 title的新样本率: 0.08126
title的新种类数: 3895 新种类率: 0.151988137511
只缺失prefix的样本数: 1766
只缺失title的样本数: 1430
同时缺失prefix和title的样本数: 2633

然而并没能看出什么东西。

5. B榜数据,发现新样本暴增

第二天拿到B榜数据,很自然的就先来了次分布统计,训练集同样还是采用的5折交叉。

--------5折交叉分布:---------
prefix的新样本数: 97661 prefix的新样本率: 0.0488305
prefix的新种类数: 84481 新种类率: 0.3981741142757493
title的新样本数: 238352 title的新样本率: 0.119176
title的新种类数: 226218 新种类率: 0.6446408165940483
只缺失prefix的样本数: 18838
只缺失title的样本数: 159529
同时缺失prefix和title的样本数: 78823
--------验证集分布--------
prefix的新样本数: 18824 prefix的新样本率: 0.37648
prefix的新种类数: 5601 新种类率: 0.25818198580252605
title的新样本数: 8832 title的新样本率: 0.17664
title的新种类数: 6130 新种类率: 0.24829876863253403
只缺失prefix的样本数: 11402
只缺失title的样本数: 1410
同时缺失prefix和title的样本数: 7422
--------测试集分布(训练+验证统计)--------
prefix的新样本数: 82246 prefix的新样本率: 0.41123
prefix的新种类数: 23262 新种类率: 0.3577778461349165
title的新样本数: 40448 title的新样本率: 0.20224
title的新种类数: 26734 新种类率: 0.3430734680782804
只缺失prefix的样本数: 46942
只缺失title的样本数: 5144
同时缺失prefix和title的样本数: 35304

好消息是复赛的验证集跟测试集同分布了,意味着线下用验证集验证会比初赛更准确。坏消息是测试集的新样本率暴增,训练集五折抽样的新样本率与验证集和测试集相去甚远,基本不再适用。

于是我的第一个想法又是构造同分布的抽样方案。首先从上面那个分析里面,发现了一个测试集跟初赛很不一样的点:新prefix的平均样本量竟然和旧数据差不多,甚至还更高,这根本就不是普通样本抽样应该会产生的数据分布。当时我的第一个感觉就是,这近40%的新prefix数据,怕是出题方硬塞进去的。而由于样本的prefix跟title之间有很强的相关性,所以间接带动了title的新样本量的上升,至少从新旧title的平均样本量来看,title还有比较明显的抽样稀释的规律。后期队友的数据分析又发现了在验证集和测试集中,有一条很明显的60%的新旧数据交界线,也证明了这一点。

既然官方是故意拿了一些原本训练集里没有的数据来抽样本,那就也仿造它从训练集里拿出一整份的新prefix来做特征就好了。于是新数据的构造方案如下:

  1. 复制一份train数据集
  2. 对复制出来的数据集按prefix进行分组归类,然后再对prefix进行五折交叉统计特征,此时相同prefix的样本只会被分配到同一折中
  3. 上一步诞生出来的只是200w的“新prefix”数据集。对原本的train数据集,还是采用原来的五折交叉统计,为了避免这里五折产生的数据集影响到后边的新旧数据比例计算,因此对数据集中空缺了prefix或title的数据进行剔除,因此得到的是约170w的“旧prefix”数据集。
  4. 按照40%的prefix新旧数据比例,从新prefix数据集中随机抽样一部分数据,并入上一步产生的“旧prefix”数据集中,使得最终数据集中的prefix新旧比例为4:6。由此得到的大约是290w的训练集数据

新抽样方案得到的数据分布如下:

----------训练集原5折交叉抽样方案-----------
prefix的新样本数: 97661 prefix的新样本率: 0.0488305
总体prefix的平均样本量:9.4263 新prefix的平均样本量:1.1560
title的新样本数: 238352 title的新样本率: 0.119176
总体title的平均样本量:5.6993 新title的平均样本量:1.0536
只缺失prefix的样本数: 18838
只缺失title的样本数: 159529
同时缺失prefix和title的样本数: 78823
                      all_data  new_data
label                 0.372959  0.412887
prefix_title_nunique   4.67469       NaN
title_prefix_nunique   29.3631   13.6182
prefix_label_len        804.66         0
title_label_len        738.313   54.6472
----------新抽样训练集----------
prefix的新样本数: 1161873 prefix的新样本率: 0.3999999311456706
总体prefix的平均样本量:9.4262 新prefix的平均样本量:9.6802
title的新样本数: 543589 title的新样本率: 0.18714228024194035
总体title的平均样本量:5.6992 新title的平均样本量:2.9126
只缺失prefix的样本数: 618284
只缺失title的样本数: 0
同时缺失prefix和title的样本数: 543589
                          all_data  new_data
label                     0.372704  0.372877
prefix_title_nunique       4.81532       NaN
title_prefix_nunique       29.0022   37.6788
prefix_label_len            553.76         0
title_label_len            680.594   435.178
----------测试集---------
prefix的新样本数: 82406 prefix的新样本率: 0.41203
总体prefix的平均样本量:3.0760 新prefix的平均样本量:3.5356
title的新样本数: 32491 title的新样本率: 0.162455
总体title的平均样本量:2.5665 新title的平均样本量:1.5130
只缺失prefix的样本数: 55066
只缺失title的样本数: 5151
同时缺失prefix和title的样本数: 27340
                          all_data  new_data
label                          NaN       NaN
prefix_title_nunique       4.66753       NaN
title_prefix_nunique       35.2135   49.6155
prefix_label_len           615.289         0
title_label_len            772.664   494.224

新方案在多项指标上要与测试集接近很多。但也有无法拟合的地方,比如prefix跟title的同步缺失样本率,新方案基本不可能抽出只缺失title的样本。新抽样方案的测试是在去掉了词向量,只有统计特征的lgb单模上测试的。由于拟合了分布,模型训练时迭代次数明显比以前更多,模型早停时的误差更小。原五折交叉方案的线上分数是0.7155,新抽样方案是0.7262,提升了1个百有多,足以说明分布拟合的重要性。但也遗留了一些新问题,到底什么样的分布指标,对模型的影响是比较大的?毕竟抽样方案并没能拟合全部的分布。

6. 随机与不随机的5折有着重大区别!

复赛一开始,队友初赛的最佳单模复现就达到了0.730的高成绩,很过瘾的占了好几天的前排。虽然当时有过疑问为什么队友的单模能这么高分,但也没去细想,毕竟队友的模型初赛成绩就很好,而我的新抽样方案也一直没融合进队友的单模中。一直到最后复赛最后一周,我负责协助最佳单模的改进时,才认真浏览过队友的代码逻辑。然后就发现队友在做五折交叉时,用的不是StratifiedKFold,也没有shuffle,直接就是KFold,队友之前也并没有觉得打乱跟不打乱有什么区别,只是觉得不打乱也可以,就没有去改过。但是当时的我在两天前研究线下验证方案时,正好留意到了训练集中prefix经常扎堆出现的情况。所以当看到kfold时,我突然开始怀疑打乱跟不打乱的kfold到底等不等价。于是去做了EDA统计:

----------训练集不打乱5折抽样方案----------
prefix的新样本数: 940520 prefix的新样本率: 0.47026
title的新样本数: 533323 title的新样本率: 0.2666615
只缺失prefix的样本数: 493298
只缺失title的样本数: 86101
同时缺失prefix和title的样本数: 447222
----------新抽样训练集----------
prefix的新样本数: 1161873 prefix的新样本率: 0.3999999311456706
title的新样本数: 543589 title的新样本率: 0.18714228024194035
只缺失prefix的样本数: 618284
只缺失title的样本数: 0
同时缺失prefix和title的样本数: 543589
----------测试集---------
prefix的新样本数: 82406 prefix的新样本率: 0.41203
title的新样本数: 32491 title的新样本率: 0.162455
只缺失prefix的样本数: 55066
只缺失title的样本数: 5151
同时缺失prefix和title的样本数: 27340

不打乱的5折竟然也很接近测试集的分布!虽然不打乱5折跟我自己抽样方案相比,有些拟合得更好,有些又没那么好,但不打乱5折对数据的改动影响要更小一些,所以很难说到底哪个方案更优。线下测试的时候,新抽样方案是固定比不打乱五折要提升2个千,但是在线上验证时,新抽样方案又比不打乱的五折差了6个万。个人自然是倾向于更为原生的不打乱5折交叉方案的,几个队友也是。老实说在得知队友无意之中就get到了正确的打开方式时,自己确实是挫败感满满的,好在不至于一直糊涂到比赛结束。

7. 通过对验证集加权来拟合分布

这个方案是B榜时在与别的师兄交流时挖过来的,通过加大验证集的样本权重,使得线上模型的分布更偏向于验证集的分布(验证集与测试集同分布),不过这就有个头疼的问题,到底设多大的权重比较好??那位师兄只测试过两个权重:5和18,他的测试结果是18比5要好很多。但我们都觉得18实在太大了。修改样本权重,影响的不仅仅是分布,还影响了样本的置信度。验证集毕竟数目太少,如果设置的权重过大的话,模型会不会过拟合验证集提供的数据信息?不过在得知那位师兄采用的是打乱5折的采样方案后,他设18的权重能有那么大提升也就不难理解了。

所以我们应该设置多少呢?我们的模型采用的是不打乱5折,数据分布上不像师兄打乱5折的有这么大的差异。由于B榜已经没有机会测试,我们最终折中选择的权重是8。出来的成绩是目测是提升有两个千,但是当时的除了权重还补充了一点新特征,所以不能确定具体用处多大。只是根据之前测试的经验来说,那几个特征应该达不到这么明显的效果,也就是权重调整这个方案应该还是提升了不少的。

8. 线上模型的训练数据抽样

验证集加权的方案是B榜第二天提交的。就在我们讨论最后一天的提交方案时,有队友针对分布提出了一个新的问题:我们之前做EDA一直都知道在线下不打乱5折的训练集跟测试集分布比较接近,那么对于线上的训练集与验证集合并后的数据,是不是对它们进行不打乱的5折依旧能保持这种比较接近的分布?就好像一道惊雷在耳边炸响一样,我突然意识到还真不一定!事实上,合并后的数据再五折交叉后的分布是长这样的:

----------合并集不打乱五折-----------
prefix的新样本数: 539600 prefix的新样本率: 0.26321951219512196
title的新样本数: 459486 title的新样本率: 0.22413951219512196
只缺失prefix的样本数: 217681
只缺失title的样本数: 137567
同时缺失prefix和title的样本数: 321919
                          all_data  new_data  old_data
label                     0.373055  0.382402  0.369716
prefix_title_nunique       3.98389       NaN   3.98389
title_prefix_nunique       29.0265    36.806   27.7929
prefix_label_len           606.291         0   822.893
title_label_len            681.676   148.542   872.141
prefix_label_ratio        0.367796       NaN  0.367796
title_label_ratio         0.377189  0.405032  0.372774
prefix_title_label_len     247.216         0   335.535
prefix_title_label_ratio  0.375035       NaN  0.375035

----------测试集-----------
prefix的新样本数: 82406 prefix的新样本率: 0.41203
title的新样本数: 32491 title的新样本率: 0.162455
只缺失prefix的样本数: 55066
只缺失title的样本数: 5151
同时缺失prefix和title的样本数: 27340
                          all_data  new_data  old_data
label                          NaN       NaN       NaN
prefix_title_nunique       4.66753       NaN   4.66753
title_prefix_nunique       35.2135   49.6155   28.1604
prefix_label_len           615.289         0   1046.46
title_label_len            772.664   494.224   967.786
prefix_label_ratio        0.372332       NaN  0.372332
title_label_ratio         0.375068  0.379764  0.372768
prefix_title_label_len     243.304         0   413.803
prefix_title_label_ratio  0.372548       NaN  0.372548

可以很明显的看到,新样本率已经不一致了,其他一些特征的均值对比以前也出现了稍大点的偏差。很快我们就想到,既然验证集本身就是同分布的,那为什么还要对它进行五折交叉呢?对训练集进行五折交叉统计,用训练集统计验证集,再把训练集跟验证集合并起来不就好了?这种思路合并出来的训练集跟验证集是长这样的:

----------train五折+valid---------
prefix的新样本数: 1091112 prefix的新样本率: 0.45463
title的新样本数: 603979 title的新样本率: 0.25165791666666665
只缺失prefix的样本数: 584514
只缺失title的样本数: 97381
同时缺失prefix和title的样本数: 506598
                          all_data  new_data  old_data
label                     0.373613  0.376669  0.371065
prefix_title_nunique       4.57889       NaN   4.57889
title_prefix_nunique       30.1043   39.6198   25.5134
prefix_label_len           607.265         0   1113.49
title_label_len            688.676   357.453   964.789
prefix_label_ratio        0.370521       NaN  0.370521
title_label_ratio         0.379445  0.392659   0.37307
prefix_title_label_len     244.954         0   449.152
prefix_title_label_ratio  0.373495       NaN  0.373495

----------测试集---------
prefix的新样本数: 82406 prefix的新样本率: 0.41203
title的新样本数: 32491 title的新样本率: 0.162455
只缺失prefix的样本数: 55066
只缺失title的样本数: 5151
同时缺失prefix和title的样本数: 27340
                          all_data  new_data  old_data
label                          NaN       NaN       NaN
prefix_title_nunique       4.66753       NaN   4.66753
title_prefix_nunique       35.2135   49.6155   28.1604
prefix_label_len           615.289         0   1046.46
title_label_len            772.664   494.224   967.786
prefix_label_ratio        0.372332       NaN  0.372332
title_label_ratio         0.375068  0.379764  0.372768
prefix_title_label_len     243.304         0   413.803
prefix_title_label_ratio  0.372548       NaN  0.372548

这样构造出来的线上训练集依旧保持了与测试集接近的分布,而且由于完整保留了同分布的验证集,使得合并后数据集的相比单纯五折的训练集的分布要更加接近测试集。但这样就又多出一个烦恼的问题,如果前一天我们对验证集的加权方案有提升,是因为平衡了线上训练集分布不一致的问题,那么现在,我们有了个更接近的线上训练集,修改验证集权重的方案还有那么大效果吗?权重又需不需要改小些?

由于我们第二天的成绩是十几名,与前10差距依旧明显,所以我们给第三天的方案设想是孤注一掷,就是经过推敲后能有效果的idea都往上堆。考虑到第三天的提交内容已经是个大杂烩了,而权重方案在第二天的测试中又是有效果的,所以最终只修改了线上训练集的构造方案,验证集的权重依旧设置为8。可惜第三天的成绩是变差了,所以目前为止已经没法确认第三天的提交中,哪些是有效果的,哪些是没效果的。

Part 2: 模型评价函数

模型的评价函数是继分布方案后另一个贯穿了整个复赛阶段的探索内容,而且是跟阈值选取的问题绑定在一块的。不同于分布方案在研究的过程中收获满满,评价函数的研究可以说是处处挫折,不过想到赛后从前排大佬那拿到了一个完美方案,也算是弥补了一点遗憾了。

比赛采用的是F1指标,受京东赛大佬影响,我也想过能不能直接改目标函数。然而那个头疼的混淆矩阵怎么也想不到应该怎么用连续的数学函数表达出来,算了,数学渣渣还是不要想这种不切实际的想法了,乖乖用logloss吧。但是F1评价函数也同样是模型没有定义的,lgb跟xgb可选的评价函数有logloss和AUC,当然也可以选择自定义F1评价函数,网上也有博客提供了自定义F1的代码。一开始也想过直接照抄,然而仔细研究了后,发现网上的代码都是直接默认阈值按0.5取的。可是直接选0.5阈值真的合适吗?阈值也是一个很重要的参数啊。如果不选0.5,那么每次迭代的阈值又该怎么确定?从理论上讲,直接用F1做评价函数无疑是最合理的,但是初赛时我已经花了一半时间研究分布方案去了,所以没有选择再费精力去研究怎么写F1评价函数,而是决定先用AUC来做。

1. 初赛的评价函数与阈值方案

初赛我的模型采用的是AUC,之所以选择AUC而不是logloss,是因为想到F1指标是基于混淆矩阵的统计,AUC也是基于混淆矩阵的指标(只不过无视了阈值的影响),所以直觉上AUC会比logloss更适合。此外还想过AUC是从ROC曲线衍生过来的,既然ROC就是对所有阈值遍历瞄点的结果,那能不能从ROC曲线上直接得到最佳的阈值点?然而看着曲线图笔画了半天依旧想不到怎么直接求解,所以模型训练完后的阈值选取,就直接仿造ROC的方式对数据集所有阈值节点进行遍历,然后选取最优F1的阈值。考虑到这样遍历出来的阈值太细致了,为了加强泛化,实际选用的阈值是前5个最佳阈值平均的结果。初赛的评价函数跟阈值的选取方案就是这么确定的。

2. AUC与logloss的差异

初赛末期找队友组队时,发现队友们用了logloss,模型效果也不错。所以初赛结束后就趁机研究了下AUC跟logloss的差异。数学理论上是分析不出定论了,只好来对比下实验结果。

大家都知道模型评价函数的不同实际影响的只是early_stopping时的迭代次数,所以我的实验重点关注的是两个评价指标迭代次数的差异,以及本地F1验证的结果。下面的表格中每种评价指标都分别用不同随机种子测了三组结果:

  AUC logloss
迭代次数 [240,215,241] [304,283,296]
早停时的评分 [0.86783,0.86822,0.86827] [0.43992,0.44033,0.43919]
最佳F1评分 [0.7358,0.7357,0.7358] [0.7364,0.7362,0.7360]
最佳阈值的均值 0.38979 0.39797

迭代次数上AUC普遍比logloss要少几十次。模型早停的评分上,AUC与logloss的抖动都是0.001左右,从模型迭代过程的输出来看,两个指标的迭代都是比较稳定的,没有抖动很厉害的情况。最佳F1评分,线下测出来logloss的成绩似乎还要好一点点,最佳阈值的均值其实也差不太多。进入复赛后,我又拿复赛的数据重新做了一次实验,logloss的迭代次数是1000左右,AUC是800左右,线上的成绩相差也不太多。如果有大佬能从理论上解释下这两个指标针对F1问题的优劣就更好了。

3.F1指标的两种思路

前面提到了,自定义F1评价函数,碰到的主要问题就是阈值应该如何选取。在跟不同人的讨论中,我发现了两种不同的思路做法:一种是将阈值当做模型的超参数,整个模型迭代过程都使用这一个阈值进行F1分数的计算,最佳阈值查找就跟搜索模型最优参数一样;另一种思路则是每一步迭代时,都去寻找最佳阈值的位置,把阈值对应的最佳F1评分作为本次迭代的分数

这两种方案到底哪种更合理,我也犹豫过。在我理解中,与F1指标相对应的是PR曲线,曲线的面积代表的是模型本身的优劣,与阈值无关。而F1指标则是关注PR曲线上的一个点是否是最优的。第一种方式就是直接关注的PR曲线上的一个特定点的最优化,或者说是给PR曲线的形状指定了一个偏向,比如是希望模型更偏向准确率,还是更偏向召回率。一开始我选择的也是这一种,觉得这种方案确认出的阈值更有泛化能力,但后面又想到损失函数毕竟是logloss,也就是迭代计算的优化方向本身就不是专门针对哪个特定点的迭代,而是针对模型整体的;第二种方式则是没有指定PR曲线的形状,只要曲线上最佳的F1阈值点的分数比之前的迭代结果更好就行,等迭代完了再去找那个最佳的阈值点。第二种方式的难点在实现上,相当于每次迭代求解时都要去遍历搜索最佳的阈值点,为了提高迭代效率,自然不可能是按照最细粒度的点去搜索,只能是比较粗粒度的限定范围的搜索。比如从0.32~0.48,以0.02为间隔找最优F1这样。

在第二种方式的基础上我还曾经探索过第三种方式,操作跟第二种方式差不多,不过不是找最优点,而是把搜索出来的F1直接求平均,相当于评定的是PR曲线上某个扇形区域的最优化。然而后来仔细想想,这种方式就跟直接用AUC去评定整个曲线面积的最优差不多,而且AUC的运行效率还更高效。

4. F1评价函数的迭代次数?

上面这两种思路的理论探索不是我比赛时主要关注的问题,我的问题发生在定义好F1评价函数后,模型的迭代上。初赛时没有尝试过F1,只是听说用F1出来的结果跟用logloss差不多,我是复赛才自己去实验了F1的效果。然而在我的模型上却试出了问题:采用F1评价后,模型的迭代输出抖动得非常厉害。情况大概就类似于20次迭代内F1可能就抖动了2个千,50~200次迭代内可能就出现了效果下降后又回升的情况。无论学习率是设0.1还是0.02,早停次数是50还是500,都是这么一个强烈的抖动。导致我用不同随机种子跑出来的5次迭代次数可能是50~700的范围,迭代次数根本确定不下来。这个问题在复赛期间困扰了我快一个星期,也厚脸皮去打听过其他队伍的情况,但大家的表示都是虽然是有点抖动,但都不是我这么夸张的。由于当时我测试用的的模型是前一节我提过的复赛的训练集新抽样方案,所以也不确定是不是我的抽样方案或者是我模型的问题。

后面最佳单模那边有锅,这个问题就搁置了。等到复赛最后一周再次在最佳单模上测试F1评价函数后,发现最佳单模的F1抖动明显好很多,大概是300~600的迭代次数。然而当时我们的logloss评价函数以及历史线上成绩测得的迭代次数大概是七八百,与400的迭代次数相差太多,因此一直到比赛结束也没敢冒险,还是用的logloss。所以是不是400的迭代次数真的更优,只能等别人的方案分享了。

5. F1方案二的完美实现

这个是从某位大佬那里挖来的,复赛前期听他说get到了评价函数的正确写法,一直很好奇,等到比赛结束后才去详细请教。他的F1评价函数采用的是每次迭代都去寻找最佳阈值,但是借助了sklearn的一个函数:sklearn.metrics.precision_recall_curve。这个函数会返回准确率、召回率、阈值的遍历列表,直接针对列表运算就可以求得所有阈值情况的F1指标,而且代码也很简洁。

def lgb_f1(y_pred, data):
    p, r, thre = precision_recall_curve(data.get_label(), y_pred)
    f1 = 2 * (p * r) / (p + r)
    return 'f1', np.nanmax(f1), True

数据的时序性探索

这个事情的起源在数据分布方案的探索上。从训练集的样本记录中,我们发现了很多prefix是扎堆出现的:要不就是直接连着好几个prefix都一样,要不就是中间穿插着一些其他的prefix,还有些是临近的prefix内容比较接近,队友的感觉是像是主办方把相同prefix数据一起取出来,而我的感觉则是这样的数据分布有点像是后端的日志记录。根据比赛最开始对数据场景的分析理解,用户的一次搜索行为应该会产生多条记录。比如推荐给用户的title有3个,那么就会对应产生3条样本数据,这三条样本数据要不只有一个被点击(标签是1),要不就全部都没被点击。如果这个样本数据真的是日志记录,那么就有时序性的关系在里面,也就不难理解为什么总是有多条相同prefix的记录相邻出现。

既然数据有可能存在时序性,那我就先按照时序数据的特点提了几个特征,比如“距离上一个(下一个)相同prefix样本的id数”“距离上一个(下一个)相同title样本的id数”。本地上先是做了EDA分析,在训练集上曲线有比较明显的相关性,但那个相关性与我们设想的不太一样,另外在测试集上(5w数据)基本体现不出相同的规律。而模型的测试结果则是显示这组时序特征有几个万的稳定提升,但让人担心的是它的迭代次数也暴涨到1500左右(我们之前测出来的迭代次数一直都是八九百附近)。后来由于提交机会不够不想冒险,这组特征又算是思路比较常规的特征,大家选择了去私聊求助其他相熟的队伍,看下他们是否做过这组特征,提升多少。对方的答复是“做了,但是没什么用”,于是我们也没尝试去提交。

到了B榜的时候,队友基于这个假设做了个更精准的规则校验,思路大致是:将连续的相同prefix且不同title_tag的记录判定为用户的一次搜索行为,在一组记录中,若模型预测有多个记录为1,则只保留模型预测概率最大的记录为1,其他记录更改为0。在线下的5w验证集上,队友的这个后处理规则总共修改了13条预测记录,全部修改正确!因此这个假设很可能是成立的。这条后处理规则最后被我们用到了B榜最后一次模型提交上,在20w测试集上总共修改了117个数据,但由于最后一次提交变量太多,不确定效果究竟如何。假设这117个数据全部修改正确的话,大约是5个万的提升。


这个比赛还有其他一些比较重点但没有整理上来的知识点。比如队友探索的NN语义模型(等我把神经网络的课程补上后再来研究),比如前排队伍的一些亮点方案(这个比赛至今没答辩,等得好心累)。后续内容可能会接着这条博客写,也可能另外开个博客整理,想到这篇整理已经跳票太久了,所以还是先把目前整理的部分发上来跟大家分享下。


相关链接: