前六篇的模型都需要标签。无监督学习没有标签,只有 \(x\),要回答的问题是”这批数据里有什么结构”。聚类是其中最常用的一种:把相似的样本归成一堆。在 LLM 的数据工程里它回答一个具体问题——这个几十 T 的语料里有哪些主题、各占多少、哪些是垃圾。这一篇讲三个聚类算法(K-Means、DBSCAN、层次聚类)各自怎么工作、假设什么、什么时候失效,手写 K-Means 并看它一步步收敛,最后用一个真实的小语料(78 句话过一个 0.5B 的语言模型得到句向量)把”每簇抽几条看看”跑一遍。

全篇的核心问题是:

K-Means 那两步为什么一定收敛,收敛到的一定是最好的吗?1 怎么知道一个语料里有什么主题、\(k\) 该取多少?2

一、总览

1. 三个算法、一个用途

本文按”算法 → 它的假设与失效 → 替代算法 → 在真实语料上用”组织:

K-Means、DBSCAN 与层次聚类的假设与失效
算法 一句话 假设 失效 本文里的数字
K-Means 分成 \(k\) 簇、每簇一个中心,反复”分配 → 更新中心” 簇球形、大小差不多;\(k\) 已知 月牙形簇切错(ARI 0.26);局部最优 手写 12 行与 scikit-learn 同数:inertia 7281
DBSCAN 按密度聚:密集处连成簇、稀疏处是噪声 簇内密度差不多 \(\varepsilon\) 难选;密度不均 月牙 ARI 1.00;\(\varepsilon = 0.05\) 碎成 8 簇
层次聚类 从每点一簇开始反复合并最近的两簇 — \(O(n^2)\) 内存 30 个点,树状图上一刀切出 3 簇
用途 语料 embedding → 聚类 → 每簇抽几条看 — — 78 句、\(k = 7\):主题全对,模板文本自成一簇

2. 本文的章节安排

本文的章节安排
章 主题 内容
二 K-Means
  • 六个数手算三轮
  • 迭代过程(四帧图)
  • 12 行实现与 scikit-learn 对数
  • 为什么一定收敛
三 \(k\) 怎么选 肘部法与轮廓系数(两者不一致时怎么办)
四 初始化 局部最优是真的:随机 vs k-means++(50 个 seed 的分布)
五 DBSCAN
  • 核心点 / 边界点 / 噪声
  • 两个月牙
  • \(\varepsilon\) 的作用
六 层次聚类 合并过程与树状图;不用先定 \(k\)
七 聚类在数据工程里 78 句真实语料 → 句向量 → K-Means → 每簇抽几条;按簇配比、找垃圾簇、SFT 多样性
八 案例:客户分群与颜色量化 54 万行真实交易 → 4,338 个客户的 R/F/M → 分 4 群、每群起名;一张照片 96,615 种颜色压成 16 种——K-Means 1957 年被发明时干的活
九 本文小结  
十 自测 六道题

3. 来龙去脉:一个为压缩发明的算法

聚类方法的来历
年 谁 当时的问题 留下的东西
1957 Lloyd(贝尔实验室,1982 年才正式发表) 电话信号数字化(PCM):把连续的电压值用最少的几个”代表值”编码,失真最小 Lloyd 算法:分配到最近的代表值 → 代表值移到组均值 → 重复。这就是 K-Means 的两步(第二章)——它最初是一个量化 / 压缩算法,第八章的颜色量化是它的本行
1967 MacQueen 统计学里怎么把观测分成 \(k\) 组 “k-means”这个名字;证明它收敛
1963 Ward 分类学、社会科学里不知道该分几组 层次聚类(第六章):不定 \(k\),画一棵树再决定切在哪
1994 Hughes,《Strategic Database Marketing》 直邮营销:几十万客户,寄给谁 RFM(最近一次、频率、金额)成为客户细分的标准三维——第八章的案例
1996 Ester、Kriegel、Sander、Xu,DBSCAN 地理数据里的簇形状不规则、还有噪声点,K-Means 的”球”不合适 按密度聚(第五章);2014 年拿 KDD 时间检验奖
2007 Arthur & Vassilvitskii,k-means++ 随机初始化经常落进坏的局部最优 初始中心彼此拉开的采样(第四章);scikit-learn 的默认
2013– 句向量 + K-Means 几亿条网页 / 对话怎么看清里面有什么 第七章:聚类成为数据工程的显微镜——按簇抽样、配比、找垃圾

