deepecgkit 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 (61) hide show
  1. deepecgkit/__init__.py +42 -0
  2. deepecgkit/__main__.py +6 -0
  3. deepecgkit/cli/__init__.py +61 -0
  4. deepecgkit/cli/commands/__init__.py +16 -0
  5. deepecgkit/cli/commands/evaluate.py +83 -0
  6. deepecgkit/cli/commands/info.py +73 -0
  7. deepecgkit/cli/commands/predict.py +94 -0
  8. deepecgkit/cli/commands/train.py +331 -0
  9. deepecgkit/cli/config.py +23 -0
  10. deepecgkit/cli/evaluation.py +251 -0
  11. deepecgkit/cli/logger.py +26 -0
  12. deepecgkit/cli/main.py +429 -0
  13. deepecgkit/cli/registry.py +32 -0
  14. deepecgkit/datasets/__init__.py +33 -0
  15. deepecgkit/datasets/af_classification.py +301 -0
  16. deepecgkit/datasets/base.py +146 -0
  17. deepecgkit/datasets/ltafdb.py +363 -0
  18. deepecgkit/datasets/mitbih_afdb.py +287 -0
  19. deepecgkit/datasets/modules.py +222 -0
  20. deepecgkit/datasets/preprocessing.py +208 -0
  21. deepecgkit/datasets/ptbxl.py +567 -0
  22. deepecgkit/datasets/splitting.py +225 -0
  23. deepecgkit/datasets/unified_af.py +316 -0
  24. deepecgkit/evaluation/__init__.py +40 -0
  25. deepecgkit/evaluation/evaluator.py +398 -0
  26. deepecgkit/evaluation/metrics.py +164 -0
  27. deepecgkit/evaluation/visualization.py +285 -0
  28. deepecgkit/models/__init__.py +51 -0
  29. deepecgkit/models/af_classifier.py +177 -0
  30. deepecgkit/models/convnext_v2_1d.py +183 -0
  31. deepecgkit/models/crnn.py +117 -0
  32. deepecgkit/models/deep_res_cnn.py +215 -0
  33. deepecgkit/models/ecg_dualnet.py +230 -0
  34. deepecgkit/models/fcn_wang.py +88 -0
  35. deepecgkit/models/gru_model.py +99 -0
  36. deepecgkit/models/inception_time.py +197 -0
  37. deepecgkit/models/kanres_wide_x.py +150 -0
  38. deepecgkit/models/kanres_x.py +126 -0
  39. deepecgkit/models/lstm_model.py +96 -0
  40. deepecgkit/models/mamba1d.py +232 -0
  41. deepecgkit/models/medformer.py +229 -0
  42. deepecgkit/models/resnet1d.py +189 -0
  43. deepecgkit/models/resnet1d_wang.py +182 -0
  44. deepecgkit/models/se_resnet1d.py +232 -0
  45. deepecgkit/models/simple_cnn.py +82 -0
  46. deepecgkit/models/tcn.py +178 -0
  47. deepecgkit/models/transformer_ecg.py +127 -0
  48. deepecgkit/models/xresnet1d.py +265 -0
  49. deepecgkit/models/xresnet1d_benchmark.py +227 -0
  50. deepecgkit/registry.py +97 -0
  51. deepecgkit/training/__init__.py +9 -0
  52. deepecgkit/training/train.py +171 -0
  53. deepecgkit/utils/__init__.py +72 -0
  54. deepecgkit/utils/download.py +123 -0
  55. deepecgkit/utils/weights.py +107 -0
  56. deepecgkit-0.1.0.dist-info/METADATA +99 -0
  57. deepecgkit-0.1.0.dist-info/RECORD +61 -0
  58. deepecgkit-0.1.0.dist-info/WHEEL +5 -0
  59. deepecgkit-0.1.0.dist-info/entry_points.txt +2 -0
  60. deepecgkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  61. deepecgkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,225 @@
