MagNET / analysis /code /build_composite_model.py
ekwan16's picture
Add files using upload-large-folder tool
ef53368 verified
Raw
History Blame Contribute Delete
15 kB
"""Regenerate the composite_model/ group in applications.hdf5 from the shipped delta-22 data.
Replaces the old build_coefficients.py, which copied pre-fit CSVs out of a private tree via
environment variables. Everything here is fit in-repo from delta22.hdf5, so the natural-products
composite-model coefficients (Figure 5C/5D, SI S15) are reproducible from released inputs alone.
The stored group's structure (formula keys, parameter order, solvent order, seed count) is read
from the current applications.hdf5 and used as a template; only the numbers are regenerated.
"""
import os
import random
import sys
import h5py
import numpy as np
import pandas as pd
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
for _p in ("analysis/code", "analysis/code/shared", "data/delta22", "data/applications"):
sys.path.insert(0, os.path.join(REPO, _p))
import delta22 as D # noqa: E402
import paths # noqa: E402
# The 22 delta-22 solutes in first-appearance order: the universe random.choices() draws from.
# nitromethane stays in the draw pool (index 14) but its rows are dropped from the fit data, exactly
# as the original pipeline did.
SOLUTE_ORDER = ["AcOH", "EtOAc", "EtOH", "THF", "acetone", "acetonitrile", "benzaldehyde", "dmf",
"dmso", "ethane", "ethylene", "glycol", "imidazole", "isopropanol", "nitromethane",
"propylene", "pyridine", "pyrrole", "pyrrolidine", "tbme", "toluene", "triethylamine"]
SEED_OFFSET = 100
BASE_FEATURES = ["experimental", "stationary", "qcd", "pcm", "desmond", "desmond_vib",
"openMM", "openMM_vib"]
ALIASES = {"des_vib": "desmond_vib", "op_vib": "openMM_vib"}
def _load_tables():
"""Three delta-22 NN tables (nitromethane rows removed) plus the pcm conversion factors:
full_tab - every row; the OLS point fits use it and drop only each formula's own missing
features, so pcm/qcd formulas fit all 12 solvents.
fit_tab - strict: all base features present (only the 4 explicit-solvent solvents survive);
the bootstrap resamples fit here, matching the stored bootstrap's 4 solvents.
apply_tab - desmond or openMM present; the RMSE predictions are evaluated over it.
"""
h5 = paths.dataset_file("delta22", root=REPO)
xlsx = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
nn = D.load_query_df_nn(h5, xlsx, verbose=False)
dft = D.load_query_df_dft(h5, xlsx, verbose=False)
factors = {"H": D.pcm_conversion_factors(dft, "H"), "C": D.pcm_conversion_factors(dft, "C")}
nn = nn[nn["solute"] != "nitromethane"].copy()
# stationary_plus_pcm folds in the per-solvent conversion factor; every other _plus_ column is a
# plain sum, built on demand by _ensure_column.
fac = nn.apply(lambda r: factors[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
nn["stationary_plus_pcm"] = nn["stationary"] + nn["pcm"] * fac
full_tab = nn.reset_index(drop=True)
fit_tab = nn.dropna(subset=BASE_FEATURES).reset_index(drop=True)
apply_tab = nn.dropna(subset=["desmond", "openMM"], how="all").reset_index(drop=True)
return full_tab, fit_tab, apply_tab, factors
def _canonical(name):
return ALIASES.get(name, name)
def _regressor_columns(formula_key):
"""The design-matrix regressors for a formula key. '_AND_' separates independent regressors;
a '_plus_' name is one precomputed sum column. Returns the list of column names to fit on."""
return [g for g in formula_key.split("_AND_")]
def _plus_parts(regressor):
"""Base features a '_plus_' regressor decomposes into (canonical names); [] if it is itself a
base feature."""
if "_plus_" not in regressor:
return []
return [_canonical(p) for p in regressor.split("_plus_") if p]
def _ensure_column(df, regressor):
"""Make sure a precomputed _plus_ regressor column exists on df (sum of its parts; the pcm part
already carries the conversion factor via stationary_plus_pcm)."""
if regressor in df.columns:
return
parts = regressor.split("_plus_")
assert _canonical(parts[0]) == "stationary", f"unexpected base term in {regressor}"
# build cumulatively so stationary_plus_pcm_plus_op_vib = stationary_plus_pcm + openMM_vib. The
# pcm chains start from the scaled stationary_plus_pcm column; everything else from raw stationary.
if parts[1:2] == ["pcm"]:
col = df["stationary_plus_pcm"].to_numpy(float).copy()
rest = parts[2:]
else:
col = df["stationary"].to_numpy(float).copy()
rest = parts[1:]
for p in rest:
col = col + df[_canonical(p)].to_numpy(float)
df[regressor] = col
def _fit(df, regressors):
"""OLS of experimental on the given regressor columns (with intercept); returns {name: coeff}
keyed by 'Intercept' and each regressor. NaN rows are dropped, matching statsmodels."""
y = pd.to_numeric(df["experimental"], errors="coerce").to_numpy(float)
cols = [df[r].to_numpy(float) for r in regressors]
X = np.column_stack([np.ones(len(df))] + cols)
mask = np.isfinite(y) & np.all(np.isfinite(X), axis=1)
beta, *_ = np.linalg.lstsq(X[mask], y[mask], rcond=None)
return dict(zip(["Intercept"] + list(regressors), beta))
def _decompose(coeffs, regressors, solvent, nucleus, factors):
"""Expand each _plus_ regressor's coefficient onto its base features (same value copied). The pcm
row is scaled by the per-solvent conversion factor ONLY when the formula used a
stationary_plus_pcm term (a free _AND_pcm regressor stays raw). Returns {base_feature: coeff}."""
out = {"intercept": coeffs["Intercept"]}
for reg, val in coeffs.items():
if reg == "Intercept":
continue
parts = _plus_parts(reg)
for base in (parts if parts else [_canonical(reg)]):
out[base] = val
uses_spp = any(_plus_parts(r)[1:2] == ["pcm"] for r in regressors)
if uses_spp and "pcm" in out:
out["pcm"] = out["pcm"] * float(factors[nucleus].get(solvent, np.nan))
return out
def _bootstrap_sample(fit_tab, nucleus, solvent, seed):
"""The rows fit for one bootstrap draw: seed the RNG with seed+100, draw 22 solutes with
replacement from SOLUTE_ORDER, keep that solvent/nucleus's rows for the drawn solutes."""
random.seed(int(seed) + SEED_OFFSET)
drawn = random.choices(SOLUTE_ORDER, k=22)
return fit_tab[(fit_tab["nucleus"] == nucleus) & (fit_tab["solvent"] == solvent)
& (fit_tab["solute"].isin(drawn))]
def _rmse(fit_tab, apply_tab, display_formula, nucleus, solvent, seed, factors):
"""Bootstrap RMSE: fit the formula on one bootstrap draw, apply those raw regressor coefficients
to every apply-table row for the solvent, and take the RMSE over labeled rows. The RMSE display
names join regressors with ' + ' (each is a column, possibly a precomputed _plus_ one)."""
regs = [t.strip() for t in display_formula.split(" + ")]
for r in (fit_tab, apply_tab):
for reg in regs:
_ensure_column(r, reg)
coeffs = _fit(_bootstrap_sample(fit_tab, nucleus, solvent, seed), regs)
a = apply_tab[(apply_tab["nucleus"] == nucleus) & (apply_tab["solvent"] == solvent)]
pred = coeffs["Intercept"] + sum(coeffs[r] * a[r].to_numpy(float) for r in regs)
exp = pd.to_numeric(a["experimental"], errors="coerce").to_numpy(float)
mask = np.isfinite(pred) & np.isfinite(exp)
return float(np.sqrt(np.mean((pred[mask] - exp[mask]) ** 2)))
def _pcm_table(factors, nucleus):
"""pcm_conversion_factors subgroup for one nucleus: solvent, pcm_conversion_factor. Stored under
the 'water' name rather than the loader's 'TIP4P'."""
s = factors[nucleus].rename(index={"TIP4P": "water"})
return pd.DataFrame({"solvent": s.index, "pcm_conversion_factor": s.to_numpy()})
def regenerate(reader):
"""Regenerate every composite_model subgroup from delta-22, keyed and ordered to match the stored
group. Returns {"ols_coefficients"|"bootstrap_coefficients"|"rmse_distributions"|
"pcm_conversion_factors": {key: DataFrame}}."""
full_tab, fit_tab, apply_tab, factors = _load_tables()
out = {k: {} for k in ("ols_coefficients", "bootstrap_coefficients",
"rmse_distributions", "pcm_conversion_factors")}
for f in reader.composite_formulas("ols_coefficients"):
regs = _regressor_columns(f)
for reg in regs:
_ensure_column(full_tab, reg)
for nuc in ("H", "C"):
try:
template = reader.ols_coefficients(f, nuc)
except KeyError:
continue
params = template["parameter"].tolist()
solvents = [c for c in template.columns if c != "parameter"]
data = {"parameter": params}
for solvent in solvents:
sub = full_tab[(full_tab["nucleus"] == nuc) & (full_tab["solvent"] == solvent)]
dec = _decompose(_fit(sub, regs), regs, solvent, nuc, factors)
data[solvent] = [dec[p] for p in params]
out["ols_coefficients"][(f, nuc)] = pd.DataFrame(data)
for f in reader.composite_formulas("bootstrap_coefficients"):
regs = _regressor_columns(f)
for reg in regs:
_ensure_column(fit_tab, reg)
for nuc in ("H", "C"):
try:
template = reader.bootstrap_coefficients(f, nuc)
except KeyError:
continue
params = [c for c in template.columns if c not in ("solvent", "seed")]
rows = []
for solvent in dict.fromkeys(template["solvent"]):
seeds = sorted(template[template["solvent"] == solvent]["seed"].unique())
for seed in seeds:
dec = _decompose(_fit(_bootstrap_sample(fit_tab, nuc, solvent, seed), regs),
regs, solvent, nuc, factors)
row = {"solvent": solvent, "seed": seed}
for p in params:
row[p] = dec["intercept"] if p == "Intercept" else dec[p]
rows.append(row)
out["bootstrap_coefficients"][(f, nuc)] = pd.DataFrame(rows)[template.columns.tolist()]
for nuc in ("H", "C"):
template = reader.rmse_distribution(nuc)
rows = []
for f in dict.fromkeys(template["formula"]):
sub = template[template["formula"] == f]
for solvent in dict.fromkeys(sub["solvent"]):
for seed in sorted(sub[sub["solvent"] == solvent]["seed"].unique()):
rows.append({"solvent": solvent, "nucleus": nuc, "formula": f, "seed": seed,
"Bootstrap_RMSE": _rmse(fit_tab, apply_tab, f, nuc, solvent, seed, factors)})
out["rmse_distributions"][nuc] = pd.DataFrame(rows)[template.columns.tolist()]
out["pcm_conversion_factors"][nuc] = _pcm_table(factors, nuc)
return out
def verify(reader, regenerated, atol=5e-3):
"""Compare regenerated numeric values against the stored group. Returns the global max abs diff.
The floor is the fixed-point encoding granularity (~1e-3), far below any recipe error."""
worst = 0.0
for (f, nuc), df in regenerated["ols_coefficients"].items():
st = reader.ols_coefficients(f, nuc)
worst = max(worst, np.abs(df.drop(columns="parameter").to_numpy(float)
- st.drop(columns="parameter").to_numpy(float)).max())
for (f, nuc), df in regenerated["bootstrap_coefficients"].items():
st = reader.bootstrap_coefficients(f, nuc).sort_values(["solvent", "seed"]).reset_index(drop=True)
df = df.sort_values(["solvent", "seed"]).reset_index(drop=True)
num = [c for c in df.columns if c not in ("solvent",)]
worst = max(worst, np.abs(df[num].to_numpy(float) - st[num].to_numpy(float)).max())
for nuc, df in regenerated["rmse_distributions"].items():
st = reader.rmse_distribution(nuc).sort_values(["formula", "solvent", "seed"]).reset_index(drop=True)
df = df.sort_values(["formula", "solvent", "seed"]).reset_index(drop=True)
worst = max(worst, np.abs(df["Bootstrap_RMSE"].to_numpy(float)
- st["Bootstrap_RMSE"].to_numpy(float)).max())
for nuc, df in regenerated["pcm_conversion_factors"].items():
st = reader.pcm_conversion_factors(nuc).set_index("solvent")["pcm_conversion_factor"]
got = df.set_index("solvent")["pcm_conversion_factor"]
worst = max(worst, np.abs(got - st.reindex(got.index)).max())
return float(worst)
_NOTE = ("Linear composite-model coefficients fit on the delta-22 dataset and applied to the "
"natural products (Figure 5C/5D, SI S15). Stored as CSV text; read via applications_reader. "
"Regenerated in-repo from delta22.hdf5 by build_composite_model.py.")
def write(hdf5_path, regenerated):
"""Overwrite the composite_model/ group in applications.hdf5 with the regenerated CSV text."""
with h5py.File(hdf5_path, "a") as h:
if "composite_model" in h:
del h["composite_model"]
cm = h.create_group("composite_model")
cm.attrs["note"] = _NOTE
for (formula, nucleus), df in regenerated["ols_coefficients"].items():
cm.require_group(f"ols_coefficients/{formula}").create_dataset(nucleus, data=df.to_csv(index=False))
for (formula, nucleus), df in regenerated["bootstrap_coefficients"].items():
cm.require_group(f"bootstrap_coefficients/{formula}").create_dataset(nucleus, data=df.to_csv(index=False))
for nucleus, df in regenerated["rmse_distributions"].items():
cm.require_group("rmse_distributions").create_dataset(nucleus, data=df.to_csv(index=False))
for nucleus, df in regenerated["pcm_conversion_factors"].items():
cm.require_group("pcm_conversion_factors").create_dataset(nucleus, data=df.to_csv(index=False))
def main():
import argparse
ap = argparse.ArgumentParser(description="Regenerate composite_model/ in applications.hdf5 from delta-22.")
ap.add_argument("--write", action="store_true",
help="overwrite the composite_model group in applications.hdf5 (default: verify only)")
args = ap.parse_args()
sys.path.insert(0, os.path.join(REPO, "data", "applications"))
from applications_reader import Applications
hdf5_path = paths.dataset_file("applications", root=REPO)
reader = Applications(hdf5_path)
regenerated = regenerate(reader)
max_diff = verify(reader, regenerated)
print(f"composite_model regenerated from delta-22; max abs diff vs stored = {max_diff:.2e}")
if args.write:
write(hdf5_path, regenerated)
print(f"wrote composite_model/ into {hdf5_path}")
if __name__ == "__main__":
main()