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,363 @@
1
+ import hashlib
2
+ import os
3
+ import shutil
4
+ import zipfile
5
+ from pathlib import Path
6
+ from typing import ClassVar, Dict, List, Optional, Tuple, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ import wfdb
11
+
12
+ from deepecgkit.registry import register_dataset
13
+
14
+ from ..utils.download import download_file
15
+ from .base import BaseECGDataset
16
+ from .preprocessing import (
17
+ ECGSegmenter,
18
+ ECGStandardizer,
19
+ RhythmAnnotationExtractor,
20
+ convert_to_tensor,
21
+ )
22
+
23
+
24
+ @register_dataset(
25
+ name="ltafdb",
26
+ input_channels=2,
27
+ num_classes=4,
28
+ description="Long-Term AF Database (2-lead, binary or 4-class)",
29
+ )
30
+ class LTAFDBDataset(BaseECGDataset):
31
+ """Long-Term AF Database (LTAFDB) Dataset.
32
+
33
+ Contains 84 long-term (typically 24-hour) two-lead ECG recordings of subjects
34
+ with paroxysmal or sustained atrial fibrillation. Rhythm annotations indicate
35
+ Normal, AF, Atrial Flutter, and Junctional rhythm segments.
36
+
37
+ Reference:
38
+ Petrutiu S, Sahakian AV, Swiryn S. Abrupt changes in fibrillatory wave
39
+ characteristics at the termination of paroxysmal atrial fibrillation in humans.
40
+ Europace. 2007;9(7):466-470.
41
+
42
+ URL:
43
+ https://physionet.org/content/ltafdb/1.0.0/
44
+ """
45
+
46
+ CLASS_LABELS: ClassVar[List[str]] = ["Normal", "AF", "AFL", "J"]
47
+ LABEL_MAPPING: ClassVar[Dict[str, int]] = {
48
+ "(N": 0,
49
+ "(AFIB": 1,
50
+ "(AFL": 2,
51
+ "(J": 3,
52
+ }
53
+ LEADS: ClassVar[List[str]] = ["ECG1", "ECG2"]
54
+ SAMPLING_RATE: ClassVar[int] = 128
55
+
56
+ DOWNLOAD_URL: ClassVar[str] = "https://physionet.org/content/ltafdb/get-zip/1.0.0/"
57
+
58
+ def __init__(
59
+ self,
60
+ data_dir: Optional[Union[str, Path]] = None,
61
+ sampling_rate: int = 300,
62
+ segment_duration_seconds: float = 10.0,
63
+ segment_overlap: float = 0.0,
64
+ binary_classification: bool = False,
65
+ use_both_leads: bool = False,
66
+ normalization: str = "zscore",
67
+ max_segments_per_record: Optional[int] = None,
68
+ transform: Optional[callable] = None,
69
+ target_transform: Optional[callable] = None,
70
+ download: bool = False,
71
+ force_download: bool = False,
72
+ verbose: bool = True,
73
+ ):
74
+ self.segment_duration_seconds = segment_duration_seconds
75
+ self.segment_overlap = segment_overlap
76
+ self.binary_classification = binary_classification
77
+ self.use_both_leads = use_both_leads
78
+ self.max_segments_per_record = max_segments_per_record
79
+ self.verbose = verbose
80
+
81
+ self.standardizer = ECGStandardizer(
82
+ target_sampling_rate=sampling_rate,
83
+ normalization=normalization,
84
+ )
85
+
86
+ self.segmenter = ECGSegmenter(
87
+ segment_duration_seconds=segment_duration_seconds,
88
+ sampling_rate=sampling_rate,
89
+ overlap=segment_overlap,
90
+ )
91
+
92
+ self.rhythm_extractor = RhythmAnnotationExtractor(
93
+ sampling_rate=sampling_rate, binary_classification=binary_classification
94
+ )
95
+
96
+ self.signals: np.ndarray = np.array([])
97
+ self.labels: np.ndarray = np.array([], dtype=np.int64)
98
+ self.record_names: List[str] = []
99
+
100
+ super().__init__(
101
+ data_dir=data_dir,
102
+ sampling_rate=sampling_rate,
103
+ leads=self.LEADS if use_both_leads else [self.LEADS[0]],
104
+ transform=transform,
105
+ target_transform=target_transform,
106
+ download=download,
107
+ force_download=force_download,
108
+ )
109
+
110
+ def _cache_key(self) -> str:
111
+ params = (
112
+ f"sr{self.sampling_rate}_dur{self.segment_duration_seconds}_"
113
+ f"ovlp{self.segment_overlap}_bin{self.binary_classification}_"
114
+ f"leads{self.use_both_leads}_norm{self.standardizer.normalization}_"
115
+ f"max{self.max_segments_per_record}"
116
+ )
117
+ return hashlib.md5(params.encode()).hexdigest()[:12]
118
+
119
+ def _cache_dir(self) -> Path:
120
+ return self.data_dir / f"cache_{self._cache_key()}"
121
+
122
+ def download(self):
123
+ if self.verbose:
124
+ print(f"Downloading Long-Term AF Database to {self.data_dir}")
125
+ print("Note: This is a large dataset (~1.7GB). Download may take a while.")
126
+
127
+ os.makedirs(self.data_dir, exist_ok=True)
128
+
129
+ zip_path = self.data_dir / "ltafdb-1.0.0.zip"
130
+ download_file(
131
+ self.DOWNLOAD_URL,
132
+ zip_path,
133
+ desc="Downloading LTAFDB",
134
+ max_retries=5,
135
+ )
136
+
137
+ if self.verbose:
138
+ print("Extracting dataset...")
139
+
140
+ with zipfile.ZipFile(zip_path, "r") as zf:
141
+ zf.extractall(self.data_dir)
142
+
143
+ nested_dir = self.data_dir / "ltafdb-1.0.0"
144
+ if nested_dir.is_dir():
145
+ for item in nested_dir.iterdir():
146
+ dest = self.data_dir / item.name
147
+ if dest.exists():
148
+ if dest.is_dir():
149
+ shutil.rmtree(dest)
150
+ else:
151
+ dest.unlink()
152
+ shutil.move(str(item), str(dest))
153
+ nested_dir.rmdir()
154
+
155
+ zip_path.unlink(missing_ok=True)
156
+
157
+ if self.verbose:
158
+ print("Download complete!")
159
+
160
+ def _resolve_record_dir(self) -> Path:
161
+ """Find the directory containing .hea/.atr record files."""
162
+ if any(self.data_dir.glob("*.hea")):
163
+ return self.data_dir
164
+ files_dir = self.data_dir / "files"
165
+ if files_dir.is_dir() and any(files_dir.glob("*.hea")):
166
+ return files_dir
167
+ return self.data_dir
168
+
169
+ def _discover_records(self, record_dir: Path) -> List[str]:
170
+ """Discover available records by finding files with both .hea and .atr extensions."""
171
+ hea_stems = {p.stem for p in record_dir.glob("*.hea")}
172
+ atr_stems = {p.stem for p in record_dir.glob("*.atr")}
173
+ return sorted(hea_stems & atr_stems, key=lambda x: (len(x), x))
174
+
175
+ def _load_data(self):
176
+ if self.verbose:
177
+ print("Loading Long-Term AF Database...")
178
+
179
+ cache_dir = self._cache_dir()
180
+ signals_path = cache_dir / "signals.npy"
181
+ labels_path = cache_dir / "labels.npy"
182
+ record_names_path = cache_dir / "record_names.npy"
183
+
184
+ if signals_path.exists() and labels_path.exists():
185
+ if self.verbose:
186
+ print(f"Loading from cache: {cache_dir.name}")
187
+ self.signals = np.load(signals_path, mmap_mode="r")
188
+ self.labels = np.load(labels_path, mmap_mode="r")
189
+ if record_names_path.exists():
190
+ self.record_names = np.load(record_names_path, allow_pickle=True).tolist()
191
+ else:
192
+ self.record_names = ["unknown"] * len(self.labels)
193
+ if self.verbose:
194
+ print(f"Loaded {len(self.labels)} segments from cache (memory-mapped)")
195
+ self._print_class_distribution()
196
+ return
197
+
198
+ record_dir = self._resolve_record_dir()
199
+ record_names = self._discover_records(record_dir)
200
+ if not record_names:
201
+ raise FileNotFoundError(
202
+ f"No valid records found in {self.data_dir}. "
203
+ "Expected .hea and .atr files from LTAFDB."
204
+ )
205
+
206
+ if self.verbose:
207
+ print(f"Found {len(record_names)} records — processing (first run only)...")
208
+
209
+ cache_dir.mkdir(parents=True, exist_ok=True)
210
+ tmp_signals = cache_dir / "signals_tmp.npy"
211
+
212
+ num_leads = 2 if self.use_both_leads else 1
213
+ seg_len = self.segmenter.segment_length
214
+
215
+ writer = None
216
+ write_offset = 0
217
+ label_chunks: List[np.ndarray] = []
218
+ all_record_names: List[str] = []
219
+ total_segments = 0
220
+
221
+ for record_name in record_names:
222
+ record_path = record_dir / record_name
223
+
224
+ try:
225
+ record = wfdb.rdrecord(str(record_path))
226
+ annotation = wfdb.rdann(str(record_path), "atr")
227
+
228
+ signals = record.p_signal.T
229
+
230
+ if not self.use_both_leads:
231
+ signals = signals[0:1, :]
232
+
233
+ standardized_signal = self.standardizer.resample(signals, self.SAMPLING_RATE)
234
+
235
+ labels = self.rhythm_extractor.extract_labels(
236
+ annotation, standardized_signal.shape[-1], self.SAMPLING_RATE
237
+ )
238
+
239
+ segments, start_indices = self.segmenter.segment(standardized_signal)
240
+
241
+ if len(segments) == 0:
242
+ continue
243
+
244
+ if (
245
+ self.max_segments_per_record is not None
246
+ and len(segments) > self.max_segments_per_record
247
+ ):
248
+ indices = np.random.choice(
249
+ len(segments),
250
+ self.max_segments_per_record,
251
+ replace=False,
252
+ )
253
+ segments = segments[indices]
254
+ start_indices = start_indices[indices]
255
+
256
+ segment_labels = self.rhythm_extractor.segment_with_labels(
257
+ labels, start_indices, self.segmenter.segment_length
258
+ )
259
+
260
+ normalized = np.stack([self.standardizer.normalize(seg) for seg in segments])
261
+
262
+ if writer is None:
263
+ estimated_total = len(record_names) * len(segments)
264
+ writer = np.lib.format.open_memmap(
265
+ str(tmp_signals),
266
+ mode="w+",
267
+ dtype=np.float32,
268
+ shape=(estimated_total, num_leads, seg_len),
269
+ )
270
+
271
+ needed = write_offset + len(normalized)
272
+ if needed > writer.shape[0]:
273
+ new_size = max(needed, writer.shape[0] * 2)
274
+ writer = np.lib.format.open_memmap(
275
+ str(tmp_signals),
276
+ mode="r+",
277
+ dtype=np.float32,
278
+ shape=(new_size, num_leads, seg_len),
279
+ )
280
+
281
+ writer[write_offset : write_offset + len(normalized)] = normalized
282
+ label_chunks.append(np.array(segment_labels, dtype=np.int64))
283
+ all_record_names.extend([record_name] * len(segments))
284
+ write_offset += len(normalized)
285
+ total_segments += len(segments)
286
+
287
+ if self.verbose:
288
+ print(
289
+ f"Loaded {record_name}: {len(segments)} segments (total: {total_segments})"
290
+ )
291
+
292
+ except Exception as e:
293
+ if self.verbose:
294
+ print(f"Error loading {record_name}: {e}")
295
+ continue
296
+
297
+ if write_offset == 0:
298
+ shutil.rmtree(cache_dir)
299
+ self.signals = np.array([])
300
+ self.labels = np.array([], dtype=np.int64)
301
+ self.record_names = []
302
+ return
303
+
304
+ del writer
305
+
306
+ final_signals = np.lib.format.open_memmap(
307
+ str(tmp_signals), mode="r+", shape=(write_offset, num_leads, seg_len)
308
+ )
309
+ np.save(signals_path, final_signals[:write_offset])
310
+ del final_signals
311
+ tmp_signals.unlink(missing_ok=True)
312
+
313
+ all_labels = np.concatenate(label_chunks)
314
+ np.save(labels_path, all_labels)
315
+ np.save(record_names_path, np.array(all_record_names))
316
+
317
+ self.signals = np.load(signals_path, mmap_mode="r")
318
+ self.labels = np.load(labels_path, mmap_mode="r")
319
+ self.record_names = all_record_names
320
+
321
+ if self.verbose:
322
+ print(f"\nCached {len(self.labels)} segments to {cache_dir.name} (memory-mapped)")
323
+ self._print_class_distribution()
324
+
325
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
326
+ signal = self.signals[idx]
327
+ label = self.labels[idx]
328
+
329
+ signal = convert_to_tensor(signal, dtype=torch.float32)
330
+ label = torch.tensor(label, dtype=torch.long)
331
+
332
+ if self.transform is not None:
333
+ signal = self.transform(signal)
334
+
335
+ if self.target_transform is not None:
336
+ label = self.target_transform(label)
337
+
338
+ return signal, label
339
+
340
+ def __len__(self) -> int:
341
+ return len(self.labels)
342
+
343
+ @property
344
+ def num_classes(self) -> int:
345
+ return 2 if self.binary_classification else len(self.CLASS_LABELS)
346
+
347
+ @property
348
+ def class_names(self) -> List[str]:
349
+ if self.binary_classification:
350
+ return ["Non-AF", "AF"]
351
+ return self.CLASS_LABELS
352
+
353
+ def get_record_info(self, idx: int) -> Dict:
354
+ return {
355
+ "record_name": self.record_names[idx],
356
+ "label": int(self.labels[idx]),
357
+ "class_name": self.class_names[int(self.labels[idx])],
358
+ "signal_shape": self.signals[idx].shape,
359
+ }
360
+
361
+ def get_class_distribution(self) -> Dict[str, int]:
362
+ unique, counts = np.unique(self.labels, return_counts=True)
363
+ return {self.class_names[int(label)]: int(count) for label, count in zip(unique, counts)}
@@ -0,0 +1,287 @@
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 torch
9
+ import wfdb
10
+
11
+ from deepecgkit.registry import register_dataset
12
+
13
+ from ..utils.download import download_file
14
+ from .base import BaseECGDataset
15
+ from .preprocessing import (
16
+ ECGSegmenter,
17
+ ECGStandardizer,
18
+ RhythmAnnotationExtractor,
19
+ convert_to_tensor,
20
+ )
21
+
22
+
23
+ @register_dataset(
24
+ name="mitbih-afdb",
25
+ input_channels=2,
26
+ num_classes=4,
27
+ description="MIT-BIH AF Database (2-lead, binary or 4-class)",
28
+ )
29
+ class MITBIHAFDBDataset(BaseECGDataset):
30
+ """MIT-BIH Atrial Fibrillation Database (AFDB) Dataset.
31
+
32
+ Contains 25 long-term (10-hour) two-lead ECG recordings from subjects with
33
+ atrial fibrillation (mostly paroxysmal). Rhythm annotations indicate Normal,
34
+ AF, Atrial Flutter, and Junctional rhythm segments.
35
+
36
+ Reference:
37
+ Moody GB, Mark RG. A new method for detecting atrial fibrillation using R-R
38
+ intervals. Computers in Cardiology. 1983;10:227-230.
39
+
40
+ URL:
41
+ https://physionet.org/content/afdb/1.0.0/
42
+ """
43
+
44
+ CLASS_LABELS: ClassVar[List[str]] = ["Normal", "AF", "AFL", "J"]
45
+ LABEL_MAPPING: ClassVar[Dict[str, int]] = {
46
+ "(N": 0,
47
+ "(AFIB": 1,
48
+ "(AFL": 2,
49
+ "(J": 3,
50
+ }
51
+ LEADS: ClassVar[List[str]] = ["ECG1", "ECG2"]
52
+ SAMPLING_RATE: ClassVar[int] = 250
53
+ DOWNLOAD_URL: ClassVar[str] = "https://physionet.org/content/afdb/get-zip/1.0.0/"
54
+
55
+ RECORD_NAMES: ClassVar[List[str]] = [
56
+ "04015",
57
+ "04043",
58
+ "04048",
59
+ "04126",
60
+ "04746",
61
+ "04908",
62
+ "04936",
63
+ "05091",
64
+ "05121",
65
+ "05261",
66
+ "06426",
67
+ "06453",
68
+ "06995",
69
+ "07162",
70
+ "07859",
71
+ "07879",
72
+ "07910",
73
+ "08215",
74
+ "08219",
75
+ "08378",
76
+ "08405",
77
+ "08434",
78
+ "08455",
79
+ ]
80
+
81
+ def __init__(
82
+ self,
83
+ data_dir: Optional[Union[str, Path]] = None,
84
+ sampling_rate: int = 300,
85
+ segment_duration_seconds: float = 10.0,
86
+ segment_overlap: float = 0.0,
87
+ binary_classification: bool = False,
88
+ use_both_leads: bool = False,
89
+ normalization: str = "zscore",
90
+ transform: Optional[callable] = None,
91
+ target_transform: Optional[callable] = None,
92
+ download: bool = False,
93
+ force_download: bool = False,
94
+ verbose: bool = True,
95
+ ):
96
+ self.segment_duration_seconds = segment_duration_seconds
97
+ self.segment_overlap = segment_overlap
98
+ self.binary_classification = binary_classification
99
+ self.use_both_leads = use_both_leads
100
+ self.verbose = verbose
101
+
102
+ self.standardizer = ECGStandardizer(
103
+ target_sampling_rate=sampling_rate,
104
+ normalization=normalization,
105
+ )
106
+
107
+ self.segmenter = ECGSegmenter(
108
+ segment_duration_seconds=segment_duration_seconds,
109
+ sampling_rate=sampling_rate,
110
+ overlap=segment_overlap,
111
+ )
112
+
113
+ self.rhythm_extractor = RhythmAnnotationExtractor(
114
+ sampling_rate=sampling_rate, binary_classification=binary_classification
115
+ )
116
+
117
+ self.signals: List[np.ndarray] = []
118
+ self.labels: List[int] = []
119
+ self.record_names: List[str] = []
120
+
121
+ super().__init__(
122
+ data_dir=data_dir,
123
+ sampling_rate=sampling_rate,
124
+ leads=self.LEADS if use_both_leads else [self.LEADS[0]],
125
+ transform=transform,
126
+ target_transform=target_transform,
127
+ download=download,
128
+ force_download=force_download,
129
+ )
130
+
131
+ def download(self):
132
+ if self.verbose:
133
+ print(f"Downloading MIT-BIH AFDB to {self.data_dir}")
134
+ print("Note: This is a ~440MB download.")
135
+
136
+ os.makedirs(self.data_dir, exist_ok=True)
137
+
138
+ zip_path = self.data_dir / "afdb-1.0.0.zip"
139
+ download_file(
140
+ self.DOWNLOAD_URL,
141
+ zip_path,
142
+ desc="Downloading MIT-BIH AFDB",
143
+ max_retries=5,
144
+ )
145
+
146
+ if self.verbose:
147
+ print("Extracting dataset...")
148
+
149
+ with zipfile.ZipFile(zip_path, "r") as zf:
150
+ zf.extractall(self.data_dir)
151
+
152
+ nested_dir = self.data_dir / "afdb-1.0.0"
153
+ if nested_dir.is_dir():
154
+ for item in nested_dir.iterdir():
155
+ dest = self.data_dir / item.name
156
+ if dest.exists():
157
+ if dest.is_dir():
158
+ shutil.rmtree(dest)
159
+ else:
160
+ dest.unlink()
161
+ shutil.move(str(item), str(dest))
162
+ nested_dir.rmdir()
163
+
164
+ # PhysioNet zips may nest records inside a files/ subdirectory
165
+ files_dir = self.data_dir / "files"
166
+ if files_dir.is_dir() and any(files_dir.glob("*.hea")):
167
+ for item in files_dir.iterdir():
168
+ dest = self.data_dir / item.name
169
+ if dest.exists():
170
+ if dest.is_dir():
171
+ shutil.rmtree(dest)
172
+ else:
173
+ dest.unlink()
174
+ shutil.move(str(item), str(dest))
175
+ files_dir.rmdir()
176
+
177
+ zip_path.unlink(missing_ok=True)
178
+
179
+ if self.verbose:
180
+ print("Download complete!")
181
+
182
+ def _load_data(self):
183
+ if self.verbose:
184
+ print("Loading MIT-BIH AFDB data...")
185
+
186
+ # PhysioNet zips may nest records inside a files/ subdirectory
187
+ files_dir = self.data_dir / "files"
188
+ if (
189
+ not any(self.data_dir.glob("*.hea"))
190
+ and files_dir.is_dir()
191
+ and any(files_dir.glob("*.hea"))
192
+ ):
193
+ self.data_dir = files_dir
194
+
195
+ if not any(self.data_dir.glob("*.hea")):
196
+ raise FileNotFoundError(
197
+ f"No record files found in {self.data_dir}. Expected .hea files from MIT-BIH AFDB."
198
+ )
199
+
200
+ for record_name in self.RECORD_NAMES:
201
+ record_path = self.data_dir / record_name
202
+
203
+ if not (self.data_dir / f"{record_name}.hea").exists():
204
+ if self.verbose:
205
+ print(f"Skipping {record_name} (not found)")
206
+ continue
207
+
208
+ try:
209
+ record = wfdb.rdrecord(str(record_path))
210
+ annotation = wfdb.rdann(str(record_path), "atr")
211
+
212
+ signals = record.p_signal.T
213
+
214
+ if not self.use_both_leads:
215
+ signals = signals[0:1, :]
216
+
217
+ standardized_signal = self.standardizer.resample(signals, self.SAMPLING_RATE)
218
+
219
+ labels = self.rhythm_extractor.extract_labels(
220
+ annotation, standardized_signal.shape[-1], self.SAMPLING_RATE
221
+ )
222
+
223
+ segments, start_indices = self.segmenter.segment(standardized_signal)
224
+
225
+ if len(segments) == 0:
226
+ continue
227
+
228
+ segment_labels = self.rhythm_extractor.segment_with_labels(
229
+ labels, start_indices, self.segmenter.segment_length
230
+ )
231
+
232
+ for segment, label in zip(segments, segment_labels):
233
+ normalized_segment = self.standardizer.normalize(segment)
234
+ self.signals.append(normalized_segment)
235
+ self.labels.append(label)
236
+ self.record_names.append(record_name)
237
+
238
+ if self.verbose:
239
+ print(f"Loaded {record_name}: {len(segments)} segments")
240
+
241
+ except Exception as e:
242
+ if self.verbose:
243
+ print(f"Error loading {record_name}: {e}")
244
+
245
+ if self.verbose:
246
+ print(f"Total segments loaded: {len(self.signals)}")
247
+ self._print_class_distribution()
248
+
249
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
250
+ signal = self.signals[idx]
251
+ label = self.labels[idx]
252
+
253
+ signal = convert_to_tensor(signal, dtype=torch.float32)
254
+ label = torch.tensor(label, dtype=torch.long)
255
+
256
+ if self.transform is not None:
257
+ signal = self.transform(signal)
258
+
259
+ if self.target_transform is not None:
260
+ label = self.target_transform(label)
261
+
262
+ return signal, label
263
+
264
+ def __len__(self) -> int:
265
+ return len(self.labels)
266
+
267
+ @property
268
+ def num_classes(self) -> int:
269
+ return 2 if self.binary_classification else len(self.CLASS_LABELS)
270
+
271
+ @property
272
+ def class_names(self) -> List[str]:
273
+ if self.binary_classification:
274
+ return ["Non-AF", "AF"]
275
+ return self.CLASS_LABELS
276
+
277
+ def get_record_info(self, idx: int) -> Dict:
278
+ return {
279
+ "record_name": self.record_names[idx],
280
+ "label": self.labels[idx],
281
+ "class_name": self.class_names[self.labels[idx]],
282
+ "signal_shape": self.signals[idx].shape,
283
+ }
284
+
285
+ def get_class_distribution(self) -> Dict[str, int]:
286
+ unique, counts = np.unique(self.labels, return_counts=True)
287
+ return {self.class_names[int(label)]: int(count) for label, count in zip(unique, counts)}