☰
Python协同过滤+Django+Flask:动漫视频推荐平台实战解析
2026/10/24 4:29:28 网站建设 项目流程

提到“django-flask基于python协同过滤算法的动漫视频在线平台”这个项目,大多数人的第一反应是:一个平台为什么要用两个Web框架?Django和Flask不是二选一的关系吗?这个项目最有意思的地方恰恰在这里——它用一套系统把两件事分开做了。Django管后台与内容面,Flask管推荐与接口面,中间用协同过滤算法把两者串联起来,形成一套完整可跑的动漫视频推荐平台。

这篇文章不打算只讲推荐算法公式的推导,也不打算只讲框架怎么配,而是完整复盘一个真实项目的搭建逻辑:从数据模型设计、协同过滤选型与实现,到Flask推荐服务落地,再到线上效果验证的坑。适合正在做视频类网站、推荐系统,或者只听说过协同过滤想把它真正用起来的开发者参考。里面所有的代码片段都来自我实际调试过的通用方案,你可以直接拿去改。

1. 双框架拆分:同一个平台里 Django 和 Flask 谁在管什么

1.1 管理面交给Django,流量面交给Flask

我先解释一下这个项目的名字逻辑。“django-flask”不是把两个框架揉进同一个进程里,而是让它们各司其职、独立部署。这种架构在大部分中小型推荐类项目里其实非常舒服。

Django负责的是“重”的那部分:用户注册登录、后台管理、视频条目管理、分类标签管理、运营配置。为什么选Django?因为它自带Admin后台、ORM、迁移机制、权限体系,做内容管理系统几乎是开箱即用。在动漫视频平台里,后台要经常上架新番、补充每部作品的标签和海报图、临时调整推荐位的置顶内容,Django的Admin加上几个自定义ListFilter就能把这些运营操作全部覆盖,比Flask手动搭后台省太多事。

Flask负责的是“轻”的那部分:推荐接口、相似动漫接口、用户行为埋点接收接口。它不关心后台长什么样,只关心一件事——怎么把离线算好的推荐结果以最快的速度通过HTTP接口送给前端。Flask路由简洁、依赖少、启动快,特别适合做这种功能单一的内部微服务。

所以两条业务线天然分离:Django团队可以按Django的方式迭代管理功能,Flask服务则可以独立升级推荐逻辑而不牵连整个后台代码。两个服务通过HTTP+JSON通信,Django后台只负责写业务数据,Flask推荐服务只负责读数据、算相似度、吐推荐结果,数据层共用同一个MySQL库。

1.2 这套架构真正解决的问题是什么

如果只用Django,把推荐逻辑写成一个View也完全能跑,但你会被几个问题拖住:第一,Django启动时管理后台会加载一大堆中间件、信号、模板配置,推荐接口每次请求都要穿一遍这层“外套”,用Gunicorn压测时QPS上不去;第二,协同过滤模型每次重算后要重新加载,如果和后台代码耦合在一起,每一次模型更新都要重启整个后台服务,风险很大;第三,如果以后推荐服务想加一个相似动漫的实时热搜池,Flask这边只加一个路由就行,完全不需要碰管理后台的代码。

独立Flask服务在部署上的玩法也更灵活。Django服务用Gunicorn多worker跑在8001端口给运营和后台上传接口用;Flask服务用轻量级Gunicorn跑在8002端口,前面挂Nginx统一转发。两个端口互相不干扰,推荐接口做缓存、限流、分版本A/B测试都只涉及Flask这一层。

我还见过一种更彻底的做法:把推荐服务做成独立的HTTP接口,由Django后台在用户登录后通过服务器端请求去调用。也就是说,前端的“猜你喜欢”请求打给Django,Django再转发给Flask。这个方案的好处是前端只需面对一个域名,坏处是多了跳转延迟。我在项目里直接让前端请求Flask域名下的/recommend/api/v1/home,绕过了Django,因为推荐接口本身就是只读的,没必要再经过后台鉴权层。如果是用户行为上报接口,更要独立出来,防止埋点流量把Django后台的慢查询拖垮。

