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,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
|
+
}
|