一条线看下来,K-Means 在六十多年里换了三次身份:先是压缩(用 \(k\) 个代表值替代一堆值),再是分组(客户、文档、基因),今天是探索(把一亿条数据变成一千个能看的簇)。三种用法的算法一模一样,差别只在”簇中心”拿来干什么:当代表值、当分组标签、还是当抽样入口。什么时候不该用它:簇不是球形(月牙、环——换 DBSCAN)、密度差很大、或者你其实想要一个”正确”的分组——聚类没有正确答案,只有有用与否(第七章)。

二、K-Means

1. 两步反复

把 \(n\) 个点分成 \(k\) 簇,每簇一个中心,目标是让每个点到自己簇中心的距离平方和(簇内平方和,inertia)最小。算法只有两步反复:分配——每个点归到最近的中心;更新——每个中心移到自己簇内所有点的均值。

先在一维上手算一遍。六个数 \(\{1, 2, 3, 10, 11, 12\}\),\(k = 2\),故意把两个初始中心都放在左边:\(c_1 = 1, c_2 = 2\)。

一维 K-Means 手算三轮
轮 中心 \(c_1, c_2\) 分配(每个数归到更近的中心) 簇内平方和 更新后的中心(各簇均值)
1 1, 2 {1} → \(c_1\);{2, 3, 10, 11, 12} → \(c_2\) 246 1, 7.6
2 1, 7.6 {1, 2, 3} → \(c_1\);{10, 11, 12} → \(c_2\) 41.7 2, 11
3 2, 11 同上,分配没变 4 2, 11——不再移动,停

第 1 轮 \(c_2\) 抢走了五个数,于是被它们的均值拉到了 7.6;第 2 轮 3 离 1 比离 7.6 近,回到 \(c_1\),两个中心各归各位;第 3 轮分配不再变化,算法停止。簇内平方和 246 → 41.7 → 4,每一轮都在降。二维上是同一件事,看一遍:

四张图:初始化时三个随机挑的中心(黑叉)两个落在同一团点里;第 1 步分配后一个中心被拉到两团之间;第 2 步三个中心各自靠近一团;第 6 步收敛,三个中心分别停在三团点的中央

6 步收敛;每步的簇内平方和: 10665 3594 841 320

初始化随机挑了三个点当中心,两个挤在同一团里;第一步分配之后,那个”没抢到点”的中心被更新到了两团之间,第二步就各归各位。簇内平方和从 10665 一路降到 320。

2. 十二行实现

def kmeans(X, k, n_iter=100, seed=0):
    rng = np.random.default_rng(seed)
    centers = X[rng.choice(len(X), k, replace=False)]                     # ① 初始化:随机挑 k 个点当中心
    for _ in range(n_iter):
        d2 = ((X[:, None, :] - centers[None, :, :]) ** 2).sum(-1)           # ② [n, k]:每个点到每个中心的距离平方
        labels = d2.argmin(1)                                               # ③ 分配:每个点归到最近的中心
        new_centers = np.array([X[labels == j].mean(0) for j in range(k)])  # ④ 更新:每个中心移到自己簇的均值
        if np.allclose(new_centers, centers): break                         # ⑤ 中心不再移动就收敛
        centers = new_centers
    inertia = d2[np.arange(len(X)), labels].sum()                         # 簇内平方和
    return labels, centers, inertia
  1. ② 用广播一次算出 \(n \times k\) 个距离(第四篇 KNN 的同一招);
  2. ③④ 就是那两步;
  3. ⑤ 中心不动了就停。

在一份真实有 6 簇、3000 个点的数据上,跑 10 个 seed 取簇内平方和最小的:

