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

发布时间:2026/10/30 19:57:55
协同过滤推荐系统实战:从算法选型到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 离线评估:准确率、召回率、覆盖率与冷启动口径

评估才是毕设不被问倒的关键。最常见的做法是:把评分数据按用户和电影拆成训练集和测试集,训练集用来算相似度和推荐,测试集用来验证推荐结果里有多少是用户真实喜欢的电影。推荐系统常用的四个口径:

  • 准确率,更准确地说是 Precision@N:推荐列表里用户真实喜欢的电影占推荐总数的比例。
  • 召回率 Recall@N:用户真实喜欢的电影里有百分之多少被推荐出来了。
  • 覆盖率:推荐系统总共推荐了多少不同的电影,覆盖率太低说明只能推热门。
  • 平均流行度:推荐列表中电影平均被评分的次数,可以检测是否掉进“热门榜陷阱”。

在论文里,如果只给一张“准确率 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", names=RATINGS_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="|", names=MOVIE_COLS, encoding="latin-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="\t";MOVIE_COLS的列数必须和 u.item 的字段数完全一致,少一个都会崩。我一般会把该压缩的列类型先降下来,user_id 和 movie_id 转成 int32,rating 转成 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(ascending=False) test_mask = rank_back < (n * 0.2).clip(lower=1) 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(lower=1)保证哪怕某个用户只有一条评分,也不会出现测试集为空的情况。

参数说明里,0.2 是常见比例,但要注意如果数据经过上一步过滤后用户评分次数分布差异很大,0.2 可能切给某个用户 0 条或 1 条。更保守的做法是每个用户固定留最后一条评分,也就是 leave-one-out,适合论文里做精确的 TopN 评估。时间切分的好处是更贴近真实场景,坏处是测试集里永远有一部分冷用户,需要在评估阶段单独处理。

4. 手写 UserCF 最小实现:相似度、k 近邻与 TopN 推荐

4.1 从 DataFrame 到用户字典:别用稠密矩阵硬撑

很多源码喜欢先 pivot 成用户乘电影的稠密 DataFrame,再拿去算余弦。数据量小的时候没问题,但 pivot 会产生大量 NaN,NaN 在相似度计算里会被当成缺失值,直接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 记录一次 Precision@10,画出曲线,选平稳区间里的中间值。很多源码把 k 写死在 10 或者 50,我建议至少做一遍这个小实验,论文里也撑得起内容。

4.3 生成 TopN:加权投票比直接取均值更稳

算出近邻后,有两种常见做法。一是预测未知电影评分再排序;二是直接统计近邻用户里谁被高分评价得最多。我一般用第二种里的加权版本:相似度作为权重,近邻给过 4 分以上的电影参与投票。

def recommend(user_id, user_items, k=20, n=10, min_rate=4.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(key=lambda x: x[1], reverse=True) 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(), key=lambda x: x[1], reverse=True) return [mid for mid, _ in ranked[:n]]

这段代码的核心是:先把所有其他用户按相似度排序,取前 k 个;然后这些近邻用户评分过的高分电影按sim * (rating - 3.0)累加得票。减 3.0 是让 3 分及以下的评分不参与正向投票,避免“5 分是喜欢、3 分也推荐”的常见毛病。最后按票数排序取 TopN。

参数说明里,k=20是近邻数量,n=10是推荐列表长度,min_rate=4.0控制只有 4 分以上的评分才被视为正反馈。这三个参数应该单独抽成配置项,答辩的时候直接说“我扫过 k 和 min_rate,这里取的是最优组合”,比拍脑袋说默认值可信得多。如果目标用户没有评分记录,这个函数返回空列表,上层要兜底到热门榜。

4.4 一个简单的评估循环:把推荐结果和测试集对起来

from collections import defaultdict def evaluate(user_items, user_test, k=20, n=10, min_rate=4.0): hits = 0 total = 0 for uid, test_items in user_test.items(): recs = recommend(uid, user_items, k=k, n=n, min_rate=min_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("Precision@10:", evaluate(user_items, user_test))

这个循环把测试集按用户整理成{user_id: set(movie_id)},然后调用上一节的 recommend 函数生成 Top10,统计命中的比例。total只累加有推荐结果的用户,冷用户不会参与计算。

参数说明里,n=10要和 recommend 里的 n 保持一致,否则 Precision 的分母算错。这里算的是 Precision@10,没有算召回,原因是时间切分后每个用户测试集里的电影数不固定,算召回需要逐个用户归一化,代码稍微麻烦一点。论文里如果只报 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(index="user_id", columns="movie_id", values="rating")直接抛出 ValueError,源码在数据预处理部分崩溃。

原因:MovieLens 原始数据里可能存在同一用户对同一电影的多条评分记录,这是因为数据在合并或处理阶段产生了重复,也可能是之前做过数据拼接没有去重。

解决:先执行ratings.groupby(["user_id", "movie_id"], as_index=False)["rating"].mean()做聚合,把重复评分取均值,再用聚合后的数据做 pivot。如果你后面还要用字典结构,这一步同样不可省,否则同一个电影会出现两个评分覆盖不一的情况。排查时可以用ratings.duplicated(["user_id", "movie_id"]).sum()先确认重复数量。

5.5 评估时用户重合度过低,指标虚低不知道怎么解释

现象:训练集和测试集按时间切分后,测试集里的用户很多在训练集里没有评分,Precision@10 只有 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 里的截图和源码可以完全对应,评委现场运行也不会翻车。

我做这套的时候,最吃亏的是先把算法和界面做漂亮,最后才补基线实验。补跑时发现训练测试划分没有固定,全部指标要重算,整整多花两天。先立基线再调算法,是这份源码最省时间的路径。如果你连基线都还没建,先停下来补上,再继续调相似度,希望帮到你。

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