Skip to content

Training API

Auto-generated from docstrings.

ecgen.training.runner

YAML-driven training runner shared by all families.

Config sections::

experiment: {name, seed, runs_root}
model:      {name | target, params}          # registry name, e.g. "vae"
data:       {dataset: {name | target, params}, batch_size, num_workers,
             pin_memory, split_params}
checkpoint: {monitor, mode, save_top_k, save_last, filename}
callbacks:  [{name | target, params}, ...]
trainer:    {...}                            # pl.Trainer kwargs
wandb:      {enabled, project, entity, run_name, tags, notes, log_model}
resume:     path/to/checkpoint.ckpt | null

Outputs go to <runs_root>/<name>/seed_<seed>/ (checkpoints/, samples/, metrics/, tb/, config_resolved.yaml, metadata.json). String values may use ${run_dir}, ${name} and ${seed}; a callback that takes a save_dir and gets none writes to ${run_dir}/samples.

parse_value

parse_value(raw: str) -> Any

Parse an override value as YAML, reading exponent notation as a float.

Source code in src/ecgen/training/runner.py
def parse_value(raw: str) -> Any:
    """Parse an override value as YAML, reading exponent notation as a float."""
    value = yaml.safe_load(raw)
    if isinstance(value, str) and _FLOAT_RE.match(value.strip()):
        return float(value)
    return value

apply_overrides

apply_overrides(cfg: Dict[str, Any], overrides: Sequence[str]) -> Dict[str, Any]

Apply dotted.key=value overrides; values are parsed as YAML.

model.params.lr=1e-3, trainer.devices=[1], callbacks.0.params.num_samples=2.

Source code in src/ecgen/training/runner.py
def apply_overrides(cfg: Dict[str, Any], overrides: Sequence[str]) -> Dict[str, Any]:
    """
    Apply ``dotted.key=value`` overrides; values are parsed as YAML.

    ``model.params.lr=1e-3``, ``trainer.devices=[1]``, ``callbacks.0.params.num_samples=2``.
    """
    cfg = copy.deepcopy(cfg)
    for item in overrides:
        if "=" not in item:
            raise ValueError(f"Override must look like key=value, got {item!r}")
        key, raw = item.split("=", 1)
        value = parse_value(raw)
        node: Any = cfg
        parts = key.split(".")
        for part in parts[:-1]:
            if isinstance(node, list):
                node = node[int(part)]
            else:
                node = node.setdefault(part, {})
                if node is None:
                    raise ValueError(f"Cannot set {key!r}: {part!r} is null")
        last = parts[-1]
        if isinstance(node, list):
            node[int(last)] = value
        else:
            node[last] = value
    return cfg

resolve_config

resolve_config(cfg: Mapping[str, Any]) -> tuple[Dict[str, Any], RunPaths]

Validate sections, fill defaults, and interpolate ${...} variables.

Source code in src/ecgen/training/runner.py
def resolve_config(cfg: Mapping[str, Any]) -> tuple[Dict[str, Any], RunPaths]:
    """Validate sections, fill defaults, and interpolate ``${...}`` variables."""
    unknown = set(cfg) - KNOWN_SECTIONS
    if unknown:
        raise ValueError(
            f"Unknown config sections: {sorted(unknown)}; expected {sorted(KNOWN_SECTIONS)}"
        )
    for section in ("model", "data"):
        if section not in cfg:
            raise ValueError(f"Config needs a '{section}' section")

    cfg = copy.deepcopy(dict(cfg))
    exp = cfg.setdefault("experiment", {})
    exp.setdefault("name", "ecg_experiment")
    exp["seed"] = int(exp.get("seed", 42))
    exp.setdefault("runs_root", "runs")

    run_dir = Path(exp["runs_root"]) / exp["name"] / f"seed_{exp['seed']}"
    cfg = _interpolate(
        cfg, {"run_dir": str(run_dir), "name": exp["name"], "seed": str(exp["seed"])}
    )
    cfg["checkpoint"] = {**DEFAULT_CHECKPOINT, **(cfg.get("checkpoint") or {})}
    cfg.setdefault("callbacks", [])
    cfg["callbacks"] = cfg["callbacks"] or []
    cfg.setdefault("trainer", {})
    cfg.setdefault("wandb", {"enabled": False})
    cfg.setdefault("resume", None)

    paths = RunPaths(
        run_dir, run_dir / "checkpoints", run_dir / "samples", run_dir / "metrics"
    )
    return cfg, paths

check_compatibility

check_compatibility(model: LightningModule, datamodule: ECGDataModule) -> None

Fail early if the model and dataset disagree on leads, length, or conditions.

Source code in src/ecgen/training/runner.py
def check_compatibility(model: pl.LightningModule, datamodule: ECGDataModule) -> None:
    """Fail early if the model and dataset disagree on leads, length, or conditions."""
    ds = datamodule.reference_dataset
    conditioner = getattr(model, "conditioner", None)
    if conditioner is not None:
        sample = ds[0]
        available = set(sample.get("cond", {}))
        missing = [f for f in conditioner.cond_fields if f not in available]
        if missing:
            raise ValueError(
                f"Conditioning uses fields {missing} that the dataset does not provide "
                f"(it provides {sorted(available)})"
            )
        if conditioner.uses_obs and datamodule.mask_spec is None:
            raise ValueError(
                "Conditioning reads the observed ECG ('obs' field) but data.masks is not set"
            )
    io = _model_io(model)
    if io["channels"] is not None and io["channels"] != ds.num_leads:
        raise ValueError(
            f"Model expects {io['channels']} channels but the dataset yields {ds.num_leads} "
            f"leads {ds.lead_names}; set data.dataset.params.leads or the model's channel count"
        )
    if io["seq_length"] is not None and io["seq_length"] != ds.seq_length:
        raise ValueError(
            f"Model expects seq_length={io['seq_length']} but the dataset yields {ds.seq_length}"
        )

train

