13 聚类分析:在未知中寻找规律

本章会用到的数据

  • 公开下载公司基本信息。代码会在首次运行时下载并保存到 data/course/

  • 这些数据能做什么:根据上市年限和发行价等特征对公司进行分组。

  • 分析时注意:聚类没有预先给定的正确标签,结果要结合变量含义和稳定性解释。

  • 判断结果:比较不同聚类数和随机起点,并说明分组是否容易解释。

【课堂核心】2.5 学时学习安排(1/2)

学习内容 分钟
动机与先修 10
K-Means 机制 25
合成案例输入 20
合成案例决策 25

【课堂核心】2.5 学时学习安排(2/2)

学习内容 分钟
形成性检查与反馈 10
独立任务与反馈 30
公开案例 20
小结 10

【可选拓展】:未列入本表的层次聚类、额外二维机制图与完整推导安排课后。

本章议程:在未知中寻找规律

1. 核心概念

  • 为何需要无监督学习?
  • 什么是聚类分析?
  • 目标:高内聚、低耦合

2. K-均值算法

  • 直觉与数学原理
  • 算法分步详解
  • 优点局限性

3. Python实战

  • 数据预处理关键步骤
  • 提出并验证候选 \(K\)
  • 解读验证聚类结果

回顾:监督学习的世界

在之前的章节中,我们的任务是有明确目标的预测。

  • 我们拥有包含“问题” (特征 X) 和“答案” (标签 y) 的数据集。
  • 目标: 训练一个模型 \(f\),使其能够根据新的 X 准确预测出 y
  • 例子: 根据贷款申请人的信息 (X),预测其是否会违约 (y)。

我们总是在一位“导师”的指导下学习。

核心问题:当没有“正确答案”时,我们如何发现规律?

但是,如果现实世界没有给我们提供 y 呢?

  • 我们只有一大堆数据点 X,没有任何预先定义的标签。
  • 我们不知道数据的内在结构。
  • 我们无法计算“准确率”,因为没有“正确答案”可以参照。

这时,我们就从“预测”转向了“发现”。

引入:无监督学习

无监督学习 (Unsupervised Learning) 是一类机器学习任务,其目标是从无标签的数据中发现隐藏的模式或内在结构

它不是为了预测一个特定的输出,而是为了理解数据本身

一个直观的类比:监督 vs. 无监督

监督学习

如同跟着一本有标准答案的习题册学习。每做一道题,都可以对照答案,知道自己是对是错,并不断修正方法。

监督学习类比 一个模型根据带有标签的数据学习预测规则。 目标:学习预测答案 带标签的数据 (X, y) 模型 f(X) 回归 / 分类 预测: ✓ 预测: ✗ 预测结果

无监督学习

如同在没有地图和导航的土地上探索。你需要自己观察地形,识别出哪里是森林,哪里是河流,哪里是山脉,从而绘制出地图。

无监督学习类比 一个模型从未标记的数据中发现隐藏的结构。 目标:发现内在结构 无标签的数据 (X) 模型 f(X) 聚类 发现的结构

核心问题:监督学习 vs. 无监督学习

这两种方法论代表了数据分析的两种不同思维范式。

特征 监督学习 (Supervised Learning) 无监督学习 (Unsupervised Learning)
数据 包含特征 (X) 和标签 (y) 只包含特征 (X)
目标 预测标签 y 发现数据 X 中的隐藏结构
核心问题 “这个新客户会违约吗?” “我的客户可以被分成哪几类?”
主要任务 分类、回归 聚类、降维、关联规则挖掘

聚类分析是无监督学习中最重要、最基础的工具。

引入案例:候选客群能否通过独立结果标签检查?

想象一家银行先按可观测特征形成候选客群,再用独立、足量、点时的结果标签检验群体差异。

  • 挑战: 银行拥有的只是客户的基本信息(收入、负债、工龄等),并没有一个现成的“高风险客户”标签。
  • 问题: 客户特征能形成哪些描述性群体?这些群体是否与风险有关,必须由聚类之外的结果标签和样本外设计回答。

这就是聚类分析要解决的问题。

可视化问题:一片混沌的客户数据

我们的起点是一堆看似杂乱无章的客户数据点。每个点代表一个客户,其在图上的位置由其经济特征(如收入、负债)决定。

我们的目标是找到一种方法,让计算机自动地为这些点“着色”,把相似的客户圈在一起。

混沌的客户数据 一团未被分类的灰色数据点,代表需要被聚类的客户。 我们能从这片“混沌”中发现结构吗? 特征 1 (如: 收入) 特征 2 (如: 负债)

聚类分析的目标:让“相似的”数据“在一起”

聚类分析 (Clustering) 是一种将数据集中的样本划分为若干个不相交子集(称为“簇”,Cluster)的过程。

核心原则:

  • 簇内相似性 (Intra-cluster similarity) 高: 同一个簇内的数据点彼此相似。
  • 簇间相似性 (Inter-cluster similarity) 低: 不同簇之间的数据点彼此不相似。

在贷款模拟案例中,我们只在选定特征与距离尺度下寻找两个描述性客群;簇号与高、低违约风险类别没有对应关系。

图解聚类原则

一个好的聚类结果,应该像下图这样:簇内部的点都紧紧地抱在一起,而不同颜色的簇之间则泾渭分明。

聚类原则示意图 两个清晰分开的簇,展示了高簇内相似性和低簇间相似性。 高簇内相似性 低簇间相似性

我们将聚焦于最经典的算法:K-均值 (K-Means)

K-均值法是一种非常流行且直观的聚类算法。它在各种应用中都表现出色,是每个数据科学家都必须掌握的基础工具。

观察下图中,即使我们不知道每个点的“标签”,我们的直觉也能清晰地将这些数据点分为三个不同的群组。

K-均值算法就是用数学的方式来形式化并实现这一过程。

K-均值的视觉直觉

代码
# 为“K-均值的视觉直觉”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“K-均值的视觉直觉”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“K-均值的视觉直觉”,从 `sklearn.datasets` 导入`make_blobs` 用于生成聚类机制演示所需的成团样本。
from sklearn.datasets import make_blobs

# 生成三个中心明确的二维点群,作为 K-means 最近质心直觉的机制输入。
X, y = make_blobs(n_samples=300, centers=3, cluster_std=0.8, random_state=42)

# 为“K-均值的视觉直觉”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(8, 5))
# 以 `X[:, 0]` 为横轴、`X[:, 1]` 为纵轴绘制散点,展示“一个可以被清晰地分为三个簇的数据集”。
plt.scatter(X[:, 0], X[:, 1], s=40, edgecolors='k', alpha=0.8, c='#808080')
# 将图题设为“直觉告诉我们这里有三个群体”,直接说明当前图形的比较目的。
plt.title('直觉告诉我们这里有三个群体', fontsize=16)
# 将横轴标为“特征 1 (Feature 1)”,明确横向编码的变量。
plt.xlabel('特征 1 (Feature 1)')
# 将纵轴标为“特征 2 (Feature 2)”,明确纵向编码的变量。
plt.ylabel('特征 2 (Feature 2)')
# 为“K-均值的视觉直觉”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 显示尚未拟合或着色的灰色机制点云,只用空间间隔建立“三个自然点群”的视觉直觉。
plt.show()
二维散点以颜色区分三个自然点群;群内点较集中、群间留有间隔,作为 K=3 的机制示例。
图 1: 一个可以被清晰地分为三个簇的数据集

解构算法名称: “K-Means”

这个名字本身就揭示了算法的两个核心要素:

  • K: 这是我们需要预先指定的超参数,代表我们希望将数据分成多少个簇。
    • 贷款案例把 K=2 作为事先确定教学候选;K 不能由事后风险标签或希望得到的业务故事决定。
    • 对于上一页的数据,最合理的选择显然是 K=3
  • Means (均值): 这指的是算法的核心操作——计算簇内数据点逐坐标的算术平均值,得到簇的质心 (Centroid)。在平方欧氏距离下,该质心最小化簇内平方和。

如何用数学语言定义一个“好”的聚类?

为了让计算机执行聚类,我们必须将“好的聚类”这个模糊概念,转化为一个可以计算和优化的数学目标。

