ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CFSFDP密度峰值聚类:从决策图到参考代码拆解与实战避坑

CFSFDP密度峰值聚类:从决策图到参考代码拆解与实战避坑

简介:面向聚类与特征选择研究者的CFSFDP参考代码包,完整实现了基于相关性分析与模糊数据分区(FDP)的特征选择流程,通过计算特征间相关性与对聚类性能的贡献度来筛选有效特征,帮助处理含模糊、离散与连续属性的数据集,简化模型并提升聚类精度。资源为zip压缩包,共7个文件,含3个csv示例数据集、1份Python主程序及说明文档,整体仅12KB,结构紧凑,便于快速定位核心模块。已有1125人学习下载,适合机器学习初学者与需要复现该算法的研究人员。除主算法实现外,代码还附有模糊聚类模块、测试脚本和多组csv数据,可直接运行并观察特征贡献度排序与聚类性能变化,为理解特征选择与模糊聚类技术的结合提供直观范例,也便于后续二次开发与参数调优。

1. 为什么 CFSFDP 的参考代码值得一行行读

CFSFDP(Clustering by Fast Search and Find of Density Peaks)这份参考代码,是我见过的聚类算法实现里性价比极高的一类:算法思路来自一篇顶级期刊论文,实现却精简到一晚上能读完,还自带决策图,让你用手而不是黑匣子来选簇中心。很多做数据分析的开发者第一次读它时会愣一下——原来聚类可以不用 KMeans 那种固定形状假设,也不用被 DBSCAN 的 eps 反复折磨。它用“局部密度”和“更高密度点距离”两个量,就把簇中心、离群点和边界点分得明明白白。

这份代码适合三类人:一是被 KMeans 的 k 值和 DBSCAN 的 eps 调参搞烦的开发者,想换一种更直观的聚类范式;二是要做密度聚类二次开发的研究者,代码结构清晰,改起来比从论文自己推快得多;三是刚接触聚类的新手,想找一个“能跑通、能画图、能解释”的入门项目。本文会从算法骨架讲起,再给出一条最小复现路径,最后把参数边界和常见坑一次说清。读完你会发现,这类参考代码不是拿来背的,是拿来拆的。

2. 读代码前先立住算法骨架:密度、距离和决策图

2.1 CFSFDP 的聚类中心长什么样:局部密度与更高密度点距离

CFSFDP 的核心假设只有一句话:聚类中心是被密度更低区域包围的局部密度高点,而且它和任何密度比它更高的点都保持一个相对远的距离。为了实现这句话,参考代码里会给每个点算两个量。第一个是局部密度 ρ,常见做法是以截断距离 dc 为半径,统计落在这个半径内的邻居数量;也可以用高斯核做加权求和,距离越近贡献越大。第二个量是 δ,表示“到更高密度点的最近距离”。如果一个点的密度已经是全局最高,那它的 δ 就直接取到所有其他点的最大距离。

有了这两个量,每个点就变成一个二维点(ρ,δ)。簇中心长在决策图的右上方:ρ 大说明附近密集,δ 大说明它和更高密度的区域隔得远,符合“局部密度峰值”的直觉。值得注意的是,离群点也会有大的 δ,但它的 ρ 往往很小,所以看决策图时不能只盯 δ,必须 ρ 和 δ 一起看。这也决定了 CFSFDP 选中心是一个“看图决策”的过程,而不是像 KMeans 那样直接算个距离平方和。

读参考代码时,我一般会先确认它实现的是哪种密度定义。两种定义的差异很实在:硬截断密度是计数,对 dc 的变化特别敏感,dc 稍微调小一点,邻居数可能从 20 掉到 3;高斯核密度是加权求和,对 dc 的敏感度低一些,密度分布也更平滑。参考代码里通常会留一个 method 参数来切换这两种模式,这个开关是读懂全篇的钥匙。

2.2 分配标签的关键一跳:更高密度最近邻数组

选完簇中心之后,剩下所有点要怎么归簇?CFSFDP 的做法不是像 KMeans 那样迭代更新中心,而是排序后一次性扫过去:每个非中心点沿着“比它密度更高的最近邻”一路爬,直到爬到某个簇中心为止。这个“更高密度最近邻”在参考代码里通常存成一个数组,叫 nneigh 或者 higher_neighbor,是整个分配过程的数据基础。

分配过程用文字描述就是:先把所有点按密度从高到低排序,密度最高的点没有更高密度邻居,跳过;其余每个点找比自己密度高的点里距离最近的那个,记下来。然后再次按密度从高到低扫一遍,把自己的簇标签赋给更高的邻居所对应的簇。这一步只要 O(N) 的扫描就能完成,不需要迭代,也不需要预先指定簇数。这也是 CFSFDP 被称为快速聚类的原因之一。

