前言
前面学习的线性回归、逻辑回归、K 近邻、决策树、随机森林和支持向量机都有一个共同前提:训练数据中存在已知答案。例如:
房屋特征 → 已知房价
客户特征 → 已知是否流失
设备数据 → 已知是否故障
模型根据这些答案学习从特征到目标的映射。但现实数据经常没有现成标签:企业可能只有消费记录,却没有预先定义客户类型;网站记录了访问行为,却不知道应该划分哪些群体;设备积累了运行状态,也未必拥有完整故障标签。此时可以使用无监督学习(Unsupervised Learning),尝试从特征中发现结构和相似性。
本文使用代码生成 900 条合成客户行为数据,再用 K-Means 聚类形成若干数据簇。
监督学习与无监督学习
监督学习
监督学习的数据包含:
特征 X + 已知标签 y
例如,用客户行为预测"流失"或"未流失"。类别含义在训练前已经确定,模型输出可以与真实标签比较,因此能够计算准确率、精确率、召回率等指标。
无监督学习
无监督学习通常只有:
特征 X
算法没有可供照抄的正确答案,而是寻找当前特征空间中的结构、相似性或分组。例如,根据消费和访问行为得到若干行为相似的数据簇。
这里的"发现"需要谨慎理解:聚类不是证明现实中天然存在某几类人,而是算法在指定特征、缩放方式和距离定义下构造分组。更换特征或算法,结果可能改变。

聚类与分类有什么区别
分类任务的训练数据有已知类别,类别含义提前确定,模型学习如何预测这些类别,并可与测试标签直接比较。聚类没有预先定义的类别答案,算法根据相似性形成簇,簇的含义还需要结合统计和领域知识分析。
分类:客户行为 → 流失 / 未流失
聚类:客户行为 → 若干行为模式不同的数据簇
聚类不一定存在唯一正确答案,也不能直接套用普通分类准确率。即使分组在数学指标上清晰,也不等于具有现实业务价值。
K-Means 是什么
K-Means 是一种常见的基于质心的聚类算法。其中,K 表示希望划分的簇数量,Means 表示每个簇用样本均值作为质心。
算法基本过程如下:
选择 K
→ 初始化 K 个质心
→ 把每个样本分给最近质心
→ 根据簇内样本重新计算质心
→ 重复分配和更新
→ 直到质心变化很小或达到迭代上限
它希望同簇样本尽量靠近自己的质心。这个目标很直观,但也带来重要限制:结果依赖特征尺度、欧氏距离、初始质心和 K 值。

什么是簇和质心
簇(Cluster)是被算法划到同一组的一批样本。质心(Centroid)是该簇在各特征维度上的平均位置。
假设一个二维簇中有三个点:
(2, 4)
(4, 6)
(6, 8)
质心是:
((2+4+6)/3, (4+6+8)/3) = (4, 6)
多维数据的计算方式相同:每一列分别求均值。质心是统计中心,不一定对应真实存在的样本。例如质心可能包含"月访问 8.7340 次"这样的数值,但并不要求某条记录恰好等于它。

K-Means 怎样完成一次迭代
假设 K=3。算法先得到三个初始质心,然后计算每个样本到三个质心的距离,将样本分配给最近者。分组完成后,再对每个簇逐列求均值,得到三个新质心。随后根据新质心重新分配样本,并继续更新。
当簇分配基本不变、质心移动很小,或达到最大迭代次数时,算法停止。因此 K-Means 是迭代算法,不是一次距离计算就直接得到最终答案。
K-Means 使用什么距离
K-Means 通常基于欧氏距离(Euclidean Distance)。对于两个 n 维样本:
A = (x₁, x₂, ..., xₙ)
B = (y₁, y₂, ..., yₙ)
距离为:
d(A, B) = √Σ(xᵢ-yᵢ)²
计算时,先求各特征差值,再平方、求和并开平方。K-Means 优化的惯性与样本到所属质心的平方距离有关。"距离近"只说明两个样本在当前数值表示中相似,不意味着它们在所有现实意义上相似。
为什么 K-Means 必须重视标准化
本文包含五个量级明显不同的特征:年度消费金额是数千到数万,月访问次数是个位到几十,折扣比例则位于 0 到 1 附近。若直接计算距离,金额差异会因为数值大而轻易压过其他特征。
因此代码使用 StandardScaler 与 KMeans 组成 Pipeline:
Pipeline(
steps=[
("scaler", StandardScaler()),
(
"clusterer",
KMeans(
n_clusters=k,
random_state=42,
n_init=20,
),
),
]
)
标准化让每列以均值为中心,并按标准差缩放;K-Means 随后在标准化空间计算距离。Pipeline 还保证新样本使用相同缩放规则。标准化不会自动让特征变得合理,它只解决数值尺度问题;无关或含义错误的特征仍会干扰聚类。

