LTR入门:从 Ranknet 到 LambdaMart 原理与 lgb 实战


前半部分内容主要基于《From RankNet to LambdaRank to LambdaMART》,最近复习 LTR 相关算法,想到之前看过这篇经典 paper,拿出来重新看看,也顺手搞了一个翻译版出来。毕竟这篇文章差不多是 LTR 算法入门必看文章了。
第二部分、第三部分是 rankloss 日常使用的一些经验;以及使用 lightgbm 实战一个简单的排序算法变体的案例,对原理比较了解的同学或者对如何实操更感兴趣的读者,可以直接跳到第二部分。

第一部分 论文翻译

1 Introduction

LambdaMART是LambdaRank的增强树版本,它基于RankNet。事实证明,RankNet,LambdaRank 和 LambdaMART是解决工业上 rank 问题的非常成功的算法:例如,多个LambdaMART的集成版本赢得了最近的 Yahoo! Learning To Rank Challenge[5]。虽然在这里我们将专注于ranking,但通常可以直接修改MART,特别是 LambdaMART,以解决各种监督学习问题(包括最大化 IR 的一些目标,如NDCG,注意 NDCG 并不是一个平滑的函数)。

本文档试图给出这些算法的独立解释。唯一需要的数学背景是基本矢量微积分;并且我们假设读者对LTR的问题有一定的了解。我们希望这个概述是完全独立的,例如,希望训练增强树模型以优化他们想到的一些信息检索目标的读者可以理解如何使用这些方法来实现这一点。网络搜索的排名目标在本文中是主要的示例。

2 RankNet

对于 RankNet [2],底层模型可以是任何模型,模型的输出是模型参数的可微函数(通常我们使用神经网络,但我们也使用boosted trees实现RankNet,我们将在下面描述)。 RankNet 训练的工作原理如下。训练数据按查询分组。在训练期间的给定点,RankNet将输入 n 维特征向量 x 映射到数字 f(x)。对于给定的 query,选择具有不同 label 的一对 URL,Ui和Uj,并且将每个这样的pair(具有特征向量xi和xj)喂给模型,得到每个 doc 的计算得分 si = f(xi) 和 sj = f (xj) 。让 Ui > Uj 表示 Ui 应该排名高于 Uj 的事件(例如,对于此查询,Ui被标记为 ‘excellent’ 而 Uj 被标记为 ‘bad’,该标注和 query 相关)。模型的两个输出通过sigmoid函数被映射为“Ui应该排名高于Uj”的概率,即:

其中参数σ的选择决定了S形函数的形状。 sigmoid模块是神经网络训练中的一种常见组件,已被证明可用于良好的概率估计[1]。 然后,我们应用交叉熵 cost 函数,他会惩罚模型输出概率与期望概率的偏差:令 Pij(hat) 是 Ui 应该排名高于 Uj 的已知概率。 然后 cost 是:

对于给定的查询,如果文档 i 被标记为比文档 j 更好,则将 Sij(可能取值为0,+1,-1)定义为1,如果文档i被标记为与文档j不太相关则为-1,如果他们有相同的标签则定义为 0。 在本文中,我们假设我们有一个确定性已知的目标排序,因此Pij(hat) = 0.5*(1+Sij).(注意,模型可以处理测量概率的更一般情况,例如,可以通过统计Ui和Uj在历史数据中的共现数据来估计Pij(hat))。结合上面两个公式可以得到:

这个 loss 有一个很好的性质是,损失函数是对称的(交换 i 和 j 并且改变 Sij 的符号会使C不变)。
即,Sij=1时,

而Sij=-1时,

请注意,当 si = sj 时,损失依然存在,等于 log2,因此模型包含一个margin(即具有不同标签的文档,但模型分配相同的分数,在排名中仍然相互推离,直到两个标签的文档之间有足够的区分度)。 此外,渐近地看(损失函数的梯度),如果评分函数给出错误的排名,cost会表现出近似线性,如果评分函数可以给出正确的排名,则是 0。 因此:

该梯度可用于更新权重wk(即模型参数),即通过随机梯度下降来降低损失(注意下式可以分解):

其中η是正的学习率(使用验证集选择的参数;通常取值为1e-3到1e-5)。
很明显:

通过梯度下降学习排名的想法是本文中出现的一个关键思想,即使当期望的cost没有良好的梯度时,甚至当模型不具有可微分参数时(例如boosted trees的集合):为了更新模型,我们必须指定相对于模型参数 wk 的 cost 的梯度,并且为了做到这一点,我们需要相对于模型得分 si 的 cost 的梯度。 增强树的梯度下降公式通过直接建模 ∂C/∂si 来绕过计算∂C/∂wk的需要。

2.1 Factoring RankNet: Speeding Up Ranknet Training

上面的公式是可以分解的,注意,这是通向 LambdaRank 的关键步骤:对于给定的一对URL,Ui和Uj:

上式中:

