This is an adaption of the Fairseq-signals for Heartwise for fine tuning DeepECG-SSL
It is highly recommended to create a dedicated conda environment before installing the following libraries
$ conda create --name fairseq python=3.9
$ conda activate fairseq
- PyTorch version >= 1.5.0
- Python version >= 3.6, and <= 3.9
- For training new models, you'll also need an NVIDIA GPU and NCCL
- To install fairseq-signals from source and develop locally:
git clone https://github.com/HeartWise-AI/fairseq-signals
cd fairseq-signals
pip install pip==24.0
pip install --editable ./
pip install omegaconf==2.0.5 hydra-core==1.0.4- To preprocess ECG datasets:
pip install pandas scipy wfdb - To build cython components:
python setup.py build_ext --inplace - For large datasets install PyArrow:
pip install pyarrow
For general commands on ECG preprocessing, model pretraining, and other fairseq-signals specifics, please check Fairseq-signals.
Here we will focus on explaining how to train your own classification models either end-to-end or with initialization from DeepECG-SSL weights (both linear probing and fine-tuning), run inference with models within fairseq-signals, and save models in ONNX format so they can be used elsewhere for inference.
To get the best of DeepECG-SSL, please make sure you prepreocessed ECGs as described by Nolin-Lapalme et al..
A docker with the preprocessing pipeline can be find here
This will update all the weights (base model + classification head).
$ CUDA_VISIBLE_DEVICES=[device_num] fairseq-hydra-train common.fp16=[use_fp16] task.data=[manifest_folder] \
model.model_path=[deepecg-ssl-path] +task.npy_dataset=true model.num_labels=[num_labels] \
criterion._name=[loss_name] checkpoint.save_dir=[checkpoint_dir] \
--config-dir examples/w2v_cmsc/config/finetuning/ecg_transformer --config-name diagnosis[device_num]should be an integer representing the CUDA GPU number (starting at 0)[use_fp16]should be eithertrueforfalse. When finetuningDeepECG-SSL, usetrue[manifest_folder]is a folder containing at least two manifest filestrain.tsvandvalid.tsv. In the sectionManifest structurewe will give an example of these files.[deepecg-ssl-path]is the.ptcorresponding toDeepECG-SSLbase model. Thevalidset is used to early-stop the fine-tuning.[+task.npy_dataset=true]in case your training/validation data are save in a panda dataframe, replace this with[+task.df_dataset=true].[num_labels]is an integer corresponding to the number of target classes in the classification. For binary classification,[num_labels] = 1.[loss_name]corresponds to the loss function. Possible values areas(asymetric loss),bf(binary focal loss),mse(mean square error loss),bce(binary cross entropy with logits loss), andmlsml(multilabel soft marginal loss).[checkpoint_dir]is the checkpoint directory subfolder name inside the folder/outputs/. fairseq-signals automatically organizes folders using the date and time when they are created.
This will freeze the base model weights and only update classification weights
$ CUDA_VISIBLE_DEVICES=[device_num] fairseq-hydra-train common.fp16=[use_fp16] task.data=[manifest_folder] \
model.model_path=[deepecg-ssl-path] model.linear_evaluation=true +task.npy_dataset=[npy_dataset] \
model.num_labels=[num_labels] criterion._name=[loss_name] checkpoint.save_dir=[checkpoint_dir] \
--config-dir examples/w2v_cmsc/config/finetuning/ecg_transformer --config-name diagnosisAll parameters are the same as for fine-tuning, except model.linear_evaluation whose value is true
In case you want to initialize all the weights (base transformer + classification head) randomly in order to perform end-to-end training, you the following.
$ CUDA_VISIBLE_DEVICES=[device_num] fairseq-hydra-train common.fp16=[use_fp16] task.data=[manifest_folder] \
model.no_pretrained_weights=true +task.npy_dataset=[npy_dataset] model.num_labels=[num_labels] \
criterion._name=[loss_name] checkpoint.save_dir=[checkpoint_dir] \
--config-dir examples/w2v_cmsc/config/finetuning/ecg_transformer --config-name diagnosisAll parameters are the same as for finetuning, expect model.no_pretrained_weights=true and [deepecg-ssl-path], the base model path, which is not set, as it is not needed.
Once you have trained your model, either by fine-tuning, linear probing or end-to-end, the final checkpoint is saved in the corresponding directory (specified with [checkpoint_dir])
Now you can run the inference on a given .tsv file that specifies your test data.
$ CUDA_VISIBLE_DEVICES=[device_num] fairseq-hydra-inference task.data=[manifest_folder] \
common_eval.path=[model_to_evaluate] common_eval.results_path=[results_path] \
task.npy_dataset=true model.num_labels=[num_labels] dataset.valid_subset=[test_file] \
--config-dir examples/w2v_cmsc/config/finetuning/ecg_transformer --config-name eval[model_to_evaluate]correspond to the.ptof the model we trained and we want to evaluate now[results_path], usually set to the same value as[model_to_evaluate]correspond to the path were the inference logits will be saved[test_file]correspond to the.tsvwe want to evaluate. Usually it is simpletest.tsv
To be able to run inference on the trained models outside fairseq-signals envirnoment, we can use the following command.
$ CUDA_VISIBLE_DEVICES=[device_num] fairseq-hydra-save common.fp16=[use_fp16] \
common_eval.path=[model_to_evaluate] model.num_labels=[num_labels] \
--config-dir examples/w2v_cmsc/config/finetuning/ecg_transformer --config-name evalA file named model.onnx will be saved in the same folder as [model_to_evaluate]
Having this file, we can run it everywhere using the following code.
import onnxruntime as ort
import numpy as np
model_name = 'model.onnx'
def get_session(filename, use_gpu=False):
if use_gpu:
return ort.InferenceSession(filename, providers=["CUDAExecutionProvider"])
else:
return ort.InferenceSession(filename, providers=["CPUExecutionProvider"])
def run_session(session, X):
"""
session: orn session, obtained from get_session
X: numpy array of shape (batch_size, 12, 2500) and dtype np.float16 ideally
"""
input = {
session.get_inputs()[0].name: X,
}
output = session.run(None, input)
return output
x = 0.00488*np.transpose(np.squeeze(X[0:15]), (0, 2, 1)).astype(np.float16)
use_gpu = False # can also be set to True
session = get_session(model_name, use_gpu)
y = run_session(session, x)
print(y)
ECG signals come in raw ADC (analog-to-digital converter) units that vary across devices and datasets. Before training, signals must be normalized so the model can learn consistently across sources. Two approaches have been used:
The original approach, implemented in the DeepECG Docker preprocessing pipeline, normalizes signals by matching their average spectral power to a reference dataset (PTB-XL). The pipeline works as follows:
- Extract raw ECG from source files (XML/NPY)
- Scale via FFT: Compute the mean FFT magnitude spectrum across all signals (lead 0), then uniformly scale every signal in the frequency domain so the dataset's average spectral power matches the PTB-XL reference (
PTBXL_POWER_RATIO = 3.003154) - Clean leads: Remove 60 Hz power line noise and flatten spectral peaks via FFT interpolation
- Save preprocessed signals
The spectral scaling step works by:
factor = PTBXL_POWER_RATIO / dataset_avg_spectral_power
scaled_signal = IFFT( FFT(signal) * factor )
This is a dataset-level normalization (one factor for the entire dataset, not per-sample), but it operates in the frequency domain and targets spectral power rather than physical units.
Limitation: While this preserves some inter-patient amplitude variation (unlike per-sample z-score), the FFT-domain scaling does not convert signals to standard physical units (millivolts). The reference power is empirically derived from PTB-XL rather than from documented ADC gains, making it harder to reason about the physical meaning of signal amplitudes. Additionally, the per-lead z-score normalization available at training time (--normalize, --mean_path, --std_path flags in the dataset config) can further destroy amplitude information if enabled.
The new approach replaces spectral power matching with a simple fixed scale factor per dataset source that converts raw ADC units directly to millivolts:
signal_mV = raw_signal * scale_factor
Each dataset/device has one scale factor determined by its documented ADC gain:
| Source | Scale Factor | Origin |
|---|---|---|
| MHI (MUSE GE) | 0.00488 | Documented ADC gain (4.88 uV/unit) |
| MIMIC | 0.001 | Dataset documentation |
| CODE-15 | 0.4694 | Estimated via calibration tool |
The scale factor is applied during preprocessing via preprocess_parquet.py:
$ python fairseq_signals/data/ecg/preprocess/preprocess_parquet.py \
[source_dir] \
--x-path [labels_file] \
--dest [output_dir] \
--scale 0.00488 \
--sample-rate 250 --sec 5 \
...For datasets with unknown ADC gain, estimate the scale factor using the calibration tool:
$ python scripts/preprocess/ecg/calibrate_dataset_scale.py /path/to/data.npy --n-samples 2000This computes the median lead-I power across a random sample and derives the scale factor that matches a millivolt reference (0.01707 mV^2, derived from MHI and MIMIC).
Advantage: All patients retain their natural amplitude variation after scaling, and signals are in standard physical units (mV) across all sources. This enables the model to learn voltage-dependent diagnostic criteria for conditions like LVH, LAE/RAE, RVH, and BAE, where absolute voltage measurements are part of the clinical criteria.
The full amplitude-preserved preprocessing and training pipeline is available in scripts/preprocess/ecg/run_ssl_amp_preserved.sh.
train.tsv for a classification task with num_labels=2. Note that the # used in the file are only for description. .tsv does not support comments.
x_path: [path_to_ecgs] #shape expected is (n_ecgs, 2500, 12)
x_shape:(2500, 12, 1)
y_path:[path_to_labels] # expected shape is (n_ecgs, n_labels_or_more)
label_indexes:[0, 5] #in this case we extract columns 0 and 5 to be our labels
@article{NolinLapalme2026,
author = {Nolin-Lapalme, Alexis and Sowa, Achille and Delfrate, Jacques and Tastet, Olivier and Corbin, Denis and Kulbay, Merve and Ozdemir, Derman and No{\"e}l, Marie-Jeanne and Marois-Blanchet, Fran{\c{c}}ois-Christophe and Harvey, Fran{\c{c}}ois and Sharma, Surbhi and Ansari, Minhaj and Chiu, I Min and D'souza, Valentina and Friedman, Sam F. and Chass{\'e}, Micha{\"e}l and Potter, Brian J. and Afilalo, Jonathan and Elias, Pierre Adil and Jabbour, Gilbert and Bahani, Mourad and Dub{\'e}, Marie-Pierre and Boyle, Patrick M. and Chatterjee, Neal A. and Barrios, Joshua and Tison, Geoffrey H. and Ouyang, David and Maddah, Mahnaz and Khurshid, Shaan and Cadrin-Tourigny, Julia and Tadros, Rafik and Hussin, Julie and Avram, Robert},
title = {Foundation models for electrocardiogram interpretation: clinical implications},
journal = {European Heart Journal},
year = {2026},
pages = {ehaf1119},
doi = {10.1093/eurheartj/ehaf1119},
URL = {https://doi.org/10.1093/eurheartj/ehaf1119},
note = {Online ahead of print}
}