手写(10 个 seed 取簇内平方和最小):inertia 7281,ARI 0.762
sklearn KMeans(6, n_init=10):       inertia 7281,ARI 0.761
每簇样本数 [569, 500, 504, 500, 459, 468]

同一个算法、同一个数。ARI(adjusted Rand index)是有真实标签时衡量聚类与标签一致程度的指标,1 是完全一致、0 是随机水平;0.76 说明 6 簇里有两对靠得近的被部分混了。真实数据没有标签,ARI 用不上,靠第三章的判据加人眼看。

3. 为什么一定收敛

两步都不会让簇内平方和变大:分配步把每个点换到更近的中心,它到中心的距离只会变小;更新步里,一堆点到某个点的距离平方和,在那个点是均值时最小(第二篇最小二乘的一维版:\(\sum (x_i - c)^2\) 对 \(c\) 求导为零得 \(c = \bar x\))。一个单调不增、有下界的量,一定收敛。但它收敛到的是局部最优——第四章会看到从不同起点出发可以停在很不一样的地方。

三、\(k\) 怎么选

\(k\) 是超参数,算法不告诉你。两个常用判据,还是那份真实有 6 簇的数据:

左:簇内平方和随 k 从 2 到 10 单调下降,在 k = 6 处(虚线)出现拐点,之后下降明显变缓;中:轮廓系数在 k = 2 最高 0.67,k = 5 有一个次高点 0.61,之后一路下降;右:k = 6 的聚类结果,六团点中两对相互靠近

不同 k 下的簇内平方和与轮廓系数
\(k\) 簇内平方和 轮廓系数  
2 42,880 0.665  
3 26,439 0.591  
4 16,412 0.532  
5 8,274 0.609  
6 7,281 0.518 ← 肘部
7 6,773 0.473  
8 6,226 0.468  
10 5,313 0.343  
  1. 肘部法:簇内平方和随 \(k\) 单调下降(\(k = n\) 时为零),但下降速度在真实簇数处突然变缓——从 5 到 6 降了 1000,从 6 到 7 只降 500。曲线的”肘部”就是 \(k\)。
  2. 轮廓系数(silhouette):对每个点算 \(a\) = 它到自己簇内其他点的平均距离,\(b\) = 它到最近的别的簇里各点的平均距离,轮廓系数 \(s = (b - a) / \max(a, b)\):自己簇很紧(\(a\) 小)、别的簇很远(\(b\) 大)时接近 1;\(a \approx b\) 时接近 0,说明这个点放哪个簇都差不多;为负说明它更像别的簇。全部点取平均,越大越好。这里它在 \(k = 2\) 最高——因为 6 个簇两两靠近形成了 3 个大组(右图看得出来),轮廓系数偏好粗的划分。

两个判据不一致时,按业务定:你想看 3 个大主题还是 6 个细主题。数据工程里 \(k\) 通常取得比”真实簇数”大得多(50、200),因为目的是让人看,不是找”正确”的划分——第七章。

四、初始化:局部最优是真的

第二章说 K-Means 收敛到局部最优。它有多严重?同一份 6 簇数据,随机初始化跑 50 个不同的 seed,看收敛后的簇内平方和分布:

直方图:随机初始化的 50 次结果(灰)大部分落在最优值 7281 附近,但有一条长尾拖到 16541——两倍于最优;k-means++(红)全部集中在 7281–7861 之间

50 个 seed:随机初始化的簇内平方和 中位数 7285,最差 16541,落到最优(≈7281)的比例 64%
         k-means++            中位数 7292,最差 7861,落到最优的比例 66%

随机初始化有 1/3 的概率停在差解,最差时是最优的两倍多——两个中心挤在同一个真簇里、另一个中心管着两个真簇(第二章四帧图的初始状态如果没有被拉开,就会停在那里)。k-means++ 初始化:第一个中心随机挑,之后每个中心按”离已有中心越远越可能被挑到”抽样,让初始中心彼此散开——最差情况从 16541 变成 7861。scikit-learn 默认用它,再加 n_init=10 多跑几次取最好。

五、DBSCAN:按密度聚

1. K-Means 的假设

