本章中实现了层次聚类算法和K均值算法,用于博客的聚类。使用的特征为词向量。即特定词在博客文章中出现的次数。
读入数据
数据中行的第一个词代表博客名,列的第一个词代表单词特征。存储的数字代表该词在该博客中出现的次数。读入该句子,用Python的list存储。【【】,【】,【】….】用两层链表结构来模拟矩阵。
层次聚类算法
首先定义向量之间的相似度度量方法是皮尔森相关系数。该相关系数比欧氏距离更适合,因为不同的博客长短不一,我们要探求的相似度是线性相关性,而非真实距离。
数据结构:
class bicluster的数据成员有
– vec:代表该聚类的特征向量
– left:如果该节点不是叶子节点,则存储其左孩子,否则为None
– right:如果该节点不是叶子节点,则存储其右孩子,否则为None
– distance:表示合并左子树和右子树时,两个特征向量之间的距离。
– id:用来标志该节点是叶节点还是内部节点,如果是叶节点,则为正数,如果不是叶节点,则为负数。
打印层次聚类树
根据最后返回的一个根节点,可以遍历其左右子树打印该聚类树。
层次聚类树的缺点是耗时,时间主要消耗在相似度的计算上。
K-均值聚类
K-均值聚类需要人为指定K值。首先随机确定K个聚类中心的位置,随后将每个点分配到与其距离最近的聚类中心,重新确定每个中心的位置,将中心移动到该类别的平均值出。反复进行这一过程,直到聚类结果不再改变为止。
详细代码及注释如下:
# -*- coding: utf-8 -*-
__author__ = 'Bai Chenjia'
from PIL import Image, ImageDraw
from math import *
import random
def readfile(filename):
"""
该函数从本目录下的 blogdata 中读取数据
数据第一行从第二个字符串开始是列标题,列标题代表单词;
数据从第二行开始是数据,其中每行开始第一个单词是博客名称,从后面开始对应词在该博客中出现的次数,即次向量
注意:表示词出现的个数的行号和列号分别和博客名和单词名一一对应
:return: rownames行名(博客名), colname列名(单词名),data每一个元素是一个list,代表本博客的词向量
"""
fp = open(filename, 'r')
lines = [line for line in fp.readlines()]
colname = lines[0].strip().split('\t')[1:] # 列名
rownames = []
data = []
for line in lines[1:]:
rownames.append(line.strip().split('\t')[0])
data.append([float(vec) for vec in line.strip().split('\t')[1:]])
return rownames, colname, data
def pearson(v1, v2):
"""
求词向量v1和词向量v2的皮尔森相关系数,两个词向量大小相同。相关系数越大,返回值越小
:param v1: 第一个词向量
:param v2:
:return:
"""
# 求和
sum1 = sum(v1)
sum2 = sum(v2)
# 求平方和
sum1Sq = sum([pow(w1, 2) for w1 in v1])
sum2Sq = sum([pow(w2, 2) for w2 in v2])
# 求乘积之和
pSum = sum([v1[i] * v2[i] for i in range(len(v1))])
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
else:
return 1.0 - num/den
class bicluster:
"""
存储每个簇,其属性包括
left:多个簇聚成的簇中最小的簇标号 right:多个簇聚成的簇中最大的簇标号
vec:词向量 id:簇标号,原始簇标号为正,聚集后的簇标号为负 distance:当前合并的簇之间的距离
"""
def __init__(self, vec, left=None, right=None, distance=0.0, iid=None):
self.left = left
self.right = right
self.vec = vec
self.id = iid
self.distance = distance
def hcluster(rows, cal_distance=pearson):
"""
层次聚类算法聚类,两个簇聚成一个簇之后,其词向量用原来两个簇的词向量取平均值表示
:param rows: 词向量集合,每一个元素代表一个词向量
:return: 返回最终的簇
"""
distances = {}
currentclustid = -1
clust = [bicluster(rows[i], iid=i) for i in range(len(rows))]
while len(clust) > 1:
# 寻找距离最小的簇对
lowespair = (0, 1)
clostest = pearson(clust[0].vec, clust[1].vec)
for i in range(len(clust)):
for j in range(i+1, len(clust)):
if (clust[i], clust[j]) not in distances:
distances[(clust[i].id, clust[j].id)] = cal_distance(clust[i].vec, clust[j].vec)
d = distances[(clust[i].id, clust[j].id)] # 所合并的两个节点的距离,即合并误差
if d < clostest:
clostest = d
lowespair = (i, j)
# 将找到的距离最小的簇对合并为新簇,新簇的vec为原来两个簇vec的平均值
mergevec = [(clust[lowespair[0]].vec[k] + clust[lowespair[1]].vec[k]) / 2 for k in range(len(clust[0].vec))]
newcluster = bicluster(mergevec, left=clust[lowespair[0]], right=clust[lowespair[1]], distance=clostest, iid=currentclustid)
currentclustid -= 1
print "本次合并的两个簇的标号分别是", clust[lowespair[0]].id, clust[lowespair[1]].id,
print "生成的簇标号是:", currentclustid+1,
print "当前簇的个数是", len(clust)
# 删除原来的两个簇,添加新簇
# 注意此处必须先删除 lowespair[1] 再删除 lowespair[0]. 因为 lowespair[1]的序号大于lowespair[0]
# 而删除会导致数组个数减少,故如果先删除序号在前的元素,会比其序号大的元素全部前移.
del clust[lowespair[1]]
del clust[lowespair[0]]
clust.append(newcluster)
return clust[0]
def printclust(clust, label=None, n=0):
"""
根据 hcluster 函数的输出,递归遍历树,输出层次聚类树的结构
根据 clust 中每个元素存储的 left 和 right 信息可以知道合并时其左右子树,递归遍历则可以输出所有子树
:param clust: 层次遍历最后输出的一个簇
:param label: 在本例中代表博客名,即聚类的对象
:param n: 在本例中代表树的层数
:return: 输出结构,无返回值
"""
for i in range(n): # n代表当前遍历的层数,层数越多,前面的空格越多
print " ",
if clust.id < 0:
# 负数标记代表这是一个分支
print '-'
else:
# 正数标记代表这是一个叶节点
if label == None:
print clust.id
else:
print label[clust.id]
if clust.left != None:
printclust(clust.left, label=label, n=n+1)
if clust.right!= None:
printclust(clust.right, label=label, n=n+1)
"""
------------------------------------------------------------------
以下几个函数利用PIL包绘制层次聚类的树形结构
-----???此处较难设计----
"""
def getheight(clust):
"""
返回聚类树的总体高度,即图形的整体高度,所有分支的高度之和。本树的高度为 99
递归计算。如果该节点是叶子节点,则该节点高度为1,否则高度为该节点左右子树高度之和
:param clust: clust是hcluster函数返回的层次聚类的最后一层
:return: 返回层次聚类树的总体高度
"""
if clust.left == None and clust.right == None:
return 1
return getheight(clust.left) + getheight(clust.right)
def getdepth(clust):
"""
返回聚类树的总体宽度,即聚类树的层数。
一个节点的误差深度等于其下属的每个分支的最大可能误差 + 自身的误差。根节点的误差为0
:param clust: 根节点
:return:返回树的总体宽度(深度)
"""
if clust.left == None and clust.right == None:
return 1
return max(getdepth(clust.left), getdepth(clust.right)) + clust.distance
def drawdendrogram(clust, labels, jpeg='clusters.jpg'):
"""
该函数调用 getheight, getdepth, drawnode 函数最终绘制出层次聚类树
具体做法:1.首先绘制根节点和根节点的水平线 2.绘制分支节点,首先获取左子树和右子树深度,再绘制到分支节点的垂直线和绘制两条水平线
:param clust: 层次聚类结果
:param labels: 博客名
:param jpeg: 结果保存的图像名
:return: 生成图像保存在本地
"""
h = getheight(clust)*20
w = 1200 # 固定宽度为1200像素
depth = getdepth(clust)
# 宽度方向的缩放因子
scaling = float(w-150)/depth
# 创建图像,白色背景
img = Image.new('RGB', (w, h), (255, 255, 255))
draw = ImageDraw.Draw(img)
# 绘制根节点的水平线,即在高速为 h/2 的地方绘制长度为10个像素的水平线
draw.line((0, h/2, 10, h/2), fill=(255, 0, 0))
# 调用 diawnode 函数绘制节点
drawnode(draw, clust, 10, (h/2), scaling, labels)
# 保存图像
img.save(jpeg, 'JPEG')
def drawnode(draw, clust, x, y, scaling, labels):
"""
??? 递归,绘制指定节点 clust 及其分支节点的垂直线和水平线
:param draw: 绘图对象
:param clust: 聚类
:param x: 水平方向绘制
:param y: 垂直方向绘制
:param scaling: 缩放因子
:param labels: 博客名
:return:
"""
if clust.id < 0:
h1 = getheight(clust.left)*20
h2 = getheight(clust.right)*20
top = y - (h1 + h2) / 2
bottom = y + (h1 + h2) / 2
# 线的长度
ll = clust.distance*scaling
# 聚类到其子节点的垂直线
draw.line((x, top + h1 / 2, x, bottom - h2 / 2), fill=(255, 0, 0))
# 连接左侧节点的水平线
draw.line((x, top + h1/2, x + ll, top + h1 / 2), fill=(255, 0, 0))
# 连接右侧节点的水平线
draw.line((x, bottom - h2 / 2, x + ll, bottom - h2/2), fill=(255, 0, 0))
# 递归遍历其左右节点
drawnode(draw, clust.left, x+ll, top+h1/2, scaling, labels)
drawnode(draw, clust.right, x+ll, bottom-h2/2, scaling, labels)
else:
# 叶节点,写标签
draw.text((x + 5, y - 7), labels[clust.id], (0, 0, 0))
"""
绘制层次聚类树部分结束-------------太强大了!!! -------------------------------------------
"""
def rotatematrix(data):
"""
列聚类,将data矩阵作转置,返回转置后的矩阵 newdata。
newdata可以使用前面写的 hcluster(newdata)函数 和 drawdendrogram(newclust, labels=words, jpeg='newclusters.jpg')函数聚类
"""
newdata = []
for i in range(len(data[0])): # 循环列
line = []
# 内层循环可用列表生成式 line = [data[j][i] for j in range(len(data))] 替代
for j in range(len(data)): # 循环行
line.append(data[j][i])
newdata.append(line)
return newdata
def kcluster(rows, distances=pearson, k=4):
"""
K均值聚类,针对博客名,单词作为向量进行聚类,k代表簇的个数
"""
# 求每行的最大值和最小值
ranges = [(min([row[i] for row in rows]), max([row[i] for row in rows]))
for i in range(len(rows[0]))]
# 随机创建k个中心点
clusters = [[random.random()*(ranges[i][1]-ranges[i][0]) + ranges[i][0] for i in range(len(rows[0]))] for j in range(k)]
lastmatches = None
for t in range(100): # 最多循环100次
print '循环:%d', t
#k个簇首先都初始化为空
bestmatches = [[] for i in range(k)]
# 循环每一行,从k个中心中查找与之最近的中心
for j in range(len(rows)):
row = rows[j]
bestmatch = 0
for i in range(k):
d = distances(clusters[i], row)
if d < distances(clusters[bestmatch], row):
bestmatch = i
bestmatches[bestmatch].append(j) # 在簇bestmatch中加入元素j
# 如果结果与上一次结果相同则结束
if bestmatches == lastmatches:
break
lastmatches = bestmatches
# 重新计算簇中心
for i in range(k):
avgs = [0.0] * len(rows[0]) # 置成0
if len(bestmatches[i]) > 0: # 如果该簇中有元素
for rowid in bestmatches[i]: #
for m in range(len(rows[rowid])):
avgs[m] += rows[rowid][m]
for j in range(len(avgs)):
avgs[j] /= len(bestmatches[i])
clusters[i] = avgs
return bestmatches
def tanimoto(v1, v2):
"""
希望拥有两件物品的人在物品方面互有叠加的情况下进行度量
Tanimoto系数度量代表交集与并集的比例,返回一个介于0和1之间的值,相似度越高,返回值越小
"""
c1, c2, shr = 0, 0, 0
for i in range(len(v1)):
if v1[i] != 0:
c1 += 1
if v2[i] != 0:
c2 += 1
if v1[i] != 0 and v2[i] != 0:
shr += 1
return 1.0 - (float(shr)/(c1+c2-shr))
def scaledown(data, distance=pearson, rate=0.001):
"""
用二维图形展示二维空间中向量的位置关系
首先初始化各点,以各顶点间的目标距离作为优化目标,计算误差,根据误差计算梯度
根据梯度移动各顶点,直到误差满足要求或达到最大迭代次数为止
"""
n = len(data)
# 存储两两点之间的目标距离
realdist = [[distance(data[i], data[j]) for j in range(n)]
for i in range(0, n)]
# 随机初始化节点在二维空间中的起始位置
loc = [[random.random(), random.random()] for i in range(n)]
# 存储投影到二维平面后两两之间的实际距离
fakedist = [[0.0 for j in range(n)] for i in range(n)]
lasterror = None # 非常小的数
for m in range(0, 1000):
print m,
#寻找投影后的位置
for i in range(n):
for j in range(n):
# 计算平面上两向量之间的欧式距离
fakedist[i][j] = sqrt(sum([pow(loc[i][x] - loc[j][x], 2)
for x in range(len(loc[i]))]))
#移动节点,记录四个方向的梯度
grad = [[0.0, 0.0] for i in range(n)]
totalerror = 0
# 当前需要移动的节点是k节点
for k in range(n):
# 循环遍历其余节点计算误差
for j in range(n):
if j == k:
continue
errorterm = (fakedist[j][k] - realdist[j][k]) / realdist[j][k]
grad[k][0] += ((loc[k][0] - loc[j][0]) / fakedist[j][k]) * errorterm
grad[k][1] += ((loc[k][1] - loc[j][1]) / fakedist[j][k]) * errorterm
totalerror += abs(errorterm)
print lasterror, totalerror, grad[0][:], grad[1][:]
# 比较误差较上一次增大还是减小
if lasterror and lasterror < totalerror:
break
lasterror = totalerror
# 根据rate参数与grad值相乘的结果,移动每一个节点
for k in range(n):
loc[k][0] -= rate * grad[k][0]
loc[k][1] -= rate * grad[k][1]
return loc
def draw2d(data, labels, jpeg='mds2d.jpg'):
"""
使用PIL生成一幅图,根据新的坐标值,在图上标出所有数据项的位置及其对应的标签
"""
img = Image.new('RGB', (2000, 2000), (255, 255, 255))
draw = ImageDraw.Draw(img)
for i in range(len(data)):
x = (data[i][0] + 0.5) * 1000
y = (data[i][1] + 0.5) * 1000
draw.text((x, y), labels[i], (0, 0, 0))
img.save(jpeg, 'JPEG')
if __name__ == '__main__':
# 读取文件,返回博客名列表,单词名列表和数据
blogname, words, data = readfile(filename="blogdata.txt")
"""
-----------------------------------------------------
行聚类,针对博客名,以单词出现频次组成的词向量为特征进行聚类
----------------------------------------------------
"""
#层次聚类,返回最终得到的聚类树的最上层(只有一个类别)
#clust = hcluster(data, cal_distance=pearson)
#不使用图像包,简单绘制层次聚类树
#printclust(clust, label=blogname)
# 根据聚类返回值 clust 获取层次聚类树的高度
#height = getheight(clust)
#print "行聚类树的高度是:", height
# 根据聚类返回值 clust 获取层次层次聚类树的深度(总体误差)
#depth = getdepth(clust)
#print "行聚类树的深度是:", depth
# 绘制层次聚类树
#drawdendrogram(clust, labels=blogname, jpeg="Cluster_BlogData//clusters.jpg")
#print "行聚类层次聚类树绘制完毕!"
"""
---------------------------------------------------------
列聚类,对单词进行聚类,处理时只需要将 data 进行转置,按照之前编写的聚类函数进行聚类
转置后的data矩阵行元素代表单词,列元素代表博客名,特征向量转为词出现在一系列博客中的次数
由于单词的数量多于博客的数量,因而运行时间更长
"""
#newdata = rotatematrix(data) # 反转颜色
#newclust = hcluster(newdata, cal_distance=pearson) # 列聚类
#drawdendrogram(newclust, labels=words, jpeg='newclusters.jpg')
"""
------------------------------------------------------
K均值聚类
"""
#kclust = kcluster(data, k = 10)
#print "簇0中元素是:", [blogname[r] for r in kclust[0]][:]
"""
-------------------------------------------------------
二维数据可视化
"""
coords = scaledown(data)
draw2d(coords, blogname)