train(cfg: Mapping[str, Any], overrides: Sequence[str] = (), config_path: Optional[str | Path] = None) -> pl.Trainer

Train a model from a config dict; returns the fitted pl.Trainer.

Parameters:

Name Type Description Default
cfg Mapping[str, Any]

Parsed experiment config.

required
overrides Sequence[str]

dotted.key=value strings applied on top of cfg.

()
config_path Optional[str | Path]

Source YAML, recorded in the run metadata.

None
Source code in src/ecgen/training/runner.py
def train(
    cfg: Mapping[str, Any],
    overrides: Sequence[str] = (),
    config_path: Optional[str | Path] = None,
) -> pl.Trainer:
    """
    Train a model from a config dict; returns the fitted ``pl.Trainer``.

    Args:
        cfg: Parsed experiment config.
        overrides: ``dotted.key=value`` strings applied on top of ``cfg``.
        config_path: Source YAML, recorded in the run metadata.
    """
    cfg, paths = resolve_config(apply_overrides(dict(cfg), overrides))
    seed = cfg["experiment"]["seed"]
    set_global_seed(seed)

    model = build_model(cfg["model"])
    datamodule = build_datamodule(cfg["data"])
    datamodule.setup("fit")
    check_compatibility(model, datamodule)
    if hasattr(model, "set_data_info"):
        model.set_data_info(datamodule.state_dict())

    for d in (paths.checkpoints, paths.samples, paths.metrics):
        d.mkdir(parents=True, exist_ok=True)
    write_yaml(cfg, paths.run_dir / "config_resolved.yaml")
    write_json(
        collect_run_metadata(
            config_path=Path(config_path) if config_path else None,
            cfg=cfg,
            run_dir=paths.run_dir,
            argv=None,
        ),
        paths.run_dir / "metadata.json",
    )

    checkpoint = ModelCheckpoint(
        dirpath=str(paths.checkpoints),
        auto_insert_metric_name=False,
        **cfg["checkpoint"],
    )
    callbacks = [checkpoint] + build_callbacks(cfg["callbacks"], paths.samples)

    trainer = pl.Trainer(
        default_root_dir=str(paths.run_dir),
        logger=_loggers(cfg, paths.run_dir),
        callbacks=callbacks,
        **cfg["trainer"],
    )
    logger.info(
        f"Training '{cfg['experiment']['name']}' ({type(model).__name__}) in {paths.run_dir}"
    )
    trainer.fit(model=model, datamodule=datamodule, ckpt_path=cfg["resume"])

    write_json(
        {
            "callback_metrics": {
                k: float(v) for k, v in trainer.callback_metrics.items()
            },
            "logged_metrics": {k: float(v) for k, v in trainer.logged_metrics.items()},
            "best_model_path": checkpoint.best_model_path,
        },
        paths.metrics / "train_val_metrics.json",
    )
    logger.info(f"Done. Best checkpoint: {checkpoint.best_model_path or '<none>'}")
    return trainer

ecgen.cli

Command-line interface:

  • ecgen train <config.yaml> [key=value ...]
  • ecgen sample <checkpoint> -n 16 -o out --format csv [--cond field=value ...]
  • ecgen evaluate <samples.npy> <config.yaml>
  • ecgen list

parse_cond

parse_cond(items: Sequence[str]) -> dict

["a=1,2,nan", "report=Sinus rhythm"] -> {"a": [1.0, 2.0, nan], "report": "Sinus rhythm"}.

Source code in src/ecgen/cli.py
def parse_cond(items: Sequence[str]) -> dict:
    """``["a=1,2,nan", "report=Sinus rhythm"]`` -> ``{"a": [1.0, 2.0, nan], "report": "Sinus rhythm"}``."""
    out = {}
    for item in items:
        if "=" not in item:
            raise ValueError(f"--cond must look like FIELD=VALUE, got {item!r}")
        key, raw = item.split("=", 1)
        raw = raw.strip().strip("'\"")
        parts = [x.strip() for x in raw.split(",")]
        try:
            values = [float(x) for x in parts]
            out[key] = values if len(values) > 1 else values[0]
        except ValueError:
            out[key] = raw
    return out

ecgen.registry

Name → class registries used to build components from YAML.

A component spec is a mapping with either a registry name or an import path target, plus optional params::

{"name": "vae", "params": {...}}
{"target": "my_pkg.models.MyModel", "params": {...}}

target lets user code outside the library plug in without registering.

Registry

Bases: Generic[T]

A named collection of classes (or factories) of one kind.

Example::

FAMILIES = Registry("family")

@FAMILIES.register("vae")
class VAELightning(...): ...

FAMILIES.get("vae")  # -> VAELightning
Source code in src/ecgen/registry.py
class Registry(Generic[T]):
    """
    A named collection of classes (or factories) of one kind.

    Example::

        FAMILIES = Registry("family")

        @FAMILIES.register("vae")
        class VAELightning(...): ...

        FAMILIES.get("vae")  # -> VAELightning
    """

    def __init__(self, kind: str) -> None:
        self.kind = kind
        self._items: Dict[str, T] = {}

    def register(self, name: str) -> Callable[[T], T]:
        def decorator(obj: T) -> T:
            self.add(name, obj)
            return obj

        return decorator

    def add(self, name: str, obj: T) -> None:
        existing = self._items.get(name)
        if existing is not None and existing is not obj:
            raise KeyError(
                f"{self.kind} {name!r} is already registered to {existing!r}"
            )
        self._items[name] = obj

    def get(self, name: str) -> T:
        try:
            return self._items[name]
        except KeyError:
            known = ", ".join(sorted(self._items)) or "<none>"
            raise KeyError(
                f"Unknown {self.kind} {name!r}. Registered: {known}"
            ) from None

    def resolve(self, spec: Mapping[str, Any]) -> T:
        """Return the class named by a spec's ``name`` or ``target``."""
        if "target" in spec and "name" in spec:
            raise ValueError(
                f"{self.kind} spec has both 'name' and 'target': {dict(spec)}"
            )
        if "target" in spec:
            return import_from_string(spec["target"])
        if "name" in spec:
            return self.get(spec["name"])
        raise ValueError(f"{self.kind} spec needs 'name' or 'target': {dict(spec)}")

    def build(self, spec: Mapping[str, Any], **extra: Any) -> Any:
        """Instantiate ``resolve(spec)(**spec['params'], **extra)``."""
        params: Optional[Mapping[str, Any]] = spec.get("params")
        return self.resolve(spec)(**dict(params or {}), **extra)

    def name_of(self, obj: Any) -> Optional[str]:
        """Registered name of ``obj`` (or of its class), if any."""
        cls = obj if isinstance(obj, type) else type(obj)
        for name, item in self._items.items():
            if item is cls:
                return name
        return None

    def __contains__(self, name: object) -> bool:
        return name in self._items

    def __iter__(self) -> Iterator[str]:
        return iter(sorted(self._items))

    def __repr__(self) -> str:
        return f"Registry({self.kind!r}, {sorted(self._items)})"

