deepecgkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. deepecgkit/__init__.py +42 -0
  2. deepecgkit/__main__.py +6 -0
  3. deepecgkit/cli/__init__.py +61 -0
  4. deepecgkit/cli/commands/__init__.py +16 -0
  5. deepecgkit/cli/commands/evaluate.py +83 -0
  6. deepecgkit/cli/commands/info.py +73 -0
  7. deepecgkit/cli/commands/predict.py +94 -0
  8. deepecgkit/cli/commands/train.py +331 -0
  9. deepecgkit/cli/config.py +23 -0
  10. deepecgkit/cli/evaluation.py +251 -0
  11. deepecgkit/cli/logger.py +26 -0
  12. deepecgkit/cli/main.py +429 -0
  13. deepecgkit/cli/registry.py +32 -0
  14. deepecgkit/datasets/__init__.py +33 -0
  15. deepecgkit/datasets/af_classification.py +301 -0
  16. deepecgkit/datasets/base.py +146 -0
  17. deepecgkit/datasets/ltafdb.py +363 -0
  18. deepecgkit/datasets/mitbih_afdb.py +287 -0
  19. deepecgkit/datasets/modules.py +222 -0
  20. deepecgkit/datasets/preprocessing.py +208 -0
  21. deepecgkit/datasets/ptbxl.py +567 -0
  22. deepecgkit/datasets/splitting.py +225 -0
  23. deepecgkit/datasets/unified_af.py +316 -0
  24. deepecgkit/evaluation/__init__.py +40 -0
  25. deepecgkit/evaluation/evaluator.py +398 -0
  26. deepecgkit/evaluation/metrics.py +164 -0
  27. deepecgkit/evaluation/visualization.py +285 -0
  28. deepecgkit/models/__init__.py +51 -0
  29. deepecgkit/models/af_classifier.py +177 -0
  30. deepecgkit/models/convnext_v2_1d.py +183 -0
  31. deepecgkit/models/crnn.py +117 -0
  32. deepecgkit/models/deep_res_cnn.py +215 -0
  33. deepecgkit/models/ecg_dualnet.py +230 -0
  34. deepecgkit/models/fcn_wang.py +88 -0
  35. deepecgkit/models/gru_model.py +99 -0
  36. deepecgkit/models/inception_time.py +197 -0
  37. deepecgkit/models/kanres_wide_x.py +150 -0
  38. deepecgkit/models/kanres_x.py +126 -0
  39. deepecgkit/models/lstm_model.py +96 -0
  40. deepecgkit/models/mamba1d.py +232 -0
  41. deepecgkit/models/medformer.py +229 -0
  42. deepecgkit/models/resnet1d.py +189 -0
  43. deepecgkit/models/resnet1d_wang.py +182 -0
  44. deepecgkit/models/se_resnet1d.py +232 -0
  45. deepecgkit/models/simple_cnn.py +82 -0
  46. deepecgkit/models/tcn.py +178 -0
  47. deepecgkit/models/transformer_ecg.py +127 -0
  48. deepecgkit/models/xresnet1d.py +265 -0
  49. deepecgkit/models/xresnet1d_benchmark.py +227 -0
  50. deepecgkit/registry.py +97 -0
  51. deepecgkit/training/__init__.py +9 -0
  52. deepecgkit/training/train.py +171 -0
  53. deepecgkit/utils/__init__.py +72 -0
  54. deepecgkit/utils/download.py +123 -0
  55. deepecgkit/utils/weights.py +107 -0
  56. deepecgkit-0.1.0.dist-info/METADATA +99 -0
  57. deepecgkit-0.1.0.dist-info/RECORD +61 -0
  58. deepecgkit-0.1.0.dist-info/WHEEL +5 -0
  59. deepecgkit-0.1.0.dist-info/entry_points.txt +2 -0
  60. deepecgkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  61. deepecgkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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