最近重讀《集體智慧程式設計》,這本當年出版的介紹推薦系統的書,在當時看來很引領潮流,放眼現在已經成了各網際網路公司必備的技術。
這次邊閱讀邊嘗試将書中的一些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 | ||
Seymour | 0.38 | 1.14 | 1.5 | 0.57 | |||
Puig | 0.89 | 4.5 | 4.02 | 2.68 | |||
LaSalle | 0.92 | 2.77 | 2.0 | 1.85 | |||
Matthews | 0.66 | 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 | 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.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();
}
注:沒有代碼下載下傳,請自行複制粘貼測試。