DooABLe / src /dooable /properties.py
pranamanam's picture
Upload 309 files
81ae663 verified
Raw
History Blame Contribute Delete
6.49 kB
"""Public molecular property data, scaffold splits, and CPU surrogate fits."""
from pathlib import Path
import hashlib, json, time
import requests, numpy as np, pandas as pd, joblib
from rdkit import Chem, DataStructs
from rdkit.Chem import rdFingerprintGenerator
from rdkit.Chem.Scaffolds import MurckoScaffold
from sklearn.ensemble import ExtraTreesRegressor
from sklearn.metrics import mean_absolute_error, r2_score
from .chemistry import canonical
DATASETS = {
"caco2": {
"url": "https://dataverse.harvard.edu/api/access/datafile/4259569",
"sep": "\t",
"smiles": "Drug",
"target": "Y",
"units": "log10(cm/s)",
},
"bace": {
"url": "https://deepchemdata.s3-us-west-1.amazonaws.com/datasets/bace.csv",
"sep": ",",
"smiles": "mol",
"target": "pIC50",
"units": "pIC50",
},
}
def download(name, directory):
"""Fetch a released dataset once and save its source URL and SHA-256 hash."""
spec = DATASETS[name]
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
path = directory / (name + ".csv")
if not path.exists():
r = requests.get(spec["url"], timeout=120)
r.raise_for_status()
path.write_bytes(r.content)
manifest = {
"dataset": name,
"source": spec["url"],
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"bytes": path.stat().st_size,
"units": spec["units"],
}
(directory / (name + "_source.json")).write_text(json.dumps(manifest, indent=2))
return path
def fingerprints(smiles):
"""Return a float32 array of shape (molecules, 1024) with Morgan bits."""
fp = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=1024)
out = np.zeros((len(smiles), 1024), dtype=np.float32)
for i, s in enumerate(smiles):
DataStructs.ConvertToNumpyArray(
fp.GetFingerprint(Chem.MolFromSmiles(s)), out[i]
)
return out
def load_data(name, path):
"""Canonicalize input molecules, average duplicate labels, and record exclusions."""
spec = DATASETS[name]
df = pd.read_csv(path, sep=spec["sep"])
rows = []
excluded = 0
for _, r in df.iterrows():
try:
s = canonical(r[spec["smiles"]])
y = float(r[spec["target"]])
if not np.isfinite(y):
raise ValueError()
rows.append((s, y))
except (ValueError, TypeError):
excluded += 1
clean = (
pd.DataFrame(rows, columns=["smiles", "value"])
.groupby("smiles", as_index=False)
.value.mean()
)
return clean, {
"raw_rows": len(df),
"excluded_rows": excluded,
"unique_molecules": len(clean),
}
def scaffold_split(smiles, seed=0, train_fraction=0.8):
"""Partition entire Bemis-Murcko groups into train and test index arrays."""
groups = {}
for i, s in enumerate(smiles):
scaffold = MurckoScaffold.MurckoScaffoldSmiles(smiles=s, includeChirality=False)
# All acyclic compounds remain in the same scaffold group.
groups.setdefault(scaffold, []).append(i)
rng = np.random.default_rng(seed)
items = list(groups.items())
rng.shuffle(items)
items.sort(key=lambda x: -len(x[1]))
train = []
test = []
trsc = []
tesc = []
for key, inds in items:
if len(train) + len(inds) <= train_fraction * len(smiles):
train += inds
trsc.append(key)
else:
test += inds
tesc.append(key)
if not train or not test:
raise ValueError("Insufficient scaffold groups for a held-out split")
assert not set(trsc) & set(tesc)
return np.array(train), np.array(test)
def fit_property(name, data_directory, output, seed=0):
"""Fit a 256-tree predictor and save weights, split membership, and test scores."""
out = Path(output)
out.mkdir(parents=True, exist_ok=True)
path = download(name, data_directory)
df, counts = load_data(name, path)
smiles = df.smiles.tolist()
x = fingerprints(smiles)
y = df.value.to_numpy()
tr, te = scaffold_split(smiles, seed)
start = time.perf_counter()
model = ExtraTreesRegressor(
n_estimators=256,
min_samples_leaf=2,
max_features=0.5,
random_state=seed,
n_jobs=2,
)
model.fit(x[tr], y[tr])
pred = model.predict(x[te])
result = {
**counts,
"dataset": name,
"seed": seed,
"split": "Bemis-Murcko scaffold, 80/20",
"train": len(tr),
"test": len(te),
"mae": mean_absolute_error(y[te], pred),
"r2": r2_score(y[te], pred),
"seconds": time.perf_counter() - start,
"units": DATASETS[name]["units"],
}
joblib.dump(model, out / (name + ".joblib"))
(out / (name + "_metrics.json")).write_text(json.dumps(result, indent=2))
df.assign(split=np.where(np.isin(np.arange(len(df)), tr), "train", "test")).to_csv(
out / (name + "_split.csv"), index=False
)
pd.DataFrame(
{"smiles": df.smiles.iloc[te], "observed": y[te], "predicted": pred}
).to_csv(out / (name + "_test_predictions.csv"), index=False)
return result
def property_rewards(graph, model_directory, weights=(0.5, 0.5), concentration=5.0):
"""Predicted BACE inhibition and Caco-2 transport, normalized to utilities."""
if not np.isfinite(concentration) or concentration <= 0:
raise ValueError("Concentration must be finite and positive")
smiles = list(graph.terminals)
x = fingerprints(smiles)
directory = Path(model_directory)
bace = joblib.load(directory / "bace.joblib").predict(x)
caco = joblib.load(directory / "caco2.joblib").predict(x)
ub = np.clip((bace - 4) / 5, 0, 1)
uc = np.clip((caco + 7) / 3, 0, 1)
w = np.asarray(weights, dtype=float)
if w.shape != (2,) or np.any(w < 0) or not np.all(np.isfinite(w)) or w.sum() <= 0:
raise ValueError("Two nonnegative property weights with positive sum required")
w = w / w.sum()
rewards = {
s: float(concentration * (w[0] * a + w[1] * b))
for s, a, b in zip(smiles, ub, uc)
}
scores = pd.DataFrame(
{
"smiles": smiles,
"predicted_bace_pIC50": bace,
"predicted_caco2_log10_cm_s": caco,
"bace_utility": ub,
"caco2_utility": uc,
}
)
return rewards, scores