K-Means 用”到中心的欧氏距离”分配,等于假设簇是球形的、大小差不多的。簇是长条形、月牙形、密度不同、大小差很多时,它会切错。

2. 三种点

DBSCAN 不预设簇的形状与个数。两个参数:邻域半径 \(\varepsilon\) 与最少点数 min_samples。半径内有至少 min_samples 个点的是核心点;在某个核心点半径内、但自己不够核心的是边界点;两者都不是的是噪声。核心点与它半径内的点连成一簇——密集处连成片,稀疏处断开。

左:两个月牙上 K-Means k = 2 的结果——一条直线把两个月牙各切一半,两个中心(黑叉)落在月牙之间,ARI 0.25;中:DBSCAN 沿密度把两个月牙完整分开,ARI 1.00;右:200 个点加 8 个离群点上 DBSCAN 的三种点——190 个核心点(实心蓝)、10 个边界点(空心橙)、8 个噪声(黑叉)

K-Means k=2:  ARI 0.255(球形假设不成立,一刀切在中间)
DBSCAN(eps=0.15, min_samples=8): ARI 1.000,2 簇 + 0 个噪声点

两个交错的月牙,K-Means 用一条直线切成左右两半,DBSCAN 沿着密度把两个月牙完整地分开——不用指定 \(k\),还能标出离群点。

3. \(\varepsilon\) 的代价

DBSCAN 不同 ε 下的簇数、噪声点与 ARI
\(\varepsilon\) 簇数 噪声点 ARI
0.05 8 167 0.311
0.10 2 3 0.996
0.15 2 0 1.000
0.30 2 0 1.000

\(\varepsilon\) 太小,月牙碎成 8 段、167 个点被当成噪声;合适的范围(0.1–0.3)内结果稳定。真实数据里的麻烦是密度不均匀:一个 \(\varepsilon\) 对密的簇太大(把邻近簇连起来)、对稀的簇太小(碎掉),没有一个全局合适的值——HDBSCAN 是为此改进的版本。另一个代价:DBSCAN 要算每个点的邻域,大数据上比 K-Means 慢。

选择的原则:簇大致球形、想指定个数、数据量大 → K-Means;形状不规则、想自动发现个数、想标出离群点 → DBSCAN。

六、层次聚类

层次聚类(凝聚式)不要求先定 \(k\):从每个点自成一簇开始,每一步合并最近的两簇,直到全部合成一簇。合并的整个过程画成一棵树——树状图(dendrogram):

左:30 个点分成三团、编了号;右:树状图——叶子是 30 个点,每次合并画一条横线、高度是合并时两簇的距离,最下面密密麻麻的小合并、最上面三次大合并,一条红色虚线在倒数第二次合并之上横切,切出 3 个分支

30 个点、Ward 链接:最后三次合并的距离 [5.01, 22.13, 54.12]——最后一次跳得很大 → 切成 3 簇;ARI 1.000

“两簇的距离”有几种定义(最近点、最远点、均值、Ward——合并后簇内平方和增加最少),Ward 最常用。读树状图:纵轴是合并时的距离,从下往上找距离突然跳大的那一层(5 → 22 → 54),在跳之前横切,切出几个分支就是几簇。代价是要存 \(n \times n\) 的距离矩阵,几万个点以上就不合适;它的用处是小数据上探索结构,以及给 K-Means 找 \(k\) 的参考。

七、聚类在数据工程里

1. “这批数据里有什么”

预训练语料几十 T、SFT 数据几十万条,没人能读完。聚类是回答”这批数据里有什么”的探索工具:

%% 语料探索的标准流程:embedding → 聚类 → 抽样人读 → 决策
flowchart LR
    T[文本] -->|embedding 模型| V["向量<br/>几百到几千维"]
    V -->|"K-Means<br/>k = 50 或 200"| K[k 个簇]
    K -->|每簇抽 5–10 条| R[人读:这一簇是什么]
    R --> A[按簇配比]
    R --> B[找垃圾簇]
    R --> D[SFT 数据保多样性]

2. 在一个真实的小语料上跑一遍

