NequIP, block by block: reproducing the original implementation with xnn#

This notebook checks every piece needed to reproduce the original NequIP model (mir-group/nequip) using the xnn re-implementation (xnn.gnn.models.nequip). For each architectural block we

  1. state the defining equation(s) from the papers,

  2. show the corresponding xnn building block,

  3. run it on a small toy system, and

  4. compare it numerically against the original nequip package (our ground truth).

The two papers used here:

  • Batzner et al., E(3)-equivariant graph neural networks for data-efficient and accurate interatomic potentials: Nat. Commun. 13, 579 (2022). The NequIP paper: species embedding, the equivariant convolution filter \(S(\vec r_{ij}) = R(r_{ij})\,Y^m_l(\hat r_{ij})\), gated nonlinearities, and the per-species energy scale/shift.

  • Musaelian et al., Learning local equivariant representations for large-scale atomistic dynamics: Nat. Commun. 14, 579 (2023). The Allegro paper (provided alongside this notebook) restates the atom-centred message-passing formalism NequIP instantiates (its eqs 1–2) and the equivariant tensor product (eq 4); its Methods pin the reference nequip + e3nn 0.4.4 software stack we compare against.

Ground truth. We import the original nequip package and compare the xnn blocks against it. Where a block has no learnable weights (polynomial cutoff, spherical harmonics, the gate) the two agree to machine precision out of the box. Where a block has weights, we transplant the weights from nequip into xnn and check that the outputs then match to ~\(10^{-16}\). The notebook ends by transplanting an entire NequIP model and showing the total energy and per-atom forces are bit-for-bit identical.

0. Setup#

We work in float64 throughout, which is what makes exact numerical comparison meaningful.

# silence the expected warnings
import logging
import warnings

logging.disable(logging.WARNING)                     # nequip's torch-version notice
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)  # required for exact comparison
torch.manual_seed(0)

from e3nn import o3

import xnn, nequip, e3nn

print("xnn  :", xnn.__version__)
print("nequip:", nequip.__version__, "(original mir-group/nequip -- ground truth)")
print("e3nn  :", e3nn.__version__)
print("torch :", torch.__version__, "| CUDA:", torch.cuda.is_available())
xnn  : 0.1.0
nequip: 0.6.2 (original mir-group/nequip -- ground truth)
e3nn  : 0.4.4
torch : 2.5.1+cu121 | CUDA: True

A toy system#

A handful of atoms of three elements (H, C, O). Everything downstream is checked on this single small graph so each block stays inspectable.

from xnn.common.data import structure_to_graph

SPECIES = [1, 6, 8]          # H, C, O  -> element (type) channels in NequIP
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)
print("edges :", graph.num_edges, " (neighbour list within r_max =", CUTOFF, ")")
print("Z     :", Z.tolist())

def report(name, diff, tol=1e-12):
    tag = "OK " if diff <= tol else "!! "
    print(f"{tag}{name:<46} max|xnn - nequip| = {diff:.2e}")
atoms : 7
edges : 42  (neighbour list within r_max = 5.0 )
Z     : [1, 6, 8, 1, 6, 8, 1]

The NequIP architecture in one picture#

NequIP is an atom-centred message-passing network (Allegro paper eqs 1–2):

\[ m^{t+1}_i=\sum_{j\in\mathcal N(i)} M_t\!\left(h^t_i,h^t_j,e_{ij}\right),\qquad h^{t+1}_i=U_t\!\left(h^t_i,m^{t+1}_i\right), \]

whose message function is an equivariant convolution: node features are combined with the spherical harmonics of the edge direction through the tensor product (Allegro paper eq 4), weighted per edge by a radial MLP:

\[\begin{split} \begin{align} & h^{(0)}_i = W\,\delta_{z z_i} & & \text{chemical (one-hot) embedding} \\[4pt] & B(r_{ij}) = \tfrac{2}{r_c}\,\tfrac{\sin(b_n r_{ij}/r_c)}{r_{ij}}\; f_{\rm cut}(r_{ij}), \qquad R(r_{ij}) = \mathrm{MLP}\big(B(r_{ij})\big) & & \text{radial basis + radial MLP} \\[4pt] & \vec Y^{\,ij}_{\ell} = Y^m_\ell(\widehat{r_j - r_i}) & & \text{angular basis} \\[4pt] & L_i = \frac{1}{\sqrt{\lambda}}\sum_{j\in\mathcal N(i)} \big(W_2\,h^{(t)}_j\big)\otimes_{R(r_{ij})} \vec Y^{\,ij}_{\ell} & & \text{convolution (interaction)} \\[4pt] & h^{(t+1)}_i = \mathrm{Gate}\big(W_3\,L_i + W_{z_i}\,h^{(t)}_i\big) & & \text{update: self-connection + gate} \\[4pt] & \varepsilon_i = W_5\,W_4\,h^{(T)}_i,\qquad E_i=\sigma_{z_i}\,\varepsilon_i+\mu_{z_i} & & \text{readout + per-species scale/shift} \\[4pt] & E=\sum_i E_i,\qquad \vec F = -\,\nabla E & & \text{total energy, forces} \end{align} \end{split}\]

We now reproduce each piece in turn.

Block 1: Chemical (element) embedding#

The initial node feature is a learnable linear embedding of the one-hot element \(h^{(0)}_i = W\,\delta_{z z_i}\) (NequIP §Methods, “type embedding”). In xnn the one-hot is produced by EquivariantGNN.node_attr() and \(W\) is NequIP.chemical_embedding (an o3.Linear); the original stacks OneHotAtomEncoding + AtomwiseLinear. Same parameters, so transplanting \(W\) makes them identical.

from nequip.nn import AtomwiseLinear
from nequip.nn.embedding import OneHotAtomEncoding
from nequip.data import AtomicDataDict
from xnn.common.models import build_model
from xnn.common.config import from_dict

# build an xnn NequIP just to grab its embedding + one-hot machinery
cfg = from_dict({
    "model": {
        "name": "nequip", "cutoff": CUTOFF, "n_features": 8, "n_interactions": 2,
        "extra": {"species": SPECIES, "l_max": 2, "avg_num_neighbors": 8.0},
    }
})
xnn_nequip = build_model(cfg.model)

node_attrs = xnn_nequip.node_attr(graph.atomic_numbers)      # one-hot  (N, 3)
one_hot = OneHotAtomEncoding(num_types=3)
m_embed = AtomwiseLinear(irreps_in=one_hot.irreps_out, irreps_out="8x0e",
                         field=AtomicDataDict.NODE_FEATURES_KEY)
xnn_nequip.chemical_embedding.load_state_dict(m_embed.linear.state_dict())

h0_xnn = xnn_nequip.chemical_embedding(node_attrs)
h0_nequip = m_embed({AtomicDataDict.NODE_FEATURES_KEY: node_attrs})[
    AtomicDataDict.NODE_FEATURES_KEY]
report("chemical embedding  h^(0)", (h0_xnn - h0_nequip).abs().max().item())
print("one-hot (first 3 atoms):\n", node_attrs[:3].int().numpy())
OK chemical embedding  h^(0)                      max|xnn - nequip| = 0.00e+00
one-hot (first 3 atoms):
 [[1 0 0]
 [0 1 0]
 [0 0 1]]

Block 2: Radial basis (trainable Bessel × polynomial cutoff)#

NequIP expands the interatomic distance in the Bessel basis (Klicpera et al.) under a smooth polynomial envelope of degree \(p\):

\[B_n(r)=\frac{2}{r_c}\,\frac{\sin(b_n\,r/r_c)}{r}\;f_{\rm cut}(r),\]

with two NequIP-specific conventions the xnn featurizer reproduces via BesselRBF(..., trainable=True, prefactor=2/r_c):

  • the frequencies \(b_n\) (initialised at \(n\pi\)) are learnable parameters (BesselBasis_trainable: true), and

  • the prefactor is \(2/r_c\), not the \(\sqrt{2/r_c}\) used by MACE/DimeNet.

The per-path radial MLP \(R(r)\) is the FullyConnectedNet fc inside each interaction block (Block 4). We compare the basis and cutoff directly against nequip.nn.radial_basis / nequip.nn.cutoffs.

