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.
Files changed (94) hide show
  1. dataeval_flow/__init__.py +93 -0
  2. dataeval_flow/__main__.py +149 -0
  3. dataeval_flow/_app/__init__.py +5 -0
  4. dataeval_flow/_app/_model/__init__.py +5 -0
  5. dataeval_flow/_app/_model/_coerce.py +126 -0
  6. dataeval_flow/_app/_model/_discover.py +171 -0
  7. dataeval_flow/_app/_model/_execution.py +108 -0
  8. dataeval_flow/_app/_model/_introspect.py +280 -0
  9. dataeval_flow/_app/_model/_item.py +213 -0
  10. dataeval_flow/_app/_model/_registry.py +255 -0
  11. dataeval_flow/_app/_model/_state.py +322 -0
  12. dataeval_flow/_app/_model/_undo.py +61 -0
  13. dataeval_flow/_app/_panes/__init__.py +35 -0
  14. dataeval_flow/_app/_panes/_config_pane.py +173 -0
  15. dataeval_flow/_app/_panes/_result_pane.py +125 -0
  16. dataeval_flow/_app/_panes/_task_pane.py +91 -0
  17. dataeval_flow/_app/_panes/_widgets.py +111 -0
  18. dataeval_flow/_app/_screens/__init__.py +25 -0
  19. dataeval_flow/_app/_screens/_base.py +242 -0
  20. dataeval_flow/_app/_screens/_detail.py +333 -0
  21. dataeval_flow/_app/_screens/_model.py +102 -0
  22. dataeval_flow/_app/_screens/_params.py +80 -0
  23. dataeval_flow/_app/_screens/_pathpicker.py +68 -0
  24. dataeval_flow/_app/_screens/_section.py +621 -0
  25. dataeval_flow/_app/_screens/_settings.py +183 -0
  26. dataeval_flow/_app/_viewmodel/__init__.py +15 -0
  27. dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
  28. dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
  29. dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
  30. dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
  31. dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
  32. dataeval_flow/_app/app.py +742 -0
  33. dataeval_flow/_app/cli.py +592 -0
  34. dataeval_flow/_logging.py +102 -0
  35. dataeval_flow/cache.py +1355 -0
  36. dataeval_flow/config/__init__.py +80 -0
  37. dataeval_flow/config/_loader.py +79 -0
  38. dataeval_flow/config/_merge.py +92 -0
  39. dataeval_flow/config/_models.py +115 -0
  40. dataeval_flow/config/_paths.py +85 -0
  41. dataeval_flow/config/schemas/__init__.py +112 -0
  42. dataeval_flow/config/schemas/_dataset.py +111 -0
  43. dataeval_flow/config/schemas/_extractor.py +119 -0
  44. dataeval_flow/config/schemas/_metadata.py +28 -0
  45. dataeval_flow/config/schemas/_preprocessor.py +18 -0
  46. dataeval_flow/config/schemas/_selection.py +100 -0
  47. dataeval_flow/config/schemas/_task.py +89 -0
  48. dataeval_flow/config/schemas/_workflow.py +135 -0
  49. dataeval_flow/dataset.py +635 -0
  50. dataeval_flow/embeddings.py +135 -0
  51. dataeval_flow/metadata.py +48 -0
  52. dataeval_flow/preprocessing.py +141 -0
  53. dataeval_flow/py.typed +0 -0
  54. dataeval_flow/runner.py +118 -0
  55. dataeval_flow/selection.py +50 -0
  56. dataeval_flow/workflow/__init__.py +328 -0
  57. dataeval_flow/workflow/_text_report.py +511 -0
  58. dataeval_flow/workflow/base.py +69 -0
  59. dataeval_flow/workflow/orchestrator.py +454 -0
  60. dataeval_flow/workflows/__init__.py +1 -0
  61. dataeval_flow/workflows/analysis/__init__.py +38 -0
  62. dataeval_flow/workflows/analysis/outputs.py +202 -0
  63. dataeval_flow/workflows/analysis/params.py +114 -0
  64. dataeval_flow/workflows/analysis/workflow.py +1313 -0
  65. dataeval_flow/workflows/cleaning/__init__.py +23 -0
  66. dataeval_flow/workflows/cleaning/outputs.py +200 -0
  67. dataeval_flow/workflows/cleaning/params.py +160 -0
  68. dataeval_flow/workflows/cleaning/report.py +304 -0
  69. dataeval_flow/workflows/cleaning/workflow.py +794 -0
  70. dataeval_flow/workflows/drift/__init__.py +1 -0
  71. dataeval_flow/workflows/drift/outputs.py +144 -0
  72. dataeval_flow/workflows/drift/params.py +332 -0
  73. dataeval_flow/workflows/drift/report.py +201 -0
  74. dataeval_flow/workflows/drift/workflow.py +647 -0
  75. dataeval_flow/workflows/ood/__init__.py +1 -0
  76. dataeval_flow/workflows/ood/outputs.py +134 -0
  77. dataeval_flow/workflows/ood/params.py +161 -0
  78. dataeval_flow/workflows/ood/report.py +311 -0
  79. dataeval_flow/workflows/ood/workflow.py +728 -0
  80. dataeval_flow/workflows/prioritization/__init__.py +1 -0
  81. dataeval_flow/workflows/prioritization/outputs.py +122 -0
  82. dataeval_flow/workflows/prioritization/params.py +124 -0
  83. dataeval_flow/workflows/prioritization/report.py +117 -0
  84. dataeval_flow/workflows/prioritization/workflow.py +587 -0
  85. dataeval_flow/workflows/splitting/__init__.py +25 -0
  86. dataeval_flow/workflows/splitting/outputs.py +101 -0
  87. dataeval_flow/workflows/splitting/params.py +61 -0
  88. dataeval_flow/workflows/splitting/report.py +485 -0
  89. dataeval_flow/workflows/splitting/workflow.py +371 -0
  90. dataeval_flow-0.1.0.dist-info/METADATA +305 -0
  91. dataeval_flow-0.1.0.dist-info/RECORD +94 -0
  92. dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
  93. dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
  94. 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