sync code from github@fbebb603b47d270f0d13b4146970b99ea374ddd7
Browse files- README.md +1 -1
- magnet/api.py +20 -14
- magnet/inference.py +107 -9
- magnet/pyproject.toml +1 -1
- magnet/run_magnet.py +21 -16
- magnet/scaling.py +1 -1
- tests/test_magnet.py +85 -0
README.md
CHANGED
|
@@ -130,7 +130,7 @@ The predictions are close to experiment:
|
|
| 130 |
|
| 131 |
**Notes:**
|
| 132 |
|
| 133 |
-
- **Passes and
|
| 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 |
-
- `
|
|
|
|
| 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,
|
| 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 |
-
|
| 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 |
-
|
| 117 |
checkpoints_dir=checkpoints_dir)
|
| 118 |
pcm = run_magnet.compute_MagNET_PCM_corrections(an_list, xyz_list, n_passes=n_passes,
|
| 119 |
-
|
| 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,
|
| 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 |
-
|
| 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,
|
| 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,
|
| 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 |
-
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 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,
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 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.
|
| 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 |
-
- `
|
| 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
|
| 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,
|
| 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,
|
| 106 |
return out
|
| 107 |
|
| 108 |
|
| 109 |
def compute_MagNET_foundation_shieldings(atomic_numbers_list, geometries_list,
|
| 110 |
-
n_passes=20,
|
| 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,
|
| 114 |
checkpoints_dir=checkpoints_dir)
|
| 115 |
|
| 116 |
|
| 117 |
def compute_MagNET_Zero_shieldings(atomic_numbers_list, geometries_list,
|
| 118 |
-
n_passes=20,
|
| 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,
|
| 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,
|
| 131 |
checkpoints_dir=checkpoints_dir)
|
| 132 |
|
| 133 |
|
| 134 |
def compute_MagNET_PCM_corrections(atomic_numbers_list, geometries_list,
|
| 135 |
-
n_passes=20,
|
| 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,
|
| 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,
|
| 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,
|
| 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,
|
| 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,
|
| 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 |
-
#
|
| 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,)
|