☰
Python+Flask豆瓣音乐聚类可视化:从数据清洗到ECharts交互
2026/9/26 6:31:50 网站建设 项目流程

简介:一套基于Python+Flask的豆瓣音乐数据聚类分析可视化项目源码,面向毕业设计、课程实践或数据可视化入门学习者,完整覆盖用户登录注册、音乐数据展示与搜索、管理员对用户和音乐数据的管理、K-Means聚类分析及可视化、豆瓣数据爬取与MySQL存储等功能链路。项目以Flask作为Web后端框架,通过pymysql完成数据库操作,调用sklearn实现聚类算法,借助matplotlib展示聚类结果,并集成WordCloud生成词云,前后端页面完整,角色权限清晰,适合以此为基础快速搭建音乐数据分析演示系统或作为毕业设计参照。压缩包共74个文件,大小约2.07MB,其中包含15个HTML页面模板、22个JS交互脚本、9个CSS样式、4个Python核心代码文件、MySQL建表SQL脚本以及字体图标等静态资源,目录区分模板、静态资源与后端逻辑,便于按模块查阅与修改。目前已有106人浏览学习,对需要快速上手Flask开发、数据爬取与K-Means聚类可视化应用的读者具有直接参考价值。

1. 豆瓣音乐聚类可视化:一个能跑通的完整数据链路

基于Python+flask的豆瓣音乐数据聚类分析可视化,拆开看就是一条标准的数据分析流水线:把豆瓣音乐专辑的评分、评价人数、流派、年份抓下来,用聚类分析分成几组,再用 Flask 起一个本地页面把分组结果画出来。真做一遍就会发现,聚类算法本身是最省事的一环,多数时间耗在数据清洗和 ECharts 调试上。这个方向适合刚学完 Python、想练 sklearn 和 Flask 的人,也适合做课设或毕设——数据源公开、结果直观、前后端都能讲到。下面按一套跑通过的做法,把采集、聚类、可视化到踩坑整条链路讲清楚。

2. 数据采集与清洗:先把豆瓣音乐的字段变成可聚类的样子

2.1 先定数据边界:字段、规模与存储方式

豆瓣音乐页面上能稳定拿到的字段有专辑名、表演者、评分、评价人数、流派标签、发行时间。第一版不需要专辑简介那种长文本,文本字段进来之后还得清洗分词,反而拖慢整个项目。我的习惯是先只拿六七个字段,把聚类和可视化链路跑通,后面有精力再加内容特征。

样本规模控制在三百到八百条就够了。聚类分析不是大数据工程,样本量太小撑不起前端散点图,太大又会让页面加载变慢。豆瓣音乐 Top 250 加上几个分类榜单,抓到四百条左右是一个舒服的区间:能分出明显的簇,图上也不至于糊成一团。存储用 CSV 而不是数据库,训练脚本和 Flask 都要读这份数据,CSV 是两边零依赖的格式;上 SQLite 反而多一层读写代码,对这类项目没有收益。字段清单大致如下:

字段来源用途
title专辑名展示、tooltip
artist表演者展示
rating评分聚类特征
rating_count评价人数聚类特征,需 log 缩放
genre流派标签构造二值特征
year发行年份聚类特征

2.2 爬虫部分的工程做法:请求头、限速与异常重试

豆瓣对爬虫不算友好,但公开榜单页面的低频抓取是可以跑通的。常见做法是 requests 抓 HTML、BeautifulSoup 解析,请求头至少要带 User-Agent 和 Referer,否则很容易被当成脚本请求直接拦掉。下面这段是抓取单页的骨架,注意爬虫可视化界面这类项目最怕的就是跑一半被限流,所以骨架里要把限速和编码处理一起写好。

import time import requests from bs4 import BeautifulSoup def fetch_page(start): url = "https://music.douban.com/top250" params = {"start": start} headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36", "Referer": "https://music.douban.com/" } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding # 让 requests 猜编码,避免中文乱码 soup = BeautifulSoup(resp.text, "html.parser") time.sleep(2) # 请求间隔,给服务器留余量 return soup

代码里有两个容易被忽略的点。resp.encoding 那行很关键,豆瓣页面是 UTF-8,但响应头不一定标注清楚,不指定的话 Python 可能按 ISO-8859-1 解析,后面字段全是乱码。time.sleep(2) 是限速底线,连续快速请求几十次就可能被临时限制访问。解析部分用 BeautifulSoup 的 select 按页面结构取节点,豆瓣的 HTML 结构改过多次,写选择器时以实际页面为准,不要照抄网上的旧博客。我一般先把页面源码存成 HTML 文件,在浏览器里手动确认选择器命中几条,再去批量抓。

