Skip to content

Commit 42a23af

Browse files
authored
Merge pull request #38 from DeepPSP/arch-preproc-pytorch
Enhance the preprocessors module with pure pytorch implementations
2 parents 1377dc5 + 81264be commit 42a23af

10 files changed

Lines changed: 426 additions & 76 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,5 @@ log
162162
*.safetensors
163163

164164
tmp*
165+
166+
.github/copilot-instructions.md

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Changed
3434
`torch_ecg.utils.download` module.
3535
- Add length verification in the `http_get` function in
3636
`torch_ecg.utils.download` module.
37+
- Enhance the pytorch `preprocessors` module: all preprocessors now have
38+
pure PyTorch implementations, and NumPy fallbacks for NumPy array inputs.
3739

3840
Deprecated
3941
~~~~~~~~~~

ROADMAP.md

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,47 +4,68 @@ This document outlines the architectural upgrades and future development plans f
44

55
## Architectural Upgrades
66

7-
### 1. Introduction of Registry Pattern
7+
### 1. Introduction of Registry Pattern ✅ Done (PR #29)
88
**Objective**: Eliminate lengthy `if-elif` branches in downstream models like `ECG_CRNN` to enhance code maintainability and extensibility.
9-
- **Current State**: Adding a new backbone currently requires modifying multiple files, including `models/ecg_crnn.py`.
9+
- **Status**: Done. `MODELS`, `BACKBONES`, `ATTN_LAYERS` registries are implemented in `models/registry.py`; `OPTIMIZERS`, `SCHEDULERS`, `LOSSES` in `components/registry.py`; `PREPROCESSORS` in `preprocessors/registry.py`. All CNN backbones and downstream models use `@BACKBONES.register()` / `@MODELS.register()` decorators. `Registry.build(name, **kwargs)` is the unified construction interface. Adding a new backbone no longer requires modifying `models/ecg_crnn.py` or any other existing file.
1010
- **Strategy**: Implement a registry mechanism similar to the one used in the `fl-sim` library. Establish `BACKBONES`, `MODELS`, and `SSL` registries.
1111
- Use decorators like `@register_backbone("resnet")` to register modules.
1212
- Use a unified `BACKBONES.build(name, **kwargs)` method for module instantiation.
1313
- **Benefits**: Decouples model definition from construction logic, making it easier for both maintainers and users to inject custom backbones.
1414

15-
### 2. Standardized Backbone API
15+
### 2. Standardized Backbone API ✅ Done (PR #30)
1616
**Objective**: Provide a unified feature extraction interface for Self-Supervised Learning (SSL) and multi-task learning.
17+
- **Status**: Done. All CNN backbones (`ResNet`, `VGG16`, `DenseNet`, `MobileNetV1/V2/V3`, `MultiScopicCNN`, `RegNet`, `Xception`) and the `Transformer` now implement `forward_features(x)` (returns feature maps before the classifier head) and `compute_features_output_shape(seq_len, batch_size)` for shape inference without running a forward pass.
1718
- **Strategy**: Follow the convention used in the `timm` library by providing a `forward_features(x)` method for all CNN and Transformer backbones.
1819
- **Features**:
1920
- Unified return of feature maps instead of classification logits.
2021
- Support for accessing intermediate activations for feature fusion or saliency analysis (e.g., Grad-CAM).
2122

22-
### 3. Leveraging Lazy Modules for Configuration Optimization
23+
### 3. Leveraging Lazy Modules for Configuration Optimization ⬜ Not started
2324
**Objective**: Reduce the burden of manually calculating and specifying `in_channels` in configuration files.
2425
- **Strategy**: Introduce `nn.LazyLinear` or `nn.LazyConv1d` in complex SSL modules.
2526
- **Benefits**: Simplifies `model_configs` by allowing the model to automatically infer input dimensions during the first forward pass, reducing boilerplate code.
2627

