ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

均值漂移聚类原理与Python实现:从带宽调参到非凸数据实战

均值漂移聚类原理与Python实现:从带宽调参到非凸数据实战

均值漂移聚类(Mean Shift Cluster)在数据挖掘里不算新面孔,但每次遇到非凸形状、簇间重叠严重的分布时,我第一个想到的还是它。它最大的特点是不需要预先指定簇数,通过不断把样本点向密度中心“漂移”,就能自动找到数据分布的高密度区域。这篇文章就用一个带详细注释的 Python 实现,把它从原理到调参完整过一遍,特别适合刚接触聚类、想搞明白“聚类到底在算什么”的新手。

我最早接触均值漂移的时候,差点被书上那堆核函数和梯度公式劝退。后来发现,它背后的直觉就和“山顶滚雪球”一样简单:给每个点找一条通往最近“山巅”的路,终点就是簇中心。真正难的不是数学,而是把数学翻译成代码,再把代码跑出合理的效果。这次我打算从算法直觉讲起,手写一个核心实现,再把带宽(bandwidth)这个关键参数掰开揉碎,最后附上我自己踩过的坑,看完你也能照着写一个。

1. 均值漂移聚类的整体设计思路

1.1 它到底解决了什么问题

先说说我为什么在众多聚类算法里推荐新人从均值漂移入门。K-Means 是绝大多数课本的第一课,但对初学者来说,K-Means 有两个很容易埋坑的假设:一是簇数 K 必须提前定好,二是决策边界天然就是直线(实际上是 Voronoi 划分)。你用 K-Means 处理一个圆环套圆环的数据,或者两个弯月形态的簇,效果大概率一言难尽。

均值漂移打破了这两条限制。它在特征空间里把样本点看成一张“密度地形图”,每个点会沿着密度上升最快的方向移动,最终停在局部密度极大值处。同一座“山”的收敛点聚成一簇,所有密度峰的数量就是聚类数。这个逻辑决定了它对任意形状的簇都有不错的适应力,而且不用从外部传入簇数。

1.2 从“山谷里的水”看透漂移逻辑

理解均值漂移最舒服的方式是想象一场雨落在连绵的山地上。雨水不会待在自己的落点不动,它永远顺着坡度往低处流;均值漂移里的样本点恰好反过来——它们顺着密度的高处爬,最后都聚集在山顶。

代码里的漂移过程就是一小步一小步地挪:对某个样本点,先找出以它为中心、半径由带宽决定的范围内的所有邻居,然后根据这些邻居的加权位置重新算出一个新位置,再把这个新位置当作下一次迭代的起点。公式长成下面这样子,但代码里其实就是加法和除法:

[ m(x) = \frac{\sum_{i} K(x_i - x) \cdot x_i}{\sum_{i} K(x_i - x)} ]

分子是邻居位置的加权和,分母是所有权重的总和。算出来的 ( m(x) ) 就是当前点下一步要移动到的目标。高斯核 ( K ) 会让离得近的邻居贡献更大的“拉力”,离得远的邻居轻轻“搭把手”。我第一遍看公式看得头晕,但写完一行new_position = np.average(neighbors, weights=weights)之后,一切都通了。

1.3 核心参数 bandwidth 的本质

带宽(bandwidth)在均值漂移里的地位,就像 K-Means 里的 K,但它藏得更深。简单说,带宽决定了每个点“看多远”来决定下一步往哪走。带宽太小,每个点只看到自己附近的一小撮邻居,漂移结果会碎成无数个小峰,聚类数爆炸;带宽太大,整个数据集在眼里都差不多,所有点全被吸引到同一个大峰,聚类数变成 1。

这背后就是核密度估计(KDE)的思想:带宽实际上是核密度估计里的平滑参数。你在做直方图时,组距选 1 还是选 10,看到的数据全貌完全不同;换到高维特征空间里,这个“组距”就是带宽。后面我会讲一种比较稳定的工程化选取方法,但你先记住一句话:先归一化数据,再调带宽,否则不同特征量纲会把带宽彻底带偏。

