简介:面向机器学习初学者与数据分析人员的KMeans聚类算法Python实现与配套说明文档,解决无标签数据下自然分群的核心问题。内容覆盖算法原理、初始化与迭代更新流程、sklearn接口调用、数据标准化处理及可视化展示,并梳理了优缺点、适用场景(用户分群、图像分割、文本聚类)和DBSCAN、谱聚类等扩展方向。资源共3个文件,包含可直接运行的.py脚本和两份TXT说明文档,压缩包仅355KB,轻量精简便于对照学习。脚本示例清晰,说明文档能帮助快速理解每一步骤,适合希望系统掌握聚类分析并参考完整代码的开发者。目前已有4902人学习下载,是一份高效入门无监督学习的实用资料。
1. KMeans聚类算法+代码:为什么它依然是入门首选
说到KMeans聚类算法,很多人的第一反应是课本里的老古董。但现实是,做用户分群、数据预处理、异常检测时,KMeans往往是我跑的第一个模型——它快、可解释、代码量还小。这篇笔记不背教材,而是顺着“KMeans聚类算法+代码”这条线,把一个能投入使用的方案拆开:从手写实现讲到sklearn调参,从K值选择讲到避坑清单,最后告诉你怎么证明聚类结果不是在自嗨。适合刚学聚类、或者已经在用但总觉得结果“差口气”的从业者。注意,这里不讨论DBSCAN、层次聚类这些替代品,先把KMeans用到极致,你自然知道什么时候该换。
2. KMeans的原理与数学直觉:先搞懂它在算什么
2.1 从K个中心点到SSE:目标函数怎么定义
KMeans的输入就两个:一个样本矩阵X,以及你想把数据分成几簇的K值。输出是每个样本的簇标签label,以及K个簇中心centroid。它做的事情很朴素:让每个样本离自己簇中心的距离尽可能近。
严格说,KMeans在最小化这样一个损失函数:
[ SSE = \sum_{i=1}^{N} \sum_{k=1}^{K} r_{ik} | x_i - \mu_k |^2 ]
其中(r_{ik})表示第i个样本是否属于第k簇(属于为1,否则为0),(\mu_k)是第k簇的中心。这个函数叫簇内平方和(Sum of Squared Errors),也叫惯性(inertia)。KMeans就是找一组簇中心(\mu)和样本分配(r),让SSE最小。
注意这里的“距离”默认是欧氏距离。欧氏距离对量纲极其敏感,特征A的范围是0到1,特征B的范围是0到10000,那距离基本由特征B说了算。这就是后面要说的标准化问题的根源。
那么SSE最小化怎么求?如果你固定簇中心,每个样本分给哪个簇是确定的——离哪个中心近就归哪个。如果你固定样本分配,簇中心的最优解就是该簇所有样本的均值。这两个条件互相依赖,没法一次算出来,只能迭代。
2.2 Lloyd算法迭代步骤:初始化-分配-更新
KMeans的标准求解算法叫Lloyd算法,流程只有四步:
- 选K个初始中心。
- 分配:对每个样本,计算它到K个中心的距离,把它归到最近的中心。
- 更新:对每一簇,重新计算所有样本的均值,作为新中心。
- 重复第2、3步,直到中心不再变化或达到最大迭代次数。
我用一个一维数据的例子带你把迭代走一遍。假设样本是[1, 2, 10, 11],K=2。
第一步,随机选初始中心。假设选了1和2(这俩样本点本身作中心)。
初始中心:c1=1,c2=2。
第一轮分配:
- 1到c1距离0,到c2距离1,归c1。
- 2到c1距离1,到c2距离0,归c2。
- 10到c1距离9,到c2距离8,归c2。
- 11到c1距离10,到c2距离9,归c2。
第一轮更新:
- c1新值 = 1
- c2新值 = (2+10+11)/3 = 7.67
第二轮分配:
- 1到c1距离0,到c2距离6.67,归c1。
- 2到c1距离1,到c2距离5.67,归c1。
- 10到c1距离9,到c2距离2.33,归c2。
- 11到c1距离10,到c2距离3.33,归c2。
第二轮更新:
- c1新值 = (1+2)/2 = 1.5
- c2新值 = (10+11)/2 = 10.5
第三轮分配:1、2归c1,10、11归c2。中心更新后还是1.5和10.5,于是收敛。
你看,最终结果把数据分成了[1,2]和[10,11],很合理。但如果你初始中心选的是1和10呢?第一轮分配后,2会归到1(距离1),11归到10(距离1),更新后中心变成1.5和10.5,结果还是一样的。但如果你选初始中心为1和11,第一轮分配10会归11(距离1),2归1,更新后中心1.5和10.5,结果也一样。这个例子比较幸运。后面你会看到,初始中心选得不好,可能陷在局部最优,分出一堆重叠的团。
这个迭代过程的时间复杂度大约是O(迭代次数 × 样本数 × 特征数 × K)。样本量和特征维数增长时,代价直线上升,所以后面用numpy向量化是必要的。
2.3 为什么KMeans对初始中心敏感:EM视角简述
把Lloyd算法放到更大背景下看,它其实是EM算法的硬版本。E步(Expectation)对应“分配样本到最近中心”,M步(Maximization)对应“重新计算中心”。因为KMeans的分配是硬分配(一个样本只能属于一个簇),所以它比高斯混合模型(GMM)的软分配更简单粗暴。
EM算法有一个特点:它只能保证收敛到目标函数的局部最小值,不能保证全局最优。也就是说,不同的初始中心,最终可能停在不同的局部最优解上。比如有两个簇靠得很近,初始中心选在一个模糊地带,最后可能把一簇劈成两半,另一簇吞并两个簇的一部分。
这也是为什么sklearn的KMeans默认使用K-Means++初始化:它让初始中心尽量彼此远离,大幅降低落到糟糕局部最优的概率。K-Means++的步骤是:
- 随机选第一个中心。
- 对每个样本,计算它到最近已有中心的距离d(x)。
- 以概率正比于d(x)² 选取下一个中心。
- 重复直到选满K个中心。
这个策略让初始中心均匀覆盖数据空间,后来被证明在理论和实践上都远好于纯随机选点。你在调参时,init参数就是控制这个的。
理解KMeans的EM本质还有一个用处:当你的数据不是球形分布、簇大小差异巨大时,KMeans天然会有偏向。它不是算法bug,而是目标函数决定的。提前知道这个边界,比事后怀疑人生更重要。
3. 用Python写KMeans:从零实现到sklearn
3.1 最小可用实现:纯Python+列表的50行代码
很多教材直接甩sklearn,但遇到“不能用第三方库”的面试题或者要魔改距离公式时,自己写一遍能救命。下面是纯Python实现,没有numpy,便于逐行理解。
def kmeans(X, k, max_iter=100): # X: list of list,每一行是一个样本 # k: 簇数 # max_iter: 最大迭代次数 centers = X[:k] # 前k个样本当初始中心,这是最简陋的初始化 n_samples = len(X) n_features = len(X[0]) for _ in range(max_iter): # 分配:每个样本归到最近中心 labels = [] for x in X: distances = [] for c in centers: # 计算欧氏距离,不开根号也不影响比较 dist = sum((x[i] - c[i]) ** 2 for i in range(n_features)) distances.append(dist) labels.append(distances.index(min(distances))) # 更新:重新计算每簇均值 new_centers = [] for k_idx in range(k): # 取出这一簇的所有样本 cluster_points = [X[i] for i in range(n_samples) if labels[i] == k_idx] if cluster_points: # 逐特征求平均值 new_centers.append([sum(p[f] for p in cluster_points) / len(cluster_points) for f in range(n_features)]) else: # 空簇保留旧中心,避免崩溃 new_centers.append(centers[k_idx]) # 检查是否收敛:新旧中心是否完全一致 if new_centers == centers: break centers = new_centers return centers, labels逻辑说明:每轮迭代先做分配,再更新中心。分配阶段不开平方根,因为比较距离大小不需要开根号,平方和的大小关系不变。更新阶段如果某个簇没有样本(空簇),保留旧中心,否则直接崩溃。收敛条件是新旧中心完全相等,用浮点数比较可能不严格,但在小数据上够用。
参数说明:max_iter限制最大迭代次数,防止不收敛时死循环。初始中心直接取了前k个样本,这在真实数据上非常糟糕,只允许在演示场景使用。实际手写代码时至少要用随机抽样替代:centers = random.sample(X, k)。
这段代码的时间复杂度是O(max_iter × k × n_samples × n_features),所以样本一多就慢。但它给了你一个完全透明的基线,后面所有加速和调参都建立在理解它的基础上。
3.2 用numpy向量化:提速和代码规范
纯Python版跑10000个样本就明显卡顿,因为内层循环全是Python解释器开销。用numpy把距离计算和均值计算交给C级别代码,性能提升几十倍。
import numpy as np def kmeans_np(X, k, max_iter=100, tol=1e-4): # X: numpy数组,shape (n_samples, n_features) # k: 簇数 # max_iter: 最大迭代次数 # tol: 中心变化阈值,小于它就算收敛 n_samples, n_features = X.shape # 随机采样k个样本作为初始中心 rng = np.random.default_rng(0) idx = rng.choice(n_samples, k, replace=False) centers = X[idx] for i in range(max_iter): # 分配:利用广播计算每个样本到每个中心的距离 # X[:, None, :] 形状 (n_samples, 1, n_features) # centers[None, :, :] 形状 (1, k, n_features) distances = np.sum((X[:, None, :] - centers[None, :, :]) ** 2, axis=2) labels = np.argmin(distances, axis=1) # 更新:用花式索引聚合 new_centers = np.zeros_like(centers) for k_idx in range(k): cluster_points = X[labels == k_idx] if len(cluster_points) > 0: new_centers[k_idx] = cluster_points.mean(axis=0) else: # 空簇处理:沿用旧中心 new_centers[k_idx] = centers[k_idx] # 计算中心偏移量 shift = np.linalg.norm(new_centers - centers) centers = new_centers if shift < tol: break return centers, labels逻辑说明:核心加速点是距离矩阵计算。X[:, None, :]把X扩展成三维数组,centers[None, :, :]也扩展成三维,两者相减时numpy广播成(n_samples, k, n_features)的差值矩阵,再平方求和得到每个样本到每个中心的距离矩阵。argmin在第二维找最小距离的下标,一次得到所有样本的标签。更新阶段用布尔掩码labels == k_idx选样本,mean(axis=0)求中心。
参数说明:tol是收敛阈值,用中心位置变化的L2范数判断。默认1e-4比纯Python版更精细。random seed固定为0,保证每次运行结果一致。空簇处理依旧保留,这在实际数据中并不罕见,特别是k设太大时。
这段代码还有优化空间:比如用np.add.at避免循环更新中心,但在k不大时,循环性能损失可忽略。优先保证可读性。
3.3 调用sklearn的KMeans:真实项目里怎么用
自己手写是为了理解,真实项目用sklearn就够了。接口稳定、Cython优化、参数齐全。
from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler # X是原始数据,numpy数组或者pandas DataFrame都行 X_scaled = StandardScaler().fit_transform(X) # 指定聚类数为5 km = KMeans(n_clusters=5, init='k-means++', n_init=10, max_iter=300, tol=1e-4, random_state=42) km.fit(X_scaled) # 获取标签和中心 labels = km.labels_ centers = km.cluster_centers_ # 获取SSE(惯性) sse = km.inertia_逻辑说明:fit之前先做标准化,这是KMeans能不能用的前提。sklearn的KMeans默认init='k-means++',n_init=10表示它用10组不同的初始中心跑10次,挑SSE最小的结果。n_init越大越稳,但耗时线性增长。inertia_属性直接给出SSE,用来做手肘法很方便。
参数说明:random_state=42固定随机种子,让实验可复现。max_iter=300是单次运行的迭代上限,tol=1e-4是收敛阈值。sklearn的fit方法会自动处理空簇,但会打印警告。你还可以设置n_jobs并行加速多次初始化,但样本量不大时没必要。
4. KMeans必调的4个参数:K值、初始化、收敛判断与随机种子
4.1 手肘法和轮廓系数:怎么选K
K值是最关键的参数,也是最难拍的。没有监督信号告诉你“真实验类别数”,只能靠启发式方法。
手肘法看SSE随K变化的曲线。K越大,每个簇内越紧凑,SSE必然下降。你找那个“下降变缓的拐点”,就像胳膊肘。代码如下:
import matplotlib.pyplot as plt from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler X_scaled = StandardScaler().fit_transform(X) sse_list = [] K_range = range(2, 11) for k in K_range: km = KMeans(n_clusters=k, n_init=10, random_state=42) km.fit(X_scaled) sse_list.append(km.inertia_) plt.plot(K_range, sse_list, marker='o') plt.xlabel('K') plt.ylabel('SSE') plt.title('Elbow Method') plt.show()逻辑说明:这段代码跑9次KMeans,记录每个K的SSE。手肘法的判断是主观的,如果曲线没有明显拐点,说明数据根本不适合KMeans——簇重叠严重或没有簇结构。
轮廓系数则不需要画图就能给出数值。它衡量每个样本和自身簇的相似度以及和最近其他簇的差异度,范围-1到1,越大越好。
from sklearn.metrics import silhouette_score best_k = -1 best_score = -1 for k in range(2, 11): km = KMeans(n_clusters=k, n_init=10, random_state=42) labels = km.fit_predict(X_scaled) score = silhouette_score(X_scaled, labels) print(f"K={k}, silhouette={score:.4f}") if score > best_score: best_score = score best_k = k print(f"Best K: {best_k}")参数说明:轮廓系数的计算代价是O(n²),样本超过5万会明显变慢。这时可以改用抽样或直接用SSE手肘法。此外,轮廓系数对凸簇有效,如果你的簇形状奇怪,它可能会误判。所以建议手肘法定范围,轮廓系数在范围内精挑,两者结合。
4.2 init与n_init:K-Means++为什么能救你
init参数决定初始中心怎么选。'random'是纯随机选样本点,'k-means++'是前面讲的改进策略。sklearn默认用k-means++,这是有原因的。
n_init控制用几组初始中心跑KMeans,最终返回SSE最小的一组。这个参数是我的后悔药——早期我为了省时间把n_init设为1,结果某些K值下聚类结果一会儿一个样。后来调成n_init=10,世界清静了。
一个实用建议:如果数据量大,先把n_init从默认10降到5跑一版看看效果,确认K值之后再用n_init=10做最终模型。如果数据量不大,直接保持10,别在这上面省。
4.3 max_iter和tol:收敛判断与计算开销的平衡
max_iter是每次运行的最大迭代轮数,tol是中心变化阈值。sklearn默认max_iter=300、tol=1e-4。这两个值的组合决定算法何时“见好就收”。
见过一个翻车案例:有人为了“提高精度”把tol设为1e-8,结果KMeans跑了上万轮还没收敛,因为浮点误差让中心微调永远达不到这个值。tol设得过小没有意义,数据本身有噪声,精细到1e-8已经不是聚类该做的事。
max_iter设太小的后果是算法没收敛就停了。判断方法很简单:查看km.n_iter_属性,如果它等于max_iter,说明你撞上了上限。此时应该加大max_iter,而不是假装结果没问题。
km = KMeans(n_clusters=5, max_iter=50, tol=1e-4, random_state=42) km.fit(X_scaled) print("实际迭代次数:", km.n_iter_) if km.n_iter_ == 50: print("警告:达到最大迭代上限,没有完全收敛")参数说明:这个警告很有用。默认300对大多数数据都够,但如果你的数据有1万个样本且特征维度很高,300次迭代也可能不够。建议始终关注这个输出。
4.4 random_state:可复现实验的后悔药
random_state控制KMeans内部所有随机行为:初始中心采样、k-means++的随机选择、空簇处理时是否有补充样本。不设random_state的话,你每次跑结果都可能不同,做实验对比时看不到真实差异。
我习惯固定random_state=42,并在代码注释里写明。这能让你在调参时判断改动是否真的有效,而不是随机种子造成的假象。另外一个俗技巧:当你觉得当前聚类结果“碰巧好”时,把random_state换几个值跑一遍,如果结果差异很大,说明你的K值或数据本身不稳定,别急着用这个好结果。
5. KMeans常见问题与避坑:跑出结果不代表跑对
5.1 数据没做标准化,量纲大的特征主导聚类
现象:聚类结果几乎只由某个量纲大的特征决定。比如用户数据里有“年龄”(20-60)和“收入”(3万-100万),KMeans分出来的簇基本是低收入、中收入、高收入,年龄完全不起作用。
原因:欧氏距离计算时,收入列的取值范围大,平方后贡献了绝大部分距离值。目标的SSE也是按欧氏距离算的,KMeans自然优先去拟合收入这个特征。
解决:聚类前对所有特征做标准化。常见做法是z-score归一化。
from sklearn.preprocessing import StandardScaler, MinMaxScaler # 方案一:z-score(推荐默认) X_scaled = StandardScaler().fit_transform(X) # 方案二:min-max到[0,1],适合稀疏数据或需要保留0的语义 X_scaled = MinMaxScaler().fit_transform(X)逻辑说明:z-score让每个特征均值为0、方差为1,它们的量纲影响被消除。min-max适合特征本身是稀疏的或者你不想让0值被偏移的情况。注意:聚类和后续验证都要用同一份标准化后的数据,不要在聚类前用未标准化数据,聚类后却在标准化数据上算轮廓系数。
5.2 离散点太多,KMeans被迫把噪声当一类
现象:聚类结果里有一个簇只有一两个样本,离其他簇很远。这个孤立簇拉低了整体SSE吗?不一定,它可能只是一个噪声点。
原因:KMeans会把每个点都分配到某个簇,它没有“拒绝”机制。如果数据里混入几个远离主体的离群点,它们会自成一簇,不仅占用K,还会让邻近样本的簇中心被拉偏。
解决:在聚类前预先剔除离群点。常见做法是基于密度或者距离的简单过滤。
from sklearn.neighbors import LocalOutlierFactor # LOF算法识别离群点 lof = LocalOutlierFactor(n_neighbors=20, contamination=0.05) outlier_mask = lof.fit_predict(X_scaled) == -1 X_clean = X_scaled[~outlier_mask] print(f"原始样本数: {len(X_scaled)},剔除离群点后: {len(X_clean)}")逻辑说明:LocalOutlierFactor同时用局部密度和可达距离判断离群点,比一维的z-score阈值更适用多特征场景。contamination=0.05表示认为5%的数据是离群点,这个比例要根据业务调整,不是固定值。剔除后再做KMeans,你会看到簇的轮廓系数明显上升。
另一种思路是调低K值,让离群点被归入最近的大簇里,但这会污染簇中心。所以预先清洗是更干净的方案。
5.3 类别不平衡时,KMeans会把大类劈开
现象:数据里两个真实类别样本量比例是10:1,但KMeans聚类结果把大类分成三块,小类完整保留。看着簇数够了,实际并没有反映真实结构。
原因:KMeans的目标是最小化SSE,它对簇的大小没有约束。当某个簇样本多、分布范围广,把它劈成两半并各自设中心,SSE下降比保留一个小簇多得多。算法认为“劈大簇”更划算。
解决:调整聚类目标,或者改用更适合不均衡数据的聚类算法。如果坚持用KMeans,可以增大K值,让大类被劈得更细,再用层次聚类合并过细的块。但更省事的办法是换用DBSCAN或者GMM。
from sklearn.mixture import GaussianMixture # GMM用概率分配样本,能更好地处理重叠和不均衡的簇 gmm = GaussianMixture(n_components=5, random_state=42) gmm_labels = gmm.fit_predict(X_scaled)逻辑说明:GMM的软分配让它对样本量差异更宽容,因为每个簇有自己的协方差结构,不会被“距离中心最近”这个单一规则支配。如果你的业务场景天然存在不均衡,比如正常用户远多于异常用户,那么KMeans劈分异常现象会频繁发生,尽早换GMM或DBSCAN更稳妥。
5.4 高维稀疏数据的距离失效问题
现象:特征是几百维,很多维度取值为0,比如文本TF-IDF向量。KMeans聚类结果不稳定,轮廓系数很低。
原因:高维空间中欧氏距离退化了,几乎所有点到中心点的距离都差不多。这是“维度灾难”的直接后果。KMeans依赖距离进行分配,距离分辨力下降,结果就变成随机分团。
解决:先降维再聚类,或者更换距离度量。对文本数据常用方法:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.decomposition import TruncatedSVD from sklearn.cluster import KMeans # 原始文本向量化后一般几千维 tfidf = TfidfVectorizer(max_features=5000) X_tfidf = tfidf.fit_transform(documents) # 用SVD降到50维 svd = TruncatedSVD(n_components=50, random_state=42) X_reduced = svd.fit_transform(X_tfidf) # 再用KMeans km = KMeans(n_clusters=8, random_state=42) km.fit(X_reduced)逻辑说明:TruncatedSVD适合稀疏矩阵,降到50维后距离计算更有效。这个方案的原理是保留主要方差,同时压缩噪声维度。降维的维数没有黄金标准,常见做法是试25、50、100,看轮廓系数选最佳。
6. 让聚类结果真正可用:轮廓系数之外的三个实操检查
轮廓系数只能说明“簇内紧凑、簇间分离”,但它不知道你的业务是否买账。我习惯在出结果后做这样三件事:
第一,画簇分布图。如果数据能降到2维,用PCA或t-SNE降维散点图看每个簇的边界是否重叠。簇边界大面积重叠,说明KMeans强行分出了本不存在的结构。
第二,检查每个簇的样本量。正常KMeans的簇应该大小均匀,如果出现某个簇只有1个点或某个簇占了80%的样本,回去看前面说的离群点和类别不平衡问题。
第三,用业务指标验证。比如做用户分群,每个簇的转化率、客单价是否呈现明显差异?如果分出来的簇在业务指标上没有区分度,那这个聚类结果再数学上再漂亮也是废的。留下这个习惯,能帮你过滤掉很多“看起来能用”的假聚类。
最后说个我的习惯:每次跑KMeans时,顺手把n_init、random_state、tol写进代码注释,然后固定下来。改一个参数就重跑一次对比,而不是同时改三个。这让我少踩了很多“调参调了个寂寞”的坑。KMeans虽然简单,但每一步都在考验你对数据的理解。希望帮到你。
本文还有配套的精品资源,点击获取