Using a dataset in a PyTorch DataLoader#
A deltacells dataset is read through DeltaCellsDataset, which returns batches of any cells you ask for as CSR arrays. This page
shows how to put it behind a PyTorch DataLoader. For a complete training setup with PyTorch Lightning, see
cellarium-ml, which provides the data loading, models and training loops built
for this kind of data.
Reading cells#
import numpy as np
from deltacells import open_dataset
ds = open_dataset("my_dataset") # a directory, or "gs://bucket/prefix"
ds.n_cells, ds.n_genes, ds.n_tiles
batch = ds.get_batch(np.array([5, 99_000, 12])) # any global cell indices, in any order
batch.indptr, batch.indices, batch.values # CSR arrays: int64, int32, float32
batch.to_scipy() # a scipy.sparse.csr_matrix
batch.to_torch_csr() # a torch.sparse_csr tensor sharing the memory
Columns are in the stored gene order (genes sorted by decreasing total counts). ds.var_names gives the gene names in that
order, and ds.gene_order the original column of each stored gene.
What a loader needs#
a data loader needs |
|
|---|---|
number of cells and genes |
|
shard boundaries for shuffling and splitting |
|
a batch by global cell index |
|
the size of a batch, to allocate output buffers (for example in shared memory) |
|
“these tiles will be needed next” |
|
gene names and the gene table |
|
cell metadata for a batch |
|
A minimal DataLoader#
The dataset stores cells in tiles of about 10,000 consecutive cells, and reading a tile is one object read. The efficient way to shuffle is therefore in two levels: visit the tiles in random order, and shuffle the cells within each tile. Batches then come from one tile at a time, and the next tiles can be fetched in the background while the current one is used.
import numpy as np
import torch
from torch.utils.data import DataLoader, IterableDataset, get_worker_info
from deltacells import open_dataset
class TileBatches(IterableDataset):
"""Shuffled batches of cells: tiles in random order, cells shuffled within each tile."""
def __init__(self, path, batch_size=5000, seed=0):
self.ds = open_dataset(path)
self.batch_size, self.seed = batch_size, seed
def __iter__(self):
info = get_worker_info()
worker, n_workers = (info.id, info.num_workers) if info else (0, 1)
rng = np.random.default_rng([self.seed, worker])
tiles = np.random.default_rng(self.seed).permutation(self.ds.n_tiles)[worker::n_workers] # this worker's tiles
self.ds.prefetch(tiles[:3])
for k, tile in enumerate(tiles):
self.ds.prefetch(tiles[k + 1 : k + 3]) # fetch the next tiles while this one is used
start, stop = self.ds.tile_bounds(int(tile))
cells = rng.permutation(np.arange(start, stop))
for i in range(0, len(cells), self.batch_size):
batch = self.ds.get_batch(cells[i : i + self.batch_size])
yield { # plain tensors cross the process boundary; the sparse tensor is built in the training process
"indptr": torch.from_numpy(batch.indptr),
"indices": torch.from_numpy(batch.indices),
"values": torch.from_numpy(batch.values),
}
def to_csr(batch, n_genes):
return torch.sparse_csr_tensor(
batch["indptr"], batch["indices"], batch["values"], size=(len(batch["indptr"]) - 1, n_genes)
)
# batch_size=None: the dataset already yields whole batches
loader = DataLoader(TileBatches("my_dataset"), batch_size=None, num_workers=4)
n_genes = loader.dataset.ds.n_genes
for batch in loader:
x = to_csr(batch, n_genes).to("cuda") # move the sparse batch to the GPU first,
x = x.to_dense() # then make it dense there
... # forward and backward pass
Each worker takes its own share of the tiles, so the workers decode in parallel on separate cores. Move the sparse batch to the GPU before densifying it: a dense batch of 5,000 cells by 30,000 genes is 600 MB, while the sparse form of a batch with 2,300 nonzeros per cell is about 90 MB, so the transfer is several times smaller.
Getting the most out of it#
Iterate tile by tile. A batch that spans two tiles reads and decodes both. Shuffling tiles and then cells within a tile (as above) keeps consecutive batches on the same tile, and it is what gives the throughput measured in the benchmarks.
Batches that span many tiles. A batch that touches no more than
max_prefetch_tilestiles (default 8) fetches them all at once. One that touches more, such as a random sample from the whole dataset, is read as a stream: the start of each tile (itsindptr, about 8 bytes per cell, one small ranged read) is read first to lay out the output, then the tiles are fetched through a window ofmax_prefetch_tiles, decoded and gathered one at a time, so memory does not grow with the number of tiles and no tile is read in full twice. This needs a backend with ranged reads, which the local andgs://backends have (a customBackendsetsranged_reads = Trueonce itsread_rangeis a real ranged read; otherwise such a batch raisesNotImplementedError). It is still slow to read a few cells from each of many tiles, as every tile is read and decoded whole: read the cells of a tile together instead.Prefetch.
prefetchstarts fetching and parsing tiles on background threads, which hides the latency of a bucket. The example above looks two tiles ahead.Memory. A decoded tile takes about 8 bytes per nonzero, which is around 0.4 GB for 10,000 deep cells.
max_cached_tiles(default 2) bounds the cache, and decode buffers are reused rather than reallocated.Threads.
decode_threads=1(the default) is right when each worker has about one core. The decoder releases the Python GIL, so more threads decode a tile’s chunks in parallel.Pickling and forking. The dataset object can be pickled, which is how it reaches the
DataLoaderworkers when they are started byspawn. Caches and threads are rebuilt in each worker. Workers started byfork(the default on Linux) get the same fresh state: a forked process starts with empty caches and no threads, so it is safe to read from the dataset in the main process before creating theDataLoader. AGCSBackendlikewise makes its own client in a forked process.Fewer copies. To avoid one copy between the worker and the training process, allocate the output tensors in shared memory and pass them to
get_batch(batch_nnzgives the size). The reference loader does this.
Cell metadata#
If the dataset has cell metadata, columns are read separately from the counts, so a loader that does not need them pays nothing. Choose the columns up front and they are copied once into a local cache:
ds = open_dataset("my_dataset", obs_columns=["cell_type", "donor_id"])
codes = ds.obs.take(batch_cell_indices, ["cell_type", "donor_id"]) # integer codes for the batch, a local lookup
ds.obs.categories("cell_type") # the vocabulary behind the codes
See Cell metadata (obs) and genes (var) for how the metadata is stored, cached and read.
A fuller example#
The repository has two examples, both runnable:
examples/reference_loader.pyis a completeIterableDatasetwith tile shuffling per epoch, splitting across data-parallel replicas and workers, prefetch hints and shared-memory output.examples/dataloader_example.pyruns it end to end on synthetic data.
These show the pattern; they are examples, not part of the package. For training, use cellarium-ml.