☰
K-means vs DBSCAN:无监督聚类算法选型与Python实战
2026/10/1 4:46:09 网站建设 项目流程

1. 从业务需求说起:什么时候需要无监督聚类

1.1 没有标签的数据,怎么找出结构

做数据分析或者机器学习项目,前期最头疼的问题通常不是模型选哪个,而是手里拿到的数据到底长什么样。很多场景下数据是没有标签的:一批用户的行为记录、一堆社区服务需求问卷、一批地理坐标点、一组商品的购买流水,没有人告诉你这些样本应该分成几类,每一类叫什么名字。这时候无监督学习就派上了用场,而聚类是无监督学习里最直观、最常用的一类方法。

聚类的目标说白了就一句话:把相似的样本归到一起,让同一个簇内的样本尽可能相似,不同簇之间的样本差异尽可能明显。这个思想非常朴素,但落到实际数据上,就会牵扯出一堆问题:相似怎么度量?距离用欧式还是余弦?分成几个簇合适?有些簇是圆形的,有些是月牙形的,有些甚至中间是断开的,一种算法能不能全部搞定?这就要回到算法本身的建模假设上来。

K-means和DBSCAN是我在实际项目里用得最多的两种聚类算法,它们的思路截然不同:K-means假设簇是球形的、大小相近的,用质心来代表簇的中心;DBSCAN假设簇是密集区域被稀疏区域分隔开的,用密度的眼光来看数据。这两种假设决定了各自的适用场景和局限,也决定了你什么时候该用哪个。这篇文章就把这两条路线从头到尾讲透,配套完整可跑的Python代码,帮你看完就能在真实数据上动手。

1.2 K-means和DBSCAN各自擅长解决什么问题

先给一个粗略的判断标准,后面会展开细讲。

  • K-means适合数据量大、样本大致呈凸形分布、簇的数量可以提前估出来的场景。比如电商用户按消费频次和客单价做分群,只要数据清洗到位、量纲处理好了,K-means跑得又快又稳,结果也好解释。
  • DBSCAN适合簇形状不规则、存在大量离群点、事先不知道簇数量的场景。比如基于地理坐标的社区服务需求分类,网点分布可能是任意形状的,而且天然有很多噪声点,DBSCAN能在聚类的同时把这些噪声标出来。

光知道结论不够,下面我把两种算法的工作原理、参数背后的逻辑、Python实现细节和实战中的坑逐个拆开,你就能理解这个结论是怎么来的了。

2. K-means原理拆解:K值怎么定、距离怎么算、为什么必须标准化

2.1 K-means三步循环和目标函数

K-means的核心逻辑可以压缩成一个三步循环,每一步都不复杂,但组合起来就能完成聚类:

  1. 随机(或用k-means++策略)初始化K个质心。
  2. 分配:把每一个样本划分到距离最近的质心所在的簇。
  3. 更新:重新计算每个簇内所有样本的均值,把这个均值作为新的质心。

然后重复第2步和第3步,直到质心位置基本不再变化或者达到最大迭代次数。这个流程本质上是坐标下降法在优化一个目标函数,目标函数叫簇内平方和(WCSS),也叫inertia:

[ J = \sum_{i=1}^{K} \sum_{x\in C_i} | x - \mu_i |^2 ]

其中 (\mu_i) 是第 (i) 个簇的质心,(| x - \mu_i |) 是样本到质心的欧式距离。K-means每轮迭代都在尽力降低这个损失,直到收敛到局部最优。注意是局部最优,不是全局最优,这也就是为什么同一份数据用不同随机种子跑,结果可能不一样。

如果你接触过EM算法,会发现K-means和EM有很强的相似性:分配步骤相当于E步(估计每个样本属于哪个簇),更新质心相当于M步(用簇内样本重新估计质心参数)。理解了这层对应关系,后面再看高斯混合模型(GMM)之类的进阶方法就不会觉得突兀了。

2.2 K值选择:肘部法则和轮廓系数的配合使用

K-means最让人头疼的问题永远是:K到底取多少。实际项目里没人会告诉你应该分成几类,这个值基本都是试出来的。

我常用的方法是肘部法则加轮廓系数一起看。

肘部法则的做法是跑一组不同K值的模型,记录每个K对应的inertia,然后画折线图。随着K增大,inertia总是下降的,因为簇越多,每个样本离自己质心的距离就越近。关键看下降速度:K从1增加到某个值之前,inertia下降很快;再往后下降变缓,曲线会出现一个类似手肘的拐点,这个拐点对应的K就是候选值。