初始质心为什么重要
K-Means 可能因为初始质心不同而收敛到不同的局部结果。本文显式使用:
KMeans(
n_clusters=k,
init="k-means++",
n_init=20,
random_state=42,
)
init="k-means++" 会尽量选择较分散的初始位置;n_init=20 表示使用不同初始化运行 20 次并保留较好的结果;random_state=42 固定随机过程,方便复查。固定种子提高可复现性,但不能证明聚类是唯一正确答案。
本文的合成客户行为数据
代码使用 make_blobs 生成 900 个样本和四个相互有重叠的隐藏生成组,再转换成五个量级不同的教学特征:
annual_spend:模拟年度消费金额;visits_per_month:模拟月访问次数;average_order_value:模拟平均订单金额;discount_ratio:模拟折扣使用比例;support_calls:模拟客服咨询次数。
超出教学范围的数值会被裁剪,最终形成 Pandas DataFrame。隐藏生成组单独保存在 hidden_groups,绝不加入特征矩阵 X,也不参与 K 的选择。现实无标签项目通常没有隐藏真值;这里保留它,只是为了在最终模型确定后做一次教学检查。
检查数据
程序实际检查了形状、特征数量、数值类型、缺失值、无穷值、重复行和隐藏标签隔离。运行结果为:
完整数据形状: (900, 5)
特征数量: 5
缺失值数量: 0
重复特征行数量: 0
隐藏分组是否进入特征矩阵: 否
五列均为 float64。主要统计如下:
| 特征 | 最小值 | 最大值 | 均值 | 标准差 |
|---|---|---|---|---|
| annual_spend | 2000.0000 | 44557.2600 | 19722.9124 | 7738.6264 |
| visits_per_month | 1.0000 | 31.6100 | 13.4364 | 6.2969 |
| average_order_value | 20.0000 | 278.6100 | 136.9067 | 48.2455 |
| discount_ratio | 0.0200 | 0.9500 | 0.3909 | 0.2093 |
| support_calls | 0.0000 | 11.6400 | 4.5953 | 2.2555 |
这张表直观显示了尺度差异,也解释了为什么不能直接把原始列送给依赖欧氏距离的 K-Means。
K 值表示什么
n_clusters 就是 K。K=3 表示把全部样本分成三个簇。K 太小时,不同结构会被合并,簇内差异可能过大;K 太大时,一个结构可能被过度拆分,还可能产生很小的簇。
K-Means 不会自动告诉我们唯一正确的 K。需要提前规定候选范围和选择规则,再综合内部指标、簇规模、稳定性和解释成本判断。
什么是惯性 Inertia
model.inertia_ 是所有样本到所属质心的平方距离总和。数值越小,说明样本总体离自己的质心更近。但 K 增加后,惯性通常只会下降:极端情况下,每个样本单独成簇,惯性可以接近 0。
因此不能选择"惯性最小"的最大 K。惯性更适合观察继续增加 K 后,改善幅度是否明显变小。
什么是肘部法
肘部法(Elbow Method)比较不同 K 的惯性。若惯性开始下降很快,随后转为平缓,转折位置形似手肘,可以作为候选 K 的参考。
本篇不调用绘图库,只输出惯性和相对下降比例。需要注意,有些数据根本没有清晰肘部,转折判断也可能带有主观性,所以还要结合轮廓系数和簇规模。