实现里有个细节容易忽略:密度相等时怎么处理。如果 A 和 B 密度完全一样,它们互不算“更高密度邻居”,排序顺序就会影响分配结果。参考代码里有的实现用严格大于号避免成环,有的会加一个微小扰动打破平局。我自己的习惯是排序时用稳定排序,并且让密度相同的点互相之间距离小的优先分配,这样结果可复现,不会出现每次运行标签不同的问题。

2.3 决策图的可解释性:为什么它是这个算法的记忆点

决策图是 CFSFDP 最有辨识度的输出,横轴是局部密度 ρ,纵轴是 δ。把数据点画上去之后,右上角的点几乎不需要解释。这种可视化让聚类过程从“调参黑匣子”变成了“看图说话”,也让非技术合作方更容易理解你为什么选这几个簇。我在某物流项目的实际落地里,就是用决策图向业务方解释为什么把某个仓库分区聚成五类,而不是拍脑袋定的五块。

不过决策图也有它的“玄学”成分:右上角多少个点算“明显”,并没有硬性标准。常见做法是画两条辅助线,一条横线对应 δ 阈值,一条竖线对应 ρ 阈值。先看竖线左边的点排除掉低密度候选,再看横线上方的点,剩下的交集就是簇中心。参考代码里通常会提供一个画决策图的函数,参数就是这两个阈值,后面第 4 章会细讲怎么取值。

3. 用参考代码跑通自己的数据:最小复现路径

3.1 前置准备:一张距离矩阵和一份“密度不均”的测试数据

在动代码之前先搞清楚输入格式。绝大多数 CFSFDP 参考代码接受两种输入:一是 N 行 m 列的特征矩阵,内部自己算两两距离;二是直接给 N×N 的距离矩阵。前者适合大多数场景,后者用于你已经有了非欧距离度量的情况。无论哪种,数据最好先做标准化,不然量纲差异会把密度计算带偏。我这里构造一份密度差异明显的二维数据来展示算法特性:外侧是一个半环形,内侧是一个密集小簇,形状和密度都不一样,KMeans 在这份数据上基本会翻车,而密度峰聚类正好能体现出优势。

import numpy as np def make_two_shapes(n=600, seed=42): """构造环形簇 + 中心密集簇,密度差异明显。""" rng = np.random.default_rng(seed) half = n // 2 # 半环形:半径 6 的大圆弧,加少量噪声 t = np.linspace(0, np.pi, half) ring = np.stack([6 * np.cos(t), 3 * np.sin(t)], axis=1) ring += rng.normal(scale=0.15, size=ring.shape) # 中心小而密的簇 core = rng.normal(loc=[0, 0], scale=[0.5, 0.5], size=(half, 2)) X = np.vstack([ring, core]) labels_true = np.array([0] * half + [1] * half) # 标准化,避免量纲影响距离 X = (X - X.mean(axis=0)) / X.std(axis=0) return X, labels_true X, y_true = make_two_shapes() # 全距离矩阵,后续所有计算都基于它 D = np.linalg.norm(X[:, None, :] - X[None, :, :], axis=2)

这段代码里,n 控制总样本数,half 决定两个簇各占一半;scale=0.5 让中心簇的半径明显小于环形,制造密度差。标准化那一步很关键,如果省略,横纵坐标单位不一致,距离矩阵会被数值大的维度主导。距离矩阵 D 用广播的方式一次算出,N=600 时约 360 万 float64,内存 28 MB,完全可接受,但到第 5 章你会看到这种方法在更大样本上会很快爆掉。

3.2 参考代码最核心的三段:估计 dc、算 ρ/δ、分配标签

下面这段是参考代码的骨架实现,我把它压缩成三个函数,各自职责单一。compute_dc 用“平均邻居占比”自动估计截断距离;compute_rho_delta 算每个点的密度和更高密度距离;assign_labels 按密度排序传播簇标签。这三段看懂,整份参考代码就懂了大半。