轮廓系数则是从"簇内紧密度"和"簇间分离度"两个维度评价聚类质量。每个样本的轮廓系数在-1到1之间,越接近1说明这个样本被分得越好。把全体样本的轮廓系数取平均,得到一个整体评分,取评分最高的K。轮廓系数的好处是不需要换K重新算质心,直接基于现有聚类结果计算即可,所以非常适合和肘部法则搭配验证。

我的实际习惯是:先用肘部法则圈定K的大致范围,再用轮廓系数在范围内细选,最后一定还要结合业务含义确认。比如做用户分群,K=4在指标上很漂亮,但是分出来的四个群业务上没法解释,这个K就不可用。指标永远是辅助,业务可解释性才是最终目标。

提示:K-means对K值很敏感,K选大了容易把一个大簇硬切成两半,K选小了又会把两个相近的簇强行合并,所以这一步不要省,也不要只看一个指标。

2.3 量纲不统一会毁掉距离计算

这一点我放在K-means原理里讲,是因为它直接决定算法成败,而且新手最容易忽略。

K-means用欧式距离衡量样本相似度,欧式距离对每一维特征的尺度都非常敏感。举个极端例子:用户数据里"年消费金额"的取值范围是0到数万,"购买次数"的取值范围是0到几十。如果不做任何处理直接算距离,消费金额这一维会完全主导距离计算,购买次数几乎变成摆设,聚类结果基本等效于只按消费金额排序分段,这是典型的错误用法。

标准做法是用StandardScaler做z-score标准化,把每个特征变成均值0、方差1:

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 标准化之后再去做聚类

有些场景也可以用MinMaxScaler把数据缩放到[0, 1]区间,具体看数据分布形态。如果特征里存在极严重的偏态,先做log变换再标准化效果通常更好。注意scaler一定要用拟合训练数据的参数去变换后续新数据,不要在全部数据上fit之后再拿同一批数据做评估,那样信息会泄露,影响对泛化能力的判断。

3. DBSCAN原理拆解:密度、邻域半径和MinPts怎么联动

3.1 核心点、边界点、噪声点的定义

DBSCAN的全称是Density-Based Spatial Clustering of Applications with Noise,密度聚类。它不关心质心,也不要求簇是凸的,而是换了一套完全不同的判定逻辑:如果某个区域样本足够稠密,就把它们连成一个簇,稀疏区域的样本直接标记为噪声。

具体判定依赖两个参数:邻域半径eps和最小样本数min_samples(通常也写作MinPts)。基于这两个参数,DBSCAN把每个样本分成三类:

  • 核心点:以该点为圆心、eps为半径的邻域内,样本数量大于等于min_samples。核心点是簇的骨架。
  • 边界点:不是核心点,但落在某个核心点的eps邻域内。边界点属于某个簇,但自身密度不够。
  • 噪声点:既不是核心点,也不在任何一个核心点的邻域内,标记为-1。

聚类过程可以想象成"人传人"的扩展过程:从一个核心点出发,把它eps范围内的所有样本拉进来,这里面如果有其他核心点,就继续扩展它们的邻域,直到没有新的核心点为止。这个扩展过程不要求簇是圆形,只要是密度相连的区域,哪怕绕成一条蛇形也能被归为同一个簇。

3.2 eps和min_samples的调参方法:KNN距离曲线

DBSCAN调参比K-means更考验经验,尤其是eps,它对结果的影响极大。eps太大,所有点都可能被连成一个簇;eps太小,一个完整的簇会被拆成碎片,大量样本变成噪声。

一个相对靠谱的确定eps方法是用KNN距离曲线。思路是:先取min_samples为K,然后计算每个样本到它第K个最近邻居的距离,把这些距离排序画一条曲线。曲线在某个位置出现明显拐点的距离值,就是合适的eps。因为曲线比较陡峭处说明样本密度变化剧烈,超过这个距离后,邻居数量会快速增长,自然形成簇的边界。

import numpy as np from sklearn.neighbors import NearestNeighbors # 假设 X_scaled 是标准化后的数据 min_samples = 5 nn = NearestNeighbors(n_neighbors=min_samples) nn.fit(X_scaled) distances, _ = nn.kneighbors(X_scaled) # 每个样本到第 min_samples 个最近邻居的距离,排序后画曲线 kneighbor_dist = np.sort(distances[:, -1]) import matplotlib.pyplot as plt plt.plot(kneighbor_dist) plt.xlabel('样本序号(按距离排序)') plt.ylabel('第 %d 个最近邻居的距离' % min_samples) plt.show()

