stm32cubeide自动补齐代码_走马观花Google TFRanking的源代码

作者:石塔西

来源:https://zhuanlan.zhihu.com/

p/52447211

整理:深度传送门

介绍

Google最近开源了基于TensorFlow的Learning-To-Rank(LTR)框架TF-Ranking[1]。

5469f6286ee015e7bedd723970790ea4.png

最初我很是兴奋。这是因为

  • LTR比目前常用的预测点击/转化的二分类算法,更加符合推荐系统的实际需求;

  • 我经常使用lightgbm, xgboost来进行LTR,但是GBDT天生只擅长处理稠密特征,而“稀疏特征”才是推荐、搜索领域中的“一等公民”。TF-Ranking基于TensorFlow,可以充分利用embedding, crossing, hashing等手段来处理“稀疏特征”;

  • TF-Ranking是一个框架,可以接入任意一种算法来计算query-doc, user-item之间的相关性,也可以在pointwise/ pairwise/ listwise这三大类loss function中方便切换。这使我们可以轻松尝试多种组合,比如基于wide&deep的pairwise LTR,基于deepfm的listwise LTR;

  • TF-Ranking是基于TensorFlow Estimator框架实现的,自动具备了分布式训练、部署的能力,可以应对海量数据集。

所以,我马上下载了TF-Ranking的源码[2]来阅读,但是粗读一遍之后,却有些失望:

  • 众所周知,TensorFlow的运行方式是“先定义DAG,再执行”。但是,一个机器学习算法,并不全是矩阵运算与求导。排序、组织pair之类操作,用普通python代码很容易完成,在tensorflow中非要调用蹩脚、文档不健全的api来完成。Tensorflow与其说是一个python库,不如说是一门全新的语言,而且这门语言细节繁杂(光“回”字就有4种写法)且缺少文档。

  • TF-Ranking因为一些“华而不实”的功能而复杂了代码,比如所谓的Groupwise Scoring Function功能;但对一些实际工程上极其重要的功能,比如“稀疏化”却支持得不够好。

总之,如果下次我要训练一个LTR模型,我肯定还首选LightGBM来完成。不过,我还是将一些重要的代码细节总结如下,可以从中管窥LTR算法的实现,也可以学习一些TensorFlow的高级用法。

LTR简介

在正式介绍TF-Ranking之前,还是需要简单介绍一下LTR算法本身。目前广泛使用的一些推荐算法,本质上还是二分类算法,类似于“点击率预估”。尽管线上效果还不错,但是距推荐系统的实际需求还有差距:

  • 推荐系统中的“排序”任务关心的是相对顺序正确与否,而不是对“单个物料的点击概率”预测得是否精准;

  • 二分类优化的loss只基于“预测分数”与“真实值”的差异,对“预测分数”可导,可用Gradient Descent优化之。而推荐系统中的很多业务指标,如NDCG, MAP,是基于“排序位置”的。“预测分数”的微小变化,或者不足以改变顺序使metric不变,导数始终为0,或者改变排序而导致metric剧烈变化,不可导,使得无法用Gradient Descent优化。

为此,Learning-To-Rank算法应运而生。学习LTR,经典的教材是Microsoft的《From RankNet to LambdaRank to LambdaMART: An Overview》[3]一文。我将其中的原理简述如下。

用户输入一个query q,待排序的两篇文章Ui, Uj,si是模型给文章Ui打的分数,预测q与Ui的相关性,sj是模型给Uj打的分数,预测q与Uj的相关性。我们可以预测将Ui排在Uj前的概率,从而将排序转化成一个二分类问题, “Ui排在Uj前面”的概率定义如下(注意 a69cce3aff518ecb190243dc717224bd.png 是一个超参,不代表sigmoid函数)

e745eb7f48a79a27c13e115640460f85.png
图1. 文章Ui排在Uj前的概率是si-sj的sigmoid函数