这需要两个关键的数学工具:

  1. 距离度量 (Distance Metric): 一种量化两个数据点之间“不相似度”的方法。
  2. 目标函数 (Objective Function): 一个评判整个聚类结果优劣的单一数值指标。

基础:使用平方欧几里得距离度量差异

最常用的距离度量方法是平方欧几里得距离 (Squared Euclidean Distance)。

对于两个 m 维的数据点 \(x^{(i)}\)\(x^{(j)}\),它们之间的平方欧几里得距离定义为:

\[ \large{d^2(x^{(i)}, x^{(j)}) = \sum_{k=1}^{m} (x_k^{(i)} - x_k^{(j)})^2 = ||x^{(i)} - x^{(j)}||^2} \]

直观理解: 这本质上就是我们在二维或三维空间中熟悉的直线距离的平方,并推广到了更高维度。

图解欧几里得距离

在二维空间中,它就是勾股定理的应用。这个距离是我们衡量“相似性”的基石。

欧几里得距离示意图 在二维坐标系中,两个点之间的欧几里得距离由勾股定理计算得出。 特征 1 特征 2 x⁽ⁱ⁾ x⁽ʲ⁾ d(x⁽ⁱ⁾, x⁽ʲ⁾) Δ 特征 1 Δ 特征 2

目标:最小化簇内离差平方和 (WCSS)

有了距离度量,我们就可以定义一个簇 \(C_k\) 的“紧凑程度”。我们使用簇内离差平方和 (Within-Cluster Sum of Squares, WCSS) 来衡量。

它等于簇内每个数据点到该簇中心点 (Centroid) \(\mu_k\) 距离的平方和。

\[ \large{\text{WCSS}(C_k) = \sum_{x_i \in C_k} ||x_i - \mu_k||^2} \]

  • \(\mu_k\) 是簇 \(k\) 的中心点(即该簇所有点的均值)。
  • WCSS 值越小,说明簇 \(k\) 内部的数据点越紧凑。

图解 WCSS

WCSS 的值就是下图中所有红色虚线长度的平方之和。我们的目标是让这个总和尽可能小。

簇内离差平方和 (WCSS) 示意图 一个簇,包含一个中心点和多个数据点,每个数据点到中心点的距离线被高亮显示。 WCSS = Σ (红色虚线长度)² μk

K-均值的最终目标函数

现在,我们可以明确 K-均值算法的最终优化目标了。

假设我们要将数据分为 \(K\) 个簇 \(C_1, C_2, \dots, C_K\),我们的目标是找到这样一种划分方式,使得所有簇的 WCSS 之和 (即 Total WCSS) 最小:

\[ \large{\min_{C_1, \dots, C_K} \sum_{k=1}^{K} \text{WCSS}(C_k) = \min_{C_1, \dots, C_K} \sum_{k=1}^{K} \sum_{x_i \in C_k} ||x_i - \mu_k||^2} \]

这个公式是固定 \(K\)、距离、缩放与样本规格下的内部优化目标和拟合指标。WCSS 随 \(K\) 增大必然不升,不能跨 \(K\) 或跨缩放方案直接充当最终质量判断;簇数、稳定性与业务采用还须结合轮廓系数、跨 seed ARI、外部或时间外检查及不采用条件。

求解挑战:一个NP-Hard问题

直接求解上述目标函数是一个极其困难的组合优化问题 (NP-hard)。

  • 对于一个有 \(N\) 个数据点和 \(K\) 个簇的数据集,可能的划分方式数量是天文数字。
  • 暴力搜索所有可能性是完全不可行的。

幸运的是,我们有一个非常高效的启发式算法,可以在实践中快速找到一个很好的局部最优解

K-均值算法流程:一个优雅的迭代过程

这个算法通过交替执行两个核心步骤来逐步优化聚类结果,直到收敛。

  1. 分配 (Assignment Step): 将每个数据点分配给离它最近的簇中心。
  2. 更新 (Update Step): 将每个簇的中心点更新为该簇内所有数据点的均值

算法不断重复这两个步骤,就像跳着一支优雅的华尔兹,直到簇的分配不再发生变化为止。

算法分步图解:起点

我们从一堆无标签的数据点开始。假设我们决定要找 K=2 个簇。

K-Means Step 0: Unlabeled Data 灰色散点展示尚未分配簇标签的二维样本。 Step 0: 我们的原始数据 (K=2)

算法分步图解:步骤 1 (初始化)

随机在数据空间中选择 K 个点作为初始的簇中心(质心, Centroids)。

K-Means Step 1: Initialize Centroids 同一组散点上新增两个随机放置的初始质心。 Step 1: 随机初始化 2 个簇中心

算法分步图解:步骤 2a (分配)

对于每个数据点,计算它到两个簇中心的距离,并将其分配给最近的那个簇。数据点开始有了“颜色”。

K-Means Step 2a: Assignment 十个数据点按欧氏距离分配:左侧五点属于蓝簇,右侧五点属于橙簇。 Step 2a (迭代 1): 将数据点分配给最近的中心

算法分步图解:步骤 2b (更新)

对于每个簇,重新计算其中心点,即该簇内所有数据点逐坐标的算术平均(质心);在平方欧氏距离下,这个更新使当前簇内平方和最小。

本例重新计算得到:蓝簇质心为 ((200+250+180+280+230)/5, (150+120+200+180+210)/5) = (228, 172);橙簇质心为 (576, 234)

\[ \large{\mu_k = \frac{1}{|C_k|} \sum_{x_i \in C_k} x_i} \]

本例质心移动到 (228,172) 与 (576,234)

K-Means Step 2b: Update Centroids 旧质心淡化;蓝簇五点的算术均值为(228,172),橙簇五点的算术均值为(576,234)。

算法分步图解:迭代循环

接下来,我们基于的簇中心,不断重复分配更新这两个步骤。

K-Means Iteration Loop 回环箭头表示重复执行样本分配与质心更新步骤。 分配 (Assignment) 更新 (Update) 1. 将点分配给最近的中心 2. 更新中心到簇的均值 重复此过程... 直到分配结果不再改变

算法分步图解:最终收敛

最终,簇中心会稳定在各自数据群的“重心”,分配结果不再变化,算法就收敛了。我们成功地发现了数据中的两个群组。

K-Means Converged State 最终散点已按最近质心分簇,两个质心位置保持稳定。 算法收敛:我们找到了两个稳定的簇

K-均值算法的动态演示

四个面板依次展示 \(K=3\) 的第 1、2、3 次迭代与收敛状态:质心向点云重心移动,簇标签逐步稳定。

代码
# 为“K-均值算法的动态演示”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“K-均值算法的动态演示”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“K-均值算法的动态演示”,从 `sklearn.datasets` 导入`make_blobs` 用于生成聚类机制演示所需的成团样本。
from sklearn.datasets import make_blobs
# 为“K-均值算法的动态演示”,从 `sklearn.cluster` 导入 `KMeans`,执行 K-means 客户分群。
from sklearn.cluster import KMeans

# 生成固定三簇点群,供四个面板复现初始化、分配和质心更新过程。
X, y_true = make_blobs(n_samples=300, centers=3, cluster_std=0.8, random_state=42)

# 封装单次 K-means 数据版本,把样本簇标签、质心位置与迭代编号绘到指定面板。
def plot_kmeans_iteration(X, kmeans, iteration, ax, show_legend=False):
    # 预测并获取中心点
    labels = kmeans.predict(X)
    # 读取 `centers` 的簇中心坐标,标出 K-means 当前迭代的位置。
    centers = kmeans.cluster_centers_

    # 绘制数据点
    ax.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis', alpha=0.7, edgecolors='k')

    # 绘制中心点
    ax.scatter(centers[:, 0], centers[:, 1], c='red', s=200, alpha=0.9, marker='X', label='Centroids', edgecolors='k')

    # 将图题设为“迭代 {iteration}”,直接说明当前图形的比较目的。
    ax.set_title(f'迭代 {iteration}', fontsize=18)
    ax.tick_params(labelsize=16)  # 统一各迭代面板的刻度字号
    # 为“K-均值算法的动态演示”添加辅助网格,便于比较位置、斜率或组间差异。
    ax.grid(True)
    # 仅在指定面板显示图例,避免四个分面重复占用空间。
    if show_legend:
        # 显示“K-均值算法的动态演示”图例,使颜色或线型与比较对象一一对应。
        ax.legend()