min_samples的选择相对简单一些,经验值一般取特征维度的两倍,或者按数据量级取5到10。min_samples越大,聚类的"抗噪性"越强,但容易把本来密度不高的小簇直接抹掉。实际使用中我习惯先定min_samples,再去KNN曲线里找eps,然后少量调整,反复看聚类结果的质量。

3.3 密度的视角能处理什么、怕什么

DBSCAN最大的优势是能处理任意形状的簇,以及自动识别噪声点。月牙形、环形的数据在K-means那里会很难看,但DBSCAN可以把它们沿着密度连通性完整地连起来。这在真实项目里非常实用,因为真实数据的簇很少是完美的球形。

不过DBSCAN也有两个明显的软肋。

第一,它对参数太敏感。同一份数据,eps差个0.05,结果可能从"一个簇加一堆噪声"变成"四个簇"。这种脆性意味着你需要对数据分布有比较好的先验认识,否则调参会变成玄学。

第二,它对密度差异很大的数据不太友好。如果数据集中同时存在一个非常稠密的簇和一个相对稀疏但也是真实簇的区域,DBSCAN的单一全局eps很难同时照顾两边。eps取大了,稠密簇会被合并;eps取小了,稀疏簇全变成噪声。这种情况下可以考虑OPTICS算法,或者先把数据分区域再分别聚类。

4. Python全流程实现:从环境准备到聚类落地

4.1 环境准备:虚拟环境与依赖安装

动手跑代码之前,先把环境整理干净。我强烈建议不要直接往系统Python里pip install一堆包,不同项目的依赖版本很可能会冲突。用内置的venv就能解决问题:

# 创建虚拟环境 python -m venv cluster_env # 激活 # Linux / macOS source cluster_env/bin/activate # Windows cluster_env\Scripts\activate # 安装依赖 pip install numpy pandas scikit-learn matplotlib

安装完成后建议顺手检查一下版本,scikit-learn版本差异偶尔会造成部分API行为变化,尤其注意新版本对部分参数弃用的警告:

import sklearn print(sklearn.__version__) # scikit-learn >= 1.0 在本文涉及的功能上都正常

如果你用的是Anaconda之类的发行版,也可以用conda create -n cluster_env python=3.10来创建环境,原理一样。环境这步看起来无关紧要,但能帮你省掉大量后面莫名其妙的报错时间。

4.2 构造和预处理数据集

为了把两种算法的差异展示清楚,我用两类经典数据集:一类是球形簇(make_blobs),一类是月牙形簇(make_moons),后者专门用来让K-means"翻车"。

import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_blobs, make_moons from sklearn.preprocessing import StandardScaler # 数据集1:球形簇 X_blob, y_blob = make_blobs(n_samples=800, centers=4, cluster_std=0.8, random_state=42) # 数据集2:月牙形簇,加少量噪声 X_moon, y_moon = make_moons(n_samples=500, noise=0.05, random_state=42)

虽然是"无监督"任务,make_blobs里的y_blob其实是有标签的,我们用它只是为了在评估聚类效果时做对照,不是给算法偷看答案。实际项目中也可以用类似思路:先用有标签的历史数据验证聚类算法表现,再应用到真正的无标签数据上。

预处理环节要做的两件事:一是标准化,前面已经讲过原因;二是可视化检查,聚类前先把数据分布图画出,如果发现特征有明显的偏态分布,考虑先变换。下面统一生成标准化版本:

scaler_blob = StandardScaler() X_blob_scaled = scaler_blob.fit_transform(X_blob) scaler_moon = StandardScaler() X_moon_scaled = scaler_moon.fit_transform(X_moon)

4.3 K-means完整实现:包括最优K的确认

先实现一个函数来对不同K值跑K-means,同时计算inertia和轮廓系数:

from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score def evaluate_kmeans(X, k_range=range(2, 11)): inertia_list = [] silhouette_list = [] models = {} for k in k_range: model = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42) model.fit(X) models[k] = model inertia_list.append(model.inertia_) silhouette_list.append(silhouette_score(X, model.labels_)) return inertia_list, silhouette_list, models inertia_blob, sil_blob, models_blob = evaluate_kmeans(X_blob_scaled) # 画出肘部曲线和轮廓系数曲线 fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].plot(range(2, 11), inertia_blob, marker='o') axes[0].set_title('肘部法则 - Inertia') axes[1].plot(range(2, 11), sil_blob, marker='o') axes[1].set_title('轮廓系数') plt.show()

