diff --git a/CHANGELOG.md b/CHANGELOG.md
index a45a663e83..269a05a1f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,11 @@ to [Semantic Versioning]. The full commit history is available in the [commit lo
#### Removed
+- Remove {class}`scvi.external.DestVI`, {class}`scvi.external.GimVI`, {class}`scvi.external.ResolVI`,
+ {class}`scvi.external.SCVIVA`, {class}`scvi.external.DiagVI`, {class}`scvi.external.Stereoscope`,
+ and {class}`scvi.external.Tangram` spatial models, along with their associated tutorials and
+ documentation, {pr}`3908`.
+
### 1.5.0 (2026-07-08)
#### Added
diff --git a/docs/api/developer.md b/docs/api/developer.md
index d80eb6f72c..557f90f7b5 100644
--- a/docs/api/developer.md
+++ b/docs/api/developer.md
@@ -154,7 +154,6 @@ Existing module classes with respective generative and inference procedures.
module.AutoZIVAE
module.Classifier
module.LDVAE
- module.MRDeconv
module.PEAKVAE
module.MULTIVAE
module.SCANVAE
@@ -178,12 +177,9 @@ Module classes in the external API with respective generative and inference proc
:template: class_no_inherited.rst
:nosignatures:
- external.gimvi.JVAE
external.cytovi.CytoVAE
external.cellassign.CellAssignModule
external.contrastivevi.ContrastiveDataSplitter
- external.stereoscope.RNADeconv
- external.stereoscope.SpatialDeconv
external.scbasset.ScBassetModule
external.contrastivevi.ContrastiveVAE
external.velovi.VELOVAE
@@ -191,12 +187,8 @@ Module classes in the external API with respective generative and inference proc
external.methylvi.METHYLVAE
external.methylvi.METHYLANVAE
external.decipher.DecipherPyroModule
- external.resolvi.RESOLVAE
external.totalanvi.TOTALANVAE
- external.scviva.nicheVAE
- external.scviva.NicheLossOutput
external.sysvi.SysVAE
- external.diagvi.DIAGVAE
external.drvi.DRVIModule
external.JointEmbeddingVAE
```
diff --git a/docs/api/user.md b/docs/api/user.md
index c25177f33d..9677a15b9d 100644
--- a/docs/api/user.md
+++ b/docs/api/user.md
@@ -25,7 +25,6 @@ import scvi
model.AUTOZI
model.CondSCVI
- model.DestVI
model.LinearSCVI
model.PEAKVI
model.SCANVI
@@ -49,9 +48,6 @@ import scvi
external.CellAssign
external.CYTOVI
- external.GIMVI
- external.RNAStereoscope
- external.SpatialStereoscope
external.SOLO
external.SCAR
external.SCBASSET
@@ -63,13 +59,9 @@ import scvi
external.METHYLANVI
external.Decipher
external.TOTALANVI
- external.RESOLVI
external.SysVI
- external.SCVIVA
- external.DIAGVI
external.DRVI
external.JointEmbeddingSCVI
- external.Tangram
```
## Data loading
diff --git a/docs/installation.md b/docs/installation.md
index b956389913..fc3a4f9977 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -90,7 +90,6 @@ It has many optional dependencies which expand its capabilities:
- _parallel_ - for parallelization engine
- _interpretability_ - for supervised models interpretability
- _dataloaders_ - for custom dataloaders use
-- _diagvi_ - for DiagVI model (requires torch_geometric and geomloss)
- _mlflow_ - for MLflow support
- _tests_ - in order to be able to perform tests
- _editing_ - for code editing
diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md
index b99da709ed..bdc5f8fd15 100644
--- a/docs/tutorials/index.md
+++ b/docs/tutorials/index.md
@@ -19,7 +19,6 @@ index_atac
index_cytometry
index_scbs
index_multimodal
-index_spatial
index_hub
index_use_cases
index_custom_dl
diff --git a/docs/tutorials/index_multimodal.md b/docs/tutorials/index_multimodal.md
index dddf3f84f8..866d5af2a2 100644
--- a/docs/tutorials/index_multimodal.md
+++ b/docs/tutorials/index_multimodal.md
@@ -9,8 +9,6 @@ notebooks/multimodal/totalVI_reference_mapping
notebooks/multimodal/cite_scrna_integration_w_totalVI
notebooks/multimodal/MultiVI_tutorial
notebooks/multimodal/totalanvi
-notebooks/multimodal/DiagVI_spatial_proteomics.ipynb
-notebooks/multimodal/DiagVI_spatial_transcriptomics.ipynb
```
```{customcard}
@@ -54,17 +52,3 @@ Go through the MultiVI workflow to perform joint analysis of paired and unpaired
Use TotalANVI to perform semi-supervised analysis of CITE-seq data, leveraging partial cell type annotations for label prediction, protein imputation, and differential abundance
```
-
-```{customcard}
-:path: notebooks/multimodal/DiagVI_spatial_proteomics
-:tags: Analysis, Integration, Modality-imputation, Dimensionality-reduction
-
-Perform integration of spatial proteomics and single-cell transcriptomics data with DiagVI
-```
-
-```{customcard}
-:path: notebooks/multimodal/DiagVI_spatial_transcriptomics
-:tags: Analysis, Integration, Modality-imputation, Dimensionality-reduction
-
-Perform integration of spatial and single-cell transcriptomics data with DiagVI
-```
diff --git a/docs/tutorials/index_spatial.md b/docs/tutorials/index_spatial.md
deleted file mode 100644
index c67af7294f..0000000000
--- a/docs/tutorials/index_spatial.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# Spatial transcriptomics
-
-```{toctree}
-:maxdepth: 1
-
-notebooks/spatial/resolVI_tutorial
-notebooks/spatial/scVIVA_tutorial
-notebooks/spatial/DestVI_tutorial
-notebooks/spatial/gimvi_tutorial
-notebooks/spatial/tangram_scvi_tools
-notebooks/spatial/stereoscope_heart_LV_tutorial
-notebooks/spatial/cell2location_lymph_node_spatial_tutorial
-```
-
-```{customcard}
-:path: notebooks/spatial/resolVI_tutorial
-:tags: Analysis, Integration, Transfer-learning, Dimensionality-reduction
-
-Use resolVI to correct cellular-resolved spatial transcriptomics data.
-```
-
-```{customcard}
-:path: notebooks/spatial/scVIVA_tutorial
-:tags: Analysis, Integration, Dimensionality-reduction, Differential-comparison
-
-Stratify spatial transcriptomics data into niche-aware cell states with scVIVA
-```
-
-```{customcard}
-:path: notebooks/spatial/DestVI_tutorial
-:tags: Deconvolution, Modality-imputation, Differential-comparison
-
-Perform multi-resolution analysis on spatial transcriptomics data with DestVI
-```
-
-```{customcard}
-:path: notebooks/spatial/gimvi_tutorial
-:tags: Modality-imputation, Integration
-
-Use gimVI to impute missing genes in spatial data
-```
-
-```{customcard}
-:path: notebooks/spatial/tangram_scvi_tools
-:tags: Deconvolution, Analysis
-
-Use Tangram to map spatial transcriptomics data
-```
-
-```{customcard}
-:path: notebooks/spatial/stereoscope_heart_LV_tutorial
-:tags: Deconvolution, Integration
-
-Go through the Stereoscope workflow to map single-cell data
-```
-
-```{customcard}
-:path: notebooks/spatial/cell2location_lymph_node_spatial_tutorial
-:tags: Dev, Analysis, Integration
-
-Spatially map lymph node cell types using Cell2location
-```
diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md
index a895900f67..76d5d9bac9 100644
--- a/docs/user_guide/index.md
+++ b/docs/user_guide/index.md
@@ -149,42 +149,9 @@ scvi-tools is composed of models that can perform one or many analysis tasks. In
* - :doc:`/user_guide/models/multivi`
- Integration of paired/unpaired multiome data, missing modality imputation, normalization of other cell- and sample-level confounding factors
- :cite:p:`AshuachGabitto21`
- * - :doc:`/user_guide/models/diagvi`
- - Diagonal integration of unpaired multiome data, dimensionality reduction, cross-modality imputation, cell label transfer
- - []
```
-## Spatial transcriptomics analysis
-
-```{eval-rst}
-.. list-table::
- :widths: 15 100 25
- :header-rows: 1
-
- * - Model
- - Tasks
- - Reference
- * - :doc:`/user_guide/models/destvi`
- - Multi-resolution deconvolution, cell-type-specific gene expression imputation, comparative analysis
- - :cite:p:`Lopez22`
- * - :doc:`/user_guide/models/stereoscope`
- - Deconvolution
- - :cite:p:`Andersson20`
- * - :doc:`/user_guide/models/gimvi`
- - Imputation of missing spatial genes
- - :cite:p:`Lopez19`
- * - :doc:`/user_guide/models/tangram`
- - Deconvolution, single cell spatial mapping
- - :cite:p:`Biancalani21`
- * - :doc:`/user_guide/models/resolvi`
- - Generative model of single-cell resolved spatial transcriptomics
- - :cite:p:`Ergen25`
- * - :doc:`/user_guide/models/scviva`
- - Representation of cells and their environments in spatial transcriptomics
- - :cite:p:`Levy25`
-```
-
## General purpose analysis
```{eval-rst}
diff --git a/docs/user_guide/models/destvi.md b/docs/user_guide/models/destvi.md
deleted file mode 100644
index 01ce1bcd96..0000000000
--- a/docs/user_guide/models/destvi.md
+++ /dev/null
@@ -1,258 +0,0 @@
-# DestVI
-
-**DestVI** {cite:p}`Lopez22` (Deconvolution of Spatial Transcriptomics profiles using Variational Inference; Python class {class}`~scvi.model.DestVI`)
-posits a conditional generative model of spatial transcriptomics down to the sub-cell-type variation level which
-can be used to explore the spatial organization of a tissue and understanding gene expression variation between tissues and conditions.
-
-The advantages of DestVI are:
-
-- Can stratify cells into discrete cell types and model continuous sub-cell-type variation.
-- Scalable to very large datasets (>1 million cells).
-
-The limitations of DestVI include:
-
-- Effectively requires a GPU for fast inference.
-
-:::{note}
-Starting scVI-Tools v1.5 this model is part of scVIVA-Tools, and no longer being maintained here.
-:::
-
-```{topic} Tutorial:
-
-- {doc}`/tutorials/notebooks/spatial/DestVI_tutorial`
-```
-
-## Preliminaries
-
-DestVI requires training two models, the scLVM (single-cell latent variable model) and the
-stLVM (spatial transcriptomic latent variable model). The scLVM takes in as input a scRNA-seq gene
-expression matrix of UMI counts $X$ with $N$ cells and $G$ genes, along with
-a vector of cell type labels $\vec{c}$. Subsequently, the stLVM takes in the trained scLVM,
-along a spatial gene expression matrix $Y$ with $S$ spots and $G$ genes.
-Optionally, the user can specify the number of components used for the mixture model underlying the
-empirical prior.
-
-## Generative process
-
-### scLVM
-
-For cell $n$, the scLVM assumes observed discrete cell type labels $c_n$ and models
-continuous covariates $\gamma_n$ of dimension $d$ to explain variation in gene expression within a cell type.
-The scLVM posits that the observed UMI counts for cell $n$ are generated by the following process:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- \gamma_n &\sim \textrm{Normal}(0, I) \tag{1} \\
- x_{ng} &\sim \textrm{NegativeBinomial}(l_nf^g(c_n, \gamma_n), p_g) \tag{2} \\
-\end{align}
-```
-
-where $l_n$ is the library size, $f$ is a two-layer neural network which outputs a $G$
-dimensional vector, and $p_g$ is the rate parameter of the negative binomial distribution for
-a given gene $g$.
-
-:::{note}
-We are using the standard rate-shape parametrization of the negative binomial here, rather than the mean-dispersion
-parametrization used in {doc}`/user_guide/models/scvi`. This is to take advantage of the additive property of
-negative binomial distributions sharing the same shape parameter. In this case, the rate parameter for the
-negative binomial modeling the expression counts for a given gene and spot is equivalent to the sum of the rate
-parameters for each contributing cell.
-:::
-
-This generative process is also summarized in the following graphical model:
-
-:::{figure} figures/scLVM_graphical_model.svg
-:align: center
-:alt: scLVM graphical model
-:class: img-fluid
-
-scLVM graphical model.
-:::
-
-The latent variables for the scLVM, along with their description, are summarized in the following table:
-
-```{eval-rst}
-.. list-table::
- :widths: 20 90 15
- :header-rows: 1
-
- * - Latent variable
- - Description
- - Code variable (if different)
- * - :math:`\gamma_n \in \mathbb{R}^d`
- - Low-dimensional representation of sub-cell-type covariates.
- - ``z``
- * - :math:`p_g \in (0, \infty)`
- - Rate parameter for the negative binomial distribution.
- - ``px_r``
-```
-
-### stLVM
-
-For the stLVM, we also model the expression counts with a $\mathrm{NegativeBinomial}$. However,
-for spatial data, we assume that each spot $s$ has expression $x_s$ composed of a bulk of cell types, with
-cell type abundance, $\beta_{sc}$, for each cell type $c$. We assume that for a given spot $s$
-and gene $g$, the observation is generated as a function of the latent variables $(c, \gamma_s^c)$ by the following process:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- \gamma_x^c &\sim \sum_{k=1}^K m_{kc} q_\Phi(\gamma^c \mid u_{kc}, c) \tag{4} \\
- x_{sg} &\sim \mathrm{NegativeBinomial}(l_s\alpha_g\sum_{c=1}^{C}\beta_{sc}f^g(c, \gamma_s^c), p_g) \tag{5} \\
-\end{align}
-```
-
-Where $l_s$ is the library size and $\alpha_g$ is a correction term for
-difference in experimental assays. Like the scLVM, $f$ is a decoder neural network, and
-$p_g$ is the rate parameter for the negative binomial distribution.
-
-To avoid the latent variable $\gamma_s^c$ from incorporating variation attributed to experimental
-assay differences, we assign an empirical prior informed by the scLVM and the corresponding
-cells of the same cell type in the scRNA-seq dataset. To compute this function, we cluster the latent space of the
-scLVM for each cell type to K cell-type-specific clusters. For each cluster we compute an empirical mean and variance.
-Above, $\{u_{kc}\}_{k=1}^K$ designates the set of cell-type-specific subclusters from cell type $c$ in the scRNA-seq dataset, and
-$q_\Phi$ designates the empirical normal distribution from the computed cluster mean and variance.
-The loss is weighted by the probability of a random cell from this cell type to be in the respective cluster in the
-scRNA-seq dataset (mixture probability, $m_{kc}$).
-In literature, the prior is referred to as a VampPrior ("variational aggregated mixture of posteriors" prior) [^ref2].
-More can be read on this prior in the DestVI paper.
-
-Lastly, an additional latent variable, $\eta_g$, is incorporated into the aggregated cell expression profile
-as a dummy cell type to represent gene-specific noise. The dummy cell type's expression profile is distributed
-as $\epsilon_g := \mathrm{Softplus}(\eta_g)$ where $\eta_g \sim \mathrm{Normal}(0, 1)$.
-Like the other cell types, there is an associated cell type abundance parameter $\beta_{sc}$ associated with $\eta$.
-We suspect each spot to only contain a fraction of the different cell types. To increase the sparsity of the cell type
-proportions, the stLVM supports L1 regularization on the cell types proportions $\beta_{sc}$. By default, this loss is
-not used.
-
-This generative process is also summarized in the following graphical model:
-
-:::{figure} figures/stLVM_graphical_model.svg
-:align: center
-:alt: stLVM graphical model
-:class: img-fluid
-
-stLVM graphical model.
-:::
-
-The latent variables for the stLVM, along with their description, are summarized in the following table:
-
-```{eval-rst}
-.. list-table::
- :widths: 20 90 15
- :header-rows: 1
-
- * - Latent variable
- - Description
- - Code variable (if different)
- * - :math:`\beta_{sc} \in (0, \infty)`
- - Spot-specific cell type abundance.
- - ``v_ind``
- * - :math:`\gamma_s^c \in (-\infty, \infty)`
- - Low-dimensional representation of sub-cell-type covariates for a given spot and cell type.
- - ``gamma``
- * - :math:`\eta_g \in (0, \infty)`
- - Gene-specific noise.
- - ``eta``
- * - :math:`\alpha_g \in (0, \infty)`
- - Correction term for technological differences.
- - ``beta``
- * - :math:`p_g \in (0,\infty)`
- - Rate parameter for the negative binomial distribution.
- - ``px_o``
-
-```
-
-## Inference
-
-### scLVM
-
-DestVI uses variational inference and specifically auto-encoding variational bayes (see {doc}`/user_guide/background/variational_inference`)
-to learn both the model parameters (the neural network params, rate params, etc.) and an approximate posterior distribution
-for the scLVM. Like {class}`scvi.model.SCVI`, the underlying class used as the encoder for DestVI is {class}`~scvi.nn.Encoder`.
-
-### stLVM
-
-For the stLVM, DestVI infers point estimates for latent variables $\gamma^c, \alpha, \beta$ using a penalized
-likelihood method. Beyond vanilla MAP inference, to regularize $\alpha$ a variance penalty is applied across all genes.
-Additionally, rather than having just $C$ parameters per spot to denote the estimated cell type abundances per spot, the stLVM
-has $dC$ parameters per spot as well to account for the latent space learned by the scLVM.
-
-The loss is defined as:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- L(l, \alpha, \beta, f^g, \gamma, p, \eta) := &-\log p(X \mid l, \alpha, \beta, f^g, \gamma, p, \eta) - \lambda_{\eta} \log p(\eta) \\
- &+ \lambda_{\alpha} \mathrm{Var}(\alpha) - \log p(\gamma \mid \mathrm{VampPrior}) + \lambda_{\beta} \lVert \beta_{sc} \rVert_1 \tag{6} \\
-\end{align}
-```
-
-Where $\mathrm{Var}(\alpha)$ refers to the empirical variance of the parameters alpha across all genes. We used this as a practical form of regularization (a similar regularizer is used in the ZINB-WaVE model [^ref3]).
-
-$\lambda_{\beta}$ (`l1_reg` in code), $\lambda_{\eta}$ (`eta_reg` in code) and $\lambda_{\alpha}$ (`beta_reg` in code) are hyperparameters used to scale the loss term. Increasing $\lambda_{\beta}$ leads to increased sparsity of cell type proportions. Increasing $\lambda_{\alpha}$ leads to less model flexibility for technical variation between single cell and spatial sequencing dataset. Increasing $\lambda_{\eta}$ leads to more genes being explained by the dummy cell type (we recommend not changing the default value).
-To avoid overfitting, DestVI amortizes inference using a neural network to parametrize the latent variables.
-Via the `amortization` parameter of {class}`scvi.module.MRDeconv`, the user can specify which of
-$\beta$ and $\gamma^c$ will be parametrized by the neural network.
-
-## Tasks
-
-### Cell type deconvolution
-
-Once the model is trained, one can retrieve the estimated cell type proportions in each spot using the method:
-
-```
->>> proportions = st_model.get_proportions()
->>> st_adata.obsm["proportions"] = proportions
-```
-
-These proportions are computed by normalizing across all learned cell type abundances, $\beta_{sc}$, for a given spot $s$.
-I.e., the estimated proportion of cell type $c$ for spot $s$ is $\frac{\beta_{sc}}{\sum_c \beta_{sc}}$.
-
-Subsequently for a given cell type, users can plot a heatmap of the cell type proportions spatially using scanpy with:
-
-```
->>> import scanpy as sc
->>> st_adata.obs['B cells'] = st_adata.obsm['proportions']['B cells']
->>> sc.pl.spatial(st_adata, color="B cells", spot_size=130)
-```
-
-### Intra cell type variation
-
-Users can retrieve the values of $\gamma$, the latent variables corresponding to the
-modeled cell-type-specific continuous covariates with:
-
-```
->>> gamma = st_model.get_gamma()["B cells"]
->>> st_adata.obsm["B_cells_gamma"] = gamma
-```
-
-### Cell-type-specific gene expression imputation
-
-Assuming the user has identified key gene modules that vary within a cell type of interest, they can
-impute the spatial pattern of the cell-type-specific gene expression with:
-
-```
->>> # Filter spots with low abundance.
->>> indices = np.where(st_adata.obsm["proportions"][ct_name].values > 0.03)[0]
->>> imputed_counts = st_model.get_scale_for_ct("Monocyte", indices=indices)[["Cxcl9", "Cxcl10", "Fcgr1"]]
-```
-
-### Comparative analysis between samples
-
-To perform differential expression across samples, one can apply a frequentist test by taking samples
-from the parameters of the generative distribution predicted for each spot in question.
-
-### Utilities function
-
-To explore the results of the output of the stLVM, we published an utilities function covering functions
-for automatic thresholding of cell type proportions, a spatial PCA analysis to find the main axis of variation
-in spatial gene expression and the described frequentist test for differential expression. Further information
-can be found on [destvi_utils](https://destvi-utils.readthedocs.io/en/latest/installation.html)
-
-[^ref2]: Jakub Tomczak, Max Welling (2018),_VAE with a VampPrior_, [Proceedings of Machine Learning Research](https://proceedings.mlr.press/v84/tomczak18a.html)
-[^ref3]: Davide Risso, Fanny Perraudeau, Svetlana Gribkova, Sandrine Dudoit, Jean-Philippe Vert (2018). _A general and flexible method for signal extraction from single-cell RNA-seq data_, [Nature Communications] (https://www.nature.com/articles/s41467-017-02554-5)
diff --git a/docs/user_guide/models/diagvi.md b/docs/user_guide/models/diagvi.md
index 554123bf95..e69de29bb2 100644
--- a/docs/user_guide/models/diagvi.md
+++ b/docs/user_guide/models/diagvi.md
@@ -1,481 +0,0 @@
-# DiagVI
-
-**DiagVI** (Diagonal multi-modal integration Variational Inference; Python class {class}`~scvi.external.DIAGVI`) is a deep generative model for diagonal integration of unpaired multi-modal single-cell data using prior knowledge about cross-modal feature correspondences. These relationships (such as associative or repressive interactions) are encoded in a guidance graph, where edge weights can represent both positive and negative covariation between features across modalities.
-
-DiagVI is inspired by the GLUE[^ref1] architecture, which uses modality-specific variational autoencoders (VAEs) to project heterogeneous data types into a shared latent space. In contrast to GLUE’s adversarial alignment strategy, DiagVI aligns modalities using Unbalanced Optimal Transport (UOT) via the Sinkhorn divergence[^ref2], explicitly accounting for differences in cell-type composition across modalities.
-
-The advantages of DiagVI are:
-
-- Flexible two-modality integration of various data types (e.g., scRNA-seq, spatial transcriptomics, spatial proteomics).
-- Full feature utilization: all features (not only overlapping ones) contribute to model training via the guidance graph and modality-specific VAEs.
-- Biologically informed alignment using prior feature correspondences via the guidance graph.
-- Robust integration of modality-specific or rare cell populations via UOT.
-- Scalable to very large datasets (>1 million cells).
-
-The limitations of DiagVI include:
-
-- Currently supports integration of two modalities only.
-- Requires prior information on cross-modal feature correspondences (explicitly or implicitly).
-- May require tuning of loss weights for optimal performance (for more information, see [Practical guidance](#practical-guidance)).
-- Effectively requires a GPU for fast inference.
-
-:::{note}
-Starting scVI-Tools v1.5 this model is part of scVIVA-Tools, and no longer being maintained here.
-:::
-
-```{topic} Tutorials:
-
-- {doc}`/tutorials/notebooks/multimodal/DiagVI_spatial_transcriptomics`
-- {doc}`/tutorials/notebooks/multimodal/DiagVI_spatial_proteomics`
-```
-
-:::{note}
-DiagVI requires additional dependencies (`geomloss`, `torch_geometric`) that are not installed by default. To use DiagVI, install scvi-tools with the `diagvi` extra:
-
-```bash
-pip install scvi-tools[diagvi]
-```
-:::
-
-
-## Preliminaries
-
-### Input
-
-DiagVI takes as input expression matrices $\mathbf{X}_1 \in \mathbb{R}^{N \times G}$ with $N$ cells and $G$ features and $\mathbf{X}_2 \in \mathbb{R}^{M \times P}$ with $M$ cells and $P$ features from two unpaired modalities.
-
-For **count data**, such as scRNA-seq, DiagVI expects a raw count expression matrix, where rows correspond to individual cells and columns correspond to features (e.g., genes).
-
-For **continuous data**, such as antibody-based single-cell or spatial proteomics, DiagVI expects a transformed (and optionally scaled) protein expression matrix, where rows correspond to cells and columns to features (e.g., marker proteins). Because antibody-based measurements are inherently relative, preprocessing is required. Typically, transformations such as arcsinh or log1p are applied to compress dynamic range and stabilize variance, followed by feature-wise scaling (e.g., z-score or min–max scaling) to ensure comparability across markers. This results in approximately normal-like feature distributions, which can be effectively modeled using a normal likelihood. Empirically, we found this approach performs better than modeling raw intensities (see also {class}`~scvi.external.CYTOVI` and the corresponding preprint {cite:p}`Ingelfinger25`).
-
-For integration with DiagVI, we recommend a simple two-step preprocessing strategy inspired by {class}`~scvi.external.CYTOVI`:
-
-1. **Arcsinh transformation** to stabilize variance and compress dynamic range.
-2. **Feature-wise min–max scaling** to rescale each marker to the [0, 1] range and account for differences in marker brightness.
-
-Optional: For both count and continuous data, DiagVI can additionally incorporate:
-- **Experimental covariates** such as batch annotation or **confounding variables** such as donor sex for one or both modalites. For simplicity, we will describe the case of categorical batch identifiers $s_n, s_m \in \{1,...,S\}$.
-- **Cell label annotations** that weakly inform the prior of the latent space and guide a classifier in semi-supervised training for one or both modalities. We assume categorical label identifiers $c_n, c_m \in\{1,...,C\}$.
-
-Both optional inputs are handled independently for each modality, enabling distinct batch structures or cell type annotations per modality.
-
-Currently supported modalities include:
-- **scRNA-seq**
-- **Spatial transcriptomics**: image-based and sequencing-based, e.g., 10x Xenium, Vizgen MERSCOPE, NanoString CosMx, and 10x Visium
-- **Spatial proteomics**: antibody-based imaging assays, e.g., PhenoCycler (formerly CODEX), 4i, Imaging Mass Cytometry, and protein add-on panels for Xenium or CosMx
-- **Single-cell proteomics**: such as CITE-seq and other antibody-derived tag (ADT)-based assays
-- Other count-based or continuous feature measurements
-
-### Model components
-
-DiagVI consists of several components which together define the overall training objective (see [Training Objective](#training-objective)).
-
-#### Modality-specific variational autoencoders
-
-DiagVI integrates two unpaired modalities by projecting their expression matrices $\mathbf{X}_1 \in \mathbb{R}^{N \times G}$ and $\mathbf{X}_2 \in \mathbb{R}^{M \times P}$ into a shared latent space in which each cell $n$ in modality 1 has a latent representation $\mathbf{z}_n \in \mathbb{R}^d$ and each cell $m$ in modality 2 has a latent representation $\mathbf{z}_m \in \mathbb{R}^d$. To find this shared state, the observed data from each modality is modeled using an independently parametrized VAE.
-
-#### Guidance graph
-
-Projecting cells into a common low-dimensional space alone does not result in semantically consistent embeddings in which identical cell types are assigned to the same region across both modalities. To ensure biological consistency in the latent space, a guidance graph establishes logical associations between the two modalities by connecting linked features (see also GLUE[^ref1]).
-
-Feature correspondences are encoded in the guidance graph $\mathcal{G} = (\mathcal{V}, \mathcal{E})$, where $\mathcal{V} = \mathcal{V}_1 \cup \mathcal{V}_2$ with modality-specific feature sets $\mathcal{V}_1$ and $\mathcal{V}_2$, and $\mathcal{E} \subseteq \mathcal{V} \times \mathcal{V}$ denotes the set of edges. Each edge $(i,j) \in \mathcal{E}$ is associated with
-
-- a weight $w_{ij} \in (0, 1]$ reflecting the confidence of the link
-- a sign $\sigma_{ij} \in \{-1,1\}$ specifying whether the interaction is associative ($\sigma_{ij} = 1$) or repressive ($\sigma_{ij} = -1$)
-
-The graph loss encourages the inner product between embeddings $\mathbf{v}_i$ and $\mathbf{v}_j$ of linked features to be large and positive for $\sigma_{ij} = 1$ and large and negative for $\sigma_{ij} = -1$, with strength modulated by edge weights.
-
-#### Unbalanced optimal transport
-
-To ensure robust alignment of cells from different modalities within the shared latent space, DiagVI leverages unbalanced optimal transport (UOT). Specifically, it minimizes the de-biased Sinkhorn divergence between latent distributions using the [GeomLoss](https://www.kernel-operations.io/geomloss/#) library[^ref4]. UOT relaxes the marginal constraints of classical optimal transport, allowing for unequal total mass between distributions. This enables robust integration in the presence of differing cell type proportions across modalities.
-
-#### Classifier
-
-Inspired by {class}`~scvi.external.RESOLVI` {cite:p}`Ergen25`, a simple cell type classifier predicting labels $c_n$ and $c_m$ from cell latent vectors $\mathbf{z}_n$ and $\mathbf{z}_m$, respectively, is integrated into the model in the semi-supervised setting. This classifier is trained jointly with the generative model.
-
-
-## Descriptive model
-
-DiagVI assumes that observations from each modality are generated from a shared $d$-dimensional latent space. For cell $n$ and feature $g$, the observed data $x_{ng}$ is generated conditionally on the cell latent variable $\mathbf{z}_n \in \mathbb{R}^{d}$, the feature latent variable $\mathbf{v}_g \in \mathbb{R}^{d}$, and its associated batch $s_n$.
-
-The prior on the feature latent variable is a standard multivariate normal:
-
-$$
-\mathbf{v}_g \sim \mathcal{N}(\mathbf{0}, \mathbf{I}_d).
-$$
-
-The prior on the cell latent variable can be either a standard multivariate normal (default) or a Gaussian mixture with $L$ components:
-
-$$
-\mathbf{z}_n \sim
-\begin{cases}
-\mathcal{N}(\mathbf{0}, \mathbf{I}_d), \\
-\sum_{l=1}^L \pi_l \, \mathcal{N}(\boldsymbol{\mu}_l, \boldsymbol{\Sigma}_l).
-\end{cases}
-$$
-
-If cell type labels are provided and a Gaussian mixture prior is used,
-$L$ is set to the number of unique cell types in the labeled data.
-
-
-## Generative process
-
-### Generative model
-
-The generative model follows the formulations used in GLUE[^ref1], scVI {cite:p}`Lopez18`, and CytoVI {cite:p}`Ingelfinger25`, and adapts depending on whether the observed data consist of discrete counts or continuous measurements. In both cases, the specific form of the data likelihood varies with the distribution used to model the observed data in each modality.
-
-Regardless of the data type and likelihood choice, the inner product between cell and feature
-latent embeddings parametrizes the decoder together with batch-specific versions of scaling ($\alpha_{s_n,g}$) and bias ($\beta_{s_n,g}$) parameters:
-
-$$
-\eta_{ng} = \alpha_{s_n,g} \, \mathbf{z}_n^\top \mathbf{v}_g + \beta_{s_n,g}.
-$$
-
-For count-based modalities, denoised and normalized expression proportions $\rho_{ng}$ are obtained via a softmax over features:
-
-$$
-\rho_{ng} = \frac{\exp(\eta_{ng})} {\sum_{g'} \exp(\eta_{ng'})}.
-$$
-
-The mean of the generative distribution is given by
-
-$$
-\mu_{ng} = l_n\rho_{ng},
-$$
-
-where $l_n$ denotes the observed library size of cell $n$. Counts are then modeled as
-
-$$
-x_{ng}
-\sim
-\mathrm{NB}(\mu_{ng}, \theta_{s_n,g})
-$$
-
-when using the negative binomial likelihood with mean $\mu_{ng}$ and dispersion $\theta_{s_n,g}$. For other likelihood choices, see [Likelihood Models](#likelihood-models).
-
-For continuous modalities, no simplex constraint is enforced and no library size normalization is applied. When using the normal likelihood, continuous expression values are modeled as
-
-$$
-x_{ng}
-\sim
-\mathcal{N}(\eta_{ng}, \sigma_{s_n,g}^2).
-$$
-
-In both settings, when a batch covariate is provided, DiagVI learns batch-specific versions of scaling and bias parameters to account for batch effects within each modality. For each modality $k$ and batch $s$, the scaling and bias parameters satisfy
-
-$$
-\boldsymbol{\alpha}^{(k)}_s \in \mathbb{R}_+^{|\mathcal{V}_k|},
-\qquad
-\boldsymbol{\beta}^{(k)}_s \in \mathbb{R}^{|\mathcal{V}_k|}.
-$$
-
-This generative process is also summarized in the following graphical model:
-
-:::{figure} figures/diagvi_graphical_model.svg
-:align: center
-:alt: DiagVI graphical model
-:class: img-fluid
-:width: 90%
-
-DiagVI graphical model. Shaded nodes represent observed data, unshaded nodes represent latent variables.
-:::
-
-### Likelihood models
-
-Depending on the modality and data characteristics, different likelihood functions can be used to reconstruct the input data. The choice of likelihood should reflect whether the data are counts or continuous measurements, and whether they exhibit excess zeros or background signal.
-
-We generally recommend:
-
-- **Negative binomial (``nb``)** for count-based single-cell RNA-seq data.
-- **Normal (``normal``)** for transformed and scaled continuous measurements.
-
-More specialized likelihoods such as zero-inflated or mixture models may be beneficial for sparse or background-contaminated measurements.
-
-DiagVI supports the following likelihood functions:
-
-```{eval-rst}
-.. list-table::
- :widths: 10 20 30 40
- :header-rows: 1
-
- * - Likelihood
- - Distribution
- - Typical modality
- - Recommended preprocessing
- * - ``nb``
- - Negative Binomial
- - Count data (e.g., scRNA-seq)
- - Raw counts
- * - ``zinb``
- - Zero-Inflated Negative Binomial
- - Strongly zero-inflated count data
- - Raw counts
- * - ``nbmixture``
- - Negative Binomial Mixture
- - Protein counts with background signal (e.g., CITE-seq ADTs)
- - Raw counts
- * - ``normal``
- - Normal
- - Continuous data
- - Transformed (e.g., arcsinh or log1p) and feature-wise scaled
- * - ``log1pnormal``
- - Log1p-Normal
- - Non-negative continuous data
- - Raw data, optionally scaled
- * - ``ziln``
- - Zero-Inflated Log-Normal
- - Sparse positive continuous data
- - Raw data, optionally scaled
- * - ``zig``
- - Zero-Inflated Gamma
- - Sparse positive continuous data
- - Raw data, optionally scaled
-```
-
-### Latent variables
-
-The exact set of variables instantiated during training depends on the chosen likelihood and on whether a Gaussian mixture prior is used for the cell latent space. Below we summarize the principal latent, decoder, and auxiliary variables used in DiagVI.
-
-```{eval-rst}
-.. list-table::
- :widths: 25 55 20
- :header-rows: 1
-
- * - Variable
- - Description
- - Code variable
- * - :math:`\mathbf{z}_n \in \mathbb{R}^{d}`
- - Low-dimensional latent representation of cell :math:`n`, capturing its underlying biological state.
- - ``z``
- * - :math:`\mathbf{v}_g \in \mathbb{R}^{d}`
- - Low-dimensional embedding of feature :math:`g`, inferred via the graph encoder from the guidance graph.
- - ``v``
- * - :math:`c_n \in \{1,\dots,C\}`
- - Observed cell type label of cell :math:`n` (if available), used for semi-supervised training and to inform the Gaussian mixture prior.
- - ``y``
- * - :math:`\eta_{ng} \in \mathbb{R}`
- - Decoder linear predictor obtained from the bilinear interaction between cell and feature embeddings.
- - N/A
- * - :math:`\boldsymbol{\rho}_n \in \Delta^{|\mathcal{V}_k|-1}`
- - Denoised, normalized expression proportions for cell :math:`n` (count data); constrained to the probability simplex via softmax.
- - ``px_scale``
- * - :math:`\mu_{ng} \in \mathbb{R}_+`
- - Mean of the generative distribution for feature :math:`g` in cell :math:`n`.
- - ``px_rate``
- * - :math:`\theta_{s_n,g} \in (0,\infty)`
- - Batch-specific inverse dispersion parameters used in count-based likelihoods.
- - ``px_r``
- * - :math:`\sigma_{s_n,g}^2 \in (0,\infty)`
- - Batch-specific variance parameters used in continuous likelihoods.
- - ``px_r``
- * - :math:`\delta_g \in (0,\infty)`
- - Dropout or zero-inflation parameters (used for zero-inflated likelihoods).
- - ``px_dropout``
- * - :math:`\pi_l`
- - Mixture weights of the Gaussian mixture prior (if enabled).
- - ``gmm_logits``
-```
-
-
-## Inference
-
-Since the posterior distributions over the latent variables are intractable, DiagVI uses variational inference and specifically auto-encoding variational Bayes (see {doc}`/user_guide/background/variational_inference`) to jointly learn model parameters and approximate posterior distributions.
-
-For a given modality, the variational distribution factorizes as
-
-$$
-q_\eta(\mathbf{z}_n, \mathbf{v} \mid x_n, \mathcal{G}) = q_\eta(\mathbf{z}_n \mid x_n) \; q_\eta(\mathbf{v} \mid \mathcal{G}).
-$$
-
-Here $\eta$ is a set of parameters corresponding to inference neural networks (encoders).
-
-
-## Training objective
-
-DiagVI is trained by minimizing a weighted sum of loss terms (weighted by `lam_*` parameters) corresponding to the [Model Components](#model-components) introduced above:
-
-- **Modality-specific VAEs**: The data reconstruction loss (`lam_data`) measures how well each modality-specific decoder reconstructs the observed data, while the KL divergence (`lam_kl`) regularizes the cell latent variables by encouraging adherence to the prior.
-- **Guidance graph**: The graph reconstruction loss (`lam_graph`) enforces biological consistency between feature embeddings using the guidance graph.
-- **UOT**: The UOT alignment loss (`lam_sinkhorn`) aligns cell distributions across modalities using unbalanced optimal transport.
-- **Classifier**: The classification loss (`lam_class`, optional) enables supervised or semi-supervised training via cell-type labels.
-
-The `lam_*` parameters control the relative importance of within-modality reconstruction (`lam_data`, `lam_kl`), cross-modality alignment (`lam_graph`, `lam_sinkhorn`), and optional supervision (`lam_class`). DiagVI uses sensible defaults for all of these values, but they may require further tuning depending on the type of data being integrated (see [Practical guidance](#practical-guidance)).
-
-
-## Practical guidance
-
-### Guidance graph creation
-
-When initializing {class}`~scvi.external.DIAGVI`, three ways to specify the guidance graph are supported:
-
-1. **Automatic construction**: If neither `guidance_graph` nor `mapping_df` are provided, DiagVI constructs a graph from overlapping feature names across modalities (e.g., shared gene symbols).
-
-2. **Custom mapping via DataFrame**: Pass a `pandas.DataFrame` to `mapping_df` in which each column corresponds to a modality name (matching the keys in `input_dict`) and each row specifies a feature pair. This is useful when feature naming conventions differ between modalities (e.g., genes vs. proteins). You can also use {func}`~scvi.external.DIAGVI.construct_custom_guidance_graph` to create a graph with custom edge weights and signs.
-
-3. **Explicit graph specification**: For full control, pass a pre-constructed `torch_geometric.data.Data` object directly to `guidance_graph`. The graph must include node features, edge indices, edge weights, edge signs, and modality-specific feature index tensors..
-
-### Loss weights
-
-DiagVI provides default values for all loss weights that have been tested across multiple datasets and integration tasks. However, depending on the modalities and the integration setting, tuning some of these weights can improve performance.
-
-In practice, we recommend paying particular attention to:
-
-- `lam_sinkhorn` (cross-modality alignment strength), and
-- `lam_class` (classification strength, if cell-type labels are provided).
-
-These two weights control the trade-off between alignment across modalities and separation of cell types within the latent space.
-
-When integrating **very different modalities** (e.g., scRNA-seq + spatial proteomics), stronger alignment is typically required. In such cases:
-
-- Higher `lam_sinkhorn`
-- Lower `lam_class` (if labels are used)
-
-This encourages the model to prioritize matching global cell distributions across modalities, even if the feature spaces differ substantially.
-
-When integrating **more similar modalities** (e.g., scRNA-seq + spatial transcriptomics), less alignment pressure is needed. In such cases:
-
-- Lower `lam_sinkhorn`
-- Higher `lam_class` (if labels are used)
-
-Here, the modalities are already structurally similar, so stronger supervision can help maintain clearer separation of biologically distinct cell types.
-
-As a general rule, we recommend `lam_sinkhorn` and `lam_class` values between 1 and 100, which work well in most settings.
-
-The default loss weight values place relatively strong emphasis on cross-modality alignment and are therefore well suited for integrating heterogeneous modalities. For more similar modalities, we recommend reducing `lam_sinkhorn` (e.g., 5-20) and increasing `lam_class` (e.g., 60-80), depending on label availability and desired separation.
-
-In practice, small grid searches over `lam_sinkhorn` and `lam_class` are often sufficient to find well-performing settings.
-
-### Sinkhorn parameters
-
-Three parameters determine the behavior of the Sinkhorn divergence used for cross-modality alignment:
-
-- `p`: order of the ground cost (Wasserstein-p distance). The default is p = 2, corresponding to a squared Euclidean ground cost.
-- `blur`: controls the strength of entropic regularization and therefore the smoothness of the transport plan. Larger values increase regularization, resulting in smoother and more diffuse transport plans.
-- `reach`: controls the marginal relaxation parameter in unbalanced optimal transport, penalizing deviations from strict mass conservation. Larger values enforce stricter marginal constraints, while smaller values allow greater mass variation between the matched distributions.
-
-Although these parameters can be specified manually, we generally **do not recommend modifying them**.
-
-By default, DiagVI follows the heuristic strategy introduced in [OTT-JAX](https://ott-jax.readthedocs.io/). In this setting:
-- `blur` is computed adaptively at each optimization step from the minibatch cost matrix, and
-- `reach` is derived as a function of blur.
-
-This adaptive strategy makes the Sinkhorn loss scale-aware and robust across datasets, and in practice removes the need for manual tuning. Adjusting these parameters is rarely necessary.
-
-
-## Tasks
-
-Here we provide an overview of some of the tasks that DiagVI can perform. Please see {class}`~scvi.external.DIAGVI` for the full API reference.
-
-### Dimensionality reduction
-
-DiagVI provides aligned low-dimensional representations for each modality. By default, the mean of the approximate posterior $q_\eta(\mathbf{z}_n \mid x_n)$ is returned.
-
-Latent representations can be obtained using:
-
-```
->>> latents = model.get_latent_representation()
->>> adata_mod1.obsm["X_diagvi"] = latents["mod1"]
->>> adata_mod2.obsm["X_diagvi"] = latents["mod2"]
-```
-
-The aligned representations can be used for downstream analyses either within each modality or jointly across modalities.
-
-For example, within-modality visualization:
-
-```
->>> import scanpy as sc
->>> sc.pp.neighbors(adata_mod1, use_rep="X_diagvi")
->>> sc.tl.umap(adata_mod1)
-```
-
-For joint analysis across modalities:
-
-```
->>> adata_combined = sc.concat(
-... [adata_mod1, adata_mod2],
-... axis=0,
-... join="inner",
-... label="modality",
-... keys=["mod1", "mod2"],
-... )
->>> sc.pp.neighbors(adata_combined, use_rep="X_diagvi")
->>> sc.tl.umap(adata_combined)
-```
-
-### Cross-modal feature imputation
-
-DiagVI can impute features from one modality into another using {func}`~scvi.external.DIAGVI.get_imputed_values`.
-
-For example, to impute protein expression for RNA cells:
-
-```
->>> imputed_protein = model.get_imputed_values(
-... query_name="rna",
-... query_adata=adata_rna,
-... )
->>> adata_rna.obsm["imputed_protein"] = imputed_protein
-```
-
-It is also possible to perform counterfactual predictions by specifying a reference batch or reference library size (see also {doc}`/user_guide/background/counterfactual_prediction`):
-
-```
->>> imputed_rna = model.get_imputed_values(
-... query_name="protein",
-... reference_batch="batch_1",
-... reference_libsize=10000,
-... )
->>> adata_protein.obsm["imputed_rna"] = imputed_rna
-```
-
-This allows imputation under specified sequencing depth or batch conditions.
-
-### Cell label transfer
-
-Aligned latent representations can be used for cross-modal cell label transfer.
-
-For example, when integrating scRNA-seq reference data with spatial proteomics data, cell-type labels can be transferred from the RNA reference to the spatial data. This can be done either using DiagVI's built-in classifier (when cell-type labels have been provided for the reference modality during training) or with external tools such as [`CellMapper`](https://cellmapper.readthedocs.io/en/latest/), a toolkit for cross-modal cell mapping and evaluation.
-
-Using DiagVI's trained classifier, cell labels can be transferred as follows:
-
-```
->>> preds = model.predict_celltype(labeled_modality="rna")
->>> adata_protein.obs["celltype_pred"] = preds["predictions"]
->>> adata_protein.obs["celltype_conf"] = preds["confidence"]
-```
-
-Alternatively, using CellMapper:
-
-```
->>> latents = model.get_latent_representation()
->>> adata_rna.obsm["X_diagvi"] = latents["rna"]
->>> adata_protein.obsm["X_diagvi"] = latents["protein"]
->>>
->>> import CellMapper
->>> cmap = CellMapper(adata_protein, adata_rna)
->>> cmap.map("cell_type_labels", use_rep="X_diagvi")
->>> cmap.evaluate_label_transfer("cell_type_labels")
-```
-
-Because DiagVI produces a shared latent space, any downstream method that operates on low-dimensional embeddings (e.g., kNN mapping, clustering, classification) can be applied directly.
-
-### Data simulation
-
-Data can be generated from the model using the posterior predictive distribution in {func}`~scvi.external.DIAGVI.posterior_predictive_sample`. This is equivalent to feeding a cell through the model, sampling from the posterior distributions of the latent variables, retrieving the likelihood parameters, and finally, sampling from this distribution.
-
-
-## References
-
-[^ref1]:
- Cao, Zhi-Jie; Gao, Ge (2022),
- _Multi-omics single-cell data integration and regulatory inference with graph-linked embedding_,
- [Nature Biotechnology](https://www.nature.com/articles/s41587-022-01284-4).
-
-[^ref2]:
- Séjourné, Thibault; Feydy, Jean; Vialard, François-Xavier; Trouvé, Alain; Peyré, Gabriel (2023),
- _Sinkhorn Divergences for Unbalanced Optimal Transport_,
- [arXiv](https://arxiv.org/abs/1910.12958).
-
-[^ref4]:
- Feydy, Jean; Séjourné, Thibault; Vialard, François‑Xavier; Amari, Shun‑ichi; Trouvé, Alain; Peyré, Gabriel (2019)
- _Interpolating between Optimal Transport and MMD using Sinkhorn Divergences_
- [The 22nd International Conference on Artificial Intelligence and Statistics](https://arxiv.org/abs/1810.08278).
diff --git a/docs/user_guide/models/figures/diagvi_graphical_model.svg b/docs/user_guide/models/figures/diagvi_graphical_model.svg
deleted file mode 100644
index 0fde13968b..0000000000
--- a/docs/user_guide/models/figures/diagvi_graphical_model.svg
+++ /dev/null
@@ -1,2358 +0,0 @@
-
-
-
-
diff --git a/docs/user_guide/models/gimvi.md b/docs/user_guide/models/gimvi.md
deleted file mode 100644
index 776829b36d..0000000000
--- a/docs/user_guide/models/gimvi.md
+++ /dev/null
@@ -1,204 +0,0 @@
-# gimVI
-
-**gimVI** {cite:p}`Lopez19` (Generative Imputation Model with Variational Inference; Python class {class}`~scvi.external.GIMVI`) is a deep generative model for the joint analysis of an scRNA-seq dataset and a spatial transcriptomics dataset that measures only a subset of the genes. gimVI learns a single shared latent space for the two modalities and uses it to **impute the genes that are missing from the spatial assay** by transferring information from the (genome-wide) sequencing reference.
-
-The two datasets are modeled by a single joint variational autoencoder ({class}`~scvi.external.gimvi.JVAE`) with modality-specific encoder and decoder heads but a shared latent representation. Because the spatial panel is a subset of the genes captured by scRNA-seq, the decoder reconstructs into the full sequencing gene space, and each modality's reconstruction loss is masked to only the genes it actually observes. Alignment of the two modalities in the shared latent space is encouraged by an adversarial classifier that is trained to predict the modality of origin from a cell's latent code, while the generative model is trained to fool it.
-
-The advantages of gimVI are:
-
-- Imputation of genes that are not measured in the spatial assay, using a paired (genome-wide) scRNA-seq reference.
-- A shared latent space that integrates spatial and sequencing cells for joint visualization and clustering.
-- Flexible, per-modality generative distributions (e.g. `zinb` for the sequencing data and `nb` for the spatial data) and per-modality choice of whether to model library size.
-- Handles the two assays measuring different gene sets and different numbers of cells.
-
-The limitations of gimVI include:
-
-- Supports exactly two datasets (one sequencing, one spatial).
-- The spatial genes must be a subset of the sequencing genes.
-- Imputation quality depends on how well the scRNA-seq reference matches the spatial tissue.
-
-:::{note}
-Starting scVI-Tools v1.5 this model is part of scVIVA-Tools, and no longer being maintained here.
-:::
-
-```{topic} Tutorials:
-
-- {doc}`/tutorials/notebooks/spatial/gimvi_tutorial`
-```
-
-:::{note}
-gimVI is a spatial transcriptomics model that will be moved to the scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and will no longer be supported in scvi-tools; it is scheduled for deprecation in v1.6.
-:::
-
-
-## Preliminaries
-
-gimVI takes as input two raw-count gene expression matrices:
-
-- a sequencing matrix $X^{(0)} \in \mathbb{N}^{N_0 \times G_0}$ with $N_0$ cells and $G_0$ genes (the full gene set), and
-- a spatial matrix $X^{(1)} \in \mathbb{N}^{N_1 \times G_1}$ with $N_1$ cells and $G_1$ genes,
-
-where the spatial genes are a subset of the sequencing genes ($\mathcal{G}_1 \subseteq \mathcal{G}_0$). Internally the decoder operates on the full sequencing gene space of size $G = G_0$, and a per-modality index mapping $\mathcal{M}_m$ records where each modality's observed genes live in that space. The spatial reconstruction is therefore evaluated only on the columns corresponding to $\mathcal{G}_1$.
-
-Each dataset can additionally carry a categorical batch covariate $s$ and, optionally, categorical cell-type labels $c$. Both datasets are registered independently with two separate calls to {meth}`~scvi.external.GIMVI.setup_anndata` before the joint model is constructed:
-
-```
->>> scvi.external.GIMVI.setup_anndata(adata_seq, batch_key="batch", labels_key="labels")
->>> scvi.external.GIMVI.setup_anndata(adata_spatial, batch_key="batch", labels_key="labels")
->>> model = scvi.external.GIMVI(adata_seq, adata_spatial)
->>> model.train()
-```
-
-The two datasets share a single batch space: batch indices of the spatial dataset are offset by the number of sequencing batches so that the two modalities never collide on a batch id (see [#2446](https://github.com/scverse/scvi-tools/issues/2446)).
-
-### Model architecture
-
-gimVI is built from the following components, which together define the [training objective](#training-objective):
-
-#### Joint encoder
-
-A single multi-head encoder ({class}`~scvi.nn.MultiEncoder`) maps both modalities into a shared $d$-dimensional latent space. Each modality has its own input layer(s) (because the two assays have different numbers of genes), followed by shared layers that produce the variational posterior $q(\mathbf{z}_n \mid x_n)$. The `mode` argument selects which input head is used.
-
-For each modality, an optional, separate library-size encoder produces a latent library size. Whether a modality models library size as a latent variable or instead uses the observed log total counts is controlled by `model_library_size` (default `[True, False]`: the sequencing data models library size, the spatial data uses the observed value).
-
-#### Joint decoder
-
-A single, shared multi-decoder ({class}`~scvi.nn.MultiDecoder`) maps the shared latent code back into the full sequencing gene space of size $G$, conditioned on the batch covariate (the decoder weights are **not** modality-specific). Because the decoder reconstructs all $G$ genes for both modalities, the latent representation of a spatial cell can be decoded into genes that were never measured spatially — this is exactly the imputation mechanism (see [Imputation of missing spatial genes](#imputation-of-missing-spatial-genes)). The modality index only selects which gene-index mapping is used to renormalize the decoded expression over each modality's observed genes; it does not route through different decoder networks.
-
-#### Adversarial modality classifier
-
-A small classifier ({class}`~scvi.module.Classifier`, 3 layers, 2 output classes) is trained to predict, from a latent code $\mathbf{z}$, which modality it came from. The generative model is trained adversarially to *fool* this classifier, which pushes the sequencing and spatial latent distributions to overlap and yields an integrated, modality-mixed latent space. This is implemented in {class}`~scvi.external.gimvi._task.GIMVITrainingPlan` (a subclass of {class}`~scvi.train.AdversarialTrainingPlan`), and the strength of the adversarial term is set by the `kappa` argument of {meth}`~scvi.external.GIMVI.train`.
-
-
-## Generative process
-
-For a cell $n$ in modality $m \in \{0, 1\}$ (0 = sequencing, 1 = spatial) with batch $s_n$, gimVI posits the following generative process over the full gene space:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- \mathbf{z}_n &\sim \mathrm{Normal}(0, I) \\
- \ell_n &\sim
- \begin{cases}
- \mathrm{LogNormal}(\ell_\mu^\top s_n,\ \ell_{\sigma^2}^\top s_n) & \text{if modality } m \text{ models library size} \\
- \delta(\log \textstyle\sum_g x_{ng}) & \text{otherwise (observed library size)}
- \end{cases} \\
- \rho_n &= f_w(\mathbf{z}_n, s_n) \\
- x_{ng} &\sim \mathrm{ObservationModel}_m(\ell_n \rho_{ng},\ \theta_{s_n g}),\quad g \in \mathcal{G}_m
-\end{align}
-```
-
-The latent code $\mathbf{z}_n$ is **shared across modalities** — the same prior and the same latent space are used for both datasets. The shared decoder $f_w$ produces normalized expression $\rho_n$ over the full gene set; for modality $m$, $\rho_n$ is renormalized to sum to 1 over only the genes observed in that modality ($\mathcal{M}_m$), and the mean rate is $\mu_{ng} = \ell_n \rho_{ng}$.
-
-The observation model is chosen per modality via `generative_distributions` (default `["zinb", "nb"]`). gimVI supports:
-
-- `zinb` — Zero-Inflated Negative Binomial (default for the sequencing data),
-- `nb` — Negative Binomial (default for the spatial data),
-- `poisson` — Poisson.
-
-The inverse dispersion $\theta$ is by default gene- and batch-specific (`dispersion="gene-batch"`); `gene` and `gene-label` parameterizations are also available.
-
-The latent variables are summarized below:
-
-```{eval-rst}
-.. list-table::
- :widths: 25 60 15
- :header-rows: 1
-
- * - Latent variable
- - Description
- - Code variable
- * - :math:`\mathbf{z}_n \in \mathbb{R}^{d}`
- - Shared low-dimensional representation of a cell, common to both modalities.
- - ``z``
- * - :math:`\rho_n \in \Delta^{|\mathcal{M}_m|-1}`
- - Denoised, normalized expression for cell :math:`n`, renormalized over the genes observed in its modality.
- - ``px_scale``
- * - :math:`\ell_n \in (0, \infty)`
- - Library size. Modeled as a latent variable for modalities flagged in ``model_library_size``; otherwise set to the observed log total count.
- - ``library``
- * - :math:`\mu_{ng} \in (0, \infty)`
- - Mean of the count likelihood, :math:`\ell_n \rho_{ng}`.
- - ``px_rate``
- * - :math:`\theta_{s_n g} \in (0, \infty)`
- - Inverse dispersion of the (zero-inflated) negative binomial, by default gene- and batch-specific.
- - ``px_r``
-```
-
-
-## Inference
-
-gimVI uses variational inference (auto-encoding variational Bayes; see {doc}`/user_guide/background/variational_inference`) with the per-modality factorization
-
-```{math}
-:nowrap: true
-
-\begin{align}
- q_\eta(\mathbf{z}_n, \ell_n \mid x_n) := q_\eta(\mathbf{z}_n \mid x_n)\, q_\eta(\ell_n \mid x_n).
-\end{align}
-```
-
-The latent posterior $q_\eta(\mathbf{z}_n \mid x_n)$ is produced by the shared multi-head encoder (using the input head for the cell's modality), and $q_\eta(\ell_n \mid x_n)$ by the modality's optional library encoder. Inputs are log-transformed (`log(1 + x)`) before encoding for numerical stability.
-
-
-## Training objective
-
-gimVI minimizes a per-modality evidence lower bound — a reconstruction term plus KL terms on the latent variables — summed across both modalities, with an adversarial term added on top:
-
-- **Reconstruction**: the negative log-likelihood of the observed counts under the chosen observation model, **masked to the genes observed in each modality** (the spatial term ignores genes outside the spatial panel).
-- **KL divergence on $\mathbf{z}$**: regularizes the shared latent variable toward the standard normal prior.
-- **KL divergence on $\ell$**: applied only for modalities that model library size as a latent variable, with a batch-specific log-normal prior.
-- **Adversarial loss**: the modality classifier is trained to predict modality from $\mathbf{z}$, while the generative model is trained to fool it. The relative weight is controlled by `kappa` (the `scale_adversarial_loss` of the training plan); when set to `"auto"` it follows `1 - kl_weight` over the warmup schedule.
-
-The two objectives (model vs. classifier) are optimized in alternating steps with manual optimization, as in {class}`~scvi.train.AdversarialTrainingPlan`.
-
-### Cyclic multi-dataset data loading
-
-The two datasets generally have different numbers of cells, so a custom data loader, {class}`~scvi.external.gimvi._task.CyclicMultiDataLoader`, combines their per-modality dataloaders: it iterates the longest loader once per epoch and **cycles the shorter loader(s)** so that every training step yields one minibatch from each modality. It accepts either a sequence (yielding a tuple of minibatches) or a mapping of modality name → loader (yielding a dict, preserving modality names).
-
-This loader is shared infrastructure: {class}`~scvi.external.DIAGVI` imports it (`from scvi.external.gimvi._task import CyclicMultiDataLoader as TrainDL`) and uses the mapping form to drive its own multi-modality training, so the same cycling logic backs both gimVI and DiagVI.
-
-
-## Tasks
-
-Here we provide an overview of some of the tasks that gimVI can perform. Please see {class}`~scvi.external.GIMVI` for the full API reference.
-
-### Dimensionality reduction
-
-{meth}`~scvi.external.GIMVI.get_latent_representation` returns the shared latent representation for **each** dataset as a list `[latent_seq, latent_spatial]`. By default the mean of the approximate posterior is returned (`deterministic=True`).
-
-```
->>> latent_seq, latent_spatial = model.get_latent_representation()
->>> adata_seq.obsm["X_gimvi"] = latent_seq
->>> adata_spatial.obsm["X_gimvi"] = latent_spatial
-```
-
-Because the latent space is shared and modality-mixed (via the adversarial classifier), the two representations can be concatenated for a joint neighbor graph and UMAP to assess integration quality:
-
-```
->>> import scanpy as sc, anndata as ad, numpy as np
->>> joint = ad.AnnData(np.concatenate([latent_seq, latent_spatial]))
->>> joint.obs["modality"] = ["seq"] * len(latent_seq) + ["spatial"] * len(latent_spatial)
->>> sc.pp.neighbors(joint, use_rep="X")
->>> sc.tl.umap(joint)
-```
-
-### Imputation of missing spatial genes
-
-The central task of gimVI is to impute, for the spatial cells, the genes that were only measured in the scRNA-seq data. {meth}`~scvi.external.GIMVI.get_imputed_values` returns the decoded expression for **all** sequencing genes for each dataset:
-
-```
->>> imputed_seq, imputed_spatial = model.get_imputed_values(normalized=True)
->>> # imputed_spatial now contains values for every sequencing gene,
->>> # including those absent from the original spatial panel.
-```
-
-- `normalized=True` (default) returns the normalized expression frequencies $\rho_n$ (decoder `sample_scale`); `normalized=False` returns the unnormalized mean rate $\mu_n = \ell_n \rho_n$ (decoder `sample_rate`).
-- `decode_mode` selects which dataset's decoding is applied. The cell is always encoded with its own modality's encoder; gimVI then uses a **single shared decoder**, and `decode_mode` only changes which modality's gene-index mapping is used to renormalize the decoded expression over the probability simplex (`indices_mappings[decode_mode]`). It does not switch to a separate per-modality decoder network.
-
-A common evaluation (used in the tutorial) holds out a fraction of genes from the spatial panel, imputes them with gimVI, and measures the per-gene Spearman correlation between the held-out true spatial expression and the imputed values.
-
-### Saving and loading
-
-gimVI uses a custom {meth}`~scvi.external.GIMVI.save` / {meth}`~scvi.external.GIMVI.load` that persists the two registries and both sets of `var_names` (the model stores two AnnData managers). `save_anndata=True` writes both `adata_seq` and `adata_spatial` alongside the model. Legacy saves (pre-v0.15.0) can be upgraded with {meth}`~scvi.external.GIMVI.convert_legacy_save`.
diff --git a/docs/user_guide/models/index.md b/docs/user_guide/models/index.md
index fdd90c8d26..238785f4f7 100644
--- a/docs/user_guide/models/index.md
+++ b/docs/user_guide/models/index.md
@@ -9,10 +9,7 @@ cellassign
contrastivevi
cytovi
decipher
-destvi
-diagvi
drvi
-gimvi
jointembeddingscvi
linearscvi
methylanvi
@@ -21,16 +18,12 @@ mrvi
multivi
peakvi
poissonvi
-resolvi
scanvi
scar
scbasset
scvi
-scviva
solo
-stereoscope
sysvi
-tangram
totalanvi
totalvi
velovi
diff --git a/docs/user_guide/models/resolvi.md b/docs/user_guide/models/resolvi.md
deleted file mode 100644
index 958a6e937e..0000000000
--- a/docs/user_guide/models/resolvi.md
+++ /dev/null
@@ -1,195 +0,0 @@
-# ResolVI
-
-**resolVI** {cite:p}`Ergen25` (Python class {class}`~scvi.external.RESOLVI`) is a generative model of single-cell resolved spatial
-transcriptomics that can subsequently be used for many common downstream tasks.
-
-The advantages of resolVI are:
-
-- Addresses noise and bias in ST data due to wrong segmentation, unspecific background and limited spatial resolution
-- Scalable to very large datasets (>1 million cells).
-
-The limitations of resolVI include:
-
-- Effectively requires a GPU for fast inference.
-- Latent space is not interpretable, unlike that of a linear method.
-- Assumes single cells are observed and do not work with low-resolution ST like Visium or Slide-Seq.
-
-:::{note}
-Starting scVI-Tools v1.5 this model is part of scVIVA-Tools, and no longer being maintained here.
-:::
-
-```{topic} Tutorials:
-
-- {doc}`/tutorials/notebooks/spatial/resolVI_tutorial`
-```
-
-## Preliminaries
-
-ResolVI takes as input spatially resolved RNA_seq count matrices downstream of cellular segmentation and molecule
-assignments to cells. These counts can be either derived from sequencing spatially resolved molecules or fluorescent
-imaging. ResolVI leverages the gene expression of neighboring cells and reassigns observed gene expression to neighboring
-cells as well as an unspecific background.
-
-ResolVI accepts as input the observed expression of the cell itself, its spatial neighbors and their gene expression
-as well as the distance between these cells. Additionally, a vector of categorical covariates $S$, representing
-batch, donor, etc., is an optional input to the model. ResolVI provides a semi-supervised mode, adjusting the prior in
-the latent space for different cell types and training a classifier to predict cell types from latent embeddings.
-
-## Generative process
-
-ResolVI posits that the observed expression of cell $n$ in gene $g$, $x_{ng}$ is generated by the following process:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- z &\sim \mathrm{MixtureOfGaussians}(\mu_1, \dots, \mu_K, \Sigma_1, \dots, \Sigma_K) \\
- \alpha_n &\sim \mathrm{Dirichlet}(C) \\
- r_{ng} &\sim \mathrm{Exponential}(R) \\
- h_{ng} &=
- \mathrm{Gamma}(r_{ng}, \frac{r_{ng}}{\alpha_0 f_\theta(z, b) + \alpha_1 \sum\limits_{{N(n)}} \beta_{N(n)} f_\theta(z_{N(n)}, b)}) + \alpha_2 bg\\
- x_{ng} &\sim \mathrm{Poisson}(l_n h_{ng})
-\end{align}
-```
-
-In particular, $z$ and $z_{N(n)}$ are the latent embeddings of the cell itself as well as its spatial neighbors
-both of dimension $L$. ResolVI uses a mixture of Gaussians prior to $z$:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- c_n &\sim \textrm{Categorical}(
- \pi_1, \pi_2, \dots, \pi_K
- ), \\
- z_n \mid c_n = c &\sim \mathcal{N}(\mu_c, \sigma_c)
-\end{align}
-```
-
-In brief, we assume that observed expression of gene $g$ for cell $n$ can be modelled as a sum over
-the components of expression truly expressed by the cell $\alpha_0$, the expression explained by neighboring
-cells $\alpha_1$ and wrongly assigned to $n$ and a component due to unspecific background $\alpha_2$.
-The expression of neighboring cells $N(n)$ is assigned to each of the neighboring cells $\beta_{N(n)n}$.
-Both the expression of cell $n$ and the expression of neighboring cells $N(n)$ are generated using the same
-generative network $f_\Theta$ from their respective latent code $z_{N(n)}$ and $z_n$.
-This generative process uses a neural network:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- f_{\theta}(z_{n}, s_n) &: \mathbb{R}^{d} \times \{0, 1\}^K \to \Delta^{G-1}
-\end{align}
-```
-
-which estimates the normalized gene expression of cell $n$. We use the observed counts per cell to scale these rates.
-
-The latent variables, along with their description, are summarized in the following table:
-
-```{eval-rst}
-.. list-table::
- :widths: 20 90 15 45
- :header-rows: 1
-
- * - Latent variable
- - Description
- - Code variable (if different)
- - Prior
- * - :math:`z_n \in \mathbb{R}^L`
- - Low-dimensional representation capturing the state of a cell
- - ``latent``
- - Mixture-of-Gaussian
- * - :math:`\beta_{N(n)} \in \Delta^{N(n) - 1}`
- - Per-neighbor diffusion
- - ``per_neighbor_diffusion``
- - Dirichlet
- * - :math:`\alpha_{n0 \dots 2} \in \Delta^{2}`
- - Per cell true, diffusion and background proportion
- - ``mixture_proportions``
- - Dirichlet
- * - :math:`bg_{ng} \in \Delta^{G - 1}`
- - Per cell estimate of background
- - ``background``
- - None
- * - :math:`background_{s} \in \mathbb{R}^G`
- - Per sample background vector
- - ``per_gene_background``
- - Dirichlet
- * - :math:`\rho_n \in \Delta^{G - 1}`
- - Per cell rate of expression
- - ``px_scale``
- - None
- * - :math:`\mu_n, \mu_{N(n)} \in \mathbb{R}^G`
- - Per cell estimated expression
- - ``px_rate and px_rate_n``
- - None
-```
-
-
-## Inference
-
-ResolVI uses variational inference, specifically auto-encoding variational Bayes
-(see {doc}`/user_guide/background/variational_inference`) in Pyro to learn both the model parameters
-(the neural network parameters, dispersion parameters, etc.) and an approximate posterior distribution.
-We perform amortization using neural network for $z_n$ and $\alpha_n$, while $\beta_{N(n)n}$ is estimated
-for each cell.
-
-## Tasks
-
-Here we provide an overview of some of the tasks that resolVI can perform. Please see {class}`scvi.external.RESOLVI`
-for the full API reference.
-
-### Dimensionality reduction
-
-For dimensionality reduction, the mean of the approximate posterior $q_\phi(z_i \mid y_i, n_i)$ is returned by default.
-This is achieved using the method:
-
-```
->>> adata.obsm["X_resolvi"] = model.get_latent_representation()
-```
-
-Users may also return samples from this distribution, as opposed to the mean, by passing the argument `give_mean=False`.
-The latent representation can be used to create a nearest neighbor graph with scanpy with:
-
-```
->>> import scanpy as sc
->>> sc.pp.neighbors(adata, use_rep="X_resolvi")
->>> adata.obsp["distances"]
-```
-
-### Transfer learning
-
-A resolVI model can be pre-trained on reference data and updated with query data using {meth}`~scvi.external.RESOLVI.load_query_data`, which then facilitates transfer of metadata like cell type annotations. $\beta_{N(n)n}$ is extended to the new cells and learned on these cells. The encoder by default does not see the batch covariate, and $z_n$ can be predicted without performing query model training. See the {doc}`/user_guide/background/transfer_learning` guide for more information.
-
-### Estimation of true expression levels
-
-In {meth}`~scvi.external.RESOLVI.get_normalized_expression` ResolVI returns the expected true expression value of $\rho_n$ under the approximate posterior. For one cell $n$, this can be written as:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- \mathbb{E}_{q_\phi(z_n \mid x_n)}\left[f_{\theta}\left(z_{n}, s_n \right) \right]
-\end{align}
-```
-
-### Differential expression
-
-Differential expression analysis is achieved with {meth}`~scvi.external.RESOLVI.differential_expression`.
-ResolVI tests differences in expression levels $\rho_{n} = f_{\theta}\left(z_n, s_n\right)$.
-We allow for importance-based sampling using pyro's built-in function.
-
-### Cell-type prediction
-
-Prediction of cell-type labels is performed with {meth}`~scvi.external.RESOLVI.predict`.
-A semisupervised model is necessary to perform this analysis as it leverages the cell-type classifier.
-ResolVI performs for each cell $n$ $c_{n} = h_{nu}\left(z_n\right)$ and samples from $z_n$ to yield
-the cell-type labels.
-
-### Differential niche abundance
-
-Differential niche abundance analysis is achieved with {meth}`~scvi.external.RESOLVI.differential_niche_abundance`.
-A semisupervised model is necessary to perform this analysis as it leverages the cell-type classifier.
-ResolVI tests differences in abundance of various cell-types in the neighborhood of a cell $n$
-$c_{n} = h_{nu}\left(z_n\right)$. Cell-type prediction vectors are averaged weighted by the distance of a specific cell
-and differential computation is performed.
diff --git a/docs/user_guide/models/scviva.md b/docs/user_guide/models/scviva.md
deleted file mode 100644
index 68c7ab993f..0000000000
--- a/docs/user_guide/models/scviva.md
+++ /dev/null
@@ -1,154 +0,0 @@
-# scVIVA
-
-**scVIVA** {cite:p}`Levy25` (Python class {class}`~scvi.external.SCVIVA`) is a generative model of single-cell resolved spatial
-transcriptomics that can subsequently be used for many common downstream tasks.
-
-The advantages of scVIVA are:
-
-- Provides a probabilistic low-dimensional representation of the state of each cell that is corrected for batch effects
- and captures its gene expression profile and its environment.
-- Enables differential expression analysis across niches while accounting for wrong assignment of molecules to cells.
-- Scalable to very large datasets (>1 million cells).
-
-The limitations of scVIVA include:
-
-- Effectively requires a GPU for fast inference.
-- Latent space is not interpretable, unlike that of a linear method.
-- Assumes single cells are observed and do not work with low-resolution ST like Visium or Slide-Seq.
-
-:::{note}
-Starting scVI-Tools v1.5 this model is part of scVIVA-Tools, and no longer being maintained here.
-:::
-
-```{topic} Tutorials:
-
-- {doc}`/tutorials/notebooks/spatial/scVIVA_tutorial`
-```
-
-## Preliminaries
-
-scVIVA takes as input spatially resolved scRNA data. In addition to the gene expression matrix ${X}$ with $N$ cells and $G$ genes,
-it requires for each cell $n$:
-- the spatial coordinates of the cell $y_n$
-- the cell type assignment (possibly coarse) $c_n \in \{1, ..., T\}$
-- the batch assignment $s_n$.
-
-
-As preprocessing, we take the $K$ nearest neighbors of a cell to define its niche using the Euclidean distance in physical space.
-We characterize the niche by its cell-type composition and gene expression. We denote by ${\alpha_n}$ the $T$-dimensional vector of cell type
-proportions among the $K$ nearest neighbors of the cell $n$. Its values are in the probability simplex.
-The niche gene expression is defined as the average expression of each cell type present in the niche.
-In practice, we leverage gene expression embeddings (PCA, scVI or similar) and characterize a cell type expression profile as the local average
-embedding of cells of the same type. The average embeddings are stored in the matrix ${\eta_n} \in \mathbb{R}^{T \times D}$, where $D$ is the embedding dimension.
-## Descriptive model
-
-We propose a latent variable model aiming to capture both gene expression heterogeneity and spatial variation resulting from the micro-environment.
-We assume these two sources of variability are both captured by a $P$-dimensional latent variable ($P \ll G$):
-
-```{math}
-:nowrap: true
-\begin{align}
- z_n \sim \mathbf{MixtureOfGaussians}(\mu_1, ..., \mu_M; \Sigma_1, ..,\Sigma_M; \pi_1, ...,\pi_M)
-\end{align}
-```
-
-We assume that the observed counts for cell $n$ and gene $g$, $x_{ng}$, are generated from the following process:
-
-```{math}
-:nowrap: true
-\begin{align}
- \rho _n &= f_{w}\left( z_n, s_n \right) \\
- x_{ng} &\sim \mathbf{NegativeBinomial}(\ell_n \rho_n, \theta_g),
- \end{align}
-```
-where $\rho_n$ is the normalized gene expression, $\ell_n$ is the library size of cell $n$ and $\theta_g$ is the dispersion parameter for gene $g$. See {doc}`/user_guide/models/scvi` for more details.
-The cell-type proportions of the cell's $K$ nearest neighbors are obtained as
-
-```{math}
-:nowrap: true
-\begin{align}
- \alpha_n &\sim \mathbf{Dirichlet}\left( f_{\omega}(z_n) \right),
-\end{align}
-```
-
-Last, we assume that the neighboring cells' average expression profiles are obtained as
-
-```{math}
-:nowrap: true
-\begin{equation}
-\eta_{nt} \sim
-\begin{cases}
-\mathcal{N} \left(f_{\nu}^{t}(z_n) \right), & \text{if } \alpha_{t} > 0 \\
-0, & \text{otherwise}
-\end{cases}
-\end{equation}
-```
-
-where $t=1,...,T$. $w$, $\omega$ and $\nu$ are neural network parameters.
-
-
-## Inference
-
-We want to maximize the evidence of the data, which can be decomposed as:
-
-```{math}
-:nowrap: true
-\begin{align}
- \log p \left( \alpha, x, \eta \mid s \right) = \log p \left(x \mid s \right) + \log p \left( \alpha, \eta \mid x, s \right).
-\end{align}
-```
-
-scVIVA uses variational inference, specifically auto-encoding variational Bayes
-(see {doc}`/user_guide/background/variational_inference`) to learn both the model parameters
-(the neural network parameters, dispersion parameters, etc.) and an approximate posterior distribution.
-
-## Tasks
-
-Here we provide an overview of some of the tasks that scVIVA can perform. Please see {class}`scvi.external.SCVIVA`
-for the full API reference.
-
-### Dimensionality reduction
-
-For dimensionality reduction, the mean of the approximate posterior $q_\phi(z \mid x)$ is returned by default.
-This is achieved using the method:
-
-```
->>> adata.obsm["X_scVIVA"] = model.get_latent_representation()
-```
-
-$\phi$ is a set of parameters corresponding to inference neural networks (encoders).
-Users may also return samples from this distribution, as opposed to the mean, by passing the argument `give_mean=False`.
-
-### Estimation of normalized expression
-
-In {meth}`~scvi.external.SCVIVA.get_normalized_expression` scVIVA returns the expected true expression value of $\rho_n$ under the approximate posterior. For one cell $n$, this can be written as:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- \mathbb{E}_{q_\phi(z_n \mid x_n)}\left[f_{w}\left(z_{n}, s_n \right) \right]
-\end{align}
-```
-
-### Differential Expression (DE)
-
-Differential expression analysis is achieved with {meth}`~scvi.external.SCVIVA.differential_expression`. \
-We leverage the lvm-DE method (see {doc}`/user_guide/background/differential_expression`) and adapt it to spatial data by taking into account cell neighborhood expression in a bid to discard false positives due to contamination. \
-Considering two groups of cells $\textit{C1}$ and $\textit{C2}$ corresponding to different spatial contexts (for instance, astrocytes in two brain regions), the goal is to determine which genes have different expression levels between the two groups. When setting `niche_mode="true"`, we compute the group spatial neighborhoods $\textit{N1}$ and $\textit{N2}$, which are the spatial nearest neighbors of a different type than the cells in $\textit{C1}$, and $\textit{C2}$ respectively.
-
-
-To determine the upregulated genes of $\textit{C1 vs C2}$, we compute DE between $\{\textit{C1, C2}\}$, $\{\textit{N1, C2}\}$ and $\{\textit{C1, N1}\}$: using lvm-DE, we test differences in expression levels $\rho_{n}$ to compute Log-Fold Changes (LFC). \
-The significantly upregulated genes for $\textit{C1, N1}$ define a set of local cell type markers, denoted $\mathcal{S}_1$. Conversely, if a gene is both higher expressed in $\textit{N1}$ compared to $\textit{C1}$ and $\textit{C1}$ compared to $\textit{C2}$, it is likely that the increased expression in $\textit{C1}$ is spurious.
-We argue that the probability of a gene being a $\textit{local marker}$ could be a relevant score to filter spurious genes. To compute this score, we considered the upregulation of a gene in one group relative to the upregulation in its neighborhood: a local marker $g$ should verify
-
-```{math}
-:nowrap: true
-\begin{align}
- \mathit{LFC^{~g}_{C1~vs~C2}} > \mathit{LFC^{~g}_{N1~vs~C2}},
-\end{align}
-```
-
-which means that the signal comes from cells in $\textit{C1}$ rather than their neighbors $\textit{N1}$. \
-We select genes for which $\mathit{LFC_{C1~vs~C2}} > 0$ and use the genes $\mathcal{S}_1$ as truly differentially expressed. We also define $\mathcal{N}_1 = \{g|\mathit{LFC^{~g}_{C1~vs~C2}} > 0,~g \notin \mathcal{S}_1 \}$. \
-We train a Gaussian process classifier on $\mathbf{X} = [LFC_{C1~vs~C2}~,~LFC_{N1~vs~C2}]$ to classify between the $\textit{local markers}$ $\mathcal{S}_1$ and the $\textit{neighborhood genes}$ $\mathcal{N}_1$. Once fitted, the classifier returns a local marker probability $p_g=\mathit{p}(g \in \mathcal{S}_1 | \mathbf{X})$ for each gene $g$, that we can compare to a given threshold $\tau$ to filter the neighborhood genes.
diff --git a/docs/user_guide/models/stereoscope.md b/docs/user_guide/models/stereoscope.md
deleted file mode 100644
index bd59aadb84..0000000000
--- a/docs/user_guide/models/stereoscope.md
+++ /dev/null
@@ -1,185 +0,0 @@
-# Stereoscope
-
-**Stereoscope** {cite:p}`Andersson20` (Python classes {class}`~scvi.external.RNAStereoscope` and {class}`~scvi.external.SpatialStereoscope`) posits a probabilistic model of spatial transcriptomics and an associated
-method for the deconvoluton of cell type profiles using a single-cell RNA sequencing reference dataset.
-
-The advantages of Stereoscope are:
-
-- Can stratify cells into discrete cell types.
-- Scalable to very large datasets (>1 million cells).
-
-The limitations of Stereoscope include:
-
-- Effectively requires a GPU for fast inference.
-
-:::{note}
-Starting scVI-Tools v1.5 this model is part of scVIVA-Tools, and no longer being maintained here.
-:::
-
-```{topic} Tutorial:
-
-- {doc}`/tutorials/notebooks/spatial/stereoscope_heart_LV_tutorial`
-```
-
-## Preliminaries
-
-Stereoscope requires training two latent variable models (LVMs): one for the single-cell reference
-dataset and one for the spatial transcriptomics dataset, which incorporates the learned parameters of the
-single-cell reference LVM. The first LVM takes in as input a scRNA-seq gene expression matrix of UMI counts
-$Y$ with $N$ cells and $G$ genes, along with a vector of cell type labels $\vec{z}$.
-Subsequently, the second LVM takes in the learned parameters of the first LVM, along with a spatial gene
-expression matrix $X$ with $S$ spots and $G$ genes.
-
-## Generative process
-
-### Single-cell reference LVM
-
-For cell $c$, the LVM assumes an observed discrete cell type label $z_c$ and models
-the UMI count observation for a given gene $g$ as a negative binomial distribution. This LVM posits that the observed
-UMI counts for cell $c$ and gene $g$ are generated by the following process:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- y_{gc} &\sim \textrm{NegativeBinomial}(s_{c}r_{gz}, p_{g}) \tag{1} \\
-\end{align}
-```
-
-where $s_c = \sum_{g\in G} y_{gc}$ is the observed library size of the cell,
-$r_{gz}$ is the latent rate parameter for the cell type $z_c$ and gene $g$,
-and $p_g$ is the latent variable representing the success probability for gene $g$.
-
-:::{note}
-We are using the standard rate-shape parametrization of the negative binomial here, rather than the mean-dispersion
-parametrization used in {doc}`/user_guide/models/scvi`. This is to take advantage of the additive property of
-negative binomial distributions sharing the same shape parameter. In this case, the rate parameter for the
-negative binomial modeling the expression counts for a given gene and spot is equivalent to the sum of the rate
-parameters for each contributing cell.
-:::
-
-This generative process is also summarized in the following graphical model:
-
-:::{figure} figures/stsc_scLVM_graphical_model.svg
-:align: center
-:alt: single-cell reference LVM graphical model
-:class: img-fluid
-
-single-cell reference LVM graphical model.
-:::
-
-The latent variables for the single-cell reference LVM, along with their description, are summarized in the following table:
-
-```{eval-rst}
-.. list-table::
- :widths: 20 90 15
- :header-rows: 1
-
- * - Latent variable
- - Description
- - Code variable (if different)
- * - :math:`r_{gz} \in (0, \infty)`
- - Rate parameter for the negative binomial distribution.
- - ``px_scale``
- * - :math:`p_g \in [0, 1]`
- - Shape parameter for the negative binomial distribution.
- - ``px_o`` :math:`:= \log \left( \frac{p_g}{1 - p_g} \right)`
-```
-
-### Spatial transcriptomics LVM
-
-For the second LVM, we also model the expression counts with a $\mathrm{NegativeBinomial}$. However,
-for spatial data, we assume that each spot $s$ has expression $x_s$ composed of a bulk of cell types, with
-cell type abundance, $v_{sz}$, for each cell type $z$. We assume that for a given spot $s$ and gene $g$,
-the observation is generated by the following process:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- x_{sg} &\sim \mathrm{NegativeBinomial}(\beta_g\sum_{z\in Z}v_{sz}r_{gz}, p_g) \tag{2} \\
-\end{align}
-```
-
-where $\beta_g$ is a gene-specific correction term for technical differences.
-The parameters $r_{gz}$ and $p_g$ are the learned parameters from the first LVM.
-
-An additional latent variable, $\eta_g$, is incorporated into the aggregated cell expression profile
-as a dummy cell type to represent gene-specific noise. The dummy cell type's expression profile is distributed
-as $\varepsilon_g := \mathrm{Softplus}(\eta_g)$ where $\eta_g \sim \mathrm{Normal}(0, 1)$ to avoid the model
-from incorrectly assigning explanatory power to this term.
-Like the other cell types, there is an associated cell type abundance parameter $\gamma_s$ associated with $\varepsilon$.
-
-This generative process is also summarized in the following graphical model:
-
-:::{figure} figures/stsc_stLVM_graphical_model.svg
-:align: center
-:alt: spatial transcriptomics LVM graphical model
-:class: img-fluid
-
-spatial transcriptomics LVM graphical model.
-:::
-
-The latent variables for the spatial transcriptomics LVM, along with their description, are summarized in the following table:
-
-```{eval-rst}
-.. list-table::
- :widths: 20 90 15
- :header-rows: 1
-
- * - Latent variable
- - Description
- - Code variable (if different)
- * - :math:`v_{sz} \in (0, \infty)`
- - Spot-specific cell type abundance. The code variable ``v_ind`` also incorporates the
- the abundance term, :math:`\gamma_s` for the dummy noise cell type, :math:`\varepsilon`.
- - ``v_ind``
- * - :math:`\eta_g \in (-\infty, \infty)`
- - Gene-specific noise. Incorporated into the model as :math:`\varepsilon_g := \mathrm{Softplus}(\eta_g)`.
- - ``eta``
- * - :math:`\beta_g \in (0, \infty)`
- - Correction term for technological differences.
- - ``beta``
- * - :math:`r_{gz} \in (0, \infty)`
- - Rate parameter for the negative binomial distribution shared from the single-cell reference LVM.
- - ``w``
- * - :math:`p_g \in [0,1]`
- - Shape parameter for the negative binomial distribution shared from the single-cell reference LVM.
- - ``px_o`` :math:`:= \log \left( \frac{p_g}{1 - p_g} \right)`
-
-```
-
-## Inference
-
-### Single-cell reference LVM
-
-Stereoscope uses maximum likelihood estimation to estimate the parameters of the first LVM w.r.t. The negative binomial model of
-UMI observations. This is achieved via stochastic gradient ascent on the likelihood function using the Pytorch framework.
-
-### Spatial transcriptomics LVM
-
-For the spatial transcriptomics LVM, Stereoscope uses MAP inference to estimate the parameters specific to the model. To be exact,
-the only parameter given a non-uniform prior is $\eta_g$ that is posited as a gene-specific random effect distributed by a standard
-Normal prior. Note, the $r_{gz}$ and $p_g$ parameters are not inferred in this step, but held fixed as the parameters shared by the
-single-cell reference LVM.
-
-## Tasks
-
-### Cell type deconvolution
-
-Once the model is trained, one can retrieve the estimated cell type proportions in each spot using the method:
-
-```
->>> proportions = spatial_model.get_proportions()
->>> st_adata.obsm["proportions"] = proportions
-```
-
-These proportions are computed by normalizing across all learned cell type abundances, $v_{sz}$, for a given spot $s$.
-I.e., the estimated proportion of cell type $z$ for spot $s$ is $\frac{v_{sz}}{\sum_{z'} v_{sz'}}$.
-
-Subsequently, for a given cell type, users can plot a heatmap of the cell type proportions spatially using scanpy with:
-
-```
->>> import scanpy as sc
->>> sc.p1.embedding(st_adata, basis="location", color="B cells")
-```
diff --git a/docs/user_guide/models/tangram.md b/docs/user_guide/models/tangram.md
deleted file mode 100644
index 30a0ce1119..0000000000
--- a/docs/user_guide/models/tangram.md
+++ /dev/null
@@ -1,122 +0,0 @@
-# Tangram
-
-:::{note}
-This model is deprecated starting v1.5.
-:::
-
-**Tangram** {cite:p}`Biancalani21` (Python class {class}`~scvi.external.Tangram`) maps single-cell RNA-seq data to spatial data, permitting deconvolution of cell types in spatial data like Visium.
-
-This is a reimplementation of Tangram, which can originally be found [here](https://github.com/broadinstitute/Tangram).
-
-The advantages of Tangram are:
-
-- It maps single-cell transcriptomes onto spatial observations with a directly
- interpretable mapping matrix.
-- It can project cell annotations, such as cell types, from single-cell data to spatial
- data.
-- It can project gene expression from the single-cell reference into spatial
- coordinates.
-- The scvi-tools implementation supports Tangram's `"cells"` and `"constrained"`
- modes.
-
-The limitations of Tangram include:
-
-- The scvi-tools model page is deprecated starting v1.5.
-- It requires matched genes between the single-cell and spatial modalities used for
- training.
-- Training is not mini-batched in the current implementation, so memory use depends on
- the number of single-cell observations and spatial observations.
-- Tangram is an optimization-based mapping model, not a generative model with posterior
- sampling.
-
-## Overview
-
-Tangram learns a matrix $M$ with shape ($n_{sc} \times n_{sp}$), in which each row sums to 1. Thus, this matrix can be viewed as a map from single cells to the spatial observations.
-
-:::{note}
-Starting scVI-Tools v1.5 this model is part of scVIVA-Tools, and no longer being maintained here.
-:::
-
-```{topic} Tutorials:
-
-- {doc}`/tutorials/notebooks/spatial/tangram_scvi_tools`
-```
-
-## Preliminaries
-
-Tangram is registered with {meth}`~scvi.external.Tangram.setup_mudata`, not
-`setup_anndata`. The input is a MuData object containing a single-cell modality and a
-spatial modality. The two modalities used for training must contain the same genes in the
-same order.
-
-The `modalities` argument tells Tangram which MuData modality contains each registered
-field. A typical setup registers:
-
-- `sc_layer`, the single-cell expression matrix used for mapping,
-- `sp_layer`, the spatial expression matrix used as the target, and
-- `density_prior_key`, an optional spatial observation column containing a density prior.
-
-If a density prior is supplied, it must sum to 1. The tutorial computes a density prior
-from estimated cell counts in spatial observations.
-
-## Mapping Objective
-
-The mapping matrix $M$ is parameterized by unconstrained trainable weights and converted
-to a row-stochastic matrix with a softmax. The predicted spatial expression matrix is:
-
-```{math}
-:nowrap: true
-
-\begin{align}
- \hat{X}_{sp} = M^\top X_{sc},
-\end{align}
-```
-
-where $X_{sc}$ is the single-cell expression matrix and $\hat{X}_{sp}$ is the spatial
-expression predicted from mapped single cells.
-
-Tangram optimizes a loss that rewards agreement between measured spatial expression and
-the predicted spatial expression. The default expression term uses gene-wise cosine
-similarity, and an optional spatial-observation-wise cosine term can also be enabled. If a
-density prior is registered, the loss includes a KL-divergence term between the predicted
-spatial density and the supplied prior.
-
-In constrained mode, Tangram also learns a cell filter and requires `target_count`. The
-loss then includes a count term that encourages the selected number of cells to match
-`target_count`, plus a filter regularizer.
-
-## Tasks
-
-Here we provide an overview of common tasks. Please see {class}`~scvi.external.Tangram`
-for the full API reference.
-
-### Mapping Matrix
-
-After training, {meth}`~scvi.external.Tangram.get_mapper_matrix` returns the mapping
-matrix with shape `(n_obs_sc, n_obs_sp)`:
-
-```
->>> mapper = model.get_mapper_matrix()
-```
-
-Each row contains a probability distribution over spatial observations for one
-single-cell observation.
-
-### Projection of Cell Annotations
-
-{meth}`~scvi.external.Tangram.project_cell_annotations` uses the mapping matrix to
-project categorical single-cell labels, such as cell types, onto spatial observations:
-
-```
->>> adata_sp.obsm["tangram_ct_pred"] = model.project_cell_annotations(
-... adata_sc, adata_sp, mapper, adata_sc.obs["cell_type"]
-... )
-```
-
-The returned DataFrame has spatial observations as rows and label categories as columns.
-
-### Projection of Genes
-
-{meth}`~scvi.external.Tangram.project_genes` multiplies the mapping matrix by the
-single-cell expression matrix and returns an AnnData object containing projected gene
-expression in spatial coordinates.
diff --git a/pyproject.toml b/pyproject.toml
index 6cfaaedffa..f1ec1b3c2d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ requires = ["hatchling"]
[project]
name = "scvi-tools"
-version = "1.5.0.post1"
+version = "1.5.0"
description = "Deep probabilistic analysis of single-cell omics data."
readme = "README.md"
requires-python = ">=3.12"
@@ -78,10 +78,10 @@ docs = [
"myst-nb",
"sphinx-autodoc-typehints",
]
-docsbuild = ["scvi-tools[docs,autotune,hub,diagvi]","mlx"]
+docsbuild = ["scvi-tools[docs,autotune,hub]","mlx"]
# scvi.autotune
-autotune = ["hyperopt>=0.2", "setuptools>=77.0.3,<82", "ray[tune]", "scib-metrics", "muon"]
+autotune = ["hyperopt>=0.2", "ray[tune]", "scib-metrics", "muon"]
# scvi.hub dependencies
hub = ["huggingface_hub", "dvc[s3]", "boto3"]
# scvi.data.add_dna_sequence
@@ -94,8 +94,6 @@ parallel = ["dask[array]", "zarr"]
interpretability = ["captum", "shap", "decoupler"]
# for custom dataloders
dataloaders = ["lamindb>=1.12.1", "cellxgene-census", "tiledbsoma", "tiledbsoma_ml", "torchdata","annbatch[zarrs]>=0.2.0"]
-# for diagvi
-diagvi = ["torch_geometric", "geomloss"]
# for mlflow
mlflow = ["mlflow","psutil","GPUtil","nvidia-ml-py"]
# for rapids
@@ -104,12 +102,11 @@ rapids-cuda12 = [ "cugraph-cu12>=24", "cuml-cu12>=24", "cupy-cuda12x", "rapids-s
rapids-cuda13 = [ "cugraph-cu13>=24", "cuml-cu13>=24", "cupy-cuda13x", "rapids-singlecell-cu13[rapids]" ]
optional = [
- "scvi-tools[autotune,mlflow,hub,file_sharing,regseq,parallel,interpretability,diagvi]",
+ "scvi-tools[autotune,mlflow,hub,file_sharing,regseq,parallel,interpretability]",
"igraph","leidenalg","pynndescent",
]
tutorials = [
"biomart",
- "cell2location",
"jupyter",
"seaborn",
"matplotlib",
diff --git a/src/scvi/external/__init__.py b/src/scvi/external/__init__.py
index 931e5ef7f3..83ae5741d4 100644
--- a/src/scvi/external/__init__.py
+++ b/src/scvi/external/__init__.py
@@ -2,35 +2,25 @@
from .contrastivevi import ContrastiveVI
from .cytovi import CYTOVI
from .decipher import Decipher
-from .diagvi import DIAGVI
from .drvi import DRVI
-from .gimvi import GIMVI
from .joint_embedding_scvi import JointEmbeddingSCVI, JointEmbeddingVAE
from .methylvi import METHYLANVI, METHYLVI
from .mrvi import MRVI
from .poissonvi import POISSONVI
-from .resolvi import RESOLVI
from .scar import SCAR
from .scbasset import SCBASSET
-from .scviva import SCVIVA
from .solo import SOLO
-from .stereoscope import RNAStereoscope, SpatialStereoscope
from .sysvi import SysVI
-from .tangram import Tangram
from .totalanvi import TOTALANVI
from .velovi import VELOVI
__all__ = [
"SCAR",
"SOLO",
- "GIMVI",
"JointEmbeddingSCVI",
"JointEmbeddingVAE",
"Decipher",
- "RNAStereoscope",
- "SpatialStereoscope",
"CellAssign",
- "Tangram",
"TOTALANVI",
"SCBASSET",
"POISSONVI",
@@ -40,9 +30,6 @@
"MRVI",
"METHYLVI",
"METHYLANVI",
- "RESOLVI",
- "SCVIVA",
"CYTOVI",
- "DIAGVI",
"DRVI",
]
diff --git a/src/scvi/external/diagvi/__init__.py b/src/scvi/external/diagvi/__init__.py
deleted file mode 100644
index cb7a89ed06..0000000000
--- a/src/scvi/external/diagvi/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""DIAGVI model for multi-modal integration with guidance graphs."""
-
-from ._model import DIAGVI
-from ._module import DIAGVAE
-from ._task import DiagTrainingPlan
-
-__all__ = ["DIAGVI", "DIAGVAE", "DiagTrainingPlan"]
diff --git a/src/scvi/external/diagvi/_base_components.py b/src/scvi/external/diagvi/_base_components.py
deleted file mode 100644
index fd5a71122a..0000000000
--- a/src/scvi/external/diagvi/_base_components.py
+++ /dev/null
@@ -1,270 +0,0 @@
-"""Base neural network components for DIAGVI model."""
-
-from __future__ import annotations
-
-import torch
-import torch.nn as nn
-import torch.nn.functional as F
-from torch.distributions import Normal
-
-from scvi.utils import dependencies
-
-EPS = 1e-8
-
-
-class DecoderSinglePathway(nn.Module):
- """Single-pathway decoder for unimodal likelihoods.
-
- Outputs a single set of parameters (scale, r, rate, dropout).
- Use for: nb, zinb, normal, log1pnormal, ziln, zig.
-
- Parameters
- ----------
- n_output
- Number of output features.
- n_batches
- Number of batches.
- normalize
- If True, apply softmax normalization to output scale (for count data).
- If False, output raw scale values (for continuous data).
- Default is True for backward compatibility.
- """
-
- def __init__(
- self,
- n_output: int,
- n_batches: int,
- normalize: bool = True,
- ):
- super().__init__()
- self.n_output = n_output
- self.n_batches = n_batches
- self.normalize = normalize
-
- self.scale_lin = nn.Parameter(torch.zeros(n_batches, n_output))
- self.bias = nn.Parameter(torch.zeros(n_batches, n_output))
- self.log_theta = nn.Parameter(torch.zeros(n_batches, n_output))
-
- self.px_dropout_param = nn.Parameter(torch.randn(n_output) * 0.01)
-
- def forward(
- self,
- u: torch.Tensor,
- l: torch.Tensor,
- batch_index: torch.Tensor,
- v: torch.Tensor,
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
- """Forward pass through the decoder.
-
- Parameters
- ----------
- u
- Cell latent representation, shape (n_cells, n_latent).
- l
- Library size (log-scale), shape (n_cells, 1).
- batch_index
- Batch indices for cells, shape (n_cells,) or (n_cells, 1).
- v
- Feature latent representation, shape (n_features, n_latent).
-
- Returns
- -------
- tuple of 4 :class:`torch.Tensor`
- - px_scale: Normalized scale parameters, shape (n_cells, n_features).
- - px_r: Overdispersion parameter, shape (n_cells, n_features).
- - px_rate: Rate parameters (mean), shape (n_cells, n_features).
- - px_dropout: Dropout/zero-inflation probabilities, shape (n_features,).
- """
- # Check batch index dimension
- if batch_index.dim() > 1:
- batch_index = batch_index.squeeze(-1)
-
- if (batch_index.max() >= self.bias.shape[0]) or (batch_index.min() < 0):
- raise IndexError(
- f"Batch index out of bounds: valid range is [0, {self.bias.shape[0] - 1}]"
- )
-
- # Get batch-specific parameters
- scale = F.softplus(self.scale_lin[batch_index])
- bias = self.bias[batch_index]
- log_theta = self.log_theta[batch_index]
-
- # Compute decoder output parameters
- raw_px_scale = scale * (u @ v.T) + bias
-
- if self.normalize:
- # Count data: softmax produces proportions, library scales to counts
- px_scale = torch.softmax(raw_px_scale, dim=-1)
- px_rate = torch.exp(l) * px_scale
- else:
- # Continuous data: no normalization, scale is positive, rate is unbounded
- px_scale = F.softplus(raw_px_scale) + EPS
- px_rate = raw_px_scale
-
- px_dropout = F.softplus(self.px_dropout_param)
- px_r = log_theta
-
- return px_scale, px_r, px_rate, px_dropout
-
-
-class DecoderDualPathway(nn.Module):
- """Dual-pathway decoder for mixture likelihoods.
-
- Outputs two sets of parameters for foreground/background mixture models.
- Use for: nbmixture.
-
- Parameters
- ----------
- n_output
- Number of output features.
- n_batches
- Number of batches.
- normalize
- If True, apply softmax normalization to output scales (for count data).
- If False, output raw scale values (for continuous data).
- Default is True for backward compatibility.
- """
-
- def __init__(
- self,
- n_output: int,
- n_batches: int,
- normalize: bool = True,
- ):
- super().__init__()
- self.n_output = n_output
- self.n_batches = n_batches
- self.normalize = normalize
-
- self.scale_lin = nn.Parameter(torch.zeros(n_batches, n_output))
-
- self.bias1 = nn.Parameter(torch.zeros(n_batches, n_output))
- self.bias2 = nn.Parameter(torch.zeros(n_batches, n_output))
-
- self.log_theta = nn.Parameter(torch.zeros(n_batches, n_output))
-
- def forward(
- self,
- u: torch.Tensor,
- l: torch.Tensor,
- batch_index: torch.Tensor,
- v: torch.Tensor,
- ) -> tuple[
- tuple[torch.Tensor, torch.Tensor],
- torch.Tensor,
- tuple[torch.Tensor, torch.Tensor],
- torch.Tensor,
- ]:
- """Forward pass through the decoder.
-
- Parameters
- ----------
- u
- Cell latent representation, shape (n_cells, n_latent).
- l
- Library size (log-scale), shape (n_cells, 1).
- batch_index
- Batch indices for cells, shape (n_cells,) or (n_cells, 1).
- v
- Feature latent representation, shape (n_features, n_latent).
-
- Returns
- -------
- tuple of 4 elements
- - scales: Tuple of (foreground, background) scale parameters.
- Each has shape (n_cells, n_features).
- - px_r: Overdispersion parameter, shape (n_cells, n_features).
- - rates: Tuple of (foreground, background) rate parameters (mean).
- Each has shape (n_cells, n_features).
- - mixture_logits: Logits for mixture component probabilities,
- shape (n_cells, n_features).
- """
- # Check batch index dimension
- if batch_index.dim() > 1:
- batch_index = batch_index.squeeze(-1)
-
- if (batch_index.max() >= self.bias1.shape[0]) or (batch_index.min() < 0):
- raise IndexError(
- f"Batch index out of bounds: valid range is [0, {self.bias1.shape[0] - 1}]"
- )
-
- # Get batch-specific parameters
- scale = F.softplus(self.scale_lin[batch_index])
- bias1 = self.bias1[batch_index]
- bias2 = self.bias2[batch_index]
- log_theta = self.log_theta[batch_index]
-
- # Compute decoder output parameters for both pathways
- raw_px_scale_1 = scale * (u @ v.T) + bias1
- raw_px_scale_2 = scale * (u @ v.T) + bias2
-
- if self.normalize:
- # Count data: softmax produces proportions, library scales to counts
- px_scale_1 = torch.softmax(raw_px_scale_1, dim=-1)
- px_scale_2 = torch.softmax(raw_px_scale_2, dim=-1)
- px_rate_1 = torch.exp(l) * px_scale_1
- px_rate_2 = torch.exp(l) * px_scale_2
- else:
- # Continuous data: no normalization, scales are positive, rates are unbounded
- px_scale_1 = F.softplus(raw_px_scale_1) + EPS
- px_scale_2 = F.softplus(raw_px_scale_2) + EPS
- px_rate_1 = raw_px_scale_1
- px_rate_2 = raw_px_scale_2
-
- mixture_logits = raw_px_scale_1 - raw_px_scale_2
- px_r = log_theta
-
- return (px_scale_1, px_scale_2), px_r, (px_rate_1, px_rate_2), mixture_logits
-
-
-class GraphEncoder(nn.Module):
- """Graph convolutional encoder for feature embeddings.
-
- Encodes feature nodes in the guidance graph to learn feature
- embeddings that capture cross-modality relationships.
-
- Parameters
- ----------
- vnum
- Number of nodes (features) in the graph.
- out_features
- Dimensionality of the output feature embeddings.
- """
-
- @dependencies("torch_geometric")
- def __init__(self, vnum: int, out_features: int):
- import torch_geometric
-
- super().__init__()
- self.vrepr = nn.Parameter(torch.zeros(vnum, out_features))
- self.conv = torch_geometric.nn.GCNConv(out_features, out_features)
- self.loc = nn.Linear(out_features, out_features)
- self.std_lin = nn.Linear(out_features, out_features)
-
- def forward(self, edge_index: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
- """Forward pass through the graph encoder.
-
- Parameters
- ----------
- edge_index
- Edge indices for the graph, shape (2, n_edges).
-
- Returns
- -------
- tuple of 3 :class:`torch.Tensor`
- - z: Sampled latent representation from the normal distribution,
- shape (n_nodes, out_features).
- - mu: Mean of the latent distribution, shape (n_nodes, out_features).
- - logvar: Log variance of the latent distribution, shape (n_nodes, out_features).
- """
- h = self.conv(self.vrepr, edge_index)
- loc = self.loc(h)
- std = F.softplus(self.std_lin(h)) + EPS
-
- dist = Normal(loc, std)
- mu = dist.loc
- std = dist.scale
- logvar = torch.log(std**2)
- z = dist.rsample()
-
- return z, mu, logvar
diff --git a/src/scvi/external/diagvi/_model.py b/src/scvi/external/diagvi/_model.py
index ec4552047d..e69de29bb2 100644
--- a/src/scvi/external/diagvi/_model.py
+++ b/src/scvi/external/diagvi/_model.py
@@ -1,1237 +0,0 @@
-"""DIAGVI model for multi-modal integration with guidance graphs."""
-
-from __future__ import annotations
-
-import logging
-import os
-import warnings
-from typing import TYPE_CHECKING, Literal
-
-import numpy as np
-import scipy.sparse
-import torch
-from mudata import MuData
-
-from scvi import REGISTRY_KEYS, settings
-from scvi.data import AnnDataManager
-from scvi.data._constants import _MODEL_NAME_KEY, _SETUP_ARGS_KEY
-from scvi.data.fields import CategoricalObsField, LabelsWithUnlabeledObsField, LayerField
-from scvi.dataloaders import DataSplitter
-from scvi.external.diagvi._utils import (
- _check_guidance_graph_consistency,
- _construct_guidance_graph,
- _load_saved_diagvi_files,
-)
-from scvi.external.gimvi._task import CyclicMultiDataLoader as TrainDL
-from scvi.model._utils import get_max_epochs_heuristic, parse_device_args, use_distributed_sampler
-from scvi.model.base import BaseModelClass, VAEMixin
-from scvi.module._constants import MODULE_KEYS
-from scvi.train import Trainer
-from scvi.utils import dependencies, setup_anndata_dsp
-from scvi.utils._docstrings import devices_dsp
-
-from ._module import DIAGVAE
-from ._task import DiagTrainingPlan
-
-if TYPE_CHECKING:
- from collections.abc import Sequence
- from typing import Literal
-
- import pandas as pd
- from anndata import AnnData
- from torch_geometric.data import Data
-
-
-logger = logging.getLogger(__name__)
-
-
-class DIAGVI(BaseModelClass, VAEMixin):
- """Diagonal Multi-Modal Integration Variational Inference (DIAGVI) model.
-
- Integrates multi-modal single-cell data using a guidance graph and supports
- semi-supervised learning and GMM priors.
-
- The model architecture is inspired by GLUE (Cao & Gao, 2022).
- This scvi-tools implementation is based on GimVI.
- Handling of continuous data in decoder is inspired by CytoVI.
-
- Parameters
- ----------
- adatas
- Dictionary mapping input names to AnnData objects.
- guidance_graph
- Precomputed guidance graph. If None, it will be constructed from the data
- by using overlapping feature names.
- mapping_df
- DataFrame specifying feature correspondences between modalities
- (used to compute the guidance graph).
- n_latent
- Dimensionality of the latent space.
- n_hidden
- Number of nodes per hidden layer.
- n_layers
- Number of hidden layers used for encoder and decoder NNs.
- dropout_rate
- Dropout rate for neural networks.
- **model_kwargs
- Additional keyword arguments for :class:`~scvi.external.diagvi._module.DIAGVAE`.
-
- Examples
- --------
- >>> adatas = {"rna_data": adata_rna, "protein_data": adata_protein}
- >>> model = DIAGVI(adatas)
- >>> model.train()
- """
-
- _module_cls = DIAGVAE
- _data_splitter_cls = DataSplitter
- _training_plan_cls = DiagTrainingPlan
-
- def __init__(
- self,
- adatas: dict[str, AnnData] | MuData,
- guidance_graph: Data | None = None,
- mapping_df: pd.DataFrame | None = None,
- n_latent: int = 50,
- n_hidden: int = 256,
- n_layers: int = 2,
- dropout_rate: float = 0.1,
- **model_kwargs,
- ):
- warnings.warn(
- "DiagVI is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- super().__init__()
- # Handle MuData input by extracting modalities as dict
- if isinstance(adatas, MuData):
- self.adatas = {mod_key: adatas.mod[mod_key] for mod_key in adatas.mod.keys()}
- else:
- self.adatas = adatas
- self.input_names = list(self.adatas.keys())
- self.adata_managers = {
- name: self._get_most_recent_anndata_manager(adata, required=True)
- for name, adata in self.adatas.items()
- }
-
- self.registries_ = []
- for adm in self.adata_managers.values():
- self._register_manager_for_instance(adm)
- self.registries_.append(adm.registry)
-
- sum_stats = {name: adm.summary_stats for name, adm in self.adata_managers.items()}
- self.summary_stats = sum_stats
- n_inputs = {name: s["n_vars"] for name, s in sum_stats.items()}
- n_batches = {name: s["n_batch"] for name, s in sum_stats.items()}
- n_labels = {name: s["n_labels"] for name, s in sum_stats.items()}
-
- generative_distributions = {
- name: adata.uns["diagvi_likelihood"] for name, adata in self.adatas.items()
- }
- normalize_lib = {
- name: adata.uns["diagvi_normalize_lib"] for name, adata in self.adatas.items()
- }
- gmm_priors = {name: adata.uns["diagvi_gmm_prior"] for name, adata in self.adatas.items()}
- semi_supervised = {
- name: adata.uns["diagvi_semi_supervised"] for name, adata in self.adatas.items()
- }
- n_mixture_components = {
- name: adata.uns["diagvi_n_mixture_components"] for name, adata in self.adatas.items()
- }
-
- # Compute guidance graph if not provided, do a sanity check
- if guidance_graph is not None:
- self.guidance_graph = guidance_graph
- else:
- self.guidance_graph = _construct_guidance_graph(self.adatas, mapping_df)
- _check_guidance_graph_consistency(self.guidance_graph, self.adatas)
- self.module = self._module_cls(
- n_inputs=n_inputs,
- n_batches=n_batches,
- n_labels=n_labels,
- modality_likelihoods=generative_distributions,
- normalize_lib=normalize_lib,
- guidance_graph=self.guidance_graph,
- use_gmm_prior=gmm_priors,
- semi_supervised=semi_supervised,
- n_mixture_components=n_mixture_components,
- n_latent=n_latent,
- n_hidden=n_hidden,
- n_layers=n_layers,
- dropout_rate=dropout_rate,
- **model_kwargs,
- )
-
- self._model_summary_string = (
- f"DiagVI Model with the following params: input names: {self.input_names}, "
- f"n_inputs: {n_inputs}, n_batches: {n_batches}, n_labels: {n_labels}, "
- f"semi_supervised: {semi_supervised}, gmm_priors: {gmm_priors}, "
- f"generative distributions: {generative_distributions}, "
- f"n_latent: {n_latent}."
- )
-
- self.init_params_ = self._get_init_params(locals())
- logger.info(self._model_summary_string)
-
- @devices_dsp.dedent
- def train(
- self,
- max_epochs: int | None = None,
- lr: float = 1e-3,
- batch_size: int = 256,
- train_size: float = 0.9,
- accelerator: str = "auto",
- devices: int | list[int] | str = "auto",
- shuffle_set_split: bool = True,
- datasplitter_kwargs: dict | None = None,
- plan_kwargs: dict | None = None,
- **kwargs,
- ):
- """Train the DIAGVI model.
-
- Parameters
- ----------
- max_epochs
- Maximum number of training epochs. If None, a heuristic is used.
- lr
- Learning rate for optimization.
- batch_size
- Minibatch size for training.
- %(param_accelerator)s
- %(param_devices)s
- train_size
- Proportion of data to use for training (rest for validation).
- shuffle_set_split
- Whether to shuffle data before splitting into train/validation.
- datasplitter_kwargs
- Additional keyword arguments for the DataSplitter and DataLoaders.
- Can be either:
- - A dict of shared kwargs applied to all modalities
- - A nested dict mapping modality names to their specific kwargs,
- e.g., ``{"rna": {"external_indexing": [train, val, test]}, "protein": {...}}``
- plan_kwargs
- Additional keyword arguments for the training plan.
- **kwargs
- Additional keyword arguments for the Trainer.
- """
- # Compute max_epochs if not provided
- if max_epochs is None:
- min_obs = np.min(
- [
- self.summary_stats[self.input_names[0]]["n_cells"],
- self.summary_stats[self.input_names[1]]["n_cells"],
- ]
- )
- max_epochs = get_max_epochs_heuristic(min_obs)
- logger.info(f"max_epochs was approximated to {max_epochs}")
-
- accelerator, devices, device = parse_device_args(
- accelerator=accelerator,
- devices=devices,
- return_device="torch",
- )
-
- # Extract early stopping parameters from kwargs with defaults
- early_stopping = kwargs.pop("early_stopping", True)
- early_stopping_monitor = kwargs.pop("early_stopping_monitor", "validation_loss")
- early_stopping_min_delta = kwargs.pop("early_stopping_min_delta", 1)
- early_stopping_patience = kwargs.pop("early_stopping_patience", 10)
- early_stopping_mode = kwargs.pop("early_stopping_mode", "min")
-
- # Initialize Trainer
- self.trainer = Trainer(
- max_epochs=max_epochs,
- enable_progress_bar=True,
- progress_bar_refresh_rate=1,
- early_stopping=early_stopping,
- early_stopping_monitor=early_stopping_monitor,
- early_stopping_min_delta=early_stopping_min_delta,
- early_stopping_patience=early_stopping_patience,
- early_stopping_mode=early_stopping_mode,
- accelerator=accelerator,
- devices=devices,
- **kwargs,
- )
-
- # Set up DataLoaders with DataSplitter
- validation_size = 1 - train_size
- self.train_indices_, self.test_indices_, self.validation_indices_ = [], [], []
- train_dls, test_dls, val_dls = {}, {}, {}
- datasplitter_kwargs = datasplitter_kwargs if isinstance(datasplitter_kwargs, dict) else {}
-
- # Support modality-specific datasplitter_kwargs
- # If keys match modality names, treat as modality-specific config
- is_modality_specific = any(key in self.input_names for key in datasplitter_kwargs.keys())
-
- for name, adm in self.adata_managers.items():
- # Get modality-specific kwargs if provided, otherwise use shared kwargs
- if is_modality_specific:
- modality_kwargs = datasplitter_kwargs.get(name, {})
- else:
- modality_kwargs = datasplitter_kwargs
-
- ds = self._data_splitter_cls(
- adm,
- train_size=train_size,
- validation_size=validation_size,
- batch_size=batch_size,
- shuffle_set_split=shuffle_set_split,
- distributed_sampler=use_distributed_sampler(kwargs.get("strategy", None)),
- **modality_kwargs,
- )
- ds.setup()
- train_dls[name] = ds.train_dataloader()
- test_dls[name] = ds.test_dataloader()
- val_dls[name] = ds.val_dataloader()
- self.train_indices_.append(ds.train_idx)
- self.test_indices_.append(ds.test_idx)
- self.validation_indices_.append(ds.val_idx)
-
- data_loader_kwargs = {
- "num_workers": settings.dl_num_workers,
- "persistent_workers": getattr(settings, "dl_persistent_workers", False),
- "pin_memory": getattr(settings, "dl_pin_memory_gpu_training", False),
- }
- train_dl = TrainDL(train_dls, **data_loader_kwargs)
- val_dl = TrainDL(val_dls, **data_loader_kwargs)
-
- # Initialize and run training plan
- plan_kwargs = plan_kwargs if isinstance(plan_kwargs, dict) else {}
- update_dict = {"lr": lr}
- plan_kwargs.update(update_dict)
- self._training_plan = self._training_plan_cls(
- self.module,
- **plan_kwargs,
- )
- if train_size == 1.0:
- self.trainer.fit(self._training_plan, train_dl)
- else:
- self.trainer.fit(
- self._training_plan, train_dataloaders=train_dl, val_dataloaders=val_dl
- )
-
- try:
- self.history_ = self.trainer.logger.history
- except AttributeError:
- self.history_ = None
-
- self.module.eval()
- self.to_device(device)
- self.is_trained_ = True
-
- @classmethod
- @setup_anndata_dsp.dedent
- def setup_anndata(
- cls,
- adata: AnnData,
- layer: str | None = None,
- batch_key: str | None = None,
- labels_key: str | None = None,
- likelihood: Literal[
- "nb", "zinb", "nbmixture", "normal", "log1pnormal", "ziln", "zig"
- ] = "nb",
- normalize_lib: bool = True,
- gmm_prior: bool = False,
- n_mixture_components: int = 10,
- unlabeled_category: str = "unknown",
- **kwargs,
- ):
- """%(summary)s.
-
- Parameters
- ----------
- adata
- AnnData object to register.
- %(param_batch_key)s
- %(param_labels_key)s
- %(param_layer)s
- likelihood
- Likelihood model for this modality (default: 'nb').
- One of:
- - 'nb' : Negative Binomial
- - 'zinb' : Zero-Inflated Negative Binomial
- - 'nbmixture' : Negative Binomial Mixture (for protein data with background/foreground)
- - 'normal' : Normal distribution
- - 'log1pnormal' : Log1p Normal distribution
- - 'ziln' : Zero-Inflated Log Normal distribution
- - 'zig' : Zero-Inflated Gamma distribution
- normalize_lib
- Whether to normalize counts with library size in the model.
- gmm_prior
- Whether to use a GMM prior for this modality.
- n_mixture_components
- Number of mixture components for the GMM prior. If labels_key is provided,
- this parameter is ignored and set to the number of unique labels in labels_key.
- unlabeled_category
- Category for unlabeled cells in labels_key.
- **kwargs
- Additional keyword arguments.
- """
- # Checks for valid likelihood and data compatibility
- continuous_likelihoods = {"ziln", "zig", "log1pnormal", "normal"}
- if likelihood in continuous_likelihoods:
- # Check for negative values (invalid for most continuous likelihoods)
- data = adata.X if layer is None else adata.layers[layer]
- if scipy.sparse.issparse(data):
- data_min = data.min()
- else:
- data_min = np.min(data)
-
- if data_min < 0 and likelihood not in {"normal"}:
- raise ValueError(
- f"Likelihood '{likelihood}' requires non-negative data, "
- f"but found minimum value {data_min}. Consider using 'normal' instead."
- )
-
- if scipy.sparse.issparse(adata.X) and not isinstance(adata.X, scipy.sparse.csr_matrix):
- adata.X = adata.X.tocsr()
- if layer is not None:
- if layer not in adata.layers:
- raise ValueError(f"Layer '{layer}' not found in adata.layers.")
- layer_data = adata.layers[layer]
- if scipy.sparse.issparse(layer_data) and not isinstance(
- layer_data, scipy.sparse.csr_matrix
- ):
- adata.layers[layer] = layer_data.tocsr()
-
- adata.uns["diagvi_likelihood"] = likelihood
- adata.uns["diagvi_normalize_lib"] = normalize_lib
- adata.uns["diagvi_gmm_prior"] = gmm_prior
-
- # If labels_key is provided, set semi_supervised to True
- # and n_mixture_components to number of unique labels
- if labels_key is not None:
- semi_supervised = True
- actual_n_components = adata.obs[labels_key].nunique()
- if n_mixture_components != 10 and n_mixture_components != actual_n_components:
- warnings.warn(
- f"n_mixture_components={n_mixture_components} is ignored; "
- f"using {actual_n_components} labels from labels_key='{labels_key}' "
- f"as n_mixture_components instead.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- n_mixture_components = actual_n_components
- else:
- semi_supervised = False
- adata.uns["diagvi_semi_supervised"] = semi_supervised
- adata.uns["diagvi_n_mixture_components"] = n_mixture_components
-
- setup_method_args = cls._get_setup_method_args(**locals())
- if labels_key is None:
- label_field = CategoricalObsField(REGISTRY_KEYS.LABELS_KEY, labels_key)
- else:
- label_field = LabelsWithUnlabeledObsField(
- REGISTRY_KEYS.LABELS_KEY, labels_key, unlabeled_category
- )
-
- is_count_data = likelihood in {"nb", "zinb", "nbmixture"}
- anndata_fields = [
- LayerField(REGISTRY_KEYS.X_KEY, layer, is_count_data=is_count_data),
- CategoricalObsField(REGISTRY_KEYS.BATCH_KEY, batch_key),
- label_field,
- ]
- adata_manager = AnnDataManager(fields=anndata_fields, setup_method_args=setup_method_args)
- adata_manager.register_fields(adata, **kwargs)
- cls.register_manager(adata_manager)
-
- @classmethod
- def setup_mudata(
- cls,
- mdata: MuData,
- modalities: list[str],
- batch_key: dict[str, str] | str | None = None,
- labels_key: dict[str, str] | str | None = None,
- layer: dict[str, str | None] | str | None = None,
- likelihood: dict[str, Literal["nb", "zinb", "nbmixture", "normal"]] | str = "nb",
- normalize_lib: dict[str, bool] | bool = True,
- gmm_prior: dict[str, bool] | bool = False,
- n_mixture_components: dict[str, int] | int = 10,
- unlabeled_category: dict[str, str] | str = "unknown",
- **kwargs,
- ):
- """Register a MuData object for use with DIAGVI.
-
- Parameters
- ----------
- mdata
- MuData object containing multiple modalities.
- modalities
- List of two modality names from mdata.mod to use.
- batch_key
- Key(s) in adata.obs for batch annotation. Can be a single string
- (applied to all modalities) or a dict mapping modality names to keys.
- labels_key
- Key(s) in adata.obs for cell type labels.
- layer
- Layer(s) in adata to use as input.
- likelihood
- Likelihood model(s) for each modality. Options: 'nb', 'zinb',
- 'nbmixture', 'normal'. Default is 'nb'.
- normalize_lib
- Whether to normalize counts with library size in the model for each modality.
- gmm_prior
- Whether to use GMM prior for each modality. Default is False.
- n_mixture_components
- Number of mixture components for the GMM prior for each modality. If labels_key
- is provided, this parameter is ignored and set to the number of unique labels
- in labels_key.
- unlabeled_category
- Category name for unlabeled cells. Default is 'unknown'.
- **kwargs
- Additional keyword arguments passed to setup_anndata.
- """
- for mod_key in modalities:
- adata = mdata.mod[mod_key]
-
- batch_key_mod = batch_key[mod_key] if isinstance(batch_key, dict) else batch_key
- labels_key_mod = labels_key[mod_key] if isinstance(labels_key, dict) else labels_key
- layer_mod = layer[mod_key] if isinstance(layer, dict) else layer
- likelihood_mod = likelihood[mod_key] if isinstance(likelihood, dict) else likelihood
- normalize_lib_mod = (
- normalize_lib[mod_key] if isinstance(normalize_lib, dict) else normalize_lib
- )
- gmm_prior_mod = gmm_prior[mod_key] if isinstance(gmm_prior, dict) else gmm_prior
- n_mixture_components_mod = (
- n_mixture_components[mod_key]
- if isinstance(n_mixture_components, dict)
- else n_mixture_components
- )
- unlabeled_category_mod = (
- unlabeled_category[mod_key]
- if isinstance(unlabeled_category, dict)
- else unlabeled_category
- )
-
- cls.setup_anndata(
- adata=adata,
- batch_key=batch_key_mod,
- labels_key=labels_key_mod,
- layer=layer_mod,
- likelihood=likelihood_mod,
- normalize_lib=normalize_lib_mod,
- gmm_prior=gmm_prior_mod,
- n_mixture_components=n_mixture_components_mod,
- unlabeled_category=unlabeled_category_mod,
- **kwargs,
- )
-
- @staticmethod
- @dependencies("torch_geometric")
- def construct_custom_guidance_graph(
- input_dict: dict[str, AnnData],
- mapping_df: pd.DataFrame,
- weight: float = 1.0,
- sign: float = 1.0,
- ) -> Data:
- """Constructs a custom guidance graph defined by the mapping_df.
-
- Parameters
- ----------
- input_dict
- Dictionary mapping modality names
- (e.g., "scRNAseq", "seqFISH") to their respective AnnData objects.
- The keys in this dictionary must match the column names in `mapping_df`.
- mapping_df
- DataFrame specifying feature correspondences between modalities.
- Each column should correspond to a modality name in input_dict,
- and each row defines a feature pair.
- weight
- Edge weight assigned to cross-modality edges.
- sign
- Edge sign assigned to cross-modality edges.
-
- Returns
- -------
- A PyTorch Geometric Data object representing the guidance graph,
- including node features, edge indices, edge weights, edge signs,
- and modality-specific feature indices.
-
- Notes
- -----
- - Features are renamed to include the modality name as a suffix.
- - Self-loops are added for all features with weight=1.0 and sign=1.0.
- - Missing features in the mapping are silently skipped.
- """
- from torch_geometric.data import Data
-
- modality_names = list(input_dict.keys())
- adata1, adata2 = list(input_dict.values())
-
- ad_1_ft = [f"{f}_{modality_names[0]}" for f in adata1.var_names]
- ad_2_ft = [f"{f}_{modality_names[1]}" for f in adata2.var_names]
-
- all_features = ad_1_ft + ad_2_ft
- feature_to_index = {f: i for i, f in enumerate(all_features)}
-
- edge_index = []
- edge_weight = []
- edge_sign = []
-
- adata_adt_vars = []
- adata_gex_vars = []
-
- for ft_pair in range(mapping_df.shape[0]):
- pair = mapping_df.iloc[ft_pair, :]
- diss_ft = f"{pair[modality_names[0]]}_{modality_names[0]}"
- sp_ft = f"{pair[modality_names[1]]}_{modality_names[1]}"
-
- if diss_ft not in feature_to_index or sp_ft not in feature_to_index:
- continue
-
- adata_adt_vars.append(sp_ft)
- adata_gex_vars.append(diss_ft)
-
- i = feature_to_index[diss_ft]
- j = feature_to_index[sp_ft]
-
- edge_index += [[i, j], [j, i]]
- edge_weight += [weight, weight]
- edge_sign += [sign, sign]
-
- for feature in all_features:
- i = feature_to_index[feature]
- edge_index.append([i, i])
- edge_weight.append(1.0)
- edge_sign.append(1.0)
-
- edge_index = torch.tensor(edge_index, dtype=torch.long).t().contiguous()
- edge_weight = torch.tensor(edge_weight, dtype=torch.float)
- edge_sign = torch.tensor(edge_sign, dtype=torch.float)
-
- x = torch.eye(len(all_features))
-
- indices_1 = torch.tensor([feature_to_index[f] for f in ad_1_ft], dtype=torch.long)
- indices_2 = torch.tensor([feature_to_index[f] for f in ad_2_ft], dtype=torch.long)
-
- guidance_graph = Data(
- x=x,
- edge_index=edge_index,
- edge_weight=edge_weight,
- edge_sign=edge_sign,
- **{
- f"{modality_names[0]}_indices": indices_1,
- f"{modality_names[1]}_indices": indices_2,
- },
- )
-
- return guidance_graph
-
- @torch.inference_mode()
- def get_latent_representation(
- self,
- adatas: dict[str, AnnData] | list[AnnData] | None = None,
- indices: dict[str, Sequence[int]] | None = None,
- batch_size: int = 1024,
- ) -> dict[str, np.ndarray]:
- """Return the latent space embedding for each dataset.
-
- Parameters
- ----------
- adatas
- Modalities to compute embeddings for. If None, uses all modalities.
- indices
- Dictionary mapping modality names to indices of cells to use.
- If ``None``, all cells are used for each modality.
- batch_size
- Minibatch size for data loading.
-
- Returns
- -------
- Dictionary mapping modality names to latent embeddings.
- """
- if adatas is None:
- adatas = self.adatas
- if isinstance(adatas, dict):
- input_names = list(adatas.keys())
- adata_list = list(adatas.values())
- else:
- input_names = self.input_names
- adata_list = adatas
-
- if indices is None:
- indices = dict.fromkeys(input_names)
-
- self.module.eval()
-
- latents = {}
- for input_name, adata in zip(input_names, adata_list, strict=False):
- mode_indices = indices.get(input_name)
- scdl = self._make_data_loader(
- adata,
- indices=mode_indices,
- batch_size=batch_size,
- )
- latent = []
- for tensors in scdl:
- latent.append(
- self.module.inference(
- **self.module._get_inference_input(tensors), mode=input_name
- )["z"]
- .cpu()
- .detach()
- )
- latent = torch.cat(latent).numpy()
- latents[input_name] = latent
-
- return latents
-
- @torch.inference_mode()
- def posterior_predictive_sample(
- self,
- adatas: dict[str, AnnData] | None = None,
- indices: dict[str, Sequence[int]] | None = None,
- n_samples: int = 1,
- batch_size: int | None = None,
- ) -> dict[str, np.ndarray]:
- """Generate posterior predictive samples for each modality.
-
- This method generates samples from the posterior predictive distribution
- p(x̂|x) for each modality, which can be used for model criticism via
- :class:`~scvi.criticism.PosteriorPredictiveCheck`.
-
- Parameters
- ----------
- adatas
- Dictionary mapping modality names to AnnData objects. If ``None``,
- defaults to the AnnData objects used to initialize the model.
- indices
- Dictionary mapping modality names to indices of cells to use.
- If ``None``, all cells are used for each modality.
- n_samples
- Number of posterior predictive samples to generate per cell.
- batch_size
- Minibatch size for data loading. Defaults to ``scvi.settings.batch_size``.
-
- Returns
- -------
- Dictionary mapping modality names to arrays of shape
- ``(n_cells, n_features, n_samples)`` if ``n_samples > 1``, else
- ``(n_cells, n_features)``.
- """
- if adatas is None:
- adatas = self.adatas
-
- if indices is None:
- indices = dict.fromkeys(adatas.keys())
-
- # Validate that all provided modalities are valid
- if not set(adatas.keys()).issubset(set(self.input_names)):
- invalid_keys = set(adatas.keys()) - set(self.input_names)
- raise ValueError(
- f"Invalid modality names: {invalid_keys}. "
- f"Must be subset of model input names: {set(self.input_names)}"
- )
-
- self.module.eval()
-
- # Create data loaders for each modality
- data_loaders = {}
- for name, adata in adatas.items():
- mode_indices = indices.get(name)
- dl = self._make_data_loader(
- adata,
- indices=mode_indices,
- batch_size=batch_size or settings.batch_size,
- )
- data_loaders[name] = dl
-
- # Collect samples for each modality
- samples_per_modality = {name: [] for name in adatas.keys()}
-
- # Process each modality separately to handle different numbers of cells
- for mode in adatas.keys():
- mode_samples = []
- for tensors in data_loaders[mode]:
- # Create a dict with only this modality's tensors
- mode_tensors_dict = {mode: tensors}
-
- # Run module.sample which handles inference + generative + sampling
- mode_result = self.module.sample(mode_tensors_dict, n_samples=n_samples)
-
- mode_sample = mode_result[mode]
-
- if n_samples > 1:
- # mode_sample shape: (n_samples, n_cells, n_features)
- # Transpose to (n_cells, n_features, n_samples)
- mode_sample = mode_sample.permute(1, 2, 0)
-
- mode_samples.append(mode_sample.numpy())
-
- # Concatenate along cell dimension (axis 0)
- samples_per_modality[mode] = np.concatenate(mode_samples, axis=0)
-
- return samples_per_modality
-
- @torch.inference_mode()
- def get_imputed_values(
- self,
- query_name: str,
- query_adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- deterministic: bool = True,
- batch_size: int = 2048,
- reference_batch: int | str | np.ndarray | None = None,
- reference_libsize: float | np.ndarray | None = None,
- ) -> np.ndarray:
- """Return imputed values for a given query modality.
-
- Parameters
- ----------
- query_name
- Name of the query modality.
- query_adata
- AnnData object for the query modality. If None, uses the registered AnnData.
- indices
- Indices of cells to use. If ``None``, all cells are used.
- deterministic
- Whether to use deterministic inference (posterior mean).
- batch_size
- Minibatch size for data loading.
- reference_batch
- Reference batch index or array for imputation. Can be a scalar, a
- length-1 array, or an array with length equal to the number of
- queried cells.
- reference_libsize
- Reference library size(s) for imputation. Can be a scalar, a
- length-1 array, or an array with length equal to the number of
- queried cells.
-
- Returns
- -------
- Imputed values.
- """
- # Choose query adata according to mode
- if query_name not in self.adatas:
- raise ValueError(f"`query_name` must be one of {list(self.adatas.keys())}!")
- if query_adata is None:
- query_adata = self.adatas[query_name]
-
- # Get batch categories for this modality
- batch_manager = self.adata_managers[query_name]
- batch_categories = batch_manager.get_state_registry(REGISTRY_KEYS.BATCH_KEY).get(
- "categorical_mapping", None
- )
-
- self.module.eval()
- dl = self._make_data_loader(query_adata, indices=indices, batch_size=batch_size)
-
- n_query_cells = query_adata.n_obs if indices is None else len(np.asarray(indices))
- full_reference_batch = None
- if reference_batch is not None:
- full_reference_batch = np.asarray(reference_batch).squeeze()
- if full_reference_batch.ndim == 0:
- full_reference_batch = np.repeat(full_reference_batch[np.newaxis], n_query_cells)
- elif full_reference_batch.ndim > 1:
- raise ValueError("`reference_batch` cannot be >1 dimensional")
- elif full_reference_batch.size == 1:
- full_reference_batch = np.repeat(full_reference_batch, n_query_cells)
- elif full_reference_batch.size != n_query_cells:
- raise ValueError(
- "`reference_batch` must be scalar, length 1, or have length equal "
- "to the number of queried cells."
- )
-
- if not np.issubdtype(full_reference_batch.dtype, np.integer):
- if batch_categories is None:
- raise ValueError(
- "`reference_batch` contains non-integer values, but no batch "
- "category mapping is available for this modality."
- )
- full_reference_batch = np.array(
- [np.where(batch_categories == lbl)[0][0] for lbl in full_reference_batch]
- )
-
- full_reference_libsize = None
- if reference_libsize is not None:
- full_reference_libsize = np.asarray(reference_libsize).squeeze()
- if full_reference_libsize.ndim == 0:
- full_reference_libsize = np.repeat(
- full_reference_libsize[np.newaxis], n_query_cells
- )
- elif full_reference_libsize.ndim > 1:
- raise ValueError("`reference_libsize` cannot be >1 dimensional")
- elif full_reference_libsize.size == 1:
- full_reference_libsize = np.repeat(full_reference_libsize, n_query_cells)
- elif full_reference_libsize.size != n_query_cells:
- raise ValueError(
- "`reference_libsize` must be scalar, length 1, or have length equal "
- "to the number of queried cells."
- )
-
- batch_offset = 0
- lib_offset = 0
- reconstructed_values = []
- for tensor in dl:
- inference_output = self.module.inference(
- **self.module._get_inference_input(tensor),
- mode=query_name,
- deterministic=deterministic,
- )
- # Use the feature embedding of the other modality for reconstruction
- inference_output["v"] = inference_output["v_other"]
-
- generative_input = self.module._get_generative_input(
- tensor,
- inference_output,
- )
-
- # Handle reference batch
- batch_size_ = generative_input[MODULE_KEYS.LIBRARY_KEY].shape[0]
- if full_reference_batch is not None:
- b = full_reference_batch[batch_offset : batch_offset + batch_size_]
- if b.size != batch_size_:
- raise RuntimeError(
- "Internal error while slicing `reference_batch` by minibatch."
- )
- batch_offset += batch_size_
- # Use batch index zero if no reference batch is provided
- else:
- b = np.zeros(batch_size_, dtype=int)
- b = np.asarray(b, dtype=int).reshape(-1, 1)
- generative_input[MODULE_KEYS.BATCH_INDEX_KEY] = torch.tensor(
- b,
- dtype=torch.long,
- device=generative_input[MODULE_KEYS.LIBRARY_KEY].device,
- )
-
- # Handle reference libsize
- if full_reference_libsize is not None:
- l = full_reference_libsize[lib_offset : lib_offset + batch_size_]
- if l.size != batch_size_:
- raise RuntimeError(
- "Internal error while slicing `reference_libsize` by minibatch."
- )
- lib_offset += batch_size_
- l = l.reshape((-1, 1))
- generative_input[MODULE_KEYS.LIBRARY_KEY] = torch.tensor(
- l,
- dtype=generative_input[MODULE_KEYS.LIBRARY_KEY].dtype,
- device=generative_input[MODULE_KEYS.LIBRARY_KEY].device,
- )
-
- # Determine reference modality
- reference_names = [m for m in self.input_names if m != query_name]
- if len(reference_names) != 1:
- raise ValueError("There must be exactly two modalities defined.")
- reference_name = reference_names[0]
-
- generative_output = self.module.generative(**generative_input, mode=reference_name)
- # Use distribution mean for correct expected value across all likelihoods
- px_dist = generative_output[MODULE_KEYS.PX_KEY]
- reconstructed_values.append(px_dist.mean.cpu().detach())
-
- reconstructed_value = torch.cat(reconstructed_values).numpy()
- return reconstructed_value
-
- def save(
- self,
- dir_path: str,
- prefix: str | None = None,
- overwrite: bool = False,
- save_anndata: bool = False,
- save_kwargs: dict | None = None,
- ):
- """Save the DIAGVI model and optionally the AnnData objects.
-
- Parameters
- ----------
- dir_path
- Directory path to save the model and AnnData objects.
- prefix
- Prefix for the saved model file name.
- overwrite
- Whether to overwrite the directory if it already exists.
- save_anndata
- Whether to save the AnnData objects used to train the model.
- save_kwargs
- Additional keyword arguments for torch.save.
-
- Raises
- ------
- ValueError
- If dir_path exists and overwrite is False.
- """
- if not os.path.exists(dir_path) or overwrite:
- os.makedirs(dir_path, exist_ok=overwrite)
- else:
- raise ValueError(
- f"{dir_path} already exists. Please provide an unexisting directory for saving."
- )
-
- file_name_prefix = prefix or ""
- save_kwargs = save_kwargs or {}
-
- adatas = self.adatas
- if save_anndata:
- for key in self.input_names:
- ad = adatas[key]
- save_path = os.path.join(dir_path, f"{file_name_prefix}adata_{key}.h5ad")
- ad.write(save_path)
-
- model_state_dict = self.module.state_dict()
-
- var_names = {}
- for key in self.input_names:
- var_names[key] = adatas[key].var_names
-
- # Get all the user attributes
- user_attributes = self._get_user_attributes()
-
- # Only save the public attributes with _ at the very end
- user_attributes = {a[0]: a[1] for a in user_attributes if a[0][-1] == "_"}
-
- model_save_path = os.path.join(dir_path, f"{file_name_prefix}model.pt")
-
- torch.save(
- {
- "model_state_dict": model_state_dict,
- "names": self.input_names,
- **{f"var_names_{mod}": var_names[mod] for mod in self.input_names},
- "attr_dict": user_attributes,
- },
- model_save_path,
- **save_kwargs,
- )
-
- @classmethod
- @devices_dsp.dedent
- def load(
- cls,
- dir_path: str,
- adatas: dict[str, AnnData] | None = None,
- accelerator: str = "auto",
- device: int | str = "auto",
- prefix: str | None = None,
- backup_url: str | None = None,
- ) -> DIAGVI:
- """Load a saved DIAGVI model from disk.
-
- Parameters
- ----------
- dir_path
- Directory path where the model was saved.
- adatas
- Dictionary mapping modality names to AnnData objects. Required when
- the model was saved with ``save_anndata=False``.
- %(param_accelerator)s
- %(param_device)s
- prefix
- Prefix used when saving the model.
- backup_url
- URL to download the model from if not found locally.
-
- Returns
- -------
- Loaded DIAGVI model instance.
-
- Raises
- ------
- ValueError
- If the saved model is from a different class or missing setup inputs.
- """
- _, _, device = parse_device_args(
- accelerator=accelerator,
- devices=device,
- return_device="torch",
- )
-
- (
- attr_dict,
- var_names_dict,
- model_state_dict,
- loaded_adatas,
- ) = _load_saved_diagvi_files(
- dir_path,
- prefix=prefix,
- map_location=device,
- backup_url=backup_url,
- )
-
- if adatas is not None:
- for name, adata in adatas.items():
- loaded_adatas[name] = adata
- var_names_dict[name] = adata.var_names
-
- missing = [name for name, adata in loaded_adatas.items() if adata is None]
- if missing:
- raise ValueError(
- f"AnnData objects for modalities {missing} were not saved with the model. "
- f"Pass them via the `adatas` parameter: "
- f"DIAGVI.load(dir_path, adatas={{...}})."
- )
-
- adatas = loaded_adatas
-
- for mod in adatas.keys():
- saved_var_names = var_names_dict[mod]
- user_var_names = adatas[mod].var_names.astype(str)
-
- if not np.array_equal(saved_var_names, user_var_names):
- warnings.warn(
- "var_names for adata passed in does not match var_names of adata "
- "used to train the model. For valid results, the vars need to be the"
- "same and in the same order as the adata used to train the model.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
-
- registries = attr_dict.pop("registries_")
- for adata, registry in zip(adatas, registries, strict=True):
- if _MODEL_NAME_KEY in registry and registry[_MODEL_NAME_KEY] != cls.__name__:
- raise ValueError("It appears you are loading a model from a different class.")
-
- if _SETUP_ARGS_KEY not in registry:
- raise ValueError(
- "Saved model does not contain original setup inputs. "
- "Cannot load the original setup."
- )
-
- cls.setup_anndata(adatas[adata], source_registry=registry, **registry[_SETUP_ARGS_KEY])
-
- # Get the parameters for the class init signature
- init_params = attr_dict.pop("init_params_")
-
- # New saving and loading, enable backwards compatibility
- if "non_kwargs" in init_params.keys():
- # Grab all the parameters except for kwargs (is a dict)
- non_kwargs = init_params["non_kwargs"]
- kwargs = init_params["kwargs"]
- kwargs = {k: v for (i, j) in kwargs.items() for (k, v) in j.items()}
- else:
- # Grab all the parameters except for kwargs (is a dict)
- non_kwargs = {k: v for k, v in init_params.items() if not isinstance(v, dict)}
- kwargs = {k: v for k, v in init_params.items() if isinstance(v, dict)}
- kwargs = {k: v for (i, j) in kwargs.items() for (k, v) in j.items()}
-
- non_kwargs.pop("adatas", None)
- kwargs.pop("adatas", None)
-
- model = cls(adatas, **non_kwargs, **kwargs)
-
- for attr, val in attr_dict.items():
- setattr(model, attr, val)
-
- model.module.load_state_dict(model_state_dict)
- model.module.eval()
- model.to_device(device)
- return model
-
- @torch.inference_mode()
- def predict_celltype(
- self,
- labeled_modality: str,
- target_modality: str | None = None,
- target_adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- batch_size: int = 2048,
- deterministic: bool = True,
- ) -> dict[str, np.ndarray]:
- """Predict cell type labels using a trained classifier.
-
- Parameters
- ----------
- labeled_modality
- Name of the modality for which the classifier was trained. A classifier
- must have been trained for this modality (i.e., labels_key must have been
- provided during setup_anndata for this modality).
- target_modality
- Name of the modality to predict cell types for. If None, defaults to the
- other modality (not labeled_modality) for cross-modal label transfer.
- target_adata
- AnnData object for the target modality. If None, uses the registered AnnData.
- indices
- Indices of cells to predict. If ``None``, all cells are used.
- batch_size
- Minibatch size for data loading.
-
- Returns
- -------
- Dictionary with keys:
- - "predictions": Array of predicted label categories (strings)
- - "probabilities": Array of class probabilities (n_cells, n_classes)
- - "confidence": Array of maximum probability for each cell
-
- Raises
- ------
- ValueError
- If labeled_modality is not a valid modality name.
- If target_modality is not a valid modality name.
- If no classifier was trained for the labeled modality.
- """
- # Validate labeled modality
- if labeled_modality not in self.input_names:
- raise ValueError(f"labeled_modality must be one of {self.input_names}!")
-
- # Default to the other modality for cross-modal transfer
- if target_modality is None:
- target_modality = (
- self.input_names[1]
- if labeled_modality == self.input_names[0]
- else self.input_names[0]
- )
-
- # Validate target modality
- if target_modality not in self.input_names:
- raise ValueError(
- f"Invalid target_modality: '{target_modality}'. Must be one of {self.input_names}."
- )
-
- # Get the classifier for the labeled modality
- if labeled_modality == self.input_names[0]:
- classifier = self.module.classifier_0
- else:
- classifier = self.module.classifier_1
-
- # Check if classifier exists
- if classifier is None:
- raise ValueError(
- f"No classifier was trained for modality '{labeled_modality}'. "
- f"To use this function, you must provide a labels_key when calling "
- f"setup_anndata for the labeled modality."
- )
-
- # Get target adata
- if target_adata is None:
- target_adata = self.adatas[target_modality]
-
- # Get label categories from the labeled modality's adata manager
- label_registry = self.adata_managers[labeled_modality].get_state_registry(
- REGISTRY_KEYS.LABELS_KEY
- )
- label_categories = label_registry.get("categorical_mapping", None)
-
- # Get latent representations for target modality
- self.module.eval()
-
- dl = self._make_data_loader(
- target_adata,
- indices=indices,
- batch_size=batch_size,
- )
-
- all_logits = []
- for tensors in dl:
- z = self.module.inference(
- **self.module._get_inference_input(tensors),
- mode=target_modality,
- deterministic=deterministic,
- )["z"]
- logits = classifier(z)
- all_logits.append(logits.cpu())
-
- # Concatenate all logits and compute predictions
- all_logits = torch.cat(all_logits, dim=0)
- probabilities = torch.nn.functional.softmax(all_logits, dim=-1).numpy()
- predicted_indices = torch.argmax(all_logits, dim=1).numpy()
- confidence = probabilities.max(axis=1)
- predictions = label_categories[predicted_indices]
-
- return {
- "predictions": predictions,
- "probabilities": probabilities,
- "confidence": confidence,
- }
diff --git a/src/scvi/external/diagvi/_module.py b/src/scvi/external/diagvi/_module.py
deleted file mode 100644
index 3c58543980..0000000000
--- a/src/scvi/external/diagvi/_module.py
+++ /dev/null
@@ -1,597 +0,0 @@
-"""DIAGVAE module for multi-modal variational autoencoder."""
-
-from __future__ import annotations
-
-import logging
-from typing import TYPE_CHECKING
-
-import torch
-import torch.nn as nn
-import torch.nn.functional as F
-from torch.distributions import Categorical, Independent, MixtureSameFamily, kl_divergence
-
-from scvi import REGISTRY_KEYS
-from scvi.distributions import (
- Log1pNormal,
- NegativeBinomial,
- NegativeBinomialMixture,
- Normal,
- ZeroInflatedGamma,
- ZeroInflatedLogNormal,
- ZeroInflatedNegativeBinomial,
-)
-from scvi.module import Classifier
-from scvi.module._constants import MODULE_KEYS
-from scvi.module.base import BaseModuleClass, LossOutput, auto_move_data
-from scvi.nn import Encoder
-
-from ._base_components import DecoderDualPathway, DecoderSinglePathway, GraphEncoder
-
-if TYPE_CHECKING:
- from torch_geometric.data import Data
-
-logger = logging.getLogger(__name__)
-
-# Decoder selection based on likelihood function
-# Single-pathway: outputs (scale, r, rate, dropout)
-# Dual-pathway: outputs ((scale1, scale2), r, (rate1, rate2), mixture_logits)
-LIKELIHOOD_TO_DECODER = {
- # Single-pathway likelihoods (unimodal distributions)
- "nb": DecoderSinglePathway,
- "zinb": DecoderSinglePathway,
- "normal": DecoderSinglePathway,
- "log1pnormal": DecoderSinglePathway,
- "ziln": DecoderSinglePathway,
- "zig": DecoderSinglePathway,
- # Dual-pathway likelihoods (mixture distributions)
- "nbmixture": DecoderDualPathway,
-}
-
-# Likelihoods that require softmax normalization (count data)
-NORMALIZE_LIKELIHOODS = {"nb", "zinb", "nbmixture"}
-NON_NORMALIZE_LIKELIHOODS = {"log1pnormal", "ziln", "zig"}
-OTHER_LIKELIHOODS = {"normal"}
-
-
-class DIAGVAE(BaseModuleClass):
- """Variational autoencoder module for DIAGVI multi-modal integration.
-
- Supports GMM priors, semi-supervised classification, and flexible
- modality-specific decoders.
-
- Parameters
- ----------
- n_inputs
- Number of input features for each modality.
- n_batches
- Number of batches for each modality.
- n_labels
- Number of labels/classes for each modality.
- modality_likelihoods
- Likelihood model for each modality.
- One of: 'nb', 'zinb', 'normal', 'log1pnormal', 'zig', 'nbmixture'.
- normalize_lib
- Whether to normalize counts with library size in the model for each modality.
- guidance_graph
- Graph object encoding feature correspondences.
- use_gmm_prior
- Whether to use a GMM prior for each modality.
- semi_supervised
- Whether to use semi-supervised classification for each modality.
- n_mixture_components
- Number of mixture components for the GMM prior for each modality. If semi_supervised
- is True, this parameter is ignored and set to the number of unique labels in labels_key.
- n_latent
- Dimensionality of the latent space.
- n_hidden
- Number of nodes per hidden layer.
- n_layers
- Number of hidden layers.
- dropout_rate
- Dropout rate for encoders.
- """
-
- def __init__(
- self,
- n_inputs: dict[str, int],
- n_batches: dict[str, int],
- n_labels: dict[str, int],
- modality_likelihoods: dict[str, str],
- normalize_lib: dict[str, bool],
- guidance_graph: Data,
- use_gmm_prior: dict[str, bool],
- semi_supervised: dict[str, bool],
- n_mixture_components: dict[str, int],
- n_latent: int,
- n_hidden: int,
- n_layers: int,
- dropout_rate: float,
- ):
- super().__init__()
-
- # Learnable parameters
- self.gmm_logits = nn.ParameterDict()
- self.gmm_means = nn.ParameterDict()
- self.gmm_scales = nn.ParameterDict()
-
- # Model attributes
- self.use_gmm_prior = use_gmm_prior
- self.semi_supervised = semi_supervised
- self.n_mixture_components = n_mixture_components
- self.n_labels = n_labels
- self.n_input_list = n_inputs
- self.n_batches_list = n_batches
- self.modality_likelihoods = modality_likelihoods
- self.normalize_lib = normalize_lib
- self.guidance_graph = guidance_graph
- self.n_latent = n_latent
-
- self.input_names = list(n_inputs.keys())
-
- # GMM prior parameters
- for name in use_gmm_prior.keys():
- if self.use_gmm_prior[name]:
- k = self.n_mixture_components[name]
- # Overwrite the number of mixture components if semi_supervised = True
- if self.semi_supervised[name]:
- k = self.n_labels[name]
- self.gmm_logits[name] = nn.Parameter(torch.zeros(k))
- self.gmm_means[name] = nn.Parameter(torch.randn(k, n_latent))
- self.gmm_scales[name] = nn.Parameter(torch.zeros(k, n_latent))
-
- # Encoders
- self.encoder_0 = Encoder(
- n_input=n_inputs[self.input_names[0]],
- n_output=n_latent,
- n_hidden=n_hidden,
- n_layers=n_layers,
- dropout_rate=dropout_rate,
- return_dist=True,
- )
-
- self.encoder_1 = Encoder(
- n_input=n_inputs[self.input_names[1]],
- n_output=n_latent,
- n_hidden=n_hidden,
- n_layers=n_layers,
- dropout_rate=dropout_rate,
- return_dist=True,
- )
-
- # Decoders - selected based on likelihood function
- likelihood_0 = modality_likelihoods[self.input_names[0]]
- likelihood_1 = modality_likelihoods[self.input_names[1]]
-
- # Determine decoder class based on likelihood
- decoder_class_0 = LIKELIHOOD_TO_DECODER.get(likelihood_0, DecoderSinglePathway)
- decoder_class_1 = LIKELIHOOD_TO_DECODER.get(likelihood_1, DecoderSinglePathway)
-
- # Determine whether to apply softmax normalization based on likelihood
- normalize_0 = likelihood_0 in NORMALIZE_LIKELIHOODS
- normalize_1 = likelihood_1 in NORMALIZE_LIKELIHOODS
- if likelihood_0 in OTHER_LIKELIHOODS:
- normalize_0 = normalize_lib[self.input_names[0]]
- if likelihood_1 in OTHER_LIKELIHOODS:
- normalize_1 = normalize_lib[self.input_names[1]]
-
- logger.debug(
- f"Decoder for '{self.input_names[0]}' (likelihood={likelihood_0}): "
- f"{decoder_class_0.__name__}, normalize={normalize_0}"
- )
- self.decoder_0 = decoder_class_0(
- n_output=n_inputs[self.input_names[0]],
- n_batches=n_batches[self.input_names[0]],
- normalize=normalize_0,
- )
-
- logger.debug(
- f"Decoder for '{self.input_names[1]}' (likelihood={likelihood_1}): "
- f"{decoder_class_1.__name__}, normalize={normalize_1}"
- )
- self.decoder_1 = decoder_class_1(
- n_output=n_inputs[self.input_names[1]],
- n_batches=n_batches[self.input_names[1]],
- normalize=normalize_1,
- )
-
- # Graph encoder
- self.graph_encoder = GraphEncoder(
- vnum=n_inputs[self.input_names[0]] + n_inputs[self.input_names[1]],
- out_features=self.n_latent,
- )
-
- # Classifiers for semi-supervised learning
- if self.semi_supervised[self.input_names[0]]:
- cls_parameters = {
- "n_layers": 0,
- "n_hidden": 128,
- "dropout_rate": 0.0,
- }
- self.classifier_0 = Classifier(
- n_latent,
- n_labels=self.n_labels[self.input_names[0]],
- use_batch_norm=False,
- use_layer_norm=True,
- **cls_parameters,
- )
- else:
- self.classifier_0 = None
-
- if self.semi_supervised[self.input_names[1]]:
- cls_parameters = {
- "n_layers": 0,
- "n_hidden": 128,
- "dropout_rate": 0.0,
- }
- self.classifier_1 = Classifier(
- n_latent,
- n_labels=self.n_labels[self.input_names[1]],
- use_batch_norm=False,
- use_layer_norm=True,
- **cls_parameters,
- )
- else:
- self.classifier_1 = None
-
- def _get_inference_input(
- self, tensors: dict[str, torch.Tensor]
- ) -> dict[str, torch.Tensor | None]:
- """Prepare input dictionary for the inference step."""
- return {
- MODULE_KEYS.X_KEY: tensors[REGISTRY_KEYS.X_KEY],
- }
-
- def _get_generative_input(
- self, tensors: dict[str, torch.Tensor], inference_outputs: dict[str, torch.Tensor]
- ) -> dict[str, torch.Tensor]:
- """Prepare input dictionary for the generative step."""
- return {
- MODULE_KEYS.Z_KEY: inference_outputs[MODULE_KEYS.Z_KEY],
- MODULE_KEYS.LIBRARY_KEY: inference_outputs[MODULE_KEYS.LIBRARY_KEY],
- MODULE_KEYS.BATCH_INDEX_KEY: tensors[REGISTRY_KEYS.BATCH_KEY],
- MODULE_KEYS.Y_KEY: tensors[REGISTRY_KEYS.LABELS_KEY],
- "v": inference_outputs["v"],
- }
-
- def encode_graph(
- self, device: torch.device
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
- """Run the graph encoder, moving the guidance graph to device on first call.
-
- Parameters
- ----------
- device
- Target device.
-
- Returns
- -------
- Tuple of (v_all, mu_all, logvar_all) graph embeddings.
- """
- if self.guidance_graph.edge_index.device != device:
- self.guidance_graph = self.guidance_graph.to(device)
- return self.graph_encoder(self.guidance_graph.edge_index)
-
- @auto_move_data
- def inference(
- self,
- x: torch.Tensor,
- mode: str | None = None,
- v_all: torch.Tensor | None = None,
- mu_all: torch.Tensor | None = None,
- logvar_all: torch.Tensor | None = None,
- deterministic: bool = False,
- ) -> dict[str, torch.Tensor]:
- """Run the inference (encoder and graph) step for a given modality.
-
- Parameters
- ----------
- x
- Input data tensor.
- mode
- Name of the modality.
- v_all
- Pre-computed graph node embeddings. If None, computed from graph encoder.
- mu_all
- Pre-computed graph encoder means. Required when v_all is provided.
- logvar_all
- Pre-computed graph encoder log-variances. Required when v_all is provided.
- deterministic
- Whether to use the mean instead of a sample.
-
- Returns
- -------
- Dictionary of inference outputs, including latent variables and
- graph embeddings.
- """
- library = torch.log(x.sum(1)).unsqueeze(1)
-
- if v_all is None:
- v_all, mu_all, logvar_all = self.encode_graph(x.device)
-
- if deterministic:
- v_all = mu_all
-
- graph = self.guidance_graph
- v = v_all[getattr(graph, f"{mode}_indices")]
- other_mode = [m for m in self.input_names if m != mode][0]
- v_other_mod = v_all[getattr(graph, f"{other_mode}_indices")]
-
- # Data inference
- if mode == self.input_names[0]:
- qz, z = self.encoder_0(x)
- else:
- qz, z = self.encoder_1(x)
-
- if deterministic:
- z = qz.loc
-
- return {
- MODULE_KEYS.QZ_KEY: qz,
- MODULE_KEYS.Z_KEY: z,
- MODULE_KEYS.LIBRARY_KEY: library,
- "v": v,
- "v_other": v_other_mod,
- "v_all": v_all,
- "mu_all": mu_all,
- "logvar_all": logvar_all,
- }
-
- @auto_move_data
- def generative(
- self,
- z: torch.Tensor,
- library: torch.Tensor,
- batch_index: torch.Tensor | None = None,
- y: torch.Tensor | None = None,
- v: torch.Tensor | None = None,
- mode: str | None = None,
- ) -> dict[str, torch.Tensor]:
- """Run the generative (decoder) model for a given modality.
-
- Parameters
- ----------
- z
- Latent variable tensor.
- library
- Library size tensor.
- batch_index
- Batch index tensor.
- y
- Cell type labels (for semi-supervised GMM prior).
- v
- Feature embedding from the graph encoder.
- mode
- Name of the modality.
-
- Returns
- -------
- Dictionary containing generative model outputs and distributions.
- """
- EPS = 1e-8
-
- # Get decoder outputs (generative distribution parameters) based on modality
- if mode == self.input_names[0]:
- px_scale, px_r, px_rate, px_dropout = self.decoder_0(z, library, batch_index, v)
- elif mode == self.input_names[1]:
- px_scale, px_r, px_rate, px_dropout = self.decoder_1(z, library, batch_index, v)
-
- # Adjust parameters based on likelihood (count vs continuous)
- if self.modality_likelihoods[mode] in NORMALIZE_LIKELIHOODS:
- px_r = px_r.exp()
- elif self.modality_likelihoods[mode] in OTHER_LIKELIHOODS and self.normalize_lib[mode]:
- px_r = px_r.exp()
- else:
- px_r = F.softplus(px_r) + EPS
-
- # Construct the appropriate generative distribution p(x) based on likelihood
- if self.modality_likelihoods[mode] == "nb":
- px = NegativeBinomial(mu=px_rate, theta=px_r)
- elif self.modality_likelihoods[mode] == "zinb":
- px = ZeroInflatedNegativeBinomial(
- mu=px_rate,
- theta=px_r,
- zi_logits=px_dropout,
- scale=px_scale,
- )
- elif self.modality_likelihoods[mode] == "normal":
- px = Normal(px_rate, px_r, normal_mu=px_scale)
- elif self.modality_likelihoods[mode] == "nbmixture":
- px = NegativeBinomialMixture(
- mu1=px_rate[0],
- mu2=px_rate[1],
- theta1=px_r,
- mixture_logits=px_dropout,
- )
- elif self.modality_likelihoods[mode] == "log1pnormal":
- px = Log1pNormal(mu=px_rate, sigma=px_r, scale=px_scale)
- elif self.modality_likelihoods[mode] == "ziln":
- px = ZeroInflatedLogNormal(
- mu=px_rate,
- scale=px_r,
- zi_logits=px_dropout,
- normal_mu=px_scale,
- )
- elif self.modality_likelihoods[mode] == "zig":
- # Clamp concentration and rate to avoid lgamma numerical issues
- # with negative values
- px = ZeroInflatedGamma(
- concentration=torch.clamp(px_rate, min=EPS),
- rate=torch.clamp(px_r, min=EPS),
- zi_logits=px_dropout,
- scale=px_scale,
- )
- else:
- raise ValueError(
- f"Unknown likelihood '{self.modality_likelihoods[mode]}' for modality '{mode}'. "
- f"Supported likelihoods: {list(LIKELIHOOD_TO_DECODER.keys())}"
- )
-
- # Construct variational prior p(z) based on GMM settings
- if self.use_gmm_prior[mode]:
- logits = self.gmm_logits[mode]
- means = self.gmm_means[mode]
- scales = torch.exp(self.gmm_scales[mode]) + 1e-4
- if self.semi_supervised[mode]:
- # Vectorized one-hot encoding
- n_labels = self.n_labels[mode]
- y_flat = y.ravel().to(z.device)
-
- # Clamp invalid labels to 0 (will be zeroed out below)
- valid_mask = (y_flat >= 0) & (y_flat < n_labels)
- y_clamped = y_flat.clamp(0, n_labels - 1).long()
-
- # One-hot encode on device
- logits_input = F.one_hot(y_clamped, num_classes=n_labels).float()
-
- # Zero out rows for invalid labels (unlabeled cells)
- logits_input = logits_input * valid_mask.unsqueeze(-1).float()
- logits = logits + 100 * logits_input
- means = means.expand(y.shape[0], -1, -1)
- scales = scales.expand(y.shape[0], -1, -1)
- cats = Categorical(logits=logits)
- normal_dists = Independent(Normal(means, scales), reinterpreted_batch_ndims=1)
- pz = MixtureSameFamily(cats, normal_dists)
- else:
- pz = Normal(torch.zeros_like(z), torch.ones_like(z))
-
- return {
- MODULE_KEYS.PX_KEY: px,
- MODULE_KEYS.PZ_KEY: pz,
- "px_rate": px_rate,
- }
-
- def loss(
- self,
- tensors: dict[str, torch.Tensor],
- inference_outputs: dict[str, torch.Tensor],
- generative_outputs: dict[str, torch.Tensor],
- lam_kl: torch.Tensor | float,
- lam_data: torch.Tensor | float,
- mode: str | None = None,
- ) -> LossOutput:
- """Compute the loss for a batch.
-
- Parameters
- ----------
- tensors
- Input data tensors.
- inference_outputs
- Outputs from the inference step.
- generative_outputs
- Outputs from the generative step.
- lam_kl
- Weight for the KL divergence term.
- lam_data
- Weight for the reconstruction loss term.
- mode
- Name of the modality.
-
- Returns
- -------
- Object containing loss components and metrics.
- """
- x = tensors[REGISTRY_KEYS.X_KEY]
- n_obs = x.shape[0]
- n_var = x.shape[1]
-
- # Data nll calculation (reconstruction loss)
- reconst_loss = -generative_outputs[MODULE_KEYS.PX_KEY].log_prob(x).sum(-1)
- reconstruction_loss_norm = torch.mean(reconst_loss)
-
- # KL divergence calculation
- if self.use_gmm_prior[mode]:
- kl_div = inference_outputs[MODULE_KEYS.QZ_KEY].log_prob(inference_outputs["z"]).sum(
- -1
- ) - generative_outputs[MODULE_KEYS.PZ_KEY].log_prob(inference_outputs["z"])
- else:
- kl_div = kl_divergence(
- inference_outputs[MODULE_KEYS.QZ_KEY], generative_outputs[MODULE_KEYS.PZ_KEY]
- ).sum(dim=-1)
- kl_local_norm = torch.sum(kl_div) / (n_obs * n_var)
-
- # Total loss
- loss = lam_data * reconstruction_loss_norm + lam_kl * kl_local_norm
-
- # Graph inference
- mu_all = inference_outputs["mu_all"]
- logvar_all = inference_outputs["logvar_all"]
- v_all = inference_outputs["v_all"]
-
- # Semi-supervised classification loss (per modality if specified)
- classification_loss = 0.0
- if self.classifier_0 is not None and mode == self.input_names[0]:
- y = tensors[REGISTRY_KEYS.LABELS_KEY].ravel().long()
- z_mean = inference_outputs[MODULE_KEYS.QZ_KEY].loc
- y_logits = self.classifier_0(z_mean)
- classification_loss += torch.nn.functional.cross_entropy(y_logits, y, reduction="mean")
- if self.classifier_1 is not None and mode == self.input_names[1]:
- y = tensors[REGISTRY_KEYS.LABELS_KEY].ravel().long()
- z_mean = inference_outputs[MODULE_KEYS.QZ_KEY].loc
- y_logits = self.classifier_1(z_mean)
- classification_loss += torch.nn.functional.cross_entropy(y_logits, y, reduction="mean")
-
- return LossOutput(
- loss=loss,
- reconstruction_loss=reconst_loss,
- kl_local=kl_local_norm,
- extra_metrics={
- "z": inference_outputs[MODULE_KEYS.Z_KEY],
- "mu_all": mu_all,
- "logvar_all": logvar_all,
- "v_all": v_all,
- "guidance_graph": self.guidance_graph,
- "classification_loss": classification_loss,
- },
- )
-
- @torch.inference_mode()
- def sample(
- self,
- tensors: dict[str, dict[str, torch.Tensor]],
- n_samples: int = 1,
- ) -> dict[str, torch.Tensor]:
- """Sample from the generative model for each modality.
-
- Parameters
- ----------
- tensors
- Dictionary mapping modality names to their respective input tensors.
- Can contain one or both modalities.
- n_samples
- Number of samples to generate per cell.
-
- Returns
- -------
- Dictionary mapping modality names to sampled tensors of shape
- ``(n_samples, n_cells, n_features)`` if ``n_samples > 1``, else
- ``(n_cells, n_features)``.
- """
- samples = {}
-
- # Iterate only over modalities present in tensors (allows single-modality sampling)
- for mode in tensors.keys():
- mode_tensors = tensors[mode]
-
- # Run inference for this modality
- inference_outputs = self.inference(
- **self._get_inference_input(mode_tensors),
- mode=mode,
- )
-
- # Run generative model
- generative_input = self._get_generative_input(mode_tensors, inference_outputs)
- generative_outputs = self.generative(**generative_input, mode=mode)
-
- # Get the distribution and sample from it
- px = generative_outputs[MODULE_KEYS.PX_KEY]
-
- if n_samples > 1:
- # Sample multiple times and stack
- sample_list = [px.sample() for _ in range(n_samples)]
- mode_sample = torch.stack(sample_list, dim=0) # (n_samples, n_cells, n_features)
- else:
- mode_sample = px.sample() # (n_cells, n_features)
-
- samples[mode] = mode_sample.cpu()
-
- return samples
diff --git a/src/scvi/external/diagvi/_task.py b/src/scvi/external/diagvi/_task.py
deleted file mode 100644
index 5408a7471f..0000000000
--- a/src/scvi/external/diagvi/_task.py
+++ /dev/null
@@ -1,525 +0,0 @@
-"""Training plan for DIAGVI model."""
-
-from __future__ import annotations
-
-import logging
-from typing import TYPE_CHECKING
-
-import torch
-
-from scvi import REGISTRY_KEYS
-from scvi.external.diagvi._utils import (
- compute_graph_loss,
- kl_divergence_graph,
-)
-from scvi.train import TrainingPlan
-from scvi.utils import dependencies
-
-if TYPE_CHECKING:
- from typing import Literal
-
-logger = logging.getLogger(__name__)
-
-
-def squared_distances(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
- """Compute squared Euclidean distances between point sets."""
- if x.dim() == 2:
- D_xx = (x * x).sum(-1).unsqueeze(1)
- D_xy = torch.matmul(x, y.permute(1, 0))
- D_yy = (y * y).sum(-1).unsqueeze(0)
- elif x.dim() == 3:
- D_xx = (x * x).sum(-1).unsqueeze(2)
- D_xy = torch.matmul(x, y.permute(0, 2, 1))
- D_yy = (y * y).sum(-1).unsqueeze(1)
- else:
- raise ValueError(f"Incorrect number of dimensions: {x.shape}")
-
- return D_xx - 2 * D_xy + D_yy
-
-
-def distances(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
- """Compute Euclidean distances between point sets."""
- return torch.sqrt(torch.clamp_min(squared_distances(x, y), 1e-8))
-
-
-cost_routines = {
- 1: lambda x, y: distances(x, y),
- 2: lambda x, y: squared_distances(x, y) / 2,
-}
-
-
-def _anneal_param(
- current_epoch: int,
- max_epochs: int,
- init_value: float,
- target_value: float,
-) -> float:
- """Linearly anneal a parameter from init_value to target_value over one third of max_epochs.
-
- Parameters
- ----------
- current_epoch
- Current epoch number.
- max_epochs
- Total number of epochs.
- init_value
- Initial value of the parameter.
- target_value
- Target value of the parameter.
-
- Returns
- -------
- Annealed parameter value.
- """
- anneal_epochs = max_epochs // 3
- if current_epoch >= anneal_epochs:
- return target_value
- progress = current_epoch / anneal_epochs
- return init_value + (target_value - init_value) * progress
-
-
-class DiagTrainingPlan(TrainingPlan):
- """Training plan for DIAGVI model.
-
- Handles multi-modal loss computation including reconstruction, KL divergence,
- graph loss, Sinkhorn (optimal transport) loss, and classification loss.
-
- Parameters
- ----------
- module
- DIAGVAE module to train.
- lam_graph
- Weight for the graph reconstruction loss.
- lam_kl
- Weight for the KL divergence loss.
- lam_data
- Weight for the data reconstruction loss.
- lam_sinkhorn
- Weight for the Sinkhorn (optimal transport) loss.
- lam_class
- Weight for the classification loss. If None, automatically set based
- on whether classification is used.
- sinkhorn_p
- Order of the Wasserstein distance (p-norm).
- sinkhorn_blur
- Blur for geomloss Sinkhorn (epsilon = blur^p). If None, computed
- adaptively from cost matrix like OTT-JAX, by default None.
- epsilon_from_cost
- Statistic of cost to compute epsilon: "std" or "mean".
- epsilon_scale
- Scaling factor: `epsilon = epsilon_scale * statistic(C)`.
- sinkhorn_reach
- Reach parameter for unbalanced OT. If None, calculate adaptive reach from blur.
- reach_scale
- Scaling factor for adaptive reach: `reach = reach_scale * blur`.
- lr
- Learning rate.
- loss_annealing
- Whether to anneal loss parameters during training.
- log_train
- Whether to log individual train loss components.
- log_val
- Whether to log individual validation loss components.
- *args
- Additional positional arguments passed to :class:`~scvi.train.TrainingPlan`.
- **kwargs
- Additional keyword arguments passed to :class:`~scvi.train.TrainingPlan`.
- """
-
- def __init__(
- self,
- module: torch.nn.Module,
- lam_graph: float = 15.0,
- lam_kl: float = 1.0,
- lam_data: float = 0.1,
- lam_sinkhorn: float = 85.0,
- lam_class: float = 5.0,
- sinkhorn_p: int = 2,
- sinkhorn_blur: float | None = None,
- epsilon_from_cost: Literal["mean", "std"] = "mean",
- epsilon_scale: float = 0.5,
- sinkhorn_reach: float | None = None,
- reach_scale: float = 3.0,
- lr: float = 1e-3,
- loss_annealing: bool = False,
- log_train: bool = True,
- log_val: bool = False,
- *args,
- **kwargs,
- ) -> None:
- super().__init__(module, *args, **kwargs)
-
- # Loss weights
- self.lam_graph = lam_graph
- self.lam_kl = lam_kl
- self.lam_data = lam_data
- self.lam_sinkhorn = lam_sinkhorn
- self.lam_class = lam_class
-
- # Sinkhorn parameters
- self.sinkhorn_p = sinkhorn_p
- self.sinkhorn_blur = sinkhorn_blur
- self.sinkhorn_reach = sinkhorn_reach
- self.epsilon_from_cost = epsilon_from_cost
- self.epsilon_scale = epsilon_scale
- self.reach_scale = reach_scale
-
- # Training parameters
- self.lr = lr
- self.loss_annealing = loss_annealing
- self.log_train = log_train
- self.log_val = log_val
-
- # Initial values for annealing (10x larger for smoother optimization start)
- self.init_blur = 10 * self.sinkhorn_blur if self.sinkhorn_blur is not None else None
- self.init_reach = 10 * self.sinkhorn_reach if self.sinkhorn_reach is not None else None
-
- # Current sinkhorn parameters
- self.current_blur = self.sinkhorn_blur
- self.current_reach = self.sinkhorn_reach
-
- def _compute_modality_losses(
- self, batch: dict[str, dict[str, torch.Tensor]], log_prefix: str = ""
- ) -> tuple[list[dict], dict, int]:
- """Compute per-modality losses (reconstruction + KL).
-
- Parameters
- ----------
- batch
- A batch of data containing tensors for each modality.
- log_prefix
- Prefix for logging keys (e.g., "train_" or "val_").
-
- Returns
- -------
- loss_outputs
- Per-modality loss information containing z, modality_loss, graph_v, classification_loss
- last_loss_output
- The last loss output object (contains shared info like guidance_graph)
- total_batch_size
- Total number of samples across all modalities
- """
- loss_outputs = []
-
- # Compute graph encoding once — identical for all modalities
- first_x = next(iter(batch.values()))[REGISTRY_KEYS.X_KEY]
- v_all, mu_all, logvar_all = self.module.encode_graph(first_x.device)
-
- for name, tensors in batch.items():
- batch_size = tensors[REGISTRY_KEYS.X_KEY].shape[0]
-
- # Update loss kwargs for current modality
- self.loss_kwargs.update(
- {"lam_kl": self.lam_kl, "lam_data": self.lam_data, "mode": name}
- )
-
- # Calculate reconstruction, KL, and classification losses
- _, _, loss_output = self.forward(
- tensors,
- loss_kwargs=self.loss_kwargs,
- inference_kwargs={
- "mode": name,
- "v_all": v_all,
- "mu_all": mu_all,
- "logvar_all": logvar_all,
- },
- generative_kwargs={"mode": name},
- )
-
- # Log per-modality metrics
- # Validation and training step
- if log_prefix:
- self.log(
- f"{log_prefix}loss_{name}",
- loss_output.loss,
- batch_size=batch_size,
- on_epoch=True,
- on_step=True,
- )
-
- # Validation and training step
- if log_prefix in {"train_", "val_"}:
- reconstruction_loss = torch.mean(
- loss_output.reconstruction_loss["reconstruction_loss"]
- )
- self.log(
- f"{log_prefix}nll_{name}",
- reconstruction_loss,
- batch_size=batch_size,
- on_epoch=True,
- )
- self.log(
- f"{log_prefix}kl_{name}",
- loss_output.kl_local["kl_local"],
- batch_size=batch_size,
- on_epoch=True,
- )
-
- loss_outputs.append(
- {
- "z": loss_output.extra_metrics["z"],
- "modality_loss": loss_output.loss,
- "graph_v": loss_output.extra_metrics["v_all"],
- "classification_loss": loss_output.extra_metrics["classification_loss"],
- }
- )
-
- total_batch_size = sum(tensors[REGISTRY_KEYS.X_KEY].shape[0] for tensors in batch.values())
-
- return loss_outputs, loss_output, total_batch_size
-
- def _compute_graph_loss(
- self,
- loss_output: dict,
- feature_embeddings: torch.Tensor,
- log: bool = False,
- batch_size: int = 0,
- ) -> torch.Tensor:
- """Compute graph reconstruction and KL loss.
-
- Parameters
- ----------
- loss_output
- Loss output object containing graph information.
- feature_embeddings
- Tensor of shape (n_features, embedding_dim) containing feature embeddings.
- log
- Whether to log graph loss components.
- batch_size
- Batch size for logging.
-
- Returns
- -------
- Scalar tensor containing the total graph loss.
- """
- graph = loss_output.extra_metrics["guidance_graph"]
-
- # Graph reconstruction loss
- graph_nll = compute_graph_loss(graph, feature_embeddings)
-
- # Graph KL divergence (normalized by number of features)
- graph_kl = kl_divergence_graph(
- loss_output.extra_metrics["mu_all"],
- loss_output.extra_metrics["logvar_all"],
- )
- graph_kl_norm = graph_kl / feature_embeddings.shape[0]
-
- if log:
- self.log("nll_graph", graph_nll, batch_size=batch_size, on_epoch=True)
- self.log("kl_graph", graph_kl_norm, batch_size=batch_size, on_epoch=True)
-
- return graph_nll + graph_kl_norm
-
- @dependencies("geomloss")
- def _compute_sinkhorn_loss(
- self, z1: torch.Tensor, z2: torch.Tensor, use_annealing: bool = False
- ) -> torch.Tensor:
- """Compute Sinkhorn (Unbalanced Optimal Transport) loss between latent spaces.
-
- Sinkhorn parameters (blur and reach) can be adaptively computed from the cost matrix
- using OTT-JAX style heuristics or set to fixed values with optional annealing.
-
- Parameters
- ----------
- z1
- Latent representations from modality 1, shape (n_cells, n_latent).
- z2
- Latent representations from modality 2, shape (n_cells, n_latent).
- use_annealing
- Whether to use annealed Sinkhorn parameters. Only applied when both
- sinkhorn_blur and sinkhorn_reach are explicitly specified.
-
- Returns
- -------
- Scalar tensor containing the Sinkhorn loss.
- """
- import geomloss
-
- # Check if adaptive computation is needed
- needs_adaptive = self.sinkhorn_blur is None or self.sinkhorn_reach is None
-
- if needs_adaptive:
- # TODO: think about how to handle the cost matrix for large batches
- # Adaptively compute Sinkhorn parameters via OTT-JAX style heuristics
- # Note: annealing is NOT applied when using adaptive computation
-
- cost_fn = cost_routines[self.sinkhorn_p]
- with torch.no_grad():
- # Compute cost matrix C
- C_st = cost_fn(z1, z2)
-
- # Compute adaptive epsilon from cost and set blur
- if self.sinkhorn_blur is None:
- if self.epsilon_from_cost == "std":
- eps = self.epsilon_scale * C_st.std().item()
- elif self.epsilon_from_cost == "mean":
- eps = self.epsilon_scale * C_st.mean().item()
- else:
- raise ValueError(f"Unknown epsilon_from_cost: {self.epsilon_from_cost}")
-
- eps = max(eps, 1e-8) # Ensure positive
-
- # Convert epsilon to blur for geomloss: epsilon = blur^p → blur = epsilon^(1/p)
- self.current_blur = eps ** (1.0 / self.sinkhorn_p)
- else:
- self.current_blur = self.sinkhorn_blur
-
- # Compute adaptive reach from blur
- if self.sinkhorn_reach is None:
- self.current_reach = self.reach_scale * self.current_blur
- else:
- self.current_reach = self.sinkhorn_reach
-
- else:
- # Both blur and reach are specified - use them with optional annealing
- if use_annealing:
- max_epochs = self.trainer.max_epochs
- self.current_blur = _anneal_param(
- self.current_epoch, max_epochs, self.init_blur, self.sinkhorn_blur
- )
- self.current_reach = _anneal_param(
- self.current_epoch, max_epochs, self.init_reach, self.sinkhorn_reach
- )
- else:
- self.current_blur = self.sinkhorn_blur
- self.current_reach = self.sinkhorn_reach
-
- sinkhorn = geomloss.SamplesLoss(
- loss="sinkhorn",
- p=self.sinkhorn_p,
- blur=self.current_blur,
- reach=self.current_reach,
- )
- return sinkhorn(z1, z2)
-
- def _compute_total_loss(
- self,
- loss_outputs: list[dict],
- loss_output: dict,
- total_batch_size: int,
- use_annealing: bool = False,
- log: bool = False,
- ) -> torch.Tensor:
- """Compute total loss combining all components.
-
- Parameters
- ----------
- loss_outputs
- List of per-modality loss information.
- loss_output
- The last loss output object (contains shared info like guidance_graph).
- total_batch_size
- Total number of samples across all modalities.
- use_annealing
- Whether to use annealed Sinkhorn parameters.
- log
- Whether to log loss components.
-
- Returns
- -------
- Scalar tensor containing the total loss.
- """
- # 1. Data loss (reconstruction + KL for each modality)
- data_loss = sum(out["modality_loss"] for out in loss_outputs)
-
- # 2. Graph loss
- feature_embeddings = loss_outputs[0]["graph_v"]
- graph_loss = self._compute_graph_loss(
- loss_output, feature_embeddings, log=log, batch_size=total_batch_size
- )
-
- # 3. Classification loss
- classification_loss = sum(out["classification_loss"] for out in loss_outputs)
- if log:
- self.log("class_loss", classification_loss, batch_size=total_batch_size, on_epoch=True)
-
- # 4. Sinkhorn (UOT) loss
- z1, z2 = loss_outputs[0]["z"], loss_outputs[1]["z"]
- sinkhorn_loss = self._compute_sinkhorn_loss(z1, z2, use_annealing=use_annealing)
- if log:
- self.log("uot_loss", sinkhorn_loss, batch_size=total_batch_size, on_epoch=True)
-
- # Combine all losses
- total_loss = (
- data_loss
- + self.lam_graph * graph_loss
- + self.lam_sinkhorn * sinkhorn_loss
- + self.lam_class * classification_loss
- )
-
- return total_loss
-
- def training_step(self, batch: dict[str, dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
- """Training step.
-
- During training, computes the losses for each modality (NLL, KL, and classification),
- the graph loss, the Sinkhorn loss between modalities, and combines them into a total loss.
-
- Parameters
- ----------
- batch
- A batch of data containing tensors for each modality.
-
- Returns
- -------
- A dictionary containing the total loss for backpropagation.
- """
- loss_outputs, loss_output, total_batch_size = self._compute_modality_losses(
- batch, log_prefix="train_"
- )
-
- total_loss = self._compute_total_loss(
- loss_outputs,
- loss_output,
- total_batch_size,
- use_annealing=self.loss_annealing,
- log=self.log_train,
- )
-
- self.log(
- "training_loss",
- total_loss,
- prog_bar=True,
- on_step=False,
- on_epoch=True,
- batch_size=total_batch_size,
- )
-
- # Return embeddings along with loss for callbacks (detached to avoid graph issues)
- embeddings = {
- name: out["z"].detach() for name, out in zip(batch.keys(), loss_outputs, strict=False)
- }
-
- return {"loss": total_loss, "embeddings": embeddings}
-
- def validation_step(self, batch: dict[str, dict[str, torch.Tensor]]):
- """Validation step.
-
- During validation, computes and logs the losses for each modality (NLL, KL, and
- classification), the graph loss, and the Sinkhorn loss between modalities, and
- combines them into a total loss.
-
- Parameters
- ----------
- batch
- A batch of data containing tensors for each modality.
-
- Returns
- -------
- Returns none. Logs validation losses.
- """
- loss_outputs, loss_output, total_batch_size = self._compute_modality_losses(
- batch, log_prefix="val_"
- )
-
- total_loss = self._compute_total_loss(
- loss_outputs, loss_output, total_batch_size, use_annealing=False, log=self.log_val
- )
-
- self.log(
- "validation_loss",
- total_loss,
- prog_bar=True,
- on_epoch=True,
- batch_size=total_batch_size,
- )
diff --git a/src/scvi/external/diagvi/_utils.py b/src/scvi/external/diagvi/_utils.py
deleted file mode 100644
index 0d3fecea79..0000000000
--- a/src/scvi/external/diagvi/_utils.py
+++ /dev/null
@@ -1,384 +0,0 @@
-"""Utility functions for the DIAGVI model."""
-
-from __future__ import annotations
-
-import logging
-import os
-from typing import TYPE_CHECKING
-
-import anndata as ad
-import numpy as np
-import scipy.spatial
-import torch
-import torch.nn.functional as F
-
-from scvi.data._download import _download
-from scvi.utils import dependencies
-
-if TYPE_CHECKING:
- from typing import Literal
-
- import pandas as pd
- from anndata import AnnData
- from torch_geometric.data import Data
-
-logger = logging.getLogger(__name__)
-
-
-@dependencies("torch_geometric")
-def _construct_guidance_graph(
- adatas: dict[str, AnnData],
- mapping_df: pd.DataFrame | None,
- weight: float = 1.0,
- sign: float = 1.0,
-) -> Data:
- """Construct a guidance graph linking features across modalities.
-
- Creates a bipartite graph where nodes represent features from each modality
- and edges connect corresponding features based on the mapping DataFrame or
- shared feature names.
-
- Parameters
- ----------
- adatas
- Dictionary mapping modality names to AnnData objects.
- mapping_df
- DataFrame with columns matching modality names, containing feature
- mappings. If None, uses shared feature names.
- weight
- Edge weight for cross-modality connections.
- sign
- Edge sign for cross-modality connections.
-
- Returns
- -------
- PyTorch Geometric Data object with node features, edge indices,
- edge weights, edge signs, and modality index tensors.
-
- Raises
- ------
- ValueError
- If not exactly two modalities are provided or no overlapping features
- exist when mapping_df is None.
- """
- from torch_geometric.data import Data
-
- if len(adatas) != 2:
- raise ValueError("Exactly two modalities are required.")
- input_names = list(adatas.keys())
- adata1, adata2 = adatas[input_names[0]], adatas[input_names[1]]
-
- if mapping_df is not None:
- features1 = list(adata1.var_names)
- features2 = list(adata2.var_names)
- else:
- shared_features = set(adata1.var_names) & set(adata2.var_names)
- if not shared_features:
- raise ValueError("No overlapping features between the two modalities.")
-
- features1 = [f"{f}_{input_names[0]}" for f in adata1.var_names]
- features2 = [f"{f}_{input_names[1]}" for f in adata2.var_names]
-
- all_features = features1 + features2
- feature_to_index = {f: i for i, f in enumerate(all_features)}
-
- edge_index = []
- edge_weight = []
- edge_sign = []
-
- if mapping_df is not None:
- for ft_pair in range(mapping_df.shape[0]):
- pair = mapping_df.iloc[ft_pair, :]
- diss_ft = pair[input_names[0]]
- sp_ft = pair[input_names[1]]
-
- i = feature_to_index[diss_ft]
- j = feature_to_index[sp_ft]
-
- edge_index += [[i, j], [j, i]]
- edge_weight += [weight, weight]
- edge_sign += [sign, sign]
-
- else:
- for feature in shared_features:
- i = feature_to_index[f"{feature}_{input_names[0]}"]
- j = feature_to_index[f"{feature}_{input_names[1]}"]
-
- edge_index += [[i, j], [j, i]]
- edge_weight += [weight, weight]
- edge_sign += [sign, sign]
-
- for feature in all_features:
- i = feature_to_index[feature]
- edge_index.append([i, i])
- edge_weight.append(weight)
- edge_sign.append(sign)
-
- edge_index = torch.tensor(edge_index, dtype=torch.long).t().contiguous()
- edge_weight = torch.tensor(edge_weight, dtype=torch.float)
- edge_sign = torch.tensor(edge_sign, dtype=torch.float)
-
- x = torch.eye(len(all_features))
-
- indices1 = torch.tensor([feature_to_index[f] for f in features1], dtype=torch.long)
- indices2 = torch.tensor([feature_to_index[f] for f in features2], dtype=torch.long)
-
- return Data(
- x=x,
- edge_index=edge_index,
- edge_weight=edge_weight,
- edge_sign=edge_sign,
- **{f"{input_names[0]}_indices": indices1, f"{input_names[1]}_indices": indices2},
- )
-
-
-def _check_guidance_graph_consistency(graph: Data, adatas: dict[str, AnnData]):
- """Validate guidance graph structure and consistency with AnnData objects.
-
- Performs several consistency checks on the guidance graph:
- 1. Node count matches total number of features across modalities
- 2. Required edge attributes (edge_weight, edge_sign) are present
- 3. Self-loops exist for all nodes
- 4. Graph is symmetric (undirected)
-
- Parameters
- ----------
- graph
- PyTorch Geometric Data object representing the guidance graph.
- adatas
- Dictionary mapping modality names to AnnData objects.
-
- Raises
- ------
- ValueError
- If any consistency check fails.
- """
- n_expected = sum(adata.shape[1] for adata in adatas.values())
-
- # 1. Check variable coverage via counts
- if graph.num_nodes != n_expected:
- raise ValueError(
- f"Graph node count {graph.num_nodes} does not match expected {n_expected}."
- )
-
- # 2. Check edge attributes
- for attr in ["edge_weight", "edge_sign"]:
- if not hasattr(graph, attr):
- raise ValueError(f"Graph missing required edge attribute: {attr}")
- if getattr(graph, attr).shape[0] != graph.edge_index.shape[1]:
- raise ValueError(f"Edge attribute {attr} does not match number of edges.")
-
- # 3. Check self-loops
- src, tgt = graph.edge_index
- self_loops = src == tgt
- n_self_loops = self_loops.sum().item()
- if n_self_loops < graph.num_nodes:
- raise ValueError("Graph is missing self-loops for some nodes.")
-
- # 4. Check symmetry (for undirected graphs: For every edge (i, j), check that (j, i) exists)
- edge_set = {(i.item(), j.item()) for i, j in zip(src, tgt, strict=False)}
- for i, j in zip(src, tgt, strict=False):
- if (j.item(), i.item()) not in edge_set:
- raise ValueError(
- f"Graph is not symmetric: edge ({i.item()}, {j.item()}) has no counterpart."
- )
-
- # If all checks pass
- logger.info("Guidance graph consistency checks passed.")
-
-
-def _load_saved_diagvi_files(
- dir_path: str,
- prefix: str | None = None,
- map_location: Literal["cpu", "cuda"] | None = None,
- backup_url: str | None = None,
-) -> tuple[dict, dict[str, np.ndarray], dict, dict[str, AnnData | None]]:
- """Loads saved DiagVI model and AnnData files from a directory.
-
- Parameters
- ----------
- dir_path
- Directory path where the model and AnnData files are stored.
- prefix
- Optional prefix for the file names.
- map_location
- Device mapping for loading the model.
- backup_url
- Optional URL to download the model file if not found locally.
-
- Returns
- -------
- A tuple containing:
- - attr_dict: Dictionary of model attributes.
- - var_names: Dictionary of variable names for each modality.
- - model_state_dict: State dictionary of the model.
- - adatas: Dictionary of AnnData objects for each modality.
-
- Raises
- ------
- ValueError
- If model file cannot be loaded.
- """
- file_name_prefix = prefix or ""
-
- model_file_name = f"{file_name_prefix}model.pt"
- model_path = os.path.join(dir_path, model_file_name)
-
- try:
- _download(backup_url, dir_path, model_file_name)
- model = torch.load(model_path, map_location=map_location, weights_only=False)
- except FileNotFoundError as exc:
- raise ValueError(f"Failed to load model file at {model_path}. ") from exc
-
- names = model["names"]
-
- adatas = {}
- var_names = {}
- for name in names:
- adata_path = os.path.join(dir_path, f"{file_name_prefix}adata_{name}.h5ad")
- if os.path.exists(adata_path):
- adatas[name] = ad.read_h5ad(adata_path)
- var_names[name] = adatas[name].var_names
- else:
- adatas[name] = None
- key = f"var_names_{name}"
- if key in model:
- var_names[name] = model[key]
-
- model_state_dict = model["model_state_dict"]
- attr_dict = model["attr_dict"]
-
- return (
- attr_dict,
- var_names,
- model_state_dict,
- adatas,
- )
-
-
-@dependencies("torch_geometric")
-def compute_graph_loss(graph: Data, feature_embeddings: torch.Tensor) -> torch.Tensor:
- """Compute graph reconstruction loss using negative sampling.
-
- Uses structured negative sampling to compute a contrastive loss that
- encourages connected nodes to have similar embeddings and unconnected
- nodes to have dissimilar embeddings.
-
- Parameters
- ----------
- graph
- PyTorch Geometric Data object with edge_index.
- feature_embeddings
- Tensor of shape (n_features, embedding_dim) containing feature embeddings.
-
- Returns
- -------
- Scalar tensor containing the graph reconstruction loss.
- """
- import torch_geometric
-
- edge_index = graph.edge_index
- edge_index_neg = torch_geometric.utils.structured_negative_sampling(edge_index)
-
- pos_i, pos_j, neg_j = edge_index_neg[0], edge_index_neg[1], edge_index_neg[2]
-
- vi = feature_embeddings[pos_i]
- vj = feature_embeddings[pos_j]
- vj_neg = feature_embeddings[neg_j]
-
- pos_logits = (vi * vj).sum(dim=1)
- pos_loss = F.logsigmoid(pos_logits).mean()
-
- neg_logits = (vi * vj_neg).sum(dim=1)
- neg_loss = F.logsigmoid(-neg_logits).mean()
-
- total_loss = -(pos_loss + neg_loss) / 2
-
- return total_loss
-
-
-def kl_divergence_graph(mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor:
- """Computes the KL divergence for graph latent variables.
-
- Parameters
- ----------
- mu
- Mean tensor of the latent variables.
- logvar
- Log-variance tensor of the latent variables.
-
- Returns
- -------
- The mean KL divergence as a tensor.
- """
- kl = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp(), dim=1)
- kl_mean = kl.mean()
- return kl_mean
-
-
-def compute_foscttm(
- latents: dict[str, np.ndarray],
- indices: list[np.ndarray] | None = None,
- downsample: bool = False,
- n_obs: int = 10000,
-) -> dict[str, float]:
- """Compute FOSCTTM (Fraction of Samples Closer Than True Match) metric.
-
- For paired multi-modal data, measures how well the model aligns corresponding
- cells in the latent space. Lower values are better (0 = perfect alignment).
- Assumes cells are aligned: mod1 cell i corresponds to mod2 cell i.
-
- Parameters
- ----------
- latents
- Dictionary mapping modality names to latent arrays.
- indices
- List of two arrays: indices for [mod1, mod2].
- Usually obtained from model.validation_indices after training.
- downsample
- Whether to downsample when the number of observations is large.
- n_obs
- Number of observations to keep if downsample is True.
- """
- # Extract latent representations for the two modalities
- latent_mod1 = latents[list(latents.keys())[0]]
- latent_mod2 = latents[list(latents.keys())[1]]
-
- # Subset if indices provided
- if indices is not None:
- latent_mod1 = latent_mod1[indices[0]]
- latent_mod2 = latent_mod2[indices[1]]
-
- # Validate shapes match
- if latent_mod1.shape[0] != latent_mod2.shape[0]:
- raise ValueError("Shapes do not match!")
-
- n_cells = latent_mod1.shape[0]
-
- # Downsample if requested and number of cells exceeds n_obs
- if n_cells > n_obs and downsample:
- np.random.seed(0)
- sample_indices = np.random.choice(n_cells, size=n_obs, replace=False)
- latent_mod1 = latent_mod1[sample_indices]
- latent_mod2 = latent_mod2[sample_indices]
- n_cells = n_obs
-
- # Compute pairwise distances
- distances = scipy.spatial.distance_matrix(latent_mod1, latent_mod2)
-
- # Compute FOSCTTM
- foscttm_mod1_to_mod2 = (distances < np.expand_dims(np.diag(distances), axis=1)).mean(axis=1)
- foscttm_mod2_to_mod1 = (distances < np.expand_dims(np.diag(distances), axis=0)).mean(axis=0)
-
- # Aggregate metrics
- mean_mod1_to_mod2 = foscttm_mod1_to_mod2.mean()
- mean_mod2_to_mod1 = foscttm_mod2_to_mod1.mean()
-
- mod1_name, mod2_name = list(latents.keys())
- foscttm_metrics = {
- f"foscttm/{mod1_name}_to_{mod2_name}": float(mean_mod1_to_mod2),
- f"foscttm/{mod2_name}_to_{mod1_name}": float(mean_mod2_to_mod1),
- "foscttm/mean": float((mean_mod1_to_mod2 + mean_mod2_to_mod1) / 2),
- }
-
- return foscttm_metrics
diff --git a/src/scvi/external/gimvi/__init__.py b/src/scvi/external/gimvi/__init__.py
deleted file mode 100644
index 93037019a3..0000000000
--- a/src/scvi/external/gimvi/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from ._model import GIMVI
-from ._module import JVAE
-
-__all__ = ["GIMVI", "JVAE"]
diff --git a/src/scvi/external/gimvi/_model.py b/src/scvi/external/gimvi/_model.py
deleted file mode 100644
index 9a884cca99..0000000000
--- a/src/scvi/external/gimvi/_model.py
+++ /dev/null
@@ -1,690 +0,0 @@
-from __future__ import annotations
-
-import logging
-import os
-import warnings
-from typing import TYPE_CHECKING
-
-import numpy as np
-import torch
-
-from scvi import REGISTRY_KEYS, settings
-from scvi.data import AnnDataManager
-from scvi.data._compat import registry_from_setup_dict
-from scvi.data._constants import _MODEL_NAME_KEY, _SETUP_ARGS_KEY
-from scvi.data.fields import CategoricalObsField, LayerField
-from scvi.dataloaders import DataSplitter
-from scvi.model._utils import _init_library_size, parse_device_args
-from scvi.model.base import BaseModelClass, RNASeqMixin, VAEMixin
-from scvi.train import Trainer
-from scvi.train._config import merge_kwargs
-from scvi.utils import setup_anndata_dsp
-from scvi.utils._docstrings import devices_dsp
-
-from ._module import JVAE
-from ._task import CyclicMultiDataLoader as TrainDL
-from ._task import GIMVITrainingPlan
-from ._utils import _load_legacy_saved_gimvi_files, _load_saved_gimvi_files
-
-if TYPE_CHECKING:
- from anndata import AnnData
-
- from scvi.dataloaders import AnnDataLoader
-
-logger = logging.getLogger(__name__)
-
-
-def _unpack_tensors(tensors):
- x = tensors[REGISTRY_KEYS.X_KEY].squeeze_(0)
- batch_index = tensors[REGISTRY_KEYS.BATCH_KEY].squeeze_(0)
- y = tensors[REGISTRY_KEYS.LABELS_KEY].squeeze_(0)
- return x, batch_index, y
-
-
-class GIMVI(VAEMixin, RNASeqMixin, BaseModelClass):
- """Joint VAE for imputing missing genes in spatial data :cite:p:`Lopez19`.
-
- Parameters
- ----------
- adata_seq
- AnnData object that has been registered via :meth:`~scvi.external.GIMVI.setup_anndata`
- and contains RNA-seq data.
- adata_spatial
- AnnData object that has been registered via :meth:`~scvi.external.GIMVI.setup_anndata`
- and contains spatial data.
- n_hidden
- Number of nodes per hidden layer.
- generative_distributions
- List of generative distribution for adata_seq data and adata_spatial data. Defaults to
- ['zinb', 'nb'].
- model_library_size
- List of bool of whether to model library size for adata_seq and adata_spatial. Defaults to
- [True, False].
- n_latent
- Dimensionality of the latent space.
- **model_kwargs
- Keyword args for :class:`~scvi.external.gimvi.JVAE`
-
- Examples
- --------
- >>> adata_seq = anndata.read_h5ad(path_to_anndata_seq)
- >>> adata_spatial = anndata.read_h5ad(path_to_anndata_spatial)
- >>> scvi.external.GIMVI.setup_anndata(adata_seq)
- >>> scvi.external.GIMVI.setup_anndata(adata_spatial)
- >>> vae = scvi.model.GIMVI(adata_seq, adata_spatial)
- >>> vae.train(n_epochs=400)
-
- Notes
- -----
- See further usage examples in the following tutorials:
-
- 1. :doc:`/tutorials/notebooks/spatial/gimvi_tutorial`
- """
-
- def __init__(
- self,
- adata_seq: AnnData,
- adata_spatial: AnnData,
- generative_distributions: list[str] | None = None,
- model_library_size: list[bool] | None = None,
- n_latent: int = 10,
- **model_kwargs,
- ):
- warnings.warn(
- "GIMVI is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- super().__init__()
- if adata_seq is adata_spatial:
- raise ValueError(
- "`adata_seq` and `adata_spatial` cannot point to the same object. "
- "If you would really like to do this, make a copy of the object and pass it in as "
- "`adata_spatial`."
- )
- model_library_size = model_library_size or [True, False]
- generative_distributions = generative_distributions or ["zinb", "nb"]
- self.adatas = [adata_seq, adata_spatial]
- self.adata_managers = {
- "seq": self._get_most_recent_anndata_manager(adata_seq, required=True),
- "spatial": self._get_most_recent_anndata_manager(adata_spatial, required=True),
- }
- self.registries_ = []
- for adm in self.adata_managers.values():
- self._register_manager_for_instance(adm)
- self.registries_.append(adm.registry)
-
- seq_var_names = adata_seq.var_names
- spatial_var_names = adata_spatial.var_names
-
- if not set(spatial_var_names) <= set(seq_var_names):
- raise ValueError("spatial genes needs to be subset of seq genes")
-
- spatial_gene_loc = [np.argwhere(seq_var_names == g)[0] for g in spatial_var_names]
- spatial_gene_loc = np.concatenate(spatial_gene_loc)
- gene_mappings = [slice(None), spatial_gene_loc]
- sum_stats = [adm.summary_stats for adm in self.adata_managers.values()]
- n_inputs = [s["n_vars"] for s in sum_stats]
-
- total_genes = n_inputs[0]
-
- adata_seq_n_batches = sum_stats[0]["n_batch"]
- adata_spatial_batch = adata_spatial.obs[
- self.adata_managers["spatial"].data_registry[REGISTRY_KEYS.BATCH_KEY].attr_key
- ]
- if np.min(adata_spatial_batch) == 0:
- # see #2446
- # since we are combining datasets, we need to increment the batch_idx of one of the
- # datasets. we only need to do this once so we check if the min is 0
- adata_spatial.obs[
- self.adata_managers["spatial"].data_registry[REGISTRY_KEYS.BATCH_KEY].attr_key
- ] += adata_seq_n_batches
-
- n_batches = sum(s["n_batch"] for s in sum_stats)
-
- library_log_means = []
- library_log_vars = []
- for adata_manager in self.adata_managers.values():
- adata_library_log_means, adata_library_log_vars = _init_library_size(
- adata_manager, n_batches
- )
- library_log_means.append(adata_library_log_means)
- library_log_vars.append(adata_library_log_vars)
-
- self.module = JVAE(
- n_inputs,
- total_genes,
- gene_mappings,
- generative_distributions,
- model_library_size,
- library_log_means,
- library_log_vars,
- n_batch=n_batches,
- n_latent=n_latent,
- **model_kwargs,
- )
-
- self._model_summary_string = (
- "GimVI Model with the following params: \nn_latent: {}, n_inputs: {}, n_genes: {}, "
- + "n_batch: {}, generative distributions: {}"
- ).format(n_latent, n_inputs, total_genes, n_batches, generative_distributions)
- self.init_params_ = self._get_init_params(locals())
-
- @devices_dsp.dedent
- def train(
- self,
- max_epochs: int = 200,
- accelerator: str = "auto",
- devices: int | list[int] | str = "auto",
- kappa: int = 5,
- train_size: float | None = None,
- validation_size: float | None = None,
- shuffle_set_split: bool = True,
- batch_size: int = 128,
- datasplitter_kwargs: dict | None = None,
- plan_kwargs: dict | None = None,
- **kwargs,
- ):
- """Train the model.
-
- Parameters
- ----------
- max_epochs
- Number of passes through the dataset. If `None`, defaults to
- `np.min([round((20000 / n_cells) * 400), 400])`
- %(param_accelerator)s
- %(param_devices)s
- kappa
- Scaling parameter for the discriminator loss.
- train_size
- Size of training set in the range [0.0, 1.0].
- validation_size
- Size of the test set. If `None`, defaults to 1 - `train_size`. If
- `train_size + validation_size < 1`, the remaining cells belong to a test set.
- shuffle_set_split
- Whether to shuffle indices before splitting. If `False`, the val, train, and test set
- are split in the sequential order of the data according to `validation_size` and
- `train_size` percentages.
- batch_size
- Minibatch size to use during training.
- datasplitter_kwargs
- Additional keyword arguments passed into :class:`~scvi.dataloaders.DataSplitter`.
- plan_kwargs
- Keyword args for model-specific Pytorch Lightning task. Keyword arguments passed
- to `train()` will overwrite values present in `plan_kwargs`, when appropriate.
- **kwargs
- Other keyword args for :class:`~scvi.train.Trainer`.
- """
- accelerator, devices, device = parse_device_args(
- accelerator=accelerator,
- devices=devices,
- return_device="torch",
- )
- datasplitter_kwargs = datasplitter_kwargs or {}
-
- self.trainer = Trainer(
- max_epochs=max_epochs,
- accelerator=accelerator,
- devices=devices,
- **kwargs,
- )
- self.train_indices_, self.test_indices_, self.validation_indices_ = [], [], []
- train_dls, test_dls, val_dls = [], [], []
- for i, adm in enumerate(self.adata_managers.values()):
- ds = DataSplitter(
- adm,
- train_size=train_size,
- validation_size=validation_size,
- batch_size=batch_size,
- shuffle_set_split=shuffle_set_split,
- **datasplitter_kwargs,
- )
- ds.setup()
- train_dls.append(ds.train_dataloader())
- test_dls.append(ds.test_dataloader())
- val = ds.val_dataloader()
- val_dls.append(val)
- val.mode = i
- self.train_indices_.append(ds.train_idx)
- self.test_indices_.append(ds.test_idx)
- self.validation_indices_.append(ds.val_idx)
- train_dl = TrainDL(train_dls)
-
- plan_kwargs = merge_kwargs(None, plan_kwargs, name="plan")
- self._training_plan = GIMVITrainingPlan(
- self.module,
- adversarial_classifier=True,
- scale_adversarial_loss=kappa,
- **plan_kwargs,
- )
-
- if train_size == 1.0:
- # circumvent the empty data loader problem if all dataset used for training
- self.trainer.fit(self._training_plan, train_dl)
- else:
- # accepts list of val dataloaders
- self.trainer.fit(self._training_plan, train_dl, val_dls)
- try:
- self.history_ = self.trainer.logger.history
- except AttributeError:
- self.history_ = None
- self.module.eval()
-
- self.to_device(device)
- self.is_trained_ = True
-
- def _make_scvi_dls(
- self, adatas: list[AnnData] = None, batch_size: int = 128
- ) -> list[AnnDataLoader]:
- if adatas is None:
- adatas = self.adatas
- post_list = [self._make_data_loader(ad, batch_size=batch_size) for ad in adatas]
- for i, dl in enumerate(post_list):
- dl.mode = i
-
- return post_list
-
- @torch.inference_mode()
- def get_latent_representation(
- self,
- adatas: list[AnnData] = None,
- deterministic: bool = True,
- batch_size: int = 128,
- ) -> list[np.ndarray]:
- """Return the latent space embedding for each dataset.
-
- Parameters
- ----------
- adatas
- List of adata seq and adata spatial.
- deterministic
- If true, use the mean of the encoder instead of a Gaussian sample.
- batch_size
- Minibatch size for data loading into model.
- """
- if adatas is None:
- adatas = self.adatas
- scdls = self._make_scvi_dls(adatas, batch_size=batch_size)
- self.module.eval()
- latents = []
- for mode, scdl in enumerate(scdls):
- latent = []
- for tensors in scdl:
- (
- sample_batch,
- *_,
- ) = _unpack_tensors(tensors)
- latent.append(
- self.module.sample_from_posterior_z(
- sample_batch, mode, deterministic=deterministic
- )
- .cpu()
- .detach()
- )
-
- latent = torch.cat(latent).numpy()
- latents.append(latent)
-
- return latents
-
- @torch.inference_mode()
- def get_imputed_values(
- self,
- adatas: list[AnnData] = None,
- deterministic: bool = True,
- normalized: bool = True,
- decode_mode: int | None = None,
- batch_size: int = 128,
- ) -> list[np.ndarray]:
- """Return imputed values for all genes for each dataset.
-
- Parameters
- ----------
- adatas
- List of adata seq and adata spatial
- deterministic
- If true, use the mean of the encoder instead of a Gaussian sample for the latent
- vector.
- normalized
- Return imputed normalized values or not.
- decode_mode
- If a `decode_mode` is given, use the encoder specific to each dataset as usual but use
- the decoder of the dataset of id `decode_mode` to impute values.
- batch_size
- Minibatch size for data loading into model.
- """
- self.module.eval()
-
- if adatas is None:
- adatas = self.adatas
- scdls = self._make_scvi_dls(adatas, batch_size=batch_size)
-
- imputed_values = []
- for mode, scdl in enumerate(scdls):
- imputed_value = []
- for tensors in scdl:
- (
- sample_batch,
- batch_index,
- label,
- *_,
- ) = _unpack_tensors(tensors)
- if normalized:
- imputed_value.append(
- self.module.sample_scale(
- sample_batch,
- mode,
- batch_index,
- label,
- deterministic=deterministic,
- decode_mode=decode_mode,
- )
- .cpu()
- .detach()
- )
- else:
- imputed_value.append(
- self.module.sample_rate(
- sample_batch,
- mode,
- batch_index,
- label,
- deterministic=deterministic,
- decode_mode=decode_mode,
- )
- .cpu()
- .detach()
- )
-
- imputed_value = torch.cat(imputed_value).numpy()
- imputed_values.append(imputed_value)
-
- return imputed_values
-
- def save(
- self,
- dir_path: str,
- prefix: str | None = None,
- overwrite: bool = False,
- save_anndata: bool = False,
- save_kwargs: dict | None = None,
- **anndata_write_kwargs,
- ):
- """Save the state of the model.
-
- Neither the trainer optimizer state nor the trainer history are saved.
- Model files are not expected to be reproducibly saved and loaded across versions
- until we reach version 1.0.
-
- Parameters
- ----------
- dir_path
- Path to a directory.
- prefix
- Prefix to prepend to saved file names.
- overwrite
- Overwrite existing data or not. If `False` and directory
- already exists at `dir_path`, error will be raised.
- save_anndata
- If True, also saves the anndata
- save_kwargs
- Keyword arguments passed into :func:`~torch.save`.
- anndata_write_kwargs
- Kwargs for anndata write function
- """
- if not os.path.exists(dir_path) or overwrite:
- os.makedirs(dir_path, exist_ok=overwrite)
- else:
- raise ValueError(
- f"{dir_path} already exists. Please provide an unexisting directory for saving."
- )
-
- file_name_prefix = prefix or ""
- save_kwargs = save_kwargs or {}
-
- seq_adata = self.adatas[0]
- spatial_adata = self.adatas[1]
- if save_anndata:
- seq_save_path = os.path.join(dir_path, f"{file_name_prefix}adata_seq.h5ad")
- seq_adata.write(seq_save_path)
-
- spatial_save_path = os.path.join(dir_path, f"{file_name_prefix}adata_spatial.h5ad")
- spatial_adata.write(spatial_save_path)
-
- # save the model state dict and the trainer state dict only
- model_state_dict = self.module.state_dict()
-
- seq_var_names = seq_adata.var_names.astype(str).to_numpy()
- spatial_var_names = spatial_adata.var_names.astype(str).to_numpy()
-
- # get all the user attributes
- user_attributes = self._get_user_attributes()
- # only save the public attributes with _ at the very end
- user_attributes = {a[0]: a[1] for a in user_attributes if a[0][-1] == "_"}
-
- model_save_path = os.path.join(dir_path, f"{file_name_prefix}model.pt")
-
- torch.save(
- {
- "model_state_dict": model_state_dict,
- "seq_var_names": seq_var_names,
- "spatial_var_names": spatial_var_names,
- "attr_dict": user_attributes,
- },
- model_save_path,
- **save_kwargs,
- )
-
- @classmethod
- @devices_dsp.dedent
- def load(
- cls,
- dir_path: str,
- adata_seq: AnnData | None = None,
- adata_spatial: AnnData | None = None,
- accelerator: str = "auto",
- device: int | str = "auto",
- prefix: str | None = None,
- backup_url: str | None = None,
- ):
- """Instantiate a model from the saved output.
-
- Parameters
- ----------
- dir_path
- Path to saved outputs.
- adata_seq
- AnnData organized in the same way as data used to train model.
- It is not necessary to run :meth:`~scvi.external.GIMVI.setup_anndata`,
- as AnnData is validated against the saved `scvi` setup dictionary.
- AnnData must be registered via :meth:`~scvi.external.GIMVI.setup_anndata`.
- adata_spatial
- AnnData organized in the same way as data used to train model.
- If None, will check for and load anndata saved with the model.
- %(param_accelerator)s
- %(param_device)s
- prefix
- Prefix of saved file names.
- backup_url
- URL to retrieve saved outputs from if not present on disk.
-
- Returns
- -------
- Model with loaded state dictionaries.
-
- Examples
- --------
- >>> vae = GIMVI.load(adata_seq, adata_spatial, save_path)
- >>> vae.get_latent_representation()
- """
- _, _, device = parse_device_args(
- accelerator=accelerator,
- devices=device,
- return_device="torch",
- )
-
- (
- attr_dict,
- seq_var_names,
- spatial_var_names,
- model_state_dict,
- loaded_adata_seq,
- loaded_adata_spatial,
- ) = _load_saved_gimvi_files(
- dir_path,
- adata_seq is None,
- adata_spatial is None,
- prefix=prefix,
- map_location=device,
- backup_url=backup_url,
- )
- adata_seq = loaded_adata_seq or adata_seq
- adata_spatial = loaded_adata_spatial or adata_spatial
- adatas = [adata_seq, adata_spatial]
- var_names = [seq_var_names, spatial_var_names]
-
- for i, adata in enumerate(adatas):
- saved_var_names = var_names[i]
- user_var_names = adata.var_names.astype(str)
- if not np.array_equal(saved_var_names, user_var_names):
- warnings.warn(
- "var_names for adata passed in does not match var_names of adata "
- "used to train the model. For valid results, the vars need to be the"
- "same and in the same order as the adata used to train the model.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
-
- registries = attr_dict.pop("registries_")
- for adata, registry in zip(adatas, registries, strict=True):
- if _MODEL_NAME_KEY in registry and registry[_MODEL_NAME_KEY] != cls.__name__:
- raise ValueError("It appears you are loading a model from a different class.")
-
- if _SETUP_ARGS_KEY not in registry:
- raise ValueError(
- "Saved model does not contain original setup inputs. "
- "Cannot load the original setup."
- )
-
- cls.setup_anndata(adata, source_registry=registry, **registry[_SETUP_ARGS_KEY])
-
- # get the parameters for the class init signature
- init_params = attr_dict.pop("init_params_")
-
- # new saving and loading, enable backwards compatibility
- if "non_kwargs" in init_params.keys():
- # grab all the parameters except for kwargs (is a dict)
- non_kwargs = init_params["non_kwargs"]
- kwargs = init_params["kwargs"]
-
- # expand out kwargs
- kwargs = {k: v for (i, j) in kwargs.items() for (k, v) in j.items()}
- else:
- # grab all the parameters except for kwargs (is a dict)
- non_kwargs = {k: v for k, v in init_params.items() if not isinstance(v, dict)}
- kwargs = {k: v for k, v in init_params.items() if isinstance(v, dict)}
- kwargs = {k: v for (i, j) in kwargs.items() for (k, v) in j.items()}
- model = cls(adata_seq, adata_spatial, **non_kwargs, **kwargs)
-
- for attr, val in attr_dict.items():
- setattr(model, attr, val)
-
- model.module.load_state_dict(model_state_dict)
- model.module.eval()
- model.to_device(device)
- return model
-
- @classmethod
- def convert_legacy_save(
- cls,
- dir_path: str,
- output_dir_path: str,
- overwrite: bool = False,
- prefix: str | None = None,
- **save_kwargs,
- ) -> None:
- """Converts a legacy saved GIMVI model ( torch.Tensor:
- """Sample tensor of latent values from the posterior.
-
- Parameters
- ----------
- x
- tensor of values with shape ``(batch_size, n_input)``
- mode
- head id to use in the encoder
- deterministic
- bool - whether to sample or not
-
- Returns
- -------
- type
- tensor of shape ``(batch_size, n_latent)``
- """
- if mode is None:
- if len(self.n_input_list) == 1:
- mode = 0
- else:
- raise Exception("Must provide a mode when having multiple datasets")
- outputs = self.inference(x, mode)
- qz_m = outputs["qz"].loc
- z = outputs["z"]
- if deterministic:
- z = qz_m
- return z
-
- def sample_from_posterior_l(
- self, x: torch.Tensor, mode: int = None, deterministic: bool = False
- ) -> torch.Tensor:
- """Sample the tensor of library sizes from the posterior.
-
- Parameters
- ----------
- x
- tensor of values with shape ``(batch_size, n_input)``
- or ``(batch_size, n_input_fish)`` depending on the mode
- mode
- head id to use in the encoder
- deterministic
- bool - whether to sample or not
-
- Returns
- -------
- type
- tensor of shape ``(batch_size, 1)``
- """
- inference_out = self.inference(x, mode)
- return (
- inference_out["ql"].loc
- if (deterministic and inference_out["ql"] is not None)
- else inference_out["library"]
- )
-
- def sample_scale(
- self,
- x: torch.Tensor,
- mode: int,
- batch_index: torch.Tensor,
- y: torch.Tensor | None = None,
- deterministic: bool = False,
- decode_mode: int | None = None,
- ) -> torch.Tensor:
- """Return the tensor of predicted frequencies of expression.
-
- Parameters
- ----------
- x
- tensor of values with shape ``(batch_size, n_input)``
- or ``(batch_size, n_input_fish)`` depending on the mode
- mode
- int encode mode (which input head to use in the model)
- batch_index
- array that indicates which batch the cells belong to with shape ``batch_size``
- y
- tensor of cell-types labels with shape ``(batch_size, n_labels)``
- deterministic
- bool - whether to sample or not
- decode_mode
- int use to a decode mode different from encoding mode
-
- Returns
- -------
- type
- tensor of predicted expression
- """
- gen_out = self._run_forward(
- x,
- mode,
- batch_index,
- y=y,
- deterministic=deterministic,
- decode_mode=decode_mode,
- )
- return gen_out["px_scale"]
-
- # This is a potential wrapper for a vae like get_sample_rate
- def get_sample_rate(self, x, batch_index, *_, **__):
- """Get the sample rate for the model."""
- return self.sample_rate(x, 0, batch_index)
-
- def _run_forward(
- self,
- x: torch.Tensor,
- mode: int,
- batch_index: torch.Tensor,
- y: torch.Tensor | None = None,
- deterministic: bool = False,
- decode_mode: int = None,
- ) -> dict:
- """Run the forward pass of the model."""
- if decode_mode is None:
- decode_mode = mode
- inference_out = self.inference(x, mode)
- if deterministic:
- z = inference_out["qz"].loc
- if inference_out["ql"] is not None:
- library = inference_out["ql"].loc
- else:
- library = inference_out["library"]
- else:
- z = inference_out["z"]
- library = inference_out["library"]
- gen_out = self.generative(z, library, batch_index, y, decode_mode)
- return gen_out
-
- def sample_rate(
- self,
- x: torch.Tensor,
- mode: int,
- batch_index: torch.Tensor,
- y: torch.Tensor | None = None,
- deterministic: bool = False,
- decode_mode: int = None,
- ) -> torch.Tensor:
- """Returns the tensor of scaled frequencies of expression.
-
- Parameters
- ----------
- x
- tensor of values with shape ``(batch_size, n_input)``
- or ``(batch_size, n_input_fish)`` depending on the mode
- y
- tensor of cell-types labels with shape ``(batch_size, n_labels)``
- mode
- int encode mode (which input head to use in the model)
- batch_index
- array that indicates which batch the cells belong to with shape ``batch_size``
- deterministic
- bool - whether to sample or not
- decode_mode
- int use to a decode mode different from encoding mode
-
- Returns
- -------
- type
- tensor of means of the scaled frequencies
- """
- gen_out = self._run_forward(
- x,
- mode,
- batch_index,
- y=y,
- deterministic=deterministic,
- decode_mode=decode_mode,
- )
- return gen_out["px_rate"]
-
- def reconstruction_loss(
- self,
- x: torch.Tensor,
- px_rate: torch.Tensor,
- px_r: torch.Tensor,
- px_dropout: torch.Tensor,
- mode: int,
- ) -> torch.Tensor:
- """Compute the reconstruction loss."""
- reconstruction_loss = None
- if self.gene_likelihoods[mode] == "zinb":
- reconstruction_loss = (
- -ZeroInflatedNegativeBinomial(mu=px_rate, theta=px_r, zi_logits=px_dropout)
- .log_prob(x)
- .sum(dim=-1)
- )
- elif self.gene_likelihoods[mode] == "nb":
- reconstruction_loss = -NegativeBinomial(mu=px_rate, theta=px_r).log_prob(x).sum(dim=-1)
- elif self.gene_likelihoods[mode] == "poisson":
- reconstruction_loss = -Poisson(px_rate).log_prob(x).sum(dim=1)
- return reconstruction_loss
-
- def _get_inference_input(self, tensors) -> dict[str, torch.Tensor | None]:
- """Get the input for the inference model."""
- return {
- "x": tensors[REGISTRY_KEYS.X_KEY],
- "batch_index": tensors.get(REGISTRY_KEYS.BATCH_KEY, None),
- }
-
- def _get_generative_input(self, tensors, inference_outputs, transform_batch=None):
- """Get the input for the generative model."""
- z = inference_outputs["z"]
- library = inference_outputs["library"]
- batch_index = tensors[REGISTRY_KEYS.BATCH_KEY]
- y = tensors[REGISTRY_KEYS.LABELS_KEY]
- if transform_batch is not None:
- batch_index = torch.ones_like(batch_index) * transform_batch
- return {"z": z, "library": library, "batch_index": batch_index, "y": y}
-
- @auto_move_data
- def inference(
- self,
- x: torch.Tensor,
- mode: int | None = 0,
- n_samples: int | None = 1,
- batch_index: torch.Tensor | None = None,
- ) -> dict:
- """Run the inference model."""
- x_ = x
- if self.log_variational:
- x_ = torch.log(1 + x_)
-
- qz, z = self.z_encoder(x_, mode)
- ql, library = None, None
- if self.model_library_bools[mode]:
- ql, library = self.l_encoders[mode](x_)
- else:
- library = torch.log(torch.sum(x, dim=1)).view(-1, 1)
-
- if n_samples > 1:
- # when z is normal, untran_z == z
- untran_z = qz.sample((n_samples,))
- z = self.z_encoder.z_transformation(untran_z)
-
- return {"qz": qz, "z": z, "ql": ql, "library": library}
-
- @auto_move_data
- def generative(
- self,
- z: torch.Tensor,
- library: torch.Tensor,
- batch_index: torch.Tensor | None = None,
- y: torch.Tensor | None = None,
- mode: int | None = 0,
- transform_batch: torch.Tensor | None = None,
- ) -> dict:
- """Run the generative model."""
- px_scale, px_r, px_rate, px_dropout = self.decoder(
- z, mode, library, self.dispersion, batch_index, y
- )
- if self.dispersion == "gene-label":
- px_r = F.linear(F.one_hot(y.type.squeeze(-1), self.n_labels).float(), self.px_r)
- elif self.dispersion == "gene-batch":
- px_r = F.linear(F.one_hot(batch_index.squeeze(-1), self.n_batch).float(), self.px_r)
- elif self.dispersion == "gene":
- px_r = self.px_r.view(1, self.px_r.size(0))
- px_r = torch.exp(px_r)
-
- px_scale = px_scale / torch.sum(px_scale[:, self.indices_mappings[mode]], dim=1).view(
- -1, 1
- )
- px_rate = px_scale * torch.exp(library)
-
- if transform_batch is not None:
- batch_index = torch.ones_like(batch_index) * transform_batch
-
- px = NegativeBinomial(mu=px_rate, theta=px_r, scale=px_scale)
-
- return {
- "px_scale": px_scale,
- "px": px,
- "px_r": px_r,
- "px_rate": px_rate,
- "px_dropout": px_dropout,
- "batch_index": batch_index,
- }
-
- def loss(
- self,
- tensors,
- inference_outputs,
- generative_outputs,
- mode: int | None = None,
- kl_weight=1.0,
- ) -> tuple[torch.Tensor, torch.Tensor]:
- """Return the reconstruction loss and the Kullback divergences.
-
- Parameters
- ----------
- x
- tensor of values with shape ``(batch_size, n_input)``
- or ``(batch_size, n_input_fish)`` depending on the mode
- batch_index
- array that indicates which batch the cells belong to with shape ``batch_size``
- y
- tensor of cell-types labels with shape (batch_size, n_labels)
- mode
- indicates which head/tail to use in the joint network
-
-
- Returns
- -------
- the reconstruction loss and the Kullback divergences
- """
- if mode is None:
- if len(self.n_input_list) == 1:
- mode = 0
- else:
- raise Exception("Must provide a mode")
- x = tensors[REGISTRY_KEYS.X_KEY]
- batch_index = tensors[REGISTRY_KEYS.BATCH_KEY]
-
- qz = inference_outputs["qz"]
- ql = inference_outputs["ql"]
- px_rate = generative_outputs["px_rate"]
- px_r = generative_outputs["px_r"]
- px_dropout = generative_outputs["px_dropout"]
-
- # mask loss to observed genes
- mapping_indices = self.indices_mappings[mode]
- reconstruction_loss = self.reconstruction_loss(
- x,
- px_rate[:, mapping_indices],
- px_r[:, mapping_indices],
- px_dropout[:, mapping_indices],
- mode,
- )
-
- # KL Divergence
- mean = torch.zeros_like(qz.loc)
- scale = torch.ones_like(qz.scale)
- kl_divergence_z = kl(qz, Normal(mean, scale)).sum(dim=1)
-
- if self.model_library_bools[mode]:
- library_log_means = getattr(self, f"library_log_means_{mode}")
- library_log_vars = getattr(self, f"library_log_vars_{mode}")
-
- local_library_log_means = F.linear(
- F.one_hot(batch_index.squeeze(-1), self.n_batch).float(), library_log_means
- )
- local_library_log_vars = F.linear(
- F.one_hot(batch_index.squeeze(-1), self.n_batch).float(), library_log_vars
- )
- kl_divergence_l = kl(
- ql,
- Normal(local_library_log_means, local_library_log_vars.sqrt()),
- ).sum(dim=1)
- else:
- kl_divergence_l = torch.zeros_like(kl_divergence_z)
-
- kl_local = kl_divergence_l + kl_divergence_z
-
- loss = torch.mean(reconstruction_loss + kl_weight * kl_local) * x.size(0)
-
- return LossOutput(loss=loss, reconstruction_loss=reconstruction_loss, kl_local=kl_local)
diff --git a/src/scvi/external/gimvi/_task.py b/src/scvi/external/gimvi/_task.py
deleted file mode 100644
index f6de370ac9..0000000000
--- a/src/scvi/external/gimvi/_task.py
+++ /dev/null
@@ -1,195 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Mapping
-from itertools import cycle
-from typing import TYPE_CHECKING
-
-import torch
-from torch.utils.data import DataLoader
-
-from scvi import REGISTRY_KEYS
-from scvi.module import Classifier
-from scvi.train import AdversarialTrainingPlan
-
-if TYPE_CHECKING:
- from collections.abc import Sequence
- from typing import Any
-
-
-class GIMVITrainingPlan(AdversarialTrainingPlan):
- """gimVI training plan."""
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- if kwargs["adversarial_classifier"] is True:
- self.n_output_classifier = 2
- self.adversarial_classifier = Classifier(
- n_input=self.module.n_latent,
- n_hidden=32,
- n_labels=self.n_output_classifier,
- n_layers=3,
- logits=True,
- )
- else:
- self.adversarial_classifier = kwargs["adversarial_classifier"]
- self.automatic_optimization = False
- self.validation_step_outputs = []
-
- def training_step(self, batch, batch_idx):
- """Training step."""
- kappa = (
- 1 - self.kl_weight
- if self.scale_adversarial_loss == "auto"
- else self.scale_adversarial_loss
- )
- opts = self.optimizers()
- if not isinstance(opts, list):
- opt1 = opts
- opt2 = None
- else:
- opt1, opt2 = opts
- # batch contains both data loader outputs
- loss_output_objs = []
- n_obs = 0
- zs = []
- for i, tensors in enumerate(batch):
- n_obs += tensors[REGISTRY_KEYS.X_KEY].shape[0]
- self.loss_kwargs.update({"kl_weight": self.kl_weight, "mode": i})
- inference_kwargs = {"mode": i}
- generative_kwargs = {"mode": i}
- inference_outputs, _, loss_output = self.forward(
- tensors,
- loss_kwargs=self.loss_kwargs,
- inference_kwargs=inference_kwargs,
- generative_kwargs=generative_kwargs,
- )
- zs.append(inference_outputs["z"])
- loss_output_objs.append(loss_output)
-
- loss = sum([scl.loss for scl in loss_output_objs])
- loss /= n_obs
- rec_loss = sum([scl.reconstruction_loss_sum for scl in loss_output_objs])
- kl = sum([scl.kl_local_sum for scl in loss_output_objs])
-
- # fool classifier if doing adversarial training
- batch_tensor = [
- torch.zeros((z.shape[0], 1), device=z.device) + i for i, z in enumerate(zs)
- ]
- if kappa > 0 and self.adversarial_classifier is not False:
- fool_loss = self.loss_adversarial_classifier(
- torch.cat(zs), torch.cat(batch_tensor).long(), False
- )
- loss += fool_loss * kappa
- opt1.zero_grad()
- self.manual_backward(loss)
- opt1.step()
- return_dict = {
- "loss": loss,
- "reconstruction_loss_sum": rec_loss,
- "kl_local_sum": kl,
- "kl_global": 0.0,
- "n_obs": n_obs,
- }
-
- # train adversarial classifier
- # this condition will not be met unless self.adversarial_classifier is not False
- if opt2 is not None:
- zs = []
- for i, tensors in enumerate(batch):
- inference_inputs = self.module._get_inference_input(tensors)
- inference_inputs.update({"mode": i})
- outputs = self.module.inference(**inference_inputs)
- zs.append(outputs["z"])
-
- batch_tensor = [
- torch.zeros((z.shape[0], 1), device=z.device) + i for i, z in enumerate(zs)
- ]
- loss = self.loss_adversarial_classifier(
- torch.cat(zs).detach(), torch.cat(batch_tensor).long(), True
- )
- loss *= kappa
- opt2.zero_grad()
- self.manual_backward(loss)
- opt2.step()
-
- return return_dict
-
- def validation_step(self, batch, batch_idx, dataloader_idx):
- """Validation step."""
- self.loss_kwargs.update({"kl_weight": self.kl_weight, "mode": dataloader_idx})
- inference_kwargs = {"mode": dataloader_idx}
- generative_kwargs = {"mode": dataloader_idx}
- _, _, loss_output = self.forward(
- batch,
- loss_kwargs=self.loss_kwargs,
- inference_kwargs=inference_kwargs,
- generative_kwargs=generative_kwargs,
- )
- reconstruction_loss = loss_output.reconstruction_loss_sum
- self.validation_step_outputs.append(
- {
- "reconstruction_loss_sum": reconstruction_loss,
- "kl_local_sum": loss_output.kl_local_sum,
- "kl_global": loss_output.kl_global,
- "n_obs": loss_output.n_obs_minibatch,
- }
- )
-
- def on_validation_epoch_end(self):
- """Aggregate validation step information."""
- super().on_validation_epoch_end()
- outputs = self.validation_step_outputs
- n_obs, elbo, rec_loss, kl_local = 0, 0, 0, 0
- for val_metrics in outputs:
- elbo += val_metrics["reconstruction_loss_sum"] + val_metrics["kl_local_sum"]
- rec_loss += val_metrics["reconstruction_loss_sum"]
- kl_local += val_metrics["kl_local_sum"]
- n_obs += val_metrics["n_obs"]
- # kl global same for each minibatch
- self.log("elbo_validation", elbo / n_obs)
- self.log("reconstruction_loss_validation", rec_loss / n_obs)
- self.log("kl_local_validation", kl_local / n_obs)
- self.log("kl_global_validation", 0.0)
- self.validation_step_outputs.clear() # free memory
-
-
-class CyclicMultiDataLoader(DataLoader):
- """Combine multiple data loaders by cycling shorter loaders to match the longest one."""
-
- def __init__(
- self,
- data_loaders: Mapping[str, DataLoader] | Sequence[DataLoader],
- **data_loader_kwargs: Any,
- ):
- self._returns_mapping = isinstance(data_loaders, Mapping)
- if self._returns_mapping:
- self.input_names = list(data_loaders.keys())
- self.data_loader_list = list(data_loaders.values())
- else:
- self.input_names = None
- self.data_loader_list = list(data_loaders)
-
- if not self.data_loader_list:
- raise ValueError("At least one data loader is required.")
-
- self.largest_train_dl_idx = max(
- range(len(self.data_loader_list)),
- key=lambda idx: len(self.data_loader_list[idx].indices),
- )
- self.largest_dl = self.data_loader_list[self.largest_train_dl_idx]
- super().__init__(self.largest_dl, **data_loader_kwargs)
-
- def __len__(self):
- return len(self.largest_dl)
-
- def __iter__(self):
- data_loaders = [
- dl if i == self.largest_train_dl_idx else cycle(dl)
- for i, dl in enumerate(self.data_loader_list)
- ]
-
- if self._returns_mapping:
- for batches in zip(*data_loaders, strict=False):
- yield dict(zip(self.input_names, batches, strict=False))
- else:
- yield from zip(*data_loaders, strict=False)
diff --git a/src/scvi/external/gimvi/_utils.py b/src/scvi/external/gimvi/_utils.py
deleted file mode 100644
index 4867927887..0000000000
--- a/src/scvi/external/gimvi/_utils.py
+++ /dev/null
@@ -1,115 +0,0 @@
-import os
-import pickle
-from typing import Literal
-
-import numpy as np
-import torch
-from anndata import AnnData, read_h5ad
-
-from scvi.data._download import _download
-
-
-def _load_legacy_saved_gimvi_files(
- dir_path: str,
- file_name_prefix: str,
- load_seq_adata: bool,
- load_spatial_adata: bool,
-) -> tuple[dict, np.ndarray, np.ndarray, dict, AnnData | None, AnnData | None]:
- model_path = os.path.join(dir_path, f"{file_name_prefix}model_params.pt")
- setup_dict_path = os.path.join(dir_path, f"{file_name_prefix}attr.pkl")
- seq_var_names_path = os.path.join(dir_path, f"{file_name_prefix}var_names_seq.csv")
- spatial_var_names_path = os.path.join(dir_path, f"{file_name_prefix}var_names_spatial.csv")
-
- model_state_dict = torch.load(model_path, map_location="cpu", weights_only=False)
-
- seq_var_names = np.genfromtxt(seq_var_names_path, delimiter=",", dtype=str)
- spatial_var_names = np.genfromtxt(spatial_var_names_path, delimiter=",", dtype=str)
-
- with open(setup_dict_path, "rb") as handle:
- attr_dict = pickle.load(handle)
-
- adata_seq, adata_spatial = None, None
- if load_seq_adata:
- seq_data_path = os.path.join(dir_path, f"{file_name_prefix}adata_seq.h5ad")
- if os.path.exists(seq_data_path):
- adata_seq = read_h5ad(seq_data_path)
- elif not os.path.exists(seq_data_path):
- Warning(
- "Save path contains no saved anndata and no adata was passed. "
- "Model will be loaded without anndata."
- )
- if load_spatial_adata:
- spatial_data_path = os.path.join(dir_path, f"{file_name_prefix}adata_spatial.h5ad")
- if os.path.exists(spatial_data_path):
- adata_spatial = read_h5ad(spatial_data_path)
- elif not os.path.exists(spatial_data_path):
- Warning(
- "Save path contains no saved anndata and no adata was passed. "
- "Model will be loaded without anndata."
- )
-
- return (
- model_state_dict,
- seq_var_names,
- spatial_var_names,
- attr_dict,
- adata_seq,
- adata_spatial,
- )
-
-
-def _load_saved_gimvi_files(
- dir_path: str,
- load_seq_adata: bool,
- load_spatial_adata: bool,
- prefix: str | None = None,
- map_location: Literal["cpu", "cuda"] | None = None,
- backup_url: str | None = None,
-) -> tuple[dict, dict, np.ndarray, np.ndarray, dict, AnnData | None, AnnData | None]:
- file_name_prefix = prefix or ""
-
- model_file_name = f"{file_name_prefix}model.pt"
- model_path = os.path.join(dir_path, model_file_name)
- try:
- _download(backup_url, dir_path, model_file_name)
- model = torch.load(model_path, map_location=map_location, weights_only=False)
- except FileNotFoundError as exc:
- raise ValueError(
- f"Failed to load model file at {model_path}. "
- "If attempting to load a saved model from >> adata = anndata.read_h5ad(path_to_anndata)
- >>> scvi.external.RESOLVI.setup_anndata(adata, batch_key="batch")
- >>> resolvi = scvi.external.RESOLVI(adata)
- >>> resolvi.train()
- >>> adata.obsm["X_resolVI"] = resolvi.get_latent_representation()
- >>> adata.layers["X_normalized_resolVI"] = resolvi.get_normalized_expression()
-
- Notes
- -----
- See further usage examples in the following tutorial:
-
- 1. :doc:`/tutorials/notebooks/spatial/resolVI_tutorial`
- """
-
- _module_cls = RESOLVAE
- _block_parameters = []
-
- def __init__(
- self,
- adata: AnnData,
- n_hidden: int = 32,
- n_hidden_encoder: int = 128,
- n_latent: int = 10,
- n_layers: int = 2,
- dropout_rate: float = 0.05,
- dispersion: Literal["gene", "gene-batch"] = "gene",
- gene_likelihood: Literal["nb", "poisson"] = "nb",
- background_ratio=None,
- median_distance=None,
- semisupervised=False,
- mixture_k=50,
- downsample_counts=True,
- **model_kwargs,
- ):
- warnings.warn(
- "RESOLVI is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- pyro.clear_param_store()
-
- super().__init__(adata)
- if semisupervised:
- self._set_indices_and_labels()
-
- results = self.compute_dataset_dependent_priors()
-
- n_cats_per_cov = (
- self.adata_manager.get_state_registry(REGISTRY_KEYS.CAT_COVS_KEY).n_cats_per_key
- if REGISTRY_KEYS.CAT_COVS_KEY in self.adata_manager.data_registry
- else None
- )
- n_labels = self.summary_stats.n_labels - 1
-
- if background_ratio is None:
- background_ratio = results["background_ratio"]
- if median_distance is None:
- median_distance = results["median_distance"]
- if downsample_counts:
- downsample_counts_mean = results["mean_log_counts"]
- downsample_counts_std = results["std_log_counts"]
- else:
- downsample_counts_mean = None
- downsample_counts_std = 1.0
-
- expression_anntorchdata = AnnTorchDataset(
- self.adata_manager,
- getitem_tensors=["X"],
- load_sparse_tensor=True,
- )
- self.module = self._module_cls(
- n_input=self.summary_stats.n_vars,
- n_batch=self.summary_stats.n_batch,
- n_cats_per_cov=n_cats_per_cov,
- n_labels=n_labels,
- mixture_k=mixture_k,
- expression_anntorchdata=expression_anntorchdata,
- n_neighbors=self.summary_stats.n_distance_neighbor,
- n_obs=self.summary_stats["n_ind_x"],
- n_hidden=n_hidden,
- n_hidden_encoder=n_hidden_encoder,
- n_latent=n_latent,
- n_layers=n_layers,
- dropout_rate=dropout_rate,
- dispersion=dispersion,
- gene_likelihood=gene_likelihood,
- background_ratio=background_ratio,
- median_distance=median_distance,
- semisupervised=semisupervised,
- downsample_counts_mean=downsample_counts_mean,
- downsample_counts_std=downsample_counts_std,
- **model_kwargs,
- )
- self._model_summary_string = (
- f"RESOLVI Model with the following params: \nn_hidden: {n_hidden} "
- f"n_latent: {n_latent}, n_layers: {n_layers}, dropout_rate: "
- f"{dropout_rate}, dispersion: {dispersion}, gene_likelihood: {gene_likelihood} "
- f"n_neighbors: {self.summary_stats.n_distance_neighbor}"
- )
- self.init_params_ = self._get_init_params(locals())
-
- def train(
- self,
- max_epochs: int = 50,
- lr: float = 3e-3,
- lr_extra: float = 1e-2,
- extra_lr_parameters: tuple = ("per_neighbor_diffusion_map", "u_prior_means"),
- batch_size: int = 512,
- weight_decay: float = 0.0,
- eps: float = 1e-4,
- n_steps_kl_warmup: int | None = None,
- n_epochs_kl_warmup: int | None = 20,
- plan_kwargs: dict | None = None,
- expose_params: list = (),
- **kwargs,
- ):
- """
- Trains the model using amortized variational inference.
-
- Parameters
- ----------
- max_epochs
- Number of passes through the dataset.
- lr
- Learning rate for optimization.
- lr_extra
- Learning rate for parameters (non-amortized and custom ones)
- extra_lr_parameters
- List of parameters to train with `lr_extra` learning rate.
- batch_size
- Minibatch size to use during training.
- weight_decay
- weight decay regularization term for optimization
- eps
- Optimizer eps
- n_steps_kl_warmup
- Number of training steps (minibatches) to scale weight on KL divergences from 0 to 1.
- Only activated when `n_epochs_kl_warmup` is set to None. If `None`, defaults
- to `floor(0.75 * adata.n_obs)`.
- n_epochs_kl_warmup
- Number of epochs to scale weight on KL divergences from 0 to 1.
- Overrides `n_steps_kl_warmup` when both are not `None`.
- plan_kwargs
- Keyword args for :class:`~resolvi.train.PyroTrainingPlan`. Keyword arguments passed to
- `train()` will overwrite values present in `plan_kwargs`, when appropriate.
- expose_params
- List of parameters to train if running model in Arches mode.
- **kwargs
- Other keyword args for :class:`~scvi.train.Trainer`.
-
- Notes
- -----
- RESOLVI trains with Pyro SVI and maintains per-cell global parameters, so it does not
- support a held-out validation set. ``train_size`` must be ``1.0`` and ``early_stopping``
- is not available.
- """
- train_size = kwargs.pop("train_size", 1.0)
- if train_size != 1.0:
- raise ValueError(
- "RESOLVI does not support a validation set: it uses Pyro SVI with per-cell "
- f"global parameters, so `train_size` must be 1.0 (got {train_size})."
- )
- if kwargs.pop("early_stopping", False):
- raise ValueError(
- "RESOLVI does not support `early_stopping` because it trains without a "
- "validation set (`train_size` must be 1.0)."
- )
-
- blocked = self._block_parameters.copy()
- for name, param in self.module.named_parameters():
- if not param.requires_grad:
- blocked.append(name)
- param.requires_grad = True
- blocked = set(blocked) - set(expose_params)
-
- if blocked:
- print("Running scArches. Set lr to 0 and blocking variables.")
-
- def per_param_callable(module_name, param_name):
- store_name = f"{module_name}$$${param_name}" if "." in param_name else param_name
- if store_name in blocked:
- return {"lr": 0.0, "weight_decay": 0, "eps": eps}
- if store_name in extra_lr_parameters:
- return {"lr": lr_extra, "weight_decay": weight_decay, "eps": eps}
- else:
- return {"lr": lr, "weight_decay": weight_decay, "eps": eps}
-
- optim = pyro.optim.Adam(per_param_callable)
-
- plan_kwargs = merge_kwargs(None, plan_kwargs, name="plan")
- plan_kwargs.update(
- {
- "optim_kwargs": {"lr": lr, "weight_decay": weight_decay, "eps": eps},
- "optim": optim,
- "blocked": blocked,
- "n_epochs_kl_warmup": n_epochs_kl_warmup
- if n_epochs_kl_warmup is not None
- else max_epochs,
- "n_steps_kl_warmup": n_steps_kl_warmup,
- "loss_fn": Trace_ELBO(
- num_particles=5, vectorize_particles=True, retain_graph=True
- ),
- }
- )
-
- super().train(
- max_epochs=max_epochs,
- train_size=1.0,
- plan_kwargs=plan_kwargs,
- batch_size=batch_size,
- **kwargs,
- )
-
- @classmethod
- @setup_anndata_dsp.dedent
- def setup_anndata(
- cls,
- adata: AnnData,
- layer: str | None = None,
- batch_key: str | None = None,
- labels_key: str | None = None,
- size_factor_key: str | None = None,
- categorical_covariate_keys: list[str] | None = None,
- prepare_data: bool | None = True,
- prepare_data_kwargs: dict = None,
- unlabeled_category: str = "unknown",
- **kwargs,
- ):
- """%(summary)s.
-
- Parameters
- ----------
- %(param_adata)s
- %(param_layer)s
- %(param_batch_key)s
- %(param_labels_key)s
- size_factor_key
- Key in ``adata.obs`` corresponding to pre-computed size factors.
- This is the physical size of a cell (e.g. cell volume) and will be used to replace the
- library size if size_scaling is True.
- %(param_cat_cov_keys)s
- prepare_data
- If True, prepares AnnData for training. Computes spatial neighbors and distances.
- prepare_data_kwargs
- Keyword args for :meth:`scvi.external.RESOLVI._prepare_data`
- %(param_unlabeled_category)s
- """
- setup_method_args = cls._get_setup_method_args(**locals())
- if layer is None:
- x = adata.X
- else:
- x = adata.layers[layer]
- assert np.min(x.sum(axis=1)) > 0, (
- "Please filter cells with less than 5 counts prior to running resolVI."
- )
- if batch_key is not None:
- adata.obs["_indices"] = (
- adata.obs[batch_key].astype(str) + "_" + adata.obs_names.astype(str)
- )
- else:
- adata.obs["_indices"] = adata.obs_names.astype(str)
- adata.obs["_indices"] = adata.obs["_indices"].astype("category")
- assert not adata.obs["_indices"].duplicated(keep="first").any(), (
- "obs_names need to be unique prior to running resolVI."
- )
- if labels_key is None:
- label_field = CategoricalObsField(REGISTRY_KEYS.LABELS_KEY, labels_key)
- else:
- label_field = LabelsWithUnlabeledObsField(
- REGISTRY_KEYS.LABELS_KEY, labels_key, unlabeled_category
- )
-
- if prepare_data:
- if prepare_data_kwargs is None:
- prepare_data_kwargs = {}
- effective_config = {"batch_key": batch_key, **prepare_data_kwargs}
- stored_config = adata.uns.get("_resolvi_prepare_data_config", None)
- neighbors_valid = (
- stored_config == effective_config
- and "index_neighbor" in adata.obsm
- and "distance_neighbor" in adata.obsm
- and int(adata.obsm["index_neighbor"].max()) < adata.n_obs
- )
- if not neighbors_valid:
- print(
- "Preparing data for training. This may take a while. "
- "RAPIDS SingleCell will be used if installed."
- )
- RESOLVI._prepare_data(adata, batch_key=batch_key, **prepare_data_kwargs)
- adata.uns["_resolvi_prepare_data_config"] = effective_config
-
- anndata_fields = [
- LayerField(REGISTRY_KEYS.X_KEY, layer, is_count_data=True),
- CategoricalObsField(REGISTRY_KEYS.BATCH_KEY, batch_key),
- NumericalObsField(REGISTRY_KEYS.SIZE_FACTOR_KEY, size_factor_key, required=False),
- ObsmField("index_neighbor", "index_neighbor"),
- ObsmField("distance_neighbor", "distance_neighbor"),
- CategoricalObsField(REGISTRY_KEYS.INDICES_KEY, "_indices"),
- label_field,
- CategoricalJointObsField(REGISTRY_KEYS.CAT_COVS_KEY, categorical_covariate_keys),
- ]
- adata_manager = AnnDataManager(fields=anndata_fields, setup_method_args=setup_method_args)
- adata_manager.register_fields(adata, **kwargs)
- cls.register_manager(adata_manager)
-
- @staticmethod
- def _prepare_data(
- adata, n_neighbors=10, spatial_rep="X_spatial", batch_key=None, slice_key=None, **kwargs
- ):
- if slice_key is not None:
- batch_key = slice_key
- try:
- import scanpy as sc
- from sklearn.neighbors._base import _kneighbors_from_graph
- except ImportError as err:
- raise ImportError(
- "Please install scanpy and scikit-learn -- `pip install scanpy`"
- ) from err
-
- # Spatial neighbors are batch dependent.
- if batch_key is None:
- indices = [np.arange(adata.n_obs)]
- else:
- indices = [
- np.where(adata.obs[batch_key] == i)[0] for i in adata.obs[batch_key].unique()
- ]
-
- distance_neighbor = 1e6 * np.ones([adata.n_obs, n_neighbors])
- index_neighbor = np.zeros([adata.n_obs, n_neighbors], dtype=int)
-
- for index in indices:
- sub_data = adata[index].copy()
- try:
- import rapids_singlecell as rsc
-
- print("RAPIDS SingleCell is installed and can be imported")
- rsc.pp.neighbors(sub_data, n_neighbors=n_neighbors + 5, use_rep=spatial_rep)
- except ImportError:
- sc.pp.neighbors(sub_data, n_neighbors=n_neighbors + 5, use_rep=spatial_rep)
- distances = sub_data.obsp["distances"] ** 2
-
- distance_neighbor[index, :], index_neighbor_batch = _kneighbors_from_graph(
- distances, n_neighbors, return_distance=True
- )
- index_neighbor[index, :] = index[index_neighbor_batch]
-
- adata.obsm["X_spatial"] = adata.obsm[spatial_rep]
- adata.obsm["index_neighbor"] = index_neighbor
- adata.obsm["distance_neighbor"] = distance_neighbor
-
- def compute_dataset_dependent_priors(self, n_small_genes=None):
- """Compute dataset-dependent prior parameters for the ResolVI model.
-
- Estimates background expression ratio and spatial kernel size from the data,
- which are used as priors during training.
-
- Parameters
- ----------
- n_small_genes
- Number of low-expressed genes used to estimate the background ratio.
- If ``None``, defaults to ``n_genes // 50``.
-
- Returns
- -------
- dict with keys ``"background_ratio"``, ``"median_distance"``,
- ``"mean_log_counts"``, and ``"std_log_counts"``.
- """
- x = self.adata_manager.get_from_registry(REGISTRY_KEYS.X_KEY)
- n_small_genes = x.shape[1] // 50 if n_small_genes is None else int(n_small_genes)
- # Computing library size over low-expressed genes (expectation for the background).
- # Handles sparse and dense counts.
- smallest_means = x[:, np.array(x.sum(0)).flatten().argsort()[:n_small_genes]].mean(
- 1
- ) / np.array(x.mean(1))
- background_ratio = np.mean(np.array(smallest_means))
-
- # Median distance for empiric expectation of kernel size in diffusion
- distance = self.adata_manager.get_from_registry("distance_neighbor")
- median_distance = np.median(np.partition(distance, 5)[:, 5])
- log_library_size = np.log1p(np.array(x.sum(1)))
- mean_log_counts = np.median(log_library_size)
- std_log_counts = np.std(log_library_size)
-
- return {
- "background_ratio": background_ratio,
- "median_distance": median_distance,
- "mean_log_counts": mean_log_counts,
- "std_log_counts": std_log_counts,
- }
-
- @de_dsp.dedent
- def differential_expression(
- self,
- adata: AnnData | None = None,
- groupby: str | None = None,
- group1: Iterable[str] | None = None,
- group2: str | None = None,
- idx1: Sequence[int] | Sequence[bool] | None = None,
- idx2: Sequence[int] | Sequence[bool] | None = None,
- subset_idx: Sequence[int] | None = None,
- mode: Literal["vanilla", "change"] = "change",
- delta: float = 0.25,
- batch_size: int | None = None,
- all_stats: bool = True,
- batch_correction: bool = False,
- batchid1: Iterable[str] | None = None,
- batchid2: Iterable[str] | None = None,
- fdr_target: float = 0.05,
- silent: bool = False,
- weights: Literal["uniform", "importance"] | None = "uniform",
- filter_outlier_cells: bool = False,
- n_samples: int = 5,
- size_scaling: bool = False,
- library_scaling: bool = False,
- **kwargs,
- ) -> pd.DataFrame:
- r"""A unified method for differential expression analysis.
-
- Implements `"vanilla"` DE :cite:p:`Lopez18` and `"change"` mode DE :cite:p:`Boyeau19`.
-
- Parameters
- ----------
- %(de_adata)s
- %(de_groupby)s
- %(de_group1)s
- %(de_group2)s
- %(de_idx1)s
- %(de_idx2)s
- %(de_subset_idx)s
- %(de_mode)s
- %(de_delta)s
- %(de_batch_size)s
- %(de_all_stats)s
- %(de_batch_correction)s
- %(de_batchid1)s
- %(de_batchid2)s
- %(de_fdr_target)s
- %(de_silent)s
- weights
- Precomputed weight for importance sampling. If `uniform` no importance sampling is
- performed.
- filter_outlier_cells
- Whether to filter outlier cells with
- :meth:`~scvi.model.base.DifferentialComputation.filter_outlier_cells
- n_samples
- Number of posterior samples to use for estimation.
- size_scaling
- If True, will scale normalized expression by size factors (e.g. cell volume).
- This needs to be setup in :meth:`~scvi.external.RESOLVI.setup_anndata` with
- `size_factor_key`. False by default.
- library_scaling
- If True, will scale normalized expression to library size. This is useful for skewed
- gene panels if library size normalization is detrimental. False by default.
- **kwargs
- Keyword args for :meth:`scvi.model.base.DifferentialComputation.get_bayes_factors`
-
- Returns
- -------
- Differential expression DataFrame.
- """
- adata = self._validate_anndata(adata)
-
- model_fn = partial(
- self.get_normalized_expression_importance
- if weights == "importance"
- else self.get_normalized_expression,
- return_numpy=True,
- n_samples=n_samples,
- batch_size=batch_size,
- weights=weights,
- return_mean=False,
- size_scaling=size_scaling,
- library_scaling=library_scaling,
- )
-
- representation_fn = self.get_latent_representation if filter_outlier_cells else None
-
- result = _de_core(
- adata_manager=self.get_anndata_manager(adata, required=True),
- model_fn=model_fn,
- representation_fn=representation_fn,
- groupby=groupby,
- group1=group1,
- group2=group2,
- idx1=idx1,
- idx2=idx2,
- subset_idx=subset_idx,
- all_stats=all_stats,
- all_stats_fn=scrna_raw_counts_properties,
- col_names=adata.var_names,
- mode=mode,
- batchid1=batchid1,
- batchid2=batchid2,
- delta=delta,
- batch_correction=batch_correction,
- fdr=fdr_target,
- silent=silent,
- **kwargs,
- )
-
- return result
-
- @de_dsp.dedent
- def differential_niche_abundance(
- self,
- adata: AnnData | None = None,
- groupby: str | None = None,
- group1: Iterable[str] | None = None,
- group2: str | None = None,
- neighbor_key: str | None = None,
- idx1: Sequence[int] | Sequence[bool] | None = None,
- idx2: Sequence[int] | Sequence[bool] | None = None,
- subset_idx: Sequence[int] | None = None,
- mode: Literal["vanilla", "change"] = "change",
- delta: float = 0.25,
- batch_size: int | None = None,
- fdr_target: float = 0.05,
- silent: bool = False,
- filter_outlier_cells: bool = False,
- pseudocounts: float = 1e-3,
- **kwargs,
- ) -> pd.DataFrame:
- r"""A unified method for niche differential abundance analysis.
-
- Implements `"vanilla"` DE :cite:p:`Lopez18` and `"change"` mode DE :cite:p:`Boyeau19`.
-
- Parameters
- ----------
- %(de_adata)s
- %(de_groupby)s
- %(de_group1)s
- %(de_group2)s
- neighbor_key
- Obsm key containing the spatial neighbors of each cell.
- %(de_idx1)s
- %(de_idx2)s
- %(de_subset_idx)s
- %(de_mode)s
- %(de_delta)s
- %(de_batch_size)s
- %(de_fdr_target)s
- %(de_silent)s
- filter_outlier_cells
- Whether to filter outlier cells with
- :meth:`~scvi.model.base.DifferentialComputation.filter_outlier_cells`
- pseudocounts
- pseudocount offset used for the mode `change`.
- When None, observations from non-expressed genes are used to estimate its value.
- **kwargs
- Keyword args for :meth:`scvi.model.base.DifferentialComputation.get_bayes_factors`
-
- Returns
- -------
- Differential expression DataFrame.
- """
- adata = self._validate_anndata(adata)
-
- model_fn = partial(
- self.get_neighbor_abundance,
- return_numpy=True,
- n_samples=5,
- batch_size=batch_size,
- return_mean=False,
- neighbor_key=neighbor_key,
- )
-
- representation_fn = self.get_latent_representation if filter_outlier_cells else None
-
- result = _de_core(
- adata_manager=self.get_anndata_manager(adata, required=True),
- model_fn=model_fn,
- representation_fn=representation_fn,
- groupby=groupby,
- group1=group1,
- group2=group2,
- idx1=idx1,
- idx2=idx2,
- subset_idx=subset_idx,
- all_stats=False,
- all_stats_fn=scrna_raw_counts_properties,
- col_names=self._label_mapping[:-1],
- mode=mode,
- batchid1=None,
- batchid2=None,
- delta=delta,
- batch_correction=False,
- fdr=fdr_target,
- silent=silent,
- pseudocounts=pseudocounts,
- **kwargs,
- )
-
- return result
-
- def predict(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- soft: bool = False,
- batch_size: int | None = 500,
- num_samples: int | None = 30,
- ) -> np.ndarray | pd.DataFrame:
- """
- Return cell label predictions.
-
- Parameters
- ----------
- adata
- AnnData object that has been registered via :meth:`~scvi.model.SCANVI.setup_anndata`.
- indices
- Subsample AnnData to these indices.
- soft
- If True, returns per class probabilities
- batch_size
- Minibatch size for data loading into the model. Defaults to `scvi.settings.batch_size`.
- num_samples
- Samples to draw from the posterior for cell-type prediction.
- """
- adata = self._validate_anndata(adata)
-
- if indices is None:
- indices = np.arange(adata.n_obs)
-
- sampled_prediction = self.sample_posterior(
- adata=adata,
- indices=indices,
- model=self.module.model_corrected,
- return_sites=["probs_prediction"],
- num_samples=num_samples,
- return_samples=False,
- batch_size=batch_size,
- summary_frequency=10,
- return_observed=True,
- )
- y_pred = sampled_prediction["post_sample_means"]["probs_prediction"]
-
- if not soft:
- y_pred = y_pred.argmax(axis=1)
- predictions = [self._code_to_label[p] for p in y_pred]
- return np.array(predictions)
- else:
- n_labels = len(y_pred[0])
- predictions = pd.DataFrame(
- y_pred,
- columns=self._label_mapping[:n_labels],
- index=adata.obs_names[indices],
- )
- return predictions
-
- def _set_indices_and_labels(self):
- """Set indices for labeled and unlabeled cells."""
- labels_state_registry = self.adata_manager.get_state_registry(REGISTRY_KEYS.LABELS_KEY)
- self.original_label_key = labels_state_registry.original_key
- self.unlabeled_category_ = labels_state_registry.unlabeled_category
-
- labels = get_anndata_attribute(
- self.adata,
- self.adata_manager.data_registry.labels.attr_name,
- self.original_label_key,
- ).ravel()
- self._label_mapping = labels_state_registry.categorical_mapping
-
- # set unlabeled and labeled indices
- self._unlabeled_indices = np.argwhere(labels == self.unlabeled_category_).ravel()
- self._labeled_indices = np.argwhere(labels != self.unlabeled_category_).ravel()
- self._code_to_label = dict(enumerate(self._label_mapping))
diff --git a/src/scvi/external/resolvi/_module.py b/src/scvi/external/resolvi/_module.py
deleted file mode 100644
index 0ef8fe3a00..0000000000
--- a/src/scvi/external/resolvi/_module.py
+++ /dev/null
@@ -1,1324 +0,0 @@
-"""Main module."""
-
-from collections.abc import Callable, Iterable
-from typing import Literal
-
-import numpy as np
-import pyro
-import torch
-import torch.nn.functional as F
-from pyro.distributions import (
- Categorical,
- Delta,
- Dirichlet,
- Exponential,
- Gamma,
- Independent,
- LogNormal,
- Multinomial,
- Normal,
- Poisson,
- constraints,
-)
-from pyro.nn import PyroModule
-
-from scvi import REGISTRY_KEYS
-from scvi.dataloaders import AnnTorchDataset
-from scvi.module._classifier import Classifier
-from scvi.module.base import PyroBaseModuleClass, auto_move_data
-from scvi.nn import DecoderSCVI, Encoder
-
-_RESOLVAE_PYRO_MODULE_NAME = "resolvae"
-
-
-class RESOLVAEModel(PyroModule):
- """A PyroModule that serves as the model for the RESOLVAE class.
-
- Parameters
- ----------
- n_input
- Number of input genes
- n_obs
- Number of total input cells
- n_neighbors
- Number of spatial neighbors to consider for diffusion.
- z_encoder
- Shared encoder between model (neighboring cells) and guide.
- expression_anntorchdata
- AnnTorchDataset containing expression data.
- n_batch
- Number of batches, if 0, no batch correction is performed.
- n_hidden
- Number of nodes per hidden layer
- n_latent
- Dimensionality of the latent space
- mixture_k
- Number of components in the Mixture-of-Gaussian prior
- n_layers
- Number of hidden layers used for encoder and decoder NNs
- n_cats_per_cov
- Number of categories for each extra categorical covariate
- n_labels
- Number of cell-type labels in the dataset
- dispersion
- One of the following
-
- * ``'gene'`` - dispersion parameter of NB is constant per gene across cells
- * ``'gene-batch'`` - dispersion can differ between different batches
- gene_likelihood
- One of
-
- * ``'nb'`` - Negative binomial distribution
- * ``'poisson'`` - Poisson distribution
- semisupervised
- Whether to use a semi-supervised model
- deeply_inject_covariates
- Whether to concatenate covariates into output of hidden layers in encoder/decoder.
- This option only applies when `n_layers` > 1.
- The covariates are concatenated to the input of subsequent hidden layers.
- use_batch_norm
- Whether to use batch norm in layers
- use_layer_norm
- Whether to use layer norm in layers
- classifier_parameters
- Parameters for the cell-type classifier
- var_activation
- Callable used to ensure positivity of the variational distributions' variance.
- When `None`, defaults to `torch.exp`.
- prior_true_amount
- Prior for true_proportion.
- Equals Gamma(prior_proportions_rate, prior_proportions_rate/prior_true_amount)
- Default is 1.0
- prior_diffusion_amount
- Prior for diffusion_proportion.
- Equals Gamma(prior_proportions_rate, prior_proportions_rate/prior_diffusion_amount)
- Default is 0.3
- sparsity_diffusion
- Prior for sparsity_diffusion. Controls the concentration of the Dirichlet distribution.
- Equals Gamma(prior_proportions_rate, prior_proportions_rate/sparsity_diffusion)
- Default is 3.0
- background_ratio:
- Prior for background_proportion
- Equals Gamma(prior_proportions_rate,
- prior_proportions_rate/(10*background_ratio*prior_true_amount))
- Default is 0.1
- prior_proportions_rate:
- Rate parameter for the prior proportions.
- median_distance:
- Kernel size in the RBF kernel to estimate distances between cells and neighbors.
- encode_covariates:
- Whether to concatenate covariates to expression in encoder
- size_scaling
- Scale counts by cell size factors instead of library size.
- """
-
- def __init__(
- self,
- n_input: int,
- n_obs: int,
- n_neighbors: int,
- z_encoder: Encoder,
- expression_anntorchdata: AnnTorchDataset,
- n_batch: int = 0,
- n_hidden: int = 32,
- n_latent: int = 10,
- mixture_k: int = 100,
- n_layers: int = 2,
- n_cats_per_cov: Iterable[int] | None = None,
- n_labels: Iterable[int] | None = None,
- dispersion: Literal["gene", "gene-batch"] = "gene",
- gene_likelihood: Literal["nb", "poisson"] = "nb",
- semisupervised: bool = False,
- deeply_inject_covariates: bool = True,
- use_batch_norm: Literal["encoder", "decoder", "none", "both"] = "none",
- use_layer_norm: Literal["encoder", "decoder", "none", "both"] = "both",
- classifier_parameters: dict | None = None,
- prior_true_amount: float = 1.0,
- prior_diffusion_amount: float = 0.3,
- sparsity_diffusion: float = 3.0,
- background_ratio: float = 0.1,
- prior_proportions_rate: float = 10.0,
- median_distance: float = 1.0,
- encode_covariates: bool = False,
- size_scaling: bool = False,
- ):
- super().__init__(_RESOLVAE_PYRO_MODULE_NAME)
- self.z_encoder = z_encoder
- self.expression_anntorchdata = expression_anntorchdata
- self.register_buffer("gene_dummy", torch.ones([n_batch, n_input]))
-
- self.dispersion = dispersion
- self.n_latent = n_latent
- self.mixture_k = mixture_k
- self.gene_likelihood = gene_likelihood
- self.n_batch = n_batch
- self.n_input = n_input
- self.n_obs = n_obs
- self.n_neighbors = n_neighbors
- self.expression_anntorchdata = expression_anntorchdata
- self.semisupervised = semisupervised
- self.eps = torch.tensor(1e-6)
- self.encode_covariates = encode_covariates
- self.size_scaling = size_scaling
-
- if self.dispersion == "gene":
- init_px_r = torch.full([n_input], 0.01)
- elif self.dispersion == "gene-batch":
- init_px_r = torch.full([n_input, n_batch], 0.01)
- else:
- raise ValueError(
- "dispersion must be one of ['gene', 'gene-batch', 'gene-label'], but input was "
- "{}.format(self.dispersion)"
- )
- self.register_buffer("px_r", init_px_r)
-
- self.register_buffer("median_distance", torch.tensor(median_distance))
- self.register_buffer("sparsity_diffusion", torch.tensor(sparsity_diffusion))
- self.register_buffer("gene_dummy", torch.ones([n_batch, n_input]))
-
- if self.semisupervised:
- mixture_k = n_labels
-
- self.register_buffer("u_prior_logits", torch.ones([mixture_k]))
- if self.semisupervised:
- self.register_buffer("u_prior_means", torch.zeros([mixture_k, n_latent]))
- self.register_buffer("u_prior_scales", torch.zeros([mixture_k, n_latent]))
- else:
- self.register_buffer("u_prior_means", torch.randn([mixture_k, n_latent]))
- self.register_buffer("u_prior_scales", torch.zeros([mixture_k, n_latent]) - 1.0)
-
- self.register_buffer("diffusion_scale", torch.tensor([1]))
- self.register_buffer(
- "prior_proportions",
- torch.tensor(
- [
- prior_true_amount,
- prior_diffusion_amount,
- 10 * background_ratio * prior_true_amount + 1e-3,
- ]
- ),
- )
- self.register_buffer("prior_proportions_rate", torch.tensor([prior_proportions_rate]))
-
- use_batch_norm_decoder = use_batch_norm == "decoder" or use_batch_norm == "both"
- use_layer_norm_decoder = use_layer_norm == "decoder" or use_layer_norm == "both"
-
- # z encoder goes from the n_input-dimensional data to an n_latent-d
- # latent space representation
- cat_list = [n_batch] + list([] if n_cats_per_cov is None else n_cats_per_cov)
-
- # decoder goes from n_latent-dimensional space to n_input-d data
- self.decoder = DecoderSCVI(
- n_latent,
- n_input,
- n_cat_list=cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden,
- inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm_decoder,
- use_layer_norm=use_layer_norm_decoder,
- )
-
- if self.semisupervised:
- classifier_parameters = classifier_parameters or {}
- self.n_labels = n_labels
- # Classifier takes n_latent as input
- cls_parameters = {
- "n_layers": 0,
- "n_hidden": 128,
- "dropout_rate": 0.0,
- }
-
- cls_parameters.update(classifier_parameters)
- self.classifier = Classifier(
- n_latent,
- n_labels=n_labels,
- use_batch_norm=False,
- use_layer_norm=True,
- **cls_parameters,
- )
- if self.size_scaling:
- self.scale_encoder = Encoder(
- n_latent,
- 1,
- n_cat_list=[n_batch],
- n_layers=0,
- n_hidden=128,
- dropout_rate=0.0,
- inject_covariates=deeply_inject_covariates,
- use_batch_norm=False,
- use_layer_norm=True,
- var_activation=torch.exp,
- var_eps=1e-6,
- )
-
- def _get_fn_args_from_batch(self, tensor_dict: dict[str, torch.Tensor]) -> Iterable | dict:
- x = tensor_dict[REGISTRY_KEYS.X_KEY]
- y = tensor_dict[REGISTRY_KEYS.LABELS_KEY].long().ravel()
- batch_index = tensor_dict[REGISTRY_KEYS.BATCH_KEY]
-
- cat_key = REGISTRY_KEYS.CAT_COVS_KEY
- cat_covs = tensor_dict[cat_key] if cat_key in tensor_dict.keys() else None
-
- ind_x = tensor_dict[REGISTRY_KEYS.INDICES_KEY].long().ravel()
- distances_n = tensor_dict["distance_neighbor"]
- ind_neighbors = tensor_dict["index_neighbor"].long()
-
- x_n = self.expression_anntorchdata[ind_neighbors.cpu().numpy().flatten(), :]["X"]
- if isinstance(x_n, np.ndarray):
- x_n = torch.from_numpy(x_n)
- x_n = x_n.to(x.device)
-
- if x.layout is torch.sparse_csr or x.layout is torch.sparse_csc:
- x = x.to_dense()
- if x_n.layout is torch.sparse_csr or x_n.layout is torch.sparse_csc:
- x_n = x_n.to_dense()
- x_n = x_n.reshape(x.shape[0], -1)
- library = torch.log(torch.sum(x, dim=1, keepdim=True))
- size_factor = tensor_dict.get(REGISTRY_KEYS.SIZE_FACTOR_KEY, None)
-
- return (), {
- "x": x,
- "ind_x": ind_x,
- "library": library,
- "y": y,
- "batch_index": batch_index,
- "cat_covs": cat_covs,
- "x_n": x_n,
- "distances_n": distances_n,
- "size_factor": size_factor,
- }
-
- @auto_move_data
- def model_unconditioned(
- self,
- x: torch.Tensor,
- ind_x: torch.Tensor,
- library: torch.Tensor,
- y: torch.Tensor,
- batch_index: torch.Tensor,
- cat_covs: torch.Tensor,
- x_n: torch.Tensor,
- distances_n: torch.Tensor,
- size_factor: torch.Tensor,
- n_obs: int | None = None,
- kl_weight: float = 1.0,
- ):
- """Full model."""
- sparsity_diffusion = pyro.sample(
- "sparsity_diffusion",
- Gamma(
- concentration=self.prior_proportions_rate,
- rate=self.prior_proportions_rate / self.sparsity_diffusion,
- ),
- ).mean()
-
- # Per gene background.
- per_gene_background = pyro.sample(
- "per_gene_background",
- Dirichlet(
- concentration=5.0 * self.gene_dummy,
- validate_args=False, # Softmax has rounding errors
- ).to_event(1),
- )
-
- prior_proportions = sparsity_diffusion * self.prior_proportions
-
- # Proportion of true counts
- true_proportion = pyro.sample(
- "true_proportion",
- Gamma(
- concentration=self.prior_proportions_rate,
- rate=self.prior_proportions_rate / prior_proportions[0],
- ),
- ).mean()
-
- # Background proportion
- background_proportion = pyro.sample(
- "background_proportion",
- Gamma(
- concentration=self.prior_proportions_rate,
- rate=self.prior_proportions_rate / prior_proportions[2],
- ),
- ).mean()
-
- # Diffusion proportion
- diffusion_proportion = pyro.sample(
- "diffusion_proportion",
- Gamma(
- concentration=self.prior_proportions_rate,
- rate=self.prior_proportions_rate / prior_proportions[1],
- ),
- ).mean()
-
- # Weights on which range diffusion happens compared to median distance.
- diffusion_scale = pyro.sample("diffuse_scale", Exponential(x.new_ones([1])).to_event(1))
-
- u_prior_logits = pyro.param("u_prior_logits", self.u_prior_logits)
- u_prior_means = pyro.param("u_prior_means", self.u_prior_means)
- u_prior_scales = pyro.param("u_prior_scales", self.u_prior_scales)
-
- with pyro.plate("obs_plate", size=n_obs or self.n_obs, subsample_size=x.shape[0], dim=-1):
- # Expected dispersion given distance between cells
- distances = 30.0 * pyro.deterministic(
- "distances",
- torch.exp(
- -torch.clamp(diffusion_scale * distances_n / self.median_distance, max=20.0)
- )
- + 1e-3,
- event_dim=1,
- ) # clamping here as otherwise gradient not defined
- px_r = 1 / pyro.sample("px_r_inv", Exponential(torch.ones_like(x)).to_event(1))
-
- per_neighbor_diffusion = pyro.sample(
- "per_neighbor_diffusion",
- Dirichlet(
- concentration=distances, validate_args=False
- ), # Softmax has rounding errors
- )
- with pyro.poutine.scale(scale=5.0):
- mixture_proportions = pyro.sample(
- "mixture_proportions",
- Dirichlet(
- concentration=torch.tensor(
- [true_proportion, diffusion_proportion, background_proportion],
- device=x.device,
- ),
- validate_args=False, # Softmax has rounding errors
- ),
- )
- true, diffusion, background = mixture_proportions.split(1, dim=-1)
- true_mixture_proportion = pyro.deterministic("true_mixture_proportion", true)
- diffusion_mixture_proportion = pyro.deterministic(
- "diffusion_mixture_proportion", diffusion
- )
- background_mixture_proportion = pyro.deterministic(
- "background_mixture_proportion", background
- )
- v = pyro.deterministic(
- "diffusion_proportion_per_neighbor",
- per_neighbor_diffusion.unsqueeze(-1) * diffusion_mixture_proportion.unsqueeze(-2),
- )
-
- if self.semisupervised:
- logits_input = (
- torch.stack(
- [
- torch.nn.functional.one_hot(y_i, self.n_labels)
- if y_i < self.n_labels
- else torch.zeros(self.n_labels).to(x.device)
- for y_i in y
- ]
- )
- .to(x.device)
- .float()
- )
- u_prior_logits = u_prior_logits + 10 * logits_input
- u_prior_means = u_prior_means.expand(x.shape[0], -1, -1)
- u_prior_scales = u_prior_scales.expand(x.shape[0], -1, -1)
- cats = Categorical(logits=u_prior_logits)
-
- normal_dists = Independent(
- Normal(u_prior_means, torch.exp(self.u_prior_scales) + 1e-4),
- reinterpreted_batch_ndims=1,
- )
-
- # sample from prior (value will be sampled by guide when computing the ELBO)
- with pyro.poutine.scale(scale=kl_weight):
- z = pyro.sample("latent", pyro.distributions.MixtureSameFamily(cats, normal_dists))
- if self.size_scaling:
- if size_factor is not None:
- library = torch.log(self.scale_encoder(z, batch_index)[1] * size_factor)
- else:
- raise ValueError(
- "size_scaling is True but no size_factor_key was provided "
- "in setup_anndata."
- )
- # get the "normalized" mean of the negative binomial
- if cat_covs is not None:
- categorical_input = list(torch.split(cat_covs, 1, dim=1))
- else:
- categorical_input = ()
- px_scale, _, px_rate, _ = self.decoder(
- self.dispersion,
- z,
- library,
- batch_index,
- *categorical_input,
- )
-
- if self.semisupervised:
- probs_prediction_ = self.classifier(z)
-
- # Stored for use in residual model.
- px_rate = pyro.deterministic("px_rate", px_rate, event_dim=1)
- pyro.deterministic("px_scale", px_scale, event_dim=1)
-
- # Set model to eval mode. Best estimate of neighbor cells.
- # Autoencoder for all neighboring cells. Autoencoder is learned above.
-
- # sample from prior for neighboring cells (mode collapse when gradient used)
- with torch.no_grad():
- if cat_covs is not None:
- categorical_input = [
- i.repeat_interleave(self.n_neighbors).unsqueeze(1)
- for i in torch.split(cat_covs, 1, dim=1)
- ]
- else:
- categorical_input = ()
- if cat_covs is not None and self.encode_covariates:
- categorical_encoder = categorical_input
- else:
- categorical_encoder = ()
-
- qz_m_n, qz_v_n, _ = self.z_encoder(
- torch.reshape(
- torch.log1p(x_n / torch.mean(x_n, dim=1, keepdim=True)),
- (x.shape[0] * self.n_neighbors, x.shape[1]),
- ),
- batch_index.repeat_interleave(self.n_neighbors).unsqueeze(1),
- *categorical_encoder,
- )
- if z.ndim == 2:
- zn = Normal(
- qz_m_n.reshape(x.shape[0], self.n_neighbors, self.n_latent),
- torch.sqrt(qz_v_n.reshape(x.shape[0], self.n_neighbors, self.n_latent)),
- ).sample()
- if self.size_scaling:
- library_n = torch.log(
- self.scale_encoder(
- qz_m_n,
- batch_index.repeat_interleave(self.n_neighbors).unsqueeze(1),
- )[1]
- * size_factor.repeat_interleave(self.n_neighbors).unsqueeze(1)
- )
- else:
- library_n = library.repeat_interleave(self.n_neighbors).unsqueeze(1)
- _, _, px_rate_n, _ = self.decoder(
- self.dispersion,
- zn.reshape([x.shape[0] * self.n_neighbors, self.n_latent]),
- library_n,
- batch_index.repeat_interleave(self.n_neighbors).unsqueeze(1),
- *categorical_input,
- )
- px_rate_n = px_rate_n.reshape([x.shape[0], self.n_neighbors, self.n_input])
- else:
- zn = Normal(
- qz_m_n.reshape(x.shape[0], self.n_neighbors, self.n_latent),
- torch.sqrt(qz_v_n.reshape(x.shape[0], self.n_neighbors, self.n_latent)),
- ).sample([z.shape[0]])
- if self.size_scaling:
- library_n = torch.log(
- self.scale_encoder(
- qz_m_n,
- batch_index.repeat_interleave(self.n_neighbors).unsqueeze(1),
- )[1]
- * size_factor.repeat_interleave(self.n_neighbors).unsqueeze(1)
- )
- else:
- library_n = library.repeat_interleave(self.n_neighbors).unsqueeze(1)
- _, _, px_rate_n, _ = self.decoder(
- self.dispersion,
- zn.reshape([z.shape[0], x.shape[0] * self.n_neighbors, self.n_latent]),
- library_n,
- batch_index.repeat_interleave(self.n_neighbors).unsqueeze(1),
- *categorical_input,
- )
- px_rate_n = px_rate_n.reshape(
- [z.shape[0], x.shape[0], self.n_neighbors, self.n_input]
- )
-
- px_rate_n = pyro.deterministic("px_rate_n", px_rate_n, event_dim=2)
-
- # Collecting all means. Sample by v from neighboring cells.
- px_rate_sum = torch.sum(
- torch.cat(
- [
- (true_mixture_proportion * px_rate).unsqueeze(-2),
- v * px_rate_n,
- ],
- dim=-2,
- ),
- dim=-2,
- )
- if self.gene_likelihood == "poisson":
- mean_nb = Delta(px_rate_sum, event_dim=1).rsample()
- else:
- mean_nb = (
- Gamma(concentration=px_r, rate=px_r / (px_rate_sum + self.eps))
- .to_event(1)
- .rsample()
- )
- background = pyro.deterministic(
- "background",
- background_mixture_proportion
- * torch.exp(library)
- * torch.matmul(
- torch.nn.functional.one_hot(batch_index.flatten(), self.n_batch).float(),
- per_gene_background,
- ),
- event_dim=1,
- )
-
- mean_poisson = pyro.deterministic(
- "mean_poisson",
- mean_nb + background,
- event_dim=1, # batch_size, n_genes
- )
-
- # Sample count distribution
- pyro.sample("obs", Poisson(mean_poisson + 1e-9).to_event(1))
-
- if self.semisupervised:
- probs_prediction = pyro.deterministic(
- "probs_prediction",
- probs_prediction_,
- event_dim=1, # batch_size, n_labels
- )
-
- # Last label is unknown class.
- is_observed = y != self.n_labels
- valid_data = y.clone()
- valid_data[~is_observed] = 0
-
- with pyro.poutine.scale(scale=50.0):
- with pyro.poutine.mask(mask=is_observed):
- pyro.sample(
- "prediction", Categorical(probs=probs_prediction), obs=valid_data
- )
-
- @auto_move_data
- def forward(
- self,
- x: torch.Tensor,
- ind_x: torch.Tensor,
- library: torch.Tensor,
- y: torch.Tensor,
- batch_index: torch.Tensor,
- cat_covs: torch.Tensor,
- x_n: torch.Tensor,
- distances_n: torch.Tensor,
- size_factor: torch.Tensor,
- n_obs: int | None = None,
- kl_weight: float = 1.0,
- ):
- """Forward pass."""
- # Using condition handle for training, this is the reconstruction loss.
- pyro.condition(self.model_unconditioned, data={"obs": x})(
- x,
- ind_x,
- library,
- y,
- batch_index,
- cat_covs,
- x_n,
- distances_n,
- size_factor,
- n_obs,
- kl_weight,
- )
-
- @auto_move_data
- def model_corrected(
- self,
- x: torch.Tensor,
- ind_x: torch.Tensor,
- library: torch.Tensor,
- y: torch.Tensor,
- batch_index: torch.Tensor,
- cat_covs: torch.Tensor,
- x_n: torch.Tensor,
- distances_n: torch.Tensor,
- size_factor: torch.Tensor,
- n_obs: int | None = None,
- kl_weight: float = 1.0,
- ):
- pyro.condition(
- self.model_unconditioned,
- data={
- "background_mixture_proportion": torch.zeros([x.shape[0], 1], device=x.device),
- "diffusion_mixture_proportion": torch.zeros([x.shape[0], 1], device=x.device),
- "true_mixture_proportion": torch.ones([x.shape[0], 1], device=x.device),
- },
- )(
- x,
- ind_x,
- library,
- y,
- batch_index,
- cat_covs,
- x_n,
- distances_n,
- size_factor,
- n_obs,
- kl_weight,
- )
-
- @auto_move_data
- def model_residuals(
- self,
- x: torch.Tensor,
- ind_x: torch.Tensor,
- library: torch.Tensor,
- y: torch.Tensor,
- batch_index: torch.Tensor,
- cat_covs: torch.Tensor,
- x_n: torch.Tensor,
- distances_n: torch.Tensor,
- size_factor: torch.Tensor,
- n_obs: int | None = None,
- kl_weight: float = 1.0,
- ):
- pyro.condition(
- self.model_unconditioned,
- data={
- "true_mixture_proportion": torch.zeros([x.shape[0], 1], device=x.device),
- },
- )(
- x,
- ind_x,
- library,
- y,
- batch_index,
- cat_covs,
- x_n,
- distances_n,
- size_factor,
- n_obs,
- kl_weight,
- )
-
- @auto_move_data
- def model_simplified(
- self,
- x: torch.Tensor,
- ind_x: torch.Tensor,
- library: torch.Tensor,
- y: torch.Tensor,
- batch_index: torch.Tensor,
- cat_covs: torch.Tensor,
- x_n: torch.Tensor,
- distances_n: torch.Tensor,
- size_factor: torch.Tensor,
- n_obs: int | None = None,
- kl_weight: float = 1.0,
- corrected_rate: bool = False,
- observed_rate: torch.Tensor = None,
- ):
- if observed_rate is not None:
- x = observed_rate
-
- hide = [
- "per_gene_background",
- "diffuse_scale",
- "sparsity_diffusion",
- "per_neighbor_diffusion",
- "mixture_proportions",
- "distances",
- "px_r_inv",
- "prediction",
- "true_proportion",
- "background_proportion",
- "diffusion_proportion",
- ]
- simplified_model = pyro.poutine.block(self.model_unconditioned, hide=hide)
-
- with pyro.poutine.scale(scale=0.01 * x.shape[0] / self.n_obs):
- if corrected_rate:
- pyro.condition(
- simplified_model,
- data={
- "background_mixture_proportion": torch.zeros(
- [x.shape[0], 1], device=x.device
- ),
- "diffusion_mixture_proportion": torch.zeros(
- [x.shape[0], 1], device=x.device
- ),
- "true_mixture_proportion": torch.ones([x.shape[0], 1], device=x.device),
- },
- )(
- x,
- ind_x,
- library,
- y,
- batch_index,
- cat_covs,
- x_n,
- distances_n,
- size_factor,
- n_obs,
- kl_weight,
- )
- else:
- simplified_model(
- x,
- ind_x,
- library,
- y,
- batch_index,
- cat_covs,
- x_n,
- distances_n,
- size_factor,
- n_obs,
- kl_weight,
- )
-
-
-class RESOLVAEGuide(PyroModule):
- """A PyroModule that serves as the guide for the RESOLVAE class.
-
- Parameters
- ----------
- n_input
- Number of input genes
- n_obs
- Number of total input cells
- n_neighbors
- Number of spatial neighbors to consider for diffusion.
- z_encoder
- Shared encoder between model (neighboring cells) and guide.
- n_latent
- Dimensionality of the latent space.
- n_batch
- Number of batches, if 0, no batch correction is performed.
- n_layers
- Number of hidden layers used for encoder and decoder NNs.
- n_hidden_encoder
- Number of nodes per hidden layer in the encoder.
- n_cats_per_cov
- Number of categories for each extra categorical covariate.
- dispersion
- One of the following
-
- * ``'gene'`` - dispersion parameter of NB is constant per gene across cells
- * ``'gene-batch'`` - dispersion can differ between different batches
- downsample_counts_mean
- Mean of the log-normal distribution used to downsample counts.
- downsample_counts_std
- Standard deviation of the log-normal distribution used to downsample counts.
- encode_covariates
- Whether to concatenate covariates to expression in encoder
- deeply_inject_covariates
- Whether to concatenate covariates into output of hidden layers in encoder/decoder.
- This option only applies when `n_layers` > 1.
- The covariates are concatenated to the input of subsequent hidden layers.
- use_batch_norm
- Whether to use batch norm in layers
- use_layer_norm
- Whether to use layer norm in layers
- use_size_factor_key
- Use size_factor AnnDataField defined by the user as scaling factor.
- Takes priority over `use_observed_lib_size`.
- use_observed_lib_size
- Use observed library size for RNA as scaling factor in mean of conditional distribution
- median_distance:
- Kernel size in the RBF kernel to estimate distances between cells and neighbors.
- diffusion_eps:
- Epsilon value for diffusion.
- """
-
- def __init__(
- self,
- n_input: int,
- n_obs: int,
- n_neighbors: int,
- z_encoder: Encoder,
- n_batch: int = 0,
- n_latent: int = 10,
- n_layers: int = 2,
- n_hidden_encoder: int = 128,
- n_cats_per_cov: Iterable[int] | None = None,
- dispersion: Literal["gene", "gene-batch"] = "gene",
- downsample_counts_mean: int | None = None,
- downsample_counts_std: float = 1.0,
- encode_covariates: bool = False,
- deeply_inject_covariates: bool = True,
- use_batch_norm: Literal["encoder", "decoder", "none", "both"] = "none",
- use_layer_norm: Literal["encoder", "decoder", "none", "both"] = "both",
- median_distance: float = 1.0,
- diffusion_eps: float = 0.01,
- ):
- super().__init__(_RESOLVAE_PYRO_MODULE_NAME)
- self.dispersion = dispersion
- self.z_encoder = z_encoder
- self.n_latent = n_latent
- self.n_batch = n_batch
- self.encode_covariates = encode_covariates
- self.n_input = n_input
- self.n_obs = n_obs
- self.n_neighbors = n_neighbors
- self.median_distance = median_distance
- self.downsample_counts_mean = downsample_counts_mean
- self.downsample_counts_std = downsample_counts_std
-
- if self.dispersion == "gene":
- init_px_r = torch.full([n_input], 0.01)
- elif self.dispersion == "gene-batch":
- init_px_r = torch.full([n_input, n_batch], 0.01)
- else:
- raise ValueError(
- f"dispersion must be one of ['gene', 'gene-batch'], but input was {dispersion}."
- )
- self.register_buffer("px_r", init_px_r)
- self.register_buffer("per_neighbor_diffusion_init", torch.zeros([n_obs, n_neighbors]))
- self.register_buffer("gene_dummy", torch.ones([n_batch, n_input]))
- self.eps = torch.tensor(1e-6)
- self.diffusion_eps = diffusion_eps
-
- use_batch_norm_encoder = use_batch_norm == "encoder" or use_batch_norm == "both"
- use_layer_norm_encoder = use_layer_norm == "encoder" or use_layer_norm == "both"
-
- # z encoder goes from the n_input-dimensional data to an n_latent-d
- # latent space representation
- cat_list = [n_batch] + list([] if n_cats_per_cov is None else n_cats_per_cov)
- encoder_cat_list = cat_list if encode_covariates else None
-
- self.diffusion_encoder = Encoder(
- n_input,
- 3,
- n_cat_list=encoder_cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden_encoder,
- dropout_rate=0.0,
- inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm_encoder,
- use_layer_norm=use_layer_norm_encoder,
- var_activation=torch.nn.Softmax(dim=-1),
- var_eps=1e-3,
- )
-
- @auto_move_data
- def forward( # not used arguments to have same set of arguments in model and guide
- self,
- x,
- ind_x,
- library,
- y,
- batch_index,
- cat_covs,
- x_n,
- distances_n,
- size_factor,
- n_obs=None,
- kl_weight=1.0,
- ):
- # Amount of true in total counts of Dirichlet
- sparsity_diffusion_est = pyro.param(
- "sparsity_diffusion_est",
- 20.0 * x.new_ones([1]),
- constraint=constraints.greater_than(1e-3),
- )
- pyro.sample("sparsity_diffusion", Delta(sparsity_diffusion_est))
-
- background_concentration = torch.softmax(
- pyro.param("background_concentration", self.gene_dummy, event_dim=2),
- dim=-1,
- )
-
- # Per gene poisson rate for background
- pyro.sample("per_gene_background", Delta(background_concentration, event_dim=2))
-
- # Amount of background in total counts of Dirichlet
- background_proportion_est = pyro.param(
- "background_proportion_est",
- 0.5 * x.new_ones([1]),
- constraint=constraints.greater_than(1e-6),
- )
- pyro.sample("background_proportion", Delta(background_proportion_est))
-
- # Amount of diffusion in total counts of Dirichlet
- diffusion_proportion_est = pyro.param(
- "diffusion_proportion_est",
- 3.0 * x.new_ones([1]),
- constraint=constraints.greater_than(1e-6),
- )
- pyro.sample("diffusion_proportion", Delta(diffusion_proportion_est))
-
- # Amount of true in total counts of Dirichlet
- true_proportion_est = pyro.param(
- "true_proportion_est", 5.0 * x.new_ones([1]), constraint=constraints.greater_than(1e-6)
- )
- pyro.sample("true_proportion", Delta(true_proportion_est))
-
- # Weights to how many neighbors diffusion happens in relation to median distance.
- diffusion_scale_est = pyro.param(
- "diffuse_scale_est",
- x.new_ones([1]),
- constraint=constraints.greater_than(self.eps),
- event_dim=1,
- )
- pyro.sample("diffuse_scale", Delta(diffusion_scale_est, event_dim=1))
-
- # Weights to which neighbor diffusion happens.
- per_neighbor_diffusion = pyro.param(
- "per_neighbor_diffusion_map",
- self.per_neighbor_diffusion_init,
- constraint=constraints.interval(-10.0, 10.0),
- event_dim=1,
- )
-
- if self.downsample_counts_mean is not None:
- downsample_counts = (
- int(
- LogNormal(
- torch.tensor(float(self.downsample_counts_mean)),
- torch.tensor(float(self.downsample_counts_std)),
- ).sample()
- )
- + 10
- )
-
- with pyro.plate("obs_plate", size=n_obs or self.n_obs, subsample=ind_x, dim=-1):
- # Dispersion of NB for counts.
- px_r_mle = pyro.param(
- "px_r_mle",
- self.px_r,
- constraint=constraints.greater_than(self.eps),
- event_dim=len(self.px_r.shape),
- )
-
- if self.dispersion == "gene-batch":
- px_r_inv = F.linear(
- torch.nn.functional.one_hot(batch_index.flatten(), self.n_batch).to(
- px_r_mle.dtype
- ),
- px_r_mle,
- )
- elif self.dispersion == "gene":
- px_r_inv = px_r_mle
- pyro.sample("px_r_inv", Delta(px_r_inv, event_dim=1))
- # Expected diffusion given distance between cells
- concentration = torch.nn.Softmax(dim=-1)(
- per_neighbor_diffusion[ind_x, :]
- - torch.clamp(torch.sqrt(distances_n / self.median_distance), max=10.0)
- )
-
- pyro.sample("per_neighbor_diffusion", Delta(concentration, event_dim=1))
-
- if cat_covs is not None and self.encode_covariates:
- categorical_input = list(torch.split(cat_covs, 1, dim=1))
- else:
- categorical_input = ()
-
- with pyro.poutine.scale(scale=5.0):
- _, mixture_proportions_est, _ = self.diffusion_encoder(
- torch.log1p(x), batch_index, *categorical_input
- )
- # Set minimum diffusion to 0.01. This helps with stability
- mixture_proportions_est[..., 1] += self.diffusion_eps
- pyro.sample("mixture_proportions", Delta(mixture_proportions_est, event_dim=1))
- with pyro.poutine.scale(scale=kl_weight):
- # use the encoder to get the parameters used to define q(z|x)
- if self.training and self.downsample_counts_mean is not None:
- x = Multinomial(total_count=downsample_counts, probs=x).sample()
- qz_m, qz_v, _ = self.z_encoder(
- torch.log1p(x / torch.mean(x, dim=1, keepdim=True)),
- batch_index,
- *categorical_input,
- )
- # sample the latent code z
- pyro.sample("latent", Normal(qz_m, torch.sqrt(qz_v)).to_event(1))
-
- @auto_move_data
- def guide_simplified(
- self,
- x: torch.Tensor,
- ind_x: torch.Tensor,
- library: torch.Tensor,
- y: torch.Tensor,
- batch_index: torch.Tensor,
- cat_covs: torch.Tensor,
- x_n: torch.Tensor,
- distances_n: torch.Tensor,
- size_factor: torch.Tensor,
- n_obs: int | None = None,
- kl_weight: float = 1.0,
- ):
- simplified_guide = pyro.poutine.block(
- self.forward,
- expose=["latent"],
- hide=[
- "sparsity_diffusion",
- "per_gene_background",
- "background_proportion",
- "diffusion_proportion",
- "true_proportion",
- "diffuse_scale",
- "px_r_inv",
- "per_neighbor_diffusion",
- "mixture_proportions",
- ],
- )
-
- with pyro.poutine.scale(scale=x.shape[0] / self.n_obs):
- simplified_guide(
- x,
- ind_x,
- library,
- y,
- batch_index,
- cat_covs,
- x_n,
- distances_n,
- size_factor,
- n_obs,
- kl_weight,
- )
-
-
-class RESOLVAE(PyroBaseModuleClass):
- """
- Implementation of resolVI.
-
- Parameters
- ----------
- n_input
- Number of input genes
- n_obs
- Number of total input cells
- n_neighbors
- Number of spatial neighbors to consider for diffusion.
- expression_anntorchdata
- AnnTorchDataset with expression data.
- n_batch
- Number of batches, if 0, no batch correction is performed.
- n_hidden
- Number of nodes per hidden layer in the decoder
- n_hidden_encoder
- Number of nodes per hidden layer in the encoder
- n_latent
- Dimensionality of the latent space
- mixture_k
- Number of components in the Mixture-of-Gaussian prior
- n_layers
- Number of hidden layers used for encoder and decoder NNs
- n_cats_per_cov
- Number of categories for each extra categorical covariate
- n_labels
- Number of cell-type labels in the dataset
- dropout_rate
- Dropout rate for neural networks
- dispersion
- One of the following
-
- * ``'gene'`` - dispersion parameter of NB is constant per gene across cells
- * ``'gene-batch'`` - dispersion can differ between different batches
- gene_likelihood
- One of
- * ``'nb'`` - Negative binomial distribution
- * ``'poisson'`` - Poisson distribution
- semi_supervised
- Whether to use a semi-supervised model
- encode_covariates
- Whether to concatenate covariates to expression in encoder
- deeply_inject_covariates
- Whether to concatenate covariates into output of hidden layers in encoder/decoder.
- This option only applies when `n_layers` > 1.
- The covariates are concatenated to the input of subsequent hidden layers.
- use_batch_norm
- Whether to use batch norm in layers
- use_layer_norm
- Whether to use layer norm in layers
- var_activation
- Callable used to ensure positivity of the variational distributions' variance.
- When `None`, defaults to `torch.exp`.
- classifier_parameters
- Parameters for the classifier
- prior_true_amount
- Prior for true_proportion.
- Equals Gamma(prior_proportions_rate, prior_proportions_rate/prior_true_amount)
- Default is 1.0
- prior_diffusion_amount
- Prior for diffusion_proportion.
- Equals Gamma(prior_proportions_rate, prior_proportions_rate/prior_diffusion_amount)
- Default is 0.3
- sparsity_diffusion
- Prior for sparsity_diffusion. Controls the concentration of the Dirichlet distribution.
- Equals Gamma(prior_proportions_rate, prior_proportions_rate/sparsity_diffusion)
- Default is 3.0
- background_ratio:
- Prior for background_proportion
- Equals Gamma(prior_proportions_rate,
- prior_proportions_rate/(10*background_ratio*prior_true_amount))
- Default is 0.1
- prior_proportions_rate:
- Rate parameter for the prior proportions.
- median_distance:
- Kernel size in the RBF kernel to estimate distances between cells and neighbors.
- size_scaling
- Instead of using observed library size, use cell area and a cell-specific scaling factor.
- downsample_counts_mean:
- Mean of the log-normal distribution used to downsample counts.
- downsample_counts_std:
- Standard deviation of the log-normal distribution used to downsample counts.
- diffusion_eps:
- Epsilon value for diffusion. Creates an offset to stabilize training.
- encode_covariates:
- Whether to concatenate covariates to expression in encoder
- latent_distribution:
- Placeholder for compatibility with other models.
- """
-
- def __init__(
- self,
- n_input: int,
- n_obs: int,
- n_neighbors: int,
- expression_anntorchdata: AnnTorchDataset,
- n_batch: int = 0,
- n_hidden: int = 32,
- n_hidden_encoder: int = 128,
- n_latent: int = 10,
- mixture_k: int = 30,
- n_layers: int = 2,
- n_cats_per_cov: Iterable[int] | None = None,
- n_labels: Iterable[int] | None = None,
- dropout_rate: float = 0.05,
- dispersion: Literal["gene", "gene-batch"] = "gene",
- gene_likelihood: Literal["nb", "poisson"] = "nb",
- semisupervised: bool = False,
- encode_covariates: bool = False,
- deeply_inject_covariates: bool = True,
- use_batch_norm: Literal["encoder", "decoder", "none", "both"] = "none",
- use_layer_norm: Literal["encoder", "decoder", "none", "both"] = "both",
- var_activation: Callable | None = None,
- classifier_parameters: dict | None = None,
- prior_true_amount: float = 1.0,
- prior_diffusion_amount: float = 0.3,
- sparsity_diffusion: float = 3.0,
- background_ratio: float = 0.1,
- prior_proportions_rate: float = 10.0,
- median_distance: float = 1.0,
- downsample_counts_mean: float | None = None,
- downsample_counts_std: float = 1.0,
- diffusion_eps: float = 0.01,
- latent_distribution: str | None = None, # placeholder
- size_scaling: bool = False,
- ):
- super().__init__()
- self.dispersion = dispersion
- self.n_latent = n_latent
- self.mixture_k = mixture_k
- self.gene_likelihood = gene_likelihood
- self.n_batch = n_batch
- self.n_input = n_input
- self.n_obs = n_obs
- self.n_neighbors = n_neighbors
- self.expression_anntorchdata = expression_anntorchdata
- self.semisupervised = semisupervised
- self.eps = torch.tensor(1e-6)
- self.encode_covariates = encode_covariates
-
- use_batch_norm_encoder = use_batch_norm == "decoder" or use_batch_norm == "both"
- use_layer_norm_encoder = use_layer_norm == "decoder" or use_layer_norm == "both"
-
- # z encoder goes from the n_input-dimensional data to an n_latent-d
- # latent space representation
- cat_list = [n_batch] + list([] if n_cats_per_cov is None else n_cats_per_cov)
- encoder_cat_list = cat_list if self.encode_covariates else None
-
- self.z_encoder = Encoder(
- n_input,
- n_latent,
- n_cat_list=encoder_cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden_encoder,
- dropout_rate=dropout_rate,
- inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm_encoder,
- use_layer_norm=use_layer_norm_encoder,
- var_activation=var_activation,
- )
-
- self._guide = RESOLVAEGuide(
- z_encoder=self.z_encoder,
- n_input=n_input,
- n_obs=n_obs,
- n_neighbors=n_neighbors,
- n_batch=n_batch,
- n_latent=n_latent,
- n_layers=n_layers,
- n_hidden_encoder=n_hidden_encoder,
- n_cats_per_cov=n_cats_per_cov,
- dispersion=dispersion,
- encode_covariates=encode_covariates,
- deeply_inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm,
- use_layer_norm=use_layer_norm,
- median_distance=median_distance,
- downsample_counts_mean=downsample_counts_mean,
- downsample_counts_std=downsample_counts_std,
- diffusion_eps=diffusion_eps,
- )
-
- self._model = RESOLVAEModel(
- n_input=n_input,
- n_obs=n_obs,
- n_neighbors=n_neighbors,
- z_encoder=self.z_encoder,
- expression_anntorchdata=expression_anntorchdata,
- n_batch=n_batch,
- n_hidden=n_hidden,
- n_latent=n_latent,
- mixture_k=mixture_k,
- n_layers=n_layers,
- n_cats_per_cov=n_cats_per_cov,
- n_labels=n_labels,
- dispersion=dispersion,
- gene_likelihood=gene_likelihood,
- semisupervised=semisupervised,
- deeply_inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm,
- use_layer_norm=use_layer_norm,
- classifier_parameters=classifier_parameters,
- prior_true_amount=prior_true_amount,
- prior_diffusion_amount=prior_diffusion_amount,
- sparsity_diffusion=sparsity_diffusion,
- background_ratio=background_ratio,
- prior_proportions_rate=prior_proportions_rate,
- median_distance=median_distance,
- size_scaling=size_scaling,
- )
- self._get_fn_args_from_batch = self._model._get_fn_args_from_batch
-
- @property
- def model(self):
- return self._model
-
- @property
- def model_corrected(self):
- return self._model.model_corrected
-
- @property
- def model_residuals(self):
- return self._model.model_residuals
-
- @property
- def model_unconditioned(self):
- return self._model.model_unconditioned
-
- @property
- def model_simplified(self):
- return self._model.model_simplified
-
- @property
- def guide(self):
- return self._guide
-
- @property
- def guide_simplified(self):
- return self._model.guide_simplified
-
- @property
- def list_obs_plate_vars(self):
- """
- Simplified plates adopted from Cell2location.
-
- 1. "name" - the name of observation/minibatch plate;
- 2. "event_dim" - the number of event dimensions.
- """
- return {
- "name": "obs_plate",
- "event_dim": 1,
- }
diff --git a/src/scvi/external/resolvi/_utils.py b/src/scvi/external/resolvi/_utils.py
deleted file mode 100644
index f7e2aa958e..0000000000
--- a/src/scvi/external/resolvi/_utils.py
+++ /dev/null
@@ -1,564 +0,0 @@
-import logging
-import warnings
-from collections.abc import Sequence
-from functools import partial
-
-import numpy as np
-import pandas as pd
-import torch
-from anndata import AnnData
-from pyro import infer
-
-from scvi import settings
-from scvi.model._utils import _get_batch_code_from_category, parse_device_args
-from scvi.utils import track
-
-logger = logging.getLogger(__name__)
-
-
-class ResolVIPredictiveMixin:
- """Mixin class for generating samples from posterior distribution using infer.predictive."""
-
- @torch.inference_mode()
- def get_latent_representation(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- give_mean: bool = True,
- mc_samples: int = 1, # consistency, noqa, pylint: disable=unused-argument
- batch_size: int | None = None,
- return_dist: bool = False,
- ) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
- """
- Return the latent representation for each cell.
-
- This is denoted as :math:`z` in RESOLVI.
-
- Parameters
- ----------
- adata
- AnnData object with equivalent structure to initial AnnData. If `None`, defaults to the
- AnnData object used to initialize the model.
- indices
- Indices of cells in adata to use. If `None`, all cells are used.
- give_mean
- Give mean of distribution or sample from it.
- mc_samples
- For consistency with scVI, this parameter is ignored.
- batch_size
- Minibatch size for data loading into model. Defaults to `scvi.settings.batch_size`.
- return_dist
- Return the distribution parameters of the latent variables rather than their sampled
- values. If `True`, ignores `give_mean` and `mc_samples`.
-
- Returns
- -------
- Low-dimensional representation for each cell or a tuple containing its mean and variance.
- """
- adata = self._validate_anndata(adata)
- scdl = self._make_data_loader(adata=adata, indices=indices, batch_size=batch_size)
- latent = []
- latent_qzm = []
- latent_qzv = []
-
- _, _, device = parse_device_args(
- accelerator="auto",
- devices="auto",
- return_device="torch",
- validate_single_device=True,
- )
-
- for tensors in scdl:
- _, kwargs = self.module._get_fn_args_from_batch(tensors)
- kwargs = {k: v.to(device) if v is not None else v for k, v in kwargs.items()}
-
- if kwargs["cat_covs"] is not None and self.module.encode_covariates:
- categorical_input = list(torch.split(kwargs["cat_covs"], 1, dim=1))
- else:
- categorical_input = ()
-
- qz_m, qz_v, z = self.module.z_encoder(
- torch.log1p(kwargs["x"] / torch.mean(kwargs["x"], dim=1, keepdim=True)),
- kwargs["batch_index"],
- *categorical_input,
- )
- qz = torch.distributions.Normal(qz_m, qz_v.sqrt())
- if give_mean:
- z = qz.loc
-
- latent += [z.cpu()]
- latent_qzm += [qz.loc.cpu()]
- latent_qzv += [qz.scale.square().cpu()]
- return (
- (torch.cat(latent_qzm).numpy(), torch.cat(latent_qzv).numpy())
- if return_dist
- else torch.cat(latent).numpy()
- )
-
- @torch.inference_mode()
- def get_normalized_expression_importance(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- transform_batch: Sequence[int | str] | None = None,
- gene_list: Sequence[str] | None = None,
- library_size: float | None = 1,
- n_samples: int = 30,
- n_samples_overall: int = None,
- batch_size: int | None = None,
- weights: str | np.ndarray | None = None,
- return_mean: bool = True,
- return_numpy: bool | None = None,
- library_scaling: bool = False,
- size_scaling: bool = False,
- ) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
- r"""Returns the normalized (decoded) importance-sampled gene expression.
-
- This is denoted as :math:`\rho_n` in the scVI paper.
-
- Parameters
- ----------
- adata
- AnnData object with equivalent structure to initial AnnData. If `None`, defaults to the
- AnnData object used to initialize the model.
- indices
- Indices of cells in adata to use. If `None`, all cells are used.
- transform_batch
- Not supported. Here for consistency with other functions.
- gene_list
- Return frequencies of expression for a subset of genes.
- This can save memory when working with large datasets and few genes are
- of interest.
- library_size
- Scale the expression frequencies to a common library size.
- This allows gene expression levels to be interpreted on a common scale of relevant
- magnitude.
- n_samples
- Number of posterior samples to use for estimation.
- n_samples_overall
- Number of posterior samples to use for estimation. Overrides `n_samples`.
- batch_size
- Minibatch size for data loading into model. Defaults to `scvi.settings.batch_size`.
- weights
- Precomputed weight for importance sampling. If `uniform` no importance sampling is
- performed.
- return_mean
- Whether to return the mean of the samples.
- return_numpy
- Return a :class:`~numpy.ndarray` instead of a :class:`~pandas.DataFrame`. DataFrame
- includes gene names as columns. If either `n_samples=1` or `return_mean=True`, defaults
- to `False`. Otherwise, it defaults to `True`.
- library_scaling
- If `True`, multiplies the decoded expression by the library size.
- size_scaling
- If `True`, divides the decoded expression by the size factor (e.g. cell_area).
- Requires that a size factor key was provided in
- :meth:`~scvi.external.RESOLVI.setup_anndata`.
- %(de_silent)s
-
- Returns
- -------
- If `n_samples` is provided and `return_mean` is False,
- this method returns a 3d tensor of shape (n_samples, n_cells, n_genes).
- If `n_samples` is provided and `return_mean` is True, it returns a 2d tensor
- of shape (n_cells, n_genes).
- In this case, return type is :class:`~pandas.DataFrame` unless `return_numpy` is True.
- Otherwise, the method expects `n_samples_overall` to be provided and returns a 2d tensor
- of shape (n_samples_overall, n_genes) sampled by importance.
- """
- adata = self._validate_anndata(adata)
-
- if indices is None:
- indices = np.arange(adata.n_obs)
- scdl = self._make_data_loader(adata=adata, indices=indices, batch_size=batch_size)
-
- if transform_batch is not None:
- warnings.warn(
- "`transform_batch` is not supported by "
- "`get_normalized_expression_importance` and will be ignored.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
-
- gene_mask = slice(None) if gene_list is None else adata.var_names.isin(gene_list)
-
- if n_samples > 1 and return_mean is False:
- if return_numpy is False:
- warnings.warn(
- "`return_numpy` must be `True` if `n_samples > 1` and `return_mean` "
- "is`False`, returning an `np.ndarray`.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- return_numpy = True
-
- exprs = []
- weighting = []
-
- _, _, device = parse_device_args(
- accelerator="auto",
- devices="auto",
- return_device="torch",
- validate_single_device=True,
- )
-
- for tensors in scdl:
- args, kwargs = self.module._get_fn_args_from_batch(tensors)
- kwargs = {k: v.to(device) if v is not None else v for k, v in kwargs.items()}
- model_now = partial(self.module.model_simplified, corrected_rate=True)
- importance_dist = infer.Importance(
- model_now, guide=self.module.guide.guide_simplified, num_samples=10 * n_samples
- )
- posterior = importance_dist.run(*args, **kwargs)
- marginal = infer.EmpiricalMarginal(posterior, sites=["mean_poisson", "px_scale"])
- samples = torch.cat([marginal().unsqueeze(1) for i in range(n_samples)], 1)
- log_weights = (
- torch.distributions.Poisson(samples[0, ...] + 1e-3)
- .log_prob(kwargs["x"].to(samples.device))
- .sum(-1)
- )
- log_weights = log_weights / kwargs["x"].to(samples.device).sum(-1)
- weighting.append(log_weights.reshape(-1).cpu())
- if library_scaling or size_scaling:
- if size_scaling:
- if "size_factor" in self.adata_manager.data_registry:
- size_factor = kwargs["size_factor"]
- samples[0, ...] = samples[0, ...] / size_factor.unsqueeze(0).repeat(
- n_samples, 1, 1
- )
- else:
- raise ValueError(
- "size_scaling is True but no size_factor_key was provided "
- "in setup_anndata."
- )
- exprs.append(samples[0, ...].cpu())
- else:
- exprs.append(samples[1, ...].cpu())
- exprs = torch.cat(exprs, axis=1).numpy()
- exprs = exprs[..., gene_mask]
- if return_mean:
- exprs = exprs.mean(0)
- weighting = torch.cat(weighting, axis=0).numpy()
- if library_size is not None:
- exprs = library_size * exprs
-
- if n_samples_overall is not None:
- # Converts the 3d tensor to a 2d tensor
- exprs = exprs.reshape(-1, exprs.shape[-1])
- n_samples_ = exprs.shape[0]
- if weights == "uniform":
- p = None
- else:
- weighting -= weighting.max()
- weighting = np.exp(weighting)
- p = weighting / weighting.sum(axis=0, keepdims=True)
-
- ind_ = np.random.choice(n_samples_, n_samples_overall, p=p, replace=True)
- exprs = exprs[ind_]
-
- if return_numpy is None or return_numpy is False:
- return pd.DataFrame(
- exprs,
- columns=adata.var_names[gene_mask],
- index=adata.obs_names[indices],
- )
- else:
- return exprs
-
- @torch.inference_mode()
- def get_normalized_expression(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- transform_batch: Sequence[int | str] | None = None,
- gene_list: Sequence[str] | None = None,
- library_size: float | None = 1,
- size_scaling: bool = False,
- n_samples: int = 1,
- n_samples_overall: int = None,
- batch_size: int | None = None,
- return_mean: bool = True,
- return_numpy: bool | None = None,
- silent: bool = True,
- **kwargs,
- ) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
- r"""Returns the normalized (decoded) gene expression.
-
- This is denoted as :math:`\rho_n` in the scVI paper.
-
- Parameters
- ----------
- adata
- AnnData object with equivalent structure to initial AnnData. If `None`, defaults to the
- AnnData object used to initialize the model.
- indices
- Indices of cells in adata to use. If `None`, all cells are used.
- transform_batch
- Batch to condition on.
- If transform_batch is:
-
- - None, then real observed batch is used.
- - int, then batch transform_batch is used.
- gene_list
- Return frequencies of expression for a subset of genes.
- This can save memory when working with large datasets and few genes are
- of interest.
- library_size
- Scale the expression frequencies to a common library size.
- This allows gene expression levels to be interpreted on a common scale of relevant
- magnitude.
- size_scaling
- If `True`, divides the decoded expression by the size factor (e.g. cell_area).
- Requires that a size factor key was provided in
- :meth:`~scvi.external.RESOLVI.setup_anndata`.
- n_samples
- Number of posterior samples to use for estimation.
- n_samples_overall
- Number of posterior samples to use for estimation. Overrides `n_samples`.
- batch_size
- Minibatch size for data loading into model. Defaults to `scvi.settings.batch_size`.
- return_mean
- Whether to return the mean of the samples.
- return_numpy
- Return a :class:`~numpy.ndarray` instead of a :class:`~pandas.DataFrame`. DataFrame
- includes gene names as columns. If either `n_samples=1` or `return_mean=True`, defaults
- to `False`. Otherwise, it defaults to `True`.
- %(de_silent)s
- **kwargs
- Additional keyword arguments passed
-
- Returns
- -------
- If `n_samples` is provided and `return_mean` is False,
- this method returns a 3d tensor of shape (n_samples, n_cells, n_genes).
- If `n_samples` is provided and `return_mean` is True, it returns a 2d tensor
- of shape (n_cells, n_genes).
- In this case, return type is :class:`~pandas.DataFrame` unless `return_numpy` is True.
- Otherwise, the method expects `n_samples_overall` to be provided and returns a 2d tensor
- of shape (n_samples_overall, n_genes).
- """
- adata = self._validate_anndata(adata)
-
- if indices is None:
- indices = np.arange(adata.n_obs)
- scdl = self._make_data_loader(adata=adata, indices=indices, batch_size=batch_size)
-
- transform_batch = _get_batch_code_from_category(
- self.get_anndata_manager(adata, required=True), transform_batch
- )
-
- gene_mask = slice(None) if gene_list is None else adata.var_names.isin(gene_list)
-
- if n_samples > 1 and return_mean is False:
- if return_numpy is False:
- warnings.warn(
- "`return_numpy` must be `True` if `n_samples > 1` and `return_mean` "
- "is`False`, returning an `np.ndarray`.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- return_numpy = True
-
- exprs = []
-
- _, _, device = parse_device_args(
- accelerator="auto",
- devices="auto",
- return_device="torch",
- validate_single_device=True,
- )
-
- for tensors in scdl:
- per_batch_exprs = []
- for batch in track(transform_batch, disable=silent):
- _, kwargs = self.module._get_fn_args_from_batch(tensors)
- kwargs = {k: v.to(device) if v is not None else v for k, v in kwargs.items()}
-
- if kwargs["cat_covs"] is not None and self.module.encode_covariates:
- categorical_input = list(torch.split(kwargs["cat_covs"], 1, dim=1))
- else:
- categorical_input = ()
-
- qz_m, qz_v, _ = self.module.z_encoder(
- torch.log1p(kwargs["x"] / torch.mean(kwargs["x"], dim=1, keepdim=True)),
- kwargs["batch_index"],
- *categorical_input,
- )
- z = torch.distributions.Normal(qz_m, qz_v.sqrt()).sample([n_samples])
-
- if kwargs["cat_covs"] is not None:
- categorical_input = list(torch.split(kwargs["cat_covs"], 1, dim=1))
- else:
- categorical_input = ()
- if batch is not None:
- batch = torch.full_like(kwargs["batch_index"], batch)
- else:
- batch = kwargs["batch_index"]
-
- px_scale, _, px_rate, _ = self.module.model.decoder(
- self.module.model.dispersion, z, kwargs["library"], batch, *categorical_input
- )
- if size_scaling:
- if "size_factor" in self.adata_manager.data_registry:
- size_factor = kwargs["size_factor"]
- px_rate = px_rate / size_factor.reshape(-1, 1, 1)
- exp_ = px_rate
- else:
- raise ValueError(
- "size_scaling is True but no size_factor_key was provided "
- "in setup_anndata."
- )
- else:
- if library_size is not None:
- exp_ = library_size * px_scale
- else:
- exp_ = px_rate
-
- exp_ = exp_[..., gene_mask]
- per_batch_exprs.append(exp_[None].cpu())
- per_batch_exprs = torch.cat(per_batch_exprs, dim=0).mean(0).numpy()
- exprs.append(per_batch_exprs)
-
- exprs = np.concatenate(exprs, axis=1)
- if return_mean:
- exprs = exprs.mean(0)
-
- if n_samples_overall is not None:
- # Converts the 3d tensor to a 2d tensor
- exprs = exprs.reshape(-1, exprs.shape[-1])
- n_samples_ = exprs.shape[0]
- ind_ = np.random.choice(n_samples_, n_samples_overall, replace=True)
- exprs = exprs[ind_]
-
- if return_numpy is None or return_numpy is False:
- return pd.DataFrame(
- exprs,
- columns=adata.var_names[gene_mask],
- index=adata.obs_names[indices],
- )
- else:
- return exprs
-
- @torch.inference_mode()
- def get_neighbor_abundance(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- neighbor_key: str | None = None,
- n_samples: int = 1,
- n_samples_overall: int = None,
- batch_size: int | None = None,
- summary_frequency: int = 2,
- weights: str | None = None,
- return_mean: bool = True,
- return_numpy: bool | None = None,
- **kwargs,
- ) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
- r"""Returns the abundance of cell-types within spatial proximity of center cells.
-
- Parameters
- ----------
- adata
- AnnData object with equivalent structure to initial AnnData. If `None`, defaults to the
- AnnData object used to initialize the model.
- indices
- Indices of cells in adata to use. If `None`, all cells are used.
- neighbor_key
- Obsm key containing the spatial neighbors of each cell.
- n_samples
- Number of posterior samples to use for estimation.
- n_samples_overall
- Number of posterior samples to use for estimation. Overrides `n_samples`.
- batch_size
- Minibatch size for data loading into model. Defaults to `scvi.settings.batch_size`.
- summary_frequency
- Compute summary_fn after summary_frequency batches. Reduces memory footprint.
- weights
- Spatial weights for each neighbor. If `None` performs no spatial weighting.
- Needs to be of shape `n_cells` by `n_neighbors`.
- return_mean
- Whether to return the mean of the samples.
- return_numpy
- Return a :class:`~numpy.ndarray` instead of a :class:`~pandas.DataFrame`. DataFrame
- includes gene names as columns. If either `n_samples=1` or `return_mean=True`, defaults
- to `False`. Otherwise, it defaults to `True`.
- kwargs
- Additional keyword arguments that have no effect and only serve for compatibility.
-
- Returns
- -------
- If `n_samples` is provided and `return_mean` is False,
- this method returns a 3d tensor of shape (n_samples, n_cells, n_celltypes).
- If `n_samples` is provided and `return_mean` is True, it returns a 2d tensor
- of shape (n_cells, n_celltypes).
- In this case, return type is :class:`~pandas.DataFrame` unless `return_numpy` is True.
- Otherwise, the method expects `n_samples_overall` to be provided and returns a 2d tensor
- of shape (n_samples_overall, n_celltypes).
- """
- if adata:
- assert neighbor_key is not None, "Must provide `neighbor_key` if `adata` is provided."
- adata = self._validate_anndata(adata)
- if indices is None:
- indices = np.arange(adata.n_obs)
- if neighbor_key is None:
- neighbor_key = self.adata_manager.registry["field_registries"]["index_neighbor"][
- "data_registry"
- ]["attr_key"]
- neighbor_obsm = adata.obsm[neighbor_key]
- else:
- neighbor_obsm = adata.obsm[neighbor_key]
- n_neighbors = neighbor_obsm.shape[-1]
-
- if n_samples > 1 and return_mean is False:
- if return_numpy is False:
- warnings.warn(
- "`return_numpy` must be `True` if `n_samples > 1` and `return_mean` "
- "is `False`, returning an `np.ndarray`.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- return_numpy = True
-
- if batch_size is not None:
- if batch_size % n_neighbors != 0:
- raise ValueError("Batch size must be divisible by the number of neighbors.")
- batch_size = batch_size if batch_size is not None else n_neighbors * settings.batch_size
- indices_ = neighbor_obsm[indices].reshape(-1)
- dl = self._make_data_loader(
- adata=adata, indices=indices_, shuffle=False, batch_size=batch_size
- )
-
- sampled_prediction = self.sample_posterior(
- input_dl=dl,
- model=self.module.model_corrected,
- return_sites=["probs_prediction"],
- summary_frequency=summary_frequency,
- num_samples=n_samples,
- return_samples=True,
- )
- flat_neighbor_abundance_ = sampled_prediction["posterior_samples"]["probs_prediction"]
- neighbor_abundance_ = flat_neighbor_abundance_.reshape(
- n_samples, len(indices), n_neighbors, -1
- )
- neighbor_abundance = np.average(neighbor_abundance_, axis=-2, weights=weights)
-
- if return_mean:
- neighbor_abundance = np.mean(neighbor_abundance, axis=0)
-
- if n_samples_overall is not None:
- # Converts the 3d tensor to a 2d tensor
- neighbor_abundance = neighbor_abundance.reshape(-1, neighbor_abundance.shape[-1])
- n_samples_ = neighbor_abundance.shape[0]
- ind_ = np.random.choice(n_samples_, n_samples_overall, replace=True)
- neighbor_abundance = neighbor_abundance[ind_]
-
- if return_numpy is None or return_numpy is False:
- assert return_mean, "Only numpy output is supported when `return_mean` is False."
- n_labels = len(neighbor_abundance[-1])
- return pd.DataFrame(
- neighbor_abundance,
- columns=self._label_mapping[:n_labels],
- index=adata.obs_names[indices],
- )
- else:
- return neighbor_abundance
diff --git a/src/scvi/external/scviva/__init__.py b/src/scvi/external/scviva/__init__.py
deleted file mode 100644
index 3241c3bb3e..0000000000
--- a/src/scvi/external/scviva/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from ._components import DirichletDecoder, NicheDecoder
-from ._constants import SCVIVA_REGISTRY_KEYS
-from ._model import SCVIVA
-from ._module import NicheLossOutput, nicheVAE
-
-__all__ = [
- "SCVIVA",
- "nicheVAE",
- "NicheDecoder",
- "NicheLossOutput",
- "DirichletDecoder",
- "SCVIVA_REGISTRY_KEYS",
-]
diff --git a/src/scvi/external/scviva/_components.py b/src/scvi/external/scviva/_components.py
deleted file mode 100644
index cf61af0b9b..0000000000
--- a/src/scvi/external/scviva/_components.py
+++ /dev/null
@@ -1,262 +0,0 @@
-from collections.abc import Iterable
-
-import torch
-from torch import nn
-from torch.distributions import Dirichlet, Normal
-
-from scvi.nn import Decoder, FCLayers
-
-
-def _identity(x):
- return x
-
-
-# Encoder
-class Encoder(nn.Module):
- """Encode data of ``n_input`` dimensions into a latent space of ``n_output`` dimensions.
-
- Uses a fully-connected neural network of ``n_hidden`` layers.
-
- Parameters
- ----------
- n_input
- The dimensionality of the input (data space)
- n_output
- The dimensionality of the output (latent space)
- n_cat_list
- A list containing the number of categories
- for each category of interest. Each category will be
- included using a one-hot encoding
- n_layers
- The number of fully-connected hidden layers
- n_hidden
- The number of nodes per hidden layer
- dropout_rate
- Dropout rate to apply to each of the hidden layers
- distribution
- Distribution of z
- var_eps
- Minimum value for the variance;
- used for numerical stability
- return_dist
- Return directly the distribution of z instead of its parameters.
- **kwargs
- Keyword args for :class:`~scvi.nn.FCLayers`
- """
-
- def __init__(
- self,
- n_input: int,
- n_output: int,
- n_cat_list: Iterable[int] = None,
- n_layers: int = 1,
- n_hidden: int = 128,
- dropout_rate: float = 0.1,
- distribution: str = "normal",
- var_eps: float = 1e-4,
- return_dist: bool = False,
- **kwargs,
- ):
- super().__init__()
-
- self.distribution = distribution
- self.var_eps = var_eps
- self.encoder = FCLayers(
- n_in=n_input,
- n_out=n_hidden,
- n_cat_list=n_cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden,
- dropout_rate=dropout_rate,
- **kwargs,
- )
-
- self.dist_encoder = nn.Linear(n_hidden, 2 * n_output)
-
- self.return_dist = return_dist
-
- if distribution == "ln":
- self.z_transformation = nn.Softmax(dim=-1)
- else:
- self.z_transformation = _identity
-
- def forward(self, x: torch.Tensor, *cat_list: int):
- r"""The forward computation for a single sample.
-
- #. Encodes the data into latent space using the encoder network
- #. Generates a mean \\( q_m \\) and variance \\( q_v \\)
- #. Samples a new value from an i.i.d. multivariate normal \\(
- # \\sim Ne(q_m, \\mathbf{I}q_v) \\)
-
- Parameters
- ----------
- x
- tensor with shape (n_input,)
- cat_list
- list of category membership(s) for this sample
-
- Returns
- -------
- 3-tuple of :py:class:`torch.Tensor`
- tensors of shape ``(n_latent,)`` for mean and var, and sample
-
- """
- # Parameters for latent distribution
- q = self.encoder(x, *cat_list)
-
- q_m, q_v = self.dist_encoder(q).chunk(2, dim=-1)
- q_v = torch.nn.Softplus()(q_v) + self.var_eps
-
- dist = Normal(q_m, q_v.sqrt())
- latent = self.z_transformation(dist.rsample())
- if self.return_dist:
- return dist, latent
- return q_m, q_v, latent
-
-
-# Decoders
-class DirichletDecoder(Decoder):
- """Predict the cell type composition from the latent space
-
- Parameters
- ----------
- n_input
- The dimensionality of the input (latent space dimension)
- n_output
- The dimensionality of the output (number of cell type)
- n_cat_list
- A list containing the number of categories for each category of interest.
- Each category will be included using a one-hot encoding
- n_layers
- The number of fully-connected hidden layers
- n_hidden
- The number of nodes per hidden layer
- concentration_eps
- Minimum value for the concentration; used for numerical stability
- **kwargs
- Keyword args for :class:`~scvi.nn.Decoder`
- """
-
- def __init__(
- self,
- n_input: int,
- n_output: int,
- n_cat_list: Iterable[int] = None,
- n_layers: int = 1,
- n_hidden: int = 128,
- concentration_eps: float = 1e-6,
- **kwargs,
- ):
- super().__init__(
- n_input=n_input,
- n_output=n_output,
- n_cat_list=n_cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden,
- **kwargs,
- )
-
- self.concentration_eps = concentration_eps
-
- def forward(self, x: torch.Tensor, *cat_list: int):
- p = self.decoder(x, *cat_list)
- p_m = self.mean_decoder(p)
-
- p_m = torch.nn.Softplus()(p_m) + self.concentration_eps
-
- dist = Dirichlet(p_m)
-
- return dist
-
-
-class NicheDecoder(nn.Module):
- """Decodes data from latent space to niche embedding space.
-
- ``n_input`` dimensions to ``n_output``
- dimensions using a fully-connected neural network of ``n_hidden`` layers.
- Output is the mean and variance of a multivariate Gaussian
-
- Parameters
- ----------
- n_input
- The dimensionality of the input (latent space)
- n_output
- The dimensionality of the output (data space)
- n_cat_list
- A list containing the number of categories
- for each category of interest. Each category will be
- included using a one-hot encoding
- n_layers
- The number of fully-connected hidden layers
- n_hidden
- The number of nodes per hidden layer
- dropout_rate
- Dropout rate to apply to each of the hidden layers
- kwargs
- Keyword args for :class:`~scvi.module._base.FCLayers`
- """
-
- def __init__(
- self,
- n_input: int,
- n_output: int,
- n_niche_components: int,
- n_cat_list: Iterable[int] = None,
- n_layers: int = 1,
- n_hidden: int = 128,
- dropout_rate: float = 0.1,
- var_eps: float = 1e-4,
- **kwargs,
- ):
- super().__init__()
-
- self.n_niche_components = n_niche_components
- self.n_output = n_output
- self.var_eps = var_eps
-
- self.decoder = FCLayers(
- n_in=n_input,
- n_out=n_hidden,
- n_cat_list=n_cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden,
- dropout_rate=dropout_rate,
- **kwargs,
- )
-
- self.dist_decoder = nn.Linear(n_hidden, 2 * n_output * n_niche_components)
-
- def forward(self, x: torch.Tensor, *cat_list: int):
- """The forward computation for a single sample.
-
- #. Decodes the data from the latent space using the decoder network
- #. Returns tensors for the mean and variance of a multivariate normal distribution
-
- Parameters
- ----------
- x
- tensor with shape ``(n_input,)``
- cat_list
- list of category membership(s) for this sample
-
- Returns
- -------
- 2-tuple of :py:class:`torch.Tensor`
- Mean and variance tensors of shape ``(n_output,)``
-
- """
- # Parameters for latent distribution
- p = self.decoder(x, *cat_list)
-
- p_m, p_v = self.dist_decoder(p).chunk(2, dim=-1)
- p_v = torch.nn.Softplus()(p_v) + self.var_eps
-
- if p.ndim == 2:
- p_m = p_m.view(p_m.shape[0], self.n_niche_components, self.n_output)
- p_v = p_v.view(p_v.shape[0], self.n_niche_components, self.n_output)
-
- elif p.ndim == 3:
- p_m = p_m.view(-1, p_m.shape[1], self.n_niche_components, self.n_output)
- p_v = p_v.view(-1, p_v.shape[1], self.n_niche_components, self.n_output)
-
- return p_m, p_v
diff --git a/src/scvi/external/scviva/_constants.py b/src/scvi/external/scviva/_constants.py
deleted file mode 100644
index a4d8113fab..0000000000
--- a/src/scvi/external/scviva/_constants.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from typing import NamedTuple
-
-
-class _SCVIVA_REGISTRY_KEYS_NT(NamedTuple):
- SAMPLE_KEY: str = "sample"
- Z1_MEAN_KEY: str = "latent_mean"
- NICHE_INDEXES_KEY: str = "niche_indexes"
- NICHE_DISTANCES_KEY: str = "niche_distances"
- NICHE_COMPOSITION_KEY: str = "niche_composition"
- Z1_MEAN_CT_KEY: str = "latent_mean_ct_key"
- CELL_COORDINATES_KEY: str = "spatial"
-
-
-SCVIVA_REGISTRY_KEYS = _SCVIVA_REGISTRY_KEYS_NT()
-
-
-class _SCVIVA_MODULE_KEYS(NamedTuple):
- # generative model
- NICHE_MEAN: str = "niche_mean"
- NICHE_VARIANCE: str = "niche_variance"
- P_NICHE_COMPOSITION: str = "niche_composition"
- P_NICHE_EXPRESSION: str = "niche_expression"
- NICHE_ATTENTION: str = "niche_attention"
- # loss
- NLL_NICHE_COMPOSITION_KEY: str = "niche_compo"
- NLL_NICHE_EXPRESSION_KEY: str = "niche_reconst"
- SPATIAL_WEIGHT_KEY: str = "spatial_weight"
-
-
-SCVIVA_MODULE_KEYS = _SCVIVA_MODULE_KEYS()
diff --git a/src/scvi/external/scviva/_log_likelihood.py b/src/scvi/external/scviva/_log_likelihood.py
deleted file mode 100644
index 6f56d30032..0000000000
--- a/src/scvi/external/scviva/_log_likelihood.py
+++ /dev/null
@@ -1,109 +0,0 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-import torch
-
-if TYPE_CHECKING:
- from collections.abc import Callable, Iterator
- from typing import Any
-
- from torch import Tensor
-
- from ._module import NicheLossOutput
-
-
-def compute_composition_error(
- module: Callable[[dict[str, Tensor | None], dict], tuple[Any, Any, NicheLossOutput]],
- dataloader: Iterator[dict[str, Tensor | None]],
- return_mean: bool = True,
- **kwargs,
-) -> float | Tensor[float]:
- """Compute the composition prediction error on the data.
-
- The error is the negative log likelihood of the data (alpha) given the latent
- variables.
-
- Parameters
- ----------
- module
- A callable (can be a :class:`~torch.nn.Module` instance) that takes a dictionary of
- :class:`~torch.Tensor`s as input and returns a tuple of three elements, where the last
- element is an instance of :class:`~scvi.external.scviva._module.NicheLossOutput`.
- dataloader
- An iterator over minibatches of data on which to compute the metric. The minibatches
- should be formatted as a dictionary of :class:`~torch.Tensor` with keys as expected by
- the ``forward`` method of ``module``.
- return_mean
- If ``True``, return the mean reconstruction error across the dataset. If ``False``,
- return the reconstruction error for each cell individually.
- **kwargs
- Additional keyword arguments to pass into ``module``.
-
- Returns
- -------
- The composition prediction error on the data.
- """
- # Iterate once over the data and computes the error
- composition_loss = []
- for tensors in dataloader:
- _, _, losses = module(tensors, **kwargs)
- if isinstance(losses.composition_loss, dict):
- composition_reconstruction_loss = torch.stack(
- list(losses.composition_loss.values())
- ).sum(dim=0)
- else:
- composition_reconstruction_loss = losses.composition_loss
- composition_loss.append(composition_reconstruction_loss)
-
- composition_loss = torch.cat(composition_loss, dim=0)
- if return_mean:
- composition_loss = composition_loss.mean()
- return composition_loss
-
-
-def compute_niche_error(
- module: Callable[[dict[str, Tensor | None], dict], tuple[Any, Any, NicheLossOutput]],
- dataloader: Iterator[dict[str, Tensor | None]],
- return_mean: bool = True,
- **kwargs,
-) -> float | Tensor[float]:
- """Compute the niche state prediction error on the data.
-
- The error is the negative log likelihood of the data (eta) given the latent
- variables.
-
- Parameters
- ----------
- module
- A callable (can be a :class:`~torch.nn.Module` instance) that takes a dictionary of
- :class:`~torch.Tensor`s as input and returns a tuple of three elements, where the last
- element is an instance of :class:`~scvi.external.scviva._module.NicheLossOutput`.
- dataloader
- An iterator over minibatches of data on which to compute the metric. The minibatches
- should be formatted as a dictionary of :class:`~torch.Tensor` with keys as expected by
- the ``forward`` method of ``module``.
- return_mean
- If ``True``, return the mean reconstruction error across the dataset. If ``False``,
- return the reconstruction error for each cell individually.
- **kwargs
- Additional keyword arguments to pass into ``module``.
-
- Returns
- -------
- The niche state prediction error of the data.
- """
- # Iterate once over the data and computes the error
- niche_loss = []
- for tensors in dataloader:
- _, _, losses = module(tensors, **kwargs)
- if isinstance(losses.niche_loss, dict):
- niche_reconstruction_loss = torch.stack(list(losses.niche_loss.values())).sum(dim=0)
- else:
- niche_reconstruction_loss = losses.niche_loss
- niche_loss.append(niche_reconstruction_loss)
-
- niche_loss = torch.cat(niche_loss, dim=0)
- if return_mean:
- niche_loss = niche_loss.mean()
- return niche_loss
diff --git a/src/scvi/external/scviva/_model.py b/src/scvi/external/scviva/_model.py
deleted file mode 100644
index 16625b0eee..0000000000
--- a/src/scvi/external/scviva/_model.py
+++ /dev/null
@@ -1,1173 +0,0 @@
-from __future__ import annotations
-
-import logging
-import warnings
-from functools import partial
-from typing import TYPE_CHECKING
-
-import anndata
-import joblib
-import numpy as np
-import pandas as pd
-import torch
-from anndata import AnnData
-from rich import print
-
-from scvi import REGISTRY_KEYS, settings
-from scvi.data import AnnDataManager
-from scvi.data._constants import _DATA_REGISTRY_KEY, _FIELD_REGISTRIES_KEY, _STATE_REGISTRY_KEY
-from scvi.data._utils import _get_adata_minify_type
-from scvi.data.fields import (
- CategoricalJointObsField,
- CategoricalObsField,
- LayerField,
- NumericalJointObsField,
- NumericalObsField,
- ObsmField,
-)
-from scvi.model._utils import _init_library_size, scrna_raw_counts_properties
-from scvi.model.base import (
- ArchesMixin,
- BaseMinifiedModeModelClass,
- EmbeddingMixin,
- RNASeqMixin,
- UnsupervisedTrainingMixin,
- VAEMixin,
-)
-from scvi.model.base._archesmixin import _get_loaded_data
-from scvi.model.base._de_core import _de_core
-from scvi.utils import de_dsp, setup_anndata_dsp, unsupported_if_adata_minified
-
-from ._constants import SCVIVA_REGISTRY_KEYS
-from ._module import nicheVAE
-from .differential_expression import _niche_de_core
-
-if TYPE_CHECKING:
- from collections.abc import Iterator, Sequence
- from typing import Literal
-
- from torch import Tensor
-
- from scvi.model.base import (
- BaseModelClass,
- )
-
- from .differential_expression import DifferentialExpressionResults
-
-from scipy.sparse import csr_matrix
-
-_SCVI_LATENT_QZM = "_scvi_latent_qzm"
-_SCVI_LATENT_QZV = "_scvi_latent_qzv"
-_SCVI_OBSERVED_LIB_SIZE = "_scvi_observed_lib_size"
-
-logger = logging.getLogger(__name__)
-
-
-class SCVIVA(
- EmbeddingMixin,
- RNASeqMixin,
- VAEMixin,
- ArchesMixin,
- UnsupervisedTrainingMixin,
- BaseMinifiedModeModelClass,
-):
- """scVIVA: variational auto-encoder with niche decoders for ST:cite:p:`Levy25`.
-
- Parameters
- ----------
- adata
- AnnData object that has been registered via :meth:`~scvi.external.SCVIVA.setup_anndata`. If
- ``None``, then the underlying module will not be initialized until training, and a
- :class:`~lightning.pytorch.core.LightningDataModule` must be passed in during training.
- n_hidden
- Number of nodes per hidden layer.
- n_latent
- Dimensionality of the latent space.
- n_layers
- Number of hidden layers used for encoder and decoder NNs.
- dropout_rate
- Dropout rate for neural networks.
- dispersion
- One of the following:
-
- * ``'gene'`` - dispersion parameter of NB is constant per gene across cells
- * ``'gene-batch'`` - dispersion can differ between different batches
- * ``'gene-label'`` - dispersion can differ between different labels
- * ``'gene-cell'`` - dispersion can differ for every gene in every cell
- gene_likelihood
- One of:
-
- * ``'nb'`` - Negative binomial distribution
- * ``'zinb'`` - Zero-inflated negative binomial distribution
- * ``'poisson'`` - Poisson distribution
- latent_distribution
- One of:
-
- * ``'normal'`` - Normal distribution
- * ``'ln'`` - Logistic normal distribution (Normal(0, I) transformed by softmax)
- **kwargs
- Additional keyword arguments for :class:`~scvi.module.VAE`.
-
- Examples
- --------
- >>> adata = anndata.read_h5ad(path_to_anndata)
- >>> scvi.external.SCVIVA.preprocessing_anndata(
- adata,
- k_nn = 20,
- sample_key = 'slide_ID',
- labels_key = "cell_type",
- cell_coordinates_key = "spatial",
- expression_embedding_key = "X_scVI",
- **kwargs
- )
- >>> scvi.external.SCVIVA.setup_anndata(adata, batch_key="batch")
- >>> vae = scvi.external.SCVIVA(adata)
- >>> vae.train()
- >>> adata.obsm["X_scVIVA"] = vae.get_latent_representation()
- >>> adata.obsm["X_normalized_scVIVA"] = vae.get_normalized_expression()
-
- Notes
- -----
- See further usage examples in the following tutorials:
-
- 1. :doc:`/tutorials/notebooks/quick_start/api_overview`
- 2. :doc:`/tutorials/notebooks/spatial/scVIVA_tutorial`
-
-
- See Also
- --------
- :class:`~scvi.external.scviva._module.nicheVAE`
- """
-
- _module_cls = nicheVAE
-
- def __init__(
- self,
- adata: AnnData | None = None,
- n_hidden: int = 128,
- n_latent: int = 10,
- n_layers: int = 1,
- dropout_rate: float = 0.1,
- dispersion: Literal["gene", "gene-batch", "gene-label", "gene-cell"] = "gene",
- gene_likelihood: Literal["zinb", "nb", "poisson"] = "poisson",
- latent_distribution: Literal["normal", "ln"] = "normal",
- **kwargs,
- ):
- warnings.warn(
- "SCVIVA is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- super().__init__(adata)
-
- self.n_labels = self.summary_stats.n_labels
-
- self._module_kwargs = {
- "n_hidden": n_hidden,
- "n_latent": n_latent,
- "n_layers": n_layers,
- "dropout_rate": dropout_rate,
- "dispersion": dispersion,
- "gene_likelihood": gene_likelihood,
- "latent_distribution": latent_distribution,
- **kwargs,
- }
- self._model_summary_string = (
- "scVIVA model with the following parameters: \n"
- f"n_hidden: {n_hidden}, n_latent: {n_latent}, n_layers: {n_layers}, "
- f"dropout_rate: {dropout_rate}, dispersion: {dispersion}, "
- f"gene_likelihood: {gene_likelihood}, latent_distribution: {latent_distribution}."
- )
-
- if self._module_init_on_train:
- self.module = None
- warnings.warn(
- "Model was initialized without `adata`. The module will be initialized when "
- "calling `train`. This behavior is experimental and may change in the future.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- else:
- n_cats_per_cov = (
- self.adata_manager.get_state_registry(REGISTRY_KEYS.CAT_COVS_KEY).n_cats_per_key
- if REGISTRY_KEYS.CAT_COVS_KEY in self.adata_manager.data_registry
- else None
- )
- n_batch = self.summary_stats.n_batch
- use_size_factor_key = REGISTRY_KEYS.SIZE_FACTOR_KEY in self.adata_manager.data_registry
- library_log_means, library_log_vars = None, None
- if not use_size_factor_key and self.minified_data_type is None:
- library_log_means, library_log_vars = _init_library_size(
- self.adata_manager, n_batch
- )
- self.module = self._module_cls(
- n_input=self.summary_stats.n_vars,
- n_output_niche=self.summary_stats.n_latent_mean,
- n_batch=n_batch,
- n_labels=self.summary_stats.n_labels,
- n_continuous_cov=self.summary_stats.get("n_extra_continuous_covs", 0),
- n_cats_per_cov=n_cats_per_cov,
- n_hidden=n_hidden,
- n_latent=n_latent,
- n_layers=n_layers,
- dropout_rate=dropout_rate,
- dispersion=dispersion,
- gene_likelihood=gene_likelihood,
- latent_distribution=latent_distribution,
- use_size_factor_key=use_size_factor_key,
- library_log_means=library_log_means,
- library_log_vars=library_log_vars,
- **kwargs,
- )
- self.module.minified_data_type = self.minified_data_type
-
- self.init_params_ = self._get_init_params(locals())
-
- @staticmethod
- def preprocessing_anndata(
- adata: AnnData,
- k_nn: int = 20,
- sample_key: str | None = None,
- labels_key: str = "cell_type",
- cell_coordinates_key: str = "spatial",
- expression_embedding_key: str = "X_scVI",
- expression_embedding_niche_key: str = "niche_activation",
- niche_composition_key: str = "niche_composition",
- niche_indexes_key: str = "niche_indexes",
- niche_distances_key: str = "niche_distances",
- log1p: bool = False,
- ) -> None:
- """Preprocess an AnnData object for scVIVA analysis.
-
- This function prepares the input AnnData object by computing niche indexes,
- neighborhood composition, and average latent space embeddings per cell type.
-
- Parameters
- ----------
- adata : AnnData
- The annotated data matrix of shape `n_obs` x `n_vars`. Rows correspond to cells
- and columns to genes.
- k_nn : int, optional
- Number of nearest neighbors for niche computation. Default is 20.
- sample_key : str or None, optional
- Key in `adata.obs` for sample identifiers. Default is None.
- labels_key : str, optional
- Key in `adata.obs` for cell type labels. the Default is "cell_type".
- cell_coordinates_key : str, optional
- Key in `adata.obsm` for spatial coordinates. Default is "spatial".
- expression_embedding_key : str, optional
- Key in `adata.obsm` for latent space embeddings. the Default is "X_scVI".
- expression_embedding_niche_key : str, optional
- Key in `adata.obsm` where average latent embeddings per cell type are stored.
- the Default is "niche_activation".
- niche_composition_key : str, optional
- Key in `adata.obsm` where neighborhood composition is stored.
- the Default is "niche_composition".
- niche_indexes_key : str, optional
- Key in `adata.obsm` where niche indexes are stored. the Default is "niche_indexes".
- niche_distances_key : str, optional
- Key in `adata.obsm` where neighbor distances are stored.
- the Default is "niche_distances".
- log1p : bool, optional
- Whether to apply log1p to latent space embeddings. Default is False.
-
- Returns
- -------
- None
- The function modifies the input AnnData object in place.
- """
- get_niche_indexes(
- adata=adata,
- sample_key=sample_key,
- niche_indexes_key=niche_indexes_key,
- niche_distances_key=niche_distances_key,
- cell_coordinates_key=cell_coordinates_key,
- k_nn=k_nn,
- )
-
- get_neighborhood_composition(
- adata=adata,
- cell_type_column=labels_key,
- indices_key=niche_indexes_key,
- niche_composition_key=niche_composition_key,
- )
-
- get_average_latent_per_celltype(
- adata=adata,
- labels_key=labels_key,
- niche_indexes_key=niche_indexes_key,
- latent_mean_key=expression_embedding_key,
- latent_mean_ct_key=expression_embedding_niche_key,
- log1p=log1p,
- )
-
- return None
-
- @classmethod
- @setup_anndata_dsp.dedent
- def setup_anndata(
- cls,
- adata: AnnData,
- layer: str | None = None,
- batch_key: str | None = None,
- size_factor_key: str | None = None,
- categorical_covariate_keys: list[str] | None = None,
- continuous_covariate_keys: list[str] | None = None,
- sample_key: str | None = None,
- labels_key: str = "cell_type",
- cell_coordinates_key: str = "spatial",
- expression_embedding_key: str = "X_scVI",
- expression_embedding_niche_key: str = "niche_activation",
- niche_composition_key: str = "niche_composition",
- niche_indexes_key: str = "niche_indexes",
- niche_distances_key: str = "niche_distances",
- **kwargs,
- ):
- """%(summary)s.
-
- Parameters
- ----------
- %(param_adata)s
- %(param_layer)s
- %(param_batch_key)s
- %(param_labels_key)s
- %(param_size_factor_key)s
- %(param_cat_cov_keys)s
- %(param_cont_cov_keys)s
- """
- setup_method_args = cls._get_setup_method_args(**locals())
- anndata_fields = [
- LayerField(REGISTRY_KEYS.X_KEY, layer, is_count_data=True),
- CategoricalObsField(REGISTRY_KEYS.BATCH_KEY, batch_key),
- CategoricalObsField(REGISTRY_KEYS.LABELS_KEY, labels_key),
- NumericalObsField(REGISTRY_KEYS.SIZE_FACTOR_KEY, size_factor_key, required=False),
- CategoricalJointObsField(REGISTRY_KEYS.CAT_COVS_KEY, categorical_covariate_keys),
- NumericalJointObsField(REGISTRY_KEYS.CONT_COVS_KEY, continuous_covariate_keys),
- CategoricalObsField(SCVIVA_REGISTRY_KEYS.SAMPLE_KEY, sample_key),
- ObsmField(SCVIVA_REGISTRY_KEYS.NICHE_COMPOSITION_KEY, niche_composition_key),
- ObsmField(SCVIVA_REGISTRY_KEYS.CELL_COORDINATES_KEY, cell_coordinates_key),
- ObsmField(SCVIVA_REGISTRY_KEYS.NICHE_INDEXES_KEY, niche_indexes_key),
- ObsmField(SCVIVA_REGISTRY_KEYS.NICHE_DISTANCES_KEY, niche_distances_key),
- ObsmField(SCVIVA_REGISTRY_KEYS.Z1_MEAN_KEY, expression_embedding_key),
- ObsmField(SCVIVA_REGISTRY_KEYS.Z1_MEAN_CT_KEY, expression_embedding_niche_key),
- ]
- # register new fields if the adata is minified
- adata_minify_type = _get_adata_minify_type(adata)
- if adata_minify_type is not None:
- anndata_fields += cls._get_fields_for_adata_minification(adata_minify_type)
- adata_manager = AnnDataManager(fields=anndata_fields, setup_method_args=setup_method_args)
- adata_manager.register_fields(adata, **kwargs)
- cls.register_manager(adata_manager)
-
- @torch.inference_mode()
- def predict_neighborhood(
- self,
- adata: AnnData | None = None,
- indices: np.ndarray | None = None,
- batch_size: int | None = 1024,
- ) -> np.ndarray:
- """
- Predict the cell type composition of each cell niche in the dataset.
-
- Parameters
- ----------
- adata
- AnnData object. If ``None``, the model's ``adata`` will be used.
- indices
- Indices of cells to use. If ``None``, all cells will be used.
- batch_size
- Minibatch size to use during inference.
-
- Returns
- -------
- ct_prediction
- Predicted cell type composition of each cell niche in the dataset.
- It is computed as the expectation of the Dirichlet distribution.
- """
- self._check_if_trained(warn=False)
-
- adata = self._validate_anndata(adata)
- scdl = self._make_data_loader(adata=adata, indices=indices, batch_size=batch_size)
-
- ct_prediction = []
- for tensors in scdl:
- inference_inputs = self.module._get_inference_input(tensors)
- outputs = self.module.inference(**inference_inputs)
-
- batch_index = tensors[REGISTRY_KEYS.BATCH_KEY]
- decoder_input = outputs["qz"].loc
-
- # put batch_index in the same device as decoder_input
- batch_index = batch_index.to(decoder_input.device)
-
- predicted_ct_prob = self.module.composition_decoder(
- decoder_input,
- batch_index,
- ) # no batch correction here
-
- ct_prediction.append(
- (
- predicted_ct_prob.concentration
- / predicted_ct_prob.concentration.sum(dim=1).unsqueeze(1)
- )
- .detach()
- .cpu()
- )
-
- return torch.cat(ct_prediction).numpy()
-
- @torch.inference_mode()
- def predict_niche_activation(
- self,
- adata: AnnData | None = None,
- indices: np.ndarray | None = None,
- batch_size: int | None = 1024,
- ) -> np.ndarray:
- """
- Predict the activation of each cell niche in the dataset.
-
- Parameters
- ----------
- adata
- AnnData object. If ``None``, the model's ``adata`` will be used.
- indices
- Indices of cells to use. If ``None``, all cells will be used.
- batch_size
- Minibatch size to use during inference.
-
- Returns
- -------
- niche_activation
- Predicted activation of each cell niche in the dataset.
- """
- self._check_if_trained(warn=False)
-
- adata = self._validate_anndata(adata)
- scdl = self._make_data_loader(adata=adata, indices=indices, batch_size=batch_size)
-
- ct_prediction = []
- for tensors in scdl:
- inference_inputs = self.module._get_inference_input(tensors)
- outputs = self.module.inference(**inference_inputs)
-
- batch_index = tensors[REGISTRY_KEYS.BATCH_KEY]
- decoder_input = outputs["qz"].loc
-
- # put batch_index in the same device as decoder_input
- batch_index = batch_index.to(decoder_input.device)
-
- p_m, p_v = self.module.niche_decoder(
- decoder_input,
- batch_index,
- ) # no batch correction here
-
- ct_prediction.append(p_m.detach().cpu())
-
- return torch.cat(ct_prediction).numpy()
-
- @de_dsp.dedent
- def differential_expression(
- self,
- adata: AnnData | None = None,
- groupby: str | None = None,
- group1: list[str] | None = None,
- group2: str | None = None,
- idx1: list[int] | list[bool] | str | None = None,
- idx2: list[int] | list[bool] | str | None = None,
- mode: Literal["vanilla", "change"] = "change",
- delta: float | list[float] = 0.15,
- batch_size: int | None = None,
- all_stats: bool = True,
- batch_correction: bool = False,
- batchid1: list[str] | None = None,
- batchid2: list[str] | None = None,
- fdr_target: float | list[float] = 0.05,
- silent: bool = False,
- weights: Literal["uniform", "importance"] | None = "uniform",
- filter_outlier_cells: bool = False,
- importance_weighting_kwargs: dict | None = None,
- ###### scVIVA specific ######
- niche_mode: bool = True,
- radius: int | None = None,
- k_nn: int | None = None,
- n_restarts_optimizer_gpc: int = 10,
- path_to_save: str | None = None,
- **kwargs,
- ) -> DifferentialExpressionResults:
- r"""A unified method for differential expression analysis.
-
- Implements ``'vanilla'`` DE :cite:p:`Lopez18` and ``'change'`` mode DE :cite:p:`Boyeau19`.
- Adds a neighborhood component to the DE analysis :cite:p:`Levy25`.
-
- Parameters
- ----------
- %(de_adata)s
- %(de_groupby)s
- %(de_group1)s
- %(de_group2)s
- %(de_idx1)s
- %(de_idx2)s
- %(de_mode)s
- %(de_delta)s
- %(de_batch_size)s
- %(de_all_stats)s
- %(de_batch_correction)s
- %(de_batchid1)s
- %(de_batchid2)s
- %(de_fdr_target)s
- %(de_silent)s
- weights
- Weights to use for sampling. If `None`, defaults to `"uniform"`.
- filter_outlier_cells
- Whether to filter outlier cells with
- :meth:`~scvi.model.base.DifferentialComputation.filter_outlier_cells`.
- importance_weighting_kwargs
- Keyword arguments passed into
- :meth:`~scvi.model.base.RNASeqMixin.get_importance_weights`.
- niche_mode
- Whether to use scVIVA DE or SCVI DE.
- radius
- Radius for scVIVA DE.
- k_nn
- Number of nearest neighbors for scVIVA DE.
- n_restarts_optimizer_gpc
- Number of restarts for the Gaussian Process Classifier optimization.
- path_to_save
- Path to save the results to, as a pickle file.
- **kwargs
- Keyword args for :meth:`scvi.model.base.DifferentialComputation.get_bayes_factors`
-
- Returns
- -------
- Differential expression dataclass.
- """
- adata = self._validate_anndata(adata)
- col_names = adata.var_names
- importance_weighting_kwargs = importance_weighting_kwargs or {}
- model_fn = partial(
- self.get_normalized_expression,
- return_numpy=True,
- n_samples=1,
- batch_size=batch_size,
- weights=weights,
- **importance_weighting_kwargs,
- )
- representation_fn = self.get_latent_representation if filter_outlier_cells else None
-
- if niche_mode:
- result = _niche_de_core(
- self.get_anndata_manager(adata, required=True),
- model_fn,
- representation_fn,
- groupby,
- group1,
- group2,
- idx1,
- idx2,
- all_stats,
- scrna_raw_counts_properties,
- col_names,
- mode,
- batchid1,
- batchid2,
- delta,
- batch_correction,
- fdr_target,
- silent,
- radius=radius,
- k_nn=k_nn,
- n_restarts_optimizer_gpc=n_restarts_optimizer_gpc,
- **kwargs,
- )
-
- else:
- result = _de_core(
- self.get_anndata_manager(adata, required=True),
- model_fn,
- representation_fn,
- groupby,
- group1,
- group2,
- idx1,
- idx2,
- all_stats,
- scrna_raw_counts_properties,
- col_names,
- mode,
- batchid1,
- batchid2,
- delta,
- batch_correction,
- fdr_target,
- silent,
- **kwargs,
- )
-
- if path_to_save is not None:
- joblib.dump(result, path_to_save)
-
- return result
-
- @torch.inference_mode()
- @unsupported_if_adata_minified
- def get_composition_error(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- batch_size: int | None = None,
- dataloader: Iterator[dict[str, Tensor | None]] = None,
- return_mean: bool = True,
- **kwargs,
- ) -> dict[str, float]:
- r"""Compute the composition prediction error on the data.
-
- The error is the negative log likelihood of the data (alpha) given the latent
- variables. This is typically written as
- :math:`p(alpha \mid z)`, the likelihood term given one posterior sample.
-
- Parameters
- ----------
- adata
- :class:`~anndata.AnnData` object with :attr:`~anndata.AnnData.var_names` in the same
- order as the ones used to train the model. If ``None`` and ``dataloader`` is also
- ``None``, it defaults to the object used to initialize the model.
- indices
- Indices of observations in ``adata`` to use. If ``None``, defaults to all observations.
- Ignored if ``dataloader`` is not ``None``
- batch_size
- Minibatch size for the forward pass. If ``None``, defaults to
- ``scvi.settings.batch_size``. Ignored if ``dataloader`` is not ``None``
- dataloader
- An iterator over minibatches of data on which to compute the metric. The minibatches
- should be formatted as a dictionary of :class:`~torch.Tensor` with keys as expected by
- the model. If ``None``, a dataloader is created from ``adata``.
- return_mean
- Whether to return the mean reconstruction loss or the reconstruction loss
- for each observation.
- **kwargs
- Additional keyword arguments to pass into the forward method of the module.
-
- Returns
- -------
- The composition prediction error on the data.
-
- Notes
- -----
- This is not the negative reconstruction error, so higher is better.
- """
- from ._log_likelihood import compute_composition_error
-
- if adata is not None and dataloader is not None:
- raise ValueError("Only one of `adata` or `dataloader` can be provided.")
-
- if dataloader is None:
- adata = self._validate_anndata(adata)
- dataloader = self._make_data_loader(
- adata=adata, indices=indices, batch_size=batch_size
- )
-
- return compute_composition_error(
- self.module, dataloader, return_mean=return_mean, **kwargs
- )
-
- @torch.inference_mode()
- @unsupported_if_adata_minified
- def get_niche_error(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- batch_size: int | None = None,
- dataloader: Iterator[dict[str, Tensor | None]] = None,
- return_mean: bool = True,
- **kwargs,
- ) -> dict[str, float]:
- r"""Compute the niche state prediction error on the data.
-
- The error is the negative log likelihood of the data (eta) given the latent
- variables. This is typically written as
- :math:`p(eta \mid z)`, the likelihood term given one posterior sample.
-
- Parameters
- ----------
- adata
- :class:`~anndata.AnnData` object with :attr:`~anndata.AnnData.var_names` in the same
- order as the ones used to train the model. If ``None`` and ``dataloader`` is also
- ``None``, it defaults to the object used to initialize the model.
- indices
- Indices of observations in ``adata`` to use. If ``None``, defaults to all observations.
- Ignored if ``dataloader`` is not ``None``
- batch_size
- Minibatch size for the forward pass. If ``None``, defaults to
- ``scvi.settings.batch_size``. Ignored if ``dataloader`` is not ``None``
- dataloader
- An iterator over minibatches of data on which to compute the metric. The minibatches
- should be formatted as a dictionary of :class:`~torch.Tensor` with keys as expected by
- the model. If ``None``, a dataloader is created from ``adata``.
- return_mean
- Whether to return the mean reconstruction loss or the reconstruction loss
- for each observation.
- **kwargs
- Additional keyword arguments to pass into the forward method of the module.
-
- Returns
- -------
- The niche state prediction error of the data.
-
- Notes
- -----
- This is not the negative reconstruction error, so higher is better.
- """
- from ._log_likelihood import compute_niche_error
-
- if adata is not None and dataloader is not None:
- raise ValueError("Only one of `adata` or `dataloader` can be provided.")
-
- if dataloader is None:
- adata = self._validate_anndata(adata)
- dataloader = self._make_data_loader(
- adata=adata, indices=indices, batch_size=batch_size
- )
-
- return compute_niche_error(self.module, dataloader, return_mean=return_mean, **kwargs)
-
- def preprocessing_query_anndata(
- self,
- adata: AnnData,
- reference_model: str | BaseModelClass,
- return_reference_var_names: bool = False,
- inplace: bool = True,
- k_nn: int = 20,
- sample_key: str | None = None,
- labels_key: str = "cell_type",
- cell_coordinates_key: str = "spatial",
- expression_embedding_key: str = "X_scVI",
- expression_embedding_niche_key: str = "niche_activation",
- niche_composition_key: str = "niche_composition",
- niche_indexes_key: str = "niche_indexes",
- niche_distances_key: str = "niche_distances",
- log1p: bool = False,
- ) -> AnnData | pd.Index | None:
- """Prepare data for query integration.
-
- Merges SCVIVA.preprocessing_anndata and ArchesMixin.prepare_query_anndata.
-
- This function will return a new AnnData object with padded zeros
- for missing features (genes, alpha, and eta), as well as correctly sorted features.
-
- Parameters
- ----------
- adata
- AnnData organized in the same way as data used to train model.
- It is not necessary to run setup_anndata,
- as AnnData is validated against the ``registry``.
- reference_model
- Either an already instantiated model of the same class or a path to
- saved outputs for the reference model.
- return_reference_var_names
- Only load and return reference var names if True.
- inplace
- Whether to subset and rearrange query vars inplace or return new AnnData.
- k_nn : int, optional
- Number of nearest neighbors for niche computation. Default is 20.
- sample_key : str or None, optional
- Key in `adata.obs` for sample identifiers. Default is None.
- labels_key : str, optional
- Key in `adata.obs` for cell type labels. the Default is "cell_type".
- cell_coordinates_key : str, optional
- Key in `adata.obsm` for spatial coordinates. Default is "spatial".
- expression_embedding_key : str, optional
- Key in `adata.obsm` for latent space embeddings. the Default is "X_scVI".
- expression_embedding_niche_key : str, optional
- Key in `adata.obsm` where average latent embeddings per cell type are stored.
- the Default is "niche_activation".
- niche_composition_key : str, optional
- Key in `adata.obsm` where neighborhood composition is stored.
- the Default is "niche_composition".
- niche_indexes_key : str, optional
- Key in `adata.obsm` where niche indexes are stored. the Default is "niche_indexes".
- niche_distances_key : str, optional
- Key in `adata.obsm` where neighbor distances are stored.
- the Default is "niche_distances".
- log1p : bool, optional
- Whether to apply log1p to latent space embeddings. Default is False.
-
-
- Returns
- -------
- Query adata ready to use in `load_query_data` unless `return_reference_var_names`
- in which case a pd.Index of reference var names is returned.
- """
- SCVIVA.preprocessing_anndata(
- adata=adata,
- k_nn=k_nn,
- sample_key=sample_key,
- labels_key=labels_key,
- cell_coordinates_key=cell_coordinates_key,
- expression_embedding_key=expression_embedding_key,
- expression_embedding_niche_key=expression_embedding_niche_key,
- niche_composition_key=niche_composition_key,
- niche_indexes_key=niche_indexes_key,
- niche_distances_key=niche_distances_key,
- log1p=log1p,
- )
-
- _, var_names, _, _ = _get_loaded_data(reference_model, device="cpu")
- var_names = pd.Index(var_names)
-
- if return_reference_var_names:
- return var_names
-
- reference_niche_composition_key = reference_model.registry[_FIELD_REGISTRIES_KEY][
- SCVIVA_REGISTRY_KEYS.NICHE_COMPOSITION_KEY
- ][_DATA_REGISTRY_KEY]["attr_key"]
- assert reference_niche_composition_key == niche_composition_key, (
- f"niche_composition_key in query ({niche_composition_key}) must match that "
- f"of reference ({reference_niche_composition_key})"
- )
- reference_expression_embedding_niche_key = reference_model.registry[_FIELD_REGISTRIES_KEY][
- SCVIVA_REGISTRY_KEYS.Z1_MEAN_CT_KEY
- ][_DATA_REGISTRY_KEY]["attr_key"]
- assert reference_expression_embedding_niche_key == expression_embedding_niche_key, (
- f"expression_embedding_niche_key in query ({expression_embedding_niche_key}) must "
- f"match that of reference ({reference_expression_embedding_niche_key})"
- )
-
- reference_label_names = reference_model.registry[_FIELD_REGISTRIES_KEY][
- SCVIVA_REGISTRY_KEYS.NICHE_COMPOSITION_KEY
- ][_STATE_REGISTRY_KEY]["column_names"]
- reference_label_names = pd.Index(reference_label_names)
-
- query_label_names = pd.Index(adata.obsm[niche_composition_key].columns)
-
- return _pad_and_sort_query_anndata(
- adata,
- var_names,
- reference_label_names,
- query_label_names,
- niche_composition_key,
- expression_embedding_niche_key,
- inplace,
- )
-
-
-def get_niche_indexes(
- adata: AnnData,
- sample_key: str,
- niche_indexes_key: str,
- cell_coordinates_key: str,
- k_nn: int,
- niche_distances_key: str,
-) -> None:
- """Get the k nearest neighbors of each cell in the dataset, grouped per sample.
-
- The indexes of the neighbors are stored in adata.obsm[niche_indexes_key] and the distances to
- the neighbors are stored in adata.obsm[niche_distances_key].
-
- Parameters
- ----------
- adata
- Anndata object
- sample_key
- Key in adata.obs that contain the sample of each cell (i.e., the donor slice)
- niche_indexes_key
- Key in adata.obsm where the indexes of the neighbors will be stored
- cell_coordinates_key
- Key in adata.obsm that contains the spatial coordinates of each cell
- k_nn
- Number of nearest neighbors to compute
- niche_distances_key
- Key in adata.obsm where the distances to the neighbors will be stored
-
- Returns
- -------
- None
-
- """
- from sklearn.neighbors import NearestNeighbors
-
- adata.obsm[niche_indexes_key] = np.zeros(
- (adata.n_obs, k_nn)
- ) # for each cell, store the indexes of its k_nn neighbors
- adata.obsm[niche_distances_key] = np.zeros(
- (adata.n_obs, k_nn)
- ) # for each cell, store the distances to its k_nn neighbors
-
- adata.obs["index"] = np.arange(adata.shape[0])
- # build a dictionary giving the index of each 'donor_slice' observation:
- donor_slice_index = {}
- for sample in adata.obs[sample_key].unique():
- donor_slice_index[sample] = adata.obs[adata.obs[sample_key] == sample]["index"].values
-
- for sample in adata.obs[sample_key].unique():
- sample_coord = adata.obsm[cell_coordinates_key][adata.obs[sample_key] == sample]
-
- # Create a NearestNeighbors object
- knn = NearestNeighbors(n_neighbors=k_nn + 1)
-
- # Fit the kNN model to the points
- knn.fit(sample_coord)
-
- # Find the indices of the kNN for each point
- distances, indices = knn.kneighbors(sample_coord)
-
- # Store the indices in the adata object
- sample_global_index = donor_slice_index[sample][indices].astype(int)
-
- adata.obsm[niche_indexes_key][adata.obs[sample_key] == sample] = sample_global_index[:, 1:]
-
- adata.obsm[niche_indexes_key] = adata.obsm[niche_indexes_key].astype(int)
-
- adata.obsm[niche_distances_key][adata.obs[sample_key] == sample] = distances[:, 1:]
-
- print(
- f"[bold cyan]Saved {niche_indexes_key} and {niche_distances_key} in adata.obsm[/bold cyan]"
- )
-
- return None
-
-
-def get_neighborhood_composition(
- adata: AnnData,
- cell_type_column: str,
- indices_key: str = "niche_indexes",
- niche_composition_key: str = "niche_composition",
-) -> None:
- """Get the composition of each neighborhood in the dataset (alpha)."""
- n_cell_types = len(adata.obs[cell_type_column].unique()) # number of cell types
- adata.obsm[niche_composition_key] = np.zeros(
- (adata.n_obs, n_cell_types)
- ) # for each cell, store the composition of its neighborhood
- # as a convex vector of the cell type proportions
-
- indices = adata.obsm[indices_key].astype(int)
-
- cell_types = adata.obs[cell_type_column].unique().tolist()
- cell_type_to_int = {cell_types[i]: i for i in range(len(cell_types))}
- adata.uns["cell_type_to_int"] = cell_type_to_int
-
- # Transform the query vector into an integer-valued vector
- integer_vector = np.vectorize(cell_type_to_int.get)(adata.obs[cell_type_column])
-
- n_cells = adata.n_obs
- # For each cell, get the cell types of its neighbors
- cell_types_in_the_neighborhood = [integer_vector[indices[cell, :]] for cell in range(n_cells)]
-
- # Compute the composition of each neighborhood
- composition = np.array(
- [
- np.bincount(
- cell_types_in_the_neighborhood[cell],
- minlength=len(cell_type_to_int),
- )
- for cell in range(n_cells)
- ]
- )
-
- # Normalize the composition of each neighborhood
- composition = composition / indices.shape[1]
- composition = np.array(composition)
-
- neighborhood_composition_df = pd.DataFrame(
- data=composition,
- columns=cell_types,
- index=adata.obs_names,
- )
-
- adata.obsm[niche_composition_key] = neighborhood_composition_df
-
- print(f"[bold green]Saved {niche_composition_key} in adata.obsm[/bold green]")
-
- return None
-
-
-def get_average_latent_per_celltype(
- adata: AnnData,
- labels_key: str,
- niche_indexes_key: str,
- latent_mean_key: str,
- latent_mean_ct_key: str = "niche_activation",
- log1p: bool = False,
-) -> None:
- """Get the average embedding per cell type in the dataset.
-
- For this one needs to provide the cell type of each cell in the
- dataset and an embedding for each cell, computed, for instance, with PCA, or scVI.
-
- Parameters
- ----------
- adata
- Anndata object
- labels_key
- Key in adata.obs that contains the cell type of each cell
- niche_indexes_key
- Key in adata.obsm that contains the indexes of the neighbors of each cell
- latent_mean_key
- Key in adata.obsm that contains the expression embedding of each cell
- latent_mean_ct_key
- Key in adata.obsm where the average embedding per cell type will be stored
- log1p
- Whether the latent space is log-transformed
-
- Returns
- -------
- None
-
- """
- n_cells = adata.n_obs
- n_cell_types = len(adata.obs[labels_key].unique())
- n_latent_z1 = adata.obsm[latent_mean_key].shape[1]
- niche_indexes = adata.obsm[niche_indexes_key]
-
- if log1p:
- z1_mean_niches = np.log1p(adata.obsm[latent_mean_key][niche_indexes])
- else:
- z1_mean_niches = adata.obsm[latent_mean_key][niche_indexes]
-
- cell_type_to_int = adata.uns["cell_type_to_int"]
- integer_vector = np.vectorize(cell_type_to_int.get)(adata.obs[labels_key])
-
- # For each cell, get the cell types of its neighbors (as integers)
- cell_types_in_the_neighborhood = np.vstack(
- [integer_vector[niche_indexes[cell, :]] for cell in range(n_cells)]
- )
-
- dict_of_cell_type_indices = {}
-
- for cell_type, cell_type_idx in cell_type_to_int.items():
- ct_row_indices, ct_col_indices = np.where(cell_types_in_the_neighborhood == cell_type_idx)
-
- # dict of cells:local index of the cells of cell_type in the neighborhood.
- result_dict = {}
- for row_idx, col_idx in zip(ct_row_indices, ct_col_indices, strict=False):
- result_dict.setdefault(row_idx, []).append(col_idx)
-
- dict_of_cell_type_indices[cell_type] = result_dict
-
- latent_mean_ct_prior = np.zeros((n_cell_types, n_latent_z1))
-
- z1_mean_niches_ct = np.stack(
- [latent_mean_ct_prior] * n_cells, axis=0
- ) # batch times n_cell_types times n_latent. Initialize your prior with a non-spatial average.
-
- # outer loop over cell types
- for cell_type, cell_type_idx in cell_type_to_int.items():
- ct_dict = dict_of_cell_type_indices[cell_type]
- # inner loop over every cell that has this cell type in its neighborhood.
- for cell_idx, neighbor_idxs in ct_dict.items():
- z1_mean_niches_ct[cell_idx, cell_type_idx, :] = np.mean(
- z1_mean_niches[cell_idx, neighbor_idxs, :], axis=0
- )
-
- adata.obsm[latent_mean_ct_key] = z1_mean_niches_ct
-
- print(f"[bold green]Saved {latent_mean_ct_key} in adata.obsm[/bold green]")
-
- return None
-
-
-def _pad_and_sort_query_anndata(
- adata: AnnData,
- reference_var_names: pd.Index,
- reference_label_names: pd.Index,
- query_label_names: pd.Index,
- niche_composition_key: str,
- expression_embedding_niche_key: str,
- inplace: bool,
- min_var_name_ratio: float = 0.8,
-) -> AnnData | None:
- r"""
- Pad and sort anndata to match reference var names.
-
- Also covers \alpha and \eta niche features.
- """
- intersection_genes = adata.var_names.intersection(reference_var_names)
- inter_len_genes = len(intersection_genes)
- if inter_len_genes == 0:
- raise ValueError(
- "No reference var names found in query data. "
- "Please rerun with return_reference_var_names=True "
- "to see reference var names."
- )
-
- ratio = inter_len_genes / len(reference_var_names)
- logger.info(f"Found {ratio * 100}% reference vars in query data.")
- if ratio < min_var_name_ratio:
- warnings.warn(
- f"Query data contains less than {min_var_name_ratio:.0%} of reference "
- "var names. This may result in poor performance.",
- UserWarning,
- stacklevel=settings.warnings_stacklevel,
- )
-
- missing_in_query = reference_label_names.difference(query_label_names)
- extra_in_query = query_label_names.difference(reference_label_names)
-
- if len(missing_in_query) > 0:
- logger.info(f"Labels not observed in query: {sorted(missing_in_query.tolist())}")
- if len(extra_in_query) > 0:
- raise ValueError(
- f"Label(s) observed in query but not in reference: {sorted(extra_in_query.tolist())}"
- )
-
- # pad alpha composition if needed
- if len(missing_in_query) > 0:
- # add missing columns
- for ct in missing_in_query:
- adata.obsm[niche_composition_key][ct] = 0.0
- # sort columns anyway to match reference
- adata.obsm[niche_composition_key] = adata.obsm[niche_composition_key][reference_label_names]
- pad_and_sorted_query_label_names = pd.Index(adata.obsm[niche_composition_key].columns)
- assert pad_and_sorted_query_label_names.equals(reference_label_names), (
- "Error when sorting query label names to match reference."
- )
-
- # pad eta niche activation if needed
- cell_type_to_int = adata.uns["cell_type_to_int"]
- if len(missing_in_query) > 0:
- # Update with missing labels: add zero embeddings
- for ct in missing_in_query:
- cell_type_to_int[ct] = len(cell_type_to_int)
- # Add zeros for this new cell type
- zeros = np.zeros(
- (adata.n_obs, 1, adata.obsm[expression_embedding_niche_key].shape[-1])
- )
- z_niche = np.concatenate([adata.obsm[expression_embedding_niche_key], zeros], axis=1)
-
- else:
- z_niche = adata.obsm[expression_embedding_niche_key]
- # reorder axis 1 to match reference_label_names
- idxs = np.array([cell_type_to_int[ct] for ct in reference_label_names])
- adata.obsm[expression_embedding_niche_key] = z_niche[:, idxs, :]
-
- genes_to_add = reference_var_names.difference(adata.var_names)
- needs_padding = len(genes_to_add) > 0
- if needs_padding:
- padding_mtx = csr_matrix(np.zeros((adata.n_obs, len(genes_to_add))))
- adata_padding = AnnData(
- X=padding_mtx.copy(),
- layers={layer: padding_mtx.copy() for layer in adata.layers if layer is not None},
- )
- adata_padding.var_names = genes_to_add
- adata_padding.obs_names = adata.obs_names
- # Concatenate object
- adata_out = anndata.concat(
- [adata, adata_padding],
- axis=1,
- join="outer",
- index_unique=None,
- merge="unique",
- )
- else:
- adata_out = adata
-
- # also covers the case when new adata has more var names than old
- if not reference_var_names.equals(adata_out.var_names):
- adata_out._inplace_subset_var(reference_var_names)
-
- if inplace:
- if adata_out is not adata:
- adata._init_as_actual(adata_out)
- else:
- return adata_out
diff --git a/src/scvi/external/scviva/_module.py b/src/scvi/external/scviva/_module.py
deleted file mode 100644
index 2da1d6fc15..0000000000
--- a/src/scvi/external/scviva/_module.py
+++ /dev/null
@@ -1,637 +0,0 @@
-from __future__ import annotations
-
-import logging
-from dataclasses import dataclass
-from typing import TYPE_CHECKING
-
-import torch
-from torch.nn.functional import one_hot
-
-from scvi import REGISTRY_KEYS
-from scvi.module import VAE, Classifier
-from scvi.module._constants import MODULE_KEYS
-from scvi.module.base import (
- LossOutput,
- auto_move_data,
-)
-
-from ._components import DirichletDecoder, Encoder, NicheDecoder
-from ._constants import SCVIVA_MODULE_KEYS, SCVIVA_REGISTRY_KEYS
-
-if TYPE_CHECKING:
- from typing import Literal
-
- import numpy as np
- from torch.distributions import Distribution
-
- from scvi._types import LossRecord
-
-logger = logging.getLogger(__name__)
-
-
-class nicheVAE(VAE):
- """Variational auto-encoder with niche decoders :cite:p:`Levy25`.
-
- Parameters
- ----------
- n_input
- Number of input features.
- n_batch
- Number of batches. If ``0``, no batch correction is performed.
- n_labels
- Number of labels.
- n_hidden
- Number of nodes per hidden layer. Passed into :class:`~scvi.nn.Encoder` and
- :class:`~scvi.nn.DecoderSCVI`.
- n_latent
- Dimensionality of the latent space.
- n_layers
- Number of hidden layers. Passed into :class:`~scvi.nn.Encoder` and
- :class:`~scvi.nn.DecoderSCVI`.
- n_layers_niche
- Number of hidden layers in the niche state decoder.
- n_layers_compo
- Number of hidden layers in the composition decoder.
- n_hidden_niche
- Number of nodes per hidden layer in the niche state decoder.
- n_hidden_compo
- Number of nodes per hidden layer in the composition decoder.
- n_continuous_cov
- Number of continuous covariates.
- n_cats_per_cov
- A list of integers containing the number of categories for each categorical covariate.
- dropout_rate
- Dropout rate. Passed into :class:`~scvi.nn.Encoder` but not :class:`~scvi.nn.DecoderSCVI`.
- dispersion
- Flexibility of the dispersion parameter when ``gene_likelihood`` is either ``"nb"`` or
- ``"zinb"``. One of the following:
-
- * ``"gene"``: parameter is constant per gene across cells.
- * ``"gene-batch"``: parameter is constant per gene per batch.
- * ``"gene-label"``: parameter is constant per gene per label.
- * ``"gene-cell"``: parameter is constant per gene per cell.
- log_variational
- If ``True``, use :func:`~torch.log1p` on input data before encoding for numerical stability
- (not normalization).
- gene_likelihood
- Distribution to use for reconstruction in the generative process. One of the following:
-
- * ``"nb"``: :class:`~scvi.distributions.NegativeBinomial`.
- * ``"zinb"``: :class:`~scvi.distributions.ZeroInflatedNegativeBinomial`.
- * ``"poisson"``: :class:`~scvi.distributions.Poisson`.
- latent_distribution
- Distribution to use for the latent space. One of the following:
-
- * ``"normal"``: isotropic normal.
- * ``"ln"``: logistic normal with normal params N(0, 1).
- niche_likelihood
- Distribution to use for the niche state. One of the following:
-
- * ``"poisson"``: :class:`~torch.distributions.Poisson`.
- * ``"gaussian"``: :class:`~torch.distributions.Normal`.
- Default is ``"gaussian"`` and Poisson should be used if the niche state is count data.
- cell_rec_weight
- Weight of the cell reconstruction loss.
- latent_kl_weight
- Weight of the latent KL divergence.
- spatial_weight
- Weight of the spatial losses
- prior_mixture
- If ``True``, use a mixture of Gaussians for the latent space. Else, use unimodal Gaussian.
- prior_mixture_k
- Number of components in the Gaussian mixture.
- semisupervised
- If ``True``, use a classifier to predict cell type labels from the latent space.
- linear_classifier
- If ``True``, use a linear classifier. Else, use a neural network.
- inpute_covariates_niche_decoder
- If ``True``, covariates are concatenated to the input of the niche state decoder.
- encode_covariates
- If ``True``, covariates are concatenated to gene expression prior to passing through
- the encoder(s). Else, only gene expression is used.
- deeply_inject_covariates
- If ``True`` and ``n_layers > 1``, covariates are concatenated to the outputs of hidden
- layers in the encoder(s) (if ``encoder_covariates`` is ``True``) and the decoder prior to
- passing through the next layer.
- batch_representation
- Method for encoding batch information. One of the following:
-
- * ``"one-hot"``: represent batches with one-hot encodings.
- * ``"embedding"``: represent batches with continuously-valued embeddings using
- :class:`~scvi.nn.Embedding`.
-
- Note that batch representations are only passed into the encoder(s) if
- ``encode_covariates`` is ``True``.
- use_batch_norm
- Specifies where to use :class:`~torch.nn.BatchNorm1d` in the model. One of the following:
-
- * ``"none"``: don't use batch norm in either encoder(s) or decoder.
- * ``"encoder"``: use batch norm only in the encoder(s).
- * ``"decoder"``: use batch norm only in the decoder.
- * ``"both"``: use batch norm in both encoder(s) and decoder.
-
- Note: if ``use_layer_norm`` is also specified, both will be applied (first
- :class:`~torch.nn.BatchNorm1d`, then :class:`~torch.nn.LayerNorm`).
- use_layer_norm
- Specifies where to use :class:`~torch.nn.LayerNorm` in the model. One of the following:
-
- * ``"none"``: don't use layer norm in either encoder(s) or decoder.
- * ``"encoder"``: use layer norm only in the encoder(s).
- * ``"decoder"``: use layer norm only in the decoder.
- * ``"both"``: use layer norm in both encoder(s) and decoder.
-
- Note: if ``use_batch_norm`` is also specified, both will be applied (first
- :class:`~torch.nn.BatchNorm1d`, then :class:`~torch.nn.LayerNorm`).
- use_size_factor_key
- If ``True``, use the :attr:`~anndata.AnnData.obs` column as defined by the
- ``size_factor_key`` parameter in the model's ``setup_anndata`` method as the scaling
- factor in the mean of the conditional distribution. Takes priority over
- ``use_observed_lib_size``.
- use_observed_lib_size
- If ``True``, use the observed library size for RNA as the scaling factor in the mean of the
- conditional distribution.
- library_log_means
- :class:`~numpy.ndarray` of shape ``(1, n_batch)`` of means of the log library sizes that
- parameterize the prior on library size if ``use_size_factor_key`` is ``False`` and
- ``use_observed_lib_size`` is ``False``.
- library_log_vars
- :class:`~numpy.ndarray` of shape ``(1, n_batch)`` of variances of the log library sizes
- that parameterize the prior on library size if ``use_size_factor_key`` is ``False`` and
- ``use_observed_lib_size`` is ``False``.
- extra_decoder_kwargs
- Additional keyword arguments passed into :class:`~scvi.nn.DecoderSCVI`.
- batch_embedding_kwargs
- Keyword arguments passed into :class:`~scvi.nn.Embedding` if ``batch_representation`` is
- set to ``"embedding"``.
-
- Notes
- -----
- Lifecycle: argument ``batch_representation`` is experimental in v1.2.
- """
-
- def __init__(
- self,
- n_input: int,
- ##############################
- n_output_niche: int,
- ##############################
- n_batch: int = 0,
- n_labels: int = 0,
- n_hidden: int = 128,
- n_latent: int = 10,
- n_layers: int = 1,
- ##############################
- n_layers_niche: int = 1,
- n_layers_compo: int = 1,
- n_hidden_niche: int = 128,
- n_hidden_compo: int = 128,
- ##############################
- n_continuous_cov: int = 0,
- n_cats_per_cov: list[int] | None = None,
- dropout_rate: float = 0.1,
- dispersion: Literal["gene", "gene-batch", "gene-label", "gene-cell"] = "gene",
- log_variational: bool = True,
- gene_likelihood: Literal["zinb", "nb", "poisson"] = "poisson",
- latent_distribution: Literal["normal", "ln"] = "normal",
- ##############################
- niche_likelihood: Literal["poisson", "gaussian"] = "gaussian",
- cell_rec_weight: float = 1.0,
- latent_kl_weight: float = 1.0,
- spatial_weight: float = 10,
- ##############################
- prior_mixture: bool = False,
- prior_mixture_k: int = 20,
- semisupervised: bool = True,
- linear_classifier: bool = True,
- ##############################
- inpute_covariates_niche_decoder: bool = True,
- encode_covariates: bool = False,
- deeply_inject_covariates: bool = True,
- batch_representation: Literal["one-hot", "embedding"] = "one-hot",
- use_batch_norm: Literal["encoder", "decoder", "none", "both"] = "none",
- use_layer_norm: Literal["encoder", "decoder", "none", "both"] = "both",
- use_size_factor_key: bool = False,
- use_observed_lib_size: bool = True,
- library_log_means: np.ndarray | None = None,
- library_log_vars: np.ndarray | None = None,
- batch_embedding_kwargs: dict | None = None,
- extra_decoder_kwargs: dict | None = None,
- extra_encoder_kwargs: dict | None = None,
- **vae_kwargs,
- ):
- super().__init__(
- n_input=n_input,
- n_batch=n_batch,
- n_labels=n_labels,
- n_hidden=n_hidden,
- n_latent=n_latent,
- n_layers=n_layers,
- n_continuous_cov=n_continuous_cov,
- n_cats_per_cov=n_cats_per_cov,
- dropout_rate=dropout_rate,
- dispersion=dispersion,
- log_variational=log_variational,
- gene_likelihood=gene_likelihood,
- latent_distribution=latent_distribution,
- encode_covariates=encode_covariates,
- deeply_inject_covariates=deeply_inject_covariates,
- batch_representation=batch_representation,
- use_size_factor_key=use_size_factor_key,
- use_observed_lib_size=use_observed_lib_size,
- library_log_means=library_log_means,
- library_log_vars=library_log_vars,
- extra_decoder_kwargs=extra_decoder_kwargs,
- batch_embedding_kwargs=batch_embedding_kwargs,
- extra_encoder_kwargs=extra_encoder_kwargs,
- use_batch_norm=use_batch_norm,
- use_layer_norm=use_layer_norm,
- **vae_kwargs,
- )
-
- self.latent_kl_weight = latent_kl_weight
- self.cell_rec_weight = cell_rec_weight
- self.spatial_weight = spatial_weight
- self.n_output_niche = n_output_niche
- self.niche_likelihood = niche_likelihood
- self.prior_mixture = prior_mixture
- self.semisupervised = semisupervised
-
- if self.batch_representation == "embedding":
- batch_dim = self.get_embedding(REGISTRY_KEYS.BATCH_KEY).embedding_dim
-
- use_batch_norm_encoder = use_batch_norm == "encoder" or use_batch_norm == "both"
- use_batch_norm_decoder = use_batch_norm == "decoder" or use_batch_norm == "both"
- use_layer_norm_encoder = use_layer_norm == "encoder" or use_layer_norm == "both"
- use_layer_norm_decoder = use_layer_norm == "decoder" or use_layer_norm == "both"
-
- if self.prior_mixture is True:
- if self.semisupervised:
- prior_mixture_k = n_labels
- self.prior_mixture_k = prior_mixture_k
-
- self.prior_means = torch.nn.Parameter(torch.zeros([prior_mixture_k, n_latent]))
- self.prior_log_scales = torch.nn.Parameter(
- torch.zeros([prior_mixture_k, n_latent])
- )
- self.prior_logits = torch.nn.Parameter(torch.ones([prior_mixture_k]))
-
- else:
- self.prior_mixture_k = prior_mixture_k
- self.prior_means = torch.nn.Parameter(torch.randn([prior_mixture_k, n_latent]))
- self.prior_log_scales = torch.nn.Parameter(
- torch.zeros([prior_mixture_k, n_latent]) - 1.0
- )
- self.prior_logits = torch.nn.Parameter(torch.ones([prior_mixture_k]))
-
- n_input_encoder = n_input + n_continuous_cov * encode_covariates
- if self.batch_representation == "embedding":
- n_input_encoder += batch_dim * encode_covariates
- cat_list = list([] if n_cats_per_cov is None else n_cats_per_cov)
- else:
- cat_list = [n_batch] + list([] if n_cats_per_cov is None else n_cats_per_cov)
-
- encoder_cat_list = cat_list if encode_covariates else None
- _extra_encoder_kwargs = extra_encoder_kwargs or {}
- self.z_encoder = Encoder(
- n_input_encoder,
- n_latent,
- n_cat_list=encoder_cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden,
- dropout_rate=dropout_rate,
- distribution=latent_distribution,
- inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm_encoder,
- use_layer_norm=use_layer_norm_encoder,
- return_dist=True,
- **_extra_encoder_kwargs,
- )
-
- n_input_decoder = n_latent + n_continuous_cov
- if self.batch_representation == "embedding":
- n_input_decoder += batch_dim
-
- _extra_decoder_kwargs = extra_decoder_kwargs or {}
-
- self.niche_decoder = NicheDecoder(
- n_input=n_input_decoder,
- n_output=n_output_niche,
- n_niche_components=n_labels,
- n_cat_list=cat_list if inpute_covariates_niche_decoder else None,
- n_layers=n_layers_niche,
- n_hidden=n_hidden_niche,
- inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm_decoder,
- use_layer_norm=use_layer_norm_decoder,
- dropout_rate=dropout_rate,
- **_extra_decoder_kwargs,
- )
-
- self.composition_decoder = DirichletDecoder(
- n_input_decoder,
- n_labels,
- n_cat_list=None, # do not batch-correct the cell type proportions.
- n_layers=n_layers_compo,
- n_hidden=n_hidden_compo,
- inject_covariates=deeply_inject_covariates,
- use_batch_norm=use_batch_norm_decoder,
- use_layer_norm=use_layer_norm_decoder,
- **_extra_decoder_kwargs,
- )
-
- if self.semisupervised:
- # Classifier takes n_latent as input
- cls_parameters = {
- "n_layers": 0 if linear_classifier else n_layers,
- "n_hidden": 0 if linear_classifier else n_hidden,
- "dropout_rate": dropout_rate,
- "logits": True, # no Softmax
- }
- self.classifier = Classifier(
- n_latent,
- n_labels=n_labels,
- use_batch_norm=use_batch_norm_encoder,
- use_layer_norm=use_layer_norm_encoder,
- **cls_parameters,
- )
- else:
- self.classifier = None
-
- @auto_move_data
- def generative(
- self,
- z: torch.Tensor,
- library: torch.Tensor,
- batch_index: torch.Tensor,
- cont_covs: torch.Tensor | None = None,
- cat_covs: torch.Tensor | None = None,
- size_factor: torch.Tensor | None = None,
- y: torch.Tensor | None = None,
- transform_batch: torch.Tensor | None = None,
- ) -> dict[str, Distribution | None]:
- """Run the generative process."""
- from torch.distributions import Categorical, Independent, MixtureSameFamily, Normal
- from torch.nn.functional import linear
-
- from scvi.distributions import (
- NegativeBinomial,
- Poisson,
- ZeroInflatedNegativeBinomial,
- )
-
- # TODO: refactor forward function to not rely on y
- # Likelihood distribution
- if cont_covs is None:
- decoder_input = z
- elif z.dim() != cont_covs.dim():
- decoder_input = torch.cat(
- [z, cont_covs.unsqueeze(0).expand(z.size(0), -1, -1)], dim=-1
- )
- else:
- decoder_input = torch.cat([z, cont_covs], dim=-1)
-
- if cat_covs is not None:
- categorical_input = torch.split(cat_covs, 1, dim=1)
- else:
- categorical_input = ()
-
- if transform_batch is not None:
- batch_index = torch.ones_like(batch_index) * transform_batch
-
- if not self.use_size_factor_key:
- size_factor = library
-
- if self.batch_representation == "embedding":
- batch_rep = self.compute_embedding(REGISTRY_KEYS.BATCH_KEY, batch_index)
- decoder_input = torch.cat([decoder_input, batch_rep], dim=-1)
- px_scale, px_r, px_rate, px_dropout = self.decoder(
- self.dispersion,
- decoder_input,
- size_factor,
- *categorical_input,
- y,
- )
- else:
- px_scale, px_r, px_rate, px_dropout = self.decoder(
- self.dispersion,
- decoder_input,
- size_factor,
- batch_index,
- *categorical_input,
- y,
- )
-
- if self.dispersion == "gene-label":
- px_r = linear(
- one_hot(y, self.n_labels).float(), self.px_r
- ) # px_r gets transposed - last dimension is nb genes
- elif self.dispersion == "gene-batch":
- px_r = linear(one_hot(batch_index, self.n_batch).float(), self.px_r)
- elif self.dispersion == "gene":
- px_r = self.px_r
-
- px_r = torch.exp(px_r)
-
- if self.gene_likelihood == "zinb":
- px = ZeroInflatedNegativeBinomial(
- mu=px_rate,
- theta=px_r,
- zi_logits=px_dropout,
- scale=px_scale,
- )
- elif self.gene_likelihood == "nb":
- px = NegativeBinomial(mu=px_rate, theta=px_r, scale=px_scale)
- elif self.gene_likelihood == "poisson":
- px = Poisson(px_rate, scale=px_scale)
-
- # Priors
- if self.use_observed_lib_size:
- pl = None
- else:
- (
- local_library_log_means,
- local_library_log_vars,
- ) = self._compute_local_library_params(batch_index)
- pl = Normal(local_library_log_means, local_library_log_vars.sqrt())
-
- if self.prior_mixture is True:
- u_prior_logits = self.prior_logits
- u_prior_means = self.prior_means
- u_prior_scales = torch.exp(self.prior_log_scales) + 1e-4
-
- if self.semisupervised:
- logits_input = (
- torch.stack(
- [
- torch.nn.functional.one_hot(y_i, self.n_labels)
- if y_i < self.n_labels
- else torch.zeros(self.n_labels)
- for y_i in y.ravel()
- ]
- )
- .to(z.device)
- .float()
- )
- u_prior_logits = u_prior_logits + 10 * logits_input
- u_prior_means = u_prior_means.expand(y.shape[0], -1, -1)
- u_prior_scales = u_prior_scales.expand(y.shape[0], -1, -1)
- cats = Categorical(logits=u_prior_logits)
- normal_dists = Independent(
- Normal(u_prior_means, u_prior_scales), reinterpreted_batch_ndims=1
- )
- pz = MixtureSameFamily(cats, normal_dists)
- else:
- pz = Normal(torch.zeros_like(z), torch.ones_like(z))
-
- niche_composition = self.composition_decoder(
- decoder_input, batch_index, *categorical_input
- ) # DirichletDecoder, niche_composition is a distribution
-
- niche_mean, niche_variance = self.niche_decoder(
- decoder_input, batch_index, *categorical_input
- )
-
- if self.niche_likelihood == "poisson":
- niche_expression = torch.distributions.Poisson(niche_variance)
-
- else:
- niche_expression = Normal(niche_mean, niche_variance)
-
- return {
- MODULE_KEYS.PX_KEY: px,
- MODULE_KEYS.PL_KEY: pl,
- MODULE_KEYS.PZ_KEY: pz,
- SCVIVA_MODULE_KEYS.NICHE_MEAN: niche_mean,
- SCVIVA_MODULE_KEYS.NICHE_VARIANCE: niche_variance,
- SCVIVA_MODULE_KEYS.P_NICHE_EXPRESSION: niche_expression,
- SCVIVA_MODULE_KEYS.P_NICHE_COMPOSITION: niche_composition,
- }
-
- def loss(
- self,
- tensors: dict[str, torch.Tensor],
- inference_outputs: dict[str, torch.Tensor | Distribution | None],
- generative_outputs: dict[str, torch.Tensor | Distribution | None],
- kl_weight: float = 1.0,
- classification_ratio=50,
- epsilon: float = 1e-6,
- n_samples_mixture: int = 10,
- ) -> NicheLossOutput:
- """Compute the loss."""
- from torch.distributions import kl_divergence
-
- x = tensors[REGISTRY_KEYS.X_KEY]
- if self.semisupervised:
- y = tensors[REGISTRY_KEYS.LABELS_KEY].ravel().long()
- z_mean = inference_outputs[MODULE_KEYS.QZ_KEY].loc
- y_ct = self.classifier(z_mean)
- classification_loss = torch.nn.functional.cross_entropy(y_ct, y, reduction="none")
-
- if self.prior_mixture is True:
- z = inference_outputs[MODULE_KEYS.QZ_KEY].rsample(
- sample_shape=(n_samples_mixture,)
- ) # sample multiple times
- # sample x n_obs x n_latent
- kl_divergence_z = (
- inference_outputs[MODULE_KEYS.QZ_KEY].log_prob(z).sum(-1)
- - generative_outputs[MODULE_KEYS.PZ_KEY].log_prob(z)
- ).mean(0)
-
- else:
- kl_divergence_z = kl_divergence(
- inference_outputs[MODULE_KEYS.QZ_KEY],
- generative_outputs[MODULE_KEYS.PZ_KEY],
- ).sum(dim=-1)
-
- if not self.use_observed_lib_size:
- kl_divergence_l = kl_divergence(
- inference_outputs[MODULE_KEYS.QL_KEY],
- generative_outputs[MODULE_KEYS.PL_KEY],
- ).sum(dim=1)
- else:
- kl_divergence_l = torch.zeros_like(kl_divergence_z)
-
- reconst_loss_cell = -generative_outputs[MODULE_KEYS.PX_KEY].log_prob(x).sum(-1)
-
- if self.semisupervised:
- reconst_loss_cell = reconst_loss_cell + classification_ratio * classification_loss
-
- kl_local_for_warmup = kl_divergence_z
- kl_local_no_warmup = kl_divergence_l
-
- weighted_kl_local = kl_weight * kl_local_for_warmup + kl_local_no_warmup
-
- niche_weights = tensors[SCVIVA_REGISTRY_KEYS.NICHE_COMPOSITION_KEY]
- niche_weights = (niche_weights > 0).float()
-
- z1_mean_niche = tensors[
- SCVIVA_REGISTRY_KEYS.Z1_MEAN_CT_KEY
- ] # batch times cell_types times n_latent
-
- reconst_loss_niche = (
- -generative_outputs[SCVIVA_MODULE_KEYS.P_NICHE_EXPRESSION]
- .log_prob(z1_mean_niche)
- .sum(dim=(-1))
- )
-
- masked_reconst_loss_niche = (reconst_loss_niche * niche_weights).sum(dim=-1)
-
- true_niche_composition = tensors[SCVIVA_REGISTRY_KEYS.NICHE_COMPOSITION_KEY] + epsilon
- true_niche_composition = true_niche_composition / true_niche_composition.sum(
- dim=-1,
- keepdim=True,
- )
-
- reconst_niche_composition = generative_outputs[SCVIVA_MODULE_KEYS.P_NICHE_COMPOSITION]
-
- composition_loss = -reconst_niche_composition.log_prob(true_niche_composition)
-
- _weighted_reconst_loss_cell = self.cell_rec_weight * reconst_loss_cell
- _weighted_reconst_loss_niche = self.spatial_weight * masked_reconst_loss_niche
- _weighted_composition_loss = self.spatial_weight * composition_loss
- _weighted_kl_local = self.latent_kl_weight * weighted_kl_local
-
- loss = torch.mean(
- _weighted_reconst_loss_cell
- + _weighted_reconst_loss_niche
- + _weighted_kl_local
- + _weighted_composition_loss
- )
-
- return NicheLossOutput(
- loss=loss,
- reconstruction_loss=reconst_loss_cell,
- classification_loss=classification_loss.mean() if self.semisupervised else None,
- true_labels=y if self.semisupervised else None,
- logits=y_ct if self.semisupervised else None,
- kl_local={
- MODULE_KEYS.KL_L_KEY: kl_divergence_l,
- MODULE_KEYS.KL_Z_KEY: kl_divergence_z,
- },
- composition_loss=composition_loss,
- niche_loss=masked_reconst_loss_niche,
- extra_metrics={
- SCVIVA_MODULE_KEYS.NLL_NICHE_COMPOSITION_KEY: torch.mean(composition_loss),
- SCVIVA_MODULE_KEYS.NLL_NICHE_EXPRESSION_KEY: torch.mean(masked_reconst_loss_niche),
- },
- )
-
-
-@dataclass
-class NicheLossOutput(LossOutput):
- """Modify loss output to record niche losses."""
-
- composition_loss: LossRecord | None = None
- niche_loss: LossRecord | None = None
-
- def __post_init__(self):
- super().__post_init__()
-
- default = 0 * self.loss
- if self.composition_loss is None:
- object.__setattr__(self, "composition_loss", default)
- if self.niche_loss is None:
- object.__setattr__(self, "niche_loss", default)
-
- object.__setattr__(self, "composition_loss", self._as_dict("composition_loss"))
- object.__setattr__(self, "niche_loss", self._as_dict("niche_loss"))
diff --git a/src/scvi/external/scviva/differential_expression/__init__.py b/src/scvi/external/scviva/differential_expression/__init__.py
deleted file mode 100644
index 4b256af323..0000000000
--- a/src/scvi/external/scviva/differential_expression/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from ._de_utils import adjusted_nearest_neighbors
-from ._marker_classifier import _gaussian_process_classifier
-from ._niche_de_core import _niche_de_core
-from ._results_dataclass import DifferentialExpressionResults
-
-__all__ = [
- "_niche_de_core",
- "adjusted_nearest_neighbors",
- "_gaussian_process_classifier",
- "DifferentialExpressionResults",
-]
diff --git a/src/scvi/external/scviva/differential_expression/_de_utils.py b/src/scvi/external/scviva/differential_expression/_de_utils.py
deleted file mode 100644
index 38163aa601..0000000000
--- a/src/scvi/external/scviva/differential_expression/_de_utils.py
+++ /dev/null
@@ -1,113 +0,0 @@
-import numpy as np
-from rich import print
-from scipy.sparse import csr_matrix
-
-from scvi.utils import dependencies
-
-
-def adjusted_nearest_neighbors(
- cell_samples: np.array,
- cell_coordinates: np.array,
- cell_labels: np.array,
- radius: int | None = None,
- k_nn: int | None = None,
- return_sparse: bool = True,
-) -> np.ndarray | csr_matrix:
- """Compute the adjacency matrix for the neighborhood of each sample."
-
- Parameters
- ----------
- cell_samples
- Array of sample id for each cell.
- cell_coordinates
- Array of cell coordinates.
- cell_labels
- Array of cell labels.
- radius
- Radius for the nearest neighbors search.
- k_nn
- Number of nearest neighbors to consider.
- return_sparse
- Whether to return a sparse matrix.
-
- Returns
- -------
- np.ndarray | csr_matrix
- Adjacency matrix for the neighborhood of each sample,
- without connections to cells of the same type.
- """
- from scipy.sparse import block_diag
- from sklearn.neighbors import NearestNeighbors
-
- adjacency_matrices = []
-
- sample_names = np.unique(cell_samples)
- print(f"Using {len(sample_names)} samples")
-
- if (radius is None) and (k_nn is None):
- raise ValueError("Either radius or k_nn must be provided")
-
- for sample in sample_names:
- mask = np.squeeze(cell_samples == sample, axis=1) # n_cells
- sample_coord = cell_coordinates[mask] # n_cells_sample_i x 2
- sample_cell_types = np.squeeze(cell_labels[mask], axis=1) # n_cells_sample_i
-
- if radius is not None:
- nn = NearestNeighbors(radius=radius)
- nn.fit(sample_coord)
- A = nn.radius_neighbors_graph(sample_coord)
- elif k_nn is not None:
- nn = NearestNeighbors(n_neighbors=k_nn + 1)
- nn.fit(sample_coord)
- A = nn.kneighbors_graph(sample_coord)
- else:
- raise ValueError("Either radius or k_nn must be provided.")
-
- # Find rows and columns of non-zero entries
- row_indices, col_indices = A.nonzero()
-
- # Create a sparse mask of entries to zero out
- # Only zero out entries where row and column have the same label
- mask_matrix = np.where(sample_cell_types[row_indices] == sample_cell_types[col_indices])[0]
-
- A_adjusted = A.copy()
- # Zero out these specific data entries
- A_adjusted.data[mask_matrix] = 0
-
- A_adjusted.eliminate_zeros()
-
- adjacency_matrices.append(A_adjusted.astype(bool, copy=False))
-
- adjacency_matrix = block_diag(adjacency_matrices, format="csr")
-
- row_counts = np.diff(adjacency_matrix.indptr)
- # print mean and std of number of neighbors, round to 2 decimals:
- print(f"Mean number of neighbors: {np.mean(row_counts):.1f} ± {np.std(row_counts):.1f}")
-
- if radius is None:
- assert np.mean(row_counts) <= k_nn, "Mean number of neighbors is greater than k_nn"
-
- if return_sparse:
- return adjacency_matrix
-
- return adjacency_matrix.toarray()
-
-
-def _get_nonzero_indices_from_rows(csr_matrix, row_idx):
- return np.unique(csr_matrix[row_idx].indices)
-
-
-@dependencies("matplotlib")
-def get_connectivity_distribution(csr_matrix):
- import matplotlib.pyplot as plt
-
- # Get the number of non-zero entries per row
- row_counts = np.diff(csr_matrix.indptr)
-
- # Create the histogram
- fig, ax = plt.subplots()
- ax.hist(row_counts, bins=np.max(row_counts) + 1, density=True)
- ax.set_xlabel("Number of non-zero entries")
- ax.set_ylabel("Number of rows")
- ax.set_title("Histogram of non-zero entries per row")
- plt.show()
diff --git a/src/scvi/external/scviva/differential_expression/_marker_classifier.py b/src/scvi/external/scviva/differential_expression/_marker_classifier.py
deleted file mode 100644
index ebd1da65a0..0000000000
--- a/src/scvi/external/scviva/differential_expression/_marker_classifier.py
+++ /dev/null
@@ -1,105 +0,0 @@
-import pandas as pd
-from sklearn.gaussian_process import GaussianProcessClassifier
-
-
-def _gaussian_process_classifier(
- lfc_g1_g2: pd.Series,
- lfc_n1_g2: pd.Series,
- fdr_g1_n1: pd.Series,
- lenght_scale_init: float = 10,
- lenght_scale_bounds: tuple = (1e1, 1e2),
- alpha_init: float = 0.1,
- alpha_bounds: tuple = (1e-3, 1.0),
- n_restarts_optimizer: int = 20,
- save_data: bool = True,
- restrict_to_upregulated: bool = True,
-) -> GaussianProcessClassifier:
- """Train a Gaussian Process Classifier on the log fold change values of two groups.
-
- Parameters
- ----------
- lfc_g1_g2
- Log fold change values of group 1 vs group 2.
- lfc_n1_g2
- Log fold change values of neighbors 1 vs group 2.
- fdr_g1_n1
- FDR values of group 1 vs neighbors 1 (boolean).
- lenght_scale_init
- Initial value for the length scale hyperparameter.
- lenght_scale_bounds
- Bounds for the length scale hyperparameter.
- alpha_init
- Initial value for the alpha hyperparameter.
- alpha_bounds
- Bounds for the alpha hyperparameter.
- n_restarts_optimizer
- Number of restarts for the optimizer.
- save_data
- If True, save the input data to the classifier.
- restrict_to_upregulated
- If True, restrict the classifier to upregulated genes,
- for the group 1 versus group 2 comparison.
-
- Returns
- -------
- GaussianProcessClassifier
- Trained Gaussian Process Classifier.
- """
- from sklearn.gaussian_process.kernels import (
- ConstantKernel as C,
- )
- from sklearn.gaussian_process.kernels import (
- RationalQuadratic,
- )
-
- kernel = C(1.0, (1e-3, 1e3)) * RationalQuadratic(
- length_scale=lenght_scale_init,
- length_scale_bounds=lenght_scale_bounds,
- alpha=alpha_init,
- alpha_bounds=alpha_bounds,
- )
-
- if restrict_to_upregulated:
- # Identify upregulated genes
- upregulated_g1_g2 = lfc_g1_g2[lfc_g1_g2 > 0]
- selected_genes = upregulated_g1_g2.index
-
- # Subset features and labels for training
- lfc_g1_g2_train = lfc_g1_g2.loc[selected_genes]
- lfc_n1_g2_train = lfc_n1_g2.loc[selected_genes]
- fdr_g1_n1_train = fdr_g1_n1.loc[selected_genes]
- else:
- selected_genes = lfc_g1_g2.index # Use all genes if not restricting
- lfc_g1_g2_train = lfc_g1_g2
- lfc_n1_g2_train = lfc_n1_g2
- fdr_g1_n1_train = fdr_g1_n1
-
- # The design matrix X is concat of lfc_g1_g2 and lfc_n1_g2:
- lfc_g1_g2_renamed = lfc_g1_g2.rename("lfc_g1_g2")
- lfc_n1_g2_renamed = lfc_n1_g2.rename("lfc_n1_g2")
- lfc_g1_g2_train_renamed = lfc_g1_g2_train.rename("lfc_g1_g2")
- lfc_n1_g2_train_renamed = lfc_n1_g2_train.rename("lfc_n1_g2")
-
- X = pd.concat(
- [lfc_g1_g2_renamed, lfc_n1_g2_renamed],
- axis=1,
- )
- X_train = pd.concat(
- [lfc_g1_g2_train_renamed, lfc_n1_g2_train_renamed],
- axis=1,
- )
-
- gpc = GaussianProcessClassifier(
- kernel=kernel, n_restarts_optimizer=n_restarts_optimizer, random_state=0
- ).fit(X_train, fdr_g1_n1_train)
-
- gpc.train_score_ = gpc.score(X_train, fdr_g1_n1_train)
- gene_probas = pd.Series(0.0, index=lfc_g1_g2.index)
- gene_probas.loc[selected_genes] = gpc.predict_proba(X_train)[:, 1]
- gpc.gene_probas_ = gene_probas
-
- if save_data:
- gpc.X_ = X
- gpc.y_ = fdr_g1_n1
-
- return gpc
diff --git a/src/scvi/external/scviva/differential_expression/_niche_de_core.py b/src/scvi/external/scviva/differential_expression/_niche_de_core.py
deleted file mode 100644
index b1e610db3f..0000000000
--- a/src/scvi/external/scviva/differential_expression/_niche_de_core.py
+++ /dev/null
@@ -1,233 +0,0 @@
-from collections.abc import Iterable as IterableClass
-
-import pandas as pd
-from rich import print
-
-from scvi import REGISTRY_KEYS
-from scvi.external.scviva import SCVIVA_REGISTRY_KEYS
-from scvi.model.base._de_core import _fdr_de_prediction, _prepare_obs
-from scvi.model.base._differential import DifferentialComputation
-from scvi.utils import track
-
-from ._de_utils import _get_nonzero_indices_from_rows, adjusted_nearest_neighbors
-from ._marker_classifier import _gaussian_process_classifier
-from ._results_dataclass import DifferentialExpressionResults
-
-
-def _niche_de_core(
- adata_manager,
- model_fn,
- representation_fn,
- groupby,
- group1,
- group2,
- idx1,
- idx2,
- all_stats,
- all_stats_fn,
- col_names,
- mode,
- batchid1,
- batchid2,
- delta,
- batch_correction,
- fdr,
- silent,
- ###### scVIVA specific ######
- radius=None,
- k_nn=None,
- lfc_select: str = "lfc_median",
- n_restarts_optimizer_gpc: int = 10,
- return_neighbors_idx: bool = True,
- **kwargs,
-) -> DifferentialExpressionResults:
- """Internal function for DE interface."""
- adata = adata_manager.adata
- if group1 is None and idx1 is None:
- group1 = adata.obs[groupby].astype("category").cat.categories.tolist()
- if len(group1) == 1:
- raise ValueError("Only a single group in the data. Can't run DE on a single group.")
-
- if not isinstance(group1, IterableClass) or isinstance(group1, str):
- group1 = [group1]
-
- # make a temp obs key using indices
- temp_key = None
- if idx1 is not None:
- obs_col, group1, group2 = _prepare_obs(idx1, idx2, adata)
- temp_key = "_scvi_temp_de"
- adata.obs[temp_key] = obs_col
- groupby = temp_key
-
- cell_samples = adata_manager.get_from_registry(SCVIVA_REGISTRY_KEYS.SAMPLE_KEY)
- cell_labels = adata_manager.get_from_registry(REGISTRY_KEYS.LABELS_KEY)
- cell_coordinates = adata_manager.get_from_registry(SCVIVA_REGISTRY_KEYS.CELL_COORDINATES_KEY)
-
- # don't compute adjusted nearest neighbors if already computed
- if "adjusted_A" in adata.uns.keys():
- A = adata.uns["adjusted_A"]
- else:
- print("Computing adjusted nearest neighbors...")
- A = adjusted_nearest_neighbors(
- cell_samples=cell_samples,
- cell_coordinates=cell_coordinates,
- cell_labels=cell_labels,
- radius=radius,
- k_nn=k_nn,
- return_sparse=True,
- )
-
- adata.uns["adjusted_A"] = A
-
- print("Computing DE...")
- if group2 is not None:
- comparisons = [
- "group1_group2",
- "group1_neighbors1",
- "neighbors1_group2",
- "neighbors1_neighbors2",
- ]
- DE_results = {comparison: [] for comparison in comparisons}
-
- else:
- comparisons = ["group1_group2", "group1_neighbors1", "neighbors1_group2"]
- DE_results = {comparison: [] for comparison in comparisons}
- dc = DifferentialComputation(model_fn, representation_fn, adata_manager)
- for g1 in track(
- group1,
- description="DE...",
- disable=silent,
- ):
- cell_idx1 = (adata.obs[groupby] == g1).to_numpy().ravel()
- neighbors_idx1 = _get_nonzero_indices_from_rows(A, cell_idx1)
-
- if group2 is None:
- cell_idx2 = ~cell_idx1
-
- DE_indices = {
- "group1_group2": [cell_idx1, cell_idx2],
- "group1_neighbors1": [cell_idx1, neighbors_idx1],
- "neighbors1_group2": [neighbors_idx1, cell_idx2],
- }
- DE_group_names = {
- "group1_group2": [g1, "Rest"],
- "group1_neighbors1": [g1, f"{g1}_neighbors"],
- "neighbors1_group2": [f"{g1}_neighbors", "Rest"],
- }
-
- else:
- cell_idx2 = (adata.obs[groupby] == group2).to_numpy().ravel()
- neighbors_idx2 = _get_nonzero_indices_from_rows(A, cell_idx2)
- DE_indices = {
- "group1_group2": [cell_idx1, cell_idx2],
- "group1_neighbors1": [cell_idx1, neighbors_idx1],
- "neighbors1_group2": [neighbors_idx1, cell_idx2],
- "neighbors1_neighbors2": [neighbors_idx1, neighbors_idx2],
- }
- DE_group_names = {
- "group1_group2": [g1, group2],
- "group1_neighbors1": [g1, f"{g1}_neighbors"],
- "neighbors1_group2": [f"{g1}_neighbors", group2],
- "neighbors1_neighbors2": [f"{g1}_neighbors", f"{group2}_neighbors"],
- }
-
- # Ensure fdr and delta are lists of the correct length
- if isinstance(fdr, list):
- assert len(fdr) == len(comparisons), (
- f"Mismatch: len(fdr)={len(fdr)}, expected={len(comparisons)}"
- )
- else:
- fdr = [fdr] * len(comparisons) # Convert to list of same value
-
- if isinstance(delta, list):
- assert len(delta) == len(comparisons), (
- f"Mismatch: len(delta)={len(delta)}, expected={len(comparisons)}"
- )
- else:
- delta = [delta] * len(comparisons) # Convert to list of same value
-
- # Assign FDR and Delta values
- DE_group_fdr = dict(zip(comparisons, fdr, strict=False))
- DE_group_delta = dict(zip(comparisons, delta, strict=False))
-
- for comparison, [cell_idx_a, cell_idx_b] in DE_indices.items():
- print(f"Running DE for {comparison}")
-
- all_info = dc.get_bayes_factors(
- cell_idx_a,
- cell_idx_b,
- mode=mode,
- delta=DE_group_delta[comparison],
- batchid1=batchid1,
- batchid2=batchid2,
- use_observed_batches=not batch_correction,
- test_mode="three",
- **kwargs,
- )
-
- if all_stats is True:
- genes_properties_dict = all_stats_fn(adata_manager, cell_idx_a, cell_idx_b)
- all_info = {**all_info, **genes_properties_dict}
-
- res = pd.DataFrame(all_info, index=col_names)
- sort_key = "proba_de" if mode == "change" else "bayes_factor"
- res = res.sort_values(by=sort_key, ascending=False)
- if mode == "change":
- res[f"is_de_fdr_{DE_group_fdr[comparison]}"] = _fdr_de_prediction(
- res["proba_de"], fdr=DE_group_fdr[comparison]
- )
- if idx1 is None:
- g1_name = DE_group_names[comparison][0]
- g2_name = DE_group_names[comparison][1]
- res["comparison"] = f"{g1_name} vs {g2_name}"
- res["group1"] = g1_name
- res["group2"] = g2_name
- DE_results[comparison].append(res)
-
- if temp_key is not None:
- del adata.obs[temp_key]
-
- DE_results["group1_group2"] = pd.concat(DE_results["group1_group2"], axis=0)
- idx_g1_g2 = DE_results["group1_group2"].index
-
- if group2 is None:
- group2 = "Rest"
-
- for groups in list(DE_results.keys())[1:]:
- group_DE_result = DE_results[groups]
- DE_results[groups] = pd.concat(group_DE_result, axis=0).reindex(idx_g1_g2)
-
- # fit the classifier
- lfc_g1_g2 = DE_results["group1_group2"][lfc_select]
- lfc_n1_g2 = DE_results["neighbors1_group2"][lfc_select]
- fdr_g1_n1 = DE_results["group1_neighbors1"][
- f"is_de_fdr_{DE_group_fdr['group1_neighbors1']}"
- ].copy()
- fdr_g1_n1.loc[DE_results["group1_neighbors1"][lfc_select] < 0] = False
-
- if fdr_g1_n1.sum() == 0:
- raise ValueError("No DE genes found between group1 and neighbors1.")
-
- print("Computing g1 confidence scores...")
- gpc_ = _gaussian_process_classifier(
- lfc_g1_g2,
- lfc_n1_g2,
- fdr_g1_n1,
- n_restarts_optimizer=n_restarts_optimizer_gpc,
- restrict_to_upregulated=True,
- )
-
- for groups in list(DE_results.keys()):
- DE_results[groups]["proba_de_g1_n1"] = gpc_.gene_probas_
-
- return DifferentialExpressionResults(
- gpc=gpc_,
- g1_g2=DE_results["group1_group2"],
- g1_n1=DE_results["group1_neighbors1"],
- n1_g2=DE_results["neighbors1_group2"],
- n1_n2=DE_results["neighbors1_neighbors2"]
- if "neighbors1_neighbors2" in DE_results
- else None,
- n1_index=neighbors_idx1 if return_neighbors_idx else None,
- n2_index=neighbors_idx2 if return_neighbors_idx and group2 != "Rest" else None,
- )
diff --git a/src/scvi/external/scviva/differential_expression/_results_dataclass.py b/src/scvi/external/scviva/differential_expression/_results_dataclass.py
deleted file mode 100644
index 763c6cd8e9..0000000000
--- a/src/scvi/external/scviva/differential_expression/_results_dataclass.py
+++ /dev/null
@@ -1,260 +0,0 @@
-from collections.abc import Iterable
-from dataclasses import dataclass, field
-from typing import Union
-
-import numpy as np
-import pandas as pd
-from rich import print
-from sklearn.gaussian_process import GaussianProcessClassifier
-
-from scvi.utils import dependencies
-
-
-@dataclass
-class DifferentialExpressionResults:
- """Dataclass for storing the results of the differential expression analysis,
- including the GP classifier
- """ # noqa: D205
-
- gpc: GaussianProcessClassifier
- g1_g2: pd.DataFrame
- g1_n1: pd.DataFrame
- n1_g2: pd.DataFrame
- n1_n2: Union[pd.DataFrame, None] = field(default=None) # noqa: UP007
- n1_index: Union[np.array, None] = field(default=None) # noqa: UP007
- n2_index: Union[np.array, None] = field(default=None) # noqa: UP007
-
- def gpc_info(self):
- """Print the log marginal likelihood value and the kernel
- of the Gaussian Process Classifier
-
- """ # noqa: D205
- print("Training score: ", self.gpc.train_score_)
- print("Marginal likelihood: ", self.gpc.log_marginal_likelihood_value_)
- print("Kernel: ", self.gpc.kernel_)
-
- @dependencies("matplotlib")
- def plot(
- self,
- X: pd.DataFrame | None = None,
- y: pd.Series | None = None,
- filter: Iterable | None = None,
- background_filter: Iterable | None = None,
- markersize: int = 50,
- fontsize: int = 10,
- chosen_colormap: str = "seismic",
- path_to_save: str | None = None,
- show_plot: bool = True,
- dpi: int = 1000,
- margin: float = 0.1,
- manual_limits: tuple | None = None,
- legend_loc: str = "upper right",
- ) -> None:
- """Plot the results of the differential expression analysis.
-
- Parameters
- ----------
- X
- Design matrix: log fold changes for group 1 vs group 2 and neighbors 1 vs group 2.
- If None, the design matrix from the classifier is used.
- y
- Labels: FDR values for group 1 vs neighbors 1.
- If None, the labels from the classifier are used.
- filter
- Genes to include in the plot.
- background_filter
- Genes to include in the plot as background.
- markersize
- Size of the markers.
- fontsize
- Font size for the gene names.
- chosen_colormap
- Colormap to use for the decision boundary.
- path_to_save
- Path to save the plot.
- show_plot
- Whether to show the plot. Default: True.
- dpi
- Resolution of the saved plot.
- margin
- Margin to add to the plot limits.
- manual_limits
- Manual limits for the plot.
- legend_loc
- Location of the legend.
-
- Returns
- -------
- None
- """
- import matplotlib.colors as mcolors
- import matplotlib.pyplot as plt
- import numpy as np
- from matplotlib.font_manager import FontProperties
- from sklearn.inspection import DecisionBoundaryDisplay
-
- # Set SVG font type to 'none' to keep text as text in SVG files
- plt.rcParams["svg.fonttype"] = "none"
- plt.rcParams["font.size"] = fontsize
- italic_font = FontProperties(style="italic", size=fontsize - 2)
-
- if X is None:
- X = self.gpc.X_
- y = self.gpc.y_
-
- if filter is None:
- filter = X.index
-
- X_display = X.loc[filter].values
- fdr_g1_n1_display = y.loc[filter]
- lfc_g1_g2_display = X.iloc[:, 0].loc[filter]
- lfc_n1_g2_display = X.iloc[:, 1].loc[filter]
-
- self.gpc.confident_genes = fdr_g1_n1_display.index[fdr_g1_n1_display]
-
- if background_filter is not None:
- X_background = X.loc[background_filter].values
- lfc_g1_g2_bg = X.iloc[:, 0].loc[background_filter]
- lfc_n1_g2_bg = X.iloc[:, 1].loc[background_filter]
-
- # Determine plot limits using both main and background data, with adjustable margin
- x_min = min(lfc_g1_g2_display.min(), lfc_g1_g2_bg.min()) - margin
- x_max = max(lfc_g1_g2_display.max(), lfc_g1_g2_bg.max()) + margin
- y_min = min(lfc_n1_g2_display.min(), lfc_n1_g2_bg.min()) - margin
- y_max = max(lfc_n1_g2_display.max(), lfc_n1_g2_bg.max()) + margin
-
- else:
- x_min, x_max = lfc_g1_g2_display.min() - margin, lfc_g1_g2_display.max() + margin
- y_min, y_max = lfc_n1_g2_display.min() - margin, lfc_n1_g2_display.max() + margin
-
- if manual_limits is not None:
- x_min, x_max, y_min, y_max = manual_limits
-
- # Create a larger figure and axes explicitly
- fig, ax = plt.subplots(figsize=(8, 8))
-
- # Manually create mesh grid for the decision boundary
- xx, yy = np.meshgrid(
- np.linspace(x_min, x_max, 100),
- np.linspace(y_min, y_max, 100),
- )
-
- # Create a custom colormap with alpha transparency
- cmap = plt.get_cmap(chosen_colormap)
- cmap_with_alpha = cmap(np.arange(cmap.N))
- cmap_with_alpha[:, -1] = 0.6 # Set the alpha channel
- chosen_colormap = mcolors.ListedColormap(cmap_with_alpha)
-
- # Generate the decision boundary with custom limits
- disp = DecisionBoundaryDisplay.from_estimator(
- self.gpc,
- np.c_[xx.ravel(), yy.ravel()],
- response_method="predict_proba",
- xlabel="LFC group1-group2",
- ylabel="LFC neighbors1-group2",
- ax=ax,
- cmap=chosen_colormap,
- )
-
- # Set the limits to ensure the decision boundary display matches the full range of data
- ax.set_xlim(x_min, x_max)
- ax.set_ylim(y_min, y_max)
-
- # Plot the identity line using the retrieved limits with np.linspace
- line_points = np.linspace(min(x_min, y_min), max(x_max, y_max), 100)
- ax.plot(line_points, line_points, "k--", alpha=0.85, zorder=0)
-
- # Plot background points in light grey if background_filter is provided
- if background_filter is not None:
- disp.ax_.scatter(
- X_background[:, 0],
- X_background[:, 1],
- c="lightgrey",
- edgecolor="none",
- s=markersize,
- alpha=0.3,
- )
- for _i, gene in enumerate(background_filter):
- ax.annotate(
- gene,
- (lfc_g1_g2_bg[gene], lfc_n1_g2_bg[gene]),
- xytext=(0, 5),
- textcoords="offset points",
- fontsize=fontsize - 2,
- fontproperties=italic_font,
- )
-
- # Scatter plot with fixed colors for True (yellow) and False (green) DE genes
- colors = np.where(fdr_g1_n1_display, "yellow", "green")
- disp.ax_.scatter(X_display[:, 0], X_display[:, 1], c=colors, edgecolor="k", s=markersize)
-
- # Manually add a colorbar for the decision boundary
- sm = plt.cm.ScalarMappable(cmap=chosen_colormap, norm=mcolors.Normalize(vmin=0, vmax=1))
- cbar = plt.colorbar(sm, ax=ax)
- cbar.set_label("Decision Boundary Probability", rotation=270, labelpad=15)
-
- # Add legend for scatter plot
- legend_elements = [
- plt.Line2D(
- [0],
- [0],
- marker="o",
- color="w",
- label="DE g1_n1 True",
- markerfacecolor="yellow",
- markersize=8,
- ),
- plt.Line2D(
- [0],
- [0],
- marker="o",
- color="w",
- label="DE g1_n1 False",
- markerfacecolor="green",
- markersize=8,
- ),
- ]
-
- # Add legend for background points if background_filter is provided
- if background_filter is not None:
- legend_elements += [
- plt.Line2D(
- [0],
- [0],
- marker="o",
- color="w",
- label="DE g1_g2 in background",
- markerfacecolor="lightgrey",
- markersize=8,
- )
- ]
- disp.ax_.legend(handles=legend_elements, loc=legend_loc)
-
- # Annotate gene names
- for _i, gene in enumerate(fdr_g1_n1_display.index):
- ax.annotate(
- gene,
- (
- lfc_g1_g2_display[gene],
- lfc_n1_g2_display[gene],
- ),
- xytext=(0, 5),
- textcoords="offset points",
- fontsize=fontsize,
- )
-
- ax.set_aspect("auto") # Allow the plot to adjust freely
-
- # Adjust layout and save/show plot
- plt.tight_layout()
- if path_to_save is not None:
- plt.savefig(
- path_to_save,
- bbox_inches="tight",
- dpi=dpi,
- )
-
- if show_plot:
- plt.show()
- else:
- plt.close()
diff --git a/src/scvi/external/stereoscope/__init__.py b/src/scvi/external/stereoscope/__init__.py
deleted file mode 100644
index 2f3027c931..0000000000
--- a/src/scvi/external/stereoscope/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from ._model import RNAStereoscope, SpatialStereoscope
-from ._module import RNADeconv, SpatialDeconv
-
-__all__ = ["RNAStereoscope", "SpatialStereoscope", "RNADeconv", "SpatialDeconv"]
diff --git a/src/scvi/external/stereoscope/_model.py b/src/scvi/external/stereoscope/_model.py
deleted file mode 100644
index 845e653906..0000000000
--- a/src/scvi/external/stereoscope/_model.py
+++ /dev/null
@@ -1,394 +0,0 @@
-from __future__ import annotations
-
-import logging
-import warnings
-from typing import TYPE_CHECKING
-
-import numpy as np
-import pandas as pd
-import torch
-
-from scvi import REGISTRY_KEYS, settings
-from scvi.data import AnnDataManager
-from scvi.data.fields import CategoricalObsField, LayerField, NumericalObsField
-from scvi.external.stereoscope._module import RNADeconv, SpatialDeconv
-from scvi.model.base import BaseModelClass, UnsupervisedTrainingMixin
-from scvi.train._config import merge_kwargs
-from scvi.utils import setup_anndata_dsp
-from scvi.utils._docstrings import devices_dsp
-
-if TYPE_CHECKING:
- from typing import Literal
-
- from anndata import AnnData
-
-logger = logging.getLogger(__name__)
-
-
-class RNAStereoscope(UnsupervisedTrainingMixin, BaseModelClass):
- """Reimplementation of Stereoscope :cite:p:`Andersson20`.
-
- Deconvolution of spatial transcriptomics from single-cell transcriptomics. Original
- implementation: https://github.com/almaan/stereoscope.
-
- Parameters
- ----------
- sc_adata
- single-cell AnnData object that has been registered via
- :meth:`~scvi.external.RNAStereoscope.setup_anndata`.
- **model_kwargs
- Keyword args for :class:`~scvi.external.stereoscope.RNADeconv`
-
- Examples
- --------
- >>> sc_adata = anndata.read_h5ad(path_to_sc_anndata)
- >>> scvi.external.RNAStereoscope.setup_anndata(sc_adata, labels_key="labels")
- >>> stereo = scvi.external.stereoscope.RNAStereoscope(sc_adata)
- >>> stereo.train()
-
- Notes
- -----
- See further usage examples in the following tutorial:
-
- 1. :doc:`/tutorials/notebooks/spatial/stereoscope_heart_LV_tutorial`
- """
-
- def __init__(
- self,
- sc_adata: AnnData,
- **model_kwargs,
- ):
- warnings.warn(
- "RNAStereoscope is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- super().__init__(sc_adata)
- self.n_genes = self.summary_stats.n_vars
- self.n_labels = self.summary_stats.n_labels
- # first we have the scRNA-seq model
- self.module = RNADeconv(
- n_genes=self.n_genes,
- n_labels=self.n_labels,
- **model_kwargs,
- )
- self._model_summary_string = (
- f"RNADeconv Model with params: \nn_genes: {self.n_genes}, n_labels: {self.n_labels}"
- )
- self.init_params_ = self._get_init_params(locals())
-
- @devices_dsp.dedent
- def train(
- self,
- max_epochs: int = 400,
- lr: float = 0.01,
- accelerator: str = "auto",
- devices: int | list[int] | str = "auto",
- train_size: float = 1,
- validation_size: float | None = None,
- shuffle_set_split: bool = True,
- batch_size: int = 128,
- datasplitter_kwargs: dict | None = None,
- plan_kwargs: dict | None = None,
- **kwargs,
- ):
- """Trains the model using MAP inference.
-
- Parameters
- ----------
- max_epochs
- Number of epochs to train for
- lr
- Learning rate for optimization.
- %(param_accelerator)s
- %(param_devices)s
- train_size
- Size of the training set in the range [0.0, 1.0].
- validation_size
- Size of the test set. If `None`, defaults to 1 - `train_size`. If
- `train_size + validation_size < 1`, the remaining cells belong to a test set.
- shuffle_set_split
- Whether to shuffle indices before splitting. If `False`, the val, train, and test set
- are split in the sequential order of the data according to `validation_size` and
- `train_size` percentages.
- batch_size
- Minibatch size to use during training.
- datasplitter_kwargs
- Additional keyword arguments passed into :class:`~scvi.dataloaders.DataSplitter`.
- plan_kwargs
- Keyword args for :class:`~scvi.train.TrainingPlan`. Keyword arguments passed to
- `train()` will overwrite values present in `plan_kwargs`, when appropriate.
- **kwargs
- Other keyword args for :class:`~scvi.train.Trainer`.
- """
- update_dict = {
- "lr": lr,
- }
- plan_kwargs = merge_kwargs(None, plan_kwargs, name="plan")
- plan_kwargs.update(update_dict)
-
- super().train(
- max_epochs=max_epochs,
- accelerator=accelerator,
- devices=devices,
- train_size=train_size,
- validation_size=validation_size,
- shuffle_set_split=shuffle_set_split,
- batch_size=batch_size,
- datasplitter_kwargs=datasplitter_kwargs,
- plan_kwargs=plan_kwargs,
- **kwargs,
- )
-
- @classmethod
- @setup_anndata_dsp.dedent
- def setup_anndata(
- cls,
- adata: AnnData,
- labels_key: str | None = None,
- layer: str | None = None,
- **kwargs,
- ):
- """%(summary)s.
-
- Parameters
- ----------
- %(param_labels_key)s
- %(param_layer)s
- """
- setup_method_args = cls._get_setup_method_args(**locals())
- anndata_fields = [
- LayerField(REGISTRY_KEYS.X_KEY, layer, is_count_data=True),
- CategoricalObsField(REGISTRY_KEYS.LABELS_KEY, labels_key),
- ]
- adata_manager = AnnDataManager(fields=anndata_fields, setup_method_args=setup_method_args)
- adata_manager.register_fields(adata, **kwargs)
- cls.register_manager(adata_manager)
-
-
-class SpatialStereoscope(UnsupervisedTrainingMixin, BaseModelClass):
- """Reimplementation of Stereoscope :cite:p:`Andersson20`.
-
- Deconvolution of spatial transcriptomics from single-cell transcriptomics. Original
- implementation: https://github.com/almaan/stereoscope.
-
- Parameters
- ----------
- st_adata
- spatial transcriptomics AnnData object that has been registered via
- :meth:`~scvi.external.SpatialStereoscope.setup_anndata`.
- sc_params
- parameters of the model learned from the single-cell RNA seq data for deconvolution.
- cell_type_mapping
- numpy array mapping for the cell types used in the deconvolution
- prior_weight
- how to reweight the minibatches for stochastic optimization. "n_obs" is the valid
- procedure, "minibatch" is the procedure implemented in Stereoscope.
- **model_kwargs
- Keyword args for :class:`~scvi.external.stereoscope.SpatialDeconv`
-
- Examples
- --------
- >>> sc_adata = anndata.read_h5ad(path_to_sc_anndata)
- >>> scvi.external.RNAStereoscope.setup_anndata(sc_adata, labels_key="labels")
- >>> sc_model = scvi.external.stereoscope.RNAStereoscope(sc_adata)
- >>> sc_model.train()
- >>> st_adata = anndata.read_h5ad(path_to_st_anndata)
- >>> scvi.external.SpatialStereoscope.setup_anndata(st_adata)
- >>> stereo = scvi.external.stereoscope.SpatialStereoscope.from_rna_model(st_adata, sc_model)
- >>> stereo.train()
- >>> st_adata.obsm["deconv"] = stereo.get_proportions()
-
- Notes
- -----
- See further usage examples in the following tutorials:
-
- 1. :doc:`/tutorials/notebooks/spatial/stereoscope_heart_LV_tutorial`
- """
-
- def __init__(
- self,
- st_adata: AnnData,
- sc_params: tuple[np.ndarray],
- cell_type_mapping: np.ndarray,
- prior_weight: Literal["n_obs", "minibatch"] = "n_obs",
- **model_kwargs,
- ):
- warnings.warn(
- "SpatialStereoscope is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- super().__init__(st_adata)
- self.module = SpatialDeconv(
- n_spots=st_adata.n_obs,
- sc_params=sc_params,
- prior_weight=prior_weight,
- **model_kwargs,
- )
- self._model_summary_string = f"RNADeconv Model with params: \nn_spots: {st_adata.n_obs}"
- self.cell_type_mapping = cell_type_mapping
- self.init_params_ = self._get_init_params(locals())
-
- @classmethod
- def from_rna_model(
- cls,
- st_adata: AnnData,
- sc_model: RNAStereoscope,
- prior_weight: Literal["n_obs", "minibatch"] = "n_obs",
- **model_kwargs,
- ):
- """Alternate constructor for exploiting a pre-trained model on RNA-seq data.
-
- Parameters
- ----------
- st_adata
- registered anndata object
- sc_model
- trained RNADeconv model
- prior_weight
- how to reweight the minibatches for stochastic optimization. "n_obs" is the valid
- procedure, "minibatch" is the procedure implemented in Stereoscope.
- **model_kwargs
- Keyword args for :class:`~scvi.external.stereoscope.SpatialDeconv`
- """
- return cls(
- st_adata,
- sc_model.module.get_params(),
- sc_model.adata_manager.get_state_registry(
- REGISTRY_KEYS.LABELS_KEY
- ).categorical_mapping,
- prior_weight=prior_weight,
- **model_kwargs,
- )
-
- def get_proportions(self, keep_noise=False) -> pd.DataFrame:
- """Returns the estimated cell type proportion for the spatial data.
-
- Shape is n_cells x n_labels OR n_cells x (n_labels + 1) if keep_noise
-
- Parameters
- ----------
- keep_noise
- whether to account for the noise term as a standalone cell type in the proportion
- estimate.
- """
- self._check_if_trained()
-
- column_names = self.cell_type_mapping
- if keep_noise:
- column_names = column_names.append("noise_term")
- return pd.DataFrame(
- data=self.module.get_proportions(keep_noise),
- columns=column_names,
- index=self.adata.obs.index,
- )
-
- def get_scale_for_ct(
- self,
- y: np.ndarray,
- ) -> np.ndarray:
- r"""Calculate the cell-type-specific expression.
-
- Parameters
- ----------
- y
- numpy array containing the list of cell types
-
- Returns
- -------
- gene_expression
- """
- self._check_if_trained()
- ind_y = np.array([np.where(ct == self.cell_type_mapping)[0][0] for ct in y])
- if ind_y.shape != y.shape:
- raise ValueError(
- "Incorrect shape after matching cell types to reference mapping. Please check "
- "cell type query."
- )
- px_scale = self.module.get_ct_specific_expression(torch.tensor(ind_y)[:, None])
- return np.array(px_scale.cpu())
-
- @devices_dsp.dedent
- def train(
- self,
- max_epochs: int = 400,
- lr: float = 0.01,
- accelerator: str = "auto",
- devices: int | list[int] | str = "auto",
- shuffle_set_split: bool = True,
- batch_size: int = 128,
- datasplitter_kwargs: dict | None = None,
- plan_kwargs: dict | None = None,
- **kwargs,
- ):
- """Trains the model using MAP inference.
-
- Parameters
- ----------
- max_epochs
- Number of epochs to train for
- lr
- Learning rate for optimization.
- %(param_accelerator)s
- %(param_devices)s
- shuffle_set_split
- Whether to shuffle indices before splitting. If `False`, the val, train, and test set
- are split in the sequential order of the data according to `validation_size` and
- `train_size` percentages.
- batch_size
- Minibatch size to use during training.
- datasplitter_kwargs
- Additional keyword arguments passed into :class:`~scvi.dataloaders.DataSplitter`.
- plan_kwargs
- Keyword args for :class:`~scvi.train.TrainingPlan`. Keyword arguments passed to
- `train()` will overwrite values present in `plan_kwargs`, when appropriate.
- **kwargs
- Other keyword args for :class:`~scvi.train.Trainer`.
- """
- update_dict = {
- "lr": lr,
- }
- plan_kwargs = merge_kwargs(None, plan_kwargs, name="plan")
- plan_kwargs.update(update_dict)
- super().train(
- max_epochs=max_epochs,
- accelerator=accelerator,
- devices=devices,
- train_size=1,
- validation_size=None,
- shuffle_set_split=shuffle_set_split,
- batch_size=batch_size,
- datasplitter_kwargs=datasplitter_kwargs,
- plan_kwargs=plan_kwargs,
- **kwargs,
- )
-
- @classmethod
- @setup_anndata_dsp.dedent
- def setup_anndata(
- cls,
- adata: AnnData,
- layer: str | None = None,
- **kwargs,
- ):
- """%(summary)s.
-
- Parameters
- ----------
- %(param_layer)s
- """
- setup_method_args = cls._get_setup_method_args(**locals())
- # add the index for each cell (provided to pyro plate for correct minibatching)
- adata.obs["_indices"] = np.arange(adata.n_obs)
- anndata_fields = [
- LayerField(REGISTRY_KEYS.X_KEY, layer, is_count_data=True),
- NumericalObsField(REGISTRY_KEYS.INDICES_KEY, "_indices"),
- ]
- adata_manager = AnnDataManager(fields=anndata_fields, setup_method_args=setup_method_args)
- adata_manager.register_fields(adata, **kwargs)
- cls.register_manager(adata_manager)
diff --git a/src/scvi/external/stereoscope/_module.py b/src/scvi/external/stereoscope/_module.py
deleted file mode 100644
index 896125429f..0000000000
--- a/src/scvi/external/stereoscope/_module.py
+++ /dev/null
@@ -1,265 +0,0 @@
-from typing import Literal
-
-import numpy as np
-import torch
-from torch.distributions import NegativeBinomial, Normal
-
-from scvi import REGISTRY_KEYS
-from scvi.module.base import BaseModuleClass, LossOutput, auto_move_data
-
-
-class RNADeconv(BaseModuleClass):
- """Model of scRNA-seq for deconvolution of spatial transcriptomics.
-
- Reimplementation of the ScModel module of Stereoscope :cite:p:`Andersson20`:
- https://github.com/almaan/stereoscope/blob/master/stsc/models.py.
-
- Parameters
- ----------
- n_genes
- Number of input genes
- n_labels
- Number of input cell types
- **model_kwargs
- Additional kwargs
- """
-
- def __init__(
- self,
- n_genes: int,
- n_labels: int,
- **model_kwargs,
- ):
- super().__init__()
- self.n_genes = n_genes
- self.n_labels = n_labels
-
- # logit param for negative binomial
- self.px_o = torch.nn.Parameter(torch.randn(self.n_genes))
- self.W = torch.nn.Parameter(
- torch.randn(self.n_genes, self.n_labels)
- ) # n_genes, n_cell types
-
- if "ct_weight" in model_kwargs:
- ct_weight = torch.tensor(model_kwargs["ct_prop"], dtype=torch.float32)
- else:
- ct_weight = torch.ones((self.n_labels,), dtype=torch.float32)
- self.register_buffer("ct_weight", ct_weight)
-
- @torch.inference_mode()
- def get_params(self) -> tuple[np.ndarray]:
- """Returns the parameters for feeding into the spatial data.
-
- Returns
- -------
- type
- list of tensor
- """
- return self.W.cpu().numpy(), self.px_o.cpu().numpy()
-
- def _get_inference_input(self, tensors):
- # we perform MAP here, so there is nothing to infer
- return {}
-
- def _get_generative_input(self, tensors, inference_outputs):
- x = tensors[REGISTRY_KEYS.X_KEY]
- y = tensors[REGISTRY_KEYS.LABELS_KEY]
-
- input_dict = {"x": x, "y": y}
- return input_dict
-
- @auto_move_data
- def inference(self):
- """Inference."""
- return {}
-
- @auto_move_data
- def generative(self, x, y):
- """Simply build the negative binomial parameters for every cell in the minibatch."""
- px_scale = torch.nn.functional.softplus(self.W)[:, y.long().ravel()].T # cells per gene
- library = torch.sum(x, dim=1, keepdim=True)
- px_rate = library * px_scale
- scaling_factor = self.ct_weight[y.long().ravel()]
-
- return {
- "px_scale": px_scale,
- "px_o": self.px_o,
- "px_rate": px_rate,
- "library": library,
- "scaling_factor": scaling_factor,
- }
-
- def loss(
- self,
- tensors,
- inference_outputs,
- generative_outputs,
- kl_weight: float = 1.0,
- ):
- """Loss computation."""
- x = tensors[REGISTRY_KEYS.X_KEY]
- px_rate = generative_outputs["px_rate"]
- px_o = generative_outputs["px_o"]
- scaling_factor = generative_outputs["scaling_factor"]
-
- reconst_loss = -NegativeBinomial(px_rate, logits=px_o).log_prob(x).sum(-1)
- loss = torch.sum(scaling_factor * reconst_loss)
-
- return LossOutput(loss=loss, reconstruction_loss=reconst_loss)
-
- @torch.inference_mode()
- def sample(
- self,
- tensors,
- n_samples=1,
- library_size=1,
- ):
- """Sample from the model."""
- raise NotImplementedError("No sampling method for Stereoscope")
-
-
-class SpatialDeconv(BaseModuleClass):
- """Model of single-cell RNA-sequencing data for deconvolution of spatial transcriptomics.
-
- Reimplementation of the STModel module of Stereoscope :cite:p:`Andersson20`:
- https://github.com/almaan/stereoscope/blob/master/stsc/models.py.
-
- Parameters
- ----------
- n_spots
- Number of input spots
- sc_params
- Tuple of ndarray of shapes [(n_genes, n_labels), (n_genes)] containing the dictionary and
- log dispersion parameters
- prior_weight
- Whether to sample the minibatch by the number of total observations or the minibatch size
- """
-
- def __init__(
- self,
- n_spots: int,
- sc_params: tuple[np.ndarray],
- prior_weight: Literal["n_obs", "minibatch"] = "n_obs",
- ):
- super().__init__()
- # unpack and copy parameters
- w, px_o = sc_params
- self.register_buffer("W", torch.tensor(w))
- self.register_buffer("px_o", torch.tensor(px_o))
-
- # setup constants
- self.n_spots = n_spots
- self.n_genes, self.n_labels = self.W.shape
- self.prior_weight = prior_weight
-
- # noise from data
- self.eta = torch.nn.Parameter(torch.randn(self.n_genes))
- # factor loadings
- self.V = torch.nn.Parameter(torch.randn(self.n_labels + 1, self.n_spots))
- # additive gene bias
- self.beta = torch.nn.Parameter(0.01 * torch.randn(self.n_genes))
-
- @torch.inference_mode()
- def get_proportions(self, keep_noise=False) -> np.ndarray:
- """Returns the loadings."""
- # get estimated unadjusted proportions
- res = torch.nn.functional.softplus(self.V).cpu().numpy().T # n_spots, n_labels + 1
- # remove dummy cell type proportion values
- if not keep_noise:
- res = res[:, :-1]
- # normalize to obtain adjusted proportions
- res = res / res.sum(axis=1).reshape(-1, 1)
- return res
-
- def _get_inference_input(self, tensors):
- # we perform MAP here, so there is nothing to infer
- return {}
-
- def _get_generative_input(self, tensors, inference_outputs):
- x = tensors[REGISTRY_KEYS.X_KEY]
- ind_x = tensors[REGISTRY_KEYS.INDICES_KEY].long().ravel()
-
- input_dict = {"x": x, "ind_x": ind_x}
- return input_dict
-
- @auto_move_data
- def inference(self):
- """Inference."""
- return {}
-
- @auto_move_data
- def generative(self, x, ind_x):
- """Build the deconvolution model for every cell in the minibatch."""
- beta = torch.nn.functional.softplus(self.beta) # n_genes
- v = torch.nn.functional.softplus(self.V) # n_labels + 1, n_spots
- w = torch.nn.functional.softplus(self.W) # n_genes, n_labels
- eps = torch.nn.functional.softplus(self.eta) # n_genes
-
- # account for gene-specific bias and add noise
- r_hat = torch.cat(
- [beta.unsqueeze(1) * w, eps.unsqueeze(1)], dim=1
- ) # n_genes, n_labels + 1
- # subsample observations
- v_ind = v[:, ind_x] # labels + 1, batch_size
- px_rate = torch.transpose(torch.matmul(r_hat, v_ind), 0, 1) # batch_size, n_genes
-
- return {"px_o": self.px_o, "px_rate": px_rate, "eta": self.eta}
-
- def loss(
- self,
- tensors,
- inference_outputs,
- generative_outputs,
- kl_weight: float = 1.0,
- n_obs: int = 1.0,
- ):
- """Loss computation."""
- x = tensors[REGISTRY_KEYS.X_KEY]
- px_rate = generative_outputs["px_rate"]
- px_o = generative_outputs["px_o"]
-
- reconst_loss = -NegativeBinomial(px_rate, logits=px_o).log_prob(x).sum(-1)
- # prior likelihood
- mean = torch.zeros_like(self.eta)
- scale = torch.ones_like(self.eta)
- neg_log_likelihood_prior = -Normal(mean, scale).log_prob(self.eta).sum()
-
- if self.prior_weight == "n_obs":
- # the correct way to reweight observations while performing stochastic optimization
- loss = n_obs * torch.mean(reconst_loss) + neg_log_likelihood_prior
- else:
- # in the original way it is done in Stereoscope; we use this option to show
- # reproducibility of their codebase
- loss = torch.sum(reconst_loss) + neg_log_likelihood_prior
- return LossOutput(
- loss=loss,
- reconstruction_loss=reconst_loss,
- kl_global=neg_log_likelihood_prior,
- )
-
- @torch.inference_mode()
- def sample(
- self,
- tensors,
- n_samples=1,
- library_size=1,
- ):
- """Sample from the model."""
- raise NotImplementedError("No sampling method for Stereoscope")
-
- @torch.inference_mode()
- @auto_move_data
- def get_ct_specific_expression(self, y):
- """Returns cell-type-specific gene expression at the queried spots.
-
- Parameters
- ----------
- y
- cell types
- """
- # cell-type-specific gene expression. Conceptually of shape (minibatch, celltype, gene).
- # But in this case, it's the same for all spots with the same cell type
- beta = torch.nn.functional.softplus(self.beta) # n_genes
- w = torch.nn.functional.softplus(self.W) # n_genes, n_cell_types
- px_ct = torch.exp(self.px_o).unsqueeze(1) * beta.unsqueeze(1) * w
- return px_ct[:, y.long().ravel()].T # shape (minibatch, genes)
diff --git a/src/scvi/external/tangram/__init__.py b/src/scvi/external/tangram/__init__.py
deleted file mode 100644
index 9a7489da28..0000000000
--- a/src/scvi/external/tangram/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from ._model import Tangram
-from ._module import TangramMapper
-
-__all__ = ["Tangram", "TangramMapper"]
diff --git a/src/scvi/external/tangram/_model.py b/src/scvi/external/tangram/_model.py
deleted file mode 100644
index 8551c7dc48..0000000000
--- a/src/scvi/external/tangram/_model.py
+++ /dev/null
@@ -1,355 +0,0 @@
-import logging
-import warnings
-from typing import Literal
-
-import numpy as np
-import pandas as pd
-import scipy
-import torch
-from anndata import AnnData
-from mudata import MuData
-
-from scvi import settings
-from scvi.data import AnnDataManager, AnnDataManagerValidationCheck, fields
-from scvi.external.tangram._module import TANGRAM_REGISTRY_KEYS, TangramMapper
-from scvi.model._utils import parse_device_args
-from scvi.model.base import BaseModelClass
-from scvi.train import TrainingPlan
-from scvi.utils import setup_anndata_dsp, track
-from scvi.utils._docstrings import devices_dsp
-
-logger = logging.getLogger(__name__)
-
-
-class Tangram(BaseModelClass):
- """Torch reimplementation of Tangram :cite:p:`Biancalani21`.
-
- Maps single-cell RNA-seq data to spatial data. Original implementation:
- https://github.com/broadinstitute/Tangram.
-
- Currently the "cells" and "constrained" modes are implemented.
-
- Parameters
- ----------
- mdata
- MuData object that has been registered via :meth:`~scvi.external.Tangram.setup_mudata`.
- constrained
- Whether to use the constrained version of Tangram instead of cells mode.
- target_count
- The number of cells to be filtered. Necessary when `constrained` is True.
- **model_kwargs
- Keyword args for :class:`~scvi.external.tangram.TangramMapper`
-
- Examples
- --------
- >>> from scvi.external import Tangram
- >>> ad_sc = anndata.read_h5ad(path_to_sc_anndata)
- >>> ad_sp = anndata.read_h5ad(path_to_sp_anndata)
- >>> markers = pd.read_csv(path_to_markers, index_col=0) # genes to use for mapping
- >>> mdata = mudata.MuData(
- {
- "sp_full": ad_sp,
- "sc_full": ad_sc,
- "sp": ad_sp[:, markers].copy(),
- "sc": ad_sc[:, markers].copy()
- }
- )
- >>> modalities = {"density_prior_key": "sp", "sc_layer": "sc", "sp_layer": "sp"}
- >>> Tangram.setup_mudata(
- mdata, density_prior_key="rna_count_based_density", modalities=modalities
- )
- >>> tangram = Tangram(sc_adata)
- >>> tangram.train()
- >>> ad_sc.obsm["tangram_mapper"] = tangram.get_mapper_matrix()
- >>> ad_sp.obsm["tangram_cts"] = tangram.project_cell_annotations(
- ad_sc, ad_sp, ad_sc.obsm["tangram_mapper"], ad_sc.obs["labels"]
- )
- >>> projected_ad_sp = tangram.project_genes(ad_sc, ad_sp, ad_sc.obsm["tangram_mapper"])
-
- Notes
- -----
- See further usage examples in the following tutorials:
- 1. :doc:`/tutorials/notebooks/spatial/tangram_scvi_tools`
- 2. The JAX version is deprecated starting v1.5 and replaced by torch backend.
- """
-
- def __init__(
- self,
- sc_adata: AnnData,
- constrained: bool = False,
- target_count: int | None = None,
- **model_kwargs,
- ):
- warnings.warn(
- "Tangram is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- super().__init__(sc_adata)
- self.n_obs_sc = self.adata_manager.get_from_registry(TANGRAM_REGISTRY_KEYS.SC_KEY).shape[0]
- self.n_obs_sp = self.adata_manager.get_from_registry(TANGRAM_REGISTRY_KEYS.SP_KEY).shape[0]
-
- if constrained and target_count is None:
- raise ValueError("Please specify `target_count` when using constrained Tangram.")
- has_density_prior = not self.adata_manager.fields[-1].is_empty
- if has_density_prior:
- prior = self.adata_manager.get_from_registry(TANGRAM_REGISTRY_KEYS.DENSITY_KEY)
- if np.abs(prior.ravel().sum() - 1) > 1e-3:
- raise ValueError("Density prior must sum to 1. Please normalize the prior.")
-
- self.module = TangramMapper(
- n_obs_sc=self.n_obs_sc,
- n_obs_sp=self.n_obs_sp,
- lambda_d=1.0 if has_density_prior else 0.0,
- constrained=constrained,
- target_count=target_count,
- **model_kwargs,
- )
- self._model_summary_string = (
- f"TangramMapper Model with params: \nn_obs_sc: {self.n_obs_sc}, "
- f"n_obs_sp: {self.n_obs_sp}"
- )
- self.init_params_ = self._get_init_params(locals())
-
- def get_mapper_matrix(self) -> np.ndarray:
- """Return the mapping matrix.
-
- Returns
- -------
- Mapping matrix of shape (n_obs_sc, n_obs_sp)
- """
- return torch.softmax(self.module.mapper_unconstrained, dim=1).detach().cpu().numpy()
-
- @devices_dsp.dedent
- def train(
- self,
- max_epochs: int = 1000,
- accelerator: str = "auto",
- devices: int | list[int] | str = "auto",
- lr: float = 0.1,
- plan_kwargs: dict | None = None,
- ):
- """Train the model.
-
- Parameters
- ----------
- max_epochs
- Number of passes through the dataset.
- %(param_accelerator)s
- %(param_devices)s
- lr
- Optimizer learning rate (default optimizer is :class:`~torch.optim.Adam`).
- Specifying optimizer via plan_kwargs overrides this choice of lr.
- plan_kwargs
- Keyword args for :class:`~scvi.train.TrainingPlan`. Keyword arguments passed to
- `train()` will overwrite values present in `plan_kwargs`, when appropriate.
- """
- update_dict = {
- "lr": lr,
- "eps": 1e-8,
- "weight_decay": 0,
- }
- if plan_kwargs is not None:
- if "optim_kwargs" in plan_kwargs:
- plan_kwargs = plan_kwargs.copy()
- optim_kwargs = {
- "lr" if key == "learning_rate" else key: value
- for key, value in plan_kwargs.pop("optim_kwargs").items()
- }
- plan_kwargs.update(optim_kwargs)
- plan_kwargs.update(update_dict)
- else:
- plan_kwargs = update_dict
-
- _, _, device = parse_device_args(
- accelerator,
- devices,
- return_device="torch",
- validate_single_device=True,
- )
- self.module.to(device)
- logger.info(f"Tangram torch module moved to {device}.")
-
- tensor_dict = self._get_tensor_dict(device=device)
- training_plan = TrainingPlan(self.module, **plan_kwargs)
- self._training_plan = training_plan
- optimizer = training_plan.configure_optimizers()["optimizer"]
- pbar = track(range(max_epochs), style="tqdm", description="Training")
- history = pd.DataFrame(index=np.arange(max_epochs), columns=["loss"])
- for i in pbar:
- training_plan.train()
- optimizer.zero_grad()
- _, _, loss_output = training_plan(tensor_dict)
- loss_output.loss.backward()
- optimizer.step()
- loss = loss_output.loss.detach().cpu().item()
- history.iloc[i] = loss
- pbar.set_description(f"Training... Loss: {loss}")
- self.history_ = {}
- self.history_["loss"] = history
- self.module.eval()
-
- @classmethod
- @setup_anndata_dsp.dedent
- def setup_mudata(
- cls,
- mdata: MuData,
- density_prior_key: str | Literal["rna_count_based", "uniform"] | None = "rna_count_based",
- sc_layer: str | None = None,
- sp_layer: str | None = None,
- modalities: dict[str, str] | None = None,
- **kwargs,
- ):
- """%(summary)s.
-
- Parameters
- ----------
- mdata
- MuData with scRNA and spatial modalities.
- sc_layer
- Layer key in scRNA modality to use for training.
- sp_layer
- Layer key in spatial modality to use for training.
- density_prior_key
- Key in spatial modality obs for density prior.
- modalities
- Mapping from `setup_mudata` param name to modality in mdata.
- """
- setup_method_args = cls._get_setup_method_args(**locals())
-
- if modalities is None:
- raise ValueError("Modalities cannot be None.")
- modalities = cls._create_modalities_attr_dict(modalities, setup_method_args)
-
- mudata_fields = [
- fields.MuDataLayerField(
- TANGRAM_REGISTRY_KEYS.SC_KEY,
- sc_layer,
- mod_key=modalities.sc_layer,
- is_count_data=False,
- mod_required=True,
- ),
- fields.MuDataLayerField(
- TANGRAM_REGISTRY_KEYS.SP_KEY,
- sp_layer,
- mod_key=modalities.sp_layer,
- is_count_data=False,
- mod_required=True,
- ),
- fields.MuDataNumericalObsField(
- TANGRAM_REGISTRY_KEYS.DENSITY_KEY,
- density_prior_key,
- mod_key=modalities.density_prior_key,
- required=False,
- mod_required=True,
- ),
- ]
- adata_manager = AnnDataManager(
- fields=mudata_fields,
- setup_method_args=setup_method_args,
- validation_checks=AnnDataManagerValidationCheck(check_fully_paired_mudata=False),
- )
- adata_manager.register_fields(mdata, **kwargs)
- sc_state = adata_manager.get_state_registry(TANGRAM_REGISTRY_KEYS.SC_KEY)
- sp_state = adata_manager.get_state_registry(TANGRAM_REGISTRY_KEYS.SP_KEY)
- # Need to access the underlying AnnData field to get these attributes
- if not (
- pd.Index(sc_state[fields.LayerField.COLUMN_NAMES_KEY]).equals(
- sp_state[fields.LayerField.COLUMN_NAMES_KEY]
- ),
- ):
- raise ValueError(
- "The column names of the spatial and single-cell layers must be the same."
- )
- cls.register_manager(adata_manager)
-
- @classmethod
- def setup_anndata(cls):
- """Not implemented, use `setup_mudata`."""
- raise NotImplementedError("Use `setup_mudata` to setup a MuData object for training.")
-
- def _get_tensor_dict(
- self,
- device: torch.device,
- ) -> dict[str, torch.Tensor]:
- """Get training data for Tangram model.
-
- Tangram does not minibatch, so we just make a dictionary of
- torch tensors here.
- """
- tensor_dict = {}
- for key in TANGRAM_REGISTRY_KEYS:
- try:
- tensor_dict[key] = self.adata_manager.get_from_registry(key)
- # When density is missing
- except KeyError:
- continue
- if scipy.sparse.issparse(tensor_dict[key]):
- tensor_dict[key] = tensor_dict[key].toarray()
- elif isinstance(tensor_dict[key], pd.DataFrame):
- tensor_dict[key] = tensor_dict[key].values
- else:
- tensor_dict[key] = tensor_dict[key]
- tensor_dict[key] = torch.as_tensor(
- tensor_dict[key], dtype=torch.float32, device=device
- )
-
- return tensor_dict
-
- @staticmethod
- def project_cell_annotations(
- adata_sc: AnnData, adata_sp: AnnData, mapper: np.ndarray, labels: pd.Series
- ) -> pd.DataFrame:
- """Project cell annotations to spatial data.
-
- Parameters
- ----------
- adata_sc
- AnnData object with single-cell data.
- adata_sp
- AnnData object with spatial data.
- mapper
- Mapping from single-cell to spatial data.
- labels
- Cell annotations to project.
-
- Returns
- -------
- Projected annotations as a :class:`pd.DataFrame` with shape (n_sp, n_labels).
- """
- if len(labels) != adata_sc.shape[0]:
- raise ValueError(
- "The number of labels must match the number of cells in the sc AnnData object."
- )
- cell_type_df = pd.get_dummies(labels)
- projection = mapper.T @ cell_type_df.values
- return pd.DataFrame(
- index=adata_sp.obs_names, columns=cell_type_df.columns, data=projection
- )
-
- @staticmethod
- def project_genes(adata_sc: AnnData, adata_sp: AnnData, mapper: np.ndarray) -> AnnData:
- """Project gene expression to spatial data.
-
- Parameters
- ----------
- adata_sc
- AnnData object with single-cell data.
- adata_sp
- AnnData object with spatial data.
- mapper
- Mapping from single-cell to spatial data.
-
- Returns
- -------
- :class:`anndata.AnnData` object with projected gene expression.
- """
- adata_ge = AnnData(
- X=mapper.T @ adata_sc.X,
- obs=adata_sp.obs,
- var=adata_sc.var,
- uns=adata_sc.uns,
- )
- return adata_ge
diff --git a/src/scvi/external/tangram/_module.py b/src/scvi/external/tangram/_module.py
deleted file mode 100644
index 2422d1e5a3..0000000000
--- a/src/scvi/external/tangram/_module.py
+++ /dev/null
@@ -1,164 +0,0 @@
-from typing import NamedTuple
-
-import torch
-from torch import nn
-
-from scvi.module.base import BaseModuleClass, LossOutput
-
-
-class _TANGRAM_REGISTRY_KEYS_NT(NamedTuple):
- SC_KEY: str = "X"
- SP_KEY: str = "Y"
- DENSITY_KEY: str = "DENSITY"
-
-
-TANGRAM_REGISTRY_KEYS = _TANGRAM_REGISTRY_KEYS_NT()
-
-EPS = 1e-8
-
-
-def _cosine_similarity(x: torch.Tensor, y: torch.Tensor, dim: int) -> torch.Tensor:
- numerator = (x * y).sum(dim=dim)
- denominator = torch.clamp(
- torch.linalg.norm(x, dim=dim) * torch.linalg.norm(y, dim=dim),
- min=EPS,
- )
- return numerator / denominator
-
-
-def _density_criterion(log_y_pred: torch.Tensor, y_true: torch.Tensor) -> torch.Tensor:
- # Kl divergence between the predicted and true distributions
- log_y_true = torch.log(y_true + EPS)
- return (y_true * (log_y_true - log_y_pred)).sum()
-
-
-class TangramMapper(BaseModuleClass):
- """Torch Tangram Mapper Model."""
-
- def __init__(
- self,
- n_obs_sc: int,
- n_obs_sp: int,
- lambda_g1: float = 1.0,
- lambda_d: float = 0.0,
- lambda_g2: float = 0.0,
- lambda_r: float = 0.0,
- lambda_count: float = 1.0,
- lambda_f_reg: float = 1.0,
- constrained: bool = False,
- target_count: int | None = None,
- ):
- super().__init__()
- self.n_obs_sc = n_obs_sc
- self.n_obs_sp = n_obs_sp
- self.lambda_g1 = lambda_g1
- self.lambda_d = lambda_d
- self.lambda_g2 = lambda_g2
- self.lambda_r = lambda_r
- self.lambda_count = lambda_count
- self.lambda_f_reg = lambda_f_reg
- self.constrained = constrained
- self.target_count = target_count
-
- self.mapper_unconstrained = nn.Parameter(torch.randn((self.n_obs_sc, self.n_obs_sp)))
-
- if self.constrained:
- self.filter_unconstrained = nn.Parameter(torch.randn((self.n_obs_sc, 1)))
-
- def _get_inference_input(self, tensors: dict[str, torch.Tensor]):
- """Get input for inference."""
- return {}
-
- def inference(self) -> dict:
- """Run inference model."""
- return {}
-
- def _get_generative_input(
- self,
- tensors: dict[str, torch.Tensor],
- inference_outputs: dict[str, torch.Tensor],
- ):
- return {}
-
- def generative(self) -> dict:
- """No generative model here."""
- return {}
-
- def loss(
- self,
- tensors,
- inference_outputs,
- generative_outputs,
- ):
- """Compute loss."""
- sp = tensors[TANGRAM_REGISTRY_KEYS.SP_KEY]
- sc = tensors[TANGRAM_REGISTRY_KEYS.SC_KEY]
- mapper = torch.softmax(self.mapper_unconstrained, dim=1)
-
- if self.constrained:
- filter = torch.sigmoid(self.filter_unconstrained)
- mapper_filtered = mapper * filter
-
- if self.lambda_d > 0:
- density = tensors[TANGRAM_REGISTRY_KEYS.DENSITY_KEY].ravel()
- if self.constrained:
- d_pred = torch.log(mapper_filtered.sum(dim=0) / (filter.sum()))
- else:
- d_pred = torch.log(mapper.sum(dim=0) / mapper.shape[0])
- density_term = self.lambda_d * _density_criterion(d_pred, density)
- else:
- density_term = 0
-
- if self.constrained:
- sc = sc * filter
-
- g_pred = mapper.T @ sc
-
- # Expression term
- if self.lambda_g1 > 0:
- gv_term = self.lambda_g1 * _cosine_similarity(sp, g_pred, dim=0).mean()
- else:
- gv_term = 0
- if self.lambda_g2 > 0:
- vg_term = self.lambda_g1 * _cosine_similarity(sp, g_pred, dim=1).mean()
- vg_term = self.lambda_g2 * vg_term
- else:
- vg_term = 0
-
- expression_term = gv_term + vg_term
-
- # Regularization terms
- if self.lambda_r > 0:
- regularizer_term = self.lambda_r * (torch.log(mapper) * mapper).sum()
- else:
- regularizer_term = 0
-
- if self.lambda_count > 0 and self.constrained:
- if self.target_count is None:
- raise ValueError("target_count must be set if in constrained mode.")
- count_term = self.lambda_count * torch.abs(filter.sum() - self.target_count)
- else:
- count_term = 0
-
- if self.lambda_f_reg > 0 and self.constrained:
- f_reg_t = filter - torch.square(filter)
- f_reg = self.lambda_f_reg * f_reg_t.sum()
- else:
- f_reg = 0
-
- # Total loss
- total_loss = -expression_term - regularizer_term + count_term + f_reg
- total_loss = total_loss + density_term
-
- return LossOutput(
- loss=total_loss,
- n_obs_minibatch=sp.shape[0],
- extra_metrics={
- "expression_term": expression_term,
- "regularizer_term": regularizer_term,
- },
- )
-
- def sample(self, *args, **kwargs):
- """Not implemented for Tangram."""
- raise NotImplementedError
diff --git a/src/scvi/hub/_template.py b/src/scvi/hub/_template.py
index f768ad5211..522d8f2d8e 100644
--- a/src/scvi/hub/_template.py
+++ b/src/scvi/hub/_template.py
@@ -39,15 +39,10 @@
condscvi_pretext = """
CondSCVI is a variational inference model for single-cell RNA-seq data that can learn an underlying
-latent space. The predictions of the model are meant to be afterward
-used for deconvolution of a second spatial transcriptomics dataset in DestVI. DestVI predicts the
-cell-type proportions as well as cell type-specific activation state
-in the spatial data.
+latent space conditioned on cell-type labels.
CondSCVI takes as input a scRNA-seq gene expression matrix with cells and genes as well as a
cell-type annotation for all cells.
-We provide an extensive [user guide](https://docs.scvi-tools.org/en/stable/user_guide/models/destvi.html)
-for DestVI including a description of CondSCVI.
- See our original manuscript for further details of the model:
[DestVI manuscript](https://www.nature.com/articles/s41587-022-01272-8).
diff --git a/src/scvi/model/__init__.py b/src/scvi/model/__init__.py
index 6ded5bddcc..ba5437425e 100644
--- a/src/scvi/model/__init__.py
+++ b/src/scvi/model/__init__.py
@@ -7,7 +7,6 @@
from ._amortizedlda import AmortizedLDA
from ._autozi import AUTOZI
from ._condscvi import CondSCVI
-from ._destvi import DestVI
from ._linear_scvi import LinearSCVI
from ._multivi import MULTIVI
from ._peakvi import PEAKVI
@@ -24,7 +23,6 @@
"SCANVI",
"PEAKVI",
"CondSCVI",
- "DestVI",
"MULTIVI",
"AmortizedLDA",
"utils",
diff --git a/src/scvi/model/_condscvi.py b/src/scvi/model/_condscvi.py
index bfea063f8b..b3496f2ba4 100644
--- a/src/scvi/model/_condscvi.py
+++ b/src/scvi/model/_condscvi.py
@@ -65,12 +65,6 @@ class CondSCVI(RNASeqMixin, VAEMixin, UnsupervisedTrainingMixin, BaseModelClass)
>>> vae = scvi.model.CondSCVI(adata)
>>> vae.train()
>>> adata.obsm["X_CondSCVI"] = vae.get_latent_representation()
-
- Notes
- -----
- See further usage examples in the following tutorial:
-
- 1. :doc:`/tutorials/notebooks/spatial/DestVI_tutorial`
"""
_module_cls = VAEC
diff --git a/src/scvi/model/_destvi.py b/src/scvi/model/_destvi.py
deleted file mode 100644
index 961fe448b9..0000000000
--- a/src/scvi/model/_destvi.py
+++ /dev/null
@@ -1,700 +0,0 @@
-from __future__ import annotations
-
-import logging
-import warnings
-from collections import OrderedDict
-from typing import TYPE_CHECKING
-
-import numpy as np
-import pandas as pd
-import torch
-from scipy.sparse import csr_matrix
-
-from scvi import REGISTRY_KEYS, settings
-from scvi.data import AnnDataManager
-from scvi.data._constants import _SETUP_ARGS_KEY
-from scvi.data.fields import CategoricalObsField, LayerField, NumericalObsField
-from scvi.model.base import BaseModelClass, UnsupervisedTrainingMixin
-from scvi.model.base._archesmixin import _get_loaded_data
-from scvi.module import MRDeconv
-from scvi.train._config import merge_kwargs
-from scvi.utils import setup_anndata_dsp
-from scvi.utils._docstrings import devices_dsp
-
-if TYPE_CHECKING:
- from collections.abc import Sequence
-
- from anndata import AnnData
-
- from scvi.model import CondSCVI
-
-logger = logging.getLogger(__name__)
-
-
-class DestVI(UnsupervisedTrainingMixin, BaseModelClass):
- """Multi-resolution deconvolution of Spatial Transcriptomics data (DestVI) :cite:p:`Lopez22`.
-
- Most users will use the alternate constructor (see example).
-
- Parameters
- ----------
- st_adata
- spatial transcriptomics AnnData object that has been registered via
- :meth:`~scvi.model.DestVI.setup_anndata`.
- cell_type_mapping
- mapping between numerals and cell type labels
- decoder_state_dict
- state_dict from the decoder of the CondSCVI model
- px_decoder_state_dict
- state_dict from the px_decoder of the CondSCVI model
- px_r
- parameters for the px_r tensor in the CondSCVI model
- n_hidden
- Number of nodes per hidden layer.
- n_latent
- Dimensionality of the latent space.
- n_layers
- Number of hidden layers used for encoder and decoder NNs.
- **module_kwargs
- Keyword args for :class:`~scvi.module.MRDeconv`
-
- Examples
- --------
- >>> sc_adata = anndata.read_h5ad(path_to_scRNA_anndata)
- >>> scvi.model.CondSCVI.setup_anndata(sc_adata)
- >>> sc_model = scvi.model.CondSCVI(sc_adata)
- >>> st_adata = anndata.read_h5ad(path_to_ST_anndata)
- >>> DestVI.setup_anndata(st_adata)
- >>> spatial_model = DestVI.from_rna_model(st_adata, sc_model)
- >>> spatial_model.train(max_epochs=2000)
- >>> st_adata.obsm["proportions"] = spatial_model.get_proportions(st_adata)
- >>> gamma = spatial_model.get_gamma(st_adata)
-
- Notes
- -----
- See further usage examples in the following tutorials:
-
- 1. :doc:`/tutorials/notebooks/spatial/DestVI_tutorial`
- 2. :doc:`/tutorials/notebooks/r/DestVI_in_R`
- """
-
- _module_cls = MRDeconv
-
- def __init__(
- self,
- st_adata: AnnData,
- cell_type_mapping: np.ndarray,
- decoder_state_dict: OrderedDict,
- px_decoder_state_dict: OrderedDict,
- px_r: torch.tensor,
- per_ct_bias: torch.tensor,
- n_hidden: int,
- n_latent: int,
- n_layers: int,
- dropout_decoder: float,
- **module_kwargs,
- ):
- warnings.warn(
- "DESTVI is a spatial transcriptomics model that will be moved to the "
- "scvi-tools spatial companion package `scviva-tools` starting in scvi-tools v1.5 and "
- "will no longer be supported here. It will be deprecated from scvi-tools in v1.6.",
- FutureWarning,
- stacklevel=settings.warnings_stacklevel,
- )
- super().__init__(st_adata)
-
- self.module = self._module_cls(
- n_spots=st_adata.n_obs,
- n_labels=cell_type_mapping.shape[0],
- n_batch=self.summary_stats.n_batch,
- decoder_state_dict=decoder_state_dict,
- px_decoder_state_dict=px_decoder_state_dict,
- px_r=px_r,
- per_ct_bias=per_ct_bias,
- n_genes=st_adata.n_vars,
- n_latent=n_latent,
- n_layers=n_layers,
- n_hidden=n_hidden,
- dropout_decoder=dropout_decoder,
- **module_kwargs,
- )
- self.cell_type_mapping = cell_type_mapping
- self.cell_type_mapping_extended = list(self.cell_type_mapping) + [
- f"additional_{i}" for i in range(self.module.add_celltypes)
- ]
- self._model_summary_string = "DestVI Model"
- self.init_params_ = self._get_init_params(locals())
-
- @classmethod
- def from_rna_model(
- cls,
- st_adata: AnnData,
- sc_model: CondSCVI,
- vamp_prior_p: int = 15,
- anndata_setup_kwargs: dict | None = None,
- **module_kwargs,
- ):
- """Alternate constructor for exploiting a pre-trained model on a RNA-seq dataset.
-
- Parameters
- ----------
- st_adata
- registered anndata object
- sc_model
- trained CondSCVI model
- vamp_prior_p
- number of mixture parameter for VampPrior calculations
- anndata_setup_kwargs
- Keyword args for :meth:`~scvi.model.DestVI.setup_anndata`
- **model_kwargs
- Keyword args for :class:`~scvi.model.DestVI`
- """
- attr_dict, var_names, load_state_dict, _ = _get_loaded_data(sc_model)
- registry = attr_dict.pop("registry_")
-
- decoder_state_dict = OrderedDict(
- (i[8:], load_state_dict[i])
- for i in load_state_dict.keys()
- if i.split(".")[0] == "decoder"
- )
- px_decoder_state_dict = OrderedDict(
- (i[11:], load_state_dict[i])
- for i in load_state_dict.keys()
- if i.split(".")[0] == "px_decoder"
- )
- px_r = load_state_dict["px_r"]
- per_ct_bias = load_state_dict["per_ct_bias"]
- mapping = registry["field_registries"]["labels"]["state_registry"]["categorical_mapping"]
-
- dropout_decoder = attr_dict["init_params_"]["non_kwargs"]["dropout_rate"]
- if vamp_prior_p is None:
- mean_vprior = None
- var_vprior = None
- elif attr_dict["init_params_"]["kwargs"]["module_kwargs"]["prior"] == "mog":
- mean_vprior = load_state_dict["prior_means"].clone().detach()
- var_vprior = torch.exp(load_state_dict["prior_log_std"]) ** 2
- mp_vprior = torch.nn.Softmax(dim=-1)(load_state_dict["prior_logits"])
- else:
- assert sc_model is not str, (
- "VampPrior requires loading CondSCVI model and providing it"
- )
- mean_vprior, var_vprior, mp_vprior = sc_model.get_vamp_prior(
- sc_model.adata, p=vamp_prior_p
- ).values()
-
- if anndata_setup_kwargs is None:
- anndata_setup_kwargs = {}
-
- cls.setup_anndata(
- st_adata,
- source_registry=registry,
- extend_categories=True,
- **anndata_setup_kwargs,
- **registry[_SETUP_ARGS_KEY],
- )
-
- return cls(
- st_adata,
- mapping,
- decoder_state_dict,
- px_decoder_state_dict,
- px_r,
- per_ct_bias,
- sc_model.module.n_hidden,
- sc_model.module.n_latent,
- sc_model.module.n_layers,
- mean_vprior=mean_vprior,
- var_vprior=var_vprior,
- mp_vprior=mp_vprior,
- dropout_decoder=dropout_decoder,
- **module_kwargs,
- )
-
- @torch.inference_mode()
- def get_proportions(
- self,
- keep_additional: bool = False,
- normalize: bool = True,
- indices: Sequence[int] | None = None,
- batch_size: int | None = None,
- ) -> pd.DataFrame:
- """Returns the estimated cell type proportion for the spatial data.
-
- Shape is n_cells x n_labels OR n_cells x (n_labels + add_celltypes) if keep_additional.
-
- Parameters
- ----------
- keep_additional
- whether to account for the additional cell-types as standalone cell types in the
- proportion estimate.
- normalize
- whether to normalize the proportions to sum to 1.
- indices
- Indices of cells in adata to use. Only used if amortization. If `None`, all cells are
- used.
- batch_size
- Minibatch size for data loading into model. Only used if amortization. Defaults to
- `scvi.settings.batch_size`.
- """
- self._check_if_trained()
-
- column_names = self.cell_type_mapping
- index_names = self.adata.obs.index
- if keep_additional:
- column_names = list(self.cell_type_mapping_extended)
- else:
- column_names = list(self.cell_type_mapping)
-
- if self.module.amortization in ["both", "proportion"]:
- stdl = self._make_data_loader(adata=self.adata, indices=indices, batch_size=batch_size)
- prop_ = []
- for tensors in stdl:
- inference_inputs = self.module._get_inference_input(tensors)
- outputs = self.module.inference(**inference_inputs)
- generative_inputs = self.module._get_generative_input(tensors, outputs)
- prop_local = self.module.generative(**generative_inputs)["v"][0, ...]
- prop_ += [prop_local.cpu()]
- data = torch.cat(prop_).detach().numpy()
- if indices:
- index_names = index_names[indices]
- else:
- data = (
- torch.nn.functional.softplus(self.module.V).transpose(0, 1).detach().cpu().numpy()
- )
- if not keep_additional:
- data = data[:, : -self.module.add_celltypes]
- if normalize:
- data = data / data.sum(axis=1, keepdims=True)
-
- return pd.DataFrame(
- data=data,
- columns=column_names,
- index=index_names,
- )
-
- @torch.inference_mode()
- def get_fine_celltypes(
- self,
- sc_model: CondSCVI,
- indices=None,
- batch_size: int | None = None,
- ) -> np.ndarray | dict[str, pd.DataFrame]:
- """Returns the estimated cell-type specific latent space for the spatial data.
-
- Parameters
- ----------
- sc_model
- trained CondSCVI model
- indices
- Indices of cells in adata to use. Only used if amortization.
- If `None`, all cells are used.
- batch_size
- Minibatch size for data loading into model. Only used if amortization.
- Defaults to `scvi.settings.batch_size`.
- """
- self._check_if_trained()
- index_names = self.adata.obs.index
- stdl = self._make_data_loader(adata=self.adata, indices=indices, batch_size=batch_size)
- if sc_model.n_fine_labels is None:
- raise RuntimeError(
- "Single cell model does not contain fine labels. "
- "Please train the single-cell model with fine labels."
- )
- predicted_fine_celltype_ = []
- for tensors in stdl:
- inference_inputs = self.module._get_inference_input(tensors)
- outputs = self.module.inference(**inference_inputs)
- generative_inputs = self.module._get_generative_input(tensors, outputs)
- generative_outputs = self.module.generative(**generative_inputs)
-
- gamma_local = generative_outputs["gamma"][0, ...].transpose(-2, -4) # c, n, p, m
- proportions_modes_local = generative_outputs["proportion_modes"][0, ...] # pmc
- n_modes, batch_size, n_celltypes = proportions_modes_local.shape
- gamma_local_ = gamma_local.permute((3, 2, 0, 1)).reshape(
- -1, self.module.n_latent
- ) # m*p*c, n
- proportions_modes_local_ = proportions_modes_local.permute(
- (1, 0, 2)
- ).flatten() # m*p*c
- v_local = (
- generative_outputs["v"][0, ..., : -self.module.add_celltypes]
- .flatten()
- .repeat_interleave(n_modes)
- ) # m*p*c
- label = (
- torch.arange(self.module.n_labels, device=gamma_local.device)
- .repeat(batch_size)
- .repeat_interleave(n_modes)
- .unsqueeze(-1)
- ) # m*p*c, 1
- predicted_fine_celltype_local = (
- v_local.unsqueeze(-1)
- * proportions_modes_local_.unsqueeze(-1)
- * torch.nn.functional.softmax(
- sc_model.module.classify(gamma_local_, label), dim=-1
- )
- )
- predicted_fine_celltype_sum = predicted_fine_celltype_local.reshape(
- batch_size, n_celltypes * n_modes, sc_model.n_fine_labels
- ).sum(1)
- predicted_fine_celltype_.append(predicted_fine_celltype_sum.detach().cpu())
- predicted_fine_celltype = torch.cat(predicted_fine_celltype_, dim=0).numpy()
-
- pred = pd.DataFrame(
- predicted_fine_celltype,
- columns=sc_model._fine_label_mapping,
- index=index_names,
- )
- return pred
-
- @torch.inference_mode()
- def get_gamma(
- self,
- indices: Sequence[int] | None = None,
- batch_size: int | None = None,
- return_numpy: bool = False,
- ) -> np.ndarray | dict[str, pd.DataFrame]:
- """Returns the estimated cell-type specific latent space for the spatial data.
-
- Parameters
- ----------
- indices
- Indices of cells in adata to use. Only used if amortization. If `None`, all cells are
- used.
- batch_size
- Minibatch size for data loading into model. Only used if amortization. Defaults to
- `scvi.settings.batch_size`.
- return_numpy
- if activated, will return a numpy array of shape is n_spots x n_latent x n_labels.
- """
- self._check_if_trained()
-
- column_names = [str(i) for i in np.arange(self.module.n_latent)]
- index_names = self.adata.obs.index
-
- if self.module.amortization in ["both", "latent"]:
- stdl = self._make_data_loader(adata=self.adata, indices=indices, batch_size=batch_size)
- gamma_ = []
- for tensors in stdl:
- inference_inputs = self.module._get_inference_input(tensors)
- outputs = self.module.inference(**inference_inputs)
- generative_inputs = self.module._get_generative_input(tensors, outputs)
- generative_outputs = self.module.generative(**generative_inputs)
- gamma_local = generative_outputs["gamma"][0, ...]
- if self.module.prior_mode == "mog":
- proportions_model_local = generative_outputs["proportion_modes"][0, ...]
- gamma_local = torch.einsum(
- "pncm,pmc->ncm", gamma_local, proportions_model_local
- )
- else:
- gamma_local = gamma_local[0, ...].squeeze(0)
- gamma_ += [gamma_local.cpu()]
- data = torch.cat(gamma_, dim=-1).numpy()
- if indices is not None:
- index_names = index_names[indices]
- else:
- data = self.module.gamma.detach().cpu().numpy()
- data = np.transpose(data, (2, 0, 1))
- if return_numpy:
- return data
- else:
- res = {}
- for i, ct in enumerate(self.cell_type_mapping):
- res[ct] = pd.DataFrame(data=data[:, :, i], columns=column_names, index=index_names)
- return res
-
- @torch.inference_mode()
- def get_latent_representation(
- self,
- adata: AnnData | None = None,
- indices: Sequence[int] | None = None,
- give_mean: bool = True,
- mc_samples: int = 5000,
- batch_size: int | None = None,
- return_dist: bool = False,
- ) -> np.ndarray(np.ndarray, np.ndarray):
- """Return the latent representation for each cell.
-
- This is typically denoted as :math:`z_n`.
-
- Parameters
- ----------
- adata
- AnnData object with equivalent structure to initial AnnData. If `None`, defaults to the
- AnnData object used to initialize the model.
- indices
- Indices of cells in adata to use. If `None`, all cells are used.
- give_mean
- Give mean of distribution or sample from it.
- mc_samples
- For distributions with no closed-form mean (e.g., `logistic normal`),
- how many Monte Carlo samples to take for computing mean.
- batch_size
- Minibatch size for data loading into model. Defaults to `scvi.settings.batch_size`.
- return_dist
- Return (mean, variance) of distributions instead of just the mean.
- If `True`, ignores `give_mean` and `mc_samples`. In the case of the latter,
- `mc_samples` is used to compute the mean of a transformed distribution.
- If `return_dist` is true the untransformed mean and variance are returned.
-
- Returns
- -------
- Low-dimensional representation for each cell or a tuple containing its mean and variance.
- """
- assert self.module.n_latent_amortization is not None, (
- "Model has no latent representation for amortized values."
- )
- self._check_if_trained(warn=False)
-
- adata = self._validate_anndata(adata)
- scdl = self._make_data_loader(adata=adata, indices=indices, batch_size=batch_size)
- latent = []
- latent_qzm = []
- latent_qzv = []
- for tensors in scdl:
- inference_inputs = self.module._get_inference_input(tensors)
- inference_outputs = self.module.inference(**inference_inputs, n_samples=mc_samples)
- z = inference_outputs["z"][0, ...]
- qz = inference_outputs["qz"]
- if give_mean:
- latent += [qz.loc[0, ...].cpu()]
- else:
- latent += [z.cpu()]
- latent_qzm += [qz.loc[0, ...].cpu()]
- latent_qzv += [qz.scale[0, ...].square().cpu()]
- return (
- (torch.cat(latent_qzm).numpy(), torch.cat(latent_qzv).numpy())
- if return_dist
- else torch.cat(latent).numpy()
- )
-
- @torch.inference_mode()
- def get_scale_for_ct(
- self,
- label: str,
- indices: Sequence[int] | None = None,
- batch_size: int | None = None,
- ) -> pd.DataFrame:
- r"""Return the scaled parameter of the NB for every spot in queried cell types.
-
- Parameters
- ----------
- label
- cell type of interest
- indices
- Indices of cells in self.adata to use. If `None`, all cells are used.
- batch_size
- Minibatch size for data loading into model. Defaults to `scvi.settings.batch_size`.
-
- Returns
- -------
- Pandas dataframe of gene_expression
- """
- self._check_if_trained()
- self._validate_anndata()
-
- cell_type_mapping_extended = list(self.cell_type_mapping) + [
- f"additional_{i}" for i in range(self.module.add_celltypes)
- ]
-
- if label not in cell_type_mapping_extended:
- raise ValueError("Unknown cell type")
- y = cell_type_mapping_extended.index(label)
-
- stdl = self._make_data_loader(self.adata, indices=indices, batch_size=batch_size)
- scale = []
- for tensors in stdl:
- inference_inputs = self.module._get_inference_input(tensors)
- outputs = self.module.inference(**inference_inputs)
- generative_inputs = self.module._get_generative_input(tensors, outputs)
- px_scale = self.module.generative(**generative_inputs)["px_mu"][0, :, y, :]
-
- scale += [px_scale.cpu()]
-
- data = torch.cat(scale).numpy()
- column_names = self.adata.var.index
- index_names = self.adata.obs.index
- if indices is not None:
- index_names = index_names[indices]
- return pd.DataFrame(data=data, columns=column_names, index=index_names)
-
- @torch.inference_mode()
- def get_expression_for_ct(
- self,
- label: str,
- indices: Sequence[int] | None = None,
- batch_size: int | None = None,
- return_sparse_array: bool = False,
- ) -> pd.DataFrame:
- r"""Return the scaled parameter of the NB for every spot in queried cell types.
-
- Parameters
- ----------
- label
- cell type of interest
- indices
- Indices of cells in self.adata to use. If `None`, all cells are used.
- batch_size
- Minibatch size for data loading into model. Defaults to `scvi.settings.batch_size`.
- return_sparse_array
- If `True`, returns a sparse array instead of a dataframe.
-
- Returns
- -------
- Pandas dataframe of gene_expression
- """
- self._check_if_trained()
-
- if label not in self.cell_type_mapping_extended:
- raise ValueError("Unknown cell type")
- y = self.cell_type_mapping_extended.index(label)
-
- stdl = self._make_data_loader(self.adata, indices=indices, batch_size=batch_size)
- expression_ct = []
- for tensors in stdl:
- inference_inputs = self.module._get_inference_input(tensors)
- outputs = self.module.inference(**inference_inputs)
- generative_inputs = self.module._get_generative_input(tensors, outputs)
- generative_outputs = self.module.generative(**generative_inputs)
- px_scale, proportions = (
- generative_outputs["px_mu"][0, ...],
- generative_outputs["v"][0, ...],
- )
- px_scale_expected = torch.einsum("mkl,mk->mkl", px_scale, proportions)
- px_scale_proportions = px_scale_expected[:, y, :] / px_scale_expected.sum(dim=1)
- x_ct = tensors["X"].to(px_scale_proportions.device) * px_scale_proportions
- expression_ct += [x_ct.cpu()]
-
- data = torch.cat(expression_ct).numpy()
- if return_sparse_array:
- data = csr_matrix(data.T)
- return data
- else:
- column_names = self.adata.var.index
- index_names = self.adata.obs.index
- if indices is not None:
- index_names = index_names[indices]
- return pd.DataFrame(data=data, columns=column_names, index=index_names)
-
- @devices_dsp.dedent
- def train(
- self,
- max_epochs: int = 2000,
- lr: float = 0.003,
- accelerator: str = "auto",
- devices: int | list[int] | str = "auto",
- train_size: float = 1.0,
- validation_size: float | None = None,
- shuffle_set_split: bool = True,
- batch_size: int = 128,
- n_epochs_kl_warmup: int = 200,
- datasplitter_kwargs: dict | None = None,
- plan_kwargs: dict | None = None,
- **kwargs,
- ):
- """Trains the model using MAP inference.
-
- Parameters
- ----------
- max_epochs
- Number of epochs to train for
- lr
- Learning rate for optimization.
- %(param_accelerator)s
- %(param_devices)s
- train_size
- Size of training set in the range [0.0, 1.0].
- validation_size
- Size of the test set. If `None`, defaults to 1 - `train_size`. If
- `train_size + validation_size < 1`, the remaining cells belong to a test set.
- shuffle_set_split
- Whether to shuffle indices before splitting. If `False`, the val, train, and test set
- are split in the sequential order of the data according to `validation_size` and
- `train_size` percentages.
- batch_size
- Minibatch size to use during training.
- n_epochs_kl_warmup
- number of epochs needed to reach unit kl weight in the elbo
- datasplitter_kwargs
- Additional keyword arguments passed into :class:`~scvi.dataloaders.DataSplitter`.
- plan_kwargs
- Keyword args for :class:`~scvi.train.TrainingPlan`. Keyword arguments passed to
- `train()` will overwrite values present in `plan_kwargs`, when appropriate.
- **kwargs
- Other keyword args for :class:`~scvi.train.Trainer`.
- """
- # A validation set is only meaningful when every spot-specific parameter is amortized.
- # With amortization != "both", the proportions (`V`) and/or latents (`gamma`) are stored
- # per spot and only optimized for the spots seen during training, so held-out validation
- # spots keep their random initialization and the validation loss is not meaningful.
- validation_requested = (
- train_size < 1.0
- or bool(validation_size)
- or kwargs.get("early_stopping", False)
- or kwargs.get("check_val_every_n_epoch", None) is not None
- )
- if validation_requested and self.module.amortization != "both":
- raise ValueError(
- "DestVI only supports a validation set (`train_size < 1.0`, `validation_size`, "
- "`early_stopping`, or `check_val_every_n_epoch`) when `amortization='both'`. With "
- f"`amortization={self.module.amortization!r}`, the per-spot parameters (`V` and/or"
- "`gamma`) are not amortized and are trained only on the training spots, leaving "
- "validation spots at their random initialization. Use `amortization='both'`, or "
- "train on all spots with `train_size=1.0` and no early stopping / validation."
- )
-
- update_dict = {
- "lr": lr,
- "n_epochs_kl_warmup": n_epochs_kl_warmup,
- }
- plan_kwargs = merge_kwargs(None, plan_kwargs, name="plan")
- plan_kwargs.update(update_dict)
- super().train(
- max_epochs=max_epochs,
- accelerator=accelerator,
- devices=devices,
- train_size=train_size,
- validation_size=validation_size,
- shuffle_set_split=shuffle_set_split,
- batch_size=batch_size,
- datasplitter_kwargs=datasplitter_kwargs,
- plan_kwargs=plan_kwargs,
- **kwargs,
- )
-
- @classmethod
- @setup_anndata_dsp.dedent
- def setup_anndata(
- cls,
- adata: AnnData,
- layer: str | None = None,
- smoothed_layer: str | None = None,
- batch_key: str | None = None,
- **kwargs,
- ):
- """%(summary)s.
-
- Parameters
- ----------
- %(param_adata)s
- %(param_layer)s
- smoothed_layer
- When provided, the model adds a spatial-smoothing regularization that encourages a
- spot's inferred cell-type proportions to agree with those inferred from its
- neighborhood, yielding spatially smoother deconvolution.
- Optional; if ``None`` the regularization is disabled.
- %(param_batch_key)s
- """
- setup_method_args = cls._get_setup_method_args(**locals())
- # add index for each cell (provided to pyro plate for correct minibatching)
- adata.obs["_indices"] = np.arange(adata.n_obs)
- anndata_fields = [
- LayerField(REGISTRY_KEYS.X_KEY, layer, is_count_data=True),
- NumericalObsField(REGISTRY_KEYS.INDICES_KEY, "_indices"),
- CategoricalObsField(REGISTRY_KEYS.BATCH_KEY, batch_key),
- ]
- if smoothed_layer is not None:
- anndata_fields.append(LayerField("x_smoothed", smoothed_layer, is_count_data=True))
- adata_manager = AnnDataManager(fields=anndata_fields, setup_method_args=setup_method_args)
- adata_manager.register_fields(adata, **kwargs)
- cls.register_manager(adata_manager)
diff --git a/src/scvi/model/base/_base_model.py b/src/scvi/model/base/_base_model.py
index c0f0fd0e0f..44037bc6c7 100644
--- a/src/scvi/model/base/_base_model.py
+++ b/src/scvi/model/base/_base_model.py
@@ -159,12 +159,8 @@ def __init__(self, adata: AnnOrMuData | None = None, registry: object | None = N
# Suffix a registry instance variable with _ to include it when saving the model.
self.registry_ = registry
self.summary_stats = AnnDataManager._get_summary_stats_from_registry(registry)
- elif (
- (self.__class__.__name__ == "GIMVI")
- or (self.__class__.__name__ == "SCVI")
- or (self.__class__.__name__ == "DIAGVI")
- ):
- # note some models do accept empty registry/adata (e.g: gimvi)
+ elif self.__class__.__name__ in ("SCVI"):
+ # note some models do accept empty registry/adata
pass
else:
raise ValueError("adata or registry must be provided.")
diff --git a/src/scvi/module/__init__.py b/src/scvi/module/__init__.py
index fd4b7b7a9e..7b68a61787 100644
--- a/src/scvi/module/__init__.py
+++ b/src/scvi/module/__init__.py
@@ -6,7 +6,6 @@
from ._amortizedlda import AmortizedLDAPyroModule
from ._autozivae import AutoZIVAE
from ._classifier import Classifier
-from ._mrdeconv import MRDeconv
from ._multivae import MULTIVAE
from ._peakvae import PEAKVAE
from ._scanvae import SCANVAE
@@ -23,7 +22,6 @@
"Classifier",
"PEAKVAE",
"VAEC",
- "MRDeconv",
"MULTIVAE",
"AmortizedLDAPyroModule",
]
diff --git a/src/scvi/module/_mrdeconv.py b/src/scvi/module/_mrdeconv.py
deleted file mode 100644
index 2bfcbf4c4a..0000000000
--- a/src/scvi/module/_mrdeconv.py
+++ /dev/null
@@ -1,676 +0,0 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-import numpy as np
-import torch
-from torch.distributions import (
- Categorical,
- Exponential,
- Independent,
- MixtureSameFamily,
- Normal,
-)
-from torch.distributions import kl_divergence as kl
-
-from scvi import REGISTRY_KEYS
-from scvi.distributions import NegativeBinomial
-from scvi.module.base import BaseModuleClass, EmbeddingModuleMixin, LossOutput, auto_move_data
-from scvi.nn import Encoder, FCLayers
-
-if TYPE_CHECKING:
- from collections import OrderedDict
- from typing import Literal
-
- import numpy as np
-
-
-def identity(x):
- """Identity function."""
- return x
-
-
-class MRDeconv(EmbeddingModuleMixin, BaseModuleClass):
- """Model for multi-resolution deconvolution of spatial transriptomics.
-
- Parameters
- ----------
- n_spots
- Number of input spots
- n_labels
- Number of cell types
- n_hidden
- Number of neurons in the hidden layers
- n_layers
- Number of layers used in the encoder networks
- n_latent
- Number of dimensions used in the latent variables
- n_genes
- Number of genes used in the decoder
- px_r
- parameters for the px_r tensor in the CondSCVI model
- per_ct_bias
- estimates of per cell-type expression bias in the CondSCVI model
- decoder_state_dict
- state_dict from the decoder of the CondSCVI model
- px_decoder_state_dict
- state_dict from the px_decoder of the CondSCVI model
- dropout_decoder
- Dropout rate for the decoder neural network (same dropout as in CondSCVI decoder)
- dropout_amortization
- Dropout rate for the amortization neural network
- n_samples_augmentation
- Number of samples used in the augmentation
- n_states_per_label
- Number of states per cell-type in each spot
- eps_v
- Epsilon value for each cell-type proportion used during training.
- n_states_per_augmented_label
- Number of states per cell-type in each spot during augmentation
- mean_vprior
- Mean parameter for each component in the empirical prior over the latent space
- var_vprior
- Diagonal variance parameter for each component in the empirical prior over the latent space
- mp_vprior
- Mixture proportion in cell type sub-clustering of each component in the empirical prior
- amortization
- prior_mode
- Mode of the prior distribution for the latent space.
- Either "mog" for mixture of gaussians or "normal" for normal distribution.
- add_celltypes
- Number of additional cell types compared to single cell data to add to the model
- n_latent_amortization
- Number of dimensions used in the latent variables for the amortization encoder
- neural network
- extra_encoder_kwargs
- Extra keyword arguments passed into :class:`~scvi.nn.FCLayers`.
- extra_decoder_kwargs
- Extra keyword arguments passed into :class:`~scvi.nn.FCLayers`.
- """
-
- def __init__(
- self,
- n_spots: int,
- n_labels: int,
- n_batch: int,
- n_hidden: int,
- n_layers: int,
- n_latent: int,
- n_genes: int,
- decoder_state_dict: OrderedDict,
- px_decoder_state_dict: OrderedDict,
- px_r: torch.tensor,
- per_ct_bias: torch.tensor,
- dropout_decoder: float,
- dropout_amortization: float = 0.03,
- augmentation: bool = True,
- n_samples_augmentation: int = 2,
- n_states_per_label: int = 3,
- eps_v: float = 2e-3,
- mean_vprior: np.ndarray = None,
- var_vprior: np.ndarray = None,
- mp_vprior: np.ndarray = None,
- amortization: Literal["none", "latent", "proportion", "both"] = "both",
- prior_mode: Literal["mog", "normal"] = "mog",
- add_celltypes: int = 1,
- n_latent_amortization: int | None = None,
- extra_encoder_kwargs: dict | None = None,
- extra_decoder_kwargs: dict | None = None,
- ):
- super().__init__()
- if prior_mode == "mog":
- assert amortization in ["both", "latent"], (
- "Amortization must be active for latent variables to use mixture "
- "of gaussians generation"
- )
- self.n_spots = n_spots
- self.n_batch = n_batch
- self.n_labels = n_labels
- self.n_hidden = n_hidden
- self.n_latent = n_latent
- self.augmentation = augmentation
- self.n_samples_augmentation = n_samples_augmentation
- self.dropout_decoder = dropout_decoder
- self.n_states_per_label = n_states_per_label
- self.dropout_amortization = dropout_amortization
- self.n_genes = n_genes
- self.amortization = amortization
- self.prior_mode = prior_mode
- self.add_celltypes = add_celltypes
- self.eps_v = eps_v
- self.n_latent_amortization = n_latent_amortization
- # unpack and copy parameters
- _extra_decoder_kwargs = extra_decoder_kwargs or {}
- cat_list = [n_labels]
- self.init_embedding(REGISTRY_KEYS.BATCH_KEY, n_batch)
- batch_dim = self.get_embedding(REGISTRY_KEYS.BATCH_KEY).embedding_dim
- n_input_decoder = n_latent + batch_dim
-
- self.decoder = FCLayers(
- n_in=n_input_decoder,
- n_out=n_hidden,
- n_cat_list=cat_list,
- n_layers=n_layers,
- n_hidden=n_hidden,
- dropout_rate=dropout_decoder,
- use_layer_norm=True,
- use_batch_norm=False,
- **_extra_decoder_kwargs,
- )
- self.decoder.load_state_dict(decoder_state_dict)
- for param in self.decoder.parameters():
- param.requires_grad = False
- self.px_decoder = torch.nn.Linear(n_hidden, n_genes)
- self.px_decoder.load_state_dict(px_decoder_state_dict)
- for param in self.px_decoder.parameters():
- param.requires_grad = False
- self.px_r = torch.nn.Parameter(px_r)
- self.register_buffer("per_ct_bias", per_ct_bias)
-
- # cell_type specific factor loadings
- self.V = torch.nn.Parameter(torch.randn(self.n_labels + self.add_celltypes, self.n_spots))
-
- # within cell_type factor loadings
- self.gamma = torch.nn.Parameter(torch.randn(n_latent, self.n_labels, self.n_spots))
- if mean_vprior is not None:
- self.register_buffer("mean_vprior", mean_vprior)
- self.register_buffer("var_vprior", var_vprior)
- self.register_buffer("mp_vprior", mp_vprior)
- cats = Categorical(probs=self.mp_vprior)
- normal_dists = Independent(
- Normal(self.mean_vprior, torch.sqrt(self.var_vprior) + 1e-4),
- reinterpreted_batch_ndims=1,
- )
- self.qz_prior = MixtureSameFamily(cats, normal_dists)
- else:
- self.mean_vprior = None
- self.var_vprior = None
- # noise from data
- self.eta = torch.nn.Parameter(torch.zeros(self.add_celltypes, self.n_genes))
- # additive gene bias
- self.beta = torch.nn.Parameter(torch.zeros(self.n_genes))
-
- # create additional neural nets for amortization
- # within cell_type factor loadings
- _extra_encoder_kwargs = extra_encoder_kwargs or {}
- if self.prior_mode == "mog":
- return_dist = (
- self.n_states_per_label * n_labels * n_latent + self.n_states_per_label * n_labels
- )
- else:
- return_dist = n_labels * n_latent
- if self.n_latent_amortization is not None:
- # Uses a combined latent space for proportions and gammas.
- self.z_encoder = Encoder(
- self.n_genes,
- n_latent_amortization,
- n_cat_list=[n_batch],
- n_layers=n_layers,
- n_hidden=n_hidden,
- dropout_rate=dropout_amortization,
- inject_covariates=True,
- use_batch_norm=False,
- use_layer_norm=True,
- var_activation=torch.nn.functional.softplus,
- return_dist=True,
- **_extra_encoder_kwargs,
- )
- else:
-
- def identity(x, batch_index=None):
- return x, Normal(x, scale=1e-6 * torch.ones_like(x))
-
- self.z_encoder = identity
- n_latent_amortization = self.n_genes
- self.gamma_encoder = torch.nn.Sequential(
- FCLayers(
- n_in=n_latent_amortization,
- n_out=n_hidden,
- n_cat_list=None,
- n_layers=2,
- n_hidden=n_hidden,
- dropout_rate=dropout_amortization,
- use_layer_norm=True,
- use_batch_norm=False,
- ),
- torch.nn.Linear(n_hidden, return_dist),
- )
- # cell type loadings
- self.V_encoder = torch.nn.Sequential(
- FCLayers(
- n_in=n_latent_amortization,
- n_out=n_hidden,
- n_cat_list=None,
- n_layers=n_layers,
- n_hidden=n_hidden,
- dropout_rate=0,
- use_layer_norm=True,
- use_batch_norm=False,
- ),
- torch.nn.Linear(n_hidden, n_labels + self.add_celltypes),
- )
-
- def _get_inference_input(self, tensors):
- x = tensors[REGISTRY_KEYS.X_KEY]
- x_smoothed = tensors.get("x_smoothed", None)
- m = x.shape[0]
- if x_smoothed is not None:
- n_samples = self.n_samples_augmentation + 2
- n_samples_observed = 2
- else:
- n_samples = self.n_samples_augmentation + 1
- n_samples_observed = 1
- px_r = torch.exp(self.px_r)
- if self.augmentation and self.training:
- with torch.no_grad():
- prior_sampled = self.qz_prior.sample([n_samples, m]).reshape(
- n_samples, -1, self.n_latent
- )
- enum_label = (
- torch.arange(0, self.n_labels).repeat(m).view((-1, 1))
- ) # m * n_labels, 1
- batch_rep = self.compute_embedding(
- REGISTRY_KEYS.BATCH_KEY, tensors[REGISTRY_KEYS.BATCH_KEY]
- )
- batch_rep_input = batch_rep.repeat_interleave(self.n_labels, dim=0).repeat(
- n_samples, 1, 1
- )
- decoder_input = torch.cat([prior_sampled, batch_rep_input], dim=-1)
- px_scale_augment_ = torch.nn.Softmax(dim=-1)(
- self.px_decoder(self.decoder(decoder_input, enum_label.to(x.device)))
- + self.per_ct_bias[enum_label.ravel()].unsqueeze(-3)
- + self.beta.view(1, 1, -1)
- )
- px_scale_augment = px_scale_augment_.reshape(
- (n_samples, x.shape[0], self.n_labels, -1)
- ) # (samples, mi, n_labels, n_genes)
- library = x.sum(-1).view(1, m, 1, 1).repeat(n_samples, 1, 1, 1)
- library[1, ...] = library[1, ...] + 50
- px_scale_augment = px_scale_augment.reshape(
- n_samples, m, self.n_labels, -1
- ) # (samples, m, n_labels, n_genes)
- px_rate = library * px_scale_augment # (samples, m, n_labels, n_genes)
- ratios_ct_augmentation = (
- torch.distributions.Dirichlet(torch.zeros(self.n_labels) + 0.03)
- .sample([n_samples, m])
- .to(x.device)
- )
- ratios_ct_augmentation = ratios_ct_augmentation.reshape(
- n_samples, m, self.n_labels
- )
- # sum over celltypes
- augmentation_rate = torch.einsum(
- "imc, imcg -> img", ratios_ct_augmentation, px_rate
- ) # (samples, m, n_genes)
- ratio_augmentation_ = (
- torch.distributions.Beta(0.4, 0.5)
- .sample([self.n_samples_augmentation - 1, m])
- .unsqueeze(-1)
- .to(x.device)
- )
- ratio_augmentation = torch.cat(
- [
- torch.zeros((n_samples_observed, m, 1), device=x.device),
- torch.ones((1, m, 1), device=x.device),
- ratio_augmentation_,
- ],
- dim=0,
- )
- augmented_counts = NegativeBinomial(
- mu=augmentation_rate, theta=px_r
- ).sample() # (samples, m, n_genes)
- x_augmented = (1 - ratio_augmentation) * x.unsqueeze(
- 0
- ) + ratio_augmentation * augmented_counts
- if x_smoothed is not None:
- x_augmented[1, ...] = x_smoothed
- else:
- x_augmented = x.unsqueeze(0)
- if x_smoothed is not None:
- x_augmented = torch.cat([x_augmented, x_smoothed.unsqueeze(0)], dim=0)
- prior_sampled = None
- ratios_ct_augmentation = None
- ratio_augmentation = None
-
- batch_index = tensors[REGISTRY_KEYS.BATCH_KEY]
-
- input_dict = {
- "batch_index": batch_index,
- "x_augmented": x_augmented,
- "prior_sampled": prior_sampled,
- "ratios_ct_augmentation": ratios_ct_augmentation,
- "ratio_augmentation": ratio_augmentation,
- }
- return input_dict
-
- def _get_generative_input(self, tensors, inference_outputs):
- z = inference_outputs["z"]
- library = inference_outputs["library"]
- ind_x = tensors[REGISTRY_KEYS.INDICES_KEY].long().ravel()
- batch_index = tensors[REGISTRY_KEYS.BATCH_KEY]
-
- input_dict = {"z": z, "ind_x": ind_x, "library": library, "batch_index": batch_index}
- return input_dict
-
- @auto_move_data
- def inference(
- self,
- x_augmented,
- batch_index,
- n_samples=1,
- prior_sampled=None,
- ratios_ct_augmentation=None,
- ratio_augmentation=None,
- ):
- """Runs the inference (encoder) model."""
- x_ = x_augmented
- library = x_augmented.sum(-1).unsqueeze(-1)
- x_ = torch.log(1 + x_)
- if self.n_latent_amortization is not None:
- qz, z = self.z_encoder(x_, batch_index)
- else:
- z = x_
- qz = Normal(x_, scale=1e-6 * torch.ones_like(x_))
-
- outputs = {
- "z": z,
- "qz": qz,
- "library": library,
- "x_augmented": x_augmented,
- "prior_sampled": prior_sampled,
- "ratio_augmentation": ratio_augmentation,
- "ratios_ct_augmentation": ratios_ct_augmentation,
- }
- return outputs
-
- @auto_move_data
- def generative(self, z, ind_x, library, batch_index):
- """Build the deconvolution model for every cell in the minibatch."""
- m = len(ind_x)
- # setup all non-linearities
- eps = torch.nn.functional.softmax(self.eta, dim=-1) # add_celltypes, n_genes
- px_r = torch.exp(self.px_r)
- n_samples = z.size(0)
-
- if self.amortization in ["both", "latent"]:
- if self.prior_mode == "mog":
- gamma_ = self.gamma_encoder(z)
- proportion_modes_logits = (
- torch.transpose(
- gamma_[:, :, -self.n_states_per_label * self.n_labels :], -1, -2
- )
- .reshape((n_samples, self.n_states_per_label, self.n_labels, m))
- .transpose(-1, -2)
- ) # n_samples, n_states_per_label, m, n_labels
- proportion_modes = torch.nn.functional.softmax(proportion_modes_logits, dim=-3)
- gamma_ind = torch.transpose(
- gamma_[:, :, : self.n_states_per_label * self.n_labels * self.n_latent], -1, -2
- ).reshape(
- (n_samples, self.n_states_per_label, self.n_latent, self.n_labels, m)
- ) # n_samples, n_states_per_label, n_latent, n_labels, m
- else:
- gamma_ind = torch.transpose(self.gamma_encoder(z), 0, 1).reshape(
- (n_samples, 1, self.n_latent, self.n_labels, m)
- )
- proportion_modes_logits = proportion_modes = torch.ones(
- (n_samples, 1, m, self.n_labels), device=z.device
- )
- else:
- gamma_ind = (
- self.gamma[:, :, ind_x].unsqueeze(0).unsqueeze(0).repeat(n_samples, 1, 1, 1, 1)
- ) # n_samples, n_latent, n_labels, m
- proportion_modes_logits = proportion_modes = torch.ones(
- (n_samples, 1, m, self.n_labels), device=z.device
- )
-
- if self.amortization in ["both", "proportion"]:
- v_ind = self.V_encoder(z)
- else:
- v_ind = (
- self.V[:, ind_x].T.unsqueeze(0).repeat(n_samples, 1, 1)
- ) # n_samples, m, labels + 1
- v_ind = torch.nn.functional.softmax(v_ind, dim=-1)
-
- px_est = torch.zeros((n_samples, m, self.n_labels, self.n_genes), device=z.device)
- enum_label = torch.arange(0, self.n_labels).repeat(m).view((-1, 1)) # m * n_labels, 1
-
- batch_rep = self.compute_embedding(REGISTRY_KEYS.BATCH_KEY, batch_index)
- batch_rep_input = (
- batch_rep.repeat_interleave(self.n_labels, dim=0).unsqueeze(0).repeat(n_samples, 1, 1)
- )
-
- for mode in range(gamma_ind.shape[-4]):
- # reshape and get gene expression value for all minibatch
- gamma_ind_ = torch.transpose(
- gamma_ind[:, mode, ...], -1, -3
- ) # n_samples, m, n_labels, n_latent
- gamma_reshape_ = gamma_ind_.reshape(
- (n_samples, -1, self.n_latent)
- ) # n_samples, m * n_labels, n_latent
- decoder_input_ = torch.cat([gamma_reshape_, batch_rep_input], dim=-1)
- h = self.decoder(decoder_input_, enum_label.to(z.device))
- px_est += proportion_modes[:, mode, ...].unsqueeze(-1) * torch.nn.Softmax(dim=-1)(
- self.px_decoder(h).reshape((n_samples, m, self.n_labels, -1))
- + self.per_ct_bias[enum_label.ravel()].reshape(1, m, self.n_labels, -1)
- + self.beta.view(1, 1, 1, -1)
- ) # (n_samples, m, n_labels, n_genes)
-
- # add the additional cell types
- eps = eps.unsqueeze(0).repeat(
- n_samples, m, 1, 1
- ) # (n_samples, m, add_celltypes, n_genes) <- this is the dummy cell type
- r_hat = torch.cat([px_est, eps], dim=-2) # n_samples, m, n_labels + add_celltypes, n_genes
-
- # now combine them for convolution. Add epsilon during training.
- eps_v = self.eps_v if self.training else 0.0
- px_scale = torch.sum(
- (v_ind.unsqueeze(-1) + eps_v) * r_hat, dim=-2
- ) # n_samples, m, n_genes
- px_rate = library * px_scale
-
- return {
- "px_r": px_r,
- "px_rate": px_rate,
- "px_scale": px_scale,
- "px_mu": r_hat,
- "gamma": gamma_ind,
- "v": v_ind,
- "proportion_modes": proportion_modes,
- "proportion_modes_logits": proportion_modes_logits,
- }
-
- def _compute_cross_entropy(self, prob_true, prob_pred):
- log_prob_pred = torch.log(prob_pred / prob_pred.sum(axis=-1, keepdim=True))
- prob_true = prob_true + 1e-20
- prob_true = prob_true / prob_true.sum(axis=-1, keepdim=True)
- kl_div = torch.nn.functional.kl_div(
- log_prob_pred, prob_true, reduction="batchmean", log_target=False
- )
-
- return kl_div
-
- def loss(
- self,
- tensors,
- inference_outputs,
- generative_outputs,
- kl_weight: float = 1.0,
- ct_sparsity_weight: float = 2.0,
- weighting_augmentation: float = 100.0,
- weighting_smoothing: float = 100.0,
- eta_reg: float = 1.0,
- beta_reg: float = 1.0,
- weighting_kl_latent: float = 1e-3,
- reconst_weight: float = 3.0,
- ):
- """Compute the loss."""
- x_augmented = inference_outputs["x_augmented"]
- px_rate = generative_outputs["px_rate"]
- px_r = generative_outputs["px_r"]
- gamma = generative_outputs["gamma"]
- v = generative_outputs["v"]
- ratio_augmentation = inference_outputs["ratio_augmentation"]
- ratios_ct_augmentation = inference_outputs["ratios_ct_augmentation"]
-
- if "x_smoothed" in tensors:
- sample_fully_augmented = 2
- n_samples = self.n_samples_augmentation + 2
- else:
- sample_fully_augmented = 1
- n_samples = self.n_samples_augmentation + 1
- m = x_augmented.shape[1]
-
- # Augmentation tensors (prior_sampled, ratio_augmentation, ratios_ct_augmentation) are
- # only produced during training (see `_get_inference_input`, gated on `self.training`).
- # During validation they are `None`, so fall back to the static (non-augmented) prior.
- if self.augmentation and self.training:
- prior_sampled = inference_outputs["prior_sampled"].reshape(
- n_samples, 1, x_augmented.shape[1], self.n_labels, self.n_latent
- )
- mean_vprior = torch.cat(
- [
- self.mean_vprior.unsqueeze(0).unsqueeze(-4).repeat(n_samples, m, 1, 1, 1),
- prior_sampled.permute(0, 2, 3, 1, 4),
- ],
- dim=-2,
- ) # n_samples, m, n_labels, p + 1, n_latent
- var_vprior = torch.cat(
- [
- self.var_vprior.unsqueeze(0).unsqueeze(-4).repeat(n_samples, m, 1, 1, 1),
- torch.min(self.var_vprior, dim=-2)
- .values.view(1, 1, self.n_labels, 1, self.n_latent)
- .repeat(n_samples, m, 1, 1, 1),
- ],
- dim=-2,
- ) # n_samples, m, n_labels, p + 1, n_latent
-
- mp_vprior = torch.cat(
- [
- (1 - ratio_augmentation.unsqueeze(-1))
- * self.mp_vprior.view(1, 1, self.n_labels, -1).repeat(n_samples, m, 1, 1),
- ratio_augmentation.unsqueeze(-1) * ratios_ct_augmentation.unsqueeze(-1),
- ],
- dim=-1,
- ) # n_samples, m, n_labels, p + 1
- else:
- mean_vprior = self.mean_vprior.unsqueeze(0).unsqueeze(0)
- var_vprior = self.var_vprior.unsqueeze(0).unsqueeze(0)
- mp_vprior = self.mp_vprior.unsqueeze(0).unsqueeze(0)
-
- proportion_modes = generative_outputs["proportion_modes"]
- # rounding errors for softmax lead to px_rate 0 which induces NaNs in the log_prob
- reconst_loss = (
- -NegativeBinomial(mu=px_rate, theta=px_r).log_prob(x_augmented).sum(-1).mean(0)
- )
- # beta prior likelihood
- mean = torch.zeros_like(self.beta)
- scale = torch.ones_like(self.beta)
- # beta loss
- glo_neg_log_likelihood_prior = -beta_reg * Normal(mean, scale).log_prob(self.beta).sum()
- loss_augmentation = torch.tensor(0.0, device=x_augmented.device)
- if self.augmentation and self.training:
- expected_proportions = torch.cat(
- [
- ratios_ct_augmentation,
- torch.zeros([n_samples, m, self.add_celltypes]).to(v.device),
- ],
- dim=-1,
- )
- loss_augmentation += weighting_augmentation * self._compute_cross_entropy(
- expected_proportions[sample_fully_augmented, ...].squeeze(0),
- v[sample_fully_augmented, ...].squeeze(0),
- )
- if "x_smoothed" in tensors:
- loss_augmentation += weighting_smoothing * self._compute_cross_entropy(
- v[1, ...].squeeze(0), v[0, ...].squeeze(0)
- )
-
- # gamma prior likelihood
- if self.mean_vprior is None:
- # isotropic normal prior
- mean = torch.zeros_like(gamma)
- scale = torch.ones_like(gamma)
- neg_log_likelihood_prior = -Normal(mean, scale).log_prob(gamma).sum(2).sum(1)
- elif self.prior_mode == "mog":
- # gamma is of shape n_samples, minibatch_size, 1, n_latent, n_labels
- gamma = gamma.permute(
- 1, 0, 4, 3, 2
- ) # p, n_samples, minibatch_size, n_labels, n_latent
- cats = Categorical(probs=mp_vprior)
- normal_dists = Independent(
- Normal(mean_vprior, var_vprior), reinterpreted_batch_ndims=1
- )
- pre_lse = MixtureSameFamily(cats, normal_dists).log_prob(
- gamma
- ) # p, n_samples, minibatch_size, n_labels
- pre_lse = pre_lse.permute(1, 0, 2, 3)
- log_likelihood_prior = torch.mul(pre_lse, proportion_modes + 1e-6).sum(
- -3
- ) # n_samples, minibatch, n_labels
- neg_log_likelihood_prior = -torch.mul(
- log_likelihood_prior, v[:, :, : -self.add_celltypes] + 1e-3
- ).sum(-1) # n_samples, minibatch, n_labels
- # neg_log_likelihood_prior = - log_likelihood_prior.sum(-1)
- else:
- gamma = gamma.permute(
- 0, 4, 1, 3, 2
- ) # n_samples, minibatch_size, 1, n_labels, n_latent
- mean_vprior = torch.transpose(
- mean_vprior, -3, -2
- ) # n_samples, m, p, n_labels, n_latent
- var_vprior = torch.transpose(var_vprior, -3, -2) # n_samples, m, p, n_labels, n_latent
- mp_vprior = torch.transpose(mp_vprior, -2, -1) # n_samples, m, p, n_labels
- pre_lse = (
- Normal(mean_vprior, torch.sqrt(var_vprior) + 1e-4)
- .log_prob(gamma)
- .sum(-1)
- .squeeze(-3)
- ) + torch.log(1e-3 + mp_vprior) # n_samples, minibatch, p, n_labels
- log_likelihood_prior = torch.logsumexp(pre_lse, -2) # n_samples, minibatch, n_labels
- neg_log_likelihood_prior = -log_likelihood_prior.sum(-1) # n_samples, minibatch
- if self.n_latent_amortization is not None:
- neg_log_likelihood_prior += weighting_kl_latent * kl(
- inference_outputs["qz"],
- Normal(
- torch.zeros([self.n_latent_amortization], device=x_augmented.device),
- torch.ones([self.n_latent_amortization], device=x_augmented.device),
- ),
- ).sum(dim=-1)
-
- v_sparsity_loss = (
- ct_sparsity_weight * torch.distributions.Categorical(probs=v[0, ...]).entropy()
- )
- v_sparsity_loss -= (
- eta_reg
- * Exponential(torch.ones_like(v[0, :, -self.add_celltypes :]))
- .log_prob(v[0, :, -self.add_celltypes :])
- .sum()
- )
-
- loss = torch.mean(
- reconst_weight * reconst_loss
- + kl_weight * (neg_log_likelihood_prior + v_sparsity_loss)
- + glo_neg_log_likelihood_prior
- + loss_augmentation
- )
-
- return LossOutput(
- loss=loss,
- reconstruction_loss=reconst_loss,
- kl_local=neg_log_likelihood_prior,
- kl_global=glo_neg_log_likelihood_prior,
- extra_metrics={
- "v_sparsity": v_sparsity_loss.mean(),
- "augmentation": loss_augmentation.mean(),
- },
- )
-
- @torch.inference_mode()
- def sample(
- self,
- tensors,
- n_samples=1,
- library_size=1,
- ):
- """Sample from the posterior."""
- raise NotImplementedError("No sampling method for DestVI")
diff --git a/tests/external/diagvi/test_diagvi.py b/tests/external/diagvi/test_diagvi.py
deleted file mode 100644
index 3cc2080e3e..0000000000
--- a/tests/external/diagvi/test_diagvi.py
+++ /dev/null
@@ -1,1678 +0,0 @@
-import numpy as np
-import pandas as pd
-import pytest
-import torch
-from anndata import AnnData
-from mudata import MuData
-
-from scvi.external.diagvi import DIAGVI
-
-# =============================================================================
-# Constants and fixtures
-# =============================================================================
-
-# Constants for test data dimensions
-N_OBS_SEQ = 100
-N_OBS_SPATIAL = 80
-N_VARS = 50
-N_LABELS = 5 # Number of cell types for semi-supervised tests
-
-
-@pytest.fixture(scope="module")
-def adata_seq():
- X = np.random.poisson(1.0, size=(N_OBS_SEQ, N_VARS))
- obs = {"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SEQ))}
- var = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- return AnnData(X=X, obs=obs, var=var)
-
-
-@pytest.fixture(scope="module")
-def adata_spatial():
- X = np.random.poisson(1.0, size=(N_OBS_SPATIAL, N_VARS))
- obs = {"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SPATIAL))}
- var = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- return AnnData(X=X, obs=obs, var=var)
-
-
-@pytest.fixture(scope="module")
-def trained_model(adata_seq, adata_spatial):
- """Module-scoped trained model to avoid redundant training across tests."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
- model = DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
- model.train(max_epochs=1, batch_size=16)
- return model, adata_seq, adata_spatial
-
-
-def make_model(adata_seq, adata_spatial):
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
- return DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
-
-
-# =============================================================================
-# Basic model functionality tests
-# =============================================================================
-
-
-def test_diagvi_initialization(adata_seq, adata_spatial):
- model = make_model(adata_seq, adata_spatial)
- assert model.module is not None
- assert "diss" in model.adatas
- assert "spatial" in model.adatas
-
-
-def test_diagvi_training(adata_seq, adata_spatial):
- model = make_model(adata_seq, adata_spatial)
- model.train(max_epochs=1, batch_size=16)
- assert model.is_trained_ is True
-
-
-def test_diagvi_train_with_modality_specific_splits(adata_seq, adata_spatial):
- """Test training with modality-specific external_indexing."""
- from sklearn.model_selection import train_test_split
-
- model = make_model(adata_seq, adata_spatial)
-
- # Create modality-specific splits
- train_diss, temp_diss = train_test_split(np.arange(N_OBS_SEQ), test_size=0.3, random_state=42)
- val_diss, test_diss = train_test_split(temp_diss, test_size=0.5, random_state=42)
-
- train_spatial, temp_spatial = train_test_split(
- np.arange(N_OBS_SPATIAL), test_size=0.4, random_state=42
- )
- val_spatial, test_spatial = train_test_split(temp_spatial, test_size=0.5, random_state=42)
-
- # Train with modality-specific splits
- model.train(
- max_epochs=1,
- batch_size=16,
- datasplitter_kwargs={
- "diss": {"external_indexing": [train_diss, val_diss, test_diss]},
- "spatial": {"external_indexing": [train_spatial, val_spatial, test_spatial]},
- },
- )
-
- assert model.is_trained_ is True
-
- # Check that splits were stored correctly
- assert len(model.train_indices_[0]) == len(train_diss)
- assert len(model.validation_indices_[0]) == len(val_diss)
- assert len(model.test_indices_[0]) == len(test_diss)
-
- assert len(model.train_indices_[1]) == len(train_spatial)
- assert len(model.validation_indices_[1]) == len(val_spatial)
- assert len(model.test_indices_[1]) == len(test_spatial)
-
- # Test that we can use these indices with get_latent_representation
- test_latents = model.get_latent_representation(
- indices={
- "diss": model.test_indices_[0],
- "spatial": model.test_indices_[1],
- }
- )
- assert test_latents["diss"].shape[0] == len(test_diss)
- assert test_latents["spatial"].shape[0] == len(test_spatial)
-
-
-def test_diagvi_latent_representation(adata_seq, adata_spatial):
- model = make_model(adata_seq, adata_spatial)
- model.train(max_epochs=1, batch_size=16)
- latents = model.get_latent_representation()
- assert latents["diss"].shape[0] == adata_seq.shape[0]
- assert latents["spatial"].shape[0] == adata_spatial.shape[0]
-
-
-def test_diagvi_latent_representation_with_indices(trained_model):
- """Test get_latent_representation with subset of indices."""
- model, adata_seq, adata_spatial = trained_model
-
- indices = {
- "diss": list(range(20)),
- "spatial": list(range(15)),
- }
-
- latents = model.get_latent_representation(indices=indices)
-
- assert latents["diss"].shape == (20, model.module.n_latent)
- assert latents["spatial"].shape == (15, model.module.n_latent)
-
- # Test with validation indices from training
- if hasattr(model, "validation_indices_") and len(model.validation_indices_) > 0:
- val_latents = model.get_latent_representation(
- indices={
- "diss": model.validation_indices_[0],
- "spatial": model.validation_indices_[1],
- }
- )
- assert val_latents["diss"].shape[0] == len(model.validation_indices_[0])
- assert val_latents["spatial"].shape[0] == len(model.validation_indices_[1])
-
-
-def test_diagvi_generative_model(adata_seq, adata_spatial):
- model = make_model(adata_seq, adata_spatial)
- model.train(max_epochs=1, batch_size=16)
- tensors = {
- "X": torch.tensor(adata_seq.X, dtype=torch.float32),
- "batch": torch.tensor(adata_seq.obs["batch"].cat.codes.values, dtype=torch.long),
- }
- inference_outputs = model.module.inference(tensors["X"], mode="diss")
- generative_outputs = model.module.generative(
- inference_outputs["z"],
- inference_outputs["library"],
- batch_index=tensors["batch"],
- v=inference_outputs["v"],
- mode="diss",
- )
- assert "px" in generative_outputs or "px_rate" in generative_outputs
- assert "pz" in generative_outputs
-
-
-def test_diagvi_loss(adata_seq, adata_spatial):
- model = make_model(adata_seq, adata_spatial)
- model.train(max_epochs=1, batch_size=16)
- device = next(model.module.parameters()).device
- tensors = {
- "X": torch.tensor(adata_seq.X, dtype=torch.float32, device=device),
- "batch": torch.tensor(
- adata_seq.obs["batch"].cat.codes.values, dtype=torch.long, device=device
- ),
- }
- inference_outputs = model.module.inference(tensors["X"], mode="diss")
- generative_outputs = model.module.generative(
- inference_outputs["z"],
- inference_outputs["library"],
- batch_index=tensors["batch"],
- v=inference_outputs["v"],
- mode="diss",
- )
- loss = model.module.loss(
- tensors, inference_outputs, generative_outputs, lam_kl=1.0, lam_data=0.1, mode="diss"
- )
- assert loss.loss is not None
- assert loss.reconstruction_loss is not None
- assert loss.kl_local is not None
-
-
-def test_diagvi_get_imputed_values(adata_seq, adata_spatial):
- model = make_model(adata_seq, adata_spatial)
- model.train(max_epochs=1, batch_size=16)
- # Impute spatial from diss
- imputed = model.get_imputed_values(query_name="spatial")
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape[0] == adata_spatial.shape[0]
- assert imputed.shape[1] == adata_seq.shape[1]
- # Impute diss from spatial
- imputed2 = model.get_imputed_values(query_name="diss")
- assert isinstance(imputed2, np.ndarray)
- assert imputed2.shape[0] == adata_seq.shape[0]
- assert imputed2.shape[1] == adata_spatial.shape[1]
-
-
-def test_diagvi_save_and_load_no_anndata(adata_seq, adata_spatial, tmp_path):
- model = make_model(adata_seq, adata_spatial)
- model.train(max_epochs=1, batch_size=16)
- save_path = tmp_path / "diagvi_test_model.pt"
- model.save(save_path, save_anndata=False)
-
- # Load the model — adatas required when save_anndata=False
- loaded_model = DIAGVI.load(save_path, adatas={"diss": adata_seq, "spatial": adata_spatial})
-
- # compare state dicts
- model_state = model.module.state_dict()
- loaded_state = loaded_model.module.state_dict()
- for key in model_state:
- assert key in loaded_state
- assert torch.allclose(model_state[key], loaded_state[key], atol=1e-6), f"Mismatch in {key}"
-
- # compare output equivalence
- latents = loaded_model.get_latent_representation()
- assert latents["diss"].shape[0] == adata_seq.shape[0]
- assert latents["spatial"].shape[0] == adata_spatial.shape[0]
-
- # Load with different adatas — same var_names/structure but fresh counts
- new_adata_seq = AnnData(
- X=np.random.poisson(2.0, size=(N_OBS_SEQ, N_VARS)).astype(np.float32),
- obs={"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SEQ))},
- var=adata_seq.var.copy(),
- )
- new_adata_spatial = AnnData(
- X=np.random.poisson(2.0, size=(N_OBS_SPATIAL, N_VARS)).astype(np.float32),
- obs={"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SPATIAL))},
- var=adata_spatial.var.copy(),
- )
- loaded_model_new = DIAGVI.load(
- save_path, adatas={"diss": new_adata_seq, "spatial": new_adata_spatial}
- )
-
- # weights identical — only data differs
- loaded_state_new = loaded_model_new.module.state_dict()
- for key in model_state:
- assert torch.allclose(model_state[key], loaded_state_new[key], atol=1e-6)
-
- # latents have correct shape for new cell counts
- latents_new = loaded_model_new.get_latent_representation()
- assert latents_new["diss"].shape == (N_OBS_SEQ, model.module.n_latent)
- assert latents_new["spatial"].shape == (N_OBS_SPATIAL, model.module.n_latent)
-
-
-def test_diagvi_save_and_load_with_anndata(adata_seq, adata_spatial, tmp_path):
- model = make_model(adata_seq, adata_spatial)
- model.train(max_epochs=1, batch_size=16)
- save_path = tmp_path / "diagvi_test_model.pt"
- model.save(save_path, save_anndata=True)
-
- # Load the model — adatas required when save_anndata=False
- loaded_model = DIAGVI.load(save_path)
-
- # compare state dicts
- model_state = model.module.state_dict()
- loaded_state = loaded_model.module.state_dict()
- for key in model_state:
- assert key in loaded_state
- assert torch.allclose(model_state[key], loaded_state[key], atol=1e-6), f"Mismatch in {key}"
-
- # compare output equivalence
- latents = loaded_model.get_latent_representation()
- assert latents["diss"].shape[0] == adata_seq.shape[0]
- assert latents["spatial"].shape[0] == adata_spatial.shape[0]
-
-
-def test_diagvi_save_and_load_with_prefix_and_anndata(adata_seq, adata_spatial, tmp_path):
- model = make_model(adata_seq, adata_spatial)
- save_path = tmp_path / "diagvi_prefixed"
-
- model.save(save_path, prefix="diagvi_", save_anndata=True)
- loaded_model = DIAGVI.load(save_path, prefix="diagvi_")
-
- assert loaded_model.input_names == model.input_names
-
-
-# =============================================================================
-# Tests for get_imputed_values
-# =============================================================================
-
-
-@pytest.mark.parametrize("reference_batch", [0, 1, "batch1", "batch2"])
-def test_get_imputed_values_reference_batch_scalar(trained_model, reference_batch):
- """Test get_imputed_values with scalar reference_batch (int or string)."""
- model, adata_seq, adata_spatial = trained_model
-
- # Impute from spatial to diss
- imputed = model.get_imputed_values(query_name="spatial", reference_batch=reference_batch)
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape == (N_OBS_SPATIAL, N_VARS)
-
- # Impute from diss to spatial
- imputed2 = model.get_imputed_values(query_name="diss", reference_batch=reference_batch)
- assert isinstance(imputed2, np.ndarray)
- assert imputed2.shape == (N_OBS_SEQ, N_VARS)
-
-
-@pytest.mark.parametrize(
- "batch_array_type",
- ["int_array", "string_array"],
-)
-def test_get_imputed_values_reference_batch_array(trained_model, batch_array_type):
- """Test get_imputed_values with array-like reference_batch."""
- model, adata_seq, adata_spatial = trained_model
-
- if batch_array_type == "int_array":
- # Integer array matching spatial obs count
- reference_batch = np.random.choice([0, 1], size=N_OBS_SPATIAL)
- else:
- # String array matching spatial obs count
- reference_batch = np.random.choice(["batch1", "batch2"], size=N_OBS_SPATIAL)
-
- imputed = model.get_imputed_values(query_name="spatial", reference_batch=reference_batch)
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape == (N_OBS_SPATIAL, N_VARS)
-
-
-@pytest.mark.parametrize("reference_libsize", [100.0, 1000.0, 10000.0])
-def test_get_imputed_values_reference_libsize_scalar(trained_model, reference_libsize):
- """Test get_imputed_values with scalar reference_libsize."""
- model, adata_seq, adata_spatial = trained_model
-
- imputed = model.get_imputed_values(query_name="spatial", reference_libsize=reference_libsize)
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape == (N_OBS_SPATIAL, N_VARS)
-
-
-def test_get_imputed_values_reference_libsize_array(trained_model):
- """Test get_imputed_values with array reference_libsize."""
- model, adata_seq, adata_spatial = trained_model
-
- # Array of library sizes matching obs count
- reference_libsize = np.random.uniform(500, 2000, size=N_OBS_SPATIAL)
-
- imputed = model.get_imputed_values(query_name="spatial", reference_libsize=reference_libsize)
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape == (N_OBS_SPATIAL, N_VARS)
-
-
-def test_get_imputed_values_combined_batch_and_libsize(trained_model):
- """Test get_imputed_values with both reference_batch and reference_libsize specified."""
- model, adata_seq, adata_spatial = trained_model
-
- # Use scalar values
- imputed = model.get_imputed_values(
- query_name="spatial", reference_batch=1, reference_libsize=1000.0
- )
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape == (N_OBS_SPATIAL, N_VARS)
-
- # Use array values
- reference_batch_arr = np.random.choice([0, 1], size=N_OBS_SPATIAL)
- reference_libsize_arr = np.random.uniform(500, 2000, size=N_OBS_SPATIAL)
-
- imputed2 = model.get_imputed_values(
- query_name="spatial",
- reference_batch=reference_batch_arr,
- reference_libsize=reference_libsize_arr,
- )
- assert isinstance(imputed2, np.ndarray)
- assert imputed2.shape == (N_OBS_SPATIAL, N_VARS)
-
- # Mixed: scalar batch, array libsize
- imputed3 = model.get_imputed_values(
- query_name="spatial",
- reference_batch="batch1",
- reference_libsize=reference_libsize_arr,
- )
- assert isinstance(imputed3, np.ndarray)
- assert imputed3.shape == (N_OBS_SPATIAL, N_VARS)
-
-
-def test_get_imputed_values_with_indices(trained_model):
- """Test get_imputed_values with subset of indices."""
- model, adata_seq, adata_spatial = trained_model
-
- # Test with small subset of indices
- indices = list(range(10))
- imputed = model.get_imputed_values(query_name="spatial", indices=indices)
-
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape == (10, N_VARS)
-
- # Test with validation indices from training
- if hasattr(model, "validation_indices_") and len(model.validation_indices_) > 0:
- val_imputed = model.get_imputed_values(
- query_name="spatial", indices=model.validation_indices_[1]
- )
- assert val_imputed.shape[0] == len(model.validation_indices_[1])
- assert val_imputed.shape[1] == N_VARS
-
-
-@pytest.mark.parametrize(
- ("error_case", "error_kwargs"),
- [
- ("wrong_size_batch", {"reference_batch": np.array([0, 1, 0])}),
- ("wrong_size_libsize", {"reference_libsize": np.array([100.0, 200.0, 300.0])}),
- ("2d_libsize", {"reference_libsize": np.array([[100.0], [200.0], [300.0]])}),
- ],
-)
-def test_get_imputed_values_errors(trained_model, error_case, error_kwargs):
- """Test that get_imputed_values raises ValueError for invalid inputs."""
- model, adata_seq, adata_spatial = trained_model
-
- with pytest.raises(ValueError):
- model.get_imputed_values(query_name="spatial", **error_kwargs)
-
-
-# =============================================================================
-# Tests for MuData support
-# =============================================================================
-
-
-@pytest.fixture(scope="module")
-def mudata_fixture():
- """Create a MuData object with two modalities for testing."""
- # Create first modality (e.g., RNA)
- X_rna = np.random.poisson(1.0, size=(N_OBS_SEQ, N_VARS))
- obs_rna = pd.DataFrame(
- {"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SEQ))}
- )
- var_rna = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- adata_rna = AnnData(X=X_rna, obs=obs_rna, var=var_rna)
-
- # Create second modality (e.g., spatial)
- X_spatial = np.random.poisson(1.0, size=(N_OBS_SPATIAL, N_VARS))
- obs_spatial = pd.DataFrame(
- {"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SPATIAL))}
- )
- var_spatial = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- adata_spatial = AnnData(X=X_spatial, obs=obs_spatial, var=var_spatial)
-
- # Create MuData
- mdata = MuData({"rna": adata_rna, "spatial": adata_spatial})
- return mdata
-
-
-def test_diagvi_mudata_setup(mudata_fixture):
- """Test that setup_mudata correctly registers both modalities."""
- mdata = mudata_fixture
-
- DIAGVI.setup_mudata(
- mdata,
- modalities=["rna", "spatial"],
- batch_key="batch",
- likelihood="nb",
- )
-
- # Check that diagvi-specific keys are set in uns
- assert "diagvi_likelihood" in mdata.mod["rna"].uns
- assert "diagvi_likelihood" in mdata.mod["spatial"].uns
- assert mdata.mod["rna"].uns["diagvi_likelihood"] == "nb"
- assert mdata.mod["spatial"].uns["diagvi_likelihood"] == "nb"
-
-
-def test_diagvi_mudata_initialization(mudata_fixture):
- """Test that DIAGVI can be initialized with a MuData object."""
- mdata = mudata_fixture
-
- DIAGVI.setup_mudata(
- mdata,
- modalities=["rna", "spatial"],
- batch_key="batch",
- likelihood="nb",
- )
-
- # Initialize model with MuData directly
- model = DIAGVI(mdata)
-
- assert model.module is not None
- assert "rna" in model.adatas
- assert "spatial" in model.adatas
- assert len(model.input_names) == 2
-
-
-def test_diagvi_mudata_training(mudata_fixture):
- """Test that DIAGVI can be trained when initialized with MuData."""
- mdata = mudata_fixture
-
- DIAGVI.setup_mudata(
- mdata,
- modalities=["rna", "spatial"],
- batch_key="batch",
- likelihood="nb",
- )
-
- model = DIAGVI(mdata)
- model.train(max_epochs=1, batch_size=16)
-
- assert model.is_trained_ is True
-
-
-def test_diagvi_mudata_latent_representation(mudata_fixture):
- """Test latent representation extraction with MuData-initialized model."""
- mdata = mudata_fixture
-
- DIAGVI.setup_mudata(
- mdata,
- modalities=["rna", "spatial"],
- batch_key="batch",
- likelihood="nb",
- )
-
- model = DIAGVI(mdata)
- model.train(max_epochs=1, batch_size=16)
-
- latents = model.get_latent_representation()
-
- assert "rna" in latents
- assert "spatial" in latents
- assert latents["rna"].shape[0] == N_OBS_SEQ
- assert latents["spatial"].shape[0] == N_OBS_SPATIAL
-
-
-def test_diagvi_mudata_imputation(mudata_fixture):
- """Test imputation with MuData-initialized model."""
- mdata = mudata_fixture
-
- DIAGVI.setup_mudata(
- mdata,
- modalities=["rna", "spatial"],
- batch_key="batch",
- likelihood="nb",
- )
-
- model = DIAGVI(mdata)
- model.train(max_epochs=1, batch_size=16)
-
- # Impute from spatial to rna
- imputed = model.get_imputed_values(query_name="spatial")
- assert isinstance(imputed, np.ndarray)
- assert imputed.shape[0] == N_OBS_SPATIAL
-
- # Impute from rna to spatial
- imputed2 = model.get_imputed_values(query_name="rna")
- assert isinstance(imputed2, np.ndarray)
- assert imputed2.shape[0] == N_OBS_SEQ
-
-
-def test_diagvi_mudata_with_different_params_per_modality(mudata_fixture):
- """Test setup_mudata with modality-specific parameters passed as dicts."""
- mdata = mudata_fixture
-
- DIAGVI.setup_mudata(
- mdata,
- modalities=["rna", "spatial"],
- batch_key={"rna": "batch", "spatial": "batch"},
- likelihood={"rna": "nb", "spatial": "zinb"},
- )
-
- assert mdata.mod["rna"].uns["diagvi_likelihood"] == "nb"
- assert mdata.mod["spatial"].uns["diagvi_likelihood"] == "zinb"
-
- model = DIAGVI(mdata)
- assert model.module is not None
-
-
-# =============================================================================
-# GMM prior and semi-supervised fixtures and tests
-# =============================================================================
-
-
-@pytest.fixture(scope="module")
-def adata_seq_with_labels():
- """AnnData with cell type labels for semi-supervised testing."""
- X = np.random.poisson(1.0, size=(N_OBS_SEQ, N_VARS))
- labels = np.random.choice([f"celltype_{i}" for i in range(N_LABELS)], size=N_OBS_SEQ)
- obs = pd.DataFrame(
- {
- "batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SEQ)),
- "cell_type": pd.Categorical(labels),
- }
- )
- var = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- return AnnData(X=X, obs=obs, var=var)
-
-
-@pytest.fixture(scope="module")
-def adata_spatial_with_labels():
- """AnnData with cell type labels for semi-supervised testing."""
- X = np.random.poisson(1.0, size=(N_OBS_SPATIAL, N_VARS))
- labels = np.random.choice([f"celltype_{i}" for i in range(N_LABELS)], size=N_OBS_SPATIAL)
- obs = pd.DataFrame(
- {
- "batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SPATIAL)),
- "cell_type": pd.Categorical(labels),
- }
- )
- var = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- return AnnData(X=X, obs=obs, var=var)
-
-
-def test_diagvi_gmm_prior_custom_components(adata_seq, adata_spatial):
- """Test DIAGVI with custom number of GMM components."""
- n_components = 7
- DIAGVI.setup_anndata(
- adata_seq,
- batch_key="batch",
- likelihood="nb",
- gmm_prior=True,
- n_mixture_components=n_components,
- )
- DIAGVI.setup_anndata(
- adata_spatial,
- batch_key="batch",
- likelihood="nb",
- gmm_prior=True,
- n_mixture_components=n_components,
- )
-
- model = DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
-
- assert model.module.gmm_means["diss"].shape[0] == n_components
- assert model.module.gmm_means["spatial"].shape[0] == n_components
-
-
-def test_diagvi_semi_supervised_one_modality(adata_seq_with_labels, adata_spatial):
- """Test semi-supervised on only one modality."""
- # Semi-supervised is automatically enabled when labels_key is provided
- DIAGVI.setup_anndata(
- adata_seq_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- gmm_prior=True,
- )
- # No labels_key means no semi-supervised mode
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb", gmm_prior=False)
-
- model = DIAGVI({"diss": adata_seq_with_labels, "spatial": adata_spatial})
-
- # First modality should have GMM prior and classifier
- assert model.module.use_gmm_prior["diss"] is True
- assert model.module.classifier_0 is not None
- assert model.module.gmm_means["diss"].shape[0] == model.module.n_labels["diss"]
-
- # Second modality should have standard normal prior and no classifier
- assert model.module.use_gmm_prior["spatial"] is False
- assert model.module.classifier_1 is None
- assert "spatial" not in model.module.gmm_means
-
- # Should still train successfully
- model.train(max_epochs=1, batch_size=16)
- assert model.is_trained_ is True
-
-
-# =============================================================================
-# Tests for automatic semi_supervised handling
-# =============================================================================
-
-
-def test_semi_supervised_auto_enabled_with_labels_key(adata_seq_with_labels):
- """Test that semi_supervised is automatically enabled when labels_key is provided."""
- DIAGVI.setup_anndata(
- adata_seq_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- )
-
- assert adata_seq_with_labels.uns["diagvi_semi_supervised"] is True
- # n_mixture_components should equal number of unique labels
- expected_n_labels = adata_seq_with_labels.obs["cell_type"].nunique()
- assert adata_seq_with_labels.uns["diagvi_n_mixture_components"] == expected_n_labels
-
-
-def test_semi_supervised_auto_disabled_without_labels_key(adata_seq):
- """Test that semi_supervised is disabled when no labels_key is provided."""
- DIAGVI.setup_anndata(
- adata_seq,
- batch_key="batch",
- likelihood="nb",
- n_mixture_components=15,
- )
-
- assert adata_seq.uns["diagvi_semi_supervised"] is False
- # n_mixture_components should be the user-specified value
- assert adata_seq.uns["diagvi_n_mixture_components"] == 15
-
-
-def test_n_mixture_components_ignored_with_labels_key_warning(adata_seq_with_labels):
- """Test that warning is raised when n_mixture_components differs from label count."""
- expected_n_labels = adata_seq_with_labels.obs["cell_type"].nunique()
-
- with pytest.warns(UserWarning, match="n_mixture_components=99 is ignored"):
- DIAGVI.setup_anndata(
- adata_seq_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- n_mixture_components=99, # This should be ignored
- )
-
- # Should use label count, not user-specified value
- assert adata_seq_with_labels.uns["diagvi_n_mixture_components"] == expected_n_labels
-
-
-def test_n_mixture_components_no_warning_when_matching(adata_seq_with_labels):
- """Test that no warning when n_mixture_components matches label count."""
- expected_n_labels = adata_seq_with_labels.obs["cell_type"].nunique()
-
- # Should not warn if the values match
- import warnings as warn_module
-
- with warn_module.catch_warnings():
- warn_module.simplefilter("error") # Turn warnings into errors
- DIAGVI.setup_anndata(
- adata_seq_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- n_mixture_components=expected_n_labels,
- )
-
-
-def test_n_mixture_components_no_warning_when_default(adata_seq_with_labels):
- """Test that no warning when n_mixture_components is default value (10)."""
- import warnings as warn_module
-
- with warn_module.catch_warnings():
- warn_module.simplefilter("error") # Turn warnings into errors
- DIAGVI.setup_anndata(
- adata_seq_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- # n_mixture_components defaults to 10, should not warn
- )
-
-
-# =============================================================================
-# Tests for construct_custom_guidance_graph
-# =============================================================================
-
-
-@pytest.fixture(scope="module")
-def guidance_graph_adatas():
- """AnnData objects for testing construct_custom_guidance_graph."""
- n_vars_diss = 20
- n_vars_spatial = 15
- adata_diss = AnnData(
- X=np.random.poisson(1.0, size=(50, n_vars_diss)),
- obs={"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=50))},
- var=pd.DataFrame(index=[f"gene{i}" for i in range(n_vars_diss)]),
- )
- adata_spatial = AnnData(
- X=np.random.poisson(1.0, size=(40, n_vars_spatial)),
- obs={"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=40))},
- var=pd.DataFrame(index=[f"gene{i}" for i in range(n_vars_spatial)]),
- )
- return adata_diss, adata_spatial
-
-
-def test_construct_custom_guidance_graph_basic(guidance_graph_adatas):
- """Test basic construction of custom guidance graph."""
- adata_diss, adata_spatial = guidance_graph_adatas
- DIAGVI.setup_anndata(adata_diss, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
-
- # Create mapping between some genes
- mapping_df = pd.DataFrame(
- {
- "diss": ["gene0", "gene1", "gene2"],
- "spatial": ["gene0", "gene1", "gene2"],
- }
- )
-
- graph = DIAGVI.construct_custom_guidance_graph(
- input_dict={"diss": adata_diss, "spatial": adata_spatial},
- mapping_df=mapping_df,
- )
-
- # Check that graph is a torch_geometric Data object
- assert hasattr(graph, "edge_index")
- assert hasattr(graph, "edge_weight")
- assert hasattr(graph, "edge_sign")
- assert hasattr(graph, "x")
- assert hasattr(graph, "diss_indices")
- assert hasattr(graph, "spatial_indices")
-
- # Check node count: diss (20) + spatial (15) = 35
- assert graph.x.shape[0] == 20 + 15
-
-
-def test_construct_custom_guidance_graph_edge_count(guidance_graph_adatas):
- """Test that edge count is correct: bidirectional edges + self-loops."""
- adata_diss, adata_spatial = guidance_graph_adatas
- DIAGVI.setup_anndata(adata_diss, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
-
- # 3 mapped pairs
- mapping_df = pd.DataFrame(
- {
- "diss": ["gene0", "gene1", "gene2"],
- "spatial": ["gene0", "gene1", "gene2"],
- }
- )
-
- graph = DIAGVI.construct_custom_guidance_graph(
- input_dict={"diss": adata_diss, "spatial": adata_spatial},
- mapping_df=mapping_df,
- )
-
- n_nodes = 20 + 15
- n_mapped_pairs = 3
- # Bidirectional edges: 2 * n_mapped_pairs
- # Self-loops: n_nodes
- expected_edges = 2 * n_mapped_pairs + n_nodes
-
- assert graph.edge_index.shape[1] == expected_edges
-
-
-def test_construct_custom_guidance_graph_custom_weight_sign(guidance_graph_adatas):
- """Test custom weight and sign parameters."""
- adata_diss, adata_spatial = guidance_graph_adatas
- DIAGVI.setup_anndata(adata_diss, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
-
- mapping_df = pd.DataFrame(
- {
- "diss": ["gene0", "gene1"],
- "spatial": ["gene0", "gene1"],
- }
- )
-
- custom_weight = 2.5
- custom_sign = -1.0
-
- graph = DIAGVI.construct_custom_guidance_graph(
- input_dict={"diss": adata_diss, "spatial": adata_spatial},
- mapping_df=mapping_df,
- weight=custom_weight,
- sign=custom_sign,
- )
-
- # Check that cross-modality edges have custom weight and sign
- # Self-loops have weight=1.0 and sign=1.0
- assert custom_weight in graph.edge_weight.tolist()
- assert custom_sign in graph.edge_sign.tolist()
-
-
-def test_construct_custom_guidance_graph_missing_features(guidance_graph_adatas):
- """Test that missing features in mapping are handled gracefully."""
- adata_diss, adata_spatial = guidance_graph_adatas
- DIAGVI.setup_anndata(adata_diss, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
-
- # Include a gene that doesn't exist in the data
- mapping_df = pd.DataFrame(
- {
- "diss": ["gene0", "gene1", "nonexistent_gene"],
- "spatial": ["gene0", "gene1", "gene2"],
- }
- )
-
- graph = DIAGVI.construct_custom_guidance_graph(
- input_dict={"diss": adata_diss, "spatial": adata_spatial},
- mapping_df=mapping_df,
- )
-
- # Graph should still be created, missing features are skipped
- assert graph is not None
- assert hasattr(graph, "edge_index")
-
- # Should only have 2 valid mapped pairs (gene0 and gene1)
- n_nodes = 20 + 15
- n_valid_mapped_pairs = 2
- expected_edges = 2 * n_valid_mapped_pairs + n_nodes
- assert graph.edge_index.shape[1] == expected_edges
-
-
-def test_construct_custom_guidance_graph_feature_renaming(guidance_graph_adatas):
- """Test that features are renamed with modality suffix."""
- adata_diss, adata_spatial = guidance_graph_adatas
- DIAGVI.setup_anndata(adata_diss, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
-
- mapping_df = pd.DataFrame(
- {
- "diss": ["gene0"],
- "spatial": ["gene0"],
- }
- )
-
- graph = DIAGVI.construct_custom_guidance_graph(
- input_dict={"diss": adata_diss, "spatial": adata_spatial},
- mapping_df=mapping_df,
- )
-
- # Check that modality indices are assigned correctly
- # First 20 nodes (diss features) should have modality_index=0
- # Next 15 nodes (spatial features) should have modality_index=1
- assert len(graph.diss_indices) == 20
- assert len(graph.spatial_indices) == 15
- assert graph.diss_indices.min() == 0
- assert graph.diss_indices.max() == 19
- assert graph.spatial_indices.min() == 20
- assert graph.spatial_indices.max() == 34
-
-
-def test_construct_custom_guidance_graph_self_loops(guidance_graph_adatas):
- """Test that self-loops are created for all nodes."""
- adata_diss, adata_spatial = guidance_graph_adatas
- DIAGVI.setup_anndata(adata_diss, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
-
- # Empty mapping - only self-loops should exist
- mapping_df = pd.DataFrame(
- {
- "diss": [],
- "spatial": [],
- }
- )
-
- graph = DIAGVI.construct_custom_guidance_graph(
- input_dict={"diss": adata_diss, "spatial": adata_spatial},
- mapping_df=mapping_df,
- )
-
- n_nodes = 20 + 15
- # With no mapped pairs, only self-loops should exist
- assert graph.edge_index.shape[1] == n_nodes
-
- # Check that all edges are self-loops (src == dst)
- src, dst = graph.edge_index
- assert (src == dst).all()
-
-
-# =============================================================================
-# Tests for DiagTrainingPlan lam_class initialization and loss_annealing
-# =============================================================================
-
-
-class MockModule(torch.nn.Module):
- """Mock module for testing DiagTrainingPlan initialization."""
-
- def __init__(self, semi_supervised):
- super().__init__()
- self.semi_supervised = semi_supervised
- # TrainingPlan expects these attributes
- self.linear = torch.nn.Linear(1, 1)
-
- def loss(self, *args, **kwargs):
- """Mock loss method required by TrainingPlan."""
- pass
-
-
-@pytest.mark.parametrize("explicit_lam_class", [0.0, 50.0, 100.0, 200.0])
-def test_lam_class_initialization_explicit(explicit_lam_class):
- """Test that explicit lam_class values are respected."""
- from scvi.external.diagvi._task import DiagTrainingPlan
-
- # Even with semi_supervised=True, explicit value should be used
- mock_module = MockModule(semi_supervised=True)
- plan = DiagTrainingPlan(mock_module, lam_class=explicit_lam_class)
-
- assert plan.lam_class == explicit_lam_class, (
- f"Expected explicit lam_class={explicit_lam_class}, got {plan.lam_class}"
- )
-
-
-def test_diagvi_training_with_loss_annealing(adata_seq, adata_spatial):
- """Test training with loss_annealing=True to cover the annealing branch."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
- model = DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
-
- # Train with loss_annealing enabled
- model.train(
- max_epochs=2,
- batch_size=16,
- plan_kwargs={
- "loss_annealing": True,
- "lam_sinkhorn": 1.0,
- "sinkhorn_blur": 1.0,
- "sinkhorn_reach": 1.0,
- },
- )
-
- assert model.is_trained_ is True
-
-
-def test_anneal_param_function():
- """Test the _anneal_param helper function directly."""
- from scvi.external.diagvi._task import _anneal_param
-
- # Test at epoch 0 (start of training)
- result = _anneal_param(current_epoch=0, max_epochs=30, init_value=10.0, target_value=1.0)
- assert result == 10.0, f"Expected 10.0 at epoch 0, got {result}"
-
- # Test at epoch 5 (halfway through annealing period of 10 epochs)
- result = _anneal_param(current_epoch=5, max_epochs=30, init_value=10.0, target_value=1.0)
- assert result == 5.5, f"Expected 5.5 at epoch 5, got {result}"
-
- # Test at epoch 10 (end of annealing period)
- result = _anneal_param(current_epoch=10, max_epochs=30, init_value=10.0, target_value=1.0)
- assert result == 1.0, f"Expected 1.0 at epoch 10, got {result}"
-
- # Test at epoch 20 (after annealing period)
- result = _anneal_param(current_epoch=20, max_epochs=30, init_value=10.0, target_value=1.0)
- assert result == 1.0, f"Expected 1.0 at epoch 20, got {result}"
-
-
-# =============================================================================
-# Tests for different likelihoods
-# =============================================================================
-
-
-@pytest.fixture(scope="module")
-def adata_continuous():
- """AnnData with continuous (non-negative) data for testing continuous likelihoods."""
- # Use log1p-normal data (non-negative)
- X = np.abs(np.random.randn(N_OBS_SEQ, N_VARS)) + 0.1 # Ensure positive
- obs = {"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SEQ))}
- var = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- return AnnData(X=X.astype(np.float32), obs=obs, var=var)
-
-
-@pytest.fixture(scope="module")
-def adata_continuous_spatial():
- """AnnData with continuous (non-negative) data for spatial modality."""
- X = np.abs(np.random.randn(N_OBS_SPATIAL, N_VARS)) + 0.1
- obs = {"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=N_OBS_SPATIAL))}
- var = pd.DataFrame(index=[f"gene{i}" for i in range(N_VARS)])
- return AnnData(X=X.astype(np.float32), obs=obs, var=var)
-
-
-@pytest.mark.parametrize("likelihood", ["nb", "zinb"])
-def test_diagvi_count_likelihoods(adata_seq, adata_spatial, likelihood):
- """Test DIAGVI with different count-based likelihoods."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood=likelihood)
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood=likelihood)
-
- model = DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
- model.train(max_epochs=1, batch_size=16)
-
- assert model.is_trained_ is True
- assert model.module.modality_likelihoods["diss"] == likelihood
- assert model.module.modality_likelihoods["spatial"] == likelihood
-
- # Test latent representation
- latents = model.get_latent_representation()
- assert latents["diss"].shape[0] == adata_seq.shape[0]
-
-
-@pytest.mark.parametrize("likelihood", ["log1pnormal", "ziln", "zig"])
-def test_diagvi_continuous_likelihoods(adata_continuous, adata_continuous_spatial, likelihood):
- """Test DIAGVI with different continuous likelihoods."""
- DIAGVI.setup_anndata(adata_continuous, batch_key="batch", likelihood=likelihood)
- DIAGVI.setup_anndata(adata_continuous_spatial, batch_key="batch", likelihood=likelihood)
-
- model = DIAGVI({"diss": adata_continuous, "spatial": adata_continuous_spatial})
- model.train(max_epochs=1, batch_size=16)
-
- assert model.is_trained_ is True
- assert model.module.modality_likelihoods["diss"] == likelihood
-
- # Verify decoder normalization is disabled for continuous data
- assert model.module.decoder_0.normalize is False
- assert model.module.decoder_1.normalize is False
-
-
-def test_diagvi_normal_likelihood(adata_continuous, adata_continuous_spatial):
- """Test DIAGVI with normal likelihood (allows negative values)."""
- DIAGVI.setup_anndata(
- adata_continuous, batch_key="batch", likelihood="normal", normalize_lib=False
- )
- DIAGVI.setup_anndata(
- adata_continuous_spatial, batch_key="batch", likelihood="normal", normalize_lib=False
- )
-
- model = DIAGVI({"diss": adata_continuous, "spatial": adata_continuous_spatial})
- model.train(max_epochs=1, batch_size=16)
-
- assert model.is_trained_ is True
-
-
-def test_diagvi_nbmixture_likelihood(adata_seq, adata_spatial):
- """Test DIAGVI with nbmixture likelihood (protein-like data)."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nbmixture")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nbmixture")
-
- model = DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
-
- # Check dual-pathway decoder is used
- from scvi.external.diagvi._base_components import DecoderDualPathway
-
- assert isinstance(model.module.decoder_0, DecoderDualPathway)
- assert isinstance(model.module.decoder_1, DecoderDualPathway)
-
- model.train(max_epochs=1, batch_size=16)
- assert model.is_trained_ is True
-
-
-def test_diagvi_mixed_likelihoods(adata_seq, adata_continuous_spatial):
- """Test DIAGVI with different likelihoods per modality."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_continuous_spatial, batch_key="batch", likelihood="log1pnormal")
-
- model = DIAGVI({"diss": adata_seq, "spatial": adata_continuous_spatial})
-
- # Check different decoder configurations
- assert model.module.decoder_0.normalize is True # nb uses softmax
- assert model.module.decoder_1.normalize is False # log1pnormal does not
-
- model.train(max_epochs=1, batch_size=16)
- assert model.is_trained_ is True
-
-
-# =============================================================================
-# Tests for posterior_predictive_sample
-# =============================================================================
-
-
-def test_posterior_predictive_sample_single(trained_model):
- """Test posterior_predictive_sample with n_samples=1."""
- model, adata_seq, adata_spatial = trained_model
-
- samples = model.posterior_predictive_sample(n_samples=1)
-
- assert "diss" in samples
- assert "spatial" in samples
- assert samples["diss"].shape == (N_OBS_SEQ, N_VARS)
- assert samples["spatial"].shape == (N_OBS_SPATIAL, N_VARS)
-
-
-def test_posterior_predictive_sample_multiple(trained_model):
- """Test posterior_predictive_sample with n_samples>1."""
- model, adata_seq, adata_spatial = trained_model
- n_samples = 5
-
- samples = model.posterior_predictive_sample(n_samples=n_samples)
-
- assert samples["diss"].shape == (N_OBS_SEQ, N_VARS, n_samples)
- assert samples["spatial"].shape == (N_OBS_SPATIAL, N_VARS, n_samples)
-
-
-def test_posterior_predictive_sample_with_indices(trained_model):
- """Test posterior_predictive_sample with subset of indices."""
- model, adata_seq, adata_spatial = trained_model
-
- indices = {
- "diss": list(range(10)),
- "spatial": list(range(20)),
- }
-
- samples = model.posterior_predictive_sample(indices=indices, n_samples=1)
-
- assert samples["diss"].shape == (10, N_VARS)
- assert samples["spatial"].shape == (20, N_VARS)
-
-
-def test_posterior_predictive_sample_invalid_modality(trained_model):
- """Test that invalid modality names raise ValueError."""
- model, adata_seq, adata_spatial = trained_model
-
- # Create fake adatas dict with invalid key
- invalid_adatas = {"invalid_modality": adata_seq}
-
- with pytest.raises(ValueError, match="Invalid modality names"):
- model.posterior_predictive_sample(adatas=invalid_adatas)
-
-
-# =============================================================================
-# Tests for adaptive Sinkhorn parameters
-# =============================================================================
-
-
-def test_diagvi_adaptive_sinkhorn(adata_seq, adata_spatial):
- """Test training with adaptive Sinkhorn parameters (blur=None, reach=None)."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
- model = DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
-
- # Train with adaptive parameters (default: blur=None, reach=None)
- model.train(
- max_epochs=2,
- batch_size=16,
- plan_kwargs={
- "sinkhorn_blur": None,
- "sinkhorn_reach": None,
- "epsilon_from_cost": "mean",
- "epsilon_scale": 0.5,
- },
- )
-
- assert model.is_trained_ is True
-
-
-@pytest.mark.parametrize("epsilon_from_cost", ["mean", "std"])
-def test_diagvi_adaptive_epsilon_methods(adata_seq, adata_spatial, epsilon_from_cost):
- """Test different methods for computing adaptive epsilon."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
- model = DIAGVI({"diss": adata_seq, "spatial": adata_spatial})
-
- model.train(
- max_epochs=1,
- batch_size=16,
- plan_kwargs={
- "sinkhorn_blur": None,
- "epsilon_from_cost": epsilon_from_cost,
- },
- )
-
- assert model.is_trained_ is True
-
-
-# =============================================================================
-# Tests for deterministic inference
-# =============================================================================
-
-
-def test_get_imputed_values_deterministic(trained_model):
- """Test get_imputed_values with deterministic=True vs False."""
- model, adata_seq, adata_spatial = trained_model
-
- # Deterministic should give same result on repeated calls
- imputed_det1 = model.get_imputed_values(query_name="spatial", deterministic=True)
- imputed_det2 = model.get_imputed_values(query_name="spatial", deterministic=True)
-
- np.testing.assert_array_equal(imputed_det1, imputed_det2)
-
-
-def test_get_latent_deterministic_mode(trained_model):
- """Test inference with deterministic mode."""
- model, adata_seq, adata_spatial = trained_model
-
- device = next(model.module.parameters()).device
- x = torch.tensor(adata_seq.X, dtype=torch.float32, device=device)
-
- # Deterministic inference should use mean instead of sampling
- outputs_det = model.module.inference(x, mode="diss", deterministic=True)
- outputs_det2 = model.module.inference(x, mode="diss", deterministic=True)
-
- # Results should be identical
- torch.testing.assert_close(outputs_det["z"], outputs_det2["z"])
-
-
-# =============================================================================
-# Tests for semi-supervised with both modalities
-# =============================================================================
-
-
-def test_semi_supervised_both_modalities(adata_seq_with_labels, adata_spatial_with_labels):
- """Test semi-supervised learning enabled for both modalities."""
- DIAGVI.setup_anndata(
- adata_seq_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- gmm_prior=True,
- )
- DIAGVI.setup_anndata(
- adata_spatial_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- gmm_prior=True,
- )
-
- model = DIAGVI({"diss": adata_seq_with_labels, "spatial": adata_spatial_with_labels})
-
- # Both modalities should have classifiers
- assert model.module.classifier_0 is not None
- assert model.module.classifier_1 is not None
-
- # Both should use GMM prior
- assert model.module.use_gmm_prior["diss"] is True
- assert model.module.use_gmm_prior["spatial"] is True
-
- model.train(max_epochs=1, batch_size=16)
- assert model.is_trained_ is True
-
-
-# =============================================================================
-# Tests for pre-provided guidance graph
-# =============================================================================
-
-
-def test_diagvi_with_precomputed_guidance_graph(adata_seq, adata_spatial):
- """Test DIAGVI initialization with a pre-computed guidance graph."""
- DIAGVI.setup_anndata(adata_seq, batch_key="batch", likelihood="nb")
- DIAGVI.setup_anndata(adata_spatial, batch_key="batch", likelihood="nb")
-
- # Create a custom guidance graph
- mapping_df = pd.DataFrame(
- {
- "diss": ["gene0", "gene1", "gene2"],
- "spatial": ["gene0", "gene1", "gene2"],
- }
- )
- custom_graph = DIAGVI.construct_custom_guidance_graph(
- input_dict={"diss": adata_seq, "spatial": adata_spatial},
- mapping_df=mapping_df,
- )
-
- # Initialize model with pre-computed graph
- model = DIAGVI(
- {"diss": adata_seq, "spatial": adata_spatial},
- guidance_graph=custom_graph,
- )
-
- assert model.guidance_graph is custom_graph
- model.train(max_epochs=1, batch_size=16)
- assert model.is_trained_ is True
-
-
-# =============================================================================
-# Tests for error cases
-# =============================================================================
-
-
-def test_get_imputed_values_invalid_query_name(trained_model):
- """Test that invalid query_name raises ValueError."""
- model, adata_seq, adata_spatial = trained_model
-
- with pytest.raises(ValueError, match="must be one of"):
- model.get_imputed_values(query_name="invalid_modality")
-
-
-def test_setup_anndata_negative_data_continuous_likelihood():
- """Test that negative data with non-normal continuous likelihood raises error."""
- # Data with negative values
- X = np.random.randn(50, 20) # Contains negatives
- adata = AnnData(
- X=X.astype(np.float32),
- obs={"batch": pd.Categorical(np.random.choice(["batch1", "batch2"], size=50))},
- )
-
- with pytest.raises(ValueError, match="requires non-negative data"):
- DIAGVI.setup_anndata(adata, batch_key="batch", likelihood="log1pnormal")
-
-
-def test_guidance_graph_consistency_check_fails():
- """Test that inconsistent guidance graph raises ValueError."""
- from torch_geometric.data import Data
-
- from scvi.external.diagvi._utils import _check_guidance_graph_consistency
-
- # Create adatas
- adata1 = AnnData(X=np.random.poisson(1.0, size=(50, 20)))
- adata2 = AnnData(X=np.random.poisson(1.0, size=(40, 15)))
-
- # Create graph with wrong number of nodes
- wrong_graph = Data(
- x=torch.eye(10), # Wrong: should be 20+15=35
- edge_index=torch.tensor([[0, 1], [1, 0]]).T,
- edge_weight=torch.ones(2),
- edge_sign=torch.ones(2),
- )
-
- with pytest.raises(ValueError, match="node count"):
- _check_guidance_graph_consistency(wrong_graph, {"mod1": adata1, "mod2": adata2})
-
-
-def test_guidance_graph_missing_self_loops():
- """Test that graph without self-loops raises ValueError."""
- from torch_geometric.data import Data
-
- from scvi.external.diagvi._utils import _check_guidance_graph_consistency
-
- adata1 = AnnData(X=np.random.poisson(1.0, size=(50, 5)))
- adata2 = AnnData(X=np.random.poisson(1.0, size=(40, 5)))
-
- # Graph with no self-loops
- no_selfloop_graph = Data(
- x=torch.eye(10),
- edge_index=torch.tensor([[0, 1], [1, 0]]).T, # No self-loops
- edge_weight=torch.ones(2),
- edge_sign=torch.ones(2),
- )
-
- with pytest.raises(ValueError, match="self-loops"):
- _check_guidance_graph_consistency(no_selfloop_graph, {"mod1": adata1, "mod2": adata2})
-
-
-# =============================================================================
-# Tests for base components
-# =============================================================================
-
-
-def test_decoder_single_pathway_outputs():
- """Test DecoderSinglePathway output shapes."""
- from scvi.external.diagvi._base_components import DecoderSinglePathway
-
- n_output = 50
- n_batches = 2
- n_latent = 10
- batch_size = 32
-
- decoder = DecoderSinglePathway(n_output=n_output, n_batches=n_batches, normalize=True)
-
- u = torch.randn(batch_size, n_latent)
- l = torch.randn(batch_size, 1)
- batch_index = torch.randint(0, n_batches, (batch_size,))
- v = torch.randn(n_output, n_latent)
-
- px_scale, px_r, px_rate, px_dropout = decoder(u, l, batch_index, v)
-
- assert px_scale.shape == (batch_size, n_output)
- assert px_r.shape == (batch_size, n_output)
- assert px_rate.shape == (batch_size, n_output)
- assert px_dropout.shape == (n_output,)
-
- # Check softmax normalization (sums to 1)
- torch.testing.assert_close(px_scale.sum(dim=1), torch.ones(batch_size), atol=1e-5, rtol=1e-5)
-
-
-def test_decoder_dual_pathway_outputs():
- """Test DecoderDualPathway output shapes."""
- from scvi.external.diagvi._base_components import DecoderDualPathway
-
- n_output = 50
- n_batches = 2
- n_latent = 10
- batch_size = 32
-
- decoder = DecoderDualPathway(n_output=n_output, n_batches=n_batches, normalize=True)
-
- u = torch.randn(batch_size, n_latent)
- l = torch.randn(batch_size, 1)
- batch_index = torch.randint(0, n_batches, (batch_size,))
- v = torch.randn(n_output, n_latent)
-
- scales, px_r, rates, mixture_logits = decoder(u, l, batch_index, v)
-
- # Dual pathway returns tuples
- assert isinstance(scales, tuple)
- assert len(scales) == 2
- assert isinstance(rates, tuple)
- assert len(rates) == 2
-
- assert scales[0].shape == (batch_size, n_output)
- assert scales[1].shape == (batch_size, n_output)
- assert rates[0].shape == (batch_size, n_output)
- assert rates[1].shape == (batch_size, n_output)
- assert px_r.shape == (batch_size, n_output)
- assert mixture_logits.shape == (batch_size, n_output)
-
-
-def test_graph_encoder_outputs():
- """Test GraphEncoder output shapes."""
- from scvi.external.diagvi._base_components import GraphEncoder
-
- n_nodes = 100
- n_latent = 50
-
- encoder = GraphEncoder(vnum=n_nodes, out_features=n_latent)
-
- # Create simple edge index with self-loops
- edge_index = torch.stack([torch.arange(n_nodes), torch.arange(n_nodes)])
-
- z, mu, logvar = encoder(edge_index)
-
- assert z.shape == (n_nodes, n_latent)
- assert mu.shape == (n_nodes, n_latent)
- assert logvar.shape == (n_nodes, n_latent)
-
-
-def test_decoder_batch_index_out_of_bounds():
- """Test that out-of-bounds batch index raises IndexError."""
- from scvi.external.diagvi._base_components import DecoderSinglePathway
-
- decoder = DecoderSinglePathway(n_output=50, n_batches=2, normalize=True)
-
- u = torch.randn(10, 10)
- l = torch.randn(10, 1)
- v = torch.randn(50, 10)
- invalid_batch = torch.tensor([0, 1, 2, 3, 0, 1, 2, 3, 0, 1]) # 2 and 3 are out of bounds
-
- with pytest.raises(IndexError, match="out of bounds"):
- decoder(u, l, invalid_batch, v)
-
-
-# =============================================================================
-# Tests for utility functions
-# =============================================================================
-
-
-def test_construct_guidance_graph_auto():
- """Test automatic guidance graph construction with shared features."""
- from scvi.external.diagvi._utils import _construct_guidance_graph
-
- # Create adatas with overlapping features
- shared_genes = [f"shared_gene{i}" for i in range(10)]
- unique_genes1 = [f"unique1_gene{i}" for i in range(5)]
- unique_genes2 = [f"unique2_gene{i}" for i in range(5)]
-
- adata1 = AnnData(
- X=np.random.poisson(1.0, size=(50, 15)),
- var=pd.DataFrame(index=shared_genes + unique_genes1),
- )
- adata2 = AnnData(
- X=np.random.poisson(1.0, size=(40, 15)),
- var=pd.DataFrame(index=shared_genes + unique_genes2),
- )
-
- graph = _construct_guidance_graph({"mod1": adata1, "mod2": adata2}, mapping_df=None)
-
- # Should have 15+15=30 nodes
- assert graph.num_nodes == 30
-
- # Edges: 10 shared * 2 (bidirectional) + 30 self-loops = 50
- assert graph.edge_index.shape[1] == 50
-
-
-def test_construct_guidance_graph_no_overlap_error():
- """Test that no overlapping features raises ValueError."""
- from scvi.external.diagvi._utils import _construct_guidance_graph
-
- adata1 = AnnData(
- X=np.random.poisson(1.0, size=(50, 5)),
- var=pd.DataFrame(index=[f"gene1_{i}" for i in range(5)]),
- )
- adata2 = AnnData(
- X=np.random.poisson(1.0, size=(40, 5)),
- var=pd.DataFrame(index=[f"gene2_{i}" for i in range(5)]), # No overlap
- )
-
- with pytest.raises(ValueError, match="No overlapping features"):
- _construct_guidance_graph({"mod1": adata1, "mod2": adata2}, mapping_df=None)
-
-
-def test_kl_divergence_graph():
- """Test KL divergence computation for graph latent variables."""
- from scvi.external.diagvi._utils import kl_divergence_graph
-
- n_nodes = 100
- n_latent = 50
-
- # Standard normal: mu=0, logvar=0 -> KL should be 0
- mu_zero = torch.zeros(n_nodes, n_latent)
- logvar_zero = torch.zeros(n_nodes, n_latent)
-
- kl_zero = kl_divergence_graph(mu_zero, logvar_zero)
- assert kl_zero.item() < 1e-5, f"KL for standard normal should be ~0, got {kl_zero.item()}"
-
- # Non-zero mu and logvar should give positive KL
- mu_nonzero = torch.randn(n_nodes, n_latent)
- logvar_nonzero = torch.randn(n_nodes, n_latent)
-
- kl_nonzero = kl_divergence_graph(mu_nonzero, logvar_nonzero)
- assert kl_nonzero.item() > 0, "KL should be positive for non-standard distribution"
-
-
-# =============================================================================
-# Tests for predict_celltype
-# =============================================================================
-
-
-@pytest.fixture(scope="module")
-def trained_semi_supervised_model(adata_seq_with_labels, adata_spatial_with_labels):
- """Module-scoped trained model with classifier for predict_celltype tests."""
- DIAGVI.setup_anndata(
- adata_seq_with_labels,
- batch_key="batch",
- labels_key="cell_type",
- likelihood="nb",
- gmm_prior=True,
- )
- DIAGVI.setup_anndata(
- adata_spatial_with_labels,
- batch_key="batch",
- likelihood="nb",
- gmm_prior=False,
- )
- model = DIAGVI({"diss": adata_seq_with_labels, "spatial": adata_spatial_with_labels})
- model.train(max_epochs=2, batch_size=16)
- return model, adata_seq_with_labels, adata_spatial_with_labels
-
-
-def test_predict_celltype_cross_modal_default(trained_semi_supervised_model):
- """Test predict_celltype defaults to transferring to the other modality."""
- model, adata_seq, adata_spatial = trained_semi_supervised_model
-
- # When source_modality="diss", target should default to "spatial"
- results = model.predict_celltype(labeled_modality="diss")
-
- assert "predictions" in results
- assert "probabilities" in results
- assert "confidence" in results
-
- # Should have predictions for spatial modality (N_OBS_SPATIAL cells)
- assert len(results["predictions"]) == N_OBS_SPATIAL
- assert results["probabilities"].shape[0] == N_OBS_SPATIAL
- # N_LABELS + 1 because LabelsWithUnlabeledObsField adds "unknown" category
- assert results["probabilities"].shape[1] == N_LABELS + 1
- assert len(results["confidence"]) == N_OBS_SPATIAL
-
-
-def test_predict_celltype_explicit_target(trained_semi_supervised_model):
- """Test predict_celltype with explicitly specified target_modality."""
- model, adata_seq, adata_spatial = trained_semi_supervised_model
-
- # Explicitly specify target as same modality (self-prediction)
- results = model.predict_celltype(labeled_modality="diss", target_modality="diss")
-
- assert len(results["predictions"]) == N_OBS_SEQ
- assert results["probabilities"].shape[0] == N_OBS_SEQ
-
-
-def test_predict_celltype_with_indices(trained_semi_supervised_model):
- """Test predict_celltype with subset of indices."""
- model, adata_seq, adata_spatial = trained_semi_supervised_model
-
- indices = list(range(20))
- results = model.predict_celltype(labeled_modality="diss", indices=indices)
-
- assert len(results["predictions"]) == 20
- assert results["probabilities"].shape[0] == 20
- assert len(results["confidence"]) == 20
-
-
-def test_predict_celltype_predictions_are_valid_labels(trained_semi_supervised_model):
- """Test that predictions are valid label categories."""
- model, adata_seq, adata_spatial = trained_semi_supervised_model
-
- results = model.predict_celltype(labeled_modality="diss")
-
- # All predictions should be valid cell type labels
- valid_labels = set(adata_seq.obs["cell_type"].cat.categories)
- for pred in results["predictions"]:
- assert pred in valid_labels
-
-
-def test_predict_celltype_confidence_bounds(trained_semi_supervised_model):
- """Test that confidence values are valid probabilities."""
- model, adata_seq, adata_spatial = trained_semi_supervised_model
-
- results = model.predict_celltype(labeled_modality="diss")
-
- # Confidence should be between 0 and 1
- assert np.all(results["confidence"] >= 0)
- assert np.all(results["confidence"] <= 1)
-
- # Probabilities should sum to 1
- prob_sums = results["probabilities"].sum(axis=1)
- np.testing.assert_array_almost_equal(prob_sums, np.ones(N_OBS_SPATIAL), decimal=5)
-
-
-def test_predict_celltype_invalid_source_modality(trained_semi_supervised_model):
- """Test that invalid labeled_modality raises ValueError."""
- model, adata_seq, adata_spatial = trained_semi_supervised_model
-
- with pytest.raises(ValueError, match="must be one of"):
- model.predict_celltype(labeled_modality="invalid_modality")
-
-
-def test_predict_celltype_invalid_target_modality(trained_semi_supervised_model):
- """Test that invalid target_modality raises ValueError."""
- model, adata_seq, adata_spatial = trained_semi_supervised_model
-
- with pytest.raises(ValueError, match="Invalid target_modality"):
- model.predict_celltype(labeled_modality="diss", target_modality="invalid_modality")
-
-
-def test_predict_celltype_no_classifier_error(trained_model):
- """Test that predict_celltype raises error when no classifier was trained."""
- model, adata_seq, adata_spatial = trained_model
-
- # This model was trained without labels_key, so no classifier exists
- with pytest.raises(ValueError, match="No classifier was trained"):
- model.predict_celltype(labeled_modality="diss")
diff --git a/tests/external/gimvi/test_gimvi.py b/tests/external/gimvi/test_gimvi.py
deleted file mode 100644
index 36dd6a800b..0000000000
--- a/tests/external/gimvi/test_gimvi.py
+++ /dev/null
@@ -1,232 +0,0 @@
-import os
-import pickle
-from itertools import islice
-
-import numpy as np
-import pytest
-import torch
-from torch.utils.data import DataLoader, TensorDataset
-
-import scvi
-from scvi.data import synthetic_iid
-from scvi.external import GIMVI
-from scvi.external.gimvi._task import CyclicMultiDataLoader
-
-
-def test_saving_and_loading(save_path):
- def legacy_save(
- model,
- dir_path,
- prefix=None,
- overwrite=False,
- save_anndata=False,
- **anndata_write_kwargs,
- ):
- # get all the user attributes
- user_attributes = model._get_user_attributes()
- # only save the public attributes with _ at the very end
- user_attributes = {a[0]: a[1] for a in user_attributes if a[0][-1] == "_"}
- # save the model state dict and the trainer state dict only
- if not os.path.exists(dir_path) or overwrite:
- os.makedirs(dir_path, exist_ok=overwrite)
- else:
- raise ValueError(
- f"{dir_path} already exists. Please provide an unexisting directory for saving."
- )
-
- file_name_prefix = prefix or ""
-
- if save_anndata:
- dataset_names = ["seq", "spatial"]
- for i in range(len(model.adatas)):
- dataset_name = dataset_names[i]
- save_path = os.path.join(dir_path, f"{file_name_prefix}adata_{dataset_name}.h5ad")
- model.adatas[i].write(save_path)
- varnames_save_path = os.path.join(
- dir_path, f"{file_name_prefix}var_names_{dataset_name}.csv"
- )
-
- var_names = model.adatas[i].var_names.astype(str)
- var_names = var_names.to_numpy()
- np.savetxt(varnames_save_path, var_names, fmt="%s")
-
- model_save_path = os.path.join(dir_path, f"{file_name_prefix}model_params.pt")
- attr_save_path = os.path.join(dir_path, f"{file_name_prefix}attr.pkl")
-
- torch.save(model.module.state_dict(), model_save_path)
- with open(attr_save_path, "wb") as f:
- pickle.dump(user_attributes, f)
-
- prefix = "GIMVI_"
- adata = synthetic_iid()
- GIMVI.setup_anndata(
- adata,
- batch_key="batch",
- )
- adata2 = synthetic_iid()
- GIMVI.setup_anndata(
- adata2,
- batch_key="batch",
- )
-
- # GIMVI
- model = GIMVI(adata, adata2)
- model.train(3, train_size=0.5)
- z1 = model.get_latent_representation([adata])
- z2 = model.get_latent_representation([adata])
- np.testing.assert_array_equal(z1, z2)
- model.save(save_path, overwrite=True, save_anndata=True, prefix=prefix)
- model = GIMVI.load(save_path, prefix=prefix)
- model.get_latent_representation()
- tmp_adata = scvi.data.synthetic_iid(n_genes=200)
- tmp_adata2 = scvi.data.synthetic_iid(n_genes=200)
- with pytest.raises(ValueError):
- GIMVI.load(save_path, adata_seq=tmp_adata, adata_spatial=tmp_adata2, prefix=prefix)
- model = GIMVI.load(save_path, adata_seq=adata, adata_spatial=adata2, prefix=prefix)
- z2 = model.get_latent_representation([adata])
- np.testing.assert_array_equal(z1, z2)
- model = GIMVI.load(
- save_path,
- adata_seq=adata,
- adata_spatial=adata2,
- prefix=prefix,
- )
- z2 = model.get_latent_representation([adata])
- np.testing.assert_almost_equal(z1, z2, decimal=3)
- assert model.is_trained is True
-
- # Test legacy loading
- legacy_save_path = os.path.join(save_path, "legacy/")
- legacy_save(model, legacy_save_path, overwrite=True, save_anndata=True, prefix=prefix)
- with pytest.raises(ValueError):
- GIMVI.load(legacy_save_path, adata_seq=adata, adata_spatial=adata2, prefix=prefix)
- GIMVI.convert_legacy_save(
- legacy_save_path,
- legacy_save_path,
- overwrite=True,
- prefix=prefix,
- )
- m = GIMVI.load(legacy_save_path, adata_seq=adata, adata_spatial=adata2, prefix=prefix)
- m.train(1)
-
-
-def test_gimvi():
- adata_seq = synthetic_iid()
- adata_spatial = synthetic_iid()
- GIMVI.setup_anndata(
- adata_seq,
- batch_key="batch",
- labels_key="labels",
- )
- GIMVI.setup_anndata(
- adata_spatial,
- batch_key="batch",
- labels_key="labels",
- )
- model = GIMVI(adata_seq, adata_spatial, n_latent=10)
- assert hasattr(model.module, "library_log_means_0")
- assert not hasattr(model.module, "library_log_means_1")
- model.train(1, check_val_every_n_epoch=1, train_size=0.5)
- model.get_latent_representation()
- model.get_imputed_values()
- model.get_imputed_values(normalized=False)
- model.get_normalized_expression(adata=adata_seq)
- model.get_normalized_expression(adata=adata_spatial)
- model.get_normalized_expression(adata=adata_seq, transform_batch="batch_1")
- model.get_normalized_expression(adata=adata_spatial, transform_batch="batch_1")
- # model.get_normalized_expression(adata=adata_seq, n_samples=2)
- # model.get_normalized_expression(adata=adata_spatial, n_samples=2)
-
- adata_spatial.var_names += "asdf"
- GIMVI.setup_anndata(
- adata_spatial,
- batch_key="batch",
- labels_key="labels",
- )
- with pytest.raises(ValueError):
- model = GIMVI(adata_seq, adata_spatial)
-
-
-def test_gimvi_model_library_size():
- adata_seq = synthetic_iid()
- adata_spatial = synthetic_iid()
- GIMVI.setup_anndata(
- adata_seq,
- batch_key="batch",
- labels_key="labels",
- )
- GIMVI.setup_anndata(
- adata_spatial,
- batch_key="batch",
- labels_key="labels",
- )
- model = GIMVI(adata_seq, adata_spatial, model_library_size=[True, True], n_latent=10)
- assert hasattr(model.module, "library_log_means_0")
- assert hasattr(model.module, "library_log_means_1")
- model.train(1, check_val_every_n_epoch=1, train_size=0.5)
- model.get_latent_representation()
- model.get_imputed_values()
-
-
-def test_gimvi_reinit():
- # see #2446
- adata_seq = synthetic_iid()
- adata_spatial = synthetic_iid()
- GIMVI.setup_anndata(
- adata_seq,
- batch_key="batch",
- labels_key="labels",
- )
- GIMVI.setup_anndata(
- adata_spatial,
- batch_key="batch",
- labels_key="labels",
- )
- model = GIMVI(adata_seq, adata_spatial)
- model.train(max_epochs=1)
- model = GIMVI(adata_seq, adata_spatial)
- model.train(max_epochs=1)
-
-
-def _loader(values: list[int]) -> DataLoader:
- loader = DataLoader(TensorDataset(torch.tensor(values)), batch_size=1, shuffle=False)
- loader.indices = list(range(len(values)))
- return loader
-
-
-def _batch_value(batch) -> int:
- return batch[0].item()
-
-
-def test_cyclic_multi_data_loader_sequence_cycles_shorter_loaders():
- short_loader = _loader([1, 2])
- long_loader = _loader([10, 11, 12])
-
- loader = CyclicMultiDataLoader([short_loader, long_loader])
-
- assert len(loader) == 3
- batches = list(islice(loader, 3))
- assert [(_batch_value(left), _batch_value(right)) for left, right in batches] == [
- (1, 10),
- (2, 11),
- (1, 12),
- ]
-
-
-def test_cyclic_multi_data_loader_mapping_preserves_modality_names():
- loader = CyclicMultiDataLoader(
- {
- "rna": _loader([1]),
- "spatial": _loader([10, 11]),
- }
- )
-
- assert len(loader) == 2
- batches = list(islice(loader, 2))
- assert [
- {name: _batch_value(batch) for name, batch in modality_batches.items()}
- for modality_batches in batches
- ] == [
- {"rna": 1, "spatial": 10},
- {"rna": 1, "spatial": 11},
- ]
diff --git a/tests/external/resolvi/test_resolvi.py b/tests/external/resolvi/test_resolvi.py
deleted file mode 100644
index 6064339b9f..0000000000
--- a/tests/external/resolvi/test_resolvi.py
+++ /dev/null
@@ -1,225 +0,0 @@
-import numpy as np
-import pytest
-
-from scvi.data import synthetic_iid
-from scvi.external import RESOLVI
-
-
-@pytest.fixture(scope="session")
-def adata():
- adata = synthetic_iid(
- generate_coordinates=True,
- n_regions=5,
- n_proteins=10,
- )
- adata.obsm["X_spatial"] = adata.obsm["coordinates"]
- adata.obs["cell_area"] = np.random.gamma(2.0, 1.0, size=adata.n_obs)
- print(adata)
- return adata
-
-
-def test_resolvi_train(adata):
- RESOLVI.setup_anndata(adata)
- model = RESOLVI(adata)
- model.train(
- max_epochs=2,
- )
- model = RESOLVI(adata, dispersion="gene-batch")
- model.train(
- max_epochs=2,
- )
-
-
-def test_resolvi_train_validation_unsupported(adata):
- # RESOLVI trains with Pyro SVI and per-cell global parameters, so it does not support a
- # validation set. `train_size != 1.0` previously raised a cryptic TypeError (collision with
- # the hardcoded `train_size=1.0`), and `early_stopping` has no validation set to monitor.
- # Both must now raise a clear ValueError, while the supported settings still train.
- RESOLVI.setup_anndata(adata)
- model = RESOLVI(adata)
- with pytest.raises(ValueError, match="train_size"):
- model.train(max_epochs=2, train_size=0.8)
- with pytest.raises(ValueError, match="early_stopping"):
- model.train(max_epochs=2, early_stopping=True)
- # explicit train_size=1.0 must not collide, and the default path still works
- model.train(max_epochs=2, train_size=1.0)
-
-
-def test_resolvi_train_size_factor(adata):
- RESOLVI.setup_anndata(adata, batch_key="batch", size_factor_key="cell_area")
- model = RESOLVI(adata, size_scaling=True)
- model.train(
- max_epochs=2,
- )
- RESOLVI.setup_anndata(adata, size_factor_key="cell_area")
- model = RESOLVI(adata, size_scaling=False)
- model.train(
- max_epochs=2,
- )
-
-
-@pytest.mark.optional
-def test_resolvi_save_load(adata):
- RESOLVI.setup_anndata(adata)
- model = RESOLVI(adata)
- model.train(
- max_epochs=2,
- )
- hist_elbo = model.history_["elbo_train"]
- latent = model.get_latent_representation()
- assert latent.shape == (adata.n_obs, model.module.n_latent)
- model.differential_expression(groupby="labels")
- model.differential_expression(groupby="labels", weights="importance")
- model.save("test_resolvi", save_anndata=True, overwrite=True)
- model2 = model.load("test_resolvi")
- np.testing.assert_array_equal(model2.history_["elbo_train"], hist_elbo)
- latent2 = model2.get_latent_representation()
- assert np.allclose(latent, latent2)
- model.load_query_data(reference_model="test_resolvi", adata=adata)
-
-
-@pytest.mark.optional
-def test_resolvi_downstream(adata):
- RESOLVI.setup_anndata(adata, size_factor_key="cell_area")
- model = RESOLVI(adata)
- model.train(
- max_epochs=2,
- )
- latent = model.get_latent_representation()
- assert latent.shape == (adata.n_obs, model.module.n_latent)
- _ = model.get_normalized_expression(n_samples=31, library_size=10000)
- _ = model.get_normalized_expression_importance(n_samples=30, library_size=10000)
- _ = model.get_normalized_expression_importance(n_samples=30, size_scaling=True)
- model.differential_expression(groupby="labels")
- model.differential_expression(groupby="labels", weights="importance")
- model.differential_expression(groupby="labels", weights="importance", size_scaling=True)
- model.sample_posterior(
- model=model.module.model_residuals,
- num_samples=30,
- return_samples=False,
- return_sites=None,
- batch_size=1000,
- )
- model.sample_posterior(
- model=model.module.model_residuals, num_samples=30, return_samples=False, batch_size=1000
- )
- model_query = model.load_query_data(reference_model=model, adata=adata)
- model_query = model.load_query_data(reference_model="test_resolvi", adata=adata)
- model_query.train(
- max_epochs=2,
- )
-
-
-def test_resolvi_normalized_expression_gene_list(adata):
- RESOLVI.setup_anndata(adata, size_factor_key="cell_area")
- model = RESOLVI(adata)
- model.train(
- max_epochs=2,
- )
- gene_list = adata.var_names[:3].tolist()
-
- # both functions must honor `gene_list` and return only the requested subset
- expr = model.get_normalized_expression(n_samples=2, gene_list=gene_list)
- assert list(expr.columns) == gene_list
- assert expr.shape == (adata.n_obs, len(gene_list))
-
- expr_imp = model.get_normalized_expression_importance(n_samples=30, gene_list=gene_list)
- assert list(expr_imp.columns) == gene_list
- assert expr_imp.shape == (adata.n_obs, len(gene_list))
-
- # `transform_batch` is not supported by the importance estimator: it must be ignored
- # (not error) and warn the user, so that `differential_expression(weights="importance")`
- # can still call it.
- with pytest.warns(UserWarning, match="transform_batch.*ignored"):
- model.get_normalized_expression_importance(n_samples=30, transform_batch=0)
-
-
-def test_resolvi_downstream_size_scaling(adata):
- RESOLVI.setup_anndata(adata, size_factor_key="cell_area")
- model = RESOLVI(adata, size_scaling=True)
- model.train(
- max_epochs=2,
- )
- latent = model.get_latent_representation()
- assert latent.shape == (adata.n_obs, model.module.n_latent)
- _ = model.get_normalized_expression(n_samples=31, library_size=10000)
- _ = model.get_normalized_expression_importance(n_samples=30, library_size=10000)
- _ = model.get_normalized_expression_importance(n_samples=30, size_scaling=True)
- model.differential_expression(groupby="labels")
- model.differential_expression(groupby="labels", weights="importance")
- model.differential_expression(groupby="labels", weights="importance", size_scaling=True)
- model.sample_posterior(
- model=model.module.model_residuals,
- num_samples=30,
- return_samples=False,
- return_sites=None,
- batch_size=1000,
- )
- model.sample_posterior(
- model=model.module.model_residuals, num_samples=30, return_samples=False, batch_size=1000
- )
- model_query = model.load_query_data(reference_model=model, adata=adata)
- model.save("test_resolvi", save_anndata=True, overwrite=True)
- model_query = model.load_query_data(reference_model="test_resolvi", adata=adata)
- model_query.train(
- max_epochs=2,
- )
-
-
-@pytest.mark.optional
-def test_resolvi_semisupervised(adata):
- RESOLVI.setup_anndata(adata, labels_key="labels")
- model = RESOLVI(adata, semisupervised=True)
- model.train(
- max_epochs=2,
- )
- model.differential_niche_abundance(
- batch_size=30,
- groupby="batch",
- neighbor_key="index_neighbor",
- )
- pred = model.predict(soft=True)
- assert pred.shape == (adata.n_obs, model.summary_stats.n_labels - 1)
- pred = model.predict(soft=False)
- assert pred.shape == (adata.n_obs,)
-
-
-def test_resolvi_scarches(adata):
- adata.obs["hemisphere"] = ["right" if x > 0 else "left" for x in adata.obsm["X_spatial"][:, 0]]
- ref_adata = adata[adata.obs["hemisphere"] == "left"].copy()
- query_adata = adata[adata.obs["hemisphere"] == "right"].copy()
-
- RESOLVI.setup_anndata(ref_adata, labels_key="labels")
- model = RESOLVI(ref_adata, semisupervised=True)
- model.train(
- max_epochs=2,
- )
-
- ref_adata.obsm["resolvi_celltypes"] = model.predict(ref_adata, num_samples=3, soft=True)
- ref_adata.obs["resolvi_predicted"] = ref_adata.obsm["resolvi_celltypes"].idxmax(axis=1)
- ref_adata.obsm["X_resolVI"] = model.get_latent_representation(ref_adata)
-
- query_adata.obs["predicted_celltype"] = "unknown"
- query_adata.obs_names = [f"query_{i}" for i in query_adata.obs_names]
-
- model.prepare_query_anndata(query_adata, reference_model=model)
- query_resolvi = model.load_query_data(query_adata, reference_model=model)
-
- query_resolvi.train(max_epochs=1)
-
- query_adata.obs["resolvi_predicted"] = query_resolvi.predict(
- query_adata, num_samples=3, soft=False
- )
- query_adata.obsm["X_resolVI"] = query_resolvi.get_latent_representation(query_adata)
-
-
-@pytest.mark.parametrize("weights", ["importance", "uniform"])
-@pytest.mark.parametrize("n_samples", [1, 3])
-@pytest.mark.parametrize("downsample_counts", [True, False])
-def test_resolvi_differential_expression(
- adata, weights: str, n_samples: int, downsample_counts: bool
-):
- RESOLVI.setup_anndata(adata)
- model = RESOLVI(adata, downsample_counts=downsample_counts)
- model.train(max_epochs=1)
- model.differential_expression(groupby="labels", weights=weights, n_samples=n_samples)
diff --git a/tests/external/scviva/test_scviva.py b/tests/external/scviva/test_scviva.py
deleted file mode 100644
index a6de3817d8..0000000000
--- a/tests/external/scviva/test_scviva.py
+++ /dev/null
@@ -1,487 +0,0 @@
-import tempfile
-
-import numpy as np
-import pytest
-from anndata import AnnData
-from sklearn.gaussian_process import GaussianProcessClassifier
-
-import scvi
-from scvi.data import synthetic_iid
-from scvi.external import SCVIVA
-from scvi.external.scviva.differential_expression import DifferentialExpressionResults
-
-N_LATENT_INTRINSIC = 20
-N_LATENT = 10
-K_NN = 5
-N_EPOCHS_SCVIVA = 2
-LABELS_KEY = "labels"
-
-setup_kwargs = {
- "sample_key": "batch",
- "labels_key": LABELS_KEY,
- "cell_coordinates_key": "coordinates",
- "expression_embedding_key": "qz1_m",
- "expression_embedding_niche_key": "qz1_m_niche_ct",
- "niche_composition_key": "neighborhood_composition",
- "niche_indexes_key": "niche_indexes",
- "niche_distances_key": "niche_distances",
-}
-
-
-@pytest.fixture(scope="session")
-def adata():
- adata = synthetic_iid(
- batch_size=256,
- n_genes=100,
- n_proteins=0,
- n_regions=0,
- n_batches=3,
- n_labels=5,
- dropout_ratio=0.5,
- generate_coordinates=True,
- sparse_format=None,
- return_mudata=False,
- )
-
- adata.obsm["qz1_m"] = np.random.normal(size=(adata.shape[0], N_LATENT_INTRINSIC))
- adata.layers["counts"] = adata.X.copy()
-
- return adata
-
-
-def test_scviva_train(adata: AnnData):
- SCVIVA.preprocessing_anndata(
- adata,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- SCVIVA.setup_anndata(
- adata,
- layer="counts",
- batch_key="batch",
- **setup_kwargs,
- )
- nichevae = SCVIVA(
- adata,
- prior_mixture=False,
- semisupervised=True,
- linear_classifier=True,
- )
-
- nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- assert nichevae.is_trained
-
-
-def test_scviva_save_load(adata):
- SCVIVA.preprocessing_anndata(
- adata,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- SCVIVA.setup_anndata(
- adata,
- layer="counts",
- batch_key="batch",
- **setup_kwargs,
- )
- nichevae = SCVIVA(
- adata,
- prior_mixture=False,
- semisupervised=True,
- linear_classifier=True,
- )
-
- nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
- hist_elbo = nichevae.history["elbo_train"]
- latent = nichevae.get_latent_representation()
- assert latent.shape == (adata.n_obs, nichevae.module.n_latent)
- nichevae.save("test_scVIVA", save_anndata=True, overwrite=True)
- model2 = nichevae.load("test_scVIVA")
- np.testing.assert_array_equal(model2.history_["elbo_train"], hist_elbo)
- latent2 = model2.get_latent_representation()
- assert np.allclose(latent, latent2, atol=1e-5)
-
- nichevae.get_elbo(indices=nichevae.validation_indices)
- nichevae.get_composition_error(return_mean=False, indices=nichevae.validation_indices)
- nichevae.get_niche_error(return_mean=False, indices=nichevae.validation_indices)
- nichevae.get_normalized_expression()
- predicted_alpha = nichevae.predict_neighborhood()
- assert predicted_alpha.shape == (adata.n_obs, nichevae.n_labels)
- assert np.allclose(predicted_alpha.sum(), adata.n_obs, atol=1e-5)
- predicted_eta = nichevae.predict_niche_activation()
- assert predicted_eta.shape == (adata.n_obs, nichevae.n_labels, N_LATENT_INTRINSIC)
-
-
-@pytest.mark.optional
-def test_scviva_differential(adata):
- SCVIVA.preprocessing_anndata(
- adata,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- SCVIVA.setup_anndata(
- adata,
- layer="counts",
- batch_key="batch",
- **setup_kwargs,
- )
- nichevae = SCVIVA(
- adata,
- prior_mixture=False,
- semisupervised=True,
- linear_classifier=True,
- )
-
- nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- nichevae.differential_expression(
- groupby="labels",
- group1="label_1",
- group2="label_2",
- batch_correction=False,
- niche_mode=False,
- fdr_target=1,
- delta=0.5,
- )
-
- nichevae.differential_expression(
- groupby="labels",
- group1="label_1",
- # group2="label_2",
- batch_correction=False,
- radius=None,
- k_nn=5,
- fdr_target=1,
- delta=0.5,
- )
- DE_results = nichevae.differential_expression(
- groupby="labels",
- group1="label_1",
- group2="label_2",
- batch_correction=False,
- radius=None,
- k_nn=5,
- fdr_target=[1, 1, 1, 1],
- delta=[0.5, 0.5, 0.5, 0.5],
- )
-
- assert isinstance(DE_results, DifferentialExpressionResults)
- assert isinstance(DE_results.gpc, GaussianProcessClassifier)
- assert hasattr(DE_results.gpc, "log_marginal_likelihood_value_")
-
- # Suppress plt.show() to avoid UI popups
- import matplotlib.pyplot as plt
-
- plt_show_backup = plt.show
- plt.show = lambda: None
-
- try:
- DE_results.plot(show_plot=False)
- finally:
- plt.show = plt_show_backup
-
- import os
-
- with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
- path = tmp.name
-
- try:
- DE_results.plot(path_to_save=path, show_plot=False)
- assert os.path.exists(path)
- assert os.path.getsize(path) > 0
- finally:
- os.remove(path)
-
- nichevae.differential_expression(
- groupby="labels",
- group1="label_1",
- group2="label_2",
- batch_correction=False,
- radius=50,
- k_nn=None,
- fdr_target=[1, 1, 1, 1],
- delta=[0.5, 0.5, 0.5, 0.5],
- )
-
-
-@pytest.fixture
-def split_ref_query_adata(adata: AnnData):
- """Split adata into ref and query using hemisphere."""
- adata.obs["hemisphere"] = [
- "right" if x > 0 else "left" for x in adata.obsm["coordinates"][:, 0]
- ]
- ref_adata = adata[adata.obs["hemisphere"] == "left"].copy()
- query_adata = adata[adata.obs["hemisphere"] == "right"].copy()
- return ref_adata, query_adata
-
-
-def test_scviva_scarches_less_features(split_ref_query_adata):
- # divide between ref and query data
- ref_adata, query_adata = split_ref_query_adata
-
- SCVIVA.preprocessing_anndata(
- ref_adata,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- SCVIVA.setup_anndata(
- ref_adata,
- layer="counts",
- batch_key="batch",
- **setup_kwargs,
- )
-
- # Reference model
- nichevae = SCVIVA(
- ref_adata,
- prior_mixture=False,
- semisupervised=True,
- linear_classifier=True,
- )
-
- nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- assert nichevae.is_trained
-
- # Make it different from reference adata - shuffling and removing a few genes here
- query_adata = query_adata[
- :, np.random.permutation(query_adata.var_names)[: query_adata.shape[1] - 5]
- ].copy()
-
- query_adata.obs["labels"] = (
- query_adata.obs["labels"].astype(str).replace("label_2", "label_1").astype("category")
- )
- # Query adata
- nichevae.preprocessing_query_anndata(
- query_adata,
- reference_model=nichevae,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- query_nichevae = nichevae.load_query_data(query_adata, reference_model=nichevae)
-
- query_nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- predicted_alpha = query_nichevae.predict_neighborhood(query_adata)
- assert predicted_alpha.shape == (query_adata.n_obs, query_nichevae.n_labels)
- query_adata.obsm["X_nichevi"] = query_nichevae.get_latent_representation(query_adata)
-
- # Check that embedding default works
- assert (
- query_nichevae.get_latent_representation(
- adata=ref_adata,
- ).shape[0]
- == ref_adata.shape[0]
- )
- assert (
- query_nichevae.get_latent_representation(
- adata=query_adata,
- ).shape[0]
- == query_adata.shape[0]
- )
-
-
-def _permute_label_categories(adata: AnnData, label_key: str = "labels") -> None:
- """Permute the order of label categories to ensure .unique() returns different order."""
- import random
-
- current_categories = list(adata.obs[label_key].cat.categories)
- new_order = random.sample(current_categories, len(current_categories)) # shuffled list
- adata.obs[label_key] = adata.obs[label_key].cat.reorder_categories(new_order, ordered=False)
-
-
-def test_scviva_scarches_same_features(split_ref_query_adata):
- # divide between ref and query data
- ref_adata, query_adata = split_ref_query_adata
-
- ref_labels = set(ref_adata.obs[LABELS_KEY].unique())
- query_labels = set(query_adata.obs[LABELS_KEY].unique())
- assert ref_labels == query_labels, (
- f"Label sets do not match:\nRef: {ref_labels}\nQuery: {query_labels}"
- )
-
- ref_adata.obs["labels"] = ref_adata.obs[LABELS_KEY].astype("category")
- _permute_label_categories(ref_adata, label_key=LABELS_KEY)
- query_adata.obs["labels"] = query_adata.obs[LABELS_KEY].astype("category")
- _permute_label_categories(query_adata, label_key=LABELS_KEY)
-
- SCVIVA.preprocessing_anndata(
- ref_adata,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- SCVIVA.setup_anndata(
- ref_adata,
- layer="counts",
- batch_key="batch",
- **setup_kwargs,
- )
-
- # Reference model
- nichevae = SCVIVA(
- ref_adata,
- prior_mixture=False,
- semisupervised=True,
- linear_classifier=True,
- )
-
- nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- assert nichevae.is_trained
-
- # Make it different from reference adata - only shuffling genes here
- query_adata = query_adata[
- :, np.random.permutation(query_adata.var_names)[: query_adata.shape[1]]
- ].copy()
-
- # Query adata
- nichevae.preprocessing_query_anndata(
- query_adata,
- reference_model=nichevae,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- query_nichevae = nichevae.load_query_data(query_adata, reference_model=nichevae)
-
- query_nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- predicted_alpha = query_nichevae.predict_neighborhood(query_adata)
- assert predicted_alpha.shape == (query_adata.n_obs, query_nichevae.n_labels)
- query_adata.obsm["X_nichevi"] = query_nichevae.get_latent_representation(query_adata)
-
- # Check that embedding default works
- assert (
- query_nichevae.get_latent_representation(
- adata=ref_adata,
- ).shape[0]
- == ref_adata.shape[0]
- )
- assert (
- query_nichevae.get_latent_representation(
- adata=query_adata,
- ).shape[0]
- == query_adata.shape[0]
- )
-
-
-def test_scviva_scarches_batch_embedding(split_ref_query_adata):
- """scArches with batch_representation='embedding':
- batch embedding must have requires_grad=True."""
- ref_adata, query_adata = split_ref_query_adata
-
- SCVIVA.preprocessing_anndata(ref_adata, k_nn=K_NN, **setup_kwargs)
- SCVIVA.setup_anndata(ref_adata, layer="counts", batch_key="batch", **setup_kwargs)
-
- nichevae = SCVIVA(
- ref_adata,
- prior_mixture=False,
- semisupervised=True,
- linear_classifier=True,
- batch_representation="embedding",
- )
- nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- nichevae.preprocessing_query_anndata(
- query_adata, reference_model=nichevae, k_nn=K_NN, **setup_kwargs
- )
- query_nichevae = nichevae.load_query_data(query_adata, reference_model=nichevae)
-
- emb = query_nichevae.module.get_embedding(scvi.REGISTRY_KEYS.BATCH_KEY)
- assert emb.weight.requires_grad, "batch embedding must be trainable during scArches surgery"
-
- query_nichevae.train(
- max_epochs=N_EPOCHS_SCVIVA,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
-
- assert query_nichevae.get_latent_representation(query_adata).shape[0] == query_adata.n_obs
- assert query_nichevae.get_latent_representation(ref_adata).shape[0] == ref_adata.n_obs
-
-
-@pytest.mark.parametrize("dispersion", ["gene", "gene-batch", "gene-label", "gene-cell"])
-def test_scviva_dispersion(adata: AnnData, dispersion: str):
- SCVIVA.preprocessing_anndata(
- adata,
- k_nn=K_NN,
- **setup_kwargs,
- )
-
- SCVIVA.setup_anndata(
- adata,
- layer="counts",
- batch_key="batch",
- **setup_kwargs,
- )
-
- model = SCVIVA(adata, dispersion=dispersion)
- model.train(
- max_epochs=2,
- )
diff --git a/tests/external/stereoscope/test_stereoscope.py b/tests/external/stereoscope/test_stereoscope.py
deleted file mode 100644
index 3241f3db76..0000000000
--- a/tests/external/stereoscope/test_stereoscope.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import numpy as np
-
-from scvi.data import synthetic_iid
-from scvi.external import RNAStereoscope, SpatialStereoscope
-
-
-def test_stereoscope(save_path):
- dataset = synthetic_iid(
- n_labels=5,
- )
- RNAStereoscope.setup_anndata(
- dataset,
- labels_key="labels",
- )
-
- # train with no proportions
- sc_model = RNAStereoscope(dataset)
- sc_model.train(max_epochs=1)
-
- # train again with proportions
- sc_model = RNAStereoscope(dataset, ct_weights=np.ones((5,)))
- sc_model.train(max_epochs=1)
- # test save/load
- sc_model.save(save_path, overwrite=True, save_anndata=True)
- sc_model = RNAStereoscope.load(save_path)
- dataset = synthetic_iid(
- n_labels=5,
- )
- SpatialStereoscope.setup_anndata(
- dataset,
- )
- st_model = SpatialStereoscope.from_rna_model(dataset, sc_model, prior_weight="minibatch")
- st_model.train(max_epochs=1)
- st_model.get_proportions()
- # test save/load
- st_model.save(save_path, overwrite=True, save_anndata=True)
- st_model = SpatialStereoscope.load(save_path)
- st_model.get_proportions()
-
- # try imputation code
- y = np.array(50 * ["label_0"])
- st_model.get_scale_for_ct(y)
diff --git a/tests/external/tangram/test_tangram.py b/tests/external/tangram/test_tangram.py
deleted file mode 100644
index ab47be6570..0000000000
--- a/tests/external/tangram/test_tangram.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import mudata
-import numpy as np
-import pytest
-
-import scvi
-from scvi.data import synthetic_iid
-from scvi.external import Tangram
-
-modalities = {"density_prior_key": "sp", "sc_layer": "sc", "sp_layer": "sp"}
-
-
-def _get_mdata(sparse_format: str | None = None):
- dataset1 = synthetic_iid(batch_size=100, sparse_format=sparse_format)
- dataset2 = dataset1[-25:].copy()
- dataset1 = dataset1[:-25].copy()
- mdata = mudata.MuData({"sc": dataset1, "sp": dataset2})
- ad_sp = mdata.mod["sp"]
- rna_count_per_spot = np.asarray(ad_sp.X.sum(axis=1)).squeeze()
- ad_sp.obs["rna_count_based_density"] = rna_count_per_spot / np.sum(rna_count_per_spot)
- ad_sp.obs["bad_prior"] = np.random.uniform(size=ad_sp.n_obs)
- return mdata
-
-
-@pytest.mark.parametrize(
- ("density_prior_key", "constrained"),
- [
- (None, False),
- ("rna_count_based_density", False),
- ("rna_count_based_density", True),
- ],
-)
-def test_tangram(density_prior_key, constrained):
- mdata = _get_mdata()
- Tangram.setup_mudata(
- mdata,
- density_prior_key=density_prior_key,
- modalities=modalities,
- )
- if constrained:
- target_count = 2
- else:
- target_count = None
- model = Tangram(mdata, constrained=constrained, target_count=target_count)
- model.train(max_epochs=1)
- mdata.mod["sc"].obsm["mapper"] = model.get_mapper_matrix()
- model.project_cell_annotations(
- mdata.mod["sc"],
- mdata.mod["sp"],
- mdata.mod["sc"].obsm["mapper"],
- mdata.mod["sc"].obs.labels,
- )
- model.project_genes(mdata.mod["sc"], mdata.mod["sp"], mdata.mod["sc"].obsm["mapper"])
-
-
-def test_tangram_errors():
- mdata = _get_mdata()
- Tangram.setup_mudata(
- mdata,
- density_prior_key="rna_count_based_density",
- modalities=modalities,
- )
- with pytest.raises(ValueError):
- Tangram(mdata, constrained=True, target_count=None)
-
- Tangram.setup_mudata(
- mdata,
- density_prior_key="bad_prior",
- modalities=modalities,
- )
- with pytest.raises(ValueError):
- Tangram(mdata)
-
-
-def test_tangram_reproducible_with_seed():
- mdata = _get_mdata()
- Tangram.setup_mudata(
- mdata,
- density_prior_key="rna_count_based_density",
- modalities=modalities,
- )
-
- scvi.settings.seed = 7
- model_a = Tangram(mdata)
- model_a.train(max_epochs=2)
- mapper_a = model_a.get_mapper_matrix()
- loss_a = model_a.history_["loss"].to_numpy(dtype=float)
-
- scvi.settings.seed = 7
- model_b = Tangram(mdata)
- model_b.train(max_epochs=2)
- mapper_b = model_b.get_mapper_matrix()
- loss_b = model_b.history_["loss"].to_numpy(dtype=float)
-
- np.testing.assert_allclose(mapper_a, mapper_b, rtol=0, atol=0)
- np.testing.assert_allclose(loss_a, loss_b, rtol=0, atol=0)
diff --git a/tests/model/test_destvi.py b/tests/model/test_destvi.py
deleted file mode 100644
index 1a88087219..0000000000
--- a/tests/model/test_destvi.py
+++ /dev/null
@@ -1,194 +0,0 @@
-import os
-
-import numpy as np
-import pytest
-import scanpy as sc
-
-from scvi.data import synthetic_iid
-from scvi.model import CondSCVI, DestVI
-
-
-def test_destvi():
- # Step1 learn CondSCVI
- n_latent = 2
- n_labels = 5
- n_layers = 2
- dataset = synthetic_iid(n_labels=n_labels)
- dataset.obs["overclustering_vamp"] = list(range(dataset.n_obs))
- CondSCVI.setup_anndata(dataset, labels_key="labels", batch_key="batch")
- sc_model = CondSCVI(
- dataset, n_latent=n_latent, n_layers=n_layers, prior="mog", num_classes_mog=10
- )
- sc_model.train(1, train_size=1)
-
- sc_model.get_normalized_expression(dataset)
- sc_model.get_elbo()
- sc_model.get_reconstruction_error()
- sc_model.get_latent_representation()
- sc_model.get_vamp_prior(dataset, p=100)
-
- # step 2 Check model setup
- DestVI.setup_anndata(dataset, layer=None)
-
- # Test clustering outside of get_vamp_prior
-
- _ = DestVI.from_rna_model(dataset, sc_model, vamp_prior_p=dataset.n_obs)
-
- del dataset.obs["overclustering_vamp"]
-
- # step 3 learn destVI with multiple amortization scheme
-
- for amor_scheme in ["both", "latent"]:
- DestVI.setup_anndata(dataset, layer=None)
- spatial_model = DestVI.from_rna_model(dataset, sc_model, amortization=amor_scheme)
- spatial_model.view_anndata_setup()
- spatial_model.train(max_epochs=1)
- assert not np.isnan(spatial_model.history["elbo_train"].values[0][0])
-
- assert spatial_model.get_proportions().shape == (dataset.n_obs, n_labels)
- assert spatial_model.get_gamma(return_numpy=True).shape == (
- dataset.n_obs,
- n_latent,
- n_labels,
- )
-
- assert spatial_model.get_scale_for_ct("label_0", np.arange(50)).shape == (
- 50,
- dataset.n_vars,
- )
-
- with pytest.raises(NotImplementedError):
- spatial_model.get_normalized_expression()
-
-
-def test_destvi_validation():
- # DestVI must be trainable with a validation set: `early_stopping`,
- # `check_val_every_n_epoch` and `train_size < 1.0` previously crashed in the loss because the
- # augmentation branch was not gated on `self.training` (validation produces no augmentation
- # tensors). See `MRDeconv.loss`.
- n_latent = 2
- n_labels = 5
- dataset = synthetic_iid(n_labels=n_labels)
- CondSCVI.setup_anndata(dataset, labels_key="labels", batch_key="batch")
- sc_model = CondSCVI(dataset, n_latent=n_latent, n_layers=2, prior="mog", num_classes_mog=10)
- sc_model.train(1, train_size=0.9)
-
- DestVI.setup_anndata(dataset, layer=None)
-
- # train_size < 1.0 with validation evaluated every epoch
- model = DestVI.from_rna_model(dataset, sc_model, amortization="both")
- model.train(max_epochs=2, train_size=0.9, check_val_every_n_epoch=1)
- assert "validation_loss" in model.history
- assert not np.isnan(model.history["validation_loss"].values[0][0])
-
- # early stopping (requires a working validation pass)
- model = DestVI.from_rna_model(dataset, sc_model, amortization="both")
- model.train(max_epochs=2, train_size=0.9, early_stopping=True)
- assert "validation_loss" in model.history
-
- # Non-amortized spot parameters (here `V`, since amortization="latent") are only trained on the
- # training spots, so a validation set would be evaluated on randomly-initialized parameters.
- # Reject it. ("proportion"/"none" are not exercised here because mog priors require amortized
- # latents, but they hit the same `amortization != 'both'` guard.)
- model = DestVI.from_rna_model(dataset, sc_model, amortization="latent")
- with pytest.raises(ValueError, match="amortization='both'"):
- model.train(max_epochs=2, train_size=0.9)
- with pytest.raises(ValueError, match="amortization='both'"):
- model.train(max_epochs=2, early_stopping=True)
- # train_size=1.0 (all spots trained) must still work
- model.train(max_epochs=1)
-
-
-@pytest.mark.internet
-def test_destvi_new(save_path: str):
- CELL_TYPE_ID = "broad_cell_types"
- G = 2000
- CELL_TYPE_HIGHRES_ID = "cell_types"
- BATCH_KEY = "batch"
- MODELS_PATH = os.path.join(save_path, "models")
- VISIUM_COLON_SC_MODEL_PATH = os.path.join(MODELS_PATH, "Visium_DestVI_v2_sc")
- VISIUM_COLON_ST_MODEL_PATH = os.path.join(MODELS_PATH, "Visium_DestVI_v2_st")
- st_adata_path = os.path.join(save_path, "st_lymph_node_preprocessed.h5ad")
- st_adata = sc.read(
- st_adata_path,
- backup_url="https://exampledata.scverse.org/scvi-tools/st_lymph_node_preprocessed.h5ad",
- )
-
- sc_adata_path = os.path.join(save_path, "sc_lymph_node_preprocessed.h5ad")
- sc_adata = sc.read(
- sc_adata_path,
- backup_url="https://exampledata.scverse.org/scvi-tools/sc_lymph_node_preprocessed.h5ad",
- )
-
- # let us filter some genes
- sc.pp.filter_genes(sc_adata, min_counts=10)
- sc_adata.layers["counts"] = sc_adata.X.copy()
- sc.pp.highly_variable_genes(
- sc_adata, n_top_genes=G, subset=True, layer="counts", flavor="seurat_v3"
- )
- sc.pp.normalize_total(sc_adata, target_sum=10e4)
- sc.pp.log1p(sc_adata)
- sc_adata.raw = sc_adata
-
- st_adata.layers["counts"] = st_adata.X.copy()
- st_adata.obsm["spatial"] = st_adata.obsm["location"]
- sc.pp.normalize_total(st_adata, target_sum=10e4)
- sc.pp.log1p(st_adata)
- st_adata.raw = st_adata
-
- # filter genes to be the same on the spatial data
- intersect = np.intersect1d(sc_adata.var_names, st_adata.var_names)
- st_adata = st_adata[:, intersect].copy()
- sc_adata = sc_adata[:, intersect].copy()
- G = len(intersect)
- print(G)
-
- CondSCVI.setup_anndata(
- sc_adata,
- layer="counts",
- labels_key=CELL_TYPE_ID,
- fine_labels_key=CELL_TYPE_HIGHRES_ID,
- batch_key=BATCH_KEY,
- )
- sc_model = CondSCVI(sc_adata, weight_obs=False, prior="mog", num_classes_mog=10)
- sc_model.train(batch_size=1024, max_epochs=1)
- sc_model.save(VISIUM_COLON_SC_MODEL_PATH, overwrite=True, save_anndata=True)
-
- # Deconvolution with stLVM
- st_adata = st_adata[st_adata.layers["counts"].sum(1) > 10].copy()
- st_adata.obs[BATCH_KEY] = "spatial"
-
- def spatial_nn_gex_smth(stadata, n_neighs):
- sc.pp.neighbors(stadata, n_neighs, use_rep="spatial", key_added="Xspatial")
- stadata.obsp["Xspatial_connectivities"] = stadata.obsp["Xspatial_connectivities"].ceil()
- stadata.obsp["Xspatial_connectivities"].setdiag(1)
- return stadata.obsp["Xspatial_connectivities"].dot(stadata.layers["counts"])
-
- st_adata.layers["smoothed"] = spatial_nn_gex_smth(st_adata, n_neighs=5)
- st_model = DestVI.from_rna_model(
- st_adata,
- sc_model,
- add_celltypes=2,
- n_latent_amortization=None,
- anndata_setup_kwargs={"smoothed_layer": "smoothed"},
- ) # prior_mode = 'normal'
- st_model.train(
- max_epochs=1,
- n_epochs_kl_warmup=200,
- batch_size=1024,
- plan_kwargs={"weighting_kl_latent": 1e-2, "ct_sparsity_weight": 0},
- )
- st_model.save(VISIUM_COLON_ST_MODEL_PATH, overwrite=True)
- st_adata.obsm["proportions"] = st_model.get_proportions(keep_additional=True)
- ct_list = st_adata.obsm["proportions"].columns
- for ct in ct_list:
- data = st_adata.obsm["proportions"][ct].values
- st_adata.obs[ct] = data
- st_adata.obsm["fine_proportions"] = st_model.get_fine_celltypes(sc_model)
- ct_list = st_adata.obsm["fine_proportions"].columns
- for ct in ct_list:
- data = st_adata.obsm["fine_proportions"][ct].values
- st_adata.obs[ct] = data
- st_adata.var["betas"] = st_model.module.beta.detach().cpu().numpy()
- for ct, g in st_model.get_gamma().items():
- st_adata.obsm[f"{ct}_gamma"] = g
diff --git a/tests/model/test_differential_abundance.py b/tests/model/test_differential_abundance.py
index 650748dd45..ead52c5ec2 100644
--- a/tests/model/test_differential_abundance.py
+++ b/tests/model/test_differential_abundance.py
@@ -9,7 +9,7 @@
from mudata import MuData
from scvi.data import synthetic_iid
-from scvi.external import SCVIVA, TOTALANVI
+from scvi.external import TOTALANVI
from scvi.model import MULTIVI, SCANVI, SCVI, TOTALVI, AmortizedLDA
if TYPE_CHECKING:
@@ -20,11 +20,9 @@
@pytest.fixture(scope="session")
def adata():
- adata = synthetic_iid(batch_size=500, generate_coordinates=True)
+ adata = synthetic_iid(batch_size=500)
adata.obs["sample"] = np.random.choice(10, size=adata.n_obs)
adata.obs["sample_str"] = [chr(i + ord("a")) for i in adata.obs["sample"]]
- adata.obsm["qz1_m"] = np.random.normal(size=(adata.shape[0], 20))
- adata.layers["counts"] = adata.X.copy()
return adata
@@ -41,7 +39,7 @@ def mdata():
@pytest.fixture(
scope="session",
- params=[SCVI, SCANVI, TOTALVI, SCVIVA, MULTIVI, TOTALANVI],
+ params=[SCVI, SCANVI, TOTALVI, MULTIVI, TOTALANVI],
)
def model(request, adata, mdata):
model_cls = request.param
@@ -49,41 +47,6 @@ def model(request, adata, mdata):
if model_cls is SCVI:
model_cls.setup_anndata(adata=adata, batch_key="batch")
- elif model_cls is SCVIVA:
- setup_kwargs = {
- "sample_key": "batch",
- "labels_key": "labels",
- "cell_coordinates_key": "coordinates",
- "expression_embedding_key": "qz1_m",
- "expression_embedding_niche_key": "qz1_m_niche_ct",
- "niche_composition_key": "neighborhood_composition",
- "niche_indexes_key": "niche_indexes",
- "niche_distances_key": "niche_distances",
- }
- model_cls.preprocessing_anndata(adata, k_nn=5, **setup_kwargs)
-
- model_cls.setup_anndata(
- adata,
- layer="counts",
- batch_key="batch",
- **setup_kwargs,
- )
- model_inst = SCVIVA(
- adata,
- prior_mixture=False,
- semisupervised=True,
- linear_classifier=True,
- )
-
- model_inst.train(
- max_epochs=1,
- train_size=0.8,
- validation_size=0.2,
- early_stopping=True,
- check_val_every_n_epoch=1,
- accelerator="cpu",
- )
- return model_inst
elif model_cls is SCANVI:
model_cls.setup_anndata(
adata=adata, labels_key="labels", unlabeled_category="NA", batch_key="batch"