SLIM is a small bilinear model that uses gene embeddings (e.g. STRING) to predict single-cell genetic perturbation responses.
No GPU. No epochs. No learning-rate schedule. No early stopping. SLIM's weights come from a closed-form Ridge solve, so "training" is two small linear-algebra solves that finish in about ten seconds on a normal laptop CPU ⚡ — where GEARS, scGPT, CPA and scLAMBDA need GPU time measured in hours.
And this is not a speed-for-accuracy trade. On the manuscript benchmarks (mean Pearson-of-delta on held-out perturbations, higher is better):
| Dataset | SLIM ⚡ | scLAMBDA | scGPT | GEARS | CPA |
|---|---|---|---|---|---|
| jurkat | 0.307 | 0.279 | 0.176 | 0.170 | 0.107 |
| hepg2 | 0.359 | 0.327 | 0.297 | 0.280 | -0.049 |
| replogle_k562_essential | 0.499 | 0.479 | 0.303 | 0.347 | 0.123 |
| replogle_rpe1_essential | 0.613 | 0.621 | 0.568 | 0.568 | 0.105 |
norman (2-gene,combo_seen1) | 0.669 | 0.599 | 0.490 | 0.602 | 0.332 |
norman (2-gene,combo_seen2) | 0.770 | 0.625 | 0.542 | 0.632 | 0.308 |
SLIM is first on five of six settings and a hair behind on the sixth — at a fraction of the compute, with ~2 hyperparameters (K, lambda_reg) and no training curve to watch. Numbers are in manuscript-results/results/test_mean_scores.csv; the full comparison, including the harder unseen_single norman split, is in REPRODUCE.md.
📄 Reproducing the manuscript? This README is a minimal install → train → eval guide for running SLIM on your own data. Every figure and table in the paper — data prep, all SLIM variants, ablations, all benchmarked baselines, evaluation and plotting — is reproduced step by step in
REPRODUCE.md.
Y = G @ W @ P.T + b
G— low-rank gene basis (PCA by default), shape(n_genes, K)W— bilinear weight mapping embedding space → basis (closed-form Ridge),(K, D)P— per-gene embedding vectors (STRING/SPACE/…),(n_perts, D)b— per-gene bias,(n_genes, 1)
Given the embedding of a held-out gene, SLIM predicts the mean expression profile of cells where that gene is perturbed. Because W has a closed form, fitting is ~10 seconds of CPU time ⚡ — there is no iterative optimizer anywhere in the path, and no GPU is required at any point.
Single-cell expression is pseudo-bulked per perturbation, centered, and reduced to a K-dimensional gene basis G by PCA (1). A weight matrix W is solved in closed form by Ridge regression (2) against pre-trained gene embeddings P (e.g. STRING, 3). The predicted mean shift plus the mean profile gives the predicted mean expression, which is turned back into a synthetic cell population by randomly sampling cells from the perturbation pools (4) and rescaling them to the predicted mean (5).
git clone https://github.com/RasmussenLab/SLIM &&cd SLIM
conda create -n slim python=3.9 && conda activate slim
pip install -e .# installs the `slim` packageIf you plan to load GEARS-format datasets (the datasets used in the manuscript — jurkat, hepg2, k562, rpe1, and norman; see Usage A), also:
pip install cell-gears
pip install torch_geometric ## required by cell-gearsFor the test suite: pip install -e ".[dev]" && pytest tests/.
An expression dataset (AnnData) with a per-cell perturbation label column in
.obsand a control group. Genes are the.varaxis.A gene-embedding file — an HDF5 file with one dataset per gene symbol, each a 1-D vector:
importh5py, numpyasnpwithh5py.File("embeddings.h5", "w") asf: f["ATF2"] =np.random.randn(64) # one entry per gene symbolf["BRAF"] =np.random.randn(64) ...
The gene symbols here must match the perturbation labels used in your data. A STRING-derived file (
data/gene_string_embeddings.v0.3.h5) is what the manuscript uses: the embeddings come from Hu, D., Schaap-Johansen, AL., Villarroel, J. et al. "Molecular maps of diseases from omics data and network embeddings", npj Systems Biology and Applications (2026), https://doi.org/10.1038/s41540-026-00746-8 — we mapped their protein/STRING identifiers to gene symbols. Please cite that paper if you use them.
Train → evaluate, end to end.
If your data is in GEARSPertData format (or is a built-in GEARS dataset like norman, adamson, replogle_k562_essential, …), everything is wired up already. Use the CLI:
# single-perturbation datasets, the dataset will be downloaded by gears
python scripts/models/run_slim.py \
--data_name replogle_k562_essential \
--embedding data/gene_string_embeddings.v0.3.h5 \
--K 10 --lambda_reg 0.1
# single + double perturbations (e.g. norman)
python scripts/models/run_slim_combo.py \
--embedding data/gene_string_embeddings.v0.3.h5 \
--K 10 --lambda_reg 0.1This writes a predicted result.h5ad (a synthetic cell population built from the predicted means) to saved_models/slim_<embedding>/<dataset>_seed<seed>/result.h5ad.
⚡ Almost all the wall-clock time you see is loading the .h5ad and pseudo-bulking it — the fit itself is ~10 seconds on CPU. There is no --device, no --epochs, and no --lr flag, because there is nothing to iterate.
Or drive it from Python:
fromslim.configimportSlimConfigfromslim.modelimportBilinearModelfromslim.datasetsimportget_dataset_adapterfromslim.pipelineimportinfer, save_resultsconfig=SlimConfig(K=10, lambda_reg=0.1)
adapter=get_dataset_adapter(
"gears", data_name="replogle_k562_essential", seed=1, mode="single",
condition_col=config.condition_col, control_tag=config.control_tag,
).load()
model=BilinearModel("data/gene_string_embeddings.v0.3.h5", config)
model.fit(adapter) # fits on adapter.train_perts()predictions=infer(adapter, model) # {gene: predicted_mean_expression}save_results(adapter, predictions, "out/replogle_k562_essential", config, model=model)Key SlimConfig knobs: K (basis rank), lambda_reg (Ridge strength), string_dim (embedding dims to use), basis_method, bias_method. See src/slim/config.py for all fields and defaults, and REPRODUCE.md §4 for the full CLI flag reference.
Score the result.h5ad you just wrote against the real held-out data — for a GEARS dataset that's the processed h5ad in its data directory:
python scripts/eval/run_eval.py \
saved_models/slim_string/replogle_k562_essential_seed1/result.h5ad \
data/replogle_k562_essential/perturb_processed.h5ad \
saved_models/slim_string/replogle_k562_essential_seed1/evalThe three positional arguments are prediction, ground truth, and output directory. It writes results.csv (per-perturbation Pearson-of-delta, MSE, MAE, MMD) and agg_results.csv (mean/median across perturbations) into that directory. No extra dependencies beyond slim's own.
The metric code itself is in src/slim/eval.py if you'd rather call it directly on in-memory arrays.
Train → evaluate, end to end.
SLIM's models never touch GEARS directly — they only talk to a small DatasetAdapter interface. To use your own data format, implement that interface once; the model, inference, and result-writing code all work unchanged.
Required pieces of the interface (src/slim/datasets/base.py):
| Member | Meaning |
|---|---|
condition_col (attr) | L.obs column holding the perturbation label |
control_tag (attr) | value in that column marking control cells |
load() | load the data; returnself |
adata (property) | the full AnnData (all splits) |
train_perts() | perturbation labels to fit on |
val_perts() | held-out validation labels (excluded from sampling pool) |
test_perts() | labels to predict at inference |
A minimal adapter for a plain AnnData whose obs["condition"] already holds bare gene names ("ATF2", "BRAF", …, and "ctrl" for controls):
importscanpyasscfromslim.datasets.baseimportDatasetAdapterclassMyAdapter(DatasetAdapter):
condition_col="condition"control_tag="ctrl"def__init__(self, h5ad_path, train, val, test):
self._path=h5ad_pathself._train, self._val, self._test=train, val, testdefload(self):
self._adata=sc.read_h5ad(self._path)
returnself@propertydefadata(self):
returnself._adatadeftrain_perts(self):
returnself._train# e.g. ["ATF2", "BRAF", ...]defval_perts(self):
returnself._valdeftest_perts(self):
returnself._testThen train and predict exactly as in Usage A:
fromslim.configimportSlimConfigfromslim.modelimportBilinearModelfromslim.pipelineimportinfer, save_resultsconfig=SlimConfig(K=10, lambda_reg=0.1)
adapter=MyAdapter("my_data.h5ad",
train=["ATF2", "BRAF"], val=["MYC"], test=["TP53"]).load()
model=BilinearModel("embeddings.h5", config)
model.fit(adapter)
predictions=model.predict(adapter.test_perts()) # {gene: mean expr (n_genes,)}save_results(adapter, predictions, "out/mydata", config, model=model)model.predict(...) returns a plain dict of predicted mean-expression vectors — if you only want the numbers, stop there and skip save_results (which additionally builds a synthetic result .h5ad for downstream scoring).
Notes / overridable hooks (defaults are usually fine):
format_condition(pert)— override if the label you predict differs from what's stored inobs[condition_col](e.g. predict"ATF2"but the column stores"ATF2+ctrl").test_cell_counts(perts)— override if the number of real cells per test perturbation lives outsideadata.raw_pseudobulk(perts)— only needed forbasis_method="nmf".
You can also register your adapter under a name and construct it via the same factory the CLI uses:
fromslim.datasetsimportregister_adapter, get_dataset_adapterregister_adapter("my_format", MyAdapter)
adapter=get_dataset_adapter("my_format", ...).load()Same scoring step as Usage A, pointed at your own paths — the result.h5adsave_results wrote, and your real held-out data:
python scripts/eval/run_eval.py \
out/mydata/result.h5ad \
my_real_test_data.h5ad \
out/mydata/evalWrites results.csv and agg_results.csv (Pearson-of-delta, MSE, MAE, MMD) into the output directory. If you skipped save_results and kept only the predicted means, call the metrics in src/slim/eval.py directly on your arrays instead.
REPRODUCE.md is the companion document for the paper. It covers, in order: environment setup and the external files you have to download yourself (GenePT / DepMap embeddings, the scGPT checkpoint); building the jurkat and hepg2 datasets from raw GEO counts; running SLIM on every dataset (single and single+double perturbations) with the full CLI flag reference; the hyperparameter and training-sample ablations; every benchmarked baseline (TrainMean, LinearSC, RidgeSC, MLP, autoencoders, GEARS, CPA, scGPT, scLAMBDA) and what each one tests; scoring predictions; and generating the figures.
src/slim/
model.py BilinearModel — single-perturbation SLIM
model_combo.py BilinearComboModel — single + double perturbations
config.py SlimConfig — all hyperparameters
basis.py/bias.py basis & bias strategies (pca/nmf/… , train_mean/ctrl_mean)
embeddings.py gene-embedding HDF5 loading
pipeline.py infer() / save_results()
scaffold.py synthetic result-population builder
eval.py evaluation metrics
datasets/ DatasetAdapter interface + GearsAdapter
scripts/ CLI entrypoints, ablations, eval, plotting
tests/ pytest suite (synthetic fixtures, no real data needed)
