dataeval-flow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. dataeval_flow/__init__.py +93 -0
  2. dataeval_flow/__main__.py +149 -0
  3. dataeval_flow/_app/__init__.py +5 -0
  4. dataeval_flow/_app/_model/__init__.py +5 -0
  5. dataeval_flow/_app/_model/_coerce.py +126 -0
  6. dataeval_flow/_app/_model/_discover.py +171 -0
  7. dataeval_flow/_app/_model/_execution.py +108 -0
  8. dataeval_flow/_app/_model/_introspect.py +280 -0
  9. dataeval_flow/_app/_model/_item.py +213 -0
  10. dataeval_flow/_app/_model/_registry.py +255 -0
  11. dataeval_flow/_app/_model/_state.py +322 -0
  12. dataeval_flow/_app/_model/_undo.py +61 -0
  13. dataeval_flow/_app/_panes/__init__.py +35 -0
  14. dataeval_flow/_app/_panes/_config_pane.py +173 -0
  15. dataeval_flow/_app/_panes/_result_pane.py +125 -0
  16. dataeval_flow/_app/_panes/_task_pane.py +91 -0
  17. dataeval_flow/_app/_panes/_widgets.py +111 -0
  18. dataeval_flow/_app/_screens/__init__.py +25 -0
  19. dataeval_flow/_app/_screens/_base.py +242 -0
  20. dataeval_flow/_app/_screens/_detail.py +333 -0
  21. dataeval_flow/_app/_screens/_model.py +102 -0
  22. dataeval_flow/_app/_screens/_params.py +80 -0
  23. dataeval_flow/_app/_screens/_pathpicker.py +68 -0
  24. dataeval_flow/_app/_screens/_section.py +621 -0
  25. dataeval_flow/_app/_screens/_settings.py +183 -0
  26. dataeval_flow/_app/_viewmodel/__init__.py +15 -0
  27. dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
  28. dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
  29. dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
  30. dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
  31. dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
  32. dataeval_flow/_app/app.py +742 -0
  33. dataeval_flow/_app/cli.py +592 -0
  34. dataeval_flow/_logging.py +102 -0
  35. dataeval_flow/cache.py +1355 -0
  36. dataeval_flow/config/__init__.py +80 -0
  37. dataeval_flow/config/_loader.py +79 -0
  38. dataeval_flow/config/_merge.py +92 -0
  39. dataeval_flow/config/_models.py +115 -0
  40. dataeval_flow/config/_paths.py +85 -0
  41. dataeval_flow/config/schemas/__init__.py +112 -0
  42. dataeval_flow/config/schemas/_dataset.py +111 -0
  43. dataeval_flow/config/schemas/_extractor.py +119 -0
  44. dataeval_flow/config/schemas/_metadata.py +28 -0
  45. dataeval_flow/config/schemas/_preprocessor.py +18 -0
  46. dataeval_flow/config/schemas/_selection.py +100 -0
  47. dataeval_flow/config/schemas/_task.py +89 -0
  48. dataeval_flow/config/schemas/_workflow.py +135 -0
  49. dataeval_flow/dataset.py +635 -0
  50. dataeval_flow/embeddings.py +135 -0
  51. dataeval_flow/metadata.py +48 -0
  52. dataeval_flow/preprocessing.py +141 -0
  53. dataeval_flow/py.typed +0 -0
  54. dataeval_flow/runner.py +118 -0
  55. dataeval_flow/selection.py +50 -0
  56. dataeval_flow/workflow/__init__.py +328 -0
  57. dataeval_flow/workflow/_text_report.py +511 -0
  58. dataeval_flow/workflow/base.py +69 -0
  59. dataeval_flow/workflow/orchestrator.py +454 -0
  60. dataeval_flow/workflows/__init__.py +1 -0
  61. dataeval_flow/workflows/analysis/__init__.py +38 -0
  62. dataeval_flow/workflows/analysis/outputs.py +202 -0
  63. dataeval_flow/workflows/analysis/params.py +114 -0
  64. dataeval_flow/workflows/analysis/workflow.py +1313 -0
  65. dataeval_flow/workflows/cleaning/__init__.py +23 -0
  66. dataeval_flow/workflows/cleaning/outputs.py +200 -0
  67. dataeval_flow/workflows/cleaning/params.py +160 -0
  68. dataeval_flow/workflows/cleaning/report.py +304 -0
  69. dataeval_flow/workflows/cleaning/workflow.py +794 -0
  70. dataeval_flow/workflows/drift/__init__.py +1 -0
  71. dataeval_flow/workflows/drift/outputs.py +144 -0
  72. dataeval_flow/workflows/drift/params.py +332 -0
  73. dataeval_flow/workflows/drift/report.py +201 -0
  74. dataeval_flow/workflows/drift/workflow.py +647 -0
  75. dataeval_flow/workflows/ood/__init__.py +1 -0
  76. dataeval_flow/workflows/ood/outputs.py +134 -0
  77. dataeval_flow/workflows/ood/params.py +161 -0
  78. dataeval_flow/workflows/ood/report.py +311 -0
  79. dataeval_flow/workflows/ood/workflow.py +728 -0
  80. dataeval_flow/workflows/prioritization/__init__.py +1 -0
  81. dataeval_flow/workflows/prioritization/outputs.py +122 -0
  82. dataeval_flow/workflows/prioritization/params.py +124 -0
  83. dataeval_flow/workflows/prioritization/report.py +117 -0
  84. dataeval_flow/workflows/prioritization/workflow.py +587 -0
  85. dataeval_flow/workflows/splitting/__init__.py +25 -0
  86. dataeval_flow/workflows/splitting/outputs.py +101 -0
  87. dataeval_flow/workflows/splitting/params.py +61 -0
  88. dataeval_flow/workflows/splitting/report.py +485 -0
  89. dataeval_flow/workflows/splitting/workflow.py +371 -0
  90. dataeval_flow-0.1.0.dist-info/METADATA +305 -0
  91. dataeval_flow-0.1.0.dist-info/RECORD +94 -0
  92. dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
  93. dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
  94. dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )