ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电影推荐系统课设:协同过滤与用户推荐矩阵实战指南

电影推荐系统课设:协同过滤与用户推荐矩阵实战指南

简介:这份资源是面向计算机相关专业在校学生、课程设计或毕业设计阶段的开发者准备的电影推荐系统完整源码,基于协同过滤算法与用户推荐矩阵实现,适合作为课程设计、期末大作业或初期项目立项的参考案例。压缩包共包含10个文件,以data数据文件、py脚本、item与txt说明文档为主,整体约21.55MB,其中数据文件承载用户评分与相似度矩阵,Python脚本负责预处理、相似度计算与推荐逻辑,结构清晰便于按模块阅读。目前已有165人学习下载,具备一定的参考热度。读者可从中获取一套可运行的推荐系统实现方案,理解协同过滤中用户相似度计算、推荐点生成等核心环节,并借助预处理脚本与说明文档快速梳理项目流程,也可在此基础上修改扩展,实现更丰富的推荐功能。

1. 电影推荐系统课设:协同过滤和用户推荐矩阵到底怎么落地

做课程设计最怕选到一个“看起来简单、跑起来全是坑”的题目。电影推荐系统就是典型:网上一搜全是“基于协同过滤”的标题,真动手时发现用户评分矩阵稀疏得可怜,相似度算出来全是零,推荐结果要么是热门榜前二十,要么干脆报错。这个项目的核心其实就两件事——用协同过滤算法从历史评分里挖出“和你口味相近的人”,再用用户推荐矩阵把预测评分组织成可排序的推荐列表。它适合正在做课程设计、需要一套能跑通、能讲清楚、能写进报告的学生,也适合想快速理解推荐系统最小闭环的开发者。Python 生态里 numpy、pandas、scikit-learn 足够撑起整个流程,不需要上深度学习框架。下面按“数据怎么准备、矩阵怎么建、相似度怎么算、推荐怎么生成、坑怎么避”的顺序拆开讲。

2. 用户推荐矩阵的构建:从原始评分到可计算的稀疏矩阵

2.1 为什么不能直接拿原始评分表做计算

原始数据通常是一张长表,每行是“某用户对某电影打了某分”,字段大概是 userId、movieId、rating、timestamp。这张表直接拿来做协同过滤有两个问题:第一,计算用户相似度时需要对齐维度,长表没法直接做向量运算;第二,电影数量动辄几千上万,如果构建成稠密矩阵,内存直接爆炸。常见做法是构建用户-物品评分矩阵,行是用户、列是电影、值是评分,没有评分的位置填 0 或 NaN。这个矩阵天然稀疏,后续计算必须用稀疏矩阵或只对非零位置操作。

我一般会先用 pandas 做透视,再转成 numpy 数组或 scipy 稀疏矩阵。透视这一步有个细节:如果某个用户只看过一部电影,他在矩阵里只有一列有值,算相似度时和谁都不像,这类用户要么过滤掉,要么在推荐阶段单独处理。

import pandas as pd import numpy as np from scipy.sparse import csr_matrix # 假设 ratings_df 是从 csv 读入的评分数据 # 字段:userId, movieId, rating ratings_df = pd.read_csv("ratings.csv") # 过滤掉评分次数过少的用户和电影,降低稀疏度 user_counts = ratings_df["userId"].value_counts() movie_counts = ratings_df["movieId"].value_counts() ratings_df = ratings_df[ ratings_df["userId"].isin(user_counts[user_counts >= 5].index) & ratings_df["movieId"].isin(movie_counts[movie_counts >= 5].index) ] # 构建用户-电影评分矩阵 user_movie_matrix = ratings_df.pivot_table( index="userId", columns="movieId", values="rating" ).fillna(0) # 转成稀疏矩阵,节省内存 sparse_matrix = csr_matrix(user_movie_matrix.values) print("矩阵形状:", user_movie_matrix.shape) print("稀疏度:", 1 - sparse_matrix.nnz / (sparse_matrix.shape[0] * sparse_matrix.shape[1]))

