Download src/remedi/models.py from ChatterjeeLab/ReMEDi: direct link, hf CLI and curl.
- Browser
- Download file 5.97 kB
-
https://huggingface.co/ChatterjeeLab/ReMEDi/resolve/main/src/remedi/models.py
- Command line
-
hf download hf://ChatterjeeLab/ReMEDi/src/remedi/models.py
-
curl -L -o models.py https://huggingface.co/ChatterjeeLab/ReMEDi/resolve/main/src/remedi/models.py
5.97 kB
| """Small molecule-dose response heads, grouped ensembles, and cross-fitted residuals.""" | |
| from pathlib import Path | |
| import joblib | |
| import numpy as np | |
| from sklearn.linear_model import Ridge | |
| from sklearn.neural_network import MLPRegressor | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.model_selection import GroupKFold | |
| from .chemistry import encode, fingerprints, max_tanimoto | |
| from .io import write_json, file_hash | |
| def design_features(chemical, dose, context, mode="ridge"): | |
| """Build condition rows from molecular features, dose in uM, and control means. | |
| chemical is (N, P), dose is (N,), and context is (N, D). Ridge | |
| features include molecule-specific linear and quadratic log-dose terms. | |
| """ | |
| dose = np.asarray(dose, float) | |
| if (dose <= 0).any() or not np.isfinite(dose).all(): | |
| raise ValueError("Concentrations must be positive micromolar values") | |
| logdose = np.log(dose)[:, None] | |
| parts = [chemical, logdose, context] | |
| if mode == "ridge": | |
| parts += [chemical*logdose, chemical*logdose**2] | |
| return np.concatenate(parts, axis=1) | |
| def _fit_head(x, y, weights, kind, alpha, hidden, seed): | |
| scale = StandardScaler().fit(x, sample_weight=weights) | |
| x = scale.transform(x) | |
| if kind == "ridge": | |
| head = Ridge(alpha=alpha).fit(x, y, sample_weight=weights) | |
| elif kind == "mlp": | |
| head = MLPRegressor(hidden_layer_sizes=(hidden, hidden), alpha=alpha, | |
| max_iter=500, early_stopping=False, random_state=seed) | |
| head.fit(x, y, sample_weight=weights) | |
| else: raise ValueError("head must be ridge or mlp") | |
| return scale, head | |
| def _predict_head(head, x): | |
| return np.asarray(head[1].predict(head[0].transform(x))) | |
| def train(data, output, kind="ridge", alpha=10., hidden=128, ensemble=5, folds=3, | |
| encoder="fingerprint", embeddings=None, seed=0): | |
| """Fit response heads and molecule-held-out residuals. | |
| data.response contains treated-minus-control means, one row per condition. | |
| Return the fitted model dictionary and save it with training metadata. | |
| """ | |
| if ensemble < 1: raise ValueError("Ensemble must contain at least one head") | |
| mask = data.obs.split.to_numpy() == "train" | |
| if not mask.any(): raise ValueError("No training conditions") | |
| train_obs = data.obs.loc[mask].reset_index(drop=True) | |
| chemical = encode(train_obs.smiles, encoder, embeddings) | |
| x = design_features(chemical, train_obs.dose_um, data.control[mask], kind) | |
| y = data.response[mask] | |
| groups = train_obs.molecule_id.to_numpy() | |
| molecules, counts = np.unique(groups, return_counts=True) | |
| if len(molecules) < max(folds, 3): raise ValueError("Too few independent training molecules") | |
| # Give each training molecule equal total weight across its conditions. | |
| base_weights = np.array([1/counts[np.where(molecules == m)[0][0]] for m in groups]) | |
| base_weights *= len(groups)/base_weights.sum() | |
| rng = np.random.default_rng(seed) | |
| heads = [] | |
| for k in range(ensemble): | |
| # Resample whole molecules so their doses and replicates stay together. | |
| sampled = rng.choice(molecules, len(molecules), replace=True) | |
| multiplicity = {m: np.sum(sampled == m) for m in molecules} | |
| weights = base_weights*np.array([multiplicity[m] for m in groups]) | |
| heads.append(_fit_head(x, y, weights, kind, alpha, hidden, seed+k)) | |
| residuals = np.empty_like(y) | |
| residual_distance = np.empty(len(y)) | |
| row_fp = fingerprints(train_obs.smiles) | |
| # Estimate residuals on compounds excluded from each fitted fold. | |
| for k, (fit, held) in enumerate(GroupKFold(n_splits=folds).split(x, y, groups)): | |
| head = _fit_head(x[fit], y[fit], base_weights[fit], kind, alpha, hidden, seed+100+k) | |
| residuals[held] = y[held]-_predict_head(head, x[held]) | |
| residual_distance[held] = 1-max_tanimoto(row_fp[held], np.unique(row_fp[fit], axis=0)) | |
| # Set endpoint-coordinate weights using training response variance. | |
| weight = 1/np.maximum(np.var(y, axis=0), np.median(np.var(y, axis=0))*.05+1e-6) | |
| weight /= weight.sum() | |
| unique_smiles = train_obs.drop_duplicates("molecule_id").smiles.tolist() | |
| train_fp = fingerprints(unique_smiles) | |
| # Cross-fitted residual rows and identities remain available for stratification. | |
| model = {"heads": heads, "kind": kind, "alpha": alpha, "hidden": hidden, | |
| "encoder": encoder, "embeddings": str(embeddings) if embeddings else None, | |
| "residuals": residuals, "residual_distance": residual_distance, | |
| "residual_smiles": train_obs.smiles.to_numpy(), | |
| "train_molecules": molecules.tolist(), "train_smiles": unique_smiles, | |
| "train_fingerprints": train_fp, "weight": weight, | |
| "feature_space_id": data.metadata.get("feature_space_id"), "study": data.metadata.get("study"), "seed": seed} | |
| output = Path(output); output.mkdir(parents=True, exist_ok=True) | |
| joblib.dump(model, output/"model.joblib") | |
| write_json(output/"training.json", {"kind": kind, "alpha": alpha, "hidden": hidden, | |
| "encoder": encoder, "ensemble": ensemble, "crossfit_folds": folds, | |
| "training_molecules": len(molecules), "training_conditions": len(y), "seed": seed, | |
| "feature_space_id": model["feature_space_id"], "model_sha256": file_hash(output/"model.joblib")}) | |
| return model | |
| def predict(model, smiles, dose, context, embeddings=None): | |
| """Return response predictions with shape (ensemble heads, actions, features).""" | |
| chemical = encode(smiles, model["encoder"], embeddings or model["embeddings"]) | |
| x = design_features(chemical, dose, np.asarray(context, float), model["kind"]) | |
| return np.stack([_predict_head(h, x) for h in model["heads"]]) | |
| def check_feature_space(model, data): | |
| if model.get("feature_space_id") != data.metadata.get("feature_space_id"): | |
| raise ValueError("Model and response data use different cell-feature spaces") | |