效果先行:两张图,151 条断言

横轴是簇数 K(从 2 到 64),琥珀色是簇内平方误差 SSE(对数轴,越低越好),绿色是量化后图像的 PSNR(越高越好)。红色竖线是拐点,落在 K=4。
官方两个例程 kmeans.cpp 和 em.cpp 加起来只有 153 行 ,没有一条断言,全部靠 imshow 加 waitKey 用眼睛看。我把它们改成无头穷举版本,写了 151 条断言,跑出七个官方代码里看不到的结论。
其中最反直觉的一条:attempts 和 K-means++ 初始化,在数据好收敛时是纯浪费;在长尾数据上,不优化最差会到最优解的 9.53 倍。
而最该记进项目规范的一条是:EM 的簇编号每次训练都不一样。
一、两个例程是同一族算法的两个端点
这两个例程经常被当成两回事,其实数学上是一家。
| 例程 | 模型 | 分配方式 | 输出 |
|---|---|---|---|
kmeans |
K 个中心点,最小化 SSE | 硬分配(非 0 即 1) | labels + centers |
em |
K 个高斯(均值+协方差+权重) | 软分配(后验概率) | labels + probs + means + covs + weights |
K-means 有一个更严格的写法:它是 EM 在高斯协方差各向同性、且方差趋于 0 时的特例。
所以两者的 API 长得完全不一样,但骨架是同一个------「用一组参数描述数据分布,再让参数自己去贴合数据」,区别只在样本归属是硬切还是软分。
抓住这个视角,后面的坑就都好定位了:K-means 的坑集中在「收敛到哪个局部极小」,EM 的坑集中在「簇号是谁」。
二、compactness 不是聚类质量,是簇内平方误差
官方 kmeans.cpp:450 只有一行:
cpp
compactness = sum(Mat(Size(N, 1), CV_64F, &dists[0]))[0];
它就是 SSE(Sum of Squared Errors),单位是「坐标单位的平方」。
这个语义决定了它只有一种合法用法:
- 随 K 单调不增。多给一个中心,每个点到最近中心的距离只会变小或不变。所以绝对不能用它比较「K=3 和 K=5 哪个聚类更好」------K 大的必胜,这是数学决定的,不是算法优劣。
- 只能比同一个 K 下的不同初始化 。这才是
attempts存在的全部意义。
真实照片实测(attempts=3,PP 初始化):
| K | compactness | PSNR (dB) | 耗时 (ms) | SSE 降幅 |
|---|---|---|---|---|
| 2 | 1 727 452 903 | 17.69 | 96 | --- |
| 4 | 555 552 984 | 22.62 | 192 | 67.84% |
| 8 | 228 697 700 | 26.47 | 389 | 58.83% |
| 16 | 109 241 998 | 29.68 | 801 | 52.23% |
| 32 | 57 183 367 | 32.49 | 1531 | 47.65% |
| 64 | 32 596 416 | 34.92 | 2654 | 43.00% |
到这里我发现一个必须提前说清的问题:PSNR 才是能跨 K 比较的指标,因为它衡量的是「压缩后图像与原图的差距」,与 K 无关。SSE 只能在同一个 K 内部横向比,跨 K 比毫无意义。
三、K 的拐点:我第一版判据永远不触发
既然 SSE 单调下降、PSNR 单调上升,那「选多大的 K」就要靠拐点------再增加 K,收益开始明显变缓的那个点。
我第一版的判据是这样的:
cpp
// 降幅首次低于 30% 即认为到拐点
if (drop_ratio < 0.30) return k;
结果永远返回 -1。因为实测降幅是 68 / 59 / 52 / 48 / 43%,单调平缓下滑,从头到尾没有任何一个「断崖」,30% 的线在 K=64 都没碰到。
固定百分比阈值在真实数据上不可用。正确做法是二阶差分(曲率):
K=2→4 降幅 1 171 899 920,下一段降幅 326 855 284,曲率 845 044 636 ← 最大
K=4→8 降幅 326 855 284,下一段降幅 119 455 702,曲率 207 399 581
K=8→16 降幅 119 455 702,下一段降幅 52 058 630,曲率 67 397 072
曲率最大的位置就是拐点,落在 K=4。物理含义是「从 2 到 4 拿到的收益,是从 4 到 8 的 3.6 倍」,再往后每一段的边际收益都只有前一段的一小半。

