第 49 课 · 进阶 · 19 分钟

Python k-means 聚类:从距离到收敛

分配样本、移动质心,重复到结果稳定。

k-means质心距离聚类

Maya 的客人分成早上赶路的、学生和在家工作的,可没人给它们贴过标签。k-means 能从行为里提出分组。这个算法真正在优化什么?

k-means 是无监督算法:没有标签可供预测。它反复把每个点分配给最近的质心,再把质心移到成员均值。循环很短,但距离缩放和初始化会决定它最终找到什么。 你还会尝试最远优先初始化,并用惯性比较不同的 k。

计算距离

常用选择是欧氏距离。平方距离保留相同排序,还能省去开方,但解释结果时欧氏距离更直观。坐标顺序必须始终一致。

import math

def distance(a, b):
    return math.dist(a, b)

print(distance((0, 0), (3, 4)))

相距五个单位

把每个点分配给质心

对每个点,比较它到所有质心的距离并保留最近的索引。确定性的平局规则可以避免因为字典或集合顺序变化而改变分配结果。

def distance(a, b):
    return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5

centroids = [(0, 0), (5, 5)]
point = (1, 1)
label = min(range(len(centroids)), key=lambda i: (distance(point, centroids[i]), i))
print(label)

最近的质心获胜

把质心移到成员均值

分配完成后,每个质心变成属于它的所有点在每个坐标上的均值。空簇没有成员,所以代码必须明确决定:保留旧质心、重新播种,还是减少 k。

points = [(0, 0), (0, 1), (5, 5), (5, 6)]
labels = [0, 0, 1, 1]
members = [point for point, label in zip(points, labels) if label == 0]
centroid = (
    sum(x for x, _ in members) / len(members),
    sum(y for _, y in members) / len(members),
)
print(tuple(round(value, 1) for value in centroid))

成员坐标的平均值

迭代到标签不再变化

重复分配和更新,直到标签或质心不再移动,或者达到步数上限。结果是局部最优,所以换一种初始化可能得到不同聚类。

def distance(a, b):
    return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5

points = [(0, 0), (0, 1), (5, 5), (5, 6)]
centroids = [(0, 0), (5, 5)]
labels = None
for _ in range(10):
    new_labels = [
        min(range(len(centroids)), key=lambda i: (distance(point, centroids[i]), i))
        for point in points
    ]
    new_centroids = []
    for index in range(len(centroids)):
        members = [point for point, label in zip(points, new_labels) if label == index]
        if members:
            new_centroids.append((
                sum(x for x, _ in members) / len(members),
                sum(y for _, y in members) / len(members),
            ))
        else:
            new_centroids.append(centroids[index])
    if new_labels == labels and new_centroids == centroids:
        break
    labels = new_labels
    centroids = new_centroids
print(labels)
print([(round(x, 1), round(y, 1)) for x, y in centroids])

两个清楚的簇

最远优先初始化让质心分散

随机初始化可能把两个质心放进同一密集区域。最远优先先选一个起点,然后反复加入“到最近质心距离最大”的点。给定起点时它是确定性的,通常能得到更有用的初始分布。

def distance(a, b):
    return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5

points = [(0, 0), (1, 0), (5, 5), (6, 5)]
centroids = [points[0]]
while len(centroids) < 2:
    candidate = max(points, key=lambda point: min(distance(point, c) for c in centroids))
    centroids.append(candidate)
print(centroids)

选择离得远的下一个质心

惯性有助于比较 k,但不能证明 k

惯性是每个点到所属质心的平方距离之和。k 增大时惯性通常下降,所以最小值并不自动代表最佳选择。应结合肘部形态、领域约束和不同初始化下的稳定性,并显式处理空簇。

def inertia(points, centroids):
    total = 0
    for point in points:
        nearest = min(centroids, key=lambda c: sum((x - y) ** 2 for x, y in zip(point, c)))
        total += sum((x - y) ** 2 for x, y in zip(point, nearest))
    return total

points = [(0, 0), (1, 0), (5, 5), (6, 5)]
for k, centroids in [(1, [(3, 2.5)]), (2, [(0.5, 0), (5.5, 5)])]:
    print(k, round(inertia(points, centroids), 1))

惯性更低不自动等于更好

本课要点

  • k-means 是无监督算法,不使用目标标签。
  • 每轮先把样本分配给最近的质心。
  • 质心再移动到已分配样本的均值。
  • 特征缩放会改变距离,从而改变聚类结果。
  • 结果依赖初始化,而且可能是局部最优。
  • 最远优先让初始质心分散;惯性可以比较 k,但仍需判断。

常见错误

  • 忘记缩放量纲不同的特征。
  • 还没分配完所有点就提前更新质心。
  • 忽略空簇,或悄悄算出 NaN 质心。
  • 因为惯性最低就选最大的 k,然后把聚类结果说成有意义的发现。
轮到你

用三个初始质心运行同样的循环,收敛后打印最终标签。

动手试试

改改变量、先预测输出,再运行代码。第一次点击“运行代码”时会下载 Python 运行时,可能需要几秒钟。

浏览器内 Python本地运行 · 无需账号
首次运行会从 CDN 下载 Pyodide。修改后点击“运行代码”。

练习本课

12 道题,作答后立即看到解释。

查看 12 道题答案开始练习
← 朴素贝叶斯监控与漂移 →