ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django电影推荐系统实战:ItemCF协同过滤与ORM性能优化

Django电影推荐系统实战:ItemCF协同过滤与ORM性能优化 简介一套基于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_length200, verbose_name电影名) genres models.CharField(max_length255, blankTrue, verbose_name类型) release_year models.IntegerField(nullTrue, blankTrue) rating models.FloatField(default0.0, verbose_name豆瓣均分) # 用于热度兜底 rating_count models.IntegerField(default0, verbose_name评分人数) class Meta: db_table movie class Rating(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_nameratings) movie models.ForeignKey(Movie, on_deletemodels.CASCADE, related_nameratings) score models.IntegerField(choices[(i, i) for i in range(1, 6)], verbose_name用户评分) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table rating unique_together (user, movie) # 一个用户对一部电影只能有一条评分这里的逻辑说明很简单unique_together约束能保证评分数据不重复是ItemCF计算相似度矩阵时的必要前提rating_count和rating两个字段纯粹做热门电影兜底——新用户没有历史行为时推荐系统最怕冷启动这时用“豆瓣均分1人数多”的电影顶上去比硬算相似度靠谱。参数上要留意一个隐藏点on_deletemodels.CASCADE从Django 2.0开始强制要求写。我看到很多老教程写models.CASCADE不带on_delete在Django 3.2上直接报TypeError。如果你下载的源码跑不起来先检查models里所有外键是不是都带了on_delete。2.2 推荐服务单独抽一层视图里只做参数解析算法逻辑全放serviceDjango新手最常见的毛病是把推荐逻辑直接堆在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_users2): 基于物品的协同过滤先构建电影-用户倒排索引 再计算电影之间共同评分用户数除以分母得余弦相似度。 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[i1:]: 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_n10): # 取用户评分过的电影 rated list(Rating.objects.filter(useruser).values_list(movie_id, flatTrue)) 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(), keylambda x: x[1], reverseTrue)] return list(Movie.objects.filter(id__inranked[:top_n]))这段代码里有个需要重点说的参数min_sim_users2。它的意思是“两部电影至少要有2个共同评分用户才计算相似度”。为什么是2不是1因为只算1个共同用户时相似度很容易被单条异常评分带偏出现“看过《教父》和《小时代》就推荐《小时代》”的荒谬结果。数据集大的时候可以把这个参数调到35过滤掉稀疏噪声但对于几千条评分的课设数据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_n12) return render(request, movies/recommend_list.html, { movies: movies, page_title: 为你推荐 }) def movie_similar(request, movie_id): movie get_object_or_404(Movie, pkmovie_id) sim_matrix build_item_similarity() similar_ids [] for mid, sim in sorted(sim_matrix.get(movie_id, {}).items(), keylambda x: x[1], reverseTrue)[:10]: similar_ids.append(mid) similar_movies list(Movie.objects.filter(id__insimilar_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, namerecommend_home), path(movie/int:movie_id/similar/, views.movie_similar, namemovie_similar), ]path(movie/int:movie_id/similar/)里的int:movie_id是Django的路径转换器它自动把URL里的数字部分转成int类型传到视图函数。如果你写成movie_id不带int:传进来的就是字符串get_object_or_404(Movie, pkmovie_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, typestr, defaultdatasets/movies.csv) parser.add_argument(--ratings, typestr, defaultdatasets/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, encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: Movie.objects.create( titlerow[title], genresrow.get(genres, ), release_yearint(row[release_year]) if row.get(release_year) else None ) # 评分导入需要先准备一个测试用户 demo_user, _ User.objects.get_or_create(usernamedemo) with open(ratings_path, encodingutf-8-sig) as f: reader csv.DictReader(f) bulk_list [] for row in reader: bulk_list.append(Rating( user_iddemo_user.id if row[user] demo else int(row[user]), movie_idint(row[movie]), scoreint(row[score]) )) Rating.objects.bulk_create(bulk_list, batch_size500)参数的讲究在于batch_size500。bulk_create是Django批量插入的高效手段但一次塞几万条数据会导致SQL语句过长在MySQL上会碰到max_allowed_packet限制。500条一批是稳妥值既不会触发SQL长度限制也不会因为批次太碎损失太多性能。另一个要点是encodingutf-8-sig。很多从网上下载的CSV文件带有BOM头Excel导出常见你用普通utf-8读第一行的列名会变成\ufefftitlerow[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(cntCount(id))然后把统计结果写回Movie.rating_count。这样冷启动推荐至少是按真实用户评分次数排序的不会出现“一部没人评过的电影排第一”的怪异现象。这引出一个更重要的认知推荐系统的效果上限由评分数据的稀疏度决定而不是由算法复杂度决定。Django里的ItemCF在10万用户、几百部电影的数据集上计算相似度矩阵可能要几十秒但在课设里只有几百条评分时你不需要优化性能反而要引入更多模拟数据来验证逻辑正确。我一般在ratings.csv里提前造400600条评分记录覆盖至少3个用户这样才能演示出“不同用户看到不同推荐结果”的效果。3.3 相似度计算时绕不开的三个细节倒排索引、共同用户数、归一化分母很多从GitHub下载的Django推荐系统源码协同过滤写得特别简陋比如直接用双层循环遍历所有用户评分算每对电影的Jaccard相似度。这种嵌套循环在电影数量超过1000之后会明显卡顿。我更推荐先构建倒排索引再算相似度也就是前面recommend.py里movie_users字典的写法。这个倒排索引的本质是从{用户: [电影列表]}翻转为{电影: {用户集合}}。好处是计算“共同评分用户”时不需要扫描全部评分记录只需要对两个用户集合做交集。Python的set交集操作是C语言实现的比纯Python的for循环遍历快很多。第二个细节是相似度的分母。我没用Jaccard系数共同用户数 / 并集用户数而是用余弦相似度的一种变体共同用户数 / (sqrt(A用户数) * sqrt(B用户数))。两者都能用但余弦公式对“热门电影”的惩罚更温和。比如《肖申克的救赎》有100个人评分《低俗小说》有80个人评分它们共同用户数60Jaccard给的是60/(10080-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__gte100).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_countCount(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_activeFalse)然后再用objects.filter(is_activeTrue)过滤推荐结果。这个方案在Django里要用自定义QuerySet覆盖默认管理器比直接delete()复杂一个层级但对“保留用户历史评分、避免unique冲突”的场景很有用。我的建议是课设项目直接硬删除工业级项目再上软删除。4.3 执行原始SQL查询什么情况下你需要放弃ORMDjango ORM覆盖了95%的场景但推荐系统里有一个场景我建议直接写原生SQL计算“用户A评过的电影里哪些电影的相似电影最多”。用ORM写需要多次查询拼接不如一条SQL搞定from django.db import connection def get_similar_candidates(user_id, limit20): 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 django3.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_conflictsTrueRating.objects.bulk_create(bulk_list, batch_size500, ignore_conflictsTrue)ignore_conflictsTrue会让数据库跳过违反唯一约束的行而不是终止整个事务。代价是这条SQL不再返回受影响行数所以如果你需要精确知道插入了多少条就别用这个参数而是在入库前用Rating.objects.filter(user_id__inuser_ids, movie_id__inmovie_ids).values_list(...)先查出已存在的记录手动剔除。5.4 前端显示推荐结果乱序Django查询集被二次切片打乱现象推荐页每次刷新电影顺序忽前忽后看起来像列表随机化。原因视图里用Movie.objects.filter(id__inranked)取推荐电影但id__in查询不保证返回顺序与ranked列表一致。数据库返回时按主键排序或按索引扫描顺序输出完全不 care 你传入列表的排列。所以你辛辛苦苦算好的相似度排序在前端展示时全被打乱。解决把查询集转成字典{id: movie}后按ranked顺序拼新列表movies Movie.objects.filter(id__inranked) 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参数。解决模板里登录表单必须写成动态获取nextform methodpost action{% url login %} {% csrf_token %} input typehidden namenext 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, encodingutf-8) as f: f.write(json.dumps(log_entry, ensure_asciiFalse) \n) return response注意这里有个坑process_response里访问response.context_data在Django 3.2里是可行的但如果你用的是模板render返回的HttpResponsecontext_data只有在响应未渲染前才有值。一旦中间件在响应生成后被调用context_data可能已经被清空。更稳妥的做法是在视图函数里自己记录日志而不是在中间件里啃上下文。我把中间件方案写出来只是为了让你理解“埋点”的思路真要落地直接写日志到文件也行。验证指标上我常用的三个值一是“推荐列表去重率”如果不同用户拿到的推荐列表完全一样说明协同过滤没生效全靠热门兜底二是“点击率”给每部电影加一个/click/movie_id/的埋点路由记录从推荐列表点进详情页的次数这个能间接反映推荐相关性三是“平均推荐耗时”超过300毫秒就说明相似度矩阵该做缓存了。我最想强调的验证习惯是不要只用一个测试账号刷页面至少要模拟新用户、老用户、评分少的用户三档。评分少的用户应该看到热门榜老用户应该看到与历史评分相似的非热门片新用户看到的内容应该和热门榜重叠度高但排序不同。这三档验证下来推荐系统才算真正通了。这套验证方法做完你会觉得之前调参的苦都值了。我自己的习惯是每改一次min_sim_users或相似度公式就跑一遍对比日志不靠肉眼看感觉。推荐系统不像前端页面结果对错不能一眼判断得靠数据记录和抽样验证。希望这个验证思路能帮你在答辩或简历里多一个“可量化效果”的抓手而不是只说“我写了个协同过滤”。本文还有配套的精品资源点击获取
返回列表