设 I 表示一组索引 (i,j),我们希望 Ui 与 Uj 的排序不同(对于给定的查询)。(集合 I 中只包含label不同的doc的集合,且每个pair仅包含一次,即(Ui,Uj)与(Uj,Ui)等价。为方便起见,我们假设 I 中只包含 (Ui,Uj) 表示 Ui 相关性大于 Uj 的pair,即 I 中的 pair 均满足 Sij=1 )
请注意,由于RankNet从概率中学习并输出概率,因此不需要 URL 的 level 具体是多少; 它只需要集合 I (也就是仅利用了数据里的相对关系),这种方式可以说没有利用完全信息,但是也更加通用,因为实际如果我们使用自动样本, pair 之间实际上可能是互相冲突的:U1 > U2,U2 > U3和 U3 > U1)。

批量化处理所有对wk更新权重的贡献:

我们引入了λi(每个url对应一个λi:注意,带有一个下标的λ是带有两个下标的λ的和)。 为了计算λi(对于url Ui),我们找到所有和 i 组成pair的另外的url,即 ( i, j ) 中的 j 和 ( k, i ) 中的 k 。可以这样计算,令 λi 初值为零。对于前者(即相关性低于 i 的文档),我们将pair对应的 λij 加到 λi 中,对于后者(即相关性高于 i 的文档),我们从 λi 中减少 λki。 例如,如果只有一对 pair ,即 I = {{1,2}},并且λ1=λ12=-λ2。

再举一个例子:
下面我们用一个实际的例子来看:有三个doc,其真实相关性满足U1>U2>U3,那么集合I中就包含{(1,2), (1,3), (2,3)}共三个pair ,则:

显然λ1=λ12+λ13,λ2=λ23−λ12,λ3=−λ13−λ23。
一般来说,我们有:

下面我们描述一下这里面的关键思想:我们可以将 λ 视为力,一个附加到每个 doc 的力,其方向指示我们希望网址移动的方向(以尽可能的满足数据中没队 pair 的约束),其长度表示力的大小。给定 URL 的 λ 是通过该 URL 所属的所有 pair 计算的。当我们第一次实现 RankNet 时,我们使用真正的随机梯度下降:在检查每对URL(具有不同标签)之后更新权重。
然而刚刚我们看到,我们可以为每个 doc 累积 λ ,将所有 pair 提供的两个相关的贡献累加到对应的 doc 上,然后进行更新。这其实是以 mini-batch 的方式来训练,其中首先为给定查询计算所有权重更新,然后应用,但训练加速的本质由对目标函数的分解带来的,而不仅仅是使用mini-batch的这种表现形式。
这导致RankNet训练中的非常显著的加速(因为更新权重代价很大,例如对于神经网络模型,它需要back propogation)。实际上,每个查询的训练时间(关于网址数量)从接近二次降提升到接近线性。它还为LambdaRank奠定了基础,但在我们讨论之前,让我们回顾一下我们希望学习的信息检索的评估标准。

这里我们可以考虑一下,如果我们使用 tensorflow,怎么实现这种加速版本的 ranknet?

3 Information Retrieval Measures

信息检索研究人员使用排名质量度量,如Mean Reciprocal Rank(MRR),Mean Average Precision (MAP), Expected Reciprocal Rank (ERR), and Normalized Discounted Cumulative Gain (NDCG)。 NDCG [9]和ERR [6]的优势在于它们处理标注具有多个相关性级别的情况(而MRR和MAP是针对二分类相关性标注设计的),并且该度量包括对用户显示的结果的位置依赖性(排名结果更靠前的具有更大的重要性),这特别适合网络中的搜索场景。
然而,所有这些度量都具有不适合作为模型损失函数的特点,因为它们是不光滑的或者是不连续的,因此梯度下降难以直接在这几个目标函数上生效。 例如,NDCG定义如下。 给定搜索结果集(对于给定查询)的DCG(折扣累积增益,Discounted Cumulative Gain)是:

其中,T是截断数目(例如,如果我们只关心返回结果的第一页,我们可能需要T = 10),li 是第 i 个列出的URL的标签。 我们通常使用五个相关级别:0,1,2,3,4。 NDCG是DCG的 normalized 版本:

其中分母是该查询可达到的最大DCG@T,因此NDCG @ T 的取值范围在0到1之间。

ERR最近被引入并且受到级联模型的启发,其中假定用户读取返回的URL列表,直到找到他们喜欢的URL。 ERR定义为:

其中,n是指对于这个 query 进行评估的前多少篇文档,l 是文档label,即相关度等级,令 lm 表示最大相关度等级,则:

Ri模拟用户在相关的排名位置处找到文档的概率。
可以把上式中的 Rr 放入连乘符号内部,则得到:

这个连乘式子表示的是:“用户停在第r篇文档的概率”。很类似于几何分布:“前k-1枪未命中,第k枪命中了”。放在 rank 的场景下指的就是“前 r-1 个文档未满足用户需求,直到第 r 个文档才满足”。
ERR中的 1/r 是位置权重,因此可以将其抽象为 φ® ,只要该函数满足 r=0 时,φ®=1,且是单调递减函数即可。比如,DCG中使用的就是:

直观地想,我们可能会期望计算损失ΔERR(交换两个文档的排名而使所有其他文档位置保持不变而导致的ERR的变化)。对于给定查询的文档数量,计算ΔERR将是文档数量立方的复杂度,因为排名位于被交换的两个 doc 之间的 doc被认为是对 ΔERR 有贡献的,而 ranknet 必须为每对文件计算这个贡献。 然而,该计算具有二次成本(其中二次成本来自需要为具有不同标签的每对文档计算ΔERR),因为它可以如下排序。 设Δ表示ΔERR。 设Ti表示1-Ri,创建一个数组A,其第i个分量是仅计算到i级的ERR。 (计算A具有线性复杂性)。

注意,与NDCG不同,保持所有其他URL固定,通过交换Ui和Uj引起的ERR的变化,取决于具有 Ui 和 Uj 之间 URL 的标签。 因此,好奇的读者可能想知道ERR是否一致:如果Ui dUj和两个排名位置交换,ERR是否必然会减少? 很容易看出,当 Ri > Rj 时,下面的计算说明上述Δ是非负的。

4 LambdaRank

虽然通过简单地使用上述度量作为验证集的标准,可以使RankNet与上述度量相匹配,但我们可以做得更好。 RankNet正在针对错误的pair的数量进行优化( 当时,是这个目标的平滑的凸近似的版本,因为实际上我们可以认为 ranknet 是一个针对 pair 的二分类模型),如果这是期望的损失,则没有问题,但它与其他一些信息检索的评估度量方式不匹配。
下图是描述该问题的示意图。 直接写下所需渐变的想法(如图中的箭头所示),而不是从cost函数中得出它们是LambdaRank [4]的基本思想之一:它允许我们绕过大多数类型引入的困难 IR 目标。 请注意,这并不意味着 LambdaRank 中的梯度不是 cost 的梯度。 在本节中,假设我们正在设计一个模型来学习NDCG。

使用 01 标记为给定查询排序的一组URL打标记,表明 url 是否满足用户需求。 也就是说,浅灰色条表示与查询无关的网址,而深蓝色条表示与查询相关的网址。
左:错误的 pair 总数是十三。
右:通过将顶部网址向下移动三个位置,并将相关网址向上移动五个,成对错误的总数已减少到十一个。
然而,对于像NDCG和ERR这样强调前几项结果的IR评估标准,这不是我们想要的。 左边的(黑色)箭头表示RankNet梯度(随着成对误差的数量而增加),而我们真正喜欢的是右边的(红色)箭头。(因为对头部文档的移动我们更加关注。假设我们想要把只能移动一篇 doc 的话,移动靠上的 doc 是更好的选择。)

4.1 From RankNet to LambdaRank

