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 @@
|
|
|
1
|
+
"""Drift monitoring workflow."""
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Drift monitoring workflow outputs."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from typing_extensions import TypedDict, TypeIs
|
|
7
|
+
|
|
8
|
+
from dataeval_flow.config.schemas import ResultMetadata
|
|
9
|
+
from dataeval_flow.workflow.base import Reportable, WorkflowOutputsBase, WorkflowReportBase
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from dataeval_flow.workflow import WorkflowResult
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ChunkResultDict",
|
|
16
|
+
"ClasswiseDriftDict",
|
|
17
|
+
"ClasswiseDriftRowDict",
|
|
18
|
+
"DetectorResultDict",
|
|
19
|
+
"DriftMonitoringMetadata",
|
|
20
|
+
"DriftMonitoringOutputs",
|
|
21
|
+
"DriftMonitoringRawOutputs",
|
|
22
|
+
"DriftMonitoringReport",
|
|
23
|
+
"DriftMonitoringResult",
|
|
24
|
+
"is_drift_result",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# TypedDicts for serialized detector outputs
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChunkResultDict(TypedDict):
|
|
34
|
+
"""Single chunk result from chunked drift analysis."""
|
|
35
|
+
|
|
36
|
+
key: str # e.g. "[0:100]"
|
|
37
|
+
index: int
|
|
38
|
+
start_index: int
|
|
39
|
+
end_index: int
|
|
40
|
+
value: float
|
|
41
|
+
upper_threshold: float | None
|
|
42
|
+
lower_threshold: float | None
|
|
43
|
+
drifted: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _DetectorResultRequired(TypedDict):
|
|
47
|
+
"""Required fields for a detector result."""
|
|
48
|
+
|
|
49
|
+
method: str
|
|
50
|
+
drifted: bool
|
|
51
|
+
distance: float
|
|
52
|
+
threshold: float
|
|
53
|
+
metric_name: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DetectorResultDict(_DetectorResultRequired, total=False):
|
|
57
|
+
"""Serialized result from a single drift detector.
|
|
58
|
+
|
|
59
|
+
``details`` contains detector-specific statistics (p-values, per-feature
|
|
60
|
+
flags, fold AUROCs, etc.). ``chunks`` is present only when chunked
|
|
61
|
+
analysis is enabled.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
details: dict[str, Any]
|
|
65
|
+
chunks: list[ChunkResultDict]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ClasswiseDriftRowDict(TypedDict):
|
|
69
|
+
"""Per-class drift result for a single detector."""
|
|
70
|
+
|
|
71
|
+
class_name: str
|
|
72
|
+
drifted: bool
|
|
73
|
+
distance: float
|
|
74
|
+
p_val: float | None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ClasswiseDriftDict(TypedDict):
|
|
78
|
+
"""Classwise drift results for one detector."""
|
|
79
|
+
|
|
80
|
+
detector: str
|
|
81
|
+
rows: list[ClasswiseDriftRowDict]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Pydantic output models
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DriftMonitoringRawOutputs(WorkflowOutputsBase):
|
|
90
|
+
"""Machine-readable results from drift monitoring workflow."""
|
|
91
|
+
|
|
92
|
+
reference_size: int = Field(
|
|
93
|
+
default=0,
|
|
94
|
+
description="Number of items in the reference dataset.",
|
|
95
|
+
)
|
|
96
|
+
test_size: int = Field(
|
|
97
|
+
default=0,
|
|
98
|
+
description="Number of items in the test dataset(s).",
|
|
99
|
+
)
|
|
100
|
+
detectors: dict[str, DetectorResultDict] = Field(
|
|
101
|
+
default_factory=dict,
|
|
102
|
+
description="Per-detector results keyed by method name.",
|
|
103
|
+
)
|
|
104
|
+
classwise: list[ClasswiseDriftDict] | None = Field(
|
|
105
|
+
default=None,
|
|
106
|
+
description="Per-class drift results (one entry per detector). None if classwise disabled.",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DriftMonitoringReport(WorkflowReportBase):
|
|
111
|
+
"""Human-readable report for drift monitoring workflow."""
|
|
112
|
+
|
|
113
|
+
findings: list[Reportable] = Field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class DriftMonitoringOutputs(BaseModel):
|
|
117
|
+
"""Complete drift monitoring workflow output."""
|
|
118
|
+
|
|
119
|
+
raw: DriftMonitoringRawOutputs
|
|
120
|
+
report: DriftMonitoringReport
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DriftMonitoringMetadata(ResultMetadata):
|
|
124
|
+
"""Metadata for the drift-monitoring workflow."""
|
|
125
|
+
|
|
126
|
+
mode: Literal["advisory", "preparatory"] = "advisory"
|
|
127
|
+
detectors_used: list[str] = Field(default_factory=list)
|
|
128
|
+
chunking_enabled: bool = False
|
|
129
|
+
classwise_enabled: bool = False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Type alias and TypeIs guard for type narrowing
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
#: Fully typed result alias for the drift-monitoring workflow.
|
|
137
|
+
DriftMonitoringResult: TypeAlias = "WorkflowResult[DriftMonitoringMetadata, DriftMonitoringOutputs]"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_drift_result(
|
|
141
|
+
result: "WorkflowResult[Any, Any]",
|
|
142
|
+
) -> TypeIs["WorkflowResult[DriftMonitoringMetadata, DriftMonitoringOutputs]"]:
|
|
143
|
+
"""Narrow a generic ``WorkflowResult`` to a drift-monitoring result."""
|
|
144
|
+
return isinstance(result.metadata, DriftMonitoringMetadata)
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Drift monitoring workflow parameters."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Annotated, ClassVar, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
from dataeval_flow.workflow.base import WorkflowParametersBase
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ChunkingConfig",
|
|
12
|
+
"DriftDetectorConfig",
|
|
13
|
+
"DriftDetectorDomainClassifier",
|
|
14
|
+
"DriftDetectorKNeighbors",
|
|
15
|
+
"DriftDetectorMMD",
|
|
16
|
+
"DriftDetectorUnivariate",
|
|
17
|
+
"DriftHealthThresholds",
|
|
18
|
+
"DriftMonitoringParameters",
|
|
19
|
+
"UpdateStrategyConfig",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Chunking configuration
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChunkingConfig(BaseModel):
|
|
29
|
+
"""Configuration for chunked (temporal) drift analysis.
|
|
30
|
+
|
|
31
|
+
When present, the test data is split into sequential chunks and each
|
|
32
|
+
chunk is tested for drift independently. This answers "when did drift
|
|
33
|
+
start?" and "did it recover?".
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
chunk_size: int | None = Field(
|
|
37
|
+
default=None,
|
|
38
|
+
gt=0,
|
|
39
|
+
description="Fixed number of samples per chunk. Mutually exclusive with chunk_count.",
|
|
40
|
+
)
|
|
41
|
+
chunk_count: int | None = Field(
|
|
42
|
+
default=None,
|
|
43
|
+
gt=0,
|
|
44
|
+
description="Split test data into this many equal chunks. Mutually exclusive with chunk_size.",
|
|
45
|
+
)
|
|
46
|
+
incomplete: Literal["keep", "drop", "append"] = Field(
|
|
47
|
+
default="keep",
|
|
48
|
+
description=(
|
|
49
|
+
"How to handle a final chunk smaller than chunk_size. "
|
|
50
|
+
"'keep': retain as-is, 'drop': discard, 'append': merge into last full chunk."
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
threshold_multiplier: float = Field(
|
|
54
|
+
default=3.0,
|
|
55
|
+
gt=0.0,
|
|
56
|
+
description=(
|
|
57
|
+
"Z-score multiplier for the chunk-level drift threshold. "
|
|
58
|
+
"Lower values are more sensitive (e.g. 2.0), higher values more conservative."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _validate_chunking(self) -> "ChunkingConfig":
|
|
64
|
+
if self.chunk_size is None and self.chunk_count is None:
|
|
65
|
+
raise ValueError("Either chunk_size or chunk_count must be set.")
|
|
66
|
+
if self.chunk_size is not None and self.chunk_count is not None:
|
|
67
|
+
raise ValueError("chunk_size and chunk_count are mutually exclusive.")
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Drift detector configs — discriminated union on ``method``
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DriftDetectorUnivariate(BaseModel):
|
|
77
|
+
"""Univariate statistical test per feature.
|
|
78
|
+
|
|
79
|
+
Applies a chosen statistical test independently to each feature dimension
|
|
80
|
+
and flags drift when enough features reject the null hypothesis (after
|
|
81
|
+
multiple-testing correction).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
85
|
+
|
|
86
|
+
method: Literal["univariate"] = "univariate"
|
|
87
|
+
test: Literal["ks", "cvm", "mwu", "anderson", "bws"] = Field(
|
|
88
|
+
default="ks",
|
|
89
|
+
description=(
|
|
90
|
+
"Statistical test to apply per feature. "
|
|
91
|
+
"'ks' (Kolmogorov-Smirnov) is a good general-purpose default. "
|
|
92
|
+
"'cvm' (Cramér-von Mises) integrates squared CDF distance. "
|
|
93
|
+
"'mwu' (Mann-Whitney U) is rank-based and outlier-robust. "
|
|
94
|
+
"'anderson' (Anderson-Darling) is tail-sensitive. "
|
|
95
|
+
"'bws' (Baumgartner-Weiss-Schindler) has high power with tail sensitivity."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
p_val: float = Field(
|
|
99
|
+
default=0.05,
|
|
100
|
+
gt=0.0,
|
|
101
|
+
lt=1.0,
|
|
102
|
+
description="Significance level for the test.",
|
|
103
|
+
)
|
|
104
|
+
correction: Literal["bonferroni", "fdr"] = Field(
|
|
105
|
+
default="bonferroni",
|
|
106
|
+
description="Multiple-testing correction method across features.",
|
|
107
|
+
)
|
|
108
|
+
alternative: Literal["two-sided", "less", "greater"] = Field(
|
|
109
|
+
default="two-sided",
|
|
110
|
+
description="Alternative hypothesis direction.",
|
|
111
|
+
)
|
|
112
|
+
n_features: int | None = Field(
|
|
113
|
+
default=None,
|
|
114
|
+
gt=0,
|
|
115
|
+
description="Expected number of features. None = infer from reference data.",
|
|
116
|
+
)
|
|
117
|
+
classwise: bool = Field(
|
|
118
|
+
default=False,
|
|
119
|
+
description=(
|
|
120
|
+
"Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
chunking: ChunkingConfig | None = Field(
|
|
124
|
+
default=None,
|
|
125
|
+
description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class DriftDetectorMMD(BaseModel):
|
|
130
|
+
"""Maximum Mean Discrepancy with permutation test.
|
|
131
|
+
|
|
132
|
+
Computes the MMD² statistic between reference and test distributions
|
|
133
|
+
using an RBF kernel, then estimates a p-value via permutation testing.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
137
|
+
|
|
138
|
+
method: Literal["mmd"] = "mmd"
|
|
139
|
+
p_val: float = Field(
|
|
140
|
+
default=0.05,
|
|
141
|
+
gt=0.0,
|
|
142
|
+
lt=1.0,
|
|
143
|
+
description="Significance level for the permutation test.",
|
|
144
|
+
)
|
|
145
|
+
n_permutations: int = Field(
|
|
146
|
+
default=100,
|
|
147
|
+
gt=0,
|
|
148
|
+
description="Number of permutations for the test.",
|
|
149
|
+
)
|
|
150
|
+
device: str | None = Field(
|
|
151
|
+
default=None,
|
|
152
|
+
description="PyTorch device for kernel computation (e.g. 'cpu', 'cuda:0'). None = auto.",
|
|
153
|
+
)
|
|
154
|
+
classwise: bool = Field(
|
|
155
|
+
default=False,
|
|
156
|
+
description=(
|
|
157
|
+
"Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
chunking: ChunkingConfig | None = Field(
|
|
161
|
+
default=None,
|
|
162
|
+
description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DriftDetectorDomainClassifier(BaseModel):
|
|
167
|
+
"""Binary domain classifier (LightGBM) approach.
|
|
168
|
+
|
|
169
|
+
Trains a binary classifier to distinguish reference from test data.
|
|
170
|
+
An AUROC significantly above 0.5 indicates the distributions are
|
|
171
|
+
distinguishable — i.e. drift has occurred.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
175
|
+
|
|
176
|
+
method: Literal["domain_classifier"] = "domain_classifier"
|
|
177
|
+
n_folds: int = Field(
|
|
178
|
+
default=5,
|
|
179
|
+
ge=2,
|
|
180
|
+
description="Number of cross-validation folds.",
|
|
181
|
+
)
|
|
182
|
+
threshold: float = Field(
|
|
183
|
+
default=0.55,
|
|
184
|
+
gt=0.5,
|
|
185
|
+
le=1.0,
|
|
186
|
+
description="AUROC threshold above which drift is declared.",
|
|
187
|
+
)
|
|
188
|
+
classwise: bool = Field(
|
|
189
|
+
default=False,
|
|
190
|
+
description=(
|
|
191
|
+
"Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
chunking: ChunkingConfig | None = Field(
|
|
195
|
+
default=None,
|
|
196
|
+
description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class DriftDetectorKNeighbors(BaseModel):
|
|
201
|
+
"""K-nearest neighbors distance-based drift detection.
|
|
202
|
+
|
|
203
|
+
Computes per-sample k-NN distances for test data against the reference
|
|
204
|
+
set. A Mann-Whitney U test determines whether test distances are
|
|
205
|
+
stochastically larger than reference self-distances.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
209
|
+
|
|
210
|
+
method: Literal["kneighbors"] = "kneighbors"
|
|
211
|
+
k: int = Field(
|
|
212
|
+
default=10,
|
|
213
|
+
gt=0,
|
|
214
|
+
description="Number of nearest neighbors.",
|
|
215
|
+
)
|
|
216
|
+
distance_metric: Literal["cosine", "euclidean"] = Field(
|
|
217
|
+
default="euclidean",
|
|
218
|
+
description="Distance metric for k-NN computation.",
|
|
219
|
+
)
|
|
220
|
+
p_val: float = Field(
|
|
221
|
+
default=0.05,
|
|
222
|
+
gt=0.0,
|
|
223
|
+
lt=1.0,
|
|
224
|
+
description="Significance level for the Mann-Whitney U test.",
|
|
225
|
+
)
|
|
226
|
+
classwise: bool = Field(
|
|
227
|
+
default=False,
|
|
228
|
+
description=(
|
|
229
|
+
"Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
chunking: ChunkingConfig | None = Field(
|
|
233
|
+
default=None,
|
|
234
|
+
description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Discriminated union — Pydantic selects the right model based on ``method``.
|
|
239
|
+
DriftDetectorConfig = Annotated[
|
|
240
|
+
DriftDetectorUnivariate | DriftDetectorMMD | DriftDetectorDomainClassifier | DriftDetectorKNeighbors,
|
|
241
|
+
Field(discriminator="method"),
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Update strategy (stubbed for future online detection)
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class UpdateStrategyConfig(BaseModel):
|
|
251
|
+
"""Reference-set update strategy — **stubbed for future use**.
|
|
252
|
+
|
|
253
|
+
When online drift detection is implemented, this will control how the
|
|
254
|
+
reference distribution evolves after each prediction step. Currently
|
|
255
|
+
accepted in configuration but not applied at runtime.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
type: Literal["last_seen", "reservoir_sampling"] = Field(
|
|
259
|
+
description="Update algorithm. 'last_seen': sliding window. 'reservoir_sampling': uniform random sample.",
|
|
260
|
+
)
|
|
261
|
+
n: int = Field(
|
|
262
|
+
gt=0,
|
|
263
|
+
description="Number of samples to retain in the updated reference set.",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Health thresholds
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class DriftHealthThresholds(BaseModel):
|
|
273
|
+
"""Configurable thresholds that control finding severity.
|
|
274
|
+
|
|
275
|
+
Findings that exceed a threshold are elevated to ``severity="warning"``;
|
|
276
|
+
otherwise they stay at ``severity="info"``.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
any_drift_is_warning: bool = Field(
|
|
280
|
+
default=True,
|
|
281
|
+
description=(
|
|
282
|
+
"Non-chunked mode: if any detector flags drift, the finding is a warning. "
|
|
283
|
+
"Set to False to treat all non-chunked results as informational."
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
chunk_drift_pct_warning: float = Field(
|
|
287
|
+
default=10.0,
|
|
288
|
+
ge=0.0,
|
|
289
|
+
le=100.0,
|
|
290
|
+
description="Chunked mode: percentage of chunks that must drift to trigger a warning.",
|
|
291
|
+
)
|
|
292
|
+
consecutive_chunks_warning: int = Field(
|
|
293
|
+
default=3,
|
|
294
|
+
ge=1,
|
|
295
|
+
description=(
|
|
296
|
+
"Chunked mode: number of consecutive drifted chunks that triggers a warning. "
|
|
297
|
+
"Sustained drift is typically more concerning than sporadic drift."
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
classwise_any_drift_is_warning: bool = Field(
|
|
301
|
+
default=True,
|
|
302
|
+
description="Classwise mode: if any class drifts for any detector, the finding is a warning.",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Top-level parameters
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class DriftMonitoringParameters(WorkflowParametersBase):
|
|
312
|
+
"""Parameters for the drift-monitoring workflow.
|
|
313
|
+
|
|
314
|
+
At least one detector must be configured. Chunking and classwise
|
|
315
|
+
analysis are optional extensions.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
detectors: Sequence[DriftDetectorConfig] = Field(
|
|
319
|
+
min_length=1,
|
|
320
|
+
description="List of drift detectors to run. At least one required.",
|
|
321
|
+
)
|
|
322
|
+
update_strategy: UpdateStrategyConfig | None = Field(
|
|
323
|
+
default=None,
|
|
324
|
+
description=(
|
|
325
|
+
"Reference-set update strategy (stubbed — not yet applied at runtime). "
|
|
326
|
+
"Accepted in config to preserve forward compatibility."
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
health_thresholds: DriftHealthThresholds = Field(
|
|
330
|
+
default_factory=DriftHealthThresholds,
|
|
331
|
+
description="Warning thresholds for drift severity classification.",
|
|
332
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Findings builders for the drift monitoring workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from dataeval_flow.workflow.base import Reportable
|
|
10
|
+
from dataeval_flow.workflows.drift.outputs import (
|
|
11
|
+
ChunkResultDict,
|
|
12
|
+
ClasswiseDriftRowDict,
|
|
13
|
+
DetectorResultDict,
|
|
14
|
+
DriftMonitoringRawOutputs,
|
|
15
|
+
)
|
|
16
|
+
from dataeval_flow.workflows.drift.params import (
|
|
17
|
+
DriftHealthThresholds,
|
|
18
|
+
DriftMonitoringParameters,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _severity_for_detector(
|
|
23
|
+
drifted: bool,
|
|
24
|
+
thresholds: DriftHealthThresholds,
|
|
25
|
+
) -> Literal["ok", "info", "warning"]:
|
|
26
|
+
"""Determine severity for a non-chunked detector result."""
|
|
27
|
+
if drifted and thresholds.any_drift_is_warning:
|
|
28
|
+
return "warning"
|
|
29
|
+
return "info" if drifted else "ok"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _severity_for_chunks(
|
|
33
|
+
chunks: list[ChunkResultDict],
|
|
34
|
+
thresholds: DriftHealthThresholds,
|
|
35
|
+
) -> Literal["ok", "info", "warning"]:
|
|
36
|
+
"""Determine severity for chunked results."""
|
|
37
|
+
if not chunks:
|
|
38
|
+
return "ok"
|
|
39
|
+
n_drifted = sum(1 for c in chunks if c["drifted"])
|
|
40
|
+
pct = 100.0 * n_drifted / len(chunks) if chunks else 0.0
|
|
41
|
+
|
|
42
|
+
# Check consecutive drift window
|
|
43
|
+
max_consecutive = _max_consecutive_drifted(chunks)
|
|
44
|
+
|
|
45
|
+
if pct >= thresholds.chunk_drift_pct_warning:
|
|
46
|
+
return "warning"
|
|
47
|
+
if max_consecutive >= thresholds.consecutive_chunks_warning:
|
|
48
|
+
return "warning"
|
|
49
|
+
return "info" if n_drifted > 0 else "ok"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _max_consecutive_drifted(chunks: list[ChunkResultDict]) -> int:
|
|
53
|
+
"""Count the longest run of consecutive drifted chunks."""
|
|
54
|
+
max_run = 0
|
|
55
|
+
current_run = 0
|
|
56
|
+
for c in chunks:
|
|
57
|
+
if c["drifted"]:
|
|
58
|
+
current_run += 1
|
|
59
|
+
max_run = max(max_run, current_run)
|
|
60
|
+
else:
|
|
61
|
+
current_run = 0
|
|
62
|
+
return max_run
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_detector_finding(
|
|
66
|
+
name: str,
|
|
67
|
+
result: DetectorResultDict,
|
|
68
|
+
thresholds: DriftHealthThresholds,
|
|
69
|
+
classwise_rows: list[ClasswiseDriftRowDict] | None = None,
|
|
70
|
+
) -> Reportable:
|
|
71
|
+
"""Build a finding for a single detector (non-chunked)."""
|
|
72
|
+
drifted = result["drifted"]
|
|
73
|
+
severity = _severity_for_detector(drifted, thresholds)
|
|
74
|
+
data: dict[str, Any] = {
|
|
75
|
+
"distance": round(result["distance"], 4),
|
|
76
|
+
"threshold": round(result["threshold"], 4),
|
|
77
|
+
"metric": result["metric_name"],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Add p_val from details if available
|
|
81
|
+
details = result.get("details", {})
|
|
82
|
+
if isinstance(details, dict) and "p_val" in details:
|
|
83
|
+
data["p_val"] = round(float(details["p_val"]), 6)
|
|
84
|
+
|
|
85
|
+
# Univariate: summarize feature drift
|
|
86
|
+
if isinstance(details, dict) and "feature_drift" in details:
|
|
87
|
+
fd = details["feature_drift"]
|
|
88
|
+
if isinstance(fd, list):
|
|
89
|
+
n_drifted = sum(fd)
|
|
90
|
+
n_total = len(fd)
|
|
91
|
+
else:
|
|
92
|
+
n_drifted = int(np.sum(fd))
|
|
93
|
+
n_total = len(fd)
|
|
94
|
+
data["features_drifted"] = f"{n_drifted} / {n_total}"
|
|
95
|
+
|
|
96
|
+
description = f"{name}: distance={data['distance']}, threshold={data['threshold']}"
|
|
97
|
+
if "p_val" in data:
|
|
98
|
+
description += f", p={data['p_val']}"
|
|
99
|
+
|
|
100
|
+
# Classwise breakdown → render as a classwise_table instead of key_value
|
|
101
|
+
if classwise_rows:
|
|
102
|
+
drifted_classes = [r["class_name"] for r in classwise_rows if r["drifted"]]
|
|
103
|
+
n_cls_drifted = len(drifted_classes)
|
|
104
|
+
n_total = len(classwise_rows)
|
|
105
|
+
description = f"Classes drifted: {', '.join(drifted_classes)}" if drifted_classes else "No classes drifted"
|
|
106
|
+
if n_cls_drifted > 0 and thresholds.classwise_any_drift_is_warning:
|
|
107
|
+
severity = "warning"
|
|
108
|
+
|
|
109
|
+
table_rows = [
|
|
110
|
+
{
|
|
111
|
+
"Class": r["class_name"],
|
|
112
|
+
"Distance": round(r["distance"], 4),
|
|
113
|
+
"PVal": round(r["p_val"], 6) if r["p_val"] is not None else None,
|
|
114
|
+
"Status": "DRIFT" if r["drifted"] else "ok",
|
|
115
|
+
}
|
|
116
|
+
for r in classwise_rows
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
data["table_rows"] = table_rows
|
|
120
|
+
data["brief"] = f"{n_cls_drifted}/{n_total} classes drifted"
|
|
121
|
+
|
|
122
|
+
return Reportable(
|
|
123
|
+
report_type="classwise_table",
|
|
124
|
+
severity=severity,
|
|
125
|
+
title=name,
|
|
126
|
+
data=data,
|
|
127
|
+
description=description,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return Reportable(
|
|
131
|
+
report_type="key_value",
|
|
132
|
+
severity=severity,
|
|
133
|
+
title=name,
|
|
134
|
+
data=data,
|
|
135
|
+
description=description,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_chunked_finding(
|
|
140
|
+
name: str,
|
|
141
|
+
result: DetectorResultDict,
|
|
142
|
+
thresholds: DriftHealthThresholds,
|
|
143
|
+
) -> Reportable:
|
|
144
|
+
"""Build a table finding for chunked detector results."""
|
|
145
|
+
chunks = result.get("chunks", [])
|
|
146
|
+
if not chunks:
|
|
147
|
+
return _build_detector_finding(name, result, thresholds)
|
|
148
|
+
|
|
149
|
+
severity = _severity_for_chunks(chunks, thresholds)
|
|
150
|
+
n_drifted = sum(1 for c in chunks if c["drifted"])
|
|
151
|
+
pct = 100.0 * n_drifted / len(chunks) if chunks else 0.0
|
|
152
|
+
max_consec = _max_consecutive_drifted(chunks)
|
|
153
|
+
|
|
154
|
+
rows: list[dict[str, Any]] = [
|
|
155
|
+
{
|
|
156
|
+
"Chunk": c["key"],
|
|
157
|
+
"Distance": round(c["value"], 4),
|
|
158
|
+
"UpperThreshold": round(c["upper_threshold"], 4) if c["upper_threshold"] is not None else None,
|
|
159
|
+
"LowerThreshold": round(c["lower_threshold"], 4) if c["lower_threshold"] is not None else None,
|
|
160
|
+
"Status": "DRIFT" if c["drifted"] else "ok",
|
|
161
|
+
}
|
|
162
|
+
for c in chunks
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
description = f"{n_drifted}/{len(chunks)} chunks drifted ({pct:.0f}%) | max consecutive: {max_consec}"
|
|
166
|
+
|
|
167
|
+
return Reportable(
|
|
168
|
+
report_type="chunk_table",
|
|
169
|
+
severity=severity,
|
|
170
|
+
title=name,
|
|
171
|
+
data={
|
|
172
|
+
"table_rows": rows,
|
|
173
|
+
"drift_flags": [c["drifted"] for c in chunks],
|
|
174
|
+
},
|
|
175
|
+
description=description,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_findings(
|
|
180
|
+
raw: DriftMonitoringRawOutputs,
|
|
181
|
+
params: DriftMonitoringParameters,
|
|
182
|
+
detector_names: dict[str, str],
|
|
183
|
+
) -> list[Reportable]:
|
|
184
|
+
"""Build all report findings from raw results."""
|
|
185
|
+
findings: list[Reportable] = []
|
|
186
|
+
|
|
187
|
+
# Index classwise results by detector display name for per-detector lookup
|
|
188
|
+
classwise_by_detector: dict[str, list[ClasswiseDriftRowDict]] = {}
|
|
189
|
+
if raw.classwise:
|
|
190
|
+
for cw in raw.classwise:
|
|
191
|
+
classwise_by_detector[cw["detector"]] = cw["rows"]
|
|
192
|
+
|
|
193
|
+
for method_key, result in raw.detectors.items():
|
|
194
|
+
name = detector_names.get(method_key, method_key)
|
|
195
|
+
cw_rows = classwise_by_detector.get(name)
|
|
196
|
+
if result.get("chunks"):
|
|
197
|
+
findings.append(_build_chunked_finding(name, result, params.health_thresholds))
|
|
198
|
+
else:
|
|
199
|
+
findings.append(_build_detector_finding(name, result, params.health_thresholds, cw_rows))
|
|
200
|
+
|
|
201
|
+
return findings
|