78 句话:6 个主题(体育、编程、烹饪、金融、天气、医学)各 12 句,外加 6 句同一个模板换几个数字的文本(模拟爬虫抓到的模板页)。把每句话过一遍 Qwen2.5-0.5B,取最后一层隐状态对 token 做平均,得到 896 维的句向量:

h = model(**batch).last_hidden_state                          # [batch, T, 896]:每个 token 一个向量
m = batch["attention_mask"][..., None].float()
E = (h * m).sum(1) / m.sum(1)                                   # mean pooling:对真实 token 取平均 → 每句一个向量
En = E / np.linalg.norm(E, axis=1, keepdims=True)               # 归一化:长度变 1,之后的欧氏距离等价于余弦相似度(见下)
km = KMeans(7, n_init=10, random_state=0).fit(En)

第一行:模型对每个 token 输出一个 896 维向量(第三篇说的 hidden state);第三行把一句话里所有 token 的向量取平均,得到”这句话”的一个向量(mean pooling),attention_mask 用来排除补齐用的空 token;第四行把每个向量的长度缩放成 1——两个单位向量的欧氏距离平方是 \(\lVert a - b \rVert^2 = 2 - 2\cos\theta\),所以之后 K-Means 用的欧氏距离就是在比较方向(第四篇的余弦相似度),不受句子长短影响。

真实小语料上不同 k 的聚类 ARI 与簇大小
\(k\) ARI(与真实主题) 每簇大小
4 0.629 24, 24, 18, 12
7 1.000 12, 12, 12, 12, 12, 12, 6
10 0.874 12, 12, 12, 12, 7, 6, 5, 5, 4, 3

\(k = 7\) 时每簇抽两条:

k = 7 时每簇抽样与人读判断
簇 大小 抽出来的两条 人读一眼的判断
0 12 央行宣布下调基准利率 25 个基点… / 这家公司季度营收超出预期… 金融
1 12 冷空气明天抵达,气温将下降… / 台风外围云系影响沿海地区… 天气
2 12 这个函数在空列表上会抛出索引越界… / 把循环改成向量化的 NumPy 操作… 编程
3 12 先把洋葱用小火炒到透明… / 面团要醒发一小时… 烹饪
4 12 病人的血压持续偏高… / 这种抗生素对革兰氏阴性菌有效… 医学
5 6 欢迎访问本站,本页面共有 12 条记录… / 欢迎访问本站,本页面共有 37 条记录… 模板页——垃圾簇
6 12 昨晚的比赛进入加时… / 马拉松选手在 35 公里处开始掉速… 体育

每簇读两条就知道这一簇是什么,簇的大小就是主题占比,模板文本自成一簇——三件事同时看到了。\(k\) 取 4 时体育与天气、金融与编程被合并(ARI 0.63),\(k\) 取 10 时几个主题被拆成两半(0.87)——但对”看看有什么”这个目的,三个 \(k\) 都能用。

3. 三个具体用法

  1. 按簇配比:某个主题(比如 SEO 垃圾、模板页)占了 20%,按簇采样把它压到 2%;某个想要的主题太少,定向补。这是 L4 预训练系列”数据配比”决策的第一步——先知道有什么,再决定要多少。
  2. 找垃圾簇:爬虫抓到的乱码、导航栏、cookie 提示常常自成一簇(上表的簇 5)——比写规则更快地发现它们。
  3. SFT 数据的多样性:几万条指令按簇采样,保证各类任务都有、没有一类占太多;也用来发现”这 500 条其实是同一个模板”的近重复(第九篇用 MinHash 做精细版)。

三个用法都不需要聚类”正确”——\(k\) 取 50 还是 200、边界切在哪,对”看看有什么”影响不大。这是无监督学习在工程里最常见的角色:探索,而不是预测。

八、案例:客户分群与颜色量化——K-Means 的两个本行

第七章用 78 句话演示了”聚类是探索”。这一章两个更大的真实数据:一个是 K-Means 最经典的商业用法(客户分群),一个是它 1957 年被发明时的本行(量化)。完整脚本 case_07_customer_segments.py(rfm / colors 两个子实验)。

