12 K-Means 聚类入门:没有标签如何发现数据分组

前言

前面学习的线性回归、逻辑回归、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 附近。若直接计算距离,金额差异会因为数值大而轻易压过其他特征。

因此代码使用 StandardScalerKMeans 组成 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=20random_state=42,只在无标签特征上拟合。选择规则在运行前就已写入代码:

  1. 排除最小簇比例低于 5% 的方案;
  2. 在剩余方案中优先看轮廓系数;
  3. 与最高轮廓系数差距不超过 0.01 时,优先选择较小 K;
  4. 惯性及下降幅度只作辅助参考;
  5. 隐藏生成组不参与选择。

实际结果如下:

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. 忘记标准化

大尺度特征会主导距离。应把 StandardScalerKMeans 放在同一个 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 篇综合项目,将串联数据检查、预处理、建模、验证、评估与结果解释。本文完成后不会自动开始该项目。

相关推荐
-dzk-1 小时前
【二叉树】LC 101.对称二叉树
算法·二叉树
白狐_7981 小时前
408数据结构第6章:图——核心考点、算法对比与AOE网真题
数据结构·算法
变量未定义~2 小时前
DFS之剪枝与优化-小猫爬山、 数独、 木棒
算法
Navigator_Z2 小时前
LeetCode //C - 1187. Make Array Strictly Increasing
c语言·算法·leetcode
龍德明宇2 小时前
如何理解大语言模型的负主体性-龍德明宇
人工智能·算法·大语言模型llm·负主体性·ai存在论
白狐_7983 小时前
408数据结构第6章:迪杰斯特拉(Dijkstra)算法——真题精讲
数据结构·算法
皓月斯语3 小时前
P2858 [USACO06FEB] Treats for the Cows G/S
数据结构·c++·算法·动态规划
旖旎夜光3 小时前
LeetCode 397:整数替换(贪心问题) —— 题解
数据结构·c++·算法·leetcode·贪心算法
奈斯先生Vector3 小时前
2026 开发者效能革命:基于创源AIGC 与开源生态的工程化实践、安全沙箱与代码智能体协同
人工智能·算法·架构·prompt·aigc