File size: 52,394 Bytes
ef53368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90b47cf
 
ef53368
 
 
 
 
 
90b47cf
ef53368
 
 
 
 
 
 
 
 
 
90b47cf
ef53368
 
 
 
 
 
 
 
 
90b47cf
ef53368
 
 
 
 
 
 
 
 
 
 
 
 
 
90b47cf
ef53368
90b47cf
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
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
"""Core analysis for the delta-22 figures (no plotting).

Shared machinery behind the delta-22 figures (Figures 2A, 3, 4; SI S1, S4-S8, S11, S13, S16-S18):
a seeded per-solute train/test fitting harness, the Pareto computation, and correlation matrices.
Reads the released data via `delta22_reader`; the figure notebooks and tests import from here.

Splits are seeded with random.seed(seed + 100), shuffle, first n_test solutes are the test set, so
results are deterministic. `fit` uses numpy lstsq directly instead of statsmodels smf.ols in the
inner loop (patsy re-parsing makes that ~1000x slower); `_fit_statsmodels` is kept as a test oracle.
"""
import random

import numpy as np
import pandas as pd

from stats import rmse, mae, ols_fit, linear_fit_1d
from spreadsheet import site_atom_indices  # noqa: F401  re-exported for notebooks (D.site_atom_indices)
from delta22_reader import (
    load_delta22_dft_data,
    load_delta22_nn_data,
    load_solutes,
    load_perturbed_shieldings,
)

# The 12 solvents with explicit (Desmond) data, in a stable order.
DESMOND_SOLVENTS = [
    "tetrahydrofuran", "dichloromethane", "chloroform", "toluene", "benzene", "chlorobenzene",
    "acetone", "dimethylsulfoxide", "acetonitrile", "trifluoroethanol", "methanol", "TIP4P",
]

# the double hybrids (and DLPNO-MP2) have no implicit-solvent shielding of their own; the reader
# substitutes a B3LYP-D3(BJ) PCM correction for them, so their pcm column is not method-specific.
# Exclude them from any analysis that compares the PCM correction across methods.
DOUBLE_HYBRID_METHODS = ["B2GP_PLYP", "B2PLYP", "dlpno_mp2", "dsd_pbep86",
                         "mPW2PLYP", "revdsd_pbep86"]

STATIONARY_VS_PCM = ["stationary", "stationary + pcm"]


# ---------------------------------------------------------------------------
# loading / assembling the flat query table

def delta22_solutes(delta22_path):
    """The list of solute names stored in the file."""
    return load_solutes(delta22_path)


def build_query_df(combined_df):
    """Flatten a reader combined table (load_delta22_dft_data / load_delta22_nn_data output,
    which is multi-indexed) into a plain DataFrame with the index levels as columns. Fitting
    and split helpers operate on this flat table."""
    return combined_df.reset_index()


def load_query_df_dft(delta22_path, experimental_path, **kwargs):
    """Convenience: load the DFT combined table and flatten it in one call."""
    return build_query_df(load_delta22_dft_data(delta22_path, experimental_path, **kwargs))


def load_query_df_nn(delta22_path, experimental_path, **kwargs):
    """Convenience: load the MagNET (NN) combined table and flatten it in one call."""
    return build_query_df(load_delta22_nn_data(delta22_path, experimental_path, **kwargs))


# ---------------------------------------------------------------------------
# composite predictor columns

# the precomputed sum columns the formulas reference as single terms (e.g. "stationary_plus_pcm" is
# one predictor = stationary + pcm, distinct from the two-term "stationary + pcm"). For MagNET (NN)
# data, stationary_plus_pcm is precomputed by the loader using the solvent-specific PCM scaling
# factors; add_composite_columns leaves an existing stationary_plus_pcm column untouched.
# NOTE: the pcm three-term chains here (stationary_plus_pcm_plus_qcd etc.) are RAW sums with an
# unscaled pcm term. build_composite_model.py needs the pcm term carried at its scaled value, so it
# builds those columns itself on top of the scaled stationary_plus_pcm. Do not swap it to this
# helper: the results differ by order 1 ppm on every pcm chain.
_COMPOSITE_COLUMNS = {
    "stationary_plus_pcm": ("stationary", "pcm"),
    "stationary_plus_desmond": ("stationary", "desmond"),
    "stationary_plus_openMM": ("stationary", "openMM"),
    "stationary_plus_qcd": ("stationary", "qcd"),
    "stationary_plus_des_vib": ("stationary", "desmond_vib"),
    "stationary_plus_op_vib": ("stationary", "openMM_vib"),
    "stationary_plus_pcm_plus_qcd": ("stationary", "pcm", "qcd"),
    "stationary_plus_pcm_plus_des_vib": ("stationary", "pcm", "desmond_vib"),
    "stationary_plus_pcm_plus_op_vib": ("stationary", "pcm", "openMM_vib"),
    "stationary_plus_desmond_plus_qcd": ("stationary", "desmond", "qcd"),
    "stationary_plus_desmond_plus_des_vib": ("stationary", "desmond", "desmond_vib"),
    "stationary_plus_openMM_plus_qcd": ("stationary", "openMM", "qcd"),
    "stationary_plus_openMM_plus_op_vib": ("stationary", "openMM", "openMM_vib"),
}


def add_composite_columns(query_df, keep_existing=("stationary_plus_pcm",)):
    """Add the precomputed sum columns the composite formulas reference as single terms. Returns a
    copy. Columns named in keep_existing that are already present (e.g. an NN stationary_plus_pcm
    built with the solvent scaling factors) are not overwritten."""
    out = query_df.copy()
    for name, parts in _COMPOSITE_COLUMNS.items():
        if name in keep_existing and name in out.columns:
            continue
        out[name] = sum(out[part] for part in parts)
    return out


# ---------------------------------------------------------------------------
# the linear fitting harness

def _formula_terms(formula):
    """The additive predictor column names in 'a + b + c'. These figures use only additive main
    effects (no interactions), so a plain split on '+' is exact."""
    return [term.strip() for term in formula.split("+") if term.strip()]


def _design_matrix(df, terms):
    """Design matrix [1, term_1, term_2, ...] as float, plus the response, with any row that has a
    missing value in the response or a predictor dropped (statsmodels drops missing rows too)."""
    response = df["experimental"].to_numpy(dtype=float)
    columns = [df[term].to_numpy(dtype=float) for term in terms]
    design = np.column_stack([np.ones(len(df))] + columns)
    keep = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
    return design[keep], response[keep]


