MagNET / analysis /code /scaling_factors.py
ekwan16's picture
sync code from github@2bc62543318f07e5f8b641923b7789a8438d06bd
fe82c54 verified
Raw
History Blame Contribute Delete
10.4 kB
"""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())