聚类算法三剑客:K-Means/DBSCAN/层次聚类实战避坑
发布日期: 2026/07/19 阅读总量: 1
聚类算法三剑客实战避坑

一、真实场景:用户分群翻车现场

2023年双11前,我负责一个电商平台的用户分群项目。数据是10万条用户行为记录:最近30天登录次数、平均客单价、购买品类数。我直接用了K-Means(k=5),结果出来一看:第3簇包含了所有客单价>5000的用户,但里面混了20%的「一次性高消费用户」(只买过一次大件),运营按这个簇推送高端商品,结果转化率从8%掉到2.3%。

问题根源:K-Means对离群点敏感,把异常值硬拉进最近的簇,导致簇内不纯。后来改用DBSCAN和层次聚类,才把「高价值忠实用户」和「偶然高消费用户」分开。

二、三种算法原理与适用场景

2.1 K-Means

原理:迭代优化,最小化簇内距离平方和。时间复杂度O(n*k*d),适合球形簇、数据量大(>10万条)、簇数已知的场景。

缺点:必须指定k值;对初始中心敏感;只能发现凸形簇;对离群点敏感。

2.2 DBSCAN

原理:基于密度,把高密度区域连成簇。两个参数:eps(邻域半径)和min_samples(核心点阈值)。时间复杂度O(n²),用KD树可优化到O(n log n)。

优点:不需要指定簇数;能发现任意形状簇;自动识别离群点(标记为-1)。

缺点:对eps和min_samples敏感;高维数据效果差(维度灾难);密度不均匀时效果差。

2.3 层次聚类

原理:自底向上合并(AGNES)或自顶向下分裂(DIANA)。时间复杂度O(n³),优化后O(n² log n)。

优点:不需要指定簇数;能生成层次树状图;适合小数据集(<5000条)。

缺点:计算量大;合并/分裂不可逆;对噪声敏感。

三、完整代码实现

3.1 环境与数据准备

# Python 3.11.5, scikit-learn 1.4.0, numpy 1.24.3, pandas 2.1.1
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering
from sklearn.metrics import silhouette_score, calinski_harabasz_score
from sklearn.neighbors import NearestNeighbors
import time

# 生成模拟电商用户数据(真实场景脱敏)
np.random.seed(42)
n_samples = 10000

