sync code from github@6f6c7d83845ee0307e16b2b9bb1b389c5bfc5007
Browse files- README.md +2 -4
- analysis/code/delta50_residuals.py +72 -0
- analysis/code/test_delta50_residuals.py +145 -0
- data/delta50/build_delta50.py +299 -0
- data/delta50/decode_delta50.py +38 -13
- data/delta50/delta50.hdf5 +2 -2
- data/delta50/test_delta50.py +29 -5
README.md
CHANGED
|
@@ -194,9 +194,7 @@ You can render the API docs with [pdoc](https://pdoc.dev):
|
|
| 194 |
pip install pdoc
|
| 195 |
python build_api_docs.py
|
| 196 |
|
| 197 |
-
|
| 198 |
-
API: `predict_shifts`, `predict_shieldings`, `implicit_solvent_correction`,
|
| 199 |
-
`explicit_solvent_correction`) and every analysis module under `analysis/code`.
|
| 200 |
|
| 201 |
### How to Cite
|
| 202 |
|
|
@@ -211,4 +209,4 @@ Adams, K.; Wagen, C.C.; Wolford, J.; Sak, M.H.; Saurí, J.; Feng, Z.; Bhadauria,
|
|
| 211 |
- The original code, model weights, and datasets in this repository are released under the MIT License (see [`LICENSE`](LICENSE)).
|
| 212 |
- Third-party literature data redistributed here remains subject to its original publications' terms and should be
|
| 213 |
cited accordingly: CP3 (`data/cp3/`, Smith and Goodman), NS372
|
| 214 |
-
(`data/ns372/`, Schattenberg and Kaupp),
|
|
|
|
| 194 |
pip install pdoc
|
| 195 |
python build_api_docs.py
|
| 196 |
|
| 197 |
+
Most people will only want to look at `api_docs/magnet.html` (the public API), though the other functions are documented as well.
|
|
|
|
|
|
|
| 198 |
|
| 199 |
### How to Cite
|
| 200 |
|
|
|
|
| 209 |
- The original code, model weights, and datasets in this repository are released under the MIT License (see [`LICENSE`](LICENSE)).
|
| 210 |
- Third-party literature data redistributed here remains subject to its original publications' terms and should be
|
| 211 |
cited accordingly: CP3 (`data/cp3/`, Smith and Goodman), NS372
|
| 212 |
+
(`data/ns372/`, Schattenberg and Kaupp), DFT8K (`data/dft8k/`, Guan and Paton), and DELTA50 (`data/delta50/`, Cohen *et al.*).
|
analysis/code/delta50_residuals.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MagNET-Zero vs DFT residuals on the DELTA50 benchmark (50 common organics). No plotting.
|
| 2 |
+
|
| 3 |
+
MagNET-Zero is compared against the DFT level it reproduces, on the same AIMNet2 geometries: protons
|
| 4 |
+
vs WP04/pcSseg-2, carbons vs wB97X-D/pcSseg-2, gas phase; residual = DFT shielding minus MagNET.
|
| 5 |
+
This is the comparison behind the paper's DELTA50 outlier observation (nitromethane, nitroethane, and
|
| 6 |
+
2-methyl-2-nitropropane are the significant outliers). Read via data/delta50/decode_delta50.py.
|
| 7 |
+
"""
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from stats import summarize_errors
|
| 11 |
+
|
| 12 |
+
# the DFT level MagNET-Zero reproduces for each nucleus; MagNET-Zero is a single per-atom array
|
| 13 |
+
NUCLEI = {
|
| 14 |
+
"H": {"atomic_number": 1, "dft": "shielding_wp04_pcSseg2", "nn": "nn_magnet_zero"},
|
| 15 |
+
"C": {"atomic_number": 6, "dft": "shielding_wb97xd_pcSseg2", "nn": "nn_magnet_zero"},
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_shieldings(delta50_path):
|
| 20 |
+
"""Flat per-atom arrays from the released reader: atomic_numbers, the MagNET predictions, the
|
| 21 |
+
DFT reference shieldings, and experimental_shift (all ppm, NaN where absent)."""
|
| 22 |
+
from decode_delta50 import Delta50
|
| 23 |
+
with Delta50(delta50_path) as ds:
|
| 24 |
+
return ds.all_atoms()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def residuals(shieldings, nucleus):
|
| 28 |
+
"""DFT-minus-MagNET-Zero shielding residuals (ppm) for one nucleus ("H" or "C"), over that
|
| 29 |
+
element's atoms where both values are present."""
|
| 30 |
+
spec = NUCLEI[nucleus]
|
| 31 |
+
z = np.asarray(shieldings["atomic_numbers"])
|
| 32 |
+
dft = np.asarray(shieldings[spec["dft"]], dtype=float)
|
| 33 |
+
nn = np.asarray(shieldings[spec["nn"]], dtype=float)
|
| 34 |
+
keep = (z == spec["atomic_number"]) & np.isfinite(dft) & np.isfinite(nn)
|
| 35 |
+
return dft[keep] - nn[keep]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def residual_stats(errors):
|
| 39 |
+
"""count / RMSE / MAE / median AE / max absolute error for a residual array."""
|
| 40 |
+
errors = np.asarray(errors, dtype=float)
|
| 41 |
+
if not errors.size:
|
| 42 |
+
return {"n": 0, "rmse": float("nan"), "mae": float("nan"), "median_ae": float("nan"),
|
| 43 |
+
"max_ae": float("nan")}
|
| 44 |
+
stats = summarize_errors(errors, np.zeros_like(errors))
|
| 45 |
+
stats["max_ae"] = float(np.abs(errors).max())
|
| 46 |
+
return stats
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def residual_summary(delta50_path):
|
| 50 |
+
"""{nucleus: residual_stats} for both nuclei, from the released file."""
|
| 51 |
+
shieldings = load_shieldings(delta50_path)
|
| 52 |
+
return {nucleus: residual_stats(residuals(shieldings, nucleus)) for nucleus in NUCLEI}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def per_molecule_max_residual(delta50_path):
|
| 56 |
+
"""{molecule_name: {"H": max|residual|, "C": max|residual|}} - the DFT-vs-MagNET-Zero outlier
|
| 57 |
+
magnitudes per molecule (nitromethane, nitroethane, 2-methyl-2-nitropropane rank highest for C)."""
|
| 58 |
+
from decode_delta50 import Delta50
|
| 59 |
+
out = {}
|
| 60 |
+
with Delta50(delta50_path) as ds:
|
| 61 |
+
for i in range(len(ds)):
|
| 62 |
+
m = ds.molecule(i)
|
| 63 |
+
z = m["atomic_numbers"]
|
| 64 |
+
ref = np.where(z == 1, m["shielding_wp04_pcSseg2"],
|
| 65 |
+
np.where(z == 6, m["shielding_wb97xd_pcSseg2"], np.nan))
|
| 66 |
+
r = ref - m["nn_magnet_zero"]
|
| 67 |
+
res = {}
|
| 68 |
+
for nucleus, zz in (("H", 1), ("C", 6)):
|
| 69 |
+
sel = (z == zz) & np.isfinite(r)
|
| 70 |
+
res[nucleus] = float(np.abs(r[sel]).max()) if sel.any() else float("nan")
|
| 71 |
+
out[m["name"]] = res
|
| 72 |
+
return out
|
analysis/code/test_delta50_residuals.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for analysis/code/delta50_residuals.py.
|
| 2 |
+
|
| 3 |
+
Synthetic tests check residual selection and statistics on a hand-built shieldings dict. Opt-in
|
| 4 |
+
tests run against the real delta50.hdf5 and reproduce the paper's DELTA50 observation: MagNET-Zero
|
| 5 |
+
tracks its DFT reference to well under 0.5 ppm (1H) and a few ppm (13C), with nitromethane,
|
| 6 |
+
nitroethane, and 2-methyl-2-nitropropane the significant 13C outliers (up to ~7 ppm).
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
+
sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta50")) # decode_delta50
|
| 16 |
+
sys.path.insert(0, HERE)
|
| 17 |
+
import paths # noqa: E402
|
| 18 |
+
|
| 19 |
+
import delta50_residuals as D # noqa: E402
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def make_shieldings():
|
| 23 |
+
"""Flat shieldings dict like the reader's all_atoms: H, C, C, H, N. MagNET-Zero is one array
|
| 24 |
+
(WP04 quality at H, wB97X-D quality at C); the DFT reference is split by nucleus."""
|
| 25 |
+
return {
|
| 26 |
+
"atomic_numbers": np.array([1, 6, 6, 1, 7]),
|
| 27 |
+
"shielding_wp04_pcSseg2": np.array([30.5, 150.5, 151.5, 31.5, 200.5]),
|
| 28 |
+
"shielding_wb97xd_pcSseg2": np.array([30.7, 150.7, 151.7, 31.7, 200.7]),
|
| 29 |
+
"nn_magnet_zero": np.array([30.6, 150.6, 151.6, 31.3, np.nan]),
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_residuals_select_nucleus_against_the_right_dft_level():
|
| 34 |
+
s = make_shieldings()
|
| 35 |
+
h = D.residuals(s, "H") # DFT WP04 minus MagNET at the two H atoms (0, 3)
|
| 36 |
+
np.testing.assert_allclose(sorted(h), sorted([30.5 - 30.6, 31.5 - 31.3]))
|
| 37 |
+
c = D.residuals(s, "C") # DFT wB97X-D minus MagNET at the two C atoms (1, 2)
|
| 38 |
+
np.testing.assert_allclose(sorted(c), sorted([150.7 - 150.6, 151.7 - 151.6]))
|
| 39 |
+
assert len(h) == 2 and len(c) == 2
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_residual_stats():
|
| 43 |
+
errors = np.array([-0.1, 0.2, 0.05, -0.05])
|
| 44 |
+
st = D.residual_stats(errors)
|
| 45 |
+
assert st["n"] == 4
|
| 46 |
+
assert st["rmse"] == pytest.approx(np.sqrt(np.mean(errors ** 2)))
|
| 47 |
+
assert st["mae"] == pytest.approx(np.mean(np.abs(errors)))
|
| 48 |
+
assert st["max_ae"] == pytest.approx(0.2)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_residual_stats_empty():
|
| 52 |
+
st = D.residual_stats(np.array([]))
|
| 53 |
+
assert st["n"] == 0 and np.isnan(st["rmse"])
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# --- opt-in: reproduce the DELTA50 residual numbers from the released data -----------------------
|
| 57 |
+
|
| 58 |
+
REAL = paths.dataset_file("delta50", file=__file__)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
|
| 62 |
+
def test_reproduces_delta50_magnet_zero_vs_dft_residuals():
|
| 63 |
+
summary = D.residual_summary(REAL)
|
| 64 |
+
# 1H: MagNET-Zero tracks WP04 to ~0.05 ppm RMSE, max residual ~0.47 ppm ("up to 0.4 ppm")
|
| 65 |
+
assert summary["H"]["rmse"] == pytest.approx(0.055, abs=0.01)
|
| 66 |
+
assert summary["H"]["max_ae"] == pytest.approx(0.469, abs=0.02)
|
| 67 |
+
# 13C: RMSE ~0.61 ppm, max residual ~6.65 ppm ("up to 7 ppm")
|
| 68 |
+
assert summary["C"]["rmse"] == pytest.approx(0.607, abs=0.03)
|
| 69 |
+
assert summary["C"]["max_ae"] == pytest.approx(6.654, abs=0.05)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
|
| 73 |
+
def test_named_13c_outliers_are_the_three_nitroalkanes():
|
| 74 |
+
per_mol = D.per_molecule_max_residual(REAL)
|
| 75 |
+
top3 = sorted(per_mol, key=lambda nm: -per_mol[nm]["C"])[:3]
|
| 76 |
+
assert set(top3) == {"nitromethane", "nitroethane", "2-methyl-2-nitropropane"}
|
| 77 |
+
# nitromethane is the single largest 13C outlier, ~6.65 ppm
|
| 78 |
+
assert per_mol["nitromethane"]["C"] == pytest.approx(6.654, abs=0.05)
|
| 79 |
+
# nitrobenzene, by contrast, is accurately predicted (paper's explicit contrast)
|
| 80 |
+
assert per_mol["nitrobenzene"]["C"] < 0.5
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# Golden experimental shifts (ppm) for the molecules whose atom order is most likely to be mismapped:
|
| 84 |
+
# the three symmetry-fallback cases and the near-degenerate distinct environments (amide N-methyls,
|
| 85 |
+
# diastereotopic methyls). Values are the published DELTA50 shifts; a mapping regression changes them.
|
| 86 |
+
_GOLDEN_MULTISET = {
|
| 87 |
+
"cyclohexanone": {"C": [25.02, 27.03, 27.03, 42.0, 42.0, 212.15]},
|
| 88 |
+
"methyl acetate": {"C": [20.71, 51.62, 171.55]},
|
| 89 |
+
"2-methyl-2-nitropropane": {"C": [27.87, 27.87, 27.87, 85.06]},
|
| 90 |
+
"n,n-dimethylformamide": {"C": [31.44, 36.48, 162.52]},
|
| 91 |
+
"n,n-dimethylacetamide": {"C": [21.58, 35.2, 38.05, 170.66]},
|
| 92 |
+
"2-methyl-2-butene": {"C": [13.41, 17.32, 25.63, 118.44, 132.1]},
|
| 93 |
+
}
|
| 94 |
+
# (0-based carbon index -> shift): pins each shift to a specific atom, so a swap of two same-value-set
|
| 95 |
+
# environments (e.g. the two DMF N-methyls) fails even though the multiset is unchanged.
|
| 96 |
+
_GOLDEN_ATOM = {
|
| 97 |
+
"n,n-dimethylformamide": {0: 162.52, 3: 31.44, 4: 36.48},
|
| 98 |
+
"n,n-dimethylacetamide": {1: 170.66, 3: 35.2, 4: 38.05, 5: 21.58},
|
| 99 |
+
"2-methyl-2-butene": {0: 17.32, 1: 132.1, 2: 118.44, 3: 13.41, 4: 25.63},
|
| 100 |
+
"cyclohexanone": {1: 212.15, 2: 42.0, 3: 42.0, 4: 27.03, 5: 27.03, 6: 25.02},
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
|
| 105 |
+
def test_experimental_multiset_matches_published_delta50():
|
| 106 |
+
from decode_delta50 import Delta50
|
| 107 |
+
with Delta50(REAL) as ds:
|
| 108 |
+
for name, expected in _GOLDEN_MULTISET.items():
|
| 109 |
+
m = ds.molecule_by_name(name)
|
| 110 |
+
got = sorted(round(float(x), 2) for x in m["experimental_shift"][m["atomic_numbers"] == 6]
|
| 111 |
+
if np.isfinite(x))
|
| 112 |
+
assert got == sorted(expected["C"]), name
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
|
| 116 |
+
def test_experimental_swap_sensitive_carbons_pinned_to_atoms():
|
| 117 |
+
from decode_delta50 import Delta50
|
| 118 |
+
with Delta50(REAL) as ds:
|
| 119 |
+
for name, atoms in _GOLDEN_ATOM.items():
|
| 120 |
+
m = ds.molecule_by_name(name)
|
| 121 |
+
for idx, shift in atoms.items():
|
| 122 |
+
assert m["atomic_numbers"][idx] == 6, (name, idx)
|
| 123 |
+
assert m["experimental_shift"][idx] == pytest.approx(shift, abs=1e-3), (name, idx)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
|
| 127 |
+
def test_experimental_shift_present_and_agrees_with_dft():
|
| 128 |
+
from decode_delta50 import Delta50
|
| 129 |
+
with Delta50(REAL) as ds:
|
| 130 |
+
atoms = ds.all_atoms()
|
| 131 |
+
z = atoms["atomic_numbers"]
|
| 132 |
+
exp = atoms["experimental_shift"]
|
| 133 |
+
# experimental is present for every H and C atom, absent for heteroatoms
|
| 134 |
+
assert np.isfinite(exp[(z == 1) | (z == 6)]).all()
|
| 135 |
+
assert np.isnan(exp[(z != 1) & (z != 6)]).all()
|
| 136 |
+
# A correct atom mapping makes scaled DFT track experiment; a scrambled one would not. These
|
| 137 |
+
# tolerances are loose (our DFT is gas phase on AIMNet2 geometries, not DELTA50's PCM level) but
|
| 138 |
+
# far below the ppm-scale RMSE a wrong assignment would produce.
|
| 139 |
+
for zz, key, tol in ((1, "shielding_wp04_pcSseg2", 0.5), (6, "shielding_wb97xd_pcSseg2", 5.0)):
|
| 140 |
+
m = (z == zz) & np.isfinite(exp)
|
| 141 |
+
sigma = atoms[key][m]
|
| 142 |
+
A = np.vstack([sigma, np.ones(m.sum())]).T
|
| 143 |
+
coef, *_ = np.linalg.lstsq(A, exp[m], rcond=None)
|
| 144 |
+
rmse = np.sqrt(np.mean((A @ coef - exp[m]) ** 2))
|
| 145 |
+
assert rmse < tol, f"Z={zz} scaled-DFT-vs-exp RMSE {rmse:.3f} too large"
|
data/delta50/build_delta50.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provenance/build script for delta50.hdf5.
|
| 2 |
+
|
| 3 |
+
Regenerates the shipped delta50.hdf5 from three raw sources (none of which are shipped, exactly as
|
| 4 |
+
for dft8k):
|
| 5 |
+
|
| 6 |
+
1. the previous delta50.hdf5, for the MagNET predictions already packaged into it
|
| 7 |
+
(nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm; these come from MagNET inference and are carried
|
| 8 |
+
forward unchanged), plus the atom order, geometries, and atomic numbers;
|
| 9 |
+
2. a directory of Gaussian NMR output files, one per molecule, each with two link jobs at the
|
| 10 |
+
MagNET-Zero reference levels (WP04 for 1H, wB97X-D for 13C; pcSseg-2; gas), computed on the
|
| 11 |
+
stored AIMNet2 geometries. Parsed with cctk. These become the per-atom DFT reference
|
| 12 |
+
shieldings shielding_wp04_pcSseg2 and shielding_wb97xd_pcSseg2, so the MagNET-Zero-vs-DFT
|
| 13 |
+
residual (Table S3/S4-style, the DELTA50 outlier check in the paper) reproduces from the file.
|
| 14 |
+
3. DELTA50_benchmark.xlsx from the DELTA50 Supporting Information (Cohen et al., Molecules 2023,
|
| 15 |
+
28, 2449; reference 47; CC BY 4.0), for the experimental 1H and 13C chemical shifts.
|
| 16 |
+
|
| 17 |
+
The experimental shifts are mapped onto this file's own atom order and stored per atom as
|
| 18 |
+
experimental_shift. DELTA50 numbers its atoms against independently built structures; those geometries
|
| 19 |
+
were recomputed here, so the numbering only sometimes lines up. Each molecule is mapped by DELTA50's
|
| 20 |
+
own atom numbering when that numbering is element-consistent and covers every H/C atom (47 of 50);
|
| 21 |
+
the three whose atom order differs (cyclohexanone, methyl acetate, 2-methyl-2-nitropropane) are
|
| 22 |
+
mapped by matching RDKit topological-symmetry classes to DELTA50 environments in order of the
|
| 23 |
+
DFT-predicted shift. The mapping is validated: coverage is 100% of H and C atoms, and the large
|
| 24 |
+
scaled-DFT-vs-experiment residuals are all chemically hard sp2 carbons (carbonyl, nitro, aromatic).
|
| 25 |
+
|
| 26 |
+
Usage:
|
| 27 |
+
python3 build_delta50.py [SRC_HDF5] [OUT_DIR] [XLSX] [DST_HDF5]
|
| 28 |
+
|
| 29 |
+
with the defaults below. Requires numpy, h5py, pandas, cctk, and rdkit.
|
| 30 |
+
"""
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
import os
|
| 33 |
+
import re
|
| 34 |
+
import sys
|
| 35 |
+
from collections import defaultdict
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
import h5py
|
| 39 |
+
|
| 40 |
+
SCALE = 1e-4
|
| 41 |
+
MARKER = -2147483648
|
| 42 |
+
|
| 43 |
+
_HOME = os.path.expanduser("~")
|
| 44 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 45 |
+
DEFAULT_SRC = os.path.join(_HERE, "delta50.hdf5")
|
| 46 |
+
DEFAULT_OUT_DIR = os.path.join(_HOME, "research", "magnet", "delta50", "delta50",
|
| 47 |
+
"output", "wp04_wb97xd_pcSseg2")
|
| 48 |
+
DEFAULT_XLSX = os.path.join(_HOME, "research", "magnet", "delta50", "DELTA50_benchmark.xlsx")
|
| 49 |
+
|
| 50 |
+
# Molecules whose canonical name here differs from the one in the previous file and the raw inputs.
|
| 51 |
+
# DELTA50 (and the old file) mislabel 2-methyl-2-nitropropane, a C-nitro compound, as a nitrate ester.
|
| 52 |
+
RENAMES = {"t-butyl nitrate": "2-methyl-2-nitropropane"}
|
| 53 |
+
|
| 54 |
+
# DELTA50 spreadsheet compound names that differ from our molecule_names. "3,3-dimethyl-1-butene"
|
| 55 |
+
# is split across two cells ("(3,3-Dimethyl-" then "1-butene)"), so both fragments are aliased.
|
| 56 |
+
NAME_ALIASES = {
|
| 57 |
+
"dmf": "n,n-dimethylformamide", "thf": "tetrahydrofuran", "dmac": "n,n-dimethylacetamide",
|
| 58 |
+
"2cyanopropane": "isobutyronitrile", "mtbe": "methyl t-butyl ether",
|
| 59 |
+
"cyclopentenone": "cyclopent-2-en-1-one", "cyclohexenone": "cyclohex-2-en-1-one",
|
| 60 |
+
"pbenzoquinone": "1,4-benzoquinone", "thp": "tetrahydropyran",
|
| 61 |
+
"33dimethyl": "t-butylethylene", "1butene": "t-butylethylene",
|
| 62 |
+
"tbutylnitrate": "2-methyl-2-nitropropane",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _norm(s):
|
| 67 |
+
return re.sub(r"[^a-z0-9]", "", str(s).lower())
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _encode(values):
|
| 71 |
+
v = np.asarray(values, dtype=np.float64)
|
| 72 |
+
finite = np.isfinite(v)
|
| 73 |
+
out = np.full(v.shape, MARKER, dtype=np.int64)
|
| 74 |
+
out[finite] = np.round(v[finite] / SCALE).astype(np.int64)
|
| 75 |
+
return out.astype(np.int32)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _parse_atom_labels(s):
|
| 79 |
+
"""DELTA50 atom-number strings: comma lists ("5,6,7"), ranges ("7-12"), singles, and mixtures."""
|
| 80 |
+
out = []
|
| 81 |
+
for tok in re.split(r"[,\s]+", str(s).strip()):
|
| 82 |
+
m = re.match(r"^(\d+)-(\d+)$", tok)
|
| 83 |
+
if m:
|
| 84 |
+
out += list(range(int(m.group(1)), int(m.group(2)) + 1))
|
| 85 |
+
elif tok.isdigit():
|
| 86 |
+
out.append(int(tok))
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def load_predictions(src_hdf5):
|
| 91 |
+
with h5py.File(src_hdf5, "r") as f:
|
| 92 |
+
return {
|
| 93 |
+
"molecule_names": [str(n) for n in f["molecule_names"].asstr()[:]],
|
| 94 |
+
"n_atoms": f["n_atoms"][:].astype(np.int32),
|
| 95 |
+
"atomic_numbers": f["atomic_numbers"][:],
|
| 96 |
+
"coordinates": f["coordinates"][:],
|
| 97 |
+
"nn_magnet_zero": f["nn_magnet_zero"][:],
|
| 98 |
+
"nn_b3lyp": f["nn_b3lyp"][:],
|
| 99 |
+
"nn_b3lyp_pcm": f["nn_b3lyp_pcm"][:],
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def load_dft_reference(out_dir, file_names, out_names, atomic_numbers, starts):
|
| 104 |
+
"""Per-atom WP04 and wB97X-D isotropic shieldings from the Gaussian outputs, aligned to the
|
| 105 |
+
stored atom order (checked). Files are matched by `file_names` (the raw-input naming) but the
|
| 106 |
+
returned per-molecule dict is keyed by `out_names` (the canonical output naming).
|
| 107 |
+
Returns (wp04, wb97xd, per_molecule_shieldings)."""
|
| 108 |
+
import cctk
|
| 109 |
+
files = {_norm(f[len("delta50-"):-len(".out")]): os.path.join(out_dir, f)
|
| 110 |
+
for f in os.listdir(out_dir) if f.endswith(".out")}
|
| 111 |
+
# the Gaussian files keep the pre-rename naming; fall back through RENAMES so re-running the
|
| 112 |
+
# build against its own (already-renamed) output still finds each file.
|
| 113 |
+
reverse = {v: k for k, v in RENAMES.items()}
|
| 114 |
+
|
| 115 |
+
def _find(name):
|
| 116 |
+
for candidate in (name, reverse.get(name, name)):
|
| 117 |
+
if _norm(candidate) in files:
|
| 118 |
+
return files[_norm(candidate)]
|
| 119 |
+
raise KeyError(f"no Gaussian output for {name!r} in {out_dir}")
|
| 120 |
+
|
| 121 |
+
wp04 = np.full(starts[-1], np.nan)
|
| 122 |
+
wb97xd = np.full(starts[-1], np.nan)
|
| 123 |
+
per_mol = {}
|
| 124 |
+
for i, (file_name, out_name) in enumerate(zip(file_names, out_names)):
|
| 125 |
+
objs = cctk.GaussianFile.read_file(_find(file_name))
|
| 126 |
+
by_level = {}
|
| 127 |
+
for g in objs:
|
| 128 |
+
ens = g.ensemble
|
| 129 |
+
iso = np.asarray(ens.get_properties_dict(ens.molecules[-1])["isotropic_shielding"],
|
| 130 |
+
dtype=float)
|
| 131 |
+
z = np.asarray(ens.molecules[-1].atomic_numbers)
|
| 132 |
+
by_level["wb97xd" if "wb97xd" in g.route_card.lower() else "wp04"] = (z, iso)
|
| 133 |
+
sl = slice(int(starts[i]), int(starts[i + 1]))
|
| 134 |
+
z_ref = atomic_numbers[sl]
|
| 135 |
+
for level, (z, iso) in by_level.items():
|
| 136 |
+
if not np.array_equal(z, z_ref):
|
| 137 |
+
raise ValueError(f"{out_name}: {level} atom order does not match the stored geometry")
|
| 138 |
+
wp04[sl] = by_level["wp04"][1]
|
| 139 |
+
wb97xd[sl] = by_level["wb97xd"][1]
|
| 140 |
+
per_mol[out_name] = {"wp04": by_level["wp04"][1], "wb97xd": by_level["wb97xd"][1]}
|
| 141 |
+
return wp04, wb97xd, per_mol
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _parse_experimental_sheets(xlsx, valid_names):
|
| 145 |
+
"""{molecule_name: {"H": [(atoms, shift)], "C": [...]}} from the two DELTA50 functional sheets."""
|
| 146 |
+
import pandas as pd
|
| 147 |
+
valid = {_norm(n): n for n in valid_names}
|
| 148 |
+
out = {n: {"H": [], "C": []} for n in valid_names}
|
| 149 |
+
for sheet, nucleus in (("1H Functional", "H"), ("13C Functional", "C")):
|
| 150 |
+
df = pd.read_excel(xlsx, sheet_name=sheet, header=None)
|
| 151 |
+
hdr = next(r for r in range(df.shape[0]) if str(df.iat[r, 0]).strip() == "Compound")
|
| 152 |
+
current = None
|
| 153 |
+
for r in range(hdr + 1, df.shape[0]):
|
| 154 |
+
compound, label, shift = df.iat[r, 0], df.iat[r, 1], df.iat[r, 2]
|
| 155 |
+
if isinstance(compound, str) and compound.strip():
|
| 156 |
+
k = _norm(compound)
|
| 157 |
+
current = valid.get(_norm(NAME_ALIASES.get(k, compound)))
|
| 158 |
+
if current is None or pd.isna(label) or pd.isna(shift):
|
| 159 |
+
continue
|
| 160 |
+
out[current][nucleus].append((_parse_atom_labels(label), float(shift)))
|
| 161 |
+
return out
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _symmetry_classes(atomic_numbers, coordinates):
|
| 165 |
+
"""RDKit topological-symmetry classes for one molecule: {rank: [atom indices]}. Bonds are
|
| 166 |
+
perceived from the geometry."""
|
| 167 |
+
from rdkit import Chem
|
| 168 |
+
from rdkit.Chem import rdDetermineBonds
|
| 169 |
+
pt = Chem.GetPeriodicTable()
|
| 170 |
+
lines = [str(len(atomic_numbers)), ""]
|
| 171 |
+
for z, xyz in zip(atomic_numbers, coordinates):
|
| 172 |
+
lines.append(f"{pt.GetElementSymbol(int(z))} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
|
| 173 |
+
mol = Chem.MolFromXYZBlock("\n".join(lines))
|
| 174 |
+
rdDetermineBonds.DetermineBonds(mol, charge=0)
|
| 175 |
+
classes = defaultdict(list)
|
| 176 |
+
for atom_idx, rank in enumerate(Chem.CanonicalRankAtoms(mol, breakTies=False)):
|
| 177 |
+
classes[rank].append(atom_idx)
|
| 178 |
+
return classes
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def map_experimental(xlsx, molecule_names, atomic_numbers, coordinates, starts, dft_per_mol):
|
| 182 |
+
"""Map DELTA50 experimental shifts onto our atom order; per-atom array (ppm, NaN where absent).
|
| 183 |
+
|
| 184 |
+
Direct: use DELTA50's atom numbering where it is element-consistent and covers every H/C atom.
|
| 185 |
+
Fallback: for molecules whose atom order differs, match RDKit symmetry classes to DELTA50
|
| 186 |
+
environments by DFT-predicted shift order. Asserts full H/C coverage."""
|
| 187 |
+
experimental = np.full(starts[-1], np.nan)
|
| 188 |
+
fallback = []
|
| 189 |
+
exp = _parse_experimental_sheets(xlsx, molecule_names)
|
| 190 |
+
for i, name in enumerate(molecule_names):
|
| 191 |
+
sl = slice(int(starts[i]), int(starts[i + 1]))
|
| 192 |
+
z = atomic_numbers[sl]
|
| 193 |
+
n = len(z)
|
| 194 |
+
envs = {1: exp[name]["H"], 6: exp[name]["C"]}
|
| 195 |
+
consistent = all(1 <= a <= n and z[a - 1] == zz
|
| 196 |
+
for zz in (1, 6) for atoms, _ in envs[zz] for a in atoms)
|
| 197 |
+
# direct: use DELTA50's numbering. conflict = two environments claim one atom with different
|
| 198 |
+
# shifts (a numbering error, e.g. overlapping ranges); route those to the fallback rather
|
| 199 |
+
# than let a later write silently win.
|
| 200 |
+
direct = np.full(n, np.nan)
|
| 201 |
+
conflict = False
|
| 202 |
+
if consistent:
|
| 203 |
+
for zz in (1, 6):
|
| 204 |
+
for atoms, shift in envs[zz]:
|
| 205 |
+
for a in atoms:
|
| 206 |
+
if np.isfinite(direct[a - 1]) and direct[a - 1] != shift:
|
| 207 |
+
conflict = True
|
| 208 |
+
direct[a - 1] = shift
|
| 209 |
+
if consistent and not conflict and np.isfinite(direct[(z == 1) | (z == 6)]).all():
|
| 210 |
+
experimental[sl] = direct
|
| 211 |
+
continue
|
| 212 |
+
# fallback: RDKit symmetry classes matched to DELTA50 environments by DFT-shift order
|
| 213 |
+
fallback.append(name)
|
| 214 |
+
classes = _symmetry_classes(z, coordinates[sl])
|
| 215 |
+
assigned = np.full(n, np.nan)
|
| 216 |
+
sigma = {1: dft_per_mol[name]["wp04"], 6: dft_per_mol[name]["wb97xd"]}
|
| 217 |
+
for zz in (1, 6):
|
| 218 |
+
element_classes = [atoms for atoms in classes.values() if z[atoms[0]] == zz]
|
| 219 |
+
if len(element_classes) != len(envs[zz]):
|
| 220 |
+
raise ValueError(f"{name}: {len(element_classes)} symmetry classes vs "
|
| 221 |
+
f"{len(envs[zz])} DELTA50 environments for Z={zz}")
|
| 222 |
+
by_sigma = sorted(element_classes, key=lambda a: np.mean([sigma[zz][j] for j in a]))
|
| 223 |
+
by_shift = sorted(envs[zz], key=lambda e: -e[1]) # sigma ascending <-> shift descending
|
| 224 |
+
for atoms, (_, shift) in zip(by_sigma, by_shift):
|
| 225 |
+
for j in atoms:
|
| 226 |
+
assigned[j] = shift
|
| 227 |
+
experimental[sl] = assigned
|
| 228 |
+
|
| 229 |
+
hc = (atomic_numbers == 1) | (atomic_numbers == 6)
|
| 230 |
+
missing = int((~np.isfinite(experimental[hc])).sum())
|
| 231 |
+
if missing:
|
| 232 |
+
raise ValueError(f"{missing} H/C atoms have no experimental shift")
|
| 233 |
+
return experimental, fallback
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def build(src_hdf5, out_dir, xlsx, dst_hdf5):
|
| 237 |
+
pred = load_predictions(src_hdf5)
|
| 238 |
+
file_names = pred["molecule_names"] # raw-input naming (files, sheets)
|
| 239 |
+
names = [RENAMES.get(n, n) for n in file_names] # canonical output naming
|
| 240 |
+
starts = np.zeros(len(names) + 1, np.int64)
|
| 241 |
+
np.cumsum(pred["n_atoms"], out=starts[1:])
|
| 242 |
+
|
| 243 |
+
coords = pred["coordinates"].astype(np.float64) * SCALE # int32 fixed point -> Angstrom
|
| 244 |
+
wp04, wb97xd, dft_per_mol = load_dft_reference(out_dir, file_names, names,
|
| 245 |
+
pred["atomic_numbers"], starts)
|
| 246 |
+
experimental, fallback = map_experimental(xlsx, names, pred["atomic_numbers"], coords, starts,
|
| 247 |
+
dft_per_mol)
|
| 248 |
+
print(f"experimental mapped by symmetry fallback: {fallback}")
|
| 249 |
+
|
| 250 |
+
string_dt = h5py.string_dtype("utf-8")
|
| 251 |
+
opts = dict(compression="gzip", shuffle=True)
|
| 252 |
+
with h5py.File(dst_hdf5, "w") as f:
|
| 253 |
+
f.attrs["dataset"] = "delta50"
|
| 254 |
+
f.attrs["description"] = (
|
| 255 |
+
"The 50 small molecules of the DELTA50 benchmark (Cohen et al., Molecules 2023, 28, "
|
| 256 |
+
"2449; CC BY 4.0) on AIMNet2 geometries. Per-atom arrays: MagNET predictions, the DFT "
|
| 257 |
+
"reference shieldings MagNET-Zero targets (WP04 for 1H, wB97X-D for 13C, pcSseg-2, gas), "
|
| 258 |
+
"and the published experimental shifts mapped onto this atom order. The MagNET-Zero-vs-"
|
| 259 |
+
"DFT residual and the experiment comparison both reproduce from this file.")
|
| 260 |
+
f.attrs["n_molecules"] = len(names)
|
| 261 |
+
f.attrs["geometry"] = "AIMNet2"
|
| 262 |
+
f.attrs["scale"] = SCALE
|
| 263 |
+
f.attrs["missing_value_marker"] = MARKER
|
| 264 |
+
f.attrs["nn_magnet_zero"] = "MagNET-Zero: WP04 (1H) / wB97X-D (13C), pcSseg-2, 20-pass"
|
| 265 |
+
f.attrs["nn_b3lyp"] = "MagNET-PCM gas-phase component: B3LYP/pcSseg-2"
|
| 266 |
+
f.attrs["nn_b3lyp_pcm"] = "MagNET-PCM chloroform component: B3LYP/pcSseg-2 + chloroform PCM"
|
| 267 |
+
f.attrs["shielding_wp04_pcSseg2"] = "DFT reference: WP04/pcSseg-2 gas (the 1H MagNET-Zero level)"
|
| 268 |
+
f.attrs["shielding_wb97xd_pcSseg2"] = "DFT reference: wB97X-D/pcSseg-2 gas (the 13C MagNET-Zero level)"
|
| 269 |
+
f.attrs["experimental_shift"] = (
|
| 270 |
+
"Experimental 1H/13C shift (ppm, CDCl3, TMS reference) from DELTA50 (Cohen et al., "
|
| 271 |
+
"Molecules 2023, 28, 2449; CC BY 4.0), mapped onto this atom order. NaN at atoms with no "
|
| 272 |
+
"reported shift (heteroatoms).")
|
| 273 |
+
|
| 274 |
+
f.create_dataset("molecule_names", data=np.array(names, dtype=object), dtype=string_dt)
|
| 275 |
+
f.create_dataset("n_atoms", data=pred["n_atoms"], **opts)
|
| 276 |
+
f.create_dataset("atomic_numbers", data=pred["atomic_numbers"], **opts)
|
| 277 |
+
f.create_dataset("coordinates", data=pred["coordinates"], **opts)
|
| 278 |
+
f.create_dataset("nn_magnet_zero", data=pred["nn_magnet_zero"], **opts)
|
| 279 |
+
f.create_dataset("nn_b3lyp", data=pred["nn_b3lyp"], **opts)
|
| 280 |
+
f.create_dataset("nn_b3lyp_pcm", data=pred["nn_b3lyp_pcm"], **opts)
|
| 281 |
+
f.create_dataset("shielding_wp04_pcSseg2", data=_encode(wp04), **opts)
|
| 282 |
+
f.create_dataset("shielding_wb97xd_pcSseg2", data=_encode(wb97xd), **opts)
|
| 283 |
+
f.create_dataset("experimental_shift", data=_encode(experimental), **opts)
|
| 284 |
+
|
| 285 |
+
return dst_hdf5
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
if __name__ == "__main__":
|
| 289 |
+
args = sys.argv[1:]
|
| 290 |
+
src = args[0] if len(args) > 0 else DEFAULT_SRC
|
| 291 |
+
out_dir = args[1] if len(args) > 1 else DEFAULT_OUT_DIR
|
| 292 |
+
xlsx = args[2] if len(args) > 2 else DEFAULT_XLSX
|
| 293 |
+
dst = args[3] if len(args) > 3 else DEFAULT_SRC
|
| 294 |
+
print(f"src predictions : {src}")
|
| 295 |
+
print(f"DFT outputs : {out_dir}")
|
| 296 |
+
print(f"experimental : {xlsx}")
|
| 297 |
+
print(f"writing : {dst}")
|
| 298 |
+
build(src, out_dir, xlsx, dst)
|
| 299 |
+
print("done.")
|
data/delta50/decode_delta50.py
CHANGED
|
@@ -2,23 +2,30 @@
|
|
| 2 |
decode_delta50.py - reader/decoder for the delta50 HDF5 dataset.
|
| 3 |
|
| 4 |
DELTA50 is a small benchmark of 50 common organic molecules (reference 47 in the paper). This
|
| 5 |
-
file holds
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
nn_magnet_zero
|
| 9 |
-
|
| 10 |
-
nn_b3lyp
|
| 11 |
-
nn_b3lyp_pcm
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
The MagNET-PCM implicit-solvent correction for a molecule is nn_b3lyp_pcm minus nn_b3lyp. The
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
Storage format:
|
| 18 |
- Per-atom arrays are concatenated across molecules. Molecule i has `n_atoms[i]` atoms and owns
|
| 19 |
the rows [start[i], start[i+1]) where start is the cumulative sum of `n_atoms`.
|
| 20 |
-
- coordinates and
|
| 21 |
-
error is <= 5e-5 (Angstrom or ppm). A missing value
|
|
|
|
| 22 |
- molecules are identified by name (`molecule_names`).
|
| 23 |
|
| 24 |
Requires: Python >= 3.7, numpy, h5py. No other dependencies.
|
|
@@ -67,8 +74,10 @@ class Delta50:
|
|
| 67 |
def molecule(self, index: int) -> dict:
|
| 68 |
"""Return one molecule's data (physical units) by position.
|
| 69 |
|
| 70 |
-
Keys: name; atomic_numbers (n,); coordinates (n, 3 Angstrom);
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
"""
|
| 73 |
self._check(index)
|
| 74 |
sl = slice(int(self._start[index]), int(self._start[index + 1]))
|
|
@@ -79,8 +88,24 @@ class Delta50:
|
|
| 79 |
"nn_magnet_zero": _decode(self.f["nn_magnet_zero"][sl]),
|
| 80 |
"nn_b3lyp": _decode(self.f["nn_b3lyp"][sl]),
|
| 81 |
"nn_b3lyp_pcm": _decode(self.f["nn_b3lyp_pcm"][sl]),
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def molecule_by_name(self, name: str) -> dict:
|
| 85 |
"""Return one molecule's data (see `molecule`) by name."""
|
| 86 |
return self.molecule(self.index_of(name))
|
|
|
|
| 2 |
decode_delta50.py - reader/decoder for the delta50 HDF5 dataset.
|
| 3 |
|
| 4 |
DELTA50 is a small benchmark of 50 common organic molecules (reference 47 in the paper). This
|
| 5 |
+
file holds, per atom on each molecule's AIMNet2 geometry: MagNET's predicted shieldings, the DFT
|
| 6 |
+
reference shieldings MagNET-Zero is trained to reproduce, and the published experimental shifts.
|
| 7 |
+
|
| 8 |
+
nn_magnet_zero MagNET-Zero (WP04 for 1H, wB97X-D for 13C; pcSseg-2; 20-pass), the
|
| 9 |
+
gas-phase triple-zeta-quality prediction
|
| 10 |
+
nn_b3lyp the gas-phase component of the MagNET-PCM correction (B3LYP/pcSseg-2)
|
| 11 |
+
nn_b3lyp_pcm the chloroform component of the MagNET-PCM correction (B3LYP/pcSseg-2 + PCM)
|
| 12 |
+
shielding_wp04_pcSseg2 DFT reference, WP04/pcSseg-2 gas (the level MagNET-Zero targets for 1H)
|
| 13 |
+
shielding_wb97xd_pcSseg2 DFT reference, wB97X-D/pcSseg-2 gas (the level MagNET-Zero targets for 13C)
|
| 14 |
+
experimental_shift experimental 1H/13C shift (ppm, CDCl3, TMS ref) from DELTA50 (Cohen et
|
| 15 |
+
al., Molecules 2023, 28, 2449; CC BY 4.0), mapped onto this atom order
|
| 16 |
|
| 17 |
The MagNET-PCM implicit-solvent correction for a molecule is nn_b3lyp_pcm minus nn_b3lyp. The
|
| 18 |
+
MagNET-Zero-vs-DFT residual (nn_magnet_zero minus shielding_wp04_pcSseg2 at 1H, minus
|
| 19 |
+
shielding_wb97xd_pcSseg2 at 13C) is what the paper uses to probe outliers (nitromethane, nitroethane,
|
| 20 |
+
and 2-methyl-2-nitropropane are the notable ones). experimental_shift is NaN at atoms with no
|
| 21 |
+
reported shift (heteroatoms).
|
| 22 |
|
| 23 |
Storage format:
|
| 24 |
- Per-atom arrays are concatenated across molecules. Molecule i has `n_atoms[i]` atoms and owns
|
| 25 |
the rows [start[i], start[i+1]) where start is the cumulative sum of `n_atoms`.
|
| 26 |
+
- coordinates, shieldings, and shifts are int32 fixed point: physical = stored * 1e-4, so
|
| 27 |
+
reconstruction error is <= 5e-5 (Angstrom or ppm). A missing value is the marker
|
| 28 |
+
-2147483648 -> NaN.
|
| 29 |
- molecules are identified by name (`molecule_names`).
|
| 30 |
|
| 31 |
Requires: Python >= 3.7, numpy, h5py. No other dependencies.
|
|
|
|
| 74 |
def molecule(self, index: int) -> dict:
|
| 75 |
"""Return one molecule's data (physical units) by position.
|
| 76 |
|
| 77 |
+
Keys: name; atomic_numbers (n,); coordinates (n, 3 Angstrom); the three predicted shieldings
|
| 78 |
+
(nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm); the two DFT reference shieldings
|
| 79 |
+
(shielding_wp04_pcSseg2, shielding_wb97xd_pcSseg2); and experimental_shift. Every per-atom
|
| 80 |
+
array is (n,) ppm; experimental_shift is NaN at atoms with no reported shift.
|
| 81 |
"""
|
| 82 |
self._check(index)
|
| 83 |
sl = slice(int(self._start[index]), int(self._start[index + 1]))
|
|
|
|
| 88 |
"nn_magnet_zero": _decode(self.f["nn_magnet_zero"][sl]),
|
| 89 |
"nn_b3lyp": _decode(self.f["nn_b3lyp"][sl]),
|
| 90 |
"nn_b3lyp_pcm": _decode(self.f["nn_b3lyp_pcm"][sl]),
|
| 91 |
+
"shielding_wp04_pcSseg2": _decode(self.f["shielding_wp04_pcSseg2"][sl]),
|
| 92 |
+
"shielding_wb97xd_pcSseg2": _decode(self.f["shielding_wb97xd_pcSseg2"][sl]),
|
| 93 |
+
"experimental_shift": _decode(self.f["experimental_shift"][sl]),
|
| 94 |
}
|
| 95 |
|
| 96 |
+
def all_atoms(self) -> dict:
|
| 97 |
+
"""Every atom in the dataset as flat arrays (physical units), for whole-dataset analyses
|
| 98 |
+
such as the MagNET-Zero-vs-DFT residual or DFT-vs-experiment comparisons.
|
| 99 |
+
|
| 100 |
+
Keys: atomic_numbers (n_total,); nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm,
|
| 101 |
+
shielding_wp04_pcSseg2, shielding_wb97xd_pcSseg2, experimental_shift, each (n_total,) ppm.
|
| 102 |
+
"""
|
| 103 |
+
keys = ("nn_magnet_zero", "nn_b3lyp", "nn_b3lyp_pcm", "shielding_wp04_pcSseg2",
|
| 104 |
+
"shielding_wb97xd_pcSseg2", "experimental_shift")
|
| 105 |
+
out = {"atomic_numbers": self.f["atomic_numbers"][:]}
|
| 106 |
+
out.update({k: _decode(self.f[k][:]) for k in keys})
|
| 107 |
+
return out
|
| 108 |
+
|
| 109 |
def molecule_by_name(self, name: str) -> dict:
|
| 110 |
"""Return one molecule's data (see `molecule`) by name."""
|
| 111 |
return self.molecule(self.index_of(name))
|
data/delta50/delta50.hdf5
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c4a4b5e42fdd2e1f07d0e98bddf4f22cdab7edd9d5d99c728e2a42f082671e2a
|
| 3 |
+
size 47212
|
data/delta50/test_delta50.py
CHANGED
|
@@ -23,8 +23,9 @@ REAL = os.path.join(HERE, "delta50.hdf5")
|
|
| 23 |
|
| 24 |
def _encode(values):
|
| 25 |
v = np.asarray(values, dtype=np.float64)
|
| 26 |
-
|
| 27 |
-
out
|
|
|
|
| 28 |
return out.astype(np.int32)
|
| 29 |
|
| 30 |
|
|
@@ -34,16 +35,21 @@ def _build_synthetic(path):
|
|
| 34 |
names = ["alpha", "beta"]
|
| 35 |
specs = [3, 5] # n_atoms
|
| 36 |
n_atoms = np.array(specs, dtype=np.int32)
|
| 37 |
-
an, coords, zero, b3, pcm, truth = [], [], [], [], [], []
|
| 38 |
for na in specs:
|
| 39 |
a = rng.integers(1, 9, size=na).astype(np.uint8)
|
| 40 |
c = rng.normal(0, 2, size=(na, 3))
|
| 41 |
z = rng.normal(100, 30, size=na)
|
| 42 |
g = rng.normal(100, 30, size=na)
|
| 43 |
p = g + rng.normal(0, 0.2, size=na)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
an.append(a); coords.append(_encode(c)); zero.append(_encode(z))
|
| 45 |
b3.append(_encode(g)); pcm.append(_encode(p))
|
| 46 |
-
|
|
|
|
| 47 |
opts = dict(compression="gzip", shuffle=True)
|
| 48 |
with h5py.File(path, "w") as f:
|
| 49 |
f.attrs["n_molecules"] = 2
|
|
@@ -57,6 +63,9 @@ def _build_synthetic(path):
|
|
| 57 |
f.create_dataset("nn_magnet_zero", data=np.concatenate(zero), **opts)
|
| 58 |
f.create_dataset("nn_b3lyp", data=np.concatenate(b3), **opts)
|
| 59 |
f.create_dataset("nn_b3lyp_pcm", data=np.concatenate(pcm), **opts)
|
|
|
|
|
|
|
|
|
|
| 60 |
return names, specs, truth
|
| 61 |
|
| 62 |
|
|
@@ -68,12 +77,17 @@ def test_reader_and_decode(tmp_path):
|
|
| 68 |
assert ds.molecule_names == names
|
| 69 |
for i, na in enumerate(specs):
|
| 70 |
m = ds.molecule(i)
|
| 71 |
-
a, c, z, g, p = truth[i]
|
| 72 |
assert m["name"] == names[i]
|
| 73 |
assert np.array_equal(m["atomic_numbers"], a)
|
| 74 |
assert m["coordinates"].shape == (na, 3)
|
| 75 |
assert np.abs(m["coordinates"] - c).max() <= 5e-5
|
| 76 |
assert np.abs(m["nn_magnet_zero"] - z).max() <= 5e-5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
assert np.allclose(ds.pcm_correction(i), p - g, atol=1e-4)
|
| 78 |
|
| 79 |
|
|
@@ -98,6 +112,16 @@ def test_real_file_smoke():
|
|
| 98 |
with D.Delta50(REAL) as ds:
|
| 99 |
assert len(ds) == 50
|
| 100 |
assert "nitromethane" in ds.molecule_names
|
|
|
|
|
|
|
|
|
|
| 101 |
m = ds.molecule_by_name("nitromethane")
|
| 102 |
assert np.isfinite(m["nn_magnet_zero"]).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
assert ds.pcm_correction(ds.index_of("nitromethane")).shape == m["atomic_numbers"].shape
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
def _encode(values):
|
| 25 |
v = np.asarray(values, dtype=np.float64)
|
| 26 |
+
finite = np.isfinite(v)
|
| 27 |
+
out = np.full(v.shape, MARKER, dtype=np.int64)
|
| 28 |
+
out[finite] = np.round(v[finite] / SCALE).astype(np.int64)
|
| 29 |
return out.astype(np.int32)
|
| 30 |
|
| 31 |
|
|
|
|
| 35 |
names = ["alpha", "beta"]
|
| 36 |
specs = [3, 5] # n_atoms
|
| 37 |
n_atoms = np.array(specs, dtype=np.int32)
|
| 38 |
+
an, coords, zero, b3, pcm, dwp, dwb, exp, truth = [], [], [], [], [], [], [], [], []
|
| 39 |
for na in specs:
|
| 40 |
a = rng.integers(1, 9, size=na).astype(np.uint8)
|
| 41 |
c = rng.normal(0, 2, size=(na, 3))
|
| 42 |
z = rng.normal(100, 30, size=na)
|
| 43 |
g = rng.normal(100, 30, size=na)
|
| 44 |
p = g + rng.normal(0, 0.2, size=na)
|
| 45 |
+
dw = z + rng.normal(0, 0.1, size=na)
|
| 46 |
+
db = z + rng.normal(0, 0.1, size=na)
|
| 47 |
+
e = rng.normal(5, 2, size=na)
|
| 48 |
+
e[a > 6] = np.nan # heteroatoms have no experimental shift
|
| 49 |
an.append(a); coords.append(_encode(c)); zero.append(_encode(z))
|
| 50 |
b3.append(_encode(g)); pcm.append(_encode(p))
|
| 51 |
+
dwp.append(_encode(dw)); dwb.append(_encode(db)); exp.append(_encode(e))
|
| 52 |
+
truth.append((a, c, z, g, p, dw, db, e))
|
| 53 |
opts = dict(compression="gzip", shuffle=True)
|
| 54 |
with h5py.File(path, "w") as f:
|
| 55 |
f.attrs["n_molecules"] = 2
|
|
|
|
| 63 |
f.create_dataset("nn_magnet_zero", data=np.concatenate(zero), **opts)
|
| 64 |
f.create_dataset("nn_b3lyp", data=np.concatenate(b3), **opts)
|
| 65 |
f.create_dataset("nn_b3lyp_pcm", data=np.concatenate(pcm), **opts)
|
| 66 |
+
f.create_dataset("shielding_wp04_pcSseg2", data=np.concatenate(dwp), **opts)
|
| 67 |
+
f.create_dataset("shielding_wb97xd_pcSseg2", data=np.concatenate(dwb), **opts)
|
| 68 |
+
f.create_dataset("experimental_shift", data=np.concatenate(exp), **opts)
|
| 69 |
return names, specs, truth
|
| 70 |
|
| 71 |
|
|
|
|
| 77 |
assert ds.molecule_names == names
|
| 78 |
for i, na in enumerate(specs):
|
| 79 |
m = ds.molecule(i)
|
| 80 |
+
a, c, z, g, p, dw, db, e = truth[i]
|
| 81 |
assert m["name"] == names[i]
|
| 82 |
assert np.array_equal(m["atomic_numbers"], a)
|
| 83 |
assert m["coordinates"].shape == (na, 3)
|
| 84 |
assert np.abs(m["coordinates"] - c).max() <= 5e-5
|
| 85 |
assert np.abs(m["nn_magnet_zero"] - z).max() <= 5e-5
|
| 86 |
+
assert np.abs(m["shielding_wp04_pcSseg2"] - dw).max() <= 5e-5
|
| 87 |
+
assert np.abs(m["shielding_wb97xd_pcSseg2"] - db).max() <= 5e-5
|
| 88 |
+
finite = np.isfinite(e)
|
| 89 |
+
assert np.abs(m["experimental_shift"][finite] - e[finite]).max() <= 5e-5
|
| 90 |
+
assert np.isnan(m["experimental_shift"][~finite]).all()
|
| 91 |
assert np.allclose(ds.pcm_correction(i), p - g, atol=1e-4)
|
| 92 |
|
| 93 |
|
|
|
|
| 112 |
with D.Delta50(REAL) as ds:
|
| 113 |
assert len(ds) == 50
|
| 114 |
assert "nitromethane" in ds.molecule_names
|
| 115 |
+
# the DELTA50 nitrate-ester misnomer is corrected to the C-nitro name here
|
| 116 |
+
assert "2-methyl-2-nitropropane" in ds.molecule_names
|
| 117 |
+
assert "t-butyl nitrate" not in ds.molecule_names
|
| 118 |
m = ds.molecule_by_name("nitromethane")
|
| 119 |
assert np.isfinite(m["nn_magnet_zero"]).all()
|
| 120 |
+
for key in ("shielding_wp04_pcSseg2", "shielding_wb97xd_pcSseg2", "experimental_shift"):
|
| 121 |
+
assert m[key].shape == m["atomic_numbers"].shape
|
| 122 |
+
# nitromethane: WP04 at H, wB97X-D at C, and an experimental shift on every H and C
|
| 123 |
+
z = m["atomic_numbers"]
|
| 124 |
+
assert np.isfinite(m["experimental_shift"][(z == 1) | (z == 6)]).all()
|
| 125 |
assert ds.pcm_correction(ds.index_of("nitromethane")).shape == m["atomic_numbers"].shape
|
| 126 |
+
atoms = ds.all_atoms()
|
| 127 |
+
assert atoms["atomic_numbers"].shape == atoms["experimental_shift"].shape
|