简介:一套基于Django和Python的电影推荐系统设计源码,面向Python/Django学习者、毕业设计选题学生及推荐系统入门开发者,旨在提供从数据表结构设计到协同过滤推荐算法落地的完整工程参考。压缩包共725个文件、37.65MB大小,包含39个Python源码文件和35个字节码文件,也涵盖41个Vue组件、53个CSS样式、30个HTML页面,以及大量JavaScript脚本、SVG矢量图、PNG/JPG图片和SQL数据库脚本,能较完整呈现后端接口、前端界面与数据存储的协作方式。目前已有449人学习/下载,适合用于课程设计、毕业设计或协同过滤推荐系统的入门实践。通过研读源码,可以梳理基于用户行为的协同过滤推荐流程、Django与MySQL的整合方法,并结合Vue组件与CSS掌握推荐结果页面的交互呈现;附带的依赖安装、运行启动批处理文件,也方便本地快速部署和调试。
1. 基于Django和Python的电影推荐系统:说它是课设,其实是一套完整的召回-排序落地
如果你搜过“Django 电影推荐系统 源码”,大概率是在准备毕业设计或者想快速起一个推荐类Web项目。这标题看起来像个课设,但真把它拆开,你会发现它同时踩了三条技术线:Django的MTV架构怎么组织业务、推荐算法怎么把“相似”算出来、以及前端页面怎么把结果摆到用户面前。我见过不少人源码下了一堆,跑起来黑屏报错,最后卡在环境上——问题不在算法多难,而在你对Django的请求生命周期和ORM的查询习惯不够熟。
这篇我按自己做过的一版方案讲:用Django 3.2 + Python 3.8起步,推荐部分不做深度学习,先用基于物品的协同过滤(ItemCF)加上简单的热度兜底,既能在课设答辩时讲清楚原理,又能让源码有实际可演示的效果。适合的读者很明确:你会Python基础,懂一点Django的MTV套路,但没系统做过推荐项目,想拿到一套能跑、能改、能写进简历的工程。
2. 先搞清楚推荐系统在Django里长什么样:数据表、推荐服务和视图的三角关系
2.1 数据模型设计是地基:User、Movie、Rating三张表就能撑起ItemCF
任何推荐系统落到Django里,第一步不是写算法,而是把数据模型定下来。基于物品的协同过滤只需要三类数据:用户、电影、评分。我一直建议用Django默认的User表做用户,自己建Movie和Rating两张表,这样auth模块的登录注册直接复用,不用自己写密码校验。
以代码来建电影表,核心字段如下:
# movies/models.py from django.db import models from django.contrib.auth.models import User class Movie(models.Model): title = models.CharField(max_length=200, verbose_name='电影名') genres = models.CharField(max_length=255, blank=True, verbose_name='类型') release_year = models.IntegerField(null=True, blank=True) rating = models.FloatField(default=0.0, verbose_name='豆瓣均分') # 用于热度兜底 rating_count = models.IntegerField(default=0, verbose_name='评分人数') class Meta: db_table = 'movie' class Rating(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ratings') movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='ratings') score = models.IntegerField(choices=[(i, i) for i in range(1, 6)], verbose_name='用户评分') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'rating' unique_together = ('user', 'movie') # 一个用户对一部电影只能有一条评分这里的逻辑说明很简单:unique_together约束能保证评分数据不重复,是ItemCF计算相似度矩阵时的必要前提;rating_count和rating两个字段纯粹做热门电影兜底——新用户没有历史行为时,推荐系统最怕冷启动,这时用“豆瓣均分1人数多”的电影顶上去,比硬算相似度靠谱。
参数上要留意一个隐藏点:on_delete=models.CASCADE从Django 2.0开始强制要求写。我看到很多老教程写models.CASCADE不带on_delete,在Django 3.2上直接报TypeError。如果你下载的源码跑不起来,先检查models里所有外键是不是都带了on_delete。
2.2 推荐服务单独抽一层:视图里只做参数解析,算法逻辑全放service
Django新手最常见的毛病是把推荐逻辑直接堆在views.py里,一个函数两三百行。我一般会把推荐算法封在services/recommend.py里,视图只负责取用户、取参数、调服务、拼上下文。这样做的直接好处是:你在manage.py shell里能单独测试推荐函数,不用启动HTTP服务;后面想换协同过滤、改成基于内容的推荐,也只需要替换service层。
# movies/services/recommend.py import math from collections import defaultdict from django.db.models import F, Q from movies.models import Movie, Rating def build_item_similarity(min_sim_users=2): """ 基于物品的协同过滤:先构建电影->用户倒排索引, 再计算电影之间共同评分用户数,除以分母得余弦相似度。 """ ratings = Rating.objects.values('user_id', 'movie_id') movie_users = defaultdict(set) for r in ratings: movie_users[r['movie_id']].add(r['user_id']) sim_matrix = defaultdict(dict) movie_ids = list(movie_users.keys()) for i, m1 in enumerate(movie_ids): users_m1 = movie_users[m1] for m2 in movie_ids[i+1:]: common = users_m1 & movie_users[m2] if len(common) >= min_sim_users: sim = len(common) / math.sqrt(len(users_m1) * len(movie_users[m2])) if sim > 0: sim_matrix[m1][m2] = sim sim_matrix[m2][m1] = sim return sim_matrix def recommend_for_user(user, top_n=10): # 取用户评分过的电影 rated = list(Rating.objects.filter(user=user).values_list('movie_id', flat=True)) if not rated: # 冷启动:按热度兜底 return list(Movie.objects.order_by('-rating_count', '-rating')[:top_n]) sim_matrix = build_item_similarity() scores = defaultdict(float) for movie_id in rated: for other_id, sim in sim_matrix.get(movie_id, {}).items(): if other_id in rated: continue scores[other_id] += sim if not scores: return list(Movie.objects.order_by('-rating_count', '-rating')[:top_n]) ranked = [mid for mid, _ in sorted(scores.items(), key=lambda x: x[1], reverse=True)] return list(Movie.objects.filter(id__in=ranked[:top_n]))这段代码里有个需要重点说的参数:min_sim_users=2。它的意思是“两部电影至少要有2个共同评分用户才计算相似度”。为什么是2不是1?因为只算1个共同用户时,相似度很容易被单条异常评分带偏,出现“看过《教父》和《小时代》就推荐《小时代》”的荒谬结果。数据集大的时候可以把这个参数调到3~5,过滤掉稀疏噪声;但对于几千条评分的课设数据,2是一个不冷也不太噪声的平衡点。
另一个细节是build_item_similarity每次都被调用,性能很差。真实工程里应该把相似度矩阵缓存到Redis或者项目里直接序列化成pickle文件存下来,评分表发生增删时再重建。这里为了源码可读性,我故意没加缓存,但你得清楚这个函数的复杂度是O(m²),m是电影数量,不缓存的话它扛不住生产流量。
2.3 视图和URL路由:把推荐结果映射到具体页面
推荐服务写完之后,视图层就很薄了。我实现两个接口:一个是首页/recommend/展示给当前登录用户的个性化推荐,另一个是“相似推荐”/movie/<id>/similar/,点进电影详情页时列出与当前电影相似的片单。
# movies/views.py from django.shortcuts import render, get_object_or_404 from django.contrib.auth.decorators import login_required from movies.services.recommend import recommend_for_user, build_item_similarity from movies.models import Movie @login_required def recommend_home(request): movies = recommend_for_user(request.user, top_n=12) return render(request, 'movies/recommend_list.html', { 'movies': movies, 'page_title': '为你推荐' }) def movie_similar(request, movie_id): movie = get_object_or_404(Movie, pk=movie_id) sim_matrix = build_item_similarity() similar_ids = [] for mid, sim in sorted(sim_matrix.get(movie_id, {}).items(), key=lambda x: x[1], reverse=True)[:10]: similar_ids.append(mid) similar_movies = list(Movie.objects.filter(id__in=similar_ids)) return render(request, 'movies/similar_list.html', { 'movie': movie, 'similar_movies': similar_movies })这段代码值得解释的地方是:recommend_home用了@login_required装饰器,未登录用户会被重定向到/accounts/login/。这是Django做推荐系统的常见做法——你必须知道“谁”才能给他推荐,所以强制登录是合理的业务约束。但冷启动不是只针对新注册用户,未登录用户你根本拿不到user,所以首页要给游客展示热门榜。
URL路由配置上我习惯加一层命名空间:
# movies/urls.py from django.urls import path from movies import views app_name = 'movies' urlpatterns = [ path('recommend/', views.recommend_home, name='recommend_home'), path('movie/<int:movie_id>/similar/', views.movie_similar, name='movie_similar'), ]path('movie/<int:movie_id>/similar/')里的<int:movie_id>是Django的路径转换器,它自动把URL里的数字部分转成int类型传到视图函数。如果你写成<movie_id>不带int:,传进来的就是字符串,get_object_or_404(Movie, pk=movie_id)虽然能正常工作,但后面你要是拿它做数学运算,Python会直接报TypeError: unsupported operand type(s)。这是一个小坑,但排查起来很隐蔽。
3. 把协同过滤跑起来:数据导入、最小命令与相似度计算的坑
3.1 从CSV导入数据:一条Django管理命令搞定批量入库
真正动手做的时候,你会发现算法写得再漂亮,没有数据全是白搭。我用的方案是让源码自带一个manage.py管理命令,一键把datasets/movies.csv和ratings.csv导入数据库。这种数据加载方式比在Django shell里一条条Movie.objects.create()要快一个数量级。
先看命令代码,我放在movies/management/commands/import_data.py:
# movies/management/commands/import_data.py import csv from django.core.management.base import BaseCommand from django.contrib.auth.models import User from movies.models import Movie, Rating class Command(BaseCommand): help = '从CSV导入电影和评分数据' def add_arguments(self, parser): parser.add_argument('--movies', type=str, default='datasets/movies.csv') parser.add_argument('--ratings', type=str, default='datasets/ratings.csv') def handle(self, *args, **options): movies_path = options['movies'] ratings_path = options['ratings'] # 先清空旧数据,避免重复导入导致unique约束冲突 Rating.objects.all().delete() Movie.objects.all().delete() with open(movies_path, encoding='utf-8-sig') as f: reader = csv.DictReader(f) for row in reader: Movie.objects.create( title=row['title'], genres=row.get('genres', ''), release_year=int(row['release_year']) if row.get('release_year') else None ) # 评分导入需要先准备一个测试用户 demo_user, _ = User.objects.get_or_create(username='demo') with open(ratings_path, encoding='utf-8-sig') as f: reader = csv.DictReader(f) bulk_list = [] for row in reader: bulk_list.append(Rating( user_id=demo_user.id if row['user'] == 'demo' else int(row['user']), movie_id=int(row['movie']), score=int(row['score']) )) Rating.objects.bulk_create(bulk_list, batch_size=500)参数的讲究在于batch_size=500。bulk_create是Django批量插入的高效手段,但一次塞几万条数据会导致SQL语句过长,在MySQL上会碰到max_allowed_packet限制。500条一批是稳妥值,既不会触发SQL长度限制,也不会因为批次太碎损失太多性能。
另一个要点是encoding='utf-8-sig'。很多从网上下载的CSV文件带有BOM头(Excel导出常见),你用普通utf-8读,第一行的列名会变成\ufefftitle,row['title']直接KeyError。加-sig后缀就是为了剥掉这个BOM字符,这是一个极其常见的血泪经验。
运行命令是:
python manage.py import_data --movies datasets/movies.csv --ratings datasets/ratings.csv3.2 最小用户冷启动:score字段和相似度的协同关系
这个系统里我设计了一个隐藏的冷启动方案:当用户首次登录且没有任何评分记录时,recommend_for_user直接按-rating_count, -rating排序取热门电影。这在大数据集上没什么问题,但课设数据往往只有一两个测试用户的评分,rating_count大多是0,导致热门榜退化成随机顺序。
我后来加了一个修正:在import_data命令里按评分数动态回填热门值,Rating.objects.values('movie_id').annotate(cnt=Count('id')),然后把统计结果写回Movie.rating_count。这样冷启动推荐至少是按真实用户评分次数排序的,不会出现“一部没人评过的电影排第一”的怪异现象。
这引出一个更重要的认知:推荐系统的效果上限由评分数据的稀疏度决定,而不是由算法复杂度决定。Django里的ItemCF在10万用户、几百部电影的数据集上,计算相似度矩阵可能要几十秒,但在课设里只有几百条评分时,你不需要优化性能,反而要引入更多模拟数据来验证逻辑正确。我一般在ratings.csv里提前造400~600条评分记录,覆盖至少3个用户,这样才能演示出“不同用户看到不同推荐结果”的效果。
3.3 相似度计算时绕不开的三个细节:倒排索引、共同用户数、归一化分母
很多从GitHub下载的Django推荐系统源码,协同过滤写得特别简陋,比如直接用双层循环遍历所有用户评分,算每对电影的Jaccard相似度。这种嵌套循环在电影数量超过1000之后会明显卡顿。我更推荐先构建倒排索引再算相似度,也就是前面recommend.py里movie_users字典的写法。
这个倒排索引的本质是:从{用户: [电影列表]}翻转为{电影: {用户集合}}。好处是计算“共同评分用户”时,不需要扫描全部评分记录,只需要对两个用户集合做交集。Python的set交集操作是C语言实现的,比纯Python的for循环遍历快很多。
第二个细节是相似度的分母。我没用Jaccard系数(共同用户数 / 并集用户数),而是用余弦相似度的一种变体:共同用户数 / (sqrt(A用户数) * sqrt(B用户数))。两者都能用,但余弦公式对“热门电影”的惩罚更温和。比如《肖申克的救赎》有100个人评分,《低俗小说》有80个人评分,它们共同用户数60,Jaccard给的是60/(100+80-60)=0.5,余弦给的是60/sqrt(100*80)=0.67。你会发现两个公式的排序结果都会偏向热门电影,但做ItemCF时热门电影本身就更常被推荐,这个偏向在所难免。
第三个细节是过滤“已经看过”的电影。我这版代码里在给other_id累加相似度之前先判断if other_id in rated: continue。如果不过滤,推荐系统会把《教父》推荐给看过《教父》的用户,因为《教父》和《教父》自己的相似度必然是全集最大。这是新手经常犯的翻车点,逻辑上看起来也很合理,但用户看到推荐列表里全是自己看过的,第一反应就是“这系统坏了”。
4. Django执行查询和删除对象:推荐系统里最容易忽略的ORM性能细节
4.1 查询优化:values vs objects.all,你选对了吗
推荐引擎每跑一次,就会对Rating表做全表查询。我写的是Rating.objects.values('user_id', 'movie_id'),它只查两个字段,返回的是字典列表。如果你写成Rating.objects.all(),Django会把每行数据都实例化成一个完整的Rating对象,ORM还要额外执行类型转换和外键关联解析,内存占用直接翻好几倍。
在评分表几万条数据时,这个差别已经足够肉眼可见:前者耗时可能100毫秒,后者可能到600毫秒以上。推荐服务是要被每次页面请求调用的,我建议所有只读字段、不需要模型方法的查询一律用values或values_list,不要图省事直接.all()。
另一个常见的查询坑是F表达式。假设你想推荐“评分人数多且豆瓣均分高”的电影作为热门兜底,正确写法是:
Movie.objects.filter(rating_count__gte=100).order_by('-rating')[:10]有些源码会写成:
Movie.objects.all()[:100] # 然后Python里再排序这会导致先把所有电影加载到内存再手写排序,一旦电影表过万,页面直接卡死。Django的order_by是在数据库层面完成的,[:10]切片最终也会翻译成LIMIT 10,这种“下推”的查询写法才是生产级的。同理,想算“每个用户评了多少部电影”时,用:
from django.db.models import Count Rating.objects.values('user_id').annotate(movie_count=Count('movie_id')).order_by('-movie_count')Count('movie_id')默认是不计NULL的,而Count('*')计所有行。如果你movie_id有NULL值,两种写法结果会不一致,这在做用户活跃度统计时会干扰推荐权重。
4.2 删除对象:delete()的级联行为和unique约束冲突处理
推荐系统开发过程中,重复导入数据是家常便饭。如果直接跑import_data命令第二次,会因为unique_together约束报错:IntegrityError: duplicate key value violates unique constraint。
我在命令里特意在写入之前执行了Rating.objects.all().delete()和Movie.objects.all().delete()。这里有个容易忽略的细节:Movie.objects.delete()是QuerySet级别的删除,Django会先收集所有related对象并触发collector,逐个执行DELETE语句。如果Movie表和Rating表之间有外键约束,删除顺序不对可能引起外键冲突。Django的处理逻辑是:先找出所有关联的Rating记录,一并删除,之后才删Movie。所以上面两行的顺序是安全的,但如果你换成了Movie.objects.all().delete()在前,Django的级联删除也会自动清理Rating,只是效率会差一点。
如果你希望“软删除”(保留评分记录,只标记用户不可见),常见做法是给Movie表加一个is_active布尔字段:
# 迁移后 Movie.objects.filter(title='某片').update(is_active=False)然后再用objects.filter(is_active=True)过滤推荐结果。这个方案在Django里要用自定义QuerySet覆盖默认管理器,比直接delete()复杂一个层级,但对“保留用户历史评分、避免unique冲突”的场景很有用。我的建议是:课设项目直接硬删除,工业级项目再上软删除。
4.3 执行原始SQL查询:什么情况下你需要放弃ORM
Django ORM覆盖了95%的场景,但推荐系统里有一个场景我建议直接写原生SQL:计算“用户A评过的电影里,哪些电影的相似电影最多”。用ORM写需要多次查询拼接,不如一条SQL搞定:
from django.db import connection def get_similar_candidates(user_id, limit=20): with connection.cursor() as cursor: cursor.execute(""" SELECT m2.id, COUNT(*) AS cnt FROM rating r1 JOIN rating r2 ON r1.movie_id = r2.movie_id AND r1.user_id != r2.user_id JOIN movie m2 ON r2.movie_id = m2.id WHERE r1.user_id = %s AND r2.user_id IN ( SELECT user_id FROM rating WHERE movie_id IN ( SELECT movie_id FROM rating WHERE user_id = %s ) ) GROUP BY m2.id ORDER BY cnt DESC LIMIT %s """, [user_id, user_id, limit]) rows = cursor.fetchall() return [row[0] for row in rows]这段SQL的本质是“找和当前用户看过相同电影的其他用户,再找这些用户看过而我还没看过的电影,按共同度排序”。它就是UserCF的简化版,但效率比ORM循环高得多。参数user_id用%s占位符传给游标,这是防止SQL注入的正确姿势,别用f-string直接拼接。connection.cursor()是Django对数据库连接封装后的原生游标,执行完后with会自动归还连接,不会泄漏连接池。
但我要说一句实在话:课设答辩时,面试官或老师如果看到你用了原生SQL,可能更关注你能不能解释清楚“为什么要绕过ORM”。我的回答模板是:“因为这段逻辑涉及多个自连接和子查询,ORM翻译出来的SQL冗长且不可控;原生SQL能让我精确控制执行计划。”这个说法既诚实又体现工程意识。
5. 推荐系统必踩的五个环境与部署坑:现象、原因、解决方案
5.1 Django版本过高导致urlpatterns语法不兼容
现象:运行python manage.py runserver后,浏览器访问首页直接报TypeError: view must be a callable or a list/tuple in the case of include()。
原因:网上流传的源码大多是Django 2.x时代的,path()里传入views.recommend这样的函数没问题;但如果你装的是Django 4.x,某些旧写法如url(r'^recommend/', views.recommend)还在用re_path语法,或者视图函数签名少了一个request参数,Django的URL解析器会直接抛错。
解决:检查urlpatterns中每一个path或re_path,确认传给include的参数是模块路径字符串,而不是视图函数对象。最稳的做法是把项目requirements.txt锁到Django 3.2 LTS,然后用python -m pip install django==3.2.25重建环境。
5.2 Python 3.10以上运行旧源码报ImportError: cannot import name 'Iterator' from 'typing'
现象:在Python 3.11上跑Django 3.2项目,执行python manage.py migrate时报ImportError,定位到collections模块。
原因:Python 3.10把typing.Iterator等类型别名从collections迁到typing,旧版第三方库(尤其是anumbe、dateutil)仍从collections导入,导致兼容性崩溃。
解决:要么把Python降到3.8或3.9运行这个项目,要么在requirements.txt里加一个typing-extensions并在项目入口文件顶部手动补兼容:
# manage.py 顶部 import collections import collections.abc collections.Iterator = collections.abc.Iterator这段代码的本质是把缺失的别名映射到collections.abc里,属于“patch兼容层”。我不想说这很优雅,但它确实能让老源码在新Python环境里跑起来,是真正的后悔药。
5.3bulk_create时唯一约束冲突,整批导入全部回滚
现象:import_data跑到一半报django.db.utils.IntegrityError,前面的数据全没了。
原因:Rating.objects.bulk_create(bulk_list)默认在一个事务里执行,其中任一条违反unique_together,整批SQL都会失败,Django会回滚整个事务。
解决:给bulk_create加ignore_conflicts=True:
Rating.objects.bulk_create(bulk_list, batch_size=500, ignore_conflicts=True)ignore_conflicts=True会让数据库跳过违反唯一约束的行,而不是终止整个事务。代价是这条SQL不再返回受影响行数,所以如果你需要精确知道插入了多少条,就别用这个参数,而是在入库前用Rating.objects.filter(user_id__in=user_ids, movie_id__in=movie_ids).values_list(...)先查出已存在的记录,手动剔除。
5.4 前端显示推荐结果乱序:Django查询集被二次切片打乱
现象:推荐页每次刷新,电影顺序忽前忽后,看起来像列表随机化。
原因:视图里用Movie.objects.filter(id__in=ranked)取推荐电影,但id__in查询不保证返回顺序与ranked列表一致。数据库返回时按主键排序或按索引扫描顺序输出,完全不 care 你传入列表的排列。所以你辛辛苦苦算好的相似度排序,在前端展示时全被打乱。
解决:把查询集转成字典{id: movie}后,按ranked顺序拼新列表:
movies = Movie.objects.filter(id__in=ranked) movie_map = {m.id: m for m in movies} ordered_movies = [movie_map[mid] for mid in ranked if mid in movie_map]这一步我几乎每次写推荐项目都会踩,也是最容易在代码评审时被问到的细节。很多源码跑起来“推荐结果不准”,其实不是算法算错,而是展示顺序错了。
5.5 登录后跳转地址写死,导致部署后/recommend/变成/accounts/login/?next=/recommend/循环
现象:用户点击登录,输入正确账号密码后,被重定向到登录页而不是推荐页。
原因:@login_required默认重定向到settings.LOGIN_URL,登录成功后又按next参数跳回原页面。如果你在模板里把登录表单的action写死成/login/,而视图里用的是Django默认的LoginView,提交后会丢失next参数。
解决:模板里登录表单必须写成动态获取next:
<form method="post" action="{% url 'login' %}"> {% csrf_token %} <input type="hidden" name="next" value="{{ next }}"> ... </form>对应地在settings.py里显式配置:
LOGIN_URL = '/accounts/login/' LOGIN_REDIRECT_URL = '/recommend/'LOGIN_REDIRECT_URL是登录成功后无next参数时的默认重定向地址,配上它之后,至少能保证团队新人在没有next的情况下也能正确回到推荐页。这个配置我没少见到处翻车的地方,建议源码里直接写死,别靠Django默认值。
6. 验证推荐效果的一个实用技巧:把推荐列表落成日志,用AB对比说服自己
项目跑通之后,最常被问的问题是:“你怎么证明推荐系统有效?”我有一套成本很低的验证方法:给Django加一个中间件,把每次推荐请求的user_id、返回的movie_ids和耗时写进文本日志。累计几天之后,用脚本分析不同用户的推荐列表重叠度,以及在日志里随机抽查“新用户是否拿到热门片单”。
# movies/middleware.py import time import json from django.utils.deprecation import MiddlewareMixin class RecommendLogMiddleware(MiddlewareMixin): def process_response(self, request, response): if request.path == '/recommend/' and request.user.is_authenticated: movies = response.context_data.get('movies', []) log_entry = { 'user': request.user.username, 'ids': [m.id for m in movies], 'ts': time.time(), } with open('logs/recommend.log', 'a', encoding='utf-8') as f: f.write(json.dumps(log_entry, ensure_ascii=False) + '\n') return response注意这里有个坑:process_response里访问response.context_data在Django 3.2里是可行的,但如果你用的是模板render返回的HttpResponse,context_data只有在响应未渲染前才有值。一旦中间件在响应生成后被调用,context_data可能已经被清空。更稳妥的做法是在视图函数里自己记录日志,而不是在中间件里啃上下文。我把中间件方案写出来只是为了让你理解“埋点”的思路,真要落地,直接写日志到文件也行。
验证指标上,我常用的三个值:一是“推荐列表去重率”,如果不同用户拿到的推荐列表完全一样,说明协同过滤没生效,全靠热门兜底;二是“点击率”,给每部电影加一个/click/<movie_id>/的埋点路由,记录从推荐列表点进详情页的次数,这个能间接反映推荐相关性;三是“平均推荐耗时”,超过300毫秒就说明相似度矩阵该做缓存了。
我最想强调的验证习惯是:不要只用一个测试账号刷页面,至少要模拟新用户、老用户、评分少的用户三档。评分少的用户应该看到热门榜,老用户应该看到与历史评分相似的非热门片,新用户看到的内容应该和热门榜重叠度高但排序不同。这三档验证下来,推荐系统才算真正通了。
这套验证方法做完,你会觉得之前调参的苦都值了。我自己的习惯是:每改一次min_sim_users或相似度公式,就跑一遍对比日志,不靠肉眼看感觉。推荐系统不像前端页面,结果对错不能一眼判断,得靠数据记录和抽样验证。希望这个验证思路能帮你在答辩或简历里多一个“可量化效果”的抓手,而不是只说“我写了个协同过滤”。
本文还有配套的精品资源,点击获取