什么是轮廓系数
轮廓系数(Silhouette Score)同时考虑样本与同簇样本是否接近,以及它与其他簇是否分离。其取值通常位于 -1 到 1:接近 1 表示当前距离空间中的簇内较紧密、簇间较分离;接近 0 表示样本可能位于簇边界;小于 0 说明一些样本可能更接近其他簇。
高轮廓系数不等于高业务价值,也不能证明现实中存在这些类别。它只是当前特征、缩放和距离定义下的内部指标。
使用轮廓系数选择 K
候选 K 固定为 2~8。每个候选都创建新的 StandardScaler + KMeans Pipeline,使用 n_init=20 和 random_state=42,只在无标签特征上拟合。选择规则在运行前就已写入代码:
- 排除最小簇比例低于 5% 的方案;
- 在剩余方案中优先看轮廓系数;
- 与最高轮廓系数差距不超过 0.01 时,优先选择较小 K;
- 惯性及下降幅度只作辅助参考;
- 隐藏生成组不参与选择。
实际结果如下:
| K | 惯性 | 惯性下降比例 | 轮廓系数 | 最小簇样本数 | 最小簇比例 |
|---|---|---|---|---|---|
| 2 | 2929.8325 | 不适用 | 0.3136 | 449 | 0.4989 |
| 3 | 2394.2395 | 0.1828 | 0.2971 | 219 | 0.2433 |
| 4 | 1896.1950 | 0.2080 | 0.2930 | 216 | 0.2400 |
| 5 | 1794.2523 | 0.0538 | 0.2595 | 114 | 0.1267 |
| 6 | 1711.3884 | 0.0462 | 0.2084 | 99 | 0.1100 |
| 7 | 1636.9067 | 0.0435 | 0.1798 | 92 | 0.1022 |
| 8 | 1574.8099 | 0.0379 | 0.1526 | 93 | 0.1033 |
所有候选的最小簇比例都超过 5%。K=2 的轮廓系数最高,为 0.3136,因此规则选择 K=2。K=4 的惯性下降幅度较明显,但轮廓系数只有 0.2930;我们没有为了迎合"四个隐藏生成组"而修改选择结果。
这个结果很有教学价值:数据生成时设置了四个重叠组,不代表内部指标一定把四组视为最清晰结构。无监督选择规则和隐藏生成过程回答的是不同问题。
训练最终 K-Means
确定 K=2 后,代码创建一个新的 Pipeline,并在完整特征 X 上重新训练。这里不把候选阶段的某个对象直接当作最终模型,目的是让"选参数"和"建立最终模型"两个步骤清晰分开。
最终标签来自:
cluster_labels = final_model.named_steps["clusterer"].labels_
随后只在分析副本中添加 cluster 列,原始五个特征的含义保持不变。
聚类编号为什么没有固定含义
最终模型输出 Cluster 0 和 Cluster 1,但编号没有自然顺序:0 不表示较差,1 也不表示较好。重新训练或更换随机状态后,即使分组结构相近,编号仍可能交换。
解释簇时应查看多列统计,而不是根据数字猜测。若现实项目需要名称,也应使用可核对的中性描述,避免自动贴上"高价值""低价值"或"高风险"等带判断色彩的标签。本文只保留编号。
查看每个簇的样本数量
最终两个簇的规模为:
| 簇编号 | 样本数 | 比例 |
|---|---|---|
| 0 | 449 | 0.4989 |
| 1 | 451 | 0.5011 |
这里两簇接近均衡,但 K-Means 并不强制每个簇大小相同。现实结果出现不均衡不一定错误,不过极小簇需要检查是否来自异常值、过度拆分或真实的小型结构。
查看每个簇的特征画像
均值画像如下:
| 簇 | 年度金额 | 月访问次数 | 平均订单金额 | 折扣比例 | 客服次数 |
|---|---|---|---|---|---|
| 0 | 17859.2714 | 8.7340 | 125.7920 | 0.5500 | 6.1969 |
| 1 | 21578.2889 | 18.1179 | 147.9720 | 0.2326 | 3.0008 |
中位数画像:
| 簇 | 年度金额 | 月访问次数 | 平均订单金额 | 折扣比例 | 客服次数 |
|---|---|---|---|---|---|
| 0 | 17035.3200 | 8.4500 | 128.5700 | 0.5572 | 6.2000 |
| 1 | 21083.2200 | 17.9300 | 150.4800 | 0.2385 | 2.9000 |
标准差画像:
| 簇 | 年度金额 | 月访问次数 | 平均订单金额 | 折扣比例 | 客服次数 |
|---|---|---|---|---|---|
| 0 | 7485.6369 | 4.1666 | 48.6443 | 0.1386 | 1.6431 |
| 1 | 7545.7518 | 4.2310 | 45.2588 | 0.1342 | 1.5390 |
均值帮助观察中心,中位数对极端值更稳健,标准差反映簇内分散程度。可以看到两簇在访问、折扣和客服次数上存在差异,但这只是合成数据的数值画像,不是现实客户属性,更不是因果结论。
将质心转换回原始单位
clusterer.cluster_centers_ 位于标准化空间,直接阅读不方便。代码通过:
original_centers = scaler.inverse_transform(
clusterer.cluster_centers_
)
得到原始单位质心:
| 簇 | annual_spend | visits_per_month | average_order_value | discount_ratio | support_calls |
|---|---|---|---|---|---|
| 0 | 17859.2714 | 8.7340 | 125.7920 | 0.5500 | 6.1969 |
| 1 | 21578.2889 | 18.1179 | 147.9720 | 0.2326 | 3.0008 |
质心与簇均值一致,这是均值质心的性质。它仍然不一定对应某条真实记录。
聚类结果怎样评价
无标签聚类通常没有唯一答案,可以从四个角度检查:内部指标关注惯性、轮廓系数、簇规模和分离程度;稳定性关注换一次抽样或初始化后结构是否相近;可解释性关注多特征画像是否能形成合理描述;实际用途关注结果能否帮助后续分析。
n_init=20 只能减小初始化偶然性,不能替代完整稳定性分析。更重要的是,算法指标不能直接授权对真实客户采取重要行动。
使用隐藏标签进行教学检查
最终 K 和模型确定后,代码才使用 adjusted_rand_score 计算调整兰德指数(Adjusted Rand Index,ARI):
ARI = 0.453989
ARI 不受簇编号交换影响,接近 1 表示两个分组较一致,接近 0 表示与随机分组相近,也可能小于 0。本次结果说明最终两簇与四个隐藏生成组存在部分对应,但并非一一恢复。
隐藏标签从未进入 X,也没有参与 K 的选择。ARI 只用于合成数据教学检查,现实无标签任务往往没有这项条件;它更不能证明聚类具有真实业务意义。
给新样本分配簇
训练完成后,Pipeline 可以把新样本分配给最近质心。代码用全体数据的 35% 和 65% 分位数构造两个范围内的教学样本,保证列名与顺序和训练数据一致。
实际输出为:
| 样本 | 预测簇 | 到簇0距离 | 到簇1距离 | 最近距离 |
|---|---|---|---|---|
| teaching_customer_a | 0 | 0.898544 | 2.175836 | 0.898544 |
| teaching_customer_b | 1 | 2.182212 | 0.819371 | 0.819371 |
这些距离来自标准化空间,不是概率或归属百分比。模型把样本分配给最近质心,并不意味着它"有 89.85% 属于簇 0"。
簇外样本与异常情况
K-Means 总会返回一个最近簇,即使新样本离所有质心都很远。程序因此计算训练样本到所属质心的距离分布:
平均值: 1.731826
95% 分位数: 2.576019
最大值: 3.728821
两个教学新样本的最近距离都低于 2.576019,因此没有触发"距离已有质心较远"的提示。但这只是一条教学阈值,不是经过验证的异常检测方法。实际系统还要检查新样本是否超出训练范围,以及数据分布是否变化。
K-Means 的常见假设
K-Means 更擅长发现围绕质心分布、形状接近球形或凸形的簇。如果数据呈弯月形、环形,或不同簇的大小和密度差异很大,最近质心的划分可能不符合直觉。
欧氏距离还必须能够合理表达相似性。把城市编码为 0、1、2 后直接聚类,会让算法误以为编号之间存在连续距离。质心使用均值,所以异常值也可能明显拉动中心。高维数据中,距离差异会变得不直观,无关特征的干扰更明显。

