File size: 10,415 Bytes
ef53368 fe82c54 ef53368 fe82c54 ef53368 fe82c54 ef53368 fe82c54 ef53368 fe82c54 ef53368 fe82c54 ef53368 fe82c54 ef53368 fe82c54 ef53368 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | """Recommended Linear Scaling Parameters for MagNET-Zero / MagNET-PCM (SI Tables S10 and S11).
Turns MagNET-Zero shieldings into chemical shifts via a per-solvent linear model:
shift = c_intercept + c_stationary * sigma_zero + c_pcm * delta_pcm
where sigma_zero is the MagNET-Zero gas-phase shielding and delta_pcm the MagNET-PCM chloroform
correction. Proton (S10) uses a THREE-parameter model (all coefficients free) because for benzene and
toluene the PCM correction points the wrong way and only a free c_pcm can go positive. Carbon (S11)
uses a TWO-parameter model: the gas and PCM terms share one per-solvent slope (PCM scaled to solvent
by a conversion factor first), which fit held-out carbons slightly better; the reported `pcm` is that
slope times the conversion factor.
The published tables are REFLECTION-SYMMETRIZED: each shielding is averaged over 20 forward passes
with the geometry mirrored for half (n_passes=10, symmetrize=True), cancelling the SO(3)-only model's
reflection-parity error. They ship verbatim in
data/scaling_factors/scaling_factors_symmetrized_{H,C}.csv; published_scaling_tables() returns them,
and build_scaling_tables(symmetrized=True) reproduces them from live inference (needs checkpoints).
build_scaling_tables(symmetrized=False) fits the raw single-pass shieldings in delta22.hdf5 instead
and lands ~0.01 ppm off. test_scaling_factors.py locks this down.
"""
import os
import sys
import numpy as np
import pandas as pd
from paths import repo_root, ensure_on_path, dataset_file
# paths.py is the bootstrap at analysis/code/ root; the other shared utilities (stats, etc.) live in
# analysis/code/shared/, and the delta-22 reader/harness is reused from data/delta22. Put all of
# these on sys.path before importing them, so scaling_factors can be imported standalone (as its
# docstring invites), not only under pytest/notebooks that already pre-wire the paths.
_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO = repo_root(__file__)
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
ensure_on_path("analysis", "code", "shared", file=__file__)
ensure_on_path("data", "delta22", file=__file__)
from stats import linear_fit_1d # noqa: E402
import delta22 as D # noqa: E402
# The 12 solvents, in the reader's native naming (water is stored as "TIP4P").
SOLVENTS = tuple(D.DESMOND_SOLVENTS)
# Nitromethane is an outlier for both nuclei (its carbon shift is anomalous); the published tables
# reproduce only with it excluded throughout.
EXCLUDE_SOLUTES = ("nitromethane",)
# The model form recommended for each nucleus.
RECOMMENDED_MODEL = {"H": "three_parameter", "C": "two_parameter"}
_COLUMNS = ["intercept", "stationary", "pcm"]
# published_scaling_tables() reads the shipped SI tables from here (provenance in the module docstring).
_SYMMETRIZED_CSV_DIR = os.path.join(_REPO, "data", "scaling_factors")
def published_scaling_tables():
"""The published SI scaling tables, {"H": Table S10, "C": Table S11}, as solvent-indexed
DataFrames (columns intercept / stationary / pcm). Read from the shipped CSVs, so no delta-22
download is needed; pass to predict_shift to turn MagNET-Zero/PCM shieldings into shifts. See the
module docstring for provenance."""
tables = {}
for nucleus in ("H", "C"):
path = os.path.join(_SYMMETRIZED_CSV_DIR, f"scaling_factors_symmetrized_{nucleus}.csv")
tables[nucleus] = pd.read_csv(path).set_index("solvent")[_COLUMNS]
return tables
def proton_scaling_table(query_df_nn, solvents=SOLVENTS, exclude_solutes=EXCLUDE_SOLUTES):
"""SI Table S10: per-solvent [intercept, stationary, pcm] for the proton three-parameter model.
Fits experimental ~ stationary + pcm freely for each solvent on the MagNET-Zero shielding and the
MagNET-PCM (chloroform) correction. Returns a DataFrame indexed by solvent.
"""
nn = query_df_nn[(query_df_nn["nucleus"] == "H")
& ~query_df_nn["solute"].isin(set(exclude_solutes))]
coeffs = D.full_fit_coefficients(nn, list(solvents), "stationary + pcm")
table = coeffs.T.rename(columns={"Intercept": "intercept"})[_COLUMNS]
table.index.name = "solvent"
return table
def carbon_conversion_factors(query_df_dft):
"""The per-solvent PCM conversion factor for carbon (delta22.pcm_conversion_factors): for each
solvent, the through-the-origin slope of that solvent's PCM correction (at the level MagNET-Zero
reproduces, wB97X-D) against chloroform's B3LYP-D3(BJ) correction (the level MagNET-PCM
reproduces), from the delta-22 DFT PCM data. So one factor converts both chloroform to the solvent
and B3LYP to wB97X-D. A Series indexed by solvent."""
return D.pcm_conversion_factors(query_df_dft, "C")
def carbon_scaling_table(query_df_nn, query_df_dft=None, solvents=SOLVENTS,
exclude_solutes=EXCLUDE_SOLUTES, conversion_factors=None):
"""SI Table S11: per-solvent [intercept, stationary, pcm] for the carbon two-parameter model.
For each solvent, fits experimental ~ (stationary + factor * pcm) with a single shared slope,
where `factor` is the per-solvent PCM conversion factor. The reported `stationary` is that slope
and the reported `pcm` is slope * factor, so the prediction equation in the module docstring
applies with MagNET-PCM's chloroform correction. The factors come from query_df_dft
(carbon_conversion_factors) unless passed in as `conversion_factors` (a Series or dict). A solvent
with fewer than two usable sites, or an undefined conversion factor, gets a row of NaN rather than
a fake zero fit. Returns a DataFrame indexed by solvent.
"""
if conversion_factors is None:
if query_df_dft is None:
raise ValueError("provide query_df_dft or conversion_factors")
conversion_factors = carbon_conversion_factors(query_df_dft)
nn = query_df_nn[(query_df_nn["nucleus"] == "C")
& ~query_df_nn["solute"].isin(set(exclude_solutes))]
rows = {}
for solvent in solvents:
factor = float(conversion_factors[solvent])
sub = nn[nn["solvent"] == solvent]
y = pd.to_numeric(sub["experimental"], errors="coerce").to_numpy(float)
x = sub["stationary"].to_numpy(float) + factor * sub["pcm"].to_numpy(float)
keep = np.isfinite(x) & np.isfinite(y)
if not np.isfinite(factor) or keep.sum() < 2:
rows[solvent] = {"intercept": np.nan, "stationary": np.nan, "pcm": np.nan}
continue
intercept, slope = linear_fit_1d(x[keep], y[keep])
rows[solvent] = {"intercept": intercept, "stationary": slope, "pcm": slope * factor}
table = pd.DataFrame.from_dict(rows, orient="index")[_COLUMNS]
table.index.name = "solvent"
return table
def build_scaling_tables(delta22_path, experimental_path, symmetrized=False, n_passes=10):
"""Re-derive both recommended-scaling tables from the released delta-22 data.
symmetrized=True reproduces the published SI Tables S10/S11 the way they were generated: live,
reflection-symmetrized inference (scaling_factors_symmetrized.py). Needs the magnet package and
model checkpoints, and is slow (re-runs inference on all 22 solutes).
symmetrized=False (default) fits the raw single-pass MagNET-Zero/PCM shieldings stored in the
HDF5, which carry the reflection-parity error the published tables correct, so it lands ~0.01 ppm
off the published SI. Use it for a checkpoint-free re-derivation.
Returns {"H": Table S10 DataFrame, "C": Table S11 DataFrame}.
"""
if symmetrized:
import scaling_factors_symmetrized as _S
override_df = _S.compute_symmetrized_nn_shieldings_df(delta22_path, n_passes=n_passes,
verbose=False)
nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False,
nn_shieldings_override_df=override_df)
else:
nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False)
dft = D.load_query_df_dft(delta22_path, experimental_path, verbose=False)
return {"H": proton_scaling_table(nn), "C": carbon_scaling_table(nn, dft)}
def predict_shift(table, solvent, magnet_zero_shielding, magnet_pcm_chloroform_correction):
"""Apply a scaling table to MagNET-Zero / MagNET-PCM outputs to predict a chemical shift.
table: a proton or carbon scaling table (DataFrame indexed by solvent). Get one from
published_scaling_tables()[nucleus] (no data download) or build_scaling_tables(...)[nucleus]
(re-derived from delta-22).
solvent: the solvent name (water is "TIP4P").
magnet_zero_shielding: the MagNET-Zero gas-phase shielding (scalar or array).
magnet_pcm_chloroform_correction: MagNET-PCM's chloroform correction (scalar or array).
The same equation serves both nuclei because the stored `pcm` coefficient already folds in the
per-solvent scaling, so you always pass the chloroform correction.
End to end, from a geometry (the H/C shieldings each model returns are per-atom arrays):
from magnet.run_magnet import compute_MagNET_Zero_shieldings, compute_MagNET_PCM_corrections
zero = compute_MagNET_Zero_shieldings([Z], [xyz])[0] # MagNET-Zero shieldings
pcm = compute_MagNET_PCM_corrections([Z], [xyz])[0] # MagNET-PCM chloroform correction
tables = published_scaling_tables()
carbons = Z == 6
shifts_13C = predict_shift(tables["C"], "benzene", zero[carbons], pcm[carbons])
"""
row = table.loc[solvent]
return (row["intercept"]
+ row["stationary"] * np.asarray(magnet_zero_shielding, dtype=float)
+ row["pcm"] * np.asarray(magnet_pcm_chloroform_correction, dtype=float))
if __name__ == "__main__":
h5 = dataset_file("delta22", root=_REPO)
xlsx = os.path.join(_REPO, "data", "delta22", "delta22_experimental.xlsx")
# symmetrized=False: the checkpoint-free raw-data fit, within ~0.01 ppm of the published SI
# (published_scaling_tables() returns the exact symmetrized SI values).
tables = build_scaling_tables(h5, xlsx)
for nucleus, label in (("H", "Table S10 (1H)"), ("C", "Table S11 (13C)")):
print(f"\n=== {label} (unsymmetrized re-derivation): {RECOMMENDED_MODEL[nucleus]} model ===")
print(tables[nucleus].round(6).to_string())
|