resolve

resolve(spec: Mapping[str, Any]) -> T

Return the class named by a spec's name or target.

Source code in src/ecgen/registry.py
def resolve(self, spec: Mapping[str, Any]) -> T:
    """Return the class named by a spec's ``name`` or ``target``."""
    if "target" in spec and "name" in spec:
        raise ValueError(
            f"{self.kind} spec has both 'name' and 'target': {dict(spec)}"
        )
    if "target" in spec:
        return import_from_string(spec["target"])
    if "name" in spec:
        return self.get(spec["name"])
    raise ValueError(f"{self.kind} spec needs 'name' or 'target': {dict(spec)}")

build

build(spec: Mapping[str, Any], **extra: Any) -> Any

Instantiate resolve(spec)(**spec['params'], **extra).

Source code in src/ecgen/registry.py
def build(self, spec: Mapping[str, Any], **extra: Any) -> Any:
    """Instantiate ``resolve(spec)(**spec['params'], **extra)``."""
    params: Optional[Mapping[str, Any]] = spec.get("params")
    return self.resolve(spec)(**dict(params or {}), **extra)

name_of

name_of(obj: Any) -> Optional[str]

Registered name of obj (or of its class), if any.

Source code in src/ecgen/registry.py
def name_of(self, obj: Any) -> Optional[str]:
    """Registered name of ``obj`` (or of its class), if any."""
    cls = obj if isinstance(obj, type) else type(obj)
    for name, item in self._items.items():
        if item is cls:
            return name
    return None

import_from_string

import_from_string(path: str) -> Any

Import package.module.attr and return attr.

Source code in src/ecgen/registry.py
def import_from_string(path: str) -> Any:
    """Import ``package.module.attr`` and return ``attr``."""
    module_path, _, attr = path.rpartition(".")
    if not module_path:
        raise ValueError(f"Invalid import path: {path!r}")
    module = importlib.import_module(module_path)
    return getattr(module, attr)

load_builtins

load_builtins() -> None

Import the built-in modules so their @register decorators run.

Source code in src/ecgen/registry.py
def load_builtins() -> None:
    """Import the built-in modules so their ``@register`` decorators run."""
    import ecgen.conditioning  # noqa: F401
    import ecgen.data.datasets  # noqa: F401
    import ecgen.data.masks  # noqa: F401
    import ecgen.families  # noqa: F401
    import ecgen.nn.backbones  # noqa: F401
    import ecgen.processes  # noqa: F401
    import ecgen.training.callbacks  # noqa: F401

training.losses.common

Common loss functions shared across model types.

mse_loss

mse_loss(pred: Tensor, target: Tensor) -> Tensor

Mean squared error loss.

Source code in training/losses/common.py
def mse_loss(pred: Tensor, target: Tensor) -> Tensor:
    """Mean squared error loss."""
    return F.mse_loss(pred, target)

l1_loss

l1_loss(pred: Tensor, target: Tensor) -> Tensor

L1 loss.

Source code in training/losses/common.py
def l1_loss(pred: Tensor, target: Tensor) -> Tensor:
    """L1 loss."""
    return F.l1_loss(pred, target)

ecgen.families.vae.losses

vae_loss

vae_loss(recon: Tensor, x: Tensor, mean: Tensor, logvar: Tensor, kl_weight: float = 0.0001) -> tuple[Tensor, Tensor, Tensor]

VAE loss function

Parameters:

Name Type Description Default
recon Tensor

Reconstructed signal

required
x Tensor

Original signal

required
mean Tensor

Latent mean

required
logvar Tensor

Latent log variance

required
kl_weight float

Weight for KL divergence term

0.0001

Returns:

Type Description
tuple[Tensor, Tensor, Tensor]

total_loss, recon_loss, kl_loss

Source code in src/ecgen/families/vae/losses.py
def vae_loss(
    recon: Tensor,
    x: Tensor,
    mean: Tensor,
    logvar: Tensor,
    kl_weight: float = 0.0001,
) -> tuple[Tensor, Tensor, Tensor]:
    """
    VAE loss function

    Args:
        recon: Reconstructed signal
        x: Original signal
        mean: Latent mean
        logvar: Latent log variance
        kl_weight: Weight for KL divergence term

    Returns:
        total_loss, recon_loss, kl_loss
    """
    recon_loss = F.mse_loss(recon, x, reduction="mean")

    kl_loss = -0.5 * torch.sum(1 + logvar - mean.pow(2) - logvar.exp())
    kl_loss = kl_loss / (x.size(0) * x.size(1) * x.size(2))

    total_loss = recon_loss + kl_weight * kl_loss

    return total_loss, recon_loss, kl_loss

gaussian_kl

gaussian_kl(mean: Tensor, logvar: Tensor, prior_mean: Tensor, prior_logvar: Tensor) -> Tensor

