bowside 0.1.0__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.
bowside/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("bowside")
5
+ except PackageNotFoundError:
6
+ pass
bowside/dataset.py ADDED
@@ -0,0 +1,535 @@
1
+ """Module for datasets to be used to train an ODE GRN model."""
2
+
3
+ from collections.abc import Sequence
4
+ from math import floor
5
+ from typing import Optional, Tuple, Union
6
+
7
+ import anndata as ad
8
+ import numpy as np
9
+ import pandas as pd
10
+ import torch
11
+ from numpy.typing import NDArray
12
+ from scipy import sparse
13
+ from torch.utils.data import Dataset
14
+
15
+ from bowside.model import BifurcatingSDE
16
+ from bowside.sampling import MarkovSampler
17
+
18
+
19
+ class MarkovChainDataset(Dataset):
20
+ """
21
+ A pytorch dataset to generate Markov chain cell sequences, ready for
22
+ training an ODE or SDE model.
23
+
24
+ Parameters
25
+ ----------
26
+ adata : ad.AnnData
27
+ AnnData object.
28
+ layer : Optional[str], optional
29
+ Layer of the AnnData object to use as the gene expression values.
30
+ If `None`, use adata.X. Default: None.
31
+ mask_var : Optional[Union[NDArray[np.bool_], str]], optional
32
+ Only use a subset of genes given by a boolean array or a string
33
+ referring to a column in `adata.var`. Default: "highly_variable".
34
+ When set to None, use all genes.
35
+ tpm_key : str, optional
36
+ Key of the transition probability matrix in `adata.obsp` or
37
+ `adata.uns`. Default: "T_forward".
38
+ recompute_matrix : bool, optional
39
+ Whether the matrix should be recomputed. Default: False.
40
+ self_transitions : bool, optional
41
+ Whether the matrix should be computed with self-transitions
42
+ enabled. Default: False.
43
+ num_chains : int, optional
44
+ Number of Markov chains to simulate. Default: 1,000.
45
+ max_iter : int, optional
46
+ Maximum number of iterations for Markov chains. Default: 1,000.
47
+ rtol : float, optional
48
+ Relative tolerance for convergence. Default: 1e-5.
49
+ atol : float, optional
50
+ Absolute tolerance for convergence. Default: 1e-5.
51
+ n_jobs : int, optional
52
+ Number of threads used for Markov sampling when running on CPU.
53
+ Default: -1 (all available threads).
54
+ seed : int, optional
55
+ Random seed. Default: 42.
56
+ copy : bool, optional
57
+ Whether the AnnData object should be copied or modified inplace.
58
+ Default: False (modify inplace).
59
+ """
60
+
61
+ adata: ad.AnnData
62
+ """AnnData object for sampling cell sequences and getting gene expression."""
63
+ X: torch.Tensor
64
+ """Gene expression tensor of shape (n_cells, n_genes)."""
65
+ sampler: MarkovSampler
66
+ """Instance of Markov chain sampler for sampling cell sequences."""
67
+ markov_chains: torch.Tensor
68
+ """Markov chains of cell sequences, represented by their index."""
69
+ gene_names: pd.Index
70
+ """Gene names index of shape (n_genes,)."""
71
+
72
+ def __init__(
73
+ self,
74
+ adata: ad.AnnData,
75
+ layer: Optional[str] = None,
76
+ mask_var: Optional[Union[NDArray[np.bool_], str]] = "highly_variable",
77
+ tpm_key: str = "T_forward",
78
+ recompute_matrix: bool = False,
79
+ self_transitions: bool = False,
80
+ num_chains: int = 1_000,
81
+ max_iter: int = 1_000,
82
+ rtol: float = 1e-5,
83
+ atol: float = 1e-5,
84
+ n_jobs: int = -1,
85
+ seed: int = 42,
86
+ copy: bool = False,
87
+ ):
88
+ # Prepare expression data
89
+ self.adata = adata.copy() if copy else adata
90
+ X = self.adata.X if layer is None else self.adata.layers[layer]
91
+ if X is None:
92
+ raise ValueError(
93
+ "Invalid gene expression matrix. Make sure that adata.X is present."
94
+ )
95
+ self.gene_names = self.adata.var_names
96
+ if mask_var is not None:
97
+ if isinstance(mask_var, str):
98
+ if adata.var[mask_var].dtype != bool:
99
+ raise TypeError(
100
+ f"Expected adata.var[mask_var] to be of type bool, but got type {adata.var[mask_var].dtype}"
101
+ )
102
+ mask_var = self.adata.var[mask_var].to_numpy()
103
+ X = X[:, mask_var]
104
+ self.gene_names = self.gene_names[mask_var]
105
+ if isinstance(
106
+ X,
107
+ (sparse.csc_array, sparse.csr_array, sparse.csc_matrix, sparse.csr_matrix),
108
+ ):
109
+ X = X.toarray()
110
+ self.X = torch.from_numpy(X)
111
+
112
+ # Sample Markov chains
113
+ self.sampler = MarkovSampler(
114
+ adata=adata,
115
+ tpm_key=tpm_key,
116
+ recompute_matrix=recompute_matrix,
117
+ self_transitions=self_transitions,
118
+ copy=False,
119
+ )
120
+ self.sampler.sample_markov_chains(
121
+ num_chains=num_chains,
122
+ max_iter=max_iter,
123
+ rtol=rtol,
124
+ atol=atol,
125
+ n_jobs=n_jobs,
126
+ seed=seed,
127
+ )
128
+ self.markov_chains = torch.from_numpy(
129
+ self.adata.uns["markov_sampling"]["cell_sequences"]
130
+ )
131
+
132
+ def __len__(self) -> int:
133
+ return self.markov_chains.shape[0]
134
+
135
+ def __getitem__(self, idx) -> torch.Tensor:
136
+ return self.X[self.markov_chains[idx, :], :]
137
+
138
+ def n_genes(self) -> int:
139
+ """
140
+ Get the number of genes in the dataset.
141
+
142
+ Returns
143
+ -------
144
+ int
145
+ Number of genes.
146
+ """
147
+ return len(self.gene_names)
148
+
149
+
150
+ class PseudotimeDataset(Dataset):
151
+ """
152
+ A pytorch dataset to sample cells along (pseudo) time, ready for
153
+ training an ODE or SDE model with a distributional loss.
154
+
155
+ Parameters
156
+ ----------
157
+ adata : ad.AnnData
158
+ AnnData object.
159
+ layer : Optional[str], optional
160
+ Layer of the AnnData object to use as the gene expression values.
161
+ If `None`, use adata.X. Default: None.
162
+ mask_var : Optional[Union[NDArray[np.bool_], str]], optional
163
+ Only use a subset of genes given by a boolean array or a string
164
+ referring to a column in `adata.var`. Default: "highly_variable".
165
+ When set to None, use all genes.
166
+ pseudotime_key : str, optional
167
+ Key of the (pseudo) time annotation in `adata.obs`. Default:
168
+ "pseudotime".
169
+ num_cells : int, optional
170
+ Number of cells to sample for each time point. Default: 1,000.
171
+ num_quantiles : int, optional
172
+ Number of quantiles for generating for sampling cells along
173
+ (pseudo) time. Default: 20.
174
+ time_points : Optional[Union[NDArray[np.bool_], str]], option
175
+ Explicit time points to use for sampling cells. Overrides
176
+ `num_quantiles`. Default: None (use quantiles along pseudottime).
177
+ seed : int, optional
178
+ Random seed. Default: 42.
179
+ copy : bool, optional
180
+ Whether the AnnData object should be copied or modified inplace.
181
+ Default: False (modify inplace).
182
+ """
183
+
184
+ adata: ad.AnnData
185
+ """AnnData object for sampling cell sequences and getting gene expression."""
186
+ X: torch.Tensor
187
+ """Gene expression tensor of shape (n_cells, n_genes)."""
188
+ sampled_cells: torch.Tensor
189
+ """Sampled cell indices of shape (num_cells, len(self.time_points))."""
190
+ gene_names: pd.Index
191
+ """Gene names index of shape (n_genes,)."""
192
+ pseudotime: torch.Tensor
193
+ """Tensor of shape (n_cells,) with pseudotime of each cell in `adata`."""
194
+ time_points: torch.Tensor
195
+ """Tensor of shape (num_quantiles,) with the quantile means."""
196
+
197
+ def __init__(
198
+ self,
199
+ adata: ad.AnnData,
200
+ layer: Optional[str] = None,
201
+ mask_var: Optional[Union[NDArray[np.bool_], str]] = "highly_variable",
202
+ pseudotime_key: str = "pseudotime",
203
+ num_cells: int = 1_000,
204
+ num_quantiles: int = 20,
205
+ time_points: Optional[Sequence[Union[int, float]]] = None,
206
+ seed: int = 42,
207
+ copy: bool = False,
208
+ ):
209
+ if pseudotime_key not in adata.obs:
210
+ raise KeyError("Pseudotime key not found in adata.obs.")
211
+ # Prepare expression data
212
+ self.adata = adata.copy() if copy else adata
213
+ X = self.adata.X if layer is None else self.adata.layers[layer]
214
+ if X is None:
215
+ raise ValueError(
216
+ "Invalid gene expression matrix. Make sure that adata.X is present."
217
+ )
218
+ self.gene_names = self.adata.var_names
219
+ if mask_var is not None:
220
+ if isinstance(mask_var, str):
221
+ if adata.var[mask_var].dtype != bool:
222
+ raise TypeError(
223
+ f"Expected adata.var[mask_var] to be of type bool, but got type {adata.var[mask_var].dtype}"
224
+ )
225
+ mask_var = self.adata.var[mask_var].to_numpy()
226
+ X = X[:, mask_var]
227
+ self.gene_names = self.gene_names[mask_var]
228
+ if isinstance(
229
+ X,
230
+ (sparse.csc_array, sparse.csr_array, sparse.csc_matrix, sparse.csr_matrix),
231
+ ):
232
+ X = X.toarray()
233
+ self.X = torch.from_numpy(X)
234
+
235
+ # Sample cells at time points
236
+ pseudotime: pd.Series = adata.obs[pseudotime_key]
237
+ if time_points is None:
238
+ # Use quantile center as bin value
239
+ pseudotime = pd.qcut(pseudotime, num_quantiles).apply(
240
+ lambda x: (x.left + x.right) / 2
241
+ )
242
+ time_points = sorted(pseudotime.cat.categories)
243
+ self.pseudotime = torch.from_numpy(pseudotime.to_numpy())
244
+ self.time_points = torch.from_numpy(np.asarray(time_points))
245
+ self.sampled_cells = torch.zeros(
246
+ num_cells, len(self.time_points), dtype=torch.int
247
+ )
248
+ integer_idx = torch.arange(len(self.adata))
249
+ generator = torch.Generator()
250
+ generator.manual_seed(seed)
251
+ for i, tp in enumerate(time_points):
252
+ tp_idx = integer_idx[self.pseudotime == tp]
253
+ self.sampled_cells[:, i] = tp_idx[
254
+ torch.randint(len(tp_idx), (num_cells,), generator=generator)
255
+ ]
256
+
257
+ def __len__(self) -> int:
258
+ return self.sampled_cells.shape[0]
259
+
260
+ def __getitem__(self, idx) -> torch.Tensor:
261
+ return self.X[self.sampled_cells[idx, :], :]
262
+
263
+ def n_genes(self) -> int:
264
+ """
265
+ Get the number of genes in the dataset.
266
+
267
+ Returns
268
+ -------
269
+ int
270
+ Number of genes.
271
+ """
272
+ return len(self.gene_names)
273
+
274
+
275
+ class VelocityDataset(Dataset):
276
+ """
277
+ A pytorch dataset containing spliced expression and RNA velocity,
278
+ ready for training an ODE model with gradient matching.
279
+
280
+ Parameters
281
+ ----------
282
+ adata : ad.AnnData
283
+ AnnData object.
284
+ layer : str, optional
285
+ Layer of the AnnData object to use as spliced expression values.
286
+ By default "Ms".
287
+ mask_var : Optional[Union[NDArray[np.bool_], str]], optional
288
+ Only use a subset of genes given by a boolean array or a string
289
+ referring to a column in `adata.var`. Default: "highly_variable".
290
+ When set to None, use all genes.
291
+ vkey : str, optional
292
+ Name of velocity estimates to be used. By default "velocity".
293
+ """
294
+
295
+ adata: ad.AnnData
296
+ """AnnData object for sampling cell sequences and getting gene expression."""
297
+ X: torch.Tensor
298
+ """Spliced expression tensor of shape (n_cells, n_genes)."""
299
+ V: torch.Tensor
300
+ """RNA velocity tensor of shape (n_cells, n_genes)"""
301
+ gene_names: pd.Index
302
+ """Gene names index of shape (n_genes,)."""
303
+
304
+ def __init__(
305
+ self,
306
+ adata: ad.AnnData,
307
+ layer: Optional[str] = "Ms",
308
+ mask_var: Optional[Union[NDArray[np.bool_], str]] = "highly_variable",
309
+ vkey: str = "velocity",
310
+ ):
311
+ # Prepare expression data
312
+ self.adata = adata
313
+ X = self.adata.X if layer is None else self.adata.layers[layer]
314
+ if X is None:
315
+ raise ValueError(
316
+ "Invalid gene expression matrix. Make sure that adata.X is present."
317
+ )
318
+ V = self.adata.layers[vkey]
319
+ self.gene_names = adata.var_names
320
+ if mask_var is not None:
321
+ if isinstance(mask_var, str):
322
+ if adata.var[mask_var].dtype != bool:
323
+ raise TypeError(
324
+ f"Expected adata.var[mask_var] to be of type bool, but got type {adata.var[mask_var].dtype}"
325
+ )
326
+ mask_var = self.adata.var[mask_var].to_numpy()
327
+ X = X[:, mask_var]
328
+ V = V[:, mask_var]
329
+ self.gene_names = self.gene_names[mask_var]
330
+ if isinstance(
331
+ X,
332
+ (sparse.csc_array, sparse.csr_array, sparse.csc_matrix, sparse.csr_matrix),
333
+ ):
334
+ X = X.toarray()
335
+ self.X = torch.from_numpy(X).to(torch.float32)
336
+ self.V = torch.from_numpy(V).to(torch.float32)
337
+
338
+ def __len__(self) -> int:
339
+ return self.X.shape[0]
340
+
341
+ def __getitem__(self, idx) -> Tuple[torch.Tensor, torch.Tensor]:
342
+ return self.X[idx, :], self.V[idx, :]
343
+
344
+ def n_genes(self) -> int:
345
+ """
346
+ Get the number of genes in the dataset.
347
+
348
+ Returns
349
+ -------
350
+ int
351
+ Number of genes.
352
+ """
353
+ return len(self.gene_names)
354
+
355
+
356
+ class BifurcatingMarkovChainDataset(MarkovChainDataset):
357
+ """
358
+ Dummy Markov chain dataset simulating an SDE with a defined
359
+ adjacency matrix instead of using a single-cell dataset.
360
+ """
361
+
362
+ def __init__(self, y0: torch.Tensor):
363
+ from bowside.training import integrate_model
364
+
365
+ self.sde = BifurcatingSDE()
366
+ n_steps = 50
367
+ self.time_points = torch.linspace(
368
+ 0, n_steps - 1, n_steps, dtype=torch.float32, device=y0.device
369
+ )
370
+ simulations = integrate_model(
371
+ self.sde, y0, time_points=self.time_points, dt=0.1
372
+ )
373
+ self.X = simulations.reshape(-1, simulations.shape[-1])
374
+ self.markov_chains = (
375
+ torch.arange(self.X.shape[0])
376
+ .reshape(simulations.shape[0], simulations.shape[1])
377
+ .permute(1, 0)
378
+ .contiguous()
379
+ )
380
+ self.gene_names = pd.Index([f"gene_{i}" for i in range(simulations.size(2))])
381
+ self.adata = ad.AnnData(
382
+ X=self.X.detach().cpu().numpy(), var=pd.DataFrame(index=self.gene_names)
383
+ )
384
+
385
+
386
+ class BifurcatingPseudotimeDataset(PseudotimeDataset):
387
+ """
388
+ Dummy pseudotime dataset simulating an SDE with a defined adjacency
389
+ matrix instead of using a single-cell dataset.
390
+ """
391
+
392
+ def __init__(self, y0: torch.Tensor):
393
+ from bowside.training import integrate_model
394
+
395
+ self.sde = BifurcatingSDE()
396
+ n_steps = 50
397
+ self.time_points = torch.linspace(
398
+ 0, n_steps - 1, n_steps, dtype=torch.float32, device=y0.device
399
+ )
400
+ simulations = integrate_model(
401
+ self.sde, y0, time_points=self.time_points, dt=0.1
402
+ )
403
+ self.X = simulations.reshape(-1, simulations.shape[-1])
404
+ self.pseudotime = self.time_points.repeat_interleave(simulations.shape[1])
405
+ self.sampled_cells = (
406
+ torch.arange(self.X.shape[0])
407
+ .reshape(simulations.shape[0], simulations.shape[1])
408
+ .permute(1, 0)
409
+ .contiguous()
410
+ )
411
+
412
+ self.gene_names = pd.Index([f"gene_{i}" for i in range(simulations.size(2))])
413
+ self.adata = ad.AnnData(
414
+ X=self.X.detach().cpu().numpy(), var=pd.DataFrame(index=self.gene_names)
415
+ )
416
+
417
+
418
+ def split_by_time_point(
419
+ dataset: PseudotimeDataset,
420
+ train_proportion: float = 0.8,
421
+ validation_proportion: float = 0.2,
422
+ test_proportion: float = 0.0,
423
+ ) -> Union[
424
+ Tuple[torch.Tensor, torch.Tensor], Tuple[torch.Tensor, torch.Tensor, torch.Tensor]
425
+ ]:
426
+ """
427
+ Split the dataset by time points. The validation time points are
428
+ taken from the end, the test time points from the middle of the
429
+ remaining time points. By default, only training and validation
430
+ indices are generated.
431
+
432
+ Parameters
433
+ ----------
434
+ dataset : PseudotimeDataset
435
+ Dataset that should be split.
436
+ train_proportion : float, optional
437
+ Proportion of timepoints for training. Default: 0.8.
438
+ validation_proportion : float, optional
439
+ Proportion of timepoints for validation. Default: 0.2.
440
+ test_proportion : float, optional
441
+ Proportion of timepoints for testing. Default: 0.0.
442
+
443
+ Returns
444
+ -------
445
+ Union[ Tuple[torch.Tensor, torch.Tensor], Tuple[torch.Tensor, torch.Tensor, torch.Tensor] ]
446
+ Indices of the training, validation, and (optionally) test
447
+ timepoints.
448
+
449
+ Raises
450
+ ------
451
+ ValueError
452
+ An error is raised if one of the proportions is not in the
453
+ appropriate range, or if they do not sum to 1.
454
+ """
455
+ if not np.isclose(train_proportion + validation_proportion + test_proportion, 1.0):
456
+ raise ValueError("Proportions must sum up to 1.")
457
+
458
+ if train_proportion <= 0.0 or train_proportion >= 1.0:
459
+ raise ValueError(
460
+ f"Training proportion must be in (0, 1), but was {train_proportion}."
461
+ )
462
+ if validation_proportion <= 0.0 or validation_proportion >= 1.0:
463
+ raise ValueError(
464
+ f"Validation proportion must be in (0, 1), but was {validation_proportion}."
465
+ )
466
+ if test_proportion < 0.0 or test_proportion >= 1.0:
467
+ raise ValueError(
468
+ f"Test proportion must be in [0, 1), but was {test_proportion}."
469
+ )
470
+
471
+ n_time_points = len(dataset.time_points)
472
+ all_indices = torch.arange(n_time_points)
473
+
474
+ # Take validation time points from the end
475
+ n_validation = floor(validation_proportion * n_time_points)
476
+ validation_indices = all_indices[-n_validation:]
477
+
478
+ train_indices = all_indices[:-n_validation]
479
+
480
+ # To test model performance based on interpolation, take time points
481
+ # from the middle of the remaining time points
482
+ if test_proportion > 0:
483
+ n_test = floor(test_proportion * n_time_points)
484
+ test_start_idx = ((n_time_points - n_validation) // 2) - (n_test // 2)
485
+ test_indices = all_indices[test_start_idx : test_start_idx + n_test]
486
+ train_indices = torch.tensor(
487
+ [idx for idx in train_indices if idx not in test_indices]
488
+ )
489
+ return train_indices, validation_indices, test_indices
490
+ return train_indices, validation_indices
491
+
492
+
493
+ class PseudotimeTimepointWrapper(Dataset):
494
+ """
495
+ Wrapper around the :class:`PseudotimeDataset` to select a subset of
496
+ the original time points, e.g., for train/validation/test split.
497
+
498
+ Parameters
499
+ ----------
500
+ dataset : PseudotimeDataset
501
+ Original dataset.
502
+ timepoint_indices : torch.Tensor
503
+ Subset of time points.
504
+ """
505
+
506
+ def __init__(self, dataset: PseudotimeDataset, timepoint_indices: torch.Tensor):
507
+ self.dataset = dataset
508
+ self.time_point_indices = timepoint_indices
509
+ self.time_points = dataset.time_points[self.time_point_indices]
510
+
511
+ def __len__(self):
512
+ return len(self.dataset)
513
+
514
+ def __getitem__(self, idx):
515
+ sample = self.dataset[idx]
516
+ if sample.dim() == 2:
517
+ sliced_sample = sample[self.time_point_indices, :]
518
+ elif sample.dim() == 3:
519
+ sliced_sample = sample[:, self.time_point_indices, :]
520
+ else:
521
+ raise ValueError(
522
+ f"Unexpected tensor shape from underlying dataset: {sample.shape}"
523
+ )
524
+ return sliced_sample
525
+
526
+ def n_genes(self) -> int:
527
+ """
528
+ Get the number of genes in the dataset.
529
+
530
+ Returns
531
+ -------
532
+ int
533
+ Number of genes.
534
+ """
535
+ return self.dataset.n_genes()