一、前言
本章中,我们将学习到如下内容:从各种不同的来源中构造算法所需的数据;两种不同的聚类算法;更多有关距离度量(distance metrics)的知识;简单的图形可视化代码,用以观察所生成的群组;最后,我们还会学习如何将异常复杂的数据集投影到二维空间中。
聚类时常被用于数据量很大(data-intensive)的应用中。跟踪消费者购买行为的零售商们,除了利用常规的消费者统计消息外,还可以利用这些信息自动检测出具有相似购买模式的消费者群体。年龄和收入都相仿的人也许会有迥然不同的着装风格,但是通过使用聚类算法,我们就可以找到“时装岛屿”,并据此开发出相应的零售或市场策略。聚类在计量生物学领域里也有大量的运用,我们用它来寻找具有相似行为的基因组,相应的研究结果可以表明,这些基因组中的基因会以同样的方式响应外界的活动,或者表明它们是相同生化通路中的一部分。
二、监督学习和无监督学习
(1)监督学习
利用样本输入和期望输出来学习如何预测的技术被称为监督学习法(supervised learning methods)。常用的监督学习法包括:神经网络、决策树、向量支持机以及贝叶斯过滤。采用这些方法的应用程序,会通过检查一组输入和期望的输出来进行“学习”。当我们想要利用这些方法中的任何一种来提取信息时,我们可以传入一组输入,然后期望应用程序能够根据其此前学到的知识来产生一个输出。
(2)无监督学习
聚类是无监督学习(unsupervised learning)的一个例子。与神经网络或决策树不同,无监督学习算法不是利用带有正确答案的样本数据进行“训练”。它们的目的是要在一组数据中找寻某种结构,而这些数据本身并不是我们要找的答案。在前面提到的时装的例子中,聚类的结果不会告诉零售商每一位顾客可能会买什么,也不会预测新来的顾客适合哪种时尚。聚类算法的目标是采集数据,然后从中找出不同的群组。其他无监督学习的例子还包括非负矩阵因式分解(non-negative matrix factorization)和自组织映射(self-organizing maps)。
三、对订阅源中的单词进行计数
几乎所有的博客都可以在线阅读,或者通过RSS订阅源进行阅读。RSS订阅源是一个包含博客及其所有文章条目信息的简单的XML文档。为了给每个博客中的单词计数,首先第一步就是要解析这些订阅源。所幸的是,有一个非常不错的程序能够完成这项工作,它就是Universal Feed Parser。从python的包或者使用pip安装feedparser包即可。
有了Universal Feed Parser,我们就可以很轻松地从任何RSS或Atom订阅源中得到标题、链接和文章的条目了。下一步,我们来编写一个从订阅源中提取所有单词的函数。新建一个feedvector.py,将下列代码加入。
import feedparser
import re
# 返回一个RSS订阅源的标题和包含单词计数情况的字典
def getwordcounts(url):
# 解析订阅源
d = feedparser.parse(url)
wc = {}
# 循环遍历所有的文章条目
for e in d.entries:
if 'summary' in e:
summary = e.summary
else:
summary = e.description
# 提取一个单词列表
words = getwords(e.title+''+summary)
for word in words:
wc.setdefault(word, 0)
wc[word] += 1
return d.feed.title, wc
def getwords(html):
# 去除所有HTML标记
txt = re.compile(r'<[^>]+>').sub('', html)
# 利用所有非字母字符拆分出单词
words = re.compile(r'[^A-Z^a-z]+').split(txt)
# 转化成小写形式
return [word.lower() for word in words if word != '']
为了开始下一步工作,我们现在需要一个订阅源的列表。这里我手动将一些博客的RSS订阅地址放在了一个叫feedlist.txt 的文件中,每一行对应一个URL。如果我们拥有自己的博客,或者有一些博客是我们特别喜欢的,同时很想看看它们和某些热门博客的对比情况如何,那么我们也可以将这些博客的URL加入到文件中。
将下列代码加入到feedvector.py 文件的末尾
apcount = {}
wordcounts = {}
feedlist=[line for line in file('feedlist.txt')]
for feedurl in feedlist:
title, wc = getwordcounts(feedurl)
wordcounts[title] = wc
for word, count in wc.items():
apcount.setdefault(word, 0)
if count>1:
apcount[word] += 1
wordlist = []
for w,bc in apcount.items():
frac = float(bc)/len(feedlist)
if frac > 0.1 and frac < 0.5:
wordlist.append(w)
out = file('blogdata.txt', 'w')
out.write('Blog')
for word in wordlist:
out.write('\t%s' % word)
out.write('\n')
for blog, wc in wordcounts.items():
out.write(blog)
for word in wordlist:
if word in wc:
out.write('\t%d' % wc[word])
else:
out.write('\t0')
out.write('\n')
验证一下,是否包含一个以制表符分割的表格,其中的每一列对应一个单词,每一行对应一个博客。本章中出现的函数都将统一采用这一文件格式,日后我们还可以据此来构造新的数据集,我们甚至还可以将一个电子表格另存为如此格式的文本文件,并沿用本章中的算法对其实施聚类。
四、分级聚类
分级聚类通过连续不断地将最为相似的群组两两合并,来构造出一个群组的层级结构。其中的每个群组都是从单一元素开始的,在本章的例子中,这个单一元素就是博客。在每次迭代的过程中,分级聚类算法会计算每两个群组间的距离,并将距离最近的两个群组合并成一个新的群组。这一过程会一直重复下去,直到只剩一个群组为止。如下图所示:
在上图中,元素的相似程度是通过它们的相对位置来体现的---两个元素距离越近,它们就越相似。开始时,群组还只有一个元素。在第二步中,我们可以看到A和B,这两个紧靠在一起的元素,已经合并成了一个新的群组,新群组所在的位置位于这两个元素的中间。在第三步中,新群组又与C进行了合并。因为D和E现在是距离最近的两个元素,所以它们共同构成了一个新的群组。最后一步将剩下的两个群组合并到了一起。
通常,待分级聚类完成之后,我们可以采用一种图形化的方式来展现所得的结果,这种图被称为树状图,图中显示了按层级排列的节点。上述例子中的树状图如下图所示:
树状图是分级聚类的一种可视化形式
树状图不仅可以利用连线来表达每个聚类的构成情况,而且还可以利用距离来体现构成聚类的各元素间相隔的远近。在图中,聚类AB与A和B之间的距离要比聚类DE与D和E之间的距离更加接近。这种图形绘制方式能够帮助我们有效地确定一个聚类中各元素间的相似程度,并以此来指示聚类的紧密程度。
本节我们将示范如何对博客数据集进行聚类,以构造博客的层级结构;如果构造成功,我们将实现将主题对博客进行分组。首先,我们需要一个方法来加载数据文件。请新建一个名为clusters.py的文件,将下列函数加入其中:
def readfile(filename):
lines = [line for line in file(filename)]
# 第一行是列标题
colnames = lines[0].strip().split('\t')[1:]
rownames = []
data = []
for line in lines[1:]:
p = line.strip().split('\t')
# 每行的第一列是行名
rownames.append(p[0])
# 剩余部分就是该行对应的数据
data.append([float(x) for x in p[1:]])
return rownames, colnames, data
下一步我们来定义紧密度。我们曾在第二章讨论过这个问题,那一章中我们以欧几里德距离和皮尔逊相关度为例对两位影评者的相似程度进行了评论。在本章的例子中,一些博客比其他博客包含更多的文章条目,或者文章条目的长度比其他博客的更长,这样会导致这些博客在总体上比其他博客包含更多的词汇。皮尔逊相关度可以纠正这一问题,因为它判断的其实是两组数据与某条直线的拟合程度。此处,皮尔逊相关度的计算代码将接受两个数字列表作为参数,并返回这两个列表的相关度分值:
def pearson(v1, v2):
# 简单求和
sum1 = sum(v1)
sum2 = sum(v2)
# 求平方和
sum1Sq = sum([pow(v, 2) for v in v1])
sum2Sq = sum([pow(v, 2) for v in v2])
# 求乘积之和
pSum = sum([v1[i]*v2[i] for i in range(len(v1))])
# 计算 r (Pearson score)
num = pSum-(sum1*sum2/len(v1))
den = sqrt((sum1Sq-pow(sum1, 2)/len(v1))*(sum2Sq-pow(sum2, 2)/len(v1)))
if den == 0:
return 0
return 1.0-num/den
分级聚类算法中的每一个聚类,可以是树种的枝节点,也可以是与数据集中实际数据行相对应的叶节点(在本例中,即为一个博客)。每一个聚类还包含了指示其位置的信息,这一信息可以是来自叶节点的行数据,也可以是来自枝节点的经合并后的数据。我们可以新建一个bicluster类,将所有这些属性存放其中,并以此来描述这棵层级树。在cluster.py中新建一个类,以代表“聚类”这一类型
class bicluster:
def __init__(self, vec, left=None, right=None, distance=0.0, id=None):
self.left = left
self.right = right
self.vec = vec
self.id = id
self.distance = distance
将hcluster算法加入clusters.py 文件中:
def hcluster(rows, distance=pearson):
distances={}
currentclustid=1
# 最开始的聚类就是数据集中的行
clust=[bicluster(rows[i], id=i) for i in range(len(rows))]
while len(clust) > 1:
lowestpair = (0,1)
closest = distance(clust[0].vec, clust[1].vec)
# 遍历每一个配对,寻找最小距离
for i in range(len(clust)):
for j in range(i+1, len(clust)):
# 用distances来缓存距离的计算值
if (clust[i].id, clust[j].id) not in distances:
distances[(clust[i].id, clust[j].id)] = distance(clust[i].vec, clust[j].vec)
d = distances[(clust[i].id, clust[j].id)]
if d < closest:
closest = d
lowestpair = (i, j)
# 计算两个聚类的平均值
mergevec=[
(clust[lowestpair[0]].vec[i]+clust[lowestpair[1]].vec[i])/2.0
for i in range(len(clust[0].vec))]
# 建立新的聚类
newcluster = bicluster(mergevec, left=clust[lowestpair[0]],
right=clust[lowestpair[1]],
distance=closest, id=currentclustid)
# 不在原始集合中的聚类,其id为负数
currentclustid -= 1
del clust[lowestpair[1]]
del clust[lowestpair[0]]
clust.append(newcluster)
return clust[0]
因为每个聚类都指向构造该聚类时被合并的另两个聚类,所以我们可以递归搜索由该函数最终返回的聚类,以重建所有的聚类及叶节点。运行分级聚类算法:
blognames, words, data = readfile('blogdata.txt')
clust = hcluster(data)
def printclust(clust, labels=None, n=0):
# 利用缩进去来建立层级布局
for i in range(n):
print ' ',
if clust.id < 0:
# 负数标记代表这是一个分支
print '-'
else:
# 正数标记代表这是一个叶节点
if labels==None:
print clust.id
else:
print labels[clust.id]
# 现在开始打印右侧分支和左侧分支
if clust.left != None:
printclust(clust.left, labels=labels, n=n+1)
if clust.right != None:
printclust(clust.right, labels=labels, n=n+1)
调用上述函数:
printclust(clust, labels=blognames)
因为我这里博客列表中的博客数量太少,所以画出来的树不是很明显。可以在一开始的feedlist 中多添加些博客的url。
五、绘制树状图
使用本书给的blogdata.txt来绘制树状图,代码如下:
def getheight(clust):
# 这是一个叶节点吗?若是,则高度为1
if clust.left==None and clust.right==None:
return 1
# 否则,高度为每个分支的高度之和
return getheight(clust.left)+getheight(clust.right)
def getdepth(clust):
# 一个叶节点的距离是0.0
if clust.left==None and clust.right==None:
return 0
# 一个枝节点的距离等于左右两侧分支中距离较大者
# 加上该枝节点自身的距离
return max(getdepth(clust.left), getdepth(clust.right))+clust.distance
def drawdendrogram(clust, labels, jpeg='clusters.jpg'):
# 高度和宽度
h = getheight(clust)*20
w = 1200
depth = getdepth(clust)
# 由于宽度是固定的,因此我们需要对距离值做相应的调整
scaling = float(w-150)/depth
# 新建一个白色背景的图片
img = Image.new('RGB', (w, h), (255, 255, 255))
draw = ImageDraw.Draw(img)
draw.line((0, h/2, 10, h/2), fill=(255, 0, 0))
# 画第一个节点
drawnode(draw, clust, 10, (h/2), scaling, labels)
img.save(jpeg, 'JPEG')
def drawnode(draw, clust, x, y, scaling, labels):
if clust.id<0:
h1 = getheight(clust.left)*20
h2 = getheight(clust.right)*20
top = y-(h1+h2)/2
bottom = y+(h1+h2)/2
# 线的长度
l1 = clust.distance*scaling
# 聚类到其子节点的垂直线
draw.line((x, top+h1/2, x, bottom-h2/2), fill=(255, 0, 0))
# 连接左侧节点的水平线
draw.line((x, top+h1/2, x+l1, top+h1/2), fill=(255, 0, 0))
# 连接右侧节点的水平线
draw.line((x, bottom-h2/2, x+l1, bottom-h2/2), fill=(255, 0, 0))
# 调用函数绘制左右节点
drawnode(draw, clust.left, x+l1, top+h1/2, scaling, labels)
drawnode(draw, clust.right, x+l1, bottom-h2/2, scaling, labels)
else:
# 如果这是一个叶节点,则绘制节点的标签
draw.text((x+5, y-7), labels[clust.id], (0, 0, 0))
blognames, words, data = readfile('blogdata.txt')
clust = hcluster(data)
# printclust(clust, labels=blognames)
drawdendrogram(clust, blognames, jpeg='blogclust.jpg')
画出来的图如下所示: