科大讯飞的这次比赛准确来说打得有点没劲,不是那种无敌手的没劲,而是使不上劲的没劲。
草草结束的初赛
这次赛题做的是广告的点击率预测,而我其实是才中途加入的。9月份的时候作为实验室项目负责人各种忙着赶进度,两位队友则是时不时去各大平台“物色”有没什么合适的比赛。到了9月下旬他们选定了科大讯飞,等到我忙完实验室项目的中期验收,开始着手打科大讯飞时,距离比赛初赛的结束时间只剩一个星期。此时队友们的最好成绩在十几名,前50进复赛。
比赛排行榜上看到好几个京东赛认识的大佬们的身影,也有不少大佬们的开源,其中最好的开源模型是林有夕的一份当时排名40+,成绩0.4245的代码。我没有看那份代码,只是大致从队友那里了解到,那份baseline就是把所有的cate特征进行编码和onehot处理,所有标签也全部展开,然后就这样几万维直接丢进去训练出来的。不过为了节省数据分析时间,我还是先看了另一份开源的EDA分析代码。这份代码分析得比较基础但也还算全面,从中可以get到不少的点:
- 有些特征只有一种取值,除了字段名可以提供点赛题背景信息外,基本上是废的
- 有些布尔型字段的label统计均值显示区分度很大,分析中作者认为是强特,但我认为其中一种取值的样本数太少,在树模型中的作用可能不大。
- 有些疑似重复字段,比如os和os_name,advert_id和advert_name
- 时间字段应该是时间戳进行天为单位的平移,因此可以放心提取出小时分钟数据,也很容易知晓训练集数据跨度为7天,测试集为1天。
- 小时特征的label区分度明显
- 赛题没有提供用户id,只提供了用户标签列表这一个multi values的特征,初步考虑是展开成一个稀疏矩阵然后做文本分析。
这些只是数据字段分析层面的,但是业务层面的呢?吸取了之前京东赛大佬的经验,再加上自己其实是第一次接触广告场景,所以这次在看完EDA后,我并没有急着动手做自己的EDA分析,而是先去网上百度了一大圈,想弄懂“广告主”“活动”“订单”“创意”这些名词究竟是什么意思。
科大讯飞的广告投放业务老实说我没了解到,网上资料也不好找。但我知道微信公众号有广告主业务,于是就去翻了微信广告的说明文档。再加上之后对数据集的这几个id字段进行一对多的统计,最后基本能跟微信文档的说明对上号:广告主首先会新建一个广告活动,这个活动其实就是广告的推广计划和投放目标。每个广告活动可能不止投递一次广告,因此会形成多个订单。每个订单下面也可能包含多个广告,每个广告会有其对应的推广人群、投放时间、广告出价等。而创意其实就是广告的具体内容,包括选择的样式模板,标题、描述等具体的广告文案。创意是由广告主创建的,同样的创意可以应用到不同的广告中。其他一些名词比如“落地页”的意思也陆续了解了,落地页其实就是点击广告后展示的第一个页面(通常会是一个海报宣传页)。
了解了这些之后,很多字段的含义和关系也就比较明朗了。在进行自己的EDA分析时,我很快就意识到“落地页跳转”和“落地页下载”应该是一对互斥特征,统计一下发现也确实如此,于是又剔除一个多余字段。然后就是结合业务场景的理解,去统计观察各字段与label之间的关系,发现在广告信息的各层级中,创意id最早开始体现对label的区分性,同个创意id的广告的点击率都很接近。这也很容易理解,对于用户来说,吸引你点进去的一定是广告展示出来的内容。随后发现的另一个对label影响比较大的是媒体广告位(同样是通过网上的资料了解,知道开辟广告位供广告主展示广告的APP也被称做流量主)。原先设想会有强相关性的app_id字段在统计分析上反而并没有发现很直观的规律。至于用户标签的字段,由于有一千多种标签,手工分析不好做,在EDA阶段就只是针对不同的标签前缀进行粗略的统计,倒也能看出一些不太明显的区分度。
整个EDA分析做了两天多,第三天才开始敲模型代码,依旧是没有看林有夕的baseline。按照以往的经验去掉冗余特征和太稀疏的id特征,再对一些cate特征进行简单的拆分合并,将数据集按天数拆分出线下训练集和验证集,线下验证成绩0.426。再跑个全量的线上模型丢上去一测,线上成绩接近0.429。嗯,这个成绩看起来比EDA分析里的0.432的baseline好多了。然后继续加创意id和广告位id等id的历史点击率特征,依然是靠线下无穿越的训练集和验证集,用全量训练数据跑线上模型,线下有提升,线上也没有出现队友们和竞赛群里面提到的点击率特征效果爆跌的问题,虽然也没有出现预想之中的大提升,想想群里面说暴跌的应该是穿越了,于是就先留着。
普通特征做到0.428就做不下去了,但这个成绩跟林有夕0.4245的baseline差的实在有点多。我不太相信一组全展开的用户标签的稀疏特征能够降这么多,于是开始细细看林有夕的baseline。它没有什么额外特征,几乎就是原始特征展开后,做了个95%的筛选,然后直接lgb训练出来的五折交叉平均的模型。由于特征工程实在太简单了,我不得不对照着把所有的id特征都丢进了模型里面训练,结果愕然的发现模型误差降了1个千。输出模型的特征重要度一看,排在tops10的竟然大半都是这些原始的id特征,就连那些id的历史点击率特征,竟然都还没有这些原始的id特征进行顺序编码后的重要度高。那些可是上千维的id特征啊…正常脱敏后都不会与label有什么相关性吧?既然特征有用,我也只能留着,但这个id特征的重要度也成了一个谜。
0.427的模型依旧很差,排名在290+(0.428的baseline排名也就300名,估计是林有夕的baseline成绩太好,导致我现在这个分数已经属于垫底了)。我又试着将用户标签展开成标签矩阵,用卡方检验筛选了50%后丢进模型,线上成绩又降了一个千,达到0.426+。到目前为止,baseline里的可以说特征就全部用完了,但却离0.4245的baseline遥遥无期,更不用说已经在0.4240以内的队友们……我终于开始不淡定了。
重新给lgb调参,误差降了一点点。然后又把点击率特征去掉,发现又降了2个万,考虑到点击率特征还没id特征重要度高,决定听从大众意见还是不要冒险用。最后又陆续删改特征,直到最后只保留下baseline的特征,发现这份的线下成绩竟然是最好的!难不成我加的特征全都是脏特征不成?又将自己的特征全部转移到baseline的那份代码中,采用baseline的训练参数和训练方式(去除掉点击率特征后就没有时序类特征了,5折交叉不会有穿越),发现线上成绩是0.4256,比我自己的好了一个千,但也比baseline差了一个千,我开始头疼了。
一直到第8天初赛截止,只好匆匆与队友组了队,默默的看着队友们的四模型融合到了排行榜第9,体会了一把当拖油瓶的感觉。尽管队友们都安慰说是我做的时间太短了。
继续龟速前进的复赛
初赛阶段的最后其实发生了一件事。科大讯飞比赛采用的是AB榜同时测评,平时只公布A榜,一个比赛阶段结束后才公布B榜,最后成绩以B榜为准的竞赛方式。初赛换榜的时候,我们发现原本A榜第9的成绩直线下滑,最终变成31名,这明显是过拟合的信号。队友们都开始有点紧张,毕竟是多模型融合,出现这么明显的下滑,过拟合恐怕很严重,而且可能还不止一个模型。而我在了解到之前有队友因为每天提交次数充足(一天可提交5次,即时出结果),直接跳过线下验证看线上成绩后,就提议他们最好重视下线下验证,还是要保证线下验证的结果没问题才行,否则很可能会直接过拟合A榜。
然而这个问题似乎还离我很遥远,毕竟我现在连自己的单模都还没搞定。复赛数据量比初赛多了一倍,各个字段的分布规律似乎都与初赛一致,就连时间也与初赛数据完全重叠。于是直接将初赛复赛数据一起去重后合并,用A榜的模型分别训练了一个五折交叉平均的结果和一个全量数据训练的结果后提交上去,线上成绩分别是0.4244和0.4242,全量训练的效果要更好一点。推测是五折交叉的数据太少导致迭代不足,所以效果不好。而另一边队友告诉我baseline的B榜成绩是0.4239,再次扶额。
复赛只有一周的时间,由于复赛开始三人要共用每天的5个名额,而我由于模型太差通常只用一个名额,所以大家的特征交流也多了一些。而我也开始借鉴了队友和初赛后开源方案的一些强特,比如对id特征出现的曝光率进行统计,比如除了对id特征直接编号外,还对它们进行onehot,然后筛选出与label相关性最高的一小部分id丢进模型。由于没有再提跟时序相关的特征,于是线下验证时又多了一步cv验证,显然cv验证的误差要稳定得多,不像时序验证(前6天训练,第7天作为线下验证)误差会在万分位上抖动(或许是因为树模型的cv验证是5个模型共享early_stopping的迭代次数的原因?),不过线上依旧是采用全量数据集训练单模型的方式。经过两天的尝试后,线上成绩继续在十万分位上缓步提升。看起来似乎也是好兆头,然而在对比队友们在加了同样一组强特后,成绩直接提升几个万来说,这种只在十万分位爬行真的有种让人吐血的感觉。
随后队友们又从竞赛群上了解到一些id特征有规律,想想还确实有可能,不然没法解释上千维的稀疏特征为何会在树模型中占那么高的重要性。于是又抽空仔细观察了下各类id特征的数据,大部分数据是没观察出来啦。不过拜以前实验室的某个开发项目所赐,我还真发现省份跟城市的编码规则似乎有那么点眼熟。又仔细想了想,他们的编码排序后的规律似乎跟行政区划代码有点像,到网上翻出全国行政区划代码一看,还真是!只不过6位数的代码被拆分丢进了这十多位数的“脱敏字段”中。其实这两个字段的数字编码中,有很多位的数字是完全不变的,把这些完全不变的位数全部去掉,就剩下6位会变化的数字,正好能跟行政区划代码对应上,虽然不是完全相同(大致上感觉似乎是把行政区划代码的每一位又进行了一个顺序编码),但也足够反推出绝大部分的省和省会城市。甚至还进一步发现省份已知但城市未知的城市字段编码规律。只能说…主办方的脱敏太不到位了,不过其他id特征就实在无能为力了。
后来队友们又在一份开源代码内发现有选手对特征进行两两交叉的数量统计(比如在某个特定的A特征值中,B特征取值的种类数目),开源中说这组特征提升不小,其中一个队友尝试后也说确实有提升。但另一个队友却觉得这些特征提得好没道理,更像是胡乱堆出来的。我一开始也这么觉得,后来对其中的几组特征做EDA分析,发现有些确实是有点规律,而且结合业务场景上似乎也有些道理,比如一个APP下的广告位数量,如果APP下的广告位太多,那么这些广告位的点击率会偏小;还有机型特征,同一个机型下出现的创意尺寸的种类越多,可能说明这个机型能支持展示的广告尺寸也越多,点击率也稍大一点。于是根据EDA和线下验证误差挑拣了一批这种交叉特征,线下提升一个万,线上提升0.5个万,队友则提升了两三个万(线上误差已经比我低了快1个千),再次吐血。最后听从队友建议又一次尝试将线上模型改成五折交叉平均,这次线上降了一个多万,总算是比baseline好了(虽然也就一点点)。
复赛最后三天,我的单模已经快做到生无可恋的地步。想着既然几个id特征都这么重要,那么最好是能找到适合这类id特征的模型,于是把目光瞄向了某位队友做不动的FFM模型(后来才知道那个不是FFM,而是中大的渣大大佬自己封装实现的NFM,据说是根据第一届腾讯赛冠军的模型思路改编的。文后有贴链接),由于之前比赛中我接触的包是libffm,而他用的模型是要用tenserflow跑的,原理似乎也不太一样。为了省事最终并没有直接拿他的FFM模型改,而是在了解到他的FFM里只有原始id跟用户标签的onehot特征后,突发奇想地给FFM的模型叠加个lgb的残差模型,这个残差模型自然不需要那些稀疏的onehot特征,只保留那些数值型特征以及编码后的cate特征就可以了。又仔细考虑了一番觉得这个思路还算经得起推敲,于是开始动手。由于是训练残差,这个问题就变成回归问题了,想了想logloss算是指数损失,回归问题里面似乎没有类似曲线的损失函数,或许可能要自定义目标函数?不过第一步为了求稳,还是先用传统的均方差做损失,就是需要调整下加上残差后超出0-1范围的预测值。为了方便看迭代误差,也为了early_stopping更准确,我把模型的评价函数重新自定义成logloss损失(其实也就是手动把残差预测值加上原来FFM的输出然后算logloss损失)。线下效果还挺明显,从0.4244降到了0.4239。线上效果也很明显,原来的FFM的线上成绩是0.4243,加上残差后误差直接降到了0.4238,心里顿时升起一股成就感。随后队友在边上问了一句:“该不会加上残差后我的FFM比你的单模还好吧?”,立马就瘪了。
感觉到研究这个残差模型更有意思,我于是开始构思自定义目标函数的问题。为了跟最终的预测目标和评价指标一致,目标函数最好是能跟原先的FFM结合起来直接套用logloss损失,其实就是将“FFM预测值+残差预测值”代入替换掉原logloss损失函数中的预测值。列了下新的目标函数,验证了下二阶可导,于是开始尝试。然而线下效果并不好,比原来的差了好几个万。再仔细一想,发现这个目标函数并没办法限制预测值在0-1之间。虽然当标签为0的样本预测值大于1时式子会直接无解,但当预测值小于0时这个代价函数直接为负数。换句话说,当预测值超出范围时这个评分指标不但不扣分,反而还倒加分了,那么当预测值等于标签值时,这个代价函数甚至都不是在最低点。不成,这还得再改一下。限制预测值在0-1之间最好的方式就是sigmod函数,原FFM输出的预测值已经是经过sigmod了,于是我决定将预测值改成将FFM输出反sigmod后,加上残差预测结果,然后再经过sigmod换算成最终预测值。写成公式就是,预测值从原来的
改成
其中 , 。 代入logloss公式算了下,依旧是二阶可导,且导数公式还跟原来的logloss一样。于是再放进模型里面跑,线下模型的成绩比均方差的略差了一点点,线上则是比原来差了一个多万分点,好处就是模型输出结果必然是0-1之间的合法值,不需要再考虑非法值问题。既然线上差距明显,大家自然还是选择成绩更优的均方差作为残差模型。其实我个人的理解中是觉得修改后的自定义目标函数应该要比均方差作为目标函数更好一些,毕竟线下验证差距不大,而我也不能排除是否有误差抖动,或者非法值调整策略的影响,尽管模型最后预测出的非法值还不到10个。当然现在评价也只能从理论上进行评价了,而且鉴于笔者数学比较渣,恐怕得找大佬鉴定下才能确定这个损失函数是否合理。
于是比赛最后一天,我在团队中的贡献度就是一个比队友们差了六七个千的渣渣单模型,和一个靠加残差勉强救活的FFM模型。而队友们复赛虽然有提升但也不够别人大,身边陆续打听到有队伍已经做到了0.4230以内的单模,而我们还全部在0,4230以上,虽然队友们靠着另一个大佬的开源baseline又做了一个也在0.4233左右的模型,就指望着质不够量来凑。融合的结果并没有太好,0.4227的成绩勉强能进前19(优秀奖)。最后一天晚上,我们拿着几份单模的预测结果用均方差算了下差异性,发现几个树模型之间相似度确实有点高,倒是加了残差后的FFM差异性比较大,可惜就是模型本身的成绩太差了。就算有心想利用下FFM的差异性,也不敢把它的融合比例调太高。其实大家更担心的是换榜后B榜成绩会不会又像初赛那样爆跌,但是整个复赛期间大家也找不出是什么特征过拟合,也只能指望多几个模型能增强下鲁棒性,其他的也做不了什么了。
第二天开榜,不出意料的再次过拟合跌到30名。由于之前有过心理准备,大家这次也没说什么,就是觉得有点可惜。比赛就这么Game Over了。
赛后解惑
这次比赛可以说打的很不爽,毕竟全程拖后腿,但还是能有一些收获。比如第一次学会主动去调研求证赛题的业务场景和业务细节,而不是全靠自己YY。比如算是做得最完善也最沉得下心的EDA分析。以及第一次实践了自己曾经有过的残差模型设想。
碰到的疑惑嘛…也不少。比如神奇的id特征重要性,现在仔细想想,1000维的id对上总数100w+的样本数据,其实也不算是太稀疏,树模型是有可能学习到的,只是效果可能没那么好。而这次id特征重要性那么高,可能真的也跟id脱敏的方式有一点关系,或许是脱敏还不够彻底吧。至于点击率特征,应该不太可能会对模型起反作用,只是对比原始id特征来说,它的效果没那么大,毕竟鱼佬开源的baseline也是加了点击率特征的,而之前跟京东赛结识的xili讨论这个问题时,他有提到总共7天的数据,按天滑窗统计点击率的话,第一天没数据,第二天数据不太准,再加上最后一天作为验证集,那剩下的有价值信息可能也就没多少了。而且为了避免穿越,还得放弃五折交叉平均的模型训练方式。
最后就是到复赛结束都没能解决的过拟合问题,由于线下一直保留有时序验证方式,理论上如果有过拟合特征的话,线下也应该能测出来了。于是最后就很厚脸皮的去搭讪了一下某位花大佬,大佬给的意见很直接:“你们是根据线上反馈来做特征的吧?我只看线下cv结果做的,线下不提升就不提交,持平也不用提交,先看线下再看线上。”,想了想,虽然我也确实是看线下验证的,但是如果线下验证差不多就会选择以线上为准(其他两位队友怎么看的我不知道),难不成就是这样过拟合的?然后大佬又给出一个消息“也可能不是过拟合,只是分差太小,在波动范围内。其实这道题几个万分点都不算过拟合。”喵?难道不是在十万分位波动的吗?我们最终成绩跟19名差了2个万啊。。。这要是靠着波动就掉到30名,我们是不是也太亏了点?随后再仔细想想,大佬说他线下不同机器跑测出波动有几个万,而我却没有真的去测过,只是感觉上是十万分位,就连验证集的数据量都跟线上测试集不在同个数量级。但或许下次真的得更加重视下线下验证才行。
相关链接:
- 赛题信息:2018科大讯飞AI营销算法大赛
- 比赛代码: https://github.com/YunaQiu/kedaxunfei2018
- 渣大的项目github:https://github.com/nzc/dnn_ctr