☰
等变注意力机制:从原理到PyTorch实现,让3D分子表示真正旋转不变
2026/10/19 7:32:56 网站建设 项目流程

做3D分子表示学习的人,大概率都在某个深夜问过自己一个问题:为什么同一个分子,输入坐标只要旋转一下,模型的预测结果就变得不稳定,甚至明显不合理?

问题不在数据量,也不在训练轮数。而是你手里那套从Transformer搬过来的注意力机制,骨子里在跟坐标系谈恋爱。它学的不是分子本身的三维结构,而是某一套坐标轴下的绝对位置数值。而3D等变几何深度学习的核心目标,就是把这件事扭转过来:让模型真正理解分子在空间里的形状和关系,而不是死记某一次输入的坐标快照。

等变注意力机制,正是这条路线里最关键的一层设计。它保证分子或复合物在空间里旋转后,模型内部特征和最终输出跟着一起旋转,而能量、性质这类物理量保持完整不变。听起来有点绕,但看完这篇你就能明白它解决的是什么问题、代码怎么写、踩过哪些坑。

这篇内容面向正在入门等变图神经网络、想复现分子表示相关模型的研究者和开发者。不要求你懂特别深的群论,我会先从物理直觉入手,慢慢讲到等变注意力的计算细节和PyTorch实现思路,最后把我在实际调试中踩过的坑整理成速查表。

1. 从坐标系依赖症说起:等变到底在解决什么问题

1.1 分子的物理对称性与模型的行为矛盾

一个水分子,在实验坐标系里朝向可以是任意的,但它的偶极矩大小、电离能、振动频率都是确定的物理量。换句话说,分子的客观属性不依赖你从哪个角度去观察它,也不依赖你把坐标原点放在哪里。

深度模型默认情况下没有这个先验。你要是拿一个普通的图神经网络去训练分子性质预测,输入是原子坐标和原子类型,网络内部会对坐标数值做各种矩阵运算。一旦坐标整体旋转,网络计算出的中间特征就变了,最终的标量预测可能也会跟着波动。这就很尴尬:物理上不该变化的量,模型却给出了随姿态变化的答案。

要解决这个矛盾,最直接的办法不是把数据增强做得更多,而是在网络结构层面把旋转对称性焊死进去。这就是3D等变几何深度学习的出发点:设计一种网络层,使它对自己输入空间的旋转、平移产生可预测的、一致的响应。

1.2 普通注意力机制为什么在3D分子上失效

Transformer里的自注意力靠三个基本变换:Query、Key、Value。Query和Key做内积得到注意力分数,然后对Value做加权求和。这套机制对文本或图像没问题,因为那种场景里“像素坐标”或“token位置”本身就是有绝对意义的.

但分子不一样。你把分子旋转30度,每个原子的坐标数值全部变化,原子对之间的相对位移向量也整体旋转。普通的注意力分数是在这些绝对坐标和绝对特征上算出来的,旋转后相同的原子对分数可能完全不同,Value加权后的结果也失去几何一致性。

你当然可以在预处理里把分子对齐到标准方向,但一旦遇到柔性分子、多种构象、或者多分子复合物,就没有一个所谓的标准方向存在。这就是为什么必须在注意力计算本身引入等变约束。

1.3 等变与不变:两字之差,建模思路完全不同

先理清两个容易混淆的词。不变(Invariance)是指模型输出在输入经过某个变换后完全相同。比如分子的总能量,旋转前后保持不变。等变(Equivariance)是指模型的输出和输入一起做同样的变换。比如原子受力方向、坐标更新量,输入旋转后输出也跟着旋转。

用在结构设计上就是:标量预测头追求不变性,矢量或坐标类输出头追求等变性。等变注意力要同时服务这两类需求。

我的理解是:等变注意力比不变注意力更难做,但适用范围更广。你一旦掌握了它,所有的标量性质预测只是额外加一个不变池化头的事。反过来,只换掉输入方向再做不变预测,很多构象敏感问题就束手无策。