# 创建二乘二面板画布,按迭代顺序展示 K-means 的分配与质心移动。
fig, axes = plt.subplots(2, 2, figsize=(12, 6))
# 创建 `axes` 画布,承载“K-均值聚类的迭代步骤 (K=3)”的并排视觉比较。
axes = axes.flatten()


# 迭代 1
kmeans_iter1 = KMeans(n_clusters=3, init='random', n_init=1, max_iter=1, random_state=100)
# 用 `X` 更新 `kmeans_iter1` 的簇中心与样本分配,完成本次 K-means 拟合。
kmeans_iter1.fit(X)
# 绘制 K-means 的指定迭代数据版本,展示质心移动与簇分配如何收敛。
plot_kmeans_iteration(X, kmeans_iter1, 1, axes[0])

# 迭代 2
kmeans_iter2 = KMeans(n_clusters=3, init=kmeans_iter1.cluster_centers_, n_init=1, max_iter=1, random_state=1)
# 用 `X` 更新 `kmeans_iter2` 的簇中心与样本分配,完成本次 K-means 拟合。
kmeans_iter2.fit(X)
# 绘制 K-means 的指定迭代数据版本,展示质心移动与簇分配如何收敛。
plot_kmeans_iteration(X, kmeans_iter2, 2, axes[1])

# 迭代 3
kmeans_iter3 = KMeans(n_clusters=3, init=kmeans_iter2.cluster_centers_, n_init=1, max_iter=1, random_state=1)
# 用 `X` 更新 `kmeans_iter3` 的簇中心与样本分配,完成本次 K-means 拟合。
kmeans_iter3.fit(X)
# 绘制 K-means 的指定迭代数据版本,展示质心移动与簇分配如何收敛。
plot_kmeans_iteration(X, kmeans_iter3, 3, axes[2])

# 迭代 10 (收敛)
kmeans_final = KMeans(n_clusters=3, init='random', n_init=1, max_iter=10, random_state=100)
# 用 `X` 更新 `kmeans_final` 的簇中心与样本分配,完成本次 K-means 拟合。
kmeans_final.fit(X)
# 绘制 K-means 的指定迭代数据版本,展示质心移动与簇分配如何收敛。
plot_kmeans_iteration(X, kmeans_final, '最终 (收敛)', axes[3], show_legend=True)

# 为“K-均值算法的动态演示”,压缩子图留白,避免标题和坐标标签相互遮挡。
fig.tight_layout()
# 显示 K-means 各迭代阶段的分配与质心移动,检查目标函数通过交替更新逐步下降。
plt.show()
四个二维散点分面依次展示 K=3 的第 1、2、3 次迭代与最终收敛状态;点的颜色表示簇标签,红色 X 表示质心,质心逐步移向各色点云中心。
图 2: K-均值聚类的迭代步骤 (K=3)

K-均值:一个强大但非完美的工具

K-均值是一个强大而简洁的算法,但理解其短板同样重要。我们需要知道在什么情况下它会表现不佳,以及如何规避这些问题。

优点 缺点/局限性
速度快、效率高: 对于大规模数据集,算法收敛速度很快。 局部最优: 结果受初始化的影响,可能收敛到局部最优而非全局最优解。
原理简单: 易于理解和实现。 对特征尺度敏感: 距离计算受变量单位的影响。
可解释性强: 聚类结果(簇中心)有直观的物理解释。 需要预先指定K值: 在许多应用中,我们并不知道最佳的簇数量。
适用性广: 能够处理各种维度的数据。 对非球形簇效果不佳:倾向于发现大小相似的球形簇。

缺点 1:结果受随机初始化影响

由于算法从随机的中心点开始,不同的初始值可能导致完全不同的聚类结果。

  • 问题: 算法只能保证收敛到一个局部最优解,但不能保证这是全局最优解
K-Means Local Optima Problem 两个面板比较同一数据在不同随机初始质心下得到的最终聚类结果。 运行 1: 好的初始化 → 好的结果 Total WCSS = 15.8 运行 2: 差的初始化 → 局部最优解 Total WCSS = 24.1

解决方案:多次初始化 (n_init)

解决方案: 实践中,我们会进行多次随机初始化,独立运行 K-均值算法,然后选择最终 WCSS 最小的那个结果作为最终答案。

幸运的是,scikit-learn 中的 KMeans 类通过 n_init 参数自动完成了这个过程。

代码
# 为“解决方案:多次初始化 (`n_init`)”,导入 KMeans,演示多次初始化后按最低 WCSS 选择聚类结果。
from sklearn.cluster import KMeans

# 本章显式 n_init=10,运行 10 个初始化并保留最低 inertia;sklearn 1.7.2 的 auto 次数取决于 init
# 并返回WCSS最低的那个结果
# 固定随机种子并重复十次初始化,保留簇内平方和最低的解。
kmeans = KMeans(n_clusters=3, n_init=10, random_state=42)

这可降低落入较差局部解的风险,但仍不保证全局最优。

缺点 2:对特征的尺度敏感

K-均值基于欧几里得距离,这意味着数值范围大的特征会在距离计算中占据主导地位。

  • 例子: 假设我们有两个特征:年收入 (范围: 20,000 - 200,000) 和 住房数量 (范围: 0 - 5)。在计算距离时,收入的巨大差异会完全掩盖住房数量的影响。
K-Means Feature Scaling Problem 左面板未缩放时横轴量级远大于纵轴并主导距离;右面板缩放后两轴尺度可比。 未标准化:收入主导距离 年收入 (20k - 200k) 子女数 (0-5) 距离 ≈ |Δ收入| Z-score 后:单位权重可比 收入 (Z-score) 子女数 (Z-score) 缩放规则定义距离权重

解决方案:先说明距离与缩放规则

  • Z-score:量纲不可比、且希望各列具有同等初始权重时的常用选项。
  • 保留业务尺度:原量纲本身已有明确业务权重时。
  • 稳健变换:重尾或离群值明显时,比较 log1p、winsorize 或 robust scaling。
  • 共同约束:缩放参数只在当前拟合样本估计,并检查簇成员、轮廓系数与原量纲画像的敏感性。
代码
# 为“解决方案:特征标准化”导入逐特征估计拟合样本均值与标准差的缩放器,避免大尺度变量主导距离。
from sklearn.preprocessing import StandardScaler

# 逐特征估计当前聚类拟合样本的均值与标准差。
scaler = StandardScaler()
# 使用同一组拟合样本逐特征统计量将财务特征转换为可比尺度。
X_scaled = scaler.fit_transform(X)

本页代码演示“各列同等初始权重”口径下的 Z-score;是否采用必须由上述目标与敏感性证据决定。

缺点 3:必须预先确定 K 的值

这是 K-均值最核心的挑战。如果选择的 K 值不当,聚类结果将毫无意义。

如下方 图 3 所示,如果我们错误地将数据分为 K=5 个簇,算法会强行将一个自然的群体拆分开,产生误导性的结论。

那么,我们如何科学地选择 K 呢?

代码
# 建立错误的五簇 K-means,演示自然三簇结构被过度切分的后果。
kmeans_wrong_k = KMeans(n_clusters=5, random_state=42, n_init=10)
# 拟合聚类器并得到 `y_pred`,让每条公司观测对应一个簇标签。
y_pred = kmeans_wrong_k.fit_predict(X)

# 为“缺点 3:必须预先确定 K 的值”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(9, 3.0))
# 以 `X[:, 0]` 为横轴、`X[:, 1]` 为纵轴绘制散点,展示“选择错误的K值 (K=5) 导致了糟糕的聚类结果”。
plt.scatter(X[:, 0], X[:, 1], c=y_pred, s=40, cmap='viridis', edgecolors='k', alpha=0.8)
# 读取 `centers` 的簇中心坐标,标出 K-means 当前迭代的位置。
centers = kmeans_wrong_k.cluster_centers_
# 以 `centers[:, 0]` 为横轴、`centers[:, 1]` 为纵轴绘制散点,展示“选择错误的K值 (K=5) 导致了糟糕的聚类结果”。
plt.scatter(centers[:, 0], centers[:, 1], c='red', s=200, alpha=0.9, marker='X', edgecolors='k')
# 将图题设为“当 K=5 时,自然的簇被强行分割”,直接说明当前图形的比较目的。
plt.title('当 K=5 时,自然的簇被强行分割', fontsize=18)
plt.xticks(fontsize=16)  # 放大横轴刻度以适配投影
plt.yticks(fontsize=16)  # 放大纵轴刻度以适配投影
# 将横轴标为“特征 1 (Feature 1)”,明确横向编码的变量。
plt.xlabel('特征 1 (Feature 1)')
# 将纵轴标为“特征 2 (Feature 2)”,明确纵向编码的变量。
plt.ylabel('特征 2 (Feature 2)')
# 为“缺点 3:必须预先确定 K 的值”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 显示单个 K=5 聚类结果及五个质心,检查自然三簇被过度切分的失败模式。
plt.show()
二维样本被五种颜色强制切成 K=5;多个自然点群被拆分,红色中心标记显示错误 K 带来的过度分割。
图 3: 选择错误的K值 (K=5) 导致了糟糕的聚类结果

