Download tests/test_data.py from Celsia/torch-dimensions: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/Celsia/torch-dimensions/resolve/main/tests/test_data.py
- Command line
-
hf download hf://Celsia/torch-dimensions/tests/test_data.py
-
curl -L -o test_data.py https://huggingface.co/Celsia/torch-dimensions/resolve/main/tests/test_data.py
17.7 kB
| """Phase 5 acceptance for td.data. See PLAN.md. | |
| The load-bearing test is that values land in the *right cells*, checked against | |
| an independently built reference. A shape check would pass just as happily on a | |
| mis-shuffled lattice, which is the exact failure this layer exists to prevent. | |
| """ | |
| import pytest | |
| import torch | |
| from torch.utils.data import DataLoader | |
| import torch_dimensions as td | |
| from torch_dimensions.data import ( | |
| LatticeDataset, | |
| LatticeSource, | |
| LatticeWindow, | |
| TensorSource, | |
| collate_lattice, | |
| from_coords, | |
| from_table, | |
| ) | |
| STATES = ("CA", "NY", "TX") | |
| SKUS = ("a", "b") | |
| def rows(n_time=8, skip=(("NY", "b"),)): | |
| """Long-format rows whose value encodes its own coordinates, so a | |
| misplacement is detectable rather than merely plausible.""" | |
| coords, times, values = [], [], [] | |
| for t in range(n_time): | |
| for s in STATES: | |
| for k in SKUS: | |
| if (s, k) in skip: | |
| continue | |
| coords.append((s, k)) | |
| times.append(2000 + t) | |
| values.append([t * 100.0 + STATES.index(s) * 10.0 + SKUS.index(k)]) | |
| return coords, times, values | |
| # -- from_coords ------------------------------------------------------------- | |
| def test_vocabularies_are_sorted_and_shape_follows(): | |
| cm = from_coords([("TX", "b"), ("CA", "a"), ("CA", "b")], names=("state", "sku")) | |
| assert cm.vocabs == (("CA", "TX"), ("a", "b")) | |
| assert cm.lattice.shape == (2, 2) | |
| assert cm.lattice.axis_names == ("time", "state", "sku") | |
| def test_unobserved_combinations_are_marked_absent(): | |
| cm = from_coords([("CA", "a"), ("CA", "b"), ("TX", "a")]) | |
| assert cm.lattice.n_valid == 3 and cm.lattice.n_cells == 4 | |
| assert not cm.lattice.valid[1, 1] # TX/b never seen | |
| def test_a_fully_observed_grid_is_dense_not_a_mask_of_all_true(): | |
| cm = from_coords([(s, k) for s in STATES for k in SKUS]) | |
| assert cm.lattice.is_dense | |
| def test_dense_flag_overrides_the_inference(): | |
| cm = from_coords([("CA", "a"), ("TX", "b")], dense=True) | |
| assert cm.lattice.is_dense and cm.lattice.n_cells == 4 | |
| def test_time_axis_is_optional(): | |
| assert not from_coords([("CA", "a")], time=False).lattice.time | |
| def test_index_round_trips_through_decode(): | |
| cm = from_coords([(s, k) for s in STATES for k in SKUS], names=("state", "sku")) | |
| pairs = [(s, k) for s in STATES for k in SKUS] | |
| for row, flat in zip(pairs, cm.index.tolist(), strict=True): | |
| assert cm.decode(flat) == row | |
| def test_encode_matches_the_index_built_at_construction(): | |
| coords = [("TX", "b"), ("CA", "a"), ("NY", "b")] | |
| cm = from_coords(coords) | |
| assert torch.equal(cm.encode(coords), cm.index) | |
| def test_encode_refuses_unknown_values_rather_than_folding_them_to_zero(): | |
| cm = from_coords([("CA", "a")], names=("state", "sku")) | |
| with pytest.raises(KeyError, match="'state'"): | |
| cm.encode([("ZZ", "a")]) | |
| with pytest.raises(ValueError, match="expected 2 coordinates"): | |
| cm.encode([("CA",)]) | |
| def test_integer_coordinates_work_too(): | |
| cm = from_coords(torch.tensor([[0, 1], [2, 0]])) | |
| assert cm.lattice.shape == (2, 2) | |
| def test_from_coords_errors(coords, match): | |
| with pytest.raises(ValueError, match=match): | |
| from_coords(coords) | |
| # -- from_table -------------------------------------------------------------- | |
| def test_values_land_in_the_cells_their_coordinates_name(): | |
| """The whole point of the layer. Checked against the encoding baked into | |
| each value, not against another call to our own mapping.""" | |
| coords, times, values = rows() | |
| table = from_table(coords, times, values, names=("state", "sku")) | |
| for t in range(len(table.times)): | |
| for si, s in enumerate(STATES): | |
| for ki, k in enumerate(SKUS): | |
| got = table.series[t, si, ki, 0].item() | |
| if (s, k) == ("NY", "b"): | |
| assert got == 0.0, "absent cell must be exactly zero" | |
| else: | |
| assert got == t * 100.0 + si * 10.0 + ki | |
| def test_absent_cells_are_zero_and_marked(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| assert table.lattice.n_valid == 5 and table.lattice.n_cells == 6 | |
| assert table.series.masked_select(~table.lattice.valid.reshape(1, 3, 2, 1)).abs().max() == 0 | |
| def test_time_axis_is_the_sorted_distinct_timestamps(): | |
| coords, times, values = rows(n_time=4) | |
| table = from_table(coords, times, values) | |
| assert table.times == (2000, 2001, 2002, 2003) | |
| assert len(table) == 4 and table.series.shape[0] == 4 | |
| def test_shape_and_feature_count(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| assert table.series.shape == (8, 3, 2, 1) | |
| assert table.n_features == 1 | |
| assert "5/6" in repr(table) | |
| def test_duplicate_time_cell_rows_are_refused(): | |
| """Keeping the last silently would turn a join bug into a plausible | |
| dataset.""" | |
| with pytest.raises(ValueError, match="duplicate"): | |
| from_table([("CA", "a"), ("CA", "a")], [2000, 2000], [[1.0], [2.0]]) | |
| def test_the_same_cell_at_different_times_is_not_a_duplicate(): | |
| table = from_table([("CA", "a"), ("CA", "a")], [2000, 2001], [[1.0], [2.0]]) | |
| assert table.series.flatten().tolist() == [1.0, 2.0] | |
| def test_from_table_errors(kwargs, match): | |
| base = { | |
| "coords": [("CA", "a"), ("CA", "b")], | |
| "times": [2000, 2000], | |
| "values": [[1.0], [2.0]], | |
| } | |
| with pytest.raises(ValueError, match=match): | |
| from_table(**{**base, **kwargs}) | |
| # -- windowing --------------------------------------------------------------- | |
| def test_windows_tile_the_axis_with_targets_after_inputs(): | |
| w = LatticeWindow(n_time=10, input_len=3, horizon=2) | |
| assert len(w) == 6 | |
| assert w[0] == (0, 3, 3, 5) | |
| assert w[-1] == (5, 8, 8, 10) | |
| for win in w: | |
| assert win.x1 == win.y0, "a window must never see its own target" | |
| def test_stride_controls_the_step(): | |
| assert [win.x0 for win in LatticeWindow(10, 3, 0, stride=3)] == [0, 3, 6] | |
| def test_horizon_zero_gives_no_target_range(): | |
| w = LatticeWindow(6, 3) | |
| assert all(win.y0 == win.y1 for win in w) | |
| def test_split_drops_windows_straddling_the_boundary(): | |
| """The gap is deliberate: a straddling window would put post-cut timesteps | |
| inside a training input.""" | |
| w = LatticeWindow(12, 3, 1) | |
| before, after = w.split(6) | |
| assert all(win.y1 <= 6 for win in before) | |
| assert all(win.x0 >= 6 for win in after) | |
| assert len(before) + len(after) < len(w) | |
| def test_split_at_time_uses_timestamps(): | |
| w = LatticeWindow(6, 2) | |
| times = [2000, 2001, 2002, 2003, 2004, 2005] | |
| by_index = w.split(3) | |
| by_time = w.split_at_time(times, 2003) | |
| assert [list(x) for x in by_time] == [list(x) for x in by_index] | |
| def test_split_at_a_time_past_the_end_puts_everything_before(): | |
| w = LatticeWindow(6, 2) | |
| before, after = w.split_at_time([2000, 2001, 2002, 2003, 2004, 2005], 2099) | |
| assert len(before) == len(w) and len(after) == 0 | |
| def test_slicing_preserves_the_window_type(): | |
| w = LatticeWindow(10, 3) | |
| assert isinstance(w[:2], LatticeWindow) and len(w[:2]) == 2 | |
| def test_window_errors(kwargs, match): | |
| with pytest.raises(ValueError, match=match): | |
| LatticeWindow(**{"n_time": 10, "input_len": 3, **kwargs}) | |
| # -- source / dataset / collate ---------------------------------------------- | |
| def test_tensor_source_satisfies_the_protocol(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| src = TensorSource(table.series, table.lattice) | |
| assert isinstance(src, LatticeSource) | |
| assert len(src) == 8 and src[0:3].shape == (3, 3, 2, 1) | |
| def test_source_rejects_a_series_that_does_not_match_the_lattice(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| with pytest.raises(ValueError, match="lattice dims"): | |
| TensorSource(torch.zeros(8, 9, 9, 1), table.lattice) | |
| def test_a_custom_source_needs_no_inheritance(): | |
| """The protocol is the customization story — this class knows nothing | |
| about the library.""" | |
| table = from_table(*rows(), names=("state", "sku")) | |
| class Custom: | |
| def __init__(self, series, lattice): | |
| self._s, self._l = series, lattice | |
| def lattice(self): | |
| return self._l | |
| def __len__(self): | |
| return self._s.shape[0] | |
| def __getitem__(self, sl): | |
| return self._s[sl] | |
| ds = LatticeDataset(Custom(table.series, table.lattice), LatticeWindow(8, 3, 1)) | |
| assert ds[0]["x"].shape == (3, 3, 2, 1) | |
| def test_dataset_yields_inputs_and_targets_without_the_lattice(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| ds = LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) | |
| sample = ds[0] | |
| assert set(sample) == {"x", "y", "window"}, "the lattice is static, not per-sample" | |
| assert sample.x.shape == (3, 3, 2, 1) and sample.y.shape == (1, 3, 2, 1) | |
| assert ds.lattice is table.lattice | |
| def test_dataset_refuses_windows_that_run_past_the_source(): | |
| table = from_table(*rows(n_time=4), names=("state", "sku")) | |
| with pytest.raises(ValueError, match="past the end"): | |
| LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) | |
| def test_dataset_refuses_an_empty_window_set(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| empty = LatticeWindow(8, 3, 1).split(0)[0] | |
| with pytest.raises(ValueError, match="empty"): | |
| LatticeDataset(TensorSource(table.series, table.lattice), empty) | |
| def test_collate_stacks_and_keeps_the_lattice_out(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| ds = LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) | |
| batch = collate_lattice([ds[0], ds[1], ds[2]]) | |
| assert batch.x.shape == (3, 3, 3, 2, 1) | |
| assert batch.y.shape == (3, 1, 3, 2, 1) | |
| assert "lattice" not in batch and len(batch["windows"]) == 3 | |
| def test_collate_refuses_ragged_windows_rather_than_padding(): | |
| table = from_table(*rows(), names=("state", "sku")) | |
| ds = LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) | |
| a = ds[0] | |
| b = dict(a) | |
| b["x"] = a["x"][:2] | |
| with pytest.raises(ValueError, match="differing input lengths"): | |
| collate_lattice([a, b]) | |
| def test_collate_refuses_an_empty_batch(): | |
| with pytest.raises(ValueError, match="empty"): | |
| collate_lattice([]) | |
| # -- end to end -------------------------------------------------------------- | |
| def test_table_to_dataloader_to_model_to_backward(): | |
| table = from_table(*rows(n_time=16), names=("state", "sku")) | |
| windows = LatticeWindow(len(table), input_len=4, horizon=1) | |
| train, _ = windows.split(10) | |
| ds = LatticeDataset(TensorSource(table.series, table.lattice), train) | |
| dl = DataLoader(ds, batch_size=2, shuffle=True, collate_fn=collate_lattice) | |
| model = td.LSTM(d_model=6, n_layers=3, lattice=table.lattice, d_input=table.n_features) | |
| batch = next(iter(dl)) | |
| out = model(batch.x) | |
| assert out.shape == (2, 4, 3, 2, 6) | |
| out.pow(2).mean().backward() | |
| assert all(p.grad is not None for p in model.parameters()) | |
| def test_d_input_is_optional_when_the_data_is_already_d_model_wide(): | |
| lat = td.Lattice(shape=(2, 3), time=True) | |
| model = td.LSTM(d_model=5, n_layers=3, lattice=lat) | |
| assert model(torch.randn(1, 4, 2, 3, 5)).shape == (1, 4, 2, 3, 5) | |
| # -- multiprocessing safety --------------------------------------------------- | |
| def _small_dataset(): | |
| lat = td.Lattice(shape=(2, 3), time=True) | |
| source = TensorSource(torch.randn(8, 2, 3, 4), lat) | |
| return LatticeDataset(source, LatticeWindow(8, input_len=3, horizon=1)) | |
| def test_samples_and_batches_survive_pickling(): | |
| """DataLoader workers send every Sample — and the collated Batch — through | |
| a pickled queue. `__getattr__ = dict.__getitem__` broke that: pickle probes | |
| optional dunders with getattr and tolerates AttributeError, not the | |
| KeyError a dict lookup raises. num_workers>0 crashed outright.""" | |
| import pickle | |
| sample = _small_dataset()[0] | |
| back = pickle.loads(pickle.dumps(sample)) | |
| assert torch.equal(back.x, sample.x) and torch.equal(back.y, sample.y) | |
| batch = collate_lattice([sample, sample]) | |
| back = pickle.loads(pickle.dumps(batch)) | |
| assert torch.equal(back.x, batch.x) | |
| def test_a_missing_field_reads_as_absent_not_as_a_keyerror(): | |
| """`getattr(sample, "y", None)` and `hasattr` must behave; a horizon-0 | |
| sample simply has no target.""" | |
| lat = td.Lattice(shape=(2, 3), time=True) | |
| source = TensorSource(torch.randn(8, 2, 3, 4), lat) | |
| sample = LatticeDataset(source, LatticeWindow(8, input_len=3, horizon=0))[0] | |
| assert getattr(sample, "y", None) is None | |
| assert not hasattr(sample, "y") | |
| with pytest.raises(AttributeError): | |
| _ = sample.y | |
| def test_dataloader_with_worker_processes(): | |
| """The end-to-end form of the pickling guarantee: real worker processes, | |
| real queues. This is the configuration every user with a large dataset | |
| reaches for first.""" | |
| dl = DataLoader(_small_dataset(), batch_size=2, num_workers=2, collate_fn=collate_lattice) | |
| batch = next(iter(dl)) | |
| assert batch.x.shape == (2, 3, 2, 3, 4) and batch.y.shape == (2, 1, 2, 3, 4) | |
| def test_collate_refuses_mixed_target_presence(): | |
| """Keying off samples[0] silently dropped every target whenever the first | |
| sample happened to lack one.""" | |
| from torch_dimensions.data.source import Sample | |
| a = Sample(x=torch.zeros(3, 2), window=None) | |
| b = Sample(x=torch.zeros(3, 2), y=torch.ones(1, 2), window=None) | |
| with pytest.raises(ValueError, match="mixed-horizon"): | |
| collate_lattice([a, b]) | |
| with pytest.raises(ValueError, match="mixed-horizon"): | |
| collate_lattice([b, a]) | |
| def test_split_at_time_refuses_unsorted_times(): | |
| """An unsorted timestamp list used to produce a silently nonsensical | |
| split — the quietest possible leakage bug.""" | |
| w = LatticeWindow(6, input_len=2, horizon=1) | |
| with pytest.raises(ValueError, match="sorted"): | |
| w.split_at_time([3, 1, 2, 5, 4, 6], 4) | |
| # -- on-disk source and masked normalization ---------------------------------- | |
| # `.npy` is numpy's container; torch does not require numpy, and this project's | |
| # own CI is an install without it. | |
| numpy = pytest.importorskip("numpy") | |
| def test_memmap_source_passes_the_source_conformance_check(tmp_path): | |
| lat = td.Lattice(shape=(3, 4), names=("h", "w")) | |
| series = torch.randn(20, 3, 4, 2) | |
| path = td.data.MemmapSource.write(tmp_path / "series.npy", series) | |
| source = td.data.MemmapSource(path, lat) | |
| report = td.testing.check_data_source(source) | |
| assert report, str(report) | |
| assert torch.allclose(source[0:20], series, atol=1e-6) | |
| def test_a_memmap_source_reaches_a_worker_process(tmp_path): | |
| """The failure this class exists to demonstrate: a live mmap handle either | |
| fails to pickle or pickles into something invalid in the child, and under | |
| DataLoader(num_workers>0) that presents as a hang (DEBUG.md #9).""" | |
| import pickle | |
| lat = td.Lattice(shape=(2, 2), names=("a", "b")) | |
| series = torch.randn(6, 2, 2, 1) | |
| path = td.data.MemmapSource.write(tmp_path / "s.npy", series) | |
| source = td.data.MemmapSource(path, lat) | |
| _ = source[0:2] # force the handle open *before* pickling | |
| revived = pickle.loads(pickle.dumps(source)) | |
| assert revived._array is None, "the mmap handle travelled into the pickle" | |
| assert torch.allclose(revived[0:6], source[0:6]) | |
| def test_a_memmap_source_refuses_a_file_that_is_not_its_lattice(tmp_path): | |
| path = td.data.MemmapSource.write(tmp_path / "s.npy", torch.randn(5, 9, 9, 1)) | |
| with pytest.raises(ValueError, match="lattice dims"): | |
| td.data.MemmapSource(path, td.Lattice(shape=(3, 4), names=("h", "w"))) | |
| def test_masked_stats_ignore_absent_cells(): | |
| """A mean over a sparse lattice's structural zeros is dragged toward zero | |
| in proportion to the sparsity, and nothing about the model then looks | |
| wrong.""" | |
| valid = torch.tensor([[True, False], [True, True]]) | |
| lat = td.Lattice(shape=(2, 2), names=("a", "b"), valid=valid) | |
| series = torch.full((10, 2, 2, 1), 5.0) | |
| series[:, 0, 1] = 0.0 # the absent cell, zero as the library guarantees | |
| naive = series.mean() | |
| stats = td.data.masked_stats(series, lat) | |
| assert abs(float(naive) - 3.75) < 1e-5, "the naive mean is dragged toward zero" | |
| present = stats.mean.reshape(-1)[[0, 2, 3]] | |
| assert torch.allclose(present, torch.full((3,), 5.0)), stats.mean | |
| def test_normalizer_round_trips(): | |
| lat = td.Lattice(shape=(2, 3), names=("a", "b")) | |
| series = torch.randn(30, 2, 3, 2) * 4 + 7 | |
| stats = td.data.masked_stats(series, lat) | |
| assert torch.allclose(stats.invert(stats.apply(series)), series, atol=1e-4) | |
| normalized = stats.apply(series) | |
| assert normalized.mean().abs() < 0.1 and abs(float(normalized.std()) - 1) < 0.2 | |
| def test_masked_stats_treat_nan_as_absent(): | |
| lat = td.Lattice(shape=(2,), names=("a",)) | |
| series = torch.full((8, 2, 1), 3.0) | |
| series[0:4, 0] = float("nan") | |
| stats = td.data.masked_stats(series, lat) | |
| assert torch.isfinite(stats.mean).all() | |
| assert abs(float(stats.mean.reshape(-1)[0]) - 3.0) < 1e-5 | |