
- 个人首页: 永远都不秃头的程序员(互关)
- C语言专栏:从零开始学习C语言
- C++专栏:C++的学习之路
- 本文章所属专栏:K-Means深度探索系列
文章目录
-
-
- 引言:K-Means的"阿喀琉斯之踵"与初始化的重要性
- [K-Means++ 原理:让质心"雨露均沾"](#K-Means++ 原理:让质心“雨露均沾”)
- [手把手代码实现:DIY你的 K-Means++ 初始化器](#手把手代码实现:DIY你的 K-Means++ 初始化器)
- [深度实践:对比 K-Means++ 与随机初始化](#深度实践:对比 K-Means++ 与随机初始化)
- [小结与展望:迈向更健壮的 K-Means 算法](#小结与展望:迈向更健壮的 K-Means 算法)
-
引言:K-Means的"阿喀琉斯之踵"与初始化的重要性
亲爱的读者朋友们,欢迎回到我们的"K-Means深度探索"系列!在第一篇文章中,我们亲手实现了 K-Means 的核心逻辑;在第二篇文章中,我们学习了如何通过肘部法则和轮廓系数选择最佳的 K 值。相信你对 K-Means 已经有了非常扎实的理解和实践经验。
然而,回想一下我们手撕 K-Means 的第一步:initialize_centroids 函数,我们是如何选择初始质心的?没错,我们是随机地从数据集中挑选 K 个点。这种随机性带来了便利,但也埋下了一个"隐患"------这正是 K-Means 算法的**"阿喀琉斯之踵"**,即它的一个致命弱点:
- 收敛速度慢: 如果初始质心选择得非常不巧,彼此靠得很近,或者远离数据点的中心区域,算法可能需要更多迭代才能收敛。
- 陷入局部最优: K-Means 算法是贪婪的,它会找到一个局部最优解。如果初始质心选择得不好,算法可能会收敛到一个次优的聚类结果,而不是全局最优解。这意味着每次运行 K-Means,即使是相同的 K 值和数据集,结果也可能不同,聚类效果不稳定。
你是否也曾为 K-Means 结果的不稳定而困扰?今天,我们就来揭示一个巧妙的优化策略,它能极大地改善 K-Means 的初始化问题,让你的聚类结果更稳定、收敛更快------它就是大名鼎鼎的 K-Means++!
K-Means++ 原理:让质心"雨露均沾"
K-Means++ 的核心思想非常优雅:它不希望初始质心都挤在一起,而是希望它们能尽可能地分散开,覆盖到不同的数据区域。 这样一来,初始质心就能更好地代表整个数据集,从而为 K-Means 算法提供一个更好的起点,帮助它更快地收敛到高质量的聚类结果。
想象一下,你需要在地图上找到 K 个最佳的起始位置来建立 K 家分店,你会怎么做?你肯定不会把它们都建在同一个街区,而是会尽量分散开,覆盖到不同的潜在客户区域。K-Means++ 就是这个思路!
K-Means++ 算法的初始化步骤:
- 随机选择第一个质心: 从数据集中随机均匀地选择第一个数据点 作为第一个簇的质心 c 1 c_1 c1。这与传统的 K-Means 随机初始化是相同的,作为起始点。
- 选择后续质心(核心步骤): 对于剩余的每一个数据点,计算它到所有已选择的质心 的最短距离 D ( x ) D(x) D(x)。这个距离 D ( x ) D(x) D(x) 表示了数据点 x x x 距离任何一个当前已选质心有多远。
- 概率加权选择: 从所有数据点中,以与 D ( x ) 2 D(x)^2 D(x)2 成正比的概率选择下一个质心。 也就是说,距离当前已选质心越远的数据点,被选为下一个质心的概率就越大。这确保了新的质心倾向于选择那些尚未被现有质心"覆盖"到的区域。
- 重复: 重复步骤 2 和 3,直到选择了 K 个质心。
为什么 D ( x ) 2 D(x)^2 D(x)2 很关键? 使用距离的平方作为权重,可以进一步放大距离较远点被选中的概率,使得质心之间更倾向于分散。
通过这种"聪明"的初始化方式,K-Means++ 确保了初始质心能够尽可能地覆盖数据空间,从而显著减少了算法陷入局部最优解的风险,并加快了收敛速度。
手把手代码实现:DIY你的 K-Means++ 初始化器
虽然 sklearn 中的 KMeans 默认就使用了 k-means++ 初始化(通过 init='k-means++' 参数),但为了加深理解,我们还是来手写一个 K-Means++ 的初始化函数。这能让你更清晰地看到它的运作逻辑!
我们沿用上一篇文章中的数据准备部分:
python
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs
from sklearn.cluster import KMeans # 用于对比或后续的完整KMeans运行
# 1. 数据准备 (沿用上一篇的数据)
X, y_true = make_blobs(n_samples=300, centers=4, cluster_std=0.60, random_state=0)
# 辅助函数:计算欧氏距离 (从第一篇复制过来)
def euclidean_distance(point1, point2):
return np.sqrt(np.sum((point1 - point2)**2))
# 可视化原始数据点分布,方便对比
plt.figure(figsize=(8, 6))
plt.scatter(X[:, 0], X[:, 1], s=50, alpha=0.8)
plt.title("原始数据点分布")
plt.xlabel("特征1")
plt.ylabel("特征2")
plt.grid(True, linestyle='--', alpha=0.6)
plt.show()
现在,我们来实现 K-Means++ 的初始化函数:
python
# 2. K-Means++ 初始化函数实现
def initialize_centroids_kmeans_plus_plus(data, k):
"""
功能:使用 K-Means++ 算法初始化 K 个质心。
用途:提供更优的初始质心,以提高 K-Means 算法的稳定性和收敛速度。
"""
n_samples, n_features = data.shape
centroids = np.zeros((k, n_features)) # 存储 K 个质心
# 步骤1: 随机选择第一个质心
first_centroid_idx = np.random.choice(n_samples)
centroids[0] = data[first_centroid_idx]
# 存储每个数据点到最近质心的距离平方 (D(x)^2)
# 初始时,所有点到第一个质心的距离平方
distances_sq = np.array([euclidean_distance(point, centroids[0])**2 for point in data])
# 步骤2-4: 迭代选择剩余的 K-1 个质心
for i in range(1, k):
# 计算每个数据点被选为下一个质心的概率
# 概率与 D(x)^2 成正比
probabilities = distances_sq / np.sum(distances_sq)
# 使用 np.random.choice 进行概率加权选择
next_centroid_idx = np.random.choice(n_samples, p=probabilities)
centroids[i] = data[next_centroid_idx]
# 更新 distances_sq:
# 对于所有数据点,更新其到新质心和旧质心集合的最近距离平方
# 只有当新质心更近时,才更新距离
for j in range(n_samples):
# 计算数据点j到新质心的距离平方
dist_to_new_centroid_sq = euclidean_distance(data[j], centroids[i])**2
# 更新 data[j] 的 distances_sq:取它到当前所有已选质心的最短距离
distances_sq[j] = min(distances_sq[j], dist_to_new_centroid_sq)
return centroids
深度实践:对比 K-Means++ 与随机初始化
为了直观地看到 K-Means++ 的优势,我们来比较它与简单随机初始化在质心分布上的差异。
python
# 3. 对比 K-Means++ 与随机初始化的质心分布
k_clusters = 4 # 假设我们知道有4个簇
# 随机初始化质心
def initialize_centroids_random(data, k):
indices = np.random.choice(data.shape[0], k, replace=False)
return data[indices]
# 运行随机初始化K-Means 10次,观察质心分布
plt.figure(figsize=(15, 6))
# 子图1:随机初始化多次结果
plt.subplot(1, 2, 1)
plt.scatter(X[:, 0], X[:, 1], s=30, alpha=0.5, label='数据点')
random_init_centroids_list = []
for _ in range(5): # 运行5次随机初始化,看看质心位置的变化
rand_centroids = initialize_centroids_random(X, k_clusters)
random_init_centroids_list.append(rand_centroids)
plt.scatter(rand_centroids[:, 0], rand_centroids[:, 1],
s=150, marker='o', alpha=0.6, edgecolor='black', linewidth=1.5,
label=f'随机质心尝试 {_ + 1}' if _ == 0 else "") # 只显示一个label
plt.title('多次随机初始化的质心分布')
plt.xlabel("特征1")
plt.ylabel("特征2")
plt.legend(['数据点', '随机质心'], loc='upper left') # 统一 legend
plt.grid(True, linestyle='--', alpha=0.6)
# 子图2:K-Means++ 初始化多次结果
plt.subplot(1, 2, 2)
plt.scatter(X[:, 0], X[:, 1], s=30, alpha=0.5, label='数据点')
kmeans_plus_plus_centroids_list = []
for _ in range(5): # 运行5次K-Means++初始化,看看质心位置
kpp_centroids = initialize_centroids_kmeans_plus_plus(X, k_clusters)
kmeans_plus_plus_centroids_list.append(kpp_centroids)
plt.scatter(kpp_centroids[:, 0], kpp_centroids[:, 1],
s=150, marker='X', alpha=0.7, edgecolor='black', linewidth=1.5,
label=f'K-Means++质心尝试 {_ + 1}' if _ == 0 else "") # 只显示一个label
plt.title('多次K-Means++初始化的质心分布')
plt.xlabel("特征1")
plt.ylabel("特征2")
plt.legend(['数据点', 'K-Means++质心'], loc='upper left') # 统一 legend
plt.grid(True, linestyle='--', alpha=0.6)
plt.tight_layout()
plt.show()
通过对比两张图,你会发现:
- 随机初始化的质心可能会聚集在某一区域,或者远离真实的簇中心,每次运行结果差异较大。
- K-Means++ 初始化的质心则倾向于更好地分散开来,每次运行都能更有效地覆盖到数据的各个"角落",并且更接近真实簇的中心。虽然每次具体位置可能不同,但整体的覆盖性明显优于随机初始化。
这种更好的初始分布,正是 K-Means++ 能够带来更快收敛和更稳定聚类结果的关键。
实践的深度思考:
- 局部最优与全局最优: K-Means 算法的本质是寻找一个局部最优解。K-Means++ 并没有改变 K-Means 的这个特性,但它通过提供一个更优的起点,大大增加了算法收敛到全局最优(或接近全局最优)解的概率。
- 成本与收益: K-Means++ 的初始化过程比简单的随机初始化要复杂,需要更多的计算。然而,这种额外的计算成本通常是值得的,因为它能减少 K-Means 主算法的迭代次数,并带来更稳定的高质量聚类结果。在大多数实际应用中,
sklearn的KMeans默认使用k-means++作为初始化策略,这足以说明其重要性和有效性。 - 理解
n_init参数: 在sklearn.cluster.KMeans中,n_init参数表示 K-Means 算法将使用不同的质心初始化运行多少次,最终选择 WCSS 最低(即聚类效果最好)的那次结果。即使有了 K-Means++,多次运行(比如n_init=10或n_init='auto')仍然是推荐的做法,这能进一步降低陷入局部最优的风险。
小结与展望:迈向更健壮的 K-Means 算法
恭喜你!🎉 通过今天的学习和实践,你不仅理解了 K-Means++ 算法的精妙之处,更亲手实现了它的初始化逻辑,并通过可视化直观地对比了它与随机初始化的优势。你现在已经成功地解决了 K-Means 算法中最常见的"初始陷阱"问题,让你的聚类结果更加稳定可靠!🚀
理解 K-Means++ 是深入掌握 K-Means 的重要一步。它告诉我们,一个算法的"起点"同样至关重要,巧妙的初始化策略能够带来事半功倍的效果。