1
+ """
2
+ Data splitting and loader creation utilities.
3
+
4
+ This module provides classes for splitting datasets and creating data loaders.
5
+ """
6
+
7
+ from collections import Counter
8
+ from typing import Optional, Tuple
9
+
10
+ import numpy as np
11
+ import torch
12
+ from sklearn.model_selection import train_test_split
13
+ from torch.utils.data import Dataset, Subset, random_split
14
+
15
+
16
+ class DataSplitter:
17
+ """Handles dataset splitting into train, validation, and test sets."""
18
+
19
+ def __init__(
20
+ self,
21
+ dataset: Dataset,
22
+ val_split: float = 0.2,
23
+ test_split: float = 0.1,
24
+ seed: Optional[int] = 42,
25
+ stratify: Optional[np.ndarray] = None,
26
+ groups: Optional[np.ndarray] = None,
27
+ ):
28
+ """Initialize the data splitter.
29
+
30
+ Args:
31
+ dataset: PyTorch dataset to split
32
+ val_split: Fraction of data to use for validation
33
+ test_split: Fraction of data to use for testing
34
+ seed: Random seed for reproducibility
35
+ stratify: Array of labels for stratified splitting (optional)
36
+ groups: Array of group identifiers (e.g. patient/record IDs) to keep
37
+ all samples from the same group in the same split (optional).
38
+ Prevents data leakage from correlated samples.
39
+ """
40
+ self.dataset = dataset
41
+ self.val_split = val_split
42
+ self.test_split = test_split
43
+ self.seed = seed
44
+ self.stratify = stratify
45
+ self.groups = groups
46
+
47
+ if val_split + test_split >= 1.0:
48
+ raise ValueError("val_split + test_split must be less than 1.0")
49
+
50
+ def split(self) -> Tuple[Dataset, Dataset, Dataset]:
51
+ """Split the dataset into train, validation, and test sets.
52
+
53
+ Returns:
54
+ Tuple of (train_dataset, val_dataset, test_dataset)
55
+ """
56
+ if self.groups is not None and len(self.groups) > 0:
57
+ return self._split_by_groups()
58
+
59
+ total_size = len(self.dataset)
60
+
61
+ # Handle small datasets by ensuring at least 1 sample per split if possible
62
+ val_size = max(1, int(total_size * self.val_split)) if self.val_split > 0 else 0
63
+ test_size = max(1, int(total_size * self.test_split)) if self.test_split > 0 else 0
64
+
65
+ # Adjust for very small datasets
66
+ if total_size <= 2:
67
+ val_size = 0
68
+ test_size = 0
69
+ elif total_size == 3:
70
+ val_size = 1
71
+ test_size = 0
72
+ elif val_size + test_size >= total_size:
73
+ # Reduce splits for small datasets
74
+ val_size = 1 if self.val_split > 0 else 0
75
+ test_size = 1 if self.test_split > 0 and total_size > val_size + 1 else 0
76
+
77
+ train_size = total_size - val_size - test_size
78
+
79
+ if self.stratify is not None and total_size > 2:
80
+ try:
81
+ indices = np.arange(total_size)
82
+ if val_size + test_size > 0:
83
+ train_idx, temp_idx = train_test_split(
84
+ indices,
85
+ test_size=(val_size + test_size) / total_size,
86
+ stratify=self.stratify,
87
+ random_state=self.seed,
88
+ )
89
+ if test_size > 0 and val_size > 0:
90
+ val_idx, test_idx = train_test_split(
91
+ temp_idx,
92
+ test_size=test_size / (val_size + test_size),
93
+ stratify=self.stratify[temp_idx],
94
+ random_state=self.seed,
95
+ )
96
+ elif test_size > 0:
97
+ test_idx = temp_idx
98
+ val_idx = []
99
+ else:
100
+ val_idx = temp_idx
101
+ test_idx = []
102
+ else:
103
+ train_idx = indices
104
+ val_idx = []
105
+ test_idx = []
106
+
107
+ train_dataset = Subset(self.dataset, train_idx)
108
+ val_dataset = (
109
+ Subset(self.dataset, val_idx) if len(val_idx) > 0 else Subset(self.dataset, [])
110
+ )
111
+ test_dataset = (
112
+ Subset(self.dataset, test_idx)
113
+ if len(test_idx) > 0
114
+ else Subset(self.dataset, [])
115
+ )
116
+ return train_dataset, val_dataset, test_dataset
117
+ except ValueError:
118
+ pass # Fall through to random splitting
119
+
120
+ if val_size + test_size == 0:
121
+ train_dataset = self.dataset
122
+ val_dataset = Subset(self.dataset, [])
123
+ test_dataset = Subset(self.dataset, [])
124
+ else:
125
+ train_dataset, val_dataset, test_dataset = random_split(
126
+ self.dataset,
127
+ [train_size, val_size, test_size],
128
+ generator=torch.Generator().manual_seed(self.seed),
129
+ )
130
+
131
+ return train_dataset, val_dataset, test_dataset
132
+
133
+ def _split_by_groups(self) -> Tuple[Dataset, Dataset, Dataset]:
134
+ """Split by groups so all samples from one group stay in the same split.
135
+
136
+ This implements inter-patient splitting for medical datasets where
137
+ segments from the same recording/patient are highly correlated.
138
+ """
139
+ unique_groups = np.unique(self.groups)
140
+ n_groups = len(unique_groups)
141
+
142
+ if n_groups < 3:
143
+ raise ValueError(f"Need at least 3 groups for train/val/test split, got {n_groups}")
144
+
145
+ val_n = max(1, int(n_groups * self.val_split)) if self.val_split > 0 else 0
146
+ test_n = max(1, int(n_groups * self.test_split)) if self.test_split > 0 else 0
147
+
148
+ if val_n + test_n >= n_groups:
149
+ val_n = 1 if self.val_split > 0 else 0
150
+ test_n = 1 if self.test_split > 0 and n_groups > val_n + 1 else 0
151
+
152
+ # Compute majority label per group for stratified group splitting
153
+ group_stratify = None
154
+ if self.stratify is not None:
155
+ group_to_labels: dict = {}
156
+ for g, label in zip(self.groups, self.stratify):
157
+ group_to_labels.setdefault(g, []).append(label)
158
+ group_stratify = np.array(
159
+ [Counter(group_to_labels[g]).most_common(1)[0][0] for g in unique_groups]
160
+ )
161
+ _, counts = np.unique(group_stratify, return_counts=True)
162
+ if np.min(counts) < 2:
163
+ group_stratify = None
164
+
165
+ group_indices = np.arange(n_groups)
166
+
167
+ try:
168
+ train_gi, temp_gi = train_test_split(
169
+ group_indices,
170
+ test_size=(val_n + test_n) / n_groups,
171
+ stratify=group_stratify,
172
+ random_state=self.seed,
173
+ )
174
+ if val_n > 0 and test_n > 0:
175
+ temp_stratify = group_stratify[temp_gi] if group_stratify is not None else None
176
+ if temp_stratify is not None:
177
+ _, tc = np.unique(temp_stratify, return_counts=True)
178
+ if np.min(tc) < 2:
179
+ temp_stratify = None
180
+ val_gi, test_gi = train_test_split(
181
+ temp_gi,
182
+ test_size=test_n / (val_n + test_n),
183
+ stratify=temp_stratify,
184
+ random_state=self.seed,
185
+ )
186
+ elif test_n > 0:
187
+ test_gi = temp_gi
188
+ val_gi = np.array([], dtype=int)
189
+ else:
190
+ val_gi = temp_gi
191
+ test_gi = np.array([], dtype=int)
192
+ except ValueError:
193
+ # Stratification failed, split without it
194
+ train_gi, temp_gi = train_test_split(
195
+ group_indices,
196
+ test_size=(val_n + test_n) / n_groups,
197
+ random_state=self.seed,
198
+ )
199
+ if val_n > 0 and test_n > 0:
200
+ val_gi, test_gi = train_test_split(
201
+ temp_gi,
202
+ test_size=test_n / (val_n + test_n),
203
+ random_state=self.seed,
204
+ )
205
+ elif test_n > 0:
206
+ test_gi = temp_gi
207
+ val_gi = np.array([], dtype=int)
208
+ else:
209
+ val_gi = temp_gi
210
+ test_gi = np.array([], dtype=int)
211
+
212
+ # Map group indices back to sample indices
213
+ train_groups = set(unique_groups[train_gi])
214
+ val_groups = set(unique_groups[val_gi]) if len(val_gi) > 0 else set()
215
+ test_groups = set(unique_groups[test_gi]) if len(test_gi) > 0 else set()
216
+
217
+ train_idx = [i for i, g in enumerate(self.groups) if g in train_groups]
218
+ val_idx = [i for i, g in enumerate(self.groups) if g in val_groups]
219
+ test_idx = [i for i, g in enumerate(self.groups) if g in test_groups]
220
+
221
+ return (
222
+ Subset(self.dataset, train_idx),
223
+ Subset(self.dataset, val_idx) if val_idx else Subset(self.dataset, []),
224
+ Subset(self.dataset, test_idx) if test_idx else Subset(self.dataset, []),
225
+ )
@@ -0,0 +1,316 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import ClassVar, Dict, List, Optional, Tuple, Type, Union
4
+
5
+ import numpy as np
6
+ import torch
7
+ from torch.utils.data import ConcatDataset, Dataset
8
+
9
+ from deepecgkit.registry import register_dataset
10
+
11
+ from .af_classification import AFClassificationDataset
12
+ from .base import BaseECGDataset
13
+ from .ltafdb import LTAFDBDataset
14
+ from .mitbih_afdb import MITBIHAFDBDataset
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # PhysioNet 2017 uses labels: N=0, A=1, O=2, ~=3
19
+ # Unified 4-class scheme: Normal=0, AF=1, AFL=2, J=3
20
+ # "Other" (O) and "Noisy" (~) have no unified equivalent → dropped
21
+ _PHYSIONET2017_LABEL_REMAP = {
22
+ 0: 0, # N (Normal) → Normal
23
+ 1: 1, # A (AF) → AF
24
+ 2: None, # O (Other) → drop
25
+ 3: None, # ~ (Noisy) → drop
26
+ }
27
+
28
+ # Binary scheme: Non-AF=0, AF=1
29
+ # "Other" maps to Non-AF, "Noisy" is still dropped
30
+ _PHYSIONET2017_BINARY_REMAP = {
31
+ 0: 0, # N (Normal) → Non-AF
32
+ 1: 1, # A (AF) → AF
33
+ 2: 0, # O (Other) → Non-AF
34
+ 3: None, # ~ (Noisy) → drop
35
+ }
36
+
37
+
38
+ class _RemappedDataset(Dataset):
39
+ """Wraps a dataset with label remapping and sample filtering.
40
+
41
+ Samples whose labels map to None are excluded from the dataset.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ dataset,
47
+ label_map: Dict[int, Optional[int]],
48
+ target_transform: Optional[callable] = None,
49
+ ):
50
+ self._dataset = dataset
51
+ self._label_map = label_map
52
+ self._target_transform = target_transform
53
+
54
+ # Filter out samples whose labels map to None
55
+ if hasattr(dataset, "labels"):
56
+ self._indices = [
57
+ i for i, lbl in enumerate(dataset.labels) if label_map.get(int(lbl)) is not None
58
+ ]
59
+ self.labels = [label_map[int(dataset.labels[i])] for i in self._indices]
60
+ else:
61
+ self._indices = list(range(len(dataset)))
62
+ self.labels = []
63
+
64
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
65
+ signal, label = self._dataset[self._indices[idx]]
66
+ new_label = torch.tensor(self._label_map[label.item()], dtype=torch.long)
67
+ if self._target_transform is not None:
68
+ new_label = self._target_transform(new_label)
69
+ return signal, new_label
70
+
71
+ def __len__(self) -> int:
72
+ return len(self._indices)
73
+
74
+
75
+ @register_dataset(
76
+ name="unified-af",
77
+ input_channels=1,
78
+ num_classes=4,
79
+ description="Unified AF dataset combining PhysioNet 2017, MIT-BIH, LTAFDB",
80
+ )
81
+ class UnifiedAFDataset(BaseECGDataset):
82
+ """Unified AF Dataset combining multiple PhysioNet AF databases.
83
+
84
+ Combines samples from the PhysioNet 2017 Challenge, MIT-BIH AFDB, and LTAFDB
85
+ into a single dataset for AF classification. Supports both binary (AF vs Non-AF)
86
+ and 4-class (Normal, AF, AFL, J) classification modes.
87
+
88
+ PhysioNet 2017 labels are remapped to the unified scheme:
89
+ - Normal (N) → Normal, AF (A) → AF
90
+ - Other (O) and Noisy (~) are dropped in 4-class mode
91
+ - Other (O) → Non-AF and Noisy (~) is dropped in binary mode
92
+ """
93
+
94
+ CLASS_LABELS: ClassVar[List[str]] = ["Normal", "AF", "AFL", "J"]
95
+ LEADS: ClassVar[List[str]] = ["ECG"]
96
+
97
+ AVAILABLE_DATASETS: ClassVar[Dict[str, Type[BaseECGDataset]]] = {
98
+ "physionet2017": AFClassificationDataset,
99
+ "mitbih_afdb": MITBIHAFDBDataset,
100
+ "ltafdb": LTAFDBDataset,
101
+ }
102
+
103
+ def _resolve_dataset_dir(self, dataset_name: str) -> Path:
104
+ dataset_class = self.AVAILABLE_DATASETS[dataset_name]
105
+ return dataset_class.get_default_data_dir()
106
+
107
+ def __init__(
108
+ self,
109
+ data_dir: Optional[Union[str, Path]] = None,
110
+ sampling_rate: int = 300,
111
+ segment_duration_seconds: float = 10.0,
112
+ datasets: Optional[List[str]] = None,
113
+ binary_classification: bool = False,
114
+ normalization: str = "zscore",
115
+ transform: Optional[callable] = None,
116
+ target_transform: Optional[callable] = None,
117
+ download: bool = False,
118
+ force_download: bool = False,
119
+ verbose: bool = True,
120
+ dataset_kwargs: Optional[Dict[str, Dict]] = None,
121
+ ):
122
+ self.segment_duration_seconds = segment_duration_seconds
123
+ self.binary_classification = binary_classification
124
+ self.verbose = verbose
125
+ self.dataset_kwargs = dataset_kwargs or {}
126
+
127
+ if datasets is None:
128
+ datasets = ["physionet2017", "mitbih_afdb", "ltafdb"]
129
+
130
+ for dataset_name in datasets:
131
+ if dataset_name not in self.AVAILABLE_DATASETS:
132
+ raise ValueError(
133
+ f"Unknown dataset: {dataset_name}. "
134
+ f"Available: {list(self.AVAILABLE_DATASETS.keys())}"
135
+ )
136
+
137
+ self.dataset_names = datasets
138
+ self.datasets = []
139
+ self.dataset_sizes = []
140
+
141
+ super().__init__(
142
+ data_dir=data_dir,
143
+ sampling_rate=sampling_rate,
144
+ leads=self.LEADS,
145
+ transform=transform,
146
+ target_transform=target_transform,
147
+ download=download,
148
+ force_download=force_download,
149
+ )
150
+
151
+ def download(self):
152
+ for dataset_name in self.dataset_names:
153
+ dataset_class = self.AVAILABLE_DATASETS[dataset_name]
154
+ dataset_dir = self._resolve_dataset_dir(dataset_name)
155
+
156
+ if dataset_dir.exists() and any(dataset_dir.iterdir()):
157
+ if self.verbose:
158
+ print(f"\n{'=' * 60}")
159
+ print(f"Skipping {dataset_name}: already exists at {dataset_dir}")
160
+ print(f"{'=' * 60}")
161
+ continue
162
+
163
+ if self.verbose:
164
+ print(f"\n{'=' * 60}")
165
+ print(f"Downloading dataset: {dataset_name}")
166
+ print(f"{'=' * 60}")
167
+
168
+ dataset_dir.mkdir(parents=True, exist_ok=True)
169
+
170
+ # Minimal instance for download only — sub-dataset download()
171
+ # methods only require self.data_dir and self.verbose.
172
+ instance = object.__new__(dataset_class)
173
+ instance.data_dir = dataset_dir
174
+ instance.verbose = self.verbose
175
+ instance.download()
176
+
177
+ def _get_dataset_kwargs(self, dataset_name: str) -> Dict:
178
+ base_kwargs = {
179
+ "sampling_rate": self.sampling_rate,
180
+ "transform": self.transform,
181
+ "segment_duration_seconds": self.segment_duration_seconds,
182
+ "download": False,
183
+ }
184
+
185
+ if dataset_name in ["mitbih_afdb", "ltafdb"]:
186
+ base_kwargs["target_transform"] = self.target_transform
187
+ base_kwargs["binary_classification"] = self.binary_classification
188
+ elif dataset_name == "physionet2017":
189
+ # target_transform is omitted — applied after label remapping
190
+ # in _RemappedDataset (see _load_data)
191
+ pass
192
+
193
+ if dataset_name in self.dataset_kwargs:
194
+ base_kwargs.update(self.dataset_kwargs[dataset_name])
195
+
196
+ return base_kwargs
197
+
198
+ def _load_data(self):
199
+ if self.verbose:
200
+ print("\nLoading unified AF dataset...")
201
+
202
+ for dataset_name in self.dataset_names:
203
+ if self.verbose:
204
+ print(f"\n{'=' * 60}")
205
+ print(f"Loading: {dataset_name}")
206
+ print(f"{'=' * 60}")
207
+
208
+ dataset_class = self.AVAILABLE_DATASETS[dataset_name]
209
+ dataset_dir = self._resolve_dataset_dir(dataset_name)
210
+
211
+ kwargs = self._get_dataset_kwargs(dataset_name)
212
+ kwargs["data_dir"] = dataset_dir
213
+ kwargs["verbose"] = self.verbose
214
+
215
+ try:
216
+ dataset = dataset_class(**kwargs)
217
+
218
+ # Remap PhysioNet 2017 labels to the unified scheme
219
+ if dataset_name == "physionet2017":
220
+ remap = (
221
+ _PHYSIONET2017_BINARY_REMAP
222
+ if self.binary_classification
223
+ else _PHYSIONET2017_LABEL_REMAP
224
+ )
225
+ original_len = len(dataset)
226
+ dataset = _RemappedDataset(
227
+ dataset, remap, target_transform=self.target_transform
228
+ )
229
+ dropped = original_len - len(dataset)
230
+ if dropped > 0 and self.verbose:
231
+ print(f" Dropped {dropped} samples with unmappable labels (Other/Noisy)")
232
+
233
+ self.datasets.append(dataset)
234
+ self.dataset_sizes.append(len(dataset))
235
+
236
+ if self.verbose:
237
+ print(f"Loaded {len(dataset)} samples from {dataset_name}")
238
+ if hasattr(dataset, "get_class_distribution"):
239
+ dist = dataset.get_class_distribution()
240
+ print(f"Class distribution: {dist}")
241
+
242
+ except Exception as e:
243
+ logger.warning("Failed to load %s: %s", dataset_name, e)
244
+ if self.verbose:
245
+ print(f"Failed to load {dataset_name}: {e}")
246
+ continue
247
+
248
+ if len(self.datasets) == 0:
249
+ raise RuntimeError("No datasets were successfully loaded")
250
+
251
+ self.concat_dataset = ConcatDataset(self.datasets)
252
+
253
+ if self.verbose:
254
+ print(f"\n{'=' * 60}")
255
+ print("Unified dataset created")
256
+ print(f"Total datasets: {len(self.datasets)}")
257
+ print(f"Total samples: {len(self.concat_dataset)}")
258
+ print(f"{'=' * 60}")
259
+ self._print_overall_distribution()
260
+
261
+ def _print_overall_distribution(self):
262
+ if len(self.datasets) == 0:
263
+ return
264
+
265
+ all_labels = []
266
+ for dataset in self.datasets:
267
+ if hasattr(dataset, "labels"):
268
+ all_labels.extend(dataset.labels)
269
+
270
+ if len(all_labels) == 0:
271
+ return
272
+
273
+ unique, counts = np.unique(all_labels, return_counts=True)
274
+ print("\nOverall class distribution:")
275
+ for label_idx, count in zip(unique, counts):
276
+ class_name = self.class_names[label_idx]
277
+ percentage = (count / len(all_labels)) * 100
278
+ print(f" {class_name}: {count} ({percentage:.1f}%)")
279
+
280
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
281
+ return self.concat_dataset[idx]
282
+
283
+ def __len__(self) -> int:
284
+ return len(self.concat_dataset)
285
+
286
+ @property
287
+ def num_classes(self) -> int:
288
+ return 2 if self.binary_classification else len(self.CLASS_LABELS)
289
+
290
+ @property
291
+ def class_names(self) -> List[str]:
292
+ if self.binary_classification:
293
+ return ["Non-AF", "AF"]
294
+ return self.CLASS_LABELS
295
+
296
+ def get_dataset_info(self) -> Dict:
297
+ return {
298
+ "num_datasets": len(self.datasets),
299
+ "dataset_names": self.dataset_names,
300
+ "dataset_sizes": self.dataset_sizes,
301
+ "total_samples": len(self),
302
+ "num_classes": self.num_classes,
303
+ "class_names": self.class_names,
304
+ }
305
+
306
+ def get_class_distribution(self) -> Dict[str, int]:
307
+ all_labels = []
308
+ for dataset in self.datasets:
309
+ if hasattr(dataset, "labels"):
310
+ all_labels.extend(dataset.labels)
311
+
312
+ if len(all_labels) == 0:
313
+ return {}
314
+
315
+ unique, counts = np.unique(all_labels, return_counts=True)
316
+ return {self.class_names[int(label)]: int(count) for label, count in zip(unique, counts)}
@@ -0,0 +1,40 @@
1
+ """
2
+ Model evaluation and metrics module for DeepECG-Kit.
3
+
4
+ This module provides comprehensive evaluation functionality including:
5
+ - Classification metrics (accuracy, precision, recall, F1, AUC)
6
+ - Regression metrics (MSE, MAE, R²)
7
+ - ECG-specific metrics
8
+ - Visualization tools
9
+ - Statistical analysis
10
+ """
11
+
12
+ from deepecgkit.evaluation.evaluator import ECGEvaluator
13
+ from deepecgkit.evaluation.metrics import (
14
+ calculate_classification_metrics,
15
+ calculate_regression_metrics,
16
+ confusion_matrix_analysis,
17
+ roc_auc_score,
18
+ )
19
+ from deepecgkit.evaluation.visualization import (
20
+ plot_calibration_curve,
21
+ plot_confusion_matrix,
22
+ plot_ecg_signals,
23
+ plot_predictions,
24
+ plot_roc_curve,
25
+ plot_training_curves,
26
+ )
27
+
28
+ __all__ = [
29
+ "ECGEvaluator",
30
+ "calculate_classification_metrics",
31
+ "calculate_regression_metrics",
32
+ "confusion_matrix_analysis",
33
+ "plot_calibration_curve",
34
+ "plot_confusion_matrix",
35
+ "plot_ecg_signals",
36
+ "plot_predictions",
37
+ "plot_roc_curve",
38
+ "plot_training_curves",
39
+ "roc_auc_score",
40
+ ]