K-Means 常见问题
1. 忘记标准化
大尺度特征会主导距离。应把 StandardScaler 和 KMeans 放在同一个 Pipeline 中。
2. 只根据惯性最小选择 K
惯性会随 K 增加而下降。应结合下降幅度、轮廓系数、簇规模、稳定性和解释成本。
3. 把簇编号当作等级
编号没有天然顺序,不能直接解释为好坏、价值或风险。
4. 把聚类结果当作真实标签
聚类是根据当前输入构造的分组,不是人工确认的事实标签。
5. K 设置过小或过大
过小会合并结构,过大会过度拆分并可能形成小簇。
6. 忽略初始化
使用合理的 init、整数 n_init 和固定随机种子,才能更容易复查结果。
7. 输入无关特征
K-Means 会使用每一列计算距离,无关特征可能稀释真正有用的结构。
8. 异常值拉动质心
先判断异常值是数据错误、合法极端样本还是独立结构,不能为了漂亮结果随意删除。
9. 类别特征直接当连续数字
类别编号的数值差通常没有欧氏距离意义,需要根据字段含义选择合适表示和算法。
10. 缺失值、高维和远离所有簇的新样本
K-Means 通常不能直接处理 NaN;高维会削弱距离直觉;远离所有质心的样本仍会被强制分配。三种情况都需要额外检查。
K-Means 的优点
K-Means 原理直观、实现简单,在中等规模数值数据上通常运行较快;它能处理较多样本,输出明确质心,便于形成簇画像,也能为新样本分配已有簇,因此适合作为聚类基线。
K-Means 的缺点
它必须提前指定 K,对初始化、尺度、异常值和无关特征敏感;它偏好围绕质心分布的簇,不擅长非凸形状和密度差异很大的结构;类别特征不容易直接使用,簇编号也没有固定含义。无标签评价本身存在困难,到质心的距离更不是概率。
什么情况下适合使用 K-Means
当数据主要是数值特征、欧氏距离能够表达相似性、特征已合理标准化、预期结构接近球形或凸形,并且需要快速得到质心和聚类基线时,可以尝试 K-Means。
若数据主要是类别或文本字段,簇呈弯曲和环形,不同区域密度差异巨大,异常值很多,特征维度极高,或无法合理定义距离,K-Means 通常不是合适起点。聚类结果也不应直接用于重要现实决策。
K-Means 与 KNN 的区别
两个算法名称中都有 K,但含义完全不同。
KNN
- 监督学习,有已知标签;
- K 表示预测时查看多少个邻居;
- 可用于分类和回归;
- 新样本根据附近训练样本预测。
K-Means
- 无监督学习,没有已知标签;
- K 表示希望划分多少个簇;
- 通过质心反复分配样本;
- 用于探索数据分组。

