无监督学习里最容易被问“你写过哪些算法”的,大概率就是聚类。而聚类里绕不开的两个名字,一个是 K-means,一个是 DBSCAN。很多教程把这两个算法拆开讲,原理是说了,但落地时怎么选参数、怎么评估结果、数据长什么样不能硬套,这些往往语焉不详。这篇就来把 K-means 和 DBSCAN 从头到尾捋一遍,包括数学直觉、Python 手写实现、sklearn 全流程、效果对比,以及我实际跑数据时踩过的几个坑。
这篇内容适合刚接触机器学习、被“聚类”概念卡住的新手,也适合写了不少分类回归、但没正经做过无监督项目的同学。不涉及复杂数学推导,尽量用生活化的类比把原理讲透,代码可以直接跑,拿你自己的 CSV 换个路径就能用。
1. 无监督学习为什么先学聚类
1.1 没有标签的时候,先看数据怎么“抱团”
监督学习追求的是“输入 X 映射到标签 y”,训练数据里每一行都带答案。但现实里大量数据是没有标签的:用户画像没有“高价值/低价值”的标注,店铺没有“应该重点打折/维持现状”的标记,传感器数据也没有“正常/异常”的说明。这时候要做的是先看数据本身的结构——哪些样本天然长得像,哪些离得很远。
聚类要做的就是这件事:让相似的样本聚合在一起,让不相似的样本分开。聚类的结果不是用来直接预测,而是用来理解数据、做后续分析的起点。比如运营拿到的用户表只有“年龄、消费金额、访问时长”三个字段,没有用户等级,你用聚类跑一遍,很可能就跑出了“年轻高频低客单”“中年低频高客单”这类天然分群,之后运营策略、推荐策略全都基于这个分群展开。
1.2 K-means 和 DBSCAN 分别适合什么场景
K-means 的核心假设是“簇是凸的、大小差不多、密度均匀”——就像在均匀的圆纸片上画几个圆,每个圆里面都有足够多的点。它简单、快、可解释性强,几万条数据跑起来毫无压力。但如果数据形状是弯月形、环形或者密度悬殊很大,K-means 就会把本来该连在一起的簇拦腰砍断。
DBSCAN 走的是另一条路——密度连通。它不预设簇的数目,只要求每个簇内样本密度够高、簇之间被稀疏区域隔开。因此它对任意形状的簇都很友好:环形、半月形、甚至复杂的嵌套形状都能处理。代价是它有两个参数(epsilon 和 min_samples)需要调,调不好就全乱套。
这两者不是替代关系,而是“看数据长什么样再选”的关系。我的经验是:先跑一趟 K-means 当作基线,再跑 DBSCAN 对比几次,最后结合业务侧写判断哪个结果更可用。单纯说某个算法“更好”没意义,跟手里的数据形状、业务诉求、可解释性要求都挂钩。
2. K-means 原理拆解:迭代收敛的“组内平方和”
2.1 从“找质心”到“循环分配”,K-means 到底在做什么
K-means 的思路极其朴素:如果你已经知道数据里有 K 个簇,那就先随便放 K 个中心点(质心),然后把每个样本分给离它最近的中心,分完一轮后重新计算每个簇的中心,再分配,再计算,直到中心点几乎不动。
这个过程用公式表达就是最小化簇内平方和(SSE,Sum of Squared Errors):
SSE = Σᵢ Σ_{x∈Cᵢ} ||x - μᵢ||²
其中 μᵢ 是第 i 个簇的质心。这个公式的含义是:所有样本到所属簇中心的距离平方之和。K-means 每次迭代都在降低 SSE,保证收敛到局部最优(不保证全局最优,所以要多跑几次选最好结果)。
这里有个很容易误解的点:K-means 聚类出来的边界其实是“最近质心”边界,也就是 Voronoi 图。这个边界一定是直线的(在高维是超平面),所以它永远分不出弯弯曲曲的簇。理解这一点,你就明白为什么环形数据 K-means 必然失效了。
2.2 K 值怎么定:肘部法和轮廓系数双校验
K 值是最令新手头疼的参数。不知道该设几类时,最常用的办法是“肘部法”。具体做法是跑 K=1 到 K=10 的 K-means,记录每个 K 的 SSE,画出折线图。SSE 会随着 K 增大一直下降,但下降幅度会有一个明显的“拐点”——从这个点之后,再增加 K 带来的收益骤减,这个点就是肘部,对应比较合理的 K。
肘部法的问题是“拐点”有时候不明朗,曲线很平滑,你很难说哪里是肘。这时配合轮廓系数(Silhouette Coefficient)一起看。轮廓系数综合了“簇内紧凑度”和“簇间分离度”,取值在 -1 到 1 之间,越接近 1 说明聚类越好。实际操作中我会画双轴图:左边是 SSE 折线,右边是轮廓系数折线,两个指标都看一眼,取一个大家都不反对的 K。
2.3 手撕 K-means:40 行 Python 吃透迭代逻辑
用 sklearn 直接调 KMeans 当然方便,但我想大多数读者核心诉求是搞懂算法内部到底怎么运转的。看一遍源码不如自己写一遍。下面这份手写实现只依赖 numpy,我把每一步、包括初始质心、分配簇、更新质心、收敛判断全部拆开写清楚。
import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_blobs # 造一组二维数据:3个簇,带一定重叠 X, y_true = make_blobs(n_samples=300, centers=3, cluster_std=0.9, random_state=42) def kmeans_manual(X, k, max_iter=100, tol=1e-4): # 步骤1:随机选k个样本作为初始质心 np.random.seed(0) indices = np.random.choice(len(X), k, replace=False) centers = X[indices].copy() for i in range(max_iter): # 步骤2:每个样本分配到离它最近的质心(欧氏距离) # X: (n_samples, n_features) centers: (k, n_features) distances = np.linalg.norm(X[:, np.newaxis, :] - centers[np.newaxis, :, :], axis=2) labels = np.argmin(distances, axis=1) # 步骤3:每个簇重新计算质心(用簇内样本的均值) new_centers = np.array([ X[labels == j].mean(axis=0) if np.any(labels == j) else centers[j] for j in range(k) ]) # 步骤4:质心变化量若小于 tol 则停止 shift = np.linalg.norm(new_centers - centers) centers = new_centers if shift < tol: print(f"第 {i+1} 轮迭代后收敛,质心位移 {shift:.6f}") break # 重新算一次标签,返回结果 distances = np.linalg.norm(X[:, np.newaxis, :] - centers[np.newaxis, :, :], axis=2) labels = np.argmin(distances, axis=1) return labels, centers labels, centers = kmeans_manual(X, k=3) print("最终质心:\n", centers) print("簇内样本数:", np.bincount(labels))这段代码核心是那行距离计算:X[:, np.newaxis, :] - centers[np.newaxis, :, :]利用 numpy 广播机制,把 (300,2) 和 (3,2) 扩展成 (300,3,2) 再逐元素相减,一次算出全部样本到全部质心的距离,不用写 for 循环,速度快也清晰。
值得留意的是,逻辑里如果某个簇分配不到样本,要保留原质心,否则会报 nan。实际数据里这种“空簇”并不少见,尤其是 K 设大了或者初始质心选得不好时,这是手写 K-means 最容易踩的暗坑。
2.4 sklearn 里的 KMeans:核心参数逐个说清楚
生产环境中没必要每次手写,sklearn 的 KMeans 封装得已经够好。我常用的参数如下:
from sklearn.cluster import KMeans kmeans = KMeans( n_clusters=3, # 簇数,必须预先指定 init='k-means++', # 初始质心优化策略 n_init=10, # 跑10次取最好结果 max_iter=300, # 单次迭代上限 tol=1e-4, # 收敛容忍度 random_state=42 # 固定随机种子 )init='k-means++'是 sklearn 默认值,它比纯随机初始化靠谱得多:逐个选质心时,越远的样本越可能成为下一个质心,这样初始质心在数据里分布得开,收敛又快又稳。n_init=10意味着同样的数据跑 10 遍,每次初始质心略有不同,最终保留 SSE 最小的那次结果。这两项组合起来基本能避免“局部最优”的坑。
2.5 手写版和调库版怎么选
手写版的价值在于理解流程和排查思路,调库版的价值在于效率和鲁棒性。真实工作中我几乎不会在手写版上跑千万级数据,但 debug 或者写教学代码时,手写版一目了然。而且手写版稍微改几行就能变成 K-medoids、二分 K-means,方便做变体的原型验证。如果你只是处理日常表格数据,请直接用 sklearn,别自己造轮子。
3. DBSCAN 原理拆解:密度相连的“区域生长”
3.1 epsilon 半径和 min_samples 这两个参数到底怎么理解
DBSCAN 这个名字翻译过来是“基于密度的带噪声空间聚类”,它的核心就是“密度可达”。算法给每个样本算好“邻居”:在半径 epsilon 范围内有多少个样本,如果这个数量大于等于 min_samples,这个样本就是核心点;如果小于,但又有核心点邻居,就是边界点;两者都不是的就是噪声点。
理解这个机制最简单的类比是“村庄扩散”:每个村子里至少要有 min_samples 个村民(样本点),才配叫一个村子。如果有人住在村子的边缘地带,周围村民不够但离某个大村很近,也算这个村的;如果方圆 epsilon 内都荒无人烟,那就是“野人”,成了噪声。这个机制天然地把离群点剔除掉,这也正是 DBSCAN 相比 K-means 的一大核心优势:不需要你专门做异常值清洗。
epsilon 设太小,大部分点都会变成噪声;设太大,所有点都变成一个簇。min_samples 一般取 2 × 特征维度,但更稳妥的做法是结合数据量和经验判断。每次调参我都会把 epsilon 的取值范围从极小往大扫一遍,看结果怎么变化,找到“簇数量明显变化前”的稳定区间来定参数。
3.2 三种点类型:核心点、边界点、噪声点
算法把所有样本分为三类:
- 核心点:在 epsilon 半径内,邻居数量 ≥ min_samples。核心点是簇的“骨架”。
- 边界点:不是核心点,但落在某个核心点的 epsilon 范围内。边界点能加入簇但不继续扩展簇。
- 噪声点:既不是核心点,也不被任何核心点覆盖到。噪声点不属于任何簇,通常标记为 -1。
有个细节值得注意:DBSCAN 的“邻居数量”默认是包含样本自身的,所以算核心点时,如果 min_samples 取 5,实际有效邻居只要 4 个就行。这个在调试时容易让人产生“怎么差一个就不过”的困惑。
3.3 手撕 DBSCAN:用队列体会“密度可达”的扩散过程
DBSCAN 的实现理解起来比 K-means 要绕一些,所以我建议你也亲手写一版,彻底明白“密度传播”是怎么发生的。这里需要用到 sklearn 的NearestNeighbors函数来高效找邻居,其他逻辑纯 numpy 实现:
from sklearn.neighbors import NearestNeighbors def dbscan_manual(X, eps, min_samples): n = len(X) labels = np.full(n, -1, dtype=int) # -1 表示未分配/噪声 # 预计算所有点的邻居 nn = NearestNeighbors(radius=eps).fit(X) neighbors_list = nn.radius_neighbors(X, radius=eps, return_distance=False) cluster_id = 0 for i in range(n): if labels[i] != -1: continue # 已被访问过 if len(neighbors_list[i]) < min_samples: labels[i] = -1 # 标记为噪声(暂时) continue # 发现新的簇,用队列做广度优先扩散 labels[i] = cluster_id queue = list(neighbors_list[i]) while queue: point = queue.pop(0) if labels[point] == -1: labels[point] = cluster_id if labels[point] != -1 and labels[point] != cluster_id: continue # 已属于其他簇 if len(neighbors_list[point]) >= min_samples: for nbr in neighbors_list[point]: if labels[nbr] == -1: labels[nbr] = cluster_id queue.append(nbr) cluster_id += 1 return labels这里每次用一个队列去扩散:从核心点出发,邻居里够格当核心点的就继续传播它的邻居,不够格的就只给它打上当前簇标记而不扩散。这个“BFS 蔓延”的过程就是密度可达的全部秘密。当然,这个手写版的复杂度偏高,大量数据不建议跑,演示逻辑够用就好。
3.4 sklearn 里的 DBSCAN:核心参数与性能注意点
实际调库时你会遇到一个明显的性能问题:DBSCAN 最朴素的实现是 O(n²) 的,一百万条数据直接跑能等哭你。sklearn 把核心算法用 C 写好了,配合 KD-Tree 或 Ball Tree 做邻居搜索,性能改善非常多。我平时跑五十万条二维数据,sklearn 版几秒出结果,但手写版可能要等几分钟。
from sklearn.cluster import DBSCAN dbscan = DBSCAN( eps=0.45, min_samples=10, metric='euclidean', algorithm='auto' ) labels = dbscan.fit_predict(X)algorithm 默认 'auto' 会在大数据量时自动选 BallTree,这点不用操心。真正需要操心的是 metric:如果特征有量纲差异,比如一列是年龄(0-80),一列是年薪(20000-200000),直接算距离,年薪这一列几乎完全主导了距离计算,聚类结果就废了。所以跑 DBSCAN 之前必须先做标准化。
4. 数据预处理和特征标准化:聚类不比分类,量纲会吞掉结构
4.1 为什么标准化对聚类几乎算“强制要求”
分类任务里,如果特征量纲差异大,树模型照样分得开;线性和神经网络需要标准化,但效果赖另外的因素更多。聚类却敏感得多:它所有的流程都在距离空间里做,一列数字从 0 到 100000 会直接覆盖掉另一列从 0 到 1 的全部波动。K-means 算簇中心时会偏向大数值维度,DBSCAN 查邻居时也会觉得“大数维度远的点 = 全体远的点”。
所以不管跑哪个聚类算法,第一步永远是 StandardScaler 或 MinMaxScaler。一般用 StandardScaler 让每列变成均值 0、方差 1 的标准高斯分布;如果确定数据的分布区间有业务意义,比如评分是 0-5、数量是 0-99999,想保留上下界,用 MinMaxScaler 也行。我的习惯是:默认 StandardScaler,除非特征含义有明确“绝对数值”的约束,否则不做别的选择。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X)注意,这里一定要先 fit 再 transform 同一个数据,不能直接 X_std = (X - X.mean()) / X.std() 这种手写方式吗?也能,但 sklearn 的 scaler 会记住均值和方差,方便之后对未知新样本做同样的变换。实操里,你不但要把训练数据标准化,后面把新数据灌进模型判断它属于哪个簇时,也要用同一套 scaler 参数,不然就出现“新样本和旧簇中心不在同一个坐标系里”的尴尬。
4.2 高维数据的“维度灾难”对聚类的隐蔽影响
特征维度一旦大于 10,距离会趋向于均匀化——大部分点到点的距离都差不多,这会让 DBSCAN 的密度定义很难成立。应对方法有几个:先做 PCA 降到 2-10 维再聚类;或者换成专门面向高维的聚类算法;或者直接接受聚类结果,把它当作“近似语义分群”。我对比过多次,最常见有效的做法是 PCA 降到 8-10 维,保留 85% 以上方差然后跑 DBSCAN,效果经常比原始高维空间直接跑好很多。
4.3 真实数据里的“脏点”怎么处理
聚类对异常点其实没那么怕,DBSCAN 本身就能把噪声标出来,K-means 则会硬性把离群点划到某个簇里,导致质心被拉偏。如果数据里明显有少量大离谱的离群值(如某个用户的消费金额是其他人的一百倍),先手动剔除再用 K-means 会稳很多。你可以先用 DBSCAN 跑一遍,把 -1 标签的样本拿出来审视一下,确认确实是异常再删,再跑 K-means。这个“两步走”的办法我在真实项目里常年用。
5. 聚类效果评估:如何衡量“聚得好”
5.1 轮廓系数:不看答案,也能打分
没有真实标签的情况下,最常用的是轮廓系数。对每个样本,轮廓系数 s(i) = (b(i) - a(i)) / max(a(i), b(i)),其中 a(i) 是它到同簇其他样本的平均距离,b(i) 是它到最近其他簇所有样本的平均距离。s(i) 接近 1 说明样本与自己的簇紧凑,离别的簇很远;接近 0 说明它正好在簇边界处;接近 -1 说明它可能被分错了。
sklearn 一行搞定:
from sklearn.metrics import silhouette_score score = silhouette_score(X_scaled, kmeans_labels) print(f"轮廓系数: {score:.4f}")但轮廓系数有个偏向:它更喜欢凸形的簇,因此 DBSCAN 在环形数据上的轮廓系数很可能不如 K-means,但实际结果却更合理。评估指标永远只是参考,不能盲目唯分数论。
5.2 有真实标签时还能看 ARI 和 NMI
有些数据集实际上是带标签的(比如手写数字、学术数据集),只是你没在聚类时用。这种情况下可以用调整兰德指数(ARI)和标准化互信息(NMI)来对比“预测分簇”和“真实类别”的吻合度。二者都取值 [0,1],越大越好。这些指标特别适合在调参时快速判断“哪个参数组合更接近数据本质”。
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score print("ARI:", adjusted_rand_score(y_true, labels)) print("NMI:", normalized_mutual_info_score(y_true, labels))5.3 画图观察:聚类结果必须“肉眼可见”
数值归数值,真正常用的是把聚类结果可视化。二维数据直接散点图,三维数据可以降维再画,或者用 PCA 投影后画。可视化是发现问题的最高效手段,比任何数学指标都直观。有一次我看着轮廓系数 0.6 觉得挺好,一画图发现数据里有个细长的月牙形簇被 K-means 硬切成了两半,换 DBSCAN 后轮廓系数反而降到 0.4,但图上清清楚楚是对的簇。这就是“指标是辅助,图才是王道”。
6. 实战案例:对用户消费行为数据进行聚类分群
6.1 生成带“形状”的模拟数据来展示算法差异
理论说再多不如数据跑一跑。我们用 sklearn 造两组数据:一组是标准的高斯圆形簇,一组是噪声较多的环形簇。同样的数据分别跑 K-means 和 DBSCAN,看结果差异。
import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import make_moons, make_blobs from sklearn.cluster import KMeans, DBSCAN from sklearn.preprocessing import StandardScaler # 数据1:标准簇 X1, _ = make_blobs(n_samples=300, centers=2, cluster_std=0.8, random_state=42) # 数据2:环形/月牙形(DBSCAN 的强项) X2, _ = make_moons(n_samples=300, noise=0.08, random_state=42) for name, X in [("blobs", X1), ("moons", X2)]: Xs = StandardScaler().fit_transform(X) kmeans = KMeans(n_clusters=2, random_state=42).fit(Xs) dbscan = DBSCAN(eps=0.2, min_samples=5).fit(Xs) fig, ax = plt.subplots(1, 2, figsize=(10, 4)) ax[0].scatter(Xs[:, 0], Xs[:, 1], c=kmeans.labels_, cmap='viridis', s=20) ax[0].set_title(f"{name} - KMeans") ax[1].scatter(Xs[:, 0], Xs[:, 1], c=dbscan.labels_, cmap='viridis', s=20) ax[1].set_title(f"{name} - DBSCAN") plt.show()跑完你会看到:blobs 数据上两者几乎一致;moons 数据上 K-means 会把“上半月”和“下半月”拦腰切断成四块难看的区域,DBSCAN 则完美地把两条月牙分开。这就是“算法适配数据形状”最直白的一课。实际工作中,我遇到过客户需求是根据轨迹经纬度划分通勤区域,用 K-means 怎么调都不对,后来换成 DBSCAN,瞬间就合理了——因为通勤轨迹本身就是带状分布,不是圆形的。
6.2 sklearn 全流程:加载 CSV 到聚类输出的完整代码
如果数据是本地 CSV,全流程大概是这样的。用一份用户消费表举例,字段包括年龄、年消费金额、月访问次数:
import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans, DBSCAN from sklearn.metrics import silhouette_score df = pd.read_csv("user_consumption.csv") # 假设前三列是特征 X = df[["age", "annual_spend", "monthly_visits"]].values # 1. 标准化 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 2. KMeans 加轮廓系数选 K best_k, best_score = None, -1 for k in range(2, 9): km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(X_scaled) score = silhouette_score(X_scaled, km.labels_) if score > best_score: best_k, best_score = k, score print(f"最佳 K = {best_k},轮廓系数 = {best_score:.4f}") km = KMeans(n_clusters=best_k, n_init=10, random_state=42).fit(X_scaled) df["cluster_kmeans"] = km.labels_ # 3. DBSCAN(需要先扫参) best_eps = 0.5 db = DBSCAN(eps=best_eps, min_samples=10).fit(X_scaled) df["cluster_dbscan"] = db.labels_ # 4. 看每个簇的统计特征,用于业务解读 print(df.groupby("cluster_kmeans")[["age", "annual_spend", "monthly_visits"]].mean())这段代码已经可以直接换成你自己的数据用。实际项目里,我还会把聚类结果导成一张带簇标签的 Excel,方便业务同事去人工核对画像跟常识是否吻合。一个簇的平均年龄 37、年均消费 5.6 万、月访问 22 次,业务一眼就能看出是“品质家庭型”;如果聚类出来的簇均值跟业务直觉对不上,不要急着改数据,先看看是不是参数没调好或者特征选择有问题,最后才是考虑数据本身的质量。
6.3 给簇“取名字”和业务落地的桥梁
聚类结果不是终点,取名字、下结论才是终点。每个簇的质心特征翻译成业务语言是必须做的。拿上面的三个簇举例:
- 簇 0:年龄均值 23、消费低、访问频繁。可以叫“离囤型”或“围观型”,运营策略以种草为主,不发大额券。
- 簇 1:年龄均值 38、消费极高、访问中等。可以叫“高价值已锁定型”,运营策略以会员维护和高客单新品推荐为主。
- 簇 2:年龄均值 51、消费中等、访问少。可以叫“慢热保守型”,触达方式要电话回访,而不是只推 App 推送。
这些名字不是算法生成的,是基于领域理解给簇“贴的标签”。聚类提供的是结构,怎么把结构翻译成行动方案,才是项目的价值所在。写报告时一定要带上每个簇的质心数值表和簇内样本数占比,否则业务方很难相信“你这个簇分完有什么用”。
6.4 深入一点:模型怎么把新样本映射到已有簇
模型训练好后,新来一个用户,怎么判断它属于哪个簇?K-means 可以用predict,直接找最近的质心:
new_user = np.array([[30, 30000, 10]]) new_user_scaled = scaler.transform(new_user) print(km.predict(new_user_scaled))但 DBSCAN 没有predict方法。这是它的一个重要局限:它是对“整批数据”做聚类的,新样本进来没法增量式地归类(sklearn 版本更新到 1.3 后依然没有直接预测接口)。实际工作中的妥协办法是:把新样本和旧数据拼接起来重新跑一次 DBSCAN,或者先用 K-means/分类模型(用聚类结果作为标签去训练一个分类器)来对新样本做预测。后者是我更推荐的思路,因为 DBSCAN 只是帮你找出了簇的结构,结构找到后建一个 KNN 或者逻辑回归来模拟决策边界,既快又稳定。
7. 干货参考:K-means 与 DBSCAN 的对比速查
7.1 二分法选型:拿到数据先问自己几个问题
快速选型的判断逻辑并不复杂:
- 知不知道数据大概有几类?如果知道,K-means 起步没问题。
- 数据是不是凸形、圆形簇?如果是,K-means 首选。
- 数据形状可能不规则、环形、长条、密度不均匀?优先 DBSCAN。
- 对噪声敏感吗?DBSCAN 能直接告诉你哪些点是离群点。
- 几百万条甚至上亿条数据?DBSCAN 的调参和计算成本会高一些,K-means 更轻量。
在真实项目里,我经常两个算法都跑,结果放一起对比,挑业务上更说得通的那份。没必要为了“某个算法更高级”就只用某个算法。
7.2 参数调节速查表
| 算法 | 核心参数 | 作用 | 调参经验 |
|---|---|---|---|
| K-means | n_clusters | 簇数目 | 肘部法 + 轮廓系数综合判断 |
| K-means | n_init | 重复次数 | 默认 10,小数据可调大防局部最优 |
| K-means | init | 质心初始化 | 一般用 k-means++ |
| DBSCAN | eps | 邻域半径 | 画 K 距离图拐点,或网格扫参 |
| DBSCAN | min_samples | 密度阈值 | 一般取 2 × 特征维度,或按数据量调 |
| 通用 | 标准化 | 消除量纲 | 几乎所有聚类任务前都做 |
7.3 K 距离图怎么画,eps 怎么选
DBSCAN 最麻烦的是选 eps。简单办法是画 K 距离图:对每个样本算它到第 min_samples 近的邻居的距离,将这些距离从小到大排序画折线,拐点对应的距离大致就是合适的 eps。这个 K 距离图的“拐点”比举重若轻,但确实在工程里很实用。
from sklearn.neighbors import NearestNeighbors k = 10 # 和 min_samples 保持一致或略大 nn = NearestNeighbors(n_neighbors=k).fit(X_scaled) distances, indices = nn.kneighbors(X_scaled) k_dist = np.sort(distances[:, -1]) plt.plot(k_dist) plt.xlabel("样本编号排序") plt.ylabel(f"第 {k} 近邻距离") plt.show()看曲线拐点,横坐标是“排序后的样本”,纵坐标是“到第 k 个邻居的距离”。拐点左边曲线陡峭上升,右边趋缓,取拐点横线对应的 y 值作为 eps 即可。不过这个拐点也不太容易自动定,我通常先扫几个 eps 值快速看簇数变化,再结合业务定。
8. 常见问题与排查技巧实录
8.1 K=3 总是跑出一堆噪声怎么办
如果 K-means 的 K 设了 3,但某个簇里明显一把低密度散点(几乎是噪声),先别怀疑 K 不对。大概率是数据本身的离群点太狠,把质心拉偏了。解决办法是先画散点图看离群点,手动剔除后重新聚类。或者干脆换 DBSCAN,让噪声阶段直接被标成 -1,不用专门清洗。我的经验是:在 K-means 之前先用 DBSCAN 跑一遍,把标为 -1 的点去掉,再做 K-means,结果经常有明显的提升。
8.2 DBSCAN 每次结果不一样吗
DBSCAN 是确定性算法:同样的参数、同样的数据,每次跑出来一模一样(不考虑多线程下邻居搜索的非确定顺序)。这一点比 K-means 心安。而 K-means 因为随机初始化,结果每次可能略有不同,所以要固定random_state才能可复现。调试时一定要固定随机种子,不然不同次画出不同图,你都不知道是参数问题还是初始化问题。
8.3 数据一列是分类变量(如性别、城市),能直接聚类吗
不能直接丢进去。距离算法不能理解“北京”、“上海”这种字符串。先把分类变量做 one-hot 编码,再丢进聚类。但 one-hot 之后维度会膨胀,而且距离语义变得不太自然,比如“男/女”编码成 0/1 后距离差 1,问题倒不大;城市编码成上百维 one-hot 后,距离主要由城市这一列主导了,聚类很容易变成“同一城市归一起”,这不一定是坏事,但一定要意识得到。
如果只有两三个分类列,聚类结果往往还可以接受;如果分类列很多,建议手动加权或先做一次类别相似度嵌入(比如目标编码),再来聚类。
8.4 数据量上百万,跑不动怎么办
先抽样跑通流程,再全量跑最终参数。很多聚类任务根本不需要全部数据参与,用分层抽样取 20 万条跑出参数,再全量预测(K-means 可以 predict,DBSCAN 只能重新跑)。如果全量 DBSCAN 还是扛不住,考虑用 HDBSCAN(sklearn 的第三方扩展)或者 MiniBatchKMeans(K-means 的批量近似版)。在电商用户分群场景里,MiniBatchKMeans 是我处理千万级用户数据的首选。
8.5 聚类结果无法复现
如果设了random_state还是复现不了,检查两件事:第一,代码里的任何涉及随机的地方(数据切分、初始化)是否都设了种子;第二,是否依赖了外部环境(比如 GPU 运算的浮点精度)。在纯 CPU 跑 sklearn 聚类,设好随机种子基本就能复现。别用np.random.seed试图统一 sklearn 内部的种子,KMeans 对随机种子有独立的random_state参数。
9. 实操总结与再一次提醒
首批跑聚类项目,我强烈建议你先画图、再跑算法、最后看指标,顺序反了会浪费很多时间。K-means 适合先用来探索数据“大概有几团”,DBSCAN 适合用来处理“形状不规则”以及“非常想标注噪声”的场景。两者不是竞争关系,而是一个流程中的两步——先用 K-means 得到大致簇数范围,再用 DBSCAN 验证边界形状和离群点,最后把聚类结果喂给分类模型做新样本判断,这套组合拳在业务项目中格外好用。
最后再说两个容易忽略的小细节。一是标准化要放到所有距离计算之前,千万不能漏。二是一切聚类结论一定要结合业务方验证——算法分出来的簇再漂亮,业务说“这两群人在实际运营里没差异”,那就得复查特征选择和参数;算法说“这群人特征模糊”,业务反馈“这本来就是过渡群体”,那只要不投入资源即可。无监督学习没有准确率的硬性考核,落地是否有效,最终看你有没有把“分群”翻译成“行动”。
遇到具体的聚类任务,多跑几次参数组合,别怕调参浪费时间——调参的过程,实际上就是你在加深对数据分布理解的过程。这个理解,才是聚类项目里最值钱的产出。