def compute_dc(D, percent=2.0): """自动估计截断距离 dc:让每点平均邻居数约为总样本数的 percent%。""" N = D.shape[0] # 取上三角,去掉重复距离和自身距离 dists = D[np.triu_indices(N, k=1)] dists = np.sort(dists) # 找排序后位于 percent% 位置的距离作为 dc idx = int(len(dists) * percent / 100.0) - 1 idx = max(idx, 0) return dists[idx] def compute_rho_delta(D, dc, method='cutoff'): """计算局部密度 rho 和更高密度最近距离 delta。""" N = D.shape[0] rho = np.zeros(N) for i in range(N): if method == 'cutoff': # 硬截断:距离小于 dc 的邻居个数,去掉自身 rho[i] = np.sum(D[i] < dc) - 1 else: # 高斯核加权:距离越近权重越高,同样去掉自身距离 0 rho[i] = np.sum(np.exp(-(D[i] / dc) ** 2)) - 1 order = np.argsort(-rho) # 按密度从大到小排序 delta = np.zeros(N) nneigh = np.zeros(N, dtype=int) # 更高密度最近邻编号 delta[order[0]] = np.max(D[order[0]]) # 密度最高点的 delta 取全局最大距离 for i in range(1, N): idx = order[i] higher = order[:i] # 密度更高的点集合 d = D[idx, higher] j = np.argmin(d) delta[idx] = d[j] nneigh[idx] = higher[j] return rho, delta, nneigh def assign_labels(D, centers, nneigh, rho): """从簇中心出发,按密度从高到低传播标签。""" N = D.shape[0] labels = np.full(N, -1, dtype=int) for k, c in enumerate(centers): labels[c] = k order = np.argsort(-rho) for i in order: if labels[i] == -1: labels[i] = labels[nneigh[i]] return labels

三个函数要连起来看。compute_dc 里的 percent 默认取 2.0,意思是一个点期望有 N×2% 个邻居,这个经验范围对应 1%~2%,是论文和参考代码里最常见的设置。compute_rho_delta 里有两个容易被忽略的细节:cutoff 模式用了严格小于号,所以自身距离 0 不会被算进去,但保险起见还是减了 1;高斯核模式里 exp(-(0/dc)²)=1,同样要减 1 去掉自身。delta 的计算只依赖密度排序,所以复杂度是排序 O(NlogN) 加扫描 O(N²),和距离矩阵计算 O(N²) 相比,压力主要集中在内存而不是 CPU。

assign_labels 的输入 centers 是人工从决策图选出的点编号。注意传播方向:密度高的点先被分配,低密度点顺着 nneigh 一路找到归宿。只要簇中心选对,这个传播过程不需要任何迭代,也不存在收敛问题。代价是如果 centers 里混进了一个低密度离群点,它会把附近一串点都带偏,所以选中心宁可少选、别多选。

3.3 画决策图、圈出簇中心并完成可视化

有了 ρ 和 δ,决策图就是一张散点图。不过光画出来还不够,要配合两条辅助线确定阈值,再用选出的中心跑一次 assign_labels,最后把结果画回原始坐标空间。这一步是整个参考代码里最有感觉的时刻,簇的形状、密度差异、边界点全部显形。

import matplotlib.pyplot as plt def plot_decision(rho, delta, rho_th=None, delta_th=None): """决策图:横轴密度 rho,纵轴更高密度距离 delta。""" plt.figure(figsize=(5, 4)) plt.scatter(rho, delta, s=8, c='steelblue') if rho_th is not None: plt.axvline(rho_th, color='gray', ls='--', lw=1) if delta_th is not None: plt.axhline(delta_th, color='gray', ls='--', lw=1) plt.xlabel('local density rho') plt.ylabel('delta') def plot_clusters(X, labels): """把聚类结果画回二维坐标空间。""" plt.figure(figsize=(6, 4)) plt.scatter(X[:, 0], X[:, 1], c=labels, s=8, cmap='plasma') plt.axis('equal')

决策图的横竖辅助线是最直接的调参平面:竖线左侧的点排除,横线下方的点排除,剩余候选就是簇中心。可视化时如果两类颜色混在一起,优先检查是不是 dc 选太大导致两个簇被密度桥连起来了;如果出现大量 -1 的灰色点,多半是簇中心选少了,有些孤岛没被传播到。这些排查逻辑,比任何参数公式都实用。

4. 参数怎么设:dc、密度核和自动选中心的边界

4.1 dc 不是半径而是“邻居配比”:1%~2% 的直觉与实现

很多初学者把 dc 当成邻域半径来调,这是最常见的误读。dc 的真正作用是通过截断距离控制密度的分辨率:dc 太小,每个点都只有少量邻居,密度曲线像梳子一样锯齿状,决策图上几乎所有点都是孤立峰;dc 太大,密度平滑成一条单调曲线,簇中心之间的差异被抹平。参考代码里用“平均邻居数占总数 1%~2%”来定 dc,本质上是一种自适应策略——样本量大,dc 自动变小,保证每点的邻居数稳定在几十个的量级。

