☰
Python协同过滤电影推荐系统:从算法原理到项目实战
2026/9/26 1:52:25 网站建设 项目流程

简介:《毕业设计论文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.99+0.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='::', engine='python', names=['userId', 'movieId', 'rating', 'timestamp'] ) print(ratings.shape) # 期望看到约 100 万行 print(ratings.head(5)) # 确认列名和数据范围 print(ratings['rating'].value_counts(normalize=True)) # 查看评分分布

这段代码先把原始评分文件读进 DataFrame。sep='::'指的是 MovieLens 1M 的分隔符,不是正则表达式里的冒号,所以要写成双冒号字符串。engine='python'是因为 pandas 默认的 C 引擎不认这种分隔符。normalize=True会输出每个评分档位的占比,这一步能快速帮你发现数据是否均衡,比如 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='::', engine='python', names=['movieId', 'title', 'genres'], encoding='latin-1' ) # 合并评分和电影标题 df = ratings.merge(movies, on='movieId', how='left') # 检查是否有缺失的电影标题 print(df.isnull().sum()) # 只保留有效评分 df = df.dropna(subset=['title']) df['rating'] = df['rating'].astype(float) print(df.head(3))

这里encoding='latin-1'是因为 movies.dat 里的片名包含非 UTF-8 字符(比如法语片名),用 utf-8 读会直接报错。merge用how='left'保证评分表的每一条记录都能对应到一部电影,如果电影表里有重复的 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( index='userId', columns='movieId', values='rating' ) # 不填充,后续计算时用mask忽略空值 # 记录每部电影被有效评分的用户数 item_rating_count = user_movie.notna().sum(axis=0) # 只保留被至少 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(axis=0)得到每列的评分数量。这里设置最小评分数 5,是一个常用的经验值,后面会讲它如何影响推荐质量。然后计算电影相似度矩阵:

def cosine_similarity_matrix(mat): """计算列与列之间的余弦相似度,输入为用户-物品矩阵""" # 将空值填充为0,仅用于计算余弦相似度 mat_filled = mat.fillna(0).values # 计算各列向量的模长 norm = np.sqrt((mat_filled ** 2).sum(axis=0)) # 避免除以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, index=user_movie.columns, columns=user_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_n=10, k=20): """给指定用户推荐 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(ascending=False) 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, key=scores.get, reverse=True)[: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), index=user_movie.index, columns=user_movie.index ) def recommend_usercf(user_id, user_movie, user_sim_df, top_n=10, k=20): """基于用户的协同过滤推荐""" user_vec = user_movie.loc[user_id] rated = user_vec[user_vec.notna()] # 找到与当前用户最相似的 k 个邻居 sim_series = user_sim_df[user_id].sort_values(ascending=False) 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, key=scores.get, reverse=True)[: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_size=0.2, random_state=42, stratify=df['userId']) # 在训练集上重新构造 user_movie 和相似度矩阵 train_user_movie = train.pivot_table(index='userId', columns='movieId', values='rating') test_user_movie = test.pivot_table(index='userId', columns='movieId', values='rating') # 对测试集中每个用户,用训练集计算推荐,再检查推荐列表与测试集实际评分的交集 def evaluate_precision_recall(recommend_fn, train_mat, test_mat, top_n=10): 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_n=top_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(f'Precision@{10} = {p:.4f}, Recall@{10} = {r:.4f}')

这里的关键点是train_test_split的stratify=df['userId'],它保证每个用户在训练集和测试集中的比例大致一致,不会出现某个用户全部评分都被切进测试集。真正喜欢定义为“测试集中打 4 分及以上”,这是一个约定,但在论文里要写清楚,否则指标没有参考基线。Precision衡量推荐列表中有多少是用户真实喜欢的,Recall衡量用户真正喜欢的电影中有多少被推荐了出来。两个指标不可兼得,毕设里一般报告 Precision@10 和 Recall@10 各一列就够了。

4. 推荐系统的四个必调参数:K 值、相似度阈值、训练集切分与最小评分约束

4.1 K 近邻的 K:太小过拟合,太大淹没个性

无论是 UserCF 还是 ItemCF,K 都是最重要的超参数。K 太小,邻居或相似电影太少,推荐结果会受单部电影或单个用户的影响,方差很大;K 太大,大量低相似度的样本平均进来,推荐结果趋于大众口味,个性化被摊平。

在 MovieLens 100K 上,我一般会扫 K = [5, 10, 20, 40, 80],画一条 Precision@10 随 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_n=10, k=k), train_user_movie, test_user_movie ) print(f'K={k:2d}, Precision={p:.4f}, Recall={r:.4f}')

这段代码循环传不同的k值给recommend_itemcf。注意 lambda 函数里k=k是在循环体内绑定的,Python 闭包延迟绑定的问题不会在这里出现,因为k是立即传给函数了。如果你把 lambda 收集到列表里再统一调用,就需要k=k这种默认参数绑定写法,否则所有函数都会使用循环结束后的 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, k=1)] print(np.percentile(sim_values, [50, 75, 90, 95]))

如果 90 分位数的相似度才有 0.3,说明数据很稀疏,阈值要相应降低。这个分布统计能让你的参数选择有数据支撑,而不是拍脑袋。

4.3 训练集/测试集划分比例:不要简单随机,要按行为量分层

前面提了test_size=0.2,这里要补充为什么不能直接用train_test_split(df):如果随机打乱整个评分表再切分,同一条用户的一部分评分在训练集,另一部分在测试集,虽然这能用于评估,但会导致相似度矩阵包含测试信息——因为相似度是基于训练集算的,而训练集里可能包含了该用户的另一部分评分,这就泄漏了用户的口味。正确做法是按用户分组,每个用户评分整体切分,或者按时间戳先发生为训练、后发生为测试。

MovieLens 自带u1.base/u1.test就是按用户分组的,选择这个数据集可以少踩这个坑。如果你自己写切分,推荐使用GroupShuffleSplit:

from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(gss.split(df, groups=df['userId'])) train = df.iloc[train_idx] test = df.iloc[test_idx]

这段代码通过groups=df['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', ascending=False).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(axis=1) # 去均值后的矩阵 user_movie_centered = user_movie.sub(user_mean, axis=0) # 之后用 user_movie_centered 计算相似度 user_sim_pearson = cosine_similarity_matrix(user_movie_centered.T)

sub(user_mean, axis=0)表示把每行的均值减掉。这样两个人即使一个总打高分、一个总打低分,只要相对打分趋势一致,相似度依然高。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_neighbors=100, metric='cosine', algorithm='brute') nn.fit(mat_sparse.T) distances, indices = nn.kneighbors(mat_sparse.T) # 构建只保留 top100 的稀疏相似度矩阵

这里mat_sparse.T让每一行代表一部电影在所有用户上的评分。n_neighbors=100意思是每部电影只保留最相似的 100 部。metric='cosine'会自动归一化,但注意 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 * decay

np.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 往往是因为加入流行度降权,这个结果写进论文很直观。最后还是那句老话:从一开始就固定随机种子,否则每次跑的指标都在变,你很难判断调参到底有没有效果。希望帮到你。

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

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

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

立即咨询