dataeval-flow 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.
- dataeval_flow/__init__.py +93 -0
- dataeval_flow/__main__.py +149 -0
- dataeval_flow/_app/__init__.py +5 -0
- dataeval_flow/_app/_model/__init__.py +5 -0
- dataeval_flow/_app/_model/_coerce.py +126 -0
- dataeval_flow/_app/_model/_discover.py +171 -0
- dataeval_flow/_app/_model/_execution.py +108 -0
- dataeval_flow/_app/_model/_introspect.py +280 -0
- dataeval_flow/_app/_model/_item.py +213 -0
- dataeval_flow/_app/_model/_registry.py +255 -0
- dataeval_flow/_app/_model/_state.py +322 -0
- dataeval_flow/_app/_model/_undo.py +61 -0
- dataeval_flow/_app/_panes/__init__.py +35 -0
- dataeval_flow/_app/_panes/_config_pane.py +173 -0
- dataeval_flow/_app/_panes/_result_pane.py +125 -0
- dataeval_flow/_app/_panes/_task_pane.py +91 -0
- dataeval_flow/_app/_panes/_widgets.py +111 -0
- dataeval_flow/_app/_screens/__init__.py +25 -0
- dataeval_flow/_app/_screens/_base.py +242 -0
- dataeval_flow/_app/_screens/_detail.py +333 -0
- dataeval_flow/_app/_screens/_model.py +102 -0
- dataeval_flow/_app/_screens/_params.py +80 -0
- dataeval_flow/_app/_screens/_pathpicker.py +68 -0
- dataeval_flow/_app/_screens/_section.py +621 -0
- dataeval_flow/_app/_screens/_settings.py +183 -0
- dataeval_flow/_app/_viewmodel/__init__.py +15 -0
- dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
- dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
- dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
- dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
- dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
- dataeval_flow/_app/app.py +742 -0
- dataeval_flow/_app/cli.py +592 -0
- dataeval_flow/_logging.py +102 -0
- dataeval_flow/cache.py +1355 -0
- dataeval_flow/config/__init__.py +80 -0
- dataeval_flow/config/_loader.py +79 -0
- dataeval_flow/config/_merge.py +92 -0
- dataeval_flow/config/_models.py +115 -0
- dataeval_flow/config/_paths.py +85 -0
- dataeval_flow/config/schemas/__init__.py +112 -0
- dataeval_flow/config/schemas/_dataset.py +111 -0
- dataeval_flow/config/schemas/_extractor.py +119 -0
- dataeval_flow/config/schemas/_metadata.py +28 -0
- dataeval_flow/config/schemas/_preprocessor.py +18 -0
- dataeval_flow/config/schemas/_selection.py +100 -0
- dataeval_flow/config/schemas/_task.py +89 -0
- dataeval_flow/config/schemas/_workflow.py +135 -0
- dataeval_flow/dataset.py +635 -0
- dataeval_flow/embeddings.py +135 -0
- dataeval_flow/metadata.py +48 -0
- dataeval_flow/preprocessing.py +141 -0
- dataeval_flow/py.typed +0 -0
- dataeval_flow/runner.py +118 -0
- dataeval_flow/selection.py +50 -0
- dataeval_flow/workflow/__init__.py +328 -0
- dataeval_flow/workflow/_text_report.py +511 -0
- dataeval_flow/workflow/base.py +69 -0
- dataeval_flow/workflow/orchestrator.py +454 -0
- dataeval_flow/workflows/__init__.py +1 -0
- dataeval_flow/workflows/analysis/__init__.py +38 -0
- dataeval_flow/workflows/analysis/outputs.py +202 -0
- dataeval_flow/workflows/analysis/params.py +114 -0
- dataeval_flow/workflows/analysis/workflow.py +1313 -0
- dataeval_flow/workflows/cleaning/__init__.py +23 -0
- dataeval_flow/workflows/cleaning/outputs.py +200 -0
- dataeval_flow/workflows/cleaning/params.py +160 -0
- dataeval_flow/workflows/cleaning/report.py +304 -0
- dataeval_flow/workflows/cleaning/workflow.py +794 -0
- dataeval_flow/workflows/drift/__init__.py +1 -0
- dataeval_flow/workflows/drift/outputs.py +144 -0
- dataeval_flow/workflows/drift/params.py +332 -0
- dataeval_flow/workflows/drift/report.py +201 -0
- dataeval_flow/workflows/drift/workflow.py +647 -0
- dataeval_flow/workflows/ood/__init__.py +1 -0
- dataeval_flow/workflows/ood/outputs.py +134 -0
- dataeval_flow/workflows/ood/params.py +161 -0
- dataeval_flow/workflows/ood/report.py +311 -0
- dataeval_flow/workflows/ood/workflow.py +728 -0
- dataeval_flow/workflows/prioritization/__init__.py +1 -0
- dataeval_flow/workflows/prioritization/outputs.py +122 -0
- dataeval_flow/workflows/prioritization/params.py +124 -0
- dataeval_flow/workflows/prioritization/report.py +117 -0
- dataeval_flow/workflows/prioritization/workflow.py +587 -0
- dataeval_flow/workflows/splitting/__init__.py +25 -0
- dataeval_flow/workflows/splitting/outputs.py +101 -0
- dataeval_flow/workflows/splitting/params.py +61 -0
- dataeval_flow/workflows/splitting/report.py +485 -0
- dataeval_flow/workflows/splitting/workflow.py +371 -0
- dataeval_flow-0.1.0.dist-info/METADATA +305 -0
- dataeval_flow-0.1.0.dist-info/RECORD +94 -0
- dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
- dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
- dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
dataeval_flow/cache.py
ADDED
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
"""Workflow cache for expensive computations — optionally disk-backed.
|
|
2
|
+
|
|
3
|
+
.. warning::
|
|
4
|
+
|
|
5
|
+
All cached values within a single ``DatasetCache`` instance **must** be
|
|
6
|
+
sourced from the same dataset. Do **not** mix artifacts from different
|
|
7
|
+
datasets in one cache — this prevents cross-dataset dependencies and
|
|
8
|
+
ensures cache invalidation is straightforward.
|
|
9
|
+
|
|
10
|
+
When ``cache_dir`` is provided, ``DatasetCache`` persists artifacts to
|
|
11
|
+
disk so that subsequent runs reuse previously computed results. When
|
|
12
|
+
``cache_dir`` is ``None``, the cache operates in **memory-only** mode:
|
|
13
|
+
disk loads always miss and disk saves are no-ops, but computed values are
|
|
14
|
+
still held in the per-instance ``_memory`` dict and the
|
|
15
|
+
``load_or_compute_*`` methods still provide a single code-path for
|
|
16
|
+
obtaining each artifact.
|
|
17
|
+
|
|
18
|
+
A global singleton registry (keyed by ``dataset_name``) ensures that the
|
|
19
|
+
same **non-disk-backed** in-memory cache is reused across multiple
|
|
20
|
+
``run_task`` calls for the same dataset configuration — use
|
|
21
|
+
:meth:`DatasetCache.get_or_create` to take advantage of this.
|
|
22
|
+
|
|
23
|
+
Provides load/save methods for four component types:
|
|
24
|
+
|
|
25
|
+
- **Embeddings** — Dense numpy arrays stored as ``.npy``
|
|
26
|
+
- **Cluster results** — Clustering output stored as ``.npz``
|
|
27
|
+
- **Metadata** — Polars DataFrame as ``.parquet`` + auxiliary attributes as ``.json``
|
|
28
|
+
- **Stats** — Unified ``StatsResult`` stored as ``.parquet`` + ``.json``.
|
|
29
|
+
Metrics accumulate incrementally: different workflows requesting different
|
|
30
|
+
``ImageStats`` flags share the same cache entry and only compute the
|
|
31
|
+
missing metrics.
|
|
32
|
+
|
|
33
|
+
Cache layout (disk-backed mode)::
|
|
34
|
+
|
|
35
|
+
cache_dir/
|
|
36
|
+
v{CACHE_VERSION}/
|
|
37
|
+
{dataset_name}_{dataset_config_hash}/
|
|
38
|
+
sel_{selection_hash}/
|
|
39
|
+
embeddings_{config_hash}.npy
|
|
40
|
+
clusters_{config_hash}.npz
|
|
41
|
+
metadata.parquet
|
|
42
|
+
metadata.json
|
|
43
|
+
stats_{scope_hash}.parquet
|
|
44
|
+
stats_{scope_hash}.json
|
|
45
|
+
|
|
46
|
+
Cache artifacts are stored under a ``v{CACHE_VERSION}`` subdirectory so
|
|
47
|
+
that different versions can coexist side-by-side. When the cache format
|
|
48
|
+
changes in a backwards-incompatible way, bump ``CACHE_VERSION`` and old
|
|
49
|
+
data is simply ignored. Users can clean up stale versions by removing the
|
|
50
|
+
old ``v*/`` directories (e.g. ``rm -rf /cache/v1``).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"CACHE_VERSION",
|
|
55
|
+
"FLAG_TO_METRIC",
|
|
56
|
+
"METRIC_TO_FLAG",
|
|
57
|
+
"DatasetCache",
|
|
58
|
+
"active_cache",
|
|
59
|
+
"get_or_compute_cluster_result",
|
|
60
|
+
"get_or_compute_embeddings",
|
|
61
|
+
"get_or_compute_metadata",
|
|
62
|
+
"get_or_compute_stats",
|
|
63
|
+
"missing_flags",
|
|
64
|
+
"scope_key",
|
|
65
|
+
"dataset_fingerprint",
|
|
66
|
+
"selection_repr",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
import contextvars
|
|
70
|
+
import hashlib
|
|
71
|
+
import json
|
|
72
|
+
import logging
|
|
73
|
+
import os
|
|
74
|
+
import tempfile
|
|
75
|
+
import threading
|
|
76
|
+
from collections.abc import Callable, Generator, Mapping, Sequence
|
|
77
|
+
from contextlib import contextmanager
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
80
|
+
|
|
81
|
+
import numpy as np
|
|
82
|
+
import polars as pl
|
|
83
|
+
from dataeval.core import ClusterResult
|
|
84
|
+
from dataeval.flags import ImageStats
|
|
85
|
+
from dataeval.protocols import AnnotatedDataset, Array
|
|
86
|
+
from numpy.typing import NDArray
|
|
87
|
+
|
|
88
|
+
if TYPE_CHECKING:
|
|
89
|
+
from dataeval import Metadata
|
|
90
|
+
|
|
91
|
+
_logger = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
# Bump this when the on-disk cache format changes in a backwards-incompatible
|
|
94
|
+
# way. Cached artifacts are stored under ``v{CACHE_VERSION}/`` so different
|
|
95
|
+
# versions coexist and users can ``rm -rf`` old directories to reclaim space.
|
|
96
|
+
CACHE_VERSION = "0"
|
|
97
|
+
|
|
98
|
+
# Default for the ``persist_memory`` constructor parameter. When ``True``
|
|
99
|
+
# (the default), ``DatasetCache`` instances hold computed artifacts in an
|
|
100
|
+
# in-memory dict so that repeated requests for the same artifact within a
|
|
101
|
+
# process are served instantly.
|
|
102
|
+
DEFAULT_PERSIST_MEMORY: bool = True
|
|
103
|
+
|
|
104
|
+
# Active cache context — set via :func:`active_cache` so that downstream
|
|
105
|
+
# convenience functions can discover the cache without explicit parameters.
|
|
106
|
+
_active_cache: contextvars.ContextVar[tuple["DatasetCache", str] | None] = contextvars.ContextVar(
|
|
107
|
+
"_active_cache", default=None
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def active_cache(cache: "DatasetCache", selection_key: str) -> Generator[None]:
|
|
113
|
+
"""Set the active cache context for the duration of the block.
|
|
114
|
+
|
|
115
|
+
Convenience functions (:func:`get_or_compute_stats`, etc.) will
|
|
116
|
+
automatically use this cache and selection key when called inside
|
|
117
|
+
the ``with`` block. Outside an ``active_cache`` block (or when
|
|
118
|
+
``cache`` is ``None``), they compute directly without caching.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
cache : DatasetCache
|
|
123
|
+
The cache instance to activate.
|
|
124
|
+
selection_key : str
|
|
125
|
+
Selection key (from :func:`selection_repr`).
|
|
126
|
+
"""
|
|
127
|
+
token = _active_cache.set((cache, selection_key))
|
|
128
|
+
try:
|
|
129
|
+
yield
|
|
130
|
+
finally:
|
|
131
|
+
_active_cache.reset(token)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Flag ↔ metric-name mappings
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
METRIC_TO_FLAG: dict[str, ImageStats] = {
|
|
139
|
+
# Pixel
|
|
140
|
+
"mean": ImageStats.PIXEL_MEAN,
|
|
141
|
+
"std": ImageStats.PIXEL_STD,
|
|
142
|
+
"var": ImageStats.PIXEL_VAR,
|
|
143
|
+
"skew": ImageStats.PIXEL_SKEW,
|
|
144
|
+
"kurtosis": ImageStats.PIXEL_KURTOSIS,
|
|
145
|
+
"entropy": ImageStats.PIXEL_ENTROPY,
|
|
146
|
+
"missing": ImageStats.PIXEL_MISSING,
|
|
147
|
+
"zeros": ImageStats.PIXEL_ZEROS,
|
|
148
|
+
"histogram": ImageStats.PIXEL_HISTOGRAM,
|
|
149
|
+
# Visual
|
|
150
|
+
"brightness": ImageStats.VISUAL_BRIGHTNESS,
|
|
151
|
+
"contrast": ImageStats.VISUAL_CONTRAST,
|
|
152
|
+
"darkness": ImageStats.VISUAL_DARKNESS,
|
|
153
|
+
"sharpness": ImageStats.VISUAL_SHARPNESS,
|
|
154
|
+
"percentiles": ImageStats.VISUAL_PERCENTILES,
|
|
155
|
+
# Dimension
|
|
156
|
+
"offset_x": ImageStats.DIMENSION_OFFSET_X,
|
|
157
|
+
"offset_y": ImageStats.DIMENSION_OFFSET_Y,
|
|
158
|
+
"width": ImageStats.DIMENSION_WIDTH,
|
|
159
|
+
"height": ImageStats.DIMENSION_HEIGHT,
|
|
160
|
+
"channels": ImageStats.DIMENSION_CHANNELS,
|
|
161
|
+
"size": ImageStats.DIMENSION_SIZE,
|
|
162
|
+
"aspect_ratio": ImageStats.DIMENSION_ASPECT_RATIO,
|
|
163
|
+
"depth": ImageStats.DIMENSION_DEPTH,
|
|
164
|
+
"center": ImageStats.DIMENSION_CENTER,
|
|
165
|
+
"distance_center": ImageStats.DIMENSION_DISTANCE_CENTER,
|
|
166
|
+
"distance_edge": ImageStats.DIMENSION_DISTANCE_EDGE,
|
|
167
|
+
"invalid_box": ImageStats.DIMENSION_INVALID_BOX,
|
|
168
|
+
# Hash
|
|
169
|
+
"xxhash": ImageStats.HASH_XXHASH,
|
|
170
|
+
"phash": ImageStats.HASH_PHASH,
|
|
171
|
+
"dhash": ImageStats.HASH_DHASH,
|
|
172
|
+
"phash_d4": ImageStats.HASH_PHASH_D4,
|
|
173
|
+
"dhash_d4": ImageStats.HASH_DHASH_D4,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
FLAG_TO_METRIC: dict[ImageStats, str] = {v: k for k, v in METRIC_TO_FLAG.items()}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Helpers
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _config_hash(config_data: str) -> str:
|
|
185
|
+
"""Generate an 8-char hex hash from a config string."""
|
|
186
|
+
return hashlib.sha256(config_data.encode()).hexdigest()[:8]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _file_content_hash(path: str | Path) -> str:
|
|
190
|
+
"""Return an 8-char hex hash of a file's contents, or 'missing' if unreadable."""
|
|
191
|
+
try:
|
|
192
|
+
h = hashlib.sha256()
|
|
193
|
+
with open(path, "rb") as f:
|
|
194
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
195
|
+
h.update(chunk)
|
|
196
|
+
return h.hexdigest()[:8]
|
|
197
|
+
except OSError:
|
|
198
|
+
return "missing"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extractor_config_key(extractor_config: Any) -> str:
|
|
202
|
+
"""Build a cache key from an extractor config, hashing model file contents.
|
|
203
|
+
|
|
204
|
+
The Pydantic ``model_dump_json`` only serializes config fields (path,
|
|
205
|
+
layer name, etc.) — not the model weights. If the user retrains and
|
|
206
|
+
overwrites the file at the same path, the JSON is identical but the
|
|
207
|
+
embeddings should differ. We append a content hash of the model file
|
|
208
|
+
so the cache key changes when the weights change.
|
|
209
|
+
"""
|
|
210
|
+
config_json = extractor_config.model_dump_json(exclude_defaults=False)
|
|
211
|
+
model_path = getattr(extractor_config, "model_path", None)
|
|
212
|
+
if model_path is not None:
|
|
213
|
+
config_json += f"|file_hash={_file_content_hash(model_path)}"
|
|
214
|
+
return config_json
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _atomic_write(target: Path, data_fn: Callable[[Path], Any], *, suffix: str = ".tmp") -> None:
|
|
218
|
+
"""Write to a temp file in the same directory, then atomically rename."""
|
|
219
|
+
fd, tmp = tempfile.mkstemp(dir=target.parent, suffix=suffix)
|
|
220
|
+
tmp_path = Path(tmp)
|
|
221
|
+
try:
|
|
222
|
+
os.close(fd)
|
|
223
|
+
data_fn(tmp_path)
|
|
224
|
+
tmp_path.rename(target) # atomic on POSIX same-filesystem
|
|
225
|
+
except BaseException:
|
|
226
|
+
tmp_path.unlink(missing_ok=True)
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _atomic_write_pair(
|
|
231
|
+
target_a: Path,
|
|
232
|
+
data_fn_a: Callable[[Path], Any],
|
|
233
|
+
target_b: Path,
|
|
234
|
+
data_fn_b: Callable[[Path], Any],
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Write two files atomically as a pair.
|
|
237
|
+
|
|
238
|
+
Both files are written to temp locations first. Only after both
|
|
239
|
+
writes succeed are they renamed into place. If the process crashes
|
|
240
|
+
between the two renames, the next load will see a missing partner
|
|
241
|
+
file and treat it as a cache miss (both files must exist for a hit).
|
|
242
|
+
"""
|
|
243
|
+
fd_a, tmp_a = tempfile.mkstemp(dir=target_a.parent, suffix=".tmp")
|
|
244
|
+
tmp_path_a = Path(tmp_a)
|
|
245
|
+
fd_b, tmp_b = tempfile.mkstemp(dir=target_b.parent, suffix=".tmp")
|
|
246
|
+
tmp_path_b = Path(tmp_b)
|
|
247
|
+
try:
|
|
248
|
+
os.close(fd_a)
|
|
249
|
+
os.close(fd_b)
|
|
250
|
+
data_fn_a(tmp_path_a)
|
|
251
|
+
data_fn_b(tmp_path_b)
|
|
252
|
+
# Both writes succeeded — rename into place.
|
|
253
|
+
tmp_path_a.rename(target_a)
|
|
254
|
+
tmp_path_b.rename(target_b)
|
|
255
|
+
except BaseException:
|
|
256
|
+
tmp_path_a.unlink(missing_ok=True)
|
|
257
|
+
tmp_path_b.unlink(missing_ok=True)
|
|
258
|
+
# Clean up any already-renamed target if the second rename failed
|
|
259
|
+
# but the first succeeded — removes the orphaned file so the next
|
|
260
|
+
# load sees a clean miss rather than a mismatched pair.
|
|
261
|
+
if not tmp_path_a.exists() and target_a.exists():
|
|
262
|
+
target_a.unlink(missing_ok=True)
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
_MAX_DS_ID_BYTES = 100 # Conservative limit (ext4 NAME_MAX = 255 bytes)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _make_dataset_id(name: str, cache_key: str) -> str:
|
|
270
|
+
"""Build a cache-safe dataset identifier.
|
|
271
|
+
|
|
272
|
+
Hashes *cache_key* so that changes to any config field produce a
|
|
273
|
+
distinct cache directory. The result is ``{name_prefix}_{hash}``
|
|
274
|
+
where the prefix keeps it human-readable and the hash guarantees
|
|
275
|
+
uniqueness.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
name : str
|
|
280
|
+
Human-readable dataset name (used as prefix).
|
|
281
|
+
cache_key : str
|
|
282
|
+
Opaque string whose content uniquely identifies the dataset
|
|
283
|
+
configuration. Provided by :func:`resolve_dataset`.
|
|
284
|
+
"""
|
|
285
|
+
config_hash = hashlib.sha256(cache_key.encode("utf-8")).hexdigest()[:16]
|
|
286
|
+
|
|
287
|
+
max_prefix = _MAX_DS_ID_BYTES - 17 # 17 = 1 ("_") + 16 (hash)
|
|
288
|
+
prefix = name.encode("utf-8")[:max_prefix].decode("utf-8", errors="ignore")
|
|
289
|
+
return f"{prefix}_{config_hash}"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def selection_repr(dataset: Any) -> str:
|
|
293
|
+
"""Build a deterministic cache key from the (possibly selected) dataset.
|
|
294
|
+
|
|
295
|
+
For datasets wrapped with ``Select``, hashes the *resolved* indices so
|
|
296
|
+
that non-deterministic selections (e.g. Shuffle) produce distinct cache
|
|
297
|
+
keys. For unwrapped datasets, returns ``"sel:all"``.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
dataset : MaiteDataset | Select
|
|
302
|
+
The dataset *after* selection has been applied.
|
|
303
|
+
|
|
304
|
+
Returns
|
|
305
|
+
-------
|
|
306
|
+
str
|
|
307
|
+
Deterministic string suitable for cache key hashing.
|
|
308
|
+
"""
|
|
309
|
+
resolve = getattr(dataset, "resolve_indices", None)
|
|
310
|
+
if resolve is not None:
|
|
311
|
+
indices = resolve()
|
|
312
|
+
idx_str = ",".join(str(i) for i in indices)
|
|
313
|
+
idx_hash = hashlib.sha256(idx_str.encode()).hexdigest()[:16]
|
|
314
|
+
return f"sel:n={len(indices)}:{idx_hash}"
|
|
315
|
+
return "sel:all"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def dataset_fingerprint(dataset: Any) -> str:
|
|
319
|
+
"""Build a content-based fingerprint by hashing a sample of datum tuples.
|
|
320
|
+
|
|
321
|
+
Hashes the first 5, middle 5, and last 5 datum tuples (or all data
|
|
322
|
+
if the dataset has 15 or fewer items) plus the dataset length using
|
|
323
|
+
xxHash. Each element of the tuple (image, target, metadata) is
|
|
324
|
+
hashed so that label or metadata changes also invalidate the cache.
|
|
325
|
+
|
|
326
|
+
Parameters
|
|
327
|
+
----------
|
|
328
|
+
dataset : Dataset
|
|
329
|
+
Any object supporting ``__len__`` and ``__getitem__`` that
|
|
330
|
+
returns ``(image, target, ...)`` tuples where elements are
|
|
331
|
+
array-like or have a bytes-serialisable ``repr``.
|
|
332
|
+
|
|
333
|
+
Returns
|
|
334
|
+
-------
|
|
335
|
+
str
|
|
336
|
+
Hex digest fingerprint of the sampled data.
|
|
337
|
+
"""
|
|
338
|
+
import xxhash as xxh
|
|
339
|
+
from dataeval.utils._internal import as_numpy
|
|
340
|
+
|
|
341
|
+
n = len(dataset)
|
|
342
|
+
hasher = xxh.xxh3_64()
|
|
343
|
+
|
|
344
|
+
# Include dataset length so additions/removals are detected even if
|
|
345
|
+
# the sampled items happen to remain unchanged.
|
|
346
|
+
hasher.update(n.to_bytes(8, "little"))
|
|
347
|
+
|
|
348
|
+
# Sample indices: first 5 + middle 5 + last 5, or all if <= 15.
|
|
349
|
+
if n <= 15:
|
|
350
|
+
indices = list(range(n))
|
|
351
|
+
else:
|
|
352
|
+
mid = n // 2
|
|
353
|
+
indices = list(range(5)) + list(range(mid - 2, mid + 3)) + list(range(n - 5, n))
|
|
354
|
+
|
|
355
|
+
for idx in indices:
|
|
356
|
+
datum = dataset[idx]
|
|
357
|
+
datum = datum if isinstance(datum, tuple) else (datum,)
|
|
358
|
+
for element in datum:
|
|
359
|
+
if isinstance(element, Array):
|
|
360
|
+
hasher.update(as_numpy(element).ravel().tobytes())
|
|
361
|
+
else:
|
|
362
|
+
hasher.update(repr(element).encode("utf-8"))
|
|
363
|
+
|
|
364
|
+
return hasher.hexdigest()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def scope_key(
|
|
368
|
+
per_image: bool = True,
|
|
369
|
+
per_target: bool = True,
|
|
370
|
+
per_channel: bool = False,
|
|
371
|
+
) -> str:
|
|
372
|
+
"""Build a deterministic scope key from per_image/per_target/per_channel settings.
|
|
373
|
+
|
|
374
|
+
Stats computed with different scope settings have incompatible
|
|
375
|
+
``source_index`` arrays and cannot be merged. The scope key ensures
|
|
376
|
+
they are cached separately.
|
|
377
|
+
"""
|
|
378
|
+
parts: list[str] = []
|
|
379
|
+
if per_image:
|
|
380
|
+
parts.append("img")
|
|
381
|
+
if per_target:
|
|
382
|
+
parts.append("tgt")
|
|
383
|
+
if per_channel:
|
|
384
|
+
parts.append("ch")
|
|
385
|
+
return "+".join(parts) or "none"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def missing_flags(cached_metrics: set[str], desired_flags: ImageStats) -> ImageStats:
|
|
389
|
+
"""Compute the ``ImageStats`` flags not yet covered by cached metrics.
|
|
390
|
+
|
|
391
|
+
Parameters
|
|
392
|
+
----------
|
|
393
|
+
cached_metrics : set[str]
|
|
394
|
+
Metric names already present in the cache.
|
|
395
|
+
desired_flags : ImageStats
|
|
396
|
+
The flags the workflow wants computed.
|
|
397
|
+
|
|
398
|
+
Returns
|
|
399
|
+
-------
|
|
400
|
+
ImageStats
|
|
401
|
+
Flags that still need to be computed. ``ImageStats.NONE`` if all
|
|
402
|
+
desired metrics are already cached.
|
|
403
|
+
"""
|
|
404
|
+
uncovered = ImageStats.NONE
|
|
405
|
+
for flag in ImageStats:
|
|
406
|
+
# Only consider individual (atomic, single-bit) flags
|
|
407
|
+
if flag.value and (flag.value & (flag.value - 1)) == 0 and flag in desired_flags:
|
|
408
|
+
metric_name = FLAG_TO_METRIC.get(flag)
|
|
409
|
+
if metric_name and metric_name not in cached_metrics:
|
|
410
|
+
uncovered |= flag
|
|
411
|
+
# Re-resolve so that dependencies of missing flags are included
|
|
412
|
+
return uncovered if uncovered else ImageStats.NONE
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# Shared compute helpers (used by both convenience functions and DatasetCache)
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _do_compute_stats(
|
|
421
|
+
dataset: AnnotatedDataset[Any],
|
|
422
|
+
desired_flags: ImageStats,
|
|
423
|
+
per_image: bool = True,
|
|
424
|
+
per_target: bool = True,
|
|
425
|
+
per_channel: bool = False,
|
|
426
|
+
) -> dict[str, Any]:
|
|
427
|
+
"""Compute stats and return as a plain dict."""
|
|
428
|
+
from dataeval.core._compute_stats import compute_stats
|
|
429
|
+
|
|
430
|
+
return dict(
|
|
431
|
+
compute_stats(
|
|
432
|
+
dataset,
|
|
433
|
+
stats=desired_flags,
|
|
434
|
+
per_image=per_image,
|
|
435
|
+
per_target=per_target,
|
|
436
|
+
per_channel=per_channel,
|
|
437
|
+
normalize_pixel_values=True,
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _do_compute_metadata(
|
|
443
|
+
dataset: AnnotatedDataset[Any],
|
|
444
|
+
auto_bin_method: Any = None,
|
|
445
|
+
exclude: Sequence[str] | None = None,
|
|
446
|
+
continuous_factor_bins: Mapping[str, int | Sequence[float]] | None = None,
|
|
447
|
+
) -> "Metadata":
|
|
448
|
+
"""Build metadata from a dataset."""
|
|
449
|
+
from dataeval_flow.metadata import build_metadata
|
|
450
|
+
|
|
451
|
+
return build_metadata(
|
|
452
|
+
dataset, auto_bin_method=auto_bin_method, exclude=exclude, continuous_factor_bins=continuous_factor_bins
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _do_compute_embeddings(
|
|
457
|
+
dataset: AnnotatedDataset[Any],
|
|
458
|
+
extractor_config: Any,
|
|
459
|
+
transforms: Any = None,
|
|
460
|
+
batch_size: int | None = None,
|
|
461
|
+
) -> NDArray[Any]:
|
|
462
|
+
"""Extract embeddings and flatten to 2-D."""
|
|
463
|
+
from dataeval_flow.embeddings import build_embeddings
|
|
464
|
+
|
|
465
|
+
embeddings = build_embeddings(dataset, extractor_config, transforms, batch_size)
|
|
466
|
+
array: NDArray[Any] = np.asarray(embeddings)
|
|
467
|
+
if array.ndim > 2:
|
|
468
|
+
array = array.reshape(array.shape[0], -1)
|
|
469
|
+
return array
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _do_compute_clusters(
|
|
473
|
+
embeddings: NDArray[Any],
|
|
474
|
+
algorithm: Literal["kmeans", "hdbscan"],
|
|
475
|
+
n_clusters: int | None,
|
|
476
|
+
) -> ClusterResult:
|
|
477
|
+
"""Run clustering on embeddings."""
|
|
478
|
+
from dataeval.core._clusterer import cluster
|
|
479
|
+
|
|
480
|
+
return cluster(embeddings, algorithm=algorithm, n_clusters=n_clusters)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
# Convenience functions (delegate to DatasetCache via active_cache context)
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def get_or_compute_stats(
|
|
489
|
+
desired_flags: ImageStats,
|
|
490
|
+
dataset: AnnotatedDataset[Any],
|
|
491
|
+
per_image: bool = True,
|
|
492
|
+
per_target: bool = True,
|
|
493
|
+
per_channel: bool = False,
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
"""Centralized stats computation with context-aware caching.
|
|
496
|
+
|
|
497
|
+
Uses the :func:`active_cache` context when set, otherwise computes
|
|
498
|
+
directly without any caching.
|
|
499
|
+
"""
|
|
500
|
+
ctx = _active_cache.get()
|
|
501
|
+
if ctx is not None:
|
|
502
|
+
cache, sel_key = ctx
|
|
503
|
+
return cache.load_or_compute_stats(
|
|
504
|
+
sel_key,
|
|
505
|
+
scope_key(per_image, per_target, per_channel),
|
|
506
|
+
desired_flags,
|
|
507
|
+
dataset,
|
|
508
|
+
per_image=per_image,
|
|
509
|
+
per_target=per_target,
|
|
510
|
+
per_channel=per_channel,
|
|
511
|
+
)
|
|
512
|
+
_logger.info("Computing stats (no cache)")
|
|
513
|
+
return _do_compute_stats(dataset, desired_flags, per_image, per_target, per_channel)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def get_or_compute_metadata(
|
|
517
|
+
dataset: AnnotatedDataset[Any],
|
|
518
|
+
auto_bin_method: Any = None,
|
|
519
|
+
exclude: Sequence[str] | None = None,
|
|
520
|
+
continuous_factor_bins: Mapping[str, int | Sequence[float]] | None = None,
|
|
521
|
+
) -> "Metadata":
|
|
522
|
+
"""Build metadata with context-aware caching.
|
|
523
|
+
|
|
524
|
+
Uses the :func:`active_cache` context when set, otherwise computes
|
|
525
|
+
directly without any caching.
|
|
526
|
+
"""
|
|
527
|
+
ctx = _active_cache.get()
|
|
528
|
+
if ctx is not None:
|
|
529
|
+
cache, sel_key = ctx
|
|
530
|
+
return cache.load_or_compute_metadata(
|
|
531
|
+
sel_key,
|
|
532
|
+
dataset,
|
|
533
|
+
auto_bin_method=auto_bin_method,
|
|
534
|
+
exclude=exclude,
|
|
535
|
+
continuous_factor_bins=continuous_factor_bins,
|
|
536
|
+
)
|
|
537
|
+
_logger.info("Building metadata (no cache)")
|
|
538
|
+
return _do_compute_metadata(dataset, auto_bin_method, exclude, continuous_factor_bins)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def get_or_compute_embeddings(
|
|
542
|
+
dataset: AnnotatedDataset[Any],
|
|
543
|
+
extractor_config: Any,
|
|
544
|
+
transforms: Any = None,
|
|
545
|
+
batch_size: int | None = None,
|
|
546
|
+
) -> NDArray[Any]:
|
|
547
|
+
"""Extract embeddings with context-aware caching.
|
|
548
|
+
|
|
549
|
+
Uses the :func:`active_cache` context when set, otherwise computes
|
|
550
|
+
directly without any caching.
|
|
551
|
+
"""
|
|
552
|
+
ctx = _active_cache.get()
|
|
553
|
+
if ctx is not None:
|
|
554
|
+
cache, sel_key = ctx
|
|
555
|
+
config_json = _extractor_config_key(extractor_config)
|
|
556
|
+
transforms_key = repr(transforms) if transforms is not None else "none"
|
|
557
|
+
return cache.load_or_compute_embeddings(
|
|
558
|
+
sel_key,
|
|
559
|
+
config_json,
|
|
560
|
+
transforms_key,
|
|
561
|
+
dataset,
|
|
562
|
+
extractor_config,
|
|
563
|
+
transforms,
|
|
564
|
+
batch_size,
|
|
565
|
+
)
|
|
566
|
+
_logger.info("Computing embeddings (no cache)")
|
|
567
|
+
return _do_compute_embeddings(dataset, extractor_config, transforms, batch_size)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def get_or_compute_cluster_result(
|
|
571
|
+
embeddings: NDArray[Any],
|
|
572
|
+
algorithm: Literal["kmeans", "hdbscan"],
|
|
573
|
+
n_clusters: int | None,
|
|
574
|
+
extractor_config: Any = None,
|
|
575
|
+
transforms: Any = None,
|
|
576
|
+
) -> ClusterResult:
|
|
577
|
+
"""Compute cluster result with context-aware caching.
|
|
578
|
+
|
|
579
|
+
Uses the :func:`active_cache` context when set, otherwise computes
|
|
580
|
+
directly without any caching.
|
|
581
|
+
"""
|
|
582
|
+
ctx = _active_cache.get()
|
|
583
|
+
if ctx is not None:
|
|
584
|
+
cache, sel_key = ctx
|
|
585
|
+
config_json = _extractor_config_key(extractor_config) if extractor_config is not None else "none"
|
|
586
|
+
transforms_key = repr(transforms) if transforms is not None else "none"
|
|
587
|
+
return cache.load_or_compute_cluster_result(
|
|
588
|
+
sel_key,
|
|
589
|
+
config_json,
|
|
590
|
+
transforms_key,
|
|
591
|
+
embeddings,
|
|
592
|
+
algorithm,
|
|
593
|
+
n_clusters,
|
|
594
|
+
)
|
|
595
|
+
_logger.info("Computing clusters (no cache)")
|
|
596
|
+
return _do_compute_clusters(embeddings, algorithm, n_clusters)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# DatasetCache
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class DatasetCache:
|
|
605
|
+
"""Cache for dataset computations — optionally disk-backed.
|
|
606
|
+
|
|
607
|
+
When *cache_dir* is a :class:`~pathlib.Path`, artifacts are persisted
|
|
608
|
+
under::
|
|
609
|
+
|
|
610
|
+
cache_dir / v{CACHE_VERSION} / dataset_name / sel_{hash} / {component}_{cfg}.{ext}
|
|
611
|
+
|
|
612
|
+
When *cache_dir* is ``None``, the cache operates in **memory-only**
|
|
613
|
+
mode: disk loads always miss and disk saves are no-ops. The
|
|
614
|
+
``load_or_compute_*`` methods still work — they just always compute
|
|
615
|
+
on first access and then serve from the in-memory ``_memory`` dict.
|
|
616
|
+
|
|
617
|
+
Use :meth:`get_or_create` to reuse an existing instance for the same
|
|
618
|
+
``(cache_dir, dataset_name)`` pair — this preserves the in-memory
|
|
619
|
+
cache across multiple ``run_task`` calls.
|
|
620
|
+
|
|
621
|
+
Parameters
|
|
622
|
+
----------
|
|
623
|
+
cache_dir : Path | None
|
|
624
|
+
Root directory for cache storage. ``None`` disables disk persistence.
|
|
625
|
+
dataset_name : str
|
|
626
|
+
Dataset identifier (from ``TaskConfig.dataset``).
|
|
627
|
+
"""
|
|
628
|
+
|
|
629
|
+
# Global singleton registry for **non-disk-backed** instances only.
|
|
630
|
+
# Disk-backed caches already have durable storage; singletons would
|
|
631
|
+
# just hold duplicate references and complicate lifetime management.
|
|
632
|
+
_instances: dict[str, "DatasetCache"] = {}
|
|
633
|
+
_instances_lock: threading.Lock = threading.Lock()
|
|
634
|
+
|
|
635
|
+
@classmethod
|
|
636
|
+
def get_or_create(cls, cache_dir: Path | None, name: str, cache_key: str) -> "DatasetCache":
|
|
637
|
+
"""Return an existing instance for this key, or create a new one.
|
|
638
|
+
|
|
639
|
+
Derives the dataset identifier from *name* and *cache_key* via
|
|
640
|
+
:func:`_make_dataset_id`.
|
|
641
|
+
|
|
642
|
+
For **non-disk-backed** caches (``cache_dir is None``), a global
|
|
643
|
+
singleton keyed by *dataset_name* is returned so that computed
|
|
644
|
+
artifacts are reused across multiple ``run_task`` calls.
|
|
645
|
+
|
|
646
|
+
For **disk-backed** caches, a fresh instance is always created
|
|
647
|
+
(the disk itself provides persistence).
|
|
648
|
+
"""
|
|
649
|
+
dataset_name = _make_dataset_id(name, cache_key)
|
|
650
|
+
if cache_dir is not None:
|
|
651
|
+
return cls(cache_dir, dataset_name)
|
|
652
|
+
with cls._instances_lock:
|
|
653
|
+
instance = cls._instances.get(dataset_name)
|
|
654
|
+
if instance is not None:
|
|
655
|
+
return instance
|
|
656
|
+
instance = cls(None, dataset_name)
|
|
657
|
+
cls._instances[dataset_name] = instance
|
|
658
|
+
return instance
|
|
659
|
+
|
|
660
|
+
@classmethod
|
|
661
|
+
def clear_instances(cls) -> None:
|
|
662
|
+
"""Remove all cached singleton instances.
|
|
663
|
+
|
|
664
|
+
Call this to release memory held by non-disk-backed caches
|
|
665
|
+
(e.g. between test runs or when datasets are no longer needed).
|
|
666
|
+
"""
|
|
667
|
+
with cls._instances_lock:
|
|
668
|
+
cls._instances.clear()
|
|
669
|
+
|
|
670
|
+
def __init__(
|
|
671
|
+
self,
|
|
672
|
+
cache_dir: Path | None,
|
|
673
|
+
dataset_name: str,
|
|
674
|
+
*,
|
|
675
|
+
persist_memory: bool = DEFAULT_PERSIST_MEMORY,
|
|
676
|
+
) -> None:
|
|
677
|
+
"""Initialize the cache with the root directory and dataset name."""
|
|
678
|
+
if cache_dir is not None and ("/" in dataset_name or "\\" in dataset_name or dataset_name in (".", "..")):
|
|
679
|
+
raise ValueError(
|
|
680
|
+
f"Invalid dataset_name for cache (must not contain path separators or be '.'/'..'): {dataset_name!r}"
|
|
681
|
+
)
|
|
682
|
+
self._cache_dir = cache_dir
|
|
683
|
+
self._dataset_name = dataset_name
|
|
684
|
+
self._dataset_dir: Path | None = None
|
|
685
|
+
self._persist_memory = persist_memory
|
|
686
|
+
# Nested in-memory cache: {selection_id: {object_id: value}}
|
|
687
|
+
# The dataset_id level is implicit (one DatasetCache per dataset).
|
|
688
|
+
self._memory: dict[str, dict[str, Any]] = {}
|
|
689
|
+
|
|
690
|
+
# =====================================================================
|
|
691
|
+
# In-memory helpers
|
|
692
|
+
# =====================================================================
|
|
693
|
+
|
|
694
|
+
def _mem_get(self, selection_key: str, object_key: str) -> Any | None:
|
|
695
|
+
"""Return a cached value from memory, or ``None``."""
|
|
696
|
+
if not self._persist_memory:
|
|
697
|
+
return None
|
|
698
|
+
sel = self._memory.get(selection_key)
|
|
699
|
+
if sel is None:
|
|
700
|
+
return None
|
|
701
|
+
return sel.get(object_key)
|
|
702
|
+
|
|
703
|
+
def _mem_set(self, selection_key: str, object_key: str, value: Any) -> None:
|
|
704
|
+
"""Store a value in the in-memory cache."""
|
|
705
|
+
if self._persist_memory:
|
|
706
|
+
self._memory.setdefault(selection_key, {})[object_key] = value
|
|
707
|
+
|
|
708
|
+
# =====================================================================
|
|
709
|
+
# Properties
|
|
710
|
+
# =====================================================================
|
|
711
|
+
|
|
712
|
+
@property
|
|
713
|
+
def disk_backed(self) -> bool:
|
|
714
|
+
"""Whether this cache persists artifacts to disk."""
|
|
715
|
+
return self._cache_dir is not None
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def cache_dir(self) -> Path | None:
|
|
719
|
+
"""Root cache directory, or ``None`` if memory-only."""
|
|
720
|
+
return self._cache_dir
|
|
721
|
+
|
|
722
|
+
@property
|
|
723
|
+
def dataset_name(self) -> str:
|
|
724
|
+
"""Dataset identifier."""
|
|
725
|
+
return self._dataset_name
|
|
726
|
+
|
|
727
|
+
@property
|
|
728
|
+
def dataset_dir(self) -> Path | None:
|
|
729
|
+
"""Dataset-specific cache directory (version-namespaced).
|
|
730
|
+
|
|
731
|
+
Returns ``None`` when not disk-backed.
|
|
732
|
+
"""
|
|
733
|
+
if self._cache_dir is None:
|
|
734
|
+
return None
|
|
735
|
+
if self._dataset_dir is None:
|
|
736
|
+
d = self._cache_dir / f"v{CACHE_VERSION}" / self._dataset_name
|
|
737
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
738
|
+
self._dataset_dir = d
|
|
739
|
+
return self._dataset_dir
|
|
740
|
+
|
|
741
|
+
# =====================================================================
|
|
742
|
+
# Embeddings (.npy)
|
|
743
|
+
# =====================================================================
|
|
744
|
+
|
|
745
|
+
def _selection_dir(self, selection_repr: str) -> Path | None:
|
|
746
|
+
"""Return (and create) the selection-specific subdirectory."""
|
|
747
|
+
dd = self.dataset_dir
|
|
748
|
+
if dd is None:
|
|
749
|
+
return None
|
|
750
|
+
s = _config_hash(selection_repr)
|
|
751
|
+
d = dd / f"sel_{s}"
|
|
752
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
753
|
+
return d
|
|
754
|
+
|
|
755
|
+
def _embeddings_path(
|
|
756
|
+
self,
|
|
757
|
+
selection_repr: str,
|
|
758
|
+
extractor_config_json: str,
|
|
759
|
+
transforms_repr: str,
|
|
760
|
+
) -> Path | None:
|
|
761
|
+
sel_dir = self._selection_dir(selection_repr)
|
|
762
|
+
if sel_dir is None:
|
|
763
|
+
return None
|
|
764
|
+
h = _config_hash(extractor_config_json + "|" + transforms_repr)
|
|
765
|
+
return sel_dir / f"embeddings_{h}.npy"
|
|
766
|
+
|
|
767
|
+
def load_embeddings(
|
|
768
|
+
self,
|
|
769
|
+
selection_repr: str,
|
|
770
|
+
extractor_config_json: str,
|
|
771
|
+
transforms_repr: str = "none",
|
|
772
|
+
) -> NDArray[Any] | None:
|
|
773
|
+
"""Load cached embedding array, or ``None`` on miss."""
|
|
774
|
+
obj_key = f"embeddings_{_config_hash(extractor_config_json + '|' + transforms_repr)}"
|
|
775
|
+
cached = self._mem_get(selection_repr, obj_key)
|
|
776
|
+
if cached is not None:
|
|
777
|
+
_logger.debug("Memory hit: embeddings for %s/%s", self._dataset_name, selection_repr)
|
|
778
|
+
return cached
|
|
779
|
+
|
|
780
|
+
path = self._embeddings_path(selection_repr, extractor_config_json, transforms_repr)
|
|
781
|
+
if path is None or not path.exists():
|
|
782
|
+
return None
|
|
783
|
+
_logger.info("Cache hit: embeddings for %s/%s", self._dataset_name, selection_repr)
|
|
784
|
+
try:
|
|
785
|
+
arr = np.load(path, allow_pickle=False)
|
|
786
|
+
self._mem_set(selection_repr, obj_key, arr)
|
|
787
|
+
return arr
|
|
788
|
+
except Exception: # noqa: BLE001
|
|
789
|
+
_logger.warning(
|
|
790
|
+
"Failed to load embeddings from cache for %s/%s — recomputing",
|
|
791
|
+
self._dataset_name,
|
|
792
|
+
selection_repr,
|
|
793
|
+
exc_info=True,
|
|
794
|
+
)
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
def save_embeddings(
|
|
798
|
+
self,
|
|
799
|
+
selection_repr: str,
|
|
800
|
+
extractor_config_json: str,
|
|
801
|
+
transforms_repr: str,
|
|
802
|
+
array: NDArray[Any],
|
|
803
|
+
) -> None:
|
|
804
|
+
"""Persist embedding array to cache (no-op on disk when not disk-backed)."""
|
|
805
|
+
obj_key = f"embeddings_{_config_hash(extractor_config_json + '|' + transforms_repr)}"
|
|
806
|
+
self._mem_set(selection_repr, obj_key, array)
|
|
807
|
+
|
|
808
|
+
path = self._embeddings_path(selection_repr, extractor_config_json, transforms_repr)
|
|
809
|
+
if path is None:
|
|
810
|
+
return
|
|
811
|
+
_atomic_write(path, lambda p: np.save(p, array), suffix=".npy")
|
|
812
|
+
_logger.info("Cache save: embeddings for %s/%s (%s)", self._dataset_name, selection_repr, path.name)
|
|
813
|
+
|
|
814
|
+
def load_or_compute_embeddings(
|
|
815
|
+
self,
|
|
816
|
+
selection_repr: str,
|
|
817
|
+
extractor_config_json: str,
|
|
818
|
+
transforms_repr: str,
|
|
819
|
+
dataset: AnnotatedDataset[Any],
|
|
820
|
+
extractor_config: Any,
|
|
821
|
+
transforms: Any = None,
|
|
822
|
+
batch_size: int | None = None,
|
|
823
|
+
) -> NDArray[Any]:
|
|
824
|
+
"""Load cached embeddings or compute, cache, and return them.
|
|
825
|
+
|
|
826
|
+
Parameters
|
|
827
|
+
----------
|
|
828
|
+
selection_repr : str
|
|
829
|
+
Selection key (from :func:`selection_repr`).
|
|
830
|
+
extractor_config_json : str
|
|
831
|
+
JSON representation of the extractor config (for cache key).
|
|
832
|
+
transforms_repr : str
|
|
833
|
+
String representation of transforms (for cache key).
|
|
834
|
+
dataset
|
|
835
|
+
The dataset to extract embeddings from on cache miss.
|
|
836
|
+
extractor_config
|
|
837
|
+
``ExtractorConfig`` used to build the extractor.
|
|
838
|
+
transforms
|
|
839
|
+
Optional preprocessing transforms.
|
|
840
|
+
batch_size : int | None
|
|
841
|
+
Batch size for extraction.
|
|
842
|
+
|
|
843
|
+
Returns
|
|
844
|
+
-------
|
|
845
|
+
NDArray
|
|
846
|
+
2-D embedding array of shape ``(N, D)``.
|
|
847
|
+
"""
|
|
848
|
+
cached = self.load_embeddings(selection_repr, extractor_config_json, transforms_repr)
|
|
849
|
+
if cached is not None:
|
|
850
|
+
return cached
|
|
851
|
+
|
|
852
|
+
_logger.info("Computing embeddings for %s/%s", self._dataset_name, selection_repr)
|
|
853
|
+
array = _do_compute_embeddings(dataset, extractor_config, transforms, batch_size)
|
|
854
|
+
self.save_embeddings(selection_repr, extractor_config_json, transforms_repr, array)
|
|
855
|
+
return array
|
|
856
|
+
|
|
857
|
+
# =====================================================================
|
|
858
|
+
# Cluster results (.npz)
|
|
859
|
+
# =====================================================================
|
|
860
|
+
|
|
861
|
+
def _cluster_path(
|
|
862
|
+
self,
|
|
863
|
+
selection_repr: str,
|
|
864
|
+
extractor_config_json: str,
|
|
865
|
+
transforms_repr: str,
|
|
866
|
+
algorithm: Literal["kmeans", "hdbscan"],
|
|
867
|
+
n_clusters: int | None,
|
|
868
|
+
) -> Path | None:
|
|
869
|
+
sel_dir = self._selection_dir(selection_repr)
|
|
870
|
+
if sel_dir is None:
|
|
871
|
+
return None
|
|
872
|
+
h = _config_hash(f"{extractor_config_json}|{transforms_repr}|{algorithm}|{n_clusters}")
|
|
873
|
+
return sel_dir / f"clusters_{h}.npz"
|
|
874
|
+
|
|
875
|
+
def load_cluster_result(
|
|
876
|
+
self,
|
|
877
|
+
selection_repr: str,
|
|
878
|
+
extractor_config_json: str,
|
|
879
|
+
transforms_repr: str,
|
|
880
|
+
algorithm: Literal["kmeans", "hdbscan"],
|
|
881
|
+
n_clusters: int | None,
|
|
882
|
+
) -> dict[str, Any] | None:
|
|
883
|
+
"""Load cached ClusterResult, or ``None`` on miss."""
|
|
884
|
+
obj_key = f"clusters_{_config_hash(f'{extractor_config_json}|{transforms_repr}|{algorithm}|{n_clusters}')}"
|
|
885
|
+
cached = self._mem_get(selection_repr, obj_key)
|
|
886
|
+
if cached is not None:
|
|
887
|
+
_logger.debug("Memory hit: cluster result for %s/%s", self._dataset_name, selection_repr)
|
|
888
|
+
return cached
|
|
889
|
+
|
|
890
|
+
path = self._cluster_path(selection_repr, extractor_config_json, transforms_repr, algorithm, n_clusters)
|
|
891
|
+
if path is None or not path.exists():
|
|
892
|
+
return None
|
|
893
|
+
_logger.info("Cache hit: cluster result for %s/%s", self._dataset_name, selection_repr)
|
|
894
|
+
try:
|
|
895
|
+
data = np.load(path, allow_pickle=False)
|
|
896
|
+
result = {
|
|
897
|
+
"clusters": data["clusters"],
|
|
898
|
+
"mst": data["mst"],
|
|
899
|
+
"linkage_tree": data["linkage_tree"],
|
|
900
|
+
"membership_strengths": data["membership_strengths"],
|
|
901
|
+
"k_neighbors": data["k_neighbors"],
|
|
902
|
+
"k_distances": data["k_distances"],
|
|
903
|
+
}
|
|
904
|
+
self._mem_set(selection_repr, obj_key, result)
|
|
905
|
+
return result
|
|
906
|
+
except Exception: # noqa: BLE001
|
|
907
|
+
_logger.warning(
|
|
908
|
+
"Failed to load cluster result from cache for %s/%s — recomputing",
|
|
909
|
+
self._dataset_name,
|
|
910
|
+
selection_repr,
|
|
911
|
+
exc_info=True,
|
|
912
|
+
)
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
def save_cluster_result(
|
|
916
|
+
self,
|
|
917
|
+
selection_repr: str,
|
|
918
|
+
extractor_config_json: str,
|
|
919
|
+
transforms_repr: str,
|
|
920
|
+
algorithm: Literal["kmeans", "hdbscan"],
|
|
921
|
+
n_clusters: int | None,
|
|
922
|
+
result: dict[str, Any],
|
|
923
|
+
) -> None:
|
|
924
|
+
"""Persist ClusterResult to cache as .npz (no-op on disk when not disk-backed)."""
|
|
925
|
+
obj_key = f"clusters_{_config_hash(f'{extractor_config_json}|{transforms_repr}|{algorithm}|{n_clusters}')}"
|
|
926
|
+
self._mem_set(selection_repr, obj_key, result)
|
|
927
|
+
|
|
928
|
+
path = self._cluster_path(selection_repr, extractor_config_json, transforms_repr, algorithm, n_clusters)
|
|
929
|
+
if path is None:
|
|
930
|
+
return
|
|
931
|
+
_atomic_write(
|
|
932
|
+
path,
|
|
933
|
+
lambda p: np.savez(
|
|
934
|
+
p,
|
|
935
|
+
clusters=result["clusters"],
|
|
936
|
+
mst=result["mst"],
|
|
937
|
+
linkage_tree=result["linkage_tree"],
|
|
938
|
+
membership_strengths=result["membership_strengths"],
|
|
939
|
+
k_neighbors=result["k_neighbors"],
|
|
940
|
+
k_distances=result["k_distances"],
|
|
941
|
+
),
|
|
942
|
+
suffix=".npz",
|
|
943
|
+
)
|
|
944
|
+
_logger.info("Cache save: cluster result for %s/%s (%s)", self._dataset_name, selection_repr, path.name)
|
|
945
|
+
|
|
946
|
+
def load_or_compute_cluster_result(
|
|
947
|
+
self,
|
|
948
|
+
selection_repr: str,
|
|
949
|
+
extractor_config_json: str,
|
|
950
|
+
transforms_repr: str,
|
|
951
|
+
embeddings: NDArray[Any],
|
|
952
|
+
algorithm: Literal["kmeans", "hdbscan"],
|
|
953
|
+
n_clusters: int | None,
|
|
954
|
+
) -> ClusterResult:
|
|
955
|
+
"""Load cached cluster result or compute, cache, and return it."""
|
|
956
|
+
cached = self.load_cluster_result(
|
|
957
|
+
selection_repr,
|
|
958
|
+
extractor_config_json,
|
|
959
|
+
transforms_repr,
|
|
960
|
+
algorithm,
|
|
961
|
+
n_clusters,
|
|
962
|
+
)
|
|
963
|
+
if cached is not None:
|
|
964
|
+
return ClusterResult(**cached)
|
|
965
|
+
|
|
966
|
+
_logger.info("Computing clusters for %s/%s (algorithm=%s)", self._dataset_name, selection_repr, algorithm)
|
|
967
|
+
cluster_result = _do_compute_clusters(embeddings, algorithm, n_clusters)
|
|
968
|
+
|
|
969
|
+
self.save_cluster_result(
|
|
970
|
+
selection_repr,
|
|
971
|
+
extractor_config_json,
|
|
972
|
+
transforms_repr,
|
|
973
|
+
algorithm,
|
|
974
|
+
n_clusters,
|
|
975
|
+
dict(cluster_result),
|
|
976
|
+
)
|
|
977
|
+
return cluster_result
|
|
978
|
+
|
|
979
|
+
# =====================================================================
|
|
980
|
+
# Metadata (.parquet + .json sidecar)
|
|
981
|
+
# =====================================================================
|
|
982
|
+
|
|
983
|
+
def _metadata_paths(self, selection_repr: str) -> tuple[Path, Path] | tuple[None, None]:
|
|
984
|
+
sel_dir = self._selection_dir(selection_repr)
|
|
985
|
+
if sel_dir is None:
|
|
986
|
+
return None, None
|
|
987
|
+
return sel_dir / "metadata.parquet", sel_dir / "metadata.json"
|
|
988
|
+
|
|
989
|
+
def load_metadata(
|
|
990
|
+
self,
|
|
991
|
+
selection_repr: str,
|
|
992
|
+
dataset: AnnotatedDataset[Any],
|
|
993
|
+
auto_bin_method: Any = None,
|
|
994
|
+
exclude: Sequence[str] | None = None,
|
|
995
|
+
continuous_factor_bins: Mapping[str, int | Sequence[float]] | None = None,
|
|
996
|
+
) -> "Metadata | None":
|
|
997
|
+
"""Load cached raw Metadata, or ``None`` on miss.
|
|
998
|
+
|
|
999
|
+
Reconstructs a ``dataeval.Metadata`` instance from the cached
|
|
1000
|
+
parquet + JSON files with ``_is_binned = False``. The caller's
|
|
1001
|
+
binning configuration is applied so that ``_bin()`` runs lazily
|
|
1002
|
+
with the current settings when factor data is first accessed.
|
|
1003
|
+
"""
|
|
1004
|
+
obj_key = "metadata"
|
|
1005
|
+
cached = self._mem_get(selection_repr, obj_key)
|
|
1006
|
+
if cached is not None:
|
|
1007
|
+
_logger.debug("Memory hit: metadata for %s/%s", self._dataset_name, selection_repr)
|
|
1008
|
+
return cached
|
|
1009
|
+
|
|
1010
|
+
from dataeval import Metadata as MetadataClass
|
|
1011
|
+
|
|
1012
|
+
pq_path, json_path = self._metadata_paths(selection_repr)
|
|
1013
|
+
if pq_path is None or json_path is None or not pq_path.exists() or not json_path.exists():
|
|
1014
|
+
return None
|
|
1015
|
+
|
|
1016
|
+
_logger.info("Cache hit: metadata for %s/%s", self._dataset_name, selection_repr)
|
|
1017
|
+
|
|
1018
|
+
try:
|
|
1019
|
+
df = pl.read_parquet(pq_path)
|
|
1020
|
+
with open(json_path, encoding="utf-8") as f:
|
|
1021
|
+
aux = json.load(f)
|
|
1022
|
+
|
|
1023
|
+
# Reconstruct Metadata without calling __init__ (which requires a dataset).
|
|
1024
|
+
# We bypass __init__ and set internal attributes directly so that
|
|
1025
|
+
# _structure() is never invoked (it requires a live dataset).
|
|
1026
|
+
# _is_binned is False so _bin() will run lazily with the caller's config.
|
|
1027
|
+
meta = object.__new__(MetadataClass)
|
|
1028
|
+
meta._dataframe = df # noqa: SLF001
|
|
1029
|
+
meta._is_structured = True # noqa: SLF001 # skip _structure()
|
|
1030
|
+
meta._is_binned = False # noqa: SLF001 # _bin() will run lazily
|
|
1031
|
+
meta._dataset = dataset # noqa: SLF001
|
|
1032
|
+
meta._has_targets = aux.get("has_targets") # noqa: SLF001
|
|
1033
|
+
meta._count = aux["item_count"] # noqa: SLF001
|
|
1034
|
+
meta._class_labels = np.asarray(aux["class_labels"], dtype=np.intp) # noqa: SLF001
|
|
1035
|
+
meta._index2label = {int(k): v for k, v in aux["index2label"].items()} # noqa: SLF001
|
|
1036
|
+
meta._item_indices = np.asarray(aux["item_indices"], dtype=np.intp) # noqa: SLF001
|
|
1037
|
+
meta._dropped_factors = {} # noqa: SLF001
|
|
1038
|
+
meta._image_factors = set(aux.get("image_factors", [])) # noqa: SLF001
|
|
1039
|
+
meta._target_factors = set(aux.get("target_factors", [])) # noqa: SLF001
|
|
1040
|
+
meta._raw = [] # noqa: SLF001
|
|
1041
|
+
meta._exclude = set(exclude or ()) # noqa: SLF001
|
|
1042
|
+
meta._include = set() # noqa: SLF001
|
|
1043
|
+
meta._continuous_factor_bins = dict(continuous_factor_bins) if continuous_factor_bins else {} # noqa: SLF001
|
|
1044
|
+
meta._auto_bin_method = auto_bin_method or "uniform_width" # noqa: SLF001
|
|
1045
|
+
meta._target_factors_only = False # noqa: SLF001
|
|
1046
|
+
# Build _factors dict from image/target factor sets
|
|
1047
|
+
meta._build_factors() # noqa: SLF001
|
|
1048
|
+
|
|
1049
|
+
# Smoke-test the reconstructed object: access commonly-used
|
|
1050
|
+
# public properties so that missing attributes surface here
|
|
1051
|
+
# (at cache-load time) rather than causing a late AttributeError
|
|
1052
|
+
# if upstream Metadata adds new internal state.
|
|
1053
|
+
meta.class_labels # noqa: B018
|
|
1054
|
+
meta.index2label # noqa: B018
|
|
1055
|
+
meta.item_count # noqa: B018
|
|
1056
|
+
|
|
1057
|
+
self._mem_set(selection_repr, obj_key, meta)
|
|
1058
|
+
return meta
|
|
1059
|
+
except Exception: # noqa: BLE001
|
|
1060
|
+
_logger.warning(
|
|
1061
|
+
"Failed to load Metadata from cache for %s/%s — recomputing",
|
|
1062
|
+
self._dataset_name,
|
|
1063
|
+
selection_repr,
|
|
1064
|
+
exc_info=True,
|
|
1065
|
+
)
|
|
1066
|
+
return None
|
|
1067
|
+
|
|
1068
|
+
def save_metadata(
|
|
1069
|
+
self,
|
|
1070
|
+
selection_repr: str,
|
|
1071
|
+
metadata: "Metadata",
|
|
1072
|
+
) -> None:
|
|
1073
|
+
"""Persist raw (pre-binned) Metadata to cache (no-op on disk when not disk-backed).
|
|
1074
|
+
|
|
1075
|
+
Only the structured DataFrame is saved — binned/digitized columns
|
|
1076
|
+
(``↕`` / ``#`` suffixes) are stripped so that a single cache entry
|
|
1077
|
+
can be reused across different binning configurations.
|
|
1078
|
+
"""
|
|
1079
|
+
self._mem_set(selection_repr, "metadata", metadata)
|
|
1080
|
+
|
|
1081
|
+
pq_path, json_path = self._metadata_paths(selection_repr)
|
|
1082
|
+
if pq_path is None or json_path is None:
|
|
1083
|
+
return
|
|
1084
|
+
|
|
1085
|
+
# .dataframe triggers _structure() but NOT _bin(), giving us
|
|
1086
|
+
# the raw structured data. Drop any binned/digitized columns
|
|
1087
|
+
# that may exist if _bin() was already called on this object.
|
|
1088
|
+
df = metadata.dataframe
|
|
1089
|
+
drop_cols = [c for c in df.columns if c.endswith("↕") or c.endswith("#")]
|
|
1090
|
+
if drop_cols:
|
|
1091
|
+
df = df.drop(drop_cols)
|
|
1092
|
+
|
|
1093
|
+
# Auxiliary attributes — factor sets instead of factor_info
|
|
1094
|
+
aux = {
|
|
1095
|
+
"class_labels": metadata.class_labels.tolist(),
|
|
1096
|
+
"index2label": {str(k): v for k, v in metadata.index2label.items()},
|
|
1097
|
+
"item_indices": metadata.item_indices.tolist(),
|
|
1098
|
+
"item_count": metadata.item_count,
|
|
1099
|
+
"image_factors": sorted(metadata._image_factors), # noqa: SLF001
|
|
1100
|
+
"target_factors": sorted(metadata._target_factors), # noqa: SLF001
|
|
1101
|
+
"has_targets": metadata._has_targets, # noqa: SLF001
|
|
1102
|
+
}
|
|
1103
|
+
_atomic_write_pair(
|
|
1104
|
+
pq_path,
|
|
1105
|
+
lambda p: df.write_parquet(p),
|
|
1106
|
+
json_path,
|
|
1107
|
+
lambda p: p.write_text(json.dumps(aux, sort_keys=True), encoding="utf-8"),
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
_logger.info("Cache save: metadata for %s/%s (%s)", self._dataset_name, selection_repr, pq_path.name)
|
|
1111
|
+
|
|
1112
|
+
def load_or_compute_metadata(
|
|
1113
|
+
self,
|
|
1114
|
+
selection_repr: str,
|
|
1115
|
+
dataset: AnnotatedDataset[Any],
|
|
1116
|
+
auto_bin_method: Any = None,
|
|
1117
|
+
exclude: Sequence[str] | None = None,
|
|
1118
|
+
continuous_factor_bins: Mapping[str, int | Sequence[float]] | None = None,
|
|
1119
|
+
) -> "Metadata":
|
|
1120
|
+
"""Load cached metadata or build, cache, and return it.
|
|
1121
|
+
|
|
1122
|
+
The cache stores only raw (pre-binned) metadata keyed by dataset
|
|
1123
|
+
selection. On hit, the caller's binning configuration is applied
|
|
1124
|
+
so that ``_bin()`` runs lazily. On miss, the metadata is built,
|
|
1125
|
+
raw data is saved, and the full object is returned.
|
|
1126
|
+
|
|
1127
|
+
Parameters
|
|
1128
|
+
----------
|
|
1129
|
+
selection_repr : str
|
|
1130
|
+
Selection key (from :func:`selection_repr`).
|
|
1131
|
+
dataset
|
|
1132
|
+
The dataset to build metadata from on cache miss.
|
|
1133
|
+
auto_bin_method
|
|
1134
|
+
Method for automatic binning of continuous values.
|
|
1135
|
+
exclude : list[str] | None
|
|
1136
|
+
Metadata columns to exclude.
|
|
1137
|
+
continuous_factor_bins : dict[str, int | list[float]] | None
|
|
1138
|
+
Number of uniform bins (int) or explicit bin edges (list[float])
|
|
1139
|
+
for specific continuous factors.
|
|
1140
|
+
|
|
1141
|
+
Returns
|
|
1142
|
+
-------
|
|
1143
|
+
Metadata
|
|
1144
|
+
DataEval Metadata instance.
|
|
1145
|
+
"""
|
|
1146
|
+
cached = self.load_metadata(
|
|
1147
|
+
selection_repr,
|
|
1148
|
+
dataset,
|
|
1149
|
+
auto_bin_method=auto_bin_method,
|
|
1150
|
+
exclude=exclude,
|
|
1151
|
+
continuous_factor_bins=continuous_factor_bins,
|
|
1152
|
+
)
|
|
1153
|
+
if cached is not None:
|
|
1154
|
+
return cached
|
|
1155
|
+
|
|
1156
|
+
_logger.info("Building metadata for %s/%s", self._dataset_name, selection_repr)
|
|
1157
|
+
metadata = _do_compute_metadata(dataset, auto_bin_method, exclude, continuous_factor_bins)
|
|
1158
|
+
self.save_metadata(selection_repr, metadata)
|
|
1159
|
+
return metadata
|
|
1160
|
+
|
|
1161
|
+
# =====================================================================
|
|
1162
|
+
# Unified stats cache (.parquet + .json sidecar)
|
|
1163
|
+
# =====================================================================
|
|
1164
|
+
|
|
1165
|
+
def _stats_paths(self, selection_repr: str, scope: str) -> tuple[Path, Path] | tuple[None, None]:
|
|
1166
|
+
sel_dir = self._selection_dir(selection_repr)
|
|
1167
|
+
if sel_dir is None:
|
|
1168
|
+
return None, None
|
|
1169
|
+
h = _config_hash(scope)
|
|
1170
|
+
return sel_dir / f"stats_{h}.parquet", sel_dir / f"stats_{h}.json"
|
|
1171
|
+
|
|
1172
|
+
def load_stats(
|
|
1173
|
+
self,
|
|
1174
|
+
selection_repr: str,
|
|
1175
|
+
scope: str,
|
|
1176
|
+
) -> dict[str, Any] | None:
|
|
1177
|
+
"""Load cached ``StatsResult``, or ``None`` on miss.
|
|
1178
|
+
|
|
1179
|
+
Returns the full dict with whatever metrics are currently cached.
|
|
1180
|
+
Inspect ``result["stats"].keys()`` to see which metrics are present.
|
|
1181
|
+
"""
|
|
1182
|
+
obj_key = f"stats_{_config_hash(scope)}"
|
|
1183
|
+
cached = self._mem_get(selection_repr, obj_key)
|
|
1184
|
+
if cached is not None:
|
|
1185
|
+
_logger.debug("Memory hit: stats for %s/%s (scope=%s)", self._dataset_name, selection_repr, scope)
|
|
1186
|
+
return cached
|
|
1187
|
+
|
|
1188
|
+
from dataeval.types import SourceIndex
|
|
1189
|
+
|
|
1190
|
+
pq_path, json_path = self._stats_paths(selection_repr, scope)
|
|
1191
|
+
if pq_path is None or json_path is None or not pq_path.exists() or not json_path.exists():
|
|
1192
|
+
return None
|
|
1193
|
+
|
|
1194
|
+
_logger.info("Cache hit: stats for %s/%s (scope=%s)", self._dataset_name, selection_repr, scope)
|
|
1195
|
+
|
|
1196
|
+
try:
|
|
1197
|
+
df = pl.read_parquet(pq_path)
|
|
1198
|
+
with open(json_path, encoding="utf-8") as f:
|
|
1199
|
+
aux = json.load(f)
|
|
1200
|
+
|
|
1201
|
+
stats: dict[str, NDArray[Any]] = {}
|
|
1202
|
+
for col in df.columns:
|
|
1203
|
+
series = df[col]
|
|
1204
|
+
if series.dtype == pl.Utf8:
|
|
1205
|
+
# Hash string columns
|
|
1206
|
+
stats[col] = series.to_numpy(writable=False).astype(object)
|
|
1207
|
+
elif series.dtype.base_type() == pl.List:
|
|
1208
|
+
# 2D array columns (histogram, percentiles, center)
|
|
1209
|
+
stats[col] = np.array(series.to_list())
|
|
1210
|
+
else:
|
|
1211
|
+
stats[col] = series.to_numpy(writable=False)
|
|
1212
|
+
|
|
1213
|
+
source_index = [SourceIndex(item=s[0], target=s[1], channel=s[2]) for s in aux["source_index"]]
|
|
1214
|
+
|
|
1215
|
+
result = {
|
|
1216
|
+
"source_index": source_index,
|
|
1217
|
+
"object_count": aux["object_count"],
|
|
1218
|
+
"invalid_box_count": aux["invalid_box_count"],
|
|
1219
|
+
"image_count": aux["image_count"],
|
|
1220
|
+
"stats": stats,
|
|
1221
|
+
}
|
|
1222
|
+
self._mem_set(selection_repr, obj_key, result)
|
|
1223
|
+
return result
|
|
1224
|
+
except Exception: # noqa: BLE001
|
|
1225
|
+
_logger.warning(
|
|
1226
|
+
"Failed to load stats from cache for %s/%s (scope=%s) — recomputing",
|
|
1227
|
+
self._dataset_name,
|
|
1228
|
+
selection_repr,
|
|
1229
|
+
scope,
|
|
1230
|
+
exc_info=True,
|
|
1231
|
+
)
|
|
1232
|
+
return None
|
|
1233
|
+
|
|
1234
|
+
def save_stats(
|
|
1235
|
+
self,
|
|
1236
|
+
selection_repr: str,
|
|
1237
|
+
scope: str,
|
|
1238
|
+
stats: dict[str, Any],
|
|
1239
|
+
) -> None:
|
|
1240
|
+
"""Persist ``StatsResult`` to cache (no-op on disk when not disk-backed).
|
|
1241
|
+
|
|
1242
|
+
Handles scalar arrays, object-dtype hash strings, and 2D arrays
|
|
1243
|
+
(histogram, percentiles, center) via Polars list columns.
|
|
1244
|
+
"""
|
|
1245
|
+
obj_key = f"stats_{_config_hash(scope)}"
|
|
1246
|
+
self._mem_set(selection_repr, obj_key, stats)
|
|
1247
|
+
|
|
1248
|
+
pq_path, json_path = self._stats_paths(selection_repr, scope)
|
|
1249
|
+
if pq_path is None or json_path is None:
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
stat_arrays: dict[str, NDArray[Any]] = dict(stats["stats"])
|
|
1253
|
+
series_dict: dict[str, pl.Series] = {}
|
|
1254
|
+
for name, arr in stat_arrays.items():
|
|
1255
|
+
if arr.dtype == object:
|
|
1256
|
+
# Hash string arrays → Utf8 columns
|
|
1257
|
+
series_dict[name] = pl.Series(name, [str(v) for v in arr])
|
|
1258
|
+
elif arr.ndim == 2:
|
|
1259
|
+
# 2D arrays (histogram, percentiles, center) → list columns
|
|
1260
|
+
series_dict[name] = pl.Series(name, arr.tolist())
|
|
1261
|
+
else:
|
|
1262
|
+
series_dict[name] = pl.Series(name, arr)
|
|
1263
|
+
df = pl.DataFrame(series_dict)
|
|
1264
|
+
|
|
1265
|
+
aux = {
|
|
1266
|
+
"source_index": [[int(si.item), si.target, si.channel] for si in stats["source_index"]],
|
|
1267
|
+
"object_count": [int(v) for v in stats["object_count"]],
|
|
1268
|
+
"invalid_box_count": [int(v) for v in stats["invalid_box_count"]],
|
|
1269
|
+
"image_count": int(stats["image_count"]),
|
|
1270
|
+
}
|
|
1271
|
+
_atomic_write_pair(
|
|
1272
|
+
pq_path,
|
|
1273
|
+
lambda p: df.write_parquet(p),
|
|
1274
|
+
json_path,
|
|
1275
|
+
lambda p: p.write_text(json.dumps(aux), encoding="utf-8"),
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
_logger.info(
|
|
1279
|
+
"Cache save: stats for %s/%s (scope=%s, metrics=%s)",
|
|
1280
|
+
self._dataset_name,
|
|
1281
|
+
selection_repr,
|
|
1282
|
+
scope,
|
|
1283
|
+
sorted(stat_arrays.keys()),
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
def load_or_compute_stats(
|
|
1287
|
+
self,
|
|
1288
|
+
selection_repr: str,
|
|
1289
|
+
scope: str,
|
|
1290
|
+
desired_flags: ImageStats,
|
|
1291
|
+
dataset: AnnotatedDataset[Any],
|
|
1292
|
+
per_image: bool = True,
|
|
1293
|
+
per_target: bool = True,
|
|
1294
|
+
per_channel: bool = False,
|
|
1295
|
+
) -> dict[str, Any]:
|
|
1296
|
+
"""Load cached stats, compute any missing metrics, merge, and save.
|
|
1297
|
+
|
|
1298
|
+
Parameters
|
|
1299
|
+
----------
|
|
1300
|
+
selection_repr : str
|
|
1301
|
+
Selection key (from :func:`selection_repr`).
|
|
1302
|
+
scope : str
|
|
1303
|
+
Scope key (from :func:`scope_key`).
|
|
1304
|
+
desired_flags : ImageStats
|
|
1305
|
+
All flags the caller needs.
|
|
1306
|
+
dataset
|
|
1307
|
+
The dataset to pass to ``compute_stats()`` on miss.
|
|
1308
|
+
per_image, per_target, per_channel
|
|
1309
|
+
Scope settings for ``compute_stats()``.
|
|
1310
|
+
|
|
1311
|
+
Returns
|
|
1312
|
+
-------
|
|
1313
|
+
dict[str, Any]
|
|
1314
|
+
A ``StatsResult``-shaped dict with at least all the
|
|
1315
|
+
requested metrics.
|
|
1316
|
+
"""
|
|
1317
|
+
cached = self.load_stats(selection_repr, scope)
|
|
1318
|
+
if cached is not None:
|
|
1319
|
+
cached_metric_names = set(cached["stats"].keys())
|
|
1320
|
+
to_compute = missing_flags(cached_metric_names, desired_flags)
|
|
1321
|
+
else:
|
|
1322
|
+
to_compute = desired_flags
|
|
1323
|
+
|
|
1324
|
+
if to_compute == ImageStats.NONE and cached is not None:
|
|
1325
|
+
_logger.info("Full cache hit for stats (scope=%s)", scope)
|
|
1326
|
+
return cached
|
|
1327
|
+
|
|
1328
|
+
if cached is not None:
|
|
1329
|
+
_logger.info(
|
|
1330
|
+
"Partial cache hit: computing missing metrics (have %s)",
|
|
1331
|
+
sorted(cached["stats"].keys()),
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
# Compute the missing stats
|
|
1335
|
+
fresh = _do_compute_stats(dataset, to_compute, per_image, per_target, per_channel)
|
|
1336
|
+
|
|
1337
|
+
if cached is None:
|
|
1338
|
+
fresh_dict = dict(fresh)
|
|
1339
|
+
self.save_stats(selection_repr, scope, fresh_dict)
|
|
1340
|
+
return fresh_dict
|
|
1341
|
+
|
|
1342
|
+
# Merge: cached structural fields + merged stats dict
|
|
1343
|
+
merged_stats = dict(cached["stats"])
|
|
1344
|
+
merged_stats.update(fresh["stats"])
|
|
1345
|
+
|
|
1346
|
+
merged: dict[str, Any] = {
|
|
1347
|
+
"source_index": cached["source_index"],
|
|
1348
|
+
"object_count": cached["object_count"],
|
|
1349
|
+
"invalid_box_count": cached["invalid_box_count"],
|
|
1350
|
+
"image_count": cached["image_count"],
|
|
1351
|
+
"stats": merged_stats,
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
self.save_stats(selection_repr, scope, merged)
|
|
1355
|
+
return merged
|