
简介面向推荐系统与知识图谱学习者的Python实战项目基于电影、演员、导演等实体关系构建知识图谱通过图结构挖掘潜在关联解决传统推荐系统可解释性弱、冷启动难等问题。压缩包共54个文件核心为43个Python脚本涵盖数据采集、预处理、图谱构建、推荐算法与离线评估等完整流程另含4个cfg配置文件、4个txt说明、2个Markdown笔记和1个SQL数据库文件整体922KB轻量易部署。目前已有175人学习下载。项目包含从IMDb/豆瓣数据抓取到Neo4j图数据库查询推理的完整代码并集成基于协同过滤与内容推荐的算法实现读者可结合Markdown文档快速复现实验也可参考cfg配置修改参数、通过SQL文件扩展数据整体代码注释清晰便于二次开发和功能扩展非常适合作为课程设计或毕业设计的参考基础。1. 电影推荐系统里为什么需要知识图谱协同过滤在用户行为数据稀疏时几乎失效新用户没有评分、新电影没有曝光推荐列表靠“大多数人的口味”兜底结果千篇一律。知识图谱解决的不是“算得更快”而是把电影、演员、导演、类型、用户这些实体之间的关系变成可检索、可推理的结构让推荐从“猜你喜欢”变成“因为你看过《盗梦空间》里的莱昂纳多所以推《泰坦尼克号》”。也就是说图谱给推荐系统补上了最缺的两样东西关联路径和可解释性。这篇文章面向正在做 Python 推荐系统、或者想用知识图谱替换协同过滤那套逻辑的工程师从数据建模、Neo4j 导入、推荐算法到 API 落地给出一套用 MovieLens 这类公开数据就能跑通的最小实现。2. 电影知识图谱怎么建模实体、关系与属性设计知识图谱构建的第一步不是写代码而是定 schema。图数据库里没有表结构约束但关系方向、标签命名、属性类型一旦定错后面所有 Cypher 查询都要跟着返工。先明确一个原则图谱里的每一条边都必须回答一个业务问题。“用户看过电影”和“用户喜欢电影”是两条不同的边前者是事实后者是偏好混在一起会让路径计算失去语义。电影推荐这个场景里推荐链路依赖的是“用户 → 电影 → 演员 → 电影”这种多跳关系所以实体和关系的设计要围绕“能否支撑多跳路径”来展开。2.1 用 MovieLens 构建电影领域三元组Pandas 清洗与映射最常见的做法是以 MovieLens 100K 公开数据作为行为来源另外准备一份电影元数据包含导演、演员、类型来补全实体信息。先把原始数据读进来做类型映射和空值处理import pandas as pd # 用户行为user_id, movie_id, rating, timestamp ratings pd.read_csv(ml-100k/u.data, sep\t, names[user_id, movie_id, rating, ts]) # 电影元数据movie_id, title, release_date, video_release, imdb_url movies pd.read_csv(ml-100k/u.item, sep|, names[movie_id, title, release_date, video_release, imdb_url], encodinglatin-1, usecols[0, 1, 2]) # 评分档位 4 视为“喜欢”用于构建 RATED_LIKE 关系 ratings[liked] (ratings[rating] 4).astype(int) liked ratings[ratings[liked] 1][[user_id, movie_id]] # 把电影标题里的年份拆出来作为属性避免整串字符串参与匹配 movies[year] movies[title].str.extract(r\((\d{4})\)) movies[clean_title] movies[title].str.replace(r\(\d{4}\), , regexTrue) print(liked.head()) print(movies[[movie_id, clean_title, year]].dropna().head())这段代码做的事情是把评分矩阵压缩成“喜欢”关系减少图谱里的边数量从标题中抽取年份属性方便后面做“同一时期电影”这类查询。注意 MovieLens 100K 的 u.item 文件是latin-1编码直接read_csv默认 utf-8 会报错这是我每次搭图谱都会踩的第一个坑。至于演员和导演数据MovieLens 自带的 u.item 里不包含完整 cast 信息常见的补全是额外用 IMDb 导出的name.basics.tsv和title.principals.tsv做 join这里不展开抓取细节重点是理解“实体来源不同没关系最终统一成三元组即可”。2.2 图数据库选型为什么电影推荐落地常用 Neo4j知识图谱可以存 RDF 三元组也可以存标签属性图Labeled Property Graph。RDF 那套比如 Apache Jena强在推理和标准化但日常做推荐的人很少用到 OWL 推理反而被 SPARQL 的写法折磨。工程上更常见的是 Neo4j原因很直接Cypher 的MATCH语法表达多跳路径比 SQL 的递归 CTE 直观得多py2neo 和 neo4j 官方驱动对 Python 生态友好Neo4j Browser 自带可视化调试路径一眼能看出问题。选 Neo4j 还有一个隐性好处关系可以带属性。比如RATED关系上可以存rating和timestamp做时间衰减推荐时直接在边上取值不用再去 join 一张评分表。这是属性图相比 RDF 的优势也是我在设计推荐链路时坚持用 Neo4j 而不是单纯把三元组放到 Elasticsearch 里的原因。2.3 关系命名与约束Cypher 里最容易错的 3 个写法关系和标签的命名规范不是形式主义直接决定查询能否被索引命中。常见的错误有三个一是关系类型用了小写或驼峰Cypher 约定关系类型用大写蛇形ACTED_IN不是ActedIn二是把用户 ID 和电影 ID 都设计成id属性查询时没有用标签区分导致扫全库三是没有为高频属性建唯一约束导致重复导入后图谱里出现一堆同名的演员节点。正确的做法是在导入前先通过 Cypher 建好约束CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE; CREATE CONSTRAINT movie_id IF NOT EXISTS FOR (m:Movie) REQUIRE m.id IS UNIQUE; CREATE CONSTRAINT actor_name IF NOT EXISTS FOR (a:Actor) REQUIRE a.name IS UNIQUE;约束建立后导入数据时用MERGE而不是CREATE否则重复运行脚本会在图里塞满重复节点。下面进入真正的建库环节。3. 用 Neo4j py2neo 把知识图谱建起来导入、索引与查询图形数据导入有两个路线数据量大用neo4j-admin import离线导入适合千万级节点的全量初始化数据量中等、需要反复试错用LOAD CSV或者 py2neo 写事务。电影推荐场景百万级以下节点用 LOAD CSV 最顺手因为可以直接复用 Python 清洗后导出的 CSV 文件不需要把数据再转成 neo4j-admin 要求的 header 格式。3.1 用 LOAD CSV 批量灌入节点和关系先把 pandas 处理好的数据导出为 CSV放在 Neo4j 的 import 目录下liked[[user_id, movie_id]].to_csv(/data/import/liked.csv, indexFalse) movies[[movie_id, clean_title, year]].to_csv(/data/import/movies.csv, indexFalse)然后在 Neo4j Browser 或通过neo4jPython 驱动执行LOAD CSV WITH HEADERS FROM file:///movies.csv AS row MERGE (m:Movie {id: toInteger(row.movie_id)}) ON CREATE SET m.title row.clean_title, m.year toInteger(row.year); LOAD CSV WITH HEADERS FROM file:///liked.csv AS row MATCH (u:User {id: toInteger(row.user_id)}) MATCH (m:Movie {id: toInteger(row.movie_id)}) MERGE (u)-[r:RATED_LIKE]-(m)LOAD CSV的处理速度取决于两个指标MATCH是否命中索引、是否开启PERIODIC COMMIT。Cypher 不像 Python 代码可以看日志遇到导入慢第一反应就是检查:schema看 User 和 Movie 的唯一约束是否生效。MERGE会把匹配和创建合并为一次操作但代价是比CREATE慢因为每次都要先查一遍。对于已知不存在重复的数据可以先CREATE后建约束把导入时间缩一半。3.2 py2neo 事务写入与批大小设置如果推荐系统跑在 Python 服务里更常见的做法是直接用 py2neo 从内存写入避免中间落盘 CSV。py2neo 5.x 的Graph.run()可以逐条执行 Cypher但逐条跑网络往返太大必须用transaction批处理from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) with graph.begin() as tx: for idx, row in liked.iterrows(): user_node Node(User, idint(row[user_id])) movie_node Node(Movie, idint(row[movie_id])) tx.merge(user_node, User, id) tx.merge(movie_node, Movie, id) tx.merge(Relationship(user_node, RATED_LIKE, movie_node))begin()开启一个事务事务内的所有操作成功才提交中途失败整体回滚。批大小是调优的关键一批 1000 到 5000 条是常见经验值批太小网络开销占比高批太大内存和锁竞争上升。py2neo 的merge接受三个参数节点、主标签、主属性写清楚后重复执行不会产生重复节点。这里有个细节tx.merge对已经存在的节点不会更新属性如果后续要更新评分时间戳需要用node[ts] row[ts]赋值后再 merge。3.3 推荐前先会查用户到电影的多跳路径数据灌进去以后先用一条多跳查询验证图谱结构是否合理。比如查“某个用户喜欢过的电影里有哪些演员还演过别的电影”MATCH (u:User {id: 42})-[:RATED_LIKE]-(m:Movie)-[:ACTED_IN]-(a:Actor)-[:ACTED_IN]-(rec:Movie) WHERE rec.id m.id RETURN rec.title, count(*) AS strength, collect(DISTINCT a.name) AS shared_actors ORDER BY strength DESC LIMIT 20;这条查询就是电影推荐系统里最常见的 Meta-Path 雏形用户 → 电影 → 演员 → 电影。collect(DISTINCT a.name)的作用是聚合路径上的演员作为解释信息count(*)统计两件电影之间共享的演员数作为相似性的粗粒度打分。能跑通这条查询说明图谱的连接是完整的推荐算法可以直接在查询结果上展开。4. 电影推荐算法实现从 Meta-Path 计数到 TransE 嵌入图谱建好只是地基推荐算法才是重头戏。基于知识图谱的推荐算法分成两条路线基于路径的规则方法和基于嵌入的表示学习方法。路径方法靠 Cypher 就能实现可解释性强适合冷启动和后台管理页面展示“推荐理由”嵌入方法用 TransE 这类模型把实体和关系映射成向量然后用向量的余弦相似度做召回语义更丰富但解释性差。两者不是互斥的落地时通常先跑路径方法拿到候选集再用嵌入相似度对候选集重排。4.1 Meta-Path 规则把“喜欢”沿图谱扩散Meta-Path 就是定义一条节点类型的通路比如上面那条U-RATED_LIKE-M-ACTED_IN-A-ACTED_IN-M中文互联网上常翻译成元路径。它的推荐逻辑是如果用户喜欢的电影 A 和电影 B 共享三位演员那么 B 和 A 的相似度就高。把这种思想拆成可复用函数META_PATHS { actor: MATCH (u:User {id: $uid})-[:RATED_LIKE]-(:Movie)-[:ACTED_IN]-(:Actor)-[:ACTED_IN]-(rec:Movie) WITH rec, count(*) AS strength RETURN rec.id AS movie_id, rec.title AS title, strength , genre: MATCH (u:User {id: $uid})-[:RATED_LIKE]-(:Movie)-[:BELONGS_TO]-(:Genre)-[:BELONGS_TO]-(rec:Movie) WITH rec, count(*) AS strength RETURN rec.id AS movie_id, rec.title AS title, strength , director: MATCH (u:User {id: $uid})-[:RATED_LIKE]-(:Movie)-[:DIRECTED_BY]-(:Director)-[:DIRECTED_BY]-(rec:Movie) WITH rec, count(*) AS strength RETURN rec.id AS movie_id, rec.title AS title, strength } def meta_path_candidates(uid, path_typeactor, limit50): cypher META_PATHS[path_type] res graph.run(cypher, uiduid).data() return res[:limit]这个函数的输出是movie_id, title, strength三元组strength表示候选电影和用户喜欢电影之间通过该路径建立的连接数。直接用count(*)做打分有个问题小李子演过 30 部电影所有喜欢其中一部的用户都会拿到同一批候选区分度不够。改进做法是把路径上的演员做 IDF 加权类似于 TF-IDF 的思路出演电影数量越少的演员他作为连接桥的权重越高。这个加权逻辑可以放在 Python 层处理不需要改 Cypher。4.2 知识图谱嵌入TransE补充语义相似度路径方法处理显式关系很强但“题材相近但没有共同演员”的电影之间没有路径这是路径方法的天然盲区。知识图谱嵌入通过训练把实体和关系投影到向量空间让《盗梦空间》和《黑客帝国》即使在图里没有直接路径向量距离也足够近。常见做法是使用 TransEfrom pykeen.pipeline import pipeline # triples_df 是 (head, relation, tail) 三元组 DataFrame result pipeline( datasetNone, trainingtriples_df, modelTransE, model_kwargs{embedding_dim: 50}, optimizerAdam, optimizer_kwargs{lr: 0.01}, training_kwargs{num_epochs: 100}, random_seed42, ) entity_embedding result.model.entity_embeddings() print(entity_embedding[entity_to_id[盗梦空间]])PyKEEN 是知识图谱嵌入的主流 Python 库内部把三元组封装成Instantiator不需要自己写负采样。这里有两个参数直接影响推荐效果embedding_dim决定向量表达能力电影场景 50 维足够超过 100 维容易过拟合lr初始学习率设 0.01训练到后半段如果不收敛常见做法是换成 Adam 的默认学习率并加大num_epochs。训练完成后计算用户喜欢电影向量均值和候选电影向量的余弦相似度得到一个 0 到 1 的语义得分。注意 TransE 打分是越小越可能是负样本所以要取负距离作为相似度信号。4.3 混合打分权重 α 怎么调路径强度和嵌入相似度是两种异质信号量纲不一样不能直接相加。我的做法是先做 min-max 归一化再用线性加权融合def hybrid_score(uid, alpha0.6, top_k30): # path_score: {movie_id: strength} 归一化 # emb_score: {movie_id: similarity} 归一化 fused {} for mid in set(path_score) | set(emb_score): p path_score.get(mid, 0.0) e emb_score.get(mid, 0.0) fused[mid] alpha * p (1 - alpha) * e return sorted(fused.items(), keylambda kv: kv[1], reverseTrue)[:top_k]alpha控制路径信号和语义信号的占比。经验上冷启动用户行为数据少路径信号稀疏alpha调小到 0.4 左右更多依赖图谱结构头部老用户路径丰富alpha可以调到 0.7。不要手工拍脑袋调alpha把用户按历史行为数量分桶每个桶网格搜索alpha看哪个值的离线精度最高就选哪个。5. 可解释推荐落地接口返回推荐理由与效果验证最后这一步把离线实验变成能给别人用的服务。推荐系统的工程化不只是把分数算出来还要把“为什么推荐”一起交给前端。用 Flask 包一层接口把推荐结果连同路径解释一起返回from flask import Flask, jsonify, request app Flask(__name__) app.route(/api/recommend, methods[GET]) def recommend(): uid int(request.args.get(uid, 1)) recs hybrid_score(uid, alpha0.6, top_k10) payload [] for movie_id, score in recs: reason explain(uid, movie_id) payload.append({movie_id: movie_id, score: score, reason: reason}) return jsonify(payload) def explain(uid, movie_id): result graph.run( MATCH path (u:User {id: $uid})-[:RATED_LIKE]-(:Movie)-[:ACTED_IN]-(a:Actor)-[:ACTED_IN]-(rec:Movie {id: $mid}) RETURN path, a.name AS actor ORDER BY path LIMIT 1 , uiduid, midmovie_id).data() if result: return f因为你喜欢的电影中有{result[0][actor]}出演 return 基于整体偏好计算explain函数做的是一次最短路径查找取第一条路径拼成人话。这就是知识图谱推荐相比协同过滤最明显的工程优势可解释性不是额外功能而是查询的副产品。评价推荐效果时在离线用留一法切分数据按时间把每个用户最后一次评分作为测试集计算 PrecisionKdef precision_at_k(recs, ground_truth, k10): rec_ids {mid for mid, _ in recs[:k]} hit len(rec_ids ground_truth) return hit / kk的取值要和业务对齐推荐位只有 5 个就评估 Precision5不要盲目追求 20。最后说一个容易忽略的验证技巧跑通接口后不要只盯着准确率从推荐结果里随机抽 20 条人工看 Cypher 返回的路径是否合理。如果路径是“你喜欢《星球大战》→ 哈里森·福特 →《亡命天涯》”这条推荐即使指标低用户也愿意点如果路径变成“你喜欢《泰坦尼克号》→ 同名导演 → 纪录片”说明关系语义设计有问题要回头检查DIRECTED_BY关系是否被错误关联。本文还有配套的精品资源点击获取