Summed KL(N(mean, var) || N(prior_mean, prior_var)).

Source code in src/ecgen/families/vae/losses.py
def gaussian_kl(
    mean: Tensor, logvar: Tensor, prior_mean: Tensor, prior_logvar: Tensor
) -> Tensor:
    """Summed ``KL(N(mean, var) || N(prior_mean, prior_var))``."""
    return 0.5 * torch.sum(
        prior_logvar
        - logvar
        + (logvar.exp() + (mean - prior_mean).pow(2)) / prior_logvar.exp()
        - 1
    )

cvae_loss

cvae_loss(recon: Tensor, x: Tensor, mean: Tensor, logvar: Tensor, prior_mean: Tensor, prior_logvar: Tensor, kl_weight: float = 0.0001, recon_mask: Tensor | None = None) -> tuple[Tensor, Tensor, Tensor]

Conditional VAE loss: MSE + kl_weight * KL to a (learned) prior.

The KL is normalized like :func:vae_loss (by B * C * T). With recon_mask (1 = count), the MSE is averaged over masked entries only.

Source code in src/ecgen/families/vae/losses.py
def cvae_loss(
    recon: Tensor,
    x: Tensor,
    mean: Tensor,
    logvar: Tensor,
    prior_mean: Tensor,
    prior_logvar: Tensor,
    kl_weight: float = 0.0001,
    recon_mask: Tensor | None = None,
) -> tuple[Tensor, Tensor, Tensor]:
    """
    Conditional VAE loss: MSE + ``kl_weight`` * KL to a (learned) prior.

    The KL is normalized like :func:`vae_loss` (by ``B * C * T``). With
    ``recon_mask`` (1 = count), the MSE is averaged over masked entries only.
    """
    if recon_mask is None:
        recon_loss = F.mse_loss(recon, x, reduction="mean")
    else:
        w = recon_mask.to(x.dtype)
        recon_loss = ((recon - x) ** 2 * w).sum() / w.sum().clamp_min(1.0)
    kl = gaussian_kl(mean, logvar, prior_mean, prior_logvar) / (
        x.size(0) * x.size(1) * x.size(2)
    )
    return recon_loss + kl_weight * kl, recon_loss, kl

ecgen.families.vqvae.losses

vqvae_loss

vqvae_loss(recon: Tensor, x: Tensor, vq_loss: Tensor) -> tuple[Tensor, Tensor, Tensor]

VQ-VAE loss function.

Combines reconstruction loss with vector quantization loss. Unlike standard VAE, there's no KL divergence term since we use discrete codes.

Parameters:

Name Type Description Default
recon Tensor

Reconstructed signal [B, C, L]

required
x Tensor

Original signal [B, C, L]

required
vq_loss Tensor

Vector quantization loss (codebook + commitment)

required

Returns:

Name Type Description
total_loss Tensor

Combined loss

recon_loss Tensor

Reconstruction loss (MSE)

vq_loss Tensor

Vector quantization loss

Source code in src/ecgen/families/vqvae/losses.py
def vqvae_loss(
    recon: Tensor,
    x: Tensor,
    vq_loss: Tensor,
) -> tuple[Tensor, Tensor, Tensor]:
    """
    VQ-VAE loss function.

    Combines reconstruction loss with vector quantization loss.
    Unlike standard VAE, there's no KL divergence term since we use discrete codes.

    Args:
        recon: Reconstructed signal [B, C, L]
        x: Original signal [B, C, L]
        vq_loss: Vector quantization loss (codebook + commitment)

    Returns:
        total_loss: Combined loss
        recon_loss: Reconstruction loss (MSE)
        vq_loss: Vector quantization loss
    """
    # Reconstruction loss
    recon_loss = F.mse_loss(recon, x, reduction="mean")

    # Total loss
    total_loss = recon_loss + vq_loss

    return total_loss, recon_loss, vq_loss

prior_loss

prior_loss(logits: Tensor, targets: Tensor) -> Tensor

Prior model loss function.

Cross-entropy loss for autoregressive prediction of discrete codes.

Parameters:

Name Type Description Default
logits Tensor

Predicted logits [B, num_embeddings, L]

required
targets Tensor

Target indices [B, L]

required

Returns:

Type Description
Tensor

Cross-entropy loss

Source code in src/ecgen/families/vqvae/losses.py
def prior_loss(
    logits: Tensor,
    targets: Tensor,
) -> Tensor:
    """
    Prior model loss function.

    Cross-entropy loss for autoregressive prediction of discrete codes.

    Args:
        logits: Predicted logits [B, num_embeddings, L]
        targets: Target indices [B, L]

    Returns:
        Cross-entropy loss
    """
    # Reshape for cross entropy: [B, num_embeddings, L] -> [B*L, num_embeddings]
    B, num_embeddings, L = logits.shape
    logits = logits.permute(0, 2, 1).contiguous()  # [B, L, num_embeddings]
    logits = logits.view(-1, num_embeddings)  # [B*L, num_embeddings]

    # Flatten targets: [B, L] -> [B*L]
    targets = targets.reshape(-1)

    # Cross-entropy loss
    loss = F.cross_entropy(logits, targets, reduction="mean")

    return loss

ecgen.families.gan.losses

Loss functions for ECG generation models.

wgan_discriminator_loss

wgan_discriminator_loss(d_real: Tensor, d_fake: Tensor, gp: Tensor) -> Tensor

WGAN-GP discriminator loss.

Parameters:

Name Type Description Default
d_real Tensor

Discriminator output on real samples

required
d_fake Tensor

Discriminator output on fake samples

required
gp Tensor

Gradient penalty

required

Returns:

Type Description
Tensor

Discriminator loss (minimize: fake - real + gp)