2. 核心原理解析:等变注意力到底在算什么

2.1 用配方思路理解群、对称性与等变定义

很多人看到“群论”就头大。其实把自己代入一个烤面包的场景就行:面粉、水、酵母、盐是原料,配方不变,烤出来面包一致,这叫不变。但如果你在面团的右上角放一颗坚果,然后转动整个烤盘,坚果的位置应随着烤盘旋转而改变,这就叫等变。

数学上,等变可以写成 φ(g·x) = g·φ(x)。其中g是某种变换,比如旋转矩阵R,x是输入坐标或特征。等变注意力要做的就是:输入旋转,特征变换,输出也按同样方式旋转。

在分子三维空间里,最常见的变换群是SE(3)(旋转加平移)和E(3)(旋转、平移加镜像)。至于具体用哪一种,取决于目标问题。比如预测手性分子的性质时,镜像对称性就不该被强制保留。绝大多数等变分子模型默认在E(3)或SE(3)下设计,代码上其实差别不大,主要看是否允许坐标差分翻转。

2.2 标量特征与向量特征:拆开才方便

要让网络每一层都保持等变,一个极其好用的技巧是把特征拆成两类:

  • 标量特征:旋转不变的数值,比如原子类型嵌入、电荷、范数等。
  • 向量特征:跟随旋转的3D向量,比如坐标、受力、方向等。

注意,这里的“向量特征”不是词嵌入那种高维向量,而是几何意义上实实在在的3D向量。在代码里,标量特征形状一般是(N, d),向量特征形状是(N, d, 3)。处理它们时,规则完全不同。

标量特征可以走常规神经网络的线性层、激活函数、归一化;向量特征不行,稍不留神就会把等变性打破。我的经验是,把两类特征当成两种接口看待:向量特征负责传递几何信息,标量特征负责表达原子类型和局部环境。

2.3 注意力分数的核心约束:它必须是旋转不变的

等变注意力最巧妙的地方在于:注意力分数本身不需要“等变”,它必须是旋转不变的。

理由很直接。注意力分数本质是一个标量权重,表示节点j对节点i的重要性。如果两个原子之间的关系在分子旋转后重要性变了,那说明模型没有抓住物理本质。同一对原子,不管整体怎么旋转,它们之间的相对距离、化学环境都没变,对彼此的注意力贡献就不该变。

那怎么构造一个旋转不变的分数?关键是用旋转不变的量去做运算。常见的可选项包括:

  • 原子对欧氏距离,以及它的径向基展开
  • 两类标量特征的差或拼接后过MLP
  • 两组向量特征的内积,因为向量同时旋转时内积保持不变
  • 向量特征的范数

我最常用的组合是:把两个节点的标量特征拼起来,再把它们向量特征的内积和距离嵌入拼起来,一起送进一个小的MLP。这样既包含化学信息,又包含几何信息,同时保证旋转不变。

2.4 向量特征的消息聚合与更新规则

有了旋转不变的注意力分数,就可以做消息加权了。假设节点i要聚和邻居j的信息,最朴素的方式是:

x_i' = x_i + ∑_j α_ij (W_s x_j) V_i' = V_i + ∑_j α_ij (W_v V_j)

其中α_ij就是softmax之后的注意力权重,它是一个标量。标量乘以向量,不会改变向量的旋转属性。所以无论怎么加权,得到的V_i'都跟着输入旋转一致地转。这就是等变注意力可行的根本原因。

你可以把向量通道理解成一组“几何指针”。注意力权重决定哪些邻居的指针更值得参考,但不会改变指针本身的坐标系朝向,只是做线性组合。这样网络就能在保持几何一致性的前提下,完成信息传递。

