最近重读《集体智慧编程》,这本当年出版的介绍推荐系统的书,在当时看来很引领潮流,放眼现在已经成了各互联网公司必备的技术。
这次边阅读边尝试将书中的一些Python语言例子用C#来实现,利于自己理解,代码贴在文中方便各位园友学习。由于本文可能涉及到的与原书版权问题,请第三方不要以任何形式转载,谢谢合作。
第二部分 聚类 - 发现群组
监督学习和无监督学习
利用样本输入和期望输出来学习如何预测的技术称为监督学习法。使用监督学习方法时,可以传入一组输入,应用程序可以根据此前学到的知识产生一个输出。
监督学习法包括如:神经网络、决策树、向量支持机及贝叶斯过滤等。
而这一部分介绍的聚类是无监督学习的一种。无监督学习算法是在一组数据中找寻某种数据结构,如聚类算法,其目标是采集数据并从中找出不同的群组。
其他无监督学习方法还包括负矩阵因式分解和自组织映射。
单词向量
聚类算法所需的数据是一组有相同的数值型属性的数据项,我们的操作正是基于这些属性。
对博客用户分类
这个示例所使用的数据集是一组指定的词汇在排名前120的博客的RSS中出现的次数,根据这个来对这些博客进行分类。这个数据的一小部分如下表所示:
| "china" | "kids" | "music" | "yahoo" | |
|---|---|---|---|---|
| Gothamist | 3 | 3 | 3 | 0 |
| GigaOM | 6 | 0 | 0 | 2 |
| Quick Online Tips | 0 | 2 | 2 | 22 |
根据单词出现的频度对博客聚类,可以分析出是否存在一类经常撰写相似主题的博客用户。
本文将略去构造这个数据源的过程,直接使用现成数据,可以在这里下载测试。这份文本中数据的格式与上面的表格差不多,只是以制表符作为分隔。后面所有代码都将基于这个格式的数据来实现。
这里稍微插一句构建测试数据的技巧,对于指定词汇的选择,可以使用一个范围如出现频率10%~50%进行过滤,这样可以去除像是the这样随处可见对聚类无意义的次,或是一些特别冷门的词,如合成词。排除这些干扰可以使后面的聚类结果更准确。
分级聚类
分级聚类通过连续不断地将最为相似的群组两两合并,来构造一个群组的层级结构。
具体方法是,先将单一元素,本例中就是博客,作为一个群组,在迭代过程中,分级聚类算法计算两个群组的距离(一开始时就是两个单一元素的距离),并将距离最近的两个群组合并为新群组,重复这个过程直到只剩一个群组。
按如上方式进行聚类,聚类的过程可以使用树状图来表示。树状图也可以体现构成聚类的元素之间间隔的远近,从而可以看出聚类中各元素间的相似程度,并以此指示聚类的紧密程度。
加载博客数据并进行聚类
下面的函数用于将上文提到的数据源加载到内存中用于聚类计算。
public Tuple<List<string>,List<string>,List<List<float>>> Readfile(string filename)
{
List<string> lines = new List<string>();
using (var fs = new FileStream(filename, FileMode.Open))
{
var sr = new StreamReader(fs);
while (!sr.EndOfStream)
{
var line = sr.ReadLine();
if (!string.IsNullOrEmpty(line))
lines.Add(line);
}
}
if (lines.Count == 0)
throw new Exception("文件为空");
//第一行是列标题
var colnames = lines[0].Trim()
.Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries)
.Skip(1).ToList();
var rownames = new List<string>();
var data = new List<List<float>>();
foreach (var l in lines.Skip(1))
{
var p = l.Trim().Split(new[] {' '}, StringSplitOptions.RemoveEmptyEntries);
// 每行第一列为行名
rownames.Add(p[0]);
// 剩余部分是行对应的数据
data.Add(p.Skip(1).Select(float.Parse).ToList());
}
return Tuple.Create(rownames, colnames, data);
}
下一步来计算紧密度。由于一些博客比其他其他博客文章更多,或文章的长度比其他文章更长,这可能会导致这些博客比其他博客包含更多的词汇。我们使用皮尔逊相关度来确定这些博客的相关程度,由于皮尔逊相关度表示两组数据与某条直线的拟合程度,这样可以排除词汇量多少对相关性的影响(简单说,一个博客与另一个博客有差不多的相同词汇,但前者比后着的词汇数量更多,使用皮尔逊相关度计算两个博客的相关度依然会得出较高的结果)。这里的皮尔逊相关度计算函数接受两个博客词汇数列表作为参数,并返回这两个博客的相关度分值。
public float Person(List<float> v1,List<float> v2)
{
//求和
var sum1 = v1.Sum();
var sum2 = v2.Sum();
//求平方和
var sum1Sq = v1.Sum(v => Math.Pow(v, 2));
var sum2Sq = v2.Sum(v => Math.Pow(v, 2));
//求乘积之和
var pSum = v1.Select((v,i)=>v*v2[i]).Sum();
//计算皮尔逊评价值
var num = pSum - (sum1 * sum2 / v1.Count);
var den = Math.Sqrt((sum1Sq - Math.Pow(sum1, 2) / v1.Count) * (sum2Sq - Math.Pow(sum2, 2) / v1.Count));
if (den == 0) return 0;
return 1 - num / (float)den;
}
由于在完全匹配的情况下,皮尔逊相关度的计算结果为1,而我们希望相关度高的两个元素“距离”更小,从而上面代码中使用1减去计算出的皮尔逊相关度。
接着我们构造一个数据结构来表示一个“聚类”。聚类可能是树中的叶节点,也可能事分支节点,对应我们的例子即可能是一个博客,也可能是几个博客聚类后的合并数据。
class BiCluster
{
// 博客中词汇数量数组
public List<float> Vec { get; set; }
public BiCluster Left { get; set; }
public BiCluster Right { get; set; }
public int Id { get; set; }
public float Distance { get; set; }
}
下面开始正式进入分类算法,分类算法以叶节点一级(即原始博客数据)聚类开始。函数的主循环中两两计算聚类相关度,以此找到最佳匹配。新生成的聚类的数据等于两个旧聚类求平均后的结果。然后重复这一过程直到只剩一个聚类。代码中一个优化的地方是保存每个配对的相关度计算结果,知道配对中的某一项被合并到另一个聚类中为止。
public BiCluster Hcluster(List<List<float>> rows, Func<List<float>, List<float>, float> distance)
{
var distances = new Dictionary<long,float>();
var currentclustid = -1;
// 最开始的聚类就是数据集中的行
var clust = rows.Select((data, i) => new BiCluster() {Vec = data, Id = i}).ToList();
while (clust.Count>1)
{
var lowestpair = Tuple.Create(0, 1);
var closest = distance(clust[0].Vec, clust[1].Vec);
//遍历每一个配对,寻找最小距离
for (int i = 0; i < clust.Count; i++)
{
for (int j = i+1; j < clust.Count; j++)
{
//用distances缓存相关度计算值
var key = ((long)clust[i].Id << 32) + clust[j].Id;
if(!distances.ContainsKey(key))
distances.Add(key, distance(clust[i].Vec,clust[j].Vec));
var d = distances[key];
if (d < closest)
{
closest = d;
lowestpair = Tuple.Create(i, j);
}
}
}
// 计算两个聚类的平均值
var mergevec = clust[lowestpair.Item1].Vec
.Select((v, i) => (v + clust[lowestpair.Item2].Vec[i])/2f).ToList();
// 建立新聚类
var newcluster = new BiCluster()
{
Vec = mergevec,
Left = clust[lowestpair.Item1],
Right = clust[lowestpair.Item2],
Distance = closest,
Id = currentclustid
};
//不在原始集合中,id为负数
--currentclustid;
var leftCluster = clust[lowestpair.Item1];
var rightCluster = clust[lowestpair.Item2];
clust.Remove(leftCluster);
clust.Remove(rightCluster);
clust.Add(newcluster);
}
return clust.FirstOrDefault();
}
在前面设计的存储聚类的数据结构BiCluster中保存了构成当前的聚类的两个原始聚类,可以使用这个信息,通过递归重建所有的中间聚类和叶节点。
下面的代码就可以测试聚类的生成。
Tester c = new Tester();
var datas = c.Readfile("blogdata.txt");
var clust = c.Hcluster(datas.Item3, c.Person);
可以使用下面的代码可视化的显示聚类的过程:
static void PrintClust(BiCluster clust, List<string> labels, int n = 0)
{
// 缩进布局
for (int i = 0; i < n; i++)
Console.Write(" ");
if (clust.Id < 0)
//分支
Console.WriteLine("├");
else Console.WriteLine($"{labels[clust.Id]}");
++n;
if (clust.Left != null) PrintClust(clust.Left,labels, n);
if (clust.Right != null) PrintClust(clust.Right,labels, n);
}
制表符├表示接下来的是两个子聚类。使用如下测试代码可以看到运行的效果:
PrintClust(clust,datas.Item1);
列聚类
上面我们以行进行聚类得到了博客的聚类结果,如果反过来以列进行聚类可以得到单词的聚类结果,从而可以知道那些单词常常被放在一起使用。举另一个常见的例子,在消费者购物清单的聚类中,如果按行聚类可以将那些有相似消费习惯的用户划分到一起,而如果按列聚类则可以把用户常一起购买的商品集合计算出来以用于捆绑销售或相关商品推荐。
把上面计算改为列聚类最简单的方式就是将数据集转置,通过下面的函数可以简单完成:
public List<List<float>> RotatMatrix(List<List<float>> data)
{
var newdata = new List<List<float>>();
for (int i = 0; i < data[0].Count; i++)
{
newdata.Add(data.Select(d=>d[i]).ToList());
}
return newdata;
}
然后测试代码也要进行调整:
Tester c = new Tester();
var datas = c.Readfile("blogdata.txt");
var rdata = c.RotatMatrix(datas.Item3);
var clust = c.Hcluster(rdata, c.Person);
PrintClust(clust,datas.Item2);
由于本身这种分级聚类算法的效率不高,移上列举类函数计算时间明显增加。
K值聚类
前文介绍的分级聚类有两个明显缺点,在没有额外处理的情况下,数据并没有真正拆分到不同组(只是以一棵二叉树的形式存在),且计算量非常大。计算量大的原因一是要计算每个项两两之间的关系,二是在项合并后要重新计算合并后项和其他项之间的关系。这导致在处理大规模数据时计算非常缓慢(如上面的列聚类)。
这一节介绍的K值聚类与分级聚类完全不同,其是通过传入的希望得到的聚类数量来根据数据的结构状态确定聚类的大小。
具体来说K值聚类的方法是,随机确定k个(按需求传入)中心位置(表示聚类中心),然后将各项分配到最邻近的中心点。分配完成后,将聚类的中心移动到分配给该聚类中心的所有项的中心位置,然后再次进行分配过程,直到分配结果不再发生变化为止。
public Dictionary<int,List<int>> Kcluster(List<List<float>> rows,
Func<List<float>, List<float>, float> distance, int k = 4)
{
// 确定每个点的最小值和最大值
var ranges = new List<float[]>();
for (var i = 0; i < rows[0].Count; i++)
{
var min = rows.Min(r => r[i]);
var max = rows.Max(r => r[i]);
ranges.Add(new [] {min,max});
}
// 随机创建k个中心点
var rnd = new Random();
var clusters = new List<List<float>>();
var rowLength = rows[0].Count;
for (int j = 0; j < k; j++)
{
var cluster = new List<float>();
for (int i = 0; i < rowLength; i++)
{
cluster.Add((float)rnd.NextDouble() * (ranges[i][1] - ranges[i][0]) + ranges[i][0]);
}
clusters.Add(cluster);
}
var lastmatches = new Dictionary<int, List<int>>(); ;
for (int t = 1; t <= 100; t++)
{
Console.WriteLine($"第{t}次迭代");
var bestmatches = new Dictionary<int, List<int>>();
for (int i = 0; i < k; i++)
{
bestmatches.Add(i, new List<int>());
}
//在每一行中寻找距离最近的中心点
for (int j = 0; j < rows.Count; j++)
{
var row = rows[j];
var bestmatch = 0;
for (int i = 0; i < k; i++)
{
var d = distance(clusters[i], row);
if (d < distance(clusters[bestmatch], row))
bestmatch = i;
bestmatches[bestmatch].Add(j);
}
}
// 如果结果与上一次相同,则整个过程结束
if (CompareDic(bestmatches, lastmatches)) break;
lastmatches = bestmatches;
// 把中心点移到其所有成员的平均位置处
for (int i = 0; i < k; i++)
{
var avgs = ArrayList.Repeat(0.0f, rows[0].Count).Cast<float>().ToList();
if (bestmatches[i].Count > 0)
{
foreach (var rowid in bestmatches[i])
for (int m = 0; m < rows[rowid].Count; m++)
avgs[m] += rows[rowid][m];
for (int j = 0; j < avgs.Count; j++)
avgs[j] /= bestmatches[i].Count;
clusters[i] = avgs;
}
}
return bestmatches;
}
throw new Exception("超过最大迭代次数");
}
算法中用到的Dictionary比较方法如下:
private bool CompareDic(Dictionary<int, List<int>> first, Dictionary<int, List<int>> second)
{
if (first == second) return true;
if ((first == null) || (second == null)) return false;
if (first.Count != second.Count) return false;
foreach (var kvp in first)
{
List<int> secondValue;
if (!second.TryGetValue(kvp.Key, out secondValue)) return false;
if (!kvp.Value.OrderBy(t => t).SequenceEqual(secondValue.OrderBy(t => t))) return false;
}
return true;
}
与分级聚类相比,这个算法产生最终结果所需要的迭代次数是非常少的。算法最终返回k组序列,其中每个序列表示一个聚类。
下面的代码可以测试上面算法:
可以明显感觉到,k值聚类算法比分级聚类算法快很多。
Tester c = new Tester();
var datas = c.Readfile("blogdata.txt");
var clust = c.Kcluster(datas.Item3, c.Person,k:10);
foreach (var ckvp in clust)
{
Console.WriteLine($"聚类 {ckvp.Key} :{string.Join(",",ckvp.Value.Select(i=>datas.Item1[i]))}");
}
附:Tanimoto系数
对于数据类型是1和0的集合类型(如一个用户喜欢某些商品的一部分,对于喜欢的物品记录为1,不喜欢的物品记为0),判断相关性的最佳办法就是Tanimoto系数。Tanimoto系数定义很简单,就是交集与并集的比率。下面的函数就可以很简单的实现这个算法:
public float Tanimoto(HashSet<bool> v1, HashSet<bool> v2)
{
return 1.0f - (float) v1.Intersect(v2).Count()/v1.Union(v2).Count();
}
参数v1和v2是两个集合,集合中元素为bool类型表示0和1。返回值为0.0到1.0之间值,0.0表示两个人喜欢的物品完全相同,1.0表示两个人喜欢的物品完全相同。