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,301 @@
1
+ import os
2
+ import shutil
3
+ import zipfile
4
+ from pathlib import Path
5
+ from typing import ClassVar, Dict, List, Optional, Tuple, Union
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import scipy.io
10
+ import torch
11
+ from tqdm import tqdm
12
+
13
+ from deepecgkit.registry import register_dataset
14
+
15
+ from ..utils.download import download_file
16
+ from .base import BaseECGDataset
17
+ from .preprocessing import ECGSegmenter, ECGStandardizer, convert_to_tensor
18
+
19
+
20
+ @register_dataset(
21
+ name="af-classification",
22
+ input_channels=1,
23
+ description="PhysioNet 2017 AF Classification (4 classes, single-lead)",
24
+ )
25
+ class AFClassificationDataset(BaseECGDataset):
26
+ """PhysioNet/Computing in Cardiology Challenge 2017 AF Classification Dataset.
27
+
28
+ This dataset contains over 10,000 single-lead ECG recordings of 30-60 seconds duration
29
+ for atrial fibrillation (AF) classification. Each recording is labeled as one of four
30
+ categories: Normal (N), Atrial Fibrillation (A), Other rhythm (O), or Noisy (~).
31
+
32
+ The recordings are from AliveCor device and represent patient-initiated recordings.
33
+
34
+ Reference:
35
+ Clifford GD, Liu C, Moody B, Li-wei HL, Silva I, Li Q, Johnson AE, Mark RG.
36
+ AF classification from a short single lead ECG recording: The PhysioNet/computing
37
+ in cardiology challenge 2017. In 2017 Computing in Cardiology (CinC) 2017 Sep 24 (pp. 1-4). IEEE.
38
+
39
+ URL:
40
+ https://physionet.org/content/challenge-2017/1.0.0/
41
+ """
42
+
43
+ CLASS_LABELS: ClassVar[List[str]] = [
44
+ "Normal",
45
+ "AF",
46
+ "Other",
47
+ "Noisy",
48
+ ]
49
+
50
+ REFERENCE_FILE: ClassVar[str] = "REFERENCE-v3.csv"
51
+
52
+ LABEL_MAPPING: ClassVar[Dict[str, int]] = {
53
+ "N": 0,
54
+ "A": 1,
55
+ "O": 2,
56
+ "~": 3,
57
+ }
58
+
59
+ LEADS: ClassVar[List[str]] = ["ECG"]
60
+ DOWNLOAD_URL: ClassVar[str] = "https://physionet.org/content/challenge-2017/get-zip/1.0.0/"
61
+ NATIVE_SAMPLING_RATE: ClassVar[int] = 300
62
+
63
+ def __init__(
64
+ self,
65
+ data_dir: Optional[Union[str, Path]] = None,
66
+ sampling_rate: int = 300,
67
+ segment_duration_seconds: float = 10.0,
68
+ segment_overlap: float = 0.0,
69
+ normalization: str = "zscore",
70
+ subset: str = "training",
71
+ transform: Optional[callable] = None,
72
+ target_transform: Optional[callable] = None,
73
+ download: bool = False,
74
+ force_download: bool = False,
75
+ version: str = "1.0.0",
76
+ verbose: bool = True,
77
+ ):
78
+ if subset not in ["training", "validation"]:
79
+ raise ValueError("subset must be either 'training' or 'validation'")
80
+
81
+ self.version = version
82
+ self.subset = subset
83
+ self.verbose = verbose
84
+ self.segment_duration_seconds = segment_duration_seconds
85
+ self.segment_overlap = segment_overlap
86
+
87
+ self.standardizer = ECGStandardizer(
88
+ target_sampling_rate=sampling_rate,
89
+ normalization=normalization,
90
+ )
91
+
92
+ self.segmenter = ECGSegmenter(
93
+ segment_duration_seconds=segment_duration_seconds,
94
+ sampling_rate=sampling_rate,
95
+ overlap=segment_overlap,
96
+ )
97
+
98
+ self.signals: List[np.ndarray] = []
99
+ self.labels: List[int] = []
100
+ self.record_names: List[str] = []
101
+ self.reference_data: Optional[pd.DataFrame] = None
102
+
103
+ super().__init__(
104
+ data_dir=data_dir,
105
+ sampling_rate=sampling_rate,
106
+ leads=self.LEADS,
107
+ transform=transform,
108
+ target_transform=target_transform,
109
+ download=download,
110
+ force_download=force_download,
111
+ )
112
+
113
+ def download(self):
114
+ """Download the AF Classification dataset from PhysioNet as a single ZIP."""
115
+ if self.verbose:
116
+ print(f"Downloading PhysioNet Challenge 2017 dataset to {self.data_dir}")
117
+ print("Note: This is a large dataset (~1.4GB). Download may take a while.")
118
+
119
+ os.makedirs(self.data_dir, exist_ok=True)
120
+
121
+ zip_path = self.data_dir / "challenge-2017-1.0.0.zip"
122
+ download_file(
123
+ self.DOWNLOAD_URL,
124
+ zip_path,
125
+ desc="Downloading PhysioNet Challenge 2017",
126
+ max_retries=5,
127
+ )
128
+
129
+ if self.verbose:
130
+ print("Extracting dataset...")
131
+
132
+ with zipfile.ZipFile(zip_path, "r") as zf:
133
+ zf.extractall(self.data_dir)
134
+
135
+ self._flatten_nested_dir()
136
+ self._extract_inner_zips()
137
+
138
+ zip_path.unlink(missing_ok=True)
139
+
140
+ if self.verbose:
141
+ print("Download complete!")
142
+
143
+ def _flatten_nested_dir(self):
144
+ """Flatten a nested extraction directory if present from a previous download."""
145
+ for child in self.data_dir.iterdir():
146
+ if not child.is_dir() or child.name in ("__MACOSX", "training2017", "sample2017"):
147
+ continue
148
+ if self.verbose:
149
+ print(f"Flattening nested directory {child.name}...")
150
+ for item in child.iterdir():
151
+ dest = self.data_dir / item.name
152
+ if dest.exists():
153
+ if dest.is_dir():
154
+ shutil.rmtree(dest)
155
+ else:
156
+ dest.unlink()
157
+ shutil.move(str(item), str(dest))
158
+ child.rmdir()
159
+ return
160
+
161
+ def _extract_inner_zips(self):
162
+ """Extract inner zip files like training2017.zip."""
163
+ for zf_path in self.data_dir.glob("*.zip"):
164
+ folder_name = zf_path.stem
165
+ target_dir = self.data_dir / folder_name
166
+ if target_dir.exists():
167
+ continue
168
+ if self.verbose:
169
+ print(f"Extracting {zf_path.name}...")
170
+ with zipfile.ZipFile(zf_path, "r") as zf:
171
+ zf.extractall(self.data_dir)
172
+ zf_path.unlink()
173
+
174
+ def _load_data(self):
175
+ """Load the AF Classification dataset into memory."""
176
+ if self.verbose:
177
+ print(f"Loading AF Classification dataset ({self.subset} subset)...")
178
+
179
+ self._flatten_nested_dir()
180
+ self._extract_inner_zips()
181
+
182
+ reference_path = self.data_dir / self.REFERENCE_FILE
183
+ if not reference_path.exists():
184
+ for ref_file in ["REFERENCE.csv", "REFERENCE-v2.csv", "REFERENCE-v1.csv"]:
185
+ alt_path = self.data_dir / ref_file
186
+ if alt_path.exists():
187
+ reference_path = alt_path
188
+ break
189
+
190
+ if not reference_path.exists():
191
+ raise FileNotFoundError(f"Reference file not found in {self.data_dir}")
192
+
193
+ self.reference_data = pd.read_csv(
194
+ reference_path, header=None, names=["record_name", "label"]
195
+ )
196
+
197
+ if self.subset == "training":
198
+ data_folder = self.data_dir / "training2017"
199
+ else:
200
+ data_folder = self.data_dir / "sample2017"
201
+
202
+ if not data_folder.exists():
203
+ raise FileNotFoundError(
204
+ f"Data folder {data_folder} not found. Please download the dataset first."
205
+ )
206
+
207
+ iterator = (
208
+ tqdm(self.reference_data.iterrows(), desc="Loading records")
209
+ if self.verbose
210
+ else self.reference_data.iterrows()
211
+ )
212
+
213
+ for _, row in iterator:
214
+ record_name = row["record_name"]
215
+ label = row["label"]
216
+
217
+ mat_file = data_folder / f"{record_name}.mat"
218
+
219
+ if mat_file.exists():
220
+ try:
221
+ mat_data = scipy.io.loadmat(str(mat_file))
222
+
223
+ if "val" in mat_data:
224
+ signal = mat_data["val"].flatten()
225
+ elif "ecg" in mat_data:
226
+ signal = mat_data["ecg"].flatten()
227
+ elif "data" in mat_data:
228
+ signal = mat_data["data"].flatten()
229
+ else:
230
+ signal_key = [k for k in mat_data.keys() if not k.startswith("__")][0]
231
+ signal = mat_data[signal_key].flatten()
232
+
233
+ if label not in self.LABEL_MAPPING:
234
+ if self.verbose:
235
+ print(f"Warning: Unknown label '{label}' for record {record_name}")
236
+ continue
237
+
238
+ label_idx = self.LABEL_MAPPING[label]
239
+
240
+ signal = signal[np.newaxis, :]
241
+ resampled = self.standardizer.resample(signal, self.NATIVE_SAMPLING_RATE)
242
+
243
+ segments, _ = self.segmenter.segment(resampled)
244
+ if len(segments) == 0:
245
+ continue
246
+
247
+ for segment in segments:
248
+ normalized = self.standardizer.normalize(segment)
249
+ self.signals.append(normalized)
250
+ self.labels.append(label_idx)
251
+ self.record_names.append(record_name)
252
+
253
+ except Exception as e:
254
+ if self.verbose:
255
+ print(f"Error loading record {record_name}: {e}")
256
+ continue
257
+
258
+ if self.verbose:
259
+ print(f"Successfully loaded {len(self.signals)} segments from {self.subset} subset")
260
+
261
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
262
+ signal = self.signals[idx]
263
+ label = self.labels[idx]
264
+
265
+ signal = convert_to_tensor(signal, dtype=torch.float32)
266
+ label = torch.tensor(label, dtype=torch.long)
267
+
268
+ if self.transform is not None:
269
+ signal = self.transform(signal)
270
+ if self.target_transform is not None:
271
+ label = self.target_transform(label)
272
+
273
+ return signal, label
274
+
275
+ def __len__(self) -> int:
276
+ return len(self.labels)
277
+
278
+ @property
279
+ def num_classes(self) -> int:
280
+ """Get the number of classes in the dataset."""
281
+ return len(self.CLASS_LABELS)
282
+
283
+ @property
284
+ def class_names(self) -> List[str]:
285
+ """Get the names of the classes in the dataset."""
286
+ return self.CLASS_LABELS
287
+
288
+ def get_record_info(self, idx: int) -> Dict:
289
+ record_name = self.record_names[idx]
290
+ label_idx = self.labels[idx]
291
+
292
+ return {
293
+ "record_name": record_name,
294
+ "label": label_idx,
295
+ "class_name": self.CLASS_LABELS[label_idx],
296
+ "signal_shape": self.signals[idx].shape,
297
+ }
298
+
299
+ def get_class_distribution(self) -> Dict[str, int]:
300
+ unique, counts = np.unique(self.labels, return_counts=True)
301
+ return {self.class_names[int(label)]: int(count) for label, count in zip(unique, counts)}
@@ -0,0 +1,146 @@
1
+ import shutil
2
+ from abc import ABC, abstractmethod
3
+ from pathlib import Path
4
+ from typing import Dict, List, Optional, Tuple, Union
5
+
6
+ import torch
7
+ from torch.utils.data import Dataset
8
+
9
+
10
+ class BaseECGDataset(Dataset, ABC):
11
+ """Base class for all ECG datasets in deepecg-kit.
12
+
13
+ This class defines the common interface and functionality for all ECG datasets.
14
+ Each specific dataset implementation should inherit from this class and implement
15
+ the required methods.
16
+ """
17
+
18
+ @classmethod
19
+ def get_default_data_dir(cls) -> Path:
20
+ """Get the default data directory for this dataset.
21
+
22
+ Returns:
23
+ Path to the default data directory
24
+ """
25
+ return Path.home() / ".deepecgkit" / "datasets" / cls.__name__.lower()
26
+
27
+ def __init__(
28
+ self,
29
+ data_dir: Optional[Union[str, Path]] = None,
30
+ sampling_rate: int = 500,
31
+ leads: Optional[List[str]] = None,
32
+ transform: Optional[callable] = None,
33
+ target_transform: Optional[callable] = None,
34
+ download: bool = False,
35
+ force_download: bool = False,
36
+ ):
37
+ """Initialize the base ECG dataset.
38
+
39
+ Args:
40
+ data_dir: Directory where the dataset is stored or will be downloaded.
41
+ If None, uses the default data directory.
42
+ sampling_rate: Sampling rate of the ECG signals (Hz)
43
+ leads: List of leads to use (e.g., ['I', 'II', 'III'] for standard leads)
44
+ transform: Optional transform to be applied to the ECG signals
45
+ target_transform: Optional transform to be applied to the labels
46
+ download: Whether to download the dataset if it doesn't exist locally.
47
+ force_download: Whether to force re-download even if the dataset exists
48
+ """
49
+ self.data_dir = Path(data_dir) if data_dir is not None else self.get_default_data_dir()
50
+ self.sampling_rate = sampling_rate
51
+ self.leads = leads
52
+ self.transform = transform
53
+ self.target_transform = target_transform
54
+ self.home_dir = Path.home()
55
+
56
+ if force_download:
57
+ self._clear_dataset()
58
+ self.download()
59
+ elif not self.data_dir.exists():
60
+ if download:
61
+ self.download()
62
+
63
+ self._load_data()
64
+
65
+ def _clear_dataset(self):
66
+ """Clear the dataset directory for re-download."""
67
+ if self.data_dir.exists():
68
+ print(f"Clearing existing dataset at {self.data_dir}...")
69
+ shutil.rmtree(self.data_dir)
70
+ self.data_dir.mkdir(parents=True, exist_ok=True)
71
+
72
+ @abstractmethod
73
+ def download(self):
74
+ """Download the dataset if it doesn't exist."""
75
+ pass
76
+
77
+ @abstractmethod
78
+ def _load_data(self):
79
+ """Load the dataset into memory."""
80
+ pass
81
+
82
+ @abstractmethod
83
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
84
+ """Get a sample from the dataset.
85
+
86
+ Args:
87
+ idx: Index of the sample to get
88
+
89
+ Returns:
90
+ Tuple of (ecg_signal, label) where:
91
+ - ecg_signal: Tensor of shape (num_leads, signal_length)
92
+ - label: Tensor of shape (num_classes,) for classification or (num_beats,) for segmentation
93
+ """
94
+ pass
95
+
96
+ @abstractmethod
97
+ def __len__(self) -> int:
98
+ """Get the number of samples in the dataset."""
99
+ pass
100
+
101
+ @property
102
+ @abstractmethod
103
+ def num_classes(self) -> int:
104
+ """Get the number of classes in the dataset."""
105
+ pass
106
+
107
+ @property
108
+ @abstractmethod
109
+ def class_names(self) -> List[str]:
110
+ """Get the names of the classes in the dataset."""
111
+ pass
112
+
113
+ def get_class_distribution(self) -> Dict[str, int]:
114
+ """Get the distribution of classes in the dataset."""
115
+ return {}
116
+
117
+ def _print_class_distribution(self):
118
+ """Print class distribution statistics."""
119
+ distribution = self.get_class_distribution()
120
+ if not distribution:
121
+ return
122
+ print("\nClass distribution:")
123
+ for class_name, count in distribution.items():
124
+ print(f" {class_name}: {count}")
125
+
126
+ def get_metadata(self) -> Dict:
127
+ """Get metadata about the dataset.
128
+
129
+ Returns:
130
+ Dictionary containing metadata such as:
131
+ - sampling_rate: Sampling rate of the signals
132
+ - num_leads: Number of leads
133
+ - lead_names: Names of the leads
134
+ - num_classes: Number of classes
135
+ - class_names: Names of the classes
136
+ - dataset_size: Number of samples
137
+ - signal_length: Length of each signal
138
+ """
139
+ return {
140
+ "sampling_rate": self.sampling_rate,
141
+ "num_leads": len(self.leads) if self.leads else None,
142
+ "lead_names": self.leads,
143
+ "num_classes": self.num_classes,
144
+ "class_names": self.class_names,
145
+ "dataset_size": len(self),
146
+ }