ARTICLE DETAIL

资讯详情

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

基于GAT和GRU的动态信任评估模型DTEM实践详解

基于GAT和GRU的动态信任评估模型DTEM实践详解 简介图神经网络GNN是处理关系数据的强大范式它通过消息传递聚合邻居信息让模型能够学习节点间的复杂依赖。在众多GNN变体中图注意力网络GAT利用注意力机制为不同邻居分配权重从而更精准地捕捉空间结构而门控循环单元GRU则擅长对时序数据进行建模。当关系随时间动态演变时单独使用任一方法都难以全面刻画数据。动态信任评估模型DTEM将两者结合使用GAT捕获每个时间步的信任网络结构使用GRU建模信任随时间的演变实现信任分数的序列预测。该模型适用于社会网络分析、风险控制、信用评估等场景尤其适合需要同时考虑拓扑结构与时间变化的应用。本文从数据预处理到模型训练详细展示了DTEM的工程落地过程并总结了时序切分、负采样等关键坑点为研究人员提供完整参考。1. 图神经网络的信任评估这个DTEM项目到底解决什么问题很多人第一次接触图神经网络的信任评估想的还是电商或者社交平台里的“打分预测”用户A给用户B打过几次分能不能用这些历史分去猜下一次打分。但实际业务里信任不是静态的。上个月大量交易的对手这个月可能就卷款跑路两个人三年前是强信任关系今年已经几乎没有互动。如果模型只依赖用户画像和当前社交网络结构它天然漏掉了“信任会随时间变化”这一层信息。这个开源期末项目给出的答案是以图注意力网络GAT捕获信任的空间依赖性用门控循环单元GRU捕获信任的时间依赖性组合成动态信任评估模型DTEM。它把原始信任记录切成语义清晰的图快照序列再把每个时间点的图结构交给GAT把序列交给GRU最终输出每个用户的信任分。适合正在做图神经网络入门、社会网络分析或需要把“图时序”思路落地成代码的研究者和开发者。2. 数据与输入管线从原始信任CSV到可训练的图快照序列2.1 两个CSV和四列结构信任数据到底长什么样项目根目录下的soc_otc.csv和soc_alpha.csv是两个公开的加密资产交易信任网络数据集每一行都是一条带时间戳的信任评分记录。标准格式是四列source、target、rating、timestamp。source是评价者target是被评价者rating是 -10 到 10 之间的整数正数表示信任负数表示不信任0 通常代表没有明确态度timestamp是这个评分发生的时间。拿到这份资源后第一件事不是直接训练而是先确认数据里有多少条记录、时间跨度多长、评分的正负分布。我一般会先用一段短脚本摸底import pandas as pd df pd.read_csv(soc_otc.csv, names[source, target, rating, timestamp]) print(df.shape) print(df[rating].describe()) print(df[timestamp].min(), df[timestamp].max()) print(df[rating].value_counts().head(10))这是一个典型的“先看数据再看模型”的步骤。shape决定后续图快照的规模rating的分布决定负采样策略timestamp的起止时间决定快照窗口怎么切。如果时间跨度只有一个月那窗口就要按天切如果跨度是一两年切周窗口更合理。数据摸底做的粗糙后面所有实验结论都不可信。2.2 从时序边表到 graph_snapshots.pkl窗口怎么切DTEM 的核心输入是graph_snapshots.pkl它里面存的是一个按时间排序的图快照列表。每个快照代表一个时间窗口内的信任网络快照与快照之间是先后顺序关系GRU 正是沿着这个顺序学习信任的时间依赖。常见做法是把整段时间轴切成长度相等的窗口窗口大小的选择直接决定模型看到的时间粒度。我一般先用周窗口跑基线再尝试半月窗口因为对于信任这种频繁变化的关系窗口太粗会抹掉突变太细则每张图太稀疏。import pandas as pd import networkx as nx import pickle RAW soc_otc.csv WINDOW 7 * 24 * 3600 # 一周一个快照 EMB train_embeddings.pkl df pd.read_csv(RAW, names[source, target, rating, timestamp]) df df.sort_values(timestamp) embeddings pickle.load(open(EMB, rb)) snapshots [] cur_start df[timestamp].min() while cur_start df[timestamp].max(): end cur_start WINDOW window_df df[(df[timestamp] cur_start) (df[timestamp] end)] g nx.DiGraph() for _, row in window_df.iterrows(): g.add_edge(row[source], row[target], weightrow[rating]) for node in g.nodes(): if node in embeddings: g.nodes[node][x] embeddings[node] else: g.nodes[node][x] None # 新用户没有预训练嵌入用零向量兜底 snapshots.append(g) cur_start end pickle.dump(snapshots, open(graph_snapshots.pkl, wb))这段脚本里有三个地方要特意说明。第一个是快照切分用半开区间[cur_start, end)避免同一条边被两个窗口重复统计。第二个是每条边都保留原始rating作为weight后面过 GAT 时会用到。第三个是节点特征从train_embeddings.pkl里取如果有些用户只出现在边里没有预训练嵌入顺手填零向量否则一进 GAT 就是空特征报错。2.3 train_embeddings.pkl用户静态特征从哪里来train_embeddings.pkl是这份资源里最容易被人忽略的文件。它存的是用户级的稠密嵌入向量维度和数据类型在 readme.md 里能查到。它的作用是给每个快照里的节点提供初始特征输入x。项目作者没有把用户画像单独建模而是用预训练嵌入表达用户的长期行为特征这些嵌入在训练时既可以冻结也可以跟着 GAT 一起微调。这里有个容易翻车的细节如果嵌入文件的索引是字符串形式的用户ID而边表里是整数两者不匹配会导致大量节点特征为空。我一般会在加载后强制统一类型并且打印一波无特征节点的比例import pickle embeddings pickle.load(open(train_embeddings.pkl, rb)) keys list(embeddings.keys())[:5] print(keys) print(type(keys[0]))如果 key 类型和边表节点类型不一致用str()或int()统一一侧即可。这一步能在模型训练前把最琐碎的数据对齐问题解决掉而不是等到 loss 变成 NaN 再回头查。图快照本身是不带方向的边列表GAT 对多关系支持有限所以方向性信息主要靠edge_index中的(src, dst)顺序保留在后续模型中会用到。3. 模型结构拆解GAT抓空间依赖、GRU抓时间依赖是怎么合流的3.1 图注意力层为什么选GAT而不是普通GCN信任传递不是均匀扩散的。A 信任 B、B 信任 CA 对 C 的信任程度取决于 B 在 A 心中的分量。GCN 用邻接矩阵做平均聚合等于把所有邻居一视同仁而 GAT 通过注意力机制计算邻居权重能区分“这个邻居的意见更重要”和“那个邻居只是个路人”。在信任评估场景里这层区别很关键因为社交网络里大多数边是弱连接。如果模型给弱连接和强信任关系同样的聚合权重学出来的节点表示会把真正重要的信任信号淹没掉。这也是这份资源选 GAT 的核心原因它让模型自己决定在聚合时应该更看重哪些邻居。import torch import torch.nn as nn from torch_geometric.nn import GATConv class GATEncoder(nn.Module): 每个时间步输入一张图快照输出每个节点的新表示 def __init__(self, in_dim, hidden_dim, heads4, dropout0.2): super().__init__() # 多头注意力每个头各自关注一类关系模式最后拼接 self.gat_1 GATConv(in_dim, hidden_dim, headsheads, dropoutdropout) # 第二层把多头输出压缩回 hidden_dimconcatFalse 表示平均而不是拼接 self.gat_2 GATConv(hidden_dim * heads, hidden_dim, heads1, concatFalse, dropoutdropout) def forward(self, x, edge_index, edge_weightNone): x self.gat_1(x, edge_index, edge_weightedge_weight) x torch.relu(x) x self.gat_2(x, edge_index, edge_weightedge_weight) return x代码里heads4是较稳妥的起步值每个注意力头学一种关系侧写。如果数据量小heads改成 2 也能跑数据量大可以试 8但显存和训练时间会明显上涨。edge_weight把窗口内的信任评分直接作为注意力的边权重传入评分高的边在聚合时天然占更大比重这也是把业务先验注入模型的一种方式。如果rating范围跨度太大比如 -10 到 10建议先归一化到 0 到 1 区间否则负数评分会让 softmax 分母出现剧烈波动。3.2 GRU 序列压缩每个时间步喂一张快照的节点表示GAT 负责回答“当前时刻谁和谁互相影响”GRU 负责回答“这种影响随时间怎么演变”。整个 DTEM 的输入是快照序列模型的 forward 要按时间步逐个过 GAT再把每个时间步得到的节点表示序列喂给 GRU最终用最后一个隐状态做信任分预测。class DTEM(nn.Module): def __init__(self, feat_dim, hidden_dim, heads4, num_layers1): super().__init__() self.gat GATEncoder(feat_dim, hidden_dim, headsheads) self.gru nn.GRU( input_sizehidden_dim, # 每个快照经 GAT 压缩后的节点表示维度 hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, ) self.scorer nn.Sequential( nn.Linear(hidden_dim, 64), nn.ReLU(), nn.Linear(64, 1), # 输出 logit训练时接 BCEWithLogitsLoss ) def forward(self, x_seq, edges_seq): # x_seq: [num_nodes, seq_len, feat_dim] embeddings [] for t in range(x_seq.size(1)): emb self.gat(x_seq[:, t], edges_seq[t]) embeddings.append(emb) emb_stack torch.stack(embeddings, dim1) # [num_nodes, seq_len, hidden] _, hidden self.gru(emb_stack) # 最后一个隐状态代表整条历史 score self.scorer(hidden[-1]) # [num_nodes, 1] return score这里最需要理解的是 GRU 的输入排列。batch_firstTrue表示输入形状是[节点数, 时间步, 特征维度]所以每个时间步对应一张图快照的全节点表示。hidden[-1]取的是最后一层最后一个时间步的隐状态它综合了整个序列信息。num_layers1对中等规模数据够用堆两层会增强时序建模能力但也更容易过拟合需要配合 dropout。3.3 融合打分相似用户的更高权重从哪里体现项目摘要里有个容易误解的表述为相似用户分配更高的权重。这不是在 GAT 的注意力里直接计算用户相似度而是通过把用户特征和社交网络结构同时输入模型让 GRU 学到“行为模式相近的用户隐状态也相近”从而在预测时给这些用户相似的评价。也就是说相似性不是预先定义的规则而是模型从特征和网络结构中隐式学出来的。实际预测时用户对的信任分需要把两个节点的隐状态组合起来。项目源代码里 scorer 只吃单个节点的表示打分的是节点维度这对应“预测某个用户的整体可信度”如果想让模型预测“A 对 B 的信任倾向”需要再拼接或做点积。我在复现时习惯两种都试先跑节点级打分确认 loss 能收敛再扩展成边级预测。边级预测时常见做法是把两个节点的表示拼接后过 MLP代码改动很小但换来的表达能力要强不少。4. 训练与验证从main.log到predicted_trust_values.csv的完整路径4.1 训练策略负采样与标签构造信任预测本质上是边预测任务。正样本是数据里真实存在的信任边负样本需要自己构造。最朴素的做法是在图上随机采样不存在的节点对作为负样本让模型学会区分“有信任”和“没信任”。但如果负样本全是随机采的模型会学偏因为真实数据里用户之间建立信任的概率本来就低随机采样太容易了。更贴近业务的做法是把负样本分成两类完全不存在的用户对以及诚实存在的低评分用户对。前者让模型学会判断“这俩人有没有关系”后者让模型学会判断“这段关系是好是坏”。我在这个项目里习惯把两类负样本按 1:1 混合总采样比例控制在 1:2 左右也就是每条正样本配两条负样本。这个比例不是拍脑袋定的负样本太少了模型会偏向预测正样本出现后面避坑章节里“预测分数全在 0.9 以上”的现象。4.2 一个能跑的训练循环骨架import torch import torch.nn.functional as F model DTEM(feat_dim64, hidden_dim128, heads4) optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion torch.nn.BCEWithLogitsLoss() for epoch in range(epochs): model.train() optimizer.zero_grad() pos_scores model(x_seq, edges_seq).gather(0, pos_index) neg_scores model(x_seq, edges_seq).gather(0, neg_index) loss criterion(pos_scores, torch.ones_like(pos_scores)) \ criterion(neg_scores, torch.zeros_like(neg_scores)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() if epoch % 10 0: auc evaluate(model, val_snapshots, val_pos_edges, val_neg_edges) print(fepoch {epoch}, loss{loss.item():.4f}, auc{auc:.4f})这段循环里有三个点值得展开。第一是clip_grad_norm_多时间步展开的 GRU 很容易梯度爆炸限制梯度范数是必须的不是可选项。第二是gather操作它先从所有节点的预测分数里取出边对应的首尾节点分数再组合避免每步都做全量预测。第三是evaluate函数里重新跑一遍前向训练时用dropout验证时要切到model.eval()再跑否则每次预测结果带随机性AUC 会忽高忽低。损失函数用的是BCEWithLogitsLoss它内部把 sigmoid 和交叉熵合并了数值上比分开写更稳定。如果预训练嵌入是冻结的优化器只需要更新 GAT 和 GRU 参数如果需要微调嵌入要把嵌入矩阵也注册进优化器但学习率要调低一个数量级否则预训练信息会被冲掉。4.3 验证指标与 predicted_trust_values.csv 怎么解读模型跑完会输出predicted_trust_values.csv这是最终交付物。表格是这样几列字段含义source评价者用户IDtarget被评价者用户IDpredicted_trust模型预测的信任分0 到 1 之间ground_truth真实评分验证集才有验证时建议同时看 AUC 和预测分分布。AUC 衡量排序能力但 AUC 高不代表预测分数标定得好预测分整体偏高或偏低说明负采样或者损失权重有问题。main.log 里每 10 个 epoch 打印的 loss 和 AUC 是判断训练是否正常的直接依据。我通常要求自己先看到 loss 在前 20 个 epoch 内稳步下降再观察 AUC 是否同步上升如果 loss 降了 AUC 不动说明模型在拟合训练集噪声要立刻调小 hidden_dim 或者加强正则。5. 避坑与排查图神经网络信任评估项目里五个高频翻车点5.1 快照一多GRU 直接 OOM现象把时间窗口切细之后快照数量从十几个涨到几十个训练时显存直接报错程序退出。原因DTEM 是按时间步逐个跑 GAT 的每一步的计算图都会被 GRU 反向传播保留。快照数量越多计算图越长显存消耗近似线性上涨。解决先检查是不是每个快照都保留了完整计算图。常见做法是减少快照数量把周窗口改成双周窗口或者降低heads和hidden_dim。如果这些都不够可以用torch.utils.checkpoint对 GAT 的 forward 做梯度检查点用时间换显存from torch.utils.checkpoint import checkpoint def gat_step(t, x_seq, edges_seq): return self.gat(x_seq[:, t], edges_seq[t]) emb checkpoint(gat_step, t, x_seq, edges_seq, use_reentrantFalse)这样每个时间步的 GAT 计算不再完整保存中间激活反向传播时重新算一遍。训练时间变长但显存占用从几十个快照的线性叠加降到了常数级别。5.2 预测分数全部偏高几乎没有低于 0.7 的样本现象验证集 Auc还有 0.8 以上把模型输出的分数直方图打出来一看大部分样本集中在 0.9 到 1.0。原因负采样比例失衡。如果负样本数量太少或者负样本全是随机抽的不存在边模型很容易找到“只要两个用户出现在同一张图里就判信任”的捷径。解决把负采样比例提高到正样本的两倍并在负样本里加入“真实存在但评分很低”的边。后者是关键它让模型必须学清楚评分高低而不只是有没有边。调整采样后再观察分数分布是否变得分散。如果仍然偏置可以给负样本损失加权重把负样本的权重从 1.0 提到 1.5。5.3 验证集AUC虚高一上线就崩现象训练和验证都在同一批用户上切分验证AUC很好换到下游任务就明显变差。原因训练集、验证集、测试集按行随机划分同一用户不同月份的信任记录同时落在两个集合里模型在验证期“见过”了这些用户的特征指标被污染。解决这种问题在时序数据里几乎必然出现。要从时间维度严格切分——前 70% 时间的数据做训练中间 15% 做验证最后 15% 做测试并且保证验证集和测试集的用户与训练集用户有重叠但时间完全不重叠。这是时序预测里计算指标的底线我在每次复现时都会先检查切分代码里有没有sample()或shuffle()有就立刻改掉。5.4 GAT 输出数值爆炸loss 变成 NaN现象训练到某一步 loss 突然变成nan往前翻 main.log前面几个 epoch 数值已经异常放大。原因原始rating范围 -10 到 10直接作为edge_weight传入 GATsoftmax 对负数权重特别敏感。负权重绝对值太大注意力分布容易出现极端值进而梯度爆炸。解决把权重压缩到 0 到 1 区间。最简单的方式是weight (rating 10) / 20把 -10 到 10 映射到 0 到 1。如果只想保留正信任关系也可以过滤掉负评分边只保留weight 0的边效果看业务设定。修改后注意重跑数据摸底因为过滤负边会显著改变图的连通结构。5.5 pickle 加载失败graph_snapshots.pkl 报错现象换了一台机器或 Python 版本重新跑pickle.load(open(graph_snapshots.pkl, rb))直接抛异常。原因pickle 序列化对 Python 版本和依赖库的类路径有要求如果对方是用 Python 3.6 和旧版 networkx 保存的图对象新版本解释器可能不认识。解决加载时锁定编码并做一次降级读取。常见做法是import pickle with open(graph_snapshots.pkl, rb) as f: snapshots pickle.load(f, encodinglatin1)如果encodinglatin1还不行就要确认对方保存时用的 pickle 协议版本。protocol4之后的文件在 Python 3.8 上基本兼容。遇到这种问题最有效的办法是让项目作者在 readme 里写清楚 Python 和 torch_geometric 版本这份资源的 readme 确实写明了版本要求第一次跑之前先对一遍能省掉很多排查时间。6. 进阶把 DTEM 移植到自己的数据集时改这三个地方6.1 从原始交互日志到可训练快照复用前的三个改动位置换数据集复现时不需要改模型结构改的是数据入口。第一个要改的地方是原始交互表的字段映射任何“谁对谁做了什么、带着什么态度、在什么时间”的日志都能映射成source, target, rating, timestamp四列。第二个要改的是用户特征自己的数据没有预训练嵌入时最简单的是用用户维度的统计特征比如历史被评价次数、平均分、活跃天数过一层Linear变成特征向量import torch # 假设 node_feats 是 [num_nodes, feat_dim] 的统计特征 x torch.tensor(node_feats, dtypetorch.float32) feat_proj torch.nn.Linear(node_feats.shape[1], 64) x feat_proj(x) # 对齐 GAT 输入维度第三个要改的是超参数表。这是我复现时固定的几个核心参数窗口大小、heads、hidden_dim、负采样比例、学习率。默认值分别是 7 天、4、128、1:2、1e-3。数据稀疏时窗口拉长到 14 天数据量大时heads可以试 8负样本比例按正样本的 1 到 2 倍之间调。改完这三个地方DTEM 就能扛住自己的数据。我后来跑别的业务数据时每次都强制先过一遍时间泄漏检查确认 train、val、test 在时间上严格分段再谈调参。这套流程帮我少做了大量无效实验也是这份资源里最值得带走的习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表