omniloader 1.0.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.
Files changed (39) hide show
  1. omniloader/__init__.py +141 -0
  2. omniloader/cli.py +156 -0
  3. omniloader/collate.py +123 -0
  4. omniloader/config.py +457 -0
  5. omniloader/data/__init__.py +0 -0
  6. omniloader/data/datasets.py +196 -0
  7. omniloader/data/factory.py +99 -0
  8. omniloader/data/npy.py +87 -0
  9. omniloader/data/splits.py +119 -0
  10. omniloader/integrations/__init__.py +0 -0
  11. omniloader/integrations/lightning.py +151 -0
  12. omniloader/introspection.py +190 -0
  13. omniloader/loader.py +255 -0
  14. omniloader/sampling/__init__.py +0 -0
  15. omniloader/sampling/bucketing.py +120 -0
  16. omniloader/sampling/sampler.py +112 -0
  17. omniloader/sampling/strategies.py +394 -0
  18. omniloader/sampling/subsamplers.py +150 -0
  19. omniloader/sampling/weights.py +187 -0
  20. omniloader/schema/__init__.py +0 -0
  21. omniloader/schema/spec.py +261 -0
  22. omniloader/schema/unify.py +135 -0
  23. omniloader/templates/__init__.py +32 -0
  24. omniloader/templates/config_template.yaml +211 -0
  25. omniloader/transforms/__init__.py +146 -0
  26. omniloader/transforms/augment.py +282 -0
  27. omniloader/transforms/base.py +137 -0
  28. omniloader/transforms/crop.py +122 -0
  29. omniloader/transforms/mix.py +112 -0
  30. omniloader/transforms/normalize.py +235 -0
  31. omniloader/transforms/stats.py +272 -0
  32. omniloader/utils/__init__.py +0 -0
  33. omniloader/utils/padding.py +159 -0
  34. omniloader/utils/seeding.py +31 -0
  35. omniloader-1.0.0.dist-info/METADATA +610 -0
  36. omniloader-1.0.0.dist-info/RECORD +39 -0
  37. omniloader-1.0.0.dist-info/WHEEL +4 -0
  38. omniloader-1.0.0.dist-info/entry_points.txt +2 -0
  39. omniloader-1.0.0.dist-info/licenses/LICENSE +21 -0