2.3 清洗与构造特征:把文本标签变成可聚类的数值向量

原始数据里有两个字段不能直接用:流派是逗号分隔的文本,评价人数是高度偏斜的数值。流派做聚类要变成数值特征,常见做法是统计全部样本里出现频率最高的几个风格,每个风格生成一列 0/1 二值特征。评价人数用 log 变换压一下长尾,否则少数大热专辑会把其他样本都挤在坐标原点附近,这个问题在 python 数据分析与可视化项目里出现频率很高。

import pandas as pd import numpy as np df = pd.read_csv("douban_music.csv") # 清洗:评分和评价人数是核心特征,缺失直接删掉 df = df.dropna(subset=["rating", "rating_count"]) df["rating"] = df["rating"].astype(float) df["rating_count"] = df["rating_count"].astype(int) # 评价人数做 log 变换,np.log1p 防止真值为 0 时算出负无穷 df["rating_count_log"] = np.log1p(df["rating_count"]) # 流派:取出 Top 8 高频风格,构造二值特征 df["genre"] = df["genre"].fillna("").astype(str) top_genres = df["genre"].str.split("、").explode().value_counts().head(8).index for genre in top_genres: df[f"genre_{genre}"] = df["genre"].str.contains(genre).astype(int) # 年份:按五年一段归组,减少年份噪声 df["decade_group"] = (df["year"] // 5) * 5 feature_cols = ["rating", "rating_count_log", "year"] + [f"genre_{g}" for g in top_genres] print(df[feature_cols].head())

这段代码揭示了一个聚类项目里最容易被低估的环节:特征列构成直接决定聚类结果长什么样。只用评分和评价人数,聚类基本就是"高分 vs 低分"两类;把流派二值特征加进来,才会出现"高口碑民谣类""流行大众类"这种更有解释力的簇。流派分隔符是中文顿号还是英文逗号,不同榜单不一致,str.split("、") 要按实际数据调整。清洗完先打印 feature_cols 的几行检查取值是否合理,再进入聚类阶段。

3. 聚类参数怎么定:KMeans、标准化与 K 值选择的完整方案

3.1 为什么选 KMeans:三个候选算法的取舍

聚类分析案例里算法选型就几条路。对豆瓣音乐这种几百条样本、十几维特征的数据,KMeans 是最省心的选择。它假设簇是凸的、大小相近,音乐数据的画像基本符合这个假设,不会出现一个簇环抱另一个簇的形状。KMeans 的输出也最好交付:每个样本一个簇编号,簇中心就是这一类专辑的平均画像,非技术人员也能看懂。

DBSCAN 能处理任意形状的簇,也不需要指定簇数量,但 eps 和 min_samples 两个参数非常玄学,调起来基本靠肉眼反复试;层次聚类在小样本上效果好,但树状图非专业人士看不懂,计算量还随样本数平方增长。一个常见的翻车点是拿聚类当分类用,指望它发现什么隐藏真相。聚类没有标准答案,目标只是让组内尽量相似、组间尽量不同,所以后续所有参数调整都围绕"结果能不能讲出人话"来验证。

3.2 标准化、PCA 降维与聚类:三段式处理

KMeans 基于欧氏距离,特征不在一个量纲上时,距离会被大数值特征主导。评分是 1 到 10,评价人数是几千到几万,年份是一九八几年到最近,不标准化的话聚类结果基本等于按评价人数分桶。所以 StandardScaler 不是可选优化,是必做项。PCA 降维的作用分两半:把十几维特征压成两到三维给前端展示,同时去掉特征间的线性冗余,让 KMeans 在降维后的空间里跑得更稳。流派二值特征之间天然互相排斥,存在线性相关,PCA 能把信息浓缩进少数主成分。

from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans from sklearn.decomposition import PCA import pandas as pd df = pd.read_csv("clustered_music.csv") feature_cols = ["rating", "rating_count_log", "year"] + \ [c for c in df.columns if c.startswith("genre_")] scaler = StandardScaler() X_scaled = scaler.fit_transform(df[feature_cols]) pca = PCA(n_components=2, random_state=42) coords = pca.fit_transform(X_scaled) kmeans = KMeans(n_clusters=4, random_state=42, n_init=10) df["cluster"] = kmeans.fit_predict(X_scaled) df["x"] = coords[:, 0] df["y"] = coords[:, 1] df.to_csv("clustered_music.csv", index=False, encoding="utf-8-sig") print(pca.explained_variance_ratio_)

