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,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."""
|