K-means 是机器学习里最经典的无监督学习算法之一。它不依赖人工标签,而是根据样本之间的距离关系,自动把数据划分成若干个簇。
这篇文章直接结合两个实际文件来讲:
Iris.csv:经典的鸢尾花数据集Iris_sort_K_mean.c:一个可以直接编译运行的 C 语言 K-means 实现
这次的重点不只是“让程序跑出来”,而是把下面几件事讲清楚:
- K-means 为什么这样设计
- 每个函数在代码里具体负责什么
- 为什么标准化、初始化和多次重启会直接影响结果
- 如何把最终聚类结果解释成可读的结论
一、什么是 K-means
K-means 的目标是:
给定一个数据集和簇数
K,把所有样本分成K类,让同一个簇里的样本尽量接近。
它会不断重复两件事:
- 把每个样本分配到最近的聚类中心
- 根据新的分组结果,重新计算聚类中心
如果你把它想成一个动态修正的过程,其实会更容易理解:
先猜几个中心,再让样本去找最近中心;样本分完以后,中心再根据样本的平均位置重新移动。
二、为什么 Iris 数据集适合拿来学聚类
Iris.csv 一共 150 条样本。每条样本包含 4 个数值特征:
- 花萼长度
SepalLengthCm - 花萼宽度
SepalWidthCm - 花瓣长度
PetalLengthCm - 花瓣宽度
PetalWidthCm
同时它还带有真实标签:
Iris-setosaIris-versicolorIris-virginica
这里要强调一个关键点:K-means 聚类时不会使用这些标签。它只看数值特征和样本之间的距离。标签保留下来只是为了在聚类完成后做结果解释。
也就是说,这个例子特别适合学习两件事:
- 算法怎么在没有标签的前提下分组
- 算法分出来的簇和真实分类之间差距有多大
三、这份 C 程序整体做了什么
Iris_sort_K_mean.c 不是只写了“一个 for 循环 + 一个距离函数”的极简版本,而是把 K-means 的完整执行路径拆成了多个可读函数。整体流程可以概括成:
- 读取 CSV,构造样本数组
- 对所有特征做标准化
- 用 K-means++ 初始化聚类中心
- 反复执行“样本分配 / 中心更新”
- 多次随机重启,选择 SSE 最小的一轮
- 输出簇分布、标签分布和样本所属簇
Iris_sort_K_mean.c 的执行顺序:先加载数据,再标准化、初始化、迭代、计算 SSE,最后保留最好的聚类结果。四、样本在程序里是怎么表示的
程序里每条样本都存到一个 Sample 结构体里:
typedef struct {
int id;
double x[FEATURES];
double x_scaled[FEATURES];
char species[32];
int cluster;
} Sample;
这里每个字段都承担了明确职责:
id:样本编号,对应 CSV 第一列x:原始 4 维特征x_scaled:标准化后的 4 维特征species:真实物种标签cluster:程序最终给出的簇编号
这种设计的好处是:原始数据、预处理数据、真实标签和聚类结果都分开存放,后面读代码时不会把“用于计算的值”和“用于展示的值”混在一起。
五、第一步:读取 CSV 数据
当前下载版在 load_iris() 中检查固定表头,parse_sample() 拆出 6 个无引号字段,用 strtol()、strtod() 验证编号和 4 个有限数值。坏行、重复编号、未知物种或超过 150 行会报错退出,不再静默跳过或截断。
你可以把这个函数理解成一个“文本转结构体”的过程:
- 文件里原本是一行逗号分隔文本
- 读进来以后变成一个
Sample - 所有样本最终放到数组
data[]里
程序入口支持命令行传入数据文件路径:
const char *filename = (argc > 1) ? argv[1] : "Iris.csv";
这意味着你既可以直接运行:
./iris_kmeans Iris.csv
可替换为相同字段顺序和物种命名的 CSV,至少 K 条、最多 150 条记录。这不是通用 CSV 导入器,不支持引号内逗号、多行字段或自动填补缺失值。
六、第二步:标准化怎样改变距离
K-means 的核心是比较距离。如果不同特征的数值尺度差异很大,那么数值更大的特征会在欧氏距离里占更大权重。
举个直观例子:如果某个维度经常变化 5 个单位,而另一个维度通常只变化 0.2 个单位,那么前者会在距离计算里显得“更重要”。这并不一定符合我们的真实意图。
所以程序在聚类前先执行了 standardize_features(),用的是标准分数:
x_scaled = (x - mean) / std
这个函数内部其实做了三件事:
- 先求每一列特征的均值
mean - 再求每一列特征的标准差
std - 最后把每条样本的原始值转换成标准化值
方差分母为 n,即 ddof=0。非恒定列变为均值接近 0、标准差接近 1;恒定列使用分母 1,变为 0。数值不被限制在 [-1,1] 内,而是每列平方差权重变成原来的 1/std²。
七、第三步:为什么初始化不能随便乱选
如果 K-means 一开始只是随机选 3 个样本当中心,算法当然也能跑,但结果通常不稳定。原因是:
- 初始中心可能靠得太近
- 某些区域一开始就没有中心覆盖
- 算法容易较早收敛到局部较差解
为了减轻这个问题,程序在 init_centroids() 中使用了 K-means++ 思想:
- 先随机选一个中心
- 后面的中心优先从“离当前已有中心更远”的样本中产生
这样一来,初始中心往往更分散,也更接近“每个簇先占一个位置”的直觉。
程序计算到已有中心的最小平方距离 D²,按 D²/sum(D²) 抽样,不是按 D 抽样。当前版本用 [0,1) 随机数、严格大于的累计阈值,并跳过零权重点,避免随机数恰好为 0 时重复选中已有中心。底层 rand() 不保证跨 C 运行库相同序列,也不保证全局最优。
八、第四步:样本是怎么被分到各个簇里的
assign_clusters() 做的是 K-means 最核心的一件事:对每个样本,计算它到所有中心的距离,然后把它分配给最近的那个中心。
这里调用的距离函数是:
double distance_sq(const double a[], const double b[])
注意,这里计算的是“欧氏距离的平方”,不是开方之后的真实距离。这样做完全合理,因为:
- 谁更近,本质上比较平方值就够了
- 不开平方可以少做一些无意义的计算
这个函数的返回值还有一个关键作用:它会告诉主循环“本轮是否有样本换簇”。如果有,就说明还没收敛;如果没有,就说明当前结果已经稳定。
九、第五步:为什么还要重新计算中心
样本被重新分组之后,原来的中心位置通常已经不再合理。因为新的簇成员已经确定,中心就应该移动到这些成员的平均位置。
update_centroids() 做的就是这件事:
- 统计每个簇有哪些样本
- 把每个簇内所有样本的 4 个特征分别求和
- 再除以该簇样本数,得到新的中心坐标
这就是名字里 “means” 的含义:每个簇由它内部样本的均值中心来代表。
空簇保留旧中心,不重新播种。因此 K=3 不保证三个簇都有成员。三个相同坐标会因平局规则全部分到第 0 簇,SSE=0,另两个簇为空,并不代表三个有效群体。
十、第六步:算法什么时候停止
程序设置了两层停止条件:
- 如果本轮没有样本更换簇,说明已经收敛,可以停止
- 如果迭代次数达到
MAX_ITER,也必须停止,防止极端情况下死循环
#define MAX_ITER 1000
当前版本输出 converged=yes/no。达到上限不等于收敛:刚更新的中心是当前成员均值,但成员未必仍属于新的最近中心。程序记录实际执行次数;MAX_ITER=1 时报告 1 次和未收敛,不再误报 2 次。
十一、为什么要重复跑 2000 次
即使使用了 K-means++,结果依然会受到随机初始化影响。所以程序没有只跑一次,而是定义了:
#define RESTARTS 2000
程序沿同一伪随机序列做 2000 次重新初始化,每次计算 SSE 并保留最小者。这是示例的搜索预算,不是 Iris 必须达到的次数;增加重启不能证明全局最优。
SSE 的含义是:
所有样本到各自所属中心的平方距离之和。
只有固定样本、K 和缩放,较小 SSE 才表示同一目标下更紧凑。原始厘米空间与标准化空间的 SSE 不能直接排优劣;改变 K 或数据版本也改变了比较条件。
十二、程序最终会输出什么
Iris_sort_K_mean.c 运行结束后,主要输出三类信息:
- 最佳结果的迭代次数和 SSE
- 标准化空间中的聚类中心
- 每个簇的样本数量与真实标签分布
以下摘录当前下载版在 macOS、Apple clang 21.0.0 下执行 ./iris_kmeans Iris.csv 42 standard 的实际输出。完整记录与 150 条样本分配见包内 reference/:
K-means 重启次数: 2000
最佳结果迭代次数: 5
最佳 SSE: 140.96581663074701
Cluster 0: total=53, setosa=0, versicolor=39, virginica=14
Cluster 1: total=47, setosa=0, versicolor=11, virginica=36
Cluster 2: total=50, setosa=50, versicolor=0, virginica=0
这组结果说明:
setosa几乎可以被单独分出来versicolor和virginica之间有一定重叠
这正是 Iris 数据集最经典的现象:setosa 的可分性很强,而后两类在特征空间里更容易交叠。
十三、把结果画出来会更直观
单看数字还不够直观。为了更容易理解聚类边界,这里把样本投影到二维平面,用 PetalLengthCm 作为横轴、PetalWidthCm 作为纵轴,并按照 K-means 的簇编号着色。
这张图能帮你很快看出三件事:
- 一团明显分离的小簇对应的就是
setosa - 另外两团虽然大体能分开,但边界有重叠
- K-means 更擅长处理“球状且分离度较高”的簇,对重叠区域没有神奇修正能力
也就是说,K-means 学到的是“距离结构”,不是“物种定义本身”。这也是为什么聚类和真实分类结果不一定完全一致。
十四、最值得动手改的几个参数
如果你下载这份程序,最值得自己动手改的参数有三个:
K:簇数,当前设为 3RESTARTS:重启次数,越大越稳定但耗时更高MAX_ITER:单次运行的最大迭代次数
你可以试几组实验:
- 把
K改成 2 或 4,看聚类结构怎么变化 - 把
RESTARTS改小,观察 SSE 是否更容易波动 - 固定种子比较
standard与raw,观察标签对应关系,不预设哪种更好
这类实验比背定义更有用,因为你会真正看到“算法参数和数据预处理是怎么改变结果的”。
十五、下载区现在提供什么
现在下载区里已经整理成一组完整的学习资料:
Iris.csv:原始数据集Iris_sort_K_mean.c:整理后的 C 语言实现版本iris-kmeans-flowchart.svg:流程图iris-kmeans-cluster-visual.svg:聚类可视化图- 下载本次验证的完整实验包:源码、原 CSV、检查脚本、依赖版本、完整结果
十六、如何编译和运行
在 macOS 或 Linux 下,可以直接这样编译:
cc -std=c11 -O2 -Wall -Wextra -Werror -pedantic Iris_sort_K_mean.c -lm -o iris_kmeans
./iris_kmeans Iris.csv
如果数据文件和程序就在同一目录,也可以直接运行:
./iris_kmeans
默认读取当前目录的 Iris.csv 并打印时间种子。复现时使用 ./iris_kmeans Iris.csv 42 standard;对照原尺度用 ./iris_kmeans Iris.csv 42 raw。保留输入行序、编译器和 C 运行库信息,相同种子不保证跨平台逐位一致。
十七、这道例子真正值得学到什么
如果把这篇文章当作算法学习材料,那么真正值得带走的不是某一行 C 代码,而是下面这些思路:
- K-means 的本质是“最近中心分配 + 均值中心更新”
- 距离型算法高度依赖数据预处理,标准化非常关键
- K-means++ 会显著改善初始化质量
- 带随机性的聚类算法往往要多次重启再选最优
- 最终结果必须结合输出分布和可视化一起解释
十八、实验审计表
K-means 的输出很容易看起来“已经完成”,但无监督学习更需要记录实验条件。下面这张表用于复查这篇 Iris 聚类实验是否真的解释了结果,而不是只展示一组漂亮的散点图。
| 审计项 | 当前实验做法 | 为什么影响结论 |
|---|---|---|
| 特征缩放 | 对 4 个数值特征做标准化。 | 缩放改变距离权重,不同空间的 SSE 不直接比较;是否保留原尺度取决于任务。 |
| 初始化 | 使用 K-means++ 选择更分散的初始中心。 | 初始中心过近会导致局部较差解,SSE 和簇分布都会变化。 |
| 随机重启 | RESTARTS = 2000,保留 SSE 最小结果。 |
单次运行不能代表算法稳定表现,多次重启能降低偶然性。 |
| 真实标签对照 | 聚类时不使用 species,只在输出阶段统计分布。 |
这能区分“无监督聚类结构”和“真实物种分类”两个问题。 |
| 二维可视化 | 使用花瓣长度和花瓣宽度投影散点图。 | 图示只展示两个维度,不能替代完整四维空间里的 SSE 解释。 |
| 失败模式 | 说明 versicolor 与 virginica 会有重叠。 |
聚类不能神奇恢复标签,重叠区域需要通过分布和样本解释。 |
十九、为什么我的 SSE 和其他 Iris 教程不一样
先核对数据,而不是马上增加重启。本站 CSV 的 SHA-256 是 600ac44f23c2e6e0ae37daac8ceb2baba4df963efa580eb31b3b576b28e34c55,本次保留其字节不变。与 scikit-learn 1.7.2 的 load_iris() 逐项比较,只有以下两条记录不同:
| 记录 Id | 本站 CSV 四维特征 | scikit-learn 1.7.2 |
|---|---|---|
| 35 | 4.9, 3.1, 1.5, 0.1 | 4.9, 3.1, 1.5, 0.2 |
| 38 | 4.9, 3.1, 1.5, 0.1 | 4.9, 3.6, 1.4, 0.1 |
UCI 的 Iris 数据说明也指出这两条历史记录的差异。Id 从 1 开始,不含表头;仅凭“Iris”这个名称不能认定输入相同。校验脚本在临时目录导出 scikit-learn 版本,不覆盖原 CSV。
| 输入与距离空间 | SSE / 标签最佳一对一匹配 | ARI |
|---|---|---|
| 本站 CSV,standard | 140.965817 / 125/150 | 0.620135 |
| scikit-learn,standard | 139.820496 / 125/150 | 0.620135 |
| 本站 CSV,raw | 78.940841 / 134/150 | 0.730238 |
| scikit-learn,raw | 78.851441 / 134/150 | 0.730238 |
四组使用同一 C 程序、seed=42、K=3、2000 次重启。数据版本改变了 SSE,即使这里的标签统计不变。ARI 对簇编号置换不敏感;125/150、134/150 是枚举 3! 种簇与物种映射后的最大对应数,不是独立测试集分类准确率。150 条记录全部参与聚类,标签只用于事后描述,不参与选中心或挑选重启结果。原尺度更接近标签是这次观察,不是通用结论。
逐项复核,而不是相信“运行成功”
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python audit_iris.py --output my-audit
本次环境:Python 3.13.9、NumPy 2.3.5、scikit-learn 1.7.2。脚本编译下载的 C 文件,从输出解析全部成员和完整精度中心,再用 NumPy 重算缩放、中心均值、最近中心、SSE 和标签分布,不以另一份 C 算法替换原程序。将物种标签整体轮换后,同一坐标和种子的分簇保持不变。
reference/download-standard-assignments.csv提供 150 条分配记录;reference/audit.json记录文件哈希、环境、四组结果和检查范围。- 12 种坏 CSV 和 4 种无效种子均应失败。旧程序接收 NaN 后打印
SSE: nan却返回 0,当前版在导入时拒绝。 - 恒定列、重复坐标与空簇不产生 NaN;
MAX_ITER=1报告未收敛;K=2、4 检查分配和中心关系。 - 完整 Iris 运行通过 AddressSanitizer、UndefinedBehaviorSanitizer。缺少兼容编译器或 sanitizer 会报告失败,不计作完成验证。
这些检查覆盖当前小型教学数据和列明的边界,不证明任意数据下的全局最优、跨平台相同随机轨迹或生产健壮性。算法背景见 K-means 文档,数据接口见 load_iris 1.7 文档。
二十、总结
结合 Iris.csv 和 Iris_sort_K_mean.c,我们可以把 K-means 的完整流程看得非常清楚:
- 先读取数据
- 再做标准化
- 用 K-means++ 初始化中心
- 反复执行“分配样本 / 更新中心”直到收敛
- 通过多次重启选出 SSE 最小的结果
对初学者来说,这个例子最大的价值在于:它既保留了算法的核心逻辑,又足够接近真实程序。你不只能理解“概念”,还能看到“概念是怎么变成代码和结果的”。
如果你刚开始学无监督学习,我很建议你亲手编译一次、改几个参数、再对照这张流程图和散点图一起看。这样比只读一遍算法定义更容易建立真正的直觉。