def fit(train_df, test_df, formula, mode="rmse"):
    """Fit experimental ~ formula by ordinary least squares on train_df and score on both sets.

    Returns (train_error, test_error, params), where params is a pandas Series indexed by
    "Intercept" and the predictor names (matching statsmodels' naming). mode is "rmse" (default)
    or "mae". Uses numpy least squares directly (see the module docstring for why)."""
    terms = _formula_terms(formula)
    train_x, train_y = _design_matrix(train_df, terms)
    beta = ols_fit(train_x, train_y)
    params = pd.Series(beta, index=["Intercept"] + terms)

    test_x, test_y = _design_matrix(test_df, terms)
    train_pred = train_x @ beta
    test_pred = test_x @ beta
    score = rmse if mode == "rmse" else mae if mode == "mae" else None
    if score is None:
        raise ValueError(f"mode must be 'rmse' or 'mae', got {mode!r}")
    return score(train_pred, train_y), score(test_pred, test_y), params


def _fit_statsmodels(train_df, test_df, formula, mode="rmse"):
    """Statsmodels OLS, kept only as a test oracle for `fit`. Slow because statsmodels re-parses
    the formula with patsy on every call; do not use it in the harness."""
    import statsmodels.formula.api as smf
    result = smf.ols(formula=f"experimental ~ {formula}", data=train_df).fit()
    score = rmse if mode == "rmse" else mae
    train_err = score(result.predict(train_df).to_numpy(), train_df["experimental"].to_numpy())
    test_err = score(result.predict(test_df).to_numpy(), test_df["experimental"].to_numpy())
    return train_err, test_err, result.params


def generate_solute_splits(n_splits, query_df, solvent, solutes,
                           n_test=10, seed_offset=100):
    """Build n_splits seeded train/test partitions for one solvent, splitting on SOLUTE so a
    molecule is never in both sets. For split k: seed the RNG with k + seed_offset, shuffle the
    solute list, take the first n_test as the test set and the rest as the training set, then select
    that solvent's rows. Returns a list of (train_df, test_df) pairs."""
    solute_list = list(solutes)
    # filter this solvent once, then group its rows by solute, so each seed just concatenates the
    # relevant groups instead of masking the whole table again.
    solvent_df = query_df[query_df["solvent"] == solvent]
    by_solute = {solute: rows for solute, rows in solvent_df.groupby("solute")}
    empty = solvent_df.iloc[0:0]

    def _gather(molecules):
        frames = [by_solute[m] for m in molecules if m in by_solute]
        return pd.concat(frames) if frames else empty

    splits = []
    for seed in range(n_splits):
        random.seed(seed + seed_offset)
        molecules = list(solute_list)
        random.shuffle(molecules)
        splits.append((_gather(molecules[n_test:]), _gather(molecules[:n_test])))
    return splits


def _solute_partitions(n_splits, solutes, n_test, seed_offset=100):
    """The seeded (train_solutes, test_solutes) name partitions the harness fits over, matching
    generate_solute_splits exactly: for split k, seed random with k + seed_offset, shuffle the full
    solute list, test = the first n_test names, train = the rest. Row selection and solvent filtering
    are left to the caller (see _split_fit_scores), so these partitions are computed once and reused
    across formulas."""
    partitions = []
    base = list(solutes)
    for seed in range(n_splits):
        random.seed(seed + seed_offset)
        molecules = list(base)
        random.shuffle(molecules)
        partitions.append((molecules[n_test:], molecules[:n_test]))
    return partitions


def _split_fit_scores(group_df, solvent, formulas, partitions, mode="rmse", min_train=2, swap=False):
    """Fit each formula over the given seeded solute partitions for one group and solvent, returning
    {(formula, seed): (train_error, test_error)} for every split that was fit.

    The fast path behind run_fits / pareto_solvent_averaged_rmse: converts each column to numpy once
    and selects each split's rows by integer index, avoiding the per-split pd.concat that dominates
    runtime in generate_solute_splits + fit. Mathematically identical to fit() on the same rows.
    Splits whose train set is smaller than min_train, whose test set is empty, or that fail to solve
    are omitted from the result. swap=True trains on the first n_test solutes instead of the rest
    (Figure 2A)."""
    solvent_df = group_df[group_df["solvent"] == solvent]
    n_rows = len(solvent_df)
    score = rmse if mode == "rmse" else mae if mode == "mae" else None
    if score is None:
        raise ValueError(f"mode must be 'rmse' or 'mae', got {mode!r}")

    # row positions (0..n_rows-1 within solvent_df) for each solute, and the numeric columns as numpy
    solute_values = solvent_df["solute"].to_numpy()
    by_solute = {}
    for position, solute in enumerate(solute_values):
        by_solute.setdefault(solute, []).append(position)
    by_solute = {s: np.array(v, dtype=np.intp) for s, v in by_solute.items()}
    empty = np.array([], dtype=np.intp)

    def gather(names):
        arrays = [by_solute[m] for m in names if m in by_solute]
        return np.concatenate(arrays) if arrays else empty

    # each split's train/test row indices, computed once and shared across formulas
    split_indices = []
    for train_names, test_names in partitions:
        train_idx, test_idx = gather(train_names), gather(test_names)
        if swap:
            train_idx, test_idx = test_idx, train_idx
        split_indices.append((train_idx, test_idx))

    response = solvent_df["experimental"].to_numpy(dtype=float)
    term_columns = {term: solvent_df[term].to_numpy(dtype=float)
                    for formula in formulas for term in _formula_terms(formula)}

    scores = {}
    for formula in formulas:
        terms = _formula_terms(formula)
        design = np.column_stack([np.ones(n_rows)] + [term_columns[t] for t in terms])
        finite = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
        for seed, (train_idx, test_idx) in enumerate(split_indices):
            if len(train_idx) < min_train or len(test_idx) < 1:
                continue
            train_fit = train_idx[finite[train_idx]]
            test_fit = test_idx[finite[test_idx]]
            try:
                beta = ols_fit(design[train_fit], response[train_fit])
                train_err = score(design[train_fit] @ beta, response[train_fit])
                test_err = score(design[test_fit] @ beta, response[test_fit])
            except Exception:
                continue
            scores[(formula, seed)] = (train_err, test_err)
    return scores


