Skip to content

Commit 97e92cf

Browse files
valsclaude
andcommitted
feat: Add JointEmbeddingSCVI model
Add JointEmbeddingSCVI, a self-supervised SCVI variant that augments the standard ELBO with a cross-correlation objective (CCO) between the embedding of the data and a binomially-thinned view of it, encouraging embeddings that are robust to count dropout/noise. A VICReg-style variance term guards against dimension collapse. The joint-embedding loss is active only during training; evaluation (get_elbo / get_marginal_ll / get_reconstruction_error) falls back to the standard reweighted ELBO, and so does any minibatch with fewer than two cells. Contents: - src/scvi/module/_joint_embedding_utils.py: binomial thinning, per-cell thinning-probability sampling, cross-correlation and variance losses - src/scvi/module/_joint_embedding_vae.py: JointEmbeddingVAE module - src/scvi/model/_joint_embedding_scvi.py: JointEmbeddingSCVI model - tests/model/test_joint_embedding_scvi.py, tests/module/test_joint_embedding_utils.py - docs/api (user + developer) and CHANGELOG entries Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 902d521 commit 97e92cf

10 files changed

Lines changed: 744 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ to [Semantic Versioning]. The full commit history is available in the [commit lo
1515
with support for batch covariates, {pr}`3620`.
1616
- Add support for rapids-singlecell, {pr}`3811`.
1717
- Add {class}`scvi.external.CytoVI` KNN imputation backend option to be cuML, {pr}`3821`.
18+
- Add {class}`scvi.model.JointEmbeddingSCVI`, a self-supervised SCVI variant using binomial thinning and a cross-correlation objective (CCO) for robust embeddings, {pr}`XXXX`.
1819

1920
#### Fixed
2021

docs/api/developer.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Existing module classes with respective generative and inference procedures.
152152
153153
module.AutoZIVAE
154154
module.Classifier
155+
module.JointEmbeddingVAE
155156
module.LDVAE
156157
module.MRDeconv
157158
module.PEAKVAE

docs/api/user.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import scvi
2626
model.AUTOZI
2727
model.CondSCVI
2828
model.DestVI
29+
model.JointEmbeddingSCVI
2930
model.LinearSCVI
3031
model.PEAKVI
3132
model.SCANVI

src/scvi/model/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ._autozi import AUTOZI
99
from ._condscvi import CondSCVI
1010
from ._destvi import DestVI
11+
from ._joint_embedding_scvi import JointEmbeddingSCVI
1112
from ._linear_scvi import LinearSCVI
1213
from ._multivi import MULTIVI
1314
from ._peakvi import PEAKVI
@@ -25,6 +26,7 @@
2526
"PEAKVI",
2627
"CondSCVI",
2728
"DestVI",
29+
"JointEmbeddingSCVI",
2830
"MULTIVI",
2931
"AmortizedLDA",
3032
"utils",
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Joint Embedding SCVI model."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from scvi.model._scvi import SCVI
8+
from scvi.module import JointEmbeddingVAE
9+
10+
if TYPE_CHECKING:
11+
from typing import Literal
12+
13+
from anndata import AnnData
14+
15+
16+
class JointEmbeddingSCVI(SCVI):
17+
"""SCVI with joint embedding loss using binomial thinning and CCO.
18+
19+
This model extends the standard SCVI with a cross-correlation objective (CCO)
20+
loss that encourages the embedding of a thinned view to match the embedding
21+
of the original data. This promotes robustness to count dropout/noise.
22+
23+
Thinning probabilities are dynamically sampled per cell to produce target
24+
library sizes that are log-uniform between min_library_size and the observed
25+
library size. This matches realistic library size variation in single-cell data.
26+
27+
Parameters
28+
----------
29+
adata
30+
AnnData object that has been registered via :meth:`~scvi.model.SCVI.setup_anndata`.
31+
registry
32+
Registry of the datamodule used to train the model, passed through when training
33+
without an :class:`~anndata.AnnData` object (see :class:`~scvi.model.SCVI`).
34+
joint_embedding_weight
35+
Weight for the CCO loss. Default is 100.0.
36+
lambda_off_diag
37+
Off-diagonal penalty in CCO loss. Default is 0.01.
38+
min_library_size
39+
Minimum target library size for thinning. Default is 10.
40+
Thinned library sizes are sampled log-uniformly between this
41+
value and the observed library size.
42+
reconstruction_weight
43+
Weight for reconstruction loss. Default is 1.0.
44+
Set to 0.0 for pure self-supervised training with only CCO loss.
45+
variance_weight
46+
Weight for variance regularization loss (VICReg-style). Default is 0.0.
47+
Set to positive value (e.g., 1.0) to prevent dimension collapse
48+
in self-supervised training.
49+
use_joint_embedding
50+
Whether to use joint embedding loss. Default is True.
51+
Set to False to train as standard SCVI.
52+
n_hidden
53+
Number of nodes per hidden layer.
54+
n_latent
55+
Dimensionality of the latent space.
56+
n_layers
57+
Number of hidden layers used for encoder and decoder NNs.
58+
dropout_rate
59+
Dropout rate for neural networks.
60+
dispersion
61+
One of the following:
62+
63+
* ``'gene'`` - dispersion parameter of NB is constant per gene across cells
64+
* ``'gene-batch'`` - dispersion can differ between different batches
65+
* ``'gene-label'`` - dispersion can differ between different labels
66+
* ``'gene-cell'`` - dispersion can differ for every gene in every cell
67+
gene_likelihood
68+
One of:
69+
70+
* ``'nb'`` - Negative binomial distribution
71+
* ``'zinb'`` - Zero-inflated negative binomial distribution
72+
* ``'poisson'`` - Poisson distribution
73+
* ``'normal'`` - ``EXPERIMENTAL`` Normal distribution
74+
use_observed_lib_size
75+
If ``True``, use the observed library size for RNA as the scaling factor in the mean of the
76+
conditional distribution.
77+
latent_distribution
78+
One of:
79+
80+
* ``'normal'`` - Normal distribution
81+
* ``'ln'`` - Logistic normal distribution (Normal(0, I) transformed by softmax)
82+
**kwargs
83+
Additional keyword arguments for :class:`~scvi.module.JointEmbeddingVAE`.
84+
85+
Examples
86+
--------
87+
>>> adata = anndata.read_h5ad(path_to_anndata)
88+
>>> scvi.model.JointEmbeddingSCVI.setup_anndata(adata, batch_key="batch")
89+
>>> model = scvi.model.JointEmbeddingSCVI(
90+
... adata,
91+
... joint_embedding_weight=1.0,
92+
... lambda_off_diag=0.01,
93+
... )
94+
>>> model.train()
95+
>>> latent = model.get_latent_representation()
96+
97+
See Also
98+
--------
99+
:class:`~scvi.model.SCVI`
100+
:class:`~scvi.module.JointEmbeddingVAE`
101+
"""
102+
103+
_module_cls = JointEmbeddingVAE
104+
105+
def __init__(
106+
self,
107+
adata: AnnData | None = None,
108+
registry: dict | None = None,
109+
joint_embedding_weight: float = 100.0,
110+
lambda_off_diag: float = 0.01,
111+
min_library_size: float = 10.0,
112+
reconstruction_weight: float = 1.0,
113+
variance_weight: float = 0.0,
114+
use_joint_embedding: bool = True,
115+
n_hidden: int = 128,
116+
n_latent: int = 10,
117+
n_layers: int = 1,
118+
dropout_rate: float = 0.1,
119+
dispersion: Literal["gene", "gene-batch", "gene-label", "gene-cell"] = "gene",
120+
gene_likelihood: Literal["zinb", "nb", "poisson", "normal"] = "zinb",
121+
use_observed_lib_size: bool = True,
122+
latent_distribution: Literal["normal", "ln"] = "normal",
123+
**kwargs,
124+
):
125+
# Pass joint embedding params through kwargs to module
126+
super().__init__(
127+
adata=adata,
128+
registry=registry,
129+
n_hidden=n_hidden,
130+
n_latent=n_latent,
131+
n_layers=n_layers,
132+
dropout_rate=dropout_rate,
133+
dispersion=dispersion,
134+
gene_likelihood=gene_likelihood,
135+
use_observed_lib_size=use_observed_lib_size,
136+
latent_distribution=latent_distribution,
137+
joint_embedding_weight=joint_embedding_weight,
138+
lambda_off_diag=lambda_off_diag,
139+
min_library_size=min_library_size,
140+
reconstruction_weight=reconstruction_weight,
141+
variance_weight=variance_weight,
142+
use_joint_embedding=use_joint_embedding,
143+
**kwargs,
144+
)
145+
146+
# Update model summary string
147+
self._model_summary_string = (
148+
"JointEmbeddingSCVI model with the following parameters: \n"
149+
f"n_hidden: {n_hidden}, n_latent: {n_latent}, n_layers: {n_layers}, "
150+
f"dropout_rate: {dropout_rate}, dispersion: {dispersion}, "
151+
f"gene_likelihood: {gene_likelihood}, latent_distribution: {latent_distribution}, "
152+
f"joint_embedding_weight: {joint_embedding_weight}, "
153+
f"lambda_off_diag: {lambda_off_diag}, min_library_size: {min_library_size}, "
154+
f"reconstruction_weight: {reconstruction_weight}, "
155+
f"variance_weight: {variance_weight}, "
156+
f"use_joint_embedding: {use_joint_embedding}."
157+
)

src/scvi/module/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ._amortizedlda import AmortizedLDAPyroModule
77
from ._autozivae import AutoZIVAE
88
from ._classifier import Classifier
9+
from ._joint_embedding_vae import JointEmbeddingVAE
910
from ._mrdeconv import MRDeconv
1011
from ._multivae import MULTIVAE
1112
from ._peakvae import PEAKVAE
@@ -21,6 +22,7 @@
2122
"AutoZIVAE",
2223
"SCANVAE",
2324
"Classifier",
25+
"JointEmbeddingVAE",
2426
"PEAKVAE",
2527
"VAEC",
2628
"MRDeconv",
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Utility functions for joint embedding loss."""
2+
3+
from __future__ import annotations
4+
5+
import torch
6+
7+
8+
def variance_loss(z: torch.Tensor, target_std: float = 1.0) -> torch.Tensor:
9+
"""Compute variance regularization loss (VICReg-style).
10+
11+
Penalizes dimensions with standard deviation below target_std.
12+
This prevents collapse where all samples map to similar embeddings.
13+
14+
Parameters
15+
----------
16+
z
17+
Embedding tensor of shape (batch_size, n_latent).
18+
target_std
19+
Target standard deviation for each dimension. Default is 1.0.
20+
21+
Returns
22+
-------
23+
torch.Tensor
24+
Scalar loss value (mean of hinge losses across dimensions).
25+
"""
26+
# Use the biased estimator so the loss is well-defined (0, not NaN) for
27+
# single-sample minibatches, which the default data splitter can emit.
28+
std = z.std(dim=0, unbiased=False)
29+
return torch.relu(target_std - std).mean()
30+
31+
32+
def binomial_split(
33+
x: torch.Tensor, p: float | torch.Tensor = 0.5
34+
) -> tuple[torch.Tensor, torch.Tensor]:
35+
"""Split counts via binomial thinning.
36+
37+
Parameters
38+
----------
39+
x
40+
Input count tensor of shape (batch_size, n_features).
41+
p
42+
Probability for binomial split. Can be a scalar or a tensor of shape
43+
(batch_size, 1) for per-cell probabilities. Default is 0.5.
44+
45+
Returns
46+
-------
47+
tuple
48+
Two tensors (x1, x2) where x1 ~ Binomial(x, p) and x2 = x - x1.
49+
Guarantees x1 + x2 = x and both are non-negative.
50+
51+
Notes
52+
-----
53+
Binomial thinning requires non-negative integer counts. Inputs are rounded
54+
and clamped to ``>= 0`` so that data stored as integer-valued floats (the
55+
usual single-cell convention) is handled, and non-count inputs do not raise
56+
mid-training.
57+
"""
58+
counts = x.round().clamp(min=0)
59+
x1 = torch.distributions.Binomial(total_count=counts, probs=p).sample()
60+
x2 = counts - x1
61+
return x1, x2
62+
63+
64+
def sample_thinning_probs(x: torch.Tensor, min_library_size: float = 10.0) -> torch.Tensor:
65+
"""Sample per-cell thinning probabilities for realistic library size variation.
66+
67+
Samples target library sizes from a log-uniform distribution between
68+
min_library_size and the observed library size for each cell. This
69+
matches the variation typically observed in real single-cell data.
70+
71+
Parameters
72+
----------
73+
x
74+
Input count tensor of shape (batch_size, n_features).
75+
min_library_size
76+
Minimum target library size. Default is 10.
77+
78+
Returns
79+
-------
80+
torch.Tensor
81+
Per-cell thinning probabilities of shape (batch_size, 1).
82+
"""
83+
# Compute observed library sizes per cell
84+
lib_size = x.sum(dim=1, keepdim=True) # (batch_size, 1)
85+
86+
# Sample target library size from LogUniform(min_library_size, lib_size)
87+
# log(L_target) ~ Uniform(log(min_lib), log(lib_size))
88+
log_min = torch.log(torch.tensor(min_library_size, device=x.device, dtype=x.dtype))
89+
log_lib = torch.log(lib_size.clamp(min=min_library_size + 1e-6))
90+
91+
# Sample uniformly in log space
92+
log_target = log_min + torch.rand_like(lib_size) * (log_lib - log_min)
93+
target_lib_size = torch.exp(log_target)
94+
95+
# Compute thinning probability: p = target_lib_size / lib_size
96+
# Clamp to [0, 1] for safety (handles edge cases where lib_size <= min_library_size)
97+
p = (target_lib_size / lib_size.clamp(min=1e-6)).clamp(0.0, 1.0)
98+
99+
return p
100+
101+
102+
def cross_correlation_loss(
103+
z1: torch.Tensor,
104+
z2: torch.Tensor,
105+
lambda_off_diag: float = 0.01,
106+
return_components: bool = False,
107+
) -> torch.Tensor | dict[str, torch.Tensor]:
108+
"""Compute cross-correlation objective (CCO) loss.
109+
110+
This loss has two components:
111+
- Diagonal (invariance): encourages C_ii = 1, meaning corresponding dimensions
112+
of the two views should be correlated. This is the self-supervision signal.
113+
- Off-diagonal (redundancy reduction): encourages C_ij = 0 for i != j, meaning
114+
different latent dimensions should be decorrelated. This prevents mode collapse.
115+
116+
Parameters
117+
----------
118+
z1
119+
First embedding tensor of shape (batch_size, n_latent).
120+
z2
121+
Second embedding tensor of shape (batch_size, n_latent).
122+
lambda_off_diag
123+
Weight for off-diagonal penalty. Default is 0.01.
124+
return_components
125+
If True, return a dict with individual components. Default is False.
126+
127+
Returns
128+
-------
129+
torch.Tensor or dict
130+
If return_components is False, returns scalar loss value.
131+
If True, returns dict with keys: 'total', 'invariance', 'redundancy'.
132+
"""
133+
# Normalize (z-score across batch dimension). The biased std keeps this
134+
# finite for single-sample minibatches and makes C a proper correlation matrix.
135+
z1_norm = (z1 - z1.mean(0)) / (z1.std(0, unbiased=False) + 1e-8)
136+
z2_norm = (z2 - z2.mean(0)) / (z2.std(0, unbiased=False) + 1e-8)
137+
138+
# Cross-correlation matrix: C[i,j] = corr(z1[:,i], z2[:,j])
139+
batch_size = z1.shape[0]
140+
C = z1_norm.T @ z2_norm / batch_size
141+
142+
# Diagonal loss (invariance): (1 - C_ii)^2 - self-supervision signal
143+
invariance_loss = ((1 - C.diagonal()) ** 2).sum()
144+
145+
# Off-diagonal loss (redundancy reduction): sum(C_ij^2 for i != j) - anti-collapse
146+
# Clone C before modifying diagonal to avoid in-place operation issues
147+
C_off_diag = C.clone()
148+
C_off_diag.fill_diagonal_(0)
149+
redundancy_loss = (C_off_diag**2).sum()
150+
151+
total_loss = invariance_loss + lambda_off_diag * redundancy_loss
152+
153+
if return_components:
154+
return {
155+
"total": total_loss,
156+
"invariance": invariance_loss,
157+
"redundancy": redundancy_loss,
158+
}
159+
return total_loss

0 commit comments

Comments
 (0)