Source code in src/ecgen/families/gan/losses.py
def wgan_discriminator_loss(d_real: Tensor, d_fake: Tensor, gp: Tensor) -> Tensor:
    """
    WGAN-GP discriminator loss.

    Args:
        d_real: Discriminator output on real samples
        d_fake: Discriminator output on fake samples
        gp: Gradient penalty

    Returns:
        Discriminator loss (minimize: fake - real + gp)
    """
    return d_fake - d_real + gp

wgan_generator_loss

wgan_generator_loss(d_fake: Tensor) -> Tensor

WGAN-GP generator loss.

Parameters:

Name Type Description Default
d_fake Tensor

Discriminator output on fake samples

required

Returns:

Type Description
Tensor

Generator loss (minimize: -d_fake)

Source code in src/ecgen/families/gan/losses.py
def wgan_generator_loss(d_fake: Tensor) -> Tensor:
    """
    WGAN-GP generator loss.

    Args:
        d_fake: Discriminator output on fake samples

    Returns:
        Generator loss (minimize: -d_fake)
    """
    return -d_fake

adversarial_loss

adversarial_loss(pred: Tensor, target_is_real: bool) -> Tensor

Standard adversarial loss (BCE).

Parameters:

Name Type Description Default
pred Tensor

Discriminator predictions

required
target_is_real bool

Whether targets should be real (1) or fake (0)

required

Returns:

Type Description
Tensor

Binary cross-entropy loss

Source code in src/ecgen/families/gan/losses.py
def adversarial_loss(pred: Tensor, target_is_real: bool) -> Tensor:
    """
    Standard adversarial loss (BCE).

    Args:
        pred: Discriminator predictions
        target_is_real: Whether targets should be real (1) or fake (0)

    Returns:
        Binary cross-entropy loss
    """
    target = torch.ones_like(pred) if target_is_real else torch.zeros_like(pred)
    return F.binary_cross_entropy_with_logits(pred, target)

least_squares_discriminator_loss

least_squares_discriminator_loss(d_real: Tensor, d_fake: Tensor) -> Tensor

Least squares GAN discriminator loss.

Parameters:

Name Type Description Default
d_real Tensor

Discriminator output on real samples

required
d_fake Tensor

Discriminator output on fake samples

required

Returns:

Type Description
Tensor

LSGAN discriminator loss

Source code in src/ecgen/families/gan/losses.py
def least_squares_discriminator_loss(d_real: Tensor, d_fake: Tensor) -> Tensor:
    """
    Least squares GAN discriminator loss.

    Args:
        d_real: Discriminator output on real samples
        d_fake: Discriminator output on fake samples

    Returns:
        LSGAN discriminator loss
    """
    return 0.5 * (torch.mean((d_real - 1) ** 2) + torch.mean(d_fake**2))

least_squares_generator_loss

least_squares_generator_loss(d_fake: Tensor) -> Tensor

Least squares GAN generator loss.

Parameters:

Name Type Description Default
d_fake Tensor

Discriminator output on fake samples

required

Returns:

Type Description
Tensor

LSGAN generator loss

Source code in src/ecgen/families/gan/losses.py
def least_squares_generator_loss(d_fake: Tensor) -> Tensor:
    """
    Least squares GAN generator loss.

    Args:
        d_fake: Discriminator output on fake samples

    Returns:
        LSGAN generator loss
    """
    return 0.5 * torch.mean((d_fake - 1) ** 2)

ecgen.training.callbacks.checkpointing

Custom checkpoint callbacks (future implementation).

CustomCheckpointCallback

Bases: Callback

Custom checkpoint callback for specialized checkpoint saving.

Future implementation for model-specific checkpointing strategies.

Source code in src/ecgen/training/callbacks/checkpointing.py
class CustomCheckpointCallback(pl.Callback):
    """
    Custom checkpoint callback for specialized checkpoint saving.

    Future implementation for model-specific checkpointing strategies.
    """
    pass

ecgen.training.callbacks.logging

Custom logging callbacks (future implementation).

CustomLoggingCallback

Bases: Callback

Custom logging callback for specialized logging.

Future implementation for model-specific logging strategies.

Source code in src/ecgen/training/callbacks/logging.py
class CustomLoggingCallback(pl.Callback):
    """
    Custom logging callback for specialized logging.

    Future implementation for model-specific logging strategies.
    """
    pass

ecgen.training.callbacks.visualization

Training callbacks for ECG generation models.

ECGVisualizationCallback

Bases: Callback

Callback to visualize real vs generated ECG samples during training.

Source code in src/ecgen/training/callbacks/visualization.py
class ECGVisualizationCallback(Callback):
    """
    Callback to visualize real vs generated ECG samples during training.
    """

    lead_names: list[str] = LEAD_NAMES_8  # replaced by the datamodule's leads at runtime

    def __init__(
        self,
        save_dir: str | Path,
        every_n_epochs: int = 10,
        n_samples: int = 1,
    ) -> None:
        super().__init__()
        self.save_dir = Path(save_dir)
        self.save_dir.mkdir(parents=True, exist_ok=True)
        self.every_n_epochs = every_n_epochs
        self.n_samples = n_samples

    def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule) -> None:
        self.lead_names = _datamodule_lead_names(trainer, LEAD_NAMES_8)
        if trainer.current_epoch % self.every_n_epochs != 0:
            return

        if not hasattr(pl_module, "_val_real_sample") or not hasattr(pl_module, "_val_fake_sample"):
            return

        real = pl_module._val_real_sample
        fake = pl_module._val_fake_sample

        if real is None or fake is None:
            return

        save_path = self.save_dir / f"sample_epoch_{trainer.current_epoch:04d}.png"
        self._plot_comparison(real, fake, save_path, title=f"Epoch {trainer.current_epoch}")

        # Log to wandb if available
        if trainer.logger is not None:
            for logger in trainer.loggers if hasattr(trainer, "loggers") else [trainer.logger]:
                if hasattr(logger, "experiment") and hasattr(logger.experiment, "log"):
                    try:
                        import wandb
                        logger.experiment.log({
                            "samples/real_vs_fake": wandb.Image(str(save_path)),
                            "epoch": trainer.current_epoch,
                        })
                    except (ImportError, AttributeError):
                        pass

    def _plot_comparison(
        self,
        real: torch.Tensor,
        fake: torch.Tensor,
        path: Path,
        title: str = "Real vs Generated",
    ) -> None:
        """Plot real vs generated ECG (first sample, all leads)."""
        real_np = real.cpu().numpy()
        fake_np = fake.cpu().numpy()
        n_leads = min(real_np.shape[0], 8)

        fig, axs = plt.subplots(n_leads, 2, figsize=(12, 1.5 * n_leads))
        if n_leads == 1:
            axs = axs.reshape(1, -1)

        for i in range(n_leads):
            axs[i, 0].plot(real_np[i], color="C0", linewidth=0.7)
            axs[i, 0].set_title(f"Real - {_name(self.lead_names, i, f'Lead {i}')}")
            axs[i, 0].set_xlim(0, real_np.shape[-1])

            axs[i, 1].plot(fake_np[i], color="C1", linewidth=0.7)
            axs[i, 1].set_title(f"Generated - {_name(self.lead_names, i, f'Lead {i}')}")
            axs[i, 1].set_xlim(0, fake_np.shape[-1])

        axs[-1, 0].set_xlabel("Time (samples)")
        axs[-1, 1].set_xlabel("Time (samples)")
        plt.suptitle(title)
        plt.tight_layout()
        plt.savefig(path, dpi=120)
        plt.close()

