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
deepecgkit/registry.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model and dataset registry for deepecg-kit.
|
|
3
|
+
|
|
4
|
+
Provides decorator-based registration so new models and datasets are
|
|
5
|
+
automatically discoverable by the CLI and other consumers.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from deepecgkit.registry import register_model, register_dataset
|
|
9
|
+
|
|
10
|
+
@register_model("my-model", description="My custom model")
|
|
11
|
+
class MyModel(nn.Module):
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
@register_dataset("my-dataset", input_channels=1, description="My dataset")
|
|
15
|
+
class MyDataset(BaseECGDataset):
|
|
16
|
+
...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Type
|
|
20
|
+
|
|
21
|
+
_MODEL_REGISTRY: Dict[str, Dict[str, Any]] = {}
|
|
22
|
+
_DATASET_REGISTRY: Dict[str, Dict[str, Any]] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register_model(
|
|
26
|
+
name: str,
|
|
27
|
+
description: str = "",
|
|
28
|
+
default_kwargs: Optional[Dict[str, Any]] = None,
|
|
29
|
+
) -> Callable:
|
|
30
|
+
def decorator(cls: Type) -> Type:
|
|
31
|
+
_MODEL_REGISTRY[name] = {
|
|
32
|
+
"class": cls,
|
|
33
|
+
"description": description,
|
|
34
|
+
"default_kwargs": default_kwargs or {},
|
|
35
|
+
}
|
|
36
|
+
cls._registry_name = name
|
|
37
|
+
return cls
|
|
38
|
+
|
|
39
|
+
return decorator
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_dataset(
|
|
43
|
+
name: str,
|
|
44
|
+
input_channels: int,
|
|
45
|
+
num_classes: Optional[int] = None,
|
|
46
|
+
description: str = "",
|
|
47
|
+
) -> Callable:
|
|
48
|
+
def decorator(cls: Type) -> Type:
|
|
49
|
+
_DATASET_REGISTRY[name] = {
|
|
50
|
+
"class": cls,
|
|
51
|
+
"input_channels": input_channels,
|
|
52
|
+
"num_classes": num_classes,
|
|
53
|
+
"description": description,
|
|
54
|
+
}
|
|
55
|
+
cls._registry_name = name
|
|
56
|
+
return cls
|
|
57
|
+
|
|
58
|
+
return decorator
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_model(name: str) -> Type:
|
|
62
|
+
if name not in _MODEL_REGISTRY:
|
|
63
|
+
available = ", ".join(sorted(_MODEL_REGISTRY.keys()))
|
|
64
|
+
raise KeyError(f"Unknown model: {name}. Available: {available}")
|
|
65
|
+
return _MODEL_REGISTRY[name]["class"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_dataset(name: str) -> Type:
|
|
69
|
+
if name not in _DATASET_REGISTRY:
|
|
70
|
+
available = ", ".join(sorted(_DATASET_REGISTRY.keys()))
|
|
71
|
+
raise KeyError(f"Unknown dataset: {name}. Available: {available}")
|
|
72
|
+
return _DATASET_REGISTRY[name]["class"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_model_names() -> List[str]:
|
|
76
|
+
return sorted(_MODEL_REGISTRY.keys())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_dataset_names() -> List[str]:
|
|
80
|
+
return sorted(_DATASET_REGISTRY.keys())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_model_info(name: str) -> Dict[str, Any]:
|
|
84
|
+
if name not in _MODEL_REGISTRY:
|
|
85
|
+
raise KeyError(f"Unknown model: {name}")
|
|
86
|
+
return dict(_MODEL_REGISTRY[name])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_dataset_info(name: str) -> Dict[str, Any]:
|
|
90
|
+
if name not in _DATASET_REGISTRY:
|
|
91
|
+
raise KeyError(f"Unknown dataset: {name}")
|
|
92
|
+
entry = dict(_DATASET_REGISTRY[name])
|
|
93
|
+
if entry["num_classes"] is None:
|
|
94
|
+
cls = entry["class"]
|
|
95
|
+
if hasattr(cls, "CLASS_LABELS"):
|
|
96
|
+
entry["num_classes"] = len(cls.CLASS_LABELS)
|
|
97
|
+
return entry
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import pytorch_lightning as pl
|
|
2
|
+
import torch
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ECGLitModel(pl.LightningModule):
|
|
6
|
+
"""
|
|
7
|
+
PyTorch Lightning module for ECG signal classification and regression.
|
|
8
|
+
|
|
9
|
+
This module wraps ECG models and provides training, validation, and testing
|
|
10
|
+
functionality with configurable optimizers and learning rate schedulers.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
model: The ECG model to train (e.g., KanResWideX, AFModel)
|
|
14
|
+
train_config: Dictionary containing training configuration:
|
|
15
|
+
- learning_rate: Learning rate for optimizer
|
|
16
|
+
- scheduler: Dict with 'factor' and 'patience' for ReduceLROnPlateau
|
|
17
|
+
- binary_classification: Bool, if True uses BCE loss for binary tasks
|
|
18
|
+
- multi_label: Bool, if True uses BCE loss for multi-label tasks
|
|
19
|
+
use_plateau_scheduler: If True, uses ReduceLROnPlateau, else StepLR
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> model = KanResWideX(input_channels=1, output_size=4)
|
|
23
|
+
>>> config = {
|
|
24
|
+
... "learning_rate": 0.001,
|
|
25
|
+
... "scheduler": {"factor": 0.5, "patience": 10},
|
|
26
|
+
... "binary_classification": False
|
|
27
|
+
... }
|
|
28
|
+
>>> lit_model = ECGLitModel(model=model, train_config=config)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, model, train_config, use_plateau_scheduler=True):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.save_hyperparameters(ignore=["model"])
|
|
34
|
+
|
|
35
|
+
self.model = model
|
|
36
|
+
|
|
37
|
+
self.learning_rate = train_config["learning_rate"]
|
|
38
|
+
self.scheduler_factor = train_config["scheduler"]["factor"]
|
|
39
|
+
self.scheduler_patience = train_config["scheduler"]["patience"]
|
|
40
|
+
|
|
41
|
+
self.use_plateau_scheduler = use_plateau_scheduler
|
|
42
|
+
self.multi_label = train_config.get("multi_label", False)
|
|
43
|
+
|
|
44
|
+
if self.multi_label or train_config.get("binary_classification", False):
|
|
45
|
+
pos_weight = train_config.get("pos_weight")
|
|
46
|
+
if pos_weight is not None:
|
|
47
|
+
pos_weight = torch.tensor(pos_weight, dtype=torch.float32)
|
|
48
|
+
self.criterion = torch.nn.BCEWithLogitsLoss(pos_weight=pos_weight)
|
|
49
|
+
elif train_config.get("task_type", "classification") == "classification":
|
|
50
|
+
self.criterion = torch.nn.CrossEntropyLoss()
|
|
51
|
+
else:
|
|
52
|
+
self.criterion = torch.nn.MSELoss()
|
|
53
|
+
|
|
54
|
+
self.test_predictions = []
|
|
55
|
+
self.test_targets = []
|
|
56
|
+
self.test_probabilities = []
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def _is_binary(self):
|
|
60
|
+
return isinstance(self.criterion, torch.nn.BCEWithLogitsLoss) and not self.multi_label
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def _is_classification(self):
|
|
64
|
+
return isinstance(self.criterion, (torch.nn.CrossEntropyLoss, torch.nn.BCEWithLogitsLoss))
|
|
65
|
+
|
|
66
|
+
def _calculate_loss(self, y_hat, y):
|
|
67
|
+
if self.multi_label:
|
|
68
|
+
return self.criterion(y_hat, y.float())
|
|
69
|
+
if self._is_binary:
|
|
70
|
+
return self.criterion(y_hat.squeeze(-1), y.float())
|
|
71
|
+
if isinstance(self.criterion, torch.nn.CrossEntropyLoss):
|
|
72
|
+
return self.criterion(y_hat, y.long())
|
|
73
|
+
return self.criterion(y_hat.float(), y.float())
|
|
74
|
+
|
|
75
|
+
def _compute_acc(self, y_hat, y):
|
|
76
|
+
if self.multi_label:
|
|
77
|
+
preds = (y_hat > 0).float()
|
|
78
|
+
return (preds == y.float()).float().mean()
|
|
79
|
+
if self._is_binary:
|
|
80
|
+
preds = (y_hat.squeeze(-1) > 0).long()
|
|
81
|
+
return (preds == y.long()).float().mean()
|
|
82
|
+
return (torch.argmax(y_hat, dim=1) == y.long()).float().mean()
|
|
83
|
+
|
|
84
|
+
def forward(self, x):
|
|
85
|
+
return self.model(x)
|
|
86
|
+
|
|
87
|
+
def training_step(self, batch, batch_idx):
|
|
88
|
+
x, y = batch
|
|
89
|
+
y_hat = self(x)
|
|
90
|
+
loss = self._calculate_loss(y_hat, y)
|
|
91
|
+
if hasattr(self.model, "l2_regularization_loss"):
|
|
92
|
+
l2_loss = self.model.l2_regularization_loss()
|
|
93
|
+
loss = loss + l2_loss
|
|
94
|
+
self.log("train_loss", loss, prog_bar=True)
|
|
95
|
+
if self._is_classification:
|
|
96
|
+
self.log("train_acc", self._compute_acc(y_hat, y), prog_bar=True)
|
|
97
|
+
return loss
|
|
98
|
+
|
|
99
|
+
def validation_step(self, batch, batch_idx):
|
|
100
|
+
x, y = batch
|
|
101
|
+
y_hat = self(x)
|
|
102
|
+
loss = self._calculate_loss(y_hat, y)
|
|
103
|
+
self.log("val_loss", loss, prog_bar=True)
|
|
104
|
+
if self._is_classification:
|
|
105
|
+
self.log("val_acc", self._compute_acc(y_hat, y), prog_bar=True)
|
|
106
|
+
return loss
|
|
107
|
+
|
|
108
|
+
def on_test_epoch_start(self):
|
|
109
|
+
self.test_predictions = []
|
|
110
|
+
self.test_targets = []
|
|
111
|
+
self.test_probabilities = []
|
|
112
|
+
|
|
113
|
+
def test_step(self, batch, batch_idx):
|
|
114
|
+
x, y = batch
|
|
115
|
+
y_hat = self(x)
|
|
116
|
+
loss = self._calculate_loss(y_hat, y)
|
|
117
|
+
self.log("test_loss", loss, prog_bar=True)
|
|
118
|
+
if self._is_classification:
|
|
119
|
+
if self.multi_label:
|
|
120
|
+
probs = torch.sigmoid(y_hat)
|
|
121
|
+
preds = (probs > 0.5).long()
|
|
122
|
+
acc = (preds == y.long()).float().mean()
|
|
123
|
+
self.test_predictions.append(preds.cpu())
|
|
124
|
+
self.test_targets.append(y.long().cpu())
|
|
125
|
+
self.test_probabilities.append(probs.cpu())
|
|
126
|
+
elif self._is_binary:
|
|
127
|
+
probs_pos = torch.sigmoid(y_hat.squeeze(-1))
|
|
128
|
+
probs = torch.stack([1 - probs_pos, probs_pos], dim=1)
|
|
129
|
+
preds = (probs_pos > 0.5).long()
|
|
130
|
+
acc = (preds == y.long()).float().mean()
|
|
131
|
+
self.test_predictions.append(preds.cpu())
|
|
132
|
+
self.test_targets.append(y.long().cpu())
|
|
133
|
+
self.test_probabilities.append(probs.cpu())
|
|
134
|
+
else:
|
|
135
|
+
probs = torch.softmax(y_hat, dim=1)
|
|
136
|
+
preds = torch.argmax(probs, dim=1)
|
|
137
|
+
acc = (preds == y.long()).float().mean()
|
|
138
|
+
self.test_predictions.append(preds.cpu())
|
|
139
|
+
self.test_targets.append(y.long().cpu())
|
|
140
|
+
self.test_probabilities.append(probs.cpu())
|
|
141
|
+
self.log("test_acc", acc, prog_bar=True)
|
|
142
|
+
return loss
|
|
143
|
+
|
|
144
|
+
def get_test_results(self):
|
|
145
|
+
if not self.test_predictions:
|
|
146
|
+
return None, None, None
|
|
147
|
+
return (
|
|
148
|
+
torch.cat(self.test_predictions).numpy(),
|
|
149
|
+
torch.cat(self.test_targets).numpy(),
|
|
150
|
+
torch.cat(self.test_probabilities).numpy(),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def configure_optimizers(self):
|
|
154
|
+
optimizer = torch.optim.Adam(self.parameters(), lr=self.learning_rate)
|
|
155
|
+
if self.use_plateau_scheduler:
|
|
156
|
+
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
|
157
|
+
optimizer,
|
|
158
|
+
mode="min",
|
|
159
|
+
factor=self.scheduler_factor,
|
|
160
|
+
patience=self.scheduler_patience,
|
|
161
|
+
)
|
|
162
|
+
return {
|
|
163
|
+
"optimizer": optimizer,
|
|
164
|
+
"lr_scheduler": {"scheduler": scheduler, "monitor": "val_loss"},
|
|
165
|
+
}
|
|
166
|
+
else:
|
|
167
|
+
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=1, gamma=0.9)
|
|
168
|
+
return {
|
|
169
|
+
"optimizer": optimizer,
|
|
170
|
+
"lr_scheduler": scheduler,
|
|
171
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import os
|
|
3
|
+
from typing import Dict, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from deepecgkit.utils.weights import (
|
|
8
|
+
list_pretrained_weights,
|
|
9
|
+
load_pretrained_weights,
|
|
10
|
+
register_weights,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"list_pretrained_weights",
|
|
15
|
+
"load_pretrained_weights",
|
|
16
|
+
"read_csv",
|
|
17
|
+
"register_weights",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_csv(
|
|
22
|
+
csv_file: str,
|
|
23
|
+
delimiter: str = ",",
|
|
24
|
+
transpose: bool = False,
|
|
25
|
+
skip_header: bool = True,
|
|
26
|
+
dtype: Optional[type] = None,
|
|
27
|
+
) -> Tuple[np.ndarray, Dict[str, int]]:
|
|
28
|
+
"""
|
|
29
|
+
Read CSV file and return data array and header mapping.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
csv_file: Path to the CSV file
|
|
33
|
+
delimiter: Column delimiter (default: ',')
|
|
34
|
+
transpose: Whether to transpose the data array
|
|
35
|
+
skip_header: Whether to skip the first row as header
|
|
36
|
+
dtype: Data type for the numpy array
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Tuple of (data_array, header_mapping)
|
|
40
|
+
"""
|
|
41
|
+
if not os.path.exists(csv_file):
|
|
42
|
+
raise FileNotFoundError(f"CSV file not found: {csv_file}")
|
|
43
|
+
|
|
44
|
+
data: List[List[str]] = []
|
|
45
|
+
header: Dict[str, int] = {}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with open(csv_file) as f:
|
|
49
|
+
csv_data = csv.reader(f, delimiter=delimiter)
|
|
50
|
+
|
|
51
|
+
if skip_header:
|
|
52
|
+
try:
|
|
53
|
+
temp = next(csv_data)
|
|
54
|
+
header = {k: v for v, k in enumerate(temp)}
|
|
55
|
+
except StopIteration as err:
|
|
56
|
+
raise ValueError("File is empty") from err
|
|
57
|
+
|
|
58
|
+
for row in csv_data:
|
|
59
|
+
data.append(row)
|
|
60
|
+
|
|
61
|
+
if not data:
|
|
62
|
+
raise ValueError("No data found in CSV file")
|
|
63
|
+
|
|
64
|
+
data_array = np.array(data, dtype=dtype)
|
|
65
|
+
|
|
66
|
+
if transpose:
|
|
67
|
+
data_array = np.transpose(data_array)
|
|
68
|
+
|
|
69
|
+
return data_array, header
|
|
70
|
+
|
|
71
|
+
except Exception as err:
|
|
72
|
+
raise ValueError(f"Error reading CSV file: {err!s}") from err
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import urllib.request
|
|
3
|
+
import zipfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DownloadProgressBar(tqdm):
|
|
11
|
+
"""Progress bar for downloads."""
|
|
12
|
+
|
|
13
|
+
def update_to(self, b=1, bsize=1, tsize=None):
|
|
14
|
+
if tsize is not None:
|
|
15
|
+
self.total = tsize
|
|
16
|
+
self.update(b * bsize - self.n)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def download_file(
|
|
20
|
+
url: str,
|
|
21
|
+
file_path: Union[str, Path],
|
|
22
|
+
desc: Optional[str] = None,
|
|
23
|
+
force: bool = False,
|
|
24
|
+
max_retries: int = 3,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Download a file with a progress bar.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
url: URL to download from
|
|
30
|
+
file_path: Path to save the file to
|
|
31
|
+
desc: Description for the progress bar
|
|
32
|
+
force: If True, re-download even if file exists
|
|
33
|
+
max_retries: Maximum number of download attempts
|
|
34
|
+
"""
|
|
35
|
+
file_path = Path(file_path)
|
|
36
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
if file_path.exists() and not force:
|
|
39
|
+
if file_path.suffix.lower() == ".zip":
|
|
40
|
+
try:
|
|
41
|
+
with zipfile.ZipFile(file_path, "r") as zip_ref:
|
|
42
|
+
if zip_ref.testzip() is not None:
|
|
43
|
+
print(f"Corrupted zip file detected at {file_path}, re-downloading...")
|
|
44
|
+
file_path.unlink()
|
|
45
|
+
else:
|
|
46
|
+
print(f"File already exists at {file_path}, skipping download.")
|
|
47
|
+
return
|
|
48
|
+
except zipfile.BadZipFile:
|
|
49
|
+
print(f"Corrupted zip file detected at {file_path}, re-downloading...")
|
|
50
|
+
file_path.unlink()
|
|
51
|
+
else:
|
|
52
|
+
print(f"File already exists at {file_path}, skipping download.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
last_exception = None
|
|
56
|
+
for attempt in range(max_retries):
|
|
57
|
+
try:
|
|
58
|
+
with DownloadProgressBar(unit="B", unit_scale=True, miniters=1, desc=desc) as t:
|
|
59
|
+
urllib.request.urlretrieve(url, filename=file_path, reporthook=t.update_to)
|
|
60
|
+
|
|
61
|
+
if file_path.suffix.lower() == ".zip":
|
|
62
|
+
with zipfile.ZipFile(file_path, "r") as zip_ref:
|
|
63
|
+
if zip_ref.testzip() is not None:
|
|
64
|
+
raise zipfile.BadZipFile("Downloaded zip file is corrupted")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
last_exception = e
|
|
69
|
+
if file_path.exists():
|
|
70
|
+
file_path.unlink()
|
|
71
|
+
|
|
72
|
+
if attempt < max_retries - 1:
|
|
73
|
+
wait_time = 2**attempt
|
|
74
|
+
print(f"Download attempt {attempt + 1} failed: {e}")
|
|
75
|
+
print(f"Retrying in {wait_time} seconds...")
|
|
76
|
+
time.sleep(wait_time)
|
|
77
|
+
else:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"Failed to download {url} after {max_retries} attempts: {last_exception}"
|
|
80
|
+
) from last_exception
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def download_and_extract(
|
|
84
|
+
urls: List[str],
|
|
85
|
+
file_paths: List[Union[str, Path]],
|
|
86
|
+
extract_dir: Optional[Union[str, Path]] = None,
|
|
87
|
+
descs: Optional[List[str]] = None,
|
|
88
|
+
force: bool = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Download files and optionally extract zip files.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
urls: List of URLs to download from
|
|
94
|
+
file_paths: List of paths to save the files to
|
|
95
|
+
extract_dir: Directory to extract zip files to. If None, files are not extracted
|
|
96
|
+
descs: List of descriptions for the progress bars
|
|
97
|
+
force: If True, re-download even if files exist
|
|
98
|
+
"""
|
|
99
|
+
if descs is None:
|
|
100
|
+
descs = [f"Downloading file {i + 1}/{len(urls)}" for i in range(len(urls))]
|
|
101
|
+
|
|
102
|
+
for url, file_path_str, desc in zip(urls, file_paths, descs):
|
|
103
|
+
file_path = Path(file_path_str)
|
|
104
|
+
download_file(url, file_path, desc, force=force, max_retries=3)
|
|
105
|
+
|
|
106
|
+
if extract_dir is not None and file_path.suffix.lower() == ".zip":
|
|
107
|
+
extract_dir = Path(extract_dir)
|
|
108
|
+
extracted_marker = extract_dir / f".{file_path.stem}_extracted"
|
|
109
|
+
|
|
110
|
+
if extracted_marker.exists() and not force:
|
|
111
|
+
print(f"{file_path.name} already extracted, skipping...")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
print(f"Extracting {file_path.name} to {extract_dir}...")
|
|
115
|
+
try:
|
|
116
|
+
with zipfile.ZipFile(file_path, "r") as zip_ref:
|
|
117
|
+
zip_ref.extractall(extract_dir)
|
|
118
|
+
extracted_marker.touch()
|
|
119
|
+
file_path.unlink()
|
|
120
|
+
except Exception as e:
|
|
121
|
+
if extracted_marker.exists():
|
|
122
|
+
extracted_marker.unlink()
|
|
123
|
+
raise RuntimeError(f"Failed to extract {file_path}: {e}") from e
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict, Optional, Union
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
|
|
6
|
+
from deepecgkit.utils.download import download_file
|
|
7
|
+
|
|
8
|
+
WEIGHTS_REGISTRY: Dict[str, Dict] = {
|
|
9
|
+
"kanres-af-30s": {
|
|
10
|
+
"url": "https://github.com/your-org/deepecg-kit/releases/download/v1.0.0/kanres-af-30s.pt",
|
|
11
|
+
"model_class": "KanResWideX",
|
|
12
|
+
"model_kwargs": {"input_channels": 1, "output_size": 4, "base_channels": 64},
|
|
13
|
+
"description": "KanResWideX trained on AF classification (30s recordings)",
|
|
14
|
+
},
|
|
15
|
+
"afmodel-30s": {
|
|
16
|
+
"url": "https://github.com/your-org/deepecg-kit/releases/download/v1.0.0/afmodel-30s.pt",
|
|
17
|
+
"model_class": "AFModel",
|
|
18
|
+
"model_kwargs": {"recording_length": 30},
|
|
19
|
+
"description": "AFModel trained on PhysioNet 2017 Challenge (30s recordings)",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_weights_dir() -> Path:
|
|
25
|
+
cache_dir = Path.home() / ".cache" / "deepecgkit" / "weights"
|
|
26
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return cache_dir
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_pretrained_weights() -> Dict[str, str]:
|
|
31
|
+
"""List all available pretrained weights with descriptions."""
|
|
32
|
+
return {name: info["description"] for name, info in WEIGHTS_REGISTRY.items()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_weight_info(weight_name: str) -> Dict:
|
|
36
|
+
"""Get information about a specific pretrained weight."""
|
|
37
|
+
if weight_name not in WEIGHTS_REGISTRY:
|
|
38
|
+
available = ", ".join(WEIGHTS_REGISTRY.keys())
|
|
39
|
+
raise ValueError(f"Unknown weight '{weight_name}'. Available: {available}")
|
|
40
|
+
return WEIGHTS_REGISTRY[weight_name]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def download_weights(weight_name: str, force: bool = False) -> Path:
|
|
44
|
+
"""Download pretrained weights by name.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
weight_name: Name of the pretrained weights (e.g., "kanres-af-30s")
|
|
48
|
+
force: If True, re-download even if weights exist locally
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Path to the downloaded weights file
|
|
52
|
+
"""
|
|
53
|
+
info = get_weight_info(weight_name)
|
|
54
|
+
weights_dir = get_weights_dir()
|
|
55
|
+
weight_path = weights_dir / f"{weight_name}.pt"
|
|
56
|
+
|
|
57
|
+
download_file(
|
|
58
|
+
url=info["url"],
|
|
59
|
+
file_path=weight_path,
|
|
60
|
+
desc=f"Downloading {weight_name} weights",
|
|
61
|
+
force=force,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return weight_path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_pretrained_weights(
|
|
68
|
+
weight_name: str,
|
|
69
|
+
map_location: Optional[Union[str, torch.device]] = None,
|
|
70
|
+
force_download: bool = False,
|
|
71
|
+
) -> Dict:
|
|
72
|
+
"""Load pretrained weights by name.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
weight_name: Name of the pretrained weights
|
|
76
|
+
map_location: Device to map weights to (e.g., "cpu", "cuda")
|
|
77
|
+
force_download: If True, re-download weights even if cached
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
State dict containing model weights
|
|
81
|
+
"""
|
|
82
|
+
weight_path = download_weights(weight_name, force=force_download)
|
|
83
|
+
return torch.load(weight_path, map_location=map_location, weights_only=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register_weights(
|
|
87
|
+
name: str,
|
|
88
|
+
url: str,
|
|
89
|
+
model_class: str,
|
|
90
|
+
model_kwargs: Dict,
|
|
91
|
+
description: str = "",
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Register custom pretrained weights.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
name: Unique name for the weights
|
|
97
|
+
url: URL to download weights from
|
|
98
|
+
model_class: Name of the model class these weights are for
|
|
99
|
+
model_kwargs: Keyword arguments to instantiate the model
|
|
100
|
+
description: Human-readable description of the weights
|
|
101
|
+
"""
|
|
102
|
+
WEIGHTS_REGISTRY[name] = {
|
|
103
|
+
"url": url,
|
|
104
|
+
"model_class": model_class,
|
|
105
|
+
"model_kwargs": model_kwargs,
|
|
106
|
+
"description": description,
|
|
107
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deepecgkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A deep learning toolkit for ECG analysis
|
|
5
|
+
Author-email: Steven <steven@simula.no>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: numpy>=1.20.0
|
|
13
|
+
Requires-Dist: scipy>=1.7.0
|
|
14
|
+
Requires-Dist: torch>=1.9.0
|
|
15
|
+
Requires-Dist: pandas>=1.3.0
|
|
16
|
+
Requires-Dist: scikit-learn>=0.24.0
|
|
17
|
+
Requires-Dist: matplotlib>=3.4.0
|
|
18
|
+
Requires-Dist: wfdb>=3.4.0
|
|
19
|
+
Requires-Dist: pyEDFlib>=0.1.0
|
|
20
|
+
Requires-Dist: PyWavelets>=1.1.0
|
|
21
|
+
Requires-Dist: seaborn>=0.11.0
|
|
22
|
+
Requires-Dist: pytorch-lightning>=1.5.0
|
|
23
|
+
Requires-Dist: litmodels>=0.1.8
|
|
24
|
+
Requires-Dist: tensorboardx>=2.6.2.2
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-cov>=3.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: black>=22.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: isort>=5.0.0; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy>=0.900; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.11.13; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# DeepECG-Kit
|
|
34
|
+
|
|
35
|
+
A PyTorch Lightning-based deep learning library for ECG analysis and arrhythmia classification. Provides a CLI for training, evaluation, and inference across multiple model architectures and ECG datasets.
|
|
36
|
+
|
|
37
|
+
Install with `pip install deepecgkit`, or for local development clone the repo and run `pip install -e ".[dev]"`.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
deepecg train -m kanres -d af-classification # train (auto-downloads dataset)
|
|
43
|
+
deepecg evaluate --checkpoint model.ckpt -d af-classification
|
|
44
|
+
deepecg predict --checkpoint model.ckpt --input ecg.npy
|
|
45
|
+
deepecg resume --checkpoint model.ckpt --epochs 100
|
|
46
|
+
deepecg list-models # list available models/datasets
|
|
47
|
+
deepecg list-datasets
|
|
48
|
+
deepecg info -m kanres
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Models:** `afmodel`, `convnext-v2`, `crnn`, `deep-res-cnn`, `dualnet`, `fcn-wang`, `gru`, `inception-time`, `kanres`, `kanres-deep`, `lstm`, `mamba`, `medformer`, `resnet`, `resnet-wang`, `se-resnet`, `simple-cnn`, `tcn`, `transformer`, `xresnet`, `xresnet1d-benchmark`
|
|
52
|
+
|
|
53
|
+
**Datasets:** `af-classification` (1-lead, 4-class, PhysioNet 2017), `ptbxl` (12-lead, 5 superclasses, multi-label), `mitbih-afdb` (2-lead, binary/4-class, MIT-BIH AFDB), `ltafdb` (2-lead, binary/4-class, Long-Term AFDB), `unified-af` (1-lead, 4-class, combined cross-dataset).
|
|
54
|
+
|
|
55
|
+
## Experiments
|
|
56
|
+
|
|
57
|
+
Experiments are defined in `experiments.yaml` and run via `python scripts/run_experiments.py`. Each experiment trains a model on a dataset using the CLI under the hood. Results save to `runs/{timestamp}-{model}-{dataset}/` with checkpoints (top 3 by val_loss), training plots, and test evaluation (confusion matrix, classification report).
|
|
58
|
+
|
|
59
|
+
### Default Training Parameters
|
|
60
|
+
|
|
61
|
+
| Parameter | Value |
|
|
62
|
+
|-----------|-------|
|
|
63
|
+
| Epochs | 50 |
|
|
64
|
+
| Batch size | 32 |
|
|
65
|
+
| Learning rate | 0.001 |
|
|
66
|
+
| Optimizer | Adam |
|
|
67
|
+
| LR scheduler | ReduceLROnPlateau (factor=0.5, patience=5) |
|
|
68
|
+
| Early stopping patience | 10 |
|
|
69
|
+
| Validation split | 0.2 |
|
|
70
|
+
| Test split | 0.1 |
|
|
71
|
+
| Seed | 42 |
|
|
72
|
+
|
|
73
|
+
## References
|
|
74
|
+
|
|
75
|
+
### Datasets
|
|
76
|
+
|
|
77
|
+
1. **af-classification** — Clifford GD, Liu C, Moody B, Li-wei HL, Silva I, Li Q, Johnson AE, Mark RG. "AF Classification from a Short Single Lead ECG Recording: The PhysioNet/Computing in Cardiology Challenge 2017." *Computing in Cardiology (CinC)*, 2017. [[PhysioNet]](https://physionet.org/content/challenge-2017/1.0.0/)
|
|
78
|
+
|
|
79
|
+
2. **ptbxl** — Wagner P, Strodthoff N, Bousseljot RD, Kreiseler D, Lunze FI, Samek W, Schaeffter T. "PTB-XL, a Large Publicly Available Electrocardiography Dataset." *Scientific Data*, 7(1):154, 2020. [[PhysioNet]](https://physionet.org/content/ptb-xl/1.0.3/)
|
|
80
|
+
|
|
81
|
+
3. **mitbih-afdb** — Moody GB, Mark RG. "A New Method for Detecting Atrial Fibrillation Using R-R Intervals." *Computers in Cardiology*, 10:227–230, 1983. [[PhysioNet]](https://physionet.org/content/afdb/1.0.0/)
|
|
82
|
+
|
|
83
|
+
4. **ltafdb** — Petrutiu S, Sahakian AV, Swiryn S. "Abrupt Changes in Fibrillatory Wave Characteristics at the Termination of Paroxysmal Atrial Fibrillation in Humans." *Europace*, 9(7):466–470, 2007. [[PhysioNet]](https://physionet.org/content/ltafdb/1.0.0/)
|
|
84
|
+
|
|
85
|
+
5. **unified-af** — Combined cross-dataset using af-classification, mitbih-afdb, and ltafdb (see references above).
|
|
86
|
+
|
|
87
|
+
### Models
|
|
88
|
+
|
|
89
|
+
6. **convnext-v2** — Woo S, Debnath S, Hu R, Chen X, Liu Z, Kweon IS, Xie S. "ConvNeXt V2: Co-designing and Scaling ConvNets with Masked Autoencoders." *CVPR*, 2023. [[GitHub]](https://github.com/facebookresearch/ConvNeXt-V2)
|
|
90
|
+
|
|
91
|
+
7. **deep-res-cnn** — Elyamani H. "ECG Classification Using Deep Residual CNN." 2022. [[GitHub]](https://github.com/HaneenElyamani/ECG-classification)
|
|
92
|
+
|
|
93
|
+
8. **inception-time** — Fawaz HI, Lucas B, Forestier G, Pelletier C, Schmidt DF, Weber J, Webb GI, Idoumghar L, Muller PA, Petitjean F. "InceptionTime: Finding AlexNet for Time Series Classification." *Data Mining and Knowledge Discovery*, 34:1936–1962, 2020. [[GitHub]](https://github.com/hfawaz/InceptionTime)
|
|
94
|
+
|
|
95
|
+
9. **mamba** — Gu A, Dao T. "Mamba: Linear-Time Sequence Modeling with Selective State Spaces." *arXiv:2312.00752*, 2023. [[GitHub]](https://github.com/state-spaces/mamba)
|
|
96
|
+
|
|
97
|
+
10. **medformer** — Wang N, Liang X, Wang Z, Zhao J, Liu Y, Peng L, Miao C. "MedFormer: A Multi-Granularity Patching Transformer for Medical Time-Series Classification." *NeurIPS*, 2024. [[GitHub]](https://github.com/DL4mHealth/Medformer)
|
|
98
|
+
|
|
99
|
+
11. **xresnet / xresnet1d-benchmark** — Strodthoff N, Wagner P, Schaeffter T, Samek W. "Deep Learning for ECG Analysis: Benchmarks and Insights from PTB-XL." *IEEE Journal of Biomedical and Health Informatics*, 25(5):1519–1528, 2021. [[GitHub]](https://github.com/helme/ecg_ptbxl_benchmarking)
|