dataeval-flow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataeval_flow/__init__.py +93 -0
- dataeval_flow/__main__.py +149 -0
- dataeval_flow/_app/__init__.py +5 -0
- dataeval_flow/_app/_model/__init__.py +5 -0
- dataeval_flow/_app/_model/_coerce.py +126 -0
- dataeval_flow/_app/_model/_discover.py +171 -0
- dataeval_flow/_app/_model/_execution.py +108 -0
- dataeval_flow/_app/_model/_introspect.py +280 -0
- dataeval_flow/_app/_model/_item.py +213 -0
- dataeval_flow/_app/_model/_registry.py +255 -0
- dataeval_flow/_app/_model/_state.py +322 -0
- dataeval_flow/_app/_model/_undo.py +61 -0
- dataeval_flow/_app/_panes/__init__.py +35 -0
- dataeval_flow/_app/_panes/_config_pane.py +173 -0
- dataeval_flow/_app/_panes/_result_pane.py +125 -0
- dataeval_flow/_app/_panes/_task_pane.py +91 -0
- dataeval_flow/_app/_panes/_widgets.py +111 -0
- dataeval_flow/_app/_screens/__init__.py +25 -0
- dataeval_flow/_app/_screens/_base.py +242 -0
- dataeval_flow/_app/_screens/_detail.py +333 -0
- dataeval_flow/_app/_screens/_model.py +102 -0
- dataeval_flow/_app/_screens/_params.py +80 -0
- dataeval_flow/_app/_screens/_pathpicker.py +68 -0
- dataeval_flow/_app/_screens/_section.py +621 -0
- dataeval_flow/_app/_screens/_settings.py +183 -0
- dataeval_flow/_app/_viewmodel/__init__.py +15 -0
- dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
- dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
- dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
- dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
- dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
- dataeval_flow/_app/app.py +742 -0
- dataeval_flow/_app/cli.py +592 -0
- dataeval_flow/_logging.py +102 -0
- dataeval_flow/cache.py +1355 -0
- dataeval_flow/config/__init__.py +80 -0
- dataeval_flow/config/_loader.py +79 -0
- dataeval_flow/config/_merge.py +92 -0
- dataeval_flow/config/_models.py +115 -0
- dataeval_flow/config/_paths.py +85 -0
- dataeval_flow/config/schemas/__init__.py +112 -0
- dataeval_flow/config/schemas/_dataset.py +111 -0
- dataeval_flow/config/schemas/_extractor.py +119 -0
- dataeval_flow/config/schemas/_metadata.py +28 -0
- dataeval_flow/config/schemas/_preprocessor.py +18 -0
- dataeval_flow/config/schemas/_selection.py +100 -0
- dataeval_flow/config/schemas/_task.py +89 -0
- dataeval_flow/config/schemas/_workflow.py +135 -0
- dataeval_flow/dataset.py +635 -0
- dataeval_flow/embeddings.py +135 -0
- dataeval_flow/metadata.py +48 -0
- dataeval_flow/preprocessing.py +141 -0
- dataeval_flow/py.typed +0 -0
- dataeval_flow/runner.py +118 -0
- dataeval_flow/selection.py +50 -0
- dataeval_flow/workflow/__init__.py +328 -0
- dataeval_flow/workflow/_text_report.py +511 -0
- dataeval_flow/workflow/base.py +69 -0
- dataeval_flow/workflow/orchestrator.py +454 -0
- dataeval_flow/workflows/__init__.py +1 -0
- dataeval_flow/workflows/analysis/__init__.py +38 -0
- dataeval_flow/workflows/analysis/outputs.py +202 -0
- dataeval_flow/workflows/analysis/params.py +114 -0
- dataeval_flow/workflows/analysis/workflow.py +1313 -0
- dataeval_flow/workflows/cleaning/__init__.py +23 -0
- dataeval_flow/workflows/cleaning/outputs.py +200 -0
- dataeval_flow/workflows/cleaning/params.py +160 -0
- dataeval_flow/workflows/cleaning/report.py +304 -0
- dataeval_flow/workflows/cleaning/workflow.py +794 -0
- dataeval_flow/workflows/drift/__init__.py +1 -0
- dataeval_flow/workflows/drift/outputs.py +144 -0
- dataeval_flow/workflows/drift/params.py +332 -0
- dataeval_flow/workflows/drift/report.py +201 -0
- dataeval_flow/workflows/drift/workflow.py +647 -0
- dataeval_flow/workflows/ood/__init__.py +1 -0
- dataeval_flow/workflows/ood/outputs.py +134 -0
- dataeval_flow/workflows/ood/params.py +161 -0
- dataeval_flow/workflows/ood/report.py +311 -0
- dataeval_flow/workflows/ood/workflow.py +728 -0
- dataeval_flow/workflows/prioritization/__init__.py +1 -0
- dataeval_flow/workflows/prioritization/outputs.py +122 -0
- dataeval_flow/workflows/prioritization/params.py +124 -0
- dataeval_flow/workflows/prioritization/report.py +117 -0
- dataeval_flow/workflows/prioritization/workflow.py +587 -0
- dataeval_flow/workflows/splitting/__init__.py +25 -0
- dataeval_flow/workflows/splitting/outputs.py +101 -0
- dataeval_flow/workflows/splitting/params.py +61 -0
- dataeval_flow/workflows/splitting/report.py +485 -0
- dataeval_flow/workflows/splitting/workflow.py +371 -0
- dataeval_flow-0.1.0.dist-info/METADATA +305 -0
- dataeval_flow-0.1.0.dist-info/RECORD +94 -0
- dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
- dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
- dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|