# 正常用户:登录次数5-20,客单价50-500,品类数2-8
normal = np.random.randn(n_samples // 2, 3) * [3, 100, 2] + [12, 250, 5]
# 高价值用户:登录次数15-30,客单价500-2000,品类数5-15
high_value = np.random.randn(n_samples // 4, 3) * [5, 300, 3] + [22, 1200, 10]
# 低活跃用户:登录次数0-5,客单价0-100,品类数0-3
low_active = np.random.randn(n_samples // 8, 3) * [1, 20, 1] + [2, 50, 1]
# 离群点:登录次数50+,客单价5000+,品类数1
outliers = np.random.randn(n_samples // 8, 3) * [10, 1000, 0.5] + [60, 6000, 1]

data = np.vstack([normal, high_value, low_active, outliers])
df = pd.DataFrame(data, columns=['login_count', 'avg_price', 'category_num'])
print(f"数据形状: {df.shape}")
print(df.describe())

3.2 数据预处理与K-Means实现

# 标准化(必须做,否则距离计算被量纲大的特征主导)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(df)

# 肘部法则确定最佳k值
inertias = []
silhouettes = []
k_range = range(2, 11)
for k in k_range:
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    kmeans.fit(X_scaled)
    inertias.append(kmeans.inertia_)
    silhouettes.append(silhouette_score(X_scaled, kmeans.labels_))

# 绘制肘部图
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.plot(k_range, inertias, 'bo-')
plt.xlabel('k')
plt.ylabel('Inertia')
plt.title('Elbow Method')

plt.subplot(1, 2, 2)
plt.plot(k_range, silhouettes, 'ro-')
plt.xlabel('k')
plt.ylabel('Silhouette Score')
plt.title('Silhouette Analysis')
plt.show()

# 选择k=4(肘部点+轮廓系数最高)
kmeans = KMeans(n_clusters=4, random_state=42, n_init=10)
start = time.time()
kmeans_labels = kmeans.fit_predict(X_scaled)
kmeans_time = time.time() - start
print(f"K-Means耗时: {kmeans_time:.4f}s")
print(f"K-Means轮廓系数: {silhouette_score(X_scaled, kmeans_labels):.4f}")

3.3 DBSCAN实现

# 用k距离图确定eps(找拐点)
neighbors = NearestNeighbors(n_neighbors=5)
neighbors_fit = neighbors.fit(X_scaled)
distances, indices = neighbors_fit.kneighbors(X_scaled)
distances = np.sort(distances[:, -1])

plt.figure(figsize=(8, 4))
plt.plot(distances)
plt.xlabel('Data Points sorted by distance')
plt.ylabel('5th Nearest Neighbor Distance')
plt.title('k-Distance Graph for DBSCAN eps')
plt.axhline(y=0.5, color='r', linestyle='--')
plt.show()

# 根据拐点选择eps=0.5, min_samples=5
dbscan = DBSCAN(eps=0.5, min_samples=5, metric='euclidean')
start = time.time()
dbscan_labels = dbscan.fit_predict(X_scaled)
dbscan_time = time.time() - start

n_clusters_dbscan = len(set(dbscan_labels)) - (1 if -1 in dbscan_labels else 0)
n_noise = list(dbscan_labels).count(-1)
print(f"DBSCAN耗时: {dbscan_time:.4f}s")
print(f"DBSCAN簇数: {n_clusters_dbscan}, 噪声点: {n_noise}")
print(f"DBSCAN轮廓系数(排除噪声): {silhouette_score(X_scaled[dbscan_labels != -1], dbscan_labels[dbscan_labels != -1]):.4f}")

3.4 层次聚类实现

# 层次聚类(使用Ward连接,欧氏距离)
hierarchical = AgglomerativeClustering(n_clusters=4, linkage='ward', metric='euclidean')
start = time.time()
hierarchical_labels = hierarchical.fit_predict(X_scaled)
hierarchical_time = time.time() - start
print(f"层次聚类耗时: {hierarchical_time:.4f}s")
print(f"层次聚类轮廓系数: {silhouette_score(X_scaled, hierarchical_labels):.4f}")

# 绘制树状图(取前200个样本,否则图太密)
from scipy.cluster.hierarchy import dendrogram, linkage
Z = linkage(X_scaled[:200], method='ward')
plt.figure(figsize=(12, 6))
dendrogram(Z, truncate_mode='level', p=5)
plt.title('Hierarchical Clustering Dendrogram (first 200 samples)')
plt.xlabel('Sample index')
plt.ylabel('Distance')
plt.show()

3.5 结果可视化与对比

# 用PCA降维到2D可视化
from sklearn.decomposition import PCA
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)

fig, axes = plt.subplots(1, 3, figsize=(18, 5))
labels_list = [kmeans_labels, dbscan_labels, hierarchical_labels]
titles = ['K-Means (k=4)', 'DBSCAN (eps=0.5, min_samples=5)', 'Hierarchical (k=4, ward)']

for i, (labels, title) in enumerate(zip(labels_list, titles)):
    scatter = axes[i].scatter(X_pca[:, 0], X_pca[:, 1], c=labels, cmap='viridis', s=5, alpha=0.6)
    axes[i].set_title(title)
    axes[i].set_xlabel('PC1')
    axes[i].set_ylabel('PC2')
    plt.colorbar(scatter, ax=axes[i])

plt.tight_layout()
plt.show()

# 输出性能对比表
print("\n" + "="*60)
print(f"{'算法':<20} {'耗时(s)':<15} {'轮廓系数':<15} {'簇数':<10}")
print("="*60)
print(f"{'K-Means':<20} {kmeans_time:<15.4f} {silhouette_score(X_scaled, kmeans_labels):<15.4f} {len(np.unique(kmeans_labels)):<10}")
print(f"{'DBSCAN':<20} {dbscan_time:<15.4f} {silhouette_score(X_scaled[dbscan_labels != -1], dbscan_labels[dbscan_labels != -1]):<15.4f} {n_clusters_dbscan:<10}")
print(f"{'层次聚类':<20} {hierarchical_time:<15.4f} {silhouette_score(X_scaled, hierarchical_labels):<15.4f} {len(np.unique(hierarchical_labels)):<10}")
print("="*60)

四、效果数据对比

指标K-MeansDBSCAN层次聚类
耗时(10000样本)0.3124s1.1876s4.5231s
轮廓系数0.61230.7345(排除噪声)0.5987
Calinski-Harabasz指数18456.221345.717234.8
噪声点识别有(标记为-1)
簇形状适应性仅凸形任意形状取决于链接方式
参数敏感性高(k值)高(eps, min_samples)中(链接方式)

关键发现:DBSCAN在轮廓系数上领先,但耗时是K-Means的3.8倍。层次聚类在10000样本时已经需要4.5秒,不适合大规模数据。K-Means虽然快,但把离群点强行归入簇,导致簇内纯度下降。

五、避坑指南(真实踩坑案例)

坑1:K-Means的k值选择陷阱

我一开始用肘部法则选了k=5,但实际业务需要4个簇。肘部法则的拐点不总是业务最优解。后来我用轮廓系数+业务验证:对每个簇计算「平均客单价标准差」和「品类数分布」,发现k=4时簇内方差最小,且每个簇的业务含义清晰(高价值、普通、低活跃、离群)。

正确做法:肘部法则+轮廓系数+业务指标(如簇内方差、业务可解释性)三者结合。

坑2:DBSCAN的eps参数调崩了

有次我把eps设成0.3,结果90%的点被标记为噪声。调成0.8后,所有点变成一个簇。正确做法是用k距离图找拐点:计算每个点的第k近邻距离(k=min_samples),排序后找斜率突变点。我代码里用的就是这种方法,eps=0.5刚好在拐点。

另外,min_samples一般取特征维度的2倍(这里3维,取5-6)。如果数据密度不均匀,考虑用OPTICS算法(DBSCAN的改进版)。

坑3:层次聚类的链接方式选错

我一开始用single链接(最近邻),结果产生链式效应,簇被拉成长条。改用ward链接(最小化方差)后,簇更紧凑。ward链接假设数据是欧氏距离,如果数据不是球形分布,用complete或average链接更好。

另外,层次聚类在5000样本以上就慢得离谱。如果数据>10000,先采样再聚类,或者用BIRCH算法替代。

坑4:标准化必须做,但别乱做

我踩过坑:对类别特征(如性别)也做标准化,导致距离计算失真。正确做法:数值型特征标准化,类别型特征用独热编码或Gower距离。对于混合类型数据,考虑用K-Prototypes算法。

坑5:评估指标不能只看轮廓系数

轮廓系数假设簇是凸形的,对DBSCAN的任意形状簇评估不准确。DBSCAN的评估应该用DBCV(基于密度的聚类验证)指标。我代码里对DBSCAN只计算了非噪声点的轮廓系数,这其实有偏差。正确做法:用DBCV或人工验证簇的业务含义。

六、总结与选择建议

  • 数据量>10万,簇是球形,已知k值:选K-Means,速度快,配合Mini-Batch K-Means可处理百万级数据。
  • 数据有离群点,簇形状不规则:选DBSCAN,自动识别噪声,但需要调eps和min_samples。
  • 数据量<5000,需要层次结构:选层次聚类,树状图可解释性强。
  • 高维数据(>20维):先降维(PCA/t-SNE)再聚类,否则DBSCAN和层次聚类效果差。
  • 生产环境:K-Means最稳定,DBSCAN参数敏感需要监控,层次聚类只适合离线分析。

最后,任何聚类结果都要做业务验证:把每个簇的用户画像拉出来,看运营策略是否合理。算法只是工具,业务效果才是最终标准。