# coding=utf-8
# SPDX-FileCopyrightText: Copyright (c) 2025 The torch-harmonics Authors. All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
import math
from typing import Optional, Tuple, Union
import torch
import torch.nn as nn
import torch.nn.functional as F
from attention_helpers import optimized_kernels_is_available
from torch_harmonics.attention.kernels_torch.attention_torch import _neighborhood_s2_attention_torch
from torch_harmonics.attention.optimized.attention_optimized import _neighborhood_s2_attention_optimized
from torch_harmonics.disco.convolution import _precompute_convolution_tensor_s2
from torch_harmonics.filter_basis import get_filter_basis
from torch_harmonics.quadrature import precompute_latitudes
[docs]
class AttentionS2(nn.Module):
r"""
(Global) attention on the 2-sphere.
This is ordinary (global) scaled dot-product attention, made geometrically
faithful on the sphere by folding the numerical quadrature weights of the
grid into the attention. Following :cite:`Bonev2025`, the softmax over keys becomes a
quadrature approximation of a continuous attention integral over the sphere:
the *logarithms* of the spherical quadrature weights are added to the
pre-softmax attention scores as an additive mask, so that after the softmax
exponential they act as multiplicative quadrature weights in the
normalization. Using log-weights lets them be passed directly as the
``attn_mask`` of :func:`torch.nn.functional.scaled_dot_product_attention`.
Incorporating the quadrature weights this way makes the layer a
resolution-agnostic neural operator (evaluable on arbitrary grids, though the
learned features remain resolution dependent) and approximately
:math:`SO(3)`-equivariant, since the underlying integral is invariant under
rotations (the Haar measure). For the local variant that confines attention
to a geodesic neighborhood, see
:class:`~torch_harmonics.NeighborhoodAttentionS2`.
Parameters
----------
in_channels : int
number of channels of the input signal (corresponds to embed_dim in MHA in PyTorch)
num_heads : int
number of attention heads
in_shape : tuple
shape of the input grid
out_shape : tuple
shape of the output grid
grid_in : str, optional
input grid type, ``"equiangular"`` by default
grid_out : str, optional
output grid type, ``"equiangular"`` by default
bias : bool, optional
if specified, adds bias to input / output projection layers
k_channels : int
number of dimensions for interior inner product in the attention matrix (corresponds to kdim in MHA in PyTorch)
out_channels : int, optional
number of dimensions for interior inner product in the attention matrix (corresponds to vdim in MHA in PyTorch)
References
----------
:cite:`Bonev2025`
"""
def __init__(
self,
in_channels: int,
num_heads: int,
in_shape: Tuple[int],
out_shape: Tuple[int],
grid_in: Optional[str] = "equiangular",
grid_out: Optional[str] = "equiangular",
scale: Optional[Union[torch.Tensor, float]] = None,
use_qknorm: Optional[bool] = False,
bias: Optional[bool] = True,
k_channels: Optional[int] = None,
out_channels: Optional[int] = None,
drop_rate: Optional[float] = 0.0,
):
super().__init__()
self.nlat_in, self.nlon_in = in_shape
self.nlat_out, self.nlon_out = out_shape
if self.nlon_in % self.nlon_out != 0:
raise ValueError(f"nlon_in ({self.nlon_in}) must be an integer multiple of nlon_out ({self.nlon_out}) for the attention p-shift to be exact")
self.in_channels = in_channels
self.num_heads = num_heads
self.k_channels = in_channels if k_channels is None else k_channels
self.out_channels = in_channels if out_channels is None else out_channels
self.drop_rate = drop_rate
self.scale = scale
# integration weights
_, wgl = precompute_latitudes(self.nlat_in, grid=grid_in)
quad_weights = 2.0 * torch.pi * wgl.to(dtype=torch.float32) / self.nlon_in
# we need to tile and flatten them accordingly
quad_weights = torch.tile(quad_weights.reshape(-1, 1), (1, self.nlon_in)).flatten()
# compute log because they are applied as an addition prior to the softmax ('attn_mask'), which includes an exponential.
# see https://pytorch.org/docs/stable/generated/torch.nn.functional.scaled_dot_product_attention.html
# for info on how 'attn_mask' is applied to the attention weights
log_quad_weights = torch.log(quad_weights).reshape(1, 1, -1)
self.register_buffer("log_quad_weights", log_quad_weights, persistent=False)
# learnable parameters — Xavier uniform init matching PyTorch MHA convention:
# bound = sqrt(6 / (fan_in + fan_out)) for each projection
if self.k_channels % self.num_heads != 0:
raise ValueError(f"Please make sure that number of heads {self.num_heads} divides k_channels {self.k_channels} evenly.")
if self.out_channels % self.num_heads != 0:
raise ValueError(f"Please make sure that number of heads {self.num_heads} divides out_channels {self.out_channels} evenly.")
scale_qk = math.sqrt(6.0 / (self.in_channels + self.k_channels))
scale_v = math.sqrt(6.0 / (self.in_channels + self.out_channels))
scale_proj = math.sqrt(3.0 / self.out_channels)
self.q_weights = nn.Parameter(scale_qk * (2 * torch.rand(self.k_channels, self.in_channels, 1, 1) - 1))
self.k_weights = nn.Parameter(scale_qk * (2 * torch.rand(self.k_channels, self.in_channels, 1, 1) - 1))
self.v_weights = nn.Parameter(scale_v * (2 * torch.rand(self.out_channels, self.in_channels, 1, 1) - 1))
self.proj_weights = nn.Parameter(scale_proj * (2 * torch.rand(self.out_channels, self.out_channels, 1, 1) - 1))
if bias:
self.q_bias = nn.Parameter(torch.zeros(self.k_channels))
self.k_bias = nn.Parameter(torch.zeros(self.k_channels))
self.v_bias = nn.Parameter(torch.zeros(self.out_channels))
self.proj_bias = nn.Parameter(torch.zeros(self.out_channels))
else:
self.q_bias = None
self.k_bias = None
self.v_bias = None
self.proj_bias = None
if use_qknorm:
self.q_norm_weights = nn.Parameter(torch.zeros(self.k_channels // self.num_heads))
self.k_norm_weights = nn.Parameter(torch.zeros(self.k_channels // self.num_heads))
else:
self.q_norm_weights = None
self.k_norm_weights = None
def extra_repr(self):
return f"in_shape={(self.nlat_in, self.nlon_in)}, out_shape={(self.nlat_out, self.nlon_out)}, in_channels={self.in_channels}, out_channels={self.out_channels}, k_channels={self.k_channels}"
[docs]
def forward(self, query: torch.Tensor, key: Optional[torch.Tensor] = None, value: Optional[torch.Tensor] = None) -> torch.Tensor:
"""
Apply global attention on the sphere.
Parameters
----------
query : torch.Tensor
Query signal of shape ``(batch, in_channels, nlat_out, nlon_out)`` (sampled on the output grid).
key : torch.Tensor, optional
Key signal of shape ``(batch, in_channels, nlat_in, nlon_in)``. Defaults to ``query`` (self-attention).
value : torch.Tensor, optional
Value signal of shape ``(batch, in_channels, nlat_in, nlon_in)``. Defaults to ``query`` (self-attention).
Returns
-------
torch.Tensor
Attention output of shape ``(batch, out_channels, nlat_out, nlon_out)``.
"""
# self attention simplification
if key is None:
key = query
if value is None:
value = query
# change this later to allow arbitrary number of batch dims
torch._check(query.dim() == 4, lambda: f"Expected 4-dimensional query tensor, got {query.dim()} dimensions")
torch._check(key.dim() == 4, lambda: f"Expected 4-dimensional key tensor, got {key.dim()} dimensions")
torch._check(value.dim() == 4, lambda: f"Expected 4-dimensional value tensor, got {value.dim()} dimensions")
# perform QKV projections
query = nn.functional.conv2d(query, self.q_weights, bias=self.q_bias)
key = nn.functional.conv2d(key, self.k_weights, bias=self.k_bias)
value = nn.functional.conv2d(value, self.v_weights, bias=self.v_bias)
# reshape
B, _, H, W = query.shape
query = query.reshape(B, self.num_heads, -1, H, W)
B, _, H, W = key.shape
key = key.reshape(B, self.num_heads, -1, H, W)
B, _, H, W = value.shape
value = value.reshape(B, self.num_heads, -1, H, W)
# reshape to the right dimensions
B, _, C, H, W = query.shape
query = query.permute(0, 1, 3, 4, 2).reshape(B, self.num_heads, H * W, C)
B, _, C, H, W = key.shape
key = key.permute(0, 1, 3, 4, 2).reshape(B, self.num_heads, H * W, C)
B, _, C, H, W = value.shape
value = value.permute(0, 1, 3, 4, 2).reshape(B, self.num_heads, H * W, C)
if self.q_norm_weights is not None:
query = F.rms_norm(query, normalized_shape=self.q_norm_weights.shape, weight=1 + self.q_norm_weights)
if self.k_norm_weights is not None:
key = F.rms_norm(key, normalized_shape=self.k_norm_weights.shape, weight=1 + self.k_norm_weights)
# apply scale — if scale is a tensor (e.g. learnable), multiply into query
# directly since SDPA only accepts a float scale
dropout_p = self.drop_rate if self.training else 0.0
if isinstance(self.scale, torch.Tensor):
query = query * self.scale
out = F.scaled_dot_product_attention(query, key, value, attn_mask=self.log_quad_weights, dropout_p=dropout_p, scale=1.0)
else:
out = F.scaled_dot_product_attention(query, key, value, attn_mask=self.log_quad_weights, dropout_p=dropout_p, scale=self.scale)
# reshape
B, _, _, C = out.shape
# (B, heads, H*W, C)
out = out.permute(0, 1, 3, 2)
# (B, heads, C, H*W)
out = out.reshape(B, self.num_heads * C, self.nlat_out, self.nlon_out)
# (B, heads*C, H, W)
out = nn.functional.conv2d(out, self.proj_weights, bias=self.proj_bias)
return out
[docs]
class NeighborhoodAttentionS2(nn.Module):
r"""
Neighborhood attention on the 2-sphere.
This is the local counterpart of :class:`~torch_harmonics.AttentionS2`.
Instead of attending globally, every output location attends only to the
input points inside a geodesic neighborhood around it -- the spherical disk
:math:`D(x) = \{x' \in S^2 : d(x, x') \le \theta_\mathrm{cutoff}\}`, where
:math:`d(\cdot, \cdot)` is the great-circle (Haversine) distance and
:math:`\theta_\mathrm{cutoff}` the cutoff radius. Restricting attention to
this disk adds an inductive bias for locality and lowers the cost from
:math:`\mathcal{O}(N^2)` to :math:`\mathcal{O}(k N)`, where :math:`k` is the
number of points in a neighborhood.
Following :cite:`Bonev2025`, the attention softmax integrates over the neighborhood
against the sphere's numerical quadrature weights. This makes the layer a
resolution-agnostic neural operator -- it can be evaluated on arbitrary grid
resolutions (though the learned features themselves remain resolution
dependent) -- and approximately :math:`SO(3)`-equivariant, since the
underlying integrals are invariant under rotations (the Haar measure).
The sparse neighborhood structure is precomputed with the same
discrete-continuous construction used for the DISCO convolutions
(:class:`~torch_harmonics.DiscreteContinuousConvS2`):
Here, only the suppot (index information) of the zero order DISCO kernel is
used to define an indicator function of the cutoff disk, so that any
input point contributes to an output location exactly when it lies within
:math:`\theta_\mathrm{cutoff}` of it. The relative weight of each input point
depends on their contribution to the softmax as well as their quadrature weights.
Parameters
----------
in_channels : int
number of channels of the input signal (corresponds to embed_dim in MHA in PyTorch)
in_shape : tuple
shape of the input grid
out_shape : tuple
shape of the output grid
grid_in : str, optional
input grid type, ``"equiangular"`` by default
grid_out : str, optional
output grid type, ``"equiangular"`` by default
bias : bool, optional
if specified, adds bias to input / output projection layers
theta_cutoff : float, optional
Angular radius of the geodesic neighborhood disk, in radians. Input points
farther than this from an output location are excluded from its attention.
If None (default), it is set to one latitudinal grid spacing of the coarser
of the input and output grids, i.e. ``pi / (nlat - 1)``. Must be positive.
k_channels : int
number of dimensions for interior inner product in the attention matrix (corresponds to kdim in MHA in PyTorch)
out_channels : int, optional
number of dimensions for interior inner product in the attention matrix (corresponds to vdim in MHA in PyTorch)
optimized_kernel : Optional[bool]
Whether to use the optimized kernel (if available)
References
----------
:cite:`Bonev2025`
"""
def __init__(
self,
in_channels: int,
in_shape: Tuple[int],
out_shape: Tuple[int],
grid_in: Optional[str] = "equiangular",
grid_out: Optional[str] = "equiangular",
num_heads: Optional[int] = 1,
scale: Optional[Union[torch.Tensor, float]] = None,
use_qknorm: Optional[bool] = False,
bias: Optional[bool] = True,
theta_cutoff: Optional[float] = None,
k_channels: Optional[int] = None,
out_channels: Optional[int] = None,
optimized_kernel: Optional[bool] = True,
):
super().__init__()
self.nlat_in, self.nlon_in = in_shape
self.nlat_out, self.nlon_out = out_shape
# direction selection: gather (self / downsample) iff nlon_in is an integer
# multiple of nlon_out; scatter (upsample) iff nlon_out is an integer multiple
# of nlon_in. Self-attention (nlon_in == nlon_out) satisfies both and falls
# through the gather path with pscale == 1.
self.upsample = (self.nlon_out % self.nlon_in == 0) and (self.nlon_in % self.nlon_out != 0)
if not (self.nlon_in % self.nlon_out == 0 or self.upsample):
raise ValueError(f"either nlon_in ({self.nlon_in}) must be an integer multiple of nlon_out ({self.nlon_out}), or vice versa, for the attention p-shift to be exact")
self.in_channels = in_channels
self.num_heads = num_heads
self.k_channels = in_channels if k_channels is None else k_channels
self.out_channels = in_channels if out_channels is None else out_channels
self.optimized_kernel = optimized_kernel and optimized_kernels_is_available()
# heuristic to compute theta cutoff based on the bandlimit of the input field
# and overlaps of the basis functions. For upsample we follow DISCO's transpose
# convention and use the coarser (input) grid spacing.
if theta_cutoff is None:
if self.upsample:
self.theta_cutoff = torch.pi / float(self.nlat_in - 1)
else:
self.theta_cutoff = torch.pi / float(self.nlat_out - 1)
else:
self.theta_cutoff = theta_cutoff
if self.theta_cutoff <= 0.0:
raise ValueError("Error, theta_cutoff has to be positive.")
# integration weights live on the input grid
_, wgl = precompute_latitudes(self.nlat_in, grid=grid_in)
quad_weights = 2.0 * torch.pi * wgl.to(dtype=torch.float32) / self.nlon_in
self.register_buffer("quad_weights", quad_weights, persistent=False)
# create a dummy filter basis to pass to the construction of the convolution tensor
# this is to avoid code duplication as the logic of pre-computing the sparsity pattern
# is identical to convolutions with a constant filter function
fb = get_filter_basis(kernel_shape=1, basis_type="zernike")
# precompute the neighborhood sparsity pattern. For upsample we mirror DISCO's
# transpose module: pass shapes swapped + transpose_normalization=True so that
# rows of psi index the (smaller) input grid and cols encode the (larger)
# output grid as ho_big * nlon_out + wo_big_canonical.
if self.upsample:
idx, _, roff = _precompute_convolution_tensor_s2(
out_shape,
in_shape,
fb,
grid_in=grid_out,
grid_out=grid_in,
theta_cutoff=self.theta_cutoff,
transpose_normalization=True,
basis_norm_mode="none",
merge_quadrature=True,
)
else:
idx, _, roff = _precompute_convolution_tensor_s2(
in_shape,
out_shape,
fb,
grid_in=grid_in,
grid_out=grid_out,
theta_cutoff=self.theta_cutoff,
transpose_normalization=False,
basis_norm_mode="none",
merge_quadrature=True,
)
# this is kept for legacy resons in case we want to resuse sorting of these entries
row_idx = idx[1, ...].contiguous()
col_idx = idx[2, ...].contiguous()
roff_idx = roff.contiguous()
self.register_buffer("psi_row_idx", row_idx, persistent=False)
self.register_buffer("psi_col_idx", col_idx, persistent=False)
self.register_buffer("psi_roff_idx", roff_idx, persistent=False)
# learnable parameters — Xavier uniform init matching PyTorch MHA convention:
# bound = sqrt(6 / (fan_in + fan_out)) for each projection
if self.k_channels % self.num_heads != 0:
raise ValueError(f"Please make sure that number of heads {self.num_heads} divides k_channels {self.k_channels} evenly.")
if self.out_channels % self.num_heads != 0:
raise ValueError(f"Please make sure that number of heads {self.num_heads} divides out_channels {self.out_channels} evenly.")
scale_qk = math.sqrt(6.0 / (self.in_channels + self.k_channels))
scale_v = math.sqrt(6.0 / (self.in_channels + self.out_channels))
scale_proj = math.sqrt(3.0 / self.out_channels)
self.q_weights = nn.Parameter(scale_qk * (2 * torch.rand(self.k_channels, self.in_channels, 1, 1) - 1))
self.k_weights = nn.Parameter(scale_qk * (2 * torch.rand(self.k_channels, self.in_channels, 1, 1) - 1))
self.v_weights = nn.Parameter(scale_v * (2 * torch.rand(self.out_channels, self.in_channels, 1, 1) - 1))
self.proj_weights = nn.Parameter(scale_proj * (2 * torch.rand(self.out_channels, self.out_channels, 1, 1) - 1))
if scale is not None:
self.scale = scale
else:
self.scale = 1 / math.sqrt(self.k_channels // self.num_heads)
if bias:
self.q_bias = nn.Parameter(torch.zeros(self.k_channels))
self.k_bias = nn.Parameter(torch.zeros(self.k_channels))
self.v_bias = nn.Parameter(torch.zeros(self.out_channels))
self.proj_bias = nn.Parameter(torch.zeros(self.out_channels))
else:
self.q_bias = None
self.k_bias = None
self.v_bias = None
self.proj_bias = None
if use_qknorm:
self.q_norm_weights = nn.Parameter(torch.zeros(self.k_channels // self.num_heads))
self.k_norm_weights = nn.Parameter(torch.zeros(self.k_channels // self.num_heads))
else:
self.q_norm_weights = None
self.k_norm_weights = None
if self.optimized_kernel:
self.attention_handle = _neighborhood_s2_attention_optimized
else:
self.attention_handle = _neighborhood_s2_attention_torch
def extra_repr(self):
return f"in_shape={(self.nlat_in, self.nlon_in)}, out_shape={(self.nlat_out, self.nlon_out)}, in_channels={self.in_channels}, out_channels={self.out_channels}, k_channels={self.k_channels}, theta_cutoff={self.theta_cutoff}"
[docs]
def forward(self, query: torch.Tensor, key: Optional[torch.Tensor] = None, value: Optional[torch.Tensor] = None) -> torch.Tensor:
"""
Apply neighborhood attention on the sphere.
Parameters
----------
query : torch.Tensor
Query signal of shape ``(batch, in_channels, nlat_out, nlon_out)`` (sampled on the output grid).
key : torch.Tensor, optional
Key signal of shape ``(batch, in_channels, nlat_in, nlon_in)`` (sampled on the input grid).
Defaults to ``query`` (self-attention, which requires matching input and output grids).
value : torch.Tensor, optional
Value signal of shape ``(batch, in_channels, nlat_in, nlon_in)`` (sampled on the input grid).
Defaults to ``query`` (self-attention, which requires matching input and output grids).
Returns
-------
torch.Tensor
Attention output of shape ``(batch, out_channels, nlat_out, nlon_out)``.
"""
# self attention simplification
if key is None:
key = query
if value is None:
value = query
# change this later to allow arbitrary number of batch dims
torch._check(query.dim() == 4, lambda: f"Expected 4-dimensional query tensor, got {query.dim()} dimensions")
torch._check(key.dim() == 4, lambda: f"Expected 4-dimensional key tensor, got {key.dim()} dimensions")
torch._check(value.dim() == 4, lambda: f"Expected 4-dimensional value tensor, got {value.dim()} dimensions")
torch._check(query.shape[-2] == self.nlat_out, lambda: f"Expected query latitudes shape[-2]=={self.nlat_out}, got {query.shape[-2]}")
torch._check(query.shape[-1] == self.nlon_out, lambda: f"Expected query longitudes shape[-1]=={self.nlon_out}, got {query.shape[-1]}")
torch._check(key.shape[-2] == self.nlat_in, lambda: f"Expected key latitudes shape[-2]=={self.nlat_in}, got {key.shape[-2]}")
torch._check(key.shape[-1] == self.nlon_in, lambda: f"Expected key longitudes shape[-1]=={self.nlon_in}, got {key.shape[-1]}")
torch._check(value.shape[-2] == self.nlat_in, lambda: f"Expected value latitudes shape[-2]=={self.nlat_in}, got {value.shape[-2]}")
torch._check(value.shape[-1] == self.nlon_in, lambda: f"Expected value longitudes shape[-1]=={self.nlon_in}, got {value.shape[-1]}")
# perform QKV projections
query = nn.functional.conv2d(query, self.q_weights, bias=self.q_bias)
key = nn.functional.conv2d(key, self.k_weights, bias=self.k_bias)
value = nn.functional.conv2d(value, self.v_weights, bias=self.v_bias)
# perform QK normalization (must come before scale)
if self.q_norm_weights is not None:
B, C, H, W = query.shape
query = query.reshape(B, self.num_heads, -1, H, W).permute(0, 1, 3, 4, 2)
query = F.rms_norm(query, normalized_shape=self.q_norm_weights.shape, weight=1 + self.q_norm_weights)
query = query.permute(0, 1, 4, 2, 3).reshape(B, C, H, W).contiguous()
if self.k_norm_weights is not None:
B, C, H, W = key.shape
key = key.reshape(B, self.num_heads, -1, H, W).permute(0, 1, 3, 4, 2)
key = F.rms_norm(key, normalized_shape=self.k_norm_weights.shape, weight=1 + self.k_norm_weights)
key = key.permute(0, 1, 4, 2, 3).reshape(B, C, H, W).contiguous()
# scale after normalization
query_scaled = query * self.scale
# TODO: insert dimension checks for input
out = self.attention_handle(
key,
value,
query_scaled,
self.quad_weights,
self.psi_col_idx,
self.psi_roff_idx,
self.num_heads,
self.nlon_in,
self.nlat_out,
self.nlon_out,
)
out = nn.functional.conv2d(out, self.proj_weights, bias=self.proj_bias)
return out