
简介这是一份面向推荐系统方向本科生的毕业设计项目完整代码包聚焦基于属性和图神经网络AGNN的推荐算法覆盖数据处理、模型设计、训练验证与结果评估全流程。压缩包共19个文件包含Python源码model.py、utils.py等、CSV与npy数据文件、README说明及多个txt辅助文档整体约4.19MB按ml_latest、ml_1m、ml_100k等数据集目录组织便于对照实验设置快速复现。目前已有62人学习适合具备机器学习与深度学习基础、想了解GNN在推荐系统中实际落地的学生或开发者。项目在AGNN框架下利用用户属性年龄、性别、职业与商品属性初始化图节点通过图卷积和池化捕捉用户-商品间的复杂关联同时涉及动态嵌入调整与大规模图处理思路压缩包内附数据预处理脚本和训练/测试入口有助于读者理解模型构建细节并在此基础上开展改进实验。1. AGNN推荐到底在解决什么问题一张图加一份属性比纯协同过滤多抓住什么把“基于属性和图神经网络的推荐算法”这个毕设标题拆开看有多少人第一反应是“又让图神经网络出来顶班”。但真正做过一次你就会发现AGNN的重点不在“图神经网络有多深”而在“属性”这两个字。属性是用户画像年龄、性别、职业、活跃时段和物品画像类型、价格档位、上架年份这些边信息。纯协同过滤只看到“谁买了什么”AGNN则让“谁”和“什么”先带着自身属性变成节点向量再靠交互边做消息传递。这套做法最直接的价值是给稀疏交互兜底一个刚上架的商品没有几条点击但只要它的类别和历史热门商品重合图卷积就能把邻居手里的信息“揉”给它。我见过不少本科毕设把图神经网络当成黑匣子输入评分矩阵、输出TopK结果跑出来还不如一个带偏置的矩阵分解。问题往往不是模型不行而是节点的属性特征根本没接对。AGNN这个方向值得做的原因很朴素属性的获取成本远低于交互数据而GCN这类结构天然适合把属性顺着一阶、二阶邻居传播出去。如果你要做推荐相关毕设或者手里的召回/排序模型被冷启动困扰本文就顺着“数据怎么构造→模型怎么写→怎么训练和评估→哪里最容易翻车”这条路子给你一套可以直接照做的方案。2. 构造可训练的图数据把用户-物品交互和属性一起喂给GNN2.1 先想清楚AGNN里的“属性”到底指什么很多教程把属性做成一个拼接向量往模型的输入层一丢就完事。但AGNN里属性不是辅助特征而是节点表示的唯一来源。什么意思在纯ID embedding的方案中每个用户/物品是一个随机初始化的向量交互稀疏时这个向量几乎学不动。而属性方案是直接用“年龄26、游戏爱好者、夜间活跃”来表示用户用“类型恐怖/冒险、发行年份2023”来表示物品。这样即使某个用户只有一两条交互记录他的初始向量也不是零向量属性矩阵X就是整个模型的起点。常见的数据来源是MovieLens这类电影评分数据或者电商的行为日志。你需要准备三张表第一张是交互表至少包含user_id、item_id、interaction评分/点击/购买第二张是用户属性表比如性别、年龄分段、职业第三张是物品属性表比如电影类型、上映年份、语言。没有属性表怎么办可以把交互表里每个用户的“历史物品类别占比”作为统计属性这是最简单的兜底做法但效果不如真实画像。属性要转成数值才能进网络。离散型特征用独热编码连续型特征做标准化。这一步的结果是X_usershape为N_user × d_user和X_itemshape为N_item × d_item两个矩阵后续拼接成全体节点的特征矩阵X。千万注意不要先拼一个超长稀疏向量再塞进GCN独热编码超过几千维会让第一层线性变换的参数爆炸训练慢而且容易过拟合。建议对出现频率低的类别做“其他”归并控制在64维以内。2.2 用Python把评分表变成邻接矩阵和特征矩阵图数据在AGNN里的标准表示是邻接矩阵。用户和物品是两种类型的节点评分/点击行为构成边。一般做法是把用户节点排在前面物品节点排在后面拼成一个(N_user N_item) × (N_user N_item)的稀疏邻接矩阵。上半右区是用户到物品的交互下半左区是物品到用户的反向传播对角线加1表示自环。import numpy as np import scipy.sparse as sp # rating_df: 三列 user_id, item_id, interaction(比如0/1或评分) # 先给用户和物品重新编号保证连续 user_idx {u: i for i, u in enumerate(rating_df[user_id].unique())} item_idx {v: i for i, v in enumerate(rating_df[item_id].unique())} n_user, n_item len(user_idx), len(item_idx) row [user_idx[u] for u in rating_df[user_id]] col [item_idx[v] n_user for v in rating_df[item_id]] # 物品整体后移 data np.ones(len(row), dtypenp.float32) # 有交互就是1 # 构建 (n_user n_item) 方阵 adj sp.coo_matrix((data, (row, col)), shape(n_user n_item, n_user n_item)) adj adj adj.T # 让消息双向传播 adj adj sp.eye(n_user n_item) # 加上自环这段代码里有两个关键决定。第一交互值我用1而不是评分原因是最开始的AGNN通常预测“会不会交互/是否点击”而不是“打几分”二值化后负采样才有意义。如果你想预测真实评分可以把评分归一化后放到data里但BCE损失就要换成MSE。第二adj必须是对称的因为GCN的消息传递是双向的用户消息要传给物品物品消息也要传给用户。只保留左上到右上这一块会丢一半信息模型无法同时学习两侧表示。接下来做对称归一化目的是防止度数高的节点热门用户/热门物品数值被邻居值“卷走”。公式是D^{-1/2} A D^{-1/2}在稀疏矩阵上这样算def normalize_adj(adj): 对称归一化邻接矩阵使传播数值稳定 d np.array(adj.sum(axis1)).flatten() d_inv_sqrt np.power(d, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] 0.0 d_mat_inv_sqrt sp.diags(d_inv_sqrt) return d_mat_inv_sqrt.dot(adj).dot(d_mat_inv_sqrt) adj_norm normalize_adj(adj).tocsr()参数上要注意如果某个节点没有自环也没邻居它的度就是0d_inv_sqrt会变成inf上面代码把inf替换成0防止NaN带进网络。这一步看着小实际是很多人“照着跑了一次就NaN”的头号原因。2.3 特征矩阵的拼接与对齐邻接矩阵定义好了特征矩阵就要和节点顺序完全对齐。前n_user行是用户属性后n_item行是物品属性。如果某个节点的属性缺失不要填0后直接过网络那等于告诉模型“这个人没有画像”会让embedding朝奇怪的方向偏。常见做法是填该属性分布的均值或者干脆把缺失当成一维独立特征。# user_feat: numpy 数组 [n_user, d_user] # item_feat: numpy 数组 [n_item, d_item] X_user np.hstack([user_feat, np.ones((n_user, 1))]) # 补一列偏差 X_item np.hstack([item_feat, np.zeros((n_item, 1))]) X np.vstack([X_user, X_item]).astype(np.float32)这里用户补1、物品补0是给模型一个“节点类型”的偏置。后面线性层会自动学出这个偏置的含义。很多AGNN实现会在初始化时把normalize_adj结果转成PyTorch稀疏张量因为图规模到几万个节点后稠密矩阵直接放不进显存。注意PyTorch的稀疏矩阵支持spmm但邻接矩阵要固定后缓存起来不要每次训练重复归一化。数据划分也别等到后面才做。我习惯先把交互表随机打乱按8:1:1切成训练/验证/测试再分别构建三个邻接矩阵。但注意验证集和测试集的交互边要保留在训练时的邻接矩阵里吗不要。如果保留模型在训练阶段就看到了预测答案的边指标虚高。正确做法是训练邻接矩阵只含训练边验证和测试时把训练边保留做节点初始化再临时加上对应验证/测试边做前向预测。也就是说训练、验证、测试各自维护一份邻接矩阵不能共用一份。这是毕设答辩时老师最喜欢问的一个点。3. 用PyTorch实现AGNN两层属性图卷积的模型与参数清单3.1 图卷积为什么能“传播属性”AGNN的每一层做的事情可以拆成两步。第一步每个节点用自己的属性向量过一个线性变换变成一个隐向量。第二步把每个节点隐向量广播给所有邻居同时收集邻居发来的消息求和或取平均。形式化地说第l层输出H^{l1} σ( A_norm · H^l · W^l )。A_norm是刚才归一化好的邻接矩阵H^l是上一层的节点向量W^l是可训练的权重矩阵。经过一层传播每个节点的表示里就混入了一阶邻居的属性信息再来一层就混入了二阶邻居的信息。关键洞察在于属性是“种子”。例如一个新商品它自己没交互但它连接的物品一阶邻居或用户的偏好二阶邻居里有相似类别传播后它的表示就带着被用户偏好“染色”过的向量。这就是为什么AGNN比纯粹用ID embedding更有优势ID embedding中一个冷启动节点没有任何邻居消息训练时梯度根本回传不到它属性图卷积则能借邻居属性“无中生有”。层数也不是越多越好。两层是经典配置因为协同过滤假设本质上是“用户可能喜欢物品的相似物品”恰好是二阶邻居。三层以上过平滑所有节点表示趋同推荐结果变得像随机排序毕设翻车大多在这。3.2 AGNN核心代码一个类搞定两层传播下面给出一份可直接训练的PyTorch模型适合用交互预测的BCE损失。为了避免过度设计我没有加Attention而把可解释性放在“不同属性的线性组合”上这样调参时更容易定位问题。import torch import torch.nn as nn import torch.nn.functional as F class AGNN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, dropout0.2): super().__init__() self.lin1 nn.Linear(in_dim, hidden_dim) self.lin2 nn.Linear(hidden_dim, out_dim) self.dropout nn.Dropout(dropout) def forward(self, x, adj): # 第一层线性变换 属性传播 x self.lin1(x) x torch.spmm(adj, x) x F.relu(x) x self.dropout(x) # 第二层线性变换 属性传播 x self.lin2(x) x torch.spmm(adj, x) return x这个类你可能觉得简单但它已经包含了AGNN的全部核心。in_dim是上一章里X的列数用户属性维度物品属性维度1hidden_dim我一般取64或128out_dim是最终embedding维度通常取32。两个nn.Linear负责把属性空间压缩到隐空间两个spmm负责把邻居信息“加权平均”到每个节点dropout放在Relu之后能减少对局部结构的过拟合。注意adj必须是PyTorch的稀疏张量而且要转换成float否则spmm会报类型错。在训练前把adj_norm转成coalesce稀疏张量# 从scipy稀疏矩阵转pytorch稀疏矩阵 adj_coo adj_norm.tocoo() indices torch.LongTensor(np.vstack([adj_coo.row, adj_coo.col])) values torch.FloatTensor(adj_coo.data) shape torch.Size(adj_coo.shape) adj_sparse torch.sparse_coo_tensor(indices, values, shape).coalesce()3.3 模型输出怎么变成推荐结果AGNN的输出是一个全体节点表示矩阵X_emb它的前n_user行是用户embedding后n_item行是物品embedding。预测用户u对物品i的交互概率就做点积再套sigmoiduser_emb x_emb[:n_user] item_emb x_emb[n_user:] logits (user_emb[u] * item_emb[i]).sum() prob torch.sigmoid(logits)这里有一个容易被忽略的参数维度问题用户和物品共享同一套线性权重。因为两个线性层的输入都来自同一个特征矩阵而用户属性和物品属性在拼接时维度可能不一样但我们做了补齐见2.3所以in_dim统一。如果你不想共享权重可以把用户和物品放进两个并行的GCN最后拼接预测但那样参数翻倍在毕设数据量下容易过拟合。训练时还要把用户embedding和物品embedding做L2正则化让点积结果有界防止某些高频用户embedding的模长过大。我一般在loss里加一项alpha * (user_emb.norm(2).pow(2) item_emb.norm(2).pow(2))alpha设为1e-5。为什么不直接输出评分因为交互数据中正样本远少于负样本而评分是显式反馈信号。在搜索和电商场景里“点击/未点击”更通用。如果坚持评分预测把Data中的值改为评分损失换成MSE模型结构不用动。你的毕设题目如果强调“推荐算法”而不是“评分预测”用BCE更安全因为TopK指标和点击率指标更常用。4. 训练AGNN的正确姿势负采样、Loss与RecallK评估4.1 负采样没有负样本就学不会排序AGNN的邻接矩阵只有正交互1没有负样本。如果直接在这个矩阵上做自监督重构模型会把所有节点预测成1因为损失里没有“应该为0”的约束。所以每个训练步骤都要采样一批负交互随机挑一对没有真实交互的user-item把它当作0标签。负采样比例是最重要的超参数之一。我习惯设1:1也就是每个正样本配一个负样本这能让正负样本数平衡模型倾向学“差异”而不是“绝对概率”。如果数据太稀疏比如正样本还不到几千条负采样比例可以提高到1:4帮模型更快看到更多的“不感兴趣”的信号。def sample_neg_batch(batch_user, batch_item, n_item, neg_ratio1): 对每个正样本生成一个负样本返回负样本的user-item对 neg_user [] neg_item [] for u in batch_user: for _ in range(neg_ratio): neg_u u # 随机挑一个未被该用户交互过的item neg_i np.random.randint(n_item) neg_user.append(neg_u) neg_item.append(neg_i) return torch.LongTensor(neg_user), torch.LongTensor(neg_item)注意这里的随机负采样有一个隐藏bug随机挑的item可能恰好是正样本里的交互于是标签矛盾。虽然在概率上影响很小但会让训练loss出现不该有的震荡。业界常用做法是采样后检查是否有交集有就重采或者用“缓存负样本”的方式。毕设里直接再采一次即可不要在这种小概率上浪费精力。4.2 Loss与训练循环BCE还是BPR怎么选AGNN的损失有两种主流选择BCE和BPR。BCE把问题看作独立二分类公式是-log(sigmoid(logit))对负样本的惩罚是决定性的。BPR则是pairwise损失它不关注绝对分数只关注“正样本的分数是否高于负样本”公式是-log(sigmoid(pos_logit - neg_logit))。BPR的优点是关注排序和TopK指标更一致缺点是训练时每个正样本必须带一个负样本负采样比例对结果更敏感。我在毕设代码里通常两种都实现默认用BCE因为它收敛更稳定而且能和点击率预测的评估对齐。训练循环结构如下def train_epoch(model, optimizer, adj, user_idx, item_idx, pos_pairs, neg_pairs): model.train() total_loss 0.0 num_batches len(pos_pairs) // BATCH_SIZE for i in range(num_batches): batch_pos pos_pairs[i*BATCH_SIZE:(i1)*BATCH_SIZE] batch_neg neg_pairs[i*BATCH_SIZE:(i1)*BATCH_SIZE] optimizer.zero_grad() x_emb model(X_tensor, adj) # 收集正样本和负样本的logits pos_u X_tensor.new_tensor(batch_pos[:, 0]) pos_i X_tensor.new_tensor(batch_pos[:, 1]) pos_logits (x_emb[pos_u] * x_emb[n_user pos_i]).sum(dim1) neg_u X_tensor.new_tensor(batch_neg[:, 0]) neg_i X_tensor.new_tensor(batch_neg[:, 1]) neg_logits (x_emb[neg_u] * x_emb[n_user neg_i]).sum(dim1) pos_loss F.binary_cross_entropy_with_logits(pos_logits, torch.ones_like(pos_logits)) neg_loss F.binary_cross_entropy_with_logits(neg_logits, torch.zeros_like(neg_logits)) loss pos_loss neg_loss # 可选加L2正则 loss loss 1e-5 * (x_emb.norm(2).pow(2)) loss.backward() optimizer.step() total_loss loss.item() return total_loss / num_batches这里最关键的参数是学习率。AGNN因为包含spmm梯度在邻接矩阵稀疏结构上流经多条路径学率过大容易震荡我通常从1e-3开始每次验证集Recall不涨就乘以0.5。另外optimizer用Adambeta2设0.99防止稀疏梯度的二阶动量为0导致更新步长抖动。4.3 评估指标与测试流程RecallK和NDCG的计算毕设答辩时只看loss曲线是不够的推荐系统标准的离线指标是RecallK和NDCGK。计算方法是对测试集中的每个用户把该用户没有交互过的所有物品排除训练集中的正样本作为候选让模型预测分数取TopK。然后看TopK里有多少出现在测试集正样本中。def evaluate(model, adj, train_user_items, test_user_items, n_item, K10): model.eval() with torch.no_grad(): x_emb model(X_tensor, adj) recalls, ndcgs [], [] # 每个用户跑一次TopK推排 for u in range(n_user): test_pos set(test_user_items[u]) if not test_pos: continue # 候选集: 所有物品去掉训练正样本 candidates list(set(range(n_item)) - train_user_items[u]) u_emb x_emb[u] score u_emb x_emb[n_user:].T # 把训练正样本的位置分数设成极小避免被选入 train_items list(train_user_items[u]) score[train_items] -1e9 topk score.topk(K).indices.cpu().numpy() hits len(set(topk) test_pos) recalls.append(hits / max(len(test_pos), 1)) # NDCG计算位置折扣 dcg 0.0 for rank, item in enumerate(topk): if item in test_pos: dcg 1.0 / np.log2(rank 2) idcg sum([1.0 / np.log2(r 2) for r in range(min(len(test_pos), K))]) ndcgs.append(dcg / max(idcg, 1e-9)) return np.mean(recalls), np.mean(ndcgs)评估代码有个容易出错的地方score[train_items] -1e9。如果不屏蔽训练正样本模型会把自己学过的边又推一遍Recall虚高。另外分配内存时score是一个长度为n_item的稠密向量若物品数超过10万u_emb x_emb[n_user:].T会非常耗内存。这时可以分批计算但毕设级数据一般没到那个量级。测试时基于哪个邻接矩阵我用训练邻接矩阵做节点初始化让所有节点都能有向量然后在前向传播时把测试边临时添加到邻接矩阵中。用邻接矩阵上的稀疏加法即可不需要重新归一化因为只是替换一部分边。这个细节写到论文里会显得你理解足够透彻。5. AGNN避坑指南数据、训练与收敛的5个常见问题5.1 特征尺度不一致导致Loss变NaN现象训练几个epoch后loss变成NaN模型输出全是nan。原因用户属性里年龄是几十、职业是0/1、物品年份是四位数这些数值一起进Linear层输出会被大数值特征主导梯度爆炸。解决所有数值型属性先做z-score标准化均值0、方差1离群值用clip截断到[-3, 3]。类别型独热编码后可以用Embedding层压缩维度但直接用标准化的稠密编码也能收敛。检查方法在训练前打印X的数值范围确保所有列在相近的尺度内。5.2 忘记加自环节点属性“传不进来”现象模型指标一直徘徊在0.5附近怎么调参都没用。原因邻接矩阵没有加对角线1spmm之后每个节点只汇聚了邻居特征自身的初始属性完全没有参与更新。如果自己还没有交互那么这一层的输出等于0或只有邻居信号属性等于白喂。解决在构建邻接矩阵时执行adj adj sp.eye(n_user n_item)。很多现成库的normalize函数会自动加自环但自己写的代码里很容易漏。加上自环后每个节点都有一条到自己的边消息传递时身份信息不会被稀释。5.3 负采样比例失衡导致模型“躺平”现象训练loss很小但Recall10只有0.2甚至更低模型基本把所有物品都预测成低分。原因当负采样比例过高比如110模型看到大量负样本优化方向变成“把所有分数压低”正样本的梯度被淹没推导出的TopK随机性很强。反过来如果无负样本模型预测全为1。解决把负采样比例设为11或12。如果你的数据正样本过于稀疏可以每个epoch重新采样负样本这样不会重复学习某几个固定负样本。我实际实验中13以上的比例几乎总让Recall明显下降。5.4 测试集删边太干净评估结果失真现象验证集指标很高但线上或自己随机遮挡边表现差很多。原因有些实现只是为了简单把测试集的用户所有交互边全部移除再预测这些边。但移除后该用户在训练邻接矩阵里成了“孤立节点”没有邻居可以聚合属性退化成纯属性预测。这不仅低估了模型还会让梯度无法回传到用户的邻居节点。解决正确做法是每个用户只移除少量测试边如10%剩下90%的交互边仍保留在训练邻接矩阵中。这样才能测到“已知用户部分行为预测新行为”的真实场景也是论文里普遍使用的leave-one-out评估法。5.5 GCN堆叠过深引来过平滑现象从三层GCN开始验证集NDCG反而下降节点embedding之间的内积趋于相同推荐结果就像按全局热门排序。原因每一次spmm都是在做邻居平均层数越多每个节点表示越接近全网平均值个性化消失。这在推荐里比在CV/NLP里更致命因为推荐本质就是靠不同节点的区分度来排序。解决模型严格控制在两层或者一层。如果确实需要捕捉高阶关系应该增加注意力机制或残差连接而不是单纯堆层。残差可以这样加在每层输出后加上输入X防止节点表示被周围完全“吃掉”。但毕设场景我不建议做太复杂的变体先把两层基准跑好论文里再讨论“为什么不用三层”。6. 让AGNN变可信的进阶操作消融实验与超参对比6.1 消融实验属性、交互边、结构各自的贡献毕设答辩时最容易被问“你这个模型到底好在哪里”。空口无凭你得能跑一张表。我通常把AGNN拆成三个变量是否有属性、是否有图传播、层数多少。这样得到四个模型模型是否有属性是否有图传播层数对应的实现改动AGNN-2L有有2本文默认模型无属性无纯ID交互有2把X换成随机初始化的Embedding无图传播有无只过MLP2去掉spmm只做两个LinearReLUMatrix Factorization无无0直接user_emb/item_emb点积这个表跑出来后如果“无属性”版本指标掉得不多说明你的属性特征选得有问题特征工程不到位如果“无图传播”版本指标也差不了太多说明这个数据集下图结构帮助有限可能交互太稠密。很多同学跑完发现带属性居然还变差了那是因为属性里有大量噪声特征比如用户ID本身这种不经筛选的特征会让GCN把不相关信息在图上扩散反而污染表示。这时应该先用单变量特征选择筛掉与交互相关性低的属性。6.2 用验证集调参学习率、隐藏维度、Dropout另一个让我后悔没早做的习惯是把实验记录写进CSV每次跑完保存一份结果。AGNN超参数不多但组合起来足够让你调一个晚上。我建议按这个顺序调第一固定隐藏维度64Dropout 0.2学习率从1e-3开始每次验证集Recall不再上升就把学习率减半直到减到1e-5为止。这个阶段大概跑10个epoch就能看到趋势。第二确认学习率后跑隐藏维度[16, 32, 64, 128]的对比。注意隐藏维度并不是越大越好维度越大越容易把训练集稀疏交互硬记住验证集NDCG会先升后降。第三Dropout在0.1到0.5之间对比交互数据越稀疏Dropout应该越高。不要忽略激活函数。ReLU是最稳的选择但如果你发现训练过程中容易死亡输出全为负换成LeakyReLUnegative_slope0.1能救回来。我在AGNN上试过tanh效果并不好因为图卷积的均值聚合会让tanh的输出快速饱和。最后一个实验上的个人习惯所有模型都用同一个随机种子、同一个测试划分并在跑完每个配置后保存最佳模型的参数和对应epoch。这样你能画出“训练epoch-验证指标”曲线答辩时展示“模型在第3个epoch就过拟合”比任何解释都有说服力。这条曲线也是判断模型是否正常收敛的后悔药——很多图神经网络在稀疏数据上从第1个epoch就开始过拟合不保存历史参数你只能眼睁睁看着它跑飞。希望这篇笔记能让你把AGNN当成一个“可解释的图推荐基线”而不是黑匣子。如果跑不出论文里的指标先从检查负采样和属性特征入手这两处修好效果通常不会差。祝你的毕设和项目都顺利希望帮到你。本文还有配套的精品资源点击获取