File size: 50,659 Bytes
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 | """Tests for analysis/code/delta22.py (the no-plotting analysis module).
The analysis functions operate on flat query DataFrames, so these tests build small synthetic
tables with a known linear relationship and check the fitting harness, the seeded splits, the
Pareto frontier, and the correlation matrix. One test proves the fast numpy `fit` gives the same
coefficients and RMSE as the original statsmodels fit.
"""
import os
import random
import sys
import numpy as np
import pandas as pd
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta22")) # delta22_reader
sys.path.insert(0, HERE)
import paths
import delta22 as D # noqa: E402
def make_query_df(n_solutes=12, solvents=("chloroform", "benzene"), noise=0.0, seed=0):
"""A flat query table whose experimental value is exactly 2*stationary + 0.5*pcm + 1 (plus
optional noise), so a model with both terms fits near-perfectly and 'stationary' alone leaves
the 0.5*pcm residual."""
rng = np.random.default_rng(seed)
rows = []
for si in range(n_solutes):
solute = f"m{si:02d}"
for solvent in solvents:
for k in range(3):
stationary = rng.normal(100, 20)
pcm = rng.normal(0, 2)
experimental = 2.0 * stationary + 0.5 * pcm + 1.0 + rng.normal(0, noise)
rows.append({
"solute": solute, "solvent": solvent, "nucleus": "H",
"site": f"{solute}_{solvent}_{k}", "experimental": experimental,
"stationary": stationary, "pcm": pcm,
"desmond": rng.normal(0, 2), "openMM": rng.normal(0, 2),
"qcd": rng.normal(0, 1), "desmond_vib": rng.normal(0, 1),
"openMM_vib": rng.normal(0, 1),
})
return pd.DataFrame(rows)
def test_fit_recovers_known_line():
df = make_query_df(noise=0.0)
train_rmse, test_rmse, params = D.fit(df, df, "stationary + pcm")
assert params["Intercept"] == pytest.approx(1.0, abs=1e-6)
assert params["stationary"] == pytest.approx(2.0, abs=1e-6)
assert params["pcm"] == pytest.approx(0.5, abs=1e-6)
assert train_rmse == pytest.approx(0.0, abs=1e-6)
assert test_rmse == pytest.approx(0.0, abs=1e-6)
def test_fit_matches_statsmodels():
df = make_query_df(noise=1.0, seed=3)
train = df[df["solute"] <= "m07"]
test = df[df["solute"] > "m07"]
for formula in ["stationary", "stationary + pcm", "stationary + pcm + qcd"]:
tr, te, params = D.fit(train, test, formula)
s_tr, s_te, s_params = D._fit_statsmodels(train, test, formula)
assert tr == pytest.approx(s_tr, abs=1e-8)
assert te == pytest.approx(s_te, abs=1e-8)
for name in s_params.index:
assert params[name] == pytest.approx(s_params[name], abs=1e-6)
def test_fit_drops_missing_like_statsmodels():
# fitting must drop rows with a missing response or predictor, exactly as statsmodels does,
# and recover the same coefficients. (Scoring is checked on a clean test set; statsmodels would
# otherwise NaN-poison an RMSE computed over rows that have missing predictors.)
clean = make_query_df(noise=0.5, seed=5).reset_index(drop=True)
train = clean.copy()
train.loc[0, "experimental"] = np.nan # missing response
train.loc[1, "pcm"] = np.nan # missing predictor
_train_err, te, params = D.fit(train, clean, "stationary + pcm")
s_tr, s_te, s_params = D._fit_statsmodels(train, clean, "stationary + pcm")
for name in s_params.index:
assert params[name] == pytest.approx(s_params[name], abs=1e-6)
assert te == pytest.approx(s_te, abs=1e-8)
def test_generate_solute_splits_seeded_and_disjoint():
df = make_query_df()
solutes = [f"m{i:02d}" for i in range(12)]
splits = D.generate_solute_splits(5, df, "chloroform", solutes, n_test=4)
assert len(splits) == 5
# reproduce the exact seed logic for split 0
random.seed(0 + 100)
expected = list(solutes)
random.shuffle(expected)
train_df, test_df = splits[0]
assert set(test_df["solute"].unique()) == set(expected[:4])
assert set(train_df["solute"].unique()) == set(expected[4:])
# a solute is never in both halves, and only the requested solvent is present
assert set(test_df["solute"]).isdisjoint(set(train_df["solute"]))
assert set(test_df["solvent"].unique()) == {"chloroform"}
# deterministic: same call gives the same partition
again = D.generate_solute_splits(5, df, "chloroform", solutes, n_test=4)
assert set(again[0][1]["solute"].unique()) == set(test_df["solute"].unique())
def test_run_fits_more_terms_lower_error():
df = make_query_df(noise=0.3, seed=1)
solutes = sorted(df["solute"].unique())
results = D.run_fits(df, ["chloroform", "benzene"], D.STATIONARY_VS_PCM,
n_splits=5, solutes=solutes, n_test=4)
assert set(results.columns) >= {"solvent", "formula", "seed", "train_RMSE", "test_RMSE"}
med = D.median_test_rmse(results, ["formula"]).set_index("formula")["test_RMSE"]
assert med["stationary + pcm"] < med["stationary"] # the pcm term genuinely helps
def test_run_fits_group_cols():
df = make_query_df(noise=0.3, seed=2)
df = pd.concat([df.assign(sap_nmr_method="wp04"),
df.assign(sap_nmr_method="dsd_pbep86")], ignore_index=True)
solutes = sorted(df["solute"].unique())
results = D.run_fits(df, ["chloroform"], ["stationary + pcm"], n_splits=3,
solutes=solutes, group_cols=["sap_nmr_method"], n_test=4)
assert "sap_nmr_method" in results.columns
assert set(results["sap_nmr_method"].unique()) == {"wp04", "dsd_pbep86"}
def test_fit_coefficients_per_seed():
df = make_query_df(noise=0.4, seed=7)
solutes = sorted(df["solute"].unique())
coeffs = D.fit_coefficients(df, "chloroform", "stationary + pcm", solutes,
n_splits=6, n_test=4)
assert len(coeffs) == 6
assert {"solvent", "seed", "Intercept", "stationary", "pcm"}.issubset(coeffs.columns)
# the recovered slope should sit near the true value of 2
assert coeffs["stationary"].mean() == pytest.approx(2.0, abs=0.1)
def test_full_fit_coefficients_shape_and_values():
df = make_query_df(noise=0.0, solvents=("chloroform", "benzene"))
coeffs = D.full_fit_coefficients(df, ["chloroform", "benzene"], "stationary + pcm")
assert list(coeffs.columns) == ["chloroform", "benzene"]
assert set(coeffs.index) == {"Intercept", "stationary", "pcm"}
# the data is exactly 2*stationary + 0.5*pcm + 1, recovered per solvent
for solvent in ["chloroform", "benzene"]:
assert coeffs.loc["Intercept", solvent] == pytest.approx(1.0, abs=1e-6)
assert coeffs.loc["stationary", solvent] == pytest.approx(2.0, abs=1e-6)
assert coeffs.loc["pcm", solvent] == pytest.approx(0.5, abs=1e-6)
# ---- opt-in: checked against the STORED composite-model coefficients ----
# These coefficients live in applications.hdf5's composite_model/ols_coefficients group. Re-fitting
# the released delta-22 data with the fast numpy harness (nitromethane dropped) must reproduce them
# to within the data's integer-encoding precision (~5e-4 after error propagation through the small
# corrections).
REAL_H5 = paths.dataset_file("delta22", file=__file__)
REAL_XLSX = os.path.join(HERE, "..", "..", "data", "delta22", "delta22_experimental.xlsx")
APPLICATIONS_H5 = paths.dataset_file("applications", file=__file__)
@pytest.mark.skipif(
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX) and os.path.exists(APPLICATIONS_H5)),
reason="real delta22.hdf5 / experimental xlsx / applications.hdf5 not present")
def test_full_fit_reproduces_stored_composite_coefficients():
import io
import h5py
query = D.load_query_df_nn(REAL_H5, REAL_XLSX, exclude_solutes=["nitromethane"], verbose=False)
worst = 0.0
with h5py.File(APPLICATIONS_H5, "r") as f:
ols = f["composite_model"]["ols_coefficients"]
for nucleus in ["H", "C"]:
sub = query[query["nucleus"] == nucleus]
for formula in ["stationary", "stationary + openMM", "stationary + openMM + qcd"]:
tag = "_AND_".join(term.strip() for term in formula.split("+"))
raw = np.array(ols[tag][nucleus]).item()
raw = raw.decode() if hasattr(raw, "decode") else raw
stored = pd.read_csv(io.StringIO(raw)).set_index("parameter")
mine = D.full_fit_coefficients(sub, list(stored.columns), formula)
mine = mine.rename(index={"Intercept": "intercept"})
for solvent in stored.columns:
for param in stored.index:
worst = max(worst, abs(mine.loc[param, solvent] - stored.loc[param, solvent]))
assert worst < 5e-4, f"worst coefficient diff {worst:.2e} exceeds the int32 precision floor"
def make_dft_query_df(methods=("wp04", "dsd_pbep86"), solvents=("chloroform", "methanol", "benzene"),
n_solutes=12, seed=0):
"""A synthetic DFT query table with method/basis/geometry columns. Experimental is
31 - (stationary + pcm) + tiny noise, i.e. a single shared slope on the gas shielding plus its
implicit-solvent correction (the real structure), so the single-column 'stationary_plus_pcm'
model fits well and 'stationary' alone leaves the pcm residual. desmond/openMM/qcd are
independent noise that does not help."""
rng = np.random.default_rng(seed)
rows = []
for method in methods:
for si in range(n_solutes):
solute = f"m{si:02d}"
for solvent in solvents:
for k in range(2):
stationary = rng.normal(100, 20)
pcm = rng.normal(0, 2)
rows.append({
"solute": solute, "solvent": solvent, "nucleus": "H",
"site": f"{solute}_{k}", "sap_nmr_method": method,
"sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
"experimental": 31.0 - (stationary + pcm) + rng.normal(0, 0.05),
"stationary": stationary, "pcm": pcm,
"desmond": rng.normal(0, 1), "openMM": rng.normal(0, 1),
"qcd": rng.normal(0, 1), "desmond_vib": rng.normal(0, 1),
"openMM_vib": rng.normal(0, 1),
})
return pd.DataFrame(rows)
def test_add_composite_columns():
df = make_query_df()
out = D.add_composite_columns(df)
assert np.allclose(out["stationary_plus_pcm"], out["stationary"] + out["pcm"])
assert np.allclose(out["stationary_plus_pcm_plus_qcd"],
out["stationary"] + out["pcm"] + out["qcd"])
# an existing NN-style stationary_plus_pcm (with scaling factors) is preserved
nn = df.copy()
nn["stationary_plus_pcm"] = 999.0
kept = D.add_composite_columns(nn)
assert np.allclose(kept["stationary_plus_pcm"], 999.0)
def test_fig3b_shift_differences_exact():
# one method, three solvents; experimental/pcm chosen so the differences are known
rows = []
vals = {"chloroform": (1.0, 0.1), "methanol": (1.5, 0.4), "benzene": (0.7, -0.2)}
for solvent, (exp, pcm) in vals.items():
rows.append({"solute": "m0", "site": "s0", "nucleus": "H", "solvent": solvent,
"sap_nmr_method": "wp04", "sap_basis": "pcSseg2",
"sap_geometry_type": "aimnet2", "experimental": exp, "pcm": pcm})
df = pd.DataFrame(rows)
out = D.fig3b_shift_differences(df, "wp04", "pcSseg2", "aimnet2", nucleus="H",
x_solvent="methanol", y_solvent="benzene", reference="chloroform")
assert len(out) == 1
assert out["exp_diff_x"].iloc[0] == pytest.approx(1.5 - 1.0)
assert out["exp_diff_y"].iloc[0] == pytest.approx(0.7 - 1.0)
assert out["pcm_diff_x"].iloc[0] == pytest.approx(0.4 - 0.1)
assert out["pcm_diff_y"].iloc[0] == pytest.approx(-0.2 - 0.1)
def test_fig3a_and_fig3d_pcm_helps():
df = D.add_composite_columns(make_dft_query_df(seed=4))
solutes = sorted(df["solute"].unique())
solvents = ["chloroform", "methanol", "benzene"]
a = D.fig3a_pcm_benefit(df, solvents, n_splits=4, solutes=solutes, n_test=4)
assert {"sap_nmr_method", "solvent", "formula", "test_RMSE"}.issubset(a.columns)
med = a.groupby("formula")["test_RMSE"].median()
assert med["stationary_plus_pcm"] < med["stationary"] # PCM lowers error
d = D.fig3d_formula_regressions(df, "wp04", "pcSseg2", "aimnet2",
["stationary + pcm", "stationary + desmond"], solvents,
n_splits=4, solutes=solutes, n_test=4)
assert set(d["formula"].unique()) == {"stationary + pcm", "stationary + desmond"}
def test_fig3a_pcm_benefit_by_solvent_keeps_solvents_separate():
# chloroform: pcm carries a real (though not perfectly scaled) part of the signal, so adding it
# to the fit lowers test error. benzene: pcm is unrelated noise on top of an already-explained
# response, so adding it as a predictor only overfits and raises test error. A solvent-pooled
# average would blend these two directions away; fig3a_pcm_benefit_by_solvent must keep one row
# per solvent so both signs are visible.
rng = np.random.default_rng(11)
rows = []
n_solutes = 16
for si in range(n_solutes):
solute = f"m{si:02d}"
for solvent in ["chloroform", "benzene"]:
for k in range(3):
stationary = rng.normal(100, 20)
if solvent == "chloroform":
pcm = rng.normal(0, 2)
experimental = 31.0 - stationary - 0.8 * pcm + rng.normal(0, 0.05)
else:
pcm = rng.normal(0, 8) # large noise unrelated to experimental
experimental = 31.0 - stationary + rng.normal(0, 0.05)
rows.append({
"solute": solute, "solvent": solvent, "nucleus": "H",
"site": f"{solute}_{solvent}_{k}", "sap_nmr_method": "wp04",
"sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
"experimental": experimental, "stationary": stationary, "pcm": pcm,
"desmond": 0.0, "openMM": 0.0, "qcd": 0.0, "desmond_vib": 0.0, "openMM_vib": 0.0,
})
df = D.add_composite_columns(pd.DataFrame(rows))
solutes = sorted(df["solute"].unique())
out = D.fig3a_pcm_benefit_by_solvent(df, ["chloroform", "benzene"], n_splits=10,
solutes=solutes, n_test=6)
assert {"sap_nmr_method", "solvent", "seed", "percent_benefit"}.issubset(out.columns)
chloro_benefit = out[out["solvent"] == "chloroform"]["percent_benefit"].median()
benz_benefit = out[out["solvent"] == "benzene"]["percent_benefit"].median()
assert chloro_benefit > 0 # PCM helps in chloroform
assert benz_benefit < 0 # PCM hurts in benzene
def test_fig3a_does_not_pool_nuclei():
# H sites (~30 ppm) and C sites (~150 ppm) with different relationships. If fig3a pooled both
# nuclei into one fit the test RMSE would blow up; filtering to one nucleus keeps it small.
rng = np.random.default_rng(0)
rows = []
for si in range(12):
for solvent in ["chloroform", "methanol", "benzene"]:
for nucleus, base, slope, offset in [("H", 30, 1.0, 31.0), ("C", 150, 1.05, 200.0)]:
stationary = rng.normal(base, base * 0.1)
pcm = rng.normal(0, 1)
rows.append({
"solute": f"m{si:02d}", "site": f"m{si:02d}_{nucleus}", "nucleus": nucleus,
"solvent": solvent, "sap_nmr_method": "wp04", "sap_basis": "pcSseg2",
"sap_geometry_type": "aimnet2", "stationary": stationary, "pcm": pcm,
"experimental": offset - slope * (stationary + pcm) + rng.normal(0, 0.05),
"desmond": 0.0, "openMM": 0.0, "qcd": 0.0, "desmond_vib": 0.0, "openMM_vib": 0.0,
})
df = D.add_composite_columns(pd.DataFrame(rows))
solutes = sorted(df["solute"].unique())
res = D.fig3a_pcm_benefit(df, ["chloroform", "methanol", "benzene"], n_splits=4,
solutes=solutes, nucleus="H", n_test=4)
assert res["test_RMSE"].median() < 1.0 # would be tens of ppm if C were pooled in
def test_add_solvent_mean():
df = pd.DataFrame({
"solute": ["m0", "m0"], "site": ["s0", "s0"], "nucleus": ["H", "H"],
"solvent": ["chloroform", "benzene"], "experimental": [2.0, 4.0], "pcm": [0.2, 0.6],
})
out = D.add_solvent_mean(df)
mean_row = out[out["solvent"] == "solvent_mean"]
assert len(mean_row) == 1
assert mean_row["experimental"].iloc[0] == pytest.approx(3.0) # mean(2, 4)
assert mean_row["pcm"].iloc[0] == pytest.approx(0.4) # mean(0.2, 0.6)
def test_solvent_pair_differences_exact():
def row(solvent, exp, pcm, des, dvib):
return {"solute": "m0", "site": "s0", "nucleus": "H", "solvent": solvent,
"experimental": exp, "pcm": pcm, "desmond": des, "desmond_vib": dvib}
df = pd.DataFrame([row("methanol", 2.0, 0.4, 0.5, 0.1), # check solvent
row("chloroform", 1.0, 0.1, 0.2, 0.05)]) # reference
out = D.solvent_pair_differences(df, "methanol", "chloroform", nucleus="H", explicit="desmond")
assert len(out) == 1
r = out.iloc[0]
assert r["exp_diff"] == pytest.approx(1.0 - 2.0) # reference - check
assert r["implicit_diff"] == pytest.approx(0.4 - 0.1) # check - reference
assert r["explicit_diff"] == pytest.approx(0.5 - 0.2)
assert r["explicit_vib_diff"] == pytest.approx((0.5 + 0.1) - (0.2 + 0.05))
def test_solvent_induced_shifts_stacks_solvents():
rng = np.random.default_rng(0)
rows = []
for solvent in ["chloroform", "methanol", "benzene"]:
for si in range(3):
rows.append({"solute": f"m{si}", "site": f"m{si}_s", "nucleus": "H", "solvent": solvent,
"experimental": rng.normal(), "pcm": rng.normal(), "desmond": rng.normal(),
"desmond_vib": rng.normal()})
df = pd.DataFrame(rows)
out = D.solvent_induced_shifts(df, "chloroform", ["chloroform", "methanol", "benzene"],
nucleus="H", explicit="desmond")
# two non-reference solvents x 3 sites = 6 rows, reference column constant
assert len(out) == 6
assert set(out["solvent"].unique()) == {"methanol", "benzene"}
assert (out["reference"] == "chloroform").all()
def test_fit_differences_to_experimental():
# exp_diff = -2 * predicted + 0.3 exactly
pred = np.linspace(-1, 1, 20)
df = pd.DataFrame({"exp_diff": -2.0 * pred + 0.3, "explicit_diff": pred})
fit = D.fit_differences_to_experimental(df, "explicit_diff")
assert fit["slope"] == pytest.approx(-2.0, abs=1e-9)
assert fit["intercept"] == pytest.approx(0.3, abs=1e-9)
assert fit["rmse"] == pytest.approx(0.0, abs=1e-9)
assert fit["n"] == 20
def test_explicit_correction_pairs_dedups_and_drops_nan():
rows = []
for method in ["wp04", "dsd_pbep86"]: # same site tiled across methods
rows.append({"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
"sap_nmr_method": method, "desmond": 0.5, "openMM": 0.4})
rows.append({"solute": "m1", "site": "s1", "nucleus": "H", "solvent": "chloroform",
"sap_nmr_method": "wp04", "desmond": np.nan, "openMM": 0.2}) # dropped (NaN)
out = D.explicit_correction_pairs(pd.DataFrame(rows), "chloroform", "H")
assert len(out) == 1
assert out.iloc[0]["desmond"] == pytest.approx(0.5)
assert out.iloc[0]["openMM"] == pytest.approx(0.4)
def test_compare_dft_nn():
# method-tiled correction; dedup by keys, then error = NN - DFT
dft = pd.DataFrame([
{"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": 0.10},
{"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "mp2", "qcd": 0.10},
{"solute": "m1", "site": "s1", "nucleus": "C", "sap_nmr_method": "wp04", "qcd": 0.50},
])
nn = pd.DataFrame([
{"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": 0.13},
{"solute": "m1", "site": "s1", "nucleus": "C", "sap_nmr_method": "MagNET", "qcd": 0.40},
])
out = D.compare_dft_nn(dft, nn, "qcd", keys=("solute", "site", "nucleus"))
assert len(out) == 2
h = out[out["nucleus"] == "H"].iloc[0]
assert h["qcd_dft"] == pytest.approx(0.10)
assert h["qcd_nn"] == pytest.approx(0.13)
assert h["error"] == pytest.approx(0.13 - 0.10)
def test_qcd_correction_by_site_filters_nucleus_and_sorts_by_solute():
# three H sites across two solutes (out of alphabetical order) plus one C site that must be
# dropped; check the nucleus filter, the solute sort, and that the DFT/NN values pass through.
dft = pd.DataFrame([
{"solute": "zzz", "site": "s0", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.70},
{"solute": "AcOH", "site": "s1", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.65},
{"solute": "AcOH", "site": "s2", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.80},
{"solute": "AcOH", "site": "s3", "nucleus": "C", "sap_nmr_method": "wp04", "qcd": -4.0},
])
nn = pd.DataFrame([
{"solute": "zzz", "site": "s0", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.72},
{"solute": "AcOH", "site": "s1", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.64},
{"solute": "AcOH", "site": "s2", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.79},
{"solute": "AcOH", "site": "s3", "nucleus": "C", "sap_nmr_method": "MagNET", "qcd": -4.1},
])
out = D.qcd_correction_by_site(dft, nn, nucleus="H")
assert len(out) == 3 # the C row is dropped
assert (out["nucleus"] == "H").all()
assert list(out["solute"]) == ["AcOH", "AcOH", "zzz"] # sorted, "zzz" sorts after "AcOH"
row = out[out["site"] == "s1"].iloc[0]
assert row["qcd_dft"] == pytest.approx(-0.65)
assert row["qcd_nn"] == pytest.approx(-0.64)
assert row["error"] == pytest.approx(-0.64 - (-0.65))
def test_compare_dft_nn_by_engine_stacks_both_engines():
# one shared site, a different desmond/openMM value each, so the two engines land in separate
# rows with a common dft_value/nn_value pair of columns instead of engine-specific column names
dft = pd.DataFrame([
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
"sap_nmr_method": "wp04", "desmond": 0.10, "openMM": 0.20},
])
nn = pd.DataFrame([
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
"sap_nmr_method": "MagNET", "desmond": 0.13, "openMM": 0.17},
])
out = D.compare_dft_nn_by_engine(dft, nn)
assert len(out) == 2
assert set(out["engine"]) == {"desmond", "openMM"}
assert list(out.columns) == ["solute", "site", "nucleus", "solvent", "dft_value", "nn_value",
"error", "engine"]
desmond_row = out[out["engine"] == "desmond"].iloc[0]
assert desmond_row["dft_value"] == pytest.approx(0.10)
assert desmond_row["nn_value"] == pytest.approx(0.13)
assert desmond_row["error"] == pytest.approx(0.13 - 0.10)
openmm_row = out[out["engine"] == "openMM"].iloc[0]
assert openmm_row["dft_value"] == pytest.approx(0.20)
assert openmm_row["nn_value"] == pytest.approx(0.17)
assert openmm_row["error"] == pytest.approx(0.17 - 0.20)
def test_explicit_correction_dft_nn_pairs_merges_sources():
dft = pd.DataFrame([
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
"sap_nmr_method": "wp04", "desmond": 0.5, "openMM": 0.4},
{"solute": "m1", "site": "s1", "nucleus": "H", "solvent": "chloroform",
"sap_nmr_method": "wp04", "desmond": -0.2, "openMM": -0.3},
])
nn = pd.DataFrame([
{"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
"sap_nmr_method": "MagNET", "desmond": 0.55, "openMM": 0.42},
# site s1 has no NN row, so it should not appear in the merged (inner-join) result
])
out = D.explicit_correction_dft_nn_pairs(dft, nn, "chloroform", "H")
assert len(out) == 1
row = out.iloc[0]
assert row["solute"] == "m0"
assert row["desmond_dft"] == pytest.approx(0.5)
assert row["openMM_dft"] == pytest.approx(0.4)
assert row["desmond_nn"] == pytest.approx(0.55)
assert row["openMM_nn"] == pytest.approx(0.42)
def test_si_s13d_fitting_accuracy_selects_formula_by_nucleus_and_engine():
# stationary + qcd + desmond exactly reproduces experimental for H; stationary + desmond_vib +
# openMM exactly reproduces it for C. Any other combination of (nucleus, engine) leaves a
# nonzero residual from the term the formula does NOT include, so a near-zero test RMSE proves
# si_s13d_fitting_accuracy picked the QCD-based formula for H and the vib-based one for C, and
# picked the desmond/openMM explicit term matching the "engine" column.
rng = np.random.default_rng(5)
solvents = ["chloroform", "methanol", "TIP4P", "benzene"]
rows = []
for si in range(14):
solute = f"m{si:02d}"
for solvent in solvents:
for k in range(2):
stationary = rng.normal(50, 5)
qcd = rng.normal(0, 1)
desmond = rng.normal(0, 1)
desmond_vib = rng.normal(0, 1)
openMM = rng.normal(0, 1)
openMM_vib = rng.normal(0, 1)
rows.append({
"solute": solute, "solvent": solvent, "site": f"{solute}_{k}",
"sap_nmr_method": "dsd_pbep86", "sap_basis": "pcSseg3",
"sap_geometry_type": "pbe0_tz",
"stationary": stationary, "qcd": qcd, "pcm": 0.0,
"desmond": desmond, "openMM": openMM,
"desmond_vib": desmond_vib, "openMM_vib": openMM_vib,
"nucleus": "H",
"experimental": stationary + qcd + desmond,
})
rows.append({
"solute": solute, "solvent": solvent, "site": f"{solute}_{k}",
"sap_nmr_method": "dsd_pbep86", "sap_basis": "pcSseg3",
"sap_geometry_type": "pbe0_tz",
"stationary": stationary, "qcd": qcd, "pcm": 0.0,
"desmond": desmond, "openMM": openMM,
"desmond_vib": desmond_vib, "openMM_vib": openMM_vib,
"nucleus": "C",
"experimental": stationary + openMM_vib + openMM,
})
dft = pd.DataFrame(rows)
nn = dft.copy() # same synthetic values for both sources; the exact-fit check is source-agnostic
solutes = sorted(dft["solute"].unique())
h_results = D.si_s13d_fitting_accuracy(dft, nn, solvents, n_splits=3, solutes=solutes,
nucleus="H")
h_desmond = h_results[h_results["engine"] == "desmond"]["test_RMSE"]
assert (h_desmond < 1e-8).all()
h_openmm = h_results[h_results["engine"] == "openMM"]["test_RMSE"]
assert (h_openmm > 0.1).all() # missing the desmond term used to build "experimental"
c_results = D.si_s13d_fitting_accuracy(dft, nn, solvents, n_splits=3, solutes=solutes,
nucleus="C")
c_openmm = c_results[c_results["engine"] == "openMM"]["test_RMSE"]
assert (c_openmm < 1e-8).all()
c_desmond = c_results[c_results["engine"] == "desmond"]["test_RMSE"]
assert (c_desmond > 0.1).all() # missing the openMM term used to build "experimental"
def test_pcm_benefit_per_solvent_positive():
df = make_dft_query_df(seed=3) # experimental = 31 - (stationary + pcm), so PCM genuinely helps
solutes = sorted(df["solute"].unique())
benefit = D.pcm_benefit_per_solvent(df, "wp04", "pcSseg2", "aimnet2",
["chloroform", "methanol", "benzene"], n_splits=4,
solutes=solutes, nucleus="H", n_test=4)
assert set(benefit.index) == {"chloroform", "methanol", "benzene"}
assert (benefit > 0).all() # adding PCM lowers the error in every solvent
def test_frame_convergence_math():
# 3 atoms, 5 frames; site = atoms 0 and 2; solvated - isolated = a known per-frame value
n_frames = 5
isolated = np.zeros((n_frames, 3))
solvated = np.zeros((n_frames, 3))
per_frame_truth = np.array([0.2, 0.4, np.nan, 0.6, 0.8])
for f in range(n_frames):
solvated[f, 0] = per_frame_truth[f]
solvated[f, 2] = per_frame_truth[f]
solvated[2, :] = np.nan # frame 2 invalid
perturbed = np.stack([isolated, solvated], axis=-1) # (5, 3, 2)
fc = D.frame_corrections(perturbed, [0, 2])
assert fc[0] == pytest.approx(0.2)
assert np.isnan(fc[2])
ra = D.running_average(fc)
# running average over the valid frames 0.2, 0.4, (skip), 0.6, 0.8
assert ra[0] == pytest.approx(0.2)
assert ra[1] == pytest.approx(0.3)
assert ra[2] == pytest.approx(0.3) # invalid frame does not move the average
assert ra[4] == pytest.approx((0.2 + 0.4 + 0.6 + 0.8) / 4)
def test_autocorrelation():
ac = D.autocorrelation(np.array([1.0, -1.0, 1.0, -1.0, 1.0, -1.0]), max_lag=2)
assert ac[0] == pytest.approx(1.0) # lag 0 is always 1
assert ac[1] < 0 # alternating -> negative lag-1 autocorrelation
def test_frame_validity():
# 4 frames, 2 atoms: frame 0 fully computed, frame 1 only the isolated value, frame 2 all NaN
# (skipped entirely), frame 3 fully computed. A frame counts as valid if anything was computed.
perturbed = np.full((4, 2, 2), np.nan)
perturbed[0] = [[1.0, 2.0], [3.0, 4.0]]
perturbed[1, 0, 0] = 5.0
perturbed[3] = [[1.0, 2.0], [3.0, 4.0]]
valid = D.frame_validity(perturbed)
assert list(valid) == [True, True, False, True]
def test_frame_validity_grid_pads_to_the_longest_solvent(monkeypatch):
# two "solvents" with different trajectory lengths; the grid must pad the shorter one with
# False past its own frame count, and frame_counts must report each solvent's true length.
fake_data = {
"acetone": np.array([[[1.0, 2.0]], [[np.nan, np.nan]], [[1.0, 2.0]]]), # 3 frames, 1 invalid
"benzene": np.array([[[1.0, 2.0]], [[1.0, 2.0]]]), # 2 frames, all valid
}
def fake_load_perturbed_shieldings(hdf5_filepath, solute, solvent, explicit_solvation_model,
shield_type):
return fake_data[solvent]
monkeypatch.setattr(D, "load_perturbed_shieldings", fake_load_perturbed_shieldings)
grid, frame_counts = D.frame_validity_grid("unused.hdf5", "solute", ["acetone", "benzene"],
"openMM")
assert grid.shape == (2, 3)
assert list(frame_counts) == [3, 2]
assert list(grid[0]) == [True, False, True]
assert list(grid[1]) == [True, True, False] # padded with False past benzene's 2 frames
def test_pareto_frontier():
points = pd.DataFrame({
"label": ["a", "b", "c", "d", "e"],
"total_time": [1.0, 2.0, 3.0, 4.0, 5.0],
"test_RMSE": [5.0, 3.0, 4.0, 1.0, 2.0],
})
front = D.pareto_frontier(points, x="total_time", y="test_RMSE")
assert list(front["label"]) == ["a", "b", "d"] # c and e are dominated
def test_correlation_matrix():
# two solvents whose per-site pcm corrections are perfectly correlated
base = np.array([0.1, 0.5, -0.3, 0.7, -0.2, 0.4])
rows = []
for i, value in enumerate(base):
rows.append({"solute": f"m{i}", "site": f"s{i}", "solvent": "chloroform", "pcm": value})
rows.append({"solute": f"m{i}", "site": f"s{i}", "solvent": "benzene", "pcm": 2 * value + 1})
df = pd.DataFrame(rows)
corr = D.correlation_matrix(df, "pcm", ["solute", "site"], "solvent")
assert corr.loc["chloroform", "benzene"] == pytest.approx(1.0, abs=1e-9)
# --- Figure 2A / S1 (Pareto) ----------------------------------------------------------------
def test_pcm_conversion_factors_recovers_known_slope():
# build DFT rows where the output method's PCM in each solvent is a known multiple of the input
# method's chloroform PCM, so the recomputed factor must equal that multiple.
rng = np.random.default_rng(0)
multiples = {"chloroform": 1.0, "benzene": 0.65, "methanol": 1.4}
rows = []
for si in range(8):
ref = rng.normal(0, 2) # the input method's chloroform PCM for this site
for solvent, mult in multiples.items():
common = {"solute": f"m{si}", "site": f"m{si}_s", "nucleus": "H",
"sap_geometry_type": "aimnet2", "sap_basis": "pcSseg2", "solvent": solvent}
# input (reference) method: chloroform carries `ref`, other solvents irrelevant
rows.append({**common, "sap_nmr_method": D.MAGNET_PCM_INPUT_METHOD,
"pcm": ref if solvent == "chloroform" else rng.normal()})
# output method: this solvent's PCM is mult * the chloroform reference
rows.append({**common, "sap_nmr_method": "wp04", "pcm": mult * ref})
df = pd.DataFrame(rows)
factors = D.pcm_conversion_factors(df, "H", output_method="wp04")
for solvent, mult in multiples.items():
assert factors[solvent] == pytest.approx(mult, abs=1e-9)
def test_add_nn_stationary_plus_pcm():
df = pd.DataFrame({
"solute": ["m0", "m0"], "site": ["a", "b"], "nucleus": ["H", "C"],
"solvent": ["benzene", "benzene"], "stationary": [100.0, 50.0], "pcm": [2.0, 4.0],
})
factors = {"H": pd.Series({"benzene": 0.5}), "C": pd.Series({"benzene": 3.0})}
out = D.add_nn_stationary_plus_pcm(df, factors)
assert out["stationary_plus_pcm"].iloc[0] == pytest.approx(100.0 + 2.0 * 0.5)
assert out["stationary_plus_pcm"].iloc[1] == pytest.approx(50.0 + 4.0 * 3.0)
def _pareto_synthetic():
"""A tiny DFT+NN pair where a 'fast' method is also more accurate, so it must sit on the
frontier and a 'slow, worse' method must not."""
rng = np.random.default_rng(1)
solvents = ["chloroform", "benzene", "TIP4P"]
dft_rows, nn_rows = [], []
for si in range(12):
solute = f"m{si:02d}"
for solvent in solvents:
stat = rng.normal(100, 20)
pcm = rng.normal(0, 2)
exp = 31.0 - (stat + pcm)
for method, noise in [("good_fast", 0.02), ("bad_slow", 1.5)]:
dft_rows.append({
"solute": solute, "site": f"{solute}_s", "nucleus": "H", "solvent": solvent,
"sap_nmr_method": method, "sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
"experimental": exp + rng.normal(0, noise), "stationary": stat, "pcm": pcm})
nn_rows.append({
"solute": solute, "site": f"{solute}_s", "nucleus": "H", "solvent": solvent,
"sap_nmr_method": "MagNET", "sap_basis": "N/A", "sap_geometry_type": "aimnet2",
"experimental": exp + rng.normal(0, 0.05), "stationary": stat, "pcm": pcm})
return pd.DataFrame(dft_rows), pd.DataFrame(nn_rows)
def test_assemble_pareto_fitting_df_and_rmse():
dft, nn = _pareto_synthetic()
factors = {"H": pd.Series({s: 1.0 for s in ["chloroform", "benzene", "TIP4P"]}),
"C": pd.Series(dtype=float)}
fitting = D.assemble_pareto_fitting_df(dft, nn, conversion_factors=factors, exclude_solutes=())
# TIP4P renamed to water, all three methods present, single predictor column built
assert "water" in set(fitting["solvent"]) and "TIP4P" not in set(fitting["solvent"])
assert set(fitting["nmr_method"]) == {"good_fast", "bad_slow", "MagNET"}
assert "stationary_plus_pcm" in fitting.columns
rmse = D.pareto_solvent_averaged_rmse(fitting, n_splits=6, n_test=6)
assert "solvent-averaged" in set(rmse["solvent"])
sav = rmse[rmse["solvent"] == "solvent-averaged"].set_index("nmr_method")["fitting_RMSE"]
assert sav["good_fast"] < sav["bad_slow"] # the accurate method has the lower RMSE
def test_attach_pareto_timings_substitutes_magnet():
rmse = pd.DataFrame({
"geometry_type": ["aimnet2", "aimnet2"], "nmr_method": ["bad_slow", "MagNET"],
"basis": ["pcSseg2", "N/A"], "nucleus": ["H", "H"], "solvent": ["solvent-averaged"] * 2,
"fitting_RMSE": [1.0, 0.2],
})
dft_gas = pd.DataFrame({"geometry_time": [10.0], "nmr_time": [990.0], "total_time": [1000.0]},
index=pd.MultiIndex.from_tuples([("aimnet2", "bad_slow", "pcSseg2")],
names=["geometry_type", "nmr_method", "basis"]))
nn = pd.DataFrame({"geometry_time": [5.0], "nmr_time": [15.0], "total_time": [20.0]},
index=pd.MultiIndex.from_tuples([("aimnet2", "MagNET", "N/A")],
names=["geometry_type", "nmr_method", "basis"]))
out = D.attach_pareto_timings(rmse, dft_gas, nn)
slow = out[out["nmr_method"] == "bad_slow"].iloc[0]
magnet = out[out["nmr_method"] == "MagNET"].iloc[0]
assert slow["total_time"] == pytest.approx(1000.0)
assert magnet["total_time"] == pytest.approx(20.0) # substituted from the NN timings
# MagNET dominates: faster and more accurate, so it is on the frontier and slow is not
front = D.pareto_frontier(out, x="total_time", y="fitting_RMSE")
assert list(front["nmr_method"]) == ["MagNET"]
def test_pareto_table_curated_keeps_only_the_documented_rows():
points = pd.DataFrame([
# kept: MagNET itself
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "MagNET", "basis": "N/A", "total_time": 1.0},
# kept: the one representative aimnet2 row (H's reference method wp04, pcSseg2)
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg2", "total_time": 2.0},
# dropped: aimnet2 + wp04 but the WRONG basis
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg1", "total_time": 3.0},
# dropped: aimnet2 + pcSseg2 but the WRONG method (not H's reference)
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "hf", "basis": "pcSseg2", "total_time": 4.0},
# kept: part of the full pbe0_tz grid, any method/basis
{"nucleus": "H", "solvent": "chloroform", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 5.0},
# dropped: right nucleus/geometry, WRONG solvent
{"nucleus": "H", "solvent": "benzene", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 6.0},
# dropped: right solvent/geometry, WRONG nucleus
{"nucleus": "C", "solvent": "chloroform", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 7.0},
])
out = D.pareto_table_curated(points, "H", solvent="chloroform")
assert len(out) == 3
assert set(out["total_time"]) == {1.0, 2.0, 5.0}
assert list(out["total_time"]) == sorted(out["total_time"]) # sorted by total_time ascending
def test_pareto_table_curated_uses_the_carbon_reference_method():
# C's reference method is wb97xd (MAGNET_PCM_OUTPUT_METHODS), not H's wp04
points = pd.DataFrame([
{"nucleus": "C", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg2", "total_time": 1.0},
{"nucleus": "C", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wb97xd", "basis": "pcSseg2", "total_time": 2.0},
])
out = D.pareto_table_curated(points, "C", solvent="chloroform")
assert list(out["nmr_method"]) == ["wb97xd"]
# Published Figure 2A values to reproduce from the released data (from the original
# fig2a_pareto_results.csv). MagNET sits at the cheap end of a flat frontier.
FIG2A_MAGNET_SOLVENT_AVERAGED = {"H": 0.1647203288369508, "C": 1.7268703504301826}
FIG2A_CONVERSION_FACTORS_H_BENZENE = 0.6513139306748533 # from magnet_pcm_conversion_factors_H.csv
@pytest.mark.skipif(
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
reason="real delta22.hdf5 / experimental xlsx not present")
def test_fig2a_reproduces_published_magnet_point():
"""Recompute the MagNET Pareto point and conversion factors from the released data and check
them against the published Figure 2A numbers (the int32 encoding sets the precision floor)."""
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
nn = D.load_query_df_nn(REAL_H5, REAL_XLSX, verbose=False)
# conversion factors recomputed from the DFT PCM data match the stored CSV value
cf_h = D.pcm_conversion_factors(dft, "H")
assert cf_h["benzene"] == pytest.approx(FIG2A_CONVERSION_FACTORS_H_BENZENE, abs=1e-6)
# build only the MagNET fitting rows and average over solvents (fast; avoids the full DFT sweep)
factors = {nuc: D.pcm_conversion_factors(dft, nuc) for nuc in ("H", "C")}
nn_fit = D.add_nn_stationary_plus_pcm(nn, factors).rename(columns=lambda c: c.replace("sap_", ""))
nn_fit["solvent"] = nn_fit["solvent"].replace("TIP4P", "water")
nn_fit["experimental"] = pd.to_numeric(nn_fit["experimental"], errors="coerce")
nn_fit = nn_fit[nn_fit["solute"] != "nitromethane"]
# the split solute order must match the full combined table (DFT solutes, in file order)
solutes = D.assemble_pareto_fitting_df(dft, nn, conversion_factors=factors)["solute"].unique().tolist()
rmse = D.pareto_solvent_averaged_rmse(nn_fit, solutes=solutes)
sav = rmse[(rmse["nmr_method"] == "MagNET") & (rmse["solvent"] == "solvent-averaged")]
for nucleus, expected in FIG2A_MAGNET_SOLVENT_AVERAGED.items():
got = sav[sav["nucleus"] == nucleus]["fitting_RMSE"].iloc[0]
assert got == pytest.approx(expected, abs=1e-4), f"{nucleus}: {got} vs {expected}"
# --- Composite-formula ablation workbook's "Correlations Between Features" -----------------------
def test_feature_correlation_matrix_synthetic():
# a and b perfectly correlated, c independent noise uncorrelated with either
n = 200
rng = np.random.default_rng(0)
a = rng.normal(size=n)
df = pd.DataFrame({
"nucleus": ["H"] * n, "solvent": ["chloroform"] * n,
"stationary": a, "pcm": a * 2 + 1, # perfectly correlated with stationary
"desmond": rng.normal(size=n), "desmond_vib": rng.normal(size=n), "qcd": rng.normal(size=n),
})
corr = D.feature_correlation_matrix(df, "H", "chloroform")
assert corr.loc["stationary", "pcm"] == pytest.approx(1.0, abs=1e-9)
corr2 = D.feature_correlation_matrix(df, "H", "chloroform", squared=True)
assert corr2.loc["stationary", "pcm"] == pytest.approx(1.0, abs=1e-9)
@pytest.mark.filterwarnings("ignore:Mean of empty slice:RuntimeWarning")
def test_average_feature_correlation_matrix_averages_across_solvents():
df = pd.DataFrame({
"nucleus": ["H"] * 4, "solvent": ["chloroform", "chloroform", "benzene", "benzene"],
"stationary": [1.0, 2.0, 1.0, 3.0], "pcm": [1.0, 2.0, 3.0, 1.0],
"desmond": [0.5, 0.5, 0.5, 0.5], "desmond_vib": [0.1, 0.2, 0.1, 0.2], "qcd": [0, 0, 0, 0],
})
avg = D.average_feature_correlation_matrix(df, "H", ["chloroform", "benzene"])
chloroform_only = D.feature_correlation_matrix(df, "H", "chloroform")
benzene_only = D.feature_correlation_matrix(df, "H", "benzene")
expected = (chloroform_only.loc["stationary", "pcm"] + benzene_only.loc["stationary", "pcm"]) / 2
assert avg.loc["stationary", "pcm"] == pytest.approx(expected)
@pytest.mark.filterwarnings("ignore:Mean of empty slice:RuntimeWarning")
def test_average_feature_correlation_matrix_ignores_a_solvent_with_too_few_sites():
# benzene has only 1 site -> feature_correlation_matrix returns all-NaN for benzene alone
# (< 2 sites). A plain sum()/len() average would make the WHOLE averaged cell NaN even though
# chloroform (4 sites) has a perfectly good value; nanmean should use chloroform alone instead.
df = pd.DataFrame({
"nucleus": ["H"] * 5,
"solvent": ["chloroform", "chloroform", "chloroform", "chloroform", "benzene"],
"stationary": [1.0, 2.0, 3.0, 4.0, 1.0], "pcm": [2.0, 4.0, 6.0, 8.0, 5.0],
"desmond": [0.5] * 5, "desmond_vib": [0.1] * 5, "qcd": [0.0] * 5,
})
avg = D.average_feature_correlation_matrix(df, "H", ["chloroform", "benzene"])
assert not np.isnan(avg.loc["stationary", "pcm"])
assert avg.loc["stationary", "pcm"] == pytest.approx(1.0) # chloroform alone: perfectly correlated
@pytest.mark.skipif(
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
reason="real delta22.hdf5 / experimental xlsx not present")
def test_ablations_feature_correlations_reproduces_published_matrices():
"""Every entry of both nuclei's published Pearson R and R^2 "Correlations Between Features"
matrices (the composite-formula ablation workbook), reproduced from the released data to 3
decimal places. Guards against
pooling all DFT methods/bases/geometries together instead of filtering to the MagNET-Zero
reference level, which throws entries off by up to 0.03."""
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
# R^2 here is the average of each solvent's r^2 (Jensen's inequality: != the square of the
# averaged r), so it is hardcoded separately from published_r, not derived from it.
published_r = {
"H": {("pcm", "stationary"): 0.298, ("desmond", "stationary"): 0.262, ("desmond", "pcm"): 0.399,
("desmond_vib", "stationary"): -0.170, ("desmond_vib", "pcm"): -0.219, ("desmond_vib", "desmond"): -0.141,
("qcd", "stationary"): -0.851, ("qcd", "pcm"): -0.362, ("qcd", "desmond"): -0.394, ("qcd", "desmond_vib"): 0.405},
"C": {("pcm", "stationary"): 0.584, ("desmond", "stationary"): 0.106, ("desmond", "pcm"): 0.667,
("desmond_vib", "stationary"): 0.531, ("desmond_vib", "pcm"): 0.369, ("desmond_vib", "desmond"): 0.075,
("qcd", "stationary"): -0.616, ("qcd", "pcm"): -0.635, ("qcd", "desmond"): -0.418, ("qcd", "desmond_vib"): -0.138},
}
published_r2 = {
"H": {("pcm", "stationary"): 0.089, ("desmond", "stationary"): 0.093, ("desmond", "pcm"): 0.386,
("desmond_vib", "stationary"): 0.032, ("desmond_vib", "pcm"): 0.049, ("desmond_vib", "desmond"): 0.025,
("qcd", "stationary"): 0.724, ("qcd", "pcm"): 0.131, ("qcd", "desmond"): 0.184, ("qcd", "desmond_vib"): 0.167},
"C": {("pcm", "stationary"): 0.341, ("desmond", "stationary"): 0.047, ("desmond", "pcm"): 0.463,
("desmond_vib", "stationary"): 0.284, ("desmond_vib", "pcm"): 0.139, ("desmond_vib", "desmond"): 0.019,
("qcd", "stationary"): 0.380, ("qcd", "pcm"): 0.404, ("qcd", "desmond"): 0.189, ("qcd", "desmond_vib"): 0.021},
}
for nucleus in ("H", "C"):
corr = D.ablations_feature_correlations(dft, nucleus)
for (a, b), expected in published_r[nucleus].items():
assert corr["r"].loc[a, b] == pytest.approx(expected, abs=5e-4), f"{nucleus} r[{a},{b}]"
for (a, b), expected in published_r2[nucleus].items():
assert corr["r2"].loc[a, b] == pytest.approx(expected, abs=5e-4), f"{nucleus} r2[{a},{b}]"
@pytest.mark.skipif(
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
reason="real delta22.hdf5 / experimental xlsx not present")
def test_pcm_desmond_correlation_by_solvent_reproduces_published_table():
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
table = D.pcm_desmond_correlation_by_solvent(dft)
published = {("H", "chloroform"): 0.739, ("H", "TIP4P"): 0.728, ("H", "benzene"): -0.454,
("C", "chloroform"): 0.408, ("C", "TIP4P"): 0.840, ("C", "benzene"): 0.594}
for (nucleus, solvent), expected in published.items():
assert table.loc[nucleus, solvent] == pytest.approx(expected, abs=5e-4), f"{nucleus}/{solvent}"
@pytest.mark.skipif(
not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
reason="real delta22.hdf5 / experimental xlsx not present")
def test_pcm_benefit_reproduces_published_figure_s5_correlations():
# Figure S5 aggregates the per-split PCM benefit with the mean across splits, not the median;
# only the mean reproduces the published Pearson R values.
dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
solutes = sorted(dft["solute"].unique())
def pearson_r(benefit, prop, exclude=()):
s = benefit.drop(index=[x for x in exclude if x in benefit.index])
x = np.array([prop[k] for k in s.index])
return float(np.corrcoef(x, s.values)[0, 1])
aromatics_tfe = ["benzene", "toluene", "chlorobenzene", "trifluoroethanol"]
benefit_h = D.pcm_benefit_per_solvent(dft, D.MAGNET_PCM_OUTPUT_METHODS["H"], "pcSseg2", "aimnet2",
D.DESMOND_SOLVENTS, n_splits=250, solutes=solutes, nucleus="H")
benefit_c = D.pcm_benefit_per_solvent(dft, D.MAGNET_PCM_OUTPUT_METHODS["C"], "pcSseg2", "aimnet2",
D.DESMOND_SOLVENTS, n_splits=250, solutes=solutes, nucleus="C")
published = { # (panel, axis): published Pearson R
"S5A 1H dielectric": (pearson_r(benefit_h, D.SOLVENT_DIELECTRIC), 0.617),
"S5A 1H polarizability": (pearson_r(benefit_h, D.SOLVENT_POLARIZABILITY), -0.801),
"S5B 13C dielectric": (pearson_r(benefit_c, D.SOLVENT_DIELECTRIC), 0.722),
"S5B 13C polarizability": (pearson_r(benefit_c, D.SOLVENT_POLARIZABILITY), -0.743),
"S5C 1H dielectric": (pearson_r(benefit_h, D.SOLVENT_DIELECTRIC, aromatics_tfe), 0.884),
"S5C 1H polarizability": (pearson_r(benefit_h, D.SOLVENT_POLARIZABILITY, aromatics_tfe), -0.587),
}
for name, (got, expected) in published.items():
assert got == pytest.approx(expected, abs=2e-3), f"{name}: {got} != {expected}"
|