GeneratedSamplesCallback

Bases: Callback

Callback to generate and save ECG samples periodically.

Source code in src/ecgen/training/callbacks/visualization.py
class GeneratedSamplesCallback(Callback):
    """
    Callback to generate and save ECG samples periodically.
    """

    lead_names: list[str] = LEAD_NAMES_8  # replaced by the datamodule's leads at runtime

    def __init__(
        self,
        save_dir: str | Path,
        every_n_epochs: int = 25,
        n_samples: int = 16,
    ) -> None:
        super().__init__()
        self.save_dir = Path(save_dir)
        self.save_dir.mkdir(parents=True, exist_ok=True)
        self.every_n_epochs = every_n_epochs
        self.n_samples = n_samples

    def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule) -> None:
        self.lead_names = _datamodule_lead_names(trainer, LEAD_NAMES_8)
        if trainer.current_epoch % self.every_n_epochs != 0:
            return

        if not hasattr(pl_module, "generate_samples"):
            return

        samples = pl_module.generate_samples(self.n_samples)

        save_path = self.save_dir / f"generated_epoch_{trainer.current_epoch:04d}.pt"
        torch.save(samples.cpu(), save_path)

        plot_path = self.save_dir / f"generated_epoch_{trainer.current_epoch:04d}.png"
        self._plot_generated(samples[0].cpu(), plot_path, title=f"Generated ECG - Epoch {trainer.current_epoch}")

        # Log to wandb if available
        if trainer.logger is not None:
            for logger in trainer.loggers if hasattr(trainer, "loggers") else [trainer.logger]:
                if hasattr(logger, "experiment") and hasattr(logger.experiment, "log"):
                    try:
                        import wandb
                        logger.experiment.log({
                            "samples/generated": wandb.Image(str(plot_path)),
                            "epoch": trainer.current_epoch,
                        })
                    except (ImportError, AttributeError):
                        pass

    def _plot_generated(
        self,
        fake: torch.Tensor,
        path: Path,
        title: str = "Generated ECG",
    ) -> None:
        """Plot generated ECG sample (all leads)."""
        fake_np = fake.numpy()
        n_leads = min(fake_np.shape[0], 8)

        fig, axs = plt.subplots(n_leads, 1, figsize=(12, 1.2 * n_leads))
        if n_leads == 1:
            axs = [axs]

        for i in range(n_leads):
            axs[i].plot(fake_np[i], color="C1", linewidth=0.7)
            axs[i].set_ylabel(_name(self.lead_names, i, f"L{i}"))
            axs[i].set_xlim(0, fake_np.shape[-1])

        axs[-1].set_xlabel("Time (samples)")
        plt.suptitle(title)
        plt.tight_layout()
        plt.savefig(path, dpi=120)
        plt.close()

VAEVisualizationCallback

Bases: Callback

Callback to visualize VAE reconstructions during training.

Saves comparison plots of real vs reconstructed ECG signals at specified intervals.

