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,23 @@
1
+ """Data cleaning workflow - public re-exports."""
2
+
3
+ from dataeval_flow.workflows.cleaning.outputs import (
4
+ DataCleaningMetadata,
5
+ DataCleaningOutputs,
6
+ DataCleaningRawOutputs,
7
+ DataCleaningReport,
8
+ DataCleaningResult,
9
+ is_cleaning_result,
10
+ )
11
+ from dataeval_flow.workflows.cleaning.params import DataCleaningParameters
12
+ from dataeval_flow.workflows.cleaning.workflow import DataCleaningWorkflow
13
+
14
+ __all__ = [
15
+ "DataCleaningMetadata",
16
+ "DataCleaningOutputs",
17
+ "DataCleaningParameters",
18
+ "DataCleaningRawOutputs",
19
+ "DataCleaningReport",
20
+ "DataCleaningResult",
21
+ "DataCleaningWorkflow",
22
+ "is_cleaning_result",
23
+ ]
@@ -0,0 +1,200 @@
1
+ """Data cleaning workflow outputs."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Literal, TypeAlias
4
+
5
+ from pydantic import BaseModel, Field
6
+ from typing_extensions import TypedDict, TypeIs
7
+
8
+ from dataeval_flow.config.schemas import ResultMetadata
9
+ from dataeval_flow.workflow.base import Reportable, WorkflowOutputsBase, WorkflowReportBase
10
+
11
+ if TYPE_CHECKING:
12
+ from dataeval_flow.workflow import WorkflowResult
13
+
14
+ __all__ = [
15
+ "ClasswisePivotDict",
16
+ "DataCleaningMetadata",
17
+ "DataCleaningOutputs",
18
+ "DataCleaningRawOutputs",
19
+ "DataCleaningReport",
20
+ "DataCleaningResult",
21
+ "DetectionDict",
22
+ "DuplicatesDict",
23
+ "LabelStatsDict",
24
+ "NearDuplicateGroupDict",
25
+ "OutlierIssueRecord",
26
+ "OutlierIssuesDict",
27
+ "SourceIndexDict",
28
+ "is_cleaning_result",
29
+ ]
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # TypedDicts for serialized evaluator outputs
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ class _OutlierIssueRecordRequired(TypedDict):
38
+ """Required fields for an outlier issue record."""
39
+
40
+ item_index: int
41
+ metric_name: str
42
+ metric_value: float
43
+
44
+
45
+ class OutlierIssueRecord(_OutlierIssueRecordRequired, total=False):
46
+ """Single outlier issue from DataEval OutliersOutput.
47
+
48
+ ``target_index`` is present for target-level outliers (object detection datasets)
49
+ and absent or ``None`` for image-level outliers.
50
+ """
51
+
52
+ target_index: int | None
53
+
54
+
55
+ class OutlierIssuesDict(TypedDict):
56
+ """Serialized outlier issues (image or target level)."""
57
+
58
+ issues: list[OutlierIssueRecord]
59
+ count: int
60
+
61
+
62
+ class SourceIndexDict(TypedDict):
63
+ """Serialized SourceIndex from DataEval — identifies an item, target, and channel."""
64
+
65
+ item: int
66
+ target: int | None
67
+ channel: int | None
68
+
69
+
70
+ #: An index value is either a plain ``int`` (image-level) or a
71
+ #: :class:`SourceIndexDict` (target/channel-level).
72
+ IndexValue = int | SourceIndexDict
73
+
74
+
75
+ class NearDuplicateGroupDict(TypedDict):
76
+ """Serialized near-duplicate group."""
77
+
78
+ indices: list[IndexValue]
79
+ methods: list[str]
80
+ orientation: str | None
81
+
82
+
83
+ class DetectionDict(TypedDict, total=False):
84
+ """Serialized duplicate detection result (exact + near groups)."""
85
+
86
+ exact: list[list[IndexValue]]
87
+ near: list[NearDuplicateGroupDict]
88
+
89
+
90
+ class DuplicatesDict(TypedDict):
91
+ """Serialized DuplicatesOutput (items + targets)."""
92
+
93
+ items: DetectionDict
94
+ targets: DetectionDict
95
+
96
+
97
+ class LabelStatsDict(TypedDict, total=False):
98
+ """Label statistics derived from Metadata."""
99
+
100
+ item_count: int
101
+ class_count: int
102
+ index2label: dict[int, str]
103
+ label_counts_per_class: dict[str, int]
104
+
105
+
106
+ class ClasswiseRowDict(TypedDict):
107
+ """Single row in the classwise outlier summary."""
108
+
109
+ class_name: str
110
+ count: int
111
+ pct: float # percentage of that class's labels flagged
112
+
113
+
114
+ class ClasswisePivotDict(TypedDict, total=False):
115
+ """Classwise outlier summary — count and % of labels flagged per class.
116
+
117
+ For classification datasets this summarises image outliers per class;
118
+ for object-detection datasets this summarises target-level outliers per
119
+ class (image-level entries are excluded since they cannot be attributed
120
+ to a single class).
121
+ """
122
+
123
+ level: str # "image" or "target"
124
+ rows: list[ClasswiseRowDict] # one per class + Total row
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Pydantic output models
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ class DataCleaningRawOutputs(WorkflowOutputsBase):
133
+ """Machine-readable results from data cleaning workflow."""
134
+
135
+ duplicates: DuplicatesDict = Field(
136
+ default_factory=lambda: {"items": {}, "targets": {}},
137
+ description="DuplicatesOutput from DataEval",
138
+ )
139
+ img_outliers: OutlierIssuesDict = Field(
140
+ default_factory=lambda: {"issues": [], "count": 0},
141
+ description="OutliersOutput for images from DataEval",
142
+ )
143
+ label_stats: LabelStatsDict = Field(
144
+ default_factory=dict, # type: ignore[assignment] # empty dict valid; all LabelStatsDict keys are optional (total=False)
145
+ description="Label statistics derived from Metadata (class_labels, index2label, item_count)",
146
+ )
147
+ target_outliers: OutlierIssuesDict | None = Field(
148
+ default=None,
149
+ description="OutliersOutput for bounding boxes (OD datasets only)",
150
+ )
151
+ classwise_outliers: ClasswisePivotDict | None = Field(
152
+ default=None,
153
+ description="Classwise outlier pivot — image-level for classification, target-level for OD",
154
+ )
155
+
156
+
157
+ class DataCleaningReport(WorkflowReportBase):
158
+ """Human-readable report for data cleaning workflow."""
159
+
160
+ findings: list[Reportable] = Field(default_factory=list)
161
+
162
+
163
+ class DataCleaningOutputs(BaseModel):
164
+ """Complete data cleaning workflow output."""
165
+
166
+ raw: DataCleaningRawOutputs
167
+ report: DataCleaningReport
168
+
169
+
170
+ class DataCleaningMetadata(ResultMetadata):
171
+ """Metadata for the data-cleaning workflow."""
172
+
173
+ mode: Literal["advisory", "preparatory"] = "advisory"
174
+ evaluators: list[str] = Field(default_factory=list)
175
+ flagged_indices: list[int] = Field(default_factory=list)
176
+ clean_indices: list[int] = Field(default_factory=list)
177
+ removed_count: int = 0
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Type alias and TypeIs guard for type narrowing
182
+ # ---------------------------------------------------------------------------
183
+
184
+ #: Fully typed result alias for the data-cleaning workflow.
185
+ DataCleaningResult: TypeAlias = "WorkflowResult[DataCleaningMetadata, DataCleaningOutputs]"
186
+
187
+
188
+ def is_cleaning_result(
189
+ result: "WorkflowResult[Any, Any]",
190
+ ) -> TypeIs["WorkflowResult[DataCleaningMetadata, DataCleaningOutputs]"]:
191
+ """Narrow a generic ``WorkflowResult`` to a data-cleaning result.
192
+
193
+ Useful in the CLI loop or any code that receives a generic result::
194
+
195
+ [result] = run_tasks(config, "my_task")
196
+ if is_cleaning_result(result):
197
+ result.metadata.flagged_indices # ✓ typed
198
+ result.data.raw.img_outliers # ✓ typed
199
+ """
200
+ return isinstance(result.metadata, DataCleaningMetadata)
@@ -0,0 +1,160 @@
1
+ """Data cleaning workflow parameters."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from dataeval_flow.workflow.base import WorkflowParametersBase
9
+
10
+ __all__ = ["DataCleaningParameters", "DataCleaningHealthThresholds"]
11
+
12
+
13
+ class DataCleaningHealthThresholds(BaseModel):
14
+ """Configurable warning thresholds for data cleaning health status.
15
+
16
+ Each threshold is a percentage (0–100). When the detected rate exceeds the
17
+ threshold the corresponding finding is elevated to ``severity="warning"``;
18
+ otherwise it stays at ``severity="info"``.
19
+
20
+ Set a threshold to ``None`` to disable the warning for that metric.
21
+ """
22
+
23
+ exact_duplicates: float = Field(
24
+ default=0.0,
25
+ ge=0.0,
26
+ le=100.0,
27
+ description=(
28
+ "Max allowable % of images in exact-duplicate groups. "
29
+ "Exact duplicates are byte-identical images that inflate dataset size without "
30
+ "adding information. Default 0% — any exact duplicates trigger a warning. "
31
+ "Raise above 0 only if your pipeline intentionally includes repeated images "
32
+ "(e.g. augmentation-before-split workflows)."
33
+ ),
34
+ )
35
+ near_duplicates: float = Field(
36
+ default=5.0,
37
+ ge=0.0,
38
+ le=100.0,
39
+ description=(
40
+ "Max allowable % of images in near-duplicate groups. "
41
+ "Near duplicates are visually similar images (crops, resizes, minor edits) "
42
+ "that can bias model training toward repeated content. Default 5%. "
43
+ "Lower to 1–2% for curated benchmarks; raise to 10–15% for large-scale "
44
+ "web-scraped datasets where some redundancy is expected."
45
+ ),
46
+ )
47
+ image_outliers: float = Field(
48
+ default=3.0,
49
+ ge=0.0,
50
+ le=100.0,
51
+ description=(
52
+ "Max allowable % of images flagged as statistical outliers "
53
+ "(unusual dimensions, brightness, entropy, or visual statistics). Default 3%. "
54
+ "Lower to 1% for safety-critical datasets; raise to 5–10% for diverse "
55
+ "real-world collections where high visual variance is expected."
56
+ ),
57
+ )
58
+ target_outliers: float = Field(
59
+ default=3.0,
60
+ ge=0.0,
61
+ le=100.0,
62
+ description=(
63
+ "Max allowable % of targets (labels/annotations) flagged as outliers "
64
+ "(unusual bounding-box sizes, aspect ratios, or annotation counts). Default 3%. "
65
+ "Lower to 1% for annotation-quality audits; raise to 5–10% for datasets "
66
+ "with naturally high annotation variance (e.g. dense object detection)."
67
+ ),
68
+ )
69
+ classwise_outliers: float = Field(
70
+ default=3.0,
71
+ ge=0.0,
72
+ le=100.0,
73
+ description=(
74
+ "Max allowable % of items flagged as outliers within any single class. Default 3%. "
75
+ "This catches classes where outlier concentration is disproportionately high, "
76
+ "which may indicate labeling errors or class definition issues. "
77
+ "Lower to 1% for label-quality audits; raise to 5–10% for classes with "
78
+ "inherently high visual diversity."
79
+ ),
80
+ )
81
+ class_label_imbalance: float = Field(
82
+ default=5.0,
83
+ ge=1.0,
84
+ description=(
85
+ "Max allowable ratio between the largest and smallest class counts "
86
+ "(max_class / min_class). Default 5:1. "
87
+ "For binary classification, 3:1 is a common threshold for 'imbalanced'. "
88
+ "For large class hierarchies (25+ classes), the long tail naturally "
89
+ "increases this ratio — raise to 10–20:1 to avoid false warnings. "
90
+ "Set to 1.0 to require perfectly balanced classes."
91
+ ),
92
+ )
93
+
94
+
95
+ class DataCleaningParameters(WorkflowParametersBase):
96
+ """Parameters for data cleaning workflow.
97
+
98
+ Required parameters must be explicitly set per CR-4.14-G-1
99
+ (avoid application-specific defaults).
100
+ """
101
+
102
+ # --- Outlier detection params ---
103
+ outlier_method: Literal["adaptive", "zscore", "modzscore", "iqr"] = Field(
104
+ description="Statistical method for outlier detection",
105
+ )
106
+ outlier_flags: Sequence[Literal["dimension", "pixel", "visual"]] = Field(
107
+ min_length=1,
108
+ description="Image statistics groups for outlier detection. At least one required.",
109
+ )
110
+ outlier_threshold: float | None = Field(
111
+ default=None,
112
+ ge=0.0,
113
+ description="Custom threshold (None = use DataEval default for chosen method)",
114
+ )
115
+ outlier_cluster_threshold: float | None = Field(
116
+ default=None,
117
+ description=(
118
+ "Std devs from cluster center to flag as outlier (requires extractor). None = skip cluster detection."
119
+ ),
120
+ )
121
+ outlier_cluster_algorithm: Literal["kmeans", "hdbscan"] | None = Field(
122
+ default=None,
123
+ description="Clustering algorithm for cluster-based outlier detection.",
124
+ )
125
+ outlier_n_clusters: int | None = Field(
126
+ default=None,
127
+ description="Expected number of clusters. None = auto-detect.",
128
+ )
129
+
130
+ # --- Duplicate detection params ---
131
+ duplicate_flags: Sequence[Literal["hash_basic", "hash_d4"]] | None = Field(
132
+ default=None,
133
+ description=(
134
+ "Hash flag groups for duplicate detection. None = DataEval default (hash_basic: xxhash + phash + dhash)."
135
+ ),
136
+ )
137
+ duplicate_merge_near: bool = Field(
138
+ default=True,
139
+ description="Merge overlapping near-duplicate groups from different detection methods.",
140
+ )
141
+ duplicate_cluster_sensitivity: float | None = Field(
142
+ default=None,
143
+ description=(
144
+ "Threshold for cluster-based near duplicate detection (requires extractor). None = skip cluster detection."
145
+ ),
146
+ )
147
+ duplicate_cluster_algorithm: Literal["kmeans", "hdbscan"] | None = Field(
148
+ default=None,
149
+ description="Clustering algorithm for cluster-based duplicate detection.",
150
+ )
151
+ duplicate_n_clusters: int | None = Field(
152
+ default=None,
153
+ description="Expected number of clusters for duplicate detection. None = auto-detect.",
154
+ )
155
+
156
+ # --- Health thresholds ---
157
+ health_thresholds: DataCleaningHealthThresholds = Field(
158
+ default_factory=DataCleaningHealthThresholds,
159
+ description="Warning thresholds for dataset health status. Findings are flagged as warnings.",
160
+ )
@@ -0,0 +1,304 @@
1
+ """Findings builders for the data cleaning workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from dataeval_flow.workflow.base import Reportable
8
+ from dataeval_flow.workflows.cleaning.outputs import (
9
+ DataCleaningRawOutputs,
10
+ IndexValue,
11
+ )
12
+ from dataeval_flow.workflows.cleaning.params import DataCleaningHealthThresholds
13
+
14
+
15
+ def _duplicate_finding(raw: DataCleaningRawOutputs, thresholds: DataCleaningHealthThresholds) -> Reportable | None:
16
+ """Build a Duplicates finding from raw results, or None if no duplicates."""
17
+ exact_groups = raw.duplicates.get("items", {}).get("exact", [])
18
+ near_groups = raw.duplicates.get("items", {}).get("near", [])
19
+ if not exact_groups and not near_groups:
20
+ return None
21
+
22
+ exact_affected = sum(len(g) for g in exact_groups)
23
+ near_affected = sum(len(g["indices"]) for g in near_groups)
24
+ # Collect methods and orientations from near groups
25
+ all_methods: set[str] = set()
26
+ orientations: dict[str, int] = {}
27
+ for g in near_groups:
28
+ all_methods.update(g.get("methods", []))
29
+ orient = g.get("orientation")
30
+ if orient is not None:
31
+ orientations[orient] = orientations.get(orient, 0) + 1
32
+ detail_lines: list[str] = []
33
+ if exact_groups:
34
+ detail_lines.append(f"{len(exact_groups)} exact-duplicate groups ({exact_affected} images)")
35
+ if near_groups:
36
+ detail_lines.append(f"{len(near_groups)} near-duplicate groups ({near_affected} images)")
37
+ if all_methods:
38
+ detail_lines.append(f" Methods: {', '.join(sorted(all_methods))}")
39
+ if orientations:
40
+ parts = [f"{c} {o}" for o, c in sorted(orientations.items())]
41
+ detail_lines.append(f" Orientations: {', '.join(parts)}")
42
+ # Determine severity from thresholds
43
+ exact_pct = (exact_affected / raw.dataset_size) * 100 if raw.dataset_size else 0.0
44
+ near_pct = (near_affected / raw.dataset_size) * 100 if raw.dataset_size else 0.0
45
+ severity: Literal["ok", "info", "warning"] = "info"
46
+ if exact_pct > thresholds.exact_duplicates or near_pct > thresholds.near_duplicates:
47
+ severity = "warning"
48
+
49
+ return Reportable(
50
+ report_type="key_value",
51
+ severity=severity,
52
+ title="Duplicates",
53
+ data={
54
+ "brief": (f"{exact_affected} exact ({round(exact_pct, 1)}%), {near_affected} near ({round(near_pct, 1)}%)"),
55
+ "detail_lines": detail_lines,
56
+ "exact_groups": len(exact_groups),
57
+ "near_groups": len(near_groups),
58
+ "exact_affected": exact_affected,
59
+ "near_affected": near_affected,
60
+ "near_methods": sorted(all_methods),
61
+ "near_orientations": orientations,
62
+ },
63
+ description=(f"{len(exact_groups)} exact duplicate groups, {len(near_groups)} near-duplicate groups found."),
64
+ )
65
+
66
+
67
+ def _label_distribution_finding(
68
+ raw: DataCleaningRawOutputs,
69
+ thresholds: DataCleaningHealthThresholds,
70
+ label_source: str | None = None,
71
+ ) -> Reportable | None:
72
+ """Build a Label Distribution finding from raw results, or None if no label stats."""
73
+ if not raw.label_stats:
74
+ return None
75
+
76
+ class_count = raw.label_stats.get("class_count", 0)
77
+ if class_count == 0:
78
+ return None # No labels — suppress finding
79
+
80
+ label_counts = raw.label_stats.get("label_counts_per_class", {})
81
+ item_count = raw.label_stats.get("item_count", 0)
82
+ counts_list = list(label_counts.values()) if label_counts else []
83
+ has_empty_class = bool(counts_list) and min(counts_list) == 0
84
+ imbalance_ratio = round(max(counts_list) / min(counts_list), 1) if counts_list and not has_empty_class else 0.0
85
+ footer_lines: list[str] = []
86
+ if label_source:
87
+ footer_lines.append(f"Labels {label_source}")
88
+ if has_empty_class:
89
+ footer_lines.append("Warning: one or more classes have zero items")
90
+ elif imbalance_ratio == 1.0:
91
+ footer_lines.append("Balanced: all classes have equal counts")
92
+ elif imbalance_ratio != 0.0:
93
+ footer_lines.append(f"Imbalance ratio: {imbalance_ratio} (max/min)")
94
+ severity: Literal["ok", "info", "warning"] = "info"
95
+ if has_empty_class or imbalance_ratio > thresholds.class_label_imbalance:
96
+ severity = "warning"
97
+
98
+ return Reportable(
99
+ report_type="table",
100
+ severity=severity,
101
+ title=("Label/Directory_Name Distribution" if label_source == "filepath" else "Label Distribution"),
102
+ data={
103
+ "brief": f"{class_count} classes, {item_count} items, imbalance {imbalance_ratio}:1",
104
+ "table_data": label_counts,
105
+ "table_headers": ("Class", "Count"),
106
+ "footer_lines": footer_lines,
107
+ # Keep existing keys for JSON/YAML consumers
108
+ "label_counts": label_counts,
109
+ "class_count": class_count,
110
+ "item_count": item_count,
111
+ "imbalance_ratio": imbalance_ratio,
112
+ "label_source": label_source,
113
+ },
114
+ description=(f"{class_count} classes, {item_count} items."),
115
+ )
116
+
117
+
118
+ def _classwise_finding(raw: DataCleaningRawOutputs, thresholds: DataCleaningHealthThresholds) -> Reportable:
119
+ """Build a Classwise Outliers finding from raw results."""
120
+ pivot = raw.classwise_outliers
121
+ rows = pivot.get("rows", None) if pivot else None
122
+
123
+ if not rows:
124
+ return Reportable(
125
+ report_type="pivot_table",
126
+ severity="ok",
127
+ title="Classwise Outliers",
128
+ data={
129
+ "brief": "no outliers detected",
130
+ "level": "image",
131
+ "table_data": [],
132
+ "table_headers": ["Class Name", "Count", "%"],
133
+ "worst_class": None,
134
+ "worst_pct": 0.0,
135
+ "classes_over_threshold": 0,
136
+ },
137
+ description="No outliers detected — classwise breakdown not applicable.",
138
+ )
139
+
140
+ level = pivot.get("level", "image") # type: ignore[union-attr]
141
+
142
+ # The last row is the "Total" row
143
+ total_row = rows[-1] if rows else {}
144
+ class_rows = rows[:-1] if len(rows) > 1 else rows
145
+ total_pct = total_row.get("pct", 0.0)
146
+
147
+ severity: Literal["ok", "info", "warning"] = "info"
148
+ if total_pct > thresholds.classwise_outliers:
149
+ severity = "warning"
150
+
151
+ # Identify the worst class and how many classes exceed the threshold
152
+ class_pcts = [r.get("pct", 0.0) for r in class_rows]
153
+ worst_row = max(class_rows, key=lambda r: r.get("pct", 0.0))
154
+ worst_name = worst_row.get("class_name", "?")
155
+ worst_pct = worst_row.get("pct", 0.0)
156
+ classes_over = sum(1 for p in class_pcts if p > thresholds.classwise_outliers)
157
+ brief_prefix = f"worst: {worst_name} ({worst_pct}%), "
158
+
159
+ # Brief: focus on concentration — which class is worst and how many are over threshold
160
+ if classes_over > 0:
161
+ brief = f"{brief_prefix}{classes_over}/{len(class_rows)} classes over {thresholds.classwise_outliers}%"
162
+ else:
163
+ brief = f"{brief_prefix}all classes within {thresholds.classwise_outliers}%"
164
+
165
+ return Reportable(
166
+ report_type="pivot_table",
167
+ severity=severity,
168
+ title="Classwise Outliers",
169
+ data={
170
+ "brief": brief,
171
+ "level": level,
172
+ "table_data": rows,
173
+ "table_headers": ["Class Name", "Count", "%"],
174
+ "worst_class": worst_name,
175
+ "worst_pct": worst_pct,
176
+ "classes_over_threshold": classes_over,
177
+ },
178
+ description=(
179
+ f"Most outliers in {worst_name} ({worst_pct}%). "
180
+ f"{classes_over}/{len(class_rows)} classes exceed {thresholds.classwise_outliers}% threshold."
181
+ ),
182
+ )
183
+
184
+
185
+ def build_findings(
186
+ raw: DataCleaningRawOutputs,
187
+ metadata: Any, # noqa: ARG001 - reserved for future metadata-based findings
188
+ thresholds: DataCleaningHealthThresholds,
189
+ label_source: str | None = None,
190
+ ) -> list[Reportable]:
191
+ """Generate human-readable findings from raw results."""
192
+ findings: list[Reportable] = []
193
+
194
+ # Outlier findings — count distinct images, not total flags
195
+ outlier_issues = raw.img_outliers.get("issues", [])
196
+ outlier_image_count = len({issue["item_index"] for issue in outlier_issues})
197
+ pct = (outlier_image_count / raw.dataset_size) * 100 if raw.dataset_size else 0
198
+ # Per-metric breakdown: count distinct images per metric
199
+ _per_metric_sets: dict[str, set[int]] = {}
200
+ for issue in outlier_issues:
201
+ _per_metric_sets.setdefault(issue["metric_name"], set()).add(issue["item_index"])
202
+ per_metric = {k: len(v) for k, v in _per_metric_sets.items()}
203
+ if outlier_image_count > 0:
204
+ img_severity: Literal["ok", "info", "warning"] = "warning" if pct > thresholds.image_outliers else "info"
205
+ img_description = f"{outlier_image_count} images ({pct:.1f}%) flagged as outliers."
206
+ else:
207
+ img_severity = "ok"
208
+ img_description = "No images flagged as outliers."
209
+ findings.append(
210
+ Reportable(
211
+ report_type="key_value",
212
+ severity=img_severity,
213
+ title="Image Outliers",
214
+ data={
215
+ "brief": f"{outlier_image_count} images ({round(pct, 1)}%)",
216
+ "multi_metric_subject": "images",
217
+ "count": outlier_image_count,
218
+ "percentage": round(pct, 1),
219
+ "per_metric": per_metric,
220
+ "total_flags": len(outlier_issues),
221
+ "dataset_size": raw.dataset_size,
222
+ },
223
+ description=img_description,
224
+ )
225
+ )
226
+
227
+ # Target outlier findings — count distinct (item, target) pairs
228
+ target_issues = raw.target_outliers.get("issues", []) if raw.target_outliers else []
229
+ target_pair_count = len({(issue["item_index"], issue.get("target_index")) for issue in target_issues})
230
+ if target_pair_count > 0:
231
+ # Total target count from label stats for percentage
232
+ total_targets = sum(raw.label_stats.get("label_counts_per_class", {}).values()) if raw.label_stats else 0
233
+ target_pct = round((target_pair_count / total_targets) * 100, 1) if total_targets > 0 else 0.0
234
+ # Per-metric breakdown for targets
235
+ _target_metric_sets: dict[str, set[tuple[int, int | None]]] = {}
236
+ for issue in target_issues:
237
+ key = (issue["item_index"], issue.get("target_index"))
238
+ _target_metric_sets.setdefault(issue["metric_name"], set()).add(key)
239
+ target_per_metric = {k: len(v) for k, v in _target_metric_sets.items()}
240
+ tgt_severity: Literal["ok", "info", "warning"] = (
241
+ "warning" if target_pct > thresholds.target_outliers else "info"
242
+ )
243
+ findings.append(
244
+ Reportable(
245
+ report_type="key_value",
246
+ severity=tgt_severity,
247
+ title="Target Outliers",
248
+ data={
249
+ "brief": f"{target_pair_count} targets ({target_pct}%)",
250
+ "multi_metric_subject": "targets",
251
+ "count": target_pair_count,
252
+ "percentage": target_pct,
253
+ "per_metric": target_per_metric,
254
+ "total_flags": len(target_issues),
255
+ "total_targets": total_targets,
256
+ },
257
+ description=f"{target_pair_count} bounding-box targets ({target_pct}%) flagged as outliers.",
258
+ )
259
+ )
260
+
261
+ # Classwise outlier pivot — right after image/target outliers
262
+ findings.append(_classwise_finding(raw, thresholds))
263
+
264
+ # Duplicate findings
265
+ dup_finding = _duplicate_finding(raw, thresholds)
266
+ if dup_finding:
267
+ findings.append(dup_finding)
268
+
269
+ # Label distribution finding
270
+ label_finding = _label_distribution_finding(raw, thresholds, label_source=label_source)
271
+ if label_finding:
272
+ findings.append(label_finding)
273
+
274
+ return findings
275
+
276
+
277
+ def _item_id_of(idx: IndexValue) -> int:
278
+ """Extract the item ID from an :class:`IndexValue`.
279
+
280
+ Returns the ``int`` directly for image-level indices, or the ``"item"``
281
+ field from a :class:`SourceIndexDict` for target-level indices.
282
+ """
283
+ if isinstance(idx, dict):
284
+ return idx["item"]
285
+ return idx
286
+
287
+
288
+ def collect_flagged_indices(raw: DataCleaningRawOutputs) -> set[int]:
289
+ """Collect all unique item indices flagged by outlier or duplicate detection."""
290
+ flagged: set[int] = set()
291
+
292
+ # Outlier-flagged items
293
+ for issue in raw.img_outliers.get("issues", []):
294
+ flagged.add(issue["item_index"])
295
+
296
+ # Duplicate-flagged items (keep first in each group, flag the rest)
297
+ for group in raw.duplicates.get("items", {}).get("exact", []):
298
+ for idx in group[1:]: # keep first, flag rest
299
+ flagged.add(_item_id_of(idx))
300
+ for group in raw.duplicates.get("items", {}).get("near", []):
301
+ for idx in group["indices"][1:]:
302
+ flagged.add(_item_id_of(idx))
303
+
304
+ return flagged