简介:本资源是一份面向人工智能与推荐系统方向本科生、研究生的毕业设计实践项目,聚焦图神经网络在异构图表示学习与个性化推荐中的落地应用。项目完整实现从异构图建模、HetGNN类模型设计、多类型节点嵌入训练,到端到端推荐效果评估的全流程,适用于社交推荐、电商知识图谱等真实场景的技术验证与课程实践。压缩包共135个文件,以96个Python脚本(含模型定义、训练/评估/消融实验逻辑)、14张可视化结果图(如参数分析、排序性能对比)、11个HTML交互页面(含用户登录/注册及论文列表展示)为核心,辅以CSV数据集、Markdown说明与YAML配置,整体仅579KB,轻量易部署。目前已有45人学习下载,提供可复现的完整代码工程、结构清晰的模块划分(含_node_classification、_ablation_study等关键实验目录)、以及基于电影/用户/演员/导演多实体的真实异构图建模思路,助读者深入理解类型感知聚合与元路径建模的工程实现细节。
1. 异构图推荐不是“把GCN套上去就行”,而是要先拆解节点类型与元路径的耦合关系
很多同学拿到毕业设计题目“基于图神经网络的异构图表示学习和推荐算法”后,第一反应是找一个PyTorch Geometric教程,把MovieLens数据扔进GCNConv里跑通——结果在验证集上AUC卡在0.72,比LightFM还低。这不是模型不行,而是根本没动过异构图的筋骨。本项目提供的完整代码包(含node_classification.csv、rank.csv、ablation_study.csv等6类核心数据文件)不是演示脚本,而是一套可复现的类型感知消息传递流水线:它强制你定义用户-物品-标签-类别四类节点的语义角色,显式声明User→Item←Tag和Item→Category→Item两条元路径,并在每轮聚合中对不同路径施加独立的权重矩阵。这意味着,当你看到param_analysis.csv里记录着12组超参组合的F1变化曲线时,背后是HetGNN层中W_{u,i}与W_{i,c}两个参数块的梯度分离更新;当你用base.html打开可视化界面看到节点嵌入聚类效果时,那其实是metapath2vec预训练+HAN注意力微调的双阶段输出。适合正在写毕设、已学过GNN基础但卡在“如何让模型理解‘导演’和‘用户’不能用同一套聚合规则”的人。
2. 构建异构图数据结构:从CSV原始表到DGL异构图对象的三步映射
2.1 理解项目数据文件的语义分层与字段约束
项目中的node_classification.csv并非普通分类标签表,而是带类型标识的异构节点索引表。观察其前5行(用pandas加载后):
import pandas as pd df = pd.read_csv("node_classification.csv") print(df.head()) # 输出示例: # node_id node_type label feature_0 feature_1 # 0 0 user 1 0.23 -0.17 # 1 1 item 0 0.89 0.41 # 2 2 actor 2 0.12 0.93 # 3 3 director 1 0.67 -0.55 # 4 4 genre 0 0.33 0.28关键点在于node_type列:它不是字符串标签,而是后续构建图结构的类型键(node type key)。DGL要求所有节点ID全局唯一,但必须按类型分组。param_analysis.csv中记录的num_nodes_per_type参数(如{"user": 1243, "item": 892, "actor": 341})正是为这一步服务——它告诉系统每个类型需要分配多少连续ID空间。若忽略此约束直接拼接ID,会导致dgl.heterograph()初始化时报Node ID out of range错误。
提示:
rank.csv中的user_id、item_id字段是业务ID,不是图节点ID。必须通过node_classification.csv中的node_id做映射,例如user_id=1024对应node_id=57(类型为"user"),这个映射关系存储在register.html生成的JSON配置里,而非硬编码在代码中。
2.2 使用DGL构建异构图:边类型定义与元路径验证
异构图的核心是边类型(edge type),它决定了消息传递的方向与语义。项目中rank.csv包含用户对物品的交互记录,但需结合ablation_study.csv中的消融实验设计,明确三条关键边类型:
| 边类型(canonical_etypes) | 源节点类型 | 目标节点类型 | 物理含义 | 是否用于主推荐任务 |
|---|---|---|---|---|
("user", "click", "item") | user | item | 显式点击行为 | 是 |
("item", "belong_to", "category") | item | category | 物品所属类别 | 是(增强冷启动) |
("item", "co_occurrence", "item") | item | item | 同一用户多次点击的物品共现 | 否(仅用于消融对比) |
构建代码需严格遵循DGL异构图规范:
import dgl import torch import numpy as np # 1. 从CSV提取三元组:(src_id, dst_id, edge_type) click_edges = [] with open("rank.csv", "r") as f: for line in f.readlines()[1:]: # 跳过header uid, iid, _ = line.strip().split(",") # 注意:此处uid/iid需映射为node_classification.csv中的node_id src_id = get_node_id(int(uid), "user") # 实现见register.html解析逻辑 dst_id = get_node_id(int(iid), "item") click_edges.append((src_id, dst_id)) # 2. 构建异构图字典 graph_data = { ("user", "click", "item"): click_edges, ("item", "belong_to", "category"): load_category_edges(), # 从category_mapping.csv读取 ("item", "co_occurrence", "item"): load_cooccurrence_edges() } # 3. 创建DGL异构图对象(关键:指定num_nodes_dict) num_nodes_dict = { "user": 1243, "item": 892, "category": 47, "actor": 341, "director": 128 } g = dgl.heterograph(graph_data, num_nodes_dict=num_nodes_dict) # 4. 验证元路径存在性(避免后续HAN层报错) metapath = ["click", "belong_to"] # user -> item -> category if not g.has_edges_between(0, 0, etype=("user", "click", "item")): raise ValueError("Edge type 'click' not found in graph")这段代码的关键在于num_nodes_dict参数——它必须与node_classification.csv中各类型的节点总数完全一致。若category类型实际有47个节点,但此处填48,DGL会在g.edges(etype="belong_to")调用时抛出IndexError。ablation_study.csv中记录的edge_type_ablation列,正是通过注释掉某条边类型(如置空("item", "co_occurrence", "item"))来验证该边对Recall@10的影响。
2.3 特征矩阵的类型对齐:避免跨类型特征污染
异构图中,不同节点类型的特征维度往往不同(如用户特征含历史点击频次,物品特征含文本Embedding)。项目node_classification.csv中feature_0至feature_15列并非全类型共享,而是按node_type分组填充。例如:
user类型:feature_0~feature_7为统计特征(点击数、平均评分等)item类型:feature_8~feature_15为文本+图像多模态特征
因此,特征矩阵不能简单堆叠:
# ❌ 错误:强行拼接导致维度错位 features = torch.cat([torch.tensor(df["feature_0"]), torch.tensor(df["feature_1"])], dim=1) # ✅ 正确:按类型分组构建特征字典 node_features = {} for ntype in ["user", "item", "category", "actor", "director"]: mask = df["node_type"] == ntype if ntype == "user": feat_cols = [f"feature_{i}" for i in range(0, 8)] elif ntype == "item": feat_cols = [f"feature_{i}" for i in range(8, 16)] else: feat_cols = ["feature_0"] # 其他类型仅用基础特征 node_features[ntype] = torch.tensor(df[mask][feat_cols].values, dtype=torch.float32) # 将特征注入图 for ntype in node_features: g.nodes[ntype].data["feat"] = node_features[ntype]param_analysis.csv中feature_dim_per_type列(如{"user": 8, "item": 8})正是为此设计。若某次实验将user特征维度设为10,但实际只提供8列,则g.nodes["user"].data["feat"]会报size mismatch错误。
3. HetGNN层实现:类型感知聚合与跨类型转换的参数化设计
3.1 类型感知邻居聚合:为每条边类型配置独立权重矩阵
标准GCN对所有邻居一视同仁,但在异构图中,“用户点击物品”和“物品属于类别”应使用不同变换。项目采用HetGNN的变体,其核心是为每个(src_ntype, etype, dst_ntype)三元组定义专属权重:
import torch.nn as nn import torch.nn.functional as F class HetGNNAggregator(nn.Module): def __init__(self, in_feats_dict, out_feats, etypes): super().__init__() # 关键:为每条边类型创建独立线性层 self.weight_dict = nn.ModuleDict() for srctype, etype, dsttype in etypes: # 输入维度:源节点特征维数 in_dim = in_feats_dict[srctype] # 输出维度:目标节点特征维数(统一为out_feats) self.weight_dict[f"{srctype}_{etype}_{dsttype}"] = nn.Linear(in_dim, out_feats) self.out_feats = out_feats def forward(self, g, feat_dict): # 初始化目标节点特征字典 h_dict = {ntype: torch.zeros(g.num_nodes(ntype), self.out_feats) for ntype in g.ntypes} # 对每条边类型执行聚合 for srctype, etype, dsttype in g.canonical_etypes: # 获取源节点特征 src_feat = feat_dict[srctype] # 获取边上的节点对 src_ids, dst_ids = g.edges(etype=(srctype, etype, dsttype)) # 变换源特征 transformed = self.weight_dict[f"{srctype}_{etype}_{dsttype}"](src_feat[src_ids]) # 汇总到目标节点(mean pooling) h_dict[dsttype][dst_ids] += transformed return h_dict # 初始化时传入边类型列表 etypes = [("user", "click", "item"), ("item", "belong_to", "category")] aggr = HetGNNAggregator( in_feats_dict={"user": 8, "item": 8, "category": 4}, out_feats=64, etypes=etypes )param_analysis.csv中weight_matrix_per_edge列记录了各边类型权重矩阵的L2范数,用于分析哪类关系贡献更大。例如当("item", "belong_to", "category")的权重范数显著高于("user", "click", "item")时,说明类别信息对推荐起主导作用——这提示应加强类别侧特征工程。
3.2 节点类型转换:跨类型注意力机制的实现细节
异构图推荐需解决“用户如何与导演交互”这类跨类型问题。项目采用轻量级类型转换模块,不引入额外参数,而是通过注意力动态加权:
class CrossTypeAttention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.W_q = nn.Linear(hidden_dim, hidden_dim) self.W_k = nn.Linear(hidden_dim, hidden_dim) self.W_v = nn.Linear(hidden_dim, hidden_dim) def forward(self, src_feat, dst_feat, g, etype): # src_feat: 源节点特征 (N_src, dim) # dst_feat: 目标节点特征 (N_dst, dim) # 获取边连接关系 src_ids, dst_ids = g.edges(etype=etype) # 计算注意力分数 q = self.W_q(dst_feat[dst_ids]) # (E, dim) k = self.W_k(src_feat[src_ids]) # (E, dim) v = self.W_v(src_feat[src_ids]) # (E, dim) attn_scores = torch.sum(q * k, dim=1) # (E,) attn_weights = F.softmax(attn_scores, dim=0) # (E,) # 加权求和 out = torch.zeros_like(dst_feat) out[dst_ids] += torch.einsum('e,ed->ed', attn_weights, v) return out # 在推荐头中调用 cross_attn = CrossTypeAttention(hidden_dim=64) user_emb = g.nodes["user"].data["h"] # (1243, 64) item_emb = g.nodes["item"].data["h"] # (892, 64) # 计算用户对物品的跨类型注意力 user_item_attn = cross_attn(user_emb, item_emb, g, ("user", "click", "item"))ablation_study.csv中cross_type_attn列的消融结果表明:关闭此模块后,新用户(冷启动)的NDCG@10下降12.3%,证实其对稀疏交互场景的关键价值。
3.3 推荐头设计:双通道打分与排序损失函数
最终推荐分数由两部分融合:
- 结构通道:基于图嵌入的相似度(
user_emb @ item_emb.T) - 属性通道:用户-物品交互的显式特征(如点击时长、评分)
class RecommendationHead(nn.Module): def __init__(self, embed_dim, attr_dim): super().__init__() self.struct_proj = nn.Linear(embed_dim * 2, 64) # user+item拼接 self.attr_proj = nn.Linear(attr_dim, 32) self.fusion = nn.Linear(64 + 32, 1) def forward(self, user_emb, item_emb, attr_feat): # 结构通道 struct_input = torch.cat([user_emb, item_emb], dim=1) # (B, 128) struct_out = F.relu(self.struct_proj(struct_input)) # (B, 64) # 属性通道 attr_out = F.relu(self.attr_proj(attr_feat)) # (B, 32) # 融合 fused = torch.cat([struct_out, attr_out], dim=1) # (B, 96) scores = self.fusion(fused).squeeze(-1) # (B,) return scores # 损失函数:采用BPR Loss(隐式反馈首选) def bpr_loss(pos_score, neg_score): # pos_score: 正样本得分 (B,) # neg_score: 负样本得分 (B,) return -torch.mean(torch.log(torch.sigmoid(pos_score - neg_score))) # 在训练循环中 pos_scores = rec_head(user_emb[pos_u], item_emb[pos_i], pos_attr) neg_scores = rec_head(user_emb[neg_u], item_emb[neg_i], neg_attr) loss = bpr_loss(pos_scores, neg_scores)rank.csv中的rank列即为BPR采样生成的负样本索引,param_analysis.csv中loss_type列记录了不同损失函数(BPR vs. MSE)对HR@10的影响。
4. 实验验证与性能调优:从ablation_study.csv解读模型决策边界
4.1 消融实验结果解析:识别真正的瓶颈模块
ablation_study.csv不是简单的准确率表格,而是控制变量法的证据链。以其中一行数据为例:
| model_variant | HR@10 | NDCG@10 | cross_type_attn | weight_matrix_per_edge | feature_dim_per_type |
|---|---|---|---|---|---|
| full_model | 0.682 | 0.491 | True | True | {"user":8,"item":8} |
| no_cross_attn | 0.593 | 0.412 | False | True | {"user":8,"item":8} |
| no_weight_per_edge | 0.615 | 0.433 | True | False | {"user":8,"item":8} |
关键发现:
- 移除跨类型注意力(
no_cross_attn)导致HR@10下降8.9个百分点,远超移除边类型权重(no_weight_per_edge)的6.7个百分点,说明跨类型交互建模比细粒度边权重更重要; no_weight_per_edge的NDCG@10仅降0.058,表明在当前数据集上,统一权重已足够捕获主要关系模式;- 若将
feature_dim_per_type改为{"user":16,"item":16}但未增加相应特征列,HR@10会骤降至0.321——证明特征维度必须与实际输入严格匹配。
注意:
ablation_study.csv中model_variant列的命名规则隐含调试逻辑。full_model代表启用所有模块;no_*前缀表示禁用某模块;only_*前缀(如only_click)表示仅保留某类边。这种命名便于快速定位问题模块。
4.2 参数敏感性分析:param_analysis.csv中的调优指南
param_analysis.csv记录了12组超参组合的验证指标,核心参数包括:
| 参数名 | 取值范围 | 最优值 | 效果说明 |
|---|---|---|---|
hidden_dim | [32, 64, 128] | 64 | 维度>64时GPU显存溢出,<64时HR@10下降明显 |
num_layers | [1, 2, 3] | 2 | 3层导致过平滑(over-smoothing),节点区分度降低 |
dropout | [0.2, 0.5, 0.7] | 0.5 | 0.2时过拟合,0.7时训练不稳定 |
lr | [0.001, 0.01, 0.1] | 0.01 | 0.1时loss震荡,0.001时收敛过慢 |
特别注意num_layers=2的深层含义:第一层聚合邻居信息,第二层聚合邻居的邻居(即二跳关系)。rank.csv中用户-物品-类别路径恰好匹配此深度,若数据中存在用户-物品-演员-导演的四跳路径,则需增至3层,但此时必须启用残差连接(代码中residual=True参数)。
4.3 推荐结果可视化:base.html中的嵌入空间分析技巧
base.html不是静态页面,而是基于plotly的交互式嵌入分析工具。打开后可执行:
- 选择节点类型:下拉菜单切换
user/item/category,观察t-SNE降维后的聚类形态; - 查看邻居关系:点击某物品节点,右侧显示其
click邻居(用户)和belong_to邻居(类别)的嵌入分布; - 验证元路径有效性:在
item视图中,用颜色标记genre属性,若同类别物品在嵌入空间中紧密聚集,说明belong_to边学习有效; - 诊断冷启动问题:筛选
user中degree=1(仅点击1次)的用户,检查其嵌入是否远离高活用户簇——若是,则需加强跨类型注意力或引入属性通道。
login.html中保存的用户会话ID,用于追踪特定用户的推荐路径。例如ID为U123的用户,其推荐列表生成过程可回溯至:U123嵌入 → 与所有item嵌入计算余弦相似度 → 按rank.csv中score列排序 → 取Top10。此路径在_paper_list.html的论文引用中被论证为优于传统协同过滤。
5. 部署前的三项硬性检查:确保模型在真实场景中鲁棒运行
5.1 节点ID连续性校验:防止图结构断裂
生产环境中,新增用户或物品会导致节点ID不连续。项目提供check_node_continuity.py脚本,必须在每次数据更新后运行:
python check_node_continuity.py --node_csv node_classification.csv --graph_dir ./graphs/该脚本执行三重校验:
- 检查
node_classification.csv中node_id是否从0开始连续编号; - 验证
num_nodes_dict中各类型总数是否等于该类型node_id最大值+1; - 确认
rank.csv中所有user_id/item_id均存在于node_classification.csv的映射表中。
若校验失败,dgl.heterograph()会静默截断缺失节点,导致推荐结果偏差。register.html中node_id_map.json文件正是此校验的产物。
5.2 边类型完整性测试:避免消息传递中断
异构图推理依赖边类型存在性。在model_inference.py中加入断言:
def validate_graph_for_inference(g): required_etypes = [("user", "click", "item"), ("item", "belong_to", "category")] for etype in required_etypes: if not g.canonical_etypes.count(etype): raise RuntimeError(f"Missing required edge type: {etype}") if g.num_edges(etype=etype) == 0: raise RuntimeError(f"Edge type {etype} has zero edges") # 在加载模型前调用 g = load_hetero_graph() validate_graph_for_inference(g)ablation_study.csv中edge_type_ablation列的None值表示该边类型被移除,此时必须同步修改required_etypes列表,否则服务启动失败。
5.3 推荐多样性量化:用coverage_ratio替代单一准确率
推荐系统不能只看HR@10,还需评估覆盖广度。项目提供diversity_eval.py计算coverage_ratio:
def calculate_coverage_ratio(recommended_items, all_items): """ recommended_items: List[List[item_id]] # batch_size x top_k all_items: Set[item_id] # 全量物品ID集合 """ covered = set() for rec_list in recommended_items: covered.update(rec_list) return len(covered) / len(all_items) # 示例:1000个用户各推荐10个物品,覆盖327个不同物品 # coverage_ratio = 327 / 892 = 0.366param_analysis.csv中coverage_ratio列显示:当hidden_dim=128时,coverage_ratio升至0.412,但HR@10反降0.015——说明模型过度泛化。最优平衡点在hidden_dim=64,此时coverage_ratio=0.366,HR@10=0.682。
本文还有配套的精品资源,点击获取