之前阅读了了KDD2018上Airbnb发表的 Real-time Personalization using Embeddings for Search Ranking at Airbnb,第一感觉是这篇论文的可实操性非常强,值得一读,相关解读也非常多了。也正式因为上面那篇文章,才又发现了这一篇文章。这篇文章记录了整个Airbnb搜索推荐从传统机器学习模型到深度模型的演进与探索,深入浅出,同时能把很多失败的尝试暴露出来。对于深度学习落地到推荐在线排序来言,基于我目前经验,我觉得还是十分值得一读的。
原文地址:https://arxiv.org/pdf/1810.09591.pdf
1. 模型演进
从2017年4月到2018年6月之间共计四次比较大的迭代,离线使用NDCG进行评估。线上使用订单总数作为指标。图中y轴表示相对提升。

GBDT是最开始的base 除此之外还有两个有趣的点:1. NDCG的提升幅度和线上最终表现存在差异,并不是说离线评估提升越多,线上表现越好。2. 从Simple NN 到 Lambdarank NN 包含取得确定收益只花费了2个月,而从Lambdaran NN 到 GBDT/FM NN 花费了9个月之久。这部分收益取得的代价可谓是很高了。
1.1 simple NN
最终上线的模型是一只包含一个隐层的NN模型,32个ReLU unites,特征沿用之前GBDT模型的特征。训练目标也和之前GBDT保持一致,将用户最终预定的item标注为1.0,没有预定的标注为0,最小化L2 regression loss。
讲道理这个模型这么简单确实没啥好说的,真是的Simple NN。但文中交代了他们最开始也想尝试自定义复杂的结构(Why can’t we be heroes?),但是最终在复杂度面前妥协了下来。最终他们选择这个简单的模型来验证整个改造后的系统流程,达到一个完备状态并且可以承接线上流量(我理解的是离线指标和线上指标至少和base持平,解决中间的各种工程问题),效果上有小幅度提升。个人认为这里最值得讲的就是决策执行,发现问题行不通赶紧切换方向小步快跑,由浅入深,先以简单的方式将流程打通再深入优化。我们团队吃过类似的亏(换句话说就是想一口就吃个胖子),引入NN尝试的第一个模型便是阿里妈妈提出的DIN模型,操作了2个月有余还无法和base持平,最终放弃(公司里另外一个尝试DIN的团队也没取得论文中提到的那么大的收益)。后来也是先使用简单的模型将整个流程跑通,排查各种问题。解决生产问题最为优先,这算是是工业界和学术界的差异了。
1.2 Lambdarank NN
Labmda Rnak可以直接针对NDCG进行优化,正因如此,在将NN和Lambda Rank相结合的时候带来了第一个突破。主要改进有以下两方面:
- 改用pairwise损失,构建{booked listing, not-booked listing}作为训练样本。训练过程中最小化booked listing和not-booked listing得分差值的交叉熵损失。
- 通过对调listing带来的NDCG的差异作为pairwise loss的权重,这样可以使得NN直接针对NDCG进行优化,同时模型优先优化排序靠前的listing。listing position从2到1收益要高于从10到9。
代码实现:

这里通过改变不同pair对的权重来优化效果可以理解,但是不明白是的最终权重的选取为什么是NDCG的变化值? 这点暂时还不是很懂。正好后面也要尝试pairwise loss。这里暂时先留个坑后面再来补充吧
1.3 Decision Tree/Factorization Machine NN
在线上使用NN模型的同时,作者还探索了一些其它模型,例如GBDT和FM。GBDT上是使用一些可替代的方法简化搜索,用以构建训练数据。FM是对每个召回的listing进行预订率的预估,使用了32维的隐向量。比较有趣的是,虽然这两个模型在测试集上表现都和线上的NN模型相近,但是排序结果的头部却十分不同。因此他们尝试将三个模型进行融合:直接将GBDT每个树的预测结果的在叶子结点中的index作为类别特征放入NN中,FM部分则是直接取预估值作为特征放入NN中。模结构如下:

1.4 Deep NN
上面模型的复杂度令人惊愕。但在尝试将训练数据变成原来的10倍,将模型变成一个简单的只包含两个隐层的DNN模型后,之前的复杂尝试都可以抛弃了。模型很简单,输入层包含195维特征,两个隐层,分别是127和83个ReLU unites。在他们的场景下他们大约使用了17亿的语料(pair对),才使得模型达到比较好的泛化效果。
输入到模型里的特征大都是listing的基础属性,比如价格、设施、历史预定数等等,这些特征通过尽可能少的特征工程后直接输入到模型中。除此外还有一些其它模型产出的特征:智能定价模型输出的价格特征和embedding的相似度特征,因为这些模型使用的的数据并不是直接参与rank模型训练的样本,从而能为DNN模型带来一些外源信息。
?文章里还表达了一些疑问,比如在cv领域,可以通过人工标注的方式来定义人类水平,从而可以和模型效果进行比较。但这种推荐的业务场景下,日志里并不能反应真正的客观事实,有很多隐藏的信息我们无法捕捉到,很难进行人工评测。这里我简直不能再认同!比如图像识别,文本分类这种都有明确的客观答案,我们是可以通过看case来发现问题的,从而优化模型效果。但推荐这种场景,我怎么知道你最终为啥没点击/预定?(没准上分钟在看房,下一分钟你朋友打电话告诉你他刚在那买了新的房子,你可以过去住!尽管你已经在输入支付密码了,你还是有可能取消) 我在我们的场景下也看过若干case(迫于年轻),也看不出啥来,而且只能看很少一部分,很难给出一些明确的结论,即便有也只能是一些泛泛而谈的屁话。我们也有一些业务上的专家试图从case上发现问题,但在我看来往往都带有很强的主观色彩,并不能使我信服,而且更严重的这种主观上的你觉得推荐结果有问题,鸡蛋里总是能挑出骨头的。
2. 一些失败的尝试
作者在这里讲了一些他们并没有取得成功的尝试,中间过程也值得我们反思。
2.1 Listing ID
通过往模型中加入高维稀疏的类别特征,比如video_id和user_id,在一些NLP任务和推荐系统中都取得了提升,所作者试图使用Listing的唯一ID作为特征的输入,使用模型直接构建embedding来表达这个ID背后所独有的特征。然而作者在多种尝试后发现,加入Listing id 非常容易使模型过拟合。虽然id特征在训练集上能带来显著的提升,但是在测试集上却没有这种变化。

作者认为这个已经被别人验证有效的方法在这里行不通的原因主要和业务形态有关系。embedding需要一个同个item有大量的语料来给出适当的取值,这在NLP任务或者视频推荐任务中都是可行的,同一个单词或者视频都可以大量重出现。但在他们的场景下,Listing受到真实世界的物理制约,同一个Listing只能出现在一个市场,同时每天只能被预定一次,一年至多只能被预定365次。然而典型情况下一个房间被预定的次数往往更少,同个Listing数据非常稀疏,这直接导致了模型过拟合。
2.2 Multi-task learning
虽然预定这个操作受到物理因素的限制,但是详情页浏览缺不受到这种约束(这里我也没明白作者想表达什么意思… 大概需要理解他们的业务背景)。他们发现了long view(这里应该是详情页长停留时长?)和种种的booking ratio之间存在某些关联。他们认为这是因为某些listing比较稀疏导致的过拟合。作者尝试使用Multi-task learning的方式对booking probability和long view同时进行建模。

