第33章 KMeans

33.1 问题从哪来#

上一章的 KNN 能回答"这个点和哪些点最像"。给一个新点,程序遍历数据库,找出距离最近的 K 条记录。

但 KNN 需要一个查询点:先给出 Q,程序再找 Q 附近的记录。

如果没有查询点,只是手里有一批数据,问题就变了。数据库里有一百个学生,每个学生有两个特征数字,比如学习时长和考试分数。程序能不能只看这些数字,自己把学生分成几组?

这就是聚类(clustering)要解决的问题:不需要标签,让程序自动分组。同一组里的点互相靠近,不同组的点互相远离。

注意一下,这里的 K 和上一章 KNN 里的 K 含义不同。KNN 的 K 是“找几条最近记录”,KMeans 的 K 是“分成几组”。


33.2 先看一个例子#

假设数据库里有 8 个学生,每个学生有两个特征:学习时长(小时/周)和考试分数(0~100)。

学生学习时长考试分数
A230
B335
C428
D540
E1580
F1685
G1778
H1890

把这 8 个点画在坐标系里,肉眼就能看出来:左边有 4 个点聚在一起(学习少、分数低),右边有 4 个点聚在一起(学习多、分数高)。

8 个点在坐标系里的分布,左边一组,右边一组

程序不能像人一样直接看出两团点。它要做的是:用一套明确的计算步骤,把这 8 个点分成两组,使得每组内部的点尽量靠近,两组之间尽量远离。


33.3 KMeans 的思路#

KMeans 的思路可以用一句话说清楚:选 K 个中心,把每个点分给最近的中心,再把中心移到组内所有点的平均位置,重复。

具体来说,分四步:

  1. 选初始中心。 从数据里挑 K 个点作为初始中心。K 是你定的,比如 K=2。实际程序里可以随机挑;为了让这次实验每次运行都一样,下面先固定选点 A 和点 E。
  2. 分配。 把每个点分给离它最近的那个中心。
  3. 更新。 对每个组,算组内所有点的平均值,把这个平均值当作新的中心。
  4. 重复。 回到第 2 步,直到中心不再变化(或者变化很小)。

初始中心点:本次固定选 A 和 E 作为中心

每个点分给最近的中心之后,数据被分成两组:

每个点分配到离它最近的中心,形成两组

然后把中心移到组内所有点的平均位置:

中心移动到组内点的平均位置

再分配、再更新。几轮之后,中心不再移动,分组稳定下来:

几轮迭代后分组稳定


33.4 最小实验#

把思路翻译成 C 代码。先定义数据结构:

#include <stdio.h>
#include <math.h>
#include <float.h>

#define N 8        // 数据点的数量
#define K 2        // 要分成几组
#define DIM 2      // 每个点的维度(二维)
#define MAX_ITER 100  // 最多迭代多少轮

struct Point {
    float x[DIM];  // 坐标
    int cluster;    // 属于哪个组(0 到 K-1)
};

Point 结构体存一个点的坐标和它当前属于哪个组。cluster 字段记录分配结果,取值 0 到 K-1。


33.5 距离和分配#

分配点到最近的中心,需要算两点之间的距离。二维点的距离就是勾股定理:

float distance(const float a[DIM], const float b[DIM])
{
    float sum = 0.0f;                       // 平方差累加器
    for (int i = 0; i < DIM; i++) {         // 遍历每个维度
        float diff = a[i] - b[i];           // 当前维度的差值
        sum += diff * diff;                 // 累加差值的平方
    }
    return sqrtf(sum);                      // 开方得到欧氏距离
}

有了距离,分配函数就是:对每个点,遍历 K 个中心,找到最近的那个。

void assign_clusters(struct Point points[N], float centers[K][DIM])
{
    for (int i = 0; i < N; i++) {
        float min_dist = FLT_MAX;
        int best = 0;
        for (int c = 0; c < K; c++) {
            float d = distance(points[i].x, centers[c]);
            if (d < min_dist) {
                min_dist = d;
                best = c;
            }
        }
        points[i].cluster = best;  // 分给最近的中心
    }
}

FLT_MAXfloat.h 里定义的最大浮点数,用来初始化"目前最近的距离",保证第一个中心的距离一定能比它小。


