11import json
22import math
3- from typing import Callable , Optional , Sequence
3+ import os
4+ from typing import Any , Callable , Optional , Sequence
45
56import e3nn_jax as e3nn
67import equinox as eqx
1011
1112from nequix .layer_norm import RMSLayerNorm
1213
14+ try :
15+ import torch # noqa: F401
16+ except ImportError :
17+ # allow openequivariance to be imported without torch, but only if it is not
18+ # installed; otherwise, the torch backend won't work if users want to use
19+ # both torch and jax.
20+ os .environ ["OEQ_NOTORCH" ] = "1"
21+
22+ try :
23+ import openequivariance as oeq
24+ import openequivariance_extjax # noqa: F401
25+
26+ OEQ_AVAILABLE = True
27+ except ImportError :
28+ OEQ_AVAILABLE = False
29+
1330
1431def bessel_basis (x : jax .Array , num_basis : int , r_max : float ) -> jax .Array :
1532 prefactor = 2.0 / r_max
@@ -32,6 +49,23 @@ def polynomial_cutoff(x: jax.Array, r_max: float, p: float) -> jax.Array:
3249 return out * jnp .where (x < 1.0 , 1.0 , 0.0 )
3350
3451
52+ class Sort (eqx .Module ):
53+ irreps : e3nn .Irreps = eqx .field (static = True )
54+ irreps_sorted : e3nn .Irreps = eqx .field (static = True )
55+ slices_sorted : list = eqx .field (static = True )
56+
57+ def __init__ (self , irreps : e3nn .Irreps ):
58+ self .irreps = irreps
59+ slices = list (irreps .slices ())
60+ irreps_sorted , _ , inv = irreps .sort ()
61+ self .slices_sorted = [slices [i ] for i in inv ]
62+ self .irreps_sorted = irreps_sorted
63+
64+ def __call__ (self , x : jax .Array ) -> jax .Array :
65+ chunks = [x [..., s ] for s in self .slices_sorted ]
66+ return jnp .concatenate (chunks , axis = - 1 )
67+
68+
3569class Linear (eqx .Module ):
3670 weights : jax .Array
3771 bias : Optional [jax .Array ]
@@ -96,14 +130,18 @@ def __call__(self, x: jax.Array) -> jax.Array:
96130
97131class NequixConvolution (eqx .Module ):
98132 output_irreps : e3nn .Irreps = eqx .field (static = True )
133+ tp_irreps : e3nn .Irreps = eqx .field (static = True )
99134 index_weights : bool = eqx .field (static = True )
100135 avg_n_neighbors : float = eqx .field (static = True )
136+ kernel : bool = eqx .field (static = True )
137+ tp_conv : Optional [Any ] = eqx .field (static = True )
101138
102139 radial_mlp : MLP
103140 linear_1 : e3nn .equinox .Linear
104141 linear_2 : e3nn .equinox .Linear
105142 skip : e3nn .equinox .Linear
106143 layer_norm : Optional [RMSLayerNorm ]
144+ sort : Sort
107145
108146 def __init__ (
109147 self ,
@@ -119,12 +157,50 @@ def __init__(
119157 avg_n_neighbors : float ,
120158 index_weights : bool = True ,
121159 layer_norm : bool = False ,
160+ kernel : bool = False ,
122161 ):
123162 self .output_irreps = output_irreps
124163 self .avg_n_neighbors = avg_n_neighbors
125164 self .index_weights = index_weights
165+ self .kernel = kernel
166+
167+ irreps_out_tp = []
168+ instructions = []
169+ for i , (mul , ir_in1 ) in enumerate (input_irreps ):
170+ for j , (_ , ir_in2 ) in enumerate (sh_irreps ):
171+ for ir_out in ir_in1 * ir_in2 :
172+ if ir_out in output_irreps :
173+ k = len (irreps_out_tp )
174+ irreps_out_tp .append ((mul , ir_out ))
175+ instructions .append ((i , j , k , "uvu" , True ))
176+
177+ tp_irreps = e3nn .Irreps (irreps_out_tp )
178+ _ , _ , inv = tp_irreps .sort ()
179+ self .tp_irreps = tp_irreps
180+
181+ if kernel :
182+ instructions = [instructions [i ] for i in inv ]
183+ if not OEQ_AVAILABLE :
184+ raise ImportError (
185+ "OpenEquivariance with JAX support is required for kernel=True. "
186+ "Install both packages:\n "
187+ " uv pip install 'openequivariance[jax]'\n "
188+ " uv pip install 'openequivariance_extjax' --no-build-isolation"
189+ )
190+ problem = oeq .TPProblem (
191+ str (input_irreps ),
192+ str (sh_irreps ),
193+ str (tp_irreps ),
194+ instructions = instructions ,
195+ shared_weights = False ,
196+ internal_weights = False ,
197+ )
198+ self .tp_conv = oeq .jax .TensorProductConv (problem , deterministic = False )
199+ else :
200+ self .tp_conv = None
126201
127- tp_irreps = e3nn .tensor_product (input_irreps , sh_irreps , filter_ir_out = output_irreps )
202+ self .sort = Sort (tp_irreps )
203+ tp_irreps = self .sort .irreps_sorted
128204
129205 k1 , k2 , k3 , k4 = jax .random .split (key , 4 )
130206
@@ -182,14 +258,27 @@ def __call__(
182258 senders : jax .Array ,
183259 receivers : jax .Array ,
184260 ) -> e3nn .IrrepsArray :
185- messages = self .linear_1 (features )[senders ]
186- messages = e3nn .tensor_product (messages , sh , filter_ir_out = self .output_irreps )
261+ messages = self .linear_1 (features )
187262 radial_message = jax .vmap (self .radial_mlp )(radial_basis )
188- messages = messages * radial_message
189263
190- messages_agg = e3nn .scatter_sum (
191- messages , dst = receivers , output_size = features .shape [0 ]
192- ) / jnp .sqrt (jax .lax .stop_gradient (self .avg_n_neighbors ))
264+ if self .kernel :
265+ messages_agg = self .sort (
266+ self .tp_conv .forward (
267+ messages .array ,
268+ sh .array ,
269+ radial_message ,
270+ receivers .astype (jnp .int32 ),
271+ senders .astype (jnp .int32 ),
272+ )
273+ )
274+ messages_agg = e3nn .IrrepsArray (self .sort .irreps_sorted , messages_agg )
275+ else :
276+ messages = messages [senders ]
277+ messages = e3nn .tensor_product (messages , sh , filter_ir_out = self .tp_irreps )
278+ messages = messages * radial_message
279+ messages_agg = e3nn .scatter_sum (messages , dst = receivers , output_size = features .shape [0 ])
280+
281+ messages_agg = messages_agg / jnp .sqrt (jax .lax .stop_gradient (self .avg_n_neighbors ))
193282
194283 skip = self .skip (species , features ) if self .index_weights else self .skip (features )
195284 features = self .linear_2 (messages_agg ) + skip
@@ -237,6 +326,7 @@ def __init__(
237326 avg_n_neighbors : float = 1.0 ,
238327 atom_energies : Optional [Sequence [float ]] = None ,
239328 layer_norm : bool = False ,
329+ kernel : bool = False ,
240330 ):
241331 self .lmax = lmax
242332 self .cutoff = cutoff
@@ -271,6 +361,7 @@ def __init__(
271361 avg_n_neighbors = avg_n_neighbors ,
272362 index_weights = index_weights ,
273363 layer_norm = layer_norm ,
364+ kernel = kernel ,
274365 )
275366 )
276367
@@ -446,7 +537,7 @@ def save_model(path: str, model: eqx.Module, config: dict):
446537 eqx .tree_serialise_leaves (f , model )
447538
448539
449- def load_model (path : str ) -> tuple [Nequix , dict ]:
540+ def load_model (path : str , kernel : bool = False ) -> tuple [Nequix , dict ]:
450541 """Load a model and its config from a file."""
451542 with open (path , "rb" ) as f :
452543 config = json .loads (f .readline ().decode ())
@@ -467,6 +558,7 @@ def load_model(path: str) -> tuple[Nequix, dict]:
467558 shift = config ["shift" ],
468559 scale = config ["scale" ],
469560 avg_n_neighbors = config ["avg_n_neighbors" ],
561+ kernel = kernel ,
470562 # NOTE: atom_energies will be in model weights
471563 )
472564 model = eqx .tree_deserialise_leaves (f , model )
0 commit comments