Source code in src/ecgen/training/callbacks/visualization.py
class VAEVisualizationCallback(Callback):
    """
    Callback to visualize VAE reconstructions during training.

    Saves comparison plots of real vs reconstructed ECG signals at specified intervals.
    """

    lead_names: list[str] = LEAD_NAMES_12  # replaced by the datamodule's leads at runtime

    def __init__(
        self,
        save_dir: str | Path,
        log_every_n_epochs: int = 5,
        num_samples: int = 4,
        plot_all_leads: bool = True,
        log_to_tensorboard: bool = True,
        log_to_wandb: bool = False,
    ):
        """
        Args:
            save_dir: Directory to save visualization plots
            log_every_n_epochs: Generate visualizations every N epochs
            num_samples: Number of samples to visualize
            plot_all_leads: If True, plot all leads separately; if False, overlay them
            log_to_tensorboard: Log images to TensorBoard
            log_to_wandb: Log images to Weights & Biases
        """
        super().__init__()
        self.save_dir = Path(save_dir)
        self.save_dir.mkdir(parents=True, exist_ok=True)
        self.log_every_n_epochs = log_every_n_epochs
        self.num_samples = num_samples
        self.plot_all_leads = plot_all_leads
        self.log_to_tensorboard = log_to_tensorboard
        self.log_to_wandb = log_to_wandb

    def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule) -> None:
        self.lead_names = _datamodule_lead_names(trainer, LEAD_NAMES_12)
        """Called at the end of validation epoch."""
        # Skip during sanity check
        if trainer.sanity_checking:
            return

        current_epoch = trainer.current_epoch

        # Only log at specified intervals
        if current_epoch % self.log_every_n_epochs != 0:
            return

        # Get validation dataloader
        val_dataloader = trainer.val_dataloaders
        if val_dataloader is None:
            return

        # Get a batch of validation data
        batch = next(iter(val_dataloader))
        ecg = get_signal(batch)

        # Move to device
        ecg = ecg.to(pl_module.device)

        # Limit to num_samples
        ecg = ecg[:self.num_samples]

        # Generate reconstructions
        pl_module.eval()
        with torch.no_grad():
            if hasattr(pl_module, 'vae'):
                recon, _, _ = pl_module.vae(ecg)
            elif hasattr(pl_module, 'vqvae'):
                recon, _, _ = pl_module.vqvae(ecg)
            else:
                recon, _, _ = pl_module(ecg)
        pl_module.train()

        # Move to CPU for plotting
        ecg_cpu = ecg.cpu()
        recon_cpu = recon.cpu()

        # Create visualization
        if self.plot_all_leads:
            fig = self._plot_comparison_separate_leads(ecg_cpu, recon_cpu, current_epoch)
        else:
            fig = self._plot_comparison_overlay(ecg_cpu, recon_cpu, current_epoch)

        # Save to disk
        save_path = self.save_dir / f"epoch_{current_epoch:04d}.png"
        fig.savefig(save_path, dpi=150, bbox_inches='tight')
        plt.close(fig)

        print(f"\n{'='*60}")
        print(f"[Epoch {current_epoch}] Saved visualization to: {save_path}")
        print(f"{'='*60}\n")

        # Log to TensorBoard
        if self.log_to_tensorboard and trainer.logger is not None:
            for logger in trainer.loggers if hasattr(trainer, 'loggers') else [trainer.logger]:
                if hasattr(logger, 'experiment') and hasattr(logger.experiment, 'add_figure'):
                    logger.experiment.add_figure(
                        "vae_reconstruction",
                        fig,
                        global_step=current_epoch
                    )

        # Log to Weights & Biases
        if self.log_to_wandb:
            try:
                import wandb
                for logger in trainer.loggers if hasattr(trainer, 'loggers') else [trainer.logger]:
                    if hasattr(logger, 'experiment') and hasattr(logger.experiment, 'log'):
                        logger.experiment.log({
                            "vae_reconstruction": wandb.Image(str(save_path)),
                            "epoch": current_epoch,
                        })
            except (ImportError, AttributeError):
                pass

    def _plot_comparison_separate_leads(
        self,
        real: torch.Tensor,
        recon: torch.Tensor,
        epoch: int
    ) -> plt.Figure:
        """
        Plot real vs reconstructed ECG with separate subplots for each lead.

        Args:
            real: Real ECG tensor [batch, channels, length]
            recon: Reconstructed ECG tensor [batch, channels, length]
            epoch: Current epoch number

        Returns:
            Matplotlib figure
        """
        n_samples = real.shape[0]
        n_leads = real.shape[1]

        # Create figure with subplots: rows for samples, columns for leads
        fig, axes = plt.subplots(
            n_samples * 2,  # 2 rows per sample (real + recon)
            n_leads,
            figsize=(2 * n_leads, 2 * n_samples * 2)
        )

        # Ensure axes is 2D
        if n_samples == 1 and n_leads == 1:
            axes = [[axes]]
        elif n_samples == 1:
            axes = [axes]
        elif n_leads == 1:
            axes = [[ax] for ax in axes]

        for sample_idx in range(n_samples):
            real_ecg = real[sample_idx].numpy()
            recon_ecg = recon[sample_idx].numpy()

            for lead_idx in range(n_leads):
                # Real ECG
                ax_real = axes[sample_idx * 2][lead_idx]
                ax_real.plot(real_ecg[lead_idx], color='C0', linewidth=0.7, label='Real')
                ax_real.set_xlim(0, real_ecg.shape[-1])
                ax_real.grid(True, alpha=0.3)

                # Add lead name on the left
                if lead_idx == 0:
                    ax_real.set_ylabel(f"Sample {sample_idx + 1}\nReal", fontsize=8)

                # Add lead name on top
                if sample_idx == 0:
                    lead_name = _name(self.lead_names, lead_idx, f"L{lead_idx}")
                    ax_real.set_title(lead_name, fontsize=9)

                # Remove x-axis labels for real (top row of each sample)
                ax_real.set_xticklabels([])

                # Reconstructed ECG
                ax_recon = axes[sample_idx * 2 + 1][lead_idx]
                ax_recon.plot(recon_ecg[lead_idx], color='C1', linewidth=0.7, label='Recon')
                ax_recon.set_xlim(0, recon_ecg.shape[-1])
                ax_recon.grid(True, alpha=0.3)

                if lead_idx == 0:
                    ax_recon.set_ylabel("Recon", fontsize=8)

                # Only show x-axis label on bottom row
                if sample_idx == n_samples - 1:
                    ax_recon.set_xlabel("Time", fontsize=8)

        fig.suptitle(f"VAE Reconstruction - Epoch {epoch}", fontsize=12, y=0.995)
        plt.tight_layout()

        return fig

    def _plot_comparison_overlay(
        self,
        real: torch.Tensor,
        recon: torch.Tensor,
        epoch: int
    ) -> plt.Figure:
        """
        Plot real vs reconstructed ECG with overlaid leads.

        Args:
            real: Real ECG tensor [batch, channels, length]
            recon: Reconstructed ECG tensor [batch, channels, length]
            epoch: Current epoch number

        Returns:
            Matplotlib figure
        """
        n_samples = real.shape[0]
        n_leads = real.shape[1]

        # Create figure with 2 columns (real, recon) and n_samples rows
        fig, axes = plt.subplots(n_samples, 2, figsize=(12, 3 * n_samples))

        # Ensure axes is 2D
        if n_samples == 1:
            axes = [axes]

        for sample_idx in range(n_samples):
            real_ecg = real[sample_idx].numpy()
            recon_ecg = recon[sample_idx].numpy()

            # Plot real ECG
            ax_real = axes[sample_idx][0]
            for lead_idx in range(n_leads):
                lead_name = _name(self.lead_names, lead_idx, f"L{lead_idx}")
                ax_real.plot(
                    real_ecg[lead_idx],
                    linewidth=0.5,
                    alpha=0.7,
                    label=lead_name
                )
            ax_real.set_title(f"Sample {sample_idx + 1} - Real", fontsize=10)
            ax_real.set_xlim(0, real_ecg.shape[-1])
            ax_real.grid(True, alpha=0.3)
            if sample_idx == 0:
                ax_real.legend(loc="upper right", fontsize=6, ncol=2)

            # Plot reconstructed ECG
            ax_recon = axes[sample_idx][1]
            for lead_idx in range(n_leads):
                lead_name = _name(self.lead_names, lead_idx, f"L{lead_idx}")
                ax_recon.plot(
                    recon_ecg[lead_idx],
                    linewidth=0.5,
                    alpha=0.7,
                    label=lead_name
                )
            ax_recon.set_title(f"Sample {sample_idx + 1} - Reconstructed", fontsize=10)
            ax_recon.set_xlim(0, recon_ecg.shape[-1])
            ax_recon.grid(True, alpha=0.3)

            # Add x-label on bottom row
            if sample_idx == n_samples - 1:
                ax_real.set_xlabel("Time (samples)", fontsize=9)
                ax_recon.set_xlabel("Time (samples)", fontsize=9)

        fig.suptitle(f"VAE Reconstruction - Epoch {epoch}", fontsize=12)
        plt.tight_layout()

        return fig

