Download predict.py from inflammatory-aging-clock/somascan-85: direct link, hf CLI and curl.
- Browser
- Download file 7.74 kB
-
https://huggingface.co/inflammatory-aging-clock/somascan-85/resolve/main/predict.py
- Command line
-
hf download hf://inflammatory-aging-clock/somascan-85/predict.py
-
curl -L -o predict.py https://huggingface.co/inflammatory-aging-clock/somascan-85/resolve/main/predict.py
7.74 kB
| #!/usr/bin/env python3 | |
| """Standalone inference for the Inflammatory Aging Clock TabM student models. | |
| Runs the distilled TabM student with public dependencies only: | |
| pip install torch tabm rtdl_num_embeddings numpy pandas | |
| Usage | |
| ----- | |
| python predict.py --input proteins.csv --output ages.csv | |
| Input | |
| ----- | |
| A CSV/TSV with one row per sample and one column per protein. Column names must | |
| match the model's feature names (e.g. ``OID00471_CXCL8`` for MARS, the SomaScan | |
| SeqId-style names for ROSMAP — see ``<model_dir>/meta.json`` -> ``feature_name``). | |
| An optional first ``sample_id`` (or ``SAMPLE.ID``) column is carried through to | |
| the output. Missing proteins are an error; NaN cells are median-imputed for | |
| models trained with a median-impute preprocessor (MARS), and rejected otherwise. | |
| Output | |
| ------ | |
| A CSV with columns ``sample_id, predicted_age``. | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| # --- pinball5 reparameterization (numpy mirror of the training code) --------- | |
| PINBALL5_ALPHAS = np.array([0.1, 0.25, 0.5, 0.75, 0.9], dtype=np.float64) | |
| def _reparameterize_pinball5_np(raw_5): | |
| """raw (..., 5) -> monotone quantiles (q01, q025, q05, q075, q09).""" | |
| def softplus(x): | |
| return np.log1p(np.exp(-np.abs(x))) + np.maximum(x, 0) | |
| m = raw_5[..., 0] | |
| s1 = softplus(raw_5[..., 1]); s2 = softplus(raw_5[..., 2]) | |
| s3 = softplus(raw_5[..., 3]); s4 = softplus(raw_5[..., 4]) | |
| return np.stack([m - s1 - s2, m - s2, m, m + s3, m + s3 + s4], axis=-1) | |
| def _pinball5_point_estimate_np(raw_5, alphas=PINBALL5_ALPHAS): | |
| """Trapezoidal-mean point estimate from raw (..., 5). Matches the teacher's mean.""" | |
| q = _reparameterize_pinball5_np(raw_5) | |
| slope_low = (q[..., 1] - q[..., 0]) / (alphas[1] - alphas[0]) | |
| slope_high = (q[..., 4] - q[..., 3]) / (alphas[4] - alphas[3]) | |
| q0 = q[..., 0] - slope_low * alphas[0] | |
| q1 = q[..., 4] + slope_high * (1.0 - alphas[4]) | |
| q_full = np.concatenate([q0[..., None], q, q1[..., None]], axis=-1) | |
| a_full = np.array([0.0, *alphas, 1.0]) | |
| trapz = np.trapezoid if hasattr(np, "trapezoid") else np.trapz # numpy>=2 vs <2 | |
| return trapz(q_full, a_full, axis=-1).astype(np.float32, copy=False) | |
| # --- preprocessing (median-impute + observed-mask channels) ------------------ | |
| def _apply_preproc(X, npz_path): | |
| """Median-impute NaN and append a mask channel for partially observed columns.""" | |
| d = np.load(npz_path) | |
| medians = d["medians"].astype(np.float32) | |
| has_nan_cols = (d["has_nan_cols"].astype(bool) if "has_nan_cols" in d.files | |
| else np.ones(medians.shape[0], dtype=bool)) | |
| X = np.asarray(X, dtype=np.float32) | |
| nan = np.isnan(X) | |
| X_imp = np.where(nan, medians, X).astype(np.float32, copy=False) | |
| if not has_nan_cols.any(): | |
| return X_imp | |
| M = (~nan[:, has_nan_cols]).astype(np.float32) | |
| return np.concatenate([X_imp, M], axis=1) | |
| # --- model construction ------------------------------------------------------ | |
| def _build_model(meta, weights_path): | |
| import tabm as _tabm | |
| state = torch.load(weights_path, map_location="cpu", weights_only=False) | |
| if isinstance(state, dict) and "state_dict" in state: | |
| sd, saved_bins = state["state_dict"], state.get("bins") | |
| else: | |
| sd, saved_bins = state, None | |
| arch = meta["arch"] | |
| em = arch["embed_meta"] | |
| if em.get("kind") == "piecewise_linear": | |
| import rtdl_num_embeddings as _rne | |
| if saved_bins is None: | |
| raise ValueError("Model declares piecewise_linear embeddings but .pt has no 'bins'.") | |
| emb = _rne.PiecewiseLinearEmbeddings( | |
| saved_bins, d_embedding=int(em["d_embedding"]), | |
| activation=bool(em.get("activation", False)), version=em.get("version", "B"), | |
| ) | |
| elif em.get("kind") in (None, "none"): | |
| emb = None | |
| else: | |
| raise ValueError(f"Unsupported embed_meta.kind={em.get('kind')!r}") | |
| model = _tabm.TabM( | |
| n_num_features=int(arch["d_in"]), cat_cardinalities=[], | |
| d_out=int(arch["n_outputs"]), num_embeddings=emb, **arch["tabm_kwargs"], | |
| ) | |
| model.load_state_dict(sd) | |
| model.eval() | |
| return model | |
| def _predict_scaled(model, X, distill_loss, batch_size=512): | |
| """Forward TabM -> (n,) scaled-y prediction, averaged over the k ensemble members.""" | |
| out_chunks = [] | |
| with torch.no_grad(): | |
| for i in range(0, len(X), batch_size): | |
| xb = torch.from_numpy(X[i:i + batch_size].astype(np.float32, copy=False)) | |
| out = model(x_num=xb, x_cat=None).float().cpu().numpy() # (B, k, d_out) | |
| if distill_loss == "pinball5": | |
| point_bk = _pinball5_point_estimate_np(out) # (B, k) | |
| out_chunks.append(point_bk.mean(axis=1)) | |
| else: | |
| out_chunks.append(out.squeeze(-1).mean(axis=1)) | |
| return np.concatenate(out_chunks, axis=0) | |
| # --- I/O --------------------------------------------------------------------- | |
| def _read_table(path): | |
| sep = "\t" if path.lower().endswith((".tsv", ".txt", ".tsv.gz", ".txt.gz")) else "," | |
| return pd.read_csv(path, sep=sep) | |
| def main(): | |
| ap = argparse.ArgumentParser(description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("--model_dir", default=".", | |
| help="Folder containing meta.json + models/ (default: current directory).") | |
| ap.add_argument("--input", required=True, help="CSV/TSV of proteins (samples x features).") | |
| ap.add_argument("--output", required=True, help="Where to write predictions CSV.") | |
| args = ap.parse_args() | |
| meta = json.load(open(os.path.join(args.model_dir, "meta.json"))) | |
| feats = meta["feature_name"] | |
| df = _read_table(args.input) | |
| # Carry a sample id column through if present. | |
| id_col = next((c for c in ("sample_id", "SAMPLE.ID", "SampleID", "ID") if c in df.columns), None) | |
| sample_ids = df[id_col].astype(str).values if id_col else np.arange(len(df)).astype(str) | |
| # The model needs EVERY trained protein present, by exact name. Column order | |
| # does not matter (selected/reordered below). Hard-fail on any missing one. | |
| feat_set = set(feats) | |
| missing = [f for f in feats if f not in df.columns] | |
| if missing: | |
| sys.exit(f"ERROR: input is missing {len(missing)}/{len(feats)} required protein " | |
| f"column(s), e.g. {missing[:5]}. Column names must match " | |
| f"{args.model_dir}/meta.json -> feature_name exactly.") | |
| # Warn about input columns the model does not use (often a naming mismatch). | |
| extra = [c for c in df.columns if c not in feat_set and c != id_col] | |
| if extra: | |
| print(f"WARNING: ignoring {len(extra)} input column(s) not used by the model, " | |
| f"e.g. {extra[:5]}.", file=sys.stderr) | |
| print(f"Matched all {len(feats)} required feature columns.", file=sys.stderr) | |
| X = df[feats].to_numpy(dtype=np.float32) # (n, n_features_raw), column order = feats | |
| if meta.get("preproc"): | |
| X = _apply_preproc(X, os.path.join(args.model_dir, meta["preproc"]["file"])) | |
| elif np.isnan(X).any(): | |
| sys.exit("ERROR: input contains NaN but this model has no imputer. " | |
| "Provide complete protein values.") | |
| model = _build_model(meta, os.path.join(args.model_dir, meta["student_weights"])) | |
| scaled = _predict_scaled(model, X, meta["regression_distill_loss"]) | |
| age = scaled * meta["y_scaler"]["scale"] + meta["y_scaler"]["mean"] | |
| out = pd.DataFrame({"sample_id": sample_ids, "predicted_age": age}) | |
| out.to_csv(args.output, index=False) | |
| print(f"Wrote {len(out)} predictions to {args.output}") | |
| if __name__ == "__main__": | |
| main() | |