通过聚类,我们可以跟踪统计消费者信息,发现具有相似消费习惯的群体,并据此开发相应的产品或者市场策略
利用样本输入和期望输出来学习如何预测的技术
无监督学习不是利用样本进行训练,而是要在一组数据中找寻某种结构
由于本文主要讲述聚类,所以对数据来源不做记录,此处提供本文数据源下载地址
读取数据:
# 读取博客的统计数据
def readFile():
with open('blogdata.txt', 'r')as f:
lines = [line for line in f]
# 第一行数据是列名称,去掉第一个blog字样
colNames = lines[0].replace('\n', '').split('\t')[1:]
# 每一行的第一个数据是行名称
rowNames = []
# data数据不包含每一行的第一列
data = []
for i in range(1, len(lines)):
# 去除换行符合空格
l = lines[i].replace('\n', '').split('\t')
# 行名是第一个数据
rowNames.append(l[0])
data.append([float(x) for x in l[1:]])
return colNames, rowNames, data
通过连续的把最相近的群组合并为新的群组来构造一个全新的群组,每一个群组都是从单一元素开始的,其过程如下图:
在上图中,每次都把最近的两个元素放在一起,合成一个新的元素,然后求得这个元素里两个元素的平均值作为新的值,再重复上面过程直到最后只剩下一个元素就可以了,而所有的元素内容都会保存在我们的数据中
对于数据的紧密度,我们依然采用上一篇提到过的皮尔逊系数:
# 比较数据d1和d2的相似度
def pearson(d1, d2):
# 求和
sum1 = sum(d1)
sum2 = sum(d2)
# 平方和
sumSq1 = sum([pow(v, 2) for v in d1])
sumSq2 = sum([pow(v, 2) for v in d2])
# 乘积之和
pSum = sum([d1[i] * d2[i] for i in range(len(d1))])
num = pSum - (sum1 * sum2 / len(d1))
den = math.sqrt(((sumSq1 - pow(sum1, 2) / len(d1)) * (sumSq2 - pow(sum2, 2) / len(d2))))
if den == 0: return 0
# num/den得到皮尔逊系数,这个数字越大表示两个数据集相似度越高
# 用1-num/den得到的结果表示两个数据集之间的距离,相似度越高距离越近
return 1 - num / den
新建一个类作为聚类的载体:
# 用来保存聚合数据的类
# vec 保存聚合数据
# left 是聚合数据的左节点
# right 是聚合数据的右节点
# id 可以用来判断数据是原始数据还是聚合数据,如果是原始数据还可以根据id获取对应的行名称
# distance中保存原始数据的距离
class bicluster:
def __init__(self, vec, left=None, right=None, id=None, distance=None):
self.vec = vec
self.left = left
self.right = right
self.id = id
self.distance = distance
有了上面的载体,我们只需要循环把两个最相近的组聚合,然后重复这个过程,就能得到一个包含了所有数据的最终聚类
# 分级聚类,将数据聚合成一个bicluster对象
def hcluster(data, distance=pearson):
distances = {}
currentclustId = -1
# 原始的聚类就是所有数据的集合
clust = [bicluster(data[i], id=i) for i in range(len(data))]
# 大循环
while len(clust) > 1:
# 默认0/1是每次大循环开始时最近的数据
# lowestpair保存最近的一组数据,closest保存他们的距离
lowestpair = (0, 1)
closest = distance(clust[0].vec, clust[1].vec)
# 两次循环保证所有数据可以比较
for i in range(len(clust)):
for j in range(len(clust)):
# 不跟自己比
if i == j: continue
# 如果当前数据没有计算过才计算,不直接用i,j是因为聚合之后i,j就不跟原始的数据对应了
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:
# 在这个大循环结束之前,i/j组合还可以代表最近的组
lowestpair = (i, j)
closest = d
# 获取当前最近组的所有项的平均值
mergevec = [(clust[lowestpair[0]].vec[i] + clust[lowestpair[1]].vec[i]) / 2 for i in range(len(data[0]))]
# 构造新的组,这个组中包含了子数据的所有信息
newclust = bicluster(mergevec, left=clust[lowestpair[0]], right=clust[lowestpair[1]], id=currentclustId,
distance=closest)
# 清除原始数据组,加入新数据
currentclustId -= 1
del clust[lowestpair[1]]
del clust[lowestpair[0]]
clust.append(newclust)
print(clust[0])
return clust[0]
通过循环执行,得到了唯一的聚类,我们可以来尝试一下使用:
colNames, rowNames, data = readFile()
hcluster(data)
这个过程会消耗一定的时间,结果会打印出来一个bicluster的对象
但是这样一个对象并不能让我们直观的感受到各个数据之间的关系,所以我们需要想办法使聚类的结构关系可视化,此处引入一个很好用的Python图像处理库PythonImagingLibrary,简称PIL,如果对这个库不了解的可以在网上学习一下简单的使用,此处不做过多介绍
要绘制图片,我们需要知道各个元素的高度和图片的宽度,由于线条的长度会根据原始数据的误差进行调整,所以我们还需要计算出总得误差并据此生成一个误差因子:
# 获取聚类的高度
def getHeight(bicluster):
# 是原始数据,高度为1
if bicluster.left is None and bicluster.right is None:
return 1
# 非原始数据,高度是两个子数据高度之和
else:
return getHeight(bicluster.left) + getHeight(bicluster.right)
#获取聚类的误差
def getDepth(bicluster):
# 原始数据误差为0
if bicluster.left is None and bicluster.right is None:
return 0
# 聚合数据取误差较大者
else:
return max(getDepth(bicluster.left), getDepth(bicluster.right)) + bicluster.distance
这样我们就可以开始绘图了:
# 绘制图片
def drawDendrogram(bicluster, labels, jpge='clusters.jpeg'):
# 设置宽高数据
h = getHeight(bicluster) * 20
w = 1200
depath = getDepth(bicluster)
# 宽度固定,所有留一点额外的空间
scaling = float((w - 150) / depath)
image = Image.new('RGB', (w, h), (255, 255, 255))
draw = ImageDraw.Draw(image)
draw.line((0, h / 2, 10, h / 2), (255, 0, 0))
print('draw start...')
drawNode(bicluster, draw, 10, h / 2, scaling, labels)
image.save(jpge, 'JPEG')
#递归绘制细节
def drawNode(bicluster, draw, x, y, scaling, labels):
# 原始数据,显示文字即可
if bicluster.left is None and bicluster.right is None:
draw.text((x + 5, y - 7), labels[bicluster.id], (0, 0, 0))
# 聚合数据,根据聚合两个元素的距离来画
else:
h1 = getHeight(bicluster.left) * 20
h2 = getHeight(bicluster.right) * 20
# 留出两个子元素高度的空隙
top = y - (h1 + h2) / 2
bottom = y + (h1 + h2) / 2
# 画出竖直的线,高度是两个子元素高度的一半
draw.line((x, top + h1 / 2, x, bottom - h2 / 2), fill=(255, 0, 0))
# 画出水平的线,宽度是缩放系数X距离
ll = scaling * bicluster.distance
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(bicluster.left, draw, x + ll, top + h1 / 2, scaling, labels)
drawNode(bicluster.right, draw, x + ll, bottom - h2 / 2, scaling, labels)
完成上面的代码之后就可以把我们得到的分级聚类结果显示出来了,还不赶紧试试
有时候我们不仅想获得行元素的聚类,也想看一下列元素的聚类结果,对应我们这次操作的数据来看,行元素的聚类可以让我们查看博客之间的相似性,但是对列元素也就是词组的相似性分析有时候对我们也很有意义,所以我们可以对数据做一下矩阵转换,再对变换过的矩阵做相同的操作,得到我们想要的结果:
def translateXY(data):
result = []
# 获取列数,用此进行循环,每个新的组包含了原来的一列元素
for x in range(len(data[0])):
# x代表第x列,y代表第y行,这样把每一列的元素都取出来形成新的数组
newrow = [data[y][x] for y in range(len(data))]
# 这样添加的数据,他的列数和他的索引是相同的
result.append(newrow)
return result
一起调用一下看看:
colNames, rowNames, data = readFile()
drawDendrogram(hcluster(data), rowNames)
drawDendrogram(hcluster(translateXY(data)), colNames, jpge='trans_clusters.jpeg')
print('end')
怎么样,同级目录下是不是多出来两张图片,显示了分组的详细情况?
优点:形象,直观
缺点:并没有真正将数据分组,计算量比较大,很耗时
k-均值聚类首先确认每一列元素的范围(计算最大值最小值),然后随机生成k个行,这些行里的每一个列元素都在范围之内
之后我们可以根据元素和这k个随机行之间的距离把元素分为k组,得到了k个组之后我们再把这k个组的所有数据取平均值,就得到了新的组,然后以新的组为中心,不断的重复运算直到组不再变化,就得到了k个组,过程如下:
代码实现:
# k均值聚类
def kclust(data, rowNames, distance=pearson, k=5):
# 存一下列数,经常要用
col_num = len(data[0])
# 随机列的数据
randomrows = []
# 通过k-v的形式存储每个随机聚点下的子数据
last_clusts = {}
new_clusts = {}
# 循环列,拿到每一列的最大值和最小值
# for x in range(len(data[0])):
# # 最大值
# col_max = max([row[x] for row in data])
# # 最小值
# col_min = min([row[x] for row in data])
# # 每一列对应的正好是索引
# max_min.append((col_max, col_min))
# 简写如下:
# 存储每一列的最大值和最小值
max_min = [(max([row[x] for row in data]), min([row[x] for row in data])) for x in range(col_num)]
# 随机k个行数据
for i in range(k):
# max_min[j][0]-max_min[j][1]表示取最大值和最小值的差值,在用这个值X随机数,在加上最小值
# 得到了最大值和最小值之间的一个随机值
# 把上述过程进行列数个次数,就得到了一个随机行
random_row = [(random.randint(0, 1) * (max_min[j][0] - max_min[j][1]) + max_min[j][1]) for j in range(col_num)]
randomrows.append(random_row)
# 大循环进行到数据不再更改
while True:
for i in range(k):
new_clusts[i] = []
# 拿每一行去跟随机行比,找到最近的,算进他的组里
for i in range(len(data)):
# 默认最近的是第一个随机行
c_index = 0
closest = distance(data[i], randomrows[c_index])
for j in range(1, k):
d = distance(randomrows[j], data[i])
# 找到了更近的
if d < closest:
c_index = j
closest = d
# 把数据放入最近的聚点的名下
new_clusts[c_index].append((rowNames[i], data[i]))
# 如果重新排之后数据没变化,说明已完成,退出循环
if last_clusts == new_clusts: break
# 数据复制,直接=的话会一直相同,用copy复制出来
last_clusts = new_clusts.copy()
# randomrows.clear()
# for k in new_clusts:
#
# # 如果组里没东西,过
# if new_clusts[k] is None or len(new_clusts[k]) == 0: continue
# # 对于组中的每一列,求平均值,形成一个结果组,放进原来的随机组里
# randomrows.append(
# [sum([row[x] for row in new_clusts[k]]) / len(new_clusts[k]) for x in range(col_num)]
# )
# 简写如下:
randomrows = [[sum([row[1][x] for row in new_clusts[k]]) / len(new_clusts[k]) for x in range(col_num)] for k in
new_clusts if new_clusts[k] is not None and len(new_clusts[k]) != 0]
return new_clusts
由于函数使用了随机的中心点作为开始,所以每次聚类的结果都可能不同
皮尔逊系数更适用于统计数据,如果我们采用的是0/1表示的有/无的数据,就需要其他的度量方法,Tanimoto系数可以满足我们的需求
他是通过数据的交集除以并集得到元素的相关度的,结果越大说明元素越相关
def tanimoto(d1, d2):
# r1/r2表示d1/d2中的非无数据个数,sr表示交集个数,此处我采用0表示没有数据,1表示有
r1, r2, sr = 0, 0, 0
for i in range(len(d1)):
if d1[i] == 1:
r1 += 1
if d2[i] == 1:
r2 += 1
if d1[i] == d2[i]:
sr += 1
# sr/(r1+r2-sr)得到的数据越大说明相似度越高,但是不利于我们看距离,
# 所以用1-sr/(r1+r2-sr)来表示距离,值越小说明距离越近,相似度越高
return 1.0-float(sr / (r1 + r2 - sr))
def scaledown(data, distance=pearson, rate=0.01):
n = len(data)
# 记录上次的误差值
last_err = None
# 记录数据的真实距离,这是我们的目标结果
realDis = [[distance(data[j], data[i]) for j in range(n)] for i in range(n)]
# 每一列随机生成一个坐标点,代表这一列的位置
rpoints = [[random.random(), random.random()] for i in range(n)]
# 做一个双层数组存储数据信息
fakeDis = [[[0.0] for j in range(n)] for i in range(n)]
while True:
# 求模拟点之间的距离,视为当前距离
for i in range(n):
for j in range(n):
fakeDis[i][j] = math.sqrt(sum([pow(rpoints[j][x] - rpoints[i][x], 2) for x in range(2)]))
grad = [[0.0, 0.0] for i in range(n)]
total_err = 0
for i in range(n):
for j in range(n):
if i == j: continue
# 记录当前两个点的误差值
err = (fakeDis[i][j] - realDis[i][j]) / realDis[i][j]
# i来移动,移动的距离是i,j在x/y轴上的差值/当前距离X误差
grad[i][0] += ((rpoints[i][0] - rpoints[j][0]) / fakeDis[i][j]) * err
grad[i][1] += ((rpoints[i][1] - rpoints[j][1]) / fakeDis[i][j]) * err
total_err += abs(err)
print(total_err)
# 移动之后如果会更混乱,则停止
if last_err is not None and total_err >= last_err: break
last_err = total_err
# 根据计算结果移动点的位置
for i in range(n):
rpoints[i][0] -= grad[i][0] * rate
rpoints[i][1] -= grad[i][1] * rate
return rpoints
绘制聚类结果
def drawPoints(points, labels, jpeg='sdc.jpeg'):
# 白色背景图
image = Image.new('RGB', (2000, 2000), (255, 255, 255))
draw = ImageDraw.Draw(image)
# 取出移动完毕的点,拿到相应的名称显示出来
for i in range(len(points)):
x = points[i][0] * 1000
y = points[i][1] * 1000
draw.text((x, y), labels[i], fill=(0, 0, 0))
image.save(jpeg)
调用
colNames, rowNames, data = readFile()
drawPoints(scaledown(data), rowNames)