33.6 更新中心#

分配完之后,每个组里的点变了。把中心移到组内所有点的平均位置:

void update_centers(struct Point points[N], float centers[K][DIM])
{
    float sum[K][DIM] = {0};   // 每组的坐标总和
    int count[K] = {0};        // 每组有多少个点

    for (int i = 0; i < N; i++) {
        int c = points[i].cluster;
        for (int d = 0; d < DIM; d++) {
            sum[c][d] += points[i].x[d];
        }
        count[c]++;
    }

    for (int c = 0; c < K; c++) {
        if (count[c] == 0) continue;  // 空组不更新,避免除零
        for (int d = 0; d < DIM; d++) {
            centers[c][d] = sum[c][d] / count[c];  // 平均值
        }
    }
}

新中心 = 组内所有点的坐标之和 / 组内点的个数。每个维度分别算。

注意:如果某个组没有分配到任何点(count[c] == 0),中心不更新。这种情况在初始化不好的时候可能出现。


33.7 迭代主循环#

距离、分配和更新写好后,主循环只剩三件事:保存旧中心、分配并更新、检查是否收敛。可以先按这个骨架写:

for (int iter = 1; iter <= MAX_ITER; iter++) {
    copy_centers(old_centers, centers);      // 保存旧中心

    assign_clusters(points, centers);        // 每个点分给最近中心
    update_centers(points, centers);         // 中心移到组内平均位置

    print_state(points, centers, iter);      // 观察这一轮

    if (!centers_changed(old_centers, centers)) {
        break;
    }
}

copy_centers 可以自己写两个嵌套循环,把 centers[c][d] 复制到 old_centers[c][d]print_state 也只是辅助观察,不影响算法本身。

把前面几个代码块按顺序放进 kmeans.c,再接上下面这段。这里固定把点 A 和点 E 作为初始中心,所以你每次运行都能得到同样的输出:

void copy_centers(float dst[K][DIM], float src[K][DIM])
{
    for (int c = 0; c < K; c++) {         // 遍历 K 个中心
        for (int d = 0; d < DIM; d++) {   // 遍历每个维度
            dst[c][d] = src[c][d];        // 逐元素复制
        }
    }
}

int centers_changed(float old_centers[K][DIM], float centers[K][DIM])
{
    for (int c = 0; c < K; c++) {
        if (distance(old_centers[c], centers[c]) >= 0.001f) {
            return 1;  // 还有中心移动得比较明显
        }
    }
    return 0;
}

void print_state(struct Point points[N], float centers[K][DIM], int iter)
{
    if (iter == 0) {                                 // 第 0 轮打印初始状态
        printf("Initial state:\n");
    } else {                                         // 其他轮打印轮次标题
        printf("=== Round %d ===\n", iter);
    }

    for (int c = 0; c < K; c++) {                    // 打印每个中心的坐标
        printf("  Center %d: (%.2f, %.2f)\n",
               c, centers[c][0], centers[c][1]);
    }

    for (int i = 0; i < N; i++) {                    // 打印每个点及其所属组
        printf("  Point %d (%.0f, %.0f) -> Group %d\n",
               i, points[i].x[0], points[i].x[1], points[i].cluster);
    }
    printf("\n");
}

void print_groups(struct Point points[N])
{
    printf("=== Final Groups ===\n");
    for (int c = 0; c < K; c++) {                    // 遍历每个组
        printf("Group %d:", c);                          // 打印组号
        for (int i = 0; i < N; i++) {                // 遍历所有点,找出属于当前组的
            if (points[i].cluster == c) {             // 该点属于当前组
                printf(" (%d, %.0f, %.0f)",
                       i, points[i].x[0], points[i].x[1]);  // 打印点序号和坐标
            }
        }
        printf("\n");
    }
}