2. 核心步骤与代码实现细节

2.1 环境准备与数据构造

老规矩,先建个干净的环境。我用的是 Python 3.9 + NumPy 1.21,绘图用 Matplotlib。动手之前先把自己模拟的数据造出来,我用make_blobs生成三簇有部分重叠的二维数据,再叠加一些偏移点,模拟真实场景里的“簇间粘连”:

import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_blobs np.random.seed(42) # 生成三簇高斯分布,簇间有一定重叠 X, y_true = make_blobs(n_samples=300, centers=3, cluster_std=1.2, random_state=42) # 添一点全局噪点,让聚类更有挑战 noise = np.random.uniform(low=-6, high=6, size=(30, 2)) X = np.vstack([X, noise]) y_true = np.hstack([y_true, -1 * np.ones(30)]) # -1 表示噪点

这里的cluster_std=1.2是个值得注意的量。它控制簇内部离散程度,相当于给均值漂移上难度:标准差太小时每个簇像铁坨一样密集,算法的漂移过程非常顺利;标准差偏大时簇与簇之间糊成一片,带宽稍微大一点,几个簇就可能被全集成为一锅粥。新手练习时建议从cluster_std=1.0起步,先验证算法正确性,再逐渐加大难度。

两个需要注意的细节:

  • 随机种子必须固定,否则后面调参时每次生成的分布都不同,你根本无法判断效果变好是算法原因还是数据换了。
  • 噪声点范围要和真实簇范围相称。我这边真实簇分布在 (-4) 到 (4) 之间,噪声横跨 ([-6, 6]),这个比例既能让噪点破坏聚类,又不至于喧宾夺主。

2.2 高斯核与带宽的计算逻辑

很多人在资料里看到 Mean Shift 里有个高斯核,以为必须去啃核密度估计的数学推导。其实你只要理解核函数在这里是个“加权器”就行。高斯核长这样:

def gaussian_kernel(distance, bandwidth): """ 高斯核权重计算 - distance: 当前点到某个邻居间的欧氏距离 - bandwidth: 带宽参数,控制距离对权重的影响烈度 返回一个 0~1 之间的浮点数作为权重 """ return np.exp(-0.5 * (distance / bandwidth) ** 2)

两个点之间距离为 0 时,权重是 1,完全信任这个邻居;距离和带宽相等时,权重掉到约 0.6,还是有一半以上的信任;距离拉到带宽的 3 倍以上,权重趋近于 0,基本等于无视。你把它想成“人脉圈”:带宽是你认为‘自己人’的社交半径,半径内的人有发言权,越靠近你,话语权越大。

计算带宽这里有一个比较实用的统计方法,我最初看见书上写“凭经验决定”时整个人都不好了。后来在某实验室的一次分享里听到一个思路:计算所有样本点之间的成对距离,取一个分位数作为带宽初值。代码实现并不复杂:

from scipy.spatial.distance import pdist # 计算所有点两两之间的距离 pairwise_dist = pdist(X) # 取中位数作为带宽参考值 bandwidth_median = np.median(pairwise_dist) # 经验上取 0.3~0.6 倍中位数作为初值 bandwidth = bandwidth_median * 0.5 print(f"建议带宽:{bandwidth:.4f}")

这个思路背后的逻辑不复杂:如果带宽设得和数据点之间的典型距离相当,那每个点至少能照顾到身边一半的邻居,核密度估计不会太稀疏;再乘一个小于 1 的系数,是给密度峰多一点“锐化”空间,避免过度平滑导致簇合并。我在多个二维数据集上测试过,0.5倍中位数是个相当稳的起点,后面再靠可视化微调。

2.3 核心漂移迭代:手写实现并逐行注释

现在到了最核心的部分。我们要对数据集里的每个点,重复执行同一个动作:找邻居、算权重、更新位置、再找邻居。为防止死循环,需要设置两个终止条件:最多迭代次数max_iter,以及漂移距离小于某个阈值tol时提前收敛。