因此,LambdaRank从 Ranknet 中获得的关键思路是,为了训练模型,我们需要的不是 cost 自身:我们需要的是(相对于模型得分的cost)的梯度。
上面提到的箭头(即 λ )正好是那些梯度。给定 doc1,它的 λ 来自于同一个query下和 doc1 具有不同标签的所有其他 URL 的贡献。
还记得吗, λ 也可以被解释为力(它是损失函数的梯度):如果 U2 比 U1 更相关,则 U1 将大小为 λ 的向下的力 (相应的,U2受到大小相等方向相反的向上的力;如果 U2 比 U1 的相关性小,那么 U1 将受到大小为 λ 的向上的力(相应的,U2被向下推)。
实验表明,通过简单地乘以 ΔNDCG (通过交换U1和U2的位置,保持其他文档不动)来修改 λ 的计算方式给出非常好的结果[4]。 因此,在LambdaRank中,我们想象有一个算法就是这样

因为在这里我们想要最大化测试指标 C(上面假设,此时的C是NDCG,NDCG是越大越好的),所以对于第k个权重的更新方式被替换为:

因此:

因此,尽管信息检索度量(被视为模型得分的函数)在不连续的,但 LambdaRank 的想法是通过计算 doc 按其得分排序后的梯度来绕过这个问题。我们在经验上证明了,这样的模型实际上直接优化了NDCG [12,7]。实际上我们已经进一步证明,如果你想优化一些其他的信息检索措施,比如MRR或MAP,那么LambdaRank可以通过简单的修改来实现这一点:唯一的变化是上面的 ΔNDCG 被相应的选择的 IR 评估方式所取代。
对于给定对,计算λ,并且U1和U2的λ增加该λ,其中选择符号使得s2-s1变得更负,使得U1倾向于向上移动排序列表U2往往会向下移动。同样,给定一对以上的URL,如果每个URL Ui具有得分si,那么对于任何特定对{Ui,Uj}(回想我们假设Ui比Uj更相关),我们将计算分开

4.2 LambdaRank: Empirical Optimization of NDCG (or other IR Measures)

4.3 When are the Lambdas Actually a Gradient?

5 MART

LambdaMART结合了MART [8]和LambdaRank。 要了解LambdaMART,我们首先回顾一下MART。 由于MART是一个增强树模型,其中模型的输出是一组回归树的输出的线性组合,我们首先简要回顾一下回归树。 假设我们得到一个数据集,每一个样本的形式类似于{xi,yi},其中 xi 是一个 d 维向量。 对于给定的向量xi,我们将其特征值索引为xij,j 的取值范围是 1 到 d。 首先考虑一个回归树桩,它由一个根节点和两个叶节点组成,其中有向边将根连接到每个叶子。 我们认为所有数据一开始都驻留在根节点上,对于给定的特征,我们遍历所有样本并找到阈值t,这样,如果xij <= t的所有样本都落到左子节点,其余的落入右子节点,然后计算

Sj会被最小化。这里L(R)是落在左侧(右侧)的样本索引集合,μL(μR)是落在左侧(右侧)的样本集合的标签值的平均值。 (总和中对j的依赖性出现在L,R和μL,μR中)。 我们会遍历所有特征和每一个可能的分割点,其给出总体最小Sj。 然后将该 split 附加到根节点。 对于我们的树桩的两个叶节点,计算落在各自叶节点的所有y的平均值。 在一般回归树中,该过程持续L-1次以形成具有L个叶节点的树。

MART是一类增强算法,可以被视为使用回归树在函数空间中执行梯度下降。 最终模型再次将输入的d维特征向量 x 映射到实数得分 F(x) 。MART是一类算法,而不是单个算法,因为它可以通过最小化不同的cost函数来解决不同的问题(例如,解决分类 ,回归或排名问题)。 但是请注意,构建MART的基础模型是最小二乘回归树,无论MART正在解决什么问题。 MART的输出F(x)可以写成:

其中每个fi(x)是由单个回归树建模的函数,αi是与第i个回归树相关联的权重。 fi和αi都是在训练期间学习的。fi 通过将向量 x 向下传递给对应的树,将 x 映射到实数值,其中树中给定节点处的路径(左或右)由特定特征 xj 的值确定,并且树的输出被认为是与每个叶子相关联的固定值(γkn,k是叶子的索引,n是树的索引)。给定训练和验证集,训练算法的用户需要调整的参数是N,还有学习率 η 和 L.(也可以为不同的树选择不同的L)。在训练期间也学习了 γkn。
如果训练了n棵树,下一棵树应该如何训练? MART使用梯度下降来减少损失:具体地,下一个回归树拟合的目标是相对于在每个训练点评估的当前模型得分的成本的m个导数:∂C(xi)。从而:

因此,每棵树建模损失函数的梯度,并且以步长 η 将新树添加到整体。 步长可以在某些情况下精确计算,或者在其他情况下使用牛顿近似。 η 作为总体学习率的作用类似于其他随机梯度下降算法:采取小于最佳步长的步长(即使得cost最小化的步长)作为正则化的一种形式。 可以显着提高模型的泛化能力。
显然,由于 MART 对梯度建模,而 LambdaRank 可以通在训练期间的任何点得到梯度,因此两种算法匹配良好:LambdaMART是两者的结合[11]。
为了理解MART,让我们接下来详细研究它是如何工作的,也许是最简单的监督学习任务:二元分类。

6 MART for Two Class Classification

这里主要参考了[8],虽然我们在这里给出了一些不同的重点; 特别是,我们允许一般的sigmoid参数σ(虽然事实证明σ的选择不会影响模型,看看这是为什么是有好处的)。
我们选择标签y∈{±1}(这具有y^2 = 1的优点,稍后将会利用这一点)。 样本x∈Rn的模型得分表示为 F(x) 。 为了使符号简洁,用 P+ 和 P- 表示P(y = 1 | x)和 P(y = 1 x)这两种条件概率。并且,如果yi = 1,则定义指示函数 I+(xi) = 1,否则 I-(xi) = 1,否则为0。 我们使用交叉熵损失函数(负二项式对数似然):

(注意与RankNet损失的相似性)。 因此,如果FN(x)是模型输出,我们选择(此处的1/2是常数,为了和[8]中的推导一致):

即:


模型损失函数的梯度是:

这个式子可以用于类比 LambdaRank 中 Lambda 梯度(实际上是 LambdaMART 中的梯度)。 它们是回归树正在建模的值。 用R_{jm}表示落在第m个树的第j个叶节点中的样本集,我们希望找到每个叶子的近似最佳预测值,即,使损失最小化的值

牛顿法也可以用于求γ:对于函数g(γ),逼近 g 的极值的Newton-Raphson step是:

这里我们从 γ = 0开始展示一步迭代。 再次让我们通过定义几个子表达式简化推导,定义:

此时:


注意,该步骤结合了梯度的估计(分子)和通常的梯度下降步长(1 / g’')。 为了把学习率 r 包含进去,我们将每个叶值γjm乘以 r 。
有趣的是,对于这个算法,σ的值没有影响。 牛顿步 γjm 与 1/σ 成比例。 由于F_{m}(x) = F_{m-1}(x)+ γ,并且由于F在损耗中出现整体因子σ,因此 σ 仅在损失函数中抵消。

6.1 Extending to Unbalanced Data Sets

待补充

7 LambdaMART

7.1 原理

要实现LambdaMART,我们只需使用MART,指定适当的梯度和Newton step。 梯度很简单:就是λi。 和MART一样,最小二乘法用于计算分裂。 在LambdaMART中,每个树都为整个数据集建模λi(不仅仅是针对单个查询)。 为了计算牛顿步,我们从上面收集一些结果:对于任何给定的 Ui 比 Uj 更相关的 pair,然后在 url 按分数排序之后,将λij定义为:

我们编写通过将Ui和Uj的等级位置交换可以得到 Zij 的效用差异(例如,Z可能是NDCG)。 我们还有

为简化表示法,让我们将上述求和运算表示如下:

因此,对于模型的任何给定状态(即,对于任何特定的分数集),对于特定的URL Ui,我们可以写下效用函数,其中λi是该效用的导数。


(注意符号的变化,因为我们在这里最大化)。 实现中,可以简单地计算每个样本 xi 的 ρij; 然后计算任何特定叶节点的γkm只涉及执行求和和除法。 注意,就逻辑回归而言,对于给定的学习率η,σ的选择对训练没有影响,因为γkm的比例为1 /σ,模型得分总是增加ηγkm,并且总是得分 出现乘以σ。
我们在下面概述了LambdaMART算法。 正如[11]中所指出的,人们可以通过从初始基础模型给出的分数开始来轻松地执行模型适应。

image.png

最后,比较 LambdaRank 和 LambdaMART 如何更新其参数是有用的。 LambdaRank 在检查每个查询后更新所有权重。 另一方面,LambdaMART中的决策(在节点处拆分)是使用落入该节点的所有数据计算的,因此LambdaMART一次只更新几个参数(即,当前叶节点的预测值) ,但使用所有数据(因为每个xi落在一些叶子中)。 这尤其意味着只要整体效用增加,LambdaMART就能够选择可能降低某些查询效用的分割和叶值。

7.2 优缺点

LambdaMART 有很多优点,取一些列举如下:

  • 直接求解排序问题,而不是用分类或者回归的方法;因此也有 ranking 算法自带的优点,对正例和负例的数量比例不敏感。
  • 可以将 NDCG 之类的不可求导的 IR 指标转换为可导的损失函数,具有明确的物理意义;
  • 可以在已有模型的基础上进行 Continue Training;
  • 可以根据自己的需求 instance 级别的魔改算法,比如某些 doc 权重很高需要富裕更高的梯度的场景。

第二部分 Other

从其他地方看来的小知识点

8 评价指标

Discounted Cumulative Gain(DCG)

对于一个关键词,所有的文档可以分为多个相关性级别,这里以rel1,rel2…来表示。文章相关性对整个列表评价指标的贡献随着位置的增加而对数衰减,位置越靠后,衰减越严重。基于DCG评价指标,列表前p个文档的评价指标定义如下:

nDCG

对于排序引擎而言,不同请求的结果列表长度往往不相同。当比较不同排序引擎的综合排序性能时,不同长度请求之间的DCG指标的可比性不高。目前在工业界常用的是Normalized DCG(nDCG),它假定能够获取到某个请求的前p个位置的完美排序列表,这个完美列表的分值称为Ideal DCG(IDCG),nDCG等于DCG与IDCG比值。所以nDCG是一个在0到1之间的值。

其中IDCG的定义为:

Expected Reciprocal Rank(ERR)

与DCG相比,除了考虑位置衰减和允许多种相关级别(以R1,R2,R3…来表示)以外,ERR更进了一步,还考虑了排在文档之前所有文档的相关性。举个例子来说,文档A非常相关,排在第5位。如果排在前面的4个文档相关度都不高,那么文档A对列表的贡献就很大。反过来,如果前面4个文档相关度很大,已经完全解决了用户的搜索需求,用户根本就不会点击第5个位置的文档,那么文档A对列表的贡献就不大。

9 关于损失函数的设计

有物理意义的 margin loss

ranknet 是一种二分类损失,也有一种叫法叫做 LPR Loss。另外一种工业界常用的 pairwise loss 是 margin loss。我们可以考虑一下这两者的区别:

  • LPR 力度会比较强,倾向于把好坏两个 doc 尽可能向两侧推。
  • Maigin 力度可控,比如我们只需要每个 pair 的 score 拉开 0.2 分的差距,不需要过大时,可以选择 maigin

但是通常可能会有这样的问题。ranknet 输出的 logit 的没有物理意义,我们想要用上 margin 可能希望在一个物理意义明确的尺度下应用,比如使用 sigmoid 讲 logit 映射到 0~1 之间。但是如果使用 sigmoid 之后的分数进入 margin loss,实际上不同位置的分数得到的梯度是不一样的.

比如说 margin 是 0.2,第一对 pair 的 正负例的预估分是(0.9,0.8)(经过了 sigmoid,sigmoid 前的 logit 是 2.19和 1.38),第二个 pair 的正负例的预估分是(0.45,0.55)(sigmoid 前的 logit 是 -0.2 和 0.2),直觉上,我们可能认为或者希望,模型对于这两个 pair 有同样的 梯度把他们拉开。而其实我们简单的计算就可以知道,由于 sigmoid 的存在对梯度有压缩性,因此前者的 logit 拿到的梯度是(-0.09,0.16),后者拿到的梯度是(-0.25,0.25),这也就意味着,中部位置的 doc 和头部尾部(最好的或者最坏的)doc 的梯度不均衡。如果我们希望学到一个均匀的分布,那可以最后的模型就会难偿所愿。

动态 margin

实际上不是什么高深的技巧,核心思路就是不同的 pair 的 weight 不一样。不同于 LPRloss,通常调整 weight 的方式是乘以一个因子(事实上,LambdaRank 就是这么改进的 Ranknet),margin loss 自带一个参数用来控制 loss 的敏感度,因此我们可以对不同的 pair 施加不同的区分度。

pointwise 和 pairwise 结合

另外前面提到,pairwise loss 完全不需要原始 doc 力度的 level 信息,对于原始数据就是 pair 形式组织的数据,pairwise loss 适用性更好,但是原本的数据如果是 pointwise 形式标注的,我们后面人工组的 pair 的话,使用 pairwise loss 实际上会丢掉一部分信息,就是我们不只是要是两个 doc 相对序正确,我们还希望每个 doc 预估到对应物理意义的档位上。此时我们可以结合使用 pointwise 和 pairwise,但这种方式最好的结合方式我们没有特别好的思路,也欢迎有经验的同学给出指导建议。另外,也可以直接使用 listwise。

第三部分 Lightgbm 实战一个小任务

问题定义与基线实现

我们的任务是这样的:有一堆搜索引擎拿到的 query,我们标注了他们对购买商品的需求等级,即这个 query 的搜索者有多希望看到搜索结果里出现电商或者直播等内容来满足购买需求,我们希望搞一个 rank 任务来学习这个任务。doc 的特征以数值类特征为主,我们使用 lightgbm 来完成这个任务。

代码比较简单:

df = prepare() #  业务数据自行编写,df type is DataFrame
y = df.iloc[:,-1]
X = df.iloc[:,0:-1]

from sklearn.model_selection import KFold, cross_val_score, learning_curve, GridSearchCV, train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.15, random_state=100)
X_train, X_valid, y_train, y_valid = train_test_split(X_train, y_train, test_size=0.15, random_state=100)
train_data = lgb.Dataset(data=X_train, 
                         label=y_train, 
                         group=[len(X_train)], 
                         categorical_feature=category_feature_names)
test_data = lgb.Dataset(data=X_test, 
                        label=y_test, 
                        group=[len(X_test)],
                        categorical_feature=category_feature_names)
valid_data = lgb.Dataset(data=X_valid, 
                         label=y_valid, 
                         group=[len(X_valid)],
                         categorical_feature=category_feature_names)


params = {
    'task': 'train',  # 执行的任务类型
    'boosting_type': 'gbrt',  # 基学习器
    'objective': 'lambdarank',  # 排序任务(目标函数)
    'num_iterations': 1000,  # 迭代次数
    'learning_rate': 0.1,  # 学习率
    'max_depth': 7,    
    'num_leaves': 50,  #   由于lightGBM是leaves_wise生长,官方说法是要小于2^max_depth
    'bagging_fraction':0.70,           ###  数据采样,'subsample'的别名
    'feature_fraction': 0.70,  ###  特征采样,'colsample_bytree'的别名
    'min_data_in_leaf': 15,  # 一个叶子节点上包含的最少样本数量
    #'min_child_weight': 0.001, # hessian
    #'metric': 'ndcg',  # 度量的指标(评估函数)
    #'max_position': 10000,  # @NDCG 位置优化
    #'metric_freq': 10,  # 每隔多少次输出一次度量结果
    'train_metric': False,  # 训练时就输出度量结果
    #'ndcg_at': [10000],
    'early_stopping_round': 50,
    'max_bin': 255,  # 一个整数,表示最大的桶的数量。默认值为 255。lightgbm 会根据它来自动压缩内存。如max_bin=255 时,则lightgbm 将使用uint8 来表示特征的每一个值。
    'lambda_l2': 0.00,
    'tree_learner': 'feature',  # 用于并行学习,‘serial’: 单台机器的tree learner
    'verbose': 2,  # 显示训练时的信息
    'feature_fraction_seed':123,
    'bagging_fraction_seed':123,
    'metric_freq':10,
    'monotone_constraints':make_monotone_constraints() # 构造单调特征
}

evals_result = {}


gbm = lgb.train(params, train_data, 
                valid_sets=[test_data, train_data],
                learning_rates= lambda iter: 0.12 * (0.995 ** iter), # 学习率衰减
                evals_result=evals_result)

这样就跑起来的,除去业务自定义的 prepare 函数准备训练数据,总体代码也就几十行。但是经过前面原理的讲解,我们能够发现这个任务这么实现有什么问题吗?

问题在于,这样执行,默认使用的是 lambdamark,也就是 lambdarank 的 loss。这种 loss 前面已经提到,特意修改为更注重高分的 instance 的准确率,低分的可能就随便排排。当前的目标函数会使得模型非常关注需求等级高的样本,然而不是说我们把头部的把强购买需求的分数预测的特别好,低购买需求的 query 随便排排就好了。可以自己写一个小函数试一下,10000 个 doc 时,有五种需求等级0~4,各占2000篇。在排序的时候,如果把需求等级为 4 的排到前 2000 个,后 8000 篇文档随机排序,ndcg@10000 可以达到0.98。从指标上非常好,但是却完全不能满足我们的意图需求预测的目的,因为我们需要高意图时出电商资源,低意图时过滤电商资源,而不是只强调高意图算准。

因此直观的想法是换成一个均匀的 loss。那直接回退成原始的 ranknet 的 loss 就可以了,BPRloss 刻画的就是整体 pairwise 的准确率。Ranknet 是比较早的模型,但是这一步退化需要我们明白模型和目标的意义。对于 RankNet,底层模型可以是任何模型,模型的输出是模型参数的可微函数。既然 lambdarank 和 mart 可以方便的结合,那 ranknet 也可以。确定了改动的方向就好办了,对于目标函数,我们用 Ranknet 的交叉熵就行,对于评估函数,就使用成对的 0-1 分类正确率就好(如果一对的 label 一致就跳过)。

评估函数我们就使用全体数据的两两准确率,这里可以用 numba 优化,具体函数参考我的另一篇文章:《python 性能优化》。

自定义损失函数

接下来是损失函数,lightgbm 和 xgboost 都提供了自定义损失函数的功能。需要注意的是,根据陈天奇的 paper,这两个树模型都是利用到二阶信息作为优化基础的,在 lightgbm 内会做如下调用:grad, hess = fobj(self.__inner_predict(0), self.train_set)。这里的 hess 就是二阶信息,但是并不是需要我们提供完整的 Hessian 矩阵,只需要提供对角线就可以,并以此充当样本的重要性。这部分在网上搜了一下,好像没找到,因此自己做了一些推导。推导的基础只有两个,第一个是模型的预测值计算方式:
image.png
另一个是交叉熵形式的损失函数:

明确上面两个公式后就可以反推梯度了,lightgbm模型利用到了二阶信息,因此还需要人工推导二阶导数的信息。关于 ranknet 损失函数的推导笔记直接贴上来,不太想写 latex 公式了。
image.png
二阶导数的推导:
image.png
再化简一下公式:
image.png

公式推导出来其实就完成了主要工作,注意用梯度定义验证推导的计算正确,写出来代码就没啥问题了。但是我们要注意实现要对齐 lightgbm 的,为了方便的画出 learning curve,我还使用了 sklearn 的接口。

适配 lightgbm 接口

我们先按照 sklearn 需要的自定义 loss 接口,给出实现。

# 一些文章说传入参数是(y_pred, y_true)是不对的,实际上类似于xgboost
# lightgbm 以如下方式调用自定义函数:grad, hess = fobj(self.__inner_predict(0), self.train_set)
# 注意下面有两个 grad_i 是一样的,注释版本是直接求导得到的,非注释版本是化简得到的
# 注意现在正在使用的导数和Burges的文章中的不一致,差了一个(1-sigmoid_delta)我的计算方式是经过验证的
# hess_i的推导见上图,注意i和j的hess是一样的
@nb.jit(nopython=True)
def ranknet_loss_v2(y_true, y_pred):
    length = len(y_true)
    grad = np.zeros(length)
    hess = np.zeros(length)
    for i in range(length):
        for j in range(i+1, length):
            if y_true[i] > y_true[j]:
                Sij = 1
            elif y_true[i] == y_true[j]:
                Sij = 0
            else:
                Sij = -1
            sigmoid_ij = 1 / (1 + np.exp(y_pred[j] - y_pred[i]))
            grad_i = -0.5 - 0.5*Sij + sigmoid_ij
            hess_i = sigmoid_ij * (1-sigmoid_ij)
            grad[i] += grad_i
            grad[j] -= grad_i
            hess[i] += hess_i
            hess[j] += hess_i
    return grad, hess

# 封装为lgb可以使用的形式
# 将参数转变为list,便于 numba 优化
def ranknet_loss_for_lgb(y_pred, train_set):
    y_true = list(train_set.label)
    return ranknet_loss_v2(y_true, y_pred)

# metrics 函数,其中 pair_acc_fast 实现可见我的另一篇文章《python 性能优化》
def pair_acc_fast_for_lgb(preds, train_set):
    label = list(train_set.label)
    return 'pairacc', pair_acc_fast(label, preds), True

然后在训练时把这个函数类似回调方式传给 lightgbm 的模型就可以:

gbm = lgb.train(params, train_data, 
                fobj=ranknet_loss_for_lgb,  # loss
                feval=pair_acc_fast_for_lgb, # metrics
                valid_sets=[test_data, train_data],
#                 learning_rates= lambda iter: 0.12 * (0.995 ** iter), # 学习率衰减
                evals_result=evals_result)

适配 sklearn 接口

训练模型是一定要尽可能的了解发生了什么,然后才能更好地通过参数控制模型的学习。一个基础也是必须的工具就是结合交叉验证绘制学习曲线,通过曲线观察模型学习过程。

在该任务上,sklearn提供了很好的辅助函数。比如cross_val_score 、 learning_curve、grid_search,但是如果直接把lightgbm.Ranker作为模型直接扔给 sklearn 是不成的,原因就在于上面说的,Ranker的训练时是需要 group 参数的。 但是我们这个任务是没有 group 的,或者说是所有的数据只有一个 group(因为是 query 粒度的任务)。

这种接口不统一的问题,使用适配器模式解决。继承 BaseEstimator 作为抽象基类,并且混入 RegressorMixin ,在模型内部组合 lightgbm 自己的模型就可以把接口匹配起来。此时 MyModel 就可以无缝接入 sklearn 了。

class Mymodel(sklearn.base.BaseEstimator, sklearn.base.RegressorMixin):
    def __init__(self, group=None, **param):
        self.group = group
        self.param = params
        
    def fit(self, X, y):
        if self.group is None:
            X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=100) 
            train_data = lgb.Dataset(data=X_train, label=y_train, group=[len(X_train)], silent=True)
            test_data = lgb.Dataset(data=X_test, label=y_test, group=[len(X_test)], silent=True)
        else:
            assert False, "未完成的功能:group信息自提供"
        self.evals_result = {}
        self.model = lgb.train(params, 
                        train_data,
                        fobj=ranknet_loss_for_lgb, # 在这里自定义了
                        feval=pair_acc_fast_for_lgb,
                        valid_sets=[test_data, train_data], 
                        learning_rates= lambda i: 0.2 * (0.99 ** i), # 学习率衰减
                        evals_result=self.evals_result)
    
    def predict(self, X):
        return self.model.predict(X)

    def score(self, X, y, sample_weight=None):
        y_pred = self.predict(X)
        return pair_acc_fast(y, y_pred)

