
简介《毕业设计论文Python协同过滤算法电影推荐系统.doc》是一份面向计算机专业毕业生的毕业设计论文资料围绕Python语言和协同过滤算法在电影推荐场景中的应用展开完整呈现了从需求分析、技术选型到系统设计与实现的整体思路。系统采用Django框架与MySQL数据库开发划分管理员和用户两种角色实现电影分类、信息管理、评分、评论、收藏等主要功能并结合实际使用场景对数据安全与界面布局提出了可行方案。压缩包大小2.43MB仅包含1个doc格式文档方便直接阅读和按需摘录。目前已有80人学习浏览。文档内含中英文摘要、目录、绪论、系统设计等章节详细阐述协同过滤推荐原理、数据库设计与功能模块划分可作为毕业设计论文撰写时的结构参照也能为开发同类推荐系统提供技术选型与设计借鉴。1. 这个毕业设计题目到底在做什么协同过滤 电影推荐系统的真实分量拿到「毕业设计论文Python协同过滤算法电影推荐系统.doc」这个标题很多人以为只是写论文实际上它要求一个能跑、能出数据、能画图、能答辩的系统用 Python 实现基于协同过滤算法的电影推荐引擎并给出完整评估结果。它解决的核心问题是在没有用户画像、没有电影标签的前提下只靠用户历史行为如何预测一个人接下来会喜欢哪部电影。适合的人群很明确计算机、软件工程方向做毕设或课程设计的学生以及想从零搭建推荐系统但不想直接套大模型黑盒的从业者。一个反直觉结论先放在这里协同过滤其实不“懂”电影它只懂行为关系但往往比内容推荐更稳这正是它作为毕设题目的价值所在。2. 先把协同过滤讲透UserCF 与 ItemCF 的选型逻辑和数学直觉2.1 协同过滤不是黑匣子它靠什么来预测你会不会喜欢一部电影协同过滤Collaborative Filtering的核心输入是一个行为矩阵 R行是用户列是电影格子是用户在电影上的行为评分、点击、收藏。矩阵里大部分格子是空的因为没人看完了所有电影。推荐的任务就是把空的格子填上预测值再按预测值从高到低排序输出。它的假设只有一条过去行为相似的人或物品未来行为也相似。这句话听起来像废话但它绕开了内容理解——不需要知道电影是科幻还是爱情也不需要给用户打年龄性别标签。只要用户 A 和用户 B 在十几部电影上的打分高度接近那么 B 看过而 A 没看过的电影就有概率成为 A 的心头好。这在毕业设计里意味着什么意味着你的系统核心就是三件事构建矩阵、算相似度、做预测加权。难点不在算法推导而在细节处理——评分怎么归一化、邻居怎么取、空值怎么处理。矩阵的稠密度决定你算出来的相似度有没有意义这一点后面避坑章会专门展开。2.2 UserCF 和 ItemCF 的分工什么时候用电影相似度什么时候用人相似度UserCF基于用户的协同过滤先找和当前用户口味最像的 K 个用户然后用这些用户的评分来预测当前用户对未看过电影的评分。ItemCF基于物品的协同过滤先计算电影之间的相似度然后根据当前用户看过的电影推荐与这些电影最相似的未看过的电影。毕设里我一般建议两个都做然后对比效果。原因有两层第一层电影推荐领域的大部分公开实验数据都说明 ItemCF 比 UserCF 稳定因为用户的兴趣会随时间漂移而电影之间的相似关系相对固定第二层答辩时老师几乎必问“你为什么选这个算法”如果你回答“我两个都做了从指标上看 ItemCF 的 Precision 高了多少”这就把纯理论答辩变成了工程论证。具体到数学直觉UserCF 的复杂度与用户数强相关当用户数数十万时每次预测都要算一遍用户间相似度虽然可以做离线缓存但更新代价高。ItemCF 的相似度矩阵只随电影数量变化而电影数量通常远小于用户数量所以离线计算更轻也更容易解释推荐结果——“因为你喜欢《黑客帝国》所以推荐《盗梦空间》”这句话在答辩演示里很有说服力。2.3 相似度度量怎么选余弦、皮尔逊、杰卡德的适用边界常见的相似度有三种。余弦相似度是向量夹角余弦范围在 [-1, 1]它对评分尺度的绝对大小不那么敏感但在两个用户打分整体偏高一个总打4分一个总打3分时计算出的相似度会被系统性拉低。皮尔逊相关系数本质上就是“去均值之后的余弦相似度”能修正每个用户的打分习惯所以我个人在评分数据上更常用它。杰卡德系数只管交集与并集的比例不管具体打分高低更适合隐式反馈数据比如用户是否收藏了电影、是否点击过而不是打了几颗星。毕设如果只用 MovieLens 的 1~5 分评分那么重点用余弦和皮尔逊如果你额外爬了一部分“用户是否看过”这种布尔数据那杰卡德可以作为一个附加实验维度。选型的边界可以用一句话概括有连续评分时优先考虑余弦或皮尔逊只有“看过/没看过”时用杰卡德数据极其稀疏时皮尔逊因为做了均值中心化会比普通余弦更鲁棒。这里的“鲁棒”不是玄学而是因为均值中心化能把用户只打过一次高分、其他全是空值的噪声压低。2.4 手算一个迷你示例为什么邻居的评分加权能补全空值假设有三个人对三部电影的评分为下表用户《肖申克的救赎》《阿甘正传》《低俗小说》小明534小红443小刚?54现在要预测小刚对《肖申克的救赎》的评分。先计算小刚与小明、小红的皮尔逊系数这里只作演示用余弦近似。小刚和小明在《阿甘》和《低俗》上评分向量是 (3,4) vs (5,4)余弦相似度约0.99。小刚和小红在共同项上是 (5,4) vs (4,3)余弦相似度约0.98。看起来差不多但注意小刚和小红在《阿甘》上都是高分而小明给《阿甘》只打了3分。如果用加权平均小刚对《肖申克》的预测值 (0.995 0.984) / (0.990.98) ≈ 4.5四舍五入是5。这个结果符合直觉因为相似用户给小刚的信息非常一致。这个迷你例子说明了两个关键点第一共同评分的数量会强烈影响相似度可信度两个共同项算出来的0.99 在大数据里几乎没有意义所以后面要设置“最小共同评分数量”阈值。第二预测结果不是机械地取最高分电影而是把相似度作为权重把邻居评分加权汇总回来。这个思想贯穿所有 UserCF 代码实现。3. 搭建可运行的推荐系统从数据到接口的完整路径3.1 数据准备用 MovieLens 100K 而不是爬虫抓豆瓣的四个理由毕设最稳妥的数据集是 MovieLens。它有 100K、1M、10M 等几个版本100K 是最小最易跑通的943 个用户对 1682 部电影的大约 10 万条评分字段只有 userId、movieId、rating、timestamp。1M 版本数据量更大适合做算法对比时体现差异但如果只有普通笔记本跑 KNN 类算法时相似度矩阵可能会让你等上几分钟到几十分钟。很多人想用 Python 爬虫抓豆瓣真实评分这里泼一盆冷水第一豆瓣反爬机制会带来额外对抗成本你要处理登录、验证码、请求频率这些工作量与推荐算法本身无关第二抓下来的数据不干净——大量用户只有一两条评分评分时间分布扭曲而且没有官方切割的训练集测试集最后的评估结果很难让答辩老师信服。MovieLens 自带按用户切割的 u1.base / u1.test 文件你能直接站在可复现的评估起点上。数据下载后先看格式。ratings.dat 在 1M 里是userId::movieId::rating::timestamp的双冒号分隔格式做预处理时顺手转成 csv。下面这段代码会读取并输出形状和前几行。import pandas as pd # 读取 MovieLens 1M 的评分文件指定分隔符和列名 ratings pd.read_csv( ratings.dat, sep::, enginepython, names[userId, movieId, rating, timestamp] ) print(ratings.shape) # 期望看到约 100 万行 print(ratings.head(5)) # 确认列名和数据范围 print(ratings[rating].value_counts(normalizeTrue)) # 查看评分分布这段代码先把原始评分文件读进 DataFrame。sep::指的是 MovieLens 1M 的分隔符不是正则表达式里的冒号所以要写成双冒号字符串。enginepython是因为 pandas 默认的 C 引擎不认这种分隔符。normalizeTrue会输出每个评分档位的占比这一步能快速帮你发现数据是否均衡比如 1 分和 5 分的分布如果严重偏斜说明用户打分习惯可能影响算法效果。3.2 环境与项目结构Python 安装和相关环境配置的注意点写推荐系统前先把环境装好。这里涉及 python 安装、vscode python环境配置之类的事。Python 3.9 以上都行第三方库我建议只用 pandas、numpy、scikit-learn、scikit-surprise可选。不要盲目上 PyTorch因为协同过滤的经典实现不需要深度学习框架用框架反而让答辩时的问题变多。项目结构我一般这样做简单清晰movie_recommender/ ├── data/ # 放 ratings.dat、movies.dat ├── src/ │ ├── preprocess.py # 数据读取和清洗 │ ├── similarity.py # 相似度计算 │ ├── recommend.py # 推荐主逻辑 │ └── evaluate.py # 离线评估 └── main.py # 一键运行入口在 Windows 上装包的时候最容易翻车的是 scikit-surprise它依赖 C 编译器。如果你不想处理编译问题完全可以不装它自己手写 KNN 的逻辑也就一百多行。用 pandas 加 numpy 就够。一个常见问题pandas 读数据出来是字符串而不是数值排错时先print(ratings.dtypes)确认类型。下面这段是预处理的标准步骤。# 把评分表与电影表合并过滤掉无评分或异常值 movies pd.read_csv( movies.dat, sep::, enginepython, names[movieId, title, genres], encodinglatin-1 ) # 合并评分和电影标题 df ratings.merge(movies, onmovieId, howleft) # 检查是否有缺失的电影标题 print(df.isnull().sum()) # 只保留有效评分 df df.dropna(subset[title]) df[rating] df[rating].astype(float) print(df.head(3))这里encodinglatin-1是因为 movies.dat 里的片名包含非 UTF-8 字符比如法语片名用 utf-8 读会直接报错。merge用howleft保证评分表的每一条记录都能对应到一部电影如果电影表里有重复的 movieId会变成笛卡尔积所以在合并前应该先检查movies.duplicated(subset[movieId]).sum()这个检查往往能避免出现行数膨胀的问题。3.3 实现 ItemCF从评分矩阵到电影相似度ItemCF 的第一步是构造用户-电影评分透视表。行是用户列是电影表里是评分。然后用余弦相似度计算电影之间的相似度。注意此时要先把 NaN 填空否则大多数距离函数会直接报错或把 NaN 传播到结果里。但填充为 0 又会导致“没看过”和“打了 1 分”被同等对待这是个细节后面避坑章会再提。import numpy as np import pandas as pd # 构造用户-电影矩阵行是 userId列是 movieId user_movie df.pivot_table( indexuserId, columnsmovieId, valuesrating ) # 不填充后续计算时用mask忽略空值 # 记录每部电影被有效评分的用户数 item_rating_count user_movie.notna().sum(axis0) # 只保留被至少 5 个用户评过的电影避免相似度失真 valid_items item_rating_count[item_rating_count 5].index user_movie user_movie[valid_items] print(user_movie.shape)pivot_table会把原 DataFrame 变成稀疏的宽表notna().sum(axis0)得到每列的评分数量。这里设置最小评分数 5是一个常用的经验值后面会讲它如何影响推荐质量。然后计算电影相似度矩阵def cosine_similarity_matrix(mat): 计算列与列之间的余弦相似度输入为用户-物品矩阵 # 将空值填充为0仅用于计算余弦相似度 mat_filled mat.fillna(0).values # 计算各列向量的模长 norm np.sqrt((mat_filled ** 2).sum(axis0)) # 避免除以0给模长加极小值 norm[norm 0] 1e-8 # 归一化 mat_norm mat_filled / norm # 余弦相似度 归一化后向量的点积 sim np.dot(mat_norm.T, mat_norm) # 对角线置为0因为自己与自己的相似度无意义 np.fill_diagonal(sim, 0) return sim item_sim cosine_similarity_matrix(user_movie) item_sim_df pd.DataFrame(item_sim, indexuser_movie.columns, columnsuser_movie.columns)这段代码的原理是余弦相似度等于归一化向量之间的点积。先按列求模长把矩阵的每列变成单位向量再点积得到的sim[i][j]就是第 i 部电影与第 j 部电影的余弦相似度。fillna(0)在这里是把缺失值当 0 处理但这一步其实是近似——0 与等长的数值向量夹角会被高估。为了避免误判应该在使用相似度之前先用一个掩码过滤掉那些共同评分太少的电影对下面推荐函数里再做。有了相似度矩阵对用户 u 推荐 top N 部电影时取用户已评分的电影集合 S对 S 中的每部电影 i累加与 i 相似的所有未评电影 j 的相似度加权评分。这里有一个参数只取与每部已看电影最相似的 K 部电影从而避免把低相似度噪声带进来。def recommend_itemcf(user_id, user_movie, item_sim_df, top_n10, k20): 给指定用户推荐 top_n 部电影 # 找到该用户所有非空的评分记录 row user_movie.loc[user_id] rated row[row.notna()] # 候选得分字典 scores {} # 遍历该用户看过的每部电影 for movie_id, rating in rated.items(): # 获取与这部电影最相似的 k 部电影 sim_series item_sim_df[movie_id].sort_values(ascendingFalse) top_k sim_series.head(k) for cand_id, sim_val in top_k.items(): if cand_id in rated.index: continue # 跳过已经看过的电影 sim_val max(sim_val, 0) # 负相似度没有意义 # 累计加权得分 scores[cand_id] scores.get(cand_id, 0) rating * sim_val # 按得分排序返回前 top_n if not scores: return [] recommend_ids sorted(scores, keyscores.get, reverseTrue)[:top_n] return recommend_ids这段代码里的rated.items()遍历了用户给过分的电影对每部已看电影head(k)只截取相似度最高的前 k 部电影。rating * sim_val是推荐得分的核心用户越喜欢已看过的电影且候选电影与这部已看过的电影越相似候选的推荐分就越高。sim_val max(sim_val, 0)是因为负相似度表示“看了这部电影的人往往讨厌另一部”在毕设里一般直接忽略避免复杂化。最后返回的recommend_ids就是给该用户的电影列表。3.4 实现 UserCF换一个视角的代码骨架UserCF 的实现思路刚好相反先算用户与用户之间的相似度再找邻居。代码结构与 ItemCF 高度相似但矩阵的行列互换。# 计算用户-用户相似度时直接对 user_movie 的转置做同样的余弦 user_sim_df pd.DataFrame( cosine_similarity_matrix(user_movie.T.values), indexuser_movie.index, columnsuser_movie.index ) def recommend_usercf(user_id, user_movie, user_sim_df, top_n10, k20): 基于用户的协同过滤推荐 user_vec user_movie.loc[user_id] rated user_vec[user_vec.notna()] # 找到与当前用户最相似的 k 个邻居 sim_series user_sim_df[user_id].sort_values(ascendingFalse) top_k sim_series.head(k) # 对候选电影做加权评分 scores {} for neighbor_id, sim_val in top_k.items(): if neighbor_id user_id: continue # 邻居的评分向量 neighbor_rated user_movie.loc[neighbor_id] for movie_id, rating in neighbor_rated[neighbor_rated.notna()].items(): if movie_id in rated.index: continue # 跳过已看 # 累加邻居相似度 * 邻居打分 scores[movie_id] scores.get(movie_id, 0) sim_val * rating if not scores: return [] recommend_ids sorted(scores, keyscores.get, reverseTrue)[:top_n] return recommend_ids与 ItemCF 的区别主要在邻居的选择对象这里找的是“和这个用户口味一致的其他人”然后直接借用邻居看过的电影。这段代码有一个潜在问题没有对邻居评分做均值中心化后面避坑章会解释为什么会导致“打分手松的人说话权重更高”。如果你在答辩前没有修正这一点导师问起来会很尴尬。3.5 评估指标用留出法算 Precision 和 Recall推荐结果如果只说“推了 10 部电影”而没有指标答辩站不住脚。常用的评估方式是留出法把每个用户的评分按时间戳切成训练集和测试集训练集算相似度测试集用来判断推荐列表是否命中。from sklearn.model_selection import train_test_split # 按用户分组切分保证每个用户都在训练和测试中都出现 train, test train_test_split(df, test_size0.2, random_state42, stratifydf[userId]) # 在训练集上重新构造 user_movie 和相似度矩阵 train_user_movie train.pivot_table(indexuserId, columnsmovieId, valuesrating) test_user_movie test.pivot_table(indexuserId, columnsmovieId, valuesrating) # 对测试集中每个用户用训练集计算推荐再检查推荐列表与测试集实际评分的交集 def evaluate_precision_recall(recommend_fn, train_mat, test_mat, top_n10): precision_sum 0 recall_sum 0 user_count 0 for user_id in test_mat.index: if user_id not in train_mat.index: continue rec_list recommend_fn(user_id, train_mat, top_ntop_n) if not rec_list: continue # 测试集中该用户实际打分大于等于4的电影视为“真正喜欢” ground_truth set(test_mat.loc[user_id][test_mat.loc[user_id] 4].index) if not ground_truth: continue rec_set set(rec_list) hit rec_set ground_truth precision_sum len(hit) / len(rec_set) recall_sum len(hit) / len(ground_truth) user_count 1 return precision_sum / user_count, recall_sum / user_count # 使用 ItemCF 做评估 p, r evaluate_precision_recall(recommend_itemcf, train_user_movie, test_user_movie) print(fPrecision{10} {p:.4f}, Recall{10} {r:.4f})这里的关键点是train_test_split的stratifydf[userId]它保证每个用户在训练集和测试集中的比例大致一致不会出现某个用户全部评分都被切进测试集。真正喜欢定义为“测试集中打 4 分及以上”这是一个约定但在论文里要写清楚否则指标没有参考基线。Precision衡量推荐列表中有多少是用户真实喜欢的Recall衡量用户真正喜欢的电影中有多少被推荐了出来。两个指标不可兼得毕设里一般报告 Precision10 和 Recall10 各一列就够了。4. 推荐系统的四个必调参数K 值、相似度阈值、训练集切分与最小评分约束4.1 K 近邻的 K太小过拟合太大淹没个性无论是 UserCF 还是 ItemCFK 都是最重要的超参数。K 太小邻居或相似电影太少推荐结果会受单部电影或单个用户的影响方差很大K 太大大量低相似度的样本平均进来推荐结果趋于大众口味个性化被摊平。在 MovieLens 100K 上我一般会扫 K [5, 10, 20, 40, 80]画一条 Precision10 随 K 变化的曲线。通常 10 到 20 之间是峰值。扫参的代码很简单for k in [5, 10, 20, 40, 80]: # 固定其它参数只改 K p, r evaluate_precision_recall( lambda uid, mat: recommend_itemcf(uid, mat, item_sim_df, top_n10, kk), train_user_movie, test_user_movie ) print(fK{k:2d}, Precision{p:.4f}, Recall{r:.4f})这段代码循环传不同的k值给recommend_itemcf。注意 lambda 函数里kk是在循环体内绑定的Python 闭包延迟绑定的问题不会在这里出现因为k是立即传给函数了。如果你把 lambda 收集到列表里再统一调用就需要kk这种默认参数绑定写法否则所有函数都会使用循环结束后的 K 值。4.2 相似度阈值把低于阈值的候选电影直接丢掉很多实现只按 Top K 截断但还会有一种情况用户看过 5 部电影每部电影都很冷门它们的相似度全部低于 0.1但 Top K 依然会把它们排序进来。这些低相似度电影会污染推荐结果。常见的做法是在候选聚合时加一个阈值条件例如SIM_THRESHOLD 0.2低于这个阈值的相似度直接忽略。阈值设多少我通常在 0.1 到 0.3 之间尝试。太高的阈值会大幅缩小候选池导致很多用户没有推荐结果太低又起不到过滤效果。建议先计算一次所有电影对相似度的分位数看 0.2 是第几个百分位再决定。# 查看相似度分布方便定阈值 sim_values item_sim_df.values[np.triu_indices_from(item_sim_df, k1)] print(np.percentile(sim_values, [50, 75, 90, 95]))如果 90 分位数的相似度才有 0.3说明数据很稀疏阈值要相应降低。这个分布统计能让你的参数选择有数据支撑而不是拍脑袋。4.3 训练集/测试集划分比例不要简单随机要按行为量分层前面提了test_size0.2这里要补充为什么不能直接用train_test_split(df)如果随机打乱整个评分表再切分同一条用户的一部分评分在训练集另一部分在测试集虽然这能用于评估但会导致相似度矩阵包含测试信息——因为相似度是基于训练集算的而训练集里可能包含了该用户的另一部分评分这就泄漏了用户的口味。正确做法是按用户分组每个用户评分整体切分或者按时间戳先发生为训练、后发生为测试。MovieLens 自带u1.base/u1.test就是按用户分组的选择这个数据集可以少踩这个坑。如果你自己写切分推荐使用GroupShuffleSplitfrom sklearn.model_selection import GroupShuffleSplit gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, test_idx next(gss.split(df, groupsdf[userId])) train df.iloc[train_idx] test df.iloc[test_idx]这段代码通过groupsdf[userId]确保同一个用户的评分不会同时出现在训练和测试里。与train_test_split的stratify不同GroupShuffleSplit是分组切分虽然可能导致某些用户的评分比例不是精确 8:2但对推荐评估来说这种切分更符合真实场景——模型永远无法在预测时看到用户在测试期的行为。4.4 最小评分数量约束过滤噪声用户与噪声电影矩阵里有大量“只评分了一两次”的用户和“只被评分了一次”的电影。保留它们会带来两个问题第一这些用户或电影没有统计意义算出的相似度几乎全是噪声第二让矩阵行数和列数过大计算量倍增。一般做法是用户在训练集中至少有 5 条评分电影至少被 5 个用户评分然后才进入矩阵。# 按用户统计评分条数保留评分数5的用户 user_counts train[userId].value_counts() valid_users user_counts[user_counts 5].index train train[train[userId].isin(valid_users)] # 按电影统计被评分次数保留次数5的电影 movie_counts train[movieId].value_counts() valid_movies movie_counts[movie_counts 5].index train train[train[movieId].isin(valid_movies)] print(f过滤后用户 {train[userId].nunique()} 个电影 {train[movieId].nunique()} 部)这里的阈值 5 是经验值。如果你发现过滤后数据量掉了太多可以把阈值降到 3如果推荐结果仍然有大量冷门噪声可以提到 10。这个参数会直接影响算法的覆盖率和准确性建议在论文里也列一张不同最小评分数量的对比表。5. 毕设翻车避坑指南协同过滤的五个典型坑与排查方法5.1 冷启动新用户注册后推荐列表空白现象系统给没有任何评分记录的用户调用推荐函数返回空列表。答辩演示时现场让评委用新账号登录页面白屏或提示“暂无推荐”。原因协同过滤的本质是基于历史行为用户没有行为矩阵上没有对应的行无法计算邻居自然就没有候选。解决做一个流行度兜底策略。当用户评分数量低于阈值比如 5 条时直接返回电影库中评分热度最高的电影列表。热度可以定义为被评分次数加权平均分# 备案没有历史行为时按热度推荐 movie_stats df.groupby(movieId)[rating].agg([count, mean]) movie_stats[popularity_score] 0.5 * movie_stats[mean] 0.5 * np.log1p(movie_stats[count]) popular_list movie_stats.sort_values(popularity_score, ascendingFalse).index[:10]np.log1p(count)是对评分次数取对数避免少数几部爆款电影完全压过其它次热门电影。0.5 和 0.5 是热度权重你可以调成 0.7/0.3让流行度更重要一些。兜底策略不算算法创新但它是完整系统不可缺的一环在论文里作为“冷启动模块”解释反而能加分。5.2 稀疏矩阵导致相似度失真为什么《教父》会“像”《猫狗大战》现象计算相似度后两部毫无关联的电影得到接近 1 的高相似度。举一个极端的例子如果某部电影只被 2 个用户看过而这两个用户恰好也共同看过另一部冷门电影那么这两部电影在只有 2 个共同评分的情况下余弦相似度可能高达 1。原因共同评分的用户数量远少于电影数量小样本的巧合被算法当成了强信号。相似度没有置信度。解决设置“最小共同评分数量”minimum co-rating count。计算相似度时如果两部电影的共同评分用户少于 N直接将相似度设为 0。代码上可以在相似度矩阵生成后按掩码修正# 计算共同评分数量矩阵 rating_binary user_movie.notna().astype(int).values co_count rating_binary.T rating_binary np.fill_diagonal(co_count, 0) # 将共同评分数量小于10的相似度置0 min_co 10 item_sim_clean item_sim.copy() item_sim_clean[co_count min_co] 0这里rating_binary.T rating_binary用矩阵乘法快速算出每对电影的共同评分人数。co_count min_co得到一个布尔矩阵用它把不达标的相似度置零。这个修正比单纯设相似度阈值更有效因为它直接过滤了不可靠的样本量。5.3 评分偏差打分手松的人永远“声量”更大现象两个用户口味其实一致但用户 A 习惯性打 4~5 分用户 B 习惯性打 2~3 分。余弦相似度计算会把他们的向量夹角拉大导致系统把 A 归为另一种人。原因余弦相似度对每部电影的绝对评分敏感而评分尺度因人而异。没有均值中心化等于把人的打分习惯当成了口味。解决在计算用户相似度前做去均值。对每一行评分减去该用户的平均分再算余弦这就是皮尔逊相似度。代码片段# 用户评分均值 user_mean user_movie.mean(axis1) # 去均值后的矩阵 user_movie_centered user_movie.sub(user_mean, axis0) # 之后用 user_movie_centered 计算相似度 user_sim_pearson cosine_similarity_matrix(user_movie_centered.T)sub(user_mean, axis0)表示把每行的均值减掉。这样两个人即使一个总打高分、一个总打低分只要相对打分趋势一致相似度依然高。ItemCF 那边也同理只是去均值的对象变成列每部电影的平均分。如果不做这一步你的系统在真实数据上的 Precision 一般会低 3~5 个百分点。5.4 评估时训练测试泄漏指标虚高却不自知现象离线评估的 Precision 达到 0.4看起来非常漂亮但做一个小范围人工体验时用户普遍觉得推荐结果“不意外”甚至全是用户已经看过的电影。原因最常见的情况是在整个数据集上计算了相似度矩阵然后在评估循环里用整个矩阵给用户推荐再用测试集计算命中。由于相似度矩阵包含测试集里的电影关系用户确实喜欢这部电影的信息以间接形式泄漏到了推荐结果里。解决严格按时间线或分组切分并且相似度矩阵只由训练集计算。评估函数里不要把item_sim_df当作全局变量而是传入训练集构造的矩阵。一个更好的习惯是写一个build_sim(train_df)函数训练一个版本再给测试集用户推荐。上面的评估代码里我们已经这么做了关键是每轮评估时都要确保item_sim_df对应的是那个训练数据而不是全量数据。5.5 矩阵爆炸电影多了之后相似度矩阵计算慢到怀疑人生现象数据从 100K 换到 1M程序在相似度计算那一步运行了二十分钟还没结束或者内存直接爆掉。原因item_sim是一个n × n的稠密矩阵n 是电影数。1M 电影库虽然只有一千多万条评分但电影数量可能超过 3 万3万 × 3万的 float64 矩阵要占 7 个多 GB 内存而 numpy 点积的计算量更是天文数字。解决用 KNN 的思路只保留每部电影最相似的 Top K。scikit-learn 的NearestNeighbors可以高效计算稀疏相似度from sklearn.neighbors import NearestNeighbors # 使用稀疏矩阵避免稠密内存 mat_sparse user_movie.fillna(0).values # 用余弦距离构建 KNN 模型 nn NearestNeighbors(n_neighbors100, metriccosine, algorithmbrute) nn.fit(mat_sparse.T) distances, indices nn.kneighbors(mat_sparse.T) # 构建只保留 top100 的稀疏相似度矩阵这里mat_sparse.T让每一行代表一部电影在所有用户上的评分。n_neighbors100意思是每部电影只保留最相似的 100 部。metriccosine会自动归一化但注意 sklearn 的余弦距离定义是1 - 相似度所以你需要用1 - distances还原相似度。这样矩阵从 O(n^2) 变成 O(n*k)内存和计算量都大幅下降。6. 把毕设做出彩一个混合推荐的进阶方案与验证技巧6.1 在 ItemCF 上叠加流行度降权让长尾电影获得曝光纯 ItemCF 容易推荐“大路货”因为热门电影与很多电影相似度都偏高。常见做法是给候选电影按热度做衰减评分次数越多推荐得分被乘以越小的系数。我一般用1 / log(1 热度)做降权。item_hot train.groupby(movieId)[rating].count() hot_count item_hot.get(cand_id, 1) decay 1 / np.log1p(hot_count) scores[cand_id] scores.get(cand_id, 0) rating * sim_val * decaynp.log1p把热度取对数后加 1让衰减因子在热度低时接近 1、热度高时低于 0.2。加了降权后原本被热门电影霸榜的前 10 位会出现不少中低热度的电影多样性指标会有明显变化。注意得分量级变了之前的相似度阈值和 Top K 需要重新扫。6.2 用“直觉清单”做人工验证指标之外的第二层保险离线指标只说明平均命中率不说明推荐结果是否“像人话”。每次调参后我会随机抽 10 个有 20 条以上评分的用户看他们的推荐列表逐项对照一个简单清单推荐里有已看过电影吗前 5 部是不是集中在同一类型会不会出现同一系列排前三有没有完全违背常识的搭配。这些检查不需要代码一张记录表就行。检查项期望推荐列表是否包含已看过的电影否前 5 部是否同一类型扎堆否同一系列电影是否连续出现过多否是否有明显无关的推荐否6.3 用覆盖率验证推荐系统的长尾挖掘能力除了 Precision 和 Recall毕设里还可以报告 Coverage即推荐列表中出现的不同电影数占全部电影数的比例。覆盖率低说明系统只在反复推荐几十部热门电影。计算起来很简单rec_all set() for uid in test_user_movie.index: rec_all.update(recommend_itemcf_improved(uid, train_user_movie, item_sim_df)) coverage len(rec_all) / len(train_user_movie.columns)这样三个指标组合起来比单个 Precision 更能体现系统价值。我的经验是覆盖率从 0.2 提升到 0.4 往往是因为加入流行度降权这个结果写进论文很直观。最后还是那句老话从一开始就固定随机种子否则每次跑的指标都在变你很难判断调参到底有没有效果。希望帮到你。本文还有配套的精品资源点击获取