《集体智慧编程》读书笔记1
最近重读《集体智慧编程》,这本当年出版的介绍推荐系统的书,在当时看来很引领潮流,放眼现在已经成了各互联网公司必备的技术。
这次边阅读边尝试将书中的一些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(); }
注:没有代码下载,请自行复制粘贴测试。