☰
从零手搓电影推荐系统:协同过滤算法落地与毕业设计实战
2026/10/1 4:57:19 网站建设 项目流程

简介:这份资源是Python基于协同过滤推荐算法的电影推荐系统完整项目,面向计算机相关专业正在做毕业设计的学生,以及需要项目实战练习的学习者,也可用于课程设计或期末大作业。项目包含全部源码与数据集,经过严格调试,下载后可直接运行,帮助读者省去从零搭建推荐系统的时间成本。压缩包共1197个文件,约76.75MB,其中22个py文件承载协同过滤核心算法与业务逻辑,14个html、12个js与8个css文件构成前端交互界面,4个csv与1个sqlite3、1个sql文件提供用户评分、电影信息等数据支撑,另有大量jpg图片用于页面展示。目前已有147人学习下载。读者可获得一套结构完整的推荐系统实现方案,涵盖用户相似度计算、评分预测与电影推荐等关键环节,便于理解协同过滤原理并在此基础上进行二次开发或论文撰写,适合作为毕设参考与实战练手项目。

1. 从零手搓一个能跑的电影推荐系统:协同过滤到底怎么落地

很多同学做毕业设计时,一看到“推荐系统”四个字就头大,觉得必须上深度学习、上大模型才够格。其实在电影推荐这个场景里,协同过滤推荐算法才是那个最稳、最容易讲清楚、也最容易跑出效果的方案。它不依赖电影的海报、简介、演员表这些内容特征,只看一件事:谁和谁的口味像,谁和谁看过同一批电影。你手头只要有一份用户对电影的评分数据,就能把整套系统跑起来。

这篇文章面向的是正在做毕业设计、需要一套能演示、能答辩、能写进论文的python电影推荐系统源码的同学。我会从数据长什么样、算法怎么算、代码怎么写、参数怎么调、坑在哪,一路讲到怎么验证推荐结果是不是靠谱。整套方案用 pandas + numpy 就能实现,不需要 GPU,一台普通笔记本十分钟内能跑完。看完你至少能得到三样东西:一个可复现的协同过滤核心、一套可替换的数据接口、一份能写进论文的实验对比思路。

2. 协同过滤的两条路线:UserCF 和 ItemCF 到底选哪个

在动手写代码之前,必须先把算法选型这件事想明白。协同过滤推荐算法主要分两条路线:基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。很多同学直接抄网上的代码,跑完也不知道自己用的是哪种,答辩时被问一句“为什么选这个”就卡住了。这一章把两条路线的原理、适用场景和选型理由讲透,再落到具体实现。

2.1 UserCF 和 ItemCF 的核心差异

UserCF 的思路是:找到和你口味相似的一批用户,把他们喜欢但你没看过的电影推荐给你。它计算的是“用户之间的相似度”。ItemCF 的思路是:找到和你历史喜欢的电影相似的电影,推荐给你。它计算的是“物品之间的相似度”。

两者的关键差异体现在数据规模和维护成本上。UserCF 适合用户数量远小于物品数量的场景,因为用户相似度矩阵是 N×N(N 为用户数),用户多了矩阵会爆炸。ItemCF 适合物品数量相对稳定的场景,电影库一般不会天天暴增,但用户会持续增长,所以工业界推荐系统更多用 ItemCF。对于毕业设计来说,MovieLens 数据集通常有几千个用户、几千部电影,两者都能跑,但 ItemCF 的推荐结果更稳定,解释性也更强——“因为你看了《星球大战》,所以推荐《帝国反击战》”这种话术答辩时很好用。

我一般会建议:如果你的数据集用户数少于物品数,用 UserCF;反之用 ItemCF。如果拿不准,两个都实现,用评测指标对比,这本身就是论文里的一章。

2.2 相似度计算的三种方式和参数含义

相似度是协同过滤的心脏。常用的有三种:余弦相似度、皮尔逊相关系数和调整余弦相似度。

余弦相似度把每个用户的评分向量看成空间中的向量,计算夹角的余弦值。它的问题是没有考虑用户评分尺度的差异——有人习惯打高分,有人习惯打低分。皮尔逊相关系数通过减去用户平均分来修正这个问题,但它要求两个用户必须有共同评分的电影,否则无法计算。调整余弦相似度则是在余弦的基础上减去物品平均分,适合物品评分偏差大的场景。