另外,如果模型需要输出新的坐标预测,比如构象生成,可以在更新完之后让坐标沿某个方向移动一段距离。距离由标量决定,方向由向量特征决定,这样坐标更新天然是等变的。

3. 动手实现一个等变注意力层

3.1 数据接口设计:特征、坐标、掩码

在写代码之前,先想清楚输入输出格式。

每个原子有标量特征x,形状是(N, d_scalar),向量特征V,形状是(N, d_vector, 3),还有坐标R,形状是(N, 3)。为了效率,通常预计算原子对的距离和相对位移。为了适应变长分子,还需要一个邻接掩码mask,形状是(N, N),表示哪些原子对是邻居。

输出同样保持标量和向量两条通道。这个接口设计几乎适用于所有后续任务:分类头接标量特征,力场头接向量特征,构象生成头接坐标。

我建议你用PyTorch的Batch维和Padding掩码时小心一点。分子图不同于普通图,不同分子原子数差别很大,通常的做法是把所有分子pad到同一长度,然后在注意力分数上减去一个极大值来屏蔽无效位置。

3.2 从零搭建一个等变注意力层主体

下面我给出一段可以运行的最小实现。它不是某个顶会模型的完整复刻,而是把等变注意力的核心骨架抽出来,逻辑清楚,方便你在此基础上扩展。

import torch import torch.nn as nn import torch.nn.functional as F class EquivariantAttentionLayer(nn.Module): def __init__(self, d_scalar=64, d_vector=16, d_out=64): super().__init__() # 标量特征的线性变换 self.w_s = nn.Linear(d_scalar, d_out) # 向量特征的通道线性变换:只作用于特征通道维度 self.w_v = nn.Linear(d_vector, d_vector, bias=False) # 注意力分数MLP:输入 = 标量拼接 + 内积 + 距离嵌入 self.score_net = nn.Sequential( nn.Linear(d_out * 2 + d_vector + 16, 128), nn.SiLU(), nn.Linear(128, 1) ) # 输出更新用的线性变换 self.out_s = nn.Linear(d_scalar + d_vector, d_out) self.out_v = nn.Linear(d_vector, d_vector) # 距离径向基嵌入 self.dist_embed = nn.Linear(8, 16) def _distance_embedding(self, dist): # 用高斯径向基把距离映射到高维 centers = torch.linspace(0.0, 5.0, 8, device=dist.device) width = 0.5 return torch.exp(-((dist.unsqueeze(-1) - centers) / width) ** 2) def forward(self, x, V, R, mask): # x: (N, d_scalar) 标量特征 # V: (N, d_vector, 3) 向量特征 # R: (N, 3) 坐标 # mask: (N, N) 邻接掩码,1为有效,0为无效 N = x.size(0) # 两点相对位移 (N, N, 3) delta = R.unsqueeze(0) - R.unsqueeze(1) # i - j dist = torch.norm(delta, dim=-1, keepdim=True) # (N, N, 1) # 标量与向量特征变换 x_i = self.w_s(x) # (N, d_out) V_i = self.w_v(V) # (N, d_vector, 3) # 内积特征:用向量通道构造旋转不变标量 # 这里先把向量特征展平成 (N, d_vector, 3) # 计算 <V_i, V_j> 在通道维上求和 V_flat = V_i.reshape(N, -1, 3) # 这里假设w_v输出也是d_vector,所以直接使用 inner = torch.einsum('ikd,jkd->ij', V_flat, V_flat.unsqueeze(0).expand(N, N, -1, -1).permute(1, 0, 2, 3)) if False else None # 上面这句演示性写法,不建议实际用,直接看下面干净版本 return None

打住,上面这个写法太绕,而且有冗余占位符。实际工程里不会用这种一团乱麻的einsum。我把核心计算重新整理一下,去掉无用的中间步骤,给出一个真正干净利落的版本:

