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,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visualization functions for ECG evaluation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import seaborn as sns
|
|
13
|
+
from sklearn.calibration import calibration_curve
|
|
14
|
+
from sklearn.metrics import auc, confusion_matrix, roc_curve
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def plot_confusion_matrix(
|
|
18
|
+
y_true: np.ndarray,
|
|
19
|
+
y_pred: np.ndarray,
|
|
20
|
+
class_names: Optional[List[str]] = None,
|
|
21
|
+
title: str = "Confusion Matrix",
|
|
22
|
+
save_path: Optional[str] = None,
|
|
23
|
+
):
|
|
24
|
+
"""Plot confusion matrix."""
|
|
25
|
+
labels = sorted(set(y_true) | set(y_pred))
|
|
26
|
+
cm = confusion_matrix(y_true, y_pred, labels=labels)
|
|
27
|
+
|
|
28
|
+
if class_names is None or len(class_names) != len(labels):
|
|
29
|
+
class_names = [str(lbl) for lbl in labels]
|
|
30
|
+
|
|
31
|
+
_, ax = plt.subplots(figsize=(8, 6))
|
|
32
|
+
|
|
33
|
+
sns.heatmap(
|
|
34
|
+
cm,
|
|
35
|
+
annot=True,
|
|
36
|
+
fmt="d",
|
|
37
|
+
cmap="Blues",
|
|
38
|
+
xticklabels=class_names,
|
|
39
|
+
yticklabels=class_names,
|
|
40
|
+
ax=ax,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
ax.set_title(title)
|
|
44
|
+
ax.set_ylabel("True Label")
|
|
45
|
+
ax.set_xlabel("Predicted Label")
|
|
46
|
+
|
|
47
|
+
if save_path:
|
|
48
|
+
plt.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
49
|
+
plt.close()
|
|
50
|
+
else:
|
|
51
|
+
plt.show()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def plot_roc_curve(
|
|
55
|
+
y_true: np.ndarray,
|
|
56
|
+
y_scores: np.ndarray,
|
|
57
|
+
title: str = "ROC Curve",
|
|
58
|
+
save_path: Optional[str] = None,
|
|
59
|
+
):
|
|
60
|
+
"""Plot ROC curve."""
|
|
61
|
+
try:
|
|
62
|
+
fpr, tpr, _ = roc_curve(y_true, y_scores)
|
|
63
|
+
roc_auc = auc(fpr, tpr)
|
|
64
|
+
|
|
65
|
+
plt.figure(figsize=(8, 6))
|
|
66
|
+
plt.plot(
|
|
67
|
+
fpr,
|
|
68
|
+
tpr,
|
|
69
|
+
color="darkorange",
|
|
70
|
+
lw=2,
|
|
71
|
+
label=f"ROC curve (area = {roc_auc:.2f})",
|
|
72
|
+
)
|
|
73
|
+
plt.plot([0, 1], [0, 1], color="navy", lw=2, linestyle="--")
|
|
74
|
+
plt.xlim([0.0, 1.0])
|
|
75
|
+
plt.ylim([0.0, 1.05])
|
|
76
|
+
plt.xlabel("False Positive Rate")
|
|
77
|
+
plt.ylabel("True Positive Rate")
|
|
78
|
+
plt.title(title)
|
|
79
|
+
plt.legend(loc="lower right")
|
|
80
|
+
|
|
81
|
+
if save_path:
|
|
82
|
+
plt.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
83
|
+
plt.close()
|
|
84
|
+
else:
|
|
85
|
+
plt.show()
|
|
86
|
+
|
|
87
|
+
except ImportError:
|
|
88
|
+
warnings.warn("Could not plot ROC curve - missing dependencies", stacklevel=2)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def plot_predictions(
|
|
92
|
+
y_true: np.ndarray,
|
|
93
|
+
y_pred: np.ndarray,
|
|
94
|
+
title: str = "Predictions vs True Values",
|
|
95
|
+
save_path: Optional[str] = None,
|
|
96
|
+
):
|
|
97
|
+
"""Plot predictions vs true values."""
|
|
98
|
+
plt.figure(figsize=(8, 6))
|
|
99
|
+
plt.scatter(y_true, y_pred, alpha=0.6)
|
|
100
|
+
plt.plot([y_true.min(), y_true.max()], [y_true.min(), y_true.max()], "r--", lw=2)
|
|
101
|
+
plt.xlabel("True Values")
|
|
102
|
+
plt.ylabel("Predictions")
|
|
103
|
+
plt.title(title)
|
|
104
|
+
|
|
105
|
+
if save_path:
|
|
106
|
+
plt.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
107
|
+
plt.close()
|
|
108
|
+
else:
|
|
109
|
+
plt.show()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def plot_ecg_signals(
|
|
113
|
+
data: np.ndarray,
|
|
114
|
+
sampling_rate: float = 500.0,
|
|
115
|
+
leads: Optional[List[str]] = None,
|
|
116
|
+
title: str = "ECG Signals",
|
|
117
|
+
save_path: Optional[str] = None,
|
|
118
|
+
):
|
|
119
|
+
"""Plot ECG signals."""
|
|
120
|
+
if data.ndim == 1:
|
|
121
|
+
data = data.reshape(-1, 1)
|
|
122
|
+
|
|
123
|
+
n_samples, n_leads = data.shape
|
|
124
|
+
time = np.arange(n_samples) / sampling_rate
|
|
125
|
+
|
|
126
|
+
if leads is None:
|
|
127
|
+
leads = [f"Lead {i + 1}" for i in range(n_leads)]
|
|
128
|
+
|
|
129
|
+
_fig, axes = plt.subplots(n_leads, 1, figsize=(12, 2 * n_leads), sharex=True)
|
|
130
|
+
if n_leads == 1:
|
|
131
|
+
axes = [axes]
|
|
132
|
+
|
|
133
|
+
for i, ax in enumerate(axes):
|
|
134
|
+
ax.plot(time, data[:, i])
|
|
135
|
+
ax.set_ylabel(f"{leads[i]} (mV)")
|
|
136
|
+
ax.grid(True, alpha=0.3)
|
|
137
|
+
|
|
138
|
+
axes[-1].set_xlabel("Time (s)")
|
|
139
|
+
plt.suptitle(title)
|
|
140
|
+
plt.tight_layout()
|
|
141
|
+
|
|
142
|
+
if save_path:
|
|
143
|
+
plt.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
144
|
+
plt.close()
|
|
145
|
+
else:
|
|
146
|
+
plt.show()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def plot_training_curves(
|
|
150
|
+
metrics_path: str,
|
|
151
|
+
save_dir: Optional[str] = None,
|
|
152
|
+
save_path: Optional[str] = None,
|
|
153
|
+
):
|
|
154
|
+
"""Plot training and validation loss and accuracy as separate figures.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
metrics_path: Path to CSVLogger metrics.csv file.
|
|
158
|
+
save_dir: Directory to save separate loss.png and accuracy.png files.
|
|
159
|
+
save_path: Deprecated combined path; if provided and save_dir is not,
|
|
160
|
+
saves loss plot to this path for backward compatibility.
|
|
161
|
+
"""
|
|
162
|
+
df = pd.read_csv(metrics_path)
|
|
163
|
+
|
|
164
|
+
has_acc = "train_acc" in df.columns
|
|
165
|
+
|
|
166
|
+
resolved_dir = None
|
|
167
|
+
if save_dir:
|
|
168
|
+
resolved_dir = Path(save_dir)
|
|
169
|
+
elif save_path:
|
|
170
|
+
resolved_dir = Path(save_path).parent
|
|
171
|
+
|
|
172
|
+
train_loss = df.dropna(subset=["train_loss"]).groupby("epoch")["train_loss"].mean()
|
|
173
|
+
val_loss = df.dropna(subset=["val_loss"]).groupby("epoch")["val_loss"].mean()
|
|
174
|
+
|
|
175
|
+
plt.figure(figsize=(10, 5))
|
|
176
|
+
plt.plot(train_loss.index, train_loss.values, label="Train Loss")
|
|
177
|
+
plt.plot(val_loss.index, val_loss.values, label="Val Loss")
|
|
178
|
+
plt.xlabel("Epoch")
|
|
179
|
+
plt.ylabel("Loss")
|
|
180
|
+
plt.title("Training & Validation Loss")
|
|
181
|
+
plt.legend()
|
|
182
|
+
plt.grid(True, alpha=0.3)
|
|
183
|
+
plt.tight_layout()
|
|
184
|
+
|
|
185
|
+
if resolved_dir:
|
|
186
|
+
plt.savefig(str(resolved_dir / "loss.png"), dpi=300, bbox_inches="tight")
|
|
187
|
+
plt.close()
|
|
188
|
+
else:
|
|
189
|
+
plt.show()
|
|
190
|
+
|
|
191
|
+
if has_acc:
|
|
192
|
+
train_acc = df.dropna(subset=["train_acc"]).groupby("epoch")["train_acc"].mean()
|
|
193
|
+
val_acc = df.dropna(subset=["val_acc"]).groupby("epoch")["val_acc"].mean()
|
|
194
|
+
|
|
195
|
+
plt.figure(figsize=(10, 5))
|
|
196
|
+
plt.plot(train_acc.index, train_acc.values, label="Train Acc")
|
|
197
|
+
plt.plot(val_acc.index, val_acc.values, label="Val Acc")
|
|
198
|
+
plt.xlabel("Epoch")
|
|
199
|
+
plt.ylabel("Accuracy")
|
|
200
|
+
plt.title("Training & Validation Accuracy")
|
|
201
|
+
plt.legend()
|
|
202
|
+
plt.grid(True, alpha=0.3)
|
|
203
|
+
plt.tight_layout()
|
|
204
|
+
|
|
205
|
+
if resolved_dir:
|
|
206
|
+
plt.savefig(str(resolved_dir / "accuracy.png"), dpi=300, bbox_inches="tight")
|
|
207
|
+
plt.close()
|
|
208
|
+
else:
|
|
209
|
+
plt.show()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def plot_calibration_curve(
|
|
213
|
+
y_true: np.ndarray,
|
|
214
|
+
y_prob: np.ndarray,
|
|
215
|
+
class_names: Optional[List[str]] = None,
|
|
216
|
+
n_bins: int = 10,
|
|
217
|
+
title: str = "Calibration Plot",
|
|
218
|
+
save_dir: Optional[str] = None,
|
|
219
|
+
save_path: Optional[str] = None,
|
|
220
|
+
):
|
|
221
|
+
"""Plot reliability diagram and prediction distribution as separate figures.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
y_true: True labels.
|
|
225
|
+
y_prob: Predicted probabilities (n_samples, n_classes).
|
|
226
|
+
class_names: Optional list of class names.
|
|
227
|
+
n_bins: Number of bins for calibration curve.
|
|
228
|
+
title: Title for the calibration curve plot.
|
|
229
|
+
save_dir: Directory to save calibration_curve.png and prediction_distribution.png.
|
|
230
|
+
save_path: Deprecated combined path; if provided and save_dir is not,
|
|
231
|
+
uses its parent directory for backward compatibility.
|
|
232
|
+
"""
|
|
233
|
+
num_classes = y_prob.shape[1]
|
|
234
|
+
if class_names is None:
|
|
235
|
+
class_names = [f"Class {i}" for i in range(num_classes)]
|
|
236
|
+
|
|
237
|
+
resolved_dir = None
|
|
238
|
+
if save_dir:
|
|
239
|
+
resolved_dir = Path(save_dir)
|
|
240
|
+
elif save_path:
|
|
241
|
+
resolved_dir = Path(save_path).parent
|
|
242
|
+
|
|
243
|
+
plt.figure(figsize=(8, 6))
|
|
244
|
+
for i in range(num_classes):
|
|
245
|
+
y_binary = (y_true == i).astype(int)
|
|
246
|
+
prob_class = y_prob[:, i]
|
|
247
|
+
if y_binary.sum() == 0:
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
fraction_of_positives, mean_predicted_value = calibration_curve(
|
|
251
|
+
y_binary, prob_class, n_bins=n_bins, strategy="uniform"
|
|
252
|
+
)
|
|
253
|
+
plt.plot(mean_predicted_value, fraction_of_positives, "s-", label=class_names[i])
|
|
254
|
+
except ValueError:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
plt.plot([0, 1], [0, 1], "k--", label="Perfectly calibrated")
|
|
258
|
+
plt.xlabel("Mean Predicted Probability")
|
|
259
|
+
plt.ylabel("Fraction of Positives")
|
|
260
|
+
plt.title(title)
|
|
261
|
+
plt.legend(loc="lower right", fontsize="small")
|
|
262
|
+
plt.grid(True, alpha=0.3)
|
|
263
|
+
plt.tight_layout()
|
|
264
|
+
|
|
265
|
+
if resolved_dir:
|
|
266
|
+
plt.savefig(str(resolved_dir / "calibration_curve.png"), dpi=300, bbox_inches="tight")
|
|
267
|
+
plt.close()
|
|
268
|
+
else:
|
|
269
|
+
plt.show()
|
|
270
|
+
|
|
271
|
+
plt.figure(figsize=(8, 6))
|
|
272
|
+
for i in range(num_classes):
|
|
273
|
+
plt.hist(y_prob[:, i], bins=n_bins, alpha=0.5, label=class_names[i])
|
|
274
|
+
plt.xlabel("Predicted Probability")
|
|
275
|
+
plt.ylabel("Count")
|
|
276
|
+
plt.title("Prediction Distribution")
|
|
277
|
+
plt.legend(fontsize="small")
|
|
278
|
+
plt.grid(True, alpha=0.3)
|
|
279
|
+
plt.tight_layout()
|
|
280
|
+
|
|
281
|
+
if resolved_dir:
|
|
282
|
+
plt.savefig(str(resolved_dir / "prediction_distribution.png"), dpi=300, bbox_inches="tight")
|
|
283
|
+
plt.close()
|
|
284
|
+
else:
|
|
285
|
+
plt.show()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deep learning models for ECG analysis.
|
|
3
|
+
|
|
4
|
+
This module contains various neural network architectures optimized for ECG signal processing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from deepecgkit.models.af_classifier import AFModel
|
|
8
|
+
from deepecgkit.models.convnext_v2_1d import ConvNeXtV21D
|
|
9
|
+
from deepecgkit.models.crnn import CRNN
|
|
10
|
+
from deepecgkit.models.deep_res_cnn import DeepResCNN
|
|
11
|
+
from deepecgkit.models.ecg_dualnet import ECGDualNet
|
|
12
|
+
from deepecgkit.models.fcn_wang import FCNWang
|
|
13
|
+
from deepecgkit.models.gru_model import GRUECG
|
|
14
|
+
from deepecgkit.models.inception_time import InceptionTime1D
|
|
15
|
+
from deepecgkit.models.kanres_wide_x import KanResDeepX
|
|
16
|
+
from deepecgkit.models.kanres_x import KanResWideX
|
|
17
|
+
from deepecgkit.models.lstm_model import LSTMECG
|
|
18
|
+
from deepecgkit.models.mamba1d import Mamba1D
|
|
19
|
+
from deepecgkit.models.medformer import Medformer
|
|
20
|
+
from deepecgkit.models.resnet1d import ResNet1D
|
|
21
|
+
from deepecgkit.models.resnet1d_wang import ResNetWang
|
|
22
|
+
from deepecgkit.models.se_resnet1d import SEResNet1D
|
|
23
|
+
from deepecgkit.models.simple_cnn import SimpleCNN
|
|
24
|
+
from deepecgkit.models.tcn import TCN
|
|
25
|
+
from deepecgkit.models.transformer_ecg import TransformerECG
|
|
26
|
+
from deepecgkit.models.xresnet1d import XResNet1D
|
|
27
|
+
from deepecgkit.models.xresnet1d_benchmark import XResNet1dBenchmark
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CRNN",
|
|
31
|
+
"GRUECG",
|
|
32
|
+
"LSTMECG",
|
|
33
|
+
"TCN",
|
|
34
|
+
"AFModel",
|
|
35
|
+
"ConvNeXtV21D",
|
|
36
|
+
"DeepResCNN",
|
|
37
|
+
"ECGDualNet",
|
|
38
|
+
"FCNWang",
|
|
39
|
+
"InceptionTime1D",
|
|
40
|
+
"KanResDeepX",
|
|
41
|
+
"KanResWideX",
|
|
42
|
+
"Mamba1D",
|
|
43
|
+
"Medformer",
|
|
44
|
+
"ResNet1D",
|
|
45
|
+
"ResNetWang",
|
|
46
|
+
"SEResNet1D",
|
|
47
|
+
"SimpleCNN",
|
|
48
|
+
"TransformerECG",
|
|
49
|
+
"XResNet1D",
|
|
50
|
+
"XResNet1dBenchmark",
|
|
51
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
from torch import nn
|
|
6
|
+
|
|
7
|
+
from deepecgkit.registry import register_model
|
|
8
|
+
from deepecgkit.utils.weights import get_weight_info, load_pretrained_weights
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConvBlock(nn.Module):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
in_channels: int,
|
|
15
|
+
out_channels: int,
|
|
16
|
+
kernel_size: int,
|
|
17
|
+
use_dropout: bool = False,
|
|
18
|
+
use_pooling: bool = False,
|
|
19
|
+
dropout_rate: float = 0.1,
|
|
20
|
+
):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.conv = nn.Conv1d(
|
|
23
|
+
in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size
|
|
24
|
+
)
|
|
25
|
+
self.batch_norm = nn.BatchNorm1d(out_channels)
|
|
26
|
+
self.relu = nn.ReLU()
|
|
27
|
+
self.use_dropout = use_dropout
|
|
28
|
+
self.use_pooling = use_pooling
|
|
29
|
+
|
|
30
|
+
if use_dropout:
|
|
31
|
+
self.dropout = nn.Dropout(dropout_rate)
|
|
32
|
+
if use_pooling:
|
|
33
|
+
self.pool = nn.MaxPool1d(kernel_size=2, stride=2)
|
|
34
|
+
|
|
35
|
+
def forward(self, x):
|
|
36
|
+
x = self.conv(x)
|
|
37
|
+
x = self.relu(self.batch_norm(x))
|
|
38
|
+
|
|
39
|
+
if self.use_dropout:
|
|
40
|
+
x = self.dropout(x)
|
|
41
|
+
if self.use_pooling:
|
|
42
|
+
x = self.pool(x)
|
|
43
|
+
|
|
44
|
+
return x
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@register_model(
|
|
48
|
+
name="afmodel",
|
|
49
|
+
description="Atrial Fibrillation model optimized for 30s ECG segments",
|
|
50
|
+
default_kwargs={"recording_length": 30},
|
|
51
|
+
)
|
|
52
|
+
class AFModel(nn.Module):
|
|
53
|
+
"""
|
|
54
|
+
Atrial Fibrillation classification model.
|
|
55
|
+
|
|
56
|
+
A convolutional neural network specifically designed for AF detection
|
|
57
|
+
in ECG signals with configurable recording length support.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
input_channels: Number of input ECG leads (default: 1)
|
|
61
|
+
output_size: Number of output classes (default: 4)
|
|
62
|
+
recording_length: Recording length in seconds (must be 6, 10, or 30)
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> model = AFModel(recording_length=30)
|
|
66
|
+
>>> x = torch.randn(32, 1, 9000)
|
|
67
|
+
>>> output = model(x)
|
|
68
|
+
>>> print(output.shape) # (32, 4)
|
|
69
|
+
|
|
70
|
+
>>> features = model.extract_features(x)
|
|
71
|
+
>>> print(features.shape) # (32, 196)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
input_channels: int = 1,
|
|
77
|
+
output_size: int = 4,
|
|
78
|
+
recording_length: int = 30,
|
|
79
|
+
):
|
|
80
|
+
super().__init__()
|
|
81
|
+
assert recording_length in [6, 10, 30], (
|
|
82
|
+
f"Recording length must be 6, 10, or 30, got {recording_length}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self.recording_length = recording_length
|
|
86
|
+
kernel_size = 6 if recording_length in [6, 10] else 32
|
|
87
|
+
|
|
88
|
+
layer_configs = [
|
|
89
|
+
(input_channels, 64, True, True),
|
|
90
|
+
(64, 64, False, False),
|
|
91
|
+
(64, 64, True, True),
|
|
92
|
+
(64, 64, False, False),
|
|
93
|
+
(64, 64, True, True),
|
|
94
|
+
(64, 64, False, False),
|
|
95
|
+
(64, 128, True, True),
|
|
96
|
+
(128, 128, False, False),
|
|
97
|
+
(128, 128, True, True),
|
|
98
|
+
(128, 128, False, False),
|
|
99
|
+
(128, 196, True, True),
|
|
100
|
+
(196, 196, False, False),
|
|
101
|
+
(196, 196, True, True),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
self.conv_layers = nn.ModuleList(
|
|
105
|
+
[
|
|
106
|
+
ConvBlock(in_ch, out_ch, kernel_size, dropout, pooling)
|
|
107
|
+
for in_ch, out_ch, dropout, pooling in layer_configs
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.adaptive_pool = nn.AdaptiveAvgPool1d(1)
|
|
112
|
+
self.flatten = nn.Flatten()
|
|
113
|
+
self._feature_dim = 196
|
|
114
|
+
self.dropout = nn.Dropout(0.3)
|
|
115
|
+
self.output = nn.Linear(in_features=self._feature_dim, out_features=output_size)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def feature_dim(self) -> int:
|
|
119
|
+
return self._feature_dim
|
|
120
|
+
|
|
121
|
+
def extract_features(self, data: torch.Tensor) -> torch.Tensor:
|
|
122
|
+
for conv_layer in self.conv_layers:
|
|
123
|
+
data = conv_layer(data)
|
|
124
|
+
data = self.adaptive_pool(data)
|
|
125
|
+
data = self.flatten(data)
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
def forward(self, data):
|
|
129
|
+
data = self.extract_features(data)
|
|
130
|
+
data = self.dropout(data)
|
|
131
|
+
return self.output(data)
|
|
132
|
+
|
|
133
|
+
def get_feature_size(self, input_size: int) -> int:
|
|
134
|
+
with torch.no_grad():
|
|
135
|
+
dummy_input = torch.randn(1, 1, input_size)
|
|
136
|
+
|
|
137
|
+
for conv_layer in self.conv_layers:
|
|
138
|
+
dummy_input = conv_layer(dummy_input)
|
|
139
|
+
|
|
140
|
+
return dummy_input.shape[-1]
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_pretrained(
|
|
144
|
+
cls,
|
|
145
|
+
weights: str,
|
|
146
|
+
map_location: Optional[Union[str, torch.device]] = None,
|
|
147
|
+
force_download: bool = False,
|
|
148
|
+
**kwargs,
|
|
149
|
+
) -> "AFModel":
|
|
150
|
+
"""Load a pretrained AFModel.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
weights: Name of pretrained weights (e.g., "afmodel-30s") or path to weights file
|
|
154
|
+
map_location: Device to map weights to (e.g., "cpu", "cuda")
|
|
155
|
+
force_download: If True, re-download weights even if cached
|
|
156
|
+
**kwargs: Override default model parameters from the weight registry
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Model with pretrained weights loaded
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
>>> model = AFModel.from_pretrained("afmodel-30s")
|
|
163
|
+
>>> model = AFModel.from_pretrained("afmodel-30s", map_location="cuda")
|
|
164
|
+
>>> model = AFModel.from_pretrained("/path/to/weights.pt", recording_length=30)
|
|
165
|
+
"""
|
|
166
|
+
weight_path = Path(weights)
|
|
167
|
+
if weight_path.exists():
|
|
168
|
+
state_dict = torch.load(weight_path, map_location=map_location, weights_only=True)
|
|
169
|
+
model = cls(**kwargs)
|
|
170
|
+
else:
|
|
171
|
+
info = get_weight_info(weights)
|
|
172
|
+
model_kwargs = {**info["model_kwargs"], **kwargs}
|
|
173
|
+
model = cls(**model_kwargs)
|
|
174
|
+
state_dict = load_pretrained_weights(weights, map_location, force_download)
|
|
175
|
+
|
|
176
|
+
model.load_state_dict(state_dict)
|
|
177
|
+
return model
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from torch import nn
|
|
3
|
+
|
|
4
|
+
from deepecgkit.registry import register_model
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GRN(nn.Module):
|
|
8
|
+
"""
|
|
9
|
+
Global Response Normalization layer for 1D signals.
|
|
10
|
+
|
|
11
|
+
Normalizes feature responses globally across the temporal dimension,
|
|
12
|
+
the key differentiator of ConvNeXtV2 over ConvNeXtV1.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
dim: Number of channels
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, dim: int):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.gamma = nn.Parameter(torch.zeros(1, dim, 1))
|
|
21
|
+
self.beta = nn.Parameter(torch.zeros(1, dim, 1))
|
|
22
|
+
|
|
23
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
24
|
+
gx = torch.norm(x, p=2, dim=2, keepdim=True)
|
|
25
|
+
nx = gx / (gx.mean(dim=1, keepdim=True) + 1e-6)
|
|
26
|
+
return self.gamma * (x * nx) + self.beta + x
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LayerNorm1d(nn.Module):
|
|
30
|
+
"""
|
|
31
|
+
LayerNorm for (B, C, T) tensors, normalizing over the channel dimension.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
dim: Number of channels
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, dim: int):
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.norm = nn.LayerNorm(dim)
|
|
40
|
+
|
|
41
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
42
|
+
x = x.transpose(1, 2)
|
|
43
|
+
x = self.norm(x)
|
|
44
|
+
x = x.transpose(1, 2)
|
|
45
|
+
return x
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConvNeXtV2Block1D(nn.Module):
|
|
49
|
+
"""
|
|
50
|
+
Single ConvNeXtV2 block with inverted bottleneck and GRN.
|
|
51
|
+
|
|
52
|
+
depthwise conv -> LayerNorm -> pointwise expand -> GELU -> GRN -> pointwise project
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
dim: Number of input/output channels
|
|
56
|
+
expansion_factor: Expansion ratio for inverted bottleneck (default: 4)
|
|
57
|
+
kernel_size: Kernel size for depthwise convolution (default: 7)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, dim: int, expansion_factor: int = 4, kernel_size: int = 7):
|
|
61
|
+
super().__init__()
|
|
62
|
+
hidden_dim = dim * expansion_factor
|
|
63
|
+
self.dwconv = nn.Conv1d(
|
|
64
|
+
dim,
|
|
65
|
+
dim,
|
|
66
|
+
kernel_size=kernel_size,
|
|
67
|
+
padding=kernel_size // 2,
|
|
68
|
+
groups=dim,
|
|
69
|
+
)
|
|
70
|
+
self.norm = LayerNorm1d(dim)
|
|
71
|
+
self.pwconv_expand = nn.Conv1d(dim, hidden_dim, kernel_size=1)
|
|
72
|
+
self.act = nn.GELU()
|
|
73
|
+
self.grn = GRN(hidden_dim)
|
|
74
|
+
self.pwconv_project = nn.Conv1d(hidden_dim, dim, kernel_size=1)
|
|
75
|
+
|
|
76
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
77
|
+
residual = x
|
|
78
|
+
x = self.dwconv(x)
|
|
79
|
+
x = self.norm(x)
|
|
80
|
+
x = self.pwconv_expand(x)
|
|
81
|
+
x = self.act(x)
|
|
82
|
+
x = self.grn(x)
|
|
83
|
+
x = self.pwconv_project(x)
|
|
84
|
+
return x + residual
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@register_model(
|
|
88
|
+
name="convnext-v2",
|
|
89
|
+
description="Modern ConvNet with depthwise convolutions and Global Response Normalization for ECG",
|
|
90
|
+
)
|
|
91
|
+
class ConvNeXtV21D(nn.Module):
|
|
92
|
+
"""
|
|
93
|
+
ConvNeXtV2 adapted for 1D ECG signals.
|
|
94
|
+
|
|
95
|
+
A modern convolutional architecture using depthwise separable convolutions,
|
|
96
|
+
LayerNorm, GELU activation, and Global Response Normalization (GRN).
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
input_channels: Number of input channels (default: 1 for single-lead ECG)
|
|
100
|
+
output_size: Number of output classes
|
|
101
|
+
dims: Channel dimensions for each stage (default: [64, 128, 256, 512])
|
|
102
|
+
depths: Number of blocks per stage (default: [2, 2, 6, 2])
|
|
103
|
+
kernel_size: Kernel size for depthwise convolutions (default: 7)
|
|
104
|
+
expansion_factor: Expansion ratio for inverted bottleneck (default: 4)
|
|
105
|
+
dropout_rate: Dropout probability (default: 0.3)
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> model = ConvNeXtV21D(input_channels=1, output_size=4)
|
|
109
|
+
>>> x = torch.randn(32, 1, 3000)
|
|
110
|
+
>>> output = model(x)
|
|
111
|
+
>>> print(output.shape)
|
|
112
|
+
|
|
113
|
+
>>> features = model.extract_features(x)
|
|
114
|
+
>>> print(features.shape) # (32, 512)
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
input_channels: int = 1,
|
|
120
|
+
output_size: int = 4,
|
|
121
|
+
dims: list | None = None,
|
|
122
|
+
depths: list | None = None,
|
|
123
|
+
kernel_size: int = 7,
|
|
124
|
+
expansion_factor: int = 4,
|
|
125
|
+
dropout_rate: float = 0.3,
|
|
126
|
+
):
|
|
127
|
+
super().__init__()
|
|
128
|
+
|
|
129
|
+
if dims is None:
|
|
130
|
+
dims = [64, 128, 256, 512]
|
|
131
|
+
if depths is None:
|
|
132
|
+
depths = [2, 2, 6, 2]
|
|
133
|
+
|
|
134
|
+
self.stem = nn.Sequential(
|
|
135
|
+
nn.Conv1d(input_channels, dims[0], kernel_size=7, stride=4, padding=3),
|
|
136
|
+
LayerNorm1d(dims[0]),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
stages = []
|
|
140
|
+
for i in range(len(dims)):
|
|
141
|
+
stage_blocks = []
|
|
142
|
+
for _ in range(depths[i]):
|
|
143
|
+
stage_blocks.append(ConvNeXtV2Block1D(dims[i], expansion_factor, kernel_size))
|
|
144
|
+
stages.append(nn.Sequential(*stage_blocks))
|
|
145
|
+
|
|
146
|
+
self.stages = nn.ModuleList(stages)
|
|
147
|
+
|
|
148
|
+
self.downsamples = nn.ModuleList()
|
|
149
|
+
for i in range(len(dims) - 1):
|
|
150
|
+
self.downsamples.append(
|
|
151
|
+
nn.Sequential(
|
|
152
|
+
LayerNorm1d(dims[i]),
|
|
153
|
+
nn.Conv1d(dims[i], dims[i + 1], kernel_size=2, stride=2),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self.pool = nn.AdaptiveAvgPool1d(1)
|
|
158
|
+
self.final_norm = nn.LayerNorm(dims[-1])
|
|
159
|
+
self.dropout = nn.Dropout(dropout_rate)
|
|
160
|
+
self._feature_dim = dims[-1]
|
|
161
|
+
|
|
162
|
+
self.classifier = nn.Linear(dims[-1], output_size)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def feature_dim(self) -> int:
|
|
166
|
+
return self._feature_dim
|
|
167
|
+
|
|
168
|
+
def extract_features(self, x: torch.Tensor) -> torch.Tensor:
|
|
169
|
+
x = self.stem(x)
|
|
170
|
+
for i, stage in enumerate(self.stages):
|
|
171
|
+
x = stage(x)
|
|
172
|
+
if i < len(self.downsamples):
|
|
173
|
+
x = self.downsamples[i](x)
|
|
174
|
+
x = self.pool(x)
|
|
175
|
+
x = torch.flatten(x, 1)
|
|
176
|
+
x = self.final_norm(x)
|
|
177
|
+
return x
|
|
178
|
+
|
|
179
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
180
|
+
x = self.extract_features(x)
|
|
181
|
+
x = self.dropout(x)
|
|
182
|
+
x = self.classifier(x)
|
|
183
|
+
return x
|