看图像也能印证:K=4 已经能分出建筑的主色调(墙体、天空、阴影、窗户),K=8 接近视觉无损,K=16 之后肉眼基本分不出来------钱花在后面的 K 上,回报很低。
四、attempts 与 K-means++:收益完全取决于数据长什么样
这是本日最有价值的一组实测。结论和直觉相反,而且分两种情况,必须是两种情况。
先用「好收敛」的数据:5 团高斯,每团 200 点,sigma=35,簇分得开、大小均匀。
| 配置 | 平均 SSE | 相对最优 |
|---|---|---|
| PP attempts=1 | 2 275 297 | 1.000× |
| PP attempts=3 | 2 275 297 | 1.000× |
| PP attempts=10 | 2 275 297 | 1.000× |
| RANDOM attempts=1 | 2 275 394 | 1.000× |
| RANDOM attempts=3 | 2 275 394 | 1.000× |
| RANDOM attempts=10 | 2 275 297 | 1.000× |
七组配置几乎同解,差距不到 0.005%。 我第一次跑出这张表时以为是代码写错了------怎么会一模一样?
原因是数据决定的:簇分得开、大小均匀时,k-means 的局部极小就只有一个,初始中心放哪儿都能爬回去。
这个结论本身会骗人。 换成「难收敛」的数据------簇大小改成 700 / 150 / 100 / 40 / 10,模拟严重长尾的类别分布------差距才炸出来:
| 配置 | 平均 SSE | 最差 SSE | 相对最优 |
|---|---|---|---|
| PP attempts=1 | 751 688 | 777 975 | 1.16× |
| PP attempts=3 | 650 640 | 650 640 | 1.00× |
| PP attempts=10 | 650 640 | 650 640 | 1.00× |
| RANDOM attempts=1 | 4 020 615 | 6 200 688 | 6.18× |
| RANDOM attempts=3 | 1 829 346 | 6 173 553 | 2.81× |
| RANDOM attempts=10 | 650 640 | 650 640 | 1.00× |

对数轴,以全局最优为 1.00× 基准线。RANDOM attempts=1 最差一轮达到 9.53 倍------同一个算法、同一份数据,只是初始中心随机撒的位置不好,代价是一个数量级。
三条可以直接抄进工程的结论:
- PP 初始化在 attempts=1 时就拿到最优,RANDOM 要 attempts=10 才追平。付 1 次 PP 的开销约等于付 10 次 RANDOM 的开销,但前者的最差情况好 8 倍。
attempts是「重启取最优」,不是「并行加速」。它只降低「落在坏局部极小」的概率,对已经最优的情况没有任何提升------表里 PP 的 1/3/10 三行完全一样,就是这个道理。- 数据好收敛时,
attempts和 PP 都是白花钱。 工业现场如果簇分得很开(比如颜色分档、形状分档),默认attempts=3已经足够;只有类别严重长尾或重叠时,加大 attempts 才有意义。
五、TermCriteria:maxCount 会被强制夹到 2, 100
这是一个不查源码绝对想不到的坑。kmeans.cpp:289-298:
cpp
if (criteria.type & TermCriteria::EPS)
criteria.epsilon = std::max(criteria.epsilon, 0.);
else
criteria.epsilon = FLT_EPSILON;
criteria.epsilon *= criteria.epsilon; // ← EPS 被平方
if (criteria.type & TermCriteria::COUNT)
criteria.maxCount = std::min(std::max(criteria.maxCount, 2), 100); // ← 夹到 [2,100]
else
criteria.maxCount = 100; // ← 不给 COUNT 就默认 100
三个直接后果:
eps传进去会被平方 。传0.01,实际阈值是1e-4。也就是说 eps 的物理量纲是「中心位移的平方」,不是像素。maxCount传 1000 也没用,最多 100。 官方例程传 10 是安全的,随便加大不会更快收敛,也不会更慢。- 只给 EPS 不给 COUNT,仍会按 100 次兜底,不会死循环。
实测在强分离的三团定点数据上,6 种 TermCriteria 组合(COUNT 5/10、EPS 0.01/1/50、只给 EPS)全部收敛到同一解 ,compactness=466754.28。
这说明收敛判据只在数据纠缠时才影响结果。 数据可分的时候,怎么配都无所谓------所以官方例程用 TermCriteria(EPS+COUNT, 10, 1.0) 看起来「很讲究」,其实那 10 次迭代根本用不完。
六、predict2() 的返回值:我一度以为官方有 bug
官方 em.cpp:52 有这么一行:
cpp
int response = cvRound(em_model->predict2( sample, noArray() )[1]);
predict2 返回 Vec2d,两个 double。我当时的推理是:[0] 是对数似然,那么 [1] 应该是后验概率 ,取值在 0,1。可 cvRound 一个 0,1 的数,结果只能得 0 或 1------4 个簇里只有 2 个能上色,图上应该只有两种颜色。
结论很自然:官方例程有截断 bug,只能画出两个簇。 我甚至为这个假设写了断言:
cpp
CHECK(kinds <= 2); // "验证" 只出现 2 种颜色
而且它通过了。
这个结论是错的,我写完去核源码后整个推翻了。 证据三处:
cpp
// em.cpp:624-627
Vec2d res;
res[0] = std::log(expDiffSum) + maxLVal - 0.5 * dim * CV_LOG2PI; // 对数似然
res[1] = label; // ← 簇下标
return res;
// em.cpp:648 eStep 里直接这么用
trainLabels.at<int>(sampleIndex) = static_cast<int>(res[1]);
// ml.hpp:926-928 官方文档
/// Zero element is a likelihood logarithm value for the sample.
/// First element is an index of the most probable mixture component.
label 是 em.cpp:607-608 里 argmax(L) 累出来的整数簇号 。所以 cvRound 在这里根本没做截断,它只是把一个整数从 double 转回 int。官方写法完全正确。
实测验证(15625 个采样点):
像素 logLik [1] probs(4簇后验) predict()
( 50,250) -16.573 1 [0.416 0.584 0.000 0.000] 1
(210,250) -11.217 0 [0.645 0.354 0.000 0.000] 0
(290,250) -11.015 2 [0.000 0.000 0.736 0.263] 2
→ predict2()[1] 覆盖 4 个簇;与 predict() 不一致点数 = 0
三条断言全部成立:[1] 是整数、[1] == argmax(probs)、[1] == predict()。
这条必须记进方法论 :断言「通过」不代表结论正确。我当时写的
CHECK(kinds <= 2)是拿自己的错误假设当标准来验证自己,属于自证。只有回头核em.cpp和ml.hpp原文才推得翻。官方例程有疑问,先怀疑自己的理解。
七、EM 的真实工程陷阱:簇编号不稳定
如果这篇文章只记住一条,记这条。
把 em.cpp 换成三种协方差类型(SPHERICAL / DIAGONAL / GENERIC),分别训练同一个模型,然后逐像素比较聚类结果。
直接按 label 比较------看起来彻底不一样:
SPHERICAL vs DIAGONAL 73.97%
SPHERICAL vs GENERIC 98.83%
DIAGONAL vs GENERIC 49.87%
98.83% 的像素不一样,看起来这是三个完全不同的算法。
按簇中心位置重映射后再比------几乎完全相同:
SPHERICAL vs DIAGONAL 1.44% 映射 [0 1 2 3] -> [2 0 1 3]
SPHERICAL vs GENERIC 2.06% 映射 [0 1 2 3] -> [2 3 1 0]
DIAGONAL vs GENERIC 1.00% 映射 [0 1 2 3] -> [3 1 2 0]
三次训练得到的簇中心坐标完全一致(都在 (155,331) / (344,167) / (165,163) / (340,330) 附近),但编号顺序每次都不一样,因为 EM 的初始均值来自内部 k-means,而它是随机的。

