AI聚类:数据不够用了之后
有时候 K 值的选择更像是一种基于业务理解的判断。
这里有一个手肘法的示意图,横轴是 K 值,纵轴是惯性值(Inertia),拐点就是比较合理的 K 值:
![手肘法找最优 K 值的示意
背景和需求
数据来自一个在线服务的用户行为日志,包含:
- 用户 ID
- 活跃天数(30 天内)
- 平均会话时长
- 功能使用频率(登录、浏览、购买、评论等)
- 最后活跃时间
数据量不算大,几万条记录,但每条记录的特征维度有点高。最开始的想法很简单:直接用 K-Means 把用户分成几个群体,然后根据各组的统计特征贴上"高频用户"、“流失用户"之类的标签。
但真正做的时候才发现问题:
- 数据的分布不一定符合 K-Means 的假设(球形簇、大小相近)
- 特征之间有很强的相关性,直接用会有问题
- 分成几组才是"合理的”?没有标签的情况下,怎么判断分得好不好?
- 有些特征是长尾分布,极端值会严重干扰聚类结果
这些都是实际会遇到的问题,不是教科书上的理想场景。
实现过程
数据预处理
拿到数据后的第一步是清洗和标准化。这一步不做干净,后面所有的聚类结果都会是错的。
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import MinMaxScaler
# 读取数据
df = pd.read_csv('user_behavior.csv')
# 处理缺失值
df = df.dropna()
# 去除极端值(用 IQR 方法)
def remove_outliers(df, column):
Q1 = df[column].quantile(0.25)
Q3 = df[column].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
return df[(df[column] >= lower) & (df[column] <= upper)]
for col in ['avg_session_duration', 'feature_usage']:
df = remove_outliers(df, col)
# 特征标准化
scaler = StandardScaler()
features = ['active_days', 'avg_session_duration', 'feature_usage']
df_scaled = scaler.fit_transform(df[features])
这一步踩过一个坑:一开始用了 MinMaxScaler,结果发现有的特征本身就是长尾分布,被压缩到 0-1 之间后,区分度丢失了。后来改成 StandardScaler,情况才好些。
但也不是所有情况 StandardScaler 都合适,要看数据分布。
特征选择和降维
原始数据里有很多特征,但不是都有用。有些特征之间高度相关,有些对区分用户没太大帮助。
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.decomposition import PCA
# 相关性分析
correlation = df[features].corr()
print(correlation)
# 如果特征相关性太高,考虑 PCA 降维
pca = PCA(n_components=0.95) # 保留 95% 的方差
df_pca = pca.fit_transform(df_scaled)
print(f"原始特征数: {df_scaled.shape[1]}, PCA后: {df_pca.shape[1]}")
实际情况中,这个项目最后保留了 3 个核心特征:活跃天数、会话时长、核心功能使用率。其他的要么相关性太高,要么对区分用户帮助不大。
尝试 K-Means
最开始试的是 K-Means,简单直接。
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
# 确定最优 K 值(手肘法)
inertia = []
K_range = range(2, 10)
for k in K_range:
kmeans = KMeans(n_clusters=k, random_state=42)
kmeans.fit(df_scaled)
inertia.append(kmeans.inertia_)
plt.plot(K_range, inertia, 'bx-')
plt.xlabel('k')
plt.ylabel('Inertia')
plt.title('手肘法找最优 K 值')
plt.show()
# 选择 K=3(假设手肘点在 3)
kmeans = KMeans(n_clusters=3, random_state=42)
clusters = kmeans.fit_predict(df_scaled)
# 给原始数据加上标签
df['cluster'] = clusters
手肘法给了个大概的方向,但并不总是很明显。有时候 K 值的选择更像是一种基于业务理解的判断。
这里有一个手肘法的示意图,横轴是 K 值,纵轴是惯性值(Inertia),拐点就是比较合理的 K 值:

图中的红圈标出了拐点,也就是惯性值下降速度明显变缓的位置,这个点对应的 K 值通常是比较合理的选择。
K-Means 跑完之后,看起来有 3 个组。但问题来了:这 3 个组到底是什么意思?各组之间的差异够大吗?
尝试层次聚类
因为 K-Means 的结果不太好解释,又试了层次聚类。
from sklearn.cluster import AgglomerativeClustering
from scipy.cluster.hierarchy import dendrogram, linkage
# 构建层次聚类
linked = linkage(df_scaled, method='ward')
# 画树状图
plt.figure(figsize=(10, 7))
dendrogram(linked, orientation='top', distance_sort='descending', show_leaf_counts=True)
plt.title('层次聚类树状图')
plt.show()
# 切分树状图得到指定数量的簇
agg_cluster = AgglomerativeClustering(n_clusters=3, linkage='ward')
df['hierarchical_cluster'] = agg_cluster.fit_predict(df_scaled)
层次聚类的好处是可以看到整个合并过程,帮助理解数据结构。树状图有时候能显式看出"自然"的分割点。

树状图从下往上显示了数据点逐步合并的过程,红线位置就是切分点,决定了最终的簇数量。这种方法的好处是能看到整个聚类过程,而不是直接跳到结果。
但层次聚类的缺点是计算复杂度高,数据量大的时候就很慢。这个项目因为数据量不大,所以还能接受。
DBSCAN 尝试
后来又试了 DBSCAN,发现效果不太理想。
from sklearn.cluster import DBSCAN
# DBSCAN 需要调 eps 和 min_samples 两个参数
dbscan = DBSCAN(eps=0.5, min_samples=5)
clusters = dbscan.fit_predict(df_scaled)
# DBSCAN 会把噪声点标记为 -1
print(f"发现的簇数: {len(set(clusters)) - (1 if -1 in clusters else 0)}")
print(f"噪声点数量: {sum(clusters == -1)}")
DBSCAN 适合发现任意形状的簇,也能自动处理噪声点。但问题在于:
- 参数 eps 和 min_samples 很难确定,需要大量尝试
- 数据密度变化大的时候,一个参数很难适应全局
- 高维空间里距离度量变得不太可靠
这个项目里,DBSCAN 最后把大部分点都标记成了噪声,说明数据结构不适合这个方法。
踩坑记录
距离度量的问题
一开始用的是欧氏距离,但后来发现对于某些特征,余弦相似度更合适。比如功能使用频率,更关心的是"使用模式"而不是"绝对数值"。
from sklearn.metrics.pairwise import cosine_similarity
# 对于某些特征,余弦相似度可能更合适
similarity_matrix = cosine_similarity(df_scaled)
但也不是所有场景都适用,需要根据业务理解来判断。
K 值选择的坑
手肘法给了参考,但有时候"手肘"并不明显。还有轮廓系数(Silhouette Score),它衡量的是一个点与同簇点的距离比它与异簇点的距离小多少。

轮廓系数的范围在 -1 到 1 之间,越接近 1 表示聚类效果越好。图中峰值对应的 K 值往往是比较合理的选择,但也不是绝对的,还是要看业务场景。
实际计算下来,不同 K 值的差异并不大。
from sklearn.metrics import silhouette_score
silhouette_scores = []
for k in range(2, 10):
kmeans = KMeans(n_clusters=k, random_state=42)
clusters = kmeans.fit_predict(df_scaled)
score = silhouette_score(df_scaled, clusters)
silhouette_scores.append(score)
plt.plot(range(2, 10), silhouette_scores)
plt.xlabel('K')
plt.ylabel('Silhouette Score')
plt.title('不同 K 值的轮廓系数')
plt.show()
最后 K 值的选择,更多是结合业务理解:想分得细一点还是粗一点,每个组是否"可解释",分组后是否能指导后续动作。
特征权重的坑
默认情况下,所有特征权重相同。但实际业务里,某些特征可能更重要。比如"活跃天数"可能比"会话时长"更能反映用户粘性。
# 手动设置特征权重
weights = np.array([0.4, 0.3, 0.3]) # 活跃天数权重更高
df_weighted = df_scaled * weights
但权重的设置没有标准方法,更多是经验和业务判断。
评估的坑
没有标签的情况下,怎么评估聚类好不好?这是一个很实际的问题。
常用的方法有:
- 内部指标:轮廓系数、Davies-Bouldin 指数
- 外部指标:如果有一些人工标注的样本,可以做对比
- 业务指标:分组后的用户在后续行为上的差异,比如转化率、留存率
from sklearn.metrics import davies_bouldin_score
db_score = davies_bouldin_score(df_scaled, clusters)
print(f"Davies-Bouldin 指数: {db_score} (越小越好)")
但这些指标都只能作为参考,真正"好不好"要看业务场景。
最终结果
折腾了一圈,最后用了 K-Means(K=4),结合特征工程和手动权重调整。结果大致是这样的:
# 各组的统计特征
cluster_stats = df.groupby('cluster').agg({
'active_days': 'mean',
'avg_session_duration': 'mean',
'feature_usage': 'mean'
})
print(cluster_stats)
基于统计特征,手动给各组贴了标签:
- 簇 0:高频用户(活跃天数高、会话时长长)
- 簇 1:中等活跃用户
- 簇 2:低频但深度用户(活跃天数少,但每次使用时长长)
- 簇 3:流失风险用户(各项指标都低)
聚类结果后来用于定向运营,比如对流失风险用户推送挽回消息,对高频用户做会员权益推荐。从实际效果看,用户分群后的转化率比"一刀切"的做法提升了不少。
结论和感受
聚类这件事,理论上几行代码就能跑通,但实际做下来,每一步都需要判断和取舍。算法给了工具,但怎么用好工具,还是得靠对数据的理解和对业务的认识。
另外,无监督学习的一个现实问题是:没有"正确答案"。所有的评估都只是相对的,很难说"这个分组是绝对对的"。更多是看"这个分组有没有用"。
最后想说的一点:聚类不是目的,是手段。如果分组后没有后续动作,或者分组对业务没有帮助,那分得再"精确"也没意义。工具是为人服务的,不是反过来。
这次实践给我的最大感受是:不要被算法的标准答案束缚,多想想实际场景的限制和需求。很多时候,简单粗暴但符合业务的做法,比复杂但脱离现实的方案更有价值。
后续可以考虑的方向:
- 尝试更多聚类算法,比如 GMM、谱聚类
- 探索流式数据的增量聚类
- 结合半监督学习,利用少量标注样本提升效果
但这些都要基于实际需求和资源来决定,不是为了"技术而技术"。
可用性说明:本文发布于 2020 年 6 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-AI聚类:数据不够用了之后(https://blog.thinkmoon.cn/post/321-ai-clustering-data-grouping-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。