K-means 聚类算法入门:基于 Iris 数据集的 C 语言实现
K-means 聚类算法入门:基于 Iris 数据集的 C 语言实现
站内搜索
直接问 AI

K-means 聚类算法入门:基于 Iris 数据集的 C 语言实现

K-means 是机器学习里最经典的无监督学习算法之一。它不依赖人工标签,而是根据样本之间的距离关系,自动把数据划分成若干个簇。

这篇文章直接结合两个实际文件来讲:

  • Iris.csv:经典的鸢尾花数据集
  • Iris_sort_K_mean.c:一个可以直接编译运行的 C 语言 K-means 实现

这次的重点不只是“让程序跑出来”,而是把下面几件事讲清楚:

  1. K-means 为什么这样设计
  2. 每个函数在代码里具体负责什么
  3. 为什么标准化、初始化和多次重启会直接影响结果
  4. 如何把最终聚类结果解释成可读的结论

一、什么是 K-means

K-means 的目标是:

给定一个数据集和簇数 K,把所有样本分成 K 类,让同一个簇里的样本尽量接近。

它会不断重复两件事:

  1. 把每个样本分配到最近的聚类中心
  2. 根据新的分组结果,重新计算聚类中心

如果你把它想成一个动态修正的过程,其实会更容易理解:

先猜几个中心,再让样本去找最近中心;样本分完以后,中心再根据样本的平均位置重新移动。

二、为什么 Iris 数据集适合拿来学聚类

Iris.csv 一共 150 条样本。每条样本包含 4 个数值特征:

  • 花萼长度 SepalLengthCm
  • 花萼宽度 SepalWidthCm
  • 花瓣长度 PetalLengthCm
  • 花瓣宽度 PetalWidthCm

同时它还带有真实标签:

  • Iris-setosa
  • Iris-versicolor
  • Iris-virginica

这里要强调一个关键点:K-means 聚类时不会使用这些标签。它只看数值特征和样本之间的距离。标签保留下来只是为了在聚类完成后做结果解释。

也就是说,这个例子特别适合学习两件事:

  • 算法怎么在没有标签的前提下分组
  • 算法分出来的簇和真实分类之间差距有多大

三、这份 C 程序整体做了什么

Iris_sort_K_mean.c 不是只写了“一个 for 循环 + 一个距离函数”的极简版本,而是把 K-means 的完整执行路径拆成了多个可读函数。整体流程可以概括成:

  1. 读取 CSV,构造样本数组
  2. 对所有特征做标准化
  3. 用 K-means++ 初始化聚类中心
  4. 反复执行“样本分配 / 中心更新”
  5. 多次随机重启,选择 SSE 最小的一轮
  6. 输出簇分布、标签分布和样本所属簇
Iris K-means 算法流程图
这张流程图对应的就是 Iris_sort_K_mean.c 的执行顺序:先加载数据,再标准化、初始化、迭代、计算 SSE,最后保留最好的聚类结果。
这张图最值得看的点: 它不是“跑一次 K-means 就结束”,而是把“单次聚类”和“多次重启选最优”拆开了。对于带随机初始化的算法,这个结构很重要。

四、样本在程序里是怎么表示的

程序里每条样本都存到一个 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 行会报错退出,不再静默跳过或截断。

你可以把这个函数理解成一个“文本转结构体”的过程:

  1. 文件里原本是一行逗号分隔文本
  2. 读进来以后变成一个 Sample
  3. 所有样本最终放到数组 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

这个函数内部其实做了三件事:

  1. 先求每一列特征的均值 mean
  2. 再求每一列特征的标准差 std
  3. 最后把每条样本的原始值转换成标准化值

方差分母为 n,即 ddof=0。非恒定列变为均值接近 0、标准差接近 1;恒定列使用分母 1,变为 0。数值不被限制在 [-1,1] 内,而是每列平方差权重变成原来的 1/std²。