import numpy as np from sklearn.metrics.pairwise import cosine_similarity def user_similarity_cosine(user_item_matrix): """基于余弦相似度计算用户相似度矩阵 user_item_matrix: 行是用户,列是电影,值是评分,未评分为0 """ # 余弦相似度对稀疏矩阵友好,但未评分填0会引入偏差 sim = cosine_similarity(user_item_matrix) np.fill_diagonal(sim, 0) # 自己和自己相似度置0,避免推荐自己看过的 return sim def user_similarity_pearson(user_item_matrix): """基于皮尔逊相关系数计算用户相似度 需要先对每个用户减去其平均评分 """ matrix = user_item_matrix.astype(float) # 只对已评分项计算均值,未评分不参与 mask = matrix > 0 user_mean = np.true_divide( (matrix * mask).sum(axis=1), mask.sum(axis=1), where=mask.sum(axis=1) != 0 ) # 中心化:已评分减去均值,未评分保持0 centered = np.where(mask, matrix - user_mean[:, np.newaxis], 0) sim = cosine_similarity(centered) np.fill_diagonal(sim, 0) return sim

上面两段代码的差别就在“是否中心化”。余弦相似度直接用原始评分,皮尔逊先减去用户平均分再算余弦。参数上要注意:np.fill_diagonal(sim, 0)这行不能省,否则每个用户和自己相似度最高,推荐结果会全是自己已经看过的电影。另外,如果某个用户评分数量为0,user_mean会出现除零,代码里用where做了保护,实际数据中要先把这类冷启动用户过滤掉。

2.3 预测评分与 Top-N 推荐的实现

有了相似度矩阵,下一步就是预测某个用户对未看电影的评分。公式是:预测分 = 用户平均分 + 相似用户对该电影的加权偏差之和 / 相似度绝对值之和。这个公式比简单加权平均更合理,因为它考虑了每个用户的评分基准线不同。

def predict_rating(user_id, item_id, user_item_matrix, sim_matrix, k=20): """预测用户对某部电影的评分 k: 取最相似的k个用户参与预测 """ user_ratings = user_item_matrix[user_id] if user_ratings[item_id] > 0: return user_ratings[item_id] # 已经看过,直接返回 # 找到看过这部电影的其他用户 rated_users = np.where(user_item_matrix[:, item_id] > 0)[0] if len(rated_users) == 0: return 0 # 没人看过,无法预测 # 取相似度最高的k个 sims = sim_matrix[user_id, rated_users] top_k_idx = np.argsort(sims)[-k:] top_users = rated_users[top_k_idx] top_sims = sims[top_k_idx] # 过滤掉负相似度或零相似度 valid = top_sims > 0 if not valid.any(): return user_ratings[user_ratings > 0].mean() if (user_ratings > 0).any() else 0 top_users = top_users[valid] top_sims = top_sims[valid] # 加权预测 user_mean = user_ratings[user_ratings > 0].mean() numerator = 0.0 denominator = 0.0 for u, s in zip(top_users, top_sims): u_mean = user_item_matrix[u][user_item_matrix[u] > 0].mean() numerator += s * (user_item_matrix[u, item_id] - u_mean) denominator += abs(s) if denominator == 0: return user_mean return user_mean + numerator / denominator

这段代码里k=20是一个关键参数。k 太小,预测容易受个别极端用户影响;k 太大,会引入不相似用户的噪声。MovieLens 数据集上一般取 20 到 40 之间效果比较稳。valid = top_sims > 0这行过滤掉负相关用户,因为口味相反的人不应该参与推荐。最后denominator == 0的保护是防止所有相似度都为零时除零崩溃。

生成 Top-N 推荐列表时,对用户所有未评分的电影调用predict_rating,按预测分排序取前 N 个即可。实际跑的时候这一步会比较慢,因为要对每个用户遍历所有未评分电影。优化方式是用矩阵运算批量计算,或者只对候选集(比如相似用户看过的电影)做预测,不要全量遍历。

3. 数据准备与工程结构:从 MovieLens 到可运行项目

