Skip to content

Commit 071e1d8

Browse files
Hananeh Oliaeiclaude
andcommitted
Address PR review comments
- Fix collate_fn fallback to work with dict batches and return consistent dict - Gate train/val part building in setup() by stage - Remove dead `if s.root is None` guard - Add comment in lightning.py about Dataset_ID for future multi-head use - Add val_frac comment in configs clarifying it is ignored when split_file is set - Add tests for AddDatasetID, collate_fn fallback, DatasetSpec, RhoRead init and setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c9dd6e3 commit 071e1d8

6 files changed

Lines changed: 307 additions & 13 deletions

File tree

src/electrai/configs/MP/config_resnet.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ data:
44
datasets:
55
- root: /scratch/gpfs/ROSENGROUP/common/globus_share_OA/mp/chg_datasets/dataset_2/mp_filelist.txt
66
split_file: /scratch/gpfs/ROSENGROUP/common/globus_share_OA/mp/chg_datasets/dataset_2/split.json
7-
val_frac: 0.005
7+
val_frac: 0.005 # ignored when split_file is provided
88
precision: f32
99
batch_size: 1
1010
train_workers: 8

src/electrai/configs/MP/config_resunet.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ data:
44
datasets:
55
- root: /scratch/gpfs/ROSENGROUP/common/globus_share_OA/mp/chg_datasets/dataset_2/mp_filelist.txt
66
split_file: /scratch/gpfs/ROSENGROUP/common/globus_share_OA/mp/chg_datasets/dataset_2/split.json
7-
val_frac: 0.005
7+
val_frac: 0.005 # ignored when split_file is provided
88
precision: f32
99
batch_size: 1
1010
train_workers: 8

src/electrai/dataloader/collate.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,4 @@ def collate_fn(batch):
77
try:
88
return default_collate(batch)
99
except RuntimeError:
10-
x, y, index, dataset_id = zip(*batch, strict=True)
11-
return list(x), list(y), list(index), list(dataset_id)
10+
return {k: [d[k] for d in batch] for k in batch[0]}

