☰
基于Python+Flask的豆瓣音乐数据聚类分析可视化实战
2026/10/11 15:30:46 网站建设 项目流程

简介:这份资源是一套基于 Python 与 Flask 的豆瓣音乐数据聚类分析可视化项目,面向正在做音乐数据分析、数据可视化课程设计或毕业设计的学生与开发者。项目围绕用户管理、音乐数据爬取、K-Means 聚类及可视化展示展开,包含用户与管理员两类角色,覆盖登录注册、音乐数据展示与搜索、后台用户与音乐管理、聚类结果可视化、数据爬取与存储等完整页面功能,适合作为数据分析与 Web 开发结合的实战参考。压缩包共 74 个文件,约 2.07MB,以 22 个 js、15 个 html、9 个 css 构成前端交互与页面结构,4 个 py 文件承载爬虫、聚类与后端逻辑,另含 sql 建表脚本、字体图标及图片等静态资源,目录按 templates、static、数据库脚本与算法模块划分,结构清晰便于二次开发。目前已有 106 人学习下载。读者可据此掌握 Flask 项目组织方式、MySQL 数据存储、sklearn 聚类流程与 matplotlib、WordCloud 可视化落地方法,并参考现有页面快速搭建自己的音乐分析系统。

1. 从一堆散乱歌单到可解释的聚类:豆瓣音乐数据聚类分析可视化在解决什么

豆瓣音乐上随便一个用户,红心歌单拉出来就是几百上千首,标签横跨民谣、后摇、City Pop、古典、电子。想从这种数据里看出「我到底在听什么」,靠肉眼翻列表基本没戏。基于 Python+Flask 的豆瓣音乐数据聚类分析可视化,干的就是把这种非结构化的听歌记录,转成能算距离、能分组、能画在页面上的结构化数据,再用聚类算法把风格相近的歌自动归堆,最后通过 Flask 把结果渲染成可交互的图表。它适合两类人:一类是手里有爬下来的音乐数据、想练完整数据分析链路的 Python 学习者;另一类是想给自己或小团队做一个「音乐口味画像」工具的开发者。整条链路的核心不是算法多高深,而是数据清洗、特征工程和可视化呈现这三段能不能接住。

2. 数据从哪来、特征怎么定:豆瓣音乐数据的采集与清洗

2.1 采集边界与字段设计

豆瓣音乐没有开放的大规模数据接口,常见做法是用 requests + BeautifulSoup 对公开可见的专辑页、标签页做低频抓取,控制请求间隔,只取页面渲染出来的静态字段。我一般会锁定这几类字段:专辑名、表演者、发行时间、豆瓣评分、评价人数、风格标签、曲目列表。其中风格标签是最关键的聚类依据,因为它是文本型类别特征,能直接映射成向量。

采集时要注意,豆瓣的页面结构会变,选择器不能写死。稳妥的方式是把解析逻辑抽成独立函数,用 try/except 包住每个字段,缺字段就填 None,不要让一条脏数据把整批任务打断。

import requests from bs4 import BeautifulSoup import time import csv HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def parse_album(url): """解析单个专辑页,返回字段字典;任一字段缺失返回 None 占位""" resp = requests.get(url, headers=HEADERS, timeout=10) soup = BeautifulSoup(resp.text, "html.parser") data = {} try: data["title"] = soup.select_one("h1 span").get_text(strip=True) except AttributeError: data["title"] = None try: # 评分和评价人数通常在 info 区域 rating = soup.select_one("strong.rating_num") data["rating"] = float(rating.get_text(strip=True)) if rating else None except (AttributeError, ValueError): data["rating"] = None try: tags = soup.select("div.tags a") data["tags"] = "|".join(t.get_text(strip=True) for t in tags) except AttributeError: data["tags"] = "" return data def crawl(urls, out_path="music_raw.csv"): rows = [] for u in urls: row = parse_album(u) rows.append(row) time.sleep(2) # 低频抓取,降低对目标站的压力 with open(out_path, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=["title", "rating", "tags"]) writer.writeheader() writer.writerows(rows) if __name__ == "__main__": # urls 需自行准备,这里只演示结构 crawl([])

这段代码的逻辑很直白:每个字段单独 try,失败就降级成 None 或空串,保证 CSV 的列结构稳定。time.sleep(2)是硬性约束,不是可选项,抓取频率过高会直接触发封禁。HEADERS里只放常规浏览器标识,不要伪造登录态。参数上,timeout=10防止单页卡死拖垮整批任务,encoding="utf-8"避免中文标签乱码。

2.2 清洗与标签向量化

