dataeval-flow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. dataeval_flow/__init__.py +93 -0
  2. dataeval_flow/__main__.py +149 -0
  3. dataeval_flow/_app/__init__.py +5 -0
  4. dataeval_flow/_app/_model/__init__.py +5 -0
  5. dataeval_flow/_app/_model/_coerce.py +126 -0
  6. dataeval_flow/_app/_model/_discover.py +171 -0
  7. dataeval_flow/_app/_model/_execution.py +108 -0
  8. dataeval_flow/_app/_model/_introspect.py +280 -0
  9. dataeval_flow/_app/_model/_item.py +213 -0
  10. dataeval_flow/_app/_model/_registry.py +255 -0
  11. dataeval_flow/_app/_model/_state.py +322 -0
  12. dataeval_flow/_app/_model/_undo.py +61 -0
  13. dataeval_flow/_app/_panes/__init__.py +35 -0
  14. dataeval_flow/_app/_panes/_config_pane.py +173 -0
  15. dataeval_flow/_app/_panes/_result_pane.py +125 -0
  16. dataeval_flow/_app/_panes/_task_pane.py +91 -0
  17. dataeval_flow/_app/_panes/_widgets.py +111 -0
  18. dataeval_flow/_app/_screens/__init__.py +25 -0
  19. dataeval_flow/_app/_screens/_base.py +242 -0
  20. dataeval_flow/_app/_screens/_detail.py +333 -0
  21. dataeval_flow/_app/_screens/_model.py +102 -0
  22. dataeval_flow/_app/_screens/_params.py +80 -0
  23. dataeval_flow/_app/_screens/_pathpicker.py +68 -0
  24. dataeval_flow/_app/_screens/_section.py +621 -0
  25. dataeval_flow/_app/_screens/_settings.py +183 -0
  26. dataeval_flow/_app/_viewmodel/__init__.py +15 -0
  27. dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
  28. dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
  29. dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
  30. dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
  31. dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
  32. dataeval_flow/_app/app.py +742 -0
  33. dataeval_flow/_app/cli.py +592 -0
  34. dataeval_flow/_logging.py +102 -0
  35. dataeval_flow/cache.py +1355 -0
  36. dataeval_flow/config/__init__.py +80 -0
  37. dataeval_flow/config/_loader.py +79 -0
  38. dataeval_flow/config/_merge.py +92 -0
  39. dataeval_flow/config/_models.py +115 -0
  40. dataeval_flow/config/_paths.py +85 -0
  41. dataeval_flow/config/schemas/__init__.py +112 -0
  42. dataeval_flow/config/schemas/_dataset.py +111 -0
  43. dataeval_flow/config/schemas/_extractor.py +119 -0
  44. dataeval_flow/config/schemas/_metadata.py +28 -0
  45. dataeval_flow/config/schemas/_preprocessor.py +18 -0
  46. dataeval_flow/config/schemas/_selection.py +100 -0
  47. dataeval_flow/config/schemas/_task.py +89 -0
  48. dataeval_flow/config/schemas/_workflow.py +135 -0
  49. dataeval_flow/dataset.py +635 -0
  50. dataeval_flow/embeddings.py +135 -0
  51. dataeval_flow/metadata.py +48 -0
  52. dataeval_flow/preprocessing.py +141 -0
  53. dataeval_flow/py.typed +0 -0
  54. dataeval_flow/runner.py +118 -0
  55. dataeval_flow/selection.py +50 -0
  56. dataeval_flow/workflow/__init__.py +328 -0
  57. dataeval_flow/workflow/_text_report.py +511 -0
  58. dataeval_flow/workflow/base.py +69 -0
  59. dataeval_flow/workflow/orchestrator.py +454 -0
  60. dataeval_flow/workflows/__init__.py +1 -0
  61. dataeval_flow/workflows/analysis/__init__.py +38 -0
  62. dataeval_flow/workflows/analysis/outputs.py +202 -0
  63. dataeval_flow/workflows/analysis/params.py +114 -0
  64. dataeval_flow/workflows/analysis/workflow.py +1313 -0
  65. dataeval_flow/workflows/cleaning/__init__.py +23 -0
  66. dataeval_flow/workflows/cleaning/outputs.py +200 -0
  67. dataeval_flow/workflows/cleaning/params.py +160 -0
  68. dataeval_flow/workflows/cleaning/report.py +304 -0
  69. dataeval_flow/workflows/cleaning/workflow.py +794 -0
  70. dataeval_flow/workflows/drift/__init__.py +1 -0
  71. dataeval_flow/workflows/drift/outputs.py +144 -0
  72. dataeval_flow/workflows/drift/params.py +332 -0
  73. dataeval_flow/workflows/drift/report.py +201 -0
  74. dataeval_flow/workflows/drift/workflow.py +647 -0
  75. dataeval_flow/workflows/ood/__init__.py +1 -0
  76. dataeval_flow/workflows/ood/outputs.py +134 -0
  77. dataeval_flow/workflows/ood/params.py +161 -0
  78. dataeval_flow/workflows/ood/report.py +311 -0
  79. dataeval_flow/workflows/ood/workflow.py +728 -0
  80. dataeval_flow/workflows/prioritization/__init__.py +1 -0
  81. dataeval_flow/workflows/prioritization/outputs.py +122 -0
  82. dataeval_flow/workflows/prioritization/params.py +124 -0
  83. dataeval_flow/workflows/prioritization/report.py +117 -0
  84. dataeval_flow/workflows/prioritization/workflow.py +587 -0
  85. dataeval_flow/workflows/splitting/__init__.py +25 -0
  86. dataeval_flow/workflows/splitting/outputs.py +101 -0
  87. dataeval_flow/workflows/splitting/params.py +61 -0
  88. dataeval_flow/workflows/splitting/report.py +485 -0
  89. dataeval_flow/workflows/splitting/workflow.py +371 -0
  90. dataeval_flow-0.1.0.dist-info/METADATA +305 -0
  91. dataeval_flow-0.1.0.dist-info/RECORD +94 -0
  92. dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
  93. dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
  94. dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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