模型结构如上图,两部分输出使用独立的输出层,共享隐层。预期是模型能够从学习到从long view到bookings的转换从而来避免overfitting。由于long view的数量和booking的数量有着巨大的差异,所以重新设计了损失函数,所以针对booking loss给了很高的权重来使得模型保持针对booking的预测。long view用log(view_duration)进行scale。线上排序过程中,仅针对booking进行预测。
然而在线上实验发现,booking没有提升,但是long view的比例缺大大提升了。通过手动查阅那些Long view和booking的case发现有一下一些原因可能导致这些问题:listing非常高端,同时价钱也很高;详情页描述信息冗长;十分特殊或者搞笑的描述以及种种其他原因。long view和booking之间的关联也是Airbnb业务上的独特之处,但在一些场景下却没什么相关性,这也使得基于Long view的booking预测模型十分具有挑战性。这些成为了他们接下来要深入研究的一个问题。
从业务中发现问题,同时再将业务问题用模型的思路去解。也许同样的方法别人能够取得非常好的效果,但在自己的场景下可能并带不来指标上的提升,这不是因为工程没有做好,很可能是因为业务上存在的特殊性导致。而这些除了亲自采坑之外,只能结合业务场景和技术内部原理进行预判了(要求不是一般的高)
3. 特征工程
在DBDT的基础上,他们做了大量的特征工程工作,在这些特征工程背后也暴露出了很多问题,在特征加入的模型时候一定是正确的,但随着多次迭代和整体业务的变化,我们很难去确定之前实现的特征在当前是否时候过时或者依旧最优。而NN一个十分吸引人的点就在于可以将他们从上面的困扰中解脱出来。虽然NN能够利用原始数据进行特征提取,但仍需要很多工作来保证模型足够高效。
3.1 Feature normalization
最开始他们直接将GBDT的特征直接输入到NN的模型里,效果非常差。决策树这类模型对数值特征的确切值并不关心,只要保证大小顺序有意义。而NN模型对数值类特征大小十分敏感。特征scale不同会导致梯度变化巨大。较大的梯度更新会导致梯度消失,这会永久关闭ReLu这样的激活函数。为了解决这一问题,作者对特征进行了normalize,使特征分布在{-1, 1}的区间内,均值为0。针对不同类型的特征选择以下两种方法:
- 如果特征值在样本中类似于正态分布则使用(feature_val-μ)/σ 。其中μ和σ分别是特征的均值和标准差。
- 如果特征值在样本中类似于幂律分布则使用 log((1+feature_val)/(1+median))。
3.2 Feature distribution
除了把特征缩放到一个较小的范围内,还要尽可能使特征分布更加平滑。作者解释了这样操作的具体原因:
Spotting bugs. 在大量的样本中,如何确认某些特征的值是不是有问题?检查特征值的范围有效但效果有限。他们发现通过检查特征值分布对照典型分布是否足够平滑是查找bug的一个好方法。特征值分布图上的峰值很有可能就是bug导致。
Facilitating generalization. 作者基于他们的观察发现,在他们的DNN模型中, 隐层的输出值会变得越来越平滑。作者把隐层中的值取出来,忽略掉零值,并且使用log(1+relu_output)进行变换后,分布如下:

这些图也给了作者一些启发:DNN之所以能在当前场景下有着不错的泛化效果,是因为构建的模型特征很多,特征组合的空间会非常大,在训练过程中模型也只能cover一部分信息。在较低的隐层中特征分布越加平滑,保证了在后面的隐层能更好的响应那些未见过的特征值。因为,作者尝试最大限度的让输入层的特征分布平滑。
除了进行线上测试外,作者还找到了一个有效的离线检测的方法。他们针对测试集里的某个特征进行缩小或者放大,然后观察测试集上的NDCG变化情况。最终发现NDCG非常稳定,也就是说模型能够在未见过的样本上表现同样稳定。
大部分特征在进行排错和适当的标准化后,其分布都会变得平滑。但是有些特殊的特征仍旧需要进行一些处理,比如list的geo信息。下面两个图是list的纬度和精度的原始值分布情况。

