一个让运营追着我骂了一下午的聚类
上个月接了个直播间用户分群需求: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看一眼,再用上面的决策流程选模型,能少走我走过的这些弯路。