跑完可以明显看到,球形数据在K=4附近出现拐点,轮廓系数也在K=4时达到峰值,符合make_blobs的真实结构。拿到最优K之后就可以拟合最终模型,并把聚类结果和真实标签做对照可视化:

best_k = 4 final_kmeans = models_blob[best_k] plt.scatter(X_blob_scaled[:, 0], X_blob_scaled[:, 1], c=final_kmeans.labels_, cmap='viridis', alpha=0.7) plt.scatter(final_kmeans.cluster_centers_[:, 0], final_kmeans.cluster_centers_[:, 1], c='red', marker='x', s=100, label='质心') plt.legend() plt.show()

同样的代码换到make_moons数据上,你就发现问题了:K-means无论如何调整K值,都不可能把两个月牙完整分开,它会部分把一侧切成两段,然后把两片区域硬凑在一起。这不是实现细节的问题,而是K-means的模型假设决定的,后面我会在选型部分细说。

4.4 DBSCAN完整实现与调参可视化

DBSCAN的代码本身很简洁,难点全在参数上:

from sklearn.cluster import DBSCAN def fit_dbscan(X, eps, min_samples): model = DBSCAN(eps=eps, min_samples=min_samples) model.fit(X) return model # 先用KNN距离曲线确定eps的候选值 min_samples_candidate = 5 nn = NearestNeighbors(n_neighbors=min_samples_candidate) nn.fit(X_moon_scaled) distances, _ = nn.kneighbors(X_moon_scaled) kneighbor_dist = np.sort(distances[:, -1]) plt.plot(kneighbor_dist) plt.xlabel('样本序号') plt.ylabel('%d-NN距离' % min_samples_candidate) plt.show()

从曲线上找到一个明显拐点的位置,比如这里大概是0.25到0.3附近。然后跑DBSCAN:

dbscan_moon = fit_dbscan(X_moon_scaled, eps=0.28, min_samples=5) n_clusters = len(set(dbscan_moon.labels_)) - (1 if -1 in dbscan_moon.labels_ else 0) n_noise = list(dbscan_moon.labels_).count(-1) print(f'簇数量: {n_clusters}, 噪声点数量: {n_noise}') plt.scatter(X_moon_scaled[:, 0], X_moon_scaled[:, 1], c=dbscan_moon.labels_, cmap='viridis', alpha=0.7) plt.show()

你会发现DBSCAN很好地认出了两个月牙簇,同时把少数远离簇主体的点标成了噪声(颜色与其他簇都不同,标签为-1)。同样在make_blobs上,DBSCAN也能识别出4个球形簇,效果与K-means相当,这印证了DBSCAN在常规簇形状上并不会输给K-means,只是参数需要多花些心思来调。

4.5 用轮廓系数评估聚类结果,注意它不能当唯一标准

无论跑K-means还是DBSCAN,我建议都用silhouette_score量化评估一下:

from sklearn.metrics import silhouette_score # 排除噪声点做轮廓系数评估 mask = dbscan_moon.labels_ != -1 # 轮廓系数不支持噪声标签 score = silhouette_score(X_moon_scaled[mask], dbscan_moon.labels_[mask]) print('DBSCAN在月牙数据上的轮廓系数:', score)

要注意一个细节:轮廓系数要求每个样本都有簇标签,DBSCAN的噪声点(-1)需要先剔除再计算,否则会报错或者给出误导性的低分。即便算出来分数不错,也不要只看这一个指标。真实场景里还要看簇内样本数量是否合理、业务含义是否清晰、聚类结果是否稳定等,综合判断才能下结论。

5. 实战踩坑记录与算法选型建议

5.1 K-means的翻车现场:异常值、初始化和簇形状

我实际项目里第一次用K-means做客户分群,结果惨不忍睹。数据里有几个消费金额特别高的异常客户,K-means硬是给它们单独分了一个簇,其他几千个用户被压缩到两个大簇里,业务上完全没法用。原因就是K-means对异常值极其敏感,异常样本在线性距离计算中会被强烈吸引到某个质心里,甚至直接把质心"拽走"。

解决办法分几个层次:数据预处理阶段先用IQR或业务规则把明显异常值剔除;或者改用对异常值更鲁棒的聚类方法;如果因为有特殊价值的群体不想删除它们,可以专门设置一个"高端客户"类别,不在整体聚类里处理。

