
简介这是一份基于 Python 与 Flask 的豆瓣音乐数据聚类分析可视化毕业设计项目面向 Web 开发、数据采集与机器学习方向的学生提供从爬虫抓取、MySQL 存储、K-Means 聚类到图表展示的完整实现思路。系统包含用户与管理两种角色覆盖登录注册、音乐检索、后台数据管理、词云生成等模块并通过 Flask pymysql sklearn matplotlib 的组合完成核心业务能够体现全栈开发与数据分析的综合能力。压缩包共 74 个文件大小约 2.07MB其中 HTML/CSS/JS 构成前端页面Python 脚本与 SQL 文件对应爬虫、聚类算法及数据库初始化字体图标等静态资源也已一并打包目录划分清晰便于直接运行和二次开发。目前已有 106 人学习下载借助该资源可以快速掌握豆瓣音乐数据的清洗入库、K-Means 聚类流程、可视化图表绘制以及后台权限管理的关键代码适合作为毕业设计参考或课程项目练手。1. 这个项目到底在做什么从豆瓣音乐到聚类可视化的完整链路很多人拿到“豆瓣音乐数据”的第一反应是直接跑一个 KMeans然后画一张散点图就算聚类分析完成。但真上手你会发现数据里全是艺术家名、风格标签、发行年份、评分和成千上万的听感描述根本没法直接喂给聚类算法。这个项目做的就是一条完整的链路用 Python 把豆瓣音乐条目抓下来清洗成结构化表格抽取出可用于聚类的特征用聚类分析把音乐分成若干风格簇最后用 Flask 搭一个轻量后端把聚类结果通过可视化图表展示在网页上。适合正在做 Python 数据分析与可视化课设、简历项目或者想搞明白“爬虫 聚类 Flask ECharts”怎么串起来的人。它的核心价值不在某一个算法多高深而是让你从头到尾跑通数据采集、特征工程、聚类建模和 Web 展示这条主线中间每一个环节都有看得见的输出也踩得到真实的坑。2. 数据从哪来豆瓣音乐爬虫与数据清洗的落地细节2.1 豆瓣音乐页面结构与爬虫选型做这个项目第一步是拿数据。常见做法是抓豆瓣音乐的榜单页或搜索页的条目信息包括唱片标题、艺术家、评分、评分人数、发行年份、流派标签、简介文本。豆瓣页面结构不算复杂但反爬机制一直存在所以不能上来就满速抓取。我这里一般用 requests BeautifulSoup 做静态页面解析因为豆瓣音乐条目页大多数内容都在 HTML 里不需要动态渲染。抓取时用一个简单的 Session 保持会话并设置真实的 User-Agent避免直接被拒绝。为了不给对方服务器造成压力单次抓取后固定 sleep 1 到 3 秒请求频率控制在每秒不超过 1 次。import requests import time from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def fetch_page(url, sessionNone): s session if session else requests.Session() try: resp s.get(url, headersheaders, timeout10) resp.raise_for_status() resp.encoding utf-8 return resp.text except requests.exceptions.RequestException as e: print(f请求失败: {e}) return None # 示例抓取一个豆瓣音乐条目页 url https://music.douban.com/subject/xxxxx/ html fetch_page(url) if html: soup BeautifulSoup(html, html.parser) title soup.select_one(h1 span).text.strip() ...这段代码做了三件事用 Session 发起请求、把页面编码强制转成 UTF-8、用 BeautifulSoup 定位标题。需要注意requests.Session会复用底层连接在连续抓取时更有效率但如果你遇到被重定向到验证页那就要检查 headers 里有没有带上 Referer 字段豆瓣经常会检查来源。timeout 参数必须设置否则某个条目卡住会导致整个爬虫线程阻塞。2.2 清洗与去重把脏数据变成可聚类的特征矩阵爬下来的原始数据有大量噪音有的唱片没有发行年份有的风格标签是“未知”还有同一个专辑因为版本不同出现重复条目。这些数据直接做聚类会让聚类分析结果完全失真所以清洗这一步不能省。我通常先做字段小写化、去空格然后按专辑标题 艺术家 发行年份三列联合去重重复的只保留评分人数最多的那条。对于缺失值数值型字段用中位数填充文本字段填“未知”。风格标签很可能有多个例如“流行 / 电子”我会把它拆开放进一个列表方便后面做词频向量化。import pandas as pd df pd.read_csv(douban_music.csv, dtypestr) # 去除首尾空格统一大小写 string_cols [title, artist, genre, intro] for col in string_cols: df[col] df[col].str.strip().str.lower() # 联合去重 df df.drop_duplicates(subset[title, artist, year], keeplast) # 数值字段转类型缺失值用中位数填充 df[rating] pd.to_numeric(df[rating], errorscoerce) df[rating_people] pd.to_numeric(df[rating_people], errorscoerce) df[year] pd.to_numeric(df[year], errorscoerce) df[rating] df[rating].fillna(df[rating].median()) df[rating_people] df[rating_people].fillna(df[rating_people].median()) df[year] df[year].fillna(df[year].median())这里的errorscoerce非常关键它会把无法解析的字符串变成 NaN而不是直接让程序报错。联合去重要放在填充缺失值之前否则你填充完数据后重复行会因为填充值不同而无法准确识别。去重时keeplast意味着保留最后一个重复项这通常是你爬虫里最后一次抓取到的最完整版本。2.3 存储方案为什么我选 SQLite 而不是 MySQL清洗后的数据量一般也就是几千到几万条完全没必要上 MySQL。SQLite 作为 Python 内置的 SQL 数据库零配置、单文件、支持标准 SQL后续 Flask 读取也特别方便。你不需要单独装数据库服务把数据写进一个.db文件项目移动位置时只要带上这个文件就能跑。import sqlite3 conn sqlite3.connect(douban_music.db) cur conn.cursor() cur.execute( CREATE TABLE IF NOT EXISTS music ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, artist TEXT, year REAL, rating REAL, rating_people REAL, genre TEXT, intro TEXT ) ) # 按行插入使用 executemany 批量写入 data_tuples list(map(tuple, df[[title, artist, year, rating, rating_people, genre, intro]].values)) cur.executemany(INSERT INTO music (title, artist, year, rating, rating_people, genre, intro) VALUES (?, ?, ?, ?, ?, ?, ?), data_tuples) conn.commit() conn.close()表结构里id自增主键用于后续快速索引year、rating存成 REAL 是因为清洗后可能有小数或缺失填充值。如果你后续要用 Flask 做筛选建议在rating和year字段上创建索引CREATE INDEX idx_music_rating ON music(rating); CREATE INDEX idx_music_year ON music(year);索引不一定会让聚类变快但会让你在 Web 端按评分区间筛选时响应明显更快。3. 聚类分析怎么做KMeans 与 PCA 降维的参数实践3.1 特征工程把文本和数值转成向量聚类分析不能直接吃文本需要把每张唱片变成一个向量。数值字段如评分、评分人数、发行年份直接标准化后放进特征矩阵。风格标签和简介文本则用 TF-IDF 向量化因为 TF-IDF 能降低“流行”“音乐”这种常见词的权重让真正有区分度的风格词起主导作用。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score import numpy as np # 数值特征 numeric_features df[[rating, rating_people, year]].values scaler StandardScaler() numeric_scaled scaler.fit_transform(numeric_features) # 文本特征风格 简介拼在一起 df[text] df[genre].fillna() df[intro].fillna() vectorizer TfidfVectorizer(max_features2000, ngram_range(1, 2), stop_wordsenglish) text_vectorized vectorizer.fit_transform(df[text]).toarray() # 合并特征 X np.hstack([numeric_scaled, text_vectorized])max_features2000是控制文本向量维度的手段如果你的语料很大不限制的话维度可能会上万不仅内存占用高聚类速度也会很慢而且大部分都是低频噪音。ngram_range(1, 2)能让模型识别“独立音乐”这种双词组合但也会增加一定维度和稀疏性。数值特征标准化是必须的否则评分范围只有 0 到 10评分人数可能是几千到几十万KMeans 会完全被评分人数主导聚类出来的簇基本就是“热门唱片”和“冷门唱片”与风格无关。3.2 聚类数 k 怎么定肘部法则与轮廓系数KMeans 需要提前指定 K胡乱填一个 5 或者 8 会给可视化埋下大坑。常见的定 K 方法是肘部法则对 K 从 2 到 10 逐个计算簇内误差平方和SSE画一条曲线看哪个点下降趋势明显放缓那个点就是“肘部”。只靠肘部法则还不够因为有时曲线很平滑没有明显拐点。这时候我会叠加轮廓系数它衡量每个样本与自身簇的相似度是否高于与其他簇的相似度取值在 -1 到 1 之间越接近 1 说明聚类越合理。sse [] silhouette [] K_range range(2, 11) for k in K_range: km KMeans(n_clustersk, random_state42, n_init10) labels km.fit_predict(X) sse.append(km.inertia_) sil silhouette_score(X, labels) silhouette.append(sil) # 打印每个 K 对应的指标 for k, s, sil in zip(K_range, sse, silhouette): print(fK{k}, SSE{s:.2f}, 轮廓系数{sil:.4f})random_state42保证每次运行选初始质心的结果一致别人复现你项目时不会因为随机种子不同而得到完全不同的簇。n_init10表示 KMeans 会从 10 次随机初始化中选最佳结果避免陷入局部最优。轮廓系数最高的 K 不一定就是最好的还要看业务上是否可解释。比如轮廓系数最高是 K7但在可视化散点图里 7 簇重叠严重而 K5 的簇边界更干净那我就会选 K5。聚类不是数学题是可解释性和参数指标的平衡。3.3 PCA 降维保留多少主成分才不丢信息特征向量维度可能有两千多维直接画散点图是不可能的。PCA 把高维特征映射到低维空间让聚类结果能在 2D 或 3D 图形里展示。但 PCA 会丢失信息一般我们保留累计解释方差 80% 到 90% 的主成分。pca_2d PCA(n_components2, random_state42) X_pca_2d pca_2d.fit_transform(X) explained pca_2d.explained_variance_ratio_ print(前两个主成分累计解释方差比:, explained.sum())如果发现前两个主成分只解释了 30% 的方差说明靠 2D 图看聚类效果会不可靠你可能需要降到 3 维或者用 T-SNE 做非线性降维。T-SNE 在可视化上经常比 PCA 聚类边界更清晰但 T-SNE 只适合可视化不适合作为聚类预处理输入因为它会改变样本间的距离关系。# 降维后的数据与聚类结果合并 df[cluster] km.fit_predict(X) df[pc1] X_pca_2d[:, 0] df[pc2] X_pca_2d[:, 1] df.to_csv(clustered_music.csv, indexFalse)这一步输出clustered_music.csv里面每一行是一张唱片带上簇编号和两个主成分坐标。后续 Flask 直接读这个 CSV 或者读数据库里对应字段就能展示。4. Flask 可视化层从后端接口到 ECharts 大屏4.1 Flask 应用结构与蓝图划分到了这一步数据集已经带有簇标签和降维坐标接下来要做的是用 Flask 把聚类结果以网页形式展示出来。我不建议把路由、数据库操作、页面模板全塞进一个app.py因为聚类项目后续必然要加筛选、详情页、图表切换代码会很快膨胀。常见做法是使用 Flask 蓝图划分模块。# app.py from flask import Flask, render_template from views import music_bp app Flask(__name__) app.register_blueprint(music_bp, url_prefix/music) app.route(/) def index(): return render_template(index.html) if __name__ __main__: app.run(debugTrue, host0.0.0.0, port5000)url_prefix/music意味着所有在views.py里的路由都会挂在/music下比如/music/clusters。debugTrue适合本地开发能看到完整报错栈但部署到生产环境必须关掉。host0.0.0.0允许局域网内的机器访问方便你在手机上打开页面检查可视化效果。4.2 用 ECharts 展示聚类散点图与热力图Flask 的主要工作是从数据库或 CSV 中读取聚类结果返回给前端。我在views.py里写一个专门提供聚类数据的接口返回 JSON 格式前端用 ECharts 异步加载。这样后端只做数据服务页面渲染全部交给 ECharts职责很清晰。# views.py from flask import Blueprint, jsonify import pandas as pd music_bp Blueprint(music_bp, __name__) music_bp.route(/clusters) def clusters(): df pd.read_csv(clustered_music.csv) # 按簇分组只取每簇的样本点坐标和标题 data [] for cluster_id, group in df.groupby(cluster): points group[[pc1, pc2, title, artist]].values.tolist() data.append({ cluster: int(cluster_id), points: points }) return jsonify(data)这段代码把每个簇的样本点按组输出前端拿到后可以直接用不同颜色绘制散点图。注意groupby(cluster)前要先确认 CSV 里的cluster列是整数否则容易出现浮点聚类标签导致前端颜色映射出错。前端页面用 CDN 引入 ECharts然后通过fetch拉到接口数据再渲染fetch(/music/clusters) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(clusterChart)); const series data.map(item ({ name: Cluster item.cluster, type: scatter, data: item.points.map(p [p[0], p[1]]), symbolSize: 8 })); chart.setOption({ tooltip: { trigger: item, formatter: params { const d params.data; return d[2] br/ d[3]; } }, xAxis: { type: value }, yAxis: { type: value }, series: series }); });symbolSize: 8控制散点大小当样本数超过几千时这个值太大了会糊成一团我会改成 4 或 5。tooltip 的 formatter 里直接返回标题和艺术家这样鼠标悬停时能具体看到是哪张唱片而不是只看到一个坐标点。ECharts 默认图例太多时会重叠如果你的簇数量超过 8 个建议把图例放在页面底部分开显示。4.3 把聚类结果做成可交互筛选的可视化大屏上一步的散点图已经完成基本可视化但如果你想更进一步可以在页面里加一些筛选器按评分区间、发行年份范围、簇编号来动态切换展示的样本。这样才算是一个完整的交互式可视化大屏而不是一张静态图。实现思路是给前端传两个参数年份区间和评分下限。后端接口接收参数后在返回数据前先过滤。music_bp.route(/clusters) def clusters(): df pd.read_csv(clustered_music.csv) min_year request.args.get(min_year, default1990, typeint) max_year request.args.get(max_year, default2024, typeint) min_rating request.args.get(min_rating, default0, typefloat) df df[(df[year] min_year) (df[year] max_year) (df[rating] min_rating)] data [] for cluster_id, group in df.groupby(cluster): ... return jsonify(data)前端在用户拖动年份滑块时重新发起一次fetch(/music/clusters?min_year...max_year...)然后更新图表。这里的性能瓶颈不大即使几千条数据也够用。但要注意如果 CSV 文件很大每次筛选都重新读 CSV 会慢更好的做法是在 Flask 启动时把数据一次性加载到内存或者查询 SQLite 而不是读 CSV。5. 避坑指南豆瓣封IP、中文字体乱码、聚类失效的 5 个排查记录5.1 爬虫抓了一半突然所有请求都被重定向到验证页面现象前 200 条数据抓得正常之后所有请求返回的 HTML 变成一个简单的验证提示页解析不到任何音乐条目。原因豆瓣的反爬机制检测到同一 IP 在短时间内的请求频率异常直接把请求重定向验证页。最常见的触发场景是不管三七二十一连续抓取中间不暂停。解决把请求间隔从 1 秒调到 3 到 5 秒并在请求头里补充Referer字段。同时做一个爬虫失败自动重试机制检测到页面没有预期标题时立即休眠 30 秒。更稳妥的方案是使用 IP 代理池轮换但个人学习项目没必要上代理压低频率足够。5.2 抓下来的简介文本全是乱码或问号现象页面解析正常但部分音乐条目的简介文本在终端打印出来是????写进 CSV 后打开全是汉字变问号。原因豆瓣页面虽然声明 UTF-8但部分老旧条目页面或某些特殊字符用了其他编码或者在使用 Requests 时没有显式设置resp.encoding导致用默认编码解析出错。解决强制指定resp.encoding utf-8并且在写入 CSV 时指定encodingutf-8-sig。utf-8-sig会在文件头部加入 BOM让 Excel 打开时不会乱码。如果你用 pandas 写文件也在to_csv里加上这个参数。5.3 聚类出来的结果全挤在一团散点图看不出任何簇现象PCA 降维后所有点都聚在一个大圆球里KMeans 的簇边界完全看不出来轮廓系数也非常低大概在 0.05 左右。原因文本向量化后特征空间极其稀疏而数值特征只有 3 维两部分的量纲差异太大。如果数值特征没有标准化或者文本向量的权重过强KMeans 在计算欧氏距离时会被高维稀疏向量主导所有样本的距离都差不多自然聚不出有意义的簇。解决先对数值特征做StandardScaler并给文本特征和数值特征设置不同权重。比如把数值特征矩阵乘以 3让它们的影响力提升或者把文本向量化改为只使用风格标签不使用大段介绍减少特征噪音。另一个有效做法是先用 PCA 降到 50 维再在这个基础上做聚类降低稀疏性影响。5.4 同一张唱片的风格标签被拆成多个簇业务上解释不通现象一个“民谣 轻音乐”的唱片聚类结果和纯民谣、纯轻音乐都不同被单独分到一个只有一两张的小簇里这些零散的小簇在可视化里特别扎眼。原因KMeans 容易受到异常点和多标签混合样本的影响。混合风格在特征空间里离单一风格簇都很远被迫自成一簇或归入最近但仍有较大距离的簇。解决在特征工程阶段对一个样本有多个风格标签的情况做拆分处理把每个风格标签单独作为一条样本进行聚类最后再把聚类结果回映射到原唱片上。另一种做法是调整 KMeans 的n_init和max_iter或者干脆使用带噪声的密度聚类 DBSCAN它对不规则簇更友好但调参需要花时间。5.5 Flask 启动后页面能打开但接口返回 JSON 时中文变成unicode转义字符现象浏览器里访问/music/clusters返回的数据是{ title: \u53e0\u76d6\u5409\u4ed6... }这样的形式前端拿到后显示正常但接口调试器里很丑而且部分字符显示乱码。原因Flask 的jsonify默认使用ensure_asciiTrue所有非 ASCII 字符都会被转成\uXXXX这符合 JSON 规范但浏览器直接打开时看起来不舒服。真正的问题是如果前端代码没有正确解码某些特殊字符会显示成乱码。解决如果只是后端调试可以在app.py里配置app.json.ensure_ascii False这样jsonify返回的 JSON 会保留中文原样接口调试器直接可读。注意这个配置在 Flask 2.2 之后可以直接设置更早版本可能需要重写JSONEncoder。前端的话确保 JS 文件本身是 UTF-8 编码并在meta charsetutf-8里声明就不会出现中文乱码。6. 跑通后的验证清单与下一步扩展方向6.1 怎么验证你的聚类是真的“有效”很多人画完散点图就觉得大功告成但聚类分析的有效性必须回到业务数据和客观指标上。我先看轮廓系数然后随机从每个簇抽 3 到 5 条记录人工读专辑标题和流派检查它们是否真的属于同一类。比如簇 0 里全是“后摇”“氛围音乐”簇 1 里全是“重金属”“硬核”那聚类就是有意义的。如果某个簇里面既有周杰伦又有巴赫说明特征工程或 K 值还需要调整。验证这一步别偷懒它是你在答辩或写项目文档时唯一能站得住脚的证据。6.2 更进一步的扩展方向这个项目骨架搭好后扩展很自然。如果你有时间和算力可以把聚类从 KMeans 换成 BERT 对简介文本做语义向量化再用 HDBSCAN 聚类得到的风格簇会比 TF-IDF 更贴近语义。但这样做会明显增加复杂度需要先装sentence-transformers在本地或 CPU 上跑几百条数据还能接受上万条就要考虑 GPU 了。另一个更轻量的扩展是给每个簇生成一份“关键词画像”用 TF-IDF 载入每个簇的文本输出权重最高的 10 个词展示在 Flask 页面上让用户一眼看出这个簇是什么风格。最后如果项目要部署给别人看把debugTrue关掉用 gunicorn 跑 Flask 应用并把静态资源交给 Nginx 处理不要直接用 Flask 提供大体积文件否则并发一上来就卡住。做这个项目给我最大的教训是不要指望别人给的数据能直接聚类数据清洗花的时间永远比写算法多。另一个习惯是给每个环节都加时间戳和随机种子比如random_state42这样整个项目的每一步都可以复盘不会因为随机性导致一次跑出一个结果、下次又变样。希望这套从爬虫到可视化的完整流程能帮到你让你少踩我踩过的坑。本文还有配套的精品资源点击获取