Source code for bioverse.sampler
from abc import ABC, abstractmethod
from typing import Tuple, cast
import awkward as ak
import numpy as np
from .dataset import Dataset
from .utilities import SHARD_SIZE, config
[docs]
class Sampler(ABC):
"""Select which dataset elements form each training or evaluation batch.
Samplers map the dataset table-of-contents (``toc``) and an active split
partition to Awkward index arrays at the scene, frame, molecule, or residue
level. :meth:`sample` groups those indices into batches according to
``batch_size``, ``batch_on``, and distributed-training settings.
Subclasses implement :meth:`index`. Common strategies include sampling
every molecule (:class:`~bioverse.samplers.molecule.MoleculeSampler`) or
every frame (:class:`~bioverse.samplers.frame.FrameSampler`).
Examples
--------
.. code-block:: python
from bioverse.samplers import MoleculeSampler
sampler = MoleculeSampler()
batch_indices = sampler.sample(
dataset, partition="train", split="default", batch_size=32
)
"""
[docs]
@abstractmethod
def index(self, toc: ak.Array, mask: ak.Array) -> ak.Array:
"""Return row indices for elements in the active split partition.
Parameters
----------
toc
Table-of-contents array describing dataset size at each level.
mask
Boolean mask selecting scenes in the current partition.
Returns
-------
ak.Array
Structured index with fields such as ``scene``, ``frame``,
``molecule``.
"""
raise NotImplementedError
def sample(
self,
dataset: Dataset,
partition: str,
split: str,
batch_size: int = 1,
batch_on: str = "scenes",
shuffle: bool = False,
drop_last: bool = False,
random_seed: int = config.seed,
world_size: int = 1,
rank: int = 0,
) -> Tuple[ak.Array, ak.Array]:
self.rng = np.random.default_rng(random_seed)
partition = dataset.split.default if partition is None else partition
toc, tos, mask = dataset.toc, dataset.tos, dataset.split[split, partition]
# todo: reduce toc with split, index, then remap
index = self.index(toc, mask)
if shuffle:
order = np.argsort(index["scene"])
index = ak.Array({k: index[k][order] for k in index.fields})
num_scenes_per_shard = np.unique(
index["scene"] // SHARD_SIZE, return_counts=True
)[1]
shuffle_index = ak.unflatten(
np.arange(len(index["scene"])), num_scenes_per_shard
)
shuffle_index = ak.Array([self.rng.permutation(s) for s in shuffle_index])
shard_perm = self.rng.permutation(len(shuffle_index))
shuffle_index = ak.flatten(shuffle_index[shard_perm])
index = ak.Array({k: index[k][shuffle_index] for k in index.fields})
if batch_on == "scenes" or batch_on == "mutations":
# tie loose ends to make equal-length batch lists in DDP
# end = (len(split_toc) - world_size + 1) // world_size * world_size # put this back if there are bugs
end = (len(index["scene"]) - world_size + 1) // world_size * world_size
return ak.unflatten(index[rank:end:world_size], min(batch_size, end - rank))
elif batch_on == "frames":
sizes = toc["frame"][index["scene"]]
elif batch_on == "molecules":
sizes = toc["molecule"][index["scene"]][index["frame"]]
elif batch_on == "residues":
sizes = toc["residue"][index["scene"]][index["frame"]][index["molecule"]]
elif batch_on == "edges":
sizes = toc["graph"][index["scene"]][index["frame"]][index["molecule"]]
# compute batch list sizes for each rank
rank_sizes = []
sizes = ak.to_numpy(ak.ravel(sizes))
for world in range(world_size):
rank_sizes.append(
ak.run_lengths(np.cumsum(sizes[world::world_size]) // batch_size)
)
# make batches
index = ak.unflatten(index[rank::world_size], rank_sizes[rank])
# ensure equal-length batch lists in DDP
rank_min_batch_num = min(len(rank_sizes[world]) for world in range(world_size))
if drop_last:
rank_min_batch_num -= 1
index = index[:rank_min_batch_num]
return index