简介:这是一套面向毕业设计场景的电影推荐系统Python项目,核心思路是将知识图谱与图神经网络结合用于个性化推荐,适合计算机相关专业学生作课程设计或毕设参考,也适合推荐系统初学者学习算法落地;数据侧采用经典的MovieLens 1M电影评分数据集,运行环境为Python3.8与TensorFlow2.3.8,代码结构清晰,训练得到模型后即可输出推荐结果。资源包内共包含三十二个文件,主体是二十一个Python脚本,分别负责数据集处理、知识图谱构建、图神经网络模型搭建、训练与评估等关键环节;同时附带五个数据文件、三个文本说明、两个说明文档和一个Markdown笔记,便于快速上手,整个资源包大小仅为十二点六九MB,下载部署都很方便。已有四百九十人学习或下载该资源,对希望快速掌握知识图谱推荐方案的同学,这套项目提供了从原始数据清洗、图谱关系构建、模型训练到结果预测的完整可运行代码,可作为毕设代码基础或进一步扩展起点,实用价值较高。
1. 从协同过滤到图神经网网络:这份项目到底解决了什么问题
做过推荐系统的同学都知道,传统的协同过滤在面对冷启动用户和稀疏评分矩阵时,表现往往不够理想。而基于 Python 的知识图谱和图神经网络电影推荐系统,核心思路是在用户和物品之间插入一张知识图谱——电影和电影之间通过导演、演员、类型、标签等实体建立关联,再利用图神经网络把这些多跳关系注入到推荐模型里。这套项目选用 ml-1m 数据集,技术栈锁定 python=3.8 + tensorflow=2.3.8,工程结构上区分了数据预处理、知识图谱构建、KGCN 模型训练与结果预测几个模块,适合需要完成毕设或课程设计的同学直接拿来做复现学习和二次开发。
打开项目后,主流程非常短:先运行 main.py 生成 KCGN.h5 权重文件,再运行 test 脚本完成预测。看起来简单,但实际跑通和调参的过程里藏着不少细节,这篇笔记就按数据准备、模型结构、训练实操、常见坑和验证方法五个层面拆开讲。
2. 数据准备与知识图谱构建:ml-1m 怎么变成可计算的图
2.1 三个原始数据文件和 data_process.py 的字段血缘
项目的数据源头是 MovieLens 1M 数据集,对应文件是 users.dat、ratings.dat、movies.dat。三个文件的原始格式都是::分隔的文本,和常见的 CSV 不太一样。users.dat 每行是UserID::Gender::Age::Occupation::Zip-code,ratings.dat 每行是UserID::MovieID::Rating::Timestamp,movies.dat 每行是MovieID::Title::Genres,其中 Genres 是|分隔的多标签。
data_process.py 的职责就是把这三份原始文件解析成后续训练可用的结构,通常的处理方式是先把::替换成标准分隔符,再过滤掉无效行和缺失字段。
import pandas as pd # 读取 ratings.dat,原始分隔符是 :: ratings = pd.read_csv('data/ratings.dat', sep='::', names=['UserID', 'MovieID', 'Rating', 'Timestamp'], engine='python') # 读取 movies.dat,注意 Genres 字段内部以 | 分隔 movies = pd.read_csv('data/movies.dat', sep='::', names=['MovieID', 'Title', 'Genres'], engine='python', encoding='latin-1') # ratings 只保留 UserID、MovieID、Rating 三列用于后续训练 ratings = ratings[['UserID', 'MovieID', 'Rating']] print(ratings.head())这里的sep='::'是关键——如果用默认逗号去读会直接把整行读成一个字段。engine='python'是因为::属于多字符分隔符,C 引擎不支持。编码方面 movies.dat 因为包含特殊字符,建议显式指定latin-1而不是 UTF-8,否则部分片名里的非 ASCII 字符会直接抛 UnicodeDecodeError。
处理完后通常会做一步索引对齐:把 UserID 和 MovieID 映射到从 0 开始的连续整数编号,因为后面的嵌入层需要输入连续索引,如果原始 ID 有空洞,训练过程中会出现索引越界。映射方式就是一个简单的字典或 pandas 的 factorize。
# 将用户和电影 ID 映射到连续索引,避免嵌入层索引越界 user_ids = ratings['UserID'].unique() movie_ids = ratings['MovieID'].unique() user2idx = {u: i for i, u in enumerate(user_ids)} movie2idx = {m: j for j, m in enumerate(movie_ids)} ratings['user_idx'] = ratings['UserID'].map(user2idx) ratings['movie_idx'] = ratings['MovieID'].map(movie2idx) print('用户数:', len(user2idx), '电影数:', len(movie2idx))2.2 create_kg.py 背后的实体-关系-三元组逻辑
知识图谱不是数据集自带的,而是要从已有数据里自动生成。create_kg.py 的核心逻辑是抽取三元组(头实体, 关系, 尾实体),这决定了图神经网络能捕获哪些语义关联。对于电影推荐场景,常见的做法是把电影作为头实体,把导演、演员、电影类型、上映年份等属性作为尾实体,属性名作为关系类型。
# 伪代码示意:从 movies.dat 抽取知识图谱三元组 triples = [] # 电影 -> 类型 for movie_id, genres in movie_genres.items(): for genre in genres: triples.append((movie_id, 'genre', genre)) # 电影 -> 导演/演员(需要额外数据或解析片名) # 如果项目内部只基于 ml-1m,最常见的是类型作为关系 with open('data/kg.txt', 'w', encoding='utf-8') as f: for h, r, t in triples: f.write(f'{h}\t{r}\t{t}\n') print('三元组数量:', len(triples))kg.txt 是知识图谱的文件落地形态,每一行是一个三元组,这三个字段分别是电影 ID、关系类型、属性实体 ID。kg_loader.py 负责把这个文件读入内存,并建立邻居映射。这个步骤的输出直接决定模型能看到几跳的图信息——如果只抽了类型一种关系,用户从《盗梦空间》能关联到同为科幻片的《星际穿越》;如果补上演员关系,还能通过诺兰这个中间节点关联到其他导演的作品。
数据准备到这里有一个容易被忽略的关键点:训练样本里的正反馈和知识图谱的实体数量需要嵌套映射,不能出现 Training 里出现的电影在主图中不存在的孤立点。如果某个电影 ID 在 ratings.dat 里出现过,但因为没有类型信息而没进三元组,这个电影在后续邻居采样时拿到的是空列表,训练过程会出现 shape 不匹配的报错。常见做法是构建知识图谱之后,把所有主图中存在的电影 ID 集合与训练样本做一次交集过滤。
3. KGCN 模型核心:layer.py、model.py、tool.py 各层拆解
3.1 layer.py 里做的不是图卷积,是邻域聚合
KGCN 全称 Knowledge Graph Convolutional Network,它和普通的 GCN 最大的区别在于每一层不是对整个图做拉普拉斯变换,而是对一个实体在知识图谱上的 N 跳邻居做采样聚合。layer.py 在这个项目里实现的正是单层聚合:给定头实体(电影)的嵌入向量,去它的邻居集合里采样固定数量(比如 N=4)的尾实体,然后对邻居嵌入做注意力加权和。
# layer.py 中聚合层的关键计算逻辑(简化示意) import tensorflow as tf class Aggregator(tf.keras.layers.Layer): def __init__(self, input_dim, output_dim, neighbor_num=4): super().__init__() self.neighbor_num = neighbor_num # 两个全连接变换,分别处理自身信息和邻居信息 self.self_w = tf.keras.layers.Dense(output_dim) self.neigh_w = tf.keras.layers.Dense(output_dim) def call(self, entity_emb, neighbor_emb): # entity_emb: [batch, dim] 当前物品嵌入 # neighbor_emb: [batch, neighbor_num, dim] 采样到的邻居嵌入 neighbor_agg = tf.reduce_mean(neighbor_emb, axis=1) # 先求均值 # 自身嵌入和邻居聚合结果拼接后过全连接 concat = tf.concat([entity_emb, neighbor_agg], axis=-1) return tf.nn.relu(self.neigh_w(concat))逻辑上,这个 Aggregator 做了三件事:对邻居嵌入取均值(也可以用 attention 替代)、将自身嵌入与邻居聚合结果拼接、过一层全连接得到更新后的实体表示。neighbor_num是超参数,设小了会丢失图结构信息,设大了会让每个 batch 的张量非常重。原项目里默认是 4,实际调整时可以根据图谱平均出度决定。
更细一点说,KGCN 和 GCN 的区别还体现在归一化策略上。GCN 用的是拉普拉斯归一化系数1/sqrt(deg(u)*deg(v)),而 KGCN 采用的是在采样后的邻居集合内做 softmax 归一化。layer.py 里如果能看到tf.nn.softmax的调用,就说明作者做了 attention 式聚合,这比纯均值聚合在准确率上通常高出一截。
3.2 model.py 里完整链路:从用户嵌入到评分预测
model.py 是模型装配的核心文件,它把用户、物品、知识图谱三路信息组合成最终的预测评分。整体拓扑是:用户 ID 查用户嵌入表,物品 ID 查物品嵌入表,再通过知识图谱结构反复做领域聚合,最后把用户嵌入和物品嵌入做内积得到预测分。
# model.py 模型组装伪代码,体现用户/物品/图信息的组合方式 class KGCN(tf.keras.Model): def __init__(self, user_num, entity_num, relation_num, embed_dim, neighbor_num): super().__init__() self.user_emb = tf.keras.layers.Embedding(user_num, embed_dim) self.entity_emb = tf.keras.layers.Embedding(entity_num, embed_dim) # 关系嵌入同样建表,用于注意力计算 self.relation_emb = tf.keras.layers.Embedding(relation_num, embed_dim) self.aggregators = [Aggregator(embed_dim, embed_dim) for _ in range(2)] def call(self, user_ids, item_ids, neighbor_entities, neighbor_relations): user_vec = self.user_emb(user_ids) # [batch, dim] item_vec = self.entity_emb(item_ids) # [batch, dim] # 第一跳邻居聚合 neigh_vec = self.aggregators[0](item_vec, neighbor_entities) # 第二跳可以再次聚合,加深图信息传播 final_vec = self.aggregators[1](neigh_vec, ...) # 用户嵌入与融合后物品嵌入做内积 logits = tf.reduce_sum(user_vec * final_vec, axis=-1) return tf.nn.sigmoid(logits)这里的entity_num在代码里往往是kg_loader返回的最大实体索引加一,而不是原始电影数量。因为知识图谱里每个类型、每个演员都是一个独立实体,所以嵌入表的大小会比电影数量大很多。用户在跑项目时如果碰到IndexError或者嵌入表维度不匹配,优先检查这里的实体数量是不是和 kg.txt 的最大 ID 对齐了。
另外要注意call方法里输入参数的组装顺序。项目里 main.py 准备 batch 时通常返回五元组(用户列表、正样本物品列表、负样本物品列表、邻居实体集、邻居关系集),顺序必须和model.call的定义保持一致,这一块是最容易漏改导致 shape 报错的地方。
3.3 tool.py 和 gpu_memory_growth.py:被忽略但决定能否跑动的工具
tool.py 在项目里承担了很多杂活,通常包括:计算互信息或相似度矩阵、构建稀疏矩阵对输入数据做预处理、保存和加载嵌入向量等。其中一段常用的逻辑是把用户评分矩阵转换成稠密或稀疏的交互矩阵,供后续评估时计算召回率。
# tool.py 中常见的交互矩阵构建逻辑 import numpy as np from scipy.sparse import coo_matrix def build_interaction_matrix(user_list, item_list, rating_list, user_num, item_num): # 构建 user-item 稀疏矩阵 rows = np.array(user_list) cols = np.array(item_list) data = np.array(rating_list, dtype=np.float32) mat = coo_matrix((data, (rows, cols)), shape=(user_num, item_num)) return mat.tocsr()gpu_memory_growth.py 则是 GPU 显存使用策略的配置文件。TensorFlow 2.x 默认会抢占所有可用显存,如果你是在共享服务器上跑,很容易把机器搞崩。这个文件的作用是通过set_memory_growth把显存分配改成按需增长。
import tensorflow as tf gpus = tf.config.experimental.list_physical_devices('GPU') if gpus: for gpu in gpus: tf.config.experimental.set_memory_growth(gpu, True)如果项目根目录里存在这个文件,建议在 main.py 的第一行就 import 它,而不是等网络建立之后再设置。因为显存策略一旦在图创建之后再修改,TensorFlow 会直接忽略。常见做法是:
import gpu_memory_growth # 放在所有 import 的最前面,先声明显存策略这里还有一个版本相关的细节:TF 2.3.8 里tf.config.experimental.set_memory_growth可用,但如果你把环境升级到 TF 2.8+,这个写法依然兼容,不过会有一条 deprecation 警告。用 2.3.8 的朋友可以无视。
4. main.py 训练全流程:从原始数据到 KCGN.h5 的实时链路
4.1 main.py 的执行顺序拆解
main.py 是整个项目的入口,从零开始训练到产出权重文件。它的执行顺序通常是这样的:加载 ratings 数据和 kg 三元组、建立索引映射和邻居字典、生成正负样本 batch、实例化 KGCN 模型、开始训练循环并周期性保存模型。
# main.py 训练主流程(结构示意) import tensorflow as tf from data_loader import load_ratings, load_kg from model import KGCN from tool import generate_sampled_batch # 1. 加载数据 ratings = load_ratings('data/ratings.dat') kg = load_kg('data/kg.txt') # 2. 构建模型 model = KGCN(user_num=len(user2idx), entity_num=kg.entity_num, relation_num=kg.relation_num, embed_dim=32, neighbor_num=4) # 3. 自定义损失函数和优化器 optimizer = tf.keras.optimizers.Adam(learning_rate=0.01) loss_fn = tf.keras.losses.BinaryCrossentropy() # 4. 训练循环 for epoch in range(10): for batch in generate_sampled_batch(ratings, kg, batch_size=256): user_ids, pos_items, neg_items, neighbors, rels = batch with tf.GradientTape() as tape: pos_score = model(user_ids, pos_items, neighbors, rels) neg_score = model(user_ids, neg_items, neighbors, rels) loss = loss_fn(tf.concat([pos_score, neg_score], axis=0), tf.concat([tf.ones_like(pos_score), tf.zeros_like(neg_score)], axis=0)) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) # 5. 保存权重文件 model.save_weights('KCGN.h5')这里采用的是负采样训练方式:对每个用户的正样本电影,随机采样同等数量的未交互电影作为负样本,然后让模型学会打分正样本高于负样本。评分标签是二值化的:1 表示用户看过,0 表示没看过,这样的设定完全贴合隐式反馈推荐场景。
save_weights('KCGN.h5')保存的只是模型权重,不是完整模型结构。这意味着 test 脚本里必须重新定义一模一样的模型结构,然后再 load 权重,否则会出现 mismatched shape 的报错。这一点很多第一次跑项目的同学会忽略,直接 load 一个 .h5 文件却忘了重建模型类。
4.2 核心超参数的作用与调整建议
下表整理了该项目几个关键超参数,以及它们对训练过程的实际影响。实际调参时不要一次性全改,最好一次只动一个变量。
| 超参数 | 典型值 | 作用 | 调整倾向 |
|---|---|---|---|
| embed_dim | 32 | 用户和实体嵌入维度 | 维度越大表达力越强,但小数据集上容易过拟合 |
| neighbor_num | 4 | 邻居采样个数 | 图谱稠密时可加大到 8,稀疏时减小 |
| learning_rate | 0.01 | 优化器学习率 | loss 震荡时按 0.5 倍衰减 |
| batch_size | 256 | 每批样本数 | 显存不足时减半 |
| l2_reg | 1e-5 | 正则化系数 | 出现过拟合时适当增加 |
| epoch | 10 | 完整训练轮数 | 看验证集 loss 决定是否提前停止 |
邻域采样数量这个参数在知识图谱场景下特别值得关注。电影知识图谱的"类型"关系平均出度可能很高(几百部电影同属科幻),而"演员"关系出度相对适中。如果 neighbor_num 设成 4,等于每层只随机看 4 个邻居,信息利用率会比较低;但设成 32 后,每个 batch 需要一次性加载 32 个邻居嵌入,显存占用线性增长。我一般习惯先把 neighbor_num 固定在 8,跑通后再根据显存余量往上加。
运行 main.py 时,如果 TensorFlow 日志里反复出现loss数值波动较大,优先检查负采样逻辑是否正确——负样本如果采样到了用户真正看过的电影但没有被过滤掉,模型会收到冲突的学习信号。常见做法是在生成负样本时加一个剔除集合判断,确保负样本不在用户的历史交互列表中。
训练结束后,项目根目录会多出一个 KCGN.h5 文件。这个文件的大小和嵌入维度相关,如果 embed_dim=32 且实体数量较多,权重文件通常在几十到上百兆之间。需要注意,这个文件是整个预测链路的基石,test.py 依赖它做预测,丢了就只能重新训练。
5. 避坑与排查:从环境配置到结果异常的五个实操记录
5.1 TensorFlow 2.3.8 与 Python 3.8 的组合兼容性问题
现象:按照 README 用 python=3.8 + tensorflow=2.3.8 安装后,import tensorflow 报错Could not load dynamic library 'cudart64_110.dll'。
原因:TensorFlow 2.3.8 默认是 CUDA 11.0 的编译版本,但机器上可能装了 CUDA 11.2 或更高版本,导致运行时找不到对应的动态链接库。
解决:安装 2.3.8 之前先确认 CUDA 版本。如果用的是 CUDA 11.2 及以上,可以直接升级 tensorflow 到 2.4+,或者用 conda 建一个 CUDA 11.0 的环境。我自己的做法是直接用 conda 创建环境时指定cudatoolkit=11.0和cudnn=8.0,再装 tensorflow 2.3.8,基本零报错。
5.2 main.py 二次执行时 KCGN.h5 被直接覆盖
现象:第一次训练正常,第二次改了几个参数重新跑 main.py,发现之前训练好的 KCGN.h5 被静默覆盖了,没有提示也没有备份。
原因:main.py 里用的model.save_weights('KCGN.h5')对同名文件是直接覆盖写入的,TensorFlow 不会自动生成备份文件。
解决:每次训练前把旧的权重文件改名,或者用带时间戳的保存路径。另一个做法是在 main.py 开头加一个检测逻辑,如果文件存在就自动重命名为KCGN_old.h5。这样万一新模型效果不如旧模型,还有后悔药可以吃。
5.3 实体索引对不齐导致嵌入层维度 mismatch
现象:运行 test.py 加载 KCGN.h5 时,提示Shapes (None, 32) and (None, 128) are incompatible。
原因:test.py 里构建模型时传入的 entity_num 和训练时不一致。常见情况是训练时 kg.txt 构建出来的实体数量为 5000,但 test 阶段重新加载数据时漏加载了部分三元组,实体数量变成 4500,嵌入表维度就直接对不上了。
解决:模型构建参数必须从同一个数据源计算。建议把 entity_num 的计算封装在 data_loader 的加载函数里,训练和测试脚本统一 import 这个值,而不是各自从数据文件里数。
5.4 显存爆炸和进程被 kill
现象:训练刚开始升级到第二个 epoch,程序直接报ResourceExhaustedError退出,有时连报错都没有只是进程被 OOM kill。
原因:前面提到的 gpu_memory_growth.py 没有在 main.py 里被 import,导致 TensorFlow 在初始化时一次性分配了全部显存。如果同时有别的进程占用显存,就直接挤爆了。
解决:把import gpu_memory_growth放到 main.py 文件的第一行,确保在 TensorFlow 初始化之前设置好按需增长。另外可以通过 batch_size 减半或 neighbor_num 减半来缩减每批数据的张量体积。
5.5 预测结果全部趋近同一个分数,没有区分度
现象:test.py 跑完,打印出的预测分值基本都在 0.47 ~ 0.53 之间,几乎齐平,完全无法区分用户的真实偏好。
原因:大概率是训练 epoch 太少没有收敛,或者正则化系数太大把模型系数压得过于平均。当然也可能是负采样没有剔除用户看过电影,导致正负样本语义混淆。
解决:先看训练日志里 loss 是否还在持续下降。如果 loss 已收敛但预测值仍集中,说明模型容量不足或特征表达过于平滑,可以尝试增大 embed_dim、减少 l2 正则系数。这个现象是图神经网络冷启动项目里最常见的诊断点,通常先从训练曲线找原因,而不是急着改模型结构。
6. test.py 预测链路与模型验证方法
6.1 预测脚本背后:从权重文件到 Top-K 推荐结果
test.py 的经验做法是先重建模型结构,再 load_weights 把 KCGN.h5 加载进去,然后用 predict 生成用户对所有未看过的电影打分,最后按分数排序输出 Top-N 推荐。这里需要注意的一点是:加载权重前必须保证模型结构完全一致,一个 embed_dim 不一致都会导致加载失败。
# test.py 预测流程(结构示意) import tensorflow as tf from model import KGCN # 重建模型,参数必须与训练时完全一致 model = KGCN(user_num=user_num, entity_num=entity_num, relation_num=relation_num, embed_dim=32, neighbor_num=4) # 加载训练好的权重 model.load_weights('KCGN.h5') # 对某个用户所有未打分电影预测分数 user_idx = 0 unseen_movies = [m for m in all_movie_ids if m not in user_watched[user_idx]] candidates = tf.constant([user_idx] * len(unseen_movies), dtype=tf.int32) items = tf.constant(unseen_movies, dtype=tf.int32) # 这里用全零填充邻居信息,预测阶段不需要采样真实邻居 scores = model(candidates, items, zero_neighbors, zero_relations).numpy().flatten() # 按分数排序取 Top 10 top_indices = scores.argsort()[-10:][::-1] rec_movies = [unseen_movies[i] for i in top_indices]预测阶段有个细节:邻居信息在验证时可以不再采样,而是用零填充,因为权重已经固化,图卷积的传播结果基本稳定。这个做法在很多图模型推理阶段通用,能大幅减少推理时的计算量。不过如果你追求精度,还是建议按训练同样的流程采样真实邻居。
6.2 让评估更有说服力的两种验证方式
作为毕设项目,建议不要只停留在"预测分数能跑出来"这一步,至少要加上离线评估指标。常见做法是随机抽 20% 的 rating 作为测试集,训练完在测试集上计算 recall@10、precision@10 或 NDCG。更细一点还可以按用户维度分组,画出不同用户活跃度下的推荐效果差异,这部分数据可以直接写进毕设论文的实验章节。
还有一个小习惯值得养成:每次训练完,把 KCGN.h5 连同训练超参数、验证指标一起记录到一个 json 文件里。因为图模型调参成本很高,如果没有记录,改了几轮参数之后回头找最优权重会非常痛苦。从那以后我每次训练结束都强制走一遍这个记录流程,再加载模型做对比时效率高很多。这是拆完这个项目之后,我认为最值得保留的工作习惯。如果你也准备拿这套工程作为毕设基础,建议先把数据准备和模型构建两份代码通读一遍再动手训练,剩下的交给时间——调参的过程本身就是最好的论文素材。希望帮到你。
本文还有配套的精品资源,点击获取