简介:一份基于 Python 与 Django 的协同过滤电影推荐系统毕业设计源码包,面向计算机、通信、人工智能、自动化等相关专业的学生、老师及从业者,也适合作为期末课程设计、课程大作业或毕业设计的参考。项目为作者个人毕设,答辩评审达 98 分,代码经过调试测试,包含用户登录注册、电影浏览、评分预测和个性化推荐等核心模块,可直接运行并支持二次开发。资源共 726 个文件,压缩包约 19.55MB,核心代码含 39 个 Python 源码与 35 个 pyc 编译文件、41 个 Vue 前端组件、164 个 JavaScript 脚本和 53 个 CSS 样式,另配 30 个 HTML 页面、2 个 SQL 数据库脚本及 3 个一键运行批处理文件,目录中还有 svg、gif、png、jpg 等静态资源用于界面展示,整体结构清晰,便于按模块学习和替换。目前已有 223 人学习浏览,对希望快速理解协同过滤落地流程、掌握 Django 项目部署与数据库设计的人来说,具有较高的参考价值。
1. 为什么毕业设计做电影推荐系统:协同过滤、推荐算法和数据库一次串起来
每年开题,最怕的就是选一个"增删改查"的管理系统,做完自己都觉得没东西讲。电影推荐系统算是性价比很高的选择:算法层有协同过滤这种既经典又可解释的推荐算法,数据层有公开的评分数据集可以直接用,展示层还能把推荐结果渲染成网页。整个题目看下来,它不是一个纯算法题,也不是一个纯 Web 题,而是逼你把协同过滤、推荐算法、数据库、Python 后端串成一个能跑通的闭环。这篇文章按我从零搭这类系统的顺序来写:先定算法选型,再设计数据库,再写推荐引擎,最后把最常见的坑全部踩一遍。无论你做课程设计还是毕业设计,照着这个路径走,至少不会在答辩前夜才翻车。
2. 协同过滤算法选型:UserCF 和 ItemCF 的取舍与计算流程
协同过滤的核心思想很简单:不做内容分析,不靠导演、类型、海报,只依赖"用户行为"。它有两种主流路子——基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。这里有个关键认知:电影推荐系统从原理上就更适合 ItemCF 做主算法,原因后面细说,但代码得先写对。
2.1 UserCF(基于用户的协同过滤):先找口味相同的人,再看他们在看什么
UserCF 的思路是"人以群分":找到和你口味最相似的一批用户,把他们看过而你没看过的电影攒起来推荐给你。拆成两步就是:先计算用户间相似度,再根据相似用户的评分做加权汇总。
第一步计算相似度,最简单可用的方式是"物品倒排表 + 余弦相似度"。不要写双层循环去遍历所有用户对,几千个用户时就会卡死。正确做法是先建立"每部电影被哪些用户评分过"的倒排表,再统计共同评分过的用户对:
# user_cf.py —— UserCF 相似度计算与推荐 import math from collections import defaultdict def build_user_rating_map(ratings): """ 入参: ratings 为 [(user_id, movie_id, score), ...] 返回: {user_id: {movie_id: score}} """ user_items = defaultdict(dict) for user_id, movie_id, score in ratings: user_items[user_id][movie_id] = float(score) return user_items def calc_user_similarity(user_items): """ 计算用户之间的余弦相似度。 核心优化:先用物品倒排表找出共同评分过的用户对, 而不是全量两两计算。 """ # 物品 -> 用户 倒排表 movie_users = defaultdict(set) for user_id, items in user_items.items(): for movie_id in items: movie_users[movie_id].add(user_id) # 统计共同评分数量 co_rated_count = defaultdict(int) for movie_id, users in movie_users.items(): for u in users: for v in users: if u != v: co_rated_count[(u, v)] += 1 # 余弦相似度: 共同评分数量 / sqrt(用户u的评分数量 * 用户v的评分数量) similarity = {} for (u, v), count in co_rated_count.items(): similarity[(u, v)] = count / math.sqrt(len(user_items[u]) * len(user_items[v])) return similarity这段代码的关键在倒排表:只有共同评过分的一对用户才会进入 co_rated_count,天然跳过了大量没有交集的用户对,计算量从 O(U²) 降到"实际有共同行为的用户对"数量。分母用两个用户各自的评分数量开根号相乘,是为了压制"评分数量多的用户天然更容易撞车"的偏差。
第二步才是真正的推荐:找到和目标用户最相似的 K 个用户,把他们评分过的高分电影加权汇总,同时过滤掉目标用户已经看过的:
def recommend_by_user_cf(user_id, user_items, similarity, k=20, top_n=10): """ 基于用户近邻做加权推荐。 k 是近邻数量,top_n 是最终返回的推荐条数。 """ # 取出与目标用户相似度最高的 k 个用户 scores = {} for (u, v), sim in similarity.items(): if u == user_id: scores[v] = sim elif v == user_id: scores[u] = sim nearest_users = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:k] # 近邻评分的相似度加权汇总 movie_scores = defaultdict(float) total_sim = defaultdict(float) for neighbor, sim in nearest_users: for movie_id, rating in user_items[neighbor].items(): if movie_id in user_items[user_id]: continue # 已看过的不推荐 movie_scores[movie_id] += sim * rating total_sim[movie_id] += sim ranked = sorted( movie_scores.items(), key=lambda x: x[1] / max(total_sim[x[0]], 1e-6), reverse=True ) return ranked[:top_n]这里我实际做了两个容易被忽略的处理:一是推荐前过滤已看过的电影,二是用 total_sim 做分母做加权平均,而不是直接累加相似度乘以评分。只累加会导致"评分行为多的用户推荐的电影天然分数高",除以总相似度后,每个候选电影的分数含义是"近邻对它评分的加权平均分",可解释性完全不同。
2.2 ItemCF(基于物品的协同过滤):先算电影之间有多像,再看你喜欢电影的邻居
ItemCF 的思路是"物以类聚":用户给《盗梦空间》打了高分,那就找出和《盗梦空间》最相似的一批电影推荐给他。相似度不是靠类型字段算的,而是靠"多少用户同时给这两部电影评分"算出来的。
计算物品相似度,同样用倒排表,这次是以用户为主体做共现统计:
# item_cf.py —— ItemCF 相似度计算与推荐 import math from collections import defaultdict def calc_item_similarity(user_items): """ 计算物品之间的余弦相似度。 分子是共同评分过两部电影的用户数。 """ # 物品 -> 用户 倒排表,记录每部电影被哪些用户评分过 item_users = defaultdict(set) for user_id, items in user_items.items(): for movie_id in items: item_users[movie_id].add(user_id) # 共现矩阵:两部电影被同一个用户评分过一次,计数加一 co_count = defaultdict(int) for user_id, items in user_items.items(): item_list = list(items.keys()) for i in range(len(item_list)): for j in range(i + 1, len(item_list)): co_count[(item_list[i], item_list[j])] += 1 # 余弦相似度:共现数 / sqrt(电影a的评分人数 * 电影b的评分人数) item_sim = {} for (a, b), count in co_count.items(): item_sim[(a, b)] = count / math.sqrt(len(item_users[a]) * len(item_users[b])) return item_sim相似度算完,还需要把这种"平铺的 key-value"压缩成"以电影为 key 的邻接表",不然推荐时每次都要全表扫描一遍相似度字典,几千部电影时延迟就上来了:
def build_sim_dict(item_sim_pairs): """把 (a, b): sim 平铺结构转成 {a: {b: sim}} 的邻接表""" sim_dict = defaultdict(dict) for (a, b), sim in item_sim_pairs: sim_dict[a][b] = sim sim_dict[b][a] = sim return sim_dict def recommend_by_item_cf(user_id, user_items, sim_dict, top_n=10): """ ItemCF 推荐:遍历用户已看过的电影,把它们的相似邻居加权汇总。 """ user_rated = user_items[user_id] if not user_rated: return [] movie_scores = defaultdict(float) for movie_id, rating in user_rated.items(): # 直接取邻接表中与当前电影相似的物品 for neighbor_movie, sim in sim_dict.get(movie_id, {}).items(): if neighbor_movie in user_rated: continue movie_scores[neighbor_movie] += sim * rating ranked = sorted(movie_scores.items(), key=lambda x: x[1], reverse=True) return ranked[:top_n]在实际动手时,我不建议直接用原始评分做加权,而是先做一步均值中心化:把每个用户的评分减去他自己的平均分,再参与计算。原因很现实:有的人习惯打 3 分,有的人手松给啥都 4 分起,不中心化的话"手松用户"的评分会在推荐结果里明显占便宜。
2.3 UserCF 和 ItemCF 怎么选:电影场景用 ItemCF 更稳的原因
很多教程把两种算法并列介绍,好像随便选一个都行,实际选型时差异很大。下面这张表是我在做方案评审时习惯用的对比维度:
| 维度 | UserCF | ItemCF |
|---|---|---|
| 适用场景 | 新闻、社交、短视频,兴趣漂移快 | 电影、图书、电商,物品相对稳定 |
| 相似度计算对象 | 用户两两对比 | 物品两两对比 |
| 计算成本 | 用户数增长时成本接近二次方爆炸 | 物品数有限,相似度矩阵可离线计算 |
| 实时性 | 用户新行为立即可用 | 物品相似度矩阵离线更新,在线只做加权 |
| 冷启动 | 新用户无行为,无法推荐 | 新电影无评分,无法被推荐 |
| 可解释性 | "和你口味相似的某某也看了…" | "因为你喜欢《某某》,推荐…" |
电影推荐系统选 ItemCF 不是因为它更高级,而是三个工程原因:
第一,物品数量可控。一个课程设计用的数据集撑死几千部电影,两两相似度哪怕朴素计算也就千万级别,存内存毫无压力;而用户数量随便就上万,UserCF 的用户对会到亿级。
第二,电影是长生命周期物品。《肖申克的救赎》的相似关系不会因为一个用户半夜打了分就改变,完全可以每天早上离线跑一次相似度矩阵,白天用户打分后只做在线聚合。UserCF 则不行,一个重度用户看完十部电影,他的相似关系全变了,在线实时算会很吃力。
第三,可解释性更好。答辩时演示"因为你喜欢 A 所以推荐 B",比"某个匿名的相似用户喜欢 B"更直观,评委也更容易听懂。
防不胜防的坑是:很多人在计算物品相似度时把"评分值"也用进来,得到的是分数修正版余弦相似度。入门真不用一上来就上修正余弦,先用 0/1 的共现矩阵跑通全流程,后面再替换公式。索引能跑通、推荐结果肉眼看着合理,比公式炫酷重要得多。
3. 数据库设计与评分数据准备:MySQL 里建表、导数据、写访问层
数据库是标题里另一个关键词,也是答辩时最容易"被问住"的部分。很多人的库就是一张表塞所有字段,或者干脆把评分数据存 CSV,被问到"你的数据一致性怎么保证"就答不上来。这一章直接给出一套能自圆其说的表结构和写法。
3.1 电影推荐系统数据库应该建几张表:users、movies、ratings 三件套
推荐系统数据库只干三件事:存用户、存电影、存评分。至于电影简介、海报、导演这些字段,属于锦上添花,等核心跑通了再补也来得及。表结构我一般这样设计:
-- 建库,字符集必须用 utf8mb4,电影标题里可能带特殊字符 CREATE DATABASE IF NOT EXISTS movie_recommend DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE movie_recommend; -- 用户表 DROP TABLE IF EXISTS users; CREATE TABLE users ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 电影表 DROP TABLE IF EXISTS movies; CREATE TABLE movies ( movie_id INT PRIMARY KEY, title VARCHAR(255) NOT NULL, genres VARCHAR(255) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 评分表 DROP TABLE IF EXISTS ratings; CREATE TABLE ratings ( user_id INT NOT NULL, movie_id INT NOT NULL, rating FLOAT NOT NULL, rated_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, movie_id), KEY idx_movie (movie_id), CONSTRAINT fk_ratings_user FOREIGN KEY (user_id) REFERENCES users(user_id), CONSTRAINT fk_ratings_movie FOREIGN KEY (movie_id) REFERENCES movies(movie_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个容易被人问到的设计决策:
movie_id 用 INT 而不用字符串。算法层要把电影做成矩阵的列索引、做字典的 key,字符串 id 白白增加内存和比较开销,用自增或数据集自带的整数 id 最顺手。
genres 用 VARCHAR 存逗号分隔的字符串就够了。只有当你要做"用户类型偏好分析"或者"内容特征混合推荐"时才需要拆一张 movie_genres 关联表,课程设计阶段拆了反而显得没想清楚。
ratings 表主键用 (user_id, movie_id) 联合主键,天然防止同一用户对同一部电影重复评分。这是数据质量的兜底保障;如果用了自增 id 做主键,同样的数据能插进去两份,后面算相似度时全是脏数据。
外键不是必须的,但我坚持加上。评分表每插入一行都要校验用户和电影是否存在,外键让数据库在源头挡住脏数据,比在 Python 里每次查询前先校验一遍可靠得多。
3.2 用公开电影评分数据做初始化:pandas 清洗后批量写入 MySQL
开发期最缺的不是代码,是数据。电影推荐系统常用的公开数据集是 MovieLens,老版本 1M 的数据是 users.dat、movies.dat、ratings.dat 三个文件,字段之间用::分隔。这个格式很典型,正好演示一下数据清洗和入库的标准流程:
# load_movielens.py —— 将 MovieLens 原始数据清洗后写入 MySQL import pandas as pd from sqlalchemy import create_engine # 1. 读取原始 .dat 文件,分隔符是 :: unames = ['user_id', 'gender', 'age', 'occupation', 'zip'] rnames = ['user_id', 'movie_id', 'rating', 'timestamp'] mnames = ['movie_id', 'title', 'genres'] users = pd.read_csv('users.dat', sep='::', engine='python', header=None, names=unames) ratings = pd.read_csv('ratings.dat', sep='::', engine='python', header=None, names=rnames) movies = pd.read_csv('movies.dat', sep='::', engine='python', header=None, names=mnames) # 2. 清洗:评分列强制转数值,过滤空值和越界值 ratings['rating'] = pd.to_numeric(ratings['rating'], errors='coerce') ratings = ratings.dropna(subset=['rating']) ratings = ratings[(ratings['rating'] >= 0.5) & (ratings['rating'] <= 5.0)] # 3. 批量写入 MySQL,SQLAlchemy 内部走 executemany,效率远高于逐条 insert engine = create_engine('mysql+pymysql://root:your_password@localhost:3306/movie_recommend?charset=utf8mb4') users.to_sql('users', engine, if_exists='replace', index=False) movies.to_sql('movies', engine, if_exists='replace', index=False) ratings.to_sql('ratings', engine, if_exists='replace', index=False) print('导入完成,评分记录数:', len(ratings))参数说明:
sep='::' 对应老版 MovieLens 的分隔符,新版如果下载到的是 CSV 格式,记得改成 sep=','。engine='python' 是必须的,因为::是多字符分隔符,pandas 默认的 C 引擎不支持,不指定的话会在读取时报错,这个问题出现过无数次。
if_exists='replace' 适合开发期反复跑脚本,但要注意它会把整张表 drop 再重建,如果表里有你自己注册的用户数据,会一起被清掉。多人协作或已经上线演示时,改成 if_exists='append' 更稳。
to_sql 批量写入内部是 executemany,几万条评分几秒钟就进去了。如果你的数据量到了百万级,给 to_sql 加个 chunksize=5000,避免一次性把整张 DataFrame 塞进内存吃满。
3.3 数据库访问层:pymysql 参数化查询与连接复用
算法模块和后面的 Flask 展示层都要频繁访问数据库,这一层写得好不好直接影响体验。最常见的问题是:SQL 用 f-string 拼接参数,以及每查一次就 new 一个连接。前者是 SQL 注入隐患,后者是性能黑洞。
更稳的写法是封装一个轻量访问层,统一管理连接和参数化查询:
# db.py —— 轻量数据库访问层 import pymysql class DB: def __init__(self, host='localhost', port=3306, user='root', password='', database='movie_recommend'): self.config = { 'host': host, 'port': port, 'user': user, 'password': password, 'database': database, 'charset': 'utf8mb4', 'cursorclass': pymysql.cursors.DictCursor } def __enter__(self): self.conn = pymysql.connect(**self.config) return self def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close() def fetch_all(self, sql, params=None): with self.conn.cursor() as cursor: cursor.execute(sql, params) return cursor.fetchall() def execute(self, sql, params=None): with self.conn.cursor() as cursor: rows = cursor.execute(sql, params) self.conn.commit() return rows用法示例:
with DB() as db: recent = db.fetch_all( "SELECT movie_id FROM ratings WHERE user_id = %s ORDER BY rated_at DESC LIMIT 10", (42,) )这里占位符必须用 %s 而不是 ?,这是 pymysql 的语法习惯,不管字段是数字还是字符串都用 %s。参数化查询的本质是让数据库驱动帮你转义,彻底杜绝拼接注入。
连接池的问题单独说一下:课程设计并发量不大,上下文管理器每次短连接完全够用;如果后面上了 Flask 并且部署到公网,再用 DBUtils 的 PooledDB 做连接池也不迟。答辩时主动说一句"我知道连接池方案,当前并发量下短连接成本可接受",往往比直接把代码写得花里胡哨更让评委信服。
4. 推荐引擎实现:相似度计算、TopN 推荐与三个必调参数
这一章是全项目的核心,也是答辩时评委盯着看的部分。协同过滤的代码实现有很多写法,但关键就三件事:评分矩阵怎么构造、相似度怎么算、TopN 推荐怎么生成且不泄漏已看过的电影。
4.1 从评分表到用户-物品评分矩阵:pivot、稀疏度与相似度计算
算法层第一步是把 ratings 表读出来,构造成"行为用户、列为电影"的矩阵。直接用 pandas 的 pivot_table 一步到位:
# build_matrix.py —— 从数据库加载评分并构造用户-物品矩阵 import pandas as pd from db import DB with DB() as db: rows = db.fetch_all("SELECT user_id, movie_id, rating FROM ratings") df = pd.DataFrame(rows, columns=['user_id', 'movie_id', 'rating']) # 行是用户,列是电影,值是评分 matrix = df.pivot_table(index='user_id', columns='movie_id', values='rating') print('矩阵形状:', matrix.shape) # 稀疏度计算:空缺比例是推荐系统的关键健康指标 total_cells = matrix.shape[0] * matrix.shape[1] filled_cells = matrix.notna().sum().sum() print(f'稀疏度: {1 - filled_cells / total_cells:.2%} 空缺')pivot_table 默认把重复的 (user_id, movie_id) 做聚合取均值,这正好兜底了数据里可能存在的重复评分。另一个容易翻车的细节:矩阵的行列索引必须是数据库里的原始 ID,不能重置成 0,1,2...,否则推荐结果回查电影标题时全部错位。稀疏度这个数字要养成打印的习惯,评分数据如果缺口超过 95%,后面相似度矩阵大量为 0 就是预期内的,不要慌。
接下来用 numpy 向量化计算物品相似度矩阵。这里我推荐先做"布尔共现 + 余弦归一"的版本,比直接拿评分数值算更稳健,也更好讲:
def cosine_similarity(matrix): """ 输入: 用户-物品矩阵,NaN 表示未评分 输出: 物品相似度矩阵,对角线置 0 """ # 评分矩阵(NaN -> 0) 与 布尔矩阵(是否评分) 分开 rating_matrix = matrix.fillna(0).values binary_matrix = (matrix.notna()).astype(int).values # 分子: 两部电影被同一用户共同评分的次数 co_occurrence = binary_matrix.T @ binary_matrix # 分母: 每部电影的评分用户数,开根号后做外积 item_popularity = binary_matrix.sum(axis=0) denominator = np.sqrt(np.outer(item_popularity, item_popularity)) denominator[denominator == 0] = 1 # 防止除零 similarity = co_occurrence / denominator np.fill_diagonal(similarity, 0) # 自己不推荐自己 return pd.DataFrame(similarity, index=matrix.columns, columns=matrix.columns)这段代码的分母是每部电影的评分人数开根号后做外积,得到的结果其实是在对"热门电影"做惩罚。一部 1000 人看过的大热片和一部 10 人看过的小众片,即使共现次数相同,大热片参与计算时会被更大的分母压下去。这个特性天然压制了"推荐结果永远是大热片"的问题。
4.2 预测评分与 TopN 生成:过滤已看、邻居截断、加权平均
有了相似度矩阵,推荐逻辑就一行能说清楚:遍历用户已评分的电影,把它们的相似邻居拿出来,按"相似度 × 评分"加权汇总。但完整实现有三个参数必须拿出来调:
# recommender.py —— ItemCF 推荐主流程 from collections import defaultdict def recommend_for_user(user_id, rating_df, item_sim_df, top_n=10, k=20, min_sim=0.1): """ :param rating_df: DataFrame, 列为 user_id, movie_id, rating :param item_sim_df: 物品相似度 DataFrame :param top_n: 最终推荐条数 :param k: 每个已看物品只取 top-k 个相似邻居 :param min_sim: 相似度下限阈值,低于该值视为噪声 """ user_rated = rating_df[rating_df['user_id'] == user_id] if user_rated.empty: return [] watched = set(user_rated['movie_id'].tolist()) movie_scores = defaultdict(float) weight_sum = defaultdict(float) for _, row in user_rated.iterrows(): movie_id = row['movie_id'] rating = row['rating'] # 取当前电影的相似邻居,按相似度排序后截断 neighbors = item_sim_df[movie_id].drop(labels=[movie_id]) neighbors = neighbors[neighbors >= min_sim].sort_values(ascending=False)[:k] for neighbor_movie, sim in neighbors.items(): if neighbor_movie in watched: continue # 已看过的直接过滤 movie_scores[neighbor_movie] += sim * rating weight_sum[neighbor_movie] += sim if not movie_scores: return [] # 加权平均:除以总相似度,消除评分数量差异 ranked = sorted( movie_scores.items(), key=lambda x: x[1] / max(weight_sum[x[0]], 1e-6), reverse=True ) return [int(movie_id) for movie_id, _ in ranked[:top_n]]三个必调参数按重要程度排:
**k(近邻数)**默认 20。k 过小,比如 3 到 5,推荐结果对单部电影的相似邻居特别敏感,随机性大;k 过大,比如 100,冷门电影的远亲也被拉进来,结果会向热门榜妥协。调试时从 20 出发,每次翻倍对比推荐列表。
**min_sim(相似度阈值)**默认 0.1。这个值有点玄学成分,低于它的邻居只说明"共同评分过几个人",统计上不显著。推荐结果太冷门就调低到 0.05,太热门就上调到 0.2,肉眼看着合适就停。
**top_n(推荐条数)**默认 10。这个不用纠结,网页端一排展示 10 部刚刚好,论文里截图也好看。
特意解释一下为什么要除以 weight_sum 做加权平均。如果不除,一个看过 200 部电影的用户,他参与推荐的基数比只看过 20 部的用户大 10 倍,累积的 movie_scores 天然偏高,最终推荐出来的电影都是"和他已看列表重叠度高的电影",而不是"他真正可能喜欢的电影"。除以总相似度之后,每个候选电影的分数含义变成"近邻们对它的加权平均分",a 分数可以直接拿来和 b 分数比大小。
4.3 推荐效果怎么验证:留一法与 Precision@N
推荐做好以后,最怕的问题不是"效果差",而是"你不知道效果有多差"。推荐系统没有标准答案,必须自己建一个评估流程。课程设计阶段我建议用留一法,简单且答辩时好解释:
# evaluate.py —— 简单离线评估:留一法 + Precision@N import random def leave_one_out_evaluate(rating_df, recommend_func, top_n=10, sample_ratio=0.1): """ 留一法评估:随机抽取用户,留出一条评分做测试, 看推荐列表里是否包含这条被留出的电影。 """ users = rating_df['user_id'].unique() # 只评估评分数量足够多的活跃用户 active_users = [u for u in users if len(rating_df[rating_df['user_id'] == u]) >= 20] sample_users = random.sample(active_users, max(1, int(len(active_users) * sample_ratio))) hit = 0 total = 0 for uid in sample_users: user_rows = rating_df[rating_df['user_id'] == uid] test_row = user_rows.sample(1).iloc[0] train_rows = user_rows.drop(index=test_row.name) recommended = recommend_func(uid, train_rows, top_n=top_n) if test_row['movie_id'] in recommended: hit += 1 total += 1 return hit / max(total, 1) precision = leave_one_out_evaluate(df, recommend_for_user) print(f'Precision@10: {precision:.4f}')留一法的关键点在于:被留出的那条评分绝对不能参与相似度矩阵的训练。很多人在评估时偷懒,直接用全量数据算相似度,再把测试电影放回推荐列表里去比对,指标虚高得离谱,论文里一眼就能被看出来是信息泄漏。Precision@10 的绝对值通常不高,百分之几到十几都正常,这个指标真正的价值是横向对比:同一个数据集上,k=5、20、50 各跑一遍,看哪个参数组合分数最高。答辩时能说出"我对比了三个 k 值,取最优"就足够了。
5. 避坑与排查:协同过滤电影推荐系统常见的五类翻车现场
这个题目看起来简单,实际跑起来能翻车的地方非常多。我把做这类系统时踩过的坑和帮别人排查过的问题总结成五类,每类按"现象 → 原因 → 解决"来写。
5.1 相似度矩阵全是 0 或小数:评分数据太稀疏
现象:算出来的物体相似度矩阵几乎全是 0,推荐结果为空,或者永远推同一部大热片。
原因:数据稀疏到"两部电影从未被同一个用户评过分",co_occurrence 矩阵大量为 0,余弦相似度自然趋近 0。刚导入几百条测试数据就急着看效果,是最常见的原因。
解决:先查数据量——SELECT COUNT(*) FROM ratings,评分记录怎么也得几千条以上才有"协同"效果。然后打印矩阵稀疏度,高于 95% 就要考虑用布尔共现版本而不是直接拿原始评分。最后在计算相似度时过滤掉被评分人数过少的电影,比如少于 5 人评分的直接不参与相似度计算,既降噪又减内存。
5.2 推荐结果里全是用户已经看过的电影
现象:网页上展示的推荐列表,第一部就是用户自己打 5 星的电影。
原因:推荐函数没有过滤"用户已评分"项。很多入门代码只做排序不过滤,相似度矩阵对角线虽然被清零了,但 A 电影的邻居里仍然包含它自己,加权汇总时自己给自己贡献了高分。
解决:在聚合循环外部维护一个 watched 集合,内部循环里第一件事就是if neighbor_movie in watched: continue。这是整个推荐引擎里最容易漏的一步。自测方法很简单:找一个评分数量多的用户跑推荐,然后检查推荐列表和 ratings 表的交集是否为 0。
5.3 数据库导入一跑就报错:表已存在、字符编码、外键冲突
现象:第二次运行导入脚本报Table 'users' already exists;或者中文电影标题在数据库里变成一串问号。
原因:建表脚本没有 DROP TABLE IF EXISTS 兜底;连接串没带 charset 参数;导入顺序错误导致外键关联失败。
解决:开发期 SQL 脚本统一加DROP TABLE IF EXISTS,按 users、movies、ratings 的顺序建表和导数据,外键表必须在父表之后写入。连接串和 pymysql 配置里都写上 charset='utf8mb4',UTF-8 的坑通常不是"中文乱码"而是"特殊字符直接报错"。批量导入时用事务包裹,失败回滚,避免留下一半干净一半脏的数据。
5.4 UserCF 全量用户相似度计算卡死
现象:用户量到几千后,两两用户相似度计算跑了十几分钟还没结束,CPU 占用拉满。
原因:UserCF 在用户数量上做两两对比,复杂度是 O(U²)。用户数从 1000 涨到 5000,计算量涨 25 倍,这是算法本身的天然瓶颈。
解决:用物品倒排表生成候选用户对,只有共同评过分的一对才算相似度,跳过大量空对。我在第 2 章给出的 UserCF 代码就是倒排表写法,能扛到上万用户。如果用户量再大,直接换 ItemCF 做主线,物品数量通常远小于用户数量,相似度矩阵可以离线计算,这才是工程上的正确选择。
5.5 新注册用户和新上架电影没有任何推荐结果
现象:新用户注册进来,推荐接口返回空列表;刚录入的电影永远不会出现在任何人的推荐里。
原因:协同过滤完全依赖评分行为。新用户没有任何评分记录,算法找不到相似用户或相似物品的锚点;新电影没有评分,相似度矩阵里它的行和列全是 0。这叫冷启动问题,是协同过滤的先天缺陷。
解决:加一层热度兜底。用户没有评分记录时,直接返回全局评分人数最多的前 20 部电影,SQL 写SELECT movie_id, COUNT(*) AS cnt FROM ratings GROUP BY movie_id ORDER BY cnt DESC LIMIT 20。新电影则可以在相似度矩阵里叠加内容相似度,比如类型相同的电影互相给个基础相似度。答辩时主动说一句"我用热度策略兜底冷启动",比装没看见强得多,这属于每个推荐系统面试官都会问的点。
6. 从离线到在线:用 Flask 把推荐结果跑成网页
6.1 Flask 最小推荐接口
算法模块跑通之后,还得给评委一个能点的东西。Flask 是 Python 生态里最轻量也最好讲的后端框架,把推荐函数包成一个 HTTP 接口只需要几十行:
# app.py —— Flask 推荐服务最小实现 from flask import Flask, jsonify import pandas as pd from recommender import recommend_for_user from db import DB app = Flask(__name__) # 应用启动时预加载数据,避免每次请求都重算相似度 with DB() as db: rows = db.fetch_all("SELECT user_id, movie_id, rating FROM ratings") rating_df = pd.DataFrame(rows) @app.route('/api/recommend/<int:user_id>') def recommend_api(user_id): result = recommend_for_user(user_id, rating_df, item_sim_df, top_n=10) return jsonify({'user_id': user_id, 'movies': result}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)这里最关键的工程决策是:相似度矩阵在模块加载时算一次,放进内存,绝对不要放进请求函数里。否则每个用户刷新一次页面就全量重算一遍相似度,几千部电影时接口延迟能卡到让人怀疑人生。实际课程设计里,item_sim_df 可以在导入数据后序列化成文件缓存,启动时直接加载,省掉每次启动的冷启动计算时间。
6.2 验证推荐效果的两条实操习惯
做到这一步,系统已经能跑了,但"能跑"和"效果对"之间还有距离。我习惯用两个办法做快速验证:
第一个是自建对照账号。注册两个新账号,给账号 A 只给科幻片打高分,给账号 B 只给爱情片打高分,各打 20 部左右,然后分别调用推荐接口。如果两边推荐列表没有明显类型分化,说明相似度计算或数据加载有问题,不需要看任何评估指标就能定位。
第二个是调参数观察变化。把 k 从 5 调到 50,对同一个用户跑推荐,结果差异巨大说明 k 没调稳;结果几乎不变说明数据稀疏或者邻居质量低。这个观察过程和调 min_sim 是配套的,肉眼确认比死磕指标更快。
我最早做这套系统时,第一版跑通后急着截图演示,结果推荐列表里全是用户已经看过的片子,第二天就要交中期报告,连夜排查才发现是过滤 watched 集合的那一行写在了错误的位置。从那以后我就养成了一个习惯:任何推荐效果的改动,先跑一遍评估脚本,再截图。这个习惯帮我省下的力气远超毕设本身,现在做任何推荐相关的方案,我都默认"评估先行"。希望这个顺序也能帮你少走一点弯路,少熬一个夜。
本文还有配套的精品资源,点击获取