聚类是机器学习里最基础也最常用的无监督学习任务之一,K-Means又是很多人上手时接触的第一个聚类算法。这次我决定不调库,直接用C++从零实现一遍K-Means,过程比想象中更“涨功力”。数据怎么存、质心怎么初始化、迭代怎么收敛、空簇怎么处理,每个环节都会逼着你去面对真实工程问题,而不是停留在“背公式”的阶段。这篇文章把《机器学习聚类之K-Means算法C++实现》这个项目的思路、代码和调试经过完整写下来,适合刚掌握C++语法、正准备进入机器学习方向的读者,也适合想系统复习一遍聚类实现细节的人。全文按“原理梳理→代码设计→核心实现→优化改造→问题排查”的顺序展开,你能直接拿走核心代码,也能避开我踩过的几个坑。
1. 整体设计与思路拆解
1.1 K-Means到底在解决什么问题
一句话理解K-Means:给定N个样本,每个样本有D维特征,把它分成K个集合,让每个集合内部的样本尽可能靠近自己的质心。数学上的目标函数是所有样本到所属簇质心的平方距离之和,也就是SSE(Sum of Squared Errors)。写成公式就是:
argmin ∑_{j=1}^{K} ∑_{x∈S_j} ||x - μ_j||²其中μ_j是第j个簇的质心,S_j是第j个簇的样本集合。值得注意的是,这个优化问题本身是NP难的,直接求全局最优很不现实。所以经典K-Means使用的是迭代式启发方法,每一步都在降低SSE,最后收敛到一个局部最优解。局部最优不一定是全局最优,这一点务必记住,后面很多坑都从这里来。
把算法想象成一次贴纸分组游戏会更好理解:先找K个“组长”,每个样本按照“离哪个组长最近就跟谁”的原则归队,归完队之后重新计算每个组所有成员的重心,把重心当作新的组长,然后重复这个过程。每次重复都包含两个核心步骤:分配(assignment)和更新(update)。分配是给每个样本找最近的质心,更新是用簇内样本的平均坐标作为新质心。代码里的主循环,本质就是这两个动作反复执行。
1.2 为什么偏要用C++写而不是直接调库
现在Python生态里一行KMeans(n_clusters=K).fit(data)就出结果,用C++从零写似乎有点自找麻烦。但我的实际经验是:调库会绕过实现细节,而C++强迫你管理内存、容器和类型,把算法的每一个环节都暴露出来。你写完一遍之后,再回去看Python库的源码或者参数文档,感觉完全不一样,很多以前靠猜的概念,比如n_init、max_iter、tol,一下子都变成了有实感的工程选择。
从性能角度说,K-Means在很多实时场景和嵌入式设备上仍有需求。C++实现可以精确控制数据结构的内存布局、访问顺序和并行粒度,这是解释型语言很难做到的。做一个基础骨架之后,想改成Mini-Batch K-Means、K-Medoids或者给分配阶段加上多线程并行,都只要替换其中几行核心逻辑,开发成本非常低。
1.3 工程模块怎么划分
我没有把全部代码塞进一个文件里,而是分了三个文件,这个习惯帮我在调试时省了不少时间:
kmeans_cpp/ ├── main.cpp ├── kmeans.h └── kmeans.cpp头文件kmeans.h放类声明、数据结构和接口定义;kmeans.cpp放算法实现;main.cpp只负责制造测试数据、调用聚类类、打印结果。这样划分的目的不是凑目录结构,而是让KMeans类可以被独立测试。比如我自己写过一个很小的单元测试,直接构造一组已知答案的样本,调fit之后检查每一个样本的簇标签是否符合预期。如果你把算法代码和测试数据混在一起,这类验证做起来会非常难受。
2. 核心细节解析与实操要点
2.1 样本、质心、簇该怎么表示
数据结构的选择直接决定代码的清晰度。很多教材喜欢用二维数组X[N][D]表示样本,但在C++里我更推荐用struct Point加std::vector的组合:
struct Point { std::vector<double> dims; int id = -1; Point() = default; explicit Point(const std::vector<double>& data, int pid = -1) : dims(data), id(pid) {} };推荐的理由有三点:第一,后续很可能要带上样本编号、标签或者其他元信息,struct天然方便扩展;第二,std::vector<double>能支持不同维度,算法代码不用写死维度数;第三,底层数组在内存里是连续分布的,访问缓存局部性比链表一类结构好得多。实际运行大样本时,这个选择带来的性能差异非常明显。
质心本身也可以直接复用Point类型,只不过它不一定要对应某个真实样本。簇归属信息我建议用一个和样本一一对应的std::vector<int> assignments_来存,值域是0到K-1。这样更新质心时,只要遍历一遍样本,累加对应簇的坐标即可,不需要额外维护每个簇的成员列表。很多新手会把簇成员集合单独存成vector<vector<int>>,不仅浪费内存,更新时还容易踩迭代器失效的坑。我建议尽量用轻量标签数组,必要的时候再按需构建临时簇成员表。
2.2 欧氏距离与平方距离技巧
K-Means最常用的距离度量是欧氏距离。教科书上一般写sqrt(sum((x_i - y_i)²)),但在分配那一步,我们只需要比较每个样本到不同质心的距离哪个更小,并不需要知道绝对距离到底是多少。既然sqrt是单调函数,比较平方距离和比较开方后的距离结果完全一致。所以在实现里我直接用平方距离:
inline double dist2(const Point& a, const Point& b) { double sum = 0.0; for (size_t i = 0; i < a.dims.size(); ++i) { double diff = a.dims[i] - b.dims[i]; sum += diff * diff; } return sum; }一次分配阶段要算N*K次距离,省掉开方能省大量浮点运算。这个优化几乎是零成本,我建议直接写进代码里。不过要注意数据异常值:如果某个特征取值特别大,平方之后可能累积到非常大的数,虽然double通常扛得住,但为了数值稳定性,最好先做特征预处理,下一节会专门说。
2.3 初始质心从随机到K-Means++
初始质心的选择直接决定K-Means是快速收敛还是陷进局部最优。最简单的方式是从样本里随机挑K个点当质心,代码好写,但稳定性很差。我做过一个简单实验,同一份数据、同一个K值,只改变随机种子,最终SSE能相差百分之三四十。
一个工程上限很高的改进是K-Means++初始化。它的思路很直白:第一个质心随机选;选后面的质心时,每个样本被选中的概率与它到已有最近质心的距离平方成正比。这样初始质心会尽量分散开,不容易出现几个质心挤在同一片区域的情况。实现起来也不复杂:
std::vector<Point> kmeansPlusPlusInit(const std::vector<Point>& data, int k, unsigned seed) { std::mt19937 gen(seed); std::uniform_int_distribution<int> pickSample(0, (int)data.size() - 1); std::vector<Point> centers; centers.push_back(data[pickSample(gen)]); std::vector<double> minDist2(data.size(), 1e20); while ((int)centers.size() < k) { double sum = 0.0; for (size_t i = 0; i < data.size(); ++i) { double d2 = dist2(data[i], centers.back()); if (d2 < minDist2[i]) minDist2[i] = d2; sum += minDist2[i]; } double r = std::uniform_real_distribution<double>(0.0, sum)(gen); double acc = 0.0; size_t picked = centers.size(); for (size_t i = 0; i < data.size(); ++i) { acc += minDist2[i]; if (acc >= r) { picked = i; break; } } centers.push_back(data[picked]); } return centers; }我通常建议默认就用K-Means++初始化,因为它只多花了一次预处理遍历的时间,但换来的是明显更稳定的聚类质量。对初学者来说,也可以先把随机初始化跑通,再用K-Means++替换对比效果。下表是我常用初始化策略的直觉对比:
| 初始化方法 | 实现复杂度 | 稳定性 | 适用场景 |
|---|---|---|---|
| 完全随机 | 低 | 差 | 教学演示、基线测试 |
| K-Means++ | 中 | 好 | 通用场景,推荐默认 |
| 最远点采样 | 中 | 中等 | 簇形状比较规则时 |
| 随机划分后取重心 | 较高 | 中等 | 极少用,仅特殊需求 |
2.4 特征预处理不能省
K-Means基于距离,对特征尺度极其敏感。一个特征取值0到1,另一个特征取值0到10000,后者在欧氏距离计算里几乎占据绝对权重,聚类结果等于只看了一个维度。所以在正式跑算法之前,我一般会做z-score标准化:每个特征减去均值,再除以标准差。离群点严重时,还要考虑是否剔除或者改用更稳健的聚类方法。很多人发现SSE偏高、结果不合理,最后查了半天,源头就是没有做数据预处理。这个问题特别隐蔽,因为代码逻辑一点没错,错在输入数据的“口径”。
3. 实操过程与核心环节实现
3.1 基础工具代码先打底
动手写KMeans类之前,我先写了两个基础工具:距离函数和初始化函数。距离函数就是上面的dist2。初始化函数这里我默认使用K-Means++,同时留一个随机初始化接口,方便对比。一个小习惯是:凡是涉及随机数的地方,都要允许传入种子。这样你复现实验结果时,不会被“随机”坑到怀疑人生。下面是我在类里预留的接口结构:
class KMeans { public: KMeans(int k, int max_iters = 100, double eps = 1e-6); void fit(const std::vector<Point>& data); std::vector<int> getAssignments() const { return assignments_; } std::vector<Point> getCentroids() const { return centroids_; } double computeSSE(const std::vector<Point>& data) const; private: int k_; int max_iters_; double eps_; std::vector<Point> centroids_; std::vector<int> assignments_; };computeSSE看起来不起眼,但它是我调试时的主力工具。每次迭代结束打印一次SSE,整个算法的行为曲线一目了然:正常情况SSE应该不断下降并趋于稳定,如果某个版本出现了上升,几乎可以断定质心更新逻辑出了问题。
3.2 迭代主循环:分配与更新
核心代码的逻辑其实很短。我给每个样本找最近的质心,记录归属;然后按簇累加样本坐标,除以簇大小得到新质心;最后检查标签有没有变化,没有变化就认为收敛:
void KMeans::fit(const std::vector<Point>& data) { int n = (int)data.size(); if (n == 0 || k_ <= 0 || k_ > n) return; assignments_.assign(n, -1); centroids_ = kmeansPlusPlusInit(data, k_, 20240517); for (int iter = 0; iter < max_iters_; ++iter) { bool changed = false; // 分配阶段 for (int i = 0; i < n; ++i) { double bestDist2 = std::numeric_limits<double>::max(); int bestIdx = 0; for (int j = 0; j < k_; ++j) { double d2 = dist2(data[i], centroids_[j]); if (d2 < bestDist2) { bestDist2 = d2; bestIdx = j; } } if (assignments_[i] != bestIdx) { assignments_[i] = bestIdx; changed = true; } } if (!changed) break; // 更新阶段:累加每个簇的坐标和样本数 std::vector<int> counts(k_, 0); std::vector<Point> newCentroids(k_); for (int j = 0; j < k_; ++j) { newCentroids[j].dims.assign(data[0].dims.size(), 0.0); } for (int i = 0; i < n; ++i) { int cid = assignments_[i]; counts[cid]++; for (size_t d = 0; d < data[i].dims.size(); ++d) { newCentroids[cid].dims[d] += data[i].dims[d]; } } for (int j = 0; j < k_; ++j) { if (counts[j] > 0) { for (size_t d = 0; d < newCentroids[j].dims.size(); ++d) { newCentroids[j].dims[d] /= counts[j]; } centroids_[j] = newCentroids[j]; } // 如果counts[j] == 0,说明出现空簇,第4章专门讲处理方式 } } }这段代码里最容易出错的地方有两个。第一,初始化newCentroids时,必须给每个质心的dims开好长度,否则后面累加会越界。第二,累加时一定要先确认assignments_[i]是有效的簇编号,否则会出现把样本累加进错误簇的问题。我在初版代码里就犯过“先覆盖旧质心,再拿新质心去累加其他簇”的错误,结果质心全漂到了一个点,SSE不降反升。后面我会专门讲这一类调试经验。
3.3 拿一个小数据集手把手验算
写完代码不能直接上大数据集,我先构造了一个能用肉眼确认结果的二维样本集合:
(1, 1) (1, 2) (2, 2) (5, 5) (6, 5) (6, 6)设K=2,假设初始质心随机选到了(1, 1)和(6, 6)。第一次分配阶段的结果是:前三个点(1,1)、(1,2)、(2,2)离(1,1)更近,归簇0;后三个点(5,5)、(6,5)、(6,6)离(6,6)更近,归簇1。更新质心的时候,簇0取横坐标均值(1+1+2)/3 = 4/3 ≈ 1.33,纵坐标均值(1+2+2)/3 = 5/3 ≈ 1.67;簇1的质心变成(17/3 ≈ 5.67, 16/3 ≈ 5.33)。
第二次迭代里,每个点到新质心的距离和上一次远近关系完全一致,所以分配不变,算法终止。从这个小例子能直观看到,K-Means迭代本质是在“换质心→重新分组→再换质心”之间循环,直到分组不再变化。这个例子值得亲手跑一遍,因为如果你实现的代码输出结果和上面不同,那基本可以确定某个环节有问题,直接复盘就好。
3.4 结果输出与简单可视化
算法本身不负责画图,但输出格式会影响你验证的效率。我习惯把结果打印成样本id + 坐标 + 簇编号的格式,同时输出最终SSE:
id=0 (1.00, 1.00) -> cluster 0 id=1 (1.00, 2.00) -> cluster 0 id=2 (2.00, 2.00) -> cluster 0 id=3 (5.00, 5.00) -> cluster 1 id=4 (6.00, 5.00) -> cluster 1 id=5 (6.00, 6.00) -> cluster 1 SSE = 2.66667如果你希望用外部工具画图,最简单的做法是让程序额外输出一个CSV文件,里面保持同样的字段。二维数据还可以用散点图直接观察簇是否合理。不要小看这一步,很多时候你算法“看起来没问题”,但画成图之后立刻能发现某个簇被吃掉、两个质心窝在同一个区域里等肉眼可见的问题。
4. 优化与稳健性处理
4.1 随机初始化导致局部最优:多次运行取最优
经典K-Means对初始质心敏感,单次运行不能代表真实效果。我常用的稳健方案非常简单:运行R次,每次用不同随机种子,保留SSE最小的一次结果。K-Means本身迭代很快,R=10到20带来的额外开销完全可接受,尤其是中小规模样本。实际代码里,只需要在外面包一层循环:
void fitBest(const std::vector<Point>& data, int runs, unsigned baseSeed) { double bestSSE = std::numeric_limits<double>::max(); for (int r = 0; r < runs; ++r) { KMeans tmp(k_, max_iters_, eps_); tmp.fitWithSeed(data, baseSeed + r); double sse = tmp.computeSSE(data); if (sse < bestSSE) { bestSSE = sse; centroids_ = tmp.getCentroids(); assignments_ = tmp.getAssignments(); } } }这种做法其实和很多成熟机器学习库里n_init参数做的事是一样的。你从零实现的时候,可能不需要做得太复杂,但至少要意识到“只看一次运行结果”会误导你判断算法效果。把多次运行取最优做成默认行为之后,聚类稳定性会提升很多。
4.2 空簇怎么处理
迭代过程中某簇没有样本,这个问题迟早会遇到。常见原因包括:K值设得比实际簇数大、初始质心挤在一起、数据分布非常不均衡。处理空簇有几种常见策略:
- 直接删除空簇,让K减1。代码简单,但破坏了“事先指定K”的语义,实际使用时要谨慎。
- 把空簇的质心重新随机选一个样本。最通用,且实现简单。
- 把空簇质心放到离所有样本最远的位置。目的是继续探索空旷区域,但受离群点影响较大。
- 保留旧质心不动,并打一条警告日志。最保守,适合调试阶段。
我自己的默认做法是:先保留旧质心不更新,同时打印警告。这是因为我更希望算法稳定收敛,而不是在迭代中途突然改变质心导致SSE震荡。等排查清楚空簇原因后,再决定是否换成随机重选策略。实际调试时,空簇大部分情况是K设大了,这时候先调小K比改代码更有效。
4.3 收敛判断的三种写法
收敛判断方式不只一种,我见过三种常见实现,各有取舍。
第一种是判断分配标签是否变化,代码里changed标志就是这个作用。它直观且开销小,但有个问题:质心可能还在极缓慢地移动,只是没有样本因此切换簇,算法就提前结束了。这样的结果通常足够好,因为样本归属已经稳定。
第二种是判断质心移动量平方和是否小于阈值eps。这种方式更精细,可以保证质心几乎不再变化,但阈值需要现场调。eps设太小会增加迭代次数,设太大又会让算法过早停止。
第三种是监控SSE变化百分比,比如当|SSE_new - SSE_old| / SSE_old < 0.001时停止。这个指标更贴近“聚类质量”,但每次都要算一遍SSE,代价稍高。
我在生产代码里用的是主判断“分配标签是否变化”,同时用max_iters_兜底。如果再严谨一点,可以同时检查一下质心位移,二者满足其一就退出。对大多数场景来说,分配标签不变已经足够稳定,不用把收敛条件搞得太复杂。
4.4 性能调优的几个方向
首先是去掉根号计算,这个前面已经做到。其次是提前终止,分配阶段一旦发现没有任何样本切换簇,就可以直接跳出循环。第三个方向是并行化分配阶段:每个样本找最近质心的过程相互独立,完全可以用#pragma omp parallel for或者std::thread并行化。我在一个接近百万样本的实验里,四线程并行分配阶段能把单轮迭代时间压缩一半以上,效果很直接。
如果样本维度很高、K也很大,可以考虑用空间索引结构,比如KD树来加速最近邻查找。但说实话,一般中小规模数据完全不需要。更实用的扩展是Mini-Batch K-Means思想:每轮随机抽一小批样本,只根据这批样本更新质心。它牺牲了一点最终精度,换来了更大的数据吞吐量,适合处理超大样本场景。只要你基础版本能写明白,改成Mini-Batch其实只是把“全量样本遍历”换成“小批次随机采样遍历”,逻辑骨架完全一样。
4.5 特征标准化实现补充
前面提到过预处理,这里给一个直接能用的标准化函数:
void standardize(std::vector<Point>& data) { if (data.empty()) return; size_t dim = data[0].dims.size(); for (size_t j = 0; j < dim; ++j) { double mean = 0.0; for (const auto& p : data) mean += p.dims[j]; mean /= (double)data.size(); double var = 0.0; for (const auto& p : data) { double diff = p.dims[j] - mean; var += diff * diff; } var /= (double)data.size(); double stddev = std::sqrt(var); for (auto& p : data) { p.dims[j] = (p.dims[j] - mean) / (stddev + 1e-12); } } }最后除了加1e-12之外,我还会检查一下标准差是否真的接近0。如果某个特征是常数,那么它无论如何都不会影响距离比较,直接让该维变成0也可以。标准化这个环节看似和聚类算法无关,但实际项目里没有它,K-Means经常会出现一些非常反直觉的结果。
5. 常见问题与排查技巧实录
5.1 为什么每次运行结果都不一样
这是初学者问我最多的问题。原因只有一个:K-Means的随机初始化。同一份数据,不同初始质心可能收敛到不同局部最优解,所以每次结果不同是正常现象,不是代码Bug。
要处理它,有几个办法。如果你需要复现实验,固定随机种子是最简单的。但固定种子只是让结果可复现,并没有提升结果质量。真正提升稳定性要靠K-Means++初始化加上多次运行取最优。我自己的经验是:先固定种子做单元测试确认代码正确,再用多次运行看真实分布。不要一上来就追求“每次结果完全一致”,这在K-Means里本来就不现实。
5.2 SSE降不下来怎么办
SSE偏高时,很多人第一反应是改代码,但我的排查顺序完全不同。
第一,检查K值是否选小了。如果数据真实有三个簇,你硬设K=2,SSE当然降不下来。可以跑一组不同K的对比,画出SSE随K变化的曲线,找“手肘点”。这个概念说起来玄,实际操作其实就是从K=1到K=8各跑一遍,看SSE下降幅度从哪开始变缓。
第二,检查特征标准化。特征尺度相差悬殊时,欧氏距离会被大数值特征主导,K-Means等于在错误的度量空间里聚类。这个原因特别容易忽略,因为算法输出看起来“有结果”,但怎么解释都不对。
第三,检查离群点。质心是簇内样本的均值,一个极端离群点就能把质心拽走,让更多正常样本被错误分配。如果你数据里能看到明显的偏远离群点,可以先考虑剔除,或者换用K-Medoids这类对离群点更稳健的方法。
第四,也是最容易被忽视的,检查数据形状。K-Means假设簇是凸形且大小相近。如果数据是月牙形、环形或者簇密度差异很大,K-Means效果天然不好。这种问题不是调参能解决的,建议换DBSCAN等密度聚类算法。
5.3 卡死与不收敛
有一次我遇到算法卡到最大迭代次数都没有满足收敛条件,打印中间结果后发现一个很隐蔽的问题:数据里有大量重复样本点,导致质心在两个位置之间不断来回震荡,分配标签也跟着抖动,形成了“循环震荡”。这种情况下,错误的收敛条件永远不会满足。
解决办法是先对重复样本做去重或合并权重,然后适当放宽收敛阈值。另外还有一个常见问题是,标准化之后某个维度接近0,但距离函数里的小数误差积累起来,让质心每次只移动1e-12那么一点,永远到不了阈值。这种情况我会在收敛判断里加入“质心位移小于eps”的判断,或者直接依赖分配标签变化,因为标签都不变了,继续迭代也没有意义。
5.4 调试要点与打印技术
我给自己的代码调试定了三条铁律。第一,玩具数据集先行。用三个或者六个点,结果能手动算出来,逻辑是否出错一眼就看出来了。第二,每次迭代打印SSE。SSE曲线必须是单调下降的,如果某一步上升,问题几乎一定出在质心更新阶段。第三,单步调试质心更新。我犯错最多的地方,就是累加新质心时把旧质心覆盖得太早,导致后一个簇的累加结果里混进了前一个簇的新坐标。
还有一个小技巧:写好分配函数之后,单独写个测试函数,给定一组已知质心和样本,检查每个样本的标签是否符合手工计算结果。这类测试成本不高,但能让你在集成进完整算法前就把最底层的逻辑验证掉。遇到奇怪的聚类结果时,我通常第一步就是重新跑这个最小测试,确认底层没有回归。
说到最后,我真正想强调的是“分步验证”的价值。K-Means的代码单看很短,但每一个小环节都能藏坑。我当初就是在一次重构时,为了省一点存储,把newCentroids的累加改成了原地更新,结果质心更新顺序出现问题,导致整个聚类结果全乱。后来我养成了一个习惯:任何改动,都先用上面那个六样本数据集跑一遍,再上真实数据。这个小习惯帮我拦下了至少两位数的低级Bug。
如果你也想自己从零写K-Means,我的建议是先别急着加K-Means++这些“进阶配置”,老老实实用随机初始化把最基本版本写对,把六样本样例跑通,再逐步加上优化模块。每一步出现问题时,你都知道该去哪一环排查。这整个项目做完之后,我最大的收获不是代码本身,而是想明白了一个朴素道理:所谓懂一个算法,不是能默写公式,而是能在代码里准确表达“数据长什么样、簇怎么归、质心怎么移”。能有这样一次完整的动手实践,比看十篇教程都管用。