简介:面向需要完成推荐系统方向毕业设计或课程设计的高校学生,资源包含一套基于协同过滤的电影推荐系统完整项目。系统基于 Django2 + Python3.7 + MySQL/SQLite + Bootstrap3 实现,同时包含基于用户(UserCF)和基于物品(ItemCF)两种协同过滤算法,并选用 MovieLens 经典数据集作为实验数据,可在线预览,能帮助读者快速理解推荐流程与 Web 工程整合方式。包体共 83 个文件,压缩包约 8.41MB,主要包含 Python 源码与编译文件、Django 模板页面、CSV 评分/电影数据、论文 PDF、Readme 与建库配置说明;文件组织按项目结构分模块存放,便于对照学习。目前已有 10610 人学习/下载。除了常规代码,还附带数据库文件、豆瓣爬虫脚本和数据导入脚本,技术文档和 Readme 较完整,适合在本地环境快速跑通整套系统,也可作为论文撰写和项目答辩的参照,尤其适合作为计算机相关专业毕设的模板与二次开发基础。
1. 协同过滤:不看电影内容,凭什么猜中你的下一部电影
做推荐系统最反直觉的一点:真正能预测你下一部电影的手段,不是分析电影的题材、导演、剧情,而是找到一群和你口味高度重合的人,看他们最近在给什么打高分。协同过滤把这个直觉变成了可计算的范式——用户之间过去的评分一致性,就是预测未来的依据。它不关心电影“是什么”,只关心“谁和谁的口味靠得近”。这份设计资源把整个协同过滤流程落在 Python 3.7 + Django 2.2.1 + MySQL 的 Web 项目里,数据库里的评分表变成相似度矩阵,预测公式变成模板上的推荐列表。搞懂这条链路的底层机制,比会调一个推荐库的接口重要得多——因为后续所有进阶方向:稀疏矩阵、推荐解释、矩阵分解,都是在回答同一个问题:这个相似度到底是怎么算出来的、算出来以后要拿它做什么。
2. 数据模型:MySQL 三张表和内存双映射如何搭起评分底座
协同过滤算法的“输入”是一堆评分记录(用户、电影、分数、时间),输出是一个有序的电影列表。Web 层能拿到这个结果的先决条件,是底层的数据结构既能回答“这个用户看过什么”,又能回答“这个电影被谁看过”。这一章先看 MySQL 的表怎么设计,再看这些行数据加载进内存之后,以什么形态参与算法计算。
2.1 models.py 里的三张表:评分表为什么必须独立存在
Django 项目里最直观的表达是 models.py。用户表、电影表、评分表——其中评分表是核心:
# models.py from django.db import models class MovieUser(models.Model): username = models.CharField(max_length=50, unique=True) created_at = models.DateTimeField(auto_now_add=True) class Genre(models.Model): name = models.CharField(max_length=50) class Movie(models.Model): title = models.CharField(max_length=200) genres = models.ManyToManyField(Genre, blank=True) class Rating(models.Model): user = models.ForeignKey(MovieUser, on_delete=models.CASCADE, db_index=True) movie = models.ForeignKey(Movie, on_delete=models.CASCADE, db_index=True) score = models.FloatField() created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('user', 'movie')Meta 里的 unique_together 在 MySQL 中生成的是(user_id, movie_id)联合唯一索引。这个约束有两个意义:第一,同一用户对同一电影只能有一条评分记录,数据层面杜绝重复;第二,这个联合索引恰好服务了推荐系统的两个高频查询——“按 user_id 查这个用户的所有评分”“按 movie_id 查这部电影被谁评分过”。外键上的 db_index 是另一层索引,但真正支撑推荐计算的往往是这个联合索引。
评分表独立成表,而不是在用户表里塞一个评分数组,原因是关系模型下“多对多”的中间表天然支持这两个方向的检索。MySQL 会按联合索引的 B+ 树组织评分数据,where user_id=5 和 where movie_id=88 都能快速定位记录行。如果在用户表里塞 JSON,查询“某部电影的评分用户列表”就必须遍历所有用户,再把 JSON 取出来解析,这是一条完全不可用的路。
score 字段用 FloatField 是刻意的:相似度和预测都要做浮点乘加,整数在计算过程中会反复被转成 float,直接用浮点型省一层转换。代价是数据库里可能出现 4.0000001 这种精度噪声,展示给用户之前做一次 round() 就好。如果只打算收集 1~5 的整数评分,也可以用 SmallIntegerField 存整数、计算时再转浮点,各有取舍。
2.2 加载到内存:为什么同时维护 user_items 和 item_users
评分表是行存储,算法是矩阵运算。协同过滤计算的第一步,是把 MySQL 的行数据转换成内存里两个方向的稀疏矩阵:
# recommend/utils.py from collections import defaultdict def load_rating_matrix(): """一次性加载评分表,构建双向映射""" rows = Rating.objects.values('user_id', 'movie_id', 'score') user_items = defaultdict(dict) # {user_id: {movie_id: score}} item_users = defaultdict(dict) # {movie_id: {user_id: score}} for row in rows: uid = row['user_id'] mid = row['movie_id'] score = row['score'] user_items[uid][mid] = score item_users[mid][uid] = score return user_items, item_usersdict 嵌套 dict 是稀疏矩阵最直接的表达:不存在评分的位置根本不占存储。user_items 服务 User-based CF——求目标用户与其他用户的相似度,需要快速取出“这个用户评过哪些电影、评分多少”;item_users 服务 Item-based CF——需要知道“某部电影被哪些用户评过分”。这两个方向的数据在计算时是互为转置的关系,缺一个就得在另一个上做全量扫描。
rows 用 values() 而不是Rating.objects.all(),是因为后者会实例化完整 Model 对象,带 id、外键对象、字段缓存和信号钩子的开销;values() 直接返回字典,查询只 SELECT 指定的三列。评分记录量到十万级之后,这个选择决定内存是 200MB 还是 800MB,算是最早踩过的内存坑之一。
提示:数据量继续增长、单机内存扛不住时,可以只加载“活跃用户”的子集,把评分次数低于阈值的用户剔除后再构建矩阵,推荐质量通常不会明显下降。
2.3 预处理参数:min_rating、min_user_count 与时间衰减的取舍
构建矩阵之外的预处理,决定了后继相似度计算的可靠性。一个电影如果只有一个人评分,它对相似度的贡献基本是噪声;min_rating=5 表示只有评分人数不少于 5 的电影才进入矩阵。用户过滤同理:只评了 1 部电影的用户,和谁都不太可能有统计意义上的共同偏好。
常见的实现思路是加载时统计频率、过滤后再填矩阵:
def build_filtered_matrix(min_rating_count=5, min_user_count=3): raw = Rating.objects.values('user_id', 'movie_id', 'score') movie_rating_count = defaultdict(int) user_rating_count = defaultdict(int) for row in raw: movie_rating_count[row['movie_id']] += 1 user_rating_count[row['user_id']] += 1 user_items, item_users = defaultdict(dict), defaultdict(dict) for row in raw: # 评分人数太少 / 评分条数太少的用户,直接剔除 if movie_rating_count[row['movie_id']] < min_rating_count: continue if user_rating_count[row['user_id']] < min_user_count: continue user_items[row['user_id']][row['movie_id']] = row['score'] item_users[row['movie_id']][row['user_id']] = row['score'] return user_items, item_usersmin_rating_count 防止“孤品电影”制造虚假高相似,min_user_count 防止“僵尸用户”污染最近邻集合。时间衰减是进一步的可选项:用户一年前的口味和现在不是一回事,可以考虑给每个评分加权 score * exp(-lambda * age_days),lambda 取 0.005 意味着 200 天前的评分权重衰减到约 1/e。这个参数看着像玄学,实测区别很明显——冷启动用户历史记录稀疏,衰减掉远历史权重后,推荐列表会明显偏向近期的活跃口味。
3. 相似度计算:余弦与皮尔逊都在做同一件去偏的事
相似度是协同过滤的核心假设的数学化:认为两个人口味接近,依据是什么?是他们给共同看过的电影的评分一致性。计算一致性的方式很多,但实践中沉淀下来的是余弦相似度和皮尔逊相关系数。这一章讲清楚两个公式的机制、边界,以及谁在什么场景下更稳。
3.1 余弦相似度:为什么方向比长度重要
把用户 A 的评分看成向量 (a1, a2, ..., an),每个维度是一部电影,值是评分。余弦相似度计算的是两个向量夹角的余弦值,公式是点积除以两个向量的模。关键机制:夹角只反映“评分走势”是否一致,不反映评分的绝对高低。
A 和 B 看同一批电影,A 全部打 5 分、B 全部打 4 分,余弦相似度约等于 1.0——因为他们对每部电影的相对次序完全一致。这正是推荐想要的:A 和 B 都觉得片子 X 比片子 Y 好,那么 B 给 Z 打高分时,A 极有可能也会喜欢 Z。而欧氏距离会把这个场景误判成“不相似”,因为它把绝对分值差也当成了差异。
实现时直接取共同评分的电影维度:
import math def cosine_similarity(vec_a: dict, vec_b: dict) -> float: """vec 是 {movie_id: score},只统计共同评分的电影""" common = set(vec_a) & set(vec_b) if len(common) < 1: return 0.0 dot = sum(vec_a[m] * vec_b[m] for m in common) norm_a = math.sqrt(sum(s ** 2 for s in vec_a.values())) norm_b = math.sqrt(sum(s ** 2 for s in vec_b.values())) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b)注意 norm_a、norm_b 用的是该用户全量评分的范数,不是共同部分的范数。这里有两种流派:只用共同维度做归一化,能把“方向上高度一致”严格映射到 1.0;用全量范数会让“共同评分数少”的用户对天然获得更低的相似度,等于顺带惩罚冷启动用户。毕业设计里一般用前者,因为实现直观;后者适合数据极稀疏的场景。两种写法算出来的绝对值不同,但 Top-N 的排序通常变化不大。
3.2 皮尔逊相关系数:先减去自己的平均分再算余弦
余弦相似度有一个隐藏的偏置:用户打分的“慷慨度”。A 常年打 5 分,B 常年打 2-3 分,他们对电影的“相对偏好”完全一致时,余弦相似度会被全局均值带偏,压缩到 0.8 以下。皮尔逊相关系数做的事就是给每个用户去均值:先把向量里的每个评分减去该用户所有评分的平均值,再计算余弦。
def pearson_similarity(vec_a: dict, vec_b: dict) -> float: common = set(vec_a) & set(vec_b) if len(common) < 2: # 少于两个共同评分,统计上没有意义 return 0.0 avg_a = sum(vec_a.values()) / len(vec_a) avg_b = sum(vec_b.values()) / len(vec_b) num = sum((vec_a[m] - avg_a) * (vec_b[m] - avg_b) for m in common) denom_a = math.sqrt(sum((vec_a[m] - avg_a) ** 2 for m in common)) denom_b = math.sqrt(sum((vec_b[m] - avg_b) ** 2 for m in common)) if denom_a == 0 or denom_b == 0: return 0.0 return num / (denom_a * denom_b)avg_a、avg_b 的计算有两种口径:全量评分的平均数,或共同评分上的平均数。用全量均值能刻画“这个用户总体打分习惯”,扣掉之后剩下的才是相对口味信号;用共同评分均值则更严格地固定共同维度上的比较基准。实践中更常见的是全量均值,因为计算代价低,而用户总体习惯本身就是需要去除的偏置。
去均值之后,A 的评分向量变成 [-1, 0, 1, 2, ...],B 也中心化到自己均值附近,两个向量的方向不再受打分区间的尺度影响。等于说皮尔逊是“做过标准化”的余弦。这也是为什么皮尔逊在评分数据上比余弦更稳:电影评分不是稀疏的 0/1 反馈,而是有明确高低含义的连续值,去偏是必要动作。
3.3 倒排索引与 O(n²) 问题:别让零计算浪费 CPU
朴素实现里,每个用户和全量其他用户算一遍相似度。用户数到 5000,就是约 1250 万对组合,其中绝大多数没有共同评分,结果是 0 或者根本是垃圾信号。工程上第一步是倒排索引:遍历评分记录,按电影把用户聚合,找出“哪些用户对确实共同看过电影”,再做相似度计算:
# min_overlap 是用户对至少共同评分的电影数 def compute_user_similarity(user_items: dict, item_users: dict, min_overlap: int = 3, top_k: int = 20) -> dict: """计算每个用户的 Top-K 最近邻用户""" overlap_count = defaultdict(int) for movie_id, users in item_users.items(): user_list = list(users.keys()) # 同一个电影下的用户两两配对,统计共同评分次数 for i in range(len(user_list)): for j in range(i + 1, len(user_list)): a, b = user_list[i], user_list[j] if a > b: a, b = b, a overlap_count[(a, b)] += 1 sims = defaultdict(dict) for (a, b), overlap in overlap_count.items(): if overlap < min_overlap: # 共同样本太少,相关性不可信 continue s = pearson_similarity(user_items[a], user_items[b]) if s <= 0: continue sims[a][b] = s sims[b][a] = s result = {} for uid, neighbors in sims.items(): # 只保留相似度最高的 top_k 个邻居 ranked = sorted(neighbors.items(), key=lambda x: -x[1])[:top_k] result[uid] = dict(ranked) return result倒排索引基于一个稀疏性的基本事实:评分矩阵绝大部分位置是空的,绝大多数用户对根本没有共同评分。先筛选“有交集的用户对”,再计算相似度,能把计算量从“用户数的平方”直接拉低到“实际交互关系数”。假设 5000 用户、每人平均评 40 部电影,全量组合是 1250 万,而有共同评分的用户对往往只有几十万,差距是一个数量级以上。
min_overlap 是这里的另一个关键阈值:两个用户只共同看过一部片子而且恰好都给满分,算出来的 1.0 毫无预测价值。把重叠数低于 3 的用户对直接记为不相似,看起来是丢掉了少量“数学上正确”的结果,实际上滤掉了大量偶然噪声——这是从公式正确走向工程有效必经的一步。计算完的结果也不用存全量矩阵,每用户只保留 Top-K 个邻居就行,这个取舍第 6 章还会展开。
4. 推荐链路:加权预测、候选集过滤与 Django 视图的完整落地
相似度算完之后,到了把“谁和谁口味像”变成“给这个用户推荐什么”的部分。完整链路分三段:候选集怎么来、预测分怎么算、Django 视图怎么把这些数字组装成响应页面。这一章走的是 Item-based CF 的路线,它比 User-based 更适合这个 MySQL 存储模型——电影数通常远小于用户数,物品相似度矩阵更小、也更稳定。
4.1 加权预测公式:预测分为什么不是简单平均
Item-based 的思路:某用户看过电影 A 并打了分,和 A 相似的电影 B,很大概率也会被这个用户喜欢。B 的预测分 = Σ(用户对 A 的评分 × A 与 B 的相似度) / Σ 相似度。除以相似度总和是个关键:它把不同强度的证据归一化成同一个量纲。如果用户看过 8 部与 B 相关的电影,和只看过 2 部,累积的原始 Σ 会差好几倍;不归一化的话“看的电影更多的人”永远拿更高分,这不是推荐想要的效果。
去中心化的预测公式更稳健:
def predict_score(user_id: int, movie_id: int, user_items: dict, item_sim: dict, movie_means: dict, global_avg: float) -> float: """ 预测用户对某电影的评分。 去中心化:每个相似电影的贡献先减去该电影的平均分,再加回目标电影平均分。 """ if user_id not in user_items or movie_id not in item_sim: return global_avg numerator = 0.0 denominator = 0.0 for watched_movie, sim in item_sim[movie_id].items(): if watched_movie not in user_items[user_id]: continue rating = user_items[user_id][watched_movie] movie_avg = movie_means.get(watched_movie, global_avg) numerator += sim * (rating - movie_avg) denominator += sim if denominator == 0: return global_avg return movie_means.get(movie_id, global_avg) + numerator / denominatormovie_means 是预处理阶段顺手算出的每部电影平均分,充当“基准分”。去中心化放大了用户的“相对偏好”:用户给 A 打 5 分而 A 的均分是 3,说明这个用户特别吃这一挂;如果用户给 A 打 3 而 A 均分也是 3,这部片子对他只是及格线,就不能给它的近邻贡献高权重。这个细节很容易被省略,但它在评分普遍偏松或偏紧的数据集上,能让推荐质量明显提升。
预测值输出还要做边界兜底:加权算出来的数可能高于 5 或低于 1,展示前 clamp 回评分区间,例如 1~5 分制写min(max(score, 1), 5)。这个 clamp 逻辑虽然简单,忘了的话列表页会出现 5.8 分这种让用户怀疑人生的数字。
4.2 候选集生成:为什么先算已看内容再选未看内容
给用户做推荐时,算法不能从全库电影里逐个算预测分——那样要算所有电影和用户全部已看内容的相似度,开销大,而且绝大多数电影和用户的历史毫无交集。标准做法是从用户已评分电影的近邻集合里捞候选:
def generate_candidates(user_id: int, user_items: dict, item_sim: dict, top_k: int = 20) -> dict: """ 候选生成:遍历用户看过的电影,收集它们的 Top-K 近邻。 返回 {movie_id: (加权分, 权重和)},权重和用于最后的归一化。 """ watched = set(user_items[user_id]) cand_weighted = defaultdict(float) cand_weight = defaultdict(float) for watched_movie in watched: for neighbor, sim in item_sim.get(watched_movie, {}).items(): if neighbor in watched: continue rating = user_items[user_id][watched_movie] cand_weighted[neighbor] += sim * rating cand_weight[neighbor] += sim return {mid: (cand_weighted[mid], cand_weight[mid]) for mid in cand_weighted if cand_weight[mid] > 0}item_sim 里存的是每部电影的 Top-K 近邻,所以每次扩展的量级是“已看电影数 × K”,而不是全库电影数。过滤 watched 是必须的一步:给用户推荐他已经看过的电影,不仅没有意义,还会让排序的可信度直接归零——他看过你还推荐,说明系统没有观察能力。排除之后,按加权分/权重的比值归一化排序,取分数最高的 N 部,就是这一轮的推荐列表。
候选集的大小是个取舍:K=10 召回太少,冷门电影永远进不了列表;K=50 召回更全但排序计算更重。毕业设计里 K=20~30 是比较稳的区间,演示的时候推荐列表质量和响应时间都能兼顾。数据量大之后,还可以用“先粗筛再精排”的思路,候选集负责召回,预测分负责排序,两个环节解耦。
4.3 视图层落地:从 predict_score 到模板上下文
Django 视图要做的不是直接返回一个 list of ids,而是把算法输出翻译成模板能直接渲染的数据。典型视图:
# views.py from django.shortcuts import render from .utils import load_user_items, load_item_sim, generate_candidates def recommend(request): user_id = request.user.id user_items = load_user_items() item_sim = load_item_sim() candidates = generate_candidates(user_id, user_items, item_sim) # 按 (加权分 / 权重和) 排序,取前 12 部 ranked = sorted( ((weighted / weight, mid) for mid, (weighted, weight) in candidates.items() if weight > 0), reverse=True )[:12] movie_ids = [mid for _, mid in ranked] movies = {m.id: m for m in Movie.objects.filter(id__in=movie_ids).prefetch_related('genres')} results = [{'score': round(score, 2), 'movie': movies[mid]} for score, mid in ranked] return render(request, 'recommend/list.html', {'results': results})这里有几个工程细节。movie_ids 到 Movie 对象的映射用 dict 而不是 list,是为了在组装 results 时按排序后的分数顺序取回电影详情,避免二次查询或者在模板里再按 id 排序。Movie.objects.filter(id__in=movie_ids).prefetch_related('genres')一次查询把所有电影和相关类别都取回内存,而不是在循环里逐个 get——这是 Django 最常见的性能瓶颈点,下一章还有更典型的案例。
推荐结果里带上“为什么推荐”在毕业设计里是加分项,实现成本也低:把 generate_candidates 的返回结构改成每个候选附上它的来源电影列表,比如{'movie': 123, 'sources': [('电影A', 0.86), ('电影B', 0.74)]},渲染时展示“因为你喜欢电影A、电影B,所以推荐电影C”。这一层解释性在整个推荐链路里几乎不增加计算量,但对使用者的信任感提升非常明显。
5. 避坑排障:冷启动、稀疏矩阵与 N+1 查询的五个翻车现场
这一章的五条记录都是实际开发里的血泪经验,每条按“现象 → 原因 → 解决”拆开。前两条是算法层面的坑,后三条是工程层面的坑,按出现的频率从高到低排列。
5.1 新用户登录之后推荐列表永远是空
现象:注册了账号、登录、进入推荐页,列表空白,后台日志没有任何报错。
原因:协同过滤的预测依赖 user_items 里的历史评分。新用户一条评分都没有,user_items 里根本没有这个 key;generate_candidates 直接返回空字典,视图循环渲染不出来。冷启动是整个协同过滤范式结构性自带的问题——没有历史就没有相似度,没有相似度就没有预测。
解决:冷启动阶段用“注册偏好采集 + 全局热度兜底”。注册时让用户选几个感兴趣的类别,系统自动写入几条种子评分(比如该类别平均分 4.0),矩阵里立刻有坐标;如果种子评分也没做,则返回全局高分电影列表作为兜底,Django ORM 一条聚合就能完成:Movie.objects.annotate(avg_score=Avg('rating__score')).order_by('-avg_score')。
5.2 相似度算出来全是 1.0,推荐列表却莫名其妙
现象:排查相似度矩阵时,发现大量用户对相似度取到 0.99、1.0,但推荐结果毫无规律,看起来像是随机排序。
原因:这些“高相似”用户对只共同看过 2-3 部电影,而且评分恰好相同。共同样本太少时,偶然一致性被公式放大成强相关——本质上是统计意义缺失。两个用户只看过同一部片且都打了 5 分,他们之间 1.0 的相似度不能说明任何长期口味问题。
解决:在相似度函数里强制加阈值,共同评分数小于 3 直接返回 0.0,而不是继续计算。这个阈值就是第 3 章里的 min_overlap。同时排查时可以看每个用户对的“证据量”即重叠电影数,如果相似度排序前几名全是重叠数只有 1 的用户,说明阈值没生效或者数据太稀疏,要先做第 2.3 节的用户和电影过滤。
5.3 加载评分表直接让开发机内存报警
现象:把全部评分记录 load 进内存准备计算,30 万条数据进程内存就飙到 800MB+,开发机风扇狂转。
原因:用了list(Rating.objects.all()),每条记录被实例化成完整的 Model 对象,包含字段缓存、对象状态、外键代理,单条对象的内存开销是原始数据库行的数倍到数十倍。这是 Django ORM 隐藏开销的典型代表。
解决:统一用Rating.objects.values('user_id', 'movie_id', 'score')只取三维数组,字典或元组承载;更极端的场景可以直接用 pandas 的 chunksize 分块读。实际上在这个项目的评分量级下,values() 加载到内存通常能压到几十 MB,这是最简单有效的一刀。
5.4 用户量上来后,相似度矩阵重建时间从 3 秒变 10 分钟
现象:几千用户时相似度秒算完成,到一万用户后每次重建都要跑十几分钟,频繁触发请求超时。
原因:朴素的双重循环对所有用户对计算相似度,复杂度是 O(n²);用户量翻倍时耗时变成 4 倍。更致命的是,如果每次 Web 请求都重新算一遍相似度,接口必然超时。这是把“计算任务”和“读路径”混在一起的典型错误。
解决:第一层优化用倒排索引只计算有共同评分的用户对(第 3.3 节);第二层优化把相似度矩阵改成“离线构建”——用 Django 自定义 management command 写成python manage.py build_sim,定时触发,计算完落盘或写入 MySQL 内存表,Web 请求只读不写。这样推荐接口的耗时不受矩阵重建影响,请求能稳定在毫秒级返回。
5.5 模板渲染推荐结果时多出几百条 SQL
现象:推荐页加载时长好几秒,用调试工具看 SQL 数量,一个页面发了两三百条 select。
原因:视图里取了电影对象列表后,模板循环里访问movie.genres等字段,而 Django ORM 默认惰性加载,每个字段访问触发一次新查询,形成典型的 N+1 问题。列表越长得越厉害,30 部电影可能产生 150+ 条查询。
解决:查询时用Movie.objects.filter(id__in=movie_ids).prefetch_related('genres')一次性把关联数据取回内存,或者直接 values + 连表查出所有需要的字段。这条修完,页面 SQL 数从两百降到个位数,加载时间肉眼可见地下降。排查顺序也很简单:先看 SQL 数量,再定位哪一行触发的查询,最后决定是 select_related 还是 prefetch_related。
6. 设计取舍:离线重建还是实时计算,相似度矩阵该存哪
整个协同过滤项目绕不开一个工程决策:相似度矩阵什么时候算、算完放哪。两种做法的边界条件值得放一起对比。
| 方案 | 适用规模 | 更新成本 | 请求耗时 |
|---|---|---|---|
| 实时计算 | 用户几百、评分几千 | 每次请求全量重算 | 毫秒到秒级 |
| 离线重建 | 用户上万、评分百万 | 定时任务低频重建 | 毫秒级读缓存 |
毕业设计场景里,演示数据通常几百到几千用户,实时计算完全能撑住;但一旦往真实环境走,思路必然变成后者——把矩阵构建做成离线任务,请求端只做查找、过滤和排序三件事。这里的一个关键取舍是:相似度表存全量矩阵还是 Top-K 近邻表。全量矩阵存储量是 O(n²),一万用户就是一亿条浮点数;Top-K 近邻只保留每个用户或每部电影最像的 K 个邻居,存储量降到 O(n×K)。第 4 章候选集生成时只用每部电影的 Top-K 近邻,正是这个取舍的结果——推荐质量在 K 足够大的时候损失很小,存储和计算量却降了一两个数量级。
另一个取舍是 User-based 和 Item-based 的选择。User-based 更符合直觉,能直接解释“这些人和你口味相似”;Item-based 在工程上更稳,因为电影数量远小于用户数量,电影相似度矩阵更小、更新成本更低,新电影冷启动也能通过旧电影相似映射获得初始推荐机会。这个项目落在 MySQL 存储模型上,倾向 Item-based 是合理的:近邻表可以直接用数据库表承载,Web 请求只需要一次 key 查询就能拿到候选集。
矩阵怎么存也有讲究。存进程缓存意味着应用重启后第一次请求要等矩阵重建;存 MySQL 内存表可以跨进程共享,但要额外建一张表承载二维结构;写 pickle 落盘最省事,适合演示数据。建议的组合是:离线任务 + 本地缓存 + 定时失效,把这套逻辑封装在自定义 management command 里,手动python manage.py build_sim就能触发,演示时还能现场展示“重建矩阵只要几秒”的效果。这份设计资源里,矩阵加载、相似度计算、推荐视图、重建命令是完整可运行的几个模块,对照这条链路拉下来跑一遍,能直观看到每个参数改动对推荐列表的影响。
从这往后走,进阶方向是矩阵分解(Funk-SVD)、混合推荐和深度学习排序模型;但所有进阶版本都绕不开这一章讨论的两个问题:相似度从哪来、算完放在哪。从那以后我每次动手前都会先问自己:这份数据是静态的还是动态的?如果是动态的,离线任务和在线读路径的边界画在哪了?想明白这两件事,推荐系统从“能跑”到“能信”的距离就近了一大半。希望帮到你。
本文还有配套的精品资源,点击获取