def run_fits(query_df, solvents, formulas, n_splits, solutes,
             group_cols=None, n_test=10, mode="rmse", min_train=2):
    """Run the fitting harness over every (group, solvent, formula, split) combination and return
    a tidy DataFrame with train_RMSE / test_RMSE per row.

    group_cols: optional list of columns to fit within separately (e.g.
    ["sap_nmr_method", "sap_basis", "sap_geometry_type"] for the per-method figures). When None,
    the whole query_df is used as a single group (the caller has pre-filtered it). Splits that are
    too small to fit, or that cannot be solved, are skipped.
    """
    rows = []
    if group_cols:
        group_iter = list(query_df.groupby(group_cols))
    else:
        group_iter = [((), query_df)]
    partitions = _solute_partitions(n_splits, solutes, n_test)
    for group_key, group_df in group_iter:
        meta = {}
        if group_cols:
            key = group_key if isinstance(group_key, tuple) else (group_key,)
            meta = dict(zip(group_cols, key))
        for solvent in solvents:
            scores = _split_fit_scores(group_df, solvent, formulas, partitions,
                                       mode=mode, min_train=min_train)
            for formula in formulas:
                for seed in range(n_splits):
                    got = scores.get((formula, seed))
                    if got is None:
                        continue
                    rows.append({**meta, "solvent": solvent, "formula": formula,
                                 "seed": seed, "train_RMSE": got[0], "test_RMSE": got[1]})
    return pd.DataFrame(rows)


def full_fit_coefficients(query_df, solvents, formula):
    """Fit `formula` on the full data (no train/test split) for each solvent and return a DataFrame
    indexed by parameter name ("Intercept" plus the predictors) with one column per solvent. This is
    the deterministic full-fit OLS whose coefficients are the composite model's coefficients, stored
    in applications.hdf5's composite_model/ols_coefficients group. Fitting the released delta-22 NN
    data here (with nitromethane dropped) reproduces those coefficients to within the data's
    integer-encoding precision."""
    columns = {}
    for solvent in solvents:
        sub = query_df[query_df["solvent"] == solvent]
        if len(sub) < 2:
            continue
        _, _, params = fit(sub, sub, formula)
        columns[solvent] = params
    return pd.DataFrame(columns)


def fit_coefficients(query_df, solvent, formula, solutes, n_splits,
                     n_test=10, seed_offset=100):
    """Fit one formula for one solvent across all seeded splits and return the per-split fitted
    coefficients as a DataFrame (one row per seed, columns = the statsmodels parameter names plus
    the seed). This is the building block for the bootstrap coefficient distributions and for
    verifying the composite-model coefficients fit on delta-22."""
    splits = generate_solute_splits(n_splits, query_df, solvent, solutes,
                                    n_test=n_test, seed_offset=seed_offset)
    records = []
    for seed, (train_df, test_df) in enumerate(splits):
        if len(train_df) < 2:
            continue
        _, _, params = fit(train_df, test_df, formula)
        record = {"solvent": solvent, "seed": seed}
        record.update({name: float(value) for name, value in params.items()})
        records.append(record)
    return pd.DataFrame(records)


# ---------------------------------------------------------------------------
# Figure S8 B-D (Convergence of Explicit Solvent Corrections)

def frame_corrections(perturbed, atom_indices):
    """Per-frame solvent correction for one site: the mean over the site's atoms of
    (solvated - isolated), from a raw (n_frames, n_atoms, 2) perturbed-shielding array
    (load_perturbed_shieldings). Frames whose site atoms were not computed read as NaN."""
    site = (perturbed[:, :, 1] - perturbed[:, :, 0])[:, list(atom_indices)]
    valid = ~np.isnan(site)
    counts = valid.sum(axis=1)
    sums = np.where(valid, site, 0.0).sum(axis=1)
    return np.where(counts > 0, sums / np.maximum(counts, 1), np.nan)


def running_average(values):
    """Cumulative mean over frames ignoring NaN frames - the running value a correction converges to
    as more molecular-dynamics frames are included (Figure S8 panel B)."""
    values = np.asarray(values, dtype=float)
    valid = ~np.isnan(values)
    counts = np.cumsum(valid)
    sums = np.cumsum(np.where(valid, values, 0.0))
    with np.errstate(invalid="ignore", divide="ignore"):
        return np.where(counts > 0, sums / counts, np.nan)


def autocorrelation(values, max_lag=None):
    """Autocorrelation of the valid per-frame corrections at lags 0..max_lag (Figure S8 panel C).
    A fast drop to zero means frames are effectively independent."""
    x = np.asarray(values, dtype=float)
    x = x[~np.isnan(x)]
    x = x - x.mean()
    n = len(x)
    if n < 2:
        return np.array([1.0])
    if max_lag is None:
        max_lag = n - 1
    denom = np.sum(x * x)
    return np.array([np.sum(x[:n - lag] * x[lag:]) / denom for lag in range(max_lag + 1)])


def frame_validity(perturbed):
    """Per frame, whether any shielding value was computed for that frame (a raw (n_frames,
    n_atoms, 2) perturbed-shielding array from load_perturbed_shieldings has NaN entries for atoms
    that were not computed). A frame with every entry NaN was skipped entirely by the DFT queue;
    Figure S8 panel D visualizes this mask across solvents for a representative solute."""
    return ~np.all(np.isnan(perturbed), axis=(1, 2))


def frame_validity_grid(hdf5_filepath, solute, solvents, explicit_solvation_model, shield_type="dft"):
    """Figure S8 panel D data: for one solute and one molecular-dynamics engine, the per-frame
    validity mask (frame_validity) for each solvent, stacked into a single boolean grid of shape
    (n_solvents, max_frames). Solvents with fewer trajectory frames than the longest one are padded
    with False (no data) past their own frame count, so every row lines up on a shared frame-index
    axis. Returns (grid, frame_counts), where frame_counts[i] is the true (unpadded) frame count for
    solvents[i], needed to normalize the frame-wise correction histograms in panel B."""
    masks = []
    for solvent in solvents:
        perturbed = load_perturbed_shieldings(hdf5_filepath, solute, solvent,
                                              explicit_solvation_model, shield_type)
        masks.append(frame_validity(perturbed))
    frame_counts = np.array([len(m) for m in masks])
    max_len = int(frame_counts.max()) if len(frame_counts) else 0
    grid = np.zeros((len(solvents), max_len), dtype=bool)
    for i, mask in enumerate(masks):
        grid[i, :len(mask)] = mask
    return grid, frame_counts


# ---------------------------------------------------------------------------
# Figures S11 / S13 (MagNET vs DFT corrections)