标准化是距离模型的选择,不是正确性的保证。 Iris 四列都以厘米计量,逐列缩放可能增加弱判别特征的权重。后文实际对照中,不缩放反而更接近物种标签;这也不是所有数据都应保留原尺度的证据。

七、第三步:为什么初始化不能随便乱选

如果 K-means 一开始只是随机选 3 个样本当中心,算法当然也能跑,但结果通常不稳定。原因是:

  • 初始中心可能靠得太近
  • 某些区域一开始就没有中心覆盖
  • 算法容易较早收敛到局部较差解

为了减轻这个问题,程序在 init_centroids() 中使用了 K-means++ 思想:

  1. 先随机选一个中心
  2. 后面的中心优先从“离当前已有中心更远”的样本中产生

这样一来,初始中心往往更分散,也更接近“每个簇先占一个位置”的直觉。

程序计算到已有中心的最小平方距离 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() 做的就是这件事:

  1. 统计每个簇有哪些样本
  2. 把每个簇内所有样本的 4 个特征分别求和
  3. 再除以该簇样本数,得到新的中心坐标

这就是名字里 “means” 的含义:每个簇由它内部样本的均值中心来代表。

空簇保留旧中心,不重新播种。因此 K=3 不保证三个簇都有成员。三个相同坐标会因平局规则全部分到第 0 簇,SSE=0,另两个簇为空,并不代表三个有效群体。

十、第六步:算法什么时候停止

程序设置了两层停止条件:

  1. 如果本轮没有样本更换簇,说明已经收敛,可以停止
  2. 如果迭代次数达到 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 或数据版本也改变了比较条件。

真正要建立的直觉: K-means 不是一个“给我一次机会就能永远正确”的算法。它本来就带随机性,所以多次尝试是正常策略,不是多余操作。

十二、程序最终会输出什么

Iris_sort_K_mean.c 运行结束后,主要输出三类信息:

  1. 最佳结果的迭代次数和 SSE
  2. 标准化空间中的聚类中心
  3. 每个簇的样本数量与真实标签分布

以下摘录当前下载版在 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 的簇编号着色。

Iris K-means 聚类二维可视化
历史运行的花瓣二维投影,用于观察形状。颜色编号不要求与本次 seed=42 一致;本次结果以 reference 中的分配记录为准,二维图不是四维决策边界。

这张图能帮你很快看出三件事:

  1. 一团明显分离的小簇对应的就是 setosa
  2. 另外两团虽然大体能分开,但边界有重叠
  3. K-means 更擅长处理“球状且分离度较高”的簇,对重叠区域没有神奇修正能力

也就是说,K-means 学到的是“距离结构”,不是“物种定义本身”。这也是为什么聚类和真实分类结果不一定完全一致。

十四、最值得动手改的几个参数

如果你下载这份程序,最值得自己动手改的参数有三个:

  • K:簇数,当前设为 3
  • RESTARTS:重启次数,越大越稳定但耗时更高
  • MAX_ITER:单次运行的最大迭代次数

你可以试几组实验:

  1. 把 K 改成 2 或 4,看聚类结构怎么变化
  2. 把 RESTARTS 改小,观察 SSE 是否更容易波动
  3. 固定种子比较 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 代码,而是下面这些思路:

  1. K-means 的本质是“最近中心分配 + 均值中心更新”
  2. 距离型算法高度依赖数据预处理,标准化非常关键
  3. K-means++ 会显著改善初始化质量
  4. 带随机性的聚类算法往往要多次重启再选最优
  5. 最终结果必须结合输出分布和可视化一起解释

十八、实验审计表

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 最小的结果

对初学者来说,这个例子最大的价值在于:它既保留了算法的核心逻辑,又足够接近真实程序。你不只能理解“概念”,还能看到“概念是怎么变成代码和结果的”。

如果你刚开始学无监督学习,我很建议你亲手编译一次、改几个参数、再对照这张流程图和散点图一起看。这样比只读一遍算法定义更容易建立真正的直觉。

发表回复

向下探索