我在实际项目里的经验是,1%~2% 是很好的起点,但不是终点。如果样本本身噪声比较大,比如传感器数据,建议把 percent 降到 0.5%~1%,让密度估计更保守,避免噪声点互相抱团形成假中心;如果数据密度分布极不均匀,比如一个簇特别密、另一个特别疏,那全局 dc 注定只能顾一头,这时候要考虑第 6 章的局部 dc 技巧。判断 dc 是否合适的粗暴方法:把距离矩阵排序后画一条曲线,取 dc 处附近的斜率变化。斜率比较陡,说明你选在了“邻居数快速上升”的位置,这个位置通常比较合理。

4.2 三个必调参数的推荐区间和相互影响

参考代码的参数表面上看只有 dc、method、簇中心阈值三个,实际调起来互相牵制。我整理了一张常用参数表,方便对照排查。

参数出现位置含义常用设定设错的表现
percentcompute_dc邻居数占总样本百分比1.0~2.0过小:决策图全是孤立点;过大:所有点粘成一团
methodcompute_rho_delta密度核类型gaussian / cutoffcutoff 对 dc 太敏感,边界毛刺多
rho_thplot_decision簇中心的最小密度看决策图分布取分界太低:离群点被当中心;太高:漏掉小簇
delta_thplot_decision簇中心的最小 δ看决策图分布取分界太低:簇内部点被误选为中心
centers_numassign_labels实际选择的簇中心数决策图右上点数多选:一个簇被劈碎;少选:两个簇被合并

其中 method 的选择容易被低估。cutoff 密度计算只有 < dc 的判断,速度稍快,但边界点的密度对 dc 的轻微变化会成片波动;gaussian 核让每个点对密度的贡献随距离平滑衰减,边界点不再是“非黑即白”,聚类结果明显稳定。除非参考代码的性能瓶颈严重,否则我一律建议优先使用 gaussian。

4.3 自动选簇中心的 γ 排序:能用但不万能

手动从决策图选中心,适合分析场景;但如果你要写成一个自动化流程,比如每晚定时聚类一批新数据,就需要一个自动选中心的启发式。最常见的做法是计算 γ_i = ρ_i × δ_i,γ 越大说明该点越是“高密度且远离其他高密度点”,把 γ 降序排列后取前 k 个。

这个启发式在簇大小相对均匀时表现不错,但它有一个明显缺陷:δ 的数量级往往远大于 ρ,导致 γ 被 δ 主导,结果是决策图左上角那批“低密度但很孤”的离群点反而拿到高分。我的修正习惯是给 ρ 设一个下界,或者对 ρ 和 δ 分别做百分位排名后再相乘,这样两个维度贡献均衡。另外,k 的取值可以看 γ 曲线的拐点,拐点之后 γ 快速下降,说明再往下选的多半是噪声点。这个“看拐点”的操作没有严格数学定义,但比硬编码 k 值要靠谱得多,也能让自动化流程不至于太离谱。

5. CFSFDP 避坑手册:从参考代码到真实数据的高频翻车点

5.1 决策图右上角挤满一片点,选不出几个清晰中心

现象:把完全陌生的数据直接丢进参考代码,dc 用默认 2% 算出后,画出的决策图右上角笼罩着一大片点,怎么圈都圈不出明显的孤立点,强行选中心后聚类结果混乱。
原因:最常见的是数据里有大量重复或近似重复的样本,距离矩阵里大量距离接近 0,导致密度分布出现“平台”,一堆点的 ρ 几乎一样;另一个高频原因是数据量纲差异大,某个维度的数值差直接主导了所有距离。
解决:先做两步预处理。第一步是标准化,让每个特征方差为 1;第二步是去重或合并近似点,我用 np.unique 对标准化后的数据取整去重,减少平台效应。之后重新算距离矩阵,决策图通常会清爽很多。如果还是挤成一团,把 percent 调小到 1.0 再试。

5.2 一个簇被切成两半,或者两个簇死活粘在一起

现象:外部形状上明明是一个连续的带状簇,聚类结果却从中间断开,变成两坨;反过来,两个距离很近的簇却分不开,边界互相交错。
原因:前者通常是 dc 设小了,或者 cut-off 密度对边界点的跳动太敏感;后者多数是 dc 设大了,两个簇之间的“密度桥”被当成同一片区域,密度峰连成一片。
解决:先把 method 切到 gaussian,它会让密度分布平滑很多,边界粘滞问题会改善不少。如果还断,就提高 percent 到 2.5~3.0;如果还粘,就降到 1.0。这个正反调节通常能在两三次内找到平衡点,远比盲目在 dc 上二分快。

5.3 离群点被当成了簇中心,一个簇瞬间变成一组噪声点

