idun 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. idun/__init__.py +11 -0
  2. idun/_jit.py +33 -0
  3. idun/_version.py +3 -0
  4. idun/base.py +127 -0
  5. idun/data/__init__.py +12 -0
  6. idun/data/binning.py +74 -0
  7. idun/data/dataset.py +130 -0
  8. idun/data/layout.py +86 -0
  9. idun/data/manifest.py +68 -0
  10. idun/data/refits.py +17 -0
  11. idun/data/writer.py +202 -0
  12. idun/dataset.py +305 -0
  13. idun/errors.py +37 -0
  14. idun/gbm/__init__.py +14 -0
  15. idun/gbm/backends/__init__.py +42 -0
  16. idun/gbm/backends/base.py +215 -0
  17. idun/gbm/backends/cpu.py +504 -0
  18. idun/gbm/backends/metal.py +917 -0
  19. idun/gbm/booster.py +173 -0
  20. idun/gbm/estimator.py +168 -0
  21. idun/gbm/features.py +82 -0
  22. idun/gbm/kernels/metal/gbm.metal +921 -0
  23. idun/gbm/multi.py +274 -0
  24. idun/gbm/objective.py +19 -0
  25. idun/gbm/params.py +109 -0
  26. idun/gbm/sampling.py +41 -0
  27. idun/gbm/split.py +404 -0
  28. idun/gbm/train.py +539 -0
  29. idun/gbm/tree.py +138 -0
  30. idun/metal/__init__.py +4 -0
  31. idun/metal/runtime.py +470 -0
  32. idun/models.py +28 -0
  33. idun/nn/__init__.py +5 -0
  34. idun/nn/estimator.py +206 -0
  35. idun/nn/inputs.py +199 -0
  36. idun/nn/kernels/metal/common.metal +175 -0
  37. idun/nn/kernels/metal/rmlp.metal +489 -0
  38. idun/nn/kernels/metal/tabm.metal +389 -0
  39. idun/nn/metal.py +30 -0
  40. idun/nn/table.py +89 -0
  41. idun/nn/training.py +34 -0
  42. idun/py.typed +0 -0
  43. idun/realmlp/__init__.py +19 -0
  44. idun/realmlp/estimator.py +91 -0
  45. idun/realmlp/metal.py +554 -0
  46. idun/realmlp/params.py +86 -0
  47. idun/realmlp/reference.py +364 -0
  48. idun/realmlp/train.py +537 -0
  49. idun/rng.py +168 -0
  50. idun/tabm/__init__.py +11 -0
  51. idun/tabm/estimator.py +72 -0
  52. idun/tabm/metal.py +425 -0
  53. idun/tabm/params.py +79 -0
  54. idun/tabm/reference.py +248 -0
  55. idun/tabm/train.py +329 -0
  56. idun-0.1.0.dist-info/METADATA +90 -0
  57. idun-0.1.0.dist-info/RECORD +60 -0
  58. idun-0.1.0.dist-info/WHEEL +5 -0
  59. idun-0.1.0.dist-info/licenses/LICENSE +202 -0
  60. idun-0.1.0.dist-info/licenses/NOTICE +39 -0