from xnn.gnn.featurizers.radial import BesselRBF
from xnn.gnn.featurizers.cutoff import PolynomialCutoff as XPoly
from nequip.nn.radial_basis import BesselBasis
from nequip.nn.cutoffs import PolynomialCutoff as NPoly

r = torch.linspace(0.2, 4.9, 60)
NRBF, P = 8, 6

xb = BesselRBF(NRBF, CUTOFF, trainable=True, prefactor=2.0 / CUTOFF)(r)
nb = BesselBasis(CUTOFF, NRBF, trainable=True)(r)
report("Bessel basis  B_n(r)  (trainable, 2/rc)", (xb - nb).abs().max().item())

xc = XPoly(CUTOFF, p=P)(r)
nc = NPoly(CUTOFF, p=P)(r)
report("polynomial cutoff  f_cut(r)", (xc - nc).abs().max().item())

# full radial edge embedding  B_n(r) * f_cut(r)  (what the radial MLP consumes)
edge = xnn_nequip.edge_feat(graph)
lengths = edge["edge_length"]
ref = BesselBasis(CUTOFF, NRBF, trainable=True)(lengths) \
    * NPoly(CUTOFF, p=P)(lengths)[:, None]
report("radial embedding  B_n * f_cut (per edge)",
       (edge["edge_radial"] - ref).abs().max().item())
OK Bessel basis  B_n(r)  (trainable, 2/rc)        max|xnn - nequip| = 2.22e-16
OK polynomial cutoff  f_cut(r)                    max|xnn - nequip| = 2.66e-15
OK radial embedding  B_n * f_cut (per edge)       max|xnn - nequip| = 5.55e-17

Block 3: Spherical harmonics of the edge direction#

The angular part of the convolution filter is the real spherical harmonics of the unit edge vector, \(Y^m_l(\hat r_{ij})\), up to degree \(\ell_{\max}\). Both codes call the same e3nn routine with the same normalisation (normalize=True, normalization="component").

One convention differs: NequIP evaluates \(Y\) on \(\vec r_{ij} = r_j - r_i\) (neighbour minus centre), whereas the xnn/MACE edge vector points the other way (centre minus neighbour). xnn.gnn.models.nequip flips the edge vectors internally, so the model matches upstream exactly; we verify that here.

vec = graph.edge_vectors()                     # pos[centre] - pos[neighbour]
ir_sh = o3.Irreps.spherical_harmonics(2)       # 1x0e+1x1o+1x2e  (l_max=2)

# the featurizer computes Y on whatever vector it is given (same e3nn call) ...
xsh = edge["edge_sh"]
ref = o3.spherical_harmonics(ir_sh, vec, normalize=True, normalization="component")
report("spherical harmonics  Y_l^m (same e3nn call)", (xsh - ref).abs().max().item())

# ... and the NequIP model core flips the edge vector before embedding,
# reproducing the upstream orientation Y(r_j - r_i):
_, sh_flipped, _ = xnn_nequip.edge_feat.embed(-vec)
n_ref = o3.spherical_harmonics(ir_sh, -vec, normalize=True, normalization="component")
report("Y on the NequIP orientation  Y(r_j - r_i)",
       (sh_flipped - n_ref).abs().max().item())
d_flip = (sh_flipped - ref).abs().max()
print(f"   odd-l components flip sign: max|Y(-r) - Y(r)| = {float(d_flip):.3f}")
OK spherical harmonics  Y_l^m (same e3nn call)    max|xnn - nequip| = 0.00e+00
OK Y on the NequIP orientation  Y(r_j - r_i)      max|xnn - nequip| = 0.00e+00
   odd-l components flip sign: max|Y(-r) - Y(r)| = 3.366

Block 4: The interaction block (equivariant convolution)#

The heart of NequIP. Messages are the tensor product of the neighbour features with the edge spherical harmonics, weighted per edge by the radial MLP, then aggregated and mixed, with an element-dependent self-connection:

\[ h_i' \;=\; W_3\Big(\tfrac{1}{\sqrt{\lambda}}\sum_{j\in\mathcal N(i)} \big(W_2\,h_j\big)\otimes_{\mathrm{MLP}(B(r_{ij}))}\vec Y^{\,ij}\Big) \;+\; \mathrm{TP}_{z_i}\!\big(h_i\big), \]

where \(\lambda\) = avg_num_neighbors (note the square root; MACE divides by the full count) and \(\mathrm{TP}_{z_i}\) is a FullyConnectedTensorProduct with the one-hot species. xnn.gnn.models.nequip.InteractionBlock mirrors nequip.nn.InteractionBlock including the upstream parameter names (linear_1/fc/tp/linear_2/sc), so the weight transplant is a plain load_state_dict. The only cosmetic difference is the edge-index convention: upstream gathers from row 1 and scatters to row 0; xnn does the opposite, so we hand the original the flipped index.

from nequip.nn import InteractionBlock as NIB
from xnn.gnn.models.nequip import InteractionBlock as XIB

na = o3.Irreps("3x0e")                        # node attrs (one-hot, 3 elements)
nf = o3.Irreps("8x0e+8x1o+8x2e")              # node feats
ea = o3.Irreps.spherical_harmonics(2)         # edge attrs (SH)
to = o3.Irreps("8x0e+8x1o+8x2e")              # output irreps
AVG = 8.0

x_int = XIB(nf, to, na, ea, n_radial=NRBF, invariant_layers=2,
            invariant_neurons=64, avg_num_neighbors=AVG, use_sc=True)
n_int = NIB(
    irreps_in={AtomicDataDict.NODE_FEATURES_KEY: nf, AtomicDataDict.NODE_ATTRS_KEY: na,
               AtomicDataDict.EDGE_ATTRS_KEY: ea,
               AtomicDataDict.EDGE_EMBEDDING_KEY: o3.Irreps(f"{NRBF}x0e")},
    irreps_out=to, invariant_layers=2, invariant_neurons=64,
    avg_num_neighbors=AVG, use_sc=True)
x_int.load_state_dict(n_int.state_dict())     # transplant ALL conv weights

N, E = graph.num_nodes, graph.num_edges
feat = torch.randn(N, nf.dim)
esh = torch.randn(E, ea.dim); erad = torch.randn(E, NRBF)
ei = graph.edge_index

h_x = x_int(feat, node_attrs, ei, esh, erad)
out = n_int({AtomicDataDict.NODE_FEATURES_KEY: feat, AtomicDataDict.NODE_ATTRS_KEY: node_attrs,
             AtomicDataDict.EDGE_ATTRS_KEY: esh, AtomicDataDict.EDGE_EMBEDDING_KEY: erad,
             AtomicDataDict.EDGE_INDEX_KEY: ei.flip(0)})   # flipped convention
report("interaction block  h'", (h_x - out[AtomicDataDict.NODE_FEATURES_KEY]).abs().max().item())
OK interaction block  h'                          max|xnn - nequip| = 0.00e+00

Block 5: Gated equivariant nonlinearity#

NequIP applies the gate nonlinearity (Weiler et al. 2018) after every convolution: scalars pass through SiLU (even) / tanh (odd); each \(\ell>0\) irrep is multiplied by an extra SiLU-activated scalar gate, preserving equivariance.

The original uses e3nn.nn.Gate verbatim. e3nn’s Gate does not compile under torch.jit.script on torch 2.x, so xnn ships _Gate, an exact, scriptable re-implementation (same sorted input layout, same second-moment-normalised activations). It has no weights, and we check it is bit-identical to the e3nn original (this is what makes the whole xnn NequIP LAMMPS-deployable, like the xnn MACE).

import torch.nn.functional as Fn
from e3nn.nn import Gate
from xnn.gnn.models.nequip import _Gate

scalars, gates, gated = o3.Irreps("8x0e+8x0o"), o3.Irreps("16x0e"), o3.Irreps("8x1o+8x2e")
g_ref = Gate(scalars, [Fn.silu, torch.tanh], gates, [Fn.silu], gated)
g_x   = _Gate(scalars, [Fn.silu, torch.tanh], gates, [Fn.silu], gated)
print("gate irreps_in :", g_ref.irreps_in, " (sorted _Sortcut layout)")
print("gate irreps_out:", g_ref.irreps_out)

t = torch.randn(11, g_ref.irreps_in.dim)
report("gated nonlinearity (eager)", (g_x(t) - g_ref(t)).abs().max().item(), tol=0.0)
g_script = torch.jit.script(g_x)
report("gated nonlinearity (torch.jit.script)", (g_script(t) - g_ref(t)).abs().max().item(), tol=0.0)
gate irreps_in : 8x0o+24x0e+8x1o+8x2e  (sorted _Sortcut layout)
gate irreps_out: 8x0e+8x0o+8x1o+8x2e
OK gated nonlinearity (eager)                     max|xnn - nequip| = 0.00e+00
OK gated nonlinearity (torch.jit.script)          max|xnn - nequip| = 0.00e+00

Block 6: A full ConvNet layer (convolution + gate), and the irreps bookkeeping#

One NequIP layer is InteractionBlock → Gate (+ optional resnet). A subtle but important upstream detail: the desired hidden irreps (num_features × every \((\ell, p)\) up to \(\ell_{\max}\), both parities) are pruned per layer to irreps actually reachable by a tensor-product path from the current features and the edge attributes (tp_path_exists). That is why the first layer has no odd scalars (the features are still all 0e) and the feature irreps grow layer by layer. We transplant a full upstream ConvNetLayer and compare.

from nequip.nn import ConvNetLayer as NCL
from xnn.gnn.models.nequip import ConvNetLayer as XCL, nequip_hidden_irreps

hidden = nequip_hidden_irreps(8, 2, parity=True)
print("desired hidden irreps :", hidden)
for i, l in enumerate(xnn_nequip.layers):
    print(f"  layer {i}: {l.conv.irreps_in}  ->  {l.irreps_out}")

x_layer = XCL(nf, hidden, na, ea, n_radial=NRBF, resnet=False,
              invariant_layers=2, invariant_neurons=64,
              avg_num_neighbors=AVG, use_sc=True)
n_layer = NCL(
    irreps_in={AtomicDataDict.NODE_FEATURES_KEY: nf, AtomicDataDict.NODE_ATTRS_KEY: na,
               AtomicDataDict.EDGE_ATTRS_KEY: ea,
               AtomicDataDict.EDGE_EMBEDDING_KEY: o3.Irreps(f"{NRBF}x0e")},
    feature_irreps_hidden=hidden, resnet=False,
    convolution_kwargs=dict(invariant_layers=2, invariant_neurons=64,
                            avg_num_neighbors=AVG, use_sc=True))
x_layer.conv.load_state_dict(n_layer.conv.state_dict())   # gate has no weights
assert x_layer.irreps_out == n_layer.equivariant_nonlin.irreps_out

h_x = x_layer(feat, node_attrs, ei, esh, erad)
out = n_layer({AtomicDataDict.NODE_FEATURES_KEY: feat, AtomicDataDict.NODE_ATTRS_KEY: node_attrs,
               AtomicDataDict.EDGE_ATTRS_KEY: esh, AtomicDataDict.EDGE_EMBEDDING_KEY: erad,
               AtomicDataDict.EDGE_INDEX_KEY: ei.flip(0)})
report("full ConvNet layer  h^(t+1)", (h_x - out[AtomicDataDict.NODE_FEATURES_KEY]).abs().max().item())
desired hidden irreps : 8x0e+8x1e+8x2e+8x0o+8x1o+8x2o
  layer 0: 8x0e  ->  8x0e+8x2e+8x1o
  layer 1: 8x0e+8x2e+8x1o  ->  8x0e+8x1e+8x2e+8x1o+8x2o
OK full ConvNet layer  h^(t+1)                    max|xnn - nequip| = 0.00e+00

Block 7: Output block (two linear atom-wise readouts)#

Unlike MACE (per-layer readouts, gated MLP at the end), NequIP reads out once, after the last layer, with two plain equivariant linears that keep only scalars:

\[\varepsilon_i = W_5\,\big(W_4\,h^{(T)}_i\big),\qquad W_4: \text{features}\to \tfrac{k}{2}\times 0e,\quad W_5: \tfrac{k}{2}\times 0e\to 1\times 0e.\]

(conv_to_output_hidden and output_hidden_to_scalar upstream; same names in xnn.)

irreps_final = xnn_nequip.layers[-1].irreps_out
m_hid = AtomwiseLinear(irreps_in={AtomicDataDict.NODE_FEATURES_KEY: irreps_final},
                       irreps_out="4x0e", field=AtomicDataDict.NODE_FEATURES_KEY)
m_out = AtomwiseLinear(irreps_in={AtomicDataDict.NODE_FEATURES_KEY: o3.Irreps("4x0e")},
                       irreps_out="1x0e", field=AtomicDataDict.NODE_FEATURES_KEY)
xnn_nequip.conv_to_output_hidden.load_state_dict(m_hid.linear.state_dict())
xnn_nequip.output_hidden_to_scalar.load_state_dict(m_out.linear.state_dict())

hT = torch.randn(N, o3.Irreps(irreps_final).dim)
eps_x = xnn_nequip.output_hidden_to_scalar(xnn_nequip.conv_to_output_hidden(hT))
eps_n = m_out({AtomicDataDict.NODE_FEATURES_KEY: m_hid(
    {AtomicDataDict.NODE_FEATURES_KEY: hT})[AtomicDataDict.NODE_FEATURES_KEY]})[
    AtomicDataDict.NODE_FEATURES_KEY]
report("output block  eps_i", (eps_x - eps_n).abs().max().item())
OK output block  eps_i                            max|xnn - nequip| = 0.00e+00

Block 8: Per-species scale/shift, site-energy sum, and conservative forces#

The raw readout is put in physical units by the per-species scale and shift (upstream PerSpeciesScaleShift, with any global rescale folded in):

\[E_i = \sigma_{z_i}\,\varepsilon_i + \mu_{z_i},\qquad E=\sum_i E_i,\qquad \vec F = -\nabla E .\]

In xnn the shift \(\mu_Z\) is the shared atom_ref embedding (exactly as in the xnn MACE) and the scale \(\sigma_Z\) is the atom_scale buffer; forces (and the stress) come uniformly from ForceStressOutput via autograd. With zero layers and \(\sigma_Z = 0\) the energy is exactly the sum of the shifts and the forces vanish: the isolated-atom limit.

from xnn.common.models import ForceStressOutput

cfg0 = from_dict({"model": {"name": "nequip", "cutoff": CUTOFF, "n_features": 8,
    "n_interactions": 0, "extra": {"species": SPECIES, "l_max": 2,
    "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("0-layer energy == sum of shifts", abs(float(o0["energy"]) - E0_expected))
report("0-layer forces == 0", o0["forces"].abs().max().item())
OK 0-layer energy == sum of shifts                max|xnn - nequip| = 0.00e+00
OK 0-layer forces == 0                            max|xnn - nequip| = 0.00e+00

Capstone: transplant a whole NequIP model and compare energy & forces#

Blocks 1–8 are the complete set of pieces. As the final check we build a full xnn NequIP and the original EnergyModel (via nequip’s own model_from_config builders) with identical hyper-parameters (\(T=3\) layers, \(\ell_{\max}=2\), parity on, 8 features, \(\lambda=8\)), transplant every weight (Bessel frequencies, embedding, all three conv layers, both readout linears, per-species scale/shift), and run both on the toy molecule, the original through its own data pipeline (nequip.data.AtomicData).

Total energy and per-atom forces are physical quantities, independent of edge ordering and orientation conventions; they must agree exactly if the two models are the same function.

from nequip.model import model_from_config
from nequip.data import AtomicData
from nequip.data.transforms import TypeMapper

NL, NF, LMAX, E0 = 3, 8, 2, np.array([0.5, -1.3, -2.1])
SIG = np.array([1.7, 0.9, 1.1])                      # per-species scales

n_model = model_from_config(dict(
    model_builders=["SimpleIrrepsConfig", "EnergyModel", "PerSpeciesRescale", "ForceOutput"],
    r_max=CUTOFF, num_layers=NL, l_max=LMAX, parity=True, num_features=NF,
    num_basis=NRBF, PolynomialCutoff_p=6, invariant_layers=2, invariant_neurons=64,
    avg_num_neighbors=AVG, use_sc=True, resnet=False,
    chemical_symbols=["H", "C", "O"],
    per_species_rescale_shifts=E0.tolist(), per_species_rescale_scales=SIG.tolist(),
), initialize=True)
seq = n_model.model.func                             # the EnergyModel sequential

cfg = from_dict({"model": {"name": "nequip", "cutoff": CUTOFF, "n_features": NF,
    "n_interactions": NL, "n_rbf": NRBF, "extra": {"species": SPECIES, "l_max": LMAX,
    "avg_num_neighbors": AVG, "atomic_energies": E0.tolist(),
    "atomic_scales": SIG.tolist()}}})
xfull = build_model(cfg.model)
xmodel = ForceStressOutput(xfull)

# ---- transplant every weight: nequip -> xnn ----
def transplant_full(x, seq, n_layers):
    with torch.no_grad():
        x.edge_feat.rbf.freqs.copy_(seq.radial_basis.basis.bessel_weights)
        x.chemical_embedding.load_state_dict(seq.chemical_embedding.linear.state_dict())
        for i in range(n_layers):
            x.layers[i].conv.load_state_dict(
                getattr(seq, f"layer{i}_convnet").conv.state_dict())
        x.conv_to_output_hidden.load_state_dict(
            seq.conv_to_output_hidden.linear.state_dict())
        x.output_hidden_to_scalar.load_state_dict(
            seq.output_hidden_to_scalar.linear.state_dict())

transplant_full(xfull, seq, NL)

# ---- xnn prediction ----
ox = xmodel(structure_to_graph({"pos": pos, "atomic_numbers": Z}, CUTOFF))
E_x = float(ox["energy"]); F_x = ox["forces"].detach().numpy()

# ---- original nequip prediction (its own data pipeline) ----
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))))
on = n_model(dd)
E_n = float(on["total_energy"].sum()); F_n = on["forces"].detach().numpy()

print(f"total energy   xnn   = {E_x:.10f} eV")
print(f"total energy   nequip = {E_n:.10f} eV")
report("FULL MODEL total energy", abs(E_x - E_n))
report("FULL MODEL per-atom forces", np.abs(F_x - F_n).max())
total energy   xnn   = -5.0635756728 eV
total energy   nequip = -5.0635756728 eV
OK FULL MODEL total energy                        max|xnn - nequip| = 8.88e-16
OK FULL MODEL per-atom forces                     max|xnn - nequip| = 3.12e-17

Summary#

Every block needed to reproduce the original NequIP was checked against the nequip package on the toy system:

Block

Weights?

agreement

1. Chemical (one-hot) embedding

transplanted

machine precision

2. Trainable Bessel basis × polynomial cutoff

transplanted (\(b_n\))

machine precision

3. Spherical harmonics (incl. the \(r_j - r_i\) orientation)

none (same e3nn call)

machine precision

4. Interaction block (conv + \(1/\sqrt{\lambda}\) + self-connection)

transplanted (load_state_dict)

bit-identical

5. Gated nonlinearity (_Gate vs e3nn.nn.Gate)

none

bit-identical (0)

6. Full ConvNet layer + tp_path_exists irreps pruning

transplanted

bit-identical

7. Output block (2 linear readouts)

transplanted

machine precision

8. Per-species scale/shift + autograd forces

n/a

exact (0 layers)

Full model

all transplanted

energy & forces ~1e-16

xnn.gnn.models.nequip is therefore a faithful reproduction of the original NequIP architecture, depending only on e3nn (no nequip / torch_runstats), sharing the xnn equivariant-GNN abstractions with MACE (same base class, edge featurizer, atom_ref, ForceStressOutput, LAMMPS export), and, unlike the original, TorchScript-deployable end to end. The companion notebook nequip_argon_train_test.ipynb trains and tests this NequIP on a realistic Argon MD dataset.