Download src/dooable/properties.py from ChatterjeeLab/DooABLe: direct link, hf CLI and curl.
- Browser
- Download file 6.49 kB
-
https://huggingface.co/ChatterjeeLab/DooABLe/resolve/main/src/dooable/properties.py
- Command line
-
hf download hf://ChatterjeeLab/DooABLe/src/dooable/properties.py
-
curl -L -o properties.py https://huggingface.co/ChatterjeeLab/DooABLe/resolve/main/src/dooable/properties.py
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 | |