AI无监督学习:聚类与降维方法
AI无监督学习上手并不难,难的是稳定跑起来。
下面只记真正影响结果的部分。
什么是无监督学习
有监督学习像老师批改作业:每道题都有标准答案,模型猜错了能扣分、能改。无监督学习更像把一堆未标记的卷子扔给你:没人告诉你对错,你只能自己看,能不能分出几类、有没有规律。
| 有监督 | 无监督 | |
|---|---|---|
| 标签 | 有 y | 没有 y |
| 目标 | 预测标签或连续值 | 发现结构 |
| 评价 | 准确率、MSE 等 | 更依赖业务解释 |
| 例子 | 房价预测、垃圾邮件分类 | 客户分群、主题发现 |
常见的三类任务:
- 聚类:把相似样本归到同一组(用户分群、商品分组)
- 降维:特征太多时压缩维度、去掉冗余(可视化、提速)
- 异常检测:没有「异常」标签时,先学正常数据的分布,偏离太远的点标出来(机房监控、欺诈识别)
一个清醒的认识:无监督不会 magically 告诉你「正确答案」。聚类分出来三类,不代表现实世界就真的该三类——可能是算法参数、特征选取带来的划分。所以流程通常是:算法先给一个结构 → 人结合业务命名、验证、迭代。
如果你手里已经有明确标签,却硬上无监督,多半是在绕远路。反过来,标签贵、标签慢、标签根本不存在时,无监督才是正经选项。
下面是一个完整的实战项目。
背景和需求
我手头的数据是这样的:
- 用户最近30天的行为日志(浏览、点击、购买、搜索等)
- 大概50万用户
- 每个用户有200多个特征维度
业务需求:把用户分成几类,方便后续做个性化推荐和精细化运营。
现实限制:
- 没有历史标签数据
- 数据质量问题不少(缺失值、异常值)
- 计算资源有限(单机16核32G)
- 业务方希望下周就要结果
实现过程
第一步:数据清洗和特征工程
先看一眼数据情况,这里用 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 的肘部法则),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=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 在这个项目里效果不理想,最后把大部分点都标成了噪声。它的两个参数 eps 和 min_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} (越小越好)")
这些指标都只能作为参考,真正"好不好"要看业务场景。聚类分得好不好,最终看"这个分组有没有用"。
最终结果
两周后,业务方给反馈:
- 用户转化率提升 8%:针对"潜在用户"和"价格敏感型用户"做了差异化推送
- 流失率降低 5%:提前识别了"流失风险用户",做了挽回
- 运营效率提升:不再是"广撒网",而是精准触达
当然,效果不是完全归功于聚类,还有后续的营销策略配合。但至少,聚类给出了一个靠谱的起点。
关键流程总结
整个无监督学习流程可以用这个图表示:
写在最后
无监督学习不是万能的,但在"有数据没标签"的场景下,它给了我们一个起点。
通过这个项目,我意识到:
- 预处理比算法本身更重要:垃圾进,垃圾出
- 可解释性很关键:算法结果要能翻译成业务语言
- 要和业务方合作:不是一个人埋头苦干就能解决的
- 聚类只是手段,不是目的:如果分组后没有后续动作,或对业务没有帮助,分得再"精确"也没意义
如果你也在做类似的事情,希望这些经验能帮到你。有问题欢迎交流。
参考资料:
本文整合了关于无监督学习(聚类、降维)的多篇笔记。
可用性说明:本文发布于 2020 年 5 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-AI无监督学习:聚类与降维方法(https://blog.thinkmoon.cn/post/311-ai-unsupervised-learning-label-pattern-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。