1. 问题导入:没有标签,如何理解一群用户?
假设你拿到一张电商用户表:每行包含最近一次购买距今天数、近 90 天购买次数和消费金额,但没有“高价值用户”“沉默用户”这类标签。我们真正想问的是:数据中是否自然存在行为相近的小群体?
这就是无监督学习:只有特征矩阵 X,没有目标标签 y,算法从数据的分布、相似性或密度中发现结构。K-Means 是最常用的聚类基线算法之一。
点击放大查看图:kmeans-cluster-intuition
2. 学习目标
- 区分聚类、分类和异常检测的输入与目标;
- 用欧氏距离解释“样本相似”的含义;
- 写出并解释 K-Means 的簇内平方和目标函数;
- 手算一次完整的“初始化 → 分配 → 更新 → 收敛”过程;
- 使用肘部法与轮廓系数选择合理的 K;
- 用
StandardScaler、Pipeline 与 KMeans 完成客户分群。
3. 聚类、分类与异常检测
|
| 分类 | 有 | 预测已定义的类别 | 是否流失、是否欺诈 |
| 聚类 | 无 | 发现相似样本群体 | 用户分群、主题发现 |
| 异常检测 | 通常无 | 找出少见或不符合模式的样本 | 设备故障、异常交易 |
聚类产生的编号只是算法内部编号。cluster_0 并不天然比 cluster_1 更好、更高价值;必须回到每一群的统计画像来命名和决策。
4. K-Means 的直觉与数学目标
4.1 什么是“最近”?
对两个特征向量 x=(x1,x2) 和 c=(c1,c2),最常用的欧氏距离是:
d(x,c)=(x1−c1)2+(x2−c2)2
距离越小,说明两点在特征空间中越相似。实际计算常比较平方距离 d2,因为平方根不改变大小关系。
4.2 目标函数:让每一簇尽可能紧凑
设数据集有 n 个样本,要分为 K 个簇;第 k 个簇的质心为 μk,其中样本集合为 Ck。K-Means 最小化簇内平方和(WCSS):
J=k=1∑Kxi∈Ck∑∥xi−μk∥22
每个点离所属质心越近、同簇内的点越集中,J 就越小。scikit-learn 的 inertia_ 即该目标值。
4.3 算法的两步循环
- 初始化 K 个质心。
- 分配:每个样本归到最近质心的簇。
- 更新:每个质心移到该簇样本的坐标均值。
- 若归属不再变化,或质心移动很小,则停止;否则重复第 2 步。
K-Means 小样例手算实验室
图上的点、右侧计算和正文手算使用同一组数据。
x₁x₂ABCDEFGHIJ××
× 质心● 簇 1(A–E)● 簇 2(F–J)
先点击“下一步”逐帧观察,再开启“自动播放”。分配阶段不移动质心;更新阶段不改变样本归属,而是把质心移到该簇的平均位置。
4.4 为什么“更新为均值”是正确的?
固定一个簇中的样本不动,假设我们要寻找一个二维代表点 μ=(μx,μy),使所有样本到它的平方距离和最小:
f(μ)=i=1∑m[(xi−μx)2+(yi−μy)2]
分别对 μx、μy 求导并令导数为 0:
∂μx∂f=2i=1∑m(μx−xi)=0⇒μx=m1i=1∑mxi
∂μy∂f=2i=1∑m(μy−yi)=0⇒μy=m1i=1∑myi
所以,在簇成员固定时,均值是唯一使平方距离和最小的点。这也解释了 K-Means 名称中的 Means。注意,如果目标改为绝对距离之和,最优代表点会变成中位数,对应的是 K-Medians,而不是 K-Means。
4.5 初始化、局部最优与 K-Means++
第一次质心放在哪里会影响最终结果。若初始质心恰好都落在同一片区域,算法可能错过另一片真实簇,最终停在较差的局部最优。
- 随机初始化:随机选 K 个样本作为质心,简单但结果波动较大;
- 多次重启
n_init:运行多次,保留 WCSS 最低的一次;
- K-Means++:先随机选一个质心;后续质心更倾向从“离已选质心很远”的点中选取。它让初始质心更分散,通常更稳定。
在 scikit-learn 中应显式设置 n_init 和 random_state:前者降低局部最优风险,后者保证实验可复现。
4.6 伪代码与停止条件
text
输入:样本矩阵 X,簇数 K
初始化:选择 K 个质心 μ₁, μ₂, ..., μₖ
重复:
1. 对每个样本 xᵢ:labelᵢ = argminₖ ||xᵢ - μₖ||²
2. 对每个簇 k:μₖ = mean({xᵢ | labelᵢ = k})
直到:标签不再变化,或质心移动量小于 tolerance,或达到最大迭代次数
输出:每个样本的簇标签、K 个质心、WCSS
若某个簇在一次分配后为空,无法计算均值。库实现通常会重新放置该质心;这往往提示 K 过大、初始化不佳,或数据中并不存在这么多稳定群体。
5. 手算案例:两轮迭代直到收敛
使用 10 个二维样本,并令 K=2:
|
| A | (1, 1) | F | (7, 7) |
| B | (1, 3) | G | (7, 9) |
| C | (2, 2) | H | (8, 8) |
| D | (3, 1) | I | (9, 7) |
| E | (3, 3) | J | (9, 9) |
5.1 初始化与第一次分配
设初始质心为 μ1(0)=(1,3),μ2(0)=(9,7)。以样本 C=(2,2) 为例:
d2(C,μ1)=(2−1)2+(2−3)2=2
d2(C,μ2)=(2−9)2+(2−7)2=74
因为 2<74,C 分到簇 1。对 E=(3,3):d2(E,μ1)=4,d2(E,μ2)=52,因此 E 也属于簇 1。逐个计算后,A–E 属于簇 1,F–J 属于簇 2。
5.1.1 完整的第一轮距离表
下表列出全部样本到初始质心的平方距离。只比较平方距离即可,因为开平方不会改变远近排序:
|
| A=(1,1) | 4 | 100 | 簇 1 |
| B=(1,3) | 0 | 80 | 簇 1 |
| C=(2,2) | 2 | 74 | 簇 1 |
| D=(3,1) | 8 | 72 | 簇 1 |
| E=(3,3) | 4 | 52 | 簇 1 |
| F=(7,7) | 52 | 4 | 簇 2 |
| G=(7,9) | 72 | 8 | 簇 2 |
| H=(8,8) | 74 | 2 | 簇 2 |
| I=(9,7) | 80 | 0 | 簇 2 |
| J=(9,9) | 100 | 4 | 簇 2 |
该表还有一个实用价值:如果某样本到两个质心的距离非常接近,它位于簇边界附近,分群结论应谨慎解释;若业务对这种用户有不同策略,可能需要保留“不确定”状态,而不强行归类。
5.2 第一次更新:为什么质心是均值?
簇 1 的新质心是五个点在每个维度上的平均值:
μ1(1)=(51+1+2+3+3,51+3+2+1+3)=(2,2)
簇 2 同理:
μ2(1)=(57+7+8+9+9,57+9+8+7+9)=(8,8)
均值使一组点到代表点的平方距离之和最小,所以更新步骤取均值,而不是中位数或随机点。
5.3 第二次分配与收敛
用新质心 (2,2)、(8,8) 再分配,所有样本归属均不变;新一轮均值也不变,算法收敛。K-Means 每次分配或更新都不会增大 J,但只保证局部最优,这也是需要多次初始化的原因。
6. 为什么必须做特征缩放?
若“购买频次”范围是 0–20,而“消费金额”范围是 0–100000,欧氏距离几乎完全由金额决定。Z-Score 标准化为:
z=σx−μ
它让每个特征具有相近尺度。缩放器只应在训练数据上 fit,再用于新数据 transform;使用 Pipeline 可以防止泄漏。
点击放大查看图:kmeans-feature-scaling
6.1 除了标准化,还要检查什么?
缩放并不是把数据“变好”的魔法。聚类前至少完成以下检查:
- 缺失值:K-Means 不能直接处理
NaN。数值列可用中位数填补,类别列要先编码或单独处理。
- 偏态分布:消费金额常呈长尾分布。极少数大额用户会拉开距离,可考虑
log1p(monetary) 后再标准化。
- 重复含义特征:总消费额与客单价、购买次数高度相关时,可能重复放大某种行为信号。
- 异常值:均值对极端点敏感。先进行业务核验;不能简单删除真实但少见的高价值用户。
- 时间窗口:RFM 的统计口径必须一致,例如都基于“观察日向前 90 天”,不能把不同期间的数据混在一起。
6.2 数值特征、类别特征和文本特征
标准 K-Means 以均值和欧氏距离为基础,天然适合连续数值特征。类别特征若用普通整数编码,会虚构“类别之间有远近”的关系,例如“城市编号 10 比城市编号 1 更远”,这是错误的。可采用 one-hot 编码、K-Prototypes,或选择其他距离/聚类方法。文本向量常更关注方向而不是长度,通常使用余弦相似度并考虑球面 K-Means。
7. 如何选择簇数 K?
7.1 肘部法
随着 K 增大,WCSS 必然下降。观察下降从明显变为缓慢的位置,作为候选 K。若没有清晰手肘,说明数据不一定存在清晰球状簇,或需要根据业务粒度决定。
7.2 轮廓系数
令 a 为样本到同簇其他样本的平均距离,b 为到最近其他簇样本的平均距离:
s=max(a,b)b−a
s 接近 1 表示簇内紧凑、簇间分离;接近 0 表示处于边界;小于 0 则可能分错簇。应结合每群规模和业务画像选择 K。
7.3 用代码同时比较多个 K
不要只跑一个 K=4 就结束。以下代码同时收集 WCSS 与平均轮廓系数;选择时还要查看每簇样本数,避免“高分但只分出一个很小的簇”。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler
# 每个可运行代码块均独立执行:直接在这里准备 RFM 小样例。
# 每一行依次是 [recency, frequency, monetary],即最近消费天数、消费次数、累计消费额。
# 这 12 位用户大致包含高价值、普通活跃、低价值沉默等几种人群。
X = np.array([
[3, 18, 6800], [7, 14, 5100], [5, 20, 7200],
[45, 3, 520], [60, 2, 350], [52, 4, 610],
[12, 8, 2200], [18, 7, 1850], [15, 9, 2400],
[90, 1, 120], [110, 1, 80], [85, 2, 150],
])
# 三列的量纲差异很大(金额是几千,次数只是个位数),
# 不标准化会让“金额”几乎独占欧氏距离,因此先转换为均值 0、标准差 1 的空间。
X_scaled = StandardScaler().fit_transform(X)
# 用列表保存每一个候选 K 的指标,方便横向比较。
results = []
for k in range(2, 9):
# n_init=20:随机初始化 20 次,保留 WCSS 最小的一次,降低偶然性。
# random_state 固定后,每次点击运行都会得到相同结果。
model = KMeans(n_clusters=k, init="k-means++", n_init=20, random_state=42)
# fit_predict 同时完成“训练质心”和“给每个用户分配簇编号”。
labels = model.fit_predict(X_scaled)
results.append({
"k": k,
# inertia_ 就是 WCSS:每个样本到所属质心的平方距离之和,越小越紧凑。
"wcss": round(model.inertia_, 1),
# 轮廓系数同时考虑簇内紧凑和簇间分离,越接近 1 越好。
"silhouette": round(silhouette_score(X_scaled, labels), 3),
# 过小的簇通常不稳定,即使轮廓系数较高也要警惕。
"smallest_cluster": int(min((labels == label).sum() for label in set(labels))),
})
# 逐行查看每个 K 的三项结果,再结合业务能否解释来作决定。
for row in results:
print(row)
# 左图寻找 WCSS 下降由快变慢的“肘部”;右图查看轮廓系数与最小簇规模。
ks = [row["k"] for row in results]
wcss = [row["wcss"] for row in results]
silhouettes = [row["silhouette"] for row in results]
smallest = [row["smallest_cluster"] for row in results]
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].plot(ks, wcss, marker="o", color="#0f766e")
axes[0].set(xlabel="簇数 K", ylabel="WCSS", title="肘部法")
axes[1].plot(ks, silhouettes, marker="o", color="#2563eb", label="轮廓系数")
axes[1].bar(ks, smallest, alpha=0.25, color="#f59e0b", label="最小簇人数")
axes[1].set(xlabel="簇数 K", title="分离度与小簇风险")
axes[1].legend()
plt.tight_layout()
plt.show()
当 K=1 时没有“其他簇”,轮廓系数没有定义;当每个样本独占一个簇时,虽然 WCSS 很低,却完全失去分群价值。因此指标从来不是单独使用的决策器。
8. Python 实战:RFM 客户分群
recency 越小代表购买越近,frequency 越大代表购买越频繁,monetary 越大代表累计消费越高。下面展示完整、可复现的训练流程:
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score
# 构造一个教学用 RFM 表:每一行是一位用户,每一列是一项行为特征。
rfm = pd.DataFrame({
"recency": [3, 7, 5, 45, 60, 52, 12, 18, 15, 90, 110, 85],
"frequency": [18, 14, 20, 3, 2, 4, 8, 7, 9, 1, 1, 2],
"monetary": [6800, 5100, 7200, 520, 350, 610, 2200, 1850, 2400, 120, 80, 150],
})
# 明确哪些列输入聚类模型,避免把后面生成的 cluster 列又误当作特征。
features = ["recency", "frequency", "monetary"]
# Pipeline 将“先标准化、再聚类”绑定为一个对象,防止训练和预测使用不同处理方式。
pipeline = Pipeline([
# StandardScaler 让三项特征对欧氏距离具有可比性。
("scaler", StandardScaler()),
# n_clusters=4 是本例的业务假设;真实项目需用上一节指标和业务目标选择。
("kmeans", KMeans(n_clusters=4, n_init=20, random_state=42)),
])
# 传入三列原始特征;Pipeline 会自动依次执行缩放与 K-Means,并返回簇编号 0~3。
labels = pipeline.fit_predict(rfm[features])
# 将结果写回表格,方便后续按簇计算画像。
rfm["cluster"] = labels
# groupby 求每簇 R/F/M 均值:这是为簇命名(高价值、沉默等)的原始依据。
profile = rfm.groupby("cluster")[features].mean().round(1)
# 轮廓系数应在“缩放后的特征空间”计算,才能与 K-Means 实际使用的距离一致。
score = silhouette_score(pipeline.named_steps["scaler"].transform(rfm[features]), labels)
# 先读 profile 的各列均值,再结合每簇人数和业务规则命名客户群。
print(profile)
print(f"平均轮廓系数: {score:.3f}")
8.1 从订单明细构造 RFM 的思路
实际项目通常先从订单明细聚合,而不是直接拿到 RFM 表。以分析日 snapshot_date 为基准:
- R(Recency):
snapshot_date - 用户最后一次成功购买日期,越小越活跃;
- F(Frequency):窗口内成功订单数,需先确认是否按订单、按购买日还是按商品件数计;
- M(Monetary):窗口内实付金额,应明确退款、取消订单、优惠券和币种处理规则。
特别注意:如果将“未来 30 天是否再次购买”作为后续验证目标,RFM 必须只使用该时间点之前可获得的数据,否则会产生时间穿越。
8.2 从标准化空间还原质心画像
K-Means 的质心在标准化空间中不易阅读。可以用 inverse_transform 还原到原始业务单位:
import pandas as pd
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
# 此代码块独立运行,因此重新创建 RFM 数据、缩放器和模型。
# 数值含义依次为:最近消费天数、消费次数、累计消费额。
rfm = pd.DataFrame({
"recency": [3, 7, 5, 45, 60, 52, 12, 18, 15, 90, 110, 85],
"frequency": [18, 14, 20, 3, 2, 4, 8, 7, 9, 1, 1, 2],
"monetary": [6800, 5100, 7200, 520, 350, 610, 2200, 1850, 2400, 120, 80, 150],
})
features = ["recency", "frequency", "monetary"]
# 保存已经拟合过的 scaler;只有它知道如何把标准化质心转换回原始单位。
scaler = StandardScaler()
X_scaled = scaler.fit_transform(rfm[features])
# 在标准化空间训练 K-Means,得到的 cluster_centers_ 也位于标准化空间。
kmeans = KMeans(n_clusters=4, n_init=20, random_state=42)
rfm["cluster"] = kmeans.fit_predict(X_scaled)
# inverse_transform 将质心还原为“天数、次数、金额”,才便于业务人员理解。
centers = pd.DataFrame(
scaler.inverse_transform(kmeans.cluster_centers_),
columns=features,
).round(1)
# 将质心行号变成可与用户 cluster 编号匹配的索引键。
centers["cluster"] = range(len(centers))
# 统计每个簇有多少用户;极小簇应谨慎解释。
size = rfm["cluster"].value_counts().sort_index()
# 以 cluster 为键合并“质心画像”和“用户数”,形成最终报告。
report = centers.set_index("cluster").join(size.rename("users"))
# 输出表中每一行就是一个可命名的客户分群画像。
print(report)
质心是均值而非“真实用户”。若金额高度偏态,还应同时报告中位数与四分位数;否则一个大额用户就可能让均值画像产生误导。
正确解释顺序:先看各簇人数,避免过度解读极小簇;再对比均值、中位数与分布;最后使用业务语言命名,例如“高频高消费”“潜力复购”“沉默低价值”,并用实验检验运营策略。
9.1 什么时候不该使用 K-Means?
K-Means 的隐含偏好是:簇近似凸形/球形、特征可用均值表示、各簇尺度大致相近。下面情况应优先评估替代方法:
|
| 月牙、环形或细长簇 | 质心和欧氏距离难以切开非凸结构 | DBSCAN、谱聚类 |
| 大量噪声与离群点 | 极端点会拉动均值质心 | DBSCAN、鲁棒预处理 |
| 类别型主导的数据 | 均值没有明确业务含义 | K-Modes、K-Prototypes |
| 簇密度差异很大 | 一个全局距离阈值/均值代表不稳定 | HDBSCAN、GMM |
| 希望软概率归属 | K-Means 只输出硬标签 | 高斯混合模型 GMM |
9.2 聚类结果要验证“稳定性”
无监督学习没有标准答案标签,不能只看一次运行的分数。可靠的分群还应:在不同随机种子下检查簇规模和画像是否稳定;在不同时间窗口上重跑,确认客群定义不会剧烈漂移;让业务专家抽样查看每群用户;最后将策略真正上线,用转化、留存或增量收入验证价值。
9. 常见失败模式与改进
|
| 一个特征完全主导分群 | 未标准化,量纲差异大 | 在 Pipeline 中使用 StandardScaler |
| 每次结果不同 | 随机初始化落入不同局部最优 | 固定 random_state,提高 n_init |
| 某簇只有极少数样本 | 离群点被单独吸引 | 审查异常值,必要时使用 DBSCAN |
| 月牙形数据分得很差 | K-Means 假设近似凸、球状簇 | 使用 DBSCAN、谱聚类或核方法 |
| 指标不错但业务无用 | 只优化数学紧凑度 | 加入业务可行动性与稳定性评审 |
10. 本节检查清单
11. 下一步
K-Means 擅长处理近似球形、规模相近的簇。下一节将学习,解决噪声点、非规则形状和未知簇数等问题。
12. 参考资料