
HetSANN 是 AAAI 2020 上的一篇异构图神经网络论文全称是 An Attention-based Graph Neural Network for Heterogeneous Structural Learning。这几年做图神经网络的人越来越多但绝大多数经典模型默认图是同质的节点只有一种类型边也只有一种关系。真到业务场景里数据几乎都是异构的——电商里有用户、商品、店铺学术网络里有作者、论文、会议节点类型不同边也各有各的含义。硬用 GCN、GAT 把这些信息压成一张普通邻接矩阵等于在聚合时把苹果、香蕉、橘子混进同一个榨汁机榨出来的汁谁也分不清是哪种水果。HetSANN 这篇工作的核心价值是在不依赖 meta-path元路径的前提下用注意力机制同时建模节点类型和边类型。它不需要你预先定义哪些路径是有意义的而是让模型自己从类型信息和结构关系里去学。我们团队复现和扩展这套思路时确实感受到它在异构图任务上比 HAN 这类依赖人工路径的方法更省心。这篇文章适合两类读者一是做 GNN 相关研究、想了解异构图建模进展的同学二是手头握着多类型关系数据、想直接上图的算法的工程师。1. 论文在解决什么问题异构图建模的两个老坎1.1 同构图与异构图的本质差异要说清楚 HetSANN 的贡献得先把异构图这件事讲明白。异构图可以形式化地写成 G (V, E, T_v, T_e, φ, ψ)其中 φ(v) 把每个节点映射到节点类型集合 T_vψ(e) 把每条边映射到边类型集合 T_e。同构图就是 T_v 和 T_e 都只有一个元素的特例所以异构图是更general的设定同构图只是它的退化情况。异构带来的麻烦有两层。第一层是特征空间的差异作者节点可能有 h-index、研究方向分布这些特征论文节点可能是词袋或者预训练向量会议节点可能是影响因子和收录范围它们的维度不同、量纲不同、语义空间也不同。如果用一个共享的线性层去投影等于强迫不同类型的信息挤进同一个坐标系。第二层是边语义的差异author 到 paper 的撰写关系和 paper 到 topic 的属于关系它们对下游任务的贡献方式完全不同。把两种边合并成同一种邻接关系聚合时就会丢失这段交互到底是什么性质的信息。举个例子学术网络里共同作者这个结构其实是 author-paper-author 形成的二阶结构它和 author-paper 的一阶结构不是一回事。传统 GNN 里的邻居消息传递默认每个邻居独立同分布可在异构图中一个作者的邻居既有论文又有会议两种邻居对判断这个作者的研究方向是什么显然权重不同而且这些权重不应该由人工设定最好由模型学着来。1.2 已有方法的两个思路和三条槽点在 HetSANN 之前异构图 GNN 基本走两条路。第一条路是 meta-path 方案代表是 HAN。它先人工指定若干条有业务含义的元路径比如作者-论文-作者表示合著关系、作者-论文-会议表示领域归属然后把每条路径变成一个同构子图在子图上做图注意力最后再做一次路径级注意力融合。这个路线的缺点是明显的meta-path 要依赖领域专家去挑不同任务的最优路径往往不同路径组合的搜索空间是指数级的。哪个领域没有几个凭经验挑路径挑到崩溃的工程师呢。第二条路是把异构投影到共享空间代表是 RGCN 和 HGT。RGCN 为每个关系类型配一个变换矩阵思想直接但关系多时参数膨胀严重。HGT 用 meta-relation 三元组头节点类型、边类型、尾节点类型来计算注意力能力强但模型结构比较复杂引入的设计组件也多。这条路线的共同问题是语义鸿沟把所有类型硬投影到同一个线性空间一个 W 矩阵很难同时表达作者语义和会议语义的差异。你可以把共享投影想象成所有国家的人都说同一种语言交流效率确实高但很多本土化的细微表达也丢了。三条槽点总结下来就是人工依赖重参数开销大类型语义被稀释。HetSANN 恰好在这三个方向上都做了针对性设计。1.3 HetSANN 的核心定位绕过 meta-path 的注意力方案HetSANN 的全称里有个关键词叫 Heterogeneous Structural Learning它的核心主张是类型之间的结构交互本身就应该成为建模对象。传统方法把类型信息当作输入特征的一部分或者只在外层做一次处理而 HetSANN 把类型信息贯穿到注意力的计算过程中。具体来说它做了三件事第一每个节点类型配一个独立的线性投影解决特征空间不一致的问题第二注意力打分函数显式地考虑边类型不同边类型使用不同的注意力参数解决边语义被合并的问题第三完全不引入 meta-path让注意力自动捕捉异构结构信息。提示如果你是第一次接触这个概念可以把 HetSANN 理解成为异构图量身定做的 GAT。GAT 在同构图上做注意力HetSANN 把注意力拆到节点类型 × 边类型的粒度上去做。2. 模型怎么设计的注意力如何同时吃下节点类型和边类型2.1 类型感知的输入变换给每个类型一个专属投影HetSANN 的输入层和普通 GNN 最大的不同在于它的投影矩阵不是共享的而是按节点类型分开的。设 h_i^0 σ(W_φ(i) · x_i)其中 φ(i) 是节点 i 的类型W_φ(i) 是该类型专属的可学习矩阵。这个设计的动机很朴素不同类型的原始特征往往不在同一个度量空间。作者的 h-index 是几十以内的整数论文的词袋向量可能是几千维的稀疏向量把它们丢进同一个线性层高维特征的信息很容易把低维特征淹没。类型专属投影有两个现实好处一是天然兼容不同维度的输入类型 A 的输入是 128 维、类型 B 的输入是 256 维各自投影到同一个 hidden_size完全不冲突二是每个类型学习到的投影方向带上了类型自身的语义偏差在后续注意力计算中模型能区分这是作者侧的信息和这是会议侧的信息。这里有个我复现时踩过的小坑如果某个类型的节点特别少该类型专属投影层只有很少的梯度样本训练容易跑偏。这时候可以给它设一个更大的学习率倍率或者先在同类型样本上用无监督任务做预训练。论文里没有细讲这个工程问题但实际做实验时非常常见。2.2 边类型感知的注意力打分类型对决定怎么互相看输入节点特征完成类型投影后主体是注意力层。GAT 的注意力系数计算方式是 a^T · [W h_i ∥ W h_j]其中 h_i、h_j 不分类型。HetSANN 把它改造成边类型感知的形式中心节点 i 和邻居 j 之间的注意力系数由连接它们的边类型 r ψ(i,j) 决定用哪一组注意力参数。公式层面可以写成e_ij LeakyReLU( a_r^T · [ W_φ(i) h_i ∥ W_φ(j) h_j ] )注意这里的三个下标a_r 是边类型 r 专属的注意力向量W_φ(i) 和 W_φ(j) 分别是节点类型 φ(i) 和 φ(j) 的投影矩阵。也就是说作者-论文这条边的注意力打分和论文-会议这条边的打分是在完全不同的参数下计算的。模型不再强行比较苹果和香蕉的相似度而是先问它们是通过什么关系连在一起的再在这个关系语境下打分。这个设计的直观理解是同一对节点如果它们之间有两类边那么在引用语境下的重要性和在合作语境下的重要性应该分开评估。作者 A 和作者 B 都发过论文 C这是共著关系作者 A 引用过作者 B 的论文这是引用关系。两种关系对判断两人研究方向接近程度的贡献显然不同HetSANN 让边类型参与打分就是这个意图。2.3 归一化与消息聚合类型约束下的邻居选择注意力打分算完之后要经过 softmax 做归一化然后加权聚合邻居消息。HetSANN 在这里对不同类型邻居进行了统一的 softmax 处理。做法是把节点 i 的所有异构邻居无论边类型放在一起计算 softmax 权重权重由各自的边类型注意力向量和节点类型投影共同决定。我个人的一个补充建议是在类型数量不平衡比较严重的数据集上统一 softmax 容易被邻居数量占优的类型带节奏。比如学术网络里会议节点邻居特别多作者节点邻居相对少统一归一化后会议邻居的注意力总权重可能超过作者邻居。这种情况更适合先按边类型各自做 softmax再把不同边类型的聚合结果做一个加权融合权重可以由类型注意力或者简单可学习参数控制。论文原文没有采用这种方案但不影响它在不平衡场景下作为工程改进手段。消息聚合完成后每个节点拿到的是在其类型上下文约束下的邻居信息而不是无差别邻居信息。这正是结构学习的体现模型通过注意力权重的分布隐式地表达出哪种类型对哪种类型更重要、哪种边关系传递的信息更关键。2.4 多头注意力、残差和层数设置HetSANN 沿用了 GAT 的多头注意力机制。每一层设置 K 个头每个头独立做一次边类型感知的注意力聚合最后把 K 个头的输出拼接起来作为下一层的输入。到最后一层分类时为了稳定输出通常用平均代替拼接。层与层之间我建议加残差连接也就是 h^(l1) h^(l) Agg^(l)(h^(l))。类型专属投影已经让每个节点带着自己的语义进入计算如果没有残差深层节点表示会逐渐失去类型边界最后所有类型混成一片语义就失效了。实验里两层模型通常就足够了三层以上不仅带来过平滑风险训练时间还成倍增加。head 数一般取 8hidden 维度取 64 或者 128这和 GAT 的经验值一致。注意残差连接在异构模型里尤其重要。因为它能让类型边界在网络加深时仍然保持否则深层各类型节点的表示差异会快速消退。3. 实验到底说了什么不开 meta-path 也能打3.1 实验设置数据集、任务与训练策略论文的实验主要在几个公开异构图数据上进行典型的包括学术文献网络作者、论文、主题三类节点和电影信息网络电影、演员、导演三类节点。任务清一色是半监督节点分类目标是对指定类型的节点打标签。训练设置的常规做法是每个类别固定取 20 个有标签样本其余节点做测试跑 5 个随机种子取平均和标准差。这个设置几乎是异构图节点分类的标配优点是可比性强缺点是标签极少模型很容易过拟合所以 dropout 和 early stopping 基本是必选项。我自己做复现时还会额外留一份验证集用于早停不然直接在测试集上挑 epoch指标虚高没意义。3.2 主要结果对比别只盯着提升几个点我把 HetSANN 和几类有代表性的基线放在一起看模型是否需要 meta-path是否显式建模节点类型是否显式建模边类型相对表现GCN / GAT否否否基准RGCN否部分是中HAN是是部分高HGT否是是高HetSANN否是是领先区间从指标看HetSANN 在多个数据上超过 HAN 或者与其持平而且没有使用任何人工指定的 meta-path。这个结果的意义不在于我比你高零点几个点而在于它证明了异构图中有效的结构信息不一定非要通过路径枚举来获取一个设计得当的注意力机制同样可以捕捉到类型之间的语义交互。对工业场景来说这省掉了一大块人工梳理路径的成本。还要注意一个现象HetSANN 在部分数据上的领先幅度并不大。这其实是异构图研究的常态说明该数据集的类型结构相对简单同构基线也能覆盖大部分信息。不要因此否定方法本身真实业务里类型越丰富这类方法的优势越明显。3.3 消融实验哪个组件最不能拆消融实验是我读这篇论文时最关心的部分因为它能告诉你哪些设计是真正起作用的。第一组消融是去掉类型感知投影把所有类型的节点用一个共享线性层处理。结果下降最明显。这说明特征空间不一致的问题不是靠注意力就能自动补回来的类型专属投影是地基。第二组消融是去掉边类型注意力把不同边类型的注意力参数合并成一组。结果也有明显下降。这说明边类型信息如果只在数据预处理时合并掉模型后续无法自行恢复它。第三类实验是敏感性分析包括注意力头数、层数、dropout 率。8 头是性价比最高的选择2 层最稳dropout 在 0.5 附近表现最好。这些都是可以抄作业的经验值。消融的结论一句话总结类型信息必须作为结构性约束贯穿整个计算过程而不是只在输入层处理一次这也是论文标题里 Structural Learning 想强调的东西。4. 从公式到可以跑的代码复现要点4.1 数据表达类型索引与分类型邻接复现 HetSANN 的第一步是把异构数据组织好。我常用的表达方式是三张表节点特征矩阵 X节点类型索引 node_type长度为 N 的整数张量边类型索引 edge_type长度为 E 的整数张量。在此基础上把邻接表按边类型切分成多个 edge_index分别对应每一种边关系。# 伪代码结构数据预处理 def prepare_hetero_graph(node_type, edge_type, edge_index): type_set torch.unique(node_type).tolist() edge_type_set torch.unique(edge_type).tolist() # 每个节点类型对应的节点索引 type_idx { t: (node_type t).nonzero(as_tupleFalse).flatten() for t in type_set } # 每条边类型对应的边索引 edge_idx_by_type { r: (edge_type r).nonzero(as_tupleFalse).flatten() for r in edge_type_set } return type_idx, edge_idx_by_type这里有个容易踩的坑节点类型索引和边类型索引都必须是稠密的整数编码从 0 开始连续编号。如果你把字符串类型直接映射成随机整数后面构造 nn.ModuleDict 和查表时会非常痛苦。建议预处理阶段就把类型做一次统一编码。4.2 类型感知投影模块的实现输入层是按类型分开的线性投影用 PyTorch 的 ModuleDict 实现最直接import torch import torch.nn as nn class TypeAwareLinear(nn.Module): def __init__(self, in_dim_by_type: dict, out_dim: int): super().__init__() self.projections nn.ModuleDict({ t: nn.Linear(in_dim, out_dim) for t, in_dim in in_dim_by_type.items() }) def forward(self, x, node_type, type_idx): # 先对每个类型做投影 outputs {} for t, idx in type_idx.items(): outputs[t] self.projections[str(t)](x[idx]) # 之后再按原始节点顺序拼接起来 # 注意先投影再拼而不是先拼再投影 return outputs关键点在于先投影再拼。如果你把所有类型的特征先拼成一个矩阵再过一个共享 Linear那类型感知的意义就没了。而先投影再拼可以让不同类型的特征在拼接前已经各归各位。4.3 边类型注意力聚合层怎么写注意力聚合层是核心建议逐步写不要一上来就全部向量化。先按边类型循环每种边类型内部按 GAT 的方式算注意力class HeteroAttentionLayer(nn.Module): def __init__(self, in_dim, out_dim, num_edge_types, n_heads8, dropout0.5): super().__init__() self.W nn.Linear(in_dim, out_dim, biasFalse) # 每个边类型一组注意力参数形状: [num_edge_types, n_heads, 2*out_dim] self.a nn.Parameter(torch.Tensor(num_edge_types, n_heads, 2 * out_dim)) nn.init.xavier_uniform_(self.a) self.leaky_relu nn.LeakyReLU(0.2) self.dropout nn.Dropout(dropout) self.n_heads n_heads def forward(self, h, edge_idx_by_type): H self.W(h) # [N, out_dim] H H.view(-1, self.n_heads, H.size(-1) // self.n_heads) # 多头拆分 out torch.zeros_like(H) # 初始化输出 for r, edge_idx in edge_idx_by_type.items(): src, dst edge_idx[0], edge_idx[1] H_src H[src] # [E_r, n_heads, d_head] H_dst H[dst] # 注意力打分使用该边类型的专属注意力向量 a_r self.a[r] # [n_heads, 2*d_head] - reshape score self.leaky_relu((H_src * a_r[:, :H_src.size(-1)] H_dst * a_r[:, H_src.size(-1):]).sum(-1)) # 按中心节点做 softmax需要先做 segment softmax # 这里省略 index_add 的精巧实现可以先循环节点再用 scatter_softmax ... return out代码里省略了 segment softmax 的细节实际实现时可以用 scatter_softmax 或者变通地先按目的地节点分组再 softmax。如果用的是 PyG可以直接用它的 Scatter 工具类能省不少事如果手撸建议先用循环版本验证正确性再优化性能。4.4 训练配置与超参数选择训练层面的配置我把常用的一套直接列出来优化器Adam学习率 5e-3可尝试 1e-2 到 1e-3 区间权重衰减5e-4dropout0.5类型样本少的数据可以降到 0.3hidden size64注意力头数8层数2标签平滑0.1 可选能提升很小时的效果early stopping以验证集损失为准patience 设为 50损失函数用交叉熵但要注意只对目标类型节点算损失。我在复现时一开始对全图所有节点算损失结果非目标类型的噪声标签把训练彻底带偏了。正确做法是构造一个 mask只保留目标类型节点中有标签的那部分。5. 复现中常见问题速查5.1 特征维度不一致怎么处理最稳很多公开异构图数据里不同类型节点的原始特征维度是各不相同的。比如某数据里论文节点是 4000 维的词袋特征作者节点是 200 维的统计特征。处理方式有三种第一种是类型感知投影也就是论文的做法每个类型一个 Linear这是最稳妥的第二种是统一 padding 到最大维度再共享投影工程实现简单但有浪费第三种是降维后再拼接适合特征维度极大的场景。我的建议是优先第一种。只有当某个类型的样本量特别少比如少于 50 个直接训练一个完整 Linear 层容易过拟合时才考虑换成共享投影加类型 embedding 的折中方案。5.2 某些类型邻居数量差异巨大异构数据里度数不平衡非常普遍电影网络里演员节点的邻居动辄上百导演节点的邻居可能只有几个。统一的邻居 softmax 会让高邻居数量的类型占据主导低邻居类型的信息被稀释。处理手段有两个一种是按节点类型分组做归一化不同类型各自 softmax再通过一个可学习的类型权重做融合另一种是设置每类邻居的采样上限限制最大的邻居数量。论文原版没有加这些处理但我在不平衡特别严重的数据上实验过分组归一化的提升是实打实的。5.3 加深网络就掉点怎么办这是图模型的经典问题。HetSANN 原版两层效果最好三层以上过平滑明显。如果你确实需要更深的结构可以考虑这样补救每一层都保留最初的类型输入表示类似 GCNII 的初始残差思路在层与层之间加 LayerNorm严格用验证集控制早停不要盲目跑满 epoch。我自己的经验是异构场景下别贪深把注意力头的数量做上去比堆层数更有效。5.4 和 HGT 这类更重的模型怎么选HGT 是另一篇有影响力的异构图注意力模型它在 HetSANN 之后提出能力更强、组件更复杂还引入了动态时间编码。选型时我给的建议是中小规模数据、类型数量在 5 个以内的项目优先选 HetSANN 或者基于它做改动因为代码简单、参数少、复现成本低超大规模、节点类型多且带时序语义的业务再考虑 HGT 这类完整工业级方案。我个人的实际体会是这篇论文最值得学的不是加了一个注意力这个动作而是类型信息应该作为一个约束贯穿整个计算过程而不是只在输入层处理一次这个理念。复现阶段一定记得把损失限制在目标类型节点上这是保证训练信号干净的关键。后来我在自己的项目里把类型感知投影那套思路移植到推荐场景的多关系交互建模中效果稳定也印证了这个方向是通用的。