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,647 @@
1
+ """Drift monitoring 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
+ import polars as pl
13
+ from dataeval.protocols import AnnotatedDataset
14
+ from dataeval.shift import (
15
+ DriftDomainClassifier,
16
+ DriftKNeighbors,
17
+ DriftMMD,
18
+ DriftOutput,
19
+ DriftUnivariate,
20
+ )
21
+ from numpy.typing import NDArray
22
+ from pydantic import BaseModel
23
+
24
+ from dataeval_flow.cache import active_cache, get_or_compute_embeddings, selection_repr
25
+ from dataeval_flow.workflow import DatasetContext, WorkflowContext, WorkflowProtocol, WorkflowResult
26
+ from dataeval_flow.workflows.drift.outputs import (
27
+ ChunkResultDict,
28
+ ClasswiseDriftDict,
29
+ ClasswiseDriftRowDict,
30
+ DetectorResultDict,
31
+ DriftMonitoringMetadata,
32
+ DriftMonitoringOutputs,
33
+ DriftMonitoringRawOutputs,
34
+ DriftMonitoringReport,
35
+ )
36
+ from dataeval_flow.workflows.drift.params import (
37
+ DriftDetectorConfig,
38
+ DriftDetectorDomainClassifier,
39
+ DriftDetectorKNeighbors,
40
+ DriftDetectorMMD,
41
+ DriftDetectorUnivariate,
42
+ DriftMonitoringParameters,
43
+ )
44
+ from dataeval_flow.workflows.drift.report import build_findings
45
+
46
+ logger: logging.Logger = logging.getLogger(__name__)
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Detector factory
50
+ # ---------------------------------------------------------------------------
51
+
52
+ # Type alias for the union of supported drift detector instances
53
+ _DriftDetector = DriftUnivariate | DriftMMD | DriftDomainClassifier | DriftKNeighbors
54
+
55
+
56
+ def _build_detector(config: DriftDetectorConfig) -> _DriftDetector: # type: ignore[type-arg]
57
+ """Instantiate a drift detector from its discriminated config."""
58
+ if isinstance(config, DriftDetectorUnivariate):
59
+ return DriftUnivariate(
60
+ method=config.test,
61
+ p_val=config.p_val,
62
+ correction=config.correction,
63
+ alternative=config.alternative,
64
+ n_features=config.n_features,
65
+ )
66
+ if isinstance(config, DriftDetectorMMD):
67
+ return DriftMMD(
68
+ p_val=config.p_val,
69
+ n_permutations=config.n_permutations,
70
+ device=config.device,
71
+ )
72
+ if isinstance(config, DriftDetectorDomainClassifier):
73
+ return DriftDomainClassifier(
74
+ n_folds=config.n_folds,
75
+ threshold=config.threshold,
76
+ )
77
+ if isinstance(config, DriftDetectorKNeighbors):
78
+ return DriftKNeighbors(
79
+ k=config.k,
80
+ distance_metric=config.distance_metric,
81
+ p_val=config.p_val,
82
+ )
83
+ raise ValueError(f"Unknown detector config type: {type(config).__name__}")
84
+
85
+
86
+ def _detector_display_name(config: DriftDetectorConfig) -> str: # type: ignore[type-arg]
87
+ """Human-readable name for a detector config, including non-default parameters."""
88
+ names: dict[str, str] = {
89
+ "univariate": "Univariate",
90
+ "mmd": "MMD",
91
+ "domain_classifier": "Domain Classifier",
92
+ "kneighbors": "K-Neighbors",
93
+ }
94
+ base = names.get(config.method, config.method)
95
+ if isinstance(config, DriftDetectorUnivariate):
96
+ base = f"{config.test.upper()} {base}"
97
+
98
+ # Collect non-default, non-internal parameters as a compact suffix
99
+ parts: list[str] = []
100
+ defaults = {name: field.default for name, field in config.model_fields.items()}
101
+ for name in config.model_fields:
102
+ if name in ("method", "chunking"):
103
+ continue
104
+ # 'test' is already in the base name for Univariate
105
+ if name == "test" and isinstance(config, DriftDetectorUnivariate):
106
+ continue
107
+ value = getattr(config, name)
108
+ if value != defaults[name]:
109
+ parts.append(f"{name}={value}")
110
+
111
+ # Add chunking params that differ from ChunkingConfig defaults
112
+ if config.chunking is not None:
113
+ chunking_defaults = {n: f.default for n, f in config.chunking.model_fields.items()}
114
+ for name in config.chunking.model_fields:
115
+ value = getattr(config.chunking, name)
116
+ if value != chunking_defaults[name]:
117
+ label = "z" if name == "threshold_multiplier" else name
118
+ parts.append(f"{label}={value}")
119
+
120
+ if parts:
121
+ base = f"{base} ({', '.join(parts)})"
122
+ return base
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Result serialization
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _serialize_result(output: DriftOutput[Any], config: DriftDetectorConfig) -> DetectorResultDict: # type: ignore[type-arg]
131
+ """Convert a non-chunked DriftOutput to a serializable dict."""
132
+ output_details = output.details if isinstance(output.details, dict) else {}
133
+ details: dict[str, Any] = {k: (v.tolist() if isinstance(v, np.ndarray) else v) for k, v in output_details.items()}
134
+ return DetectorResultDict(
135
+ method=config.method,
136
+ drifted=output.drifted,
137
+ distance=float(output.distance),
138
+ threshold=float(output.threshold),
139
+ metric_name=output.metric_name,
140
+ details=details,
141
+ )
142
+
143
+
144
+ def _serialize_chunked_result(output: DriftOutput[pl.DataFrame], config: DriftDetectorConfig) -> DetectorResultDict: # type: ignore[type-arg]
145
+ """Convert a chunked DriftOutput (with polars DataFrame details) to a serializable dict."""
146
+ output_details = output.details if isinstance(output.details, pl.DataFrame) else pl.DataFrame()
147
+ chunks: list[ChunkResultDict] = [
148
+ ChunkResultDict(
149
+ key=row["key"],
150
+ index=row["index"],
151
+ start_index=row["start_index"],
152
+ end_index=row["end_index"],
153
+ value=float(row["value"]),
154
+ upper_threshold=row.get("upper_threshold"),
155
+ lower_threshold=row.get("lower_threshold"),
156
+ drifted=row["drifted"],
157
+ )
158
+ for row in output_details.iter_rows(named=True)
159
+ ]
160
+ return DetectorResultDict(
161
+ method=config.method,
162
+ drifted=output.drifted,
163
+ distance=float(output.distance),
164
+ threshold=float(output.threshold),
165
+ metric_name=output.metric_name,
166
+ details={},
167
+ chunks=chunks,
168
+ )
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Label extraction
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def _extract_labels(dataset: AnnotatedDataset[Any]) -> NDArray[np.intp] | None:
177
+ """Extract integer class labels from dataset targets.
178
+
179
+ Returns None if the dataset has no usable labels.
180
+ """
181
+ try:
182
+ n = len(dataset)
183
+ labels: list[int] = []
184
+ for i in range(n):
185
+ _, target, _ = dataset[i]
186
+ t = np.asarray(target)
187
+ if t.ndim == 0:
188
+ labels.append(int(t))
189
+ elif t.ndim == 1 and t.size > 1:
190
+ # One-hot encoded — take argmax
191
+ labels.append(int(np.argmax(t)))
192
+ elif t.ndim == 1 and t.size == 1:
193
+ labels.append(int(t[0]))
194
+ else:
195
+ return None
196
+ return np.array(labels, dtype=np.intp)
197
+ except Exception: # noqa: BLE001
198
+ logger.debug("Could not extract labels for classwise drift", exc_info=True)
199
+ return None
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Embedding extraction helpers
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ def _get_embeddings_for_context(
208
+ dc: DatasetContext,
209
+ dataset: AnnotatedDataset[Any],
210
+ ) -> NDArray[np.float32]:
211
+ """Extract embeddings for a dataset context, using cache if available."""
212
+ if dc.extractor is None:
213
+ raise ValueError(
214
+ "Drift monitoring requires a model/extractor to compute embeddings. Configure 'models' in the task config."
215
+ )
216
+ sel_key = selection_repr(dataset)
217
+ with contextlib.ExitStack() as stack:
218
+ if dc.cache is not None:
219
+ stack.enter_context(active_cache(dc.cache, sel_key))
220
+ return get_or_compute_embeddings(
221
+ dataset,
222
+ dc.extractor,
223
+ dc.transforms,
224
+ dc.batch_size,
225
+ )
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Classwise drift detection
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def _any_classwise(detectors: Sequence[DriftDetectorConfig]) -> bool: # type: ignore[type-arg]
234
+ """Return True if any detector has classwise enabled."""
235
+ return any(d.classwise for d in detectors)
236
+
237
+
238
+ def _run_classwise_drift(
239
+ ref_embeddings: NDArray[np.float32],
240
+ test_embeddings: NDArray[np.float32],
241
+ ref_labels: NDArray[np.intp],
242
+ test_labels: NDArray[np.intp],
243
+ params: DriftMonitoringParameters,
244
+ detector_names: dict[str, str],
245
+ ) -> list[ClasswiseDriftDict]:
246
+ """Run drift detection per class (only for detectors with classwise=True)."""
247
+ unique_classes = np.unique(np.concatenate([ref_labels, test_labels]))
248
+ results: list[ClasswiseDriftDict] = []
249
+
250
+ method_keys = _unique_method_keys(params.detectors)
251
+
252
+ for det_config, method_key in zip(params.detectors, method_keys, strict=True):
253
+ if not det_config.classwise:
254
+ continue
255
+
256
+ name = detector_names.get(method_key, method_key)
257
+ rows: list[ClasswiseDriftRowDict] = []
258
+
259
+ for cls in unique_classes:
260
+ ref_mask = ref_labels == cls
261
+ test_mask = test_labels == cls
262
+ ref_cls = ref_embeddings[ref_mask]
263
+ test_cls = test_embeddings[test_mask]
264
+
265
+ if len(ref_cls) < 2 or len(test_cls) < 2:
266
+ logger.debug(
267
+ "Skipping class %s for %s: too few samples (ref=%d, test=%d)",
268
+ cls,
269
+ name,
270
+ len(ref_cls),
271
+ len(test_cls),
272
+ )
273
+ continue
274
+
275
+ try:
276
+ detector = _build_detector(det_config)
277
+ detector.fit(ref_cls)
278
+ output = detector.predict(test_cls)
279
+
280
+ p_val: float | None = None
281
+ if isinstance(output.details, dict):
282
+ p_val = output.details.get("p_val")
283
+
284
+ rows.append(
285
+ ClasswiseDriftRowDict(
286
+ class_name=str(int(cls)),
287
+ drifted=output.drifted,
288
+ distance=float(output.distance),
289
+ p_val=float(p_val) if p_val is not None else None,
290
+ )
291
+ )
292
+ except Exception: # noqa: BLE001
293
+ logger.warning("Classwise detector %s failed for class %s", name, cls, exc_info=True)
294
+ continue
295
+
296
+ results.append(ClasswiseDriftDict(detector=name, rows=rows))
297
+
298
+ return results
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Workflow class
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ def _unique_method_keys(
307
+ detectors: Sequence[DriftDetectorConfig], # type: ignore[type-arg]
308
+ ) -> list[str]:
309
+ """Return a unique key for each detector, appending a numeric suffix for duplicates."""
310
+ counts: dict[str, int] = {}
311
+ keys: list[str] = []
312
+ for det in detectors:
313
+ base = det.method
314
+ counts[base] = counts.get(base, 0) + 1
315
+ # Second pass: assign suffixes only when a method appears more than once
316
+ seen: dict[str, int] = {}
317
+ for det in detectors:
318
+ base = det.method
319
+ if counts[base] == 1:
320
+ keys.append(base)
321
+ else:
322
+ idx = seen.get(base, 0) + 1
323
+ seen[base] = idx
324
+ keys.append(f"{base}_{idx}")
325
+ return keys
326
+
327
+
328
+ def _run_all_detectors(
329
+ params: DriftMonitoringParameters,
330
+ ref_embeddings: NDArray[np.float32],
331
+ test_embeddings: NDArray[np.float32],
332
+ ) -> tuple[dict[str, DetectorResultDict], dict[str, str], list[str]]:
333
+ """Run all configured drift detectors and return results, names, and errors."""
334
+ logger.info("[3/4] Running %d drift detector(s)…", len(params.detectors))
335
+ t0 = _time.monotonic()
336
+
337
+ detector_results: dict[str, DetectorResultDict] = {}
338
+ detector_names: dict[str, str] = {}
339
+ detector_errors: list[str] = []
340
+ method_keys = _unique_method_keys(params.detectors)
341
+
342
+ for det_config, method_key in zip(params.detectors, method_keys, strict=True):
343
+ display = _detector_display_name(det_config)
344
+ detector_names[method_key] = display
345
+
346
+ try:
347
+ detector = _build_detector(det_config)
348
+
349
+ if det_config.chunking is not None:
350
+ from dataeval.utils.thresholds import ZScoreThreshold
351
+
352
+ chunk_threshold = ZScoreThreshold(multiplier=det_config.chunking.threshold_multiplier)
353
+ chunked = detector.chunked(
354
+ chunk_size=det_config.chunking.chunk_size,
355
+ chunk_count=det_config.chunking.chunk_count,
356
+ threshold=chunk_threshold,
357
+ )
358
+ chunked.fit(ref_embeddings)
359
+ output = chunked.predict(test_embeddings)
360
+ detector_results[method_key] = _serialize_chunked_result(output, det_config)
361
+ else:
362
+ detector.fit(ref_embeddings)
363
+ output = detector.predict(test_embeddings)
364
+ detector_results[method_key] = _serialize_result(output, det_config)
365
+
366
+ status = "DRIFT" if output.drifted else "ok"
367
+ logger.info(" %s: %s (distance=%.4f, threshold=%.4f)", display, status, output.distance, output.threshold)
368
+ except Exception as e: # noqa: BLE001
369
+ logger.warning("Detector %s failed: %s", display, e, exc_info=True)
370
+ detector_errors.append(f"{display}: {e}")
371
+
372
+ logger.info("[3/4] Detection complete in %.1fs", _time.monotonic() - t0)
373
+ return detector_results, detector_names, detector_errors
374
+
375
+
376
+ def _handle_classwise(
377
+ params: DriftMonitoringParameters,
378
+ ref_embeddings: NDArray[np.float32],
379
+ test_embeddings: NDArray[np.float32],
380
+ ref_labels: NDArray[np.intp] | None,
381
+ test_label_parts: list[NDArray[np.intp]],
382
+ detector_names: dict[str, str],
383
+ ) -> list[ClasswiseDriftDict] | None:
384
+ """Run classwise drift detection if enabled and labels are available."""
385
+ if not _any_classwise(params.detectors):
386
+ logger.info("[4/4] Classwise drift not enabled — skipping.")
387
+ return None
388
+
389
+ if ref_labels is None or not test_label_parts:
390
+ logger.warning("Classwise drift requested but labels not available — skipping.")
391
+ return None
392
+
393
+ test_labels = np.concatenate(test_label_parts) if len(test_label_parts) > 1 else test_label_parts[0]
394
+ logger.info("[4/4] Running classwise drift detection…")
395
+ t0 = _time.monotonic()
396
+ results = _run_classwise_drift(
397
+ ref_embeddings,
398
+ test_embeddings,
399
+ ref_labels,
400
+ test_labels,
401
+ params,
402
+ detector_names,
403
+ )
404
+ logger.info("[4/4] Classwise detection complete in %.1fs", _time.monotonic() - t0)
405
+ return results
406
+
407
+
408
+ class DriftMonitoringWorkflow(WorkflowProtocol[DriftMonitoringMetadata, DriftMonitoringOutputs]):
409
+ """Drift monitoring workflow using DataEval shift detectors."""
410
+
411
+ @property
412
+ def name(self) -> str:
413
+ """Name of the workflow, used in configs and task routing."""
414
+ return "drift-monitoring"
415
+
416
+ @property
417
+ def description(self) -> str:
418
+ """Description of the workflow for users."""
419
+ return "Monitor incoming data for distribution drift against a reference dataset"
420
+
421
+ @property
422
+ def params_schema(self) -> type[DriftMonitoringParameters]:
423
+ """Params schema is the union of all supported detector configs, plus workflow-level settings."""
424
+ return DriftMonitoringParameters
425
+
426
+ @property
427
+ def output_schema(self) -> type[DriftMonitoringOutputs]:
428
+ """Output schema includes both raw detector outputs and a user-friendly report."""
429
+ return DriftMonitoringOutputs
430
+
431
+ def execute(
432
+ self,
433
+ context: WorkflowContext,
434
+ params: BaseModel | None = None,
435
+ ) -> WorkflowResult[DriftMonitoringMetadata, DriftMonitoringOutputs]:
436
+ """Run drift monitoring workflow."""
437
+ if not isinstance(context, WorkflowContext):
438
+ return WorkflowResult(
439
+ name=self.name,
440
+ success=False,
441
+ data=self._empty_outputs(),
442
+ errors=[f"Expected WorkflowContext, got {type(context).__name__}"],
443
+ metadata=DriftMonitoringMetadata(),
444
+ )
445
+
446
+ if params is None:
447
+ return WorkflowResult(
448
+ name=self.name,
449
+ success=False,
450
+ data=self._empty_outputs(),
451
+ errors=["DriftMonitoringParameters required"],
452
+ metadata=DriftMonitoringMetadata(),
453
+ )
454
+
455
+ if not isinstance(params, DriftMonitoringParameters):
456
+ return WorkflowResult(
457
+ name=self.name,
458
+ success=False,
459
+ data=self._empty_outputs(),
460
+ errors=[f"Expected DriftMonitoringParameters, got {type(params).__name__}"],
461
+ metadata=DriftMonitoringMetadata(),
462
+ )
463
+
464
+ try:
465
+ return self._run(context, params)
466
+ except Exception as e:
467
+ logger.exception("Workflow '%s' failed", self.name)
468
+ return WorkflowResult(
469
+ name=self.name,
470
+ success=False,
471
+ data=self._empty_outputs(),
472
+ errors=[f"Workflow execution failed: {e}"],
473
+ metadata=DriftMonitoringMetadata(),
474
+ )
475
+
476
+ def _run(
477
+ self,
478
+ context: WorkflowContext,
479
+ params: DriftMonitoringParameters,
480
+ ) -> WorkflowResult[DriftMonitoringMetadata, DriftMonitoringOutputs]:
481
+ """Core execution logic."""
482
+ # --- 1. Validate: need 2+ datasets ---
483
+ dc_items = list(context.dataset_contexts.items())
484
+ if len(dc_items) < 2:
485
+ return WorkflowResult(
486
+ name=self.name,
487
+ success=False,
488
+ data=self._empty_outputs(),
489
+ errors=[
490
+ f"Drift monitoring requires at least 2 datasets (reference + test), "
491
+ f"got {len(dc_items)}: {[n for n, _ in dc_items]}"
492
+ ],
493
+ metadata=DriftMonitoringMetadata(),
494
+ )
495
+
496
+ # Log stubbed update strategy
497
+ if params.update_strategy is not None:
498
+ logger.warning(
499
+ "update_strategy is configured but not yet applied at runtime. "
500
+ "This setting is accepted for forward compatibility."
501
+ )
502
+
503
+ # --- 2. Prepare datasets ---
504
+ ref_dc, ref_dataset, test_datasets = self._prepare_datasets(dc_items)
505
+
506
+ # --- 3. Extract embeddings ---
507
+ ref_embeddings, test_embeddings, ref_labels, test_label_parts = self._extract_all_embeddings(
508
+ ref_dc, ref_dataset, test_datasets, params
509
+ )
510
+
511
+ # --- 4. Run detectors ---
512
+ detector_results, detector_names, detector_errors = _run_all_detectors(params, ref_embeddings, test_embeddings)
513
+
514
+ # --- 5. Classwise drift ---
515
+ classwise_results = _handle_classwise(
516
+ params, ref_embeddings, test_embeddings, ref_labels, test_label_parts, detector_names
517
+ )
518
+
519
+ # --- 6. Build outputs ---
520
+ return self._build_workflow_result(
521
+ params,
522
+ ref_embeddings,
523
+ test_embeddings,
524
+ detector_results,
525
+ detector_names,
526
+ detector_errors,
527
+ classwise_results,
528
+ )
529
+
530
+ def _prepare_datasets(
531
+ self,
532
+ dc_items: list[tuple[str, DatasetContext]],
533
+ ) -> tuple[DatasetContext, AnnotatedDataset[Any], list[tuple[str, DatasetContext, AnnotatedDataset[Any]]]]:
534
+ """Identify reference vs test datasets and apply selections."""
535
+ from dataeval_flow.selection import build_selection
536
+
537
+ ref_name, ref_dc = dc_items[0]
538
+ test_contexts = dc_items[1:]
539
+
540
+ logger.info(
541
+ "[1/4] Preparing datasets: reference=%s, test=%s",
542
+ ref_name,
543
+ [n for n, _ in test_contexts],
544
+ )
545
+
546
+ # Apply selection to reference
547
+ ref_dataset: AnnotatedDataset[Any] = ref_dc.dataset
548
+ if ref_dc.selection_steps:
549
+ ref_dataset = build_selection(ref_dataset, ref_dc.selection_steps) # type: ignore[arg-type]
550
+
551
+ # Apply selection to test datasets
552
+ test_datasets: list[tuple[str, DatasetContext, AnnotatedDataset[Any]]] = []
553
+ for t_name, t_dc in test_contexts:
554
+ t_ds = t_dc.dataset
555
+ if t_dc.selection_steps:
556
+ t_ds = build_selection(t_ds, t_dc.selection_steps) # type: ignore[arg-type]
557
+ test_datasets.append((t_name, t_dc, t_ds))
558
+
559
+ return ref_dc, ref_dataset, test_datasets
560
+
561
+ def _extract_all_embeddings(
562
+ self,
563
+ ref_dc: DatasetContext,
564
+ ref_dataset: AnnotatedDataset[Any],
565
+ test_datasets: list[tuple[str, DatasetContext, AnnotatedDataset[Any]]],
566
+ params: DriftMonitoringParameters,
567
+ ) -> tuple[NDArray[np.float32], NDArray[np.float32], NDArray[np.intp] | None, list[NDArray[np.intp]]]:
568
+ """Extract embeddings for reference and test datasets."""
569
+ logger.info("[2/4] Extracting embeddings…")
570
+ t0 = _time.monotonic()
571
+
572
+ ref_embeddings = _get_embeddings_for_context(ref_dc, ref_dataset)
573
+ logger.info(" Reference embeddings: %s", ref_embeddings.shape)
574
+
575
+ test_embedding_parts: list[NDArray[np.float32]] = []
576
+ test_label_parts: list[NDArray[np.intp]] = []
577
+
578
+ for t_name, t_dc, t_ds in test_datasets:
579
+ emb = _get_embeddings_for_context(t_dc, t_ds)
580
+ test_embedding_parts.append(emb)
581
+ logger.info(" Test embeddings (%s): %s", t_name, emb.shape)
582
+
583
+ if _any_classwise(params.detectors):
584
+ t_labels = _extract_labels(t_ds)
585
+ if t_labels is not None:
586
+ test_label_parts.append(t_labels)
587
+
588
+ test_embeddings = (
589
+ np.concatenate(test_embedding_parts, axis=0) if len(test_embedding_parts) > 1 else test_embedding_parts[0]
590
+ )
591
+
592
+ ref_labels = _extract_labels(ref_dataset) if _any_classwise(params.detectors) else None
593
+
594
+ logger.info(
595
+ "[2/4] Embeddings ready in %.1fs (ref=%d, test=%d)",
596
+ _time.monotonic() - t0,
597
+ len(ref_embeddings),
598
+ len(test_embeddings),
599
+ )
600
+
601
+ return ref_embeddings, test_embeddings, ref_labels, test_label_parts
602
+
603
+ def _build_workflow_result(
604
+ self,
605
+ params: DriftMonitoringParameters,
606
+ ref_embeddings: NDArray[np.float32],
607
+ test_embeddings: NDArray[np.float32],
608
+ detector_results: dict[str, DetectorResultDict],
609
+ detector_names: dict[str, str],
610
+ detector_errors: list[str],
611
+ classwise_results: list[ClasswiseDriftDict] | None,
612
+ ) -> WorkflowResult[DriftMonitoringMetadata, DriftMonitoringOutputs]:
613
+ """Build the final workflow result from raw outputs."""
614
+ raw = DriftMonitoringRawOutputs(
615
+ dataset_size=len(ref_embeddings) + len(test_embeddings),
616
+ reference_size=len(ref_embeddings),
617
+ test_size=len(test_embeddings),
618
+ detectors=detector_results,
619
+ classwise=classwise_results,
620
+ )
621
+
622
+ findings = build_findings(raw, params, detector_names)
623
+
624
+ summary = f"Drift monitoring complete. Reference: {raw.reference_size} items, Test: {raw.test_size} items."
625
+
626
+ report = DriftMonitoringReport(summary=summary, findings=findings)
627
+
628
+ result_metadata = DriftMonitoringMetadata(
629
+ mode=params.mode,
630
+ detectors_used=list(detector_results.keys()),
631
+ chunking_enabled=any(d.chunking is not None for d in params.detectors),
632
+ classwise_enabled=_any_classwise(params.detectors),
633
+ )
634
+
635
+ return WorkflowResult(
636
+ name=self.name,
637
+ success=True,
638
+ data=DriftMonitoringOutputs(raw=raw, report=report),
639
+ metadata=result_metadata,
640
+ errors=detector_errors if detector_errors else [],
641
+ )
642
+
643
+ def _empty_outputs(self) -> DriftMonitoringOutputs:
644
+ return DriftMonitoringOutputs(
645
+ raw=DriftMonitoringRawOutputs(dataset_size=0),
646
+ report=DriftMonitoringReport(summary="Workflow failed", findings=[]),
647
+ )
@@ -0,0 +1 @@
1
+ """OOD detection workflow."""