def compare_dft_nn(dft_df, nn_df, value_col, keys=("solute", "site", "nucleus")):
    """Merge the DFT and MagNET (NN) tables on the given keys and return, per site, the DFT and NN
    value of one correction column plus their difference (error = NN - DFT). Used for the
    rovibrational (qcd) comparison of Figure S11 and the explicit-solvent (desmond/openMM) comparison
    of Figure S13. The correction is tiled across NMR methods, so duplicate keyed rows are dropped
    before merging."""
    keys = list(keys)
    dft = dft_df[keys + [value_col]].drop_duplicates(subset=keys).dropna(subset=[value_col])
    nn = nn_df[keys + [value_col]].drop_duplicates(subset=keys).dropna(subset=[value_col])
    merged = dft.merge(nn, on=keys, suffixes=("_dft", "_nn"))
    merged["error"] = merged[f"{value_col}_nn"] - merged[f"{value_col}_dft"]
    return merged


def qcd_correction_by_site(dft_df, nn_df, nucleus="H"):
    """Per site, the DFT and MagNET (NN) rovibrational (QCD) correction for one nucleus, sorted by
    solute (SI Figure S11 panel C, the per-solute view behind panel A's aggregate scatter). QCD is a
    gas-phase rovibrational correction, so unlike the explicit-solvent per-site view (Figure S13 panel
    C) there is no solvent to split on: this is a plain wrapper around compare_dft_nn that keeps one
    nucleus and orders the rows for the per-solute strip plot."""
    merged = compare_dft_nn(dft_df, nn_df, "qcd", keys=("solute", "site", "nucleus"))
    sub = merged[merged["nucleus"] == nucleus]
    return sub.sort_values("solute", kind="stable").reset_index(drop=True)


def compare_dft_nn_by_engine(dft_df, nn_df, engines=("desmond", "openMM"),
                             keys=("solute", "site", "nucleus", "solvent")):
    """SI Figure S13 panels A and B data: compare_dft_nn run separately for each molecular-dynamics
    engine (Desmond and OpenMM), stacked into one table with a shared 'dft_value' / 'nn_value' /
    'error' column and an 'engine' label. compare_dft_nn alone cannot be stacked directly because it
    names its value columns after value_col (e.g. 'desmond_dft' vs 'openMM_dft'); this renames them
    to a common pair of columns first so both engines can share one axis."""
    frames = []
    for engine in engines:
        one = compare_dft_nn(dft_df, nn_df, engine, keys=keys)
        one = one.rename(columns={f"{engine}_dft": "dft_value", f"{engine}_nn": "nn_value"})
        one["engine"] = engine
        frames.append(one[list(keys) + ["dft_value", "nn_value", "error", "engine"]])
    return pd.concat(frames, ignore_index=True)


# ---------------------------------------------------------------------------
# Figure S5 (PCM Captures Bulk Solvent Effects)

# bulk solvent properties used to test what the PCM benefit tracks (TIP4P uses water's values)
SOLVENT_DIELECTRIC = {
    "chloroform": 4.81, "dichloromethane": 9.08, "methanol": 32.6, "TIP4P": 78.54,
    "trifluoroethanol": 26.7, "acetone": 21.01, "acetonitrile": 36.64, "dimethylsulfoxide": 47.0,
    "tetrahydrofuran": 7.52, "benzene": 2.28, "chlorobenzene": 5.69, "toluene": 2.38,
}
SOLVENT_POLARIZABILITY = {
    "chloroform": 8.5, "dichloromethane": 6.5, "methanol": 3.3, "TIP4P": 1.5,
    "trifluoroethanol": 4.5, "acetone": 6.4, "acetonitrile": 4.4, "dimethylsulfoxide": 7.9,
    "tetrahydrofuran": 8.0, "benzene": 10.3, "chlorobenzene": 12.4, "toluene": 12.3,
}


def pcm_benefit_per_solvent(query_df_dft, method, basis, geometry, solvents, n_splits, solutes,
                            nucleus="H", n_test=10):
    """Panel S5 data: per solvent, the percent reduction in mean test RMSE from adding the
    implicit-solvent (PCM) term, for one DFT method. Returns a Series indexed by solvent. A positive
    value means PCM lowered the error. Plotted against the bulk solvent properties above.

    The published Figure S5 aggregates the per-split test RMSEs with the mean (not the median); this
    is what reproduces the paper's Pearson R values exactly."""
    one = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
                       & (query_df_dft["sap_basis"] == basis)
                       & (query_df_dft["sap_geometry_type"] == geometry)
                       & (query_df_dft["nucleus"] == nucleus)]
    one = add_composite_columns(one)
    results = run_fits(one, solvents, ["stationary", "stationary_plus_pcm"],
                       n_splits=n_splits, solutes=solutes, n_test=n_test)
    mean = results.groupby(["solvent", "formula"])["test_RMSE"].mean().unstack("formula")
    return -((mean["stationary_plus_pcm"] - mean["stationary"]) / mean["stationary"]) * 100.0


# ---------------------------------------------------------------------------
# Figure S7 (Explicit Solvent Corrections are Method-Independent)

def explicit_correction_pairs(combined_df, solvent, nucleus="H"):
    """Per site, the Desmond and OpenMM explicit-solvent corrections for one solvent (Figure S7).
    The explicit correction does not depend on the NMR method, so the same value is tiled across the
    method rows; duplicate site rows are dropped. Works on either the DFT or the NN combined table.
    A tight Desmond-vs-OpenMM agreement is the method-independence claim."""
    sub = combined_df[(combined_df["nucleus"] == nucleus) & (combined_df["solvent"] == solvent)]
    out = sub[["solute", "site", "desmond", "openMM"]].drop_duplicates(subset=["solute", "site"])
    return out.dropna(subset=["desmond", "openMM"]).reset_index(drop=True)


def explicit_correction_dft_nn_pairs(dft_df, nn_df, solvent, nucleus="H"):
    """Per site, the Desmond and OpenMM explicit-solvent corrections from both DFT and MagNET-x (NN)
    for one solvent and nucleus (Figure S13 panel C, the per-site view behind panel A's aggregate
    scatter). Built from two explicit_correction_pairs calls (one per source) merged on
    (solute, site), giving columns desmond_dft, openMM_dft, desmond_nn, openMM_nn."""
    dft_pairs = explicit_correction_pairs(dft_df, solvent, nucleus)
    nn_pairs = explicit_correction_pairs(nn_df, solvent, nucleus)
    return dft_pairs.merge(nn_pairs, on=["solute", "site"], suffixes=("_dft", "_nn"))