27-
### 4. Consolidation and Optimization of Preprocessors and Augmenters
28+
### 4. Consolidation and Optimization of Preprocessors and Augmenters 🔄 In Progress
2829
**Objective**: Eliminate redundancy between NumPy and PyTorch implementations and optimize performance by keeping computations on the GPU.
29-
- **Pure PyTorch Filtering**: Implement `BandPass` and `BaselineRemove` using pure PyTorch (e.g., using `torchaudio.functional` or custom FFT-based filters) to avoid expensive CPU-GPU data transfers.
30-
- **Unification of Managers**: Refactor `PreprocManager` and `AugmenterManager` to share a common base or registry, as their logic for managing sequences of transforms is very similar.
31-
- **Dimension Agnostic Transforms**: Ensure all preprocessors and augmenters can handle arbitrary batch and lead dimensions (using `...` in slicing and einops where possible), reducing the need for functions like `preprocess_multi_lead_signal`.
32-
- **Numpy Version Maintenance**: Keep `_preprocessors` (NumPy version) only for offline data preparation or deployment environments without PyTorch, while making the PyTorch `preprocessors` the primary choice for training pipelines.
30+
- **Pure PyTorch Filtering** ✅: `BandPass` now uses a zero-phase FFT-based filter and `BaselineRemove` uses dual `avg_pool1d`, both implemented in `utils/utils_signal_t.py`. No more CPU-GPU data transfers.
31+
- **Unification of Managers**: Refactor `PreprocManager` and `AugmenterManager` to share a common base or registry, as their logic for managing sequences of transforms is very similar.
32+
- **Dimension Agnostic Transforms** ⬜ (augmenters pending): Preprocessors now handle arbitrary leading batch dimensions (`..., n_leads, siglen`). Augmenters still need to be updated.
33+
- **Numpy Version Maintenance** ✅: `_preprocessors` (NumPy) is kept only for offline data preparation or deployment environments without PyTorch, while making the PyTorch `preprocessors` the primary choice for training pipelines.
3334

34-
### 5. Pandas 3.0 Migration and Dtype Consistency
35+
### 5. Pandas 3.0 Migration and Dtype Consistency ⬜ Not started
3536
**Objective**: Ensure compatibility with Pandas 3.0+, particularly concerning Arrow-backed strings and stricter type checking.
3637
- **Explicit Object Dtypes**: Explicitly set `dtype=object` for DataFrame columns intended to hold list-like objects (e.g., diagnoses, available signals) to prevent errors when Arrow-backed strings are used.
3738
- **Initialization Refactoring**: Replace patterns of initializing columns with `None` or `""` and then populating with lists via `.at[]` with more robust initializations like `[[] for _ in range(len(df))]` or using `.apply()`.
3839
- **Vectorized Operations**: Prefer `.apply()` or other vectorized pandas operations over `iterrows()` loops for better performance and type consistency.
3940

4041
---
4142

42-
## Self-Supervised Learning (SSL) Roadmap
43+
## Complete Incomplete Models ⬜
4344

45+
Several model files in `torch_ecg/models/` are currently stubs (`raise NotImplementedError` throughout). All stubs already inherit the correct mixins (`SizeMixin`, `CitationMixin`) and have backbone API signatures (`forward_features`, `compute_features_output_shape`) scaffolded. These should be completed before the SSL phase.
46+
47+
| File | Classes | Notes |
48+
|---|---|---|
49+
| `models/cnn/darknet.py` | `DarkNet` | Backbone for YOLO-style detection |
50+
| `models/cnn/efficientnet.py` | `EfficientNet`, `EfficientNetV2` | Mobile-efficient compound scaling |
51+
| `models/cnn/ho_resnet.py` | `MidPointResNet`, `RK4ResNet`, `RK8ResNet` | Higher-Order ODE ResNets |
52+
| `models/grad_cam.py` | `GradCam` | Saliency analysis (logic partially drafted, not wired up) |
53+
| `models/ecg_fcn.py` | `ECG_FCN` | Fully-convolutional segmentation (fully commented out) |
54+
55+
---
56+
57+
## Self-Supervised Learning (SSL) Roadmap ⬜
58+
59+
The `torch_ecg/ssl/` module is an empty shell. `ssl/README.md` contains a survey of target architectures (CLOCS, ST-MEM, MAE-ECG, SimCLR, TF-C, 3M-ECG, CMSC, ECG-BERT).
60+
61+
**Prerequisites**: Item 3 (Lazy Modules) and the model stubs above should be completed first.
62+
63+
- [ ] Define base classes: `BaseContrastiveLearner`, `BaseMaskedAutoencoder` (in `ssl/base.py`).
4464
- [ ] Implement a base contrastive learning framework (supporting paradigms like SimCLR, MoCo).
4565
- [ ] Implement Masked Autoencoder (MAE) logic optimized for 1D physiological signals.
4666
- [ ] Official implementation of classic models: CLOCS, ST-MEM, MAE-ECG.
4767
- [ ] Provide a unified Fine-tuning API for seamless integration with downstream tasks in `torch_ecg.models`.
68+
- [ ] Add `SSL` registry (analogous to `MODELS`, `BACKBONES`).
4869