int main(void)
{
    struct Point points[N] = {                                  // 8个点,cluster 初始化为 -1 表示未分配
        {{2, 30}, -1}, {{3, 35}, -1}, {{4, 28}, -1}, {{5, 40}, -1},
        {{15, 80}, -1}, {{16, 85}, -1}, {{17, 78}, -1}, {{18, 90}, -1}
    };

    float centers[K][DIM] = {                                   // 初始中心选点 A 和点 E
        {2, 30},   // 点 A
        {15, 80}   // 点 E
    };
    float old_centers[K][DIM] = {0};                            // 用于保存上一轮的中心

    print_state(points, centers, 0);                            // 打印初始状态

    for (int iter = 1; iter <= MAX_ITER; iter++) {              // 迭代主循环
        copy_centers(old_centers, centers);                     // 保存旧中心,用于判断收敛

        assign_clusters(points, centers);                       // 分配:每个点归入最近的中心
        update_centers(points, centers);                        // 更新:中心移到组内平均位置

        print_state(points, centers, iter);                     // 打印本轮结果

        if (!centers_changed(old_centers, centers)) {           // 中心不再移动则提前结束
            printf("Centers no longer change, iteration complete.\n\n");
            break;
        }
    }

    print_groups(points);                                       // 打印最终分组
    return 0;
}

33.8 编译运行#

保存为 kmeans.c,编译时加 -lm(因为用了 sqrtf):

$ gcc kmeans.c -o kmeans -lm
$ ./kmeans

如果你在 Windows PowerShell 里运行,第二行命令写成 .\kmeans.exe

运行结果(中间省略了部分轮次):

Initial state:
  Center 0: (2.00, 30.00)
  Center 1: (15.00, 80.00)
  Point 0 (2, 30) -> Group -1
  ...

=== Round 1 ===
  Center 0: (3.50, 33.25)
  Center 1: (16.50, 83.25)
  Point 0 (2, 30) -> Group 0
  Point 1 (3, 35) -> Group 0
  Point 2 (4, 28) -> Group 0
  Point 3 (5, 40) -> Group 0
  Point 4 (15, 80) -> Group 1
  Point 5 (16, 85) -> Group 1
  Point 6 (17, 78) -> Group 1
  Point 7 (18, 90) -> Group 1

=== Round 2 ===
  Center 0: (3.50, 33.25)
  Center 1: (16.50, 83.25)
  ...

Centers no longer change, iteration complete.

=== Final Groups ===
Group 0: (0, 2, 30) (1, 3, 35) (2, 4, 28) (3, 5, 40)
Group 1: (4, 15, 80) (5, 16, 85) (6, 17, 78) (7, 18, 90)

初始中心选的是点 A 和点 E,它们恰好在两组的"核心"附近,所以第一轮分配就正确了,第二轮中心没有移动,算法收敛。


33.9 数据/内存/流程里发生了什么#

33.9.1 每一轮在做什么#

迭代的核心是两个函数:assign_clustersupdate_centers

assign_clusters 对每个点算 K 次距离,取最小的。8 个点、2 个中心,一轮要算 $8 \times 2 = 16$ 次距离。每次距离计算涉及 2 次减法、2 次乘法、2 次加法、1 次开方。

update_centers 对每个点累加坐标,最后除以组内点数。8 个点、2 维,一轮要做 $8 \times 2 = 16$ 次加法,最后 2 个组各做 2 次除法。

33.9.2 内存布局#

points 数组在内存里是一段连续空间。每个 Point 结构体包含 2 个 float(8 字节)加 1 个 int(4 字节),共 12 字节。8 个点总共 96 字节。

centers 是一个 K × DIM 的二维浮点数组,2 × 2 = 4 个 float,16 字节。

points[0]:  [2.0, 30.0, cluster=0]   (12 字节)
points[1]:  [3.0, 35.0, cluster=0]   (12 字节)
...
centers[0]: [3.50, 33.25]            (8 字节)
centers[1]: [16.50, 83.25]           (8 字节)

33.9.3 收敛的含义#

centers_changed 函数比较新旧中心的距离。如果所有中心移动距离都小于 0.001,就认为收敛了。

为什么不是比较精确的 0?因为浮点运算有精度误差。两次除法的结果可能差 $10^{-7}$ 这个量级。直接比较 0,可能让程序迟迟等不到“完全相等”的那一刻。在这个例子里,0.001 已经足够区分中心是否还在明显移动。

33.9.4 初始中心的影响#

这个例子里,初始中心选的是点 A(2, 30)和点 E(15, 80),恰好在两组的中心附近。如果初始中心选得不好,比如选点 A(2, 30)和点 D(5, 40),它们都在左边那一组里,会发生什么?