原始 CSV 拿到手,第一件事是去重和补空。专辑名重复的按评分保留一条,评分缺失的用同标签均值填充,标签为空的直接丢弃——没有风格标签的样本对聚类没有贡献。接下来是标签向量化,最省事的是 CountVectorizer 做 one-hot 式的多热编码,把「民谣|独立」拆成两个维度。

import pandas as pd from sklearn.feature_extraction.text import CountVectorizer df = pd.read_csv("music_raw.csv") df = df.dropna(subset=["tags"]) df = df.drop_duplicates(subset=["title"], keep="first") df["rating"] = df["rating"].fillna(df["rating"].mean()) # 标签用竖线分隔,先转成空格分隔的字符串 df["tag_text"] = df["tags"].str.replace("|", " ", regex=False) vectorizer = CountVectorizer(token_pattern=r"[^\s]+", binary=True) tag_matrix = vectorizer.fit_transform(df["tag_text"]) print("标签维度:", tag_matrix.shape) print("前 20 个标签:", vectorizer.get_feature_names_out()[:20])

binary=True表示只关心标签出现与否,不关心出现次数,这对风格标签更合理——一首歌标了「民谣」两次和一次没有区别。token_pattern=r"[^\s]+"是为了让中文标签不被默认的英文分词规则切碎。输出的tag_matrix是一个稀疏矩阵,行是专辑,列是标签,后面聚类直接吃这个矩阵。

提示:如果标签维度超过几千,稀疏矩阵会变得很宽,聚类前建议用TruncatedSVD降到 50~100 维,既提速又能去掉长尾噪声标签。

3. 聚类算法怎么选、参数怎么调:K-Means 与层次聚类的落地对比

3.1 为什么默认先上 K-Means

音乐标签数据的特点是维度高、样本量中等、簇形状大致呈球形分布,K-Means 在这种场景下性价比最高:实现简单、收敛快、结果容易解释。它的目标是最小化簇内平方和,迭代地把每个样本分配给最近的质心。缺点也明显——K 要预先指定,对异常值敏感,初始质心随机可能导致每次结果不一样。

我一般先用肘部法粗筛 K,再用轮廓系数确认。肘部法看的是簇内平方和随 K 下降的拐点,轮廓系数看的是样本与自身簇和其他簇的分离程度,两者结合比单看一个靠谱。

from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import matplotlib.pyplot as plt inertias, silhouettes = [], [] K_range = range(2, 11) for k in K_range: km = KMeans(n_clusters=k, init="k-means++", n_init=10, random_state=42) labels = km.fit_predict(tag_matrix) inertias.append(km.inertia_) silhouettes.append(silhouette_score(tag_matrix, labels)) fig, ax1 = plt.subplots(figsize=(8, 5)) ax1.plot(list(K_range), inertias, "o-", color="steelblue", label="簇内平方和") ax1.set_xlabel("聚类数 K") ax1.set_ylabel("Inertia") ax2 = ax1.twinx() ax2.plot(list(K_range), silhouettes, "s--", color="coral", label="轮廓系数") ax2.set_ylabel("Silhouette") plt.title("K 值选择:肘部法与轮廓系数") plt.show()

init="k-means++"让初始质心尽量分散,减少陷入局部最优的概率;n_init=10表示跑 10 次不同初始化取最优,这是稳定结果的后悔药;random_state=42保证复现。实际调参时,如果轮廓系数在某个 K 之后明显下滑,而肘部拐点也落在附近,就取那个 K。音乐标签数据常见的合理 K 在 4 到 8 之间,太少会把民谣和独立混在一起,太多会拆出只有一两首歌的碎簇。

3.2 层次聚类什么时候更合适

当你想看「哪些风格彼此更接近」的树状关系,而不是硬分组时,层次聚类更直观。它不需要预先定 K,通过凝聚方式自底向上合并,最后画成树状图,切在哪一层就得到对应数量的簇。代价是计算复杂度高,样本超过几千条就会明显变慢。

from scipy.cluster.hierarchy import linkage, dendrogram import matplotlib.pyplot as plt # 层次聚类对样本量敏感,先抽样 200 条演示 sample = tag_matrix[:200].toarray() Z = linkage(sample, method="ward") plt.figure(figsize=(12, 6)) dendrogram(Z, truncate_mode="lastp", p=20, leaf_rotation=90) plt.title("音乐标签层次聚类树状图(截断显示)") plt.xlabel("簇") plt.ylabel("合并距离") plt.tight_layout() plt.show()

method="ward"最小化合并后的方差增量,适合欧氏距离下的紧凑簇;truncate_mode="lastp"配合p=20只显示最后 20 个合并节点,否则几百条叶子标签会把图挤成一团黑。层次聚类的结果适合做探索性分析,真要上线到 Flask 页面,还是 K-Means 的标签更好用,因为每个样本有明确的簇编号。