4970
---
5071

test/test_preproc_perf.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""
2+
Performance and Numerical Consistency Benchmarking for Preprocessors.
3+
"""
4+
5+
import time
6+
7+
import numpy as np
8+
import pytest
9+
import torch
10+
11+
from torch_ecg.preprocessors import BandPass, BaselineRemove
12+
13+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
14+
15+
16+
def generate_test_signal(batch_size=32, n_leads=12, seq_len=5000, fs=500):
17+
# Sinusoidal signal + baseline drift + noise
18+
t = torch.linspace(0, seq_len / fs, seq_len)
19+
# Pure signal (10 Hz)
20+
sig = torch.sin(2 * np.pi * 10 * t)
21+
# Baseline drift (0.1 Hz)
22+
drift = 2.0 * torch.sin(2 * np.pi * 0.1 * t)
23+
# Noise
24+
noise = 0.5 * torch.randn(seq_len)
25+
26+
base_sig = sig + drift + noise
27+
# Expand to batch and leads
28+
batch_sig = base_sig.unsqueeze(0).unsqueeze(0).repeat(batch_size, n_leads, 1)
29+
return batch_sig
30+
31+
32+
def test_bandpass_consistency():
33+
fs = 500
34+
lowcut, highcut = 0.5, 45
35+
sig_torch = generate_test_signal(batch_size=4, seq_len=10000, fs=fs).to(DEVICE)
36+
sig_np = sig_torch.cpu().numpy()
37+
38+
# 1. SciPy version (via legacy path)
39+
bp = BandPass(fs=fs, lowcut=lowcut, highcut=highcut)
40+
out_np = bp._forward_numpy(sig_np)
41+
42+
# 2. Torch version
43+
out_torch = bp._forward_torch(sig_torch)
44+
45+
# Check consistency (FFT filter and IIR filter won't be identical, but should be close in passband)
46+
# We use MSE as a loose metric because frequency domain zeroing != time domain recursion
47+
diff = np.abs(out_np - out_torch.cpu().numpy())
48+
mse = np.mean(diff**2)
49+
print(f"\nBandPass Consistency (MSE): {mse:.6f}")
50+
# FFT filtering is usually "cleaner" than IIR, so some difference is expected
51+
assert mse < 0.1
52+
53+
54+
def test_baseline_remove_consistency():
55+
fs = 500
56+
window1, window2 = 0.2, 0.6
57+
sig_torch = generate_test_signal(batch_size=4, seq_len=10000, fs=fs).to(DEVICE)
58+
sig_np = sig_torch.cpu().numpy()
59+
60+
br = BaselineRemove(fs=fs, window1=window1, window2=window2)
61+
62+
# 1. SciPy version (Median Filter)
63+
out_np = br._forward_numpy(sig_np)
64+
65+
# 2. Torch version (Sliding Average)
66+
out_torch = br._forward_torch(sig_torch)
67+
68+
diff = np.abs(out_np - out_torch.cpu().numpy())
69+
mse = np.mean(diff**2)
70+
print(f"\nBaselineRemove Consistency (MSE): {mse:.6f}")
71+
# Median filter and Sliding average are different algorithms, but should achieve same goal
72+
assert mse < 0.5
73+
74+
75+
@pytest.mark.skipif(not torch.cuda.is_available(), reason="GPU performance test requires CUDA")
76+
def test_preproc_performance():
77+
batch_size = 128
78+
n_leads = 12
79+
seq_len = 5000
80+
fs = 500
81+
sig_torch = generate_test_signal(batch_size, n_leads, seq_len, fs).to(DEVICE)
82+
83+
bp = BandPass(fs=fs, lowcut=0.5, highcut=45).to(DEVICE)
84+
br = BaselineRemove(fs=fs).to(DEVICE)
85+
86+
# Warmup
87+
_ = bp(sig_torch)
88+
_ = br(sig_torch)
89+
torch.cuda.synchronize()
90+
91+
# 1. Performance of Torch path
92+
start = time.time()
93+
for _ in range(10):
94+
_ = bp._forward_torch(sig_torch)
95+
_ = br._forward_torch(sig_torch)
96+
torch.cuda.synchronize()
97+
torch_time = (time.time() - start) / 10
98+
99+
# 2. Performance of NumPy path (includes data transfer simulation)
100+
start = time.time()
101+
for _ in range(10):
102+
# Simulation of old way: move to CPU -> proc -> move back
103+
tmp = sig_torch.cpu().numpy()
104+
_ = bp._forward_numpy(tmp)
105+
_ = br._forward_numpy(tmp)
106+
_ = torch.as_tensor(tmp).to(DEVICE)
107+
numpy_time = (time.time() - start) / 10
108+
109+
speedup = numpy_time / torch_time
110+
print(f"\nPerformance Result (Batch size {batch_size}):")
111+
print(f"NumPy Path (with transfers): {numpy_time:.4f}s")
112+
print(f"Torch Path (on GPU): {torch_time:.4f}s")
113+
print(f"Speedup: {speedup:.2f}x")
114+
115+
assert speedup > 2.0
116+
117+
118+
if __name__ == "__main__":
119+
pytest.main([__file__])

test/test_preprocessors_t.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
""" """
22

3+
import numpy as np
34
import pytest
45
import torch
56

@@ -16,6 +17,7 @@
1617
)
1718

1819
test_sig = torch.randn(2, 12, 8000)
20+
test_sig_np = test_sig.cpu().numpy()
1921

2022

2123
class DummyPreProcessor(torch.nn.Module):
@@ -40,8 +42,15 @@ def test_preproc_manager() -> None:
4042
ppm.add_(Normalize(method="min-max"))
4143
ppm.add_(Resample(fs=300, dst_fs=500), pos=0)
4244

45+
# Test with torch.Tensor
4346
sig = test_sig.clone()
4447
sig = ppm(sig)
48+
assert isinstance(sig, torch.Tensor)
49+
50+
# Test with np.ndarray
51+
sig_np = test_sig_np.copy()
52+
sig_np = ppm(sig_np)
53+
assert isinstance(sig_np, np.ndarray)
4554

4655
config = CFG(
4756
random=False,
@@ -108,8 +117,14 @@ def test_preproc_manager() -> None:
108117

109118
def test_bandpass() -> None:
110119
bp = BandPass(fs=500)
120+
# Tensor
111121
sig = test_sig.clone()
112122
sig = bp(sig)
123+
assert isinstance(sig, torch.Tensor)
124+
# ndarray
125+
sig_np = test_sig_np.copy()
126+
sig_np = bp(sig_np)
127+
assert isinstance(sig_np, np.ndarray)
113128

114129
bp = BandPass(fs=500, lowcut=0, highcut=40)
115130
sig = test_sig.clone()
@@ -124,8 +139,14 @@ def test_bandpass() -> None:
124139

125140
def test_baseline_remove() -> None:
126141
br = BaselineRemove(fs=500, inplace=False)
142+
# Tensor
127143
sig = test_sig.clone()
128144
sig = br(sig)
145+
assert isinstance(sig, torch.Tensor)
146+
# ndarray
147+
sig_np = test_sig_np.copy()
148+
sig_np = br(sig_np)
149+
assert isinstance(sig_np, np.ndarray)
129150

130151
br = BaselineRemove(fs=500, window1=0.3, window2=0.7)
131152
sig = test_sig.clone()
@@ -139,8 +160,14 @@ def test_baseline_remove() -> None:
139160

140161
def test_normalize() -> None:
141162
norm = Normalize(method="min-max", inplace=False)
163+
# Tensor
142164
sig = test_sig.clone()
143165
sig = norm(sig)
166+
assert isinstance(sig, torch.Tensor)
167+
# ndarray
168+
sig_np = test_sig_np.copy()
169+
sig_np = norm(sig_np)
170+
assert isinstance(sig_np, np.ndarray)
144171

145172
norm = Normalize(method="z-score")
146173
sig = test_sig.clone()
@@ -159,3 +186,17 @@ def test_normalize() -> None:
159186
sig = norm(sig)
160187

161188
del norm, sig
189+
190+
191+
def test_resample() -> None:
192+
rsmp = Resample(fs=500, dst_fs=300)
193+
# Tensor
194+
sig = test_sig.clone()
195+
sig = rsmp(sig)
196+
assert isinstance(sig, torch.Tensor)
197+
# ndarray
198+
sig_np = test_sig_np.copy()
199+
sig_np = rsmp(sig_np)
200+
assert isinstance(sig_np, np.ndarray)
201+
202+
del rsmp, sig

0 commit comments

Comments
 (0)