training.metrics.reconstruction

Reconstruction quality metrics.

reconstruction_mse

reconstruction_mse(pred: Tensor, target: Tensor) -> Tensor

Mean squared error for reconstruction quality.

Source code in training/metrics/reconstruction.py
7
8
9
def reconstruction_mse(pred: Tensor, target: Tensor) -> Tensor:
    """Mean squared error for reconstruction quality."""
    return torch.mean((pred - target) ** 2)

reconstruction_mae

reconstruction_mae(pred: Tensor, target: Tensor) -> Tensor

Mean absolute error for reconstruction quality.

Source code in training/metrics/reconstruction.py
def reconstruction_mae(pred: Tensor, target: Tensor) -> Tensor:
    """Mean absolute error for reconstruction quality."""
    return torch.mean(torch.abs(pred - target))

training.metrics.generation

Generation quality metrics.

signal_to_noise_ratio

signal_to_noise_ratio(signal: Tensor, noise: Tensor) -> Tensor

Calculate signal-to-noise ratio.

Source code in training/metrics/generation.py
def signal_to_noise_ratio(signal: Tensor, noise: Tensor) -> Tensor:
    """Calculate signal-to-noise ratio."""
    signal_power = torch.mean(signal ** 2)
    noise_power = torch.mean(noise ** 2)
    return 10 * torch.log10(signal_power / (noise_power + 1e-8))

training.metrics.clinical

Metrics for ECG generation evaluation.

wasserstein_distance

wasserstein_distance(d_real: Tensor, d_fake: Tensor) -> Tensor

Compute Wasserstein distance estimate from discriminator outputs.

Parameters:

Name Type Description Default
d_real Tensor

Discriminator output on real samples

required
d_fake Tensor

Discriminator output on fake samples

required

Returns:

Type Description
Tensor

Wasserstein distance estimate (higher is better)

Source code in training/metrics/clinical.py
def wasserstein_distance(d_real: Tensor, d_fake: Tensor) -> Tensor:
    """
    Compute Wasserstein distance estimate from discriminator outputs.

    Args:
        d_real: Discriminator output on real samples
        d_fake: Discriminator output on fake samples

    Returns:
        Wasserstein distance estimate (higher is better)
    """
    return d_real.mean() - d_fake.mean()

gradient_penalty_loss

gradient_penalty_loss(discriminator: Module, real_data: Tensor, fake_data: Tensor, lmbda: float = 10.0) -> Tensor

Compute WGAN-GP gradient penalty.

Parameters:

Name Type Description Default
discriminator Module

Discriminator network

required
real_data Tensor

Real samples (B, C, L)

required
fake_data Tensor

Fake samples (B, C, L)

required
lmbda float

Gradient penalty coefficient

10.0

Returns:

Type Description
Tensor

Gradient penalty loss

Source code in training/metrics/clinical.py
def gradient_penalty_loss(
    discriminator: torch.nn.Module,
    real_data: Tensor,
    fake_data: Tensor,
    lmbda: float = 10.0,
) -> Tensor:
    """
    Compute WGAN-GP gradient penalty.

    Args:
        discriminator: Discriminator network
        real_data: Real samples (B, C, L)
        fake_data: Fake samples (B, C, L)
        lmbda: Gradient penalty coefficient

    Returns:
        Gradient penalty loss
    """
    batch_size = real_data.size(0)
    alpha = torch.rand(batch_size, 1, 1, device=real_data.device)
    alpha = alpha.expand_as(real_data)

    interpolates = alpha * real_data + (1 - alpha) * fake_data
    interpolates.requires_grad_(True)

    disc_interpolates = discriminator(interpolates)

    gradients = torch.autograd.grad(
        outputs=disc_interpolates,
        inputs=interpolates,
        grad_outputs=torch.ones_like(disc_interpolates, device=real_data.device),
        create_graph=True,
        retain_graph=True,
        only_inputs=True,
    )[0]

    gradients = gradients.view(batch_size, -1)
    gradient_penalty = ((gradients.norm(2, dim=1) - 1) ** 2).mean() * lmbda

    return gradient_penalty