Allegro, block by block: reproducing the original implementation with xnn#
This notebook checks every piece needed to reproduce the original Allegro model
(mir-group/allegro, v0.3.0, the e3nn-era
reference implementation of the paper) using the xnn re-implementation
(xnn.gnn.models.allegro). For each architectural block we
state the defining equation(s) from the paper,
show the corresponding
xnnbuilding block,run it on a small toy system, and
compare it numerically against the original
allegropackage (ground truth).
The paper (provided alongside this notebook): Musaelian, Batzner et al.,
Learning local equivariant representations for large-scale atomistic dynamics,
Nat. Commun. 14, 579 (2023). Equation numbers below refer to it. Its Methods
pin the nequip/e3nn 0.4.4 stack we compare against.
Ground truth. Where a block has no learnable weights (radial basis shape, spherical harmonics, the Wigner-3j tensor products) the two agree to machine precision out of the box. Where a block has weights, we transplant them from
allegrointoxnnand check the outputs match to ~\(10^{-16}\). The notebook ends by transplanting an entire Allegro model and showing energy and forces are bit-for-bit identical.
0. Setup: float64 for exact comparison#
# silence the expected warnings
import logging, warnings
logging.disable(logging.WARNING)
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=FutureWarning,
message="You are using `torch.load` with `weights_only=False`")
import numpy as np
import torch
torch.set_default_dtype(torch.float64)
torch.manual_seed(0)
from e3nn import o3
import xnn, allegro, nequip, e3nn
print("xnn :", xnn.__version__)
print("allegro:", allegro.__version__, "(original mir-group/allegro -- ground truth)")
print("nequip :", nequip.__version__, "| e3nn:", e3nn.__version__,
"| torch:", torch.__version__)
xnn : 0.1.0
allegro: 0.3.0 (original mir-group/allegro -- ground truth)
nequip : 0.6.2 | e3nn: 0.4.4 | torch: 2.5.1+cu121
A toy system#
from xnn.common.data import structure_to_graph
SPECIES = [1, 6, 8] # H, C, O
CUTOFF = 5.0 # r_max
rng = np.random.default_rng(1)
pos = rng.uniform(0, 4, (7, 3))
Z = np.array(([1, 6, 8] * 7)[:7])
graph = structure_to_graph({"pos": pos, "atomic_numbers": Z}, CUTOFF)
print("atoms:", graph.num_nodes, "| edges:", graph.num_edges)
def report(name, diff, tol=1e-12):
tag = "OK " if diff <= tol else "!! "
print(f"{tag}{name:<48} max|xnn - allegro| = {diff:.2e}")
atoms: 7 | edges: 42
The Allegro architecture in one picture#
Allegro is strictly local: no message passing. The energy is a sum of per-species-scaled pair energies (paper eqs 5–6),
and each ordered pair \(ij\) carries two latents that interact at every layer (paper Fig. 1): an invariant scalar latent \(x^{ij,L}\) and an equivariant tensor latent \(V^{ij,L}_{n,\ell,p}\):
We reproduce each block in turn.
Block 1: Radial basis (trainable “normalized sinc” Bessel × polynomial cutoff)#
The reference code’s AllegroBesselBasis is
\(B_n(r) = \sin(b_n r)\,/\,(\pi r / r_c)\cdot u(r)\) with trainable
frequencies \(b_n = n\pi/r_c\) and the degree-\(p\) polynomial envelope \(u\) (eq 8;
“Bessel basis functions with a polynomial envelope”). The xnn featurizer
reproduces it with BesselRBF(..., trainable=True, prefactor=r_c/π), the same
family as NequIP/MACE, differing only in prefactor and frequency
parameterization (transplant: freqs = bessel_weights · r_c).
from xnn.common.models import build_model
from xnn.common.config import from_dict
LMAX, NL, NF, NRBF, AVG = 2, 2, 8, 8, 8.0
TB, LAT, EE = [16, 32], [32], [16]
cfg = from_dict({"model": {"name": "allegro", "cutoff": CUTOFF, "n_features": NF,
"n_interactions": NL, "n_rbf": NRBF,
"extra": {"species": SPECIES, "l_max": LMAX, "avg_num_neighbors": AVG,
"two_body_latent": TB, "latent": LAT, "edge_eng": EE}}})
xal = build_model(cfg.model)
from nequip.model import model_from_config
n_model = model_from_config(dict(
model_builders=["allegro.model.Allegro"],
r_max=CUTOFF, num_layers=NL, l_max=LMAX, parity="o3_full",
num_tensor_features=NF, num_bessels_per_basis=NRBF, PolynomialCutoff_p=6.0,
avg_num_neighbors=AVG, chemical_symbols=["H", "C", "O"],
two_body_latent_mlp_latent_dimensions=TB, latent_mlp_latent_dimensions=LAT,
env_embed_mlp_latent_dimensions=[], edge_eng_mlp_latent_dimensions=EE,
), initialize=True)
seq = n_model.model # the Allegro sequential (no force wrapper here)
print("original blocks:", [n for n, _ in seq.named_children()])
# transplant the trainable Bessel frequencies and compare the basis on real edges
with torch.no_grad():
xal.edge_feat.rbf.freqs.copy_(seq.radial_basis.bessel_weights * CUTOFF)
edge = xal.edge_feat(graph)
r = edge["edge_length"]
ref = torch.sin(r[:, None] * seq.radial_basis.bessel_weights) / (np.pi * r[:, None] / CUTOFF)
p = 6.0; x = r[:, None] / CUTOFF
env = (1 - (p+1)*(p+2)/2*x**p + p*(p+2)*x**(p+1) - p*(p+1)/2*x**(p+2)) * (x < 1)
report("Bessel basis x cutoff B_n(r) u(r)", (edge["edge_radial"] - ref*env).abs().max().item())
original blocks: ['radial_basis', 'typeembed', 'spharm', 'allegro', 'edge_eng', 'edge_eng_sum', 'total_energy_sum']
OK Bessel basis x cutoff B_n(r) u(r) max|xnn - allegro| = 8.60e-16
Block 2: Two-body scalar embedding · eq 7#
The pair’s initial invariant latent embeds the chemistry of the pair and the
distance. The reference code implements eq 7 as ProductTypeEmbedding: learned
per-type embeddings of centre and neighbour, concatenated and multiplied
elementwise with a linear projection of the radial basis. In xnn this is the
type_embeddings parameter plus basis_embed. Both weights transplant.
def copy_fcn(fcn, mod):
'''upstream ScalarMLPFunction -> e3nn FullyConnectedNet (same math, verified)'''
sd = dict(mod.named_parameters())
with torch.no_grad():
for i in range(len(fcn.hs) - 1):
getattr(fcn, f"layer{i}").weight.copy_(sd[f"_forward._weight_{i}"])
with torch.no_grad():
xal.type_embeddings.copy_(seq.typeembed.type_embeddings)
copy_fcn(xal.basis_embed, seq.typeembed.basis_mlp)
# xnn two-body embedding on the toy graph (centre = edge_index[1] in xnn)
types = xal.z_to_index[graph.atomic_numbers].clamp(min=0)
ei = graph.edge_index
x_embed = torch.cat((xal.type_embeddings[0][types[ei[1]]],
xal.type_embeddings[1][types[ei[0]]]), dim=-1) \
* xal.basis_embed(edge["edge_radial"])
# original, driven with the same per-edge inputs (its centre is row 0)
from nequip.data import AtomicDataDict
data = {AtomicDataDict.ATOM_TYPE_KEY: types.unsqueeze(-1),
AtomicDataDict.EDGE_INDEX_KEY: ei.flip(0),
AtomicDataDict.EDGE_EMBEDDING_KEY: edge["edge_radial"],
AtomicDataDict.POSITIONS_KEY: graph.pos,
AtomicDataDict.EDGE_CELL_SHIFT_KEY: graph.cell_shifts.to(graph.pos.dtype)}
n_embed = seq.typeembed(dict(data))[AtomicDataDict.EDGE_EMBEDDING_KEY]
report("two-body product embedding x^(ij,0)", (x_embed - n_embed).abs().max().item())
OK two-body product embedding x^(ij,0) max|xnn - allegro| = 8.88e-16
Block 3: Spherical harmonics & initial tensor features · eqs 9–10#
The angular basis is \(\vec Y^{ij}_{\ell,p} = Y^m_\ell(\hat r_{ij})\) with
\(\vec r_{ij} = \vec r_j - \vec r_i\) (paper notation), the opposite orientation
to the xnn/MACE edge vector, so the model flips internally, exactly as the
xnn NequIP does. The initial equivariant features are the spherical harmonics
weighted into \(n_{\rm tensor}\) channels by weights generated from the scalar
latent (eqs 9–10): _ChannelWeighter vs the original MakeWeightedChannels.
from allegro.nn._strided import MakeWeightedChannels
from xnn.gnn.models.allegro import _ChannelWeighter
vec = graph.edge_vectors()
ir_sh = o3.Irreps.spherical_harmonics(LMAX)
ref_sh = o3.spherical_harmonics(ir_sh, -vec, normalize=True, normalization="component")
_, xsh, _ = xal.edge_feat.embed(-vec)
report("spherical harmonics Y(r_j - r_i)", (xsh - ref_sh).abs().max().item())
m_wc = MakeWeightedChannels(irreps_in=ir_sh, multiplicity_out=NF)
x_wc = _ChannelWeighter(ir_sh, NF)
w = torch.randn(graph.num_edges, m_wc.weight_numel)
report("weighted channels V^(ij,0) = w Y", (x_wc(xsh, w) - m_wc(xsh, w)).abs().max().item(), tol=0.0)
OK spherical harmonics Y(r_j - r_i) max|xnn - allegro| = 0.00e+00
OK weighted channels V^(ij,0) = w Y max|xnn - allegro| = 0.00e+00
Block 4: The weighted-environment tensor product · eqs 11–14#
The core of Allegro. The embedded environment of atom \(i\) is the weighted
sum \(\sum_{k\in\mathcal N(i)} w^{ik,L}_{n,\ell_2,p_2}\vec Y^{ik}\) (eq 14, with
learned weights from the scalar latents, the “density trick”, eqs 12–13; the
reference code subtracts the edge’s own term and normalizes by
\(1/\sqrt{\lambda-1}\)). The pair tensors are then contracted with it through a
per-channel (“uuu”), weightless tensor product whose Wigner-3j blocks carry
\(\sqrt{2\ell_{\rm out}+1}\): xnn’s _Contracter vs the original strided
Contracter, bit-for-bit.
from allegro.nn._strided import Contracter
from xnn.gnn.models.allegro import _Contracter
arg_irs = [ir for _, ir in ir_sh]
env_irs = [ir for _, ir in ir_sh]
# allowed o3_full irreps, pruned to those reachable from arg x env (as the model does)
out_irs = [ir for ir in (o3.Irrep(l, p) for l in range(LMAX + 1) for p in (1, -1))
if any(ir in a * e for a in arg_irs for e in env_irs)]
instr, full_out = [], []
for ir_out in out_irs:
for i1, ir1 in enumerate(arg_irs):
for i2, ir2 in enumerate(env_irs):
if ir_out in ir1 * ir2:
instr.append((i1, i2, ir_out)); full_out.append(ir_out)
m_tp = Contracter(
irreps_in1=o3.Irreps([(NF, ir) for ir in arg_irs]),
irreps_in2=o3.Irreps([(NF, ir) for ir in env_irs]),
irreps_out=o3.Irreps([(NF, ir) for ir in full_out]),
instructions=[(i1, i2, k) for k, (i1, i2, _) in enumerate(instr)],
connection_mode="uuu", shared_weights=False, has_weight=False)
x_tp = _Contracter(arg_irs, env_irs, instr)
E = graph.num_edges
t1 = torch.randn(E, NF, sum(ir.dim for ir in arg_irs))
t2 = torch.randn(E, NF, sum(ir.dim for ir in env_irs))
report("weightless uuu tensor product (eqs 11-13)",
(x_tp(t1, t2) - m_tp(t1, t2)).abs().max().item())
OK weightless uuu tensor product (eqs 11-13) max|xnn - allegro| = 1.07e-14
Block 5: Scalar latent MLPs & the cumulative-softmax resnet · eq 15#
The scalar outputs of each TP are concatenated with the previous latent and
compressed by the latent MLP (eq 15). All Allegro MLPs are variance-preserving
scalar MLPs; e3nn.nn.FullyConnectedNet is numerically identical to the
original ScalarMLPFunction given the same weights. The residual update uses
normalized cumulative-softmax coefficients (Supplementary Note 2), reproduced
in xnn (_resnet_params).
from allegro.nn._fc import ScalarMLPFunction
from e3nn.nn import FullyConnectedNet
import torch.nn.functional as Fn
up = ScalarMLPFunction(mlp_input_dimension=24, mlp_latent_dimensions=[32, 64],
mlp_output_dimension=16, mlp_nonlinearity="silu")
fc = FullyConnectedNet([24, 32, 64, 16], Fn.silu)
copy_fcn(fc, up)
t = torch.randn(9, 24)
report("variance-preserving scalar MLP (eq 15)", (fc(t) - up(t)).abs().max().item())
# resnet coefficients: zeros -> exp() -> all-equal cumulative softmax
params = torch.zeros(NL + 1)
coeff = (params - params.max()).exp(); cumsum = coeff.cumsum(0) + 1e-12
print("layer resnet coefficients (old, new) per layer:",
[(float((cumsum[i-1]/cumsum[i]).sqrt()), float((coeff[i]/cumsum[i]).sqrt()))
for i in range(1, NL + 1)])
OK variance-preserving scalar MLP (eq 15) max|xnn - allegro| = 1.33e-15
layer resnet coefficients (old, new) per layer: [(0.7071067811867243, 0.7071067811863707), (0.8164965809277941, 0.5773502691895296)]
Block 6: Channel-mixing linear · eq 16#
The outputs of all TP paths with the same irrep are linearly mixed back into
num_tensor_features channels with a \(1/\sqrt{\text{mul}\cdot n_{\rm paths}}\)
normalization (eq 16). xnn’s _StridedLinear keeps the original’s flat
weight layout, so the weight vector transplants directly.
from allegro.nn._strided import Linear as StridedLinearRef
from xnn.gnn.models.allegro import _StridedLinear
m_lin = StridedLinearRef(o3.Irreps([(NF, ir) for ir in full_out]),
o3.Irreps([(NF, ir) for ir in out_irs]),
shared_weights=True, internal_weights=True)
x_lin = _StridedLinear(full_out, out_irs, NF)
with torch.no_grad():
x_lin.w.copy_(m_lin.w)
t = torch.randn(E, NF, sum(ir.dim for ir in full_out))
report("channel-mixing linear (eq 16)",
(x_lin(t) - m_lin(t.reshape(E, -1))).abs().max().item())
OK channel-mixing linear (eq 16) max|xnn - allegro| = 1.33e-15
Block 7: Pair energy, edgewise sum, per-species scale/shift · eqs 5–6, 17#
The final scalar latent is read out to the pair energy \(E_{ij}\) by the output
MLP (eq 17); pair energies are summed onto their centre atom with the
\(1/\sqrt{\lambda}\) normalization, then the per-species scale/shift of eq 5
(atom_scale / atom_ref in xnn, PerSpeciesRescale upstream) and autograd
forces \(\vec F = -\nabla E\). With a zero scale the energy is exactly the sum
of shifts and forces vanish.
from xnn.common.models import ForceStressOutput
cfg0 = from_dict({"model": {"name": "allegro", "cutoff": CUTOFF, "n_features": NF,
"n_interactions": 1, "extra": {"species": SPECIES, "l_max": 1,
"two_body_latent": TB, "latent": LAT, "edge_eng": EE,
"atomic_energies": [0.5, -1.3, -2.1], "atomic_scales": 0.0}}})
m0 = ForceStressOutput(build_model(cfg0.model))
o0 = m0(graph)
E0_expected = sum({1: 0.5, 6: -1.3, 8: -2.1}[int(z)] for z in Z)
report("sigma=0 energy == sum of shifts", abs(float(o0["energy"]) - E0_expected))
report("sigma=0 forces == 0", o0["forces"].abs().max().item())
OK sigma=0 energy == sum of shifts max|xnn - allegro| = 0.00e+00
OK sigma=0 forces == 0 max|xnn - allegro| = 0.00e+00
Capstone: transplant a whole Allegro model and compare energy & forces#
We rebuild the original with the per-species scale/shift and force output, transplant every weight (Bessel frequencies, type/basis embedding, all latent / env-embed MLPs, the channel-mixing linears, resnet coefficients, the output MLP, and the scale/shift), and run both on the toy molecule, the original through its own data pipeline.
from nequip.data import AtomicData
from nequip.data.transforms import TypeMapper
E0 = [0.5, -1.3, -2.1]; SIG = [1.7, 0.9, 1.1]
n_full = model_from_config(dict(
model_builders=["allegro.model.Allegro", "PerSpeciesRescale", "ForceOutput"],
r_max=CUTOFF, num_layers=NL, l_max=LMAX, parity="o3_full",
num_tensor_features=NF, num_bessels_per_basis=NRBF, PolynomialCutoff_p=6.0,
avg_num_neighbors=AVG, chemical_symbols=["H", "C", "O"],
two_body_latent_mlp_latent_dimensions=TB, latent_mlp_latent_dimensions=LAT,
env_embed_mlp_latent_dimensions=[], edge_eng_mlp_latent_dimensions=EE,
per_species_rescale_shifts=E0, per_species_rescale_scales=SIG,
), initialize=True)
seq = n_full.model.func
al = seq.allegro
cfgF = from_dict({"model": {"name": "allegro", "cutoff": CUTOFF, "n_features": NF,
"n_interactions": NL, "n_rbf": NRBF,
"extra": {"species": SPECIES, "l_max": LMAX, "avg_num_neighbors": AVG,
"two_body_latent": TB, "latent": LAT, "edge_eng": EE,
"atomic_energies": E0, "atomic_scales": SIG}}})
xfull = build_model(cfgF.model)
def transplant_full(x, seq, n_layers):
al = seq.allegro
with torch.no_grad():
x.edge_feat.rbf.freqs.copy_(seq.radial_basis.bessel_weights * float(al.r_max))
x.type_embeddings.copy_(seq.typeembed.type_embeddings)
copy_fcn(x.basis_embed, seq.typeembed.basis_mlp)
for i in range(n_layers):
copy_fcn(x.latents[i], al.latents[i])
copy_fcn(x.env_embed_mlps[i], al.env_embed_mlps[i])
x.linears[i].w.copy_(al.linears[i].w)
copy_fcn(x.final_latent, al.final_latent)
copy_fcn(x.edge_eng, seq.edge_eng._module)
x._resnet_params.copy_(al._latent_resnet_coefficients_params)
transplant_full(xfull, seq, NL)
ox = ForceStressOutput(xfull)(structure_to_graph({"pos": pos, "atomic_numbers": Z}, CUTOFF))
tm = TypeMapper(chemical_symbols=["H", "C", "O"])
dd = AtomicData.to_AtomicDataDict(tm(AtomicData.from_points(
pos=torch.tensor(pos), r_max=CUTOFF, atomic_numbers=torch.tensor(Z))))
dd[AtomicDataDict.POSITIONS_KEY].requires_grad_(True)
om = n_full(dd)
E_m = float(om[AtomicDataDict.TOTAL_ENERGY_KEY].sum())
F_m = -torch.autograd.grad(om[AtomicDataDict.TOTAL_ENERGY_KEY].sum(),
dd[AtomicDataDict.POSITIONS_KEY])[0]
print(f"total energy xnn = {float(ox['energy']):.10f} eV")
print(f"total energy allegro = {E_m:.10f} eV")
report("FULL MODEL total energy", abs(float(ox["energy"]) - E_m))
report("FULL MODEL per-atom forces",
(ox["forces"].detach() - F_m).abs().max().item())
total energy xnn = -8.7580160536 eV
total energy allegro = -8.7580160536 eV
OK FULL MODEL total energy max|xnn - allegro| = 1.78e-15
OK FULL MODEL per-atom forces max|xnn - allegro| = 6.22e-15
Summary#
Block |
Paper eq |
Weights? |
agreement |
|---|---|---|---|
1. Trainable “normalized sinc” Bessel × cutoff |
8 |
transplanted (\(b_n\)) |
machine precision |
2. Two-body product embedding |
7 |
transplanted |
machine precision |
3. Spherical harmonics + weighted channels |
9–10 |
transplanted |
bit-identical (0) |
4. Weighted-env “uuu” tensor product |
11–14 |
none (w3j) |
machine precision |
5. Scalar latent MLPs + cumulative-softmax resnet |
15 |
transplanted |
machine precision |
6. Channel-mixing linear |
16 |
transplanted (flat layout) |
machine precision |
7. Pair energy, \(1/\sqrt\lambda\) sums, \(\sigma_Z/\mu_Z\) |
5–6, 17 |
n/a |
exact |
Full model |
5–17 |
all transplanted |
energy 0, forces ~1e-15 |
xnn.gnn.models.allegro is a faithful reproduction of the original Allegro,
depending only on e3nn (no allegro/nequip/opt_einsum_fx), sharing the
xnn equivariant-GNN abstractions with NequIP and MACE, and TorchScript /
LAMMPS-deployable (the pair_allegro path). The companion notebooks train and
test it on realistic Argon MD data.