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,567 @@
1
+ import ast
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 pandas as pd
10
+ import torch
11
+ import wfdb
12
+ from tqdm import tqdm
13
+
14
+ from deepecgkit.registry import register_dataset
15
+
16
+ from ..utils.download import download_file
17
+ from .base import BaseECGDataset
18
+ from .preprocessing import ECGStandardizer, convert_to_tensor
19
+
20
+
21
+ @register_dataset(
22
+ name="ptbxl",
23
+ input_channels=12,
24
+ num_classes=5,
25
+ description="PTB-XL 12-lead ECG dataset (multi-task)",
26
+ )
27
+ class PTBXLDataset(BaseECGDataset):
28
+ """PTB-XL ECG Dataset.
29
+
30
+ PTB-XL is a large publicly available electrocardiography dataset containing
31
+ 21,837 clinical 12-lead ECGs from 18,885 patients of 10 second length.
32
+ Each ECG is annotated with up to 71 different diagnostic statements conforming
33
+ to the SCP-ECG standard.
34
+
35
+ The dataset supports multiple diagnostic classification tasks:
36
+ - Superclass: 5 diagnostic superclasses (NORM, MI, STTC, CD, HYP)
37
+ - Subclass: 23 diagnostic subclasses
38
+ - Diagnostic: All individual diagnostic SCP codes (~44 statements)
39
+ - Form: 19 form statements
40
+ - Rhythm: 12 rhythm statements
41
+ - All: All SCP statement codes (~71 statements)
42
+
43
+ Reference:
44
+ Wagner P, Strodthoff N, Bousseljot RD, Kreiseler D, Lunze FI, Samek W, Schaeffter T.
45
+ PTB-XL, a large publicly available electrocardiography dataset.
46
+ Scientific Data. 2020 May 25;7(1):154.
47
+
48
+ URL:
49
+ https://physionet.org/content/ptb-xl/1.0.3/
50
+ """
51
+
52
+ CLASS_LABELS_SUPERCLASS: ClassVar[List[str]] = ["NORM", "MI", "STTC", "CD", "HYP"]
53
+ CLASS_LABELS_SUBCLASS: ClassVar[List[str]] = [
54
+ "NORM",
55
+ "IMI",
56
+ "ASMI",
57
+ "ILMI",
58
+ "AMI",
59
+ "ALMI",
60
+ "INJAS",
61
+ "LMI",
62
+ "INJAL",
63
+ "ISCAL",
64
+ "ISCAN",
65
+ "INJIN",
66
+ "INJLA",
67
+ "PMI",
68
+ "INJIL",
69
+ "ISCIN",
70
+ "ISCIL",
71
+ "ISCAS",
72
+ "LAFB",
73
+ "IRBBB",
74
+ "LPFB",
75
+ "CRBBB",
76
+ "CLBBB",
77
+ ]
78
+ CLASS_LABELS_FORM: ClassVar[List[str]] = [
79
+ "NDT",
80
+ "NST_",
81
+ "DIG",
82
+ "LNGQT",
83
+ "ABQRS",
84
+ "PVC",
85
+ "STD_",
86
+ "VCLVH",
87
+ "QWAVE",
88
+ "LOWT",
89
+ "NT_",
90
+ "PAC",
91
+ "LPR",
92
+ "INVT",
93
+ "LVOLT",
94
+ "HVOLT",
95
+ "TAB_",
96
+ "STE_",
97
+ "PRC(S)",
98
+ ]
99
+ CLASS_LABELS_RHYTHM: ClassVar[List[str]] = [
100
+ "SR",
101
+ "AFIB",
102
+ "STACH",
103
+ "SARRH",
104
+ "SBRAD",
105
+ "PACE",
106
+ "SVARR",
107
+ "BIGU",
108
+ "AFLT",
109
+ "SVTAC",
110
+ "PSVT",
111
+ "TRIGU",
112
+ ]
113
+
114
+ LABEL_MAPPING_SUPERCLASS: ClassVar[Dict[str, int]] = {
115
+ label: i for i, label in enumerate(CLASS_LABELS_SUPERCLASS)
116
+ }
117
+
118
+ LEADS: ClassVar[List[str]] = [
119
+ "I",
120
+ "II",
121
+ "III",
122
+ "AVR",
123
+ "AVL",
124
+ "AVF",
125
+ "V1",
126
+ "V2",
127
+ "V3",
128
+ "V4",
129
+ "V5",
130
+ "V6",
131
+ ]
132
+ SAMPLING_RATE_HR: ClassVar[int] = 500
133
+ SAMPLING_RATE_LR: ClassVar[int] = 100
134
+
135
+ def __init__(
136
+ self,
137
+ data_dir: Optional[Union[str, Path]] = None,
138
+ sampling_rate: int = 500,
139
+ task: str = "superclass",
140
+ use_high_resolution: bool = True,
141
+ folds: Optional[List[int]] = None,
142
+ leads: Optional[List[str]] = None,
143
+ normalization: str = "zscore",
144
+ multi_label: bool = True,
145
+ transform: Optional[callable] = None,
146
+ target_transform: Optional[callable] = None,
147
+ download: bool = False,
148
+ force_download: bool = False,
149
+ verbose: bool = True,
150
+ ):
151
+ """Initialize the PTB-XL dataset.
152
+
153
+ Args:
154
+ data_dir: Directory where the dataset is stored or will be downloaded.
155
+ If None, uses ~/.deepecgkit/datasets/ptbxldataset
156
+ sampling_rate: Target sampling rate for the ECG signals (Hz)
157
+ task: Classification task - one of "superclass", "subclass",
158
+ "diagnostic", "form", "rhythm", or "all"
159
+ use_high_resolution: Whether to use 500Hz (True) or 100Hz (False) recordings
160
+ folds: List of folds to include (1-10). None means all folds.
161
+ Recommended: folds 1-8 for training, 9 for validation, 10 for testing
162
+ leads: List of lead names to use. None means all 12 leads.
163
+ normalization: Normalization method - "zscore", "minmax", or "none"
164
+ multi_label: If True, returns multi-hot encoded labels. If False, returns
165
+ single label (first/primary diagnosis)
166
+ transform: Optional transform to be applied to the ECG signals
167
+ target_transform: Optional transform to be applied to the labels
168
+ download: Whether to download the dataset if it doesn't exist
169
+ verbose: Whether to print progress information
170
+ """
171
+ valid_tasks = ["superclass", "subclass", "diagnostic", "form", "rhythm", "all"]
172
+ if task not in valid_tasks:
173
+ raise ValueError(f"task must be one of {valid_tasks}, got '{task}'")
174
+
175
+ self.task = task
176
+ self.use_high_resolution = use_high_resolution
177
+ self.folds = folds
178
+ self.multi_label = multi_label
179
+ self.verbose = verbose
180
+ self._leads = leads
181
+
182
+ source_sampling_rate = (
183
+ self.SAMPLING_RATE_HR if use_high_resolution else self.SAMPLING_RATE_LR
184
+ )
185
+
186
+ self.standardizer = ECGStandardizer(
187
+ target_sampling_rate=sampling_rate,
188
+ target_duration_seconds=10.0,
189
+ normalization=normalization,
190
+ clip_method="center",
191
+ )
192
+
193
+ self.signals: List[np.ndarray] = []
194
+ self.labels: List[np.ndarray] = []
195
+ self.record_names: List[str] = []
196
+ self.metadata_df: Optional[pd.DataFrame] = None
197
+ self.scp_statements: Optional[pd.DataFrame] = None
198
+ self.source_sampling_rate = source_sampling_rate
199
+
200
+ super().__init__(
201
+ data_dir=data_dir,
202
+ sampling_rate=sampling_rate,
203
+ leads=leads if leads else self.LEADS,
204
+ transform=transform,
205
+ target_transform=target_transform,
206
+ download=download,
207
+ force_download=force_download,
208
+ )
209
+
210
+ @staticmethod
211
+ def _fix_record_list(records: List[str]) -> List[str]:
212
+ """Fix corrupted RECORDS file from PhysioNet.
213
+
214
+ The PTB-XL RECORDS file on PhysioNet has a missing newline that
215
+ concatenates two record paths (e.g. 'records100/...records500/...').
216
+ This splits them back into separate entries.
217
+ """
218
+ fixed = []
219
+ for rec in records:
220
+ if "records100" in rec and "records500" in rec:
221
+ idx = rec.index("records500")
222
+ fixed.append(rec[:idx])
223
+ fixed.append(rec[idx:])
224
+ else:
225
+ fixed.append(rec)
226
+ return fixed
227
+
228
+ DOWNLOAD_URL: ClassVar[str] = (
229
+ "https://physionet.org/static/published-projects/ptb-xl/"
230
+ "ptb-xl-a-large-publicly-available-electrocardiography-dataset-1.0.3.zip"
231
+ )
232
+
233
+ def download(self):
234
+ """Download the PTB-XL dataset from PhysioNet as a single ZIP."""
235
+ if self.verbose:
236
+ print(f"Downloading PTB-XL dataset to {self.data_dir}")
237
+ print("Note: This is a large dataset (~2.5GB). Download may take a while.")
238
+
239
+ os.makedirs(self.data_dir, exist_ok=True)
240
+
241
+ zip_path = self.data_dir / "ptb-xl-1.0.3.zip"
242
+ download_file(
243
+ self.DOWNLOAD_URL,
244
+ zip_path,
245
+ desc="Downloading PTB-XL",
246
+ max_retries=5,
247
+ )
248
+
249
+ if self.verbose:
250
+ print("Extracting dataset...")
251
+
252
+ with zipfile.ZipFile(zip_path, "r") as zf:
253
+ zf.extractall(self.data_dir)
254
+
255
+ nested_dir = (
256
+ self.data_dir / "ptb-xl-a-large-publicly-available-electrocardiography-dataset-1.0.3"
257
+ )
258
+ if nested_dir.is_dir():
259
+ for item in nested_dir.iterdir():
260
+ dest = self.data_dir / item.name
261
+ if dest.exists():
262
+ if dest.is_dir():
263
+ shutil.rmtree(dest)
264
+ else:
265
+ dest.unlink()
266
+ shutil.move(str(item), str(dest))
267
+ nested_dir.rmdir()
268
+
269
+ zip_path.unlink(missing_ok=True)
270
+
271
+ if self.verbose:
272
+ print("Download complete!")
273
+
274
+ def _load_data(self):
275
+ """Load the PTB-XL dataset into memory."""
276
+ if self.verbose:
277
+ print("Loading PTB-XL dataset...")
278
+
279
+ metadata_path = self.data_dir / "ptbxl_database.csv"
280
+ if not metadata_path.exists():
281
+ raise FileNotFoundError(
282
+ f"Metadata file not found at {metadata_path}. "
283
+ "Use download=True or manually download from: "
284
+ "https://physionet.org/content/ptb-xl/1.0.3/"
285
+ )
286
+
287
+ self.metadata_df = pd.read_csv(metadata_path, index_col="ecg_id")
288
+ self.metadata_df.scp_codes = self.metadata_df.scp_codes.apply(ast.literal_eval)
289
+
290
+ scp_path = self.data_dir / "scp_statements.csv"
291
+ if scp_path.exists():
292
+ self.scp_statements = pd.read_csv(scp_path, index_col=0)
293
+ else:
294
+ self.scp_statements = None
295
+
296
+ if self.folds is not None:
297
+ self.metadata_df = self.metadata_df[self.metadata_df.strat_fold.isin(self.folds)]
298
+ if self.verbose:
299
+ print(f"Using folds: {self.folds} ({len(self.metadata_df)} records)")
300
+
301
+ label_columns = self._get_label_columns()
302
+
303
+ if self._leads:
304
+ lead_indices = [self.LEADS.index(lead) for lead in self._leads if lead in self.LEADS]
305
+ else:
306
+ lead_indices = list(range(len(self.LEADS)))
307
+
308
+ iterator = (
309
+ tqdm(self.metadata_df.iterrows(), total=len(self.metadata_df), desc="Loading records")
310
+ if self.verbose
311
+ else self.metadata_df.iterrows()
312
+ )
313
+
314
+ for ecg_id, row in iterator:
315
+ try:
316
+ record_path = (
317
+ self.data_dir / row.filename_hr
318
+ if self.use_high_resolution
319
+ else self.data_dir / row.filename_lr
320
+ )
321
+ record_path = str(record_path).replace(".hea", "").replace(".dat", "")
322
+
323
+ record = wfdb.rdrecord(record_path)
324
+ signal = record.p_signal.T
325
+
326
+ signal = signal[lead_indices, :]
327
+
328
+ if self.source_sampling_rate != self.sampling_rate:
329
+ signal = self.standardizer.resample(signal, self.source_sampling_rate)
330
+
331
+ signal = self.standardizer.normalize(signal)
332
+
333
+ labels = self._extract_labels(row.scp_codes, label_columns)
334
+
335
+ self.signals.append(signal.astype(np.float32))
336
+ self.labels.append(labels)
337
+ self.record_names.append(str(ecg_id))
338
+
339
+ except Exception as e:
340
+ if self.verbose:
341
+ print(f"Error loading record {ecg_id}: {e}")
342
+ continue
343
+
344
+ if self.verbose:
345
+ print(f"Successfully loaded {len(self.signals)} records")
346
+ self._print_class_distribution()
347
+
348
+ def _get_label_columns(self) -> List[str]:
349
+ """Get the label columns based on the task."""
350
+ if self.task == "superclass":
351
+ return self.CLASS_LABELS_SUPERCLASS
352
+ elif self.task == "subclass":
353
+ return self.CLASS_LABELS_SUBCLASS
354
+ elif self.task == "form":
355
+ return self.CLASS_LABELS_FORM
356
+ elif self.task == "rhythm":
357
+ return self.CLASS_LABELS_RHYTHM
358
+ elif self.task == "diagnostic":
359
+ if self.scp_statements is not None:
360
+ diag = self.scp_statements[self.scp_statements.diagnostic == 1.0]
361
+ return sorted(diag.index.tolist())
362
+ raise ValueError(
363
+ "scp_statements.csv is required for the 'diagnostic' task. "
364
+ "Ensure the dataset is fully downloaded."
365
+ )
366
+ else: # all
367
+ if self.scp_statements is not None:
368
+ return sorted(self.scp_statements.index.tolist())
369
+ # Fallback: union of hardcoded lists
370
+ all_labels = (
371
+ self.CLASS_LABELS_SUPERCLASS
372
+ + self.CLASS_LABELS_SUBCLASS
373
+ + self.CLASS_LABELS_FORM
374
+ + self.CLASS_LABELS_RHYTHM
375
+ )
376
+ return sorted(set(all_labels))
377
+
378
+ def _extract_labels(self, scp_codes: Dict[str, float], label_columns: List[str]) -> np.ndarray:
379
+ """Extract labels from SCP codes."""
380
+ if self.multi_label:
381
+ labels = np.zeros(len(label_columns), dtype=np.float32)
382
+ if self.task == "superclass" and self.scp_statements is not None:
383
+ for code, likelihood in scp_codes.items():
384
+ if likelihood >= 50.0 and code in self.scp_statements.index:
385
+ stmt = self.scp_statements.loc[code]
386
+ if pd.notna(stmt.diagnostic_class):
387
+ superclass = stmt.diagnostic_class
388
+ if superclass in label_columns:
389
+ idx = label_columns.index(superclass)
390
+ labels[idx] = 1.0
391
+ else:
392
+ for code, likelihood in scp_codes.items():
393
+ if code in label_columns:
394
+ idx = label_columns.index(code)
395
+ labels[idx] = 1.0 if likelihood >= 50.0 else 0.0
396
+ return labels
397
+ else:
398
+ if self.task == "superclass" and self.scp_statements is not None:
399
+ superclass_counts = {cls: 0.0 for cls in self.CLASS_LABELS_SUPERCLASS}
400
+ for code, likelihood in scp_codes.items():
401
+ if likelihood >= 50.0 and code in self.scp_statements.index:
402
+ stmt = self.scp_statements.loc[code]
403
+ if pd.notna(stmt.diagnostic_class):
404
+ superclass = stmt.diagnostic_class
405
+ if superclass in superclass_counts:
406
+ superclass_counts[superclass] += likelihood
407
+ if any(v > 0 for v in superclass_counts.values()):
408
+ primary_class = max(superclass_counts, key=superclass_counts.get)
409
+ return np.array(
410
+ self.CLASS_LABELS_SUPERCLASS.index(primary_class), dtype=np.int64
411
+ )
412
+ for code, likelihood in scp_codes.items():
413
+ if code in label_columns and likelihood >= 50.0:
414
+ return np.array(label_columns.index(code), dtype=np.int64)
415
+ return np.array(0, dtype=np.int64)
416
+
417
+ def _print_class_distribution(self):
418
+ """Print class distribution statistics."""
419
+ if len(self.labels) == 0:
420
+ return
421
+
422
+ label_columns = self._get_label_columns()
423
+ print(f"\nClass distribution ({self.task}):")
424
+
425
+ if self.multi_label:
426
+ labels_array = np.stack(self.labels)
427
+ for i, class_name in enumerate(label_columns):
428
+ count = int(labels_array[:, i].sum())
429
+ if count > 0:
430
+ print(f" {class_name}: {count}")
431
+ else:
432
+ unique, counts = np.unique(self.labels, return_counts=True)
433
+ for label_idx, count in zip(unique, counts):
434
+ if label_idx < len(label_columns):
435
+ print(f" {label_columns[label_idx]}: {count}")
436
+
437
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
438
+ """Get a sample from the dataset.
439
+
440
+ Args:
441
+ idx: Index of the sample to get
442
+
443
+ Returns:
444
+ Tuple of (ecg_signal, label) where:
445
+ - ecg_signal: Tensor of shape (num_leads, signal_length)
446
+ - label: Tensor of shape (num_classes,) for multi-label or scalar for single-label
447
+ """
448
+ signal = self.signals[idx]
449
+ label = self.labels[idx]
450
+
451
+ signal = convert_to_tensor(signal, dtype=torch.float32)
452
+
453
+ if self.multi_label:
454
+ label = torch.tensor(label, dtype=torch.float32)
455
+ else:
456
+ label = torch.tensor(label, dtype=torch.long)
457
+
458
+ if self.transform is not None:
459
+ signal = self.transform(signal)
460
+
461
+ if self.target_transform is not None:
462
+ label = self.target_transform(label)
463
+
464
+ return signal, label
465
+
466
+ def __len__(self) -> int:
467
+ return len(self.labels)
468
+
469
+ @property
470
+ def num_classes(self) -> int:
471
+ """Get the number of classes in the dataset."""
472
+ return len(self._get_label_columns())
473
+
474
+ @property
475
+ def class_names(self) -> List[str]:
476
+ """Get the names of the classes in the dataset."""
477
+ return self._get_label_columns()
478
+
479
+ def get_record_info(self, idx: int) -> Dict:
480
+ """Get record information for a specific sample.
481
+
482
+ Args:
483
+ idx: Index of the sample
484
+
485
+ Returns:
486
+ Dictionary containing record information
487
+ """
488
+ ecg_id = int(self.record_names[idx])
489
+ row = self.metadata_df.loc[ecg_id]
490
+
491
+ return {
492
+ "ecg_id": ecg_id,
493
+ "patient_id": row.patient_id,
494
+ "age": row.age,
495
+ "sex": row.sex,
496
+ "signal_shape": self.signals[idx].shape,
497
+ "scp_codes": row.scp_codes,
498
+ "strat_fold": row.strat_fold,
499
+ "labels": self.labels[idx].tolist() if self.multi_label else int(self.labels[idx]),
500
+ }
501
+
502
+ def get_class_distribution(self) -> Dict[str, int]:
503
+ """Get the distribution of classes in the dataset.
504
+
505
+ Returns:
506
+ Dictionary mapping class names to their counts
507
+ """
508
+ label_columns = self._get_label_columns()
509
+ distribution = {name: 0 for name in label_columns}
510
+
511
+ if self.multi_label:
512
+ labels_array = np.stack(self.labels)
513
+ for i, class_name in enumerate(label_columns):
514
+ distribution[class_name] = int(labels_array[:, i].sum())
515
+ else:
516
+ for lbl in self.labels:
517
+ label_idx = int(lbl) if isinstance(lbl, np.ndarray) else lbl
518
+ if label_idx < len(label_columns):
519
+ distribution[label_columns[label_idx]] += 1
520
+
521
+ return distribution
522
+
523
+ def get_folds_split(
524
+ self,
525
+ train_folds: Optional[List[int]] = None,
526
+ val_folds: Optional[List[int]] = None,
527
+ test_folds: Optional[List[int]] = None,
528
+ ) -> Dict[str, "PTBXLDataset"]:
529
+ """Create train/val/test splits based on stratified folds.
530
+
531
+ The PTB-XL dataset comes with 10 pre-defined stratified folds.
532
+ Recommended split: folds 1-8 for training, 9 for validation, 10 for testing.
533
+
534
+ Args:
535
+ train_folds: Folds for training (default: 1-8)
536
+ val_folds: Folds for validation (default: 9)
537
+ test_folds: Folds for testing (default: 10)
538
+
539
+ Returns:
540
+ Dictionary with 'train', 'val', 'test' PTBXLDataset instances
541
+ """
542
+ if train_folds is None:
543
+ train_folds = list(range(1, 9))
544
+ if val_folds is None:
545
+ val_folds = [9]
546
+ if test_folds is None:
547
+ test_folds = [10]
548
+
549
+ common_kwargs = {
550
+ "data_dir": self.data_dir,
551
+ "sampling_rate": self.sampling_rate,
552
+ "task": self.task,
553
+ "use_high_resolution": self.use_high_resolution,
554
+ "leads": self._leads,
555
+ "normalization": self.standardizer.normalization,
556
+ "multi_label": self.multi_label,
557
+ "transform": self.transform,
558
+ "target_transform": self.target_transform,
559
+ "download": False,
560
+ "verbose": self.verbose,
561
+ }
562
+
563
+ return {
564
+ "train": PTBXLDataset(folds=train_folds, **common_kwargs),
565
+ "val": PTBXLDataset(folds=val_folds, **common_kwargs),
566
+ "test": PTBXLDataset(folds=test_folds, **common_kwargs),
567
+ }