在 ML-For-Beginners 中让聚类不再依赖 K-Means:基于尼日利亚歌曲数据集实践层次、密度与分布型聚类
本篇技术指南围绕 translations/bn/5-Clustering/2-K-Means/assignment.md 这份课后作业展开:在学习了 K-Means 聚类之后,尝试使用一种不是 K-Means 的聚类算法,在同一份数据上建模并记录学习收获。文章会先完整还原 5-Clustering/2-K-Means/README.md 中的基线流程(数据清洗、特征选择、K-Means、轮廓系数、肘部法则),再结合 5-Clustering/1-Visualize/README.md 给出的 Scikit-learn 聚类方法地图,逐一手把手实现层次凝聚聚类、DBSCAN 与高斯混合模型三种替代方案。读完本文,你将能够独立完成该作业、对聚类结果做轮廓系数与标签一致性评估,并理解"什么时候 K-Means 不适用、该换哪种算法"。
作业任务解读:这门课后练习到底要求什么
该作业的原文指令翻译如下:
本课中你已经学习了 K-Means 聚类。有时 K-Means 并不适合你的数据。请创建一个 notebook,使用本课或其他来源的数据(请注明来源),展示一种不使用 K-Means 的聚类方法。你学到了什么?
作业的评分标准(Rubric)为三档:
| 标准 | 优秀(Exemplary) | 合格(Adequate) | 需改进(Needs Improvement) |
|---|---|---|---|
| 整体交付 | 提交一个带有良好文档说明的聚类模型 notebook | 提交的 notebook 文档不完整和/或内容不完整 | 提交了未完成的工作 |
由此可以看出,作业的完成度评判核心不在"模型精度多高",而在三件事:选择了非 K-Means 的算法、过程有据可查、对结果有自己的解读。这也与 5-Clustering/1-Visualize/README.md 强调的主旨一致:聚类是探索性分析,"你选用的方法取决于你的数据"。
要完成它,最好的切入点就是本仓库已经准备好的 nigerian-songs.csv 数据集、上一课的 1-Visualize/notebook.ipynb(完成数据导入与清洗),以及本课的 2-K-Means/notebook.ipynb(完成特征挑选与 K-Means 基线)。仓库在 solution 目录下还提供了答案参考(notebook.ipynb、tester.ipynb 以及 R/Julia 实现),可作为提交前对照。
前置准备:数据从哪来、基线怎么打
加载并理解数据
作业允许"使用本课数据或其他来源数据(注明来源)"。最省事且最能和课程结论对话的选择,就是本课数据集:
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
# 相对本课目录 5-Clustering/2-K-Means/ 的上级 data 目录
df = pd.read_csv("../data/nigerian-songs.csv")
df.head()
该数据集包含 530 行、16 列(曲名、专辑、艺人、艺人主流派、发行年份、时长、流行度以及 danceability、acousticness、energy、instrumentalness、liveness、loudness、speechiness、tempo、time_signature 等 Spotify 音频特征),这是 1-Visualize/README.md 中 df.info() 的输出内容,其中 8 列为 float64、4 列为 int64、4 列为 object,全表无空值。
K-Means 课延续上一课的数据清洗结论,只保留 afro dancehall、afropop、nigerian pop 三个占主导的流派,并剔除 popularity == 0(无排名的噪声)的记录:
df = df[(df['artist_top_genre'] == 'afro dancehall') |
(df['artist_top_genre'] == 'afropop') |
(df['artist_top_genre'] == 'nigerian pop')]
df = df[(df['popularity'] > 0)]
上一课的探索还发现一个关键事实:数据整体相关性很弱,唯一较强相关的是 energy 与 loudness(大声的音乐通常能量感强)。这意味着聚类算法能从这份数据中"读出"的结构有限,为后续 K-Means 表现不佳埋下伏笔——这也正是本作业选择替代算法的动机来源。
用箱线图观察离群值
在 K-Means 课程的准备环节,脚本对 popularity、acousticness、energy、instrumentalness、liveness、loudness、speechiness、tempo、time_signature、danceability、length、release_date 共 12 个特征逐列绘制 sns.boxplot(参见 2-K-Means/notebook.ipynb):
plt.figure(figsize=(20,20), dpi=200)
plt.subplot(4,3,1)
sns.boxplot(x = 'popularity', data = df)
# ... 依次对每个特征重复 subplot + boxplot ...
plt.subplot(4,3,12)
sns.boxplot(x = 'release_date', data = df)
结论是"数据有些嘈杂":每个特征列都存在明显的离群点。课程给出的处理策略是不要彻底删除离群值——那会让数据量变得过小,而是选择量纲相近的列参与聚类。
构造特征矩阵 X 与编码标签
基线做法选择 artist_top_genre、popularity、danceability、acousticness、loudness、energy 六个特征,并用 LabelEncoder 将流派字符串转为数值:
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
X = df.loc[:, ('artist_top_genre','popularity','danceability',
'acousticness','loudness','energy')]
y = df['artist_top_genre']
X['artist_top_genre'] = le.fit_transform(X['artist_top_genre'])
y = le.transform(y)
注意:这里 y 虽被编码,但它只是用来事后核对聚类结果与真实流派的吻合度,聚类本身(无监督)并不消费标签。
K-Means 基线:3 个簇 + 轮廓系数
数据集只保留了 3 个主流流派,因此先试 n_clusters = 3:
from sklearn.cluster import KMeans
nclusters = 3
seed = 0
km = KMeans(n_clusters=nclusters, random_state=seed)
km.fit(X)
y_cluster_kmeans = km.predict(X)
y_cluster_kmeans
输出是一个数组,每行数据对应一个预测簇标签(0、1 或 2)。接着用轮廓系数(silhouette score)评估聚类质量:
from sklearn import metrics
score = metrics.silhouette_score(X, y_cluster_kmeans)
score
轮廓系数取值范围为 -1 到 1:越接近 1 表示簇内稠密、簇间分离清晰;接近 0 表示簇与簇重叠,样本紧贴相邻簇的决策边界。课程中该模型得分约为 0.53,"刚好在中间",说明数据并不特别适合这种(基于质心、球状假设的)聚类方式——但课程为了教学仍然继续了下去。
用肘部法则验证 k 的选择
"因为知道有 3 个流派就选 3 个簇"只是一种猜测,需要用肘部法则验证。思路是对 k 从 1 到 10 逐一运行 K-Means,记录每次的组内平方和(WCSS / inertia):
from sklearn.cluster import KMeans
wcss = []
for i in range(1, 11):
kmeans = KMeans(n_clusters=i, init='k-means++', random_state=42)
kmeans.fit(X)
wcss.append(kmeans.inertia_)
对这段代码涉及的关键参数,课程有明确注释:
range(1, 11):聚类过程的迭代次数集合;random_state:决定质心初始化的随机数生成方式,固定后可复现;WCSS(within-cluster sums of squares):簇内所有点到簇质心的平方平均距离;inertia:K-Means 试图最小化的目标,即"簇内部一致程度"的度量,每次迭代后被追加进wcss变量;k-means++:Scikit-learn 提供的质心初始化优化,让初始质心彼此(大体上)远离,通常优于纯随机初始化。
绘制 k 与 WCSS 的折线图,弯折处("肘部")对应的 k 即最优簇数:
plt.figure(figsize=(10,5))
sns.lineplot(x=range(1, 11), y=wcss, marker='o', color='red')
plt.title('Elbow')
plt.xlabel('Number of clusters')
plt.ylabel('WCSS')
plt.show()
该图与 images/elbow.png 一致地表明,3 或许确实是一个合理取值。
展示簇并评估"精度"
以 3 个簇重新拟合后,用 popularity 与 danceability 做散点并给不同簇着色:
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=3)
kmeans.fit(X)
labels = kmeans.predict(X)
plt.scatter(df['popularity'], df['danceability'], c=labels)
plt.xlabel('popularity')
plt.ylabel('danceability')
plt.show()
再拿簇标签与真实流派编码对比,计算"正确率":
labels = kmeans.labels_
correct_labels = sum(y == labels)
print("Result: %d out of %d samples were correctly labeled." % (correct_labels, y.size))
print('Accuracy score: {0:0.2f}'.format(correct_labels/float(y.size)))
课程的结论是:模型准确率并不理想,散点图中簇的形状已经暗示了原因——这份数据过于不均衡、特征之间相关度太低、各列取值方差过大,因此难以聚出清晰边界;实际形成的簇很可能被我们事先划定的三个流派标签严重带偏(见 images/clusters.png 的效果图)。在 Scikit-learn 的文档语境里,这种簇边界不清晰的模型被称为存在 variance(方差)问题:数据中各数值相对均值偏离过大。
为什么 K-Means 在这里表现不佳
K-Means 的硬伤可以总结为四点,这也是作业要求你另寻算法的全部理由:
- 必须预先指定 k:虽然肘部法则能给出参考,但"参考"不等于"正确答案";
- 簇形状假设为凸/球状:对非球形、长条形、嵌套形簇无能为力;
- 对量纲敏感:各特征取值范围差异大时,方差大的特征会主导距离计算;
- 不擅长处理噪声与密度不均:所有点都会被硬性划入某个簇,没有"噪声点"概念。
上一课 1-Visualize/README.md 将上述问题概括得更体系化:簇可以是"flat/non-flat geometry(欧氏/非欧氏几何)"、"transductive/inductive(转导/归纳)"、"constrained(带约束)"、"density(密度型)"等不同性质,K-Means 只覆盖其中很小一类。
替代算法路线图:Scikit-learn 给了你哪些选项
依据 1-Visualize/README.md 中列出的方法对照表,除去 K-Means,适合本作业"换一种方法"的候选及其适用场景是:
| 方法 | 适用场景(仓库文档原文含义) |
|---|---|
| Affinity propagation(亲和传播) | 簇多、簇大小不均,归纳式 |
| Mean-shift(均值漂移) | 簇多、簇大小不均,归纳式 |
| Spectral clustering(谱聚类) | 簇少、簇大小均匀,转导式 |
| Ward hierarchical clustering(Ward 层次聚类) | 簇多、存在约束,转导式 |
| Agglomerative clustering(凝聚层次聚类) | 簇多、存在约束、支持非欧氏距离,转导式 |
| DBSCAN | 非平坦几何、簇大小不均、存在噪声,转导式 |
| OPTICS | 非平坦几何、密度可变的簇,转导式 |
| Gaussian mixtures(高斯混合) | 平坦几何,归纳式 |
| BIRCH | 带离群点的大规模数据集,归纳式 |
按算法家族划分(同样源自该课):
- 层次聚类(hierarchical):对象根据与相邻对象的接近程度归类,Scikit-learn 的凝聚聚类属于此类;
- 质心聚类(centroid):K-Means 为代表,需指定 k;
- 分布型聚类(distribution-based):基于统计建模判断数据点属于某簇的概率,高斯混合方法属于此类;
- 密度型聚类(density-based):按点与点之间的密度聚集,远离群体的点被视为离群点/噪声,DBSCAN、Mean-shift、OPTICS 属于此类。
下方三种方案分别从层次、密度、分布三类中各取一个代表性算法,全部可直接照抄进你的作业 notebook。
方案 A:层次凝聚聚类(AgglomerativeClustering,Ward 连接)
层次聚类不需要预设"距离是到质心的欧氏距离"这种球状假设,而是从每个样本各自成簇开始,按相似度逐层合并。Scikit-learn 中 Ward 连接以最小化合并时簇内方差增量为目标,与 K-Means 的最小化 inertia 思路在数学上同源,但聚类方式完全不同——它是转导式的,直接对给定样本分组,不产出可推广到新样本的模型。
from sklearn.cluster import AgglomerativeClustering
from sklearn import metrics
# 先用与 K-Means 相同的 n_clusters=3 保持可比性
agg = AgglomerativeClustering(n_clusters=3, linkage='ward')
labels_agg = agg.fit_predict(X)
score_agg = metrics.silhouette_score(X, labels_agg)
print('Agglomerative silhouette:', round(score_agg, 3))
# 与真实流派对照
correct_agg = sum(y == labels_agg)
print('Matched samples: %d / %d' % (correct_agg, y.size))
比 K-Means 更进一步,凝聚聚类还可以用**树状图(dendrogram)**直接观察合并过程,不需要提前拍脑袋定 k。Scikit-learn 不直接提供画树状图的 API,需要借助 SciPy:
from scipy.cluster.hierarchy import dendrogram, linkage
from scipy.spatial.distance import pdist
# 基于 Ward 连接的层次结构
Z = linkage(pdist(X), method='ward')
plt.figure(figsize=(12, 6))
dendrogram(Z, truncate_mode='level', p=5)
plt.title('Hierarchical Clustering Dendrogram (Ward)')
plt.xlabel('Sample index / (cluster size)')
plt.ylabel('Distance')
plt.show()
观察树状图中最大的纵向间隙,可以佐证"数据到底分几簇更自然",这正是作业"展示不同方法并说明学到了什么"的理想素材。
方案 B:密度型聚类(DBSCAN)
K-Means 会给每个点硬分配一个簇;DBSCAN 则完全不同:它把点划分为核心点、边界点与噪声点,噪声点会被标记为 -1,而不是被硬塞进某个簇。这对本数据集的现实意义很大——上一课已指出数据存在大量离群点,而"离群点是否应该拥有一个簇标签"本身就是值得在作业里讨论的问题。
from sklearn.cluster import DBSCAN
# eps 控制邻域半径,min_samples 控制成为核心点所需的最少邻居数
db = DBSCAN(eps=3, min_samples=10)
labels_db = db.fit_predict(X)
n_noise = list(labels_db).count(-1)
n_clusters_db = len(set(labels_db)) - (1 if -1 in labels_db else 0)
print('DBSCAN found %d clusters, %d noise points' % (n_clusters_db, n_noise))
# DBSCAN 结果包含 -1,直接计算 silhouette 需要剔除噪声点
mask = labels_db != -1
if mask.sum() > 1 and len(set(labels_db[mask])) > 1:
score_db = metrics.silhouette_score(X[mask], labels_db[mask])
print('DBSCAN silhouette (excluding noise):', round(score_db, 3))
eps 与 min_samples 是 DBSCAN 的两个超参数:eps 过小会导致几乎全是噪声,过大则把所有点并成一簇;min_samples 越大,越容易把稀疏区域判为噪声。由于它们对结果影响显著,一个诚实的做法是在一定范围上网格搜索,并记录不同参数下的簇数、噪声点占比与轮廓系数,把"参数敏感性"写进作业的文档说明中。
import numpy as np
for eps in [2, 3, 5, 8]:
for min_samples in [5, 10, 20]:
db = DBSCAN(eps=eps, min_samples=min_samples).fit(X)
labels_tmp = db.labels_
n_clu = len(set(labels_tmp)) - (1 if -1 in labels_tmp else 0)
n_noi = int((labels_tmp == -1).sum())
print('eps=%.1f min_samples=%2d -> clusters=%d, noise=%d'
% (eps, min_samples, n_clu, n_noi))
方案 C:分布型聚类(高斯混合模型,GaussianMixture)
高斯混合模型把数据看成若干个高斯分布的叠加,输出的是"每个点属于每个簇的概率"(软分配),而不是 K-Means 那样的硬 0/1 标签。它可以拟合椭圆状、大小不均的簇,并且在密度聚类认为"该点是噪声"的地方,仍能给出一个带概率的归属——这正好回应了作业开头的提示"K-Means 有时不合适"。
from sklearn.mixture import GaussianMixture
# 与前面的 n_clusters 保持一致,取 3 个分量
gmm = GaussianMixture(n_components=3, random_state=0)
gmm.fit(X)
labels_gmm = gmm.predict(X)
proba_gmm = gmm.predict_proba(X) # 每行的软分配概率
score_gmm = metrics.silhouette_score(X, labels_gmm)
print('GaussianMixture silhouette:', round(score_gmm, 3))
print('Mean max membership probability:',
round(proba_gmm.max(axis=1).mean(), 3))
# 与真实流派对照
correct_gmm = sum(y == labels_gmm)
print('Matched samples: %d / %d' % (correct_gmm, y.size))
predict_proba 返回的是 n_samples × n_components 的概率矩阵;某簇的最大后验概率普遍偏低,说明大量样本在两个流派特征带之间模糊重叠——这一观察可以直接写进作业的"你学到了什么"。
共通教训:特征缩放是绕不开的一步
课程 Challenge 与 Review 部分都反复提示了特征缩放:课程指出,不做缩放的代码里留有被注释的标准化步骤;一旦启用标准缩放,"轮廓系数会下降,而肘部曲线的拐点会变平滑"。原因在于:不缩放时,方差更大的特征会在欧氏距离中携带更高权重,掩盖了其他特征的信息;缩放后各列回归到同一量纲,聚类反映的才是综合特征结构而非单一特征。
因此无论选方案 A/B/C,都建议在作业里做一组"缩放前 vs 缩放后"的对照实验:
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
# 特征缩放 + 任一聚类器组合为管道,避免数据泄漏
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# 例:对缩放后的数据重跑层次聚类
agg_scaled = AgglomerativeClustering(n_clusters=3, linkage='ward')
labels_agg_scaled = agg_scaled.fit_predict(X_scaled)
print('Agglomerative silhouette (scaled):',
round(metrics.silhouette_score(X_scaled, labels_agg_scaled), 3))
# 例:对缩放后的数据重跑高斯混合
gmm_scaled = GaussianMixture(n_components=3, random_state=0)
labels_gmm_scaled = gmm_scaled.fit_predict(X_scaled)
print('GaussianMixture silhouette (scaled):',
round(metrics.silhouette_score(X_scaled, labels_gmm_scaled), 3))
把四种结果(K-Means、Agglomerative、DBSCAN、GMM × 原始/缩放)整理成一张对比表,是让作业达到"优秀"档位的最直接方式。
对照评分细则自查与文档化建议
对照作业的 Rubric,可以从以下三个层面自查 notebook 是否达到"Exemplary":
- 选用了非 K-Means 的聚类方法:优先展示一个"从头到尾完整跑通"的算法(层次/密度/分布三选一即可),代码注释说明参数含义与选取依据;
- 文档完整:每个代码块前后用 Markdown 说明"在做什么、预期输出、为什么这么做";至少包含数据来源声明(如"数据取自本仓库 5-Clustering/data/nigerian-songs.csv,出自 ML-For-Beginners 聚类课程")、一次 silhouette 或其他内部指标评估、一次与已知流派的标签对照分析;
- 有"你学到了什么"的结论段:把课程的结论复述并验证——例如本数据"相关性弱、量纲差异大、三类流派特征带相互重叠",导致无论 K-Means 还是替代算法都难以得到高轮廓系数;此时聚类更适合用作探索性工具而非分类器,这一认知本身就是作业要的训练目标。
若想进一步提高聚类质量,可沿课程 Challenge 的方向继续:清理更多离群点、换用不同的特征组合(例如只取相关度更高的 energy/loudness)、对样本加权等。仓库的 solution/notebook.ipynb 与 solution/tester.ipynb 提供了参考答案与自动评测脚本,R 语言学习者还可参考 solution/R/lesson_15-R.ipynb(其中还给出了 K-Means 的完整五步迭代描述:分配、重算质心、再分配,直到质心几乎不再移动)。
小结
本作业的真正价值不在于"找出一个比 K-Means 更准的模型",而在于体验"方法选择依赖数据性质"这一无监督学习的核心方法论。K-Means 教程用尼日利亚歌曲数据给出了 0.53 的轮廓系数与不理想的标签吻合度,恰恰为替代算法提供了绝佳的对照基线;而层次聚类让你看到合并层级、DBSCAN 让你直面噪声点、高斯混合让你获得软概率分配,三种视角互补,共同构成对同一份"难聚"数据的完整画像。按上文步骤完成建模、评估、缩放对照与文档化,即可交付一份符合仓库作业评分标准的高质量 notebook。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00