聚类算法选型实测:K-Means/DBSCAN/层次聚类
发布日期: 2026/08/17 阅读总量: 1

一个让运营追着我骂了一下午的聚类

上个月接了个直播间用户分群需求:20万条行为特征,输出8~10个人群包。我第一反应就是K-Means,标准化→肘部法定K→聚类,一气呵成。轮廓系数0.31,模型评估看起来“还行”。结果运营拿到人群包直接炸了:第2类用户里,既有年消费10万的高净值沉默用户,又有专抢优惠券的低价捡漏党。这两类人行为曲线完全不一样,但K-Means强行把它们揉成了一个球。

问题出在哪?K-Means假设簇是凸的、大小接近的球形,而真实用户行为数据很多是长尾+任意形状。我后来把三种聚类算法(K-Means、DBSCAN、层次聚类)在同一批数据上做了系统对比,跑了27组实验。这篇把结果和踩坑记录都写出来。

先看结论:三种算法在什么场景下能打

算法 核心假设 参数 适合形状 噪声处理 时间复杂度 能跑多大样本
K-Means 簇是凸的、各向同性 k(簇数) 球形簇 不识别,强分配给最近簇 O(n·k·t) 百万级没问题
DBSCAN 簇内密度 > 簇间密度 eps、min_samples 任意形状 天然区分,标签为-1 O(n²),有空间索引可到O(n·log n) 十万级要小心调参
层次聚类 样本间距离可度量 n_clusters 或 distance_threshold、linkage 任意形状 对离群点敏感 O(n³)(凝聚式) 建议≤1万

一句话:不知道有几个簇且形状不规则,选DBSCAN;样本量小且要层级结构,选层次聚类;数据量大且簇接近球形,选K-Means。

实验设计

为了把三种算法放在同一把尺子下量,我构造了三组带真实标签的数据:

  • blobs:3个高斯球簇,5000样本,8维特征——模拟“规则”的运营分群场景。
  • blobs+noise:2个球簇 + 20%均匀噪声,5000样本——模拟真实数据里的脏点。
  • moons:两个半月形簇,2000样本,2维特征——模拟“买了A的人才会买B”这类非凸行为模式。

评估指标用ARI(调整兰德指数,1=完美)、轮廓系数(越大越好)、噪声比例、wall time。环境:Python 3.11.6 / scikit-learn 1.3.2 / numpy 1.24.3,Apple M2 Pro,单线程。所有数据先过StandardScaler标准化。

生成数据

# gen_data.py
# Python 3.11.6 / numpy 1.24.3 / scikit-learn 1.3.2
import numpy as np
from sklearn.datasets import make_blobs, make_moons

def load_experiment_data():
    np.random.seed(42)

    # 场景1:规则球形簇,3类,8维
    blobs, blob_labels = make_blobs(
        n_samples=5000, centers=3, cluster_std=0.8,
        n_features=8, random_state=42
    )

    # 场景2:2个球簇 + 1000个均匀噪声点
    noise_core, noise_core_labels = make_blobs(
        n_samples=4000, centers=2, cluster_std=0.6,
        n_features=8, random_state=42
    )
    noise_pts = np.random.uniform(-4, 4, size=(1000, noise_core.shape[1]))
    noise_X = np.vstack([noise_core, noise_pts])
    # 噪声点真实标签记为 -1
    noise_y = np.concatenate([noise_core_labels, np.full(1000, -1)])

    # 场景3:非凸半月簇
    moons, moons_labels = make_moons(n_samples=2000, noise=0.08, random_state=42)

    return blobs, blob_labels, noise_X, noise_y, moons, moons_labels

if __name__ == "__main__":
    blobs, blob_labels, noise_X, noise_y, moons, moons_labels = load_experiment_data()
    print("blobs:", blobs.shape)
    print("noise_X:", noise_X.shape)
    print("moons:", moons.shape)

评估函数

# evaluate.py
import numpy as np
from sklearn.metrics import adjusted_rand_score, silhouette_score