这段代码的关键在过滤阈值。user_counts >= 5和movie_counts >= 5是我常用的起步值,低于这个数的用户或电影对相似度计算的贡献极小,反而增加噪声。pivot_table默认用均值聚合,但这里每个用户对每部电影只有一条评分,所以不会触发聚合。fillna(0)把缺失评分填成 0,注意 0 在评分体系里代表“未评分”,不是“打了 0 分”,后续计算余弦相似度时 0 不参与点积,所以没问题。csr_matrix按行压缩存储,适合按用户取行的场景。

2.2 评分矩阵的归一化与去偏

用户打分习惯差异很大:有人习惯打 4 到 5 分,有人只打 1 到 3 分。如果不做归一化,相似度会被这种个人偏置带偏。常见做法是减去用户平均分,把评分变成“高于/低于个人平均”的偏差值。这样两个用户即使绝对分数不同,只要相对偏好一致,相似度依然高。

# 计算每个用户的平均分(只对已评分项求平均) user_means = np.true_divide( user_movie_matrix.sum(axis=1), (user_movie_matrix != 0).sum(axis=1) ).replace([np.inf, -np.inf], 0) # 中心化:已评分项减去用户平均分,未评分项保持 0 centered_matrix = user_movie_matrix.sub(user_means, axis=0) centered_matrix[user_movie_matrix == 0] = 0 centered_sparse = csr_matrix(centered_matrix.values)

user_means的计算要小心除零:如果某个用户所有评分都被过滤掉了,分母为 0,结果变成 inf。用replace把 inf 和 -inf 替换成 0 是兜底做法。中心化之后,未评分位置仍然保持 0,这样在余弦相似度计算中不会引入虚假信号。这一步在报告里可以写成“消除用户评分偏置”,是加分项。

2.3 相似度计算的选型:余弦、皮尔逊还是调整余弦

协同过滤里用户相似度常用三种:余弦相似度、皮尔逊相关系数、调整余弦相似度。余弦相似度直接算两个向量的夹角,对未评分项填 0 敏感;皮尔逊先减去均值再算相关,等价于中心化后的余弦;调整余弦则是减去物品平均分。对于电影评分场景,我一般用中心化后的余弦,也就是先做 2.2 的归一化再算余弦,效果比原始余弦稳。

from sklearn.metrics.pairwise import cosine_similarity # 计算用户之间的相似度矩阵 user_similarity = cosine_similarity(centered_sparse) np.fill_diagonal(user_similarity, 0) # 自己和自己不算相似 # 取某个用户最相似的 K 个邻居 def get_top_k_similar_users(user_index, similarity_matrix, k=20): sim_scores = list(enumerate(similarity_matrix[user_index])) sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True) return sim_scores[1:k+1] # 跳过自己

cosine_similarity接收稀疏矩阵,返回稠密相似度矩阵。如果用户数上万,这个矩阵会很大,可以改用NearestNeighbors或分块计算。np.fill_diagonal把对角线置零,避免推荐时把自己算成最相似邻居。get_top_k_similar_users里k=20是经验值,用户数少可以调到 10,用户数多可以到 50,但太大反而引入不相关邻居。

3. 协同过滤推荐生成:从相似用户到电影打分排序

3.1 基于用户的协同过滤预测公式

拿到 K 个相似用户后,预测目标用户对某部未看电影的评分,公式是:预测分 = 用户平均分 + 相似度加权平均的邻居偏差。用数学写就是pred = mean_u + sum(sim * (rating_neighbor - mean_neighbor)) / sum(sim)。这个公式比直接加权平均原始评分更合理,因为它考虑了邻居自己的打分偏置。

def predict_rating(user_index, movie_index, user_movie_matrix, user_similarity, user_means, k=20): # 找到看过这部电影的相似用户 movie_ratings = user_movie_matrix[:, movie_index] rated_users = np.where(movie_ratings > 0)[0] if len(rated_users) == 0: return user_means[user_index] # 没人看过,返回用户平均分 # 取相似度最高的 k 个已评分用户 sim_scores = [(u, user_similarity[user_index, u]) for u in rated_users] sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)[:k] numerator = 0.0 denominator = 0.0 for neighbor, sim in sim_scores: if sim <= 0: continue rating = user_movie_matrix[neighbor, movie_index] numerator += sim * (rating - user_means[neighbor]) denominator += abs(sim) if denominator == 0: return user_means[user_index] return user_means[user_index] + numerator / denominator

