AI无监督学习:聚类与降维方法

AI无监督学习上手并不难,难的是稳定跑起来。

下面只记真正影响结果的部分。

什么是无监督学习

有监督学习像老师批改作业:每道题都有标准答案,模型猜错了能扣分、能改。无监督学习更像把一堆未标记的卷子扔给你:没人告诉你对错,你只能自己看,能不能分出几类、有没有规律

有监督无监督
标签y没有 y
目标预测标签或连续值发现结构
评价准确率、MSE 等更依赖业务解释
例子房价预测、垃圾邮件分类客户分群、主题发现

常见的三类任务:

  • 聚类:把相似样本归到同一组(用户分群、商品分组)
  • 降维:特征太多时压缩维度、去掉冗余(可视化、提速)
  • 异常检测:没有「异常」标签时,先学正常数据的分布,偏离太远的点标出来(机房监控、欺诈识别)

一个清醒的认识:无监督不会 magically 告诉你「正确答案」。聚类分出来三类,不代表现实世界就真的该三类——可能是算法参数、特征选取带来的划分。所以流程通常是:算法先给一个结构 → 人结合业务命名、验证、迭代。

如果你手里已经有明确标签,却硬上无监督,多半是在绕远路。反过来,标签贵、标签慢、标签根本不存在时,无监督才是正经选项。

下面是一个完整的实战项目。

背景和需求

我手头的数据是这样的:

  • 用户最近30天的行为日志(浏览、点击、购买、搜索等)
  • 大概50万用户
  • 每个用户有200多个特征维度

业务需求:把用户分成几类,方便后续做个性化推荐和精细化运营。

现实限制

  1. 没有历史标签数据
  2. 数据质量问题不少(缺失值、异常值)
  3. 计算资源有限(单机16核32G)
  4. 业务方希望下周就要结果

实现过程

第一步:数据清洗和特征工程

先看一眼数据情况,这里用 Python 处理:

import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer

# 读取数据
df = pd.read_parquet('user_behavior.parquet')

# 先看数据概况
print(f"原始数据形状: {df.shape}")
print(f"缺失值占比:\n{df.isnull().sum() / len(df)}")

# 处理缺失值
# 对于数值型特征,用中位数填充
numeric_features = df.select_dtypes(include=[np.number]).columns
imputer = SimpleImputer(strategy='median')
df[numeric_features] = imputer.fit_transform(df[numeric_features])

# 处理异常值
def cap_outliers(df, column, lower=0.01, upper=0.99):
    lower_bound = df[column].quantile(lower)
    upper_bound = df[column].quantile(upper)
    df[column] = df[column].clip(lower_bound, upper_bound)
    return df

for col in ['daily_clicks', 'daily_views', 'total_spend']:
    df = cap_outliers(df, col)

# 特征标准化(重要!)
scaler = StandardScaler()
scaled_features = scaler.fit_transform(df[numeric_features])

踩坑点:刚开始没做标准化,聚类结果完全不对。因为不同特征的尺度差异太大(比如购买金额是几千,点击次数是几十),距离计算完全被大数值特征主导。

另一种去极端值的方法是 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)]

裁剪(clip)保留样本只压数值,IQR 直接丢样本,看数据量取舍。

还有一个坑:标准化器选错了。一开始用了 MinMaxScaler,结果发现有的特征是长尾分布,被压缩到 0-1 之间后区分度丢失了,后来改成 StandardScaler 才好些。也不是所有情况 StandardScaler 都合适,要看数据分布。

类别型特征别直接 one-hot:我一开始把类别特征 one-hot 后丢进去,维度爆炸而且 PCA 效果很差。对类别特征要单独处理,可以用 Target Encoding 或者 Embedding,而不是简单 one-hot。

第二步:降维处理

200多个维度太多了,容易维度灾难。维数灾难不只是课本上的名词——它会真金白银地吃掉计算资源和时间。高维带来的直接痛点:

  • 训练时间翻倍、单轮 epoch 跑到几小时
  • 内存占用飙升,一个 batch 就十几 GB
  • 特征冗余导致过拟合,训练集和测试集差距拉大
  • 线上推理超过几百毫秒,完全受不了