完整实践代码
下面的代码不访问网络、不读取外部数据,也不生成图片。保存路径为 12_kmeans.py。
"""第 12 篇:使用 K-Means 分析本地生成的无标签教学数据。"""
from __future__ import annotations
import sys
from typing import Any, Sequence
try:
import numpy as np
import pandas as pd
import sklearn
from pandas.api.types import is_numeric_dtype
from sklearn.cluster import KMeans
from sklearn.datasets import make_blobs
from sklearn.metrics import adjusted_rand_score, silhouette_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
except ImportError as exc:
print(f"缺少运行依赖:{exc.name}。请先在当前环境中安装所需包。", file=sys.stderr)
raise
RANDOM_STATE = 42
SAMPLE_COUNT = 900
FEATURE_COLUMNS = [
"annual_spend",
"visits_per_month",
"average_order_value",
"discount_ratio",
"support_calls",
]
CANDIDATE_K_VALUES = list(range(2, 9))
MIN_CLUSTER_RATIO = 0.05
SILHOUETTE_TOLERANCE = 0.01
def check_environment() -> None:
"""输出实际解释器和直接依赖版本。"""
if sys.version_info < (3, 9):
raise RuntimeError("本示例需要 Python 3.9 或更高版本。")
print(f"Python: {sys.version.split()[0]}")
print(f"NumPy: {np.__version__}")
print(f"Pandas: {pd.__version__}")
print(f"Scikit-learn: {sklearn.__version__}")
print(f"Interpreter: {sys.executable}")
def generate_dataset() -> tuple[pd.DataFrame, np.ndarray]:
"""生成五维合成客户行为数据,并把隐藏分组单独返回。"""
latent_centers = np.array(
[
[-1.45, -1.20, 0.85, 1.15, 0.80],
[1.35, 1.05, 1.35, -1.05, -0.75],
[0.60, -0.75, -1.30, 0.95, 1.15],
[-0.75, 1.35, -0.65, -0.85, -1.15],
],
dtype=float,
)
latent_features, hidden_groups = make_blobs(
n_samples=SAMPLE_COUNT,
centers=latent_centers,
cluster_std=[0.95, 1.00, 1.05, 0.95],
random_state=RANDOM_STATE,
shuffle=True,
)
# 把潜在空间转换为量级不同的教学特征;这些数值不代表真实客户。
data = pd.DataFrame(
{
"annual_spend": 20000.0 + 5200.0 * latent_features[:, 0],
"visits_per_month": 13.0 + 4.2 * latent_features[:, 1],
"average_order_value": 135.0 + 32.0 * latent_features[:, 2],
"discount_ratio": 0.38 + 0.15 * latent_features[:, 3],
"support_calls": 4.5 + 1.6 * latent_features[:, 4],
},
columns=FEATURE_COLUMNS,
)
data["annual_spend"] = data["annual_spend"].clip(2000.0, 50000.0)
data["visits_per_month"] = data["visits_per_month"].clip(1.0, 35.0)
data["average_order_value"] = data["average_order_value"].clip(20.0, 300.0)
data["discount_ratio"] = data["discount_ratio"].clip(0.02, 0.95)
data["support_calls"] = data["support_calls"].clip(0.0, 15.0)
data = data.round(
{
"annual_spend": 2,
"visits_per_month": 2,
"average_order_value": 2,
"discount_ratio": 4,
"support_calls": 2,
}
)
return data, hidden_groups.astype(int)
def validate_dataset(
data: pd.DataFrame,
hidden_groups: np.ndarray,
) -> pd.DataFrame:
"""检查数据结构、数值有效性和隐藏标签隔离情况。"""
if list(data.columns) != FEATURE_COLUMNS:
raise ValueError("特征列名或顺序与预期不一致。")
if data.shape != (SAMPLE_COUNT, len(FEATURE_COLUMNS)):
raise ValueError(f"数据形状异常:{data.shape}")
if len(hidden_groups) != len(data):
raise ValueError("隐藏分组数量与特征样本数不一致。")
if any(not is_numeric_dtype(data[column]) for column in FEATURE_COLUMNS):
raise TypeError("所有 K-Means 特征都必须是数值类型。")
if data.isna().any().any():
raise ValueError("特征中存在缺失值。")
if not np.isfinite(data.to_numpy(dtype=float)).all():
raise ValueError("特征中存在无穷值或非有限数值。")
if data.duplicated().any():
raise ValueError("合成数据出现重复特征行,请检查生成逻辑。")
if "hidden_group" in data.columns:
raise ValueError("隐藏分组误入特征矩阵。")
if len(np.unique(hidden_groups)) != 4:
raise ValueError("合成数据没有形成预期的四个隐藏生成分组。")
print(f"\n完整数据形状: {data.shape}")
print(f"特征数量: {data.shape[1]}")
print(f"缺失值数量: {int(data.isna().sum().sum())}")
print(f"重复特征行数量: {int(data.duplicated().sum())}")
print("隐藏分组是否进入特征矩阵: 否")
print("隐藏分组仅由生成函数单独保留,训练和 K 选择阶段不会使用。")
print("\n前 5 行教学数据:")
print(data.head().to_string(index=False))
print("\n特征数据类型:")
print(data.dtypes.to_string())
print("\n特征统计:")
print(
data.describe()
.loc[["min", "max", "mean", "std"]]
.T.to_string(float_format=lambda value: f"{value:.4f}")
)
return data.loc[:, FEATURE_COLUMNS].copy()
def build_pipeline(n_clusters: int) -> Pipeline:
"""为一个候选 K 创建全新的标准化与聚类 Pipeline。"""
if n_clusters < 2:
raise ValueError("本示例的候选 K 必须至少为 2。")
return Pipeline(
steps=[
("scaler", StandardScaler()),
(
"clusterer",
KMeans(
n_clusters=n_clusters,
init="k-means++",
n_init=20,
random_state=RANDOM_STATE,
),
),
]
)
def evaluate_candidate_k(
features: pd.DataFrame,
candidate_values: Sequence[int],
) -> pd.DataFrame:
"""只用无标签特征评估候选 K。"""
rows: list[dict[str, Any]] = []
for k in candidate_values:
if k >= len(features):
raise ValueError(f"K={k} 不能大于或等于样本数量。")
pipeline = build_pipeline(k)
pipeline.fit(features)
scaler: StandardScaler = pipeline.named_steps["scaler"]
clusterer: KMeans = pipeline.named_steps["clusterer"]
labels = clusterer.labels_.astype(int)
scaled_features = scaler.transform(features)
cluster_sizes = np.bincount(labels, minlength=k)
rows.append(
{
"k": int(k),
"inertia": float(clusterer.inertia_),
"inertia_drop_ratio": np.nan,
"silhouette": float(silhouette_score(scaled_features, labels)),
"min_cluster_size": int(cluster_sizes.min()),
"min_cluster_ratio": float(cluster_sizes.min() / len(features)),
"cluster_sizes": cluster_sizes.tolist(),
}
)
results = pd.DataFrame(rows).sort_values("k").reset_index(drop=True)
for position in range(1, len(results)):
previous = float(results.loc[position - 1, "inertia"])
current = float(results.loc[position, "inertia"])
results.loc[position, "inertia_drop_ratio"] = (previous - current) / previous
print("\n候选 K 评估结果:")
display_columns = [
"k",
"inertia",
"inertia_drop_ratio",
"silhouette",
"min_cluster_size",
"min_cluster_ratio",
"cluster_sizes",
]
print(
results[display_columns].to_string(
index=False,
na_rep="not_applicable",
formatters={
"inertia": lambda value: f"{float(value):.4f}",
"inertia_drop_ratio": lambda value: (
"not_applicable" if pd.isna(value) else f"{float(value):.4f}"
),
"silhouette": lambda value: f"{float(value):.4f}",
"min_cluster_ratio": lambda value: f"{float(value):.4f}",
},
)
)
return results
def select_best_k(candidate_results: pd.DataFrame) -> tuple[int, str]:
"""按预先声明的簇规模、轮廓系数和较小 K 规则选 K。"""
eligible = candidate_results.loc[
candidate_results["min_cluster_ratio"] >= MIN_CLUSTER_RATIO
].copy()
if eligible.empty:
fallback = candidate_results.sort_values(
["silhouette", "min_cluster_ratio", "k"],
ascending=[False, False, True],
).iloc[0]
best_k = int(fallback["k"])
reason = (
"所有候选都出现小于 5% 的簇;已发出警告,并在轮廓系数较高的方案中"
"兼顾最小簇比例和较小 K。"
)
print("警告:所有候选 K 都产生了极小簇,结果需要谨慎解释。")
return best_k, reason
highest_silhouette = float(eligible["silhouette"].max())
close_candidates = eligible.loc[
eligible["silhouette"] >= highest_silhouette - SILHOUETTE_TOLERANCE
]
best_k = int(close_candidates["k"].min())
reason = (
"先排除最小簇比例低于 5% 的方案;在剩余方案中找到最高轮廓系数,"
"并把与最高值差距不超过 0.01 的方案视为接近,最终优先选择更小的 K。"
"惯性下降只作为辅助参考,隐藏生成分组没有参与选择。"
)
return best_k, reason
def train_final_model(
features: pd.DataFrame,
best_k: int,
) -> tuple[Pipeline, np.ndarray, pd.DataFrame]:
"""创建新的最终 Pipeline,并在完整无标签特征上训练。"""
final_model = build_pipeline(best_k)
final_model.fit(features)
clusterer: KMeans = final_model.named_steps["clusterer"]
labels = clusterer.labels_.astype(int)
clustered_data = features.copy()
clustered_data["cluster"] = labels
return final_model, labels, clustered_data
def build_cluster_profiles(
clustered_data: pd.DataFrame,
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""计算簇规模以及均值、中位数和标准差画像。"""
cluster_sizes = clustered_data["cluster"].value_counts().sort_index()
cluster_ratios = (cluster_sizes / len(clustered_data)).rename("ratio")
size_table = pd.concat(
[cluster_sizes.rename("size"), cluster_ratios],
axis=1,
)
means = clustered_data.groupby("cluster")[FEATURE_COLUMNS].mean()
medians = clustered_data.groupby("cluster")[FEATURE_COLUMNS].median()
standard_deviations = clustered_data.groupby("cluster")[FEATURE_COLUMNS].std()
print("\n最终簇样本数量和比例:")
print(
size_table.to_string(
formatters={"ratio": lambda value: f"{float(value):.4f}"}
)
)
print("\n各簇特征均值:")
print(means.to_string(float_format=lambda value: f"{value:.4f}"))
print("\n各簇特征中位数:")
print(medians.to_string(float_format=lambda value: f"{value:.4f}"))
print("\n各簇特征标准差:")
print(standard_deviations.to_string(float_format=lambda value: f"{value:.4f}"))
return size_table, means, medians, standard_deviations
def recover_original_centers(final_model: Pipeline) -> pd.DataFrame:
"""把标准化空间质心反转换为原始教学单位。"""
scaler: StandardScaler = final_model.named_steps["scaler"]
clusterer: KMeans = final_model.named_steps["clusterer"]
standardized_centers = pd.DataFrame(
clusterer.cluster_centers_,
columns=FEATURE_COLUMNS,
)
standardized_centers.index.name = "cluster"
original_centers = pd.DataFrame(
scaler.inverse_transform(clusterer.cluster_centers_),
columns=FEATURE_COLUMNS,
)
original_centers.index.name = "cluster"
print("\n标准化空间质心:")
print(standardized_centers.to_string(float_format=lambda value: f"{value:.4f}"))
print("\n原始单位质心:")
print(original_centers.to_string(float_format=lambda value: f"{value:.4f}"))
return original_centers
def calculate_assigned_distances(
final_model: Pipeline,
features: pd.DataFrame,
cluster_labels: np.ndarray,
) -> tuple[np.ndarray, dict[str, float]]:
"""计算训练样本到所属质心的距离分布。"""
scaler: StandardScaler = final_model.named_steps["scaler"]
clusterer: KMeans = final_model.named_steps["clusterer"]
scaled_features = scaler.transform(features)
all_distances = clusterer.transform(scaled_features)
assigned_distances = all_distances[
np.arange(len(cluster_labels)),
cluster_labels,
]
stats = {
"mean": float(np.mean(assigned_distances)),
"q95": float(np.quantile(assigned_distances, 0.95)),
"max": float(np.max(assigned_distances)),
}
print("\n训练样本到所属质心的距离:")
print(f"平均值: {stats['mean']:.6f}")
print(f"95% 分位数: {stats['q95']:.6f}")
print(f"最大值: {stats['max']:.6f}")
return assigned_distances, stats
def evaluate_synthetic_structure(
hidden_groups: np.ndarray,
cluster_labels: np.ndarray,
) -> float:
"""最终训练完成后,才用隐藏标签进行一次合成数据教学检查。"""
ari = float(adjusted_rand_score(hidden_groups, cluster_labels))
print(f"\n合成数据教学检查 ARI: {ari:.6f}")
print("ARI 没有参与 K 的选择,只用于检查代码生成结构与最终分组的一致程度。")
return ari
def build_new_customers(features: pd.DataFrame) -> pd.DataFrame:
"""用全体数据分位数构造两个位于训练范围内的教学样本。"""
lower = features.quantile(0.35)
upper = features.quantile(0.65)
new_customers = pd.DataFrame(
[
{
"annual_spend": lower["annual_spend"],
"visits_per_month": lower["visits_per_month"],
"average_order_value": upper["average_order_value"],
"discount_ratio": upper["discount_ratio"],
"support_calls": upper["support_calls"],
},
{
"annual_spend": upper["annual_spend"],
"visits_per_month": upper["visits_per_month"],
"average_order_value": lower["average_order_value"],
"discount_ratio": lower["discount_ratio"],
"support_calls": lower["support_calls"],
},
],
index=["teaching_customer_a", "teaching_customer_b"],
columns=FEATURE_COLUMNS,
)
minimums = features.min()
maximums = features.max()
if not new_customers.ge(minimums).all().all() or not new_customers.le(maximums).all().all():
raise ValueError("新教学样本超出训练特征范围。")
return new_customers
def predict_new_customers(
final_model: Pipeline,
new_customers: pd.DataFrame,
training_distance_q95: float,
) -> pd.DataFrame:
"""输出新样本簇编号、到各质心距离和谨慎提示。"""
if list(new_customers.columns) != FEATURE_COLUMNS:
raise ValueError("新样本列名或顺序与训练特征不一致。")
scaler: StandardScaler = final_model.named_steps["scaler"]
clusterer: KMeans = final_model.named_steps["clusterer"]
predicted_clusters = final_model.predict(new_customers).astype(int)
transformed_new = scaler.transform(new_customers)
distances = clusterer.transform(transformed_new)
nearest_distances = distances[
np.arange(len(new_customers)),
predicted_clusters,
]
distance_columns = [
f"distance_to_cluster_{cluster_id}"
for cluster_id in range(clusterer.n_clusters)
]
result = pd.concat(
[
new_customers.reset_index(names="sample"),
pd.DataFrame(distances, columns=distance_columns),
],
axis=1,
)
result["predicted_cluster"] = predicted_clusters
result["nearest_distance"] = nearest_distances
result["beyond_training_q95"] = nearest_distances > training_distance_q95
print("\n新客户预测:")
print(result.to_string(index=False, float_format=lambda value: f"{value:.6f}"))
for row in result.itertuples(index=False):
if bool(row.beyond_training_q95):
print(f"提示:{row.sample} 距离已有质心较远,簇分配需要谨慎解释。")
else:
print(f"提示:{row.sample} 的最近距离没有超过训练距离 95% 分位数。")
print("到质心的距离是标准化特征空间中的距离,不是概率或归属百分比。")
return result
def main() -> None:
"""执行完整、无隐藏标签选参的 K-Means 教学流程。"""
check_environment()
data, hidden_groups = generate_dataset()
features = validate_dataset(data, hidden_groups)
candidate_results = evaluate_candidate_k(features, CANDIDATE_K_VALUES)
best_k, selection_reason = select_best_k(candidate_results)
print(f"\n最终选择 K: {best_k}")
print(f"选择依据: {selection_reason}")
final_model, cluster_labels, clustered_data = train_final_model(features, best_k)
build_cluster_profiles(clustered_data)
recover_original_centers(final_model)
_, distance_stats = calculate_assigned_distances(
final_model,
features,
cluster_labels,
)
# 隐藏分组直到最终模型和 K 都确定后才用于一次教学检查。
evaluate_synthetic_structure(hidden_groups, cluster_labels)
new_customers = build_new_customers(features)
predict_new_customers(final_model, new_customers, distance_stats["q95"])
print("\n谨慎结论:")
print(
"以上结果只描述固定合成数据在当前特征和欧氏距离下形成的分组。"
"簇编号没有等级含义,距离不是概率,ARI 也不能证明现实业务价值。"
)
print("程序运行完成。")
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"\n程序运行失败:{exc}", file=sys.stderr)
raise
运行命令:
python 12_kmeans.py
本机使用 Python 3.11.4、NumPy 1.24.3、Pandas 1.5.3 和 scikit-learn 1.3.0 验证通过。当前 Windows 与 MKL 组合会输出 scikit-learn 关于线程配置的提示,但程序退出码为 0,前述数值均来自实际运行。不同版本可能出现轻微浮点差异。
代码结构说明
generate_dataset() 只负责生成特征和单独的隐藏组;validate_dataset() 确保隐藏组没有进入 X。build_pipeline() 保证每个候选 K 都得到新的标准化器和 K-Means。
evaluate_candidate_k() 计算惯性、下降比例、轮廓系数和最小簇规模;select_best_k() 只接收无标签评估表,因此无法读取隐藏标签。K 确定后,train_final_model() 创建新的 Pipeline。
build_cluster_profiles() 计算均值、中位数和标准差;recover_original_centers() 把质心反标准化;calculate_assigned_distances() 统计训练距离。直到这些步骤完成,evaluate_synthetic_structure() 才读取隐藏组计算 ARI。最后两个函数构造并预测新样本,同时明确距离不是概率。
本文总结
K-Means 通过"分配到最近质心"和"重新计算均值"反复更新簇。使用它时,标准化、初始化次数、随机种子和 K 的选择都很重要。惯性会随 K 增大而下降,不能单独决定 K;轮廓系数、最小簇规模和解释成本需要共同考虑。
本次合成实验按预先规则选择 K=2,形成 449 和 451 个样本的两个簇。隐藏四组只在最终阶段用于 ARI 教学检查,没有进入训练或选参。簇编号不是等级,质心是统计中心,到质心的距离也不是概率。
下一篇预告
下一篇是本系列第 13 篇综合项目,将串联数据检查、预处理、建模、验证、评估与结果解释。本文完成后不会自动开始该项目。