这段代码有几个关键点。movie_ratings > 0用来找看过这部电影的用户,因为 0 代表未评分。sim <= 0的邻居直接跳过,负相似度意味着口味相反,加权进去会拉低预测质量。denominator用abs(sim)防止负相似度抵消。如果所有邻居相似度都是负的,分母为 0,回退到用户平均分。这个回退策略在报告里可以写成“冷启动兜底”。

3.2 生成 Top-N 推荐列表

预测完所有未看电影的评分后,按分数排序取前 N 部。注意要排除用户已经看过的电影,否则推荐列表里全是看过的,用户会觉得系统坏了。

def recommend_movies(user_index, user_movie_matrix, user_similarity, user_means, movie_ids, top_n=10, k=20): num_movies = user_movie_matrix.shape[1] watched = user_movie_matrix[user_index].nonzero()[1] predictions = [] for movie_index in range(num_movies): if movie_index in watched: continue pred = predict_rating(user_index, movie_index, user_movie_matrix, user_similarity, user_means, k) predictions.append((movie_index, pred)) predictions.sort(key=lambda x: x[1], reverse=True) top_movies = predictions[:top_n] return [(movie_ids[idx], round(score, 2)) for idx, score in top_movies]

watched用nonzero取已评分电影的列索引,避免推荐已看内容。predictions列表存的是电影索引和预测分,排序后取前 N。movie_ids是列索引到真实电影 ID 的映射,输出时转回去。这个函数对每个用户都要遍历所有电影,用户多的时候会慢,可以先用相似度矩阵筛出候选电影集合再预测,减少计算量。

3.3 评估指标:RMSE 和 Hit Rate 怎么算

课程设计报告里通常要放评估。评分预测用 RMSE,推荐列表用 Hit Rate 或 Precision@K。RMSE 是预测分和真实分的均方根误差,越低越好。Hit Rate 是推荐列表里有多少比例命中了用户实际看过的电影。

from sklearn.model_selection import train_test_split # 划分训练集和测试集 train_df, test_df = train_test_split(ratings_df, test_size=0.2, random_state=42) # 在训练集上构建矩阵和相似度,在测试集上算 RMSE # ...(省略矩阵构建和相似度计算,同上) def compute_rmse(test_df, user_movie_matrix, user_similarity, user_means, user_id_to_index, movie_id_to_index): errors = [] for _, row in test_df.iterrows(): u_idx = user_id_to_index.get(row["userId"]) m_idx = movie_id_to_index.get(row["movieId"]) if u_idx is None or m_idx is None: continue pred = predict_rating(u_idx, m_idx, user_movie_matrix, user_similarity, user_means) errors.append((pred - row["rating"]) ** 2) return np.sqrt(np.mean(errors))

train_test_split按 8:2 划分,random_state固定保证可复现。user_id_to_index和movie_id_to_index是 ID 到矩阵索引的映射字典,构建矩阵时顺手生成。RMSE 计算时跳过训练集里没出现过的用户或电影,否则索引对不上。Hit Rate 的计算类似,对每个测试用户生成 Top-N 推荐,看测试集里该用户看过的电影有多少落在推荐列表里。

4. 避坑与排查:协同过滤课设里最容易翻车的五个地方

4.1 相似度矩阵全是零或 NaN

现象:算出来的user_similarity大部分是 0,或者出现 NaN,推荐结果全是热门电影。原因通常是评分矩阵太稀疏,两个用户没有共同评分电影,余弦相似度为 0;或者某个用户所有评分都被过滤后,中心化时除以 0 产生 NaN。解决:降低过滤阈值,比如从 5 降到 3;检查user_means计算时是否有除零,用np.nan_to_num兜底;如果数据本身太稀疏,考虑改用基于物品的协同过滤,物品共同评分用户通常比用户共同评分电影多。

4.2 推荐列表里全是已经看过的电影

现象:给用户推荐了 10 部电影,用户说“这些我都看过了”。原因是在生成推荐时没有排除已评分电影,或者排除逻辑写错了。解决:用user_movie_matrix[user_index].nonzero()[1]取已看电影索引,在循环里continue跳过。注意nonzero返回的是列索引,不是电影 ID,别搞混。