1. 客户分群:RFM + K-Means

问题与数据:UCI Online Retail——一家英国网店 2010-12 到 2011-12 的 541,909 行交易记录(发票号、商品、数量、单价、客户号、国家)。运营想知道:几千个客户里,谁是要维护的大客户、谁快流失了、谁是新客——然后对每一群做不同的事。这是 1994 年以来直邮营销的标准做法:RFM——每个客户三个数,R(Recency,最近一次购买距今几天)、F(Frequency,买过几次)、M(Monetary,一共花了多少)。

清洗是真实数据的第一课:135,080 行没有客户号(匿名结账)、10,624 行是退货(数量为负、发票号以 C 开头),去掉之后剩 397,884 行、4,338 个客户、18,532 张发票。

思路:

%% 图:从 54 万行交易到四群客户
flowchart TB
    T["541,909 行交易"] -->|去匿名、去退货| T2["397,884 行"]
    T2 -->|按客户聚合| R["4,338 × 3 的 RFM 表<br/>R 天 · F 次 · M £"]
    R -->|log1p + 标准化| S["三列都是长尾<br/>M 中位 £670、最大 £280,206"]
    S -->|K-Means k=4| K["四个簇"]
    K -->|看每簇的 R/F/M 均值| N["起名:冠军 / 忠实 / 新客 / 流失中"]

三列都是长尾(M 中位数 £670、最大 £280,206),直接算欧氏距离会被几个大客户主导——先 log1p 再标准化(第二章的”必须标准化”在真实数据上的样子)。

df = raw[raw.customer_id.notna() & (raw.quantity > 0) & ~raw.invoice.str.startswith("C")]
now = df.invoice_date.max() + pd.Timedelta(days=1)
rfm = df.groupby("customer_id").agg(R=("invoice_date", lambda d: (now - d.max()).days),   # 最近一次距今几天
                                    F=("invoice", "nunique"),                             # 买过几次
                                    M=("amount", "sum"))                                  # 一共花了多少
Xs = StandardScaler().fit_transform(np.log1p(rfm.values))
for k in range(2, 9):                                                                    # 第三章:k 怎么选
    km = KMeans(k, n_init=10, random_state=0).fit(Xs)
    print(k, km.inertia_, silhouette_score(Xs, km.labels_))
rfm["cluster"] = KMeans(4, n_init=10, random_state=0).fit_predict(Xs)
rfm.groupby("cluster").agg(人数=("R", "size"), R=("R", "mean"), F=("F", "mean"), M=("M", "mean"))

效果:\(k\) 怎么选——轮廓系数在 \(k = 2\) 最高(0.433),但”活跃 / 不活跃”两群对运营没用;惯性的拐点在 4 附近,且 4 群每群都能起出名字。这是第三章说的”两个指标不一致时怎么办”:看业务能不能用。

4,338 个客户的四群画像
簇 人数 R 均值(天) F 均值(次) M 均值(£) M 合计占比 画像
1 714 12 14.0 8,093 64.8% 冠军:最近来过、买得最多
2 1,168 72 4.0 1,806 23.7% 忠实:常来、花得不少
3 832 18 2.0 562 5.2% 新客 / 低频:最近来过一两次
0 1,624 181 1.0 342 6.2% 流失中:半年没来

左:4,338 个客户按 R(距上次购买天数)与 M(总消费,对数)散点,四种颜色是四个簇,冠军在左上、流失中在右下;右:k 从 2 到 8 的惯性与轮廓系数

四群的 R、F、M 均值三张柱状图

读法:714 个人(16%)贡献了 65% 的营业额——这是零售业的常识(帕累托),K-Means 把它从 54 万行里算了出来;1,624 个人(37%)平均 181 天没来,只买过一次,这一群是”流失召回”的名单。四群对应四个动作:冠军维护(别让他们走)、忠实升级(推更贵的)、新客促复购(第二单优惠券)、流失召回(唤醒短信)。K-Means 没有”发现”这四群——它只是把三维空间切成了四块;给每块起名字、决定对它做什么,是人的事。这就是第七章”探索,不是预测”在商业上的形态。