现象:决策图里挑中心时,看到一个 δ 极大、ρ 也还行的点,于是把它选成中心,结果这个簇分出来全是零零散散的点,几乎没有内部结构。
原因:δ 大是“孤立”的信号,ρ 不小可能是“小簇”的信号,但在真实数据里这两者经常同时出现在离群点身上——它离主簇远,周围虽然有几个近邻,但整体密度远低于真正的簇。
解决:选中心前盯住 rho_th,不要只看 δ。做法是先画一条 ρ 的竖线,把左半边的候选全部排除;每次选中心后,看该簇的样本数量和平均密度,如果样本数小于总样本的 1%,基本可以判定为误选的噪声簇。这在参考代码里没有自动提示,是我的血泪经验。

5.4 5 万样本内存爆掉:全距离矩阵是罪魁祸首

现象:数据量从几千涨到几万,代码在算 D 矩阵那一行直接卡死,或者内存占用冲到十几 GB 最终被杀。
原因:全距离矩阵的规模是 O(N²),N=5 万时 float64 矩阵需要 20 GB 内存,大多数个人机器扛不住。参考代码为了教学清晰通常不优化这一步,但真实数据不能这么跑。
解决:改用 K 近邻稀疏图代替全距离矩阵。用 KDTree 求出每个点的最近 k 个邻居和对应距离,然后只在邻居对之间评估密度和 δ,复杂度降到 O(Nk)。密度全局最高的点在稀疏图里可能没有更高密度邻居,需要单独处理,把它标记为虚拟根节点。这个改造会牺牲一点精度,但能让样本量提升两个数量级。

5.5 相同密度的点分配结果反复横跳,边界标签不稳定

现象:同一份数据,只是改变排序方式或者稍微改一下 dc,边界一带的标签就换来换去,簇中心没变,但边界归属不稳定。
原因:CFSFDP 分配阶段依赖严格的“更高密度”关系,当两个点密度相等时,谁比谁高完全看排序顺序。离散化产生的等密度点越多,这种抖动越明显。
解决:在 compute_rho_delta 里把密度排序改成二级排序,第一关键字是 rho,第二关键字是最小邻居距离,这样相等密度能按几何位置破平;或者给 rho 加一个极小的随机扰动,扰动固定随机种子,保证每次运行结果一致。稳定排序加固定种子是我习惯的底线,至少能让结果在多次运行时可复现。

6. 把全局 dc 改成局部 dc:一个提升密度不均场景效果的小技巧

前面提过,全局 dc 只能提供一个统一的尺度,遇到“一个簇密、一个簇疏”的数据会顾此失彼。我实际用过的一个有效变体是局部 dc:对每个点,取它到第 k 近邻的距离作为该点的局部尺度 dci,然后用高斯核计算密度时,让每个点用自己的 dci 而不是全局统一的 dc。这样密区自动用小尺度,疏区自动用大尺度,密度图谱便平滑了很多。

from scipy.spatial import cKDTree def compute_local_rho(X, k=20): """局部 dc 的高斯核密度:每个点用自己的第 k 近邻距离做尺度。""" tree = cKDTree(X) # query 返回 [距离, 索引],取第 k+1 个,即第 k 近邻 dist_k, _ = tree.query(X, k=k + 1) dc_i = dist_k[:, -1] + 1e-8 # 逐个点算高斯密度,用到自身时 exp(0)=1,要减掉 rho = np.zeros(X.shape[0]) for i in range(X.shape[0]): d = np.linalg.norm(X - X[i], axis=1) rho[i] = np.sum(np.exp(-(d / dc_i[i]) ** 2)) - 1 return rho

参数 k 取 20 是我平常用的起点,k 越小局部尺度越敏感,容易把单个点自身的局部密度抬得很高;k 越大越接近全局 dc 的效果。验证这个改动值不值得,最直接的方法是真标签数据上跑 ARI,或者在业务数据上看簇内距离是否下降。比如对前面那个环形加中心簇的测试集,全局 dc 版本的分离往往依赖运气,局部 dc 版本则稳定地把环和中心簇分开,边界也更干净。

这个技巧并不适合所有场景,如果你的数据密度本来就均匀,局部 dc 反而会引入额外噪声。但遇到不确定情况,我会直接跑一遍对比,数据说话。克劳德·香农有句话我很认同:“信息来源于差异。”CFSFDP 的参考代码真正教会我的不是调参技巧,而是让差异在决策图上自己说话。每次拿到新数据,我如今都会先画决策图,再看几眼右上角的形状,再决定这个项目适不适合密度峰这条路。这条路不一定每次都对,但它值得一试,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进