ARTICLE DETAIL

资讯详情

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

知识图谱与图神经网络:电影推荐系统构建实战

知识图谱与图神经网络:电影推荐系统构建实战 简介这是一份基于Python知识图谱与图神经网络的电影推荐系统毕业设计源码包面向计算机相关专业毕设、课程设计或推荐系统方向初学者用于快速复现并理解知识图谱增强的推荐算法。项目使用ml-1m数据集运行环境为python3.8、tensorflow2.3.8运行主程序可生成KCGN.h5权重文件再由预测脚本完成预测评估代码组织结构清晰适合直接作为毕业设计项目骨架。压缩包共32个文件以21个py源码文件为主体配合5个dat数据文件、txt说明、readme及md文档整体大小约12.69MB源码覆盖电影数据预处理、知识图谱构建、KGCN图卷积模型训练与评估、Web端展示等模块并包含gpu内存配置、装饰器、通用工具等辅助脚本便于二次修改。资源已有489人学习下载读者可获得完整可运行的项目代码、数据集与说明文档节省从零搭建环境与理解图神经网络推荐流程的时间。1. 知识图谱图神经网络做电影推荐先解决协同过滤的两个短板大部分电影推荐毕设从 MovieLens 出发终点是协同过滤评分矩阵、SVD、RMSE公式漂亮答辩时一问就露怯——新电影没有评分怎么推矩阵稀疏到 97% 是空值推荐理由又是什么知识图谱补上了语义维度。把导演、演员、类型、标签整理成实体与关系推荐不再只靠交互矩阵而是多了一条可解释路径用户喜欢诺兰诺兰导演了《盗梦空间》《星际穿越》就有理由进候选。图神经网络把多跳邻域信息聚合成用户向量和电影向量支撑召回与排序。这个组合适合有 Python 基础、会 pandas 的开发者也适合想同时展示数据工程与模型能力的毕设选题。下文按可复现链路展开构建知识图谱训练图神经网络接入推荐闭环做指标与消融验证。2. 基于Python的电影知识图谱构建本体设计、三元组清洗与Neo4j写入先交代环境Python 3.9 以上Neo4j 4.x 或 5.xPython 侧装pandas、py2neo模型阶段再补torch和torch_geometric。安装顺序上有个常见坑torch要按官网选的 CUDA 版本装torch_geometric装不上时先降级成 CPU 版 torch 保底这个数据规模 CPU 完全够跑。知识图谱构建的目的是服务推荐不是堆规模。把图谱做得越大图神经网络聚合时引入的噪声越多清洗成本和答辩追问也越多。所以先想清楚放什么再谈怎么写代码。2.1 五类实体、四类关系图谱规模不是越大越好电影推荐场景里控制住实体类型反而更容易出效果。我一般只保留五类实体User、Movie、Actor、Director、Genre。关系四类用户给电影打分 RATED、演员出演 ACTED_IN、导演执导 DIRECTED、电影属于某类型 BELONGS_TO。这样设计有三个理由数据源全部来自 MovieLens 加一份豆瓣抓取成本可控RGCN 的关系维度小训练稳定答辩时每类实体和关系都能讲清来源与用途。实体类型数据来源清洗要点Userratings.csv 的 userId过滤交互次数少于 5 的用户Moviemovies.csv 的 movieId主实体id 做去重Actor豆瓣演员表或 tags 推断去重、去空名、合并同人异名Director豆瓣导演表一部电影保留一个导演即可Genremovies.csv 的 genres竖线分隔后拆开关系表要额外定义属性。RATED 带 rating 和 timestamp 两个属性这是后面训练正负样本的唯一依据ACTED_IN、DIRECTED、BELONGS_TO 都不带属性只表达拓扑。评分保留原始 1 到 5 分训练时再决定是转成 0 到 1 权重还是按阈值二值化——这个决策要在论文里写一句答辩常问“你把评分二值化丢了多少信息”。2.2 用 pandas 清洗数据产出 head/tail/rel 三元组不管后面写 Neo4j 还是转 PyG统一先整理成head,tail,rel三列的 DataFrame。这里最关键的动作是给实体 id 加前缀用户写u:1电影写m:1演员写a:1导演写d:1类型写g:Crime。前缀解决了两类 id 撞号问题也让后续脚本能按前缀直接推断实体标签。import pandas as pd ratings pd.read_csv(ml-latest-small/ratings.csv) movies pd.read_csv(ml-latest-small/movies.csv) # 评分 4 视为正样本先保留全量用于建图 triples [] for _, r in ratings.iterrows(): triples.append((fu:{r[userId]}, fm:{r[movieId]}, RATED)) # 电影类型字段用竖线分隔拆成多条 BELONGS_TO for _, m in movies.iterrows(): for g in str(m[genres]).split(|): if g and g ! (no genres listed): triples.append((fm:{m[movieId]}, fg:{g}, BELONGS_TO)) # 豆瓣抓取的演员导演假设列名是 movie_id / actor_name / director_name actors pd.read_csv(douban_actor.csv) for _, r in actors.dropna(subset[actor_name]).iterrows(): triples.append((fa:{r[actor_name]}, fm:{r[movie_id]}, ACTED_IN)) kg_df pd.DataFrame(triples, columns[head, tail, rel]) kg_df kg_df.drop_duplicates() print(kg_df[rel].value_counts())逻辑说明评分边用 userId 做头实体movieId 做尾实体方向从用户指向电影类型和演员关系统一保持“实体指向电影”的方向这样后续转边表时不需要再调整。drop_duplicates是因为同一演员可能被重复抓取同一条关系写两遍会在图里产生重边重边对 RGCN 的聚合权重有直接影响。参数说明fu:{r[userId]}这种前缀写法是整条链路的约定第 2.3 节写 Neo4j 时靠它映射节点标签第 3 章转 PyG 时靠它编号。豆瓣数据如果没抓到退路是用 MovieLens 自带的tags.csv生成TAGGED关系实体类型少一类但流程完全一样。2.3 用 py2neo 与 Cypher 分批写入 Neo4j写入 Neo4j 常见做法是py2neo.Graph起事务配合apoc.merge.node做幂等写入。MERGE而不是CREATE的目的是允许脚本重复跑第二次执行不会插出重复节点。from py2neo import Graph graph Graph(bolt://127.0.0.1:7687, auth(neo4j, neo4j)) PREFIX_LABEL {u: User, m: Movie, a: Actor, d: Director, g: Genre} def entity_label(eid): return PREFIX_LABEL[eid.split(:)[0]] # 收集所有实体去重后带标签写入 rows [] for col in (head, tail): for eid in kg_df[col]: if eid not in {r[id] for r in rows}: rows.append({id: eid, label: entity_label(eid)}) node_cypher UNWIND $rows AS row CALL apoc.merge.node([row.label, Entity], {id: row.id}) YIELD node RETURN count(*) for i in range(0, len(rows), 500): graph.run(node_cypher, rowsrows[i:i 500])逻辑说明apoc.merge.node的第一个参数是标签数组这里同时打上具体标签User/Movie和公共标签 Entity公共标签便于建统一索引和统计节点数。第二个参数是唯一键属性用前面约定的带前缀 id。分批取 500 条是为了避免单个事务过大Neo4j 在写大事务时的提交时间会非线性上升实测 500 到 1000 是安全区间。关系写入单独处理因为 RATED 带属性其余不带。apoc.merge.relationship支持动态关系类型rel_cypher UNWIND $rows AS row MATCH (h:Entity {id: row.head}), (t:Entity {id: row.tail}) CALL apoc.merge.relationship(h, row.rel, {}, row.props, t, row.props) YIELD rel RETURN count(*) rel_rows [] for _, r in kg_df.iterrows(): props {} if r[rel] RATED: score ratings.loc[(ratings[userId] int(r[head][2:])) (ratings[movieId] int(r[tail][2:])), rating].values if len(score): props {rating: float(score[0])} rel_rows.append({head: r[head], tail: r[tail], rel: r[rel], props: props}) for i in range(0, len(rel_rows), 500): graph.run(rel_cypher, rowsrel_rows[i:i 500])参数说明row.props同时传给创建和匹配分支表示关系属性属于这条边本身更新时不覆盖已有值。没装 APOC 的话退化成按关系类型写四条独立MATCH加MERGE语句缺点是要为每种关系手写一遍头尾标签。写入完成后建索引并检查分布CREATE INDEX entity_id IF NOT EXISTS FOR (n:Entity) ON (n.id); MATCH (n) RETURN labels(n) AS label, count(*) AS cnt ORDER BY cnt DESC; MATCH ()-[r]-() RETURN type(r) AS rel, count(*) AS cnt;注意Neo4j Browser 的图谱可视化默认只渲染一小部分节点侧边栏对标签数量也有限制看到“只显示 25 个标签”或图上节点稀疏不代表构建失败。判断构建成功与否一律以count(*)查询结果为准可视化只负责演示。检查孤立节点用MATCH (n) WHERE NOT (n)--() RETURN count(n)比例超过 5% 就要回去查清洗逻辑。3. 图神经网络训练把知识图谱变成可计算的电影向量图谱建好只完成了数据侧工作。Neo4j 里的图不能直接进 PyTorch第 3 章解决两件事选什么模型、怎么把图结构喂给模型。3.1 为什么选 GNN 而不是 TransE 或 Node2Vec知识图谱嵌入的经典做法是 TransE把关系看成头实体到尾实体的平移向量。它能做链接预测但推荐任务里要让“用户向量和电影向量的内积”代表偏好TransE 学完还得再包一层预测层链路变长。Node2Vec 和 DeepWalk 是无监督游走根本不利用评分信号且冷启动节点没有游走路径正好踩在协同过滤的同一个短板上。GNN 的消息传递机制和推荐目标更匹配每个节点迭代聚合邻居信息评分可以作为边权重参与聚合用户和电影被编码到同一个向量空间内积就是偏好分。知识图谱多关系的特点对应 RGCN关系图卷积网络每个关系类型单独一套变换参数比普通 GCN 更能保留“导演”和“出演”这两种边的语义差异。如果只关注交互图LightGCN 这类去非线性模型效果更好但毕设题目点名图神经网络RGCN 在论文里更好讲故事效果也完全够。3.2 把三元组转成 edge_index 和 edge_typePyG 的 RGCNConv 需要三个输入节点特征、边索引、边类型。第 2 章的kg_df三列在这里直接复用给所有实体统一编号把关系名映射成整数反向边也一起补上让信息在图上双向传播import torch from collections import defaultdict node_ids sorted(set(kg_df[head]) | set(kg_df[tail])) node2id {n: i for i, n in enumerate(node_ids)} rel_map {RATED: 0, ACTED_IN: 1, DIRECTED: 2, BELONGS_TO: 3} edge_list, edge_types [], [] for h, t, rel in kg_df.values: edge_list.append((node2id[h], node2id[t])) edge_types.append(rel_map[rel]) # 反向边使用独立的类型编号避免和正向边混淆 edge_list.append((node2id[t], node2id[h])) edge_types.append(rel_map[rel] len(rel_map)) edge_index torch.tensor(edge_list, dtypetorch.long).t().contiguous() edge_type torch.tensor(edge_types, dtypetorch.long)逻辑说明反向边的类型编号故意偏移len(rel_map)等于把 4 类关系扩展成 8 类让 RGCN 能区分边的方向。userId编号从 0 开始连续排num_nodes取len(node_ids)这是 Embedding 表初始化的依据。类型字段如果混入未知值会在rel_map处直接 KeyError这反而是好事能在训练前暴露数据清洗漏洞。3.3 一个能跑通的最小 R-GCN 实现模型保持最小可跑状态所有实体共享一个 Embedding 表后面接两层 RGCNConv输出向量直接用于内积打分。这个实现不是论文级严谨但训练快、易调试答辩时讲清“共享嵌入加关系感知聚合”的取舍即可。import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import RGCNConv class KGRGCN(nn.Module): def __init__(self, num_nodes, num_rels, hidden64, num_layers2): super().__init__() self.emb nn.Embedding(num_nodes, hidden) self.convs nn.ModuleList() for _ in range(num_layers): self.convs.append(RGCNConv(hidden, hidden, num_rels)) self.dropout nn.Dropout(0.2) def forward(self, edge_index, edge_type): x self.emb.weight for conv in self.convs: x conv(x, edge_index, edge_type) x F.relu(x) x self.dropout(x) return x def score(self, users, items, edge_index, edge_type): x self.forward(edge_index, edge_type) return (x[users] * x[items]).sum(dim-1) model KGRGCN(num_nodeslen(node_ids), num_relslen(rel_map) * 2)逻辑说明forward里输入self.emb.weight等于把所有实体从 id 映射成 dense 向量经过两层关系感知聚合后每个节点的表示同时包含自身 ID 信息和多跳邻居的结构信息。score用逐元素相乘再求和等价于向量内积输出就是用户对电影的偏好分。参数说明num_rels必须传 8对应 3.2 节反向边扩充后的类型数hidden64在 MovieLens 最小数据集上是性价比很高的选择。3.4 负采样、BPR 损失与三个必调参数训练目标不是回归预测评分而是排序正确。对每个正样本用户喜欢的电影随机采样一个该用户没看过的电影作为负样本用 BPR 损失要求正样本得分高于负样本import random def build_negatives(pos_pairs, user_positive, num_movies): neg_u, neg_i [], [] for u, i in pos_pairs: for _ in range(3): # 每个正样本配 3 个负样本 while True: ni random.randint(0, num_movies - 1) if ni not in user_positive[u]: break neg_u.append(u) neg_i.append(ni) return torch.tensor(neg_u), torch.tensor(neg_i) def bpr_loss(pos_score, neg_score): return -F.logsigmoid(pos_score - neg_score).mean()逻辑说明build_negatives里的 while 循环保证负样本不落在用户真实喜欢的集合里这是负采样最重要的约束——把用户喜欢的电影当负样本会让模型学出相反信号。bpr_loss只要求正负样本的相对顺序不要求绝对分和 Top-N 推荐的评估目标一致。训练循环里每个 epoch 重新采样负样本固定同一批负样本会导致模型记住噪声。三个必调参数的经验区间和调坏表现参数推荐区间调坏的表现hidden32 到 128过小欠拟合NDCG 上不去过大过拟合测试集num_layers2 到 3超过 3 层出现过平滑所有电影向量趋同dropout0.1 到 0.3太高时 loss 降不下来推荐结果接近随机学习率用 Adam 默认 0.001epoch 20 到 30 轮训练集准确率接近 1 但验证指标停滞时优先调 dropout 而不是加层数。4. 电影推荐系统闭环GNN 召回、排序重排与冷启动兜底图神经网络输出的向量本身只是中间产物要让老师看到“推荐系统”需要把召回、排序、评估三段拼成闭环。第 4 章就是把第 3 章的嵌入用起来。4.1 用 GNN 嵌入做全库 Top-N 召回召回阶段的任务是快速缩小候选集。用户向量和全量电影向量做内积取 Top 50。在 MovieLens 3000 部电影的规模下全库暴力内积是毫秒级不需要上向量数据库model.eval() with torch.no_grad(): emb model(edge_index, edge_type).cpu() user_vec emb[user_id] # 目标用户的嵌入 movie_vec emb[movie_start:movie_end] # 只取电影分区的向量 scores movie_vec user_vec top50 scores.topk(50).indices.numpy()逻辑说明movie_start和movie_end是第 2 章编号时保留下来的电影 id 区间因为实体编号先排用户再排电影这个区间在预处理时固定。Top-N 召回只依赖内积不引入额外模型能直观展示 GNN 嵌入的质量。参数说明topk(50)的 50 是召回深度太小会漏掉正样本太大会加重排序阶段负担评估时 Recall10 只取前 10但召回深度一般设 50 到 100。数据规模扩大到十万级电影时内积换成 faiss 的IndexFlatIP索引构建一行代码效果不变。4.2 召回候选的排序重排LightGBM 二次排序召回只用了嵌入内积特征维度单一。排序阶段把召回结果展平成特征行加入流行度、评分统计、GNN 得分等特征用 LightGBM 做二分类标签是“评分是否大于等于 4”。这一步是推荐系统“召回排序”架构的体现也是答辩的加分项import lightgbm as lgb feat_cols [gnn_score, movie_pop, movie_avg, user_avg_genre, dir_same] X candidates[feat_cols] y (candidates[rating] 4).astype(int) model lgb.LGBMClassifier(n_estimators200, learning_rate0.05, num_leaves15) model.fit(X, y) candidates[rank_score] model.predict_proba(X)[:, 1]逻辑说明movie_pop是电影被评分的次数movie_avg是历史均分user_avg_genre是用户对同类型电影的历史平均分dir_same表示候选电影导演是否在用户历史偏好导演集合里。这些特征全部能从知识图谱和原始评分表聚合出来不需要额外数据源。排序模型只对召回候选打分不做全库遍历这是两阶段架构的核心区别。参数说明n_estimators200控制树数量小数据集超过 200 棵开始过拟合num_leaves15限制树复杂度防止在稀疏交互数据上记忆噪声。如果不想引入 LightGBMsklearn 的 LogisticRegression 也能完成相同叙事只是效果上限低一些。4.3 时间切分防泄漏与冷启动兜底评估切分必须按时间而不是随机切分。随机切分会让模型在训练时看到测试期的交互行为测试指标虚高答辩被问到数据划分时很难解释ratings ratings.sort_values(timestamp) cut int(len(ratings) * 0.8) train_ratings ratings.iloc[:cut] test_ratings ratings.iloc[cut:] # 建图只用训练集测试集只保留训练集出现过的电影 kg_df build_kg(train_ratings, movies) test_ratings test_ratings[test_ratings[movieId].isin(train_ratings[movieId].unique())]逻辑说明第 2 章建图脚本传train_ratings保证训练链路看不到未来评分。测试集里训练集没出现过的电影直接过滤否则模型没见过该节点报错和冷启动问题混在一起无法定位。冷启动兜底分两类新用户没有任何交互推荐直接落到该用户注册时选择的类型按类型内流行度排序新电影如果带着导演演员类型关系进入图谱RGCN 依然能通过邻居聚合算出向量这正是知识图谱相对协同过滤的优势消融实验里要单独统计这类电影的命中率。5. 答辩前验证评估脚本、消融实验和三个必答追问5.1 评估脚本Recall10 和 NDCG10 不依赖框架自己实现评估指标比调库更稳妥因为面试官和评委可能追问公式细节。每个测试用户取前 10 个推荐与测试集真实交互求交import math def recall_at_k(pred, true_items, k10): hit len(set(pred[:k]) set(true_items)) return hit / max(len(true_items), 1) def ndcg_at_k(pred, true_items, k10): dcg 0.0 for rank, item in enumerate(pred[:k]): if item in true_items: dcg 1.0 / math.log2(rank 2) idcg sum(1.0 / math.log2(r 2) for r in range(1, min(k, len(true_items)) 1)) return dcg / idcg if idcg 0 else 0.0逻辑说明NDCG 的分母是理想排序下的最大 DCG当测试用户只有一条正样本时idcg等于 1NDCG10 退化成一个位置指标。评估时要注意候选集排除训练集出现过的“已看过”电影否则系统把用户已经看过的电影推回前排指标虚高。5.2 消融实验对照表怎么设计才有说服力消融实验证明三件事知识图谱确实带来增益、GNN 比浅层模型强、每个组件都必要。四行对照表即可模型Recall10NDCG10冷启动电影命中率流行度基线0.210.180.06SVD纯评分矩阵0.280.230.14交互二部图 GNN0.310.260.19知识图谱 RGCN完整0.340.280.31表里的数字是典型量级真实值随切分方式浮动但趋势稳定知识图谱带来的增益主要集中在冷启动和长尾电影热门电影上流行度基线本来就不弱。答辩如果被问“为什么完整模型在头部电影上没有碾压式优势”直接拿这一列数据说明 KG 解决的是稀疏场景比强行解释整体提升更有说服力。5.3 答辩必答的三个技术追问第一个追问知识图谱比协同过滤多带来了什么回答要落到两点冷启动时新电影只要有导演演员类型关系就能获得向量以及推荐结果可以通过图谱路径给出解释。演示时准备一条 Cypher把“用户 → 评分 → 某电影 → 同导演 → 新电影”路径打出来比任何口头解释都直观。第二个追问为什么用 RGCN 而不是普通 GCN核心答案是知识图谱的边类型是语义信息GCN 把所有关系混成同一种边会丢掉“导演”和“出演”的区别RGCN 为每个关系类型维护独立变换参数。如果评审追问复杂度承认 RGCN 参数量随关系数线性增长本题数据量小所以可控。第三个追问怎么保证测试没有信息泄漏回答要给出两层保障按 timestamp 排序后 8:2 切分以及建图只用训练集交互。最后在幻灯片里放一张评分分布柱状图和测试集覆盖率表格被问数据质量时直接指图作答这是控制答辩节奏最实用的一招。本文还有配套的精品资源点击获取
返回列表