AI聚类:数据不够用了之后

有时候 K 值的选择更像是一种基于业务理解的判断。

这里有一个手肘法的示意图,横轴是 K 值,纵轴是惯性值(Inertia),拐点就是比较合理的 K 值:

![手肘法找最优 K 值的示意

背景和需求

数据来自一个在线服务的用户行为日志,包含:

  • 用户 ID
  • 活跃天数(30 天内)
  • 平均会话时长
  • 功能使用频率(登录、浏览、购买、评论等)
  • 最后活跃时间

数据量不算大,几万条记录,但每条记录的特征维度有点高。最开始的想法很简单:直接用 K-Means 把用户分成几个群体,然后根据各组的统计特征贴上"高频用户"、“流失用户"之类的标签。

但真正做的时候才发现问题:

  1. 数据的分布不一定符合 K-Means 的假设(球形簇、大小相近)
  2. 特征之间有很强的相关性,直接用会有问题
  3. 分成几组才是"合理的”?没有标签的情况下,怎么判断分得好不好?
  4. 有些特征是长尾分布,极端值会严重干扰聚类结果

这些都是实际会遇到的问题,不是教科书上的理想场景。

实现过程

数据预处理

拿到数据后的第一步是清洗和标准化。这一步不做干净,后面所有的聚类结果都会是错的。

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 值通常是最优选择

图中的红圈标出了拐点,也就是惯性值下降速度明显变缓的位置,这个点对应的 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 适合发现任意形状的簇,也能自动处理噪声点。但问题在于:

  1. 参数 eps 和 min_samples 很难确定,需要大量尝试
  2. 数据密度变化大的时候,一个参数很难适应全局
  3. 高维空间里距离度量变得不太可靠

这个项目里,DBSCAN 最后把大部分点都标记成了噪声,说明数据结构不适合这个方法。

踩坑记录

距离度量的问题

一开始用的是欧氏距离,但后来发现对于某些特征,余弦相似度更合适。比如功能使用频率,更关心的是"使用模式"而不是"绝对数值"。

from sklearn.metrics.pairwise import cosine_similarity

# 对于某些特征,余弦相似度可能更合适
similarity_matrix = cosine_similarity(df_scaled)

但也不是所有场景都适用,需要根据业务理解来判断。

K 值选择的坑

手肘法给了参考,但有时候"手肘"并不明显。还有轮廓系数(Silhouette Score),它衡量的是一个点与同簇点的距离比它与异簇点的距离小多少。

轮廓系数随 K 值变化的曲线,峰值对应的 K 值通常是比较合理的选择

轮廓系数的范围在 -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

但权重的设置没有标准方法,更多是经验和业务判断。

评估的坑

没有标签的情况下,怎么评估聚类好不好?这是一个很实际的问题。

常用的方法有:

  1. 内部指标:轮廓系数、Davies-Bouldin 指数
  2. 外部指标:如果有一些人工标注的样本,可以做对比
  3. 业务指标:分组后的用户在后续行为上的差异,比如转化率、留存率
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/) 转载或引用必须申明原指尖魔法屋来源及源地址!