可见,经纬度的分布非常不平滑。为了使得特征变得更加平滑,作者计算了list实际位置到用户看到的地图中心的偏移量。变成了下面这样。

看起来数据在图中部十分集中,是因为地图尾部数据导致数据被缩放。再进一步使用log()对经纬度进行变换。

其特征值对应的分布也变得平滑许多。

需要注意的是,在这里的针对经纬度进行的一系列变换是有损的,其会将多个位置映射到相同的偏移量上,这使得模型能够学习到基于距离的全局特征,而不是一些基于特定地理位置的特征。至于特定地理位置的特征,则使用高基数的类别特征进行刻画。
Check feature completeness. 在做一些场景下,分析那些不平滑的特征也能帮助我们发现模型缺失的特征。作者举了个例子,在他们的场景下有个未来可预定天数这样一个特征来刻画list的质量。但是这个值分布不平滑。结合业务发现有些list有最短预定时间的要求,这直接影响了这一特征。但是他们的做法是并没有直接将最短停留时间和这个特征放到模型中,他们认为这个特征和日期相关,设计起来非常复杂。他们使用了list的平均预定天数对这个特征进行了标准化。最终特征在新的这个高维空间上变得平滑。

3.3 High cardinality categorical features
除了listing_id外,作者还尝试了其他类似的特征。其中有些通过简单的特征加工就在NN里取得了比较不错的效果,比如街道信息。在之前的GBDT模型中需要有一个复杂的流程来追踪社区和城市的预定等级分布。然而在NN模型中,交给模型自己处理这类信息是非常简单的,他们通过将用户query和listing的位置信息组合创建了一个新的类别特征。以San Francisco为例,通过level 12的S2编码,一个Embarcadero附近的listing,其位置信息可以标识为(539058204),然后通过某种hash方式可以将{“San Francisco”, 539058204}表示为71829521。然后直接使用这个hash后的数值来构建类别特征。这个数值可以作为embedding信息输入到NN。在模型训练过程中,模型能够捕捉到embedding背后所编码进去的S2 cell和query信息。
可以从下图中看出,针对给出的San Francisco这个例子来说,模型学习的效果符合他们预期:高亮的点除了标记处城市中受欢迎的的位置外,同时还表明了对更远的位置的偏好,而不是最靠近大桥的位置,因为那里是交通最拥堵的地方。

