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
state the defining equation(s) from the papers,
show the corresponding
xnnbuilding block,run it on a small toy system, and
compare it numerically against the original
nequippackage (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.4software stack we compare against.
Ground truth. We import the original
nequippackage and compare thexnnblocks 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 fromnequipintoxnnand 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):
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:
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\):
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), andthe 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:
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:
(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):
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 ( |
bit-identical |
5. Gated nonlinearity ( |
none |
bit-identical (0) |
6. Full ConvNet layer + |
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.