第一轮分配:8 个点里,大部分点离 A 或 D 都差不多远,可能被分成两个不太合理的组。更新中心之后,中心会往数据的真正中心移动。经过几轮迭代,最终可能还是分对了,也可能分不对。

这就是 KMeans 的一个重要性质:初始中心的选择会影响最终结果。 运行多次、取最好的那次,是一种常用的策略。


33.10 常见坑#

坑 1:K 选错了。 K 是你定的,程序不知道数据应该分几组。如果数据本来有 3 组,你设 K=2,会把两个组合并成一个。如果设 K=10,会把一组拆成好几个碎片。选 K 没有万能公式,需要对数据有基本了解。

坑 2:初始中心选到同一个点。 如果随机选的 K 个初始中心恰好很近(甚至相同),算法可能收敛到一个很差的结果。一种改进方法是选初始中心时让它们尽量分散。

坑 3:数据没有归一化。 学习时长的范围是 040,考试分数的范围是 0100。距离计算时,考试分数的差值(比如 50 分)会主导距离,学习时长的差值(比如 10 小时)变得不重要。实际使用时,通常先把每个特征缩放到同一个范围(比如 0~1),再做聚类。这个例子还保留原始数值,是为了让坐标和表格容易对上。

坑 4:空组。 如果某个组在分配后没有任何点,update_centers 里的 count[c] == 0 会跳过更新。这个组的中心就停在原地不动。程序不会崩,但这意味着 K 个组有一个是废的。实际应用中,可以检测到空组并重新初始化它的中心。

坑 5:忘了 <float.h> FLT_MAX 定义在 float.h 里,不包含这个头文件会编译报错。sqrtfmath.h 里,编译时要加 -lm


33.11 自己试试看#

Q1:换一组初始中心。 把初始中心改成 {2, 30}{5, 40}(都在左边那一组),运行程序,观察需要几轮才能收敛,最终分组是否正确。

提示:直接改 centers 数组的初始值,不改其他代码。初始中心都在同一侧时,第一轮分配可能严重偏向一边,但中心会逐步向数据密集区移动。观察最终是否仍然分成了左右两组。

Q2:加更多数据。points 数组里再加 4 个点,分布在两组之间(比如 (10, 55), (11, 60), (9, 50), (12, 65)),观察中间地带的点被分到哪一组。

提示:在 points 数组末尾追加新元素,同时把 N 改成对应的新数量。中间地带的点距离两个中心差不多远,可能被分到左边也可能被分到右边——这取决于它离哪个中心更近一点点。

Q3:改成 K=3。K 改成 3,再加一组初始中心,观察分组结果。数据是否真的有 3 组?

提示:K 改成 3,centers 数组加一组初始值(比如 {8, 50} 放在两组之间)。数据只有两个自然聚集区时,K=3 会强行拆出第三个组——可能把某个组对半拆开,或者分出一个很小的碎片组。

Q4:打印每轮的中心坐标。main 的循环里加一行打印,观察中心是怎么一步步移动的。

提示:在 for 循环末尾、centers_changed 判断之前,加一段 for (int c = 0; c < K; c++) printf("Center%d: (%.1f, %.1f)\n", c, centers[c][0], centers[c][1]);。观察中心从初始位置一步步收敛到最终位置。

Q5:算组内误差。 写一个函数,计算每个组内所有点到该组中心的距离之和。这个值越小,说明分组越紧凑。

提示:对第 c 组,遍历所有点,如果 points[i].cluster == c,就累加 distance(points[i].x, centers[c])。跑两次不同初始中心的聚类,算出的组内距离总和较小的那次分组质量更好。


下一章的问题#

KMeans 能把数据自动分成几组:给它一组没有标签的点,它会按照距离的远近把它们归到不同的组里。中心点是组的代表,迭代让中心一步步移到当前分组的平均位置。

但到目前为止,KNN 返回的是近邻记录,KMeans 返回的是组别。它们都不是直接算出一个连续数值的办法。如果想预测一个具体数值——比如根据学习时长预测考试分数,或者根据房屋面积预测价格——分组就不够用了。问题不再是"属于哪一组",而是"分数大概是 72 分"或者"价格大概是 150 万"。

预测连续数值,需要另一种办法。