解决方案:用“肘部法则”提出候选 K

“肘部法则”是根据当前样本 WCSS 边际下降提出候选 K 的启发式方法。

  1. 对一系列不同的 K 值(例如,从 1 到 10)运行 K-均值算法。
  2. 为每个 K 值计算最终的总簇内离差平方和 (Total WCSS)。
  3. 将 Total WCSS 随 K 值的变化绘制成图。
  4. WCSS 下降率变缓的“手肘”只是候选复杂度折中,须再查稳定性、轮廓及外部/业务有效性。

“肘部法则”的直觉

  • 单调性:WCSS 随 \(K\) 增大必然不升。
  • 本章现象:在已知三中心的模拟数据中,\(K\approx3\) 附近的 WCSS 边际下降变缓,只是与生成设定一致的可视启发。
  • 解释边界:折点不能识别真实结构、证明业务有效性或给出无条件“最佳 \(K\)”。
  • 真实任务:事先确定候选,并检查稳定性、轮廓以及外部或业务证据。

“肘部法则”的 Python 实现与可视化

在同一生成样本上比较 \(K=1,\ldots,10\)图 4\(K=3\) 附近由陡转缓,只提出候选值;还需结合稳定性、轮廓系数与业务解释。

代码
# 为““肘部法则”的 Python 实现与可视化”,从 `sklearn.cluster` 导入 `KMeans`,执行 K-means 客户分群。
from sklearn.cluster import KMeans

# 初始化 WCSS 路径,随后为每个候选簇数保存同一数据上的簇内平方和。
wcss = []
# 建立 `k_values` 的有序取值网格,用于展示“使用肘部法则提出候选 K”随参数变化的比较结果。
k_values = range(1, 11)
# 遍历 `k in k_values` 的候选超参数,逐个拟合并记录““肘部法则”的 Python 实现与可视化”的评价量。
for k in k_values:
    # 创建 `KMeans` 实例 `kmeans`,用于按样本到质心的距离执行 K-means 聚类。
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    # 用 `X` 更新 `kmeans` 的簇中心与样本分配,完成本次 K-means 拟合。
    kmeans.fit(X)
    wcss.append(kmeans.inertia_) # .inertia_ 属性就是 Total WCSS

# 将候选 K 与 WCSS 组成肘部曲线,检查边际下降的转折位置。

# 为““肘部法则”的 Python 实现与可视化”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(8, 6))
# 以 `k_values` 为横轴、`wcss` 为纵轴绘制曲线,展示“使用肘部法则提出候选 K”。
plt.plot(k_values, wcss, 'o-', color='crimson')
# 将图题设为“肘部法则示意图”,直接说明当前图形的比较目的。
plt.title('肘部法则示意图', fontsize=16)
# 将横轴标为“簇的数量 (K)”,明确横向编码的变量。
plt.xlabel('簇的数量 (K)')
# 将纵轴标为“总簇内离差平方和 (Total WCSS)”,明确纵向编码的变量。
plt.ylabel('总簇内离差平方和 (Total WCSS)')
# 为““肘部法则”的 Python 实现与可视化”,设置横轴刻度位置或标签,明确候选取值。
plt.xticks(k_values)
# 标注肘部点
plt.axvline(x=3, color='grey', linestyle='--', linewidth=2)
# 以箭头标注““手肘”点 (K=3)”,指出“使用肘部法则提出候选 K”中的候选拐点或阈值。
plt.annotate('“手肘”点 (K=3)', xy=(3, wcss[2]), xytext=(4, 2000),
             # 指定 `arrowprops` 为注释箭头样式,细化“使用肘部法则提出候选 K”的输出。
             arrowprops=dict(facecolor='black', shrink=0.05, width=1.5, headwidth=8),
             # 将肘点文字设为 14pt 并置于白底上,避免曲线穿过标注。
             fontsize=14, backgroundcolor='white')
# 为““肘部法则”的 Python 实现与可视化”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(True)
# 显示 WCSS 随 K 增加的下降曲线,寻找边际改善明显放缓的肘部候选。
plt.show()
肘部法则候选 K:横轴为候选 K、纵轴为 WCSS;折点不能单独证明业务分群。
图 4: 使用肘部法则提出候选 K

实战案例:对贷款客户进行风险分群

现在,我们把理论应用到一个合成的贷款风险教学案例。它只用于演示聚类流程,不能代表任何真实借款人群体。

  • 数据: 代码按预设机制生成客户特征与结果标签;不是公开贷款数据。
  • 目标:不使用“是否违约”这个标签的前提下,仅根据客户的经济特征,将他们划分为不同的风险群体。
  • 内部核对: 完成聚类后,再用未参与聚类的合成结果标签做教学性 sanity check;由于标签由同一生成机制产生,这不是外部有效性证据。

确定依赖与随机种子

我们的分析将主要依赖于 pandas 进行数据处理,sklearn 进行机器学习,以及 matplotlibseaborn 进行可视化。

Library Role
pandas 用于数据读取、操作和清洗 (DataFrames)
numpy 用于高效的数值计算
sklearn.cluster 包含 KMeans 等聚类算法
sklearn.preprocessing 包含按拟合样本逐特征估计均值与标准差的 StandardScaler 等预处理工具
matplotlib.pyplot 用于创建静态、可定制化的图表
seaborn 基于 matplotlib 的高级可视化库
代码
# 为“实战步骤 1:导入必要的 Python 库”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“实战步骤 1:导入必要的 Python 库”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“实战步骤 1:导入必要的 Python 库”,从 `sklearn.cluster` 导入 `KMeans`,执行 K-means 客户分群。
from sklearn.cluster import KMeans
# 为“实战步骤 1:导入必要的 Python 库”,导入 `StandardScaler`,按本次无监督机制样本的特征列统一量纲后再计算聚类距离。
from sklearn.preprocessing import StandardScaler
# 为“实战步骤 1:导入必要的 Python 库”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“实战步骤 1:导入必要的 Python 库”导入 `seaborn` 并绑定 `sns`,用于编码当前任务的统计分布或分组关系。
import seaborn as sns

读取后立即限定字段与样本

本节生成明确标记的模拟贷款数据,只解释 K-means 机制,不作为真实信贷或风险证据。

我们将聚焦于几个关键的经济特征:annual_inc (年收入), dti (债务收入比), emp_length (工龄), 和 home_ownership (住房情况)。

代码
# 为了教学演示,我们创建一个模拟的贷款数据集
# 真实世界的数据需要更复杂的清洗过程
np.random.seed(42)

# 为“实战步骤 2:数据读取与初步探索”,创建模拟数据
n_samples = 1000
annual_inc = np.random.lognormal(10.5, 0.5, n_samples)  # 年收入
dti = np.random.normal(15, 5, n_samples)  # 债务收入比
emp_length_num = np.random.uniform(0, 10, n_samples)  # 工龄
home_ownership_num = np.random.choice([0, 1, 2], n_samples)  # 住房情况

# 创建合成结果标签(基于预设特征逻辑)
# 该机制只服务教学,不能解释真实借款人的信用结果
default_prob = (1 / (1 + np.exp(-(-5 +
                                 # 年收入每增加 1 元,线性违约得分按 -0.00003 调整。
                                 -0.00003 * annual_inc +
                                 # 债务收入比每增加 1 单位,线性违约得分增加 0.1。
                                 0.1 * dti +
                                 # 工龄每增加 1 年,线性违约得分减少 0.2。
                                 -0.2 * emp_length_num))))
