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,222 @@
1
+ """
2
+ PyTorch Lightning data module for ECG datasets.
3
+ """
4
+
5
+ from typing import Dict, Optional, Type, Union
6
+
7
+ import numpy as np
8
+ import pytorch_lightning as pl
9
+ import torch
10
+ from torch.utils.data import DataLoader, Dataset
11
+
12
+ from deepecgkit.datasets.base import BaseECGDataset
13
+ from deepecgkit.datasets.splitting import DataSplitter
14
+
15
+
16
+ class ECGDataModule(pl.LightningDataModule):
17
+ """PyTorch Lightning data module for ECG datasets."""
18
+
19
+ def __init__(
20
+ self,
21
+ dataset: Optional[Union[BaseECGDataset, Dataset]] = None,
22
+ dataset_class: Optional[Type[BaseECGDataset]] = None,
23
+ data_dir: Optional[str] = None,
24
+ sampling_rate: int = 500,
25
+ leads: Optional[list] = None,
26
+ transform: Optional[callable] = None,
27
+ target_transform: Optional[callable] = None,
28
+ download: bool = False,
29
+ batch_size: int = 32,
30
+ val_split: float = 0.2,
31
+ test_split: float = 0.1,
32
+ num_workers: int = 4,
33
+ seed: int = 42,
34
+ stratify: bool = True,
35
+ pin_memory: bool = False,
36
+ persistent_workers: bool = True,
37
+ prefetch_factor: int = 2,
38
+ verbose: bool = True,
39
+ dataset_kwargs: Optional[Dict] = None,
40
+ ):
41
+ """Initialize the ECG data module.
42
+
43
+ Args:
44
+ dataset: PyTorch dataset instance (optional)
45
+ dataset_class: Class of dataset to create (optional)
46
+ data_dir: Directory containing ECG data files (optional, uses dataset's default if None)
47
+ sampling_rate: Sampling rate of the ECG signals (Hz)
48
+ leads: List of leads to use (e.g., ['I', 'II', 'III'] for standard leads)
49
+ transform: Optional transform to be applied to the ECG signals
50
+ target_transform: Optional transform to be applied to the labels
51
+ download: Whether to download the dataset if it doesn't exist
52
+ batch_size: Batch size for data loaders
53
+ val_split: Fraction of data to use for validation
54
+ test_split: Fraction of data to use for testing
55
+ num_workers: Number of worker processes for data loading
56
+ seed: Random seed for reproducibility
57
+ stratify: Whether to use stratified splitting based on labels
58
+ dataset_kwargs: Additional keyword arguments to pass to the dataset class
59
+ """
60
+ super().__init__()
61
+ self.dataset = dataset
62
+ self.dataset_class = dataset_class
63
+ self.data_dir = data_dir
64
+ self.sampling_rate = sampling_rate
65
+ self.leads = leads
66
+ self.transform = transform
67
+ self.target_transform = target_transform
68
+ self.download = download
69
+ self.batch_size = batch_size
70
+ self.val_split = val_split
71
+ self.test_split = test_split
72
+ self.num_workers = num_workers
73
+ self.seed = seed
74
+ self.stratify = stratify
75
+ self.pin_memory = pin_memory
76
+ self.persistent_workers = persistent_workers
77
+ self.prefetch_factor = prefetch_factor
78
+ self.verbose = verbose
79
+ self.dataset_kwargs = dataset_kwargs or {}
80
+
81
+ self.train_dataset = None
82
+ self.val_dataset = None
83
+ self.test_dataset = None
84
+
85
+ def setup(self, stage: Optional[str] = None):
86
+ """Set up the datasets.
87
+
88
+ Args:
89
+ stage: Current stage ('fit', 'validate', 'test', or 'predict')
90
+ """
91
+ if self.dataset is None:
92
+ if self.dataset_class is None:
93
+ raise ValueError("dataset_class must be provided if dataset is None")
94
+
95
+ if self.data_dir is None:
96
+ self.data_dir = self.dataset_class.get_default_data_dir()
97
+
98
+ init_kwargs = {
99
+ "data_dir": self.data_dir,
100
+ "sampling_rate": self.sampling_rate,
101
+ "transform": self.transform,
102
+ "target_transform": self.target_transform,
103
+ "download": self.download,
104
+ }
105
+
106
+ if self.leads is not None:
107
+ init_kwargs["leads"] = self.leads
108
+
109
+ init_kwargs.update(self.dataset_kwargs)
110
+
111
+ self.dataset = self.dataset_class(**init_kwargs)
112
+
113
+ stratify_labels = None
114
+ if (
115
+ self.stratify and len(self.dataset) >= 8
116
+ ): # Need at least 2 samples per class for 4 classes
117
+ try:
118
+ all_labels = []
119
+ for i in range(len(self.dataset)):
120
+ _, label = self.dataset[i]
121
+ all_labels.append(label)
122
+ stacked = torch.stack(all_labels).numpy()
123
+
124
+ # For multi-label (2D), convert rows to string keys for stratification
125
+ if stacked.ndim > 1:
126
+ stratify_labels = np.array(
127
+ ["_".join(str(int(v)) for v in row) for row in stacked]
128
+ )
129
+ else:
130
+ stratify_labels = stacked
131
+
132
+ # Check if we have enough samples per class for stratification
133
+ _, counts = np.unique(stratify_labels, return_counts=True)
134
+ if np.min(counts) < 2:
135
+ stratify_labels = None # Disable stratification
136
+ except Exception:
137
+ stratify_labels = None # Fallback to no stratification
138
+
139
+ # Extract groups for patient-level splitting (prevents data leakage)
140
+ groups = None
141
+ if hasattr(self.dataset, "record_names") and self.dataset.record_names:
142
+ groups = np.array(self.dataset.record_names)
143
+ if self.verbose:
144
+ n_groups = len(np.unique(groups))
145
+ print(f"Using patient-level splitting ({n_groups} groups)")
146
+
147
+ splitter = DataSplitter(
148
+ dataset=self.dataset,
149
+ val_split=self.val_split,
150
+ test_split=self.test_split,
151
+ seed=self.seed,
152
+ stratify=stratify_labels,
153
+ groups=groups,
154
+ )
155
+ self.train_dataset, self.val_dataset, self.test_dataset = splitter.split()
156
+
157
+ if self.verbose:
158
+ print(f"Dataset size: {len(self.train_dataset)}")
159
+ print(f"Validation set size: {len(self.val_dataset)}")
160
+ print(f"Test set size: {len(self.test_dataset)}")
161
+
162
+ def train_dataloader(self) -> DataLoader:
163
+ """Get the training data loader."""
164
+ if self.train_dataset is None:
165
+ raise RuntimeError("Call setup() before using the data module")
166
+ return DataLoader(
167
+ self.train_dataset,
168
+ batch_size=self.batch_size,
169
+ shuffle=True,
170
+ num_workers=self.num_workers,
171
+ pin_memory=self.pin_memory,
172
+ persistent_workers=self.persistent_workers and self.num_workers > 0,
173
+ prefetch_factor=self.prefetch_factor if self.num_workers > 0 else None,
174
+ )
175
+
176
+ def val_dataloader(self) -> DataLoader:
177
+ """Get the validation data loader."""
178
+ if self.val_dataset is None:
179
+ raise RuntimeError("Call setup() before using the data module")
180
+ return DataLoader(
181
+ self.val_dataset,
182
+ batch_size=self.batch_size,
183
+ shuffle=False,
184
+ num_workers=self.num_workers,
185
+ pin_memory=self.pin_memory,
186
+ persistent_workers=self.persistent_workers and self.num_workers > 0,
187
+ prefetch_factor=self.prefetch_factor if self.num_workers > 0 else None,
188
+ )
189
+
190
+ def test_dataloader(self) -> DataLoader:
191
+ """Get the test data loader."""
192
+ if self.test_dataset is None:
193
+ raise RuntimeError("Call setup() before using the data module")
194
+ return DataLoader(
195
+ self.test_dataset,
196
+ batch_size=self.batch_size,
197
+ shuffle=False,
198
+ num_workers=self.num_workers,
199
+ pin_memory=self.pin_memory,
200
+ persistent_workers=self.persistent_workers and self.num_workers > 0,
201
+ prefetch_factor=self.prefetch_factor if self.num_workers > 0 else None,
202
+ )
203
+
204
+ def get_metadata(self) -> Dict:
205
+ """Get metadata about the dataset.
206
+
207
+ Returns:
208
+ Dictionary containing dataset metadata
209
+ """
210
+ if self.dataset is None:
211
+ raise RuntimeError("Call setup() before getting metadata")
212
+ if isinstance(self.dataset, BaseECGDataset):
213
+ return self.dataset.get_metadata()
214
+ return {
215
+ "dataset_size": len(self.dataset),
216
+ "batch_size": self.batch_size,
217
+ "num_workers": self.num_workers,
218
+ }
219
+
220
+ def print_metadata(self):
221
+ """Print metadata about the dataset."""
222
+ print(self.get_metadata())
@@ -0,0 +1,208 @@
1
+ from typing import ClassVar, Dict, Optional, Tuple, Union
2
+
3
+ import numpy as np
4
+ import torch
5
+ from scipy import signal
6
+
7
+
8
+ class ECGStandardizer:
9
+ def __init__(
10
+ self,
11
+ target_sampling_rate: int = 300,
12
+ target_length: Optional[int] = None,
13
+ target_duration_seconds: Optional[float] = None,
14
+ normalization: str = "zscore",
15
+ clip_method: str = "center",
16
+ ):
17
+ self.target_sampling_rate = target_sampling_rate
18
+ self.normalization = normalization
19
+ self.clip_method = clip_method
20
+
21
+ if target_length is not None and target_duration_seconds is not None:
22
+ raise ValueError("Cannot specify both target_length and target_duration_seconds")
23
+
24
+ if target_duration_seconds is not None:
25
+ self.target_length = int(target_duration_seconds * target_sampling_rate)
26
+ else:
27
+ self.target_length = target_length
28
+
29
+ def resample(self, ecg_signal: np.ndarray, original_sampling_rate: int) -> np.ndarray:
30
+ if original_sampling_rate == self.target_sampling_rate:
31
+ return ecg_signal
32
+
33
+ num_samples = ecg_signal.shape[-1]
34
+ target_samples = int(num_samples * self.target_sampling_rate / original_sampling_rate)
35
+
36
+ if ecg_signal.ndim == 1:
37
+ return signal.resample(ecg_signal, target_samples)
38
+ else:
39
+ resampled = np.zeros((ecg_signal.shape[0], target_samples))
40
+ for i in range(ecg_signal.shape[0]):
41
+ resampled[i] = signal.resample(ecg_signal[i], target_samples)
42
+ return resampled
43
+
44
+ def normalize(self, ecg_signal: np.ndarray) -> np.ndarray:
45
+ if self.normalization == "zscore":
46
+ mean = np.mean(ecg_signal, axis=-1, keepdims=True)
47
+ std = np.std(ecg_signal, axis=-1, keepdims=True)
48
+ return (ecg_signal - mean) / (std + 1e-8)
49
+
50
+ elif self.normalization == "minmax":
51
+ min_val = np.min(ecg_signal, axis=-1, keepdims=True)
52
+ max_val = np.max(ecg_signal, axis=-1, keepdims=True)
53
+ range_val = max_val - min_val
54
+ return np.where(
55
+ range_val > 1e-8, (ecg_signal - min_val) / range_val, ecg_signal - min_val
56
+ )
57
+
58
+ elif self.normalization == "none":
59
+ return ecg_signal
60
+
61
+ else:
62
+ raise ValueError(f"Unknown normalization method: {self.normalization}")
63
+
64
+ def clip_or_pad(self, ecg_signal: np.ndarray) -> np.ndarray:
65
+ if self.target_length is None or ecg_signal.shape[-1] == self.target_length:
66
+ return ecg_signal
67
+
68
+ current_length = ecg_signal.shape[-1]
69
+
70
+ if ecg_signal.ndim == 1:
71
+ ecg_signal = ecg_signal[np.newaxis, :]
72
+
73
+ if self.clip_method not in ("center", "start", "end"):
74
+ raise ValueError(f"Unknown clip_method: {self.clip_method}")
75
+
76
+ if current_length < self.target_length:
77
+ return self._pad_signal(ecg_signal, current_length)
78
+ return self._clip_signal(ecg_signal, current_length)
79
+
80
+ def _pad_signal(self, ecg_signal: np.ndarray, current_length: int) -> np.ndarray:
81
+ pad_width = self.target_length - current_length
82
+ if self.clip_method == "center":
83
+ pad_left = pad_width // 2
84
+ pad_right = pad_width - pad_left
85
+ return np.pad(ecg_signal, ((0, 0), (pad_left, pad_right)), mode="constant")
86
+ if self.clip_method == "start":
87
+ return np.pad(ecg_signal, ((0, 0), (0, pad_width)), mode="constant")
88
+ return np.pad(ecg_signal, ((0, 0), (pad_width, 0)), mode="constant")
89
+
90
+ def _clip_signal(self, ecg_signal: np.ndarray, current_length: int) -> np.ndarray:
91
+ excess = current_length - self.target_length
92
+ if self.clip_method == "center":
93
+ start = excess // 2
94
+ return ecg_signal[:, start : start + self.target_length]
95
+ if self.clip_method == "start":
96
+ return ecg_signal[:, : self.target_length]
97
+ return ecg_signal[:, -self.target_length :]
98
+
99
+ def __call__(self, ecg_signal: np.ndarray, original_sampling_rate: int) -> np.ndarray:
100
+ ecg_signal = self.resample(ecg_signal, original_sampling_rate)
101
+ ecg_signal = self.clip_or_pad(ecg_signal)
102
+ ecg_signal = self.normalize(ecg_signal)
103
+ return ecg_signal
104
+
105
+
106
+ class ECGSegmenter:
107
+ def __init__(
108
+ self,
109
+ segment_duration_seconds: float,
110
+ sampling_rate: int,
111
+ overlap: float = 0.0,
112
+ ):
113
+ self.segment_duration_seconds = segment_duration_seconds
114
+ self.sampling_rate = sampling_rate
115
+ self.overlap = overlap
116
+ self.segment_length = int(segment_duration_seconds * sampling_rate)
117
+ self.stride = int(self.segment_length * (1 - overlap))
118
+
119
+ def segment(self, ecg_signal: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
120
+ if ecg_signal.ndim == 1:
121
+ ecg_signal = ecg_signal[np.newaxis, :]
122
+
123
+ _, signal_length = ecg_signal.shape
124
+
125
+ if signal_length < self.segment_length:
126
+ return np.array([]), np.array([])
127
+
128
+ segments = []
129
+ start_indices = []
130
+
131
+ for start in range(0, signal_length - self.segment_length + 1, self.stride):
132
+ segment = ecg_signal[:, start : start + self.segment_length]
133
+ segments.append(segment)
134
+ start_indices.append(start)
135
+
136
+ return np.array(segments), np.array(start_indices)
137
+
138
+
139
+ class RhythmAnnotationExtractor:
140
+ RHYTHM_MAP: ClassVar[Dict[str, int]] = {
141
+ "(AFIB": 1,
142
+ "(AFL": 2,
143
+ "(J": 3,
144
+ "(N": 0,
145
+ }
146
+
147
+ def __init__(
148
+ self,
149
+ sampling_rate: int,
150
+ binary_classification: bool = False,
151
+ ):
152
+ self.sampling_rate = sampling_rate
153
+ self.binary_classification = binary_classification
154
+
155
+ def extract_labels(
156
+ self,
157
+ annotation,
158
+ signal_length: int,
159
+ original_sampling_rate: Optional[int] = None,
160
+ ) -> np.ndarray:
161
+ labels = np.zeros(signal_length, dtype=np.int64)
162
+
163
+ if not hasattr(annotation, "aux_note") or not hasattr(annotation, "sample"):
164
+ return labels
165
+
166
+ scale = 1.0
167
+ if original_sampling_rate is not None and original_sampling_rate != self.sampling_rate:
168
+ scale = self.sampling_rate / original_sampling_rate
169
+
170
+ for i, (sample_idx, aux_note) in enumerate(zip(annotation.sample, annotation.aux_note)):
171
+ scaled_idx = int(sample_idx * scale)
172
+ if scaled_idx >= signal_length:
173
+ break
174
+
175
+ rhythm_code = self.RHYTHM_MAP.get(aux_note.strip(), 0)
176
+
177
+ if self.binary_classification:
178
+ rhythm_code = 1 if rhythm_code == 1 else 0
179
+
180
+ raw_next = annotation.sample[i + 1] if i + 1 < len(annotation.sample) else signal_length
181
+ next_sample = min(int(raw_next * scale), signal_length)
182
+
183
+ labels[scaled_idx:next_sample] = rhythm_code
184
+
185
+ return labels
186
+
187
+ def segment_with_labels(
188
+ self, labels: np.ndarray, segment_start_indices: np.ndarray, segment_length: int
189
+ ) -> np.ndarray:
190
+ segment_labels = []
191
+
192
+ for start_idx in segment_start_indices:
193
+ segment_label_region = labels[start_idx : start_idx + segment_length]
194
+
195
+ unique, counts = np.unique(segment_label_region, return_counts=True)
196
+ majority_label = unique[np.argmax(counts)]
197
+
198
+ segment_labels.append(majority_label)
199
+
200
+ return np.array(segment_labels)
201
+
202
+
203
+ def convert_to_tensor(
204
+ data: Union[np.ndarray, torch.Tensor], dtype: torch.dtype = torch.float32
205
+ ) -> torch.Tensor:
206
+ if isinstance(data, torch.Tensor):
207
+ return data.to(dtype)
208
+ return torch.from_numpy(data).to(dtype)