Feature/mil benchmark and representation methods - #154
Conversation
…ions New modules ----------- src/patpy/tl/mil_models.py TorchMILWrapper adapter for torchmil (ABMIL, TransMIL, DSMIL) with per-label training, cell-importance extraction, and multi-class support. src/patpy/tl/benchmark.py MILBenchmark: cross-validated benchmark for inductive MIL models. RepresentationBenchmark: benchmark for any method that returns sample representations (unsupervised Pseudobulk / CellComposition / GroupedPseudobulk and supervised MixMIL / ABMIL used as feature extractors), fitting a LogisticRegression / Ridge probe on train and evaluating on val/test. src/patpy/pp/splits.py make_sample_splits: stratified train/val/test splitting at sample level. src/patpy/pl/mil.py plot_attention_umap, plot_attention_by_cell_type, plot_attention_celltype_heatmap: cell-level attention visualisations. Changes to existing files -------------------------- src/patpy/tl/supervised.py MixMIL: get_sample_representations, fine_tune, predict_on_adata. PULSAR / PaSCient: get_sample_representations. src/patpy/pl/__init__.py, src/patpy/pp/__init__.py, src/patpy/tl/__init__.py Export new public symbols. pyproject.toml Add torchmil and related optional dependencies. docs/notebooks/mil_methods_example.ipynb Tutorial covering MIL training, cross-validated benchmarking, patient UMAPs, representation quality, ROC curves, and attention plots for multiple labels.
n_classes in _build_tensors now reads from _label_mappings (built on full adata) instead of np.unique on the training split, preventing index-out-of- bounds when a class is absent from the current split. Also rename `col` → `lk_vals` in MILBenchmark._run_one_split to avoid shadowing the outer split-column variable.
…tcome) _build_label_mappings and MILBenchmark pre-population both skipped numeric columns. Numeric classification labels (e.g. Outcome with values 2.0-5.0) were cast directly to int, producing out-of-range class indices when target values don't start at 0. Fix: build a sorted 0-indexed class mapping for all classification labels regardless of dtype.
ABMIL does not expose intermediate bag embeddings so it cannot be used as a representation extractor. Remove it from the rep_models dict.
Use a forward hook on model.classifier to capture the bag embedding (attention-weighted aggregation of cell features) as the patient representation. Re-adds ABMIL to the RepresentationBenchmark.
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
|
We moved the tutorials to https://github.com/lueckenlab/patpy_tutorials , can you push the notebook there instead? |
|
Also please render the notebook. It will appear in the documentation in the same way as it looks as a notebook. So all the images and cell outputs must be there |
VladimirShitov
left a comment
There was a problem hiding this comment.
This is a very valuable contribution, thanks for implementing it! I left some comments that need to be addressed before the PR can be merged. Briefly:
- The tutorial notebook should be moved to a separate repo and rendered. Also, I'd advocate for extending supervised methods example notebook instead of creating a new one.
- Benchmark functionality should be deleted from this PR and moved to another one. It's definitely very interesting, but too much for this PR, and it will slow down the merging.
- Some code pieces need improvement, see the comments below.
| "raw pre-softmax scores — returning softmax-normalised weights instead.", | ||
| stacklevel=3, | ||
| ) | ||
| return model.get_cell_importance(label=label) |
There was a problem hiding this comment.
This should simply be an extension of get_cell_importance method in the corresponding class. I don't see necessity in a separate function, and certainly not in the plotting module.
| att_col = f"{label}_importance" | ||
| weight_label = "Mean attention (softmax)" if normalized else "Mean attention score (pre-softmax)" | ||
|
|
||
| frame = pd.DataFrame( |
There was a problem hiding this comment.
This is repeated in every function, worth a separate util function
| # Capture pre-softmax inputs via a hook on nn.Softmax modules | ||
| pre_softmax: list[torch.Tensor] = [] | ||
|
|
||
| def _make_hook(store: list): |
There was a problem hiding this comment.
Why is it needed on what does it do? Please leave a comment in the code
| model.eval() | ||
| weights_all: list[np.ndarray] = [] | ||
|
|
||
| if normalized: |
There was a problem hiding this comment.
This function looks very complicated and some blocks of code repeat in different branches. Can it be simplified?
| captured: list[torch.Tensor] = [] | ||
|
|
||
| def _hook(module, inp, out): | ||
| captured.append(inp[0].detach().cpu()) |
There was a problem hiding this comment.
Is there a simpler way of doing it?
| lr: float = 1e-3, | ||
| weight_decay: float = 1e-4, | ||
| device: str = "auto", | ||
| seed: int = 42, |
There was a problem hiding this comment.
Just to double check, are the defaults the same as in original repo or the ones authors recommend?
| weight_decay=weight_decay, | ||
| device=device, | ||
| seed=seed, | ||
| ) |
There was a problem hiding this comment.
I love how simple these classes look, this is great!
torchmil models expose return_rep in their forward signature; use it directly instead of a classifier-input hook. Falls back to the hook for models that don't support return_rep.
- Add TransMIL and DSMIL to make_models with max_cells_per_bag=2000 to prevent OOM on large COMBAT bags (up to ~9k cells/patient) - Use return_rep=True (local torchmil extension) for bag embeddings with fallback to classifier-input hook for stock torchmil - Add paper-ready summary table: mean ± std on test set, pivoted by label × model × metric - Add patient representation UMAPs for all methods (unsupervised + MixMIL/ABMIL) using model.plot_embedding()
- Remove module-level docstring header from pl/mil.py (reviewer request) - Replace _get_cell_importance wrapper with _store_importance that writes weights into adata.obs for sc.pl compatibility - plot_attention_umap: use sc.pl.umap per-class subset instead of raw matplotlib scatter - plot_attention_by_cell_type: use sc.pl.violin grouped by cell type - Add normalized= parameter to MixMIL.get_cell_importance for API consistency with TorchMILWrapper (MixMIL always returns softmax weights) - Fix val_size docstring in splits.py: clarifies it is a fraction of all samples, not of remaining (non-test) samples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Could you also add tests to make sure the code is running well? Coding agents typically do it well |
| label_keys: list[str], | ||
| tasks: list[_PREDICTION_TASKS], | ||
| n_splits: int = 3, | ||
| split_col: str = "split", |
There was a problem hiding this comment.
There is in common cases no split for unsupervised representation methods, no?
| else: | ||
| return pd.Series(probe.predict(X), index=index, name=label) | ||
|
|
||
| def _compute_metrics( |
There was a problem hiding this comment.
Running time could be interesting to report as well. @VladimirShitov wdyt?
|
@Dinesh-Adhithya-H , could you address comments in this PR and move the benchmarking code to another one? |
No description provided.