提示:双框架不是必须的,但一定要把“内容管理面”和“算法流量面”在逻辑上拆开。哪怕你只有一个Django项目,也建议用不同的App、不同的数据库连接池来隔离这两部分。拆得早,后面模型迭代和接口压测都轻松得多。

2. 数据是第一位的:动漫视频里的用户行为怎么建模

2.1 三张核心表:用户、视频条目、行为日志

推荐系统里有句话叫“垃圾进,垃圾出”。协同过滤算法本身不挑数据,但你的数据如果没把“谁”、“看了什么”、“看得怎么样”记录完整,后面所有计算都白搭。我设计的第一版数据结构包含三张表,基本能覆盖动漫视频平台的所有需求。

用户表在Django默认的User模型上扩展出user_id、nickname、signup_time就够,真正重要的是行为表。视频条目表也不复杂,除了基础标题、封面、播放地址之外,一定要留几个关键字段:category_id(分类)、tags(标签,建议用JSON存数组)、publish_date(上线时间)、total_play_count(总播放次数,定期从统计表刷进去)、item_embedding(内容向量,冷启动时会用到)。

用户行为表是推荐引擎的核心,我设计成按天分区的日志表,字段如下:

CREATE TABLE user_behaviors ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, item_id INT NOT NULL, behavior_type VARCHAR(16) NOT NULL, watch_ratio FLOAT DEFAULT 0.0, created_at DATETIME NOT NULL, KEY idx_user_time (user_id, created_at), KEY idx_item_time (item_id, created_at) );

behavior_type这个字段非常关键,我用它区分六种行为:view(播放)、like(点赞)、collect(收藏)、share(分享)、comment(评论)、finish(看到结尾)。不要小看这一张表,它决定了你能用什么算法、能算多复杂。为了避免多条行为记录膨胀,我在Flask推荐服务里做实时点击流,最终每秒写一次批量入库,Django后台则提供一个同步接口把统计结果刷到total_play_count里。

2.2 行为评分不是拍脑袋,而是加权组合

协同过滤算法要求输入一个“用户对物品的评分矩阵”,但网站上几乎没有用户会主动给动漫打分。解决办法是把行为记录转换成0到100之间的隐式评分。我采用的评分公式是这样:

# 通过默认行为加权合成隐评分 def compute_rating(row): rating = 0.0 if row["behavior_type"] == "view": rating += 30.0 * min(row["watch_ratio"], 0.8) # 防滥用:超过80%只记0.8 elif row["behavior_type"] == "finish": rating += 50.0 elif row["behavior_type"] == "like": rating += 45.0 elif row["behavior_type"] == "collect": rating += 60.0 elif row["behavior_type"] == "share": rating += 70.0 return min(rating, 100.0)

为什么这么设计权重?观看行为的权重最低但最容易产生,适合做“广度”;收藏和分享行为的成本更高,说明用户确实喜欢,权重相对高一些。考虑到大部分用户不会主动点赞,所以我给collect和share定到60和70,让重度用户的偏好信号能更明显地影响结果。watch_ratio乘0.8的上限是为了防止有人挂机播放刷数据,这在实际线上几乎是必须处理的。

2.3 真实调度链路和离线宽表

光有行为日志表还不够,我在Django后台新建了一个定时任务,每小时做一次聚合,把一天内的所有行为按“用户-物品”维度合成一张推荐宽表user_item_rating,字段是user_id、item_id、rating、update_time。这张宽表就是离线协同过滤的输入。不要直接拿明细日志跑算法,因为日志量大,重复计算的时间和资源都不可控。先聚合出宽表,再在下一次推荐模型重算前做快照,是我跑下来最稳的路子。

