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,117 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from torch import nn
|
|
3
|
+
|
|
4
|
+
from deepecgkit.registry import register_model
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register_model(
|
|
8
|
+
name="crnn",
|
|
9
|
+
description="CNN-LSTM hybrid for local feature extraction and temporal aggregation",
|
|
10
|
+
)
|
|
11
|
+
class CRNN(nn.Module):
|
|
12
|
+
"""
|
|
13
|
+
Convolutional Recurrent Neural Network for ECG classification.
|
|
14
|
+
|
|
15
|
+
Uses a CNN front-end for local feature extraction followed by a
|
|
16
|
+
bidirectional LSTM for temporal aggregation, combining the strengths
|
|
17
|
+
of both architectures.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
input_channels: Number of input channels (default: 1 for single-lead ECG)
|
|
21
|
+
output_size: Number of output classes
|
|
22
|
+
cnn_channels: List of channel sizes for CNN stages (default: [32, 64, 128, 256])
|
|
23
|
+
lstm_hidden_size: Size of LSTM hidden state (default: 128)
|
|
24
|
+
lstm_num_layers: Number of LSTM layers (default: 2)
|
|
25
|
+
bidirectional: Use bidirectional LSTM (default: True)
|
|
26
|
+
dropout_rate: Dropout probability (default: 0.3)
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> model = CRNN(input_channels=1, output_size=4)
|
|
30
|
+
>>> x = torch.randn(32, 1, 3000)
|
|
31
|
+
>>> output = model(x)
|
|
32
|
+
>>> print(output.shape)
|
|
33
|
+
|
|
34
|
+
>>> features = model.extract_features(x)
|
|
35
|
+
>>> print(features.shape) # (32, 256)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
input_channels: int = 1,
|
|
41
|
+
output_size: int = 4,
|
|
42
|
+
cnn_channels: list | None = None,
|
|
43
|
+
lstm_hidden_size: int = 128,
|
|
44
|
+
lstm_num_layers: int = 2,
|
|
45
|
+
bidirectional: bool = True,
|
|
46
|
+
dropout_rate: float = 0.3,
|
|
47
|
+
):
|
|
48
|
+
super().__init__()
|
|
49
|
+
|
|
50
|
+
if cnn_channels is None:
|
|
51
|
+
cnn_channels = [32, 64, 128, 256]
|
|
52
|
+
|
|
53
|
+
self.lstm_hidden_size = lstm_hidden_size
|
|
54
|
+
self.lstm_num_layers = lstm_num_layers
|
|
55
|
+
self.bidirectional = bidirectional
|
|
56
|
+
self.num_directions = 2 if bidirectional else 1
|
|
57
|
+
|
|
58
|
+
layers = []
|
|
59
|
+
in_ch = input_channels
|
|
60
|
+
kernel_sizes = [7, 5, 3, 3]
|
|
61
|
+
for i, out_ch in enumerate(cnn_channels):
|
|
62
|
+
ks = kernel_sizes[i] if i < len(kernel_sizes) else 3
|
|
63
|
+
layers.extend(
|
|
64
|
+
[
|
|
65
|
+
nn.Conv1d(in_ch, out_ch, kernel_size=ks, padding=ks // 2),
|
|
66
|
+
nn.BatchNorm1d(out_ch),
|
|
67
|
+
nn.ReLU(),
|
|
68
|
+
nn.MaxPool1d(kernel_size=2),
|
|
69
|
+
nn.Dropout(dropout_rate),
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
in_ch = out_ch
|
|
73
|
+
|
|
74
|
+
self.cnn = nn.Sequential(*layers)
|
|
75
|
+
|
|
76
|
+
self.lstm = nn.LSTM(
|
|
77
|
+
input_size=cnn_channels[-1],
|
|
78
|
+
hidden_size=lstm_hidden_size,
|
|
79
|
+
num_layers=lstm_num_layers,
|
|
80
|
+
batch_first=True,
|
|
81
|
+
dropout=dropout_rate if lstm_num_layers > 1 else 0,
|
|
82
|
+
bidirectional=bidirectional,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self.dropout = nn.Dropout(dropout_rate)
|
|
86
|
+
self._feature_dim = lstm_hidden_size * self.num_directions
|
|
87
|
+
|
|
88
|
+
self.classifier = nn.Sequential(
|
|
89
|
+
nn.Linear(self._feature_dim, 64),
|
|
90
|
+
nn.ReLU(),
|
|
91
|
+
nn.Dropout(dropout_rate),
|
|
92
|
+
nn.Linear(64, output_size),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def feature_dim(self) -> int:
|
|
97
|
+
return self._feature_dim
|
|
98
|
+
|
|
99
|
+
def extract_features(self, x: torch.Tensor) -> torch.Tensor:
|
|
100
|
+
batch_size = x.size(0)
|
|
101
|
+
x = self.cnn(x)
|
|
102
|
+
x = x.transpose(1, 2)
|
|
103
|
+
_, (h_n, _) = self.lstm(x)
|
|
104
|
+
|
|
105
|
+
if self.bidirectional:
|
|
106
|
+
h_n = h_n.view(self.lstm_num_layers, 2, batch_size, self.lstm_hidden_size)
|
|
107
|
+
h_n = h_n[-1]
|
|
108
|
+
h_n = torch.cat([h_n[0], h_n[1]], dim=1)
|
|
109
|
+
else:
|
|
110
|
+
h_n = h_n[-1]
|
|
111
|
+
|
|
112
|
+
return h_n
|
|
113
|
+
|
|
114
|
+
def forward(self, x):
|
|
115
|
+
x = self.extract_features(x)
|
|
116
|
+
x = self.dropout(x)
|
|
117
|
+
return self.classifier(x)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import torch.nn.functional as func
|
|
3
|
+
from torch import nn
|
|
4
|
+
|
|
5
|
+
from deepecgkit.registry import register_model
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StemResBlock2D(nn.Module):
|
|
9
|
+
"""
|
|
10
|
+
First residual block following the stem convolution.
|
|
11
|
+
|
|
12
|
+
Unlike subsequent blocks, this starts directly with convolution
|
|
13
|
+
(no pre-activation BN/ReLU), matching the Keras reference architecture.
|
|
14
|
+
All convolutions use valid padding (no padding).
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
in_channels: Number of input filter channels
|
|
18
|
+
out_channels: Number of output filter channels
|
|
19
|
+
dropout_rate: Dropout probability
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
in_channels: int,
|
|
25
|
+
out_channels: int,
|
|
26
|
+
dropout_rate: float = 0.1,
|
|
27
|
+
):
|
|
28
|
+
super().__init__()
|
|
29
|
+
|
|
30
|
+
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=(1, 5))
|
|
31
|
+
self.bn1 = nn.BatchNorm2d(out_channels)
|
|
32
|
+
self.dropout1 = nn.Dropout(dropout_rate)
|
|
33
|
+
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=(1, 5), stride=(1, 2))
|
|
34
|
+
|
|
35
|
+
self.shortcut_pool = nn.MaxPool2d(kernel_size=(1, 9), stride=(1, 2))
|
|
36
|
+
self.shortcut_proj = nn.Conv2d(in_channels, out_channels, kernel_size=1)
|
|
37
|
+
|
|
38
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
39
|
+
out = self.conv1(x)
|
|
40
|
+
out = self.bn1(out)
|
|
41
|
+
out = func.relu(out)
|
|
42
|
+
out = self.dropout1(out)
|
|
43
|
+
out = self.conv2(out)
|
|
44
|
+
|
|
45
|
+
shortcut = self.shortcut_pool(x)
|
|
46
|
+
shortcut = self.shortcut_proj(shortcut)
|
|
47
|
+
|
|
48
|
+
return out + shortcut
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PreActResBlock2D(nn.Module):
|
|
52
|
+
"""
|
|
53
|
+
Pre-activation 2D residual block for blocks 2-4.
|
|
54
|
+
|
|
55
|
+
Follows BN -> ReLU -> Dropout -> Conv -> BN -> ReLU -> Dropout -> Conv(stride=2)
|
|
56
|
+
pattern matching the Keras reference model. All convolutions use valid padding.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
in_channels: Number of input filter channels
|
|
60
|
+
out_channels: Number of output filter channels
|
|
61
|
+
dropout_rate: Dropout probability
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
in_channels: int,
|
|
67
|
+
out_channels: int,
|
|
68
|
+
dropout_rate: float = 0.1,
|
|
69
|
+
):
|
|
70
|
+
super().__init__()
|
|
71
|
+
|
|
72
|
+
self.bn1 = nn.BatchNorm2d(in_channels)
|
|
73
|
+
self.dropout1 = nn.Dropout(dropout_rate)
|
|
74
|
+
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=(1, 5))
|
|
75
|
+
self.bn2 = nn.BatchNorm2d(out_channels)
|
|
76
|
+
self.dropout2 = nn.Dropout(dropout_rate)
|
|
77
|
+
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=(1, 5), stride=(1, 2))
|
|
78
|
+
|
|
79
|
+
self.shortcut_pool = nn.MaxPool2d(kernel_size=(1, 9), stride=(1, 2))
|
|
80
|
+
self.shortcut_proj = (
|
|
81
|
+
nn.Conv2d(in_channels, out_channels, kernel_size=1)
|
|
82
|
+
if in_channels != out_channels
|
|
83
|
+
else nn.Identity()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
87
|
+
out = self.bn1(x)
|
|
88
|
+
out = func.relu(out)
|
|
89
|
+
out = self.dropout1(out)
|
|
90
|
+
out = self.conv1(out)
|
|
91
|
+
|
|
92
|
+
out = self.bn2(out)
|
|
93
|
+
out = func.relu(out)
|
|
94
|
+
out = self.dropout2(out)
|
|
95
|
+
out = self.conv2(out)
|
|
96
|
+
|
|
97
|
+
shortcut = self.shortcut_pool(x)
|
|
98
|
+
shortcut = self.shortcut_proj(shortcut)
|
|
99
|
+
|
|
100
|
+
return out + shortcut
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@register_model(
|
|
104
|
+
name="deep-res-cnn",
|
|
105
|
+
description="Deep Residual 2D CNN (Elyamani et al. 2022) for multi-lead ECG classification",
|
|
106
|
+
)
|
|
107
|
+
class DeepResCNN(nn.Module):
|
|
108
|
+
"""
|
|
109
|
+
Deep Residual 2D CNN for ECG classification.
|
|
110
|
+
|
|
111
|
+
Faithful implementation of Elyamani et al. (2022). Uses Conv2d with (1, k)
|
|
112
|
+
kernels and valid padding to process each ECG lead independently along the
|
|
113
|
+
time axis, then fuses across leads with a (leads, 1) convolution at the end.
|
|
114
|
+
|
|
115
|
+
Input convention: (batch, leads, time) -- standard deepecg-kit format.
|
|
116
|
+
Internally reshaped to (batch, 1, leads, time) for 2D convolution.
|
|
117
|
+
|
|
118
|
+
The classifier head includes L2 regularization matching the original Keras
|
|
119
|
+
model. Call l2_regularization_loss() to obtain the penalty term.
|
|
120
|
+
|
|
121
|
+
Reference:
|
|
122
|
+
https://github.com/HaneenElyamani/ECG-classification
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
input_channels: Number of ECG leads (default: 12)
|
|
126
|
+
output_size: Number of output classes (default: 5)
|
|
127
|
+
dropout_rate: Dropout probability in residual blocks (default: 0.1)
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
>>> model = DeepResCNN(input_channels=12, output_size=5)
|
|
131
|
+
>>> x = torch.randn(32, 12, 1000)
|
|
132
|
+
>>> output = model(x)
|
|
133
|
+
>>> print(output.shape)
|
|
134
|
+
|
|
135
|
+
>>> features = model.extract_features(x)
|
|
136
|
+
>>> print(features.shape) # (32, 128)
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
input_channels: int = 12,
|
|
142
|
+
output_size: int = 5,
|
|
143
|
+
dropout_rate: float = 0.1,
|
|
144
|
+
):
|
|
145
|
+
super().__init__()
|
|
146
|
+
|
|
147
|
+
self.stem_conv = nn.Conv2d(1, 32, kernel_size=(1, 7))
|
|
148
|
+
self.stem_bn = nn.BatchNorm2d(32)
|
|
149
|
+
|
|
150
|
+
self.block1 = StemResBlock2D(32, 64, dropout_rate=dropout_rate)
|
|
151
|
+
self.block2 = PreActResBlock2D(64, 64, dropout_rate=dropout_rate)
|
|
152
|
+
self.block3 = PreActResBlock2D(64, 128, dropout_rate=dropout_rate)
|
|
153
|
+
self.block4 = PreActResBlock2D(128, 128, dropout_rate=dropout_rate)
|
|
154
|
+
|
|
155
|
+
self.lead_fusion = nn.Conv2d(128, 128, kernel_size=(input_channels, 1))
|
|
156
|
+
self.lead_fusion_bn = nn.BatchNorm2d(128)
|
|
157
|
+
self.pool = nn.AdaptiveAvgPool2d(1)
|
|
158
|
+
|
|
159
|
+
self._feature_dim = 128
|
|
160
|
+
|
|
161
|
+
self.fc1 = nn.Linear(128, 128)
|
|
162
|
+
self.fc1_bn = nn.BatchNorm1d(128)
|
|
163
|
+
self.fc1_drop = nn.Dropout(dropout_rate)
|
|
164
|
+
|
|
165
|
+
self.fc2 = nn.Linear(128, 64)
|
|
166
|
+
self.fc2_bn = nn.BatchNorm1d(64)
|
|
167
|
+
self.fc2_drop = nn.Dropout(0.15)
|
|
168
|
+
|
|
169
|
+
self.fc_out = nn.Linear(64, output_size)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def feature_dim(self) -> int:
|
|
173
|
+
return self._feature_dim
|
|
174
|
+
|
|
175
|
+
def l2_regularization_loss(self) -> torch.Tensor:
|
|
176
|
+
"""Return the L2 penalty for the classifier head weights.
|
|
177
|
+
|
|
178
|
+
Matches the Keras kernel_regularizer=L2(lambda) on the two Dense layers.
|
|
179
|
+
Add this to the training loss for full equivalence with the original model.
|
|
180
|
+
"""
|
|
181
|
+
return 0.005 * self.fc1.weight.pow(2).sum() + 0.009 * self.fc2.weight.pow(2).sum()
|
|
182
|
+
|
|
183
|
+
def extract_features(self, x: torch.Tensor) -> torch.Tensor:
|
|
184
|
+
x = x.unsqueeze(1)
|
|
185
|
+
|
|
186
|
+
x = self.stem_conv(x)
|
|
187
|
+
x = self.stem_bn(x)
|
|
188
|
+
x = func.relu(x)
|
|
189
|
+
|
|
190
|
+
x = self.block1(x)
|
|
191
|
+
x = self.block2(x)
|
|
192
|
+
x = self.block3(x)
|
|
193
|
+
x = self.block4(x)
|
|
194
|
+
|
|
195
|
+
x = self.lead_fusion(x)
|
|
196
|
+
x = self.lead_fusion_bn(x)
|
|
197
|
+
x = func.relu(x)
|
|
198
|
+
x = self.pool(x)
|
|
199
|
+
return torch.flatten(x, 1)
|
|
200
|
+
|
|
201
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
202
|
+
x = self.extract_features(x)
|
|
203
|
+
|
|
204
|
+
x = self.fc1(x)
|
|
205
|
+
x = self.fc1_bn(x)
|
|
206
|
+
x = func.relu(x)
|
|
207
|
+
x = self.fc1_drop(x)
|
|
208
|
+
|
|
209
|
+
x = self.fc2(x)
|
|
210
|
+
x = self.fc2_bn(x)
|
|
211
|
+
x = func.relu(x)
|
|
212
|
+
x = self.fc2_drop(x)
|
|
213
|
+
|
|
214
|
+
x = self.fc_out(x)
|
|
215
|
+
return x
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
from torch import nn
|
|
5
|
+
|
|
6
|
+
from deepecgkit.registry import register_model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PositionalEncoding(nn.Module):
|
|
10
|
+
"""
|
|
11
|
+
Sinusoidal positional encoding for transformer branch.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
d_model: Dimension of the model
|
|
15
|
+
max_len: Maximum sequence length (default: 5000)
|
|
16
|
+
dropout: Dropout probability (default: 0.1)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, d_model: int, max_len: int = 5000, dropout: float = 0.1):
|
|
20
|
+
super().__init__()
|
|
21
|
+
self.dropout = nn.Dropout(p=dropout)
|
|
22
|
+
|
|
23
|
+
position = torch.arange(max_len).unsqueeze(1)
|
|
24
|
+
div_term = torch.exp(torch.arange(0, d_model, 2) * (-math.log(10000.0) / d_model))
|
|
25
|
+
pe = torch.zeros(1, max_len, d_model)
|
|
26
|
+
pe[0, :, 0::2] = torch.sin(position * div_term)
|
|
27
|
+
pe[0, :, 1::2] = torch.cos(position * div_term)
|
|
28
|
+
self.register_buffer("pe", pe)
|
|
29
|
+
|
|
30
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
31
|
+
x = x + self.pe[:, : x.size(1)]
|
|
32
|
+
return self.dropout(x)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CNNLSTMBranch(nn.Module):
|
|
36
|
+
"""
|
|
37
|
+
CNN feature extraction followed by LSTM temporal aggregation.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
input_channels: Number of input ECG channels
|
|
41
|
+
cnn_channels: Output channels for CNN stages (default: 128)
|
|
42
|
+
lstm_hidden: LSTM hidden size (default: 128)
|
|
43
|
+
lstm_layers: Number of LSTM layers (default: 1)
|
|
44
|
+
dropout_rate: Dropout probability (default: 0.3)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
input_channels: int,
|
|
50
|
+
cnn_channels: int = 128,
|
|
51
|
+
lstm_hidden: int = 128,
|
|
52
|
+
lstm_layers: int = 1,
|
|
53
|
+
dropout_rate: float = 0.3,
|
|
54
|
+
):
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.lstm_hidden = lstm_hidden
|
|
57
|
+
|
|
58
|
+
self.cnn = nn.Sequential(
|
|
59
|
+
nn.Conv1d(input_channels, 64, kernel_size=7, padding=3),
|
|
60
|
+
nn.BatchNorm1d(64),
|
|
61
|
+
nn.ReLU(),
|
|
62
|
+
nn.MaxPool1d(kernel_size=2),
|
|
63
|
+
nn.Dropout(dropout_rate),
|
|
64
|
+
nn.Conv1d(64, cnn_channels, kernel_size=5, padding=2),
|
|
65
|
+
nn.BatchNorm1d(cnn_channels),
|
|
66
|
+
nn.ReLU(),
|
|
67
|
+
nn.MaxPool1d(kernel_size=2),
|
|
68
|
+
nn.Dropout(dropout_rate),
|
|
69
|
+
nn.Conv1d(cnn_channels, cnn_channels, kernel_size=3, padding=1),
|
|
70
|
+
nn.BatchNorm1d(cnn_channels),
|
|
71
|
+
nn.ReLU(),
|
|
72
|
+
nn.MaxPool1d(kernel_size=2),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
self.lstm = nn.LSTM(
|
|
76
|
+
input_size=cnn_channels,
|
|
77
|
+
hidden_size=lstm_hidden,
|
|
78
|
+
num_layers=lstm_layers,
|
|
79
|
+
batch_first=True,
|
|
80
|
+
dropout=dropout_rate if lstm_layers > 1 else 0,
|
|
81
|
+
bidirectional=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self.output_dim = lstm_hidden * 2
|
|
85
|
+
|
|
86
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
87
|
+
batch_size = x.size(0)
|
|
88
|
+
x = self.cnn(x)
|
|
89
|
+
x = x.transpose(1, 2)
|
|
90
|
+
_, (h_n, _) = self.lstm(x)
|
|
91
|
+
h_n = h_n.view(-1, 2, batch_size, self.lstm_hidden)
|
|
92
|
+
h_n = h_n[-1]
|
|
93
|
+
return torch.cat([h_n[0], h_n[1]], dim=1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TransformerBranch(nn.Module):
|
|
97
|
+
"""
|
|
98
|
+
Transformer encoder for global temporal attention.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
input_channels: Number of input ECG channels
|
|
102
|
+
d_model: Transformer model dimension (default: 128)
|
|
103
|
+
nhead: Number of attention heads (default: 8)
|
|
104
|
+
num_layers: Number of transformer encoder layers (default: 2)
|
|
105
|
+
dim_feedforward: Feedforward dimension (default: 256)
|
|
106
|
+
dropout_rate: Dropout probability (default: 0.1)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
input_channels: int,
|
|
112
|
+
d_model: int = 128,
|
|
113
|
+
nhead: int = 8,
|
|
114
|
+
num_layers: int = 2,
|
|
115
|
+
dim_feedforward: int = 256,
|
|
116
|
+
dropout_rate: float = 0.1,
|
|
117
|
+
):
|
|
118
|
+
super().__init__()
|
|
119
|
+
|
|
120
|
+
self.input_projection = nn.Linear(input_channels, d_model)
|
|
121
|
+
self.positional_encoding = PositionalEncoding(d_model, dropout=dropout_rate)
|
|
122
|
+
|
|
123
|
+
encoder_layer = nn.TransformerEncoderLayer(
|
|
124
|
+
d_model=d_model,
|
|
125
|
+
nhead=nhead,
|
|
126
|
+
dim_feedforward=dim_feedforward,
|
|
127
|
+
dropout=dropout_rate,
|
|
128
|
+
batch_first=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
|
|
132
|
+
self.pool = nn.AdaptiveAvgPool1d(1)
|
|
133
|
+
self.output_dim = d_model
|
|
134
|
+
|
|
135
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
136
|
+
x = x.transpose(1, 2)
|
|
137
|
+
x = self.input_projection(x)
|
|
138
|
+
x = self.positional_encoding(x)
|
|
139
|
+
x = self.encoder(x)
|
|
140
|
+
x = x.transpose(1, 2)
|
|
141
|
+
x = self.pool(x)
|
|
142
|
+
return x.squeeze(-1)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@register_model(
|
|
146
|
+
name="dualnet",
|
|
147
|
+
description="Dual-path architecture with CNN-LSTM and Transformer branches for ECG",
|
|
148
|
+
)
|
|
149
|
+
class ECGDualNet(nn.Module):
|
|
150
|
+
"""
|
|
151
|
+
Dual-path ECG classification network.
|
|
152
|
+
|
|
153
|
+
Runs a CNN-LSTM branch and a Transformer branch in parallel,
|
|
154
|
+
then fuses their outputs via concatenation and a fusion head.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
input_channels: Number of input channels (default: 1 for single-lead ECG)
|
|
158
|
+
output_size: Number of output classes
|
|
159
|
+
cnn_channels: CNN output channels in CNN-LSTM branch (default: 128)
|
|
160
|
+
lstm_hidden: LSTM hidden size in CNN-LSTM branch (default: 128)
|
|
161
|
+
lstm_layers: Number of LSTM layers (default: 1)
|
|
162
|
+
d_model: Transformer model dimension (default: 128)
|
|
163
|
+
nhead: Number of attention heads (default: 8)
|
|
164
|
+
transformer_layers: Number of transformer encoder layers (default: 2)
|
|
165
|
+
dim_feedforward: Transformer feedforward dimension (default: 256)
|
|
166
|
+
dropout_rate: Dropout probability (default: 0.3)
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> model = ECGDualNet(input_channels=1, output_size=4)
|
|
170
|
+
>>> x = torch.randn(32, 1, 3000)
|
|
171
|
+
>>> output = model(x)
|
|
172
|
+
>>> print(output.shape)
|
|
173
|
+
|
|
174
|
+
>>> features = model.extract_features(x)
|
|
175
|
+
>>> print(features.shape) # (32, 384)
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
input_channels: int = 1,
|
|
181
|
+
output_size: int = 4,
|
|
182
|
+
cnn_channels: int = 128,
|
|
183
|
+
lstm_hidden: int = 128,
|
|
184
|
+
lstm_layers: int = 1,
|
|
185
|
+
d_model: int = 128,
|
|
186
|
+
nhead: int = 8,
|
|
187
|
+
transformer_layers: int = 2,
|
|
188
|
+
dim_feedforward: int = 256,
|
|
189
|
+
dropout_rate: float = 0.3,
|
|
190
|
+
):
|
|
191
|
+
super().__init__()
|
|
192
|
+
|
|
193
|
+
self.cnn_lstm_branch = CNNLSTMBranch(
|
|
194
|
+
input_channels=input_channels,
|
|
195
|
+
cnn_channels=cnn_channels,
|
|
196
|
+
lstm_hidden=lstm_hidden,
|
|
197
|
+
lstm_layers=lstm_layers,
|
|
198
|
+
dropout_rate=dropout_rate,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
self.transformer_branch = TransformerBranch(
|
|
202
|
+
input_channels=input_channels,
|
|
203
|
+
d_model=d_model,
|
|
204
|
+
nhead=nhead,
|
|
205
|
+
num_layers=transformer_layers,
|
|
206
|
+
dim_feedforward=dim_feedforward,
|
|
207
|
+
dropout_rate=dropout_rate,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
self._feature_dim = self.cnn_lstm_branch.output_dim + self.transformer_branch.output_dim
|
|
211
|
+
|
|
212
|
+
self.classifier = nn.Sequential(
|
|
213
|
+
nn.Linear(self._feature_dim, 128),
|
|
214
|
+
nn.ReLU(),
|
|
215
|
+
nn.Dropout(dropout_rate),
|
|
216
|
+
nn.Linear(128, output_size),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def feature_dim(self) -> int:
|
|
221
|
+
return self._feature_dim
|
|
222
|
+
|
|
223
|
+
def extract_features(self, x: torch.Tensor) -> torch.Tensor:
|
|
224
|
+
cnn_lstm_out = self.cnn_lstm_branch(x)
|
|
225
|
+
transformer_out = self.transformer_branch(x)
|
|
226
|
+
return torch.cat([cnn_lstm_out, transformer_out], dim=1)
|
|
227
|
+
|
|
228
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
229
|
+
x = self.extract_features(x)
|
|
230
|
+
return self.classifier(x)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from torch import nn
|
|
3
|
+
|
|
4
|
+
from deepecgkit.registry import register_model
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register_model(
|
|
8
|
+
name="fcn-wang",
|
|
9
|
+
description="Fully Convolutional Network (Wang et al.) for time series classification",
|
|
10
|
+
)
|
|
11
|
+
class FCNWang(nn.Module):
|
|
12
|
+
"""
|
|
13
|
+
Fully Convolutional Network for ECG signal classification.
|
|
14
|
+
|
|
15
|
+
Based on Wang et al.'s FCN architecture for time series classification,
|
|
16
|
+
using three convolutional blocks with batch normalization and global
|
|
17
|
+
average pooling. No dense layers except the final classifier.
|
|
18
|
+
|
|
19
|
+
Reference:
|
|
20
|
+
Wang Z., Yan W., Oates T. "Time Series Classification from Scratch
|
|
21
|
+
with Deep Neural Networks: A Strong Baseline" (2017)
|
|
22
|
+
https://github.com/helme/ecg_ptbxl_benchmarking
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
input_channels: Number of input channels (default: 12 for 12-lead ECG)
|
|
26
|
+
output_size: Number of output classes
|
|
27
|
+
filters: List of filter counts for each conv block (default: [128, 256, 128])
|
|
28
|
+
kernel_sizes: List of kernel sizes for each conv block (default: [8, 5, 3])
|
|
29
|
+
dropout_rate: Dropout probability before classifier (default: 0.3)
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> model = FCNWang(input_channels=12, output_size=5)
|
|
33
|
+
>>> x = torch.randn(32, 12, 1000)
|
|
34
|
+
>>> output = model(x)
|
|
35
|
+
>>> print(output.shape)
|
|
36
|
+
|
|
37
|
+
>>> features = model.extract_features(x)
|
|
38
|
+
>>> print(features.shape) # (32, 128)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
input_channels: int = 12,
|
|
44
|
+
output_size: int = 5,
|
|
45
|
+
filters: list[int] | None = None,
|
|
46
|
+
kernel_sizes: list[int] | None = None,
|
|
47
|
+
dropout_rate: float = 0.3,
|
|
48
|
+
):
|
|
49
|
+
super().__init__()
|
|
50
|
+
|
|
51
|
+
if filters is None:
|
|
52
|
+
filters = [128, 256, 128]
|
|
53
|
+
if kernel_sizes is None:
|
|
54
|
+
kernel_sizes = [8, 5, 3]
|
|
55
|
+
|
|
56
|
+
assert len(filters) == len(kernel_sizes)
|
|
57
|
+
|
|
58
|
+
blocks = []
|
|
59
|
+
in_ch = input_channels
|
|
60
|
+
for nf, ks in zip(filters, kernel_sizes):
|
|
61
|
+
blocks.append(
|
|
62
|
+
nn.Sequential(
|
|
63
|
+
nn.Conv1d(in_ch, nf, kernel_size=ks, padding=(ks - 1) // 2, bias=False),
|
|
64
|
+
nn.BatchNorm1d(nf),
|
|
65
|
+
nn.ReLU(inplace=True),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
in_ch = nf
|
|
69
|
+
|
|
70
|
+
self.conv_blocks = nn.Sequential(*blocks)
|
|
71
|
+
self.global_pool = nn.AdaptiveAvgPool1d(1)
|
|
72
|
+
self.dropout = nn.Dropout(dropout_rate)
|
|
73
|
+
self._feature_dim = filters[-1]
|
|
74
|
+
self.classifier = nn.Linear(self._feature_dim, output_size)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def feature_dim(self) -> int:
|
|
78
|
+
return self._feature_dim
|
|
79
|
+
|
|
80
|
+
def extract_features(self, x: torch.Tensor) -> torch.Tensor:
|
|
81
|
+
x = self.conv_blocks(x)
|
|
82
|
+
x = self.global_pool(x)
|
|
83
|
+
return torch.flatten(x, 1)
|
|
84
|
+
|
|
85
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
86
|
+
x = self.extract_features(x)
|
|
87
|
+
x = self.dropout(x)
|
|
88
|
+
return self.classifier(x)
|