注意力权重可视化基础

咱们直接进入正题。注意力权重可视化,说白了就是把GNN模型"看"哪里给亮出来。我刚开始做这个的时候,也觉得不就是画个热力图吗?后来发现,权重提取这一步就够你折腾半天的。

核心思路:先拿到每层每个头的注意力系数矩阵,再把它映射成可视化的颜色块,最后从颜色分布中读出模型的"注意力偏好"。

3.1 权重矩阵提取

权重矩阵藏在哪儿?对于图注意力网络(GAT),注意力系数就在 forward 过程中的 alpha 变量里。我个人的习惯是注册一个 forward hook,在模型跑前向时把注意力系数"截获"下来。

来,直接看代码。下面这段是从 PyTorch Geometric 的 GAT 层里提取权重的典型写法:

import torch
import torch.nn.functional as F
from torch_geometric.nn import GATConv

# 定义带钩子的包装类
class GATWithAttn(torch.nn.Module):
    def __init__(self, in_dim, out_dim):
        super().__init__()
        self.conv = GATConv(in_dim, out_dim, heads=4, concat=False)
        self.attn_weights = None

    def forward(self, x, edge_index):
        # 注册钩子
        self.conv.attn = None  # 重置
        out = self.conv(x, edge_index)
        # 注意力系数存在 self.conv 的 _alpha 属性中
        # 需要手动从 GATConv 内部取出
        # 这里为了演示,我们用 hook 的方式
        return out

def get_attn_hook(module, inputs, outputs):
    # 在 GATConv 的 forward 中,注意力系数被保存在 module._alpha
    if hasattr(module, '_alpha') and module._alpha is not None:
        module._cached_attn = module._alpha.detach().cpu()

model = GATWithAttn(16, 8)
handler = model.conv.register_forward_hook(get_attn_hook)

# 跑一次前向
x = torch.randn(10, 16)
edge_index = torch.randint(0, 10, (2, 40))
out = model(x, edge_index)

# 提取注意力权重
attn_weights = model.conv._cached_attn
print(f"注意力权重形状: {attn_weights.shape}")
# 输出: (边数, 头数) 或 (头数, 边数) 取决于版本
小提示:不同版本的 PyG 存储注意力的位置不同。我建议你在注册 hook 后用 print(dir(module)) 看一眼。用 _alpha 还是 attention_weights,版本之间经常变。

拿到原始权重后,通常需要 reshape 成 (头数, 节点数, 节点数) 的稠密矩阵,才能方便地画热力图。为什么会这样?因为稀疏格式的边列表虽然省内存,但可视化的时候你得把缺失的位置补成 0。

import torch
from torch_geometric.utils import to_dense_adj

# 假设 attn_weights 形状为 (边数, 头数)
# 需要转成稠密邻接矩阵形式

edge_index = edge_index  # (2, 边数)
num_nodes = x.shape[0]
num_heads = attn_weights.shape[1]

# 为每个头构造稠密注意力矩阵
attn_dense = []
for h in range(num_heads):
    # 取出第 h 头的注意力值
    attn_h = attn_weights[:, h]
    # 转成稠密矩阵 (节点数 x 节点数)
    dense_h = to_dense_adj(edge_index, max_nodes=num_nodes, edge_attr=attn_h)
    attn_dense.append(dense_h.squeeze(0))

attn_dense = torch.stack(attn_dense, dim=0)
print(f"稠密注意力矩阵形状: {attn_dense.shape}")
# 输出: (头数, 节点数, 节点数)

嗯,这一步要注意:to_dense_adj 会把没有边的位置填成 0,所以你的注意力矩阵里会有大量的 0 值区域。这是正常的。

3.2 热力图绘制基础

权重提出来了,下一步就是画图。我个人偏爱 seaborn 的 heatmap,配色干净,参数直观。不过 matplotlib 的 imshow 也能干,就是调颜色麻烦点。

来一个直接能跑的例子:

import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np

# 取第 0 个头的注意力矩阵
attn_map = attn_dense[0].numpy()  # (节点数, 节点数)