# 按给定概率抽取 `default` 的 0/1 标签,固定“实战步骤 2:数据读取与初步探索”的分类结果。
default = np.random.binomial(1, default_prob, n_samples)

# 创建DataFrame
df = pd.DataFrame({
    # 保留年收入作为客户偿债能力特征。
    'annual_inc': annual_inc,
    # 保留债务收入比作为负债压力特征。
    'dti': dti,
    # 保留数值化就业年限作为职业稳定性特征。
    'emp_length_num': emp_length_num,
    # 保留数值化住房状态作为资产条件特征。
    'home_ownership_num': home_ownership_num,
    # 附上模拟事件字段,只用于代码流检查而不参与 K-means 拟合或画像命名。
    'default': default
# 完成 `df` 的客户特征与违约标签字段,保证聚类输入和事后画像使用同一观测。
})

# 我们关心的特征列
feature_cols = ['annual_inc', 'dti', 'emp_length_num', 'home_ownership_num']
# 记录模拟事件所需字段,不把它作为监督目标或外部验证标签。
target_col = 'default'

# 创建一个新的DataFrame用于分析,并移除含有缺失值的行以简化流程
df_cluster = df[feature_cols + [target_col]].dropna().copy()

# 打印 包含 3 项的键值记录。
print({'样本数': len(df_cluster), '特征数': len(feature_cols), '缺失单元格': int(df_cluster.isna().sum().sum())})  # 用单行摘要替代会滚动的head与info输出
{'样本数': 1000, '特征数': 4, '缺失单元格': 0}

Scaler 只在训练窗拟合

这是 K-均值聚类前最关键的一步。annual_inc 的数值范围远大于其他特征,我们必须对其进行标准化。

我们将使用 StandardScaler,逐特征估计本次待聚类样本的均值与标准差,再把这批拟合样本转换到可比尺度;本段不声称对未来样本保持相同列统计量。

代码
# 从客户表提取余额、收入、年龄等事先确定聚类字段,排除标签与标识列。
X = df_cluster[feature_cols]

# 建立逐特征标准化器,准备仅由聚类拟合样本估计均值与尺度。
scaler = StandardScaler()
# 用全部待聚类拟合样本逐特征估计均值与标准差;本段是描述性分群,不声明未来预测性能。
X_scaled = scaler.fit_transform(X)

# 将标准化数组恢复为带字段名的表格,便于检查每列量纲与后续画像解释。
X_scaled_df = pd.DataFrame(X_scaled, columns=feature_cols)

# 打印 包含 2 项的键值记录。
print({'标准化前均值': X.mean().round(2).to_dict(), '标准化后均值绝对值上限': float(X_scaled_df.mean().abs().max())})  # 只投影尺度检查所需摘要
{'标准化前均值': {'annual_inc': 41434.56, 'dti': 15.35, 'emp_length_num': 4.97, 'home_ownership_num': 1.03}, '标准化后均值绝对值上限': 3.410605131648481e-16}

K 是验证候选而非肘部真理

图 5 显示 \(K=2\)\(K=3\) 均可作为候选。本例事先确定并执行 \(K=2\)\(K=3\) 仅列为未执行的课后敏感性候选,不属于本次结果,不能据此声称稳健。

代码
# 初始化簇内平方和累积列表,按候选 K 保存每个模型的惯性指标。
wcss = []
# 建立 `k_range` 的有序取值网格,用于展示“贷款客户数据集的肘部法则分析”随参数变化的比较结果。
k_range = range(1, 11)
# 遍历 `k in k_range` 的候选超参数,逐个拟合并记录“实战步骤 4:使用肘部法则提出候选 K”的评价量。
for k in k_range:
    # 创建 `KMeans` 实例 `kmeans_elbow`,用于按样本到质心的距离执行 K-means 聚类。
    kmeans_elbow = KMeans(n_clusters=k, n_init=10, random_state=42)
    # 用 `X_scaled_df` 更新 `kmeans_elbow` 的簇中心与样本分配,完成本次 K-means 拟合。
    kmeans_elbow.fit(X_scaled_df)
    # 把 K-means 簇内离差 `kmeans_elbow.inertia_` 加入 `wcss`,形成 K 值的肘部曲线。
    wcss.append(kmeans_elbow.inertia_)


# 为“实战步骤 4:使用肘部法则提出候选 K”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(10, 6))
# 以 `k_range` 为横轴、`wcss` 为纵轴绘制曲线,展示“贷款客户数据集的肘部法则分析”。
plt.plot(k_range, wcss, marker='o', linestyle='--')
# 将图题设为“贷款客户聚类的肘部法则”,直接说明当前图形的比较目的。
plt.title('贷款客户聚类的肘部法则', fontsize=16)
# 将横轴标为“簇的数量 K”,明确横向编码的变量。
plt.xlabel('簇的数量 K')
# 将纵轴标为“总簇内离差平方和 (WCSS)”,明确纵向编码的变量。
plt.ylabel('总簇内离差平方和 (WCSS)')
# 为“实战步骤 4:使用肘部法则提出候选 K”,设置横轴刻度位置或标签,明确候选取值。
plt.xticks(k_range)
# 在 `x=2` 处添加垂直参考线,标出“贷款客户数据集的肘部法则分析”的基准或阈值。
plt.axvline(x=2, color='red', linestyle='--', label='K=2(事先确定候选)')
# 显示“实战步骤 4:使用肘部法则提出候选 K”图例,使颜色或线型与比较对象一一对应。
plt.legend()
# 显示合成客户特征上的 WCSS 路径,检查选定 K 是否位于边际下降转折处。
plt.show()
贷款客户数据集的肘部法则分析:横轴为候选 K、纵轴为簇内平方和 WCSS;曲线由陡转缓的折点用于提出 K 候选,不能单独证明业务分群。
图 5: 贷款客户数据集的肘部法则分析

确定 K 后再拟合 K-Means

我们选定了 K=2,现在可以正式运行 K-均值算法,并为每个客户分配一个簇标签(0 或 1)。

我们将把这个新的 cluster 标签添加回我们原始的 df_cluster DataFrame 中,以便进行后续分析。

代码
# 用选定簇数在标准化客户特征上拟合 K-means 并生成簇标签。
# 固定两簇方案与随机种子,生成可复现的客户分群基线。
kmeans = KMeans(n_clusters=2, n_init=10, random_state=42)
# 拟合聚类器并得到 `cluster_labels`,让每条公司观测对应一个簇标签。
cluster_labels = kmeans.fit_predict(X_scaled_df)

# 将每条客户观测的簇标签回填到原表,供分簇画像与违约率聚合。
df_cluster['cluster'] = cluster_labels

# 报告“簇样本数”中的 `df_cluster`,核对“实战步骤 5:执行 K-均值聚类 (K=2)”的样本形状。
print({'簇样本数': df_cluster['cluster'].value_counts().sort_index().to_dict()})  # 用单行字典核对分群规模,避免stdout滚动
{'簇样本数': {0: 492, 1: 508}}

簇标签须由特征画像命名

聚类本身只是第一步,最重要的工作是理解每个簇代表了什么样的客户群体。

我们通过计算每个簇中各个特征的平均值来为它们“画像”。

代码
# 按簇汇总原量纲特征均值,形成可解释的客户画像表。
cluster_profiles = df_cluster.groupby('cluster')[feature_cols].mean()
# 同时汇总样本量、模拟事件与原量纲画像,令讲解直接接受执行结果检查。
cluster_audit = df_cluster.groupby('cluster').agg(sample_count=('default', 'size'), default_count=('default', 'sum'), simulated_default_rate=('default', 'mean'), annual_inc=('annual_inc', 'mean'), dti=('dti', 'mean'), emp_length_num=('emp_length_num', 'mean'), home_ownership_num=('home_ownership_num', 'mean')).reset_index()
# 核对两个簇覆盖全部模拟观测,避免画像丢行。
assert cluster_audit['sample_count'].sum() == len(df_cluster)
# 确定当前随机种子的稀少模拟事件总数,阻止稳定风险叙事。
assert cluster_audit['default_count'].sum() == int(df_cluster['default'].sum()) == 5
# 建立短中文表头的展示副本,保留底层英文变量供后续检查代码复用。
cluster_audit_display = cluster_audit.rename(columns={'cluster': '簇', 'sample_count': '样本数', 'default_count': '模拟事件数', 'simulated_default_rate': '模拟事件率', 'annual_inc': '年收入', 'dti': 'DTI', 'emp_length_num': '工龄', 'home_ownership_num': '住房编码'})
# 输出两行八列紧凑画像与事件检查表,确保最右侧住房编码完整可见。
display(cluster_audit_display.round({'模拟事件率': 4, '年收入': 2, 'DTI': 2, '工龄': 2, '住房编码': 2}))
表 1: 两个客户群体的特征画像对比
样本数 模拟事件数 模拟事件率 年收入 DTI 工龄 住房编码
0 0 492 2 0.0041 41679.28 14.85 5.89 0.35
1 1 508 3 0.0059 41197.55 15.85 4.08 1.69