2. 颜色量化:一张照片 96,615 种颜色 → 16 种

问题:一张 640×427 的照片有 273,280 个像素、96,615 种不同的颜色,每像素 24 位 = 801 KB。GIF 只能存 256 色、早期显示器只有 16 色——怎么用 \(k\) 种颜色画这张图、看起来最不失真?这正是 Lloyd 1957 年的问题(电话信号用几个电平编码),只是把一维的电压换成三维的 RGB。

思路与代码:每个像素是 RGB 空间里的一个点,K-Means 找 \(k\) 个簇中心(”代表色”),每个像素换成离它最近的代表色。拟合只用随机抽的 1 万个像素就够:

img = load_sample_image("china.jpg")                  # [427, 640, 3],scikit-learn 自带的示例图
pixels = img.reshape(-1, 3) / 255                     # 273,280 个三维的点
km = KMeans(16, n_init=4, random_state=0).fit(pixels[rng.choice(len(pixels), 10000)])
quantized = km.cluster_centers_[km.predict(pixels)]   # 每个像素 → 离它最近的代表色

效果:

颜色量化——k 种颜色的大小与失真
\(k\) 每像素位数 文件大小 相对原图 均方误差(0–255 尺度)
2 1 33 KB 4.2% 1,285
4 2 67 KB 8.3% 456
16 4 133 KB 16.7% 115
64 6 200 KB 25.0% 38
原图 24 801 KB 100% 0

原图与 k = 2 / 4 / 16 / 64 种颜色的量化结果:k=2 只剩明暗,k=16 已经能看,k=64 几乎看不出差别

\(k = 16\) 时文件是原来的 1/6、已经能看;\(k = 64\) 几乎看不出差别、1/4 大小。K-Means 的”惯性”(各点到簇中心的距离平方和)在这里有了物理意义——它就是压缩失真,第三章那条肘部曲线就是”多用一位能少多少失真”。这也是理解 LLM 权重量化(把权重压成 4 位,L4 第十二篇)的最直观入口:把一堆数用少数几个代表值替代,代表值怎么选、失真怎么算,是同一件事。

落地还差什么:RFM 分群要定期重跑——客户在群之间流动,上个月的冠军这个月可能进了流失名单,运营看的是群之间的迁移矩阵;聚类结果要和 A/B 测试连起来——对”流失中”发唤醒短信到底有没有用,聚类不回答,实验才回答;颜色量化在今天的图像压缩里已被 JPEG / WebP 的变换编码取代,但同一个算法活在向量数据库的乘积量化(PQ)里——把 768 维 embedding 切成 96 段、每段用 K-Means 量化成 256 个代表值,内存降 24 倍——向量检索系统(Faiss 的 IVF-PQ)的标配。

九、本文小结

  • K-Means:分配 → 更新中心反复,12 行;每步簇内平方和不增(更新步的均值是最小二乘解)所以一定收敛,但到局部最优——随机初始化 1/3 的概率停在差解、最差是最优的两倍,k-means++ 让初始中心散开、再加 n_init 多跑几次。手写与 scikit-learn 同数(inertia 7281、ARI 0.76)。
  • \(k\):肘部法(簇内平方和的拐点)或轮廓系数,两者不一致时按业务定;数据工程里 \(k\) 取得大(50–200),目的是让人看。
  • DBSCAN 按密度聚:核心点 / 边界点 / 噪声;不用指定 \(k\)、能找任意形状、能标离群点——两个月牙 K-Means ARI 0.26 vs DBSCAN 1.00;代价是 \(\varepsilon\) 难选(0.05 碎成 8 簇)、密度不均时没有全局合适的值、大数据慢。
  • 层次聚类:反复合并最近两簇,树状图上找合并距离突然跳大的那一层横切;\(O(n^2)\) 内存,小数据探索用。
  • 数据工程:文本 → 句向量(模型最后一层 mean pooling、归一化)→ K-Means → 每簇抽几条人读。78 句真实语料 \(k = 7\) 主题全对、模板页自成一簇;用法是按簇配比、找垃圾簇、SFT 保多样性——探索,不是预测,聚类不需要”正确”。

  • 案例:54 万行交易 → 4,338 个客户 RFM → K-Means 4 群:714 个冠军贡献 65% 营业额、1,624 个半年没来;\(k\) 按业务能否起名选,不只看轮廓系数。颜色量化:96,615 色 → 16 色,1/6 大小——K-Means 1957 年的本行,惯性 = 失真。