7d89b5de4e7c0d131b12c71f0eaf91c9.png定义为”Ui排在Uj前面”这个论述的真实性(ground truth),1代表论述为真,0代表为假,0.5代表Ui与Uj位置应该相同。如下图所示,做一个简单的数据变换,用 97848cfe-d92e-eb11-8da9-e4434bdf6706.svg 来代表“Ui排在Uj前面”论述的真实性。从而我们可以定义binary cross entropy loss如下

90e31cfebe8f441b4c95a5552b45829c.png
图2

我们再简化一下“训练集”的构成,因为如果一对儿文章“Ui排在Uj前”为真,那么”Uj排在Ui前”一定为假,导致冗余。所以,在训练集中,我们只需要保留所有 07bf8a7571aba2ae5b05a01ec3720a8f.png=1的训练样本。然后再让以上binary cross-entropy loss对”待优化变量4b11abedaf2f4b1acbdeddf6620f943b.png”求导,则有

d6dbb3d16e36ef46a99d21f1d1dfc6cc.png
图3

注意上式中,“预测得分s对w”的导 bbb4ee6b37915d7a6f13132c630e29ee.png 和 9fc6affa4ebb05b720779111d97ecaef.png,可以由NN来实现,也可以由GBDT来实现,属于“开箱即用”的成熟技术,就不用赘言了。在这里,我们关心的 e98ae99e67b996f45369f4c1aa89ddee.png,即loss function对第i篇文章的预测得分的导数,注意这里我们用7e177cfd6e1367c220c271a7d52d76bd.png来表示,这也就是LambdaRank, LambdaMART一系列算法名字的由来。

注意两点:

  • 这里7e177cfd6e1367c220c271a7d52d76bd.png还是一个真的梯度,区别于下面要说的为了优化某不可导指标而人为设计的梯度;

  • 以上binary loss function,对“列表头部的排序正确性”与“列表尾部的排序正确性”一视同仁,实际上优化的是AUC

而如果我们要优化NDCG这样重点关注头部位置的指标,正如前所述,这些指标对单个文档的“预测得分”的微小变化,要么无动于衷,要么反应剧烈,即我们无法定义一个“既能优化NDCG,又对283fe8995dcb8cb59b047866cc8c62cc.png连续可导”的loss。那怎么办?

LambdaRank/LambdaMART的解决思路,简单而被证明有效:既然无法定义一个符合要求的loss,那就不定义了,直接定义一个符合我们要求的梯度就行了。这个不经过loss,而被人为定义出来的梯度,就是所谓的Lambda梯度。

那么优化NDCG需要怎样的Lambda梯度?很简单,

1. 在上一轮迭代结束后,将候选文档/物料,按照上一轮迭代得到的预测分数从大到小排序;

2. 对这一对儿,如果还用binary cross entropy loss预测排序是否正确,其梯度定义和图3中一样

419ba80721f28a2d3d5cbf4aa2af9c33.png
图4. 不考虑优化指标,只考虑排序正确性时的Lambda梯度

3. 将Ui,Uj的位置调换,计算NDCG的变化a6848cfe-d92e-eb11-8da9-e4434bdf6706.svg ,然后将a6848cfe-d92e-eb11-8da9-e4434bdf6706.svg乘到上一步得到的Lambda梯度上,就是优化NDCG所需要的Lambda梯度:

5e440aee2ccbe98a35b0408e3f3707e4.png

以上公式中a6848cfe-d92e-eb11-8da9-e4434bdf6706.svg,在TF-RANKING的代码中被称为Lambda weight。优化不同的指标,将会定义不同的Lambda weight。

综上所述,我们可以看到,实现一个Learning-To-Rank算法,有4个重点:

1. 样本如何组织。显而易见,排序只对“同一个query下的候选文档”或“同一个推荐session下的候选商品”才有意义。所以,与普通二分类不同,LTR训练集有一个分组的概念,同一个组内的候选物料才能匹配成对,相互比较;

