我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

协同过滤推荐系统实战:从算法选型到MovieLens毕设避坑指南

协同过滤推荐系统实战:从算法选型到MovieLens毕设避坑指南 简介面向计算机相关专业学生这是一份基于协同过滤推荐算法的电影推荐系统毕业设计项目适用于课程设计、期末大作业或毕设答辩已获导师指导并拿到97分高分评价。压缩包内共687个文件总大小约13.32MB包含SVG图标、JavaScript脚本、CSS样式、Vue组件、HTML页面等前端资源Python源文件、pyc编译文件及SQL数据库脚本等后端与数据部分另有安装和启动批处理脚本及论文doc文档类型覆盖完整目录结构清晰。目前已有356人学习浏览。项目提供可直接运行的完整系统附带全部数据与论文无需修改即可在本地搭建可帮助读者深入理解协同过滤算法原理、评分数据预处理、相似度计算与推荐列表生成等核心环节并支持二次开发是完成毕业设计或提升推荐系统开发能力的实用参考。1. 冷启动不是算法问题一份毕设级电影推荐系统的真实轮廓如果你从网盘下过那份 python 基于协同过滤推荐算法的电影推荐系统毕设源码十有八九经历过这样的场景代码能跑界面能开但推荐结果里全是《泰坦尼克号》《阿凡达》这类谁都知道的大片答辩时导师一句“你的算法比热门榜强在哪”直接问穿。这不是算法写错了而是整个方案里最容易被忽略的评估环节没有立住。这类毕设系统的核心通常是一份 MovieLens 评分数据、一段 UserCF 或 ItemCF 的 Python 实现、一个展示推荐结果的 Web 页面外加一篇能对齐代码的论文。下面按一名接手这套源码的工程师视角讲清协同过滤的算法选型、数据处理、最小实现、踩坑与答辩验证适合准备做推荐系统毕设、或者想拿这份源码二次开发的人。2. 协同过滤凭什么立在 C 位算法选型与离线评估口径2.1 UserCF 和 ItemCF 的差异电影场景默认先看谁协同过滤整个家族都建立在“相似的人或相似的东西”上。UserCF 先找和你口味最接近的用户再把这些用户喜欢而你没看过的电影推给你ItemCF 先找和你看过的电影最像的电影再按相似度排序推荐。这两种思路在 MovieLens 这种 1 到 5 分显式评分数据上实现成本都不高但效果差异明显。电影推荐场景里ItemCF 通常占优。原因很直接用户兴趣在较长时间内相对稳定电影之间的相似关系可以离线全部算好线上只做查表而 UserCF 更偏社会化适合新闻、社区这类热点驱动的场景——今天大家都在看什么就把什么推给你。做毕设时我见过不少源码里写的是 UserCF原因也很实际UserCF 更容易在论文里讲清楚推荐逻辑能用一句话解释实现起来也不会在答辩时被连环追问。所以如果你只是想先把毕设跑通哪个算法出现在源码里就先吃透哪个如果想加亮点两个都实现并做对比论文里能多一张表。具体到代码UserCF 的在线部分要计算目标用户和其他所有用户的相似度用户量上万后实时计算会明显变慢ItemCF 相似度可以预先算好存成文件在线推荐只需按用户评过的电影聚合。毕设数据量一般不大这个差别体现不出来但如果你把模型套到爬下来的豆瓣数据上差异立刻可见。两种算法在电影场景下的适用边界可以简单对比如下维度UserCFItemCF核心假设相似用户口味一致相似物品会被共同喜欢适合场景新闻、社区、热点电影、电商、图书离线计算用户相似度可离线算物品相似度更适合离线算在线开销实时算目标用户与其他用户相似度查表聚合通常更快推荐解释“和你口味相近的人在看什么”“你喜欢的电影的同类”做选型的时候不用太纠结理论先看你手上的数据量和业务形态。十万条评分、几百个用户时UserCF 足够如果用户涨到几万在线实时算相似度会变成灾难那时 ItemCF 的离线物品相似度优势就出来了。2.2 相似度计算余弦、皮尔逊、修正余弦的取舍相似度是整个协同过滤的灵魂选错公式后面所有推荐结果都是错的。三种常见公式在我实际做的时候是这样取舍的余弦相似度直接拿两个向量算夹角。在 5 分制评分下一个平均打 4.5 分的用户和一个平均打 2.5 分的用户即使口味高度一致余弦值也被整体打分习惯带偏。皮尔逊相关系数先各自减去用户均分再算余弦等于只比较“偏高还是偏低”的形态。这是 UserCF 最常用的相似度能缓解用户打分尺度不一致的问题。修正余弦在 ItemCF 里更常见分母减去的是该用户对物品评分的均分或者直接对行内评分做去均值处理。具体实现里每个物品向量先减去该用户的平均分再算余弦。我的建议是毕设代码里默认优先用皮尔逊相关系数因为 MovieLens 的评分偏置非常明显有的人只打 3 到 5 分有的人只打 1 到 2 分。如果源码里用的是普通余弦你换数据后先检查这一点。相似度算完后一定要做数值清理别直接让 NaN 进入排序不然后面全乱。选公式还有一个实操标准看相似度分布。算完所有用户两两相似度后如果大多数值落在 0.8 以上说明中心化没做好如果大多数是 0说明数据太稀疏或共同评分太少需要回上一环节调整过滤阈值。这个检查通常用seaborn.histplot画一眼比反复调 k 值更快定位问题。2.3 离线评估准确率、召回率、覆盖率与冷启动口径评估才是毕设不被问倒的关键。最常见的做法是把评分数据按用户和电影拆成训练集和测试集训练集用来算相似度和推荐测试集用来验证推荐结果里有多少是用户真实喜欢的电影。推荐系统常用的四个口径准确率更准确地说是 PrecisionN推荐列表里用户真实喜欢的电影占推荐总数的比例。召回率 RecallN用户真实喜欢的电影里有百分之多少被推荐出来了。覆盖率推荐系统总共推荐了多少不同的电影覆盖率太低说明只能推热门。平均流行度推荐列表中电影平均被评分的次数可以检测是否掉进“热门榜陷阱”。在论文里如果只给一张“准确率 20%、召回率 15%”的表是不够的。你应该同时跑两个基线随机推荐和全局热门推荐。热门榜几乎一定在准确率上和协同过滤差不了太多如果算法没有超出热门榜说明你的数据量太小或相似度选得不对。我一般建议把所有离线实验固定在同一份训练测试划分上并记录随机种子否则每次运行结果都不一样论文里写出来的数字无法复现。冷启动不是算法问题而是系统问题新注册用户没有任何历史评分协同过滤根本没法算相似度新上映电影没有任何被评分记录ItemCF 也找不到相似物品。方案是在推荐入口加一层兜底策略用热门榜或同类目热门填空等用户的评分积累到阈值后再切到协同过滤。这样既有用户体验也有明确的代码逻辑可以在论文里单独开一节讲。3. 把 zip 里的数据变成训练集评分矩阵构建与稀疏度处理3.1 MovieLens 数据读进来别拿到手就 pivot这类毕设源码通常带的是 MovieLens 100K 数据集u.data 是评分记录每行四个字段制表符分隔u.item 是电影信息竖线分隔包含片名、上映日期、类型。常见做法是直接用 pandas 读但我拿到源码第一件事不是跑主函数而是先看一眼原始数据。import pandas as pd RATINGS_COLS [user_id, movie_id, rating, timestamp] ratings pd.read_csv(ml-100k/u.data, sep\t, namesRATINGS_COLS) MOVIE_COLS [movie_id, title, release_date, video_release_date, imdb_url, unknown, Action, Adventure, Animation, Children, Comedy, Crime, Documentary, Drama, Fantasy, Film_Noir, Horror, Musical, Mystery, Romance, Sci_Fi, Thriller, War, Western] movies pd.read_csv(ml-100k/u.item, sep|, namesMOVIE_COLS, encodinglatin-1) print(ratings.shape, movies.shape) print(ratings.isna().sum())这段代码做的事很基础把评分和电影信息分别读成 DataFrame列名对齐原始文件格式。注意 u.item 的编码是 latin-1直接用 utf-8 读会在片名上报 UnicodeDecodeError这是很多源码跑不起来的第一道坑。print形状和缺失值不是多余的看一眼能提前发现文件是否被截断或列数不对。参数说明里RATINGS_COLS的顺序必须和 u.data 里每行的字段一致tsv 文件用sep\tMOVIE_COLS的列数必须和 u.item 的字段数完全一致少一个都会崩。我一般会把该压缩的列类型先降下来user_id 和 movie_id 转成 int32rating 转成 int8后面构建矩阵时内存能省不少数据量大了差别很明显。如果你在 Ubuntu 或服务器上跑记得先确认 Python 环境和 pandas 版本源码里常见的报错有一半是 pandas API 改动造成的。3.2 稀疏度 94% 时怎么办先裁掉只看过一部电影的用户MovieLens 100K 有 943 个用户、1682 部电影、10 万条评分总格数是 943 乘 1682 约 158 万评分只占 6.3%也就是稀疏度约 94%。直接拿全部数据算相似度不是不行但结果会被大量只有一两条评分的边缘用户干扰。我一般按评分次数过滤。rating_counts ratings.groupby(user_id)[rating].count() movie_counts ratings.groupby(movie_id)[rating].count() active_users rating_counts[rating_counts 20].index popular_movies movie_counts[movie_counts 5].index ratings ratings[ratings[user_id].isin(active_users) ratings[movie_id].isin(popular_movies)].copy() print(ratings.shape) print(活跃用户:, len(active_users), 热门电影:, len(popular_movies))这一段的含义是要求每个用户至少评价过 20 部电影每部电影至少被评价过 5 次。阈值怎么定要看你的数据规模100K 数据集这样过滤后通常能去掉三四成边缘评分但保留核心关系如果你用的是 1M 数据集可以把用户阈值提高到 50电影阈值提高到 10减少计算量。过滤太狠会让测试集可评估的用户变少过滤太少则相似度里的噪声变大。在论文里一定要写清楚这个阈值否则实验不可复现。有个容易忽略的细节过滤顺序会影响结果。先用评分次数过滤再按时间切分和先切分再过滤得到的训练测试集完全不同。我习惯先过滤再切分原因是被过滤掉的边缘用户不该占用“未来”评分的位置否则测试集会混入大量没有训练历史的冷用户离线指标被拉低。3.3 用时间戳切分每个用户最新的 20% 当作测试集协同过滤的评估最忌随机切分。如果随机把一个用户的历史评分一半放训练一半放测试同一段历史的相似度信息会泄漏到预测里分数虚高。电影推荐有明确的时间语义正确做法是模拟过去预测未来。ratings[timestamp] pd.to_numeric(ratings[timestamp]) ratings ratings.sort_values([user_id, timestamp]) n ratings.groupby(user_id)[user_id].transform(count) rank_back ratings.groupby(user_id).cumcount(ascendingFalse) test_mask rank_back (n * 0.2).clip(lower1) train ratings[~test_mask].drop(columns[n, rank_back]) test ratings[test_mask].drop(columns[n, rank_back]) print(train.shape, test.shape)这段逻辑是数据先按用户和时间升序排序然后对每个用户从最新一条开始倒着编号。rank_back为 0 表示该用户最后一条评分最新的 20% 会被切到测试集剩下 80% 进训练集。clip(lower1)保证哪怕某个用户只有一条评分也不会出现测试集为空的情况。参数说明里0.2 是常见比例但要注意如果数据经过上一步过滤后用户评分次数分布差异很大0.2 可能切给某个用户 0 条或 1 条。更保守的做法是每个用户固定留最后一条评分也就是 leave-one-out适合论文里做精确的 TopN 评估。时间切分的好处是更贴近真实场景坏处是测试集里永远有一部分冷用户需要在评估阶段单独处理。4. 手写 UserCF 最小实现相似度、k 近邻与 TopN 推荐4.1 从 DataFrame 到用户字典别用稠密矩阵硬撑很多源码喜欢先 pivot 成用户乘电影的稠密 DataFrame再拿去算余弦。数据量小的时候没问题但 pivot 会产生大量 NaNNaN 在相似度计算里会被当成缺失值直接fillna(0)会把“用户没看过”和“用户评了 1 分”混为一谈逻辑上是错的。更稳的做法是保持稀疏结构用 dict 或 scipy.sparse。from collections import defaultdict def build_user_items(ratings: pd.DataFrame): user_items defaultdict(dict) for uid, mid, rating in zip(ratings[user_id], ratings[movie_id], ratings[rating]): user_items[uid][mid] float(rating) return user_items user_items build_user_items(train) print(用户数:, len(user_items)) print(样例:, dict(list(user_items.items())[:1]))这个函数把训练集压成{user_id: {movie_id: rating}}的字典结构。它不生成稠密矩阵内存占用只和有效评分成正比。后续算用户相似度时只需要对两个用户的键做集合交集速度快且天然避开了 NaN。一个 10 万条评分的 100K 数据这个结构在内存里只占几 MB。如果你的源码是用 pivot 写的建议先改成这个结构后面所有代码会好写很多。参数说明里外层 dict 的键是用户 ID内层 dict 的键是电影 ID。这层结构同时承载了 UserCF 需要的所有信息后面如果要做 ItemCF再用同一份 train 构建{movie_id: {user_id: rating}}转置字典即可不需要额外读文件。如果你担心字典遍历慢可以把用户 ID 映射成 0 到 N-1 的整数再用列表套字典速度会快一截。4.2 皮尔逊相似度函数与 k 近邻选择import math def pearson_sim(u1_items: dict, u2_items: dict): common set(u1_items) set(u2_items) if len(common) 3: return 0.0 u1_mean sum(u1_items.values()) / len(u1_items) u2_mean sum(u2_items.values()) / len(u2_items) num sum((u1_items[c] - u1_mean) * (u2_items[c] - u2_mean) for c in common) d1 math.sqrt(sum((u1_items[c] - u1_mean) ** 2 for c in common)) d2 math.sqrt(sum((u2_items[c] - u2_mean) ** 2 for c in common)) if d1 0 or d2 0: return 0.0 return num / (d1 * d2)皮尔逊函数的关键是先取两个用户共同评分的电影共同评分数太少时直接返回 0。这里设了 3 条的下限原因是少于 3 个共同评分的相似度极不稳定一个巧合评分就能把相关系数顶到 1.0后期推荐结果会被带歪。计算顺序上先各自减均值再累乘天然缓解了不同用户打分尺度差异的问题。如果某用户在共同评分上完全没有波动分母会为 0所以要加保护。k 近邻的 k 是 UserCF 最重要的超参数。MovieLens 100K 上常见的合理区间是 20 到 40。k 太小邻居口味单一推荐多样性差k 太大混进大量相似度很低的用户噪声会淹没信号。怎么选把 k 从 5 扫到 80每隔 5 记录一次 Precision10画出曲线选平稳区间里的中间值。很多源码把 k 写死在 10 或者 50我建议至少做一遍这个小实验论文里也撑得起内容。4.3 生成 TopN加权投票比直接取均值更稳算出近邻后有两种常见做法。一是预测未知电影评分再排序二是直接统计近邻用户里谁被高分评价得最多。我一般用第二种里的加权版本相似度作为权重近邻给过 4 分以上的电影参与投票。def recommend(user_id, user_items, k20, n10, min_rate4.0): target user_items.get(user_id) if not target: return [] scored [] for other_id, other_items in user_items.items(): if other_id user_id: continue sim pearson_sim(target, other_items) if sim 0: continue scored.append((other_id, sim)) scored.sort(keylambda x: x[1], reverseTrue) neighbors scored[:k] scores defaultdict(float) for other_id, sim in neighbors: for mid, rating in user_items[other_id].items(): if mid in target: continue if rating min_rate: scores[mid] sim * (rating - 3.0) ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return [mid for mid, _ in ranked[:n]]这段代码的核心是先把所有其他用户按相似度排序取前 k 个然后这些近邻用户评分过的高分电影按sim * (rating - 3.0)累加得票。减 3.0 是让 3 分及以下的评分不参与正向投票避免“5 分是喜欢、3 分也推荐”的常见毛病。最后按票数排序取 TopN。参数说明里k20是近邻数量n10是推荐列表长度min_rate4.0控制只有 4 分以上的评分才被视为正反馈。这三个参数应该单独抽成配置项答辩的时候直接说“我扫过 k 和 min_rate这里取的是最优组合”比拍脑袋说默认值可信得多。如果目标用户没有评分记录这个函数返回空列表上层要兜底到热门榜。4.4 一个简单的评估循环把推荐结果和测试集对起来from collections import defaultdict def evaluate(user_items, user_test, k20, n10, min_rate4.0): hits 0 total 0 for uid, test_items in user_test.items(): recs recommend(uid, user_items, kk, nn, min_ratemin_rate) if not recs: continue hits len(set(recs) set(test_items)) total n return hits / total if total else 0.0 user_test defaultdict(set) for uid, mid, _ in zip(test[user_id], test[movie_id], test[rating]): user_test[uid].add(mid) print(Precision10:, evaluate(user_items, user_test))这个循环把测试集按用户整理成{user_id: set(movie_id)}然后调用上一节的 recommend 函数生成 Top10统计命中的比例。total只累加有推荐结果的用户冷用户不会参与计算。参数说明里n10要和 recommend 里的 n 保持一致否则 Precision 的分母算错。这里算的是 Precision10没有算召回原因是时间切分后每个用户测试集里的电影数不固定算召回需要逐个用户归一化代码稍微麻烦一点。论文里如果只报 Precision 不报 Recall容易被追问建议再做一轮 leave-one-out 评估把 Recall 也补上。5. 避坑指南协同过滤推荐最容易翻车的 5 个环节5.1 用户均分差异导致推荐全偏高分党现象无论给谁推荐结果都偏向那些平均分给得高的用户喜欢的电影冷门文艺片永远进不了列表。原因没有对评分做中心化。如果用户 A 习惯性打 4 到 5 分用户 B 习惯性打 2 到 3 分A 和任何人的余弦相似度都会被整体高分撑大导致他频繁成为很多人的近邻。解决使用皮尔逊相关系数替代普通余弦或者至少先用rating - user_mean做一次中心化。检查方法是分用户打印平均分如果均分差距超过 1.5 分基本可以断定是这个原因。另外可以把相似度矩阵按用户拆出来看一眼看看高分用户是不是普遍排在所有人的近邻前列。5.2 近邻全是冷门片爱好者推荐结果没人认识现象算法推荐列表里出现大量评分人数只有个位数的电影用户反馈“这片子我听都没听过”。原因一个只看过 5 部冷门片但每部都给 5 分的用户和另一个只看过 5 部冷门片的用户皮尔逊相似度可能高达 1.0他们的共同点被过分放大。解决在相似度函数里提高共同评分数的下限从 3 提到 5 或 8同时在训练数据过滤时提高电影被评分次数的最低值。还可以在推荐阶段加一道热门度惩罚把候选电影评分人数低于阈值的直接过滤。检查手段是统计推荐列表的平均电影被评分次数如果明显低于训练集平均水平就要怀疑掉进冷门陷阱。5.3 相似度出现 0 或 NaN排序结果全乱现象预测评分出现大量 0 或空值推荐列表里混入莫名其妙的老电影。原因皮尔逊公式里分数向量全相同导致分母为 0或者两个用户没有共同评分时返回空值。很多源码直接把这个值放进排序Python 排序遇到 NaN 会得到不确定顺序。解决在相似度函数开头就判断共同评分数不足阈值的直接返回 0.0分母计算后判断是否为 0最终数值再做一次math.isnan保护。这个坑我在第一次实现时踩过血泪经验是任何相似度矩阵都要单独存一份原始值方便排查不要什么都现场重算。5.4 pivot 报错 Index contains duplicate entries现象df.pivot(indexuser_id, columnsmovie_id, valuesrating)直接抛出 ValueError源码在数据预处理部分崩溃。原因MovieLens 原始数据里可能存在同一用户对同一电影的多条评分记录这是因为数据在合并或处理阶段产生了重复也可能是之前做过数据拼接没有去重。解决先执行ratings.groupby([user_id, movie_id], as_indexFalse)[rating].mean()做聚合把重复评分取均值再用聚合后的数据做 pivot。如果你后面还要用字典结构这一步同样不可省否则同一个电影会出现两个评分覆盖不一的情况。排查时可以用ratings.duplicated([user_id, movie_id]).sum()先确认重复数量。5.5 评估时用户重合度过低指标虚低不知道怎么解释现象训练集和测试集按时间切分后测试集里的用户很多在训练集里没有评分Precision10 只有 5%比随机推荐还差。原因时间切分按全局最新 20% 切新注册用户的所有行为全落在测试集里算法完全没有他们的历史这属于冷启动不是协同过滤的真实效果。解决评估时只统计训练集和测试集都出现的用户对只在测试集出现的用户用热门榜兜底并在论文里说明。同时在切分脚本里打印两个集合的用户重合度低于 80% 就要检查过滤条件是否太激进。离线实验要的是“同一批人的过去推未来”不是拿新人难为算法。6. 答辩前必做的基线验证让推荐结果经得起灵魂拷问6.1 先跑 Random 和 MostPopular 两个基线在优化协同过滤之前先交两份基线成绩单。随机基线从全部电影里随机抽 10 部热门基线按训练集里每部电影被评分次数排序取前 10 部。这两个基线代码不超过 30 行却能让你在答辩时直接回答“你的算法比基线强多少”。通常热门榜的准确率不低如果你的算法只高 1 到 2 个百分点需要在论文里解释算法在长尾覆盖率上的优势而不是只盯着准确率。6.2 把推荐结果导出成一张能讲故事的表格离线指标只能证明平均效果好答辩时评委更想看具体例子。建议为 3 个用户导出原有高评分电影、相似用户信息、推荐 Top10 及相似度权重。可以按下面这种形式整理用户看过的代表电影推荐 Top5依据用户 42《肖申克的救赎》(5)《低俗小说》(0.82)相似用户 17 给 5 分用户 128《星球大战》(4)《夺宝奇兵》(0.76)相似用户 3、88 给 5 分这张表比任何指标都能说明问题因为它把算法逻辑变成了人能读懂的故事。导出时用json.dump保存推荐结果和相似用户权重答辩前重新生成一次即可不用反复跑全量实验。6.3 保存随机种子并固定数据版本离线实验最尴尬的是上次跑出准确率 18%这次跑出来 22%。原因是数据切分或采样时没有固定随机种子。解决方法是把随机种子写进配置数据文件加版本号推荐结果统一落盘。这样论文里的数字、PPT 里的截图和源码可以完全对应评委现场运行也不会翻车。我做这套的时候最吃亏的是先把算法和界面做漂亮最后才补基线实验。补跑时发现训练测试划分没有固定全部指标要重算整整多花两天。先立基线再调算法是这份源码最省时间的路径。如果你连基线都还没建先停下来补上再继续调相似度希望帮到你。本文还有配套的精品资源点击获取
返回列表