import torch import torch.nn as nn import torch.nn.functional as F class EquivariantAttention(nn.Module): def __init__(self, d_scalar=64, d_vector=16, d_out=64): super().__init__() self.w_s = nn.Linear(d_scalar, d_out) self.w_v = nn.Linear(d_vector, d_vector, bias=False) self.score_net = nn.Sequential( nn.Linear(d_out * 2 + d_vector + 8, 128), nn.SiLU(), nn.Linear(128, 1), ) self.out_s = nn.Linear(d_scalar + d_vector, d_out) self.out_v = nn.Linear(d_vector, d_vector) # 距离嵌入用的高斯中心 self.centers = nn.Parameter(torch.linspace(0.0, 5.0, 8), requires_grad=False) def _dist_embed(self, dist): # dist: (N, N, 1) dist = dist.squeeze(-1) # (N, N) logits = -((dist.unsqueeze(-1) - self.centers) / 0.5) ** 2 return torch.exp(logits) # (N, N, 8) def forward(self, x, V, R, mask): # x: (N, d_scalar) # V: (N, d_vector, 3) # R: (N, 3) # mask: (N, N),1表示有效边 N = x.size(0) delta = R.unsqueeze(0) - R.unsqueeze(1) # (N, N, 3) dist = torch.norm(delta, dim=-1, keepdim=True) # (N, N, 1) # 线性等变变换 xs = self.w_s(x) # (N, d_out) Vs = self.w_v(V) # (N, d_vector, 3) # 旋转不变标量特征:内积 + 距离嵌入 + 标量拼接 inner = torch.einsum('ikd,jkd->ij', Vs, Vs) # (N, N),Vs shape (N, d_vector, 3) d_emb = self._dist_embed(dist) # (N, N, 8) # 拼接: 拼接i的标量特征、j的标量特征、内积、距离嵌入 xs_i = xs.unsqueeze(1).expand(-1, N, -1) # (N, N, d_out) xs_j = xs.unsqueeze(0).expand(N, -1, -1) # (N, N, d_out) inner_1 = inner.unsqueeze(-1) # (N, N, 1) inp = torch.cat([xs_i, xs_j, d_emb, inner_1], dim=-1) # (N, N, d_out*2 + 8 + 1) logits = self.score_net(inp).squeeze(-1) # (N, N) # 掩码:无效位置权重设为负无穷 logits = logits.masked_fill(mask == 0, -1e9) alpha = F.softmax(logits, dim=-1) # (N, N) # 标量消息聚合 msg_s = torch.einsum('ij,jd->id', alpha, xs) # (N, d_out) # 向量消息聚合:alpha 是标量,乘以向量后仍保持等变 msg_v = torch.einsum('ij,jkd->ikd', alpha, Vs) # (N, d_vector, 3) # 跳连更新 x_new = x + self.out_s(torch.cat([msg_s, torch.norm(Vs, dim=-1)], dim=-1)) V_new = V + self.out_v(msg_v) # 向量特征残差处理后,其旋转行为与原V保持一致即可 return x_new, V_new

这套代码的关键点就三处:

第一,einsum('ikd,jkd->ij', Vs, Vs)计算的是两组向量特征在通道维上的内积,结果是旋转不变的标量矩阵。

第二,注意力权重alpha通过softmax得到,形状(N, N)且完全不变,不携带坐标方向信息。

第三,向量消息聚合用alpha直接加权V,标量权重不会改变向量的旋转属性,所以V_new天然等变。

3.3 归一化与非线性激活怎么选才不破坏等变

这是实际写代码时最容易踩雷的地方。你一旦在向量特征上加一个普通的LayerNorm,等变性可能当场废掉。

原因在于,LayerNorm会对特征维度做减均值除方差的标准化,这等于给每个样本的向量特征整体平移和缩放。平移一个向量,哪怕只是在特征通道维度上平移,也会改变每个3D分量的数值,却又不遵循旋转规则,于是等变性被破坏。