2. 打分。本来很简单的一个步骤,将query-doc, user-item的特征喂进模型,模型的输出就是我们需要的分数,预测query-doc, user-item的相关程度。但是TF-Ranking却使用了所谓的Groupwise Scoring Function(GSF),给一个候选物料bd85b7e085812fe6d7150196816834d1.png打分时,要把它与其他候选物料编组,并且要考虑bd85b7e085812fe6d7150196816834d1.png在组内的不同位置上,然后给这一组同时打分。我觉得是一个华而不实、得不偿失的功能;

3. 定义loss。Learning-To-Rank有pointwise/pairwise/listwise三种定义loss的方式。Pointwise与普通的ctr预估无异,上面的例子介绍的就是pairwise, listwise以后找个机会再介绍。不同的loss定义方式,再结合优化不同指标而定义的不同lambda weight,可以衍化出更多种算法;

4. Lambda Weight。如前所述,根据需要优化的指标(NDCG/MAP/MRR)的不同,需要定义不同的lambda weight,乘在5aff838d182b60f7fcc937f6c64f553d.png的前面。

接下来,就让我们看看,TF-Ranking是如何实现以上四个方面的。

加载数据

排序必须针对同一个query,或同一个推荐session。所以,一个样本必须是针对同一个query,或隶属同一个推荐session的候选item/doc list。这一点在data.libsvm_generator这个函数中有所体现,它把具有相同qid的样本聚合在一起。从libsvm文件中加载数据的代码如下所示:

train_dataset = tf.data.Dataset.from_generator(  tfr.data.libsvm_generator(path, num_features, list_size),  output_types=({str(k): tf.float32 for k in range(1, num_features + 1)},        tf.float32),  output_shapes=({str(k): tf.TensorShape([list_size, 1]) for k in range(1, num_features + 1)},        tf.TensorShape([list_size])))train_dataset = train_dataset.batch(3)op_next_features, op_next_labels = train_dataset.make_one_shot_iterator().get_next()

这里op_next_features是一个dict,key是feature name,value是[batch_size, list_size, 1]形状的tensor。List_size就是一个query下待排序的最大doc/item数目。如果不足,要补齐如果超过,则要抽样截断。这种固定list_size的做法,既浪费空间,又复杂了代码,使得代码充斥着对相应位置的x或y是“真实元素”还是“填充元素”的判断。

尽管TF-Ranking声称自己是scalable的,但是仅从目前的数据加载上来看,每个特征都读成[batch_size, list_size, 1]的稠密矩阵。而在实际系统中,这样的矩阵肯定有不少超级稀疏的,甚至是全0的,从而浪费了大量内存与计算时间。

打分

打分遵循了Learning Groupwise Scoring Functions Using Deep Neural Networks这篇文章的思路,是所谓的multi-item scoring,即同时给一组候选物料同时打分。之所以这样做,根据作者的说法,是为了模仿用户在面对物料列表时先相互比较,再决定点击哪一个的“比较”过程,因此给某物料打分必须要考虑该物料所在的group。

做法上也很简单、粗暴,一个group_size=m的group,就是将m个物料的特征都拼接起来,喂入模型。而NN的最后一层也不再只有一个输出,而必须输出m个数值,即同时为组内的所有物料打分拼接就会有顺序问题,为使预测结果不受拼接顺序的影响,就必须考虑从全部n个候选物料中抽取一个长度为m的group的所有排列组合。当然论文中也提出了简化算法,即先shuffle再依次取长度为m的滑窗。

在tf_ranking_libsvm._score_fn中,提供了一个打分函数的例子