3.3 聚类结果怎么落库

算完标签,要把「专辑—簇编号」的映射存下来,供 Flask 读取。最轻量的做法是写回 CSV 或 SQLite,不要为了一个可视化项目上重型数据库。

import sqlite3 df["cluster"] = KMeans(n_clusters=6, init="k-means++", n_init=10, random_state=42).fit_predict(tag_matrix) conn = sqlite3.connect("music.db") df[["title", "rating", "tags", "cluster"]].to_sql( "albums", conn, if_exists="replace", index=False ) conn.close()

if_exists="replace"每次重跑覆盖旧表,避免数据叠加。字段只存可视化需要的列,标签原文保留,方便前端做筛选。到这里,数据侧的准备就完成了,接下来交给 Flask。

4. Flask 后端与 ECharts 前端:把聚类结果画成能点的图

4.1 路由设计与数据接口

Flask 在这个项目里只干两件事:提供页面、吐出 JSON。不要把聚类计算放在请求里实时跑,那会让页面响应变得不可预测。正确做法是离线算好存库,Flask 只做查询和序列化。

from flask import Flask, render_template, jsonify import sqlite3 app = Flask(__name__) def query_db(sql, args=()): conn = sqlite3.connect("music.db") conn.row_factory = sqlite3.Row cur = conn.execute(sql, args) rows = [dict(r) for r in cur.fetchall()] conn.close() return rows @app.route("/") def index(): return render_template("index.html") @app.route("/api/clusters") def api_clusters(): """返回每个簇的专辑数量和平均评分""" sql = """ SELECT cluster, COUNT(*) AS cnt, ROUND(AVG(rating), 2) AS avg_rating FROM albums GROUP BY cluster ORDER BY cluster """ return jsonify(query_db(sql)) @app.route("/api/albums/<int:cluster_id>") def api_albums(cluster_id): sql = "SELECT title, rating, tags FROM albums WHERE cluster = ? LIMIT 50" return jsonify(query_db(sql, (cluster_id,))) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5000)

query_db把连接、游标、关闭封装成一个函数,避免每个路由重复写样板代码。jsonify自动处理中文编码,不用手动设ensure_ascii。/api/albums/<int:cluster_id>用路径参数接收簇编号,前端点击某个簇时按需拉取明细,而不是一次性把所有数据塞进页面。debug=True只在开发时开,上线要关掉,否则会暴露堆栈信息。

4.2 ECharts 渲染聚类散点与柱状图

前端用 ECharts 是最省事的选择,CDN 引入即可,不需要构建工具。页面加载时先请求/api/clusters画柱状图,点击柱子再请求对应簇的明细。