肉眼看这三张图几乎一样------这就是真相:区域形状几乎没变,变的只是「哪个簇被叫做 0 号」。
工业影响很直接 :如果按 label 索引固定颜色或固定类别名,每次重训颜色就乱跳,「红色=缺陷、绿色=合格」这种约定会随机失效。产线上表现为「昨天好好的,今天相机重启后判定全反了」。
对策三选一:
- 按簇中心的某个物理量排序后重新编号(如中心亮度、中心 Y 坐标)
- 保存训练时的
means,推理时按最近中心做一次对齐映射 - 干脆不用簇号,直接用
predict2的第二个参数probs做软判决加阈值
八、两个被实测推翻的猜测
学习笔记里,被推翻的猜测和成立的结论一样值钱。
| 猜测 | 验证结果 |
|---|---|
predict2()[1] 是后验概率,官方 em.cpp 有截断 bug |
❌ 错 。em.cpp:626 res[1] = label,是簇下标,官方写法正确 |
centers 在 K 大于唯一颜色数时会少于 K 行 |
❌ 错 。kmeans.cpp:284 恒分配 K 行 |
第二条值得多说一句。我原本的推理是:如果图里只有 3 种颜色,K 却给了 8,那一定有空簇,空簇应该被「删掉」,所以 centers 会少于 8 行。
源码 kmeans.cpp:284:
cpp
Mat centers(K, dims, type), old_centers(K, dims, type), temp(1, dims, type);
centers 在循环外就按 K 分配好了。空簇的处理是「保留旧中心,不再移动」,而不是删掉一个中心。
实测 3 种唯一颜色的图上 K=8,返回 8×3 个值,compactness ≈ 0(唯一颜色被完美覆盖)。
工程含义 :centers.total() == K * dims 恒成立,可以放心按 K 索引。但要警惕多个中心坐标完全重合------那些就是空簇,它们会让「实际用到的簇数」虚高。
九、官方例程隐藏的坑:sampleCount 可能只有 1
官方 kmeans.cpp 第 36 行:
cpp
int i, sampleCount = rng.uniform(1, 1001); // 最小值是 1!
rng.uniform(1, 1001) 返回 1 到 1000 之间的任意整数,包含 1。
此时 clusterCount 被 MIN(clusterCount, sampleCount) 压成 1,而 kmeans() 对 K==1 有特殊分支(kmeans.cpp:300-304):attempts=1、maxCount=2。
本工程跑 10 轮,最少一轮只拿到 n=23 ,compactness=28007,与其它轮完全不是一个量级。
这说明官方例程的 for(;;) 循环里,每轮的 compactness 是在不同量级的数据上算出来的。 按回车看下一张图的人不会意识到这一点------他会以为数字变小是算法变好了。
教训 :任何要在官方例程上加自动化判断的人,必须先固定 sampleCount,否则那些数字毫无意义。
十、本日 API 速查
| 接口 | 关键参数 | 说明 |
|---|---|---|
kmeans(data, K, labels, TermCriteria, attempts, flags, centers) |
labels 为 N×1 列向量 |
返回 compactness = SSE |
KMEANS_PP_CENTERS |
--- | K-means++ 初始化,attempts=1 即可 |
KMEANS_RANDOM_CENTERS |
--- | 纯随机,需靠 attempts 兜底 |
TermCriteria(type, maxCount, eps) |
maxCount∈[2,100] |
eps 会被平方 |
EM::setClustersNumber(n) |
--- | 高斯个数 |
EM::setCovarianceMatrixType(t) |
COV_MAT_SPHERICAL / DIAGONAL / GENERIC |
形状自由度递增 |
EM::trainEM(samples) |
返回 bool |
内部先用 k-means 初始化均值 |
EM::predict2(sample, probs) |
返回 Vec2d |
[0] 对数似然,[1] 簇下标 |
EM::getMeans() / getWeights() |
--- | 簇中心 / 混合权重 |
EM::getCovs(vector<Mat>&) |
输出参数,非返回值 | 我在这里编译失败过一次 |
十一、踩坑速查表
| # | 现象 | 根因 | 解决 |
|---|---|---|---|
| 1 | CMake 报 No SOURCES given to target |
先写了 CMakeLists,源文件还没建 | 先 touch 占位,再逐个编 target |
| 2 | 改完源码,make 说没变化 |
共享目录时钟偏移,判定目标比源文件新 | rm -rf build 全量重编 |
| 3 | labels.rows == labels.cols 断言失败 |
我以为输出是方阵,实际是 N×1 列向量 |
改 rows==N && cols==1 |
| 4 | getCovs() 编译不过 |
它是输出参数 void getCovs(vector<Mat>&) |
传入容器,不接返回值 |
| 5 | 三种协方差类型对比时程序崩溃 | types[3] 定义在循环内却用 c 索引到 3 越界 |
数组提到循环外 |
| 6 | 7 组配置结果完全一致 | 数据太「好收敛」,局部极小唯一 | 换成 700/150/100/40/10 长尾数据 |
| 7 | 拐点检测永远返回 -1 | 用了固定阈值 30%,实际降幅平缓下滑 | 改二阶差分(曲率最大处) |
| 8 | 配图里 SSE 曲线画反了 | log(max)-log(v) 方向写反 |
改 log(v)-log(min),逐像素核对 |
第 8 条值得单独提一句:数值下降但视觉上升 ,这种图发出去是最丢人的。我最后是逐像素取 y 中位数核对才发现的------画完图不能只靠眼睛扫一眼觉得对。
十二、AI 与 LLM Wiki:这一课沉淀了什么
这一天的产物不是八张图和 151 条断言,而是三个可以复用的判断规则:
- 先分清「能跨条件比较的量」和「只能在同条件下比较的量」。 SSE 不能跨 K 比,PSNR 可以。搞混这一条,后面所有的选型结论都是错的。这个区分比记住 API 参数重要得多。
- 断言通过不等于结论正确。 我拿自己的错误假设去写断言,它当然通过。真正推翻它的是回头核
em.cpp:626和ml.hpp:926-928的原文。自证是所有技术判断里最危险的陷阱。 - 「看起来不同」和「真的不同」之间,隔着一次对齐。 三种协方差的聚类结果直接比差 50%~98%,按簇中心重映射后只差 1%~2%。这个差值不是算法差异,是编号差异。没有对齐过的对比数据,不能下任何结论。
LLM Wiki 里这一节建了 聚类与期望最大化:K-means与EM高斯混合 概念页,包含 30 多条 API 表、逐段语法分析和 15 条踩坑记录。四张负面结论(predict2 截断、centers 少于 K、协方差类型差异大、EM 软分配可见)都单独记了一栏------负面结论是最省后来人时间的那部分知识。
进度推进到 77 / 97(79.4%),剩下的 20 节里,ml 模块还有 6 节。
下一节:digits_svm 和 train_svmsgd------从无监督切到有监督,正好和今天形成对照。