第33章 KMeans
33.1 问题从哪来#
上一章的 KNN 能回答"这个点和哪些点最像"。给一个新点,程序遍历数据库,找出距离最近的 K 条记录。
但 KNN 需要一个查询点:先给出 Q,程序再找 Q 附近的记录。
如果没有查询点,只是手里有一批数据,问题就变了。数据库里有一百个学生,每个学生有两个特征数字,比如学习时长和考试分数。程序能不能只看这些数字,自己把学生分成几组?
这就是聚类(clustering)要解决的问题:不需要标签,让程序自动分组。同一组里的点互相靠近,不同组的点互相远离。
注意一下,这里的 K 和上一章 KNN 里的 K 含义不同。KNN 的 K 是“找几条最近记录”,KMeans 的 K 是“分成几组”。
33.2 先看一个例子#
假设数据库里有 8 个学生,每个学生有两个特征:学习时长(小时/周)和考试分数(0~100)。
| 学生 | 学习时长 | 考试分数 |
|---|---|---|
| A | 2 | 30 |
| B | 3 | 35 |
| C | 4 | 28 |
| D | 5 | 40 |
| E | 15 | 80 |
| F | 16 | 85 |
| G | 17 | 78 |
| H | 18 | 90 |
把这 8 个点画在坐标系里,肉眼就能看出来:左边有 4 个点聚在一起(学习少、分数低),右边有 4 个点聚在一起(学习多、分数高)。
程序不能像人一样直接看出两团点。它要做的是:用一套明确的计算步骤,把这 8 个点分成两组,使得每组内部的点尽量靠近,两组之间尽量远离。
33.3 KMeans 的思路#
KMeans 的思路可以用一句话说清楚:选 K 个中心,把每个点分给最近的中心,再把中心移到组内所有点的平均位置,重复。
具体来说,分四步:
- 选初始中心。 从数据里挑 K 个点作为初始中心。K 是你定的,比如 K=2。实际程序里可以随机挑;为了让这次实验每次运行都一样,下面先固定选点 A 和点 E。
- 分配。 把每个点分给离它最近的那个中心。
- 更新。 对每个组,算组内所有点的平均值,把这个平均值当作新的中心。
- 重复。 回到第 2 步,直到中心不再变化(或者变化很小)。
每个点分给最近的中心之后,数据被分成两组:
然后把中心移到组内所有点的平均位置:
再分配、再更新。几轮之后,中心不再移动,分组稳定下来:
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_MAX 是 float.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_clusters 和 update_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 里,不包含这个头文件会编译报错。sqrtf 在 math.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 万"。
预测连续数值,需要另一种办法。