我在做特征工程时也犯过一个常见错误:觉得特征越多越好,模型就能学到更多东西。但实际上很多特征高度相关,有的甚至是噪声。降维思路主要有两条:特征选择(硬砍)特征提取(PCA 等)

特征选择:基于树模型的重要性排序

最简单的想法是直接砍掉不重要的特征。训练一个 LightGBM,按重要性排序,只保留靠前的:

import lightgbm as lgb

# 假设有少量标注或伪标签可用
lgb_model = lgb.LGBMClassifier(n_estimators=100, random_state=42)
lgb_model.fit(X_train, y_train)

feature_importance = lgb_model.feature_importances_
sorted_idx = np.argsort(feature_importance)[::-1]
top_features = sorted_idx[:100]
X_selected = X_train[:, top_features]

硬砍虽然快(训练时间能降到原来的 1/5),但容易误杀——特征重要性是基于某个特定模型的,砍掉的特征里可能包含对别的模型有用的非线性信息。所以更稳妥的做法是和 PCA 结合。

PCA 降维

from sklearn.decomposition import PCA
import matplotlib.pyplot as plt

# PCA降维
pca = PCA()
pca.fit(scaled_features)

# 看看降维效果
plt.figure(figsize=(10, 6))
plt.plot(np.cumsum(pca.explained_variance_ratio_))
plt.xlabel('Number of Components')
plt.ylabel('Cumulative Explained Variance')
plt.axhline(y=0.9, color='r', linestyle='--')
plt.show()

# 选择保留90%方差的主成分数量
n_components_90 = np.argmax(np.cumsum(pca.explained_variance_ratio_) >= 0.9) + 1
print(f"保留90%方差需要 {n_components_90} 个主成分")

# 实际降维
pca_final = PCA(n_components=n_components_90)
reduced_features = pca_final.fit_transform(scaled_features)

PCA降维效果

踩坑点:PCA对异常值敏感。所以在做PCA之前,一定要先处理异常值。我第一次没处理好,降维效果很差,后来才发现有几个用户的异常行为数据把整个PCA都带偏了。

维度也不是压得越低越好。一开始我想把维度压到很低,结果信息损失明显、准确率掉得厉害。用累计方差贡献率曲线找拐点(PCA 的肘部法则),80-100 这个区间对大多数情况是个安全的起点

重要:避免测试集泄露。降维时只能用训练集拟合 scaler 和 PCA,然后用它们去转换测试集,否则测试集信息会泄露进训练过程:

# 错误做法:把训练集和测试集拼一起 fit
scaler = StandardScaler()
X_all_scaled = scaler.fit_transform(np.vstack([X_train, X_test]))

# 正确做法:只用训练集 fit
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)  # 只 transform,不 fit

t-SNE 可视化

PCA 之后,我还会用 t-SNE 把数据降到 2 维看看分布,帮自己理解数据结构、判断后续怎么走:

from sklearn.manifold import TSNE

# 只取部分样本,t-SNE 很慢
tsne = TSNE(n_components=2, random_state=42, perplexity=30)
X_tsne = tsne.fit_transform(scaled_features[:5000])

plt.figure(figsize=(10, 8))
plt.scatter(X_tsne[:, 0], X_tsne[:, 1], alpha=0.6)
plt.title('t-SNE Visualization')
plt.show()

t-SNE 不是用来给训练数据降维的(太慢、且只对转换过的样本负责),而是用来可视化、判断数据有没有明显团块。我在这个项目里就是靠它发现数据确实分成几簇,才确认聚类是值得做的。

补充一句:流形学习里还有 UMAP,速度比 t-SNE 快、且能保留更多全局结构,数据量大时可以考虑用它替代 t-SNE 做可视化。

第三步:聚类分析

K-means 与 K 值选择

用 K-means 做聚类,但首先得确定 K 值:

from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

# 用肘部法则和轮廓系数找最佳K
inertias = []
silhouette_scores = []
K_range = range(2, 15)

for k in K_range:
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    kmeans.fit(reduced_features)
    inertias.append(kmeans.inertia_)
    silhouette_scores.append(silhouette_score(reduced_features, kmeans.labels_))

