Skip to content

Commit 45a01ca

Browse files
Merge pull request #134 from DeepLearnPhysics/feature/joint-dataset
Add joint overlay dataset
2 parents a80a8df + d45b95d commit 45a01ca

10 files changed

Lines changed: 970 additions & 13 deletions

File tree

src/spine/io/dataset/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
"""Torch-backed dataset adapters for SPINE IO.
22
3-
The dataset layer sits between low-level readers and the DataLoader. It is
3+
The dataset layer sits between low-level readers and PyTorch DataLoaders. It is
44
responsible for:
55
66
- exposing ``__len__`` and ``__getitem__`` for torch
77
- converting raw reader outputs into parser products
88
- attaching augmentation, collate-type, and overlay metadata
9+
- composing source datasets when training needs aligned cache products
10+
(``MixedDataset``) or unaligned overlay pairs (``JointDataset``)
911
"""
1012

1113
from .hdf5 import HDF5Dataset
14+
from .joint import JointDataset
1215
from .larcv import LArCVDataset
1316
from .mixed import MixedDataset
1417

15-
__all__ = ["HDF5Dataset", "LArCVDataset", "MixedDataset"]
18+
__all__ = ["HDF5Dataset", "JointDataset", "LArCVDataset", "MixedDataset"]

src/spine/io/dataset/hdf5.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,11 @@ def __init__(
7676
Reader-specific keyword arguments forwarded to the selected HDF5
7777
backend reader
7878
"""
79+
# Initialize parent class
7980
super().__init__()
8081

82+
# Validate the configuration and prepare reader arguments before
83+
# instantiating the backend.
8184
if not TORCH_AVAILABLE:
8285
raise ImportError("PyTorch is required to use HDF5Dataset.")
8386
if keys is not None and skip_keys is not None:
@@ -94,6 +97,9 @@ def __init__(
9497
)
9598
reader_stage_map: dict[str, str] = {}
9699

100+
# If a parser schema is provided, instantiate the parsers and collect
101+
# the raw HDF5 products they require. In staged mode, also validate
102+
# schema-level stage assignments and build the reader key-to-stage map.
97103
if schema is not None:
98104
if dtype is None:
99105
raise ValueError("An explicit `dtype` is required when using `schema`.")
@@ -125,7 +131,7 @@ def __init__(
125131
else:
126132
self.keys.update(inferred_keys)
127133

128-
self.build_augmenter(augment)
134+
# Initialize the appropriate reader backend
129135
if staged:
130136
self.reader = StageHDF5Reader(
131137
stage=stage,
@@ -136,6 +142,9 @@ def __init__(
136142
else:
137143
self.reader = HDF5Reader(**kwargs)
138144

145+
# Initialize the augmenter
146+
self.build_augmenter(augment)
147+
139148
def __len__(self) -> int:
140149
"""Return the number of entries exposed by the backend reader."""
141150
return len(self.reader)

src/spine/io/dataset/joint.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Dataset wrapper for overlaying events from two independent sources."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from typing import Any, ClassVar
7+
8+
from ..overlay import Overlayer
9+
from .base import BaseDataset, DataDict
10+
11+
__all__ = ["JointDataset"]
12+
13+
14+
class JointDataset(BaseDataset):
15+
"""Torch dataset that overlays unaligned primary/secondary events.
16+
17+
This class is intentionally different from :class:`MixedDataset`:
18+
19+
- ``MixedDataset`` is an aligned merge of products that describe the same
20+
event across backends.
21+
- ``JointDataset`` is an unaligned merge of products that describe
22+
different events and should be overlaid for training.
23+
24+
``JointDataset`` does not decide which secondary event to use. A joint
25+
sampler provides indexes of the form ``(primary_idx, secondary_idx)``. If
26+
``secondary_idx`` is ``None`` or if a scalar primary index is provided, the
27+
dataset returns the primary sample unchanged. This keeps pairing policy and
28+
pair probability in the sampler, while this class only instantiates source
29+
datasets and applies the existing :class:`spine.io.overlay.Overlayer`.
30+
31+
The first implementation supports one primary event plus at most one
32+
secondary event per sample. Both sources must expose the same data keys,
33+
collate types, and overlay methods so that the overlayer can operate on a
34+
common product schema.
35+
"""
36+
37+
name: ClassVar[str] = "joint"
38+
joint: ClassVar[bool] = True
39+
primary: BaseDataset
40+
secondary: BaseDataset
41+
reader: Any
42+
43+
def __init__(
44+
self,
45+
primary: Mapping[str, Any] | str | BaseDataset,
46+
secondary: Mapping[str, Any] | str | BaseDataset,
47+
base: Mapping[str, Any] | str | None = None,
48+
dtype: str | None = None,
49+
augment: Mapping[str, Any] | None = None,
50+
) -> None:
51+
"""Instantiate the joint overlay dataset.
52+
53+
Parameters
54+
----------
55+
primary : mapping, str or BaseDataset
56+
Primary dataset configuration, overrides to a shared ``base``
57+
configuration, or already-instantiated dataset. The primary
58+
controls the length and primary index order.
59+
secondary : mapping, str or BaseDataset
60+
Secondary dataset configuration, overrides to a shared ``base``
61+
configuration, or already-instantiated dataset.
62+
base : mapping or str, optional
63+
Shared dataset configuration merged into both source configs before
64+
instantiation. Source blocks override values from ``base``. Use
65+
this for common schema/parser options, and put source-specific
66+
values such as file paths or entry filters in ``primary`` and
67+
``secondary``.
68+
dtype : str, optional
69+
Floating-point dtype forwarded when instantiating configured
70+
datasets.
71+
augment : mapping, optional
72+
Augmentation applied after the primary sample is returned or after
73+
the primary/secondary samples are overlaid.
74+
"""
75+
# Initialize parent class
76+
super().__init__()
77+
78+
# Instantiate the source datasets. The optional `base` block lets users
79+
# define a common schema once while keeping paths and filters local to
80+
# each source block.
81+
self.primary = self.build_dataset(
82+
self.resolve_source_config(base, primary),
83+
dtype,
84+
)
85+
self.secondary = self.build_dataset(
86+
self.resolve_source_config(base, secondary), dtype
87+
)
88+
89+
# Expose the primary reader for compatibility with code that inspects
90+
# the dataset's main source. Secondary access is internal to overlays.
91+
self.reader = getattr(self.primary, "reader", None)
92+
if len(self.primary) < 1:
93+
raise ValueError("The primary dataset must expose at least one entry.")
94+
if len(self.secondary) < 1:
95+
raise ValueError("The secondary dataset must expose at least one entry.")
96+
97+
# The overlayer expects the same logical products on both sides. It uses
98+
# the primary metadata after compatibility is validated.
99+
self.validate_metadata(
100+
"data type", self.primary.data_types, self.secondary.data_types
101+
)
102+
self.validate_metadata(
103+
"overlay", self.primary.overlay_methods, self.secondary.overlay_methods
104+
)
105+
106+
self.overlayer = Overlayer(
107+
multiplicity=2,
108+
mode="constant",
109+
data_types=self.primary.data_types,
110+
methods=self.primary.overlay_methods,
111+
)
112+
113+
# Initialize the augmenter
114+
self.build_augmenter(augment)
115+
116+
@staticmethod
117+
def resolve_source_config(
118+
base: Mapping[str, Any] | str | None,
119+
source: Mapping[str, Any] | str | BaseDataset,
120+
) -> Mapping[str, Any] | str | BaseDataset:
121+
"""Merge one source override block into the shared base config.
122+
123+
Already-instantiated datasets are returned unchanged. String configs
124+
cannot be merged with ``base`` because there is no mapping to update.
125+
"""
126+
if base is None or not isinstance(source, Mapping):
127+
return source
128+
if not isinstance(base, Mapping):
129+
raise ValueError(
130+
"A shared `base` config can only be merged with source "
131+
"override mappings."
132+
)
133+
134+
return {**dict(base), **dict(source)}
135+
136+
@staticmethod
137+
def build_dataset(
138+
source: Mapping[str, Any] | str | BaseDataset,
139+
dtype: str | None,
140+
) -> BaseDataset:
141+
"""Instantiate one source dataset unless an object is already provided.
142+
143+
Source-specific options, including entry filters, must be present in
144+
the source config itself before this method is called.
145+
"""
146+
if isinstance(source, (Mapping, str)):
147+
from ..factories import dataset_factory
148+
149+
return dataset_factory(source, dtype=dtype)
150+
151+
return source
152+
153+
def __len__(self) -> int:
154+
"""Return the number of primary entries.
155+
156+
Joint samplers iterate over the primary source and independently choose
157+
secondary indexes to pair with those primary entries.
158+
"""
159+
return len(self.primary)
160+
161+
def __getitem__(self, idx: int | tuple[int, int | None]) -> DataDict:
162+
"""Return one primary sample or one primary/secondary overlay.
163+
164+
Parameters
165+
----------
166+
idx : int or tuple[int, int or None]
167+
A scalar index returns the corresponding primary sample without an
168+
overlay. A tuple ``(primary_idx, secondary_idx)`` overlays the two
169+
source samples. A tuple ``(primary_idx, None)`` returns the primary
170+
sample without touching the secondary source.
171+
"""
172+
primary_idx, secondary_idx = self.resolve_pair_index(idx)
173+
primary = self.primary[primary_idx]
174+
if secondary_idx is None:
175+
return self.apply_augmenter(primary)
176+
177+
overlaid = self.overlayer([primary, self.secondary[secondary_idx]])
178+
assert len(overlaid) == 1, "Joint overlays should produce one sample."
179+
return self.apply_augmenter(overlaid[0])
180+
181+
def resolve_pair_index(
182+
self, idx: int | tuple[int, int | None]
183+
) -> tuple[int, int | None]:
184+
"""Resolve the primary and optional secondary indexes for one sample."""
185+
if isinstance(idx, tuple):
186+
if len(idx) != 2:
187+
raise ValueError("JointDataset tuple indexes must have length 2.")
188+
primary_idx, secondary_idx = idx
189+
if secondary_idx is not None:
190+
secondary_idx = self.validate_secondary_index(int(secondary_idx))
191+
return int(primary_idx), secondary_idx
192+
193+
return idx, None
194+
195+
def validate_secondary_index(self, secondary_idx: int) -> int:
196+
"""Validate one secondary index produced by a joint sampler."""
197+
if secondary_idx < 0 or secondary_idx >= len(self.secondary):
198+
raise ValueError("Secondary index is outside of bounds.")
199+
200+
return secondary_idx
201+
202+
@staticmethod
203+
def validate_metadata(
204+
label: str,
205+
primary: Mapping[str, str | None],
206+
secondary: Mapping[str, str | None],
207+
) -> None:
208+
"""Ensure both datasets expose compatible metadata for overlaying.
209+
210+
The current joint overlay implementation is schema-preserving: every
211+
product emitted by the primary must also be emitted by the secondary
212+
with the same collate type and overlay method.
213+
"""
214+
primary_keys = set(primary)
215+
secondary_keys = set(secondary)
216+
if primary_keys != secondary_keys:
217+
missing = sorted(primary_keys - secondary_keys)
218+
extra = sorted(secondary_keys - primary_keys)
219+
raise ValueError(
220+
f"JointDataset {label} keys must match between primary and "
221+
f"secondary datasets. Missing in secondary: {missing}. "
222+
f"Extra in secondary: {extra}."
223+
)
224+
225+
for key in primary:
226+
if primary[key] != secondary[key]:
227+
raise ValueError(
228+
f"JointDataset {label} mismatch for '{key}': "
229+
f"{primary[key]!r} vs {secondary[key]!r}."
230+
)
231+
232+
@property
233+
def data_types(self) -> dict[str, str]:
234+
"""Return the collate type for each joint output product."""
235+
return dict(self.primary.data_types)
236+
237+
@property
238+
def overlay_methods(self) -> dict[str, str | None]:
239+
"""Return overlay methods for any downstream batch-level overlay."""
240+
return dict(self.primary.overlay_methods)
241+
242+
@property
243+
def data_keys(self) -> tuple[str, ...]:
244+
"""Return the names of all products emitted by joint samples."""
245+
return tuple(self.data_types.keys())

0 commit comments

Comments
 (0)