PTB-XL+

Open Completed

Quick facts

Formatfeatures & annotations for PTB-XL · no raw ECGs
Patients18,869
Records21,799
Leads12
LicenseCC BY 4.0
OriginKarlsruhe Institute of Technology — Germany

Overview

PTB-XL+ is a companion release, not a standalone dataset: it ships no raw ECGs. It annotates the same 21,799 recordings PTB-XL holds, keyed by PTB-XL’s own ecg_id, and its value is that it gives several independent opinions about each recording:

Everything is CC BY 4.0, so unlike MIMIC-IV-ECG there is no redistribution question here.

How ECGBench integrates it — no separate splits

There is deliberately no ptbxl_plus config, and no ecgbench splits --dataset ptbxl_plus. Every PTB-XL+ row is a PTB-XL record, so generating a ten-fold partition for it would create a second ECGBench-blessed split over recordings that ptbxl already partitions. A user who trained on PTB-XL’s folds and evaluated on PTB-XL+’s would be testing on training data, with both partitions carrying ECGBench’s imprimatur. Rather than create that trap and then warn about it, we do not create it.

Instead PTB-XL+ is a label and feature provider: load PTB-XL on its own official folds and join these columns onto it.

from ecgbench import ECGDataset
from ecgbench.labels.ptbxl_plus import load_ptbxl_plus

ds = ECGDataset("ptbxl", split="train",
                data_path="/path/to/ptb-xl/1.0.3/", labels=True)
plus = load_ptbxl_plus("/path/to/ptb-xl-plus/1.0.1/", features=("unig",))
joined = plus.reindex(ds.metadata_df["ecg_id"].values)   # 100% match

You need both downloads: PTB-XL+ has no waveforms.

What ships, and how much of PTB-XL it covers

ArtefactRows / filesColumnsCoverage of PTB-XL's 21,799
`labels/ptbxl_statements.csv`21,7993complete
`labels/12sl_statements.csv`21,7994complete
`features/ecgdeli_features.csv`21,799531complete
`features/12sl_features.csv`21,799782complete; key hidden at col 145
`features/unig_features.csv`21,795748**4 records missing**
`median_beats/unig/`21,7945 missing
`median_beats/12sl/`20,914**885 missing**
`fiducial_points/ecgdeli/`283,326 `.atr`~13 per record
`labels/snomed_description.csv`2878vocabulary, not per record
`features/feature_description.csv`19510feature dictionary

About those counts

Recomputed from the shipped files, all of which were verified against the release’s own SHA256SUMS.txt — so the four defects below are upstream, not download damage.

1. 12sl_features.csv hides its key column mid-table. ecg_id is column 145 of 783, sitting between QRS_Area_aVF and P_On_Global rather than at the front. Inspect the first or last few columns — the obvious thing to do with a 783-column table — and you will conclude it has no key at all. Locate the key by name, never by position.

2. Neither 12SL table is sorted by ecg_id. Both 12sl_features.csv and 12sl_statements.csv run 1, 21803, 21804, 21805, 21806, … — the same order as each other, but not ascending. So joining by row position, or assuming sorted ids, attaches values to the wrong recordings. (We confirmed the two files’ key columns are identical in order, so a positional join between them happens to work — but nothing else should rely on it.)

3. Every median_beats/12sl/*.hea is unreadable by wfdb.rdrecord (300 of 300 sampled). The record line reads ge_median_beats_wfdb/00001_medians 12 500 600 — a stale producer-side directory prefix — and wfdb rejects the /, raising HeaderSyntaxError: invalid syntax in record line. The unig headers are clean. The two providers also pad record stems differently: 00001_medians for 12sl, 000001_medians for unig.

4. unig median-beat amplitudes are about 1000x too large. They decode to 600 samples x 12 leads at 500 Hz — a 1.2 s averaged beat, correctly — but span roughly −1361 to +602 against a declared 5.9756(-1557)/mV gain, which is microvolts rather than millivolts.

Because of 3 and 4, ECGBench returns median beats as paths (median_beat_path()) and never decodes them: it will not hand back a signal whose units it cannot state. The feature and statement tables, which are what the dataset is mostly for, are unaffected.

Two label sets, two different quantities. ptbxl_scp_codes is cardiologist-assigned with per-statement likelihoods; 12sl_statements is a commercial algorithm’s output. They use different vocabularies, mapped into a shared 287-concept SNOMED set of which 176 appear in both. Do not treat either as ground truth for the other.

Loading with ECGBench

from ecgbench import ECGDataset
from ecgbench.labels.ptbxl_plus import load_features, load_ptbxl_plus

# Waveforms and folds come from PTB-XL; annotations from PTB-XL+.
ds = ECGDataset("ptbxl", split="train",
                data_path="/path/to/ptb-xl/1.0.3/", labels=True)
plus = load_ptbxl_plus("/path/to/ptb-xl-plus/1.0.1/", features=("unig",))

plus.shape                         # (21799, 755)
joined = plus.reindex(ds.metadata_df["ecg_id"].values)
joined.notna().any(axis=1).sum()   # 17376 of 17376 -- a complete join

joined.iloc[0]["ptbxl_scp_codes"]      # [('NORM', 100.0), ('LVOLT', 100.0), ('SR', 100.0)]
joined.iloc[0]["12sl_statements"]      # ['NSR', 'NML']
joined.iloc[0]["unig_QRS_Dur_Global"]  # 86.0 ms
joined.iloc[0]["unig_QT_Int_Global"]   # 410.0 ms

# The 12sl table is keyed for you from the statements file, in file order:
load_features("/path/to/ptb-xl-plus/1.0.1/", "12sl").index[:5]
# Index([1, 21803, 21804, 21805, 21806]) -- deliberately not ascending

# features= is empty by default: the three providers together are >2000
# columns, and prefix=True keeps their many shared names from colliding.