trustcv 1.0.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.
- trustcv/__init__.py +300 -0
- trustcv/checkers/__init__.py +12 -0
- trustcv/checkers/balance.py +353 -0
- trustcv/checkers/leakage.py +746 -0
- trustcv/core/__init__.py +29 -0
- trustcv/core/base.py +541 -0
- trustcv/core/callbacks.py +485 -0
- trustcv/core/runner.py +459 -0
- trustcv/datasets/__init__.py +23 -0
- trustcv/datasets/loaders.py +414 -0
- trustcv/frameworks/__init__.py +75 -0
- trustcv/frameworks/monai.py +631 -0
- trustcv/frameworks/pytorch.py +564 -0
- trustcv/frameworks/tensorflow.py +452 -0
- trustcv/metrics/__init__.py +122 -0
- trustcv/metrics/clinical.py +551 -0
- trustcv/metrics/medical_metrics.py +387 -0
- trustcv/reporting/__init__.py +11 -0
- trustcv/reporting/regulatory_report.py +909 -0
- trustcv/reporting/universal_report.py +393 -0
- trustcv/splitters/__init__.py +131 -0
- trustcv/splitters/grouped.py +679 -0
- trustcv/splitters/iid.py +592 -0
- trustcv/splitters/spatial.py +709 -0
- trustcv/splitters/temporal.py +853 -0
- trustcv/validators.py +1678 -0
- trustcv/visualization/__init__.py +31 -0
- trustcv/visualization/plots.py +521 -0
- trustcv-1.0.0.dist-info/METADATA +349 -0
- trustcv-1.0.0.dist-info/RECORD +34 -0
- trustcv-1.0.0.dist-info/WHEEL +5 -0
- trustcv-1.0.0.dist-info/licenses/AUTHORS.md +27 -0
- trustcv-1.0.0.dist-info/licenses/LICENSE +26 -0
- trustcv-1.0.0.dist-info/top_level.txt +1 -0
trustcv/__init__.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
trustcv - Trustworthy Cross-Validation Toolkit
|
|
3
|
+
|
|
4
|
+
Developed at SMAILE (Stockholm Medical Artificial Intelligence and Learning Environments)
|
|
5
|
+
Karolinska Institutet Core Facility - https://smile.ki.se
|
|
6
|
+
Contributors: Farhad Abtahi, Abdelamir Karbalaie, SMAILE Team
|
|
7
|
+
=============================================
|
|
8
|
+
|
|
9
|
+
A framework-agnostic Python library for trustworthy cross-validation that provides a unified
|
|
10
|
+
interface across scikit-learn, PyTorch, TensorFlow/Keras, MONAI, JAX, and other
|
|
11
|
+
ML frameworks. It promotes best practices in model evaluation, providing both
|
|
12
|
+
familiar interfaces and customizable validators tailored for reliable validation.
|
|
13
|
+
|
|
14
|
+
Developed at SMAILE (Stockholm Medical Artificial Intelligence and Learning Environments)
|
|
15
|
+
Karolinska Institutet Core Facility
|
|
16
|
+
Website: https://smile.ki.se
|
|
17
|
+
|
|
18
|
+
Main Features:
|
|
19
|
+
- Framework-agnostic: Works with any ML/DL framework
|
|
20
|
+
- Advanced cross-validation strategies (hierarchical, temporal, spatial)
|
|
21
|
+
- Automatic data leakage detection
|
|
22
|
+
- Patient/group-level and temporal splitting
|
|
23
|
+
- Regulatory compliance reporting (FDA, CE MDR)
|
|
24
|
+
- Interactive visualizations
|
|
25
|
+
- Support for PyTorch, TensorFlow, MONAI, and more
|
|
26
|
+
|
|
27
|
+
Contributors:
|
|
28
|
+
- Farhad Abtahi
|
|
29
|
+
- Abdelamir Karbalaie
|
|
30
|
+
- SMAILE Team
|
|
31
|
+
|
|
32
|
+
For more information about AI research and collaboration opportunities,
|
|
33
|
+
visit SMAILE at https://smile.ki.se
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__version__ = "1.0.0"
|
|
37
|
+
__author__ = "SMAILE Team, Karolinska Institutet"
|
|
38
|
+
__institution__ = "SMAILE - Stockholm Medical AI and Learning Environments, Karolinska Institutet"
|
|
39
|
+
__website__ = "https://smile.ki.se"
|
|
40
|
+
|
|
41
|
+
from .checkers import BalanceChecker, DataLeakageChecker
|
|
42
|
+
|
|
43
|
+
# Import new framework-agnostic components
|
|
44
|
+
from .core import (
|
|
45
|
+
ClassDistributionLogger,
|
|
46
|
+
CVCallback,
|
|
47
|
+
CVResults,
|
|
48
|
+
EarlyStopping,
|
|
49
|
+
ModelCheckpoint,
|
|
50
|
+
ProgressLogger,
|
|
51
|
+
UniversalCVRunner,
|
|
52
|
+
)
|
|
53
|
+
from .metrics import ClinicalMetrics
|
|
54
|
+
|
|
55
|
+
# Import all splitters
|
|
56
|
+
from .splitters import ( # I.I.D. methods; Grouped methods; Temporal methods; Spatial methods
|
|
57
|
+
LOOCV,
|
|
58
|
+
LPOCV,
|
|
59
|
+
BlockedTimeSeries,
|
|
60
|
+
BootstrapValidation,
|
|
61
|
+
BufferedSpatialCV,
|
|
62
|
+
CombinatorialPurgedCV,
|
|
63
|
+
EnvironmentalHealthCV,
|
|
64
|
+
ExpandingWindowCV,
|
|
65
|
+
GroupKFoldMedical,
|
|
66
|
+
HierarchicalGroupKFold,
|
|
67
|
+
HoldOut,
|
|
68
|
+
KFoldMedical,
|
|
69
|
+
LeaveOneGroupOut,
|
|
70
|
+
MonteCarloCV,
|
|
71
|
+
NestedCV,
|
|
72
|
+
NestedGroupedCV,
|
|
73
|
+
NestedTemporalCV,
|
|
74
|
+
PurgedGroupTimeSeriesSplit,
|
|
75
|
+
PurgedKFoldCV,
|
|
76
|
+
RepeatedGroupKFold,
|
|
77
|
+
RepeatedKFold,
|
|
78
|
+
RollingWindowCV,
|
|
79
|
+
SpatialBlockCV,
|
|
80
|
+
SpatiotemporalBlockCV,
|
|
81
|
+
StratifiedGroupKFold,
|
|
82
|
+
StratifiedKFoldMedical,
|
|
83
|
+
TimeSeriesSplit,
|
|
84
|
+
)
|
|
85
|
+
from .validators import MedicalValidator, NestedTemporalCV, TrustCVValidator
|
|
86
|
+
|
|
87
|
+
# Conditionally import framework-specific runners if available
|
|
88
|
+
try:
|
|
89
|
+
from .frameworks.pytorch import TorchCVRunner
|
|
90
|
+
|
|
91
|
+
_pytorch_available = True
|
|
92
|
+
except ImportError:
|
|
93
|
+
_pytorch_available = False
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
from .frameworks.tensorflow import KerasCVRunner
|
|
97
|
+
|
|
98
|
+
_tensorflow_available = True
|
|
99
|
+
except ImportError:
|
|
100
|
+
_tensorflow_available = False
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
from .frameworks.monai import MONAICVRunner
|
|
104
|
+
|
|
105
|
+
_monai_available = True
|
|
106
|
+
except ImportError:
|
|
107
|
+
_monai_available = False
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
# Core validators and checkers
|
|
111
|
+
"TrustCVValidator",
|
|
112
|
+
"MedicalValidator",
|
|
113
|
+
"DataLeakageChecker",
|
|
114
|
+
"BalanceChecker",
|
|
115
|
+
"ClinicalMetrics",
|
|
116
|
+
"NestedTemporalCV",
|
|
117
|
+
"NestedGroupedCV",
|
|
118
|
+
# Framework-agnostic components
|
|
119
|
+
"UniversalCVRunner",
|
|
120
|
+
"CVResults",
|
|
121
|
+
"CVCallback",
|
|
122
|
+
"EarlyStopping",
|
|
123
|
+
"ModelCheckpoint",
|
|
124
|
+
"ProgressLogger",
|
|
125
|
+
"ClassDistributionLogger",
|
|
126
|
+
# I.I.D. methods
|
|
127
|
+
"HoldOut",
|
|
128
|
+
"KFoldMedical",
|
|
129
|
+
"StratifiedKFoldMedical",
|
|
130
|
+
"RepeatedKFold",
|
|
131
|
+
"LOOCV",
|
|
132
|
+
"LPOCV",
|
|
133
|
+
"BootstrapValidation",
|
|
134
|
+
"MonteCarloCV",
|
|
135
|
+
"NestedCV",
|
|
136
|
+
# Grouped methods
|
|
137
|
+
"GroupKFoldMedical",
|
|
138
|
+
"StratifiedGroupKFold",
|
|
139
|
+
"LeaveOneGroupOut",
|
|
140
|
+
"RepeatedGroupKFold",
|
|
141
|
+
"NestedGroupedCV",
|
|
142
|
+
"HierarchicalGroupKFold",
|
|
143
|
+
# Temporal methods
|
|
144
|
+
"TimeSeriesSplit",
|
|
145
|
+
"BlockedTimeSeries",
|
|
146
|
+
"RollingWindowCV",
|
|
147
|
+
"ExpandingWindowCV",
|
|
148
|
+
"PurgedKFoldCV",
|
|
149
|
+
"CombinatorialPurgedCV",
|
|
150
|
+
"PurgedGroupTimeSeriesSplit",
|
|
151
|
+
"NestedTemporalCV",
|
|
152
|
+
# Spatial methods
|
|
153
|
+
"SpatialBlockCV",
|
|
154
|
+
"BufferedSpatialCV",
|
|
155
|
+
"SpatiotemporalBlockCV",
|
|
156
|
+
"EnvironmentalHealthCV",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
# Add framework-specific runners if available
|
|
160
|
+
if _pytorch_available:
|
|
161
|
+
__all__.append("TorchCVRunner")
|
|
162
|
+
if _tensorflow_available:
|
|
163
|
+
__all__.append("KerasCVRunner")
|
|
164
|
+
if _monai_available:
|
|
165
|
+
__all__.append("MONAICVRunner")
|
|
166
|
+
|
|
167
|
+
# === Canonical name aliases (consistency with sklearn/literature) ===
|
|
168
|
+
import warnings as _trustcv_warnings
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _trustcv_deprecated(old, new):
|
|
172
|
+
_trustcv_warnings.warn(
|
|
173
|
+
f"'{old}' is deprecated; use '{new}' instead.", category=DeprecationWarning, stacklevel=2
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---- Canonical aliases (bind canonical names to existing implementations) ----
|
|
178
|
+
# IID / Grouped
|
|
179
|
+
try:
|
|
180
|
+
KFold = KFoldMedical
|
|
181
|
+
except NameError:
|
|
182
|
+
pass
|
|
183
|
+
try:
|
|
184
|
+
StratifiedKFold = StratifiedKFoldMedical
|
|
185
|
+
except NameError:
|
|
186
|
+
pass
|
|
187
|
+
try:
|
|
188
|
+
LeaveOneOut = LOOCV
|
|
189
|
+
except NameError:
|
|
190
|
+
pass
|
|
191
|
+
try:
|
|
192
|
+
LeavePOut = LPOCV
|
|
193
|
+
except NameError:
|
|
194
|
+
pass
|
|
195
|
+
try:
|
|
196
|
+
GroupKFold = GroupKFoldMedical
|
|
197
|
+
except NameError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
# Temporal
|
|
201
|
+
try:
|
|
202
|
+
BlockedTimeSeriesSplit = BlockedTimeSeries
|
|
203
|
+
except NameError:
|
|
204
|
+
pass
|
|
205
|
+
try:
|
|
206
|
+
RollingWindowSplit = RollingWindowCV
|
|
207
|
+
except NameError:
|
|
208
|
+
pass
|
|
209
|
+
try:
|
|
210
|
+
ExpandingWindowSplit = ExpandingWindowCV
|
|
211
|
+
except NameError:
|
|
212
|
+
pass
|
|
213
|
+
try:
|
|
214
|
+
PurgedKFold = PurgedKFoldCV
|
|
215
|
+
except NameError:
|
|
216
|
+
pass
|
|
217
|
+
try:
|
|
218
|
+
CombinatorialPurgedKFold = CombinatorialPurgedCV
|
|
219
|
+
except NameError:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# Spatial
|
|
223
|
+
try:
|
|
224
|
+
SpatialBlockSplit = SpatialBlockCV
|
|
225
|
+
except NameError:
|
|
226
|
+
pass
|
|
227
|
+
try:
|
|
228
|
+
BufferedSpatialSplit = BufferedSpatialCV
|
|
229
|
+
except NameError:
|
|
230
|
+
pass
|
|
231
|
+
try:
|
|
232
|
+
SpatiotemporalBlockSplit = SpatiotemporalBlockCV
|
|
233
|
+
except NameError:
|
|
234
|
+
pass
|
|
235
|
+
try:
|
|
236
|
+
EnvironmentalHealthSplit = EnvironmentalHealthCV
|
|
237
|
+
except NameError:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
# Checkers
|
|
241
|
+
try:
|
|
242
|
+
LeakageChecker = DataLeakageChecker
|
|
243
|
+
except NameError:
|
|
244
|
+
pass
|
|
245
|
+
try:
|
|
246
|
+
ClassBalanceChecker = BalanceChecker
|
|
247
|
+
except NameError:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
# ---- Extend __all__ with canonical names (non-breaking) ----
|
|
251
|
+
__all__.extend(
|
|
252
|
+
[
|
|
253
|
+
# IID / Grouped
|
|
254
|
+
"KFold",
|
|
255
|
+
"StratifiedKFold",
|
|
256
|
+
"LeaveOneOut",
|
|
257
|
+
"LeavePOut",
|
|
258
|
+
"GroupKFold",
|
|
259
|
+
# Temporal
|
|
260
|
+
"BlockedTimeSeriesSplit",
|
|
261
|
+
"RollingWindowSplit",
|
|
262
|
+
"ExpandingWindowSplit",
|
|
263
|
+
"PurgedKFold",
|
|
264
|
+
"CombinatorialPurgedKFold",
|
|
265
|
+
# Spatial
|
|
266
|
+
"SpatialBlockSplit",
|
|
267
|
+
"BufferedSpatialSplit",
|
|
268
|
+
"SpatiotemporalBlockSplit",
|
|
269
|
+
"EnvironmentalHealthSplit",
|
|
270
|
+
# Checkers
|
|
271
|
+
"LeakageChecker",
|
|
272
|
+
"ClassBalanceChecker",
|
|
273
|
+
]
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---- Optional: gentle deprecation notices (future-proofing) ----
|
|
278
|
+
def __getattr__(name):
|
|
279
|
+
_map = {
|
|
280
|
+
"KFoldMedical": "KFold",
|
|
281
|
+
"StratifiedKFoldMedical": "StratifiedKFold",
|
|
282
|
+
"LOOCV": "LeaveOneOut",
|
|
283
|
+
"LPOCV": "LeavePOut",
|
|
284
|
+
"GroupKFoldMedical": "GroupKFold",
|
|
285
|
+
"BlockedTimeSeries": "BlockedTimeSeriesSplit",
|
|
286
|
+
"RollingWindowCV": "RollingWindowSplit",
|
|
287
|
+
"ExpandingWindowCV": "ExpandingWindowSplit",
|
|
288
|
+
"PurgedKFoldCV": "PurgedKFold",
|
|
289
|
+
"CombinatorialPurgedCV": "CombinatorialPurgedKFold",
|
|
290
|
+
"SpatialBlockCV": "SpatialBlockSplit",
|
|
291
|
+
"BufferedSpatialCV": "BufferedSpatialSplit",
|
|
292
|
+
"SpatiotemporalBlockCV": "SpatiotemporalBlockSplit",
|
|
293
|
+
"EnvironmentalHealthCV": "EnvironmentalHealthSplit",
|
|
294
|
+
"DataLeakageChecker": "LeakageChecker",
|
|
295
|
+
"BalanceChecker": "ClassBalanceChecker",
|
|
296
|
+
}
|
|
297
|
+
if name in _map:
|
|
298
|
+
_trustcv_deprecated(name, _map[name])
|
|
299
|
+
return globals().get(_map[name])
|
|
300
|
+
raise AttributeError(f"module 'trustcv' has no attribute '{name}'")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data integrity and leakage checkers for ML
|
|
3
|
+
|
|
4
|
+
Developed at SMAILE (Stockholm Medical Artificial Intelligence and Learning Environments)
|
|
5
|
+
Karolinska Institutet Core Facility - https://smile.ki.se
|
|
6
|
+
Contributors: Farhad Abtahi, Abdelamir Karbalaie, SMAILE Team
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .balance import BalanceChecker
|
|
10
|
+
from .leakage import DataLeakageChecker
|
|
11
|
+
|
|
12
|
+
__all__ = ["DataLeakageChecker", "BalanceChecker"]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Balance checker for medical datasets
|
|
3
|
+
|
|
4
|
+
Developed at SMAILE (Stockholm Medical Artificial Intelligence and Learning Environments)
|
|
5
|
+
Karolinska Institutet Core Facility - https://smile.ki.se
|
|
6
|
+
Contributors: Farhad Abtahi, Abdelamir Karbalaie, SMAILE Team
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BalanceChecker:
|
|
17
|
+
"""
|
|
18
|
+
Checks for class imbalance and data distribution issues
|
|
19
|
+
|
|
20
|
+
Critical for medical datasets where disease prevalence
|
|
21
|
+
can be extremely low (< 1% positive cases)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, threshold: float = 0.1):
|
|
25
|
+
"""
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
threshold : float
|
|
29
|
+
Imbalance threshold for warnings (default 10% difference)
|
|
30
|
+
"""
|
|
31
|
+
self.threshold = threshold
|
|
32
|
+
self.report = {}
|
|
33
|
+
|
|
34
|
+
def check_class_balance(
|
|
35
|
+
self, y: np.ndarray, groups: Optional[np.ndarray] = None
|
|
36
|
+
) -> Dict[str, Any]:
|
|
37
|
+
"""
|
|
38
|
+
Check class distribution in dataset
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
y : array-like
|
|
43
|
+
Target labels
|
|
44
|
+
groups : array-like, optional
|
|
45
|
+
Group identifiers (e.g., patient IDs)
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
dict
|
|
50
|
+
Balance report with statistics and warnings
|
|
51
|
+
"""
|
|
52
|
+
y = np.array(y)
|
|
53
|
+
unique_classes, counts = np.unique(y, return_counts=True)
|
|
54
|
+
n_samples = len(y)
|
|
55
|
+
|
|
56
|
+
# Calculate class distribution
|
|
57
|
+
class_dist = {
|
|
58
|
+
str(cls): {"count": int(count), "percentage": float(count / n_samples * 100)}
|
|
59
|
+
for cls, count in zip(unique_classes, counts)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Calculate imbalance ratio
|
|
63
|
+
min_class = counts.min()
|
|
64
|
+
max_class = counts.max()
|
|
65
|
+
imbalance_ratio = max_class / min_class if min_class > 0 else np.inf
|
|
66
|
+
|
|
67
|
+
# Check for severe imbalance
|
|
68
|
+
minority_pct = min_class / n_samples
|
|
69
|
+
|
|
70
|
+
report = {
|
|
71
|
+
"n_classes": len(unique_classes),
|
|
72
|
+
"class_distribution": class_dist,
|
|
73
|
+
"imbalance_ratio": float(imbalance_ratio),
|
|
74
|
+
"minority_percentage": float(minority_pct * 100),
|
|
75
|
+
"warnings": [],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Generate warnings
|
|
79
|
+
if imbalance_ratio > 10:
|
|
80
|
+
report["warnings"].append(
|
|
81
|
+
f"SEVERE IMBALANCE: Ratio {imbalance_ratio:.1f}:1. "
|
|
82
|
+
"Consider stratified splitting or resampling."
|
|
83
|
+
)
|
|
84
|
+
elif imbalance_ratio > 5:
|
|
85
|
+
report["warnings"].append(
|
|
86
|
+
f"Moderate imbalance: Ratio {imbalance_ratio:.1f}:1. "
|
|
87
|
+
"Use stratified cross-validation."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if minority_pct < 0.01:
|
|
91
|
+
report["warnings"].append(
|
|
92
|
+
f"Rare event: Minority class < 1% ({minority_pct*100:.2f}%). "
|
|
93
|
+
"Consider specialized techniques (SMOTE, cost-sensitive learning)."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Check group-level balance if provided
|
|
97
|
+
if groups is not None:
|
|
98
|
+
report["group_analysis"] = self._check_group_balance(y, groups)
|
|
99
|
+
|
|
100
|
+
self.report = report
|
|
101
|
+
return report
|
|
102
|
+
|
|
103
|
+
def _check_group_balance(self, y: np.ndarray, groups: np.ndarray) -> Dict[str, Any]:
|
|
104
|
+
"""
|
|
105
|
+
Check balance within groups (e.g., per patient)
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
y : array-like
|
|
110
|
+
Target labels
|
|
111
|
+
groups : array-like
|
|
112
|
+
Group identifiers
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
dict
|
|
117
|
+
Group-level balance analysis
|
|
118
|
+
"""
|
|
119
|
+
groups = np.array(groups)
|
|
120
|
+
unique_groups = np.unique(groups)
|
|
121
|
+
|
|
122
|
+
group_stats = []
|
|
123
|
+
for group in unique_groups:
|
|
124
|
+
group_mask = groups == group
|
|
125
|
+
group_y = y[group_mask]
|
|
126
|
+
|
|
127
|
+
if len(np.unique(group_y)) > 1:
|
|
128
|
+
# Mixed labels in group
|
|
129
|
+
unique_classes, counts = np.unique(group_y, return_counts=True)
|
|
130
|
+
majority_class = unique_classes[np.argmax(counts)]
|
|
131
|
+
group_stats.append(
|
|
132
|
+
{
|
|
133
|
+
"group": group,
|
|
134
|
+
"n_samples": len(group_y),
|
|
135
|
+
"n_classes": len(unique_classes),
|
|
136
|
+
"majority_class": int(majority_class),
|
|
137
|
+
"mixed": True,
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
# Single class in group
|
|
142
|
+
group_stats.append(
|
|
143
|
+
{
|
|
144
|
+
"group": group,
|
|
145
|
+
"n_samples": len(group_y),
|
|
146
|
+
"n_classes": 1,
|
|
147
|
+
"majority_class": int(group_y[0]),
|
|
148
|
+
"mixed": False,
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Analyze group patterns
|
|
153
|
+
mixed_groups = sum(1 for g in group_stats if g["mixed"])
|
|
154
|
+
single_class_groups = len(group_stats) - mixed_groups
|
|
155
|
+
|
|
156
|
+
analysis = {
|
|
157
|
+
"n_groups": len(unique_groups),
|
|
158
|
+
"mixed_label_groups": mixed_groups,
|
|
159
|
+
"single_label_groups": single_class_groups,
|
|
160
|
+
"warnings": [],
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if mixed_groups > 0:
|
|
164
|
+
analysis["warnings"].append(
|
|
165
|
+
f"{mixed_groups} groups have mixed labels. "
|
|
166
|
+
"This may affect patient-level predictions."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return analysis
|
|
170
|
+
|
|
171
|
+
def check_feature_distribution(
|
|
172
|
+
self, X: np.ndarray, feature_names: Optional[List[str]] = None
|
|
173
|
+
) -> Dict[str, Any]:
|
|
174
|
+
"""
|
|
175
|
+
Check feature distributions for potential issues
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
X : array-like
|
|
180
|
+
Feature matrix
|
|
181
|
+
feature_names : list, optional
|
|
182
|
+
Names of features
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
dict
|
|
187
|
+
Feature distribution analysis
|
|
188
|
+
"""
|
|
189
|
+
X = np.array(X)
|
|
190
|
+
n_samples, n_features = X.shape
|
|
191
|
+
|
|
192
|
+
if feature_names is None:
|
|
193
|
+
feature_names = [f"Feature_{i}" for i in range(n_features)]
|
|
194
|
+
|
|
195
|
+
feature_stats = []
|
|
196
|
+
warnings = []
|
|
197
|
+
|
|
198
|
+
for i, name in enumerate(feature_names):
|
|
199
|
+
feature = X[:, i]
|
|
200
|
+
|
|
201
|
+
stats = {
|
|
202
|
+
"name": name,
|
|
203
|
+
"mean": float(np.mean(feature)),
|
|
204
|
+
"std": float(np.std(feature)),
|
|
205
|
+
"min": float(np.min(feature)),
|
|
206
|
+
"max": float(np.max(feature)),
|
|
207
|
+
"n_unique": len(np.unique(feature)),
|
|
208
|
+
"n_missing": int(np.isnan(feature).sum()),
|
|
209
|
+
"skewness": float(self._calculate_skewness(feature)),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Check for issues
|
|
213
|
+
if stats["std"] == 0:
|
|
214
|
+
warnings.append(f"{name}: Zero variance (constant feature)")
|
|
215
|
+
elif stats["n_unique"] == 1:
|
|
216
|
+
warnings.append(f"{name}: Only one unique value")
|
|
217
|
+
elif stats["n_missing"] > n_samples * 0.2:
|
|
218
|
+
warnings.append(
|
|
219
|
+
f"{name}: High missing rate ({stats['n_missing']/n_samples*100:.1f}%)"
|
|
220
|
+
)
|
|
221
|
+
elif abs(stats["skewness"]) > 2:
|
|
222
|
+
warnings.append(f"{name}: Highly skewed (skewness={stats['skewness']:.2f})")
|
|
223
|
+
|
|
224
|
+
feature_stats.append(stats)
|
|
225
|
+
|
|
226
|
+
return {"n_features": n_features, "feature_statistics": feature_stats, "warnings": warnings}
|
|
227
|
+
|
|
228
|
+
def _calculate_skewness(self, x: np.ndarray) -> float:
|
|
229
|
+
"""Calculate skewness of a distribution"""
|
|
230
|
+
x = x[~np.isnan(x)] # Remove NaN values
|
|
231
|
+
if len(x) < 3:
|
|
232
|
+
return 0.0
|
|
233
|
+
|
|
234
|
+
mean = np.mean(x)
|
|
235
|
+
std = np.std(x)
|
|
236
|
+
|
|
237
|
+
if std == 0:
|
|
238
|
+
return 0.0
|
|
239
|
+
|
|
240
|
+
skewness = np.mean(((x - mean) / std) ** 3)
|
|
241
|
+
return skewness
|
|
242
|
+
|
|
243
|
+
def check_cv_balance(self, X, y, cv_splitter, groups=None):
|
|
244
|
+
"""
|
|
245
|
+
Check balance across CV folds
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
X : array-like
|
|
250
|
+
Features
|
|
251
|
+
y : array-like
|
|
252
|
+
Labels
|
|
253
|
+
cv_splitter : cross-validation object
|
|
254
|
+
CV splitter to test
|
|
255
|
+
groups : array-like, optional
|
|
256
|
+
Group labels
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
dict
|
|
261
|
+
CV balance analysis
|
|
262
|
+
"""
|
|
263
|
+
y = np.array(y)
|
|
264
|
+
fold_stats = []
|
|
265
|
+
|
|
266
|
+
for fold_idx, (train_idx, test_idx) in enumerate(cv_splitter.split(X, y, groups)):
|
|
267
|
+
y_train = y[train_idx]
|
|
268
|
+
y_test = y[test_idx]
|
|
269
|
+
|
|
270
|
+
# Calculate distributions
|
|
271
|
+
train_dist = np.bincount(y_train) / len(y_train)
|
|
272
|
+
test_dist = np.bincount(y_test) / len(y_test)
|
|
273
|
+
|
|
274
|
+
# Ensure same length
|
|
275
|
+
max_len = max(len(train_dist), len(test_dist))
|
|
276
|
+
train_dist = np.pad(train_dist, (0, max_len - len(train_dist)))
|
|
277
|
+
test_dist = np.pad(test_dist, (0, max_len - len(test_dist)))
|
|
278
|
+
|
|
279
|
+
# Calculate distribution difference
|
|
280
|
+
dist_diff = np.abs(train_dist - test_dist).max()
|
|
281
|
+
|
|
282
|
+
fold_stats.append(
|
|
283
|
+
{
|
|
284
|
+
"fold": fold_idx,
|
|
285
|
+
"train_size": len(train_idx),
|
|
286
|
+
"test_size": len(test_idx),
|
|
287
|
+
"train_distribution": train_dist.tolist(),
|
|
288
|
+
"test_distribution": test_dist.tolist(),
|
|
289
|
+
"max_distribution_diff": float(dist_diff),
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Check for issues
|
|
294
|
+
warnings = []
|
|
295
|
+
max_diff = max(f["max_distribution_diff"] for f in fold_stats)
|
|
296
|
+
|
|
297
|
+
if max_diff > self.threshold:
|
|
298
|
+
warnings.append(
|
|
299
|
+
f"Large distribution difference between train/test: {max_diff*100:.1f}%. "
|
|
300
|
+
"Consider stratified splitting."
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
size_variation = np.std([f["test_size"] for f in fold_stats])
|
|
304
|
+
if size_variation > np.mean([f["test_size"] for f in fold_stats]) * 0.1:
|
|
305
|
+
warnings.append(
|
|
306
|
+
"Large variation in fold sizes. " "This may affect performance estimates."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"n_folds": len(fold_stats),
|
|
311
|
+
"fold_statistics": fold_stats,
|
|
312
|
+
"max_distribution_difference": float(max_diff),
|
|
313
|
+
"warnings": warnings,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
def generate_report(self) -> str:
|
|
317
|
+
"""
|
|
318
|
+
Generate human-readable balance report
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
str
|
|
323
|
+
Formatted report
|
|
324
|
+
"""
|
|
325
|
+
if not self.report:
|
|
326
|
+
return "No balance check performed yet."
|
|
327
|
+
|
|
328
|
+
lines = ["=" * 50, "BALANCE CHECK REPORT", "=" * 50]
|
|
329
|
+
|
|
330
|
+
# Class balance
|
|
331
|
+
if "class_distribution" in self.report:
|
|
332
|
+
lines.append("\nCLASS BALANCE:")
|
|
333
|
+
for cls, stats in self.report["class_distribution"].items():
|
|
334
|
+
lines.append(
|
|
335
|
+
f" Class {cls}: {stats['count']} samples ({stats['percentage']:.1f}%)"
|
|
336
|
+
)
|
|
337
|
+
lines.append(f" Imbalance ratio: {self.report['imbalance_ratio']:.2f}:1")
|
|
338
|
+
|
|
339
|
+
# Warnings
|
|
340
|
+
if self.report.get("warnings"):
|
|
341
|
+
lines.append("\nWARNINGS:")
|
|
342
|
+
for warning in self.report["warnings"]:
|
|
343
|
+
lines.append(f" ⚠️ {warning}")
|
|
344
|
+
|
|
345
|
+
# Group analysis
|
|
346
|
+
if "group_analysis" in self.report:
|
|
347
|
+
ga = self.report["group_analysis"]
|
|
348
|
+
lines.append(f"\nGROUP ANALYSIS:")
|
|
349
|
+
lines.append(f" Total groups: {ga['n_groups']}")
|
|
350
|
+
lines.append(f" Mixed-label groups: {ga['mixed_label_groups']}")
|
|
351
|
+
lines.append(f" Single-label groups: {ga['single_label_groups']}")
|
|
352
|
+
|
|
353
|
+
return "\n".join(lines)
|