# Django管理命令里的聚合逻辑: python manage.py build_rating_matrix from django.core.management.base import BaseCommand from recommend.models import UserBehavior, UserItemRating from django.db.models import Sum class Command(BaseCommand): def handle(self, *args, **options): # 按最近30天窗口聚合,窗口可配置 aggregated = ( UserBehavior.objects .filter(created_at__gte="2024-01-01") .values("user_id", "item_id") .annotate(total_rating=Sum("score")) ) UserItemRating.objects.all().delete() objs = [ UserItemRating(user_id=row["user_id"], item_id=row["item_id"], rating=row["total_rating"]) for row in aggregated ] UserItemRating.objects.bulk_create(objs, batch_size=2000)

这个环节有个容易踩的坑:直接把明细数据传给算法会导致内存占用巨大。我在实际项目中用了近三个月的行为数据,明细表有几百万行,但聚合后的宽表只有几十万行,内存压力降了一个数量级。

3. 为什么是协同过滤,以及这边用哪个变种

3.1 UserCF与ItemCF的取舍

协同过滤大体分成两种:基于用户的协同过滤(UserCF)和基于物品的协同过滤(ItemCF)。UserCF的思路是“和你口味相似的人看过什么,就给你推荐什么”;ItemCF的思路是“和你喜欢的物品像的物品,就推荐给你”。

在动漫视频平台里,我的经验是ItemCF的推荐效果和工程可维护性都更好。原因是:动漫内容本身是重口味偏好的,用户群体可能出现很明显的圈层分化,看热血番的人和看恋爱番的人重叠度很低。UserCF要求用户之间有足够多的共同行为才能算出相似性,随着用户量增长,用户相似矩阵是O(N^2)规模的,线上维护成本很高。ItemCF计算的是物品间的相似度,物品数量比用户数量小得多,而且动漫内容相对稳定,一个季度上线的番剧数量有限,每周维护物品相似矩阵的开销很小。

更实际的一点是,ItemCF给出的推荐结果解释性很强:“因为你喜欢《某战斗番》,所以推荐《某同类番》”。这种逻辑直接展示在界面上,用户更容易接受。很多人第一次写协同过滤一上来就写UserCF,跑完看到结果却解释不清楚原理,最终都不如ItemCF落地顺畅。

3.2 从用户行为矩阵到相似度矩阵的计算

我把离线训练流程拆成三个步骤:读宽表、算物品相似度矩阵、生成Top-N推荐。

第一步,把宽表变成稀疏矩阵。用pivot_table很直观,但真实数据里有几万个用户和几千个物品,直接转成稠密矩阵会有大量NaN。建议用pandas的pivot_table矩阵然后填充0,或者直接用scipy.sparse.csr_matrix来建矩阵。第二步,对物品两两计算余弦相似度。第三步,对用户已经看过的物品,找出相似度最高的若干物品,按加权得分排序输出。

# 简化版物品协同过滤核心代码 import numpy as np import pandas as pd from sklearn.metrics.pairwise import cosine_similarity # df_rating: DataFrame, 列包括 [user_id, item_id, rating] def build_item_similarity(df_rating): pivot = df_rating.pivot_table(index="user_id", columns="item_id", values="rating").fillna(0) # item_sim形状: (n_items, n_items) item_sim = cosine_similarity(pivot.T, dense_output=False) item_ids = pivot.columns.tolist() sim_df = pd.DataFrame(item_sim, index=item_ids, columns=item_ids) return sim_df def recommend_for_user(user_id, seen_items, sim_df, df_rating, top_n=20): scores = {} for item in seen_items: if item not in sim_df.index: continue sim_items = sim_df[item].sort_values(ascending=False)[1: 51] # 每部只取最相似的50部 for sim_item, sim_score in sim_items.items(): if sim_item in seen_items: continue scores[sim_item] = scores.get(sim_item, 0.0) + sim_score ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n] return [item_id for item_id, _ in ranked]

