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 +15 -0
- novae/_constants.py +87 -0
- novae/_logging.py +40 -0
- novae/data/__init__.py +3 -0
- novae/data/convert.py +143 -0
- novae/data/datamodule.py +68 -0
- novae/data/dataset.py +254 -0
- novae/model.py +561 -0
- novae/module/__init__.py +6 -0
- novae/module/aggregate.py +36 -0
- novae/module/augment.py +78 -0
- novae/module/embed.py +181 -0
- novae/module/encode.py +57 -0
- novae/module/infer.py +181 -0
- novae/module/swav.py +265 -0
- novae/monitor/__init__.py +1 -0
- novae/monitor/callback.py +80 -0
- novae/monitor/eval.py +231 -0
- novae/monitor/log.py +13 -0
- novae/plot/__init__.py +3 -0
- novae/plot/_graph.py +66 -0
- novae/plot/_heatmap.py +49 -0
- novae/plot/_spatial.py +107 -0
- novae/utils/__init__.py +21 -0
- novae/utils/_build.py +195 -0
- novae/utils/_correct.py +75 -0
- novae/utils/_data.py +665 -0
- novae/utils/_docs.py +33 -0
- novae/utils/_mode.py +43 -0
- novae/utils/_utils.py +140 -0
- novae/utils/_validate.py +254 -0
- novae-0.0.1.dist-info/LICENSE +29 -0
- novae-0.0.1.dist-info/METADATA +33 -0
- novae-0.0.1.dist-info/RECORD +35 -0
- novae-0.0.1.dist-info/WHEEL +4 -0
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
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
|
novae/data/datamodule.py
ADDED
|
@@ -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
|