三个参数值得单独说。n_components=2 是为了可视化,散点图只需要两维;如果后面要做簇画像雷达图,可以保留 PCA 前三维,但不影响散点图。n_init=10 是 KMeans 的官方推荐值,它让算法用 10 个不同初始中心各跑一次,取效果最好的结果,避免随机初始中心把结果带进局部最优。random_state=42 保证每次跑出来的簇编号和坐标一致,否则前端图表每次刷新都不一样,排查问题会很痛苦。PCA 的坐标没有业务含义,x 和 y 不是"评分"和"年份",前端散点图的轴标题写"PC1 / PC2",hover 的 tooltip 里放真正的字段。

3.3 K 值怎么定:肘部法则与轮廓系数的具体操作

K 值是最没法拍脑袋的参数。经验上音乐数据取 3 到 5 类比较合理,太少分不出风格差异,太多每类样本量不足。但经验不能作为唯一依据,需要用两个指标交叉验证。肘部法则看簇内误差平方和 inertia,随着 K 增大会持续下降,下降速度从某个点开始放缓,那个拐弯的位置就是合理 K。轮廓系数衡量样本与同簇样本的相似度,取值范围 -1 到 1,越大说明聚类越紧凑。

from sklearn.metrics import silhouette_score wcss = [] silhouette = [] for k in range(2, 11): km = KMeans(n_clusters=k, random_state=42, n_init=10) labels = km.fit_predict(X_scaled) wcss.append(km.inertia_) sil = silhouette_score(X_scaled, labels) silhouette.append(sil) print(f"k={k}: wcss={km.inertia_:.0f}, silhouette={sil:.4f}") # 输出示例(不同数据结果不同): # k=2: wcss=182.4, silhouette=0.31 # k=3: wcss=131.0, silhouette=0.36 # k=4: wcss=104.2, silhouette=0.34 # k=5: wcss=88.6, silhouette=0.29

先把这段结果在终端打印出来,把 K 和 silhouette 的对应关系写到纸上,再决定最终 K。注意轮廓系数不是越大越好:K=2 时轮廓系数往往很高,但分两类对音乐数据几乎没有解释力。正确的读法是:在 K 较小且业务上合理的范围内,选轮廓系数最高的那个。上例 k=3 是 0.36 最高,k=4 是 0.34 只低一点点,这时候我会实际看 k=3 和 k=4 的样本分布,哪个能讲出"高口碑独立音乐""流行热门"这种话,就选哪个。

4. Flask + ECharts:把聚类结果变成可交互的数据页面

4.1 项目目录结构:把训练和 Web 服务分开

Flask 是轻量级 Web 框架,这个项目里它的职责就是读 CSV、返回 JSON、渲染页面模板。整个项目建议分成四块,训练脚本和 Web 服务彻底分离:train_model.py 跑一次,把带聚类标签和坐标的结果写回 CSV;app.py 只负责读取,不做计算。这样前端页面反复刷新不会触发重新聚类,也方便单独调试。目录结构如下:

douban_music_cluster/ ├── app.py # Flask 入口 ├── train_model.py # 数据清洗 + 聚类 + 输出 clustered_music.csv ├── data/ │ └── clustered_music.csv # 带 cluster/x/y 列的最终数据 ├── templates/ │ └── index.html # 页面模板 └── static/ └── js/ └── echarts.min.js # ECharts 库文件

templates 和 static 是 Flask 默认约定的目录名,改名字要额外配置,没必要。ECharts 的 JS 文件下载后放本地,比引 CDN 靠谱,内网环境或断网演示时不会白屏。这里多说一句:有人把训练逻辑塞进 app.py 的路由里,页面一打开就训练一次,数据量大时接口卡十几秒。训练和展示分离不是风格问题,是性能问题。

4.2 Flask 接口设计:只传 JSON,不传模板变量

页面要交互,前端就要能拿到原始数据。用 render_template 把 DataFrame 转成模板变量塞进页面,页面一刷新数据就固定,没法做筛选联动。正确做法是 Flask 暴露一个 JSON 接口,前端用 fetch 去取,页面只负责展示。这也是 Flask 开发里比较标准的做法,路由保持精简,逻辑都在前端。

