Add files using upload-large-folder tool
Browse files- analysis/code/applications.py +549 -0
- analysis/code/applications_plots.py +752 -0
- analysis/code/build_composite_model.py +290 -0
- analysis/code/build_nb_applications.py +282 -0
- analysis/code/build_nb_composite_models.py +124 -0
- analysis/code/build_nb_delta22.py +798 -0
- analysis/code/build_nb_magnet_benchmark.py +159 -0
- analysis/code/build_nb_scaling.py +126 -0
- analysis/code/composite_models.py +395 -0
- analysis/code/composite_plots.py +41 -0
- analysis/code/convergence_plot.py +621 -0
- analysis/code/dataset_summary.py +78 -0
- analysis/code/delta22.py +927 -0
- analysis/code/dft8k_residuals.py +216 -0
- analysis/code/diels_alder.py +137 -0
- analysis/code/fig2b_plots.py +52 -0
- analysis/code/fig3_plots.py +160 -0
- analysis/code/fig4_plots.py +71 -0
- analysis/code/fig5b_plots.py +76 -0
- analysis/code/leveling.py +322 -0
- analysis/code/leveling_plots.py +195 -0
- analysis/code/magnet_benchmark.py +327 -0
- analysis/code/magnet_benchmark_run.py +121 -0
- analysis/code/nb_build.py +28 -0
- analysis/code/pareto_plot.py +198 -0
- analysis/code/paths.py +64 -0
- analysis/code/scaling_factors.py +220 -0
- analysis/code/scaling_factors_symmetrized.py +113 -0
- analysis/code/test_applications_analysis.py +424 -0
- analysis/code/test_carbon_monoxide.py +41 -0
- analysis/code/test_composite_model.py +124 -0
- analysis/code/test_composite_models.py +178 -0
- analysis/code/test_delta22_analysis.py +916 -0
- analysis/code/test_dft8k_residuals.py +137 -0
- analysis/code/test_leveling.py +233 -0
- analysis/si_figures/si_figure_s03_leveling.ipynb +386 -0
- analysis/si_figures/si_figure_s11.ipynb +139 -0
- analysis/si_figures/si_figure_s12_carbon_monoxide.ipynb +126 -0
- analysis/si_figures/si_figure_s13.ipynb +173 -0
- analysis/si_figures/si_figure_s14.ipynb +157 -0
analysis/code/applications.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core analysis for the natural-products ("applications") figures (Figures 5C/5D, SI S8, S15). No plotting.
|
| 2 |
+
|
| 3 |
+
Takes an Applications loader (data/applications/applications_reader.py) and computes the numbers the
|
| 4 |
+
figure notebooks and tests share. A "composite model" predicts a shift as an OLS-weighted sum of
|
| 5 |
+
physics terms (gas-phase shielding + implicit correction + rovibrational correction); these figures
|
| 6 |
+
test how well weights fit on delta-22 carry to the natural-products test set.
|
| 7 |
+
"""
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import statsmodels.formula.api as smf
|
| 11 |
+
|
| 12 |
+
from stats import rmse as _rmse
|
| 13 |
+
from spreadsheet import site_atom_indices
|
| 14 |
+
from applications_reader import SOLVENTS, SHELL_SIZES
|
| 15 |
+
|
| 16 |
+
# composite-model formula sets (column names refer to build_query_df_nn output)
|
| 17 |
+
FITTING_FORMULAS_H = [
|
| 18 |
+
"stationary", "stationary_plus_pcm", "stationary + pcm", "stationary + pcm + qcd",
|
| 19 |
+
"stationary_plus_qcd + pcm", "stationary_plus_pcm_plus_qcd", "stationary + openMM",
|
| 20 |
+
"stationary + openMM + qcd", "stationary_plus_qcd + openMM", "stationary_plus_openMM_plus_qcd",
|
| 21 |
+
]
|
| 22 |
+
FITTING_FORMULAS_C = [
|
| 23 |
+
"stationary", "stationary_plus_pcm", "stationary + pcm", "stationary + pcm + openMM_vib",
|
| 24 |
+
"stationary_plus_op_vib + pcm", "stationary_plus_pcm_plus_op_vib", "stationary + openMM",
|
| 25 |
+
"stationary + openMM + openMM_vib", "stationary_plus_op_vib + openMM", "stationary_plus_openMM_plus_op_vib",
|
| 26 |
+
]
|
| 27 |
+
FORMULA_REMAP = {
|
| 28 |
+
"stationary_plus_pcm": "pcm2", "stationary + pcm": "pcm3", "stationary + pcm + qcd": "pcm4",
|
| 29 |
+
"stationary + pcm + openMM_vib": "pcm4", "stationary_plus_qcd + pcm": "semi_parsimonious_pcm",
|
| 30 |
+
"stationary_plus_op_vib + pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_qcd": "parsimonious_pcm",
|
| 31 |
+
"stationary_plus_pcm_plus_op_vib": "parsimonious_pcm", "stationary + openMM": "expl3",
|
| 32 |
+
"stationary + openMM + qcd": "expl4", "stationary + openMM + openMM_vib": "expl4",
|
| 33 |
+
"stationary_plus_qcd + openMM": "semi_parsimonious_expl", "stationary_plus_op_vib + openMM": "semi_parsimonious_expl",
|
| 34 |
+
"stationary_plus_openMM_plus_qcd": "parsimonious_expl", "stationary_plus_openMM_plus_op_vib": "parsimonious_expl",
|
| 35 |
+
}
|
| 36 |
+
# manuscript test-set membership, keyed by the canonical solute names build_query_df_nn emits
|
| 37 |
+
ALL_IN_ONE_BIN = {"Test Set": [
|
| 38 |
+
"isomer_1E", "isomer_1Z", "isomer_2E", "isomer_2Z", "isomer_3E", "isomer_3Z", "isomer_4N", "isomer_4O",
|
| 39 |
+
"vomicine", "prednisone", "peptide", "flavone", "dihydrotanshinone_I",
|
| 40 |
+
]}
|
| 41 |
+
# canonical solute -> manuscript display label (used as figure x-tick labels)
|
| 42 |
+
SOLUTE_DISPLAY = {
|
| 43 |
+
"isomer_1E": "Isomer 1 (E)", "isomer_1Z": "Isomer 1 (Z)", "isomer_2E": "Isomer 2 (E)",
|
| 44 |
+
"isomer_2Z": "Isomer 2 (Z)", "isomer_3E": "Isomer 3 (E)", "isomer_3Z": "Isomer 3 (Z)",
|
| 45 |
+
"isomer_4N": "1,4-dimethylpyridin-\n2(1H)one", "isomer_4O": "2-methoxy-\n4-methylpyridine",
|
| 46 |
+
"vomicine": "Vomicine", "prednisone": "Prednisone", "peptide": "Acetyl-L-alanyl-L-\nglutamine",
|
| 47 |
+
"flavone": "Flavone", "dihydrotanshinone_I": "Dihydrotanshinone I",
|
| 48 |
+
}
|
| 49 |
+
# the peptide (and, for carbon, the pyridone isomer_4N) is highlighted green as the high-error outlier
|
| 50 |
+
PEPTIDE_HIGHLIGHT_H = {"peptide": "#B0FF40"}
|
| 51 |
+
PEPTIDE_HIGHLIGHT_C = {"isomer_4N": "#B0FF40", "peptide": "#B0FF40"}
|
| 52 |
+
|
| 53 |
+
# bootstrap-coefficient file token -> remapped formula name, per nucleus
|
| 54 |
+
_BOOTSTRAP_TOKENS = {
|
| 55 |
+
"H": {"stationary_plus_pcm": "pcm2", "stationary_AND_pcm_AND_qcd": "pcm4",
|
| 56 |
+
"stationary_plus_qcd_AND_pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_qcd": "parsimonious_pcm",
|
| 57 |
+
"stationary_AND_openMM_AND_qcd": "expl4", "stationary_plus_qcd_AND_openMM": "semi_parsimonious_expl",
|
| 58 |
+
"stationary_plus_openMM_plus_qcd": "parsimonious_expl"},
|
| 59 |
+
"C": {"stationary_plus_pcm": "pcm2", "stationary_AND_pcm_AND_openMM_vib": "pcm4",
|
| 60 |
+
"stationary_plus_op_vib_AND_pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_op_vib": "parsimonious_pcm",
|
| 61 |
+
"stationary_AND_openMM_AND_openMM_vib": "expl4", "stationary_plus_op_vib_AND_openMM": "semi_parsimonious_expl",
|
| 62 |
+
"stationary_plus_openMM_plus_op_vib": "parsimonious_expl"},
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def shell_convergence_corrections(loader, solute, nucleus, solvents=SOLVENTS):
|
| 67 |
+
"""MagNET-X explicit-solvent correction (solvated minus isolated shielding) for each NMR
|
| 68 |
+
site, averaged over the site's atoms and over the MD frames, as a function of solvent shell
|
| 69 |
+
size. Backs SI Figure S8 panel A (shown for vomicine, proton sites).
|
| 70 |
+
|
| 71 |
+
Returns a DataFrame indexed by (site, solvent) with one column per shell ("shell_50" ...
|
| 72 |
+
"shell_650"). Shells absent for a given solute/solvent (e.g. the isomer_1Z/chloroform/250
|
| 73 |
+
gap) come back as NaN.
|
| 74 |
+
"""
|
| 75 |
+
sites = loader.site_atom_table(uuid_prefix_h_sites=True)
|
| 76 |
+
sites = sites[(sites["solute"] == solute) & (sites["nucleus"] == nucleus)]
|
| 77 |
+
rows = []
|
| 78 |
+
for _, r in sites.iterrows():
|
| 79 |
+
idx = site_atom_indices(r["atom_numbers"])
|
| 80 |
+
for solvent in solvents:
|
| 81 |
+
present = set(loader.available_shells(solute, solvent))
|
| 82 |
+
vals = {"site": r["site"], "solvent": solvent}
|
| 83 |
+
for shell in SHELL_SIZES:
|
| 84 |
+
if shell not in present:
|
| 85 |
+
vals[f"shell_{shell}"] = np.nan
|
| 86 |
+
continue
|
| 87 |
+
mx = loader.magnet_x(solute, solvent, shell) # (frames, atoms, 2)
|
| 88 |
+
corr = mx[:, idx, 1] - mx[:, idx, 0] # solvated - isolated
|
| 89 |
+
corr = np.mean(corr, axis=1) # average over the site's atoms
|
| 90 |
+
corr = corr[~np.isnan(corr)] # drop frames with missing data
|
| 91 |
+
vals[f"shell_{shell}"] = float(np.mean(corr)) if len(corr) else np.nan
|
| 92 |
+
rows.append(vals)
|
| 93 |
+
return pd.DataFrame(rows).set_index(["site", "solvent"])
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def experimental_stack(loader):
|
| 97 |
+
"""Experimental shifts as a long DataFrame indexed (solute, nucleus, site, solvent)."""
|
| 98 |
+
exp = loader.experiment(uuid_prefix_h_sites=True).drop(columns=["atom_numbers"])
|
| 99 |
+
exp = exp.set_index(["solute", "nucleus", "site"])
|
| 100 |
+
stack = exp.stack(future_stack=True)
|
| 101 |
+
stack = pd.DataFrame(stack, columns=["experimental"])
|
| 102 |
+
stack.index.names = ["solute", "nucleus", "site", "solvent"]
|
| 103 |
+
return stack.sort_index()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _site_mean(arr, idx):
|
| 107 |
+
return float(np.asarray(arr)[idx].mean())
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def build_query_df_nn(loader):
|
| 111 |
+
"""Assemble the per-(solute, nucleus, site, solvent) NN feature table that the composite-model
|
| 112 |
+
figures fit and plot (Figure 5C/5D, SI S15). Columns: experimental, stationary, pcm, qcd,
|
| 113 |
+
openMM, openMM_vib, plus the composite columns added by add_composite_columns
|
| 114 |
+
(stationary_plus_pcm/_qcd/_op_vib and their pcm+/openMM+ combinations). All inputs (MagNET
|
| 115 |
+
shieldings and the PCM conversion factors) are read through the loader.
|
| 116 |
+
"""
|
| 117 |
+
sites = loader.site_atom_table(uuid_prefix_h_sites=True)
|
| 118 |
+
solvents = [c for c in loader.experiment().columns
|
| 119 |
+
if c not in ("solute", "site", "nucleus", "atom_numbers")]
|
| 120 |
+
|
| 121 |
+
# Cache the per-solute / per-(solute, solvent) loader reads. Each accessor opens the HDF5 file
|
| 122 |
+
# fresh, and the loops below touch only ~13 solutes (or ~52 solute-solvent pairs) across
|
| 123 |
+
# hundreds of sites, so without caching this re-opens the file thousands of times (~12x slower).
|
| 124 |
+
_mz_cache, _qcd_cache, _shell_cache, _mx_cache, _mxs_cache = {}, {}, {}, {}, {}
|
| 125 |
+
def get_mz(solute):
|
| 126 |
+
if solute not in _mz_cache: _mz_cache[solute] = loader.magnet_zero(solute)
|
| 127 |
+
return _mz_cache[solute]
|
| 128 |
+
def get_qcd(solute):
|
| 129 |
+
if solute not in _qcd_cache: _qcd_cache[solute] = loader.qcd(solute)
|
| 130 |
+
return _qcd_cache[solute]
|
| 131 |
+
def get_shells(solute, solvent):
|
| 132 |
+
k = (solute, solvent)
|
| 133 |
+
if k not in _shell_cache: _shell_cache[k] = loader.available_shells(solute, solvent)
|
| 134 |
+
return _shell_cache[k]
|
| 135 |
+
def get_mx(solute, solvent):
|
| 136 |
+
k = (solute, solvent)
|
| 137 |
+
if k not in _mx_cache: _mx_cache[k] = loader.magnet_x(solute, solvent, 650)
|
| 138 |
+
return _mx_cache[k]
|
| 139 |
+
def get_mxs(solute, solvent):
|
| 140 |
+
k = (solute, solvent)
|
| 141 |
+
if k not in _mxs_cache: _mxs_cache[k] = loader.magnet_x_stationary(solute, solvent)
|
| 142 |
+
return _mxs_cache[k]
|
| 143 |
+
|
| 144 |
+
# --- MagNET-Zero stationary + PCM (PCM predicted for chloroform, broadcast to all solvents) ---
|
| 145 |
+
mz_rows = []
|
| 146 |
+
for _, r in sites.iterrows():
|
| 147 |
+
idx = site_atom_indices(r["atom_numbers"])
|
| 148 |
+
mz = get_mz(r["solute"])
|
| 149 |
+
mz_rows.append([r["solute"], r["nucleus"], r["site"],
|
| 150 |
+
_site_mean(mz["stationary"], idx), _site_mean(mz["pcm_correction"], idx)])
|
| 151 |
+
mz = pd.DataFrame(mz_rows, columns=["solute", "nucleus", "site", "stationary", "pcm"])
|
| 152 |
+
mz = pd.concat([mz.assign(solvent=s) for s in solvents], ignore_index=True)
|
| 153 |
+
mz = mz.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
|
| 154 |
+
|
| 155 |
+
# --- QCD correction (solvent-independent, tiled across solvents) ---
|
| 156 |
+
qcd_rows = []
|
| 157 |
+
for _, r in sites.iterrows():
|
| 158 |
+
idx = site_atom_indices(r["atom_numbers"])
|
| 159 |
+
q = get_qcd(r["solute"])
|
| 160 |
+
corr = q["trajectories"].mean(0).mean(0)[idx, 3] - q["stationary"][idx, 3]
|
| 161 |
+
qcd_rows.append([r["solute"], r["nucleus"], r["site"],
|
| 162 |
+
float(corr.mean()) if len(corr) else np.nan])
|
| 163 |
+
qcd = pd.DataFrame(qcd_rows, columns=["solute", "nucleus", "site", "qcd"])
|
| 164 |
+
qcd = pd.concat([qcd.assign(solvent=s) for s in solvents], ignore_index=True)
|
| 165 |
+
qcd = qcd.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
|
| 166 |
+
|
| 167 |
+
# --- MagNET-X explicit-solvent correction + vibrational correction (per solvent, 650-atom shell) ---
|
| 168 |
+
ex_rows = []
|
| 169 |
+
for _, r in sites.iterrows():
|
| 170 |
+
idx = site_atom_indices(r["atom_numbers"])
|
| 171 |
+
for solvent in solvents:
|
| 172 |
+
shells = get_shells(r["solute"], solvent)
|
| 173 |
+
if 650 not in shells:
|
| 174 |
+
ex_rows.append([r["solute"], r["nucleus"], r["site"], solvent, np.nan, np.nan]); continue
|
| 175 |
+
mx = get_mx(r["solute"], solvent) # (frames, atoms, [isolated, solvated])
|
| 176 |
+
corr = mx[:, idx, 1] - mx[:, idx, 0]
|
| 177 |
+
corr = np.mean(corr, axis=1)
|
| 178 |
+
corr = corr[~np.isnan(corr)]
|
| 179 |
+
openmm = float(np.mean(corr)) if len(corr) else np.nan
|
| 180 |
+
stat = get_mxs(r["solute"], solvent)
|
| 181 |
+
vib = float(np.mean(mx.mean(0)[idx, 0] - np.asarray(stat)[idx]))
|
| 182 |
+
ex_rows.append([r["solute"], r["nucleus"], r["site"], solvent, openmm, vib])
|
| 183 |
+
ex = pd.DataFrame(ex_rows, columns=["solute", "nucleus", "site", "solvent", "openMM", "openMM_vib"])
|
| 184 |
+
ex = ex.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
|
| 185 |
+
|
| 186 |
+
# --- combine + composite columns ---
|
| 187 |
+
comb = pd.concat([experimental_stack(loader), mz, ex, qcd], axis=1).reset_index()
|
| 188 |
+
comb = comb.set_index(["solute", "nucleus", "site", "solvent"])
|
| 189 |
+
# stationary_plus_pcm: scale the chloroform PCM correction to each solvent (factors from the loader,
|
| 190 |
+
# which renames water -> TIP4P to match the explicit-solvent naming)
|
| 191 |
+
conv = {}
|
| 192 |
+
for nuc in ["H", "C"]:
|
| 193 |
+
s = loader.pcm_conversion_factors(nuc).set_index("solvent")["pcm_conversion_factor"]
|
| 194 |
+
conv[nuc] = s.rename({"water": "TIP4P"})
|
| 195 |
+
nn = comb.reset_index()
|
| 196 |
+
factors = nn.apply(lambda r: conv[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
|
| 197 |
+
nn["stationary_plus_pcm"] = nn["stationary"] + nn["pcm"] * factors
|
| 198 |
+
nn = nn.set_index(comb.index.names)
|
| 199 |
+
comb["stationary_plus_pcm"] = nn["stationary_plus_pcm"]
|
| 200 |
+
comb["stationary_plus_qcd"] = comb["stationary"] + comb["qcd"]
|
| 201 |
+
comb["stationary_plus_op_vib"] = comb["stationary"] + comb["openMM_vib"]
|
| 202 |
+
comb["stationary_plus_pcm_plus_qcd"] = comb["stationary_plus_pcm"] + comb["qcd"]
|
| 203 |
+
comb["stationary_plus_pcm_plus_op_vib"] = comb["stationary_plus_pcm"] + comb["openMM_vib"]
|
| 204 |
+
comb["stationary_plus_openMM_plus_qcd"] = comb["stationary"] + comb["openMM"] + comb["qcd"]
|
| 205 |
+
comb["stationary_plus_openMM_plus_op_vib"] = comb["stationary"] + comb["openMM"] + comb["openMM_vib"]
|
| 206 |
+
return comb.reset_index()
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ----------------------------------------------------------------------------
|
| 210 |
+
# Composite-model fitting (Figure 5C/5D, SI S15). The coefficients applied at scale come from the
|
| 211 |
+
# loader's composite_model group (fit on delta-22).
|
| 212 |
+
# ----------------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
def fit(fit_df, formula):
|
| 215 |
+
"""OLS of experimental shift on a composite formula; returns (RMSE, params).
|
| 216 |
+
|
| 217 |
+
params is a pandas Series indexed by "Intercept" and the predictor names (matching statsmodels'
|
| 218 |
+
naming, which the composite-model coefficient code downstream relies on). This does the ordinary
|
| 219 |
+
least squares directly with numpy (np.linalg.lstsq), which is identical to the notebooks'
|
| 220 |
+
statsmodels OLS but far faster in this fitting loop: statsmodels re-parses the formula with patsy
|
| 221 |
+
on every call (~1000x slower per call). The `fit`-matches-`_fit_statsmodels` test guards the
|
| 222 |
+
equivalence.
|
| 223 |
+
|
| 224 |
+
Rows with a missing experimental value or a missing predictor are dropped before fitting and are
|
| 225 |
+
not scored: statsmodels drops those rows when fitting too, and its `predict` returns NaN for a row
|
| 226 |
+
with a missing predictor, so a single missing value never turns the whole RMSE into NaN.
|
| 227 |
+
"""
|
| 228 |
+
terms = [term.strip() for term in formula.split("+") if term.strip()]
|
| 229 |
+
response = fit_df["experimental"].to_numpy(dtype=float)
|
| 230 |
+
columns = [fit_df[term].to_numpy(dtype=float) for term in terms]
|
| 231 |
+
design = np.column_stack([np.ones(len(fit_df))] + columns)
|
| 232 |
+
keep = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
|
| 233 |
+
design, response = design[keep], response[keep]
|
| 234 |
+
beta, *_ = np.linalg.lstsq(design, response, rcond=None)
|
| 235 |
+
params = pd.Series(beta, index=["Intercept"] + terms)
|
| 236 |
+
return _rmse(design @ beta, response), params
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _fit_statsmodels(fit_df, formula):
|
| 240 |
+
"""The original statsmodels OLS, kept only as a test oracle for `fit` (see the equivalence test).
|
| 241 |
+
Slow because statsmodels re-parses the formula with patsy on every call; do not use in the harness."""
|
| 242 |
+
result = smf.ols(formula=f"experimental ~ {formula}", data=fit_df).fit()
|
| 243 |
+
fit_df = fit_df.copy()
|
| 244 |
+
fit_df["predicted"] = result.predict(fit_df)
|
| 245 |
+
scored = fit_df[fit_df["experimental"].notna() & fit_df["predicted"].notna()]
|
| 246 |
+
return _rmse(scored["predicted"], scored["experimental"]), result.params
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def fit_formulas_per_solvent(df, formulas, solvents, formula_remap):
|
| 250 |
+
"""One OLS fit per (formula, solvent), pooled across all solutes; returns rmse and params rows."""
|
| 251 |
+
rows = []
|
| 252 |
+
for formula in formulas:
|
| 253 |
+
for solvent in solvents:
|
| 254 |
+
d = df[df["solvent"] == solvent].copy()
|
| 255 |
+
rmse, params = fit(d, formula)
|
| 256 |
+
rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
|
| 257 |
+
"rmse": rmse, "params": params})
|
| 258 |
+
return pd.DataFrame(rows)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def fit_formulas_per_solvent_and_solute(df, formulas, solvents, solutes, formula_remap):
|
| 262 |
+
"""One OLS fit per (formula, solvent, solute); skips a group with under 2 rows or no
|
| 263 |
+
experimental data, and records the exception instead of raising if a fit fails."""
|
| 264 |
+
rows = []
|
| 265 |
+
for formula in formulas:
|
| 266 |
+
for solvent in solvents:
|
| 267 |
+
for solute in solutes:
|
| 268 |
+
d = df[(df["solvent"] == solvent) & (df["solute"] == solute)].copy()
|
| 269 |
+
if len(d) < 2 or d["experimental"].isnull().all():
|
| 270 |
+
continue
|
| 271 |
+
try:
|
| 272 |
+
rmse, params = fit(d, formula)
|
| 273 |
+
rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
|
| 274 |
+
"solute": solute, "rmse": rmse, "params": params})
|
| 275 |
+
except Exception as e: # noqa: BLE001 - mirror source behaviour
|
| 276 |
+
rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
|
| 277 |
+
"solute": solute, "rmse": np.nan, "params": str(e)})
|
| 278 |
+
return pd.DataFrame(rows)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def per_solvent_fits(query_df_nn):
|
| 282 |
+
"""all_solute_fitting_results: full per-solvent OLS fits for both nuclei ('scaled to test set')."""
|
| 283 |
+
out = {}
|
| 284 |
+
formulas = {"H": FITTING_FORMULAS_H, "C": FITTING_FORMULAS_C}
|
| 285 |
+
solvents = list(query_df_nn["solvent"].unique())
|
| 286 |
+
for nuc in ["H", "C"]:
|
| 287 |
+
d = query_df_nn[query_df_nn["nucleus"] == nuc].copy()
|
| 288 |
+
out[nuc] = fit_formulas_per_solvent(d, formulas[nuc], solvents, FORMULA_REMAP)
|
| 289 |
+
return out
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def per_solute_fits(query_df_nn):
|
| 293 |
+
"""per_solute_fitting_results: per-solvent-and-solute OLS fits ('scaled to solute' baseline)."""
|
| 294 |
+
out = {}
|
| 295 |
+
formulas = {"H": FITTING_FORMULAS_H, "C": FITTING_FORMULAS_C}
|
| 296 |
+
solvents = list(query_df_nn["solvent"].unique())
|
| 297 |
+
solutes = list(query_df_nn["solute"].unique())
|
| 298 |
+
for nuc in ["H", "C"]:
|
| 299 |
+
d = query_df_nn[query_df_nn["nucleus"] == nuc].copy()
|
| 300 |
+
out[nuc] = fit_formulas_per_solvent_and_solute(d, formulas[nuc], solvents, solutes, FORMULA_REMAP)
|
| 301 |
+
return out
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def build_bootstrap_seed_coeffs(loader):
|
| 305 |
+
"""Per-nucleus DataFrame of bootstrap coefficients with a remapped 'formula' column, read from
|
| 306 |
+
the loader's composite_model group (the delta-22 bootstrap fits applied to the natural products)."""
|
| 307 |
+
out = {}
|
| 308 |
+
for nuc in ["H", "C"]:
|
| 309 |
+
frames = []
|
| 310 |
+
for token, name in _BOOTSTRAP_TOKENS[nuc].items():
|
| 311 |
+
df = loader.bootstrap_coefficients(token, nuc).copy()
|
| 312 |
+
df["formula"] = name
|
| 313 |
+
frames.append(df)
|
| 314 |
+
combined = pd.concat(frames, ignore_index=True, sort=False)
|
| 315 |
+
front = [c for c in ["solvent", "formula", "seed"] if c in combined.columns]
|
| 316 |
+
out[nuc] = combined[front + [c for c in combined.columns if c not in front]]
|
| 317 |
+
return out
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def apply_bootstrap_params_to_full_dataset(data_df, params_df, nucleus=None):
|
| 321 |
+
"""Apply each bootstrap coefficient set (per formula/seed/solvent) to the full NP dataset,
|
| 322 |
+
returning predictions: solute, nucleus, site, solvent, formula, seed, experimental, predicted."""
|
| 323 |
+
coeffs_df = params_df.copy()
|
| 324 |
+
if nucleus is not None:
|
| 325 |
+
data_df = data_df[data_df["nucleus"] == nucleus]
|
| 326 |
+
keep = ["solute", "nucleus", "site", "solvent", "experimental"]
|
| 327 |
+
keep += [c for c in data_df.columns if c in coeffs_df.columns and c not in keep]
|
| 328 |
+
data_df = data_df[keep]
|
| 329 |
+
merged = coeffs_df.merge(data_df, how="outer", on="solvent", suffixes=("_coeff", "_data"))
|
| 330 |
+
idx = ["solute", "nucleus", "site", "solvent", "formula", "seed"]
|
| 331 |
+
merged = merged[idx + [c for c in merged.columns if c not in idx]]
|
| 332 |
+
# vectorized prediction: intercept (0 if missing) plus, for each parameter, coeff*data where the
|
| 333 |
+
# coeff is present (a missing coeff contributes nothing; a present coeff with missing data
|
| 334 |
+
# propagates NaN).
|
| 335 |
+
pred = merged["Intercept"].astype(float).fillna(0.0) if "Intercept" in merged.columns else 0.0
|
| 336 |
+
for cc in [c for c in merged.columns if c.endswith("_coeff")]:
|
| 337 |
+
dc = cc.replace("_coeff", "_data")
|
| 338 |
+
if dc not in merged.columns:
|
| 339 |
+
continue
|
| 340 |
+
term = merged[cc].astype(float) * merged[dc].astype(float)
|
| 341 |
+
pred = pred + term.where(merged[cc].notna(), 0.0)
|
| 342 |
+
merged["predicted"] = pred
|
| 343 |
+
return merged[idx + ["experimental", "predicted"]]
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def compute_solute_rmses(df, solute_col="solute"):
|
| 347 |
+
"""Per (solvent, nucleus, formula, seed, solute) RMSE over a solute's sites."""
|
| 348 |
+
valid = df[df["experimental"].notnull() & df["predicted"].notnull()].copy()
|
| 349 |
+
# RMSE is sqrt(mean(squared error)), so a single vectorized groupby-mean over a precomputed
|
| 350 |
+
# squared-error column is identical to grouped.apply(rmse) but ~19x faster on the ~200k groups
|
| 351 |
+
# of a full bootstrap table (stats.rmse is exactly np.sqrt(np.mean(np.square(...)))).
|
| 352 |
+
valid["_squared_error"] = np.square(valid["predicted"] - valid["experimental"])
|
| 353 |
+
result = (valid.groupby(["solvent", "nucleus", "formula", "seed", solute_col])["_squared_error"]
|
| 354 |
+
.mean().pow(0.5).reset_index(name="Bootstrap_RMSE"))
|
| 355 |
+
return result.rename(columns={solute_col: "solute"}) if solute_col != "solute" else result
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def compute_grouped_rmse(df, solute_groups, solute_col="solute"):
|
| 359 |
+
"""Per (solvent, nucleus, formula, seed, solute_group) RMSE over all sites in the group."""
|
| 360 |
+
lookup = {s: g for g, ss in solute_groups.items() for s in ss}
|
| 361 |
+
df = df.copy()
|
| 362 |
+
df["solute_group"] = df[solute_col].map(lookup).fillna("Unknown")
|
| 363 |
+
valid = df[df["experimental"].notnull() & df["predicted"].notnull()].copy()
|
| 364 |
+
# vectorized RMSE, same identity and speedup as compute_solute_rmses above
|
| 365 |
+
valid["_squared_error"] = np.square(valid["predicted"] - valid["experimental"])
|
| 366 |
+
return (valid.groupby(["solvent", "nucleus", "formula", "seed", "solute_group"])["_squared_error"]
|
| 367 |
+
.mean().pow(0.5).reset_index(name="Bootstrap_RMSE"))
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# ---------------------------------------------------------------------------------------------
|
| 371 |
+
# SI Figure S15's "Fitting RMSE Comparisons (chloroform, H/C)" bar chart: per test-set solute,
|
| 372 |
+
# three ways of choosing the composite-model coefficients -- "Scaled to Solute" (fit each solute
|
| 373 |
+
# on its own), "Scaled to Test Set" (one fit pooled across all 12 test-set solutes), "Extrapolated
|
| 374 |
+
# from delta22" (delta-22's bootstrap fits applied cold, never seeing the test set). Built from
|
| 375 |
+
# per_solute_fits (Scaled to Solute), per_solvent_fits (Scaled to Test Set, split back out per
|
| 376 |
+
# solute by scaled_to_test_set_per_solute_rmse below), and apply_bootstrap_params_to_full_dataset +
|
| 377 |
+
# compute_solute_rmses (Extrapolated from delta22, the same data Figure 5D's boxplot uses).
|
| 378 |
+
|
| 379 |
+
def scaled_to_test_set_per_solute_rmse(query_df_nn, all_solute_fits, nucleus, solvent, formula):
|
| 380 |
+
"""per_solvent_fits' output has one pooled rmse per (formula, solvent): the model is fit once
|
| 381 |
+
across every test-set solute together. To get a comparable per-solute rmse (needed to sit
|
| 382 |
+
"Scaled to Test Set" next to "Scaled to Solute" and "Extrapolated from delta22" in the same bar
|
| 383 |
+
chart), this applies that one pooled fit's coefficients to each solute's own sites and scores
|
| 384 |
+
them individually, reusing apply_bootstrap_params_to_full_dataset's prediction machinery by
|
| 385 |
+
reshaping the fitted statsmodels Params Series into that function's expected wide
|
| 386 |
+
"<term>_coeff" row format."""
|
| 387 |
+
remapped = FORMULA_REMAP.get(formula, formula)
|
| 388 |
+
fits = all_solute_fits[nucleus]
|
| 389 |
+
match = fits[(fits["formula"] == remapped) & (fits["solvent"] == solvent)]
|
| 390 |
+
if match.empty:
|
| 391 |
+
raise KeyError(f"no per_solvent_fits row for formula={remapped!r} solvent={solvent!r}")
|
| 392 |
+
params = match.iloc[0]["params"]
|
| 393 |
+
# bare term names, matching data_df's own raw column names -- apply_bootstrap_params_to_full_
|
| 394 |
+
# dataset's merge(..., suffixes=("_coeff", "_data")) is what appends "_coeff"/"_data" (only
|
| 395 |
+
# triggered by the name COLLIDING with data_df's column of the same name); pre-suffixing here
|
| 396 |
+
# would just create an unmatched "<term>_coeff" column no data_df column collides with.
|
| 397 |
+
wide = {"Intercept": float(params.get("Intercept", 0.0)), "solvent": solvent,
|
| 398 |
+
"formula": remapped, "seed": 0}
|
| 399 |
+
for term in params.index:
|
| 400 |
+
if term != "Intercept":
|
| 401 |
+
wide[term] = float(params[term])
|
| 402 |
+
params_df = pd.DataFrame([wide])
|
| 403 |
+
# Restrict to this one solvent before the merge: apply_bootstrap_params_to_full_dataset's outer
|
| 404 |
+
# join zero-fills a missing coefficient instead of raising, so an unfiltered call would
|
| 405 |
+
# silently score every OTHER solvent's rows against this solvent's plane as if the missing
|
| 406 |
+
# coefficients were all zero.
|
| 407 |
+
solvent_only = query_df_nn[query_df_nn["solvent"] == solvent]
|
| 408 |
+
preds = apply_bootstrap_params_to_full_dataset(solvent_only, params_df, nucleus=nucleus)
|
| 409 |
+
return compute_solute_rmses(preds)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def fitting_rmse_comparison_table(query_df_nn, per_solute_fits_result, per_solvent_fits_result,
|
| 413 |
+
bootstrap_rmses, nucleus, solvent, formula):
|
| 414 |
+
"""Assembles SI Figure S15's "Fitting RMSE Comparisons" table: one row per test-set solute,
|
| 415 |
+
columns "Scaled to Solute", "Scaled to Test Set", "Extrapolated from delta22" (ppm RMSE), all
|
| 416 |
+
at one nucleus/solvent/formula. bootstrap_rmses is compute_solute_rmses' output for `nucleus`
|
| 417 |
+
(e.g. Figure 5D's bootstrap_rmses_h), averaged here over its bootstrap seeds."""
|
| 418 |
+
remapped = FORMULA_REMAP.get(formula, formula)
|
| 419 |
+
|
| 420 |
+
solute_fit = per_solute_fits_result[nucleus]
|
| 421 |
+
scaled_to_solute = (solute_fit[(solute_fit["formula"] == remapped) & (solute_fit["solvent"] == solvent)]
|
| 422 |
+
.set_index("solute")["rmse"])
|
| 423 |
+
|
| 424 |
+
scaled_to_test_set = (scaled_to_test_set_per_solute_rmse(query_df_nn, per_solvent_fits_result, nucleus, solvent, formula)
|
| 425 |
+
.query("solvent == @solvent and formula == @remapped")
|
| 426 |
+
.set_index("solute")["Bootstrap_RMSE"])
|
| 427 |
+
|
| 428 |
+
boot = bootstrap_rmses[(bootstrap_rmses["nucleus"] == nucleus) & (bootstrap_rmses["solvent"] == solvent)
|
| 429 |
+
& (bootstrap_rmses["formula"] == remapped)]
|
| 430 |
+
extrapolated = boot.groupby("solute")["Bootstrap_RMSE"].mean()
|
| 431 |
+
|
| 432 |
+
table = pd.DataFrame({"Scaled to Solute": scaled_to_solute, "Scaled to Test Set": scaled_to_test_set,
|
| 433 |
+
"Extrapolated from delta22": extrapolated})
|
| 434 |
+
return table.dropna(how="all")
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def distribution_shift_by_solvent_table(per_solute_fits_result, per_solvent_fits_result,
|
| 438 |
+
bootstrap_rmses, nucleus, formula, solvents=None):
|
| 439 |
+
"""SI Figure S15's per-solvent "distribution shift is minor" cross-check -- the solvent-averaged
|
| 440 |
+
companion to fitting_rmse_comparison_table (which is per-solute for a single solvent). For one
|
| 441 |
+
nucleus and formula, the mean test-set RMSE under the three ways of choosing the composite-model
|
| 442 |
+
coefficients, one row per solvent: "Extrapolated from delta22" (delta-22's bootstrap
|
| 443 |
+
coefficients applied cold), "Scaled to Test Set" (one fit pooled across the whole test set), and
|
| 444 |
+
"Scaled to Solute" (a separate fit per solute). Where the three sit close, re-optimizing the
|
| 445 |
+
coefficients barely helps, so they transfer and the residual error is physics; the one solvent
|
| 446 |
+
where "Extrapolated" rises well above the refits is TIP4P (water), dominated by the peptide's
|
| 447 |
+
conformer-population sensitivity, which is why S15 reports solvent-averaged performance.
|
| 448 |
+
|
| 449 |
+
bootstrap_rmses is compute_solute_rmses' output for `nucleus` (e.g. the si_figure_s15 notebook's
|
| 450 |
+
bootstrap_rmses["H"]); per_solute_fits_result / per_solvent_fits_result are the per_solute_fits /
|
| 451 |
+
per_solvent_fits dicts keyed by nucleus. Averages over bootstrap seeds and over each solvent's
|
| 452 |
+
solutes."""
|
| 453 |
+
remapped = FORMULA_REMAP.get(formula, formula)
|
| 454 |
+
boot = bootstrap_rmses[(bootstrap_rmses["nucleus"] == nucleus)
|
| 455 |
+
& (bootstrap_rmses["formula"] == remapped)]
|
| 456 |
+
test_set = per_solvent_fits_result[nucleus]
|
| 457 |
+
test_set = test_set[test_set["formula"] == remapped]
|
| 458 |
+
solute = per_solute_fits_result[nucleus]
|
| 459 |
+
solute = solute[solute["formula"] == remapped]
|
| 460 |
+
if solvents is None:
|
| 461 |
+
solvents = sorted(boot["solvent"].unique())
|
| 462 |
+
rows = []
|
| 463 |
+
for solvent in solvents:
|
| 464 |
+
rows.append({
|
| 465 |
+
"solvent": solvent,
|
| 466 |
+
"Extrapolated from delta22": boot[boot["solvent"] == solvent]["Bootstrap_RMSE"].mean(),
|
| 467 |
+
"Scaled to Test Set": test_set[test_set["solvent"] == solvent]["rmse"].mean(),
|
| 468 |
+
"Scaled to Solute": solute[solute["solvent"] == solvent]["rmse"].mean(),
|
| 469 |
+
})
|
| 470 |
+
return pd.DataFrame(rows).set_index("solvent")
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
# ---------------------------------------------------------------------------------------------
|
| 474 |
+
# SI Figure S15's "Feature Space Coverage by Solvent" and "Residuals for Delta22 Fitting
|
| 475 |
+
# Coefficients" panels: do the test set and delta-22 cover the same region of feature space (so
|
| 476 |
+
# the test set's higher error can't be blamed on extrapolating outside what delta-22's fitting
|
| 477 |
+
# coefficients ever saw)? Both panels combine the test set (this module's query_df_nn) with
|
| 478 |
+
# delta-22's own per-site data at the same two features (delta22.py's query_df_nn, passed in by
|
| 479 |
+
# the caller so this module doesn't import delta22.py directly).
|
| 480 |
+
|
| 481 |
+
# Feature Space Coverage only compares the 4 solvents both datasets have explicit-solvent (OpenMM)
|
| 482 |
+
# coverage for -- delta-22 has Desmond+OpenMM MD for all 12 solvents, but the natural-products test
|
| 483 |
+
# set (sigma-fresh-derived) only has 4.
|
| 484 |
+
FEATURE_SPACE_SOLVENTS = ("chloroform", "benzene", "methanol", "TIP4P")
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def _feature_space_config(nucleus):
|
| 488 |
+
if nucleus == "H":
|
| 489 |
+
return {"x_feature": "stationary_plus_qcd", "y_feature": "openMM"}
|
| 490 |
+
if nucleus == "C":
|
| 491 |
+
return {"x_feature": "stationary_plus_op_vib", "y_feature": "openMM"}
|
| 492 |
+
raise ValueError(f"unsupported nucleus {nucleus!r}, expected 'H' or 'C'")
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus, cols):
|
| 496 |
+
"""The shared setup both panels below need: the test set's own rows (excludes any row
|
| 497 |
+
literally named "delta22", a defensive filter) tagged "Test Set", concatenated with
|
| 498 |
+
delta22_query_df_nn's rows (already delta-22-only) tagged "Delta22"."""
|
| 499 |
+
test = query_df_nn[query_df_nn["nucleus"] == nucleus].copy()
|
| 500 |
+
test = test[~test["solute"].astype(str).str.strip().str.lower().eq("delta22")]
|
| 501 |
+
test["dataset"] = "Test Set"
|
| 502 |
+
d22 = delta22_query_df_nn[delta22_query_df_nn["nucleus"] == nucleus].copy()
|
| 503 |
+
d22["dataset"] = "Delta22"
|
| 504 |
+
return pd.concat([test[cols + ["dataset"]], d22[cols + ["dataset"]]], ignore_index=True)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def feature_space_coverage_table(query_df_nn, delta22_query_df_nn, nucleus, solvents=FEATURE_SPACE_SOLVENTS):
|
| 508 |
+
"""SI Figure S15's "Feature Space Coverage by Solvent": test-set and delta-22 per-site feature
|
| 509 |
+
values, mean-centered globally -- one scalar mean per feature, pooled over every solvent and
|
| 510 |
+
both datasets (the notebook's separate fitting cell instead centers per solvent). Returns a
|
| 511 |
+
tidy DataFrame: solvent, dataset ("Test Set"/"Delta22"), x (centered), y (centered)."""
|
| 512 |
+
cfg = _feature_space_config(nucleus)
|
| 513 |
+
x_feature, y_feature = cfg["x_feature"], cfg["y_feature"]
|
| 514 |
+
combined = _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus, ["solvent", x_feature, y_feature])
|
| 515 |
+
combined = combined[combined["solvent"].isin(solvents)].dropna(subset=[x_feature, y_feature])
|
| 516 |
+
combined["x"] = combined[x_feature] - combined[x_feature].mean()
|
| 517 |
+
combined["y"] = combined[y_feature] - combined[y_feature].mean()
|
| 518 |
+
return combined[["solvent", "dataset", "x", "y"]].reset_index(drop=True)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus, solvents=FEATURE_SPACE_SOLVENTS):
|
| 522 |
+
"""SI Figure S15's "Residuals for Delta22 Fitting Coefficients": fits a 2-feature OLS plane
|
| 523 |
+
(experimental ~ x_feature + y_feature, the same two features feature_space_coverage_table
|
| 524 |
+
plots) to delta-22's data alone, separately per solvent, then applies that one plane to both
|
| 525 |
+
delta-22 and the test set, returning residual = experimental - predicted. For a linear model,
|
| 526 |
+
minimizing RMSE directly (Nelder-Mead) is the same convex problem as ordinary least squares, so
|
| 527 |
+
np.linalg.lstsq gives the identical plane. Returns a tidy DataFrame: solvent, dataset,
|
| 528 |
+
experimental, residual."""
|
| 529 |
+
cfg = _feature_space_config(nucleus)
|
| 530 |
+
x_feature, y_feature = cfg["x_feature"], cfg["y_feature"]
|
| 531 |
+
combined = _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus,
|
| 532 |
+
["solvent", x_feature, y_feature, "experimental"])
|
| 533 |
+
combined = combined[combined["solvent"].isin(solvents)].dropna(subset=[x_feature, y_feature, "experimental"])
|
| 534 |
+
|
| 535 |
+
rows = []
|
| 536 |
+
for solvent in solvents:
|
| 537 |
+
sdf = combined[combined["solvent"] == solvent]
|
| 538 |
+
d22_sdf = sdf[sdf["dataset"] == "Delta22"]
|
| 539 |
+
if len(d22_sdf) < 3:
|
| 540 |
+
continue
|
| 541 |
+
design = np.column_stack([np.ones(len(d22_sdf)), d22_sdf[x_feature], d22_sdf[y_feature]])
|
| 542 |
+
c0, c1, c2 = np.linalg.lstsq(design, d22_sdf["experimental"].to_numpy(), rcond=None)[0]
|
| 543 |
+
predicted = c0 + c1 * sdf[x_feature] + c2 * sdf[y_feature]
|
| 544 |
+
out = sdf[["solvent", "dataset", "experimental"]].copy()
|
| 545 |
+
out["residual"] = sdf["experimental"] - predicted
|
| 546 |
+
rows.append(out)
|
| 547 |
+
if not rows:
|
| 548 |
+
return pd.DataFrame(columns=["solvent", "dataset", "experimental", "residual"])
|
| 549 |
+
return pd.concat(rows, ignore_index=True).reset_index(drop=True)
|
analysis/code/applications_plots.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Plotting for the natural-products figures (main-text Figures 5C/5D, SI Figure S15).
|
| 2 |
+
|
| 3 |
+
Holds every plotting engine for the applications (natural-products) notebooks: the delta-22-vs-test-set
|
| 4 |
+
benefit bar chart (Figure 5C), the shared per-solute box-plot engine (Figure 5D and SI Figure S15's
|
| 5 |
+
bootstrap-RMSE panels), and SI Figure S15's fitting-RMSE comparison bars, feature-space/residual scatter
|
| 6 |
+
grids, and distribution-shift bars. Engines are carried over from the source notebooks with notebook
|
| 7 |
+
globals turned into explicit arguments. The numbers come from applications.py; this module is kept
|
| 8 |
+
separate so applications.py stays plotting-free.
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import seaborn as sns
|
| 15 |
+
import matplotlib.pyplot as plt
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def plot_nps_on_boxplot_delta22_simplified(
|
| 19 |
+
regression_ss_df,
|
| 20 |
+
nps_rmse_df,
|
| 21 |
+
best_possible_df, # expects a 'rmse' column
|
| 22 |
+
nucleus,
|
| 23 |
+
formulas,
|
| 24 |
+
colors,
|
| 25 |
+
site_counts,
|
| 26 |
+
solvents_filter=None,
|
| 27 |
+
box_width=0.20,
|
| 28 |
+
box_gap=0.05,
|
| 29 |
+
formula_gap=0.15,
|
| 30 |
+
figsize=(14, 8),
|
| 31 |
+
formula_remap=None,
|
| 32 |
+
solute_remap=None,
|
| 33 |
+
solute_order=None,
|
| 34 |
+
title=None,
|
| 35 |
+
save_path=None,
|
| 36 |
+
solute_color_remap=None,
|
| 37 |
+
max_bar_height=0.05,
|
| 38 |
+
|
| 39 |
+
# --- SELF-SCALING BASELINE CONTROLS ---
|
| 40 |
+
show_baseline=True,
|
| 41 |
+
baseline_annotation_text='Self-scaling baseline (dotted)',
|
| 42 |
+
baseline_annotation_x=0.985,
|
| 43 |
+
baseline_annotation_y=0.93,
|
| 44 |
+
baseline_annotation_fontsize=9,
|
| 45 |
+
|
| 46 |
+
# --- FULL FIT LINE CONTROLS ---
|
| 47 |
+
show_full_fit_line=False,
|
| 48 |
+
all_solute_fitting_results=None, # {nucleus: per-solvent fit df}; required when show_full_fit_line
|
| 49 |
+
full_fit_line_label='Full Fit RMSE',
|
| 50 |
+
full_fit_line_label_x=0.99,
|
| 51 |
+
|
| 52 |
+
# --- INSET AXIS CONTROLS ---
|
| 53 |
+
site_count_axis_mode='attached',
|
| 54 |
+
site_count_inset_axis_offset=0.0,
|
| 55 |
+
site_count_inset_area_frac=0.1,
|
| 56 |
+
site_count_inset_axis_label_pad=0.04,
|
| 57 |
+
site_count_alpha=0.25,
|
| 58 |
+
site_count_label='Site count',
|
| 59 |
+
site_count_preserve_visual_height=True,
|
| 60 |
+
|
| 61 |
+
# ---- font sizes ----
|
| 62 |
+
xtick_fontsize=10,
|
| 63 |
+
delta22_fontsize=10,
|
| 64 |
+
ylabel_fontsize=13,
|
| 65 |
+
title_fontsize=16,
|
| 66 |
+
|
| 67 |
+
# ---- SAVE SVG ----
|
| 68 |
+
save_svg_path=None,
|
| 69 |
+
default_svg_name="Figure_5C_extras.svg",
|
| 70 |
+
svg_transparent=True,
|
| 71 |
+
):
|
| 72 |
+
"""
|
| 73 |
+
Simplified box and whisker plot:
|
| 74 |
+
- Plots all solutes separately (no grouping)
|
| 75 |
+
- Averages RMSE across solvents for each bootstrap seed
|
| 76 |
+
- Shows "best possible" RMSE as dashed baseline for each solute
|
| 77 |
+
|
| 78 |
+
Parameters:
|
| 79 |
+
- regression_ss_df: Delta-22 regression samples (Bootstrap_RMSE)
|
| 80 |
+
- nps_rmse_df: Should have columns: solvent, nucleus, formula, seed, solute, Bootstrap_RMSE
|
| 81 |
+
- best_possible_df: DataFrame with best possible RMSEs per solute/solvent/formula
|
| 82 |
+
Should have columns: formula, solvent, solute, rmse, params
|
| 83 |
+
- nucleus: Which nucleus to filter for
|
| 84 |
+
- formulas: List of formulas to compare
|
| 85 |
+
- colors: Colors for each formula
|
| 86 |
+
- site_counts: DataFrame indexed by solute with nucleus count columns (e.g., 'H', 'C')
|
| 87 |
+
- solvents_filter: Optional list of solvents to include in averaging
|
| 88 |
+
- show_baseline: Whether to show the 'best possible df' baseline
|
| 89 |
+
- show_full_fit_line: Whether to draw a line representing the full-dataset fit RMSE across all solutes
|
| 90 |
+
- full_fit_line_label: Label for the full fit line
|
| 91 |
+
- full_fit_line_label_x: X-coordinate for the full fit line label
|
| 92 |
+
- box_width: Width of each box
|
| 93 |
+
- box_gap: Gap between boxes
|
| 94 |
+
- formula_gap: Gap between formulas
|
| 95 |
+
- figsize: Figure size
|
| 96 |
+
- formula_remap: Optional dict to remap formula names
|
| 97 |
+
- max_bar_height: Maximum visual height target for site-count bars relative to RMSE axis
|
| 98 |
+
- site_count_axis_mode: One of {'attached', 'inset', 'off'}
|
| 99 |
+
- site_count_inset_axis_offset: Horizontal inset-axis adjustment in axes coordinates (clipped to stay inside in inset mode)
|
| 100 |
+
- site_count_inset_area_frac: Fraction of total figure area to allocate to inset axis (inset mode only)
|
| 101 |
+
- site_count_alpha: Alpha for site-count bars
|
| 102 |
+
- site_count_label: Label for site-count axis
|
| 103 |
+
- site_count_preserve_visual_height: Keep the old visual bar footprint when using a right axis
|
| 104 |
+
"""
|
| 105 |
+
if formula_remap is None:
|
| 106 |
+
formula_remap = {}
|
| 107 |
+
|
| 108 |
+
if solute_remap is None:
|
| 109 |
+
solute_remap = {}
|
| 110 |
+
|
| 111 |
+
if solute_color_remap is None:
|
| 112 |
+
solute_color_remap = {}
|
| 113 |
+
|
| 114 |
+
valid_axis_modes = {'attached', 'inset', 'off'}
|
| 115 |
+
if site_count_axis_mode not in valid_axis_modes:
|
| 116 |
+
raise ValueError(f"site_count_axis_mode must be one of {sorted(valid_axis_modes)}")
|
| 117 |
+
|
| 118 |
+
if site_counts is not None and not isinstance(site_counts, pd.DataFrame):
|
| 119 |
+
raise TypeError("site_counts must be a pandas DataFrame or None")
|
| 120 |
+
|
| 121 |
+
if site_count_inset_area_frac < 0 or site_count_inset_area_frac > 1:
|
| 122 |
+
raise ValueError("site_count_inset_area_frac must be between 0 and 1")
|
| 123 |
+
|
| 124 |
+
def get_rounded_count_ticks(max_count, target_ticks=7):
|
| 125 |
+
if max_count <= 0:
|
| 126 |
+
return np.array([0.0, 1.0]), 1.0
|
| 127 |
+
|
| 128 |
+
rough_step = max_count / max(target_ticks - 1, 1)
|
| 129 |
+
magnitude = 10 ** np.floor(np.log10(rough_step)) if rough_step > 0 else 1.0
|
| 130 |
+
normalized = rough_step / magnitude
|
| 131 |
+
if normalized <= 1:
|
| 132 |
+
nice_base = 1
|
| 133 |
+
elif normalized <= 2:
|
| 134 |
+
nice_base = 2
|
| 135 |
+
elif normalized <= 5:
|
| 136 |
+
nice_base = 5
|
| 137 |
+
else:
|
| 138 |
+
nice_base = 10
|
| 139 |
+
|
| 140 |
+
step = nice_base * magnitude
|
| 141 |
+
rounded_top = np.ceil(max_count / step) * step
|
| 142 |
+
ticks = np.arange(0, rounded_top + 0.5 * step, step)
|
| 143 |
+
return ticks, rounded_top
|
| 144 |
+
|
| 145 |
+
n_formulas = len(formulas)
|
| 146 |
+
|
| 147 |
+
if solvents_filter is not None:
|
| 148 |
+
regression_ss_df = regression_ss_df[regression_ss_df['solvent'].isin(solvents_filter)].copy()
|
| 149 |
+
nps_rmse_df = nps_rmse_df[nps_rmse_df['solvent'].isin(solvents_filter)].copy()
|
| 150 |
+
best_possible_df = best_possible_df[best_possible_df['solvent'].isin(solvents_filter)].copy()
|
| 151 |
+
|
| 152 |
+
if regression_ss_df.empty:
|
| 153 |
+
raise ValueError(f"No regression data found for solvents: {solvents_filter}")
|
| 154 |
+
if nps_rmse_df.empty:
|
| 155 |
+
raise ValueError(f"No NPS RMSE data found for solvents: {solvents_filter}")
|
| 156 |
+
|
| 157 |
+
def get_delta22_averaged_rmse(regression_df, formula):
|
| 158 |
+
df = regression_df[regression_df['formula'] == formula]
|
| 159 |
+
return df.groupby('seed')['Bootstrap_RMSE'].mean().values
|
| 160 |
+
|
| 161 |
+
def get_solute_bootstrap_averaged(nps_df, formula, nucleus):
|
| 162 |
+
np_formula = formula_remap.get(formula, formula)
|
| 163 |
+
df = nps_df[
|
| 164 |
+
(nps_df['formula'] == np_formula) &
|
| 165 |
+
(nps_df['nucleus'] == nucleus)
|
| 166 |
+
]
|
| 167 |
+
if df.empty:
|
| 168 |
+
return {}
|
| 169 |
+
|
| 170 |
+
# average over solvent per solute/seed, then collect each solute's per-seed array
|
| 171 |
+
averaged_df = df.groupby(['solute', 'seed'])['Bootstrap_RMSE'].mean().reset_index()
|
| 172 |
+
solute_dict = {}
|
| 173 |
+
for solute in averaged_df['solute'].unique():
|
| 174 |
+
arr = averaged_df[averaged_df['solute'] == solute]['Bootstrap_RMSE'].values
|
| 175 |
+
if arr.size > 0:
|
| 176 |
+
solute_dict[solute] = arr
|
| 177 |
+
return solute_dict
|
| 178 |
+
|
| 179 |
+
def get_best_possible_averaged(best_df, solute, formula):
|
| 180 |
+
np_formula = formula_remap.get(formula, formula)
|
| 181 |
+
df = best_df[
|
| 182 |
+
(best_df['formula'] == np_formula) &
|
| 183 |
+
(best_df['solute'] == solute)
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
if df.empty:
|
| 187 |
+
# fall back to the raw formula name in case best_possible_df wasn't remapped
|
| 188 |
+
df = best_df[
|
| 189 |
+
(best_df['formula'] == formula) &
|
| 190 |
+
(best_df['solute'] == solute)
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
if df.empty:
|
| 194 |
+
return None
|
| 195 |
+
return df['rmse'].mean()
|
| 196 |
+
|
| 197 |
+
all_solutes = set()
|
| 198 |
+
for formula in formulas:
|
| 199 |
+
np_formula = formula_remap.get(formula, formula)
|
| 200 |
+
df_filtered = nps_rmse_df[
|
| 201 |
+
(nps_rmse_df['formula'] == np_formula) &
|
| 202 |
+
(nps_rmse_df['nucleus'] == nucleus)
|
| 203 |
+
]
|
| 204 |
+
all_solutes.update(df_filtered['solute'].unique())
|
| 205 |
+
all_solutes = sorted(list(all_solutes))
|
| 206 |
+
if solute_order is not None: # explicit ordering (e.g. main-text Figure 5D)
|
| 207 |
+
ordered = [s for s in solute_order if s in all_solutes]
|
| 208 |
+
all_solutes = ordered + [s for s in all_solutes if s not in ordered]
|
| 209 |
+
|
| 210 |
+
delta22_by_formula = [get_delta22_averaged_rmse(regression_ss_df, f) for f in formulas]
|
| 211 |
+
|
| 212 |
+
# each formula block is 1 delta-22 box plus one box per solute
|
| 213 |
+
total_boxes_per_formula = 1 + len(all_solutes)
|
| 214 |
+
|
| 215 |
+
all_data = []
|
| 216 |
+
all_positions = []
|
| 217 |
+
all_colors = []
|
| 218 |
+
all_alphas = []
|
| 219 |
+
tick_positions = []
|
| 220 |
+
tick_labels = []
|
| 221 |
+
baseline_segments = [] # Store (x_start, x_end, y_value, solute_label) for baseline segments
|
| 222 |
+
|
| 223 |
+
count_bar_positions = []
|
| 224 |
+
count_bar_values = []
|
| 225 |
+
delta22_count_by_nucleus = {'H': 43.0, 'C': 50.0}
|
| 226 |
+
|
| 227 |
+
current_position = 0.0
|
| 228 |
+
|
| 229 |
+
for j, formula in enumerate(formulas):
|
| 230 |
+
|
| 231 |
+
# 1) Delta-22 box (solvent-averaged)
|
| 232 |
+
box_pos = current_position + box_width / 2
|
| 233 |
+
all_data.append(delta22_by_formula[j])
|
| 234 |
+
all_positions.append(box_pos)
|
| 235 |
+
all_colors.append(colors[j])
|
| 236 |
+
all_alphas.append(1.0)
|
| 237 |
+
tick_positions.append(box_pos)
|
| 238 |
+
tick_labels.append('delta-22')
|
| 239 |
+
|
| 240 |
+
delta22_count = delta22_count_by_nucleus.get(nucleus)
|
| 241 |
+
if delta22_count is not None:
|
| 242 |
+
count_bar_positions.append(box_pos)
|
| 243 |
+
count_bar_values.append(float(delta22_count))
|
| 244 |
+
|
| 245 |
+
current_position += box_width + box_gap
|
| 246 |
+
|
| 247 |
+
# 2) Individual solute boxes (solvent-averaged, lighter alpha)
|
| 248 |
+
solute_data = get_solute_bootstrap_averaged(nps_rmse_df, formula, nucleus)
|
| 249 |
+
|
| 250 |
+
count_col = None
|
| 251 |
+
if site_counts is not None:
|
| 252 |
+
if nucleus in site_counts.columns:
|
| 253 |
+
count_col = nucleus
|
| 254 |
+
elif 'H' in site_counts.columns:
|
| 255 |
+
count_col = 'H'
|
| 256 |
+
|
| 257 |
+
for solute in all_solutes:
|
| 258 |
+
box_pos = current_position + box_width / 2
|
| 259 |
+
if solute in solute_data and len(solute_data[solute]) > 0:
|
| 260 |
+
all_data.append(solute_data[solute])
|
| 261 |
+
all_positions.append(box_pos)
|
| 262 |
+
all_colors.append(solute_color_remap.get(solute, colors[j]))
|
| 263 |
+
all_alphas.append(0.5)
|
| 264 |
+
tick_positions.append(box_pos)
|
| 265 |
+
tick_labels.append(solute_remap.get(solute, solute))
|
| 266 |
+
|
| 267 |
+
if (
|
| 268 |
+
count_col is not None
|
| 269 |
+
and solute in site_counts.index
|
| 270 |
+
and pd.notna(site_counts.loc[solute, count_col])
|
| 271 |
+
):
|
| 272 |
+
count_bar_positions.append(box_pos)
|
| 273 |
+
count_bar_values.append(float(site_counts.loc[solute, count_col]))
|
| 274 |
+
else:
|
| 275 |
+
count_bar_positions.append(box_pos)
|
| 276 |
+
count_bar_values.append(0.0)
|
| 277 |
+
|
| 278 |
+
# best_possible_df has no nucleus column, so this doesn't filter by nucleus
|
| 279 |
+
if show_baseline:
|
| 280 |
+
best_rmse = get_best_possible_averaged(best_possible_df, solute, formula)
|
| 281 |
+
if best_rmse is not None:
|
| 282 |
+
# baseline segment spans the box's full width including its gap
|
| 283 |
+
x_start = current_position - box_gap / 2
|
| 284 |
+
x_end = current_position + box_width + box_gap / 2
|
| 285 |
+
baseline_segments.append((x_start, x_end, best_rmse, solute))
|
| 286 |
+
|
| 287 |
+
current_position += box_width + box_gap
|
| 288 |
+
|
| 289 |
+
# Add gap after formula (except last)
|
| 290 |
+
if j < n_formulas - 1:
|
| 291 |
+
current_position += formula_gap
|
| 292 |
+
|
| 293 |
+
plot_figsize = figsize
|
| 294 |
+
fig, ax = plt.subplots(figsize=plot_figsize)
|
| 295 |
+
|
| 296 |
+
# skip boxes with no data
|
| 297 |
+
valid_indices = [i for i, data in enumerate(all_data) if len(data) > 0]
|
| 298 |
+
valid_data = [all_data[i] for i in valid_indices]
|
| 299 |
+
valid_positions = [all_positions[i] for i in valid_indices]
|
| 300 |
+
valid_colors = [all_colors[i] for i in valid_indices]
|
| 301 |
+
valid_alphas = [all_alphas[i] for i in valid_indices]
|
| 302 |
+
|
| 303 |
+
if valid_data:
|
| 304 |
+
box = ax.boxplot(
|
| 305 |
+
valid_data,
|
| 306 |
+
positions=valid_positions,
|
| 307 |
+
widths=box_width,
|
| 308 |
+
showfliers=False,
|
| 309 |
+
patch_artist=True,
|
| 310 |
+
whiskerprops=dict(color='#404040'),
|
| 311 |
+
capprops=dict(color='#404040')
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
for patch, color, alpha in zip(box['boxes'], valid_colors, valid_alphas):
|
| 315 |
+
patch.set_facecolor(color)
|
| 316 |
+
patch.set_alpha(alpha)
|
| 317 |
+
for median in box['medians']:
|
| 318 |
+
median.set_color('#404040')
|
| 319 |
+
|
| 320 |
+
if show_baseline:
|
| 321 |
+
for i, (x_start, x_end, y_value, _) in enumerate(baseline_segments):
|
| 322 |
+
ax.plot([x_start, x_end], [y_value, y_value],
|
| 323 |
+
color='gray', linestyle='--', linewidth=1.5, alpha=0.7)
|
| 324 |
+
|
| 325 |
+
# connect to the previous segment unless this is the first solute of a formula block
|
| 326 |
+
# (connects across formula gaps too, not just within one)
|
| 327 |
+
if len(formulas) > 0 and len(baseline_segments) > 0 and i % (len(baseline_segments) / len(formulas)) != 0:
|
| 328 |
+
_, prev_x_end, prev_y, _ = baseline_segments[i - 1]
|
| 329 |
+
ax.plot([prev_x_end, x_start], [prev_y, y_value],
|
| 330 |
+
color='gray', linestyle='--', linewidth=1.5, alpha=0.7)
|
| 331 |
+
|
| 332 |
+
if show_baseline and baseline_segments and baseline_annotation_text:
|
| 333 |
+
ax.text(
|
| 334 |
+
baseline_annotation_x,
|
| 335 |
+
baseline_annotation_y,
|
| 336 |
+
baseline_annotation_text,
|
| 337 |
+
transform=ax.transAxes,
|
| 338 |
+
ha='right',
|
| 339 |
+
va='top',
|
| 340 |
+
fontsize=baseline_annotation_fontsize,
|
| 341 |
+
color='gray',
|
| 342 |
+
style='italic',
|
| 343 |
+
alpha=0.85,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# the full-fit-line RMSE uses the first formula's delta-22 fit as its reference
|
| 347 |
+
if show_full_fit_line:
|
| 348 |
+
formula_remapped = formula_remap.get(formulas[0], formulas[0])
|
| 349 |
+
query = f'formula == "{formula_remapped}"' + (f' and solvent in {solvents_filter}' if solvents_filter is not None else '')
|
| 350 |
+
full_fit_rmse = all_solute_fitting_results[nucleus].query(query)['rmse'].mean()
|
| 351 |
+
ax.axhline(y=full_fit_rmse, color='red', linestyle='-.', linewidth=1.5, alpha=0.7)
|
| 352 |
+
ax.text(full_fit_line_label_x,
|
| 353 |
+
full_fit_rmse, f'{full_fit_line_label}: {full_fit_rmse:.3f} ppm',
|
| 354 |
+
transform=ax.get_yaxis_transform(),
|
| 355 |
+
ha='right', va='bottom', fontsize=9, color='red', style='italic', alpha=0.7)
|
| 356 |
+
|
| 357 |
+
ax.set_xticks(tick_positions)
|
| 358 |
+
ax.set_xticklabels(tick_labels, rotation=75, fontsize=xtick_fontsize, ha='center')
|
| 359 |
+
for i, label_obj in enumerate(ax.get_xticklabels()):
|
| 360 |
+
if tick_labels[i] == 'delta-22':
|
| 361 |
+
label_obj.set_weight('bold')
|
| 362 |
+
label_obj.set_fontsize(delta22_fontsize)
|
| 363 |
+
label_obj.set_style('italic')
|
| 364 |
+
|
| 365 |
+
# vertical separators between formula blocks
|
| 366 |
+
for j in range(1, n_formulas):
|
| 367 |
+
x_pos = j * (total_boxes_per_formula * box_width + formula_gap) - formula_gap / 2
|
| 368 |
+
ax.axvline(x=x_pos, color='gray', linestyle='--', linewidth=1, alpha=0.5)
|
| 369 |
+
|
| 370 |
+
if solvents_filter is not None:
|
| 371 |
+
solvents_str = ', '.join(solvents_filter)
|
| 372 |
+
ylabel = f"1H RMSE (ppm)\n(Averaged across: {solvents_str})"
|
| 373 |
+
else:
|
| 374 |
+
ylabel = "1H RMSE (ppm)"
|
| 375 |
+
ax.set_ylabel(ylabel, fontsize=ylabel_fontsize, fontweight='bold')
|
| 376 |
+
ax.set_ylim(bottom=0)
|
| 377 |
+
|
| 378 |
+
# caller may override via title=; the default text reflects the solvent filter
|
| 379 |
+
if title is None:
|
| 380 |
+
if solvents_filter is not None:
|
| 381 |
+
title = f"Delta22 vs Individual Solute Bootstrap Sample RMSEs ({nucleus} nucleus; {solvents_filter})"
|
| 382 |
+
else:
|
| 383 |
+
title = f"Delta22 vs Individual Solute Bootstrap Sample RMSEs ({nucleus} nucleus, solvent-averaged)"
|
| 384 |
+
ax.set_title(title, fontsize=title_fontsize, fontweight='bold')
|
| 385 |
+
|
| 386 |
+
ax.set_xlim(-0.2, current_position + 0.2)
|
| 387 |
+
|
| 388 |
+
# site-count bars use a dedicated right (twin) axis
|
| 389 |
+
added_site_count_bars = False
|
| 390 |
+
site_count_ax = None
|
| 391 |
+
max_count = max(count_bar_values) if count_bar_values else 0.0
|
| 392 |
+
if site_count_axis_mode != 'off' and max_bar_height > 0 and len(count_bar_positions) > 0 and max_count > 0:
|
| 393 |
+
site_count_ax = ax.twinx()
|
| 394 |
+
|
| 395 |
+
tick_counts, rounded_count_top = get_rounded_count_ticks(max_count)
|
| 396 |
+
if site_count_preserve_visual_height:
|
| 397 |
+
rmse_top = ax.get_ylim()[1]
|
| 398 |
+
rmse_top = max(rmse_top, 1e-6)
|
| 399 |
+
bar_target = max(max_bar_height, 1e-6)
|
| 400 |
+
# Keep bars in the lower band while right-axis tick labels remain true counts.
|
| 401 |
+
axis_top = max(rounded_count_top * (rmse_top / bar_target), rounded_count_top * 1.05)
|
| 402 |
+
else:
|
| 403 |
+
axis_top = max(rounded_count_top, 1.0)
|
| 404 |
+
|
| 405 |
+
site_count_ax.bar(
|
| 406 |
+
count_bar_positions,
|
| 407 |
+
count_bar_values,
|
| 408 |
+
width=box_width + box_gap,
|
| 409 |
+
bottom=0,
|
| 410 |
+
color='#404040',
|
| 411 |
+
alpha=site_count_alpha,
|
| 412 |
+
edgecolor='none',
|
| 413 |
+
zorder=0,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
site_count_ax.set_ylim(0, axis_top)
|
| 417 |
+
|
| 418 |
+
if site_count_axis_mode == 'inset':
|
| 419 |
+
# Hide inset twin-axis renderer and draw a short attached axis manually.
|
| 420 |
+
for spine_name in ('left', 'right', 'top', 'bottom'):
|
| 421 |
+
site_count_ax.spines[spine_name].set_visible(False)
|
| 422 |
+
site_count_ax.patch.set_visible(False)
|
| 423 |
+
site_count_ax.yaxis.set_visible(False)
|
| 424 |
+
|
| 425 |
+
inset_axis_top_frac = min(rounded_count_top / axis_top, 1.0)
|
| 426 |
+
# clip to keep the inset axis inside the plotting area near the right edge
|
| 427 |
+
inset_x = float(np.clip(1.0 + site_count_inset_axis_offset, 0.0, 1.0))
|
| 428 |
+
|
| 429 |
+
ax.plot(
|
| 430 |
+
[inset_x, inset_x],
|
| 431 |
+
[0.0, inset_axis_top_frac],
|
| 432 |
+
transform=ax.transAxes,
|
| 433 |
+
color='#404040',
|
| 434 |
+
linewidth=1.0,
|
| 435 |
+
clip_on=False,
|
| 436 |
+
zorder=3,
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
# Manual ticks and tick labels, confined to the short segment.
|
| 440 |
+
tick_len = 0.008
|
| 441 |
+
tick_label_pad = 0.012
|
| 442 |
+
for t in tick_counts:
|
| 443 |
+
if t == 0:
|
| 444 |
+
continue
|
| 445 |
+
y_frac = t / axis_top
|
| 446 |
+
if y_frac <= inset_axis_top_frac + 1e-9:
|
| 447 |
+
ax.plot(
|
| 448 |
+
[inset_x, inset_x + tick_len],
|
| 449 |
+
[y_frac, y_frac],
|
| 450 |
+
transform=ax.transAxes,
|
| 451 |
+
color='#404040',
|
| 452 |
+
linewidth=0.9,
|
| 453 |
+
clip_on=False,
|
| 454 |
+
zorder=3,
|
| 455 |
+
)
|
| 456 |
+
ax.text(
|
| 457 |
+
inset_x + tick_len + tick_label_pad,
|
| 458 |
+
y_frac,
|
| 459 |
+
f"{int(t)}",
|
| 460 |
+
transform=ax.transAxes,
|
| 461 |
+
va='center',
|
| 462 |
+
ha='left',
|
| 463 |
+
fontsize=10,
|
| 464 |
+
color='#404040',
|
| 465 |
+
clip_on=False,
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
label_x = inset_x + site_count_inset_axis_label_pad
|
| 469 |
+
ax.text(
|
| 470 |
+
label_x,
|
| 471 |
+
inset_axis_top_frac / 2,
|
| 472 |
+
site_count_label,
|
| 473 |
+
transform=ax.transAxes,
|
| 474 |
+
rotation=90,
|
| 475 |
+
va='center',
|
| 476 |
+
ha='left',
|
| 477 |
+
fontsize=11,
|
| 478 |
+
fontweight='bold',
|
| 479 |
+
color='#404040',
|
| 480 |
+
clip_on=False,
|
| 481 |
+
)
|
| 482 |
+
else:
|
| 483 |
+
site_count_ax.set_yticks(tick_counts)
|
| 484 |
+
site_count_ax.set_yticklabels([f"{int(t)}" for t in tick_counts])
|
| 485 |
+
site_count_ax.set_ylabel(site_count_label, fontsize=13, fontweight='bold', color='#404040')
|
| 486 |
+
site_count_ax.tick_params(axis='y', labelcolor='#404040')
|
| 487 |
+
site_count_ax.spines['right'].set_color('#404040')
|
| 488 |
+
|
| 489 |
+
site_count_ax.grid(False)
|
| 490 |
+
|
| 491 |
+
# Keep RMSE artists visually above site-count bars.
|
| 492 |
+
ax.set_zorder(2)
|
| 493 |
+
ax.patch.set_alpha(0)
|
| 494 |
+
site_count_ax.set_zorder(1)
|
| 495 |
+
added_site_count_bars = True
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
if site_count_axis_mode == 'inset' and added_site_count_bars:
|
| 499 |
+
ax.set_xlim(left=-box_width/2-box_gap/2, right=4.55)
|
| 500 |
+
plt.tight_layout()
|
| 501 |
+
# PNG is the release format; SVG is also supported for standalone vector export
|
| 502 |
+
if save_path is not None:
|
| 503 |
+
outdir = os.path.dirname(save_path)
|
| 504 |
+
if outdir:
|
| 505 |
+
os.makedirs(outdir, exist_ok=True)
|
| 506 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 507 |
+
|
| 508 |
+
if save_svg_path is not None:
|
| 509 |
+
# If user passed a directory, save into it with default name
|
| 510 |
+
if os.path.isdir(save_svg_path) or save_svg_path.endswith(os.sep):
|
| 511 |
+
save_svg_path = os.path.join(save_svg_path, default_svg_name)
|
| 512 |
+
|
| 513 |
+
if not save_svg_path.lower().endswith(".svg"):
|
| 514 |
+
save_svg_path = save_svg_path + ".svg"
|
| 515 |
+
|
| 516 |
+
outdir = os.path.dirname(save_svg_path)
|
| 517 |
+
if outdir:
|
| 518 |
+
os.makedirs(outdir, exist_ok=True)
|
| 519 |
+
|
| 520 |
+
fig.savefig(
|
| 521 |
+
save_svg_path,
|
| 522 |
+
format="svg",
|
| 523 |
+
bbox_inches="tight",
|
| 524 |
+
pad_inches=0.02,
|
| 525 |
+
transparent=svg_transparent,
|
| 526 |
+
)
|
| 527 |
+
print(f"Saved SVG to: {os.path.abspath(save_svg_path)}")
|
| 528 |
+
|
| 529 |
+
plt.show()
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def plot_nps_benefit_barplot(regression_ss_df, nps_rmse_df, nucleus, solvents, np_solute_groups,
|
| 533 |
+
formulas, labels, colors, formula_remap=None, figsize=(6, 5),
|
| 534 |
+
box_width=0.18, formula_gap=0.04, solvent_gap=0.35, spacer_width=0.0,
|
| 535 |
+
y_min=0.0, y_max=None, save_path=None):
|
| 536 |
+
"""Grouped bar chart of mean bootstrap RMSE per (solvent, dataset, formula), with 2.5-97.5
|
| 537 |
+
percentile bootstrap error bars. Delta-22 is drawn first within each solvent, then the
|
| 538 |
+
NP/complex bin. Used by main-text Figure 5C (implicit vs explicit correction, delta-22 vs the
|
| 539 |
+
test set)."""
|
| 540 |
+
sns.set_theme(style="ticks", context="paper")
|
| 541 |
+
formula_remap = formula_remap or {}
|
| 542 |
+
n_formulas = len(formulas)
|
| 543 |
+
label_remap = {labels[0]: "Implicit", labels[1]: "Explicit"}
|
| 544 |
+
label_to_color = {label_remap.get(labels[i], labels[i]): colors[i] for i in range(len(labels))}
|
| 545 |
+
flat_solvents = [s for g in solvents for s in g] if isinstance(solvents[0], list) else list(solvents)
|
| 546 |
+
solvent_groups_iter = solvents if isinstance(solvents[0], list) else [solvents]
|
| 547 |
+
|
| 548 |
+
def d22_by_solvent(formula):
|
| 549 |
+
df = regression_ss_df[regression_ss_df["formula"] == formula]
|
| 550 |
+
return {s: df[df["solvent"] == s]["Bootstrap_RMSE"].dropna().values for s in flat_solvents}
|
| 551 |
+
|
| 552 |
+
def np_grouped(formula, solvent):
|
| 553 |
+
df = nps_rmse_df[(nps_rmse_df["formula"] == formula) & (nps_rmse_df["solvent"] == solvent)
|
| 554 |
+
& (nps_rmse_df["nucleus"] == nucleus)]
|
| 555 |
+
if df.empty:
|
| 556 |
+
return {}
|
| 557 |
+
out = {}
|
| 558 |
+
for g in np_solute_groups.keys():
|
| 559 |
+
arr = df[df["solute_group"] == g]["Bootstrap_RMSE"].dropna().values
|
| 560 |
+
if arr.size:
|
| 561 |
+
out[g] = arr
|
| 562 |
+
return out
|
| 563 |
+
|
| 564 |
+
def summ(vals):
|
| 565 |
+
vals = np.asarray(vals, float); vals = vals[~np.isnan(vals)]
|
| 566 |
+
if vals.size == 0:
|
| 567 |
+
return None
|
| 568 |
+
m = np.mean(vals); lo, hi = np.percentile(vals, [2.5, 97.5])
|
| 569 |
+
return m, m - lo, hi - m
|
| 570 |
+
|
| 571 |
+
d22 = [d22_by_solvent(f) for f in formulas]
|
| 572 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 573 |
+
category_gap = 0.55
|
| 574 |
+
x = 0.0
|
| 575 |
+
ticks, ticklabels, solvent_centers, seen = [], [], {}, {}
|
| 576 |
+
|
| 577 |
+
def draw_pair(center, getter):
|
| 578 |
+
drew = False
|
| 579 |
+
for j, formula in enumerate(formulas):
|
| 580 |
+
stats = getter(j, formula)
|
| 581 |
+
if stats is None:
|
| 582 |
+
continue
|
| 583 |
+
mean, elo, ehi = stats
|
| 584 |
+
disp = label_remap.get(labels[j], labels[j])
|
| 585 |
+
offset = (j - (n_formulas - 1) / 2) * (box_width + formula_gap)
|
| 586 |
+
bar = ax.bar(center + offset, mean, width=box_width,
|
| 587 |
+
yerr=np.array([[elo], [ehi]]), capsize=3, color=label_to_color[disp],
|
| 588 |
+
edgecolor="black", linewidth=0.9, zorder=3,
|
| 589 |
+
label=disp if disp not in seen else None)
|
| 590 |
+
seen.setdefault(disp, bar)
|
| 591 |
+
drew = True
|
| 592 |
+
return drew
|
| 593 |
+
|
| 594 |
+
for solvent_group in solvent_groups_iter:
|
| 595 |
+
for solvent in solvent_group:
|
| 596 |
+
centers = [x]
|
| 597 |
+
draw_pair(x, lambda j, f: summ(d22[j].get(solvent, np.array([]))))
|
| 598 |
+
ticks.append(x); ticklabels.append("delta-22"); x += category_gap
|
| 599 |
+
for bin_label in np_solute_groups.keys():
|
| 600 |
+
if draw_pair(x, lambda j, f, s=solvent, b=bin_label:
|
| 601 |
+
summ(np_grouped(formula_remap.get(f, f), s).get(b, [])) if
|
| 602 |
+
np_grouped(formula_remap.get(f, f), s).get(b) is not None else None):
|
| 603 |
+
centers.append(x); ticks.append(x)
|
| 604 |
+
ticklabels.append("complex" if bin_label == "Test Set" else bin_label)
|
| 605 |
+
x += category_gap
|
| 606 |
+
solvent_centers[solvent] = np.mean(centers)
|
| 607 |
+
x += solvent_gap
|
| 608 |
+
x += spacer_width
|
| 609 |
+
|
| 610 |
+
ax.set_xticks(ticks)
|
| 611 |
+
ax.set_xticklabels(ticklabels, rotation=45, fontsize=9, style="italic", ha="center")
|
| 612 |
+
for lab in ax.get_xticklabels():
|
| 613 |
+
if lab.get_text() == "delta-22":
|
| 614 |
+
lab.set_fontweight("bold"); lab.set_fontsize(10); lab.set_style("normal")
|
| 615 |
+
for solvent, center in solvent_centers.items():
|
| 616 |
+
ax.text(center, -0.17, solvent, transform=ax.get_xaxis_transform(),
|
| 617 |
+
ha="center", va="top", fontsize=12, fontweight="bold")
|
| 618 |
+
nuc = "^{1}\\mathrm{H}" if nucleus == "H" else "^{13}\\mathrm{C}"
|
| 619 |
+
ax.set_ylabel(r"RMSE $(" + nuc + r"$ ppm)", fontsize=13)
|
| 620 |
+
ax.set_ylim(bottom=y_min, top=y_max)
|
| 621 |
+
disp_nuc = "1H" if nucleus == "H" else "13C"
|
| 622 |
+
ax.set_title(f"Delta-22 vs NP/Isomer Bootstrap RMSE by Solvent ({disp_nuc} nucleus)",
|
| 623 |
+
fontsize=14, fontweight="bold")
|
| 624 |
+
ax.tick_params(axis="both", which="major", labelsize=10, length=4, width=0.8)
|
| 625 |
+
ax.yaxis.grid(True, linestyle="-", linewidth=0.4, alpha=0.35, zorder=0)
|
| 626 |
+
ax.xaxis.grid(False)
|
| 627 |
+
sns.despine(ax=ax, top=True, right=True)
|
| 628 |
+
for side in ["left", "bottom", "right", "top"]:
|
| 629 |
+
ax.spines[side].set_linewidth(0.9)
|
| 630 |
+
leg = ax.legend(frameon=False, fontsize=10)
|
| 631 |
+
if leg is not None:
|
| 632 |
+
leg.set_title("")
|
| 633 |
+
fig.tight_layout()
|
| 634 |
+
if save_path:
|
| 635 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 636 |
+
plt.show()
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def plot_fitting_rmse_comparison_bars(table, nucleus, solvent, figsize=(11, 4), save_path=None):
|
| 640 |
+
"""One group of three bars per test-set solute: fit on that solute alone ("Scaled to Solute"),
|
| 641 |
+
fit once across the whole test set ("Scaled to Test Set"), and delta-22's bootstrap
|
| 642 |
+
coefficients applied cold ("Extrapolated from delta22"). Used by SI Figure S15's "Fitting RMSE
|
| 643 |
+
Comparisons" panel."""
|
| 644 |
+
colors = {"Scaled to Solute": "#4C72B0", "Scaled to Test Set": "#DD8452",
|
| 645 |
+
"Extrapolated from delta22": "#55A868"}
|
| 646 |
+
columns = list(colors.keys())
|
| 647 |
+
x = np.arange(len(table))
|
| 648 |
+
width = 0.27
|
| 649 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 650 |
+
for i, col in enumerate(columns):
|
| 651 |
+
ax.bar(x + (i - 1) * width, table[col].to_numpy(), width,
|
| 652 |
+
label=col.replace("delta22", "Δ22"), color=colors[col])
|
| 653 |
+
ax.set_xticks(x)
|
| 654 |
+
ax.set_xticklabels([str(s).replace("\n", " ") for s in table.index], rotation=30, ha="right")
|
| 655 |
+
ax.set_ylabel(f"{'¹H' if nucleus == 'H' else '¹³C'} RMSE (ppm)")
|
| 656 |
+
ax.set_title(f"Fitting RMSE Comparisons ({solvent}, {nucleus})")
|
| 657 |
+
ax.legend()
|
| 658 |
+
fig.tight_layout()
|
| 659 |
+
if save_path:
|
| 660 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 661 |
+
return fig, ax
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
# --- SI Figure S15 feature-space / residual 2x2 grids + distribution-shift bars ---
|
| 665 |
+
# style constants private to the two _dataset_scatter_grid callers below.
|
| 666 |
+
_DATASET_COLORS = {"Test Set": "#7e57c2", "Delta22": "#2ca02c"} # purple circles / green squares
|
| 667 |
+
_DATASET_MARKERS = {"Test Set": "o", "Delta22": "s"}
|
| 668 |
+
_SOLVENT_DISPLAY = {"chloroform": "Chloroform", "benzene": "Benzene",
|
| 669 |
+
"methanol": "Methanol", "TIP4P": "Water (TIP4P)"}
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def _dataset_scatter_grid(table, x_col, y_col, solvents, title, subtitle, x_label, y_label,
|
| 673 |
+
figsize, zero_lines):
|
| 674 |
+
"""2x2 solvent grid overlaying the Test Set and delta-22 point clouds, shared x/y across panels."""
|
| 675 |
+
fig, axes = plt.subplots(2, 2, figsize=figsize, sharex=True, sharey=True)
|
| 676 |
+
axes = axes.ravel()
|
| 677 |
+
for ax, solvent in zip(axes, solvents):
|
| 678 |
+
sdf = table[table["solvent"] == solvent]
|
| 679 |
+
for dataset in ["Test Set", "Delta22"]:
|
| 680 |
+
sub = sdf[sdf["dataset"] == dataset]
|
| 681 |
+
if not sub.empty:
|
| 682 |
+
ax.scatter(sub[x_col], sub[y_col], s=32, alpha=0.75, marker=_DATASET_MARKERS[dataset],
|
| 683 |
+
c=_DATASET_COLORS[dataset], edgecolors="white", linewidths=0.5, label=dataset)
|
| 684 |
+
if zero_lines:
|
| 685 |
+
ax.axhline(0, color="0.35", linestyle="--", linewidth=1.0, zorder=0)
|
| 686 |
+
ax.axvline(0, color="0.35", linestyle="--", linewidth=1.0, zorder=0)
|
| 687 |
+
else:
|
| 688 |
+
ax.axhline(0, color="0.5", linestyle="--", linewidth=1.0, zorder=0)
|
| 689 |
+
ax.set_title(_SOLVENT_DISPLAY.get(solvent, solvent), fontsize=11, fontweight="semibold")
|
| 690 |
+
ax.grid(alpha=0.2)
|
| 691 |
+
handles, labels = axes[0].get_legend_handles_labels()
|
| 692 |
+
fig.legend(handles, labels, loc="center left", bbox_to_anchor=(0.88, 0.5), frameon=False, title="Dataset")
|
| 693 |
+
fig.supxlabel(x_label, fontsize=11)
|
| 694 |
+
fig.supylabel(y_label, fontsize=11)
|
| 695 |
+
fig.suptitle(title, fontsize=13, fontweight="bold")
|
| 696 |
+
fig.text(0.5, 0.94, subtitle, ha="center", fontsize=10, color="0.35")
|
| 697 |
+
fig.tight_layout(rect=[0.02, 0.02, 0.86, 0.92])
|
| 698 |
+
return fig, axes
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def plot_feature_space_coverage_grid(table, nucleus, x_label,
|
| 702 |
+
solvents=("chloroform", "benzene", "methanol", "TIP4P"),
|
| 703 |
+
figsize=(11, 9), save_path=None):
|
| 704 |
+
"""SI Figure S15's "Feature Space Coverage by Solvent" panel."""
|
| 705 |
+
subtitle = f"{'Hydrogen' if nucleus == 'H' else 'Carbon'} sites: Test Set vs Delta22"
|
| 706 |
+
fig, axes = _dataset_scatter_grid(table, "x", "y", solvents, "Feature Space Coverage by Solvent",
|
| 707 |
+
subtitle, x_label, "OpenMM Correction (centered, ppm)",
|
| 708 |
+
figsize, zero_lines=True)
|
| 709 |
+
if save_path:
|
| 710 |
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
| 711 |
+
return fig, axes
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def plot_delta22_plane_residuals_grid(table, nucleus,
|
| 715 |
+
solvents=("chloroform", "benzene", "methanol", "TIP4P"),
|
| 716 |
+
figsize=(11, 9), save_path=None):
|
| 717 |
+
"""SI Figure S15's "Residuals for Delta22 Fitting Coefficients" panel."""
|
| 718 |
+
subtitle = f"{'Hydrogen' if nucleus == 'H' else 'Carbon'} sites: Test Set vs Delta22"
|
| 719 |
+
fig, axes = _dataset_scatter_grid(table, "experimental", "residual", solvents,
|
| 720 |
+
"Residuals for Delta22 Fitting Coefficients", subtitle,
|
| 721 |
+
"Experimental Shielding (ppm)",
|
| 722 |
+
"Residual vs Delta22 Plane (experimental - predicted)",
|
| 723 |
+
figsize, zero_lines=False)
|
| 724 |
+
if save_path:
|
| 725 |
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
| 726 |
+
return fig, axes
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def plot_distribution_shift_by_solvent_bars(table, nucleus, figsize=(7, 5), save_path=None):
|
| 730 |
+
"""One group of three bars per solvent: extrapolated-from-delta22 / scaled-to-test-set /
|
| 731 |
+
scaled-to-solute RMSE. The three sitting close together (except water) is the transferability
|
| 732 |
+
story. Used by SI Figure S15's "Distribution Shift by Solvent" panel."""
|
| 733 |
+
colors = {"Extrapolated from delta22": "#A72608", "Scaled to Test Set": "#C93240",
|
| 734 |
+
"Scaled to Solute": "#61a89a"}
|
| 735 |
+
columns = list(colors.keys())
|
| 736 |
+
x = np.arange(len(table))
|
| 737 |
+
width = 0.26
|
| 738 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 739 |
+
for i, col in enumerate(columns):
|
| 740 |
+
ax.bar(x + (i - 1) * width, table[col].to_numpy(), width,
|
| 741 |
+
label=col.replace("delta22", "Δ22"), color=colors[col])
|
| 742 |
+
ax.set_xticks(x)
|
| 743 |
+
ax.set_xticklabels([str(s) for s in table.index])
|
| 744 |
+
ax.set_ylabel(f"{'¹H' if nucleus == 'H' else '¹³C'} RMSE (ppm)")
|
| 745 |
+
ax.set_title(f"Distribution Shift by Solvent ({nucleus})")
|
| 746 |
+
ax.legend(fontsize=9)
|
| 747 |
+
ax.grid(axis="y", alpha=0.3)
|
| 748 |
+
fig.tight_layout()
|
| 749 |
+
if save_path:
|
| 750 |
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
| 751 |
+
return fig, ax
|
| 752 |
+
|
analysis/code/build_composite_model.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regenerate the composite_model/ group in applications.hdf5 from the shipped delta-22 data.
|
| 2 |
+
|
| 3 |
+
Replaces the old build_coefficients.py, which copied pre-fit CSVs out of a private tree via
|
| 4 |
+
environment variables. Everything here is fit in-repo from delta22.hdf5, so the natural-products
|
| 5 |
+
composite-model coefficients (Figure 5C/5D, SI S15) are reproducible from released inputs alone.
|
| 6 |
+
|
| 7 |
+
The stored group's structure (formula keys, parameter order, solvent order, seed count) is read
|
| 8 |
+
from the current applications.hdf5 and used as a template; only the numbers are regenerated.
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import random
|
| 12 |
+
import sys
|
| 13 |
+
|
| 14 |
+
import h5py
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 19 |
+
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
|
| 20 |
+
for _p in ("analysis/code", "analysis/code/shared", "data/delta22", "data/applications"):
|
| 21 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 22 |
+
|
| 23 |
+
import delta22 as D # noqa: E402
|
| 24 |
+
import paths # noqa: E402
|
| 25 |
+
|
| 26 |
+
# The 22 delta-22 solutes in first-appearance order: the universe random.choices() draws from.
|
| 27 |
+
# nitromethane stays in the draw pool (index 14) but its rows are dropped from the fit data, exactly
|
| 28 |
+
# as the original pipeline did.
|
| 29 |
+
SOLUTE_ORDER = ["AcOH", "EtOAc", "EtOH", "THF", "acetone", "acetonitrile", "benzaldehyde", "dmf",
|
| 30 |
+
"dmso", "ethane", "ethylene", "glycol", "imidazole", "isopropanol", "nitromethane",
|
| 31 |
+
"propylene", "pyridine", "pyrrole", "pyrrolidine", "tbme", "toluene", "triethylamine"]
|
| 32 |
+
SEED_OFFSET = 100
|
| 33 |
+
BASE_FEATURES = ["experimental", "stationary", "qcd", "pcm", "desmond", "desmond_vib",
|
| 34 |
+
"openMM", "openMM_vib"]
|
| 35 |
+
ALIASES = {"des_vib": "desmond_vib", "op_vib": "openMM_vib"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _load_tables():
|
| 39 |
+
"""Three delta-22 NN tables (nitromethane rows removed) plus the pcm conversion factors:
|
| 40 |
+
|
| 41 |
+
full_tab - every row; the OLS point fits use it and drop only each formula's own missing
|
| 42 |
+
features, so pcm/qcd formulas fit all 12 solvents.
|
| 43 |
+
fit_tab - strict: all base features present (only the 4 explicit-solvent solvents survive);
|
| 44 |
+
the bootstrap resamples fit here, matching the stored bootstrap's 4 solvents.
|
| 45 |
+
apply_tab - desmond or openMM present; the RMSE predictions are evaluated over it.
|
| 46 |
+
"""
|
| 47 |
+
h5 = paths.dataset_file("delta22", root=REPO)
|
| 48 |
+
xlsx = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 49 |
+
nn = D.load_query_df_nn(h5, xlsx, verbose=False)
|
| 50 |
+
dft = D.load_query_df_dft(h5, xlsx, verbose=False)
|
| 51 |
+
factors = {"H": D.pcm_conversion_factors(dft, "H"), "C": D.pcm_conversion_factors(dft, "C")}
|
| 52 |
+
nn = nn[nn["solute"] != "nitromethane"].copy()
|
| 53 |
+
# stationary_plus_pcm folds in the per-solvent conversion factor; every other _plus_ column is a
|
| 54 |
+
# plain sum, built on demand by _ensure_column.
|
| 55 |
+
fac = nn.apply(lambda r: factors[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
|
| 56 |
+
nn["stationary_plus_pcm"] = nn["stationary"] + nn["pcm"] * fac
|
| 57 |
+
full_tab = nn.reset_index(drop=True)
|
| 58 |
+
fit_tab = nn.dropna(subset=BASE_FEATURES).reset_index(drop=True)
|
| 59 |
+
apply_tab = nn.dropna(subset=["desmond", "openMM"], how="all").reset_index(drop=True)
|
| 60 |
+
return full_tab, fit_tab, apply_tab, factors
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _canonical(name):
|
| 64 |
+
return ALIASES.get(name, name)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _regressor_columns(formula_key):
|
| 68 |
+
"""The design-matrix regressors for a formula key. '_AND_' separates independent regressors;
|
| 69 |
+
a '_plus_' name is one precomputed sum column. Returns the list of column names to fit on."""
|
| 70 |
+
return [g for g in formula_key.split("_AND_")]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _plus_parts(regressor):
|
| 74 |
+
"""Base features a '_plus_' regressor decomposes into (canonical names); [] if it is itself a
|
| 75 |
+
base feature."""
|
| 76 |
+
if "_plus_" not in regressor:
|
| 77 |
+
return []
|
| 78 |
+
return [_canonical(p) for p in regressor.split("_plus_") if p]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _ensure_column(df, regressor):
|
| 82 |
+
"""Make sure a precomputed _plus_ regressor column exists on df (sum of its parts; the pcm part
|
| 83 |
+
already carries the conversion factor via stationary_plus_pcm)."""
|
| 84 |
+
if regressor in df.columns:
|
| 85 |
+
return
|
| 86 |
+
parts = regressor.split("_plus_")
|
| 87 |
+
assert _canonical(parts[0]) == "stationary", f"unexpected base term in {regressor}"
|
| 88 |
+
# build cumulatively so stationary_plus_pcm_plus_op_vib = stationary_plus_pcm + openMM_vib. The
|
| 89 |
+
# pcm chains start from the scaled stationary_plus_pcm column; everything else from raw stationary.
|
| 90 |
+
if parts[1:2] == ["pcm"]:
|
| 91 |
+
col = df["stationary_plus_pcm"].to_numpy(float).copy()
|
| 92 |
+
rest = parts[2:]
|
| 93 |
+
else:
|
| 94 |
+
col = df["stationary"].to_numpy(float).copy()
|
| 95 |
+
rest = parts[1:]
|
| 96 |
+
for p in rest:
|
| 97 |
+
col = col + df[_canonical(p)].to_numpy(float)
|
| 98 |
+
df[regressor] = col
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _fit(df, regressors):
|
| 102 |
+
"""OLS of experimental on the given regressor columns (with intercept); returns {name: coeff}
|
| 103 |
+
keyed by 'Intercept' and each regressor. NaN rows are dropped, matching statsmodels."""
|
| 104 |
+
y = pd.to_numeric(df["experimental"], errors="coerce").to_numpy(float)
|
| 105 |
+
cols = [df[r].to_numpy(float) for r in regressors]
|
| 106 |
+
X = np.column_stack([np.ones(len(df))] + cols)
|
| 107 |
+
mask = np.isfinite(y) & np.all(np.isfinite(X), axis=1)
|
| 108 |
+
beta, *_ = np.linalg.lstsq(X[mask], y[mask], rcond=None)
|
| 109 |
+
return dict(zip(["Intercept"] + list(regressors), beta))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _decompose(coeffs, regressors, solvent, nucleus, factors):
|
| 113 |
+
"""Expand each _plus_ regressor's coefficient onto its base features (same value copied). The pcm
|
| 114 |
+
row is scaled by the per-solvent conversion factor ONLY when the formula used a
|
| 115 |
+
stationary_plus_pcm term (a free _AND_pcm regressor stays raw). Returns {base_feature: coeff}."""
|
| 116 |
+
out = {"intercept": coeffs["Intercept"]}
|
| 117 |
+
for reg, val in coeffs.items():
|
| 118 |
+
if reg == "Intercept":
|
| 119 |
+
continue
|
| 120 |
+
parts = _plus_parts(reg)
|
| 121 |
+
for base in (parts if parts else [_canonical(reg)]):
|
| 122 |
+
out[base] = val
|
| 123 |
+
uses_spp = any(_plus_parts(r)[1:2] == ["pcm"] for r in regressors)
|
| 124 |
+
if uses_spp and "pcm" in out:
|
| 125 |
+
out["pcm"] = out["pcm"] * float(factors[nucleus].get(solvent, np.nan))
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _bootstrap_sample(fit_tab, nucleus, solvent, seed):
|
| 130 |
+
"""The rows fit for one bootstrap draw: seed the RNG with seed+100, draw 22 solutes with
|
| 131 |
+
replacement from SOLUTE_ORDER, keep that solvent/nucleus's rows for the drawn solutes."""
|
| 132 |
+
random.seed(int(seed) + SEED_OFFSET)
|
| 133 |
+
drawn = random.choices(SOLUTE_ORDER, k=22)
|
| 134 |
+
return fit_tab[(fit_tab["nucleus"] == nucleus) & (fit_tab["solvent"] == solvent)
|
| 135 |
+
& (fit_tab["solute"].isin(drawn))]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _rmse(fit_tab, apply_tab, display_formula, nucleus, solvent, seed, factors):
|
| 139 |
+
"""Bootstrap RMSE: fit the formula on one bootstrap draw, apply those raw regressor coefficients
|
| 140 |
+
to every apply-table row for the solvent, and take the RMSE over labeled rows. The RMSE display
|
| 141 |
+
names join regressors with ' + ' (each is a column, possibly a precomputed _plus_ one)."""
|
| 142 |
+
regs = [t.strip() for t in display_formula.split(" + ")]
|
| 143 |
+
for r in (fit_tab, apply_tab):
|
| 144 |
+
for reg in regs:
|
| 145 |
+
_ensure_column(r, reg)
|
| 146 |
+
coeffs = _fit(_bootstrap_sample(fit_tab, nucleus, solvent, seed), regs)
|
| 147 |
+
a = apply_tab[(apply_tab["nucleus"] == nucleus) & (apply_tab["solvent"] == solvent)]
|
| 148 |
+
pred = coeffs["Intercept"] + sum(coeffs[r] * a[r].to_numpy(float) for r in regs)
|
| 149 |
+
exp = pd.to_numeric(a["experimental"], errors="coerce").to_numpy(float)
|
| 150 |
+
mask = np.isfinite(pred) & np.isfinite(exp)
|
| 151 |
+
return float(np.sqrt(np.mean((pred[mask] - exp[mask]) ** 2)))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _pcm_table(factors, nucleus):
|
| 155 |
+
"""pcm_conversion_factors subgroup for one nucleus: solvent, pcm_conversion_factor. Stored under
|
| 156 |
+
the 'water' name rather than the loader's 'TIP4P'."""
|
| 157 |
+
s = factors[nucleus].rename(index={"TIP4P": "water"})
|
| 158 |
+
return pd.DataFrame({"solvent": s.index, "pcm_conversion_factor": s.to_numpy()})
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def regenerate(reader):
|
| 162 |
+
"""Regenerate every composite_model subgroup from delta-22, keyed and ordered to match the stored
|
| 163 |
+
group. Returns {"ols_coefficients"|"bootstrap_coefficients"|"rmse_distributions"|
|
| 164 |
+
"pcm_conversion_factors": {key: DataFrame}}."""
|
| 165 |
+
full_tab, fit_tab, apply_tab, factors = _load_tables()
|
| 166 |
+
out = {k: {} for k in ("ols_coefficients", "bootstrap_coefficients",
|
| 167 |
+
"rmse_distributions", "pcm_conversion_factors")}
|
| 168 |
+
|
| 169 |
+
for f in reader.composite_formulas("ols_coefficients"):
|
| 170 |
+
regs = _regressor_columns(f)
|
| 171 |
+
for reg in regs:
|
| 172 |
+
_ensure_column(full_tab, reg)
|
| 173 |
+
for nuc in ("H", "C"):
|
| 174 |
+
try:
|
| 175 |
+
template = reader.ols_coefficients(f, nuc)
|
| 176 |
+
except KeyError:
|
| 177 |
+
continue
|
| 178 |
+
params = template["parameter"].tolist()
|
| 179 |
+
solvents = [c for c in template.columns if c != "parameter"]
|
| 180 |
+
data = {"parameter": params}
|
| 181 |
+
for solvent in solvents:
|
| 182 |
+
sub = full_tab[(full_tab["nucleus"] == nuc) & (full_tab["solvent"] == solvent)]
|
| 183 |
+
dec = _decompose(_fit(sub, regs), regs, solvent, nuc, factors)
|
| 184 |
+
data[solvent] = [dec[p] for p in params]
|
| 185 |
+
out["ols_coefficients"][(f, nuc)] = pd.DataFrame(data)
|
| 186 |
+
|
| 187 |
+
for f in reader.composite_formulas("bootstrap_coefficients"):
|
| 188 |
+
regs = _regressor_columns(f)
|
| 189 |
+
for reg in regs:
|
| 190 |
+
_ensure_column(fit_tab, reg)
|
| 191 |
+
for nuc in ("H", "C"):
|
| 192 |
+
try:
|
| 193 |
+
template = reader.bootstrap_coefficients(f, nuc)
|
| 194 |
+
except KeyError:
|
| 195 |
+
continue
|
| 196 |
+
params = [c for c in template.columns if c not in ("solvent", "seed")]
|
| 197 |
+
rows = []
|
| 198 |
+
for solvent in dict.fromkeys(template["solvent"]):
|
| 199 |
+
seeds = sorted(template[template["solvent"] == solvent]["seed"].unique())
|
| 200 |
+
for seed in seeds:
|
| 201 |
+
dec = _decompose(_fit(_bootstrap_sample(fit_tab, nuc, solvent, seed), regs),
|
| 202 |
+
regs, solvent, nuc, factors)
|
| 203 |
+
row = {"solvent": solvent, "seed": seed}
|
| 204 |
+
for p in params:
|
| 205 |
+
row[p] = dec["intercept"] if p == "Intercept" else dec[p]
|
| 206 |
+
rows.append(row)
|
| 207 |
+
out["bootstrap_coefficients"][(f, nuc)] = pd.DataFrame(rows)[template.columns.tolist()]
|
| 208 |
+
|
| 209 |
+
for nuc in ("H", "C"):
|
| 210 |
+
template = reader.rmse_distribution(nuc)
|
| 211 |
+
rows = []
|
| 212 |
+
for f in dict.fromkeys(template["formula"]):
|
| 213 |
+
sub = template[template["formula"] == f]
|
| 214 |
+
for solvent in dict.fromkeys(sub["solvent"]):
|
| 215 |
+
for seed in sorted(sub[sub["solvent"] == solvent]["seed"].unique()):
|
| 216 |
+
rows.append({"solvent": solvent, "nucleus": nuc, "formula": f, "seed": seed,
|
| 217 |
+
"Bootstrap_RMSE": _rmse(fit_tab, apply_tab, f, nuc, solvent, seed, factors)})
|
| 218 |
+
out["rmse_distributions"][nuc] = pd.DataFrame(rows)[template.columns.tolist()]
|
| 219 |
+
out["pcm_conversion_factors"][nuc] = _pcm_table(factors, nuc)
|
| 220 |
+
|
| 221 |
+
return out
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def verify(reader, regenerated, atol=5e-3):
|
| 225 |
+
"""Compare regenerated numeric values against the stored group. Returns the global max abs diff.
|
| 226 |
+
The floor is the fixed-point encoding granularity (~1e-3), far below any recipe error."""
|
| 227 |
+
worst = 0.0
|
| 228 |
+
for (f, nuc), df in regenerated["ols_coefficients"].items():
|
| 229 |
+
st = reader.ols_coefficients(f, nuc)
|
| 230 |
+
worst = max(worst, np.abs(df.drop(columns="parameter").to_numpy(float)
|
| 231 |
+
- st.drop(columns="parameter").to_numpy(float)).max())
|
| 232 |
+
for (f, nuc), df in regenerated["bootstrap_coefficients"].items():
|
| 233 |
+
st = reader.bootstrap_coefficients(f, nuc).sort_values(["solvent", "seed"]).reset_index(drop=True)
|
| 234 |
+
df = df.sort_values(["solvent", "seed"]).reset_index(drop=True)
|
| 235 |
+
num = [c for c in df.columns if c not in ("solvent",)]
|
| 236 |
+
worst = max(worst, np.abs(df[num].to_numpy(float) - st[num].to_numpy(float)).max())
|
| 237 |
+
for nuc, df in regenerated["rmse_distributions"].items():
|
| 238 |
+
st = reader.rmse_distribution(nuc).sort_values(["formula", "solvent", "seed"]).reset_index(drop=True)
|
| 239 |
+
df = df.sort_values(["formula", "solvent", "seed"]).reset_index(drop=True)
|
| 240 |
+
worst = max(worst, np.abs(df["Bootstrap_RMSE"].to_numpy(float)
|
| 241 |
+
- st["Bootstrap_RMSE"].to_numpy(float)).max())
|
| 242 |
+
for nuc, df in regenerated["pcm_conversion_factors"].items():
|
| 243 |
+
st = reader.pcm_conversion_factors(nuc).set_index("solvent")["pcm_conversion_factor"]
|
| 244 |
+
got = df.set_index("solvent")["pcm_conversion_factor"]
|
| 245 |
+
worst = max(worst, np.abs(got - st.reindex(got.index)).max())
|
| 246 |
+
return float(worst)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
_NOTE = ("Linear composite-model coefficients fit on the delta-22 dataset and applied to the "
|
| 250 |
+
"natural products (Figure 5C/5D, SI S15). Stored as CSV text; read via applications_reader. "
|
| 251 |
+
"Regenerated in-repo from delta22.hdf5 by build_composite_model.py.")
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def write(hdf5_path, regenerated):
|
| 255 |
+
"""Overwrite the composite_model/ group in applications.hdf5 with the regenerated CSV text."""
|
| 256 |
+
with h5py.File(hdf5_path, "a") as h:
|
| 257 |
+
if "composite_model" in h:
|
| 258 |
+
del h["composite_model"]
|
| 259 |
+
cm = h.create_group("composite_model")
|
| 260 |
+
cm.attrs["note"] = _NOTE
|
| 261 |
+
for (formula, nucleus), df in regenerated["ols_coefficients"].items():
|
| 262 |
+
cm.require_group(f"ols_coefficients/{formula}").create_dataset(nucleus, data=df.to_csv(index=False))
|
| 263 |
+
for (formula, nucleus), df in regenerated["bootstrap_coefficients"].items():
|
| 264 |
+
cm.require_group(f"bootstrap_coefficients/{formula}").create_dataset(nucleus, data=df.to_csv(index=False))
|
| 265 |
+
for nucleus, df in regenerated["rmse_distributions"].items():
|
| 266 |
+
cm.require_group("rmse_distributions").create_dataset(nucleus, data=df.to_csv(index=False))
|
| 267 |
+
for nucleus, df in regenerated["pcm_conversion_factors"].items():
|
| 268 |
+
cm.require_group("pcm_conversion_factors").create_dataset(nucleus, data=df.to_csv(index=False))
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def main():
|
| 272 |
+
import argparse
|
| 273 |
+
ap = argparse.ArgumentParser(description="Regenerate composite_model/ in applications.hdf5 from delta-22.")
|
| 274 |
+
ap.add_argument("--write", action="store_true",
|
| 275 |
+
help="overwrite the composite_model group in applications.hdf5 (default: verify only)")
|
| 276 |
+
args = ap.parse_args()
|
| 277 |
+
sys.path.insert(0, os.path.join(REPO, "data", "applications"))
|
| 278 |
+
from applications_reader import Applications
|
| 279 |
+
hdf5_path = paths.dataset_file("applications", root=REPO)
|
| 280 |
+
reader = Applications(hdf5_path)
|
| 281 |
+
regenerated = regenerate(reader)
|
| 282 |
+
max_diff = verify(reader, regenerated)
|
| 283 |
+
print(f"composite_model regenerated from delta-22; max abs diff vs stored = {max_diff:.2e}")
|
| 284 |
+
if args.write:
|
| 285 |
+
write(hdf5_path, regenerated)
|
| 286 |
+
print(f"wrote composite_model/ into {hdf5_path}")
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
if __name__ == "__main__":
|
| 290 |
+
main()
|
analysis/code/build_nb_applications.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# build_nb_applications.py -- generator for the natural-products figure notebooks.
|
| 2 |
+
#
|
| 3 |
+
# Source of truth for the notebooks: edit the cell sources below, then regenerate the notebook(s) you
|
| 4 |
+
# touched (regenerating overwrites the .ipynb, so edit this script, not the notebook):
|
| 5 |
+
#
|
| 6 |
+
# python3 build_nb_applications.py si_figure_s08_vomicine
|
| 7 |
+
#
|
| 8 |
+
# Run with no arguments to list the notebook names; the NOTEBOOKS dict maps each to its destination.
|
| 9 |
+
# The numbers come from applications.py; the notebooks arrange and plot them.
|
| 10 |
+
from nb_build import md, code, save_notebook as _save
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ----------------------------------------------------------------------------
|
| 14 |
+
# SI Figure S8, panel A: explicit-solvent shell-size convergence for vomicine
|
| 15 |
+
# ----------------------------------------------------------------------------
|
| 16 |
+
si_figure_s08 = [
|
| 17 |
+
md(r"""
|
| 18 |
+
# SI Figure S8 (panel A): explicit-solvent correction vs solvent-shell size (vomicine)
|
| 19 |
+
|
| 20 |
+
The MagNET-x explicit-solvent correction (solvated minus isolated, averaged over a proton site's
|
| 21 |
+
atoms and MD frames) vs solvent-shell size (heavy-atom count of the solute-plus-solvent cluster),
|
| 22 |
+
for vomicine, one panel per solvent.
|
| 23 |
+
"""),
|
| 24 |
+
code(r"""
|
| 25 |
+
import os, sys
|
| 26 |
+
|
| 27 |
+
# make the in-repo modules importable (not pip-installed)
|
| 28 |
+
REPO = os.path.abspath("../..")
|
| 29 |
+
for _p in ("data/applications", "analysis/code", "analysis/code/shared"):
|
| 30 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 31 |
+
"""),
|
| 32 |
+
code(r"""
|
| 33 |
+
import matplotlib.pyplot as plt
|
| 34 |
+
|
| 35 |
+
from applications_reader import Applications, SHELL_SIZES
|
| 36 |
+
import applications
|
| 37 |
+
import paths
|
| 38 |
+
"""),
|
| 39 |
+
code(r"""
|
| 40 |
+
DATA = os.path.join(REPO, "data", "applications")
|
| 41 |
+
|
| 42 |
+
def figure_path(name):
|
| 43 |
+
os.makedirs("figures", exist_ok=True)
|
| 44 |
+
return os.path.join("figures", name)
|
| 45 |
+
"""),
|
| 46 |
+
code(r"""
|
| 47 |
+
loader = Applications(paths.dataset_file("applications", root=REPO),
|
| 48 |
+
os.path.join(DATA, "applications_experimental.xlsx"))
|
| 49 |
+
|
| 50 |
+
# compute the per-site, per-solvent shell-convergence corrections for vomicine (proton sites)
|
| 51 |
+
SOLUTE, NUCLEUS = "vomicine", "H"
|
| 52 |
+
corrections = applications.shell_convergence_corrections(loader, SOLUTE, NUCLEUS)
|
| 53 |
+
corrections.head()
|
| 54 |
+
"""),
|
| 55 |
+
code(r"""
|
| 56 |
+
# panel layout and font styling for the 2x2 grid (one panel per solvent)
|
| 57 |
+
solvent_order = ["chloroform", "methanol", "TIP4P", "benzene"]
|
| 58 |
+
shell_cols = [f"shell_{s}" for s in SHELL_SIZES]
|
| 59 |
+
plt.rcParams.update({"font.family": "serif", "font.serif": ["Arial", "DejaVu Serif"],
|
| 60 |
+
"axes.labelsize": 12, "axes.titlesize": 14})
|
| 61 |
+
"""),
|
| 62 |
+
code(r"""
|
| 63 |
+
# 2x2 panel, one solvent each; one line per proton site
|
| 64 |
+
fig, axes = plt.subplots(2, 2, figsize=(11, 8), sharex=True, sharey=True)
|
| 65 |
+
for ax, solvent in zip(axes.ravel(), solvent_order):
|
| 66 |
+
sub = corrections.xs(solvent, level="solvent")
|
| 67 |
+
sub = sub.dropna(how="all")
|
| 68 |
+
ax.set_title("TIP4P" if solvent == "TIP4P" else solvent.capitalize(), fontweight="bold")
|
| 69 |
+
for site, row in sub.iterrows():
|
| 70 |
+
ax.plot(SHELL_SIZES, row[shell_cols].values.astype(float),
|
| 71 |
+
marker="o", linewidth=1.5, alpha=0.75, label=site)
|
| 72 |
+
ax.grid(True, alpha=0.3)
|
| 73 |
+
ax.set_xlabel("Shell Size (Heavy Atom Count)", fontweight="bold")
|
| 74 |
+
ax.set_ylabel("OpenMM correction (ppm)", fontweight="bold")
|
| 75 |
+
fig.suptitle("Shell-size Convergence for Vomicine", fontweight="bold", fontsize=18)
|
| 76 |
+
fig.tight_layout()
|
| 77 |
+
fig.savefig(figure_path("si_figure_s08_vomicine.png"), dpi=300, bbox_inches="tight")
|
| 78 |
+
plt.show()
|
| 79 |
+
"""),
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
# ----------------------------------------------------------------------------
|
| 83 |
+
# SI Figure S15: transferability of composite-model coefficients to the test set (1H + 13C)
|
| 84 |
+
# ----------------------------------------------------------------------------
|
| 85 |
+
si_figure_s15 = [
|
| 86 |
+
md(r"""
|
| 87 |
+
# SI Figure S15: per-solute RMSE with delta-22 composite coefficients applied
|
| 88 |
+
|
| 89 |
+
Per-solute ¹H and ¹³C RMSE for the natural-products / olefin-isomer test set with delta-22
|
| 90 |
+
coefficients applied (reproduces Figure 5C in ¹H, plus the ¹³C analogue), plus fitting-RMSE
|
| 91 |
+
comparisons across coefficient choices, feature-space coverage by solvent, delta-22-plane residuals,
|
| 92 |
+
and the RMSE distribution shift by solvent.
|
| 93 |
+
"""),
|
| 94 |
+
code(r"""
|
| 95 |
+
import os, sys
|
| 96 |
+
|
| 97 |
+
# make the in-repo modules importable (not pip-installed)
|
| 98 |
+
REPO = os.path.abspath("../..")
|
| 99 |
+
for _p in ("data/applications", "data/delta22", "analysis/code", "analysis/code/shared"):
|
| 100 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 101 |
+
"""),
|
| 102 |
+
code(r"""
|
| 103 |
+
import matplotlib.pyplot as plt
|
| 104 |
+
|
| 105 |
+
from applications_reader import Applications
|
| 106 |
+
import applications
|
| 107 |
+
import applications_plots
|
| 108 |
+
import delta22
|
| 109 |
+
import paths
|
| 110 |
+
"""),
|
| 111 |
+
code(r"""
|
| 112 |
+
DATA = os.path.join(REPO, "data", "applications")
|
| 113 |
+
DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
|
| 114 |
+
DELTA22_XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 115 |
+
|
| 116 |
+
def figure_path(name):
|
| 117 |
+
os.makedirs("figures", exist_ok=True)
|
| 118 |
+
return os.path.join("figures", name)
|
| 119 |
+
"""),
|
| 120 |
+
code(r"""
|
| 121 |
+
loader = Applications(paths.dataset_file("applications", root=REPO),
|
| 122 |
+
os.path.join(DATA, "applications_experimental.xlsx"))
|
| 123 |
+
|
| 124 |
+
# assemble the feature table and run the composite-model fits + bootstrap
|
| 125 |
+
query_df_nn = applications.build_query_df_nn(loader)
|
| 126 |
+
site_counts = (query_df_nn.drop_duplicates(subset=["solute", "nucleus", "site"])
|
| 127 |
+
.groupby(["solute", "nucleus"]).size().unstack(fill_value=0))
|
| 128 |
+
|
| 129 |
+
per_solute = applications.per_solute_fits(query_df_nn) # "scaled to solute" baseline
|
| 130 |
+
all_solute = applications.per_solvent_fits(query_df_nn) # "scaled to test set" full fit
|
| 131 |
+
|
| 132 |
+
seed = applications.build_bootstrap_seed_coeffs(loader)
|
| 133 |
+
bootstrap_rmses = {}
|
| 134 |
+
for nuc in ["H", "C"]:
|
| 135 |
+
preds = applications.apply_bootstrap_params_to_full_dataset(query_df_nn, seed[nuc], nucleus=nuc)
|
| 136 |
+
bootstrap_rmses[nuc] = applications.compute_solute_rmses(preds)
|
| 137 |
+
"""),
|
| 138 |
+
code(r"""
|
| 139 |
+
# published solute order (isomers, then the two pyridines, then the natural products); matches
|
| 140 |
+
# main-text Figure 5D. delta-22 is drawn first by the box-plot engine, so it is not listed here.
|
| 141 |
+
S15_SOLUTE_ORDER = ["isomer_1E", "isomer_1Z", "isomer_2E", "isomer_2Z", "isomer_3E", "isomer_3Z",
|
| 142 |
+
"isomer_4N", "isomer_4O", "vomicine", "prednisone", "peptide", "flavone",
|
| 143 |
+
"dihydrotanshinone_I"]
|
| 144 |
+
"""),
|
| 145 |
+
code(r"""
|
| 146 |
+
# 1H panel (reproduces Figure 5C)
|
| 147 |
+
applications_plots.plot_nps_on_boxplot_delta22_simplified(
|
| 148 |
+
loader.rmse_distribution("H"), bootstrap_rmses["H"], per_solute["H"],
|
| 149 |
+
nucleus="H", formulas=["stationary_plus_qcd + openMM"], colors=["#61a89a"],
|
| 150 |
+
site_counts=site_counts, formula_remap=applications.FORMULA_REMAP,
|
| 151 |
+
solute_remap=applications.SOLUTE_DISPLAY, solute_order=S15_SOLUTE_ORDER,
|
| 152 |
+
solute_color_remap=applications.PEPTIDE_HIGHLIGHT_H,
|
| 153 |
+
figsize=(14, 8), box_width=0.20, box_gap=0.1,
|
| 154 |
+
show_baseline=True, baseline_annotation_text="Scaled to solute",
|
| 155 |
+
baseline_annotation_x=0.215, baseline_annotation_y=0.39,
|
| 156 |
+
show_full_fit_line=True, full_fit_line_label="Scaled to Test Set", full_fit_line_label_x=0.835,
|
| 157 |
+
all_solute_fitting_results=all_solute,
|
| 158 |
+
max_bar_height=0.06, site_count_axis_mode="inset", site_count_inset_area_frac=0.1,
|
| 159 |
+
site_count_inset_axis_offset=-0.06, site_count_inset_axis_label_pad=0.045,
|
| 160 |
+
save_path=figure_path("si_figure_s15_1H.png"),
|
| 161 |
+
)
|
| 162 |
+
"""),
|
| 163 |
+
code(r"""
|
| 164 |
+
# 13C panel
|
| 165 |
+
applications_plots.plot_nps_on_boxplot_delta22_simplified(
|
| 166 |
+
loader.rmse_distribution("C"), bootstrap_rmses["C"], per_solute["C"],
|
| 167 |
+
nucleus="C", formulas=["stationary_plus_op_vib + openMM"], colors=["#61a89a"],
|
| 168 |
+
site_counts=site_counts, formula_remap=applications.FORMULA_REMAP,
|
| 169 |
+
solute_remap=applications.SOLUTE_DISPLAY, solute_order=S15_SOLUTE_ORDER,
|
| 170 |
+
solute_color_remap=applications.PEPTIDE_HIGHLIGHT_C,
|
| 171 |
+
figsize=(14, 8), box_width=0.20, box_gap=0.1,
|
| 172 |
+
show_baseline=True, baseline_annotation_text="Scaled to solute",
|
| 173 |
+
baseline_annotation_x=0.28, baseline_annotation_y=0.355,
|
| 174 |
+
show_full_fit_line=True, full_fit_line_label="Scaled to Test Set", full_fit_line_label_x=0.98,
|
| 175 |
+
all_solute_fitting_results=all_solute,
|
| 176 |
+
max_bar_height=0.8, site_count_axis_mode="inset", site_count_inset_area_frac=0.1,
|
| 177 |
+
site_count_inset_axis_offset=-0.06, site_count_inset_axis_label_pad=0.045,
|
| 178 |
+
save_path=figure_path("si_figure_s15_13C.png"),
|
| 179 |
+
)
|
| 180 |
+
"""),
|
| 181 |
+
md(r"""
|
| 182 |
+
## Fitting RMSE Comparisons (chloroform)
|
| 183 |
+
|
| 184 |
+
Per-solute RMSE under three coefficient choices (Scaled to Solute / Scaled to Test Set / Extrapolated
|
| 185 |
+
from delta22).
|
| 186 |
+
"""),
|
| 187 |
+
code(r"""
|
| 188 |
+
for nucleus, formula in [("H", "stationary_plus_qcd + openMM"), ("C", "stationary_plus_op_vib + openMM")]:
|
| 189 |
+
table = applications.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses[nucleus],
|
| 190 |
+
nucleus, "chloroform", formula)
|
| 191 |
+
table = table.rename(index=applications.SOLUTE_DISPLAY)
|
| 192 |
+
order = [applications.SOLUTE_DISPLAY.get(k, k) for k in S15_SOLUTE_ORDER] # published order; peptide has no chloroform data
|
| 193 |
+
table = table.reindex([n for n in order if n in table.index])
|
| 194 |
+
print(f"--- {nucleus} ---")
|
| 195 |
+
display(table.round(3))
|
| 196 |
+
applications_plots.plot_fitting_rmse_comparison_bars(
|
| 197 |
+
table, nucleus, "chloroform",
|
| 198 |
+
save_path=figure_path(f"si_figure_s15_fitting_rmse_comparisons_{'1H' if nucleus == 'H' else '13C'}.png"))
|
| 199 |
+
plt.show()
|
| 200 |
+
"""),
|
| 201 |
+
md(r"""
|
| 202 |
+
## Feature Space Coverage by Solvent
|
| 203 |
+
|
| 204 |
+
Test-set vs delta-22 mean-centered feature values across the four explicit-solvent solvents.
|
| 205 |
+
"""),
|
| 206 |
+
code(r"""
|
| 207 |
+
delta22_query_df_nn = delta22.add_composite_columns(delta22.load_query_df_nn(
|
| 208 |
+
DELTA22_HDF5, DELTA22_XLSX, verbose=False))
|
| 209 |
+
"""),
|
| 210 |
+
code(r"""
|
| 211 |
+
_FEATURE_X_LABELS = {
|
| 212 |
+
"H": "Gas-Phase Shielding + QCD Correction (centered, ppm)",
|
| 213 |
+
"C": "Gas-Phase Shielding + OpenMM Vibrational Correction (centered, ppm)",
|
| 214 |
+
}
|
| 215 |
+
for nucleus in ["H", "C"]:
|
| 216 |
+
coverage = applications.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, nucleus)
|
| 217 |
+
applications_plots.plot_feature_space_coverage_grid(
|
| 218 |
+
coverage, nucleus, _FEATURE_X_LABELS[nucleus],
|
| 219 |
+
save_path=figure_path(f"si_figure_s15_feature_space_coverage_{'1H' if nucleus == 'H' else '13C'}.png"))
|
| 220 |
+
plt.show()
|
| 221 |
+
"""),
|
| 222 |
+
md(r"""
|
| 223 |
+
## Residuals for Delta22 Fitting Coefficients
|
| 224 |
+
|
| 225 |
+
Residuals of a delta-22-only 2-feature plane applied to both delta-22 and the test set, vs
|
| 226 |
+
experimental shielding.
|
| 227 |
+
"""),
|
| 228 |
+
code(r"""
|
| 229 |
+
for nucleus in ["H", "C"]:
|
| 230 |
+
residuals = applications.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus)
|
| 231 |
+
applications_plots.plot_delta22_plane_residuals_grid(
|
| 232 |
+
residuals, nucleus,
|
| 233 |
+
save_path=figure_path(f"si_figure_s15_delta22_plane_residuals_{'1H' if nucleus == 'H' else '13C'}.png"))
|
| 234 |
+
plt.show()
|
| 235 |
+
"""),
|
| 236 |
+
md(r"""
|
| 237 |
+
## Distribution Shift by Solvent
|
| 238 |
+
|
| 239 |
+
Per-solvent mean test-set RMSE under the three coefficient choices.
|
| 240 |
+
"""),
|
| 241 |
+
code(r"""
|
| 242 |
+
for nucleus, formula in [("H", "stationary_plus_qcd + openMM"), ("C", "stationary_plus_op_vib + openMM")]:
|
| 243 |
+
shift = applications.distribution_shift_by_solvent_table(per_solute, all_solute, bootstrap_rmses[nucleus],
|
| 244 |
+
nucleus, formula)
|
| 245 |
+
print(f"--- {nucleus} ---")
|
| 246 |
+
display(shift.round(3))
|
| 247 |
+
applications_plots.plot_distribution_shift_by_solvent_bars(
|
| 248 |
+
shift, nucleus,
|
| 249 |
+
save_path=figure_path(f"si_figure_s15_distribution_shift_by_solvent_{'1H' if nucleus == 'H' else '13C'}.png"))
|
| 250 |
+
plt.show()
|
| 251 |
+
"""),
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
# NOTE: main-text Figure 5 panels C and D (analysis/manuscript_figures/fig5c.ipynb, fig5d.ipynb) are
|
| 255 |
+
# HAND-AUTHORED, not generated here. Their plotting engines (plot_nps_benefit_barplot for 5C,
|
| 256 |
+
# plot_nps_on_boxplot_delta22_simplified for 5D, also used by SI S15) live in applications_plots.py;
|
| 257 |
+
# the notebooks only load data and call them. The SI notebooks above are still generated here.
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
NOTEBOOKS = {
|
| 261 |
+
"si_figure_s08_vomicine": (si_figure_s08, "analysis/si_figures/si_figure_s08_panelA_vomicine.ipynb"),
|
| 262 |
+
"si_figure_s15": (si_figure_s15, "analysis/si_figures/si_figure_s15.ipynb"),
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
if __name__ == "__main__":
|
| 266 |
+
import os
|
| 267 |
+
import sys
|
| 268 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 269 |
+
repo = os.path.abspath(os.path.join(here, "..", ".."))
|
| 270 |
+
names = sys.argv[1:]
|
| 271 |
+
if not names:
|
| 272 |
+
print("usage: python3 build_nb_applications.py <name> [<name> ...]")
|
| 273 |
+
print("regenerates ONLY the named notebook(s) -- pick just the one(s) you edited,")
|
| 274 |
+
print("since regenerating clears a notebook's execution outputs.")
|
| 275 |
+
print("available names:", ", ".join(NOTEBOOKS))
|
| 276 |
+
raise SystemExit(1)
|
| 277 |
+
unknown = [n for n in names if n not in NOTEBOOKS]
|
| 278 |
+
if unknown:
|
| 279 |
+
raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
|
| 280 |
+
for name in names:
|
| 281 |
+
cells, relpath = NOTEBOOKS[name]
|
| 282 |
+
_save(cells, os.path.join(repo, relpath))
|
analysis/code/build_nb_composite_models.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Source of truth for the composite-model ablation-study notebook. Edit the cell sources here,
|
| 2 |
+
then regenerate:
|
| 3 |
+
|
| 4 |
+
python3 build_nb_composite_models.py --write
|
| 5 |
+
jupyter nbconvert --to notebook --execute --inplace ../si_figures/si_composite_models_ablations.ipynb
|
| 6 |
+
|
| 7 |
+
Regenerating overwrites the .ipynb, so do not hand-edit the notebook. All real code lives in
|
| 8 |
+
composite_models.py (numbers and the openpyxl table-writing layer, which itself calls delta22.py's
|
| 9 |
+
existing fitting harness); the notebook only runs it. The notebook itself lives in
|
| 10 |
+
analysis/si_figures/ (grouped by role), not here.
|
| 11 |
+
|
| 12 |
+
Run with no arguments to see this usage message; pass --write to regenerate. This generator produces
|
| 13 |
+
a single notebook and takes no name argument, so a bare run must not silently overwrite it.
|
| 14 |
+
"""
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
from nb_build import md, code, save_notebook as _save
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
_BOOTSTRAP = r"""
|
| 22 |
+
import os, sys
|
| 23 |
+
|
| 24 |
+
# make the in-repo modules importable (not pip-installed)
|
| 25 |
+
REPO = os.path.abspath("../..")
|
| 26 |
+
for _p in ("data/delta22", "analysis/code", "analysis/code/shared"):
|
| 27 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
_IMPORTS = r"""
|
| 31 |
+
import matplotlib.pyplot as plt
|
| 32 |
+
|
| 33 |
+
import delta22
|
| 34 |
+
import composite_models
|
| 35 |
+
import composite_plots
|
| 36 |
+
import paths
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
_SETUP = r"""
|
| 40 |
+
DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
|
| 41 |
+
XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 42 |
+
|
| 43 |
+
def figure_path(name):
|
| 44 |
+
os.makedirs("figures", exist_ok=True)
|
| 45 |
+
return os.path.join("figures", name)
|
| 46 |
+
|
| 47 |
+
def document_path(name):
|
| 48 |
+
os.makedirs("documents", exist_ok=True)
|
| 49 |
+
return os.path.join("documents", name)
|
| 50 |
+
|
| 51 |
+
# the shipped ablations.xlsx used 250 seeded train/test splits per (formula, solvent)
|
| 52 |
+
N_SPLITS = 250
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
si_composite_models_ablations = [
|
| 56 |
+
md(r"""
|
| 57 |
+
# SI Figure S15: composite-formula ablation workbook (`ablations.xlsx`)
|
| 58 |
+
|
| 59 |
+
Rebuilds the `ablations.xlsx` workbook behind ~32 of SI Figure S15's images: ~25 composite-formula
|
| 60 |
+
variants (stationary geometry plus some combination of implicit PCM, explicit Desmond, and
|
| 61 |
+
rovibrational QCD corrections) across all 12 delta-22 solvents, at two reference levels:
|
| 62 |
+
DSD-PBEP86/pcSseg-3 (geometry PBE0/tz) and the MagNET-Zero training reference (WP04/pcSseg-2 for ¹H,
|
| 63 |
+
wB97X-D/pcSseg-2 for ¹³C).
|
| 64 |
+
"""),
|
| 65 |
+
code(_BOOTSTRAP),
|
| 66 |
+
code(_IMPORTS),
|
| 67 |
+
code(_SETUP),
|
| 68 |
+
code(r"""
|
| 69 |
+
query_df_dft = delta22.add_composite_columns(delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False))
|
| 70 |
+
solutes = delta22.delta22_solutes(DELTA22_HDF5)
|
| 71 |
+
print(len(query_df_dft), "rows;", len(solutes), "solutes")
|
| 72 |
+
"""),
|
| 73 |
+
md("## Preview: one formula's mean test RMSE at the DSD-PBEP86 reference level"),
|
| 74 |
+
code(r"""
|
| 75 |
+
# preview before the full build (all ~25 formulas x 12 solvents x 250 splits x 2 nuclei x 2
|
| 76 |
+
# reference levels)
|
| 77 |
+
level = composite_models.REFERENCE_LEVELS["dsd"]
|
| 78 |
+
preview = composite_models.ablation_rmse_table(query_df_dft, "H", level["method_h"], level["basis_h"],
|
| 79 |
+
level["geometry_h"], solutes, n_splits=20)
|
| 80 |
+
preview[["chloroform", "benzene", "Mean Test RMSE"]].round(3)
|
| 81 |
+
"""),
|
| 82 |
+
md("## Build the full ablations workbook (both reference levels, both nuclei)"),
|
| 83 |
+
code(r"""
|
| 84 |
+
output_path = document_path("ablations.xlsx")
|
| 85 |
+
composite_models.build_ablations_workbook(query_df_dft, solutes, output_path, n_splits=N_SPLITS)
|
| 86 |
+
"""),
|
| 87 |
+
md(r"""
|
| 88 |
+
## Correlations Between Features
|
| 89 |
+
|
| 90 |
+
Solvent-averaged Pearson r correlation matrix between the five composite-model features (stationary
|
| 91 |
+
shielding, PCM, Desmond, its vibrational analogue, QCD), both nuclei, plus a per-solvent
|
| 92 |
+
PCM-vs-Desmond table.
|
| 93 |
+
"""),
|
| 94 |
+
code(r"""
|
| 95 |
+
for nucleus, label in [("H", "Proton"), ("C", "Carbon")]:
|
| 96 |
+
corr = delta22.si_s15_feature_correlations(query_df_dft, nucleus)
|
| 97 |
+
nuc_label = "1H" if nucleus == "H" else "13C"
|
| 98 |
+
nuc_title = "$^{1}$H" if nucleus == "H" else "$^{13}$C"
|
| 99 |
+
composite_plots.plot_feature_correlation_heatmap(
|
| 100 |
+
corr["r"], vmin=-1, vmax=1, cmap="RdBu",
|
| 101 |
+
title=f"Solvent-Averaged Pearson $r$ Correlation Matrix for {nuc_title}",
|
| 102 |
+
save_path=figure_path(f"si_figure_s15_feature_corr_r_{nuc_label}.png"))
|
| 103 |
+
plt.show()
|
| 104 |
+
"""),
|
| 105 |
+
code(r"""
|
| 106 |
+
pcm_desmond_corr = delta22.pcm_desmond_correlation_by_solvent(query_df_dft)
|
| 107 |
+
print("PCM vs. Desmond Pearson R by nucleus and solvent:")
|
| 108 |
+
display(pcm_desmond_corr.round(3))
|
| 109 |
+
composite_plots.plot_pcm_desmond_correlation_table(pcm_desmond_corr,
|
| 110 |
+
save_path=figure_path("si_figure_s15_pcm_desmond_corr_table.png"))
|
| 111 |
+
plt.show()
|
| 112 |
+
"""),
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
if "--write" not in sys.argv:
|
| 118 |
+
print("usage: python3 build_nb_composite_models.py --write")
|
| 119 |
+
print("regenerates analysis/si_figures/si_composite_models_ablations.ipynb -- pass --write to actually do it,")
|
| 120 |
+
print("since regenerating clears the notebook's execution outputs.")
|
| 121 |
+
sys.exit(0)
|
| 122 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 123 |
+
repo = os.path.abspath(os.path.join(here, "..", ".."))
|
| 124 |
+
_save(si_composite_models_ablations, os.path.join(repo, "analysis", "si_figures", "si_composite_models_ablations.ipynb"))
|
analysis/code/build_nb_delta22.py
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# build_nb_delta22.py -- generator for the delta-22 figure notebooks.
|
| 2 |
+
#
|
| 3 |
+
# This script is the source of truth for the notebooks. Edit the cell sources here, then
|
| 4 |
+
# regenerate and re-execute ONLY the notebook(s) you touched, e.g.:
|
| 5 |
+
#
|
| 6 |
+
# python3 build_nb_delta22.py si_figure_s04
|
| 7 |
+
# jupyter nbconvert --to notebook --execute --inplace ../si_figures/si_figure_s04.ipynb
|
| 8 |
+
#
|
| 9 |
+
# Regenerating overwrites the .ipynb; do not hand-edit it. All real code lives in delta22.py
|
| 10 |
+
# (numbers); plotting is inline in each notebook.
|
| 11 |
+
#
|
| 12 |
+
# Run with no arguments to list the available notebook names. Regenerating a notebook clears its
|
| 13 |
+
# execution outputs, so there is no "build everything" shortcut -- pick just the notebook(s) touched.
|
| 14 |
+
from nb_build import md, code, save_notebook as _save
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# under nbconvert the CWD is the notebook's own directory, so sys.path is set relative to it
|
| 18 |
+
_BOOTSTRAP = r"""
|
| 19 |
+
import os, sys
|
| 20 |
+
|
| 21 |
+
# make the in-repo modules importable (not pip-installed)
|
| 22 |
+
REPO = os.path.abspath("../..")
|
| 23 |
+
for _p in ("data/delta22", "analysis/code", "analysis/code/shared"):
|
| 24 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# pure imports for the figure notebooks
|
| 28 |
+
_IMPORTS = r"""
|
| 29 |
+
import matplotlib.pyplot as plt
|
| 30 |
+
|
| 31 |
+
import delta22
|
| 32 |
+
import delta22_plots
|
| 33 |
+
import paths
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
# data-file path constants + the figure_path helper, for the figure notebooks
|
| 37 |
+
_SETUP = r"""
|
| 38 |
+
DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
|
| 39 |
+
XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 40 |
+
|
| 41 |
+
def figure_path(name):
|
| 42 |
+
os.makedirs("figures", exist_ok=True)
|
| 43 |
+
return os.path.join("figures", name)
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
# pure imports for the table notebooks (no matplotlib -- they only print and write spreadsheets)
|
| 47 |
+
_IMPORTS_TABLE = r"""
|
| 48 |
+
import delta22
|
| 49 |
+
import paths
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
# figure imports plus numpy, for the few notebooks that use numpy directly in a notebook cell
|
| 53 |
+
_IMPORTS_NP = _IMPORTS.replace("import matplotlib.pyplot as plt",
|
| 54 |
+
"import numpy as np\nimport matplotlib.pyplot as plt", 1)
|
| 55 |
+
# table imports plus numpy/pandas (or just pandas), used directly when building the table dataframes
|
| 56 |
+
_IMPORTS_TABLE_NPPD = _IMPORTS_TABLE.replace("import delta22",
|
| 57 |
+
"import numpy as np\nimport pandas as pd\n\nimport delta22", 1)
|
| 58 |
+
_IMPORTS_TABLE_PD = _IMPORTS_TABLE.replace("import delta22",
|
| 59 |
+
"import pandas as pd\n\nimport delta22", 1)
|
| 60 |
+
|
| 61 |
+
# data-file path constants + the document_path helper, for the table notebooks
|
| 62 |
+
_SETUP_TABLE = r"""
|
| 63 |
+
DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
|
| 64 |
+
XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 65 |
+
|
| 66 |
+
def document_path(name):
|
| 67 |
+
os.makedirs("documents", exist_ok=True)
|
| 68 |
+
return os.path.join("documents", name)
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
# only the notebooks that use N_SPLITS include this cell (fig2a_pareto uses its own, smaller
|
| 72 |
+
# PARETO_N_SPLITS instead).
|
| 73 |
+
_N_SPLITS_CELL = r"""
|
| 74 |
+
# the published panels use 250 seeded train/test splits
|
| 75 |
+
N_SPLITS = 250
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# Figure 3 (analysis/manuscript_figures/fig3.ipynb) is hand-authored with its plotting inline; its
|
| 80 |
+
# data comes from delta22.py (fig3a_pcm_benefit_by_solvent, fig3b_shift_differences,
|
| 81 |
+
# fig3d_formula_regressions).
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ----------------------------------------------------------------------------
|
| 85 |
+
# Figure 4 + SI S16/S17/S18: Explicit Solvent Corrections are Physically Meaningful
|
| 86 |
+
# ----------------------------------------------------------------------------
|
| 87 |
+
_FIG4_LOAD = r"""
|
| 88 |
+
# Figure 4 uses one DFT method for the proton sites; differences are taken against a reference
|
| 89 |
+
# solvent, and the solvent_mean pseudo-solvent is added for the solvent-averaged reference.
|
| 90 |
+
METHOD, BASIS, GEOM = "b3lyp_d3bj", "pcSseg2", "aimnet2"
|
| 91 |
+
q = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
|
| 92 |
+
one = q[(q["sap_nmr_method"] == METHOD) & (q["sap_basis"] == BASIS) & (q["sap_geometry_type"] == GEOM)]
|
| 93 |
+
one = delta22.add_solvent_mean(one)
|
| 94 |
+
print(len(one), "rows for", METHOD, BASIS, GEOM)
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
# shared display labels for the 12 delta-22 solvents, used by Figure 4C and SI Figures S16-S18
|
| 98 |
+
_SOLVENT_LABELS = r"""
|
| 99 |
+
FIG4C_LABELS = {
|
| 100 |
+
"chloroform": "CDCl3", "dichloromethane": "DCM", "tetrahydrofuran": "THF",
|
| 101 |
+
"acetonitrile": "MeCN", "dimethylsulfoxide": "DMSO", "acetone": "acetone",
|
| 102 |
+
"methanol": "MeOH", "TIP4P": "TIP4P", "trifluoroethanol": "TFE",
|
| 103 |
+
"benzene": "benzene", "toluene": "toluene", "chlorobenzene": "chlorobenzene",
|
| 104 |
+
}
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
# Figure 4 (analysis/manuscript_figures/fig4.ipynb) is hand-authored with its plotting inline; its
|
| 108 |
+
# data comes from delta22.py (solvent_induced_shifts, solvent_pair_differences,
|
| 109 |
+
# fit_differences_to_experimental). The _FIG4_LOAD / _SOLVENT_LABELS cells above are shared with the
|
| 110 |
+
# SI S16-S18 notebook below.
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
si_figure_s16_s18 = [
|
| 114 |
+
md(r"""
|
| 115 |
+
# SI Figures S16-S18: predicted vs experimental solvent-induced shifts by reference solvent
|
| 116 |
+
|
| 117 |
+
Per solvent, implicit (PCM) and explicit (Desmond) predicted solvent-induced shift differences vs
|
| 118 |
+
measured (y=x ideal), for three reference solvents: **S16** chloroform, **S17** benzene, **S18**
|
| 119 |
+
solvent-averaged. One panel per solvent, each point a proton site.
|
| 120 |
+
"""),
|
| 121 |
+
code(_BOOTSTRAP),
|
| 122 |
+
code(_IMPORTS),
|
| 123 |
+
code(_SETUP),
|
| 124 |
+
code(_FIG4_LOAD),
|
| 125 |
+
code(_SOLVENT_LABELS),
|
| 126 |
+
code(r"""
|
| 127 |
+
# panel order matches the canonical figures; a different order from delta22.DESMOND_SOLVENTS
|
| 128 |
+
SI_S16_18_SOLVENT_ORDER = [
|
| 129 |
+
"chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
|
| 130 |
+
"dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P", "benzene", "toluene", "chlorobenzene",
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
# panel titles use the full solvent name; axis labels use the compact NMR abbreviations (THF, DCM,
|
| 134 |
+
# DMSO, TFE, CDCl3); the reference is CDCl3 / Benzene / "Avg Corr." for S16 / S17 / S18.
|
| 135 |
+
TITLE_LABEL = {"chloroform": "Chloroform", "tetrahydrofuran": "Tetrahydrofuran",
|
| 136 |
+
"dichloromethane": "Dichloromethane", "acetone": "Acetone", "acetonitrile": "Acetonitrile",
|
| 137 |
+
"dimethylsulfoxide": "Dimethylsulfoxide", "trifluoroethanol": "Trifluoroethanol",
|
| 138 |
+
"methanol": "Methanol", "TIP4P": "TIP4P", "benzene": "Benzene", "toluene": "Toluene",
|
| 139 |
+
"chlorobenzene": "Chlorobenzene"}
|
| 140 |
+
AXIS_LABEL = {**TITLE_LABEL, "chloroform": "CDCl3", "tetrahydrofuran": "THF",
|
| 141 |
+
"dichloromethane": "DCM", "dimethylsulfoxide": "DMSO", "trifluoroethanol": "TFE"}
|
| 142 |
+
REF_LABEL = {"chloroform": "CDCl3", "benzene": "Benzene", "solvent_mean": "Avg Corr."}
|
| 143 |
+
"""),
|
| 144 |
+
code(r"""
|
| 145 |
+
for figure, reference in [("S16", "chloroform"), ("S17", "benzene"), ("S18", "solvent_mean")]:
|
| 146 |
+
sh = delta22.solvent_induced_shifts(one, reference, SI_S16_18_SOLVENT_ORDER, nucleus="H", explicit="desmond")
|
| 147 |
+
implicit_fit = delta22.fit_differences_to_experimental(sh, "implicit_diff")
|
| 148 |
+
explicit_fit = delta22.fit_differences_to_experimental(sh, "explicit_diff")
|
| 149 |
+
print(f"{figure}: reference={reference:12s} n={explicit_fit['n']:4d} "
|
| 150 |
+
f"implicit fit RMSE={implicit_fit['rmse']:.3f} explicit fit RMSE={explicit_fit['rmse']:.3f}")
|
| 151 |
+
panel_solvents = [s for s in SI_S16_18_SOLVENT_ORDER if s != reference]
|
| 152 |
+
delta22_plots.plot_shift_prediction_scatter_grid(
|
| 153 |
+
sh, panel_solvents, REF_LABEL[reference], AXIS_LABEL, TITLE_LABEL,
|
| 154 |
+
save_path=figure_path(f"si_figure_{figure.lower()}.png"))
|
| 155 |
+
plt.show()
|
| 156 |
+
"""),
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ----------------------------------------------------------------------------
|
| 161 |
+
# SI Figure S7: Explicit Solvent Corrections are Method-Independent
|
| 162 |
+
# ----------------------------------------------------------------------------
|
| 163 |
+
si_figure_s07 = [
|
| 164 |
+
md(r"""
|
| 165 |
+
# SI Figure S7: DFT ¹³C explicit-solvent correction, Desmond vs OpenMM
|
| 166 |
+
|
| 167 |
+
For the four OpenMM solvents, the DFT ¹³C explicit-solvent correction from Desmond MD vs from OpenMM
|
| 168 |
+
(y=x reference line).
|
| 169 |
+
"""),
|
| 170 |
+
code(_BOOTSTRAP),
|
| 171 |
+
code(_IMPORTS_NP),
|
| 172 |
+
code(_SETUP),
|
| 173 |
+
code(r"""
|
| 174 |
+
dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
|
| 175 |
+
"""),
|
| 176 |
+
md("## DFT explicit corrections: Desmond vs OpenMM"),
|
| 177 |
+
code(r"""
|
| 178 |
+
SOLVENT_ORDER = ["chloroform", "methanol", "TIP4P", "benzene"]
|
| 179 |
+
dft_pairs = {sv: delta22.explicit_correction_pairs(dft, sv, "C") for sv in SOLVENT_ORDER}
|
| 180 |
+
for sv, p in dft_pairs.items():
|
| 181 |
+
print(f"DFT {sv:12s} n={len(p):3d} r={np.corrcoef(p['desmond'], p['openMM'])[0,1]:.3f}")
|
| 182 |
+
delta22_plots.plot_desmond_vs_openmm_grid(dft_pairs, save_path=figure_path("si_figure_s07.png"))
|
| 183 |
+
plt.show()
|
| 184 |
+
"""),
|
| 185 |
+
]
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ----------------------------------------------------------------------------
|
| 189 |
+
# SI Figure S4: Implicit Solvent Corrections are Highly Correlated
|
| 190 |
+
# ----------------------------------------------------------------------------
|
| 191 |
+
si_figure_s04 = [
|
| 192 |
+
md(r"""
|
| 193 |
+
# SI Figure S4: correlation of implicit (PCM) corrections across solvents and methods
|
| 194 |
+
|
| 195 |
+
**S4A** correlation across solvents (both nuclei), **S4B** one solvent vs another (¹H), **S4C**
|
| 196 |
+
correlation across methods (both nuclei). Cells show -log10(1-r), so 3 means r=0.999.
|
| 197 |
+
"""),
|
| 198 |
+
code(_BOOTSTRAP),
|
| 199 |
+
code(_IMPORTS_NP),
|
| 200 |
+
code(_SETUP),
|
| 201 |
+
code(r"""
|
| 202 |
+
METHOD, BASIS, GEOM = "b3lyp_d3bj", "pcSseg2", "pbe0_tz"
|
| 203 |
+
dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
|
| 204 |
+
base = dft[(dft["sap_nmr_method"] == METHOD) & (dft["sap_basis"] == BASIS)
|
| 205 |
+
& (dft["sap_geometry_type"] == GEOM)]
|
| 206 |
+
one = base[base["nucleus"] == "H"]
|
| 207 |
+
|
| 208 |
+
# solvent order that groups polar-aprotic -> polar-protic -> aromatic (matches the published panel)
|
| 209 |
+
ORDERED_SOLVENTS = ["chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
|
| 210 |
+
"dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P",
|
| 211 |
+
"benzene", "toluene", "chlorobenzene"]
|
| 212 |
+
"""),
|
| 213 |
+
md("## S4A: solvent-vs-solvent PCM correlation (¹H and ¹³C)"),
|
| 214 |
+
code(r"""
|
| 215 |
+
for nucleus, label in [("H", "1H"), ("C", "13C")]:
|
| 216 |
+
solvent_corr = delta22.correlation_matrix(base[base["nucleus"] == nucleus], "pcm", ["solute", "site"], "solvent")
|
| 217 |
+
solvent_corr = solvent_corr.reindex(index=ORDERED_SOLVENTS, columns=ORDERED_SOLVENTS)
|
| 218 |
+
vals = solvent_corr.values[np.triu_indices_from(solvent_corr.values, k=1)]
|
| 219 |
+
print(f"solvent PCM correlation ({label}): mean r={np.nanmean(vals):.4f} min r={np.nanmin(vals):.4f}")
|
| 220 |
+
caption = ("Correlation coefficients between solvents across all 22 solutes.\n"
|
| 221 |
+
f"PCM corrections computed with {METHOD} with the {BASIS} basis.\n"
|
| 222 |
+
"A value of 3 means the coefficient is 0.999.")
|
| 223 |
+
delta22_plots.plot_correlation_matrix(solvent_corr, f"Solvents are Highly Correlated ({nucleus})", caption,
|
| 224 |
+
colormap="Reds", show_values=True,
|
| 225 |
+
save_path=figure_path(f"si_figure_s04a_{label}.png"))
|
| 226 |
+
"""),
|
| 227 |
+
md("## S4B: PCM corrections, chloroform vs acetonitrile (1H) -- one square of S4A"),
|
| 228 |
+
code(r"""
|
| 229 |
+
pair = one.pivot_table(index=["solute", "site"], columns="solvent", values="pcm")[
|
| 230 |
+
["chloroform", "acetonitrile"]].dropna()
|
| 231 |
+
delta22_plots.plot_pcm_scatter(pair["chloroform"], pair["acetonitrile"], "chloroform", "acetonitrile", "H",
|
| 232 |
+
save_path=figure_path("si_figure_s04b_1H.png"))
|
| 233 |
+
"""),
|
| 234 |
+
md("## S4C: method-vs-method correlation (chloroform, double hybrids excluded, ¹H and ¹³C)"),
|
| 235 |
+
code(r"""
|
| 236 |
+
# one solvent, exclude the double hybrids whose PCM is the substituted reference value
|
| 237 |
+
for nucleus, label in [("H", "1H"), ("C", "13C")]:
|
| 238 |
+
chcl3 = dft[(dft["sap_basis"] == BASIS) & (dft["sap_geometry_type"] == GEOM)
|
| 239 |
+
& (dft["nucleus"] == nucleus) & (dft["solvent"] == "chloroform")
|
| 240 |
+
& (~dft["sap_nmr_method"].isin(delta22.DOUBLE_HYBRID_METHODS))]
|
| 241 |
+
method_corr = delta22.correlation_matrix(chcl3, "pcm", ["solute", "site"], "sap_nmr_method")
|
| 242 |
+
method_order = sorted(method_corr.index) # alphabetical, matches the published panel
|
| 243 |
+
method_corr = method_corr.reindex(index=method_order, columns=method_order)
|
| 244 |
+
mvals = method_corr.values[np.triu_indices_from(method_corr.values, k=1)]
|
| 245 |
+
print(f"method PCM correlation ({label}): mean r={np.nanmean(mvals):.4f} min r={np.nanmin(mvals):.4f}")
|
| 246 |
+
caption = ("Correlation coefficients between NMR methods across all 22 solutes.\n"
|
| 247 |
+
f"PCM corrections for chloroform with the {BASIS} basis.")
|
| 248 |
+
delta22_plots.plot_correlation_matrix(method_corr, f"NMR Methods are Highly Correlated ({nucleus})", caption,
|
| 249 |
+
colormap="Reds", show_values=True,
|
| 250 |
+
save_path=figure_path(f"si_figure_s04c_{label}.png"))
|
| 251 |
+
"""),
|
| 252 |
+
]
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# ----------------------------------------------------------------------------
|
| 256 |
+
# SI Figures S11 and S13: MagNET reproduces the DFT corrections
|
| 257 |
+
# ----------------------------------------------------------------------------
|
| 258 |
+
_DFT_NN_LOAD = r"""
|
| 259 |
+
dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
|
| 260 |
+
nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)
|
| 261 |
+
"""
|
| 262 |
+
|
| 263 |
+
si_figure_s11 = [
|
| 264 |
+
md(r"""
|
| 265 |
+
# SI Figure S11: MagNET vs DFT rovibrational (QCD) corrections
|
| 266 |
+
|
| 267 |
+
Four panels: **A** MagNET (NN) vs B3LYP/cc-pVDZ rovibrational (QCD) correction per site, both nuclei;
|
| 268 |
+
**B** the NN-minus-DFT error distribution; **C/D** every proton/carbon site's DFT and NN QCD
|
| 269 |
+
correction, one column per solute. (QCD is gas-phase, so no solvent axis.)
|
| 270 |
+
"""),
|
| 271 |
+
code(_BOOTSTRAP),
|
| 272 |
+
code(_IMPORTS),
|
| 273 |
+
code(_SETUP),
|
| 274 |
+
code(_DFT_NN_LOAD),
|
| 275 |
+
md("## Panels A and B: MagNET vs DFT QCD correction, both nuclei"),
|
| 276 |
+
code(r"""
|
| 277 |
+
qcd = delta22.compare_dft_nn(dft, nn, "qcd", keys=("solute", "site", "nucleus"))
|
| 278 |
+
delta22_plots.plot_qcd_scatter(qcd, save_path=figure_path("si_figure_s11a_scatter.png"))
|
| 279 |
+
delta22_plots.plot_qcd_error_histogram(qcd, save_path=figure_path("si_figure_s11b_hist.png"))
|
| 280 |
+
plt.show()
|
| 281 |
+
"""),
|
| 282 |
+
md("## Panel C: every proton site's QCD correction, one column per solute"),
|
| 283 |
+
code(r"""
|
| 284 |
+
delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus="H"), "H", "upper left",
|
| 285 |
+
save_path=figure_path("si_figure_s11c_sites_1H.png"))
|
| 286 |
+
plt.show()
|
| 287 |
+
"""),
|
| 288 |
+
md("## Panel D: every carbon site's QCD correction, one column per solute"),
|
| 289 |
+
code(r"""
|
| 290 |
+
delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus="C"), "C", "upper right",
|
| 291 |
+
save_path=figure_path("si_figure_s11d_sites_13C.png"))
|
| 292 |
+
plt.show()
|
| 293 |
+
"""),
|
| 294 |
+
]
|
| 295 |
+
|
| 296 |
+
si_figure_s13 = [
|
| 297 |
+
md(r"""
|
| 298 |
+
# SI Figure S13: MagNET-x vs DFT explicit-solvent corrections
|
| 299 |
+
|
| 300 |
+
Four panels, both nuclei: **A** MagNET-x (NN) vs DFT explicit-solvent correction per site/solvent,
|
| 301 |
+
Desmond and OpenMM overlaid; **B** the NN-minus-DFT error distribution; **C** every site's DFT and NN
|
| 302 |
+
correction in chloroform, one column per solute; **D** the semi-parsimonious composite model's fit accuracy vs experiment across the four OpenMM solvents, split by engine and
|
| 303 |
+
DFT-vs-NN source.
|
| 304 |
+
"""),
|
| 305 |
+
code(_BOOTSTRAP),
|
| 306 |
+
code(_IMPORTS),
|
| 307 |
+
code(_SETUP),
|
| 308 |
+
code(_DFT_NN_LOAD),
|
| 309 |
+
code(r"""
|
| 310 |
+
# openMM/Desmond engine hues (used by the scatter/histogram/site/fitting-accuracy panels below)
|
| 311 |
+
ENGINE_COLORS = {"openMM": "#2E86AB", "desmond": "#A23B72"}
|
| 312 |
+
ENGINE_LABELS = {"desmond": "Desmond", "openMM": "OpenMM"}
|
| 313 |
+
"""),
|
| 314 |
+
md("## Panels A and B: MagNET-x vs DFT explicit corrections, Desmond and OpenMM overlaid"),
|
| 315 |
+
code(r"""
|
| 316 |
+
explicit_by_engine = delta22.compare_dft_nn_by_engine(dft, nn, keys=("solute", "site", "nucleus", "solvent"))
|
| 317 |
+
delta22_plots.plot_dft_nn_scatter_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,
|
| 318 |
+
save_path=figure_path("si_figure_s13a_scatter.png"))
|
| 319 |
+
delta22_plots.plot_dft_nn_error_histogram_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,
|
| 320 |
+
save_path=figure_path("si_figure_s13b_hist.png"))
|
| 321 |
+
plt.show()
|
| 322 |
+
"""),
|
| 323 |
+
md("## Panel C: every proton/carbon site's explicit correction in chloroform"),
|
| 324 |
+
code(r"""
|
| 325 |
+
for nucleus, label in [("H", "Proton"), ("C", "Carbon")]:
|
| 326 |
+
pairs = delta22.explicit_correction_dft_nn_pairs(dft, nn, "chloroform", nucleus)
|
| 327 |
+
delta22_plots.plot_explicit_correction_by_site(
|
| 328 |
+
pairs, f"All {label} Sites: DFT vs NN Explicit Corrections (chloroform)", ENGINE_COLORS,
|
| 329 |
+
save_path=figure_path(f"si_figure_s13c_sites_{'1H' if nucleus == 'H' else '13C'}.png"))
|
| 330 |
+
plt.show()
|
| 331 |
+
"""),
|
| 332 |
+
md("## Panel D: fitting accuracy of the semi-parsimonious composite model"),
|
| 333 |
+
code(_N_SPLITS_CELL),
|
| 334 |
+
code(r"""
|
| 335 |
+
SOLVENT_ORDER = ["chloroform", "methanol", "TIP4P", "benzene"]
|
| 336 |
+
solutes = sorted(dft["solute"].unique())
|
| 337 |
+
for nucleus, label in [("H", "1H"), ("C", "13C")]:
|
| 338 |
+
fitting = delta22.si_s13d_fitting_accuracy(dft, nn, SOLVENT_ORDER, N_SPLITS, solutes, nucleus=nucleus)
|
| 339 |
+
delta22_plots.plot_fitting_accuracy_boxplot(
|
| 340 |
+
fitting, SOLVENT_ORDER, label, ENGINE_COLORS,
|
| 341 |
+
save_path=figure_path(f"si_figure_s13d_fitting_{'1H' if nucleus == 'H' else '13C'}.png"))
|
| 342 |
+
plt.show()
|
| 343 |
+
"""),
|
| 344 |
+
]
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ----------------------------------------------------------------------------
|
| 348 |
+
# SI Figure S5: PCM Captures Bulk Solvent Effects
|
| 349 |
+
# ----------------------------------------------------------------------------
|
| 350 |
+
si_figure_s05 = [
|
| 351 |
+
md(r"""
|
| 352 |
+
# SI Figure S5: per-solvent PCM benefit vs bulk dielectric constant and polarizability
|
| 353 |
+
|
| 354 |
+
Per-solvent PCM benefit (% test-RMSE reduction) vs bulk dielectric constant and polarizability, for
|
| 355 |
+
the MagNET-Zero reference method per nucleus (WP04 ¹H, wB97X-D ¹³C). Panels A (¹H) and B (¹³C)
|
| 356 |
+
show all solvents; C repeats ¹H excluding aromatics and trifluoroethanol.
|
| 357 |
+
"""),
|
| 358 |
+
code(_BOOTSTRAP),
|
| 359 |
+
code(_IMPORTS),
|
| 360 |
+
code(_SETUP),
|
| 361 |
+
code(_N_SPLITS_CELL),
|
| 362 |
+
code(r"""
|
| 363 |
+
dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
|
| 364 |
+
solutes = sorted(dft["solute"].unique())
|
| 365 |
+
benefit = {}
|
| 366 |
+
for nucleus, label in [("H", "1H"), ("C", "13C")]:
|
| 367 |
+
method = delta22.MAGNET_PCM_OUTPUT_METHODS[nucleus]
|
| 368 |
+
benefit[nucleus] = delta22.pcm_benefit_per_solvent(dft, method, "pcSseg2", "aimnet2",
|
| 369 |
+
delta22.DESMOND_SOLVENTS, n_splits=N_SPLITS,
|
| 370 |
+
solutes=solutes, nucleus=nucleus)
|
| 371 |
+
print(f"{label} ({method}):"); print(benefit[nucleus].round(1).to_string())
|
| 372 |
+
"""),
|
| 373 |
+
md("## S5A (1H, all solvents), S5B (13C, all solvents), and S5C (1H, excluding aromatics and trifluoroethanol)"),
|
| 374 |
+
code(r"""
|
| 375 |
+
# panels A (1H) and B (13C): all solvents
|
| 376 |
+
for nucleus, letter, nuc_label in [("H", "A", "H"), ("C", "B", "C")]:
|
| 377 |
+
delta22_plots.plot_pcm_benefit_vs_properties(
|
| 378 |
+
benefit[nucleus], delta22.SOLVENT_DIELECTRIC, delta22.SOLVENT_POLARIZABILITY, nuc_label,
|
| 379 |
+
save_path=figure_path(f"si_figure_s05{letter}_all.png"))
|
| 380 |
+
# panel C is 1H only, excluding the aromatic solvents and trifluoroethanol (matches the published SI)
|
| 381 |
+
delta22_plots.plot_pcm_benefit_vs_properties(
|
| 382 |
+
benefit["H"], delta22.SOLVENT_DIELECTRIC, delta22.SOLVENT_POLARIZABILITY, "H",
|
| 383 |
+
exclude=["benzene", "toluene", "chlorobenzene", "trifluoroethanol"],
|
| 384 |
+
title_extra="Excluding Aromatic Solvents and Trifluoroethanol",
|
| 385 |
+
save_path=figure_path("si_figure_s05A_no_aromatics_tfe.png"))
|
| 386 |
+
"""),
|
| 387 |
+
]
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ----------------------------------------------------------------------------
|
| 391 |
+
# SI Figure S8 (panels B-D): Convergence of Explicit Solvent Corrections
|
| 392 |
+
# ----------------------------------------------------------------------------
|
| 393 |
+
si_figure_s08 = [
|
| 394 |
+
md(r"""
|
| 395 |
+
# SI Figure S8 (panels B-D): explicit-solvent correction vs MD-frame count
|
| 396 |
+
|
| 397 |
+
For an example site (AcOH proton in chloroform), the explicit-solvent correction vs number of MD frames included (running average): OpenMM vs Desmond (B1) and DFT vs MagNET-x/"NN" at fixed OpenMM (B3), with per-frame distributions (B2/B4) and autocorrelation (C). Panel D shows which frames have computed DFT data per solvent (also the source of B's histogram normalization).
|
| 398 |
+
"""),
|
| 399 |
+
code(_BOOTSTRAP),
|
| 400 |
+
code(r"""
|
| 401 |
+
import numpy as np
|
| 402 |
+
import matplotlib.pyplot as plt
|
| 403 |
+
|
| 404 |
+
import delta22
|
| 405 |
+
import delta22_reader
|
| 406 |
+
import delta22_plots
|
| 407 |
+
import paths
|
| 408 |
+
"""),
|
| 409 |
+
code(_SETUP),
|
| 410 |
+
code(r"""
|
| 411 |
+
site_atoms = delta22_reader.load_site_atom_data(XLSX, verbose=False)
|
| 412 |
+
idx = delta22.site_atom_indices(site_atoms.loc[("AcOH", "H", "H"), "atom_numbers"])
|
| 413 |
+
SOLUTE, SOLVENT = "AcOH", "chloroform"
|
| 414 |
+
"""),
|
| 415 |
+
code(r"""
|
| 416 |
+
# OpenMM/Desmond engine hues; DFT vs NN reuse the OpenMM hue (DFT lightened, NN full strength)
|
| 417 |
+
ENGINE_COLORS = {"openMM": "#2E86AB", "desmond": "#A23B72"}
|
| 418 |
+
ENGINE_LABELS = {"openMM": "OpenMM", "desmond": "Desmond"}
|
| 419 |
+
|
| 420 |
+
SOURCE_COLORS = {"dft": delta22_plots.lighten_color(ENGINE_COLORS["openMM"]), "nn": ENGINE_COLORS["openMM"]}
|
| 421 |
+
SOURCE_LABELS = {"dft": "DFT", "nn": "NN"}
|
| 422 |
+
LABEL_COLORS = {**ENGINE_COLORS, **SOURCE_COLORS}
|
| 423 |
+
LABEL_NAMES = {**ENGINE_LABELS, **SOURCE_LABELS}
|
| 424 |
+
"""),
|
| 425 |
+
md(r"""
|
| 426 |
+
## Panel B1/B2: OpenMM vs Desmond
|
| 427 |
+
|
| 428 |
+
Running-average convergence (B1) and per-frame distribution (B2), comparing the two MD engines.
|
| 429 |
+
"""),
|
| 430 |
+
code(r"""
|
| 431 |
+
running_by_engine, finals_by_engine, per_frame_by_engine, n_frames_by_engine = {}, {}, {}, {}
|
| 432 |
+
for engine in ["openMM", "desmond"]:
|
| 433 |
+
perturbed = delta22_reader.load_perturbed_shieldings(DELTA22_HDF5, SOLUTE, SOLVENT, engine, "dft")
|
| 434 |
+
per_frame = delta22.frame_corrections(perturbed, idx)
|
| 435 |
+
running = delta22.running_average(per_frame)
|
| 436 |
+
running_by_engine[engine] = running
|
| 437 |
+
finals_by_engine[engine] = running[~np.isnan(running)][-1]
|
| 438 |
+
per_frame_by_engine[engine] = per_frame
|
| 439 |
+
n_frames_by_engine[engine] = len(per_frame) # the total trajectory length, valid or not
|
| 440 |
+
print(f"{engine}: {int(np.sum(~np.isnan(per_frame)))} / {len(per_frame)} valid frames, "
|
| 441 |
+
f"converged correction {finals_by_engine[engine]:.4f} ppm")
|
| 442 |
+
|
| 443 |
+
delta22_plots.plot_frame_convergence(
|
| 444 |
+
running_by_engine, finals_by_engine, LABEL_COLORS, LABEL_NAMES,
|
| 445 |
+
title=f"Convergence of Explicit Solvent Corrections\n({SOLUTE} in {SOLVENT})",
|
| 446 |
+
save_path=figure_path("si_figure_s08_b1_convergence_engine.png"))
|
| 447 |
+
plt.show()
|
| 448 |
+
|
| 449 |
+
delta22_plots.plot_frame_correction_histogram(
|
| 450 |
+
per_frame_by_engine, n_frames_by_engine, LABEL_COLORS, LABEL_NAMES,
|
| 451 |
+
title="Distribution of Frame-wise Corrections",
|
| 452 |
+
save_path=figure_path("si_figure_s08_b2_histogram_engine.png"))
|
| 453 |
+
plt.show()
|
| 454 |
+
"""),
|
| 455 |
+
md(r"""
|
| 456 |
+
## Panel B3/B4: DFT vs NN
|
| 457 |
+
|
| 458 |
+
Same running average (B3) and per-frame distribution (B4), at fixed OpenMM engine, comparing DFT vs
|
| 459 |
+
MagNET-x ("NN") shieldings.
|
| 460 |
+
"""),
|
| 461 |
+
code(r"""
|
| 462 |
+
running_by_source, finals_by_source, per_frame_by_source, n_frames_by_source = {}, {}, {}, {}
|
| 463 |
+
for source in ["dft", "nn"]:
|
| 464 |
+
perturbed = delta22_reader.load_perturbed_shieldings(DELTA22_HDF5, SOLUTE, SOLVENT, "openMM", source)
|
| 465 |
+
per_frame = delta22.frame_corrections(perturbed, idx)
|
| 466 |
+
running = delta22.running_average(per_frame)
|
| 467 |
+
running_by_source[source] = running
|
| 468 |
+
finals_by_source[source] = running[~np.isnan(running)][-1]
|
| 469 |
+
per_frame_by_source[source] = per_frame
|
| 470 |
+
n_frames_by_source[source] = len(per_frame)
|
| 471 |
+
print(f"{source}: {int(np.sum(~np.isnan(per_frame)))} / {len(per_frame)} valid frames, "
|
| 472 |
+
f"converged correction {finals_by_source[source]:.4f} ppm")
|
| 473 |
+
|
| 474 |
+
delta22_plots.plot_frame_convergence(
|
| 475 |
+
running_by_source, finals_by_source, LABEL_COLORS, LABEL_NAMES, xlabel="Number of MD Frames",
|
| 476 |
+
title=f"OpenMM DFT vs. NN Convergence Comparison\n({SOLUTE} in {SOLVENT})",
|
| 477 |
+
save_path=figure_path("si_figure_s08_b3_convergence_source.png"))
|
| 478 |
+
plt.show()
|
| 479 |
+
|
| 480 |
+
delta22_plots.plot_frame_correction_histogram(
|
| 481 |
+
per_frame_by_source, n_frames_by_source, LABEL_COLORS, LABEL_NAMES,
|
| 482 |
+
title="Distribution of Frame-wise Corrections",
|
| 483 |
+
save_path=figure_path("si_figure_s08_b4_histogram_source.png"))
|
| 484 |
+
plt.show()
|
| 485 |
+
"""),
|
| 486 |
+
md(r"""
|
| 487 |
+
## Panel C: autocorrelation, DFT vs NN
|
| 488 |
+
|
| 489 |
+
Autocorrelation of the per-frame correction to lag 200 (~100 frames per trajectory repeat), DFT vs NN,
|
| 490 |
+
same OpenMM trajectory as B3/B4.
|
| 491 |
+
"""),
|
| 492 |
+
code(r"""
|
| 493 |
+
autocorr_by_source = {source: delta22.autocorrelation(per_frame_by_source[source], max_lag=200)
|
| 494 |
+
for source in ["dft", "nn"]}
|
| 495 |
+
for source, autocorr in autocorr_by_source.items():
|
| 496 |
+
print(f"{source}: lag-1 autocorrelation {autocorr[1]:.3f}")
|
| 497 |
+
|
| 498 |
+
delta22_plots.plot_frame_autocorrelation(
|
| 499 |
+
autocorr_by_source, LABEL_COLORS, LABEL_NAMES,
|
| 500 |
+
title=f"Autocorrelation of Frame-wise Corrections\n({SOLUTE} in {SOLVENT})",
|
| 501 |
+
save_path=figure_path("si_figure_s08_c_autocorrelation.png"))
|
| 502 |
+
plt.show()
|
| 503 |
+
"""),
|
| 504 |
+
md(r"""
|
| 505 |
+
## Panel D: frame data availability
|
| 506 |
+
|
| 507 |
+
Which trajectory frames have computed DFT shielding for AcOH, across all 12 Desmond and 4 OpenMM
|
| 508 |
+
solvents. DFT was computed for a non-contiguous subset (compute-budget limits; jobs queued randomly).
|
| 509 |
+
"""),
|
| 510 |
+
code(r"""
|
| 511 |
+
# row order matching the published panel: chloroform first, then by solvent class (aprotic ->
|
| 512 |
+
# protic -> aromatic); OpenMM only has the four explicit-solvent solvents
|
| 513 |
+
DESMOND_ORDER = ["chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
|
| 514 |
+
"dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P",
|
| 515 |
+
"benzene", "toluene", "chlorobenzene"]
|
| 516 |
+
OPENMM_ORDER = ["chloroform", "methanol", "TIP4P", "benzene"]
|
| 517 |
+
|
| 518 |
+
grids_by_engine, solvents_by_engine = {}, {}
|
| 519 |
+
for engine, solvents in [("desmond", DESMOND_ORDER), ("openMM", OPENMM_ORDER)]:
|
| 520 |
+
grid, frame_counts = delta22.frame_validity_grid(DELTA22_HDF5, SOLUTE, solvents, engine, shield_type="dft")
|
| 521 |
+
grids_by_engine[engine] = grid
|
| 522 |
+
solvents_by_engine[engine] = [delta22_plots.display_solvent_name(s) for s in solvents]
|
| 523 |
+
print(f"{engine}: {len(solvents)} solvents, frame counts {dict(zip(solvents, frame_counts))}")
|
| 524 |
+
|
| 525 |
+
delta22_plots.plot_frame_validity_heatmaps(grids_by_engine, solvents_by_engine, ENGINE_LABELS, solute=SOLUTE,
|
| 526 |
+
save_path=figure_path("si_figure_s08_d_frame_validity.png"))
|
| 527 |
+
plt.show()
|
| 528 |
+
"""),
|
| 529 |
+
]
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
# ----------------------------------------------------------------------------
|
| 533 |
+
# Figure 2A + SI Figure S1: the accuracy-vs-cost Pareto frontier
|
| 534 |
+
# ----------------------------------------------------------------------------
|
| 535 |
+
_PARETO_LOAD = r"""
|
| 536 |
+
# Load the flat DFT and MagNET tables and the timing tables.
|
| 537 |
+
dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
|
| 538 |
+
nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)
|
| 539 |
+
dft_gas_timings, nn_timings = delta22.load_pareto_timings(DELTA22_HDF5)
|
| 540 |
+
|
| 541 |
+
# One point per method/basis/geometry/nucleus/solvent, plus the solvent-averaged rows, each with its
|
| 542 |
+
# mean test RMSE and total compute time. MagNET appears as a single method (aimnet2, basis "N/A").
|
| 543 |
+
# Figure 2A uses 100 seeded splits, not the 250 the other delta-22 panels use, and trains on the
|
| 544 |
+
# first 10 shuffled solutes / tests on the rest; fig2a_pareto_points handles that split convention.
|
| 545 |
+
PARETO_N_SPLITS = 100
|
| 546 |
+
points = delta22.fig2a_pareto_points(dft, nn, dft_gas_timings, nn_timings, n_splits=PARETO_N_SPLITS)
|
| 547 |
+
print(points["nmr_method"].nunique(), "methods;",
|
| 548 |
+
"MagNET total time", float(points.query("nmr_method=='MagNET'")["total_time"].iloc[0]), "s")
|
| 549 |
+
"""
|
| 550 |
+
|
| 551 |
+
# Figure 2A (analysis/manuscript_figures/fig2a_pareto.ipynb) is hand-authored with its plotting
|
| 552 |
+
# inline; its data comes from delta22.py.
|
| 553 |
+
|
| 554 |
+
# SI Figure S1 (analysis/si_figures/si_figure_s01.ipynb) is hand-authored too, with the same Pareto
|
| 555 |
+
# panel engine as Figure 2A run for both nuclei; its data comes from delta22.py.
|
| 556 |
+
|
| 557 |
+
# ----------------------------------------------------------------------------
|
| 558 |
+
# SI Tables S1/S2: the Pareto plot data (same chloroform rows as SI Figure S1)
|
| 559 |
+
# ----------------------------------------------------------------------------
|
| 560 |
+
si_table_s01_s02_pareto = [
|
| 561 |
+
md(r"""
|
| 562 |
+
# SI Tables S1 and S2: the Pareto plot data
|
| 563 |
+
|
| 564 |
+
Each method's total compute time and CDCl3 test RMSE, for proton (S1) and carbon (S2): a curated
|
| 565 |
+
55-row subset (MagNET, one AIMNet2-geometry row, and the DFT grid on PBE0/cc-pVTZ geometries).
|
| 566 |
+
"""),
|
| 567 |
+
code(_BOOTSTRAP),
|
| 568 |
+
code(_IMPORTS_TABLE_NPPD),
|
| 569 |
+
code(_SETUP_TABLE),
|
| 570 |
+
code(_PARETO_LOAD),
|
| 571 |
+
md("## Tables S1 and S2"),
|
| 572 |
+
code(r"""
|
| 573 |
+
# full column set matching the SI tables: identity + fitting RMSE + the three time components and
|
| 574 |
+
# their log10 (all solvents here are chloroform). geometry_time and nmr_time sum to total_time.
|
| 575 |
+
COLUMNS = ["geometry_type", "nmr_method", "basis", "solvent", "fitting_RMSE",
|
| 576 |
+
"geometry_time", "nmr_time", "total_time", "log10_total_time"]
|
| 577 |
+
|
| 578 |
+
curated = {}
|
| 579 |
+
for nucleus, label in [("H", "S1"), ("C", "S2")]:
|
| 580 |
+
sub = delta22.pareto_table_curated(points, nucleus).copy()
|
| 581 |
+
sub["log10_total_time"] = np.log10(sub["total_time"])
|
| 582 |
+
sub = sub[COLUMNS]
|
| 583 |
+
curated[label] = sub
|
| 584 |
+
print(f"Table {label} ({nucleus}): {len(sub)} rows x {sub.shape[1]} columns")
|
| 585 |
+
print(sub.to_string(index=False), "\n")
|
| 586 |
+
|
| 587 |
+
# write the two tables to this notebook's documents/ folder, one sheet per SI table
|
| 588 |
+
out = document_path("si_table_s01_s02_pareto.xlsx")
|
| 589 |
+
with pd.ExcelWriter(out) as writer:
|
| 590 |
+
curated["S1"].to_excel(writer, sheet_name="Table S1 (1H)", index=False)
|
| 591 |
+
curated["S2"].to_excel(writer, sheet_name="Table S2 (13C)", index=False)
|
| 592 |
+
print("wrote", os.path.relpath(out, REPO))
|
| 593 |
+
"""),
|
| 594 |
+
]
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
# ----------------------------------------------------------------------------
|
| 598 |
+
# SI Figure S6: Explicit Solvation is Broadly Beneficial
|
| 599 |
+
# ----------------------------------------------------------------------------
|
| 600 |
+
si_figure_s06 = [
|
| 601 |
+
md(r"""
|
| 602 |
+
# SI Figure S6: per-solvent test RMSE of six solvent-correction models
|
| 603 |
+
|
| 604 |
+
Per nucleus, the per-solvent test RMSE of six solvent-correction models: SotA implicit (single-slope
|
| 605 |
+
PCM), the paper's implicit fit (stationary + PCM as separate terms), and explicit (Desmond), each
|
| 606 |
+
with and without a vibrational correction (QCD for ¹H, Desmond vibration for ¹³C). Level of theory:
|
| 607 |
+
dsd_pbep86/pcSseg3 on pbe0_tz geometries, PCM substituted from b3lyp_d3bj/pcSseg3.
|
| 608 |
+
"""),
|
| 609 |
+
code(_BOOTSTRAP),
|
| 610 |
+
code(_IMPORTS),
|
| 611 |
+
code(_SETUP),
|
| 612 |
+
code(r"""
|
| 613 |
+
query = delta22.add_composite_columns(delta22.load_query_df_dft(
|
| 614 |
+
DELTA22_HDF5, XLSX, pcm_reference_method="b3lyp_d3bj", pcm_reference_basis="pcSseg3", verbose=False))
|
| 615 |
+
solutes = sorted(query["solute"].unique())
|
| 616 |
+
|
| 617 |
+
METHOD, BASIS, GEOM = "dsd_pbep86", "pcSseg3", "pbe0_tz"
|
| 618 |
+
|
| 619 |
+
# same six categories for both nuclei; the "+ Vibrations" term differs (qcd for H, desmond_vib for C)
|
| 620 |
+
LADDER = {
|
| 621 |
+
"H": {
|
| 622 |
+
"stationary_plus_pcm": "SotA Implicit Solvent",
|
| 623 |
+
"stationary_plus_pcm_plus_qcd": "SotA Implicit Solvent + Vibrations",
|
| 624 |
+
"stationary + pcm": "Implicit Solvent",
|
| 625 |
+
"stationary_plus_qcd + pcm": "Implicit Solvent + Vibrations",
|
| 626 |
+
"stationary + desmond": "Explicit Solvent",
|
| 627 |
+
"stationary_plus_qcd + desmond": "Explicit Solvent + Vibrations"},
|
| 628 |
+
"C": {
|
| 629 |
+
"stationary_plus_pcm": "SotA Implicit Solvent",
|
| 630 |
+
"stationary_plus_pcm_plus_des_vib": "SotA Implicit Solvent + Vibrations",
|
| 631 |
+
"stationary + pcm": "Implicit Solvent",
|
| 632 |
+
"stationary_plus_des_vib + pcm": "Implicit Solvent + Vibrations",
|
| 633 |
+
"stationary + desmond": "Explicit Solvent",
|
| 634 |
+
"stationary_plus_des_vib + desmond": "Explicit Solvent + Vibrations"},
|
| 635 |
+
}
|
| 636 |
+
"""),
|
| 637 |
+
code(r"""
|
| 638 |
+
# one dark/light pair per solvent-treatment family (SotA implicit / implicit / explicit), light = +Vibrations
|
| 639 |
+
S6_COLORS = ["#C4B037", "#F5EDA0", "#A72608", "#E4A0A0", "#5F7C8A", "#B9E7DF"]
|
| 640 |
+
"""),
|
| 641 |
+
md("## Formula ladder per nucleus"),
|
| 642 |
+
code(_N_SPLITS_CELL),
|
| 643 |
+
code(r"""
|
| 644 |
+
for nucleus, labels in LADDER.items():
|
| 645 |
+
results = delta22.fig3d_formula_regressions(query, METHOD, BASIS, GEOM, list(labels),
|
| 646 |
+
delta22.DESMOND_SOLVENTS, n_splits=N_SPLITS,
|
| 647 |
+
solutes=solutes, nucleus=nucleus)
|
| 648 |
+
med = results.groupby("formula")["test_RMSE"].median()
|
| 649 |
+
print(f"--- {nucleus} ({METHOD}) median test RMSE ---")
|
| 650 |
+
for formula in labels:
|
| 651 |
+
print(f" {labels[formula]:38s} {med[formula]:.4f}")
|
| 652 |
+
delta22_plots.plot_formula_ladder_boxplot(
|
| 653 |
+
results, labels, delta22.SOLVENT_GROUPS, nucleus=nucleus, colors=S6_COLORS,
|
| 654 |
+
title=f"Explicit Solvation + Vibrational Effects Improve Predictions Across Solvents ({nucleus} Nucleus)",
|
| 655 |
+
save_path=figure_path(f"si_figure_s06_ladder_{'1H' if nucleus == 'H' else '13C'}.png"))
|
| 656 |
+
plt.show()
|
| 657 |
+
"""),
|
| 658 |
+
]
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
# ----------------------------------------------------------------------------
|
| 662 |
+
# SI Table S6: Explicit solvent corrections are superior for predicting solvent-induced shifts
|
| 663 |
+
# ----------------------------------------------------------------------------
|
| 664 |
+
# SI table row order (chloroform is the reference and so is excluded)
|
| 665 |
+
_S6_SOLVENT_ORDER = [
|
| 666 |
+
"tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile", "dimethylsulfoxide",
|
| 667 |
+
"trifluoroethanol", "methanol", "TIP4P", "benzene", "toluene", "chlorobenzene",
|
| 668 |
+
]
|
| 669 |
+
si_table_s06 = [
|
| 670 |
+
md(r"""
|
| 671 |
+
# SI Table S6: per-solvent implicit vs explicit fit RMSE (chloroform reference, ¹H)
|
| 672 |
+
|
| 673 |
+
Per solvent (chloroform reference), fit RMSE of experimental solvent-induced ¹H shifts using implicit
|
| 674 |
+
(PCM) vs explicit (Desmond), and the percent improvement.
|
| 675 |
+
"""),
|
| 676 |
+
code(_BOOTSTRAP),
|
| 677 |
+
code(_IMPORTS_TABLE_PD),
|
| 678 |
+
code(_SETUP_TABLE),
|
| 679 |
+
code(_FIG4_LOAD),
|
| 680 |
+
md("## Table S6: per-solvent implicit vs explicit fit RMSE (chloroform reference, 1H)"),
|
| 681 |
+
code(r"""
|
| 682 |
+
rows = []
|
| 683 |
+
for solvent in _S6_SOLVENT_ORDER:
|
| 684 |
+
sp = delta22.solvent_pair_differences(one, solvent, "chloroform", nucleus="H", explicit="desmond")
|
| 685 |
+
implicit = delta22.fit_differences_to_experimental(sp, "implicit_diff")["rmse"]
|
| 686 |
+
explicit = delta22.fit_differences_to_experimental(sp, "explicit_diff")["rmse"]
|
| 687 |
+
rows.append({"solvent": solvent,
|
| 688 |
+
"implicit_fitting_rmse": round(implicit, 4),
|
| 689 |
+
"explicit_fitting_rmse": round(explicit, 4),
|
| 690 |
+
"explicit_improvement_pct": round(100.0 * (implicit - explicit) / implicit, 2)})
|
| 691 |
+
table_s6 = pd.DataFrame(rows)
|
| 692 |
+
print(table_s6.to_string(index=False))
|
| 693 |
+
|
| 694 |
+
# write the table to this notebook's documents/ folder
|
| 695 |
+
out = document_path("si_table_s06_solvent_corrections.xlsx")
|
| 696 |
+
with pd.ExcelWriter(out) as writer:
|
| 697 |
+
table_s6.to_excel(writer, sheet_name="Table S6", index=False)
|
| 698 |
+
print("wrote", os.path.relpath(out, REPO))
|
| 699 |
+
""".replace("_S6_SOLVENT_ORDER", repr(_S6_SOLVENT_ORDER))),
|
| 700 |
+
]
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
# ----------------------------------------------------------------------------
|
| 704 |
+
# SI Figure S14: End-to-End Neural Network Acceleration Delivers Equivalent Performance
|
| 705 |
+
# ----------------------------------------------------------------------------
|
| 706 |
+
si_figure_s14 = [
|
| 707 |
+
md(r"""
|
| 708 |
+
# SI Figure S14: test RMSE with DFT vs MagNET features, by solvent
|
| 709 |
+
|
| 710 |
+
For the four explicit-solvent solvents (chloroform, methanol, TIP4P water, benzene), per-solvent test
|
| 711 |
+
RMSE of predicting experimental ¹H/¹³C shifts with **DFT** features (solid) vs end-to-end **MagNET/NN**
|
| 712 |
+
features (lightened), each under three conditions (implicit SOTA, explicit, explicit + vibrations).
|
| 713 |
+
Two pages, one per MD engine (Desmond, OpenMM); nitromethane dropped.
|
| 714 |
+
"""),
|
| 715 |
+
code(_BOOTSTRAP),
|
| 716 |
+
code(_IMPORTS),
|
| 717 |
+
code(_SETUP),
|
| 718 |
+
code(_N_SPLITS_CELL),
|
| 719 |
+
code(r"""
|
| 720 |
+
dft = delta22.add_composite_columns(delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False))
|
| 721 |
+
nn = delta22.add_composite_columns(delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False))
|
| 722 |
+
print(len(dft), "DFT rows;", len(nn), "NN rows")
|
| 723 |
+
|
| 724 |
+
LABELS = ["Implicit Solvent (SOTA)", "Explicit Solvent", "Explicit Solvent + Vibrations"]
|
| 725 |
+
SS_LABELS = {"TIP4P": "Water (TIP4P)"}
|
| 726 |
+
S14_SOLVENTS = ["chloroform", "methanol", "TIP4P", "benzene"] # the 4 explicit solvents, SI order
|
| 727 |
+
"""),
|
| 728 |
+
md("## Desmond page (DFT vs NN)"),
|
| 729 |
+
code(r"""
|
| 730 |
+
# implicit = 2-term (stationary + pcm); explicit = stationary + desmond;
|
| 731 |
+
# explicit + vibrations = stationary_plus_qcd + desmond (1H) / stationary_plus_des_vib + desmond (13C)
|
| 732 |
+
DESMOND_FORMULAS = {
|
| 733 |
+
"H": ["stationary + pcm", "stationary + desmond", "stationary_plus_qcd + desmond"],
|
| 734 |
+
"C": ["stationary + pcm", "stationary + desmond", "stationary_plus_des_vib + desmond"],
|
| 735 |
+
}
|
| 736 |
+
for nucleus, label in [("H", "1H"), ("C", "13C")]:
|
| 737 |
+
formulas = DESMOND_FORMULAS[nucleus]
|
| 738 |
+
delta22_plots.plot_ss_boxplot_dft_vs_nn(
|
| 739 |
+
delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),
|
| 740 |
+
delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),
|
| 741 |
+
S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,
|
| 742 |
+
save_path=figure_path(f"si_figure_s14_desmond_{label}.png"))
|
| 743 |
+
plt.show()
|
| 744 |
+
"""),
|
| 745 |
+
md("## OpenMM page (DFT vs NN)"),
|
| 746 |
+
code(r"""
|
| 747 |
+
# implicit = 1-term composite (stationary_plus_pcm); explicit = stationary + openMM;
|
| 748 |
+
# explicit + vibrations = stationary_plus_qcd + openMM (1H) / stationary_plus_op_vib + openMM (13C)
|
| 749 |
+
OPENMM_FORMULAS = {
|
| 750 |
+
"H": ["stationary_plus_pcm", "stationary + openMM", "stationary_plus_qcd + openMM"],
|
| 751 |
+
"C": ["stationary_plus_pcm", "stationary + openMM", "stationary_plus_op_vib + openMM"],
|
| 752 |
+
}
|
| 753 |
+
for nucleus, label in [("H", "1H"), ("C", "13C")]:
|
| 754 |
+
formulas = OPENMM_FORMULAS[nucleus]
|
| 755 |
+
delta22_plots.plot_ss_boxplot_dft_vs_nn(
|
| 756 |
+
delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),
|
| 757 |
+
delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),
|
| 758 |
+
S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,
|
| 759 |
+
save_path=figure_path(f"si_figure_s14_openmm_{label}.png"))
|
| 760 |
+
plt.show()
|
| 761 |
+
"""),
|
| 762 |
+
]
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
# name -> (cells, path relative to repo root). Notebooks are grouped by role (manuscript/si_figures/
|
| 766 |
+
# si_tables), not by dataset.
|
| 767 |
+
NOTEBOOKS = {
|
| 768 |
+
"si_figure_s06": (si_figure_s06, "analysis/si_figures/si_figure_s06.ipynb"),
|
| 769 |
+
"si_figure_s14": (si_figure_s14, "analysis/si_figures/si_figure_s14.ipynb"),
|
| 770 |
+
"si_table_s06": (si_table_s06, "analysis/si_tables/si_table_s06_solvent_corrections.ipynb"),
|
| 771 |
+
"si_table_s01_s02_pareto": (si_table_s01_s02_pareto, "analysis/si_tables/si_table_s01_s02_pareto.ipynb"),
|
| 772 |
+
"si_figure_s16_s18": (si_figure_s16_s18, "analysis/si_figures/si_figure_s16_s18.ipynb"),
|
| 773 |
+
"si_figure_s07": (si_figure_s07, "analysis/si_figures/si_figure_s07.ipynb"),
|
| 774 |
+
"si_figure_s04": (si_figure_s04, "analysis/si_figures/si_figure_s04.ipynb"),
|
| 775 |
+
"si_figure_s11": (si_figure_s11, "analysis/si_figures/si_figure_s11.ipynb"),
|
| 776 |
+
"si_figure_s13": (si_figure_s13, "analysis/si_figures/si_figure_s13.ipynb"),
|
| 777 |
+
"si_figure_s05": (si_figure_s05, "analysis/si_figures/si_figure_s05.ipynb"),
|
| 778 |
+
"si_figure_s08": (si_figure_s08, "analysis/si_figures/si_figure_s08_panelsBCD.ipynb"),
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
if __name__ == "__main__":
|
| 782 |
+
import os
|
| 783 |
+
import sys
|
| 784 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 785 |
+
repo = os.path.abspath(os.path.join(here, "..", ".."))
|
| 786 |
+
names = sys.argv[1:]
|
| 787 |
+
if not names:
|
| 788 |
+
print("usage: python3 build_nb_delta22.py <name> [<name> ...]")
|
| 789 |
+
print("regenerates ONLY the named notebook(s) -- pick just the one(s) you edited,")
|
| 790 |
+
print("since regenerating clears a notebook's execution outputs.")
|
| 791 |
+
print("available names:", ", ".join(NOTEBOOKS))
|
| 792 |
+
raise SystemExit(1)
|
| 793 |
+
unknown = [n for n in names if n not in NOTEBOOKS]
|
| 794 |
+
if unknown:
|
| 795 |
+
raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
|
| 796 |
+
for name in names:
|
| 797 |
+
cells, relpath = NOTEBOOKS[name]
|
| 798 |
+
_save(cells, os.path.join(repo, relpath))
|
analysis/code/build_nb_magnet_benchmark.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Source of truth for the MagNET-benchmark table notebooks (Tables S3/S4 and Table S5). Edit the
|
| 2 |
+
cell sources here, then regenerate and re-execute ONLY the notebook you touched, e.g.:
|
| 3 |
+
|
| 4 |
+
python3 build_nb_magnet_benchmark.py si_table_s03_s04_performance
|
| 5 |
+
jupyter nbconvert --to notebook --execute --inplace ../si_tables/si_table_s03_s04_performance.ipynb
|
| 6 |
+
|
| 7 |
+
python3 build_nb_magnet_benchmark.py si_table_s05_qcd
|
| 8 |
+
jupyter nbconvert --to notebook --execute --inplace ../si_tables/si_table_s05_qcd.ipynb
|
| 9 |
+
|
| 10 |
+
Regenerating overwrites the .ipynb (clearing its execution outputs), so do not hand-edit the
|
| 11 |
+
notebooks and regenerate only what you changed. All real code lives in magnet_benchmark.py (the
|
| 12 |
+
PUBLISHED reference values and the exact-reproduction group mapping); the notebooks only read
|
| 13 |
+
data/magnet_test_predictions/ through it. The notebooks live in analysis/si_tables/ (grouped by
|
| 14 |
+
role), not here.
|
| 15 |
+
|
| 16 |
+
Run with no arguments to list the available notebook names.
|
| 17 |
+
"""
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
|
| 21 |
+
from nb_build import md, code, save_notebook as _save
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
_BOOTSTRAP = r"""
|
| 25 |
+
import os, sys
|
| 26 |
+
|
| 27 |
+
# make the in-repo modules importable (not pip-installed)
|
| 28 |
+
REPO = os.path.abspath("../..")
|
| 29 |
+
for _p in ("data/magnet_test_predictions", "analysis/code", "analysis/code/shared"):
|
| 30 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
_IMPORTS = r"""
|
| 34 |
+
import pandas as pd
|
| 35 |
+
import magnet_test_predictions_reader
|
| 36 |
+
import magnet_benchmark
|
| 37 |
+
import paths
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
_SETUP = r"""
|
| 41 |
+
PREDICTIONS = paths.dataset_file("magnet_test_predictions", root=REPO)
|
| 42 |
+
|
| 43 |
+
def document_path(name):
|
| 44 |
+
# table/spreadsheet outputs go under this notebook's documents/ folder, created on first save
|
| 45 |
+
os.makedirs("documents", exist_ok=True)
|
| 46 |
+
return os.path.join("documents", name)
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
si_table_s03_s04_performance = [
|
| 50 |
+
md(r"""
|
| 51 |
+
# Tables S3 and S4: MagNET performance across geometries
|
| 52 |
+
|
| 53 |
+
How accurately MagNET reproduces its DFT training reference (PBE0/pcSseg-1) as geometries move from
|
| 54 |
+
stationary to vibrated to solvated, for the foundation model and the chloroform MagNET-x.
|
| 55 |
+
"""),
|
| 56 |
+
code(_BOOTSTRAP),
|
| 57 |
+
code(_IMPORTS),
|
| 58 |
+
code(_SETUP),
|
| 59 |
+
code(r"""
|
| 60 |
+
rows = magnet_benchmark.exact_stats_table(PREDICTIONS, magnet_test_predictions_reader)
|
| 61 |
+
res = pd.DataFrame(rows).set_index(["nucleus", "model", "test_set"])
|
| 62 |
+
|
| 63 |
+
table_rows = []
|
| 64 |
+
for nucleus in ("1H", "13C"):
|
| 65 |
+
for model in magnet_benchmark.MODELS:
|
| 66 |
+
for ts in magnet_benchmark.TEST_SETS:
|
| 67 |
+
r = res.loc[(nucleus, model, ts)]
|
| 68 |
+
pmed, pmae, prmse = magnet_benchmark.PUBLISHED[nucleus][(model, ts)]
|
| 69 |
+
table_rows.append(dict(nucleus=nucleus, model=model, test_set=ts, n=int(r.n),
|
| 70 |
+
median_repro=round(r.median_ae, 6), median_SI=round(pmed, 6),
|
| 71 |
+
mae_repro=round(r.mae, 6), mae_SI=round(pmae, 6),
|
| 72 |
+
rmse_repro=round(r.rmse, 5), rmse_SI=round(prmse, 5)))
|
| 73 |
+
table = pd.DataFrame(table_rows)
|
| 74 |
+
table_s3 = table[table.nucleus == "1H"].drop(columns="nucleus")
|
| 75 |
+
table_s4 = table[table.nucleus == "13C"].drop(columns="nucleus")
|
| 76 |
+
print("Table S3 (1H):"); display(table_s3)
|
| 77 |
+
print("Table S4 (13C):"); display(table_s4)
|
| 78 |
+
|
| 79 |
+
# write the reproduced tables to this notebook's documents/ folder, one sheet per SI table
|
| 80 |
+
out = document_path("si_table_s03_s04_performance.xlsx")
|
| 81 |
+
with pd.ExcelWriter(out) as writer:
|
| 82 |
+
table_s3.to_excel(writer, sheet_name="Table S3 (1H)", index=False)
|
| 83 |
+
table_s4.to_excel(writer, sheet_name="Table S4 (13C)", index=False)
|
| 84 |
+
print("wrote", os.path.relpath(out, REPO))
|
| 85 |
+
"""),
|
| 86 |
+
md("## Exact-reproduction check"),
|
| 87 |
+
code(r"""
|
| 88 |
+
# every row's reproduced median/MAE/RMSE should match the published SI value to a few parts per million
|
| 89 |
+
max_dev = (table[["median_repro", "median_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
|
| 90 |
+
table[["mae_repro", "mae_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
|
| 91 |
+
table[["rmse_repro", "rmse_SI"]].diff(axis=1).iloc[:, -1].abs().max())
|
| 92 |
+
print("largest reproduced-vs-published deviation (median, MAE, RMSE):", max_dev)
|
| 93 |
+
assert max(max_dev) < 1e-3, "a row diverged from the SI by more than float rounding"
|
| 94 |
+
"""),
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
si_table_s05_qcd = [
|
| 99 |
+
md(r"""
|
| 100 |
+
# Table S5: Performance statistics for predicting QCD corrections
|
| 101 |
+
|
| 102 |
+
Foundation MagNET's accuracy at predicting the rovibrational (QCD) correction (stationary vs
|
| 103 |
+
trajectory-averaged shielding) over qcdtraj2500 (2500 molecules), ¹H and ¹³C, all shieldings at
|
| 104 |
+
PBE0/pcSseg-1.
|
| 105 |
+
"""),
|
| 106 |
+
code(_BOOTSTRAP),
|
| 107 |
+
code(_IMPORTS),
|
| 108 |
+
code(_SETUP),
|
| 109 |
+
code(r"""
|
| 110 |
+
rows = magnet_benchmark.qcd_stats_table(PREDICTIONS, magnet_test_predictions_reader)
|
| 111 |
+
table_rows = []
|
| 112 |
+
for r in rows:
|
| 113 |
+
nucleus = r["model"].split("(")[1].rstrip(")") # "1H" / "13C"
|
| 114 |
+
pmed, pmae, prmse = magnet_benchmark.PUBLISHED_S5[nucleus]
|
| 115 |
+
table_rows.append(dict(model=r["model"], n=int(r["n"]),
|
| 116 |
+
median_repro=round(r["median_ae"], 8), median_SI=round(pmed, 8),
|
| 117 |
+
mae_repro=round(r["mae"], 8), mae_SI=round(pmae, 8),
|
| 118 |
+
rmse_repro=round(r["rmse"], 8), rmse_SI=round(prmse, 8)))
|
| 119 |
+
table_s5 = pd.DataFrame(table_rows)
|
| 120 |
+
print("Table S5 (QCD corrections):"); display(table_s5)
|
| 121 |
+
|
| 122 |
+
# write the reproduced table to this notebook's documents/ folder
|
| 123 |
+
out = document_path("si_table_s05_qcd.xlsx")
|
| 124 |
+
with pd.ExcelWriter(out) as writer:
|
| 125 |
+
table_s5.to_excel(writer, sheet_name="Table S5", index=False)
|
| 126 |
+
print("wrote", os.path.relpath(out, REPO))
|
| 127 |
+
"""),
|
| 128 |
+
md("## Exact-reproduction check"),
|
| 129 |
+
code(r"""
|
| 130 |
+
# every reproduced median/MAE/RMSE should match the published SI value to a few parts per million
|
| 131 |
+
dev = max((table_s5[["median_repro", "median_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
|
| 132 |
+
table_s5[["mae_repro", "mae_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
|
| 133 |
+
table_s5[["rmse_repro", "rmse_SI"]].diff(axis=1).iloc[:, -1].abs().max()))
|
| 134 |
+
print("largest reproduced-vs-published deviation:", dev)
|
| 135 |
+
assert dev < 1e-3, "a row diverged from the SI by more than float rounding"
|
| 136 |
+
"""),
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# name -> (cells, path relative to repo root)
|
| 141 |
+
NOTEBOOKS = {
|
| 142 |
+
"si_table_s03_s04_performance": (si_table_s03_s04_performance,
|
| 143 |
+
"analysis/si_tables/si_table_s03_s04_performance.ipynb"),
|
| 144 |
+
"si_table_s05_qcd": (si_table_s05_qcd, "analysis/si_tables/si_table_s05_qcd.ipynb"),
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if __name__ == "__main__":
|
| 148 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 149 |
+
repo = os.path.abspath(os.path.join(here, "..", ".."))
|
| 150 |
+
names = sys.argv[1:]
|
| 151 |
+
if not names:
|
| 152 |
+
print("available names:", ", ".join(NOTEBOOKS))
|
| 153 |
+
sys.exit(0)
|
| 154 |
+
unknown = [n for n in names if n not in NOTEBOOKS]
|
| 155 |
+
if unknown:
|
| 156 |
+
raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
|
| 157 |
+
for name in names:
|
| 158 |
+
cells, relpath = NOTEBOOKS[name]
|
| 159 |
+
_save(cells, os.path.join(repo, relpath))
|
analysis/code/build_nb_scaling.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Source of truth for the Tables S10/S11 notebook (MagNET-Zero/MagNET-PCM scaling factors). Edit
|
| 2 |
+
the cell sources here, then regenerate:
|
| 3 |
+
|
| 4 |
+
python3 build_nb_scaling.py si_table_s10_s11_scaling
|
| 5 |
+
jupyter nbconvert --to notebook --execute --inplace ../si_tables/si_table_s10_s11_scaling.ipynb
|
| 6 |
+
|
| 7 |
+
Regenerating overwrites the .ipynb (clearing its execution outputs). All real code lives in
|
| 8 |
+
scaling_factors.py (the published tables and the re-derivation from delta-22); the notebook only
|
| 9 |
+
runs it. The notebook lives in analysis/si_tables/ (grouped by role), not here.
|
| 10 |
+
|
| 11 |
+
Run with no arguments to list the available notebook names.
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
from nb_build import md, code, save_notebook as _save
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
_BOOTSTRAP = r"""
|
| 20 |
+
import os, sys
|
| 21 |
+
|
| 22 |
+
# make the in-repo modules importable (not pip-installed)
|
| 23 |
+
REPO = os.path.abspath("../..")
|
| 24 |
+
for _p in ("data/delta22", "data/scaling_factors", "data/applications",
|
| 25 |
+
"analysis/code", "analysis/code/shared"):
|
| 26 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
_IMPORTS = r"""
|
| 30 |
+
import numpy as np
|
| 31 |
+
import pandas as pd
|
| 32 |
+
import scaling_factors
|
| 33 |
+
import scaling_factors_reader
|
| 34 |
+
import build_composite_model
|
| 35 |
+
import paths
|
| 36 |
+
from applications_reader import Applications
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
_SETUP = r"""
|
| 40 |
+
DELTA22 = paths.dataset_file("delta22", root=REPO)
|
| 41 |
+
XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 42 |
+
|
| 43 |
+
def document_path(name):
|
| 44 |
+
os.makedirs("documents", exist_ok=True)
|
| 45 |
+
return os.path.join("documents", name)
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
si_table_s10_s11_scaling = [
|
| 49 |
+
md(r"""
|
| 50 |
+
# Tables S10 and S11: MagNET-Zero/MagNET-PCM scaling factors
|
| 51 |
+
|
| 52 |
+
Per-solvent linear coefficients (intercept, stationary, pcm) mapping MagNET-Zero shieldings plus a
|
| 53 |
+
MagNET-PCM correction to predicted shifts: ¹H (S10) and ¹³C (S11).
|
| 54 |
+
"""),
|
| 55 |
+
code(_BOOTSTRAP),
|
| 56 |
+
code(_IMPORTS),
|
| 57 |
+
code(_SETUP),
|
| 58 |
+
code(r"""
|
| 59 |
+
tables = scaling_factors.published_scaling_tables() # {"H": Table S10, "C": Table S11}
|
| 60 |
+
print("Table S10 (1H):"); display(tables["H"])
|
| 61 |
+
print("Table S11 (13C):"); display(tables["C"])
|
| 62 |
+
|
| 63 |
+
out = document_path("si_table_s10_s11_scaling.xlsx")
|
| 64 |
+
with pd.ExcelWriter(out) as writer:
|
| 65 |
+
tables["H"].reset_index().to_excel(writer, sheet_name="Table S10 (1H)", index=False)
|
| 66 |
+
tables["C"].reset_index().to_excel(writer, sheet_name="Table S11 (13C)", index=False)
|
| 67 |
+
print("wrote", os.path.relpath(out, REPO))
|
| 68 |
+
"""),
|
| 69 |
+
md("## Reproducibility check: re-derive from delta-22"),
|
| 70 |
+
code(r"""
|
| 71 |
+
derived = scaling_factors.build_scaling_tables(DELTA22, XLSX)
|
| 72 |
+
for nucleus in ("H", "C"):
|
| 73 |
+
p = tables[nucleus]
|
| 74 |
+
d = derived[nucleus].reindex(p.index)[p.columns]
|
| 75 |
+
max_dev = float(np.abs(p.values - d.values).max())
|
| 76 |
+
print(f"{nucleus}: largest published-vs-rederived deviation = {max_dev:.2e}")
|
| 77 |
+
assert max_dev < 1e-5, f"{nucleus} scaling table diverged from the published values"
|
| 78 |
+
print("both tables reproduce from delta-22")
|
| 79 |
+
"""),
|
| 80 |
+
md(r"""
|
| 81 |
+
## Deployment tables with reflection symmetrization
|
| 82 |
+
|
| 83 |
+
The tables above match the published SI exactly. For serving new molecules, the recommended tables
|
| 84 |
+
average each prediction with its mirror image, correcting a reflection-parity error. Reproducing
|
| 85 |
+
them needs the model checkpoints; the shipped tables are shown below.
|
| 86 |
+
"""),
|
| 87 |
+
code(r"""
|
| 88 |
+
symmetrized = scaling_factors_reader.load_symmetrized_tables()
|
| 89 |
+
print("Symmetrized deployment Table S10 (1H):"); display(symmetrized["H"])
|
| 90 |
+
print("Symmetrized deployment Table S11 (13C):"); display(symmetrized["C"])
|
| 91 |
+
"""),
|
| 92 |
+
md(r"""
|
| 93 |
+
## Composite-model coefficients (Figure 5C/5D, SI S15)
|
| 94 |
+
|
| 95 |
+
The natural-products figures use a larger family of per-solvent coefficients fit on delta-22 (the
|
| 96 |
+
`composite_model` group in `applications.hdf5`): OLS fits, 1000-seed bootstrap resamples, their RMSE
|
| 97 |
+
distributions, and the PCM conversion factors. The group regenerates from released inputs alone.
|
| 98 |
+
"""),
|
| 99 |
+
code(r"""
|
| 100 |
+
reader = Applications(paths.dataset_file("applications", root=REPO))
|
| 101 |
+
regenerated = build_composite_model.regenerate(reader)
|
| 102 |
+
max_dev = build_composite_model.verify(reader, regenerated)
|
| 103 |
+
print(f"composite_model largest regenerated-vs-stored deviation = {max_dev:.2e}")
|
| 104 |
+
"""),
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# name -> (cells, path relative to repo root)
|
| 109 |
+
NOTEBOOKS = {
|
| 110 |
+
"si_table_s10_s11_scaling": (si_table_s10_s11_scaling,
|
| 111 |
+
"analysis/si_tables/si_table_s10_s11_scaling.ipynb"),
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 116 |
+
repo = os.path.abspath(os.path.join(here, "..", ".."))
|
| 117 |
+
names = sys.argv[1:]
|
| 118 |
+
if not names:
|
| 119 |
+
print("available names:", ", ".join(NOTEBOOKS))
|
| 120 |
+
sys.exit(0)
|
| 121 |
+
unknown = [n for n in names if n not in NOTEBOOKS]
|
| 122 |
+
if unknown:
|
| 123 |
+
raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
|
| 124 |
+
for name in names:
|
| 125 |
+
cells, relpath = NOTEBOOKS[name]
|
| 126 |
+
_save(cells, os.path.join(repo, relpath))
|
analysis/code/composite_models.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reproduces the delta-22 composite-formula ablation study behind SI Figure S15's Excel screenshots.
|
| 2 |
+
|
| 3 |
+
Rebuilds `ablations.xlsx`: ~25 composite-formula variants (stationary geometry plus some combination
|
| 4 |
+
of implicit PCM, explicit Desmond, and rovibrational QCD corrections) across all 12 delta-22 solvents,
|
| 5 |
+
at two reference levels: DSD-PBEP86/pcSseg-3 (geometry PBE0/tz) and the MagNET-Zero training reference
|
| 6 |
+
(WP04/pcSseg-2 for 1H, wB97X-D/pcSseg-2 for 13C, AIMNet2 geometries). The fitting reuses delta22.py's
|
| 7 |
+
harness (same as si_figure_s06 at the DSD level, widened to the full formula set and both reference
|
| 8 |
+
levels); the openpyxl table-writing layer (color scales, %benefit formulas, best-model block)
|
| 9 |
+
matches the published screenshots.
|
| 10 |
+
|
| 11 |
+
Nitromethane: kept in (unlike scaling_factors.py, which drops it as an ML outlier). Per the SI, this
|
| 12 |
+
is a plain OLS fit, not ML, so all 22 solutes are used (10 test / 12 train per split).
|
| 13 |
+
"""
|
| 14 |
+
from openpyxl import Workbook
|
| 15 |
+
from openpyxl.styles import Font, PatternFill
|
| 16 |
+
from openpyxl.utils import get_column_letter
|
| 17 |
+
from openpyxl.formatting.rule import ColorScaleRule, FormulaRule
|
| 18 |
+
|
| 19 |
+
import delta22 as D
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# formulas and reference-level configuration
|
| 23 |
+
|
| 24 |
+
# the full ablation formula set (one-term through three-term composites), Desmond explicit-solvent
|
| 25 |
+
# variant. This order is also the display order in "Multi-Term Models".
|
| 26 |
+
FORMULAS = [
|
| 27 |
+
"stationary",
|
| 28 |
+
"stationary + pcm",
|
| 29 |
+
"stationary + desmond",
|
| 30 |
+
"stationary + qcd",
|
| 31 |
+
"stationary + desmond_vib",
|
| 32 |
+
"stationary + pcm + qcd",
|
| 33 |
+
"stationary + pcm + desmond_vib",
|
| 34 |
+
"stationary + desmond + qcd",
|
| 35 |
+
"stationary + desmond + desmond_vib",
|
| 36 |
+
"stationary_plus_pcm",
|
| 37 |
+
"stationary_plus_desmond",
|
| 38 |
+
"stationary_plus_qcd",
|
| 39 |
+
"stationary_plus_des_vib",
|
| 40 |
+
"stationary_plus_pcm + qcd",
|
| 41 |
+
"stationary_plus_pcm + desmond_vib",
|
| 42 |
+
"stationary_plus_desmond + qcd",
|
| 43 |
+
"stationary_plus_desmond + desmond_vib",
|
| 44 |
+
"stationary_plus_qcd + pcm",
|
| 45 |
+
"stationary_plus_qcd + desmond",
|
| 46 |
+
"stationary_plus_des_vib + pcm",
|
| 47 |
+
"stationary_plus_des_vib + desmond",
|
| 48 |
+
"stationary_plus_pcm_plus_qcd",
|
| 49 |
+
"stationary_plus_pcm_plus_des_vib",
|
| 50 |
+
"stationary_plus_desmond_plus_qcd",
|
| 51 |
+
"stationary_plus_desmond_plus_des_vib",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
# display column order for the spreadsheet (distinct from D.DESMOND_SOLVENTS' iteration order)
|
| 55 |
+
ORDERED_SOLVENTS = [
|
| 56 |
+
"chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
|
| 57 |
+
"dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P", "benzene", "toluene",
|
| 58 |
+
"chlorobenzene",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
# the two reference levels the shipped workbook compares. "geometry" is the sap_geometry_type the
|
| 62 |
+
# stationary/pcm/etc columns were computed on; PCM's own reference level (used for the "PCM ="
|
| 63 |
+
# header line) is fixed at B3LYP-D3(BJ)/pcSseg-3 for both, matching load_delta22_dft_data's default.
|
| 64 |
+
# sheet_suffix_h/sheet_suffix_c differ within a level (the shipped workbook names the carbon sheet
|
| 65 |
+
# after wB97X-D, not WP04, since each nucleus uses its own MagNET-Zero training reference method).
|
| 66 |
+
REFERENCE_LEVELS = {
|
| 67 |
+
"dsd": {
|
| 68 |
+
"sheet_suffix_h": "DSD", "sheet_suffix_c": "DSD",
|
| 69 |
+
"method_h": "dsd_pbep86", "basis_h": "pcSseg3", "geometry_h": "pbe0_tz",
|
| 70 |
+
"method_c": "dsd_pbep86", "basis_c": "pcSseg3", "geometry_c": "pbe0_tz",
|
| 71 |
+
},
|
| 72 |
+
"wp04_wb97xd": {
|
| 73 |
+
"sheet_suffix_h": "WP04", "sheet_suffix_c": "wB97XD",
|
| 74 |
+
"method_h": "wp04", "basis_h": "pcSseg2", "geometry_h": "aimnet2",
|
| 75 |
+
"method_c": "wb97xd", "basis_c": "pcSseg2", "geometry_c": "aimnet2",
|
| 76 |
+
},
|
| 77 |
+
}
|
| 78 |
+
PCM_REFERENCE_METHOD = "b3lyp_d3bj"
|
| 79 |
+
PCM_REFERENCE_BASIS = "pcSseg3"
|
| 80 |
+
|
| 81 |
+
# vmin/vmax for the RMSE color scale, per nucleus (fixed, not data-dependent -- matches the shipped
|
| 82 |
+
# workbook so repeated runs render identically)
|
| 83 |
+
_VMIN_VMAX = {"H": (0.06, 0.28), "C": (1.2, 2.8)}
|
| 84 |
+
|
| 85 |
+
# per-nucleus labeled formula pairs the "story" sections of the sheet compare
|
| 86 |
+
PROTON_FORMULA_CONFIG = {
|
| 87 |
+
"pair1_a": "stationary + pcm + qcd",
|
| 88 |
+
"pair1_b": "stationary + desmond + qcd",
|
| 89 |
+
"pair2_title": "benefit of qcd over desmond_vib",
|
| 90 |
+
"pair2_a": "stationary + desmond_vib",
|
| 91 |
+
"pair2_b": "stationary + qcd",
|
| 92 |
+
"pair3_a": "stationary + desmond + desmond_vib",
|
| 93 |
+
"pair3_b": "stationary + desmond + qcd",
|
| 94 |
+
"best_multi": "stationary + desmond + qcd",
|
| 95 |
+
"best_semi": "stationary_plus_qcd + desmond",
|
| 96 |
+
"best_pars": "stationary_plus_desmond_plus_qcd",
|
| 97 |
+
}
|
| 98 |
+
CARBON_FORMULA_CONFIG = {
|
| 99 |
+
"pair1_a": "stationary + pcm + desmond_vib",
|
| 100 |
+
"pair1_b": "stationary + desmond + desmond_vib",
|
| 101 |
+
"pair2_title": "benefit of desmond_vib over qcd",
|
| 102 |
+
"pair2_a": "stationary + qcd",
|
| 103 |
+
"pair2_b": "stationary + desmond_vib",
|
| 104 |
+
"pair3_a": "stationary + desmond + qcd",
|
| 105 |
+
"pair3_b": "stationary + desmond + desmond_vib",
|
| 106 |
+
"best_multi": "stationary + desmond + desmond_vib",
|
| 107 |
+
"best_semi": "stationary_plus_des_vib + desmond",
|
| 108 |
+
"best_pars": "stationary_plus_desmond_plus_des_vib",
|
| 109 |
+
}
|
| 110 |
+
_FORMULA_CONFIG = {"H": PROTON_FORMULA_CONFIG, "C": CARBON_FORMULA_CONFIG}
|
| 111 |
+
|
| 112 |
+
# This ablation study fits plain OLS composite formulas, not an ML model. The canonical SI text
|
| 113 |
+
# for Figure S15 states that "since no ML was used in this particular analysis, all 22 molecules
|
| 114 |
+
# were considered, including nitromethane" -- other, ML-related analyses (e.g. scaling_factors.py's
|
| 115 |
+
# EXCLUDE_SOLUTES) drop nitromethane as an ML-training outlier; this module's default excludes nothing.
|
| 116 |
+
EXCLUDE_SOLUTES = ()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
# fitting: reuses delta22.py's harness entirely
|
| 121 |
+
|
| 122 |
+
def _filtered_query_df(query_df_dft, method, basis, geometry, exclude_solutes=EXCLUDE_SOLUTES):
|
| 123 |
+
"""One (method, basis, geometry) slice of the DFT query table, composite columns added,
|
| 124 |
+
any solutes in `exclude_solutes` dropped (default: none -- see EXCLUDE_SOLUTES above; this
|
| 125 |
+
analysis is documented in the SI as using all 22 delta-22 solutes, since it's a plain OLS fit,
|
| 126 |
+
not an ML model)."""
|
| 127 |
+
sub = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
|
| 128 |
+
& (query_df_dft["sap_basis"] == basis)
|
| 129 |
+
& (query_df_dft["sap_geometry_type"] == geometry)]
|
| 130 |
+
sub = sub[~sub["solute"].isin(set(exclude_solutes))]
|
| 131 |
+
return D.add_composite_columns(sub)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def ablation_rmse_table(query_df_dft, nucleus, method, basis, geometry, solutes,
|
| 135 |
+
formulas=FORMULAS, solvents=None, n_splits=250,
|
| 136 |
+
exclude_solutes=EXCLUDE_SOLUTES):
|
| 137 |
+
"""The formula x solvent test-RMSE table for one nucleus at one reference level: mean test RMSE
|
| 138 |
+
over `n_splits` seeded splits (delta22.generate_solute_splits/run_fits), pivoted to formula rows
|
| 139 |
+
x solvent columns, with an appended "Mean Test RMSE" column (the row mean across solvents).
|
| 140 |
+
Row/column order matches FORMULAS / ORDERED_SOLVENTS. This is the DataFrame write_nucleus_table
|
| 141 |
+
reads from."""
|
| 142 |
+
solvents = list(solvents) if solvents is not None else ORDERED_SOLVENTS
|
| 143 |
+
nuc_df = query_df_dft[query_df_dft["nucleus"] == nucleus]
|
| 144 |
+
sub = _filtered_query_df(nuc_df, method, basis, geometry, exclude_solutes)
|
| 145 |
+
results = D.run_fits(sub, solvents, formulas, n_splits, solutes)
|
| 146 |
+
mean_rmse = results.groupby(["solvent", "formula"])["test_RMSE"].mean().reset_index()
|
| 147 |
+
table = mean_rmse.pivot(index="formula", columns="solvent", values="test_RMSE")
|
| 148 |
+
table = table.reindex(index=formulas, columns=solvents)
|
| 149 |
+
table["Mean Test RMSE"] = table[solvents].mean(axis=1)
|
| 150 |
+
return table
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
# openpyxl table-writing layer
|
| 155 |
+
|
| 156 |
+
_TABLE_COLUMNS = ORDERED_SOLVENTS + ["Mean Test RMSE"]
|
| 157 |
+
_WHITE_TEXT_THRESHOLD_FRACTION = 0.50
|
| 158 |
+
_PERCENT_COLOR_LIMIT = 0.40
|
| 159 |
+
_PERCENT_WHITE_TEXT_MIN_ABS = 0.25
|
| 160 |
+
_PERCENT_WHITE_TEXT_MAX_ABS = 0.40
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _canonical_formula_name(name):
|
| 164 |
+
return "".join(ch for ch in name.lower() if ch.isalnum())
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _build_formula_lookup(df):
|
| 168 |
+
return {_canonical_formula_name(idx): idx for idx in df.index}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _get_rmse(df, lookup, formula_name, col):
|
| 172 |
+
key = _canonical_formula_name(formula_name)
|
| 173 |
+
if key not in lookup:
|
| 174 |
+
raise KeyError(f"Formula {formula_name!r} was not found in test RMSE dataframe index.")
|
| 175 |
+
return float(df.loc[lookup[key], col])
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _write_benefit_formula_row(ws, row, ref_row, target_row, start_col, n_cols):
|
| 179 |
+
# percent benefit = (reference - target) / reference, written as a live Excel formula so the
|
| 180 |
+
# spreadsheet stays self-consistent if a cell above it is ever hand-edited
|
| 181 |
+
for c in range(start_col, start_col + n_cols):
|
| 182 |
+
ref = f"{get_column_letter(c)}{ref_row}"
|
| 183 |
+
target = f"{get_column_letter(c)}{target_row}"
|
| 184 |
+
ws.cell(row=row, column=c, value=f"=IFERROR(({ref}-{target})/{ref},0)")
|
| 185 |
+
ws.cell(row=row, column=c).number_format = "0%"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _format_method_label(method_name, basis_name):
|
| 189 |
+
return f"{method_name.upper().replace('_', '-')}/{basis_name}"
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _write_color_scale_note(ws, vmin, vmax, threshold_fraction, pct_limit, pct_white_min, pct_white_max):
|
| 193 |
+
note_col = 16 # column P
|
| 194 |
+
threshold_value = vmin + threshold_fraction * (vmax - vmin)
|
| 195 |
+
|
| 196 |
+
ws.cell(row=1, column=note_col, value="Color Scale Note").font = Font(bold=True)
|
| 197 |
+
ws.cell(row=2, column=note_col, value=f"RMSE gradient uses fixed range [{vmin:.2f}, {vmax:.2f}] from notebook settings.")
|
| 198 |
+
ws.cell(row=3, column=note_col, value=f"White text for RMSE cells below {threshold_fraction:.0%} of range (<= {threshold_value:.3f}).")
|
| 199 |
+
ws.cell(row=4, column=note_col, value=f"%benefit uses fixed RdBu range [-{pct_limit:.0%}, +{pct_limit:.0%}], centered at 0%.")
|
| 200 |
+
ws.cell(row=5, column=note_col, value=f"White text for %benefit where abs(value) is at least {pct_white_min:.0%}.")
|
| 201 |
+
|
| 202 |
+
ws.cell(row=7, column=note_col, value="RMSE Legend").font = Font(bold=True)
|
| 203 |
+
ws.cell(row=8, column=note_col, value=f"Low ({vmin:.2f})")
|
| 204 |
+
ws.cell(row=9, column=note_col, value=f"Mid ({(vmin + vmax) / 2:.2f})")
|
| 205 |
+
ws.cell(row=10, column=note_col, value=f"High ({vmax:.2f})")
|
| 206 |
+
ws.cell(row=8, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="2D004B")
|
| 207 |
+
ws.cell(row=9, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="CC4778")
|
| 208 |
+
ws.cell(row=10, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="F0F921")
|
| 209 |
+
|
| 210 |
+
ws.cell(row=12, column=note_col, value="%Benefit Legend").font = Font(bold=True)
|
| 211 |
+
ws.cell(row=13, column=note_col, value=f"Negative (-{pct_limit:.0%})")
|
| 212 |
+
ws.cell(row=14, column=note_col, value="Zero")
|
| 213 |
+
ws.cell(row=15, column=note_col, value=f"Positive (+{pct_limit:.0%})")
|
| 214 |
+
ws.cell(row=13, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="B2182B")
|
| 215 |
+
ws.cell(row=14, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="F7F7F7")
|
| 216 |
+
ws.cell(row=15, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="2166AC")
|
| 217 |
+
|
| 218 |
+
ws.column_dimensions[get_column_letter(note_col)].width = 82
|
| 219 |
+
ws.column_dimensions[get_column_letter(note_col + 1)].width = 14
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _apply_rmse_conditional_format(ws, rmse_rows, start_col, end_col, vmin, vmax):
|
| 223 |
+
rule = ColorScaleRule(start_type="num", start_value=vmin, start_color="2D004B",
|
| 224 |
+
mid_type="num", mid_value=(vmin + vmax) / 2, mid_color="CC4778",
|
| 225 |
+
end_type="num", end_value=vmax, end_color="F0F921")
|
| 226 |
+
for r in rmse_rows:
|
| 227 |
+
ws.conditional_formatting.add(f"{get_column_letter(start_col)}{r}:{get_column_letter(end_col)}{r}", rule)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _apply_percent_conditional_format(ws, pct_rows, start_col, end_col, pct_limit):
|
| 231 |
+
rule = ColorScaleRule(start_type="num", start_value=-pct_limit, start_color="B2182B",
|
| 232 |
+
mid_type="num", mid_value=0, mid_color="F7F7F7",
|
| 233 |
+
end_type="num", end_value=pct_limit, end_color="2166AC")
|
| 234 |
+
for r in pct_rows:
|
| 235 |
+
ws.conditional_formatting.add(f"{get_column_letter(start_col)}{r}:{get_column_letter(end_col)}{r}", rule)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _apply_percent_font_contrast(ws, pct_rows, start_col, end_col, pct_white_min, pct_white_max):
|
| 239 |
+
min_s = f"{pct_white_min:.6f}"
|
| 240 |
+
for r in pct_rows:
|
| 241 |
+
for c in range(start_col, end_col + 1):
|
| 242 |
+
addr = f"{get_column_letter(c)}{r}"
|
| 243 |
+
rule = FormulaRule(formula=[f"ABS({addr})>={min_s}"], stopIfTrue=False, font=Font(color="FFFFFFFF"))
|
| 244 |
+
ws.conditional_formatting.add(addr, rule)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _apply_rmse_font_contrast(ws, rmse_rows, start_col, end_col, vmin, vmax, threshold_fraction):
|
| 248 |
+
threshold_value = vmin + threshold_fraction * (vmax - vmin)
|
| 249 |
+
for r in rmse_rows:
|
| 250 |
+
for c in range(start_col, end_col + 1):
|
| 251 |
+
cell = ws.cell(row=r, column=c)
|
| 252 |
+
if isinstance(cell.value, (int, float)):
|
| 253 |
+
cell.font = Font(color="FFFFFFFF" if float(cell.value) <= threshold_value else "FF000000")
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def write_nucleus_table(ws, start_row, nucleus_name, stationary_desc, pcm_desc, test_rmse_df,
|
| 257 |
+
vmin, vmax, formula_config,
|
| 258 |
+
threshold_fraction=_WHITE_TEXT_THRESHOLD_FRACTION,
|
| 259 |
+
pct_limit=_PERCENT_COLOR_LIMIT,
|
| 260 |
+
pct_white_min=_PERCENT_WHITE_TEXT_MIN_ABS,
|
| 261 |
+
pct_white_max=_PERCENT_WHITE_TEXT_MAX_ABS):
|
| 262 |
+
"""Write one nucleus's ablation table into `ws` starting at `start_row`; returns the next free
|
| 263 |
+
row. Layout: a header block, a "Multi-Term Models" section (reference stationary row; benefit of
|
| 264 |
+
desmond over pcm; three labeled formula-pair comparisons from formula_config), then a "Best
|
| 265 |
+
Model: Approach Comparison" section (SOTA/multi-term/semi-parsimonious/parsimonious, each vs the
|
| 266 |
+
others). Every RMSE row gets a fixed-range color scale; every %benefit row is a live Excel
|
| 267 |
+
formula with a diverging color scale. The layout matches the published workbook screenshots
|
| 268 |
+
section-for-section."""
|
| 269 |
+
lookup = _build_formula_lookup(test_rmse_df)
|
| 270 |
+
c0, c1 = 1, 2
|
| 271 |
+
c_end = c1 + len(_TABLE_COLUMNS) - 1
|
| 272 |
+
rmse_rows, pct_rows = [], []
|
| 273 |
+
|
| 274 |
+
def _rmse_row(row, label, formula):
|
| 275 |
+
ws.cell(row=row, column=c0, value=label)
|
| 276 |
+
rmse_rows.append(row)
|
| 277 |
+
for i, col_name in enumerate(_TABLE_COLUMNS, start=c1):
|
| 278 |
+
ws.cell(row=row, column=i, value=_get_rmse(test_rmse_df, lookup, formula, col_name))
|
| 279 |
+
ws.cell(row=row, column=i).number_format = "0.000"
|
| 280 |
+
|
| 281 |
+
def _benefit_row(row, ref_row, target_row):
|
| 282 |
+
ws.cell(row=row, column=c0, value="%benefit")
|
| 283 |
+
pct_rows.append(row)
|
| 284 |
+
_write_benefit_formula_row(ws, row, ref_row, target_row, c1, len(_TABLE_COLUMNS))
|
| 285 |
+
|
| 286 |
+
ws.cell(row=start_row, column=c0, value=nucleus_name).font = Font(bold=True)
|
| 287 |
+
ws.cell(row=start_row, column=c1, value=f"stationary = {stationary_desc}")
|
| 288 |
+
ws.cell(row=start_row + 1, column=c1, value=f"PCM = {pcm_desc}")
|
| 289 |
+
|
| 290 |
+
ws.cell(row=start_row + 3, column=c0, value="Multi-Term Models").font = Font(bold=True)
|
| 291 |
+
ws.cell(row=start_row + 4, column=c0, value="Test RMSEs (Solvent-Specific)").font = Font(bold=True)
|
| 292 |
+
for i, col_name in enumerate(_TABLE_COLUMNS, start=c1):
|
| 293 |
+
ws.cell(row=start_row + 4, column=i, value=col_name).font = Font(bold=True)
|
| 294 |
+
ws.cell(row=start_row + 5, column=c0, value="formula").font = Font(bold=True)
|
| 295 |
+
|
| 296 |
+
ws.cell(row=start_row + 7, column=c0, value="reference").font = Font(bold=True)
|
| 297 |
+
_rmse_row(start_row + 8, "stationary", "stationary")
|
| 298 |
+
|
| 299 |
+
ws.cell(row=start_row + 10, column=c0, value="benefit of desmond over pcm").font = Font(bold=True)
|
| 300 |
+
_rmse_row(start_row + 11, "stationary + pcm", "stationary + pcm")
|
| 301 |
+
_rmse_row(start_row + 12, "stationary + desmond", "stationary + desmond")
|
| 302 |
+
_benefit_row(start_row + 13, start_row + 11, start_row + 12)
|
| 303 |
+
|
| 304 |
+
_rmse_row(start_row + 15, formula_config["pair1_a"], formula_config["pair1_a"])
|
| 305 |
+
_rmse_row(start_row + 16, formula_config["pair1_b"], formula_config["pair1_b"])
|
| 306 |
+
_benefit_row(start_row + 17, start_row + 15, start_row + 16)
|
| 307 |
+
|
| 308 |
+
ws.cell(row=start_row + 19, column=c0, value=formula_config["pair2_title"]).font = Font(bold=True)
|
| 309 |
+
_rmse_row(start_row + 20, formula_config["pair2_a"], formula_config["pair2_a"])
|
| 310 |
+
_rmse_row(start_row + 21, formula_config["pair2_b"], formula_config["pair2_b"])
|
| 311 |
+
_benefit_row(start_row + 22, start_row + 20, start_row + 21)
|
| 312 |
+
|
| 313 |
+
_rmse_row(start_row + 24, formula_config["pair3_a"], formula_config["pair3_a"])
|
| 314 |
+
_rmse_row(start_row + 25, formula_config["pair3_b"], formula_config["pair3_b"])
|
| 315 |
+
_benefit_row(start_row + 26, start_row + 24, start_row + 25)
|
| 316 |
+
|
| 317 |
+
ws.cell(row=start_row + 29, column=c0, value="Best Model: Approach Comparison").font = Font(bold=True)
|
| 318 |
+
_rmse_row(start_row + 31, "SOTA: stationary_plus_pcm", "stationary_plus_pcm")
|
| 319 |
+
_rmse_row(start_row + 32, f"Multi-term: {formula_config['best_multi']}", formula_config["best_multi"])
|
| 320 |
+
_rmse_row(start_row + 33, f"Semi-parsimonious: {formula_config['best_semi']}", formula_config["best_semi"])
|
| 321 |
+
_rmse_row(start_row + 34, f"Parsimonious: {formula_config['best_pars']}", formula_config["best_pars"])
|
| 322 |
+
|
| 323 |
+
ws.cell(row=start_row + 36, column=c0, value="%benefit Multi-term vs. SOTA")
|
| 324 |
+
pct_rows.append(start_row + 36)
|
| 325 |
+
_write_benefit_formula_row(ws, start_row + 36, start_row + 31, start_row + 32, c1, len(_TABLE_COLUMNS))
|
| 326 |
+
ws.cell(row=start_row + 37, column=c0, value="%benefit Semi-parsimonious vs. SOTA")
|
| 327 |
+
pct_rows.append(start_row + 37)
|
| 328 |
+
_write_benefit_formula_row(ws, start_row + 37, start_row + 31, start_row + 33, c1, len(_TABLE_COLUMNS))
|
| 329 |
+
ws.cell(row=start_row + 38, column=c0, value="%benefit Parsimonious vs. SOTA")
|
| 330 |
+
pct_rows.append(start_row + 38)
|
| 331 |
+
_write_benefit_formula_row(ws, start_row + 38, start_row + 31, start_row + 34, c1, len(_TABLE_COLUMNS))
|
| 332 |
+
ws.cell(row=start_row + 40, column=c0, value="%benefit Semi-parsimonious vs. Multi-term")
|
| 333 |
+
pct_rows.append(start_row + 40)
|
| 334 |
+
_write_benefit_formula_row(ws, start_row + 40, start_row + 32, start_row + 33, c1, len(_TABLE_COLUMNS))
|
| 335 |
+
ws.cell(row=start_row + 41, column=c0, value="%benefit Parsimonious vs. Multi-term")
|
| 336 |
+
pct_rows.append(start_row + 41)
|
| 337 |
+
_write_benefit_formula_row(ws, start_row + 41, start_row + 32, start_row + 34, c1, len(_TABLE_COLUMNS))
|
| 338 |
+
|
| 339 |
+
ws.column_dimensions["A"].width = 52
|
| 340 |
+
for col_idx in range(2, 2 + len(_TABLE_COLUMNS)):
|
| 341 |
+
ws.column_dimensions[get_column_letter(col_idx)].width = 14
|
| 342 |
+
|
| 343 |
+
_apply_rmse_conditional_format(ws, rmse_rows, c1, c_end, vmin, vmax)
|
| 344 |
+
_apply_percent_conditional_format(ws, pct_rows, c1, c_end, pct_limit)
|
| 345 |
+
_apply_rmse_font_contrast(ws, rmse_rows, c1, c_end, vmin, vmax, threshold_fraction)
|
| 346 |
+
_apply_percent_font_contrast(ws, pct_rows, c1, c_end, pct_white_min, pct_white_max)
|
| 347 |
+
_write_color_scale_note(ws, vmin, vmax, threshold_fraction, pct_limit, pct_white_min, pct_white_max)
|
| 348 |
+
|
| 349 |
+
return start_row + 45
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ---------------------------------------------------------------------------
|
| 353 |
+
# top-level pipeline
|
| 354 |
+
|
| 355 |
+
def build_ablations_workbook(query_df_dft, solutes, output_path,
|
| 356 |
+
reference_levels=("dsd", "wp04_wb97xd"), n_splits=250,
|
| 357 |
+
exclude_solutes=EXCLUDE_SOLUTES, verbose=True):
|
| 358 |
+
"""Computes the ablation RMSE tables for both nuclei at each requested reference level and
|
| 359 |
+
writes them into one workbook, one sheet per (nucleus, reference level) -- 4 sheets for the
|
| 360 |
+
default two reference levels, matching the shipped ablations_updated.xlsx: "Proton (DSD)",
|
| 361 |
+
"Carbon (DSD)", "Proton (WP04)", "Carbon (wB97XD)". Pass a single-element reference_levels to
|
| 362 |
+
produce the plain "Proton"/"Carbon" two-sheet form for one reference level. query_df_dft is
|
| 363 |
+
delta22.load_query_df_dft(...)'s output (unfiltered by method/basis); this function does the
|
| 364 |
+
per-reference-level filtering itself."""
|
| 365 |
+
wb = Workbook()
|
| 366 |
+
first = True
|
| 367 |
+
for level_key in reference_levels:
|
| 368 |
+
level = REFERENCE_LEVELS[level_key]
|
| 369 |
+
for nucleus, method_key, basis_key, geom_key, suffix_key in (
|
| 370 |
+
("H", "method_h", "basis_h", "geometry_h", "sheet_suffix_h"),
|
| 371 |
+
("C", "method_c", "basis_c", "geometry_c", "sheet_suffix_c"),
|
| 372 |
+
):
|
| 373 |
+
method, basis, geometry = level[method_key], level[basis_key], level[geom_key]
|
| 374 |
+
if verbose:
|
| 375 |
+
print(f"fitting {nucleus} at {method}/{basis} ({level_key}), {n_splits} splits/solvent...",
|
| 376 |
+
flush=True)
|
| 377 |
+
table = ablation_rmse_table(query_df_dft, nucleus, method, basis, geometry, solutes,
|
| 378 |
+
n_splits=n_splits, exclude_solutes=exclude_solutes)
|
| 379 |
+
nucleus_name = "Proton" if nucleus == "H" else "Carbon"
|
| 380 |
+
sheet_title = (f"{nucleus_name} ({level[suffix_key]})" if len(reference_levels) > 1
|
| 381 |
+
else nucleus_name)
|
| 382 |
+
ws = wb.active if first else wb.create_sheet()
|
| 383 |
+
ws.title = sheet_title
|
| 384 |
+
first = False
|
| 385 |
+
write_nucleus_table(
|
| 386 |
+
ws=ws, start_row=1, nucleus_name=nucleus_name,
|
| 387 |
+
stationary_desc=_format_method_label(method, basis),
|
| 388 |
+
pcm_desc=_format_method_label(PCM_REFERENCE_METHOD, PCM_REFERENCE_BASIS),
|
| 389 |
+
test_rmse_df=table, vmin=_VMIN_VMAX[nucleus][0], vmax=_VMIN_VMAX[nucleus][1],
|
| 390 |
+
formula_config=_FORMULA_CONFIG[nucleus],
|
| 391 |
+
)
|
| 392 |
+
wb.save(output_path)
|
| 393 |
+
if verbose:
|
| 394 |
+
print(f"wrote {output_path}", flush=True)
|
| 395 |
+
return output_path
|
analysis/code/composite_plots.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Correlation-heatmap panels for the composite-model ablation SI figure (Figure S15).
|
| 2 |
+
|
| 3 |
+
Two seaborn heatmaps behind Figure S15's feature-correlation panels: a per-nucleus lower-triangle
|
| 4 |
+
correlation matrix among the five composite-model features (stationary shielding, PCM, Desmond, its
|
| 5 |
+
vibrational analogue, QCD), and a PCM-vs-Desmond Pearson r table by nucleus and solvent. The notebook
|
| 6 |
+
supplies the correlation table(s) computed by delta22.py and calls these functions directly.
|
| 7 |
+
"""
|
| 8 |
+
import numpy as np
|
| 9 |
+
import seaborn as sns
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def plot_feature_correlation_heatmap(corr_matrix, vmin, vmax, cmap, title="", figsize=(6, 5), save_path=None):
|
| 14 |
+
"""Lower-triangle seaborn heatmap of a correlation matrix (Pearson r)."""
|
| 15 |
+
# In a strict lower triangle the first row and last column hold no cells, so drop them to avoid
|
| 16 |
+
# a blank leading/trailing strip; the diagonal of the trimmed matrix is a real below-diagonal value.
|
| 17 |
+
trimmed = corr_matrix.iloc[1:, :-1]
|
| 18 |
+
mask = np.triu(np.ones(trimmed.shape, dtype=bool), k=1) # keep the diagonal of the trimmed matrix
|
| 19 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 20 |
+
sns.heatmap(trimmed, mask=mask, annot=True, fmt=".3f", cmap=cmap, vmin=vmin, vmax=vmax,
|
| 21 |
+
square=True, cbar=False, ax=ax)
|
| 22 |
+
ax.set_title(title, fontsize=11)
|
| 23 |
+
fig.tight_layout()
|
| 24 |
+
if save_path:
|
| 25 |
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
| 26 |
+
return fig
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def plot_pcm_desmond_correlation_table(corr_table, figsize=(13, 4.5), save_path=None):
|
| 30 |
+
"""Annotated seaborn heatmap of PCM-vs-Desmond Pearson r: rows = nucleus, columns = solvent."""
|
| 31 |
+
# tall figsize keeps the two rows from being squashed by the rotated solvent labels
|
| 32 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 33 |
+
sns.heatmap(corr_table, annot=True, fmt=".3f", cmap="RdBu", vmin=-1, vmax=1, square=True,
|
| 34 |
+
linewidths=0.5, cbar=False, ax=ax)
|
| 35 |
+
ax.set_title("PCM vs. Desmond Pearson $r$ by Solvent")
|
| 36 |
+
_sup = {"H": "$^{1}$H", "C": "$^{13}$C"}
|
| 37 |
+
ax.set_yticklabels([_sup.get(t.get_text(), t.get_text()) for t in ax.get_yticklabels()], rotation=0)
|
| 38 |
+
fig.tight_layout()
|
| 39 |
+
if save_path:
|
| 40 |
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
| 41 |
+
return fig
|
analysis/code/convergence_plot.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""3D accuracy-vs-slope-vs-year convergence panel for Figure 2D.
|
| 2 |
+
|
| 3 |
+
The notebook supplies the per-method statistics table, the method->rung map, and the rung->color map as
|
| 4 |
+
arguments (`results_df`, `methods_categorization`, `method_color_map`) and calls
|
| 5 |
+
`plot_statistics_3d_emphasize_rungs(...)`.
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
import seaborn as sns
|
| 12 |
+
from matplotlib.ticker import MaxNLocator
|
| 13 |
+
from matplotlib import colors as mcolors
|
| 14 |
+
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (registers the 3d projection)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def plot_statistics_3d_emphasize_rungs(
|
| 18 |
+
x_param="Slope",
|
| 19 |
+
y_param="RMSE (scaled)",
|
| 20 |
+
z_param=None,
|
| 21 |
+
results_df=None,
|
| 22 |
+
methods_categorization=None,
|
| 23 |
+
method_color_map=None,
|
| 24 |
+
figsize=(9, 7),
|
| 25 |
+
elev=12,
|
| 26 |
+
azim=20,
|
| 27 |
+
|
| 28 |
+
# =========================
|
| 29 |
+
# SAVING
|
| 30 |
+
# =========================
|
| 31 |
+
save_path=None,
|
| 32 |
+
save_formats=("png",),
|
| 33 |
+
dpi=300,
|
| 34 |
+
svg_transparent=True,
|
| 35 |
+
png_transparent=False,
|
| 36 |
+
|
| 37 |
+
# =========================
|
| 38 |
+
# DROPLINES (ALL POINTS)
|
| 39 |
+
# =========================
|
| 40 |
+
droplines_for_all=True,
|
| 41 |
+
dropline_alpha=0.1,
|
| 42 |
+
dropline_lw=0.8,
|
| 43 |
+
dropline_color="0.4",
|
| 44 |
+
dropline_shadow_alpha=0.25,
|
| 45 |
+
dropline_shadow_size=18,
|
| 46 |
+
|
| 47 |
+
xlim=None,
|
| 48 |
+
ylim=None,
|
| 49 |
+
zlim=None,
|
| 50 |
+
|
| 51 |
+
# =========================
|
| 52 |
+
# YEAR CONTROL
|
| 53 |
+
# =========================
|
| 54 |
+
year_map=None,
|
| 55 |
+
year_default=np.nan,
|
| 56 |
+
year_tick_step=2,
|
| 57 |
+
drop_missing_years=False,
|
| 58 |
+
|
| 59 |
+
# =========================
|
| 60 |
+
# Z LANES
|
| 61 |
+
# =========================
|
| 62 |
+
z_lane_by_category=True,
|
| 63 |
+
z_lane_width=0.35,
|
| 64 |
+
z_jitter_by_category=0.0,
|
| 65 |
+
|
| 66 |
+
# =========================
|
| 67 |
+
# XY LANES
|
| 68 |
+
# =========================
|
| 69 |
+
xy_lane_by_category=True,
|
| 70 |
+
xy_lane_strength=0.25,
|
| 71 |
+
xy_lane_style="circle",
|
| 72 |
+
lane_exclude_categories=("ab initio",),
|
| 73 |
+
|
| 74 |
+
auto_expand_xy_limits=True,
|
| 75 |
+
xy_limit_pad_frac=0.06,
|
| 76 |
+
|
| 77 |
+
# =========================
|
| 78 |
+
# BROKEN Z
|
| 79 |
+
# =========================
|
| 80 |
+
z_break=None,
|
| 81 |
+
z_break_gap=2.0,
|
| 82 |
+
drop_years_in_break=False,
|
| 83 |
+
|
| 84 |
+
# =========================
|
| 85 |
+
# DECADE GROUPING
|
| 86 |
+
# =========================
|
| 87 |
+
group_by_decade=True,
|
| 88 |
+
decade_bin=10,
|
| 89 |
+
decade_alpha=0.85,
|
| 90 |
+
decade_marker_size=80,
|
| 91 |
+
decade_sort_ascending=True,
|
| 92 |
+
|
| 93 |
+
# =========================
|
| 94 |
+
# XY GRID PLANE
|
| 95 |
+
# =========================
|
| 96 |
+
show_xy_grid_plane=True,
|
| 97 |
+
xy_grid_z=0.0,
|
| 98 |
+
grid_alpha=0.15,
|
| 99 |
+
grid_lw=0.4,
|
| 100 |
+
show_plot=True,
|
| 101 |
+
auto_xy_grid_z_if_years=True,
|
| 102 |
+
|
| 103 |
+
# =========================
|
| 104 |
+
# BASELINE SHADOW COLOR
|
| 105 |
+
# =========================
|
| 106 |
+
baseline_tint_strength=0.35,
|
| 107 |
+
baseline_gray="#7A7A7A",
|
| 108 |
+
|
| 109 |
+
# =========================
|
| 110 |
+
# MAJOR TICKS (CUSTOMIZABLE)
|
| 111 |
+
# =========================
|
| 112 |
+
x_major_nbins=5,
|
| 113 |
+
y_major_nbins=5,
|
| 114 |
+
major_tick_length=0.1,
|
| 115 |
+
major_tick_width=0.1,
|
| 116 |
+
major_tick_labelsize=10,
|
| 117 |
+
major_tick_pad=2,
|
| 118 |
+
major_tick_color="0.2",
|
| 119 |
+
major_tick_labelcolor="0.1",
|
| 120 |
+
major_tick_rotation_x=0,
|
| 121 |
+
major_tick_rotation_y=0,
|
| 122 |
+
major_tick_rotation_z=0,
|
| 123 |
+
|
| 124 |
+
# =========================
|
| 125 |
+
# "SPINES"/BORDERS FOR 3D AXES (CUSTOMIZABLE)
|
| 126 |
+
# =========================
|
| 127 |
+
axisline_lw=0.3,
|
| 128 |
+
axisline_color="0.05",
|
| 129 |
+
|
| 130 |
+
pane_fill=False,
|
| 131 |
+
pane_alpha=0.0,
|
| 132 |
+
pane_color="white",
|
| 133 |
+
pane_edgecolor="0.15",
|
| 134 |
+
pane_edge_lw=0.3,
|
| 135 |
+
|
| 136 |
+
# alias (so passing axis_linewidth won't crash)
|
| 137 |
+
axis_linewidth=None,
|
| 138 |
+
|
| 139 |
+
# =========================
|
| 140 |
+
# HIGHLIGHT + LABEL METHODS
|
| 141 |
+
# =========================
|
| 142 |
+
highlight_methods=None, # list/tuple of method names to highlight
|
| 143 |
+
highlight_marker_size=140, # size for highlighted markers
|
| 144 |
+
highlight_edgecolor="k", # outline color
|
| 145 |
+
highlight_edgewidth=1.2, # outline thickness
|
| 146 |
+
highlight_alpha=1.0, # alpha for highlighted markers
|
| 147 |
+
highlight_text=True, # whether to draw labels
|
| 148 |
+
highlight_text_size=8, # label fontsize
|
| 149 |
+
highlight_text_color="k", # label color
|
| 150 |
+
highlight_text_dx_frac=0.008, # x-offset for text (fraction of x-range)
|
| 151 |
+
highlight_text_dy_frac=0.008, # y-offset for text (fraction of y-range)
|
| 152 |
+
highlight_text_dz=0.0, # z-offset in z-units (mapped units)
|
| 153 |
+
highlight_text_bbox=True, # draw a small white box behind text
|
| 154 |
+
highlight_text_bbox_alpha=0.65, # bbox alpha
|
| 155 |
+
|
| 156 |
+
# legend
|
| 157 |
+
show_legend=False,
|
| 158 |
+
legend_title="Rung Type",
|
| 159 |
+
legend_fontsize=8,
|
| 160 |
+
legend_title_fontsize=9,
|
| 161 |
+
legend_loc="center left",
|
| 162 |
+
legend_bbox_to_anchor=(1.02, 0.5),
|
| 163 |
+
):
|
| 164 |
+
"""Draw the Figure 2D 3D scatter of RMSE vs. slope vs. year, grouped and colored by rung."""
|
| 165 |
+
sns.set_theme(context="paper", style="ticks", font_scale=1.0)
|
| 166 |
+
|
| 167 |
+
def _coerce_pair(val, name, allow_none=True):
|
| 168 |
+
if val is None:
|
| 169 |
+
if allow_none:
|
| 170 |
+
return None
|
| 171 |
+
raise ValueError(f"{name} cannot be None")
|
| 172 |
+
if np.isscalar(val):
|
| 173 |
+
raise ValueError(
|
| 174 |
+
f"{name} must be a 2-tuple like ({name}_min, {name}_max), not a scalar: {val}"
|
| 175 |
+
)
|
| 176 |
+
if len(val) != 2:
|
| 177 |
+
raise ValueError(f"{name} must be length-2 like (min, max). Got: {val}")
|
| 178 |
+
return (float(val[0]), float(val[1]))
|
| 179 |
+
|
| 180 |
+
# apply alias if user passed axis_linewidth
|
| 181 |
+
if axis_linewidth is not None:
|
| 182 |
+
axisline_lw = float(axis_linewidth)
|
| 183 |
+
|
| 184 |
+
# coerce user limits early
|
| 185 |
+
xlim2 = _coerce_pair(xlim, "xlim", allow_none=True)
|
| 186 |
+
ylim2 = _coerce_pair(ylim, "ylim", allow_none=True)
|
| 187 |
+
zlim2 = _coerce_pair(zlim, "zlim", allow_none=True)
|
| 188 |
+
|
| 189 |
+
def _draw_xy_grid_plane(ax, xlim, ylim, z=0.0, alpha=0.25, lw=0.8):
|
| 190 |
+
x_ticks = ax.get_xticks()
|
| 191 |
+
y_ticks = ax.get_yticks()
|
| 192 |
+
for x in x_ticks:
|
| 193 |
+
if xlim[0] <= x <= xlim[1]:
|
| 194 |
+
ax.plot([x, x], [ylim[0], ylim[1]], [z, z],
|
| 195 |
+
color="0.0", alpha=alpha, linewidth=lw, zorder=1)
|
| 196 |
+
for y in y_ticks:
|
| 197 |
+
if ylim[0] <= y <= ylim[1]:
|
| 198 |
+
ax.plot([xlim[0], xlim[1]], [y, y], [z, z],
|
| 199 |
+
color="0.0", alpha=alpha, linewidth=lw, zorder=1)
|
| 200 |
+
|
| 201 |
+
def _blend_hex(c1, c2, t):
|
| 202 |
+
c1 = np.array(mcolors.to_rgb(c1))
|
| 203 |
+
c2 = np.array(mcolors.to_rgb(c2))
|
| 204 |
+
return (1 - t) * c1 + t * c2
|
| 205 |
+
|
| 206 |
+
def _make_z_break_mapper(z_break, gap, drop_in_break):
|
| 207 |
+
z_break2 = _coerce_pair(z_break, "z_break", allow_none=True)
|
| 208 |
+
if z_break2 is None:
|
| 209 |
+
return (lambda y: float(y)), (lambda z: float(z))
|
| 210 |
+
|
| 211 |
+
lo, hi = z_break2
|
| 212 |
+
if not (hi > lo):
|
| 213 |
+
raise ValueError(f"z_break must be (lo, hi) with hi>lo; got {z_break2}")
|
| 214 |
+
span = hi - lo
|
| 215 |
+
gap = float(gap)
|
| 216 |
+
|
| 217 |
+
def fwd(y):
|
| 218 |
+
y = float(y)
|
| 219 |
+
if lo < y < hi:
|
| 220 |
+
if drop_in_break:
|
| 221 |
+
return None
|
| 222 |
+
y = lo if (y - lo) <= (hi - y) else hi
|
| 223 |
+
if y <= lo:
|
| 224 |
+
return y
|
| 225 |
+
return y - span + gap
|
| 226 |
+
|
| 227 |
+
def inv(z):
|
| 228 |
+
z = float(z)
|
| 229 |
+
if z <= lo:
|
| 230 |
+
return z
|
| 231 |
+
return z + span - gap
|
| 232 |
+
|
| 233 |
+
return fwd, inv
|
| 234 |
+
|
| 235 |
+
def _norm_cat(s: str) -> str:
|
| 236 |
+
s = str(s).lower()
|
| 237 |
+
return "".join(ch for ch in s if ch.isalnum())
|
| 238 |
+
|
| 239 |
+
exclude_norm = {_norm_cat(c) for c in (lane_exclude_categories or ())}
|
| 240 |
+
|
| 241 |
+
z_map, _ = _make_z_break_mapper(z_break, z_break_gap, drop_years_in_break)
|
| 242 |
+
|
| 243 |
+
# --- Prepare dataframe ---
|
| 244 |
+
df = results_df.copy()
|
| 245 |
+
df["category"] = df["Method"].map(methods_categorization).fillna("Unknown")
|
| 246 |
+
df["_cat_norm"] = df["category"].apply(_norm_cat)
|
| 247 |
+
|
| 248 |
+
rung_order = [c for c in method_color_map.keys() if c in df["category"].unique()]
|
| 249 |
+
rung_order += [c for c in df["category"].unique() if c not in rung_order]
|
| 250 |
+
|
| 251 |
+
# --- Z assignment ---
|
| 252 |
+
if z_param is not None:
|
| 253 |
+
df["_z_raw"] = df[z_param].astype(float)
|
| 254 |
+
df["_year"] = df["_z_raw"].copy()
|
| 255 |
+
else:
|
| 256 |
+
if year_map is None:
|
| 257 |
+
raise ValueError("z_param is None, so you must provide year_map={Method: year}.")
|
| 258 |
+
df["_z_raw"] = df["Method"].map(year_map).fillna(year_default)
|
| 259 |
+
|
| 260 |
+
missing_methods = df[df["_z_raw"].isna()]["Method"].unique()
|
| 261 |
+
if len(missing_methods) > 0:
|
| 262 |
+
if drop_missing_years:
|
| 263 |
+
df = df.dropna(subset=["_z_raw"]).copy()
|
| 264 |
+
else:
|
| 265 |
+
examples = missing_methods[:12].tolist()
|
| 266 |
+
raise ValueError(
|
| 267 |
+
f"Missing years for {len(missing_methods)} methods. "
|
| 268 |
+
f"Examples: {examples}. "
|
| 269 |
+
f"Either add them to year_map or set drop_missing_years=True."
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
df["_z_raw"] = df["_z_raw"].astype(float)
|
| 273 |
+
df["_year"] = df["_z_raw"].copy()
|
| 274 |
+
|
| 275 |
+
if z_lane_by_category:
|
| 276 |
+
cat_index = {cat: i for i, cat in enumerate(rung_order)}
|
| 277 |
+
offsets = df["category"].map(lambda c: cat_index.get(c, 0)).astype(float)
|
| 278 |
+
offsets = offsets - offsets.mean()
|
| 279 |
+
is_excluded = df["_cat_norm"].isin(exclude_norm)
|
| 280 |
+
offsets = offsets.where(~is_excluded, 0.0)
|
| 281 |
+
df["_z_raw"] = df["_z_raw"] + offsets * float(z_lane_width)
|
| 282 |
+
|
| 283 |
+
if z_jitter_by_category and z_jitter_by_category != 0.0:
|
| 284 |
+
rng = np.random.default_rng(0)
|
| 285 |
+
df["_z_raw"] = df["_z_raw"] + rng.uniform(-1, 1, size=len(df)) * float(z_jitter_by_category)
|
| 286 |
+
|
| 287 |
+
# --- apply z break mapping ---
|
| 288 |
+
if z_break is not None:
|
| 289 |
+
z_mapped = df["_z_raw"].apply(z_map)
|
| 290 |
+
if drop_years_in_break:
|
| 291 |
+
df = df.loc[~z_mapped.isna()].copy()
|
| 292 |
+
z_mapped = df["_z_raw"].apply(z_map)
|
| 293 |
+
df["_z"] = z_mapped.astype(float)
|
| 294 |
+
else:
|
| 295 |
+
df["_z"] = df["_z_raw"].astype(float)
|
| 296 |
+
|
| 297 |
+
# --- Base limits from ORIGINAL (unshifted) data ---
|
| 298 |
+
x_min, x_max = df[x_param].min(), df[x_param].max()
|
| 299 |
+
y_min, y_max = df[y_param].min(), df[y_param].max()
|
| 300 |
+
x_pad = 0.15 * (x_max - x_min) if x_max > x_min else 0.5
|
| 301 |
+
y_pad = 0.15 * (y_max - y_min) if y_max > y_min else 0.5
|
| 302 |
+
xm_default = (x_min - x_pad, x_max + x_pad)
|
| 303 |
+
ym_default = (y_min - y_pad, y_max + y_pad)
|
| 304 |
+
|
| 305 |
+
# Use provided limits (or defaults) to define lane sizes
|
| 306 |
+
x0l, x1l = (xm_default if xlim2 is None else xlim2)
|
| 307 |
+
y0l, y1l = (ym_default if ylim2 is None else ylim2)
|
| 308 |
+
xr = (x1l - x0l) if (x1l - x0l) != 0 else 1.0
|
| 309 |
+
yr = (y1l - y0l) if (y1l - y0l) != 0 else 1.0
|
| 310 |
+
|
| 311 |
+
# --- XY category lanes ---
|
| 312 |
+
if xy_lane_by_category:
|
| 313 |
+
n = max(1, len(rung_order))
|
| 314 |
+
cat_index = {cat: i for i, cat in enumerate(rung_order)}
|
| 315 |
+
|
| 316 |
+
if xy_lane_style.lower() == "grid":
|
| 317 |
+
cols = int(np.ceil(np.sqrt(n)))
|
| 318 |
+
rows = int(np.ceil(n / cols))
|
| 319 |
+
|
| 320 |
+
def grid_xy(i):
|
| 321 |
+
r = i // cols
|
| 322 |
+
c = i % cols
|
| 323 |
+
cx = c - (cols - 1) / 2
|
| 324 |
+
cy = r - (rows - 1) / 2
|
| 325 |
+
return cx, cy
|
| 326 |
+
|
| 327 |
+
coords = {cat: grid_xy(cat_index[cat]) for cat in rung_order}
|
| 328 |
+
else:
|
| 329 |
+
angles = np.linspace(0, 2*np.pi, n, endpoint=False)
|
| 330 |
+
coords = {cat: (np.cos(angles[cat_index[cat]]), np.sin(angles[cat_index[cat]])) for cat in rung_order}
|
| 331 |
+
|
| 332 |
+
dx = float(xy_lane_strength) * xr
|
| 333 |
+
dy = float(xy_lane_strength) * yr
|
| 334 |
+
is_excluded = df["_cat_norm"].isin(exclude_norm)
|
| 335 |
+
|
| 336 |
+
def _dx(c): return coords.get(c, (0.0, 0.0))[0] * dx
|
| 337 |
+
def _dy(c): return coords.get(c, (0.0, 0.0))[1] * dy
|
| 338 |
+
|
| 339 |
+
df["_x_plot"] = df[x_param].astype(float) + np.where(is_excluded, 0.0, df["category"].map(_dx).astype(float))
|
| 340 |
+
df["_y_plot"] = df[y_param].astype(float) + np.where(is_excluded, 0.0, df["category"].map(_dy).astype(float))
|
| 341 |
+
else:
|
| 342 |
+
df["_x_plot"] = df[x_param].astype(float)
|
| 343 |
+
df["_y_plot"] = df[y_param].astype(float)
|
| 344 |
+
|
| 345 |
+
# Expand axis limits to include shifted points
|
| 346 |
+
if auto_expand_xy_limits:
|
| 347 |
+
xmn, xmx = float(np.nanmin(df["_x_plot"])), float(np.nanmax(df["_x_plot"]))
|
| 348 |
+
ymn, ymx = float(np.nanmin(df["_y_plot"])), float(np.nanmax(df["_y_plot"]))
|
| 349 |
+
xr2 = (xmx - xmn) if (xmx - xmn) != 0 else 1.0
|
| 350 |
+
yr2 = (ymx - ymn) if (ymx - ymn) != 0 else 1.0
|
| 351 |
+
xpad2 = xy_limit_pad_frac * xr2
|
| 352 |
+
ypad2 = xy_limit_pad_frac * yr2
|
| 353 |
+
xm_shifted = (xmn - xpad2, xmx + xpad2)
|
| 354 |
+
ym_shifted = (ymn - ypad2, ymx + ypad2)
|
| 355 |
+
else:
|
| 356 |
+
xm_shifted = xm_default
|
| 357 |
+
ym_shifted = ym_default
|
| 358 |
+
|
| 359 |
+
# baseline plane fix
|
| 360 |
+
if zlim2 is not None:
|
| 361 |
+
z_base = z_map(zlim2[0]) if (z_break is not None) else float(zlim2[0])
|
| 362 |
+
else:
|
| 363 |
+
z_base = float(np.nanmin(df["_z"]))
|
| 364 |
+
Z_BASELINE = z_base
|
| 365 |
+
if auto_xy_grid_z_if_years and (z_param is None):
|
| 366 |
+
xy_grid_z = Z_BASELINE
|
| 367 |
+
|
| 368 |
+
# decade grouping
|
| 369 |
+
if group_by_decade and (z_param is None):
|
| 370 |
+
df["_decade"] = (np.floor(df["_year"] / decade_bin) * decade_bin).astype(int)
|
| 371 |
+
decade_order = sorted(df["_decade"].unique(), reverse=not decade_sort_ascending)
|
| 372 |
+
else:
|
| 373 |
+
df["_decade"] = np.nan
|
| 374 |
+
decade_order = []
|
| 375 |
+
|
| 376 |
+
# --- Figure ---
|
| 377 |
+
fig = plt.figure(figsize=figsize, constrained_layout=True)
|
| 378 |
+
ax = fig.add_subplot(111, projection="3d")
|
| 379 |
+
ax.view_init(elev=elev, azim=azim)
|
| 380 |
+
ax.set_box_aspect((1.5, 2, 1.5))
|
| 381 |
+
|
| 382 |
+
# axis lines
|
| 383 |
+
for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
|
| 384 |
+
try:
|
| 385 |
+
axis.line.set_linewidth(axisline_lw)
|
| 386 |
+
axis.line.set_color(axisline_color)
|
| 387 |
+
except Exception:
|
| 388 |
+
pass
|
| 389 |
+
|
| 390 |
+
# panes
|
| 391 |
+
for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
|
| 392 |
+
try:
|
| 393 |
+
axis.pane.set_edgecolor(pane_edgecolor)
|
| 394 |
+
axis.pane.set_linewidth(pane_edge_lw)
|
| 395 |
+
if pane_fill:
|
| 396 |
+
axis.pane.set_facecolor(mcolors.to_rgba(pane_color, pane_alpha))
|
| 397 |
+
else:
|
| 398 |
+
axis.pane.set_facecolor((1, 1, 1, 0))
|
| 399 |
+
except Exception:
|
| 400 |
+
pass
|
| 401 |
+
|
| 402 |
+
# --- SCATTER ---
|
| 403 |
+
if group_by_decade and (z_param is None):
|
| 404 |
+
for d in decade_order:
|
| 405 |
+
sub_d = df[df["_decade"] == d]
|
| 406 |
+
if sub_d.empty:
|
| 407 |
+
continue
|
| 408 |
+
for cat in rung_order:
|
| 409 |
+
sub = sub_d[sub_d["category"] == cat]
|
| 410 |
+
if sub.empty:
|
| 411 |
+
continue
|
| 412 |
+
ax.scatter(
|
| 413 |
+
sub["_x_plot"], sub["_y_plot"], sub["_z"],
|
| 414 |
+
s=decade_marker_size,
|
| 415 |
+
depthshade=False,
|
| 416 |
+
color=method_color_map.get(cat, "#777777"),
|
| 417 |
+
edgecolor="none",
|
| 418 |
+
alpha=decade_alpha,
|
| 419 |
+
zorder=5,
|
| 420 |
+
)
|
| 421 |
+
else:
|
| 422 |
+
for cat in rung_order:
|
| 423 |
+
sub = df[df["category"] == cat]
|
| 424 |
+
if sub.empty:
|
| 425 |
+
continue
|
| 426 |
+
ax.scatter(
|
| 427 |
+
sub["_x_plot"], sub["_y_plot"], sub["_z"],
|
| 428 |
+
s=80,
|
| 429 |
+
depthshade=False,
|
| 430 |
+
color=method_color_map.get(cat, "#777777"),
|
| 431 |
+
edgecolor="none",
|
| 432 |
+
alpha=0.85,
|
| 433 |
+
zorder=5,
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
# --- DROPLINES FOR ALL POINTS ---
|
| 437 |
+
if droplines_for_all:
|
| 438 |
+
cat_cols = df["category"].map(lambda c: method_color_map.get(c, "#777777"))
|
| 439 |
+
shadow_cols = [_blend_hex(baseline_gray, c, baseline_tint_strength) for c in cat_cols]
|
| 440 |
+
for (x0, y0, z0, shcol) in zip(df["_x_plot"].to_numpy(float),
|
| 441 |
+
df["_y_plot"].to_numpy(float),
|
| 442 |
+
df["_z"].to_numpy(float),
|
| 443 |
+
shadow_cols):
|
| 444 |
+
ax.plot([x0, x0], [y0, y0], [z0, Z_BASELINE],
|
| 445 |
+
color=dropline_color, alpha=dropline_alpha,
|
| 446 |
+
linewidth=dropline_lw, zorder=2)
|
| 447 |
+
ax.scatter([x0], [y0], [Z_BASELINE],
|
| 448 |
+
s=dropline_shadow_size,
|
| 449 |
+
color=shcol,
|
| 450 |
+
alpha=dropline_shadow_alpha,
|
| 451 |
+
depthshade=False,
|
| 452 |
+
zorder=2)
|
| 453 |
+
|
| 454 |
+
# --- limits ---
|
| 455 |
+
ax.set_xlim(xm_shifted if xlim2 is None else xlim2)
|
| 456 |
+
ax.set_ylim(ym_shifted if ylim2 is None else ylim2)
|
| 457 |
+
if zlim2 is not None:
|
| 458 |
+
ax.set_zlim(z_map(zlim2[0]), z_map(zlim2[1])) if (z_break is not None) else ax.set_zlim(zlim2)
|
| 459 |
+
else:
|
| 460 |
+
zmin = float(np.nanmin(df["_z"]))
|
| 461 |
+
zmax = float(np.nanmax(df["_z"]))
|
| 462 |
+
ax.set_zlim(zmin - 0.5, zmax + 0.5)
|
| 463 |
+
|
| 464 |
+
ax.set_ylabel("Scaled RMSE (" + r"$^{1}\mathrm{H}$" + " ppm)", fontsize=13, labelpad=10)
|
| 465 |
+
ax.set_xlabel("Slope vs. CCSD(T)", fontsize=13, labelpad=10)
|
| 466 |
+
ax.set_zlabel("Year", fontsize=13, labelpad=8)
|
| 467 |
+
|
| 468 |
+
# --- major tick locations ---
|
| 469 |
+
ax.xaxis.set_major_locator(MaxNLocator(nbins=x_major_nbins))
|
| 470 |
+
ax.yaxis.set_major_locator(MaxNLocator(nbins=y_major_nbins))
|
| 471 |
+
|
| 472 |
+
# --- major tick appearance ---
|
| 473 |
+
for axis_name in ("x", "y", "z"):
|
| 474 |
+
ax.tick_params(
|
| 475 |
+
axis=axis_name,
|
| 476 |
+
which="major",
|
| 477 |
+
length=major_tick_length,
|
| 478 |
+
width=major_tick_width,
|
| 479 |
+
pad=major_tick_pad,
|
| 480 |
+
colors=major_tick_color,
|
| 481 |
+
labelsize=major_tick_labelsize,
|
| 482 |
+
labelcolor=major_tick_labelcolor,
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
for lbl in ax.get_xticklabels():
|
| 486 |
+
lbl.set_rotation(major_tick_rotation_x)
|
| 487 |
+
for lbl in ax.get_yticklabels():
|
| 488 |
+
lbl.set_rotation(major_tick_rotation_y)
|
| 489 |
+
for lbl in ax.get_zticklabels():
|
| 490 |
+
lbl.set_rotation(major_tick_rotation_z)
|
| 491 |
+
|
| 492 |
+
# --- Z ticks as years ---
|
| 493 |
+
if (z_param is None) or (z_break is not None):
|
| 494 |
+
if zlim2 is not None:
|
| 495 |
+
real_min, real_max = float(zlim2[0]), float(zlim2[1])
|
| 496 |
+
else:
|
| 497 |
+
real_min, real_max = float(np.nanmin(df["_year"])), float(np.nanmax(df["_year"]))
|
| 498 |
+
|
| 499 |
+
if z_break is not None:
|
| 500 |
+
lo, hi = map(float, _coerce_pair(z_break, "z_break", allow_none=False))
|
| 501 |
+
step = int(year_tick_step)
|
| 502 |
+
ticks_real = list(np.arange(np.floor(real_min), np.floor(min(real_max, lo)) + 1, step))
|
| 503 |
+
ticks_real += list(np.arange(np.ceil(max(real_min, hi)), np.ceil(real_max) + 1, step))
|
| 504 |
+
ticks_real = [int(t) for t in ticks_real]
|
| 505 |
+
ticks_pos = [z_map(t) for t in ticks_real]
|
| 506 |
+
ax.set_zticks(ticks_pos)
|
| 507 |
+
ax.set_zticklabels([str(t) for t in ticks_real], fontsize=major_tick_labelsize, color=major_tick_labelcolor)
|
| 508 |
+
else:
|
| 509 |
+
ticks = np.arange(int(np.floor(real_min)), int(np.ceil(real_max)) + 1, int(year_tick_step))
|
| 510 |
+
ax.set_zticks(ticks)
|
| 511 |
+
ax.set_zticklabels([str(t) for t in ticks], fontsize=major_tick_labelsize, color=major_tick_labelcolor)
|
| 512 |
+
|
| 513 |
+
ax.grid(False)
|
| 514 |
+
|
| 515 |
+
if show_xy_grid_plane:
|
| 516 |
+
_draw_xy_grid_plane(ax, xlim=ax.get_xlim(), ylim=ax.get_ylim(), z=xy_grid_z,
|
| 517 |
+
alpha=grid_alpha, lw=grid_lw)
|
| 518 |
+
|
| 519 |
+
# =========================
|
| 520 |
+
# HIGHLIGHT + LABEL
|
| 521 |
+
# =========================
|
| 522 |
+
if highlight_methods:
|
| 523 |
+
hm = set(highlight_methods)
|
| 524 |
+
sub_h = df[df["Method"].isin(hm)].copy()
|
| 525 |
+
if not sub_h.empty:
|
| 526 |
+
ax.scatter(
|
| 527 |
+
sub_h["_x_plot"], sub_h["_y_plot"], sub_h["_z"],
|
| 528 |
+
s=highlight_marker_size,
|
| 529 |
+
depthshade=False,
|
| 530 |
+
color=sub_h["category"].map(lambda c: method_color_map.get(c, "#777777")),
|
| 531 |
+
edgecolor=highlight_edgecolor,
|
| 532 |
+
linewidths=highlight_edgewidth,
|
| 533 |
+
alpha=highlight_alpha,
|
| 534 |
+
zorder=20,
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
if highlight_text:
|
| 538 |
+
dx_text = float(highlight_text_dx_frac) * xr
|
| 539 |
+
dy_text = float(highlight_text_dy_frac) * yr
|
| 540 |
+
for _, r in sub_h.iterrows():
|
| 541 |
+
px, py, pz = float(r["_x_plot"]), float(r["_y_plot"]), float(r["_z"])
|
| 542 |
+
tx, ty, tz = px + dx_text, py + dy_text, pz + float(highlight_text_dz)
|
| 543 |
+
# thin leader line so the label reads in clear space, not under the marker
|
| 544 |
+
ax.plot([px, tx], [py, ty], [pz, tz], color="0.45", linewidth=0.6, zorder=25)
|
| 545 |
+
txt_kwargs = dict(
|
| 546 |
+
fontsize=highlight_text_size,
|
| 547 |
+
color=highlight_text_color,
|
| 548 |
+
zorder=30, ha="left", va="center",
|
| 549 |
+
)
|
| 550 |
+
if highlight_text_bbox:
|
| 551 |
+
txt_kwargs["bbox"] = dict(
|
| 552 |
+
boxstyle="round,pad=0.15",
|
| 553 |
+
facecolor=(1, 1, 1, highlight_text_bbox_alpha),
|
| 554 |
+
edgecolor="none",
|
| 555 |
+
)
|
| 556 |
+
ax.text(tx, ty, tz, str(r["Method"]), **txt_kwargs)
|
| 557 |
+
|
| 558 |
+
if show_legend:
|
| 559 |
+
import matplotlib.patches as mpatches
|
| 560 |
+
patches = [mpatches.Patch(color=method_color_map.get(cat, "#777777"), label=cat) for cat in rung_order]
|
| 561 |
+
ax.legend(handles=patches,
|
| 562 |
+
title=legend_title,
|
| 563 |
+
fontsize=legend_fontsize,
|
| 564 |
+
title_fontsize=legend_title_fontsize,
|
| 565 |
+
loc=legend_loc,
|
| 566 |
+
bbox_to_anchor=legend_bbox_to_anchor,
|
| 567 |
+
frameon=True)
|
| 568 |
+
|
| 569 |
+
# =========================
|
| 570 |
+
# SAVE
|
| 571 |
+
# =========================
|
| 572 |
+
def _save_figure(fig, save_path, formats=("svg", "png"), dpi=300,
|
| 573 |
+
svg_transparent=True, png_transparent=False):
|
| 574 |
+
if not save_path:
|
| 575 |
+
return []
|
| 576 |
+
root, ext = os.path.splitext(save_path)
|
| 577 |
+
os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
|
| 578 |
+
saved = []
|
| 579 |
+
|
| 580 |
+
def _save_one(outpath, fmt):
|
| 581 |
+
fmt = fmt.lower().lstrip(".")
|
| 582 |
+
if fmt == "svg":
|
| 583 |
+
fig.savefig(outpath, format="svg", bbox_inches="tight", pad_inches=0.12,
|
| 584 |
+
dpi=dpi, transparent=bool(svg_transparent))
|
| 585 |
+
elif fmt in {"png", "jpg", "jpeg", "tif", "tiff"}:
|
| 586 |
+
fig.savefig(outpath, format=fmt, bbox_inches="tight", pad_inches=0.12,
|
| 587 |
+
dpi=dpi, transparent=bool(png_transparent))
|
| 588 |
+
else:
|
| 589 |
+
fig.savefig(outpath, format=fmt, bbox_inches="tight", pad_inches=0.12,
|
| 590 |
+
dpi=dpi, transparent=False)
|
| 591 |
+
|
| 592 |
+
if ext.lower() in {".png", ".svg", ".pdf", ".jpg", ".jpeg", ".tif", ".tiff"}:
|
| 593 |
+
fmt = ext.lower().lstrip(".")
|
| 594 |
+
_save_one(save_path, fmt)
|
| 595 |
+
saved.append(save_path)
|
| 596 |
+
return saved
|
| 597 |
+
|
| 598 |
+
for f in formats:
|
| 599 |
+
f = f.lower().lstrip(".")
|
| 600 |
+
out = f"{save_path}.{f}"
|
| 601 |
+
_save_one(out, f)
|
| 602 |
+
saved.append(out)
|
| 603 |
+
return saved
|
| 604 |
+
|
| 605 |
+
saved_files = _save_figure(
|
| 606 |
+
fig,
|
| 607 |
+
save_path=save_path,
|
| 608 |
+
formats=save_formats,
|
| 609 |
+
dpi=dpi,
|
| 610 |
+
svg_transparent=svg_transparent,
|
| 611 |
+
png_transparent=png_transparent,
|
| 612 |
+
)
|
| 613 |
+
if saved_files:
|
| 614 |
+
print("Saved:", *saved_files, sep="\n - ")
|
| 615 |
+
|
| 616 |
+
if show_plot:
|
| 617 |
+
plt.show()
|
| 618 |
+
else:
|
| 619 |
+
plt.close(fig)
|
| 620 |
+
|
| 621 |
+
return fig, ax, saved_files
|
analysis/code/dataset_summary.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SI Table S8 (Dataset Summary Statistics): molecule and 1H/13C site counts for the training datasets.
|
| 2 |
+
|
| 3 |
+
Site counts from each file's flat ``atomic_numbers`` (1H = 1, 13C = 6); molecule counts from each
|
| 4 |
+
file's per-molecule index. Vectorized reads, so seconds even on the multi-million-molecule files. The
|
| 5 |
+
MagNET-Zero rows combine the two sigma-pepper groups (initial PBE0/pcSseg-1, WP04/wB97X-D/pcSseg-2
|
| 6 |
+
fine-tuning) with sigma-concentrate.
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
import h5py
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
import paths
|
| 14 |
+
|
| 15 |
+
# Published SI Table S8 values, for the exact-reproduction check: label -> (molecules, 1H, 13C).
|
| 16 |
+
PUBLISHED_S8 = {
|
| 17 |
+
"sigma-shake": (4_787_816, 72_175_813, 50_542_154),
|
| 18 |
+
"MagNET-Zero (initial round)": (2_167_210, 24_047_999, 15_330_361),
|
| 19 |
+
"MagNET-Zero (fine-tuning)": (370_105, 4_042_135, 2_557_211),
|
| 20 |
+
"MagNET-Zero (both rounds combined)": (2_537_315, 28_090_134, 17_887_572),
|
| 21 |
+
"sigma-concentrate": (50_000, 754_437, 528_569),
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _counts(node):
|
| 26 |
+
"""(molecules, 1H sites, 13C sites) for an open h5py file or group."""
|
| 27 |
+
z = node["atomic_numbers"][:]
|
| 28 |
+
n_1h = int((z == 1).sum())
|
| 29 |
+
n_13c = int((z == 6).sum())
|
| 30 |
+
if "molecule_id" in node:
|
| 31 |
+
n_mol = node["molecule_id"].shape[0]
|
| 32 |
+
elif "smiles_offsets" in node: # offsets are n_molecules + 1
|
| 33 |
+
n_mol = node["smiles_offsets"].shape[0] - 1
|
| 34 |
+
elif "sigma_shake_id" in node:
|
| 35 |
+
n_mol = node["sigma_shake_id"].shape[0]
|
| 36 |
+
else:
|
| 37 |
+
raise KeyError("node has no recognized per-molecule key "
|
| 38 |
+
"(molecule_id / smiles_offsets / sigma_shake_id)")
|
| 39 |
+
return n_mol, n_1h, n_13c
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def counts(path, group=None):
|
| 43 |
+
"""(molecules, 1H sites, 13C sites) for one HDF5 file, optionally within one group."""
|
| 44 |
+
with h5py.File(path, "r") as f:
|
| 45 |
+
return _counts(f[group] if group else f)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def summary_table(data_dir):
|
| 49 |
+
"""SI Table S8 as a DataFrame with columns dataset / molecules / n_1H_sites / n_13C_sites.
|
| 50 |
+
|
| 51 |
+
data_dir is the release repo's ``data/`` directory; the three source files are read from their
|
| 52 |
+
dataset subfolders. The two MagNET-Zero training rounds and their combination are assembled from
|
| 53 |
+
the sigma-pepper groups plus sigma-concentrate.
|
| 54 |
+
"""
|
| 55 |
+
# data_dir is the repo's data/ folder; its parent is the repo root the deposit fallback needs.
|
| 56 |
+
root = os.path.dirname(os.path.normpath(data_dir))
|
| 57 |
+
shake = paths.dataset_file("sigma-shake", root=root)
|
| 58 |
+
pepper = paths.dataset_file("sigma-pepper", root=root)
|
| 59 |
+
concentrate = paths.dataset_file("sigma-concentrate", root=root)
|
| 60 |
+
|
| 61 |
+
sigma_shake = counts(shake)
|
| 62 |
+
initial = counts(pepper, "pbe0_pcSseg1") # MagNET-Zero initial round
|
| 63 |
+
pepper_ft = counts(pepper, "wp04_wb97xd_pcSseg2") # fine-tuning: sigma-pepper part
|
| 64 |
+
sigma_concentrate = counts(concentrate) # fine-tuning: sigma-concentrate part
|
| 65 |
+
fine_tuning = tuple(a + b for a, b in zip(pepper_ft, sigma_concentrate))
|
| 66 |
+
combined = tuple(a + b for a, b in zip(initial, fine_tuning))
|
| 67 |
+
|
| 68 |
+
rows = [
|
| 69 |
+
("sigma-shake", sigma_shake),
|
| 70 |
+
("MagNET-Zero (initial round)", initial),
|
| 71 |
+
("MagNET-Zero (fine-tuning)", fine_tuning),
|
| 72 |
+
("MagNET-Zero (both rounds combined)", combined),
|
| 73 |
+
("sigma-concentrate", sigma_concentrate),
|
| 74 |
+
]
|
| 75 |
+
return pd.DataFrame(
|
| 76 |
+
[{"dataset": label, "molecules": m, "n_1H_sites": h, "n_13C_sites": c}
|
| 77 |
+
for label, (m, h, c) in rows]
|
| 78 |
+
)
|
analysis/code/delta22.py
ADDED
|
@@ -0,0 +1,927 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core analysis for the delta-22 figures (no plotting).
|
| 2 |
+
|
| 3 |
+
Shared machinery behind the delta-22 figures (Figures 2A, 3, 4; SI S1, S4-S8, S11, S13, S16-S18):
|
| 4 |
+
a seeded per-solute train/test fitting harness, the Pareto computation, and correlation matrices.
|
| 5 |
+
Reads the released data via `delta22_reader`; the figure notebooks and tests import from here.
|
| 6 |
+
|
| 7 |
+
Splits are seeded with random.seed(seed + 100), shuffle, first n_test solutes are the test set, so
|
| 8 |
+
results are deterministic. `fit` uses numpy lstsq directly instead of statsmodels smf.ols in the
|
| 9 |
+
inner loop (patsy re-parsing makes that ~1000x slower); `_fit_statsmodels` is kept as a test oracle.
|
| 10 |
+
"""
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
from stats import rmse, mae, ols_fit, linear_fit_1d
|
| 17 |
+
from spreadsheet import site_atom_indices # noqa: F401 re-exported for notebooks (D.site_atom_indices)
|
| 18 |
+
from delta22_reader import (
|
| 19 |
+
load_delta22_dft_data,
|
| 20 |
+
load_delta22_nn_data,
|
| 21 |
+
load_solutes,
|
| 22 |
+
load_perturbed_shieldings,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# The 12 solvents with explicit (Desmond) data, in a stable order.
|
| 26 |
+
DESMOND_SOLVENTS = [
|
| 27 |
+
"tetrahydrofuran", "dichloromethane", "chloroform", "toluene", "benzene", "chlorobenzene",
|
| 28 |
+
"acetone", "dimethylsulfoxide", "acetonitrile", "trifluoroethanol", "methanol", "TIP4P",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
# the double hybrids (and DLPNO-MP2) have no implicit-solvent shielding of their own; the reader
|
| 32 |
+
# substitutes a B3LYP-D3(BJ) PCM correction for them, so their pcm column is not method-specific.
|
| 33 |
+
# Exclude them from any analysis that compares the PCM correction across methods.
|
| 34 |
+
DOUBLE_HYBRID_METHODS = ["B2GP_PLYP", "B2PLYP", "dlpno_mp2", "dsd_pbep86",
|
| 35 |
+
"mPW2PLYP", "revdsd_pbep86"]
|
| 36 |
+
|
| 37 |
+
STATIONARY_VS_PCM = ["stationary", "stationary + pcm"]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# loading / assembling the flat query table
|
| 42 |
+
|
| 43 |
+
def delta22_solutes(delta22_path):
|
| 44 |
+
"""The list of solute names stored in the file."""
|
| 45 |
+
return load_solutes(delta22_path)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def build_query_df(combined_df):
|
| 49 |
+
"""Flatten a reader combined table (load_delta22_dft_data / load_delta22_nn_data output,
|
| 50 |
+
which is multi-indexed) into a plain DataFrame with the index levels as columns. Fitting
|
| 51 |
+
and split helpers operate on this flat table."""
|
| 52 |
+
return combined_df.reset_index()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_query_df_dft(delta22_path, experimental_path, **kwargs):
|
| 56 |
+
"""Convenience: load the DFT combined table and flatten it in one call."""
|
| 57 |
+
return build_query_df(load_delta22_dft_data(delta22_path, experimental_path, **kwargs))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def load_query_df_nn(delta22_path, experimental_path, **kwargs):
|
| 61 |
+
"""Convenience: load the MagNET (NN) combined table and flatten it in one call."""
|
| 62 |
+
return build_query_df(load_delta22_nn_data(delta22_path, experimental_path, **kwargs))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# composite predictor columns
|
| 67 |
+
|
| 68 |
+
# the precomputed sum columns the formulas reference as single terms (e.g. "stationary_plus_pcm" is
|
| 69 |
+
# one predictor = stationary + pcm, distinct from the two-term "stationary + pcm"). For MagNET (NN)
|
| 70 |
+
# data, stationary_plus_pcm is precomputed by the loader using the solvent-specific PCM scaling
|
| 71 |
+
# factors; add_composite_columns leaves an existing stationary_plus_pcm column untouched.
|
| 72 |
+
# NOTE: the pcm three-term chains here (stationary_plus_pcm_plus_qcd etc.) are RAW sums with an
|
| 73 |
+
# unscaled pcm term. build_composite_model.py needs the pcm term carried at its scaled value, so it
|
| 74 |
+
# builds those columns itself on top of the scaled stationary_plus_pcm. Do not swap it to this
|
| 75 |
+
# helper: the results differ by order 1 ppm on every pcm chain.
|
| 76 |
+
_COMPOSITE_COLUMNS = {
|
| 77 |
+
"stationary_plus_pcm": ("stationary", "pcm"),
|
| 78 |
+
"stationary_plus_desmond": ("stationary", "desmond"),
|
| 79 |
+
"stationary_plus_openMM": ("stationary", "openMM"),
|
| 80 |
+
"stationary_plus_qcd": ("stationary", "qcd"),
|
| 81 |
+
"stationary_plus_des_vib": ("stationary", "desmond_vib"),
|
| 82 |
+
"stationary_plus_op_vib": ("stationary", "openMM_vib"),
|
| 83 |
+
"stationary_plus_pcm_plus_qcd": ("stationary", "pcm", "qcd"),
|
| 84 |
+
"stationary_plus_pcm_plus_des_vib": ("stationary", "pcm", "desmond_vib"),
|
| 85 |
+
"stationary_plus_pcm_plus_op_vib": ("stationary", "pcm", "openMM_vib"),
|
| 86 |
+
"stationary_plus_desmond_plus_qcd": ("stationary", "desmond", "qcd"),
|
| 87 |
+
"stationary_plus_desmond_plus_des_vib": ("stationary", "desmond", "desmond_vib"),
|
| 88 |
+
"stationary_plus_openMM_plus_qcd": ("stationary", "openMM", "qcd"),
|
| 89 |
+
"stationary_plus_openMM_plus_op_vib": ("stationary", "openMM", "openMM_vib"),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def add_composite_columns(query_df, keep_existing=("stationary_plus_pcm",)):
|
| 94 |
+
"""Add the precomputed sum columns the composite formulas reference as single terms. Returns a
|
| 95 |
+
copy. Columns named in keep_existing that are already present (e.g. an NN stationary_plus_pcm
|
| 96 |
+
built with the solvent scaling factors) are not overwritten."""
|
| 97 |
+
out = query_df.copy()
|
| 98 |
+
for name, parts in _COMPOSITE_COLUMNS.items():
|
| 99 |
+
if name in keep_existing and name in out.columns:
|
| 100 |
+
continue
|
| 101 |
+
out[name] = sum(out[part] for part in parts)
|
| 102 |
+
return out
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
# the linear fitting harness
|
| 107 |
+
|
| 108 |
+
def _formula_terms(formula):
|
| 109 |
+
"""The additive predictor column names in 'a + b + c'. These figures use only additive main
|
| 110 |
+
effects (no interactions), so a plain split on '+' is exact."""
|
| 111 |
+
return [term.strip() for term in formula.split("+") if term.strip()]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _design_matrix(df, terms):
|
| 115 |
+
"""Design matrix [1, term_1, term_2, ...] as float, plus the response, with any row that has a
|
| 116 |
+
missing value in the response or a predictor dropped (statsmodels drops missing rows too)."""
|
| 117 |
+
response = df["experimental"].to_numpy(dtype=float)
|
| 118 |
+
columns = [df[term].to_numpy(dtype=float) for term in terms]
|
| 119 |
+
design = np.column_stack([np.ones(len(df))] + columns)
|
| 120 |
+
keep = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
|
| 121 |
+
return design[keep], response[keep]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def fit(train_df, test_df, formula, mode="rmse"):
|
| 125 |
+
"""Fit experimental ~ formula by ordinary least squares on train_df and score on both sets.
|
| 126 |
+
|
| 127 |
+
Returns (train_error, test_error, params), where params is a pandas Series indexed by
|
| 128 |
+
"Intercept" and the predictor names (matching statsmodels' naming). mode is "rmse" (default)
|
| 129 |
+
or "mae". Uses numpy least squares directly (see the module docstring for why)."""
|
| 130 |
+
terms = _formula_terms(formula)
|
| 131 |
+
train_x, train_y = _design_matrix(train_df, terms)
|
| 132 |
+
beta = ols_fit(train_x, train_y)
|
| 133 |
+
params = pd.Series(beta, index=["Intercept"] + terms)
|
| 134 |
+
|
| 135 |
+
test_x, test_y = _design_matrix(test_df, terms)
|
| 136 |
+
train_pred = train_x @ beta
|
| 137 |
+
test_pred = test_x @ beta
|
| 138 |
+
score = rmse if mode == "rmse" else mae if mode == "mae" else None
|
| 139 |
+
if score is None:
|
| 140 |
+
raise ValueError(f"mode must be 'rmse' or 'mae', got {mode!r}")
|
| 141 |
+
return score(train_pred, train_y), score(test_pred, test_y), params
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _fit_statsmodels(train_df, test_df, formula, mode="rmse"):
|
| 145 |
+
"""Statsmodels OLS, kept only as a test oracle for `fit`. Slow because statsmodels re-parses
|
| 146 |
+
the formula with patsy on every call; do not use it in the harness."""
|
| 147 |
+
import statsmodels.formula.api as smf
|
| 148 |
+
result = smf.ols(formula=f"experimental ~ {formula}", data=train_df).fit()
|
| 149 |
+
score = rmse if mode == "rmse" else mae
|
| 150 |
+
train_err = score(result.predict(train_df).to_numpy(), train_df["experimental"].to_numpy())
|
| 151 |
+
test_err = score(result.predict(test_df).to_numpy(), test_df["experimental"].to_numpy())
|
| 152 |
+
return train_err, test_err, result.params
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def generate_solute_splits(n_splits, query_df, solvent, solutes,
|
| 156 |
+
n_test=10, seed_offset=100):
|
| 157 |
+
"""Build n_splits seeded train/test partitions for one solvent, splitting on SOLUTE so a
|
| 158 |
+
molecule is never in both sets. For split k: seed the RNG with k + seed_offset, shuffle the
|
| 159 |
+
solute list, take the first n_test as the test set and the rest as the training set, then select
|
| 160 |
+
that solvent's rows. Returns a list of (train_df, test_df) pairs."""
|
| 161 |
+
solute_list = list(solutes)
|
| 162 |
+
# filter this solvent once, then group its rows by solute, so each seed just concatenates the
|
| 163 |
+
# relevant groups instead of masking the whole table again.
|
| 164 |
+
solvent_df = query_df[query_df["solvent"] == solvent]
|
| 165 |
+
by_solute = {solute: rows for solute, rows in solvent_df.groupby("solute")}
|
| 166 |
+
empty = solvent_df.iloc[0:0]
|
| 167 |
+
|
| 168 |
+
def _gather(molecules):
|
| 169 |
+
frames = [by_solute[m] for m in molecules if m in by_solute]
|
| 170 |
+
return pd.concat(frames) if frames else empty
|
| 171 |
+
|
| 172 |
+
splits = []
|
| 173 |
+
for seed in range(n_splits):
|
| 174 |
+
random.seed(seed + seed_offset)
|
| 175 |
+
molecules = list(solute_list)
|
| 176 |
+
random.shuffle(molecules)
|
| 177 |
+
splits.append((_gather(molecules[n_test:]), _gather(molecules[:n_test])))
|
| 178 |
+
return splits
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _solute_partitions(n_splits, solutes, n_test, seed_offset=100):
|
| 182 |
+
"""The seeded (train_solutes, test_solutes) name partitions the harness fits over, matching
|
| 183 |
+
generate_solute_splits exactly: for split k, seed random with k + seed_offset, shuffle the full
|
| 184 |
+
solute list, test = the first n_test names, train = the rest. Row selection and solvent filtering
|
| 185 |
+
are left to the caller (see _split_fit_scores), so these partitions are computed once and reused
|
| 186 |
+
across formulas."""
|
| 187 |
+
partitions = []
|
| 188 |
+
base = list(solutes)
|
| 189 |
+
for seed in range(n_splits):
|
| 190 |
+
random.seed(seed + seed_offset)
|
| 191 |
+
molecules = list(base)
|
| 192 |
+
random.shuffle(molecules)
|
| 193 |
+
partitions.append((molecules[n_test:], molecules[:n_test]))
|
| 194 |
+
return partitions
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _split_fit_scores(group_df, solvent, formulas, partitions, mode="rmse", min_train=2, swap=False):
|
| 198 |
+
"""Fit each formula over the given seeded solute partitions for one group and solvent, returning
|
| 199 |
+
{(formula, seed): (train_error, test_error)} for every split that was fit.
|
| 200 |
+
|
| 201 |
+
The fast path behind run_fits / pareto_solvent_averaged_rmse: converts each column to numpy once
|
| 202 |
+
and selects each split's rows by integer index, avoiding the per-split pd.concat that dominates
|
| 203 |
+
runtime in generate_solute_splits + fit. Mathematically identical to fit() on the same rows.
|
| 204 |
+
Splits whose train set is smaller than min_train, whose test set is empty, or that fail to solve
|
| 205 |
+
are omitted from the result. swap=True trains on the first n_test solutes instead of the rest
|
| 206 |
+
(Figure 2A)."""
|
| 207 |
+
solvent_df = group_df[group_df["solvent"] == solvent]
|
| 208 |
+
n_rows = len(solvent_df)
|
| 209 |
+
score = rmse if mode == "rmse" else mae if mode == "mae" else None
|
| 210 |
+
if score is None:
|
| 211 |
+
raise ValueError(f"mode must be 'rmse' or 'mae', got {mode!r}")
|
| 212 |
+
|
| 213 |
+
# row positions (0..n_rows-1 within solvent_df) for each solute, and the numeric columns as numpy
|
| 214 |
+
solute_values = solvent_df["solute"].to_numpy()
|
| 215 |
+
by_solute = {}
|
| 216 |
+
for position, solute in enumerate(solute_values):
|
| 217 |
+
by_solute.setdefault(solute, []).append(position)
|
| 218 |
+
by_solute = {s: np.array(v, dtype=np.intp) for s, v in by_solute.items()}
|
| 219 |
+
empty = np.array([], dtype=np.intp)
|
| 220 |
+
|
| 221 |
+
def gather(names):
|
| 222 |
+
arrays = [by_solute[m] for m in names if m in by_solute]
|
| 223 |
+
return np.concatenate(arrays) if arrays else empty
|
| 224 |
+
|
| 225 |
+
# each split's train/test row indices, computed once and shared across formulas
|
| 226 |
+
split_indices = []
|
| 227 |
+
for train_names, test_names in partitions:
|
| 228 |
+
train_idx, test_idx = gather(train_names), gather(test_names)
|
| 229 |
+
if swap:
|
| 230 |
+
train_idx, test_idx = test_idx, train_idx
|
| 231 |
+
split_indices.append((train_idx, test_idx))
|
| 232 |
+
|
| 233 |
+
response = solvent_df["experimental"].to_numpy(dtype=float)
|
| 234 |
+
term_columns = {term: solvent_df[term].to_numpy(dtype=float)
|
| 235 |
+
for formula in formulas for term in _formula_terms(formula)}
|
| 236 |
+
|
| 237 |
+
scores = {}
|
| 238 |
+
for formula in formulas:
|
| 239 |
+
terms = _formula_terms(formula)
|
| 240 |
+
design = np.column_stack([np.ones(n_rows)] + [term_columns[t] for t in terms])
|
| 241 |
+
finite = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
|
| 242 |
+
for seed, (train_idx, test_idx) in enumerate(split_indices):
|
| 243 |
+
if len(train_idx) < min_train or len(test_idx) < 1:
|
| 244 |
+
continue
|
| 245 |
+
train_fit = train_idx[finite[train_idx]]
|
| 246 |
+
test_fit = test_idx[finite[test_idx]]
|
| 247 |
+
try:
|
| 248 |
+
beta = ols_fit(design[train_fit], response[train_fit])
|
| 249 |
+
train_err = score(design[train_fit] @ beta, response[train_fit])
|
| 250 |
+
test_err = score(design[test_fit] @ beta, response[test_fit])
|
| 251 |
+
except Exception:
|
| 252 |
+
continue
|
| 253 |
+
scores[(formula, seed)] = (train_err, test_err)
|
| 254 |
+
return scores
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def run_fits(query_df, solvents, formulas, n_splits, solutes,
|
| 258 |
+
group_cols=None, n_test=10, mode="rmse", min_train=2):
|
| 259 |
+
"""Run the fitting harness over every (group, solvent, formula, split) combination and return
|
| 260 |
+
a tidy DataFrame with train_RMSE / test_RMSE per row.
|
| 261 |
+
|
| 262 |
+
group_cols: optional list of columns to fit within separately (e.g.
|
| 263 |
+
["sap_nmr_method", "sap_basis", "sap_geometry_type"] for the per-method figures). When None,
|
| 264 |
+
the whole query_df is used as a single group (the caller has pre-filtered it). Splits that are
|
| 265 |
+
too small to fit, or that cannot be solved, are skipped.
|
| 266 |
+
"""
|
| 267 |
+
rows = []
|
| 268 |
+
if group_cols:
|
| 269 |
+
group_iter = list(query_df.groupby(group_cols))
|
| 270 |
+
else:
|
| 271 |
+
group_iter = [((), query_df)]
|
| 272 |
+
partitions = _solute_partitions(n_splits, solutes, n_test)
|
| 273 |
+
for group_key, group_df in group_iter:
|
| 274 |
+
meta = {}
|
| 275 |
+
if group_cols:
|
| 276 |
+
key = group_key if isinstance(group_key, tuple) else (group_key,)
|
| 277 |
+
meta = dict(zip(group_cols, key))
|
| 278 |
+
for solvent in solvents:
|
| 279 |
+
scores = _split_fit_scores(group_df, solvent, formulas, partitions,
|
| 280 |
+
mode=mode, min_train=min_train)
|
| 281 |
+
for formula in formulas:
|
| 282 |
+
for seed in range(n_splits):
|
| 283 |
+
got = scores.get((formula, seed))
|
| 284 |
+
if got is None:
|
| 285 |
+
continue
|
| 286 |
+
rows.append({**meta, "solvent": solvent, "formula": formula,
|
| 287 |
+
"seed": seed, "train_RMSE": got[0], "test_RMSE": got[1]})
|
| 288 |
+
return pd.DataFrame(rows)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def full_fit_coefficients(query_df, solvents, formula):
|
| 292 |
+
"""Fit `formula` on the full data (no train/test split) for each solvent and return a DataFrame
|
| 293 |
+
indexed by parameter name ("Intercept" plus the predictors) with one column per solvent. This is
|
| 294 |
+
the deterministic full-fit OLS whose coefficients are the composite model's coefficients, stored
|
| 295 |
+
in applications.hdf5's composite_model/ols_coefficients group. Fitting the released delta-22 NN
|
| 296 |
+
data here (with nitromethane dropped) reproduces those coefficients to within the data's
|
| 297 |
+
integer-encoding precision."""
|
| 298 |
+
columns = {}
|
| 299 |
+
for solvent in solvents:
|
| 300 |
+
sub = query_df[query_df["solvent"] == solvent]
|
| 301 |
+
if len(sub) < 2:
|
| 302 |
+
continue
|
| 303 |
+
_, _, params = fit(sub, sub, formula)
|
| 304 |
+
columns[solvent] = params
|
| 305 |
+
return pd.DataFrame(columns)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def fit_coefficients(query_df, solvent, formula, solutes, n_splits,
|
| 309 |
+
n_test=10, seed_offset=100):
|
| 310 |
+
"""Fit one formula for one solvent across all seeded splits and return the per-split fitted
|
| 311 |
+
coefficients as a DataFrame (one row per seed, columns = the statsmodels parameter names plus
|
| 312 |
+
the seed). This is the building block for the bootstrap coefficient distributions and for
|
| 313 |
+
verifying the composite-model coefficients fit on delta-22."""
|
| 314 |
+
splits = generate_solute_splits(n_splits, query_df, solvent, solutes,
|
| 315 |
+
n_test=n_test, seed_offset=seed_offset)
|
| 316 |
+
records = []
|
| 317 |
+
for seed, (train_df, test_df) in enumerate(splits):
|
| 318 |
+
if len(train_df) < 2:
|
| 319 |
+
continue
|
| 320 |
+
_, _, params = fit(train_df, test_df, formula)
|
| 321 |
+
record = {"solvent": solvent, "seed": seed}
|
| 322 |
+
record.update({name: float(value) for name, value in params.items()})
|
| 323 |
+
records.append(record)
|
| 324 |
+
return pd.DataFrame(records)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ---------------------------------------------------------------------------
|
| 328 |
+
# Figure S8 B-D (Convergence of Explicit Solvent Corrections)
|
| 329 |
+
|
| 330 |
+
def frame_corrections(perturbed, atom_indices):
|
| 331 |
+
"""Per-frame solvent correction for one site: the mean over the site's atoms of
|
| 332 |
+
(solvated - isolated), from a raw (n_frames, n_atoms, 2) perturbed-shielding array
|
| 333 |
+
(load_perturbed_shieldings). Frames whose site atoms were not computed read as NaN."""
|
| 334 |
+
site = (perturbed[:, :, 1] - perturbed[:, :, 0])[:, list(atom_indices)]
|
| 335 |
+
valid = ~np.isnan(site)
|
| 336 |
+
counts = valid.sum(axis=1)
|
| 337 |
+
sums = np.where(valid, site, 0.0).sum(axis=1)
|
| 338 |
+
return np.where(counts > 0, sums / np.maximum(counts, 1), np.nan)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def running_average(values):
|
| 342 |
+
"""Cumulative mean over frames ignoring NaN frames - the running value a correction converges to
|
| 343 |
+
as more molecular-dynamics frames are included (Figure S8 panel B)."""
|
| 344 |
+
values = np.asarray(values, dtype=float)
|
| 345 |
+
valid = ~np.isnan(values)
|
| 346 |
+
counts = np.cumsum(valid)
|
| 347 |
+
sums = np.cumsum(np.where(valid, values, 0.0))
|
| 348 |
+
with np.errstate(invalid="ignore", divide="ignore"):
|
| 349 |
+
return np.where(counts > 0, sums / counts, np.nan)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def autocorrelation(values, max_lag=None):
|
| 353 |
+
"""Autocorrelation of the valid per-frame corrections at lags 0..max_lag (Figure S8 panel C).
|
| 354 |
+
A fast drop to zero means frames are effectively independent."""
|
| 355 |
+
x = np.asarray(values, dtype=float)
|
| 356 |
+
x = x[~np.isnan(x)]
|
| 357 |
+
x = x - x.mean()
|
| 358 |
+
n = len(x)
|
| 359 |
+
if n < 2:
|
| 360 |
+
return np.array([1.0])
|
| 361 |
+
if max_lag is None:
|
| 362 |
+
max_lag = n - 1
|
| 363 |
+
denom = np.sum(x * x)
|
| 364 |
+
return np.array([np.sum(x[:n - lag] * x[lag:]) / denom for lag in range(max_lag + 1)])
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def frame_validity(perturbed):
|
| 368 |
+
"""Per frame, whether any shielding value was computed for that frame (a raw (n_frames,
|
| 369 |
+
n_atoms, 2) perturbed-shielding array from load_perturbed_shieldings has NaN entries for atoms
|
| 370 |
+
that were not computed). A frame with every entry NaN was skipped entirely by the DFT queue;
|
| 371 |
+
Figure S8 panel D visualizes this mask across solvents for a representative solute."""
|
| 372 |
+
return ~np.all(np.isnan(perturbed), axis=(1, 2))
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def frame_validity_grid(hdf5_filepath, solute, solvents, explicit_solvation_model, shield_type="dft"):
|
| 376 |
+
"""Figure S8 panel D data: for one solute and one molecular-dynamics engine, the per-frame
|
| 377 |
+
validity mask (frame_validity) for each solvent, stacked into a single boolean grid of shape
|
| 378 |
+
(n_solvents, max_frames). Solvents with fewer trajectory frames than the longest one are padded
|
| 379 |
+
with False (no data) past their own frame count, so every row lines up on a shared frame-index
|
| 380 |
+
axis. Returns (grid, frame_counts), where frame_counts[i] is the true (unpadded) frame count for
|
| 381 |
+
solvents[i], needed to normalize the frame-wise correction histograms in panel B."""
|
| 382 |
+
masks = []
|
| 383 |
+
for solvent in solvents:
|
| 384 |
+
perturbed = load_perturbed_shieldings(hdf5_filepath, solute, solvent,
|
| 385 |
+
explicit_solvation_model, shield_type)
|
| 386 |
+
masks.append(frame_validity(perturbed))
|
| 387 |
+
frame_counts = np.array([len(m) for m in masks])
|
| 388 |
+
max_len = int(frame_counts.max()) if len(frame_counts) else 0
|
| 389 |
+
grid = np.zeros((len(solvents), max_len), dtype=bool)
|
| 390 |
+
for i, mask in enumerate(masks):
|
| 391 |
+
grid[i, :len(mask)] = mask
|
| 392 |
+
return grid, frame_counts
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ---------------------------------------------------------------------------
|
| 396 |
+
# Figures S11 / S13 (MagNET vs DFT corrections)
|
| 397 |
+
|
| 398 |
+
def compare_dft_nn(dft_df, nn_df, value_col, keys=("solute", "site", "nucleus")):
|
| 399 |
+
"""Merge the DFT and MagNET (NN) tables on the given keys and return, per site, the DFT and NN
|
| 400 |
+
value of one correction column plus their difference (error = NN - DFT). Used for the
|
| 401 |
+
rovibrational (qcd) comparison of Figure S11 and the explicit-solvent (desmond/openMM) comparison
|
| 402 |
+
of Figure S13. The correction is tiled across NMR methods, so duplicate keyed rows are dropped
|
| 403 |
+
before merging."""
|
| 404 |
+
keys = list(keys)
|
| 405 |
+
dft = dft_df[keys + [value_col]].drop_duplicates(subset=keys).dropna(subset=[value_col])
|
| 406 |
+
nn = nn_df[keys + [value_col]].drop_duplicates(subset=keys).dropna(subset=[value_col])
|
| 407 |
+
merged = dft.merge(nn, on=keys, suffixes=("_dft", "_nn"))
|
| 408 |
+
merged["error"] = merged[f"{value_col}_nn"] - merged[f"{value_col}_dft"]
|
| 409 |
+
return merged
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def qcd_correction_by_site(dft_df, nn_df, nucleus="H"):
|
| 413 |
+
"""Per site, the DFT and MagNET (NN) rovibrational (QCD) correction for one nucleus, sorted by
|
| 414 |
+
solute (SI Figure S11 panel C, the per-solute view behind panel A's aggregate scatter). QCD is a
|
| 415 |
+
gas-phase rovibrational correction, so unlike the explicit-solvent per-site view (Figure S13 panel
|
| 416 |
+
C) there is no solvent to split on: this is a plain wrapper around compare_dft_nn that keeps one
|
| 417 |
+
nucleus and orders the rows for the per-solute strip plot."""
|
| 418 |
+
merged = compare_dft_nn(dft_df, nn_df, "qcd", keys=("solute", "site", "nucleus"))
|
| 419 |
+
sub = merged[merged["nucleus"] == nucleus]
|
| 420 |
+
return sub.sort_values("solute", kind="stable").reset_index(drop=True)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def compare_dft_nn_by_engine(dft_df, nn_df, engines=("desmond", "openMM"),
|
| 424 |
+
keys=("solute", "site", "nucleus", "solvent")):
|
| 425 |
+
"""SI Figure S13 panels A and B data: compare_dft_nn run separately for each molecular-dynamics
|
| 426 |
+
engine (Desmond and OpenMM), stacked into one table with a shared 'dft_value' / 'nn_value' /
|
| 427 |
+
'error' column and an 'engine' label. compare_dft_nn alone cannot be stacked directly because it
|
| 428 |
+
names its value columns after value_col (e.g. 'desmond_dft' vs 'openMM_dft'); this renames them
|
| 429 |
+
to a common pair of columns first so both engines can share one axis."""
|
| 430 |
+
frames = []
|
| 431 |
+
for engine in engines:
|
| 432 |
+
one = compare_dft_nn(dft_df, nn_df, engine, keys=keys)
|
| 433 |
+
one = one.rename(columns={f"{engine}_dft": "dft_value", f"{engine}_nn": "nn_value"})
|
| 434 |
+
one["engine"] = engine
|
| 435 |
+
frames.append(one[list(keys) + ["dft_value", "nn_value", "error", "engine"]])
|
| 436 |
+
return pd.concat(frames, ignore_index=True)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
# ---------------------------------------------------------------------------
|
| 440 |
+
# Figure S5 (PCM Captures Bulk Solvent Effects)
|
| 441 |
+
|
| 442 |
+
# bulk solvent properties used to test what the PCM benefit tracks (TIP4P uses water's values)
|
| 443 |
+
SOLVENT_DIELECTRIC = {
|
| 444 |
+
"chloroform": 4.81, "dichloromethane": 9.08, "methanol": 32.6, "TIP4P": 78.54,
|
| 445 |
+
"trifluoroethanol": 26.7, "acetone": 21.01, "acetonitrile": 36.64, "dimethylsulfoxide": 47.0,
|
| 446 |
+
"tetrahydrofuran": 7.52, "benzene": 2.28, "chlorobenzene": 5.69, "toluene": 2.38,
|
| 447 |
+
}
|
| 448 |
+
SOLVENT_POLARIZABILITY = {
|
| 449 |
+
"chloroform": 8.5, "dichloromethane": 6.5, "methanol": 3.3, "TIP4P": 1.5,
|
| 450 |
+
"trifluoroethanol": 4.5, "acetone": 6.4, "acetonitrile": 4.4, "dimethylsulfoxide": 7.9,
|
| 451 |
+
"tetrahydrofuran": 8.0, "benzene": 10.3, "chlorobenzene": 12.4, "toluene": 12.3,
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def pcm_benefit_per_solvent(query_df_dft, method, basis, geometry, solvents, n_splits, solutes,
|
| 456 |
+
nucleus="H", n_test=10):
|
| 457 |
+
"""Panel S5 data: per solvent, the percent reduction in mean test RMSE from adding the
|
| 458 |
+
implicit-solvent (PCM) term, for one DFT method. Returns a Series indexed by solvent. A positive
|
| 459 |
+
value means PCM lowered the error. Plotted against the bulk solvent properties above.
|
| 460 |
+
|
| 461 |
+
The published Figure S5 aggregates the per-split test RMSEs with the mean (not the median); this
|
| 462 |
+
is what reproduces the paper's Pearson R values exactly."""
|
| 463 |
+
one = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
|
| 464 |
+
& (query_df_dft["sap_basis"] == basis)
|
| 465 |
+
& (query_df_dft["sap_geometry_type"] == geometry)
|
| 466 |
+
& (query_df_dft["nucleus"] == nucleus)]
|
| 467 |
+
one = add_composite_columns(one)
|
| 468 |
+
results = run_fits(one, solvents, ["stationary", "stationary_plus_pcm"],
|
| 469 |
+
n_splits=n_splits, solutes=solutes, n_test=n_test)
|
| 470 |
+
mean = results.groupby(["solvent", "formula"])["test_RMSE"].mean().unstack("formula")
|
| 471 |
+
return -((mean["stationary_plus_pcm"] - mean["stationary"]) / mean["stationary"]) * 100.0
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
# ---------------------------------------------------------------------------
|
| 475 |
+
# Figure S7 (Explicit Solvent Corrections are Method-Independent)
|
| 476 |
+
|
| 477 |
+
def explicit_correction_pairs(combined_df, solvent, nucleus="H"):
|
| 478 |
+
"""Per site, the Desmond and OpenMM explicit-solvent corrections for one solvent (Figure S7).
|
| 479 |
+
The explicit correction does not depend on the NMR method, so the same value is tiled across the
|
| 480 |
+
method rows; duplicate site rows are dropped. Works on either the DFT or the NN combined table.
|
| 481 |
+
A tight Desmond-vs-OpenMM agreement is the method-independence claim."""
|
| 482 |
+
sub = combined_df[(combined_df["nucleus"] == nucleus) & (combined_df["solvent"] == solvent)]
|
| 483 |
+
out = sub[["solute", "site", "desmond", "openMM"]].drop_duplicates(subset=["solute", "site"])
|
| 484 |
+
return out.dropna(subset=["desmond", "openMM"]).reset_index(drop=True)
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def explicit_correction_dft_nn_pairs(dft_df, nn_df, solvent, nucleus="H"):
|
| 488 |
+
"""Per site, the Desmond and OpenMM explicit-solvent corrections from both DFT and MagNET-x (NN)
|
| 489 |
+
for one solvent and nucleus (Figure S13 panel C, the per-site view behind panel A's aggregate
|
| 490 |
+
scatter). Built from two explicit_correction_pairs calls (one per source) merged on
|
| 491 |
+
(solute, site), giving columns desmond_dft, openMM_dft, desmond_nn, openMM_nn."""
|
| 492 |
+
dft_pairs = explicit_correction_pairs(dft_df, solvent, nucleus)
|
| 493 |
+
nn_pairs = explicit_correction_pairs(nn_df, solvent, nucleus)
|
| 494 |
+
return dft_pairs.merge(nn_pairs, on=["solute", "site"], suffixes=("_dft", "_nn"))
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def si_s13d_fitting_accuracy(query_df_dft, query_df_nn, solvents, n_splits, solutes, nucleus="H",
|
| 498 |
+
method="dsd_pbep86", basis="pcSseg3", geometry="pbe0_tz", n_test=10):
|
| 499 |
+
"""Figure S13 panel D data: the semi-parsimonious composite model's (composite_models.py's
|
| 500 |
+
best_semi formula) test RMSE against experiment, for every solvent, split by molecular-dynamics
|
| 501 |
+
engine (Desmond vs OpenMM) and by the source of the explicit-solvent/vibrational term (DFT vs
|
| 502 |
+
MagNET-x, "NN"). Protons use the QCD rovibrational correction (stationary_plus_qcd), which the
|
| 503 |
+
SI text says is the same B3LYP/cc-pVDZ value for both DFT and NN bars; carbons use the classical
|
| 504 |
+
vibrational correction computed from the same engine's trajectories (stationary_plus_desmond_vib
|
| 505 |
+
or stationary_plus_openMM_vib), which does differ between the DFT and NN combined tables. Returns
|
| 506 |
+
a tidy DataFrame with one row per (engine, source, solvent, seed) and a test_RMSE column."""
|
| 507 |
+
engine_vib_column = {"desmond": "stationary_plus_des_vib", "openMM": "stationary_plus_op_vib"}
|
| 508 |
+
rows = []
|
| 509 |
+
for source_name, query_df in [("DFT", query_df_dft), ("NN", query_df_nn)]:
|
| 510 |
+
sub = query_df[query_df["nucleus"] == nucleus]
|
| 511 |
+
if source_name == "DFT":
|
| 512 |
+
sub = sub[(sub["sap_nmr_method"] == method) & (sub["sap_basis"] == basis)
|
| 513 |
+
& (sub["sap_geometry_type"] == geometry)]
|
| 514 |
+
sub = add_composite_columns(sub)
|
| 515 |
+
for engine in ("desmond", "openMM"):
|
| 516 |
+
base_term = "stationary_plus_qcd" if nucleus == "H" else engine_vib_column[engine]
|
| 517 |
+
formula = f"{base_term} + {engine}"
|
| 518 |
+
results = run_fits(sub, solvents, [formula], n_splits=n_splits, solutes=solutes,
|
| 519 |
+
n_test=n_test)
|
| 520 |
+
results["engine"] = engine
|
| 521 |
+
results["source"] = source_name
|
| 522 |
+
rows.append(results)
|
| 523 |
+
return pd.concat(rows, ignore_index=True)
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# ---------------------------------------------------------------------------
|
| 527 |
+
# Figure 2A / Figure S1 (the accuracy-vs-cost Pareto frontier)
|
| 528 |
+
|
| 529 |
+
# MagNET predicts the implicit-solvent (PCM) correction for chloroform only. To get a value for the
|
| 530 |
+
# other solvents, the chloroform correction is scaled by a per-solvent factor, fit on the delta-22
|
| 531 |
+
# DFT PCM corrections themselves: the slope (through the origin) of one method's PCM correction in
|
| 532 |
+
# that solvent against the reference method's PCM correction in chloroform. The reference (input)
|
| 533 |
+
# method is B3LYP-D3(BJ); the output method is the one MagNET was trained to reproduce (wP04 for
|
| 534 |
+
# proton, wB97X-D for carbon). These factors are also stored in applications.hdf5, but recomputing
|
| 535 |
+
# them here keeps Figure 2A self-contained within the delta-22 data.
|
| 536 |
+
MAGNET_PCM_INPUT_METHOD = "b3lyp_d3bj"
|
| 537 |
+
MAGNET_PCM_OUTPUT_METHODS = {"H": "wp04", "C": "wb97xd"}
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def pcm_conversion_factors(query_df_dft, nucleus, output_method=None,
|
| 541 |
+
input_method=MAGNET_PCM_INPUT_METHOD,
|
| 542 |
+
geometry="aimnet2", basis="pcSseg2"):
|
| 543 |
+
"""The per-solvent factor that scales MagNET's chloroform PCM correction to each solvent, for
|
| 544 |
+
one nucleus. Recomputed from the delta-22 DFT PCM corrections: for each solvent, the
|
| 545 |
+
through-the-origin slope of the output method's PCM correction in that solvent against the input
|
| 546 |
+
(reference) method's PCM correction in chloroform, over the shared sites. Returns a Series
|
| 547 |
+
indexed by solvent in the reader's native naming (water is "TIP4P"). Matches the factors stored
|
| 548 |
+
in applications.hdf5 to machine precision."""
|
| 549 |
+
if output_method is None:
|
| 550 |
+
output_method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
|
| 551 |
+
base = query_df_dft[(query_df_dft["sap_geometry_type"] == geometry)
|
| 552 |
+
& (query_df_dft["sap_basis"] == basis)
|
| 553 |
+
& (query_df_dft["nucleus"] == nucleus)]
|
| 554 |
+
reference = (base[(base["sap_nmr_method"] == input_method) & (base["solvent"] == "chloroform")]
|
| 555 |
+
.set_index(["solute", "site"])["pcm"])
|
| 556 |
+
output = base[base["sap_nmr_method"] == output_method]
|
| 557 |
+
factors = {}
|
| 558 |
+
for solvent in DESMOND_SOLVENTS:
|
| 559 |
+
y = output[output["solvent"] == solvent].set_index(["solute", "site"])["pcm"]
|
| 560 |
+
joined = pd.concat([reference.rename("x"), y.rename("y")], axis=1).dropna()
|
| 561 |
+
xv = joined["x"].to_numpy(dtype=float)
|
| 562 |
+
yv = joined["y"].to_numpy(dtype=float)
|
| 563 |
+
factors[solvent] = float((xv * yv).sum() / (xv * xv).sum()) if len(xv) else np.nan
|
| 564 |
+
return pd.Series(factors, name="pcm_conversion_factor")
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def add_nn_stationary_plus_pcm(query_df_nn, factors_by_nucleus):
|
| 568 |
+
"""Add the MagNET stationary_plus_pcm column to a flat NN query table: the gas-phase shielding
|
| 569 |
+
plus the chloroform PCM correction scaled by the per-solvent, per-nucleus factor from
|
| 570 |
+
pcm_conversion_factors. factors_by_nucleus maps "H"/"C" to those Series. Returns a copy."""
|
| 571 |
+
out = query_df_nn.copy()
|
| 572 |
+
factor = out.apply(lambda r: factors_by_nucleus[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
|
| 573 |
+
out["stationary_plus_pcm"] = out["stationary"] + out["pcm"] * factor
|
| 574 |
+
return out
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
# the columns the Pareto fit needs from each flat table, before the "sap_" prefix is stripped
|
| 578 |
+
_PARETO_COLUMNS = ["sap_geometry_type", "sap_nmr_method", "sap_basis", "nucleus", "site",
|
| 579 |
+
"solute", "solvent", "experimental", "stationary_plus_pcm"]
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
def assemble_pareto_fitting_df(query_df_dft, query_df_nn, conversion_factors=None,
|
| 583 |
+
exclude_solutes=("nitromethane",)):
|
| 584 |
+
"""Build the combined DFT + MagNET table the Pareto fit runs on: experimental shift against the
|
| 585 |
+
single stationary_plus_pcm predictor, for every method/basis/geometry/nucleus/solvent. The DFT
|
| 586 |
+
predictor is stationary + pcm; the MagNET predictor uses the scaled chloroform correction
|
| 587 |
+
(conversion_factors, recomputed from query_df_dft if not given). TIP4P is renamed to water and
|
| 588 |
+
the excluded solutes (nitromethane by default, an outlier) are removed. The DFT rows come first
|
| 589 |
+
so the solute order is stable for the seeded splits."""
|
| 590 |
+
if conversion_factors is None:
|
| 591 |
+
conversion_factors = {nuc: pcm_conversion_factors(query_df_dft, nuc) for nuc in ("H", "C")}
|
| 592 |
+
dft = query_df_dft.copy()
|
| 593 |
+
dft["stationary_plus_pcm"] = dft["stationary"] + dft["pcm"]
|
| 594 |
+
nn = add_nn_stationary_plus_pcm(query_df_nn, conversion_factors)
|
| 595 |
+
combined = pd.concat([dft[_PARETO_COLUMNS], nn[_PARETO_COLUMNS]], ignore_index=True)
|
| 596 |
+
combined = combined.rename(columns=lambda c: c.replace("sap_", ""))
|
| 597 |
+
combined["solvent"] = combined["solvent"].replace("TIP4P", "water")
|
| 598 |
+
combined["experimental"] = pd.to_numeric(combined["experimental"], errors="coerce")
|
| 599 |
+
if exclude_solutes:
|
| 600 |
+
combined = combined[~combined["solute"].isin(set(exclude_solutes))]
|
| 601 |
+
return combined.reset_index(drop=True)
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def pareto_solvent_averaged_rmse(fitting_df, solutes=None, n_splits=100, n_test=10,
|
| 605 |
+
random_state=1, formula="stationary_plus_pcm"):
|
| 606 |
+
"""For every (geometry, method, basis, nucleus, solvent), the mean test RMSE of the
|
| 607 |
+
experimental ~ stationary_plus_pcm fit over n_splits seeded per-solute splits, then a
|
| 608 |
+
solvent-averaged row per (geometry, method, basis, nucleus). This is the accuracy axis of
|
| 609 |
+
Figure 2A / Figure S1.
|
| 610 |
+
|
| 611 |
+
Figure 2A splits differently from the other figures: it trains on the first n_test shuffled
|
| 612 |
+
solutes and tests on the rest, seeding with random_state + split index (the other figures train
|
| 613 |
+
on the remainder and seed with +100); swap=True on _split_fit_scores gets that train-on-first-
|
| 614 |
+
n_test behavior. The per-split errors are averaged with the mean, not the median used elsewhere."""
|
| 615 |
+
if solutes is None:
|
| 616 |
+
solutes = fitting_df["solute"].unique().tolist()
|
| 617 |
+
group_cols = ["geometry_type", "nmr_method", "basis", "nucleus", "solvent"]
|
| 618 |
+
rows = []
|
| 619 |
+
partitions = _solute_partitions(n_splits, solutes, n_test, seed_offset=random_state)
|
| 620 |
+
for key, group_df in fitting_df.groupby(group_cols):
|
| 621 |
+
solvent = key[group_cols.index("solvent")]
|
| 622 |
+
scores = _split_fit_scores(group_df, solvent, [formula], partitions, swap=True)
|
| 623 |
+
errors = [scores.get((formula, seed), (np.nan, np.nan))[1] for seed in range(n_splits)]
|
| 624 |
+
with np.errstate(invalid="ignore"):
|
| 625 |
+
mean_err = float(np.nanmean(errors)) if errors else np.nan
|
| 626 |
+
rows.append({**dict(zip(group_cols, key)), "fitting_RMSE": mean_err})
|
| 627 |
+
raw = pd.DataFrame(rows)
|
| 628 |
+
averaged = (raw.groupby(["geometry_type", "nmr_method", "basis", "nucleus"])["fitting_RMSE"]
|
| 629 |
+
.mean().reset_index())
|
| 630 |
+
averaged["solvent"] = "solvent-averaged"
|
| 631 |
+
return pd.concat([raw, averaged], ignore_index=True)
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def load_pareto_timings(delta22_path):
|
| 635 |
+
"""The compute-time axis of Figure 2A: (dft_gas_timings, nn_timings). DFT timings are the
|
| 636 |
+
gas-phase ('none' solvent) total time per (geometry, method, basis); MagNET timings sum the NMR
|
| 637 |
+
time across its solvents and keep one geometry time. Reads the timing groups through the reader."""
|
| 638 |
+
from delta22_reader import load_delta22_dft_timings, load_delta22_nn_timings
|
| 639 |
+
dft = load_delta22_dft_timings(delta22_path)
|
| 640 |
+
dft.index = dft.index.set_names([n.replace("sap_", "") for n in dft.index.names])
|
| 641 |
+
dft = dft.droplevel("solvent_model")
|
| 642 |
+
dft_gas = dft[dft.index.get_level_values("solvent") == "none"].droplevel("solvent")
|
| 643 |
+
nn = load_delta22_nn_timings(delta22_path)
|
| 644 |
+
nn.index = nn.index.set_names([n.replace("sap_", "") for n in nn.index.names])
|
| 645 |
+
nn = nn.droplevel("solvent_model")
|
| 646 |
+
nn = nn.groupby(["geometry_type", "nmr_method", "basis"]).agg(
|
| 647 |
+
{"geometry_time": "first", "nmr_time": "sum"})
|
| 648 |
+
nn["total_time"] = nn["geometry_time"] + nn["nmr_time"]
|
| 649 |
+
return dft_gas, nn
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def attach_pareto_timings(rmse_df, dft_gas_timings, nn_timings):
|
| 653 |
+
"""Join the gas-phase DFT timings onto the per-method RMSE rows and substitute the MagNET
|
| 654 |
+
timings (which are not in the DFT gas table) for the MagNET rows. Returns rmse_df with
|
| 655 |
+
geometry_time / nmr_time / total_time columns added."""
|
| 656 |
+
out = rmse_df.join(dft_gas_timings, on=["geometry_type", "nmr_method", "basis"], how="left")
|
| 657 |
+
magnet = out["nmr_method"] == "MagNET"
|
| 658 |
+
nn_row = nn_timings.loc[("aimnet2", "MagNET", "N/A")]
|
| 659 |
+
out.loc[magnet, ["geometry_time", "nmr_time", "total_time"]] = \
|
| 660 |
+
nn_row[["geometry_time", "nmr_time", "total_time"]].to_numpy()
|
| 661 |
+
return out
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def fig2a_pareto_points(query_df_dft, query_df_nn, dft_gas_timings, nn_timings,
|
| 665 |
+
solutes=None, n_splits=100, n_test=10, random_state=1,
|
| 666 |
+
conversion_factors=None):
|
| 667 |
+
"""The full Figure 2A / Figure S1 table: one row per (geometry, method, basis, nucleus, solvent)
|
| 668 |
+
plus the solvent-averaged rows, each carrying its mean test RMSE and its total compute time.
|
| 669 |
+
Pass the flat DFT and NN query tables (build_query_df output) and the two timing tables from
|
| 670 |
+
load_pareto_timings. The frontier for a given nucleus is pareto_frontier on the
|
| 671 |
+
solvent-averaged rows with x="total_time", y="fitting_RMSE"."""
|
| 672 |
+
fitting = assemble_pareto_fitting_df(query_df_dft, query_df_nn, conversion_factors)
|
| 673 |
+
rmse = pareto_solvent_averaged_rmse(fitting, solutes, n_splits=n_splits, n_test=n_test,
|
| 674 |
+
random_state=random_state)
|
| 675 |
+
return attach_pareto_timings(rmse, dft_gas_timings, nn_timings)
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
def pareto_table_curated(points_df, nucleus, solvent="chloroform"):
|
| 679 |
+
"""SI Tables S1 (1H, nucleus="H") and S2 (13C, nucleus="C"): the curated 55-row subset of
|
| 680 |
+
fig2a_pareto_points' full cross product that the source tables print. fig2a_pareto_points (and
|
| 681 |
+
the SI Figure S1 scatter built from it) wants every aimnet2-geometry method/basis combination,
|
| 682 |
+
each an unlabeled point on the plot; the printed tables instead keep only MagNET plus a single
|
| 683 |
+
representative aimnet2-geometry DFT row (the same reference method each nucleus's MagNET-Zero
|
| 684 |
+
output is trained against, MAGNET_PCM_OUTPUT_METHODS, at pcSseg2), alongside the full
|
| 685 |
+
pbe0_tz-geometry grid (18 methods x 3 basis sets, minus mp2/pcSseg3 which was never computed, so
|
| 686 |
+
53 rows). Sorted by total_time ascending to match the source table's row order."""
|
| 687 |
+
sub = points_df[(points_df["nucleus"] == nucleus) & (points_df["solvent"] == solvent)]
|
| 688 |
+
aimnet2_method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
|
| 689 |
+
keep = (
|
| 690 |
+
(sub["geometry_type"] == "pbe0_tz")
|
| 691 |
+
| (sub["nmr_method"] == "MagNET")
|
| 692 |
+
| ((sub["geometry_type"] == "aimnet2") & (sub["nmr_method"] == aimnet2_method)
|
| 693 |
+
& (sub["basis"] == "pcSseg2"))
|
| 694 |
+
)
|
| 695 |
+
return sub[keep].sort_values("total_time").reset_index(drop=True)
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def pareto_frontier(points_df, x="total_time", y="test_RMSE"):
|
| 699 |
+
"""Return the rows of points_df on the lower-left Pareto frontier, i.e. the points for which
|
| 700 |
+
no other point has both a smaller x (compute time) and a smaller y (error). Sorted by x.
|
| 701 |
+
"""
|
| 702 |
+
ordered = points_df.sort_values([x, y]).reset_index(drop=True)
|
| 703 |
+
frontier_rows = []
|
| 704 |
+
best_y = np.inf
|
| 705 |
+
for _, row in ordered.iterrows():
|
| 706 |
+
if row[y] < best_y:
|
| 707 |
+
frontier_rows.append(row)
|
| 708 |
+
best_y = row[y]
|
| 709 |
+
return pd.DataFrame(frontier_rows).reset_index(drop=True)
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
# ---------------------------------------------------------------------------
|
| 713 |
+
# correlation matrices (Figure 2B / Figure S4)
|
| 714 |
+
|
| 715 |
+
def correlation_matrix(query_df, value_col, sites_index, column_var):
|
| 716 |
+
"""Correlation matrix of value_col across the levels of column_var. Rows are the shared sites
|
| 717 |
+
(sites_index, e.g. ["solute", "site"]); columns are the column_var values (e.g. "solvent" or
|
| 718 |
+
"sap_nmr_method"). Returns a square DataFrame of Pearson correlations between the columns,
|
| 719 |
+
computed on the sites where both columns have a value."""
|
| 720 |
+
pivot = query_df.pivot_table(index=sites_index, columns=column_var, values=value_col)
|
| 721 |
+
return pivot.corr()
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
# SI Figure S15 "Correlations Between Features": correlations among the composite features within
|
| 725 |
+
# one nucleus and solvent.
|
| 726 |
+
FEATURE_CORRELATION_COLUMNS = ("stationary", "pcm", "desmond", "desmond_vib", "qcd")
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def feature_correlation_matrix(query_df, nucleus, solvent, cols=FEATURE_CORRELATION_COLUMNS, squared=False):
|
| 730 |
+
"""Pearson correlation matrix (R^2 if squared=True) between the feature columns, within one
|
| 731 |
+
nucleus and solvent, over sites where both features have a value. query_df must be filtered to a
|
| 732 |
+
single (sap_nmr_method, sap_basis, sap_geometry_type); si_s15_feature_correlations does that."""
|
| 733 |
+
sub = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == solvent)][list(cols)]
|
| 734 |
+
sub = sub.dropna(how="all")
|
| 735 |
+
if len(sub) < 2:
|
| 736 |
+
return pd.DataFrame(np.nan, index=cols, columns=cols)
|
| 737 |
+
corr = sub.corr(method="pearson")
|
| 738 |
+
return corr ** 2 if squared else corr
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
def average_feature_correlation_matrix(query_df, nucleus, solvents, cols=FEATURE_CORRELATION_COLUMNS,
|
| 742 |
+
squared=False):
|
| 743 |
+
"""Cell-by-cell nanmean of feature_correlation_matrix over the solvents (SI Figure S15's
|
| 744 |
+
solvent-averaged matrix). nanmean so a solvent with too few sites for one feature pair drops out
|
| 745 |
+
of that cell alone, not the whole average. Same single-level-of-theory requirement."""
|
| 746 |
+
mats = [feature_correlation_matrix(query_df, nucleus, s, cols, squared) for s in solvents]
|
| 747 |
+
stacked = np.stack([m.to_numpy() for m in mats])
|
| 748 |
+
with np.errstate(invalid="ignore"):
|
| 749 |
+
avg = np.nanmean(stacked, axis=0)
|
| 750 |
+
return pd.DataFrame(avg, index=mats[0].index, columns=mats[0].columns)
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
def si_s15_feature_correlations(query_df_dft, nucleus, solvents=None):
|
| 754 |
+
"""Solvent-averaged feature correlations for one nucleus from the unfiltered
|
| 755 |
+
load_query_df_dft(...) output: filters to the reference level
|
| 756 |
+
(MAGNET_PCM_OUTPUT_METHODS[nucleus], pcSseg2, aimnet2), then returns {"r": Pearson R matrix,
|
| 757 |
+
"r2": R^2 matrix}. The single-level filter is required; pooling levels of theory gives wrong
|
| 758 |
+
numbers."""
|
| 759 |
+
solvents = list(solvents) if solvents is not None else sorted(query_df_dft["solvent"].unique())
|
| 760 |
+
method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
|
| 761 |
+
sub = query_df_dft[(query_df_dft["nucleus"] == nucleus) & (query_df_dft["sap_nmr_method"] == method)
|
| 762 |
+
& (query_df_dft["sap_basis"] == "pcSseg2") & (query_df_dft["sap_geometry_type"] == "aimnet2")]
|
| 763 |
+
return {"r": average_feature_correlation_matrix(sub, nucleus, solvents),
|
| 764 |
+
"r2": average_feature_correlation_matrix(sub, nucleus, solvents, squared=True)}
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def pcm_desmond_correlation_by_solvent(query_df_dft, solvents=None):
|
| 768 |
+
"""SI Figure S15's PCM-vs-Desmond table: per nucleus and solvent, the Pearson correlation
|
| 769 |
+
between the PCM (implicit) and Desmond (explicit) corrections. Filters to the reference level
|
| 770 |
+
per nucleus itself (same as si_s15_feature_correlations). Returns a DataFrame indexed by
|
| 771 |
+
nucleus, one column per solvent."""
|
| 772 |
+
solvents = list(solvents) if solvents is not None else sorted(query_df_dft["solvent"].unique())
|
| 773 |
+
rows = {}
|
| 774 |
+
for nucleus in ("H", "C"):
|
| 775 |
+
method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
|
| 776 |
+
nuc_df = query_df_dft[(query_df_dft["nucleus"] == nucleus) & (query_df_dft["sap_nmr_method"] == method)
|
| 777 |
+
& (query_df_dft["sap_basis"] == "pcSseg2") & (query_df_dft["sap_geometry_type"] == "aimnet2")]
|
| 778 |
+
row = {}
|
| 779 |
+
for solvent in solvents:
|
| 780 |
+
sub = nuc_df[nuc_df["solvent"] == solvent][["pcm", "desmond"]].dropna()
|
| 781 |
+
row[solvent] = float(sub["pcm"].corr(sub["desmond"])) if len(sub) > 1 else float("nan")
|
| 782 |
+
rows[nucleus] = row
|
| 783 |
+
return pd.DataFrame(rows).T
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
# ---------------------------------------------------------------------------
|
| 787 |
+
# PCM benefit (Figure 3A / Figure S5): how much a correction reduces test error
|
| 788 |
+
|
| 789 |
+
def median_test_rmse(results_df, by):
|
| 790 |
+
"""Median test_RMSE grouped by the given columns (e.g. ["solvent", "formula"] or
|
| 791 |
+
["sap_nmr_method", "formula"]). A small convenience used by several figures."""
|
| 792 |
+
return results_df.groupby(by)["test_RMSE"].median().reset_index()
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
# ---------------------------------------------------------------------------
|
| 796 |
+
# Figure 3 panel data (Explicit Solvation is Critical for Accuracy)
|
| 797 |
+
|
| 798 |
+
# the three solvent classes Figure 3 / S6 group by
|
| 799 |
+
SOLVENT_GROUPS = {
|
| 800 |
+
"Polar Aprotic": ["chloroform", "dichloromethane", "tetrahydrofuran",
|
| 801 |
+
"acetonitrile", "dimethylsulfoxide", "acetone"],
|
| 802 |
+
"Polar Protic": ["methanol", "TIP4P", "trifluoroethanol"],
|
| 803 |
+
"Aromatic": ["benzene", "toluene", "chlorobenzene"],
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def fig3a_pcm_benefit(query_df_dft, solvents, n_splits, solutes, nucleus="H", n_test=10):
|
| 808 |
+
"""Panel 3A data: for every DFT method/basis/geometry, the seeded test RMSE with and without the
|
| 809 |
+
implicit-solvent term ("stationary" vs the single-column "stationary_plus_pcm"). The boxplot
|
| 810 |
+
shows how much the PCM term lowers error across methods. One nucleus at a time (proton and
|
| 811 |
+
carbon shieldings sit on different scales, so they are never pooled in a fit). Pass a query_df
|
| 812 |
+
that has been through add_composite_columns. Returns the tidy run_fits results (one row per
|
| 813 |
+
method/solvent/formula/seed)."""
|
| 814 |
+
sub = query_df_dft[query_df_dft["nucleus"] == nucleus]
|
| 815 |
+
return run_fits(sub, solvents, ["stationary", "stationary_plus_pcm"],
|
| 816 |
+
n_splits=n_splits, solutes=solutes,
|
| 817 |
+
group_cols=["sap_nmr_method", "sap_basis", "sap_geometry_type"], n_test=n_test)
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
def fig3a_pcm_benefit_by_solvent(query_df_dft, solvents, n_splits, solutes, nucleus="H", n_test=10):
|
| 821 |
+
"""Panel 3A data, split by solvent: for every DFT method and solvent, the per-split percent
|
| 822 |
+
benefit of adding the implicit-solvent (PCM) term, 100 * (stationary - stationary_plus_pcm) /
|
| 823 |
+
stationary. A positive value means PCM lowered test RMSE for that split; a negative value means
|
| 824 |
+
PCM made it worse. Unlike fig3a_pcm_benefit (which reports raw RMSE pooled across solvents),
|
| 825 |
+
this keeps one row per (method, solvent, seed), so a solvent such as benzene can be shown
|
| 826 |
+
separately from chloroform and the aromatic-solvent-specific PCM penalty stays visible instead
|
| 827 |
+
of being averaged away."""
|
| 828 |
+
results = fig3a_pcm_benefit(query_df_dft, solvents, n_splits, solutes,
|
| 829 |
+
nucleus=nucleus, n_test=n_test)
|
| 830 |
+
pivot = results.pivot_table(
|
| 831 |
+
index=["sap_nmr_method", "sap_basis", "sap_geometry_type", "solvent", "seed"],
|
| 832 |
+
columns="formula", values="test_RMSE").reset_index()
|
| 833 |
+
pivot["percent_benefit"] = (100.0 * (pivot["stationary"] - pivot["stationary_plus_pcm"])
|
| 834 |
+
/ pivot["stationary"])
|
| 835 |
+
return pivot
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
def fig3b_shift_differences(query_df_dft, method, basis, geometry, nucleus="H",
|
| 839 |
+
x_solvent="methanol", y_solvent="benzene", reference="chloroform"):
|
| 840 |
+
"""Panel 3B data: per site, the experimental solvent-induced shift differences relative to a
|
| 841 |
+
reference solvent (x = shift in x_solvent minus reference, y = shift in y_solvent minus
|
| 842 |
+
reference) and the implicit-solvent (PCM) prediction of the same differences. One DFT method.
|
| 843 |
+
The scatter compares whether PCM reproduces the measured solvent-to-solvent differences."""
|
| 844 |
+
sub = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
|
| 845 |
+
& (query_df_dft["sap_basis"] == basis)
|
| 846 |
+
& (query_df_dft["sap_geometry_type"] == geometry)
|
| 847 |
+
& (query_df_dft["nucleus"] == nucleus)]
|
| 848 |
+
cols = ["solute", "site", "experimental", "pcm"]
|
| 849 |
+
ref = sub[sub["solvent"] == reference][cols]
|
| 850 |
+
xs = sub[sub["solvent"] == x_solvent][cols]
|
| 851 |
+
ys = sub[sub["solvent"] == y_solvent][cols]
|
| 852 |
+
merged = (xs.merge(ref, on=["solute", "site"], suffixes=("_x", "_ref"))
|
| 853 |
+
.merge(ys, on=["solute", "site"]))
|
| 854 |
+
merged.columns = ["solute", "site", "exp_x", "pcm_x", "exp_ref", "pcm_ref", "exp_y", "pcm_y"]
|
| 855 |
+
merged["exp_diff_x"] = merged["exp_x"] - merged["exp_ref"]
|
| 856 |
+
merged["exp_diff_y"] = merged["exp_y"] - merged["exp_ref"]
|
| 857 |
+
merged["pcm_diff_x"] = merged["pcm_x"] - merged["pcm_ref"]
|
| 858 |
+
merged["pcm_diff_y"] = merged["pcm_y"] - merged["pcm_ref"]
|
| 859 |
+
return merged.dropna(subset=["exp_diff_x", "exp_diff_y"]).reset_index(drop=True)
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
def fig3d_formula_regressions(query_df_dft, method, basis, geometry, formulas, solvents,
|
| 863 |
+
n_splits, solutes, nucleus="H", n_test=10):
|
| 864 |
+
"""Panel 3D data: for one DFT method, the seeded test RMSE of an implicit-vs-explicit formula
|
| 865 |
+
ladder per solvent. Pass a query_df that has been through add_composite_columns. Returns the
|
| 866 |
+
tidy run_fits results (one row per solvent/formula/seed)."""
|
| 867 |
+
one = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
|
| 868 |
+
& (query_df_dft["sap_basis"] == basis)
|
| 869 |
+
& (query_df_dft["sap_geometry_type"] == geometry)
|
| 870 |
+
& (query_df_dft["nucleus"] == nucleus)]
|
| 871 |
+
return run_fits(one, solvents, formulas, n_splits=n_splits, solutes=solutes, n_test=n_test)
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
# ---------------------------------------------------------------------------
|
| 875 |
+
# Figure 4 + SI S16/S17/S18 (solvent-induced shifts: experiment vs prediction)
|
| 876 |
+
|
| 877 |
+
def add_solvent_mean(query_df, group_cols=("solute", "site", "nucleus")):
|
| 878 |
+
"""Append a 'solvent_mean' pseudo-solvent: for each site, the mean over the real solvents of the
|
| 879 |
+
numeric columns. Used as the solvent-averaged reference for Figure S18. Returns a new DataFrame
|
| 880 |
+
with the extra rows."""
|
| 881 |
+
means = query_df.groupby(list(group_cols)).mean(numeric_only=True).reset_index()
|
| 882 |
+
means["solvent"] = "solvent_mean"
|
| 883 |
+
return pd.concat([query_df, means], ignore_index=True)
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def solvent_pair_differences(query_df, check_solvent, reference_solvent, nucleus="H",
|
| 887 |
+
explicit="desmond"):
|
| 888 |
+
"""Per (solute, site), the experimental and predicted correction differences between one solvent
|
| 889 |
+
and a reference solvent (Figure 4): the experimental difference is reference minus check, and
|
| 890 |
+
each correction difference (implicit PCM, explicit, and explicit+vibrations) is check minus
|
| 891 |
+
reference. One DFT method's columns must already be selected."""
|
| 892 |
+
a = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == check_solvent)]
|
| 893 |
+
b = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == reference_solvent)]
|
| 894 |
+
merged = a.merge(b, on=["solute", "site"], suffixes=(f"_{check_solvent}", f"_{reference_solvent}"))
|
| 895 |
+
c, r, e = check_solvent, reference_solvent, explicit
|
| 896 |
+
out = pd.DataFrame({
|
| 897 |
+
"solute": merged["solute"], "site": merged["site"],
|
| 898 |
+
"reference": reference_solvent, "solvent": check_solvent,
|
| 899 |
+
"exp_diff": merged[f"experimental_{r}"] - merged[f"experimental_{c}"],
|
| 900 |
+
"implicit_diff": merged[f"pcm_{c}"] - merged[f"pcm_{r}"],
|
| 901 |
+
"explicit_diff": merged[f"{e}_{c}"] - merged[f"{e}_{r}"],
|
| 902 |
+
"explicit_vib_diff": ((merged[f"{e}_{c}"] + merged[f"{e}_vib_{c}"])
|
| 903 |
+
- (merged[f"{e}_{r}"] + merged[f"{e}_vib_{r}"])),
|
| 904 |
+
})
|
| 905 |
+
return out.dropna(subset=["exp_diff"]).reset_index(drop=True)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def solvent_induced_shifts(query_df, reference, solvents, nucleus="H", explicit="desmond"):
|
| 909 |
+
"""The full set of solvent-vs-reference differences for one reference solvent (the data behind
|
| 910 |
+
Figure S16 with chloroform, S17 with benzene, S18 with solvent_mean): every solvent except the
|
| 911 |
+
reference, stacked. query_df should already include the solvent_mean rows if reference is
|
| 912 |
+
'solvent_mean' (see add_solvent_mean)."""
|
| 913 |
+
frames = [solvent_pair_differences(query_df, solvent, reference, nucleus, explicit)
|
| 914 |
+
for solvent in solvents if solvent != reference]
|
| 915 |
+
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
def fit_differences_to_experimental(diff_df, predicted_col):
|
| 919 |
+
"""Fit exp_diff = slope * predicted_col + intercept (the Figure 4C fit of the experimental
|
| 920 |
+
solvent-induced shift differences to a correction's predicted differences) and return
|
| 921 |
+
{slope, intercept, rmse, n}. Rows missing either value are dropped."""
|
| 922 |
+
data = diff_df.dropna(subset=["exp_diff", predicted_col])
|
| 923 |
+
x = data[predicted_col].to_numpy(dtype=float)
|
| 924 |
+
y = data["exp_diff"].to_numpy(dtype=float)
|
| 925 |
+
intercept, slope = linear_fit_1d(x, y)
|
| 926 |
+
pred = intercept + slope * x
|
| 927 |
+
return {"intercept": intercept, "slope": slope, "rmse": rmse(pred, y), "n": int(len(x))}
|
analysis/code/dft8k_residuals.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MagNET-Zero vs DFT residuals on the DFT8K benchmark (~7,000 organics, out of sample). No plotting.
|
| 2 |
+
|
| 3 |
+
Each nucleus is compared against the level MagNET-Zero reproduces: protons vs WP04/pcSseg-2, carbons
|
| 4 |
+
vs wB97X-D/pcSseg-2, gas phase on AIMNet2 geometries; residual = DFT shielding minus MagNET. Read via
|
| 5 |
+
data/dft8k/dft8k_reader.py. The figure notebook and tests import from here.
|
| 6 |
+
"""
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from stats import summarize_errors
|
| 10 |
+
|
| 11 |
+
# the DFT level each MagNET-Zero model reproduces, and the element it predicts
|
| 12 |
+
NUCLEI = {
|
| 13 |
+
"H": {"atomic_number": 1, "dft": "wp04_pcSseg2", "nn": "nn_wp04_pcSseg2"},
|
| 14 |
+
"C": {"atomic_number": 6, "dft": "wb97xd_pcSseg2", "nn": "nn_wb97xd_pcSseg2"},
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_shieldings(dft8k_path):
|
| 19 |
+
"""The flat per-atom shielding arrays from the DFT8K AIMNet2 group, through the released
|
| 20 |
+
reader. Returns the reader's all_shieldings dict (atomic_numbers plus the DFT and MagNET
|
| 21 |
+
shieldings, each ppm, NaN where not computed)."""
|
| 22 |
+
from dft8k_reader import DFT8k
|
| 23 |
+
with DFT8k(dft8k_path) as ds:
|
| 24 |
+
return ds.aimnet2.all_shieldings()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def residuals(shieldings, nucleus):
|
| 28 |
+
"""The DFT-minus-MagNET shielding residuals (ppm) for one nucleus, over the atoms of that
|
| 29 |
+
element where MagNET makes a prediction. shieldings is the load_shieldings dict; nucleus is
|
| 30 |
+
"H" or "C". Atoms the model does not predict (its prediction is NaN) are dropped."""
|
| 31 |
+
spec = NUCLEI[nucleus]
|
| 32 |
+
z = np.asarray(shieldings["atomic_numbers"])
|
| 33 |
+
dft = np.asarray(shieldings[spec["dft"]], dtype=float)
|
| 34 |
+
nn = np.asarray(shieldings[spec["nn"]], dtype=float)
|
| 35 |
+
keep = (z == spec["atomic_number"]) & np.isfinite(dft) & np.isfinite(nn)
|
| 36 |
+
return dft[keep] - nn[keep]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def residual_stats(errors, small_threshold=0.1):
|
| 40 |
+
"""Summary statistics of a residual array: count, RMSE, MAE, the 95th percentile of the
|
| 41 |
+
absolute error, and the fraction of atoms whose absolute error is below small_threshold ppm
|
| 42 |
+
(the paper reports >95% of proton sites below 0.1 ppm)."""
|
| 43 |
+
errors = np.asarray(errors, dtype=float)
|
| 44 |
+
abs_err = np.abs(errors)
|
| 45 |
+
if not errors.size:
|
| 46 |
+
return {"n": 0, "rmse": float("nan"), "mae": float("nan"), "median_ae": float("nan"),
|
| 47 |
+
"abs_p95": float("nan"), "frac_below": float("nan")}
|
| 48 |
+
stats = summarize_errors(errors, np.zeros_like(errors))
|
| 49 |
+
stats["abs_p95"] = float(np.quantile(abs_err, 0.95))
|
| 50 |
+
stats["frac_below"] = float(np.mean(abs_err < small_threshold))
|
| 51 |
+
return stats
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def residual_summary(dft8k_path, small_threshold=0.1):
|
| 55 |
+
"""Load the DFT8K shieldings and return {nucleus: residual_stats} for both nuclei."""
|
| 56 |
+
shieldings = load_shieldings(dft8k_path)
|
| 57 |
+
return {nucleus: residual_stats(residuals(shieldings, nucleus), small_threshold)
|
| 58 |
+
for nucleus in NUCLEI}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------------------------------------------------------------------------
|
| 62 |
+
# Figure 5B's molecule-level panels: the two extreme-residual callouts and the six functional-
|
| 63 |
+
# group boxes. Unlike the whole-dataset histogram above, these need each molecule's SMILES (to
|
| 64 |
+
# find/classify structures), so they read the reader's per-molecule API instead of all_shieldings.
|
| 65 |
+
|
| 66 |
+
def iter_molecule_residuals(dft8k_path, nucleus):
|
| 67 |
+
"""Yield (molecule_index, molecule_id, smiles, residuals) for every DFT8K molecule, where
|
| 68 |
+
residuals is that molecule's DFT-minus-MagNET residual array (ppm) for its atoms of the given
|
| 69 |
+
nucleus (empty if the molecule has none of that element, or MagNET made no prediction there).
|
| 70 |
+
Molecules with no SMILES on file (the literal "none") are still yielded; callers that need a
|
| 71 |
+
parseable structure should skip those themselves."""
|
| 72 |
+
from dft8k_reader import DFT8k
|
| 73 |
+
spec = NUCLEI[nucleus]
|
| 74 |
+
with DFT8k(dft8k_path) as ds:
|
| 75 |
+
g = ds.aimnet2
|
| 76 |
+
# One bulk read of the flat per-atom arrays, then slice per molecule using the group's own
|
| 77 |
+
# precomputed atom offsets (g._start). Calling g.molecule(i) per molecule instead does
|
| 78 |
+
# several small h5py reads each for all ~7,000 molecules, ~260x slower for identical data.
|
| 79 |
+
flat = g.all_shieldings()
|
| 80 |
+
z_all, dft_all, nn_all = flat["atomic_numbers"], flat[spec["dft"]], flat[spec["nn"]]
|
| 81 |
+
start = g._start
|
| 82 |
+
for i in range(g.n_molecules):
|
| 83 |
+
sl = slice(int(start[i]), int(start[i + 1]))
|
| 84 |
+
z, dft, nn = z_all[sl], dft_all[sl], nn_all[sl]
|
| 85 |
+
keep = (z == spec["atomic_number"]) & np.isfinite(dft) & np.isfinite(nn)
|
| 86 |
+
yield i, int(g.molecule_ids[i]), g.smiles(i), (dft[keep] - nn[keep])
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def find_extreme_residual(dft8k_path, nucleus, sign="max"):
|
| 90 |
+
"""The single most extreme DFT-minus-MagNET residual anywhere in the DFT8K set for one
|
| 91 |
+
nucleus: the largest positive residual if sign="max", or the most negative if sign="min". The
|
| 92 |
+
exact extremum over the full released test set (not a random sample), so it is deterministic.
|
| 93 |
+
For 1H, sign="max" reproduces the published figure's positive-tail callout exactly (a porphyrin
|
| 94 |
+
ring, +17.62 ppm). sign="min" gives a different, chemically unremarkable ester outlier at
|
| 95 |
+
-4.06 ppm rather than the figure's negative-tail callout (a sulfonium zwitterion, -1.04 ppm):
|
| 96 |
+
that callout is a hand-picked illustrative example, not the strict extremum. See
|
| 97 |
+
molecule_by_id/find_extreme_zwitterion_residual for how that specific example was located.
|
| 98 |
+
Returns a dict with the parent molecule's id/smiles and the residual value, or None if no atom
|
| 99 |
+
of that nucleus has a prediction anywhere in the set."""
|
| 100 |
+
if sign not in ("max", "min"):
|
| 101 |
+
raise ValueError(f"sign must be 'max' or 'min', got {sign!r}")
|
| 102 |
+
best = None
|
| 103 |
+
for i, mol_id, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
|
| 104 |
+
if res.size == 0:
|
| 105 |
+
continue
|
| 106 |
+
val = float(res.max()) if sign == "max" else float(res.min())
|
| 107 |
+
if best is None or (sign == "max" and val > best["residual"]) or (sign == "min" and val < best["residual"]):
|
| 108 |
+
best = {"molecule_index": i, "molecule_id": int(mol_id), "smiles": smiles, "residual": val}
|
| 109 |
+
return best
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def molecule_by_id(dft8k_path, nucleus, molecule_id):
|
| 113 |
+
"""The residual data for one specific DFT8K molecule, by its integer id. Used to look up
|
| 114 |
+
Figure 5B's sulfonium-zwitterion callout (id 88779, identified by brute-force scanning the
|
| 115 |
+
most-negative 1H residuals for a match to the published -1.040 ppm value -- see
|
| 116 |
+
find_extreme_zwitterion_residual for the automated version of that search) once its id is
|
| 117 |
+
known, without re-scanning the whole dataset. Returns the same dict shape as
|
| 118 |
+
find_extreme_residual (residual is that molecule's single most negative residual for the given
|
| 119 |
+
nucleus), or None if the molecule has no atom of that nucleus with a prediction."""
|
| 120 |
+
for i, mol_id, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
|
| 121 |
+
if mol_id == molecule_id:
|
| 122 |
+
if res.size == 0:
|
| 123 |
+
return None
|
| 124 |
+
return {"molecule_index": i, "molecule_id": int(mol_id), "smiles": smiles,
|
| 125 |
+
"residual": float(res.min())}
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def find_extreme_zwitterion_residual(dft8k_path, nucleus, sign="min"):
|
| 130 |
+
"""Like find_extreme_residual, but restricted to molecules containing BOTH a formally
|
| 131 |
+
positively-charged sulfur ([S+]) and a formally negatively-charged atom ([-]) -- i.e.
|
| 132 |
+
sulfonium zwitterions. Even this narrower filter does not land on the same molecule as the
|
| 133 |
+
published callout: a different, more negative sulfonium zwitterion exists in the set. Provided
|
| 134 |
+
for exploration, not as an exact reproduction. The published example itself is available via
|
| 135 |
+
molecule_by_id(..., molecule_id=88779)."""
|
| 136 |
+
from rdkit import Chem
|
| 137 |
+
if sign not in ("max", "min"):
|
| 138 |
+
raise ValueError(f"sign must be 'max' or 'min', got {sign!r}")
|
| 139 |
+
pos_s = Chem.MolFromSmarts("[S+]")
|
| 140 |
+
neg = Chem.MolFromSmarts("[-]")
|
| 141 |
+
best = None
|
| 142 |
+
for i, mol_id, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
|
| 143 |
+
if res.size == 0 or smiles in _UNPARSEABLE_SMILES:
|
| 144 |
+
continue
|
| 145 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 146 |
+
if mol is None or not (mol.HasSubstructMatch(pos_s) and mol.HasSubstructMatch(neg)):
|
| 147 |
+
continue
|
| 148 |
+
val = float(res.max()) if sign == "max" else float(res.min())
|
| 149 |
+
if best is None or (sign == "max" and val > best["residual"]) or (sign == "min" and val < best["residual"]):
|
| 150 |
+
best = {"molecule_index": i, "molecule_id": int(mol_id), "smiles": smiles, "residual": val}
|
| 151 |
+
return best
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# A handful of DFT8K molecules have no usable SMILES: some store the literal "none" (documented in
|
| 155 |
+
# dft8k_reader.py), and a few others store the literal "nan" (an apparent upstream data quirk). Both
|
| 156 |
+
# are skipped before ever reaching RDKit, which would otherwise print a harmless but noisy
|
| 157 |
+
# parse-error message to stderr for each one.
|
| 158 |
+
_UNPARSEABLE_SMILES = {"none", "nan", ""}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# SMARTS patterns for the six functional groups the published figure highlights. These identify
|
| 162 |
+
# whether a molecule CONTAINS the group at all (a molecule-level classification), not which atom
|
| 163 |
+
# specifically carries it: the released dft8k.hdf5 stores atoms in whatever order the original DFT
|
| 164 |
+
# input used, not SMILES parse order, so a matched substructure cannot be reliably mapped back to
|
| 165 |
+
# one specific stored atom without extra data. Averaging over every atom of the nucleus in every
|
| 166 |
+
# matching molecule is the robust alternative used here.
|
| 167 |
+
FUNCTIONAL_GROUP_SMARTS = {
|
| 168 |
+
"Carbonyls": "[CX3]=[OX1]",
|
| 169 |
+
"Amines": "[NX3;H2,H1,H0;!$(NC=O);!$(N=[!#6]);!a]",
|
| 170 |
+
"Sulfonyl": "[SX4](=[OX1])(=[OX1])",
|
| 171 |
+
"Pyridines": "[n;r6]",
|
| 172 |
+
"Furans": "[o;r5]",
|
| 173 |
+
"Nitroso": "[NX2]=[OX1]",
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def functional_group_errors(dft8k_path, nucleus, patterns=None):
|
| 178 |
+
"""Mean absolute DFT-minus-MagNET residual (ppm), for every atom of `nucleus`, in every DFT8K
|
| 179 |
+
molecule whose SMILES matches each functional-group SMARTS pattern (default
|
| 180 |
+
FUNCTIONAL_GROUP_SMARTS). A molecule can match more than one group (e.g. a pyridine bearing a
|
| 181 |
+
carbonyl counts in both), matching the natural reading of the published panel's per-group boxes
|
| 182 |
+
as independent slices of the same dataset, not a mutually exclusive partition.
|
| 183 |
+
|
| 184 |
+
Returns {group_name: {"mean_abs_error": ppm, "n_molecules": matching molecule count,
|
| 185 |
+
"n_atoms": total atoms of this nucleus pooled across those molecules}}. A group with no matches
|
| 186 |
+
in the released set gets NaN/0/0.
|
| 187 |
+
"""
|
| 188 |
+
from rdkit import Chem
|
| 189 |
+
if patterns is None:
|
| 190 |
+
patterns = FUNCTIONAL_GROUP_SMARTS
|
| 191 |
+
compiled = {name: Chem.MolFromSmarts(smarts) for name, smarts in patterns.items()}
|
| 192 |
+
for name, patt in compiled.items():
|
| 193 |
+
if patt is None:
|
| 194 |
+
raise ValueError(f"invalid SMARTS for group {name!r}: {patterns[name]!r}")
|
| 195 |
+
pooled_residuals = {name: [] for name in patterns}
|
| 196 |
+
n_molecules = {name: 0 for name in patterns}
|
| 197 |
+
for _, _, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
|
| 198 |
+
if smiles in _UNPARSEABLE_SMILES or res.size == 0:
|
| 199 |
+
continue
|
| 200 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 201 |
+
if mol is None:
|
| 202 |
+
continue
|
| 203 |
+
for name, patt in compiled.items():
|
| 204 |
+
if mol.HasSubstructMatch(patt):
|
| 205 |
+
pooled_residuals[name].append(res)
|
| 206 |
+
n_molecules[name] += 1
|
| 207 |
+
out = {}
|
| 208 |
+
for name in patterns:
|
| 209 |
+
chunks = pooled_residuals[name]
|
| 210 |
+
if chunks:
|
| 211 |
+
all_res = np.concatenate(chunks)
|
| 212 |
+
out[name] = {"mean_abs_error": float(np.mean(np.abs(all_res))),
|
| 213 |
+
"n_molecules": n_molecules[name], "n_atoms": int(all_res.size)}
|
| 214 |
+
else:
|
| 215 |
+
out[name] = {"mean_abs_error": float("nan"), "n_molecules": 0, "n_atoms": 0}
|
| 216 |
+
return out
|
analysis/code/diels_alder.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SI Figure S2: literature Diels-Alder barriers for ethylene + butadiene.
|
| 2 |
+
|
| 3 |
+
Published quantum-chemistry predictions span ~15-45 kcal/mol (experiment ~23.3). The barriers are a
|
| 4 |
+
literature compilation (paper reference 26); the table below is the data (no dataset/reader). This
|
| 5 |
+
file holds the table and its method-family grouping; the bar chart is drawn inline in the notebook.
|
| 6 |
+
"""
|
| 7 |
+
# experimental activation barrier for the ethylene + butadiene Diels-Alder reaction, kcal/mol
|
| 8 |
+
EXPERIMENTAL_BARRIER = 23.3
|
| 9 |
+
|
| 10 |
+
# (method // geometry, predicted barrier in kcal/mol), compiled from the literature (reference 26).
|
| 11 |
+
# The label before "//" is the energy method; it sets the bar's group and label.
|
| 12 |
+
DATA = [
|
| 13 |
+
("HF/3-21G//HF/3-21G", 35.9),
|
| 14 |
+
("HF/6-31G*//HF/6-31G*", 45.0),
|
| 15 |
+
("HF/6-31G**//HF/6-31G**", 45.9),
|
| 16 |
+
("CASSCF(6,6)/3-21G// CASSCF(6,6)/3-21G", 37.3),
|
| 17 |
+
("CASSCF(6,6)/6-31G*//B3LYP/6-31G*", 43.8),
|
| 18 |
+
("MRMP2-CAS(6,6)/6-31G*//B3LYP/6-31G*", 28.6),
|
| 19 |
+
("CASSCF(6,6)/6-31G*// CASSCF(6,6)/6-31G*", 43.8),
|
| 20 |
+
("CASSCF(6,6)/6-31G(d,p)//CASSCF(6,6)/6-31(d,p)", 44.5),
|
| 21 |
+
("CASSCF(6,6)-MP2/6-31G(d,p)//CASSCF(6,6)/6-31(d,p)", 39.4),
|
| 22 |
+
("CASSCF(6,6)/6-311+G(d,p)//CASSCF(6,6)/6-31(d,p)", 37.0),
|
| 23 |
+
("CASSCF(6,6)-MP2/6-311+G(d,p)//CASSCF(6,6)/6-31(d,p)", 40.9),
|
| 24 |
+
("CASSCF(6,6)/6-31G*//CASSCF(6,6)/6-31G*", 25.0),
|
| 25 |
+
("CASSCF(6,6)/6-31G*//CASSCF(6,6)/6-31G*", 43.6),
|
| 26 |
+
("MR-AQCC/6-31G*//MR-AQCC/6-31G*", 25.7),
|
| 27 |
+
("MR-AQCC/6-31G**//MR-AQCC/6-31G**", 25.3),
|
| 28 |
+
("MR-AQCC/6-31G**//MR-AQCC/6-31G**", 24.2),
|
| 29 |
+
("MR-AQCC/6-311G(2d,p)//MR-AQCC/6-311G(2d,p)", 23.7),
|
| 30 |
+
("MP2/6-31G*//HF/6-31G*", 16.6),
|
| 31 |
+
("MP2/6-31G*//MP2/6-31G*", 18.5),
|
| 32 |
+
("MP2/6-31G*//B3LYP/6-31G*", 20.4),
|
| 33 |
+
("MP2/6-31G**//MP2/6-31G**", 15.9),
|
| 34 |
+
("MP2/6-311G(d,p)//B3LYP/6-31G", 17.5),
|
| 35 |
+
("MP3/6-31G*//HF/6-31G*", 26.9),
|
| 36 |
+
("MP3/6-311G(d,p)//B3LYP/6-31G", 28.2),
|
| 37 |
+
("MP4(SDQ)/6-31G*//HF/6-31G*", 29.0),
|
| 38 |
+
("MP4(SDTQ)/6-31G*//HF/6-31G*", 21.9),
|
| 39 |
+
("MP4(SDTQ)/6-31G*//MP2/6-31G*", 22.4),
|
| 40 |
+
("MP4(SDTQ)/6-311G(d,p)//B3LYP/6-31G", 22.8),
|
| 41 |
+
("B3LYP/6-31G*//B3LYP/6-31G*", 23.1),
|
| 42 |
+
("B3LYP/6-31G*//B3LYP/6-31G*", 24.9),
|
| 43 |
+
("B3LYP/6-31G**//B3LYP/6-31G**", 22.4),
|
| 44 |
+
("B3LYP/6-311+G(2d,p)//B3LYP/6-311+G(2d,p)", 27.2),
|
| 45 |
+
("BPW91/6-31G*//BPW91/6-31G*", 26.2),
|
| 46 |
+
("MPW1K/6-31+G**//MPW1K/6-31+G*", 24.4),
|
| 47 |
+
("KMLYP/6-31G*//KMLYP/6-31G*", 21.1),
|
| 48 |
+
("KMLYP/6-311G//KMLYP/6-31G", 22.4),
|
| 49 |
+
("OLYP/6-31G(d)//OLYP/6-31G(d)", 26.7),
|
| 50 |
+
("OLYP/6-311+G(2d,p)//OLYP/6-311+G(2d,p)", 30.1),
|
| 51 |
+
("OLYP/6-311G(2df,2pd)// OLYP/6-311G(2df,2pd)", 29.2),
|
| 52 |
+
("O3LYP/6-31G(d)//OLYP/6-31G(d)", 26.8),
|
| 53 |
+
("O3LYP/6-311+G(2d,p)//OLYP/6-311+G(2d,p)", 30.1),
|
| 54 |
+
("O3LYP/6-311G(2df,2pd)// OLYP/6-311G(2df,2pd)", 29.3),
|
| 55 |
+
("M06-2x/6-31G(d)", 20.4),
|
| 56 |
+
("M06-2x/cc-pVTZ", 23.2),
|
| 57 |
+
("ωB97X-D/6-31G(d)", 21.7),
|
| 58 |
+
("ωB97X-D/cc-pVTZ", 25.1),
|
| 59 |
+
("B2PLYP/cc-pVDZ", 24.0),
|
| 60 |
+
("QCISD(T)/6-31G*//CASSCF(6,6)/6-31G*", 25.5),
|
| 61 |
+
("QCISD(T)/6-31G*//B3LYP/6-31G*", 25.0),
|
| 62 |
+
("CCSD/6-311G(d,p)//B3LYP/6-31G", 30.8),
|
| 63 |
+
("CCSD(T)/6-311G(d,p)//B3LYP/6-31G", 25.7),
|
| 64 |
+
("CCSD(T)/6-31G**//B3LYP/6-31G**", 27.6),
|
| 65 |
+
("G2MP2/6-311+G(3df,2p)", 24.6),
|
| 66 |
+
("G2MS/6-311+G(2df,2p)", 23.9),
|
| 67 |
+
("CBS-QB3", 22.9),
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
# the method-family groups, in the left-to-right order the figure lays them out
|
| 71 |
+
CATEGORY_ORDER = ["HF", "MP2", "MP3", "MP4", "DFT", "CASSCF",
|
| 72 |
+
"Multireference", "Coupled Cluster", "Composite"]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def energy_method(method_string):
|
| 76 |
+
"""The energy method (the part before '//') of a 'method//geometry' string. This is what the
|
| 77 |
+
bar is labeled with and categorized by; a string with no '//' is returned unchanged."""
|
| 78 |
+
return method_string.split("//")[0].strip() if "//" in method_string else method_string
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def categorize_method(method):
|
| 82 |
+
"""The method family ('HF', 'MP2', 'DFT', 'CASSCF', ...) of an energy method, by the same rules
|
| 83 |
+
as the original figure. Used to group the bars."""
|
| 84 |
+
m = method.upper()
|
| 85 |
+
if m.startswith("HF"):
|
| 86 |
+
return "HF"
|
| 87 |
+
if m.startswith("MP2"):
|
| 88 |
+
return "MP2"
|
| 89 |
+
if m.startswith("MP3"):
|
| 90 |
+
return "MP3"
|
| 91 |
+
if m.startswith("MP4"):
|
| 92 |
+
return "MP4"
|
| 93 |
+
if any(k in m for k in ("B3LYP", "BPW91", "MPW1K", "KMLYP", "OLYP", "O3LYP", "M06",
|
| 94 |
+
"ωB97X", "B2PLYP")):
|
| 95 |
+
return "DFT"
|
| 96 |
+
if "CASSCF" in m:
|
| 97 |
+
return "CASSCF"
|
| 98 |
+
if "CCSD" in m or "QCISD" in m:
|
| 99 |
+
return "Coupled Cluster"
|
| 100 |
+
if m.startswith("G2") or "CBS" in m:
|
| 101 |
+
return "Composite"
|
| 102 |
+
if "MR-AQCC" in m or "MRMP2" in m:
|
| 103 |
+
return "Multireference"
|
| 104 |
+
return "Other"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def ordered_layout(data=DATA, group_spacing=2):
|
| 108 |
+
"""Lay the bars out grouped by method family and sorted by barrier within each group, with a gap
|
| 109 |
+
of group_spacing between groups. Returns a dict with parallel lists methods (the energy-method
|
| 110 |
+
labels), energies, positions (x of each bar), and group_labels / group_boundaries (the
|
| 111 |
+
(start, end) bar position of each group), in CATEGORY_ORDER.
|
| 112 |
+
"""
|
| 113 |
+
grouped = {}
|
| 114 |
+
for method_string, energy in data:
|
| 115 |
+
method = energy_method(method_string)
|
| 116 |
+
grouped.setdefault(categorize_method(method), []).append((method, energy))
|
| 117 |
+
for entries in grouped.values():
|
| 118 |
+
entries.sort(key=lambda me: me[1])
|
| 119 |
+
|
| 120 |
+
methods, energies, positions = [], [], []
|
| 121 |
+
group_labels, group_boundaries = [], []
|
| 122 |
+
pos = 0
|
| 123 |
+
for i, category in enumerate(CATEGORY_ORDER):
|
| 124 |
+
if category not in grouped:
|
| 125 |
+
continue
|
| 126 |
+
if i > 0 and methods: # gap before each group after the first present one
|
| 127 |
+
pos += group_spacing
|
| 128 |
+
start = pos
|
| 129 |
+
for method, energy in grouped[category]:
|
| 130 |
+
methods.append(method)
|
| 131 |
+
energies.append(energy)
|
| 132 |
+
positions.append(pos)
|
| 133 |
+
pos += 1
|
| 134 |
+
group_labels.append(category)
|
| 135 |
+
group_boundaries.append((start, pos - 1))
|
| 136 |
+
return {"methods": methods, "energies": energies, "positions": positions,
|
| 137 |
+
"group_labels": group_labels, "group_boundaries": group_boundaries}
|
analysis/code/fig2b_plots.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Correlation-matrix plotting engine for Figure 2B.
|
| 2 |
+
|
| 3 |
+
The notebook computes the pairwise Pearson correlation table across methods (delta-22 1H stationary
|
| 4 |
+
shieldings, pcSseg-2 basis) and calls `plot_correlation_matrix(...)` to draw the lower-triangle
|
| 5 |
+
heatmap.
|
| 6 |
+
"""
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import seaborn as sns
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def plot_correlation_matrix(corr_matrix, save_png, figsize=(6, 6)):
|
| 14 |
+
"""Draw and save the Figure 2B lower-triangle heatmap of pairwise Pearson correlations."""
|
| 15 |
+
arr = corr_matrix.to_numpy(dtype=float, copy=True)
|
| 16 |
+
np.fill_diagonal(arr, np.nan) # ignore the self-correlation diagonal
|
| 17 |
+
transformed = pd.DataFrame(-np.log10(1 - arr), # spread values near 1 so differences are visible
|
| 18 |
+
index=corr_matrix.index, columns=corr_matrix.columns)
|
| 19 |
+
# Tight lower triangle: the first row and last method have no pairs below the diagonal, so drop the
|
| 20 |
+
# first row and last column, then hide the strict upper triangle. Every drawn cell is then a real
|
| 21 |
+
# method pair, with no empty self-correlation squares on the axes.
|
| 22 |
+
transformed = transformed.iloc[1:, :-1]
|
| 23 |
+
mask = np.triu(np.ones(transformed.shape, dtype=bool), k=1)
|
| 24 |
+
|
| 25 |
+
sns.set_theme(style="white", context="paper")
|
| 26 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 27 |
+
|
| 28 |
+
r_ticks = [0.99, 0.999, 0.9999]
|
| 29 |
+
cb_values = [-np.log10(1 - r) for r in r_ticks]
|
| 30 |
+
hm = sns.heatmap(transformed, mask=mask, cmap="Reds", vmin=1, vmax=4, square=True,
|
| 31 |
+
linewidths=1, cbar_kws={"shrink": 0.65, "pad": -0.05, "ticks": cb_values}, ax=ax)
|
| 32 |
+
|
| 33 |
+
cbar = hm.collections[0].colorbar
|
| 34 |
+
cbar.set_ticks(cb_values)
|
| 35 |
+
cbar.set_ticklabels([f"{r:g}" for r in r_ticks]) # ticks are Pearson r values
|
| 36 |
+
cax = cbar.ax
|
| 37 |
+
pos = cax.get_position()
|
| 38 |
+
cax.set_position([pos.x0, pos.y0 + 0.04, pos.width, pos.height])
|
| 39 |
+
cax.text(0.5, 1.08, r"Pearson $\boldsymbol{r}$", transform=cax.transAxes, ha="center", va="bottom",
|
| 40 |
+
fontsize=11, bbox=dict(boxstyle="round,pad=0.25", facecolor="#EBEBEB", edgecolor="none", alpha=0.3))
|
| 41 |
+
|
| 42 |
+
for side in ("left", "bottom"):
|
| 43 |
+
ax.spines[side].set_visible(True)
|
| 44 |
+
ax.spines[side].set_linewidth(0.6)
|
| 45 |
+
ax.tick_params(axis="x", length=1.5, width=0.6, labelsize=10)
|
| 46 |
+
ax.tick_params(axis="y", length=1.5, width=0.6, labelsize=10)
|
| 47 |
+
plt.setp(ax.get_xticklabels(), rotation=90, ha="right", rotation_mode="anchor", va="top")
|
| 48 |
+
plt.setp(ax.get_yticklabels(), rotation=0, va="center")
|
| 49 |
+
sns.despine(ax=ax, top=True, right=True, left=False, bottom=False)
|
| 50 |
+
|
| 51 |
+
fig.savefig(save_png, dpi=300, bbox_inches="tight", pad_inches=0.02)
|
| 52 |
+
plt.show()
|
analysis/code/fig3_plots.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Plotting engines for Figure 3 panels 3A, 3B, and 3D (delta-22 solvent-correction figures).
|
| 2 |
+
|
| 3 |
+
Each panel's notebook (fig3a_pcm_benefit.ipynb, fig3b_shifts.ipynb, fig3d_solvent_corrections.ipynb)
|
| 4 |
+
supplies the data table and styling dicts and calls the corresponding function here.
|
| 5 |
+
"""
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import matplotlib.ticker as mticker
|
| 10 |
+
import seaborn as sns
|
| 11 |
+
from matplotlib.ticker import MaxNLocator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def plot_pcm_benefit_with_arrows(benefit_df, combos, colors, save_path,
|
| 15 |
+
xlimits=(-20, 35), figsize=(6, 6)):
|
| 16 |
+
"""Figure 3A: implicit-solvent (PCM) benefit by method, chloroform vs benzene."""
|
| 17 |
+
solvents = ["chloroform", "benzene"]
|
| 18 |
+
labels = [m for (m, _b, _g) in combos]
|
| 19 |
+
arrow_label = "__arrow__"
|
| 20 |
+
ordered_labels = [arrow_label] + labels # blank spacer row at the top holds the arrows
|
| 21 |
+
|
| 22 |
+
rows = []
|
| 23 |
+
for method, basis, geom in combos:
|
| 24 |
+
for solvent in solvents:
|
| 25 |
+
sub = benefit_df[(benefit_df["sap_nmr_method"] == method) &
|
| 26 |
+
(benefit_df["sap_basis"] == basis) &
|
| 27 |
+
(benefit_df["sap_geometry_type"] == geom) &
|
| 28 |
+
(benefit_df["solvent"] == solvent)]
|
| 29 |
+
for v in sub["percent_benefit"]:
|
| 30 |
+
rows.append({"Label": method, "Solvent": solvent, "PCM_Benefit": float(v)})
|
| 31 |
+
long_df = pd.DataFrame(rows)
|
| 32 |
+
long_df = pd.concat([long_df, pd.DataFrame([{"Label": arrow_label, "Solvent": solvents[0],
|
| 33 |
+
"PCM_Benefit": float(np.mean(xlimits))}])],
|
| 34 |
+
ignore_index=True)
|
| 35 |
+
long_df["Label"] = pd.Categorical(long_df["Label"], categories=ordered_labels, ordered=True)
|
| 36 |
+
|
| 37 |
+
sns.set_theme(context="paper", style="white")
|
| 38 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 39 |
+
sns.boxplot(data=long_df, x="PCM_Benefit", y="Label", order=ordered_labels,
|
| 40 |
+
hue="Solvent", hue_order=solvents, palette=colors, dodge=False, width=0.3,
|
| 41 |
+
fliersize=0, linewidth=0.5, whiskerprops=dict(alpha=0.5, linewidth=0.8),
|
| 42 |
+
capprops=dict(alpha=0.5, linewidth=1), medianprops=dict(alpha=0.9, linewidth=0.4),
|
| 43 |
+
boxprops=dict(alpha=0.9), ax=ax, orient="h")
|
| 44 |
+
|
| 45 |
+
# blank the spacer row's tick label
|
| 46 |
+
ticks = ax.get_yticks()
|
| 47 |
+
ax.yaxis.set_major_locator(mticker.FixedLocator(ticks))
|
| 48 |
+
ax.yaxis.set_major_formatter(mticker.FixedFormatter(
|
| 49 |
+
["" if t.get_text() == arrow_label else t.get_text() for t in ax.get_yticklabels()]))
|
| 50 |
+
|
| 51 |
+
leg = ax.get_legend()
|
| 52 |
+
if leg is not None:
|
| 53 |
+
leg.set_title("")
|
| 54 |
+
for txt in leg.get_texts():
|
| 55 |
+
txt.set_text(txt.get_text().capitalize())
|
| 56 |
+
leg.set_frame_on(True); leg.get_frame().set_alpha(0.95)
|
| 57 |
+
for t in leg.get_texts():
|
| 58 |
+
t.set_fontsize(13)
|
| 59 |
+
leg.set_bbox_to_anchor((0.8, 1.02)); leg._loc = 9
|
| 60 |
+
|
| 61 |
+
# arrows on the blank spacer row
|
| 62 |
+
y_spacer = ordered_labels.index(arrow_label)
|
| 63 |
+
span = xlimits[1] - xlimits[0]
|
| 64 |
+
offset = 0.02 * span
|
| 65 |
+
|
| 66 |
+
def box_arrow(text, x, ha, facecolor):
|
| 67 |
+
ax.text(x, y_spacer, text, ha=ha, va="center", fontsize=11, fontweight="bold",
|
| 68 |
+
color="white", clip_on=False, zorder=10,
|
| 69 |
+
bbox=dict(boxstyle=f"{'larrow' if ha=='right' else 'rarrow'},pad=0.35",
|
| 70 |
+
fc=facecolor, ec="none", alpha=0.85))
|
| 71 |
+
box_arrow("Improves Acc.", +offset, "left", "#708B75")
|
| 72 |
+
box_arrow("Decreases Acc.", -offset, "right", "#C3A29E")
|
| 73 |
+
|
| 74 |
+
ax.axvline(0, color="black", lw=0.2, alpha=0.4, zorder=0)
|
| 75 |
+
ax.set_xlim(*xlimits)
|
| 76 |
+
ax.margins(x=0.01)
|
| 77 |
+
ax.set_xlabel("Benefit of PCM (%)", fontsize=13)
|
| 78 |
+
ax.set_ylabel("")
|
| 79 |
+
sns.despine(ax=ax, left=False, bottom=False)
|
| 80 |
+
for spine in ax.spines.values():
|
| 81 |
+
spine.set_linewidth(0.6)
|
| 82 |
+
ax.tick_params(axis="both", which="major", length=1.5, width=0.6, labelsize=10)
|
| 83 |
+
fig.tight_layout()
|
| 84 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 85 |
+
plt.show()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def plot_shift_vs_pcm(shift_df, save_path, xlimits=(-0.2, 0.2), ylimits=(-1.6, 0.3), figsize=(8, 4)):
|
| 89 |
+
"""Figure 3B: measured vs PCM-predicted solvent-induced shift differences."""
|
| 90 |
+
sns.set_theme(style="white", context="paper")
|
| 91 |
+
fig, axes = plt.subplots(1, 2, figsize=figsize)
|
| 92 |
+
# left: experimental solvent-induced shifts (teal) -- large and uncorrelated
|
| 93 |
+
sns.scatterplot(data=shift_df, x="exp_diff_x", y="exp_diff_y", ax=axes[0],
|
| 94 |
+
color="#44AA99", s=15, alpha=1.0, edgecolor=None)
|
| 95 |
+
# right: PCM solvent corrections (dark red) -- small and linearly correlated. Same axis limits
|
| 96 |
+
# as the left panel, so the tiny PCM predictions are visible against the real shifts.
|
| 97 |
+
pcm = shift_df.dropna(subset=["pcm_diff_x", "pcm_diff_y"])
|
| 98 |
+
sns.scatterplot(data=pcm, x="pcm_diff_x", y="pcm_diff_y", ax=axes[1],
|
| 99 |
+
color="#A72608", s=15, alpha=0.5, edgecolor=None)
|
| 100 |
+
for ax in axes:
|
| 101 |
+
ax.set_xlim(*xlimits); ax.set_ylim(*ylimits)
|
| 102 |
+
ax.xaxis.set_major_locator(MaxNLocator(nbins=6))
|
| 103 |
+
ax.yaxis.set_major_locator(MaxNLocator(nbins=8))
|
| 104 |
+
ax.tick_params(axis="both", which="major", direction="out", length=1.5, width=0.6, labelsize=10)
|
| 105 |
+
axes[0].set_xlabel(r"$\delta_{MeOD} - \delta_{CDCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
|
| 106 |
+
axes[0].set_ylabel(r"$\delta_{benzene} - \delta_{CDCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
|
| 107 |
+
axes[1].set_xlabel(r"$\Delta\sigma_{MeOH} - \Delta\sigma_{CHCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
|
| 108 |
+
axes[1].set_ylabel(r"$\Delta\sigma_{benzene} - \Delta\sigma_{CHCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
|
| 109 |
+
plt.tight_layout(rect=[0, 0, 1, 0.95])
|
| 110 |
+
sns.despine()
|
| 111 |
+
for ax in axes:
|
| 112 |
+
for spine in ax.spines.values():
|
| 113 |
+
spine.set_linewidth(0.6)
|
| 114 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 115 |
+
plt.show()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def plot_solvent_correction_boxplot(results_df, formula_labels, solvent_order, colors,
|
| 119 |
+
solvent_labels, save_path, box_widths=0.70, figsize=(8, 6)):
|
| 120 |
+
"""Figure 3D: implicit vs explicit solvent corrections, one box per solvent."""
|
| 121 |
+
labels = list(formula_labels.values()) # implicit, implicit+vib, explicit, explicit+vib
|
| 122 |
+
palette = dict(zip(labels, colors))
|
| 123 |
+
df = results_df[results_df["formula"].isin(formula_labels)].copy()
|
| 124 |
+
df["Model"] = df["formula"].map(formula_labels)
|
| 125 |
+
df = df[df["solvent"].isin(solvent_order)].copy()
|
| 126 |
+
df["solvent"] = pd.Categorical(df["solvent"], categories=solvent_order, ordered=True)
|
| 127 |
+
xpos = {s: float(i) for i, s in enumerate(solvent_order)}
|
| 128 |
+
df["xpos"] = df["solvent"].map(xpos).astype(float)
|
| 129 |
+
|
| 130 |
+
sns.set_theme(context="paper", style="white")
|
| 131 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 132 |
+
sns.boxplot(data=df, x="xpos", y="test_RMSE", hue="Model", hue_order=labels, palette=palette,
|
| 133 |
+
width=box_widths, showfliers=False, linewidth=0.5,
|
| 134 |
+
whiskerprops=dict(alpha=0.5, linewidth=0.8), capprops=dict(alpha=0.5, linewidth=1),
|
| 135 |
+
medianprops=dict(alpha=0.9, linewidth=0.4), boxprops=dict(alpha=0.9), ax=ax)
|
| 136 |
+
|
| 137 |
+
# center each solvent's tick under its "Explicit Solvent" box
|
| 138 |
+
per_box = box_widths / len(labels)
|
| 139 |
+
offset = (labels.index("Explicit Solvent") - (len(labels) - 1) / 2) * per_box
|
| 140 |
+
ax.set_xticks([xpos[s] + offset for s in solvent_order])
|
| 141 |
+
ax.set_xticklabels([solvent_labels.get(s, s) for s in solvent_order], rotation=45, fontsize=8, ha="right")
|
| 142 |
+
|
| 143 |
+
ax.set_ylabel(r"Accuracy vs. Benchmark (RMSE, $^{1}\mathrm{H}$ ppm)", fontsize=13)
|
| 144 |
+
ax.set_xlabel("")
|
| 145 |
+
ax.set_ylim(bottom=0)
|
| 146 |
+
ax.set_title("Accuracy Improves Broadly with Explicit Solvation", fontsize=14, fontweight="bold")
|
| 147 |
+
leg = ax.get_legend()
|
| 148 |
+
if leg is not None:
|
| 149 |
+
leg.set_title("")
|
| 150 |
+
for t in leg.get_texts():
|
| 151 |
+
t.set_fontsize(13)
|
| 152 |
+
ax.yaxis.set_major_locator(MaxNLocator(nbins=6))
|
| 153 |
+
ax.tick_params(axis="both", which="major", direction="out", length=1.5, width=0.6, labelsize=10)
|
| 154 |
+
ax.grid(False)
|
| 155 |
+
sns.despine(ax=ax)
|
| 156 |
+
for spine in ax.spines.values():
|
| 157 |
+
spine.set_linewidth(0.6)
|
| 158 |
+
fig.tight_layout()
|
| 159 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 160 |
+
plt.show()
|
analysis/code/fig4_plots.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Figure 4 panel-drawing engines.
|
| 2 |
+
|
| 3 |
+
4A/4B share one scatter engine (predicted vs measured solvent-induced shift, implicit vs explicit
|
| 4 |
+
prediction column); 4C is a dumbbell plot of per-solvent fit RMSE vs experimental shift range. The
|
| 5 |
+
notebook supplies the data table and the styling dicts (highlight map, order, palette, markers /
|
| 6 |
+
colors) as arguments and calls `save_panel(...)` to draw and write the PNG.
|
| 7 |
+
"""
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import seaborn as sns
|
| 10 |
+
from adjustText import adjust_text
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def draw_shift_scatter(ax, shift_df, pred_col, highlight_map, order, palette, markers, limits=(-0.25, 2.0)):
|
| 14 |
+
"""Figure 4A/4B: scatter predicted vs experimental solvent-induced shift, colored by solvent."""
|
| 15 |
+
df = shift_df[shift_df["solvent"].isin(highlight_map)].copy()
|
| 16 |
+
df["Solvent"] = df["solvent"].map(highlight_map)
|
| 17 |
+
sns.scatterplot(data=df, x="exp_diff", y=pred_col, hue="Solvent", style="Solvent",
|
| 18 |
+
hue_order=order, style_order=order, palette=palette,
|
| 19 |
+
markers=markers, s=60, alpha=0.6, edgecolor=None, ax=ax)
|
| 20 |
+
lo, hi = limits
|
| 21 |
+
ax.plot([lo, hi], [lo, hi], linestyle="--", alpha=0.35, linewidth=0.6, color="k") # ideal y = x
|
| 22 |
+
ax.set_xlim(lo, hi); ax.set_ylim(lo, hi); ax.set_box_aspect(1)
|
| 23 |
+
ax.set_xlabel(r"Experimental Solvent-Induced Shift (Δδ vs. CDCl3, $^{1}\mathrm{H}$ ppm)",
|
| 24 |
+
fontsize=13, labelpad=10)
|
| 25 |
+
ax.set_ylabel(r"Predicted Solvent-Induced Shift (Δδ vs. CDCl3, $^{1}\mathrm{H}$ ppm)",
|
| 26 |
+
fontsize=13, labelpad=10)
|
| 27 |
+
leg = ax.get_legend()
|
| 28 |
+
if leg is not None:
|
| 29 |
+
leg.set_title(None)
|
| 30 |
+
for side in ax.spines:
|
| 31 |
+
ax.spines[side].set_linewidth(0.6)
|
| 32 |
+
ax.tick_params(labelsize=10, length=1.5, width=0.6)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def save_panel(draw_fn, save_path, figsize=(6, 6)):
|
| 36 |
+
"""Create a figure/axes, call `draw_fn(ax)`, then save and show the panel as a PNG."""
|
| 37 |
+
sns.set_theme(style="ticks", context="paper")
|
| 38 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 39 |
+
draw_fn(ax)
|
| 40 |
+
fig.tight_layout()
|
| 41 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 42 |
+
plt.show()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def draw_rmse_dumbbell(ax, rows, labels, colors):
|
| 46 |
+
"""Figure 4C: dumbbell plot of implicit vs explicit solvent-correction RMSE per solvent."""
|
| 47 |
+
rows_sorted = sorted(rows, key=lambda r: r["range"])
|
| 48 |
+
xs = [r["range"] for r in rows_sorted]
|
| 49 |
+
for r in rows_sorted: # gray dumbbell connectors
|
| 50 |
+
ax.plot([r["range"], r["range"]], [r["implicit"], r["explicit"]],
|
| 51 |
+
color="#cccccc", lw=1.5, zorder=1)
|
| 52 |
+
ax.scatter(xs, [r["implicit"] for r in rows_sorted], s=60, marker="D",
|
| 53 |
+
color=colors["Implicit (PCM)"], alpha=0.85, edgecolors="none",
|
| 54 |
+
label="Implicit (PCM)", zorder=2)
|
| 55 |
+
ax.scatter(xs, [r["explicit"] for r in rows_sorted], s=60, marker="o",
|
| 56 |
+
color=colors["Explicit (Desmond)"], alpha=0.85, edgecolors="none",
|
| 57 |
+
label="Explicit (Desmond)", zorder=2)
|
| 58 |
+
# frame the data, then label each dumbbell at its midpoint (adjustText avoids overlaps)
|
| 59 |
+
xr = max(xs) - min(xs); yv = [r[k] for r in rows_sorted for k in ("implicit", "explicit")]
|
| 60 |
+
ax.set_xlim(min(xs) - 0.06 * xr, max(xs) + 0.10 * xr)
|
| 61 |
+
ax.set_ylim(min(yv) - 0.06 * (max(yv) - min(yv)), max(yv) + 0.10 * (max(yv) - min(yv)))
|
| 62 |
+
texts = [ax.text(r["range"], 0.5 * (r["implicit"] + r["explicit"]), labels[r["solvent"]],
|
| 63 |
+
fontsize=10, color="black", va="center", ha="left") for r in rows_sorted]
|
| 64 |
+
adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle="-", color="gray", lw=0.5, alpha=0.8))
|
| 65 |
+
ax.legend(loc="upper left", framealpha=0.9, fontsize=11)
|
| 66 |
+
ax.set_box_aspect(1)
|
| 67 |
+
ax.set_xlabel(r"Range of Solvent-Induced Shifts ($^{1}\mathrm{H}$ ppm)", fontsize=13, labelpad=10)
|
| 68 |
+
ax.set_ylabel(r"Predicted Solvent-Induced Shift RMSE ($^{1}\mathrm{H}$ ppm)", fontsize=13, labelpad=10)
|
| 69 |
+
for side in ax.spines:
|
| 70 |
+
ax.spines[side].set_linewidth(0.6)
|
| 71 |
+
ax.tick_params(labelsize=10, length=1.5, width=0.6)
|
analysis/code/fig5b_plots.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Plotting engines for Figure 5B (MagNET-Zero vs DFT on the DFT8K benchmark).
|
| 2 |
+
|
| 3 |
+
The notebook computes residuals / functional-group error tables via `dft8k_residuals` and calls the
|
| 4 |
+
drawing functions here; `show_dft8k_molecule` renders the inline-only RDKit structure callouts (not
|
| 5 |
+
saved to disk).
|
| 6 |
+
"""
|
| 7 |
+
import numpy as np
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import seaborn as sns
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def plot_dft8k_residual_histogram(errors, save_path, color="#5D737E", bin_width=0.0025, xlim=0.23,
|
| 13 |
+
grey_box=0.1, x_tick=0.05, figsize=(8, 3)):
|
| 14 |
+
"""Draw the DFT8K residual histogram for one nucleus and save it to save_path (or skip saving
|
| 15 |
+
if save_path is None)."""
|
| 16 |
+
# The published panel is slate (#5D737E) with a shaded +/-0.1 ppm window; the "MagNET-Zero Closely
|
| 17 |
+
# Reproduces DFT" title and "X% of Compounds" bracket are added in the figure compositing step.
|
| 18 |
+
sns.set_theme(context="paper", style="white")
|
| 19 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 20 |
+
edges = np.arange(-xlim, xlim + bin_width, bin_width)
|
| 21 |
+
sns.histplot(x=errors, bins=edges, stat="count", element="bars", ax=ax,
|
| 22 |
+
color=color, edgecolor=None, alpha=0.9, linewidth=0)
|
| 23 |
+
ax.set_xlim(-xlim, xlim)
|
| 24 |
+
ax.set_xlabel("Residual vs. DFT8K (ppm)", fontsize=13)
|
| 25 |
+
ax.set_ylabel("Counts", fontsize=13)
|
| 26 |
+
if grey_box is not None: # shade the +/- grey_box ppm window
|
| 27 |
+
ax.axvspan(-grey_box, grey_box, alpha=0.05, facecolor="k", edgecolor="none", zorder=0)
|
| 28 |
+
for xv in (-grey_box, grey_box):
|
| 29 |
+
ax.axvline(xv, color="k", ls="--", lw=1.0, alpha=0.35, zorder=1)
|
| 30 |
+
if x_tick is not None:
|
| 31 |
+
ax.set_xticks(np.arange(-np.floor(xlim / x_tick) * x_tick, xlim + x_tick / 2, x_tick))
|
| 32 |
+
sns.despine(ax=ax, top=True, right=True)
|
| 33 |
+
ax.spines["left"].set_linewidth(0.6); ax.spines["bottom"].set_linewidth(0.6)
|
| 34 |
+
ax.tick_params(axis="both", which="major", direction="out", length=1.5, width=0.6, labelsize=10)
|
| 35 |
+
ax.minorticks_off()
|
| 36 |
+
fig.tight_layout()
|
| 37 |
+
if save_path: # 13C is an inline-only companion, not saved
|
| 38 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 39 |
+
plt.show()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def plot_dft8k_functional_group_errors(group_errors, nucleus, save_path, figsize=(7, 3.5)):
|
| 43 |
+
"""Bar chart of mean |residual| by functional group for one nucleus, saved to save_path."""
|
| 44 |
+
# The panel composites these mean |residual| values as molecule-icon insets; this bar chart is
|
| 45 |
+
# the plain reproduction of the numbers.
|
| 46 |
+
names = list(group_errors.keys())
|
| 47 |
+
values = [group_errors[n]["mean_abs_error"] for n in names]
|
| 48 |
+
counts = [group_errors[n]["n_molecules"] for n in names]
|
| 49 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 50 |
+
bars = ax.bar(names, values, color="#4C9B8F")
|
| 51 |
+
for bar, n in zip(bars, counts):
|
| 52 |
+
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f"n={n:,}",
|
| 53 |
+
ha="center", va="bottom", fontsize=8)
|
| 54 |
+
lbl = "¹H" if nucleus == "H" else "¹³C"
|
| 55 |
+
ax.set_ylabel(f"mean |{lbl} residual| (ppm)")
|
| 56 |
+
ax.set_title(f"{lbl} error by functional group (whole DFT8K set)")
|
| 57 |
+
fig.autofmt_xdate(rotation=30)
|
| 58 |
+
fig.tight_layout()
|
| 59 |
+
if save_path: # only the 1H panel is a saved deliverable
|
| 60 |
+
fig.savefig(save_path, dpi=200, bbox_inches="tight")
|
| 61 |
+
plt.show()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def show_dft8k_molecule(smiles, size=(350, 300)):
|
| 65 |
+
"""Display an RDKit structure drawing for the given SMILES inline; nothing is saved to disk."""
|
| 66 |
+
from rdkit import Chem
|
| 67 |
+
from rdkit.Chem.Draw import rdMolDraw2D
|
| 68 |
+
from IPython.display import Image, display
|
| 69 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 70 |
+
if mol is None:
|
| 71 |
+
raise ValueError(f"RDKit could not parse SMILES: {smiles!r}")
|
| 72 |
+
drawer = rdMolDraw2D.MolDraw2DCairo(*size)
|
| 73 |
+
drawer.drawOptions().addStereoAnnotation = False
|
| 74 |
+
rdMolDraw2D.PrepareAndDrawMolecule(drawer, mol)
|
| 75 |
+
drawer.FinishDrawing()
|
| 76 |
+
display(Image(drawer.GetDrawingText())) # inline only; whole-molecule, no atom highlighting
|
analysis/code/leveling.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core data loading and analysis for the leveling-effect study (NS372 and delta22). No plotting.
|
| 2 |
+
|
| 3 |
+
Reads the two datasets, decodes the stored shieldings, and runs the per-nucleus correlation / PCA
|
| 4 |
+
analysis. The figure notebook (built by build_nb_leveling.py) and test_leveling.py import from here.
|
| 5 |
+
|
| 6 |
+
The claim tested, on each dataset separately (never pooled): once a linear scaling is allowed for,
|
| 7 |
+
any two shielding methods are near-perfectly correlated, PC1 carries almost all between-method
|
| 8 |
+
variance, and the methods fall on a 1D curve in (PC1, PC2).
|
| 9 |
+
"""
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import h5py
|
| 13 |
+
import openpyxl
|
| 14 |
+
from sklearn.decomposition import PCA
|
| 15 |
+
from sklearn.preprocessing import StandardScaler
|
| 16 |
+
|
| 17 |
+
from fixed_point import decode_fixed_point as _decode_fixed_point
|
| 18 |
+
from stats import rmse as _rmse
|
| 19 |
+
|
| 20 |
+
# canonical functional-family ordering used to sort method columns in every figure
|
| 21 |
+
FAMILY_ORDER = ['LDA', 'GGA', 'mGGA', 'GH', 'RSH', 'LH', 'DH', 'WFT', 'ref']
|
| 22 |
+
|
| 23 |
+
def sort_by_family(M, methods, families):
|
| 24 |
+
"""Stable-sort method columns of M into FAMILY_ORDER (reference family last)."""
|
| 25 |
+
rank = {f: i for i, f in enumerate(FAMILY_ORDER)}
|
| 26 |
+
idx = sorted(range(len(methods)), key=lambda i: (rank.get(families[i], 99), i))
|
| 27 |
+
return M[:, idx], [methods[i] for i in idx], [families[i] for i in idx]
|
| 28 |
+
|
| 29 |
+
def log_r(R):
|
| 30 |
+
"""-log10(1-|r|): 1 -> r=0.9, 2 -> 0.99, 3 -> 0.999, ..."""
|
| 31 |
+
return -np.log10(np.clip(1 - np.abs(R), 1e-6, None))
|
| 32 |
+
|
| 33 |
+
def scaled_rmse(method, ref):
|
| 34 |
+
"""RMSE of residuals after the per-method linear fit method ~ a*ref + b."""
|
| 35 |
+
a, b = np.polyfit(ref, method, 1)
|
| 36 |
+
return _rmse(method, a * ref + b)
|
| 37 |
+
|
| 38 |
+
def _parabola_fit(xn, yn, n_angles=720):
|
| 39 |
+
# best degree-2 fit y_rot ~ x_rot searched over rotation angle theta
|
| 40 |
+
best = (0.0, -np.inf, None)
|
| 41 |
+
for th in np.linspace(0, np.pi, n_angles):
|
| 42 |
+
c, s = np.cos(th), np.sin(th)
|
| 43 |
+
xr, yr = c * xn + s * yn, -s * xn + c * yn
|
| 44 |
+
A = np.column_stack([np.ones_like(xr), xr, xr ** 2])
|
| 45 |
+
coef, *_ = np.linalg.lstsq(A, yr, rcond=None)
|
| 46 |
+
ss_res = ((yr - A @ coef) ** 2).sum()
|
| 47 |
+
ss_tot = ((yr - yr.mean()) ** 2).sum()
|
| 48 |
+
r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
| 49 |
+
if r2 > best[1]:
|
| 50 |
+
best = (th, r2, coef)
|
| 51 |
+
return best
|
| 52 |
+
|
| 53 |
+
def analyze_nucleus(M, methods, ref=None):
|
| 54 |
+
"""Correlation, PCA, and parabola-fit diagnostics for one nucleus.
|
| 55 |
+
|
| 56 |
+
M is an (n_observations, n_methods) shielding matrix; ref (optional) is the reference method's
|
| 57 |
+
values, used to compute each method's scaled RMSE against it.
|
| 58 |
+
"""
|
| 59 |
+
R = np.corrcoef(M, rowvar=False)
|
| 60 |
+
iu = np.triu_indices_from(R, k=1)
|
| 61 |
+
rv = R[iu]
|
| 62 |
+
Z = StandardScaler().fit_transform(M)
|
| 63 |
+
pca = PCA().fit(Z)
|
| 64 |
+
ev = pca.explained_variance_ratio_
|
| 65 |
+
pc1, pc2 = pca.components_[0].copy(), pca.components_[1].copy()
|
| 66 |
+
if pc1.mean() < 0: # cosmetic sign convention
|
| 67 |
+
pc1 = -pc1
|
| 68 |
+
mx, my = pc1.mean(), pc2.mean()
|
| 69 |
+
sx, sy = max(pc1.std(), 1e-12), max(pc2.std(), 1e-12)
|
| 70 |
+
xn, yn = (pc1 - mx) / sx, (pc2 - my) / sy
|
| 71 |
+
th, pr2, coef = _parabola_fit(xn, yn)
|
| 72 |
+
c, s = np.cos(th), np.sin(th)
|
| 73 |
+
par = dict(theta=th, coef=coef, r2=pr2, mx=mx, sx=sx, my=my, sy=sy,
|
| 74 |
+
xr=c * xn + s * yn)
|
| 75 |
+
out = dict(R=R, methods=list(methods), pc1=pc1, pc2=pc2, ev=ev, par=par,
|
| 76 |
+
r_min=float(rv.min()), r_med=float(np.median(rv)),
|
| 77 |
+
r_max=float(rv.max()))
|
| 78 |
+
if ref is not None:
|
| 79 |
+
out['scaled_rmse'] = {m: scaled_rmse(M[:, i], ref)
|
| 80 |
+
for i, m in enumerate(methods)}
|
| 81 |
+
return out
|
| 82 |
+
|
| 83 |
+
def parabola_curve(par, npts=300):
|
| 84 |
+
"""Sample points along the fitted parabola (from analyze_nucleus's `par`) in PC1/PC2 space."""
|
| 85 |
+
th, coef = par['theta'], par['coef']
|
| 86 |
+
c, s = np.cos(th), np.sin(th)
|
| 87 |
+
xr = par['xr']
|
| 88 |
+
vertex = -coef[1] / (2 * coef[2]) if coef[2] != 0 else float(xr.mean())
|
| 89 |
+
lo, hi = min(xr.min(), vertex), max(xr.max(), vertex)
|
| 90 |
+
pad = 0.15 * (hi - lo + 1e-9)
|
| 91 |
+
t = np.linspace(lo - pad, hi + pad, npts)
|
| 92 |
+
yr = coef[0] + coef[1] * t + coef[2] * t ** 2
|
| 93 |
+
xn, yn = c * t - s * yr, s * t + c * yr
|
| 94 |
+
return xn * par['sx'] + par['mx'], yn * par['sy'] + par['my']
|
| 95 |
+
|
| 96 |
+
def summarize(data, res):
|
| 97 |
+
"""Per-nucleus leveling diagnostics (analyze_nucleus results) as one summary DataFrame."""
|
| 98 |
+
import pandas as pd
|
| 99 |
+
rows = []
|
| 100 |
+
for nuc, r in res.items():
|
| 101 |
+
rows.append(dict(nucleus=nuc,
|
| 102 |
+
n_obs=data[nuc]['M'].shape[0],
|
| 103 |
+
n_methods=data[nuc]['M'].shape[1],
|
| 104 |
+
r_min=r['r_min'], r_median=r['r_med'],
|
| 105 |
+
PC1_pct=r['ev'][0] * 100, PC2_pct=r['ev'][1] * 100,
|
| 106 |
+
PC3plus_pct=(1 - r['ev'][0] - r['ev'][1]) * 100,
|
| 107 |
+
parabola_R2=r['par']['r2']))
|
| 108 |
+
return pd.DataFrame(rows)
|
| 109 |
+
|
| 110 |
+
def dataset_logr_max(res):
|
| 111 |
+
"""Largest -log10(1-|r|) over every nucleus of a dataset (lower triangle of each R)."""
|
| 112 |
+
vm = 0.0
|
| 113 |
+
for r in res.values():
|
| 114 |
+
nm = len(r['methods']); m = nm - 1
|
| 115 |
+
sub = r['R'][1:, :-1]
|
| 116 |
+
hide = np.triu(np.ones((m, m), dtype=bool), k=1)
|
| 117 |
+
vm = max(vm, float(np.nanmax(np.where(hide, np.nan, log_r(sub)))))
|
| 118 |
+
return float(np.ceil(vm * 10) / 10)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ----------------------------------------------------------------------------
|
| 122 |
+
# NS372 (Kaupp) loader
|
| 123 |
+
# ----------------------------------------------------------------------------
|
| 124 |
+
# (functional, tau-treatment, %HF, family, display label)
|
| 125 |
+
# one tau-variant per functional: tauC (Gaussian default) for tau-dependent
|
| 126 |
+
# meta-GGAs / hybrids, tauD for local hybrids. Match keys use real Unicode.
|
| 127 |
+
METHODS = [
|
| 128 |
+
('SVWN', None, 0, 'LDA', 'SVWN'),
|
| 129 |
+
('BLYP', None, 0, 'GGA', 'BLYP'), ('BP86', None, 0, 'GGA', 'BP86'),
|
| 130 |
+
('PBE', None, 0, 'GGA', 'PBE'), ('B97D', None, 0, 'GGA', 'B97D'),
|
| 131 |
+
('HCTH', None, 0, 'GGA', 'HCTH'),
|
| 132 |
+
('KT1', None, 0, 'GGA', 'KT1'), ('KT2', None, 0, 'GGA', 'KT2'), ('KT3', None, 0, 'GGA', 'KT3'),
|
| 133 |
+
('TPSS', 'τC', 0, 'mGGA', 'TPSS/τC'), ('τ-HCTH', 'τC', 0, 'mGGA', 'τ-HCTH/τC'),
|
| 134 |
+
('M06-L', 'τC', 0, 'mGGA', 'M06-L/τC'), ('VSXC', 'τC', 0, 'mGGA', 'VSXC/τC'),
|
| 135 |
+
('MN15-L', 'τC', 0, 'mGGA', 'MN15-L/τC'), ('B97M-V', 'τC', 0, 'mGGA', 'B97M-V/τC'),
|
| 136 |
+
('SCAN', 'τC', 0, 'mGGA', 'SCAN/τC'), ('rSCAN', 'τC', 0, 'mGGA', 'rSCAN/τC'),
|
| 137 |
+
('r2SCAN', 'τC', 0, 'mGGA', 'r2SCAN/τC'),
|
| 138 |
+
('TPSSh', 'τC', 10, 'GH', 'TPSSh/τC'), ('B3LYP', None, 20, 'GH', 'B3LYP'),
|
| 139 |
+
('B97-2', None, 21, 'GH', 'B97-2'), ('PBE0', None, 25, 'GH', 'PBE0'),
|
| 140 |
+
('M06', 'τC', 27, 'GH', 'M06/τC'), ('PW6B95', 'τC', 28, 'GH', 'PW6B95/τC'),
|
| 141 |
+
('MN15', 'τC', 44, 'GH', 'MN15/τC'), ('BHLYP', None, 50, 'GH', 'BHLYP'),
|
| 142 |
+
('M06-2X', 'τC', 54, 'GH', 'M06-2X/τC'),
|
| 143 |
+
('CAM-B3LYP', None, 65, 'RSH', 'CAM-B3LYP'),
|
| 144 |
+
('ωB97X-D', None, 100, 'RSH', 'ωB97X-D'), ('ωB97X-V', None, 100, 'RSH', 'ωB97X-V'),
|
| 145 |
+
('ωB97M-V', 'τC', 100, 'RSH', 'ωB97M-V/τC'),
|
| 146 |
+
('LH07s-SVWN', None, 22, 'LH', 'LH07s-SVWN'),
|
| 147 |
+
('MPSTS', 'τD', 30, 'LH', 'mPSTS/τD'),
|
| 148 |
+
('LHJ14', 'τD', 10, 'LH', 'LHJ14/τD'),
|
| 149 |
+
('LH07t-SVWN', 'τD', 48, 'LH', 'LH07t-SVWN/τD'),
|
| 150 |
+
('LH12ct-SsirPW92', 'τD', 65, 'LH', 'LH12ct-SsirPW92/τD'),
|
| 151 |
+
('LH12ct-SsifPW92', 'τD', 71, 'LH', 'LH12ct-SsifPW92/τD'),
|
| 152 |
+
('LH14t-calPBE', 'τD', 50, 'LH', 'LH14t-calPBE/τD'),
|
| 153 |
+
('LH20t', 'τD', 71, 'LH', 'LH20t/τD'),
|
| 154 |
+
('B2PLYP', None, 53, 'DH', 'B2PLYP'), ('B2GP-PLYP', None, 65, 'DH', 'B2GP-PLYP'),
|
| 155 |
+
('DSD-PBEP86', None, 70, 'DH', 'DSD-PBEP86'),
|
| 156 |
+
('Hartree-Fock', None, 100, 'WFT', 'HF'), ('MP2', None, 100, 'WFT', 'MP2'),
|
| 157 |
+
]
|
| 158 |
+
# (display nucleus, source sheet, in-sheet nucleus filter, molecules to drop)
|
| 159 |
+
NUCLEI = [
|
| 160 |
+
('1H', 'S6 - 1H Shieldings', None, ()),
|
| 161 |
+
('11B', 'S7 - 11B Shieldings', None, ('BH',)),
|
| 162 |
+
('13C', 'S8 - 13C Shieldings', None, ()),
|
| 163 |
+
('15N', 'S9 - 15N Shieldings', None, ()),
|
| 164 |
+
('17O', 'S10 - 17O Shieldings', None, ('O3',)),
|
| 165 |
+
('19F', 'S11 - 19F Shieldings', None, ('F3-',)),
|
| 166 |
+
('31P', 'S12 - 31P - 33S Shieldings', '31P', ()),
|
| 167 |
+
('33S', 'S12 - 31P - 33S Shieldings', '33S', ()),
|
| 168 |
+
]
|
| 169 |
+
|
| 170 |
+
def read_nucleus(wb, sheet, nuc_filter=None, exclude=()):
|
| 171 |
+
"""Read the CCSD(T) reference plus each method's shielding vector from one Kaupp SI sheet."""
|
| 172 |
+
ws = wb[sheet]
|
| 173 |
+
rows = []; last_nuc = None
|
| 174 |
+
for r in range(4, ws.max_row + 1):
|
| 175 |
+
v = ws.cell(r, 1).value
|
| 176 |
+
if v:
|
| 177 |
+
last_nuc = v
|
| 178 |
+
if isinstance(ws.cell(r, 4).value, (int, float)):
|
| 179 |
+
mol = str(ws.cell(r, 2).value or '').strip()
|
| 180 |
+
if mol in exclude:
|
| 181 |
+
continue
|
| 182 |
+
if nuc_filter is None or last_nuc == nuc_filter:
|
| 183 |
+
rows.append(r)
|
| 184 |
+
ref = np.array([ws.cell(r, 4).value for r in rows], dtype=float)
|
| 185 |
+
last_f = None; cols = []
|
| 186 |
+
for c in range(7, ws.max_column + 1):
|
| 187 |
+
h = ws.cell(1, c).value
|
| 188 |
+
if h is not None:
|
| 189 |
+
last_f = h
|
| 190 |
+
cols.append((c, last_f, ws.cell(2, c).value))
|
| 191 |
+
data = {}
|
| 192 |
+
for c, f, sub in cols:
|
| 193 |
+
if f is None:
|
| 194 |
+
continue
|
| 195 |
+
vals = []; ok = True
|
| 196 |
+
for r in rows:
|
| 197 |
+
v = ws.cell(r, c).value
|
| 198 |
+
if not isinstance(v, (int, float)):
|
| 199 |
+
ok = False; break
|
| 200 |
+
vals.append(v)
|
| 201 |
+
if not ok or (f, sub) in data:
|
| 202 |
+
continue
|
| 203 |
+
data[(f, sub)] = np.array(vals, dtype=float)
|
| 204 |
+
return ref, data
|
| 205 |
+
|
| 206 |
+
def load_ns372(path):
|
| 207 |
+
"""Load the NS372 (Kaupp) shielding matrix, sorted by family, for each of the 8 nuclei."""
|
| 208 |
+
wb = openpyxl.load_workbook(path, data_only=True)
|
| 209 |
+
out = {}
|
| 210 |
+
for nuc, sheet, filt, excl in NUCLEI:
|
| 211 |
+
ref, data = read_nucleus(wb, sheet, filt, excl)
|
| 212 |
+
used = [m for m in METHODS if (m[0], m[1]) in data]
|
| 213 |
+
cols = [data[(m[0], m[1])] for m in used]
|
| 214 |
+
labels = [m[4] for m in used] + ['CCSD(T)']
|
| 215 |
+
fams = [m[3] for m in used] + ['ref']
|
| 216 |
+
M = np.column_stack(cols + [ref])
|
| 217 |
+
M, labels, fams = sort_by_family(M, labels, fams)
|
| 218 |
+
out[nuc] = dict(M=M, methods=labels, ref=ref, families=fams)
|
| 219 |
+
return out
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ----------------------------------------------------------------------------
|
| 223 |
+
# main-text Figure 2D (convergence to the electronic-structure limit)
|
| 224 |
+
# ----------------------------------------------------------------------------
|
| 225 |
+
# The 1H / pcSseg-3 slice of the Kaupp SI, as a raw-named table, plus the two
|
| 226 |
+
# per-method statistics the 3D panel plots (slope and scaled RMSE vs CCSD(T)).
|
| 227 |
+
# These reuse the NS372 reader above; nothing new is read from disk.
|
| 228 |
+
def load_ns372_1h_convergence(path):
|
| 229 |
+
"""1H CCSD(T) reference plus each benchmarked functional's shieldings, as columns.
|
| 230 |
+
|
| 231 |
+
Tau policy differs from the SI S3 leveling analysis: Figure 2D uses the tauD kinetic-energy-density
|
| 232 |
+
treatment for every tau-dependent functional, whereas load_ns372/METHODS uses tauC for meta-GGAs.
|
| 233 |
+
So this selects tauD when present, otherwise the functional's single column, keyed by raw
|
| 234 |
+
functional name ('Hartree-Fock' renamed 'HF' for the labels).
|
| 235 |
+
"""
|
| 236 |
+
wb = openpyxl.load_workbook(path, data_only=True)
|
| 237 |
+
ref, data = read_nucleus(wb, 'S6 - 1H Shieldings')
|
| 238 |
+
names = {m[0] for m in METHODS} # the 44 benchmarked functionals
|
| 239 |
+
variants = {} # functional -> {tau: values}
|
| 240 |
+
for (func, tau), vals in data.items():
|
| 241 |
+
if func in names:
|
| 242 |
+
variants.setdefault(func, {})[tau] = vals
|
| 243 |
+
cols = {'CCSD(T)': ref}
|
| 244 |
+
for func, by_tau in variants.items():
|
| 245 |
+
vals = by_tau['τD'] if 'τD' in by_tau else next(iter(by_tau.values()))
|
| 246 |
+
cols['HF' if func == 'Hartree-Fock' else func] = vals
|
| 247 |
+
return pd.DataFrame(cols)
|
| 248 |
+
|
| 249 |
+
def convergence_statistics(cc_df, reference='CCSD(T)'):
|
| 250 |
+
"""Per method: the slope of method ~ a*CCSD(T) + b, and the scaled RMSE that Figure 2D plots.
|
| 251 |
+
|
| 252 |
+
The prediction is rescaled back into reference units, y_scaled = (method - b) / a, and compared
|
| 253 |
+
to CCSD(T); this equals the residual RMSE divided by the slope, so it differs from scaled_rmse,
|
| 254 |
+
which the S3 bar plot leaves in the method's own units. These are the x/y coordinates of the
|
| 255 |
+
Figure 2D points.
|
| 256 |
+
"""
|
| 257 |
+
ref = cc_df[reference].to_numpy(float)
|
| 258 |
+
rows = []
|
| 259 |
+
for method in cc_df.columns:
|
| 260 |
+
if method == reference:
|
| 261 |
+
continue
|
| 262 |
+
y = cc_df[method].to_numpy(float)
|
| 263 |
+
slope, intercept = np.polyfit(ref, y, 1)
|
| 264 |
+
y_scaled = (y - intercept) / slope
|
| 265 |
+
rows.append({'Method': method, 'Slope': slope,
|
| 266 |
+
'RMSE (scaled)': _rmse(ref, y_scaled)})
|
| 267 |
+
return pd.DataFrame(rows)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ----------------------------------------------------------------------------
|
| 271 |
+
# delta22 loader (reads the released file; shieldings are decoded on read)
|
| 272 |
+
# ----------------------------------------------------------------------------
|
| 273 |
+
DELTA22_REF = 'DSD-PBEP86'
|
| 274 |
+
|
| 275 |
+
def load_delta22(path):
|
| 276 |
+
"""Load the delta22 shielding matrix, sorted by family, for 1H and 13C sites."""
|
| 277 |
+
f = h5py.File(path, 'r')
|
| 278 |
+
mn = [m.decode() for m in f['conventional_nmr_method_names'][:]]
|
| 279 |
+
order = ['hf', 'blyp_d3bj', 'bp86_d3bj', 'b97d3_d3bj', 'tpsstpss_d3bj',
|
| 280 |
+
'b3lyp_d3bj', 'pbe0_d3bj', 'm062x_d3', 'wb97xd', 'wp04', 'wc04',
|
| 281 |
+
'B2PLYP', 'mPW2PLYP', 'B2GP_PLYP', 'dsd_pbep86', 'revdsd_pbep86',
|
| 282 |
+
'mp2', 'dlpno_mp2']
|
| 283 |
+
disp = {'hf': 'HF', 'blyp_d3bj': 'BLYP-D3', 'bp86_d3bj': 'BP86-D3',
|
| 284 |
+
'b97d3_d3bj': 'B97D3', 'tpsstpss_d3bj': 'TPSS-D3', 'b3lyp_d3bj': 'B3LYP-D3',
|
| 285 |
+
'pbe0_d3bj': 'PBE0-D3', 'm062x_d3': 'M06-2X-D3', 'wb97xd': 'ωB97X-D',
|
| 286 |
+
'wp04': 'WP04', 'wc04': 'WC04', 'B2PLYP': 'B2PLYP', 'mPW2PLYP': 'mPW2PLYP',
|
| 287 |
+
'B2GP_PLYP': 'B2GP-PLYP', 'dsd_pbep86': 'DSD-PBEP86',
|
| 288 |
+
'revdsd_pbep86': 'revDSD-PBEP86', 'mp2': 'MP2', 'dlpno_mp2': 'DLPNO-MP2'}
|
| 289 |
+
fam = {'hf': 'WFT', 'blyp_d3bj': 'GGA', 'bp86_d3bj': 'GGA', 'b97d3_d3bj': 'GGA',
|
| 290 |
+
'tpsstpss_d3bj': 'mGGA', 'b3lyp_d3bj': 'GH', 'pbe0_d3bj': 'GH',
|
| 291 |
+
'm062x_d3': 'GH', 'wb97xd': 'RSH', 'wp04': 'GH', 'wc04': 'GH',
|
| 292 |
+
'B2PLYP': 'DH', 'mPW2PLYP': 'DH', 'B2GP_PLYP': 'DH', 'dsd_pbep86': 'DH',
|
| 293 |
+
'revdsd_pbep86': 'DH', 'mp2': 'WFT', 'dlpno_mp2': 'WFT'}
|
| 294 |
+
gas = {}
|
| 295 |
+
for i, m in enumerate(mn):
|
| 296 |
+
func, basis, smodel, _ = m.split(',')
|
| 297 |
+
if smodel == 'gas':
|
| 298 |
+
gas.setdefault(func, {})[basis] = i
|
| 299 |
+
sel, labels, families = [], [], []
|
| 300 |
+
for func in order:
|
| 301 |
+
b = gas[func]
|
| 302 |
+
big = 'pcSseg3' if 'pcSseg3' in b else ('pcSseg2' if 'pcSseg2' in b else 'pcSseg1')
|
| 303 |
+
sel.append(b[big]); labels.append(disp[func]); families.append(fam[func])
|
| 304 |
+
GEOM = 1 # geometries axis: 0 = AIMNet2, 1 = PBE0/cc-pVTZ
|
| 305 |
+
rows = {1: [], 6: []}
|
| 306 |
+
for s in f['solutes']:
|
| 307 |
+
an = f['solutes'][s]['atomic_numbers'][:]
|
| 308 |
+
cs = _decode_fixed_point(
|
| 309 |
+
f['solutes'][s]['stationary_and_pcm']['conventional_shieldings'][:, GEOM, :])
|
| 310 |
+
for ai, z in enumerate(an):
|
| 311 |
+
if z in (1, 6):
|
| 312 |
+
rows[int(z)].append(cs[sel, ai])
|
| 313 |
+
f.close()
|
| 314 |
+
out = {}
|
| 315 |
+
for nuc, z in (('1H', 1), ('13C', 6)):
|
| 316 |
+
M = np.array(rows[z])
|
| 317 |
+
M, labs, fams = sort_by_family(M, list(labels), list(families))
|
| 318 |
+
# use DSD-PBEP86 as the reference vector for delta22 so the scaled RMSE
|
| 319 |
+
# bar plot mirrors NS372's CCSD(T) role
|
| 320 |
+
ref_idx = labs.index(DELTA22_REF)
|
| 321 |
+
out[nuc] = dict(M=M, methods=labs, ref=M[:, ref_idx], families=fams)
|
| 322 |
+
return out
|
analysis/code/leveling_plots.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Correlation-matrix and PCA-loadings plotting engine for the leveling-effect study (SI Figure S3).
|
| 2 |
+
|
| 3 |
+
Draws the two figure types built by build_nb_leveling.py: per-nucleus inter-method Pearson
|
| 4 |
+
correlation matrices (log-scaled, with a scaled-RMSE side bar and a colour-scale histogram inset)
|
| 5 |
+
and PC1/PC2 method-loading panels (with a fitted parabola and adjustText-repelled labels). The
|
| 6 |
+
notebook supplies the family-colour map, nucleus display labels, and adjustText tuning as arguments.
|
| 7 |
+
"""
|
| 8 |
+
import numpy as np
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
from adjustText import adjust_text
|
| 11 |
+
|
| 12 |
+
from leveling import log_r, parabola_curve
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _adjust_pca_labels(texts, ax, nuc, pca_adjust, pca_adjust_default):
|
| 16 |
+
params = pca_adjust.get(nuc, pca_adjust_default)
|
| 17 |
+
adjust_text(texts, ax=ax, max_move=None, min_arrow_len=0,
|
| 18 |
+
ensure_inside_axes=True,
|
| 19 |
+
arrowprops=dict(arrowstyle='-', color='0.55', lw=0.35),
|
| 20 |
+
**params)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _family_legend(ax, families, family_colors, include_ref=True):
|
| 24 |
+
present = []
|
| 25 |
+
for f in families:
|
| 26 |
+
if f == 'ref' and not include_ref:
|
| 27 |
+
continue
|
| 28 |
+
if f not in present:
|
| 29 |
+
present.append(f)
|
| 30 |
+
handles = [plt.Line2D([], [], marker='*' if f=='ref' else 'o', ls='',
|
| 31 |
+
mfc=family_colors.get(f,'#333333'), mec='k',
|
| 32 |
+
ms=12 if f=='ref' else 6.5,
|
| 33 |
+
label='CCSD(T)' if f=='ref' else f)
|
| 34 |
+
for f in present]
|
| 35 |
+
ax.legend(handles=handles, loc='best', fontsize=6.5, ncol=2,
|
| 36 |
+
frameon=True, framealpha=0.9)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _pca_panel(ax, name, nuc, r, families, family_colors, nuc_display, pca_adjust, pca_adjust_default):
|
| 40 |
+
pc1, pc2 = r['pc1'], r['pc2']
|
| 41 |
+
methods = r['methods']
|
| 42 |
+
cx, cy = parabola_curve(r['par'])
|
| 43 |
+
# small padding: the dense central cluster fills more of the plot in inches,
|
| 44 |
+
# giving adjustText more room per label
|
| 45 |
+
xpad = 0.08*(pc1.max()-pc1.min()+1e-9)
|
| 46 |
+
ypad = 0.08*(pc2.max()-pc2.min()+1e-9)
|
| 47 |
+
xlim = (pc1.min()-xpad, pc1.max()+xpad)
|
| 48 |
+
ylim = (pc2.min()-ypad, pc2.max()+ypad)
|
| 49 |
+
m = (cx>=xlim[0])&(cx<=xlim[1])&(cy>=ylim[0])&(cy<=ylim[1])
|
| 50 |
+
ax.plot(cx[m], cy[m], color='0.75', ls='--', lw=1.2, alpha=0.7, zorder=1)
|
| 51 |
+
texts = []
|
| 52 |
+
for i, lab in enumerate(methods):
|
| 53 |
+
fam = families[i]
|
| 54 |
+
if fam == 'ref':
|
| 55 |
+
ax.scatter(pc1[i], pc2[i], s=75, marker='*',
|
| 56 |
+
c=family_colors['ref'], edgecolors='white',
|
| 57 |
+
linewidths=0.8, zorder=5)
|
| 58 |
+
else:
|
| 59 |
+
ax.scatter(pc1[i], pc2[i], s=11,
|
| 60 |
+
c=family_colors.get(fam, '#333333'),
|
| 61 |
+
edgecolors='black', linewidths=0.3, zorder=4)
|
| 62 |
+
texts.append(ax.text(pc1[i], pc2[i], lab, fontsize=3.8, zorder=6))
|
| 63 |
+
ax.set_xlim(*xlim); ax.set_ylim(*ylim)
|
| 64 |
+
_adjust_pca_labels(texts, ax, nuc, pca_adjust, pca_adjust_default)
|
| 65 |
+
_family_legend(ax, families, family_colors, include_ref=True)
|
| 66 |
+
ev = r['ev']
|
| 67 |
+
ax.set_title(f'{name} {nuc_display.get(nuc, nuc)} - '
|
| 68 |
+
f'method loadings in PC1/PC2 space\n'
|
| 69 |
+
f'PC1 = {ev[0]*100:.2f}%, PC2 = {ev[1]*100:.2f}%, '
|
| 70 |
+
f'parabola R² = {r["par"]["r2"]:.3f}', fontsize=9)
|
| 71 |
+
ax.set_xlabel('PC1 loading', fontsize=8)
|
| 72 |
+
ax.set_ylabel('PC2 loading', fontsize=8)
|
| 73 |
+
ax.tick_params(labelsize=7)
|
| 74 |
+
ax.axhline(0, color='gray', lw=0.4); ax.axvline(0, color='gray', lw=0.4)
|
| 75 |
+
ax.grid(True, ls=':', alpha=0.3)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def plot_pca_pair(name, nucs, res, fam_lookup, family_colors, nuc_display, pca_adjust, pca_adjust_default):
|
| 79 |
+
"""PC1/PC2 method-loading panels, one nucleus per row, stacked in a single figure."""
|
| 80 |
+
n = len(nucs)
|
| 81 |
+
fig, axes = plt.subplots(n, 1, figsize=(7.0, 5.2*n), squeeze=False)
|
| 82 |
+
for k, nuc in enumerate(nucs):
|
| 83 |
+
_pca_panel(axes[k][0], name, nuc, res[nuc], fam_lookup[nuc],
|
| 84 |
+
family_colors, nuc_display, pca_adjust, pca_adjust_default)
|
| 85 |
+
fig.tight_layout(h_pad=3.6)
|
| 86 |
+
return fig
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def plot_corr_matrix(name, nucs, res, fam_lookup, vmax, ref_name, nuc_display):
|
| 90 |
+
"""Per-nucleus inter-method correlation heatmap with a scaled-RMSE side bar.
|
| 91 |
+
|
| 92 |
+
Layout: [ RMSE bar (viridis, bars grow leftward) | heatmap (inferno) ]. Methods are reordered so
|
| 93 |
+
the reference is first and the rest are sorted by ascending scaled RMSE vs the reference. The
|
| 94 |
+
heatmap is the lower triangle with the diagonal removed; its upper-right corner carries an inset
|
| 95 |
+
histogram of all pairwise log-correlations with the colour bar below it, showing two tick sets
|
| 96 |
+
(bottom = -log10(1-|r|) integers, top = the equivalent raw r values). All matrices share `vmax`.
|
| 97 |
+
"""
|
| 98 |
+
nm = len(res[nucs[0]]['methods'])
|
| 99 |
+
m = nm - 1
|
| 100 |
+
hm_h, hm_w, bar_w = 0.135*nm + 1.9, 6.0, 1.5
|
| 101 |
+
fig_w = bar_w + hm_w
|
| 102 |
+
fig_h = hm_h * len(nucs) + 0.4
|
| 103 |
+
fig, axes = plt.subplots(len(nucs), 2, sharey='row',
|
| 104 |
+
gridspec_kw={'width_ratios': [bar_w, hm_w]},
|
| 105 |
+
figsize=(fig_w, fig_h),
|
| 106 |
+
layout='constrained', squeeze=False)
|
| 107 |
+
fig.get_layout_engine().set(wspace=0.01) # tighten gap to y-labels
|
| 108 |
+
cmap_corr = plt.cm.inferno.copy(); cmap_corr.set_bad('white')
|
| 109 |
+
cmap_rmse = plt.cm.viridis
|
| 110 |
+
fs = 4.3 if m > 24 else 6.0
|
| 111 |
+
ticks_log = [t for t in [1, 2, 3, 4, 5] if t <= vmax + 0.01]
|
| 112 |
+
for k, nuc in enumerate(nucs):
|
| 113 |
+
axR = axes[k, 0]; ax = axes[k, 1]
|
| 114 |
+
r = res[nuc]; R = r['R']; methods = r['methods']
|
| 115 |
+
rmse_dict = r['scaled_rmse']
|
| 116 |
+
# ----- reorder: reference first, others by ascending RMSE vs ref -----
|
| 117 |
+
ref_idx = methods.index(ref_name)
|
| 118 |
+
others = [i for i in range(nm) if i != ref_idx]
|
| 119 |
+
others_sorted = sorted(others, key=lambda i: rmse_dict[methods[i]])
|
| 120 |
+
new_order = [ref_idx] + others_sorted
|
| 121 |
+
methods_s = [methods[i] for i in new_order]
|
| 122 |
+
R_s = R[np.ix_(new_order, new_order)]
|
| 123 |
+
rmse_s = np.array([rmse_dict[mth] for mth in methods_s])
|
| 124 |
+
# ----- heatmap (drop row 0 = ref, col -1 = worst-RMSE method) -----
|
| 125 |
+
sub = R_s[1:, :-1]
|
| 126 |
+
hide = np.triu(np.ones((m, m), dtype=bool), k=1)
|
| 127 |
+
lr = log_r(sub)
|
| 128 |
+
im = ax.imshow(np.ma.masked_array(lr, hide), cmap=cmap_corr,
|
| 129 |
+
vmin=0, vmax=vmax, aspect='auto')
|
| 130 |
+
ax.set_xticks(range(m))
|
| 131 |
+
ax.set_xticklabels(methods_s[:-1], rotation=90, fontsize=fs)
|
| 132 |
+
ax.tick_params(left=False, labelleft=False)
|
| 133 |
+
if m <= 20:
|
| 134 |
+
for i in range(m):
|
| 135 |
+
for j in range(i+1):
|
| 136 |
+
ax.text(j, i, f'{sub[i,j]:.5f}', ha='center', va='center',
|
| 137 |
+
fontsize=3.4,
|
| 138 |
+
color='white' if lr[i,j] < vmax*0.5 else 'black')
|
| 139 |
+
ax.set_title(f'{name} {nuc_display.get(nuc, nuc)} - '
|
| 140 |
+
f'inter-method Pearson correlation', fontsize=9)
|
| 141 |
+
# ----- histogram + colour bar inset, jammed into the upper-right -----
|
| 142 |
+
# NS372 (kaupp): scale the inset up 20% so it fills more of the corner.
|
| 143 |
+
# A small visible gap is left between hist bottom and cb top so the
|
| 144 |
+
# histogram's tick marks are visible; the dual stacked tick labels
|
| 145 |
+
# under the cb (top line = -log10(1-|r|), bottom line = Pearson r)
|
| 146 |
+
# serve both axes since hist and cb share x-extent.
|
| 147 |
+
all_lr = lr[~hide]
|
| 148 |
+
counts, edges = np.histogram(all_lr, bins=28, range=(0.0, vmax))
|
| 149 |
+
centers = 0.5*(edges[:-1] + edges[1:])
|
| 150 |
+
scale = 1.2 if name == 'NS372' else 1.0
|
| 151 |
+
hist_w = 0.50 * scale
|
| 152 |
+
hist_h = 0.18 * scale
|
| 153 |
+
cb_h = 0.014 # thinner colour bar
|
| 154 |
+
gap = 0.012 # visible gap for hist tick marks
|
| 155 |
+
hist_top = 0.97
|
| 156 |
+
x0 = 0.98 - hist_w
|
| 157 |
+
hist_y0 = hist_top - hist_h
|
| 158 |
+
cb_y0 = hist_y0 - gap - cb_h
|
| 159 |
+
hax = ax.inset_axes([x0, hist_y0, hist_w, hist_h])
|
| 160 |
+
hax.set_facecolor('white')
|
| 161 |
+
hax.bar(centers, counts, width=(edges[1]-edges[0])*0.92,
|
| 162 |
+
color=cmap_corr(centers / vmax), edgecolor='black', linewidth=0.3)
|
| 163 |
+
hax.axvline(float(np.median(all_lr)), color='black', lw=1.6, ls='--')
|
| 164 |
+
hax.set_xlim(0, vmax)
|
| 165 |
+
hax.set_xticks(ticks_log)
|
| 166 |
+
hax.tick_params(axis='x', labelbottom=False, bottom=True, length=3)
|
| 167 |
+
hax.set_ylabel('count', fontsize=6, labelpad=1)
|
| 168 |
+
hax.tick_params(axis='y', labelsize=5)
|
| 169 |
+
hax.grid(axis='y', ls=':', alpha=0.4)
|
| 170 |
+
# colour bar, slightly below the histogram with a small visible gap
|
| 171 |
+
cbax = ax.inset_axes([x0, cb_y0, hist_w, cb_h])
|
| 172 |
+
cb = fig.colorbar(im, cax=cbax, orientation='horizontal')
|
| 173 |
+
cb.set_ticks(ticks_log)
|
| 174 |
+
cb.ax.set_xticklabels([f'{t}\n0.{"9"*t}' for t in ticks_log])
|
| 175 |
+
cb.ax.tick_params(axis='x', labelsize=5.5, pad=1,
|
| 176 |
+
top=False, labeltop=False)
|
| 177 |
+
cb.set_label('-log10(1-|r|) / Pearson r', fontsize=6, labelpad=2)
|
| 178 |
+
# ----- RMSE bar on the LEFT (viridis, bars grow leftward) -----
|
| 179 |
+
row_methods = methods_s[1:]
|
| 180 |
+
rmse_row = rmse_s[1:]
|
| 181 |
+
rmse_max = max(rmse_row.max(), 1e-6)
|
| 182 |
+
bar_colors = cmap_rmse(rmse_row / rmse_max)
|
| 183 |
+
axR.barh(range(m), rmse_row, color=bar_colors,
|
| 184 |
+
edgecolor='black', linewidth=0.3)
|
| 185 |
+
axR.yaxis.set_ticks_position('right')
|
| 186 |
+
axR.yaxis.set_label_position('right')
|
| 187 |
+
axR.set_yticks(range(m))
|
| 188 |
+
axR.set_yticklabels(row_methods, fontsize=fs)
|
| 189 |
+
axR.set_xlim(rmse_max * 1.06, 0) # reversed: 0 on the right
|
| 190 |
+
axR.set_xlabel(f'scaled RMSE (ppm)\nvs {ref_name}', fontsize=6.5)
|
| 191 |
+
axR.tick_params(axis='x', labelsize=5.5)
|
| 192 |
+
axR.tick_params(axis='y', length=0, pad=2)
|
| 193 |
+
axR.grid(axis='x', ls=':', alpha=0.4)
|
| 194 |
+
axR.set_ylim(m - 0.5, -0.5) # imshow has inverted y
|
| 195 |
+
return fig
|
analysis/code/magnet_benchmark.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tables S3 and S4: MagNET shielding accuracy across stationary, vibrated, and solvated geometries.
|
| 2 |
+
|
| 3 |
+
How well MagNET reproduces its DFT training reference (PBE0/pcSseg-1) for the foundation model and the
|
| 4 |
+
chloroform MagNET-x, on four test sets:
|
| 5 |
+
|
| 6 |
+
stationary_internal held-out GDB resting geometries (sigma-shake test split)
|
| 7 |
+
vibrated_internal MD frames of those molecules (gdb_qcd)
|
| 8 |
+
vibrated_external perturbed outside-benchmark geometries (dft8k)
|
| 9 |
+
isolated_chloroform isolated-solute chloroform MD snapshots (sigma-fresh)
|
| 10 |
+
|
| 11 |
+
Two reproduction paths; the shipped notebook uses the first:
|
| 12 |
+
1. EXACT, from `data/magnet_test_predictions/`: raw per-atom predictions over the FULL test sets,
|
| 13 |
+
computed once from the released checkpoints. `exact_stats_table` maps each (nucleus, model, test
|
| 14 |
+
set) to its group and calls the reader's `stats()`; median/mean/RMSE reproduce exactly, no
|
| 15 |
+
checkpoints needed.
|
| 16 |
+
2. FROM SCRATCH: `cases_*` assembles (atomic numbers, geometry, DFT shielding) test cases and
|
| 17 |
+
`magnet_benchmark_run.py` runs a model over them. Needs the checkpoints and only samples each set.
|
| 18 |
+
|
| 19 |
+
RMSE note (path 2): median and mean AE reproduce from a modest sample; RMSE does not. For the vibrated
|
| 20 |
+
sets it is dominated by ~1-in-10,000 extreme MD frames (the CO-style breakdown of Figure S12), so the
|
| 21 |
+
published 13C RMSE (7.14 ppm) needs the full ~1.9M-frame set; path 1 sidesteps this. The
|
| 22 |
+
isolated-chloroform row runs in full even under path 2; its foundation 1H RMSE (0.30 ppm) vs MAE
|
| 23 |
+
(0.07 ppm) reflects a broad heavy tail of real deshielded geometries (not DFT failures), kept as-is.
|
| 24 |
+
The Figure S10 stationary-only ablation checkpoint is not released, so that figure is not reproduced.
|
| 25 |
+
"""
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
from stats import summarize_errors
|
| 29 |
+
|
| 30 |
+
# --------------------------------------------------------------------------- published SI values
|
| 31 |
+
# (median absolute error, mean absolute error, RMSE), per (model, test set). From SI Tables S3/S4.
|
| 32 |
+
# Keys: model in {"MagNET", "MagNET-x"}; test set as named above.
|
| 33 |
+
PUBLISHED = {
|
| 34 |
+
"1H": {
|
| 35 |
+
("MagNET", "stationary_internal"): (0.020946503, 0.030467307, 0.05941977),
|
| 36 |
+
("MagNET", "vibrated_internal"): (0.027399063, 0.039091997, 0.10485571),
|
| 37 |
+
("MagNET", "vibrated_external"): (0.03241539, 0.04796361, 0.11397923),
|
| 38 |
+
("MagNET", "isolated_chloroform"): (0.03216362, 0.071752764, 0.29437021),
|
| 39 |
+
("MagNET-x", "stationary_internal"): (0.02163124, 0.031185, 0.06033938),
|
| 40 |
+
("MagNET-x", "vibrated_internal"): (0.027683258, 0.039436683, 0.10467214),
|
| 41 |
+
("MagNET-x", "vibrated_external"): (0.023479462, 0.034395833, 0.06877531),
|
| 42 |
+
("MagNET-x", "isolated_chloroform"): (0.022384644, 0.030986413, 0.04612577),
|
| 43 |
+
},
|
| 44 |
+
"13C": {
|
| 45 |
+
("MagNET", "stationary_internal"): (0.23955917, 0.36742094, 1.98238429),
|
| 46 |
+
("MagNET", "vibrated_internal"): (0.31917763, 0.47851510, 7.13765463),
|
| 47 |
+
("MagNET", "vibrated_external"): (0.38428497, 0.57037073, 0.98834883),
|
| 48 |
+
("MagNET", "isolated_chloroform"): (0.36051940, 0.58715980, 1.22315871),
|
| 49 |
+
("MagNET-x", "stationary_internal"): (0.25761414, 0.39035153, 1.98721069),
|
| 50 |
+
("MagNET-x", "vibrated_internal"): (0.33805847, 0.50241500, 7.15089956),
|
| 51 |
+
("MagNET-x", "vibrated_external"): (0.32779312, 0.47457147, 0.81721424),
|
| 52 |
+
("MagNET-x", "isolated_chloroform"): (0.30544280, 0.43592300, 0.72233414),
|
| 53 |
+
},
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
NUCLEUS_Z = {"1H": 1, "13C": 6}
|
| 57 |
+
TEST_SETS = ["stationary_internal", "vibrated_internal", "vibrated_external", "isolated_chloroform"]
|
| 58 |
+
MODELS = ["MagNET", "MagNET-x"]
|
| 59 |
+
|
| 60 |
+
# Table S5: QCD-correction statistics for the foundation MagNET, one row per nucleus. Computed over
|
| 61 |
+
# the qcdtraj2500 test set (2500 molecules) at PBE0/pcSseg-1. Published (median AE, MAE, RMSE):
|
| 62 |
+
PUBLISHED_S5 = {
|
| 63 |
+
"1H": (0.01526165, 0.023047674, 0.04020983),
|
| 64 |
+
"13C": (0.17198181, 0.26223338, 0.50198762),
|
| 65 |
+
}
|
| 66 |
+
QCD_GROUP = {"1H": "qcdtraj2500_H_pretrained_H", "13C": "qcdtraj2500_C_pretrained_C"}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def qcd_stats_table(predictions_path, decode_module):
|
| 70 |
+
"""Table S5: (median AE, MAE, RMSE, n) of the QCD-correction errors for the foundation MagNET,
|
| 71 |
+
one row per nucleus, read from data/magnet_test_predictions/ via qcd_correction_stats."""
|
| 72 |
+
rows = []
|
| 73 |
+
for nucleus in ("1H", "13C"):
|
| 74 |
+
med, mae, rmse, n = decode_module.qcd_correction_stats(predictions_path, QCD_GROUP[nucleus])
|
| 75 |
+
rows.append(dict(model=f"MagNET ({nucleus})", median_ae=med, mae=mae, rmse=rmse, n=n))
|
| 76 |
+
return rows
|
| 77 |
+
|
| 78 |
+
# --------------------------------------------------------------------------- exact reproduction
|
| 79 |
+
# from data/magnet_test_predictions/ (see the module docstring, path 1). That dataset names its
|
| 80 |
+
# groups after its own test-set families (gasphaseinternal, gasphasedft8k, solutesmd500isolated),
|
| 81 |
+
# which don't match the four TEST_SETS names above one-to-one, so this is the mapping between them.
|
| 82 |
+
TEST_PREDICTIONS_MODEL_GROUP = {"MagNET": "pretrained", "MagNET-x": "chloroform"}
|
| 83 |
+
TEST_PREDICTIONS_NUCLEUS = {"1H": "H", "13C": "C"}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_predictions_group(model, test_set, nucleus):
|
| 87 |
+
"""The data/magnet_test_predictions/ group name holding the raw predictions for one Table S3/S4
|
| 88 |
+
row. `model` is "MagNET" or "MagNET-x", `test_set` is one of TEST_SETS, `nucleus` is "1H" or
|
| 89 |
+
"13C". See the TEST_PREDICTIONS_* maps below for the underlying group-naming scheme."""
|
| 90 |
+
n = TEST_PREDICTIONS_NUCLEUS[nucleus]
|
| 91 |
+
m = TEST_PREDICTIONS_MODEL_GROUP[model]
|
| 92 |
+
if test_set == "stationary_internal":
|
| 93 |
+
return f"gasphaseinternal_{n}_{m}_{n}_vib0"
|
| 94 |
+
if test_set == "vibrated_internal":
|
| 95 |
+
return f"gasphaseinternal_{n}_{m}_{n}_vib1"
|
| 96 |
+
if test_set == "vibrated_external":
|
| 97 |
+
return f"gasphasedft8k_{n}_{m}_{n}_vib1"
|
| 98 |
+
if test_set == "isolated_chloroform":
|
| 99 |
+
return f"solutesmd500isolated_chloroform_{n}_{m}_{n}"
|
| 100 |
+
raise ValueError(f"unknown test set {test_set!r}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def exact_stats_table(predictions_path, decode_module):
|
| 104 |
+
"""One row per (nucleus, model, test set), with (median_ae, mae, rmse, n) read directly from the
|
| 105 |
+
full released MagNET predictions -- no sampling, no live inference, so RMSE reproduces exactly
|
| 106 |
+
too (see the module docstring, path 1). `decode_module` is
|
| 107 |
+
data/magnet_test_predictions/magnet_test_predictions_reader.py; the caller imports and passes it
|
| 108 |
+
in, so this module carries no path dependency on data/magnet_test_predictions/."""
|
| 109 |
+
rows = []
|
| 110 |
+
for nucleus in NUCLEUS_Z:
|
| 111 |
+
for model in MODELS:
|
| 112 |
+
for test_set in TEST_SETS:
|
| 113 |
+
group = test_predictions_group(model, test_set, nucleus)
|
| 114 |
+
med, mae, rmse, n = decode_module.stats(predictions_path, group)
|
| 115 |
+
rows.append({"nucleus": nucleus, "model": model, "test_set": test_set,
|
| 116 |
+
"median_ae": med, "mae": mae, "rmse": rmse, "n": n})
|
| 117 |
+
return rows
|
| 118 |
+
|
| 119 |
+
# The elements MagNET was trained on (matches SUPPORTED_ELEMENTS in the magnet package). Structures
|
| 120 |
+
# with any other element (e.g. the phosphorus in dft8k) are excluded from the test sets: the model
|
| 121 |
+
# refuses them, and they cannot be fairly scored anyway.
|
| 122 |
+
SUPPORTED_ELEMENTS = frozenset({1, 6, 7, 8, 9, 16, 17})
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def filter_supported(atomic_numbers_list, geometries_list, dft_list):
|
| 126 |
+
"""Drop any structure whose atoms are not all in SUPPORTED_ELEMENTS. Returns the three lists,
|
| 127 |
+
filtered in lockstep, plus the number dropped."""
|
| 128 |
+
keep = [i for i, an in enumerate(atomic_numbers_list)
|
| 129 |
+
if set(np.unique(an).tolist()) <= SUPPORTED_ELEMENTS]
|
| 130 |
+
dropped = len(atomic_numbers_list) - len(keep)
|
| 131 |
+
return ([atomic_numbers_list[i] for i in keep],
|
| 132 |
+
[geometries_list[i] for i in keep],
|
| 133 |
+
[dft_list[i] for i in keep], dropped)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# --------------------------------------------------------------------------- error statistics
|
| 137 |
+
|
| 138 |
+
def collect_abs_errors(predicted_list, dft_list, atomic_numbers_list, z):
|
| 139 |
+
"""Concatenate the absolute errors |predicted - dft| over all atoms with atomic number `z`,
|
| 140 |
+
across a list of test cases. Atoms where either value is NaN (not predicted or not computed) are
|
| 141 |
+
dropped."""
|
| 142 |
+
out = []
|
| 143 |
+
for pred, dft, an in zip(predicted_list, dft_list, atomic_numbers_list):
|
| 144 |
+
pred = np.asarray(pred, dtype=np.float64)
|
| 145 |
+
dft = np.asarray(dft, dtype=np.float64)
|
| 146 |
+
an = np.asarray(an)
|
| 147 |
+
mask = (an == z) & np.isfinite(pred) & np.isfinite(dft)
|
| 148 |
+
if mask.any():
|
| 149 |
+
out.append(np.abs(pred[mask] - dft[mask]))
|
| 150 |
+
return np.concatenate(out) if out else np.array([])
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def summarize(abs_errors):
|
| 154 |
+
"""Median absolute error, mean absolute error, RMSE, and count for an array of absolute errors."""
|
| 155 |
+
e = np.asarray(abs_errors, dtype=np.float64)
|
| 156 |
+
if e.size == 0:
|
| 157 |
+
return {"median_ae": float("nan"), "mae": float("nan"), "rmse": float("nan"), "n": 0}
|
| 158 |
+
return summarize_errors(e, np.zeros_like(e))
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def error_table(predicted_list, dft_list, atomic_numbers_list):
|
| 162 |
+
"""Summaries for both nuclei: {"1H": {...}, "13C": {...}}."""
|
| 163 |
+
return {
|
| 164 |
+
nucleus: summarize(collect_abs_errors(predicted_list, dft_list, atomic_numbers_list, z))
|
| 165 |
+
for nucleus, z in NUCLEUS_Z.items()
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# --------------------------------------------------------------------------- test-case assembly
|
| 170 |
+
# Each function returns three parallel lists: atomic_numbers, geometry (n_atoms, 3), and the DFT
|
| 171 |
+
# reference shielding (n_atoms,), one entry per sampled structure. A model is then run over the
|
| 172 |
+
# geometries and compared with the DFT shieldings.
|
| 173 |
+
|
| 174 |
+
def cases_stationary_internal(sigma_shake, n_sample=2000, seed=0):
|
| 175 |
+
"""Resting geometries of held-out GDB molecules (sigma-shake test split)."""
|
| 176 |
+
test_ids = sigma_shake.split("test")
|
| 177 |
+
rng = np.random.default_rng(seed)
|
| 178 |
+
chosen = rng.choice(test_ids, size=min(n_sample, len(test_ids)), replace=False)
|
| 179 |
+
ans, geos, dfts = [], [], []
|
| 180 |
+
for mid in chosen:
|
| 181 |
+
m = sigma_shake.by_id(int(mid))
|
| 182 |
+
ans.append(m["atomic_numbers"])
|
| 183 |
+
geos.append(m["stationary_coords"])
|
| 184 |
+
dfts.append(m["shielding_stationary"])
|
| 185 |
+
return ans, geos, dfts
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def cases_vibrated_internal(gdb_qcd, n_sample=2000, frames_per_molecule=4, seed=0):
|
| 189 |
+
"""Molecular-dynamics frames of held-out GDB molecules (gdb_qcd)."""
|
| 190 |
+
rng = np.random.default_rng(seed)
|
| 191 |
+
mol_idx = rng.choice(gdb_qcd.n_molecules, size=min(n_sample, gdb_qcd.n_molecules), replace=False)
|
| 192 |
+
ans, geos, dfts = [], [], []
|
| 193 |
+
for i in mol_idx:
|
| 194 |
+
m = gdb_qcd.molecule(int(i))
|
| 195 |
+
nt, nf = m["coordinates"].shape[0], m["coordinates"].shape[1]
|
| 196 |
+
for _ in range(frames_per_molecule):
|
| 197 |
+
t, f = int(rng.integers(nt)), int(rng.integers(nf))
|
| 198 |
+
ans.append(m["atomic_numbers"])
|
| 199 |
+
geos.append(m["coordinates"][t, f])
|
| 200 |
+
dfts.append(m["shieldings"][t, f])
|
| 201 |
+
return ans, geos, dfts
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def cases_vibrated_external(dft8k, n_sample=2000, seed=0):
|
| 205 |
+
"""Perturbed (non-stationary) geometries of the dft8k benchmark; geometry 0 is the reference, 1
|
| 206 |
+
and 2 are perturbations, so the vibrated set uses geometries 1 and 2."""
|
| 207 |
+
rng = np.random.default_rng(seed)
|
| 208 |
+
n = dft8k.b3lyp.n_molecules
|
| 209 |
+
chosen = rng.choice(n, size=min(n_sample, n), replace=False)
|
| 210 |
+
ans, geos, dfts = [], [], []
|
| 211 |
+
for i in chosen:
|
| 212 |
+
m = dft8k.b3lyp.molecule(int(i))
|
| 213 |
+
for g in (1, 2):
|
| 214 |
+
ans.append(m["atomic_numbers"])
|
| 215 |
+
geos.append(m["coordinates"][g])
|
| 216 |
+
dfts.append(m["shielding"][g])
|
| 217 |
+
return ans, geos, dfts
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# The isolated-chloroform test set is defined entirely from the released sigma-fresh file, so the
|
| 221 |
+
# table is reproducible without any external file. The foundation model's RMSE on this row is large
|
| 222 |
+
# (0.30 ppm 1H) because the error is heavy-tailed, not because of a few bad frames: the optional
|
| 223 |
+
# scrub in isolated_chloroform_testset removes the most strongly-deshielded frames and the RMSE
|
| 224 |
+
# barely moves (0.31 -> 0.30). Those frames are real geometries, not DFT failures, so the default
|
| 225 |
+
# test set keeps them.
|
| 226 |
+
|
| 227 |
+
CHLOROFORM_OUTLIER_SIGMA = 5 # cutoff used by the MagNET-x training pipeline (train_MagNET_X.py)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _chloroform_test_solutes(sigma_fresh):
|
| 231 |
+
"""Solute indices flagged as the held-out test split in the released sigma-fresh file. This
|
| 232 |
+
matches the paper's chloroform test split exactly (534 solutes)."""
|
| 233 |
+
group = sigma_fresh.f["chloroform"]
|
| 234 |
+
out = []
|
| 235 |
+
for name in group.keys():
|
| 236 |
+
kind = group[name].attrs["type"]
|
| 237 |
+
if isinstance(kind, bytes):
|
| 238 |
+
kind = kind.decode()
|
| 239 |
+
if kind == "test":
|
| 240 |
+
out.append(int(name.split("_")[1]))
|
| 241 |
+
return sorted(out)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def isolated_chloroform_testset(sigma_fresh, max_frame=10, n_sigma=CHLOROFORM_OUTLIER_SIGMA):
|
| 245 |
+
"""Assemble the chloroform isolated-solute test set for Tables S3/S4, from released data only.
|
| 246 |
+
|
| 247 |
+
Each case is one isolated-solute snapshot: the solute geometry from a chloroform MD frame and the
|
| 248 |
+
isolated (no-solvent) DFT shielding computed for it. The set is defined as:
|
| 249 |
+
|
| 250 |
+
* test solutes : attrs['type'] == 'test' (the paper's held-out split; evaluating MagNET-x on
|
| 251 |
+
its training solutes would be data leakage).
|
| 252 |
+
* frames : frame_index 1..max_frame where the isolated shielding was actually computed.
|
| 253 |
+
|
| 254 |
+
The published Tables S3/S4 use the full set (ans_all/geos_all/dfts_all). `flagged` marks the most
|
| 255 |
+
strongly deshielded frames (~16-17 ppm isolated shielding, far-downfield O-H / N-H), found with the
|
| 256 |
+
same +/- n_sigma cut the MagNET-x training pipeline applies (train_MagNET_X.py), recomputed here
|
| 257 |
+
from the dataset's own shieldings. Their shieldings vary smoothly along each MD trajectory and sit
|
| 258 |
+
on a continuous low-shielding tail, so this split is a diagnostic for documentation, not a
|
| 259 |
+
correction: ans/geos/dfts (with those frames removed) exist only to show how much of the RMSE they
|
| 260 |
+
carry.
|
| 261 |
+
|
| 262 |
+
Returns a dict:
|
| 263 |
+
ans, geos, dfts -- test set with the deshielded-tail frames removed (diagnostic only)
|
| 264 |
+
ans_all, geos_all, dfts_all -- the full published test set (reproduces Tables S3/S4)
|
| 265 |
+
flagged -- sorted [(solute_index, frame_index), ...] in the deshielded tail
|
| 266 |
+
bands -- {'isolated': (lo, hi), 'solvated': (lo, hi)} n_sigma bands (ppm)
|
| 267 |
+
"""
|
| 268 |
+
frames = []
|
| 269 |
+
iso_H, solv_H = [], []
|
| 270 |
+
for si in _chloroform_test_solutes(sigma_fresh):
|
| 271 |
+
s = sigma_fresh.solute("chloroform", si)
|
| 272 |
+
ns = s["n_solute_atoms"]
|
| 273 |
+
an = s["atomic_numbers"][:ns] # solute is the leading ns atoms
|
| 274 |
+
is_h = an == 1
|
| 275 |
+
iso = s["shielding_isolated"] # (n_frames, n_solute) ppm, NaN where not done
|
| 276 |
+
solv = s["shielding_solvated"]
|
| 277 |
+
coords = s["coordinates"]
|
| 278 |
+
for row in range(min(max_frame, iso.shape[0])): # row r -> frame_index r+1 (1-based)
|
| 279 |
+
sh = iso[row]
|
| 280 |
+
if not np.isfinite(sh).any():
|
| 281 |
+
continue
|
| 282 |
+
solv_row = solv[row]
|
| 283 |
+
frames.append({"solute": si, "frame": row + 1, "an": an,
|
| 284 |
+
"geo": coords[row][:ns], "iso": sh, "solv": solv_row, "is_h": is_h})
|
| 285 |
+
h = sh[is_h]
|
| 286 |
+
iso_H.append(h[np.isfinite(h)])
|
| 287 |
+
h = solv_row[is_h]
|
| 288 |
+
solv_H.append(h[np.isfinite(h)])
|
| 289 |
+
iso_H = np.concatenate(iso_H)
|
| 290 |
+
solv_H = np.concatenate(solv_H)
|
| 291 |
+
iso_band = (iso_H.mean() - n_sigma * iso_H.std(), iso_H.mean() + n_sigma * iso_H.std())
|
| 292 |
+
solv_band = (solv_H.mean() - n_sigma * solv_H.std(), solv_H.mean() + n_sigma * solv_H.std())
|
| 293 |
+
|
| 294 |
+
def is_outlier(fr):
|
| 295 |
+
h = fr["iso"][fr["is_h"]]
|
| 296 |
+
h = h[np.isfinite(h)]
|
| 297 |
+
if ((h < iso_band[0]) | (h > iso_band[1])).any():
|
| 298 |
+
return True
|
| 299 |
+
h = fr["solv"][fr["is_h"]]
|
| 300 |
+
h = h[np.isfinite(h)]
|
| 301 |
+
return bool(h.size and ((h < solv_band[0]) | (h > solv_band[1])).any())
|
| 302 |
+
|
| 303 |
+
out = {"ans": [], "geos": [], "dfts": [], "ans_all": [], "geos_all": [], "dfts_all": [],
|
| 304 |
+
"flagged": [], "bands": {"isolated": iso_band, "solvated": solv_band}}
|
| 305 |
+
for fr in frames:
|
| 306 |
+
out["ans_all"].append(fr["an"])
|
| 307 |
+
out["geos_all"].append(fr["geo"])
|
| 308 |
+
out["dfts_all"].append(fr["iso"])
|
| 309 |
+
if is_outlier(fr):
|
| 310 |
+
out["flagged"].append((fr["solute"], fr["frame"]))
|
| 311 |
+
else:
|
| 312 |
+
out["ans"].append(fr["an"])
|
| 313 |
+
out["geos"].append(fr["geo"])
|
| 314 |
+
out["dfts"].append(fr["iso"])
|
| 315 |
+
out["flagged"].sort()
|
| 316 |
+
return out
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def cases_isolated_chloroform(sigma_fresh, scrub=False, max_frame=10):
|
| 320 |
+
"""Isolated-solute snapshots from chloroform MD (sigma-fresh), as (ans, geos, dfts).
|
| 321 |
+
|
| 322 |
+
Default (scrub=False) is the full published test set, which reproduces Tables S3/S4. scrub=True
|
| 323 |
+
drops the strongly-deshielded-proton frames; that is a diagnostic, not a correction (those frames
|
| 324 |
+
are real geometries, not DFT failures). See isolated_chloroform_testset.
|
| 325 |
+
"""
|
| 326 |
+
t = isolated_chloroform_testset(sigma_fresh, max_frame=max_frame)
|
| 327 |
+
return (t["ans"], t["geos"], t["dfts"]) if scrub else (t["ans_all"], t["geos_all"], t["dfts_all"])
|
analysis/code/magnet_benchmark_run.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Produce the magnet_benchmark results table by running MagNET and MagNET-x over the four test sets
|
| 2 |
+
and write it to performance_results.csv.
|
| 3 |
+
|
| 4 |
+
Needs the checkpoints and datasets, so it does not run in CI; they ship in the repo (Git LFS via the
|
| 5 |
+
Hugging Face checkout) and resolve automatically. One row per (model, test set, nucleus) with median
|
| 6 |
+
AE, MAE, RMSE, and atom count. The three vibrated/stationary sets are sampled; isolated-chloroform is
|
| 7 |
+
run in full. The shipped CSV used --sample 500 --seed 0; re-running reproduces it (sampled rows within
|
| 8 |
+
tolerance, isolated rows exactly).
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python magnet_benchmark_run.py --out performance_results.csv --n-passes 1 --sample 500 --seed 0
|
| 12 |
+
"""
|
| 13 |
+
import argparse
|
| 14 |
+
import csv
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
|
| 19 |
+
from paths import ensure_on_path, repo_root, dataset_file
|
| 20 |
+
|
| 21 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 22 |
+
GH = repo_root(__file__)
|
| 23 |
+
if HERE not in sys.path:
|
| 24 |
+
sys.path.insert(0, HERE)
|
| 25 |
+
ensure_on_path("analysis", "code", "shared", file=__file__) # stats.py etc. live here
|
| 26 |
+
ensure_on_path("magnet", file=__file__)
|
| 27 |
+
for _d in ("sigma-shake", "gdb_qcd", "dft8k", "sigma-fresh"):
|
| 28 |
+
ensure_on_path("data", _d, file=__file__)
|
| 29 |
+
|
| 30 |
+
import magnet_benchmark as M # noqa: E402
|
| 31 |
+
from run_magnet import compute_MagNET_foundation_shieldings, _predict_with # noqa: E402
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def magnet_x_chloroform_shieldings(ans, geos, n_passes, device):
|
| 35 |
+
"""Absolute shieldings from the MagNET-x chloroform model (run as a plain predictor, no solvent),
|
| 36 |
+
used to score MagNET-x on the same test sets as the foundation model."""
|
| 37 |
+
return _predict_with("MagNET-x-chloroform_H", "MagNET-x-chloroform_C", ans, geos,
|
| 38 |
+
n_passes=n_passes, device=device)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
MODEL_RUNNERS = {
|
| 42 |
+
"MagNET": compute_MagNET_foundation_shieldings,
|
| 43 |
+
"MagNET-x": magnet_x_chloroform_shieldings,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def assemble_all(paths, sample, seed):
|
| 48 |
+
"""Assemble the four test sets, returning {test_set: (ans, geos, dfts)}."""
|
| 49 |
+
from decode_sigma_shake import SigmaShake
|
| 50 |
+
from decode_gdb_qcd import GDBQCD
|
| 51 |
+
from dft8k_reader import DFT8k
|
| 52 |
+
from decode_sigma_fresh import SigmaFresh
|
| 53 |
+
|
| 54 |
+
raw = {}
|
| 55 |
+
with SigmaShake(paths["sigma_shake"]) as ss:
|
| 56 |
+
raw["stationary_internal"] = M.cases_stationary_internal(ss, n_sample=sample, seed=seed)
|
| 57 |
+
with GDBQCD(paths["gdb_qcd"]) as gq:
|
| 58 |
+
raw["vibrated_internal"] = M.cases_vibrated_internal(gq, n_sample=sample // 3, seed=seed)
|
| 59 |
+
with DFT8k(paths["dft8k"]) as d8:
|
| 60 |
+
raw["vibrated_external"] = M.cases_vibrated_external(d8, n_sample=sample, seed=seed)
|
| 61 |
+
with SigmaFresh(paths["sigma_fresh"]) as sf:
|
| 62 |
+
# the full published test set (held-out solutes, frames 1..10 with an isolated calculation);
|
| 63 |
+
# not sampled, so this row reproduces Tables S3/S4 exactly
|
| 64 |
+
raw["isolated_chloroform"] = M.cases_isolated_chloroform(sf, scrub=False)
|
| 65 |
+
# exclude molecules with elements outside MagNET's vocabulary (e.g. phosphorus in dft8k); the
|
| 66 |
+
# model would refuse them anyway, and scoring them would not be a fair test
|
| 67 |
+
cases = {}
|
| 68 |
+
for ts, (ans, geos, dfts) in raw.items():
|
| 69 |
+
a, g, d, dropped = M.filter_supported(ans, geos, dfts)
|
| 70 |
+
if dropped:
|
| 71 |
+
print(f" {ts}: dropped {dropped} structure(s) with unsupported elements", flush=True)
|
| 72 |
+
cases[ts] = (a, g, d)
|
| 73 |
+
return cases
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main():
|
| 77 |
+
"""Assemble the four test sets, run both models over them, and write performance_results.csv."""
|
| 78 |
+
ap = argparse.ArgumentParser()
|
| 79 |
+
ap.add_argument("--out", default=os.path.join(HERE, "..", "..", "data", "magnet_benchmark", "performance_results.csv"))
|
| 80 |
+
ap.add_argument("--n-passes", type=int, default=1)
|
| 81 |
+
ap.add_argument("--sample", type=int, default=500)
|
| 82 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 83 |
+
ap.add_argument("--device", default="cpu")
|
| 84 |
+
ap.add_argument("--sigma-shake", default=dataset_file("sigma-shake", root=GH))
|
| 85 |
+
ap.add_argument("--gdb-qcd", default=dataset_file("gdb_qcd", root=GH))
|
| 86 |
+
ap.add_argument("--dft8k", default=dataset_file("dft8k", root=GH))
|
| 87 |
+
ap.add_argument("--sigma-fresh", default=dataset_file("sigma-fresh", root=GH))
|
| 88 |
+
args = ap.parse_args()
|
| 89 |
+
|
| 90 |
+
paths = {"sigma_shake": args.sigma_shake, "gdb_qcd": args.gdb_qcd,
|
| 91 |
+
"dft8k": args.dft8k, "sigma_fresh": args.sigma_fresh}
|
| 92 |
+
print(f"assembling test cases (sample={args.sample}, seed={args.seed})...", flush=True)
|
| 93 |
+
cases = assemble_all(paths, args.sample, args.seed)
|
| 94 |
+
for ts, (ans, _, _) in cases.items():
|
| 95 |
+
print(f" {ts}: {len(ans)} structures", flush=True)
|
| 96 |
+
|
| 97 |
+
rows = []
|
| 98 |
+
for model, runner in MODEL_RUNNERS.items():
|
| 99 |
+
for ts in M.TEST_SETS:
|
| 100 |
+
ans, geos, dfts = cases[ts]
|
| 101 |
+
t = time.time()
|
| 102 |
+
pred = runner(ans, geos, args.n_passes, args.device)
|
| 103 |
+
tab = M.error_table(pred, dfts, ans)
|
| 104 |
+
dt = time.time() - t
|
| 105 |
+
for nucleus in ("1H", "13C"):
|
| 106 |
+
s = tab[nucleus]
|
| 107 |
+
rows.append(dict(model=model, test_set=ts, nucleus=nucleus,
|
| 108 |
+
median_ae=s["median_ae"], mae=s["mae"], rmse=s["rmse"], n=s["n"]))
|
| 109 |
+
print(f"{model:9s} {ts:20s} ({dt:.0f}s) "
|
| 110 |
+
f"1H mae {tab['1H']['mae']:.4f} 13C mae {tab['13C']['mae']:.4f}", flush=True)
|
| 111 |
+
|
| 112 |
+
with open(args.out, "w", newline="") as f:
|
| 113 |
+
w = csv.DictWriter(f, fieldnames=["model", "test_set", "nucleus", "median_ae", "mae", "rmse", "n"])
|
| 114 |
+
w.writeheader()
|
| 115 |
+
for r in rows:
|
| 116 |
+
w.writerow(r)
|
| 117 |
+
print(f"wrote {args.out} ({len(rows)} rows)")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
main()
|
analysis/code/nb_build.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared helpers for the ``build_nb_*.py`` notebook generators.
|
| 2 |
+
|
| 3 |
+
Each ``build_nb_<x>.py`` assembles a notebook from a list of cells built with :func:`md` and
|
| 4 |
+
:func:`code`, then writes it with :func:`save_notebook`. The notebook metadata written here is
|
| 5 |
+
intentionally minimal -- ``jupyter nbconvert --execute`` fills in the full ``language_info``/kernel
|
| 6 |
+
block when the notebook is run.
|
| 7 |
+
"""
|
| 8 |
+
import nbformat
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def md(source):
|
| 12 |
+
"""Return a markdown cell from a string, with leading/trailing blank lines stripped."""
|
| 13 |
+
return nbformat.v4.new_markdown_cell(source.strip("\n"))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def code(source):
|
| 17 |
+
"""Return a code cell from a string, with leading/trailing blank lines stripped."""
|
| 18 |
+
return nbformat.v4.new_code_cell(source.strip("\n"))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def save_notebook(cells, path):
|
| 22 |
+
"""Write a notebook made of ``cells`` to ``path`` and print a confirmation line."""
|
| 23 |
+
nb = nbformat.v4.new_notebook()
|
| 24 |
+
nb.cells = cells
|
| 25 |
+
nb.metadata = {"language_info": {"name": "python"}}
|
| 26 |
+
with open(path, "w") as f:
|
| 27 |
+
nbformat.write(nb, f)
|
| 28 |
+
print("wrote", path)
|
analysis/code/pareto_plot.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Broken-axis accuracy-vs-cost Pareto panel used by Figure 2A and SI Figure S1.
|
| 2 |
+
|
| 3 |
+
Both figures draw the same panel (fitting RMSE vs total compute time, colored by method type, shaped by
|
| 4 |
+
geometry source, sized by basis) and differ only in their config dicts and which panels they show, so
|
| 5 |
+
that shared drawing engine lives here instead of being duplicated in each notebook. Call
|
| 6 |
+
`plot_pareto_panel(...)`; the notebook supplies the points table and the styling dicts.
|
| 7 |
+
"""
|
| 8 |
+
import numpy as np
|
| 9 |
+
import seaborn as sns
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
from matplotlib.lines import Line2D
|
| 12 |
+
from matplotlib.gridspec import GridSpec
|
| 13 |
+
|
| 14 |
+
# methods colored as double hybrids; dlpno_mp2 is classified ab initio instead (checked first)
|
| 15 |
+
DOUBLE_HYBRID_METHODS = ["dsd_pbep86", "B2GP_PLYP", "B2PLYP", "mPW2PLYP", "revdsd_pbep86", "dlpno_mp2"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _format_seconds(seconds):
|
| 19 |
+
if not np.isfinite(seconds) or seconds <= 0:
|
| 20 |
+
return ""
|
| 21 |
+
nice = lambda v: str(int(round(v)))
|
| 22 |
+
if seconds < 60:
|
| 23 |
+
return f"{nice(seconds)} s"
|
| 24 |
+
if seconds < 3600:
|
| 25 |
+
return f"{nice(seconds / 60)} min"
|
| 26 |
+
if seconds < 86400:
|
| 27 |
+
return f"{nice(seconds / 3600)} h"
|
| 28 |
+
return f"{nice(seconds / 86400)} day"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _apply_time_xticklabels(ax, xticks):
|
| 32 |
+
ax.set_xticks(xticks)
|
| 33 |
+
ax.set_xticklabels([_format_seconds(10 ** float(t)) for t in xticks], fontsize=10)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _classify_method(method):
|
| 37 |
+
# returns (label_text, category); category drives color, label_text is the annotation
|
| 38 |
+
if method in ["hf", "mp2", "dlpno_mp2"]:
|
| 39 |
+
return method, "ab initio"
|
| 40 |
+
if method in DOUBLE_HYBRID_METHODS:
|
| 41 |
+
return method.lower(), "double hybrids"
|
| 42 |
+
if method.startswith("MagNET"):
|
| 43 |
+
return "MagNET-Zero", "MagNET-Zero"
|
| 44 |
+
if method.startswith(("wp", "wc")):
|
| 45 |
+
return method, "NMR-specific"
|
| 46 |
+
return method.split("_")[0], "DFT"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _classify_and_plot_points(df, x_range1, ax1, ax2, color_map, geometry_marker_map,
|
| 50 |
+
marker_alpha, manual_label_positions, labels_per_category=6):
|
| 51 |
+
size_dict = {"pcSseg1": 20, "pcSseg2": 50, "pcSseg3": 100, "N/A": 110}
|
| 52 |
+
color_counts = {k: 0 for k in ["ab initio", "double hybrids", "MagNET-Zero", "NMR-specific", "DFT"]}
|
| 53 |
+
basis_counts = {k: 0 for k in ["pcSseg1", "pcSseg2", "pcSseg3"]}
|
| 54 |
+
geometry_counts = {}
|
| 55 |
+
|
| 56 |
+
x_all = np.log10(df["total_time"].astype(float).values)
|
| 57 |
+
x_leftmost = np.nanmin(x_all) if len(x_all) else np.nan
|
| 58 |
+
|
| 59 |
+
# label selection: the min-RMSE point per category plus a few spread across time
|
| 60 |
+
tmp = df.copy()
|
| 61 |
+
tmp["_log10_time"] = np.log10(tmp["total_time"].astype(float))
|
| 62 |
+
tmp["_rmse"] = tmp["fitting_RMSE"].astype(float)
|
| 63 |
+
tmp["_category"] = tmp["nmr_method"].astype(str).apply(lambda m: _classify_method(m)[1])
|
| 64 |
+
label_indices = set()
|
| 65 |
+
for cat in ["MagNET-Zero", "ab initio", "double hybrids", "NMR-specific", "DFT"]:
|
| 66 |
+
sub = tmp[tmp["_category"] == cat]
|
| 67 |
+
if sub.empty:
|
| 68 |
+
continue
|
| 69 |
+
picks = [int(sub["_rmse"].idxmin())]
|
| 70 |
+
n_extra = max(0, labels_per_category - 1)
|
| 71 |
+
if n_extra > 0 and len(sub) > 1:
|
| 72 |
+
tvals = sub["_log10_time"].to_numpy()
|
| 73 |
+
for q in np.linspace(0.05, 0.95, num=n_extra):
|
| 74 |
+
picks.append(int((sub["_log10_time"] - np.quantile(tvals, q)).abs().idxmin()))
|
| 75 |
+
seen, unique = set(), []
|
| 76 |
+
for p in picks:
|
| 77 |
+
if p not in seen:
|
| 78 |
+
unique.append(p); seen.add(p)
|
| 79 |
+
label_indices.update(unique[:labels_per_category])
|
| 80 |
+
|
| 81 |
+
for idx, row in df.iterrows():
|
| 82 |
+
x_val = float(np.log10(float(row.total_time)))
|
| 83 |
+
y_val = float(row["fitting_RMSE"])
|
| 84 |
+
geometry_type = str(row["geometry_type"]); method_raw = str(row["nmr_method"]); basis = str(row["basis"])
|
| 85 |
+
label_text, category = _classify_method(method_raw)
|
| 86 |
+
color_counts[category] += 1
|
| 87 |
+
point_color = color_map[category]
|
| 88 |
+
marker = geometry_marker_map.get(geometry_type, "o")
|
| 89 |
+
geometry_counts[geometry_type] = geometry_counts.get(geometry_type, 0) + 1
|
| 90 |
+
if basis in basis_counts:
|
| 91 |
+
basis_counts[basis] += 1
|
| 92 |
+
base_size = size_dict.get(basis, size_dict.get("N/A", 30))
|
| 93 |
+
size = base_size * (2.8 if abs(x_val - x_leftmost) <= 1e-12 else 1.0)
|
| 94 |
+
ax = ax1 if (x_range1[0] <= x_val <= x_range1[1]) else ax2
|
| 95 |
+
unfilled = marker in ["x", "+", "1", "2", "3", "4", "|", "_"]
|
| 96 |
+
kws = dict(x=[x_val], y=[y_val], ax=ax, color=point_color, s=size, marker=marker,
|
| 97 |
+
legend=False, alpha=marker_alpha, zorder=3)
|
| 98 |
+
kws.update(dict(linewidth=1.2) if unfilled else dict(edgecolor="white", linewidth=0.6))
|
| 99 |
+
sns.scatterplot(**kws)
|
| 100 |
+
|
| 101 |
+
override = None
|
| 102 |
+
if manual_label_positions:
|
| 103 |
+
for key in [label_text, method_raw, (method_raw, basis),
|
| 104 |
+
(method_raw, basis, geometry_type), (label_text, basis, geometry_type)]:
|
| 105 |
+
if key in manual_label_positions:
|
| 106 |
+
override = manual_label_positions[key]; break
|
| 107 |
+
if (idx in label_indices) or (override is not None):
|
| 108 |
+
xytext = tuple(override["xytext"]) if isinstance(override, dict) else (4, 3)
|
| 109 |
+
ax.annotate(label_text, xy=(x_val, y_val), xytext=xytext, textcoords="offset points",
|
| 110 |
+
ha="left", va="bottom", fontsize=8, color=point_color, zorder=4,
|
| 111 |
+
annotation_clip=False)
|
| 112 |
+
return color_counts, basis_counts, geometry_counts
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _add_legends(ax1, ax2, color_counts, basis_counts, geometry_counts, color_map,
|
| 116 |
+
geometry_marker_map, method_legend_label_map, geometry_legend_label_map):
|
| 117 |
+
method_order = ["MagNET-Zero", "ab initio", "double hybrids", "NMR-specific", "DFT"]
|
| 118 |
+
method_elems = [Line2D([0], [0], marker="o", color="w", markerfacecolor=color_map[c],
|
| 119 |
+
markeredgecolor="white", markeredgewidth=0.6, markersize=8, linestyle="None",
|
| 120 |
+
label=method_legend_label_map.get(c, c))
|
| 121 |
+
for c in method_order if color_counts.get(c, 0) > 0]
|
| 122 |
+
basis_elems = [Line2D([0], [0], marker="o", color="none", markerfacecolor="k", markersize=ms, label=b)
|
| 123 |
+
for b, ms in [("pcSseg1", 3), ("pcSseg2", 6), ("pcSseg3", 8)] if basis_counts.get(b, 0) > 0]
|
| 124 |
+
geometry_elems = []
|
| 125 |
+
for g in ["aimnet2", "pbe0_tz"]:
|
| 126 |
+
if geometry_counts.get(g, 0) > 0:
|
| 127 |
+
gm = geometry_marker_map.get(g, "o")
|
| 128 |
+
geometry_elems.append(Line2D([0], [0], marker=gm, color="k",
|
| 129 |
+
markerfacecolor="none" if gm == "x" else "k",
|
| 130 |
+
markeredgecolor="k", markersize=7, linestyle="None",
|
| 131 |
+
label=geometry_legend_label_map.get(g, g)))
|
| 132 |
+
ax1.add_artist(ax1.legend(handles=method_elems, loc="upper left", bbox_to_anchor=(0.005, 0.995),
|
| 133 |
+
frameon=True, fontsize=9, title="Method Type", title_fontsize=9,
|
| 134 |
+
borderaxespad=0.0, handletextpad=0.4, labelspacing=0.3))
|
| 135 |
+
ax2.add_artist(ax2.legend(handles=basis_elems, loc="upper right", bbox_to_anchor=(0.995, 0.995),
|
| 136 |
+
frameon=True, fontsize=9, title="Basis Set", title_fontsize=9,
|
| 137 |
+
borderaxespad=0.0, handletextpad=0.4, labelspacing=0.3))
|
| 138 |
+
if geometry_elems:
|
| 139 |
+
ax2.add_artist(ax2.legend(handles=geometry_elems, loc="upper right", bbox_to_anchor=(0.80, 0.995),
|
| 140 |
+
frameon=True, fontsize=9, title="Geometries", title_fontsize=9,
|
| 141 |
+
borderaxespad=0.0, handletextpad=0.4, labelspacing=0.3))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def plot_pareto_panel(pareto_df, query_str, nucleus, method_color_map, method_legend_label_map,
|
| 145 |
+
geometry_legend_label_map, geometry_marker_map, xlim_left, xlim_right, ylim,
|
| 146 |
+
figsize, marker_alpha, manual_label_positions, save_png=None,
|
| 147 |
+
suptitle="MagNET-Zero Extends a Flat Pareto Frontier",
|
| 148 |
+
supxlabel="Total delta-22 calculation time"):
|
| 149 |
+
"""Draw the broken-axis accuracy-vs-cost Pareto panel and optionally save it as a PNG."""
|
| 150 |
+
sns.set_theme(style="ticks", context="paper", rc={"axes.grid": False})
|
| 151 |
+
query_df = pareto_df.query(query_str.format(nucleus=nucleus), engine="python").reset_index(drop=True)
|
| 152 |
+
default_color_map = {"ab initio": "black", "double hybrids": "#ede6ba", "MagNET-Zero": "#61a89a",
|
| 153 |
+
"NMR-specific": "#a72608", "DFT": "black"}
|
| 154 |
+
color_map = {**default_color_map, **method_color_map}
|
| 155 |
+
|
| 156 |
+
fig = plt.figure(figsize=figsize)
|
| 157 |
+
gs = GridSpec(1, 2, width_ratios=[1, 5], wspace=0.05)
|
| 158 |
+
ax1 = fig.add_subplot(gs[0]); ax2 = fig.add_subplot(gs[1], sharey=ax1)
|
| 159 |
+
|
| 160 |
+
cc, bc, gc = _classify_and_plot_points(query_df, xlim_left, ax1, ax2, color_map,
|
| 161 |
+
geometry_marker_map, marker_alpha, manual_label_positions)
|
| 162 |
+
|
| 163 |
+
ax1.set_xlim(xlim_left); ax2.set_xlim(xlim_right)
|
| 164 |
+
ax1.set_ylim(ylim); ax2.set_ylim(ylim)
|
| 165 |
+
y_ticks = np.linspace(ylim[0], ylim[1], num=6)
|
| 166 |
+
ax1.set_yticks(y_ticks); ax1.set_yticklabels([f"{t:.2f}" for t in y_ticks], fontsize=10)
|
| 167 |
+
|
| 168 |
+
_apply_time_xticklabels(ax1, np.array([xlim_left[0], (xlim_left[0] + xlim_left[1]) / 2, xlim_left[1]]))
|
| 169 |
+
_apply_time_xticklabels(ax2, np.arange(xlim_right[0] + 0.25, xlim_right[1] + 0.25, 0.25))
|
| 170 |
+
ax1.tick_params(axis="both", which="both", length=1.5, width=0.6, labelsize=10)
|
| 171 |
+
ax2.tick_params(axis="x", which="both", length=1.5, width=0.6, labelsize=10)
|
| 172 |
+
ax2.tick_params(axis="y", which="both", left=False, right=False, labelleft=False)
|
| 173 |
+
ax1.grid(False); ax2.grid(False)
|
| 174 |
+
|
| 175 |
+
for ax in (ax1, ax2):
|
| 176 |
+
for side in ("left", "right", "top", "bottom"):
|
| 177 |
+
ax.spines[side].set_visible(True); ax.spines[side].set_linewidth(0.6)
|
| 178 |
+
ax1.spines["right"].set_visible(False); ax2.spines["left"].set_visible(False)
|
| 179 |
+
|
| 180 |
+
# broken-axis break marks
|
| 181 |
+
width_ratio = ax2.get_position().width / ax1.get_position().width
|
| 182 |
+
d1 = 0.01; d2 = d1 / width_ratio; slope2 = width_ratio
|
| 183 |
+
kw = dict(color="k", clip_on=False, lw=0.6, transform=ax1.transAxes)
|
| 184 |
+
ax1.plot((1 - d1, 1 + d1), (-d1, d1), **kw); ax1.plot((1 - d1, 1 + d1), (1 - d1, 1 + d1), **kw)
|
| 185 |
+
kw.update(transform=ax2.transAxes)
|
| 186 |
+
ax2.plot((-d2, d2), (-d2 * slope2, d2 * slope2), **kw); ax2.plot((-d2, d2), (1 - d2 * slope2, 1 + d2 * slope2), **kw)
|
| 187 |
+
|
| 188 |
+
fig.subplots_adjust(left=0.15, right=0.95, top=0.92, bottom=0.12)
|
| 189 |
+
fig.suptitle(suptitle, fontsize=14)
|
| 190 |
+
fig.supxlabel(supxlabel, fontsize=13)
|
| 191 |
+
ax1.set_ylabel(r"RMSE ($^{1}$H ppm, CDCl$_3$)" if nucleus == "H" else r"RMSE ($^{13}$C ppm, CDCl$_3$)",
|
| 192 |
+
fontsize=13)
|
| 193 |
+
|
| 194 |
+
_add_legends(ax1, ax2, cc, bc, gc, color_map, geometry_marker_map,
|
| 195 |
+
method_legend_label_map, geometry_legend_label_map)
|
| 196 |
+
if save_png:
|
| 197 |
+
fig.savefig(save_png, dpi=300, bbox_inches="tight")
|
| 198 |
+
plt.show()
|
analysis/code/paths.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Repo-root and sys.path resolution for the analysis code.
|
| 2 |
+
|
| 3 |
+
Bootstrap-only: imports nothing from analysis/code/ (it is what makes that folder importable), so it
|
| 4 |
+
stays at analysis/code/ root. repo_root(file) assumes the caller sits two dirs below the repo root
|
| 5 |
+
(analysis/code/x.py -> ../..), which holds for every module here.
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
def repo_root(file):
|
| 11 |
+
"""The release repo root, given a module's __file__."""
|
| 12 |
+
return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(file)), "..", ".."))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def dataset_file(name, filename=None, *, file=None, root=None):
|
| 16 |
+
"""Absolute path to a large released dataset file, in the repo at data/<name>/<filename>.
|
| 17 |
+
|
| 18 |
+
`filename` defaults to "<name>.hdf5". Give `root` (the repo root) or `file` (a module __file__,
|
| 19 |
+
from which the repo root is derived) so the file can be located. Only the large, git-ignored
|
| 20 |
+
data files go through here; small companion files (readers, spreadsheets) always live in the
|
| 21 |
+
repo and are addressed directly. The Hugging Face checkout carries these files via Git LFS.
|
| 22 |
+
"""
|
| 23 |
+
if filename is None:
|
| 24 |
+
filename = "{}.hdf5".format(name)
|
| 25 |
+
if root is None:
|
| 26 |
+
if file is None:
|
| 27 |
+
raise ValueError(
|
| 28 |
+
"dataset_file needs root=<repo root> or file=__file__ to locate {!r}".format(filename)
|
| 29 |
+
)
|
| 30 |
+
root = repo_root(file)
|
| 31 |
+
return os.path.join(root, "data", name, filename)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def checkpoints_root(*, required=False):
|
| 35 |
+
"""Absolute path to the in-repo model_checkpoints/ folder holding the trained weights.
|
| 36 |
+
|
| 37 |
+
Returns the path when it exists, else None so callers can skip checkpoint-dependent work, unless
|
| 38 |
+
required=True, which raises. The Hugging Face checkout carries the weights via Git LFS; they are
|
| 39 |
+
git-ignored on GitHub, so this is None in CI.
|
| 40 |
+
"""
|
| 41 |
+
in_repo = os.path.join(repo_root(__file__), "model_checkpoints")
|
| 42 |
+
if os.path.exists(in_repo):
|
| 43 |
+
return in_repo
|
| 44 |
+
if required:
|
| 45 |
+
raise RuntimeError(
|
| 46 |
+
"No model_checkpoints/ found. It ships in the repo via Git LFS in the Hugging Face "
|
| 47 |
+
"checkout (hf download ekwan16/MagNET --include 'model_checkpoints/*')."
|
| 48 |
+
)
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def ensure_on_path(*relative_parts, file=None, root=None):
|
| 53 |
+
"""Inserts os.path.join(root, *relative_parts) at the front of sys.path, if not already present.
|
| 54 |
+
|
| 55 |
+
Pass either `file` (typically __file__, resolved via repo_root) or `root` directly.
|
| 56 |
+
"""
|
| 57 |
+
if root is None:
|
| 58 |
+
if file is None:
|
| 59 |
+
raise ValueError("ensure_on_path needs either file=__file__ or root=<repo root>")
|
| 60 |
+
root = repo_root(file)
|
| 61 |
+
target = os.path.join(root, *relative_parts)
|
| 62 |
+
if target not in sys.path:
|
| 63 |
+
sys.path.insert(0, target)
|
| 64 |
+
return target
|
analysis/code/scaling_factors.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recommended Linear Scaling Parameters for MagNET-Zero / MagNET-PCM (SI Tables S10 and S11).
|
| 2 |
+
|
| 3 |
+
Turns MagNET-Zero shieldings into chemical shifts via a per-solvent linear model:
|
| 4 |
+
|
| 5 |
+
shift = c_intercept + c_stationary * sigma_zero + c_pcm * delta_pcm
|
| 6 |
+
|
| 7 |
+
where sigma_zero is the MagNET-Zero gas-phase shielding and delta_pcm the MagNET-PCM chloroform
|
| 8 |
+
correction. Proton (S10) uses a THREE-parameter model (all coefficients free) because for benzene and
|
| 9 |
+
toluene the PCM correction points the wrong way and only a free c_pcm can go positive. Carbon (S11)
|
| 10 |
+
uses a TWO-parameter model: the gas and PCM terms share one per-solvent slope (PCM scaled to solvent
|
| 11 |
+
by a conversion factor first), which fit held-out carbons slightly better; the reported `pcm` is that
|
| 12 |
+
slope times the conversion factor.
|
| 13 |
+
|
| 14 |
+
Everything reproduces from the released delta-22 data to the precision of its integer encoding
|
| 15 |
+
(`test_scaling_factors.py` locks it down). `build_scaling_tables(..., symmetrized=True)` instead
|
| 16 |
+
computes a separate deployment variant via reflection-symmetrized inference
|
| 17 |
+
(`scaling_factors_symmetrized.py`); it does not match these tables and is not what the SI reports.
|
| 18 |
+
"""
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
from paths import repo_root, ensure_on_path, dataset_file
|
| 26 |
+
|
| 27 |
+
# paths.py is the bootstrap at analysis/code/ root; the other shared utilities (stats, etc.) live in
|
| 28 |
+
# analysis/code/shared/, and the delta-22 reader/harness is reused from data/delta22. Put all of
|
| 29 |
+
# these on sys.path before importing them, so scaling_factors can be imported standalone (as its
|
| 30 |
+
# docstring invites), not only under pytest/notebooks that already pre-wire the paths.
|
| 31 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 32 |
+
_REPO = repo_root(__file__)
|
| 33 |
+
if _HERE not in sys.path:
|
| 34 |
+
sys.path.insert(0, _HERE)
|
| 35 |
+
ensure_on_path("analysis", "code", "shared", file=__file__)
|
| 36 |
+
ensure_on_path("data", "delta22", file=__file__)
|
| 37 |
+
|
| 38 |
+
from stats import linear_fit_1d # noqa: E402
|
| 39 |
+
import delta22 as D # noqa: E402
|
| 40 |
+
|
| 41 |
+
# The 12 solvents, in the reader's native naming (water is stored as "TIP4P").
|
| 42 |
+
SOLVENTS = tuple(D.DESMOND_SOLVENTS)
|
| 43 |
+
|
| 44 |
+
# Nitromethane is an outlier for both nuclei (its carbon shift is anomalous); the published tables
|
| 45 |
+
# reproduce only with it excluded throughout.
|
| 46 |
+
EXCLUDE_SOLUTES = ("nitromethane",)
|
| 47 |
+
|
| 48 |
+
# The model form recommended for each nucleus.
|
| 49 |
+
RECOMMENDED_MODEL = {"H": "three_parameter", "C": "two_parameter"}
|
| 50 |
+
|
| 51 |
+
_COLUMNS = ["intercept", "stationary", "pcm"]
|
| 52 |
+
|
| 53 |
+
# The published SI Tables S10 (proton) and S11 (carbon), shipped verbatim so you can convert
|
| 54 |
+
# shieldings to shifts WITHOUT the delta-22 data download. These are exactly what
|
| 55 |
+
# build_scaling_tables() reproduces from delta-22; test_scaling_factors.py asserts the two agree, so
|
| 56 |
+
# they cannot silently drift. Prefer published_scaling_tables() for a quick lookup, and
|
| 57 |
+
# build_scaling_tables() when you want to re-derive them from the raw data.
|
| 58 |
+
_PUBLISHED_TABLE_CSV = {
|
| 59 |
+
"H": """solvent,intercept,stationary,pcm
|
| 60 |
+
tetrahydrofuran,31.321785,-0.980175,-0.786672
|
| 61 |
+
dichloromethane,31.377310,-0.979870,-0.808573
|
| 62 |
+
chloroform,31.294992,-0.975794,-0.852690
|
| 63 |
+
toluene,31.716957,-0.996733,1.944856
|
| 64 |
+
benzene,31.987569,-1.005289,2.236515
|
| 65 |
+
chlorobenzene,31.681438,-0.993846,0.830236
|
| 66 |
+
acetone,31.512157,-0.987261,-1.238414
|
| 67 |
+
dimethylsulfoxide,31.598698,-0.991103,-1.355248
|
| 68 |
+
acetonitrile,31.496110,-0.985716,-0.974465
|
| 69 |
+
trifluoroethanol,30.876004,-0.959997,-0.958852
|
| 70 |
+
methanol,31.256030,-0.976446,-1.250121
|
| 71 |
+
TIP4P,31.359134,-0.978809,-1.478298
|
| 72 |
+
""",
|
| 73 |
+
"C": """solvent,intercept,stationary,pcm
|
| 74 |
+
tetrahydrofuran,171.054488,-0.919000,-1.069509
|
| 75 |
+
dichloromethane,171.509389,-0.921167,-1.115015
|
| 76 |
+
chloroform,171.728797,-0.924231,-0.936804
|
| 77 |
+
toluene,171.690907,-0.924987,-0.618410
|
| 78 |
+
benzene,171.967177,-0.927033,-0.593716
|
| 79 |
+
chlorobenzene,171.075910,-0.921238,-0.997641
|
| 80 |
+
acetone,171.308023,-0.919610,-1.239503
|
| 81 |
+
dimethylsulfoxide,170.598425,-0.918674,-1.296501
|
| 82 |
+
acetonitrile,171.879839,-0.922629,-1.287664
|
| 83 |
+
trifluoroethanol,174.237671,-0.939012,-1.290073
|
| 84 |
+
methanol,172.426161,-0.927566,-1.288847
|
| 85 |
+
TIP4P,173.696379,-0.937854,-1.342668
|
| 86 |
+
""",
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def published_scaling_tables():
|
| 91 |
+
"""The published SI scaling tables, {"H": Table S10, "C": Table S11}, as solvent-indexed
|
| 92 |
+
DataFrames. No data download needed; use these with predict_shift to turn MagNET-Zero/PCM
|
| 93 |
+
shieldings into chemical shifts."""
|
| 94 |
+
import io
|
| 95 |
+
return {nucleus: pd.read_csv(io.StringIO(csv)).set_index("solvent")
|
| 96 |
+
for nucleus, csv in _PUBLISHED_TABLE_CSV.items()}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def proton_scaling_table(query_df_nn, solvents=SOLVENTS, exclude_solutes=EXCLUDE_SOLUTES):
|
| 100 |
+
"""SI Table S10: per-solvent [intercept, stationary, pcm] for the proton three-parameter model.
|
| 101 |
+
|
| 102 |
+
Fits experimental ~ stationary + pcm freely for each solvent on the MagNET-Zero shielding and the
|
| 103 |
+
MagNET-PCM (chloroform) correction. Returns a DataFrame indexed by solvent.
|
| 104 |
+
"""
|
| 105 |
+
nn = query_df_nn[(query_df_nn["nucleus"] == "H")
|
| 106 |
+
& ~query_df_nn["solute"].isin(set(exclude_solutes))]
|
| 107 |
+
coeffs = D.full_fit_coefficients(nn, list(solvents), "stationary + pcm")
|
| 108 |
+
table = coeffs.T.rename(columns={"Intercept": "intercept"})[_COLUMNS]
|
| 109 |
+
table.index.name = "solvent"
|
| 110 |
+
return table
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def carbon_conversion_factors(query_df_dft):
|
| 114 |
+
"""The per-solvent PCM conversion factor for carbon (delta22.pcm_conversion_factors): for each
|
| 115 |
+
solvent, the through-the-origin slope of that solvent's PCM correction (at the level MagNET-Zero
|
| 116 |
+
reproduces, wB97X-D) against chloroform's B3LYP-D3(BJ) correction (the level MagNET-PCM
|
| 117 |
+
reproduces), from the delta-22 DFT PCM data. So one factor converts both chloroform to the solvent
|
| 118 |
+
and B3LYP to wB97X-D. A Series indexed by solvent."""
|
| 119 |
+
return D.pcm_conversion_factors(query_df_dft, "C")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def carbon_scaling_table(query_df_nn, query_df_dft=None, solvents=SOLVENTS,
|
| 123 |
+
exclude_solutes=EXCLUDE_SOLUTES, conversion_factors=None):
|
| 124 |
+
"""SI Table S11: per-solvent [intercept, stationary, pcm] for the carbon two-parameter model.
|
| 125 |
+
|
| 126 |
+
For each solvent, fits experimental ~ (stationary + factor * pcm) with a single shared slope,
|
| 127 |
+
where `factor` is the per-solvent PCM conversion factor. The reported `stationary` is that slope
|
| 128 |
+
and the reported `pcm` is slope * factor, so the prediction equation in the module docstring
|
| 129 |
+
applies with MagNET-PCM's chloroform correction. The factors come from query_df_dft
|
| 130 |
+
(carbon_conversion_factors) unless passed in as `conversion_factors` (a Series or dict). A solvent
|
| 131 |
+
with fewer than two usable sites, or an undefined conversion factor, gets a row of NaN rather than
|
| 132 |
+
a fake zero fit. Returns a DataFrame indexed by solvent.
|
| 133 |
+
"""
|
| 134 |
+
if conversion_factors is None:
|
| 135 |
+
if query_df_dft is None:
|
| 136 |
+
raise ValueError("provide query_df_dft or conversion_factors")
|
| 137 |
+
conversion_factors = carbon_conversion_factors(query_df_dft)
|
| 138 |
+
nn = query_df_nn[(query_df_nn["nucleus"] == "C")
|
| 139 |
+
& ~query_df_nn["solute"].isin(set(exclude_solutes))]
|
| 140 |
+
rows = {}
|
| 141 |
+
for solvent in solvents:
|
| 142 |
+
factor = float(conversion_factors[solvent])
|
| 143 |
+
sub = nn[nn["solvent"] == solvent]
|
| 144 |
+
y = pd.to_numeric(sub["experimental"], errors="coerce").to_numpy(float)
|
| 145 |
+
x = sub["stationary"].to_numpy(float) + factor * sub["pcm"].to_numpy(float)
|
| 146 |
+
keep = np.isfinite(x) & np.isfinite(y)
|
| 147 |
+
if not np.isfinite(factor) or keep.sum() < 2:
|
| 148 |
+
rows[solvent] = {"intercept": np.nan, "stationary": np.nan, "pcm": np.nan}
|
| 149 |
+
continue
|
| 150 |
+
intercept, slope = linear_fit_1d(x[keep], y[keep])
|
| 151 |
+
rows[solvent] = {"intercept": intercept, "stationary": slope, "pcm": slope * factor}
|
| 152 |
+
table = pd.DataFrame.from_dict(rows, orient="index")[_COLUMNS]
|
| 153 |
+
table.index.name = "solvent"
|
| 154 |
+
return table
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def build_scaling_tables(delta22_path, experimental_path, symmetrized=False, n_passes=10):
|
| 158 |
+
"""Reproduce both recommended-scaling tables from the released delta-22 data.
|
| 159 |
+
|
| 160 |
+
symmetrized=False (default) reproduces the published SI Tables S10/S11 exactly, using the
|
| 161 |
+
HDF5's stored MagNET-Zero/PCM shieldings -- this is what test_scaling_factors.py checks against
|
| 162 |
+
and what published_scaling_tables() ships.
|
| 163 |
+
|
| 164 |
+
symmetrized=True instead computes the shieldings via live, symmetrized inference
|
| 165 |
+
(predict_shieldings(..., n_passes=n_passes, symmetrize=True), see
|
| 166 |
+
scaling_factors_symmetrized.py), which corrects a reflection-parity bug in the stored HDF5
|
| 167 |
+
values (they come from a single unsymmetrized forward pass). Use this for a deployment-quality
|
| 168 |
+
scaling table -- e.g. serving MagNET-Zero/MagNET-PCM on new "everyday" molecules -- rather than
|
| 169 |
+
for reproducing the SI. Requires the magnet package and released model checkpoints; slower
|
| 170 |
+
(re-runs inference on all 22 solutes instead of reading pre-baked values).
|
| 171 |
+
|
| 172 |
+
Returns {"H": Table S10 DataFrame, "C": Table S11 DataFrame}.
|
| 173 |
+
"""
|
| 174 |
+
if symmetrized:
|
| 175 |
+
import scaling_factors_symmetrized as _S
|
| 176 |
+
override_df = _S.compute_symmetrized_nn_shieldings_df(delta22_path, n_passes=n_passes,
|
| 177 |
+
verbose=False)
|
| 178 |
+
nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False,
|
| 179 |
+
nn_shieldings_override_df=override_df)
|
| 180 |
+
else:
|
| 181 |
+
nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False)
|
| 182 |
+
dft = D.load_query_df_dft(delta22_path, experimental_path, verbose=False)
|
| 183 |
+
return {"H": proton_scaling_table(nn), "C": carbon_scaling_table(nn, dft)}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def predict_shift(table, solvent, magnet_zero_shielding, magnet_pcm_chloroform_correction):
|
| 187 |
+
"""Apply a scaling table to MagNET-Zero / MagNET-PCM outputs to predict a chemical shift.
|
| 188 |
+
|
| 189 |
+
table: a proton or carbon scaling table (DataFrame indexed by solvent). Get one from
|
| 190 |
+
published_scaling_tables()[nucleus] (no data download) or build_scaling_tables(...)[nucleus]
|
| 191 |
+
(re-derived from delta-22).
|
| 192 |
+
solvent: the solvent name (water is "TIP4P").
|
| 193 |
+
magnet_zero_shielding: the MagNET-Zero gas-phase shielding (scalar or array).
|
| 194 |
+
magnet_pcm_chloroform_correction: MagNET-PCM's chloroform correction (scalar or array).
|
| 195 |
+
|
| 196 |
+
The same equation serves both nuclei because the stored `pcm` coefficient already folds in the
|
| 197 |
+
per-solvent scaling, so you always pass the chloroform correction.
|
| 198 |
+
|
| 199 |
+
End to end, from a geometry (the H/C shieldings each model returns are per-atom arrays):
|
| 200 |
+
|
| 201 |
+
from magnet.run_magnet import compute_MagNET_Zero_shieldings, compute_MagNET_PCM_corrections
|
| 202 |
+
zero = compute_MagNET_Zero_shieldings([Z], [xyz])[0] # MagNET-Zero shieldings
|
| 203 |
+
pcm = compute_MagNET_PCM_corrections([Z], [xyz])[0] # MagNET-PCM chloroform correction
|
| 204 |
+
tables = published_scaling_tables()
|
| 205 |
+
carbons = Z == 6
|
| 206 |
+
shifts_13C = predict_shift(tables["C"], "benzene", zero[carbons], pcm[carbons])
|
| 207 |
+
"""
|
| 208 |
+
row = table.loc[solvent]
|
| 209 |
+
return (row["intercept"]
|
| 210 |
+
+ row["stationary"] * np.asarray(magnet_zero_shielding, dtype=float)
|
| 211 |
+
+ row["pcm"] * np.asarray(magnet_pcm_chloroform_correction, dtype=float))
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
h5 = dataset_file("delta22", root=_REPO)
|
| 216 |
+
xlsx = os.path.join(_REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 217 |
+
tables = build_scaling_tables(h5, xlsx)
|
| 218 |
+
for nucleus, label in (("H", "Table S10 (1H)"), ("C", "Table S11 (13C)")):
|
| 219 |
+
print(f"\n=== {label}: {RECOMMENDED_MODEL[nucleus]} model ===")
|
| 220 |
+
print(tables[nucleus].round(6).to_string())
|
analysis/code/scaling_factors_symmetrized.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Computes delta-22's MagNET-Zero / MagNET-PCM shieldings via live, symmetrized inference,
|
| 2 |
+
instead of reading the pre-baked (unsymmetrized, single-pass) values stored in delta22.hdf5.
|
| 3 |
+
|
| 4 |
+
Why: `data/delta22/delta22_reader.py::load_stationary_and_pcm_nn_shieldings` decodes
|
| 5 |
+
`nn_gas_shieldings`/`nn_pcm_corrections` straight out of the HDF5. Those were computed with a
|
| 6 |
+
single, unsymmetrized forward pass (see `magnet/run_magnet.py`'s module docstring: "The released
|
| 7 |
+
reference shieldings are NOT symmetrized"). Isotropic shielding is parity-even, so the SO(3)-only
|
| 8 |
+
backbone's un-symmetrized predictions carry a spurious reflection error that is not always
|
| 9 |
+
negligible, even on small molecules (delta-22's solutes are 6-22 atoms). `predict_shieldings(...,
|
| 10 |
+
symmetrize=True)` fixes this for free by averaging the prediction on the original geometry and its
|
| 11 |
+
mirror image.
|
| 12 |
+
|
| 13 |
+
This module produces a scaling-factor table meant for deployment (e.g. serving MagNET-Zero/PCM on
|
| 14 |
+
arbitrary "everyday" molecules), not a replacement for the SI's published Tables S10/S11: it is a
|
| 15 |
+
separate, corrected-methodology table for production use, computed the same way
|
| 16 |
+
`scaling_factors.build_scaling_tables(symmetrized=True)` does.
|
| 17 |
+
"""
|
| 18 |
+
import contextlib
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
|
| 22 |
+
import h5py
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pandas as pd
|
| 25 |
+
|
| 26 |
+
from paths import repo_root, ensure_on_path, checkpoints_root
|
| 27 |
+
|
| 28 |
+
_REPO = repo_root(__file__)
|
| 29 |
+
# _REPO itself must be on sys.path too: run_magnet.py does `from magnet import ...`, which needs
|
| 30 |
+
# the magnet/ package's PARENT directory on sys.path, not just magnet/ itself. Without this, the
|
| 31 |
+
# import only works by accident when something else (e.g. `pip install -e .`, or pytest's rootdir
|
| 32 |
+
# handling) has already put _REPO on sys.path.
|
| 33 |
+
ensure_on_path(root=_REPO)
|
| 34 |
+
ensure_on_path("magnet", file=__file__)
|
| 35 |
+
ensure_on_path("data", "delta22", file=__file__)
|
| 36 |
+
|
| 37 |
+
import run_magnet # noqa: E402
|
| 38 |
+
from run_magnet import compute_MagNET_Zero_shieldings, compute_MagNET_PCM_corrections # noqa: E402
|
| 39 |
+
from delta22_reader import load_solutes, load_stationary_geometries # noqa: E402
|
| 40 |
+
|
| 41 |
+
@contextlib.contextmanager
|
| 42 |
+
def _resolved_checkpoints():
|
| 43 |
+
"""Temporarily points run_magnet.MODEL_CHECKPOINTS at absolute paths, restoring the original
|
| 44 |
+
(relative-path) dict afterward -- run_magnet.MODEL_CHECKPOINTS is a shared module-level dict,
|
| 45 |
+
and other code importing run_magnet in the same process expects the original relative paths.
|
| 46 |
+
Its values already start with "model_checkpoints/", so join each against the repo root."""
|
| 47 |
+
base = os.path.dirname(checkpoints_root(required=True))
|
| 48 |
+
original = run_magnet.MODEL_CHECKPOINTS
|
| 49 |
+
run_magnet.MODEL_CHECKPOINTS = {
|
| 50 |
+
key: os.path.join(base, rel_path) for key, rel_path in original.items()
|
| 51 |
+
}
|
| 52 |
+
try:
|
| 53 |
+
yield
|
| 54 |
+
finally:
|
| 55 |
+
run_magnet.MODEL_CHECKPOINTS = original
|
| 56 |
+
|
| 57 |
+
_NN_COLUMNS = ["solute", "sap_geometry_type", "sap_nmr_method", "sap_basis",
|
| 58 |
+
"solvent_model", "solvent", "shieldings", "geometry_time", "nmr_time"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _load_atomic_numbers(delta22_path, solute):
|
| 62 |
+
with h5py.File(delta22_path, "r") as f:
|
| 63 |
+
return np.array(f["solutes"][solute]["atomic_numbers"])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _load_aimnet2_geometry_time(delta22_path, solute):
|
| 67 |
+
with h5py.File(delta22_path, "r") as f:
|
| 68 |
+
return float(np.array(f["solutes"][solute]["stationary_and_pcm"]["geometry_optimization_timings"])[0])
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def compute_symmetrized_nn_shieldings_df(delta22_path, n_passes=10, device=None, verbose=True):
|
| 72 |
+
"""Same row shape as load_stationary_and_pcm_nn_shieldings (one "gas" + one "pcm_correction"
|
| 73 |
+
row per solute), but the shieldings come from live predict_shieldings(symmetrize=True)
|
| 74 |
+
inference instead of the HDF5's stored, unsymmetrized values.
|
| 75 |
+
|
| 76 |
+
n_passes=10 with symmetrize=True means 10 forward passes on the original geometry and 10 on
|
| 77 |
+
its mirror image, all 20 averaged together.
|
| 78 |
+
|
| 79 |
+
Returns a DataFrame with the same 9 columns load_stationary_and_pcm_nn_shieldings produces;
|
| 80 |
+
pass it as `nn_shieldings_override_df` to delta22_reader.load_delta22_nn_data.
|
| 81 |
+
"""
|
| 82 |
+
solutes = load_solutes(delta22_path)
|
| 83 |
+
atomic_numbers_list = [_load_atomic_numbers(delta22_path, s) for s in solutes]
|
| 84 |
+
geometries_list = [load_stationary_geometries(delta22_path, s)["aimnet2"] for s in solutes]
|
| 85 |
+
|
| 86 |
+
if verbose:
|
| 87 |
+
print(f"computing symmetrized MagNET-Zero/PCM shieldings for {len(solutes)} solutes "
|
| 88 |
+
f"(n_passes={n_passes}, symmetrize=True)...", flush=True)
|
| 89 |
+
|
| 90 |
+
t0 = time.time()
|
| 91 |
+
with _resolved_checkpoints():
|
| 92 |
+
zero_shieldings = compute_MagNET_Zero_shieldings(
|
| 93 |
+
atomic_numbers_list, geometries_list, n_passes=n_passes, symmetrize=True, device=device)
|
| 94 |
+
t1 = time.time()
|
| 95 |
+
pcm_corrections = compute_MagNET_PCM_corrections(
|
| 96 |
+
atomic_numbers_list, geometries_list, n_passes=n_passes, symmetrize=True, device=device)
|
| 97 |
+
t2 = time.time()
|
| 98 |
+
|
| 99 |
+
if verbose:
|
| 100 |
+
print(f" MagNET-Zero: {t1-t0:.1f}s total, MagNET-PCM: {t2-t1:.1f}s total", flush=True)
|
| 101 |
+
|
| 102 |
+
zero_time_per_solute = (t1 - t0) / len(solutes)
|
| 103 |
+
pcm_time_per_solute = (t2 - t1) / len(solutes)
|
| 104 |
+
|
| 105 |
+
rows = []
|
| 106 |
+
for solute, gas, pcm in zip(solutes, zero_shieldings, pcm_corrections):
|
| 107 |
+
geom_time = _load_aimnet2_geometry_time(delta22_path, solute)
|
| 108 |
+
rows.append([solute, "aimnet2", "MagNET", "N/A", "gas", "none",
|
| 109 |
+
np.asarray(gas, dtype=float), geom_time, zero_time_per_solute])
|
| 110 |
+
rows.append([solute, "aimnet2", "MagNET", "N/A", "pcm_correction", "chloroform",
|
| 111 |
+
np.asarray(pcm, dtype=float), geom_time, pcm_time_per_solute])
|
| 112 |
+
|
| 113 |
+
return pd.DataFrame(rows, columns=_NN_COLUMNS)
|
analysis/code/test_applications_analysis.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for analysis/code/applications.py (the no-plotting analysis module).
|
| 2 |
+
|
| 3 |
+
Builds a tiny synthetic applications.hdf5 + spreadsheet so the correction math can be checked
|
| 4 |
+
in CI without the multi-hundred-megabyte release files.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import h5py
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
+
sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "applications"))
|
| 16 |
+
sys.path.insert(0, HERE)
|
| 17 |
+
import paths
|
| 18 |
+
|
| 19 |
+
from applications_reader import Applications, SHELL_SIZES, SOLVENTS # noqa: E402
|
| 20 |
+
import applications as A # noqa: E402
|
| 21 |
+
|
| 22 |
+
SCALE = 1e4
|
| 23 |
+
MISS = np.int32(-2147483648)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_distribution_shift_by_solvent_table_averages_per_solvent():
|
| 27 |
+
"""distribution_shift_by_solvent_table averages the three coefficient-choice RMSEs per solvent,
|
| 28 |
+
and surfaces the water column as the biggest extrapolation gap (the S15 story)."""
|
| 29 |
+
remapped = A.FORMULA_REMAP["stationary_plus_qcd + openMM"]
|
| 30 |
+
per_solute = {"H": pd.DataFrame([
|
| 31 |
+
{"formula": remapped, "solvent": "chloroform", "solute": "a", "rmse": 0.10},
|
| 32 |
+
{"formula": remapped, "solvent": "chloroform", "solute": "b", "rmse": 0.20},
|
| 33 |
+
{"formula": remapped, "solvent": "TIP4P", "solute": "a", "rmse": 0.30},
|
| 34 |
+
{"formula": remapped, "solvent": "TIP4P", "solute": "b", "rmse": 0.50},
|
| 35 |
+
])}
|
| 36 |
+
per_solvent = {"H": pd.DataFrame([
|
| 37 |
+
{"formula": remapped, "solvent": "chloroform", "rmse": 0.16},
|
| 38 |
+
{"formula": remapped, "solvent": "TIP4P", "rmse": 0.45},
|
| 39 |
+
])}
|
| 40 |
+
bootstrap = pd.DataFrame([
|
| 41 |
+
{"nucleus": "H", "formula": remapped, "solvent": "chloroform", "solute": "a", "seed": 0, "Bootstrap_RMSE": 0.14},
|
| 42 |
+
{"nucleus": "H", "formula": remapped, "solvent": "chloroform", "solute": "b", "seed": 0, "Bootstrap_RMSE": 0.18},
|
| 43 |
+
{"nucleus": "H", "formula": remapped, "solvent": "TIP4P", "solute": "a", "seed": 0, "Bootstrap_RMSE": 0.60},
|
| 44 |
+
{"nucleus": "H", "formula": remapped, "solvent": "TIP4P", "solute": "b", "seed": 0, "Bootstrap_RMSE": 0.80},
|
| 45 |
+
])
|
| 46 |
+
table = A.distribution_shift_by_solvent_table(per_solute, per_solvent, bootstrap, "H",
|
| 47 |
+
"stationary_plus_qcd + openMM")
|
| 48 |
+
assert list(table.index) == ["TIP4P", "chloroform"] # sorted; uppercase sorts before lowercase
|
| 49 |
+
assert list(table.columns) == ["Extrapolated from delta22", "Scaled to Test Set", "Scaled to Solute"]
|
| 50 |
+
assert table.loc["chloroform", "Scaled to Solute"] == pytest.approx(0.15)
|
| 51 |
+
assert table.loc["chloroform", "Scaled to Test Set"] == pytest.approx(0.16)
|
| 52 |
+
assert table.loc["chloroform", "Extrapolated from delta22"] == pytest.approx(0.16)
|
| 53 |
+
assert table.loc["TIP4P", "Scaled to Solute"] == pytest.approx(0.40)
|
| 54 |
+
assert table.loc["TIP4P", "Extrapolated from delta22"] == pytest.approx(0.70)
|
| 55 |
+
chl_gap = table.loc["chloroform", "Extrapolated from delta22"] - table.loc["chloroform", "Scaled to Solute"]
|
| 56 |
+
water_gap = table.loc["TIP4P", "Extrapolated from delta22"] - table.loc["TIP4P", "Scaled to Solute"]
|
| 57 |
+
assert water_gap > chl_gap
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _enc(a):
|
| 61 |
+
a = np.asarray(a, dtype=np.float64)
|
| 62 |
+
nan = np.isnan(a)
|
| 63 |
+
out = np.round(np.where(nan, 0.0, a) * SCALE).astype(np.int32)
|
| 64 |
+
out[nan] = MISS
|
| 65 |
+
return out
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@pytest.fixture
|
| 69 |
+
def loader(tmp_path):
|
| 70 |
+
"""One solute (vomicine), 3 atoms, one H site over atoms 1+2, one C site over atom 3.
|
| 71 |
+
magnet_x is set so the solvated-minus-isolated correction is a known constant per shell."""
|
| 72 |
+
h5 = tmp_path / "applications.hdf5"
|
| 73 |
+
xlsx = tmp_path / "exp.xlsx"
|
| 74 |
+
with h5py.File(h5, "w") as f:
|
| 75 |
+
sg = f.create_group("solvents")
|
| 76 |
+
for sv in SOLVENTS:
|
| 77 |
+
sg.create_group(sv).create_dataset("atomic_numbers", data=np.array([6], np.int32))
|
| 78 |
+
g = f.create_group("vomicine")
|
| 79 |
+
g.create_dataset("atomic_numbers", data=np.array([1, 1, 6], np.int32))
|
| 80 |
+
mx = g.create_group("magnet_x")
|
| 81 |
+
for sv in SOLVENTS:
|
| 82 |
+
svg = mx.create_group(sv)
|
| 83 |
+
for k, shell in enumerate(SHELL_SIZES):
|
| 84 |
+
arr = np.zeros((4, 3, 2), dtype=np.float64)
|
| 85 |
+
arr[:, :, 0] = 10.0 # isolated
|
| 86 |
+
arr[:, :, 1] = 10.0 + 0.1 * (k + 1) # solvated -> correction = 0.1*(k+1)
|
| 87 |
+
svg.create_dataset(f"shell_{shell}", data=_enc(arr))
|
| 88 |
+
svg.create_dataset("stationary", data=_enc(np.full(3, 10.0)))
|
| 89 |
+
mz = g.create_group("magnet_zero")
|
| 90 |
+
mz.create_dataset("stationary", data=_enc(np.array([100.0, 101.0, 50.0])))
|
| 91 |
+
mz.create_dataset("pcm_correction", data=_enc(np.array([-1.0, -1.0, -2.0])))
|
| 92 |
+
q = g.create_group("qcd")
|
| 93 |
+
q.create_dataset("stationary", data=_enc(np.zeros((3, 4))))
|
| 94 |
+
q.create_dataset("trajectories", data=_enc(np.zeros((2, 3, 3, 4)))) # corr -> 0
|
| 95 |
+
cm = f.create_group("composite_model")
|
| 96 |
+
cmc = cm.create_group("pcm_conversion_factors")
|
| 97 |
+
# the real conversion table uses 'water' (renamed to TIP4P by build_query_df_nn), never both
|
| 98 |
+
rows = "solvent,pcm_conversion_factor\n" + "".join(
|
| 99 |
+
f"{s},1.0\n" for s in ["chloroform", "benzene", "methanol", "water"])
|
| 100 |
+
cmc.create_dataset("H", data=rows)
|
| 101 |
+
cmc.create_dataset("C", data=rows)
|
| 102 |
+
with pd.ExcelWriter(xlsx) as w:
|
| 103 |
+
pd.DataFrame({
|
| 104 |
+
"site": ["H", "1 C"], "nucleus": ["H", "C"],
|
| 105 |
+
"atom_numbers": ["1,2", "3"],
|
| 106 |
+
"chloroform": [1.0, 2.0], "benzene": [1.0, 2.0],
|
| 107 |
+
"methanol": [1.0, 2.0], "water": [1.0, 2.0],
|
| 108 |
+
}).to_excel(w, sheet_name="vomicine", index=False)
|
| 109 |
+
return Applications(h5, xlsx)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_shell_convergence_values(loader):
|
| 113 |
+
df = A.shell_convergence_corrections(loader, "vomicine", "H")
|
| 114 |
+
# one H site (uuid-prefixed) x 4 solvents
|
| 115 |
+
assert df.index.get_level_values("solvent").nunique() == len(SOLVENTS)
|
| 116 |
+
assert df.index.get_level_values("site").unique().tolist() == ["01 H"]
|
| 117 |
+
# correction for shell k (0-based) is exactly 0.1*(k+1) within the int32 quantum
|
| 118 |
+
row = df.xs("chloroform", level="solvent").iloc[0]
|
| 119 |
+
for k, shell in enumerate(SHELL_SIZES):
|
| 120 |
+
assert row[f"shell_{shell}"] == pytest.approx(0.1 * (k + 1), abs=5e-5)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_only_requested_nucleus(loader):
|
| 124 |
+
df = A.shell_convergence_corrections(loader, "vomicine", "H")
|
| 125 |
+
# C site must not appear in an H-nucleus convergence table
|
| 126 |
+
sites = df.index.get_level_values("site").unique().tolist()
|
| 127 |
+
assert all("C" not in s for s in sites)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_site_atom_indices():
|
| 131 |
+
assert A.site_atom_indices("1,2") == [0, 1]
|
| 132 |
+
assert A.site_atom_indices("21") == [20]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_fit_recovers_known_line():
|
| 136 |
+
# experimental = 2*stationary + 5 exactly -> intercept ~5, slope ~2, RMSE ~0
|
| 137 |
+
df = pd.DataFrame({"stationary": [1.0, 2.0, 3.0, 4.0], "experimental": [7.0, 9.0, 11.0, 13.0]})
|
| 138 |
+
rmse, params = A.fit(df, "stationary")
|
| 139 |
+
assert rmse < 1e-9
|
| 140 |
+
assert params["Intercept"] == pytest.approx(5.0, abs=1e-6)
|
| 141 |
+
assert params["stationary"] == pytest.approx(2.0, abs=1e-6)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_fit_ignores_rows_with_no_experimental_value():
|
| 145 |
+
# one site has no experimental measurement (common in the real natural-products spreadsheet);
|
| 146 |
+
# it must not turn the whole RMSE into NaN, only be left out of the score
|
| 147 |
+
df = pd.DataFrame({
|
| 148 |
+
"stationary": [1.0, 2.0, 3.0, 4.0],
|
| 149 |
+
"experimental": [7.0, 9.0, 11.0, np.nan],
|
| 150 |
+
})
|
| 151 |
+
rmse, params = A.fit(df, "stationary")
|
| 152 |
+
assert rmse < 1e-9
|
| 153 |
+
assert params["stationary"] == pytest.approx(2.0, abs=1e-6)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_fit_matches_statsmodels():
|
| 157 |
+
# the numpy fit must be numerically identical to the original statsmodels OLS oracle, across
|
| 158 |
+
# multi-term formulas and with missing values present (rows dropped identically by both).
|
| 159 |
+
rng = np.random.default_rng(7)
|
| 160 |
+
n = 60
|
| 161 |
+
df = pd.DataFrame({
|
| 162 |
+
"stationary": rng.normal(100, 20, n), "pcm": rng.normal(0, 2, n),
|
| 163 |
+
"qcd": rng.normal(0, 1, n), "openMM": rng.normal(0, 2, n),
|
| 164 |
+
})
|
| 165 |
+
df["experimental"] = (2.0 * df["stationary"] + 0.5 * df["pcm"] - 0.3 * df["qcd"]
|
| 166 |
+
+ 1.0 + rng.normal(0, 1, n))
|
| 167 |
+
df.loc[[3, 17, 42], "experimental"] = np.nan # missing experimental values
|
| 168 |
+
df.loc[[8, 25], "pcm"] = np.nan # missing predictor values
|
| 169 |
+
for formula in ["stationary", "stationary + pcm", "stationary + pcm + qcd",
|
| 170 |
+
"stationary + pcm + qcd + openMM"]:
|
| 171 |
+
rmse, params = A.fit(df, formula)
|
| 172 |
+
s_rmse, s_params = A._fit_statsmodels(df, formula)
|
| 173 |
+
assert rmse == pytest.approx(s_rmse, abs=1e-8), formula
|
| 174 |
+
for name in s_params.index:
|
| 175 |
+
assert params[name] == pytest.approx(s_params[name], abs=1e-6), f"{formula}:{name}"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_build_query_df_nn(loader):
|
| 179 |
+
q = A.build_query_df_nn(loader)
|
| 180 |
+
expected = {"solute", "nucleus", "site", "solvent", "experimental", "stationary", "pcm",
|
| 181 |
+
"openMM", "openMM_vib", "qcd", "stationary_plus_pcm", "stationary_plus_qcd",
|
| 182 |
+
"stationary_plus_op_vib"}
|
| 183 |
+
assert expected.issubset(q.columns)
|
| 184 |
+
# H site covers atoms 1+2: stationary = mean(100, 101) = 100.5; pcm = mean(-1, -1) = -1;
|
| 185 |
+
# conversion factor 1.0 -> stationary_plus_pcm = 99.5
|
| 186 |
+
h = q[(q["nucleus"] == "H") & (q["solvent"] == "chloroform")].iloc[0]
|
| 187 |
+
assert h["stationary"] == pytest.approx(100.5, abs=1e-3)
|
| 188 |
+
assert h["stationary_plus_pcm"] == pytest.approx(99.5, abs=1e-3)
|
| 189 |
+
assert h["qcd"] == pytest.approx(0.0, abs=1e-3)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_apply_bootstrap_nan_handling():
|
| 193 |
+
# a missing coeff (openMM=NaN) contributes nothing; a present coeff with missing data -> NaN
|
| 194 |
+
coeffs = pd.DataFrame([{"solvent": "chloroform", "formula": "pcm2", "seed": 0,
|
| 195 |
+
"Intercept": 5.0, "stationary": 2.0, "openMM": np.nan}])
|
| 196 |
+
data = pd.DataFrame([
|
| 197 |
+
{"solute": "m", "nucleus": "H", "site": "a", "solvent": "chloroform",
|
| 198 |
+
"experimental": 7.0, "stationary": 1.0, "openMM": 9.0}, # -> 5 + 2*1 = 7
|
| 199 |
+
{"solute": "m", "nucleus": "H", "site": "b", "solvent": "chloroform",
|
| 200 |
+
"experimental": np.nan, "stationary": np.nan, "openMM": 9.0}, # stationary data NaN -> NaN
|
| 201 |
+
])
|
| 202 |
+
out = A.apply_bootstrap_params_to_full_dataset(data, coeffs, nucleus="H").set_index("site")
|
| 203 |
+
assert out.loc["a", "predicted"] == pytest.approx(7.0)
|
| 204 |
+
assert np.isnan(out.loc["b", "predicted"])
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_apply_bootstrap_and_solute_rmse():
|
| 208 |
+
# one formula/seed/solvent: predicted = 5 + 2*stationary; experimental matches -> RMSE 0
|
| 209 |
+
coeffs = pd.DataFrame({"solvent": ["chloroform"], "formula": ["pcm2"], "seed": [0],
|
| 210 |
+
"Intercept": [5.0], "stationary": [2.0]})
|
| 211 |
+
data = pd.DataFrame({
|
| 212 |
+
"solute": ["m", "m"], "nucleus": ["H", "H"], "site": ["01 H", "02 H"],
|
| 213 |
+
"solvent": ["chloroform", "chloroform"], "experimental": [7.0, 9.0], "stationary": [1.0, 2.0],
|
| 214 |
+
})
|
| 215 |
+
preds = A.apply_bootstrap_params_to_full_dataset(data, coeffs, nucleus="H")
|
| 216 |
+
assert np.allclose(preds["predicted"], preds["experimental"])
|
| 217 |
+
rmses = A.compute_solute_rmses(preds)
|
| 218 |
+
assert rmses["Bootstrap_RMSE"].iloc[0] == pytest.approx(0.0, abs=1e-9)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# --- SI Figure S15's "Fitting RMSE Comparisons" -----------------------------------------------
|
| 222 |
+
|
| 223 |
+
def _rmse_comparison_fixture():
|
| 224 |
+
"""Two test-set solutes ("m", "n"), one solvent, formula "stationary + openMM". Each solute has
|
| 225 |
+
only 2 points against 3 free parameters, so a per-solute-only fit is always exact (RMSE 0) --
|
| 226 |
+
that part needs no special data. The 4 points are deliberately NOT co-planar in (stationary,
|
| 227 |
+
openMM, experimental) space (verified: an earlier version of this fixture picked co-planar
|
| 228 |
+
points by accident, which made the pooled "Scaled to Test Set" fit ALSO exact and silently
|
| 229 |
+
hid the "formula" column bug this fixture now catches -- see the FORMULA_REMAP note below), so
|
| 230 |
+
the pooled fit has nonzero RMSE and "Scaled to Test Set" != "Scaled to Solute". The bootstrap
|
| 231 |
+
coefficients are also chosen far from both fits, so all three columns differ."""
|
| 232 |
+
query_df_nn = pd.DataFrame([
|
| 233 |
+
{"solute": "m", "nucleus": "H", "site": "01", "solvent": "chloroform", "experimental": 10.0, "stationary": 5.0, "openMM": 1.0},
|
| 234 |
+
{"solute": "m", "nucleus": "H", "site": "02", "solvent": "chloroform", "experimental": 12.0, "stationary": 6.0, "openMM": 1.0},
|
| 235 |
+
{"solute": "n", "nucleus": "H", "site": "01", "solvent": "chloroform", "experimental": 20.0, "stationary": 5.0, "openMM": 2.0},
|
| 236 |
+
{"solute": "n", "nucleus": "H", "site": "02", "solvent": "chloroform", "experimental": 23.0, "stationary": 7.0, "openMM": 2.0},
|
| 237 |
+
])
|
| 238 |
+
formula = "stationary + openMM"
|
| 239 |
+
per_solute = {"H": A.fit_formulas_per_solvent_and_solute(query_df_nn, [formula], ["chloroform"],
|
| 240 |
+
["m", "n"], A.FORMULA_REMAP)}
|
| 241 |
+
all_solute = {"H": A.fit_formulas_per_solvent(query_df_nn, [formula], ["chloroform"], A.FORMULA_REMAP)}
|
| 242 |
+
# The real pipeline (applications.build_bootstrap_seed_coeffs) always stores the REMAPPED
|
| 243 |
+
# formula name in its "formula" column, same as per_solute_fits/per_solvent_fits above --
|
| 244 |
+
# fitting_rmse_comparison_table looks up bootstrap_rmses by the remapped name, so a coeffs
|
| 245 |
+
# frame built with the raw formula string would never match and "Extrapolated from delta22"
|
| 246 |
+
# would silently come out all-NaN instead of raising. Match that convention here.
|
| 247 |
+
remapped = A.FORMULA_REMAP[formula]
|
| 248 |
+
coeffs = pd.DataFrame({"solvent": ["chloroform"], "formula": [remapped], "seed": [0],
|
| 249 |
+
"Intercept": [0.0], "stationary": [1.0], "openMM": [1.0]})
|
| 250 |
+
preds = A.apply_bootstrap_params_to_full_dataset(query_df_nn, coeffs, nucleus="H")
|
| 251 |
+
bootstrap_rmses = A.compute_solute_rmses(preds)
|
| 252 |
+
return query_df_nn, per_solute, all_solute, bootstrap_rmses, formula
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_scaled_to_test_set_per_solute_rmse_applies_one_pooled_fit_per_solute():
|
| 256 |
+
query_df_nn, _, all_solute, _, formula = _rmse_comparison_fixture()
|
| 257 |
+
out = A.scaled_to_test_set_per_solute_rmse(query_df_nn, all_solute, "H", "chloroform", formula)
|
| 258 |
+
assert set(out["solute"]) == {"m", "n"}
|
| 259 |
+
# both solutes score under the SAME pooled coefficients, so their RMSEs need not be equal
|
| 260 |
+
assert out["Bootstrap_RMSE"].notna().all()
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_fitting_rmse_comparison_table_has_all_three_columns_and_solutes():
|
| 264 |
+
query_df_nn, per_solute, all_solute, bootstrap_rmses, formula = _rmse_comparison_fixture()
|
| 265 |
+
table = A.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses,
|
| 266 |
+
"H", "chloroform", formula)
|
| 267 |
+
assert set(table.columns) == {"Scaled to Solute", "Scaled to Test Set", "Extrapolated from delta22"}
|
| 268 |
+
assert set(table.index) == {"m", "n"}
|
| 269 |
+
assert table.notna().all().all() # a formula-remapping mismatch would leave a column all-NaN
|
| 270 |
+
# "Scaled to Solute" fits each solute alone with 2 free params on 2 points -> exact, RMSE 0
|
| 271 |
+
assert table["Scaled to Solute"].max() < 1e-8
|
| 272 |
+
# the fixture's points are deliberately non-coplanar (see _rmse_comparison_fixture), so the
|
| 273 |
+
# pooled fit is NOT exact and genuinely differs from the per-solute fit
|
| 274 |
+
assert table["Scaled to Test Set"].max() > 1e-3
|
| 275 |
+
# the bootstrap coefficients are far from either fit, so this column differs from both
|
| 276 |
+
assert (table["Extrapolated from delta22"] - table["Scaled to Solute"]).abs().min() > 0.1
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@pytest.mark.skipif(
|
| 280 |
+
not os.path.exists(paths.dataset_file("applications", file=__file__)),
|
| 281 |
+
reason="real applications.hdf5 not present")
|
| 282 |
+
def test_fitting_rmse_comparison_table_reproduces_published_chloroform_values():
|
| 283 |
+
"""Every published "Fitting RMSE Comparisons (chloroform, H)" value (SI Figure S15), read
|
| 284 |
+
directly off the SI, reproduced from the released data. Cross-checks the delta-22-bootstrap
|
| 285 |
+
("Extrapolated") column at a slightly looser tolerance since it averages over random seeds."""
|
| 286 |
+
real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
|
| 287 |
+
loader = Applications(paths.dataset_file("applications", file=__file__),
|
| 288 |
+
os.path.join(real_data_dir, "applications_experimental.xlsx"))
|
| 289 |
+
query_df_nn = A.build_query_df_nn(loader)
|
| 290 |
+
seed = A.build_bootstrap_seed_coeffs(loader)
|
| 291 |
+
per_solute = A.per_solute_fits(query_df_nn)
|
| 292 |
+
all_solute = A.per_solvent_fits(query_df_nn)
|
| 293 |
+
preds_h = A.apply_bootstrap_params_to_full_dataset(query_df_nn, seed["H"], nucleus="H")
|
| 294 |
+
bootstrap_rmses_h = A.compute_solute_rmses(preds_h)
|
| 295 |
+
table = A.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses_h,
|
| 296 |
+
"H", "chloroform", "stationary_plus_qcd + openMM")
|
| 297 |
+
# (solute, column) -> published value, read off the SI Figure S15 bar chart
|
| 298 |
+
published = {
|
| 299 |
+
("isomer_1E", "Scaled to Solute"): 0.138, ("isomer_1E", "Scaled to Test Set"): 0.138,
|
| 300 |
+
("vomicine", "Scaled to Solute"): 0.173, ("vomicine", "Scaled to Test Set"): 0.183,
|
| 301 |
+
("prednisone", "Scaled to Solute"): 0.130, ("prednisone", "Scaled to Test Set"): 0.153,
|
| 302 |
+
("flavone", "Scaled to Solute"): 0.021, ("flavone", "Scaled to Test Set"): 0.062,
|
| 303 |
+
}
|
| 304 |
+
for (solute, col), expected in published.items():
|
| 305 |
+
assert table.loc[solute, col] == pytest.approx(expected, abs=3e-3), f"{solute}/{col}"
|
| 306 |
+
published_extrapolated = {"isomer_1E": 0.146, "vomicine": 0.180, "prednisone": 0.166, "flavone": 0.060}
|
| 307 |
+
for solute, expected in published_extrapolated.items():
|
| 308 |
+
assert table.loc[solute, "Extrapolated from delta22"] == pytest.approx(expected, abs=0.02), solute
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@pytest.mark.skipif(
|
| 312 |
+
not os.path.exists(paths.dataset_file("applications", file=__file__)),
|
| 313 |
+
reason="real applications.hdf5 not present")
|
| 314 |
+
def test_distribution_shift_by_solvent_table_flags_water_on_real_data():
|
| 315 |
+
"""The per-solvent distribution-shift check on real data: for protons, TIP4P (water) must show
|
| 316 |
+
the largest 'Extrapolated from delta22' minus 'Scaled to Solute' gap -- the exact claim the
|
| 317 |
+
function's docstring and SI Figure S15's narrative make (the peptide dominates the water column).
|
| 318 |
+
Also spot-checks two published per-solvent RMSE values."""
|
| 319 |
+
real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
|
| 320 |
+
loader = Applications(paths.dataset_file("applications", file=__file__),
|
| 321 |
+
os.path.join(real_data_dir, "applications_experimental.xlsx"))
|
| 322 |
+
query_df_nn = A.build_query_df_nn(loader)
|
| 323 |
+
seed = A.build_bootstrap_seed_coeffs(loader)
|
| 324 |
+
per_solute = A.per_solute_fits(query_df_nn)
|
| 325 |
+
all_solute = A.per_solvent_fits(query_df_nn)
|
| 326 |
+
bootstrap_h = A.compute_solute_rmses(
|
| 327 |
+
A.apply_bootstrap_params_to_full_dataset(query_df_nn, seed["H"], nucleus="H"))
|
| 328 |
+
table = A.distribution_shift_by_solvent_table(per_solute, all_solute, bootstrap_h, "H",
|
| 329 |
+
"stationary_plus_qcd + openMM")
|
| 330 |
+
gap = table["Extrapolated from delta22"] - table["Scaled to Solute"]
|
| 331 |
+
assert gap.idxmax() == "TIP4P", f"expected water to have the largest extrapolation gap, got {gap.idxmax()}"
|
| 332 |
+
assert table.loc["TIP4P", "Extrapolated from delta22"] == pytest.approx(0.336, abs=0.01)
|
| 333 |
+
assert table.loc["chloroform", "Scaled to Solute"] == pytest.approx(0.107, abs=0.01)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
# --- SI Figure S15's "Feature Space Coverage by Solvent" / "Residuals for Delta22 Fitting Coefficients" ---
|
| 337 |
+
|
| 338 |
+
def _feature_space_fixture():
|
| 339 |
+
"""A test-set solute ("m") and a delta-22 solute, both nucleus H, in chloroform and benzene
|
| 340 |
+
(TIP4P/methanol deliberately absent from "m" to check the solvent filter drops rows cleanly).
|
| 341 |
+
stationary_plus_qcd/openMM are chosen so delta-22's chloroform plane is exact-fittable
|
| 342 |
+
(3 points, 3 free parameters -> zero residual)."""
|
| 343 |
+
query_df_nn = pd.DataFrame([
|
| 344 |
+
{"solute": "m", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 10.0, "openMM": 1.0, "experimental": 12.0},
|
| 345 |
+
{"solute": "m", "nucleus": "H", "solvent": "benzene", "stationary_plus_qcd": 11.0, "openMM": 2.0, "experimental": 15.0},
|
| 346 |
+
{"solute": "m", "nucleus": "C", "solvent": "chloroform", "stationary_plus_op_vib": 100.0, "openMM": 3.0, "experimental": 120.0},
|
| 347 |
+
])
|
| 348 |
+
delta22_query_df_nn = pd.DataFrame([
|
| 349 |
+
{"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 12.0, "openMM": 0.0, "experimental": 12.0},
|
| 350 |
+
{"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 14.0, "openMM": 1.0, "experimental": 15.0},
|
| 351 |
+
{"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 16.0, "openMM": 2.0, "experimental": 18.0},
|
| 352 |
+
{"solute": "delta22", "nucleus": "H", "solvent": "benzene", "stationary_plus_qcd": 9.0, "openMM": 1.5, "experimental": 13.0},
|
| 353 |
+
])
|
| 354 |
+
return query_df_nn, delta22_query_df_nn
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def test_feature_space_coverage_table_combines_and_centers_both_datasets():
|
| 358 |
+
query_df_nn, delta22_query_df_nn = _feature_space_fixture()
|
| 359 |
+
out = A.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform", "benzene"))
|
| 360 |
+
assert set(out["dataset"]) == {"Test Set", "Delta22"}
|
| 361 |
+
assert set(out["solvent"]) == {"chloroform", "benzene"}
|
| 362 |
+
# global centering: the pooled x column (both datasets, both solvents) averages to 0
|
| 363 |
+
assert out["x"].mean() == pytest.approx(0.0, abs=1e-9)
|
| 364 |
+
assert out["y"].mean() == pytest.approx(0.0, abs=1e-9)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def test_feature_space_coverage_table_drops_solvents_test_set_lacks():
|
| 368 |
+
query_df_nn, delta22_query_df_nn = _feature_space_fixture()
|
| 369 |
+
out = A.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, "H",
|
| 370 |
+
solvents=("chloroform", "benzene", "methanol", "TIP4P"))
|
| 371 |
+
# "m" (the only test-set H solute) has no methanol/TIP4P rows, so those solvents drop out
|
| 372 |
+
assert set(out["solvent"]) == {"chloroform", "benzene"}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def test_delta22_plane_residuals_table_delta22_residuals_are_near_zero():
|
| 376 |
+
# delta-22's own 3 chloroform points exactly determine a 3-parameter plane, so delta-22's own
|
| 377 |
+
# residuals against that plane must be ~0 (an OLS fit's residuals average to 0 with an
|
| 378 |
+
# intercept; with exactly as many points as parameters, every residual is individually ~0)
|
| 379 |
+
query_df_nn, delta22_query_df_nn = _feature_space_fixture()
|
| 380 |
+
out = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform",))
|
| 381 |
+
d22 = out[out["dataset"] == "Delta22"]
|
| 382 |
+
assert len(d22) == 3
|
| 383 |
+
assert np.allclose(d22["residual"], 0.0, atol=1e-6)
|
| 384 |
+
# the test-set row scores against that SAME plane and is not forced to be exact
|
| 385 |
+
test_row = out[out["dataset"] == "Test Set"]
|
| 386 |
+
assert len(test_row) == 1
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def test_delta22_plane_residuals_table_skips_solvents_with_too_few_delta22_points():
|
| 390 |
+
# benzene has only 1 delta-22 point (< 3 needed to fit a plane) -> dropped entirely, no crash
|
| 391 |
+
query_df_nn, delta22_query_df_nn = _feature_space_fixture()
|
| 392 |
+
out = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform", "benzene"))
|
| 393 |
+
assert set(out["solvent"]) == {"chloroform"}
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
@pytest.mark.skipif(
|
| 397 |
+
not os.path.exists(paths.dataset_file("applications", file=__file__)),
|
| 398 |
+
reason="real applications.hdf5 not present")
|
| 399 |
+
def test_feature_space_and_residuals_tables_sane_on_real_data():
|
| 400 |
+
"""Not an exact-number reproduction (these are scatter plots, not summary statistics) --
|
| 401 |
+
structural sanity checks: delta-22's own residuals against its own fitted plane average to
|
| 402 |
+
(near) zero every solvent, and the known H/TIP4P data-scarcity caveat from the SI text ("the
|
| 403 |
+
available experimental data for water in the test set is limited to one solute") shows up as a
|
| 404 |
+
small test-set count for that one solvent."""
|
| 405 |
+
real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
|
| 406 |
+
delta22_dir = os.path.join(HERE, "..", "..", "data", "delta22")
|
| 407 |
+
sys.path.insert(0, delta22_dir)
|
| 408 |
+
import delta22 as D
|
| 409 |
+
loader = Applications(paths.dataset_file("applications", file=__file__),
|
| 410 |
+
os.path.join(real_data_dir, "applications_experimental.xlsx"))
|
| 411 |
+
query_df_nn = A.build_query_df_nn(loader)
|
| 412 |
+
delta22_query_df_nn = D.add_composite_columns(D.load_query_df_nn(
|
| 413 |
+
paths.dataset_file("delta22", file=__file__), os.path.join(delta22_dir, "delta22_experimental.xlsx"),
|
| 414 |
+
verbose=False))
|
| 415 |
+
for nucleus in ["H", "C"]:
|
| 416 |
+
residuals = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus)
|
| 417 |
+
d22_means = residuals[residuals["dataset"] == "Delta22"].groupby("solvent")["residual"].mean()
|
| 418 |
+
assert (d22_means.abs() < 1e-6).all()
|
| 419 |
+
test_counts = residuals[residuals["dataset"] == "Test Set"].groupby("solvent").size()
|
| 420 |
+
assert test_counts["chloroform"] > 50 # plenty of chloroform test-set data
|
| 421 |
+
# SI-documented data scarcity: "the available experimental data for water in the test set
|
| 422 |
+
# is limited to one solute" -- the residuals table (which requires an experimental value,
|
| 423 |
+
# unlike the feature-space-coverage table) sees far fewer TIP4P test-set rows than chloroform
|
| 424 |
+
assert 0 < test_counts.get("TIP4P", 0) < test_counts["chloroform"] / 5
|
analysis/code/test_carbon_monoxide.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for analysis/code/carbon_monoxide.py. The figure's claim is that MagNET tracks
|
| 2 |
+
DFT near the equilibrium bond length but drifts in the non-equilibrium tails; that is checked against
|
| 3 |
+
the shipped scan data, along with the loaders and the histogram helper."""
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 11 |
+
sys.path.insert(0, HERE)
|
| 12 |
+
|
| 13 |
+
import carbon_monoxide as co # noqa: E402
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_comparison_loads_and_is_sorted():
|
| 17 |
+
df = co.load_comparison()
|
| 18 |
+
assert len(df) == 81
|
| 19 |
+
assert list(df.columns) == ["bond_length", "gaussian_c", "magnet_c", "delta_c",
|
| 20 |
+
"gaussian_o", "magnet_o", "delta_o"]
|
| 21 |
+
assert df["bond_length"].is_monotonic_increasing
|
| 22 |
+
assert df["bond_length"].min() == pytest.approx(0.70, abs=1e-3)
|
| 23 |
+
assert df["bond_length"].max() == pytest.approx(1.50, abs=1e-3)
|
| 24 |
+
# delta is exactly MagNET minus DFT
|
| 25 |
+
assert np.allclose(df["delta_c"], df["magnet_c"] - df["gaussian_c"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_histogram_helper():
|
| 29 |
+
centers, counts, bin_width = co.load_bond_length_histogram()
|
| 30 |
+
assert len(centers) == len(counts) == 100
|
| 31 |
+
assert bin_width > 0
|
| 32 |
+
assert counts.sum() > 0 # there is real bond-length support behind the error panel
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_error_grows_away_from_equilibrium():
|
| 36 |
+
"""The published claim: the carbon error is small near the equilibrium bond length and much
|
| 37 |
+
larger in the stretched/compressed tails the model never trained on."""
|
| 38 |
+
near, tails = co.equilibrium_vs_extreme_error()
|
| 39 |
+
assert near < 30.0 # near-equilibrium error stays modest (about 19 ppm)
|
| 40 |
+
assert tails > 100.0 # tails blow up (about 185 ppm)
|
| 41 |
+
assert tails > 5 * near # the tails are far worse, which is the point of the figure
|
analysis/code/test_composite_model.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for build_composite_model.py: the composite_model/ group in applications.hdf5 reproduces
|
| 2 |
+
from the shipped delta-22 data, so the natural-products coefficients need no private tree or
|
| 3 |
+
environment variables. Skipped when the large hdf5 files are absent."""
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 12 |
+
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
|
| 13 |
+
for _p in ("analysis/code", "analysis/code/shared", "data/delta22", "data/applications"):
|
| 14 |
+
sys.path.insert(0, os.path.join(REPO, _p))
|
| 15 |
+
|
| 16 |
+
import build_composite_model as B # noqa: E402
|
| 17 |
+
import paths # noqa: E402
|
| 18 |
+
|
| 19 |
+
DELTA22 = paths.dataset_file("delta22", root=REPO)
|
| 20 |
+
APPLICATIONS = paths.dataset_file("applications", root=REPO)
|
| 21 |
+
_HAVE_DATA = os.path.exists(DELTA22) and os.path.exists(APPLICATIONS)
|
| 22 |
+
|
| 23 |
+
# The fixed-point integer encoding (int32 x 1e4) sets the reproduction floor; recipe errors are ~1.
|
| 24 |
+
ENCODING_TOL = 5e-3
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _reader():
|
| 28 |
+
from applications_reader import Applications
|
| 29 |
+
return Applications(APPLICATIONS)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
|
| 33 |
+
def test_ols_and_pcm_reproduce():
|
| 34 |
+
"""Every OLS formula (all solvents, both nuclei) and both PCM-conversion tables reproduce."""
|
| 35 |
+
reader = _reader()
|
| 36 |
+
full_tab, _fit_tab, _apply_tab, factors = B._load_tables()
|
| 37 |
+
worst = 0.0
|
| 38 |
+
for formula in reader.composite_formulas("ols_coefficients"):
|
| 39 |
+
regressors = B._regressor_columns(formula)
|
| 40 |
+
for reg in regressors:
|
| 41 |
+
B._ensure_column(full_tab, reg)
|
| 42 |
+
for nucleus in ("H", "C"):
|
| 43 |
+
stored = reader.ols_coefficients(formula, nucleus).set_index("parameter")
|
| 44 |
+
for solvent in stored.columns:
|
| 45 |
+
sub = full_tab[(full_tab["nucleus"] == nucleus) & (full_tab["solvent"] == solvent)]
|
| 46 |
+
got = B._decompose(B._fit(sub, regressors), regressors, solvent, nucleus, factors)
|
| 47 |
+
worst = max(worst, max(abs(float(stored.loc[p, solvent]) - got[p]) for p in stored.index))
|
| 48 |
+
assert worst < ENCODING_TOL, f"OLS max diff {worst}"
|
| 49 |
+
|
| 50 |
+
for nucleus in ("H", "C"):
|
| 51 |
+
stored = reader.pcm_conversion_factors(nucleus).set_index("solvent")["pcm_conversion_factor"]
|
| 52 |
+
got = B._pcm_table(factors, nucleus).set_index("solvent")["pcm_conversion_factor"]
|
| 53 |
+
assert np.abs(got - stored.reindex(got.index)).max() < 1e-9
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
|
| 57 |
+
def test_bootstrap_and_rmse_reproduce():
|
| 58 |
+
"""Bootstrap coefficients and RMSE distributions reproduce on a sample of seeds (the fixed-seed
|
| 59 |
+
resampler makes every seed deterministic, so a sample is representative). This is the fast CI
|
| 60 |
+
check; build_composite_model.main() re-fits all 1000 seeds and is the authoritative full pass."""
|
| 61 |
+
reader = _reader()
|
| 62 |
+
_full_tab, fit_tab, apply_tab, factors = B._load_tables()
|
| 63 |
+
seeds = range(0, 1000, 97)
|
| 64 |
+
|
| 65 |
+
worst = 0.0
|
| 66 |
+
for formula in reader.composite_formulas("bootstrap_coefficients"):
|
| 67 |
+
regressors = B._regressor_columns(formula)
|
| 68 |
+
for reg in regressors:
|
| 69 |
+
B._ensure_column(fit_tab, reg)
|
| 70 |
+
for nucleus in ("H", "C"):
|
| 71 |
+
try:
|
| 72 |
+
stored = reader.bootstrap_coefficients(formula, nucleus)
|
| 73 |
+
except KeyError:
|
| 74 |
+
continue
|
| 75 |
+
params = [c for c in stored.columns if c not in ("solvent", "seed")]
|
| 76 |
+
for solvent in stored["solvent"].unique():
|
| 77 |
+
for seed in seeds:
|
| 78 |
+
row = stored[(stored["solvent"] == solvent) & (stored["seed"] == seed)]
|
| 79 |
+
if not len(row):
|
| 80 |
+
continue
|
| 81 |
+
got = B._decompose(B._fit(B._bootstrap_sample(fit_tab, nucleus, solvent, seed),
|
| 82 |
+
regressors), regressors, solvent, nucleus, factors)
|
| 83 |
+
for p in params:
|
| 84 |
+
key = "intercept" if p == "Intercept" else p
|
| 85 |
+
worst = max(worst, abs(float(row[p].iloc[0]) - got[key]))
|
| 86 |
+
assert worst < ENCODING_TOL, f"bootstrap max diff {worst}"
|
| 87 |
+
|
| 88 |
+
worst = 0.0
|
| 89 |
+
for nucleus in ("H", "C"):
|
| 90 |
+
stored = reader.rmse_distribution(nucleus)
|
| 91 |
+
for formula in stored["formula"].unique():
|
| 92 |
+
sub = stored[stored["formula"] == formula]
|
| 93 |
+
for solvent in sub["solvent"].unique():
|
| 94 |
+
for seed in seeds:
|
| 95 |
+
row = sub[(sub["solvent"] == solvent) & (sub["seed"] == seed)]
|
| 96 |
+
if not len(row):
|
| 97 |
+
continue
|
| 98 |
+
got = B._rmse(fit_tab, apply_tab, formula, nucleus, solvent, seed, factors)
|
| 99 |
+
worst = max(worst, abs(float(row["Bootstrap_RMSE"].iloc[0]) - got))
|
| 100 |
+
assert worst < ENCODING_TOL, f"rmse max diff {worst}"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
|
| 104 |
+
def test_bootstrap_is_deterministic():
|
| 105 |
+
"""The resampler reseeds internally, so a given seed draws the same solutes no matter the
|
| 106 |
+
external RNG state going in."""
|
| 107 |
+
fit_tab = B._load_tables()[1]
|
| 108 |
+
random.seed(123)
|
| 109 |
+
a = B._bootstrap_sample(fit_tab, "C", "chloroform", 7)
|
| 110 |
+
random.seed(456)
|
| 111 |
+
b = B._bootstrap_sample(fit_tab, "C", "chloroform", 7)
|
| 112 |
+
assert list(a["solute"]) == list(b["solute"])
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
|
| 116 |
+
def test_write_roundtrips(tmp_path):
|
| 117 |
+
"""write() serializes the regenerated tables into an hdf5 file that reads back unchanged through
|
| 118 |
+
Applications, so --write cannot silently corrupt the shipped file's composite_model group."""
|
| 119 |
+
from applications_reader import Applications
|
| 120 |
+
regenerated = B.regenerate(_reader())
|
| 121 |
+
scratch = str(tmp_path / "cm.hdf5")
|
| 122 |
+
B.write(scratch, regenerated)
|
| 123 |
+
worst = B.verify(Applications(scratch), regenerated)
|
| 124 |
+
assert worst < 1e-6, f"write/read round-trip diff {worst}"
|
analysis/code/test_composite_models.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for analysis/code/composite_models.py.
|
| 2 |
+
|
| 3 |
+
The fitting side is exercised on a small synthetic delta-22-shaped query table (composite_models
|
| 4 |
+
adds no new fitting logic of its own -- it calls delta22.py's harness directly, which has its own
|
| 5 |
+
tests). What's specific to this module is the pivot/reindex shape and the openpyxl table-writing
|
| 6 |
+
layer, so those get direct, hermetic tests here: the formula lookup helpers, the %benefit formula
|
| 7 |
+
writer, and the full workbook structure (sheet names, header cells, RMSE cell placement) on
|
| 8 |
+
synthetic data. An opt-in test checks the real data reproduces a plausible RMSE range.
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 18 |
+
sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta22")) # delta22_reader
|
| 19 |
+
sys.path.insert(0, HERE)
|
| 20 |
+
import paths
|
| 21 |
+
|
| 22 |
+
import composite_models as CM # noqa: E402
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _synthetic_query_df_dft(solutes, lots, seed=0):
|
| 26 |
+
"""A query_df_dft-shaped table with all the columns composite_models' formulas reference, at
|
| 27 |
+
each of the given (method, basis, geometry) triples, for both nuclei."""
|
| 28 |
+
rng = np.random.default_rng(seed)
|
| 29 |
+
rows = []
|
| 30 |
+
for solute in solutes:
|
| 31 |
+
for solvent in CM.ORDERED_SOLVENTS:
|
| 32 |
+
for nucleus in ["H", "C"]:
|
| 33 |
+
for method, basis, geometry in lots:
|
| 34 |
+
stationary = rng.normal(30 if nucleus == "H" else 150, 5)
|
| 35 |
+
pcm = rng.normal(0, 0.5)
|
| 36 |
+
desmond = rng.normal(0, 0.5)
|
| 37 |
+
qcd = rng.normal(0, 0.2)
|
| 38 |
+
desmond_vib = rng.normal(0, 0.3)
|
| 39 |
+
openMM = rng.normal(0, 0.5)
|
| 40 |
+
openMM_vib = rng.normal(0, 0.3)
|
| 41 |
+
experimental = stationary + 0.5 * pcm + 0.3 * desmond + rng.normal(0, 0.05)
|
| 42 |
+
rows.append(dict(
|
| 43 |
+
solute=solute, solvent=solvent, nucleus=nucleus,
|
| 44 |
+
sap_nmr_method=method, sap_basis=basis, sap_geometry_type=geometry,
|
| 45 |
+
stationary=stationary, pcm=pcm, desmond=desmond, qcd=qcd,
|
| 46 |
+
desmond_vib=desmond_vib, openMM=openMM, openMM_vib=openMM_vib,
|
| 47 |
+
experimental=experimental))
|
| 48 |
+
return pd.DataFrame(rows)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
SOLUTES = [f"m{i}" for i in range(16)]
|
| 52 |
+
DSD_LOT = ("dsd_pbep86", "pcSseg3", "pbe0_tz")
|
| 53 |
+
WP04_LOT = ("wp04", "pcSseg2", "aimnet2")
|
| 54 |
+
WB97XD_LOT = ("wb97xd", "pcSseg2", "aimnet2")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_canonical_formula_name_ignores_case_spaces_underscores():
|
| 58 |
+
assert CM._canonical_formula_name("stationary + PCM") == CM._canonical_formula_name("stationary+pcm")
|
| 59 |
+
assert CM._canonical_formula_name("stationary_plus_pcm") != CM._canonical_formula_name("stationary + pcm")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_get_rmse_looks_up_by_canonical_name():
|
| 63 |
+
df = pd.DataFrame({"chloroform": [0.123]}, index=["stationary + pcm"])
|
| 64 |
+
lookup = CM._build_formula_lookup(df)
|
| 65 |
+
assert CM._get_rmse(df, lookup, "stationary+PCM", "chloroform") == pytest.approx(0.123)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_get_rmse_missing_formula_raises_keyerror():
|
| 69 |
+
df = pd.DataFrame({"chloroform": [0.1]}, index=["stationary"])
|
| 70 |
+
lookup = CM._build_formula_lookup(df)
|
| 71 |
+
with pytest.raises(KeyError):
|
| 72 |
+
CM._get_rmse(df, lookup, "not_a_real_formula", "chloroform")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_ablation_rmse_table_shape_and_exact_recovery():
|
| 76 |
+
# experimental = stationary + 0.5*pcm + 0.3*desmond exactly (no noise), so a formula containing
|
| 77 |
+
# both pcm and qcd should fit near-perfectly (tiny test RMSE) while "stationary" alone
|
| 78 |
+
# (missing both real predictors) should not. "stationary + pcm + qcd" is a real formula in
|
| 79 |
+
# CM.FORMULAS; there is no formula combining pcm and desmond together, so the ground truth is
|
| 80 |
+
# built from pcm and qcd instead.
|
| 81 |
+
rng = np.random.default_rng(1)
|
| 82 |
+
rows = []
|
| 83 |
+
for solute in SOLUTES:
|
| 84 |
+
for solvent in CM.ORDERED_SOLVENTS:
|
| 85 |
+
stationary = rng.normal(30, 5)
|
| 86 |
+
pcm = rng.normal(0, 0.5)
|
| 87 |
+
desmond = rng.normal(0, 0.5)
|
| 88 |
+
qcd = rng.normal(0, 0.2)
|
| 89 |
+
desmond_vib = rng.normal(0, 0.3)
|
| 90 |
+
openMM = rng.normal(0, 0.5)
|
| 91 |
+
openMM_vib = rng.normal(0, 0.3)
|
| 92 |
+
experimental = stationary + 0.5 * pcm + 0.3 * qcd
|
| 93 |
+
rows.append(dict(solute=solute, solvent=solvent, nucleus="H",
|
| 94 |
+
sap_nmr_method="dsd_pbep86", sap_basis="pcSseg3",
|
| 95 |
+
sap_geometry_type="pbe0_tz", stationary=stationary, pcm=pcm,
|
| 96 |
+
desmond=desmond, qcd=qcd, desmond_vib=desmond_vib, openMM=openMM,
|
| 97 |
+
openMM_vib=openMM_vib, experimental=experimental))
|
| 98 |
+
query_df_dft = pd.DataFrame(rows)
|
| 99 |
+
|
| 100 |
+
table = CM.ablation_rmse_table(query_df_dft, "H", "dsd_pbep86", "pcSseg3", "pbe0_tz", SOLUTES,
|
| 101 |
+
n_splits=10)
|
| 102 |
+
assert list(table.index) == CM.FORMULAS
|
| 103 |
+
assert list(table.columns) == CM.ORDERED_SOLVENTS + ["Mean Test RMSE"]
|
| 104 |
+
assert table.loc["stationary + pcm + qcd", "Mean Test RMSE"] < 0.05
|
| 105 |
+
assert table.loc["stationary", "Mean Test RMSE"] > table.loc["stationary + pcm + qcd", "Mean Test RMSE"]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_write_nucleus_table_cell_placement():
|
| 109 |
+
query_df_dft = _synthetic_query_df_dft(SOLUTES, [DSD_LOT])
|
| 110 |
+
table = CM.ablation_rmse_table(query_df_dft, "H", *DSD_LOT, SOLUTES, n_splits=5)
|
| 111 |
+
|
| 112 |
+
from openpyxl import Workbook
|
| 113 |
+
wb = Workbook()
|
| 114 |
+
ws = wb.active
|
| 115 |
+
next_row = CM.write_nucleus_table(
|
| 116 |
+
ws=ws, start_row=1, nucleus_name="Proton", stationary_desc="DSD-PBEP86/pcSseg3",
|
| 117 |
+
pcm_desc="B3LYP-D3BJ/pcSseg3", test_rmse_df=table, vmin=0.06, vmax=0.28,
|
| 118 |
+
formula_config=CM.PROTON_FORMULA_CONFIG)
|
| 119 |
+
|
| 120 |
+
assert ws["A1"].value == "Proton"
|
| 121 |
+
assert ws["A6"].value == "formula"
|
| 122 |
+
assert ws["A9"].value == "stationary"
|
| 123 |
+
assert ws["B9"].value == pytest.approx(table.loc["stationary", "chloroform"])
|
| 124 |
+
# the "Mean Test RMSE" column is the last of the 13 data columns (B..N)
|
| 125 |
+
assert ws["N5"].value == "Mean Test RMSE"
|
| 126 |
+
assert ws["N9"].value == pytest.approx(table.loc["stationary", "Mean Test RMSE"])
|
| 127 |
+
# %benefit rows carry a live Excel formula, not a precomputed number
|
| 128 |
+
assert str(ws["B14"].value).startswith("=IFERROR(")
|
| 129 |
+
assert next_row == 1 + 45
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_build_ablations_workbook_single_level_two_sheets():
|
| 133 |
+
query_df_dft = _synthetic_query_df_dft(SOLUTES, [DSD_LOT])
|
| 134 |
+
import tempfile
|
| 135 |
+
with tempfile.TemporaryDirectory() as td:
|
| 136 |
+
out = os.path.join(td, "ablations.xlsx")
|
| 137 |
+
CM.build_ablations_workbook(query_df_dft, SOLUTES, out, reference_levels=("dsd",),
|
| 138 |
+
n_splits=5, verbose=False)
|
| 139 |
+
from openpyxl import load_workbook
|
| 140 |
+
wb = load_workbook(out)
|
| 141 |
+
assert wb.sheetnames == ["Proton", "Carbon"]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_build_ablations_workbook_both_levels_four_sheets_matching_shipped_names():
|
| 145 |
+
query_df_dft = _synthetic_query_df_dft(SOLUTES, [DSD_LOT, WP04_LOT, WB97XD_LOT])
|
| 146 |
+
import tempfile
|
| 147 |
+
with tempfile.TemporaryDirectory() as td:
|
| 148 |
+
out = os.path.join(td, "ablations.xlsx")
|
| 149 |
+
CM.build_ablations_workbook(query_df_dft, SOLUTES, out, n_splits=5, verbose=False)
|
| 150 |
+
from openpyxl import load_workbook
|
| 151 |
+
wb = load_workbook(out)
|
| 152 |
+
# matches wolford/ablations_updated 2.xlsx's exact sheet names
|
| 153 |
+
assert wb.sheetnames == ["Proton (DSD)", "Carbon (DSD)", "Proton (WP04)", "Carbon (wB97XD)"]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# --- opt-in: reproduce a plausible ablation table from the real delta-22 data ---------------------
|
| 157 |
+
|
| 158 |
+
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
|
| 159 |
+
REAL_H5 = paths.dataset_file("delta22", root=REPO)
|
| 160 |
+
REAL_XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@pytest.mark.skipif(not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
|
| 164 |
+
reason="real delta22.hdf5 / experimental xlsx not present")
|
| 165 |
+
def test_ablation_rmse_table_reproduces_plausible_range_from_real_data():
|
| 166 |
+
import delta22 as D
|
| 167 |
+
query_df_dft = D.add_composite_columns(D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False))
|
| 168 |
+
solutes = D.delta22_solutes(REAL_H5)
|
| 169 |
+
level = CM.REFERENCE_LEVELS["dsd"]
|
| 170 |
+
table = CM.ablation_rmse_table(query_df_dft, "H", level["method_h"], level["basis_h"],
|
| 171 |
+
level["geometry_h"], solutes, n_splits=20)
|
| 172 |
+
vmin, vmax = CM._VMIN_VMAX["H"]
|
| 173 |
+
# the shipped workbook's fixed color-scale range is itself a real-data-derived sanity bound:
|
| 174 |
+
# every formula's mean test RMSE should land inside it
|
| 175 |
+
assert (table["Mean Test RMSE"] >= vmin * 0.5).all()
|
| 176 |
+
assert (table["Mean Test RMSE"] <= vmax * 1.5).all()
|
| 177 |
+
# the fullest model should not be worse than the bare stationary reference
|
| 178 |
+
assert table.loc["stationary + desmond + qcd", "Mean Test RMSE"] < table.loc["stationary", "Mean Test RMSE"]
|
analysis/code/test_delta22_analysis.py
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for analysis/code/delta22.py (the no-plotting analysis module).
|
| 2 |
+
|
| 3 |
+
The analysis functions operate on flat query DataFrames, so these tests build small synthetic
|
| 4 |
+
tables with a known linear relationship and check the fitting harness, the seeded splits, the
|
| 5 |
+
Pareto frontier, and the correlation matrix. One test proves the fast numpy `fit` gives the same
|
| 6 |
+
coefficients and RMSE as the original statsmodels fit.
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 17 |
+
sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta22")) # delta22_reader
|
| 18 |
+
sys.path.insert(0, HERE)
|
| 19 |
+
import paths
|
| 20 |
+
|
| 21 |
+
import delta22 as D # noqa: E402
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def make_query_df(n_solutes=12, solvents=("chloroform", "benzene"), noise=0.0, seed=0):
|
| 25 |
+
"""A flat query table whose experimental value is exactly 2*stationary + 0.5*pcm + 1 (plus
|
| 26 |
+
optional noise), so a model with both terms fits near-perfectly and 'stationary' alone leaves
|
| 27 |
+
the 0.5*pcm residual."""
|
| 28 |
+
rng = np.random.default_rng(seed)
|
| 29 |
+
rows = []
|
| 30 |
+
for si in range(n_solutes):
|
| 31 |
+
solute = f"m{si:02d}"
|
| 32 |
+
for solvent in solvents:
|
| 33 |
+
for k in range(3):
|
| 34 |
+
stationary = rng.normal(100, 20)
|
| 35 |
+
pcm = rng.normal(0, 2)
|
| 36 |
+
experimental = 2.0 * stationary + 0.5 * pcm + 1.0 + rng.normal(0, noise)
|
| 37 |
+
rows.append({
|
| 38 |
+
"solute": solute, "solvent": solvent, "nucleus": "H",
|
| 39 |
+
"site": f"{solute}_{solvent}_{k}", "experimental": experimental,
|
| 40 |
+
"stationary": stationary, "pcm": pcm,
|
| 41 |
+
"desmond": rng.normal(0, 2), "openMM": rng.normal(0, 2),
|
| 42 |
+
"qcd": rng.normal(0, 1), "desmond_vib": rng.normal(0, 1),
|
| 43 |
+
"openMM_vib": rng.normal(0, 1),
|
| 44 |
+
})
|
| 45 |
+
return pd.DataFrame(rows)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_fit_recovers_known_line():
|
| 49 |
+
df = make_query_df(noise=0.0)
|
| 50 |
+
train_rmse, test_rmse, params = D.fit(df, df, "stationary + pcm")
|
| 51 |
+
assert params["Intercept"] == pytest.approx(1.0, abs=1e-6)
|
| 52 |
+
assert params["stationary"] == pytest.approx(2.0, abs=1e-6)
|
| 53 |
+
assert params["pcm"] == pytest.approx(0.5, abs=1e-6)
|
| 54 |
+
assert train_rmse == pytest.approx(0.0, abs=1e-6)
|
| 55 |
+
assert test_rmse == pytest.approx(0.0, abs=1e-6)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_fit_matches_statsmodels():
|
| 59 |
+
df = make_query_df(noise=1.0, seed=3)
|
| 60 |
+
train = df[df["solute"] <= "m07"]
|
| 61 |
+
test = df[df["solute"] > "m07"]
|
| 62 |
+
for formula in ["stationary", "stationary + pcm", "stationary + pcm + qcd"]:
|
| 63 |
+
tr, te, params = D.fit(train, test, formula)
|
| 64 |
+
s_tr, s_te, s_params = D._fit_statsmodels(train, test, formula)
|
| 65 |
+
assert tr == pytest.approx(s_tr, abs=1e-8)
|
| 66 |
+
assert te == pytest.approx(s_te, abs=1e-8)
|
| 67 |
+
for name in s_params.index:
|
| 68 |
+
assert params[name] == pytest.approx(s_params[name], abs=1e-6)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_fit_drops_missing_like_statsmodels():
|
| 72 |
+
# fitting must drop rows with a missing response or predictor, exactly as statsmodels does,
|
| 73 |
+
# and recover the same coefficients. (Scoring is checked on a clean test set; statsmodels would
|
| 74 |
+
# otherwise NaN-poison an RMSE computed over rows that have missing predictors.)
|
| 75 |
+
clean = make_query_df(noise=0.5, seed=5).reset_index(drop=True)
|
| 76 |
+
train = clean.copy()
|
| 77 |
+
train.loc[0, "experimental"] = np.nan # missing response
|
| 78 |
+
train.loc[1, "pcm"] = np.nan # missing predictor
|
| 79 |
+
_train_err, te, params = D.fit(train, clean, "stationary + pcm")
|
| 80 |
+
s_tr, s_te, s_params = D._fit_statsmodels(train, clean, "stationary + pcm")
|
| 81 |
+
for name in s_params.index:
|
| 82 |
+
assert params[name] == pytest.approx(s_params[name], abs=1e-6)
|
| 83 |
+
assert te == pytest.approx(s_te, abs=1e-8)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_generate_solute_splits_seeded_and_disjoint():
|
| 87 |
+
df = make_query_df()
|
| 88 |
+
solutes = [f"m{i:02d}" for i in range(12)]
|
| 89 |
+
splits = D.generate_solute_splits(5, df, "chloroform", solutes, n_test=4)
|
| 90 |
+
assert len(splits) == 5
|
| 91 |
+
# reproduce the exact seed logic for split 0
|
| 92 |
+
random.seed(0 + 100)
|
| 93 |
+
expected = list(solutes)
|
| 94 |
+
random.shuffle(expected)
|
| 95 |
+
train_df, test_df = splits[0]
|
| 96 |
+
assert set(test_df["solute"].unique()) == set(expected[:4])
|
| 97 |
+
assert set(train_df["solute"].unique()) == set(expected[4:])
|
| 98 |
+
# a solute is never in both halves, and only the requested solvent is present
|
| 99 |
+
assert set(test_df["solute"]).isdisjoint(set(train_df["solute"]))
|
| 100 |
+
assert set(test_df["solvent"].unique()) == {"chloroform"}
|
| 101 |
+
# deterministic: same call gives the same partition
|
| 102 |
+
again = D.generate_solute_splits(5, df, "chloroform", solutes, n_test=4)
|
| 103 |
+
assert set(again[0][1]["solute"].unique()) == set(test_df["solute"].unique())
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_run_fits_more_terms_lower_error():
|
| 107 |
+
df = make_query_df(noise=0.3, seed=1)
|
| 108 |
+
solutes = sorted(df["solute"].unique())
|
| 109 |
+
results = D.run_fits(df, ["chloroform", "benzene"], D.STATIONARY_VS_PCM,
|
| 110 |
+
n_splits=5, solutes=solutes, n_test=4)
|
| 111 |
+
assert set(results.columns) >= {"solvent", "formula", "seed", "train_RMSE", "test_RMSE"}
|
| 112 |
+
med = D.median_test_rmse(results, ["formula"]).set_index("formula")["test_RMSE"]
|
| 113 |
+
assert med["stationary + pcm"] < med["stationary"] # the pcm term genuinely helps
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_run_fits_group_cols():
|
| 117 |
+
df = make_query_df(noise=0.3, seed=2)
|
| 118 |
+
df = pd.concat([df.assign(sap_nmr_method="wp04"),
|
| 119 |
+
df.assign(sap_nmr_method="dsd_pbep86")], ignore_index=True)
|
| 120 |
+
solutes = sorted(df["solute"].unique())
|
| 121 |
+
results = D.run_fits(df, ["chloroform"], ["stationary + pcm"], n_splits=3,
|
| 122 |
+
solutes=solutes, group_cols=["sap_nmr_method"], n_test=4)
|
| 123 |
+
assert "sap_nmr_method" in results.columns
|
| 124 |
+
assert set(results["sap_nmr_method"].unique()) == {"wp04", "dsd_pbep86"}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_fit_coefficients_per_seed():
|
| 128 |
+
df = make_query_df(noise=0.4, seed=7)
|
| 129 |
+
solutes = sorted(df["solute"].unique())
|
| 130 |
+
coeffs = D.fit_coefficients(df, "chloroform", "stationary + pcm", solutes,
|
| 131 |
+
n_splits=6, n_test=4)
|
| 132 |
+
assert len(coeffs) == 6
|
| 133 |
+
assert {"solvent", "seed", "Intercept", "stationary", "pcm"}.issubset(coeffs.columns)
|
| 134 |
+
# the recovered slope should sit near the true value of 2
|
| 135 |
+
assert coeffs["stationary"].mean() == pytest.approx(2.0, abs=0.1)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_full_fit_coefficients_shape_and_values():
|
| 139 |
+
df = make_query_df(noise=0.0, solvents=("chloroform", "benzene"))
|
| 140 |
+
coeffs = D.full_fit_coefficients(df, ["chloroform", "benzene"], "stationary + pcm")
|
| 141 |
+
assert list(coeffs.columns) == ["chloroform", "benzene"]
|
| 142 |
+
assert set(coeffs.index) == {"Intercept", "stationary", "pcm"}
|
| 143 |
+
# the data is exactly 2*stationary + 0.5*pcm + 1, recovered per solvent
|
| 144 |
+
for solvent in ["chloroform", "benzene"]:
|
| 145 |
+
assert coeffs.loc["Intercept", solvent] == pytest.approx(1.0, abs=1e-6)
|
| 146 |
+
assert coeffs.loc["stationary", solvent] == pytest.approx(2.0, abs=1e-6)
|
| 147 |
+
assert coeffs.loc["pcm", solvent] == pytest.approx(0.5, abs=1e-6)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ---- opt-in: checked against the STORED composite-model coefficients ----
|
| 151 |
+
# These coefficients live in applications.hdf5's composite_model/ols_coefficients group. Re-fitting
|
| 152 |
+
# the released delta-22 data with the fast numpy harness (nitromethane dropped) must reproduce them
|
| 153 |
+
# to within the data's integer-encoding precision (~5e-4 after error propagation through the small
|
| 154 |
+
# corrections).
|
| 155 |
+
REAL_H5 = paths.dataset_file("delta22", file=__file__)
|
| 156 |
+
REAL_XLSX = os.path.join(HERE, "..", "..", "data", "delta22", "delta22_experimental.xlsx")
|
| 157 |
+
APPLICATIONS_H5 = paths.dataset_file("applications", file=__file__)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@pytest.mark.skipif(
|
| 161 |
+
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX) and os.path.exists(APPLICATIONS_H5)),
|
| 162 |
+
reason="real delta22.hdf5 / experimental xlsx / applications.hdf5 not present")
|
| 163 |
+
def test_full_fit_reproduces_stored_composite_coefficients():
|
| 164 |
+
import io
|
| 165 |
+
import h5py
|
| 166 |
+
|
| 167 |
+
query = D.load_query_df_nn(REAL_H5, REAL_XLSX, exclude_solutes=["nitromethane"], verbose=False)
|
| 168 |
+
worst = 0.0
|
| 169 |
+
with h5py.File(APPLICATIONS_H5, "r") as f:
|
| 170 |
+
ols = f["composite_model"]["ols_coefficients"]
|
| 171 |
+
for nucleus in ["H", "C"]:
|
| 172 |
+
sub = query[query["nucleus"] == nucleus]
|
| 173 |
+
for formula in ["stationary", "stationary + openMM", "stationary + openMM + qcd"]:
|
| 174 |
+
tag = "_AND_".join(term.strip() for term in formula.split("+"))
|
| 175 |
+
raw = np.array(ols[tag][nucleus]).item()
|
| 176 |
+
raw = raw.decode() if hasattr(raw, "decode") else raw
|
| 177 |
+
stored = pd.read_csv(io.StringIO(raw)).set_index("parameter")
|
| 178 |
+
mine = D.full_fit_coefficients(sub, list(stored.columns), formula)
|
| 179 |
+
mine = mine.rename(index={"Intercept": "intercept"})
|
| 180 |
+
for solvent in stored.columns:
|
| 181 |
+
for param in stored.index:
|
| 182 |
+
worst = max(worst, abs(mine.loc[param, solvent] - stored.loc[param, solvent]))
|
| 183 |
+
assert worst < 5e-4, f"worst coefficient diff {worst:.2e} exceeds the int32 precision floor"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def make_dft_query_df(methods=("wp04", "dsd_pbep86"), solvents=("chloroform", "methanol", "benzene"),
|
| 187 |
+
n_solutes=12, seed=0):
|
| 188 |
+
"""A synthetic DFT query table with method/basis/geometry columns. Experimental is
|
| 189 |
+
31 - (stationary + pcm) + tiny noise, i.e. a single shared slope on the gas shielding plus its
|
| 190 |
+
implicit-solvent correction (the real structure), so the single-column 'stationary_plus_pcm'
|
| 191 |
+
model fits well and 'stationary' alone leaves the pcm residual. desmond/openMM/qcd are
|
| 192 |
+
independent noise that does not help."""
|
| 193 |
+
rng = np.random.default_rng(seed)
|
| 194 |
+
rows = []
|
| 195 |
+
for method in methods:
|
| 196 |
+
for si in range(n_solutes):
|
| 197 |
+
solute = f"m{si:02d}"
|
| 198 |
+
for solvent in solvents:
|
| 199 |
+
for k in range(2):
|
| 200 |
+
stationary = rng.normal(100, 20)
|
| 201 |
+
pcm = rng.normal(0, 2)
|
| 202 |
+
rows.append({
|
| 203 |
+
"solute": solute, "solvent": solvent, "nucleus": "H",
|
| 204 |
+
"site": f"{solute}_{k}", "sap_nmr_method": method,
|
| 205 |
+
"sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
|
| 206 |
+
"experimental": 31.0 - (stationary + pcm) + rng.normal(0, 0.05),
|
| 207 |
+
"stationary": stationary, "pcm": pcm,
|
| 208 |
+
"desmond": rng.normal(0, 1), "openMM": rng.normal(0, 1),
|
| 209 |
+
"qcd": rng.normal(0, 1), "desmond_vib": rng.normal(0, 1),
|
| 210 |
+
"openMM_vib": rng.normal(0, 1),
|
| 211 |
+
})
|
| 212 |
+
return pd.DataFrame(rows)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_add_composite_columns():
|
| 216 |
+
df = make_query_df()
|
| 217 |
+
out = D.add_composite_columns(df)
|
| 218 |
+
assert np.allclose(out["stationary_plus_pcm"], out["stationary"] + out["pcm"])
|
| 219 |
+
assert np.allclose(out["stationary_plus_pcm_plus_qcd"],
|
| 220 |
+
out["stationary"] + out["pcm"] + out["qcd"])
|
| 221 |
+
# an existing NN-style stationary_plus_pcm (with scaling factors) is preserved
|
| 222 |
+
nn = df.copy()
|
| 223 |
+
nn["stationary_plus_pcm"] = 999.0
|
| 224 |
+
kept = D.add_composite_columns(nn)
|
| 225 |
+
assert np.allclose(kept["stationary_plus_pcm"], 999.0)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_fig3b_shift_differences_exact():
|
| 229 |
+
# one method, three solvents; experimental/pcm chosen so the differences are known
|
| 230 |
+
rows = []
|
| 231 |
+
vals = {"chloroform": (1.0, 0.1), "methanol": (1.5, 0.4), "benzene": (0.7, -0.2)}
|
| 232 |
+
for solvent, (exp, pcm) in vals.items():
|
| 233 |
+
rows.append({"solute": "m0", "site": "s0", "nucleus": "H", "solvent": solvent,
|
| 234 |
+
"sap_nmr_method": "wp04", "sap_basis": "pcSseg2",
|
| 235 |
+
"sap_geometry_type": "aimnet2", "experimental": exp, "pcm": pcm})
|
| 236 |
+
df = pd.DataFrame(rows)
|
| 237 |
+
out = D.fig3b_shift_differences(df, "wp04", "pcSseg2", "aimnet2", nucleus="H",
|
| 238 |
+
x_solvent="methanol", y_solvent="benzene", reference="chloroform")
|
| 239 |
+
assert len(out) == 1
|
| 240 |
+
assert out["exp_diff_x"].iloc[0] == pytest.approx(1.5 - 1.0)
|
| 241 |
+
assert out["exp_diff_y"].iloc[0] == pytest.approx(0.7 - 1.0)
|
| 242 |
+
assert out["pcm_diff_x"].iloc[0] == pytest.approx(0.4 - 0.1)
|
| 243 |
+
assert out["pcm_diff_y"].iloc[0] == pytest.approx(-0.2 - 0.1)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def test_fig3a_and_fig3d_pcm_helps():
|
| 247 |
+
df = D.add_composite_columns(make_dft_query_df(seed=4))
|
| 248 |
+
solutes = sorted(df["solute"].unique())
|
| 249 |
+
solvents = ["chloroform", "methanol", "benzene"]
|
| 250 |
+
a = D.fig3a_pcm_benefit(df, solvents, n_splits=4, solutes=solutes, n_test=4)
|
| 251 |
+
assert {"sap_nmr_method", "solvent", "formula", "test_RMSE"}.issubset(a.columns)
|
| 252 |
+
med = a.groupby("formula")["test_RMSE"].median()
|
| 253 |
+
assert med["stationary_plus_pcm"] < med["stationary"] # PCM lowers error
|
| 254 |
+
d = D.fig3d_formula_regressions(df, "wp04", "pcSseg2", "aimnet2",
|
| 255 |
+
["stationary + pcm", "stationary + desmond"], solvents,
|
| 256 |
+
n_splits=4, solutes=solutes, n_test=4)
|
| 257 |
+
assert set(d["formula"].unique()) == {"stationary + pcm", "stationary + desmond"}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def test_fig3a_pcm_benefit_by_solvent_keeps_solvents_separate():
|
| 261 |
+
# chloroform: pcm carries a real (though not perfectly scaled) part of the signal, so adding it
|
| 262 |
+
# to the fit lowers test error. benzene: pcm is unrelated noise on top of an already-explained
|
| 263 |
+
# response, so adding it as a predictor only overfits and raises test error. A solvent-pooled
|
| 264 |
+
# average would blend these two directions away; fig3a_pcm_benefit_by_solvent must keep one row
|
| 265 |
+
# per solvent so both signs are visible.
|
| 266 |
+
rng = np.random.default_rng(11)
|
| 267 |
+
rows = []
|
| 268 |
+
n_solutes = 16
|
| 269 |
+
for si in range(n_solutes):
|
| 270 |
+
solute = f"m{si:02d}"
|
| 271 |
+
for solvent in ["chloroform", "benzene"]:
|
| 272 |
+
for k in range(3):
|
| 273 |
+
stationary = rng.normal(100, 20)
|
| 274 |
+
if solvent == "chloroform":
|
| 275 |
+
pcm = rng.normal(0, 2)
|
| 276 |
+
experimental = 31.0 - stationary - 0.8 * pcm + rng.normal(0, 0.05)
|
| 277 |
+
else:
|
| 278 |
+
pcm = rng.normal(0, 8) # large noise unrelated to experimental
|
| 279 |
+
experimental = 31.0 - stationary + rng.normal(0, 0.05)
|
| 280 |
+
rows.append({
|
| 281 |
+
"solute": solute, "solvent": solvent, "nucleus": "H",
|
| 282 |
+
"site": f"{solute}_{solvent}_{k}", "sap_nmr_method": "wp04",
|
| 283 |
+
"sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
|
| 284 |
+
"experimental": experimental, "stationary": stationary, "pcm": pcm,
|
| 285 |
+
"desmond": 0.0, "openMM": 0.0, "qcd": 0.0, "desmond_vib": 0.0, "openMM_vib": 0.0,
|
| 286 |
+
})
|
| 287 |
+
df = D.add_composite_columns(pd.DataFrame(rows))
|
| 288 |
+
solutes = sorted(df["solute"].unique())
|
| 289 |
+
out = D.fig3a_pcm_benefit_by_solvent(df, ["chloroform", "benzene"], n_splits=10,
|
| 290 |
+
solutes=solutes, n_test=6)
|
| 291 |
+
assert {"sap_nmr_method", "solvent", "seed", "percent_benefit"}.issubset(out.columns)
|
| 292 |
+
chloro_benefit = out[out["solvent"] == "chloroform"]["percent_benefit"].median()
|
| 293 |
+
benz_benefit = out[out["solvent"] == "benzene"]["percent_benefit"].median()
|
| 294 |
+
assert chloro_benefit > 0 # PCM helps in chloroform
|
| 295 |
+
assert benz_benefit < 0 # PCM hurts in benzene
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_fig3a_does_not_pool_nuclei():
|
| 299 |
+
# H sites (~30 ppm) and C sites (~150 ppm) with different relationships. If fig3a pooled both
|
| 300 |
+
# nuclei into one fit the test RMSE would blow up; filtering to one nucleus keeps it small.
|
| 301 |
+
rng = np.random.default_rng(0)
|
| 302 |
+
rows = []
|
| 303 |
+
for si in range(12):
|
| 304 |
+
for solvent in ["chloroform", "methanol", "benzene"]:
|
| 305 |
+
for nucleus, base, slope, offset in [("H", 30, 1.0, 31.0), ("C", 150, 1.05, 200.0)]:
|
| 306 |
+
stationary = rng.normal(base, base * 0.1)
|
| 307 |
+
pcm = rng.normal(0, 1)
|
| 308 |
+
rows.append({
|
| 309 |
+
"solute": f"m{si:02d}", "site": f"m{si:02d}_{nucleus}", "nucleus": nucleus,
|
| 310 |
+
"solvent": solvent, "sap_nmr_method": "wp04", "sap_basis": "pcSseg2",
|
| 311 |
+
"sap_geometry_type": "aimnet2", "stationary": stationary, "pcm": pcm,
|
| 312 |
+
"experimental": offset - slope * (stationary + pcm) + rng.normal(0, 0.05),
|
| 313 |
+
"desmond": 0.0, "openMM": 0.0, "qcd": 0.0, "desmond_vib": 0.0, "openMM_vib": 0.0,
|
| 314 |
+
})
|
| 315 |
+
df = D.add_composite_columns(pd.DataFrame(rows))
|
| 316 |
+
solutes = sorted(df["solute"].unique())
|
| 317 |
+
res = D.fig3a_pcm_benefit(df, ["chloroform", "methanol", "benzene"], n_splits=4,
|
| 318 |
+
solutes=solutes, nucleus="H", n_test=4)
|
| 319 |
+
assert res["test_RMSE"].median() < 1.0 # would be tens of ppm if C were pooled in
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def test_add_solvent_mean():
|
| 323 |
+
df = pd.DataFrame({
|
| 324 |
+
"solute": ["m0", "m0"], "site": ["s0", "s0"], "nucleus": ["H", "H"],
|
| 325 |
+
"solvent": ["chloroform", "benzene"], "experimental": [2.0, 4.0], "pcm": [0.2, 0.6],
|
| 326 |
+
})
|
| 327 |
+
out = D.add_solvent_mean(df)
|
| 328 |
+
mean_row = out[out["solvent"] == "solvent_mean"]
|
| 329 |
+
assert len(mean_row) == 1
|
| 330 |
+
assert mean_row["experimental"].iloc[0] == pytest.approx(3.0) # mean(2, 4)
|
| 331 |
+
assert mean_row["pcm"].iloc[0] == pytest.approx(0.4) # mean(0.2, 0.6)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def test_solvent_pair_differences_exact():
|
| 335 |
+
def row(solvent, exp, pcm, des, dvib):
|
| 336 |
+
return {"solute": "m0", "site": "s0", "nucleus": "H", "solvent": solvent,
|
| 337 |
+
"experimental": exp, "pcm": pcm, "desmond": des, "desmond_vib": dvib}
|
| 338 |
+
df = pd.DataFrame([row("methanol", 2.0, 0.4, 0.5, 0.1), # check solvent
|
| 339 |
+
row("chloroform", 1.0, 0.1, 0.2, 0.05)]) # reference
|
| 340 |
+
out = D.solvent_pair_differences(df, "methanol", "chloroform", nucleus="H", explicit="desmond")
|
| 341 |
+
assert len(out) == 1
|
| 342 |
+
r = out.iloc[0]
|
| 343 |
+
assert r["exp_diff"] == pytest.approx(1.0 - 2.0) # reference - check
|
| 344 |
+
assert r["implicit_diff"] == pytest.approx(0.4 - 0.1) # check - reference
|
| 345 |
+
assert r["explicit_diff"] == pytest.approx(0.5 - 0.2)
|
| 346 |
+
assert r["explicit_vib_diff"] == pytest.approx((0.5 + 0.1) - (0.2 + 0.05))
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def test_solvent_induced_shifts_stacks_solvents():
|
| 350 |
+
rng = np.random.default_rng(0)
|
| 351 |
+
rows = []
|
| 352 |
+
for solvent in ["chloroform", "methanol", "benzene"]:
|
| 353 |
+
for si in range(3):
|
| 354 |
+
rows.append({"solute": f"m{si}", "site": f"m{si}_s", "nucleus": "H", "solvent": solvent,
|
| 355 |
+
"experimental": rng.normal(), "pcm": rng.normal(), "desmond": rng.normal(),
|
| 356 |
+
"desmond_vib": rng.normal()})
|
| 357 |
+
df = pd.DataFrame(rows)
|
| 358 |
+
out = D.solvent_induced_shifts(df, "chloroform", ["chloroform", "methanol", "benzene"],
|
| 359 |
+
nucleus="H", explicit="desmond")
|
| 360 |
+
# two non-reference solvents x 3 sites = 6 rows, reference column constant
|
| 361 |
+
assert len(out) == 6
|
| 362 |
+
assert set(out["solvent"].unique()) == {"methanol", "benzene"}
|
| 363 |
+
assert (out["reference"] == "chloroform").all()
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def test_fit_differences_to_experimental():
|
| 367 |
+
# exp_diff = -2 * predicted + 0.3 exactly
|
| 368 |
+
pred = np.linspace(-1, 1, 20)
|
| 369 |
+
df = pd.DataFrame({"exp_diff": -2.0 * pred + 0.3, "explicit_diff": pred})
|
| 370 |
+
fit = D.fit_differences_to_experimental(df, "explicit_diff")
|
| 371 |
+
assert fit["slope"] == pytest.approx(-2.0, abs=1e-9)
|
| 372 |
+
assert fit["intercept"] == pytest.approx(0.3, abs=1e-9)
|
| 373 |
+
assert fit["rmse"] == pytest.approx(0.0, abs=1e-9)
|
| 374 |
+
assert fit["n"] == 20
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def test_explicit_correction_pairs_dedups_and_drops_nan():
|
| 378 |
+
rows = []
|
| 379 |
+
for method in ["wp04", "dsd_pbep86"]: # same site tiled across methods
|
| 380 |
+
rows.append({"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
|
| 381 |
+
"sap_nmr_method": method, "desmond": 0.5, "openMM": 0.4})
|
| 382 |
+
rows.append({"solute": "m1", "site": "s1", "nucleus": "H", "solvent": "chloroform",
|
| 383 |
+
"sap_nmr_method": "wp04", "desmond": np.nan, "openMM": 0.2}) # dropped (NaN)
|
| 384 |
+
out = D.explicit_correction_pairs(pd.DataFrame(rows), "chloroform", "H")
|
| 385 |
+
assert len(out) == 1
|
| 386 |
+
assert out.iloc[0]["desmond"] == pytest.approx(0.5)
|
| 387 |
+
assert out.iloc[0]["openMM"] == pytest.approx(0.4)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def test_compare_dft_nn():
|
| 391 |
+
# method-tiled correction; dedup by keys, then error = NN - DFT
|
| 392 |
+
dft = pd.DataFrame([
|
| 393 |
+
{"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": 0.10},
|
| 394 |
+
{"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "mp2", "qcd": 0.10},
|
| 395 |
+
{"solute": "m1", "site": "s1", "nucleus": "C", "sap_nmr_method": "wp04", "qcd": 0.50},
|
| 396 |
+
])
|
| 397 |
+
nn = pd.DataFrame([
|
| 398 |
+
{"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": 0.13},
|
| 399 |
+
{"solute": "m1", "site": "s1", "nucleus": "C", "sap_nmr_method": "MagNET", "qcd": 0.40},
|
| 400 |
+
])
|
| 401 |
+
out = D.compare_dft_nn(dft, nn, "qcd", keys=("solute", "site", "nucleus"))
|
| 402 |
+
assert len(out) == 2
|
| 403 |
+
h = out[out["nucleus"] == "H"].iloc[0]
|
| 404 |
+
assert h["qcd_dft"] == pytest.approx(0.10)
|
| 405 |
+
assert h["qcd_nn"] == pytest.approx(0.13)
|
| 406 |
+
assert h["error"] == pytest.approx(0.13 - 0.10)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def test_qcd_correction_by_site_filters_nucleus_and_sorts_by_solute():
|
| 410 |
+
# three H sites across two solutes (out of alphabetical order) plus one C site that must be
|
| 411 |
+
# dropped; check the nucleus filter, the solute sort, and that the DFT/NN values pass through.
|
| 412 |
+
dft = pd.DataFrame([
|
| 413 |
+
{"solute": "zzz", "site": "s0", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.70},
|
| 414 |
+
{"solute": "AcOH", "site": "s1", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.65},
|
| 415 |
+
{"solute": "AcOH", "site": "s2", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.80},
|
| 416 |
+
{"solute": "AcOH", "site": "s3", "nucleus": "C", "sap_nmr_method": "wp04", "qcd": -4.0},
|
| 417 |
+
])
|
| 418 |
+
nn = pd.DataFrame([
|
| 419 |
+
{"solute": "zzz", "site": "s0", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.72},
|
| 420 |
+
{"solute": "AcOH", "site": "s1", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.64},
|
| 421 |
+
{"solute": "AcOH", "site": "s2", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.79},
|
| 422 |
+
{"solute": "AcOH", "site": "s3", "nucleus": "C", "sap_nmr_method": "MagNET", "qcd": -4.1},
|
| 423 |
+
])
|
| 424 |
+
out = D.qcd_correction_by_site(dft, nn, nucleus="H")
|
| 425 |
+
assert len(out) == 3 # the C row is dropped
|
| 426 |
+
assert (out["nucleus"] == "H").all()
|
| 427 |
+
assert list(out["solute"]) == ["AcOH", "AcOH", "zzz"] # sorted, "zzz" sorts after "AcOH"
|
| 428 |
+
row = out[out["site"] == "s1"].iloc[0]
|
| 429 |
+
assert row["qcd_dft"] == pytest.approx(-0.65)
|
| 430 |
+
assert row["qcd_nn"] == pytest.approx(-0.64)
|
| 431 |
+
assert row["error"] == pytest.approx(-0.64 - (-0.65))
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def test_compare_dft_nn_by_engine_stacks_both_engines():
|
| 435 |
+
# one shared site, a different desmond/openMM value each, so the two engines land in separate
|
| 436 |
+
# rows with a common dft_value/nn_value pair of columns instead of engine-specific column names
|
| 437 |
+
dft = pd.DataFrame([
|
| 438 |
+
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
|
| 439 |
+
"sap_nmr_method": "wp04", "desmond": 0.10, "openMM": 0.20},
|
| 440 |
+
])
|
| 441 |
+
nn = pd.DataFrame([
|
| 442 |
+
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
|
| 443 |
+
"sap_nmr_method": "MagNET", "desmond": 0.13, "openMM": 0.17},
|
| 444 |
+
])
|
| 445 |
+
out = D.compare_dft_nn_by_engine(dft, nn)
|
| 446 |
+
assert len(out) == 2
|
| 447 |
+
assert set(out["engine"]) == {"desmond", "openMM"}
|
| 448 |
+
assert list(out.columns) == ["solute", "site", "nucleus", "solvent", "dft_value", "nn_value",
|
| 449 |
+
"error", "engine"]
|
| 450 |
+
desmond_row = out[out["engine"] == "desmond"].iloc[0]
|
| 451 |
+
assert desmond_row["dft_value"] == pytest.approx(0.10)
|
| 452 |
+
assert desmond_row["nn_value"] == pytest.approx(0.13)
|
| 453 |
+
assert desmond_row["error"] == pytest.approx(0.13 - 0.10)
|
| 454 |
+
openmm_row = out[out["engine"] == "openMM"].iloc[0]
|
| 455 |
+
assert openmm_row["dft_value"] == pytest.approx(0.20)
|
| 456 |
+
assert openmm_row["nn_value"] == pytest.approx(0.17)
|
| 457 |
+
assert openmm_row["error"] == pytest.approx(0.17 - 0.20)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def test_explicit_correction_dft_nn_pairs_merges_sources():
|
| 461 |
+
dft = pd.DataFrame([
|
| 462 |
+
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
|
| 463 |
+
"sap_nmr_method": "wp04", "desmond": 0.5, "openMM": 0.4},
|
| 464 |
+
{"solute": "m1", "site": "s1", "nucleus": "H", "solvent": "chloroform",
|
| 465 |
+
"sap_nmr_method": "wp04", "desmond": -0.2, "openMM": -0.3},
|
| 466 |
+
])
|
| 467 |
+
nn = pd.DataFrame([
|
| 468 |
+
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
|
| 469 |
+
"sap_nmr_method": "MagNET", "desmond": 0.55, "openMM": 0.42},
|
| 470 |
+
# site s1 has no NN row, so it should not appear in the merged (inner-join) result
|
| 471 |
+
])
|
| 472 |
+
out = D.explicit_correction_dft_nn_pairs(dft, nn, "chloroform", "H")
|
| 473 |
+
assert len(out) == 1
|
| 474 |
+
row = out.iloc[0]
|
| 475 |
+
assert row["solute"] == "m0"
|
| 476 |
+
assert row["desmond_dft"] == pytest.approx(0.5)
|
| 477 |
+
assert row["openMM_dft"] == pytest.approx(0.4)
|
| 478 |
+
assert row["desmond_nn"] == pytest.approx(0.55)
|
| 479 |
+
assert row["openMM_nn"] == pytest.approx(0.42)
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def test_si_s13d_fitting_accuracy_selects_formula_by_nucleus_and_engine():
|
| 483 |
+
# stationary + qcd + desmond exactly reproduces experimental for H; stationary + desmond_vib +
|
| 484 |
+
# openMM exactly reproduces it for C. Any other combination of (nucleus, engine) leaves a
|
| 485 |
+
# nonzero residual from the term the formula does NOT include, so a near-zero test RMSE proves
|
| 486 |
+
# si_s13d_fitting_accuracy picked the QCD-based formula for H and the vib-based one for C, and
|
| 487 |
+
# picked the desmond/openMM explicit term matching the "engine" column.
|
| 488 |
+
rng = np.random.default_rng(5)
|
| 489 |
+
solvents = ["chloroform", "methanol", "TIP4P", "benzene"]
|
| 490 |
+
rows = []
|
| 491 |
+
for si in range(14):
|
| 492 |
+
solute = f"m{si:02d}"
|
| 493 |
+
for solvent in solvents:
|
| 494 |
+
for k in range(2):
|
| 495 |
+
stationary = rng.normal(50, 5)
|
| 496 |
+
qcd = rng.normal(0, 1)
|
| 497 |
+
desmond = rng.normal(0, 1)
|
| 498 |
+
desmond_vib = rng.normal(0, 1)
|
| 499 |
+
openMM = rng.normal(0, 1)
|
| 500 |
+
openMM_vib = rng.normal(0, 1)
|
| 501 |
+
rows.append({
|
| 502 |
+
"solute": solute, "solvent": solvent, "site": f"{solute}_{k}",
|
| 503 |
+
"sap_nmr_method": "dsd_pbep86", "sap_basis": "pcSseg3",
|
| 504 |
+
"sap_geometry_type": "pbe0_tz",
|
| 505 |
+
"stationary": stationary, "qcd": qcd, "pcm": 0.0,
|
| 506 |
+
"desmond": desmond, "openMM": openMM,
|
| 507 |
+
"desmond_vib": desmond_vib, "openMM_vib": openMM_vib,
|
| 508 |
+
"nucleus": "H",
|
| 509 |
+
"experimental": stationary + qcd + desmond,
|
| 510 |
+
})
|
| 511 |
+
rows.append({
|
| 512 |
+
"solute": solute, "solvent": solvent, "site": f"{solute}_{k}",
|
| 513 |
+
"sap_nmr_method": "dsd_pbep86", "sap_basis": "pcSseg3",
|
| 514 |
+
"sap_geometry_type": "pbe0_tz",
|
| 515 |
+
"stationary": stationary, "qcd": qcd, "pcm": 0.0,
|
| 516 |
+
"desmond": desmond, "openMM": openMM,
|
| 517 |
+
"desmond_vib": desmond_vib, "openMM_vib": openMM_vib,
|
| 518 |
+
"nucleus": "C",
|
| 519 |
+
"experimental": stationary + openMM_vib + openMM,
|
| 520 |
+
})
|
| 521 |
+
dft = pd.DataFrame(rows)
|
| 522 |
+
nn = dft.copy() # same synthetic values for both sources; the exact-fit check is source-agnostic
|
| 523 |
+
solutes = sorted(dft["solute"].unique())
|
| 524 |
+
|
| 525 |
+
h_results = D.si_s13d_fitting_accuracy(dft, nn, solvents, n_splits=3, solutes=solutes,
|
| 526 |
+
nucleus="H")
|
| 527 |
+
h_desmond = h_results[h_results["engine"] == "desmond"]["test_RMSE"]
|
| 528 |
+
assert (h_desmond < 1e-8).all()
|
| 529 |
+
h_openmm = h_results[h_results["engine"] == "openMM"]["test_RMSE"]
|
| 530 |
+
assert (h_openmm > 0.1).all() # missing the desmond term used to build "experimental"
|
| 531 |
+
|
| 532 |
+
c_results = D.si_s13d_fitting_accuracy(dft, nn, solvents, n_splits=3, solutes=solutes,
|
| 533 |
+
nucleus="C")
|
| 534 |
+
c_openmm = c_results[c_results["engine"] == "openMM"]["test_RMSE"]
|
| 535 |
+
assert (c_openmm < 1e-8).all()
|
| 536 |
+
c_desmond = c_results[c_results["engine"] == "desmond"]["test_RMSE"]
|
| 537 |
+
assert (c_desmond > 0.1).all() # missing the openMM term used to build "experimental"
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def test_pcm_benefit_per_solvent_positive():
|
| 541 |
+
df = make_dft_query_df(seed=3) # experimental = 31 - (stationary + pcm), so PCM genuinely helps
|
| 542 |
+
solutes = sorted(df["solute"].unique())
|
| 543 |
+
benefit = D.pcm_benefit_per_solvent(df, "wp04", "pcSseg2", "aimnet2",
|
| 544 |
+
["chloroform", "methanol", "benzene"], n_splits=4,
|
| 545 |
+
solutes=solutes, nucleus="H", n_test=4)
|
| 546 |
+
assert set(benefit.index) == {"chloroform", "methanol", "benzene"}
|
| 547 |
+
assert (benefit > 0).all() # adding PCM lowers the error in every solvent
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def test_frame_convergence_math():
|
| 551 |
+
# 3 atoms, 5 frames; site = atoms 0 and 2; solvated - isolated = a known per-frame value
|
| 552 |
+
n_frames = 5
|
| 553 |
+
isolated = np.zeros((n_frames, 3))
|
| 554 |
+
solvated = np.zeros((n_frames, 3))
|
| 555 |
+
per_frame_truth = np.array([0.2, 0.4, np.nan, 0.6, 0.8])
|
| 556 |
+
for f in range(n_frames):
|
| 557 |
+
solvated[f, 0] = per_frame_truth[f]
|
| 558 |
+
solvated[f, 2] = per_frame_truth[f]
|
| 559 |
+
solvated[2, :] = np.nan # frame 2 invalid
|
| 560 |
+
perturbed = np.stack([isolated, solvated], axis=-1) # (5, 3, 2)
|
| 561 |
+
fc = D.frame_corrections(perturbed, [0, 2])
|
| 562 |
+
assert fc[0] == pytest.approx(0.2)
|
| 563 |
+
assert np.isnan(fc[2])
|
| 564 |
+
ra = D.running_average(fc)
|
| 565 |
+
# running average over the valid frames 0.2, 0.4, (skip), 0.6, 0.8
|
| 566 |
+
assert ra[0] == pytest.approx(0.2)
|
| 567 |
+
assert ra[1] == pytest.approx(0.3)
|
| 568 |
+
assert ra[2] == pytest.approx(0.3) # invalid frame does not move the average
|
| 569 |
+
assert ra[4] == pytest.approx((0.2 + 0.4 + 0.6 + 0.8) / 4)
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def test_autocorrelation():
|
| 573 |
+
ac = D.autocorrelation(np.array([1.0, -1.0, 1.0, -1.0, 1.0, -1.0]), max_lag=2)
|
| 574 |
+
assert ac[0] == pytest.approx(1.0) # lag 0 is always 1
|
| 575 |
+
assert ac[1] < 0 # alternating -> negative lag-1 autocorrelation
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def test_frame_validity():
|
| 579 |
+
# 4 frames, 2 atoms: frame 0 fully computed, frame 1 only the isolated value, frame 2 all NaN
|
| 580 |
+
# (skipped entirely), frame 3 fully computed. A frame counts as valid if anything was computed.
|
| 581 |
+
perturbed = np.full((4, 2, 2), np.nan)
|
| 582 |
+
perturbed[0] = [[1.0, 2.0], [3.0, 4.0]]
|
| 583 |
+
perturbed[1, 0, 0] = 5.0
|
| 584 |
+
perturbed[3] = [[1.0, 2.0], [3.0, 4.0]]
|
| 585 |
+
valid = D.frame_validity(perturbed)
|
| 586 |
+
assert list(valid) == [True, True, False, True]
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def test_frame_validity_grid_pads_to_the_longest_solvent(monkeypatch):
|
| 590 |
+
# two "solvents" with different trajectory lengths; the grid must pad the shorter one with
|
| 591 |
+
# False past its own frame count, and frame_counts must report each solvent's true length.
|
| 592 |
+
fake_data = {
|
| 593 |
+
"acetone": np.array([[[1.0, 2.0]], [[np.nan, np.nan]], [[1.0, 2.0]]]), # 3 frames, 1 invalid
|
| 594 |
+
"benzene": np.array([[[1.0, 2.0]], [[1.0, 2.0]]]), # 2 frames, all valid
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
def fake_load_perturbed_shieldings(hdf5_filepath, solute, solvent, explicit_solvation_model,
|
| 598 |
+
shield_type):
|
| 599 |
+
return fake_data[solvent]
|
| 600 |
+
|
| 601 |
+
monkeypatch.setattr(D, "load_perturbed_shieldings", fake_load_perturbed_shieldings)
|
| 602 |
+
grid, frame_counts = D.frame_validity_grid("unused.hdf5", "solute", ["acetone", "benzene"],
|
| 603 |
+
"openMM")
|
| 604 |
+
assert grid.shape == (2, 3)
|
| 605 |
+
assert list(frame_counts) == [3, 2]
|
| 606 |
+
assert list(grid[0]) == [True, False, True]
|
| 607 |
+
assert list(grid[1]) == [True, True, False] # padded with False past benzene's 2 frames
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
def test_pareto_frontier():
|
| 611 |
+
points = pd.DataFrame({
|
| 612 |
+
"label": ["a", "b", "c", "d", "e"],
|
| 613 |
+
"total_time": [1.0, 2.0, 3.0, 4.0, 5.0],
|
| 614 |
+
"test_RMSE": [5.0, 3.0, 4.0, 1.0, 2.0],
|
| 615 |
+
})
|
| 616 |
+
front = D.pareto_frontier(points, x="total_time", y="test_RMSE")
|
| 617 |
+
assert list(front["label"]) == ["a", "b", "d"] # c and e are dominated
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def test_correlation_matrix():
|
| 621 |
+
# two solvents whose per-site pcm corrections are perfectly correlated
|
| 622 |
+
base = np.array([0.1, 0.5, -0.3, 0.7, -0.2, 0.4])
|
| 623 |
+
rows = []
|
| 624 |
+
for i, value in enumerate(base):
|
| 625 |
+
rows.append({"solute": f"m{i}", "site": f"s{i}", "solvent": "chloroform", "pcm": value})
|
| 626 |
+
rows.append({"solute": f"m{i}", "site": f"s{i}", "solvent": "benzene", "pcm": 2 * value + 1})
|
| 627 |
+
df = pd.DataFrame(rows)
|
| 628 |
+
corr = D.correlation_matrix(df, "pcm", ["solute", "site"], "solvent")
|
| 629 |
+
assert corr.loc["chloroform", "benzene"] == pytest.approx(1.0, abs=1e-9)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
# --- Figure 2A / S1 (Pareto) ----------------------------------------------------------------
|
| 633 |
+
|
| 634 |
+
def test_pcm_conversion_factors_recovers_known_slope():
|
| 635 |
+
# build DFT rows where the output method's PCM in each solvent is a known multiple of the input
|
| 636 |
+
# method's chloroform PCM, so the recomputed factor must equal that multiple.
|
| 637 |
+
rng = np.random.default_rng(0)
|
| 638 |
+
multiples = {"chloroform": 1.0, "benzene": 0.65, "methanol": 1.4}
|
| 639 |
+
rows = []
|
| 640 |
+
for si in range(8):
|
| 641 |
+
ref = rng.normal(0, 2) # the input method's chloroform PCM for this site
|
| 642 |
+
for solvent, mult in multiples.items():
|
| 643 |
+
common = {"solute": f"m{si}", "site": f"m{si}_s", "nucleus": "H",
|
| 644 |
+
"sap_geometry_type": "aimnet2", "sap_basis": "pcSseg2", "solvent": solvent}
|
| 645 |
+
# input (reference) method: chloroform carries `ref`, other solvents irrelevant
|
| 646 |
+
rows.append({**common, "sap_nmr_method": D.MAGNET_PCM_INPUT_METHOD,
|
| 647 |
+
"pcm": ref if solvent == "chloroform" else rng.normal()})
|
| 648 |
+
# output method: this solvent's PCM is mult * the chloroform reference
|
| 649 |
+
rows.append({**common, "sap_nmr_method": "wp04", "pcm": mult * ref})
|
| 650 |
+
df = pd.DataFrame(rows)
|
| 651 |
+
factors = D.pcm_conversion_factors(df, "H", output_method="wp04")
|
| 652 |
+
for solvent, mult in multiples.items():
|
| 653 |
+
assert factors[solvent] == pytest.approx(mult, abs=1e-9)
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
def test_add_nn_stationary_plus_pcm():
|
| 657 |
+
df = pd.DataFrame({
|
| 658 |
+
"solute": ["m0", "m0"], "site": ["a", "b"], "nucleus": ["H", "C"],
|
| 659 |
+
"solvent": ["benzene", "benzene"], "stationary": [100.0, 50.0], "pcm": [2.0, 4.0],
|
| 660 |
+
})
|
| 661 |
+
factors = {"H": pd.Series({"benzene": 0.5}), "C": pd.Series({"benzene": 3.0})}
|
| 662 |
+
out = D.add_nn_stationary_plus_pcm(df, factors)
|
| 663 |
+
assert out["stationary_plus_pcm"].iloc[0] == pytest.approx(100.0 + 2.0 * 0.5)
|
| 664 |
+
assert out["stationary_plus_pcm"].iloc[1] == pytest.approx(50.0 + 4.0 * 3.0)
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def _pareto_synthetic():
|
| 668 |
+
"""A tiny DFT+NN pair where a 'fast' method is also more accurate, so it must sit on the
|
| 669 |
+
frontier and a 'slow, worse' method must not."""
|
| 670 |
+
rng = np.random.default_rng(1)
|
| 671 |
+
solvents = ["chloroform", "benzene", "TIP4P"]
|
| 672 |
+
dft_rows, nn_rows = [], []
|
| 673 |
+
for si in range(12):
|
| 674 |
+
solute = f"m{si:02d}"
|
| 675 |
+
for solvent in solvents:
|
| 676 |
+
stat = rng.normal(100, 20)
|
| 677 |
+
pcm = rng.normal(0, 2)
|
| 678 |
+
exp = 31.0 - (stat + pcm)
|
| 679 |
+
for method, noise in [("good_fast", 0.02), ("bad_slow", 1.5)]:
|
| 680 |
+
dft_rows.append({
|
| 681 |
+
"solute": solute, "site": f"{solute}_s", "nucleus": "H", "solvent": solvent,
|
| 682 |
+
"sap_nmr_method": method, "sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
|
| 683 |
+
"experimental": exp + rng.normal(0, noise), "stationary": stat, "pcm": pcm})
|
| 684 |
+
nn_rows.append({
|
| 685 |
+
"solute": solute, "site": f"{solute}_s", "nucleus": "H", "solvent": solvent,
|
| 686 |
+
"sap_nmr_method": "MagNET", "sap_basis": "N/A", "sap_geometry_type": "aimnet2",
|
| 687 |
+
"experimental": exp + rng.normal(0, 0.05), "stationary": stat, "pcm": pcm})
|
| 688 |
+
return pd.DataFrame(dft_rows), pd.DataFrame(nn_rows)
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def test_assemble_pareto_fitting_df_and_rmse():
|
| 692 |
+
dft, nn = _pareto_synthetic()
|
| 693 |
+
factors = {"H": pd.Series({s: 1.0 for s in ["chloroform", "benzene", "TIP4P"]}),
|
| 694 |
+
"C": pd.Series(dtype=float)}
|
| 695 |
+
fitting = D.assemble_pareto_fitting_df(dft, nn, conversion_factors=factors, exclude_solutes=())
|
| 696 |
+
# TIP4P renamed to water, all three methods present, single predictor column built
|
| 697 |
+
assert "water" in set(fitting["solvent"]) and "TIP4P" not in set(fitting["solvent"])
|
| 698 |
+
assert set(fitting["nmr_method"]) == {"good_fast", "bad_slow", "MagNET"}
|
| 699 |
+
assert "stationary_plus_pcm" in fitting.columns
|
| 700 |
+
rmse = D.pareto_solvent_averaged_rmse(fitting, n_splits=6, n_test=6)
|
| 701 |
+
assert "solvent-averaged" in set(rmse["solvent"])
|
| 702 |
+
sav = rmse[rmse["solvent"] == "solvent-averaged"].set_index("nmr_method")["fitting_RMSE"]
|
| 703 |
+
assert sav["good_fast"] < sav["bad_slow"] # the accurate method has the lower RMSE
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
def test_attach_pareto_timings_substitutes_magnet():
|
| 707 |
+
rmse = pd.DataFrame({
|
| 708 |
+
"geometry_type": ["aimnet2", "aimnet2"], "nmr_method": ["bad_slow", "MagNET"],
|
| 709 |
+
"basis": ["pcSseg2", "N/A"], "nucleus": ["H", "H"], "solvent": ["solvent-averaged"] * 2,
|
| 710 |
+
"fitting_RMSE": [1.0, 0.2],
|
| 711 |
+
})
|
| 712 |
+
dft_gas = pd.DataFrame({"geometry_time": [10.0], "nmr_time": [990.0], "total_time": [1000.0]},
|
| 713 |
+
index=pd.MultiIndex.from_tuples([("aimnet2", "bad_slow", "pcSseg2")],
|
| 714 |
+
names=["geometry_type", "nmr_method", "basis"]))
|
| 715 |
+
nn = pd.DataFrame({"geometry_time": [5.0], "nmr_time": [15.0], "total_time": [20.0]},
|
| 716 |
+
index=pd.MultiIndex.from_tuples([("aimnet2", "MagNET", "N/A")],
|
| 717 |
+
names=["geometry_type", "nmr_method", "basis"]))
|
| 718 |
+
out = D.attach_pareto_timings(rmse, dft_gas, nn)
|
| 719 |
+
slow = out[out["nmr_method"] == "bad_slow"].iloc[0]
|
| 720 |
+
magnet = out[out["nmr_method"] == "MagNET"].iloc[0]
|
| 721 |
+
assert slow["total_time"] == pytest.approx(1000.0)
|
| 722 |
+
assert magnet["total_time"] == pytest.approx(20.0) # substituted from the NN timings
|
| 723 |
+
# MagNET dominates: faster and more accurate, so it is on the frontier and slow is not
|
| 724 |
+
front = D.pareto_frontier(out, x="total_time", y="fitting_RMSE")
|
| 725 |
+
assert list(front["nmr_method"]) == ["MagNET"]
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
def test_pareto_table_curated_keeps_only_the_documented_rows():
|
| 729 |
+
points = pd.DataFrame([
|
| 730 |
+
# kept: MagNET itself
|
| 731 |
+
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "MagNET", "basis": "N/A", "total_time": 1.0},
|
| 732 |
+
# kept: the one representative aimnet2 row (H's reference method wp04, pcSseg2)
|
| 733 |
+
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg2", "total_time": 2.0},
|
| 734 |
+
# dropped: aimnet2 + wp04 but the WRONG basis
|
| 735 |
+
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg1", "total_time": 3.0},
|
| 736 |
+
# dropped: aimnet2 + pcSseg2 but the WRONG method (not H's reference)
|
| 737 |
+
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "hf", "basis": "pcSseg2", "total_time": 4.0},
|
| 738 |
+
# kept: part of the full pbe0_tz grid, any method/basis
|
| 739 |
+
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 5.0},
|
| 740 |
+
# dropped: right nucleus/geometry, WRONG solvent
|
| 741 |
+
{"nucleus": "H", "solvent": "benzene", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 6.0},
|
| 742 |
+
# dropped: right solvent/geometry, WRONG nucleus
|
| 743 |
+
{"nucleus": "C", "solvent": "chloroform", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 7.0},
|
| 744 |
+
])
|
| 745 |
+
out = D.pareto_table_curated(points, "H", solvent="chloroform")
|
| 746 |
+
assert len(out) == 3
|
| 747 |
+
assert set(out["total_time"]) == {1.0, 2.0, 5.0}
|
| 748 |
+
assert list(out["total_time"]) == sorted(out["total_time"]) # sorted by total_time ascending
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def test_pareto_table_curated_uses_the_carbon_reference_method():
|
| 752 |
+
# C's reference method is wb97xd (MAGNET_PCM_OUTPUT_METHODS), not H's wp04
|
| 753 |
+
points = pd.DataFrame([
|
| 754 |
+
{"nucleus": "C", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg2", "total_time": 1.0},
|
| 755 |
+
{"nucleus": "C", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wb97xd", "basis": "pcSseg2", "total_time": 2.0},
|
| 756 |
+
])
|
| 757 |
+
out = D.pareto_table_curated(points, "C", solvent="chloroform")
|
| 758 |
+
assert list(out["nmr_method"]) == ["wb97xd"]
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
# Published Figure 2A values to reproduce from the released data (from the original
|
| 762 |
+
# fig2a_pareto_results.csv). MagNET sits at the cheap end of a flat frontier.
|
| 763 |
+
FIG2A_MAGNET_SOLVENT_AVERAGED = {"H": 0.1647203288369508, "C": 1.7268703504301826}
|
| 764 |
+
FIG2A_CONVERSION_FACTORS_H_BENZENE = 0.6513139306748533 # from magnet_pcm_conversion_factors_H.csv
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
@pytest.mark.skipif(
|
| 768 |
+
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
|
| 769 |
+
reason="real delta22.hdf5 / experimental xlsx not present")
|
| 770 |
+
def test_fig2a_reproduces_published_magnet_point():
|
| 771 |
+
"""Recompute the MagNET Pareto point and conversion factors from the released data and check
|
| 772 |
+
them against the published Figure 2A numbers (the int32 encoding sets the precision floor)."""
|
| 773 |
+
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
|
| 774 |
+
nn = D.load_query_df_nn(REAL_H5, REAL_XLSX, verbose=False)
|
| 775 |
+
# conversion factors recomputed from the DFT PCM data match the stored CSV value
|
| 776 |
+
cf_h = D.pcm_conversion_factors(dft, "H")
|
| 777 |
+
assert cf_h["benzene"] == pytest.approx(FIG2A_CONVERSION_FACTORS_H_BENZENE, abs=1e-6)
|
| 778 |
+
# build only the MagNET fitting rows and average over solvents (fast; avoids the full DFT sweep)
|
| 779 |
+
factors = {nuc: D.pcm_conversion_factors(dft, nuc) for nuc in ("H", "C")}
|
| 780 |
+
nn_fit = D.add_nn_stationary_plus_pcm(nn, factors).rename(columns=lambda c: c.replace("sap_", ""))
|
| 781 |
+
nn_fit["solvent"] = nn_fit["solvent"].replace("TIP4P", "water")
|
| 782 |
+
nn_fit["experimental"] = pd.to_numeric(nn_fit["experimental"], errors="coerce")
|
| 783 |
+
nn_fit = nn_fit[nn_fit["solute"] != "nitromethane"]
|
| 784 |
+
# the split solute order must match the full combined table (DFT solutes, in file order)
|
| 785 |
+
solutes = D.assemble_pareto_fitting_df(dft, nn, conversion_factors=factors)["solute"].unique().tolist()
|
| 786 |
+
rmse = D.pareto_solvent_averaged_rmse(nn_fit, solutes=solutes)
|
| 787 |
+
sav = rmse[(rmse["nmr_method"] == "MagNET") & (rmse["solvent"] == "solvent-averaged")]
|
| 788 |
+
for nucleus, expected in FIG2A_MAGNET_SOLVENT_AVERAGED.items():
|
| 789 |
+
got = sav[sav["nucleus"] == nucleus]["fitting_RMSE"].iloc[0]
|
| 790 |
+
assert got == pytest.approx(expected, abs=1e-4), f"{nucleus}: {got} vs {expected}"
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
# --- SI Figure S15's "Correlations Between Features" ---------------------------------------------
|
| 794 |
+
|
| 795 |
+
def test_feature_correlation_matrix_synthetic():
|
| 796 |
+
# a and b perfectly correlated, c independent noise uncorrelated with either
|
| 797 |
+
n = 200
|
| 798 |
+
rng = np.random.default_rng(0)
|
| 799 |
+
a = rng.normal(size=n)
|
| 800 |
+
df = pd.DataFrame({
|
| 801 |
+
"nucleus": ["H"] * n, "solvent": ["chloroform"] * n,
|
| 802 |
+
"stationary": a, "pcm": a * 2 + 1, # perfectly correlated with stationary
|
| 803 |
+
"desmond": rng.normal(size=n), "desmond_vib": rng.normal(size=n), "qcd": rng.normal(size=n),
|
| 804 |
+
})
|
| 805 |
+
corr = D.feature_correlation_matrix(df, "H", "chloroform")
|
| 806 |
+
assert corr.loc["stationary", "pcm"] == pytest.approx(1.0, abs=1e-9)
|
| 807 |
+
corr2 = D.feature_correlation_matrix(df, "H", "chloroform", squared=True)
|
| 808 |
+
assert corr2.loc["stationary", "pcm"] == pytest.approx(1.0, abs=1e-9)
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
@pytest.mark.filterwarnings("ignore:Mean of empty slice:RuntimeWarning")
|
| 812 |
+
def test_average_feature_correlation_matrix_averages_across_solvents():
|
| 813 |
+
df = pd.DataFrame({
|
| 814 |
+
"nucleus": ["H"] * 4, "solvent": ["chloroform", "chloroform", "benzene", "benzene"],
|
| 815 |
+
"stationary": [1.0, 2.0, 1.0, 3.0], "pcm": [1.0, 2.0, 3.0, 1.0],
|
| 816 |
+
"desmond": [0.5, 0.5, 0.5, 0.5], "desmond_vib": [0.1, 0.2, 0.1, 0.2], "qcd": [0, 0, 0, 0],
|
| 817 |
+
})
|
| 818 |
+
avg = D.average_feature_correlation_matrix(df, "H", ["chloroform", "benzene"])
|
| 819 |
+
chloroform_only = D.feature_correlation_matrix(df, "H", "chloroform")
|
| 820 |
+
benzene_only = D.feature_correlation_matrix(df, "H", "benzene")
|
| 821 |
+
expected = (chloroform_only.loc["stationary", "pcm"] + benzene_only.loc["stationary", "pcm"]) / 2
|
| 822 |
+
assert avg.loc["stationary", "pcm"] == pytest.approx(expected)
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
@pytest.mark.filterwarnings("ignore:Mean of empty slice:RuntimeWarning")
|
| 826 |
+
def test_average_feature_correlation_matrix_ignores_a_solvent_with_too_few_sites():
|
| 827 |
+
# benzene has only 1 site -> feature_correlation_matrix returns all-NaN for benzene alone
|
| 828 |
+
# (< 2 sites). A plain sum()/len() average would make the WHOLE averaged cell NaN even though
|
| 829 |
+
# chloroform (4 sites) has a perfectly good value; nanmean should use chloroform alone instead.
|
| 830 |
+
df = pd.DataFrame({
|
| 831 |
+
"nucleus": ["H"] * 5,
|
| 832 |
+
"solvent": ["chloroform", "chloroform", "chloroform", "chloroform", "benzene"],
|
| 833 |
+
"stationary": [1.0, 2.0, 3.0, 4.0, 1.0], "pcm": [2.0, 4.0, 6.0, 8.0, 5.0],
|
| 834 |
+
"desmond": [0.5] * 5, "desmond_vib": [0.1] * 5, "qcd": [0.0] * 5,
|
| 835 |
+
})
|
| 836 |
+
avg = D.average_feature_correlation_matrix(df, "H", ["chloroform", "benzene"])
|
| 837 |
+
assert not np.isnan(avg.loc["stationary", "pcm"])
|
| 838 |
+
assert avg.loc["stationary", "pcm"] == pytest.approx(1.0) # chloroform alone: perfectly correlated
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
@pytest.mark.skipif(
|
| 842 |
+
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
|
| 843 |
+
reason="real delta22.hdf5 / experimental xlsx not present")
|
| 844 |
+
def test_si_s15_feature_correlations_reproduces_published_matrices():
|
| 845 |
+
"""Every entry of both nuclei's published Pearson R and R^2 "Correlations Between Features"
|
| 846 |
+
matrices (SI Figure S15), reproduced from the released data to 3 decimal places. Guards against
|
| 847 |
+
pooling all DFT methods/bases/geometries together instead of filtering to the MagNET-Zero
|
| 848 |
+
reference level, which throws entries off by up to 0.03."""
|
| 849 |
+
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
|
| 850 |
+
# R^2 here is the average of each solvent's r^2 (Jensen's inequality: != the square of the
|
| 851 |
+
# averaged r), so it is hardcoded separately from published_r, not derived from it.
|
| 852 |
+
published_r = {
|
| 853 |
+
"H": {("pcm", "stationary"): 0.298, ("desmond", "stationary"): 0.262, ("desmond", "pcm"): 0.399,
|
| 854 |
+
("desmond_vib", "stationary"): -0.170, ("desmond_vib", "pcm"): -0.219, ("desmond_vib", "desmond"): -0.141,
|
| 855 |
+
("qcd", "stationary"): -0.851, ("qcd", "pcm"): -0.362, ("qcd", "desmond"): -0.394, ("qcd", "desmond_vib"): 0.405},
|
| 856 |
+
"C": {("pcm", "stationary"): 0.584, ("desmond", "stationary"): 0.106, ("desmond", "pcm"): 0.667,
|
| 857 |
+
("desmond_vib", "stationary"): 0.531, ("desmond_vib", "pcm"): 0.369, ("desmond_vib", "desmond"): 0.075,
|
| 858 |
+
("qcd", "stationary"): -0.616, ("qcd", "pcm"): -0.635, ("qcd", "desmond"): -0.418, ("qcd", "desmond_vib"): -0.138},
|
| 859 |
+
}
|
| 860 |
+
published_r2 = {
|
| 861 |
+
"H": {("pcm", "stationary"): 0.089, ("desmond", "stationary"): 0.093, ("desmond", "pcm"): 0.386,
|
| 862 |
+
("desmond_vib", "stationary"): 0.032, ("desmond_vib", "pcm"): 0.049, ("desmond_vib", "desmond"): 0.025,
|
| 863 |
+
("qcd", "stationary"): 0.724, ("qcd", "pcm"): 0.131, ("qcd", "desmond"): 0.184, ("qcd", "desmond_vib"): 0.167},
|
| 864 |
+
"C": {("pcm", "stationary"): 0.341, ("desmond", "stationary"): 0.047, ("desmond", "pcm"): 0.463,
|
| 865 |
+
("desmond_vib", "stationary"): 0.284, ("desmond_vib", "pcm"): 0.139, ("desmond_vib", "desmond"): 0.019,
|
| 866 |
+
("qcd", "stationary"): 0.380, ("qcd", "pcm"): 0.404, ("qcd", "desmond"): 0.189, ("qcd", "desmond_vib"): 0.021},
|
| 867 |
+
}
|
| 868 |
+
for nucleus in ("H", "C"):
|
| 869 |
+
corr = D.si_s15_feature_correlations(dft, nucleus)
|
| 870 |
+
for (a, b), expected in published_r[nucleus].items():
|
| 871 |
+
assert corr["r"].loc[a, b] == pytest.approx(expected, abs=5e-4), f"{nucleus} r[{a},{b}]"
|
| 872 |
+
for (a, b), expected in published_r2[nucleus].items():
|
| 873 |
+
assert corr["r2"].loc[a, b] == pytest.approx(expected, abs=5e-4), f"{nucleus} r2[{a},{b}]"
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
@pytest.mark.skipif(
|
| 877 |
+
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
|
| 878 |
+
reason="real delta22.hdf5 / experimental xlsx not present")
|
| 879 |
+
def test_pcm_desmond_correlation_by_solvent_reproduces_published_table():
|
| 880 |
+
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
|
| 881 |
+
table = D.pcm_desmond_correlation_by_solvent(dft)
|
| 882 |
+
published = {("H", "chloroform"): 0.739, ("H", "TIP4P"): 0.728, ("H", "benzene"): -0.454,
|
| 883 |
+
("C", "chloroform"): 0.408, ("C", "TIP4P"): 0.840, ("C", "benzene"): 0.594}
|
| 884 |
+
for (nucleus, solvent), expected in published.items():
|
| 885 |
+
assert table.loc[nucleus, solvent] == pytest.approx(expected, abs=5e-4), f"{nucleus}/{solvent}"
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
@pytest.mark.skipif(
|
| 889 |
+
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
|
| 890 |
+
reason="real delta22.hdf5 / experimental xlsx not present")
|
| 891 |
+
def test_pcm_benefit_reproduces_published_figure_s5_correlations():
|
| 892 |
+
# Figure S5 aggregates the per-split PCM benefit with the mean across splits, not the median;
|
| 893 |
+
# only the mean reproduces the published Pearson R values.
|
| 894 |
+
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
|
| 895 |
+
solutes = sorted(dft["solute"].unique())
|
| 896 |
+
|
| 897 |
+
def pearson_r(benefit, prop, exclude=()):
|
| 898 |
+
s = benefit.drop(index=[x for x in exclude if x in benefit.index])
|
| 899 |
+
x = np.array([prop[k] for k in s.index])
|
| 900 |
+
return float(np.corrcoef(x, s.values)[0, 1])
|
| 901 |
+
|
| 902 |
+
aromatics_tfe = ["benzene", "toluene", "chlorobenzene", "trifluoroethanol"]
|
| 903 |
+
benefit_h = D.pcm_benefit_per_solvent(dft, D.MAGNET_PCM_OUTPUT_METHODS["H"], "pcSseg2", "aimnet2",
|
| 904 |
+
D.DESMOND_SOLVENTS, n_splits=250, solutes=solutes, nucleus="H")
|
| 905 |
+
benefit_c = D.pcm_benefit_per_solvent(dft, D.MAGNET_PCM_OUTPUT_METHODS["C"], "pcSseg2", "aimnet2",
|
| 906 |
+
D.DESMOND_SOLVENTS, n_splits=250, solutes=solutes, nucleus="C")
|
| 907 |
+
published = { # (panel, axis): published Pearson R
|
| 908 |
+
"S5A 1H dielectric": (pearson_r(benefit_h, D.SOLVENT_DIELECTRIC), 0.617),
|
| 909 |
+
"S5A 1H polarizability": (pearson_r(benefit_h, D.SOLVENT_POLARIZABILITY), -0.801),
|
| 910 |
+
"S5B 13C dielectric": (pearson_r(benefit_c, D.SOLVENT_DIELECTRIC), 0.722),
|
| 911 |
+
"S5B 13C polarizability": (pearson_r(benefit_c, D.SOLVENT_POLARIZABILITY), -0.743),
|
| 912 |
+
"S5C 1H dielectric": (pearson_r(benefit_h, D.SOLVENT_DIELECTRIC, aromatics_tfe), 0.884),
|
| 913 |
+
"S5C 1H polarizability": (pearson_r(benefit_h, D.SOLVENT_POLARIZABILITY, aromatics_tfe), -0.587),
|
| 914 |
+
}
|
| 915 |
+
for name, (got, expected) in published.items():
|
| 916 |
+
assert got == pytest.approx(expected, abs=2e-3), f"{name}: {got} != {expected}"
|
analysis/code/test_dft8k_residuals.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for analysis/code/dft8k_residuals.py.
|
| 2 |
+
|
| 3 |
+
The synthetic tests check the residual selection and statistics on a hand-built shieldings dict.
|
| 4 |
+
One opt-in test runs against the real dft8k.hdf5 and asserts the published DFT8K residual numbers
|
| 5 |
+
(MagNET-Zero is a useful surrogate for DFT): 1H RMSE ~0.11 ppm, 13C RMSE ~0.81 ppm, with >95% of
|
| 6 |
+
proton sites below 0.1 ppm error.
|
| 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", "dft8k")) # dft8k_reader
|
| 16 |
+
sys.path.insert(0, HERE)
|
| 17 |
+
import paths
|
| 18 |
+
|
| 19 |
+
import dft8k_residuals as D # noqa: E402
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def make_shieldings():
|
| 23 |
+
"""A flat shieldings dict like the reader's all_shieldings: H, C, C, H, N. MagNET predicts H
|
| 24 |
+
with the WP04 model and C with the wB97X-D model; other atoms are NaN. The 1H residuals are
|
| 25 |
+
-0.1 and +0.2; the 13C residuals are 0.1 and 0.1."""
|
| 26 |
+
return {
|
| 27 |
+
"atomic_numbers": np.array([1, 6, 6, 1, 7]),
|
| 28 |
+
"wp04_pcSseg2": np.array([30.5, 150.5, 151.5, 31.5, 200.5]),
|
| 29 |
+
"wb97xd_pcSseg2": np.array([30.7, 150.7, 151.7, 31.7, 200.7]),
|
| 30 |
+
"nn_wp04_pcSseg2": np.array([30.6, np.nan, np.nan, 31.3, np.nan]),
|
| 31 |
+
"nn_wb97xd_pcSseg2": np.array([np.nan, 150.6, 151.6, np.nan, np.nan]),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_residuals_select_nucleus_and_drop_unpredicted():
|
| 36 |
+
s = make_shieldings()
|
| 37 |
+
h = D.residuals(s, "H")
|
| 38 |
+
# only the two H atoms (indices 0, 3), residual = DFT - MagNET
|
| 39 |
+
np.testing.assert_allclose(sorted(h), sorted([30.5 - 30.6, 31.5 - 31.3]))
|
| 40 |
+
c = D.residuals(s, "C")
|
| 41 |
+
np.testing.assert_allclose(sorted(c), sorted([150.7 - 150.6, 151.7 - 151.6]))
|
| 42 |
+
# the nitrogen atom is in neither nucleus' residuals
|
| 43 |
+
assert len(h) == 2 and len(c) == 2
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_residual_stats():
|
| 47 |
+
errors = np.array([-0.1, 0.2, 0.05, -0.05])
|
| 48 |
+
st = D.residual_stats(errors, small_threshold=0.1)
|
| 49 |
+
assert st["n"] == 4
|
| 50 |
+
assert st["rmse"] == pytest.approx(np.sqrt(np.mean(errors ** 2)))
|
| 51 |
+
assert st["mae"] == pytest.approx(np.mean(np.abs(errors)))
|
| 52 |
+
# two of four are strictly below 0.1 ppm absolute (0.05 and 0.05); 0.1 and 0.2 are not
|
| 53 |
+
assert st["frac_below"] == pytest.approx(0.5)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_residual_stats_empty():
|
| 57 |
+
st = D.residual_stats(np.array([]))
|
| 58 |
+
assert st["n"] == 0 and np.isnan(st["rmse"])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# --- opt-in: reproduce the published DFT8K residual numbers from the released data --------------
|
| 62 |
+
|
| 63 |
+
REAL = paths.dataset_file("dft8k", file=__file__)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
|
| 67 |
+
def test_reproduces_published_dft8k_residuals():
|
| 68 |
+
summary = D.residual_summary(REAL)
|
| 69 |
+
# 1H: RMSE ~0.11 ppm, >95% of sites below 0.1 ppm error (paper Figure 5 / DFT8K text)
|
| 70 |
+
assert summary["H"]["rmse"] == pytest.approx(0.105, abs=0.01)
|
| 71 |
+
assert summary["H"]["mae"] == pytest.approx(0.035, abs=0.01)
|
| 72 |
+
assert summary["H"]["frac_below"] >= 0.95
|
| 73 |
+
# 13C: RMSE ~0.81 ppm, MAE ~0.41 ppm
|
| 74 |
+
assert summary["C"]["rmse"] == pytest.approx(0.807, abs=0.02)
|
| 75 |
+
assert summary["C"]["mae"] == pytest.approx(0.409, abs=0.02)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
|
| 79 |
+
def test_extreme_residual_reproduces_the_published_porphyrin_callout():
|
| 80 |
+
# Figure 5B's positive-tail outlier callout ("Porphyrin Rings", error +17.620 ppm) is the true
|
| 81 |
+
# global maximum 1H residual, so this reproduces it exactly.
|
| 82 |
+
extreme = D.find_extreme_residual(REAL, "H", sign="max")
|
| 83 |
+
assert extreme["residual"] == pytest.approx(17.6197, abs=1e-3)
|
| 84 |
+
assert extreme["molecule_id"] == 20170716
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
|
| 88 |
+
def test_extreme_residual_min_is_not_the_published_zwitterion_callout():
|
| 89 |
+
# The negative-tail callout ("Sulfonium Zwitterion", -1.040 ppm) is NOT the true global
|
| 90 |
+
# minimum -- a different, chemically unremarkable molecule is more negative. This test locks
|
| 91 |
+
# down that documented discrepancy so it is not silently "fixed" by a future data update.
|
| 92 |
+
extreme = D.find_extreme_residual(REAL, "H", sign="min")
|
| 93 |
+
assert extreme["residual"] < -1.5
|
| 94 |
+
assert extreme["molecule_id"] != 88779
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
|
| 98 |
+
def test_molecule_by_id_reproduces_the_published_zwitterion_callout():
|
| 99 |
+
zwitterion = D.molecule_by_id(REAL, "H", molecule_id=88779)
|
| 100 |
+
assert zwitterion["residual"] == pytest.approx(-1.0403, abs=1e-3)
|
| 101 |
+
assert "[S+]" in zwitterion["smiles"] and "[O-]" in zwitterion["smiles"]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
|
| 105 |
+
def test_functional_group_errors_reproduces_published_values():
|
| 106 |
+
# published (Figure 5B, mean |1H residual| per group): Carbonyls 0.034, Amines 0.036,
|
| 107 |
+
# Sulfonyl 0.039, Pyridines 0.042, Furans 0.034, Nitroso 0.088 ppm.
|
| 108 |
+
published = {"Carbonyls": 0.034, "Amines": 0.036, "Sulfonyl": 0.039,
|
| 109 |
+
"Pyridines": 0.042, "Furans": 0.034, "Nitroso": 0.088}
|
| 110 |
+
group_errors = D.functional_group_errors(REAL, "H")
|
| 111 |
+
for name, expected in published.items():
|
| 112 |
+
assert group_errors[name]["mean_abs_error"] == pytest.approx(expected, abs=2e-3), name
|
| 113 |
+
assert group_errors[name]["n_molecules"] > 0
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
|
| 117 |
+
def test_functional_group_errors_respects_an_explicit_empty_patterns_dict():
|
| 118 |
+
# patterns=None means "use the default set"; patterns={} is a real, distinct request for "no
|
| 119 |
+
# groups" and must return {} rather than silently falling back to the default (an `x or
|
| 120 |
+
# DEFAULT` check would wrongly treat an empty dict as falsy and do exactly that).
|
| 121 |
+
assert D.functional_group_errors(REAL, "H", patterns={}) == {}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_find_extreme_zwitterion_residual_requires_a_valid_sign():
|
| 125 |
+
with pytest.raises(ValueError):
|
| 126 |
+
D.find_extreme_zwitterion_residual(REAL, "H", sign="sideways")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
|
| 130 |
+
def test_find_extreme_zwitterion_residual_finds_a_real_sulfonium_zwitterion():
|
| 131 |
+
# not asserted to match the published callout exactly (see molecule_by_id's docstring: the
|
| 132 |
+
# published example is a hand-picked one, not the strict extremum among zwitterions) -- this
|
| 133 |
+
# just checks the search itself works and returns a genuine sulfonium zwitterion.
|
| 134 |
+
result = D.find_extreme_zwitterion_residual(REAL, "H", sign="min")
|
| 135 |
+
assert result is not None
|
| 136 |
+
assert "[S+]" in result["smiles"] and "[O-]" in result["smiles"]
|
| 137 |
+
assert result["residual"] < 0
|
analysis/code/test_leveling.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for the leveling-effect analysis core (leveling.py).
|
| 3 |
+
|
| 4 |
+
Most tests build tiny synthetic inputs in the exact on-disk formats (so the fixtures
|
| 5 |
+
double as a spec of what the loaders expect) and need no large data. Two opt-in tests
|
| 6 |
+
run against the real delta22.hdf5 and the Kaupp NS372 spreadsheet if they are present.
|
| 7 |
+
|
| 8 |
+
Run: pytest test_leveling.py -q
|
| 9 |
+
Requires: pytest, numpy, h5py, pandas, openpyxl, scikit-learn.
|
| 10 |
+
"""
|
| 11 |
+
import os
|
| 12 |
+
import numpy as np
|
| 13 |
+
import h5py
|
| 14 |
+
import openpyxl
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from fixed_point import MISSING_MARKER as _MISSING_MARKER
|
| 18 |
+
from leveling import (
|
| 19 |
+
_decode_fixed_point,
|
| 20 |
+
analyze_nucleus,
|
| 21 |
+
read_nucleus,
|
| 22 |
+
load_delta22,
|
| 23 |
+
load_ns372,
|
| 24 |
+
load_ns372_1h_convergence,
|
| 25 |
+
convergence_statistics,
|
| 26 |
+
DELTA22_REF,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
SCALE = 1e4
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _enc(arr):
|
| 33 |
+
"""Encode floats the way the real file does: int32 = round(value * 1e4), NaN -> marker."""
|
| 34 |
+
a = np.asarray(arr, dtype=np.float64)
|
| 35 |
+
mask = np.isnan(a)
|
| 36 |
+
out = np.zeros(a.shape, dtype=np.int32)
|
| 37 |
+
out[~mask] = np.rint(a[~mask] * SCALE).astype(np.int32)
|
| 38 |
+
out[mask] = _MISSING_MARKER
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# fixed-point decoding
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
def test_decode_round_trip_and_marker():
|
| 46 |
+
values = np.array([0.0, 207.07, -3.5, np.nan, 12345.6789], dtype=np.float64)
|
| 47 |
+
decoded = _decode_fixed_point(_enc(values))
|
| 48 |
+
assert np.isnan(decoded[3])
|
| 49 |
+
np.testing.assert_allclose(decoded[~np.isnan(decoded)],
|
| 50 |
+
values[~np.isnan(values)], atol=1e-4)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_decode_passes_through_float():
|
| 54 |
+
# an older plain-decimal copy must read unchanged (decode is a no-op on floats)
|
| 55 |
+
values = np.array([1.0, 2.5, -7.25], dtype=np.float64)
|
| 56 |
+
np.testing.assert_array_equal(_decode_fixed_point(values), values)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_decode_handles_int64_and_no_marker():
|
| 60 |
+
# a re-encode at int64 must decode like int32 (any integer dtype), and an
|
| 61 |
+
# integer array with no missing marker must round-trip with no spurious blanks
|
| 62 |
+
vals = np.array([10000, 20000, -35000], dtype=np.int64) # = 1.0, 2.0, -3.5
|
| 63 |
+
out = _decode_fixed_point(vals)
|
| 64 |
+
np.testing.assert_allclose(out, [1.0, 2.0, -3.5])
|
| 65 |
+
assert not np.isnan(out).any()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# leveling analysis on a synthetic, perfectly-leveled matrix
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
def test_analyze_nucleus_detects_leveling():
|
| 72 |
+
# build methods that are affine transforms of one base signal plus tiny noise:
|
| 73 |
+
# after scaling they are interchangeable, so PC1 should dominate and every
|
| 74 |
+
# pairwise correlation should be near 1.
|
| 75 |
+
rng = np.random.RandomState(0)
|
| 76 |
+
base = rng.normal(size=60)
|
| 77 |
+
slopes = np.linspace(0.8, 1.3, 8)
|
| 78 |
+
offsets = np.linspace(-5, 5, 8)
|
| 79 |
+
cols = [a * base + b + 0.01 * rng.normal(size=60)
|
| 80 |
+
for a, b in zip(slopes, offsets)]
|
| 81 |
+
M = np.column_stack(cols)
|
| 82 |
+
methods = [f"m{i}" for i in range(8)]
|
| 83 |
+
res = analyze_nucleus(M, methods, ref=M[:, 0])
|
| 84 |
+
assert res['ev'][0] > 0.99 # PC1 carries essentially all variance
|
| 85 |
+
assert res['r_min'] > 0.99 # worst pairwise correlation still near 1
|
| 86 |
+
assert set(res['scaled_rmse']) == set(methods)
|
| 87 |
+
assert 'r2' in res['par'] and 'theta' in res['par']
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
# delta22 loader against a synthetic int32 HDF5 in the real layout
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
DELTA22_FUNCS = ['hf', 'blyp_d3bj', 'bp86_d3bj', 'b97d3_d3bj', 'tpsstpss_d3bj',
|
| 94 |
+
'b3lyp_d3bj', 'pbe0_d3bj', 'm062x_d3', 'wb97xd', 'wp04', 'wc04',
|
| 95 |
+
'B2PLYP', 'mPW2PLYP', 'B2GP_PLYP', 'dsd_pbep86', 'revdsd_pbep86',
|
| 96 |
+
'mp2', 'dlpno_mp2']
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _make_synthetic_delta22(path):
|
| 100 |
+
# one gas method label per functional. most sit at pcSseg3, but mp2 is only at
|
| 101 |
+
# pcSseg2 and hf only at pcSseg1, so the loader's largest-basis fallback ladder
|
| 102 |
+
# (pcSseg3 then pcSseg2 then pcSseg1) is exercised.
|
| 103 |
+
basis_for = {f: "pcSseg3" for f in DELTA22_FUNCS}
|
| 104 |
+
basis_for["mp2"] = "pcSseg2"
|
| 105 |
+
basis_for["hf"] = "pcSseg1"
|
| 106 |
+
names = [f"{f},{basis_for[f]},gas,none" for f in DELTA22_FUNCS]
|
| 107 |
+
n_methods = len(names)
|
| 108 |
+
with h5py.File(path, "w") as f:
|
| 109 |
+
f.create_dataset("conventional_nmr_method_names",
|
| 110 |
+
data=np.array(names, dtype=h5py.string_dtype()))
|
| 111 |
+
# one solute, atoms H, C, H, C, N (N is ignored: not 1H/13C)
|
| 112 |
+
z = np.array([1, 6, 1, 6, 7], np.uint8)
|
| 113 |
+
g = f.create_group("solutes/mol1")
|
| 114 |
+
g.create_dataset("atomic_numbers", data=z)
|
| 115 |
+
# conventional_shieldings[method, geometry, atom]; geometry 1 = PBE0/cc-pVTZ
|
| 116 |
+
base = np.array([30.0, 150.0, 31.0, 151.0, 200.0])
|
| 117 |
+
cs = np.zeros((n_methods, 2, len(z)))
|
| 118 |
+
for mi in range(n_methods):
|
| 119 |
+
cs[mi, 0] = base + 0.5 * mi # aimnet2 geometry (unused)
|
| 120 |
+
cs[mi, 1] = base + 0.01 * mi # pbe0_tz geometry (used)
|
| 121 |
+
g.create_dataset("stationary_and_pcm/conventional_shieldings", data=_enc(cs))
|
| 122 |
+
return base
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_load_delta22_synthetic(tmp_path):
|
| 126 |
+
p = tmp_path / "delta22.hdf5"
|
| 127 |
+
base = _make_synthetic_delta22(str(p))
|
| 128 |
+
out = load_delta22(str(p))
|
| 129 |
+
assert set(out) == {"1H", "13C"}
|
| 130 |
+
# 1 solute has two H sites (atoms 0,2) and two C sites (atoms 1,3)
|
| 131 |
+
assert out["1H"]["M"].shape == (2, len(DELTA22_FUNCS))
|
| 132 |
+
assert out["13C"]["M"].shape == (2, len(DELTA22_FUNCS))
|
| 133 |
+
assert DELTA22_REF in out["1H"]["methods"]
|
| 134 |
+
# every functional was selected via the basis-fallback ladder, so no method is missing
|
| 135 |
+
assert not np.isnan(out["1H"]["M"]).any()
|
| 136 |
+
assert not np.isnan(out["13C"]["M"]).any()
|
| 137 |
+
# decoded values must match what we wrote on the pbe0_tz geometry; pick the
|
| 138 |
+
# DSD-PBEP86 method column for the first H site (atom 0)
|
| 139 |
+
ref_method_index = DELTA22_FUNCS.index("dsd_pbep86")
|
| 140 |
+
expected = base[0] + 0.01 * ref_method_index
|
| 141 |
+
col = out["1H"]["methods"].index(DELTA22_REF)
|
| 142 |
+
np.testing.assert_allclose(out["1H"]["M"][0, col], expected, atol=1e-4)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
# NS372 sheet parsing against a synthetic Kaupp-style worksheet
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
def _make_synthetic_kaupp_sheet(path):
|
| 149 |
+
wb = openpyxl.Workbook()
|
| 150 |
+
ws = wb.active
|
| 151 |
+
ws.title = "S6 - 1H Shieldings"
|
| 152 |
+
# row 1: functional headers from column 7; row 2: tau sub-labels
|
| 153 |
+
# two methods: SVWN (no tau) and TPSS (tau = tauC), matching leveling.METHODS keys
|
| 154 |
+
ws.cell(1, 7, "SVWN")
|
| 155 |
+
ws.cell(1, 8, "TPSS"); ws.cell(2, 8, "τC")
|
| 156 |
+
# data rows from row 4: col1 nucleus (sticky), col2 molecule, col4 CCSD(T) ref
|
| 157 |
+
rows = [("1H", "molA", 30.0, 30.1, 30.2),
|
| 158 |
+
("1H", "molB", 28.0, 28.2, 28.1),
|
| 159 |
+
("1H", "molC", 25.0, 25.3, 25.1)]
|
| 160 |
+
for i, (nuc, mol, ref, svwn, tpss) in enumerate(rows):
|
| 161 |
+
r = 4 + i
|
| 162 |
+
ws.cell(r, 1, nuc)
|
| 163 |
+
ws.cell(r, 2, mol)
|
| 164 |
+
ws.cell(r, 4, ref)
|
| 165 |
+
ws.cell(r, 7, svwn)
|
| 166 |
+
ws.cell(r, 8, tpss)
|
| 167 |
+
wb.save(path)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_read_nucleus_synthetic(tmp_path):
|
| 171 |
+
p = tmp_path / "kaupp.xlsx"
|
| 172 |
+
_make_synthetic_kaupp_sheet(str(p))
|
| 173 |
+
wb = openpyxl.load_workbook(str(p), data_only=True)
|
| 174 |
+
ref, data = read_nucleus(wb, "S6 - 1H Shieldings")
|
| 175 |
+
np.testing.assert_allclose(ref, [30.0, 28.0, 25.0])
|
| 176 |
+
assert ("SVWN", None) in data
|
| 177 |
+
assert ("TPSS", "τC") in data
|
| 178 |
+
np.testing.assert_allclose(data[("SVWN", None)], [30.1, 28.2, 25.3])
|
| 179 |
+
np.testing.assert_allclose(data[("TPSS", "τC")], [30.2, 28.1, 25.1])
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
# opt-in tests against the real files (skipped when absent, e.g. in CI)
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
def _find(*candidates):
|
| 186 |
+
for c in candidates:
|
| 187 |
+
if c and os.path.exists(c):
|
| 188 |
+
return c
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 192 |
+
import paths
|
| 193 |
+
_REAL_DELTA22 = _find(
|
| 194 |
+
paths.dataset_file("delta22", file=__file__), # deposit copy, then in-repo data/delta22/
|
| 195 |
+
os.path.join(_HERE, "delta22.hdf5"),
|
| 196 |
+
os.path.join(_HERE, "delta22", "delta22.hdf5"),
|
| 197 |
+
)
|
| 198 |
+
_REAL_KAUPP = _find(
|
| 199 |
+
os.path.join(_HERE, "..", "..", "data", "ns372", "ct1c00919_si_002.xlsx"),
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@pytest.mark.skipif(_REAL_DELTA22 is None, reason="real delta22.hdf5 not present")
|
| 204 |
+
def test_real_delta22_reproduces_leveling():
|
| 205 |
+
out = load_delta22(_REAL_DELTA22)
|
| 206 |
+
assert out["1H"]["M"].shape == (145, 18)
|
| 207 |
+
assert out["13C"]["M"].shape == (76, 18)
|
| 208 |
+
res = analyze_nucleus(out["1H"]["M"], out["1H"]["methods"], out["1H"]["ref"])
|
| 209 |
+
assert res['ev'][0] > 0.99 # PC1 dominance on the real proton data
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@pytest.mark.skipif(_REAL_KAUPP is None, reason="real Kaupp NS372 spreadsheet not present")
|
| 213 |
+
def test_real_ns372_loads_all_nuclei():
|
| 214 |
+
out = load_ns372(_REAL_KAUPP)
|
| 215 |
+
assert set(out) == {"1H", "11B", "13C", "15N", "17O", "19F", "31P", "33S"}
|
| 216 |
+
res = analyze_nucleus(out["13C"]["M"], out["13C"]["methods"], out["13C"]["ref"])
|
| 217 |
+
assert res['ev'][0] > 0.99
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@pytest.mark.skipif(_REAL_KAUPP is None, reason="real Kaupp NS372 spreadsheet not present")
|
| 221 |
+
def test_real_ns372_1h_convergence_stats():
|
| 222 |
+
# Figure 2D input: the 1H slice reduces to 44 methods vs the CCSD(T) reference,
|
| 223 |
+
# and each method's slope/scaled RMSE lands in the expected physical range.
|
| 224 |
+
cc = load_ns372_1h_convergence(_REAL_KAUPP)
|
| 225 |
+
assert cc.shape == (124, 45) # 44 methods + CCSD(T)
|
| 226 |
+
assert "CCSD(T)" in cc.columns and "HF" in cc.columns # 'Hartree-Fock' header renamed
|
| 227 |
+
res = convergence_statistics(cc).set_index("Method")
|
| 228 |
+
assert len(res) == 44
|
| 229 |
+
assert (res["Slope"].between(0.9, 1.15)).all() # near the y=x limit
|
| 230 |
+
assert (res["RMSE (scaled)"] > 0).all()
|
| 231 |
+
# the double hybrid sits closest to the electronic-structure limit
|
| 232 |
+
assert res["RMSE (scaled)"].idxmin() == "DSD-PBEP86"
|
| 233 |
+
assert res.loc["DSD-PBEP86", "RMSE (scaled)"] < 0.1
|
analysis/si_figures/si_figure_s03_leveling.ipynb
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "b61775ea",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# SI Figure S3: inter-method correlation and PCA of NS372 and delta-22 shieldings\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Inter-method correlation matrices and PC1/PC2 loadings for NS372 (44 functionals x 8 nuclei) and\n",
|
| 11 |
+
"delta22 (18 functionals, ¹H/¹³C), plus a combined summary table."
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"cell_type": "markdown",
|
| 16 |
+
"id": "aeb46df4",
|
| 17 |
+
"metadata": {},
|
| 18 |
+
"source": [
|
| 19 |
+
"Two independent NMR shielding datasets, analyzed separately (never pooled):\n",
|
| 20 |
+
"\n",
|
| 21 |
+
"| Dataset | Source | Methods | Nuclei | Reference |\n",
|
| 22 |
+
"|---|---|---|---|---|\n",
|
| 23 |
+
"| **NS372** | Schattenberg & Kaupp, *JCTC* **17**, 7602 (2021) | 44 DFT/WFT functionals | ¹H ¹¹B ¹³C ¹⁵N ¹⁷O ¹⁹F ³¹P ³³S | CCSD(T)/pcSseg-3 |\n",
|
| 24 |
+
"| **delta22** | in-house `delta22.hdf5` | 18 gas-phase functionals, largest basis (pcSseg-3; mp2 → pcSseg-2), PBE0/cc-pVTZ geometry | ¹H ¹³C | DSD-PBEP86 (highest-rung in-set method, stands in for CCSD(T)) |"
|
| 25 |
+
]
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"cell_type": "markdown",
|
| 29 |
+
"id": "d0ce838b",
|
| 30 |
+
"metadata": {},
|
| 31 |
+
"source": [
|
| 32 |
+
"## 1. Configuration"
|
| 33 |
+
]
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"cell_type": "code",
|
| 37 |
+
"execution_count": null,
|
| 38 |
+
"id": "62157a75",
|
| 39 |
+
"metadata": {},
|
| 40 |
+
"outputs": [],
|
| 41 |
+
"source": [
|
| 42 |
+
"import os, sys\n",
|
| 43 |
+
"\n",
|
| 44 |
+
"REPO = os.path.abspath(\"../..\")\n",
|
| 45 |
+
"for _p in (\"analysis/code\", \"analysis/code/shared\"):\n",
|
| 46 |
+
" sys.path.insert(0, os.path.join(REPO, _p))"
|
| 47 |
+
]
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"cell_type": "code",
|
| 51 |
+
"execution_count": null,
|
| 52 |
+
"id": "8ea46824",
|
| 53 |
+
"metadata": {},
|
| 54 |
+
"outputs": [],
|
| 55 |
+
"source": [
|
| 56 |
+
"import glob\n",
|
| 57 |
+
"import pandas as pd\n",
|
| 58 |
+
"import matplotlib.pyplot as plt\n",
|
| 59 |
+
"\n",
|
| 60 |
+
"import paths\n",
|
| 61 |
+
"import leveling\n",
|
| 62 |
+
"import leveling_plots"
|
| 63 |
+
]
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"cell_type": "code",
|
| 67 |
+
"execution_count": null,
|
| 68 |
+
"id": "34796b33",
|
| 69 |
+
"metadata": {},
|
| 70 |
+
"outputs": [],
|
| 71 |
+
"source": [
|
| 72 |
+
"# inputs:\n",
|
| 73 |
+
"# - the Kaupp NS372 spreadsheet is small and ships in the repo's data/ns372/ folder\n",
|
| 74 |
+
"# - delta22.hdf5 is large: it resolves from the repo's data/delta22/ folder, carried by the\n",
|
| 75 |
+
"# Hugging Face checkout via Git LFS (see analysis/code/paths.py)\n",
|
| 76 |
+
"# the delta22 file is encoded as whole numbers (real value x 10,000); the loader\n",
|
| 77 |
+
"# in leveling.py decodes it on read (a plain-decimal copy also works, since the\n",
|
| 78 |
+
"# decode step is a no-op on floating-point data).\n",
|
| 79 |
+
"KAUPP_XLSX = os.path.join(REPO, \"data\", \"ns372\", \"ct1c00919_si_002.xlsx\")\n",
|
| 80 |
+
"DELTA22_H5 = paths.dataset_file(\"delta22\", root=REPO)\n",
|
| 81 |
+
"SAVE_DPI = 200 # SI-quality raster output\n",
|
| 82 |
+
"\n",
|
| 83 |
+
"def figure_path(name):\n",
|
| 84 |
+
" os.makedirs(\"figures\", exist_ok=True)\n",
|
| 85 |
+
" return os.path.join(\"figures\", name)\n",
|
| 86 |
+
"\n",
|
| 87 |
+
"# self-clean: this notebook builds PNG names dynamically (one per nucleus), so drop any\n",
|
| 88 |
+
"# previously written figures before regenerating (glob on a missing folder returns [])\n",
|
| 89 |
+
"for _stale in glob.glob(os.path.join(\"figures\", \"*.png\")):\n",
|
| 90 |
+
" os.remove(_stale)\n",
|
| 91 |
+
"\n",
|
| 92 |
+
"pd.set_option('display.width', 150)\n",
|
| 93 |
+
"pd.set_option('display.max_columns', 25)"
|
| 94 |
+
]
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"cell_type": "code",
|
| 98 |
+
"execution_count": null,
|
| 99 |
+
"id": "a402f2fc",
|
| 100 |
+
"metadata": {},
|
| 101 |
+
"outputs": [],
|
| 102 |
+
"source": [
|
| 103 |
+
"# functional-family colours used for every figure (family ordering lives in leveling.py)\n",
|
| 104 |
+
"FAMILY_COLORS = {'LDA':'#777777','GGA':'#1f77b4','mGGA':'#17becf','GH':'#2ca02c',\n",
|
| 105 |
+
" 'RSH':'#9467bd','LH':'#8c564b','DH':'#ff7f0e','WFT':'#d62728',\n",
|
| 106 |
+
" 'ref':'#FF1493'}\n",
|
| 107 |
+
"\n",
|
| 108 |
+
"# proper Unicode-superscript labels for nuclei in figure titles\n",
|
| 109 |
+
"NUC_DISPLAY = {'1H':'¹H','11B':'¹¹B','13C':'¹³C',\n",
|
| 110 |
+
" '15N':'¹⁵N','17O':'¹⁷O','19F':'¹⁹F',\n",
|
| 111 |
+
" '31P':'³¹P','33S':'³³S'}\n",
|
| 112 |
+
"\n",
|
| 113 |
+
"# adjustText parameters tuned per nucleus -- 1H and 13C have very dense central\n",
|
| 114 |
+
"# clusters and need stronger expansion; the paramagnetic-shielding nuclei\n",
|
| 115 |
+
"# (15N/17O/19F) already spread methods along the parabola and need a gentler\n",
|
| 116 |
+
"# pass to avoid over-flinging labels.\n",
|
| 117 |
+
"PCA_ADJUST_DEFAULT = dict(\n",
|
| 118 |
+
" force_text=(0.35, 0.55), force_explode=(0.25, 0.40),\n",
|
| 119 |
+
" force_static=(0.10, 0.15), force_pull=(0.02, 0.02),\n",
|
| 120 |
+
" expand=(1.25, 1.35), time_lim=4,\n",
|
| 121 |
+
")\n",
|
| 122 |
+
"PCA_ADJUST = {\n",
|
| 123 |
+
" '1H': {**PCA_ADJUST_DEFAULT, 'force_text':(0.75, 1.05),\n",
|
| 124 |
+
" 'force_explode':(0.65, 0.90), 'expand':(1.7, 1.9), 'time_lim':7},\n",
|
| 125 |
+
" '11B': {**PCA_ADJUST_DEFAULT, 'force_text':(0.50, 0.75),\n",
|
| 126 |
+
" 'force_explode':(0.40, 0.60), 'expand':(1.4, 1.55), 'time_lim':5},\n",
|
| 127 |
+
" '13C': {**PCA_ADJUST_DEFAULT, 'force_text':(0.70, 1.00),\n",
|
| 128 |
+
" 'force_explode':(0.60, 0.85), 'expand':(1.6, 1.8), 'time_lim':6},\n",
|
| 129 |
+
" '15N': {**PCA_ADJUST_DEFAULT, 'force_text':(0.60, 0.85),\n",
|
| 130 |
+
" 'force_explode':(0.50, 0.70), 'expand':(1.5, 1.65), 'time_lim':6},\n",
|
| 131 |
+
" '17O': {**PCA_ADJUST_DEFAULT, 'force_text':(0.60, 0.85),\n",
|
| 132 |
+
" 'force_explode':(0.50, 0.70), 'expand':(1.5, 1.65), 'time_lim':6},\n",
|
| 133 |
+
" '31P': {**PCA_ADJUST_DEFAULT, 'force_text':(0.65, 0.90),\n",
|
| 134 |
+
" 'force_explode':(0.55, 0.75), 'expand':(1.55, 1.7), 'time_lim':6},\n",
|
| 135 |
+
" '33S': {**PCA_ADJUST_DEFAULT, 'force_text':(0.65, 0.90),\n",
|
| 136 |
+
" 'force_explode':(0.55, 0.75), 'expand':(1.55, 1.7), 'time_lim':6},\n",
|
| 137 |
+
"}"
|
| 138 |
+
]
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
"cell_type": "markdown",
|
| 142 |
+
"id": "52b5cdac",
|
| 143 |
+
"metadata": {},
|
| 144 |
+
"source": [
|
| 145 |
+
"## 2. NS372 (Kaupp) - definitions\n",
|
| 146 |
+
"\n",
|
| 147 |
+
"Conventional GIAO shieldings for 44 functionals across 8 main-group nuclei, with a\n",
|
| 148 |
+
"CCSD(T)/pcSseg-3 reference. Kaupp Reduced-Set exclusions (F₃⁻, O₃, BH - multireference\n",
|
| 149 |
+
"outliers) are applied. Input: `ct1c00919_si_002.xlsx`."
|
| 150 |
+
]
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"cell_type": "markdown",
|
| 154 |
+
"id": "f87c7141",
|
| 155 |
+
"metadata": {},
|
| 156 |
+
"source": [
|
| 157 |
+
"## 3. delta22 - definitions\n",
|
| 158 |
+
"\n",
|
| 159 |
+
"Gas-phase conventional GIAO shieldings from `delta22.hdf5`: 18 functionals at their\n",
|
| 160 |
+
"largest available basis (pcSseg-3; plain `mp2` only to pcSseg-2), at the PBE0/cc-pVTZ\n",
|
| 161 |
+
"geometry. Observations are pooled ¹H / ¹³C atom sites across all 22 solutes. There is no\n",
|
| 162 |
+
"CCSD(T) reference in the file; **DSD-PBEP86 is used as the reference** for delta22 - it\n",
|
| 163 |
+
"is the highest-rung double-hybrid available in this method set and stands in for CCSD(T)\n",
|
| 164 |
+
"in the same role. The stored whole-number shieldings are decoded on read."
|
| 165 |
+
]
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"cell_type": "markdown",
|
| 169 |
+
"id": "be87913f",
|
| 170 |
+
"metadata": {},
|
| 171 |
+
"source": [
|
| 172 |
+
"## 4. Load datasets + global colour scale\n",
|
| 173 |
+
"\n",
|
| 174 |
+
"Run the loaders, analyse every nucleus, and compute the figure-wide\n",
|
| 175 |
+
"`-log10(1-|r|)` maximum (`GLOBAL_VMAX`) used as the colour scale on every correlation matrix\n",
|
| 176 |
+
"below, for NS372 and delta22 alike."
|
| 177 |
+
]
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"cell_type": "code",
|
| 181 |
+
"execution_count": null,
|
| 182 |
+
"id": "d0a24460",
|
| 183 |
+
"metadata": {},
|
| 184 |
+
"outputs": [],
|
| 185 |
+
"source": [
|
| 186 |
+
"ns372 = leveling.load_ns372(KAUPP_XLSX)\n",
|
| 187 |
+
"delta22 = leveling.load_delta22(DELTA22_H5)\n",
|
| 188 |
+
"\n",
|
| 189 |
+
"res_ns372 = {nuc: leveling.analyze_nucleus(d['M'], d['methods'], d['ref'])\n",
|
| 190 |
+
" for nuc, d in ns372.items()}\n",
|
| 191 |
+
"res_delta22 = {nuc: leveling.analyze_nucleus(d['M'], d['methods'], d['ref'])\n",
|
| 192 |
+
" for nuc, d in delta22.items()}\n",
|
| 193 |
+
"\n",
|
| 194 |
+
"GLOBAL_VMAX = max(leveling.dataset_logr_max(res_ns372), leveling.dataset_logr_max(res_delta22))\n",
|
| 195 |
+
"print(f'NS372 : {len(ns372)} nuclei')\n",
|
| 196 |
+
"print(f'delta22: {len(delta22)} nuclei (reference = {leveling.DELTA22_REF})')\n",
|
| 197 |
+
"print(f'global colour scale: 0 -> {GLOBAL_VMAX} on the -log10(1-|r|) axis')\n",
|
| 198 |
+
"for nuc, d in ns372.items():\n",
|
| 199 |
+
" print(f' NS372 {nuc:4s}: {d[\"M\"].shape[0]:4d} mols x {d[\"M\"].shape[1]-1} methods + CCSD(T)')\n",
|
| 200 |
+
"for nuc, d in delta22.items():\n",
|
| 201 |
+
" print(f' delta22 {nuc:4s}: {d[\"M\"].shape[0]:4d} sites x {d[\"M\"].shape[1]} methods')"
|
| 202 |
+
]
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"cell_type": "markdown",
|
| 206 |
+
"id": "0d07150e",
|
| 207 |
+
"metadata": {},
|
| 208 |
+
"source": [
|
| 209 |
+
"## 5. NS372 results"
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"cell_type": "markdown",
|
| 214 |
+
"id": "747989ff",
|
| 215 |
+
"metadata": {},
|
| 216 |
+
"source": [
|
| 217 |
+
"### 5.1 Leveling diagnostics - NS372"
|
| 218 |
+
]
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"cell_type": "code",
|
| 222 |
+
"execution_count": null,
|
| 223 |
+
"id": "52a133ee",
|
| 224 |
+
"metadata": {},
|
| 225 |
+
"outputs": [],
|
| 226 |
+
"source": [
|
| 227 |
+
"sum_ns372 = leveling.summarize(ns372, res_ns372)\n",
|
| 228 |
+
"print('NS372 - leveling diagnostics (CCSD(T) included as a method column):')\n",
|
| 229 |
+
"sum_ns372.round(5)"
|
| 230 |
+
]
|
| 231 |
+
},
|
| 232 |
+
{
|
| 233 |
+
"cell_type": "markdown",
|
| 234 |
+
"id": "a385e6f5",
|
| 235 |
+
"metadata": {},
|
| 236 |
+
"source": [
|
| 237 |
+
"### 5.2 Per-method scaled RMSE vs CCSD(T)\n",
|
| 238 |
+
"\n",
|
| 239 |
+
"`scaled_rmse` = RMSE of residuals after a per-method linear fit\n",
|
| 240 |
+
"`sigma_method ~ a*sigma_CCSD(T) + b` - the error that survives empirical linear scaling."
|
| 241 |
+
]
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"cell_type": "code",
|
| 245 |
+
"execution_count": null,
|
| 246 |
+
"id": "319abc43",
|
| 247 |
+
"metadata": {},
|
| 248 |
+
"outputs": [],
|
| 249 |
+
"source": [
|
| 250 |
+
"rmse_ns372 = pd.DataFrame({nuc: res_ns372[nuc]['scaled_rmse'] for nuc in res_ns372})\n",
|
| 251 |
+
"rmse_ns372 = rmse_ns372.drop(index='CCSD(T)', errors='ignore')\n",
|
| 252 |
+
"rmse_ns372 = rmse_ns372.reindex(ns372['1H']['methods'][:-1]) # family order\n",
|
| 253 |
+
"print('NS372 - per-method scaled RMSE vs CCSD(T) (ppm):')\n",
|
| 254 |
+
"rmse_ns372.round(3)"
|
| 255 |
+
]
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"cell_type": "markdown",
|
| 259 |
+
"id": "59cb8672",
|
| 260 |
+
"metadata": {},
|
| 261 |
+
"source": [
|
| 262 |
+
"### 5.3 Inter-method correlation matrices - NS372 (one nucleus per file)"
|
| 263 |
+
]
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"cell_type": "code",
|
| 267 |
+
"execution_count": null,
|
| 268 |
+
"id": "73c90f1e",
|
| 269 |
+
"metadata": {},
|
| 270 |
+
"outputs": [],
|
| 271 |
+
"source": [
|
| 272 |
+
"NS372_NUCS = ['1H', '11B', '13C', '15N', '17O', '19F', '31P', '33S']\n",
|
| 273 |
+
"for nuc in NS372_NUCS:\n",
|
| 274 |
+
" fams = {nuc: ns372[nuc]['families']}\n",
|
| 275 |
+
" fig = leveling_plots.plot_corr_matrix('NS372', [nuc], res_ns372, fams,\n",
|
| 276 |
+
" GLOBAL_VMAX, ref_name='CCSD(T)',\n",
|
| 277 |
+
" nuc_display=NUC_DISPLAY)\n",
|
| 278 |
+
" fig.savefig(figure_path(f'si_figure_s03_corr_{nuc}.png'),\n",
|
| 279 |
+
" dpi=SAVE_DPI, bbox_inches='tight')\n",
|
| 280 |
+
" plt.show()"
|
| 281 |
+
]
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"cell_type": "markdown",
|
| 285 |
+
"id": "33da4dd0",
|
| 286 |
+
"metadata": {},
|
| 287 |
+
"source": [
|
| 288 |
+
"### 5.4 PC1/PC2 structure - NS372 (one nucleus per file)"
|
| 289 |
+
]
|
| 290 |
+
},
|
| 291 |
+
{
|
| 292 |
+
"cell_type": "code",
|
| 293 |
+
"execution_count": null,
|
| 294 |
+
"id": "034bf193",
|
| 295 |
+
"metadata": {},
|
| 296 |
+
"outputs": [],
|
| 297 |
+
"source": [
|
| 298 |
+
"for nuc in NS372_NUCS:\n",
|
| 299 |
+
" fams = {nuc: ns372[nuc]['families']}\n",
|
| 300 |
+
" fig = leveling_plots.plot_pca_pair('NS372', [nuc], res_ns372, fams,\n",
|
| 301 |
+
" family_colors=FAMILY_COLORS, nuc_display=NUC_DISPLAY,\n",
|
| 302 |
+
" pca_adjust=PCA_ADJUST, pca_adjust_default=PCA_ADJUST_DEFAULT)\n",
|
| 303 |
+
" fig.savefig(figure_path(f'si_figure_s03_pca_{nuc}.png'),\n",
|
| 304 |
+
" dpi=SAVE_DPI, bbox_inches='tight')\n",
|
| 305 |
+
" plt.show()"
|
| 306 |
+
]
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"cell_type": "markdown",
|
| 310 |
+
"id": "3ec202cf",
|
| 311 |
+
"metadata": {},
|
| 312 |
+
"source": [
|
| 313 |
+
"### 5.5 delta22 panels (lead the published figure: corr ¹H/¹³C + PCA)\n",
|
| 314 |
+
"\n",
|
| 315 |
+
"The same correlation + PCA layout on the delta22 gas-phase set. DSD-PBEP86 is the reference (delta22\n",
|
| 316 |
+
"has no CCSD(T)); it appears as an ordinary double-hybrid point in the PCA, with no CCSD(T) star."
|
| 317 |
+
]
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"cell_type": "code",
|
| 321 |
+
"execution_count": null,
|
| 322 |
+
"id": "fc1d57ab",
|
| 323 |
+
"metadata": {},
|
| 324 |
+
"outputs": [],
|
| 325 |
+
"source": [
|
| 326 |
+
"# delta22 correlation matrices (canonical S3 A = 1H, B = 13C)\n",
|
| 327 |
+
"for nuc in ['1H', '13C']:\n",
|
| 328 |
+
" fams = {nuc: delta22[nuc]['families']}\n",
|
| 329 |
+
" fig = leveling_plots.plot_corr_matrix('delta22', [nuc], res_delta22, fams,\n",
|
| 330 |
+
" GLOBAL_VMAX, ref_name=leveling.DELTA22_REF,\n",
|
| 331 |
+
" nuc_display=NUC_DISPLAY)\n",
|
| 332 |
+
" fig.savefig(figure_path(f'si_figure_s03_delta22_corr_{nuc}.png'),\n",
|
| 333 |
+
" dpi=SAVE_DPI, bbox_inches='tight')\n",
|
| 334 |
+
" plt.show()\n",
|
| 335 |
+
"\n",
|
| 336 |
+
"# delta22 PCA projection: 1H and 13C stacked in one figure (canonical S3 C)\n",
|
| 337 |
+
"fams = {nuc: delta22[nuc]['families'] for nuc in ['1H', '13C']}\n",
|
| 338 |
+
"fig = leveling_plots.plot_pca_pair('delta22', ['1H', '13C'], res_delta22, fams,\n",
|
| 339 |
+
" family_colors=FAMILY_COLORS, nuc_display=NUC_DISPLAY,\n",
|
| 340 |
+
" pca_adjust=PCA_ADJUST, pca_adjust_default=PCA_ADJUST_DEFAULT)\n",
|
| 341 |
+
"fig.savefig(figure_path('si_figure_s03_delta22_pca.png'),\n",
|
| 342 |
+
" dpi=SAVE_DPI, bbox_inches='tight')\n",
|
| 343 |
+
"plt.show()"
|
| 344 |
+
]
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"cell_type": "markdown",
|
| 348 |
+
"id": "4c1e4350",
|
| 349 |
+
"metadata": {},
|
| 350 |
+
"source": [
|
| 351 |
+
"## 6. Combined summary (both datasets)"
|
| 352 |
+
]
|
| 353 |
+
},
|
| 354 |
+
{
|
| 355 |
+
"cell_type": "code",
|
| 356 |
+
"execution_count": null,
|
| 357 |
+
"id": "185ce298",
|
| 358 |
+
"metadata": {},
|
| 359 |
+
"outputs": [],
|
| 360 |
+
"source": [
|
| 361 |
+
"# delta22's per-nucleus diagnostics (same summarize() used for NS372 above), computed here\n",
|
| 362 |
+
"# too so this combined table is self-contained -- it reuses res_delta22 from the shared load\n",
|
| 363 |
+
"# cell above, so this is cheap (no new correlation/PCA computation).\n",
|
| 364 |
+
"sum_delta22 = leveling.summarize(delta22, res_delta22)\n",
|
| 365 |
+
"\n",
|
| 366 |
+
"combined = pd.concat([sum_ns372.assign(dataset='NS372'),\n",
|
| 367 |
+
" sum_delta22.assign(dataset='delta22')], ignore_index=True)\n",
|
| 368 |
+
"combined = combined[['dataset','nucleus','n_obs','n_methods','r_min','r_median',\n",
|
| 369 |
+
" 'PC1_pct','PC2_pct','PC3plus_pct','parabola_R2']]\n",
|
| 370 |
+
"print('Leveling effect - both datasets (analyzed separately):')\n",
|
| 371 |
+
"print(f' PC1 range : {combined.PC1_pct.min():.2f}% - {combined.PC1_pct.max():.2f}%')\n",
|
| 372 |
+
"print(f' min pairwise r : {combined.r_min.min():.5f} (worst case, all nuclei)')\n",
|
| 373 |
+
"print(f' parabola R2 range : {combined.parabola_R2.min():.3f} - {combined.parabola_R2.max():.3f}')\n",
|
| 374 |
+
"print(f' global colour scale : 0 -> {GLOBAL_VMAX} on -log10(1-|r|)')\n",
|
| 375 |
+
"combined.round(5)"
|
| 376 |
+
]
|
| 377 |
+
}
|
| 378 |
+
],
|
| 379 |
+
"metadata": {
|
| 380 |
+
"language_info": {
|
| 381 |
+
"name": "python"
|
| 382 |
+
}
|
| 383 |
+
},
|
| 384 |
+
"nbformat": 4,
|
| 385 |
+
"nbformat_minor": 5
|
| 386 |
+
}
|
analysis/si_figures/si_figure_s11.ipynb
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "934387b2",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# SI Figure S11: MagNET vs DFT rovibrational (QCD) corrections\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Four panels: **A** MagNET (NN) vs B3LYP/cc-pVDZ rovibrational (QCD) correction per site, both nuclei;\n",
|
| 11 |
+
"**B** the NN-minus-DFT error distribution; **C/D** every proton/carbon site's DFT and NN QCD\n",
|
| 12 |
+
"correction, one column per solute. (QCD is gas-phase, so no solvent axis.)"
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"cell_type": "code",
|
| 17 |
+
"execution_count": null,
|
| 18 |
+
"id": "d004ad45",
|
| 19 |
+
"metadata": {},
|
| 20 |
+
"outputs": [],
|
| 21 |
+
"source": [
|
| 22 |
+
"import os, sys\n",
|
| 23 |
+
"\n",
|
| 24 |
+
"# make the in-repo modules importable (not pip-installed)\n",
|
| 25 |
+
"REPO = os.path.abspath(\"../..\")\n",
|
| 26 |
+
"for _p in (\"data/delta22\", \"analysis/code\", \"analysis/code/shared\"):\n",
|
| 27 |
+
" sys.path.insert(0, os.path.join(REPO, _p))"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"cell_type": "code",
|
| 32 |
+
"execution_count": null,
|
| 33 |
+
"id": "366412b1",
|
| 34 |
+
"metadata": {},
|
| 35 |
+
"outputs": [],
|
| 36 |
+
"source": [
|
| 37 |
+
"import matplotlib.pyplot as plt\n",
|
| 38 |
+
"\n",
|
| 39 |
+
"import delta22\n",
|
| 40 |
+
"import delta22_plots\n",
|
| 41 |
+
"import paths"
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"cell_type": "code",
|
| 46 |
+
"execution_count": null,
|
| 47 |
+
"id": "d241818b",
|
| 48 |
+
"metadata": {},
|
| 49 |
+
"outputs": [],
|
| 50 |
+
"source": [
|
| 51 |
+
"DELTA22_HDF5 = paths.dataset_file(\"delta22\", root=REPO)\n",
|
| 52 |
+
"XLSX = os.path.join(REPO, \"data\", \"delta22\", \"delta22_experimental.xlsx\")\n",
|
| 53 |
+
"\n",
|
| 54 |
+
"def figure_path(name):\n",
|
| 55 |
+
" os.makedirs(\"figures\", exist_ok=True)\n",
|
| 56 |
+
" return os.path.join(\"figures\", name)"
|
| 57 |
+
]
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"cell_type": "code",
|
| 61 |
+
"execution_count": null,
|
| 62 |
+
"id": "787bb8c4",
|
| 63 |
+
"metadata": {},
|
| 64 |
+
"outputs": [],
|
| 65 |
+
"source": [
|
| 66 |
+
"dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)\n",
|
| 67 |
+
"nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)"
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"cell_type": "markdown",
|
| 72 |
+
"id": "0f824acd",
|
| 73 |
+
"metadata": {},
|
| 74 |
+
"source": [
|
| 75 |
+
"## Panels A and B: MagNET vs DFT QCD correction, both nuclei"
|
| 76 |
+
]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"cell_type": "code",
|
| 80 |
+
"execution_count": null,
|
| 81 |
+
"id": "31fdd1fa",
|
| 82 |
+
"metadata": {},
|
| 83 |
+
"outputs": [],
|
| 84 |
+
"source": [
|
| 85 |
+
"qcd = delta22.compare_dft_nn(dft, nn, \"qcd\", keys=(\"solute\", \"site\", \"nucleus\"))\n",
|
| 86 |
+
"delta22_plots.plot_qcd_scatter(qcd, save_path=figure_path(\"si_figure_s11a_scatter.png\"))\n",
|
| 87 |
+
"delta22_plots.plot_qcd_error_histogram(qcd, save_path=figure_path(\"si_figure_s11b_hist.png\"))\n",
|
| 88 |
+
"plt.show()"
|
| 89 |
+
]
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"cell_type": "markdown",
|
| 93 |
+
"id": "080ca90b",
|
| 94 |
+
"metadata": {},
|
| 95 |
+
"source": [
|
| 96 |
+
"## Panel C: every proton site's QCD correction, one column per solute"
|
| 97 |
+
]
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"cell_type": "code",
|
| 101 |
+
"execution_count": null,
|
| 102 |
+
"id": "26be5d0a",
|
| 103 |
+
"metadata": {},
|
| 104 |
+
"outputs": [],
|
| 105 |
+
"source": [
|
| 106 |
+
"delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus=\"H\"), \"H\", \"upper left\",\n",
|
| 107 |
+
" save_path=figure_path(\"si_figure_s11c_sites_1H.png\"))\n",
|
| 108 |
+
"plt.show()"
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"cell_type": "markdown",
|
| 113 |
+
"id": "42977cbe",
|
| 114 |
+
"metadata": {},
|
| 115 |
+
"source": [
|
| 116 |
+
"## Panel D: every carbon site's QCD correction, one column per solute"
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"cell_type": "code",
|
| 121 |
+
"execution_count": null,
|
| 122 |
+
"id": "4430159b",
|
| 123 |
+
"metadata": {},
|
| 124 |
+
"outputs": [],
|
| 125 |
+
"source": [
|
| 126 |
+
"delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus=\"C\"), \"C\", \"upper right\",\n",
|
| 127 |
+
" save_path=figure_path(\"si_figure_s11d_sites_13C.png\"))\n",
|
| 128 |
+
"plt.show()"
|
| 129 |
+
]
|
| 130 |
+
}
|
| 131 |
+
],
|
| 132 |
+
"metadata": {
|
| 133 |
+
"language_info": {
|
| 134 |
+
"name": "python"
|
| 135 |
+
}
|
| 136 |
+
},
|
| 137 |
+
"nbformat": 4,
|
| 138 |
+
"nbformat_minor": 5
|
| 139 |
+
}
|
analysis/si_figures/si_figure_s12_carbon_monoxide.ipynb
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "cecbae9e",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# SI Figure S12: MagNET vs DFT ¹³C shielding of carbon monoxide across bond lengths\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Carbon monoxide stretched/compressed over a wide range of bond lengths, ¹³C shielding by DFT\n",
|
| 11 |
+
"(PBE1PBE/pcSseg-1, gas) vs the MagNET foundation model. The right panel shades the bond-length\n",
|
| 12 |
+
"distribution seen in real molecules."
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"cell_type": "code",
|
| 17 |
+
"execution_count": null,
|
| 18 |
+
"id": "326ed41a",
|
| 19 |
+
"metadata": {},
|
| 20 |
+
"outputs": [],
|
| 21 |
+
"source": [
|
| 22 |
+
"import os, sys\n",
|
| 23 |
+
"\n",
|
| 24 |
+
"# make the in-repo modules importable (not pip-installed)\n",
|
| 25 |
+
"REPO = os.path.abspath(\"../..\")\n",
|
| 26 |
+
"for _p in (\"analysis/code\", \"analysis/code/shared\"):\n",
|
| 27 |
+
" sys.path.insert(0, os.path.join(REPO, _p))"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"cell_type": "code",
|
| 32 |
+
"execution_count": null,
|
| 33 |
+
"id": "19aad17e",
|
| 34 |
+
"metadata": {},
|
| 35 |
+
"outputs": [],
|
| 36 |
+
"source": [
|
| 37 |
+
"import matplotlib.pyplot as plt\n",
|
| 38 |
+
"\n",
|
| 39 |
+
"import carbon_monoxide"
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"cell_type": "code",
|
| 44 |
+
"execution_count": null,
|
| 45 |
+
"id": "42d7d8b0",
|
| 46 |
+
"metadata": {},
|
| 47 |
+
"outputs": [],
|
| 48 |
+
"source": [
|
| 49 |
+
"def figure_path(name):\n",
|
| 50 |
+
" os.makedirs(\"figures\", exist_ok=True)\n",
|
| 51 |
+
" return os.path.join(\"figures\", name)"
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"cell_type": "code",
|
| 56 |
+
"execution_count": null,
|
| 57 |
+
"id": "17186f45",
|
| 58 |
+
"metadata": {},
|
| 59 |
+
"outputs": [],
|
| 60 |
+
"source": [
|
| 61 |
+
"df = carbon_monoxide.load_comparison()\n",
|
| 62 |
+
"near, tails = carbon_monoxide.equilibrium_vs_extreme_error(df)\n",
|
| 63 |
+
"print(f'{len(df)} bond lengths from {df.bond_length.min():.2f} to {df.bond_length.max():.2f} angstrom')\n",
|
| 64 |
+
"print(f'mean |carbon error|: {near:.1f} ppm near equilibrium vs {tails:.1f} ppm in the tails')"
|
| 65 |
+
]
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"cell_type": "code",
|
| 69 |
+
"execution_count": null,
|
| 70 |
+
"id": "48de095f",
|
| 71 |
+
"metadata": {},
|
| 72 |
+
"outputs": [],
|
| 73 |
+
"source": [
|
| 74 |
+
"fig, axes = plt.subplots(1, 2, figsize=(12, 5))\n",
|
| 75 |
+
"\n",
|
| 76 |
+
"ax = axes[0]\n",
|
| 77 |
+
"ax.plot(df[\"bond_length\"], df[\"gaussian_c\"], marker=\"o\", linestyle=\"-\", color=\"black\", label=\"Gaussian C\")\n",
|
| 78 |
+
"ax.plot(df[\"bond_length\"], df[\"magnet_c\"], marker=\"o\", linestyle=\"-\", color=\"gray\", label=\"MagNET C\")\n",
|
| 79 |
+
"ax.set_xlabel(\"Bond length (Å)\", fontweight=\"bold\")\n",
|
| 80 |
+
"ax.set_ylabel(\"Shielding (ppm)\", fontweight=\"bold\")\n",
|
| 81 |
+
"ax.set_title(\"13C Shielding vs. Bond Length\", fontweight=\"bold\")\n",
|
| 82 |
+
"ax.legend(frameon=False)\n",
|
| 83 |
+
"\n",
|
| 84 |
+
"ax = axes[1]\n",
|
| 85 |
+
"centers, counts, bin_width = carbon_monoxide.load_bond_length_histogram()\n",
|
| 86 |
+
"ax_hist = ax.twinx()\n",
|
| 87 |
+
"ax_hist.bar(centers, counts, width=bin_width, color=\"0.75\", edgecolor=\"none\", zorder=0)\n",
|
| 88 |
+
"ax_hist.set_yticks([])\n",
|
| 89 |
+
"ax_hist.grid(False)\n",
|
| 90 |
+
"ax_hist.set_zorder(0)\n",
|
| 91 |
+
"ax.set_zorder(1)\n",
|
| 92 |
+
"ax.patch.set_alpha(0) # let the histogram show through the transparent foreground axes\n",
|
| 93 |
+
"ax.plot(df[\"bond_length\"], df[\"delta_c\"], marker=\"o\", linestyle=\"-\", color=\"black\")\n",
|
| 94 |
+
"ax.axhline(0, color=\"gray\", linewidth=1, linestyle=\"--\")\n",
|
| 95 |
+
"ax.set_xlabel(\"Bond length (Å)\", fontweight=\"bold\")\n",
|
| 96 |
+
"ax.set_ylabel(\"Δ shielding (MagNET − Gaussian, ppm)\", fontweight=\"bold\")\n",
|
| 97 |
+
"ax.set_title(\"Shielding Differences vs. Bond Lengths\", fontweight=\"bold\")\n",
|
| 98 |
+
"\n",
|
| 99 |
+
"fig.tight_layout()\n",
|
| 100 |
+
"fig.savefig(figure_path(\"si_figure_s12.png\"), dpi=300, bbox_inches=\"tight\")\n",
|
| 101 |
+
"plt.show()"
|
| 102 |
+
]
|
| 103 |
+
}
|
| 104 |
+
],
|
| 105 |
+
"metadata": {
|
| 106 |
+
"kernelspec": {
|
| 107 |
+
"display_name": "Python 3",
|
| 108 |
+
"language": "python",
|
| 109 |
+
"name": "python3"
|
| 110 |
+
},
|
| 111 |
+
"language_info": {
|
| 112 |
+
"codemirror_mode": {
|
| 113 |
+
"name": "ipython",
|
| 114 |
+
"version": 3
|
| 115 |
+
},
|
| 116 |
+
"file_extension": ".py",
|
| 117 |
+
"mimetype": "text/x-python",
|
| 118 |
+
"name": "python",
|
| 119 |
+
"nbconvert_exporter": "python",
|
| 120 |
+
"pygments_lexer": "ipython3",
|
| 121 |
+
"version": "3.12.13"
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
"nbformat": 4,
|
| 125 |
+
"nbformat_minor": 5
|
| 126 |
+
}
|
analysis/si_figures/si_figure_s13.ipynb
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "1127ad90",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# SI Figure S13: MagNET-x vs DFT explicit-solvent corrections\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"Four panels, both nuclei: **A** MagNET-x (NN) vs DFT explicit-solvent correction per site/solvent,\n",
|
| 11 |
+
"Desmond and OpenMM overlaid; **B** the NN-minus-DFT error distribution; **C** every site's DFT and NN\n",
|
| 12 |
+
"correction in chloroform, one column per solute; **D** the semi-parsimonious composite model's fit accuracy vs experiment across the four OpenMM solvents, split by engine and\n",
|
| 13 |
+
"DFT-vs-NN source."
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"cell_type": "code",
|
| 18 |
+
"execution_count": null,
|
| 19 |
+
"id": "649a6ece",
|
| 20 |
+
"metadata": {},
|
| 21 |
+
"outputs": [],
|
| 22 |
+
"source": [
|
| 23 |
+
"import os, sys\n",
|
| 24 |
+
"\n",
|
| 25 |
+
"# make the in-repo modules importable (not pip-installed)\n",
|
| 26 |
+
"REPO = os.path.abspath(\"../..\")\n",
|
| 27 |
+
"for _p in (\"data/delta22\", \"analysis/code\", \"analysis/code/shared\"):\n",
|
| 28 |
+
" sys.path.insert(0, os.path.join(REPO, _p))"
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"cell_type": "code",
|
| 33 |
+
"execution_count": null,
|
| 34 |
+
"id": "dd166a12",
|
| 35 |
+
"metadata": {},
|
| 36 |
+
"outputs": [],
|
| 37 |
+
"source": [
|
| 38 |
+
"import matplotlib.pyplot as plt\n",
|
| 39 |
+
"\n",
|
| 40 |
+
"import delta22\n",
|
| 41 |
+
"import delta22_plots\n",
|
| 42 |
+
"import paths"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"cell_type": "code",
|
| 47 |
+
"execution_count": null,
|
| 48 |
+
"id": "da96ff0e",
|
| 49 |
+
"metadata": {},
|
| 50 |
+
"outputs": [],
|
| 51 |
+
"source": [
|
| 52 |
+
"DELTA22_HDF5 = paths.dataset_file(\"delta22\", root=REPO)\n",
|
| 53 |
+
"XLSX = os.path.join(REPO, \"data\", \"delta22\", \"delta22_experimental.xlsx\")\n",
|
| 54 |
+
"\n",
|
| 55 |
+
"def figure_path(name):\n",
|
| 56 |
+
" os.makedirs(\"figures\", exist_ok=True)\n",
|
| 57 |
+
" return os.path.join(\"figures\", name)"
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"cell_type": "code",
|
| 62 |
+
"execution_count": null,
|
| 63 |
+
"id": "0cc06b06",
|
| 64 |
+
"metadata": {},
|
| 65 |
+
"outputs": [],
|
| 66 |
+
"source": [
|
| 67 |
+
"dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)\n",
|
| 68 |
+
"nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)"
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"cell_type": "code",
|
| 73 |
+
"execution_count": null,
|
| 74 |
+
"id": "af66d177",
|
| 75 |
+
"metadata": {},
|
| 76 |
+
"outputs": [],
|
| 77 |
+
"source": [
|
| 78 |
+
"# openMM/Desmond engine hues (used by the scatter/histogram/site/fitting-accuracy panels below)\n",
|
| 79 |
+
"ENGINE_COLORS = {\"openMM\": \"#2E86AB\", \"desmond\": \"#A23B72\"}\n",
|
| 80 |
+
"ENGINE_LABELS = {\"desmond\": \"Desmond\", \"openMM\": \"OpenMM\"}"
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"cell_type": "markdown",
|
| 85 |
+
"id": "8ddc9cdc",
|
| 86 |
+
"metadata": {},
|
| 87 |
+
"source": [
|
| 88 |
+
"## Panels A and B: MagNET-x vs DFT explicit corrections, Desmond and OpenMM overlaid"
|
| 89 |
+
]
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"cell_type": "code",
|
| 93 |
+
"execution_count": null,
|
| 94 |
+
"id": "622c1d35",
|
| 95 |
+
"metadata": {},
|
| 96 |
+
"outputs": [],
|
| 97 |
+
"source": [
|
| 98 |
+
"explicit_by_engine = delta22.compare_dft_nn_by_engine(dft, nn, keys=(\"solute\", \"site\", \"nucleus\", \"solvent\"))\n",
|
| 99 |
+
"delta22_plots.plot_dft_nn_scatter_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,\n",
|
| 100 |
+
" save_path=figure_path(\"si_figure_s13a_scatter.png\"))\n",
|
| 101 |
+
"delta22_plots.plot_dft_nn_error_histogram_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,\n",
|
| 102 |
+
" save_path=figure_path(\"si_figure_s13b_hist.png\"))\n",
|
| 103 |
+
"plt.show()"
|
| 104 |
+
]
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"cell_type": "markdown",
|
| 108 |
+
"id": "2c511445",
|
| 109 |
+
"metadata": {},
|
| 110 |
+
"source": [
|
| 111 |
+
"## Panel C: every proton/carbon site's explicit correction in chloroform"
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"cell_type": "code",
|
| 116 |
+
"execution_count": null,
|
| 117 |
+
"id": "ca4e7871",
|
| 118 |
+
"metadata": {},
|
| 119 |
+
"outputs": [],
|
| 120 |
+
"source": [
|
| 121 |
+
"for nucleus, label in [(\"H\", \"Proton\"), (\"C\", \"Carbon\")]:\n",
|
| 122 |
+
" pairs = delta22.explicit_correction_dft_nn_pairs(dft, nn, \"chloroform\", nucleus)\n",
|
| 123 |
+
" delta22_plots.plot_explicit_correction_by_site(\n",
|
| 124 |
+
" pairs, f\"All {label} Sites: DFT vs NN Explicit Corrections (chloroform)\", ENGINE_COLORS,\n",
|
| 125 |
+
" save_path=figure_path(f\"si_figure_s13c_sites_{'1H' if nucleus == 'H' else '13C'}.png\"))\n",
|
| 126 |
+
"plt.show()"
|
| 127 |
+
]
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"cell_type": "markdown",
|
| 131 |
+
"id": "0813f518",
|
| 132 |
+
"metadata": {},
|
| 133 |
+
"source": [
|
| 134 |
+
"## Panel D: fitting accuracy of the semi-parsimonious composite model"
|
| 135 |
+
]
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"cell_type": "code",
|
| 139 |
+
"execution_count": null,
|
| 140 |
+
"id": "0ebd8702",
|
| 141 |
+
"metadata": {},
|
| 142 |
+
"outputs": [],
|
| 143 |
+
"source": [
|
| 144 |
+
"# the published panels use 250 seeded train/test splits\n",
|
| 145 |
+
"N_SPLITS = 250"
|
| 146 |
+
]
|
| 147 |
+
},
|
| 148 |
+
{
|
| 149 |
+
"cell_type": "code",
|
| 150 |
+
"execution_count": null,
|
| 151 |
+
"id": "8ce39a57",
|
| 152 |
+
"metadata": {},
|
| 153 |
+
"outputs": [],
|
| 154 |
+
"source": [
|
| 155 |
+
"SOLVENT_ORDER = [\"chloroform\", \"methanol\", \"TIP4P\", \"benzene\"]\n",
|
| 156 |
+
"solutes = sorted(dft[\"solute\"].unique())\n",
|
| 157 |
+
"for nucleus, label in [(\"H\", \"1H\"), (\"C\", \"13C\")]:\n",
|
| 158 |
+
" fitting = delta22.si_s13d_fitting_accuracy(dft, nn, SOLVENT_ORDER, N_SPLITS, solutes, nucleus=nucleus)\n",
|
| 159 |
+
" delta22_plots.plot_fitting_accuracy_boxplot(\n",
|
| 160 |
+
" fitting, SOLVENT_ORDER, label, ENGINE_COLORS,\n",
|
| 161 |
+
" save_path=figure_path(f\"si_figure_s13d_fitting_{'1H' if nucleus == 'H' else '13C'}.png\"))\n",
|
| 162 |
+
"plt.show()"
|
| 163 |
+
]
|
| 164 |
+
}
|
| 165 |
+
],
|
| 166 |
+
"metadata": {
|
| 167 |
+
"language_info": {
|
| 168 |
+
"name": "python"
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
"nbformat": 4,
|
| 172 |
+
"nbformat_minor": 5
|
| 173 |
+
}
|
analysis/si_figures/si_figure_s14.ipynb
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "8cd77576",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# SI Figure S14: test RMSE with DFT vs MagNET features, by solvent\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"For the four explicit-solvent solvents (chloroform, methanol, TIP4P water, benzene), per-solvent test\n",
|
| 11 |
+
"RMSE of predicting experimental ¹H/¹³C shifts with **DFT** features (solid) vs end-to-end **MagNET/NN**\n",
|
| 12 |
+
"features (lightened), each under three conditions (implicit SOTA, explicit, explicit + vibrations).\n",
|
| 13 |
+
"Two pages, one per MD engine (Desmond, OpenMM); nitromethane dropped."
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"cell_type": "code",
|
| 18 |
+
"execution_count": null,
|
| 19 |
+
"id": "67f9b9de",
|
| 20 |
+
"metadata": {},
|
| 21 |
+
"outputs": [],
|
| 22 |
+
"source": [
|
| 23 |
+
"import os, sys\n",
|
| 24 |
+
"\n",
|
| 25 |
+
"# make the in-repo modules importable (not pip-installed)\n",
|
| 26 |
+
"REPO = os.path.abspath(\"../..\")\n",
|
| 27 |
+
"for _p in (\"data/delta22\", \"analysis/code\", \"analysis/code/shared\"):\n",
|
| 28 |
+
" sys.path.insert(0, os.path.join(REPO, _p))"
|
| 29 |
+
]
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"cell_type": "code",
|
| 33 |
+
"execution_count": null,
|
| 34 |
+
"id": "eddfed92",
|
| 35 |
+
"metadata": {},
|
| 36 |
+
"outputs": [],
|
| 37 |
+
"source": [
|
| 38 |
+
"import matplotlib.pyplot as plt\n",
|
| 39 |
+
"\n",
|
| 40 |
+
"import delta22\n",
|
| 41 |
+
"import delta22_plots\n",
|
| 42 |
+
"import paths"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"cell_type": "code",
|
| 47 |
+
"execution_count": null,
|
| 48 |
+
"id": "c05cd017",
|
| 49 |
+
"metadata": {},
|
| 50 |
+
"outputs": [],
|
| 51 |
+
"source": [
|
| 52 |
+
"DELTA22_HDF5 = paths.dataset_file(\"delta22\", root=REPO)\n",
|
| 53 |
+
"XLSX = os.path.join(REPO, \"data\", \"delta22\", \"delta22_experimental.xlsx\")\n",
|
| 54 |
+
"\n",
|
| 55 |
+
"def figure_path(name):\n",
|
| 56 |
+
" os.makedirs(\"figures\", exist_ok=True)\n",
|
| 57 |
+
" return os.path.join(\"figures\", name)"
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"cell_type": "code",
|
| 62 |
+
"execution_count": null,
|
| 63 |
+
"id": "db2e619d",
|
| 64 |
+
"metadata": {},
|
| 65 |
+
"outputs": [],
|
| 66 |
+
"source": [
|
| 67 |
+
"# the published panels use 250 seeded train/test splits\n",
|
| 68 |
+
"N_SPLITS = 250"
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"cell_type": "code",
|
| 73 |
+
"execution_count": null,
|
| 74 |
+
"id": "b57840e8",
|
| 75 |
+
"metadata": {},
|
| 76 |
+
"outputs": [],
|
| 77 |
+
"source": [
|
| 78 |
+
"dft = delta22.add_composite_columns(delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False))\n",
|
| 79 |
+
"nn = delta22.add_composite_columns(delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False))\n",
|
| 80 |
+
"print(len(dft), \"DFT rows;\", len(nn), \"NN rows\")\n",
|
| 81 |
+
"\n",
|
| 82 |
+
"LABELS = [\"Implicit Solvent (SOTA)\", \"Explicit Solvent\", \"Explicit Solvent + Vibrations\"]\n",
|
| 83 |
+
"SS_LABELS = {\"TIP4P\": \"Water (TIP4P)\"}\n",
|
| 84 |
+
"S14_SOLVENTS = [\"chloroform\", \"methanol\", \"TIP4P\", \"benzene\"] # the 4 explicit solvents, SI order"
|
| 85 |
+
]
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"cell_type": "markdown",
|
| 89 |
+
"id": "a0645b27",
|
| 90 |
+
"metadata": {},
|
| 91 |
+
"source": [
|
| 92 |
+
"## Desmond page (DFT vs NN)"
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"cell_type": "code",
|
| 97 |
+
"execution_count": null,
|
| 98 |
+
"id": "5a7d9851",
|
| 99 |
+
"metadata": {},
|
| 100 |
+
"outputs": [],
|
| 101 |
+
"source": [
|
| 102 |
+
"# implicit = 2-term (stationary + pcm); explicit = stationary + desmond;\n",
|
| 103 |
+
"# explicit + vibrations = stationary_plus_qcd + desmond (1H) / stationary_plus_des_vib + desmond (13C)\n",
|
| 104 |
+
"DESMOND_FORMULAS = {\n",
|
| 105 |
+
" \"H\": [\"stationary + pcm\", \"stationary + desmond\", \"stationary_plus_qcd + desmond\"],\n",
|
| 106 |
+
" \"C\": [\"stationary + pcm\", \"stationary + desmond\", \"stationary_plus_des_vib + desmond\"],\n",
|
| 107 |
+
"}\n",
|
| 108 |
+
"for nucleus, label in [(\"H\", \"1H\"), (\"C\", \"13C\")]:\n",
|
| 109 |
+
" formulas = DESMOND_FORMULAS[nucleus]\n",
|
| 110 |
+
" delta22_plots.plot_ss_boxplot_dft_vs_nn(\n",
|
| 111 |
+
" delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),\n",
|
| 112 |
+
" delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),\n",
|
| 113 |
+
" S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,\n",
|
| 114 |
+
" save_path=figure_path(f\"si_figure_s14_desmond_{label}.png\"))\n",
|
| 115 |
+
"plt.show()"
|
| 116 |
+
]
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"cell_type": "markdown",
|
| 120 |
+
"id": "c8523187",
|
| 121 |
+
"metadata": {},
|
| 122 |
+
"source": [
|
| 123 |
+
"## OpenMM page (DFT vs NN)"
|
| 124 |
+
]
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"cell_type": "code",
|
| 128 |
+
"execution_count": null,
|
| 129 |
+
"id": "fc91d9af",
|
| 130 |
+
"metadata": {},
|
| 131 |
+
"outputs": [],
|
| 132 |
+
"source": [
|
| 133 |
+
"# implicit = 1-term composite (stationary_plus_pcm); explicit = stationary + openMM;\n",
|
| 134 |
+
"# explicit + vibrations = stationary_plus_qcd + openMM (1H) / stationary_plus_op_vib + openMM (13C)\n",
|
| 135 |
+
"OPENMM_FORMULAS = {\n",
|
| 136 |
+
" \"H\": [\"stationary_plus_pcm\", \"stationary + openMM\", \"stationary_plus_qcd + openMM\"],\n",
|
| 137 |
+
" \"C\": [\"stationary_plus_pcm\", \"stationary + openMM\", \"stationary_plus_op_vib + openMM\"],\n",
|
| 138 |
+
"}\n",
|
| 139 |
+
"for nucleus, label in [(\"H\", \"1H\"), (\"C\", \"13C\")]:\n",
|
| 140 |
+
" formulas = OPENMM_FORMULAS[nucleus]\n",
|
| 141 |
+
" delta22_plots.plot_ss_boxplot_dft_vs_nn(\n",
|
| 142 |
+
" delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),\n",
|
| 143 |
+
" delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),\n",
|
| 144 |
+
" S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,\n",
|
| 145 |
+
" save_path=figure_path(f\"si_figure_s14_openmm_{label}.png\"))\n",
|
| 146 |
+
"plt.show()"
|
| 147 |
+
]
|
| 148 |
+
}
|
| 149 |
+
],
|
| 150 |
+
"metadata": {
|
| 151 |
+
"language_info": {
|
| 152 |
+
"name": "python"
|
| 153 |
+
}
|
| 154 |
+
},
|
| 155 |
+
"nbformat": 4,
|
| 156 |
+
"nbformat_minor": 5
|
| 157 |
+
}
|