固定种子下的描述性解读:

  • 两簇年收入接近(41,679 对 41,198);可见均值差异主要是工龄(5.89 对 4.08)、债务收入比(14.85 对 15.85)与住房编码(0.35 对 1.69)。
  • 簇编号只是算法标签;画像是本次模拟样本的描述,不是“高/低风险”类别。

所有精确数值以本页代码生成表为准,不从簇号或五个模拟事件推断总体风险。

可视化解读:簇画像对比

  • 变换来源:沿用 K-means 拟合前的 StandardScaler
  • 柱高含义:簇均值相对全样本同一特征均值的 Z-score。
  • 比较边界:只在同一特征内比较两簇,不跨异质量纲特征解释柱高。
代码
# 使用拟合 K-means 输入时的逐特征均值与标准差转换两个簇的原量纲均值。
profile_z = pd.DataFrame(scaler.transform(cluster_profiles), index=cluster_profiles.index, columns=feature_cols).T
# 绘制逐特征跨簇可比的 Z-score 并列柱图,比较描述性客户画像。
profile_z.plot(kind='bar', figsize=(10, 6))
# 将图题设为“标准化的簇特征均值对比”,直接说明当前图形的比较目的。
plt.title('簇均值相对全样本的逐特征 Z-score', fontsize=16)
# 将纵轴标为“标准化均值 (Z-score)”,明确纵向编码的变量。
plt.ylabel('相对全样本均值的 Z-score')
# 将横轴标为“特征”,明确横向编码的变量。
plt.xlabel('特征')
# 为“可视化解读:簇画像对比”,设置横轴刻度位置或标签,明确候选取值。
plt.xticks(rotation=45)
# 显示“可视化解读:簇画像对比”图例,使颜色或线型与比较对象一一对应。
plt.legend(title='Cluster')
# 为“可视化解读:簇画像对比”添加辅助网格,便于比较位置、斜率或组间差异。
plt.grid(axis='y', linestyle='--')
# 显示逐特征标准化画像,并以原始单位表作为业务解释依据。
plt.show()
横轴列出收入、负债收入比、工龄和住房状态,两个系列表示两个模拟簇;柱高是簇均值相对全样本同一特征均值的 Z-score。
图 6: 两个客户群体的特征均值对比

合成违约率只作内部核对

  • 用途:用合成违约率检查分群是否与本次生成机制一致。
  • 独立性default 未参与聚类,但它不是独立收集的真实标签。
  • 不能推出:样本外风险识别能力。
代码
# 从画像检查表提取样本数、模拟事件数与比例,保持两页输出同源。
default_audit = cluster_audit[['cluster', 'sample_count', 'default_count', 'simulated_default_rate']].rename(columns={'cluster': 'Cluster', 'sample_count': '模拟样本数', 'default_count': '模拟违约数', 'simulated_default_rate': '模拟违约率'})
# 再次核对总事件数为五,防止图表把稀少事件误写成稳定风险排序。
assert int(default_audit['模拟违约数'].sum()) == 5
# 输出两个簇的分母、事件数与描述性比例。
display(default_audit.round({'模拟违约率': 4}))
表 2: 两个合成客户群体的违约率(内部核对)
Cluster 模拟样本数 模拟违约数 模拟违约率
0 0 492 2 0.0041
1 1 508 3 0.0059

本次随机种子下的观察:

  • 簇 0 与簇 1 分别只有 2 与 3 个模拟违约,相差仅 1 个事件,总计只有 5 个事件。
  • 这些比例无法支持稳定排序、显著性或总体风险推断,只用于核对本次生成机制。

可视化验证结果

代码
# 为“可视化验证结果”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(8, 5))
# 以簇编号分组并用模拟事件数作为柱高,避免把极少事件比例视觉放大。
sns.barplot(data=default_audit, x='Cluster', y='模拟违约数', palette='viridis')
# 用图题显式报告五个模拟事件的证据边界。
plt.title('5 个模拟违约事件:仅作描述性核对', fontsize=16)
# 将纵轴标为模拟事件数,避免误读为稳定总体风险率。
plt.ylabel('模拟违约事件数')
# 固定包含零点的五事件纵轴,保持绝对数量容易检查。
plt.ylim(0, 5)
# 遍历两个簇,在柱顶同时标注事件数、样本数与描述性比例。
for _, audit_row in default_audit.iterrows():
    # 标注当前簇的完整分子、分母与比例,不隐藏小样本边界。
    plt.text(audit_row['Cluster'], audit_row['模拟违约数'] + 0.15, f"{int(audit_row['模拟违约数'])}/{int(audit_row['模拟样本数'])} ({audit_row['模拟违约率']:.1%})", color='black', ha='center', fontsize=12)
# 显示五个模拟事件的描述性计数,并与前页检查表交叉核对。
plt.show()
柱状图显示簇0有2个、簇1有3个模拟违约,总计仅5个事件,只作描述性内部核对。
图 7: 五个模拟违约事件的描述性核对

当前两簇仅相差一个模拟事件,不能据此声称风险关联或排序。真实用途需要足量外部标签、不确定性分析、时间外验证与稳定性检验。

可视化我们的发现

我们可以通过散点图来更直观地展示两个簇在关键维度上的差异。

图 8 展示两簇在收入—DTI 平面的明显重叠;均值差异以代码生成画像表为准,不能仅凭散点或簇号命名风险。

代码
# 为“可视化我们的发现”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(9, 3.8))
# 绘制年收入—债务收入比散点,并以簇标签着色。
sns.scatterplot(
    data=df_cluster, # 使用所有数据
    # 指定 `x` 为横轴变量,细化“客户分群在年收入和债务收入比维度上的分布”的输出。
    x='annual_inc',
    # 指定 `y` 为纵轴变量,细化“客户分群在年收入和债务收入比维度上的分布”的输出。
    y='dti',
    # 指定 `hue` 为分组着色字段,细化“客户分群在年收入和债务收入比维度上的分布”的输出。
    hue='cluster',
    style='cluster',
    # 指定 `palette` 为分组色板,细化“客户分群在年收入和债务收入比维度上的分布”的输出。
    palette='viridis',
    markers={0: 'o', 1: '^'},
    # 将散点透明度设为 0.6,减轻重叠观测的遮挡。
    alpha=0.6
# 完成 `scatterplot` 的轴、分组和色板参数,按簇显示收入—负债率分布。
)
# 将图题设为“客户分群的可视化结果”,直接说明当前图形的比较目的。
plt.title('客户分群的可视化结果', fontsize=16)
# 将横轴标为“年收入 (Annual Income)”,明确横向编码的变量。
plt.xlabel('年收入 (Annual Income)')
# 将纵轴标为“债务收入比 (Debt-to-Income Ratio)”,明确纵向编码的变量。
plt.ylabel('债务收入比 (Debt-to-Income Ratio)')
plt.xlim(0, 250000) # 限制x轴范围以便更好地观察
# 显示“可视化我们的发现”图例,使颜色或线型与比较对象一一对应。
plt.legend(title='客户分群')
# 显示按簇着色的收入—债务收入比散点,不绘制质心;检查簇间重叠并限定可视化不等同于外部验证。
plt.show()
横轴为年收入、纵轴为债务收入比,颜色表示聚类标签;点云展示两个客户群在这两个财务维度上的重叠与分离。
图 8: 客户分群在年收入和债务收入比维度上的分布