def si_s13d_fitting_accuracy(query_df_dft, query_df_nn, solvents, n_splits, solutes, nucleus="H",
                             method="dsd_pbep86", basis="pcSseg3", geometry="pbe0_tz", n_test=10):
    """Figure S13 panel D data: the semi-parsimonious composite model's (composite_models.py's
    best_semi formula) test RMSE against experiment, for every solvent, split by molecular-dynamics
    engine (Desmond vs OpenMM) and by the source of the explicit-solvent/vibrational term (DFT vs
    MagNET-x, "NN"). Protons use the QCD rovibrational correction (stationary_plus_qcd), which the
    SI text says is the same B3LYP/cc-pVDZ value for both DFT and NN bars; carbons use the classical
    vibrational correction computed from the same engine's trajectories (stationary_plus_desmond_vib
    or stationary_plus_openMM_vib), which does differ between the DFT and NN combined tables. Returns
    a tidy DataFrame with one row per (engine, source, solvent, seed) and a test_RMSE column."""
    engine_vib_column = {"desmond": "stationary_plus_des_vib", "openMM": "stationary_plus_op_vib"}
    rows = []
    for source_name, query_df in [("DFT", query_df_dft), ("NN", query_df_nn)]:
        sub = query_df[query_df["nucleus"] == nucleus]
        if source_name == "DFT":
            sub = sub[(sub["sap_nmr_method"] == method) & (sub["sap_basis"] == basis)
                     & (sub["sap_geometry_type"] == geometry)]
        sub = add_composite_columns(sub)
        for engine in ("desmond", "openMM"):
            base_term = "stationary_plus_qcd" if nucleus == "H" else engine_vib_column[engine]
            formula = f"{base_term} + {engine}"
            results = run_fits(sub, solvents, [formula], n_splits=n_splits, solutes=solutes,
                               n_test=n_test)
            results["engine"] = engine
            results["source"] = source_name
            rows.append(results)
    return pd.concat(rows, ignore_index=True)


# ---------------------------------------------------------------------------
# Figure 2A / Figure S1 (the accuracy-vs-cost Pareto frontier)

# MagNET predicts the implicit-solvent (PCM) correction for chloroform only. To get a value for the
# other solvents, the chloroform correction is scaled by a per-solvent factor, fit on the delta-22
# DFT PCM corrections themselves: the slope (through the origin) of one method's PCM correction in
# that solvent against the reference method's PCM correction in chloroform. The reference (input)
# method is B3LYP-D3(BJ); the output method is the one MagNET was trained to reproduce (wP04 for
# proton, wB97X-D for carbon). These factors are also stored in applications.hdf5, but recomputing
# them here keeps Figure 2A self-contained within the delta-22 data.
MAGNET_PCM_INPUT_METHOD = "b3lyp_d3bj"
MAGNET_PCM_OUTPUT_METHODS = {"H": "wp04", "C": "wb97xd"}


def pcm_conversion_factors(query_df_dft, nucleus, output_method=None,
                           input_method=MAGNET_PCM_INPUT_METHOD,
                           geometry="aimnet2", basis="pcSseg2"):
    """The per-solvent factor that scales MagNET's chloroform PCM correction to each solvent, for
    one nucleus. Recomputed from the delta-22 DFT PCM corrections: for each solvent, the
    through-the-origin slope of the output method's PCM correction in that solvent against the input
    (reference) method's PCM correction in chloroform, over the shared sites. Returns a Series
    indexed by solvent in the reader's native naming (water is "TIP4P"). Matches the factors stored
    in applications.hdf5 to machine precision."""
    if output_method is None:
        output_method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
    base = query_df_dft[(query_df_dft["sap_geometry_type"] == geometry)
                        & (query_df_dft["sap_basis"] == basis)
                        & (query_df_dft["nucleus"] == nucleus)]
    reference = (base[(base["sap_nmr_method"] == input_method) & (base["solvent"] == "chloroform")]
                 .set_index(["solute", "site"])["pcm"])
    output = base[base["sap_nmr_method"] == output_method]
    factors = {}
    for solvent in DESMOND_SOLVENTS:
        y = output[output["solvent"] == solvent].set_index(["solute", "site"])["pcm"]
        joined = pd.concat([reference.rename("x"), y.rename("y")], axis=1).dropna()
        xv = joined["x"].to_numpy(dtype=float)
        yv = joined["y"].to_numpy(dtype=float)
        factors[solvent] = float((xv * yv).sum() / (xv * xv).sum()) if len(xv) else np.nan
    return pd.Series(factors, name="pcm_conversion_factor")


