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)