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^(l+1) = h^(l) + Agg^(l)(h^(l))。类型专属投影已经让每个节点带着自己的语义进入计算,如果没有残差,深层节点表示会逐渐失去类型边界,最后所有类型混成一片,语义就失效了。实验里两层模型通常就足够了,三层以上不仅带来过平滑风险,训练时间还成倍增加。head 数一般取 8,hidden 维度取 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_tuple=False).flatten() for t in type_set } # 每条边类型对应的边索引 edge_idx_by_type = { r: (edge_type == r).nonzero(as_tuple=False).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_heads=8, dropout=0.5): super().__init__() self.W = nn.Linear(in_dim, out_dim, bias=False) # 每个边类型一组注意力参数,形状: [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-4
- dropout:0.5,类型样本少的数据可以降到 0.3
- hidden size:64
- 注意力头数: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 这类完整工业级方案。
我个人的实际体会是:这篇论文最值得学的不是"加了一个注意力"这个动作,而是"类型信息应该作为一个约束贯穿整个计算过程,而不是只在输入层处理一次"这个理念。复现阶段一定记得把损失限制在目标类型节点上,这是保证训练信号干净的关键。后来我在自己的项目里把类型感知投影那套思路移植到推荐场景的多关系交互建模中,效果稳定,也印证了这个方向是通用的。