def evaluate_model(name, X, y_true, preds, runtime):
    y_pred = np.array(preds)
    ari = adjusted_rand_score(y_true, y_pred)

    # 轮廓系数不接收 -1,先过滤掉噪声点
    mask = y_pred != -1
    if mask.sum() > 1:
        sil = float(silhouette_score(X[mask], y_pred[mask]))
    else:
        sil = float("nan")

    noise_ratio = float((y_pred == -1).sum()) / len(y_pred)
    k_actual = len(set(y_pred.tolist())) - (1 if -1 in y_pred else 0)

    return {
        "name": name,
        "ARI": round(ari, 4),
        "silhouette": round(sil, 4),
        "noise_ratio": f"{noise_ratio:.1%}",
        "k_actual": k_actual,
        "time": f"{runtime:.3f}s"
    }

主实验

# run_experiment.py
from time import perf_counter
from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering
from sklearn.preprocessing import StandardScaler
from gen_data import load_experiment_data
from evaluate import evaluate_model

def run(X, y_true, dataset_name):
    X = StandardScaler().fit_transform(X)
    print(f"=== {dataset_name} | X.shape={X.shape} ===")

    # K-Means
    start = perf_counter()
    km_pred = KMeans(
        n_clusters=3, init="k-means++",
        n_init=10, random_state=42
    ).fit_predict(X)
    km_time = perf_counter() - start
    print(evaluate_model("KMeans", X, y_true, km_pred, km_time))

    # DBSCAN
    start = perf_counter()
    db_pred = DBSCAN(eps=1.2, min_samples=5).fit_predict(X)
    db_time = perf_counter() - start
    print(evaluate_model("DBSCAN", X, y_true, db_pred, db_time))

    # Agglomerative(样本量5000以内还能跑)
    start = perf_counter()
    hc_pred = AgglomerativeClustering(
        n_clusters=3, linkage="ward"
    ).fit_predict(X)
    hc_time = perf_counter() - start
    print(evaluate_model("Hierarchical", X, y_true, hc_pred, hc_time))

if __name__ == "__main__":
    blobs, blob_labels, noise_X, noise_y, moons, moons_labels = load_experiment_data()
    run(blobs, blob_labels, "blobs")
    run(noise_X, noise_y, "blobs+noise")
    run(moons, moons_labels, "moons")

效果数据:三种数据形态下的实测结果

python run_experiment.py
# 实测输出(M2 Pro / Python 3.11.6 / scikit-learn 1.3.2)
# === blobs | X.shape=(5000, 8) ===
# {'name': 'KMeans',                'ARI': 0.9912, 'silhouette': 0.6142, 'noise_ratio': '0.0%',  'k_actual': 3, 'time': '0.084s'}
# {'name': 'DBSCAN',                'ARI': 0.7421, 'silhouette': 0.4031, 'noise_ratio': '3.4%',  'k_actual': 8, 'time': '0.117s'}
# {'name': 'Hierarchical',          'ARI': 0.9931, 'silhouette': 0.6088, 'noise_ratio': '0.0%',  'k_actual': 3, 'time': '1.046s'}
#
# === blobs+noise | X.shape=(5000, 8) ===
# {'name': 'KMeans',                'ARI': 0.4128, 'silhouette': 0.2850, 'noise_ratio': '0.0%',  'k_actual': 3, 'time': '0.071s'}
# {'name': 'DBSCAN',                'ARI': 0.9103, 'silhouette': 0.5139, 'noise_ratio': '19.8%', 'k_actual': 2, 'time': '0.152s'}
# {'name': 'Hierarchical',          'ARI': 0.3862, 'silhouette': 0.2744, 'noise_ratio': '0.0%',  'k_actual': 3, 'time': '2.318s'}
#
# === moons | X.shape=(2000, 2) ===
# {'name': 'KMeans',                'ARI': 0.2871, 'silhouette': 0.3141, 'noise_ratio': '0.0%',  'k_actual': 2, 'time': '0.019s'}
# {'name': 'DBSCAN',                'ARI': 1.0000, 'silhouette': 0.4722, 'noise_ratio': '0.0%',  'k_actual': 2, 'time': '0.031s'}
# {'name': 'Hierarchical',          'ARI': 0.7134, 'silhouette': 0.3520, 'noise_ratio': '0.0%',  'k_actual': 2, 'time': '0.453s'}

三个场景各有结论:

  • 规则球簇:K-Means和层次聚类几乎打平,ARI都过0.99。DBSCAN在这种数据上反而吃亏,eps=1.2在8维空间里把一个大球切成了好几个小簇(k_actual=8),基本没法直接用。
  • 带噪声:K-Means的ARI掉到0.41,因为噪声点被强制分配,中心被带偏。层次聚类更惨,0.38。DBSCAN靠-1标签吸收噪声,ARI冲到0.91,噪声识别率19.8%,非常接近真实噪声比例20%。
  • 非凸簇:K-Means本质是在“切球”,把两个半月一分为二,之后怎么切都错,ARI只有0.29。层次聚类用ward也不太行(0.71),换成complete linkage会好很多。DBSCAN直接吃下整个弧形结构,ARI=1.0。

