[技术博客] 较科学的排名算法介绍与实现
咱们在制做公客网项目时,有一个功能是对教师和课程的评分排名。排名这个玩意吧能够简单地加权平均分,也能够用一些复杂的方法。本文打算首先介绍一下一些经常使用的简单排名方法有哪些问题,接着介绍一下一些其余几种简单的科学排名算法,以及咱们的Python实现,最后讲一下咱们的解决方案和其余东西。python
本文着重介绍的排名算法大多来自于《谁排第一?关于评价与排序的科学》一书,例子,说明和代码则是本身完成的,若有疏漏请指出,谢谢。git
太长不看版
简单的方法如算术平均,加权平均是有很多漏洞和问题的。github
咱们介绍的算法考虑并试图解决了这些问题,更科学一些。可是人无完人,这些算法依然仍是能够被针对的,只是难度大一些。算法
咱们实现的算法的仓库在这里<a href="https://github.com/canuse/ranking">仓库</a>,若是想尝试的话,看一下里面的例子应该能够很快地部署到本身的项目中。数据库
写完之后发现有人造了一个rankit的python库,更加完善。就咱们项目来讲仍是本身造的轮子舒服一点,由于这个库须要先把数据转成pandas的dataframe再调用,若是是新项目的话使用这个库也是更好的选择。app
传统经常使用排名算法以及它们解决咱们问题的局限性
平均/加权平均
这种算法实现快,简单,效果也凑合。这种算法的问题也很多,好比刷分问题难以杜绝,不一样人评分分布不一样,这个问题在评分群体少,打分目标交集少的状况尤其严重。好比咱们的项目,目前阶段基本上每门课只有2~5个打分,一个高分低分对排名的影响极大。而实际上,每门课上课人数基本上只有50人左右,所以每门课的打分人数理想状态下也就是60人(考虑前几届的),群体依然较少,问题依然没有避免。机器学习
IMDB算法(贝叶斯算法)
IMDB算法的公式大概是这样:参考这个<a href="https://www.zhihu.com/question/19746144">知乎问题</a>学习
weighted rank (WR) = (v ÷ (v+m)) × R + (m ÷ (v+m)) × C R = average for the movie (mean) v = number of votes for the movie m = minimum votes required C = the mean vote across the whole report (currently 6.9)
简单来讲就是评分人数达到必定标准才会进入排名,评分人数少可是评分高的项目会受到一些“惩罚”,最终选出的是你们都说好的东西。网站
可是就咱们的项目而言,首先评分人数要求就难以解决,限制线低的话和平均没啥区别,限制线高的话基本没有项目知足条件。此外平均分高的时候区别度会显著下降。所以咱们也没有采用这个算法。ui
Wilson区间法
简单来讲是计算每一个项目好评的置信区间进行排名,听说Reddit在用,网上找到了一个公式: 可是这个算法有一个问题就是评分人数越多的项目越容易排在前面,这样投票人数少的项目难以反超。 另外鄙人数学水平较低,本垃圾的概统是两天速成的,这公式看上去就让我兴致全无,不想实现。
几种科学的排名算法及咱们的实现
咱们打算介绍一下梅西法(Massey Ranking),科利法(Colley Ranking),处理平局的梅西法,科利法和排名聚合。
这些算法主要的优势是解决了一些状况下的恶意刷分以及不一样人群评分标准的差别,总体来讲不算十分难以理解,实现起来也不算困难,每一个算法的代码行数在50行左右。
这些算法都被数学建模,BCS等体育赛事等普遍使用过。Netflix曾经采用了有平局的科利法做为排名方法,而马尔可夫法是Page Rank的原型之一。在实际应用中,咱们发现马尔可夫法并不很适合咱们的场景,所以咱们最终没有使用。
梅西法
咱们从最简单的梅西法开始。梅西法看中的是评分的分差,利用它给出不一样项目的打分,进而排名。梅西法如今做为BCS评分的重要组成部分,也是Netflix曾经采用的排名方法。
数学理论
咱们经过构造一个评分矩阵,来描述两个项目的优劣关系,进而作出评分。例如一我的给A打了4分,给B打了2分,那么咱们构造一个列,在A位置记录一个1,B位置记录一个-1,把他放到评分矩阵里,同时记录数字的差(2)为这行的值。重复这个操做,咱们就能够获得一个 (评分条数) * (项目个数) 的系数矩阵X以及一个 (评分条数) * 1 的值矩阵Y。咱们能够试图找到一个XY之间的关系r,能够表示为 Xr=Y,这个r就描述了这些评分项目之间的差异。 可是显然这个方程组高度超定且矛盾,极可能没有有惟一解,所以咱们经过计算 $X^{T}Xr=X^{T}Y$ 的方式求一个最小二乘解做为最佳线性无偏估计。
步骤及例子
以上是无聊的理论部分,在实际应用中,咱们作了简化来构造$X^{T}X$,我用一个例子来讲明一下实际的算法。
咱们假设有如下4个用户对3门课程打分(1-5):
用户 | A | B | C |
---|---|---|---|
1 | 5 | 3 | X |
2 | 5 | 1 | 2 |
3 | 1 | X | X |
4 | X | 3 | 4 |
首先咱们统计一下有效的课程评分对:
# | Win | Lose | delta |
---|---|---|---|
1 | A | B | 2 |
2 | A | B | 4 |
3 | A | C | 3 |
4 | C | B | 1 |
5 | C | B | 1 |
这里用户3被认为是恶意刷分(事实上,若是他AB都投了1分,也会被发现是恶意刷分),而用户的打分分布(如2和4对于BC的分布)也被消除了。
而后咱们按如下规定构造矩阵:
- 矩阵的主对角线上为该元素参与的评价次数
- 每有一个评价,将AB和BA位置的值减一
- 评分矩阵是每一个元素的评分收益的绝对值
所以咱们获得了以下的矩阵:
显然该矩阵不满秩,所以咱们将最后一行(任意一行)替换,表示全部项目的Massey评价之和为0.
如今它满秩了,能够解了,解就是这几项的评分。
项目 | Massey 评分 | 排名 |
---|---|---|
A | 1.9167 | 1 |
B | -1.3333 | 3 |
C | -0.5833 | 2 |
以上就是基础的梅西法了,在咱们的实际状况中还要考虑打分相同等状况,将在后面处理平局中描述。
代码
如下是咱们的实现代码:
def massey(self) -> np.array: """ The Massey Ranking. Returns the item name, ranking and the score. """ # We first init the Massey matrix score_list = np.zeros(self.item_num) for i in self.record_list: if i[2] == i[3]: # Throw away draw games continue winner = self.all_item.index(i[0]) looser = self.all_item.index(i[1]) score_list[winner] += i[2] - i[3] score_list[looser] += i[3] - i[2] self.item_mat[winner][winner] += 1 self.item_mat[looser][looser] += 1 self.item_mat[winner][looser] -= 1 self.item_mat[looser][winner] -= 1 # replace the last line with 1 self.item_mat[-1] = np.ones(self.item_num) score_list[-1] = 0 # solve the matrix score = np.linalg.solve(self.item_mat, score_list) for i in range(self.item_num): self.ranking.append([self.all_item[i], 0, score[i]]) self.ranking.sort(key=lambda x: x[-1], reverse=True) for i in range(self.item_num): self.ranking[i][1] = i + 1 if i > 0 and self.ranking[i][2] == self.ranking[i - 1][2]: self.ranking[i][1] = self.ranking[i - 1][1] return self.ranking
科利法
相比于梅西法,科利法的数学推理要复杂一些,不过最终实现上难度与梅西法至关。一样的,科利法也是BCS评分的重要组成部分,Netflix也曾经采用的排名方法。
数学原理
简单来讲科利法以传统的胜率模型为基础,考虑了对手的强弱来修正补偿。科利法的主要特色是它不考虑评分差距,即5-1和3-2对于其来讲是一致的,都记为前者比后者好1次。这样,恶意刷分的行为就会被尽量地消除一些,同时也避免了误伤。
科利法的得分能够由公式
$$ r_{i}=\frac{1+w_{i}}{2+t{i}} $$
给出,其中 $w_{i}$,$t_{i}$ 为获胜场数和总场数。
在初始时全部项目的评分都是0.5,随着记录的次数增长,有的项目评分上升,有的则下降。为了方便求解,咱们作了一些近似,则有:
$$ \begin{aligned} w_{i} &= \frac{t_{i}-l_{i}}{2}+\frac{t_{i}+l_{i}}{2} \ &= \frac{t_{i}-l_{i}}{2}+\frac{t_{i}}{2}\ &\approx \frac{t_{i}-l_{i}}{2}+\sum_{j \in opponents}{r_{j}} \end{aligned} $$
这样咱们就把每一个项目的$r$与其余项目关联起来了,能够利用相似上面梅西法的矩阵求解了。一样的,咱们构造一个线性方程组$Cr=b$,其中$r_{n*1}$为目标评分向量,$b_{i}=1=0.5(w_{i}-l_{i})$为每一个项目的打分,$C$的主对角线上为该项目的有效评价组数+2,其他元素为对应元素的评价组数的相反数。
与梅西法不一样的是,这里这个方程组是满秩的,所以咱们不须要替换其中的一行。
梅西法和科利法看上去十分相似,可是他们仍是略有区别的。首先,科利法不是无偏估计,此外,科利法考虑的是获胜次数而不是分差。固然还有一个结合了两种方法的科利化梅西法,这又是一种其余的算法了。
步骤与例子
咱们依然以上面的例子来看一下科利法是怎么运行的。
有效的课程评分对:
# | Win | Lose | delta |
---|---|---|---|
1 | A | B | 2 |
2 | A | B | 4 |
3 | A | C | 3 |
4 | C | B | 1 |
5 | C | B | 1 |
而后咱们按如下规定构造矩阵:
- 矩阵的主对角线上为该元素参与的评价次数+2
- 每有一个评价,将AB和BA位置的值减一
- 评分向量是该项目与其余项目比较获胜的次数
所以咱们获得了以下的矩阵:
$$ \left[ \begin{matrix} 5 & -2 & -1 \ -2 & 6 & -2 \ -1 & -2 & 5 \end{matrix} \right] * r = \left[ \begin{matrix} 2.5 \ -1 \ 1.5 \end{matrix} \right] $$
解方程,就能够获得科利法的评分了。
项目 | Colley 评分 | 排名 |
---|---|---|
A | 0.708 | 1 |
B | 0.25 | 3 |
C | 0.542 | 2 |
一样的,科利法也须要处理一下平局,咱们在以后会提到这个问题。
注:书上第27页,若是我没理解错的话例子的解是有问题的。它的$p_{4}$好像算错了。
代码
如下是咱们的实现代码:
def colley(self)-> np.array: """ The Colley Ranking. Returns the item name, ranking and the score. """ # We first init the Colley matrix self.item_mat = np.zeros(shape=(self.item_num, self.item_num)) self.ranking = [] score_list = np.ones(self.item_num) for i in range(self.item_num): self.item_mat[i][i]=2 for i in self.record_list: if i[2] == i[3]: # Throw away draw games continue winner = self.all_item.index(i[0]) looser = self.all_item.index(i[1]) score_list[winner] += 0.5 score_list[looser] -= 0.5 self.item_mat[winner][winner] += 1 self.item_mat[looser][looser] += 1 self.item_mat[winner][looser] -= 1 self.item_mat[looser][winner] -= 1 # solve the matrix score = np.linalg.solve(self.item_mat, score_list) for i in range(self.item_num): self.ranking.append([self.all_item[i], 0, score[i]]) self.ranking.sort(key=lambda x: x[-1], reverse=True) for i in range(self.item_num): self.ranking[i][1] = i + 1 if i > 0 and self.ranking[i][2] == self.ranking[i - 1][2]: self.ranking[i][1] = self.ranking[i - 1][1] return self.ranking
马尔可夫法
虽然咱们最后没有采用这种算法,可是也仍是介绍一下。马尔可夫法是Page Rank的模型之一,也有不少变种,主要是投票的形式不一样,差距也比较大。没法找到一个合适的投票方式也是咱们没有采用它的缘由之一。
数学原理
正如其名,马尔可夫法的核心是马尔科夫链,就是那个概统里面最简单的送分大题。简单来讲,马尔可夫法描述的是一个利益无关的人,按照马尔科夫链随机在目标之间移动,最后统计一下它处于每一个目标的时间(次数),就是这些项目的评分了。即求$Cr=r$中的$r$。能够看出算法的关键是马尔科夫链的构造,这里主要是一个投票模型,有如下几种常见的投票方法:
- 输家向赢家投1票
- 输家向赢家投对方评分票
- 两家各项对方投对方评分票
整个算法的流程是:投票->根据结果构造马尔科夫链->计算稳态向量
例子
咱们仍是经过上面的那个例子看一下这个算法。这里咱们采用的是互相投对方评分票。每进行一次投票就至关于$C_{i,j}+=s_{i},C_{j,i}+=s_{j}$
仍是刚才的例子:
用户 | A | B | C |
---|---|---|---|
1 | 5 | 3 | X |
2 | 5 | 1 | 2 |
3 | 1 | X | X |
4 | X | 3 | 4 |
通过投票,咱们能够得出:
$$ C=\left[ \begin{matrix} 0 & 4 & 2 \ 10 & 0 & 6 \ 5 & 4 & 0 \end{matrix} \right] $$
而后咱们进行归一化:
$$ C'=\left[ \begin{matrix} 0 & \frac{4}{6} & \frac{2}{6} \ \frac{10}{16} & 0 & \frac{6}{16} \ \frac{5}{9} & \frac{4}{9} & 0 \end{matrix} \right] $$
这里有一个特殊状况,若是某一行所有是0的话(表示没有人给他投票),咱们把这一行处理成所有是1/n,相似Page Rank的悬挂节点,表示这个项目比较优秀,咱们能够随机另外选一个点从新开始。
咱们的目标是求$C'$的稳态向量,经过查阅概统书和线代书,通常的求法是经过求特征值与特征向量,在构造向量积进行计算。具体来讲,就是求$|\lambda E-C|=0$中的$\lambda$。利用性质能够知道有一个解是1,另外两解能够根据行列式和CASIO较轻松的计算出。而后咱们再计算特征向量。。。
固然,咱们也有其余算法。利用马尔可夫链特性和条件有比较简单的作法。咱们能够经过$Cr=r$硬解,即C左乘r的值不变列方程。通过简单计算,咱们有
$$ \begin{aligned} Mr &=0 \ 其中 M &= C'^{T}-E \ &= \left[ \begin{matrix} -1 & \frac{10}{16} & \frac{5}{9} \ \frac{4}{6} & -1 & \frac{4}{9} \ \frac{2}{6} & \frac{6}{16} & -1 \end{matrix} \right] \end{aligned} $$
显然,$M$ 不满秩。咱们依然采用梅西法中用过的小技巧,将最后一行都换成1,此时方程变为:
$$ \left[ \begin{matrix} -1 & \frac{10}{16} & \frac{5}{9} \ \frac{4}{6} & -1 & \frac{4}{9} \ 1 & 1 & 1 \end{matrix} \right] * r = \left[ \begin{matrix} 0 \ 0 \ 1 \end{matrix} \right] $$
如今能够解了,解出:
项目 | Markov 评分 | 排名 |
---|---|---|
A | 0.373 | 1 |
B | 0.365 | 2 |
C | 0.261 | 3 |
然而,使用只投一票的方式,接触的结果是:
项目 | Markov 评分' | 排名 |
---|---|---|
A | 0.632 | 1 |
B | 0.053 | 3 |
C | 0.316 | 2 |
评分差距较大,并且还有许多其余投票方式,咱们一时没有搞懂应该采用哪种,所以咱们就没有采用Markov法。
咱们也对此进行了调查,其实在书的13章有提到,马尔可夫法对于评分组中的小秩改变十分敏感,而咱们的样例数据比较小,形成告终果的波动至关大。而梅西法和科利法对秩改变敏感性较低,能对这种数据容错。
处理平局
因为咱们采起的是五分制打分,平局其实是很是常见的状况。若是简单地扔掉平局的话一是会致使大量数据被浪费,另外还会致使实际的样本很是少。在这里,咱们参考了Netflix当年的解决方案,对于两种算法,咱们将每个平局,例如给AB都投了3分,分解成两组:A3.5,B2.5和A2.5,B3.5都加入计算。
咱们也增长了一个方法处理这个过程,该方法会扫描读入的记录,找出平局并在最后加上两组记录。
def find_dup(self) -> None: for i in self.record_list: if i[2] == i[3]: self.record_list.append([i[0], i[1], i[2] + 0.5, i[2] - 0.5]) self.record_list.append([i[1], i[0], i[2] + 0.5, i[2] - 0.5])
排名聚合
咱们前面介绍了两种科学的排名方法,而在实际应用中,显然没有什么网站会显示两种不一样的排名的。为此,咱们采起了排名聚合的方式,将两个排名综合起来。咱们采起了波达计数的方式进行排名。简单来讲,就是将不一样排名方法的名次取和做为总名词的比较因素。值得注意的是,波达计数的输入排名序列中的内容能够是不一样的,好比A课程只在平均法中出现而B课程在平均法和梅西法中都出现了。通常来讲,咱们会给A课程一个模拟的梅西法排名,能够取最后或者中位值。在实际应用中,咱们给A课程增长了中位值做为虚拟排名。代码以下:
""" borda merge Input example: [ [['A',3],['B',1],['D',2]], [['A',2],['B',1],['D',4],['C',3]], ] :param ranks: A list of items and their rank. :return: the merged ranking list """ # first get all items all_item=[] scores=[] length_of_rank=[] for i in ranks: length_of_rank.append(len(i)) for j in i: if not j[0] in all_item: all_item.append(j[0]) for i in all_item: scores.append([i]) for j in range(len(length_of_rank)): scores[-1].append(-1) # Do broda count for i in range(len(ranks)): for j in range(len(ranks[i])): scores[all_item.index(ranks[i][j][0])][i+1]=ranks[i][j][1] # add unranked items for j in range(len(all_item)): if scores[j][i+1]==-1: scores[j][i+1]=0.5*length_of_rank[i]+0.5 borda=[] for i in scores: borda.append([i[0],sum(i[1:]),0]) borda.sort(key=lambda x:x[1],reverse=False) for i in range(len(borda)): borda[i][2] = i + 1 if i > 0 and borda[i][1] == borda[i - 1][1]: borda[i][2] = borda[i - 1][2] return borda
算法效果
介绍完了这些科学的排名方法,咱们再用一个例子来讲明为何这些排名方法更好。下面的例子部分出自咱们网站数据库中的一些打分:
课程A | 课程B | 课程C | 课程D | |
---|---|---|---|---|
a | 5 | 3 | X | X |
b | 3 | 2 | X | 2 |
c | 5 | X | 3 | 4 |
d | X | X | 5 | X |
e | X | X | 5 | 5 |
f | X | 5 | 5 | 4 |
平均分 | Massey | Colley | borda | |
---|---|---|---|---|
a | 2 | 1 | 1 | 1 |
b | 4 | 2 | 2 | 2 |
c | 1 | 4 | 3 | 3 |
d | 3 | 3 | 4 | 3 |
能够看到排名上有较大的差距。事实上,因为abc,def分属两个不一样的群体,他们的打分标准不一致,def的平均给分更高,这能够从公共课CD上看出来。使用平均值的方法就会致使这个问题。
对于有人恶意评分的状况,显然假设有一我的恶意给一门课,假设为D刷分,若是他只给D打分的话,他的打分其实是不会计入梅西法或者科利法的(由于不存在评分对),对咱们的打分没有影响。而简单平均值法就没有办法防范这种类型的攻击。
对于一个较复杂的状况,好比有人恶意刷分,给A课程打1分,D课程打5分,这种状况对于咱们是有必定影响的,咱们也分析了一下影响。因为6人样本较小,咱们假设有60我的进行了上述打分(每种10个),接着逐个增长刷分的,看看有什么影响。咱们只记录了临界变化。
刷分人数 / 排名 | 0 | 3 | 15 | 25 | +INF |
---|---|---|---|---|---|
课程A | 1 | 1 | 1 | 1 | 2 |
课程B | 2 | 2 | 3 | 3 | 2 |
课程C | 3 | 4 | 3 | 3 | 2 |
课程D | 3 | 3 | 2 | 1 | 1 |
可见,想要刷分须要有42%的用户投极端票才行。若是有半数的人都这么认为的话,这显然已经不能算是刷分,而是这门课确实出了什么问题吧。
咱们再看看简单平均值的表现,咱们对于课程BC进行刷分:
| 刷分人数 | 排名 | 0 | 2 | 3 | 10 | 11 | |----|-------|-------|------|----|------| | 课程A | 2 | 1 | 1 | 1 | 1 | | 课程B | 4 | 4 | 4 | 3 | 2 | | 课程C | 1 | 1 | 2 | 2 | 4 | | 课程D | 3 | 3 | 3 | 3 | 3 |
显然,刷分的影响十分巨大,仅仅6%的恶意评分就能让原来的第一变成最后。
可是这些算法也不是万无一失的,固然也会有不少专门针对性的手段找出算法的漏洞,但总的来讲,这些算法是更加科学的。
其余算法
固然,排名算法还有许多其余更好更优秀的,咱们采用的仅仅是最简单的。复杂一点的有基纳法,马尔可夫法等等,Netflix如今采用的是一种分组对抗算法,以及一些learning to rate的机器学习算法。可是咱们目前水平有限而且比较懒,所以暂时没有考虑实现那些算法。
咱们的解决方案
目前咱们采用有平局的梅西法和科利法的波达计数做为课程主排名。对于其余课程(例如某我的只投了这门课),咱们仍然采用算术平均值,并将其加在上面排名以后(至关于将主排名和这些课程又作了一个波达计数)。