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.
- idun/__init__.py +11 -0
- idun/_jit.py +33 -0
- idun/_version.py +3 -0
- idun/base.py +127 -0
- idun/data/__init__.py +12 -0
- idun/data/binning.py +74 -0
- idun/data/dataset.py +130 -0
- idun/data/layout.py +86 -0
- idun/data/manifest.py +68 -0
- idun/data/refits.py +17 -0
- idun/data/writer.py +202 -0
- idun/dataset.py +305 -0
- idun/errors.py +37 -0
- idun/gbm/__init__.py +14 -0
- idun/gbm/backends/__init__.py +42 -0
- idun/gbm/backends/base.py +215 -0
- idun/gbm/backends/cpu.py +504 -0
- idun/gbm/backends/metal.py +917 -0
- idun/gbm/booster.py +173 -0
- idun/gbm/estimator.py +168 -0
- idun/gbm/features.py +82 -0
- idun/gbm/kernels/metal/gbm.metal +921 -0
- idun/gbm/multi.py +274 -0
- idun/gbm/objective.py +19 -0
- idun/gbm/params.py +109 -0
- idun/gbm/sampling.py +41 -0
- idun/gbm/split.py +404 -0
- idun/gbm/train.py +539 -0
- idun/gbm/tree.py +138 -0
- idun/metal/__init__.py +4 -0
- idun/metal/runtime.py +470 -0
- idun/models.py +28 -0
- idun/nn/__init__.py +5 -0
- idun/nn/estimator.py +206 -0
- idun/nn/inputs.py +199 -0
- idun/nn/kernels/metal/common.metal +175 -0
- idun/nn/kernels/metal/rmlp.metal +489 -0
- idun/nn/kernels/metal/tabm.metal +389 -0
- idun/nn/metal.py +30 -0
- idun/nn/table.py +89 -0
- idun/nn/training.py +34 -0
- idun/py.typed +0 -0
- idun/realmlp/__init__.py +19 -0
- idun/realmlp/estimator.py +91 -0
- idun/realmlp/metal.py +554 -0
- idun/realmlp/params.py +86 -0
- idun/realmlp/reference.py +364 -0
- idun/realmlp/train.py +537 -0
- idun/rng.py +168 -0
- idun/tabm/__init__.py +11 -0
- idun/tabm/estimator.py +72 -0
- idun/tabm/metal.py +425 -0
- idun/tabm/params.py +79 -0
- idun/tabm/reference.py +248 -0
- idun/tabm/train.py +329 -0
- idun-0.1.0.dist-info/METADATA +90 -0
- idun-0.1.0.dist-info/RECORD +60 -0
- idun-0.1.0.dist-info/WHEEL +5 -0
- idun-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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
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
|