plt.figure(figsize=(8, 6))
sns.heatmap(
    attn_map,
    cmap='YlOrRd',      # 黄-橙-红渐变,高亮明显
    annot=False,         # 数据点太多时不要标数字
    cbar=True,           # 显示颜色条
    square=True,         # 格子保持正方形
    linewidths=0.2,     # 格子间距线宽
    linecolor='white'
)
plt.title('Head 0 注意力权重热力图', fontsize=14)
plt.xlabel('目标节点')
plt.ylabel('源节点')
plt.tight_layout()
plt.show()
关键参数解读:
  • cmap:颜色映射。YlOrRd 适合高亮强注意力,viridis 适合看梯度细节。
  • annot:节点数超过 20 就别标数字了,否则一团黑。
  • square:一定要开,不然格子会被拉伸变形,视觉上容易误判。

我在项目中遇到过一个问题:多个头的热力图怎么排布比较直观?我的做法是画一个 2x2 或 3x3 的子图矩阵,每个子图对应一个头,统一颜色范围。

fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()

# 统一颜色范围
vmin = attn_dense.min().item()
vmax = attn_dense.max().item()

for h in range(4):
    sns.heatmap(
        attn_dense[h].numpy(),
        ax=axes[h],
        cmap='YlOrRd',
        vmin=vmin,
        vmax=vmax,
        cbar=True,
        square=True
    )
    axes[h].set_title(f'头 {h}')

plt.suptitle('GAT 4头注意力热力图对比', fontsize=16)
plt.tight_layout()
plt.show()

这样一眼就能看出哪个头关注的是全局,哪个头只盯着局部。我记得有次分析一个分子图分类任务,头 2 和头 3 的模式完全不一样——一个抓官能团内部连接,一个抓分子间长程作用。这种差异从热力图上看特别明显。

注意避坑:别直接用 attn_weights 的原始值画图。我曾经因为没做归一化,结果热力图全是一个颜色,根本看不出梯度。建议在画图前做一次 行归一化(softmax 或 min-max),让每行的注意力之和为 1,这样颜色对比才鲜明。

3.3 注意力模式初探

热力图画出来了,怎么看?我总结了几种常见的注意力模式,用表格列出来,你对照着看就行:

模式名称 热力图特征 可能的含义
对角主导型 主对角线高亮,其余区域暗淡 节点主要关注自身特征,自注意力占优。常见于深层GNN,表示过平滑前兆
局部集中型 沿对角线附近有带状高亮 节点关注其1-2跳邻居,典型的图局部信息聚合模式
全局分散型 高亮区域散布在整个矩阵中 节点关注远距离节点,可能学到了长程依赖关系
社区块状型 出现明显的方块状高亮区域 模型发现了图社区结构,同社区内节点互相关注
均匀模糊型 整个矩阵颜色均匀,无明显热点 注意力没有学到有效区分,可能是模型欠拟合或图本身同质化

你想想看,如果热力图上全是均匀模糊型,那模型基本没学到什么东西。我遇到过一个失败的案例——训练了 200 轮的 GAT,热力图全是均匀的黄色,后来发现是学习率太大,注意力参数全崩了。

下面这张图概括了从模型到模式分析的完整流程,我把它画成了 SVG,方便你理解各个环节的关系:

注意力权重可视化流程 阶段一:权重提取 GNN 模型(GAT) Forward Hook 截获 注意力系数 alpha 阶段二:矩阵转换 稀疏 → 稠密矩阵 Reshape (头×N×N) 行归一化 阶段三:可视化与分析 热力图(seaborn) 注意力模式分析 常见模式:对角主导型 | 局部集中型 | 全局分散型 | 社区块状型 | 均匀模糊型 通过模式对比,判断模型是否学到有效结构信息

最后说一个我踩过的坑。有一次我把注意力矩阵画出来,发现所有头几乎一模一样,当时百思不得其解。后来排查了半天,原来是共享了同一个 hook 对象的引用,所有头的权重都指向同一份内存。嗯,这种 bug 光看代码很难发现,热力图一出来就现原形了。

我的调试习惯:每画一张热力图,顺手把矩阵的均值、方差打印出来。如果多个头的统计量完全一致,十有八九是代码引用问题。如果方差特别小(比如 < 0.01),那模型基本没学到注意力差异,该检查训练过程了。

这一节的内容就这些。你拿到手里的应该是权重的提取方法、热力图的绘制套路,以及五种常见的注意力模式。下回看到一张注意力热力图,至少能说出"哦,这是局部集中型,模型在聚合邻居信息"这样的话。