这段代码看起来逻辑很清晰,但直接上线会有一个问题:评分矩阵里填0的全是“缺失值”而不是“真实评分0”。比如用户从没看过的动漫会被当成负样本处理,这会严重污染相似度计算。我实验后的修正方案是:只在两个物品被至少一个共同用户评分过时才计算它们的相似度,否则直接置为0。使用cosine_similarity和csr_matrix的朋友要注意这里,在实际项目里我倾向于保留稀疏矩阵本身,而不是把缺失位置变成0。

不过这个简化版本做原型验证、跑通流程已经足够。实际项目中我会用scipy.sparse代替稠密矩阵,使用sklearn的工具也需要留意稀疏矩阵的兼容性。

3.3 矩阵分解SVD作为兜底方案

纯粹基于共现物品的ItemCF遭遇数据稀疏时,很多用户只对一两部动漫有行为,共现矩阵太稀薄,推荐结果会退化。我后来又接了一套基于隐语义的SVD方案,思路是把用户-物品评分矩阵分解成用户隐向量矩阵和物品隐向量矩阵,维度一般取80到120。SVD的优点是能挖掘用户和物品之间隐藏的关联,哪怕没有直接共现,也能通过共享的隐因子关联起来。

在Python里实现SVD最省事的方式是用surprise库,它内置了SVD模型,原生支持训练集切分和RMSE评估:

from surprise import SVD, Dataset, Reader, accuracy from surprise.model_selection import train_test_split reader = Reader(line_format="user item rating", rating_scale=(0, 100)) data = Dataset.load_from_df( df_rating[["user_id", "item_id", "rating"]].astype({ "user_id": int, "item_id": int }), reader ) trainset, testset = train_test_split(data, test_size=0.2) algo = SVD(n_factors=80, n_epochs=20, lr_all=0.005, reg_all=0.02) algo.fit(trainset) predictions = algo.test(testset) rmse = accuracy.rmse(predictions, verbose=False)

SVD算出的预测分可以直接作为推荐分数,也可以用于第二阶段的冷启动补充:当ItemCF因为相似物品太少找不出结果时,就用SVD的预测Top-N去填补。

实际项目通常是两套并行跑,然后做融合排序:ItemCF候选集作为主体,SVD预测分作为一个特征参与排序。最容易翻车的点是SVD对超参数比较敏感,n_factors太大容易过拟合,太小则丢失语义,我在80-120之间测试下来效果最稳。

4. 算法跑通只是开始:冷启动、稀疏矩阵与热门偏差

4.1 冷启动:没有行为的新视频怎么推荐

新上线的动漫在一周内几乎没有任何用户行为,ItemCF和SVD都拿它没办法。解决冷启动不能靠协同过滤本身,我采用的方案是“基于内容的相似补充”。在新番发布时引入两个字段:类别、标签。比如一部新番标签为“战斗、热血、漫画改编”,就把它和已有这些标签且历史上表现较好的动漫建立关联,直接放进推荐候选集里。

这部分逻辑在Django后台比较容易实现:发布新番时表单里填写标签,保存时自动计算一次“内容相似TopN”,把相似结果写进content_similar表。Flask推荐接口发现协同过滤的候选集对某个用户不足20条时,马上从内容相似表里拿项来补充。

冷启动里还有一个被很多人忽略的小细节:新番本身自带热度权重。我会在推荐排序时给新上线的物品加一个hot_bonus,让它们有机会被用户发现,这个值随时间衰减,一周后归零。否则协同过滤推荐永远是老番的天下,新内容几乎没有曝光机会。

4.2 稀疏矩阵:评分矩阵里全是空洞

动漫视频平台里,绝大多数用户只看过不到5部作品。这些用户共同评分过的作品交集非常小,物品相似度计算出来往往是个位数。面对这样的矩阵,硬算全院路线的余弦相似度就会遇到“共有0个共同评分”的尴尬。