image.png

一些模型内部细节

  1. xgboost一直训练误差小于测试误差,表现出过拟合,不是数据的问题,线性模型确实可以训练误差等于测试误差。树的最大深度已经是2了,其他过拟合参数也控制住了。争取解决这个问题
    1. DART模型参数
      1. rate_drop:前面已有的树失效的概率
      2. skip_drop:本次迭代不进行dropout的概率
    2. 直方图优化
      1. 这个算法有两种实现, 区别在于global variant在树的构造过程中只建立一次直方图,每次分裂都从缓存的直方图中寻找分裂点。local variant的话,每个深度等级分裂前都会重新创建。
      2. https://juejin.im/post/5b935e4d5188255c5966e768,注意下面这张图中已经知道了某个特征的某个取值应该落入哪个bin,这个信息在f.bins[i]中
    3. lightgbm 中直方图做差进一步提高效率,计算某一节点的叶节点的直方图可以通过将该节点的直方图与另一子节点的直方图做差得到,所以每次分裂只需计算分裂后样本数较少的子节点的直方图然后通过做差的方式获得另一个子节点的直方图,进一步提高效率。一篇介绍:https://www.biaodianfu.com/lightgbm.html,注意这里有一点说的不对,xgboost中也有直方图,不过是lightgbm继续对直方图算法进行优化。

  1. Goss 可能会运行得更快,后续尝试。
  2. adaboost中有样本权重,xgboost中没有,但是实际上二阶导数可以看作是对损失函数的加权

参考
美团技术文章:深入浅出排序学习:写给程序员的算法系统开发实践
美团技术文章:大众点评搜索基于知识图谱的深度学习排序实践
排序学习调研:http://xtf615.com/2018/12/25/learning-to-rank/
卢明东的博客:https://lumingdong.cn/learning-to-rank-in-recommendation-system.html
原始论文:https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/MSR-TR-2010-82.pdf

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值