def add_nn_stationary_plus_pcm(query_df_nn, factors_by_nucleus):
    """Add the MagNET stationary_plus_pcm column to a flat NN query table: the gas-phase shielding
    plus the chloroform PCM correction scaled by the per-solvent, per-nucleus factor from
    pcm_conversion_factors. factors_by_nucleus maps "H"/"C" to those Series. Returns a copy."""
    out = query_df_nn.copy()
    factor = out.apply(lambda r: factors_by_nucleus[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
    out["stationary_plus_pcm"] = out["stationary"] + out["pcm"] * factor
    return out


# the columns the Pareto fit needs from each flat table, before the "sap_" prefix is stripped
_PARETO_COLUMNS = ["sap_geometry_type", "sap_nmr_method", "sap_basis", "nucleus", "site",
                   "solute", "solvent", "experimental", "stationary_plus_pcm"]


def assemble_pareto_fitting_df(query_df_dft, query_df_nn, conversion_factors=None,
                               exclude_solutes=("nitromethane",)):
    """Build the combined DFT + MagNET table the Pareto fit runs on: experimental shift against the
    single stationary_plus_pcm predictor, for every method/basis/geometry/nucleus/solvent. The DFT
    predictor is stationary + pcm; the MagNET predictor uses the scaled chloroform correction
    (conversion_factors, recomputed from query_df_dft if not given). TIP4P is renamed to water and
    the excluded solutes (nitromethane by default, an outlier) are removed. The DFT rows come first
    so the solute order is stable for the seeded splits."""
    if conversion_factors is None:
        conversion_factors = {nuc: pcm_conversion_factors(query_df_dft, nuc) for nuc in ("H", "C")}
    dft = query_df_dft.copy()
    dft["stationary_plus_pcm"] = dft["stationary"] + dft["pcm"]
    nn = add_nn_stationary_plus_pcm(query_df_nn, conversion_factors)
    combined = pd.concat([dft[_PARETO_COLUMNS], nn[_PARETO_COLUMNS]], ignore_index=True)
    combined = combined.rename(columns=lambda c: c.replace("sap_", ""))
    combined["solvent"] = combined["solvent"].replace("TIP4P", "water")
    combined["experimental"] = pd.to_numeric(combined["experimental"], errors="coerce")
    if exclude_solutes:
        combined = combined[~combined["solute"].isin(set(exclude_solutes))]
    return combined.reset_index(drop=True)


def pareto_solvent_averaged_rmse(fitting_df, solutes=None, n_splits=100, n_test=10,
                                 random_state=1, formula="stationary_plus_pcm"):
    """For every (geometry, method, basis, nucleus, solvent), the mean test RMSE of the
    experimental ~ stationary_plus_pcm fit over n_splits seeded per-solute splits, then a
    solvent-averaged row per (geometry, method, basis, nucleus). This is the accuracy axis of
    Figure 2A / Figure S1.

    Figure 2A splits differently from the other figures: it trains on the first n_test shuffled
    solutes and tests on the rest, seeding with random_state + split index (the other figures train
    on the remainder and seed with +100); swap=True on _split_fit_scores gets that train-on-first-
    n_test behavior. The per-split errors are averaged with the mean, not the median used elsewhere."""
    if solutes is None:
        solutes = fitting_df["solute"].unique().tolist()
    group_cols = ["geometry_type", "nmr_method", "basis", "nucleus", "solvent"]
    rows = []
    partitions = _solute_partitions(n_splits, solutes, n_test, seed_offset=random_state)
    for key, group_df in fitting_df.groupby(group_cols):
        solvent = key[group_cols.index("solvent")]
        scores = _split_fit_scores(group_df, solvent, [formula], partitions, swap=True)
        errors = [scores.get((formula, seed), (np.nan, np.nan))[1] for seed in range(n_splits)]
        with np.errstate(invalid="ignore"):
            mean_err = float(np.nanmean(errors)) if errors else np.nan
        rows.append({**dict(zip(group_cols, key)), "fitting_RMSE": mean_err})
    raw = pd.DataFrame(rows)
    averaged = (raw.groupby(["geometry_type", "nmr_method", "basis", "nucleus"])["fitting_RMSE"]
                .mean().reset_index())
    averaged["solvent"] = "solvent-averaged"
    return pd.concat([raw, averaged], ignore_index=True)


def load_pareto_timings(delta22_path):
    """The compute-time axis of Figure 2A: (dft_gas_timings, nn_timings). DFT timings are the
    gas-phase ('none' solvent) total time per (geometry, method, basis); MagNET timings sum the NMR
    time across its solvents and keep one geometry time. Reads the timing groups through the reader."""
    from delta22_reader import load_delta22_dft_timings, load_delta22_nn_timings
    dft = load_delta22_dft_timings(delta22_path)
    dft.index = dft.index.set_names([n.replace("sap_", "") for n in dft.index.names])
    dft = dft.droplevel("solvent_model")
    dft_gas = dft[dft.index.get_level_values("solvent") == "none"].droplevel("solvent")
    nn = load_delta22_nn_timings(delta22_path)
    nn.index = nn.index.set_names([n.replace("sap_", "") for n in nn.index.names])
    nn = nn.droplevel("solvent_model")
    nn = nn.groupby(["geometry_type", "nmr_method", "basis"]).agg(
        {"geometry_time": "first", "nmr_time": "sum"})
    nn["total_time"] = nn["geometry_time"] + nn["nmr_time"]
    return dft_gas, nn


def attach_pareto_timings(rmse_df, dft_gas_timings, nn_timings):
    """Join the gas-phase DFT timings onto the per-method RMSE rows and substitute the MagNET
    timings (which are not in the DFT gas table) for the MagNET rows. Returns rmse_df with
    geometry_time / nmr_time / total_time columns added."""
    out = rmse_df.join(dft_gas_timings, on=["geometry_type", "nmr_method", "basis"], how="left")
    magnet = out["nmr_method"] == "MagNET"
    nn_row = nn_timings.loc[("aimnet2", "MagNET", "N/A")]
    out.loc[magnet, ["geometry_time", "nmr_time", "total_time"]] = \
        nn_row[["geometry_time", "nmr_time", "total_time"]].to_numpy()
    return out


def fig2a_pareto_points(query_df_dft, query_df_nn, dft_gas_timings, nn_timings,
                        solutes=None, n_splits=100, n_test=10, random_state=1,
                        conversion_factors=None):
    """The full Figure 2A / Figure S1 table: one row per (geometry, method, basis, nucleus, solvent)
    plus the solvent-averaged rows, each carrying its mean test RMSE and its total compute time.
    Pass the flat DFT and NN query tables (build_query_df output) and the two timing tables from
    load_pareto_timings. The frontier for a given nucleus is pareto_frontier on the
    solvent-averaged rows with x="total_time", y="fitting_RMSE"."""
    fitting = assemble_pareto_fitting_df(query_df_dft, query_df_nn, conversion_factors)
    rmse = pareto_solvent_averaged_rmse(fitting, solutes, n_splits=n_splits, n_test=n_test,
                                        random_state=random_state)
    return attach_pareto_timings(rmse, dft_gas_timings, nn_timings)


def pareto_table_curated(points_df, nucleus, solvent="chloroform"):
    """SI Tables S1 (1H, nucleus="H") and S2 (13C, nucleus="C"): the curated 55-row subset of
    fig2a_pareto_points' full cross product that the source tables print. fig2a_pareto_points (and
    the SI Figure S1 scatter built from it) wants every aimnet2-geometry method/basis combination,
    each an unlabeled point on the plot; the printed tables instead keep only MagNET plus a single
    representative aimnet2-geometry DFT row (the same reference method each nucleus's MagNET-Zero
    output is trained against, MAGNET_PCM_OUTPUT_METHODS, at pcSseg2), alongside the full
    pbe0_tz-geometry grid (18 methods x 3 basis sets, minus mp2/pcSseg3 which was never computed, so
    53 rows). Sorted by total_time ascending to match the source table's row order."""
    sub = points_df[(points_df["nucleus"] == nucleus) & (points_df["solvent"] == solvent)]
    aimnet2_method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
    keep = (
        (sub["geometry_type"] == "pbe0_tz")
        | (sub["nmr_method"] == "MagNET")
        | ((sub["geometry_type"] == "aimnet2") & (sub["nmr_method"] == aimnet2_method)
           & (sub["basis"] == "pcSseg2"))
    )
    return sub[keep].sort_values("total_time").reset_index(drop=True)


def pareto_frontier(points_df, x="total_time", y="test_RMSE"):
    """Return the rows of points_df on the lower-left Pareto frontier, i.e. the points for which
    no other point has both a smaller x (compute time) and a smaller y (error). Sorted by x.
    """
    ordered = points_df.sort_values([x, y]).reset_index(drop=True)
    frontier_rows = []
    best_y = np.inf
    for _, row in ordered.iterrows():
        if row[y] < best_y:
            frontier_rows.append(row)
            best_y = row[y]
    return pd.DataFrame(frontier_rows).reset_index(drop=True)


# ---------------------------------------------------------------------------
# correlation matrices (Figure 2B / Figure S4)

def correlation_matrix(query_df, value_col, sites_index, column_var):
    """Correlation matrix of value_col across the levels of column_var. Rows are the shared sites
    (sites_index, e.g. ["solute", "site"]); columns are the column_var values (e.g. "solvent" or
    "sap_nmr_method"). Returns a square DataFrame of Pearson correlations between the columns,
    computed on the sites where both columns have a value."""
    pivot = query_df.pivot_table(index=sites_index, columns=column_var, values=value_col)
    return pivot.corr()


# Composite-formula ablation workbook, "Correlations Between Features": correlations among the
# composite features within one nucleus and solvent.
FEATURE_CORRELATION_COLUMNS = ("stationary", "pcm", "desmond", "desmond_vib", "qcd")


def feature_correlation_matrix(query_df, nucleus, solvent, cols=FEATURE_CORRELATION_COLUMNS, squared=False):
    """Pearson correlation matrix (R^2 if squared=True) between the feature columns, within one
    nucleus and solvent, over sites where both features have a value. query_df must be filtered to a
    single (sap_nmr_method, sap_basis, sap_geometry_type); ablations_feature_correlations does that."""
    sub = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == solvent)][list(cols)]
    sub = sub.dropna(how="all")
    if len(sub) < 2:
        return pd.DataFrame(np.nan, index=cols, columns=cols)
    corr = sub.corr(method="pearson")
    return corr ** 2 if squared else corr