算法讲清楚了,接下来要解决“数据从哪来、代码怎么组织”的问题。很多毕业设计源码跑不起来,不是算法错,而是数据路径、编码格式、依赖版本这些工程细节翻车。这一章把数据加载、预处理和项目结构一次性讲清楚。

3.1 MovieLens 数据集的字段含义与加载方式

MovieLens 是推荐系统最常用的公开数据集,常见的有 ml-latest-small(约10万条评分)和 ml-1m(约100万条评分)。毕业设计用 ml-latest-small 就够了,跑得快,效果也够演示。它包含两个核心文件:ratings.csv和movies.csv。

ratings.csv的字段是:userId、movieId、rating、timestamp。movies.csv的字段是:movieId、title、genres。评分是 0.5 到 5.0 的浮点数,每 0.5 一档。

import pandas as pd import numpy as np def load_movielens(data_dir): """加载 MovieLens 数据集 data_dir: 包含 ratings.csv 和 movies.csv 的目录 """ ratings = pd.read_csv(f"{data_dir}/ratings.csv") movies = pd.read_csv(f"{data_dir}/movies.csv") # 检查缺失值 print("ratings 缺失值:", ratings.isnull().sum().sum()) print("movies 缺失值:", movies.isnull().sum().sum()) # 过滤评分次数过少的用户和电影,缓解冷启动 user_counts = ratings["userId"].value_counts() movie_counts = ratings["movieId"].value_counts() active_users = user_counts[user_counts >= 20].index popular_movies = movie_counts[movie_counts >= 20].index ratings = ratings[ ratings["userId"].isin(active_users) & ratings["movieId"].isin(popular_movies) ] print(f"过滤后评分条数:{len(ratings)}") print(f"用户数:{ratings['userId'].nunique()},电影数:{ratings['movieId'].nunique()}") return ratings, movies

这段代码里user_counts >= 20和movie_counts >= 20是两个经验阈值。评分少于20条的用户,行为模式不稳定,参与相似度计算反而引入噪声;被评分少于20次的电影,推荐出去也没多少人能验证。过滤后数据量会减少,但推荐质量会提升。实际跑的时候先打印过滤前后的数量对比,写进论文里就是数据预处理章节的内容。

3.2 构建用户-物品评分矩阵

协同过滤的输入是一个用户×物品的评分矩阵。pandas 的 pivot 可以直接构建,但要注意内存。ml-latest-small 大概几千用户、几千电影,稠密矩阵也就几十兆,没问题。如果是 ml-1m 或更大,就要用稀疏矩阵。

from scipy.sparse import csr_matrix def build_matrix(ratings): """构建用户-物品评分矩阵 返回:稠密矩阵、用户ID映射、电影ID映射 """ # 重新编码用户和电影ID,从0开始连续 user_ids = ratings["userId"].unique() movie_ids = ratings["movieId"].unique() user_to_idx = {uid: i for i, uid in enumerate(user_ids)} movie_to_idx = {mid: i for i, mid in enumerate(movie_ids)} rows = ratings["userId"].map(user_to_idx).values cols = ratings["movieId"].map(movie_to_idx).values vals = ratings["rating"].values.astype(float) n_users = len(user_ids) n_movies = len(movie_ids) # 稀疏矩阵构建,适合大数据集 sparse_matrix = csr_matrix((vals, (rows, cols)), shape=(n_users, n_movies)) # 转稠密用于后续相似度计算,数据大时改用稀疏运算 dense_matrix = sparse_matrix.toarray() return dense_matrix, user_to_idx, movie_to_idx, user_ids, movie_ids

这里做了 ID 重映射,因为原始 userId 和 movieId 可能不连续(比如有用户注销后 ID 留空)。重映射后矩阵索引从0开始,后续所有操作都用索引而不是原始 ID。csr_matrix是稀疏存储,toarray()转稠密。如果数据集超过10万用户,建议保持稀疏,相似度计算用sklearn的稀疏接口,否则内存会爆。这个函数返回的映射字典要保存好,推荐结果最终要映射回原始 movieId 才能显示电影名。

3.3 项目目录结构与依赖清单

一个能交付的毕业设计项目,目录结构要清晰,答辩时老师一眼能看懂。我一般会这样组织:

movie_recommend/ ├── data/ │ ├── ratings.csv │ └── movies.csv ├── src/ │ ├── data_loader.py # 数据加载与预处理 │ ├── similarity.py # 相似度计算 │ ├── recommender.py # 推荐核心逻辑 │ ├── evaluate.py # 评测指标 │ └── main.py # 入口 ├── requirements.txt └── README.md

requirements.txt里写清楚版本,避免换台电脑就报错:

pandas==2.0.3 numpy==1.24.3 scikit-learn==1.3.0 scipy==1.11.1

版本号不是随便写的,pandas 2.x 和 1.x 在value_counts和map行为上有差异,numpy 1.24 以上对np.true_divide的where参数支持才完整。毕业设计答辩用的电脑不一定和你开发机一样,锁版本能省很多解释成本。

4. 避坑与排查:协同过滤跑不通的五个血泪经验

这一章是我带过几届毕业设计后攒下来的踩坑记录。协同过滤的代码看起来简单,但实际跑的时候各种玄学问题层出不穷。下面五条按“现象→原因→解决”写,遇到问题直接对号入座。

4.1 推荐结果全是冷门电影

现象:跑出来的 Top-N 推荐列表里,电影名字都很陌生,评分人数个位数。

原因:相似度矩阵计算时,冷门电影因为评分人数少,只要有一两个相似用户打高分,预测分就会被拉得很高。这是协同过滤的流行度偏差问题。

解决:在预测分上乘一个流行度惩罚项,或者直接在数据预处理阶段过滤掉评分次数少于阈值的电影。我一般用后者,简单粗暴。如果不想过滤,可以在排序时加一个score * log(1 + movie_rating_count)的加权。

4.2 相似度矩阵出现 NaN

现象:cosine_similarity返回的矩阵里有 NaN,后续计算全部污染。

原因:某个用户对所有电影的评分都是0(过滤后仍可能存在),导致其评分向量模长为0,余弦相似度分母为零。

解决:在计算相似度前检查每行是否全零,全零行直接剔除。代码里加一行matrix = matrix[matrix.sum(axis=1) > 0],同时同步更新用户映射。

4.3 评测指标 RMSE 高得离谱

现象:用测试集算 RMSE,结果超过2.0,而评分范围才0.5到5.0。

原因:预测时没有做评分截断,预测分可能超出评分范围;或者训练集和测试集划分时没有按用户分层,导致某些用户在测试集里的电影在训练集里完全没出现过。

解决:预测分做np.clip(pred, 0.5, 5.0)截断。划分数据集时用train_test_split的stratify参数按用户分层,保证每个用户在训练集里至少有一条评分。

4.4 跑一次要十几分钟

现象:生成推荐列表时卡住,CPU 跑满,十几分钟出不来结果。

原因:对每个用户的每部未评分电影都调用一次predict_rating,复杂度是 O(用户数 × 电影数 × k),数据量稍大就爆炸。

解决:改成矩阵运算。先用相似度矩阵和评分矩阵做矩阵乘法,一次性算出所有预测分,再对每个用户取 Top-N。核心代码是pred = sim_matrix.dot(ratings) / np.abs(sim_matrix).dot(ratings > 0),一行顶几千次循环。

4.5 换数据集后 ID 对不上

现象:推荐列表里的电影 ID 在movies.csv里找不到,显示为 NaN。

原因:构建矩阵时做了 ID 重映射,但输出推荐结果时忘了映射回原始 movieId。

解决:保存movie_ids数组,推荐结果拿到索引后,用movie_ids[idx]取回原始 ID,再和movies.csv做 merge。这个映射关系在build_matrix函数里已经返回了,关键是别在后续流程里丢掉。

5. 评测与调参:怎么证明你的推荐系统真的有效

代码跑通了,推荐列表也出来了,但答辩时老师一定会问:“你怎么知道推荐得准?”这一章讲评测指标和调参方法,让你的毕业设计从“能跑”变成“有说服力”。

5.1 评分预测指标:RMSE 和 MAE

RMSE(均方根误差)和 MAE(平均绝对误差)衡量的是预测评分和真实评分的差距。RMSE 对大误差更敏感,MAE 更稳健。两个都算,论文里放一起对比。