import os import pandas as pd from flask import Flask, render_template, jsonify app = Flask(__name__) BASE_DIR = os.path.abspath(os.path.dirname(__file__)) @app.route("/") def index(): return render_template("index.html") @app.route("/api/clusters") def clusters(): df = pd.read_csv(os.path.join(BASE_DIR, "data", "clustered_music.csv")) records = df[[ "title", "artist", "rating", "rating_count", "year", "cluster", "x", "y" ]].to_dict(orient="records") return jsonify({"data": records}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)

这段代码里最值得说的是 BASE_DIR 和 os.path.join。Flask 项目在本地开发时,os.getcwd() 通常就是项目根目录,但部署到服务器或者用 systemd 启动时,工作目录可能完全不是这一层。BASE_DIR 用file定位 app.py 自身的绝对路径,再从它往下拼 data 目录,无论从哪里启动都不会路径翻车。to_dict(orient="records") 把每一行变成一个字典,JSON 序列化后字段名保持不变,前端直接用同名属性。host="0.0.0.0" 让局域网内其他机器也能访问,方便拿手机验证页面效果。debug=True 只在开发时开,线上一定要关,不然报错堆栈会暴露给访问者。

4.3 ECharts 配置:散点图的三个必调点

ECharts 数据可视化在这个项目里的核心是散点图,每个点是专辑,x/y 是 PCA 降维坐标,颜色是聚类簇。三个必调点:按簇分组构造 series、tooltip 显示专辑真实信息、颜色映射固定。下面这段是 index.html 里的核心脚本。

<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <script src="/static/js/echarts.min.js"></script> </head> <body> <div id="chart" style="height: 600px; width: 100%;"></div> <script> const colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de']; fetch('/api/clusters') .then(res => res.json()) .then(json => { const grouped = {}; json.data.forEach(item => { const key = item.cluster; if (!grouped[key]) grouped[key] = []; grouped[key].push({ value: [item.x, item.y, item.rating, item.year], name: `${item.title} - ${item.artist}` }); }); const clusterNames = ['口碑佳作', '大众流行', '独立小众', '经典怀旧']; const series = Object.keys(grouped).map(cluster => ({ type: 'scatter', name: clusterNames[cluster] || `第${cluster}类`, data: grouped[cluster], symbolSize: 10, itemStyle: { color: colors[cluster] } })); const chart = echarts.init(document.getElementById('chart')); chart.setOption({ tooltip: { formatter: function(params) { const d = params.data; return `${d.name}<br/>评分: ${d.value[2]}<br/>发行年份: ${d.value[3]}`; } }, xAxis: { name: 'PC1' }, yAxis: { name: 'PC2' }, series: series }); }); </script> </body> </html>

三个要点逐个说。第一,series 必须按簇分开,不能把所有点塞进一个 series,否则 ECharts 无法给不同簇上不同颜色,图例也做不出来。第二,tooltip.formatter 里取 params.data.name 而不是 params.name,因为散点图每个数据点是一个对象,name 存在 data 对象上。第三,symbolSize 定为 10,太小密集区域看不清,太大点与点重叠更严重。数据量超过五百条时,把 symbolSize 降到 6,加 itemStyle.opacity 设为 0.7 做半透明,能明显缓解重叠。

5. 避坑指南:豆瓣限流与 Flask 路径的五个常见问题

5.1 豆瓣限流:页面突然变成 302 或返回验证码

现象:爬虫跑了两百多条之后,请求不再返回数据,而是 302 跳到验证页,或者直接 403。

原因:豆瓣对单 IP 的请求频率有限制,连续高频抓取会触发风控。这不是永久封禁,但会持续几分钟到几十分钟,期间请求全部失效。

解决:请求间隔从 2 秒加到 4 到 6 秒,抓取中断后等十分钟再继续。已经成功抓到的数据先落盘,断点续抓比一次性全量抓完稳得多。我一般把 start 参数和已抓条数打印出来,重启脚本时直接从断点往后抓。只碰 Top 250 这种静态榜单页,不要动搜索接口,搜索接口的风控严格得多。

5.2 中文乱码:专辑名在页面里变成问号

现象:CSV 里中文正常,但 Flask 页面和 ECharts tooltip 里中文变成乱码或问号。

原因:三个环节都可能出问题。requests 没有正确识别响应编码,抓回来就是乱码;CSV 保存用了默认 ANSI 编码,Flask 读进来后乱码;HTML 模板缺 charset 声明。

