简介:本资源是一个面向人工智能初学者与项目实践者的音乐推荐系统完整工程,融合用户画像构建与基于用户的协同过滤算法,解决个性化音乐推荐中的冷启动与精度提升问题。项目基于KKBox公开竞赛数据集实现,采用Python3开发,Django框架搭建前后端,MySQL存储核心数据,并引入SVD矩阵分解优化相似度计算与评分预测。压缩包共74个文件,含30个Python核心逻辑脚本(如recommend.py、models.py)、8个HTML模板页、9个CSS样式文件、7个JS交互脚本及2个MP3示例音频,另有Dockerfile、docker-compose.yml等容器化配置,整体12.84MB,结构清晰便于模块化学习与二次开发。目前已有363人学习下载,提供从数据预处理(genre_proc.py)、画像特征提取(replace_genre_lang.py)到推荐服务部署的全流程可运行代码,附带SQLite本地数据库与完整静态资源,开箱即用,适合AI项目实战训练与推荐系统原理验证。
1. 这不是“猜你喜欢”,而是用用户画像锚定协同过滤的冷启动破局点
你打开一个音乐App,首页推荐列表里突然出现一首你从没听过、但听完三秒就收藏的歌——它背后大概率不是靠“你听过的歌相似的人也听了这首歌”这种纯行为关联,而是先确认了“你是24岁、常驻成都、通勤地铁单程42分钟、过去7天深夜23:00–01:00活跃度峰值明显、偏好粤语老歌与独立电子混搭”的用户画像标签,再在这个画像切片内跑协同过滤。本项目正是把这两层逻辑拧在一起:用户画像不是静态标签墙,而是协同过滤的约束域和加权因子;协同过滤也不是盲目找邻居,而是在画像定义的语义子空间里计算相似度。它不依赖海量用户重叠行为,对新用户、小众曲库、长尾歌手更友好;也不靠黑盒深度模型,所有特征可解释、权重可调试、推荐路径可回溯。适合正在做课程设计、毕设或企业内部轻量推荐模块的Python/Django开发者,尤其当你手头只有KKBox公开数据集(含用户播放序列、付费状态、注册时长、设备类型等20+维度)且不想直接套用LightFM或Surprise库默认参数时——这个项目就是一份带完整数据预处理链路、双路融合策略、Django服务封装的可调试样板。
2. 用户画像构建:从原始行为日志到可参与协同计算的结构化向量
2.1 为什么不能直接用原始字段做画像?——缺失值、稀疏性与语义鸿沟
KKBox数据集包含members.csv(用户基础属性)、transactions.csv(付费记录)、user_logs.csv(播放日志)三张核心表。直接拼接gender、city、bd(生日)字段生成one-hot向量会立刻暴露出三个硬伤:
gender字段缺失率达63%,简单填充“unknown”会导致该维度在余弦相似度计算中贡献为负;city有超过1200个离散值,one-hot后向量维度爆炸(>1200),而单个用户仅激活2–3个bit;bd(生日)若转为年龄,需处理闰年、时区、隐私脱敏(项目中实际采用age_group分段:18–24, 25–34, 35–44, 45+),否则数值型直接参与距离计算会产生尺度偏差。
提示:项目中
replace_genre_lang.py和genre_proc.py脚本的存在,恰恰说明原始genres.csv存在多语言混杂(如“R&B”与“节奏蓝调”并存)、层级混乱(“Jazz”下混入“Smooth Jazz”和“Jazz Fusion”)问题。不清洗就直接用于画像标签,协同过滤算出的“相似用户”可能只是因为都听过同一堆乱码标签。
2.2 实际采用的四层画像构建法:统计层 + 行为层 + 偏好层 + 加权层
项目在models.py中定义的UserProfile模型并非简单继承User,而是通过以下四步生成最终画像向量:
2.2.1 统计层:人口学特征的平滑编码
# models.py 片段 class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) # 年龄分段(避免数值尺度干扰) age_group = models.CharField(max_length=10, choices=[ ('18-24', '18-24'), ('25-34', '25-34'), ('35-44', '35-44'), ('45+', '45+') ]) # 城市热度归一化(非one-hot,而是按城市用户数排名取log) city_popularity = models.FloatField() # 计算逻辑见 genre_proc.py 第47行city_popularity值由genre_proc.py中calculate_city_popularity()函数生成:先统计各城市用户总数,取自然对数后线性映射到[0,1]区间。这样北京、上海用户权重≈0.98,而小城市用户不会因one-hot稀疏被淹没。
2.2.2 行为层:播放序列的TF-IDF压缩
用户播放日志user_logs.csv被转化为{song_id: play_count}字典,但直接用播放次数做向量会导致热门歌曲(如周杰伦单曲)主导相似度。项目采用改进TF-IDF:
- TF=
log(1 + play_count)(抑制高频项) - IDF=
log(total_users / users_who_played_this_song)(突出小众偏好) - 最终向量维度控制在512维(通过
scikit-learn的TruncatedSVD降维,n_components=512),保存在UserProfile.play_vector字段(BinaryField存储pickle序列化结果)。
2.2.3 偏好层:基于SVD的隐因子提取
这才是协同过滤的核心输入。项目未使用surprise.SVD,而是自实现矩阵分解:
# recommend.py 第112行 def svd_decomposition(rating_matrix, k=50): """rating_matrix: scipy.sparse.csr_matrix, shape=(n_users, n_songs)""" u, s, vt = svds(rating_matrix, k=k, solver='arpack') # arpack比lobpcg更稳定 # 对奇异值进行幂律衰减加权:s[i] *= (0.95 ** i) weighted_s = s * np.power(0.95, np.arange(len(s))) return u @ np.diag(weighted_s), vt.Tk=50是经验值——KKBox数据集用户数≈20万,歌曲数≈300万,k<100时RMSE下降趋缓,k>60内存占用陡增。关键在s[i] *= (0.95 ** i)这行:强制隐因子按重要性衰减,避免第49维噪声干扰推荐。
2.2.4 加权层:画像向量与SVD向量的融合系数
最终用户向量 =α × 统计向量 + β × 行为向量 + γ × SVD向量,其中α=0.2,β=0.3,γ=0.5。该系数在settings.py中配置,可通过A/B测试调整。例如:对新注册用户(is_new=True),动态提升β至0.6——因为其统计信息少,行为序列更可信。
| 向量类型 | 维度 | 存储位置 | 更新频率 |
|---|---|---|---|
| 统计向量 | 12 | UserProfile数据库字段 | 用户注册/资料更新时 |
| 行为向量 | 512 | UserProfile.play_vector二进制字段 | 每日定时任务(manage.py update_play_vectors) |
| SVD向量 | 50 | 内存缓存(recommend.py全局变量) | 每周全量重训练(python manage.py train_svd) |
3. 协同过滤融合机制:在画像约束下重定义“邻居”与“相似度”
3.1 传统协同过滤的失效场景:当“相似用户”根本不存在
标准UserCF算法要求:对目标用户u,找出与其共同评分歌曲数≥5的用户集合N(u),再按皮尔逊相关系数排序取Top-K。但在KKBox数据中:
- 72%的用户只听过≤3首歌;
- 两个用户共同听过的歌曲中位数=0;
- 即使强行计算,皮尔逊系数在稀疏矩阵下标准差>0.4,不可信。
项目彻底放弃“共同评分”前提,改为画像引导的邻居发现:
- 先用
age_group、city_popularity、play_vector三类特征,在UserProfile表中执行近似最近邻(ANN)搜索; - 限定候选邻居池大小为500(
settings.RECOMMEND_NEIGHBOR_POOL=500); - 在此池内,才用修正的余弦相似度计算协同得分。
3.2 修正余弦相似度:剔除用户偏置与歌曲偏置
标准余弦相似度对评分均值敏感。项目采用如下公式(见recommend.py第287行):
$$ \text{sim}(u,v) = \frac{\sum_{i \in I_{uv}} (r_{ui} - \bar{r}u)(r{vi} - \bar{r}v)}{\sqrt{\sum{i \in I_{uv}} (r_{ui} - \bar{r}u)^2} \cdot \sqrt{\sum{i \in I_{uv}} (r_{vi} - \bar{r}_v)^2}} $$
其中:
- $I_{uv}$ 是用户u和v共同听过的歌曲集合;
- $\bar{r}_u$ 是用户u所有播放记录的加权平均分(权重=播放时长/总时长);
- $\bar{r}_v$ 同理;
- 关键改进:
r_{ui}不是原始评分(KKBox无显式评分),而是播放完成度比率:min(1.0, play_duration / song_duration)。这比单纯“是否播放”更能反映偏好强度。
3.3 双路推荐生成:画像召回 + 协同精排
整个推荐流程分两阶段(views.py中get_recommendations()函数):
3.3.1 第一阶段:画像召回(Recall)
- 输入:当前用户画像向量(12+512+50=574维);
- 方法:FAISS索引(
faiss.IndexFlatIP)搜索Top-200相似用户; - 输出:这200人听过的所有歌曲去重后,按
popularity_score(播放次数×付费转化率)降序取Top-1000作为候选集。
3.3.2 第二阶段:协同精排(Ranking)
- 对候选集中的每首歌s,计算:
$$ \text{score}(s) = \sum_{v \in N(u)} \text{sim}(u,v) \times r_{vs} $$ - 其中$r_{vs}$是用户v对歌曲s的播放完成度比率;
- 为防热门歌曲垄断,加入多样性惩罚项:若歌曲s所属流派g已在Top-10中出现≥2次,则
score(s) *= 0.7(见recommend.py第356行)。
# views.py 片段:推荐主逻辑 def get_recommendations(request): user_profile = request.user.userprofile # 阶段1:画像召回(FAISS搜索) recall_pool = faiss_search(user_profile.get_combined_vector(), k=200) candidate_songs = get_candidate_songs(recall_pool) # Top-1000 # 阶段2:协同精排(带流派多样性惩罚) ranked_scores = [] for song in candidate_songs: base_score = 0 for neighbor in recall_pool: if song.id in neighbor.play_history: base_score += neighbor.similarity_with_user * neighbor.play_history[song.id] # 流派惩罚 if song.genre in [s.genre for s in ranked_scores[:10]]: if genre_count(song.genre, ranked_scores[:10]) >= 2: base_score *= 0.7 ranked_scores.append((song, base_score)) return sorted(ranked_scores, key=lambda x: x[1], reverse=True)[:20]注意:
faiss_search()函数在recommend.py中封装,初始化时自动加载faiss_index.bin(由python manage.py build_faiss_index生成)。若服务器无GPU,需在settings.py中设置FAISS_CPU_ONLY=True,否则IndexFlatIP会报CUDA内存错误。
4. Django服务化部署:从本地调试到生产环境的参数调优清单
4.1 数据库优化:SQLite3仅限开发,MySQL才是生产标配
项目自带db.sqlite3和db.sqlite3.bak,但这仅用于快速验证。生产必须切换至MySQL,否则:
- 并发写入时
sqlite3锁表导致推荐接口超时(实测QPS>15即失败); UserProfile.play_vector二进制字段在SQLite中查询缓慢(BLOB类型无索引);user_logs表单日增量超50万行,SQLite无法支撑。
迁移步骤(settings.py修改):
# settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'music_recommender', 'USER': 'recommender_user', 'PASSWORD': os.getenv('DB_PASSWORD'), 'HOST': 'mysql-server', # Docker Compose中服务名 'PORT': '3306', 'OPTIONS': { 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", 'charset': 'utf8mb4', }, 'TEST': { 'CHARSET': 'utf8mb4', 'COLLATION': 'utf8mb4_unicode_ci', } } }提示:
CHARSET=utf8mb4必须显式声明,否则KKBox数据中的emoji歌手名(如“ØZI”)会插入失败。init_command禁用宽松模式,避免NULL值被静默转为0。
4.2 Docker Compose服务编排:分离计算与Web层
docker-compose.yml定义了三服务:
web: Django应用(Dockerfile基于python:3.9-slim);mysql: MySQL 8.0(挂载./mysql-data:/var/lib/mysql);redis: 用于缓存FAISS索引与SVD矩阵(REDIS_URL=redis://redis:6379/1)。
关键环境变量(.env文件):
DEBUG=False SECRET_KEY=your-secret-key-here DB_PASSWORD=strong_password REDIS_URL=redis://redis:6379/1 FAISS_INDEX_PATH=/app/data/faiss_index.bin SVD_MODEL_PATH=/app/data/svd_model.pkl4.3 性能压测与瓶颈定位:三个必查指标
部署后执行locust压测(locustfile.py已内置):
locust -f locustfile.py --host=http://localhost:8000 --users=100 --spawn-rate=10重点关注:
| 指标 | 健康阈值 | 定位方法 | 优化方案 |
|---|---|---|---|
GET /recommend/P95延迟 | <800ms | django-silk中间件监控 | 将faiss_search()移至Celery异步任务,前端轮询 |
| MySQL慢查询数 | <3次/分钟 | SHOW FULL PROCESSLIST;+pt-query-digest | 对userprofile_play_vector字段添加GENERATED COLUMN虚拟列并建索引 |
| Redis内存使用率 | <70% | redis-cli info memory | grep used_memory_percent | 设置FAISS_INDEX_TTL=3600,每小时自动重建索引 |
4.4 推荐效果验证:不用A/B测试也能看懂的三个指标
在admin.py中注册RecommendationLog模型后,后台可导出日志分析:
- 覆盖率(Coverage):推荐列表中歌曲占全库比例。健康值:15%–25%(过低说明冷启动差,过高说明多样性不足);
- 新颖性(Novelty):推荐歌曲平均流行度(播放量排名)的倒数。值越高越新颖,但>0.8时用户接受度断崖下跌;
- 惊喜度(Serendipity):用户最终播放的歌曲中,未出现在其历史播放列表的比例。计算公式:
len(set(played_songs) - set(history_songs)) / len(played_songs)。
-- 在MySQL中快速计算惊喜度(示例) SELECT COUNT(*) FILTER (WHERE s.id NOT IN ( SELECT song_id FROM user_play_history WHERE user_id = 123 ))::float / COUNT(*) AS serendipity FROM recommendation_log rl JOIN songs s ON rl.song_id = s.id WHERE rl.user_id = 123 AND rl.timestamp > NOW() - INTERVAL '7 days';5. 进阶技巧:用remove_language_empty_char.py解决多语言标签的协同污染
KKBox数据集中,同一首歌常有多个语言版本的流派标签(如英文“Rock”、中文“摇滚”、日文“ロック”),若不做处理,协同过滤会误判:用户A听“Rock”和用户B听“ロック”被算作不同偏好,实际他们可能都喜欢Queen乐队。项目中remove_language_empty_char.py正是专治此病——它不简单做翻译,而是构建跨语言标签映射图谱。
5.1 标签标准化三步法
5.1.1 清洗空格与符号
原始genres.txt中存在"Rock "(末尾空格)、"Pop & Rock"(&符号)、"Jazz / Blues"(斜杠)等变体。脚本先统一替换:
# remove_language_empty_char.py 第22行 genre = re.sub(r'[\s/&\\]+', ' ', genre.strip()).strip() # 结果:"Rock", "Pop Rock", "Jazz Blues"5.1.2 语言无关词干提取
对英文、中文、日文标签分别处理:
- 英文:
nltk.stem.PorterStemmer().stem("Blues") → "blu"; - 中文:用
jieba分词后取核心名词(“蓝调音乐”→“蓝调”); - 日文:
fugashi分词后过滤助词(“ロック”→“ロック”本身已是词干)。
最终映射表genre_mapping.json示例:
{ "blu": ["Blues", "蓝调", "ブルース"], "rock": ["Rock", "摇滚", "ロック"], "jazz": ["Jazz", "爵士", "ジャズ"] }5.1.3 协同计算时的动态归一化
当用户u听过歌曲s,其流派标签原为["Rock", "Classic Rock"],系统不直接存这两个字符串,而是:
- 查
genre_mapping.json得["rock", "rock"]; - 去重后存为
["rock"]; - 在协同相似度计算中,
r_{us}(用户u对流派g的偏好)=sum(play_completion_rate for all songs in g) / count(songs in g)。
这样,听“Queen - Bohemian Rhapsody”(标签:["Rock", "Classic Rock"])和听“X JAPAN - Art of Life”(标签:["ロック", "クラシックロック"])的用户,在流派维度上完全对齐。
5.2 验证映射有效性:用Jaccard相似度反推
运行python remove_language_empty_char.py --validate会输出:
Genre mapping validation: - "Rock" ↔ "ロック": Jaccard similarity = 0.92 (threshold 0.85 ✅) - "Blues" ↔ "ブルース": Jaccard similarity = 0.88 (threshold 0.85 ✅) - "Pop" ↔ "ポップ": Jaccard similarity = 0.71 (threshold 0.85 ❌ → 被排除)低于阈值的映射对会被丢弃,避免强行合并语义偏差大的标签(如“Pop”在日语中常指“偶像流行”,与西方Pop音乐差异显著)。
这个细节看似微小,却决定了协同过滤能否穿透语言壁垒——当你的用户画像里写着“偏好日本摇滚”,系统推荐的不该是《东京爱情故事》主题曲,而该是《Burning Down the House》的日语翻唱版。
本文还有配套的精品资源,点击获取