from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error, mean_absolute_error def evaluate_rating_prediction(ratings, test_size=0.2): """划分训练测试集,评测评分预测误差""" train, test = train_test_split(ratings, test_size=test_size, random_state=42) # 用训练集构建矩阵和相似度 train_matrix, user_to_idx, movie_to_idx, _, _ = build_matrix(train) sim_matrix = user_similarity_pearson(train_matrix) # 批量预测测试集 preds, truths = [], [] for _, row in test.iterrows(): u = user_to_idx.get(row["userId"]) m = movie_to_idx.get(row["movieId"]) if u is None or m is None: continue # 测试集里的冷启动用户/电影跳过 pred = predict_rating(u, m, train_matrix, sim_matrix, k=20) preds.append(np.clip(pred, 0.5, 5.0)) truths.append(row["rating"]) rmse = np.sqrt(mean_squared_error(truths, preds)) mae = mean_absolute_error(truths, preds) print(f"RMSE: {rmse:.4f}, MAE: {mae:.4f}, 测试样本数: {len(preds)}") return rmse, mae

这段代码里random_state=42保证每次划分一致,方便复现。user_to_idx.get()返回 None 时跳过,是因为测试集里可能有训练集没出现过的用户或电影,这些无法预测,强行算会报错。np.clip截断到评分范围。MovieLens 上 ItemCF 的 RMSE 一般在 0.85 到 0.95 之间,如果你的结果超过 1.0,检查相似度计算和 k 值。

5.2 Top-N 推荐指标:Precision@K 和 Recall@K

评分预测指标衡量的是“打分准不准”,但推荐系统实际关心的是“推的东西用户喜不喜欢”。Precision@K 表示推荐的前 K 个里有多少是用户真正喜欢的,Recall@K 表示用户喜欢的电影里有多少被推荐出来了。

def precision_recall_at_k(ratings, k=10, threshold=4.0, test_size=0.2): """计算 Precision@K 和 Recall@K threshold: 评分大于等于该值视为“喜欢” """ train, test = train_test_split(ratings, test_size=test_size, random_state=42) train_matrix, user_to_idx, movie_to_idx, _, movie_ids = build_matrix(train) sim_matrix = user_similarity_pearson(train_matrix) precisions, recalls = [], [] for uid in test["userId"].unique(): u = user_to_idx.get(uid) if u is None: continue # 用户喜欢的电影集合(测试集) user_test = test[(test["userId"] == uid) & (test["rating"] >= threshold)] if len(user_test) == 0: continue liked = set(user_test["movieId"].map(movie_to_idx).dropna().astype(int)) # 预测所有未评分电影的分数 scores = [] for m in range(train_matrix.shape[1]): if train_matrix[u, m] == 0: scores.append((m, predict_rating(u, m, train_matrix, sim_matrix, k=20))) scores.sort(key=lambda x: x[1], reverse=True) top_k = set([m for m, _ in scores[:k]]) hit = len(top_k & liked) precisions.append(hit / k) recalls.append(hit / len(liked) if liked else 0) print(f"Precision@{k}: {np.mean(precisions):.4f}") print(f"Recall@{k}: {np.mean(recalls):.4f}") return np.mean(precisions), np.mean(recalls)

threshold=4.0是定义“喜欢”的阈值,MovieLens 里 4 分以上通常算好评。k=10是推荐列表长度,论文里可以画一条 K 从 5 到 50 的曲线,展示 Precision 和 Recall 的权衡。注意这段代码里对每个用户遍历所有电影算预测分,实际跑会比较慢,可以用 4.4 节说的矩阵运算优化。

5.3 关键参数调优:相似度算法、K 值和阈值

调参是毕业设计里最容易出彩的部分。把不同参数组合的评测结果做成表格,论文里就是现成的实验章节。

参数候选值推荐范围影响
相似度算法余弦/皮尔逊/调整余弦皮尔逊皮尔逊对评分尺度差异更鲁棒
邻居数 K5/10/20/40/8020-40太小欠拟合,太大引入噪声
喜欢阈值3.0/3.5/4.0/4.54.0阈值越高,评测越严格
推荐列表长度5/10/20/5010影响 Precision 和 Recall 权衡

