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,101 @@
|
|
|
1
|
+
"""Dataset splitting workflow outputs."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, TypeAlias
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from typing_extensions import 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
|
+
"DataSplittingMetadata",
|
|
16
|
+
"DataSplittingOutputs",
|
|
17
|
+
"DataSplittingRawOutputs",
|
|
18
|
+
"DataSplittingReport",
|
|
19
|
+
"DataSplittingResult",
|
|
20
|
+
"SplitInfo",
|
|
21
|
+
"is_splitting_result",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Raw outputs
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SplitInfo(BaseModel):
|
|
31
|
+
"""Per-fold split information."""
|
|
32
|
+
|
|
33
|
+
fold: int
|
|
34
|
+
train_indices: list[int]
|
|
35
|
+
val_indices: list[int]
|
|
36
|
+
label_stats_train: dict[str, Any] = Field(default_factory=dict)
|
|
37
|
+
label_stats_val: dict[str, Any] = Field(default_factory=dict)
|
|
38
|
+
coverage_train: dict[str, Any] | None = None
|
|
39
|
+
coverage_val: dict[str, Any] | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DataSplittingRawOutputs(WorkflowOutputsBase):
|
|
43
|
+
"""Machine-readable splitting results."""
|
|
44
|
+
|
|
45
|
+
pre_split_balance: dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
pre_split_diversity: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
label_stats_full: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
test_indices: list[int] = Field(default_factory=list)
|
|
49
|
+
label_stats_test: dict[str, Any] = Field(default_factory=dict)
|
|
50
|
+
coverage_test: dict[str, Any] | None = None
|
|
51
|
+
folds: list[SplitInfo] = Field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Report
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DataSplittingReport(WorkflowReportBase):
|
|
60
|
+
"""Human-readable splitting report."""
|
|
61
|
+
|
|
62
|
+
findings: list[Reportable] = Field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Composite output
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DataSplittingOutputs(BaseModel):
|
|
71
|
+
"""Composite output: raw results + human-readable report."""
|
|
72
|
+
|
|
73
|
+
raw: DataSplittingRawOutputs
|
|
74
|
+
report: DataSplittingReport
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Metadata
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DataSplittingMetadata(ResultMetadata):
|
|
83
|
+
"""Splitting-specific metadata extending the JATIC envelope."""
|
|
84
|
+
|
|
85
|
+
num_folds: int = 1
|
|
86
|
+
stratified: bool = True
|
|
87
|
+
split_on: list[str] | None = None
|
|
88
|
+
rebalance_method: str | None = None
|
|
89
|
+
split_sizes: dict[str, int] = Field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Result alias and guard
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
DataSplittingResult: TypeAlias = "WorkflowResult[DataSplittingMetadata, DataSplittingOutputs]"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_splitting_result(result: "WorkflowResult[Any, Any]") -> TypeIs["DataSplittingResult"]:
|
|
100
|
+
"""Type guard for splitting workflow results."""
|
|
101
|
+
return isinstance(result.metadata, DataSplittingMetadata)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Dataset splitting workflow parameters."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from dataeval_flow.workflow.base import WorkflowParametersBase
|
|
8
|
+
|
|
9
|
+
__all__ = ["DataSplittingParameters"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DataSplittingParameters(WorkflowParametersBase):
|
|
13
|
+
"""Parameters for the dataset splitting workflow.
|
|
14
|
+
|
|
15
|
+
Controls how the dataset is split into train/val/test partitions and
|
|
16
|
+
what assessments are run on each split.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
test_frac: float = Field(
|
|
20
|
+
default=0.2,
|
|
21
|
+
ge=0.0,
|
|
22
|
+
lt=1.0,
|
|
23
|
+
description="Fraction of the dataset held out for the test set.",
|
|
24
|
+
)
|
|
25
|
+
val_frac: float = Field(
|
|
26
|
+
default=0.1,
|
|
27
|
+
ge=0.0,
|
|
28
|
+
lt=1.0,
|
|
29
|
+
description="Fraction of training data reserved for validation (single-fold).",
|
|
30
|
+
)
|
|
31
|
+
num_folds: int = Field(
|
|
32
|
+
default=1,
|
|
33
|
+
ge=1,
|
|
34
|
+
description="Number of train/val folds. If 1, val_frac must be > 0.",
|
|
35
|
+
)
|
|
36
|
+
stratify: bool = Field(
|
|
37
|
+
default=True,
|
|
38
|
+
description="Preserve class distribution within each partition.",
|
|
39
|
+
)
|
|
40
|
+
split_on: list[str] | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="Metadata keys to group on so no group spans train/val.",
|
|
43
|
+
)
|
|
44
|
+
rebalance_method: Literal["global", "interclass"] | None = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="ClassBalance method for train split. None = no rebalancing.",
|
|
47
|
+
)
|
|
48
|
+
coverage_percent: float = Field(
|
|
49
|
+
default=0.01,
|
|
50
|
+
gt=0.0,
|
|
51
|
+
lt=1.0,
|
|
52
|
+
description=(
|
|
53
|
+
"Proportion of observations considered uncovered for coverage_adaptive "
|
|
54
|
+
"(when model provided). Per h2_detect_undersampling tutorial."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
num_observations: int = Field(
|
|
58
|
+
default=50,
|
|
59
|
+
ge=1,
|
|
60
|
+
description="Number of neighbors for coverage_adaptive (when model provided).",
|
|
61
|
+
)
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Findings builders for the dataset splitting 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.splitting.outputs import DataSplittingRawOutputs, SplitInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_factor_table(
|
|
12
|
+
rows: list[dict[str, Any]],
|
|
13
|
+
value_key: str,
|
|
14
|
+
value_label: str,
|
|
15
|
+
) -> list[str]:
|
|
16
|
+
"""Format a list of factor dicts as a text table for detail_lines."""
|
|
17
|
+
if not rows:
|
|
18
|
+
return []
|
|
19
|
+
w_name = max(6, *(len(str(r.get("factor_name", ""))) for r in rows))
|
|
20
|
+
lines = [
|
|
21
|
+
f"{'Factor':<{w_name}} {value_label:>10} Flag",
|
|
22
|
+
f"{'-' * w_name} {'-' * 10} ----",
|
|
23
|
+
]
|
|
24
|
+
for r in rows:
|
|
25
|
+
name = str(r.get("factor_name", ""))
|
|
26
|
+
val = r.get(value_key, 0.0)
|
|
27
|
+
flag_key = "is_imbalanced" if "is_imbalanced" in r else "is_low_diversity"
|
|
28
|
+
flagged = r.get(flag_key, False)
|
|
29
|
+
flag_str = " [!!]" if flagged else ""
|
|
30
|
+
lines.append(f"{name:<{w_name}} {val:>10.4f}{flag_str}")
|
|
31
|
+
return lines
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_label_counts(label_counts: dict[str, int] | list[int] | None) -> dict[str, int]:
|
|
35
|
+
"""Normalize label_counts_per_class to ``{str_key: count}``."""
|
|
36
|
+
if not label_counts:
|
|
37
|
+
return {}
|
|
38
|
+
if isinstance(label_counts, dict):
|
|
39
|
+
return {str(k): v for k, v in label_counts.items()}
|
|
40
|
+
return {str(i): c for i, c in enumerate(label_counts)}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Split sizes (consolidated for multi-fold)
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_split_sizes(raw: DataSplittingRawOutputs) -> list[Reportable]:
|
|
49
|
+
"""Build split-size findings — consolidated pivot table for multi-fold."""
|
|
50
|
+
if not raw.folds:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
test_size = len(raw.test_indices)
|
|
54
|
+
|
|
55
|
+
# Single fold: keep the original key_value format
|
|
56
|
+
if len(raw.folds) == 1:
|
|
57
|
+
fold = raw.folds[0]
|
|
58
|
+
return [
|
|
59
|
+
Reportable(
|
|
60
|
+
report_type="key_value",
|
|
61
|
+
severity="info",
|
|
62
|
+
title=f"Fold {fold.fold} split sizes",
|
|
63
|
+
data={
|
|
64
|
+
"train": len(fold.train_indices),
|
|
65
|
+
"val": len(fold.val_indices),
|
|
66
|
+
"test": test_size,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Multi-fold: consolidated pivot table
|
|
72
|
+
rows: list[dict[str, Any]] = []
|
|
73
|
+
train_sizes: list[int] = []
|
|
74
|
+
val_sizes: list[int] = []
|
|
75
|
+
for fold_info in raw.folds:
|
|
76
|
+
t = len(fold_info.train_indices)
|
|
77
|
+
v = len(fold_info.val_indices)
|
|
78
|
+
train_sizes.append(t)
|
|
79
|
+
val_sizes.append(v)
|
|
80
|
+
rows.append({"Fold": str(fold_info.fold), "Train": t, "Val": v, "Test": test_size})
|
|
81
|
+
|
|
82
|
+
footer_lines: list[str] = []
|
|
83
|
+
for name, sizes in [("Train", train_sizes), ("Val", val_sizes)]:
|
|
84
|
+
lo, hi = min(sizes), max(sizes)
|
|
85
|
+
footer_lines.append(f"{name}: {lo}-{hi} (range {hi - lo})" if lo != hi else f"{name}: {lo}")
|
|
86
|
+
footer_lines.append(f"Test: {test_size} (shared across folds)")
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
Reportable(
|
|
90
|
+
report_type="pivot_table",
|
|
91
|
+
severity="info",
|
|
92
|
+
title="Split sizes across folds",
|
|
93
|
+
data={
|
|
94
|
+
"brief": f"{len(raw.folds)} folds, test={test_size}",
|
|
95
|
+
"table_data": rows,
|
|
96
|
+
"table_headers": ["Fold", "Train", "Val", "Test"],
|
|
97
|
+
"footer_lines": footer_lines,
|
|
98
|
+
},
|
|
99
|
+
description="Split sizes per fold. Test set is shared across folds.",
|
|
100
|
+
)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Cross-split class distribution
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
_MAX_CLASSES_DISPLAY = 20
|
|
109
|
+
_TOP_CLASSES = 10
|
|
110
|
+
_BOTTOM_CLASSES = 5
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _make_distribution_row(
|
|
114
|
+
cls: str,
|
|
115
|
+
splits: dict[str, dict[str, int]],
|
|
116
|
+
split_totals: dict[str, int],
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Build one row of the cross-split distribution table."""
|
|
119
|
+
row: dict[str, Any] = {"Class": cls}
|
|
120
|
+
pcts: dict[str, int] = {}
|
|
121
|
+
raw_counts: dict[str, int] = {}
|
|
122
|
+
for sn, counts_map in splits.items():
|
|
123
|
+
count = counts_map.get(cls, 0)
|
|
124
|
+
total = split_totals.get(sn, 0)
|
|
125
|
+
pcts[sn] = round(count / total * 100) if total else 0
|
|
126
|
+
raw_counts[sn] = count
|
|
127
|
+
|
|
128
|
+
split_names = [sn for sn in splits if sn != "Full"]
|
|
129
|
+
all_same = len({pcts[sn] for sn in split_names}) == 1
|
|
130
|
+
|
|
131
|
+
for sn in splits:
|
|
132
|
+
if all_same and sn != "Full":
|
|
133
|
+
row[sn] = str(raw_counts[sn])
|
|
134
|
+
else:
|
|
135
|
+
row[sn] = f"{raw_counts[sn]} ({pcts[sn]}%)"
|
|
136
|
+
return row
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _truncate_classes(all_classes: list[str]) -> tuple[list[str], list[str], int]:
|
|
140
|
+
"""Return (top, bottom, omitted) after truncation if needed."""
|
|
141
|
+
if len(all_classes) > _MAX_CLASSES_DISPLAY:
|
|
142
|
+
return (
|
|
143
|
+
all_classes[:_TOP_CLASSES],
|
|
144
|
+
all_classes[-_BOTTOM_CLASSES:],
|
|
145
|
+
len(all_classes) - _TOP_CLASSES - _BOTTOM_CLASSES,
|
|
146
|
+
)
|
|
147
|
+
return all_classes, [], 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_distribution_rows(
|
|
151
|
+
all_classes: list[str],
|
|
152
|
+
splits: dict[str, dict[str, int]],
|
|
153
|
+
split_totals: dict[str, int],
|
|
154
|
+
) -> list[dict[str, Any]]:
|
|
155
|
+
"""Build the full row list including placeholder for omitted classes."""
|
|
156
|
+
top, bottom, omitted = _truncate_classes(all_classes)
|
|
157
|
+
rows: list[dict[str, Any]] = [_make_distribution_row(cls, splits, split_totals) for cls in top]
|
|
158
|
+
if omitted:
|
|
159
|
+
placeholder: dict[str, Any] = {"Class": f"... {omitted} more ..."}
|
|
160
|
+
for sn in splits:
|
|
161
|
+
placeholder[sn] = ""
|
|
162
|
+
rows.append(placeholder)
|
|
163
|
+
rows.extend(_make_distribution_row(cls, splits, split_totals) for cls in bottom)
|
|
164
|
+
return rows
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_cross_split_distribution(
|
|
168
|
+
raw: DataSplittingRawOutputs,
|
|
169
|
+
) -> list[Reportable]:
|
|
170
|
+
"""Build cross-split class distribution pivot table(s)."""
|
|
171
|
+
full_counts = _normalize_label_counts(raw.label_stats_full.get("label_counts_per_class"))
|
|
172
|
+
if not full_counts:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
folds_with_stats = [f for f in raw.folds if f.label_stats_train]
|
|
176
|
+
if not folds_with_stats:
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
test_counts = _normalize_label_counts(raw.label_stats_test.get("label_counts_per_class"))
|
|
180
|
+
has_test = bool(test_counts)
|
|
181
|
+
|
|
182
|
+
findings: list[Reportable] = []
|
|
183
|
+
folds_to_show = folds_with_stats[:1] if len(folds_with_stats) > 1 else folds_with_stats
|
|
184
|
+
|
|
185
|
+
for fold_info in folds_to_show:
|
|
186
|
+
train_counts = _normalize_label_counts(fold_info.label_stats_train.get("label_counts_per_class"))
|
|
187
|
+
val_counts = _normalize_label_counts(fold_info.label_stats_val.get("label_counts_per_class"))
|
|
188
|
+
|
|
189
|
+
splits: dict[str, dict[str, int]] = {"Train": train_counts, "Val": val_counts}
|
|
190
|
+
if has_test:
|
|
191
|
+
splits["Test"] = test_counts
|
|
192
|
+
splits["Full"] = full_counts
|
|
193
|
+
|
|
194
|
+
split_totals = {name: sum(c.values()) for name, c in splits.items()}
|
|
195
|
+
all_classes = sorted(full_counts.keys(), key=lambda c: full_counts.get(c, 0), reverse=True)
|
|
196
|
+
rows = _build_distribution_rows(all_classes, splits, split_totals)
|
|
197
|
+
|
|
198
|
+
max_dev, worst_class, worst_split = _max_proportion_deviation(splits, full_counts, split_totals)
|
|
199
|
+
|
|
200
|
+
headers = ["Class", "Train", "Val"]
|
|
201
|
+
if has_test:
|
|
202
|
+
headers.append("Test")
|
|
203
|
+
headers.append("Full")
|
|
204
|
+
|
|
205
|
+
footer_lines: list[str] = []
|
|
206
|
+
if max_dev > 0:
|
|
207
|
+
footer_lines.append(
|
|
208
|
+
f"Max proportion deviation from full dataset: {max_dev:.1f}pp ({worst_class} in {worst_split})"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
num_folds = len(folds_with_stats)
|
|
212
|
+
if num_folds > 1:
|
|
213
|
+
title = f"Class distribution across splits (fold {fold_info.fold} of {num_folds})"
|
|
214
|
+
else:
|
|
215
|
+
title = "Class distribution across splits"
|
|
216
|
+
|
|
217
|
+
findings.append(
|
|
218
|
+
Reportable(
|
|
219
|
+
report_type="pivot_table",
|
|
220
|
+
severity="info",
|
|
221
|
+
title=title,
|
|
222
|
+
data={
|
|
223
|
+
"table_data": rows,
|
|
224
|
+
"table_headers": headers,
|
|
225
|
+
"footer_lines": footer_lines,
|
|
226
|
+
},
|
|
227
|
+
description="Per-class counts and proportions across splits.",
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return findings
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _max_proportion_deviation(
|
|
235
|
+
splits: dict[str, dict[str, int]],
|
|
236
|
+
full_counts: dict[str, int],
|
|
237
|
+
split_totals: dict[str, int],
|
|
238
|
+
) -> tuple[float, str, str]:
|
|
239
|
+
"""Find the maximum absolute proportion deviation from the full dataset.
|
|
240
|
+
|
|
241
|
+
Returns ``(max_dev_pct, worst_class, worst_split)``.
|
|
242
|
+
"""
|
|
243
|
+
full_total = split_totals.get("Full", 0)
|
|
244
|
+
if full_total == 0:
|
|
245
|
+
return 0.0, "", ""
|
|
246
|
+
|
|
247
|
+
max_dev = 0.0
|
|
248
|
+
worst_class = ""
|
|
249
|
+
worst_split = ""
|
|
250
|
+
|
|
251
|
+
for cls, full_count in full_counts.items():
|
|
252
|
+
full_pct = full_count / full_total * 100
|
|
253
|
+
for sn, counts in splits.items():
|
|
254
|
+
if sn == "Full":
|
|
255
|
+
continue
|
|
256
|
+
total = split_totals.get(sn, 0)
|
|
257
|
+
if total == 0:
|
|
258
|
+
continue
|
|
259
|
+
split_pct = counts.get(cls, 0) / total * 100
|
|
260
|
+
dev = abs(split_pct - full_pct)
|
|
261
|
+
if dev > max_dev:
|
|
262
|
+
max_dev = dev
|
|
263
|
+
worst_class = cls
|
|
264
|
+
worst_split = sn
|
|
265
|
+
|
|
266
|
+
return round(max_dev, 1), worst_class, worst_split
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Stratification quality health check
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _worst_deviation_across_folds(
|
|
275
|
+
folds_with_stats: list[SplitInfo],
|
|
276
|
+
full_counts: dict[str, int],
|
|
277
|
+
full_total: int,
|
|
278
|
+
test_counts: dict[str, int],
|
|
279
|
+
) -> tuple[float, str, str, int]:
|
|
280
|
+
"""Find the worst proportion deviation across all folds.
|
|
281
|
+
|
|
282
|
+
Returns ``(max_dev, worst_class, worst_split, worst_fold)``.
|
|
283
|
+
"""
|
|
284
|
+
max_dev = 0.0
|
|
285
|
+
worst_class = ""
|
|
286
|
+
worst_split = ""
|
|
287
|
+
worst_fold = 0
|
|
288
|
+
|
|
289
|
+
for fold_info in folds_with_stats:
|
|
290
|
+
train_counts = _normalize_label_counts(fold_info.label_stats_train.get("label_counts_per_class"))
|
|
291
|
+
val_counts = _normalize_label_counts(fold_info.label_stats_val.get("label_counts_per_class"))
|
|
292
|
+
|
|
293
|
+
splits: dict[str, dict[str, int]] = {"train": train_counts, "val": val_counts}
|
|
294
|
+
if test_counts:
|
|
295
|
+
splits["test"] = test_counts
|
|
296
|
+
|
|
297
|
+
split_totals = {name: sum(c.values()) for name, c in splits.items()}
|
|
298
|
+
|
|
299
|
+
for cls, full_count in full_counts.items():
|
|
300
|
+
full_pct = full_count / full_total * 100
|
|
301
|
+
for sn, counts in splits.items():
|
|
302
|
+
total = split_totals.get(sn, 0)
|
|
303
|
+
if total == 0:
|
|
304
|
+
continue
|
|
305
|
+
split_pct = counts.get(cls, 0) / total * 100
|
|
306
|
+
dev = abs(split_pct - full_pct)
|
|
307
|
+
if dev > max_dev:
|
|
308
|
+
max_dev = dev
|
|
309
|
+
worst_class = cls
|
|
310
|
+
worst_split = sn
|
|
311
|
+
worst_fold = fold_info.fold
|
|
312
|
+
|
|
313
|
+
return round(max_dev, 1), worst_class, worst_split, worst_fold
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_stratification_check(raw: DataSplittingRawOutputs) -> list[Reportable]:
|
|
317
|
+
"""Build a stratification quality health-check finding."""
|
|
318
|
+
full_counts = _normalize_label_counts(raw.label_stats_full.get("label_counts_per_class"))
|
|
319
|
+
if not full_counts:
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
folds_with_stats = [f for f in raw.folds if f.label_stats_train]
|
|
323
|
+
if not folds_with_stats:
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
test_counts = _normalize_label_counts(raw.label_stats_test.get("label_counts_per_class"))
|
|
327
|
+
full_total = sum(full_counts.values())
|
|
328
|
+
if full_total == 0:
|
|
329
|
+
return []
|
|
330
|
+
|
|
331
|
+
global_max_dev, global_worst_class, global_worst_split, global_worst_fold = _worst_deviation_across_folds(
|
|
332
|
+
folds_with_stats, full_counts, full_total, test_counts
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Severity thresholds
|
|
336
|
+
severity: Literal["ok", "info", "warning"]
|
|
337
|
+
if global_max_dev <= 2.0:
|
|
338
|
+
severity = "ok"
|
|
339
|
+
status = "OK"
|
|
340
|
+
elif global_max_dev <= 10.0:
|
|
341
|
+
severity = "info"
|
|
342
|
+
status = "OK"
|
|
343
|
+
else:
|
|
344
|
+
severity = "warning"
|
|
345
|
+
status = "WARNING"
|
|
346
|
+
|
|
347
|
+
brief = f"{status} - max deviation {global_max_dev}pp"
|
|
348
|
+
|
|
349
|
+
detail_lines: list[str] = [f"Max proportion deviation: {global_max_dev}pp"]
|
|
350
|
+
if global_max_dev > 0:
|
|
351
|
+
full_pct = round(full_counts.get(global_worst_class, 0) / full_total * 100, 1)
|
|
352
|
+
fold_label = f"fold {global_worst_fold} " if len(folds_with_stats) > 1 else ""
|
|
353
|
+
detail_lines.append(
|
|
354
|
+
f" Worst: class '{global_worst_class}' in {fold_label}{global_worst_split} "
|
|
355
|
+
f"(deviation {global_max_dev}pp from {full_pct}% in full)"
|
|
356
|
+
)
|
|
357
|
+
detail_lines.append("")
|
|
358
|
+
detail_lines.append(f"Folds checked: {len(folds_with_stats)}")
|
|
359
|
+
detail_lines.append(f"Classes checked: {len(full_counts)}")
|
|
360
|
+
|
|
361
|
+
return [
|
|
362
|
+
Reportable(
|
|
363
|
+
report_type="key_value",
|
|
364
|
+
severity=severity,
|
|
365
|
+
title="Stratification quality",
|
|
366
|
+
data={
|
|
367
|
+
"brief": brief,
|
|
368
|
+
"detail_lines": detail_lines,
|
|
369
|
+
},
|
|
370
|
+
description="Checks whether class proportions in each split match the full dataset.\n"
|
|
371
|
+
" Train proportions may differ if rebalancing was applied.",
|
|
372
|
+
)
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Main entry point
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def build_findings(
|
|
382
|
+
raw: DataSplittingRawOutputs,
|
|
383
|
+
) -> list[Reportable]:
|
|
384
|
+
"""Build human-readable findings from raw outputs."""
|
|
385
|
+
findings: list[Reportable] = []
|
|
386
|
+
|
|
387
|
+
# --- 1. Full-dataset label distribution ---
|
|
388
|
+
label_counts = raw.label_stats_full.get("label_counts_per_class")
|
|
389
|
+
if label_counts:
|
|
390
|
+
normalized = _normalize_label_counts(label_counts)
|
|
391
|
+
counts = list(normalized.values())
|
|
392
|
+
max_count = max(counts) if counts else 0
|
|
393
|
+
min_count = min(counts) if counts else 0
|
|
394
|
+
ratio = max_count / min_count if min_count > 0 else float("inf")
|
|
395
|
+
severity: Literal["ok", "info", "warning"] = "warning" if ratio > 10 else "info"
|
|
396
|
+
findings.append(
|
|
397
|
+
Reportable(
|
|
398
|
+
report_type="table",
|
|
399
|
+
severity=severity,
|
|
400
|
+
title="Class distribution (full dataset)",
|
|
401
|
+
data={
|
|
402
|
+
"table_data": normalized,
|
|
403
|
+
"table_headers": ("Class", "Count"),
|
|
404
|
+
},
|
|
405
|
+
description=f"Max/min class ratio: {ratio:.1f}:1" if min_count > 0 else "Some classes have 0 samples",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# --- 2. Split sizes (consolidated for multi-fold) ---
|
|
410
|
+
findings.extend(_build_split_sizes(raw))
|
|
411
|
+
|
|
412
|
+
# --- 3. Cross-split class distribution ---
|
|
413
|
+
findings.extend(_build_cross_split_distribution(raw))
|
|
414
|
+
|
|
415
|
+
# --- 4. Stratification quality health check ---
|
|
416
|
+
findings.extend(_build_stratification_check(raw))
|
|
417
|
+
|
|
418
|
+
# --- 5. Balance scores ---
|
|
419
|
+
balance_data = raw.pre_split_balance.get("balance")
|
|
420
|
+
if balance_data and isinstance(balance_data, list):
|
|
421
|
+
findings.append(
|
|
422
|
+
Reportable(
|
|
423
|
+
report_type="key_value",
|
|
424
|
+
severity="info",
|
|
425
|
+
title="Pre-split balance (mutual information)",
|
|
426
|
+
data={"detail_lines": _format_factor_table(balance_data, "mi_value", "MI Score")},
|
|
427
|
+
description="Higher MI = stronger correlation between factor and class label.",
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# --- 6. Diversity scores ---
|
|
432
|
+
diversity_data = raw.pre_split_diversity.get("factors")
|
|
433
|
+
if diversity_data and isinstance(diversity_data, list):
|
|
434
|
+
findings.append(
|
|
435
|
+
Reportable(
|
|
436
|
+
report_type="key_value",
|
|
437
|
+
severity="info",
|
|
438
|
+
title="Pre-split diversity",
|
|
439
|
+
data={"detail_lines": _format_factor_table(diversity_data, "diversity_value", "Diversity")},
|
|
440
|
+
description="Values near 1.0 = high diversity. Low diversity factors are flagged.",
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# --- 7. Per-split coverage ---
|
|
445
|
+
for fold_info in raw.folds:
|
|
446
|
+
for split_name, coverage in [("train", fold_info.coverage_train), ("val", fold_info.coverage_val)]:
|
|
447
|
+
if coverage:
|
|
448
|
+
uncovered = coverage.get("uncovered_indices", [])
|
|
449
|
+
split_size = len(fold_info.train_indices) if split_name == "train" else len(fold_info.val_indices)
|
|
450
|
+
pct = (len(uncovered) / split_size * 100) if split_size > 0 else 0
|
|
451
|
+
cov_severity: Literal["ok", "info", "warning"] = "warning" if pct > 5 else "info"
|
|
452
|
+
findings.append(
|
|
453
|
+
Reportable(
|
|
454
|
+
report_type="key_value",
|
|
455
|
+
severity=cov_severity,
|
|
456
|
+
title=f"Coverage: fold {fold_info.fold} {split_name}",
|
|
457
|
+
data={
|
|
458
|
+
"uncovered_count": len(uncovered),
|
|
459
|
+
"split_size": split_size,
|
|
460
|
+
"uncovered_pct": round(pct, 2),
|
|
461
|
+
"coverage_radius": coverage.get("coverage_radius"),
|
|
462
|
+
},
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if raw.coverage_test:
|
|
467
|
+
uncovered = raw.coverage_test.get("uncovered_indices", [])
|
|
468
|
+
test_size = len(raw.test_indices)
|
|
469
|
+
pct = (len(uncovered) / test_size * 100) if test_size > 0 else 0
|
|
470
|
+
cov_severity = "warning" if pct > 5 else "info"
|
|
471
|
+
findings.append(
|
|
472
|
+
Reportable(
|
|
473
|
+
report_type="key_value",
|
|
474
|
+
severity=cov_severity,
|
|
475
|
+
title="Coverage: test",
|
|
476
|
+
data={
|
|
477
|
+
"uncovered_count": len(uncovered),
|
|
478
|
+
"split_size": test_size,
|
|
479
|
+
"uncovered_pct": round(pct, 2),
|
|
480
|
+
"coverage_radius": raw.coverage_test.get("coverage_radius"),
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return findings
|