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,728 @@
|
|
|
1
|
+
"""OOD detection workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import time as _time
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from dataeval.core import factor_deviation, factor_predictors
|
|
13
|
+
from dataeval.protocols import AnnotatedDataset
|
|
14
|
+
from dataeval.shift import OODDomainClassifier, OODKNeighbors, OODOutput
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from dataeval_flow.cache import (
|
|
19
|
+
active_cache,
|
|
20
|
+
get_or_compute_embeddings,
|
|
21
|
+
get_or_compute_metadata,
|
|
22
|
+
get_or_compute_stats,
|
|
23
|
+
selection_repr,
|
|
24
|
+
)
|
|
25
|
+
from dataeval_flow.workflow import DatasetContext, WorkflowContext, WorkflowProtocol, WorkflowResult
|
|
26
|
+
from dataeval_flow.workflows.ood.outputs import (
|
|
27
|
+
DetectorOODResultDict,
|
|
28
|
+
FactorDeviationDict,
|
|
29
|
+
OODDetectionMetadata,
|
|
30
|
+
OODDetectionOutputs,
|
|
31
|
+
OODDetectionRawOutputs,
|
|
32
|
+
OODDetectionReport,
|
|
33
|
+
OODSampleDict,
|
|
34
|
+
)
|
|
35
|
+
from dataeval_flow.workflows.ood.params import (
|
|
36
|
+
OODDetectionParameters,
|
|
37
|
+
OODDetectorConfig,
|
|
38
|
+
OODDetectorDomainClassifier,
|
|
39
|
+
OODDetectorKNeighbors,
|
|
40
|
+
)
|
|
41
|
+
from dataeval_flow.workflows.ood.report import build_findings
|
|
42
|
+
|
|
43
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Detector factory
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_OODDetector = OODKNeighbors | OODDomainClassifier
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_ood_detector(config: OODDetectorConfig) -> _OODDetector: # type: ignore[type-arg]
|
|
53
|
+
"""Instantiate an OOD detector from its discriminated config."""
|
|
54
|
+
if isinstance(config, OODDetectorKNeighbors):
|
|
55
|
+
return OODKNeighbors(
|
|
56
|
+
k=config.k,
|
|
57
|
+
distance_metric=config.distance_metric,
|
|
58
|
+
threshold_perc=config.threshold_perc,
|
|
59
|
+
)
|
|
60
|
+
if isinstance(config, OODDetectorDomainClassifier):
|
|
61
|
+
return OODDomainClassifier(
|
|
62
|
+
n_folds=config.n_folds,
|
|
63
|
+
n_repeats=config.n_repeats,
|
|
64
|
+
n_std=config.n_std,
|
|
65
|
+
threshold_perc=config.threshold_perc,
|
|
66
|
+
)
|
|
67
|
+
raise ValueError(f"Unknown OOD detector config type: {type(config).__name__}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ood_detector_display_name(config: OODDetectorConfig) -> str: # type: ignore[type-arg]
|
|
71
|
+
"""Human-readable name for an OOD detector config, including non-default parameters."""
|
|
72
|
+
names: dict[str, str] = {
|
|
73
|
+
"kneighbors": "K-Neighbors",
|
|
74
|
+
"domain_classifier": "Domain Classifier",
|
|
75
|
+
}
|
|
76
|
+
base = names.get(config.method, config.method)
|
|
77
|
+
|
|
78
|
+
# Collect non-default, non-internal parameters as a compact suffix
|
|
79
|
+
parts: list[str] = []
|
|
80
|
+
defaults = {name: field.default for name, field in config.model_fields.items()}
|
|
81
|
+
for name in config.model_fields:
|
|
82
|
+
if name == "method":
|
|
83
|
+
continue
|
|
84
|
+
value = getattr(config, name)
|
|
85
|
+
if value != defaults[name]:
|
|
86
|
+
parts.append(f"{name}={value}")
|
|
87
|
+
|
|
88
|
+
if parts:
|
|
89
|
+
base = f"{base} ({', '.join(parts)})"
|
|
90
|
+
return base
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Result serialization
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _serialize_ood_result(
|
|
99
|
+
output: OODOutput,
|
|
100
|
+
config: OODDetectorConfig, # type: ignore[type-arg]
|
|
101
|
+
test_size: int,
|
|
102
|
+
) -> DetectorOODResultDict:
|
|
103
|
+
"""Convert an OODOutput to a serializable dict."""
|
|
104
|
+
is_ood = output.is_ood
|
|
105
|
+
scores = output.instance_score
|
|
106
|
+
ood_count = int(np.sum(is_ood))
|
|
107
|
+
ood_pct = 100.0 * ood_count / test_size if test_size > 0 else 0.0
|
|
108
|
+
|
|
109
|
+
# Compute threshold from the predict logic: scores > threshold => OOD
|
|
110
|
+
# We derive it from the first non-OOD sample's boundary or max non-OOD score
|
|
111
|
+
if ood_count < test_size:
|
|
112
|
+
non_ood_scores = scores[~is_ood]
|
|
113
|
+
threshold_score = float(np.max(non_ood_scores)) if len(non_ood_scores) > 0 else 0.0
|
|
114
|
+
else:
|
|
115
|
+
# All samples are OOD
|
|
116
|
+
threshold_score = float(np.min(scores))
|
|
117
|
+
|
|
118
|
+
samples: list[OODSampleDict] = [
|
|
119
|
+
OODSampleDict(index=int(i), score=float(scores[i]), is_ood=bool(is_ood[i])) for i in range(test_size)
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
return DetectorOODResultDict(
|
|
123
|
+
method=config.method,
|
|
124
|
+
ood_count=ood_count,
|
|
125
|
+
total_count=test_size,
|
|
126
|
+
ood_percentage=round(ood_pct, 2),
|
|
127
|
+
threshold_score=round(threshold_score, 6),
|
|
128
|
+
samples=samples,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Embedding extraction helpers
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get_embeddings_for_context(
|
|
138
|
+
dc: DatasetContext,
|
|
139
|
+
dataset: AnnotatedDataset[Any],
|
|
140
|
+
) -> NDArray[np.float32]:
|
|
141
|
+
"""Extract embeddings for a dataset context, using cache if available."""
|
|
142
|
+
if dc.extractor is None:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"OOD detection requires a model/extractor to compute embeddings. Configure 'models' in the task config."
|
|
145
|
+
)
|
|
146
|
+
sel_key = selection_repr(dataset)
|
|
147
|
+
with contextlib.ExitStack() as stack:
|
|
148
|
+
if dc.cache is not None:
|
|
149
|
+
stack.enter_context(active_cache(dc.cache, sel_key))
|
|
150
|
+
return get_or_compute_embeddings(
|
|
151
|
+
dataset,
|
|
152
|
+
dc.extractor,
|
|
153
|
+
dc.transforms,
|
|
154
|
+
dc.batch_size,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Unique method keys
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _unique_method_keys(
|
|
164
|
+
detectors: Sequence[OODDetectorConfig], # type: ignore[type-arg]
|
|
165
|
+
) -> list[str]:
|
|
166
|
+
"""Return a unique key for each detector, appending a numeric suffix for duplicates."""
|
|
167
|
+
counts: dict[str, int] = {}
|
|
168
|
+
for det in detectors:
|
|
169
|
+
counts[det.method] = counts.get(det.method, 0) + 1
|
|
170
|
+
seen: dict[str, int] = {}
|
|
171
|
+
keys: list[str] = []
|
|
172
|
+
for det in detectors:
|
|
173
|
+
base = det.method
|
|
174
|
+
if counts[base] == 1:
|
|
175
|
+
keys.append(base)
|
|
176
|
+
else:
|
|
177
|
+
idx = seen.get(base, 0) + 1
|
|
178
|
+
seen[base] = idx
|
|
179
|
+
keys.append(f"{base}_{idx}")
|
|
180
|
+
return keys
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Detector execution
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _run_all_ood_detectors(
|
|
189
|
+
params: OODDetectionParameters,
|
|
190
|
+
ref_embeddings: NDArray[np.float32],
|
|
191
|
+
test_embeddings: NDArray[np.float32],
|
|
192
|
+
) -> tuple[dict[str, DetectorOODResultDict], dict[str, str], list[str], list[NDArray[np.bool_]]]:
|
|
193
|
+
"""Run all configured OOD detectors and return results, names, errors, and is_ood arrays."""
|
|
194
|
+
logger.info("[3/5] Running %d OOD detector(s)…", len(params.detectors))
|
|
195
|
+
t0 = _time.monotonic()
|
|
196
|
+
|
|
197
|
+
detector_results: dict[str, DetectorOODResultDict] = {}
|
|
198
|
+
detector_names: dict[str, str] = {}
|
|
199
|
+
detector_errors: list[str] = []
|
|
200
|
+
is_ood_arrays: list[NDArray[np.bool_]] = []
|
|
201
|
+
method_keys = _unique_method_keys(params.detectors)
|
|
202
|
+
test_size = len(test_embeddings)
|
|
203
|
+
|
|
204
|
+
for det_config, method_key in zip(params.detectors, method_keys, strict=True):
|
|
205
|
+
display = _ood_detector_display_name(det_config)
|
|
206
|
+
detector_names[method_key] = display
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
detector = _build_ood_detector(det_config)
|
|
210
|
+
detector.fit(ref_embeddings)
|
|
211
|
+
output: OODOutput = detector.predict(test_embeddings)
|
|
212
|
+
|
|
213
|
+
detector_results[method_key] = _serialize_ood_result(output, det_config, test_size)
|
|
214
|
+
is_ood_arrays.append(output.is_ood)
|
|
215
|
+
|
|
216
|
+
ood_count = int(np.sum(output.is_ood))
|
|
217
|
+
logger.info(" %s: %d/%d OOD (%.1f%%)", display, ood_count, test_size, 100.0 * ood_count / test_size)
|
|
218
|
+
except Exception as e: # noqa: BLE001
|
|
219
|
+
logger.warning("OOD detector %s failed: %s", display, e, exc_info=True)
|
|
220
|
+
detector_errors.append(f"{display}: {e}")
|
|
221
|
+
|
|
222
|
+
logger.info("[3/5] OOD detection complete in %.1fs", _time.monotonic() - t0)
|
|
223
|
+
return detector_results, detector_names, detector_errors, is_ood_arrays
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Metadata insights
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _extract_metadata_factors(
|
|
232
|
+
dc: DatasetContext,
|
|
233
|
+
dataset: AnnotatedDataset[Any],
|
|
234
|
+
) -> dict[str, NDArray[Any]] | None:
|
|
235
|
+
"""Extract metadata factor arrays from a dataset, returning None on failure.
|
|
236
|
+
|
|
237
|
+
Drops the ``id`` factor (not useful for deviation analysis) and includes
|
|
238
|
+
class labels when available.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
with contextlib.ExitStack() as stack:
|
|
242
|
+
if dc.cache is not None:
|
|
243
|
+
sel_key = selection_repr(dataset)
|
|
244
|
+
stack.enter_context(active_cache(dc.cache, sel_key))
|
|
245
|
+
metadata = get_or_compute_metadata(dataset)
|
|
246
|
+
factor_names = list(metadata.factor_names)
|
|
247
|
+
|
|
248
|
+
# Extract raw continuous values from the dataframe for deviation analysis
|
|
249
|
+
df = metadata.dataframe
|
|
250
|
+
factors: dict[str, NDArray[Any]] = {}
|
|
251
|
+
for name in factor_names:
|
|
252
|
+
if name in df.columns:
|
|
253
|
+
factors[name] = df[name].to_numpy()
|
|
254
|
+
|
|
255
|
+
# Drop 'id' — not useful for deviation/predictor analysis
|
|
256
|
+
factors.pop("id", None)
|
|
257
|
+
|
|
258
|
+
# Include class labels as a numeric factor when available
|
|
259
|
+
if hasattr(metadata, "class_labels") and metadata.class_labels is not None:
|
|
260
|
+
labels = np.asarray(metadata.class_labels)
|
|
261
|
+
if np.issubdtype(labels.dtype, np.number) and len(labels) == len(df):
|
|
262
|
+
factors["class_label"] = labels
|
|
263
|
+
|
|
264
|
+
return factors if factors else None
|
|
265
|
+
except Exception: # noqa: BLE001
|
|
266
|
+
logger.warning("Failed to extract metadata factors", exc_info=True)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _extract_stats_factors(
|
|
271
|
+
dc: DatasetContext,
|
|
272
|
+
dataset: AnnotatedDataset[Any],
|
|
273
|
+
) -> dict[str, NDArray[Any]] | None:
|
|
274
|
+
"""Compute per-image stats and return as ``{metric_name: array}``."""
|
|
275
|
+
from dataeval.flags import ImageStats
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
with contextlib.ExitStack() as stack:
|
|
279
|
+
if dc.cache is not None:
|
|
280
|
+
sel_key = selection_repr(dataset)
|
|
281
|
+
stack.enter_context(active_cache(dc.cache, sel_key))
|
|
282
|
+
stats_result = get_or_compute_stats(
|
|
283
|
+
desired_flags=ImageStats.ALL,
|
|
284
|
+
dataset=dataset,
|
|
285
|
+
per_image=True,
|
|
286
|
+
per_target=False,
|
|
287
|
+
per_channel=False,
|
|
288
|
+
)
|
|
289
|
+
stats_map = stats_result.get("stats", {})
|
|
290
|
+
if not stats_map:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
n_images = stats_result.get("image_count", 0)
|
|
294
|
+
factors: dict[str, NDArray[Any]] = {}
|
|
295
|
+
for name, arr in stats_map.items():
|
|
296
|
+
arr = np.asarray(arr)
|
|
297
|
+
# Only keep numeric arrays with exactly one value per image
|
|
298
|
+
if np.issubdtype(arr.dtype, np.number) and len(arr) == n_images:
|
|
299
|
+
factors[f"f_{name}"] = arr
|
|
300
|
+
return factors if factors else None
|
|
301
|
+
except Exception: # noqa: BLE001
|
|
302
|
+
logger.warning("Failed to extract stats factors", exc_info=True)
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _merge_factor_parts(
|
|
307
|
+
meta_parts: list[dict[str, NDArray[Any]]],
|
|
308
|
+
stats_parts: list[dict[str, NDArray[Any]]],
|
|
309
|
+
) -> list[dict[str, NDArray[Any]]]:
|
|
310
|
+
"""Merge per-dataset metadata and stats factor dicts.
|
|
311
|
+
|
|
312
|
+
When metadata is available, each metadata dict is augmented with its
|
|
313
|
+
corresponding stats dict. When metadata is absent, stats-only dicts
|
|
314
|
+
are returned.
|
|
315
|
+
"""
|
|
316
|
+
if not meta_parts:
|
|
317
|
+
return list(stats_parts)
|
|
318
|
+
|
|
319
|
+
merged: list[dict[str, NDArray[Any]]] = []
|
|
320
|
+
for i, meta_dict in enumerate(meta_parts):
|
|
321
|
+
combined = dict(meta_dict)
|
|
322
|
+
if i < len(stats_parts):
|
|
323
|
+
combined.update(stats_parts[i])
|
|
324
|
+
merged.append(combined)
|
|
325
|
+
# Include any remaining stats-only datasets
|
|
326
|
+
merged.extend(stats_parts[len(meta_parts) :])
|
|
327
|
+
return merged
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _intersect_numeric_factors(
|
|
331
|
+
ref_factors: dict[str, NDArray[Any]],
|
|
332
|
+
test_factor_parts: list[dict[str, NDArray[Any]]],
|
|
333
|
+
) -> tuple[dict[str, NDArray[Any]], dict[str, NDArray[Any]]] | None:
|
|
334
|
+
"""Intersect factor keys, concatenate test parts, and filter to numeric columns."""
|
|
335
|
+
common_keys = set(ref_factors.keys())
|
|
336
|
+
for t_factors in test_factor_parts:
|
|
337
|
+
common_keys &= set(t_factors.keys())
|
|
338
|
+
|
|
339
|
+
if not common_keys:
|
|
340
|
+
logger.warning("Skipping metadata insights: no common factors between reference and test.")
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
sorted_keys = sorted(common_keys)
|
|
344
|
+
ref_common = {k: ref_factors[k] for k in sorted_keys}
|
|
345
|
+
|
|
346
|
+
# Concatenate test factors
|
|
347
|
+
test_common: dict[str, NDArray[Any]] = {}
|
|
348
|
+
for key in sorted_keys:
|
|
349
|
+
arrays = [t[key] for t in test_factor_parts if key in t]
|
|
350
|
+
test_common[key] = np.concatenate(arrays) if len(arrays) > 1 else arrays[0]
|
|
351
|
+
|
|
352
|
+
# Filter to numeric-only factors (factor_deviation/factor_predictors require numeric data)
|
|
353
|
+
numeric_keys = [k for k in sorted_keys if np.issubdtype(ref_common[k].dtype, np.number)]
|
|
354
|
+
if not numeric_keys:
|
|
355
|
+
logger.info("Skipping metadata insights: no numeric factors.")
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
# Exclude 2D arrays — factor_predictors uses np.column_stack but builds
|
|
359
|
+
# discrete_features from the number of *keys*, causing a dimension mismatch
|
|
360
|
+
# when any array is multi-dimensional.
|
|
361
|
+
numeric_keys = [k for k in numeric_keys if ref_common[k].ndim == 1]
|
|
362
|
+
if not numeric_keys:
|
|
363
|
+
logger.info("Skipping metadata insights: no 1D numeric factors.")
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# Drop factors that contain NaN/Inf or have zero variance — these cause
|
|
367
|
+
# downstream errors in factor_predictors (sklearn mutual_info_classif).
|
|
368
|
+
clean_keys: list[str] = []
|
|
369
|
+
for k in numeric_keys:
|
|
370
|
+
r, t = ref_common[k], test_common[k]
|
|
371
|
+
if not np.all(np.isfinite(r)) or not np.all(np.isfinite(t)):
|
|
372
|
+
logger.debug("Dropping factor %r: contains NaN/Inf values", k)
|
|
373
|
+
continue
|
|
374
|
+
if np.std(t) == 0:
|
|
375
|
+
logger.debug("Dropping factor %r: zero variance in test data", k)
|
|
376
|
+
continue
|
|
377
|
+
clean_keys.append(k)
|
|
378
|
+
|
|
379
|
+
if not clean_keys:
|
|
380
|
+
logger.info("Skipping metadata insights: no clean numeric factors after sanitization.")
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
{k: ref_common[k] for k in clean_keys},
|
|
385
|
+
{k: test_common[k] for k in clean_keys},
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _collect_numeric_factors(
|
|
390
|
+
ref_dc: DatasetContext,
|
|
391
|
+
ref_dataset: AnnotatedDataset[Any],
|
|
392
|
+
test_datasets: list[tuple[str, DatasetContext, AnnotatedDataset[Any]]],
|
|
393
|
+
) -> tuple[dict[str, NDArray[Any]], dict[str, NDArray[Any]]] | None:
|
|
394
|
+
"""Collect and intersect numeric metadata + stats factors from reference and test datasets.
|
|
395
|
+
|
|
396
|
+
Stats are always computed for both reference and test datasets. Metadata
|
|
397
|
+
factors (from the dataset's own metadata dicts) are included when
|
|
398
|
+
available; if test data lacks metadata, only stats-based factors are used.
|
|
399
|
+
|
|
400
|
+
Returns ``(ref_factors, test_factors)`` dicts keyed by factor name, or
|
|
401
|
+
*None* when no usable numeric factors are available.
|
|
402
|
+
"""
|
|
403
|
+
# --- Stats factors (always available) ---
|
|
404
|
+
ref_stats = _extract_stats_factors(ref_dc, ref_dataset)
|
|
405
|
+
|
|
406
|
+
test_stats_parts: list[dict[str, NDArray[Any]]] = []
|
|
407
|
+
for _, t_dc, t_ds in test_datasets:
|
|
408
|
+
t_stats = _extract_stats_factors(t_dc, t_ds)
|
|
409
|
+
if t_stats is not None:
|
|
410
|
+
test_stats_parts.append(t_stats)
|
|
411
|
+
|
|
412
|
+
# --- Metadata factors (may be absent on test data) ---
|
|
413
|
+
ref_meta = _extract_metadata_factors(ref_dc, ref_dataset)
|
|
414
|
+
|
|
415
|
+
test_meta_parts: list[dict[str, NDArray[Any]]] = []
|
|
416
|
+
for _, t_dc, t_ds in test_datasets:
|
|
417
|
+
t_meta = _extract_metadata_factors(t_dc, t_ds)
|
|
418
|
+
if t_meta is not None:
|
|
419
|
+
test_meta_parts.append(t_meta)
|
|
420
|
+
|
|
421
|
+
# Merge metadata + stats for reference
|
|
422
|
+
ref_factors: dict[str, NDArray[Any]] = {}
|
|
423
|
+
if ref_meta:
|
|
424
|
+
ref_factors.update(ref_meta)
|
|
425
|
+
if ref_stats:
|
|
426
|
+
ref_factors.update(ref_stats)
|
|
427
|
+
|
|
428
|
+
if not ref_factors:
|
|
429
|
+
logger.info("Skipping metadata insights: no reference factors.")
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
# Merge metadata + stats for test
|
|
433
|
+
test_factor_parts = _merge_factor_parts(test_meta_parts, test_stats_parts)
|
|
434
|
+
|
|
435
|
+
if not test_factor_parts:
|
|
436
|
+
logger.info("Skipping metadata insights: no test factors.")
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
return _intersect_numeric_factors(ref_factors, test_factor_parts)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _compute_metadata_insights(
|
|
443
|
+
ref_dc: DatasetContext,
|
|
444
|
+
ref_dataset: AnnotatedDataset[Any],
|
|
445
|
+
test_datasets: list[tuple[str, DatasetContext, AnnotatedDataset[Any]]],
|
|
446
|
+
ood_indices: list[int],
|
|
447
|
+
max_insights: int,
|
|
448
|
+
) -> tuple[list[FactorDeviationDict] | None, dict[str, float] | None]:
|
|
449
|
+
"""Compute factor_deviation and factor_predictors for OOD samples."""
|
|
450
|
+
if not ood_indices:
|
|
451
|
+
return None, None
|
|
452
|
+
|
|
453
|
+
logger.info("[5/5] Computing metadata insights for %d OOD samples…", len(ood_indices))
|
|
454
|
+
t0 = _time.monotonic()
|
|
455
|
+
|
|
456
|
+
collected = _collect_numeric_factors(ref_dc, ref_dataset, test_datasets)
|
|
457
|
+
if collected is None:
|
|
458
|
+
return None, None
|
|
459
|
+
ref_factors_common, test_factors_common = collected
|
|
460
|
+
|
|
461
|
+
# Compute factor_deviation for top OOD samples
|
|
462
|
+
capped_indices = ood_indices[:max_insights]
|
|
463
|
+
deviations_list: list[FactorDeviationDict] | None = None
|
|
464
|
+
try:
|
|
465
|
+
raw_devs = factor_deviation(ref_factors_common, test_factors_common, capped_indices)
|
|
466
|
+
deviations_list = [
|
|
467
|
+
FactorDeviationDict(index=idx, deviations=dict(devs))
|
|
468
|
+
for idx, devs in zip(capped_indices, raw_devs, strict=True)
|
|
469
|
+
]
|
|
470
|
+
except Exception: # noqa: BLE001
|
|
471
|
+
logger.warning("factor_deviation failed", exc_info=True)
|
|
472
|
+
|
|
473
|
+
# Compute factor_predictors across all OOD samples
|
|
474
|
+
predictors: dict[str, float] | None = None
|
|
475
|
+
try:
|
|
476
|
+
raw_preds = factor_predictors(test_factors_common, ood_indices)
|
|
477
|
+
predictors = {k: round(float(v), 4) for k, v in sorted(raw_preds.items(), key=lambda x: -x[1])}
|
|
478
|
+
except Exception: # noqa: BLE001
|
|
479
|
+
logger.warning("factor_predictors failed", exc_info=True)
|
|
480
|
+
|
|
481
|
+
logger.info("[5/5] Metadata insights complete in %.1fs", _time.monotonic() - t0)
|
|
482
|
+
return deviations_list, predictors
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
# Workflow class
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class OODDetectionWorkflow(WorkflowProtocol[OODDetectionMetadata, OODDetectionOutputs]):
|
|
491
|
+
"""OOD detection workflow using DataEval OOD detectors."""
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def name(self) -> str:
|
|
495
|
+
"""Name of the workflow, used in configs and task routing."""
|
|
496
|
+
return "ood-detection"
|
|
497
|
+
|
|
498
|
+
@property
|
|
499
|
+
def description(self) -> str:
|
|
500
|
+
"""Description of the workflow for users."""
|
|
501
|
+
return "Detect out-of-distribution samples in test data against a reference dataset"
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def params_schema(self) -> type[OODDetectionParameters]:
|
|
505
|
+
"""Params schema is the union of all supported OOD detector configs, plus workflow-level settings."""
|
|
506
|
+
return OODDetectionParameters
|
|
507
|
+
|
|
508
|
+
@property
|
|
509
|
+
def output_schema(self) -> type[OODDetectionOutputs]:
|
|
510
|
+
"""Output schema includes both raw detector outputs and a user-friendly report."""
|
|
511
|
+
return OODDetectionOutputs
|
|
512
|
+
|
|
513
|
+
def execute(
|
|
514
|
+
self,
|
|
515
|
+
context: WorkflowContext,
|
|
516
|
+
params: BaseModel | None = None,
|
|
517
|
+
) -> WorkflowResult[OODDetectionMetadata, OODDetectionOutputs]:
|
|
518
|
+
"""Run OOD detection workflow."""
|
|
519
|
+
if not isinstance(context, WorkflowContext):
|
|
520
|
+
return WorkflowResult(
|
|
521
|
+
name=self.name,
|
|
522
|
+
success=False,
|
|
523
|
+
data=self._empty_outputs(),
|
|
524
|
+
errors=[f"Expected WorkflowContext, got {type(context).__name__}"],
|
|
525
|
+
metadata=OODDetectionMetadata(),
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
if params is None:
|
|
529
|
+
return WorkflowResult(
|
|
530
|
+
name=self.name,
|
|
531
|
+
success=False,
|
|
532
|
+
data=self._empty_outputs(),
|
|
533
|
+
errors=["OODDetectionParameters required"],
|
|
534
|
+
metadata=OODDetectionMetadata(),
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
if not isinstance(params, OODDetectionParameters):
|
|
538
|
+
return WorkflowResult(
|
|
539
|
+
name=self.name,
|
|
540
|
+
success=False,
|
|
541
|
+
data=self._empty_outputs(),
|
|
542
|
+
errors=[f"Expected OODDetectionParameters, got {type(params).__name__}"],
|
|
543
|
+
metadata=OODDetectionMetadata(),
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
return self._run(context, params)
|
|
548
|
+
except Exception as e:
|
|
549
|
+
logger.exception("Workflow '%s' failed", self.name)
|
|
550
|
+
return WorkflowResult(
|
|
551
|
+
name=self.name,
|
|
552
|
+
success=False,
|
|
553
|
+
data=self._empty_outputs(),
|
|
554
|
+
errors=[f"Workflow execution failed: {e}"],
|
|
555
|
+
metadata=OODDetectionMetadata(),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def _run(
|
|
559
|
+
self,
|
|
560
|
+
context: WorkflowContext,
|
|
561
|
+
params: OODDetectionParameters,
|
|
562
|
+
) -> WorkflowResult[OODDetectionMetadata, OODDetectionOutputs]:
|
|
563
|
+
"""Core execution logic."""
|
|
564
|
+
# --- 1. Validate: need 2+ datasets ---
|
|
565
|
+
dc_items = list(context.dataset_contexts.items())
|
|
566
|
+
if len(dc_items) < 2:
|
|
567
|
+
return WorkflowResult(
|
|
568
|
+
name=self.name,
|
|
569
|
+
success=False,
|
|
570
|
+
data=self._empty_outputs(),
|
|
571
|
+
errors=[
|
|
572
|
+
f"OOD detection requires at least 2 datasets (reference + test), "
|
|
573
|
+
f"got {len(dc_items)}: {[n for n, _ in dc_items]}"
|
|
574
|
+
],
|
|
575
|
+
metadata=OODDetectionMetadata(),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# --- 2. Prepare datasets ---
|
|
579
|
+
ref_dc, ref_dataset, test_datasets = self._prepare_datasets(dc_items)
|
|
580
|
+
|
|
581
|
+
# --- 3. Extract embeddings ---
|
|
582
|
+
ref_embeddings, test_embeddings = self._extract_all_embeddings(ref_dc, ref_dataset, test_datasets)
|
|
583
|
+
|
|
584
|
+
# --- 4. Run OOD detectors ---
|
|
585
|
+
detector_results, detector_names, detector_errors, is_ood_arrays = _run_all_ood_detectors(
|
|
586
|
+
params, ref_embeddings, test_embeddings
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# --- 5. Compute union OOD indices ---
|
|
590
|
+
ood_indices: list[int] = []
|
|
591
|
+
if is_ood_arrays:
|
|
592
|
+
union_ood = np.zeros(len(test_embeddings), dtype=bool)
|
|
593
|
+
for arr in is_ood_arrays:
|
|
594
|
+
union_ood |= arr
|
|
595
|
+
ood_indices = [int(i) for i in np.where(union_ood)[0]]
|
|
596
|
+
|
|
597
|
+
# --- 6. Metadata insights ---
|
|
598
|
+
factor_devs: list[FactorDeviationDict] | None = None
|
|
599
|
+
factor_preds: dict[str, float] | None = None
|
|
600
|
+
if params.metadata_insights and ood_indices:
|
|
601
|
+
factor_devs, factor_preds = _compute_metadata_insights(
|
|
602
|
+
ref_dc, ref_dataset, test_datasets, ood_indices, params.max_ood_insights
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# --- 7. Build outputs ---
|
|
606
|
+
return self._build_workflow_result(
|
|
607
|
+
params,
|
|
608
|
+
ref_embeddings,
|
|
609
|
+
test_embeddings,
|
|
610
|
+
detector_results,
|
|
611
|
+
detector_names,
|
|
612
|
+
detector_errors,
|
|
613
|
+
ood_indices,
|
|
614
|
+
factor_devs,
|
|
615
|
+
factor_preds,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def _prepare_datasets(
|
|
619
|
+
self,
|
|
620
|
+
dc_items: list[tuple[str, DatasetContext]],
|
|
621
|
+
) -> tuple[DatasetContext, AnnotatedDataset[Any], list[tuple[str, DatasetContext, AnnotatedDataset[Any]]]]:
|
|
622
|
+
"""Identify reference vs test datasets and apply selections."""
|
|
623
|
+
from dataeval_flow.selection import build_selection
|
|
624
|
+
|
|
625
|
+
ref_name, ref_dc = dc_items[0]
|
|
626
|
+
test_contexts = dc_items[1:]
|
|
627
|
+
|
|
628
|
+
logger.info(
|
|
629
|
+
"[1/5] Preparing datasets: reference=%s, test=%s",
|
|
630
|
+
ref_name,
|
|
631
|
+
[n for n, _ in test_contexts],
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
# Apply selection to reference
|
|
635
|
+
ref_dataset: AnnotatedDataset[Any] = ref_dc.dataset
|
|
636
|
+
if ref_dc.selection_steps:
|
|
637
|
+
ref_dataset = build_selection(ref_dataset, ref_dc.selection_steps) # type: ignore[arg-type]
|
|
638
|
+
|
|
639
|
+
# Apply selection to test datasets
|
|
640
|
+
test_datasets: list[tuple[str, DatasetContext, AnnotatedDataset[Any]]] = []
|
|
641
|
+
for t_name, t_dc in test_contexts:
|
|
642
|
+
t_ds = t_dc.dataset
|
|
643
|
+
if t_dc.selection_steps:
|
|
644
|
+
t_ds = build_selection(t_ds, t_dc.selection_steps) # type: ignore[arg-type]
|
|
645
|
+
test_datasets.append((t_name, t_dc, t_ds))
|
|
646
|
+
|
|
647
|
+
return ref_dc, ref_dataset, test_datasets
|
|
648
|
+
|
|
649
|
+
def _extract_all_embeddings(
|
|
650
|
+
self,
|
|
651
|
+
ref_dc: DatasetContext,
|
|
652
|
+
ref_dataset: AnnotatedDataset[Any],
|
|
653
|
+
test_datasets: list[tuple[str, DatasetContext, AnnotatedDataset[Any]]],
|
|
654
|
+
) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
|
|
655
|
+
"""Extract embeddings for reference and test datasets."""
|
|
656
|
+
logger.info("[2/5] Extracting embeddings…")
|
|
657
|
+
t0 = _time.monotonic()
|
|
658
|
+
|
|
659
|
+
ref_embeddings = _get_embeddings_for_context(ref_dc, ref_dataset)
|
|
660
|
+
logger.info(" Reference embeddings: %s", ref_embeddings.shape)
|
|
661
|
+
|
|
662
|
+
test_embedding_parts: list[NDArray[np.float32]] = []
|
|
663
|
+
for t_name, t_dc, t_ds in test_datasets:
|
|
664
|
+
emb = _get_embeddings_for_context(t_dc, t_ds)
|
|
665
|
+
test_embedding_parts.append(emb)
|
|
666
|
+
logger.info(" Test embeddings (%s): %s", t_name, emb.shape)
|
|
667
|
+
|
|
668
|
+
test_embeddings = (
|
|
669
|
+
np.concatenate(test_embedding_parts, axis=0) if len(test_embedding_parts) > 1 else test_embedding_parts[0]
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
logger.info(
|
|
673
|
+
"[2/5] Embeddings ready in %.1fs (ref=%d, test=%d)",
|
|
674
|
+
_time.monotonic() - t0,
|
|
675
|
+
len(ref_embeddings),
|
|
676
|
+
len(test_embeddings),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
return ref_embeddings, test_embeddings
|
|
680
|
+
|
|
681
|
+
def _build_workflow_result(
|
|
682
|
+
self,
|
|
683
|
+
params: OODDetectionParameters,
|
|
684
|
+
ref_embeddings: NDArray[np.float32],
|
|
685
|
+
test_embeddings: NDArray[np.float32],
|
|
686
|
+
detector_results: dict[str, DetectorOODResultDict],
|
|
687
|
+
detector_names: dict[str, str],
|
|
688
|
+
detector_errors: list[str],
|
|
689
|
+
ood_indices: list[int],
|
|
690
|
+
factor_deviations: list[FactorDeviationDict] | None,
|
|
691
|
+
factor_predictors_result: dict[str, float] | None,
|
|
692
|
+
) -> WorkflowResult[OODDetectionMetadata, OODDetectionOutputs]:
|
|
693
|
+
"""Build the final workflow result from raw outputs."""
|
|
694
|
+
raw = OODDetectionRawOutputs(
|
|
695
|
+
dataset_size=len(ref_embeddings) + len(test_embeddings),
|
|
696
|
+
reference_size=len(ref_embeddings),
|
|
697
|
+
test_size=len(test_embeddings),
|
|
698
|
+
detectors=detector_results,
|
|
699
|
+
ood_indices=ood_indices,
|
|
700
|
+
factor_deviations=factor_deviations,
|
|
701
|
+
factor_predictors=factor_predictors_result,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
findings = build_findings(raw, params, detector_names)
|
|
705
|
+
|
|
706
|
+
summary = f"OOD detection complete. Reference: {raw.reference_size} items, Test: {raw.test_size} items."
|
|
707
|
+
|
|
708
|
+
report = OODDetectionReport(summary=summary, findings=findings)
|
|
709
|
+
|
|
710
|
+
result_metadata = OODDetectionMetadata(
|
|
711
|
+
mode=params.mode,
|
|
712
|
+
detectors_used=list(detector_results.keys()),
|
|
713
|
+
metadata_insights_enabled=params.metadata_insights and bool(ood_indices),
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
return WorkflowResult(
|
|
717
|
+
name=self.name,
|
|
718
|
+
success=True,
|
|
719
|
+
data=OODDetectionOutputs(raw=raw, report=report),
|
|
720
|
+
metadata=result_metadata,
|
|
721
|
+
errors=detector_errors if detector_errors else [],
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
def _empty_outputs(self) -> OODDetectionOutputs:
|
|
725
|
+
return OODDetectionOutputs(
|
|
726
|
+
raw=OODDetectionRawOutputs(dataset_size=0),
|
|
727
|
+
report=OODDetectionReport(summary="Workflow failed", findings=[]),
|
|
728
|
+
)
|