Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.0.0
current_version = 1.1.1
commit = True
tag = True

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [1.1.1] — 2026-09-05
### Removed
- **Breaking**: `condition_on_intrinsic` parameter removed from `Cellina`,
`CellinaModule`, `CellinaGCN`, and `CellinaGCNModule`; behaviour is now always
Expand All @@ -27,6 +28,11 @@ All notable changes to this project will be documented in this file.
- Explicitly stored zeros in the spatial connectivity matrix (e.g. from
in-place weight thresholding) no longer become message-passing edges; they
are dropped via `eliminate_zeros()` on a copy, leaving `adata.obsp` untouched.
- `CellinaGCN` counterfactual loaders now add donor -> seed edges only instead of
bidirectional seed <-> donor edges. With multi-hop sampling, the reverse edges let
donors aggregate over the (control) seeds and pulled counterfactuals back toward
the control state. Donor draws use the same RNG stream, so `seed` still reproduces
the same donor sets; predictions change.

## [1.0.0] — 2026-06-04 - Release

Expand Down
69 changes: 34 additions & 35 deletions docs/tutorial.ipynb

Large diffs are not rendered by default.

58 changes: 26 additions & 32 deletions docs/tutorial_gat.ipynb

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ requires = ["hatchling"]

[project]
name = "cellina"
version = "1.1.0"
version = "1.1.1"
description = "Cellina: Dual-encoder VAE for spatial transcriptomics with adversarial domain forgetting"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
24 changes: 11 additions & 13 deletions src/cellina/_cellina_gcn_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,18 +215,15 @@ def _make_counterfactual_loader(
f"len(neighbour_indices) ({len(neighbour_indices)})"
)

cf_src_parts, cf_dst_parts = [], []
for s in indices:
chosen = rng.choice(neighbour_indices, size=n_neighbors_per_seed, replace=False)
cf_src_parts.append(np.full(n_neighbors_per_seed, s))
cf_dst_parts.append(chosen)
cf_src = np.concatenate(cf_src_parts)
cf_dst = np.concatenate(cf_dst_parts)

cf_edges = np.stack([
np.concatenate([cf_src, cf_dst]),
np.concatenate([cf_dst, cf_src]),
], axis=0)
# Same RNG stream / draw order as before, so seeds reproduce the same donor sets.
donors = [rng.choice(neighbour_indices, size=n_neighbors_per_seed, replace=False)
for _ in indices]
cf_src = np.repeat(indices, n_neighbors_per_seed)
cf_dst = np.concatenate(donors)

# Donor -> seed only. Bidirectional edges let donors aggregate over the (control)
# seeds under multi-hop sampling, pulling the counterfactual back toward control.
cf_edges = np.stack([cf_dst, cf_src], axis=0)

new_edge_index = np.concatenate([filtered_edges, cf_edges], axis=1)
new_edge_index = torch.tensor(new_edge_index, dtype=torch.long)
Expand Down Expand Up @@ -715,7 +712,8 @@ def _resolve_num_neighbors(num_neighbors: Optional[List[int]], n_layers: int) ->
- any other length -> ``UserWarning``
"""
if num_neighbors is None:
return [-1] * n_layers
# NOTE: this was the number of neighbors that did best in our benchmarks
return [-20] * n_layers
num_neighbors = list(num_neighbors)
if len(num_neighbors) != n_layers:
warnings.warn(
Expand Down
20 changes: 19 additions & 1 deletion tests/test_cellina_gcn.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def test_num_neighbors_resolution(adata_with_spatial):
with warnings.catch_warnings():
warnings.simplefilter("error", UserWarning)
model_default = CellinaGCN(adata_with_spatial, n_latent=5, n_layers=3)
assert model_default._num_neighbors == [-1, -1, -1]
assert model_default._num_neighbors == [-20, -20, -20]

# Length 1 != n_layers -> warns, used as-is (no broadcast).
with pytest.warns(UserWarning):
Expand Down Expand Up @@ -1036,3 +1036,21 @@ def test_counterfactual_rejects_oversized_neighbourhood(trained_model):
donors = np.arange(60, 70)
with pytest.raises(ValueError, match="must be less than"):
model.get_counterfactual_latents(np.arange(10), donors, n_neighbors_per_seed=len(donors))


def test_counterfactual_edges_are_donor_to_seed_only(trained_model):
"""Counterfactual rewiring adds donor -> seed edges only; no edge may point at a donor from a seed."""
model, adata = trained_model
indices = np.arange(10)
donors = np.arange(60, adata.n_obs)
k = 5
loader = model._make_counterfactual_loader(indices, donors, n_neighbors_per_seed=k, batch_size=10, seed=0)
src, dst = loader.node_loader.data.edge_index.numpy()
seed_src, seed_dst = np.isin(src, indices), np.isin(dst, indices)
assert not seed_src.any(), "seeds must not be message sources (all seed edges were dropped)"
assert seed_dst.sum() == len(indices) * k
assert np.isin(src[seed_dst], donors).all()
# seed sets are reproducible and drawn from the same stream as before the fix
rng = np.random.default_rng(0)
expected = np.concatenate([rng.choice(donors, size=k, replace=False) for _ in indices])
np.testing.assert_array_equal(np.sort(src[seed_dst]), np.sort(expected))
Loading