Skip to content

Add EnsembleMF: ensembled two-tower matrix factorization - #458

Open
PascalIversen wants to merge 6 commits into
developmentfrom
feat/ensemble-mf
Open

Add EnsembleMF: ensembled two-tower matrix factorization#458
PascalIversen wants to merge 6 commits into
developmentfrom
feat/ensemble-mf

Conversation

@PascalIversen

@PascalIversen PascalIversen commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Adds EnsembleMF, a two-tower matrix factorization model for drug response prediction.

Basically, I found that the graph convolutional part of my earlier idea does not really drive the performance. So this is the easiest model with which I get SOTA performance >RF.

AI summary:

Predicts the cell line x drug response matrix as a dot product of latent factors. The factors are
not free parameters but the outputs of small residual MLPs, one over gene expression and one over
Morgan fingerprints, so a held-out cell line can be scored from its features alone. On top of the
dot product sit per-cell, per-drug and global bias terms, a free per-drug id embedding (every drug
is seen during training, so an id-indexed latent is learnable and captures behaviour that
fingerprints only approximate), and a small interaction head. And then we ensemble the whole thing

PascalIversen and others added 5 commits August 11, 2026 11:03
…dings in EnsembleMF

predict() previously ignored its cell_line_input/drug_input arguments and only reused
tensors cached from train(), so cross-study prediction silently scored overlapping ids
with the training study's stale features instead of the ones actually passed in. It now
re-encodes from whatever features it's given, via a shared _encode_dataset helper, reusing
only the scaler fit during train().

The free per-drug id embedding assumed every drug is seen during training, but the id map
was built from all drugs with features, not just training-observed ones. A held-out
leave-drug-out drug therefore got an untrained, never-gradient-updated embedding added to
its prediction instead of being treated as unknown. A new _drug_seen_mask now restricts the
embedding contribution to drugs that actually appeared in a training batch.

Also: build_model() no longer seeds the global torch/numpy RNG (it was fully overridden by
train()'s per-member seeding anyway, so it only had the side effect of leaking into other
code sharing the process); per-member seeding is now scoped inside torch.random.fork_rng()
and correctly honors seed < 0 as an opt-out; an output_earlystopping set that doesn't
overlap the feature sets now raises instead of silently truncating training at `patience`
epochs; a missing scaler now raises a clear error instead of an AttributeError; and the
gene_list fallback default now matches the shipped hyperparameter.
…live per-call

The rank transform previously computed each gene's rank via argsort over whatever cell-line
cohort happened to be passed to a given call, refit every time. That had two consequences: at
train time it used held-out cell lines' own expression values to help compute ranks for the
training cohort (train/test feature leakage), and at predict time - now that predict()
actually re-encodes the features it is handed - a single new cell line scored alone collapsed
to an uninformative constant (rank of one value among itself is always the same), and the same
cell line could get different features depending on what other cell lines were batched
alongside it in the same call.

The rank reference is now a sorted per-gene array fit once from training cell lines (mirroring
how the scaler already worked), and every later call - including single-row predict() calls -
looks a new value up against that fixed reference instead of recomputing ranks live. Persisted
in save()/load() alongside the scaler.

Also trimmed the module/method docstrings of ablation-run commentary and formatting that
didn't describe current model behavior.
@PascalIversen
PascalIversen requested a lite review from Copilot August 18, 2026 12:52
@PascalIversen
PascalIversen marked this pull request as ready for review August 18, 2026 12:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new EnsembleMF multi-drug model to drevalpy for drug response prediction, implementing an ensembled two-tower matrix factorization with feature-derived latent factors (gene expression + Morgan fingerprints) and associated bias/interaction terms. The PR also wires the model into the factory/registry, documents it in Sphinx docs, and adds regression/smoke tests.

Changes:

  • Introduce drevalpy.models.EnsembleMF implementation, hyperparameters, and save/load support.
  • Register EnsembleMF in MODEL_FACTORY and include it in the global model test matrix.
  • Add docs + dedicated tests covering training, prediction, save/load, and unknown-id fallback behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_ensemble_mf.py New tests for factory registration, training/prediction, save/load round-trip, and unknown-id fallback.
tests/test_drp_model.py Adds EnsembleMF to the factory registration assertion.
tests/models/test_global_models.py Includes EnsembleMF in parametrized global-model tests with reduced hyperparams for speed.
drevalpy/models/EnsembleMF/hyperparameters.yaml Adds default hyperparameter grid for EnsembleMF.
drevalpy/models/EnsembleMF/ensemble_mf.py Implements the ensembled two-tower MF model, training loop, prediction logic, and persistence.
drevalpy/models/EnsembleMF/init.py Exposes EnsembleMF from the subpackage.
drevalpy/models/init.py Registers EnsembleMF in exports and MODEL_FACTORY.
docs/usage.rst Documents EnsembleMF in the model table and updates the table header wording.
docs/drevalpy.models.rst Adds drevalpy.models.EnsembleMF to the documented modules list.
docs/drevalpy.models.EnsembleMF.rst New Sphinx automodule page for EnsembleMF.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread drevalpy/models/EnsembleMF/ensemble_mf.py
Comment thread drevalpy/models/EnsembleMF/ensemble_mf.py Outdated
Comment thread drevalpy/models/EnsembleMF/ensemble_mf.py Outdated
Encode only the batch's unique cell/drug indices per mini-batch instead
of the whole feature matrices, and raise on an unrecognized
feature_transform value instead of silently falling back to arcsinh.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (3)

drevalpy/models/EnsembleMF/ensemble_mf.py:258

  • Similarly, fitting the StandardScaler on mat[np.isin(...)] will error cryptically if none of the training cell_line_ids have features. Since this can happen when IDs don’t overlap, it’s better to validate the overlap and raise a targeted ValueError.
        if len(train_ids) > 0:
            self._scaler = StandardScaler().fit(mat[np.isin(cell_ids, np.unique(train_ids))])

drevalpy/models/EnsembleMF/ensemble_mf.py:383

  • The automatic train/val split uses torch.randperm on the global RNG state and can also produce an empty training set when there’s only 1 training pair. This contradicts the docstring claim that seeding is scoped to a forked RNG state, and can make runs/tests nondeterministic or crash on tiny datasets.

Consider forking/seeding RNG locally for the split and handling len(y) < 2 by skipping the split (or using the same pair for both train and val).

        else:
            perm = torch.randperm(len(y), device=self.device)
            n_val = max(1, int(0.1 * len(y)))
            val = (ci[perm[:n_val]], di[perm[:n_val]], y[perm[:n_val]])
            ci, di, y = ci[perm[n_val:]], di[perm[n_val:]], y[perm[n_val:]]

drevalpy/models/EnsembleMF/ensemble_mf.py:240

  • When fitting the rank reference, mat[np.isin(...)] can be empty if none of the training cell_line_ids are present in the provided feature dataset. In that case, np.sort yields an empty reference and the subsequent scaler fit will fail with a generic sklearn error before the intended "No training pairs matched..." error is raised.

Add an explicit overlap check and raise a clear ValueError here.

This issue also appears on line 257 of the same file.

            if len(train_ids) > 0:
                self._rank_reference = np.sort(mat[np.isin(cell_ids, np.unique(train_ids))], axis=0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants