Conditioning¶
Conditional models generate ECGs given something: a label, patient data,
ECG measurements, or part of an ECG. Conditioning in ECGEN is separate from the
model families: a family that supports it takes a conditioning: block in its
model.params, and the same block works for every conditional family.
The pieces¶
| Piece | Where | What it does |
|---|---|---|
| Condition fields | the dataset | Each sample carries cond[field] (and cond_missing[field]). MIMIC: measurements (9 machine measurements). Synthetic: measurements (heart rate, amplitude), rhythm (3 classes), labels (3 multi-hot). |
| Field encoders | ecgen.conditioning.encoders |
Turn one field into one slot: a global vector, a sequence of tokens, or a time-aligned dense signal. |
| CompositeConditioner | ecgen.conditioning.composite |
Runs all encoders, sums vectors, concatenates tokens and dense channels, and applies condition dropout. Output: ConditionOutput(vector, tokens, tokens_mask, dense). |
| Injection layers | ecgen.nn.injection |
How a backbone consumes the slots: FiLM, AdaGN, AdaLNZero, AddToTimeEmb (vector), CrossAttention1d (tokens), concat_dense (dense). All start as the identity (zero-initialised). |
| Observation masks | ecgen.data.masks |
For partial-ECG conditioning: data.masks adds an obs_mask to every batch, and the masked_signal encoder conditions on signal * obs_mask. |
Encoders¶
name |
Field value | Slot | Notes |
|---|---|---|---|
categorical |
class index [B] |
vector | Missing / negative index → learned "unknown" embedding |
multilabel |
multi-hot [B, L] |
vector (or tokens with as_tokens: true) |
SSSD-ECG-style linear projection; tokens = one per present label |
numeric |
normalized values [B, F] + missing mask |
vector (or tokens) | Missingness is an explicit input |
fourier |
normalized values [B, F] |
vector | Random Fourier features, for sharp dependence on scalars like heart rate |
masked_signal |
reads obs / obs_mask (field name obs) |
dense | [x * mask, mask], optionally projected by a 1×1 conv |
text_hash |
strings list[str] |
tokens (or vector) | Hashed bag-of-words embeddings, trained with the model; no dependencies |
text |
strings list[str] |
tokens (or vector) | Frozen Hugging Face encoder (model_name, max_length); needs ecgen[text]; weights stay out of the checkpoint, embeddings are cached per distinct text |
Text conditions¶
Report text is a string field: MIMIC (report: true joins the machine report
lines), PTB-XL (report), and the synthetic data (a templated
"sinus tachycardia, high voltage"). Strings are collated into lists and
encoded into tokens that the backbone attends to (cross-attention in unet1d
and dit1d). At sampling time pass one string for all samples or one per
sample: sample(n, cond={"report": "Sinus bradycardia"}). An empty string or a
missing field uses the null token.
Config¶
model:
name: <a conditional family>
params:
conditioning:
vector_dim: 256 # every vector encoder is projected to this and summed
token_dim: 256
encoders:
- {field: measurements, name: numeric, params: {num_features: 9, dim: 256}}
- {field: obs, name: masked_signal, params: {num_leads: 12}}
dropout: {measurements: 0.1} # per-field drop probability while training
data:
masks: # only needed when an encoder reads `obs`
name: mixture
params:
components:
- {weight: 0.6, name: lead_subset, params: {keep: [[I], [I, II]]}}
- {weight: 0.2, name: time_span, params: {min_frac: 0.1, max_frac: 0.5}}
- {weight: 0.2, name: unobserved}
Mask generators: none (all observed), unobserved, lead_subset (keep lists of
lead names, or a random min_leads..max_leads), time_span (imputation),
forecast (observe a prefix), random_points, and mixture.
Dropout, missing fields, and guidance¶
While training, each field is replaced by a learned null embedding with its
dropout probability. A field that is not given at all (at sampling time) uses
the same null. So one model can be sampled with any subset of its conditions,
and classifier-free guidance compares a pass with the conditions to a pass with
drop="all".
Checks before training¶
The runner refuses a config when:
- the family does not support conditioning (
Capabilities.conditional); - an encoder produces a slot the family cannot consume (
accepts); - an encoder's field is not in the dataset's samples;
- an encoder reads
obsbutdata.masksis not set.
Raw values at sampling time¶
Training data statistics travel with the checkpoint (data_info: lead names,
sampling rate, length, and a normalizer per numeric field). At sampling time,
pass numeric fields in their raw units and the model normalizes them:
model = SomeFamily.load_from_checkpoint("runs/.../last.ckpt")
x = model.sample(8, cond={"measurements": [75.0, 1.2]}) # heart rate, amplitude
x = model.sample(8, cond={}) # unconditional (null embeddings)
Use NaN (or None) for an unknown value inside a numeric field.
Conditional families¶
| Family | accepts |
How conditions enter | Extras |
|---|---|---|---|
vae (CVAE) |
vector, dense | FiLM after every encoder/decoder residual block; dense concatenated to the encoder input and (pooled) to the decoder input | learned_prior: true → p(z \| c); latent_type: global → one latent vector per ECG, whose prior samples decode to coherent ECGs (spatial latents reconstruct well but their prior samples do not); recon_on: unobserved for imputation |
pulse2pulse (cGAN) |
vector, dense | Generator: FiLM on every decoder stage, dense concatenated to the input noise. Critic: projection head for vectors, dense concatenated to its input (paired / pix2pix critic) | l1_weight (e.g. 100) + l1_on: unobserved for lead reconstruction; the critic has its own conditioner copy (no dropout) |
vqvae_prior (conditional PixelCNN) |
vector, dense | Condition added to both gate pre-activations of every causal block (V_f h, V_g h); dense pooled to code resolution |
sample(..., guidance=w) = classifier-free guidance on the logits; the stage-1 VQ-VAE stays unconditional |
diffusion (and flow_matching) |
vector, tokens, dense | Vector added to the time embedding (→ AdaGN in every block), tokens by cross-attention at the attention levels, dense concatenated to the input | sample(guidance=w) = CFG; loss_on: unobserved + clean_observed for SSSD-style imputation; training-free inpaint="replace" or "repaint" |
All three keep their original parameters when conditioning is absent, so
unconditional checkpoints load unchanged. Conditional models paste_observed
the given part of obs into their output by default.
Example configs: configs/experiments/cvae_mimic.yaml,
pulse2pulse_lead_reconstruction.yaml, prior_mimic_conditional.yaml.
Checking condition adherence¶
scripts/evaluate/condition_adherence.py samples a model at a grid of
requested heart rates and measures the heart rate of the output
(ecgen.evaluation.measure.heart_rate, R-peak detection; method="neurokit"
uses NeuroKit2):
python scripts/evaluate/condition_adherence.py runs/cvae_mimic/seed_42/checkpoints/last.ckpt \
--family vae --target rr_interval --rates 50,70,90,110,130
It reports MAE and Pearson r between requested and measured rates; an unconditional checkpoint gives the baseline (r ≈ 0).