File size: 32,569 Bytes
ef53368 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 | """Core analysis for the natural-products ("applications") figures (Figures 5C/5D, SI S8, S15). No plotting.
Takes an Applications loader (data/applications/applications_reader.py) and computes the numbers the
figure notebooks and tests share. A "composite model" predicts a shift as an OLS-weighted sum of
physics terms (gas-phase shielding + implicit correction + rovibrational correction); these figures
test how well weights fit on delta-22 carry to the natural-products test set.
"""
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
from stats import rmse as _rmse
from spreadsheet import site_atom_indices
from applications_reader import SOLVENTS, SHELL_SIZES
# composite-model formula sets (column names refer to build_query_df_nn output)
FITTING_FORMULAS_H = [
"stationary", "stationary_plus_pcm", "stationary + pcm", "stationary + pcm + qcd",
"stationary_plus_qcd + pcm", "stationary_plus_pcm_plus_qcd", "stationary + openMM",
"stationary + openMM + qcd", "stationary_plus_qcd + openMM", "stationary_plus_openMM_plus_qcd",
]
FITTING_FORMULAS_C = [
"stationary", "stationary_plus_pcm", "stationary + pcm", "stationary + pcm + openMM_vib",
"stationary_plus_op_vib + pcm", "stationary_plus_pcm_plus_op_vib", "stationary + openMM",
"stationary + openMM + openMM_vib", "stationary_plus_op_vib + openMM", "stationary_plus_openMM_plus_op_vib",
]
FORMULA_REMAP = {
"stationary_plus_pcm": "pcm2", "stationary + pcm": "pcm3", "stationary + pcm + qcd": "pcm4",
"stationary + pcm + openMM_vib": "pcm4", "stationary_plus_qcd + pcm": "semi_parsimonious_pcm",
"stationary_plus_op_vib + pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_qcd": "parsimonious_pcm",
"stationary_plus_pcm_plus_op_vib": "parsimonious_pcm", "stationary + openMM": "expl3",
"stationary + openMM + qcd": "expl4", "stationary + openMM + openMM_vib": "expl4",
"stationary_plus_qcd + openMM": "semi_parsimonious_expl", "stationary_plus_op_vib + openMM": "semi_parsimonious_expl",
"stationary_plus_openMM_plus_qcd": "parsimonious_expl", "stationary_plus_openMM_plus_op_vib": "parsimonious_expl",
}
# manuscript test-set membership, keyed by the canonical solute names build_query_df_nn emits
ALL_IN_ONE_BIN = {"Test Set": [
"isomer_1E", "isomer_1Z", "isomer_2E", "isomer_2Z", "isomer_3E", "isomer_3Z", "isomer_4N", "isomer_4O",
"vomicine", "prednisone", "peptide", "flavone", "dihydrotanshinone_I",
]}
# canonical solute -> manuscript display label (used as figure x-tick labels)
SOLUTE_DISPLAY = {
"isomer_1E": "Isomer 1 (E)", "isomer_1Z": "Isomer 1 (Z)", "isomer_2E": "Isomer 2 (E)",
"isomer_2Z": "Isomer 2 (Z)", "isomer_3E": "Isomer 3 (E)", "isomer_3Z": "Isomer 3 (Z)",
"isomer_4N": "1,4-dimethylpyridin-\n2(1H)one", "isomer_4O": "2-methoxy-\n4-methylpyridine",
"vomicine": "Vomicine", "prednisone": "Prednisone", "peptide": "Acetyl-L-alanyl-L-\nglutamine",
"flavone": "Flavone", "dihydrotanshinone_I": "Dihydrotanshinone I",
}
# the peptide (and, for carbon, the pyridone isomer_4N) is highlighted green as the high-error outlier
PEPTIDE_HIGHLIGHT_H = {"peptide": "#B0FF40"}
PEPTIDE_HIGHLIGHT_C = {"isomer_4N": "#B0FF40", "peptide": "#B0FF40"}
# bootstrap-coefficient file token -> remapped formula name, per nucleus
_BOOTSTRAP_TOKENS = {
"H": {"stationary_plus_pcm": "pcm2", "stationary_AND_pcm_AND_qcd": "pcm4",
"stationary_plus_qcd_AND_pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_qcd": "parsimonious_pcm",
"stationary_AND_openMM_AND_qcd": "expl4", "stationary_plus_qcd_AND_openMM": "semi_parsimonious_expl",
"stationary_plus_openMM_plus_qcd": "parsimonious_expl"},
"C": {"stationary_plus_pcm": "pcm2", "stationary_AND_pcm_AND_openMM_vib": "pcm4",
"stationary_plus_op_vib_AND_pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_op_vib": "parsimonious_pcm",
"stationary_AND_openMM_AND_openMM_vib": "expl4", "stationary_plus_op_vib_AND_openMM": "semi_parsimonious_expl",
"stationary_plus_openMM_plus_op_vib": "parsimonious_expl"},
}
def shell_convergence_corrections(loader, solute, nucleus, solvents=SOLVENTS):
"""MagNET-X explicit-solvent correction (solvated minus isolated shielding) for each NMR
site, averaged over the site's atoms and over the MD frames, as a function of solvent shell
size. Backs SI Figure S8 panel A (shown for vomicine, proton sites).
Returns a DataFrame indexed by (site, solvent) with one column per shell ("shell_50" ...
"shell_650"). Shells absent for a given solute/solvent (e.g. the isomer_1Z/chloroform/250
gap) come back as NaN.
"""
sites = loader.site_atom_table(uuid_prefix_h_sites=True)
sites = sites[(sites["solute"] == solute) & (sites["nucleus"] == nucleus)]
rows = []
for _, r in sites.iterrows():
idx = site_atom_indices(r["atom_numbers"])
for solvent in solvents:
present = set(loader.available_shells(solute, solvent))
vals = {"site": r["site"], "solvent": solvent}
for shell in SHELL_SIZES:
if shell not in present:
vals[f"shell_{shell}"] = np.nan
continue
mx = loader.magnet_x(solute, solvent, shell) # (frames, atoms, 2)
corr = mx[:, idx, 1] - mx[:, idx, 0] # solvated - isolated
corr = np.mean(corr, axis=1) # average over the site's atoms
corr = corr[~np.isnan(corr)] # drop frames with missing data
vals[f"shell_{shell}"] = float(np.mean(corr)) if len(corr) else np.nan
rows.append(vals)
return pd.DataFrame(rows).set_index(["site", "solvent"])
def experimental_stack(loader):
"""Experimental shifts as a long DataFrame indexed (solute, nucleus, site, solvent)."""
exp = loader.experiment(uuid_prefix_h_sites=True).drop(columns=["atom_numbers"])
exp = exp.set_index(["solute", "nucleus", "site"])
stack = exp.stack(future_stack=True)
stack = pd.DataFrame(stack, columns=["experimental"])
stack.index.names = ["solute", "nucleus", "site", "solvent"]
return stack.sort_index()
def _site_mean(arr, idx):
return float(np.asarray(arr)[idx].mean())
def build_query_df_nn(loader):
"""Assemble the per-(solute, nucleus, site, solvent) NN feature table that the composite-model
figures fit and plot (Figure 5C/5D, SI S15). Columns: experimental, stationary, pcm, qcd,
openMM, openMM_vib, plus the composite columns added by add_composite_columns
(stationary_plus_pcm/_qcd/_op_vib and their pcm+/openMM+ combinations). All inputs (MagNET
shieldings and the PCM conversion factors) are read through the loader.
"""
sites = loader.site_atom_table(uuid_prefix_h_sites=True)
solvents = [c for c in loader.experiment().columns
if c not in ("solute", "site", "nucleus", "atom_numbers")]
# Cache the per-solute / per-(solute, solvent) loader reads. Each accessor opens the HDF5 file
# fresh, and the loops below touch only ~13 solutes (or ~52 solute-solvent pairs) across
# hundreds of sites, so without caching this re-opens the file thousands of times (~12x slower).
_mz_cache, _qcd_cache, _shell_cache, _mx_cache, _mxs_cache = {}, {}, {}, {}, {}
def get_mz(solute):
if solute not in _mz_cache: _mz_cache[solute] = loader.magnet_zero(solute)
return _mz_cache[solute]
def get_qcd(solute):
if solute not in _qcd_cache: _qcd_cache[solute] = loader.qcd(solute)
return _qcd_cache[solute]
def get_shells(solute, solvent):
k = (solute, solvent)
if k not in _shell_cache: _shell_cache[k] = loader.available_shells(solute, solvent)
return _shell_cache[k]
def get_mx(solute, solvent):
k = (solute, solvent)
if k not in _mx_cache: _mx_cache[k] = loader.magnet_x(solute, solvent, 650)
return _mx_cache[k]
def get_mxs(solute, solvent):
k = (solute, solvent)
if k not in _mxs_cache: _mxs_cache[k] = loader.magnet_x_stationary(solute, solvent)
return _mxs_cache[k]
# --- MagNET-Zero stationary + PCM (PCM predicted for chloroform, broadcast to all solvents) ---
mz_rows = []
for _, r in sites.iterrows():
idx = site_atom_indices(r["atom_numbers"])
mz = get_mz(r["solute"])
mz_rows.append([r["solute"], r["nucleus"], r["site"],
_site_mean(mz["stationary"], idx), _site_mean(mz["pcm_correction"], idx)])
mz = pd.DataFrame(mz_rows, columns=["solute", "nucleus", "site", "stationary", "pcm"])
mz = pd.concat([mz.assign(solvent=s) for s in solvents], ignore_index=True)
mz = mz.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
# --- QCD correction (solvent-independent, tiled across solvents) ---
qcd_rows = []
for _, r in sites.iterrows():
idx = site_atom_indices(r["atom_numbers"])
q = get_qcd(r["solute"])
corr = q["trajectories"].mean(0).mean(0)[idx, 3] - q["stationary"][idx, 3]
qcd_rows.append([r["solute"], r["nucleus"], r["site"],
float(corr.mean()) if len(corr) else np.nan])
qcd = pd.DataFrame(qcd_rows, columns=["solute", "nucleus", "site", "qcd"])
qcd = pd.concat([qcd.assign(solvent=s) for s in solvents], ignore_index=True)
qcd = qcd.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
# --- MagNET-X explicit-solvent correction + vibrational correction (per solvent, 650-atom shell) ---
ex_rows = []
for _, r in sites.iterrows():
idx = site_atom_indices(r["atom_numbers"])
for solvent in solvents:
shells = get_shells(r["solute"], solvent)
if 650 not in shells:
ex_rows.append([r["solute"], r["nucleus"], r["site"], solvent, np.nan, np.nan]); continue
mx = get_mx(r["solute"], solvent) # (frames, atoms, [isolated, solvated])
corr = mx[:, idx, 1] - mx[:, idx, 0]
corr = np.mean(corr, axis=1)
corr = corr[~np.isnan(corr)]
openmm = float(np.mean(corr)) if len(corr) else np.nan
stat = get_mxs(r["solute"], solvent)
vib = float(np.mean(mx.mean(0)[idx, 0] - np.asarray(stat)[idx]))
ex_rows.append([r["solute"], r["nucleus"], r["site"], solvent, openmm, vib])
ex = pd.DataFrame(ex_rows, columns=["solute", "nucleus", "site", "solvent", "openMM", "openMM_vib"])
ex = ex.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
# --- combine + composite columns ---
comb = pd.concat([experimental_stack(loader), mz, ex, qcd], axis=1).reset_index()
comb = comb.set_index(["solute", "nucleus", "site", "solvent"])
# stationary_plus_pcm: scale the chloroform PCM correction to each solvent (factors from the loader,
# which renames water -> TIP4P to match the explicit-solvent naming)
conv = {}
for nuc in ["H", "C"]:
s = loader.pcm_conversion_factors(nuc).set_index("solvent")["pcm_conversion_factor"]
conv[nuc] = s.rename({"water": "TIP4P"})
nn = comb.reset_index()
factors = nn.apply(lambda r: conv[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
nn["stationary_plus_pcm"] = nn["stationary"] + nn["pcm"] * factors
nn = nn.set_index(comb.index.names)
comb["stationary_plus_pcm"] = nn["stationary_plus_pcm"]
comb["stationary_plus_qcd"] = comb["stationary"] + comb["qcd"]
comb["stationary_plus_op_vib"] = comb["stationary"] + comb["openMM_vib"]
comb["stationary_plus_pcm_plus_qcd"] = comb["stationary_plus_pcm"] + comb["qcd"]
comb["stationary_plus_pcm_plus_op_vib"] = comb["stationary_plus_pcm"] + comb["openMM_vib"]
comb["stationary_plus_openMM_plus_qcd"] = comb["stationary"] + comb["openMM"] + comb["qcd"]
comb["stationary_plus_openMM_plus_op_vib"] = comb["stationary"] + comb["openMM"] + comb["openMM_vib"]
return comb.reset_index()
# ----------------------------------------------------------------------------
# Composite-model fitting (Figure 5C/5D, SI S15). The coefficients applied at scale come from the
# loader's composite_model group (fit on delta-22).
# ----------------------------------------------------------------------------
def fit(fit_df, formula):
"""OLS of experimental shift on a composite formula; returns (RMSE, params).
params is a pandas Series indexed by "Intercept" and the predictor names (matching statsmodels'
naming, which the composite-model coefficient code downstream relies on). This does the ordinary
least squares directly with numpy (np.linalg.lstsq), which is identical to the notebooks'
statsmodels OLS but far faster in this fitting loop: statsmodels re-parses the formula with patsy
on every call (~1000x slower per call). The `fit`-matches-`_fit_statsmodels` test guards the
equivalence.
Rows with a missing experimental value or a missing predictor are dropped before fitting and are
not scored: statsmodels drops those rows when fitting too, and its `predict` returns NaN for a row
with a missing predictor, so a single missing value never turns the whole RMSE into NaN.
"""
terms = [term.strip() for term in formula.split("+") if term.strip()]
response = fit_df["experimental"].to_numpy(dtype=float)
columns = [fit_df[term].to_numpy(dtype=float) for term in terms]
design = np.column_stack([np.ones(len(fit_df))] + columns)
keep = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
design, response = design[keep], response[keep]
beta, *_ = np.linalg.lstsq(design, response, rcond=None)
params = pd.Series(beta, index=["Intercept"] + terms)
return _rmse(design @ beta, response), params
def _fit_statsmodels(fit_df, formula):
"""The original statsmodels OLS, kept only as a test oracle for `fit` (see the equivalence test).
Slow because statsmodels re-parses the formula with patsy on every call; do not use in the harness."""
result = smf.ols(formula=f"experimental ~ {formula}", data=fit_df).fit()
fit_df = fit_df.copy()
fit_df["predicted"] = result.predict(fit_df)
scored = fit_df[fit_df["experimental"].notna() & fit_df["predicted"].notna()]
return _rmse(scored["predicted"], scored["experimental"]), result.params
def fit_formulas_per_solvent(df, formulas, solvents, formula_remap):
"""One OLS fit per (formula, solvent), pooled across all solutes; returns rmse and params rows."""
rows = []
for formula in formulas:
for solvent in solvents:
d = df[df["solvent"] == solvent].copy()
rmse, params = fit(d, formula)
rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
"rmse": rmse, "params": params})
return pd.DataFrame(rows)
def fit_formulas_per_solvent_and_solute(df, formulas, solvents, solutes, formula_remap):
"""One OLS fit per (formula, solvent, solute); skips a group with under 2 rows or no
experimental data, and records the exception instead of raising if a fit fails."""
rows = []
for formula in formulas:
for solvent in solvents:
for solute in solutes:
d = df[(df["solvent"] == solvent) & (df["solute"] == solute)].copy()
if len(d) < 2 or d["experimental"].isnull().all():
continue
try:
rmse, params = fit(d, formula)
rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
"solute": solute, "rmse": rmse, "params": params})
except Exception as e: # noqa: BLE001 - mirror source behaviour
rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
"solute": solute, "rmse": np.nan, "params": str(e)})
return pd.DataFrame(rows)
def per_solvent_fits(query_df_nn):
"""all_solute_fitting_results: full per-solvent OLS fits for both nuclei ('scaled to test set')."""
out = {}
formulas = {"H": FITTING_FORMULAS_H, "C": FITTING_FORMULAS_C}
solvents = list(query_df_nn["solvent"].unique())
for nuc in ["H", "C"]:
d = query_df_nn[query_df_nn["nucleus"] == nuc].copy()
out[nuc] = fit_formulas_per_solvent(d, formulas[nuc], solvents, FORMULA_REMAP)
return out
def per_solute_fits(query_df_nn):
"""per_solute_fitting_results: per-solvent-and-solute OLS fits ('scaled to solute' baseline)."""
out = {}
formulas = {"H": FITTING_FORMULAS_H, "C": FITTING_FORMULAS_C}
solvents = list(query_df_nn["solvent"].unique())
solutes = list(query_df_nn["solute"].unique())
for nuc in ["H", "C"]:
d = query_df_nn[query_df_nn["nucleus"] == nuc].copy()
out[nuc] = fit_formulas_per_solvent_and_solute(d, formulas[nuc], solvents, solutes, FORMULA_REMAP)
return out
def build_bootstrap_seed_coeffs(loader):
"""Per-nucleus DataFrame of bootstrap coefficients with a remapped 'formula' column, read from
the loader's composite_model group (the delta-22 bootstrap fits applied to the natural products)."""
out = {}
for nuc in ["H", "C"]:
frames = []
for token, name in _BOOTSTRAP_TOKENS[nuc].items():
df = loader.bootstrap_coefficients(token, nuc).copy()
df["formula"] = name
frames.append(df)
combined = pd.concat(frames, ignore_index=True, sort=False)
front = [c for c in ["solvent", "formula", "seed"] if c in combined.columns]
out[nuc] = combined[front + [c for c in combined.columns if c not in front]]
return out
def apply_bootstrap_params_to_full_dataset(data_df, params_df, nucleus=None):
"""Apply each bootstrap coefficient set (per formula/seed/solvent) to the full NP dataset,
returning predictions: solute, nucleus, site, solvent, formula, seed, experimental, predicted."""
coeffs_df = params_df.copy()
if nucleus is not None:
data_df = data_df[data_df["nucleus"] == nucleus]
keep = ["solute", "nucleus", "site", "solvent", "experimental"]
keep += [c for c in data_df.columns if c in coeffs_df.columns and c not in keep]
data_df = data_df[keep]
merged = coeffs_df.merge(data_df, how="outer", on="solvent", suffixes=("_coeff", "_data"))
idx = ["solute", "nucleus", "site", "solvent", "formula", "seed"]
merged = merged[idx + [c for c in merged.columns if c not in idx]]
# vectorized prediction: intercept (0 if missing) plus, for each parameter, coeff*data where the
# coeff is present (a missing coeff contributes nothing; a present coeff with missing data
# propagates NaN).
pred = merged["Intercept"].astype(float).fillna(0.0) if "Intercept" in merged.columns else 0.0
for cc in [c for c in merged.columns if c.endswith("_coeff")]:
dc = cc.replace("_coeff", "_data")
if dc not in merged.columns:
continue
term = merged[cc].astype(float) * merged[dc].astype(float)
pred = pred + term.where(merged[cc].notna(), 0.0)
merged["predicted"] = pred
return merged[idx + ["experimental", "predicted"]]
def compute_solute_rmses(df, solute_col="solute"):
"""Per (solvent, nucleus, formula, seed, solute) RMSE over a solute's sites."""
valid = df[df["experimental"].notnull() & df["predicted"].notnull()].copy()
# RMSE is sqrt(mean(squared error)), so a single vectorized groupby-mean over a precomputed
# squared-error column is identical to grouped.apply(rmse) but ~19x faster on the ~200k groups
# of a full bootstrap table (stats.rmse is exactly np.sqrt(np.mean(np.square(...)))).
valid["_squared_error"] = np.square(valid["predicted"] - valid["experimental"])
result = (valid.groupby(["solvent", "nucleus", "formula", "seed", solute_col])["_squared_error"]
.mean().pow(0.5).reset_index(name="Bootstrap_RMSE"))
return result.rename(columns={solute_col: "solute"}) if solute_col != "solute" else result
def compute_grouped_rmse(df, solute_groups, solute_col="solute"):
"""Per (solvent, nucleus, formula, seed, solute_group) RMSE over all sites in the group."""
lookup = {s: g for g, ss in solute_groups.items() for s in ss}
df = df.copy()
df["solute_group"] = df[solute_col].map(lookup).fillna("Unknown")
valid = df[df["experimental"].notnull() & df["predicted"].notnull()].copy()
# vectorized RMSE, same identity and speedup as compute_solute_rmses above
valid["_squared_error"] = np.square(valid["predicted"] - valid["experimental"])
return (valid.groupby(["solvent", "nucleus", "formula", "seed", "solute_group"])["_squared_error"]
.mean().pow(0.5).reset_index(name="Bootstrap_RMSE"))
# ---------------------------------------------------------------------------------------------
# SI Figure S15's "Fitting RMSE Comparisons (chloroform, H/C)" bar chart: per test-set solute,
# three ways of choosing the composite-model coefficients -- "Scaled to Solute" (fit each solute
# on its own), "Scaled to Test Set" (one fit pooled across all 12 test-set solutes), "Extrapolated
# from delta22" (delta-22's bootstrap fits applied cold, never seeing the test set). Built from
# per_solute_fits (Scaled to Solute), per_solvent_fits (Scaled to Test Set, split back out per
# solute by scaled_to_test_set_per_solute_rmse below), and apply_bootstrap_params_to_full_dataset +
# compute_solute_rmses (Extrapolated from delta22, the same data Figure 5D's boxplot uses).
def scaled_to_test_set_per_solute_rmse(query_df_nn, all_solute_fits, nucleus, solvent, formula):
"""per_solvent_fits' output has one pooled rmse per (formula, solvent): the model is fit once
across every test-set solute together. To get a comparable per-solute rmse (needed to sit
"Scaled to Test Set" next to "Scaled to Solute" and "Extrapolated from delta22" in the same bar
chart), this applies that one pooled fit's coefficients to each solute's own sites and scores
them individually, reusing apply_bootstrap_params_to_full_dataset's prediction machinery by
reshaping the fitted statsmodels Params Series into that function's expected wide
"<term>_coeff" row format."""
remapped = FORMULA_REMAP.get(formula, formula)
fits = all_solute_fits[nucleus]
match = fits[(fits["formula"] == remapped) & (fits["solvent"] == solvent)]
if match.empty:
raise KeyError(f"no per_solvent_fits row for formula={remapped!r} solvent={solvent!r}")
params = match.iloc[0]["params"]
# bare term names, matching data_df's own raw column names -- apply_bootstrap_params_to_full_
# dataset's merge(..., suffixes=("_coeff", "_data")) is what appends "_coeff"/"_data" (only
# triggered by the name COLLIDING with data_df's column of the same name); pre-suffixing here
# would just create an unmatched "<term>_coeff" column no data_df column collides with.
wide = {"Intercept": float(params.get("Intercept", 0.0)), "solvent": solvent,
"formula": remapped, "seed": 0}
for term in params.index:
if term != "Intercept":
wide[term] = float(params[term])
params_df = pd.DataFrame([wide])
# Restrict to this one solvent before the merge: apply_bootstrap_params_to_full_dataset's outer
# join zero-fills a missing coefficient instead of raising, so an unfiltered call would
# silently score every OTHER solvent's rows against this solvent's plane as if the missing
# coefficients were all zero.
solvent_only = query_df_nn[query_df_nn["solvent"] == solvent]
preds = apply_bootstrap_params_to_full_dataset(solvent_only, params_df, nucleus=nucleus)
return compute_solute_rmses(preds)
def fitting_rmse_comparison_table(query_df_nn, per_solute_fits_result, per_solvent_fits_result,
bootstrap_rmses, nucleus, solvent, formula):
"""Assembles SI Figure S15's "Fitting RMSE Comparisons" table: one row per test-set solute,
columns "Scaled to Solute", "Scaled to Test Set", "Extrapolated from delta22" (ppm RMSE), all
at one nucleus/solvent/formula. bootstrap_rmses is compute_solute_rmses' output for `nucleus`
(e.g. Figure 5D's bootstrap_rmses_h), averaged here over its bootstrap seeds."""
remapped = FORMULA_REMAP.get(formula, formula)
solute_fit = per_solute_fits_result[nucleus]
scaled_to_solute = (solute_fit[(solute_fit["formula"] == remapped) & (solute_fit["solvent"] == solvent)]
.set_index("solute")["rmse"])
scaled_to_test_set = (scaled_to_test_set_per_solute_rmse(query_df_nn, per_solvent_fits_result, nucleus, solvent, formula)
.query("solvent == @solvent and formula == @remapped")
.set_index("solute")["Bootstrap_RMSE"])
boot = bootstrap_rmses[(bootstrap_rmses["nucleus"] == nucleus) & (bootstrap_rmses["solvent"] == solvent)
& (bootstrap_rmses["formula"] == remapped)]
extrapolated = boot.groupby("solute")["Bootstrap_RMSE"].mean()
table = pd.DataFrame({"Scaled to Solute": scaled_to_solute, "Scaled to Test Set": scaled_to_test_set,
"Extrapolated from delta22": extrapolated})
return table.dropna(how="all")
def distribution_shift_by_solvent_table(per_solute_fits_result, per_solvent_fits_result,
bootstrap_rmses, nucleus, formula, solvents=None):
"""SI Figure S15's per-solvent "distribution shift is minor" cross-check -- the solvent-averaged
companion to fitting_rmse_comparison_table (which is per-solute for a single solvent). For one
nucleus and formula, the mean test-set RMSE under the three ways of choosing the composite-model
coefficients, one row per solvent: "Extrapolated from delta22" (delta-22's bootstrap
coefficients applied cold), "Scaled to Test Set" (one fit pooled across the whole test set), and
"Scaled to Solute" (a separate fit per solute). Where the three sit close, re-optimizing the
coefficients barely helps, so they transfer and the residual error is physics; the one solvent
where "Extrapolated" rises well above the refits is TIP4P (water), dominated by the peptide's
conformer-population sensitivity, which is why S15 reports solvent-averaged performance.
bootstrap_rmses is compute_solute_rmses' output for `nucleus` (e.g. the si_figure_s15 notebook's
bootstrap_rmses["H"]); per_solute_fits_result / per_solvent_fits_result are the per_solute_fits /
per_solvent_fits dicts keyed by nucleus. Averages over bootstrap seeds and over each solvent's
solutes."""
remapped = FORMULA_REMAP.get(formula, formula)
boot = bootstrap_rmses[(bootstrap_rmses["nucleus"] == nucleus)
& (bootstrap_rmses["formula"] == remapped)]
test_set = per_solvent_fits_result[nucleus]
test_set = test_set[test_set["formula"] == remapped]
solute = per_solute_fits_result[nucleus]
solute = solute[solute["formula"] == remapped]
if solvents is None:
solvents = sorted(boot["solvent"].unique())
rows = []
for solvent in solvents:
rows.append({
"solvent": solvent,
"Extrapolated from delta22": boot[boot["solvent"] == solvent]["Bootstrap_RMSE"].mean(),
"Scaled to Test Set": test_set[test_set["solvent"] == solvent]["rmse"].mean(),
"Scaled to Solute": solute[solute["solvent"] == solvent]["rmse"].mean(),
})
return pd.DataFrame(rows).set_index("solvent")
# ---------------------------------------------------------------------------------------------
# SI Figure S15's "Feature Space Coverage by Solvent" and "Residuals for Delta22 Fitting
# Coefficients" panels: do the test set and delta-22 cover the same region of feature space (so
# the test set's higher error can't be blamed on extrapolating outside what delta-22's fitting
# coefficients ever saw)? Both panels combine the test set (this module's query_df_nn) with
# delta-22's own per-site data at the same two features (delta22.py's query_df_nn, passed in by
# the caller so this module doesn't import delta22.py directly).
# Feature Space Coverage only compares the 4 solvents both datasets have explicit-solvent (OpenMM)
# coverage for -- delta-22 has Desmond+OpenMM MD for all 12 solvents, but the natural-products test
# set (sigma-fresh-derived) only has 4.
FEATURE_SPACE_SOLVENTS = ("chloroform", "benzene", "methanol", "TIP4P")
def _feature_space_config(nucleus):
if nucleus == "H":
return {"x_feature": "stationary_plus_qcd", "y_feature": "openMM"}
if nucleus == "C":
return {"x_feature": "stationary_plus_op_vib", "y_feature": "openMM"}
raise ValueError(f"unsupported nucleus {nucleus!r}, expected 'H' or 'C'")
def _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus, cols):
"""The shared setup both panels below need: the test set's own rows (excludes any row
literally named "delta22", a defensive filter) tagged "Test Set", concatenated with
delta22_query_df_nn's rows (already delta-22-only) tagged "Delta22"."""
test = query_df_nn[query_df_nn["nucleus"] == nucleus].copy()
test = test[~test["solute"].astype(str).str.strip().str.lower().eq("delta22")]
test["dataset"] = "Test Set"
d22 = delta22_query_df_nn[delta22_query_df_nn["nucleus"] == nucleus].copy()
d22["dataset"] = "Delta22"
return pd.concat([test[cols + ["dataset"]], d22[cols + ["dataset"]]], ignore_index=True)
def feature_space_coverage_table(query_df_nn, delta22_query_df_nn, nucleus, solvents=FEATURE_SPACE_SOLVENTS):
"""SI Figure S15's "Feature Space Coverage by Solvent": test-set and delta-22 per-site feature
values, mean-centered globally -- one scalar mean per feature, pooled over every solvent and
both datasets (the notebook's separate fitting cell instead centers per solvent). Returns a
tidy DataFrame: solvent, dataset ("Test Set"/"Delta22"), x (centered), y (centered)."""
cfg = _feature_space_config(nucleus)
x_feature, y_feature = cfg["x_feature"], cfg["y_feature"]
combined = _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus, ["solvent", x_feature, y_feature])
combined = combined[combined["solvent"].isin(solvents)].dropna(subset=[x_feature, y_feature])
combined["x"] = combined[x_feature] - combined[x_feature].mean()
combined["y"] = combined[y_feature] - combined[y_feature].mean()
return combined[["solvent", "dataset", "x", "y"]].reset_index(drop=True)
def delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus, solvents=FEATURE_SPACE_SOLVENTS):
"""SI Figure S15's "Residuals for Delta22 Fitting Coefficients": fits a 2-feature OLS plane
(experimental ~ x_feature + y_feature, the same two features feature_space_coverage_table
plots) to delta-22's data alone, separately per solvent, then applies that one plane to both
delta-22 and the test set, returning residual = experimental - predicted. For a linear model,
minimizing RMSE directly (Nelder-Mead) is the same convex problem as ordinary least squares, so
np.linalg.lstsq gives the identical plane. Returns a tidy DataFrame: solvent, dataset,
experimental, residual."""
cfg = _feature_space_config(nucleus)
x_feature, y_feature = cfg["x_feature"], cfg["y_feature"]
combined = _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus,
["solvent", x_feature, y_feature, "experimental"])
combined = combined[combined["solvent"].isin(solvents)].dropna(subset=[x_feature, y_feature, "experimental"])
rows = []
for solvent in solvents:
sdf = combined[combined["solvent"] == solvent]
d22_sdf = sdf[sdf["dataset"] == "Delta22"]
if len(d22_sdf) < 3:
continue
design = np.column_stack([np.ones(len(d22_sdf)), d22_sdf[x_feature], d22_sdf[y_feature]])
c0, c1, c2 = np.linalg.lstsq(design, d22_sdf["experimental"].to_numpy(), rcond=None)[0]
predicted = c0 + c1 * sdf[x_feature] + c2 * sdf[y_feature]
out = sdf[["solvent", "dataset", "experimental"]].copy()
out["residual"] = sdf["experimental"] - predicted
rows.append(out)
if not rows:
return pd.DataFrame(columns=["solvent", "dataset", "experimental", "residual"])
return pd.concat(rows, ignore_index=True).reset_index(drop=True)
|