我采用的策略是“门槛过滤”:构建相似度矩阵时,只保留那些至少有3个共同评分用户的物品对。如果条件太严格,可以降到2个,但不要降到1个。只有1个共同用户时,相似度波动极大,体验很飘。对评分很少的用户,比如少于3条行为的用户,直接不要进入个性化推荐流程,改用热门池和内容相似推荐。这样既保证了个性化推荐的稳定性,也节约了推荐服务的计算资源。

4.3 热门偏差:推荐越来越像排行榜

这是所有推荐系统都会遇到的问题。ItemCF天然倾向于把高热度物品和其他物品的相似度都算得很高,导致推荐结果慢慢变成“大家都在看的榜单”,失去了个性化。我在实验里发现,如果不做任何处理,在首页“猜你喜欢”里排第1名的推荐,和用户历史喜欢的物品相似度很高,但也很可能是近期全网播放量第一的番。

处理手段有三种:热度稀释、相似度归一化、以及推荐结果多样性约束。

热度稀释的做法比较直接:在排序阶段,对每一个候选物品的最终得分乘以一个惩罚系数。punish_score = score / (1 + alpha * log(1 + total_play_count)),alpha取0.1到0.3之间。播放量越高,得分被压低得越多。归一化则是把每行相似度除以行内最大值,让相似度分量在0-1之间,避免高分物品在某些物品上贡献过大。多样性约束更简单:同一个系列或同一个标签的动漫在Top-N里最多出现2部,确保推荐结果不局限于单一类型。

实际线上我测得比较有效的是“打败30%的热门”,也就是在排序前先把每部候选动漫的播放量分位数算出来,把播放量打分前30%的候选稍微降权。

5. 把推荐服务做成Flask接口的工程细节

5.1 模型文件存哪里、什么时候加载

离线阶段算完的物品相似度矩阵、SVD模型参数,不能全部放在内存里算完就丢。我在Django端用一个管理命令把推荐模型文件导出为二进制格式,例如item_sim.npz、svd_model.pkl,Flask服务启动时用模块级全局变量一次性加载到内存。第一次启动加载几十兆的矩阵可能需要几秒,但加载之后每个请求都在内存里直接查表,性能很好。

保存相似度矩阵时我一般只保留每个物品最相似的50个邻居,其他直接扔掉。一个5000物品的矩阵完整存下来可能几十MB,只存Top50邻居之后文件能压缩到几MB,加载速度也快得多。

# 保存相似度Top50邻居 def save_sim_topk(sim_df, topk=50): topk_dict = {} for item_id in sim_df.index: row = sim_df.loc[item_id].sort_values(ascending=False) row = row.drop(index=item_id) # 去掉自己和自己的相似度 topk_dict[item_id] = row.iloc[:topk].to_dict() return topk_dict

5.2 推荐接口设计与缓存策略

Flask推荐接口不要做太重的事情。我的接口设计是:

  • GET /recommend/api/v1/home?user_id=123:首页个性化推荐,返回20条动漫
  • GET /recommend/api/v1/similar?item_id=456:视频详情页的“相似番剧推荐”
  • POST /recommend/api/v1/behavior:接收行为上报埋点

首页推荐是重灾区。用户每次刷新都完整跑一遍候选加打分会很浪费,我加了两层缓存。第一层是Redis缓存,同一个user_id的推荐结果缓存5到10分钟,命中就直接返回;第二层是Flask进程内缓存,用LRU字典缓存最近1000个用户的推荐结果,TTL 60秒。

from flask import Flask, jsonify, request from functools import lru_cache from redis import Redis app = Flask(__name__) cache = Redis.from_url("redis://localhost:6379/0") @lru_cache(maxsize=1000) def get_top_n_cached(user_id, top_n): # 这里从内存中的相似矩阵和用户评分里算推荐 return compute_recommendations(user_id, top_n) @app.route("/recommend/api/v1/home") def recommend_home(): user_id = request.args.get("user_id", type=int) if not user_id: return jsonify({"error": "missing user_id"}), 400 cache_key = f"rec:home:{user_id}:v1" cached = cache.get(cache_key) if cached: return jsonify({"recommendations": cached.split(",")}), 200 items = get_top_n_cached(user_id, 20) cache.set(cache_key, ",".join(map(str, items)), ex=600) return jsonify({"recommendations": items}), 200

