MedalCare-XL (Synthetic 12-Lead ECGs from Simulations)

Open Completed

Quick facts

Format12-lead · 10 s · 500 Hz · CSV (leads in rows, no header) · raw/noise/filtered variants
Patients0 (synthetic)
Records16,842
Leads12
LicenseCC BY 4.0
OriginMedical Univ. of Graz; KIT; PTB; Univ. of Edinburgh — Austria / Germany / UK

Overview

16,842 twelve-lead ECGs that no heart ever produced. Every record is the output of a multi-scale electrophysiological simulation: atrial and ventricular models were run independently over anatomical meshes, composed into 10 s rhythms at 500 Hz, and projected onto torso models to give a surface 12-lead trace. Eight conditions ship, one of them subdivided eight ways.

That makes it the odd one out in this catalogue, and worth being precise about why. The label is exact by construction — it is the setting the simulator was given, so there is no reader disagreement, no comorbidity, no borderline case and no diagnostic uncertainty. A model that separates these classes perfectly has learned to separate simulator configurations, which is a weaker claim than separating patients. Used as intended — pre-training, augmentation, controlled ablations where you need to vary one physiological parameter and hold the rest fixed — the exactness is the point. Used as a benchmark on its own, it will overstate.

Three practical things to know before loading it:

The CSVs are transposed relative to every other CSV dataset here. Each file is 12 rows × 5000 columns with no header row — one row per lead, samples running along the row. chapman_shaoxing and ningbo_iva are the other way round, samples in rows under a header naming the leads. ECGBench gives this its own signal_format, csv_lead_rows, because reading one layout with the other’s reader returns a plausibly-shaped array of the wrong thing rather than raising.

Each record ships three times. <n>_raw.csv is the noise-free simulator output, <n>_noise.csv adds superimposed noise, and <n>_filtered.csv applies the release’s 0.5–150 Hz order-3 Butterworth pair. These are one record in three renderings, not three records. ECGBench wires up filtered, the closest of the three to a recorded ECG; the other two are one string substitution away and the label loader exposes both.

The split is the authors’ own, not a generated one. Their train/validation/test directories become folds 1, 2 and 3, so --n-folds has no effect here and there are three fold CSVs rather than ten. See “The split guarantee has one hole” below for the one place it does not do what the README says.

Class breakdown (all 16,842 records, recomputed from the files)

ConditionSubclassTrainValTestTotal
Normal sinus rhythmsinus9002002001,300
AV blockavblock9002002001,300
Left bundle branch blocklbbb9002002001,300
Right bundle branch blockrbbb8982002001,298
Left atrial enlargementlae1,0401301301,300
Fibrotic atrial cardiomyopathyfam1,0401301301,300
Interatrial conduction blockiab9941241261,244
Myocardial infarctionLAD_0.39002002001,300
LAD_1.09002002001,300
LCX_0.3_ant450150100700
LCX_0.3_post450100100650
LCX_1.0_ant400100100600
LCX_1.0_post450100100650
RCA_0.39002002001,300
RCA_1.09002002001,300
**MI subtotal****5,350****1,250****1,200****7,800**
**All****12,022****2,434****2,386****16,842**

About those counts

The title says 16,900 and what ships is 16,842. Both the paper’s title and the Zenodo title read “16,900 … electrocardiograms”. The v1.3 deposit holds 16,842, and there is no changelog explaining the difference.

Figure Published ECGBench (v1.3) Diff
Records 16,900 16,842 −58

The recomputed figure was derived three independent ways, which all agree:

The shortfall is concentrated in iab, the only class that does not reach a round total (1,244 against 1,300 for its peers). 13 of the 186 run directories have gaps in their file numbering — iab/train/run_S66 holds 107 files numbered up to 000130, and iab/test/run_S62 is missing numbers 17, 71, 72 and 75 out of 130. ECGBench enumerates records from the files that exist rather than from a range, so a gap costs a record rather than producing a broken path.

Class counts are single-label and sum to the record total, unlike the multi-label datasets in this catalogue. Every record carries exactly one condition and none is unlabelled. The 15-class pathology_subclass above is also what the config stratifies on, so the split table and the stratification label are the same quantity here — which is not true of most pages in this catalogue.

Myocardial infarction is 46.3% of the release and each remaining condition is 7.4–7.7%. That is an authoring choice about how finely to subdivide MI, not a prevalence.

The local copy was checksum-verified before any figure above was computed. MedalCare-XL.zip has md5 96497fcbc5c443bec1280f2033836776, matching Zenodo’s published value, and the extracted tree is an exact bidirectional match with the archive listing — 84,416 files, 0 missing, 0 extra.

The split guarantee has one hole

The release README states that ECGs “calculated with the same anatomical model but different electrophysiological parameters are only present in one of the test, validation and training datasets but never in multiple.”

That holds within each condition and fails across them. Ventricular model S64 is the test-side model for sinus, AV block, LBBB, RBBB and every MI subclass — and simultaneously a train-side model for the three atrial conditions (fam, iab, lae). S67 does the same for validation against train. Eleven of the thirteen models sit in exactly one fold; those two straddle.

This is not a name collision. Checked at the parameter level: sinus/test/run_S64 and fam/train/run_S64 agree on all 84 ventricular parameters that are constant within a run directory — same ionic model, same conductivities, same action-potential durations, same fibre angles. It is one ventricular model appearing on both sides of the split.

No records are shared, though, so this is not duplicate-row leakage. Two independent checks:

What it means in practice: if you train a classifier that spans the atrial conditions (fam/iab/lae) and the ventricular ones (everything else), the same ventricular anatomy appears in both your training and your test set. Within either arm alone, the guarantee holds as stated. model_id is exported in every fold CSV precisely so this is checkable — and regroupable — by hand rather than being something you have to take on trust.

Ventricular model by fold

ModelFolds it appears inNote
S623 (test)
S632 (val)
S64**1 and 3**test for sinus/avblock/lbbb/rbbb/mi, train for fam/iab/lae
S65–S661 (train)
S67**1 and 2**val for sinus/avblock/lbbb/rbbb/mi, train for fam/iab/lae
S68–S741 (train)

Validation summary (500 Hz)

VersionRecordsNote
original16,842all records, with is_valid + quality_issues
clean16,81099.81% pass rate
excluded32all amplitude_outlier — see below

32 excluded records, and why it is not a units bug

All 32 exclusions fail amplitude_outlier, and 30 of the 32 fail on V1 (the other two on V2 and V3). The magnitudes are not marginal: 10.1 to 879.6 mV, median 25.7 mV, where a random 400-record sample of the release peaks at a median of 2.09 mV with a 99th percentile of 5.30 mV. There is a wide empty gap between the bulk of the release and the failures, so the [-10, 10] mV threshold is not slicing into a continuum.

Two things rule out a configuration mistake on ECGBench’s side. First, the blowups are already present in the raw simulator output, before the noise and filtering stages ever ran, so they are a forward-solution artefact rather than something the release’s processing introduced. Second, if the unit scale were wrong, every record would fail, not 0.19% of them.

V1 is the precordial electrode closest to the ventricular surface, which is where a torso mesh node sitting too near the source would produce exactly this. The exclusions span six of the eight conditions (24 of the 32 are MI records), so it is not a property of one class.

They are not deleted — version="original" returns all 16,842 with is_valid and quality_issues marking them, and output/medalcare_xl/validation_report.json names each one and the lead and range that failed.

The simulation parameters are the real ground truth

Every record ships two plain-text parameter files alongside it: <n>_AtrialParameters.txt (~21 keys) and <n>_VentricularParameters.txt (~105 keys), holding the ionic model, tissue conductivities, regional conduction velocities, action-potential durations, fibre angles, stimulus sites, ischaemic-region geometry, the atrial and torso meshes and all ten electrode positions. For a simulated dataset these are the ground truth — the pathology label is a summary of them.

ecgbench.labels.medalcare_xl.load_simulation_parameters returns them as a frame indexed by record id, with atrial. and ventricular. prefixes because the two files reuse key names (im.name, G.torso) and an unprefixed concat would silently drop one of each. It is opt-in: labels=True does not read them, because that would be 33,684 file opens on every dataset construction. Pass record_ids= to restrict it to a split.

The key set is ragged, and that is information rather than damage:

Condition Parameter difference
MI adds 14 isch[0].* keys — the ischaemic region’s size, position and velocities
LBBB / RBBB drop the stim[*] block; removing stimulus sites is how the block is created
LAE drops the 4 cv_t.* regional atrial conduction velocities

So the union is 119 ventricular and 21 atrial keys, of which 70 and 17 appear in every condition. A NaN means the condition has no such parameter, not that a value is missing.

One thing ECGBench deliberately does not read: the per-run-directory siginfo.csv. For fam, iab and lae its info2 column holds a foreign simulation id rather than the record number, and in 13 of the 186 run directories it has more rows than there are records — so joining it means guessing at row order, on exactly the tables where the guess is least checkable. The parameter files carry the same anatomy (geo.atria, geo.torso) keyed by record number, so nothing is lost by ignoring it.

How the folds were made

Not generated — adopted. The release’s own train/, validation/ and test/ directories become folds 1, 2 and 3, giving 12,022 / 2,434 / 2,386 (71.4% / 14.5% / 14.2%, matching the README’s stated ~70/15/15). Because the splits are predefined, --n-folds has no effect and there are three fold CSVs, not ten.

patient_id_column is set to model_id — the ventricular simulation model, S62S74. There are no patients here, and rather than leave the field null this points at the grouping unit that plays the same role: the thing the authors defined the split around, and the one whose reuse is worth being able to see. Read “The split guarantee has one hole” above before assuming it is fold-disjoint; two of the thirteen models are not.

Fold membership is identical between original/ and clean/clean/ is a row subset of 16,810, not a re-split.

With one train fold and one test fold there is no ten-fold rotation to run here. If you want cross-validation, regroup by model_id, which every fold CSV carries.

Building the splits

# Point --data-path at the INNER directory: the release extracts to a
# MedalCare-XL/MedalCare-XL/ nesting, and the one you want holds
# WP2_largeDataset_Noise/. It must be writable — the release ships no
# metadata table, so ECGBench generates ecgbench_metadata.csv there.
ecgbench splits --dataset medalcare_xl \
  --data-path /path/to/MedalCare-XL/MedalCare-XL/

Loading with ECGBench

from ecgbench import ECGDataset

# Fold CSVs come from the Hub; data_path points at your local signals.
# labels=True needs `ecgbench splits` to have run once — the release ships
# no metadata table, so the labels come from the CSV that run generates.
ds = ECGDataset(
    "medalcare_xl",
    split="train",
    data_path="/path/to/MedalCare-XL/MedalCare-XL/",
    labels=True,
)

len(ds)                                    # 11996   (clean; original: 12022)
ds[0]["signal"].shape                      # (12, 5000)   10 s at 500 Hz
ds[0]["record_id"]                         # 'avblock_train_S65_000001'
ds[0]["labels"]["pathology"]               # 'avblock'
ds[0]["labels"]["pathology_name"]          # 'AV block'
ds[0]["labels"]["pathology_subclass"]      # 'avblock'   (15-class label)
ds[0]["labels"]["mi_subclass"]             # nan  — blank for non-MI records
ds[0]["labels"]["model_id"]                # 'S65'  — the simulation model
ds[0]["signal"].abs().max()                # 1.9783  (millivolts, no scaling)

# The CSVs are 12 rows x 5000 columns with NO header — the transpose of
# every other CSV dataset here. That is what csv_lead_rows means.
ds.config.signal_format                    # 'csv_lead_rows'
ds.config.lead_names
# ['I','II','III','aVR','aVL','aVF','V1','V2','V3','V4','V5','V6']

# Standard lead order, so leads= is about selection rather than repair.
lead = ECGDataset("medalcare_xl", split="train",
                  data_path="/path/to/MedalCare-XL/MedalCare-XL/",
                  leads=["II", "V1", "V5"])
lead[0]["signal"].shape                    # (3, 5000)

# Every record is exactly 5000 samples, so window= is optional here —
# useful for cutting batch size, not for making batching possible.
win = ECGDataset("medalcare_xl", split="train",
                 data_path="/path/to/MedalCare-XL/MedalCare-XL/",
                 window=(3000, 2000))     # the last 4 s
win[0]["signal"].shape                     # (12, 2000)

# Samples are already millivolts; units="uV" scales by 1000.
uv = ECGDataset("medalcare_xl", split="train",
                data_path="/path/to/MedalCare-XL/MedalCare-XL/",
                units="uV")
uv[0]["signal"].abs().max()                # 1978.3

The other two signal variants, and the simulation parameters

import numpy as np
from ecgbench.labels import load_labels
from ecgbench.labels.medalcare_xl import load_simulation_parameters

root = "/path/to/MedalCare-XL/MedalCare-XL/"
labels = load_labels("medalcare_xl", root)

# ECGBench wires up the filtered variant; the other two are columns here.
labels.loc["sinus_train_S65_000001", ["signal_path",
                                      "signal_path_raw",
                                      "signal_path_noise"]]

# One record in three renderings — same simulation, different processing.
row = labels.loc["sinus_train_S65_000001"]
for column in ("signal_path_raw", "signal_path_noise", "signal_path"):
    signal = np.loadtxt(f"{root}/{row[column]}", delimiter=",")
    print(column, signal.shape, round(float(np.abs(signal).max()), 3))

# The parameters that produced each record. Opt-in: it reads two text
# files per record, so restrict it to the split you care about.
params = load_simulation_parameters(
    root, "medalcare_xl", record_ids=list(labels.index[:200])
)
params["ventricular.im.name"].iloc[0]      # 'MitchellSchaeffer'
params["atrial.im.name"].iloc[0]           # 'Courtemanche'
params["atrial.geo.atria"].iloc[0]         # 'cn617_g043'  — the atrial mesh
params["atrial.geo.torso"].iloc[0]         # 'torsoID16'
# Units travel with the value rather than being coerced away:
params["atrial.cv_t.BulkTissue"].iloc[0]   # '591mm/s'
# 14 isch[0].* columns, populated for MI records and NaN elsewhere.