Skip to content

Commit 451d963

Browse files
authored
fix: support validation set in DestVI training and raise clear errors for unsupported validation in RESOLVI (#3881)
1 parent 902d521 commit 451d963

8 files changed

Lines changed: 135 additions & 6 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
fail_fast: false
22
default_language_version:
33
python: python3
4+
node: 22.20.0
45
default_stages:
56
- pre-commit
67
- pre-push

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ to [Semantic Versioning]. The full commit history is available in the [commit lo
2525
- Changed {class}`scvi.external.Tangram` backend to be in Pytorch, {pr}`3786`.
2626
- Consolidate parts of the training and data loading between {class}`~scvi.external.GIMVI`
2727
and {class}`scvi.external.DIAGVI`, {pr}`3830`.
28+
- support validation set in DestVI training and raise clear errors for unsupported validation
29+
in RESOLVI, {pr}`3881`.
2830

2931
#### Removed
3032

src/scvi/external/resolvi/_model.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,25 @@ def train(
229229
List of parameters to train if running model in Arches mode.
230230
**kwargs
231231
Other keyword args for :class:`~scvi.train.Trainer`.
232+
233+
Notes
234+
-----
235+
RESOLVI trains with Pyro SVI and maintains per-cell global parameters, so it does not
236+
support a held-out validation set. ``train_size`` must be ``1.0`` and ``early_stopping``
237+
is not available.
232238
"""
239+
train_size = kwargs.pop("train_size", 1.0)
240+
if train_size != 1.0:
241+
raise ValueError(
242+
"RESOLVI does not support a validation set: it uses Pyro SVI with per-cell "
243+
f"global parameters, so `train_size` must be 1.0 (got {train_size})."
244+
)
245+
if kwargs.pop("early_stopping", False):
246+
raise ValueError(
247+
"RESOLVI does not support `early_stopping` because it trains without a "
248+
"validation set (`train_size` must be 1.0)."
249+
)
250+
233251
blocked = self._block_parameters.copy()
234252
for name, param in self.module.named_parameters():
235253
if not param.requires_grad:

src/scvi/external/resolvi/_utils.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,13 @@ def get_normalized_expression_importance(
172172
indices = np.arange(adata.n_obs)
173173
scdl = self._make_data_loader(adata=adata, indices=indices, batch_size=batch_size)
174174

175-
transform_batch = _get_batch_code_from_category(
176-
self.get_anndata_manager(adata, required=True), transform_batch
177-
)
175+
if transform_batch is not None:
176+
warnings.warn(
177+
"`transform_batch` is not supported by "
178+
"`get_normalized_expression_importance` and will be ignored.",
179+
UserWarning,
180+
stacklevel=settings.warnings_stacklevel,
181+
)
178182

179183
gene_mask = slice(None) if gene_list is None else adata.var_names.isin(gene_list)
180184

@@ -231,6 +235,7 @@ def get_normalized_expression_importance(
231235
else:
232236
exprs.append(samples[1, ...].cpu())
233237
exprs = torch.cat(exprs, axis=1).numpy()
238+
exprs = exprs[..., gene_mask]
234239
if return_mean:
235240
exprs = exprs.mean(0)
236241
weighting = torch.cat(weighting, axis=0).numpy()

src/scvi/model/_destvi.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,26 @@ def train(
623623
**kwargs
624624
Other keyword args for :class:`~scvi.train.Trainer`.
625625
"""
626+
# A validation set is only meaningful when every spot-specific parameter is amortized.
627+
# With amortization != "both", the proportions (`V`) and/or latents (`gamma`) are stored
628+
# per spot and only optimized for the spots seen during training, so held-out validation
629+
# spots keep their random initialization and the validation loss is not meaningful.
630+
validation_requested = (
631+
train_size < 1.0
632+
or bool(validation_size)
633+
or kwargs.get("early_stopping", False)
634+
or kwargs.get("check_val_every_n_epoch", None) is not None
635+
)
636+
if validation_requested and self.module.amortization != "both":
637+
raise ValueError(
638+
"DestVI only supports a validation set (`train_size < 1.0`, `validation_size`, "
639+
"`early_stopping`, or `check_val_every_n_epoch`) when `amortization='both'`. With "
640+
f"`amortization={self.module.amortization!r}`, the per-spot parameters (`V` and/or"
641+
"`gamma`) are not amortized and are trained only on the training spots, leaving "
642+
"validation spots at their random initialization. Use `amortization='both'`, or "
643+
"train on all spots with `train_size=1.0` and no early stopping / validation."
644+
)
645+
626646
update_dict = {
627647
"lr": lr,
628648
"n_epochs_kl_warmup": n_epochs_kl_warmup,
@@ -659,7 +679,10 @@ def setup_anndata(
659679
%(param_adata)s
660680
%(param_layer)s
661681
smoothed_layer
662-
param that...
682+
When provided, the model adds a spatial-smoothing regularization that encourages a
683+
spot's inferred cell-type proportions to agree with those inferred from its
684+
neighborhood, yielding spatially smoother deconvolution.
685+
Optional; if ``None`` the regularization is disabled.
663686
%(param_batch_key)s
664687
"""
665688
setup_method_args = cls._get_setup_method_args(**locals())

src/scvi/module/_mrdeconv.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,10 @@ def loss(
520520
n_samples = self.n_samples_augmentation + 1
521521
m = x_augmented.shape[1]
522522

523-
if self.augmentation:
523+
# Augmentation tensors (prior_sampled, ratio_augmentation, ratios_ct_augmentation) are
524+
# only produced during training (see `_get_inference_input`, gated on `self.training`).
525+
# During validation they are `None`, so fall back to the static (non-augmented) prior.
526+
if self.augmentation and self.training:
524527
prior_sampled = inference_outputs["prior_sampled"].reshape(
525528
n_samples, 1, x_augmented.shape[1], self.n_labels, self.n_latent
526529
)
@@ -565,7 +568,7 @@ def loss(
565568
# beta loss
566569
glo_neg_log_likelihood_prior = -beta_reg * Normal(mean, scale).log_prob(self.beta).sum()
567570
loss_augmentation = torch.tensor(0.0, device=x_augmented.device)
568-
if self.augmentation:
571+
if self.augmentation and self.training:
569572
expected_proportions = torch.cat(
570573
[
571574
ratios_ct_augmentation,

tests/external/resolvi/test_resolvi.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ def test_resolvi_train(adata):
3030
)
3131

3232

33+
def test_resolvi_train_validation_unsupported(adata):
34+
# RESOLVI trains with Pyro SVI and per-cell global parameters, so it does not support a
35+
# validation set. `train_size != 1.0` previously raised a cryptic TypeError (collision with
36+
# the hardcoded `train_size=1.0`), and `early_stopping` has no validation set to monitor.
37+
# Both must now raise a clear ValueError, while the supported settings still train.
38+
RESOLVI.setup_anndata(adata)
39+
model = RESOLVI(adata)
40+
with pytest.raises(ValueError, match="train_size"):
41+
model.train(max_epochs=2, train_size=0.8)
42+
with pytest.raises(ValueError, match="early_stopping"):
43+
model.train(max_epochs=2, early_stopping=True)
44+
# explicit train_size=1.0 must not collide, and the default path still works
45+
model.train(max_epochs=2, train_size=1.0)
46+
47+
3348
def test_resolvi_train_size_factor(adata):
3449
RESOLVI.setup_anndata(adata, batch_key="batch", size_factor_key="cell_area")
3550
model = RESOLVI(adata, size_scaling=True)
@@ -95,6 +110,30 @@ def test_resolvi_downstream(adata):
95110
)
96111

97112

113+
def test_resolvi_normalized_expression_gene_list(adata):
114+
RESOLVI.setup_anndata(adata, size_factor_key="cell_area")
115+
model = RESOLVI(adata)
116+
model.train(
117+
max_epochs=2,
118+
)
119+
gene_list = adata.var_names[:3].tolist()
120+
121+
# both functions must honor `gene_list` and return only the requested subset
122+
expr = model.get_normalized_expression(n_samples=2, gene_list=gene_list)
123+
assert list(expr.columns) == gene_list
124+
assert expr.shape == (adata.n_obs, len(gene_list))
125+
126+
expr_imp = model.get_normalized_expression_importance(n_samples=30, gene_list=gene_list)
127+
assert list(expr_imp.columns) == gene_list
128+
assert expr_imp.shape == (adata.n_obs, len(gene_list))
129+
130+
# `transform_batch` is not supported by the importance estimator: it must be ignored
131+
# (not error) and warn the user, so that `differential_expression(weights="importance")`
132+
# can still call it.
133+
with pytest.warns(UserWarning, match="transform_batch.*ignored"):
134+
model.get_normalized_expression_importance(n_samples=30, transform_batch=0)
135+
136+
98137
def test_resolvi_downstream_size_scaling(adata):
99138
RESOLVI.setup_anndata(adata, size_factor_key="cell_area")
100139
model = RESOLVI(adata, size_scaling=True)

tests/model/test_destvi.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,44 @@ def test_destvi():
6161
spatial_model.get_normalized_expression()
6262

6363

64+
def test_destvi_validation():
65+
# DestVI must be trainable with a validation set: `early_stopping`,
66+
# `check_val_every_n_epoch` and `train_size < 1.0` previously crashed in the loss because the
67+
# augmentation branch was not gated on `self.training` (validation produces no augmentation
68+
# tensors). See `MRDeconv.loss`.
69+
n_latent = 2
70+
n_labels = 5
71+
dataset = synthetic_iid(n_labels=n_labels)
72+
CondSCVI.setup_anndata(dataset, labels_key="labels", batch_key="batch")
73+
sc_model = CondSCVI(dataset, n_latent=n_latent, n_layers=2, prior="mog", num_classes_mog=10)
74+
sc_model.train(1, train_size=0.9)
75+
76+
DestVI.setup_anndata(dataset, layer=None)
77+
78+
# train_size < 1.0 with validation evaluated every epoch
79+
model = DestVI.from_rna_model(dataset, sc_model, amortization="both")
80+
model.train(max_epochs=2, train_size=0.9, check_val_every_n_epoch=1)
81+
assert "validation_loss" in model.history
82+
assert not np.isnan(model.history["validation_loss"].values[0][0])
83+
84+
# early stopping (requires a working validation pass)
85+
model = DestVI.from_rna_model(dataset, sc_model, amortization="both")
86+
model.train(max_epochs=2, train_size=0.9, early_stopping=True)
87+
assert "validation_loss" in model.history
88+
89+
# Non-amortized spot parameters (here `V`, since amortization="latent") are only trained on the
90+
# training spots, so a validation set would be evaluated on randomly-initialized parameters.
91+
# Reject it. ("proportion"/"none" are not exercised here because mog priors require amortized
92+
# latents, but they hit the same `amortization != 'both'` guard.)
93+
model = DestVI.from_rna_model(dataset, sc_model, amortization="latent")
94+
with pytest.raises(ValueError, match="amortization='both'"):
95+
model.train(max_epochs=2, train_size=0.9)
96+
with pytest.raises(ValueError, match="amortization='both'"):
97+
model.train(max_epochs=2, early_stopping=True)
98+
# train_size=1.0 (all spots trained) must still work
99+
model.train(max_epochs=1)
100+
101+
64102
@pytest.mark.internet
65103
def test_destvi_new(save_path: str):
66104
CELL_TYPE_ID = "broad_cell_types"

0 commit comments

Comments
 (0)