解决:抓取时用 resp.encoding = resp.apparent_encoding;保存 CSV 统一用 encoding="utf-8-sig",带 BOM 的格式既能被 Excel 正常打开,也能被 pandas 读回;HTML head 里加 。三件事都做了,乱码基本消失。有个细节:pandas 读 CSV 时要写 pd.read_csv("xxx.csv", encoding="utf-8-sig"),不然 Windows 默认编码对不上,这个坑在本地开发时最容易出现。

5.3 Flask 部署后路径失效:static 和附件目录找不到

现象:本地 python app.py 一切正常,部署到服务器或换一台机器后,页面样式加载不出来,数据文件报 File not found。Windows 上开发的 flask 项目部署到 Linux 服务器上,附件和 static 路径经常出这种问题。

原因:代码里用了相对路径,比如 "data/clustered_music.csv",而 Flask 进程的工作目录不是项目根目录。Windows 和 Linux 的路径分隔符差异也是一个翻车点,本地拼接用了反斜杠,服务器上识别不了。

解决:只用 os.path.abspath(os.path.dirname(file)) 拼 BASE_DIR,所有文件路径用 os.path.join(BASE_DIR, "data", "clustered_music.csv")。模板和 static 路径 Flask 会自动基于 app 所在目录解析,所以 app.py 要放在项目根目录,不要套一层子目录。这个血泪经验我记了很久,现在写 Flask 项目第一行就定义 BASE_DIR。

5.4 聚类结果全是同一类,或某一类占比超过九成

现象:跑完 KMeans,cluster 列全是 0,或者某个簇占了 95% 的样本,可视化图上几乎是一个颜色。

原因:最常见的是没有做标准化,某个大数值特征主导了距离;其次是特征选太少,比如只用了评分和评价人数,数据本身拉不开差距;还有可能是 K 值取太大,所有样本在低维空间堆成一团。

解决:先实施 StandardScaler,然后用前面讲的轮廓系数确认 K 范围。如果标准化后还是挤在一起,检查 year 字段是不是有大量空值被填成了 0,这种假数据会产生一个"全是 0"的簇。把缺失年份的样本单独标记出来,比填 0 更诚实。

5.5 散点图所有点重叠在原点附近

现象:ECharts 图出来了,但几百个点全堆在坐标中心,看不出聚类结构。

原因:PCA 降维后的 x/y 坐标相对值太小,或者有少量极端离群点把坐标轴拉伸了,其余点都被压缩到一小块区域。

解决:PCA 前先检查每个特征的方差,如果某个流派二值特征 99% 都是 0,它几乎是个常数,会被 StandardScaler 放大成强噪声,应该从参与计算的特征里剔除。离群点的问题更多出现在评价人数极少的专辑上,用百分位数裁剪一下,把 rating_count 上下各 1% 的样本缩到边界值,坐标就不会被扯开。

6. 让聚类结果经得起追问:验证方法与 ECharts 交互下钻

聚类标签回写 CSV 之后,第一个验证动作是抽样回看:每个簇随机抽三到五条,看专辑名和表演者是不是"真的像同类"。这一步不写代码,但比任何指标都直观。我做过一次聚类后,k=4 时有个簇全是九十年代的民谣专辑,另一个簇是近五年的流行电子,这种结果才能拿去跟别人讲。

第二个验证动作是画簇画像雷达图。把每个簇的特征均值求出来,取五六个有业务含义的维度,评分、评价人数对数、年份、几个主要流派占比,归一化后用 ECharts 的 radar 类型画出来。四个簇的雷达叠在一起,能一眼看出"高评分低评价数"这样的话。雷达图的数据可以在后端用 pandas groupby 算好,直接塞进 /api/cluster_profiles 接口,前端不用做聚合逻辑。

再往前走一步,加一个交互筛选。页面顶部放一个下拉框按簇筛选,散点图只渲染选中簇的点。fetch 回来的数据本来就全在浏览器里,筛选只是过滤数组,不需要重新请求后端。这个交互加上之后,整个页面从"静态展示"变成了"可探索的工具",也是往可视化大屏方向过渡的第一步——多张图表联动、点击散点联动雷达图,都是同一套状态管理思路。

我第一版做这个项目时跳过了标准化,聚类结果按评分分桶,图倒是好看,但讲不出任何音乐层面的故事。后来把 silhouette 打出来看,才意识到数值量纲在 KMeans 里的权力比算法本身还大。这个坑我记到现在,也希望你做完这一版之后,能比我当时少走这段弯路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询