我一般会固定其他参数,单独调一个,画出折线图。比如 K 从 5 到 80,RMSE 先降后升,最低点就是最优 K。这个图放在论文里,比单纯说“我用了 K=20”有说服力得多。

5.4 用矩阵运算把评测速度提上来

5.2 节的代码跑一次可能要几分钟,调参时反复跑不现实。用矩阵运算重写预测部分:

def batch_predict(train_matrix, sim_matrix): """批量预测所有用户对所有电影的评分 利用矩阵运算替代循环 """ # 用户平均分 mask = train_matrix > 0 user_means = np.true_divide( (train_matrix * mask).sum(axis=1), mask.sum(axis=1), where=mask.sum(axis=1) != 0 ) user_means = np.nan_to_num(user_means) # 中心化评分矩阵 centered = np.where(mask, train_matrix - user_means[:, np.newaxis], 0) # 分子:相似度加权偏差之和 numerator = sim_matrix.dot(centered) # 分母:相似度绝对值之和(只对已评分项) denominator = np.abs(sim_matrix).dot(mask.astype(float)) # 避免除零 denominator[denominator == 0] = 1 pred = user_means[:, np.newaxis] + numerator / denominator return np.clip(pred, 0.5, 5.0)

这个函数一次性算出所有预测分,耗时从分钟级降到秒级。np.true_divide的where参数处理除零,np.nan_to_num兜底。denominator[denominator == 0] = 1是防止某些电影没人评分导致分母为零。调参时用这个批量版本,效率提升非常明显。

6. 从毕业设计到可演示系统:一个技巧让答辩加分

最后讲一个我压箱底的技巧:把推荐结果和电影海报、简介关联起来,做一个简单的 Web 界面。毕业设计答辩时,老师看代码看多了会疲劳,一个能点击、能切换用户、能看到推荐理由的界面,比一堆命令行输出有冲击力得多。

具体做法是用 Flask 起一个轻量服务,前端用最简单的 HTML 表格。核心是把推荐结果和movies.csv做 merge,拿到电影名和类型。推荐理由可以这样生成:“因为你喜欢《星球大战》和《帝国反击战》,为你推荐《绝地归来》”——这需要记录推荐时贡献最大的相似物品或相似用户。

def explain_recommendation(user_id, movie_id, train_matrix, sim_matrix, movie_ids, movies_df, top_n=3): """生成推荐理由:找出贡献最大的几部已看电影""" u = user_id m = movie_id # 找到看过这部电影的用户 rated_users = np.where(train_matrix[:, m] > 0)[0] if len(rated_users) == 0: return "暂无推荐理由" # 按相似度排序 sims = sim_matrix[u, rated_users] top_idx = np.argsort(sims)[-top_n:][::-1] reasons = [] for idx in top_idx: other_user = rated_users[idx] # 找这个相似用户看过、且目标用户也看过的电影 common = np.where((train_matrix[u] > 0) & (train_matrix[other_user] > 0))[0] if len(common) > 0: # 取评分最高的一部 best = common[np.argmax(train_matrix[u][common])] title = movies_df[movies_df["movieId"] == movie_ids[best]]["title"].values if len(title) > 0: reasons.append(title[0]) if reasons: return f"因为你喜欢《{'》《'.join(reasons[:2])}》,为你推荐" return "根据相似用户推荐"

这个函数返回的字符串直接拼到推荐列表里,答辩演示时效果很好。top_n=3控制理由数量,太多显得啰嗦。common找的是目标用户和相似用户都看过的电影,取目标用户评分最高的那部作为理由,逻辑上最自然。

界面部分用 Flask 的render_template传一个列表就行,不需要前端框架。用户切换用下拉框,选完提交表单重新请求。整个 Web 部分不超过100行代码,但能让你的毕业设计从“算法演示”变成“系统演示”。

我自己的习惯是:算法核心和评测部分保持命令行可复现,Web 界面只做展示层,不掺业务逻辑。这样论文里写算法章节时干净利落,答辩演示时又有东西可看。另外,推荐理由这个功能一定要做,它逼着你回头理解相似度矩阵的物理含义,对写论文的“结果分析”章节帮助很大。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询