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,134 @@
|
|
|
1
|
+
"""OOD detection 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 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
|
+
"DetectorOODResultDict",
|
|
16
|
+
"FactorDeviationDict",
|
|
17
|
+
"OODDetectionMetadata",
|
|
18
|
+
"OODDetectionOutputs",
|
|
19
|
+
"OODDetectionRawOutputs",
|
|
20
|
+
"OODDetectionReport",
|
|
21
|
+
"OODDetectionResult",
|
|
22
|
+
"OODSampleDict",
|
|
23
|
+
"is_ood_result",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# TypedDicts for serialized detector outputs
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OODSampleDict(TypedDict):
|
|
33
|
+
"""Per-sample OOD result."""
|
|
34
|
+
|
|
35
|
+
index: int
|
|
36
|
+
score: float
|
|
37
|
+
is_ood: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _DetectorOODResultRequired(TypedDict):
|
|
41
|
+
"""Required fields for an OOD detector result."""
|
|
42
|
+
|
|
43
|
+
method: str
|
|
44
|
+
ood_count: int
|
|
45
|
+
total_count: int
|
|
46
|
+
ood_percentage: float
|
|
47
|
+
threshold_score: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DetectorOODResultDict(_DetectorOODResultRequired, total=False):
|
|
51
|
+
"""Serialized result from a single OOD detector.
|
|
52
|
+
|
|
53
|
+
``samples`` contains per-sample scores and OOD flags for all test
|
|
54
|
+
samples.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
samples: list[OODSampleDict]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FactorDeviationDict(TypedDict):
|
|
61
|
+
"""Per-sample metadata factor deviations for an OOD sample."""
|
|
62
|
+
|
|
63
|
+
index: int
|
|
64
|
+
deviations: dict[str, float]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Pydantic output models
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OODDetectionRawOutputs(WorkflowOutputsBase):
|
|
73
|
+
"""Machine-readable results from OOD detection workflow."""
|
|
74
|
+
|
|
75
|
+
reference_size: int = Field(
|
|
76
|
+
default=0,
|
|
77
|
+
description="Number of items in the reference dataset.",
|
|
78
|
+
)
|
|
79
|
+
test_size: int = Field(
|
|
80
|
+
default=0,
|
|
81
|
+
description="Number of items in the test dataset(s).",
|
|
82
|
+
)
|
|
83
|
+
detectors: dict[str, DetectorOODResultDict] = Field(
|
|
84
|
+
default_factory=dict,
|
|
85
|
+
description="Per-detector results keyed by method name.",
|
|
86
|
+
)
|
|
87
|
+
ood_indices: list[int] = Field(
|
|
88
|
+
default_factory=list,
|
|
89
|
+
description="Union of OOD sample indices across all detectors.",
|
|
90
|
+
)
|
|
91
|
+
factor_deviations: list[FactorDeviationDict] | None = Field(
|
|
92
|
+
default=None,
|
|
93
|
+
description="Per-OOD-sample metadata factor deviations. None if metadata insights disabled.",
|
|
94
|
+
)
|
|
95
|
+
factor_predictors: dict[str, float] | None = Field(
|
|
96
|
+
default=None,
|
|
97
|
+
description="Mutual information (bits) per metadata factor with OOD status. None if insights disabled.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OODDetectionReport(WorkflowReportBase):
|
|
102
|
+
"""Human-readable report for OOD detection workflow."""
|
|
103
|
+
|
|
104
|
+
findings: list[Reportable] = Field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OODDetectionOutputs(BaseModel):
|
|
108
|
+
"""Complete OOD detection workflow output."""
|
|
109
|
+
|
|
110
|
+
raw: OODDetectionRawOutputs
|
|
111
|
+
report: OODDetectionReport
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class OODDetectionMetadata(ResultMetadata):
|
|
115
|
+
"""Metadata for the ood-detection workflow."""
|
|
116
|
+
|
|
117
|
+
mode: Literal["advisory", "preparatory"] = "advisory"
|
|
118
|
+
detectors_used: list[str] = Field(default_factory=list)
|
|
119
|
+
metadata_insights_enabled: bool = False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Type alias and TypeIs guard for type narrowing
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
#: Fully typed result alias for the ood-detection workflow.
|
|
127
|
+
OODDetectionResult: TypeAlias = "WorkflowResult[OODDetectionMetadata, OODDetectionOutputs]"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_ood_result(
|
|
131
|
+
result: "WorkflowResult[Any, Any]",
|
|
132
|
+
) -> TypeIs["WorkflowResult[OODDetectionMetadata, OODDetectionOutputs]"]:
|
|
133
|
+
"""Narrow a generic ``WorkflowResult`` to an OOD detection result."""
|
|
134
|
+
return isinstance(result.metadata, OODDetectionMetadata)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""OOD detection workflow parameters."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Annotated, ClassVar, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from dataeval_flow.workflow.base import WorkflowParametersBase
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"OODDetectionParameters",
|
|
12
|
+
"OODDetectorConfig",
|
|
13
|
+
"OODDetectorDomainClassifier",
|
|
14
|
+
"OODDetectorKNeighbors",
|
|
15
|
+
"OODHealthThresholds",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# OOD detector configs — discriminated union on ``method``
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OODDetectorKNeighbors(BaseModel):
|
|
25
|
+
"""K-nearest neighbors OOD detector.
|
|
26
|
+
|
|
27
|
+
Uses average distance to k nearest neighbors in embedding space to
|
|
28
|
+
detect OOD samples. Samples with larger average distances to their
|
|
29
|
+
k nearest neighbors in the reference set are considered more likely OOD.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
33
|
+
|
|
34
|
+
method: Literal["kneighbors"] = "kneighbors"
|
|
35
|
+
k: int = Field(
|
|
36
|
+
default=10,
|
|
37
|
+
gt=0,
|
|
38
|
+
description="Number of nearest neighbors to consider.",
|
|
39
|
+
)
|
|
40
|
+
distance_metric: Literal["cosine", "euclidean"] = Field(
|
|
41
|
+
default="cosine",
|
|
42
|
+
description="Distance metric for k-NN computation.",
|
|
43
|
+
)
|
|
44
|
+
threshold_perc: float = Field(
|
|
45
|
+
default=95.0,
|
|
46
|
+
gt=0.0,
|
|
47
|
+
le=100.0,
|
|
48
|
+
description=(
|
|
49
|
+
"Percentage of reference data considered normal (0-100). "
|
|
50
|
+
"Higher values result in more permissive thresholds."
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OODDetectorDomainClassifier(BaseModel):
|
|
56
|
+
"""Domain classifier OOD detector.
|
|
57
|
+
|
|
58
|
+
Uses a LightGBM classifier's ability to distinguish test samples from
|
|
59
|
+
reference samples as an OOD signal. Samples that a classifier can easily
|
|
60
|
+
identify as 'not reference' are likely OOD.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
64
|
+
|
|
65
|
+
method: Literal["domain_classifier"] = "domain_classifier"
|
|
66
|
+
n_folds: int = Field(
|
|
67
|
+
default=5,
|
|
68
|
+
ge=2,
|
|
69
|
+
description="Number of cross-validation folds per repeat.",
|
|
70
|
+
)
|
|
71
|
+
n_repeats: int = Field(
|
|
72
|
+
default=5,
|
|
73
|
+
ge=1,
|
|
74
|
+
description="Number of times to repeat the k-fold split.",
|
|
75
|
+
)
|
|
76
|
+
n_std: float = Field(
|
|
77
|
+
default=2.0,
|
|
78
|
+
gt=0.0,
|
|
79
|
+
description="Number of standard deviations above the null mean for threshold.",
|
|
80
|
+
)
|
|
81
|
+
threshold_perc: float = Field(
|
|
82
|
+
default=95.0,
|
|
83
|
+
gt=0.0,
|
|
84
|
+
le=100.0,
|
|
85
|
+
description=(
|
|
86
|
+
"Percentage of reference data considered normal (0-100). "
|
|
87
|
+
"Higher values result in more permissive thresholds."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Discriminated union — Pydantic selects the right model based on ``method``.
|
|
93
|
+
OODDetectorConfig = Annotated[
|
|
94
|
+
OODDetectorKNeighbors | OODDetectorDomainClassifier,
|
|
95
|
+
Field(discriminator="method"),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Health thresholds
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OODHealthThresholds(BaseModel):
|
|
105
|
+
"""Configurable thresholds that control finding severity.
|
|
106
|
+
|
|
107
|
+
Findings that exceed a threshold are elevated to ``severity="warning"``;
|
|
108
|
+
otherwise they stay at ``severity="info"`` or ``severity="ok"``.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
ood_pct_warning: float = Field(
|
|
112
|
+
default=10.0,
|
|
113
|
+
ge=0.0,
|
|
114
|
+
le=100.0,
|
|
115
|
+
description="Percentage of test samples flagged OOD that triggers a warning.",
|
|
116
|
+
)
|
|
117
|
+
ood_pct_info: float = Field(
|
|
118
|
+
default=1.0,
|
|
119
|
+
ge=0.0,
|
|
120
|
+
le=100.0,
|
|
121
|
+
description=(
|
|
122
|
+
"Percentage of test samples flagged OOD that triggers an info finding. "
|
|
123
|
+
"Below this percentage, severity is 'ok'."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Top-level parameters
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class OODDetectionParameters(WorkflowParametersBase):
|
|
134
|
+
"""Parameters for the ood-detection workflow.
|
|
135
|
+
|
|
136
|
+
At least one detector must be configured. Metadata insights are
|
|
137
|
+
enabled by default to explain why samples are flagged OOD.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
detectors: Sequence[OODDetectorConfig] = Field(
|
|
141
|
+
min_length=1,
|
|
142
|
+
description="List of OOD detectors to run. At least one required.",
|
|
143
|
+
)
|
|
144
|
+
health_thresholds: OODHealthThresholds = Field(
|
|
145
|
+
default_factory=OODHealthThresholds,
|
|
146
|
+
description="Warning thresholds for OOD severity classification.",
|
|
147
|
+
)
|
|
148
|
+
metadata_insights: bool = Field(
|
|
149
|
+
default=True,
|
|
150
|
+
description=(
|
|
151
|
+
"Whether to compute factor_deviation and factor_predictors for OOD samples to explain why they are flagged."
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
max_ood_insights: int = Field(
|
|
155
|
+
default=50,
|
|
156
|
+
gt=0,
|
|
157
|
+
description=(
|
|
158
|
+
"Maximum number of OOD samples to compute detailed metadata "
|
|
159
|
+
"deviation for. Caps compute cost on large datasets."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Findings builders for the OOD detection workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from dataeval_flow.workflow.base import Reportable
|
|
8
|
+
from dataeval_flow.workflows.ood.outputs import (
|
|
9
|
+
DetectorOODResultDict,
|
|
10
|
+
FactorDeviationDict,
|
|
11
|
+
OODDetectionRawOutputs,
|
|
12
|
+
)
|
|
13
|
+
from dataeval_flow.workflows.ood.params import (
|
|
14
|
+
OODDetectionParameters,
|
|
15
|
+
OODHealthThresholds,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _severity_for_ood(
|
|
20
|
+
ood_pct: float,
|
|
21
|
+
thresholds: OODHealthThresholds,
|
|
22
|
+
) -> Literal["ok", "info", "warning"]:
|
|
23
|
+
"""Determine severity for an OOD detector result based on OOD percentage."""
|
|
24
|
+
if ood_pct >= thresholds.ood_pct_warning:
|
|
25
|
+
return "warning"
|
|
26
|
+
if ood_pct >= thresholds.ood_pct_info:
|
|
27
|
+
return "info"
|
|
28
|
+
return "ok"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _score_histogram_lines(det_result: DetectorOODResultDict, n_bins: int = 10) -> list[str]:
|
|
32
|
+
"""Build ASCII histogram lines for a detector's score distribution."""
|
|
33
|
+
samples = det_result.get("samples", [])
|
|
34
|
+
if not samples:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
in_scores = [s["score"] for s in samples if not s["is_ood"]]
|
|
38
|
+
ood_scores = [s["score"] for s in samples if s["is_ood"]]
|
|
39
|
+
all_scores = [s["score"] for s in samples]
|
|
40
|
+
|
|
41
|
+
lo = min(all_scores)
|
|
42
|
+
hi = max(all_scores)
|
|
43
|
+
if hi == lo:
|
|
44
|
+
return [f"All scores = {lo:.4f}"]
|
|
45
|
+
|
|
46
|
+
bin_w = (hi - lo) / n_bins
|
|
47
|
+
threshold = det_result["threshold_score"]
|
|
48
|
+
bar_max = 30
|
|
49
|
+
|
|
50
|
+
# Build bins
|
|
51
|
+
bins: list[tuple[float, float, int, int]] = []
|
|
52
|
+
for i in range(n_bins):
|
|
53
|
+
b_lo = lo + i * bin_w
|
|
54
|
+
b_hi = b_lo + bin_w
|
|
55
|
+
ic = sum(1 for s in in_scores if (b_lo <= s < b_hi) or (i == n_bins - 1 and s == b_hi))
|
|
56
|
+
oc = sum(1 for s in ood_scores if (b_lo <= s < b_hi) or (i == n_bins - 1 and s == b_hi))
|
|
57
|
+
bins.append((b_lo, b_hi, ic, oc))
|
|
58
|
+
|
|
59
|
+
max_count = max(ic + oc for _, _, ic, oc in bins) or 1
|
|
60
|
+
|
|
61
|
+
# Format ranges to determine column width
|
|
62
|
+
range_strs = [f"{lo:.3f}-{hi:.3f}" for lo, hi, _, _ in bins]
|
|
63
|
+
w_range = max(len(r) for r in range_strs)
|
|
64
|
+
|
|
65
|
+
lines = [
|
|
66
|
+
"",
|
|
67
|
+
f"{'Range':>{w_range}} {'In':>4} {'OOD':>4}",
|
|
68
|
+
f"{'-' * w_range} {'-' * 4} {'-' * 4} {'-' * bar_max}",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
for range_str, (_, _, ic, oc) in zip(range_strs, bins, strict=True):
|
|
72
|
+
total_bar = int(((ic + oc) / max_count) * bar_max)
|
|
73
|
+
in_bar = int((ic / max_count) * bar_max) if ic else 0
|
|
74
|
+
ood_bar = total_bar - in_bar
|
|
75
|
+
bar = "\u2588" * in_bar + "\u2591" * ood_bar
|
|
76
|
+
b_lo = float(range_str.split("-")[0])
|
|
77
|
+
b_hi = float(range_str.split("-")[1])
|
|
78
|
+
marker = " \u2190 threshold" if b_lo <= threshold < b_hi else ""
|
|
79
|
+
lines.append(f"{range_str:>{w_range}} {ic:4d} {oc:4d} {bar}{marker}")
|
|
80
|
+
|
|
81
|
+
lines.append(f"\u2588 in-dist \u2591 OOD (threshold={threshold:.4f})")
|
|
82
|
+
return lines
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_detector_finding(
|
|
86
|
+
name: str,
|
|
87
|
+
result: DetectorOODResultDict,
|
|
88
|
+
thresholds: OODHealthThresholds,
|
|
89
|
+
) -> Reportable:
|
|
90
|
+
"""Build a finding for a single OOD detector."""
|
|
91
|
+
ood_pct = result["ood_percentage"]
|
|
92
|
+
severity = _severity_for_ood(ood_pct, thresholds)
|
|
93
|
+
|
|
94
|
+
detail_lines = _score_histogram_lines(result)
|
|
95
|
+
|
|
96
|
+
data: dict[str, Any] = {
|
|
97
|
+
"ood_count": result["ood_count"],
|
|
98
|
+
"total_count": result["total_count"],
|
|
99
|
+
"ood_percentage": f"{ood_pct:.1f}%",
|
|
100
|
+
"threshold_score": round(result["threshold_score"], 6),
|
|
101
|
+
"detail_lines": detail_lines,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
description = f"{name}: {result['ood_count']}/{result['total_count']} samples OOD ({ood_pct:.1f}%)"
|
|
105
|
+
|
|
106
|
+
return Reportable(
|
|
107
|
+
report_type="key_value",
|
|
108
|
+
severity=severity,
|
|
109
|
+
title=name,
|
|
110
|
+
data=data,
|
|
111
|
+
description=description,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _build_factor_predictors_finding(
|
|
116
|
+
predictors: dict[str, float],
|
|
117
|
+
) -> Reportable:
|
|
118
|
+
"""Build a table finding showing mutual information per factor."""
|
|
119
|
+
data: dict[str, Any] = {
|
|
120
|
+
"table_data": {k: round(v, 4) for k, v in predictors.items()},
|
|
121
|
+
"table_headers": ("Factor", "MI (bits)"),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return Reportable(
|
|
125
|
+
report_type="table",
|
|
126
|
+
severity="info",
|
|
127
|
+
title="OOD Factor Predictors",
|
|
128
|
+
data=data,
|
|
129
|
+
description="Mutual information between metadata factors and OOD status (higher = stronger association)",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _compute_normalized_scores(
|
|
134
|
+
detectors: dict[str, DetectorOODResultDict],
|
|
135
|
+
) -> tuple[dict[int, float], set[int], dict[str, set[int]]]:
|
|
136
|
+
"""Normalize OOD scores across detectors and find mutually agreed OOD samples.
|
|
137
|
+
|
|
138
|
+
Scores are normalized by dividing by the detector's threshold, giving a
|
|
139
|
+
unitless ratio where 1.0 = at threshold and >1.0 = OOD. This makes scores
|
|
140
|
+
comparable across detectors with different scales (e.g. distance-based
|
|
141
|
+
KNeighbors vs probability-based DomainClassifier).
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
normalized_scores
|
|
146
|
+
Mapping of sample index to mean normalized score across all detectors.
|
|
147
|
+
mutual_ood
|
|
148
|
+
Set of sample indices flagged as OOD by *every* detector.
|
|
149
|
+
unique_ood
|
|
150
|
+
Per-detector sets of OOD indices unique to that detector (not in mutual).
|
|
151
|
+
"""
|
|
152
|
+
# Collect per-detector OOD index sets and normalized scores
|
|
153
|
+
per_detector_ood: dict[str, set[int]] = {}
|
|
154
|
+
per_sample_norm: dict[int, list[float]] = {}
|
|
155
|
+
|
|
156
|
+
for method, det_result in detectors.items():
|
|
157
|
+
threshold = det_result["threshold_score"]
|
|
158
|
+
if threshold <= 0:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
ood_set: set[int] = set()
|
|
162
|
+
for s in det_result.get("samples", []):
|
|
163
|
+
norm = s["score"] / threshold
|
|
164
|
+
per_sample_norm.setdefault(s["index"], []).append(norm)
|
|
165
|
+
if s["is_ood"]:
|
|
166
|
+
ood_set.add(s["index"])
|
|
167
|
+
per_detector_ood[method] = ood_set
|
|
168
|
+
|
|
169
|
+
# Mutual agreement: intersection of all detectors' OOD sets
|
|
170
|
+
ood_sets = list(per_detector_ood.values())
|
|
171
|
+
mutual_ood = ood_sets[0].intersection(*ood_sets[1:]) if ood_sets else set()
|
|
172
|
+
|
|
173
|
+
# Per-detector unique OOD (flagged by this detector only, not in mutual)
|
|
174
|
+
unique_ood = {method: ood - mutual_ood for method, ood in per_detector_ood.items()}
|
|
175
|
+
|
|
176
|
+
# Average normalized score across detectors
|
|
177
|
+
normalized_scores = {idx: sum(vals) / len(vals) for idx, vals in per_sample_norm.items()}
|
|
178
|
+
|
|
179
|
+
return normalized_scores, mutual_ood, unique_ood
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_factor_deviations_finding(
|
|
183
|
+
deviations: list[FactorDeviationDict],
|
|
184
|
+
normalized_scores: dict[int, float],
|
|
185
|
+
mutual_ood: set[int],
|
|
186
|
+
) -> Reportable:
|
|
187
|
+
"""Build a finding for per-sample metadata deviations.
|
|
188
|
+
|
|
189
|
+
Only includes samples that all detectors agree are OOD, sorted by
|
|
190
|
+
normalized OOD score (descending).
|
|
191
|
+
"""
|
|
192
|
+
# Filter to mutually agreed OOD samples
|
|
193
|
+
agreed_devs = [d for d in deviations if d["index"] in mutual_ood]
|
|
194
|
+
|
|
195
|
+
# Sort by normalized score (most OOD first)
|
|
196
|
+
agreed_devs.sort(key=lambda d: normalized_scores.get(d["index"], 0.0), reverse=True)
|
|
197
|
+
|
|
198
|
+
detail_lines: list[str] = []
|
|
199
|
+
for dev in agreed_devs[:10]: # Cap display at 10 samples
|
|
200
|
+
norm = normalized_scores.get(dev["index"], 0.0)
|
|
201
|
+
top_factors = list(dev["deviations"].items())[:3]
|
|
202
|
+
factors_str = ", ".join(f"{k}={v:.2f}" for k, v in top_factors)
|
|
203
|
+
detail_lines.append(f"Sample {dev['index']:4d} (score={norm:.2f}x): {factors_str}")
|
|
204
|
+
|
|
205
|
+
n_agreed = len(agreed_devs)
|
|
206
|
+
n_total = len(deviations)
|
|
207
|
+
|
|
208
|
+
return Reportable(
|
|
209
|
+
report_type="key_value",
|
|
210
|
+
severity="info",
|
|
211
|
+
title="OOD Sample Metadata Deviations",
|
|
212
|
+
data={"detail_lines": detail_lines},
|
|
213
|
+
description=(
|
|
214
|
+
f"{n_agreed}/{n_total} OOD samples agreed by all detectors "
|
|
215
|
+
f"(sorted by normalized score, showing top {min(10, n_agreed)})"
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _build_aggregate_finding(
|
|
221
|
+
mutual_ood: set[int],
|
|
222
|
+
normalized_scores: dict[int, float],
|
|
223
|
+
total_ood: int,
|
|
224
|
+
test_size: int,
|
|
225
|
+
thresholds: OODHealthThresholds,
|
|
226
|
+
) -> Reportable:
|
|
227
|
+
"""Build a finding for the aggregate (mutually agreed) OOD result."""
|
|
228
|
+
sorted_indices = sorted(mutual_ood, key=lambda i: normalized_scores.get(i, 0.0), reverse=True)
|
|
229
|
+
detail_lines: list[str] = []
|
|
230
|
+
for idx in sorted_indices[:10]:
|
|
231
|
+
norm = normalized_scores.get(idx, 0.0)
|
|
232
|
+
detail_lines.append(f"Sample {idx:4d} (score={norm:.2f}x)")
|
|
233
|
+
|
|
234
|
+
n_mutual = len(mutual_ood)
|
|
235
|
+
ood_pct = (n_mutual / test_size * 100) if test_size else 0.0
|
|
236
|
+
severity = _severity_for_ood(ood_pct, thresholds)
|
|
237
|
+
|
|
238
|
+
return Reportable(
|
|
239
|
+
report_type="key_value",
|
|
240
|
+
severity=severity,
|
|
241
|
+
title="Aggregate OOD (all detectors agree)",
|
|
242
|
+
data={"detail_lines": detail_lines},
|
|
243
|
+
description=(
|
|
244
|
+
f"{n_mutual}/{total_ood} OOD samples agreed by all detectors ({ood_pct:.1f}%) "
|
|
245
|
+
f"(sorted by normalized score, showing top {min(10, n_mutual)})"
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _build_unique_ood_finding(
|
|
251
|
+
unique_ood: dict[str, set[int]],
|
|
252
|
+
normalized_scores: dict[int, float],
|
|
253
|
+
detector_names: dict[str, str],
|
|
254
|
+
) -> Reportable:
|
|
255
|
+
"""Build a single finding listing OOD samples unique to each detector."""
|
|
256
|
+
detail_lines: list[str] = []
|
|
257
|
+
for method_key, unique_indices in unique_ood.items():
|
|
258
|
+
if not unique_indices:
|
|
259
|
+
continue
|
|
260
|
+
name = detector_names.get(method_key, method_key)
|
|
261
|
+
sorted_indices = sorted(unique_indices, key=lambda i: normalized_scores.get(i, 0.0), reverse=True)
|
|
262
|
+
detail_lines.append(f"{name}: {len(unique_indices)} unique sample(s)")
|
|
263
|
+
for idx in sorted_indices[:10]:
|
|
264
|
+
norm = normalized_scores.get(idx, 0.0)
|
|
265
|
+
detail_lines.append(f" Sample {idx:4d} (score={norm:.2f}x)")
|
|
266
|
+
|
|
267
|
+
total_unique = sum(len(v) for v in unique_ood.values())
|
|
268
|
+
|
|
269
|
+
return Reportable(
|
|
270
|
+
report_type="key_value",
|
|
271
|
+
severity="info",
|
|
272
|
+
title="Unique OOD Samples (single-detector only)",
|
|
273
|
+
data={"detail_lines": detail_lines},
|
|
274
|
+
description=f"{total_unique} sample(s) flagged by only one detector",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def build_findings(
|
|
279
|
+
raw: OODDetectionRawOutputs,
|
|
280
|
+
params: OODDetectionParameters,
|
|
281
|
+
detector_names: dict[str, str],
|
|
282
|
+
) -> list[Reportable]:
|
|
283
|
+
"""Build all report findings from raw results."""
|
|
284
|
+
findings: list[Reportable] = []
|
|
285
|
+
multi_detector = len(raw.detectors) > 1
|
|
286
|
+
|
|
287
|
+
# Per-detector findings
|
|
288
|
+
for method_key, result in raw.detectors.items():
|
|
289
|
+
name = detector_names.get(method_key, method_key)
|
|
290
|
+
findings.append(_build_detector_finding(name, result, params.health_thresholds))
|
|
291
|
+
|
|
292
|
+
# Compute normalized scores for cross-detector comparison
|
|
293
|
+
normalized_scores, mutual_ood, unique_ood = _compute_normalized_scores(raw.detectors)
|
|
294
|
+
|
|
295
|
+
# Aggregate + unique findings (only when multiple detectors)
|
|
296
|
+
if multi_detector:
|
|
297
|
+
total_ood = len(raw.ood_indices)
|
|
298
|
+
findings.append(
|
|
299
|
+
_build_aggregate_finding(mutual_ood, normalized_scores, total_ood, raw.test_size, params.health_thresholds)
|
|
300
|
+
)
|
|
301
|
+
if any(unique_ood.values()):
|
|
302
|
+
findings.append(_build_unique_ood_finding(unique_ood, normalized_scores, detector_names))
|
|
303
|
+
|
|
304
|
+
# Metadata insights findings
|
|
305
|
+
if raw.factor_predictors:
|
|
306
|
+
findings.append(_build_factor_predictors_finding(raw.factor_predictors))
|
|
307
|
+
|
|
308
|
+
if raw.factor_deviations:
|
|
309
|
+
findings.append(_build_factor_deviations_finding(raw.factor_deviations, normalized_scores, mutual_ood))
|
|
310
|
+
|
|
311
|
+
return findings
|