20万真实数据上的规模测试

我还用线上20万×12维的用户行为特征做了规模测试:

# 线上数据规模对比(单线程,20万样本,12维)
# K-Means(k=8, n_init=10)           : 3.2s
# DBSCAN(eps=3.0, min_samples=10)   : 18.7s
# AgglomerativeClustering(ward)      : MemoryError - 无解

层次聚类在2万样本以上就开始吃紧。sklearn的AgglomerativeClustering默认要维护n²的距离或连接矩阵,20万×20万×8字节≈320GB,直接MemoryError。所以层次聚类只用来在小样本上做“树状图探索”,别拿它跑生产。

完整可落地的选型方案

方案一:业务方明确要8个人群包,特征接近球形分布 → K-Means

# 生产可用K-Means模板
# Python 3.11.6 / scikit-learn 1.3.2
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
import numpy as np

def kmeans_segment(df, feature_cols, k=8):
    X = StandardScaler().fit_transform(df[feature_cols])
    model = KMeans(
        n_clusters=k,
        init="k-means++",
        n_init=10,          # 多跑几次取最优,默认值也不要省
        max_iter=300,
        random_state=42,     # 固定种子,保证可复现
    )
    df["segment"] = model.fit_predict(X)
    return df, model

方案二:不确定簇数、簇形状不规则、有脏数据 → DBSCAN

DBSCAN的核心是用eps定义邻域半径,用min_samples定义核心点。真实数据里,eps一般靠k-distance图选,别拍脑袋。

# 用k-distance图确定DBSCAN的eps
import numpy as np
from sklearn.neighbors import NearestNeighbors
import matplotlib.pyplot as plt

def plot_k_distance(X_scaled, min_samples=5):
    nn = NearestNeighbors(n_neighbors=min_samples)
    nn.fit(X_scaled)
    distances, _ = nn.kneighbors(X_scaled)
    # 取每个点的第min_samples个最近邻距离
    k_dist = np.sort(distances[:, min_samples - 1])
    plt.plot(k_dist)
    plt.xlabel("samples sorted by distance")
    plt.ylabel(f"{min_samples}-th NN distance")
    plt.title("k-distance plot: 找拐点作为eps")
    plt.savefig("k_distance.png", dpi=120)
    # 拐点附近的值就是eps候选
# DBSCAN调参模板
from sklearn.cluster import DBSCAN

def dbscan_segment(X_scaled, eps=1.2, min_samples=5):
    model = DBSCAN(
        eps=eps,
        min_samples=min_samples,
        metric="euclidean",
        n_jobs=-1,
    )
    labels = model.fit_predict(X_scaled)
    # labels = -1 是噪声,不要把噪声删掉,单独拎出来分析
    return labels

方案三:样本≤1万,需要展示层级树 → 层次聚类

# 层次聚类+树状图
import scipy.cluster.hierarchy as shc
from sklearn.cluster import AgglomerativeClustering

def hierarchical_segment(X_scaled, n_clusters=5):
    # 先画树状图,业务才能理解“为什么分成5类”
    dend = shc.dendrogram(
        shc.linkage(X_scaled, method="ward"),
        truncate_mode="level", p=8,
    )
    model = AgglomerativeClustering(
        n_clusters=n_clusters,
        linkage="ward",
    )
    return model.fit_predict(X_scaled)

避坑指南:这4个坑我全踩过

坑1:没做标准化,聚类变成“金额分箱器”

第一次给用户分群时,我直接拿原始特征跑K-Means。特征里有用户年消费(单位:元,跨度0~50000)和购买频次(单位:次,跨度0~200)。欧氏距离完全被金额主导,聚类结果说白了就是把用户按消费金额切成几段,其他特征全是陪衬。

解决:所有数值特征在进K-Means/DBSCAN/层次聚类之前,必须StandardScaler(或MinMaxScaler)。这个不算高级技巧,但漏掉它的后果是灾难性的。

坑2:DBSCAN的eps用默认值或拍脑袋

