ARTICLE DETAIL

资讯详情

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

知识图谱+图神经网络:突破协同过滤盲区的电影推荐实践

知识图谱+图神经网络:突破协同过滤盲区的电影推荐实践 简介一套基于Python的知识图谱与图神经网络GNN的电影推荐系统是面向高校毕业设计或课程设计的高分项目适合对推荐算法、知识图谱建模和TensorFlow实践感兴趣的开发者。压缩包共32个文件涵盖21个Python脚本、5个dat数据文件、3个txt说明、2个readme及1个md文档整体大小约12.69MB目录划分清晰。项目采用MovieLens 1M数据集运行环境为Python 3.8与TensorFlow 2.3.8实现上涉及数据预处理、知识图谱构建、KGCN知识图谱卷积网络层编写、模型训练、评估指标计算以及基于Flask的Web展示等完整流程。直接运行main.py可生成KCGN.h5模型再运行test.py即可输出预测结果方便后续二次开发或嵌入论文实验。目前已有489人学习下载尤其适合推荐系统方向毕设、课设需要可运行源码作为参考的学生。1. 协同过滤预测不了“为什么推荐”知识图谱加图神经网络才是差异推荐系统入门先学协同过滤它有一个明显盲区冷门电影、新电影和低频用户几乎没有交互记录模型只能把电影全部推向头部。电影类数据恰好有大量结构化上下文——导演、演员、类型、系列、出品方——把这些上下文建成知识图谱再靠图神经网络把实体之间的路径聚合成向量推荐就不再只依赖“多少人看过”而是能回答“这部片子和你偏好过的导演、演员与类型在图上隔了几跳”。这套链路在毕设里能同时撑起两个亮点一端是可以放在论文里展示的知识图谱构建另一端是可以从头实现并画出收敛曲线的图模型训练。数据源、网络框架、评估脚本全部落在 Python 技术栈内语义清晰工作量可拆解适合要同时拿出“工程量”和“模型改进”的毕业设计。下文按数据整理、模型设计、训练评估、推理技巧的顺序完整过一遍。2. 数据准备与知识图谱构建从 MovieLens 原始表到三元组2.1 数据源选择与实体对齐为什么毕设常从 MovieLens 出发起步用 MovieLens 1M 或 100K 拿用户行为再用 IMDb 或 TMDb 补电影属性。选 MovieLens 的原因很直接.dat文件本身就是::分隔评分规模、用户 ID 都是规整的不需要走爬虫论文数据来源部分也不会被质疑。而电影的中文资料往往散落在多个站点逐条抓演员表和导演反而耗时把力气花在和推荐效果无关的地方。跨表匹配时要注意年份和标题规范化。IMDb 的title.basics.tsv.gz里标题带(1999)后缀MovieLens 的movies.dat用的是“Toy Story (1995)”直接做字符串比较会失败。常见做法是把年份抽出来只按“去年份后的小写标题”做 join而不是拿 IMDbID 硬碰匹配率会明显提升。下面是一个标准化函数两侧数据都套同一套处理再合并import re def normalize_title(raw: str) - str: # 去掉年份后缀把分隔符统一成空格再做小写压缩 s re.sub(r\(\d{4}\), , raw) s re.sub(r[_\-/], , s.lower()) return .join(s.split()) # 对 MovieLens 标题和 IMDb 标题分别生成 key movies[key] movies[title].map(normalize_title) imdb[key] imdb[primaryTitle].map(normalize_title) matched movies.merge(imdb[[key, director, actors]], howleft, onkey)这里有个容易翻车的点同一部电影的系列名称在两边并不一致比如“Star Wars: Episode IV - A New Hope”标准化后仍然和 IMDb 的“Star Wars”对不上。这种匹配失败会导致系列片丢掉大量实体边。务实做法是把没匹配上的电影继续保留为纯用户-电影节点图谱边少一点不致命但把整张实体表删掉才会在答辩时被追问。对齐后实体数量通常会缩水两到三成这个比例写进论文方法节不回避即可。2.2 三元组关系设计把评分、导演、类型定义成可训练边图谱不是把所有字段搬进去边类型直接决定图神经网络能学到什么。下列关系表是电影推荐场景里常用的一套行号可以作为第 3 章代码里edge_type的索引依据关系名头实体尾实体构建规则说明rateusermovie评分 ≥ 4隐式正反馈belongs_tomoviegenre一部电影多条类型归属directed_bymoviedirector只取第一位导演导演实体acted_bymovieactor最多取前五位演员实体follow_seriesmoviemovie同系列相邻作品系列关联also_watchedmoviemovie按共同用户高分共现挖掘共现边最后一类also_watched会引入同构图边是让模型从“用户共现”里补一层信号的常用手段构建时要先算电影-电影共现矩阵只保留支持度大于阈值的边避免全连接。实际工程中把边统一导出成head_id, relation, tail_id三个字段ID 用字符串前缀区分类型u12、m345、p789这样可视化阶段看到 ID 的前缀就不会串。直接拿 pandas 就能构建完全部三元组。评分类边从 ratings 表过滤评分大于等于 4 的记录类型、导演、演员边则依赖 2.1 节的匹配结果展开。完整流程如下def build_triples(ratings, movie_meta): triples [] # 正反馈只保留 4 星以上的隐式交互 positive ratings[ratings[rating] 4] for _, row in positive.iterrows(): triples.append((fu{row.user_id}, rate, fm{row.movie_id})) # 电影-类型movie_meta 已经把 genres 拆成列表 for _, row in movie_meta.iterrows(): for genre in row[genres]: triples.append((fm{row.movie_id}, belongs_to, fg:{genre})) for actor in row.get(actors, [])[:5]: triples.append((fm{row.movie_id}, acted_by, fp:{actor})) if row.get(director): triples.append((fm{row.movie_id}, directed_by, fp:{row.director})) # 共现边保留出现次数 30 的电影对 cooccur positive.merge(positive, onuser_id) pair_cnt cooccur.groupby([movie_id_x, movie_id_y]).size() for (m1, m2), cnt in pair_cnt[pair_cnt 30].items(): if m1 ! m2: triples.append((fm{m1}, also_watched, fm{m2})) return triples代码里的iterrows只适合十万级三元组的构建把 1M 数据全量滑过会慢但毕设通常只取 100K 子集运行时间在几分钟内可以接受真要加速就换成to_records(indexFalse)配合列表推导。also_watched阈值 30 在 MovieLens 1M 上通常得到几千条边如果结果过多就提阈值到 50边越多第 3 章模型的邻接消息矩阵就越大并不一定代表质量更高。2.3 图存储选型Neo4j 适合展示内存图结构适合训练有人会把“知识图谱构建”理解成必须上 Neo4j但训练图神经网络时直接用torch_geometric的HeteroData更省力所有节点和边都在内存里没有序列化、连接池和查询开销。Neo4j 的价值更多在答辩演示——从某个用户出发能看到按层展开的推荐解释路径。把第 2.2 节生成的 CSV 批量导入 Neo4j 用来展示语法如下LOAD CSV WITH HEADERS FROM file:///triples.csv AS row MERGE (h:Entity {name: row.head}) MERGE (t:Entity {name: row.tail}) MERGE (h)-[r:REL {type: row.relation}]-(t)用MERGE而不是CREATE能避免重复边否则同一部电影的类型边会在多次导入时成倍堆积。在训练侧真正喂给模型的是一套统一的整数索引把所有头实体和尾实体做字典编码edge_index保存为[2, num_edges]的长整型张量edge_type保存边的关系 id。下面是从三元组转成 PyG 数据对象的最小片段from torch_geometric.data import HeteroData data HeteroData() # 每个实体分配 0..N-1 的整数 id含义记录在独立映射表里 edge_index torch.tensor(edges_id, dtypetorch.long) data[user].num_nodes n_user data[movie].num_nodes n_movie data[entity].num_nodes n_entity # 按三元组把头尾类型划分到不同关系键上 data[user, rate, movie].edge_index edge_index_um data[movie, belongs_to, entity].edge_index edge_index_mg data[movie, acted_by, entity].edge_index edge_index_ma data[movie, directed_by, entity].edge_index edge_index_md如果不打算用 R-GCN 这类关系感知模型也可以把所有节点类型统一放进一张表关系数量自然变少那种做法实现简单但会丢掉“导演”和“演员”在语义上的差别下一章说明为什么关系感知权重通常值回为此付出的开销。3. 图神经网络模型结构关系感知消息传递与嵌入设计3.1 选 R-GCN 还是 LightGCN关系种类少时直接上 R-GCN知识图谱里的边带类型用户到电影是“rate”电影到导演是“directed_by”两者在推荐中的含义完全不同。LightGCN 把所有边等同看待只传播不区分关系适合纯用户-物品图一旦加入导演、演员实体等同对待会让“用户-类型”和“用户-演员”的信号被平均最终嵌入被高频实体主导。R-GCN 对每种关系维护独立的变换矩阵消息只沿当前关系的投影聚合能保留“看过某导演电影的人”和“喜欢某类型的人”之间的差异。电影图谱的关系数不到十种W_r参数量完全可以承担关系数一旦超过几十种显存压力会陡增那是另一个话题。为了直接在 PyTorch 里跑通节点初始化可以暂时统一为一个嵌入表所有实体共享一套浮点矩阵只是按顺序分配 id。这样模型代码更短调试时打印edge_type也更直观提示先统一节点表再用edge_type区分语义是快速跑通的最小实现。等指标稳定了再把它拆成HeteroData的三类节点也不迟。3.2 从零写一个关系 GCN 层公式与代码对应R-GCN 每一层计算分成两步。第一步每个节点沿每种关系边把邻居特征通过该关系的权重矩阵做线性变换第二步把所有关系加总叠加上节点自己的自环变换再过非线性激活。聚合默认做求和也可以按邻居数量归一化。代码取两层叠加即可import torch import torch.nn as nn import torch.nn.functional as F class RelationGCNLayer(nn.Module): def __init__(self, in_dim: int, out_dim: int, num_relations: int): super().__init__() # 每种关系独立线性层体现出关系感知 self.rel_linear nn.ModuleList([ nn.Linear(in_dim, out_dim, biasFalse) for _ in range(num_relations) ]) self.self_loop nn.Linear(in_dim, out_dim, biasFalse) self.norm nn.LayerNorm(out_dim) def forward(self, x, edge_index, edge_type): out self.self_loop(x) # 保留节点自身信息 src, dst edge_index # 按关系分组聚合邻居消息 for r, linear in enumerate(self.rel_linear): mask edge_type r msg linear(x[src[mask]]) out out.index_add_(0, dst[mask], msg) return self.norm(F.relu(out))这段实现里x是[num_nodes, in_dim]的节点嵌入edge_index存边的端点edge_type存关系索引。index_add_是图神经网络聚合的标准操作按目标节点 id 做累加比message_passing的接口更透明也方便把中间张量取出来检查。注意边的方向处理如果按无向图解释需要把正反两个方向都拼进edge_index反方向配独立的edge_type否则消息只能单向流动。想在模型里体现“注意力”时可以在聚合前给每条边算一个权重权重由两端嵌入的内积经过 sigmoid 得到。这个微型关系图注意力层在答辩时很好讲只加三行# 边注意力权重邻居重要性随训练自动调整 pred torch.sigmoid(torch.sum(linear(x[src[mask]]) * x[dst[mask]], dim-1)) weight pred.unsqueeze(1) out out.index_add_(0, dst[mask], weight * linear(x[src[mask]]))这样写出的边权是“源节点与目标节点经过同一关系变换后的匹配度”可视化时甚至能导出某部电影得分最高的几条关系路径解释性比黑盒模型好不少。3.3 嵌入初始化与超参配置图谱节点要随机还是预训练一个常见误区是给每个类型、导演实体做 one-hot再塞进模型。影视图谱的实体数量少则几千多则几十万one-hot 会让未出现过的实体完全失效而且使稀疏叶子节点变成噪声源。更可控的方案是全部节点都随机初始化把嵌入矩阵放进优化器里一起训练。如果想让论文表达“知识图谱额外提供了信息”也可以把类型名用 Sentence-BERT 抽一个平均向量做初值但对这个毕设规模初值影响通常很小随机初始化加足量训练就能收敛。层数不需要多经验上两层就够。消息传递每加一层节点就能多感知一跳的邻居三层以上在稀疏电影图上容易过平滑所有节点嵌入趋向一致召回率曲线会出现下拐点。主模型维度设 64Dropout 设 0.2训练集偏小时把 Dropout 提到 0.3 并加LayerNorm能明显降低训练曲线的抖动。这套默认参数如下参数建议值理由GNN 层数23 层以上容易过平滑隐藏维度641M 数据量下容量足够Dropout0.2稀疏实体多时再高会让收敛变慢学习率1e-3Adam 下的稳定起点关系数6对应第 2.2 节的关系表最终预测把用户嵌入和电影嵌入做点积得到用户对电影的得分。这里刻意不接多层感知机目的就是保持可解释得分高时可以反查是哪些关系的邻居贡献了大分。4. 训练与评估BPR 损失、负采样与排序指标4.1 负采样策略为什么随机负例让模型只学会流行度电影推荐在大多数数据集上是隐式反馈训练的目标是让正样本边的得分高于负边。正样本取已有的高分边负样本在每个 batch 里从用户没看过的电影中采样。这里不能图省事做全图随机如果用户常看科幻而负例全从数据库所有电影里随机抽模型最后学会的是“流行度排序”个性化差异被流失。常用做法是 batch 内负采样——把同一个 batch 里其它用户的正样本电影当作当前用户负例def batch_neg_sampling(user_ids, movie_ids, num_movie, neg_num1): negs [] for u, m in zip(user_ids, movie_ids): # 随机选电影作为负样本不精确排除正例但够用 neg torch.randint(num_movie, (neg_num,)) negs.append(neg) return torch.stack(negs)这段代码没有真正排除已经看过的电影会混入小概率噪声换来的是实现简单与训练稳定通常可以接受。要做精确排除时把每个用户的正样本集合存成set采样命中时循环重抽即可但训练会变慢。毕设里两种都行论文实验小节写明用的是哪种采样方式就好。4.2 BPR 损失让正样本比负样本打分更高的排序约束排序类推荐默认使用 BPR 损失目标是最大化正样本和负样本之间的打分差形式是对数几率-log(sigmoid(pos_score - neg_score))。BPR 天然是一对一约束不像交叉熵要算全类别概率实现更贴推荐场景。加 L2 正则能约束用户和电影的嵌入范数防止稀疏实体直接学飞def bpr_loss(pos_score, neg_score, embeddings, l2_reg1e-5): # 正负样本得分差越大损失越小 loss -torch.log(torch.sigmoid(pos_score - neg_score) 1e-8).mean() # 全部嵌入拼起来算 L2 范数 l2 torch.cat([e.flatten() for e in embeddings]).norm(2) return loss l2_reg * l2这里pos_score是正样本对点积neg_score是同一用户与负样本电影的点积。1e-8防止负差值过大时log计算出现负无穷。负采样数量neg_num设 1 即可它控制每个正样本对应多少负例设大会提升训练稳定性但会拉慢收敛。训练循环用朴素for epoch写更直观不必额外引 PyTorch Lightning。4.3 训练主循环与验证集切分按时间顺序切分才谈得上泛化毕设里最常见的错误是随机切分把同一用户同一时期的打分拆进训练和验证评估结果虚高。正确做法是把用户的时间戳排序前 80% 做训练后 20% 做验证验证时每个用户只保留最后消费的五条电影推荐结果就能对比“预测的下一次消费是否真的发生”。核心代码如下ratings[ts] ratings[ts].astype(int) ratings ratings.sort_values([user_id, ts]) train_parts, valid_parts [], [] for uid, group in ratings.groupby(user_id): group group.sort_values(ts) # 前 80% 做训练最后 5 条做验证 split max(1, int(len(group) * 0.8)) train_parts.append(group.iloc[:split]) valid_parts.append(group.iloc[split:].tail(5))valid_parts里每个用户保留最多五条边用“将来时”的样本评估推荐效果。注意总量少于六条评分的用户80% 切分会把验证集抽空这时直接跳过该用户否则评估阶段会出现大量空集合整个指标不再可信。配合早停会更稳。每五个 epoch 看一次验证集指标连续三次没有提升就降低学习率或直接终止并保留最后一版最优参数。电影推荐线上波动大死磕固定 epoch 数往往过拟合。4.4 评估指标Hit10、NDCG10 的实现与理解离线指标衡量排序质量。Hit10 是预测的前十条里有没有命中用户未来真实消费的电影NDCG10 在命中基础上额外惩罚把正确结果排太靠后的情况Recall10 是用户未来消费里有多少被前十条覆盖。三者都依赖“对每个用户从候选池全量打分”这一步def evaluate_hit_ndcg(model, valid_edges, all_movie_ids, k10): hit ndcg 0.0 for uid, popular_movies in valid_edges.items(): score torch.sigmoid(model.predict(uid, all_movie_ids)) topk score.topk(k).indices.cpu().tolist() hits set(popular_movies) set(topk) if hits: hit 1.0 # 按命中最靠前位置计算 NDCG 分值 rank min([topk.index(m) for m in hits]) 1 ndcg 1.0 / math.log2(rank 1) return hit / len(valid_edges), ndcg / len(valid_edges)评估循环把全量电影都过一遍打分这会拖慢验证速度所以每五个 epoch 只评估一次或随机抽 1000 个用户做子集评估。如果 Hit10 高得异常先检查负样本池是否太小全量电影作为候选池时 Hit10 并不能直接横向对比论文数据写实验时必须注明候选集规模。默认超参组合如下超参数名默认值调参优先级batch_size1024显存不足就减半neg_num1影响训练稳定性lr1e-3不收敛先降到 3e-4l2_reg1e-5过大嵌入退化epochs50十轮无提升则早停5. 推理阶段的 3 个实战技巧候选集裁剪、嵌入缓存与冷启动处理5.1 离线候选集裁剪把全量打分改成粗排加精排服务端给每个用户全量打分一次要遍历上万部电影实际可能被点开的只有一小部分。离线先把“用户看过的电影类型 join 出的电影”和不低于平均分的头部电影混合成 200 到 500 条的候选池在线推理只在这个池子里做图模型打分单用户耗时可以压到全量评分的十分之一以下。毕设演示时也更容易画出“用户 → 候选池 → Top10”的链路而不是一张黑盒打分清单。5.2 嵌入缓存训练完成后只遍历一次模型收敛后用户和电影的嵌入已经固定推荐打分本质是点积。把训练好的user_embedding和movie_embedding抽出来计算每个用户与全量电影的点积并按用户分桶存下 Top100 结果就构成了“嵌入缓存 TopK 表”的最小服务模式推理直接查表不再过模型。用 FAISS 可以替代暴力 TopKimport faiss # 训练好的电影嵌入按行排成 (num_movie, emb_dim) index faiss.IndexFlatIP(movie_emb.shape[1]) index.add(movie_emb) # 单个用户嵌入做内积检索返回 top 20 的电影下标 sims, idx index.search(user_emb.reshape(1, -1), 20)IndexFlatIP是精确内积索引十万级电影以内速度足够这时还不需要上 IVFPQ 近似索引。进 FAISS 之前先把嵌入归一化否则内积混入范数因子推荐会偏向模长更大的嵌入。缓存后的推理不再依赖模型新增的评分反馈可以攒够一批后重训并重建缓存。5.3 冷启动处理从图谱走一跳或两跳路径新用户没有历史交互嵌入表和缓存全部失效。此时知识图谱还能救回一部分体验取新用户注册时选择的偏好类型或主动看过的一部电影沿belongs_to和also_watched两条关系各做一跳扩散把扩展结果按类型和导演统计频次作为冷启动候选的特征向量投影到嵌入空间。这条路不经过训练好的网络本质是图谱引导的规则推荐但它负责处理系统里唯一无法被模型覆盖的新用户场景。实现时给所有全新实体挂一个全局平均嵌入做兜底让分数不为零反馈数据积累到阈值后重新触发一次增量训练再刷新缓存表。本文还有配套的精品资源点击获取
返回列表