
简介基于用户画像与协同过滤算法的音乐推荐系统以Python语言实现是面向计算机、通信、人工智能、自动化等相关专业学生、老师及从业者的高分毕业设计项目既可作课程大作业参考也可作为二次开发底本。资源压缩包共收录七十六个文件整体大小约十二点八四兆以Python源码为核心包含三十个py模块另有九个CSS样式、八个HTML页面、七个JavaScript脚本、SQLite数据库以及Docker部署配置并附带少量音频与字体样例覆盖数据存储、推荐算法、前端交互到部署运行的完整链路。已有155人浏览或学习说明其在毕设借鉴与自学入门方面具备一定认可度。项目整体结构清晰用户画像与协同过滤模块独立可读可帮助读者理解推荐系统的实际落地过程同时附带的README、依赖列表与部署文件便于快速跑通实验环境适合希望掌握完整Web推荐系统实现细节的学习者。1. 基于用户画像与协同过滤算法的音乐推荐系统毕业设计从来不缺模型缺的是把两者拧成一套工程做一个能跑的基于用户画像与协同过滤算法的音乐推荐系统毕业设计里最容易被问倒的不是模型本身而是“推荐结果凭什么合理”。哪怕你照着开源项目把源码抄完答辩老师一句“用户画像权重怎么定的”就能让整个项目翻车。这篇文章顺着播放日志清洗、标签画像构建、UserCF/ItemCF跑通、再到混合推荐的全链路走一遍把关键参数和你可能踩的坑一起交代清楚。适合正在做推荐类毕设的本科生也适合想快速把协同过滤落地成可演示系统的开发者。整个方案不依赖第三方推荐框架用 Python 的 pandas 和 numpy 就能实现几千用户、几万条行为级别的数据就足够撑起完整的演示和论文图。2. 为什么必须是用户画像协同过滤两种算法的能力边界与互补关系2.1 用户画像不是用户标签是权重的堆叠用户画像这个词听着抽象落到代码里其实就是一张表每一行是“用户ID 标签 权重”。标签在音乐场景下通常是歌手、流派、语种、年代这种带语义的元信息。静态画像是注册时填的偏好动态画像则是从行为里累计出来的。音乐推荐系统的现场动态画像远比静态画像有用因为用户嘴上说的“我喜欢民谣”远不如他循环播放了二十遍《米店》来得真实。画像权重不是简单计数而是三层计算的叠加。第一层是行为类型权重播放、收藏、循环三者的价值显然不同常见做法是播放计 1 分、收藏计 2 分、循环计 3 分第二层是时间衰减一个月前听的歌和昨天听的歌对当前兴趣的指示强度完全不同我习惯用半衰期 14 天的指数衰减第三层是归一化把每个用户的标签权重除以 L2 范数避免活跃用户的标签分天然碾压轻度用户。用公式写就是事件权重 行为类型权重 × 0.5^(天数/半衰期)标签权重 Σ事件权重 / L2范数。做完这三层你会得到类似“摇滚 0.6、民谣 0.3、粤语 0.2”这样的可解释结果。这一层对论文写作特别划算因为推荐结果能一一对应回画像标签答辩时不用对着黑匣子硬编理由。纯协同过滤就做不到这件事它只能告诉你“因为和你相似的人听了这首歌”没法告诉你“因为你歌单里民谣浓度很高”。2.2 UserCF与ItemCF选型不是看名字是看规模和密度协同过滤的两种常见形态是 UserCF基于用户的协同过滤和 ItemCF基于物品的协同过滤。UserCF 找的是“和你行为相似的一群人”把邻居们爱听的歌推荐给你ItemCF 找的是“和你听过的歌相似的其他歌”从歌的相似关系出发做推荐。两者的差别不只是方向更是数据需求和工程落地方式的差别。先看矩阵规模。音乐平台通常是用户数量远大于歌曲数量ItemCF 需要维护的是歌曲×歌曲相似度矩阵歌曲数量相对稳定可以离线算好线上直接查表UserCF 的相似度矩阵是用户×用户每来一批新用户矩阵就要膨胀一次在线增长的压力大得多。所以在工业级的音乐推荐里ItemCF 更常见。但毕设场景反过来如果你只有几百个用户样本UserCF 算出来的“朋友关系”反而好讲故事“人以群分”比“歌以类聚”更容易让评委听懂你在做什么。相似度计算选哪个公式也是一个关键决策点。余弦相似度简单直观皮尔逊相关系数则会对用户评分风格做中心化适合用户平均行为强度差异大的场景。但皮尔逊有一个隐雷如果两个用户都只对同一首歌产生过行为计算时分母会变成 0相似度结果是 NaN。这个坑在数据稀疏时非常致命后面避坑章会专门讲解决办法。做选型时我一般会先统计人均行为数如果人均不到 10 条就直接放弃 UserCF 转向 ItemCF因为用户相似度矩阵会稀疏到没法看。2.3 为什么把画像和协同过滤放在一个系统里纯粹协同过滤有两个让毕设很被动的毛病第一新用户没有任何行为时相似度计算直接瘫痪推荐列表为空第二长尾用户的行为集中在少数热门歌上协同过滤的结果被头部歌曲霸占个性化荡然无存。用户画像恰好能补这两块画像依赖的是用户自身的行为不依赖用户间相似度哪怕只有三五次播放记录也能打出“民谣、国语、2000年代”这样的标签从曲库里捞出对应标签下没听过的候选。反过来画像的问题在于它永远围着已知偏好打转发现不了惊喜。一个只听国风的人画像标签大概率只有古风、国语这些但协同过滤可能因为他和某个民谣爱好者行为相似找出他从未标记过、听感却接近民谣的专辑。这就是为什么常见做法是让两者分工画像负责召回候选和解释理由协同过滤负责在候选里做精排和探索。两者拧在一起后系统才能兼顾“用户懂你”和“算法能挖出你没听过的好歌”这也正好呼应了标题里“基于用户画像与协同过滤”的组合逻辑。3. 源码级实现从播放日志清洗到画像构建与双算法推荐3.1 数据准备把播放日志清洗成可计算的行为事件表拿到任何推荐系统的源码第一件事不是跑模型而是把原始日志处理成一张干净的事件表。这里我以最常见的字段结构为例user_id、song_id、song_name、singer、genre、behavior、timestamp其中 behavior 只有 播放/收藏/循环 三种。先用 pandas 读进来打印前面几行确认字段名再开始清洗。import pandas as pd import numpy as np # 原始日志毕设里通常从 CSV 或数据库导出 raw_logs pd.read_csv(play_logs.csv, encodingutf-8) print(raw_logs.head())清洗的核心是四步去重、过滤行为类型、把时间转成可计算的天数差、给歌曲打标签。下面这段代码是我每次都会用的模板。# 字段重命名原始日志可能叫法不一致 raw_logs.columns [user_id, song_id, song_name, singer, genre, behavior, timestamp] df raw_logs.drop_duplicates() # 只保留三种有效行为垃圾数据直接丢 valid_behaviors [play, collect, loop] df df[df[behavior].isin(valid_behaviors)].copy() # 把 timestamp 转成 datetime并计算距参考日的天数差 df[ts] pd.to_datetime(df[timestamp]) days_ago (df[ts].max() - df[ts]).dt.days df[days_ago] days_ago.clip(lower0) # 给歌曲打标签这里直接用 genre 字段作为标签 df[tag] df[genre]这段代码里有几个需要你自己定的参数。valid_behaviors 直接决定了后续画像权重字典的枚举值如果你日志里还有“分享”行为想把它算进去就必须先加在这个列表里否则会被静默过滤掉。days_ago 的计算基准用的是 df[ts].max() 而不是当前时刻这点很关键离线评估时如果拿“现在”去减历史时间结果的衰减分布会随着你跑代码的时间不同而变化测试集和训练集的语义就不统一了。最后genre 字段不一定干净比如可能存在 “RB/Funk” 这种复合流派要不要拆分取决于你论文里对标签的定义拆了会更细不拆会更稳。3.2 构建用户画像行为权重、时间衰减与L2归一化画像权重三要素是行为权重、时间衰减、归一化。三者的计算顺序不能乱先给每条事件算出事件权重再聚合到用户标签维度最后做归一化。代码实现如下。# 1. 行为权重播放1分收藏2分循环3分 behavior_weight {play: 1.0, collect: 2.0, loop: 3.0} df[event_weight] ( df[behavior].map(behavior_weight) * np.power(0.5, df[days_ago] / 14.0) ) # 2. 按用户标签聚合成原始标签分 profile df.groupby([user_id, tag])[event_weight].sum().reset_index() # 3. L2归一化避免活跃用户标签分碾压轻度用户 norm profile.groupby(user_id)[event_weight].transform(lambda x: np.sqrt((x ** 2).sum())) profile[final_weight] profile[event_weight] / norm行为权重字典是你可控的第一个旋钮。如果收藏行为更体现长期偏好就把 collect 调到 3.0如果循环才是真爱的证明就把 loop 调到 4.0。半衰期 14 天表示 14 天前的行为权重衰减到一半把 14 改成 7 就变成更看重短期兴趣改成 30 则更平滑。做毕设时建议用 14 作为默认值理由是它既不会让三个月前的偏好彻底消失又能让近一周的偏好明显冒头。L2 归一化之后每个用户的标签权重向量长度都是 1画雷达图的时候不会因为某个用户行为特别多而全部凸出来。这步产出的 profile 表就是整篇论文里的“用户画像”实体。你可以在 Jupyter 里随机抽一个用户把他的 Top5 标签打印出来那种“国风 0.4、古风 0.3、广播剧 0.2、华语 0.1”的效果放进论文就是一张非常有说服力的插图。3.3 协同过滤UserCF与ItemCF的邻域计算与推荐先要生成一张用户-歌曲矩阵。这里要注意矩阵里的值不是播放次数而是把行为类型映射成一个兴趣分我用 aggfuncmax 是避免同一用户对同一首歌重复行为把分数叠加得失去意义。# 手工映射行为为兴趣分 df[score] df[behavior].map({play: 1.0, collect: 2.0, loop: 3.0}) # 用户-歌曲矩阵行为用户列歌曲 pivot df.pivot_table(indexuser_id, columnssong_id, valuesscore, aggfuncmax).fillna(0)很多同学第一次在这里算出的是稀疏矩阵整个 DataFrame 都是 0会以为跑错了。其实没有稀疏是正常现象。关键在于后面的相似度计算方向不能错pandas 的 corr() 方法默认按列计算两两相关系数所以 pivot 的行是用户、列是歌曲时UserCF 必须先转置再算。# UserCF用户相似度矩阵 user_sim pivot.T.corr(methodpearson) K 20 def recommend_by_user_cf(user_id, top_k10): if user_id not in user_sim.index: return pd.Series(dtypefloat) similar_users user_sim[user_id].drop(user_id).sort_values(ascendingFalse).head(K) neighbors pivot.loc[similar_users.index] # 邻居的兴趣分加权求和权重是相似度 weighted_sum neighbors.T.dot(similar_users) # 去掉目标用户已经听过的歌 heard pivot.loc[user_id] unseen weighted_sum[heard 0].sort_values(ascendingFalse) return unseen.head(top_k)ItemCF 就是反过来直接对 pivot 求相关性得到的是歌曲×歌曲的相似度矩阵。# ItemCF歌曲相似度矩阵 song_sim pivot.corr(methodpearson) def recommend_by_item_cf(user_id, top_k10): heard_songs pivot.loc[user_id] heard_songs heard_songs[heard_songs 0] scores pd.Series(dtypefloat) for song_id, weight in heard_songs.items(): sims song_sim[song_id].drop(song_id) scores scores.add(sims * weight, fill_value0) # 去掉已听过的歌避免推荐列表出现历史播放 scores scores.drop(labelsheard_songs.index, errorsignore) return scores.sort_values(ascendingFalse).head(top_k)代码里的 K 是 UserCF 的近邻数默认 20调法后面专门讲。两个 recommend 函数最后都有一句去掉已听歌曲这是推荐系统的硬约束用户已经消费过的内容再推出来会被评委一眼看出问题。如果你想在演示里体现个性化对比 5 个不同用户的 Top10 列表看重叠度重叠度越低说明个性分化越好。3.4 混合推荐用画像召回兜底协同过滤做精排单跑协同过滤会冷启动瘫痪单跑画像又会缺乏惊喜所以混合推荐是毕设系统的正常形态。我用的是一个很实用的 Rank 融合策略两种算法分别给出候选再按排序位置加权而不是直接用原始分数相加。原因是不同算法的分数尺度不同直接相加会偏向分数普遍更高的那个算法。这里的核心参数是 alpha默认 0.6 表示协同过滤权重更大。def recommend_by_profile(user_id, top_k10): user_tags profile[profile[user_id] user_id].sort_values(final_weight, ascendingFalse) candidates df[df[tag].isin(user_tags[tag].head(5))][song_id].unique() # 取用户没听过的歌按歌曲热度排序 heard pivot.loc[user_id] if user_id in pivot.index else pd.Series(dtypefloat) unseen [s for s in candidates if s not in heard.index] return pd.Series({s: 1.0 for s in unseen[:top_k]}) def hybrid_recommend(user_id, top_k10, alpha0.6): if user_id not in pivot.index: # 纯冷启动回退到热门榜 return hot_songs(top_k) cf_result recommend_by_item_cf(user_id, top_ktop_k * 2) profile_result recommend_by_profile(user_id, top_ktop_k * 2) score {} for i, song_id in enumerate(cf_result.index): score[song_id] score.get(song_id, 0) alpha * (1.0 / (i 1)) for i, song_id in enumerate(profile_result.index): score[song_id] score.get(song_id, 0) (1 - alpha) * (1.0 / (i 1)) final pd.Series(score).sort_values(ascendingFalse).head(top_k) return final这段里用 1/(i1) 作为排序权重意思是排第一的候选拿 1 分排第二拿 0.5 分排第三拿 0.33 分呈衰减曲线。这么做的好处是规避了分数尺度问题也让两个算法在融合时真正“平等对话”。需要调的就是 alpha后面评估章会给一套扫参的做法。如果你想让架构看起来更完整可以在 hybrid_recommend 前面再加一层缓存把每个用户最近一次推荐结果存成 JSON避免重复计算这个点写在论文里也算一个小亮点。4. 避坑与常见问题排查推荐结果翻车时的四个排查方向4.1 相似度矩阵全是NaN协同过滤直接瘫痪现象用 corr() 计算 user_sim 或 song_sim 后矩阵里大面积是 NaN推荐函数返回空列表。原因数据太稀疏皮尔逊相关系数在用户或歌曲只有一个行为时标准差为 0除法直接出现无穷或无效值。尤其是很多用户只听过一两首歌的毕设数据这个现象几乎是必然的。解决在算相似度之前先过滤掉行为数过少的用户和歌曲。常见做法是行为数小于 5 的用户和歌曲都去掉。active_users df.groupby(user_id).size() active_songs df.groupby(song_id).size() valid_users active_users[active_users 5].index valid_songs active_songs[active_songs 5].index df_filtered df[df[user_id].isin(valid_users) df[song_id].isin(valid_songs)]这个阈值 5 不是写死的。数据量大可以抬高到 10数据量小可以降到 3。但注意过滤只会作用在相似度计算上完整推荐流程里的冷启动兜底仍然要覆盖被过滤掉的用户。另一个补救办法是在 pivot 上加上一个极小扰动比如pivot pivot 1e-6能让皮尔逊系数不出现 NaN但这样做治标不治本相似度依然不可信。4.2 推荐结果全被热门歌霸榜没有个性现象不同用户拿到的 Top10 前几名几乎一样全是平台最热门的几首歌。原因用户行为分布高度偏态少数热门歌曲拥有海量行为在协同过滤的加和里天然占绝对优势。这是音乐推荐里最典型的二八现象不处理的话用户画像是做了但用户看到的推荐结果一点画像的影子都没有。解决给歌曲加“流行度惩罚”原理是文本检索里的 IDF。行为越广泛的歌曲权重被压得越低。song_freq df.groupby(song_id)[user_id].nunique() n_users df[user_id].nunique() song_idf np.log((n_users 1) / (song_freq 1)) df[score_idf] df[score] * df[song_id].map(song_idf)算完 score_idf 之后再用这个新字段去生成 pivot 和相似度矩阵。这样冷门但用户真实喜欢的歌才有机会浮上来。我在做这个调整前Top10 重叠度超过 80%调完之后能压到 40% 以下视觉效果非常明显论文里前后对比图也好看。4.3 新用户进来推荐是空的现象冷启动用户没有任何历史行为画像和协同过滤两边都返回空前端展示直接白屏。原因推荐系统在实现时只依赖行为侧的数据没有给空行为用户准备兜底策略。这是毕设系统最容易被现场演示戳穿的一个破绽老师随手点一个新注册的测试账号列表空白就意味着整套逻辑没有闭合。解决准备一个全局热门榜作为最后的 fallback。热门榜不要直接用播放量排序建议做一个行为加权并且加对数惩罚防止刷榜。hot_score df.groupby(song_id).apply( lambda x: x[score].sum() / (1 np.log(1 len(x))) ).sort_values(ascendingFalse) def hot_songs(top_k10): return hot_score.head(top_k)很多同学到这里就结束了但为了让冷启动不至于千篇一律我还会加一个“每流派取 Top3”的逻辑热门榜覆盖的流派类型多一些。这部分代码虽然简单却能在答辩时讲出一段“冷启动方案设计”的故事很加分。4.4 画像权重被活跃用户带偏现象爱听歌的用户的画像标签几乎全为高分五六个标签都是 0.2 以上互相之间区分度很小。原因只做了事件权重的累计没有做归一化行为量大的用户无论什么行为都会堆积出高分。另一个隐性原因是活跃用户听的歌多标签自然覆盖广什么都喜欢就等于什么都不喜欢。解决除了 L2 归一化还要限制画像保留的标签数量只留每个用户 Top5 的高权重标签作为混合推荐时的召回依据。top_tags profile.sort_values([user_id, final_weight], ascending[True, False]) top_tags top_tags.groupby(user_id).head(5)标签数量这个参数需要在 5 和 20 之间取舍。5 个标签召回更集中、解释性更强20 个标签召回覆盖更广、不容易漏歌。我一般让混合推荐里画像触发的候选歌单用 Top5但展示在论文雷达图里的画像用 Top10两者不矛盾。5. 评估与调参用覆盖率和精确率而不是直觉验收推荐质量5.1 离线切分时间点切分比随机切分更诚实很多同学做推荐系统评估时喜欢直接 shuffle 数据再拆 train/test这是个非常严重的逻辑错误。推荐系统的数据是时序产生的用户先听歌、产生了行为我们才能拿这个行为去推荐。如果用随机切分训练集里可能包含了某首歌在时间上“未来”的行为模型等于开了天眼。所以离线评估一定要按时间切我习惯用最后 7 天做测试集之前的全部做训练集。cutoff df[ts].max() - pd.Timedelta(days7) train_df df[df[ts] cutoff] test_df df[df[ts] cutoff]这样处理之后你评估的是“模型在历史数据上学习能不能预测最近一周用户听了什么”这比随机切分接近真实线上行为。唯一要注意的是train_df 里筛选有效用户和歌曲的阈值与测试集要保持一致。如果训练集里某个用户只有 3 条行为但在测试集里他有 10 条那么评估时你会因“训练时过滤掉了”而漏掉他的真实验证指标会偏低这是无声的偏差。5.2 精确率、召回率与覆盖率的Python实现推荐系统离线指标很多毕设阶段最核心的三个是精确率、召回率和覆盖率。精确率关心推荐列表里有多少是用户真正消费过的召回率关心用户所有消费过的歌里被捞出来多少覆盖率关心推荐系统有没有触及整个曲库的各个角落。def precision_recall(recommended, actual, k10): if len(recommended) 0: return 0.0, 0.0 hit len(set(recommended) set(actual)) precision hit / min(k, len(recommended)) recall hit / len(actual) if len(actual) else 0.0 return precision, recall def coverage(all_recommendations, all_songs): unique_recommended set().union(*all_recommendations) return len(unique_recommended) / len(all_songs)注意 precision 公式里我用了 min(k, len(recommended))这是为了防止推荐列表不足 k 条时精确率被虚高。覆盖率分母是整个曲库的歌曲数分子是所有用户推荐列表去重后的歌曲种类。只盯精确率没有意义因为热门歌能被反复推荐来刷高精确率覆盖率低就说明推荐结果聚集在头部没有挖掘长尾。做毕设时我一般会在 5 个不同 alpha 下各跑一遍这三项指标画三条折线比单点指标有说服力得多答辩也能少背两句废话。5.3 两个必调参数K近邻数量与最小行为阈值UserCF 里 K 是近邻用户数ItemCF 里没有 K但换汤不换药最终候选集都需要一个截断。K 的取值区间我一般扫 10 到 60。K 太小邻居太少推荐结果方差很大今天跑和明天跑可能完全不同K 太大把不相似的人也拉进来个性化被稀释。具体选多少用验证集扫一遍精确率曲线取曲线变平滑的那个临界点就是合适的值。最小行为阈值直接影响矩阵密度。刚才避坑章里讲过滤时用了一个 5这个值高了有效用户数量骤减矩阵是变稠了但覆盖被砍烂值低了矩阵稀疏得没法看。常见做法是分别用 3、5、10 各跑一遍比较覆盖率的变化幅度。覆盖率下降超过 20% 的阈值就不要用哪怕矩阵看着再漂亮。这两个参数调完之后推荐系统才算从“能跑”进入“可用”的状态。6. 进阶技巧用动态权重让画像和协同过滤互补6.1 从固定alpha到基于行为量的动态调整固定 alpha 的混合推荐虽然简单但它假设所有用户都是同一类用户这明显不成立。老用户行为特别多协同过滤的邻居关系比较可信应该多听协同过滤的新用户行为只有三五条系数矩阵根本算不准应该多依赖画像召回。让 alpha 跟着行为量走才是更稳的做法。def dynamic_alpha(user_id): count user_behavior_count.get(user_id, 0) if count 5: return 0.2 elif count 30: return 0.7 else: return 0.4 0.1 * (count - 5) / 25这个分段函数的逻辑是行为少于 5 条时协同过滤权重只有 0.2画像主导行为超过 30 条时协同过滤权重拉到 0.7中间线性过渡。拐点要根据你自己数据的用户行为分布来定但原则不变给协同过滤配的权重与行为量成正比画像权重哪怕降到 0.3 也不要归零否则你又会丢掉画像的可解释性。把这个函数接入混合推荐后你会发现评估指标里召回率有明显改善因为新用户不再被一个算不准的协同过滤拽偏。6.2 用Shadow测试验证策略再全量上线毕业设计里做真 A/B Test 通常没有流量条件但可以用一个简化版 Shadow Test让新旧两套策略在同一个测试集上同时跑把各自推荐列表落盘再分别比较覆盖率和精确率。这种做法的好处是不需要真实用户参与纯离线就能完成却能模拟出“如果线上用了新策略会怎样”的效果。# 假设 old_results 是固定 alpha0.6 的结果new_results 是动态 alpha 的结果 old_cov coverage(old_results, all_songs) new_cov coverage(new_results, all_songs) print(fOld coverage: {old_cov:.3f}, New coverage: {new_cov:.3f})我在这套项目上踩过最大的坑就是最初只有固定 alpha 时精心调出一个好看的精确率结果换了一批测试数据立刻崩掉。后来换成动态 alpha虽然最优精确率比固定版本低了两三个点但覆盖率稳定高出近十个百分点推荐列表的多样性肉眼可见地提升。我最早那版毕设直接拿皮尔逊相关系数硬跑 UserCF所有用户前几名全被周杰伦承包不是不行而是不带过滤和惩罚的算法在真实日志里就是会烂大街。把动态权重、IDF 惩罚、覆盖率这几个顺手加上之后推荐结果才真正有了人人不同的感觉。愿你绕开这些坑用更短时间跑通自己的音乐推荐系统。希望帮到你。本文还有配套的精品资源点击获取