4. 系统工程
这部分简单介绍了他们的系统流程和中间遇到的一些坑,服务端使用java编写,主要负责召回和排序两件事,同时把日志保存下来用于离线训练,使用Thrift对原始数据进行序列化。使用spark对数据进行处理,构建训练语料。使用TensorFlow训练模型,离线的统计/评估脚本大多使用Java/Scala进行编写。最终模型会放在Java的服务端进行使用。
Protobufs and Dataset. 他们之前的GBDT的训练流程使用的都是csv格式的语料。在迁移到TensorFlow的时候尽可能多的复用了以前的流程。所以最开始他们使用了feed_dict这个api输入数据(众所周知,这个api实在是慢),也忽略效率了这一问题。等他们发现他们机器的GPU的利用率只有25 %不到的时候他们才反应过来(偷笑)他们大部分训练时间都话费在了解析数据上。作者用「骡子拖法拉利」来形容他们当时的状态。最终他们改用Protobuf组织数据,并使用data_set后,训练速度提升了17倍,GPU利用率也提升到了90,最终使他们的训练语料的大小从数周提升到了数月。
Refactoring static features. 在Airbnb的场景下,listing有很多特征很少发生变化,比如房间数目,地理位置等,在构建样本的时候提取这些特征会造成一些输入上的瓶颈。为了解决这一问题,他们仅仅将Listing_id作为一个类别特征使用,通过Listing_id构建了一个不可训练的embedding,试图通过这个embedding表达所有这些近乎静态的特征。这样一来,降低了从硬盘上读取数据的大小,从而可以探索更多用户的历史行为数据。
Java NN laibrary. 最早从2017年开始,他们就试图将TensorFlow的模型放到产品里使用。如果使用调用其他编程语言的库,数据转换会耗费大量时间,无法满足线上排序对延时的要求,所以他们使用java实现了线上排序部分。
作者他们遇到的工程问题可以说是很真实了……(我们踩了同样的坑。我们遇到的坑除了训练效率问题外,还有一点就是我们在小流量实践NN模型的时候,线上还保留了旧版本的模型,我们需要复用同一个pipeline,换言之我们在日志中序列化保存的数据是另外一种中间结果,我们离线去做一些转换,来同时支持我们NN模型和原有模型的训练。因为保存的中间结果,线上服务使用C++编写,离线格式转NN使用的是python,相同的逻辑用不同语言实现了两次,导致这里曾经出现了一些不一致的地方,后续排查问题的时候也带来了一些困扰,在系统设计初期应该尽量避免这些问题。另外,我们这边线上排序的时间也是十分有限,大约100ms,使用TF Serving数据传输耗时大约在10ms左右。目前采取的方案是,实验模型使用TF Serving进行预测,待模型稳定后,使用C++代码实现模型线上排序部分以降低耗时。
5. 超参数
在最开始的时候,作者他们也曾花费大量时间调整模型参数,虽然并没有带来太多实质上的提升,但解决了他们的措施恐惧症(Fear of missing out),同时也让他们在后面做出选择的时候更加自信。
Dropout. 最开始作者他们以为NN中的必须添加dropout以起到正则化的作用。在他们的场景下,作者进行多种尝试后,发现dropout最终都会导致他们离线评估指标轻微下降。作者认为,dropout更像是一种数据增强变换:一些随机的数据缺能够模仿到训练集中数据缺失的数据,这会帮助模型提升效果。但是在他们的场景下,这种随机的数据缺只会产生很多无效的情况,反而会分散模型的注意力,使模型效果变差。作者他们找到一种可供替代的解决方案,他们针对一些特定特征的分布,手工构造了一些噪声,最终可以帮助模型在离线NDCG上提升接近1%。但遗憾的是,这些改进在线上并没有显著效果。
Initialization. 纯粹出于习惯,作者尝试使用了全零初始化训练模型,最终发现这是一个非常糟糕的方法(笑)。在他们调研了多种方法后,他们选择了Xavir对参数进行初始化,并且将embedding的区间统一在{-1,1}。
Learning rate. 学习率可调整的区间非常宽泛,但在他们的场景下,作者发现Adam的默认值效果已经非常好了。最终他们使用的是LazyAdamOptimizer,这个优化器在训练较大的embedding的时候速度更快。
Batch size. 不同的Batch size会对模型的训练速度产生不同的影响,至于对模型本身的影响也难以理解,作者抛出了 https://arxiv.org/abs/1711.00489 这篇文章,算是他们可找到的为数不多的参考。他们也没有完全按照那边文章里的建议,最终他们只是将Batch size固定到200来训练模型。
6. 特征权重
作者他们在尝试理解特征在NN里表现效果上也做了一些尝试,分析特征的重要新对于模型迭代和工程设计都非常重要。虽然NN在特征间的非线性关系计算上非常强大,但这也是的我们在分析任何单独特征的时候变得非常困难。
Score Decomposition. 在之前的GBDT上,作者使用Partial Dependence Plots进行分析。他们刚开始的时候进行了一些「naive attempt」,尝试对模型最终得分进行分解,来分析每个输入节点对结果的贡献,但因为有ReLU这种非线性的激活函数存在,这基本无法实现。
Ablation Test. 这个方法是通过每次去掉一个特征,使用剩余的特征重新训练模型,用新旧模型性能差异比例来表示特征的效果。作者他们在这里也有些困惑:通过随机丢掉一个特征获得的性能差异和重新训练时候在离线指标上观察到的噪声十分类似(我理解是离线评估变化都很微弱。)这可能是由于他们的特征经过一系列工程后,模型能够弥补其中单个特征的缺失。作者也使用忒修斯悖论来评价这种方法:能不能一直从模型中去一个特征,声称模型效果没有变化?
Permutation Test. 作者他们尝试使用随机置换测试集中某个特征取值的方式来分析特征权重。某个特征越重要,那么在打乱之后,预测结果会变得越差。然而这个测试却得到了一些荒谬的结果:在他们预测房间预订概率的场景下,房间数量被该方法认为是最重要的特征。他们忽略了这个方法的一个大前提,即特征间相互独立。房间数量背后往往隐含着房间价格,可以接待的客人数量,娱乐设施等等属性。单独对这个特征进行打乱会产生很多并不会在真实世界中存在的样本,在这些不存在的样本空间里分析出的特征重要性会有很严重的误导性。但这个测试方法也并不是一无是处:如果随机置换某个特征后预测效果没有发生变化,也正说明模型并不需要这个特征。
TopBot Analysis. Topbot是top-bottom analyzer的缩写。他们针对一次query召回的样本进行排序,分别取rank排序在头部和尾部的数据,对比特征在这两组数据上的取值分布。这个比较可以明显的看到模型对某个特征在不同取值区间上的利用情况。如图所示,排序靠前的房间更倾向于更低的价钱,这表明模型对房间的价格敏感。然而不论头部或是尾部的房间,其在review count这个特征维度上分布差异不大,这也反映出这个版本的模型并没有很好的利用这一特征。

虽然他们找了很多方法去分析特征权重,但都没有拜托条件独立的困扰,似乎TopBot Analysis 算是一个可行的方法。
7. 总结
作者坦然说,最开始他们受到各种深度学习成功案例的影响,对整个排序模型迁移到深度模型上来这件事太过乐观,觉得只要简单的把模型本身替换掉就能带来巨大的提升。所以最开始他们并没有任何收益,这也使得他们一度陷入到绝望的山谷。最开始,模型离线指标是下降的,这也让他们意识到迁移到深度模型要伴随着整个系统的扩展,需要从模型的角度重新思考。虽然深度模型表现已经超过GDBT,但因为GBDT在小的尺度上具有更好的可解释性,同时也更好操作,他们依旧会使用GBDT解决一些中等规模的问题。
作者表示会真心推荐深度学习给其他人,不单单是因为深度模型在线上带来了巨大的提升,深度模型也改变了作者他们技术重心。在这之前,他们花费了大量精力在模型的特征工程方面,而深度学习使他们从中解脱出来。这也让他们在一个更高的层次上思考问题:怎么能够提升优化目标?是否已经准备表征了用户?从开始探索深度学习到发文,两年多时间,在作者看来他们才刚刚迈出了一小步。 (完)
原本也不是想做通篇的翻译。很多地方觉得很简单,没必要,但是真的实操起来确实会遇到各种各样的问题,况且不论是实操的人还是这个人的leader都对深度学习没有任何经验的时候,文章里讲到的坑,翻过的错很容易出现。比如你的老大想当然的问你为什么通过分数分解的方式分析每个特征权重。虽然现在已经9012年了,但是我们的推荐系统还是以FM为核心,当然出去吹逼都会说什么深度模型,倒头来还不就是在一个很小的流量上跑了跑,还是核心指标低了好几个点的情况下。现在面临的情况就是组里原本两个做这个方向的同学历时8个月,还没成功的将DNN和之前模型效果达成一致,现在还被砍掉了一个人。可能马上这个项目就又要被沉寂了吧…… 虽然现在我也没什么机限会去实操这些,经验有,不好总结什么,但文章的介绍的路径和细节上来看,作者也是很实在,踩过的坑,解决问题的思路都值得学习。
总之,期待春天早日到来吧。