ekwan16 commited on
Commit
53b4926
·
verified ·
1 Parent(s): e16c7ac

sync code from github@fbebb603b47d270f0d13b4146970b99ea374ddd7

Browse files
README.md CHANGED
@@ -130,7 +130,7 @@ The predictions are close to experiment:
130
 
131
  **Notes:**
132
 
133
- - **Passes and symmetry.** `n_passes` (default 10) averages out equivariance error over the specified number of forward passes. If `symmetrize` (default True) is set, an additional `n_passes` are also performed on the mirror image of the input geometry (for a total of `2*n_passes`).
134
  - **Geometry.** MagNET-Zero and MagNET-PCM require [AIMNet2](https://github.com/isayevlab/AIMNet2)-optimized geometries. Do not use other geometries.
135
  - **Components.** Pass `return_components=True` to also get the MagNET-Zero shielding, the MagNET-PCM correction, and the scaling coefficients behind each shift, as a `dict`.
136
  - **Other models.** For raw shieldings and solvent corrections, see the [API documentation](#api-documentation).
 
130
 
131
  **Notes:**
132
 
133
+ - **Passes and mirror averaging.** `n_passes` (default 10) averages out equivariance error over the specified number of forward passes. If `mirror_average` (default True) is set, an additional `n_passes` are also performed on the mirror image of the input geometry (for a total of `2*n_passes`). The passes are run as a single batch per head rather than one forward pass each, so raising `n_passes` costs far less than it used to; `max_batch_graphs` caps how many run at once and does not change the answer. `mirror_average` was called `symmetrize` before, and that name still works but warns: the option averages over the mirror image, not over symmetry-equivalent nuclei.
134
  - **Geometry.** MagNET-Zero and MagNET-PCM require [AIMNet2](https://github.com/isayevlab/AIMNet2)-optimized geometries. Do not use other geometries.
135
  - **Components.** Pass `return_components=True` to also get the MagNET-Zero shielding, the MagNET-PCM correction, and the scaling coefficients behind each shift, as a `dict`.
136
  - **Other models.** For raw shieldings and solvent corrections, see the [API documentation](#api-documentation).
magnet/api.py CHANGED
@@ -13,7 +13,8 @@ Four entry points, each taking one molecule (`atomic_numbers` shaped `(N,)` and
13
  **Shared options** (all four functions):
14
 
15
  - `n_passes` (default `10`): average out equivariance error over `n_passes` forward passes
16
- - `symmetrize` (default `True`): if True, average over `n_passes` on the input geometry and `n_passes` on the mirror image of the input geometry
 
17
  - `device` (default `None`): where to run, a torch device or a string like `"cpu"` or `"cuda"`; uses GPU if available
18
  - `checkpoints_dir` (default `None`): directory holding the released weights, if they are not in the current directory or the repo; may be the `model_checkpoints/` folder or its parent, and takes precedence over both
19
  """
@@ -24,6 +25,7 @@ from collections import Counter
24
  import numpy as np
25
 
26
  from . import run_magnet
 
27
  from . import scaling
28
 
29
  _MODELS = {
@@ -67,7 +69,7 @@ def _as_batch(atomic_numbers, coordinates):
67
  return an_list, xyz_list, single
68
 
69
 
70
- def predict_shifts(atomic_numbers, coordinates, solvent="chloroform", n_passes=10, symmetrize=True,
71
  device=None, return_components=False, checkpoints_dir=None):
72
  """Predict the <sup>1</sup>H and <sup>13</sup>C chemical shifts of a molecule in a solvent.
73
 
@@ -82,7 +84,7 @@ def predict_shifts(atomic_numbers, coordinates, solvent="chloroform", n_passes=1
82
  `"chloroform"`, `"toluene"`, `"benzene"`, `"chlorobenzene"`, `"acetone"`,
83
  `"dimethylsulfoxide"`, `"acetonitrile"`, `"trifluoroethanol"`, `"methanol"`, `"water"`.
84
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
85
- symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
86
  doubling the passes.
87
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
88
  uses GPU if available.
@@ -104,6 +106,7 @@ def predict_shifts(atomic_numbers, coordinates, solvent="chloroform", n_passes=1
104
  `{"H": {"intercept": ..., "stationary": ..., "pcm": ...}, "C": {...}}`, so that
105
  `shift = intercept + stationary * zero_shielding + pcm * pcm_correction`.
106
  """
 
107
  _check_passes(n_passes)
108
  tables = scaling.published_scaling_tables()
109
  key = "TIP4P" if solvent == "water" else solvent
@@ -113,10 +116,10 @@ def predict_shifts(atomic_numbers, coordinates, solvent="chloroform", n_passes=1
113
 
114
  an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
115
  zero = run_magnet.compute_MagNET_Zero_shieldings(an_list, xyz_list, n_passes=n_passes,
116
- symmetrize=symmetrize, device=device,
117
  checkpoints_dir=checkpoints_dir)
118
  pcm = run_magnet.compute_MagNET_PCM_corrections(an_list, xyz_list, n_passes=n_passes,
119
- symmetrize=symmetrize, device=device,
120
  checkpoints_dir=checkpoints_dir)
121
  results = []
122
  for atoms, sigma, delta in zip(an_list, zero, pcm):
@@ -133,7 +136,7 @@ def predict_shifts(atomic_numbers, coordinates, solvent="chloroform", n_passes=1
133
  return results[0] if single else results
134
 
135
 
136
- def predict_shieldings(atomic_numbers, coordinates, model="MagNET", n_passes=10, symmetrize=True,
137
  device=None, checkpoints_dir=None):
138
  """Predict gas-phase NMR shielding constants for a molecule.
139
 
@@ -147,7 +150,7 @@ def predict_shieldings(atomic_numbers, coordinates, model="MagNET", n_passes=10,
147
  model: `"MagNET"`, the general foundation model, or `"MagNET-Zero"`, which is more accurate but
148
  expects an AIMNet2-optimized geometry.
149
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
150
- symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
151
  doubling the passes.
152
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
153
  uses GPU if available.
@@ -161,6 +164,7 @@ def predict_shieldings(atomic_numbers, coordinates, model="MagNET", n_passes=10,
161
  other elements come back as `0.0`, not a prediction. (`predict_shifts` instead returns `NaN`
162
  there.) Pass a list of molecules and you get a list of arrays back.
163
  """
 
164
  redirect = {"MagNET-PCM": "implicit_solvent_correction", "MagNET-x": "explicit_solvent_correction"}
165
  if model in redirect:
166
  raise ValueError(f"{model!r} is a correction model; use magnet.{redirect[model]}(...), "
@@ -169,12 +173,12 @@ def predict_shieldings(atomic_numbers, coordinates, model="MagNET", n_passes=10,
169
  raise ValueError(f"model must be one of {list(_MODELS)}; got {model!r}")
170
  _check_passes(n_passes)
171
  an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
172
- out = _MODELS[model](an_list, xyz_list, n_passes=n_passes, symmetrize=symmetrize, device=device,
173
  checkpoints_dir=checkpoints_dir)
174
  return out[0] if single else out
175
 
176
 
177
- def implicit_solvent_correction(atomic_numbers, coordinates, n_passes=10, symmetrize=True, device=None,
178
  checkpoints_dir=None):
179
  """Predict how a solvent changes a molecule's shieldings, using a fast continuum-solvent model.
180
 
@@ -188,7 +192,7 @@ def implicit_solvent_correction(atomic_numbers, coordinates, n_passes=10, symmet
188
  atomic_numbers: element numbers, shape `(N,)`; or a list of such arrays.
189
  coordinates: xyz positions in Angstrom, shape `(N, 3)`; or a list of them.
190
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
191
- symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
192
  doubling the passes.
193
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
194
  uses GPU if available.
@@ -201,10 +205,11 @@ def implicit_solvent_correction(atomic_numbers, coordinates, n_passes=10, symmet
201
  atoms; atoms of other elements come back as `0.0`. Pass a list of molecules and you get a list
202
  of arrays back.
203
  """
 
204
  _check_passes(n_passes)
205
  an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
206
  out = run_magnet.compute_MagNET_PCM_corrections(an_list, xyz_list, n_passes=n_passes,
207
- symmetrize=symmetrize, device=device,
208
  checkpoints_dir=checkpoints_dir)
209
  return out[0] if single else out
210
 
@@ -258,7 +263,7 @@ def _explicit_one(atoms, xyz, solute_atoms, solvent):
258
 
259
 
260
  def explicit_solvent_correction(atomic_numbers, coordinates, solute_atoms, solvent="chloroform",
261
- n_passes=10, symmetrize=True, device=None,
262
  solvent_distance_threshold=12.0, checkpoints_dir=None):
263
  """Predict a solvent's effect on shieldings from an MD snapshot with explicit solvent molecules.
264
 
@@ -276,7 +281,7 @@ def explicit_solvent_correction(atomic_numbers, coordinates, solute_atoms, solve
276
  solvent: `"chloroform"`, `"benzene"`, `"methanol"`, or `"water"`, the four MagNET-x supports
277
  (default `"chloroform"`).
278
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
279
- symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
280
  doubling the passes.
281
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
282
  uses GPU if available.
@@ -291,6 +296,7 @@ def explicit_solvent_correction(atomic_numbers, coordinates, solute_atoms, solve
291
  One correction in ppm per solute atom, in the order you listed them in `solute_atoms`. Pass a
292
  list of snapshots and you get a list of arrays back.
293
  """
 
294
  if solvent not in run_magnet.N_ATOMS_PER_SOLVENT:
295
  raise ValueError(f"solvent must be one of {sorted(run_magnet.N_ATOMS_PER_SOLVENT)}; "
296
  f"got {solvent!r}")
@@ -304,6 +310,6 @@ def explicit_solvent_correction(atomic_numbers, coordinates, solute_atoms, solve
304
  full_xyz_list.append(full_xyz)
305
  out = run_magnet.compute_MagNET_x_corrections(
306
  solvent, solute_an_list, full_an_list, full_xyz_list,
307
- n_passes=n_passes, symmetrize=symmetrize, device=device,
308
  solvent_distance_threshold=solvent_distance_threshold, checkpoints_dir=checkpoints_dir)
309
  return out[0] if single else out
 
13
  **Shared options** (all four functions):
14
 
15
  - `n_passes` (default `10`): average out equivariance error over `n_passes` forward passes
16
+ - `mirror_average` (default `True`): if True, average over `n_passes` on the input geometry and `n_passes` on the mirror image of the input geometry.
17
+ (Formerly `symmetrize`, which still works but warns: it averages over the mirror image, not over symmetry-equivalent nuclei.)
18
  - `device` (default `None`): where to run, a torch device or a string like `"cpu"` or `"cuda"`; uses GPU if available
19
  - `checkpoints_dir` (default `None`): directory holding the released weights, if they are not in the current directory or the repo; may be the `model_checkpoints/` folder or its parent, and takes precedence over both
20
  """
 
25
  import numpy as np
26
 
27
  from . import run_magnet
28
+ from .inference import resolve_mirror_average
29
  from . import scaling
30
 
31
  _MODELS = {
 
69
  return an_list, xyz_list, single
70
 
71
 
72
+ def predict_shifts(atomic_numbers, coordinates, solvent="chloroform", n_passes=10, mirror_average=True, symmetrize=None,
73
  device=None, return_components=False, checkpoints_dir=None):
74
  """Predict the <sup>1</sup>H and <sup>13</sup>C chemical shifts of a molecule in a solvent.
75
 
 
84
  `"chloroform"`, `"toluene"`, `"benzene"`, `"chlorobenzene"`, `"acetone"`,
85
  `"dimethylsulfoxide"`, `"acetonitrile"`, `"trifluoroethanol"`, `"methanol"`, `"water"`.
86
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
87
+ mirror_average: if True, also average over the mirror image of the input geometry (default `True`),
88
  doubling the passes.
89
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
90
  uses GPU if available.
 
106
  `{"H": {"intercept": ..., "stationary": ..., "pcm": ...}, "C": {...}}`, so that
107
  `shift = intercept + stationary * zero_shielding + pcm * pcm_correction`.
108
  """
109
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "predict_shifts")
110
  _check_passes(n_passes)
111
  tables = scaling.published_scaling_tables()
112
  key = "TIP4P" if solvent == "water" else solvent
 
116
 
117
  an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
118
  zero = run_magnet.compute_MagNET_Zero_shieldings(an_list, xyz_list, n_passes=n_passes,
119
+ mirror_average=mirror_average, device=device,
120
  checkpoints_dir=checkpoints_dir)
121
  pcm = run_magnet.compute_MagNET_PCM_corrections(an_list, xyz_list, n_passes=n_passes,
122
+ mirror_average=mirror_average, device=device,
123
  checkpoints_dir=checkpoints_dir)
124
  results = []
125
  for atoms, sigma, delta in zip(an_list, zero, pcm):
 
136
  return results[0] if single else results
137
 
138
 
139
+ def predict_shieldings(atomic_numbers, coordinates, model="MagNET", n_passes=10, mirror_average=True, symmetrize=None,
140
  device=None, checkpoints_dir=None):
141
  """Predict gas-phase NMR shielding constants for a molecule.
142
 
 
150
  model: `"MagNET"`, the general foundation model, or `"MagNET-Zero"`, which is more accurate but
151
  expects an AIMNet2-optimized geometry.
152
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
153
+ mirror_average: if True, also average over the mirror image of the input geometry (default `True`),
154
  doubling the passes.
155
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
156
  uses GPU if available.
 
164
  other elements come back as `0.0`, not a prediction. (`predict_shifts` instead returns `NaN`
165
  there.) Pass a list of molecules and you get a list of arrays back.
166
  """
167
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "predict_shieldings")
168
  redirect = {"MagNET-PCM": "implicit_solvent_correction", "MagNET-x": "explicit_solvent_correction"}
169
  if model in redirect:
170
  raise ValueError(f"{model!r} is a correction model; use magnet.{redirect[model]}(...), "
 
173
  raise ValueError(f"model must be one of {list(_MODELS)}; got {model!r}")
174
  _check_passes(n_passes)
175
  an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
176
+ out = _MODELS[model](an_list, xyz_list, n_passes=n_passes, mirror_average=mirror_average, device=device,
177
  checkpoints_dir=checkpoints_dir)
178
  return out[0] if single else out
179
 
180
 
181
+ def implicit_solvent_correction(atomic_numbers, coordinates, n_passes=10, mirror_average=True, symmetrize=None, device=None,
182
  checkpoints_dir=None):
183
  """Predict how a solvent changes a molecule's shieldings, using a fast continuum-solvent model.
184
 
 
192
  atomic_numbers: element numbers, shape `(N,)`; or a list of such arrays.
193
  coordinates: xyz positions in Angstrom, shape `(N, 3)`; or a list of them.
194
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
195
+ mirror_average: if True, also average over the mirror image of the input geometry (default `True`),
196
  doubling the passes.
197
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
198
  uses GPU if available.
 
205
  atoms; atoms of other elements come back as `0.0`. Pass a list of molecules and you get a list
206
  of arrays back.
207
  """
208
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "implicit_solvent_correction")
209
  _check_passes(n_passes)
210
  an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
211
  out = run_magnet.compute_MagNET_PCM_corrections(an_list, xyz_list, n_passes=n_passes,
212
+ mirror_average=mirror_average, device=device,
213
  checkpoints_dir=checkpoints_dir)
214
  return out[0] if single else out
215
 
 
263
 
264
 
265
  def explicit_solvent_correction(atomic_numbers, coordinates, solute_atoms, solvent="chloroform",
266
+ n_passes=10, mirror_average=True, symmetrize=None, device=None,
267
  solvent_distance_threshold=12.0, checkpoints_dir=None):
268
  """Predict a solvent's effect on shieldings from an MD snapshot with explicit solvent molecules.
269
 
 
281
  solvent: `"chloroform"`, `"benzene"`, `"methanol"`, or `"water"`, the four MagNET-x supports
282
  (default `"chloroform"`).
283
  n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
284
+ mirror_average: if True, also average over the mirror image of the input geometry (default `True`),
285
  doubling the passes.
286
  device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
287
  uses GPU if available.
 
296
  One correction in ppm per solute atom, in the order you listed them in `solute_atoms`. Pass a
297
  list of snapshots and you get a list of arrays back.
298
  """
299
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "explicit_solvent_correction")
300
  if solvent not in run_magnet.N_ATOMS_PER_SOLVENT:
301
  raise ValueError(f"solvent must be one of {sorted(run_magnet.N_ATOMS_PER_SOLVENT)}; "
302
  f"got {solvent!r}")
 
310
  full_xyz_list.append(full_xyz)
311
  out = run_magnet.compute_MagNET_x_corrections(
312
  solvent, solute_an_list, full_an_list, full_xyz_list,
313
+ n_passes=n_passes, mirror_average=mirror_average, device=device,
314
  solvent_distance_threshold=solvent_distance_threshold, checkpoints_dir=checkpoints_dir)
315
  return out[0] if single else out
magnet/inference.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import numpy as np
2
  import torch
3
  import torch_geometric
@@ -8,6 +10,36 @@ import torch_scatter
8
  # other element are not meaningful, so predict_shieldings refuses them.
9
  SUPPORTED_ELEMENTS = frozenset({1, 6, 7, 8, 9, 16, 17})
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  def yield_data(solute_atomic_numbers, geometries, atomic_numbers, shieldings = None, N_atoms_per_solvent = 3, solvent_distance_threshold = None, atom_type = 'H'):
13
  data = torch_geometric.data.Data(
@@ -106,7 +138,60 @@ def _predict_once(model_H, model_C, solute_atomic_numbers, geometry, atomic_numb
106
  return y_pred_combined
107
 
108
 
109
- def predict_shieldings(model_H, model_C, solute_atomic_numbers, geometry, atomic_numbers = None, N_atoms_per_solvent = None, solvent_distance_threshold = None, device = 'cpu', n_passes = 1, symmetrize = False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  """Predict 1H/13C shieldings for the solute atoms.
111
 
112
  A single forward pass is NOT deterministic: each edge picks a random local
@@ -114,11 +199,22 @@ def predict_shieldings(model_H, model_C, solute_atomic_numbers, geometry, atomic
114
  the output varies by ~0.01 ppm (13C) between passes. Set n_passes (e.g. 20, as in
115
  the MagNET paper) to average that frame noise away.
116
 
117
- symmetrize=True also averages the prediction over the molecule and its mirror image.
118
  Isotropic shielding is parity-even (a molecule and its reflection have identical
119
  shieldings), but the SO(3)-only model does not enforce this and can disagree by
120
- ~0.3 ppm on 13C for large molecules; symmetrizing removes that spurious error.
 
 
 
 
 
 
 
 
 
121
  """
 
 
122
  solute_atomic_numbers = np.asarray(solute_atomic_numbers)
123
  if atomic_numbers is None:
124
  atomic_numbers = solute_atomic_numbers
@@ -139,16 +235,18 @@ def predict_shieldings(model_H, model_C, solute_atomic_numbers, geometry, atomic
139
 
140
  geometry = np.asarray(geometry, dtype=float)
141
  geometries = [geometry]
142
- if symmetrize:
143
  reflected = geometry.copy()
144
  reflected[..., 0] = -reflected[..., 0] # mirror across the yz-plane (an improper rotation)
145
  geometries.append(reflected)
146
 
 
 
 
147
  preds = []
148
- for geom in geometries:
149
- for _ in range(n_passes):
150
- preds.append(np.squeeze(_predict_once(model_H, model_C, solute_atomic_numbers, geom,
151
- atomic_numbers, N_atoms_per_solvent,
152
- solvent_distance_threshold, device)))
153
  # atleast_1d keeps a one-atom solute a (1,) array instead of a 0-d scalar after the per-pass squeeze
154
  return np.atleast_1d(np.mean(preds, axis=0))
 
1
+ import warnings
2
+
3
  import numpy as np
4
  import torch
5
  import torch_geometric
 
10
  # other element are not meaningful, so predict_shieldings refuses them.
11
  SUPPORTED_ELEMENTS = frozenset({1, 6, 7, 8, 9, 16, 17})
12
 
13
+ # How many graphs go through one forward pass at most. The passes of one prediction are batched
14
+ # together, and a large solvated system at a high n_passes would otherwise have to fit in memory
15
+ # all at once. Chunking changes nothing about the answer.
16
+ MAX_BATCH_GRAPHS = 64
17
+
18
+
19
+ def resolve_mirror_average(mirror_average, symmetrize, caller):
20
+ """Take whichever of the two names the caller used, and warn if it was the old one.
21
+
22
+ `symmetrize` was renamed to `mirror_average`: the option averages a prediction over the
23
+ molecule and its mirror image, where the old name read as though it averaged
24
+ symmetry-equivalent nuclei. The old name still works.
25
+
26
+ Args:
27
+ mirror_average: what the caller passed under the new name.
28
+ symmetrize: what the caller passed under the old name, or None if it did not.
29
+ caller: the function name to name in the warning.
30
+
31
+ Returns:
32
+ The setting to use.
33
+ """
34
+ if symmetrize is None:
35
+ return mirror_average
36
+ warnings.warn(
37
+ f"{caller}(symmetrize=...) is deprecated; use mirror_average=... instead. The option "
38
+ f"averages the prediction over the molecule and its mirror image, which 'symmetrize' "
39
+ f"read as though it meant averaging symmetry-equivalent nuclei.",
40
+ DeprecationWarning, stacklevel = 3)
41
+ return symmetrize
42
+
43
 
44
  def yield_data(solute_atomic_numbers, geometries, atomic_numbers, shieldings = None, N_atoms_per_solvent = 3, solvent_distance_threshold = None, atom_type = 'H'):
45
  data = torch_geometric.data.Data(
 
138
  return y_pred_combined
139
 
140
 
141
+ def _predict_batch(model_H, model_C, solute_atomic_numbers, geometries, atomic_numbers,
142
+ N_atoms_per_solvent, solvent_distance_threshold, device):
143
+ """Run every geometry in `geometries` through both heads, one forward pass per head.
144
+
145
+ Each geometry becomes one graph, and the graphs are concatenated into a single
146
+ torch_geometric Batch, so a list of geometries costs two forward passes rather than two per
147
+ geometry. The model recomputes `natoms` from `batch.batch`, so the per-graph `batch` and
148
+ `natoms` that yield_data writes are dropped before concatenating and PyG assigns its own.
149
+
150
+ Returns one (n_solute,) array per geometry, in the order given.
151
+ """
152
+ n = len(geometries)
153
+ combined = [None] * n
154
+ for atom_type in ['H', 'C']:
155
+ data_list = []
156
+ for geom in geometries:
157
+ data = yield_data(
158
+ solute_atomic_numbers = solute_atomic_numbers,
159
+ geometries = geom,
160
+ atomic_numbers = atomic_numbers,
161
+ shieldings = None,
162
+ N_atoms_per_solvent = N_atoms_per_solvent if N_atoms_per_solvent is not None else 3,
163
+ solvent_distance_threshold = solvent_distance_threshold,
164
+ atom_type = atom_type,
165
+ )
166
+ # PyG owns `batch` on a Batch, and the model rebuilds `natoms` from it; keeping the
167
+ # single-graph versions here would have them concatenated as ordinary attributes.
168
+ del data.batch, data.natoms
169
+ data_list.append(data)
170
+ batch = torch_geometric.data.Batch.from_data_list(data_list).to(device)
171
+
172
+ with torch.no_grad():
173
+ y_pred = (model_H if atom_type == 'H' else model_C).forward(batch).cpu()
174
+ # the masks live on `device`; move them to CPU to match y_pred (a no-op on CPU, and
175
+ # required on CUDA, where indexing a CPU tensor with a device mask would raise)
176
+ atom_type_mask = batch.atom_type_mask.cpu()
177
+ solute = batch.solute.cpu()
178
+ graph_of_atom = batch.batch.cpu()
179
+ y_pred[~atom_type_mask] = 0.
180
+ keep = solute == 1
181
+ y_solute = y_pred[keep]
182
+ graph_of_solute = graph_of_atom[keep]
183
+
184
+ for i in range(n):
185
+ y = np.squeeze(y_solute[graph_of_solute == i].numpy())
186
+ # combine H and C shieldings into one array, exactly as the unbatched path does
187
+ if atom_type == 'H':
188
+ combined[i] = y
189
+ elif atom_type == 'C':
190
+ combined[i][y != 0.0] = y[y != 0.0]
191
+ return combined
192
+
193
+
194
+ def predict_shieldings(model_H, model_C, solute_atomic_numbers, geometry, atomic_numbers = None, N_atoms_per_solvent = None, solvent_distance_threshold = None, device = 'cpu', n_passes = 1, mirror_average = False, symmetrize = None, max_batch_graphs = MAX_BATCH_GRAPHS):
195
  """Predict 1H/13C shieldings for the solute atoms.
196
 
197
  A single forward pass is NOT deterministic: each edge picks a random local
 
199
  the output varies by ~0.01 ppm (13C) between passes. Set n_passes (e.g. 20, as in
200
  the MagNET paper) to average that frame noise away.
201
 
202
+ mirror_average=True also averages the prediction over the molecule and its mirror image.
203
  Isotropic shielding is parity-even (a molecule and its reflection have identical
204
  shieldings), but the SO(3)-only model does not enforce this and can disagree by
205
+ ~0.3 ppm on 13C for large molecules; averaging over the mirror image removes that
206
+ spurious error.
207
+
208
+ The passes are independent of one another, so they are run as one batch per head rather than
209
+ one forward pass each: n_passes with mirror_average costs 2*n_passes graphs and two forward
210
+ passes, not 4*n_passes. `max_batch_graphs` caps how many graphs go through at once, so a large
211
+ solvated system at a high n_passes does not have to fit in memory all at once. The answer does
212
+ not depend on it.
213
+
214
+ `symmetrize` is the old name for `mirror_average` and still works, with a DeprecationWarning.
215
  """
216
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "predict_shieldings")
217
+
218
  solute_atomic_numbers = np.asarray(solute_atomic_numbers)
219
  if atomic_numbers is None:
220
  atomic_numbers = solute_atomic_numbers
 
235
 
236
  geometry = np.asarray(geometry, dtype=float)
237
  geometries = [geometry]
238
+ if mirror_average:
239
  reflected = geometry.copy()
240
  reflected[..., 0] = -reflected[..., 0] # mirror across the yz-plane (an improper rotation)
241
  geometries.append(reflected)
242
 
243
+ # every pass of every geometry, run together rather than one at a time
244
+ repeated = [geom for geom in geometries for _ in range(n_passes)]
245
+
246
  preds = []
247
+ for start in range(0, len(repeated), max_batch_graphs):
248
+ preds.extend(_predict_batch(model_H, model_C, solute_atomic_numbers,
249
+ repeated[start:start + max_batch_graphs], atomic_numbers,
250
+ N_atoms_per_solvent, solvent_distance_threshold, device))
 
251
  # atleast_1d keeps a one-atom solute a (1,) array instead of a 0-d scalar after the per-pass squeeze
252
  return np.atleast_1d(np.mean(preds, axis=0))
magnet/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "magnet-nmr"
7
- version = "0.1.0"
8
  description = "MagNET: equivariant neural networks for NMR chemical-shift (shielding) prediction"
9
  readme = {text = "MagNET: equivariant neural networks for NMR chemical-shift (shielding) prediction. Models, datasets, install instructions, and usage examples are at https://github.com/ekwan/MagNET", content-type = "text/markdown"}
10
  requires-python = ">=3.9"
 
4
 
5
  [project]
6
  name = "magnet-nmr"
7
+ version = "0.2.0"
8
  description = "MagNET: equivariant neural networks for NMR chemical-shift (shielding) prediction"
9
  readme = {text = "MagNET: equivariant neural networks for NMR chemical-shift (shielding) prediction. Models, datasets, install instructions, and usage examples are at https://github.com/ekwan/MagNET", content-type = "text/markdown"}
10
  requires-python = ">=3.9"
magnet/run_magnet.py CHANGED
@@ -5,10 +5,10 @@ expose the two prediction options:
5
 
6
  - `n_passes`: average over this many forward passes to remove the per-pass frame noise (the MagNET
7
  paper uses 20).
8
- - `symmetrize`: also average over the molecule and its mirror image. Isotropic shielding is
9
  parity-even, but the SO(3)-only network does not enforce that, so symmetrizing removes a spurious
10
  reflection error that grows to ~0.3 ppm on 13C for large molecules. It costs one extra evaluation.
11
- The released reference shieldings are NOT symmetrized, so leave it False to reproduce them and turn
12
  it on for best accuracy on large molecules.
13
 
14
  Geometries are the AIMNet2 (machine-learning-optimized) stationary geometries, in Angstrom.
@@ -19,7 +19,7 @@ import numpy as np
19
  import torch
20
 
21
  from magnet.model import MagNET_Lightning
22
- from magnet.inference import predict_shieldings
23
 
24
  MODEL_CHECKPOINTS = {
25
  # foundation model (predicts the gas-phase shielding the rovibrational/QCD analysis builds on)
@@ -89,10 +89,11 @@ def load_model_to_device(path, device=None, checkpoints_dir=None):
89
 
90
 
91
  def _predict_with(key_H, key_C, atomic_numbers_list, geometries_list,
92
- n_passes=20, symmetrize=False, device=None, checkpoints_dir=None, **predict_kwargs):
93
  """Load one model pair and run predict_shieldings over a list of molecules. Returns a list of
94
  per-atom shielding arrays. Extra keyword arguments are forwarded to predict_shieldings (used by
95
  the explicit-solvent path for the solute/solvent split)."""
 
96
  if device is None:
97
  device = _default_device()
98
  model_H = load_model_to_device(MODEL_CHECKPOINTS[key_H], device, checkpoints_dir=checkpoints_dir)
@@ -102,37 +103,39 @@ def _predict_with(key_H, key_C, atomic_numbers_list, geometries_list,
102
  atomic_numbers, geometry = entry[0], entry[1]
103
  out.append(np.atleast_1d(predict_shieldings(
104
  model_H, model_C, solute_atomic_numbers=atomic_numbers, geometry=geometry,
105
- device=device, n_passes=n_passes, symmetrize=symmetrize, **predict_kwargs).squeeze()))
106
  return out
107
 
108
 
109
  def compute_MagNET_foundation_shieldings(atomic_numbers_list, geometries_list,
110
- n_passes=20, symmetrize=False, device=None, checkpoints_dir=None):
111
  """MagNET foundation-model gas-phase shieldings (1H and 13C) for AIMNet2 geometries."""
 
112
  return _predict_with("MagNET_H", "MagNET_C", atomic_numbers_list, geometries_list,
113
- n_passes=n_passes, symmetrize=symmetrize, device=device,
114
  checkpoints_dir=checkpoints_dir)
115
 
116
 
117
  def compute_MagNET_Zero_shieldings(atomic_numbers_list, geometries_list,
118
- n_passes=20, symmetrize=False, device=None, checkpoints_dir=None):
119
  """MagNET-Zero gas-phase shieldings (1H and 13C) for a list of AIMNet2 geometries.
120
 
121
  Args:
122
  atomic_numbers_list: list of (N,) integer atomic-number arrays.
123
  geometries_list: list of (N, 3) coordinate arrays, in Angstrom.
124
- n_passes, symmetrize: prediction options (see module docstring).
125
  checkpoints_dir: optional directory holding the released weights (see load_model_to_device).
126
 
127
  Returns a list of (N,) shielding arrays (1H at H atoms, 13C at C atoms).
128
  """
 
129
  return _predict_with("MagNET-Zero_H", "MagNET-Zero_C", atomic_numbers_list, geometries_list,
130
- n_passes=n_passes, symmetrize=symmetrize, device=device,
131
  checkpoints_dir=checkpoints_dir)
132
 
133
 
134
  def compute_MagNET_PCM_corrections(atomic_numbers_list, geometries_list,
135
- n_passes=20, symmetrize=False, device=None, checkpoints_dir=None):
136
  """MagNET-PCM implicit-solvent (chloroform) corrections (1H and 13C) for AIMNet2 geometries.
137
 
138
  The correction is the difference between the PCM-corrected shielding and the gas-phase shielding,
@@ -140,13 +143,14 @@ def compute_MagNET_PCM_corrections(atomic_numbers_list, geometries_list,
140
 
141
  Returns a list of (N,) correction arrays.
142
  """
 
143
  gas = _predict_with("MagNET-PCM-withoutPCM_H", "MagNET-PCM-withoutPCM_C",
144
  atomic_numbers_list, geometries_list,
145
- n_passes=n_passes, symmetrize=symmetrize, device=device,
146
  checkpoints_dir=checkpoints_dir)
147
  pcm = _predict_with("MagNET-PCM-withPCM_H", "MagNET-PCM-withPCM_C",
148
  atomic_numbers_list, geometries_list,
149
- n_passes=n_passes, symmetrize=symmetrize, device=device,
150
  checkpoints_dir=checkpoints_dir)
151
  return [pcm[i] - gas[i] for i in range(len(pcm))]
152
 
@@ -156,7 +160,7 @@ N_ATOMS_PER_SOLVENT = {"chloroform": 5, "benzene": 12, "methanol": 6, "water": 3
156
 
157
 
158
  def compute_MagNET_x_corrections(solvent, solute_atomic_numbers_list, atomic_numbers_list,
159
- geometries_list, n_passes=20, symmetrize=False, device=None,
160
  solvent_distance_threshold=12.0, checkpoints_dir=None):
161
  """MagNET-x explicit-solvent corrections (solvated minus isolated) for one solvent.
162
 
@@ -167,7 +171,7 @@ def compute_MagNET_x_corrections(solvent, solute_atomic_numbers_list, atomic_num
167
  The solute atoms must come first, followed by complete solvent molecules in contiguous
168
  blocks of N_ATOMS_PER_SOLVENT[solvent] atoms.
169
  geometries_list: list of (n_total, 3) coordinate arrays (same atom order), in Angstrom.
170
- n_passes, symmetrize: prediction options (see module docstring).
171
  solvent_distance_threshold: drop solvent molecules whose nearest atom is beyond this many
172
  Angstrom of the solute (the released pipeline used 12.0). The isolated pass uses 0.0
173
  internally, which strips all solvent.
@@ -176,6 +180,7 @@ def compute_MagNET_x_corrections(solvent, solute_atomic_numbers_list, atomic_num
176
  Returns a list of (n_solute,) correction arrays. The published pipeline averages this over MD
177
  frames; a single-frame value is only an estimate.
178
  """
 
179
  if solvent not in N_ATOMS_PER_SOLVENT:
180
  raise ValueError(f"solvent must be one of {sorted(N_ATOMS_PER_SOLVENT)}; got {solvent!r}")
181
  n_per = N_ATOMS_PER_SOLVENT[solvent]
@@ -187,7 +192,7 @@ def compute_MagNET_x_corrections(solvent, solute_atomic_numbers_list, atomic_num
187
  for solute_Z, full_Z, geom in zip(solute_atomic_numbers_list, atomic_numbers_list, geometries_list):
188
  kw = dict(model_H=model_H, model_C=model_C, solute_atomic_numbers=solute_Z, geometry=geom,
189
  atomic_numbers=full_Z, N_atoms_per_solvent=n_per, device=device,
190
- n_passes=n_passes, symmetrize=symmetrize)
191
  solvated = np.atleast_1d(predict_shieldings(solvent_distance_threshold=solvent_distance_threshold, **kw).squeeze())
192
  isolated = np.atleast_1d(predict_shieldings(solvent_distance_threshold=0.0, **kw).squeeze())
193
  out.append(solvated - isolated)
 
5
 
6
  - `n_passes`: average over this many forward passes to remove the per-pass frame noise (the MagNET
7
  paper uses 20).
8
+ - `mirror_average`: also average over the molecule and its mirror image. Isotropic shielding is
9
  parity-even, but the SO(3)-only network does not enforce that, so symmetrizing removes a spurious
10
  reflection error that grows to ~0.3 ppm on 13C for large molecules. It costs one extra evaluation.
11
+ The released reference shieldings are NOT mirror-averaged, so leave it False to reproduce them and turn
12
  it on for best accuracy on large molecules.
13
 
14
  Geometries are the AIMNet2 (machine-learning-optimized) stationary geometries, in Angstrom.
 
19
  import torch
20
 
21
  from magnet.model import MagNET_Lightning
22
+ from magnet.inference import predict_shieldings, resolve_mirror_average
23
 
24
  MODEL_CHECKPOINTS = {
25
  # foundation model (predicts the gas-phase shielding the rovibrational/QCD analysis builds on)
 
89
 
90
 
91
  def _predict_with(key_H, key_C, atomic_numbers_list, geometries_list,
92
+ n_passes=20, mirror_average=False, symmetrize=None, device=None, checkpoints_dir=None, **predict_kwargs):
93
  """Load one model pair and run predict_shieldings over a list of molecules. Returns a list of
94
  per-atom shielding arrays. Extra keyword arguments are forwarded to predict_shieldings (used by
95
  the explicit-solvent path for the solute/solvent split)."""
96
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "_predict_with")
97
  if device is None:
98
  device = _default_device()
99
  model_H = load_model_to_device(MODEL_CHECKPOINTS[key_H], device, checkpoints_dir=checkpoints_dir)
 
103
  atomic_numbers, geometry = entry[0], entry[1]
104
  out.append(np.atleast_1d(predict_shieldings(
105
  model_H, model_C, solute_atomic_numbers=atomic_numbers, geometry=geometry,
106
+ device=device, n_passes=n_passes, mirror_average=mirror_average, **predict_kwargs).squeeze()))
107
  return out
108
 
109
 
110
  def compute_MagNET_foundation_shieldings(atomic_numbers_list, geometries_list,
111
+ n_passes=20, mirror_average=False, symmetrize=None, device=None, checkpoints_dir=None):
112
  """MagNET foundation-model gas-phase shieldings (1H and 13C) for AIMNet2 geometries."""
113
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "compute_MagNET_foundation_shieldings")
114
  return _predict_with("MagNET_H", "MagNET_C", atomic_numbers_list, geometries_list,
115
+ n_passes=n_passes, mirror_average=mirror_average, device=device,
116
  checkpoints_dir=checkpoints_dir)
117
 
118
 
119
  def compute_MagNET_Zero_shieldings(atomic_numbers_list, geometries_list,
120
+ n_passes=20, mirror_average=False, symmetrize=None, device=None, checkpoints_dir=None):
121
  """MagNET-Zero gas-phase shieldings (1H and 13C) for a list of AIMNet2 geometries.
122
 
123
  Args:
124
  atomic_numbers_list: list of (N,) integer atomic-number arrays.
125
  geometries_list: list of (N, 3) coordinate arrays, in Angstrom.
126
+ n_passes, mirror_average: prediction options (see module docstring).
127
  checkpoints_dir: optional directory holding the released weights (see load_model_to_device).
128
 
129
  Returns a list of (N,) shielding arrays (1H at H atoms, 13C at C atoms).
130
  """
131
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "compute_MagNET_Zero_shieldings")
132
  return _predict_with("MagNET-Zero_H", "MagNET-Zero_C", atomic_numbers_list, geometries_list,
133
+ n_passes=n_passes, mirror_average=mirror_average, device=device,
134
  checkpoints_dir=checkpoints_dir)
135
 
136
 
137
  def compute_MagNET_PCM_corrections(atomic_numbers_list, geometries_list,
138
+ n_passes=20, mirror_average=False, symmetrize=None, device=None, checkpoints_dir=None):
139
  """MagNET-PCM implicit-solvent (chloroform) corrections (1H and 13C) for AIMNet2 geometries.
140
 
141
  The correction is the difference between the PCM-corrected shielding and the gas-phase shielding,
 
143
 
144
  Returns a list of (N,) correction arrays.
145
  """
146
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "compute_MagNET_PCM_corrections")
147
  gas = _predict_with("MagNET-PCM-withoutPCM_H", "MagNET-PCM-withoutPCM_C",
148
  atomic_numbers_list, geometries_list,
149
+ n_passes=n_passes, mirror_average=mirror_average, device=device,
150
  checkpoints_dir=checkpoints_dir)
151
  pcm = _predict_with("MagNET-PCM-withPCM_H", "MagNET-PCM-withPCM_C",
152
  atomic_numbers_list, geometries_list,
153
+ n_passes=n_passes, mirror_average=mirror_average, device=device,
154
  checkpoints_dir=checkpoints_dir)
155
  return [pcm[i] - gas[i] for i in range(len(pcm))]
156
 
 
160
 
161
 
162
  def compute_MagNET_x_corrections(solvent, solute_atomic_numbers_list, atomic_numbers_list,
163
+ geometries_list, n_passes=20, mirror_average=False, symmetrize=None, device=None,
164
  solvent_distance_threshold=12.0, checkpoints_dir=None):
165
  """MagNET-x explicit-solvent corrections (solvated minus isolated) for one solvent.
166
 
 
171
  The solute atoms must come first, followed by complete solvent molecules in contiguous
172
  blocks of N_ATOMS_PER_SOLVENT[solvent] atoms.
173
  geometries_list: list of (n_total, 3) coordinate arrays (same atom order), in Angstrom.
174
+ n_passes, mirror_average: prediction options (see module docstring).
175
  solvent_distance_threshold: drop solvent molecules whose nearest atom is beyond this many
176
  Angstrom of the solute (the released pipeline used 12.0). The isolated pass uses 0.0
177
  internally, which strips all solvent.
 
180
  Returns a list of (n_solute,) correction arrays. The published pipeline averages this over MD
181
  frames; a single-frame value is only an estimate.
182
  """
183
+ mirror_average = resolve_mirror_average(mirror_average, symmetrize, "compute_MagNET_x_corrections")
184
  if solvent not in N_ATOMS_PER_SOLVENT:
185
  raise ValueError(f"solvent must be one of {sorted(N_ATOMS_PER_SOLVENT)}; got {solvent!r}")
186
  n_per = N_ATOMS_PER_SOLVENT[solvent]
 
192
  for solute_Z, full_Z, geom in zip(solute_atomic_numbers_list, atomic_numbers_list, geometries_list):
193
  kw = dict(model_H=model_H, model_C=model_C, solute_atomic_numbers=solute_Z, geometry=geom,
194
  atomic_numbers=full_Z, N_atoms_per_solvent=n_per, device=device,
195
+ n_passes=n_passes, mirror_average=mirror_average)
196
  solvated = np.atleast_1d(predict_shieldings(solvent_distance_threshold=solvent_distance_threshold, **kw).squeeze())
197
  isolated = np.atleast_1d(predict_shieldings(solvent_distance_threshold=0.0, **kw).squeeze())
198
  out.append(solvated - isolated)
magnet/scaling.py CHANGED
@@ -20,7 +20,7 @@ import io
20
  import numpy as np
21
 
22
  # Published SI Tables S10 (proton) and S11 (carbon), verbatim (reflection-symmetrized: n_passes=10,
23
- # symmetrize=True). Columns: intercept, stationary, pcm. Kept byte-for-byte in sync with
24
  # data/scaling_factors/scaling_factors_symmetrized_{H,C}.csv by test_scaling_factors.py.
25
  _PUBLISHED_TABLE_CSV = {
26
  "H": """solvent,intercept,stationary,pcm
 
20
  import numpy as np
21
 
22
  # Published SI Tables S10 (proton) and S11 (carbon), verbatim (reflection-symmetrized: n_passes=10,
23
+ # mirror_average=True, the option this code called symmetrize when the tables were made). Columns: intercept, stationary, pcm. Kept byte-for-byte in sync with
24
  # data/scaling_factors/scaling_factors_symmetrized_{H,C}.csv by test_scaling_factors.py.
25
  _PUBLISHED_TABLE_CSV = {
26
  "H": """solvent,intercept,stationary,pcm
tests/test_magnet.py CHANGED
@@ -260,3 +260,88 @@ def test_real_magnet_x_reproduces_dft_correction():
260
  keep = ((Zs == 1) | (Zs == 6)) & np.isfinite(ref)
261
  rms = np.sqrt(np.mean((correction[keep] - ref[keep]) ** 2))
262
  assert rms < 0.3 # measured ~0.085 ppm on this pose; model error, not exact
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  keep = ((Zs == 1) | (Zs == 6)) & np.isfinite(ref)
261
  rms = np.sqrt(np.mean((correction[keep] - ref[keep]) ** 2))
262
  assert rms < 0.3 # measured ~0.085 ppm on this pose; model error, not exact
263
+
264
+
265
+ # =====================================================================
266
+ # mirror_average (formerly symmetrize), and the batching behind the passes
267
+ # =====================================================================
268
+
269
+ def test_symmetrize_is_deprecated_but_still_works():
270
+ """The old name keeps working and says so, since magnet-nmr is published under it."""
271
+ import warnings
272
+ from magnet.inference import resolve_mirror_average
273
+ with warnings.catch_warnings(record=True) as caught:
274
+ warnings.simplefilter("always")
275
+ assert resolve_mirror_average(False, True, "predict_shieldings") is True
276
+ assert len(caught) == 1
277
+ assert issubclass(caught[0].category, DeprecationWarning)
278
+ assert "mirror_average" in str(caught[0].message)
279
+
280
+
281
+ def test_mirror_average_passes_without_warning():
282
+ """The new name is the quiet one."""
283
+ import warnings
284
+ from magnet.inference import resolve_mirror_average
285
+ with warnings.catch_warnings(record=True) as caught:
286
+ warnings.simplefilter("always")
287
+ assert resolve_mirror_average(True, None, "predict_shieldings") is True
288
+ assert resolve_mirror_average(False, None, "predict_shieldings") is False
289
+ assert caught == []
290
+
291
+
292
+ def test_symmetrize_wins_when_both_are_given():
293
+ """A caller still passing the old name gets what it asked for, whatever the new one defaults to."""
294
+ import warnings
295
+ from magnet.inference import resolve_mirror_average
296
+ with warnings.catch_warnings():
297
+ warnings.simplefilter("ignore")
298
+ assert resolve_mirror_average(True, False, "predict_shieldings") is False
299
+
300
+
301
+ @pytest.mark.skipif(not os.path.exists(CKPT), reason="released MagNET-Zero checkpoints not present")
302
+ def test_mirror_average_matches_symmetrize():
303
+ """The rename is a rename: both names give the same numbers."""
304
+ import warnings
305
+ dev = "cpu"
306
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_1H.ckpt"), map_location=dev).eval()
307
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_13C.ckpt"), map_location=dev).eval()
308
+ z = np.array([6, 1, 1, 1, 1])
309
+ xyz = np.array([[0., 0., 0.], [0.629, 0.629, 0.629], [-0.629, -0.629, 0.629],
310
+ [-0.629, 0.629, -0.629], [0.629, -0.629, -0.629]])
311
+ with warnings.catch_warnings():
312
+ warnings.simplefilter("ignore", DeprecationWarning)
313
+ old = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev,
314
+ n_passes=60, symmetrize=True)
315
+ new = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev,
316
+ n_passes=60, mirror_average=True)
317
+ # both average the same frame noise away, so they agree to well inside it
318
+ assert np.max(np.abs(old - new)) < 0.05
319
+
320
+
321
+ @pytest.mark.skipif(not os.path.exists(CKPT), reason="released MagNET-Zero checkpoints not present")
322
+ def test_chunking_does_not_change_the_answer():
323
+ """max_batch_graphs bounds memory and nothing else: the passes are averaged either way."""
324
+ dev = "cpu"
325
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_1H.ckpt"), map_location=dev).eval()
326
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_13C.ckpt"), map_location=dev).eval()
327
+ z = np.array([6, 1, 1, 1, 1])
328
+ xyz = np.array([[0., 0., 0.], [0.629, 0.629, 0.629], [-0.629, -0.629, 0.629],
329
+ [-0.629, 0.629, -0.629], [0.629, -0.629, -0.629]])
330
+ kw = dict(solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=40, mirror_average=True)
331
+ whole = predict_shieldings(mH, mC, max_batch_graphs=1000, **kw)
332
+ chunked = predict_shieldings(mH, mC, max_batch_graphs=3, **kw)
333
+ # same passes, same average, only the number of forward calls differs
334
+ assert np.max(np.abs(whole - chunked)) < 0.05
335
+
336
+
337
+ @pytest.mark.skipif(not os.path.exists(CKPT), reason="released MagNET-Zero checkpoints not present")
338
+ def test_one_atom_solute_stays_one_dimensional():
339
+ """The per-graph squeeze must not turn a one-atom solute into a scalar."""
340
+ dev = "cpu"
341
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_1H.ckpt"), map_location=dev).eval()
342
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_13C.ckpt"), map_location=dev).eval()
343
+ z = np.array([6, 1, 1, 1, 1])
344
+ xyz = np.array([[0., 0., 0.], [0.629, 0.629, 0.629], [-0.629, -0.629, 0.629],
345
+ [-0.629, 0.629, -0.629], [0.629, -0.629, -0.629]])
346
+ out = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=2)
347
+ assert out.ndim == 1 and out.shape == (5,)