另外两个K-means的常见问题也一并总结:

  • 初始质心选择:虽然k-means++已经大幅改善了随机初始化的问题,但极端情况下仍然可能陷入局部最优。我用n_init=10到20做多次初始化,然后取inertia最小的结果,稳定很多。
  • 簇形状失配:K-means假设每个簇是Voronoi划分出来的凸区域,实际遇到月牙、环形、狭长带状的分布就直接失灵,别硬撑,换密度聚类。

5.2 DBSCAN的翻车现场:eps的脆弱性和"连锁合并"

DBSCAN最典型的坑是对eps的微小变化反应剧烈。我曾经在一个POI聚类项目里,eps从0.3调到0.35,结果簇数量从5个直接变成12个,因为超过某个密度阈值后,一个本来被稀疏区隔开的簇被"连锁反应"般连到了一起,多个簇被误合并。

自那以后我总结了一套相对可靠的调参流程:

  1. 先用KNN距离曲线圈定eps的大致区间;
  2. 在这个区间内按0.05步长跑网格搜索;
  3. 用"簇数量+噪声比例+业务可解释性"三个维度综合评分,选一个最稳定的区域,而不是只挑单点最优。

比如只要eps在0.25到0.30之间结果都稳定在指定簇数,说明这个参数区域是可信的;如果eps稍微一变结果就剧烈抖动,说明数据本身的簇结构不清晰,这时候无论怎么调参数都很难得到稳定的业务结论,应该回头检查特征工程。

DBSCAN在高维数据上的表现也更差。当特征维度变高,距离度量会趋于钝化,样本间距离差异变小,eps很难找到有效分界。这种情况下我通常先用PCA把维度压到两到三维,或者用UMAP降维后再聚类,效果往往会有明显改善。

5.3 选型判断流程:一张表看懂什么时候用哪个

为了让选型更直观,我把自己常用的判断路径整理成了表格,方便大家保存参考:

判断维度选K-means选DBSCAN
簇的形状凸形、球形、大小相近任意形状(月牙、环状、长条)
异常点/噪声需要提前清洗,算法本身不识别天然识别为-1,不需要预处理剔除
簇的数量需要事先确定或用肘部法/轮廓系数估计不用预设,由密度自动决定
数据量百万级照样很快(复杂度近似O(n))不使用空间索引时偏慢(接近O(n^2))
高维数据距离钝化问题较轻,仍能凑合跑严重依赖密度,高维下eps难定义
参数可解释性只有K,方便向业务方解释eps+min_samples,业务方理解成本高

这个表格不能当死规则用,但它覆盖了我踩坑经验的80%。简单说:

  • 不确定簇数量,或者数据里有大量噪声,优先DBSCAN;
  • 数据量非常大、簇的形状又比较规整,优先K-means,速度优势明显;
  • 如果业务更关注"哪些点算正常、哪些点是异常",DBSCAN天然提供这个视角;
  • 如果业务需要把每个簇解释成一个"典型用户画像",K-means的质心直接给你一组特征均值,解释起来很顺手。

5.4 我在真实项目里的组合用法

最后分享一个比较进阶的组合思路,也是我最近几个项目里用得比较多的流程:先用DBSCAN把数据里的噪声点识别并剔除,得到一个相对干净的样本集;再用K-means对剔除噪声后的数据做细分。

这个流程的好处在于,两类算法互补。DBSCAN擅长处理噪声和任意形状,但它的簇结果通常比较粗糙,直接拿它的簇作为最终分组不太方便做深度画像,因为DBSCAN没有质心概念。K-means正好相反,需要干净的输入数据,但能给出清晰可解释的质心和明确的簇个数。两者一前一后配合,往往能得到既稳定又解释得通的结果。

另一个实战体会是,聚类做完之后不要急着交付,花时间做一个"稳定性检查":用不同的随机种子重新跑K-means多次,看看哪些样本的簇归属经常变化。稳定的簇才是可信的,边缘样本的归属本来就是模糊的,如果边缘样本占的比例太高,说明这个聚类解决方案本身就不够靠谱,需要回到特征工程步骤调整。

实际项目中,聚类从来不是终点,它更像是帮你快速理解数据的一个透镜。K-means和DBSCAN的差别也不只是算法上的优劣,而是你选择用什么样的视角看待数据。理解了这两套视角的底层逻辑,再遇到任何无监督分析任务,选型这件事就不会让你纠结太久了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询