咱們在製做公客網項目時,有一個功能是對教師和課程的評分排名。排名這個玩意吧能夠簡單地加權平均分,也能夠用一些複雜的方法。本文打算首先介紹一下一些經常使用的簡單排名方法有哪些問題,接着介紹一下一些其餘幾種簡單的科學排名算法,以及咱們的Python實現,最後講一下咱們的解決方案和其餘東西。python
本文着重介紹的排名算法大多來自於《誰排第一?關於評價與排序的科學》一書,例子,說明和代碼則是本身完成的,若有疏漏請指出,謝謝。git
簡單的方法如算術平均,加權平均是有很多漏洞和問題的。github
咱們介紹的算法考慮並試圖解決了這些問題,更科學一些。可是人無完人,這些算法依然仍是能夠被針對的,只是難度大一些。算法
咱們實現的算法的倉庫在這裏倉庫,若是想嘗試的話,看一下里面的例子應該能夠很快地部署到本身的項目中。數據庫
寫完之後發現有人造了一個rankit的python庫,更加完善。就咱們項目來講仍是本身造的輪子舒服一點,由於這個庫須要先把數據轉成pandas的dataframe再調用,若是是新項目的話使用這個庫也是更好的選擇。app
這種算法實現快,簡單,效果也湊合。這種算法的問題也很多,好比刷分問題難以杜絕,不一樣人評分分佈不一樣,這個問題在評分羣體少,打分目標交集少的狀況尤其嚴重。好比咱們的項目,目前階段基本上每門課只有2~5個打分,一個高分低分對排名的影響極大。而實際上,每門課上課人數基本上只有50人左右,所以每門課的打分人數理想狀態下也就是60人(考慮前幾屆的),羣體依然較少,問題依然沒有避免。機器學習
IMDB算法的公式大概是這樣:參考這個知乎問題學習
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
簡單來講是計算每一個項目好評的置信區間進行排名,聽說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的分佈)也被消除了。
而後咱們按如下規定構造矩陣:
所以咱們獲得了以下的矩陣:
顯然該矩陣不滿秩,所以咱們將最後一行(任意一行)替換,表示全部項目的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 |
而後咱們按如下規定構造矩陣:
所以咱們獲得了以下的矩陣:
\[ \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\)。能夠看出算法的關鍵是馬爾科夫鏈的構造,這裏主要是一個投票模型,有如下幾種常見的投票方法:
整個算法的流程是:投票->根據結果構造馬爾科夫鏈->計算穩態向量
咱們仍是經過上面的那個例子看一下這個算法。這裏咱們採用的是互相投對方評分票。每進行一次投票就至關於\(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 |
---|---|---|---|---|---|
課程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的機器學習算法。可是咱們目前水平有限而且比較懶,所以暫時沒有考慮實現那些算法。
目前咱們採用有平局的梅西法和科利法的波達計數做爲課程主排名。對於其餘課程(例如某我的只投了這門課),咱們仍然採用算術平均值,並將其加在上面排名以後(至關於將主排名和這些課程又作了一個波達計數)。