novae 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
novae/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ import importlib.metadata
2
+ import logging
3
+
4
+ __version__ = importlib.metadata.version("novae")
5
+
6
+ from ._logging import configure_logger
7
+ from .model import Novae
8
+ from . import utils
9
+ from . import data
10
+ from . import monitor
11
+ from . import plot
12
+ from ._constants import settings
13
+
14
+ log = logging.getLogger("novae")
15
+ configure_logger(log)
novae/_constants.py ADDED
@@ -0,0 +1,87 @@
1
+ import numpy as np
2
+
3
+
4
+ class Keys:
5
+ # obs keys
6
+ LEAVES: str = "novae_leaves"
7
+ DOMAINS_PREFIX: str = "novae_domains_"
8
+ IS_VALID_OBS: str = "neighborhood_valid"
9
+ SLIDE_ID: str = "novae_sid"
10
+
11
+ # obsm keys
12
+ REPR: str = "novae_latent"
13
+ REPR_CORRECTED: str = "novae_latent_corrected"
14
+
15
+ # obsp keys
16
+ ADJ: str = "spatial_distances"
17
+ ADJ_LOCAL: str = "spatial_distances_local"
18
+ ADJ_PAIR: str = "spatial_distances_pair"
19
+
20
+ # var keys
21
+ VAR_MEAN: str = "mean"
22
+ VAR_STD: str = "std"
23
+ IS_KNOWN_GENE: str = "in_vocabulary"
24
+ HIGHLY_VARIABLE: str = "highly_variable"
25
+ USE_GENE: str = "novae_use_gene"
26
+
27
+ # layer keys
28
+ COUNTS_LAYER: str = "counts"
29
+
30
+ # misc keys
31
+ UNS_TISSUE: str = "novae_tissue"
32
+ ADATA_INDEX: str = "adata_index"
33
+ N_BATCHES: str = "n_batches"
34
+ NOVAE_VERSION: str = "novae_version"
35
+
36
+
37
+ class Nums:
38
+ # training constants
39
+ EPS: float = 1e-8
40
+ MIN_DATASET_LENGTH: int = 50_000
41
+ MAX_DATASET_LENGTH_RATIO: float = 0.02
42
+ DEFAULT_SAMPLE_CELLS: int = 100_000
43
+ WARMUP_EPOCHS: int = 1
44
+
45
+ # distances constants and thresholds (in microns)
46
+ CELLS_CHARACTERISTIC_DISTANCE: int = 20 # characteristic distance between two cells, in microns
47
+ DELAUNAY_RADIUS_TH: int = 100
48
+ MEAN_DISTANCE_UPPER_TH_WARNING: float = 50
49
+ MEAN_DISTANCE_LOWER_TH_WARNING: float = 4
50
+
51
+ # genes constants
52
+ N_HVG_THRESHOLD: int = 500
53
+ MIN_GENES_FOR_HVG: int = 100
54
+ MIN_GENES: int = 20
55
+
56
+ # swav head constants
57
+ SWAV_EPSILON: float = 0.05
58
+ SINKHORN_ITERATIONS: int = 3
59
+ QUEUE_SIZE: int = 5
60
+ QUEUE_WEIGHT_THRESHOLD: float = 0.99
61
+
62
+ # misc nums
63
+ MEAN_NGH_TH_WARNING: float = 3.5
64
+ N_OBS_THRESHOLD: int = 2_000_000 # above this number, lazy loading is used
65
+
66
+ def disable_lazy_loading(self):
67
+ """Disable lazy loading of subgraphs in the NovaeDataset."""
68
+ Nums.N_OBS_THRESHOLD = np.inf
69
+
70
+ def enable_lazy_loading(self, n_obs_threshold: int = 0):
71
+ """Enable lazy loading of subgraphs in the NovaeDataset.
72
+
73
+ Args:
74
+ n_obs_threshold: Lazy loading is used above this number of cells in an AnnData object.
75
+ """
76
+ Nums.N_OBS_THRESHOLD = n_obs_threshold
77
+
78
+ @property
79
+ def warmup_epochs(self):
80
+ return Nums.WARMUP_EPOCHS
81
+
82
+ @warmup_epochs.setter
83
+ def warmup_epochs(self, value: int):
84
+ Nums.WARMUP_EPOCHS = value
85
+
86
+
87
+ settings = Nums()
novae/_logging.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ log = logging.getLogger(__name__)
6
+
7
+
8
+ class ColorFormatter(logging.Formatter):
9
+ grey = "\x1b[38;20m"
10
+ blue = "\x1b[36;20m"
11
+ yellow = "\x1b[33;20m"
12
+ red = "\x1b[31;20m"
13
+ bold_red = "\x1b[31;1m"
14
+ reset = "\x1b[0m"
15
+
16
+ prefix = "[%(levelname)s] (%(name)s)"
17
+ suffix = "%(message)s"
18
+
19
+ FORMATS = {
20
+ logging.DEBUG: f"{grey}{prefix}{reset} {suffix}",
21
+ logging.INFO: f"{blue}{prefix}{reset} {suffix}",
22
+ logging.WARNING: f"{yellow}{prefix}{reset} {suffix}",
23
+ logging.ERROR: f"{red}{prefix}{reset} {suffix}",
24
+ logging.CRITICAL: f"{bold_red}{prefix}{reset} {suffix}",
25
+ }
26
+
27
+ def format(self, record):
28
+ log_fmt = self.FORMATS.get(record.levelno)
29
+ formatter = logging.Formatter(log_fmt)
30
+ return formatter.format(record)
31
+
32
+
33
+ def configure_logger(log: logging.Logger):
34
+ log.setLevel(logging.INFO)
35
+
36
+ consoleHandler = logging.StreamHandler()
37
+ consoleHandler.setFormatter(ColorFormatter())
38
+
39
+ log.addHandler(consoleHandler)
40
+ log.propagate = False
novae/data/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .convert import AnnDataTorch
2
+ from .dataset import NovaeDataset
3
+ from .datamodule import NovaeDatamodule
novae/data/convert.py ADDED
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+ from anndata import AnnData
6
+ from sklearn.preprocessing import LabelEncoder
7
+ from torch import Tensor
8
+
9
+ from .._constants import Keys, Nums
10
+ from ..module import CellEmbedder
11
+ from ..utils import sparse_std
12
+
13
+
14
+ class AnnDataTorch:
15
+ tensors: list[Tensor] | None
16
+ genes_indices_list: list[Tensor]
17
+
18
+ def __init__(self, adatas: list[AnnData], cell_embedder: CellEmbedder):
19
+ """Converting AnnData objects to PyTorch tensors.
20
+
21
+ Args:
22
+ adatas: A list of `AnnData` objects.
23
+ cell_embedder: A [novae.module.CellEmbedder][] object.
24
+ """
25
+ super().__init__()
26
+ self.adatas = adatas
27
+ self.cell_embedder = cell_embedder
28
+
29
+ self.genes_indices_list = [self._adata_to_genes_indices(adata) for adata in self.adatas]
30
+ self.tensors = None
31
+
32
+ self.means, self.stds, self.label_encoder = self._compute_means_stds()
33
+
34
+ # Tensors are loaded in memory for low numbers of cells
35
+ if sum(adata.n_obs for adata in self.adatas) < Nums.N_OBS_THRESHOLD:
36
+ self.tensors = [self.to_tensor(adata) for adata in self.adatas]
37
+
38
+ def _adata_to_genes_indices(self, adata: AnnData) -> Tensor:
39
+ return self.cell_embedder.genes_to_indices(adata.var_names[self._keep_var(adata)])[None, :]
40
+
41
+ def _keep_var(self, adata: AnnData) -> AnnData:
42
+ return adata.var[Keys.USE_GENE]
43
+
44
+ def _compute_means_stds(self) -> tuple[Tensor, Tensor, LabelEncoder]:
45
+ means, stds = {}, {}
46
+
47
+ for adata in self.adatas:
48
+ slide_ids = adata.obs[Keys.SLIDE_ID]
49
+ for slide_id in slide_ids.cat.categories:
50
+ adata_slide = adata[adata.obs[Keys.SLIDE_ID] == slide_id, self._keep_var(adata)]
51
+
52
+ mean = adata_slide.X.mean(0)
53
+ mean = mean.A1 if isinstance(mean, np.matrix) else mean
54
+ means[slide_id] = mean.astype(np.float32)
55
+
56
+ std = adata_slide.X.std(0) if isinstance(adata_slide.X, np.ndarray) else sparse_std(adata_slide.X, 0).A1
57
+ stds[slide_id] = std.astype(np.float32)
58
+
59
+ label_encoder = LabelEncoder()
60
+ label_encoder.fit(list(means.keys()))
61
+
62
+ means = [torch.tensor(means[slide_id]) for slide_id in label_encoder.classes_]
63
+ stds = [torch.tensor(stds[slide_id]) for slide_id in label_encoder.classes_]
64
+
65
+ return means, stds, label_encoder
66
+
67
+ def to_tensor(self, adata: AnnData, where_counts: Tensor | None = None) -> Tensor | tuple[Tensor, Tensor]:
68
+ """Get the normalized gene expressions of the cells in the dataset.
69
+ Only the genes of interest are kept (known genes and highly variable).
70
+
71
+ Args:
72
+ adata: An `AnnData` object.
73
+ where_counts: Where to keep expression as counts.
74
+
75
+ Returns:
76
+ A `Tensor` containing the normalized gene expresions.
77
+ """
78
+ adata = adata[:, self._keep_var(adata)]
79
+
80
+ if len(np.unique(adata.obs[Keys.SLIDE_ID])) == 1:
81
+ slide_id_index = self.label_encoder.transform([adata.obs.iloc[0][Keys.SLIDE_ID]])[0]
82
+ mean, std = self.means[slide_id_index], self.stds[slide_id_index]
83
+ else:
84
+ slide_id_indices = self.label_encoder.transform(adata.obs[Keys.SLIDE_ID])
85
+ mean = torch.stack([self.means[i] for i in slide_id_indices]) # TODO: avoid stack (only if not fast enough)
86
+ std = torch.stack([self.stds[i] for i in slide_id_indices])
87
+
88
+ X = adata.X if isinstance(adata.X, np.ndarray) else adata.X.toarray()
89
+ X = torch.tensor(X, dtype=torch.float32)
90
+ X = (X - mean) / (std + Nums.EPS)
91
+
92
+ if where_counts is None:
93
+ return X
94
+
95
+ counts = adata.layers[Keys.COUNTS_LAYER][:, where_counts]
96
+ counts = counts if isinstance(counts, np.ndarray) else counts.toarray()
97
+
98
+ return X[:, ~where_counts], torch.tensor(counts, dtype=torch.float32)
99
+
100
+ def __getitem__(self, item: tuple[int, slice]) -> tuple[Tensor, Tensor]:
101
+ """Get the expression values for a subset of cells (corresponding to a subgraph).
102
+
103
+ Args:
104
+ item: A `tuple` containing the index of the `AnnData` object and the indices of the cells in the neighborhoods.
105
+
106
+ Returns:
107
+ A `Tensor` of normalized gene expressions and a `Tensor` of gene indices.
108
+ """
109
+ adata_index, obs_indices = item
110
+
111
+ if self.tensors is not None:
112
+ return self.tensors[adata_index][obs_indices], self.genes_indices_list[adata_index]
113
+
114
+ adata = self.adatas[adata_index]
115
+ adata_view = adata[obs_indices]
116
+
117
+ return self.to_tensor(adata_view), self.genes_indices_list[adata_index]
118
+
119
+ def item_with_counts(
120
+ self, adata_index: int, obs_indices: slice, counts_ratio: float
121
+ ) -> tuple[Tensor, Tensor, Tensor]:
122
+ adata = self.adatas[adata_index]
123
+ adata_view = adata[obs_indices]
124
+
125
+ genes_indices = self.genes_indices_list[adata_index]
126
+
127
+ where_counts = _where_count(counts_ratio, len(genes_indices[0]))
128
+
129
+ X, counts = self.to_tensor(adata_view, where_counts)
130
+
131
+ return X, counts, genes_indices[:, ~where_counts], genes_indices[:, where_counts]
132
+
133
+
134
+ def _where_count(counts_ratio: float, n_vars: int) -> Tensor:
135
+ n_vars_counts = int(counts_ratio * n_vars)
136
+ where_counts = torch.cat(
137
+ [
138
+ torch.ones(n_vars_counts, dtype=torch.bool),
139
+ torch.zeros(n_vars - n_vars_counts, dtype=torch.bool),
140
+ ]
141
+ )
142
+ where_counts = where_counts[torch.randperm(where_counts.size(0))]
143
+ return where_counts
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import lightning as L
4
+ from anndata import AnnData
5
+ from torch_geometric.loader import DataLoader
6
+
7
+ from ..module import CellEmbedder
8
+ from . import NovaeDataset
9
+
10
+
11
+ class NovaeDatamodule(L.LightningDataModule):
12
+ """
13
+ Datamodule used for training and inference. Small wrapper around the [novae.data.NovaeDataset][]
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ adatas: list[AnnData],
19
+ cell_embedder: CellEmbedder,
20
+ batch_size: int,
21
+ n_hops_local: int,
22
+ n_hops_view: int,
23
+ num_workers: int = 0,
24
+ sample_cells: int | None = None,
25
+ counts_ratio: float | None = None,
26
+ ) -> None:
27
+ super().__init__()
28
+ self.dataset = NovaeDataset(
29
+ adatas,
30
+ cell_embedder=cell_embedder,
31
+ batch_size=batch_size,
32
+ n_hops_local=n_hops_local,
33
+ n_hops_view=n_hops_view,
34
+ sample_cells=sample_cells,
35
+ counts_ratio=counts_ratio,
36
+ )
37
+ self.batch_size = batch_size
38
+ self.num_workers = num_workers
39
+
40
+ def train_dataloader(self) -> DataLoader:
41
+ """Get a Pytorch dataloader for prediction.
42
+
43
+ Returns:
44
+ The training dataloader.
45
+ """
46
+ self.dataset.training = True
47
+ return DataLoader(
48
+ self.dataset,
49
+ batch_size=self.batch_size,
50
+ shuffle=False,
51
+ drop_last=True,
52
+ num_workers=self.num_workers,
53
+ )
54
+
55
+ def predict_dataloader(self) -> DataLoader:
56
+ """Get a Pytorch dataloader for prediction or inference.
57
+
58
+ Returns:
59
+ The prediction dataloader.
60
+ """
61
+ self.dataset.training = False
62
+ return DataLoader(
63
+ self.dataset,
64
+ batch_size=self.batch_size,
65
+ shuffle=False,
66
+ drop_last=False,
67
+ num_workers=self.num_workers,
68
+ )
novae/data/dataset.py ADDED
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import torch
6
+ from anndata import AnnData
7
+ from scipy.sparse import csr_matrix, lil_matrix
8
+ from torch.utils.data import Dataset
9
+ from torch_geometric.data import Data
10
+ from torch_geometric.utils.convert import from_scipy_sparse_matrix
11
+
12
+ from .. import utils
13
+ from .._constants import Keys, Nums
14
+ from ..module import CellEmbedder
15
+ from . import AnnDataTorch
16
+
17
+
18
+ class NovaeDataset(Dataset):
19
+ """
20
+ Dataset used for training and inference.
21
+
22
+ It extracts the the neighborhood of a cell, and convert it to PyTorch Geometric Data.
23
+
24
+ Attributes:
25
+ valid_indices (list[np.ndarray]): List containing, for each `adata`, an array that denotes the indices of the cells whose neighborhood is valid.
26
+ obs_ilocs (np.ndarray): An array of shape `(total_valid_indices, 2)`. The first column corresponds to the adata index, and the second column is the cell index for the corresponding adata.
27
+ shuffled_obs_ilocs (np.ndarray): same as obs_ilocs, but shuffled. Each batch will contain cells from the same slide.
28
+ """
29
+
30
+ valid_indices: list[np.ndarray]
31
+ obs_ilocs: np.ndarray
32
+ shuffled_obs_ilocs: np.ndarray
33
+
34
+ @utils.format_docs
35
+ def __init__(
36
+ self,
37
+ adatas: list[AnnData],
38
+ cell_embedder: CellEmbedder,
39
+ batch_size: int,
40
+ n_hops_local: int,
41
+ n_hops_view: int,
42
+ sample_cells: int | None = None,
43
+ counts_ratio: float | None = None,
44
+ ) -> None:
45
+ """_summary_
46
+
47
+ Args:
48
+ adatas: A list of `AnnData` objects.
49
+ cell_embedder: A [novae.module.CellEmbedder][] object.
50
+ batch_size: The model batch size.
51
+ {n_hops_local}
52
+ {n_hops_view}
53
+ sample_cells: If not None, the dataset if used to sample the subgraphs from precisely `sample_cells` cells.
54
+ """
55
+ super().__init__()
56
+ self.adatas = adatas
57
+ self.cell_embedder = cell_embedder
58
+ self.anndata_torch = AnnDataTorch(self.adatas, self.cell_embedder)
59
+
60
+ self.training = False
61
+
62
+ self.batch_size = batch_size
63
+ self.n_hops_local = n_hops_local
64
+ self.n_hops_view = n_hops_view
65
+ self.sample_cells = sample_cells
66
+ self.counts_ratio = counts_ratio
67
+
68
+ self.single_adata = len(self.adatas) == 1
69
+ self.single_slide_mode = self.single_adata and len(np.unique(self.adatas[0].obs[Keys.SLIDE_ID])) == 1
70
+
71
+ self._init_dataset()
72
+
73
+ def __repr__(self) -> str:
74
+ multi_slide_mode, multi_adata = not self.single_slide_mode, not self.single_adata
75
+ n_samples = sum(len(indices) for indices in self.valid_indices)
76
+ return f"{self.__class__.__name__} with {n_samples} samples ({multi_slide_mode=}, {multi_adata=})"
77
+
78
+ def _init_dataset(self):
79
+ for adata in self.adatas:
80
+ adjacency: csr_matrix = adata.obsp[Keys.ADJ]
81
+
82
+ if Keys.ADJ_LOCAL not in adata.obsp:
83
+ adata.obsp[Keys.ADJ_LOCAL] = _to_adjacency_local(adjacency, self.n_hops_local)
84
+ if Keys.ADJ_PAIR not in adata.obsp:
85
+ adata.obsp[Keys.ADJ_PAIR] = _to_adjacency_view(adjacency, self.n_hops_view)
86
+ if Keys.IS_VALID_OBS not in adata.obs:
87
+ adata.obs[Keys.IS_VALID_OBS] = adata.obsp[Keys.ADJ_PAIR].sum(1).A1 > 0
88
+
89
+ self.valid_indices = [utils.valid_indices(adata) for adata in self.adatas]
90
+
91
+ self.obs_ilocs = None
92
+ if self.single_adata:
93
+ self.obs_ilocs = np.array([(0, obs_index) for obs_index in self.valid_indices[0]])
94
+
95
+ self.slides_metadata: pd.DataFrame = pd.concat(
96
+ [
97
+ self._adata_slides_metadata(adata_index, obs_indices)
98
+ for adata_index, obs_indices in enumerate(self.valid_indices)
99
+ ],
100
+ axis=0,
101
+ )
102
+
103
+ self.shuffle_obs_ilocs()
104
+
105
+ def __len__(self) -> int:
106
+ if self.sample_cells is not None:
107
+ return min(self.sample_cells, len(self.shuffled_obs_ilocs))
108
+
109
+ if self.training:
110
+ n_obs = len(self.shuffled_obs_ilocs)
111
+ return min(n_obs, max(Nums.MIN_DATASET_LENGTH, int(n_obs * Nums.MAX_DATASET_LENGTH_RATIO)))
112
+
113
+ assert self.single_adata, "Multi-adata mode not supported for inference"
114
+
115
+ return len(self.obs_ilocs)
116
+
117
+ def __getitem__(self, index: int) -> dict[str, Data]:
118
+ """Gets a sample from the dataset, with one "main" graph and its corresponding "view" graph (only during training).
119
+
120
+ Args:
121
+ index: Index of the sample to retrieve.
122
+
123
+ Returns:
124
+ A dictionnary whose keys are names, and values are PyTorch Geometric `Data` objects. The `"view"` graph is only provided during training.
125
+ """
126
+ if self.training or self.sample_cells is not None:
127
+ adata_index, obs_index = self.shuffled_obs_ilocs[index]
128
+ else:
129
+ adata_index, obs_index = self.obs_ilocs[index]
130
+
131
+ data = self.to_pyg_data(adata_index, obs_index)
132
+
133
+ if not self.training or self.counts_ratio is not None:
134
+ return {"main": data}
135
+
136
+ adjacency_pair: csr_matrix = self.adatas[adata_index].obsp[Keys.ADJ_PAIR]
137
+ cell_view_index = np.random.choice(list(adjacency_pair[obs_index].indices), size=1)[0]
138
+
139
+ return {"main": data, "view": self.to_pyg_data(adata_index, cell_view_index)}
140
+
141
+ def to_pyg_data(self, adata_index: int, obs_index: int) -> Data:
142
+ """Create a PyTorch Geometric Data object for the input cell
143
+
144
+ Args:
145
+ adata_index: The index of the `AnnData` object
146
+ obs_index: The index of the input cell for the corresponding `AnnData` object
147
+
148
+ Returns:
149
+ A Data object
150
+ """
151
+ adata = self.adatas[adata_index]
152
+ adjacency_local: csr_matrix = adata.obsp[Keys.ADJ_LOCAL]
153
+ obs_indices = adjacency_local[obs_index].indices
154
+
155
+ adjacency: csr_matrix = adata.obsp[Keys.ADJ]
156
+ edge_index, edge_weight = from_scipy_sparse_matrix(adjacency[obs_indices][:, obs_indices])
157
+ edge_attr = edge_weight[:, None].to(torch.float32) / Nums.CELLS_CHARACTERISTIC_DISTANCE
158
+
159
+ if self.counts_ratio is None or not self.training:
160
+ x, genes_indices = self.anndata_torch[adata_index, obs_indices]
161
+ counts, counts_genes_indices = None, None
162
+ else:
163
+ x, counts, genes_indices, counts_genes_indices = self.anndata_torch.item_with_counts(
164
+ adata_index, obs_indices, self.counts_ratio
165
+ )
166
+
167
+ return Data(
168
+ x=x,
169
+ edge_index=edge_index,
170
+ edge_attr=edge_attr,
171
+ genes_indices=genes_indices,
172
+ slide_id=adata.obs[Keys.SLIDE_ID].iloc[0],
173
+ counts=counts,
174
+ counts_genes_indices=counts_genes_indices,
175
+ )
176
+
177
+ def shuffle_obs_ilocs(self):
178
+ """Shuffle the indices of the cells to be used in the dataset (for training only)."""
179
+ if self.single_slide_mode:
180
+ self.shuffled_obs_ilocs = self.obs_ilocs[np.random.permutation(len(self.obs_ilocs))]
181
+ return
182
+
183
+ adata_indices = np.empty((0, self.batch_size), dtype=int)
184
+ batched_obs_indices = np.empty((0, self.batch_size), dtype=int)
185
+
186
+ for uid in self.slides_metadata.index:
187
+ adata_index = self.slides_metadata.loc[uid, Keys.ADATA_INDEX]
188
+ adata = self.adatas[adata_index]
189
+ _obs_indices = np.where((adata.obs[Keys.SLIDE_ID] == uid) & adata.obs[Keys.IS_VALID_OBS])[0]
190
+ _obs_indices = np.random.permutation(_obs_indices)
191
+
192
+ n_elements = self.slides_metadata.loc[uid, Keys.N_BATCHES] * self.batch_size
193
+ if len(_obs_indices) >= n_elements:
194
+ _obs_indices = _obs_indices[:n_elements]
195
+ else:
196
+ _obs_indices = np.random.choice(_obs_indices, size=n_elements)
197
+
198
+ _obs_indices = _obs_indices.reshape((-1, self.batch_size))
199
+
200
+ adata_indices = np.concatenate([adata_indices, np.full_like(_obs_indices, adata_index)], axis=0)
201
+ batched_obs_indices = np.concatenate([batched_obs_indices, _obs_indices], axis=0)
202
+
203
+ permutation = np.random.permutation(len(batched_obs_indices))
204
+ adata_indices = adata_indices[permutation].flatten()
205
+ obs_indices = batched_obs_indices[permutation].flatten()
206
+
207
+ self.shuffled_obs_ilocs = np.stack([adata_indices, obs_indices], axis=1)
208
+
209
+ def _adata_slides_metadata(self, adata_index: int, obs_indices: list[int]) -> pd.DataFrame:
210
+ obs_counts: pd.Series = self.adatas[adata_index].obs.iloc[obs_indices][Keys.SLIDE_ID].value_counts()
211
+ slides_metadata = obs_counts.to_frame()
212
+ slides_metadata[Keys.ADATA_INDEX] = adata_index
213
+ slides_metadata[Keys.N_BATCHES] = (slides_metadata["count"] // self.batch_size).clip(1)
214
+ return slides_metadata
215
+
216
+
217
+ def _to_adjacency_local(adjacency: csr_matrix, n_hops_local: int) -> csr_matrix:
218
+ """
219
+ Creates an adjancency matrix for which all nodes
220
+ at a distance inferior to `n_hops_local` are linked.
221
+ """
222
+ assert n_hops_local >= 1, f"n_hops_local must be greater than 0. Found {n_hops_local}."
223
+
224
+ adjacency_local: lil_matrix = adjacency.copy().tolil()
225
+ adjacency_local.setdiag(1)
226
+ for _ in range(n_hops_local - 1):
227
+ adjacency_local = adjacency_local @ adjacency
228
+ return adjacency_local.tocsr()
229
+
230
+
231
+ def _to_adjacency_view(adjacency: csr_matrix, n_hops_view: int) -> csr_matrix:
232
+ """
233
+ Creates an adjacancy matrix for which all nodes separated by
234
+ precisely `n_hops_view` nodes are linked.
235
+ """
236
+ assert n_hops_view >= 1, f"n_hops_view must be greater than 0. Found {n_hops_view}."
237
+
238
+ if n_hops_view == 1:
239
+ adjacency_pair = adjacency.copy()
240
+ adjacency_pair.setdiag(0)
241
+ adjacency_pair.eliminate_zeros()
242
+ return adjacency_pair
243
+
244
+ adjacency_pair = adjacency.copy()
245
+ adjacency_pair.setdiag(1)
246
+ for i in range(n_hops_view - 1):
247
+ if i == n_hops_view - 2:
248
+ adjacency_previous = adjacency_pair.copy()
249
+ adjacency_pair = adjacency_pair @ adjacency
250
+ adjacency_pair = adjacency_pair.tolil()
251
+ adjacency_pair[adjacency_previous.nonzero()] = 0
252
+ adjacency_pair: csr_matrix = adjacency_pair.tocsr()
253
+ adjacency_pair.eliminate_zeros()
254
+ return adjacency_pair