简介:图神经网络(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"], weight=row["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 信任 C,A 对 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, heads=4, dropout=0.2): super().__init__() # 多头注意力:每个头各自关注一类关系模式,最后拼接 self.gat_1 = GATConv(in_dim, hidden_dim, heads=heads, dropout=dropout) # 第二层把多头输出压缩回 hidden_dim,concat=False 表示平均而不是拼接 self.gat_2 = GATConv(hidden_dim * heads, hidden_dim, heads=1, concat=False, dropout=dropout) def forward(self, x, edge_index, edge_weight=None): x = self.gat_1(x, edge_index, edge_weight=edge_weight) x = torch.relu(x) x = self.gat_2(x, edge_index, edge_weight=edge_weight) return x代码里heads=4是较稳妥的起步值,每个注意力头学一种关系侧写。如果数据量小,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, heads=4, num_layers=1): super().__init__() self.gat = GATEncoder(feat_dim, hidden_dim, heads=heads) self.gru = nn.GRU( input_size=hidden_dim, # 每个快照经 GAT 压缩后的节点表示维度 hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, ) 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, dim=1) # [num_nodes, seq_len, hidden] _, hidden = self.gru(emb_stack) # 最后一个隐状态代表整条历史 score = self.scorer(hidden[-1]) # [num_nodes, 1] return score这里最需要理解的是 GRU 的输入排列。batch_first=True表示输入形状是[节点数, 时间步, 特征维度],所以每个时间步对应一张图快照的全节点表示。hidden[-1]取的是最后一层最后一个时间步的隐状态,它综合了整个序列信息。num_layers=1对中等规模数据够用,堆两层会增强时序建模能力,但也更容易过拟合,需要配合 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_dim=64, hidden_dim=128, heads=4) optimizer = torch.optim.Adam(model.parameters(), lr=1e-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(f"epoch {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 | 评价者用户ID |
| target | 被评价者用户ID |
| predicted_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_reentrant=False)这样每个时间步的 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传入 GAT,softmax 对负数权重特别敏感。负权重绝对值太大,注意力分布容易出现极端值,进而梯度爆炸。
解决:把权重压缩到 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, encoding="latin1")如果encoding="latin1"还不行,就要确认对方保存时用的 pickle 协议版本。protocol=4之后的文件在 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, dtype=torch.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 在时间上严格分段,再谈调参。这套流程帮我少做了大量无效实验,也是这份资源里最值得带走的习惯,希望帮到你。
本文还有配套的精品资源,点击获取