def mean_shift_single_point(point, data, bandwidth, max_iter=50, tol=1e-3): """ 对单个样本点执行均值漂移迭代,返回收敛后的中心点 参数: - point: 当前样本点的坐标,形状为 (n_features,) - data: 全部样本点,形状为 (n_samples, n_features) - bandwidth: 带宽 - max_iter: 最大迭代次数 - tol: 漂移量阈值,小于此值认为收敛 """ center = point.copy() for _ in range(max_iter): # 1. 计算当前中心到所有样本点的距离 distances = np.linalg.norm(data - center, axis=1) # 2. 用高斯核将距离转成权重 weights = gaussian_kernel(distances, bandwidth) # 3. 对权重做归一化,防止数值溢出或除零 weight_sum = np.sum(weights) if weight_sum == 0: break # 所有邻居权重都为零,无法继续漂移 # 4. 计算加权平均位置,这就是新的中心 new_center = np.sum(data * weights[:, np.newaxis], axis=0) / weight_sum # 5. 判断漂移量是否足够小 shift_distance = np.linalg.norm(new_center - center) center = new_center if shift_distance < tol: break return center

这段代码里最容易被新手忽略的是weights[:, np.newaxis]。data的形状是(300, 2),而经过高斯核算出的weights形状是(300,),一维数组直接去乘二维矩阵时会按行广播,但规则不够直观。加np.newaxis之后变成(300, 1),这样每一行乘上对应的标量权重,语义非常清晰。

另一个容易被忽略的是权重全为 0 的边界情况。理论上带宽只要大于 0,高斯核总会有非零输出,但在浮点数计算里,距离稍大时np.exp(-0.5 * (distance / bandwidth) ** 2)会下溢成 0。一旦某次迭代里所有点权重都是 0,weight_sum也就是 0,除零异常直接抛出。所以我特意加了个if weight_sum == 0: break,宁可让这个点提前停止迭代,也不能让程序崩溃。

2.4 全数据集漂移与簇中心合并

单个点的漂移逻辑完成以后,整个数据集的处理就是套个循环。但这里有个隐藏问题:每个点独立漂移,最终可能收敛到同一个峰上。比如一个高密度区有 100 个点,它们收敛的结果可能是几乎重合的 100 个中心,直接把这些当簇中心显然不合适。所以必须有一个合并步骤:把距离特别近的中心点归并成一个簇中心。

def mean_shift_cluster(data, bandwidth, max_iter=50, tol=1e-3): """ 完整均值漂移聚类过程 返回值: - cluster_centers: 簇中心集合 - labels: 每个样本点归属的簇编号 """ # 第一阶段:每个点独立漂移 centers = np.array([mean_shift_single_point(p, data, bandwidth, max_iter, tol) for p in data]) # 第二阶段:合并距离过近的中心点 unique_centers = [] for center in centers: # 判断当前中心是否与已有中心太近 if len(unique_centers) == 0: unique_centers.append(center) continue distances = np.linalg.norm(np.array(unique_centers) - center, axis=1) if np.min(distances) > bandwidth * 0.5: # 合并阈值 unique_centers.append(center) unique_centers = np.array(unique_centers) # 第三阶段:把每个样本点归到最近的中心 labels = [] for p in data: distances = np.linalg.norm(unique_centers - p, axis=1) labels.append(np.argmin(distances)) return unique_centers, np.array(labels) # 执行聚类 centers, labels = mean_shift_cluster(X, bandwidth=bandwidth) print(f"识别到 {len(centers)} 个簇")

合并阈值为什么取bandwidth * 0.5?因为两个中心如果距离小于半个带宽,说明它们已经被同一个高密度区域覆盖,本质上是同一个峰。这个系数是经验值,我试过更小的系数,结果簇数偏多;更大的系数会把本应分离的簇硬凑在一起。这个环节是整个算法里比较像“艺术”的地方,但好在它对结果不算特别敏感,给一个合理的比例就能稳定运行。

第三阶段的归属过程和 K-Means 最后一步几乎一致——每个点分配到离它最近的中心。这说明均值漂移虽然前段逻辑和 K-Means 完全不同,但最后的“标签分配”思想殊途同归。