idun/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Idun: tabular learners on the Apple silicon GPU (Metal), with CPU reference backends.
2
+
3
+ The learners are `idun.gbm` (idun_gbm), `idun.tabm` (idun_tabm) and `idun.realmlp` (idun_realmlp); `Dataset` holds
4
+ tabular data binned for them. The public interface is documented in docs/api/python-api.md.
5
+ """
6
+
7
+ from idun._version import __version__
8
+ from idun.dataset import Dataset
9
+ from idun.models import load
10
+
11
+ __all__ = ["Dataset", "__version__", "load"]
idun/_jit.py ADDED
@@ -0,0 +1,33 @@
1
+ """The one place idun compiles Python with numba.
2
+
3
+ Every jitted function goes through :func:`njit`, so the options that bit-exact parity with the GPU backends depends
4
+ on are set once. ``fastmath`` stays off: with it, LLVM contracts ``a * b + c`` into a fused multiply-add and the CPU
5
+ results stop matching the Metal kernels, which are compiled with contraction off (docs/architecture/determinism.md).
6
+ Compiled code is cached next to the source. Under ``NUMBA_DISABLE_JIT=1`` (the coverage run) numba hands back the
7
+ Python function itself.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any, cast
12
+
13
+ import numba
14
+
15
+
16
+ def njit[F: Callable[..., Any]](*, parallel: bool = False) -> Callable[[F], F]:
17
+ """Return numba's ``njit`` decorator with idun's fixed options.
18
+
19
+ Parameters
20
+ ----------
21
+ parallel
22
+ Compile ``numba.prange`` loops as parallel loops.
23
+
24
+ Returns
25
+ -------
26
+ Callable
27
+ A decorator that compiles a function in nopython mode.
28
+ """
29
+
30
+ def decorate(func: F) -> F:
31
+ return cast("F", numba.njit(cache=True, fastmath=False, nogil=True, parallel=parallel)(func))
32
+
33
+ return decorate
idun/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """The package version: a leaf module, so that any module can name it without importing the package."""
2
+
3
+ __version__ = "0.1.0"
idun/base.py ADDED
@@ -0,0 +1,127 @@
1
+ """What every idun estimator shares: scikit-learn's conventions without importing it.
2
+
3
+ `Estimator` holds the constructor parameters (`get_params`, `set_params`), the checks of inputs that scikit-learn's
4
+ ``validate_data`` would make (a missing target, a column count other than the fitted one, an unfitted estimator) and
5
+ scikit-learn's tags. `Regressor` and `Classifier` add the task and ``score`` (R² and accuracy, as scikit-learn's mixins
6
+ define them).
7
+ """
8
+
9
+ import inspect
10
+ from typing import Any, ClassVar, Self
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ from idun.dataset import n_columns
16
+ from idun.errors import NotFittedError
17
+
18
+
19
+ class Estimator:
20
+ """Constructor parameters as attributes; `get_params` and `set_params` as scikit-learn defines them.
21
+
22
+ Subclasses name their task in ``_estimator_type`` (``"classifier"`` or ``"regressor"``), which scikit-learn reads
23
+ through `__sklearn_tags__`.
24
+ """
25
+
26
+ _estimator_type: ClassVar[str]
27
+
28
+ @classmethod
29
+ def _param_names(cls) -> list[str]:
30
+ return [name for name in inspect.signature(cls.__init__).parameters if name != "self"]
31
+
32
+ def get_params(self, deep: bool = True) -> dict[str, Any]: # noqa: ARG002, FBT001, FBT002 - scikit-learn's signature
33
+ """Return the constructor parameters (scikit-learn's convention)."""
34
+ return {name: getattr(self, name) for name in self._param_names()}
35
+
36
+ def set_params(self, **params: Any) -> Self:
37
+ """Set constructor parameters; an unknown name raises ``ValueError``."""
38
+ unknown = sorted(set(params) - set(self._param_names()))
39
+ if unknown:
40
+ raise ValueError(f"unknown parameters {unknown}")
41
+ for name, value in params.items():
42
+ setattr(self, name, value)
43
+ return self
44
+
45
+ def _check_fitted(self, attribute: str) -> None:
46
+ if not hasattr(self, attribute):
47
+ raise NotFittedError(f"this {type(self).__name__} is not fitted: call fit first")
48
+
49
+ def _require_y(self, y: Any, hint: str = "") -> None:
50
+ if y is None:
51
+ raise ValueError(f"{type(self).__name__} requires y to be passed, but the target y is None{hint}")
52
+
53
+ def _check_n_features(self, X: Any) -> None: # noqa: N803 - scikit-learn's name
54
+ n = n_columns(X)
55
+ if n != self.n_features_in_:
56
+ raise ValueError(
57
+ f"X has {n} features, but {type(self).__name__} is expecting {self.n_features_in_} features as input."
58
+ )
59
+
60
+ n_features_in_: int
61
+
62
+ def __sklearn_tags__(self) -> Any:
63
+ """Return scikit-learn's tags for this estimator.
64
+
65
+ Only scikit-learn calls this, so it is imported here and is never a dependency of idun. Missing values are
66
+ allowed (idun_gbm bins them apart; the networks read them as 0).
67
+ """
68
+ from sklearn.utils import ClassifierTags, InputTags, RegressorTags, Tags, TargetTags # noqa: PLC0415, TID251
69
+
70
+ classifier = self._estimator_type == "classifier"
71
+ return Tags(
72
+ estimator_type=self._estimator_type,
73
+ target_tags=TargetTags(required=True),
74
+ classifier_tags=ClassifierTags() if classifier else None,
75
+ regressor_tags=None if classifier else RegressorTags(),
76
+ input_tags=InputTags(allow_nan=True),
77
+ )
78
+
79
+ def __repr__(self) -> str:
80
+ defaults = {n: p.default for n, p in inspect.signature(type(self).__init__).parameters.items() if n != "self"}
81
+ changed = ", ".join(f"{n}={v!r}" for n, v in self.get_params().items() if v != defaults.get(n))
82
+ return f"{type(self).__name__}({changed})"
83
+
84
+
85
+ def _weights(sample_weight: Any, n: int) -> NDArray[np.float64]:
86
+ if sample_weight is None:
87
+ return np.ones(n, np.float64)
88
+ weights = np.asarray(sample_weight, np.float64)
89
+ if weights.shape != (n,):
90
+ raise ValueError(f"sample_weight must have one value per row ({n}), got shape {weights.shape}")
91
+ return weights
92
+
93
+
94
+ class Regressor(Estimator):
95
+ """A regression estimator: ``score`` is the coefficient of determination R² of ``predict``."""
96
+
97
+ _estimator_type = "regressor"
98
+
99
+ def predict(self, X: Any) -> NDArray[Any]: # noqa: N803 - scikit-learn's name
100
+ """Return the prediction of each row of ``X``."""
101
+ raise NotImplementedError
102
+
103
+ def score(self, X: Any, y: Any, sample_weight: Any = None) -> float: # noqa: N803 - scikit-learn's name
104
+ """Return R² = 1 − Σ w (y − ŷ)² / Σ w (y − ȳ)², with ȳ the weighted mean of ``y`` (1.0 when y is constant)."""
105
+ target = np.asarray(y, np.float64)
106
+ weights = _weights(sample_weight, target.size)
107
+ residual = float(np.sum(weights * (target - np.asarray(self.predict(X), np.float64)) ** 2))
108
+ total = float(np.sum(weights * (target - np.average(target, weights=weights)) ** 2))
109
+ if total == 0:
110
+ return 1.0 if residual == 0 else 0.0
111
+ return 1.0 - residual / total
112
+
113
+
114
+ class Classifier(Estimator):
115
+ """A classification estimator: ``score`` is the (weighted) share of rows whose ``predict`` equals ``y``."""
116
+
117
+ _estimator_type = "classifier"
118
+
119
+ def predict(self, X: Any) -> NDArray[Any]: # noqa: N803 - scikit-learn's name
120
+ """Return the predicted label of each row of ``X``."""
121
+ raise NotImplementedError
122
+
123
+ def score(self, X: Any, y: Any, sample_weight: Any = None) -> float: # noqa: N803 - scikit-learn's name
124
+ """Return the accuracy of ``predict(X)`` against the labels ``y``."""
125
+ labels = np.asarray(y)
126
+ weights = _weights(sample_weight, labels.size)
127
+ return float(np.average(np.asarray(self.predict(X)) == labels, weights=weights))
idun/data/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """The packed on-disk dataset: its writer and its memory-mapped reader.
2
+
3
+ A packed dataset is a date-sorted panel stored column-major (4-bit codes, raw numeric values and their bins per
4
+ window, one categorical column, a target), wrapped by Metal without a copy. Format: docs/architecture/data-layout.md.
5
+ API: docs/api/python-api.md. Numerai's builder is `idun_numerai.data.build_cache`.
6
+ """
7
+
8
+ from idun.data.dataset import PackedDataset
9
+ from idun.data.refits import Refit
10
+ from idun.data.writer import PackedWriter
11
+
12
+ __all__ = ["PackedDataset", "PackedWriter", "Refit"]
idun/data/binning.py ADDED
@@ -0,0 +1,74 @@
1
+ """Quantile bins for the 22 named features (docs/architecture/data-layout.md §2).
2
+
3
+ Each refit gets its own binning, fitted on a sample of its own training window so later rows never leak into the
4
+ edges (LightGBM: ``bin_construct_sample_cnt = 200000``, ``max_bin = 255``):
5
+
6
+ 1. `sample_rows`: 200,000 row indices drawn without replacement from the window by ``PCG64([seed, refit])``.
7
+ 2. `quantile_edges`: per feature, the distinct finite sample values. With at most 255 of them every value gets its own
8
+ bin; otherwise cuts sit at the ``i / 255`` quantiles (by count) of the sample. Each upper edge is the float32
9
+ midpoint between the two distinct values it separates, and never rounds onto the upper one.
10
+ 3. `apply_bins`: a value goes to the first bin whose upper edge is ≥ the value; NaN goes to bin 255.
11
+
12
+ This is plain quantile binning, not LightGBM's ``BinMapper``.
13
+ """
14
+
15
+ from collections.abc import Sequence
16
+
17
+ import numpy as np
18
+ from numpy.typing import NDArray
19
+
20
+ from idun.data.layout import MAX_NAMED_BINS, MISSING_BIN
21
+
22
+ SAMPLE_ROWS = 200_000
23
+
24
+
25
+ def sample_rows(window_rows: int, size: int, *, seed: int, refit: int) -> NDArray[np.int64]:
26
+ """Sorted, distinct row indices in ``[0, window_rows)``: all of them when ``size >= window_rows``."""
27
+ if window_rows < 1 or size < 1:
28
+ raise ValueError(f"window_rows and size must be positive, got {window_rows} and {size}")
29
+ if size >= window_rows:
30
+ return np.arange(window_rows, dtype=np.int64)
31
+ generator = np.random.Generator(np.random.PCG64([seed, refit]))
32
+ return np.sort(generator.choice(window_rows, size=size, replace=False)).astype(np.int64)
33
+
34
+
35
+ def quantile_edges(values: NDArray[np.float32], max_bins: int = MAX_NAMED_BINS) -> NDArray[np.float32]:
36
+ """Strictly increasing float32 upper edges (``n_bins - 1`` of them, ``n_bins <= max_bins``) for a sample.
37
+
38
+ Non-finite sample values are ignored. With no finite value there is a single bin and no edge.
39
+ """
40
+ if values.dtype != np.float32:
41
+ raise TypeError(f"values must be float32, got {values.dtype}")
42
+ if max_bins < 1:
43
+ raise ValueError(f"max_bins must be positive, got {max_bins}")
44
+ distinct, counts = np.unique(values[np.isfinite(values)], return_counts=True)
45
+ if distinct.size <= max_bins:
46
+ cuts = np.arange(max(distinct.size - 1, 0))
47
+ else:
48
+ cumulative = np.cumsum(counts)
49
+ total = int(cumulative[-1])
50
+ quantiles = (total * np.arange(1, max_bins, dtype=np.int64) + max_bins - 1) // max_bins # ceil(total * i / max)
51
+ cuts = np.unique(np.searchsorted(cumulative, quantiles, side="left"))
52
+ cuts = cuts[cuts < distinct.size - 1]
53
+ lower, upper = distinct[cuts], distinct[cuts + 1]
54
+ middle = ((lower.astype(np.float64) + upper.astype(np.float64)) / 2).astype(np.float32)
55
+ return np.where(middle < upper, middle, lower)
56
+
57
+
58
+ def edge_table(edges: Sequence[NDArray[np.float32]], max_bins: int = MAX_NAMED_BINS) -> NDArray[np.float32]:
59
+ """Stack per-feature edges into a ``(features, max_bins)`` table padded with +inf (so +inf ends every row)."""
60
+ table = np.full((len(edges), max_bins), np.inf, np.float32)
61
+ for row, feature_edges in enumerate(edges):
62
+ if feature_edges.size >= max_bins:
63
+ raise ValueError(f"feature {row} has {feature_edges.size} edges; at most {max_bins - 1} fit")
64
+ table[row, : feature_edges.size] = feature_edges
65
+ return table
66
+
67
+
68
+ def apply_bins(values: NDArray[np.float32], edges_row: NDArray[np.float32]) -> NDArray[np.uint8]:
69
+ """Bin of each value under one row of an `edge_table`: first edge ≥ value; NaN → `MISSING_BIN`."""
70
+ if edges_row.shape != (MAX_NAMED_BINS,) or not np.isposinf(edges_row[-1]):
71
+ raise ValueError("edges_row must be one +inf-padded row of an edge_table")
72
+ bins = np.searchsorted(edges_row, values, side="left").astype(np.uint8)
73
+ bins[np.isnan(values)] = MISSING_BIN
74
+ return bins
idun/data/dataset.py ADDED
@@ -0,0 +1,130 @@
1
+ """`PackedDataset`: the read-only, memory-mapped reader of the packed cache (docs/architecture/data-layout.md §2).
2
+
3
+ Opening a cache checks its format version, that no source file changed since it was built, and that every array file
4
+ has its recorded size. ``verify=True`` (or `PackedDataset.verify`) also recomputes every checksum. Nothing is copied
5
+ into memory: each array is an ``np.memmap`` view, read-only, and columns are contiguous.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from functools import cached_property
11
+ from pathlib import Path
12
+ from typing import Any, Self
13
+
14
+ import numpy as np
15
+ from numpy.typing import NDArray
16
+
17
+ from idun.data import layout
18
+ from idun.data.manifest import check_fresh, read_manifest, sha256_file
19
+ from idun.data.refits import Refit
20
+ from idun.errors import CacheError
21
+
22
+ COPY_ENV = "IDUN_CACHE_COPY" # "1": a copied cache, checked by its checksums instead of its source files
23
+
24
+
25
+ class PackedDataset:
26
+ """One packed cache with one named-feature binning selected. Build with `idun.data.build_cache`.
27
+
28
+ Rows are in date order (on Numerai's cache, every row with a ``target_jupiter_60``). Features are in the order named
29
+ (sorted), obfuscated (sorted), country.
30
+
31
+ Attributes
32
+ ----------
33
+ n_rows : int
34
+ Rows in the panel.
35
+ binning : str
36
+ The selected named-feature binning: ``r<k>``, fitted on refit ``k``'s training window.
37
+ target, era, ticker, country : NDArray
38
+ Per row: float32 target, int32 days since 1970-01-01, int32 index into `tickers`, uint8 index into `countries`.
39
+ obfuscated : NDArray[np.uint8]
40
+ ``(n_obfuscated, ceil(n_rows / 2))``: two rows per byte (`idun.data.layout.unpack_nibbles`).
41
+ named_values, named_bins : NDArray
42
+ ``(n_named, n_rows)``: raw float32 values (NaN kept), and their bins under `binning` (255 = missing).
43
+ named_edges : NDArray[np.float32]
44
+ ``(n_named, 255)`` upper bin edges under `binning`, +inf padded; `named_n_bins` real bins per feature.
45
+ """
46
+
47
+ def __init__(self, path: Path, manifest: dict[str, Any], binning: str) -> None:
48
+ if binning not in manifest["binnings"]:
49
+ raise CacheError(f"{path} has no binning {binning!r}; it has {sorted(manifest['binnings'])}")
50
+ self.path = path
51
+ self.binning = binning
52
+ self.n_rows = n = int(manifest["n_rows"])
53
+ features = manifest["features"]
54
+ self.named_features: tuple[str, ...] = tuple(features["named"])
55
+ self.obfuscated_features: tuple[str, ...] = tuple(features["obfuscated"])
56
+ self.feature_names = (*self.named_features, *self.obfuscated_features, features["country"])
57
+ self.countries: tuple[str, ...] = tuple(manifest["countries"])
58
+ self.refits = tuple(Refit(**refit) for refit in manifest["refits"])
59
+ self.seed = int(manifest["seed"])
60
+ self._manifest = manifest
61
+ strides = manifest["strides"]
62
+ n_named, n_obfuscated = len(self.named_features), len(self.obfuscated_features)
63
+ self.target: NDArray[np.float32] = _map(path / layout.TARGET_FILE, np.float32, (n,))
64
+ self.era: NDArray[np.int32] = _map(path / layout.ERA_FILE, np.int32, (n,))
65
+ self.ticker: NDArray[np.int32] = _map(path / layout.TICKER_FILE, np.int32, (n,))
66
+ self.country: NDArray[np.uint8] = _map(path / layout.COUNTRY_FILE, np.uint8, (n,))
67
+ # idun_gbm reads the country as the one categorical column (BinnedData): a zero-stride view, nothing copied
68
+ self.categories: tuple[tuple[str, ...], ...] = (self.countries,)
69
+ self.categorical: NDArray[np.uint8] = self.country[np.newaxis, :]
70
+ obfuscated = _map(path / layout.OBFUSCATED_FILE, np.uint8, (n_obfuscated, strides["obfuscated"]))
71
+ self.obfuscated: NDArray[np.uint8] = obfuscated[:, : layout.packed_bytes(n)]
72
+ values = _map(path / layout.NAMED_VALUES_FILE, np.float32, (n_named, strides["named_values"] // 4))
73
+ self.named_values: NDArray[np.float32] = values[:, :n]
74
+ bins = _map(path / layout.named_bins_file(binning), np.uint8, (n_named, strides["named_bins"]))
75
+ self.named_bins: NDArray[np.uint8] = bins[:, :n]
76
+ edges = np.fromfile(path / layout.named_edges_file(binning), np.float32)
77
+ self.named_edges = edges.reshape(n_named, layout.MAX_NAMED_BINS)
78
+ self.named_n_bins: tuple[int, ...] = tuple(manifest["binnings"][binning]["n_bins"])
79
+
80
+ @classmethod
81
+ def open(cls, path: str | Path, *, binning: str, verify: bool = False) -> Self:
82
+ """Open the cache at ``path`` with ``binning`` selected (``"r0"``, ``"r1"`` or ``"r2"`` for the v6 protocol).
83
+
84
+ Raises `CacheError` when the cache is missing, of another format version, stale (a source file changed),
85
+ truncated, or (with ``verify``) when a checksum differs. With the environment variable ``IDUN_CACHE_COPY=1``
86
+ the cache is a copy on a machine without the source files (a second Mac): the source check is replaced by
87
+ the checksum check of every array file, so a copy is never opened unchecked.
88
+ """
89
+ path = Path(path)
90
+ manifest = read_manifest(path)
91
+ copy = os.environ.get(COPY_ENV) == "1"
92
+ try:
93
+ if not copy:
94
+ check_fresh(manifest)
95
+ for name, array in manifest["arrays"].items():
96
+ file = path / name
97
+ if not file.is_file() or file.stat().st_size != array["bytes"]:
98
+ raise CacheError(f"{file} is missing or truncated; rebuild the cache")
99
+ dataset = cls(path, manifest, binning)
100
+ except KeyError as missing:
101
+ raise CacheError(f"{path / layout.MANIFEST} lacks {missing}; rebuild the cache") from None
102
+ if verify or copy:
103
+ dataset.verify()
104
+ return dataset
105
+
106
+ def verify(self) -> None:
107
+ """Recompute the sha256 of every array file and raise `CacheError` on any mismatch."""
108
+ for name, array in self._manifest["arrays"].items():
109
+ if sha256_file(self.path / name) != array["sha256"]:
110
+ raise CacheError(f"{self.path / name}: checksum mismatch; the cache is corrupt, rebuild it")
111
+
112
+ @cached_property
113
+ def tickers(self) -> tuple[str, ...]:
114
+ """Sorted ticker names; `ticker` indexes into them."""
115
+ names: list[str] = json.loads((self.path / layout.TICKERS_FILE).read_text())
116
+ return tuple(names)
117
+
118
+ def obfuscated_codes(self, feature: int, lo: int = 0, hi: int | None = None) -> NDArray[np.uint8]:
119
+ """Unpacked codes (0..4) of obfuscated feature ``feature`` for rows ``[lo, hi)``; all rows by default."""
120
+ hi = self.n_rows if hi is None else hi
121
+ if hi > self.n_rows:
122
+ raise ValueError(f"row {hi} is past the panel's {self.n_rows} rows")
123
+ return layout.unpack_nibbles(self.obfuscated[feature], lo, hi)
124
+
125
+ def __repr__(self) -> str:
126
+ return f"PackedDataset({str(self.path)!r}, binning={self.binning!r}, n_rows={self.n_rows})"
127
+
128
+
129
+ def _map(path: Path, dtype: type[np.generic], shape: tuple[int, ...]) -> NDArray[Any]:
130
+ return np.memmap(path, dtype=dtype, mode="r", shape=shape)
idun/data/layout.py ADDED
@@ -0,0 +1,86 @@
1
+ """On-disk layout of the packed cache: file names, column strides, 4-bit packing (docs/architecture/data-layout.md §2).
2
+
3
+ Every multi-column array is column-major: column ``c`` starts at byte ``c * stride`` of its file, and the stride is the
4
+ column's byte length rounded up to a 16 KiB page. A whole file therefore maps page-aligned (Metal wraps it without a
5
+ copy), and every column starts on a 16-byte boundary for vector loads.
6
+
7
+ Obfuscated features pack two **rows** per byte: row ``2i`` in the low nibble of byte ``i``, row ``2i + 1`` in the high
8
+ nibble. A column with an odd row count has a zero high nibble in its last byte.
9
+ """
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+
14
+ FORMAT = "idun-packed" # the packed format's id, stamped into every manifest
15
+ FORMAT_VERSION = 1
16
+ PAGE_BYTES = 16 * 1024
17
+
18
+
19
+ OBFUSCATED_LEVELS = 5 # obfuscated values are exactly 0..4: they are their own bins
20
+ MAX_NAMED_BINS = 255 # real bins 0..254 for a named feature
21
+ MISSING_BIN = 255 # NaN
22
+
23
+ MANIFEST = "manifest.json"
24
+ OBFUSCATED_FILE = "obf.u4"
25
+ NAMED_VALUES_FILE = "named.f32"
26
+ COUNTRY_FILE = "country.u8"
27
+ TARGET_FILE = "y.f32"
28
+ ERA_FILE = "era.i32"
29
+ TICKER_FILE = "ticker.i32"
30
+ TICKERS_FILE = "tickers.json"
31
+
32
+ _LOW = np.uint8(0x0F)
33
+ _FOUR = np.uint8(4)
34
+
35
+
36
+ def binning_name(refit: int) -> str:
37
+ """Name of the named-feature binning fitted on refit ``refit``'s training window: ``r0``, ``r1``, ..."""
38
+ return f"r{refit}"
39
+
40
+
41
+ def named_bins_file(binning: str) -> str:
42
+ """File holding every row's named-feature bins under ``binning``."""
43
+ return f"named.{binning}.u8"
44
+
45
+
46
+ def named_edges_file(binning: str) -> str:
47
+ """File holding the upper bin edges of ``binning``: float32, one row of `MAX_NAMED_BINS` per named feature."""
48
+ return f"named.{binning}.edges.f32"
49
+
50
+
51
+ def column_stride(column_bytes: int) -> int:
52
+ """Byte distance between consecutive columns: ``column_bytes`` rounded up to a whole page (at least one page)."""
53
+ if column_bytes < 1:
54
+ raise ValueError(f"column_bytes must be positive, got {column_bytes}")
55
+ return -(-column_bytes // PAGE_BYTES) * PAGE_BYTES
56
+
57
+
58
+ def packed_bytes(n_rows: int) -> int:
59
+ """Bytes of one 4-bit packed column of ``n_rows`` rows."""
60
+ if n_rows < 0:
61
+ raise ValueError(f"n_rows must be non-negative, got {n_rows}")
62
+ return (n_rows + 1) // 2
63
+
64
+
65
+ def pack_nibbles(codes: NDArray[np.uint8]) -> NDArray[np.uint8]:
66
+ """Pack 4-bit codes two rows per byte along the last axis (low nibble = even row); an odd tail is zero-padded."""
67
+ if codes.dtype != np.uint8:
68
+ raise TypeError(f"codes must be uint8, got {codes.dtype}")
69
+ if codes.size and int(codes.max()) > 0x0F:
70
+ raise ValueError(f"codes must fit in 4 bits, got {int(codes.max())}")
71
+ if codes.shape[-1] % 2:
72
+ codes = np.concatenate([codes, np.zeros((*codes.shape[:-1], 1), np.uint8)], axis=-1)
73
+ return np.ascontiguousarray(codes[..., 0::2] | (codes[..., 1::2] << _FOUR))
74
+
75
+
76
+ def unpack_nibbles(packed: NDArray[np.uint8], lo: int, hi: int) -> NDArray[np.uint8]:
77
+ """Codes of rows ``[lo, hi)`` from columns packed by `pack_nibbles` (last axis = packed bytes)."""
78
+ if not 0 <= lo <= hi <= 2 * packed.shape[-1]:
79
+ raise ValueError(f"rows [{lo}, {hi}) are outside the {2 * packed.shape[-1]} rows these bytes hold")
80
+ first, stop = lo // 2, (hi + 1) // 2
81
+ chunk = packed[..., first:stop]
82
+ codes = np.empty((*chunk.shape[:-1], 2 * chunk.shape[-1]), np.uint8)
83
+ codes[..., 0::2] = chunk & _LOW
84
+ codes[..., 1::2] = chunk >> _FOUR
85
+ start = lo - 2 * first
86
+ return codes[..., start : start + hi - lo]
idun/data/manifest.py ADDED
@@ -0,0 +1,68 @@
1
+ """The cache manifest: what the cache was built from, how, and the checksum of every array file.
2
+
3
+ The manifest is the cache's single source of metadata. The reader trusts nothing else: it compares the source
4
+ fingerprints against the files on disk (a stale cache fails loudly) and the array sizes against the files it maps.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from idun.data.layout import FORMAT, FORMAT_VERSION, MANIFEST
14
+ from idun.errors import CacheError
15
+
16
+ _HASH_CHUNK = 8 * 1024 * 1024
17
+
18
+
19
+ def fingerprint_sources(source_dir: Path, names: Sequence[str]) -> list[dict[str, Any]]:
20
+ """Name, size and modification time of each source file ``names``; a change to any of them makes a cache stale."""
21
+ prints = []
22
+ for name in names:
23
+ path = source_dir / name
24
+ if not path.is_file():
25
+ raise CacheError(f"source file {path} is missing")
26
+ stat = path.stat()
27
+ prints.append({"name": name, "bytes": stat.st_size, "mtime_ns": stat.st_mtime_ns})
28
+ return prints
29
+
30
+
31
+ def sha256_file(path: Path) -> str:
32
+ """Hex sha256 of a file, read in chunks."""
33
+ digest = hashlib.sha256()
34
+ with path.open("rb") as handle:
35
+ while chunk := handle.read(_HASH_CHUNK):
36
+ digest.update(chunk)
37
+ return digest.hexdigest()
38
+
39
+
40
+ def write_manifest(cache_dir: Path, manifest: dict[str, Any]) -> None:
41
+ """Write ``manifest.json`` (format and version are stamped here)."""
42
+ stamped = {"format": FORMAT, "format_version": FORMAT_VERSION, **manifest}
43
+ (cache_dir / MANIFEST).write_text(json.dumps(stamped, indent=1) + "\n")
44
+
45
+
46
+ def read_manifest(cache_dir: Path) -> dict[str, Any]:
47
+ """Read ``manifest.json`` and check its format and version."""
48
+ path = cache_dir / MANIFEST
49
+ if not path.is_file():
50
+ raise CacheError(f"no cache at {cache_dir}: {MANIFEST} is missing (build it with `make cache`)")
51
+ manifest: dict[str, Any] = json.loads(path.read_text())
52
+ if manifest.get("format") != FORMAT or manifest.get("format_version") != FORMAT_VERSION:
53
+ raise CacheError(
54
+ f"{path}: format {manifest.get('format')!r} version {manifest.get('format_version')!r}; this idun reads "
55
+ f"{FORMAT!r} version {FORMAT_VERSION}. Rebuild the cache."
56
+ )
57
+ return manifest
58
+
59
+
60
+ def check_fresh(manifest: dict[str, Any]) -> None:
61
+ """Raise `CacheError` when a source file changed since the cache was built."""
62
+ built = {source["name"]: source for source in manifest["sources"]}
63
+ for now in fingerprint_sources(Path(manifest["source_dir"]), list(built)):
64
+ if built.get(now["name"]) != now:
65
+ raise CacheError(
66
+ f"stale cache: {now['name']} changed since the cache was built "
67
+ f"({built.get(now['name'])} → {now}). Rebuild the cache."
68
+ )
idun/data/refits.py ADDED
@@ -0,0 +1,17 @@
1
+ """`Refit`: one walk-forward window of a date-sorted packed dataset.
2
+
3
+ A packed dataset's manifest lists its windows, and each binning is fitted on one window's training rows. Which dates
4
+ the windows start on is the protocol's choice: Numerai's is `idun_numerai.refits`.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Refit:
12
+ """One refit: train on rows ``[0, train_hi)``, predict rows ``[pred_lo, pred_hi)`` of the date-sorted panel."""
13
+
14
+ date: str
15
+ train_hi: int
16
+ pred_lo: int
17
+ pred_hi: int