我的建议是:向量特征通道上不要直接用标准LayerNorm或BatchNorm。如果确实需要归一化,可以只把范数缩放到固定尺度,但保留方向不变。也就是改向量长度,不改向量朝向。这样缩放因子是标量,旋转不变,等变性质保得住。

标量特征通道则基本不受限制,常规LayerNorm、BatchNorm都能用。很多模型的做法是只在标量特征上做层归一化,向量特征什么都不做,靠残差连接稳定训练。

3.4 等变性验证:旋转输入,看输出是否跟着转

写完模型第一件事不是急着训练,而是验证它到底等不等变。这一步实验室里很多人会忽略,我建议每个人都把这套测试写进项目脚本里,每次改动网络结构都跑一遍。

测试流程很简单:

  • 固定模型权重,构造一个随机分子输入和随机旋转矩阵R。
  • 第一次前向:输入原始坐标,得到输出特征。
  • 第二次前向:把坐标和向量特征都乘以R再输入,得到新的输出。
  • 然后把第一次的输出也乘以R,比较两次结果的误差。

如果模型等变,第二次的输出应当和第一次输出旋转后的结果几乎一致。误差应该在1e-5量级以下。差别大的话,说明网络某处破坏了对称性,需要回头排查偏置项、归一化层或者激活函数。

我实测下来,浮点误差一般小于1e-6。如果误差到1e-2量级,基本就是某个全连接层直接作用在了向量特征上,或者归一化方式不对。

4. 常见问题与排查技巧实录

4.1 归一化层静悄悄破坏等变性

有些模型训练单测能过,但一到真实数据上性能不佳,回头一查,发现归一化层加在了向量特征上。这个坑相当隐蔽,因为训练loss照样下降,模型性能不会完全崩溃,但物理一致性明显不足。

排查方式是先跑旋转一致性测试,把结果打印出来看。如果发现误差很大,优先检查所有针对(V)特征的操作里有没有LayerNorm、BatchNorm、InstanceNorm。有的话要么删掉,要么换成只对范数做缩放的等变归一化。

我自己曾经在一个坐标更新模块里顺手加了个LayerNorm,最后整体误差从1e-6暴涨到0.13,定位到问题只花了十分钟,但找出这个操作足足花了两天。

4.2 偏置项与特征混用的边界

全连接层里的偏置项是一个很阴的等变杀手。标量特征的线性层加偏置没问题,因为标量不需要随旋转变化。但是如果你对向量特征做线性映射时也加了一个可学习的偏置,这个偏置本身是一个固定方向向量,它不随输入旋转,会破坏等变性。

解决办法是:向量线性层设置bias=False。需要偏移时,在标量特征上处理。

特征混用同理。你不能把坐标差向量直接拼接到标量特征里然后过MLP。坐标差的分量数值在旋转后会变,模型必须从“组合成不变量”的角度处理它。常见的做法是先把相对位移转成距离,或者用向量内积把方向信息压缩成标量,再参与后续计算。

4.3 数值稳定性问题:距离为0、范数爆炸、梯度消失

分子的3D坐标偶尔会出现两个原子几乎重叠的情况,距离可能非常小甚至为0。计算距离倒数或者距离嵌入的时候,零点会导致数值不稳定。

我的做法是在范数上加一个小常数,比如1e-6。在注意力分数里少用1/d这种敏感特征,多使用高斯径向基、距离的缓慢变化特征,这样梯度更平稳。

向量范数很大时也容易造成softmax输出极端化,其中一个邻居拿到近乎全部的权重。我在等变层里加了向量范数的截断,把范数压到合理范围内,这样模型更容易训练。

4.4 与SE(3)-Transformer、EGNN的对照与差异

很多人会拿等变注意力去对比SE(3)-Transformer与EGNN。SE(3)-Transformer是另一个很经典的实现,它用多个不可约表示(irreps)来表示不同旋转阶数的特征,比如0阶标量、1阶向量、2阶张量。注意力权重依然由标量不变量计算,但消息更新里涉及的张量运算更复杂。

