集体智慧编程——博客文章聚类-Python实现

本章中实现了层次聚类算法和K均值算法,用于博客的聚类。使用的特征为词向量。即特定词在博客文章中出现的次数。

  1. 读入数据
    数据中行的第一个词代表博客名,列的第一个词代表单词特征。存储的数字代表该词在该博客中出现的次数。读入该句子,用Python的list存储。【【】,【】,【】….】用两层链表结构来模拟矩阵。

  2. 层次聚类算法
    首先定义向量之间的相似度度量方法是皮尔森相关系数。该相关系数比欧氏距离更适合,因为不同的博客长短不一,我们要探求的相似度是线性相关性,而非真实距离。
    数据结构:
    class bicluster的数据成员有
    – vec:代表该聚类的特征向量
    – left:如果该节点不是叶子节点,则存储其左孩子,否则为None
    – right:如果该节点不是叶子节点,则存储其右孩子,否则为None
    – distance:表示合并左子树和右子树时,两个特征向量之间的距离。
    – id:用来标志该节点是叶节点还是内部节点,如果是叶节点,则为正数,如果不是叶节点,则为负数。

  3. 打印层次聚类树
    根据最后返回的一个根节点,可以遍历其左右子树打印该聚类树。

  4. 层次聚类树的缺点是耗时,时间主要消耗在相似度的计算上。

  5. 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)

你可能感兴趣的:(集体智慧编程)