Equivariant nonlinearity: squash_activation
Role
Without nonlinearities a stack of equivariant layers collapses to a polynomial of fixed degree in the input. Elementwise activations, however, break equivariance for non-scalar irreps: \(\tanh\) applied to each component of a vector does not commute with rotations. Equivariant nonlinearities therefore act on vectors only through their norms, through invariant gates, or through functions on the sphere.
Built-in members of this kind: normact, gate_magnitudes,
gate_scalars_mlp and s2act (nonlin_kind), the e3nn gate inside
EquiConv, and irrep_activation as a stage.
The new layer
The squash function of capsule networks rescales each copy of an irrep according to its own norm:
The output norm \(\|\bm x_c\|^2/(\kappa_c^2+\|\bm x_c\|^2)\) grows quadratically for small inputs and saturates at one for large ones: the layer suppresses weak, noisy directions and caps large ones, with a learned knee \(\kappa_c\) per copy. Even scalars, which admit any pointwise function, use SiLU.
Symmetry.
Rotations. The scale factor depends on \(\|\bm x_c\|\), which is invariant, so the map multiplies each copy by an invariant (checklist 3c).
Inversion. An odd-parity copy changes sign, \(\bm x_c\to-\bm x_c\). The squash is an odd function of \(\bm x_c\), so its output changes sign too. This makes the same formula valid for pseudoscalars
0o, where a function like SiLU would be wrong (checklist 5).
Implementation
class SquashActivation(nn.Module):
r"""``v -> v |v| / (kappa^2 + |v|^2)`` for every copy except even scalars.
The map rescales each copy by a function of its norm, so it commutes with
rotations; it is odd in ``v``, so it also commutes with inversion for odd
parity copies. Even scalars use SiLU. ``kappa`` is learned per copy.
"""
def __init__(self, irreps):
super().__init__()
self.irreps = o3.Irreps(irreps)
self.raw_knee = nn.Parameter(torch.zeros(self.irreps.num_irreps))
self.register_buffer(
"even_scalar",
torch.tensor(
[ir.l == 0 and ir.p == 1 for mul, ir in self.irreps for _ in range(mul)]
),
)
def forward(self, x):
norms = []
for (mul, ir), sl in zip(self.irreps, self.irreps.slices()):
block = x[..., sl].reshape(*x.shape[:-1], mul, ir.dim)
norms.append(zero_safe_norm(block, dim=-1))
norm = torch.cat(norms, dim=-1)
knee = nn.functional.softplus(self.raw_knee) + 1e-3
squash = norm / (knee.square() + norm.square())
scale = expand_per_copy(squash, self.irreps)
even = expand_per_copy(self.even_scalar.to(x.dtype), self.irreps).bool()
return torch.where(even, nn.functional.silu(x), x * scale)
zero_safe_norm returns zero with a zero gradient at the origin, so the
layer has well-defined gradients even for vanishing copies.
@register_transform("squash_activation")
def _squash_activation(options, inputs, mapper):
(features,) = inputs
module = SquashActivation(global_irreps(features), **options)
return module, [features]
Usage
- id: activation_1
transform: squash_activation
inputs: [normalized_1]
outputs: [node_1]
Variations
Use a gate that depends on all invariants of the entity (as
gate_scalars_mlpdoes) instead of each copy’s own norm, which lets scalars switch vectors on and off.Replace the rational squash by \(\tanh(\|\bm x\|/\kappa)\,\bm x/\|\bm x\|\) for a bounded, norm-preserving-at-small-scale variant.