3. 实操过程与调参实战

3.1 对非凸数据的聚类效果对比

光说不练假把式,我专门构造了两个经典的非凸数据集来验证均值漂移的成色:一个是环套环,一个是两个月牙。这两种分布在 K-Means 手下基本是“送人头”。

from sklearn.datasets import make_circles, make_moons # 环套环数据 X_circles, _ = make_circles(n_samples=300, factor=0.4, noise=0.05) # 月牙数据 X_moons, _ = make_moons(n_samples=300, noise=0.05)

用同样的 mean_shift_cluster 跑完以后,可以画成图来看效果。手写均值漂移在这两类数据上都能把内外环或者两个月牙清楚分开,原因还是那句老话:它依赖的是密度拓扑结构,而不是样本间的直线距离。高密度区域在哪,簇边界就在哪,不会像 K-Means 那样硬生生划一条平分线。

如果你手头暂时没有可视化代码,可以先用labels去比对真实标签,算一下调整兰氏指数(ARI),分数接近 1 说明聚类效果和真实分布高度一致。

3.2 完整可视化与效果评定

实践里画图是个好用的“照妖镜”,代码和参数有没有问题一眼便知。我用 Matplotlib 把原始数据、漂移过程、簇中心三面并排展示了一下,可以发现:一些原本位于两簇交界处的点,会朝一个方向不断“挤”过去,最终边界处的点沿着密度脊线滑向某一侧,这是均值漂移聚类的天然优势——边界模糊的样本会被密度微弱的差异“推到”更合理的簇里。

import matplotlib.pyplot as plt plt.figure(figsize=(8, 6)) plt.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', s=20) plt.scatter(centers[:, 0], centers[:, 1], c='red', marker='x', s=200, label='簇中心') plt.title(f"Mean Shift 聚类结果(带宽={bandwidth:.3f})") plt.legend() plt.show()

拿到图以后如何判断好坏?我一般看三点:簇数量是否和肉眼观察吻合;簇与簇之间是否有明显重叠残留;噪点是否被单独剥出来。如果发现簇数异常,不要急着改代码,先把带宽按 1.2 到 1.5 倍的方向调一调,再观察趋势。

3.3 带宽选择与收敛性调参

带宽直接决定密度估计的平滑程度,是最需要反复试验的参数。这里我总结了三条在不同数据规模下的经验策略:

  • 小样本(< 500 点):用pdist算全量距离代价不高,可以直接取中位数作为带宽候选值,上下浮动 20% 再跑三次,观察簇数波动。
  • 中等样本(千级):抽样 300~500 个点计算距离统计量,避免全量距离矩阵把内存撑爆。
  • 高维特征:先做标准化,然后对每个特征分别算方差,按方差比例给不同特征设置带宽缩扩系数。高维空间里所有距离都会变得“扁平”,不处理的话带宽往往给得过大,直接被拉成一个簇。

收敛性方面,我最常用的组合是max_iter=50、tol=1e-3。高分辨率数据集上可以把tol降到1e-4,但迭代次数要同步提高到 100,因为更小的阈值意味着需要更多次迭代才能满足停止条件。一个性能上的提醒:如果你直接对 1000 个点跑全量 pairwise 距离计算,然后每个点又在迭代中每次计算全部距离,复杂度是 (O(n^2 \times iter)),点多了速度会非常感人。实测 2000 点时单次聚类的耗时已经接近 40 秒,这个场景就要考虑用 KD-Tree 做邻居搜索来加速。

4. 常见问题排查与避坑技巧

4.1 数据归一化不彻底

均值漂移严重依赖欧氏距离,而欧氏距离对特征量纲极为敏感。假设数据里第一个特征单位是“厘米”,范围在 0~100,第二个特征单位是“米”,范围在 0~1,那么距离计算几乎完全被厘米这个特征主导,密度估计就失效了。

处理办法很直接:在跑聚类之前,用StandardScaler或MinMaxScaler做一次标准化。我自己的习惯是先StandardScaler,因为它的分布更接近高斯假设;但如果后续要用带宽中位数法,MinMaxScaler会把距离控制在一个固定区间内,带宽的可解释性更强。

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 所有后续计算都用 X_scaled centers, labels = mean_shift_cluster(X_scaled, bandwidth=bandwidth)

4.2 带宽取到全数据的中位数,簇数永远是 1

这个问题我见过太多人踩。原因是中位数距离代表数据集里“普通”两点之间的距离,而带宽取到这么大会让每个点的核函数覆盖大半个数据集,所有点都被平滑成一个峰,最终聚类数当然永远是 1。

解决办法是把带宽降到中位数的 0.3~0.5 倍,甚至更低。我常用的调试口诀是:“先给中位数的一半,看簇数;多了降一半,少了提两成”。这个口诀适用范围很广,至少能覆盖 90% 的二维低维数据聚类需求。

4.3 聚类结果不稳定,每次跑都不一样

如果随机种子没固定,每次生成的测试数据都不一样,聚类结果自然变,这不怪算法。但如果你固定了数据,结果还是变,那就要检查是不是某个步骤引入了随机性。我早期在一个项目里用np.random.choice做了数据抽样之后忘了固定种子,导致每次抽样范围不同,聚类边界就一直在跳。后来规定代码入口统一设np.random.seed(42),所有下游实验全部稳定复现,调参效率大幅提升。

4.4 新手最容易犯的向量化误区

这里我必须多说一句:写均值漂移时,新手往往会陷入三层for循环——外层遍历所有点、中层遍历迭代次数、内层遍历所有邻居。我自己第一版就是三层循环,500 个点跑了 20 多秒,实在不能忍。

后来做了两个优化:

  • 矩阵化邻居权重:把整份data当作邻居集合,一次算完全部距离,再用向量化方式更新中心,内层循环直接消失。
  • 中心点合并阶段用空间索引:当簇中心数量较多时,暴力比对多个中心之间的距离也费时间,用scipy.spatial.cKDTree可以显著加速。
from scipy.spatial import cKDTree tree = cKDTree(unique_centers) # 用树结构加速最近中心查询,替代第三阶段的逐点全量距离计算

4.5 快速问题定位清单

我把平时排查的路径整理成了一张表,遇到问题时可以照单查:

现象可能原因处理办法
簇数过多,碎成大量微簇带宽太小带宽向上调整 1.5~2 倍
簇数过少,趋于 1带宽太大带宽降到中位数的 0.3~0.5 倍
结果每次运行不一致数据生成或采样未固定种子设置全局np.random.seed()
聚类耗时过长全量距离计算 + 迭代次数多归一化后用 KD-Tree 加速
某维度特征完全不起作用量纲差异过大标准化或归一化后再聚类
收敛点分布在异常位置数据中存在离群噪声先做一步离群点修剪

5. 从演示到实战的最后一步

均值漂移聚类虽然不像 K-Means 那样在工业界遍地开花,但在图像分割、目标跟踪、用户行为分群这类场景里依然有不少份额。我在某图像处理 Demo 里用均值漂移做像素颜色聚类,带宽选的是颜色空间里的欧氏距离,跑出来的分割结果既平滑又保持了细节边缘,比直接上 K-Means 灵活得多。这个方向值得你后续深入。

如果你打算拿它处理真实业务数据,我的建议是:先可视化、后跑参、再部署。任何一个聚类项目,脱离可视化的调参都约等于盲人摸象,因为目标函数不会告诉你“这些簇到底好不好”。

最后分享一个我在实战里的小技巧:均值漂移对离群点有天然的“隔离”效果——低密度区域的点很难漂移到高密度峰上,最终会形成独立的微小簇或停留在原地。这一步可以用来做粗粒度的离群点检测。我在一个用户行为分群项目里就利用这个特性,把那些行为模式独特的高价值用户自动分离了出来,后续人工复核成本极低。这也是均值漂移这么多年没被 K-Means 完全替代的重要原因——它不只是聚类工具,更是一把探索数据密度结构的钥匙。

RELATED READING

延伸阅读

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