File size: 7,379 Bytes
ecc81b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"""An on-disk source, and masked normalization statistics.

Two things the data layer promised and did not have. The ``LatticeSource``
protocol's claim is that "a memory-mapped array, a zarr store, or a database
cursor all batch correctly" — a claim with two in-memory implementations behind
it, which is a promise rather than a feature. :class:`MemmapSource` is the
on-disk one, and it is written to fail the way real on-disk sources fail so
that :func:`~torch_dimensions.testing.check_data_source` has something honest
to check.

Normalization is here for a narrower reason: a mean taken over a sparse
lattice's zeros is wrong, invisibly. The absent cells are exactly zero by
construction, so they drag every statistic toward zero in proportion to how
sparse the lattice is, and nothing about the resulting model looks broken.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

import torch

from torch_dimensions.lattice import Lattice

__all__ = ["MemmapSource", "Normalizer", "masked_stats"]


def _numpy() -> Any:
    """numpy, or an error that says what to do about it.

    torch does not require numpy, and a CPU-only install genuinely may not have
    it — this project's own CI is such an install, which is where the bare
    ``ModuleNotFoundError: No module named 'numpy'`` from three frames inside a
    ``.npy`` writer was first seen. The ``.npy`` container is numpy's format;
    needing numpy to read it is not a surprise, but being told so is.
    """
    try:
        import numpy
    except ModuleNotFoundError as e:  # pragma: no cover - environment-dependent
        raise ModuleNotFoundError(
            "MemmapSource reads and writes .npy files, which needs numpy: pip install numpy. "
            "(torch does not require it, so a minimal install may not have it.) For an "
            "in-memory source, use td.data.TensorSource instead."
        ) from e
    return numpy


class MemmapSource:
    """A ``.npy`` file on disk, memory-mapped, as a :class:`LatticeSource`.

        td.data.MemmapSource.write("series.npy", tensor)
        source = td.data.MemmapSource("series.npy", lattice)

    Only the requested slice is read, so the file may be far larger than
    memory.

    **The handle is opened lazily and dropped on pickling.** That is the whole
    difficulty of an on-disk source and the reason this class exists as a
    reference: ``DataLoader(num_workers>0)`` pickles the source into each
    worker, and a live mmap or file handle either fails to pickle or —
    worse — pickles into a handle that is invalid in the child. DEBUG.md #9
    records what that failure mode looks like from the outside: not an
    exception, a hang. Each worker reopens the file itself.

    Needs ``numpy`` — ``.npy`` is numpy's container. It is imported lazily, so
    this module never breaks an import that would otherwise work, and the
    error when it is missing says what to install. torch does **not** require
    numpy: assuming it did is DEBUG.md #24.
    """

    def __init__(
        self,
        path: str | Path,
        lattice: Lattice,
        *,
        dtype: torch.dtype = torch.float32,
    ) -> None:
        self.path = Path(path)
        if not self.path.exists():
            raise FileNotFoundError(f"no such file: {self.path}")
        self._lattice = lattice
        self.dtype = dtype
        self._array: Any = None
        head = self._open()
        got = tuple(head.shape[1:-1])
        if got != tuple(lattice.shape):
            raise ValueError(
                f"{self.path.name} has lattice dims {got}, but the lattice declares "
                f"{tuple(lattice.shape)}"
            )

    @staticmethod
    def write(path: str | Path, series: torch.Tensor) -> Path:
        """Write a ``(T, *shape, F)`` tensor to a ``.npy`` this can read."""
        np = _numpy()
        path = Path(path)
        np.save(path, series.detach().cpu().numpy())
        return path if path.suffix == ".npy" else path.with_suffix(".npy")

    def _open(self) -> Any:
        if self._array is None:
            self._array = _numpy().load(self.path, mmap_mode="r")
        return self._array

    @property
    def lattice(self) -> Lattice:
        return self._lattice

    def __len__(self) -> int:
        return int(self._open().shape[0])

    def __getitem__(self, index: slice) -> torch.Tensor:
        # `.copy()` because torch cannot take ownership of a read-only mmap
        # view, and a tensor that aliases one would be a use-after-close the
        # moment the handle is dropped.
        return torch.from_numpy(self._open()[index].copy()).to(self.dtype)

    def __getstate__(self) -> dict:
        state = self.__dict__.copy()
        state["_array"] = None  # the child process opens its own
        return state

    def __repr__(self) -> str:
        return f"MemmapSource({self.path.name}, {self._lattice})"


@dataclass(frozen=True)
class Normalizer:
    """Per-cell mean and scale, applied and inverted.

    Deliberately a value object with no state beyond the statistics: the
    library computes them and applies them, and never decides *when* — fitting
    on the wrong split is the caller's classic mistake to make, and hiding it
    inside a training loop this library does not have would only make it
    harder to see.
    """

    mean: torch.Tensor
    scale: torch.Tensor

    def apply(self, x: torch.Tensor) -> torch.Tensor:
        return (x - self.mean.to(x.device)) / self.scale.to(x.device)

    def invert(self, x: torch.Tensor) -> torch.Tensor:
        return x * self.scale.to(x.device) + self.mean.to(x.device)


def masked_stats(
    series: torch.Tensor,
    lattice: Lattice,
    *,
    eps: float = 1e-6,
    per_cell: bool = True,
) -> Normalizer:
    """Mean and standard deviation over **present cells only**.

    Args:
        series: ``(T, *lattice.shape, F)``.
        lattice: supplies the validity mask.
        per_cell: statistics per cell (the default — each series has its own
            scale) or one set shared across the lattice.

    Absent cells hold exactly zero, so a plain ``series.mean()`` on a lattice
    that is 30% absent is pulled 30% toward zero and the standard deviation
    with it. Nothing about the resulting model looks wrong; it is simply
    trained on data centred on a number that means nothing. NaNs are treated
    as absent too, so a real gap and a structural absence are handled the same
    way.
    """
    if series.ndim != lattice.rank + 2:
        raise ValueError(
            f"expected a (T, *{lattice.shape}, F) tensor; got shape {tuple(series.shape)}"
        )
    present = lattice.mask(torch.bool).reshape(*lattice.shape, 1).to(series.device)
    known = present.unsqueeze(0) & ~series.isnan()
    values = torch.nan_to_num(series, nan=0.0)

    dims: tuple[int, ...] = (0,) if per_cell else tuple(range(series.ndim - 1))
    count = known.expand_as(values).sum(dims).clamp_min(1)
    mean = values.sum(dims) / count
    # Var over the same masked set: subtract the mean only where a value exists,
    # or the absent zeros contribute (0 - mean)^2 and inflate the scale.
    centered = (values - mean) * known
    var = (centered * centered).sum(dims) / count
    scale = var.sqrt().clamp_min(eps)
    return Normalizer(mean=mean, scale=scale)