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.
- omniloader/__init__.py +141 -0
- omniloader/cli.py +156 -0
- omniloader/collate.py +123 -0
- omniloader/config.py +457 -0
- omniloader/data/__init__.py +0 -0
- omniloader/data/datasets.py +196 -0
- omniloader/data/factory.py +99 -0
- omniloader/data/npy.py +87 -0
- omniloader/data/splits.py +119 -0
- omniloader/integrations/__init__.py +0 -0
- omniloader/integrations/lightning.py +151 -0
- omniloader/introspection.py +190 -0
- omniloader/loader.py +255 -0
- omniloader/sampling/__init__.py +0 -0
- omniloader/sampling/bucketing.py +120 -0
- omniloader/sampling/sampler.py +112 -0
- omniloader/sampling/strategies.py +394 -0
- omniloader/sampling/subsamplers.py +150 -0
- omniloader/sampling/weights.py +187 -0
- omniloader/schema/__init__.py +0 -0
- omniloader/schema/spec.py +261 -0
- omniloader/schema/unify.py +135 -0
- omniloader/templates/__init__.py +32 -0
- omniloader/templates/config_template.yaml +211 -0
- omniloader/transforms/__init__.py +146 -0
- omniloader/transforms/augment.py +282 -0
- omniloader/transforms/base.py +137 -0
- omniloader/transforms/crop.py +122 -0
- omniloader/transforms/mix.py +112 -0
- omniloader/transforms/normalize.py +235 -0
- omniloader/transforms/stats.py +272 -0
- omniloader/utils/__init__.py +0 -0
- omniloader/utils/padding.py +159 -0
- omniloader/utils/seeding.py +31 -0
- omniloader-1.0.0.dist-info/METADATA +610 -0
- omniloader-1.0.0.dist-info/RECORD +39 -0
- omniloader-1.0.0.dist-info/WHEEL +4 -0
- omniloader-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|