做推荐系统的同学最容易遇到的情况是:模型讲得头头是道,数据却拿不出来;或者辛苦爬下来的电影数据,清洗完能用的不到六成。这个项目之所以让我觉得值得复盘,不是因为它用了多前沿的算法,而是它把“爬虫采集 → 数据清洗 → 个性化推荐 → 可视化大屏”这条路完整走通了一遍,最终能在一个页面上清楚看到:系统推荐了什么、为什么这样推荐、数据源头长什么样。
如果你正准备做电影推荐相关的毕业设计、个人作品集,或者只是想给公众号、内容站加一个“猜你喜欢”模块,这篇文章会偏实用一些。我会直接把架构如何拆、爬虫字段怎么定、相似度矩阵为什么放 Redis、看板接口怎么切这些关键决策讲清楚,也会把一段时间里踩到的坑一并列出来。
1. 把“爬虫、推荐、看板”串成一条可落地的数据闭环
1.1 这不是一个单页大屏,而是一条闭合数据链
很多人拿到“电影推荐可视化系统”这个题目,第一反应就是先找数据集,然后套一个协同过滤,再画几个图表。这样做的结果往往是:数据和自己的业务对不上,推荐结果解释不清楚,看板也只是把表格换成了折线图。
我对这个项目的理解是,它要做的事情不只是“推荐算法”,而是让数据从采集、存储、运算到展示形成一条可追溯的链路。用户在看板上看到的“热门导演 Top10”“科幻类评分分布”“你的偏好画像”,背后应该能从原始数据一路溯源到算法结果。推荐系统里有一个很基础的应用叫“可解释性”,在真实项目里,它不只靠模型输出字段,更多靠可视化的方式让用户看见推荐依据。
这个项目的完整数据流大概是这样的:爬虫定时从目标站点采集电影基本信息和用户评分数据,清洗后写入 MySQL;推荐服务根据行为数据离线计算电影之间的相似度矩阵,并把结果缓存到 Redis;前端可视化看板通过 Flask 提供的 JSON 接口读取数据,在 ECharts 上完成电影推荐列表、评分走势和用户画像的展示。
整套系统中,最核心的并不是某个单独的 Python 库,而是模块之间的“数据约定”。字段格式统一,模型跑得顺畅;接口结构清晰,看板就出得快。所以第一步,不是急着写爬虫,而是先定表结构。
1.2 技术选型怎么定,后续能少很多麻烦
这里我用的主体框架是 Python 3.9 + Flask 2.x + MySQL 8.0 + Redis 6.x,前端是原生 HTML + ECharts 5。不用 Vue 或 React,是因为项目本身的重点在数据处理链路,前端只需要承载看板和几个交互即可。如果你之前没搭过 Python 环境,建议直接去官网下载对应操作系统的安装包,安装时勾选“Add Python to PATH”,避免后面命令行敲不出 python。
爬虫库我用的是 Requests + BeautifulSoup 4,没有上 Scrapy。原因很简单:这个项目的数据量级,单体脚本完全扛得住,Scrapy 的项目结构反而会把简单问题复杂化。如果目标站点数据量超过几十万条,再迁移到 Scrapy 也不迟。解析电影页面时,BS4 不如 lxml 快,但胜在写起来直观,且数据格式有替换成本,对排错更友好。
关键依赖如下:
| 依赖 | 版本建议 | 用途 |
|---|---|---|
| requests | 2.31.x | 采集接口和页面 |
| beautifulsoup4 | 4.12.x | HTML 结构解析 |
| lxml | 4.9.x | 作为 bs4 后端解析器 |
| pandas | 2.0.x | 数据清洗和聚合 |
| Flask | 2.3.x | 提供推荐和可视化接口 |
| flask-cors | 4.0.x | 解决本地调试跨域 |
| scikit-surprise | 1.1.x | 可选的 SVD 基准对比 |
| redis | 5.0.x | 缓存推荐矩阵和热点数据 |
数据库我用 MySQL 而不是 SQLite,是因为推荐系统在计算时要做大量 JOIN 和索引查找,SQLite 在并发读取上会比较吃力。Redis 则承担了两类职责:一是保存计算后的电影相似度矩阵,把原本需要 200ms 的实时计算缩短到 20ms 以内;二是保存可视化看板需要的聚合结果,避免首页每次刷新都去跑一次 GROUP BY。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 爬虫采集层必须考虑的细节:字段质量、中断恢复、推荐算法口径
2.1 页面字段容易拿,脏数据也容易糊一脸:入库前就定好唯一键
爬虫开始前,我先设计了一套靠“唯一键”防重录的数据库表,这才是采集层真正值得花功夫的地方。
模拟常见的电影站数据,新增了一张 movie_info 表:
id主键,自增source_code数据源内容唯一键,比如“平台编码 + 电影页面对应ID”movie_title电影名pub_year上映年份duration_min片长,单位分钟director、actors、genre导演、主演、类型avg_score、vote_count评分和评分人数poster_url、source_url封面与详情链接created_at、updated_at插入与更新时间
这里最重要的一点是:不要用电影名作为唯一索引。同名电影太多了,条目容易出错。我选用外部平台 URL 或平台内容 ID 做唯一键,清洗时更新,插入时 INSERT ... ON DUPLICATE KEY UPDATE,这样爬虫中断后再启动,不会产生重复记录。
用户行为表 user_behavior 单独存放交互数据,包括 user_id、movie_id、rating、create_time。系统里如果缺少真实用户行为,可以用公开的数据集模拟。这里提醒一下,很多人在可视化时忽略一个问题:推荐系统里的 user_id,必须和展示页的 user_id 一一对应,否则看板上出现“你的偏好”就是假象。
2.2 稳定采集的上限,往往不是反爬,而是自己没写容错
很多人写爬虫只盯着页面解析成功那一条路径,忽略超时、字段缺失、页面改版这三种情况。这个项目我最初也犯过同样的错:爬了 800 条后程序突然抛 KeyError,直接中断,前面全部白跑。
后来我把核心请求封装成带重试的版本,代码结构类似这样:
python复制import time
import requests
from requests.adapters import HTTPAdapter
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=2))
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Accept": "text/html,application/xhtml+xml",
}
def safe_fetch(url, max_retry=3):
for attempt in range(max_retry):
try:
resp = session.get(url, headers=HEADERS, timeout=8)
if resp.status_code == 200:
return resp
elif resp.status_code in (403, 429):
time.sleep(2 * (attempt + 1))
except requests.RequestException as exc:
print(f"[retry] {url} {exc}")
time.sleep(1)
return None
如果 safe_fetch 返回 None,对应的详情页就不记录,而不是让整个程序崩溃。爬虫记录日志也非常有意义:每次抓完,我会把一个 JSON 文件写成 {"page": 10, "ok": 200, "skip": 3},至少保证重启后不用人工去核对数据。
同一个站点内部,不要把“并发 10”设得太高。最简单的项目,其实用一个单线程循环加 time.sleep(1.2) 的限速,就能拿到完整数据。数据抓取完停个几秒,不仅对目标站点友好,也方便后续排查是哪条记录出了问题。
2.3 “process finished with exit code 0 但没输出”的排查方法
不少人会遇到 PyCharm 里运行爬虫,日志区域没有任何 print 输出,只显示一行“Process finished with exit code 0”。这里先说明:exit code 0 只是“进程正常退出”,并不等于“爬虫执行成功”。
常规检查顺序,我建议按下面三步来:
- 确认脚本入口处有没有立即执行 print。有时是因为代码被写在函数里,只有定义没有调用,自然不会有输出。
- 如果脚本使用
print()输出了大量内容,且进程很快就结束,检查代码里是否有异常的 continue 或 return,导致实际什么都没打印就退出。 - 在 PyCharm 的 Run 配置里确认 Python 解释器路径正确,不要选成空环境。Windows 下输出缓冲也可能让少量日志看不到,可以在 print 中加
flush=True:
python复制print("开始抓取第1页", flush=True)
只要入口有明确日志,接下来的过程就通过可观察性来判断了。这个排查方法,放到所有爬虫采集项目里都适用。
3. 推荐引擎:ItemCF 相似度为什么够用,以及 Redis 缓存怎么设计
3.1 我没有优先上深度学习的原因
在项目初期阶段,很多同学肯定会纠结:要不要用 Word2Vec、Graph Embedding、甚至大模型来算电影相似度?我的建议是:先做一版能解释、能落地的传统协同过滤,再做增强。
电影推荐场景里,用户的显式行为以“评分”为主,隐式行为是“浏览时长”“点击次数”。如果我只拿到评分数据,用户—电影矩阵会非常稀疏。这时候 SVD 和矩阵分解能起到压缩作用,但它需要调参、需要评测,也需要解释成本。
系统第一版选择了基于物品的协同过滤(ItemCF)。核心逻辑是:如果一批用户同时喜欢电影 A 和电影 B,那 A 和 B 就有相似度;用户看过 A 后,就可以把 B 推荐给他。这个算法在电影视频领域效果稳定,而且结果很容易解释:因为“和你喜欢《流浪地球》的人,还喜欢《星际穿越》”,看板可以直接展示这种解释性文字。
评分数据用统一口径处理:如果没有评分,把“用户观看某部电影”记为 1,作为隐式行为;如果存在 1-10 分评分,则用 rating / 10 归一化作为权重。
3.2 相似度计算时如何避免笛卡尔积爆炸
常见的 ItemCF 相似度可以用余弦公式表示:
text复制w(i, j) = 同时交互过 i 和 j 的用户数 / sqrt(交互过 i 的用户数 * 交互过 j 的用户数)
但这个公式背后的笛卡尔积问题相当明显:电影数量几万部时,两两组合是亿级。真正工程里,不会先算所有全量组合,而会先用“倒排索引”思路,从用户交互记录出发,只统计共同出现过的电影对。
简化版思路如下:
python复制from collections import defaultdict
import math
def calc_item_similarity(user_movies):
movie_users = defaultdict(set)
for user, movies in user_movies.items():
for movie_id in movies:
movie_users[movie_id].add(user)
co_count = defaultdict(int)
for user, movies in user_movies.items():
movie_list = sorted(set(movies))
for i in range(len(movie_list)):
for j in range(i + 1, len(movie_list)):
a, b = movie_list[i], movie_list[j]
co_count[(a, b)] += 1
co_count[(b, a)] += 1
similarity = {}
for pair, co_num in co_count.items():
a, b = pair
denom = math.sqrt(len(movie_users[a]) * len(movie_users[b]))
if denom:
similarity[pair] = co_num / denom
return similarity
初版代码写成这样没问题,在数据量上来后需要做三步剪枝:
- 只保留行为数在 3 到 200 之间的用户,避免个别重度用户扭曲统计结果。
- 单个用户内部,只要电影数量超过 50,就按时间取最近 50 部,防止共现数被高频用户放大。
- 计算完后过滤相似度低于 0.1 的项,能大幅减小存入 Redis 的体积。
这样处理后,存下来的矩阵不是“全量N×N”,而是“有价值的稀疏相似对”,推荐服务在线上做 TopN 召回时才不会被拖慢。
3.3 为什么相似度矩阵要放 Redis,而不是查 MySQL
如果每次推荐请求都动态遍历相似矩阵,用户点击一次页面,后端可能需要扫描几百万个键值对,响应时间很难看。改进是把离线算好的相似度写进 Redis。
我通常用 Hash 结构,key 是 itemcf:sim:{movie_id},field 是相似电影 ID,value 是相似度分数。
python复制import redis
r = redis.Redis(host="127.0.0.1", port=6379, db=1, decode_responses=True)
def write_sim_to_redis(similarity):
pipe = r.pipeline()
cache_valid_num = 0
for (movie_a, movie_b), score in similarity.items():
if score < 0.1:
continue
pipe.hset(f"itemcf:sim:{movie_a}", movie_b, round(score, 4))
cache_valid_num += 1
if cache_valid_num % 2000 == 0:
pipe.execute()
pipe.execute()
推荐时,先取用户最近看过的 N 部电影,再合并它们对应的 Redis Hash,按相似度总分排序。这里有个小经验:不要直接用相似度总和,因为看过的老片会干扰结果。更好的做法是给用户行为权重加时间衰减,公式类似 weight = 1 / (1 + log(days_ago + 1))。这样用户三个月前看的电影权重更低,推荐更能反映最近偏好。
冷启动用户没有历史记录时,ItemCF 无法工作,此时直接把“高分 + 热门 + 类型均衡”的电影作为默认推荐,并在看板注释里标明“根据当前热度为你推荐”,而不是硬算。
4. 可视化看板的模块拆分:不止是能看,还要能用来解释推荐原因
4.1 图表不是越多越好,要把问题拆成四个层次
做可视化,最容易掉进“什么图都放上去”的误区。我的取舍思路是:看板需要回答四类问题,每个问题只配一个主图和两个辅助提示。
第一类问题:当前用户是谁?我用雷达图展示用户的类型偏好分布,五个维度取喜剧、动作、科幻、爱情、悬疑。为了避免用户只有几次点击导致雷达图过于极端,会加一个默认基准:用全局数据平均占比做平滑,这步叫拉普拉斯平滑的通俗版。
第二类问题:系统推荐了哪几部电影?这里用卡片列表更合适,图表反而难承载。一个卡片包含电影海报、片名、评分、推荐理由,例如“因为看过《盗梦空间》推荐”。
第三类问题:全站电影的热度和评分走势如何?使用折线图展示近一年不同电影类型的月均评分人数,可帮助运营判断内容消费趋势。这个图从 MySQL 里聚合数据,前端用 ECharts 的 dataZoom 提供范围筛选。
第四类问题:得分分布是否集中?使用箱线图或直方图,看不同类型的评分中位数。箱线图比柱状图更合适,因为它一目了然显示中位数、四分位和异常值,特别适合比较动作片和纪录片的得分差异。
可视化模块的技术栈我用了 Flask 写接口,ECharts 直接通过 CDN 引入。没有自建 Node 服务,因为数据接口和页面的数据量都不大,纯 Python 已经满足需求。
4.2 后端接口数据结构怎么约定,前端能不返工
前端和后端联调时,最怕的数据结构变来变去。系统里我统一返回 JSON,整体结构如下:
json复制{
"code": 0,
"data": {
"recommend_list": [
{
"movie_id": 1024,
"title": "星际穿越",
"score": 9.4,
"reason": "因为你看过《盗梦空间》",
"poster": "https://..."
}
],
"user_profile": {
"科幻": 0.33,
"动作": 0.21,
"爱情": 0.12
}
},
"message": "success"
}
后端接口我拆成这几个:/api/user/<user_id>/recommend 返回推荐结果;/api/user/<user_id>/profile 返回用户画像;/api/stats/trend 返回评分趋势;/api/stats/distribution 返回类型分布和箱线图数据。
推荐列表接口一定不要把原始相似矩阵返回给前端,只返回最终 TopN,避免接口体重过大。后端在写接口时使用 Flask 的 jsonify,并通过 flask-cors 开放跨域,否则本地 HTML 文件直接打开时会因为跨域问题拿不到数据。
核心 Flask 接口示例:
python复制from flask import Flask, jsonify
from redis import Redis
app = Flask(__name__)
r = Redis.from_url("redis://127.0.0.1:6379/1", decode_responses=True)
@app.route("/api/user/<int:user_id>/recommend")
def recommend(user_id):
movies = get_user_recent_movies(user_id)
merged = {}
for mid in movies:
sims = r.hgetall(f"itemcf:sim:{mid}")
for other_movie, score in sims.items():
merged[other_movie] = merged.get(other_movie, 0) + float(score)
top_movies = sorted(merged.items(), key=lambda x: x[1], reverse=True)[:20]
result = fill_movie_detail(top_movies)
return jsonify({"code": 0, "data": result, "message": "success"})
4.3 ECharts 实时刷新时,要注意数据增量而不是整表重查
有的可视化看板希望实现“实时刷新”,每隔几秒更新图表。刚开始我做的是每 5 秒请求一次接口,后端每条接口都 SELECT * FROM movie_info,页面一开,MySQL CPU 直接飙高。
后来调整为:静态聚合数据每小时刷新一次,反映最新入库量;滚动数据只在用户勾选时间范围时才重新请求。推荐接口则使用 Redis 里的热点缓存,key 例如 hot:user:{user_id}:top:20,设置了 5 分钟过期。实时刷新并不等于所有接口都缩短间隔,更合理的方案是给不同数据配不同缓存 TTL。
如果要做得更细致,可以引入 WebSocket,让后端推送“爬虫新增了 N 条数据”这类消息,前端收到后再增量更新右上角的状态指标。可视化看板本质是“时间切片的快照”,没必要追求毫秒级。
5. 联调期最常见的几个问题,以及我采用的性能优化手段
5.1 MySQL 查询慢:先看表结构,再看是否绕开了索引
推荐和统计接口在第一次写完后,数据量到 5 万条左右时,趋势接口耗时已超过 3 秒。后来我通过 EXPLAIN 发现,user_behavior 表查询 WHERE user_id = ? 时走了全表扫描。加上联合索引后,查询时间降到十几毫秒。
常见的业务索引组合:
user_behavior: keyidx_user_id_time(user_id, create_time)movie_info: keyidx_genre(genre),因为看板经常按类型筛movie_behavior_count: 如果统计评分人数多,建议在评分人数列建索引,或直接做成预聚合表
如果你的项目还要做“同一导演作品对比”,也会涉及导演字段索引。MySQL 里索引不是越多越好,主要为 WHERE、ORDER BY、GROUP BY 涉及的字段建。
5.2 Python 端的 N+1 查询比算法慢更隐蔽
Flask 接口跑完推荐,拿到了 20 个 movie_id,接下来最坏的选择是循环 20 次查询 MySQL 详情。这是典型的 N+1 问题,小数据量没关系,多用户并发时接口响应时间会直线上升。
更好的写法是用一次 IN 查询取回所有电影详情:
python复制movie_ids = [item[0] for item in top_movies]
placeholders = ",".join(["%s"] * len(movie_ids))
sql = f"""
SELECT movie_id, title, score, poster_url
FROM movie_info
WHERE movie_id IN ({placeholders})
"""
然后把结果组装成字典返回。记住,一次网络往返永远好过 N 次。
5.3 Redis 缓存失效风暴怎么避免
如果同一时刻所有用户的推荐缓存全部过期,数据库会瞬时被请求打满。给不同用户设置不同的缓存过期时间,最简单的办法是:
python复制cache_ttl = random.randint(240, 360)
随机化过期时间后,缓存碎裂问题大幅缓解。缓存不命中时,后端还应该加一个分布式锁,避免多个请求同时去重建同一位用户的推荐结果。
5.4 定时采集与线上服务解耦
我不建议把爬虫执行放在 Flask 进程内,因为爬虫占用的内存和带宽会影响 API 稳定性。这个项目里,我将爬虫写成独立脚本 crawler.py,通过系统定时任务每天凌晨 2 点执行一次。
如果生产环境没有定时任务条件,可以在代码里加入一个后台线程。但严格来说,官方推荐的方案仍是把爬虫拆成独立服务。数据采集完成后,脚本会执行最后一步:清理 Redis 推荐缓存,让用户下一次浏览时拿到新版推荐,而不是继续用旧矩阵。
6. 跑完这个项目后,我保留下来的一些实用操作习惯
6.1 日志和目录结构要像后端工程一样对待
项目目录我会分成 crawler/、etl/、dal/、api/、frontend/。哪怕只是一个演示项目,也要让两周后的自己能顺畅继续改。crawler/ 负责请求和解析;etl/ 负责清洗字段和入库;api/ 只写接口路由;dal/ 统一操作 MySQL 和 Redis。
日志方面,不要只在代码里写 print。我会用 Python 自带的 logging 模块,将运行日志同时输出到控制台和文件。文件保留 7 天的轮转记录,这样半夜爬虫出问题时,第二天早上我能知道断在哪一页。
6.2 推荐效果不能只看准确率,还要看覆盖率
只给用户推荐常见大片,TopN 的准确率可能挺好看,但用户很快会觉得无聊。算法侧,我会在最终结果里做一层“类别重排”:生成前 50 个候选,然后用最少量的替换让结果中的类型平均覆盖至少 4 类。代码不需要很复杂,核心是保证每页推荐里不要连续出现 5 部都是同一类型。
6.3 小技巧:给每个推荐结果都保留“被解释”的入口
做推荐系统,很容易陷入算法细节,却忘记用户问得最多的问题是“为什么推荐这个”。我在可视化卡片上的“推荐理由”来自 ItemCF 的最强解释:推荐电影的候选中,与用户最近看过电影共现次数最高的那部,系统就展示“因为你看过某某”。这个理由真实、直观、可验证。系统上线后,用户大多会对这条解释产生更多点击和反馈,也顺便为下一次模型更新积累了行为数据。
同样地,看板上的筛选条件也要保留可重置状态。用户刷新一次页面前,所有图表和推荐列表联动;刷新后回到默认画像。这个交互逻辑维护成本低,但感知上会让系统显得完整得多。
这个项目原本只是为了解决“电影推荐怎么从零开始做到可看、可解释”的问题,真正跑完后我发现,它更像一条微型的工业级数据管道。无论你是准备把项目写进简历,还是想继续扩展到图书、课程、短视频推荐,这套“爬虫清洗入库 → ItemCF 相似计算 → Redis 缓存 → ECharts 可视化解释推荐原因”的顺序都值得保留。推荐算法本身会迭代,但数据质量和链路设计才是后续所有功能的地基。