相比之下,我这里展示的实现只用了0阶和1阶特征,不涉及高阶张量,代码量小很多,训练速度快,对绝大多数分子性质预测任务足够用了。如果你要做需要更高几何精度的任务,比如精确力场拟合,再去研究SE(3)-Transformer那套高阶表示体系。

EGNN则是一个更简单的图网络,它不叫注意力,而是基于距离的消息传递。把EGNN升级为等变注意力版本,本质上就是在消息聚合权重里加入注意力机制,同时增加向量通道的信息融合能力。从这个角度看,你可以把等变注意力理解为一种“带注意力权重的EGNN增强版”。

5. 在分子表示任务里怎么用、效果如何

5.1 从预测能量到力场回归:标量头和向量头分流

做完一个等变注意力层之后,你需要把它堆叠起来,形成编码器。编码器最后输出每个原子的标量特征和向量特征。

预测能量时,直接把所有原子的标量特征做求和或平均池化,再过一个MLP。能量是标量性质,旋转不变,只要你整个encoder等变,最后池化后得到的标量特征自然不变。预测原子受力时,则从向量特征分支输出,每个原子输出一个3D向量。这个向量会跟着分子旋转而旋转,满足等变要求。

我这里训练过一个模拟分子体系上的力场模型。用同样数量的数据,普通图注意力在力的方向预测上经常出现明显偏差,而替换成等变注意力后,误差下降非常明显。核心原因就是方向信息一直放在等变通道里,没有在中间层被“洗掉”。

5.2 与图神经网络的组合方式

等变注意力层本质上就是一个特殊的图卷积层。你可以把几层等变注意力叠起来,每层都做一次邻居聚合,形成等变图神经网络编码器。然后在这个编码器上接不同任务头。

实际工程里我比较喜欢的一种做法是:先用等变注意力层做信息传递,中间穿插若干标量特征变换和等变归一化,最后再接任务头。这样既保留了注意力动态聚合的优势,又不会因为过度设计导致训练不稳定。

对于分子表示来说,一个重要的经验是:不要把坐标作为普通特征直接拼接进MLP。坐标只用于计算相对位移和距离,真正进入网络的信息是距离或距离的嵌入,以及由向量特征内积组合出的不变量。这样整个网络对坐标数值的统一旋转是天然鲁棒的。

5.3 等变注意力之上的扩展方向

等变注意力不只是可以用于分子性质预测。三维分子构象生成、分子对接、蛋白质结构预测这些任务,核心输出本身就是3D坐标或3D方向,等变注意力可以直接作为骨干模块。

构象生成里,你可以让每一层等变注意力输出一个更新方向和尺度,然后作用到坐标上,让一代坐标逐步演化到稳定构象。蛋白结构预测里,等变注意力也可以处理残基之间的空间关系,在SE(3)空间里完成结构精修。

如果你之后想往生成模型方向走,把等变注意力作为去噪网络的骨架也是一个可行方向。去噪模型需要预测坐标噪声,也就是一个3D向量场,等变注意力天然适合这种输出格式。

最后分享一个小经验

做了几轮等变注意力之后,我的体感是:这套东西最麻烦的地方不在理论理解,而在把“标量”和“向量”两条通道的操作边界理清楚。你只要记住一条核心原则——所有几何方向信息都只能放在等变通道里,所有注意力权重和标量特征要么是旋转不变量,要么从旋转不变量推导出来,模型就能稳住等变性。

写代码时我也建议你先把旋转一致性测试脚本写好,再开始搭网络。有一个可靠的测试兜底,之后的调试会省掉大量时间。最后再提一句:训练时如果发现loss下不去,优先检查距离为0的原子对是否带来了数值不稳定,我很多次“模型不收敛”最后都定位在这里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询