def average_feature_correlation_matrix(query_df, nucleus, solvents, cols=FEATURE_CORRELATION_COLUMNS,
                                       squared=False):
    """Cell-by-cell nanmean of feature_correlation_matrix over the solvents (the ablation workbook's
    solvent-averaged matrix). nanmean so a solvent with too few sites for one feature pair drops out
    of that cell alone, not the whole average. Same single-level-of-theory requirement."""
    mats = [feature_correlation_matrix(query_df, nucleus, s, cols, squared) for s in solvents]
    stacked = np.stack([m.to_numpy() for m in mats])
    with np.errstate(invalid="ignore"):
        avg = np.nanmean(stacked, axis=0)
    return pd.DataFrame(avg, index=mats[0].index, columns=mats[0].columns)


def ablations_feature_correlations(query_df_dft, nucleus, solvents=None):
    """Solvent-averaged feature correlations for one nucleus from the unfiltered
    load_query_df_dft(...) output: filters to the reference level
    (MAGNET_PCM_OUTPUT_METHODS[nucleus], pcSseg2, aimnet2), then returns {"r": Pearson R matrix,
    "r2": R^2 matrix}. The single-level filter is required; pooling levels of theory gives wrong
    numbers."""
    solvents = list(solvents) if solvents is not None else sorted(query_df_dft["solvent"].unique())
    method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
    sub = query_df_dft[(query_df_dft["nucleus"] == nucleus) & (query_df_dft["sap_nmr_method"] == method)
                       & (query_df_dft["sap_basis"] == "pcSseg2") & (query_df_dft["sap_geometry_type"] == "aimnet2")]
    return {"r": average_feature_correlation_matrix(sub, nucleus, solvents),
            "r2": average_feature_correlation_matrix(sub, nucleus, solvents, squared=True)}


def pcm_desmond_correlation_by_solvent(query_df_dft, solvents=None):
    """The ablation workbook's PCM-vs-Desmond table: per nucleus and solvent, the Pearson correlation
    between the PCM (implicit) and Desmond (explicit) corrections. Filters to the reference level
    per nucleus itself (same as ablations_feature_correlations). Returns a DataFrame indexed by
    nucleus, one column per solvent."""
    solvents = list(solvents) if solvents is not None else sorted(query_df_dft["solvent"].unique())
    rows = {}
    for nucleus in ("H", "C"):
        method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
        nuc_df = query_df_dft[(query_df_dft["nucleus"] == nucleus) & (query_df_dft["sap_nmr_method"] == method)
                              & (query_df_dft["sap_basis"] == "pcSseg2") & (query_df_dft["sap_geometry_type"] == "aimnet2")]
        row = {}
        for solvent in solvents:
            sub = nuc_df[nuc_df["solvent"] == solvent][["pcm", "desmond"]].dropna()
            row[solvent] = float(sub["pcm"].corr(sub["desmond"])) if len(sub) > 1 else float("nan")
        rows[nucleus] = row
    return pd.DataFrame(rows).T


# ---------------------------------------------------------------------------
# PCM benefit (Figure 3A / Figure S5): how much a correction reduces test error

def median_test_rmse(results_df, by):
    """Median test_RMSE grouped by the given columns (e.g. ["solvent", "formula"] or
    ["sap_nmr_method", "formula"]). A small convenience used by several figures."""
    return results_df.groupby(by)["test_RMSE"].median().reset_index()


# ---------------------------------------------------------------------------
# Figure 3 panel data (Explicit Solvation is Critical for Accuracy)

# the three solvent classes Figure 3 / S6 group by
SOLVENT_GROUPS = {
    "Polar Aprotic": ["chloroform", "dichloromethane", "tetrahydrofuran",
                      "acetonitrile", "dimethylsulfoxide", "acetone"],
    "Polar Protic": ["methanol", "TIP4P", "trifluoroethanol"],
    "Aromatic": ["benzene", "toluene", "chlorobenzene"],
}


def fig3a_pcm_benefit(query_df_dft, solvents, n_splits, solutes, nucleus="H", n_test=10):
    """Panel 3A data: for every DFT method/basis/geometry, the seeded test RMSE with and without the
    implicit-solvent term ("stationary" vs the single-column "stationary_plus_pcm"). The boxplot
    shows how much the PCM term lowers error across methods. One nucleus at a time (proton and
    carbon shieldings sit on different scales, so they are never pooled in a fit). Pass a query_df
    that has been through add_composite_columns. Returns the tidy run_fits results (one row per
    method/solvent/formula/seed)."""
    sub = query_df_dft[query_df_dft["nucleus"] == nucleus]
    return run_fits(sub, solvents, ["stationary", "stationary_plus_pcm"],
                    n_splits=n_splits, solutes=solutes,
                    group_cols=["sap_nmr_method", "sap_basis", "sap_geometry_type"], n_test=n_test)


