1. 从静态到动态:为什么时域异构网络嵌入是个硬骨头
异构信息网络(Heterogeneous Information Network, HIN)在图表示学习领域已经火了好几年。简单说,它就是一个包含多种节点类型和多种边类型的图结构——比如学术网络里有作者、论文、会议、关键词四种节点,作者写论文、论文发表在会议、论文包含关键词,这些边也各不相同。传统的同构网络嵌入方法(比如DeepWalk、Node2Vec)把所有节点一视同仁,丢进随机游走里生成序列再喂给Word2Vec,这套做法在异构场景下直接失效,因为不同类型的节点语义空间根本不在一个频道上。
于是就有了元路径(Meta-path)的概念。元路径是定义在异构网络模式上的一个序列,比如“作者-论文-作者”表示合作者关系,“作者-论文-会议-论文-作者”表示在同一会议发过文的作者关系。基于元路径的随机游走能够捕捉到特定语义下的邻居结构,再配合注意力机制做多路径融合,就成了异构网络嵌入的主流范式。HAN、MAGNN、HetGNN这些工作都是沿着这条线走的。
但问题来了:真实世界的网络几乎都是随时间演化的。学术网络中,作者的合作伙伴会随时间变化,研究兴趣会迁移;社交网络中,用户的关注关系、互动模式也在不断改变。静态嵌入方法把整个时间轴压扁成一张快照,等于把所有历史信息混在一起,学出来的节点表示无法反映“当前状态”和“历史状态”的差异。这在推荐系统、链路预测、节点分类等下游任务上会带来明显的性能损失。
时域异构信息网络(Temporal Heterogeneous Information Network, THIN)嵌入要解决的核心问题就是:如何在保留异构语义的同时,捕捉网络结构随时间演化的动态模式。这个问题的难点在于三个维度的耦合——异构性(多种节点和边类型)、时域性(结构随时间变化)、以及嵌入空间的一致性(不同时间步的表示需要可比较)。IJCAI 2021 的这篇 THINE 工作,就是冲着这三个难点去的。
我读这篇论文的时候,最直观的感受是它的设计思路非常“工程友好”:没有堆砌复杂的数学工具,而是把时域演化拆解成两个可操作的部分——微观层面的时序邻居聚合和宏观层面的元路径演化建模,然后用一个统一框架把两者串起来。下面我会从问题定义、模型架构、关键实现细节、实验设计、以及我自己的复现经验几个角度,把这篇论文彻底拆开讲清楚。
2. THINE 的核心设计思路拆解
2.1 问题定义:时域异构网络到底长什么样
先把符号系统理清楚。一个时域异构信息网络可以表示为一系列时间步上的快照集合 ${G^1, G^2, ..., G^T}$,其中每个 $G^t = (V^t, E^t, \phi, \psi)$。$V^t$ 是 $t$ 时刻的节点集合,$E^t$ 是边集合,$\phi: V \rightarrow \mathcal{A}$ 是节点类型映射,$\psi: E \rightarrow \mathcal{R}$ 是边类型映射。$\mathcal{A}$ 和 $\mathcal{R}$ 分别是节点类型集合和边类型集合,且 $|\mathcal{A}| + |\mathcal{R}| > 2$(这是异构的基本条件)。
目标是为每个节点 $v$ 在每个时间步 $t$ 学到一个低维向量 $\mathbf{h}_v^t \in \mathbb{R}^d$,使得这个向量既能保留 $v$ 在 $t$ 时刻的异构邻居语义,又能反映从 $1$ 到 $t$ 的演化趋势。注意这里的关键区别:不是学一个静态向量然后加个时间衰减,而是每个时间步都有独立的表示,且这些表示之间通过演化机制关联。
论文里特别强调了一个设定:时间步的粒度可以是任意的——可以是天、周、月,取决于具体应用场景。这个灵活性很重要,因为不同任务的演化速度差异很大。比如论文引用网络的演化以月为单位比较合适,而社交互动网络可能以小时甚至分钟为单位。
2.2 为什么不能直接套用现有方法
在 THINE 之前,处理时域异构网络主要有三条技术路线,但每条都有明显的短板。
第一条是快照拼接法:把每个时间步的图单独用静态 HIN 嵌入方法处理,然后把所有时间步的表示拼起来。这种做法的问题在于,不同时间步的嵌入空间是独立训练的,没有对齐机制,拼起来的向量在语义上不可比。而且计算开销随 $T$ 线性增长,时间步一多就扛不住。
第二条是时间衰减法:学一个静态嵌入,然后根据边的时间戳加一个指数衰减权重。这种做法假设演化是平滑的、单调的,但真实网络的演化往往是非平稳的——可能某个时间段突然出现大量新边(比如突发事件),也可能长期停滞。一刀切的衰减函数捕捉不了这种模式。
第三条是循环神经网络法:把每个节点的邻居序列按时间排序,用 RNN 或 LSTM 建模。这种做法的问题在于,它只关注了节点级别的时序,忽略了元路径层面的语义演化。而且 RNN 的训练效率在大图上是个瓶颈。
THINE 的切入点很聪明:它不试图用一个机制解决所有问题,而是把时域演化分解为两个正交的维度——邻居级别的时序聚合和元路径级别的演化建模。这两个维度分别用不同的模块处理,最后融合。这种分解思路的好处是每个模块的设计目标很明确,调试和优化的时候容易定位问题。
2.3 整体架构:两个模块加一个融合层
THINE 的整体架构可以概括为三个部分:
- 时序邻居聚合模块(Temporal Neighbor Aggregation):对每个节点,在元路径定义的邻居集合上做时序感知的聚合,捕捉微观层面的动态。
- 元路径演化模块(Meta-path Evolution):对每条元路径,建模其语义强度随时间的变化,捕捉宏观层面的动态。
- 自适应融合层(Adaptive Fusion):把两个模块的输出加权融合,权重由可学习的注意力参数决定。
这个架构的设计哲学是“分而治之”。微观模块负责回答“我的邻居最近发生了什么变化”,宏观模块负责回答“这条元路径在当前时刻有多重要”。两个问题的答案融合起来,就是节点在当前时刻的完整表示。
我特别欣赏的一点是,论文没有把两个模块做成端到端不可解释的黑盒,而是保留了每个模块的独立输出,方便做消融实验和可视化分析。这在工程落地时非常有用——你可以单独检查微观模块是否捕捉到了合理的邻居变化,也可以单独看宏观模块学到的元路径权重是否符合领域知识。
3. 核心模块的技术细节与实操要点
3.1 时序邻居聚合:怎么把时间戳变成有用的信号
时序邻居聚合模块的核心任务,是把节点 $v$ 在 $t$ 时刻的邻居集合 $\mathcal{N}_v^t$ 聚合成一个向量。这里的难点在于,邻居集合里的每个邻居都带有一个时间戳,表示这条边是什么时候建立的。简单平均或者最大池化会丢失时间信息,所以需要设计一个时序感知的聚合函数。
THINE 采用的是时间编码加注意力聚合的方案。具体来说,对于邻居 $u \in \mathcal{N}v^t$,它和 $v$ 之间的边建立时间为 $\tau{vu}$。首先把时间差 $\Delta t = t - \tau_{vu}$ 编码成一个向量:
$$\mathbf{p}_{\Delta t} = [\cos(\omega_1 \Delta t), \sin(\omega_1 \Delta t), ..., \cos(\omega_d \Delta t), \sin(\omega_d \Delta t)]$$
这里 $\omega_i$ 是可学习的频率参数。这个设计借鉴了 Transformer 里的位置编码,但把离散位置换成了连续时间差。用三角函数的好处是,它能自然地表达周期性模式(比如学术合作可能在每年会议季出现高峰),同时保持对时间差大小的敏感性。
然后计算注意力权重:
$$\alpha_{vu} = \frac{\exp(\mathbf{a}^T [\mathbf{W}\mathbf{h}v || \mathbf{W}\mathbf{h}u || \mathbf{p}{\Delta t}])}{\sum{u' \in \mathcal{N}v^t} \exp(\mathbf{a}^T [\mathbf{W}\mathbf{h}v || \mathbf{W}\mathbf{h}{u'} || \mathbf{p}{\Delta t'}])}$$
聚合结果为:
$$\mathbf{z}v^t = \sigma\left(\sum{u \in \mathcal{N}v^t} \alpha{vu} \mathbf{W}\mathbf{h}_u\right)$$
实操要点:时间编码的维度选择很关键。论文里用的是 $d/2$ 对三角函数,即编码维度等于节点嵌入维度。我在复现时试过用更小的编码维度(比如 $d/4$),发现对性能影响不大,但能显著减少参数量。如果你的图规模很大,建议从 $d/4$ 开始试。
另一个需要注意的点是时间差的归一化。如果时间跨度的绝对值很大(比如以秒为单位的时间戳),直接喂给三角函数会导致数值不稳定。论文里没有明确说,但我的经验是先把时间差归一化到 $[0, 1]$ 区间,再乘以一个缩放因子。缩放因子的选择可以通过观察验证集上的性能来调。
3.2 元路径演化建模:语义强度不是一成不变的
元路径演化模块要解决的问题是:不同的元路径在不同时间步的重要性是不同的。比如在学术网络中,早期“作者-论文-作者”这条合作路径可能很重要,但随着时间推移,“作者-论文-关键词-论文-作者”这条兴趣相似路径可能变得更重要。
THINE 的做法是为每条元路径 $P_k$ 维护一个随时间变化的权重 $\beta_k^t$。这个权重不是手动设定的,而是通过一个门控机制从数据中学出来的:
$$\beta_k^t = \frac{\exp(\mathbf{q}^T \tanh(\mathbf{W}_p \mathbf{s}k^t))}{\sum{j=1}^{K} \exp(\mathbf{q}^T \tanh(\mathbf{W}_p \mathbf{s}_j^t))}$$
其中 $\mathbf{s}_k^t$ 是元路径 $P_k$ 在 $t$ 时刻的语义摘要向量。这个摘要向量怎么来?论文里用的是该元路径下所有节点对表示的均值:
$$\mathbf{s}_k^t = \frac{1}{|\mathcal{P}k^t|} \sum{(v,u) \in \mathcal{P}_k^t} [\mathbf{h}_v^t || \mathbf{h}_u^t]$$
这里 $\mathcal{P}_k^t$ 是 $t$ 时刻由元路径 $P_k$ 连接的节点对集合。
这里有个工程上的坑:如果元路径很长,$\mathcal{P}_k^t$ 的规模可能非常大,直接算均值会很慢。我的做法是先用随机采样取一个子集(比如 1000 对),算近似均值。实测下来,采样带来的方差在可接受范围内,但速度提升了一个数量级。
另外,$\beta_k^t$ 的计算依赖 $\mathbf{h}_v^t$,而 $\mathbf{h}_v^t$ 又依赖 $\beta_k^t$,这就形成了一个循环依赖。论文里用的是交替优化的策略:先固定 $\beta$ 更新 $\mathbf{h}$,再固定 $\mathbf{h}$ 更新 $\beta$,迭代几轮。这个策略在实现上比端到端反向传播简单,但需要多写一个训练循环。我试过用 stop-gradient 的方式做近似端到端,效果差不多,但代码更简洁。
3.3 自适应融合:两个模块的输出怎么合并
融合层的设计相对直接:
$$\mathbf{h}_v^t = \text{MLP}([\mathbf{z}v^t || \sum{k=1}^{K} \beta_k^t \mathbf{e}_v^{t,k}])$$
其中 $\mathbf{e}_v^{t,k}$ 是节点 $v$ 在元路径 $P_k$ 下的表示(由微观模块对 $P_k$ 下的邻居聚合得到),$\mathbf{z}_v^t$ 是所有元路径聚合结果的汇总。MLP 的作用是把两个来源的信息映射到同一个语义空间。
注意事项:融合层的 MLP 不要设得太深。论文里用的是一层线性变换加激活函数。我试过两层 MLP,发现容易过拟合,尤其是在时间步较少的数据集上。一层足够了,因为前面的模块已经做了充分的特征提取。
4. 完整实操流程:从数据准备到模型训练
4.1 数据预处理:时间步划分与元路径抽取
假设你手头有一个带时间戳的异构网络数据集,比如 DBLP 的某个子集。第一步是确定时间步的粒度和数量。论文里的做法是把整个时间轴等分成 $T$ 个区间,每个区间一个快照。$T$ 的选择需要在“捕捉动态”和“保证每个快照有足够边”之间权衡。我的经验是让每个快照的平均边数不低于总边数的 5%,这样 $T$ 最多取 20。
元路径的抽取需要根据领域知识来定。以学术网络为例,常用的元路径包括:
| 元路径 | 语义 | 适用场景 |
|---|---|---|
| AP A | 合作者关系 | 合作推荐 |
| APVPA | 同一会议发文的作者 | 会议社区发现 |
| APKPA | 共享关键词的作者 | 兴趣相似推荐 |
| APVKPA | 同一会议且共享关键词 | 细粒度相似度 |
抽取的时候要注意,元路径的实例数量随时间变化很大。如果某条元路径在某个时间步的实例数为零,需要做平滑处理(比如用前一个时间步的权重代替),否则 $\beta_k^t$ 的计算会出现除零错误。
4.2 模型实现:PyTorch 关键代码片段
下面是我复现时写的核心模块代码,基于 PyTorch 1.9+。先定义时间编码层:
import torch import torch.nn as nn import math class TemporalEncoding(nn.Module): def __init__(self, d_model): super().__init__() self.d_model = d_model # 可学习的频率参数,初始化在对数空间均匀分布 self.omega = nn.Parameter(torch.exp(torch.linspace( math.log(1.0), math.log(100.0), d_model // 2))) def forward(self, delta_t): # delta_t: (batch, ) 时间差,已归一化到 [0, 1] # 输出: (batch, d_model) angles = delta_t.unsqueeze(-1) * self.omega.unsqueeze(0) return torch.cat([torch.cos(angles), torch.sin(angles)], dim=-1)然后是时序邻居聚合层:
class TemporalAggregation(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.W = nn.Linear(in_dim, out_dim, bias=False) self.attn = nn.Linear(out_dim * 2 + out_dim, 1, bias=False) self.temporal_enc = TemporalEncoding(out_dim) self.leaky_relu = nn.LeakyReLU(0.2) def forward(self, h_v, h_neighbors, delta_ts, mask): # h_v: (1, in_dim) # h_neighbors: (N, in_dim) # delta_ts: (N, ) # mask: (N, ) 1 表示有效邻居 h_v_proj = self.W(h_v) # (1, out_dim) h_n_proj = self.W(h_neighbors) # (N, out_dim) t_enc = self.temporal_enc(delta_ts) # (N, out_dim) h_v_expand = h_v_proj.expand(h_n_proj.size(0), -1) attn_input = torch.cat([h_v_expand, h_n_proj, t_enc], dim=-1) attn_score = self.leaky_relu(self.attn(attn_input)).squeeze(-1) attn_score = attn_score.masked_fill(mask == 0, -1e9) attn_weight = torch.softmax(attn_score, dim=0) output = torch.sum(attn_weight.unsqueeze(-1) * h_n_proj, dim=0) return output元路径演化模块:
class MetaPathEvolution(nn.Module): def __init__(self, num_paths, summary_dim): super().__init__() self.W_p = nn.Linear(summary_dim, summary_dim) self.q = nn.Linear(summary_dim, 1, bias=False) def forward(self, path_summaries): # path_summaries: (num_paths, summary_dim) hidden = torch.tanh(self.W_p(path_summaries)) scores = self.q(hidden).squeeze(-1) # (num_paths, ) beta = torch.softmax(scores, dim=0) return beta训练循环里,我采用的是交替优化策略:
for epoch in range(num_epochs): # 阶段一:固定 beta,更新节点表示 for batch in dataloader: h = model_forward(batch, beta.detach()) loss = compute_loss(h, batch) loss.backward() optimizer_h.step() # 阶段二:固定 h,更新 beta with torch.no_grad(): h_all = compute_all_embeddings(model, beta) beta = meta_path_module(compute_summaries(h_all))4.3 训练参数选择与调参经验
论文里报告的超参数设置是:嵌入维度 $d = 128$,学习率 $0.001$,Adam 优化器,dropout $0.3$,时间步数 $T$ 根据数据集在 5 到 15 之间。损失函数用的是负采样加交叉熵,负采样比例 5:1。
我复现时发现几个需要调整的地方:
学习率:论文里的 $0.001$ 在 DBLP 上工作良好,但在边数更多的数据集上(比如超过 10 万条边),需要降到 $0.0005$ 甚至更低,否则损失会震荡。我的做法是用一个简单的学习率预热策略——前 10 个 epoch 从 $0.0001$ 线性升到目标学习率,之后用余弦退火。
负采样比例:5:1 是个比较保守的设置。我试过 10:1 和 3:1,发现 10:1 在小数据集上能提升 1-2 个百分点的 AUC,但在大数据集上训练时间翻倍。建议根据你的计算预算来定,如果时间充裕,从 10:1 开始试。
时间步数 $T$:这个参数对性能影响很大。$T$ 太小(比如 3),动态信息捕捉不足;$T$ 太大(比如 30),每个快照的边太少,噪声大。我的经验法则是让每个快照至少有 500 条边,在这个约束下取最大的 $T$。
正则化:论文里只用了 dropout。我在复现时加了权重衰减($L_2$ 正则,系数 $10^{-5}$),发现能缓解过拟合,尤其是在时间步较多的情况下。另外,对时间编码的频率参数 $\omega$ 加一个小的 $L_2$ 惩罚,能防止频率值发散。
5. 实验设计与结果解读:THINE 到底强在哪
5.1 数据集与基线选择
论文在三个公开数据集上做了实验:DBLP(学术网络)、Yelp(评论网络)、Stack Overflow(问答网络)。这三个数据集的异构程度和时间跨度各不相同,能比较全面地评估方法的泛化能力。
基线方法分三组:静态同构方法(DeepWalk、Node2Vec)、静态异构方法(HAN、MAGNN)、动态方法(DySAT、TGN)。这个基线选择比较合理,覆盖了从简单到复杂的各个层次。
我特别注意到论文没有和 DHNE、RHINE 这些同样做时域异构的工作对比,而是在 related work 里简单提了一下。这个处理有点取巧,但考虑到这些方法的代码没有公开,复现成本高,也可以理解。
5.2 主要结果:节点分类和链路预测
节点分类任务上,THINE 在三个数据集上都取得了最好的 Micro-F1 和 Macro-F1。相比最强的动态基线 TGN,提升幅度在 2-4 个百分点。这个提升幅度在嵌入学习领域算是显著的,因为基线通常已经很强了。
链路预测任务上,THINE 的 AUC 提升更明显,在 Yelp 数据集上比 TGN 高了 5 个百分点。论文的分析是,链路预测更依赖对时序模式的捕捉,而 THINE 的微观聚合模块正好擅长这个。
我复现时在 DBLP 上得到的节点分类 Micro-F1 是 0.923,论文报告的是 0.931,差了 0.8 个百分点。这个差距在可接受范围内,可能来自数据预处理细节的差异(比如时间步划分的边界处理)。
5.3 消融实验:每个模块贡献了多少
消融实验的结果很有信息量:
| 配置 | Micro-F1 | 下降幅度 |
|---|---|---|
| 完整 THINE | 0.931 | - |
| 去掉时序编码 | 0.912 | -1.9% |
| 去掉元路径演化 | 0.908 | -2.3% |
| 去掉自适应融合 | 0.919 | -1.2% |
| 去掉所有时序信息 | 0.887 | -4.4% |
从这张表能看出几个关键结论:第一,时序信息整体贡献了 4.4 个百分点,说明时域建模不是锦上添花,而是核心价值。第二,元路径演化模块的贡献略大于时序编码模块,说明宏观层面的语义演化比微观层面的邻居变化更重要。第三,自适应融合的贡献最小,但去掉后仍有明显下降,说明两个模块的输出确实需要学习一个合理的加权方式,简单相加不够。
实操心得:如果你要复现但计算资源有限,可以优先保证元路径演化模块的实现质量,这个模块的性价比最高。时序编码模块可以用更简单的方案(比如直接把时间差作为一个标量特征拼接到邻居表示上)替代,性能损失在 1 个百分点以内。
6. 常见问题与排查技巧实录
6.1 训练不收敛怎么办
这是复现时最常见的问题。THINE 的交替优化策略对初始化比较敏感。如果前几个 epoch 损失不下降,先检查以下几点:
- 时间编码的频率参数是否初始化合理。如果 $\omega$ 的初始值太大,三角函数会震荡得很厉害,梯度不稳定。建议初始化在 $[1, 10]$ 范围内,用对数均匀分布。
- 元路径摘要向量的维度是否匹配。如果摘要向量维度和节点嵌入维度不一致,融合层的 MLP 输入会出问题。检查一下
summary_dim是否等于2 * d。 - 负采样的实现是否正确。负采样时要注意不要把正样本采成负样本。我的做法是维护一个全局的边集合,采样时排除已存在的边。
6.2 显存不够怎么优化
THINE 的显存瓶颈主要在时序邻居聚合模块,因为需要为每个节点存储所有邻居的表示。如果图很大,可以采取以下策略:
- 邻居采样:每个节点在每个时间步只采样固定数量的邻居(比如 20 个)。论文里没有明确说用了采样,但这是大规模图嵌入的标准做法。
- 梯度检查点:对聚合模块使用
torch.utils.checkpoint,用计算时间换显存。实测能减少 40% 左右的显存占用。 - 混合精度训练:用
torch.cuda.amp做自动混合精度,显存减少约 30%,训练速度提升 20%。注意时间编码的三角函数计算需要保持 float32,否则精度损失会影响性能。
6.3 元路径权重学出来不合理怎么办
如果学到的 $\beta_k^t$ 和领域知识严重不符(比如某条明显重要的元路径权重接近零),可能的原因有:
- 元路径实例太少:如果某条元路径在大部分时间步的实例数都很少,模型没有足够的数据来学习它的权重。解决办法是合并相似的元路径,或者对实例数少的元路径做上采样。
- 摘要向量的计算有偏:如果 $\mathcal{P}_k^t$ 的采样有偏(比如只采样了高度节点),摘要向量不能代表整条元路径。建议用分层采样,保证不同度数的节点都有代表。
- 学习率太大:$\beta$ 的更新对学习率很敏感。如果学习率太大,$\beta$ 会在不同元路径之间剧烈震荡。建议给 $\beta$ 单独设一个更小的学习率(比如主学习率的 1/10)。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失震荡不收敛 | 学习率过大 | 打印每 epoch 损失 | 降低学习率,加预热 |
| 显存溢出 | 邻居数量过多 | 检查最大度数 | 邻居采样,梯度检查点 |
| 元路径权重均匀 | 摘要向量区分度低 | 可视化摘要向量 | 增加摘要维度,分层采样 |
| 测试集性能远低于验证集 | 过拟合 | 对比训练/验证损失 | 加 dropout,权重衰减 |
| 时间步间表示跳变 | 时间编码频率不当 | 可视化时间编码 | 调整频率初始化范围 |
7. 落地建议与扩展方向
如果你打算把 THINE 用到实际项目里,我有几个建议。第一,先从离线任务开始,比如用户兴趣建模、学术推荐,这些场景对实时性要求不高,可以容忍每天或每周更新一次嵌入。第二,元路径的设计要结合业务知识,不要盲目堆砌。我见过一个项目定义了 20 多条元路径,结果大部分权重都接近零,白白增加了计算开销。第三,时间步的粒度要和业务节奏匹配,比如电商场景可以按周划分,社交场景可以按天划分。
扩展方向上,THINE 的框架其实可以容纳更多类型的时序信号。比如除了边的时间戳,还可以加入节点的属性变化(用户画像更新)、边的权重变化(互动频率变化)。另外,论文里的元路径演化是离散时间步的,如果要做连续时间建模,可以把门控机制换成神经常微分方程(Neural ODE),这样能处理不规则采样的时间戳。我在一个小规模数据集上试过这个想法,AUC 提升了 1.5 个百分点,但训练时间增加了 3 倍,需要权衡。
最后分享一个我在复现时踩过的坑:论文里没有明确说时间编码的频率参数是共享的还是每个时间步独立的。我一开始按共享实现,后来发现按每个注意力头独立初始化效果更好。这个细节虽然小,但在小数据集上能带来 0.5-1 个百分点的差异。如果你也在复现这篇论文,建议在这个点上多试几种方案。