def _score_fn(unused_context_features, group_features, mode, unused_params,unused_config):  """Defines the network to score a group of documents."""  with tf.name_scope("input_layer"):    group_input = [    # group_features[name]的形状是[batch_size, list_size, 1]    # flatten将上述形状压缩成[batch_size, list_size]      tf.layers.flatten(group_features[name])      for name in sorted(example_feature_columns())    ]    # 这里完成将同一个group内所有候选物料的特征拼接起来,拼接结果[batch_size, list_size*num_features]    input_layer = tf.concat(group_input, 1)  is_training = (mode == tf.estimator.ModeKeys.TRAIN)  cur_layer = tf.layers.batch_normalization(input_layer, training=is_training)  for i, layer_width in enumerate(int(d) for d in FLAGS.hidden_layer_dims):    cur_layer = tf.layers.dense(cur_layer, units=layer_width)    cur_layer = tf.nn.relu(cur_layer)        ……  # 注意最终预测层的输出是group_size,即同时为group内所有候选物料打分  logits = tf.layers.dense(cur_layer, units=FLAGS.group_size)  return logits

以上User-defined scoring function只是针对从全部n个候选文档中抽取出来的单一一个group的。从全部n个候选文档中多轮抽取m个文档,再聚合多轮得分的过程发生在model.make_groupwise_ranking_fn._groupwise_dnn_v2这个函数中

……# 根据"先shuffle再取滑窗"的方式,从总共list_size个候选文档中,抽取了num_groups个长度为group_size的groupindices, mask = _form_group_indices_nd(is_valid, group_size)num_groups = array_ops.shape(mask)[1]with ops.name_scope('group_features'):  # For context features, We have shape [batch_size * num_groups, ...].  large_batch_context_features = ……  # 原来的每个batch是[batch_size, list_size]  # 由于groupwise scoring时,需要考虑每个文档在长度=m的group中每个位置的情况  # 所以新的batch是[batch_size * num_groups, group_size, ...]  # For example feature, we have shape [batch_size * num_groups, group_size, ...].  large_batch_group_features = ……# 交给user-defined code进行打分,得分scores的形状shape=[batch_size * num_groups, group_size].scores = _score_fn(large_batch_context_features, large_batch_group_features, reuse=False)# 聚合多轮打分,最终打分仍与原始输入相同形状,即[batch_size, list_size]with ops.name_scope('accumulate_scores'):  scores = array_ops.reshape(scores, [batch_size, num_groups, group_size])  # Reset invalid scores to 0 based on mask.  scores = ……  # list_scores: [batch_size, list_size]  list_scores = array_ops.scatter_nd(indices, scores, [batch_size, list_size])  # Use average.  list_scores /= math_ops.to_float(group_size)

我对这种所谓的Groupwise/Multi-Item Scoring很不以为然。模仿用户在物料序列中的“先比较、再选择”的行为,已经由Pairwise/Listwise Loss Function体现了,在打分上再体现“比较”的意味,最多算是锦上添花。但是付出的代价,却是

  • 按组拼接特征导致的特征维度增长,

  • 考虑多种拼接顺序导致的训练/预测样本的增加,

  • shuffle导致的训练与预测时的不确定性,

总之,我觉得“得不偿失”。所以在自带的例子中,group_size的缺省值就是1,又切回了传统的所谓single-item scoring的模式。

Loss Function

以经典的pairwise_logistic_loss为例,它的定义在losses._pairwise_logistic_loss中

def _pairwise_logistic_loss(labels,logits,weights=None,lambda_weight=None,reduction=core_losses.Reduction.SUM_BY_NONZERO_WEIGHTS,name=None):  def _loss(logits):    """The loss of pairwise logits with l_i > l_j."""    # 代码中的公式,既能处理li>lj的pair,也能处理li    # 但是为了避免冗余,我们在loss中只考虑li>lj的pair,所以    # The following is the same as log(1 + exp(-pairwise_logits)).    # 而且这里的logits是si-sj,即打分之差    return nn_ops.relu(-logits) + math_ops.log1p(math_ops.exp(-math_ops.abs(logits)))  with ops.name_scope(name, 'pairwise_logistic_loss', (labels, logits, weights)):    return _pairwise_loss(_loss, labels, logits, weights, lambda_weight, reduction=reduction)

在_pairwise_loss中

def _pairwise_loss(loss_fn,labels,logits,weights=None,lambda_weight=None,reduction=core_losses.Reduction.SUM_BY_NONZERO_WEIGHTS):  """  labels与logits都是[batch_size, list_size]的形状  """  # 先根据logits(即当前预测得分)进行排序,底层是调用nn_ops.top_k实现的  sorted_labels, sorted_logits, sorted_weights = _sort_and_normalize(labels, logits, weights)  # _pairwise_comparison  # 将sorted_logits(预测得分)两两相减,得到n^2个pairwise_logits  # 调用lambda_weight这个计算器,根据需要优化的metric, 计算出pairwise_weights  _, pairwise_logits, pairwise_weights = _pairwise_comparison(sorted_labels, sorted_logits, sorted_weights, lambda_weight)  ……  # 根据pairwise_logits(即同一个query下两个候选物料的预测分数之差)计算某一pair上的loss  # 再根据pairwise_weights(所谓的Lambda Weight,即这一pair中两个物料交换顺序导致的optimize metric的变化) 进行加权聚合  return core_losses.compute_weighted_loss(    loss_fn(pairwise_logits), weights=pairwise_weights, reduction=reduction)

Lambda Weight

LTR的一大优点就是能够直接优化NDCG, MAP这样依赖排序、不可导的指标。其作法是在计算普通的pair loss之后,为每个pair loss乘以一个权重,即代码中的lambda weight。比如在按照上一轮迭代后所有文档的得分排好序后,某两个文档 deef54e4f03cdf4d3d275b4a63290b86.png6ef45983d66355b7edc253e657034cc0.png,调换二者顺序而导致的NDCG的变化,就是 ffe67abf9fcc24e3698cdb05a70bb4b3.png这一对儿的lambda weight。

Lambda weight计算器的基类_LambdaWeight和优化不同指标的lambda weight的实现都定义在losses.py中。以最常见的优化NDCG为例,具体实现见losses.create_ndcg_lambda_weight函数,

def create_ndcg_lambda_weight(topn=None, smooth_fraction=0.):  return DCGLambdaWeight(    topn,    # gain_fn是NDCG公式中的分子    gain_fn=lambda labels: math_ops.pow(2.0, labels) - 1.,    # rank_discount_fn是NDCG公式中的分母    rank_discount_fn=lambda rank: 1. / math_ops.log1p(rank),    normalized=True,                 smooth_fraction=smooth_fraction)

而真正计算lambda weight发生在DCGLambdaWeight.pair_weights函数中

……gain = self._gain_fn(sorted_labels)# 得到DCGif self._normalized:  # 除以max_dcg,得到NDCG  gain *= self._inverse_max_dcg(sorted_labels)# 两两相减,得到pair_gain,即delta gainpair_gain = array_ops.expand_dims(gain, 2) - array_ops.expand_dims(gain, 1)……# 一种新的计算rank discount的方式,具体公式见论文# 中的公式(15)u = _discount_for_relative_rank_diff()# Standard discount in the LambdaMART paperv = _discount_for_absolute_rank()# pair_discount是以上两种方式计算得到的discount的加权平均pair_discount = (1. - self._smooth_fraction) * u + self._smooth_fraction * v# delta NDCGpair_weight = math_ops.abs(pair_gain) * pair_discount……

总结

本文记录了TF-Ranking源代码中一些重要的代码片段,能够提纲挈领地串起整个TF-Ranking的代码流程。在我看来,TF-Ranking现阶段还不成熟,加入Grouping Scoring Function这样华而不实的功能使代码变得复杂,却对“稀疏”这样的实用功能支持得不够好。尽管Google声称使用TF-Ranking在Gmail Search和Google Drive Recommendation上都发挥出很好的性能,但我觉得,目前的TF-Ranking离撼动RankLib, LightGBM在LTR领域的地位还任重道远。

如果有耐心读到这里的话,就关注一下公众号吧:)

9e20c58364804b8e8d33df7f15eeb6fb.png

参考

[1] https://ai.googleblog.com/2018/12/tf-ranking-scalable-tensorflow-library.html

[2] https://github.com/tensorflow/ranking

[3] https://www.microsoft.com/en-us/research/publication/from-ranknet-to-lambdarank-to-lambdamart-an-overview/

f5f2664c2938a43e63f42d4093f4d684.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值