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)
|