src/electrai/dataloader/dataset.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,6 @@ def __init__(
7979
]
8080
filled: list[DatasetSpec] = []
8181
for i, s in enumerate(specs):
82-
if s.root is None:
83-
raise ValueError(
84-
f"`root` is required for dataset {i + 1}. Received: {s.root!r}"
85-
)
8682
dataset_id = s.dataset_id if s.dataset_id is not None else i
8783
filled.append(
8884
DatasetSpec(
@@ -120,10 +116,12 @@ def setup(self, stage: str):
120116

121117
dataset_id = int(spec.dataset_id)
122118

123-
train_parts.append(AddDatasetID(splits["train"], dataset_id))
124-
val_parts.append(AddDatasetID(splits["validation"], dataset_id))
125-
if "test" in splits and splits["test"] is not None:
126-
test_parts.append(AddDatasetID(splits["test"], dataset_id))
119+
if stage == "fit":
120+
train_parts.append(AddDatasetID(splits["train"], dataset_id))
121+
val_parts.append(AddDatasetID(splits["validation"], dataset_id))
122+
elif stage == "test":
123+
if "test" in splits and splits["test"] is not None:
124+
test_parts.append(AddDatasetID(splits["test"], dataset_id))
127125

128126
if stage == "fit":
129127
self.train_set = (

src/electrai/lightning.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def validation_step(self, batch):
4343
return loss
4444

4545
def _loss_calculation(self, batch):
46+
# batch["Dataset_ID"] is available for future multi-head model extensions
4647
x = batch["data"]
4748
y = batch["label"]
4849
if isinstance(x, list):

tests/electrai/dataloader/test_dataset.py

Lines changed: 297 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import json
45
from typing import TYPE_CHECKING
6+
from unittest.mock import MagicMock, patch
57

68
import numpy as np
79
import pytest
10+
import torch
811
import zarr
12+
from torch.utils.data import Dataset
913

10-
from electrai.dataloader.dataset import RhoData
14+
from electrai.dataloader.collate import collate_fn
15+
from electrai.dataloader.dataset import AddDatasetID, DatasetSpec, RhoData, RhoRead
1116
from electrai.dataloader.utils import load_zarr
1217

1318
if TYPE_CHECKING:
1419
from pathlib import Path
1520

1621

22+
# --- Fixtures ---
23+
24+
1725
@pytest.fixture
1826
def rng():
1927
return np.random.default_rng(seed=0)
@@ -39,6 +47,24 @@ def filelist(tmp_path: Path, zarr_root: Path) -> Path:
3947
return filelist_path
4048

4149

50+
class SimpleDataset(Dataset):
51+
def __init__(self, n: int):
52+
self.n = n
53+
54+
def __len__(self):
55+
return self.n
56+
57+
def __getitem__(self, idx):
58+
return {
59+
"data": torch.tensor([float(idx)]),
60+
"label": torch.tensor([float(idx)]),
61+
"index": str(idx),
62+
}
63+
64+
65+
# --- TestLoadZarr ---
66+
67+
4268
class TestLoadZarr:
4369
def test_returns_arrays_divided_by_volume(self, zarr_root: Path):
4470
data, label = load_zarr(zarr_root, "mp-1")
@@ -57,6 +83,9 @@ def test_missing_structure_attr_raises(self, tmp_path: Path, rng):
5783
load_zarr(tmp_path, "bad")
5884

5985

86+
# --- TestRhoDataFormatDetection ---
87+
88+
6089
class TestRhoDataFormatDetection:
6190
def test_detects_zarr_format(self, filelist: Path):
6291
dataset = RhoData(str(filelist), precision="f32", augmentation=False)
@@ -90,3 +119,270 @@ def test_getitem_zarr(self, filelist: Path):
90119
assert "label" in item
91120
assert "index" in item
92121
assert item["data"].shape[0] == 1 # unsqueeze adds channel dim
122+
123+
124+
# --- TestAddDatasetID ---
125+
126+
127+
class TestAddDatasetID:
128+
def test_len_preserved(self):
129+
base = SimpleDataset(10)
130+
wrapped = AddDatasetID(base, functional_id=3)
131+
assert len(wrapped) == 10
132+
133+
def test_dataset_id_added(self):
134+
base = SimpleDataset(5)
135+
wrapped = AddDatasetID(base, functional_id=7)
136+
sample = wrapped[0]
137+
assert sample["Dataset_ID"] == 7
138+
139+
def test_dataset_id_is_int(self):
140+
base = SimpleDataset(3)
141+
wrapped = AddDatasetID(base, functional_id="2")
142+
assert isinstance(wrapped[0]["Dataset_ID"], int)
143+
144+
def test_original_keys_preserved(self):
145+
base = SimpleDataset(3)
146+
wrapped = AddDatasetID(base, functional_id=1)
147+
sample = wrapped[0]
148+
assert "data" in sample
149+
assert "label" in sample
150+
assert "index" in sample
151+
152+
def test_custom_key(self):
153+
base = SimpleDataset(3)
154+
wrapped = AddDatasetID(base, functional_id=5, key="src_id")
155+
assert "src_id" in wrapped[0]
156+
assert wrapped[0]["src_id"] == 5
157+
158+
159+
# --- TestCollateFn ---
160+
161+
162+
class TestCollateFn:
163+
def test_uniform_shapes_uses_default_collate(self):
164+
batch = [
165+
{
166+
"data": torch.zeros(4, 4, 4),
167+
"label": torch.ones(4, 4, 4),
168+
"index": "a",
169+
"Dataset_ID": 0,
170+
},
171+
{
172+
"data": torch.zeros(4, 4, 4),
173+
"label": torch.ones(4, 4, 4),
174+
"index": "b",
175+
"Dataset_ID": 0,
176+
},
177+
]
178+
result = collate_fn(batch)
179+
assert isinstance(result, dict)
180+
assert result["data"].shape == (2, 4, 4, 4)
181+
182+
def test_mismatched_shapes_fallback_returns_dict(self):
183+
batch = [
184+
{
185+
"data": torch.zeros(4, 4, 4),
186+
"label": torch.ones(4, 4, 4),
187+
"index": "a",
188+
"Dataset_ID": 0,
189+
},
190+
{
191+
"data": torch.zeros(8, 8, 8),
192+
"label": torch.ones(8, 8, 8),
193+
"index": "b",
194+
"Dataset_ID": 1,
195+
},
196+
]
197+
result = collate_fn(batch)
198+
assert isinstance(result, dict)
199+
assert len(result["data"]) == 2
200+
assert len(result["label"]) == 2
201+
202+
def test_fallback_preserves_all_keys(self):
203+
batch = [
204+
{
205+
"data": torch.zeros(4, 4, 4),
206+
"label": torch.ones(4, 4, 4),
207+
"index": "a",
208+
"Dataset_ID": 0,
209+
},
210+
{
211+
"data": torch.zeros(8, 8, 8),
212+
"label": torch.ones(8, 8, 8),
213+
"index": "b",
214+
"Dataset_ID": 1,
215+
},
216+
]
217+
result = collate_fn(batch)
218+
assert set(result.keys()) == {"data", "label", "index", "Dataset_ID"}
219+
220+
def test_fallback_correct_values(self):
221+
batch = [
222+
{
223+
"data": torch.zeros(2, 2, 2),
224+
"label": torch.ones(2, 2, 2),
225+
"index": "x",
226+
"Dataset_ID": 3,
227+
},
228+
{
229+
"data": torch.zeros(3, 3, 3),
230+
"label": torch.ones(3, 3, 3),
231+
"index": "y",
232+
"Dataset_ID": 5,
233+
},
234+
]
235+
result = collate_fn(batch)
236+
assert result["index"] == ["x", "y"]
237+
assert result["Dataset_ID"] == [3, 5]
238+
239+
240+
# --- TestDatasetSpec ---
241+
242+
243+
class TestDatasetSpec:
244+
def test_required_root(self):
245+
spec = DatasetSpec(root="/some/path")
246+
assert spec.root == "/some/path"
247+
248+
def test_defaults_are_none(self):
249+
spec = DatasetSpec(root="/p")
250+
assert spec.split_file is None
251+
assert spec.val_frac is None
252+
assert spec.dataset_id is None
253+
254+
def test_frozen(self):
255+
spec = DatasetSpec(root="/p")
256+
with pytest.raises(dataclasses.FrozenInstanceError):
257+
spec.root = "/other"
258+
259+
260+
# --- TestRhoReadInit ---
261+
262+
263+
class TestRhoReadInit:
264+
def test_empty_datasets_raises(self):
265+
with pytest.raises(ValueError, match="at least one"):
266+
RhoRead(datasets=[])
267+
268+
def test_none_datasets_raises(self):
269+
with pytest.raises(ValueError, match="at least one"):
270+
RhoRead(datasets=None)
271+
272+
def test_auto_assigns_dataset_id(self):
273+
reader = RhoRead(
274+
datasets=[{"root": "/a/filelist.txt"}, {"root": "/b/filelist.txt"}]
275+
)
276+
assert reader.specs[0].dataset_id == 0
277+
assert reader.specs[1].dataset_id == 1
278+
279+
def test_explicit_dataset_id_preserved(self):
280+
reader = RhoRead(datasets=[{"root": "/a/filelist.txt", "dataset_id": 42}])
281+
assert reader.specs[0].dataset_id == 42
282+
283+
def test_default_val_frac_applied(self):
284+
reader = RhoRead(datasets=[{"root": "/a/filelist.txt"}], default_val_frac=0.1)
285+
assert reader.specs[0].val_frac == 0.1
286+
287+
def test_per_spec_val_frac_overrides_default(self):
288+
reader = RhoRead(
289+
datasets=[{"root": "/a/filelist.txt", "val_frac": 0.2}],
290+
default_val_frac=0.1,
291+
)
292+
assert reader.specs[0].val_frac == 0.2
293+
294+
def test_default_split_file_applied(self):
295+
reader = RhoRead(
296+
datasets=[{"root": "/a/filelist.txt"}],
297+
default_split_file="/default/split.json",
298+
)
299+
assert reader.specs[0].split_file == "/default/split.json"
300+
301+
def test_per_spec_split_file_overrides_default(self):
302+
reader = RhoRead(
303+
datasets=[{"root": "/a/filelist.txt", "split_file": "/custom/split.json"}],
304+
default_split_file="/default/split.json",
305+
)
306+
assert reader.specs[0].split_file == "/custom/split.json"
307+
308+
309+
# --- TestRhoReadSetup ---
310+
311+
312+
class TestRhoReadSetup:
313+
def _make_fake_splits(self, n: int = 5):
314+
ds = SimpleDataset(n)
315+
from torch.utils.data import Subset
316+
317+
train = Subset(ds, list(range(4)))
318+
val = Subset(ds, [4])
319+
return {"train": train, "validation": val}
320+
321+
@patch("electrai.dataloader.dataset.split_data")
322+
@patch("electrai.dataloader.dataset.RhoData")
323+
def test_fit_stage_builds_train_and_val(self, mock_rho, mock_split):
324+
mock_rho.return_value = MagicMock(spec=Dataset)
325+
mock_split.return_value = self._make_fake_splits()
326+
327+
reader = RhoRead(datasets=[{"root": "/a/filelist.txt"}])
328+
reader.setup("fit")
329+
330+
assert reader.train_set is not None
331+
assert reader.val_set is not None
332+
assert reader.test_set is None
333+
334+
@patch("electrai.dataloader.dataset.split_data")
335+
@patch("electrai.dataloader.dataset.RhoData")
336+
def test_test_stage_does_not_build_train_or_val(self, mock_rho, mock_split):
337+
mock_rho.return_value = MagicMock(spec=Dataset)
338+
splits = self._make_fake_splits()
339+
splits["test"] = splits["train"]
340+
mock_split.return_value = splits
341+
342+
reader = RhoRead(datasets=[{"root": "/a/filelist.txt"}])
343+
reader.setup("test")
344+
345+
assert reader.test_set is not None
346+
assert reader.train_set is None
347+
assert reader.val_set is None
348+
349+
@patch("electrai.dataloader.dataset.split_data")
350+
@patch("electrai.dataloader.dataset.RhoData")
351+
def test_multi_dataset_concat(self, mock_rho, mock_split):
352+
from torch.utils.data import ConcatDataset
353+
354+
mock_rho.return_value = MagicMock(spec=Dataset)
355+
mock_split.return_value = self._make_fake_splits()
356+
357+
reader = RhoRead(
358+
datasets=[{"root": "/a/filelist.txt"}, {"root": "/b/filelist.txt"}]
359+
)
360+
reader.setup("fit")
361+
362+
assert isinstance(reader.train_set, ConcatDataset)
363+
assert isinstance(reader.val_set, ConcatDataset)
364+
365+
@patch("electrai.dataloader.dataset.split_data")
366+
@patch("electrai.dataloader.dataset.RhoData")
367+
def test_single_dataset_no_concat(self, mock_rho, mock_split):
368+
from torch.utils.data import ConcatDataset
369+
370+
mock_rho.return_value = MagicMock(spec=Dataset)
371+
mock_split.return_value = self._make_fake_splits()
372+
373+
reader = RhoRead(datasets=[{"root": "/a/filelist.txt"}])
374+
reader.setup("fit")
375+
376+
assert not isinstance(reader.train_set, ConcatDataset)
377+
378+
@patch("electrai.dataloader.dataset.split_data")
379+
@patch("electrai.dataloader.dataset.RhoData")
380+
def test_dataset_id_propagated(self, mock_rho, mock_split):
381+
mock_rho.return_value = MagicMock(spec=Dataset)
382+
mock_split.return_value = self._make_fake_splits()
383+
384+
reader = RhoRead(datasets=[{"root": "/a/filelist.txt", "dataset_id": 99}])
385+
reader.setup("fit")
386+
387+
sample = reader.train_set[0]
388+
assert sample["Dataset_ID"] == 99

0 commit comments

Comments
 (0)