featm 0.5.7__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.
- featm/__init__.py +86 -0
- featm/cache.py +190 -0
- featm/checkpoint.py +231 -0
- featm/cli.py +141 -0
- featm/contracts.py +93 -0
- featm/distributed/__init__.py +25 -0
- featm/distributed/checkpoint.py +541 -0
- featm/distributed/contracts.py +99 -0
- featm/distributed/dask_runtime.py +142 -0
- featm/distributed/execution.py +1118 -0
- featm/distributed/local.py +83 -0
- featm/distributed/partition_provider.py +245 -0
- featm/distributed/planner.py +80 -0
- featm/distributed/runtime.py +61 -0
- featm/engine.py +497 -0
- featm/execution.py +1994 -0
- featm/executor.py +145 -0
- featm/extractor.py +353 -0
- featm/features/__init__.py +27 -0
- featm/features/_utils.py +498 -0
- featm/features/audio/__init__.py +0 -0
- featm/features/audio/full.py +302 -0
- featm/features/event/__init__.py +0 -0
- featm/features/event/full.py +96 -0
- featm/features/geometry/triangulation.py +283 -0
- featm/features/graph/__init__.py +0 -0
- featm/features/graph/full.py +379 -0
- featm/features/image/basic.py +66 -0
- featm/features/image/full.py +394 -0
- featm/features/information/__init__.py +0 -0
- featm/features/information/full.py +150 -0
- featm/features/manifold/__init__.py +0 -0
- featm/features/manifold/full.py +63 -0
- featm/features/matrix/__init__.py +0 -0
- featm/features/matrix/full.py +56 -0
- featm/features/multimodal/__init__.py +0 -0
- featm/features/multimodal/full.py +60 -0
- featm/features/pointcloud/__init__.py +0 -0
- featm/features/pointcloud/full.py +168 -0
- featm/features/sequence/__init__.py +0 -0
- featm/features/sequence/full.py +128 -0
- featm/features/signal/__init__.py +0 -0
- featm/features/signal/full.py +249 -0
- featm/features/spatial/__init__.py +0 -0
- featm/features/spatial/full.py +109 -0
- featm/features/tabular/categorical.py +27 -0
- featm/features/tabular/datetime.py +66 -0
- featm/features/tabular/full.py +404 -0
- featm/features/tabular/interactions.py +41 -0
- featm/features/tabular/numeric.py +70 -0
- featm/features/text/__init__.py +0 -0
- featm/features/text/full.py +317 -0
- featm/features/timeseries/basic.py +110 -0
- featm/features/timeseries/full.py +676 -0
- featm/features/topology/__init__.py +0 -0
- featm/features/topology/full.py +129 -0
- featm/features/video/__init__.py +0 -0
- featm/features/video/full.py +117 -0
- featm/gpu/__init__.py +50 -0
- featm/gpu/backend.py +248 -0
- featm/gpu/cache.py +184 -0
- featm/gpu/dispatch.py +596 -0
- featm/gpu/exceptions.py +79 -0
- featm/gpu/kernels/__init__.py +1 -0
- featm/gpu/kernels/reductions.py +112 -0
- featm/gpu/kernels/spectral.py +207 -0
- featm/gpu/memory.py +211 -0
- featm/gpu/registration.py +88 -0
- featm/matrix.py +553 -0
- featm/planner.py +99 -0
- featm/registry.py +590 -0
- featm/report.py +232 -0
- featm/resources.py +412 -0
- featm/sampling.py +146 -0
- featm/schema.py +131 -0
- featm/sources.py +736 -0
- featm/state.py +749 -0
- featm-0.5.7.dist-info/METADATA +299 -0
- featm-0.5.7.dist-info/RECORD +83 -0
- featm-0.5.7.dist-info/WHEEL +5 -0
- featm-0.5.7.dist-info/entry_points.txt +2 -0
- featm-0.5.7.dist-info/licenses/LICENSE +21 -0
- featm-0.5.7.dist-info/top_level.txt +1 -0
featm/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from . import features as _features # register built-ins
|
|
2
|
+
from . import state as _state # register Step 2 stateful two-pass encoders
|
|
3
|
+
from . import gpu # Step 3: registers no CUDA import at module scope -- see featm.gpu.backend
|
|
4
|
+
from .engine import (
|
|
5
|
+
AutoFeatures,
|
|
6
|
+
create_feature,
|
|
7
|
+
extract,
|
|
8
|
+
feature_info,
|
|
9
|
+
list_families,
|
|
10
|
+
list_features,
|
|
11
|
+
list_modalities,
|
|
12
|
+
search,
|
|
13
|
+
)
|
|
14
|
+
from .extractor import FeatureExtractor, FittedFeatureExtractor, fit, load_fitted
|
|
15
|
+
from .distributed import distributed_extract
|
|
16
|
+
from .matrix import FeatureMatrix, MaterializationGuardError
|
|
17
|
+
from .registry import (
|
|
18
|
+
BatchSemantics,
|
|
19
|
+
Complexity,
|
|
20
|
+
Cost,
|
|
21
|
+
FeatureOutput,
|
|
22
|
+
FeatureSpec,
|
|
23
|
+
MemoryProfile,
|
|
24
|
+
REGISTRY,
|
|
25
|
+
RegistryValidationError,
|
|
26
|
+
register_feature,
|
|
27
|
+
)
|
|
28
|
+
from .report import ExecutionReport
|
|
29
|
+
from .resources import ResourceBudget, parse_size
|
|
30
|
+
from .schema import DatasetProfile, SemanticType, infer_schema
|
|
31
|
+
from .sources import (
|
|
32
|
+
ArrowSource,
|
|
33
|
+
CSVSource,
|
|
34
|
+
IteratorSource,
|
|
35
|
+
JSONLSource,
|
|
36
|
+
ParquetSource,
|
|
37
|
+
PandasSource,
|
|
38
|
+
Source,
|
|
39
|
+
SourceNotReplayableError,
|
|
40
|
+
resolve_source,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ArrowSource",
|
|
45
|
+
"AutoFeatures",
|
|
46
|
+
"BatchSemantics",
|
|
47
|
+
"CSVSource",
|
|
48
|
+
"Complexity",
|
|
49
|
+
"Cost",
|
|
50
|
+
"DatasetProfile",
|
|
51
|
+
"ExecutionReport",
|
|
52
|
+
"FeatureExtractor",
|
|
53
|
+
"FeatureMatrix",
|
|
54
|
+
"FeatureOutput",
|
|
55
|
+
"FeatureSpec",
|
|
56
|
+
"FittedFeatureExtractor",
|
|
57
|
+
"IteratorSource",
|
|
58
|
+
"JSONLSource",
|
|
59
|
+
"MaterializationGuardError",
|
|
60
|
+
"MemoryProfile",
|
|
61
|
+
"PandasSource",
|
|
62
|
+
"ParquetSource",
|
|
63
|
+
"REGISTRY",
|
|
64
|
+
"RegistryValidationError",
|
|
65
|
+
"ResourceBudget",
|
|
66
|
+
"SemanticType",
|
|
67
|
+
"Source",
|
|
68
|
+
"SourceNotReplayableError",
|
|
69
|
+
"create_feature",
|
|
70
|
+
"distributed_extract",
|
|
71
|
+
"extract",
|
|
72
|
+
"feature_info",
|
|
73
|
+
"fit",
|
|
74
|
+
"gpu",
|
|
75
|
+
"infer_schema",
|
|
76
|
+
"list_families",
|
|
77
|
+
"list_features",
|
|
78
|
+
"list_modalities",
|
|
79
|
+
"load_fitted",
|
|
80
|
+
"parse_size",
|
|
81
|
+
"register_feature",
|
|
82
|
+
"resolve_source",
|
|
83
|
+
"search",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
__version__ = "0.5.7"
|
featm/cache.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Shared intermediate computation cache.
|
|
2
|
+
|
|
3
|
+
Selected features frequently depend on the same expensive intermediate (one
|
|
4
|
+
FFT feeding several spectral descriptors, one Delaunay triangulation feeding
|
|
5
|
+
several simplex statistics, one graph Laplacian eigendecomposition feeding
|
|
6
|
+
several spectral graph features, and so on). This module gives feature
|
|
7
|
+
implementations a way to memoize such intermediates *within one execution
|
|
8
|
+
scope* (one batch of one :func:`featm.extract` / engine run) so that
|
|
9
|
+
selecting several related features does not recompute shared work.
|
|
10
|
+
|
|
11
|
+
Design:
|
|
12
|
+
|
|
13
|
+
- A cache is explicitly scoped via :func:`scoped_cache`, a context manager
|
|
14
|
+
entered once per batch/call. Each scope gets its own cache instance, so
|
|
15
|
+
data from different batches or different datasets can never leak into one
|
|
16
|
+
another -- the cache simply does not outlive its scope.
|
|
17
|
+
- Within a scope, callers look up the *current* cache with
|
|
18
|
+
:func:`current_cache` (backed by a ``contextvars.ContextVar`` so it is
|
|
19
|
+
correct across threads) and call :meth:`SharedIntermediateCache.get_or_compute`.
|
|
20
|
+
- The cache obeys a byte budget with LRU eviction so a scope cannot grow
|
|
21
|
+
without bound; evicted entries are simply recomputed on next use.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextvars
|
|
26
|
+
import hashlib
|
|
27
|
+
import threading
|
|
28
|
+
from collections import OrderedDict
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from typing import Any, Callable, Iterator
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
_DEFAULT_BUDGET_BYTES = 256 * 1024 * 1024 # 256MB per execution scope by default
|
|
35
|
+
|
|
36
|
+
_CURRENT: contextvars.ContextVar["SharedIntermediateCache | None"] = contextvars.ContextVar(
|
|
37
|
+
"featm_current_cache", default=None
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _default_size_estimate(value: Any) -> int:
|
|
42
|
+
if isinstance(value, np.ndarray):
|
|
43
|
+
return int(value.nbytes)
|
|
44
|
+
if isinstance(value, (list, tuple)):
|
|
45
|
+
return sum(_default_size_estimate(v) for v in value) or 64
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return sum(_default_size_estimate(v) for v in value.values()) or 64
|
|
48
|
+
if isinstance(value, (bytes, bytearray, str)):
|
|
49
|
+
return len(value)
|
|
50
|
+
return 64 # crude fallback for scalars/small objects
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SharedIntermediateCache:
|
|
54
|
+
"""A bounded, LRU-evicting cache for one execution scope."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, max_bytes: int = _DEFAULT_BUDGET_BYTES) -> None:
|
|
57
|
+
self.max_bytes = max_bytes
|
|
58
|
+
self._store: "OrderedDict[tuple, tuple[Any, int]]" = OrderedDict()
|
|
59
|
+
self._bytes = 0
|
|
60
|
+
self.hits = 0
|
|
61
|
+
self.misses = 0
|
|
62
|
+
self.evictions = 0
|
|
63
|
+
self.oversized_skips = 0
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
|
|
66
|
+
def get_or_compute(
|
|
67
|
+
self,
|
|
68
|
+
key: tuple,
|
|
69
|
+
compute_fn: Callable[[], Any],
|
|
70
|
+
*,
|
|
71
|
+
size_estimator: Callable[[Any], int] | None = None,
|
|
72
|
+
) -> Any:
|
|
73
|
+
with self._lock:
|
|
74
|
+
hit = self._store.get(key)
|
|
75
|
+
if hit is not None:
|
|
76
|
+
self.hits += 1
|
|
77
|
+
self._store.move_to_end(key)
|
|
78
|
+
return hit[0]
|
|
79
|
+
|
|
80
|
+
# Compute outside the lock: recomputation for an already-cached key by a
|
|
81
|
+
# concurrent caller is wasted work, not a correctness problem, and this
|
|
82
|
+
# avoids holding the lock across a potentially expensive computation.
|
|
83
|
+
value = compute_fn()
|
|
84
|
+
estimator = size_estimator or _default_size_estimate
|
|
85
|
+
nbytes = max(int(estimator(value)), 1)
|
|
86
|
+
|
|
87
|
+
with self._lock:
|
|
88
|
+
self.misses += 1
|
|
89
|
+
if nbytes > self.max_bytes:
|
|
90
|
+
# A single value larger than the whole budget must never be
|
|
91
|
+
# retained -- caching it would permanently keep the cache over
|
|
92
|
+
# budget (nothing left to evict it in favor of). Serve it to
|
|
93
|
+
# the caller without storing it; it will simply be recomputed
|
|
94
|
+
# next time. This is the correct trade for a hard budget.
|
|
95
|
+
self.oversized_skips += 1
|
|
96
|
+
return value
|
|
97
|
+
self._store[key] = (value, nbytes)
|
|
98
|
+
self._store.move_to_end(key)
|
|
99
|
+
self._bytes += nbytes
|
|
100
|
+
self._evict_if_needed()
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
def _evict_if_needed(self) -> None:
|
|
104
|
+
# No "leave at least one entry" exception: an entry only ever reaches
|
|
105
|
+
# this point after already passing the oversized-value check above, so
|
|
106
|
+
# evicting everything else is always sufficient to get back under
|
|
107
|
+
# budget without having to evict the entry that was just inserted.
|
|
108
|
+
while self._bytes > self.max_bytes and self._store:
|
|
109
|
+
_, (_, nbytes) = self._store.popitem(last=False)
|
|
110
|
+
self._bytes -= nbytes
|
|
111
|
+
self.evictions += 1
|
|
112
|
+
|
|
113
|
+
def clear(self) -> None:
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._store.clear()
|
|
116
|
+
self._bytes = 0
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def size_bytes(self) -> int:
|
|
120
|
+
return self._bytes
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def entry_count(self) -> int:
|
|
124
|
+
return len(self._store)
|
|
125
|
+
|
|
126
|
+
def stats(self) -> dict[str, int]:
|
|
127
|
+
return {
|
|
128
|
+
"hits": self.hits,
|
|
129
|
+
"misses": self.misses,
|
|
130
|
+
"evictions": self.evictions,
|
|
131
|
+
"oversized_skips": self.oversized_skips,
|
|
132
|
+
"entries": self.entry_count,
|
|
133
|
+
"bytes": self._bytes,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def current_cache() -> SharedIntermediateCache | None:
|
|
138
|
+
"""Return the cache for the current execution scope, or ``None`` outside one."""
|
|
139
|
+
return _CURRENT.get()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@contextmanager
|
|
143
|
+
def scoped_cache(
|
|
144
|
+
cache: SharedIntermediateCache | None = None,
|
|
145
|
+
*,
|
|
146
|
+
max_bytes: int = _DEFAULT_BUDGET_BYTES,
|
|
147
|
+
) -> Iterator[SharedIntermediateCache]:
|
|
148
|
+
"""Open one fresh cache scope (one batch, or one whole-array ``extract`` call).
|
|
149
|
+
|
|
150
|
+
A new :class:`SharedIntermediateCache` is created per scope unless one is
|
|
151
|
+
passed explicitly, guaranteeing intermediates never leak across scopes.
|
|
152
|
+
"""
|
|
153
|
+
c = cache if cache is not None else SharedIntermediateCache(max_bytes=max_bytes)
|
|
154
|
+
token = _CURRENT.set(c)
|
|
155
|
+
try:
|
|
156
|
+
yield c
|
|
157
|
+
finally:
|
|
158
|
+
_CURRENT.reset(token)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def array_cache_key(namespace: str, arr: Any, **params: Any) -> tuple:
|
|
162
|
+
"""Build a content-addressed cache key for a numpy-convertible array.
|
|
163
|
+
|
|
164
|
+
Content hashing (rather than object identity) is used here because arrays
|
|
165
|
+
are frequently re-wrapped/copied between the caller and the feature
|
|
166
|
+
implementation; hashing the actual bytes still gives a correct cache hit
|
|
167
|
+
without relying on the same Python object surviving the whole call.
|
|
168
|
+
|
|
169
|
+
``np.ascontiguousarray(a).tobytes()`` always makes a full copy, which is
|
|
170
|
+
wasteful for a large array whose only purpose here is to be hashed. The
|
|
171
|
+
common case -- an already C-contiguous array -- can be hashed straight
|
|
172
|
+
from its existing buffer via a ``memoryview`` with no copy at all; a copy
|
|
173
|
+
is made only for the genuinely non-contiguous case, where one is
|
|
174
|
+
unavoidable to get a well-defined byte order to hash.
|
|
175
|
+
"""
|
|
176
|
+
a = np.asarray(arr)
|
|
177
|
+
buf = a.data if a.flags["C_CONTIGUOUS"] else np.ascontiguousarray(a).tobytes()
|
|
178
|
+
digest = hashlib.blake2b(buf, digest_size=16).hexdigest()
|
|
179
|
+
return (namespace, digest, a.shape, str(a.dtype), tuple(sorted(params.items())))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def identity_cache_key(namespace: str, obj: Any, **params: Any) -> tuple:
|
|
183
|
+
"""Build an object-identity cache key for objects too expensive to hash by content.
|
|
184
|
+
|
|
185
|
+
Valid only *within one execution scope*: Python guarantees ``id()`` is
|
|
186
|
+
unique among objects alive at the same time, and a scope's cache never
|
|
187
|
+
outlives the batch whose objects it was built from, so this cannot
|
|
188
|
+
collide across batches or datasets.
|
|
189
|
+
"""
|
|
190
|
+
return (namespace, id(obj), tuple(sorted(params.items())))
|
featm/checkpoint.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Checkpoint / resume for long out-of-core extraction runs.
|
|
2
|
+
|
|
3
|
+
A checkpoint directory holds one small JSON manifest (written atomically —
|
|
4
|
+
temp file + ``os.replace``, so a crash mid-write cannot leave a corrupt
|
|
5
|
+
manifest that looks valid) plus any fitted-state artifacts and the partial
|
|
6
|
+
disk-backed output written so far.
|
|
7
|
+
|
|
8
|
+
Granularity: checkpointing is at the *batch* level for one execution plan
|
|
9
|
+
(the whole set of selected features run together), not at the sub-batch
|
|
10
|
+
level and not independently per feature. A run has up to two phases:
|
|
11
|
+
|
|
12
|
+
- ``fit`` -- only present if at least one selected feature is stateful;
|
|
13
|
+
scans the source once, merging each stateful feature's state,
|
|
14
|
+
checkpointing the running accumulator after every batch so an
|
|
15
|
+
interrupted fit resumes mid-pass rather than from scratch.
|
|
16
|
+
- ``transform`` -- scans the source (again, if there was a fit phase) computing
|
|
17
|
+
every selected feature per batch and writing one output shard
|
|
18
|
+
per batch, checkpointing after each shard.
|
|
19
|
+
|
|
20
|
+
On resume, already-completed batches for the active phase are skipped by
|
|
21
|
+
re-iterating the source and discarding them (cheap: no feature computation
|
|
22
|
+
runs against a skipped batch) rather than recomputing anything. Compatibility
|
|
23
|
+
is verified against a fingerprint of the source, schema, selected features
|
|
24
|
+
and parameters, and the relevant execution settings before any work is
|
|
25
|
+
reused; an incompatible checkpoint is rejected with a clear explanation
|
|
26
|
+
rather than silently reused.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import pickle
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
CHECKPOINT_MANIFEST_VERSION = 3
|
|
38
|
+
MANIFEST_FILENAME = "checkpoint.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CheckpointIncompatibleError(RuntimeError):
|
|
42
|
+
"""Raised when an existing checkpoint does not match the requested run."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class CheckpointIdentity:
|
|
47
|
+
"""Everything that must match between a checkpoint and the run resuming it."""
|
|
48
|
+
|
|
49
|
+
featm_version: str
|
|
50
|
+
source_fingerprint: str
|
|
51
|
+
schema_fingerprint: str
|
|
52
|
+
features: list[str]
|
|
53
|
+
params: dict[str, dict[str, Any]]
|
|
54
|
+
settings: dict[str, Any]
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, Any]:
|
|
57
|
+
return {
|
|
58
|
+
"featm_version": self.featm_version,
|
|
59
|
+
"source_fingerprint": self.source_fingerprint,
|
|
60
|
+
"schema_fingerprint": self.schema_fingerprint,
|
|
61
|
+
"features": list(self.features),
|
|
62
|
+
"params": self.params,
|
|
63
|
+
"settings": self.settings,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _atomic_write_json(path: Path, payload: dict) -> None:
|
|
68
|
+
tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
|
|
69
|
+
tmp.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
70
|
+
os.replace(tmp, path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _safe_filename(name: str) -> str:
|
|
74
|
+
return "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CheckpointManager:
|
|
78
|
+
def __init__(self, directory: str | Path) -> None:
|
|
79
|
+
self.directory = Path(directory)
|
|
80
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
self.output_dir = self.directory / "output"
|
|
82
|
+
self.state_dir = self.directory / "state"
|
|
83
|
+
self.manifest_path = self.directory / MANIFEST_FILENAME
|
|
84
|
+
|
|
85
|
+
# --- manifest lifecycle -------------------------------------------------
|
|
86
|
+
def load(self) -> dict[str, Any] | None:
|
|
87
|
+
if not self.manifest_path.exists():
|
|
88
|
+
return None
|
|
89
|
+
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
|
90
|
+
if not isinstance(manifest, dict):
|
|
91
|
+
raise CheckpointIncompatibleError(
|
|
92
|
+
f"Existing checkpoint manifest at {self.manifest_path} is invalid: "
|
|
93
|
+
"its JSON root must be an object. Use a fresh checkpoint directory."
|
|
94
|
+
)
|
|
95
|
+
return manifest
|
|
96
|
+
|
|
97
|
+
def _check_compatible(self, existing: dict[str, Any], identity: CheckpointIdentity) -> None:
|
|
98
|
+
# manifest_version is compared explicitly (local checkpoint
|
|
99
|
+
# replay-history closure pass, v0.4.3 -> v0.4.4): a checkpoint
|
|
100
|
+
# written under an older manifest schema (v1, before failure/
|
|
101
|
+
# degradation history was persisted at all) must be rejected as
|
|
102
|
+
# incompatible on resume, not silently reused -- an old manifest
|
|
103
|
+
# simply has no `fit_failed_features`/`transform_priming_failed_
|
|
104
|
+
# features`/`degraded_batches_by_feature` keys to restore, which
|
|
105
|
+
# would silently reintroduce the exact history-loss bug this pass
|
|
106
|
+
# fixes for any pre-existing checkpoint. A versioned package string
|
|
107
|
+
# (`featm_version`) is not a substitute for this -- the schema, not
|
|
108
|
+
# just the code, changed.
|
|
109
|
+
if existing.get("manifest_version") != CHECKPOINT_MANIFEST_VERSION:
|
|
110
|
+
raise CheckpointIncompatibleError(
|
|
111
|
+
f"Checkpoint at {self.directory} was written under manifest schema version "
|
|
112
|
+
f"{existing.get('manifest_version')!r}, but this version of featm requires "
|
|
113
|
+
f"{CHECKPOINT_MANIFEST_VERSION!r} (older checkpoints do not persist enough failure/degradation "
|
|
114
|
+
"history to resume with a replay-equivalent report). Use a fresh checkpoint directory."
|
|
115
|
+
)
|
|
116
|
+
wanted = identity.to_dict()
|
|
117
|
+
mismatches = []
|
|
118
|
+
for key, want in wanted.items():
|
|
119
|
+
got = existing.get(key)
|
|
120
|
+
if got != want:
|
|
121
|
+
mismatches.append(f"{key}: checkpoint has {got!r}, requested run has {want!r}")
|
|
122
|
+
if mismatches:
|
|
123
|
+
raise CheckpointIncompatibleError(
|
|
124
|
+
f"Checkpoint at {self.directory} is not compatible with this run:\n "
|
|
125
|
+
+ "\n ".join(mismatches)
|
|
126
|
+
+ "\nUse a fresh checkpoint directory, or match the source/features/params/settings exactly."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def initialize_or_resume(self, identity: CheckpointIdentity, *, has_fit_phase: bool) -> dict[str, Any]:
|
|
130
|
+
"""Load a compatible existing manifest, or create a fresh one.
|
|
131
|
+
|
|
132
|
+
Raises :class:`CheckpointIncompatibleError` if a checkpoint exists at
|
|
133
|
+
this directory but does not match ``identity``.
|
|
134
|
+
"""
|
|
135
|
+
existing = self.load()
|
|
136
|
+
if existing is not None:
|
|
137
|
+
self._check_compatible(existing, identity)
|
|
138
|
+
return existing
|
|
139
|
+
manifest = identity.to_dict()
|
|
140
|
+
manifest.update(
|
|
141
|
+
{
|
|
142
|
+
"manifest_version": CHECKPOINT_MANIFEST_VERSION,
|
|
143
|
+
"phase": "fit" if has_fit_phase else "transform",
|
|
144
|
+
"fit_batches_completed": 0,
|
|
145
|
+
"fit_accumulator_file": None,
|
|
146
|
+
"fit_state_files": {},
|
|
147
|
+
# Local checkpoint replay-history closure pass (v0.4.3 ->
|
|
148
|
+
# v0.4.4): durable failure/degradation metadata, so a resumed
|
|
149
|
+
# (partial or zero-read "done") run's report is
|
|
150
|
+
# replay-equivalent to what an uninterrupted run would have
|
|
151
|
+
# reported -- not silently clean merely because the feature
|
|
152
|
+
# that failed left no successfully-fitted-state file behind
|
|
153
|
+
# to key a restore off of. Each `..._failed_features` value
|
|
154
|
+
# is ``{"kind": "failed" | "optional_dependency", "message": str}``
|
|
155
|
+
# so a restore can call the matching ExecutionReport method.
|
|
156
|
+
"fit_failed_features": {},
|
|
157
|
+
"transform_priming_failed_features": {},
|
|
158
|
+
"degraded_batches_by_feature": {},
|
|
159
|
+
"transform_batches_completed": 0,
|
|
160
|
+
"transform_shards_written": 0,
|
|
161
|
+
"done": False,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
self.save(manifest)
|
|
165
|
+
return manifest
|
|
166
|
+
|
|
167
|
+
def save(self, manifest: dict[str, Any]) -> None:
|
|
168
|
+
_atomic_write_json(self.manifest_path, manifest)
|
|
169
|
+
|
|
170
|
+
# --- fitted state artifacts ----------------------------------------------
|
|
171
|
+
def save_fit_state(self, feature_name: str, state: Any) -> str:
|
|
172
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
filename = f"{_safe_filename(feature_name)}.pkl"
|
|
174
|
+
path = self.state_dir / filename
|
|
175
|
+
tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
|
|
176
|
+
with open(tmp, "wb") as fh:
|
|
177
|
+
pickle.dump(state, fh)
|
|
178
|
+
os.replace(tmp, path)
|
|
179
|
+
return filename
|
|
180
|
+
|
|
181
|
+
def load_fit_state(self, filename: str) -> Any:
|
|
182
|
+
with open(self.state_dir / filename, "rb") as fh:
|
|
183
|
+
return pickle.load(fh)
|
|
184
|
+
|
|
185
|
+
def save_fit_accumulators(self, accumulators: dict[str, Any], batch_index: int) -> str:
|
|
186
|
+
"""Persist in-progress fit accumulators as a *versioned* snapshot, named by batch index.
|
|
187
|
+
|
|
188
|
+
This does NOT update the manifest -- the caller must separately call
|
|
189
|
+
:meth:`save` with the manifest referencing this filename once it wants
|
|
190
|
+
to commit to this snapshot as the new resume point. Two files being
|
|
191
|
+
each individually atomic (this one, and the manifest) does not make
|
|
192
|
+
writing both together atomic: a crash between the two would otherwise
|
|
193
|
+
leave the manifest pointing at (and reprocessing) a batch whose
|
|
194
|
+
accumulator snapshot has already moved past it, double-counting that
|
|
195
|
+
batch's contribution to the fitted state. Versioning by batch index
|
|
196
|
+
means an orphaned snapshot from a crash before the manifest commit is
|
|
197
|
+
simply never referenced and is harmless (cleaned up opportunistically
|
|
198
|
+
by :meth:`cleanup_orphan_accumulator_snapshots`).
|
|
199
|
+
"""
|
|
200
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
filename = f"_fit_accumulators-{batch_index:010d}.pkl"
|
|
202
|
+
path = self.state_dir / filename
|
|
203
|
+
tmp = path.with_suffix(path.suffix + f".tmp{os.getpid()}")
|
|
204
|
+
with open(tmp, "wb") as fh:
|
|
205
|
+
pickle.dump(accumulators, fh)
|
|
206
|
+
os.replace(tmp, path)
|
|
207
|
+
return filename
|
|
208
|
+
|
|
209
|
+
def load_fit_accumulators(self, filename: str) -> dict[str, Any] | None:
|
|
210
|
+
"""Load exactly the snapshot the manifest committed to -- never "the latest on disk"."""
|
|
211
|
+
path = self.state_dir / filename
|
|
212
|
+
if not path.exists():
|
|
213
|
+
from .matrix import CheckpointCorruptionError
|
|
214
|
+
raise CheckpointCorruptionError(f"Committed fit snapshot is missing: {path}")
|
|
215
|
+
try:
|
|
216
|
+
with open(path, "rb") as fh:
|
|
217
|
+
return pickle.load(fh)
|
|
218
|
+
except (OSError, pickle.UnpicklingError, EOFError) as exc:
|
|
219
|
+
from .matrix import CheckpointCorruptionError
|
|
220
|
+
raise CheckpointCorruptionError(f"Committed fit snapshot is unreadable: {path}") from exc
|
|
221
|
+
|
|
222
|
+
def cleanup_orphan_accumulator_snapshots(self, keep_filename: str | None) -> None:
|
|
223
|
+
"""Remove old/orphaned accumulator snapshots, keeping only the manifest-committed one."""
|
|
224
|
+
if not self.state_dir.exists():
|
|
225
|
+
return
|
|
226
|
+
for p in self.state_dir.glob("_fit_accumulators-*.pkl"):
|
|
227
|
+
if keep_filename is None or p.name != keep_filename:
|
|
228
|
+
try:
|
|
229
|
+
p.unlink()
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
featm/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from .engine import feature_info, list_families, list_features, list_modalities, search
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _spec_dict(spec):
|
|
11
|
+
data = asdict(spec)
|
|
12
|
+
data.pop("implementation", None)
|
|
13
|
+
cost = data.get("cost")
|
|
14
|
+
if hasattr(cost, "value"):
|
|
15
|
+
data["cost"] = cost.value
|
|
16
|
+
return data
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _print_specs(specs) -> None:
|
|
20
|
+
for spec in specs:
|
|
21
|
+
suffix = f" [{', '.join(spec.requires)}]" if spec.requires else ""
|
|
22
|
+
print(f"{spec.name}{suffix}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="featm",
|
|
28
|
+
description="Browse the featm feature registry.",
|
|
29
|
+
)
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
p_list = sub.add_parser("list", help="List registered feature names")
|
|
33
|
+
p_list.add_argument("pattern", nargs="?", default=None)
|
|
34
|
+
p_list.add_argument("--modality", default=None)
|
|
35
|
+
p_list.add_argument("--family", default=None)
|
|
36
|
+
|
|
37
|
+
p_search = sub.add_parser("search", help="Search feature names/descriptions/tags")
|
|
38
|
+
p_search.add_argument("query")
|
|
39
|
+
|
|
40
|
+
p_info = sub.add_parser("info", help="Show metadata for one feature")
|
|
41
|
+
p_info.add_argument("name")
|
|
42
|
+
p_info.add_argument("--json", action="store_true")
|
|
43
|
+
|
|
44
|
+
sub.add_parser("modalities", help="List available modalities")
|
|
45
|
+
|
|
46
|
+
p_families = sub.add_parser("families", help="List feature families")
|
|
47
|
+
p_families.add_argument("--modality", default=None)
|
|
48
|
+
|
|
49
|
+
p_run = sub.add_parser("run", help="Run an out-of-core extraction against a file source")
|
|
50
|
+
p_run.add_argument("source", help="Path to a CSV/TSV/JSONL/Parquet/Arrow file")
|
|
51
|
+
p_run.add_argument("--features", nargs="+", required=True, help="Explicit feature names to extract")
|
|
52
|
+
p_run.add_argument(
|
|
53
|
+
"--column", action="append", default=[], metavar="FEATURE=COLUMN",
|
|
54
|
+
help="Explicit input column for one tabular feature, e.g. tabular.scaling.zscore=price. Repeatable.",
|
|
55
|
+
)
|
|
56
|
+
p_run.add_argument("--batch-rows", type=int, default=None)
|
|
57
|
+
p_run.add_argument("--memory-budget", default=None, help="e.g. 2GB")
|
|
58
|
+
p_run.add_argument("--disk-budget", default=None, help="e.g. 50GB")
|
|
59
|
+
p_run.add_argument("--output", choices=["auto", "memory", "disk"], default="auto")
|
|
60
|
+
p_run.add_argument("--output-path", default=None, help="Write the result here (.parquet or .csv)")
|
|
61
|
+
p_run.add_argument("--mode", choices=["exact", "approximate", "auto"], default="exact")
|
|
62
|
+
p_run.add_argument("--errors", choices=["raise", "warn", "skip"], default="skip")
|
|
63
|
+
p_run.add_argument("--checkpoint", default=None, help="Checkpoint directory for resumable runs")
|
|
64
|
+
p_run.add_argument("--json", action="store_true", help="Print the execution report as JSON")
|
|
65
|
+
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
args = build_parser().parse_args(argv)
|
|
71
|
+
|
|
72
|
+
if args.command == "list":
|
|
73
|
+
for name in list_features(args.pattern, modality=args.modality, family=args.family):
|
|
74
|
+
print(name)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
if args.command == "search":
|
|
78
|
+
_print_specs(search(args.query))
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
if args.command == "info":
|
|
82
|
+
spec = feature_info(args.name)
|
|
83
|
+
data = _spec_dict(spec)
|
|
84
|
+
if args.json:
|
|
85
|
+
print(json.dumps(data, indent=2, default=str))
|
|
86
|
+
else:
|
|
87
|
+
for key, value in data.items():
|
|
88
|
+
print(f"{key}: {value}")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
if args.command == "modalities":
|
|
92
|
+
for modality in list_modalities():
|
|
93
|
+
print(modality)
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
if args.command == "families":
|
|
97
|
+
for family in list_families(modality=args.modality):
|
|
98
|
+
print(family)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
if args.command == "run":
|
|
102
|
+
from .engine import extract as _extract
|
|
103
|
+
|
|
104
|
+
columns = {}
|
|
105
|
+
for item in args.column:
|
|
106
|
+
key, _, value = item.partition("=")
|
|
107
|
+
if not _:
|
|
108
|
+
print(f"--column expects FEATURE=COLUMN, got: {item!r}")
|
|
109
|
+
return 2
|
|
110
|
+
columns[key] = value
|
|
111
|
+
|
|
112
|
+
result = _extract(
|
|
113
|
+
args.source,
|
|
114
|
+
features=args.features,
|
|
115
|
+
columns=columns or None,
|
|
116
|
+
batch_rows=args.batch_rows,
|
|
117
|
+
memory_budget=args.memory_budget,
|
|
118
|
+
disk_budget=args.disk_budget,
|
|
119
|
+
output=args.output,
|
|
120
|
+
mode=args.mode,
|
|
121
|
+
errors=args.errors,
|
|
122
|
+
checkpoint=args.checkpoint,
|
|
123
|
+
)
|
|
124
|
+
if args.json:
|
|
125
|
+
print(json.dumps(result.report.to_dict(), indent=2, default=str))
|
|
126
|
+
else:
|
|
127
|
+
print(f"shape: {result.shape} backend: {'disk' if result.is_disk_backed else 'memory'}")
|
|
128
|
+
print(result.report.summary())
|
|
129
|
+
if args.output_path:
|
|
130
|
+
if str(args.output_path).endswith(".csv"):
|
|
131
|
+
result.to_csv(args.output_path)
|
|
132
|
+
else:
|
|
133
|
+
result.to_parquet(args.output_path)
|
|
134
|
+
print(f"wrote {result.shape[0]} rows x {result.shape[1]} columns to {args.output_path}")
|
|
135
|
+
return 0 if not result.report.failed_features else 1
|
|
136
|
+
|
|
137
|
+
return 2
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
raise SystemExit(main())
|