<!DOCTYPE html> <html lang="zh"> <head> <meta charset="utf-8"> <title>豆瓣音乐聚类可视化</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="bar" style="width:100%;height:400px;"></div> <div id="detail" style="width:100%;height:400px;"></div> <script> const barChart = echarts.init(document.getElementById('bar')); const detailChart = echarts.init(document.getElementById('detail')); fetch('/api/clusters').then(r => r.json()).then(data => { barChart.setOption({ title: { text: '各簇专辑数量与平均评分' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.map(d => '簇' + d.cluster) }, yAxis: [{ type: 'value', name: '数量' }, { type: 'value', name: '评分', max: 10 }], series: [ { name: '数量', type: 'bar', data: data.map(d => d.cnt) }, { name: '平均评分', type: 'line', yAxisIndex: 1, data: data.map(d => d.avg_rating) } ] }); // 点击柱子拉取该簇明细 barChart.on('click', params => { const cid = params.dataIndex; fetch('/api/albums/' + cid).then(r => r.json()).then(list => { detailChart.setOption({ title: { text: '簇' + cid + ' 专辑评分分布' }, tooltip: {}, xAxis: { type: 'category', data: list.map(a => a.title) }, yAxis: { type: 'value', max: 10 }, series: [{ type: 'bar', data: list.map(a => a.rating) }] }); }); }); }); </script> </body> </html>

柱状图和折线图共用 x 轴,双 y 轴分别表示数量和评分,这样一眼能看出「哪个簇歌多但评分低」。点击事件用dataIndex直接当簇编号,前提是后端返回的顺序和簇编号一致,所以 SQL 里必须ORDER BY cluster。明细图限制 50 条,避免专辑名太多导致横坐标挤成一团——这也是热词里「python 画图横坐标太密集」的典型场景,解法就是截断加旋转,或者改用横向条形图。

注意:ECharts 的 CDN 地址要选稳定版本,不要用 latest,否则某天上游更新可能让页面白屏。

5. 避坑与排查:聚类可视化项目里最容易翻车的五件事

5.1 现象:每次跑 K-Means 结果都不一样

原因:没有固定random_state,且n_init用了默认值,初始质心随机导致收敛到不同局部最优。解决:显式设random_state=42,并把n_init提到 10 以上,让算法多跑几次取最优。如果数据量小,可以直接用init="k-means++"配合固定随机种子,基本能保证复现。

5.2 现象:所有歌被分到一个簇,其他簇是空的

原因:标签向量化后维度极高但每行非零元素极少,样本间距离几乎相等,K-Means 找不到有意义的边界。解决:先做降维,用TruncatedSVD(n_components=50)压一遍再聚类;或者改用余弦距离的球形 K-Means。另一个常见原因是标签字段没清洗干净,混入了「想听」「听过」这类行为标签,它们和风格无关,会稀释信号,要在向量化前过滤掉。

5.3 现象:Flask 页面能打开但图表空白

原因:fetch请求的路径写错,或者后端返回的不是合法 JSON。排查顺序是先在浏览器直接访问/api/clusters看返回内容,如果是 HTML 报错页说明路由没匹配上;如果返回正常但图表不显示,检查 ECharts 容器的height是否设了具体值——容器高度为 0 是新手最常踩的坑,height:100%在父元素没高度时等于 0。

5.4 现象:中文标签在页面上显示成乱码

原因:SQLite 默认编码或 Flask 响应头没声明 UTF-8。解决:建库时确认用 UTF-8,jsonify在 Flask 新版本里默认就是 UTF-8,如果还乱码,检查 HTML 的<meta charset="utf-8">是否漏了。另外 CSV 读取时pd.read_csv要显式传encoding="utf-8",Windows 上默认可能是 GBK。

5.5 现象:抓取跑一半被封,后续请求全部超时

原因:请求频率过高或并发太多。解决:把time.sleep调到 2~3 秒,不要用多线程并发抓同一站点。已经拿到手的部分数据先落盘,不要等全部抓完再写文件,否则中断一次全白干。抓取任务要能断点续传,记录已完成的 URL 集合,重跑时跳过。

6. 让聚类结果更可信:轮廓系数验证与簇标签自动命名

聚类跑通只是第一步,结果可不可信、能不能讲清楚,才是这个项目值不值得继续投入的分水岭。我一般会做两件事:用轮廓系数做定量验证,用高频标签做定性命名。

轮廓系数的取值范围是 -1 到 1,越接近 1 表示样本离自己簇越近、离其他簇越远。低于 0.2 基本说明簇之间重叠严重,这时候要么降维、要么换特征、要么承认这批数据本身就不适合硬聚类。下面这段代码把每个簇的轮廓系数单独算出来,能定位到具体是哪个簇拖了后腿。

from sklearn.metrics import silhouette_samples import numpy as np labels = df["cluster"].values sil_vals = silhouette_samples(tag_matrix, labels) for c in sorted(set(labels)): mask = labels == c print(f"簇 {c}: 样本数={mask.sum()}, " f"平均轮廓系数={sil_vals[mask].mean():.3f}")

如果某个簇的轮廓系数明显偏低,通常是它和相邻簇的标签高度重叠,比如「民谣」和「独立民谣」被拆成两个簇。这时候可以考虑合并,或者回到特征工程阶段,把过于细碎的标签归并成上位标签。

定性命名更简单:对每个簇,统计簇内出现频率最高的标签,取前三个拼成簇名。

from collections import Counter for c in sorted(set(labels)): sub = df[df["cluster"] == c] tags = " ".join(sub["tags"].fillna("")).split("|") top3 = [t for t, _ in Counter(tags).most_common(3) if t] print(f"簇 {c} 建议命名:{' / '.join(top3)}")

这样得到的名字比如「民谣 / 独立 / acoustic」,比干巴巴的「簇 0」直观得多,前端展示时直接把这个名字传给 ECharts 的 x 轴,用户一眼就知道这堆歌是什么风格。

还有一个容易被忽略的点:评分字段要不要参与聚类。我的经验是不要。评分是连续值,和标签的二元特征量纲差太多,直接拼在一起会让评分主导距离计算,最后分出来的簇变成「高分簇」和「低分簇」,而不是风格簇。评分更适合作为聚类后的分析维度,比如看哪个风格簇的平均评分最高,而不是作为聚类输入。

最后说个习惯:每次调整特征或参数后,把 K 值、轮廓系数、簇数量记在一个小本子上,跑个五六轮再决定最终方案。我早期图省事,改完参数直接看散点图觉得「差不多」,结果上线后被问「为什么这两堆看起来一样却分成两类」,答不上来。后来养成记录的习惯,每个决策都有数字支撑,别人问起来也能说清楚。希望帮到你。

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

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

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

立即咨询