MagNET / data /delta50 /build_delta50.py
ekwan16's picture
sync code from github@6f6c7d83845ee0307e16b2b9bb1b389c5bfc5007
23c6f41 verified
Raw
History Blame Contribute Delete
15.3 kB
"""Provenance/build script for delta50.hdf5.
Regenerates the shipped delta50.hdf5 from three raw sources (none of which are shipped, exactly as
for dft8k):
1. the previous delta50.hdf5, for the MagNET predictions already packaged into it
(nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm; these come from MagNET inference and are carried
forward unchanged), plus the atom order, geometries, and atomic numbers;
2. a directory of Gaussian NMR output files, one per molecule, each with two link jobs at the
MagNET-Zero reference levels (WP04 for 1H, wB97X-D for 13C; pcSseg-2; gas), computed on the
stored AIMNet2 geometries. Parsed with cctk. These become the per-atom DFT reference
shieldings shielding_wp04_pcSseg2 and shielding_wb97xd_pcSseg2, so the MagNET-Zero-vs-DFT
residual (Table S3/S4-style, the DELTA50 outlier check in the paper) reproduces from the file.
3. DELTA50_benchmark.xlsx from the DELTA50 Supporting Information (Cohen et al., Molecules 2023,
28, 2449; reference 47; CC BY 4.0), for the experimental 1H and 13C chemical shifts.
The experimental shifts are mapped onto this file's own atom order and stored per atom as
experimental_shift. DELTA50 numbers its atoms against independently built structures; those geometries
were recomputed here, so the numbering only sometimes lines up. Each molecule is mapped by DELTA50's
own atom numbering when that numbering is element-consistent and covers every H/C atom (47 of 50);
the three whose atom order differs (cyclohexanone, methyl acetate, 2-methyl-2-nitropropane) are
mapped by matching RDKit topological-symmetry classes to DELTA50 environments in order of the
DFT-predicted shift. The mapping is validated: coverage is 100% of H and C atoms, and the large
scaled-DFT-vs-experiment residuals are all chemically hard sp2 carbons (carbonyl, nitro, aromatic).
Usage:
python3 build_delta50.py [SRC_HDF5] [OUT_DIR] [XLSX] [DST_HDF5]
with the defaults below. Requires numpy, h5py, pandas, cctk, and rdkit.
"""
from __future__ import annotations
import os
import re
import sys
from collections import defaultdict
import numpy as np
import h5py
SCALE = 1e-4
MARKER = -2147483648
_HOME = os.path.expanduser("~")
_HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_SRC = os.path.join(_HERE, "delta50.hdf5")
DEFAULT_OUT_DIR = os.path.join(_HOME, "research", "magnet", "delta50", "delta50",
"output", "wp04_wb97xd_pcSseg2")
DEFAULT_XLSX = os.path.join(_HOME, "research", "magnet", "delta50", "DELTA50_benchmark.xlsx")
# Molecules whose canonical name here differs from the one in the previous file and the raw inputs.
# DELTA50 (and the old file) mislabel 2-methyl-2-nitropropane, a C-nitro compound, as a nitrate ester.
RENAMES = {"t-butyl nitrate": "2-methyl-2-nitropropane"}
# DELTA50 spreadsheet compound names that differ from our molecule_names. "3,3-dimethyl-1-butene"
# is split across two cells ("(3,3-Dimethyl-" then "1-butene)"), so both fragments are aliased.
NAME_ALIASES = {
"dmf": "n,n-dimethylformamide", "thf": "tetrahydrofuran", "dmac": "n,n-dimethylacetamide",
"2cyanopropane": "isobutyronitrile", "mtbe": "methyl t-butyl ether",
"cyclopentenone": "cyclopent-2-en-1-one", "cyclohexenone": "cyclohex-2-en-1-one",
"pbenzoquinone": "1,4-benzoquinone", "thp": "tetrahydropyran",
"33dimethyl": "t-butylethylene", "1butene": "t-butylethylene",
"tbutylnitrate": "2-methyl-2-nitropropane",
}
def _norm(s):
return re.sub(r"[^a-z0-9]", "", str(s).lower())
def _encode(values):
v = np.asarray(values, dtype=np.float64)
finite = np.isfinite(v)
out = np.full(v.shape, MARKER, dtype=np.int64)
out[finite] = np.round(v[finite] / SCALE).astype(np.int64)
return out.astype(np.int32)
def _parse_atom_labels(s):
"""DELTA50 atom-number strings: comma lists ("5,6,7"), ranges ("7-12"), singles, and mixtures."""
out = []
for tok in re.split(r"[,\s]+", str(s).strip()):
m = re.match(r"^(\d+)-(\d+)$", tok)
if m:
out += list(range(int(m.group(1)), int(m.group(2)) + 1))
elif tok.isdigit():
out.append(int(tok))
return out
def load_predictions(src_hdf5):
with h5py.File(src_hdf5, "r") as f:
return {
"molecule_names": [str(n) for n in f["molecule_names"].asstr()[:]],
"n_atoms": f["n_atoms"][:].astype(np.int32),
"atomic_numbers": f["atomic_numbers"][:],
"coordinates": f["coordinates"][:],
"nn_magnet_zero": f["nn_magnet_zero"][:],
"nn_b3lyp": f["nn_b3lyp"][:],
"nn_b3lyp_pcm": f["nn_b3lyp_pcm"][:],
}
def load_dft_reference(out_dir, file_names, out_names, atomic_numbers, starts):
"""Per-atom WP04 and wB97X-D isotropic shieldings from the Gaussian outputs, aligned to the
stored atom order (checked). Files are matched by `file_names` (the raw-input naming) but the
returned per-molecule dict is keyed by `out_names` (the canonical output naming).
Returns (wp04, wb97xd, per_molecule_shieldings)."""
import cctk
files = {_norm(f[len("delta50-"):-len(".out")]): os.path.join(out_dir, f)
for f in os.listdir(out_dir) if f.endswith(".out")}
# the Gaussian files keep the pre-rename naming; fall back through RENAMES so re-running the
# build against its own (already-renamed) output still finds each file.
reverse = {v: k for k, v in RENAMES.items()}
def _find(name):
for candidate in (name, reverse.get(name, name)):
if _norm(candidate) in files:
return files[_norm(candidate)]
raise KeyError(f"no Gaussian output for {name!r} in {out_dir}")
wp04 = np.full(starts[-1], np.nan)
wb97xd = np.full(starts[-1], np.nan)
per_mol = {}
for i, (file_name, out_name) in enumerate(zip(file_names, out_names)):
objs = cctk.GaussianFile.read_file(_find(file_name))
by_level = {}
for g in objs:
ens = g.ensemble
iso = np.asarray(ens.get_properties_dict(ens.molecules[-1])["isotropic_shielding"],
dtype=float)
z = np.asarray(ens.molecules[-1].atomic_numbers)
by_level["wb97xd" if "wb97xd" in g.route_card.lower() else "wp04"] = (z, iso)
sl = slice(int(starts[i]), int(starts[i + 1]))
z_ref = atomic_numbers[sl]
for level, (z, iso) in by_level.items():
if not np.array_equal(z, z_ref):
raise ValueError(f"{out_name}: {level} atom order does not match the stored geometry")
wp04[sl] = by_level["wp04"][1]
wb97xd[sl] = by_level["wb97xd"][1]
per_mol[out_name] = {"wp04": by_level["wp04"][1], "wb97xd": by_level["wb97xd"][1]}
return wp04, wb97xd, per_mol
def _parse_experimental_sheets(xlsx, valid_names):
"""{molecule_name: {"H": [(atoms, shift)], "C": [...]}} from the two DELTA50 functional sheets."""
import pandas as pd
valid = {_norm(n): n for n in valid_names}
out = {n: {"H": [], "C": []} for n in valid_names}
for sheet, nucleus in (("1H Functional", "H"), ("13C Functional", "C")):
df = pd.read_excel(xlsx, sheet_name=sheet, header=None)
hdr = next(r for r in range(df.shape[0]) if str(df.iat[r, 0]).strip() == "Compound")
current = None
for r in range(hdr + 1, df.shape[0]):
compound, label, shift = df.iat[r, 0], df.iat[r, 1], df.iat[r, 2]
if isinstance(compound, str) and compound.strip():
k = _norm(compound)
current = valid.get(_norm(NAME_ALIASES.get(k, compound)))
if current is None or pd.isna(label) or pd.isna(shift):
continue
out[current][nucleus].append((_parse_atom_labels(label), float(shift)))
return out
def _symmetry_classes(atomic_numbers, coordinates):
"""RDKit topological-symmetry classes for one molecule: {rank: [atom indices]}. Bonds are
perceived from the geometry."""
from rdkit import Chem
from rdkit.Chem import rdDetermineBonds
pt = Chem.GetPeriodicTable()
lines = [str(len(atomic_numbers)), ""]
for z, xyz in zip(atomic_numbers, coordinates):
lines.append(f"{pt.GetElementSymbol(int(z))} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
mol = Chem.MolFromXYZBlock("\n".join(lines))
rdDetermineBonds.DetermineBonds(mol, charge=0)
classes = defaultdict(list)
for atom_idx, rank in enumerate(Chem.CanonicalRankAtoms(mol, breakTies=False)):
classes[rank].append(atom_idx)
return classes
def map_experimental(xlsx, molecule_names, atomic_numbers, coordinates, starts, dft_per_mol):
"""Map DELTA50 experimental shifts onto our atom order; per-atom array (ppm, NaN where absent).
Direct: use DELTA50's atom numbering where it is element-consistent and covers every H/C atom.
Fallback: for molecules whose atom order differs, match RDKit symmetry classes to DELTA50
environments by DFT-predicted shift order. Asserts full H/C coverage."""
experimental = np.full(starts[-1], np.nan)
fallback = []
exp = _parse_experimental_sheets(xlsx, molecule_names)
for i, name in enumerate(molecule_names):
sl = slice(int(starts[i]), int(starts[i + 1]))
z = atomic_numbers[sl]
n = len(z)
envs = {1: exp[name]["H"], 6: exp[name]["C"]}
consistent = all(1 <= a <= n and z[a - 1] == zz
for zz in (1, 6) for atoms, _ in envs[zz] for a in atoms)
# direct: use DELTA50's numbering. conflict = two environments claim one atom with different
# shifts (a numbering error, e.g. overlapping ranges); route those to the fallback rather
# than let a later write silently win.
direct = np.full(n, np.nan)
conflict = False
if consistent:
for zz in (1, 6):
for atoms, shift in envs[zz]:
for a in atoms:
if np.isfinite(direct[a - 1]) and direct[a - 1] != shift:
conflict = True
direct[a - 1] = shift
if consistent and not conflict and np.isfinite(direct[(z == 1) | (z == 6)]).all():
experimental[sl] = direct
continue
# fallback: RDKit symmetry classes matched to DELTA50 environments by DFT-shift order
fallback.append(name)
classes = _symmetry_classes(z, coordinates[sl])
assigned = np.full(n, np.nan)
sigma = {1: dft_per_mol[name]["wp04"], 6: dft_per_mol[name]["wb97xd"]}
for zz in (1, 6):
element_classes = [atoms for atoms in classes.values() if z[atoms[0]] == zz]
if len(element_classes) != len(envs[zz]):
raise ValueError(f"{name}: {len(element_classes)} symmetry classes vs "
f"{len(envs[zz])} DELTA50 environments for Z={zz}")
by_sigma = sorted(element_classes, key=lambda a: np.mean([sigma[zz][j] for j in a]))
by_shift = sorted(envs[zz], key=lambda e: -e[1]) # sigma ascending <-> shift descending
for atoms, (_, shift) in zip(by_sigma, by_shift):
for j in atoms:
assigned[j] = shift
experimental[sl] = assigned
hc = (atomic_numbers == 1) | (atomic_numbers == 6)
missing = int((~np.isfinite(experimental[hc])).sum())
if missing:
raise ValueError(f"{missing} H/C atoms have no experimental shift")
return experimental, fallback
def build(src_hdf5, out_dir, xlsx, dst_hdf5):
pred = load_predictions(src_hdf5)
file_names = pred["molecule_names"] # raw-input naming (files, sheets)
names = [RENAMES.get(n, n) for n in file_names] # canonical output naming
starts = np.zeros(len(names) + 1, np.int64)
np.cumsum(pred["n_atoms"], out=starts[1:])
coords = pred["coordinates"].astype(np.float64) * SCALE # int32 fixed point -> Angstrom
wp04, wb97xd, dft_per_mol = load_dft_reference(out_dir, file_names, names,
pred["atomic_numbers"], starts)
experimental, fallback = map_experimental(xlsx, names, pred["atomic_numbers"], coords, starts,
dft_per_mol)
print(f"experimental mapped by symmetry fallback: {fallback}")
string_dt = h5py.string_dtype("utf-8")
opts = dict(compression="gzip", shuffle=True)
with h5py.File(dst_hdf5, "w") as f:
f.attrs["dataset"] = "delta50"
f.attrs["description"] = (
"The 50 small molecules of the DELTA50 benchmark (Cohen et al., Molecules 2023, 28, "
"2449; CC BY 4.0) on AIMNet2 geometries. Per-atom arrays: MagNET predictions, the DFT "
"reference shieldings MagNET-Zero targets (WP04 for 1H, wB97X-D for 13C, pcSseg-2, gas), "
"and the published experimental shifts mapped onto this atom order. The MagNET-Zero-vs-"
"DFT residual and the experiment comparison both reproduce from this file.")
f.attrs["n_molecules"] = len(names)
f.attrs["geometry"] = "AIMNet2"
f.attrs["scale"] = SCALE
f.attrs["missing_value_marker"] = MARKER
f.attrs["nn_magnet_zero"] = "MagNET-Zero: WP04 (1H) / wB97X-D (13C), pcSseg-2, 20-pass"
f.attrs["nn_b3lyp"] = "MagNET-PCM gas-phase component: B3LYP/pcSseg-2"
f.attrs["nn_b3lyp_pcm"] = "MagNET-PCM chloroform component: B3LYP/pcSseg-2 + chloroform PCM"
f.attrs["shielding_wp04_pcSseg2"] = "DFT reference: WP04/pcSseg-2 gas (the 1H MagNET-Zero level)"
f.attrs["shielding_wb97xd_pcSseg2"] = "DFT reference: wB97X-D/pcSseg-2 gas (the 13C MagNET-Zero level)"
f.attrs["experimental_shift"] = (
"Experimental 1H/13C shift (ppm, CDCl3, TMS reference) from DELTA50 (Cohen et al., "
"Molecules 2023, 28, 2449; CC BY 4.0), mapped onto this atom order. NaN at atoms with no "
"reported shift (heteroatoms).")
f.create_dataset("molecule_names", data=np.array(names, dtype=object), dtype=string_dt)
f.create_dataset("n_atoms", data=pred["n_atoms"], **opts)
f.create_dataset("atomic_numbers", data=pred["atomic_numbers"], **opts)
f.create_dataset("coordinates", data=pred["coordinates"], **opts)
f.create_dataset("nn_magnet_zero", data=pred["nn_magnet_zero"], **opts)
f.create_dataset("nn_b3lyp", data=pred["nn_b3lyp"], **opts)
f.create_dataset("nn_b3lyp_pcm", data=pred["nn_b3lyp_pcm"], **opts)
f.create_dataset("shielding_wp04_pcSseg2", data=_encode(wp04), **opts)
f.create_dataset("shielding_wb97xd_pcSseg2", data=_encode(wb97xd), **opts)
f.create_dataset("experimental_shift", data=_encode(experimental), **opts)
return dst_hdf5
if __name__ == "__main__":
args = sys.argv[1:]
src = args[0] if len(args) > 0 else DEFAULT_SRC
out_dir = args[1] if len(args) > 1 else DEFAULT_OUT_DIR
xlsx = args[2] if len(args) > 2 else DEFAULT_XLSX
dst = args[3] if len(args) > 3 else DEFAULT_SRC
print(f"src predictions : {src}")
print(f"DFT outputs : {out_dir}")
print(f"experimental : {xlsx}")
print(f"writing : {dst}")
build(src, out_dir, xlsx, dst)
print("done.")