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 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)