def fig3a_pcm_benefit_by_solvent(query_df_dft, solvents, n_splits, solutes, nucleus="H", n_test=10):
    """Panel 3A data, split by solvent: for every DFT method and solvent, the per-split percent
    benefit of adding the implicit-solvent (PCM) term, 100 * (stationary - stationary_plus_pcm) /
    stationary. A positive value means PCM lowered test RMSE for that split; a negative value means
    PCM made it worse. Unlike fig3a_pcm_benefit (which reports raw RMSE pooled across solvents),
    this keeps one row per (method, solvent, seed), so a solvent such as benzene can be shown
    separately from chloroform and the aromatic-solvent-specific PCM penalty stays visible instead
    of being averaged away."""
    results = fig3a_pcm_benefit(query_df_dft, solvents, n_splits, solutes,
                                nucleus=nucleus, n_test=n_test)
    pivot = results.pivot_table(
        index=["sap_nmr_method", "sap_basis", "sap_geometry_type", "solvent", "seed"],
        columns="formula", values="test_RMSE").reset_index()
    pivot["percent_benefit"] = (100.0 * (pivot["stationary"] - pivot["stationary_plus_pcm"])
                                / pivot["stationary"])
    return pivot


def fig3b_shift_differences(query_df_dft, method, basis, geometry, nucleus="H",
                            x_solvent="methanol", y_solvent="benzene", reference="chloroform"):
    """Panel 3B data: per site, the experimental solvent-induced shift differences relative to a
    reference solvent (x = shift in x_solvent minus reference, y = shift in y_solvent minus
    reference) and the implicit-solvent (PCM) prediction of the same differences. One DFT method.
    The scatter compares whether PCM reproduces the measured solvent-to-solvent differences."""
    sub = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
                       & (query_df_dft["sap_basis"] == basis)
                       & (query_df_dft["sap_geometry_type"] == geometry)
                       & (query_df_dft["nucleus"] == nucleus)]
    cols = ["solute", "site", "experimental", "pcm"]
    ref = sub[sub["solvent"] == reference][cols]
    xs = sub[sub["solvent"] == x_solvent][cols]
    ys = sub[sub["solvent"] == y_solvent][cols]
    merged = (xs.merge(ref, on=["solute", "site"], suffixes=("_x", "_ref"))
                .merge(ys, on=["solute", "site"]))
    merged.columns = ["solute", "site", "exp_x", "pcm_x", "exp_ref", "pcm_ref", "exp_y", "pcm_y"]
    merged["exp_diff_x"] = merged["exp_x"] - merged["exp_ref"]
    merged["exp_diff_y"] = merged["exp_y"] - merged["exp_ref"]
    merged["pcm_diff_x"] = merged["pcm_x"] - merged["pcm_ref"]
    merged["pcm_diff_y"] = merged["pcm_y"] - merged["pcm_ref"]
    return merged.dropna(subset=["exp_diff_x", "exp_diff_y"]).reset_index(drop=True)


def fig3d_formula_regressions(query_df_dft, method, basis, geometry, formulas, solvents,
                              n_splits, solutes, nucleus="H", n_test=10):
    """Panel 3D data: for one DFT method, the seeded test RMSE of an implicit-vs-explicit formula
    ladder per solvent. Pass a query_df that has been through add_composite_columns. Returns the
    tidy run_fits results (one row per solvent/formula/seed)."""
    one = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
                       & (query_df_dft["sap_basis"] == basis)
                       & (query_df_dft["sap_geometry_type"] == geometry)
                       & (query_df_dft["nucleus"] == nucleus)]
    return run_fits(one, solvents, formulas, n_splits=n_splits, solutes=solutes, n_test=n_test)


# ---------------------------------------------------------------------------
# Figure 4 + SI S16/S17/S18 (solvent-induced shifts: experiment vs prediction)

def add_solvent_mean(query_df, group_cols=("solute", "site", "nucleus")):
    """Append a 'solvent_mean' pseudo-solvent: for each site, the mean over the real solvents of the
    numeric columns. Used as the solvent-averaged reference for Figure S18. Returns a new DataFrame
    with the extra rows."""
    means = query_df.groupby(list(group_cols)).mean(numeric_only=True).reset_index()
    means["solvent"] = "solvent_mean"
    return pd.concat([query_df, means], ignore_index=True)


def solvent_pair_differences(query_df, check_solvent, reference_solvent, nucleus="H",
                             explicit="desmond"):
    """Per (solute, site), the experimental and predicted correction differences between one solvent
    and a reference solvent (Figure 4): the experimental difference is reference minus check, and
    each correction difference (implicit PCM, explicit, and explicit+vibrations) is check minus
    reference. One DFT method's columns must already be selected."""
    a = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == check_solvent)]
    b = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == reference_solvent)]
    merged = a.merge(b, on=["solute", "site"], suffixes=(f"_{check_solvent}", f"_{reference_solvent}"))
    c, r, e = check_solvent, reference_solvent, explicit
    out = pd.DataFrame({
        "solute": merged["solute"], "site": merged["site"],
        "reference": reference_solvent, "solvent": check_solvent,
        "exp_diff": merged[f"experimental_{r}"] - merged[f"experimental_{c}"],
        "implicit_diff": merged[f"pcm_{c}"] - merged[f"pcm_{r}"],
        "explicit_diff": merged[f"{e}_{c}"] - merged[f"{e}_{r}"],
        "explicit_vib_diff": ((merged[f"{e}_{c}"] + merged[f"{e}_vib_{c}"])
                              - (merged[f"{e}_{r}"] + merged[f"{e}_vib_{r}"])),
    })
    return out.dropna(subset=["exp_diff"]).reset_index(drop=True)


def solvent_induced_shifts(query_df, reference, solvents, nucleus="H", explicit="desmond"):
    """The full set of solvent-vs-reference differences for one reference solvent (the data behind
    Figure S16 with chloroform, S17 with benzene, S18 with solvent_mean): every solvent except the
    reference, stacked. query_df should already include the solvent_mean rows if reference is
    'solvent_mean' (see add_solvent_mean)."""
    frames = [solvent_pair_differences(query_df, solvent, reference, nucleus, explicit)
              for solvent in solvents if solvent != reference]
    return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()


def fit_differences_to_experimental(diff_df, predicted_col):
    """Fit exp_diff = slope * predicted_col + intercept (the Figure 4C fit of the experimental
    solvent-induced shift differences to a correction's predicted differences) and return
    {slope, intercept, rmse, n}. Rows missing either value are dropped."""
    data = diff_df.dropna(subset=["exp_diff", predicted_col])
    x = data[predicted_col].to_numpy(dtype=float)
    y = data["exp_diff"].to_numpy(dtype=float)
    intercept, slope = linear_fit_1d(x, y)
    pred = intercept + slope * x
    return {"intercept": intercept, "slope": slope, "rmse": rmse(pred, y), "n": int(len(x))}