Source code for omniloader.sampling.subsamplers

"""Per-dataset index draws with configurable reuse and exhaustion policies.

An :class:`IndexPool` produces the local indices to visit within a single
dataset for a given epoch. It captures three orthogonal choices:

* **replacement** — whether a draw may repeat an index within one epoch;
* **exhaustion policy** — for without-replacement draws, whether each epoch is a
  fresh random subsample (``"fresh"``) or whether the pool shuffles through the
  entire dataset before any index is reused across epochs (``"exhaust"``);
* **weights** — optional per-index weights for skewed sampling (e.g. class
  balancing within a dataset).

Draws are fully determined by a base ``seed`` and the epoch number, so runs are
reproducible.
"""

from __future__ import annotations

import enum
from typing import TYPE_CHECKING

import torch

if TYPE_CHECKING:
    from collections.abc import Sequence


[docs] class ExhaustionPolicy(enum.Enum): """How without-replacement draws relate across epochs. Attributes: FRESH: Every epoch independently samples without replacement from the full dataset; nothing is remembered between epochs. EXHAUST: Maintain a running shuffled ordering of the whole dataset and consume it across epochs, reshuffling only once every index has been used. This guarantees uniform coverage before any reuse. """ FRESH = "fresh" EXHAUST = "exhaust"
[docs] class IndexPool: """Draw local indices for one dataset, honouring reuse and exhaustion policy. Args: size: Number of samples in the dataset. replacement: When ``True``, indices may repeat within an epoch and the exhaustion policy is ignored. Defaults to ``False``. policy: The :class:`ExhaustionPolicy` for without-replacement draws. seed: Base seed; combined with the epoch to make draws reproducible. weights: Optional per-index sampling weights of length ``size`` (e.g. class-balancing weights). When set, draws are weighted via ``torch.multinomial`` and the exhaustion policy is bypassed. Raises: ValueError: If ``size`` is not positive or ``weights`` has a bad length. """ def __init__( self, size: int, replacement: bool = False, policy: ExhaustionPolicy = ExhaustionPolicy.FRESH, seed: int = 0, weights: Sequence[float] | None = None, ) -> None: if size <= 0: raise ValueError(f"IndexPool size must be positive, got {size}") if weights is not None and len(weights) != size: raise ValueError(f"weights must have length {size}, got {len(weights)}") self.size = size self.replacement = replacement self.policy = policy self.seed = seed self.weights = ( torch.as_tensor(weights, dtype=torch.float32) if weights is not None else None ) self._buffer: list[int] = [] self._reshuffles = 0 def _generator(self, epoch: int, salt: int = 0) -> torch.Generator: """Build a deterministic generator for an epoch. Args: epoch: The epoch number. salt: Extra offset to decorrelate successive reshuffles. Returns: A seeded :class:`torch.Generator`. """ gen = torch.Generator() gen.manual_seed(self.seed + epoch * 1_000_003 + salt) return gen
[docs] def draw(self, count: int, epoch: int) -> list[int]: """Return ``count`` local indices to visit this epoch. Args: count: Number of indices requested. May exceed ``size`` when ``replacement`` is ``True`` or under the ``EXHAUST`` policy (which wraps across reshuffles). epoch: The epoch number, used to seed the draw. Returns: A list of ``count`` indices in ``[0, size)``. """ if count <= 0: return [] if self.weights is not None: # Weighted draw (e.g. class-balanced); with replacement when count # exceeds the number of non-zero-weight indices. gen = self._generator(epoch) replacement = self.replacement or count > int((self.weights > 0).sum().item()) return torch.multinomial( self.weights, count, replacement=replacement, generator=gen ).tolist() if self.replacement: gen = self._generator(epoch) return torch.randint(0, self.size, (count,), generator=gen).tolist() if self.policy is ExhaustionPolicy.FRESH: gen = self._generator(epoch) perm = torch.randperm(self.size, generator=gen).tolist() if count <= self.size: return perm[:count] # Requested more than available: tile whole reshuffles then top up. out = list(perm) while len(out) < count: gen = self._generator(epoch, salt=len(out)) out.extend(torch.randperm(self.size, generator=gen).tolist()) return out[:count] # EXHAUST: consume a persistent buffer, reshuffling only when empty. out: list[int] = [] while len(out) < count: if not self._buffer: gen = self._generator(self._reshuffles, salt=7) self._buffer = torch.randperm(self.size, generator=gen).tolist() self._reshuffles += 1 take = min(count - len(out), len(self._buffer)) out.extend(self._buffer[:take]) self._buffer = self._buffer[take:] return out