Python中如何基于听众年龄分布对离散音乐流派聚类?
方案结论
你不需要直接硬套默认欧氏距离的6维点KMeans聚类,你的数据本质是有序分箱的概率分布数据,用适配分布特性+年龄有序性的自定义距离做聚类,结果会合理很多。100个左右流派的样本量很小,实现起来没有难度。
核心思路
1. 数据预处理
首先把每个流派的听众占比整理成标准格式:
- 每个流派对应长度为6的一维数组,索引0到5分别对应A1(0-10岁)到A6(61岁及以上)的听众占比
- 修正你示例里的标签笔误(比如Pop条目里重复标注的A3、Rock条目缺失的占比项),确保每个数组所有元素加和为1,无占比的年龄段填0即可
- 不要做标准化/归一化操作:所有维度的量纲统一为占比,尺度完全一致,标准化会破坏分布的相对比例关系。
2. 距离度量选择(最影响聚类效果的环节)
不要直接用默认欧氏距离:欧氏距离会把6个年龄段当成完全独立的维度,既不考虑数据是概率分布的特性,也不考虑年龄段的顺序关系——比如「受众集中在A2」和「受众集中在A3」的流派,实际受众年龄差只有10岁,欧氏距离算出来的差异和「受众集中在A2」「受众集中在A6(差50岁)」的差异一样,完全不符合业务逻辑。
推荐按优先级选下面的距离:
- 第一选择:Wasserstein距离(推土机距离/EMD):专门衡量两个分布之间的差异,且支持自定义分箱之间的距离成本——你可以把相邻年龄段的移动成本设为1,隔一个设为2,以此类推,完全匹配年龄分段有序的特性,算出来的相似性最符合常识。
- 第二选择:Jensen-Shannon(JS)散度:是KL散度的对称平滑版本,取值范围0-1,专门衡量概率分布的重叠度,0代表分布完全一致,缺点是默认不考虑年龄段的顺序关系,适合你不需要考虑年龄相邻性的场景。
- 不推荐用余弦距离、曼哈顿距离这类通用距离,适配性都不如上面两个。
3. 聚类算法选择
因为要用自定义距离,不要选原生只支持欧氏距离的KMeans,选下面几个适配小样本+自定义距离的算法即可:
- 层次聚类(Agglomerative Clustering):最推荐,100个样本跑起来速度极快,支持预计算自定义距离矩阵,还可以画树状图直观查看不同聚类粒度的结果,方便你决定分多少个簇。
- DBSCAN:不需要提前指定簇数,能自动把受众特征极特殊的离群流派(比如低龄向儿歌、老年向戏曲这类和主流流派受众差异极大的内容)标记为噪声,不会强行归到某个簇里。
- KMedoids(中心点聚类):如果你需要固定簇的数量,用这个替代KMeans,它的簇中心是实际存在的流派样本而非虚拟均值点,对异常值更鲁棒,完美支持自定义距离。
Python实现代码
首先安装依赖:pip install numpy scipy scikit-learn matplotlib
完整可运行示例(你把模拟数据替换成自己的真实数据即可):
import numpy as np from scipy.spatial.distance import pdist from scipy.cluster.hierarchy import linkage, dendrogram, fcluster import matplotlib.pyplot as plt # ---------------------- 1. 加载数据,替换成你自己的真实数据即可 ---------------------- # 每行对应一个流派,列依次是A1~A6的听众占比 genre_names = ["Pop", "Rock", "Children's Songs", "Opera", "HipHop", "Classical"] genre_dist = np.array([ [0.05, 0.3, 0.35, 0.2, 0.05, 0.05], # 修正笔误后的Pop分布 [0.05, 0.2, 0.3, 0.2, 0.15, 0.1], # 修正补全后的Rock分布 [0.9, 0.08, 0.02, 0, 0, 0], # 儿歌示例 [0, 0.02, 0.03, 0.1, 0.25, 0.6], # 戏曲示例 [0.02, 0.7, 0.23, 0.04, 0.01, 0], # 嘻哈示例 [0.01, 0.05, 0.1, 0.24, 0.35, 0.25] # 古典乐示例 ]) # ---------------------- 2. 计算自定义距离矩阵 ---------------------- # 方法1:计算Wasserstein距离(推荐),等距年龄分箱的一维EMD可直接用CDF差求和实现 def wasserstein_dist(p, q): cdf_p = np.cumsum(p) cdf_q = np.cumsum(q) return np.sum(np.abs(cdf_p - cdf_q)) dist_matrix = pdist(genre_dist, metric=wasserstein_dist) # 如果用JS散度可以替换成下面的实现 # def js_dist(p,q): # m = (p+q)/2 # kl_pm = np.sum(p * np.log(p/m + 1e-10)) # kl_qm = np.sum(q * np.log(q/m + 1e-10)) # return np.sqrt(0.5*(kl_pm + kl_qm)) # dist_matrix = pdist(genre_dist, metric=js_dist) # ---------------------- 3. 层次聚类+结果输出 ---------------------- Z = linkage(dist_matrix, method='average') # 平均链接法,对分布类数据效果稳定 # 画树状图看聚类效果 plt.figure(figsize=(10,6)) dendrogram(Z, labels=genre_names) plt.title("Genre Clustering Dendrogram by Age Distribution") plt.show() # 设定距离阈值切分簇,阈值可以根据树状图的跳变点调整 cluster_labels = fcluster(Z, t=1.0, criterion='distance') # 输出聚类结果 for label in np.unique(cluster_labels): print(f"簇{label}: {[genre_names[i] for i in range(len(genre_names)) if cluster_labels[i]==label]}")
调优提示
- 聚类的簇数没有固定标准答案,你可以通过树状图观察流派合并的距离差,选距离跳变最大的位置切分即可。
- 如果你发现聚类结果太散,可以调大切分距离阈值;如果想把特殊流派单独分出去,可以换DBSCAN,调整邻域距离参数和最小样本数即可。
- 如果你的数据里很多流派某个年龄段占比为0,用JS散度的时候记得加极小值(代码里的1e-10)避免对数计算报错。
内容的提问来源于stack exchange,提问作者user11369844
相关产品推荐
相关产品推荐