omniloader/config.py ADDED
@@ -0,0 +1,457 @@
1
+ """Configuration and reproducibility helpers.
2
+
3
+ :class:`OmniConfig` bundles the knobs a training run needs — seed, batch size,
4
+ worker count, the mixing strategy and the normalization/augmentation pipeline —
5
+ and loads from a dict, a JSON file or a YAML file so experiments stay
6
+ declarative. :func:`seed_everything` seeds Python, NumPy and PyTorch in one call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import random
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import numpy as np
18
+ import torch
19
+ import yaml
20
+
21
+ from omniloader.sampling.strategies import (
22
+ AnnealedTemperatureStrategy,
23
+ FixedWeightStrategy,
24
+ MixingStrategy,
25
+ ProportionalStrategy,
26
+ RoundRobinStrategy,
27
+ SubsampleConfig,
28
+ TemperatureStrategy,
29
+ )
30
+ from omniloader.sampling.subsamplers import ExhaustionPolicy
31
+ from omniloader.transforms import build_transform
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Callable, Sequence
35
+
36
+ from torch.utils.data import DataLoader
37
+
38
+ from omniloader.loader import OmniLoader, SizedDataset
39
+ from omniloader.schema.spec import DatasetSchema, UnifiedSchema
40
+ from omniloader.transforms import Compose
41
+
42
+ #: Registry mapping strategy names to their classes.
43
+ STRATEGIES: dict[str, type[MixingStrategy]] = {
44
+ "proportional": ProportionalStrategy,
45
+ "temperature": TemperatureStrategy,
46
+ "annealed_temperature": AnnealedTemperatureStrategy,
47
+ "fixed": FixedWeightStrategy,
48
+ "round_robin": RoundRobinStrategy,
49
+ }
50
+
51
+ #: Recognised ``collate`` names for :meth:`OmniConfig.build_collate`.
52
+ COLLATES: tuple[str, ...] = ("unified", "dynamic", "mixup")
53
+
54
+
55
+ def _subsample_from_dict(entry: dict[str, Any]) -> SubsampleConfig:
56
+ """Build a :class:`SubsampleConfig` from a config dict.
57
+
58
+ Args:
59
+ entry: Mapping with any of ``replacement``, ``policy`` (``"fresh"`` or
60
+ ``"exhaust"``), ``effective_size`` and ``sample_weights``.
61
+
62
+ Returns:
63
+ The populated :class:`SubsampleConfig`.
64
+
65
+ """
66
+ kwargs: dict[str, Any] = dict(entry)
67
+ if "policy" in kwargs and not isinstance(kwargs["policy"], ExhaustionPolicy):
68
+ kwargs["policy"] = ExhaustionPolicy(kwargs["policy"])
69
+ return SubsampleConfig(**kwargs)
70
+
71
+
72
+ def seed_everything(seed: int) -> int:
73
+ """Seed Python, NumPy and PyTorch RNGs for reproducibility.
74
+
75
+ Args:
76
+ seed: The seed value.
77
+
78
+ Returns:
79
+ The seed, for convenience.
80
+
81
+ """
82
+ random.seed(seed)
83
+ np.random.seed(seed)
84
+ torch.manual_seed(seed)
85
+ return seed
86
+
87
+
88
+ @dataclass
89
+ class OmniConfig:
90
+ """Declarative configuration for an OmniLoader training run.
91
+
92
+ Args:
93
+ seed: Global RNG seed.
94
+ batch_size: Batch size for the ``DataLoader``.
95
+ num_workers: Number of worker processes for the ``DataLoader``.
96
+ pad_features: Whether the unifier pads/crops sequence values.
97
+ strategy: Name of the mixing strategy (a key of :data:`STRATEGIES`).
98
+ strategy_kwargs: Extra keyword arguments forwarded to the strategy
99
+ (e.g. ``temperature``, ``weights``, ``samples_per_dataset``).
100
+ transforms: Ordered list of transform config dicts (each with a ``name``
101
+ key) applied during training. Stats-based normalizers may reference a
102
+ saved stats file via ``stats_path`` (or ``union_stats_path``) instead of
103
+ inlining ``stats``. See :func:`~omniloader.transforms.build_transform`.
104
+ eval_transforms: Transforms applied during evaluation. When ``None``,
105
+ only the non-augmenting (train-agnostic) transforms in
106
+ :attr:`transforms`, such as ``normalize``, take effect.
107
+ datasets: Declarative dataset entries (adapter + args + schema) built by
108
+ :meth:`build_datasets`. See :mod:`omniloader.data.factory`.
109
+ subsample: Optional per-dataset subsampling entries (one per dataset, or
110
+ ``None`` to skip a dataset), each a dict for :class:`SubsampleConfig`.
111
+ Built by :meth:`build_subsample` and fed to :meth:`build_strategy`.
112
+ collate: Collate function name — one of :data:`COLLATES` (``"unified"``,
113
+ ``"dynamic"`` or ``"mixup"``). See :meth:`build_collate`. ``"dynamic"``
114
+ only reduces padding with :attr:`pad_features` set to ``False`` (under a
115
+ fixed ``time_dim`` it behaves like ``"unified"``).
116
+ collate_kwargs: Extra keyword arguments for the collator. For ``"dynamic"``
117
+ an optional ``keys`` list; for ``"mixup"`` a ``base`` collate name plus
118
+ ``alpha``/``mode``/``p``.
119
+ bucketing: Optional length-bucketing entry with a ``key`` (sequence name to
120
+ bucket by) and optional ``batch_size``/``bucket_multiplier``/
121
+ ``drop_last``/``shuffle``. When set, :meth:`build_dataloader` batches
122
+ with :class:`~omniloader.sampling.bucketing.LengthBucketBatchSampler`.
123
+ Only meaningful with ``pad_features=False`` (a no-op under a fixed
124
+ ``time_dim``).
125
+ num_replicas: Distributed replica count for the sampler. ``None`` infers
126
+ it from ``torch.distributed``.
127
+ rank: This process's distributed rank. ``None`` infers it.
128
+ drop_last: Drop the uneven tail when sharding across replicas.
129
+ pin_memory: Copy tensors into pinned memory before returning them (faster
130
+ host-to-GPU transfer). Forwarded to the ``DataLoader``.
131
+ persistent_workers: Keep worker processes alive between epochs. Only takes
132
+ effect when :attr:`num_workers` > 0.
133
+ prefetch_factor: Batches prefetched per worker. Only applied when
134
+ :attr:`num_workers` > 0; ``None`` uses the PyTorch default.
135
+
136
+ """
137
+
138
+ seed: int = 0
139
+ batch_size: int = 32
140
+ num_workers: int = 0
141
+ pad_features: bool = True
142
+ strategy: str = "proportional"
143
+ strategy_kwargs: dict[str, Any] = field(default_factory=dict)
144
+ transforms: list[dict[str, Any]] = field(default_factory=list)
145
+ eval_transforms: list[dict[str, Any]] | None = None
146
+ datasets: list[dict[str, Any]] = field(default_factory=list)
147
+ subsample: list[dict[str, Any] | None] | None = None
148
+ collate: str = "unified"
149
+ collate_kwargs: dict[str, Any] = field(default_factory=dict)
150
+ bucketing: dict[str, Any] | None = None
151
+ num_replicas: int | None = None
152
+ rank: int | None = None
153
+ drop_last: bool = False
154
+ pin_memory: bool = False
155
+ persistent_workers: bool = False
156
+ prefetch_factor: int | None = None
157
+
158
+ @classmethod
159
+ def from_dict(cls, data: dict[str, Any]) -> OmniConfig:
160
+ """Build a config from a plain dict, ignoring unknown keys.
161
+
162
+ Args:
163
+ data: Mapping of field names to values.
164
+
165
+ Returns:
166
+ The populated config.
167
+
168
+ """
169
+ fields = set(cls.__dataclass_fields__)
170
+ return cls(**{k: v for k, v in data.items() if k in fields})
171
+
172
+ @classmethod
173
+ def from_yaml(cls, path: str | Path) -> OmniConfig:
174
+ """Load a config from a YAML file."""
175
+ with Path(path).open("r", encoding="utf-8") as f:
176
+ return cls.from_dict(yaml.safe_load(f) or {})
177
+
178
+ @classmethod
179
+ def from_json(cls, path: str | Path) -> OmniConfig:
180
+ """Load a config from a JSON file."""
181
+ with Path(path).open("r", encoding="utf-8") as f:
182
+ return cls.from_dict(json.load(f) or {})
183
+
184
+ @classmethod
185
+ def from_file(cls, path: str | Path) -> OmniConfig:
186
+ """Load a config from a ``.json``, ``.yaml`` or ``.yml`` file by extension.
187
+
188
+ Args:
189
+ path: Path to a config file.
190
+
191
+ Returns:
192
+ The populated config.
193
+
194
+ Raises:
195
+ ValueError: If the file extension is not recognised.
196
+
197
+ """
198
+ suffix = Path(path).suffix.lower()
199
+ if suffix == ".json":
200
+ return cls.from_json(path)
201
+ if suffix in {".yaml", ".yml"}:
202
+ return cls.from_yaml(path)
203
+ raise ValueError(f"Unsupported config extension {suffix!r}; use .json/.yaml/.yml")
204
+
205
+ def to_dict(self) -> dict[str, Any]:
206
+ """Return the config as a plain dict."""
207
+ return {
208
+ "seed": self.seed,
209
+ "batch_size": self.batch_size,
210
+ "num_workers": self.num_workers,
211
+ "pad_features": self.pad_features,
212
+ "strategy": self.strategy,
213
+ "strategy_kwargs": dict(self.strategy_kwargs),
214
+ "transforms": [dict(t) for t in self.transforms],
215
+ "eval_transforms": (
216
+ None if self.eval_transforms is None else [dict(t) for t in self.eval_transforms]
217
+ ),
218
+ "datasets": [dict(d) for d in self.datasets],
219
+ "subsample": (
220
+ None
221
+ if self.subsample is None
222
+ else [None if s is None else dict(s) for s in self.subsample]
223
+ ),
224
+ "collate": self.collate,
225
+ "collate_kwargs": dict(self.collate_kwargs),
226
+ "bucketing": None if self.bucketing is None else dict(self.bucketing),
227
+ "num_replicas": self.num_replicas,
228
+ "rank": self.rank,
229
+ "drop_last": self.drop_last,
230
+ "pin_memory": self.pin_memory,
231
+ "persistent_workers": self.persistent_workers,
232
+ "prefetch_factor": self.prefetch_factor,
233
+ }
234
+
235
+ def build_strategy(
236
+ self,
237
+ sizes: Sequence[int],
238
+ subsample: Sequence[SubsampleConfig | None] | None = None,
239
+ ) -> MixingStrategy:
240
+ """Instantiate the configured mixing strategy for the given sizes.
241
+
242
+ Args:
243
+ sizes: Per-dataset sample counts.
244
+ subsample: Optional per-dataset subsample configs.
245
+
246
+ Returns:
247
+ The configured :class:`~omniloader.sampling.strategies.MixingStrategy`.
248
+
249
+ Raises:
250
+ ValueError: If :attr:`strategy` is not a known strategy name.
251
+
252
+ """
253
+ if self.strategy not in STRATEGIES:
254
+ raise ValueError(
255
+ f"Unknown strategy {self.strategy!r}; expected one of {sorted(STRATEGIES)}"
256
+ )
257
+ strategy_cls = STRATEGIES[self.strategy]
258
+ return strategy_cls(sizes, seed=self.seed, subsample=subsample, **self.strategy_kwargs)
259
+
260
+ def build_transform(self, schema: UnifiedSchema, training: bool = True) -> Compose | None:
261
+ """Build the transform pipeline for a split.
262
+
263
+ Args:
264
+ schema: The unified schema, injected into schema-aware transforms.
265
+ training: When ``True``, uses :attr:`transforms`; otherwise uses
266
+ :attr:`eval_transforms` if set, else :attr:`transforms` (whose
267
+ augmentations self-skip in eval mode).
268
+
269
+ Returns:
270
+ A :class:`~omniloader.transforms.Compose`, or ``None`` if empty.
271
+
272
+ """
273
+ configs = self.transforms
274
+ if not training and self.eval_transforms is not None:
275
+ configs = self.eval_transforms
276
+ return build_transform(configs, schema)
277
+
278
+ def build_datasets(self) -> tuple[list[SizedDataset], list[DatasetSchema]]:
279
+ """Construct the datasets/schemas declared in :attr:`datasets`.
280
+
281
+ Returns:
282
+ A ``(datasets, schemas)`` tuple. See
283
+ :func:`omniloader.data.factory.build_datasets`.
284
+
285
+ """
286
+ from omniloader.data.factory import build_datasets
287
+
288
+ return build_datasets(self.datasets)
289
+
290
+ def build_subsample(self) -> list[SubsampleConfig | None] | None:
291
+ """Build the per-dataset :class:`SubsampleConfig` list from :attr:`subsample`.
292
+
293
+ Returns:
294
+ One :class:`SubsampleConfig` (or ``None``) per dataset, or ``None`` when
295
+ :attr:`subsample` is unset.
296
+
297
+ """
298
+ if self.subsample is None:
299
+ return None
300
+ return [None if entry is None else _subsample_from_dict(entry) for entry in self.subsample]
301
+
302
+ def build_collate(
303
+ self, schema: UnifiedSchema, training: bool = True
304
+ ) -> Callable[[Sequence[dict[str, Any]]], dict[str, Any]]:
305
+ """Build the collate function named by :attr:`collate`.
306
+
307
+ Args:
308
+ schema: The unified schema, injected into schema-aware collators.
309
+ training: When ``False``, the batch-level ``"mixup"`` augmentation is
310
+ disabled and its ``base`` collator is used instead.
311
+
312
+ Returns:
313
+ A callable turning a list of samples into a batched dict.
314
+
315
+ Raises:
316
+ ValueError: If :attr:`collate` (or a ``mixup`` ``base``) is unknown.
317
+
318
+ """
319
+ from omniloader.collate import DynamicCollator, unified_collate
320
+
321
+ if self.collate not in COLLATES:
322
+ raise ValueError(f"Unknown collate {self.collate!r}; expected one of {list(COLLATES)}")
323
+
324
+ def _base(name: str, kwargs: dict[str, Any]) -> Any:
325
+ if name == "unified":
326
+ return unified_collate
327
+ if name == "dynamic":
328
+ return DynamicCollator(schema, keys=kwargs.get("keys"))
329
+ raise ValueError(f"Unknown collate {name!r}; expected 'unified' or 'dynamic'")
330
+
331
+ if self.collate != "mixup":
332
+ return _base(self.collate, self.collate_kwargs)
333
+
334
+ from omniloader.transforms.mix import MixupCollator
335
+
336
+ kwargs = dict(self.collate_kwargs)
337
+ base = _base(kwargs.pop("base", "unified"), kwargs)
338
+ kwargs.pop("keys", None) # consumed by the base collator only
339
+ if not training:
340
+ return base # mixing is a train-time augmentation
341
+ kwargs.setdefault("seed", self.seed)
342
+ return MixupCollator(base, schema, **kwargs)
343
+
344
+ def build_loader(
345
+ self,
346
+ datasets: Sequence[SizedDataset] | None = None,
347
+ schemas: Sequence[DatasetSchema] | None = None,
348
+ training: bool = True,
349
+ ) -> OmniLoader:
350
+ """Build a fully configured :class:`~omniloader.loader.OmniLoader`.
351
+
352
+ Args:
353
+ datasets: Source datasets. When ``None``, they are built from
354
+ :attr:`datasets` via :meth:`build_datasets`.
355
+ schemas: Per-dataset schemas. Required when ``datasets`` is given;
356
+ ignored (rebuilt) when ``datasets`` is ``None``.
357
+ training: Whether augmentations are active (``False`` for val/test).
358
+
359
+ Returns:
360
+ The loader with its transform pipeline wired in.
361
+
362
+ Raises:
363
+ ValueError: If ``datasets`` is given without ``schemas``.
364
+
365
+ """
366
+ from omniloader.loader import OmniLoader
367
+
368
+ if datasets is None:
369
+ datasets, schemas = self.build_datasets()
370
+ elif schemas is None:
371
+ raise ValueError("schemas must be given when datasets is provided")
372
+ loader = OmniLoader(
373
+ datasets,
374
+ schemas,
375
+ pad_features=self.pad_features,
376
+ training=training,
377
+ seed=self.seed,
378
+ )
379
+ loader.transform = self.build_transform(loader.schema, training=training)
380
+ return loader
381
+
382
+ def build_dataloader(
383
+ self,
384
+ datasets: Sequence[SizedDataset] | None = None,
385
+ schemas: Sequence[DatasetSchema] | None = None,
386
+ training: bool = True,
387
+ epoch: int = 0,
388
+ ) -> DataLoader:
389
+ """Assemble a ready-to-iterate ``DataLoader`` entirely from this config.
390
+
391
+ Wires together the loader, mixing strategy, distributed sampler, collate
392
+ function and (optionally) length bucketing. Training loaders draw indices
393
+ through the configured :class:`~omniloader.sampling.strategies.MixingStrategy`;
394
+ evaluation loaders (``training=False``) iterate sequentially for a stable,
395
+ full-coverage pass.
396
+
397
+ Args:
398
+ datasets: Source datasets, or ``None`` to build them from
399
+ :attr:`datasets`.
400
+ schemas: Per-dataset schemas (required with ``datasets``).
401
+ training: Whether this is the training split.
402
+ epoch: Initial epoch for the sampler and per-sample augmentation.
403
+
404
+ Returns:
405
+ A configured :class:`torch.utils.data.DataLoader`.
406
+
407
+ """
408
+ from torch.utils.data import DataLoader, SequentialSampler
409
+
410
+ from omniloader.sampling.sampler import OmniSampler
411
+ from omniloader.utils.seeding import seed_worker
412
+
413
+ loader = self.build_loader(datasets, schemas, training=training)
414
+ loader.set_epoch(epoch)
415
+ collate = self.build_collate(loader.schema, training=training)
416
+
417
+ sampler: OmniSampler | None = None
418
+ if training:
419
+ strategy = self.build_strategy(loader.dataset_sizes, subsample=self.build_subsample())
420
+ sampler = OmniSampler(
421
+ strategy,
422
+ epoch=epoch,
423
+ num_replicas=self.num_replicas,
424
+ rank=self.rank,
425
+ drop_last=self.drop_last,
426
+ )
427
+
428
+ # persistent_workers/prefetch_factor are only valid with worker processes.
429
+ kwargs: dict[str, Any] = {
430
+ "num_workers": self.num_workers,
431
+ "collate_fn": collate,
432
+ "worker_init_fn": seed_worker,
433
+ "pin_memory": self.pin_memory,
434
+ }
435
+ if self.num_workers > 0:
436
+ kwargs["persistent_workers"] = self.persistent_workers
437
+ if self.prefetch_factor is not None:
438
+ kwargs["prefetch_factor"] = self.prefetch_factor
439
+
440
+ if self.bucketing is not None:
441
+ from omniloader.sampling.bucketing import LengthBucketBatchSampler
442
+
443
+ opts = dict(self.bucketing)
444
+ key = opts.pop("key")
445
+ batch_size = opts.pop("batch_size", self.batch_size)
446
+ base_sampler = sampler if sampler is not None else SequentialSampler(loader)
447
+ opts.setdefault("shuffle", training)
448
+ batch_sampler = LengthBucketBatchSampler(
449
+ base_sampler,
450
+ loader.sequence_lengths(key),
451
+ batch_size,
452
+ seed=self.seed,
453
+ **opts,
454
+ )
455
+ return DataLoader(loader, batch_sampler=batch_sampler, **kwargs)
456
+
457
+ return DataLoader(loader, batch_size=self.batch_size, sampler=sampler, **kwargs)
File without changes
@@ -0,0 +1,196 @@
1
+ """Dataset adapters that yield raw sample dicts for OmniLoader.
2
+
3
+ The concrete datasets here (an HDF5-backed reader and an in-memory tensor
4
+ dataset) implement the loose contract OmniLoader expects: ``__len__`` returns the
5
+ number of samples and ``__getitem__`` returns a ``dict`` of named values. The
6
+ :class:`~omniloader.schema.spec.DatasetSchema` describing what a dataset provides is
7
+ supplied alongside it to :class:`~omniloader.loader.OmniLoader`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import OrderedDict
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import h5py
17
+ import numpy as np
18
+ import torch
19
+ from torch.utils.data import Dataset
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping, Sequence
23
+
24
+
25
+ def _decode_hdf5_value(dset: h5py.Dataset) -> Any:
26
+ """Read a single HDF5 dataset into a tensor, string, or list of strings.
27
+
28
+ Args:
29
+ dset: An open HDF5 dataset object.
30
+
31
+ Returns:
32
+ A :class:`torch.Tensor` for numeric data, a ``str`` for scalar strings,
33
+ or a ``list[str]`` for string arrays.
34
+
35
+ """
36
+ if dset.dtype.kind in {"S", "O"}:
37
+ value = dset.asstr()[()]
38
+ if isinstance(value, (np.ndarray, list)):
39
+ return [str(v) for v in value]
40
+ return str(value)
41
+
42
+ value = dset[()]
43
+ if isinstance(value, np.ndarray):
44
+ return torch.from_numpy(value)
45
+ if isinstance(value, (bool, np.bool_)):
46
+ return torch.tensor(bool(value))
47
+ if isinstance(value, (int, np.integer)):
48
+ return torch.tensor(int(value))
49
+ if isinstance(value, (float, np.floating)):
50
+ return torch.tensor(float(value))
51
+ return torch.as_tensor(value)
52
+
53
+
54
+ class HDF5Dataset(Dataset):
55
+ """Read samples from a group of an HDF5 file, one sub-group per sample.
56
+
57
+ The file is expected to hold a group named ``subset`` (e.g. ``"train"``)
58
+ whose children are per-sample groups, each containing the named value
59
+ datasets (features, targets, masks, metadata). The file handle is opened
60
+ lazily per worker process so the dataset is safe to use with
61
+ ``num_workers > 0``.
62
+
63
+ Args:
64
+ h5_path: Path to the ``.h5`` file.
65
+ subset: Name of the top-level group to read (e.g. ``"train"``).
66
+ keys: Optional subset of value names to load per sample. When ``None``,
67
+ every dataset in the sample group is loaded.
68
+ cache_size: If ``> 0``, keep an in-process LRU cache of up to this many
69
+ decoded samples to speed up repeated reads.
70
+ preload: If ``True``, decode every sample into memory up front (fastest
71
+ for small datasets; uses the most memory).
72
+
73
+ Raises:
74
+ KeyError: If ``subset`` is not present in the file.
75
+
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ h5_path: str | Path,
81
+ subset: str,
82
+ keys: Sequence[str] | None = None,
83
+ cache_size: int = 0,
84
+ preload: bool = False,
85
+ ) -> None:
86
+ self.h5_path = Path(h5_path)
87
+ self.subset = subset
88
+ self.keys = list(keys) if keys is not None else None
89
+ self.cache_size = cache_size
90
+ self._file: h5py.File | None = None
91
+ self._cache: OrderedDict[int, dict[str, Any]] = OrderedDict()
92
+ with h5py.File(self.h5_path, "r") as f:
93
+ if subset not in f:
94
+ raise KeyError(f"Subset {subset!r} not found in {self.h5_path}")
95
+ self.sample_ids = sorted(f[subset].keys())
96
+ self._preloaded: list[dict[str, Any]] | None = (
97
+ [self._read(i) for i in range(len(self.sample_ids))] if preload else None
98
+ )
99
+
100
+ def _handle(self) -> h5py.File:
101
+ """Return a per-process open file handle, opening it on first use."""
102
+ if self._file is None:
103
+ self._file = h5py.File(self.h5_path, "r")
104
+ return self._file
105
+
106
+ def _read(self, index: int) -> dict[str, Any]:
107
+ """Decode the sample at ``index`` directly from the HDF5 file."""
108
+ sample_id = self.sample_ids[index]
109
+ group = self._handle()[self.subset][sample_id]
110
+ names = self.keys if self.keys is not None else list(group.keys())
111
+ return {name: _decode_hdf5_value(group[name]) for name in names if name in group}
112
+
113
+ def __len__(self) -> int:
114
+ """Number of samples in the subset."""
115
+ return len(self.sample_ids)
116
+
117
+ def __getitem__(self, index: int) -> dict[str, Any]:
118
+ """Load the sample at ``index`` as a dict of named values.
119
+
120
+ Args:
121
+ index: Position within the subset.
122
+
123
+ Returns:
124
+ A dict mapping each stored key to a tensor or string.
125
+
126
+ """
127
+ if self._preloaded is not None:
128
+ return self._preloaded[index]
129
+ if self.cache_size > 0 and index in self._cache:
130
+ self._cache.move_to_end(index) # mark as most-recently used
131
+ return self._cache[index]
132
+ sample = self._read(index)
133
+ if self.cache_size > 0:
134
+ self._cache[index] = sample
135
+ if len(self._cache) > self.cache_size:
136
+ self._cache.popitem(last=False) # evict least-recently used
137
+ return sample
138
+
139
+ def __getstate__(self) -> dict[str, Any]:
140
+ """Drop the open file handle and cache when pickling (for workers)."""
141
+ state = self.__dict__.copy()
142
+ state["_file"] = None
143
+ state["_cache"] = OrderedDict()
144
+ return state
145
+
146
+
147
+ class DictTensorDataset(Dataset):
148
+ """In-memory dataset over a dict of stacked tensors, keyed by sample index.
149
+
150
+ Useful for tests and small experiments: each entry maps a name to a tensor
151
+ whose leading dimension is the sample axis. ``__getitem__`` returns the
152
+ per-sample slice of every tensor as a dict.
153
+
154
+ Args:
155
+ tensors: Mapping of value name to a tensor of shape ``(N, ...)`` where
156
+ ``N`` is the number of samples (equal across all tensors).
157
+ metadata: Optional mapping of metadata name to a per-sample sequence
158
+ (e.g. dataset name or sample key) of length ``N``.
159
+
160
+ Raises:
161
+ ValueError: If the tensors disagree on the number of samples.
162
+
163
+ """
164
+
165
+ def __init__(
166
+ self,
167
+ tensors: Mapping[str, torch.Tensor],
168
+ metadata: Mapping[str, Sequence[Any]] | None = None,
169
+ ) -> None:
170
+ if not tensors:
171
+ raise ValueError("DictTensorDataset requires at least one tensor")
172
+ lengths = {t.shape[0] for t in tensors.values()}
173
+ if len(lengths) != 1:
174
+ raise ValueError(f"All tensors must share the sample dimension, got {lengths}")
175
+ self.tensors = dict(tensors)
176
+ self.metadata = {k: list(v) for k, v in (metadata or {}).items()}
177
+ self._length = lengths.pop()
178
+
179
+ def __len__(self) -> int:
180
+ """Number of samples."""
181
+ return self._length
182
+
183
+ def __getitem__(self, index: int) -> dict[str, Any]:
184
+ """Return the per-sample slice of every tensor plus metadata.
185
+
186
+ Args:
187
+ index: Sample position.
188
+
189
+ Returns:
190
+ A dict of the sample's values and metadata.
191
+
192
+ """
193
+ sample: dict[str, Any] = {name: t[index] for name, t in self.tensors.items()}
194
+ for name, values in self.metadata.items():
195
+ sample[name] = values[index]
196
+ return sample