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
@@ -0,0 +1,794 @@
1
+ """Data Cleaning Workflow — orchestration + processor + factory helpers."""
2
+
3
+ __all__ = ["DataCleaningWorkflow"]
4
+
5
+ import contextlib
6
+ import logging
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any, cast
10
+
11
+ import polars as pl
12
+ from dataeval import Metadata
13
+ from dataeval.flags import ImageStats
14
+ from dataeval.protocols import AnnotatedDataset
15
+ from dataeval.quality import Duplicates, DuplicatesOutput, Outliers
16
+ from pydantic import BaseModel
17
+
18
+ from dataeval_flow.cache import active_cache, get_or_compute_metadata
19
+ from dataeval_flow.embeddings import build_extractor
20
+ from dataeval_flow.workflow import WorkflowContext, WorkflowProtocol, WorkflowResult
21
+ from dataeval_flow.workflow.base import Reportable
22
+ from dataeval_flow.workflows.cleaning.outputs import (
23
+ ClasswisePivotDict,
24
+ ClasswiseRowDict,
25
+ DataCleaningMetadata,
26
+ DataCleaningOutputs,
27
+ DataCleaningRawOutputs,
28
+ DataCleaningReport,
29
+ DetectionDict,
30
+ DuplicatesDict,
31
+ IndexValue,
32
+ LabelStatsDict,
33
+ OutlierIssuesDict,
34
+ SourceIndexDict,
35
+ )
36
+ from dataeval_flow.workflows.cleaning.params import DataCleaningParameters
37
+ from dataeval_flow.workflows.cleaning.report import build_findings, collect_flagged_indices
38
+
39
+ logger: logging.Logger = logging.getLogger(__name__)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Protocols for private DataEval types
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Factory helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+ FLAG_MAP: dict[str, ImageStats] = {
52
+ "dimension": ImageStats.DIMENSION,
53
+ "pixel": ImageStats.PIXEL,
54
+ "visual": ImageStats.VISUAL,
55
+ }
56
+
57
+ HASH_FLAG_MAP: dict[str, ImageStats] = {
58
+ "hash_basic": ImageStats.HASH_DUPLICATES_BASIC,
59
+ "hash_d4": ImageStats.HASH_DUPLICATES_D4,
60
+ }
61
+
62
+
63
+ def _build_outliers(
64
+ params: DataCleaningParameters,
65
+ extractor: Callable | None = None,
66
+ ) -> Outliers:
67
+ """Build Outliers evaluator from cleaning parameters."""
68
+ flags = ImageStats.NONE
69
+ for name in params.outlier_flags:
70
+ flags |= FLAG_MAP[name]
71
+
72
+ # Validate cluster params require an extractor
73
+ has_cluster = (
74
+ params.outlier_cluster_threshold is not None
75
+ or params.outlier_cluster_algorithm is not None
76
+ or params.outlier_n_clusters is not None
77
+ )
78
+ if has_cluster and extractor is None:
79
+ raise ValueError(
80
+ "Cluster-based outlier detection requires an extractor. "
81
+ "Configure a model/extractor or remove cluster params."
82
+ )
83
+
84
+ return Outliers(
85
+ flags=flags,
86
+ outlier_threshold=(params.outlier_method, params.outlier_threshold),
87
+ cluster_threshold=params.outlier_cluster_threshold,
88
+ cluster_algorithm=params.outlier_cluster_algorithm,
89
+ n_clusters=params.outlier_n_clusters,
90
+ extractor=extractor,
91
+ )
92
+
93
+
94
+ def _build_duplicates(
95
+ params: DataCleaningParameters,
96
+ extractor: Callable | None = None,
97
+ batch_size: int | None = None,
98
+ ) -> Duplicates:
99
+ """Build Duplicates evaluator from cleaning parameters."""
100
+ # Build hash flags
101
+ flags = ImageStats.NONE
102
+ if params.duplicate_flags is not None:
103
+ for name in params.duplicate_flags:
104
+ flags |= HASH_FLAG_MAP[name]
105
+
106
+ # Validate cluster params require an extractor
107
+ has_cluster = (
108
+ params.duplicate_cluster_sensitivity is not None
109
+ or params.duplicate_cluster_algorithm is not None
110
+ or params.duplicate_n_clusters is not None
111
+ )
112
+ if has_cluster and extractor is None:
113
+ raise ValueError(
114
+ "Cluster-based duplicate detection requires an extractor. "
115
+ "Configure a model/extractor or remove cluster params."
116
+ )
117
+
118
+ # Pass flags only if explicitly configured; otherwise let DataEval use its default.
119
+ kwargs: dict[str, object] = {
120
+ "merge_near_duplicates": params.duplicate_merge_near,
121
+ "cluster_sensitivity": params.duplicate_cluster_sensitivity,
122
+ "cluster_algorithm": params.duplicate_cluster_algorithm,
123
+ "n_clusters": params.duplicate_n_clusters,
124
+ "extractor": extractor,
125
+ "batch_size": batch_size,
126
+ }
127
+ if params.duplicate_flags is not None:
128
+ kwargs["flags"] = flags
129
+
130
+ return Duplicates(**kwargs) # type: ignore[arg-type]
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Result serialization helpers
135
+ # ---------------------------------------------------------------------------
136
+
137
+
138
+ def _serialize_outlier_issues(issues: "pl.DataFrame") -> "OutlierIssuesDict":
139
+ """Serialize outlier issues Polars DataFrame to plain dict.
140
+
141
+ Parameters
142
+ ----------
143
+ issues : polars.DataFrame
144
+ Polars DataFrame with columns: item_index, metric_name, metric_value,
145
+ and optionally target_index.
146
+ """
147
+ return {
148
+ # to_dicts() returns list[dict[str, Any]]; rows match OutlierIssueRecord shape at runtime.
149
+ "issues": issues.to_dicts(), # type: ignore[typeddict-item]
150
+ "count": len(issues),
151
+ }
152
+
153
+
154
+ def _serialize_duplicates(result: "DuplicatesOutput") -> "DuplicatesDict":
155
+ """Serialize DuplicatesOutput to plain dict from its DataFrame.
156
+
157
+ DuplicatesOutput wraps a DataFrame with columns: group_id, level,
158
+ dup_type, item_indices, target_indices, methods, orientation.
159
+ """
160
+
161
+ def _indices_from_row(row: dict[str, Any]) -> list[IndexValue]:
162
+ """Build index list from a DataFrame row, using SourceIndexDict for targets."""
163
+ items = row["item_indices"]
164
+ targets = row.get("target_indices")
165
+ if targets is not None:
166
+ return [SourceIndexDict(item=i, target=t, channel=None) for i, t in zip(items, targets, strict=True)]
167
+ return [int(i) for i in items]
168
+
169
+ def _detection_from_df(df: "pl.DataFrame") -> "DetectionDict":
170
+ out: DetectionDict = {}
171
+ exact_df = df.filter(pl.col("dup_type") == "exact")
172
+ if len(exact_df) > 0:
173
+ out["exact"] = [_indices_from_row(row) for row in exact_df.iter_rows(named=True)]
174
+
175
+ near_df = df.filter(pl.col("dup_type") == "near")
176
+ if len(near_df) > 0:
177
+ out["near"] = [
178
+ {
179
+ "indices": _indices_from_row(row),
180
+ "methods": sorted(row["methods"]),
181
+ "orientation": row.get("orientation"),
182
+ }
183
+ for row in near_df.iter_rows(named=True)
184
+ ]
185
+ return out
186
+
187
+ items_df = result.data().filter(pl.col("level") == "item")
188
+ targets_df = result.data().filter(pl.col("level") == "target")
189
+ return {
190
+ "items": _detection_from_df(items_df),
191
+ "targets": _detection_from_df(targets_df),
192
+ }
193
+
194
+
195
+ def _compute_label_stats(metadata: Metadata) -> "LabelStatsDict":
196
+ """Compute label statistics from Metadata instance."""
197
+ _, _, label_counts = _build_class_labels_df(metadata)
198
+
199
+ return {
200
+ "item_count": metadata.item_count,
201
+ "class_count": len(metadata.index2label),
202
+ "index2label": dict(metadata.index2label),
203
+ "label_counts_per_class": label_counts,
204
+ }
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Processor (internal — not exported)
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ def _resolve_flags(params: DataCleaningParameters) -> tuple[ImageStats, ImageStats]:
213
+ """Resolve outlier and hash flags from cleaning parameters."""
214
+ outlier_flags = ImageStats.NONE
215
+ for name in params.outlier_flags:
216
+ outlier_flags |= FLAG_MAP[name]
217
+
218
+ hash_flags = ImageStats.NONE
219
+ if params.duplicate_flags is not None:
220
+ for name in params.duplicate_flags:
221
+ hash_flags |= HASH_FLAG_MAP[name]
222
+ else:
223
+ # DataEval default when no flags are specified
224
+ hash_flags = ImageStats.HASH_DUPLICATES_BASIC
225
+
226
+ return outlier_flags, hash_flags
227
+
228
+
229
+ def _split_outlier_issues(
230
+ issues_df: "pl.DataFrame",
231
+ ) -> tuple["pl.DataFrame", "pl.DataFrame | None"]:
232
+ """Split outlier issues into image-level and target-level DataFrames."""
233
+ if "target_index" in issues_df.columns:
234
+ img_issues = issues_df.filter(issues_df["target_index"].is_null())
235
+ target_issues = issues_df.filter(issues_df["target_index"].is_not_null())
236
+ else:
237
+ img_issues = issues_df
238
+ target_issues = None
239
+ return img_issues, target_issues
240
+
241
+
242
+ def _validate_cluster_params(params: DataCleaningParameters, extractor: Callable | None) -> None:
243
+ """Validate that cluster-based detection params have an accompanying extractor."""
244
+ has_outlier_cluster = (
245
+ params.outlier_cluster_threshold is not None
246
+ or params.outlier_cluster_algorithm is not None
247
+ or params.outlier_n_clusters is not None
248
+ )
249
+ if has_outlier_cluster and extractor is None:
250
+ raise ValueError(
251
+ "Cluster-based outlier detection requires an extractor. "
252
+ "Configure a model/extractor or remove cluster params."
253
+ )
254
+
255
+ has_dup_cluster = (
256
+ params.duplicate_cluster_sensitivity is not None
257
+ or params.duplicate_cluster_algorithm is not None
258
+ or params.duplicate_n_clusters is not None
259
+ )
260
+ if has_dup_cluster and extractor is None:
261
+ raise ValueError(
262
+ "Cluster-based duplicate detection requires an extractor. "
263
+ "Configure a model/extractor or remove cluster params."
264
+ )
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class CleaningRunContext:
269
+ """Extractor plumbing passed from execute() to _run_cleaning()."""
270
+
271
+ extractor_config: Any = None
272
+ transforms: Callable | None = None
273
+ batch_size: int | None = None
274
+
275
+
276
+ def _build_class_labels_df(
277
+ metadata: "Metadata",
278
+ ) -> tuple[pl.DataFrame, list[str], dict[str, int]]:
279
+ """Build a DataFrame mapping items/targets to class names and label counts.
280
+
281
+ Returns
282
+ -------
283
+ labels_df
284
+ DataFrame with ``item_index``, optionally ``target_index``, and ``class_name``.
285
+ id_cols
286
+ Column names to use as join keys (``["item_index"]`` or
287
+ ``["item_index", "target_index"]``).
288
+ label_counts
289
+ Number of items/targets per class name.
290
+ """
291
+ index2label = metadata.index2label
292
+ has_targets = metadata.has_targets()
293
+
294
+ label_counts: dict[str, int] = {}
295
+ for lbl in metadata.class_labels:
296
+ name = index2label.get(lbl, str(lbl))
297
+ label_counts[name] = label_counts.get(name, 0) + 1
298
+
299
+ if has_targets and hasattr(metadata, "target_data"):
300
+ td = metadata.target_data.select("item_index", "target_index", "class_label")
301
+ names = [index2label.get(int(c), str(c)) for c in td["class_label"].to_list()]
302
+ labels_df = td.with_columns(pl.Series("class_name", names)).select("item_index", "target_index", "class_name")
303
+ id_cols = ["item_index", "target_index"]
304
+ else:
305
+ item_ids = getattr(metadata, "item_indices", list(range(len(metadata.class_labels))))
306
+ names = [index2label.get(lbl, str(lbl)) for lbl in metadata.class_labels]
307
+ labels_df = pl.DataFrame({"item_index": item_ids, "class_name": names})
308
+ id_cols = ["item_index"]
309
+
310
+ return labels_df, id_cols, label_counts
311
+
312
+
313
+ def _compute_classwise_pivot(
314
+ target_issues: "pl.DataFrame | None",
315
+ img_issues: "pl.DataFrame",
316
+ metadata: "Metadata | None",
317
+ ) -> "ClasswisePivotDict | None":
318
+ """Compute classwise outlier pivot from the globally-detected outlier issues.
319
+
320
+ Groups the same target (or image) outlier issues used for the headline
321
+ count by class, so the per-class rows sum to the headline total.
322
+
323
+ Returns a simplified summary with count and % of labels flagged per class.
324
+ """
325
+ if metadata is None:
326
+ return None
327
+ try:
328
+ # Use target-level issues for OD datasets, image-level otherwise
329
+ has_targets = metadata.has_targets()
330
+ if has_targets:
331
+ if target_issues is None or target_issues.shape[0] == 0:
332
+ return None
333
+ issues_df = target_issues
334
+ else:
335
+ if img_issues.shape[0] == 0:
336
+ return None
337
+ issues_df = img_issues
338
+
339
+ labels_df, id_cols, label_counts = _build_class_labels_df(metadata)
340
+ total_labels = sum(label_counts.values())
341
+
342
+ for col in id_cols:
343
+ if col in issues_df.columns and issues_df[col].dtype != labels_df[col].dtype:
344
+ labels_df = labels_df.with_columns(pl.col(col).cast(issues_df[col].dtype))
345
+
346
+ # Count unique outlier items/targets per class (not per metric flag)
347
+ unique_per_class = (
348
+ issues_df.join(labels_df, on=id_cols, how="left")
349
+ .select(id_cols + ["class_name"])
350
+ .unique()
351
+ .group_by("class_name")
352
+ .len()
353
+ .sort("len", descending=True)
354
+ )
355
+
356
+ rows: list[ClasswiseRowDict] = []
357
+ grand_total = 0
358
+ for row_dict in unique_per_class.to_dicts():
359
+ name = str(row_dict.get("class_name", ""))
360
+ count = int(row_dict.get("len", 0))
361
+ grand_total += count
362
+ denom = label_counts.get(name, 0)
363
+ pct = round((count / denom) * 100, 1) if denom > 0 else 0.0
364
+ rows.append({"class_name": name, "count": count, "pct": pct})
365
+
366
+ total_pct = round((grand_total / total_labels) * 100, 1) if total_labels > 0 else 0.0
367
+ rows.append({"class_name": "Total", "count": grand_total, "pct": total_pct})
368
+
369
+ return ClasswisePivotDict(
370
+ level="target" if has_targets else "image",
371
+ rows=rows,
372
+ )
373
+ except Exception: # noqa: BLE001
374
+ logger.warning("Classwise pivot unavailable", exc_info=True)
375
+ return None
376
+
377
+
378
+ def _compute_embeddings(
379
+ dataset: AnnotatedDataset[Any],
380
+ extractor: Callable,
381
+ run_ctx: CleaningRunContext | None,
382
+ ) -> Any:
383
+ """Compute or load cached embeddings for cluster-based detection."""
384
+ import time as _time
385
+
386
+ logger.info(" [4c] Loading embeddings for cluster-based detection…")
387
+ _t0 = _time.monotonic()
388
+ if run_ctx is not None and run_ctx.extractor_config is not None:
389
+ from dataeval_flow.cache import get_or_compute_embeddings
390
+
391
+ embeddings_array = get_or_compute_embeddings(
392
+ dataset,
393
+ run_ctx.extractor_config,
394
+ run_ctx.transforms,
395
+ run_ctx.batch_size,
396
+ )
397
+ else:
398
+ from dataeval.utils.arrays import flatten_samples, to_numpy
399
+
400
+ images = [item[0] if isinstance(item, tuple) else item for item in dataset]
401
+ embeddings = extractor(images) # type: ignore[misc]
402
+ embeddings_array = flatten_samples(to_numpy(embeddings))
403
+ logger.info(" [4c] Embeddings ready in %.1fs", _time.monotonic() - _t0)
404
+ return embeddings_array
405
+
406
+
407
+ def _merge_outlier_outputs(
408
+ outliers_eval: Outliers,
409
+ stats_output: Any,
410
+ embeddings_array: Any,
411
+ params: DataCleaningParameters,
412
+ run_ctx: CleaningRunContext | None,
413
+ ) -> Any:
414
+ """Run cluster-based outlier detection and merge with stats-based results.
415
+
416
+ Returns the merged OutliersOutput combining stats-based and cluster-based issues.
417
+ """
418
+ import time as _time
419
+
420
+ from dataeval.quality import OutliersOutput
421
+
422
+ from dataeval_flow.cache import get_or_compute_cluster_result
423
+
424
+ logger.info(" [4d] Running cluster-based outlier detection…")
425
+ _t0 = _time.monotonic()
426
+ outlier_cluster_result = get_or_compute_cluster_result(
427
+ embeddings_array,
428
+ algorithm=params.outlier_cluster_algorithm or "hdbscan",
429
+ n_clusters=params.outlier_n_clusters,
430
+ extractor_config=run_ctx.extractor_config if run_ctx else None,
431
+ transforms=run_ctx.transforms if run_ctx else None,
432
+ )
433
+ cluster_outlier_output = outliers_eval.from_clusters(
434
+ embeddings_array,
435
+ outlier_cluster_result,
436
+ cluster_threshold=params.outlier_cluster_threshold,
437
+ )
438
+ logger.info(" [4d] Cluster-based outlier detection done in %.1fs", _time.monotonic() - _t0)
439
+
440
+ # Merge stats-based + cluster-based issues via concat
441
+ column_order = ["item_index", "target_index", "metric_name", "metric_value"]
442
+ stats_issues = stats_output.data()
443
+ cluster_issues = cluster_outlier_output.data()
444
+ dfs: list[pl.DataFrame] = []
445
+ for df in [stats_issues, cluster_issues]:
446
+ if "target_index" not in df.columns:
447
+ df = df.with_columns(pl.lit(None, dtype=pl.Int64).alias("target_index"))
448
+ dfs.append(df.select(column_order))
449
+ merged_issues = pl.concat(dfs).sort(["item_index", "metric_name"])
450
+ if merged_issues["target_index"].null_count() == len(merged_issues):
451
+ merged_issues = merged_issues.drop("target_index")
452
+ return OutliersOutput(merged_issues)
453
+
454
+
455
+ def _run_duplicate_detection(
456
+ params: DataCleaningParameters,
457
+ hash_flags: ImageStats,
458
+ calc_result: Any,
459
+ embeddings_array: Any | None,
460
+ run_ctx: CleaningRunContext | None,
461
+ ) -> DuplicatesOutput:
462
+ """Run hash-based and optionally cluster-based duplicate detection.
463
+
464
+ Returns the final DuplicatesOutput (merged if cluster-based detection is used).
465
+ """
466
+ import time as _time
467
+
468
+ logger.info(" [4e] Running duplicate detection…")
469
+ _t0 = _time.monotonic()
470
+
471
+ # Hash-based duplicate detection (always, using cached stats)
472
+ dup_kwargs: dict[str, object] = {"merge_near_duplicates": params.duplicate_merge_near}
473
+ if params.duplicate_flags is not None:
474
+ dup_kwargs["flags"] = hash_flags
475
+ duplicates_eval = Duplicates(**dup_kwargs) # type: ignore[arg-type]
476
+ hash_dup_result = duplicates_eval.from_stats(calc_result) # type: ignore[arg-type]
477
+
478
+ has_dup_cluster = embeddings_array is not None and params.duplicate_cluster_sensitivity is not None
479
+ if has_dup_cluster:
480
+ duplicates_result = _merge_duplicate_results(hash_dup_result, embeddings_array, params, run_ctx)
481
+ else:
482
+ duplicates_result = hash_dup_result
483
+
484
+ logger.info(" [4e] Duplicate detection done in %.1fs", _time.monotonic() - _t0)
485
+ return duplicates_result
486
+
487
+
488
+ def _merge_duplicate_results(
489
+ hash_dup_result: DuplicatesOutput,
490
+ embeddings_array: Any,
491
+ params: DataCleaningParameters,
492
+ run_ctx: CleaningRunContext | None,
493
+ ) -> DuplicatesOutput:
494
+ """Run cluster-based duplicate detection and merge with hash-based results."""
495
+ from dataeval_flow.cache import get_or_compute_cluster_result
496
+
497
+ dup_cluster_result = get_or_compute_cluster_result(
498
+ embeddings_array,
499
+ algorithm=params.duplicate_cluster_algorithm or "hdbscan",
500
+ n_clusters=params.duplicate_n_clusters,
501
+ extractor_config=run_ctx.extractor_config if run_ctx else None,
502
+ transforms=run_ctx.transforms if run_ctx else None,
503
+ )
504
+ dup_cluster_eval = Duplicates(
505
+ merge_near_duplicates=params.duplicate_merge_near,
506
+ cluster_sensitivity=params.duplicate_cluster_sensitivity,
507
+ )
508
+ cluster_dup_result = dup_cluster_eval.from_clusters(dup_cluster_result)
509
+
510
+ hash_df = hash_dup_result.data()
511
+ cluster_df = cluster_dup_result.data()
512
+ if len(cluster_df) == 0:
513
+ return hash_dup_result
514
+
515
+ # Re-number cluster group_ids to avoid collision with hash group_ids
516
+ max_group_id = cast(int, hash_df["group_id"].max()) + 1 if len(hash_df) > 0 else 0
517
+ cluster_df = cluster_df.with_columns(pl.col("group_id") + max_group_id)
518
+ # Align columns before concat
519
+ for col in hash_df.columns:
520
+ if col not in cluster_df.columns:
521
+ cluster_df = cluster_df.with_columns(pl.lit(None).alias(col).cast(hash_df[col].dtype))
522
+ for col in cluster_df.columns:
523
+ if col not in hash_df.columns:
524
+ hash_df = hash_df.with_columns(pl.lit(None).alias(col).cast(cluster_df[col].dtype))
525
+ merged_df = pl.concat([hash_df.select(sorted(hash_df.columns)), cluster_df.select(sorted(hash_df.columns))])
526
+ return DuplicatesOutput(merged_df)
527
+
528
+
529
+ def _run_cleaning(
530
+ dataset: AnnotatedDataset[Any],
531
+ params: DataCleaningParameters,
532
+ extractor: Callable | None = None,
533
+ metadata: Metadata | None = None,
534
+ run_ctx: CleaningRunContext | None = None,
535
+ ) -> DataCleaningRawOutputs:
536
+ """Run outlier + duplicate detection on dataset.
537
+
538
+ Stats are obtained via :func:`~dataeval_flow.cache.get_or_compute_stats`,
539
+ which transparently handles caching via the :func:`active_cache` context.
540
+ Evaluators consume pre-computed stats via ``from_stats()``; cluster-based
541
+ detection (when an extractor is configured) is handled separately via
542
+ ``from_clusters()`` / ``evaluate()``.
543
+ """
544
+ import time as _time
545
+
546
+ from dataeval_flow.cache import get_or_compute_stats
547
+
548
+ _validate_cluster_params(params, extractor)
549
+
550
+ outlier_flags, hash_flags = _resolve_flags(params)
551
+
552
+ # --- Centralized stats: cache-aware load / compute / save ---
553
+ _t0 = _time.monotonic()
554
+ calc_result = get_or_compute_stats(
555
+ desired_flags=outlier_flags | hash_flags,
556
+ dataset=dataset,
557
+ )
558
+ logger.info(" [4a] Image stats ready in %.1fs", _time.monotonic() - _t0)
559
+
560
+ # --- Outlier detection via from_stats() ---
561
+ logger.info(" [4b] Running stats-based outlier detection…")
562
+ _t0 = _time.monotonic()
563
+ outliers_eval = Outliers(
564
+ flags=outlier_flags,
565
+ outlier_threshold=(params.outlier_method, params.outlier_threshold),
566
+ )
567
+ outlier_output = outliers_eval.from_stats(calc_result, per_target=True) # type: ignore[arg-type]
568
+ logger.info(" [4b] Stats-based outlier detection done in %.1fs", _time.monotonic() - _t0)
569
+
570
+ # --- Shared embeddings for cluster-based detection ---
571
+ has_outlier_cluster = extractor is not None and params.outlier_cluster_threshold is not None
572
+ has_dup_cluster = extractor is not None and params.duplicate_cluster_sensitivity is not None
573
+ embeddings_array = None
574
+
575
+ if (has_outlier_cluster or has_dup_cluster) and extractor is not None:
576
+ embeddings_array = _compute_embeddings(dataset, extractor, run_ctx)
577
+
578
+ # --- Cluster-based outlier detection ---
579
+ if has_outlier_cluster and embeddings_array is not None:
580
+ outlier_output = _merge_outlier_outputs(outliers_eval, outlier_output, embeddings_array, params, run_ctx)
581
+
582
+ img_issues, target_issues = _split_outlier_issues(outlier_output.data())
583
+
584
+ # --- Classwise outlier pivot ---
585
+ classwise_pivot = _compute_classwise_pivot(target_issues, img_issues, metadata)
586
+
587
+ # --- Duplicate detection ---
588
+ duplicates_result = _run_duplicate_detection(params, hash_flags, calc_result, embeddings_array, run_ctx)
589
+
590
+ # Label stats
591
+ label_stats: LabelStatsDict = _compute_label_stats(metadata) if metadata else {} # type: ignore[assignment]
592
+
593
+ return DataCleaningRawOutputs(
594
+ dataset_size=len(dataset),
595
+ img_outliers=_serialize_outlier_issues(img_issues),
596
+ target_outliers=_serialize_outlier_issues(target_issues)
597
+ if target_issues is not None and len(target_issues) > 0
598
+ else None,
599
+ duplicates=_serialize_duplicates(duplicates_result),
600
+ label_stats=label_stats,
601
+ classwise_outliers=classwise_pivot,
602
+ )
603
+
604
+
605
+ # ---------------------------------------------------------------------------
606
+ # Workflow
607
+ # ---------------------------------------------------------------------------
608
+
609
+
610
+ class DataCleaningWorkflow(WorkflowProtocol[DataCleaningMetadata, DataCleaningOutputs]):
611
+ """Data cleaning workflow using DataEval evaluators."""
612
+
613
+ @property
614
+ def name(self) -> str:
615
+ """Workflow identifier."""
616
+ return "data-cleaning"
617
+
618
+ @property
619
+ def description(self) -> str:
620
+ """Human-readable description."""
621
+ return "Outlier and duplicate detection for image datasets"
622
+
623
+ @property
624
+ def params_schema(self) -> type[DataCleaningParameters]:
625
+ """Pydantic model for workflow parameters."""
626
+ return DataCleaningParameters
627
+
628
+ @property
629
+ def output_schema(self) -> type[DataCleaningOutputs]:
630
+ """Pydantic model for workflow output."""
631
+ return DataCleaningOutputs
632
+
633
+ def execute(
634
+ self,
635
+ context: WorkflowContext,
636
+ params: BaseModel | None = None,
637
+ ) -> WorkflowResult[DataCleaningMetadata, DataCleaningOutputs]:
638
+ """Run data cleaning workflow on dataset."""
639
+ from dataeval_flow.selection import build_selection
640
+
641
+ if not isinstance(context, WorkflowContext):
642
+ return WorkflowResult(
643
+ name=self.name,
644
+ success=False,
645
+ data=self._empty_outputs(),
646
+ errors=[f"Expected WorkflowContext, got {type(context).__name__}"],
647
+ metadata=DataCleaningMetadata(),
648
+ )
649
+
650
+ if params is None:
651
+ return WorkflowResult(
652
+ name=self.name,
653
+ success=False,
654
+ data=self._empty_outputs(),
655
+ errors=["DataCleaningParameters required (no defaults per CR-4.14-G-1)"],
656
+ metadata=DataCleaningMetadata(),
657
+ )
658
+
659
+ if not isinstance(params, DataCleaningParameters):
660
+ return WorkflowResult(
661
+ name=self.name,
662
+ success=False,
663
+ data=self._empty_outputs(),
664
+ errors=[f"Expected DataCleaningParameters, got {type(params).__name__}"],
665
+ metadata=DataCleaningMetadata(),
666
+ )
667
+
668
+ try:
669
+ import time as _time
670
+
671
+ # All arg-type suppressions in this block: MaiteDataset (and Select wrapper)
672
+ # conforms to DataEval's dataset protocol at runtime via duck typing;
673
+ # pyright can't verify cross-library structural conformance.
674
+ from dataeval_flow.cache import selection_repr as _sel_repr
675
+
676
+ # Resolve the single dataset context (cleaning is single-dataset)
677
+ dc = next(iter(context.dataset_contexts.values()))
678
+
679
+ # 1. Apply selection if configured
680
+ dataset = dc.dataset
681
+ if dc.selection_steps:
682
+ logger.info("[1/4] Applying selection (%d steps)…", len(dc.selection_steps))
683
+ _t0 = _time.monotonic()
684
+ dataset = build_selection(dataset, dc.selection_steps) # type: ignore[arg-type]
685
+ logger.info("[1/4] Selection applied in %.1fs", _time.monotonic() - _t0)
686
+
687
+ # Compute selection key (shared by metadata + stats caching)
688
+ sel_key = _sel_repr(dataset)
689
+
690
+ # 2. Build extractor if configured
691
+ extractor = None
692
+ if dc.extractor:
693
+ logger.info("[2/4] Building extractor…")
694
+ _t0 = _time.monotonic()
695
+ extractor = build_extractor(
696
+ extractor_config=dc.extractor,
697
+ transforms=dc.transforms,
698
+ )
699
+ logger.info("[2/4] Extractor built in %.1fs", _time.monotonic() - _t0)
700
+
701
+ # 3–4. Activate cache context so all downstream get_or_compute_*
702
+ # calls automatically use the cache without explicit threading.
703
+ run_ctx = CleaningRunContext(
704
+ extractor_config=dc.extractor,
705
+ transforms=dc.transforms,
706
+ batch_size=dc.batch_size,
707
+ )
708
+ with contextlib.ExitStack() as stack:
709
+ if dc.cache is not None:
710
+ stack.enter_context(active_cache(dc.cache, sel_key))
711
+
712
+ # 3. Build metadata for label stats (cache-aware via active_cache)
713
+ logger.info("[3/4] Loading metadata…")
714
+ _t0 = _time.monotonic()
715
+ metadata = get_or_compute_metadata(dataset)
716
+ logger.info("[3/4] Metadata ready in %.1fs", _time.monotonic() - _t0)
717
+
718
+ # 4. Run cleaning evaluators (cache-aware via active_cache)
719
+ logger.info("[4/4] Running outlier and duplicate detection on %d items…", len(dataset))
720
+ _t0 = _time.monotonic()
721
+ raw = _run_cleaning(
722
+ dataset,
723
+ params,
724
+ extractor,
725
+ metadata, # type: ignore[arg-type]
726
+ run_ctx,
727
+ )
728
+ logger.info(
729
+ "[4/4] Detection complete in %.1fs: %d outliers, %d exact dup groups, %d near dup groups",
730
+ _time.monotonic() - _t0,
731
+ raw.img_outliers.get("count", 0),
732
+ len(raw.duplicates.get("items", {}).get("exact", [])),
733
+ len(raw.duplicates.get("items", {}).get("near", [])),
734
+ )
735
+
736
+ # 5. Generate findings from raw results
737
+ findings = build_findings(raw, metadata, params.health_thresholds, label_source=dc.label_source)
738
+
739
+ # 6. Preparatory mode: compute clean indices (exclude flagged items)
740
+ result_metadata = DataCleaningMetadata(
741
+ mode=params.mode,
742
+ evaluators=["outliers", "duplicates"],
743
+ )
744
+ if params.mode == "preparatory":
745
+ flagged = collect_flagged_indices(raw)
746
+ all_indices = set(range(raw.dataset_size))
747
+ clean_indices = sorted(all_indices - flagged)
748
+ result_metadata.flagged_indices = sorted(flagged)
749
+ result_metadata.clean_indices = clean_indices
750
+ result_metadata.removed_count = len(flagged)
751
+ findings.append(
752
+ Reportable(
753
+ report_type="key_value",
754
+ title="Preparatory Mode",
755
+ data={
756
+ "brief": f"{len(flagged)} flagged, {len(clean_indices)} retained",
757
+ "flagged": len(flagged),
758
+ "retained": len(clean_indices),
759
+ },
760
+ description=(
761
+ f"Preparatory mode: {len(flagged)} items flagged for removal, "
762
+ f"{len(clean_indices)} items retained."
763
+ ),
764
+ )
765
+ )
766
+
767
+ # 7. Build report
768
+ report = DataCleaningReport(
769
+ summary=f"Data cleaning complete. Dataset: {raw.dataset_size} items. Mode: {params.mode}.",
770
+ findings=findings,
771
+ )
772
+
773
+ return WorkflowResult(
774
+ name=self.name,
775
+ success=True,
776
+ data=DataCleaningOutputs(raw=raw, report=report),
777
+ metadata=result_metadata,
778
+ dataset=dataset,
779
+ )
780
+ except Exception as e:
781
+ logger.exception("Workflow '%s' failed", self.name)
782
+ return WorkflowResult(
783
+ name=self.name,
784
+ success=False,
785
+ data=self._empty_outputs(),
786
+ errors=[f"Workflow execution failed: {e}"],
787
+ metadata=DataCleaningMetadata(),
788
+ )
789
+
790
+ def _empty_outputs(self) -> DataCleaningOutputs:
791
+ return DataCleaningOutputs(
792
+ raw=DataCleaningRawOutputs(dataset_size=0),
793
+ report=DataCleaningReport(summary="Workflow failed", findings=[]),
794
+ )