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.
- deepecgkit/__init__.py +42 -0
- deepecgkit/__main__.py +6 -0
- deepecgkit/cli/__init__.py +61 -0
- deepecgkit/cli/commands/__init__.py +16 -0
- deepecgkit/cli/commands/evaluate.py +83 -0
- deepecgkit/cli/commands/info.py +73 -0
- deepecgkit/cli/commands/predict.py +94 -0
- deepecgkit/cli/commands/train.py +331 -0
- deepecgkit/cli/config.py +23 -0
- deepecgkit/cli/evaluation.py +251 -0
- deepecgkit/cli/logger.py +26 -0
- deepecgkit/cli/main.py +429 -0
- deepecgkit/cli/registry.py +32 -0
- deepecgkit/datasets/__init__.py +33 -0
- deepecgkit/datasets/af_classification.py +301 -0
- deepecgkit/datasets/base.py +146 -0
- deepecgkit/datasets/ltafdb.py +363 -0
- deepecgkit/datasets/mitbih_afdb.py +287 -0
- deepecgkit/datasets/modules.py +222 -0
- deepecgkit/datasets/preprocessing.py +208 -0
- deepecgkit/datasets/ptbxl.py +567 -0
- deepecgkit/datasets/splitting.py +225 -0
- deepecgkit/datasets/unified_af.py +316 -0
- deepecgkit/evaluation/__init__.py +40 -0
- deepecgkit/evaluation/evaluator.py +398 -0
- deepecgkit/evaluation/metrics.py +164 -0
- deepecgkit/evaluation/visualization.py +285 -0
- deepecgkit/models/__init__.py +51 -0
- deepecgkit/models/af_classifier.py +177 -0
- deepecgkit/models/convnext_v2_1d.py +183 -0
- deepecgkit/models/crnn.py +117 -0
- deepecgkit/models/deep_res_cnn.py +215 -0
- deepecgkit/models/ecg_dualnet.py +230 -0
- deepecgkit/models/fcn_wang.py +88 -0
- deepecgkit/models/gru_model.py +99 -0
- deepecgkit/models/inception_time.py +197 -0
- deepecgkit/models/kanres_wide_x.py +150 -0
- deepecgkit/models/kanres_x.py +126 -0
- deepecgkit/models/lstm_model.py +96 -0
- deepecgkit/models/mamba1d.py +232 -0
- deepecgkit/models/medformer.py +229 -0
- deepecgkit/models/resnet1d.py +189 -0
- deepecgkit/models/resnet1d_wang.py +182 -0
- deepecgkit/models/se_resnet1d.py +232 -0
- deepecgkit/models/simple_cnn.py +82 -0
- deepecgkit/models/tcn.py +178 -0
- deepecgkit/models/transformer_ecg.py +127 -0
- deepecgkit/models/xresnet1d.py +265 -0
- deepecgkit/models/xresnet1d_benchmark.py +227 -0
- deepecgkit/registry.py +97 -0
- deepecgkit/training/__init__.py +9 -0
- deepecgkit/training/train.py +171 -0
- deepecgkit/utils/__init__.py +72 -0
- deepecgkit/utils/download.py +123 -0
- deepecgkit/utils/weights.py +107 -0
- deepecgkit-0.1.0.dist-info/METADATA +99 -0
- deepecgkit-0.1.0.dist-info/RECORD +61 -0
- deepecgkit-0.1.0.dist-info/WHEEL +5 -0
- deepecgkit-0.1.0.dist-info/entry_points.txt +2 -0
- deepecgkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|