结论:聚类分析是强大的探索性工具

  • 从未知中创造价值: 即使没有明确的标签,聚类分析也能帮助我们从数据中发现有意义的、可操作的群体。
  • 商业应用广泛:
    • 市场营销: 客户细分,识别不同偏好的用户群体以进行精准营销。
    • 金融: 形成候选客群,再用独立标签验证风险差异或异常交易识别效果。
    • 投资: 将股票或资产根据其风险收益特征进行分组。
  • 重要提示: K-均值只是众多聚类算法中的一种,它最适合发现球形的簇。

超越 K-均值:一瞥其他聚类方法

对于更复杂的非球形或密度不均的数据,我们可能需要探索如 DBSCAN 或层次聚类等其他方法。

其他聚类算法 两个面板,左边展示了层次聚类的树状图和结果,右边展示了DBSCAN识别非球形簇和噪声点的能力。 层次聚类 (Hierarchical) 构建“家族树” (树状图) DBSCAN (基于密度) 噪声 发现任意形状的簇并识别噪声

检查:尺度、K 与局部最优

独立回答三问:

  1. 为何必须先固定缩放口径?
  2. K-均值与 KNN 的 K 分别代表什么?
  3. 单个 seed 的最低 WCSS 能否证明该 K 是真实业务分群?

反馈:两个 K 对应不同任务

  • 缩放:距离受量纲支配,所以缩放口径必须先确定。
  • 两个 K:K-均值的 K 是簇数,KNN 的 K 是邻居数。
  • WCSS 边界:单次 WCSS 只衡量当前规格的簇内平方和,不能证明真实结构、业务效用或稳定性。
  • 反馈路径:漏任一项者回看 相关内容,三项均分清者进入候选 K 检查。

独立任务:解释聚类选择边界

  • 固定候选:事先确定 candidate_k=[2,3,4]seeds=[0,1,2,3,4],保持特征、缩放和 n_init=10 不变。
  • 逐 K 提交:平均 WCSS、平均轮廓系数,以及相对 seed 0 的最小 ARI。
  • 入选后提交:仅对按事先确定规则入选的 selected_k 提交原量纲画像与模拟事件检查。
  • 采用规则:先保留 min ARI >= 0.90 的 K,再选平均轮廓系数最高者;轮廓差在 0.01 内时取更小 K。
  • 失败与边界:WCSS 只作边际下降描述;无稳定候选则“不采用”。即使通过,缺外部或时间外业务标签时也只能作探索性画像。

课堂练习与思考 (3/3)

3. 场景思考

除了课程中提到的描述性客户细分,请再构思两个你认为可以使用 K-均值分析方法解决的经济或商业问题

请简要说明:

  • 对象:业务问题、观测单位与时间粒度。
  • 特征:入群字段、信息时点与未纳入聚类的结果字段。
  • 距离/缩放:距离度量、变换或权重及其业务理由。
  • 原量纲画像:回到原始单位报告每簇中心、离散与中性名称。
  • 检查:指定未参与聚类的外部结果或时间外窗口。
  • 结果不理想时如何解释:事先确定不稳定、无增量或不采用标准。

练习:先完成再查看答案:聚类检查先最终测试

  • 候选比较:提交三个 K 的 WCSS、轮廓系数、跨 seed ARI 与事先确定的采用规则。
  • 入选结果:提交入选 K 的原量纲画像和合成事件检查。
  • 两个自拟场景:分别提交对象、特征、距离/缩放、原量纲画像、外部或时间外检查、失败/不采用条件;六项各 1 分。
  • 失败处理:若无候选通过稳定门槛,画像与事件统一写 N/A,并提交“不采用”说明。
  • 解释边界:不得用模拟事件率反向命名真实风险;缺任一项请重做,提交后进入答案。

练习参考答案与自查标准

  1. 候选 K:同一缩放输入上比较 K=2、3、4;轮廓系数衡量簇内凝聚与簇间分离,ARI 衡量初始化稳定性,二者都不等于业务有效性。
  2. 采用顺序:先过 min ARI >= 0.90,再按平均轮廓系数选择,0.01 内取更小 K;若无候选过门槛则不采用。
  3. 完成内容口径:每个 K 只交 WCSS、轮廓系数与最小 ARI;仅 selected_k 交原量纲画像与模拟事件检查。无稳定 K 时后两项为 N/A,并写“不采用”。
  4. 场景答案框架:必须写明对象、特征、距离、原量纲画像、外部或时间外检查与结果不理想时如何解释;只有“找客户群”不得分。

场景示范一:长三角制造企业经营画像

  • 问题与对象:对沪苏浙皖非金融上市公司年度公司—年观测做探索性经营画像,不预先命名“优质/风险”簇。
  • 特征与距离:只用公告时点已可得的 ROA、资产负债率、营收增长率、经营现金流/资产与规模;在每个开发期内 winsorize 后逐特征 z-score,采用平方欧氏距离。
  • 画像用途:回到原量纲报告中位数、四分位距与行业构成,再由分析师给出中性描述名;簇号本身没有经济含义。
  • 验证:跨随机种子报告 ARI,并在下一年度确定 scaler/质心后检查成员稳定性;另用未参与聚类的下一年现金流波动作外部描述性检查。
  • 不采用条件:无候选 K 通过稳定门槛、时间外成员大幅漂移、单一行业主导,或画像对缩放/极值处理敏感时,结论为“不采用”。聚类不识别经营变化的因果来源。

场景示范二:银行网点现金需求分群

  • 问题与对象:以已授权的中国银行网点—月为对象,形成补钞与运钞排班候选组;不把簇直接命名为欺诈或高风险。
  • 特征与距离:使用历史日均取款额、周末占比、波动率、节假日峰值比和地理配送时长;对偏态金额取 log1p,再用训练期 robust scaling 与平方欧氏距离。
  • 画像用途:在原量纲中报告现金需求、波动与配送时长,供运营团队设计候选服务等级,而不是自动决定资源分配。
  • 验证:用未来三个月确定规则检验簇内需求预测误差与成员稳定性,并以实际缺钞次数作为未参与聚类的外部结果;同时按城市层级检查公平与覆盖。
  • 不采用条件:未来误差不优于单一排班基线、成员漂移超过事先确定阈值、授权字段缺失或城市覆盖失衡时停止采用。外部结果差异仍是关联证据,不是因果标签。

候选 K 计算设置

表 3
代码
from sklearn.metrics import adjusted_rand_score, silhouette_score  # 同时检查初始化稳定性与几何分离度
candidate_k_values = [2, 3, 4]  # 事先确定三个有效簇数候选
seeds = [0, 1, 2, 3, 4]  # 事先确定五个可复算随机种子
candidate_labels, candidate_rows = {}, []  # 收集每个 K 与 seed 的标签及指标
for candidate_k in candidate_k_values:  # 逐个比较事先确定簇数
    for seed in seeds:  # 在每个簇数下重复五次初始化检查
        candidate_model = KMeans(n_clusters=candidate_k, n_init=10, random_state=seed)  # 仅改变 K 与随机种子
        candidate_assignment = candidate_model.fit_predict(X_scaled_df)  # 在同一标准化输入上拟合
        candidate_labels[(candidate_k, seed)] = candidate_assignment  # 保存成员标签供稳定性比较
        candidate_rows.append({'K': candidate_k, 'seed': seed, 'WCSS': candidate_model.inertia_, 'silhouette': silhouette_score(X_scaled_df, candidate_assignment), 'ARI_vs_seed0': adjusted_rand_score(candidate_labels[(candidate_k, 0)], candidate_assignment)})  # 保存完整候选比较结果
