Radial basis: bessel_basis
Role
A radial basis turns an interatomic distance into a vector of smooth invariant features \(\bm\varphi(r)\). Every distance dependence in the network — radial filters of convolutions, attention scores, encoder amplitudes — is a learned function of these features, so the basis sets the radial resolution of the model.
Built-in members of this kind: the Gaussian soft one-hot basis of the flat
MANDALA network and gaussian_basis (fixed or trainable centres), and the
sine basis with polynomial cutoff cutoff_radial_expansion.
The new layer
Spherical Bessel functions of order zero are the eigenfunctions of the Laplacian in a ball with Dirichlet boundary at \(r_c\):
They are orthogonal on \([0,r_c]\) with weight \(r^2\), so different basis functions carry non-redundant radial information. The polynomial envelope
brings every feature smoothly to zero at the cutoff, so the predicted operator varies smoothly when an atom enters or leaves the neighbourhood — important for molecular dynamics and for differentiable geometry. Optional trainable frequencies let the model adapt the radial resolution.
Onsite edges. At \(r=0\), \(\sin(n\pi r/r_c)/r\) is finite (\(n\pi/r_c\)) but its direct evaluation divides by zero. Writing it as
gives exact values and gradients everywhere (checklist item 7).
Symmetry. Lengths are invariant, so any function of them is invariant.
Implementation
class BesselBasis(nn.Module):
r"""``phi_n(r) = sqrt(2/r_c) * sin(n pi r / r_c) / r * u(r / r_c)``.
``u`` is the C^2 polynomial envelope of order ``p``. The ``sin(x)/r`` factor
is evaluated through ``sinc`` so that onsite edges (``r = 0``) are finite.
"""
def __init__(self, cutoff, n_basis=8, envelope_exponent=6, trainable=False):
super().__init__()
self.cutoff = float(cutoff)
self.p = int(envelope_exponent)
frequencies = torch.arange(1, n_basis + 1, dtype=torch.get_default_dtype())
if trainable:
self.frequencies = nn.Parameter(frequencies)
else:
self.register_buffer("frequencies", frequencies)
def envelope(self, x):
p = self.p
u = (
1
- (p + 1) * (p + 2) / 2 * x**p
+ p * (p + 2) * x ** (p + 1)
- p * (p + 1) / 2 * x ** (p + 2)
)
return torch.where(x < 1, u, torch.zeros_like(u))
def forward(self, lengths):
r = lengths.reshape(-1, 1)
x = r / self.cutoff
# sin(n pi x) / r == (n pi / r_c) * sinc(n x)
radial = (
self.frequencies * math.pi / self.cutoff * torch.sinc(self.frequencies * x)
)
return math.sqrt(2 / self.cutoff) * radial * self.envelope(x)
The factory requires one length per edge and declares N invariant
scalars on the same support:
@register_transform("bessel_basis")
def _bessel_basis(options, inputs, mapper):
(lengths,) = inputs
if lengths.domain != "edge" or lengths.dim != 1:
raise ValueError("bessel_basis expects one edge length per edge")
module = BesselBasis(**options)
width = len(module.frequencies)
return module, [
FeatureSpec("edge", irreps=f"{width}x0e", width=width, support=lengths.support)
]
Usage
- id: radial
transform: bessel_basis
inputs: [edge_lengths]
outputs: [radial]
options: {cutoff: 6.0, n_basis: 8, envelope_exponent: 6, trainable: false}
radial is a 8x0e edge feature and can be consumed by any layer that
expects invariant radial features, including the built-in
radial_tensor_product and projected_attention.
Variations
Replace the envelope by a learned monotone function to let the network choose its effective range.
Scale the argument per species pair by a characteristic bond length \(r_0^{(ab)}\) (as
pair_distance_normalization: pair_r0does for the Gaussian basis) to give every bond type the same resolution near its equilibrium distance.