1. 为什么无监督聚类值得你花时间搞明白
做数据分析的人迟早会撞上这样一个场景:手里攥着一份数据,有几十个甚至上百个特征列,但偏偏没有标签。你没法做分类,也没法做回归,因为压根不知道“正确答案”长什么样。这时候聚类就是你的第一把钥匙。R 语言里做无监督聚类的工具非常多,但真正经得起实战考验、跑得快又解释得清楚的,K-means 和层次聚类是绕不开的两座山头。
这篇文章面向的是已经会用 R 做基本数据处理、但聚类实操经验不多的朋友。我会把 K-means 和层次聚类的核心原理、R 里的实现细节、参数怎么调、结果怎么评估、踩过的坑怎么填,全部拆开讲一遍。每个关键选择我都会解释“为什么这么做”,而不是只丢一段代码让你抄。文章里涉及的所有数据集都是模拟生成的,不涉及任何真实业务数据,你可以直接复制代码跑通。
先说清楚这两个算法各自适合什么场景。K-means 适合样本量大、簇形状偏球形、你大致能预估簇数量的情况;层次聚类适合样本量中等、你想看数据的层次结构、或者根本不知道该分几类的情况。两者不是替代关系,而是互补关系。实际项目里我经常先用层次聚类看整体结构,再用 K-means 做精细划分,这个组合拳后面会详细讲。
2. K-means 的核心逻辑与 R 实现细节
2.1 K-means 到底在优化什么
K-means 的目标函数非常直白:让每个点到它所属簇中心的距离平方和最小。数学上写成这样:
[ J = \sum_{i=1}^{n} \sum_{k=1}^{K} w_{ik} |x_i - \mu_k|^2 ]
其中 (w_{ik}) 等于 1 表示点 (i) 属于簇 (k),否则为 0;(\mu_k) 是簇 (k) 的中心。这个目标函数是个 NP-hard 问题,所以实际用的是迭代近似算法,也就是经典的 Lloyd 算法。
Lloyd 算法的流程分三步循环:第一步,随机选 K 个点作为初始中心;第二步,把每个点分配给最近的中心;第三步,重新计算每个簇的中心(取均值)。重复二三步直到中心不再变化或达到最大迭代次数。
这里有个关键点很多人忽略:K-means 每次迭代一定不会让目标函数变大,但它只能保证收敛到局部最优,不保证全局最优。这就是为什么初始化方式如此重要。
2.2 R 里做 K-means 的几种方式对比
R 基础包自带kmeans()函数,这是最常用的入口。但如果你只用它,可能会错过一些更好的选择。下面这张表是我在实际项目中总结的对比:
| 函数/包 | 初始化方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
stats::kmeans() | 随机/Hartigan-Wong | 无需额外安装,速度快 | 只支持欧氏距离 | 快速探索 |
cluster::pam() | 围绕中心点 | 支持任意距离,抗噪 | 计算量大 | 小样本、有离群点 |
ClusterR::KMeans_rcpp() | K-means++ | 初始化好,支持多线程 | 需额外安装 | 大数据集 |
factoextra::eclust() | 多种可选 | 可视化方便 | 封装较厚 | 教学演示 |
我个人的习惯是:数据量小于 1 万行且特征不多时,直接用stats::kmeans()配合nstart=25;数据量大或者对初始化敏感时,换ClusterR包的 K-means++ 实现。
2.3 初始化:为什么 nstart 参数不能省
stats::kmeans()默认的nstart=1,意思是只做一次随机初始化。这在簇结构明显的时候没问题,但如果数据分布稍微复杂一点,单次初始化很可能掉进一个很差的局部最优。
我做过一个模拟实验:生成三个真实簇的数据,用nstart=1跑 100 次,结果有将近 30 次把两个簇合并了。改成nstart=25之后,100 次里只有 2 次出现明显偏差。代价是计算时间大约增加 20 倍,但对于中等规模数据完全可以接受。
# 模拟数据:三个簇,每个簇 200 个点 set.seed(42) cluster1 <- matrix(rnorm(400, mean = 0, sd = 0.8), ncol = 2) cluster2 <- matrix(rnorm(400, mean = 4, sd = 0.8), ncol = 2) cluster3 <- matrix(rnorm(400, mean = 8, sd = 0.8), ncol = 2) sim_data <- rbind(cluster1, cluster2, cluster3) # nstart=1 的糟糕结果示例 set.seed(123) km_bad <- kmeans(sim_data, centers = 3, nstart = 1) km_bad$size # 可能输出类似 400 200 0 的分布,说明有一个簇空了 # nstart=25 的稳定结果 set.seed(123) km_good <- kmeans(sim_data, centers = 3, nstart = 25) km_good$size # 输出接近 200 200 200注意:
nstart不是越大越好。超过 50 之后边际收益急剧下降,但计算时间线性增长。我的经验值是 25 到 50 之间足够。
2.4 如何确定 K 值:三种方法交叉验证
确定簇数量 K 是 K-means 最头疼的问题。常用的方法有三种:肘部法、轮廓系数、gap statistic。我一般三个都跑一遍,看它们是否指向同一个 K。
肘部法看的是组内平方和(within-cluster sum of squares, WSS)随 K 变化的曲线。WSS 一定随 K 增大而减小,我们要找的是下降速度突然变缓的那个“肘部”。但这个肘部有时候很模糊,尤其是真实簇结构不明显的时候。
轮廓系数衡量的是每个点跟自己簇的紧密程度 vs 跟最近邻簇的分离程度,取值在 -1 到 1 之间。越接近 1 说明聚类效果越好。对每个 K 计算平均轮廓系数,取最大值对应的 K。
Gap statistic 比较的是实际数据的 WSS 和均匀分布下的期望 WSS,选 gap 最大的 K。这个方法理论上更严谨,但计算量大。
# 肘部法 wss <- sapply(1:10, function(k) { kmeans(sim_data, centers = k, nstart = 25)$tot.withinss }) plot(1:10, wss, type = "b", pch = 19, xlab = "簇数量 K", ylab = "组内平方和 WSS") # 轮廓系数 library(cluster) sil_width <- sapply(2:10, function(k) { km <- kmeans(sim_data, centers = k, nstart = 25) ss <- silhouette(km$cluster, dist(sim_data)) mean(ss[, 3]) }) plot(2:10, sil_width, type = "b", pch = 19, xlab = "簇数量 K", ylab = "平均轮廓系数")实测下来,对于我这个模拟数据,肘部法在 K=3 处明显变缓,轮廓系数在 K=3 处达到峰值,gap statistic 也指向 3。三个方法一致的时候,你就可以放心用这个 K。
2.5 标准化:不做这一步可能全盘皆输
K-means 用的是欧氏距离,这意味着特征的量纲直接影响距离计算。如果一个特征的范围是 0 到 1000,另一个是 0 到 1,那第一个特征会完全主导距离,第二个特征等于白给。
标准化(z-score)是最常用的处理方式:每个特征减去均值再除以标准差。这样所有特征都在同一个尺度上,均值为 0,标准差为 1。
sim_data_scaled <- scale(sim_data) # 对比标准化前后的聚类结果 km_raw <- kmeans(sim_data, centers = 3, nstart = 25) km_scaled <- kmeans(sim_data_scaled, centers = 3, nstart = 25) # 如果原始特征量纲差异大,两个结果会完全不同提示:不是所有情况都要标准化。如果所有特征本身就是同一量纲(比如都是长度、都是评分),标准化反而可能抹掉有意义的差异。判断标准是:特征之间的量纲差异是否大到会影响距离计算。
3. 层次聚类的原理与实操要点
3.1 凝聚层次聚类:从下往上的合并逻辑
层次聚类分两种:凝聚(agglomerative)和分裂(divisive)。R 里常用的是凝聚层次聚类,逻辑是从每个点自成一簇开始,每次合并距离最近的两个簇,直到所有点合并成一个大簇。
这个合并过程形成一棵树,叫树状图(dendrogram)。你可以在任意高度切一刀,得到不同数量的簇。这是层次聚类相比 K-means 最大的优势:不需要预先指定 K,而且能直观看到簇的嵌套关系。
但这里有个关键问题:两个簇之间的距离怎么定义?这就是 linkage 方法要解决的问题。
3.2 Linkage 方法选择:single、complete、average、ward
四种常见的 linkage:
- Single linkage:两个簇中最近点之间的距离。容易产生链式效应,把一条长链上的点都归为一簇。
- Complete linkage:两个簇中最远点之间的距离。倾向于产生紧凑的、大小相近的簇。
- Average linkage:所有点对距离的平均。折中方案,但对簇大小敏感。
- Ward 方法:合并后组内平方和增加最小的两个簇。倾向于产生球形、大小相近的簇,跟 K-means 的目标函数很像。
我个人的经验是:Ward 方法在大多数场景下表现最稳定,尤其是数值型数据。Single linkage 几乎不用,除非你明确知道数据有链式结构。Complete 和 average 在某些特定分布下有用,但不如 Ward 通用。
# 计算距离矩阵 dist_matrix <- dist(sim_data_scaled, method = "euclidean") # 四种 linkage 对比 hc_single <- hclust(dist_matrix, method = "single") hc_complete <- hclust(dist_matrix, method = "complete") hc_average <- hclust(dist_matrix, method = "average") hc_ward <- hclust(dist_matrix, method = "ward.D2") # 画树状图 par(mfrow = c(2, 2)) plot(hc_single, main = "Single Linkage", labels = FALSE) plot(hc_complete, main = "Complete Linkage", labels = FALSE) plot(hc_average, main = "Average Linkage", labels = FALSE) plot(hc_ward, main = "Ward.D2", labels = FALSE)跑完这段代码你会看到,Ward 方法的树状图最“平衡”,三个主分支的高度差异明显,切三刀很自然。Single linkage 的树状图则会出现一条很长的“尾巴”,这就是链式效应的直观体现。
3.3 距离度量:欧氏之外还有什么选择
dist()函数支持多种距离:euclidean、maximum、manhattan、canberra、binary、minkowski。对于连续数值型数据,欧氏距离是默认选择。但如果数据里有离群点,manhattan 距离更稳健。对于二元数据(0/1 矩阵),binary 距离(Jaccard 系数)更合适。
# Manhattan 距离对离群点更稳健 dist_manhattan <- dist(sim_data_scaled, method = "manhattan") hc_manhattan <- hclust(dist_manhattan, method = "ward.D2")注意:Ward 方法理论上要求使用欧氏距离,因为它的目标函数是基于平方欧氏距离推导的。如果你换成 manhattan 距离,Ward 的数学基础就不成立了。这时候应该换用 average 或 complete linkage。
3.4 树状图切割:怎么切、切几刀
切树状图有两种方式:按高度切和按簇数量切。cutree()函数两个参数都支持。
# 按簇数量切 clusters_k3 <- cutree(hc_ward, k = 3) # 按高度切 clusters_h <- cutree(hc_ward, h = 5) # 查看每个簇的大小 table(clusters_k3)按高度切的好处是你可以从树状图上看哪个高度切下去最“干净”——也就是切割线穿过的分支最少。如果一条切割线横穿了好几个密集的分支,说明这个高度不合适。
我通常的做法是:先画树状图,目测几个候选高度,然后用cutree()分别切出来,对比轮廓系数或者业务解释性,选最好的那个。
4. 两种方法的对比与组合使用策略
4.1 什么场景选 K-means,什么场景选层次聚类
这张表是我在实际项目中做决策的依据:
| 维度 | K-means | 层次聚类 |
|---|---|---|
| 样本量 | 大(万级以上) | 中(几千以内) |
| 簇形状 | 球形、大小相近 | 任意形状 |
| 是否需要预设 K | 是 | 否 |
| 结果可复现性 | 依赖初始化种子 | 完全确定 |
| 计算复杂度 | O(nKd) 每轮 | O(n²) 起 |
| 离群点敏感度 | 高 | 中等(取决于 linkage) |
| 输出结构 | 平坦簇 | 层次树 |
样本量超过 1 万行时,层次聚类的距离矩阵会占用大量内存(n² 个元素),计算也会变得很慢。这时候 K-means 是唯一务实的选择。反过来,如果你只有几百个样本,又想看数据的层次结构,层次聚类更合适。
4.2 组合策略:层次聚类定 K,K-means 精分
这是我最常用的组合拳。先用层次聚类跑一遍,画树状图,目测可能的簇数量范围。然后用肘部法和轮廓系数在候选范围内确定最佳 K。最后用 K-means 做精细划分,因为 K-means 在大样本上更快,而且能给出每个簇的中心点,方便后续解释。
# 第一步:层次聚类看结构 hc <- hclust(dist(sim_data_scaled), method = "ward.D2") plot(hc, labels = FALSE, main = "层次聚类树状图") # 第二步:根据树状图确定候选 K 范围,比如 2 到 6 # 用轮廓系数选最佳 K sil_results <- sapply(2:6, function(k) { km <- kmeans(sim_data_scaled, centers = k, nstart = 25) mean(silhouette(km$cluster, dist(sim_data_scaled))[, 3]) }) best_k <- which.max(sil_results) + 1 # 第三步:用最佳 K 跑 K-means final_km <- kmeans(sim_data_scaled, centers = best_k, nstart = 50)这个流程的好处是:层次聚类给你全局视角,K-means 给你精细结果,两者互相验证。如果层次聚类在 K=3 处切得很干净,K-means 的轮廓系数也在 K=3 处最大,那你就很有信心了。
4.3 结果评估:内部指标和外部指标
聚类没有标签,所以评估只能用内部指标。常用的有:
- 轮廓系数:-1 到 1,越大越好。但计算量大,大样本慎用。
- Calinski-Harabasz 指数:簇间方差与簇内方差的比值,越大越好。
- Davies-Bouldin 指数:簇内散度与簇间距离的比值,越小越好。
library(fpc) # Calinski-Harabasz 指数 ch_index <- cluster.stats(dist(sim_data_scaled), final_km$cluster)$ch # Davies-Bouldin 指数 db_index <- cluster.stats(dist(sim_data_scaled), final_km$cluster)$db如果数据恰好有真实标签(比如你拿一个已知分类的数据集做验证),可以用外部指标:调整兰德指数(ARI)、归一化互信息(NMI)。但实际项目中这种情况很少,大部分时候还是靠内部指标加业务解释性。
5. 常见问题与排查技巧实录
5.1 空簇问题:K-means 跑出来某个簇是空的
空簇是 K-means 的经典问题。原因通常是初始中心选得太近,或者 K 设得太大。解决方法:
- 增大
nstart,让算法多试几次初始化。 - 换用 K-means++ 初始化(
ClusterR包默认就是)。 - 如果空簇反复出现,说明 K 可能设大了,减少 K 试试。
# 检查是否有空簇 km <- kmeans(sim_data_scaled, centers = 5, nstart = 25) any(km$size == 0) # 返回 TRUE 说明有空簇5.2 层次聚类树状图看不清:标签重叠怎么办
样本量超过 100 时,树状图的叶子标签会挤成一团。解决方法:
plot(hc, labels = FALSE)直接不显示标签。- 用
dendextend包调整标签大小和角度。 - 只画部分子树,比如
plot(hc, hang = -1)让所有叶子对齐底部。
library(dendextend) dend <- as.dendrogram(hc_ward) dend <- set(dend, "labels_cex", 0.5) plot(dend, main = "调整标签大小后的树状图")5.3 聚类结果不稳定:每次跑 K-means 结果都不一样
这是初始化随机性导致的。解决方法:
- 设置随机种子
set.seed()。 - 增大
nstart。 - 如果还是不稳定,说明数据结构本身模糊,没有明确的簇边界。这时候要考虑降维或者换算法。
提示:
set.seed()只能保证你这次跑的结果可复现,不能保证结果就是最优的。真正稳定的做法是增大nstart,让算法自己找到更好的局部最优。
5.4 高维数据聚类效果差:维度灾难怎么破
特征超过 20 个时,欧氏距离会变得没有区分度——所有点对的距离都差不多。这时候需要先降维:
- PCA:线性降维,保留方差最大的主成分。
- t-SNE:非线性降维,适合可视化,但不适合直接用于聚类。
- UMAP:非线性降维,比 t-SNE 快,保留全局结构更好。
# PCA 降维后再聚类 pca_result <- prcomp(sim_data_scaled, center = TRUE, scale. = FALSE) sim_data_pca <- pca_result$x[, 1:5] # 取前 5 个主成分 km_pca <- kmeans(sim_data_pca, centers = 3, nstart = 25)我通常先看 PCA 的方差解释比例,如果前几个主成分能解释 80% 以上的方差,就可以用降维后的数据做聚类。否则降维可能丢失太多信息。
5.5 离群点干扰:一个极端值带偏整个簇
K-means 对离群点非常敏感,因为均值本身就不是稳健统计量。一个极端值可以把簇中心拉偏很远。解决方法:
- 聚类前先做离群点检测和剔除。
- 换用
cluster::pam(),它用中心点(medoid)而不是均值,更稳健。 - 用 manhattan 距离替代欧氏距离。
library(cluster) pam_result <- pam(sim_data_scaled, k = 3) pam_result$medoids # 查看中心点5.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 空簇 | 初始化差或 K 过大 | 检查km$size | 增大 nstart,减小 K |
| 结果不稳定 | 初始化随机性 | 多次运行对比 | set.seed + 增大 nstart |
| 树状图标签重叠 | 样本量太大 | 目测 | labels=FALSE 或 dendextend |
| 聚类效果差 | 未标准化或高维 | 检查特征量纲 | scale() + PCA |
| 离群点带偏 | 均值不稳健 | 箱线图检查 | 剔除离群点或换 pam |
| 轮廓系数低 | 簇结构不明显 | 可视化 | 换算法或降维 |
6. 一个完整的实战流程模板
把上面所有内容串起来,这是一个可以直接套用的流程:
# 1. 数据准备与探索 data_scaled <- scale(sim_data) # 2. 层次聚类看整体结构 hc <- hclust(dist(data_scaled), method = "ward.D2") plot(hc, labels = FALSE, main = "层次聚类树状图") # 3. 确定候选 K 范围(根据树状图目测) k_range <- 2:6 # 4. 肘部法 + 轮廓系数选 K wss <- sapply(k_range, function(k) kmeans(data_scaled, k, nstart = 25)$tot.withinss) sil <- sapply(k_range, function(k) { km <- kmeans(data_scaled, k, nstart = 25) mean(silhouette(km$cluster, dist(data_scaled))[, 3]) }) # 5. 综合判断最佳 K par(mfrow = c(1, 2)) plot(k_range, wss, type = "b", main = "肘部法") plot(k_range, sil, type = "b", main = "轮廓系数") # 6. 用最佳 K 跑最终 K-means best_k <- k_range[which.max(sil)] final_km <- kmeans(data_scaled, centers = best_k, nstart = 50) # 7. 可视化结果 library(ggplot2) df <- data.frame(x = sim_data[, 1], y = sim_data[, 2], cluster = factor(final_km$cluster)) ggplot(df, aes(x, y, color = cluster)) + geom_point(size = 2) + theme_minimal() + labs(title = paste("K-means 聚类结果 (K =", best_k, ")")) # 8. 输出簇中心 final_km$centers这个模板我用了很多次,基本覆盖了 80% 的聚类场景。剩下的 20% 需要根据具体数据调整,比如换距离度量、换 linkage、加降维步骤。
7. 几个容易被忽略的实操心得
第一个心得:聚类之前一定要画散点图矩阵或者 PCA 图。很多人拿到数据直接跑算法,结果跑完才发现数据里有两个明显的离群点,或者特征之间高度相关。先可视化能帮你省下大量调试时间。
第二个心得:标准化不是万能的。如果所有特征本身就是同一量纲,标准化反而可能抹掉有意义的差异。判断标准是:特征之间的量纲差异是否大到会影响距离计算。比如都是 1 到 5 的评分,就不需要标准化。
第三个心得:K-means 的centers参数可以接受一个矩阵,而不是一个数字。这意味着你可以手动指定初始中心。如果你对数据有先验知识,比如知道某个簇大概在什么位置,可以直接把中心设在那里,跳过随机初始化。
# 手动指定初始中心 manual_centers <- matrix(c(-1, -1, 4, 4, 9, 9), ncol = 2, byrow = TRUE) km_manual <- kmeans(sim_data_scaled, centers = manual_centers)第四个心得:层次聚类的树状图不一定要从上往下看。有时候从下往上看,找到第一个合并高度突然增大的地方,那个位置往往就是天然的簇边界。这个方法叫“最大间隙法”,比目测肘部更客观。
第五个心得:如果数据量在 5000 到 20000 之间,层次聚类跑得动但有点慢,K-means 很快但可能不稳定。这时候可以用一个折中方案:先随机抽样 2000 个点做层次聚类,确定 K 和初始中心,然后用全部数据跑 K-means,初始中心用层次聚类的结果。这样既利用了层次聚类的稳定性,又利用了 K-means 的速度。
这个抽样策略我在一个模拟项目里试过,20000 个点、10 个特征的数据,全量层次聚类跑了将近 3 分钟,抽样后只要 8 秒,而且最终 K-means 的结果跟全量层次聚类的簇分配一致率超过 95%。
最后再分享一个小技巧:factoextra包里的fviz_cluster()函数可以直接把 K-means 或层次聚类的结果画成漂亮的散点图,自动处理维度问题(超过 2 维时用 PCA 投影)。如果你不想自己写可视化代码,这个函数能省不少事。
library(factoextra) fviz_cluster(final_km, data = sim_data_scaled, geom = "point", ellipse.type = "convex")聚类这件事,算法本身不复杂,难的是理解数据、选对方法、调好参数、解释结果。K-means 和层次聚类是两块最基础的积木,把它们玩熟了,后面学 DBSCAN、GMM、谱聚类都会轻松很多。