具体的代码可以参考这个jupyter notebook

背景介绍

热门推荐,最直接的方法是采用两个,一个是基于点击量,此外是基于点击率的。这两个值都会有一点问题。

例子1: A和B两个展示品,A点击了12次,B点击了4000次。但是A一共曝光了12次,B则曝光了8000次。

在这个例子中,我们可以看到,A的点击率可能接近100%,但是因为给它的曝光次数不多,则永远无法被推荐。这中判断热门推荐的方式的问题就是新上架的展示品永远无法得到推荐。

例子2: A和B两个展示品,A点击了1次,共展示了2次;B点击了99次,共展示了200次。

这个例子中,则发生了另一个问题,A的点击率为$1/2 =50\%$,而B的点击率为$99/200 = 49.5\%$。但是可能存在的问题就是,A的曝光次数过少,虽然点击率高,但是这个数据的随机波动会很大,而B的点击率却很难确认(可以思考两个数据的分布)。单纯采用点击率的方式设计热门点击,则可能存在这个概率问题。

为了解决上面的问题,我在实际的游戏展示推荐中,采用了一个更合理的解决方案。

如何讲分布概率转为推荐分数

一个思路

z-score其实给我们提供了一个方法,我们可以假设点击数符合一个二项分布,然后我们可以基于曝光数($N$)和点击数($C$)描绘点击数的分布为:

\[C/N \sim Binom(N, \frac{C}{N}) / N\]

于是,我们可以通过分布图重新描述刚才例子2中的点击结果,可以得到,A展示品的点击率概率是50%的概率很低,而B展示品的点击率概率为49.5%的概率则很高。

我们何不比较这两个展示品的置信区间的上下限。比如,我们可以直接可以比较A点击率的下5%的分位数概率和B点击率的下5%的概率。显然,这样的话B的这个概率值就明显大于A。从这个思路出发,我们就可以设计基于置信区间的推荐分数。我们上面采用概率采样的方法,相对来说数据会不稳定,且计算量巨大,于是思路转变为,更好的计算二项分布的置信区间。

而且,这个还有一个好处是我们可以得到一个百分比的分数,而使得最后的结果不需要归一化。

Wilson方法

查阅维基的这个页面,我们可以找到一种计算二项分布的置信区间的方法(当然里面提到了一些更实验性的方案,但主流上推荐使用该方案的概率比较大)

\[W(p, n) = \frac {p + \frac {z_{1 - \frac{\alpha}{2}} ^ 2} {2n} \pm \frac {z_{1 - \frac{\alpha}{2}} ^ 2} {2n} \sqrt{4n(1-p)p + z_{1 - \frac{\alpha }{2}} ^ 2}} {1 + z_{1 - \frac{\alpha}{2}} ^ 2/n}\]

其中:

  1. $p$表示实际点击率
  2. $n$表示展示次数
  3. $\alpha$表示需要置信区间的宽度
  4. $z_t$表示标准正态分布

以下是用scala演示的计算代码:

import breeze.stats.distributions.Gaussian
import math._

def W(pos: Int, n: Int, confidence: Double = 0.95): Double = {
    val g = new Gaussian(0.0, 1.0)
    val z = g.inverseCdf(1-(1-confidence)/2)
    val phat: Double = 1.0 * pos / n
    return (
        (phat + z * z / (2 * n) - z * sqrt((phat * (1 - phat) + z * z / (4.0 * n)) / n)) / (1.0 + z * z / n),
        (phat + z * z / (2 * n) + z * sqrt((phat * (1 - phat) + z * z / (4.0 * n)) / n)) / (1.0 + z * z / n)
    )
}

python版本如下:

import numpy as np
import scipy.stats as sps

def W(pos, n, confidence=0.95):
    z = sps.norm.pdf((1 - confidence)/2, 0, 1)
    phat = pos / n
    return (
        (phat + z ** 2 / (2 * n) - z * np.sqrt((phat * (1 - phat) + z ** 2 / (4 * n)) / n)) / (1 + z ** 2 / n),
        (phat + z ** 2 / (2 * n) + z * np.sqrt((phat * (1 - phat) + z ** 2 / (4 * n)) / n)) / (1 + z ** 2 / n)
    )

我们最终可以演算出两个分数为

W(1, 2) # (0.364289812745783, 0.635710187254217)
W(99, 200) # (0.4809099483722197, 0.5090979980832595)

进一步的改进

当然,我们选择了下限的值作为比较,一定没有问题。但是,在业务中,我们还需要做一个改进,这个改进是基于我要配合的业务特性决定的。在具体的业务中,我们的功能主要是在一个滚动的栏位中显示5、6个游戏,而这个游戏列表更换速度很快(意味着上架下架很快),而刚上架的游戏我们希望更多机会曝光,一部分是为了给算法提供足够的数据,其次是这涉及到广告主的合作观感。如果单纯按照Wilson下限分数来做推荐排序的话,会出现的问题就是给予新上线的游戏的展示次数过低,所以在具体推荐的算法中,我设计了一个临界曝光次数来考虑这个问题。在临界曝光次数以下,我们按照Wilson的上限分数来算;超过则按下限;在曝光很少次数时,其分数固定在1,即必定给曝光机会。这样就符合了我们这个业务需要的场景,具体实现如下:

def W_adjust(pos, n, confidence=0.95, n1=10, n2=100):
    if n <= n1:
        return 1.
    elif n1 < n <= n2:
        return W(pos, n, confidence)[1]
    else:
        return W(pos, n, confidence)[0]

还能做什么改进

当然,以上的做法可能还涉及到一个时间上下文的问题,就是因为我们主营的小游戏版本迭代很快,并且和广告主的合作变更也很快,这使得过早的点击数据已经不太起作用了,最初的解决方案有两个:1. 基于反比函数(或其他递减函数)来重新计算点击数的值,使得过早以前的点击数对最后的比重影响降低;2. 只选择固定窗口的最新的点击数据作为样本总体。

最后,我们选择了第二号方案,一部分原因是公司在设备算力上的不足,计算过于久远的Query可能计算过慢;第二原因是,对点击数做一些变化之后,可能丧失了分数的统计学意义。基于这两个理由,我们做出了最后的方案。