简介:基于Python的协同过滤电影推荐系统源码包,面向推荐系统开发者、算法学习者及计算机相关专业学生,可用于毕业设计、课程项目或技术入门。包内共228个文件,以17个Python源码文件为核心,覆盖数据预处理、用户与物品相似度计算、基于用户与基于物品的协同过滤、推荐结果生成与排序等完整流程;另含csv评分与电影数据集、pyc缓存、js/css前端页面及SQL脚本等,整体约6.92MB,目录结构清晰便于按模块查阅。实现中涉及余弦相似度、皮尔逊相关系数等常用度量,并配有简易交互界面,便于直接运行与观察推荐效果。已有60人学习下载,对希望快速上手电影推荐系统开发或深入理解协同过滤算法细节的读者而言,是一份可运行、可改造的实践参考。
1. 基于Python协同过滤算法的电影推荐系统:这份源码到底能跑出什么
假设你手上有一份MovieLens评分表,几万行“用户-电影-评分”三元组。你要做的不是查SQL,而是回答一个具体问题:用户A没看过的电影里,他最可能给出高分的是哪几部?基于Python协同过滤算法的电影推荐系统,正是用纯Python(pandas + numpy)把这个问题落地的源码项目。它不依赖TensorFlow,不引入深度模型,核心是协同过滤算法里的UserCF与ItemCF两种经典变体。适合三类人:刚学完Python基础、想做课程设计的学生;需要一个快速基线模型的算法工程师;以及想给原型系统加上“猜你喜欢”功能的产品开发者。接下来我会按源码的实现顺序,把数据装载、矩阵构建、相似度计算、推荐生成和评估这条链路完整讲透。
2. 协同过滤原理与选型:UserCF、ItemCF和三种相似度度量的取舍
2.1 基于用户的协同过滤(UserCF):先找相似的人,再推他们的片单
基于用户的协同过滤(User-Based Collaborative Filtering,简称UserCF)的直觉来自“人以群分”。假设用户A给《肖申克的救赎》《阿甘正传》《低俗小说》都打了接近满分的评价,用户B也恰好对《肖申克的救赎》和《阿甘正传》打了高分,系统就有理由推断A和B的观影口味相近。此时B给过高分的《绿里奇迹》A还没看过,它就会被选进候选推荐列表。
这个流程在源码里通常拆成两段:离线段和在线段。离线段要两两计算用户之间的相似度,形成用户相似度矩阵;在线段则从目标用户的K个最近邻居看过的电影里,剔除已看过的,再按预测评分排序取Top-N。核心代码常见这样写:
# UserCF 离线相似度计算 def compute_user_similarity(ratings): """ ratings: dict[user_id][movie_id] = rating 返回值: dict[user_id] -> [(neighbor_id, sim), ...] """ users = list(ratings.keys()) sim_matrix = {u: {} for u in users} for i in range(len(users)): for j in range(i + 1, len(users)): u1, u2 = users[i], users[j] # 取两个用户共同评过分的电影集合 common = set(ratings[u1].keys()) & set(ratings[u2].keys()) if len(common) < 2: # 共同评过分至少2部电影,结果才可信 continue sim = cosine_similarity(ratings[u1], ratings[u2], common) sim_matrix[u1][u2] = sim_matrix[u2][u1] = sim return sim_matrix这段代码的关键在common交集。如果两个用户只共同看过一部电影,算出的相似度是随机噪声;把共同项阈值设成2或5,是控制推荐结果稳定性的第一个开关。cosine_similarity内部实现是标准的余弦夹角:先按共同电影取出两个评分向量,再点积除以模长。这里要注意,向量里只包含共同评分的电影,不能用全量向量补0,否则稀疏矩阵会把无数不相关用户拉成“高度相似”,推荐质量会明显下降。
选UserCF还是ItemCF,实践里有个简单判断标准:如果用户数远大于物品数,计算用户相似度矩阵的代价就更大。电影推荐场景里电影数量在几千到几十万,用户量却可能破千万,工业界因此更倾向ItemCF。但在课程设计这种用户只有几百的场景,UserCF反而更好调试,因为你可以直接抽查某个用户,看他的邻居画像是否合理。
2.2 基于物品的协同过滤(ItemCF):先算电影邻居,再借历史评分加权
基于物品的协同过滤(Item-Based Collaborative Filtering,简称ItemCF)思路恰好反过来,它把“相似的电影”作为推荐单元。逻辑是:如果很多人同时给《盗梦空间》和《星际穿越》打高分,这两部电影在行为数据上就是相似的。之后用户对《盗梦空间》给出好评,系统直接把《星际穿越》推给他,不需要知道用户和谁口味相似。
ItemCF相比UserCF有更直观的可解释性,推荐理由能写成“因为你看过《盗梦空间》,所以推荐《星际穿越》”。源码实现里,离线计算从用户两两相似变成电影两两相似。同样的数据量下,电影相似度矩阵的行列数由电影数量决定,通常比用户矩阵更大但更稀疏。一份典型实现是:
# ItemCF 离线计算电影相似度矩阵 def compute_item_similarity(ratings): """ ratings: dict[user_id][movie_id] = rating 返回值: dict[movie_id] -> [(neighbor_id, sim), ...] """ movies = set() for ratings_of_user in ratings.values(): movies.update(ratings_of_user.keys()) # 先统计每部电影被哪些用户评过分,建立倒排索引 movie_users = {m: set() for m in movies} for uid, movie_ratings in ratings.items(): for mid in movie_ratings: movie_users[mid].add(uid) item_sim = {} movie_list = list(movies) for i in range(len(movie_list)): for j in range(i + 1, len(movie_list)): m1, m2 = movie_list[i], movie_list[j] common_users = movie_users[m1] & movie_users[m2] if len(common_users) < 2: continue # 用余弦相似度,评分就是向量分量 dot = sum(ratings[u][m1] * ratings[u][m2] for u in common_users) norm1 = sum(ratings[u][m1] ** 2 for u in movie_users[m1]) ** 0.5 norm2 = sum(ratings[u][m2] ** 2 for u in movie_users[m2]) ** 0.5 item_sim.setdefault(m1, {})[m2] = dot / (norm1 * norm2) item_sim.setdefault(m2, {})[m1] = dot / (norm1 * norm2) return item_sim这里有个性能上容易翻车的点:双重循环遍历电影列表,时间复杂度是O(M²),M是电影数量。MovieLens 100K数据集里电影数是1682,全量两两计算只有约140万对,纯Python勉强能扛。如果换成10万部电影的工业数据集,这个双重循环会膨胀到百亿级别。所以业内通常用倒排索引加Top-N截断——只保留每部电影相似度最高的几十个邻居,而不是全量矩阵。很多初学者把源码原样换成大数据集,跑一晚上没结束,就以为死循环了,实际上是没估算复杂度。这里的movie_users倒排索引就是为后续优化预留的接口,把内层循环改成只遍历候选电影,能节省一个数量级的时间。
2.3 余弦、皮尔逊、杰卡德:三种相似度各自的适用边界
相似度度量是协同过滤的灵魂,选错公式,推荐结果会变得很怪。三个度量各有侧重,源码里通常都封装好,留一个参数切换。
余弦相似度只关心评分向量方向,不关心长度。它天然适配“两个用户打分基准不同但趋势一致”的情况。实现上点积除以模长即可,但要注意:如果用户评分向量里没评分的位补0,稀疏矩阵会让大量用户被算成“完全不相似”,这也是源码里用共同评分集合而不补0的原因。
皮尔逊相关系数会对每个用户的评分先做均值中心化,减去个人平均分后再算余弦。这一步消除了“有人手松给分高,有人手紧给分低”的系统性偏差。举个例子,A的平均分是4.5,B的平均分是3.0,两人对同一部电影的评分都高于各自平均分0.5分,余弦会认为他们不够相似,皮尔逊却会觉得口味一致。在评分习惯差异明显的场景里,皮尔逊通常优于余弦。
杰卡德相似系数不考虑评分数值,只看两个用户共同评过多少部电影,交集除以并集。它适用于评分记录稀疏、数值本身不可信的场景,比如隐式反馈(点过=1,没点=0)。但如果评分是1到5的整数,直接用杰卡德会丢掉大量信息,属于信息利用不充分。
我一般按这个顺序做实验:先跑皮尔逊,看RMSE是否合理;效果差就换余弦对比一次;数据若是隐式反馈则直接上杰卡德。三种度量不是可以随意替换的等价选项,它们对应的是“评分习惯是否偏移”“数据是否稀疏”“是显式还是隐式反馈”三个不同假设。源码里推荐函数把这三个函数并列封装,正是为了做对照实验时不改主流程。
3. 复现这份源码:MovieLens数据集装载、评分矩阵构建与推荐主流程
3.1 u.data字段与装载脚本:三个参数别抄错
打开源码包里的data目录,最常见的是三份文件:u.data(评分主表)、u.user(用户属性)、u.item(电影属性)。u.data每一行是user_id movie_id rating timestamp,制表符分隔。rating取值是1到5的整数,timestamp是Unix时间戳,推荐计算里基本用不到,但保留着方便做时间维度的切分。
先把评分表装进内存,常见做法是用pandas的read_csv:
import pandas as pd # 加载 MovieLens 100K 的 u.data df = pd.read_csv( "data/u.data", sep="\t", # 注意是制表符,不是逗号 header=None, # 原文件没有列名,必须指定 names=["user_id", "movie_id", "rating", "timestamp"] ) print(df.head()) print("用户数:", df["user_id"].nunique()) print("电影数:", df["movie_id"].nunique()) print("评分总数:", len(df))参数说明:sep="\t"必须写对,这是网上抄代码时最容易出错的地方,写成逗号后pandas会把整行读成一列;header=None表示文件本身没有列名,列名由names参数补充。三个实际输出的数量在100K数据集上通常是943、1682、100000,对不上就先检查数据集版本。字段装载是整套源码的地基,这里读错,后面的矩阵和相似度全是错位数据,而且极难察觉——评分全对,推荐出来的电影ID对不上,这是典型的“数据没洗干净的玄学问题”。
3.2 从DataFrame到评分矩阵:稀疏矩阵与字典两种存储选型
协同过滤的计算核心是“用户×电影”的二维矩阵。但直接用pandas构造943×1682的稠密DataFrame,每个格子都填值,矩阵里93%以上的位置是空值,内存和算力都浪费在零上。源码里常见两种做法:scipy.sparse稀疏矩阵和dict套dict结构。前者面向计算,后者面向调试。
from scipy.sparse import csr_matrix # 方式一:scipy 稀疏矩阵,适合相似度计算 user_ids = df["user_id"].astype("category").cat.codes.values movie_ids = df["movie_id"].astype("category").cat.codes.values ratings = df["rating"].values rating_matrix = csr_matrix((ratings, (user_ids, movie_ids))) print("稀疏矩阵形状:", rating_matrix.shape) print("非零元素个数:", rating_matrix.nnz) # 方式二:字典套字典,适合写循环和调试 def df_to_dict(df): data = {} for row in df.itertuples(index=False): uid, mid, rating = row.user_id, row.movie_id, row.rating data.setdefault(uid, {})[mid] = rating return data rating_dict = df_to_dict(df) print("用户196的评分记录数:", len(rating_dict[196]))逻辑说明:把user_id和movie_id用astype("category").cat.codes转成连续的整数编码,是为了能用数组下标直接定位矩阵,避免字符串查表拖慢后续的密集循环。稀疏矩阵只存非零元素,形式上还是943行×1682列,内存占用却只和10万个真实评分相关。字典版本是逻辑调试的入口,写推荐循环时rating_dict[uid][mid]一眼能看懂,但它的哈希表开销比稀疏矩阵大一个量级,适合数据量小、需要频繁读单条记录的阶段。
这里要提醒一句:两种结构不能混用索引。稀疏矩阵的下标是重编码后的整数,字典的key是原始user_id,两者不一致。在数据处理流程里,我会把“原始ID转编码”的映射关系保存为两个字典,否则最后推荐结果输出给业务系统时,根本没有办法把电影编码翻译回电影原名。
3.3 UserCF预测函数:逐行看懂加权平均评分
这是整套源码最核心的一段逻辑。给定目标用户u,先从相似度最高的K个邻居里收集候选电影,再用邻居的评分做加权平均,权重就是相似度。
def predict_rating(u, i, user_sim, rating_dict, k=20): """ 预测用户 u 对电影 i 的评分 u: 目标用户id i: 目标电影id user_sim: 用户相似度字典 {u: [(neighbor_id, sim), ...]} rating_dict: 用户评分字典 {u: {i: rating}} k: 参与预测的邻居数量 """ neighbors = user_sim[u][:k] # 只取相似度最高的前 k 个邻居 total_sim = 0.0 weighted_sum = 0.0 for neighbor, sim in neighbors: # 邻居必须真的给电影 i 评过分 if i in rating_dict.get(neighbor, {}): r_ni = rating_dict[neighbor][i] # 邻居对 i 的实际评分 weighted_sum += sim * r_ni total_sim += sim if total_sim == 0: return None # 没有邻居评过,放弃预测 return weighted_sum / total_sim # 相似度加权平均分参数说明:k=20是邻居数量,是后续调参最关键的旋钮。weighted_sum / total_sim是相似度加权平均,相似度越高的邻居说话越有分量。这里没有做均值中心化,工程上可以先用这个版本跑通,再看要不要引入中心化提高精度。返回None的情况会被上层跳过滤掉,进入冷启动兜底分支——如果你发现预测结果大量是None,先检查相似度邻居是不是为空,而不是怀疑预测函数本身。
值得注意,这个预测函数是“逐部电影”计算的,实际生成推荐列表时,要枚举所有用户没看过的电影逐一预测,再排序取前N。在943个用户、1682部电影的数据集上,这个循环还能跑动;用户和电影数量各放大100倍时,就必须改成先聚合候选集再预测,或者改用矩阵分解。这也是为什么源码的注释里通常会写“本实现面向教学场景,生产环境请改用近似最近邻”。
3.4 ItemCF推荐列表生成:历史评分乘相似度再累加
ItemCF在生成推荐时不需要实时遍历全部用户,而是从用户的历史评分电影出发,把每部电影的相似邻居捞出来,按“历史评分×电影相似度”累加得分排序。
def recommend_item_cf(rating_dict, item_sim, user_id, top_n=10, k=10): """ 基于 ItemCF 给用户推荐 top_n 部电影 item_sim: 电影相似度字典 {m: [(neighbor_movie_id, sim), ...]} k: 每部已看电影取多少个相似邻居 """ user_rated = rating_dict.get(user_id, {}) scores = {} # movie_id -> 累计得分 for movie, r in user_rated.items(): # 从每部已看过的电影,找它最相似的 k 部电影 for sim_movie, sim in item_sim.get(movie, [])[:k]: if sim_movie in user_rated: # 已看过的电影不再推荐 continue # 得分 = 历史评分 × 两部电影的相似度 scores[sim_movie] = scores.get(sim_movie, 0.0) + r * sim ranked = sorted(scores.items(), key=lambda x: -x[1]) return [mid for mid, _ in ranked[:top_n]]逻辑说明:r * sim是ItemCF经典的打分公式。用户给《盗梦空间》的评分越高,且《盗梦空间》与《星际穿越》越相似,《星际穿越》的候选得分就越高。scores字典把同一部电影来自不同历史入口的得分累加,自然融入“看过越多相似片,排名越靠前”的信号。[:k]截断是性能开关,实际使用中k取10到50,太大不会显著提升效果,反而会把相似度很低的噪音电影拖进来。
到这里,主干逻辑已经跑通。最小复现命令通常是:
python main.py --alg item_cf --top_n 10 --k 10如果输出里有一半电影是用户已经看过的,检查recommend_item_cf里是否漏掉了user_rated过滤,这一行决定推荐列表是惊喜还是废话。完整跑通后,下一步就是调参看指标,下面第4章会把最影响效果的四个参数逐一说清楚。
4. 调好四个旋钮:邻居数、均值中心化、相似度阈值与评估指标
4.1 邻居数K:从“覆盖”到“精准”的平衡点
K是协同过滤里最直观也最影响效果的参数。K太小,参与加权的邻居太少,预测评分方差大,偶尔会输出极端值;K太大,大量相似度很低的邻居进入加权,把预测结果往全局平均分拉,推荐列表变得中庸。
MovieLens 100K上,UserCF的K常见取值是10到80。K=20时覆盖率不会太低,推荐的电影相对精准;K=80时RMSE通常会略微下降,但推荐列表里开始出现用户完全没兴趣的冷门片。实践里我用一组小实验来定K:分别跑K=10、20、30、40、50、80,打印RMSE和Top-10命中率,选两者乘积最大的K。如果你的源码实现了网格搜索也不难,但要注意,K调整后相似度矩阵本身不变,不需要重新计算,只是预测阶段取邻居数量不同,所以跑一组K值的时间成本很低。
提示:K的调参结果高度依赖数据规模,换一个数据集后原来的最优K就不作数了,不要迷信别人博客里的“K=20最优”。
4.2 评分均值中心化:防止“手松”和“手紧”干扰预测
在2.3节提过皮尔逊协作就做了均值中心化。同样的道理,UserCF的加权平均公式里如果不做中心化,手松的用户(平均分4.8)天然比手紧的用户(平均分2.5)更有“话语权”。因为预测公式里邻居评分直接参与加权,手松用户的评分普遍偏高,他作为邻居时会系统性拉高预测值。
改进后的预测公式长这样:
def predict_rating_centered(u, i, user_sim, rating_dict, avg_rating, k=20): """ avg_rating: dict[user_id] -> 该用户的平均评分 预测值 = 目标用户平均分 + 邻居评分偏差的加权平均 """ neighbors = user_sim[u][:k] total_sim = 0.0 weighted_sum = 0.0 for neighbor, sim in neighbors: if i in rating_dict.get(neighbor, {}): r_ni = rating_dict[neighbor][i] # 减掉邻居自身平均分,只取“偏差”参与加权 weighted_sum += sim * (r_ni - avg_rating[neighbor]) total_sim += sim if total_sim == 0: return None return avg_rating[u] + weighted_sum / total_sim逻辑说明:avg_rating[u]是目标用户自己的平均分,代表他的基准打分习惯;r_ni - avg_rating[neighbor]是邻居对这部电影的“额外喜好程度”。中心化之后,手松和手紧的差异被抹平,预测的是“这个用户在自己基准之上,会对这部电影有多少额外好感”。这项改动通常能明显降低RMSE,也是源码里“简单版”和“完整版”预测函数的主要区别。
4.3 共同评分项阈值:过滤“偶然重合”的伪邻居
相似度计算里有一层隐藏参数:共同评分项数量下限。两个用户只共同看过一部电影时,余弦相似度不是1就是-1,因为只有一维向量,方向必然完全一致或完全相反。这种相似度没有任何统计意义,却会把完全不搭边的用户配对成“最佳邻居”。
源码里通常会对相似度计算加一个条件:
def similarity_with_min_common(ratings_u1, ratings_u2, min_common=5): common = set(ratings_u1.keys()) & set(ratings_u2.keys()) if len(common) < min_common: return 0.0 # 共同评分太少,认为不相似 # 继续算余弦或皮尔逊 ...参数说明:min_common=5表示两个用户至少共同评过5部电影,相似度才被接受。这个阈值在UserCF里建议设2到10之间,设太高会把活跃用户的大部分邻居过滤掉,推荐覆盖率暴跌;设太低又会混入大量随机邻居。判断标准很简单:打印一下每个用户的有效邻居数量,如果大量用户邻居数不足K,说明min_common设高了,或者数据本身太稀疏。
数据稀疏时,这个阈值就是“后悔药”——先把阈值调低跑通,再逐步调高看指标变化。很多源码里没有暴露这个参数,如果你想改,通常只需要在相似度函数入口处加这一行判断,不需要动主流程。
4.4 RMSE、Precision@N与召回率:一次把评估脚本写对
推荐系统的评估,最怕只用“预测准不准”一个维度,因为RMSE低不代表推荐列表用户爱看。我一般会同时跑三个指标:RMSE看回归精度,Precision@N和召回率看排序质量。
from sklearn.metrics import mean_squared_error def evaluate(test_ratings, predict_func, k=20): """ test_ratings: dict[user_id][movie_id] = rating 返回 RMSE、Precision@10、Recall@10 """ y_true, y_pred = [], [] hit_count = 0 total_recommend = 0 total_relevant = 0 for uid, items in test_ratings.items(): # 收集该用户在测试集里的真实评分 recent_items = list(items.keys()) if len(recent_items) == 0: continue # 对候选电影预测评分 for mid, true_r in items.items(): pred_r = predict_func(uid, mid, k=k) if pred_r is None: continue y_true.append(true_r) y_pred.append(pred_r) # 取预测分数最高的前10部电影作为推荐列表 recommend_list = top_n_recommend(uid, predict_func, top_n=10, seen=set(items.keys())) hit_count += len(set(recommend_list) & set(recent_items)) total_recommend += 10 total_relevant += min(len(recent_items), 10) rmse = mean_squared_error(y_true, y_pred) ** 0.5 precision = hit_count / total_recommend recall = hit_count / total_relevant if total_relevant else 0.0 return rmse, precision, recall参数说明:Precision@10衡量推荐列表里有多少电影是用户确实看过的,Recall@10衡量推荐列表覆盖了用户真实观看行为的多大比例。这两项在电影场景里通常互斥,选阈值时要看产品侧更在意“推荐的是不是精品”还是“能覆盖多少兴趣面”。
这里必须强调评估数据分割方式:如果随机打乱评分再分割成训练集和测试集,同一用户对同一部电影的评分不会出现两次,所以随机分割在这类数据上不算严重泄漏。但按用户分割更贴近真实场景——用用户前80%观看行为训练,后20%验证,模拟的正是“已知历史,预测未来”。两种分割方式得出的RMSE差距通常不小,源码里建议两种都保留,对比着看。
5. 避坑指南:协同过滤源码跑起来之后的五个常见问题
5.1 新用户推荐列表为空:冷启动问题没有兜底方案
现象:新注册用户没有任何评分记录,调用推荐函数直接返回空列表。原因:协同过滤的推荐流程依赖历史评分。UserCF要和新用户算相似度,ItemCF要拿新用户的评分记录去关联相似电影,两者都要求用户先有行为数据,这是协同过滤与生俱来的冷启动短板。解决:在推荐入口加一个兜底分支,无历史行为时按全局热度榜单推荐:
def recommend_with_fallback(rating_dict, user_id, top_n=10): if user_id not in rating_dict or len(rating_dict[user_id]) == 0: return global_hot_movies(top_n) # 兜底:全局最热电影 return recommend_item_cf(rating_dict, item_sim, user_id, top_n)这套兜底方案在课程设计里足够,但在生产里通常还会加上基于内容画像的召回通道,协同过滤只作为其中一个召回来源。
5.2 相似度普遍偏低甚至为0:零填充把向量夹角拉偏了
现象:算出来的用户相似度大量是0,Top-N推荐质量极差。原因:写余弦相似度时,把用户对没看过电影的评分填充成0,再构建全量向量计算夹角。用户向量里大部分维度是0,导致夹角几乎都接近90度,余弦值趋近0。解决:只用共同评分项构造向量,即先取common集合,再从集合中取值组向量。如果一定要用全量向量,就把0项排除在点积计算之外。
5.3 皮尔逊系数出现nan或报标准差为0:评分完全一致的用户
现象:计算相似度时报nan,或者输出莫名其妙的高分推荐。原因:皮尔逊相关系数的分母是评分向量的标准差。当两个用户共同评分的电影恰好得分完全一致时,标准差为0,除零后产生nan。极端情况是两个用户只共同评过同一部电影且分数相同,这种情况在稀疏数据里非常常见。解决:相似度函数里加一个判断,标准差为0或共同项太少时返回0,而不是让nan污染整个相似度矩阵。
def pearson_similarity(vec1, vec2): mean1, mean2 = sum(vec1) / len(vec1), sum(vec2) / len(vec2) diff1 = [v - mean1 for v in vec1] diff2 = [v - mean2 for v in vec2] denom = (sum(v**2 for v in diff1) * sum(v**2 for v in diff2)) ** 0.5 if denom == 0: return 0.0 return sum(a * b for a, b in zip(diff1, diff2)) / denom这段代码里denom == 0的早退判断,避免的就是nan问题。很多源码教程省略了这个保护,导致调参时相似度矩阵里一旦出现nan,后续所有加权计算全被污染,而且错误会在几层循环之后才暴露,极难排查。
5.4 预测评分全部挤在同一个值附近:加权公式里漏了归一化
现象:预测评分几乎都收敛到3.5到4.0之间,推荐列表排序全靠微弱差异支撑。原因:计算加权平均时只累加sim * r,没有累加sim做分母归一化,或者归一化时除以的是总邻居数而不是有效评分邻居的总相似度。不同用户的邻居数量差异很大,不归一化时,邻居多的用户天然得分更高。解决:回到3.3节的代码,确认分母是total_sim(参与评分的邻居相似度之和),不是固定数字。这是新手最容易悄悄改坏的地方。
5.5 离线评估结果虚高:切分数据时混进了未来信息
现象:评估指标很好看,RMSE很低,但上线后推荐效果远不如离线结果。原因:把整个评分表随机切成训练集和测试集,可能同一个用户同一部电影同时出现在两边;更常见的是,用户早期的评分混进测试集,后看的行为混进训练集,模型相当于“提前看答案”。解决:按时间戳切分。MovieLens每行评分都带timestamp,按时间戳升序排序,每个用户取前80%做训练、后20%做测试,严格模拟预测未来的场景。切分函数可以参考:
# 按评分时间序为用户切分训练测试集 df = df.sort_values(["user_id", "timestamp"]) train, test = [], [] for uid, group in df.groupby("user_id"): if len(group) < 2: train.append(group) continue cut = int(len(group) * 0.8) train.append(group.iloc[:cut]) test.append(group.iloc[cut:]) train_df = pd.concat(train) test_df = pd.concat(test)这组代码处理完的train_df和test_df直接喂给后续评估函数,你会发现RMSE会比随机切分高一些,但那才是真实水平。
6. 一个提升效果的进阶技巧:在ItemCF中引入评分偏好加权
调完参数、避开常见坑之后,想让推荐结果再上一个台阶,可以试一个改动成本极低的技巧:给电影相似度计算加入热门惩罚项,同时给用户历史评分做时间衰减。前者解决“热门电影和谁都相似”的偏置问题,后者让近期的观影偏好权重更高。
热门惩罚的原理很简单:两部电影共同评分的人越多,先验上相似度就越高——因为它们都是热门片。用1 / log(1 + popularity)作为权重乘到相似度上,可以有效压住热门片的过度扩散。时间衰减更直接,在4.4节的recommend_item_cf里,把历史评分r乘上一个随时间递减的系数,近期评分权重更高。
import math, time def recommend_with_time_decay(rating_dict, item_sim, user_id, timestamp_dict, top_n=10, half_life_days=30): """ timestamp_dict: dict[(user_id, movie_id)] -> 评分Unix时间戳 半衰期 half_life_days=30,超过30天的评分权重衰减一半 """ now = time.time() user_rated = rating_dict.get(user_id, {}) scores = {} for movie, r in user_rated.items(): ts = timestamp_dict.get((user_id, movie), now) age_days = (now - ts) / 86400.0 # 时间差换算成天数 decay = 0.5 ** (age_days / half_life_days) # 指数衰减 for sim_movie, sim in item_sim.get(movie, [])[:10]: if sim_movie in user_rated: continue scores[sim_movie] = scores.get(sim_movie, 0.0) + r * decay * sim ranked = sorted(scores.items(), key=lambda x: -x[1]) return [mid for mid, _ in ranked[:top_n]]逻辑说明:0.5 ** (age_days / half_life_days)是标准的指数半衰期衰减,30天前评分的权重是现在的0.5,60天前是0.25。decay直接乘进得分,既不改变原算法框架,又让推荐结果更贴近用户当前兴趣。这个技巧在电影这类兴趣变化缓慢的领域提升有限,但如果换成短视频、新闻这类时效性强的场景,收益会非常明显。
这两个技巧配合使用,是我个人在这套源码上做提升最顺手的一套组合。先加热门惩罚压住头部效应,再用时间衰减贴近当下口味,两者都不需要改动相似度矩阵的主体结构,工程量小、回滚也容易。如果你现在手里正跑着一份协同过滤的课程设计或实验基线,不妨先备份一份原结果,再按这个思路改,用同一份测试集对比新旧版本的RMSE和Precision@10。这套源码的价值不只是能跑通,更重要的是它把推荐系统的几个关键决策点都暴露在你能看到、能改成的地方——希望帮到你。
本文还有配套的精品资源,点击获取