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,1313 @@
1
+ """Data Analysis Workflow — comprehensive quality analysis across dataset splits.
2
+
3
+ Assessments are organized by the issue they help diagnose:
4
+
5
+ - **Image Quality** — anomalous or corrupt images (outliers)
6
+ - **Data Redundancy** — duplicate/near-duplicate images, cross-split leakage
7
+ - **Label Health** — label completeness, distribution, cross-split parity
8
+ - **Metadata Bias** — metadata-factor/label correlations (Balance, Diversity)
9
+ - **Distribution Shift** — embedding-space divergence between splits
10
+ """
11
+
12
+ import contextlib
13
+ import logging
14
+ import warnings
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from itertools import combinations
18
+ from typing import Any, Literal
19
+
20
+ import numpy as np
21
+ import polars as pl
22
+ from dataeval import Embeddings, Metadata
23
+ from dataeval.bias import Balance, Diversity
24
+ from dataeval.core import (
25
+ LabelStatsResult,
26
+ StatsResult,
27
+ divergence_fnn,
28
+ divergence_mst,
29
+ label_parity,
30
+ label_stats,
31
+ )
32
+ from dataeval.flags import ImageStats
33
+ from dataeval.protocols import AnnotatedDataset
34
+ from dataeval.quality import Duplicates, Outliers
35
+ from pydantic import BaseModel
36
+
37
+ from dataeval_flow.cache import active_cache, get_or_compute_metadata, get_or_compute_stats
38
+ from dataeval_flow.cache import selection_repr as _sel_repr
39
+ from dataeval_flow.workflow import WorkflowContext, WorkflowProtocol, WorkflowResult
40
+ from dataeval_flow.workflow.base import Reportable
41
+ from dataeval_flow.workflows.analysis.outputs import (
42
+ BiasResult,
43
+ CrossSplitLabelHealth,
44
+ CrossSplitRedundancy,
45
+ CrossSplitResult,
46
+ DataAnalysisMetadata,
47
+ DataAnalysisOutputs,
48
+ DataAnalysisRawOutputs,
49
+ DataAnalysisReport,
50
+ DistributionShiftResult,
51
+ ImageQualityResult,
52
+ LabelHealthResult,
53
+ RedundancyResult,
54
+ SplitResult,
55
+ )
56
+ from dataeval_flow.workflows.analysis.params import DataAnalysisHealthThresholds, DataAnalysisParameters
57
+
58
+ __all__ = ["DataAnalysisWorkflow"]
59
+
60
+ # Note: Dataset analysis runs outlier detection at image level only
61
+ # (per_target=False). Target-level outlier analysis (individual bounding
62
+ # boxes) is intentionally excluded to focus on image-quality issues.
63
+
64
+ _logger = logging.getLogger(__name__)
65
+
66
+ FLAG_MAP: dict[str, ImageStats] = {
67
+ "dimension": ImageStats.DIMENSION,
68
+ "pixel": ImageStats.PIXEL,
69
+ "visual": ImageStats.VISUAL,
70
+ }
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Shared computation layer
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ @dataclass
79
+ class SplitData:
80
+ """Shared computation results for a single split.
81
+
82
+ Produced by ``_compute_split_data`` and consumed by the per-split
83
+ assessment functions.
84
+ """
85
+
86
+ metadata: Metadata
87
+ calc_result: "StatsResult"
88
+ img_mask: np.ndarray[Any, Any]
89
+ label_stats: "LabelStatsResult"
90
+ embeddings: np.ndarray[Any, Any] | None
91
+ dataset_len: int
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Serialization / conversion helpers
96
+ # ---------------------------------------------------------------------------
97
+
98
+
99
+ def _to_serializable(obj: Any) -> Any:
100
+ """Convert non-JSON-serializable types to plain Python types recursively."""
101
+ if isinstance(obj, dict):
102
+ return {_to_serializable(k): _to_serializable(v) for k, v in obj.items()}
103
+ if isinstance(obj, list):
104
+ return [_to_serializable(v) for v in obj]
105
+ if isinstance(obj, tuple):
106
+ return [_to_serializable(v) for v in obj]
107
+ if isinstance(obj, np.integer):
108
+ return int(obj)
109
+ if isinstance(obj, np.floating):
110
+ return float(obj)
111
+ if isinstance(obj, np.bool_):
112
+ return bool(obj)
113
+ if isinstance(obj, np.ndarray):
114
+ return _to_serializable(obj.tolist())
115
+ if isinstance(obj, frozenset | set):
116
+ return sorted(str(v) for v in obj)
117
+ return obj
118
+
119
+
120
+ def _impute_array(arr: np.ndarray) -> np.ndarray | None:
121
+ """Replace NaN/inf with the median of finite values.
122
+
123
+ Non-RGB images (e.g. 1-channel palette) can overflow float16 in
124
+ ``compute_stats()``, producing inf/NaN that would corrupt the int64 cast
125
+ in ``Metadata.factor_data``.
126
+
127
+ Returns ``None`` when *all* values are non-finite (the factor should
128
+ be skipped entirely).
129
+ """
130
+ finite_mask = np.isfinite(arr)
131
+ if not finite_mask.any():
132
+ return None
133
+ if finite_mask.all():
134
+ return arr
135
+ median = np.median(arr[finite_mask])
136
+ result = arr.copy()
137
+ result[~finite_mask] = median
138
+ return result
139
+
140
+
141
+ def _extract_level_stats(
142
+ calc_result: "StatsResult",
143
+ mask: np.ndarray,
144
+ expected_len: int,
145
+ ) -> dict[str, np.ndarray]:
146
+ """Extract and impute 1-D numeric stats arrays for a given source-index level.
147
+
148
+ Non-numeric arrays (e.g. hash strings) are silently skipped.
149
+ """
150
+ factors: dict[str, np.ndarray] = {}
151
+ for name, arr in calc_result["stats"].items():
152
+ if arr.ndim != 1 or not np.issubdtype(arr.dtype, np.number):
153
+ continue
154
+ level_arr = arr[mask]
155
+ if len(level_arr) != expected_len:
156
+ continue
157
+ imputed = _impute_array(level_arr)
158
+ if imputed is not None:
159
+ factors[name] = imputed
160
+ return factors
161
+
162
+
163
+ def _inject_image_stats(
164
+ metadata: Metadata,
165
+ calc_result: "StatsResult",
166
+ img_mask: np.ndarray,
167
+ n_images: int,
168
+ ) -> None:
169
+ """Inject computed image/target statistics into *metadata* as factors.
170
+
171
+ For **object-detection** datasets ``factor_data`` reads from target-level
172
+ rows, so image-level factors stored at ``level="image"`` would be null
173
+ and corrupt the int64 cast. This helper avoids that by broadcasting
174
+ image-level arrays to target level via ``item_indices`` and adding
175
+ everything at ``level="target"``.
176
+
177
+ For **classification** datasets (no targets) the stats are simply added
178
+ at ``level="image"``.
179
+ """
180
+ is_od = metadata.has_targets()
181
+
182
+ # ── Image-level stats ──────────────────────────────────────────────
183
+ img_factors = _extract_level_stats(calc_result, img_mask, n_images)
184
+
185
+ if is_od:
186
+ # Broadcast image-level → target-level via item_indices
187
+ if img_factors:
188
+ broadcast = {k: v[metadata.item_indices] for k, v in img_factors.items()}
189
+ metadata.add_factors(broadcast, level="target")
190
+
191
+ # ── Target-level stats ─────────────────────────────────────────
192
+ tgt_mask = ~img_mask
193
+ if tgt_mask.any():
194
+ n_targets = int(tgt_mask.sum())
195
+ tgt_factors = _extract_level_stats(calc_result, tgt_mask, n_targets)
196
+ if tgt_factors:
197
+ prefixed = {f"target_{k}": v for k, v in tgt_factors.items()}
198
+ metadata.add_factors(prefixed, level="target")
199
+ else:
200
+ # Classification: image-level rows are used directly by factor_data
201
+ if img_factors:
202
+ metadata.add_factors(img_factors, level="image")
203
+
204
+
205
+ def _compute_metadata_summary(metadata: Metadata) -> dict[str, dict[str, Any]]:
206
+ """Compute per-factor summary statistics from metadata."""
207
+ summary: dict[str, dict[str, Any]] = {}
208
+ df = metadata.image_data
209
+ factor_info = metadata.factor_info
210
+
211
+ for name, info in factor_info.items():
212
+ stats: dict[str, Any] = {"type": info.factor_type}
213
+
214
+ if name not in df.columns:
215
+ summary[name] = stats
216
+ continue
217
+
218
+ col = df[name]
219
+ stats["null_count"] = col.null_count()
220
+
221
+ if info.factor_type == "continuous":
222
+ stats["min"] = col.min()
223
+ stats["max"] = col.max()
224
+ stats["mean"] = col.mean()
225
+ stats["std"] = col.std()
226
+ else:
227
+ stats["unique_values"] = col.n_unique()
228
+ vc = col.value_counts().sort("count", descending=True)
229
+ if len(vc) > 0:
230
+ top = min(10, len(vc))
231
+ values = vc[name].head(top).to_list()
232
+ counts = vc["count"].head(top).to_list()
233
+ stats["top_values"] = dict(zip(values, counts, strict=True))
234
+
235
+ summary[name] = stats
236
+
237
+ return _to_serializable(summary)
238
+
239
+
240
+ def _labels_from_counts(label_counts: Mapping[int, int]) -> np.ndarray:
241
+ """Reconstruct a flat label array from per-class counts."""
242
+ if not label_counts:
243
+ return np.array([], dtype=int)
244
+ return np.concatenate([np.full(count, cls_id) for cls_id, count in label_counts.items()])
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Compute shared split data
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ def _resolve_outlier_flags(params: DataAnalysisParameters) -> ImageStats:
253
+ """Resolve outlier flags from analysis parameters."""
254
+ flags = ImageStats.NONE
255
+ for name in params.outlier_flags:
256
+ flags |= FLAG_MAP[name]
257
+ return flags
258
+
259
+
260
+ def _compute_split_data(
261
+ dataset: "AnnotatedDataset[Any]",
262
+ params: DataAnalysisParameters,
263
+ extractor: Embeddings | None = None,
264
+ split_name: str = "default",
265
+ ) -> SplitData:
266
+ """Compute shared data for a single split.
267
+
268
+ Builds metadata, runs ``compute_stats`` (via shared cache),
269
+ injects image stats, computes label stats, and extracts embeddings.
270
+ The result is a ``SplitData`` object consumed by the per-split
271
+ assessment functions.
272
+ """
273
+ _logger.info(" Processing metadata for '%s' ...", split_name)
274
+ metadata = get_or_compute_metadata(dataset)
275
+
276
+ # Single compute_stats() call — combines image-stat, outlier-stat,
277
+ # and hash flags into one pass over the dataset.
278
+ is_od = metadata.has_targets()
279
+ _logger.info(" Computing image statistics for '%s' ...", split_name)
280
+ outlier_flags = _resolve_outlier_flags(params)
281
+ all_flags = outlier_flags | ImageStats.HASH
282
+ calc_result = get_or_compute_stats(
283
+ desired_flags=all_flags,
284
+ dataset=dataset,
285
+ per_image=True,
286
+ per_target=is_od,
287
+ )
288
+ source_index = calc_result["source_index"]
289
+ img_mask = np.array([si.target is None for si in source_index])
290
+
291
+ # Inject image stats as metadata factors for bias analysis
292
+ if params.include_image_stats:
293
+ _inject_image_stats(metadata, calc_result, img_mask, len(dataset)) # type: ignore[arg-type]
294
+
295
+ # Label statistics
296
+ _logger.info(" Computing label statistics for '%s' ...", split_name)
297
+ index2label = dataset.metadata.get("index2label")
298
+ ls = label_stats(
299
+ class_labels=metadata.class_labels,
300
+ item_indices=metadata.item_indices,
301
+ index2label=index2label,
302
+ image_count=len(dataset),
303
+ )
304
+
305
+ # Embeddings (optional)
306
+ emb = None
307
+ if extractor is not None:
308
+ _logger.info(" Extracting embeddings for '%s' ...", split_name)
309
+ emb = np.asarray(extractor)
310
+
311
+ return SplitData(
312
+ metadata=metadata,
313
+ calc_result=calc_result, # type: ignore[arg-type]
314
+ img_mask=img_mask,
315
+ label_stats=ls,
316
+ embeddings=emb,
317
+ dataset_len=len(dataset),
318
+ )
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Per-split assessment functions
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def _assess_image_quality(
327
+ data: SplitData,
328
+ outlier_method: Literal["adaptive", "zscore", "modzscore", "iqr"],
329
+ outlier_threshold: float | None = None,
330
+ ) -> ImageQualityResult:
331
+ """Assess image quality via outlier detection."""
332
+ _logger.info(" Detecting outliers ...")
333
+
334
+ # Filter to image-level entries only
335
+ img_calc: StatsResult = {
336
+ "source_index": [si for si, m in zip(data.calc_result["source_index"], data.img_mask, strict=True) if m],
337
+ "object_count": data.calc_result["object_count"],
338
+ "invalid_box_count": data.calc_result["invalid_box_count"],
339
+ "image_count": data.calc_result["image_count"],
340
+ "stats": {k: v[data.img_mask] for k, v in data.calc_result["stats"].items()},
341
+ }
342
+
343
+ outliers_eval = Outliers(outlier_threshold=(f"{outlier_method}", outlier_threshold))
344
+ outlier_df = outliers_eval.from_stats(img_calc).data()
345
+
346
+ if len(outlier_df) == 0:
347
+ return ImageQualityResult(outlier_count=0, outlier_rate=0.0, outlier_summary={})
348
+
349
+ outlier_count: int = outlier_df["item_index"].n_unique()
350
+ metric_agg = outlier_df.group_by("metric_name").agg(pl.col("item_index").n_unique().alias("count"))
351
+ outlier_by_metric: dict[str, int] = dict(metric_agg.iter_rows())
352
+
353
+ return ImageQualityResult(
354
+ outlier_count=outlier_count,
355
+ outlier_rate=outlier_count / max(data.dataset_len, 1),
356
+ outlier_summary=_to_serializable(outlier_by_metric),
357
+ )
358
+
359
+
360
+ def _assess_redundancy(data: SplitData) -> RedundancyResult:
361
+ """Assess data redundancy via duplicate detection."""
362
+ _logger.info(" Detecting duplicates ...")
363
+ dup_result = Duplicates().from_stats(data.calc_result)
364
+ exact_groups = dup_result.items.exact or []
365
+ near_groups = dup_result.items.near or []
366
+
367
+ near_index_groups = [list(g[0]) if isinstance(g, tuple) else list(g.indices) for g in near_groups]
368
+
369
+ return RedundancyResult(
370
+ exact_duplicate_groups=len(exact_groups),
371
+ near_duplicate_groups=len(near_groups),
372
+ exact_duplicates_count=sum(len(g) for g in exact_groups),
373
+ near_duplicates_count=sum(len(g) for g in near_index_groups),
374
+ exact_groups=[list(g) for g in exact_groups],
375
+ near_groups=near_index_groups,
376
+ )
377
+
378
+
379
+ def _assess_label_health(data: SplitData) -> LabelHealthResult:
380
+ """Assess label completeness and distribution."""
381
+ ls = data.label_stats
382
+ class_dist = _to_serializable(
383
+ {ls["index2label"].get(k, str(k)): v for k, v in ls["label_counts_per_class"].items()}
384
+ )
385
+
386
+ return LabelHealthResult(
387
+ num_classes=ls["class_count"],
388
+ class_distribution=class_dist,
389
+ empty_images=list(ls["empty_image_indices"]),
390
+ )
391
+
392
+
393
+ def _assess_bias(
394
+ data: SplitData,
395
+ balance: bool,
396
+ diversity_method: Literal["simpson", "shannon"] | None,
397
+ ) -> BiasResult:
398
+ """Assess metadata bias via Balance and Diversity evaluators."""
399
+ if balance or diversity_method:
400
+ _logger.info(" Running bias analysis ...")
401
+
402
+ balance_summary: dict[str, Any] | None = None
403
+ diversity_summary: dict[str, Any] | None = None
404
+
405
+ # Suppress sklearn warning about high-cardinality discrete factors
406
+ # being used as y in mutual_info_classif — expected when injected
407
+ # image stats have many unique integer values.
408
+ with warnings.catch_warnings():
409
+ warnings.filterwarnings("ignore", message=".*unique classes.*", module="sklearn")
410
+
411
+ if balance and data.metadata.factor_names:
412
+ bal_result = Balance().evaluate(data.metadata)
413
+ balance_summary = _to_serializable(
414
+ {
415
+ "balance": bal_result.balance.to_dicts(),
416
+ "factors": bal_result.factors.to_dicts(),
417
+ "classwise": bal_result.classwise.to_dicts(),
418
+ }
419
+ )
420
+
421
+ if diversity_method is not None and data.metadata.factor_names:
422
+ div_result = Diversity(method=diversity_method).evaluate(data.metadata)
423
+ diversity_summary = _to_serializable(
424
+ {
425
+ "factors": div_result.factors.to_dicts(),
426
+ "classwise": div_result.classwise.to_dicts(),
427
+ }
428
+ )
429
+
430
+ _logger.info(" Summarizing metadata ...")
431
+ meta_summary = _compute_metadata_summary(data.metadata)
432
+
433
+ return BiasResult(
434
+ metadata_factors=list(data.metadata.factor_names),
435
+ metadata_summary=meta_summary,
436
+ balance_summary=balance_summary,
437
+ diversity_summary=diversity_summary,
438
+ )
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Cross-split assessment functions
443
+ # ---------------------------------------------------------------------------
444
+
445
+
446
+ def _assess_cross_redundancy(
447
+ calc_a: "StatsResult",
448
+ calc_b: "StatsResult",
449
+ name_a: str,
450
+ name_b: str,
451
+ ) -> CrossSplitRedundancy:
452
+ """Detect duplicate images across two splits (data leakage).
453
+
454
+ When ``Duplicates().from_stats([stats_a, stats_b])`` is called with a
455
+ list of stats, each row in the output DataFrame is a duplicate group.
456
+ We only report groups where both datasets have members (true cross-split
457
+ leakage), ignoring within-split duplicates.
458
+ """
459
+ dup_result = Duplicates().from_stats([calc_a, calc_b])
460
+ ds_names = {0: name_a, 1: name_b}
461
+
462
+ exact_groups, near_groups = (
463
+ _extract_cross_groups(dup_result.items.data(), dup_type, ds_names) for dup_type in ("exact", "near")
464
+ )
465
+
466
+ exact_count = sum(sum(len(v) for v in g.values()) for g in exact_groups)
467
+ near_count = sum(sum(len(v) for v in g.values()) for g in near_groups)
468
+
469
+ return CrossSplitRedundancy(
470
+ duplicate_leakage=_to_serializable(
471
+ {
472
+ "exact_count": exact_count,
473
+ "near_count": near_count,
474
+ "exact_groups": exact_groups,
475
+ "near_groups": near_groups,
476
+ }
477
+ )
478
+ )
479
+
480
+
481
+ def _extract_cross_groups(
482
+ df: pl.DataFrame,
483
+ dup_type: str,
484
+ ds_names: dict[int, str],
485
+ ) -> list[dict[str, list[int]]]:
486
+ """Extract cross-dataset duplicate groups from a multi-dataset DuplicatesOutput.
487
+
488
+ Only includes groups where members span both datasets (true cross-split
489
+ duplicates). Groups that are entirely within one dataset are skipped.
490
+ """
491
+ filtered = df.filter((pl.col("dup_type") == dup_type) & (pl.col("level") == "item"))
492
+ groups: list[dict[str, list[int]]] = []
493
+ for row in filtered.iter_rows(named=True):
494
+ by_ds: dict[str, list[int]] = {}
495
+ for item, ds_idx in zip(row["item_indices"], row["dataset_indices"], strict=True):
496
+ by_ds.setdefault(ds_names[ds_idx], []).append(item)
497
+ # Only keep groups that span both datasets (true leakage)
498
+ if len(by_ds) >= 2:
499
+ groups.append({k: sorted(v) for k, v in by_ds.items()})
500
+ return groups
501
+
502
+
503
+ def _assess_cross_label_health(
504
+ ls_a: "LabelStatsResult",
505
+ ls_b: "LabelStatsResult",
506
+ name_a: str,
507
+ name_b: str,
508
+ ) -> CrossSplitLabelHealth:
509
+ """Compare label distributions and test parity between two splits."""
510
+ # Label overlap
511
+ classes_a = set(ls_a["label_counts_per_class"].keys())
512
+ classes_b = set(ls_b["label_counts_per_class"].keys())
513
+
514
+ shared = classes_a & classes_b
515
+ only_a = classes_a - classes_b
516
+ only_b = classes_b - classes_a
517
+
518
+ i2l: dict[int, str] = {**ls_a["index2label"], **ls_b["index2label"]}
519
+
520
+ total_a = max(ls_a["label_count"], 1)
521
+ total_b = max(ls_b["label_count"], 1)
522
+
523
+ proportion_diff: dict[str, dict[str, float]] = {}
524
+ for cls in sorted(shared):
525
+ label = i2l.get(cls, str(cls))
526
+ prop_a = ls_a["label_counts_per_class"][cls] / total_a
527
+ prop_b = ls_b["label_counts_per_class"][cls] / total_b
528
+ proportion_diff[label] = {
529
+ name_a: round(prop_a, 4),
530
+ name_b: round(prop_b, 4),
531
+ "difference": round(abs(prop_a - prop_b), 4),
532
+ }
533
+
534
+ label_overlap = _to_serializable(
535
+ {
536
+ "shared_classes": [i2l.get(c, str(c)) for c in sorted(shared)],
537
+ f"{name_a}_only": [i2l.get(c, str(c)) for c in sorted(only_a)],
538
+ f"{name_b}_only": [i2l.get(c, str(c)) for c in sorted(only_b)],
539
+ "proportion_comparison": proportion_diff,
540
+ }
541
+ )
542
+
543
+ # Label parity — chi-squared test
544
+ num_classes = max(ls_a["class_count"], ls_b["class_count"])
545
+ labels_a = _labels_from_counts(ls_a["label_counts_per_class"])
546
+ labels_b = _labels_from_counts(ls_b["label_counts_per_class"])
547
+ if num_classes > 0 and len(labels_a) > 0 and len(labels_b) > 0:
548
+ lp_result = label_parity(labels_a, labels_b, num_classes=num_classes)
549
+ lp_summary: dict[str, Any] = _to_serializable(
550
+ {
551
+ "chi_squared": lp_result["chi_squared"],
552
+ "p_value": lp_result["p_value"],
553
+ "significant": lp_result["p_value"] < 0.05,
554
+ }
555
+ )
556
+ else:
557
+ lp_summary = {"chi_squared": 0.0, "p_value": 1.0, "significant": False}
558
+
559
+ return CrossSplitLabelHealth(
560
+ label_overlap=label_overlap,
561
+ label_parity=lp_summary,
562
+ )
563
+
564
+
565
+ def _assess_distribution_shift(
566
+ emb_a: np.ndarray | None,
567
+ emb_b: np.ndarray | None,
568
+ divergence_method: Literal["mst", "fnn"] | None,
569
+ ) -> DistributionShiftResult:
570
+ """Compute embedding-space divergence between two splits."""
571
+ if divergence_method is None or emb_a is None or emb_b is None:
572
+ return DistributionShiftResult()
573
+
574
+ div_fn = divergence_mst if divergence_method == "mst" else divergence_fnn
575
+ div_result = div_fn(emb_a, emb_b)
576
+ return DistributionShiftResult(
577
+ divergence=float(div_result["divergence"]),
578
+ divergence_method=divergence_method,
579
+ )
580
+
581
+
582
+ # ---------------------------------------------------------------------------
583
+ # Findings builders (one per assessment area)
584
+ # ---------------------------------------------------------------------------
585
+
586
+
587
+ def _finding_image_quality(
588
+ splits: dict[str, SplitResult],
589
+ thresholds: DataAnalysisHealthThresholds,
590
+ ) -> Reportable:
591
+ """Cross-split image quality comparison table."""
592
+ rows: list[dict[str, Any]] = []
593
+ total_outliers = 0
594
+ worst_pct = 0.0
595
+
596
+ for name, sr in splits.items():
597
+ iq = sr.image_quality
598
+ n = sr.num_samples
599
+ pct = round((iq.outlier_count / max(n, 1)) * 100, 1)
600
+ top = sorted(iq.outlier_summary.items(), key=lambda x: x[1], reverse=True)[:3]
601
+ top_str = " ".join(f"{k}({v})" for k, v in top) if top else "-"
602
+ rows.append({"Split": name, "Items": n, "Outliers": iq.outlier_count, "Rate": f"{pct}%", "Top Flags": top_str})
603
+ total_outliers += iq.outlier_count
604
+ worst_pct = max(worst_pct, pct)
605
+
606
+ severity: Literal["ok", "info", "warning"] = "warning" if worst_pct > thresholds.image_outliers else "info"
607
+ if total_outliers == 0:
608
+ severity = "ok"
609
+
610
+ # Build brief: compact per-split summary
611
+ parts = [f"{sr.image_quality.outlier_count}/{sr.num_samples}" for sr in splits.values()]
612
+ brief = f"{total_outliers} outliers ({', '.join(parts)})"
613
+
614
+ return Reportable(
615
+ report_type="pivot_table",
616
+ severity=severity,
617
+ title="Image Quality",
618
+ data={
619
+ "brief": brief,
620
+ "table_data": rows,
621
+ "table_headers": ["Split", "Items", "Outliers", "Rate", "Top Flags"],
622
+ },
623
+ description=f"{total_outliers} images flagged across {len(splits)} split(s).",
624
+ )
625
+
626
+
627
+ def _finding_redundancy(
628
+ splits: dict[str, SplitResult],
629
+ thresholds: DataAnalysisHealthThresholds,
630
+ ) -> Reportable:
631
+ """Cross-split redundancy comparison table."""
632
+ rows: list[dict[str, Any]] = []
633
+ any_dupes = False
634
+ worst_sev: Literal["ok", "info", "warning"] = "ok"
635
+
636
+ for name, sr in splits.items():
637
+ rd = sr.redundancy
638
+ n = sr.num_samples
639
+ exact_pct = round((rd.exact_duplicates_count / max(n, 1)) * 100, 1)
640
+ near_pct = round((rd.near_duplicates_count / max(n, 1)) * 100, 1)
641
+ rows.append(
642
+ {
643
+ "Split": name,
644
+ "Exact": f"{rd.exact_duplicates_count} ({exact_pct}%)",
645
+ "Near": f"{rd.near_duplicates_count} ({near_pct}%)",
646
+ }
647
+ )
648
+ if rd.exact_duplicate_groups or rd.near_duplicate_groups:
649
+ any_dupes = True
650
+ if exact_pct > thresholds.exact_duplicates or near_pct > thresholds.near_duplicates:
651
+ worst_sev = "warning"
652
+ elif any_dupes and worst_sev == "ok":
653
+ worst_sev = "info"
654
+
655
+ if not any_dupes:
656
+ return Reportable(
657
+ report_type="key_value",
658
+ severity="ok",
659
+ title="Redundancy",
660
+ data={"brief": "No duplicates in any split"},
661
+ description="No duplicates detected.",
662
+ )
663
+
664
+ total_exact = sum(sr.redundancy.exact_duplicates_count for sr in splits.values())
665
+ total_near = sum(sr.redundancy.near_duplicates_count for sr in splits.values())
666
+ brief = f"{total_exact} exact, {total_near} near duplicates"
667
+
668
+ return Reportable(
669
+ report_type="pivot_table",
670
+ severity=worst_sev,
671
+ title="Redundancy",
672
+ data={
673
+ "brief": brief,
674
+ "table_data": rows,
675
+ "table_headers": ["Split", "Exact", "Near"],
676
+ },
677
+ description=f"{total_exact} exact + {total_near} near duplicates across {len(splits)} split(s).",
678
+ )
679
+
680
+
681
+ def _finding_label_balance(
682
+ splits: dict[str, SplitResult],
683
+ thresholds: DataAnalysisHealthThresholds,
684
+ ) -> Reportable:
685
+ """Cross-split label balance comparison table."""
686
+ # Collect all class names across splits
687
+ all_classes: dict[str, dict[str, int]] = {}
688
+ split_names = list(splits.keys())
689
+ imbalance_ratios: dict[str, float] = {}
690
+
691
+ split_totals: dict[str, int] = {}
692
+ for name, sr in splits.items():
693
+ lh = sr.label_health
694
+ counts = list(lh.class_distribution.values()) if lh.class_distribution else []
695
+ has_empty = bool(counts) and min(counts) == 0
696
+ ratio = round(max(counts) / min(counts), 1) if counts and not has_empty else 0.0
697
+ imbalance_ratios[name] = ratio
698
+ split_totals[name] = sum(counts)
699
+ for cls, count in lh.class_distribution.items():
700
+ all_classes.setdefault(cls, {})[name] = count
701
+
702
+ # Build rows sorted by total count descending, with % of split total
703
+ rows: list[dict[str, Any]] = []
704
+ for cls in sorted(all_classes, key=lambda c: sum(all_classes[c].values()), reverse=True):
705
+ row: dict[str, Any] = {"Class": cls}
706
+ for sn in split_names:
707
+ count = all_classes[cls].get(sn, 0)
708
+ total = split_totals.get(sn, 0)
709
+ pct = round(count / total * 100) if total else 0
710
+ row[sn] = f"{count} ({pct}%)"
711
+ rows.append(row)
712
+
713
+ # Determine severity
714
+ num_classes = len(all_classes)
715
+ worst_ratio = max(imbalance_ratios.values()) if imbalance_ratios else 0.0
716
+ any_empty = any(len(sr.label_health.empty_images) > 0 for sr in splits.values())
717
+ severity: Literal["ok", "info", "warning"] = "info"
718
+ if any_empty or worst_ratio > thresholds.class_label_imbalance:
719
+ severity = "warning"
720
+
721
+ # Footer with imbalance ratios
722
+ footer_lines: list[str] = []
723
+ footer_lines.append("Imbalance ratio:")
724
+ for n, r in imbalance_ratios.items():
725
+ footer_lines.append(f" {n}: {r}:1")
726
+ for name, sr in splits.items():
727
+ empty = len(sr.label_health.empty_images)
728
+ if empty:
729
+ footer_lines.append(f"{name}: {empty} images with no labels")
730
+
731
+ brief = f"{num_classes} classes, imbalance {'/'.join(f'{r}' for r in imbalance_ratios.values())}:1"
732
+
733
+ return Reportable(
734
+ report_type="pivot_table",
735
+ severity=severity,
736
+ title="Label Balance",
737
+ data={
738
+ "brief": brief,
739
+ "table_data": rows,
740
+ "table_headers": ["Class"] + split_names,
741
+ "footer_lines": footer_lines,
742
+ },
743
+ description=f"{num_classes} classes across {len(splits)} split(s).",
744
+ )
745
+
746
+
747
+ def _factor_name(f: dict[str, Any]) -> str:
748
+ """Extract factor name from a factor dict, tolerating key variations."""
749
+ return str(f.get("factor_name", f.get("factor", f.get("name", "?"))))
750
+
751
+
752
+ def _extract_balance_insights(balance_summary: dict[str, Any] | None) -> list[str]:
753
+ """Extract top high-MI factor names from balance results."""
754
+ if balance_summary is None:
755
+ return []
756
+ insights: list[str] = []
757
+ # "balance" holds factor-to-class MI; "factors" holds inter-factor MI
758
+ bal_factors = balance_summary.get("balance", balance_summary.get("factors", []))
759
+ sorted_factors = sorted(bal_factors, key=lambda f: f.get("mi_value", f.get("score", 0)), reverse=True)
760
+ for f in sorted_factors[:3]:
761
+ score = f.get("mi_value", f.get("score", 0))
762
+ if score > 0.1:
763
+ insights.append(f"{_factor_name(f)} (MI={score:.2f})")
764
+ return insights
765
+
766
+
767
+ def _extract_diversity_insights(diversity_summary: dict[str, Any] | None) -> list[str]:
768
+ """Extract top low-diversity factor names from diversity results."""
769
+ if diversity_summary is None:
770
+ return []
771
+ insights: list[str] = []
772
+ div_factors = diversity_summary.get("factors", [])
773
+ sorted_factors = sorted(div_factors, key=lambda f: f.get("diversity_value", f.get("score", 0)))
774
+ for f in sorted_factors[:3]:
775
+ score = f.get("diversity_value", f.get("score", 0))
776
+ if score < 0.5:
777
+ insights.append(f"{_factor_name(f)} ({score:.2f})")
778
+ return insights
779
+
780
+
781
+ def _finding_bias(
782
+ splits: dict[str, SplitResult],
783
+ ) -> Reportable:
784
+ """Cross-split bias summary."""
785
+ balance_by_split: dict[str, list[str]] = {}
786
+ diversity_by_split: dict[str, list[str]] = {}
787
+ n_factors = 0
788
+ any_warning = False
789
+
790
+ for name, sr in splits.items():
791
+ bias = sr.bias
792
+ n_factors = max(n_factors, len(bias.metadata_factors))
793
+ bal = _extract_balance_insights(bias.balance_summary)
794
+ div = _extract_diversity_insights(bias.diversity_summary)
795
+ if bal:
796
+ balance_by_split[name] = bal
797
+ any_warning = True
798
+ if div:
799
+ diversity_by_split[name] = div
800
+ any_warning = True
801
+
802
+ brief = f"{n_factors} factors checked"
803
+ if any_warning:
804
+ brief += ", issues found"
805
+
806
+ # If no issues at all, return simple key_value
807
+ if not balance_by_split and not diversity_by_split:
808
+ return Reportable(
809
+ report_type="key_value",
810
+ severity="info",
811
+ title="Bias",
812
+ data={
813
+ "brief": brief,
814
+ "detail_lines": ["No high-MI or low-diversity factors in any split."],
815
+ },
816
+ description=f"{n_factors} metadata factors checked across {len(splits)} split(s).",
817
+ )
818
+
819
+ # Build table rows: one row per split
820
+ all_split_names = list(splits.keys())
821
+ rows: list[dict[str, str]] = []
822
+ for sn in all_split_names:
823
+ bal_items = balance_by_split.get(sn, [])
824
+ div_items = diversity_by_split.get(sn, [])
825
+ rows.append(
826
+ {
827
+ "Data Split": sn,
828
+ "Top High MI Factors": "\n".join(bal_items) if bal_items else "-",
829
+ "Low Diversity Factors": "\n".join(div_items) if div_items else "-",
830
+ }
831
+ )
832
+
833
+ return Reportable(
834
+ report_type="pivot_table",
835
+ severity="warning" if any_warning else "info",
836
+ title="Bias",
837
+ data={
838
+ "brief": brief,
839
+ "table_data": rows,
840
+ "table_headers": ["Data Split", "Top High MI Factors", "Low Diversity Factors"],
841
+ },
842
+ description=f"{n_factors} metadata factors checked across {len(splits)} split(s).",
843
+ )
844
+
845
+
846
+ def _finding_label_overlap(
847
+ cross_split: dict[str, CrossSplitResult],
848
+ ) -> Reportable:
849
+ """Aggregate label overlap across all split pairs."""
850
+ rows: list[dict[str, Any]] = []
851
+ total_exclusive = 0
852
+
853
+ for pair_name, csr in cross_split.items():
854
+ overlap = csr.label_health.label_overlap
855
+ shared = overlap.get("shared_classes", [])
856
+ exclusive = sum(len(v) for k, v in overlap.items() if k.endswith("_only") and isinstance(v, list))
857
+ total_exclusive += exclusive
858
+ status = f"{exclusive} exclusive" if exclusive else "all shared"
859
+ rows.append({"Pair": pair_name, "Shared": len(shared), "Exclusive": exclusive, "Status": status})
860
+
861
+ if total_exclusive:
862
+ brief = f"{total_exclusive} exclusive classes across pairs"
863
+ severity: Literal["ok", "info", "warning"] = "warning"
864
+ else:
865
+ # Grab class count from first pair
866
+ first = next(iter(cross_split.values()))
867
+ n = len(first.label_health.label_overlap.get("shared_classes", []))
868
+ brief = f"All {n} classes shared across splits"
869
+ severity = "ok"
870
+
871
+ if len(rows) == 1:
872
+ return Reportable(
873
+ report_type="key_value",
874
+ severity=severity,
875
+ title="Label Overlap",
876
+ data={"brief": brief},
877
+ description=brief + ".",
878
+ )
879
+
880
+ return Reportable(
881
+ report_type="pivot_table",
882
+ severity=severity,
883
+ title="Label Overlap",
884
+ data={
885
+ "brief": brief,
886
+ "table_data": rows,
887
+ "table_headers": ["Pair", "Shared", "Exclusive", "Status"],
888
+ },
889
+ description=brief + ".",
890
+ )
891
+
892
+
893
+ def _finding_label_parity(
894
+ cross_split: dict[str, CrossSplitResult],
895
+ ) -> Reportable | None:
896
+ """Aggregate label parity across all split pairs."""
897
+ rows: list[dict[str, Any]] = []
898
+ any_sig = False
899
+
900
+ for pair_name, csr in cross_split.items():
901
+ if csr.label_health.label_parity is None:
902
+ continue
903
+ p = csr.label_health.label_parity.get("p_value", 1.0)
904
+ sig = csr.label_health.label_parity.get("significant", False)
905
+ if sig:
906
+ any_sig = True
907
+ rows.append({"Pair": pair_name, "p-value": f"{p:.2g}", "Significant": "yes" if sig else "no"})
908
+
909
+ if not rows:
910
+ return None
911
+
912
+ n_sig = sum(1 for r in rows if r["Significant"] == "yes")
913
+ brief = f"{n_sig}/{len(rows)} pair(s) significantly different" if any_sig else "No significant differences"
914
+
915
+ if len(rows) == 1:
916
+ return Reportable(
917
+ report_type="key_value",
918
+ severity="warning" if any_sig else "ok",
919
+ title="Label Parity",
920
+ data={"brief": brief},
921
+ description=f"Chi-squared test: {brief}.",
922
+ )
923
+
924
+ return Reportable(
925
+ report_type="pivot_table",
926
+ severity="warning" if any_sig else "ok",
927
+ title="Label Parity",
928
+ data={
929
+ "brief": brief,
930
+ "table_data": rows,
931
+ "table_headers": ["Pair", "p-value", "Significant"],
932
+ },
933
+ description=f"Chi-squared test: {brief}.",
934
+ )
935
+
936
+
937
+ def _finding_leakage(
938
+ cross_split: dict[str, CrossSplitResult],
939
+ ) -> Reportable:
940
+ """Aggregate duplicate leakage across all split pairs."""
941
+ rows: list[dict[str, Any]] = []
942
+ total_exact = 0
943
+ total_near = 0
944
+
945
+ for pair_name, csr in cross_split.items():
946
+ leakage = csr.redundancy.duplicate_leakage
947
+ exact = leakage.get("exact_count", 0)
948
+ near = leakage.get("near_count", 0)
949
+ total_exact += exact
950
+ total_near += near
951
+ rows.append({"Pair": pair_name, "Exact": exact, "Near": near})
952
+
953
+ any_leakage = total_exact > 0 or total_near > 0
954
+ if not any_leakage:
955
+ return Reportable(
956
+ report_type="key_value",
957
+ severity="ok",
958
+ title="Leakage",
959
+ data={"brief": "No cross-split duplicates"},
960
+ description="No cross-split duplicates detected.",
961
+ )
962
+
963
+ parts = []
964
+ if total_exact:
965
+ parts.append(f"{total_exact} exact")
966
+ if total_near:
967
+ parts.append(f"{total_near} near")
968
+ brief = f"{' + '.join(parts)} cross-split duplicates"
969
+
970
+ if len(rows) == 1:
971
+ return Reportable(
972
+ report_type="key_value",
973
+ severity="warning",
974
+ title="Leakage",
975
+ data={"brief": brief},
976
+ description=f"{brief} (data leakage).",
977
+ )
978
+
979
+ return Reportable(
980
+ report_type="pivot_table",
981
+ severity="warning",
982
+ title="Leakage",
983
+ data={
984
+ "brief": brief,
985
+ "table_data": rows,
986
+ "table_headers": ["Pair", "Exact", "Near"],
987
+ },
988
+ description=f"{brief} (data leakage).",
989
+ )
990
+
991
+
992
+ def _divergence_level(div: float, threshold: float) -> tuple[str, Literal["ok", "info", "warning"]]:
993
+ """Classify divergence into (level, severity)."""
994
+ if div > threshold:
995
+ return "high", "warning"
996
+ if div > threshold * 0.4:
997
+ return "moderate", "info"
998
+ return "low", "ok"
999
+
1000
+
1001
+ def _finding_distribution_shift(
1002
+ cross_split: dict[str, CrossSplitResult],
1003
+ thresholds: DataAnalysisHealthThresholds,
1004
+ ) -> Reportable | None:
1005
+ """Aggregate distribution shift across all split pairs."""
1006
+ rows: list[dict[str, Any]] = []
1007
+ worst_sev: Literal["ok", "info", "warning"] = "ok"
1008
+ sev_rank = {"ok": 0, "info": 1, "warning": 2}
1009
+
1010
+ for pair_name, csr in cross_split.items():
1011
+ ds = csr.distribution_shift
1012
+ if ds.divergence is None:
1013
+ continue
1014
+ level, sev = _divergence_level(ds.divergence, thresholds.distribution_shift)
1015
+ if sev_rank[sev] > sev_rank[worst_sev]:
1016
+ worst_sev = sev
1017
+ row = {"Pair": pair_name, "Divergence": f"{ds.divergence:.4f}", "Method": ds.divergence_method, "Level": level}
1018
+ rows.append(row)
1019
+
1020
+ if not rows:
1021
+ return None
1022
+
1023
+ levels = [r["Level"] for r in rows]
1024
+ if "high" in levels:
1025
+ brief = f"{levels.count('high')}/{len(rows)} pair(s) high divergence"
1026
+ elif "moderate" in levels:
1027
+ brief = f"{levels.count('moderate')}/{len(rows)} pair(s) moderate divergence"
1028
+ else:
1029
+ brief = "Low divergence across all pairs"
1030
+
1031
+ if len(rows) == 1:
1032
+ r = rows[0]
1033
+ brief = f"{r['Level']} divergence: {r['Divergence']} ({r['Method']})"
1034
+ return Reportable(
1035
+ report_type="key_value",
1036
+ severity=worst_sev,
1037
+ title="Distribution Shift",
1038
+ data={"brief": brief},
1039
+ description=f"{brief.capitalize()}.",
1040
+ )
1041
+
1042
+ return Reportable(
1043
+ report_type="pivot_table",
1044
+ severity=worst_sev,
1045
+ title="Distribution Shift",
1046
+ data={
1047
+ "brief": brief,
1048
+ "table_data": rows,
1049
+ "table_headers": ["Pair", "Divergence", "Method", "Level"],
1050
+ },
1051
+ description=brief + ".",
1052
+ )
1053
+
1054
+
1055
+ def _build_findings(
1056
+ splits: dict[str, SplitResult],
1057
+ cross_split: dict[str, CrossSplitResult],
1058
+ thresholds: DataAnalysisHealthThresholds,
1059
+ ) -> list[Reportable]:
1060
+ """Generate human-readable findings from analysis results."""
1061
+ findings: list[Reportable] = []
1062
+
1063
+ # Per-split metrics as cross-split comparison tables
1064
+ findings.append(_finding_image_quality(splits, thresholds))
1065
+ findings.append(_finding_redundancy(splits, thresholds))
1066
+ findings.append(_finding_label_balance(splits, thresholds))
1067
+ findings.append(_finding_bias(splits))
1068
+
1069
+ # Cross-split assessments aggregated into one finding each
1070
+ if cross_split:
1071
+ findings.append(_finding_label_overlap(cross_split))
1072
+ parity = _finding_label_parity(cross_split)
1073
+ if parity:
1074
+ findings.append(parity)
1075
+ findings.append(_finding_leakage(cross_split))
1076
+ shift = _finding_distribution_shift(cross_split, thresholds)
1077
+ if shift:
1078
+ findings.append(shift)
1079
+
1080
+ return findings
1081
+
1082
+
1083
+ # ---------------------------------------------------------------------------
1084
+ # Workflow
1085
+ # ---------------------------------------------------------------------------
1086
+
1087
+
1088
+ class DataAnalysisWorkflow(WorkflowProtocol[DataAnalysisMetadata, DataAnalysisOutputs]):
1089
+ """Comprehensive quality analysis across dataset splits.
1090
+
1091
+ Supports two modes of input via ``WorkflowContext``:
1092
+
1093
+ **Multi-split** — ``context.dataset_contexts`` has 2+ entries (e.g. train/test):
1094
+ Each entry is analyzed independently, and pairwise cross-split
1095
+ comparisons (label overlap, embedding divergence, duplicate leakage)
1096
+ are computed.
1097
+
1098
+ **Single omnibus** — single dataset via ``context.dataset`` (e.g. COCO/YOLO):
1099
+ The single dataset is analyzed as one split. No cross-split analysis
1100
+ is performed.
1101
+ """
1102
+
1103
+ @property
1104
+ def name(self) -> str:
1105
+ """Workflow identifier."""
1106
+ return "data-analysis"
1107
+
1108
+ @property
1109
+ def description(self) -> str:
1110
+ """Human-readable description."""
1111
+ return "Comprehensive quality analysis across dataset splits"
1112
+
1113
+ @property
1114
+ def params_schema(self) -> type[DataAnalysisParameters]:
1115
+ """Pydantic model for workflow parameters."""
1116
+ return DataAnalysisParameters
1117
+
1118
+ @property
1119
+ def output_schema(self) -> type[DataAnalysisOutputs]:
1120
+ """Pydantic model for workflow output."""
1121
+ return DataAnalysisOutputs
1122
+
1123
+ def execute(
1124
+ self,
1125
+ context: WorkflowContext,
1126
+ params: BaseModel | None = None,
1127
+ ) -> WorkflowResult[DataAnalysisMetadata, DataAnalysisOutputs]:
1128
+ """Run data analysis workflow."""
1129
+ if not isinstance(context, WorkflowContext):
1130
+ return WorkflowResult(
1131
+ name=self.name,
1132
+ success=False,
1133
+ data=self._empty_outputs(),
1134
+ metadata=DataAnalysisMetadata(),
1135
+ errors=[f"Expected WorkflowContext, got {type(context).__name__}"],
1136
+ )
1137
+
1138
+ if params is None:
1139
+ return WorkflowResult(
1140
+ name=self.name,
1141
+ success=False,
1142
+ data=self._empty_outputs(),
1143
+ metadata=DataAnalysisMetadata(),
1144
+ errors=["DataAnalysisParameters required (no defaults per CR-4.14-G-1)"],
1145
+ )
1146
+
1147
+ if not isinstance(params, DataAnalysisParameters):
1148
+ return WorkflowResult(
1149
+ name=self.name,
1150
+ success=False,
1151
+ data=self._empty_outputs(),
1152
+ metadata=DataAnalysisMetadata(),
1153
+ errors=[f"Expected DataAnalysisParameters, got {type(params).__name__}"],
1154
+ )
1155
+
1156
+ try:
1157
+ return self._run(context, params)
1158
+ except Exception as e:
1159
+ _logger.exception("Workflow '%s' failed", self.name)
1160
+ return WorkflowResult(
1161
+ name=self.name,
1162
+ success=False,
1163
+ data=self._empty_outputs(),
1164
+ metadata=DataAnalysisMetadata(),
1165
+ errors=[f"Workflow execution failed: {e}"],
1166
+ )
1167
+
1168
+ def _run(
1169
+ self, context: WorkflowContext, params: DataAnalysisParameters
1170
+ ) -> WorkflowResult[DataAnalysisMetadata, DataAnalysisOutputs]:
1171
+ """Core execution logic after parameter validation."""
1172
+ from dataeval_flow.embeddings import build_embeddings
1173
+ from dataeval_flow.selection import build_selection
1174
+
1175
+ # ── Phase 1: Compute shared data per split ──────────────────
1176
+ split_data: dict[str, SplitData] = {}
1177
+ # Track resolved datasets per source for the WorkflowResult
1178
+ source_datasets: dict[str, AnnotatedDataset[Any]] = {}
1179
+ last_dataset: AnnotatedDataset[Any] | None = None
1180
+
1181
+ for split_idx, (split_name, dc) in enumerate(context.dataset_contexts.items(), 1):
1182
+ dataset = dc.dataset
1183
+
1184
+ # Apply selection (Limit, Shuffle, ClassFilter, etc.) if configured
1185
+ if dc.selection_steps:
1186
+ dataset = build_selection(dataset, dc.selection_steps) # type: ignore[arg-type]
1187
+
1188
+ last_dataset = dataset
1189
+ source_datasets[split_name] = dataset
1190
+ sel_key = _sel_repr(dataset)
1191
+
1192
+ _logger.info(
1193
+ "[data-analysis] Analyzing split %d/%d: '%s' (%d samples)",
1194
+ split_idx,
1195
+ len(context.dataset_contexts),
1196
+ split_name,
1197
+ len(dataset),
1198
+ )
1199
+
1200
+ embeddings = None
1201
+ if dc.extractor:
1202
+ embeddings = build_embeddings(
1203
+ dataset, # type: ignore[arg-type]
1204
+ extractor_config=dc.extractor,
1205
+ transforms=dc.transforms,
1206
+ batch_size=dc.batch_size,
1207
+ )
1208
+
1209
+ # Use shared cache infrastructure when available
1210
+ with contextlib.ExitStack() as stack:
1211
+ if dc.cache is not None:
1212
+ stack.enter_context(active_cache(dc.cache, sel_key))
1213
+
1214
+ split_data[split_name] = _compute_split_data(
1215
+ dataset, # type: ignore[arg-type]
1216
+ params=params,
1217
+ extractor=embeddings,
1218
+ split_name=split_name,
1219
+ )
1220
+
1221
+ # ── Phase 2: Run per-split assessments ──────────────────────
1222
+ split_results: dict[str, SplitResult] = {}
1223
+ total_samples = 0
1224
+
1225
+ for split_name, data in split_data.items():
1226
+ _logger.info("[data-analysis] Assessing '%s' ...", split_name)
1227
+ sr = SplitResult(
1228
+ num_samples=data.dataset_len,
1229
+ image_quality=_assess_image_quality(data, params.outlier_method, params.outlier_threshold),
1230
+ redundancy=_assess_redundancy(data),
1231
+ label_health=_assess_label_health(data),
1232
+ bias=_assess_bias(data, params.balance, params.diversity_method),
1233
+ )
1234
+ split_results[split_name] = sr
1235
+ total_samples += sr.num_samples
1236
+
1237
+ # ── Phase 3: Cross-split assessments ────────────────────────
1238
+ cross_split: dict[str, CrossSplitResult] = {}
1239
+ if len(context.dataset_contexts) >= 2:
1240
+ _logger.info(
1241
+ "[data-analysis] Running cross-split analysis (%d splits)",
1242
+ len(context.dataset_contexts),
1243
+ )
1244
+ for name_a, name_b in combinations(split_data.keys(), 2):
1245
+ key = f"{name_a}_vs_{name_b}"
1246
+ da, db = split_data[name_a], split_data[name_b]
1247
+
1248
+ _logger.info(" Comparing %s vs %s ...", name_a, name_b)
1249
+ cross_split[key] = CrossSplitResult(
1250
+ redundancy=_assess_cross_redundancy(
1251
+ da.calc_result,
1252
+ db.calc_result,
1253
+ name_a,
1254
+ name_b,
1255
+ ),
1256
+ label_health=_assess_cross_label_health(
1257
+ da.label_stats,
1258
+ db.label_stats,
1259
+ name_a,
1260
+ name_b,
1261
+ ),
1262
+ distribution_shift=_assess_distribution_shift(
1263
+ da.embeddings,
1264
+ db.embeddings,
1265
+ params.divergence_method,
1266
+ ),
1267
+ )
1268
+
1269
+ # ── Phase 4: Assemble outputs & findings ────────────────────
1270
+ _logger.info("[data-analysis] Building report (%d total samples)", total_samples)
1271
+ findings = _build_findings(split_results, cross_split, params.health_thresholds)
1272
+
1273
+ # Workflow-specific metadata
1274
+ result_metadata = DataAnalysisMetadata(
1275
+ mode=params.mode,
1276
+ split_names=list(context.dataset_contexts.keys()),
1277
+ )
1278
+ if params.mode == "preparatory":
1279
+ findings.append(
1280
+ Reportable(
1281
+ report_type="text",
1282
+ severity="info",
1283
+ title="Preparatory Mode",
1284
+ data="Preparatory mode active.",
1285
+ description="Review per-split outlier and duplicate counts to identify items for removal.",
1286
+ )
1287
+ )
1288
+
1289
+ raw = DataAnalysisRawOutputs(
1290
+ dataset_size=total_samples,
1291
+ splits=split_results,
1292
+ cross_split=cross_split,
1293
+ )
1294
+
1295
+ report = DataAnalysisReport(
1296
+ summary=(f"Dataset analysis complete. {len(split_results)} split(s), {total_samples} total items."),
1297
+ findings=findings,
1298
+ )
1299
+
1300
+ return WorkflowResult(
1301
+ name=self.name,
1302
+ success=True,
1303
+ data=DataAnalysisOutputs(raw=raw, report=report),
1304
+ metadata=result_metadata,
1305
+ dataset=last_dataset,
1306
+ sources=source_datasets,
1307
+ )
1308
+
1309
+ def _empty_outputs(self) -> DataAnalysisOutputs:
1310
+ return DataAnalysisOutputs(
1311
+ raw=DataAnalysisRawOutputs(dataset_size=0),
1312
+ report=DataAnalysisReport(summary="Workflow failed", findings=[]),
1313
+ )