# 画肘部图
plt.figure(figsize=(12, 5))
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, silhouette_scores, 'go-')
plt.xlabel('K')
plt.ylabel('Silhouette Score')
plt.title('Silhouette Score')
plt.tight_layout()
plt.show()

K值选择

根据结果,我选择了 K=5。

# 最终聚类
kmeans = KMeans(n_clusters=5, random_state=42, n_init=10)
clusters = kmeans.fit_predict(reduced_features)

# 添加聚类标签
df['cluster'] = clusters

手肘法给了个大概方向,但"肘部"并不总是很明显。轮廓系数衡量的是一个点与同簇点比与异簇点近多少,范围 -1 到 1,越接近 1 越好,>0.3 算可接受。实际算下来不同 K 值差异可能都不大,最后还得结合业务:想分细一点还是粗一点,每个组是否"可解释"。

层次聚类

K-means 给了结果但不好解释时,可以试层次聚类。它的好处是能看到整个合并过程,树状图(dendrogram)有时候能显式看出"自然"的分割点:

from sklearn.cluster import AgglomerativeClustering
from scipy.cluster.hierarchy import dendrogram, linkage

# 构建层次聚类(数据量大时建议先抽样,ward 法计算量大)
linked = linkage(reduced_features[:5000], 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=5, linkage='ward')
df['hierarchical_cluster'] = agg_cluster.fit_predict(reduced_features)

层次聚类的缺点是计算复杂度高,数据量大的时候很慢。所以一般先抽样画树状图看结构,再决定要么用全量跑、要么退回 K-means。

DBSCAN

DBSCAN 适合发现任意形状的簇,也能自动把噪声点标记为 -1,不用预先指定 K

from sklearn.cluster import DBSCAN

dbscan = DBSCAN(eps=0.5, min_samples=5)
clusters_db = dbscan.fit_predict(reduced_features)

print(f"发现的簇数: {len(set(clusters_db)) - (1 if -1 in clusters_db else 0)}")
print(f"噪声点数量: {sum(clusters_db == -1)}")

但 DBSCAN 在这个项目里效果不理想,最后把大部分点都标成了噪声。它的两个参数 epsmin_samples 很难定、需要大量尝试;数据密度变化大时一个参数很难适应全局;高维空间里距离度量也变得不太可靠。这说明这批数据的结构不适合 DBSCAN,而不是 DBSCAN 本身不好。

算法选型经验:球形簇、量大选 K-means(或 MiniBatchKMeans);想要层级结构、数据量不大选层次聚类;密度不均、形状任意、想自动识别噪声选 DBSCAN。三者都可以试一遍,哪个结果更"可解释"就用哪个。

第四步:结果分析和可视化

看看聚出来的用户长什么样:

# 分析各簇的特征
cluster_stats = df.groupby('cluster')[numeric_features].agg(['mean', 'std'])
print(cluster_stats)

# 可视化(用前两个主成分)
plt.figure(figsize=(10, 8))
scatter = plt.scatter(reduced_features[:, 0], reduced_features[:, 1],
                     c=clusters, cmap='viridis', alpha=0.6)
plt.colorbar(scatter)
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.title('User Clusters (PCA Reduced)')
plt.show()

用户聚类可视化

业务解释

  • Cluster 0:高价值用户(购买金额大,频率高)
  • Cluster 1:潜在用户(浏览多但购买少)
  • Cluster 2:流失风险用户(近期活跃度下降)
  • Cluster 3:价格敏感型(只买打折商品)
  • Cluster 4:新用户(注册时间短,行为模式不明显)

踩坑总结

1. 数据质量问题

问题:原始数据里有很多缺失值和异常值,直接用起来效果很差。

解决

  • 缺失值用中位数填充(比均值稳健)
  • 异常值用分位数裁剪
  • 特征标准化必须做

2. 聚类数选择困难

问题:肘部法则的"肘部"不明显,轮廓系数最大值对应的 K 太大(>10),业务不适用。

