Adding a Dataset¶
This guide covers adding a new ECG dataset (PTB-XL, a private cohort, pre-extracted NumPy arrays, ...) and training an existing model on it with ecgen train. There are three steps:
- Subclass
BaseECGDatasetand return the dict sample contract. - Make it reachable from YAML: register a name with
@DATASETS.register("name"), or refer to it by import path withtarget:. - Point the
data:section of an experiment YAML at it.
No DataModule or training script is needed: the generic ecgen.data.datamodule.ECGDataModule builds the train/val/test splits of any dataset and the runner does the rest. See MIMIC-IV-ECG for the reference implementation (ecgen.data.datasets.MIMICIVECG), ecgen.data.datasets.SyntheticECG for a minimal one, and the Data API reference for signatures.
1. The BaseECGDataset contract¶
ecgen.data.datasets.base.BaseECGDataset (torch.utils.data.Dataset + abc.ABC):
class BaseECGDataset(Dataset, ABC):
lead_names: List[str] # names of the C output channels, in order
fs: int # sampling rate in Hz
seq_length: int # samples per record (T)
feature_normalizer: Optional[FeatureNormalizer] = None
@abstractmethod
def __len__(self) -> int: ...
@abstractmethod
def __getitem__(self, idx: int) -> Dict[str, Any]: ...
@property
def num_leads(self) -> int: # len(self.lead_names)
...
__getitem__ returns a dict:
| Key | Type | Notes |
|---|---|---|
"signal" |
FloatTensor [C, T] |
C == len(lead_names), T == seq_length |
"cond" |
{field: Tensor} |
Condition fields, e.g. "measurements": [F]; {} for none |
"cond_missing" |
{field: BoolTensor} |
Same keys as cond, True = missing; {} for none |
"meta" |
dict |
Record ids etc.; collected into lists, never fed to a model |
ECGCollate stacks the samples into an ecgen.types.ECGBatch (signal [B, C, T], lead_names, fs, cond, cond_missing, meta), taking lead_names and fs from the dataset. Every family reads the signal with get_signal(batch), so a dataset that follows this contract works with all of them.
Conventions¶
These are not enforced, but ECGDataModule and the runner rely on them:
- The constructor takes
split("train","val","test");ECGDataModulefills it in for each split, and all otherdata.dataset.paramsare passed as keyword arguments. - A deterministic, seeded split computed inside the dataset. MIMIC splits by subject, so if your data has patient IDs, split on them to avoid leakage.
lead_names,fsandseq_lengthset in__init__. The runner compareslen(lead_names)andseq_lengthwith the model before training, andECGDataModulestores them in every checkpoint. Use names fromecgen.data.leads(STANDARD_12,INDEPENDENT_8,resolve_leads) where they apply.- Output
float32. The VAE/VQ-VAE encoders downsample by2 ** (len(channel_multipliers) - 1), soseq_lengthmust divide evenly (5000 / 8 = 625). - Amplitude scaling via
ecgen.data.datasets.base.scale_signal(ecg, scale)("zscore"per record,"none", or a number), the same as MIMIC. - If you provide numeric conditions, fit a
FeatureNormalizeron the train split and accept afeature_normalizerargument:ECGDataModulepasses the train dataset's normalizer to val/test when the constructor takes one.
2. Implement the dataset¶
The example reads one (leads, time) .npy file per record:
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional
import numpy as np
import torch
from ecgen.data.datasets.base import BaseECGDataset, Scale, scale_signal
from ecgen.data.leads import STANDARD_12, LeadSpec, resolve_leads
from ecgen.registry import DATASETS
@DATASETS.register("npy_ecg")
class NpyECGDataset(BaseECGDataset):
"""One ``.npy`` file of shape (12, time) per record, leads in standard order."""
def __init__(
self,
root: str,
split: str = "train",
val_split: float = 0.1,
test_split: float = 0.1,
max_samples: Optional[int] = None,
seed: int = 42,
leads: LeadSpec = None,
seq_length: int = 5000,
fs: int = 500,
scale: Scale = "zscore",
) -> None:
if split not in ("train", "val", "test"):
raise ValueError(f"Unknown split: {split}")
self.lead_names = resolve_leads(leads, STANDARD_12)
self._idx = [STANDARD_12.index(n) for n in self.lead_names]
self.seq_length = seq_length
self.fs = fs
self.scale = scale
files = sorted(Path(root).glob("*.npy"))
if not files:
raise FileNotFoundError(f"No .npy files in {root}")
# Deterministic split; the same seed gives disjoint train/val/test
perm = np.random.default_rng(seed).permutation(len(files))
n_test = int(round(len(files) * test_split))
n_val = int(round(len(files) * val_split))
idx = {
"test": perm[:n_test],
"val": perm[n_test:n_test + n_val],
"train": perm[n_test + n_val:],
}[split]
self.files = [files[i] for i in sorted(idx)]
if max_samples is not None:
self.files = self.files[:max_samples]
def __len__(self) -> int:
return len(self.files)
def __getitem__(self, idx: int) -> Dict[str, Any]:
ecg = np.load(self.files[idx]).astype(np.float32)[self._idx] # (C, time)
ecg = ecg[:, : self.seq_length]
if ecg.shape[1] < self.seq_length:
ecg = np.pad(ecg, ((0, 0), (0, self.seq_length - ecg.shape[1])))
return {
"signal": torch.from_numpy(scale_signal(ecg, self.scale)),
"cond": {},
"cond_missing": {},
"meta": {"file": self.files[idx].name},
}
Check it quickly:
import torch
from my_project.data.npy_ecg import NpyECGDataset
ds = NpyECGDataset("data/raw/npy_ecg", split="val")
x = ds[0]["signal"]
assert x.shape == (ds.num_leads, ds.seq_length) and x.dtype == torch.float32
Write a test
Add a test that writes a few random arrays to tmp_path, then checks split disjointness, output shape and dtype, and that ECGDataModule(dataset={"target": ..., "params": {...}}, batch_size=2) yields an ECGBatch. Run it with pytest tests/.
3. Make it reachable from YAML¶
Two options:
Refer to the class by import path. The module only has to be importable (installed, or on
PYTHONPATH):
@DATASETS.register("npy_ecg") adds the class to ecgen.registry.DATASETS when its module is
imported. Built-in datasets live in src/ecgen/data/datasets/ and are imported by
ecgen/data/datasets/__init__.py, which ecgen.registry.load_builtins() imports; add yours
there to make name: npy_ecg work everywhere (ecgen list shows it):
4. Experiment YAML¶
Copy an existing experiment (for example configs/experiments/vqvae_mimic.yaml) to configs/experiments/vqvae_npy_ecg.yaml and replace the data: section:
experiment:
name: vqvae_npy_ecg
seed: 42
runs_root: runs
data:
dataset:
name: npy_ecg # or target: my_project.data.npy_ecg.NpyECGDataset
params:
root: data/raw/npy_ecg
max_samples: null
val_split: 0.1
test_split: 0.1
leads: null # 12 leads
seq_length: 5000
split_params: # optional per-split overrides
val: {max_samples: 500}
batch_size: 32
num_workers: 4
Run it:
ecgen train configs/experiments/vqvae_npy_ecg.yaml
ecgen train configs/experiments/vqvae_mimic.yaml \
data.dataset.name=npy_ecg 'data.dataset.params={root: data/raw/npy_ecg}' # without a new file
Outputs go to runs/<experiment.name>/seed_<seed>/, as described in Training with ecgen train.
Model compatibility checklist
- The model's
in_channels(VAE, VQ-VAE) ornum_channels(Pulse2Pulse) must equallen(lead_names); the runner fails early otherwise. seq_lengthmust be divisible by the encoder's total downsampling factor (8 with the defaultchannel_multipliers: [1, 2, 4, 4]), and equal Pulse2Pulse'sseq_length(a multiple of 2500).- Pulse2Pulse uses 8 leads (
num_channels: 8,leads: independent8). See Pulse2Pulse GAN.
configs/dataset/*.yaml fragments are reference values only; the runner reads the experiment YAML.
Checklist¶
-
BaseECGDatasetsubclass implementing__len__and__getitem__, settinglead_names,fsandseq_length -
__getitem__returns{"signal", "cond", "cond_missing", "meta"} - Constructor takes
split; split is seeded and leakage-free - Registered with
@DATASETS.register("<name>")(imported byecgen.data.datasets) or referenced withtarget: -
configs/experiments/<model>_<name>.yamlwith adata.datasetsection - A test under
tests/