candidate_run_audit = pd.DataFrame(candidate_rows)  # 形成十五行候选学习安排
k_selection_audit = candidate_run_audit.groupby('K').agg(mean_WCSS=('WCSS', 'mean'), mean_silhouette=('silhouette', 'mean'), min_ARI=('ARI_vs_seed0', 'min')).reset_index()  # 汇总选 K 所需三项证据
stable_candidates = k_selection_audit.query('min_ARI >= 0.90').copy()  # 先执行事先确定稳定门槛
best_stable_silhouette = stable_candidates['mean_silhouette'].max() if not stable_candidates.empty else np.nan  # 确定稳定候选中的最佳轮廓值
near_best_candidates = stable_candidates.query('mean_silhouette >= @best_stable_silhouette - 0.01') if not stable_candidates.empty else stable_candidates  # 实现0.01内视为并列
selected_k = int(near_best_candidates['K'].min()) if not near_best_candidates.empty else None  # 并列取较小K;无稳定候选则不采用
selected_assignment = candidate_labels[(selected_k, 0)] if selected_k is not None else np.repeat(-1, len(df_cluster))  # 仅为完成要求的 K 生成画像
selected_frame = df_cluster[feature_cols + ['default']].copy().assign(cluster=selected_assignment)  # 绑定入选 K 的 seed0 标签
profile_audit = selected_frame.query('cluster >= 0').groupby('cluster')[feature_cols].mean().reset_index()  # 生成入选 K 的原量纲画像
event_audit = selected_frame.query('cluster >= 0').groupby('cluster').agg(sample_count=('default', 'size'), simulated_event_count=('default', 'sum'), simulated_event_rate=('default', 'mean')).reset_index()  # 检查入选 K 的模拟事件
assert selected_k is None or event_audit.simulated_event_count.sum() == 5  # 入选分群只能包含同五个模拟事件
# 无稳定 K 时生成显式 N/A 完成内容,避免空表被误读为漏交答案。
profile_delivery = profile_audit if selected_k is not None else pd.DataFrame({'status': ['N/A:无稳定 K,不采用']})
# 让事件检查与画像遵循同一 selected_k 口径和失败路径。
event_delivery = event_audit if selected_k is not None else pd.DataFrame({'status': ['N/A:无稳定 K,不采用']})

候选 K 答案(1/3):几何与稳定性

代码
display(k_selection_audit.round(4))  # 展示三个候选的几何与初始化稳定性
print({'采用规则': 'min_ARI>=0.90 后最大 mean_silhouette;差0.01内取较小K', 'selected_K': selected_k, 'status': '探索性画像' if selected_k is not None else '不采用'})  # 动态报告事先确定决策
{'采用规则': 'min_ARI>=0.90 后最大 mean_silhouette;差0.01内取较小K', 'selected_K': 4, 'status': '探索性画像'}
表 4: 候选 K 的平均 WCSS、轮廓系数与最小 ARI
K mean_WCSS mean_silhouette min_ARI
0 2 3221.5034 0.2051 0.9920
1 3 2704.8796 0.2103 0.9493
2 4 2283.8049 0.2237 0.9535

候选 K 答案(2/3):入选 K 原量纲画像

代码
display(profile_delivery.round(2))  # 仅展示入选 K 画像;无稳定 K 时明确完成内容 N/A
表 5: 通过稳定门槛的候选 K 原量纲簇画像
cluster annual_inc dti emp_length_num home_ownership_num
0 0 36651.35 14.71 4.96 0.03
1 1 34437.15 15.70 2.29 1.54
2 2 36030.53 15.75 7.57 1.52
3 3 89659.23 15.08 5.00 1.09

候选 K 答案(3/3):模拟事件检查

代码
display(event_delivery.round(4))  # 仅展示入选 K 事件检查;无稳定 K 时明确完成内容 N/A
表 6: 入选 K 的模拟事件内部检查
cluster sample_count simulated_event_count simulated_event_rate
0 0 300 2 0.0067
1 1 293 3 0.0102
2 2 301 0 0.0000
3 3 106 0 0.0000

候选 K 结果只支持探索性结构

  • 标签不变性:ARI 对簇编号置换不变。
  • 证据范围:轮廓与稳定性通过只支持当前特征空间中的探索性结构。
  • 画像规则:按预先声明的特征规则匹配。
  • 合成事件边界:五个事件只核对代码流,不支撑真实违约预测、因果效应或信贷决策公平性。

Q & A

谢谢大家!

公开中国数据练习:公司分群稳定性

表 7: 中国上市公司基础特征的 K 均值分群稳定性
代码
from pathlib import Path  # 管理公开公司基础信息路径
from urllib.request import urlretrieve  # 复用本章隐藏设置单元安装的浏览器标识下载器
import pandas as pd  # 清洗发行价与上市年份
from sklearn.cluster import KMeans  # 执行预先指定 K 的分群
from sklearn.impute import SimpleImputer  # 使用中位数完成最小缺失处理
from sklearn.metrics import adjusted_rand_score, silhouette_score  # 比较不同初始化稳定性与几何分离度
from sklearn.preprocessing import StandardScaler  # 由当前聚类拟合样本逐特征估计均值与标准差
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择公司基础信息文件。
basic_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/stock_basic_data.h5'), Path('C:/qiufei/data/stock/stock_basic_data.h5'), Path('data/course/stock_basic_data.h5')] if candidate_path.exists()), Path('data/course/stock_basic_data.h5'))
if not basic_path.exists():
    basic_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/stock_basic_data.h5', basic_path)
cluster_data = pd.read_hdf(basic_path, key='stock_basic_info', columns=['listed_date', 'issue_price'])  # 选择性读取分群字段
cluster_data['listing_year'] = pd.to_datetime(cluster_data['listed_date'], errors='coerce').dt.year  # 构造公司生命周期特征
cluster_sample = cluster_data.query('issue_price > 0 and 1991 <= listing_year <= 2024').copy()  # 固定有效横截面样本
cluster_features = ['issue_price', 'listing_year']  # 确定两个可解释分群维度
local_imputed = SimpleImputer(strategy='median').fit_transform(cluster_sample[cluster_features])  # 在当前无监督样本中位数插补两个画像字段
local_scaled = StandardScaler().fit_transform(local_imputed)  # 在同一聚类样本逐特征缩放以统一距离口径
local_run_rows = []  # 收集公开每个K与seed的几何证据
for local_k in [2, 3, 4]:  # 复用核心任务的候选 K 集合
    local_reference = KMeans(n_clusters=local_k, n_init=20, random_state=0).fit_predict(local_scaled)  # 确定 seed0 参考划分
    for local_seed in [0, 1, 2, 3, 4]:  # 复用核心任务的五种初始化
        local_model = KMeans(n_clusters=local_k, n_init=20, random_state=local_seed).fit(local_scaled)  # 仅改变初始化种子
        local_run_rows.append({'K': local_k, 'seed': local_seed, 'WCSS': local_model.inertia_, 'silhouette': silhouette_score(local_scaled, local_model.labels_), 'ARI_vs_seed0': adjusted_rand_score(local_reference, local_model.labels_)})  # 保存同要求逐次证据
local_k_audit = pd.DataFrame(local_run_rows).groupby('K').agg(mean_WCSS=('WCSS', 'mean'), mean_silhouette=('silhouette', 'mean'), min_ARI=('ARI_vs_seed0', 'min')).reset_index()  # 汇总三个候选K
local_stable = local_k_audit.query('min_ARI >= 0.90').copy()  # 应用事先确定稳定门槛
local_best_silhouette = local_stable['mean_silhouette'].max() if not local_stable.empty else np.nan  # 确定最佳轮廓值
local_near_best = local_stable.query('mean_silhouette >= @local_best_silhouette - 0.01') if not local_stable.empty else local_stable  # 落在0.01范围内者并列
local_selected_k = int(local_near_best['K'].min()) if not local_near_best.empty else None  # 并列取小K;无稳定候选则不采用
代码
display(local_k_audit.round(4))  # 展示公开 WCSS、轮廓与跨 seed 稳定性
print({'文件': basic_path.name, '样本数': len(cluster_sample), 'selected_K': local_selected_k, '边界': '探索性结构;仍需外部或时间外业务检查'})  # 输出同要求的采用状态与边界
{'文件': 'stock_basic_data.h5', '样本数': 5402, 'selected_K': 3, '边界': '探索性结构;仍需外部或时间外业务检查'}
表 8
K mean_WCSS mean_silhouette min_ARI
0 2 6130.5365 0.5188 1.0000
1 3 3723.0729 0.5613 1.0000
2 4 2769.9113 0.5311 0.9758

本章小结

  • 能解释分配—更新—停止,并用标准化、多个初始化与 ARI 检查稳定性。
  • \(K\) 是验证候选,不由肘部图自动给出“真值”;簇名必须来自特征画像。
  • 稳定聚类仍是探索性结构,不等于风险类别、因果类型或信贷决策依据。
  • 下一章进入缺失数据,因为聚类前的样本保留与插补也会改变几何结构。