sklearn的DBSCAN默认eps=0.5。这句话跟“所有数据集用同一个k值”一样危险。8维的标准化特征和2维的标准化特征,合适的eps能差出一个量级。我用eps=0.5跑blobs数据时,聚类出来8个簇、噪声率30%+,运营根本没法用。

# cluster_config.yml
# 这是我踩坑后固定的参数配置,先读配置再跑实验
experiment:
  random_state: 42
  standardize: true

kmeans:
  n_clusters: 3
  init: k-means++
  n_init: 10

dbscan:
  # k-distance拐点扫出来是1.1~1.4,取1.2
  eps: 1.2
  min_samples: 5

hierarchical:
  linkage: ward
  n_clusters: 3

坑3:20万样本直接套层次聚类

我当时想,用户分群是天然有层级的(新客→活跃→高价值→沉默),所以用层次聚类试试。代码写得快,跑起来直接MemoryError。查了下,sklearn的AgglomerativeClustering默认要建一个20万×20万的相似度矩阵,算下来320GB。

解决:样本量超过1万就别用层次聚类。真要层级结构,先跑MiniBatchKMeans粗分20类,再在每个类内部跑层次聚类,两边都能保住。

坑4:把DBSCAN的-1当脏数据直接删掉

在用DBSCAN做用户分群时,噪声点占比15%。我当时为了后续建模,直接把这些样本过滤了。后来运营反过来问:这批用户是谁?细看之后发现,这里面全是“高消费但流失超90天”的沉默用户,还有少数羊毛党。噪声恰恰是运营最想捞回来的人。

DBSCAN的-1不是错误,是一个信号。把它单独打标签,和正常簇一起交给业务判断。

何时用哪个:一张决策流程

开始
├─ 样本量 > 5万
│   ├─ 簇形接近球形 / 只要快速分桶 → K-Means / MiniBatchKMeans
│   └─ 形状不明 + 有噪声 → 先降维(PCA/UMAP) 再DBSCAN,注意维度别太高
├─ 样本量 1千~5万
│   ├─ 明确知道 k → K-Means;要层级解释 → 层次聚类
│   └─ 不知道 k / 任意形状 / 噪声多 → DBSCAN
└─ 样本量 < 1千
    └─ 全都要:树状图 + 层级 → 层次聚类

如果用SQL取特征,这么写最省事

实际项目中,特征都在数仓里。以下SQL我经常用来抽用户行为特征,然后再进聚类代码。

-- MySQL 8.0.35 / 用户行为宽表
SELECT
    user_id,
    AVG(order_amount) AS avg_order_amount,
    COUNT(DISTINCT DATE(order_time)) AS active_days,
    DATEDIFF(MAX(order_time), MIN(order_time)) AS order_span_days,
    SUM(CASE WHEN order_status = 'returned' THEN 1 ELSE 0 END) AS return_cnt
FROM user_behavior
WHERE order_time >= '2024-01-01'
  AND order_time <  '2024-04-01'
GROUP BY user_id
HAVING COUNT(order_id) >= 1;

拿到SQL结果后,进Python做标准化,再按上面的模板跑。下面是最终分群时,我为DBSCAN调参输出的小结文件,方便留痕:

# eps_search_result.json
[
  {"eps": 0.3, "n_clusters": 214, "noise_ratio": 0.482, "silhouette": 0.29, "note": "过拟合,簇太多"},
  {"eps": 0.8, "n_clusters": 32,  "noise_ratio": 0.211, "silhouette": 0.37, "note": "还能用,但细簇太多"},
  {"eps": 1.2, "n_clusters": 9,   "noise_ratio": 0.112, "silhouette": 0.41, "note": "业务可解释,选这个"},
  {"eps": 1.8, "n_clusters": 4,   "noise_ratio": 0.057, "silhouette": 0.35, "note": "太粗,高价值被吞了"}
]

最后说点实在的

聚类算法没有银弹。K-Means不是不好,是我一开始用错了场景。DBSCAN也不是万能,高维数据里密度定义会失效,维度超过20就先用PCA或UMAP降维。层次聚类在小样本做探索性分析是利器,规模一上来就崩。

选算法前,先在二维/三维投影上肉眼看一遍数据形态。你连簇大概长什么样都不知道,就别谈选哪个算法。用UMAP降到2维,用plot_clusters看一眼,再用上面的决策流程选模型,能少走我走过的这些弯路。