基于知识图谱与图神经网络的电影推荐系统:KGCN实战解析

发布时间:2026/9/13 16:25:24
基于知识图谱与图神经网络的电影推荐系统:KGCN实战解析 简介一份完整高分毕业设计项目基于Python知识图谱与图神经网络实现电影推荐系统面向需要完成毕设、期末大作业或课程设计的高校学生。资源共31个文件以21个Python脚本为主体覆盖知识图谱构建、数据加载、KGCN模型实现、训练评估以及Web端推荐界面等核心模块另有5个dat数据集、说明文档与readme压缩包仅14.84MB部署便捷。代码注释详细模块划分清晰涵盖数据预处理、知识图谱构建、模型训练与评估、Web端推荐展示全链路整个工程目录结构清晰按数据、模型、工具、Web等模块组织新手也可按逻辑读懂完整算法流程便于二次开发与功能演示。资源内附使用说明与readme支持快速配置与调试项目经过严格测试可稳定运行能直接支撑毕业答辩或大作业展示。目前已有129人学习下载是高实用价值、导师认可的高分参考项目。1. 为什么毕设推荐系统开始扎堆用「知识图谱 图神经网络」大多数人做电影推荐系统毕设第一步想到的是协同过滤算相似度矩阵、找最近邻、输出 Top-N。代码好写但答辩时有两个很难绕开的问题——冷启动用户怎么推推荐结果有没有依据知识图谱和图神经网络的组合恰好把这两个问题换了种答法把电影的类型、导演、演员构造成三元组再把用户和电影的交互记录与三元组放进同一张图里用图神经网络把邻居信息一层层聚合到物品向量上。用户没看过某部电影但电影有自己的属性集合物品向量照样能算出来推荐依据从「你像谁」变成了「你连接了谁」。这个项目源码树里几个关键文件对应了完整链路data_process.py 清洗 MovieLens 评分数据create_kg.py 负责构建知识图谱三元组kg_loader.py 与 data_loader.py 把图和评分组织成 batchKGCN 目录下的 layer.py 与 model.py 是整个图卷积网络的核心train.py 和 evaluation.py 完成训练与指标计算web/app.py 提供一个可直接操作的界面。下面按「数据 → 加载 → 模型 → 训练 → 部署」的顺序拆开讲中间会给出可以直接复用的代码片段和参数说明。2. 从 users.dat 到 create_kg.py数据清洗与知识图谱构建图神经网络的电影推荐系统项目源码里最容易忽略但决定最终效果的是数据预处理这一步。很多论文里写「我们构建了一个包含 XX 三元组的知识图谱」代码里却只有一个 load 函数。这个项目把数据处理拆成了两个独立脚本data_process.py 负责把评分数据整理成模型需要的整数 ID 序列create_kg.py 专门把电影属性变成三元组文件。这两步分开做后续调试时能很清楚地定位问题是出在数据对齐还是模型结构上。2.1 MovieLens 三件套字段与读取方式data 目录下是 users.dat、ratings.dat、movies.dat经典的 MovieLens 格式。与常见 CSV 不同它的分隔符是两个冒号::读取时如果沿用默认的逗号分隔会得到一列脏数据。文件每行格式关键字段users.datUserID::Gender::Age::Occupation::Zip-codeUserIDratings.datUserID::MovieID::Rating::TimestampUserID, MovieID, Ratingmovies.datMovieID::Title::GenresMovieID, Title, Genres比如 ratings.dat 中的一行是1::1193::5::978300760代表用户 1 给电影 1193 打了 5 分。读取代码需要显式指定分隔符和列名import pandas as pd ratings pd.read_csv( data/ratings.dat, sep::, enginepython, names[user_id, movie_id, rating, timestamp] ) users pd.read_csv( data/users.dat, sep::, enginepython, names[user_id, gender, age, occupation, zip] ) movies pd.read_csv( data/movies.dat, sep::, enginepython, names[movie_id, title, genres] ) print(ratings.head())这里sep::是多字符分隔符pandas 的 C 引擎不支持所以必须加enginepython否则直接抛 ParserError。这个坑在 Windows 的 Jupyter Notebook 里非常常见。三个文件都没有表头names参数必须显式给出后续 join 时列名才能对上。读取完成后三个 DataFrame 是后续所有处理的起点这一步写对了后面数据流水线才不会出类型错乱的问题。2.2 data_process.py把原始分箱数据整理成模型输入data_process.py 做的不是简单的去重而是把 MovieLens 的原始字段压缩成模型真正需要的三列用户 ID、电影 ID、评分。还有一个很容易被忽略的操作是类型对齐movies.dat 里的 movie_id 是整数而 ratings.dat 里也是整数但如果不统一类型pandas 的 merge 会按 object 类型去匹配导致明明存在的数据匹配不上。常见做法是先显式转换再筛选列。ratings[movie_id] ratings[movie_id].astype(int) movies[movie_id] movies[movie_id].astype(int) # 只保留模型需要的字段 ratings ratings[[user_id, movie_id, rating]].sort_values(user_id) ratings.to_csv(data/ratings_clean.csv, indexFalse, headerFalse)这一步输出的ratings_clean.csv每行是user_id,movie_id,rating没有表头。后续 data_loader.py 读取时就按固定列名解析不再关心原始数据里有没有多余字段。排序是为了让相同用户的交互记录聚在一起batch 采样时更均匀。很多复现失败的场景都出在「没排序 随机切分导致训练集测试集有重叠」所以这里多做一步后面能少排查两小时。2.3 create_kg.py从电影属性中提取三元组知识图谱构建是这个项目的核心差异点。create_kg.py 把 movies.dat 里的每一部电影作为一个实体把电影的类型作为一种关系下的另一个实体构成(head, relation, tail)三元组。比如《盗梦空间》属于「科幻」类型就会生成(1, belongs_to, Sci-Fi)这样的三元组。实际项目中还可以把导演、演员扩展进去但第一步从类型开始成本最低也最容易验证链路通不通。kg_triplets [] for _, row in movies.iterrows(): movie_id row[movie_id] for genre in row[genres].split(|): kg_triplets.append((movie_id, belongs_to, genre)) kg_df pd.DataFrame(kg_triplets, columns[head, relation, tail]) kg_df.to_csv(data/kg.csv, indexFalse, headerFalse) print(kg_df.head(), triplets:, len(kg_df))需要强调的细节是 head 侧存的是 movie_id 而不是电影标题因为后续模型的 embedding 层用整数 ID 做索引如果用字符串标题还要再做一层映射徒增代码量。输出文件不用表头每行三个值之间用逗号分隔。构建完可以顺手统计实体数和关系数这些数字会直接决定 embedding 矩阵的 shape也是答辩时「数据集分析」章节的现成素材。3. kg_loader.py 与 data_loader.py把图和评分喂给模型数据加载是这套源码里最需要理清的部分。很多人把模型跑不起来归咎于显存或版本实际十有八九是数据加载时 shape 对不上。这个项目把加载拆成两个类kg_loader.py 负责将三元组文件转换为实体 ID 映射和邻接表data_loader.py 负责把评分数据切分成训练集与测试集并在每个 batch 内生成负样本。两个类在 train.py 中组合使用职责非常分明。3.1 kg_loader.py实体映射与邻接表构建KG 文件里是字符串形式的实体名模型没法直接用需要给每个实体分配一个整数 ID。kg_loader.py 的做法是遍历一遍三元组收集所有实体到集合中再枚举建映射。同时需要把每个实体的邻居关系存成邻接表后面图卷积采样时才不需要每次全图扫描。class KGLoader: def __init__(self, kg_path): self.entities set() self.relations set() self.triplets [] with open(kg_path, r, encodingutf-8) as f: for line in f: h, r, t line.strip().split(,) self.entities.update([h, t]) self.relations.add(r) self.triplets.append((h, r, t)) self.entity2id {e: i for i, e in enumerate(self.entities)} self.relation2id {r: i for i, r in enumerate(self.relations)} n_entity len(self.entities) self.adj_list [[] for _ in range(n_entity)] for h, r, t in self.triplets: self.adj_list[self.entity2id[h]].append( (self.entity2id[t], self.relation2id[r]) )这段代码的关键在于set()自动去重实体 ID 是从 0 开始连续排列的这样模型初始化 embedding 矩阵时vocab_size直接用len(self.entities)就行。邻接表里存的是(邻居实体ID, 关系ID)元组列表后续采样时一次取列表、一次 random.sample开销极小。注意这里没有做反向关系补充即只保留了 head 指向 tail 的方向如果希望图卷积能够双向传播需要在构建时把(tail, reverse_relation, head)也加进去常见做法是给关系 ID 加一个偏移量实现正反向区分。3.2 data_loader.py正负样本切分与 batch 生成data_loader.py 需要解决两件事一是把用户交互记录按比例切分为训练集和测试集二是为每个正样本搭配一个负样本。负样本的生成策略直接影响推荐效果这里用的是最稳妥的随机采样从全部电影中随机挑一个用户没有交互过的电影作为负样本。import random def train_test_split(interactions, test_ratio0.2): train, test [], [] for uid, items in interactions.items(): random.shuffle(items) split_idx int(len(items) * (1 - test_ratio)) for item in items[:split_idx]: train.append((uid, item, 1)) for item in items[split_idx:]: test.append((uid, item, 1)) return train, test def sample_negative(uid, pos_item, item_pool, neg_num1): negs [] while len(negs) neg_num: neg random.choice(item_pool) if neg ! pos_item: negs.append(neg) return negstrain_test_split是按用户维度切分确保同一个用户的交互不会同时出现在训练集和测试集里。切分时保持了 1 的正样本标签负样本在训练循环里临时生成而不是预处理时一次性生成这样每一轮 epoch 采到的负样本都不同相当于变相扩充了数据量。item_pool 就是全部电影的 ID 列表直接从上一节清洗后的数据里取。3.3 为什么每个 batch 都要现场采样邻居这是理解本项目训练流程的关键。假设知识图谱有 10 万个实体直接对每个物品的所有邻居做图卷积是不现实的内存和计算量都扛不住。KGCN 的做法是每个 batch 只对当前 batch 内的物品采样固定数量的邻居比如 8 个再对邻居的邻居也采样 8 个用一个两层的计算子图完成传播。这种「小批量采样」策略让每一轮的图结构都是随机生成的既控制了显存占用又天然带了 dropout 性质削弱过拟合。def sample_neighbors(entity_ids, adj_list, max_neighbor8): neighbor_entities [] neighbor_relations [] for eid in entity_ids: neighbors adj_list[eid] if len(neighbors) max_neighbor: sampled random.sample(neighbors, max_neighbor) else: sampled neighbors neighbor_entities.append([e[0] for e in sampled]) neighbor_relations.append([e[1] for e in sampled]) return neighbor_entities, neighbor_relations固定max_neighbor的目的是保证输出 shape 是[batch_size, max_neighbor]这样模型可以像处理定长序列一样处理图数据。如果邻居不足 8 个直接返回全部不做填充因为实际数据中尾部物品的邻居普遍偏少强行补零反而会引入噪声。这个函数的调用频率是每个 batch 一次所以在性能上要尽量轻量纯 Python 列表操作在这个规模下已经足够不需要引入额外数据结构。4. 图神经网络的电影推荐系统核心KGCN 的 layer.py 与 model.pyKGCN 是 Knowledge Graph Convolutional Networks 的缩写核心思想是用用户向量作为注意力来源在知识图谱上聚合物品多跳邻居的信息。这套思路比传统 GCN 多了一个「个性化」的设计同样的物品对不同用户聚合出的邻居权重不一样从而解决用户兴趣差异问题。项目里 KGCN 目录下的代码就是这一思想的直接实现。4.1 layer.py带用户注意力的邻域聚合单元layer.py 定义了单层图卷积的具体操作。输入有三个用户向量、当前物品的邻居实体向量、连接两者的关系向量。聚合的第一步是计算注意力分数用用户向量和关系向量做内积得到每个邻居对当前用户的重要程度再经过 softmax 归一化成权重。import tensorflow as tf class KGCNLayer(tf.keras.layers.Layer): def __init__(self, aggregatorsum): super().__init__() self.aggregator aggregator def call(self, user_embed, item_embed, neighbor_embed, relation_embed): # 注意力分数用户向量与关系向量的内积 scores tf.reduce_sum(user_embed * relation_embed, axis-1) scores tf.nn.softmax(scores, axis-1) # 邻居加权求和 neighbor_agg tf.reduce_sum( tf.expand_dims(scores, -1) * neighbor_embed, axis1 ) if self.aggregator sum: return item_embed neighbor_agg elif self.aggregator concat: return tf.concat([item_embed, neighbor_agg], axis-1) else: return neighbor_aggtf.reduce_sum(user_embed * relation_embed, axis-1)这一步是在算用户对某一种关系的感兴趣程度。比如喜欢科幻的用户和「belongs_to」这个关系向量的内积分数就高那么科幻邻居的权重就会偏大。softmax沿着邻居维度归一化保证权重和为 1避免聚合结果数值不稳定。最后三种聚合器的区别在于如何融合物品自身向量和邻居聚合向量sum 最简单concat 表达能力最强。实际调试时建议先用 sum 跑通全流程再换 concat 看指标是否有提升。4.2 model.py从输入到推荐得分的整体组装model.py 组装了完整的模型结构。输入是用户 ID 和物品 ID经过 embedding 层查表得到向量然后经过两层 KGCNLayer 传播。第一层聚合的是物品的一跳邻居第二层以第一层的输出作为输入再聚合邻居的邻居信息相当于捕获了二阶关系。代码结构清晰体现在层的复用上class KGCN(tf.keras.Model): def __init__(self, n_user, n_entity, n_relation, embed_dim32, n_layer2): super().__init__() self.user_embed tf.keras.layers.Embedding(n_user, embed_dim) self.entity_embed tf.keras.layers.Embedding(n_entity, embed_dim) self.relation_embed tf.keras.layers.Embedding(n_relation, embed_dim) self.layers [ KGCNLayer(aggregatorsum) for _ in range(n_layer) ] def call(self, users, items, neighbors_list, relations_list): user_vec self.user_embed(users) item_vec self.entity_embed(items) for layer, (neighbor_entities, neighbor_relations) in enumerate( zip(neighbors_list, relations_list) ): neighbor_vec self.entity_embed(neighbor_entities) relation_vec self.relation_embed(neighbor_relations) item_vec layer(user_vec, item_vec, neighbor_vec, relation_vec) score tf.reduce_sum(user_vec * item_vec, axis-1) return scoreembedding 层有一个容易踩坑的地方传入的 ID 必须是 int32 或者 int64Python 默认的 int 在 TF 2.x 下偶尔会报类型不匹配错误。邻居实体和关系在外部已经通过 sample_neighbors 函数转换成了整数 ID 列表这里直接查表即可。每一层的 item_vec 都是上一层的输出所以即使定义了三层、四层代码结构也不用改只需要改n_layer参数。最终分数用用户向量和物品向量的内积得到不做 sigmoid因为在计算 BPR loss 时直接对分数做差值会更稳定。4.3 三种聚合器的选择效果与开销对比聚合器的选择会影响最终推荐的精度和训练速度下表是三种方式在本项目中的具体差异聚合器融合方式参数开销典型场景sumitem neighbor无额外参数邻居信息与自身同等重要concat[item, neighbor] 拼接后过全连接增加一层全连接参数需要区分两者贡献度neighbor只用邻居聚合结果无额外参数物品自身向量质量差时从实践角度来看sum 聚合器在任何规模的数据集上都能快速收敛适合作为 baseline。concat 会在 embedding 维度翻倍后续全连接层的参数也随之增加在小数据集上容易过拟合。neighbor 聚合器最激进丢弃了物品自身的 embedding 信息只在邻居信息非常丰富的情况下才有效果。做消融实验时三种聚合器应该在同一套超参数下对比先看整体指标差异再看训练曲线的收敛速度判断是模型容量问题还是数据噪声问题。5. train.py 训练循环与 evaluation.py 评估模型结构定义好后训练环节决定了最终能不能复现出一个可用的推荐系统。train.py 的核心是 BPR 损失函数和负采样训练的循环。BPR 的思想很直接对于同一个用户正样本的得分要高于负样本的得分且差值越大越好。这个损失函数在隐式反馈场景下比 MSE 更合理因为它不关心评分的绝对值只关心排序关系。5.1 超参数设定与选择依据项目中没有给出固定的超参数文件但根据 KGCN 原论文的常用配置以及 MovieLens 数据集的规模可以从下面这组参数开始调试参数推荐值说明batch_size128太小梯度抖动过大太大会显存溢出n_neighbor8每跳邻居采样数8 是常见起点n_layer2两层捕获二阶信息三层信息更广但更稀疏embed_dim32向量维度越大表达能力越强但越容易过拟合learning_rate0.01Adam 默认学习率对 BPR loss 通常稳定epochs10MovieLens 1M 规模不需要跑太多轮这里没有选择 lr0.001 是因为 BPR loss 的梯度本身就比较平滑0.01 在多数情况下收敛更快而且配合 Adam 的自适应调整不会出现发散。如果你用更大的数据集比如 MovieLens 10M建议把 learning_rate 调回 0.005并增加一个学习率衰减策略。5.2 train.py 主体逻辑与损失函数实现optimizer tf.keras.optimizers.Adam(learning_rate0.01) for epoch in range(n_epochs): total_loss 0.0 for batch in batch_generator(train_data, batch_size): users, pos_items, neg_items batch # 采样邻居 pos_neighbors sample_all_neighbors(pos_items) neg_neighbors sample_all_neighbors(neg_items) with tf.GradientTape() as tape: pos_score model(users, pos_items, pos_neighbors) neg_score model(users, neg_items, neg_neighbors) loss -tf.reduce_mean(tf.math.log_sigmoid(pos_score - neg_score)) grads tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) total_loss loss.numpy() print(fepoch {epoch}, loss {total_loss:.4f}) if (epoch 1) % 5 0: model.save_weights(fcheckpoints/kgcn_epoch_{epoch}.h5)第 8 行的tf.math.log_sigmoid(pos_score - neg_score)是 BPR loss 的核心表达式前面加负号是为了把最大化问题转成梯度下降问题。训练时必须用tf.GradientTape()记录前向传播中的全部计算图否则tape.gradient拿到的是 None 而且不会有任何报错提示这属于 TF 2.x 里最常见的静默失败场景。checkpoint 每 5 轮保存一次中间结果可以用来回溯哪一轮开始过拟合。5.3 evaluation.py 的召回率与精确率计算评估部分用 Top-K 命中率来反映推荐质量。对测试集中的每个用户训练集未出现过的电影计算得分取分数最高的 K 部看看命中了几部测试集里的真实交互。def evaluate(model, test_data, all_items, kg_loader, k10): hits 0 total 0 for uid, pos_items in test_data.items(): items list(all_items - pos_items) scores [] for i in range(0, len(items), 200): batch_items items[i:i 200] neighbors, relations kg_loader.sample_all(batch_items) batch_scores model([uid] * len(batch_items), batch_items, neighbors, relations) scores.extend(batch_scores.numpy().tolist()) top_k_ids [items[i] for i in np.argsort(scores)[-k:][::-1]] hits len(set(top_k_ids) pos_items) total len(pos_items) recall hits / total precision hits / (len(test_data) * k) return recall, precision按 200 个物品一个 chunk 分批推理是为了避免一次把几万部电影全部塞进模型导致内存暴涨。只计算items - pos_items是防止用户已经看过的电影出现在候选集里这种评估方式叫「留一法」的简化版。最终输出两个指标recall 表示测试集中真实交互被推荐出来的比例precision 表示推荐列表里真实交互的占比。答辩时如果能画出 recall10 随 epoch 变化的折线图比只贴一个最终数字更有说服力。5.4 gpu_memory_growth.py 的作用显存按需分配gpu_memory_growth.py 这个文件很小但很关键它解决的是 TensorFlow 默认把 GPU 全部显存占满的问题。在课程设计答辩现场如果先跑了一个模型再跑这个推荐系统很容易出现 CUDA_OUT_OF_MEMORY。该文件通过以下方式启用显存按需增长import tensorflow as tf gpus tf.config.experimental.list_physical_devices(GPU) if gpus: try: tf.config.experimental.set_memory_growth(gpus[0], True) except RuntimeError as e: print(fGPU init error: {e})set_memory_growth(True)让显存在训练过程中按需申请而不是初始化时就全部占用。如果你用 CPU 跑这个文件不会报错只是没有任何效果。另外注意这段代码必须在创建任何 Tensor、模型或优化器之前执行所以它被单独拆成一个模块并在 train.py 和 main.py 的第一行 import顺序错了就白写了。6. web/app.py 部署与本地复现的运行排查模型训练完成后项目提供的 web/app.py 和 win.py 是两套交互入口。app.py 用 Flask 起一个本地 Web 服务输入用户 ID 返回推荐列表win.py 是 Windows 桌面版入口适合在开题、期中、答辩时直接演示。两者底层调用的都是同一个模型和同一份训练好的权重文件不同之处只在于交互载体。启动前需要确保模型权重已经保存在 checkpoints 目录下如果没训练过直接跑 Web 端会在加载权重时报找不到文件。python train.py python web/app.py访问 http://127.0.0.1:5000 后页面会提供一个输入框和一个推荐按钮。输入数据集中存在的用户 ID系统会调用 model.py 里重新实现的前向传播函数对候选电影集合逐个打分按 score 排序后把 Top-10 显示在页面上。这个 Web 端的前端逻辑很简单但它证明了模型已经完成序列化可以在非训练环境中加载权重进行推理这是毕设项目完整性的关键加分项。6.1 本地复现的常见坑先看数据在不在。刚解压源码后直接运行 train.py必须确保 data 目录下的 users.dat、ratings.dat、movies.dat 都已存在data_process.py 和 create_kg.py 需要按顺序先跑一遍生成中间文件不要跳过直接训练。如果压缩包已经内置了 kg.csv 和 ratings_clean.csv就可以直接从 train.py 开始。再看 Python 环境。项目依赖 tensorflow、pandas、flask建议用 conda 建一个干净环境Python 版本选 3.8 或 3.9 最稳。TF 2.10 以上在 Windows 上对 CUDA 版本要求严格如果只是复现效果直接用 CPU 版 tensorflow 即可MovieLens 1M 这个规模用 CPU 训练两个 epoch 也就几分钟不构成瓶颈。命令行里如果出现python was not found; run without arguments to install from the Microsoft Store说明没有勾选系统 PATH需要在安装 Python 时勾选 Add Python to PATH或者用py -3 train.py强制指定解释器。数据链路排查按这个顺序来先打印 kg_loader 里 entity2id 的长度如果为 0 说明 kg.csv 路径不对或者三元组分隔符不是逗号再打印训练集和测试集条数如果测试集为 0 说明用户交互数量太少test_ratio取 0.2 时分到 0 条需要调大test_ratio或换数据最后看 batch 的 shape[batch_size, n_neighbor]才能进 embedding 层。这三步走完多数训练报错都能定位到是数据问题而不是模型问题。本文还有配套的精品资源点击获取