4.3 预测评分超出 1 到 5 的范围

现象:预测分出现 6.2 或 -0.5,排序时这些异常值排在最前面。原因是加权公式里邻居偏差可能很大,加上用户平均分后越界。解决:在predict_rating返回前做截断,return max(1.0, min(5.0, pred))。这个截断在报告里可以写成“评分边界约束”,是工程上常见的后处理。

4.4 计算太慢,跑一次要十几分钟

现象:用户数几千、电影数上万时,cosine_similarity和逐电影预测耗时很长。原因是相似度矩阵是稠密的,预测时对每部电影都遍历所有邻居。解决:用NearestNeighbors的kneighbors只算 K 近邻,不存完整相似度矩阵;预测时只对候选电影集合计算,候选集可以取相似用户看过的电影并集,而不是全部电影。这样能把计算量降一个数量级。

4.5 训练集和测试集划分后索引对不上

现象:RMSE 计算时报 KeyError,或者预测时取到错误的用户。原因是划分后训练集里的用户 ID 和原始矩阵的索引映射不一致。解决:先在完整数据上构建user_id_to_index和movie_id_to_index,再划分训练测试集;或者划分后重新构建映射,但测试集里出现训练集没有的用户时直接跳过。我一般用前者,映射关系稳定,不容易出错。

5. 进阶技巧:用矩阵分解和混合推荐把效果再拉一档

协同过滤跑通之后,如果报告想拿高分,可以加一个矩阵分解的对比实验。核心思路是把用户-物品评分矩阵分解成两个低秩矩阵的乘积,用梯度下降最小化预测误差。Python 里可以用surprise库,也可以手写 SGD。手写版本更能体现理解,代码量也不大。

import numpy as np def matrix_factorization(R, K=20, steps=500, alpha=0.002, beta=0.02): """R: 评分矩阵,K: 隐因子维度,steps: 迭代次数""" num_users, num_items = R.shape P = np.random.normal(scale=1./K, size=(num_users, K)) Q = np.random.normal(scale=1./K, size=(num_items, K)) for step in range(steps): for i in range(num_users): for j in range(num_items): if R[i, j] > 0: eij = R[i, j] - np.dot(P[i, :], Q[j, :].T) P[i, :] += alpha * (2 * eij * Q[j, :] - beta * P[i, :]) Q[j, :] += alpha * (2 * eij * P[i, :] - beta * Q[j, :]) # 每 50 步打印一次损失 if step % 50 == 0: loss = 0 for i in range(num_users): for j in range(num_items): if R[i, j] > 0: loss += (R[i, j] - np.dot(P[i, :], Q[j, :].T)) ** 2 loss += beta/2 * (np.linalg.norm(P[i, :])**2 + np.linalg.norm(Q[j, :])**2) print(f"step {step}, loss {loss:.2f}") return P, Q

K=20是隐因子维度,电影推荐常用 10 到 50,太小欠拟合,太大过拟合。alpha是学习率,beta是正则化系数,防止 P 和 Q 数值爆炸。这个实现是朴素 SGD,每次只用一个评分更新,收敛慢但代码直观。跑通后可以把预测分和协同过滤的预测分做加权融合,比如final_score = 0.6 * cf_score + 0.4 * mf_score,通常比单一方法稳。混合推荐在报告里可以写成“多模型融合策略”,是加分项。

验证方法上,除了 RMSE,还可以看推荐列表的多样性:统计 Top-N 里不同电影类型的数量,多样性太低说明系统只推热门。我一般会在推荐后加一个简单的重排,把同一类型的电影限制在 3 部以内,强制拉开多样性。这个技巧不需要改模型,只在输出层做后处理,性价比很高。

最后说个血泪经验:课设代码一定要把随机种子固定住,np.random.seed(42)和random_state=42都加上。否则每次跑出来的 RMSE 都不一样,报告里的数字对不上,答辩时被问“为什么两次结果不同”会很被动。另外,矩阵分解的 SGD 对学习率很敏感,alpha从 0.002 起步,跑不通就降到 0.001,别一上来就 0.01,容易发散。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进