这里有个挺容易忽略的点:多worker进程下,每个Flask进程都会各自加载一份模型字典,Redis缓存是共享的,但进程内缓存各算各的。如果只依赖进程内缓存,后端的压力不会减太多。所以我把Redis当成唯一主缓存,进程内LRU只是用来挡住同一秒内的重复请求。还有一点就是必须给缓存键加版本号,例如v1,模型重算之后可以平滑切新版本,不用等待旧缓存过期。

5.3 离线训练与在线推荐的节奏

推荐模型不需要实时更新,也不能一个月不更新。我定的节奏是这样的:午夜1点用Django管理命令拉取最近30天行为日志,聚合出宽表,跑ItemCF和SVD,生成Top50邻居矩阵和隐向量,导出文件。凌晨3点Flask服务平滑重载模型文件。重载策略是读文件生成新字典,然后通过全局变量一次性替换旧引用,而不是重启进程,这样可以避免服务中断。

说起来容易,第一次实现时还是踩了坑。我直接用的pickle序列化模型,然后想在Flask运行中覆盖变量,结果多个worker进程的旧变量还留在老进程里。后来我换成了文件监听机制:Flask进程监控一个model_version.json,发现版本号变化就重新加载新文件。这种方式我用下来最省心。

6. 上线之后的验证路径:从点击率到完播率

6.1 第一版评估指标踩过的坑

在实验室里,我拿历史数据做切分,算出SVD的RMSE只有20左右,ItemCF的召回率也不错。实际上线后,首页推荐位的点击率并没有比纯热门榜单好看多少。问题出在“评估指标和业务指标对不上”。

协同过滤的离线指标主要衡量“预测分数准不准”,但用户是不是真的喜欢推荐内容,更适合看点击率、完播率、收藏转化率。一次点击只能说明封面吸引了用户,不一定说明内容匹配。所以我在Flask推荐接口里做了埋点,记录每次曝光、点击、播放完成三个事件,统计三大指标:

指标计算方式业务含义
点击率推荐位被点击次数 / 曝光次数推荐结果对用户的吸引力
完播率看到结尾的播放次数 / 总播放次数内容质量和匹配度
收藏率被收藏次数 / 点击次数用户对推荐结果的认可程度

第一版推荐上线后点击率有提升,但完播率低迷。后来测试发现,很多高相似度的作品属于同一系列或类似画风,用户点进去发现剧情不对胃口。后来我在排序阶段把“作品更偏大众向还是偏小众向”这个属性加了进去,通过调低小众作品的权重,完播率才慢慢升上来。

6.2 A/B测试与调整方向

做推荐系统一定要有A/B实验习惯。我用了一个很简单的分流方案:按user_id模100分流,比如尾号0到49的流量走新版推荐逻辑,尾号50到99保持旧版。观察周期至少一周,比较点击率和完播率,再判断新版是否投入使用。

我调整最多的参数是三个地方:候选取样范围、相似度TopN数量、惩罚系数alpha。候选取样范围决定了每次推荐在多少部动漫里选,太窄会让结果单一,太宽会增加噪声;TopN邻居的数量决定了推荐的多样性,50到80之间的邻居数表现得比较平衡;alpha则需要按用户活跃度分开调。

整个项目从零到上线跑通,框架层面没有特别高深的地方,真正的复杂度都集中在数据构造和推荐结果质量的控制上。如果你也想复刻这个项目,我建议第一步先把Django后台的用户行为和视频条目数据模型建好,第二步用Jupyter把相似矩阵的可视化算出来,确认推荐结果看起来合理,再考虑上Flask接口。模型和框架都是工具,数据要是乱了,再好的算法也救不回来。

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

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

立即咨询