配套代码:本文全部数字与图由 classical-ml/07_clustering.py 产生(iterate / kmeans / choose_k / init / dbscan / hierarchical / corpus 七个子实验;第八章案例由 case_07_customer_segments.py 产生,首次运行下载 23 MB 的交易表;语料与句向量在同目录 _sentences.py,第一次运行加载本地缓存的 Qwen2.5-0.5B,之后读 out/sentence_embeddings.npz),CPU 上一分钟内跑完。

十、自测

  1. K-Means 的簇内平方和为什么随 \(k\) 单调下降?\(k = n\) 时是多少?

    答案

    多一个中心只会让点到最近中心的距离不增;\(k = n\) 时每点自成一簇,为 0。

  2. K-Means 的更新步为什么把中心移到均值,而不是中位数或别的点?

    答案

    目标是距离平方和,一堆点到某点的距离平方和在均值处最小(对 \(c\) 求导为零得 \(c = \bar x\));若目标换成绝对距离和,就该用中位数(K-Medians)。

  3. 同一份数据跑两次 K-Means 得到不同的簇内平方和 7281 与 16541。发生了什么?怎么避免?

    答案

    收敛到了不同的局部最优(后者两个中心挤在一个真簇里);用 k-means++ 初始化并 n_init 多跑几次取最小。

  4. 一批数据里有一个很大的簇和三个很小的簇,K-Means \(k = 4\) 会怎样?该用什么?

    答案

    K-Means 假设等大,会把大簇切开、把小簇合并;用 DBSCAN 或层次聚类,或先按密度分再细分。

  5. DBSCAN 的 \(\varepsilon\) 从 0.15 调到 0.05,簇数与噪声点数各怎么变?

    答案

    簇变多(碎成 8 段)、噪声变多(167 个)——半径小了,很多点凑不够 min_samples 个邻居。

  6. 给 50 万条 SFT 数据做多样性检查,\(k\) 取 200 而不是”真实簇数”,为什么可以?

    答案

    目的是让人每簇抽几条看有什么、按簇配比,不需要划分”正确”;\(k\) 大一点只是把大主题拆细,不影响这三个用法。

下一篇

聚类回答”有哪些堆”,下一篇回答另一个问题:896 维的句向量怎么”看”?降维——PCA 找方差最大的方向、它与 SVD 的关系、解释方差告诉你数据的有效维度;t-SNE / UMAP 画出好看的二维图;以及本文那批句向量里藏着的一个陷阱——任意两句的余弦相似度都在 0.5 以上。

  1. 分配步把每个点换到更近的中心、更新步把中心移到均值(一堆点到某点的距离平方和在均值处最小),两步都不让簇内平方和变大,单调有界所以一定收敛。但收敛到的是局部最优:随机初始化 50 次里约 1/3 停在差解、最差是最优的两倍(16541 vs 7281);k-means++ 让初始中心散开,再 n_init 多跑几次取最好。详见第二章、第四章。 ↩

  2. 文本过 embedding 模型(最后一层隐状态 mean pooling、归一化)→ K-Means → 每簇抽几条人读:簇的内容就是主题、大小就是占比、模板页自成一簇。78 句真实语料 \(k = 7\) 主题全对。\(k\) 用肘部法或轮廓系数,两者不一致按业务定;数据工程里 \(k\) 取 50–200——聚类在这里是探索工具,不需要「正确」。详见第三章、第七章。 ↩

这篇对你有用?

本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/unsupervised-learning-kmeans-pca-and-embedding-clusters.html)的前提下,欢迎各种形式的转载、翻译或商业引用。


COMMENTS

评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。

×