解决

  • 结合业务理解,K 不能太多(5-8个比较合理)
  • 轮廓系数 >0.3 就算可接受
  • 让业务同事看结果,共同决策

3. 计算性能问题

问题:50万用户 + 200维度,单机跑 K-means 要几个小时。

解决

  • 先 PCA 降维到 30 维左右
  • 用 MiniBatchKMeans 替代普通 K-means
  • 分批处理,每批 5万用户
from sklearn.cluster import MiniBatchKMeans

mbk = MiniBatchKMeans(n_clusters=5, batch_size=50000, random_state=42)
clusters = mbk.fit_predict(reduced_features)

4. 可解释性问题

问题:算法给的结果,怎么向业务解释?

解决

  • 给每个簇做个"画像"(主要特征)
  • 找代表性用户给业务看
  • 做一个简单的用户分布图
# 簇的"画像"
for cluster_id in range(5):
    cluster_users = df[df['cluster'] == cluster_id]
    print(f"\n=== Cluster {cluster_id} ===")
    print(f"用户数量: {len(cluster_users)}")
    print(f"平均购买金额: {cluster_users['total_spend'].mean():.2f}")
    print(f"平均点击次数: {cluster_users['daily_clicks'].mean():.2f}")

5. 距离度量选错

一开始默认用欧氏距离,但有些特征用余弦相似度更合适——比如功能使用频率,更关心"使用模式"而不是"绝对数值"。

from sklearn.metrics.pairwise import cosine_similarity

similarity_matrix = cosine_similarity(scaled_features)

距离度量要看业务理解,不是所有场景都适用欧氏距离。

6. 特征权重

默认所有特征权重相同,但实际业务里某些特征可能更重要(比如"活跃天数"可能比"会话时长"更能反映用户粘性)。可以手动加权:

# 手动设置特征权重
weights = np.array([0.4, 0.3, 0.3])
df_weighted = scaled_features * weights

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

7. 没有标签怎么评估

无监督最大的现实问题:没有"正确答案"。常用的评估方式:

  • 内部指标:轮廓系数、Davies-Bouldin 指数(越小越好)
  • 外部指标:如果有一些人工标注的样本,可以对比
  • 业务指标:分组后用户在后续行为上的差异,比如转化率、留存率
from sklearn.metrics import davies_bouldin_score

db_score = davies_bouldin_score(reduced_features, clusters)
print(f"Davies-Bouldin 指数: {db_score} (越小越好)")

这些指标都只能作为参考,真正"好不好"要看业务场景。聚类分得好不好,最终看"这个分组有没有用"。

最终结果

两周后,业务方给反馈:

  1. 用户转化率提升 8%:针对"潜在用户"和"价格敏感型用户"做了差异化推送
  2. 流失率降低 5%:提前识别了"流失风险用户",做了挽回
  3. 运营效率提升:不再是"广撒网",而是精准触达

当然,效果不是完全归功于聚类,还有后续的营销策略配合。但至少,聚类给出了一个靠谱的起点。

关键流程总结

整个无监督学习流程可以用这个图表示:

graph TD A[原始用户行为数据] --> B[数据清洗] B --> C[特征标准化] C --> D[PCA降维] D --> E[确定K值] E --> F[K-means聚类] F --> G[结果分析] G --> H[业务应用] H --> I[效果评估] I -->|需要优化| B

写在最后

无监督学习不是万能的,但在"有数据没标签"的场景下,它给了我们一个起点。

通过这个项目,我意识到:

  1. 预处理比算法本身更重要:垃圾进,垃圾出
  2. 可解释性很关键:算法结果要能翻译成业务语言
  3. 要和业务方合作:不是一个人埋头苦干就能解决的
  4. 聚类只是手段,不是目的:如果分组后没有后续动作,或对业务没有帮助,分得再"精确"也没意义

如果你也在做类似的事情,希望这些经验能帮到你。有问题欢迎交流。


参考资料

本文整合了关于无监督学习(聚类、降维)的多篇笔记。

可用性说明:本文发布于 2020 年 5 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-AI无监督学习:聚类与降维方法https://blog.thinkmoon.cn/post/311-ai-unsupervised-learning-label-pattern-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!