ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DBSCAN聚类算法详解:从原理到Python实现,彻底摆脱K-Means的局限

DBSCAN聚类算法详解:从原理到Python实现,彻底摆脱K-Means的局限 1. 项目概述为什么我弃用K-Means转向DBSCAN1.1 聚类问题里的“老面孔”为什么不够用做机器学习的朋友应该都有体会聚类算法里最早接触的往往是K-Means。“给定K值、初始化中心点、迭代更新、直到收敛”这套流程简单直观我当年入门时也拿它处理了不少表格数据。但随着实际项目越做越深入K-Means的局限性也暴露得越来越明显。先说最让我头疼的三点。第一K-Means假设簇的形状是凸的、接近球形遇到月牙形、环形、长条形这种真实分布时聚类结果经常是一团浆糊。第二它对噪声点和离群点几乎没有任何抵抗力几个极端值就能把聚类中心拉偏。第三K值需要提前指定而很多场景下我们根本不知道数据该分成几类多次尝试又缺乏客观依据。这几个问题叠加在一起就导致K-Means在很多真实数据集上表现很不稳定。1.2 DBSCAN能做什么适合谁DBSCAN这个算法全称是Density-Based Spatial Clustering of Applications with Noise翻译过来就是“基于密度的带噪声应用空间聚类”。它的核心思路和K-Means完全不同——它不看“距离中心点有多远”而是看“某个点周围密集到什么程度”。只要一个区域里的点是足够“挤”的它就可以被拉成一个簇单独飘在边缘的那些点则会被标成噪声。我第一次用DBSCAN处理一个“商户位置聚类”项目时效果确实让我有些意外。底层的经纬度数据分布很不均匀市中心密密麻麻郊区稀疏分散如果用K-Means要么把市中心强行切开要么把郊区硬塞进一个大簇。DBSCAN跑完之后市中心的热门商圈被切成一个个高密度簇郊区的稀疏点直接归为噪声整体结果和人脑的直觉判断高度接近。这篇文章适合几类人看刚学机器学习聚类算法但被K-Means各种“刁难”的新手在实际业务里处理过有噪声、无规则形状数据的工程师以及准备面试、想系统理清密度聚类原理的求职者。我会先把理论讲透再用图解把算法每一步拆开最后给出一份可直接运行的Python实现和调参经验。读完这篇你不仅能跑通DBSCAN还能知道它为什么有效、什么时候失效。2. 核心概念与算法原理密度到底怎么定义2.1 三个角色核心点、边界点、噪声点DBSCAN的密度定义其实不复杂它只需要两个参数。第一个参数是eps也叫邻域半径。它表示以某个点为中心、画一个半径为eps的圆这个圆覆盖的范围就叫该点的eps邻域。第二个参数是min_samples它表示一个点的eps邻域内最少需要有多少个点包括该点自身这个点才能被称为“核心点”。基于这两个参数数据集里的每个点会被分成三类核心点自身邻域内的点数大于等于min_samples的点。这种点位于簇的内部周围很拥挤是形成簇的骨干力量。边界点自身邻域内的点数小于min_samples但它落在某个核心点的邻域内。这种点处于簇的边缘不属于任何一个核心点但又不孤单到被当成噪声。噪声点既不是核心点也不在任何核心点的邻域内。这种点周围太稀疏了算法认为它不属于任何簇。用一个生活场景来理解会更直观。想象你在一个大广场上看演唱会人群是一簇一簇聚集的。核心点就像站在人群最密集中央的那些人身边前后左右都是人密度极高。边界点像站在人群外圈的人周围虽然没那么挤了但仍然能碰到“中心区”的人。而那些独自坐在远处草坪上的路人既不在人群中央也不在人堆边缘就是噪声点。2.2 密度直达、密度可达与密度相连划分完三类点之后DBSCAN还需要回答一个关键问题不同的点如何被归到同一个簇里这里引入了三组概念。先看“密度直达”。如果点A是核心点点B在A的eps邻域内那么就说B由A密度直达或者说A到B是密度直达的关系。需要额外注意密度直达不要求两个点都是核心点只要出发点是核心点、到达点落在它的邻域内即可。再看“密度可达”。如果存在一条链条A直达BB直达CC直达D那么从A出发就能沿着这条链一路“走”到D我们说D对A是密度可达的。这个关系有点像“朋友的朋友”A认识BB认识CC认识D虽然A和D之间没有直接交集但通过中间人的传递A依然能联系到D。最后是“密度相连”。如果存在一个点O使得点A和点B都从O密度可达那么A和B就是密度相连的。这个概念是用来合并簇的——当两条密度可达链在某处交汇时它们对应的点集应该合并成一个整体。到这里一个簇的定义就清楚了在一个簇内任意两个点之间都是密度相连的并且一个簇由所有密度可达的点组成。换句话说DBSCAN的聚类过程就是不断从核心点出发用“密度可达”这条链把点串起来串成一个个相连的团。2.3 算法完整执行过程把上面的概念串起来DBSCAN的完整执行步骤可以用五步概括从数据集中任意选取一个未被访问的点P。计算P的eps邻域内的点数。如果邻域内点数大于等于min_samples则P被标记为核心点并创建一个新簇C否则P被暂时标记为噪声点注意是“暂时”后续如果某个核心点的邻域覆盖到它它会被重新归入簇。对核心点P邻域内的所有点逐个加入簇C。对于其中未被访问的点重复步骤2的密度判断过程把新发现的核心点邻域内的点也加入C这个过程用队列或栈实现直到没有新的点能加入C。继续选取下一个未被访问的点重复上面的步骤生成下一个簇。所有点都被访问过后算法终止。最终未被任何簇标记的点就是噪声点。整个算法非常依赖eps和min_samples这两个参数。这两个值一旦定得不合适聚类效果就会差很多后面我会专门用一节内容来讲调参方法。3. 参数选择与调优两个参数怎么定3.1 eps半径定得太小会怎样eps决定了“密度”的空间尺度。它设得太小每个点的邻域里都装不下几个点导致大量点被标记为噪声甚至出现“该聚成的一簇被切成好多碎片”的情况。反过来说如果eps设得太大邻域范围过大不同簇之间的边界会被模糊掉原本独立的两拨数据可能被强行合成一个簇噪声点也容易被误收进簇里。实际项目中我常用的一个办法是看“K-距离图”。思路很简单对于每个点计算它到第K个最近邻居的距离K通常取min_samples然后把所有点的这个距离值从小到大排序画一条曲线。曲线会出现一个明显的拐点这个拐点对应的距离值就是一个比较合理的eps。举个我处理过的例子。当时对一组二维点云数据做聚类我取min_samples5然后计算每个点到第5个最近邻居的距离。排序后画图发现距离值在0.3左右有个明显的“肘部”拐点之前曲线平缓拐点之后曲线突然抬头。这说明大部分点在第5个邻居的距离在0.3以内超过0.3之后邻居就明显稀疏了。于是我把eps设在0.3附近实验效果很理想。3.2 min_samples的经验取值min_samples控制的是“成为核心点的最低门槛”。它设得太小比如设成1那么几乎每个点都能成为核心点噪声概念就失去了意义设得太大的话又会导致很多本应成为核心点的点被降级簇会被过度拆分。业界有一个比较通用的经验值二维数据取4到5高维数据可以适当增大一般取数据维度乘以2再加1也就是2 * n_features 1。这个公式不是绝对的但它是一个很好的起点。我自己的习惯是先把这个值固定下来再去调eps两个参数交替微调而不是同时大范围搜索这样定位问题会更快。3.3 参数组合的常见误区调参过程中最容易犯的错是“只调一个参数、忽略另一个”。有些资料会让你先用K-距离图定eps再按定式选min_samples但在真实数据上eps的最优值其实高度依赖min_samples的选择。你把min_samples从3改成6同样的K-距离图算出来的最佳eps可能就从0.4跳到0.55了。所以我的建议是先用2 * n_features 1定一个min_samples画出K-距离图得到一个初始eps然后跑一次聚类观察噪声占比和簇的形状如果噪声太多试着增大eps如果簇被连成一大片试着减小eps如果某个簇内部细碎了就增大min_samples。每次只动一个参数记录结果再动另一个这样反复迭代两三轮就能收敛到不错的组合。4. Python代码完整实现4.1 环境准备与数据构造先交代一下运行环境我这边用的是Python 3.9版本依赖库分别为numpy 1.23、scikit-learn 1.2、matplotlib 3.6。这些都是比较稳定的版本你只要用pip install numpy scikit-learn matplotlib就能把环境凑齐。为了演示效果我构造一个包含三类簇和若干噪声点的合成数据集。用make_blobs生成三个团再手动添加一些随机噪声点。这样既能展示DBSCAN对常规簇的识别能力也能展示它的噪声标注能力。import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_blobs from sklearn.cluster import DBSCAN from sklearn.preprocessing import StandardScaler # 生成三个簇 X, y_true make_blobs(n_samples300, centers3, cluster_std0.6, random_state42) # 手动添加一些噪声点 rng np.random.RandomState(10) noise rng.uniform(low-4, high4, size(30, 2)) X np.vstack([X, noise])4.2 使用sklearn实现DBSCANsklearn里已经封装好了现成的DBSCAN实现实际业务中直接用就行没必要自己造轮子。核心代码非常短# 标准化让各个维度的尺度统一 X_scaled StandardScaler().fit_transform(X) # 训练模型 db DBSCAN(eps0.5, min_samples5).fit(X_scaled) # 聚类标签噪声点的标签为 -1 labels db.labels_ n_clusters len(set(labels)) - (1 if -1 in labels else 0) n_noise list(labels).count(-1) print(f识别出的簇数量: {n_clusters}) print(f噪声点数量: {n_noise})这里有一个关键的细节我在训练前先做了标准化。原因很简单DBSCAN是距离敏感型算法如果特征A的取值范围是0到1特征B的取值范围是0到10000那么eps这个半径基本上只会被特征B主导特征A的距离贡献几乎可以忽略。标准化能把每个特征拉到同一尺度让距离计算更公平。这个问题在后面“常见问题”一节我还会展开讲。4.3 可视化聚类结果代码跑完只看数字是没有体感的直接可视化才能直观地验证效果。我写了一个简单的绘图函数把聚类结果和噪声点分开画出来噪声点单独用五角星标记def plot_dbscan(X, labels): unique_labels set(labels) colors plt.cm.Set1(np.linspace(0, 1, len(unique_labels))) for label, color in zip(unique_labels, colors): if label -1: # 噪声点用黑色五角星 plt.scatter(X[labels -1, 0], X[labels -1, 1], cblack, marker*, s60, labelNoise) else: plt.scatter(X[labels label, 0], X[labels label, 1], c[color], s40, labelfCluster {label}) plt.legend() plt.title(DBSCAN Clustering Result) plt.show() plot_dbscan(X_scaled, labels)从图上能看到的是三个本应分开的簇被干净地识别出来外围那些稀疏的噪声点全部被打上了噪声标签没有混进任何一个簇。这一点也是DBSCAN相比K-Means最直观的优势K-Means会把每个点都强行分配到某个簇哪怕它是个明显的离群点而DBSCAN给了点“拒收”的权利。4.4 手写一个简化版DBSCAN直接用sklearn当然很方便但要想真正理解一个算法还是应该自己动手实现一遍。我在这里写了一个不依赖sklearn的简化版DBSCAN只用numpy和队列逻辑上和原始论文保持一致。from collections import deque def region_query(X, point_idx, eps): 返回点 point_idx 的 eps 邻域内所有点的索引 distances np.linalg.norm(X - X[point_idx], axis1) return np.where(distances eps)[0] def expand_cluster(X, labels, point_idx, neighbors_idx, cluster_id, eps, min_samples): 从核心点出发通过密度可达关系扩展簇 queue deque(neighbors_idx) while queue: curr_idx queue.popleft() # 如果当前点还没有被访问过先标记归属 if labels[curr_idx] -1: labels[curr_idx] cluster_id # 如果当前点已经被访问过跳过 if labels[curr_idx] ! cluster_id: continue curr_neighbors region_query(X, curr_idx, eps) if len(curr_neighbors) min_samples: for neighbor in curr_neighbors: if labels[neighbor] -1: labels[neighbor] cluster_id queue.append(neighbor) elif labels[neighbor] -1: labels[neighbor] cluster_id queue.append(neighbor) def my_dbscan(X, eps, min_samples): labels np.full(X.shape[0], -1) cluster_id 0 for point_idx in range(X.shape[0]): if labels[point_idx] ! -1: continue neighbors region_query(X, point_idx, eps) if len(neighbors) min_samples: labels[point_idx] -1 # 噪声点 else: cluster_id 1 labels[point_idx] cluster_id expand_cluster(X, labels, point_idx, neighbors, cluster_id, eps, min_samples) return labels labels_manual my_dbscan(X_scaled, eps0.5, min_samples5)我自己把这段代码和sklearn版本的结果做过对比在大多数合成数据上两者的簇分配几乎完全一致。当然sklearn的底层实现做了很多优化比如用KDTree或者BallTree加速邻域查询大数据量上要快得多这里只是为了帮助理解原理而写。5. 常见问题排查与实操心得5.1 数据标准化最大的坑先说说我踩过最深的一次坑。有一次拿用户行为数据做聚类特征是“登录次数”和“消费金额”前者取值范围是0到50后者是100到10000。我直接丢给DBSCAN跑结果不管怎么调eps聚类效果都很差。后来我画了张散点图才发现“消费金额”这一维的尺度把欧氏距离完全主导了eps稍大一点登录次数这个维度的差异就完全被淹没。解决办法就是在聚类之前做标准化。用StandardScaler把每个特征的均值归零、方差归1或者用MinMaxScaler缩放到0到1区间。我个人的习惯是优先用StandardScaler因为它对离群点相对没那么敏感而DBSCAN又恰恰是要处理离群点的。注意标准化一定要在训练之前对全量数据做不能拿一部分数据算均值和方差再套到另一部分上。5.2 高维数据与距离度量DBSCAN在二维三维数据上表现很直观但到了高维空间事情就变得复杂了。随着维度增加所有点之间的距离都会趋于相似这就是著名的“维度灾难”。如果直接在高维空间里使用欧氏距离eps阈值会很难选——你会发现无论怎么调点之间的密度差异都不明显。面对高维数据我有两条建议。第一先做降维比如用PCA或者t-SNE把特征压缩到二维或三维再聚类这样既方便调参也方便可视化验证。第二换距离度量不一定非要用欧氏距离。如果特征是类别型的可以尝试汉明距离如果特征分布偏斜严重可以考虑马氏距离。总之不要一上来就默认欧氏距离全家通用。5.3 密度不均与参数冲突DBSCAN的另一个天生局限是“一个eps打天下”。如果数据集中不同簇的密度差异非常大比如一个簇很紧凑、另一个簇很松散那么无论eps怎么取总有一个簇会被拆分或合并。这不是你调参不够努力而是算法的固有缺陷。应对这种情况有几个思路。一是改用改进版的OPTICS算法它不需要指定eps能自适应不同密度的簇二是先对数据做密度分层把明显不同密度的区域拆开分别用不同参数聚类三是考虑先用DBSCAN跑一遍把噪声点剔除后再对剩余数据用其他算法做二次聚类。我在实际项目中用得比较多的是第三招效果一般都不错。5.4 性能优化与大数据场景DBSCAN的时间复杂度如果用朴素实现每个点都要计算到其他所有点的距离复杂度是O(n^2)。在几万点的规模下还能凑合一旦到了百万级速度会慢到无法忍受。sklearn的DBSCAN默认会根据数据规模自动选择KDTree或BallTree索引能将邻域查询从O(n)降到O(log n)实测在10万点规模下提速非常明显。如果你在用自定义代码处理大数据量建议不要直接用双重循环去算距离矩阵可以考虑用scipy.spatial的KDTree或者借助sklearn的NearestNeighbors模块。另外如果数据量实在太大一个预处理技巧是先用K-Means做一个粗聚类把数据分块再对每个块执行DBSCAN最后合并块之间的簇。这个做法不保证理论上最优但工程上很实用。6. 与其他聚类算法的横向对比6.1 K-Means与DBSCAN的核心差异面了很多次试过两种算法的对比我觉得把差异梳理成一个表格会特别清晰维度K-MeansDBSCAN簇形状偏向凸形、球形任意形状尤其擅长月牙形、环形噪声处理无概念每个点都必须归属某个簇可显式区分噪声点簇数量需提前指定K值自动发现无需指定参数个数只有K值eps和min_samples两个参数高维表现通常还可以受维度灾难影响较大可解释性聚类中心明确没有中心靠密度关系定义一句话总结如果你的数据是干净、规则、高维的大规模样本K-Means通常更快更稳如果你的数据里有噪声、形状不规则、簇数量未知DBSCAN更合适。两者不是替代关系而是互补关系。6.2 层次聚类与DBSCAN层次聚类是另一类经典方法它通过不断合并或分裂来生成树状图。相比DBSCAN层次聚类的好处是结果包含一个完整的层次结构你可以在不同粒度上观察聚类。但它的缺点是计算复杂度普遍较高而且对于百万级大数据处理起来不太友好。DBSCAN在这一点上胜出它的邻域查询可以用空间索引加速处理百万点以内的数据经常是可行的。另外层次聚类同样不擅长处理噪声合并过程中离群点容易干扰簇间距离的计算。因此在“噪声大数据未知簇数”这个组合场景下DBSCAN通常是更好的第一选择。6.3 什么时候不该用DBSCAN说了DBSCAN这么多优点也必须说说它不适合的场景。最典型的就是“密度差异极大的多层次数据”。比如你把全球人口数据按经纬度聚类城市和乡村的密度差了不止一个数量级一个固定的eps很难同时处理好这两种区域。这时候更合适的是OPTICS或者先按区域分开再各自单独聚类。另外如果数据本身是高维稀疏的比如文本的TF-IDF向量维度可能上万距离计算几乎没有区分度DBSCAN的密度定义基本失效。这种场景优先考虑基于主题模型或谱聚类的方法会更合理。选算法之前先看清自己的数据形态比任何参数调优都重要。7. 调参经验总结与扩展方向7.1 我实测下来的参数调整步骤把前面零散的调参经验归纳成一套流水线方便你直接照着操作。第一步数据清洗。先移除明显缺失值和重复样本再用StandardScaler做标准化。第二步初选min_samples。二维数据取4到5高维数据按2 * n_features 1起步。第三步绘制K-距离图取拐点值作为初始eps。第四步跑一次聚类观察簇数量、噪声占比、簇内样本数分布。第五步基于结果做定向调整。如果噪声过多增大eps或减小min_samples。如果簇被合并减小eps或增大min_samples。如果簇被切碎增大eps或减小min_samples。第六步用轮廓系数作为量化指标辅助验证。这套流程我在几个项目上跑下来基本都能在几轮迭代内找到可用的参数组合。7.2 用轮廓系数辅助判断聚类质量调参不能只靠人眼去看图最好有一个客观指标。我常用的是轮廓系数它的取值在-1到1之间值越接近1说明簇内紧密、簇间分离聚类效果越好。from sklearn.metrics import silhouette_score # 只对非噪声点计算轮廓系数 mask labels ! -1 score silhouette_score(X_scaled[mask], labels[mask]) print(f轮廓系数: {score:.4f})有一点要注意如果数据里噪声点很多计算轮廓系数时要先把它们排除掉。因为噪声点本来就不属于任何簇把它们算进去会严重拉低得分但这个低分并不能真正反映聚类质量。我试过多次排除噪声点后的轮廓系数更有参考价值。7.3 后续可以怎么扩展学会了DBSCAN之后还有几个方向可以继续深入研究。一个是OPTICS算法它能解决DBSCAN“单一密度阈值”的问题适合密度不均匀的数据。另一个是HDBSCAN它是DBSCAN的层次化改进版本能自动找到不同密度的簇在Python的hdbscan库里有现成实现。如果你对密度聚类在空间数据上的应用感兴趣还可以研究一下ST-DBSCAN它是针对时空数据的扩展版本能同时考虑空间距离和时间跨度在地理轨迹挖掘中非常实用。根据我个人这几年的使用体会DBSCAN是一个“上限很高、下限也不低”的算法。它的原理不复杂代码实现也不长但真正用好它需要对数据特征、参数意义和算法局限都有足够理解。踩过几次坑之后我最大的感受是拿到任何聚类任务别急着写代码先看清楚数据长什么样再决定用哪个算法。数据是圆的规则团K-Means能省事数据带着噪声、形状又奇怪上DBSCAN准没错。最后再分享一个小技巧——调参的时候把每次实验的eps、min_samples、簇数、噪声数、轮廓系数统一记下来固定变量、单参数轮换着调比起随手乱试效率能翻好几倍。
返回列表