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,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
|
+
)
|