最近重讀《集體智慧編程》,這本當年出版的介紹推薦系統的書,在當時看來很引領潮流,放眼現在已經成了各互聯網公司必備的技術。
這次邊閱讀邊嘗試將書中的一些Python語言例子用C#來實現,利於自己理解,代碼貼在文中方便各位園友學習。由於本文可能涉及到的與原書版權問題,請第三方不要以任何形式轉載,謝謝合作。
第一部分 推薦
協作型過濾
協作型過濾算法的做法是對一大群人進行搜索,找出其中品味與我們相近的一小群人。並將這一小群人的偏好進行組合來構造一個推薦列表。
基於用戶的協作性過濾
數據源
作為示例,數據源被放在一個C#的字典中,對於現實中的應用,這個數據應該是被來自於數據庫中。
var critics = new Dictionary<string, Dictionary<string, float>> { ["Lisa Rose"] = new Dictionary<string, float> { ["Lady in the Water"] = 2.5f, ["Snakes on a Plane"] = 3.5f, ["Just My Luck"] = 3.0f, ["Superman Returns"] = 3.5f, ["You, Me and Dupree"] = 2.5f, ["The Night Listener"] = 3.0f }, ["Gene Seymour"] = new Dictionary<string, float> { ["Lady in the Water"] = 3.0f, ["Snakes on a Plane"] = 3.5f, ["Just My Luck"] = 1.5f, ["Superman Returns"] = 5.0f, ["The Night Listener"] = 3.0f, ["You, Me and Dupree"] = 3.5f }, ["Michael Phillips"] = new Dictionary<string, float> { ["Lady in the Water"] = 2.5f, ["Snakes on a Plane"] = 3.0f, ["Superman Returns"] = 3.5f, ["The Night Listener"] = 4.0f }, ["Claudia Puig"] = new Dictionary<string, float> { ["Snakes on a Plane"] = 3.5f, ["Just My Luck"] = 3.0f, ["The Night Listener"] = 4.5f, ["Superman Returns"] = 4.0f, ["You, Me and Dupree"] = 2.5f }, ["Mick LaSalle"] = new Dictionary<string, float> { ["Lady in the Water"] = 3.0f, ["Snakes on a Plane"] = 4.0f, ["Just My Luck"] = 2.0f, ["Superman Returns"] = 3.0f, ["The Night Listener"] = 3.0f, ["You, Me and Dupree"] = 2.0f }, ["Jack Matthews"] = new Dictionary<string, float> { ["Lady in the Water"] = 3.0f, ["Snakes on a Plane"] = 4.0f, ["The Night Listener"] = 3.0f, ["Superman Returns"] = 5.0f, ["You, Me and Dupree"] = 3.5f }, ["Toby"] = new Dictionary<string, float> { ["Snakes on a Plane"] = 4.5f, ["You, Me and Dupree"] = 1.0f, ["Superman Returns"] = 4.0f } };
上面的字典清晰的展示了一位影評者對若干部電影的打分,分值為1-5。
有了這么一個字典就可以方便的查找到某人對某部影片的評分:
var score = critics["Tody"]["Snakes on a Plane"];
相似度評價值
下一步是確定人們在品味方面的相似度。這需要將每個人與其他所有人進行對比,並計算相似度評價值。計算相似度評價值的算法有歐幾里德距離和皮爾遜相關度。
歐幾里德距離
先來看下圖:
橫軸表示電影用戶對電影You, Me and Dupree的評分,縱軸表示對電影Snakes on a Plane的評分。兩個評分形成的坐標點被標記在坐標系中。而兩個坐標點的距離越近坐標是兩個用戶的偏好越相近。
如計算Toby和LaSalle的距離可以使用最基本三角函數:
var dist = Math.Sqrt(Math.Pow(4.5 - 4, 2) + Math.Pow(1 - 2, 2));
上面計算的距離值,相似度越高的距離越近值越小。為了使相似度高的評價計算值越高,使用下面方法計算一個倒數:
var similar = 1/(1 + dist);
這樣就會得到一個介於0到1之間的值來表示兩人偏好的相似度。
上面的討論是基於對兩部電影的評分,如果是對三部電影或多部電影綜合評價,這個方法可以類推。
如加上Superman Returns這部電影后,計算相似度代碼:
var dist = Math.Sqrt(Math.Pow(4.5 - 4, 2) + Math.Pow(1 - 2, 2) + Math.Pow(3-4,2)); var similar = 1/(1 + dist);
有了上面的理論基礎,可以構造如下計算兩人偏好相似度的函數:
public double SimDistance(Dictionary<string, Dictionary<string, float>> prefs, string person1, string person2) { //得到雙方都評價過的電影列表 var si = prefs[person1].Keys.Intersect(prefs[person2].Keys).ToList(); if (!si.Any()) return 0; var sumSquares = si.Sum(s => Math.Pow(prefs[person1][s] - prefs[person2][s], 2)); return 1/(1 + Math.Sqrt(sumSquares)); }
如計算Lisa Rose和Gene Seymour的相似度:
var similar = SimDistance(critics, "Lisa Rose", "Gene Seymour");
皮爾遜相關系數
皮爾遜相關系數是判斷兩組數據與某一直線擬合程度的一種度量。這種方法在偏好相對於平均水平偏離較大時可以有更好的結果。
同樣以一個坐標圖來說明:
不同於前文的坐標圖,這里橫軸表示LaSalle給出的評價,縱軸表示Seymour給出的評價。
圖中的虛線被稱為最佳擬合線,其繪制的方式是盡量靠近所有坐標點。當兩個人對所有電影評分相同時,最佳擬合線的將呈現為斜率為1,且與坐標重合。
下面的圖反映了皮爾遜相關系數另一個特點:
可以看到雖然Jack Matthews對相同電影的打分普遍高於Lisa Rose,但擬合度要好於上圖,這說明他們兩人有着相似的偏好,而使用歐幾里德距離算法是無法得出這樣的結論的。
原書對皮爾遜相關系數算法的實現語焉不詳,博主看了半天也沒有搞懂,這里也就直接上算法代碼:
public double SimPerson(Dictionary<string, Dictionary<string, float>> prefs, string person1, string person2) { //得到雙方都評價過的電影列表 var si = prefs[person1].Keys.Intersect(prefs[person2].Keys).ToList(); if (!si.Any()) return -1;//沒有共同評價的電影,返回-1 (博主注,原文是返回1,感覺是個bug) //各種打分求和 var sum1 = si.Sum(s => prefs[person1][s]); var sum2 = si.Sum(s => prefs[person2][s]); //打分的平方和 var sum1Sq = si.Sum(s => Math.Pow(prefs[person1][s],2)); var sum2Sq = si.Sum(s => Math.Pow(prefs[person2][s],2)); //打分乘積之和 var pSum = si.Sum(s => prefs[person1][s]*prefs[person2][s]); //計算皮爾遜評價值 var num = pSum - (sum1*sum2/si.Count); var den = Math.Sqrt((sum1Sq - Math.Pow(sum1, 2)/si.Count)*(sum2Sq - Math.Pow(sum2, 2)/si.Count)); if (den == 0) return 0; return num/den; }
函數返回一個-1到1之間的數值,1表示兩人有着完全一致的評價。 我們使用如下代碼測試Lisa Rose和Gene Seymour的皮爾遜相關系數:
var similar = SimPerson(critics, "Lisa Rose", "Gene Seymour"); Console.WriteLine(similar);
其他相似度評價的算法還有如Jaccard系數或曼哈頓距離算法。
為了后面的推薦使用方便,我們把這個相似度計算提取為一個接口,其兩種實現都有相同的簽名,並且都是分值越大,相似度越高。
interface ISimilar { double Calc(Dictionary<string, Dictionary<string, float>> prefs, string person1, string person2); } public class SimilarDistance : ISimilar { public double Calc(Dictionary<string, Dictionary<string, float>> prefs, string person1, string person2) { //同前...略 } } public class SimilarPerson : ISimilar { public double Calc(Dictionary<string, Dictionary<string, float>> prefs, string person1, string person2) { //同前...略 } }
使用也很簡單:
ISimilar simiCaculator = new SimilarPerson(); var similar = simiCaculator.Calc(critics, "Lisa Rose", "Gene Seymour"); Console.WriteLine(similar);
查找偏好最相近的評論者
有了上面的評分函數,尋找偏好相近的評論者就是很簡單的事了。直接上函數:
public readonly Dictionary<string, Dictionary<string, float>> Critics = new Dictionary<string, Dictionary<string, float>> { //初始偏好數據 - 略 }; //n:為返回最相似匹配者數目 //similar:相似度函數 public List<KeyValuePair<double,string>> TopMatches(Dictionary<string, Dictionary<string, float>> prefs, string person, int n = 5, ISimilar similar = null) { if(similar == null) similar = new SimilarPerson(); var dic = prefs.Where(p => p.Key != person) .Select(p => p.Key) .ToDictionary(other => similar.Calc(prefs, person, other), other => other); var sortDic = new SortedDictionary<double, string>(dic, new SimilarComparer()); return sortDic.Take(n).ToList(); } class SimilarComparer : IComparer<Double> { public int Compare(double x, double y) { return y.CompareTo(x); } }
使用下面的代碼可以進行測試:
Tester c = new Tester(); var result = c.TopMatches(c.Critics, "Toby", 3); Console.WriteLine(string.Join(Environment.NewLine, result.Select(kvp=>$"{kvp.Key}, {kvp.Value}")));
通過結果看到,Lisa Rose與Toby有最相似的偏好。
推薦電影
通過找到偏好相似度高的評價者並從其喜愛的電影中選擇推薦品,有時候可能不會得到太滿意的結果。下面的介紹一種通過相似度對影片評分進行加權的方法來推薦影片。
評論者 | 相似度 | Night評分 | Night加權 | Lady評分 | Lady加權 | Luck評分 | Luck加權 |
---|---|---|---|---|---|---|---|
Rose | 0.99 | 3.0 | 2.97 | 2.5 | 2.48 | 3.0 | 2.97 |
Seymour | 0.38 | 3.0 | 1.14 | 3.0 | 1.14 | 1.5 | 0.57 |
Puig | 0.89 | 4.5 | 4.02 | 3.0 | 2.68 | ||
LaSalle | 0.92 | 3.0 | 2.77 | 3.0 | 2.77 | 2.0 | 1.85 |
Matthews | 0.66 | 3.0 | 1.99 | 3.0 | 1.99 | ||
評分總計 | 12.89 | 8.38 | 8.07 | ||||
相似度之和 | 3.84 | 2.95 | 3.18 | ||||
評分和/相似度和 | 3.35 | 2.83 | 2.53 |
上表是幾位評論者對三部電影的評分情況。表中,加權分值一列給出了評分經過評論者相似度加權后的值。這樣,與我們相似度高的人對整體的評價值所起的作用更多。
得到評分綜合后,再用其除以相似度之和(統計學上就稱為“加權平均”),這樣得到的結果可以修正因為一部影片評價的人較多而帶來的影響。
上面的過成可以用下面的代碼來進行:
//利用所有他人評價值加權,為某人提供建議 public List<KeyValuePair<double, string>> GetRecommendations(Dictionary<string, Dictionary<string, float>> prefs, string person, ISimilar similar = null) { if (similar == null) similar = new SimilarPerson(); var totals =new Dictionary<string,double>(); var simSums = new Dictionary<string,double>(); foreach (var other in prefs) { //不和自己比較 if(other.Key == person) continue; var sim = similar.Calc(prefs, person, other.Key); //忽略相似度小於等於0的情況 if (sim <= 0) continue; foreach (var kvp in prefs[other.Key]) { //只對自己未看過的電影評價 if (!prefs[person].ContainsKey(kvp.Key)) { //相似度 x 評價值 if(!totals.ContainsKey(kvp.Key)) totals.Add(kvp.Key,0); totals[kvp.Key] += prefs[other.Key][kvp.Key]*sim; //相似度之和 if(!simSums.ContainsKey(kvp.Key)) simSums.Add(kvp.Key,0); simSums[kvp.Key] += sim; } } } var avgVal = totals.ToDictionary(t => t.Value/simSums[t.Key], t => t.Key); var rankings = new SortedDictionary<double, string>(avgVal,new SimilarComparer()); return rankings.ToList(); }
測試代碼:
Tester c = new Tester(); var result = c.GetRecommendations(c.Critics, "Toby"); Console.WriteLine(string.Join(Environment.NewLine, result.Select(kvp=>$"{kvp.Key}, {kvp.Value}")));
這樣我們就得到了一份電影推薦列表。
相似商品
上面介紹的根據指定人員與其他評分者偏好相似度的方法來推薦物品。現實生活中還需要一種根據直接得到相近物品的算法,用於如購物網站中用戶不登錄情況下的相似物品推薦。
如同之前尋找偏好相似的人,我們把評分數據中的人與電影對調,就可以套用之前的算法尋找相似度接近的電影。
我們使用下面的代碼轉換評分數據:
public Dictionary<string, Dictionary<string, float>> TransformPrefs( Dictionary<string, Dictionary<string, float>> prefs) { var result = new Dictionary<string, Dictionary<string, float>>(); foreach (var personKvp in prefs) { foreach (var itemKvp in personKvp.Value) { if(!result.ContainsKey(itemKvp.Key)) result.Add(itemKvp.Key,new Dictionary<string, float>()); //將人員和物品對調 result[itemKvp.Key].Add(personKvp.Key, prefs[personKvp.Key][itemKvp.Key]); } } return result; }
下面的測試代碼,可以得到與《Superman Returns》最相近的影片:
Tester c = new Tester(); var movies = c.TransformPrefs(c.Critics); var result = c.TopMatches(movies, "Superman Returns"); Console.WriteLine(string.Join(Environment.NewLine, result.Select(kvp=>$"{kvp.Key}, {kvp.Value}")));
注意:結果中的負值表示,喜歡Superman Returns的人,有存在不喜歡Just My Luck的傾向。
下面圖展示了負相關情況下的擬合線:
類似為用戶推薦影片,反過來我們也可以為影片推薦評論者。這種場景可以用於電商中將某些產品的廣告定向投給某些客戶。
Tester c = new Tester(); var movies = c.TransformPrefs(c.Critics); var result = c.GetRecommendations(movies, "Just My Luck"); Console.WriteLine(string.Join(Environment.NewLine, result.Select(kvp=>$"{kvp.Key}, {kvp.Value}")));
基於物品的協作型過濾
之前介紹的方法存在的問題是,對於大規模數據集(如Amazon購物數據),將一個用戶與所有其他用戶比較,計算量過於大。另外,這些用戶購物的偏好一般也差異很大(如有些人多買食品,有些人常買圖書),這樣很難計算用戶之間的相似性。
這一部分介紹的基於物品的協作性過濾可以預先執行計算大部分計算任務,從而可以更快速的給出用戶推薦結果。
基於物品的協作性過濾的思路也是為每件物品預先計算好最為相近的其他物品。然后查看用戶評價過的物品中用戶評分高的,找出與這些物品相近的物品推薦給用戶。由於物品的變動性相對用戶來說要小,所以預先計算物品之間的相似度是可行的。
構造物品比較數據集
我們通過如下所示的函數構造包含相近物品的數據集。這個數據集只需要構造一次,就可以在每次推薦時反復使用:
public Dictionary<string, List<KeyValuePair<double,string>>> CalculateSimilarItems( Dictionary<string, Dictionary<string, float>> prefs, int n) { //字典key為物品,value為與這個物品最為相近的前n個物品 var result = new Dictionary<string, List<KeyValuePair<double,string>>>(); var itemPrefs = TransformPrefs(prefs); var c = 0; foreach (var itemPref in itemPrefs) { //顯示運行進度 ++c; if(c%100==0) Console.WriteLine($"{c} / {itemPref.Value.Count}"); //尋找最為相近的物品 var sources = TopMatches(itemPrefs, itemPref.Key, n, new SimilarDistance()); result.Add(itemPref.Key,sources); } return result; }
測試一下這個函數:
Tester c = new Tester(); var result = c.CalculateSimilarItems(c.Critics, 10); Console.WriteLine(JsonConvert.SerializeObject(result));
隨着物品和用戶的增長,物品間的相似度會趨於穩定,這個函數也就不用頻繁的來執行了。
推薦物品
下面的表格展示了推薦的過成,表格的每一行都是曾經看過的影片,評分列代表對看過電影的評分。剩余幾列是沒有看過的電影,對於每一部沒看過的電影,第一列是此電影與同一行中看過電影的相似度,第二列是基於對同一行看過的電影的評分加權后的相似度(加權方式就是將評分與相似度相乘)。
歸一化結果一行顯示了對未看過電影最終的評分,其就是用加權后的總計除以相似度的總計而來。
影片 | 評分 | Night相似度 | 加權 | Lady相似度 | 加權 | Luck相似度 | 加權 |
---|---|---|---|---|---|---|---|
Snakes | 4.5 | 0.182 | 0.818 | 0.222 | 0.999 | 0.105 | 0.474 |
Superman | 4.0 | 0.103 | 0.412 | 0.091 | 0.363 | 0.065 | 0.258 |
Dupree | 1.0 | 0.148 | 0.418 | 0.4 | 0.4 | 0.182 | 0.182 |
總計 | 0.433 | 1.378 | 0.713 | 1.762 | 0.352 | 0.914 | |
歸一化結果 | 3.183 | 2.473 | 2.598 |
方法了解了,實現就很容易了,見如下代碼:
public List<KeyValuePair<double, string>> GetRecommendedItems( Dictionary<string, Dictionary<string, float>> prefs, Dictionary<string, List<KeyValuePair<double, string>>> itemMatch, string user) { var userRatings = prefs[user]; var scores= new Dictionary<string,double>(); var totalSim = new Dictionary<string,double>(); //遍歷當前用戶評分的產品 foreach (var ratingKvp in userRatings) { //循環當前物品相近的物品 foreach (var simiKvp in itemMatch[ratingKvp.Key]) { //如果該用戶已經對當前物品評價過,則忽略 if(userRatings.ContainsKey(simiKvp.Value)) continue; //評價值與相似度的加權值和 if(!scores.ContainsKey(simiKvp.Value)) scores.Add(simiKvp.Value,0); scores[simiKvp.Value] += simiKvp.Key*ratingKvp.Value; //全部相似度之和 if(!totalSim.ContainsKey(simiKvp.Value)) totalSim.Add(simiKvp.Value, 0); totalSim[simiKvp.Value] += simiKvp.Key; } } //將每個合計值除以加權和,求出平均值 var avgVal = scores.ToDictionary(t => t.Value / totalSim[t.Key], t => t.Key); //按最高值排序,返回評分結果 var rankings = new SortedDictionary<double, string > (avgVal, new SimilarComparer()); return rankings.ToList(); }
下面的代碼可以測試上面的函數:
Tester c = new Tester(); //再生產場景中,下面的值應該被計算並存儲 var simiItems = c.CalculateSimilarItems(c.Critics, 10); var recommended = c.GetRecommendedItems(c.Critics, simiItems, "Toby"); Console.WriteLine(JsonConvert.SerializeObject(recommended));
最后,關於選擇
上面討論了基於用戶與基於物品兩種過濾方式,實際應用中應該如何選擇呢?
一個大的原則是,對於稀疏數據集,基於物品的過濾方法通常要優於基於用戶的過濾方法,對於密集數據集,兩個效果幾乎一樣。另外對於較小規模(可以在內存中存儲),變化頻繁的數據集基於用戶過濾更適合。
另外,如果出現積分相同的情況,文中的TopMatches會報錯,可以使用如下寫法替換
public List<KeyValuePair<double,string>> TopMatches(Dictionary<string, Dictionary<string, float>> prefs, string person, int n = 5, ISimilar similar = null) { if(similar == null) similar = new SimilarPerson(); var dicKey = prefs.Where(p => p.Key != person).Select(p => p.Key).ToList(); var dic = new Dictionary<double, string>(dicKey.Count); foreach (var pi in dicKey) { var score = similar.Calc(prefs, person, pi); if(!dic.ContainsKey(score)) dic.Add(score, pi); } var sortDic = new SortedDictionary<double, string>(dic, new SimilarComparer()); return sortDic.Take(n).ToList(); }
注:沒有代碼下載,請自行復制粘貼測試。