| Format | 12-lead · 10 s · 500 Hz (also 100 Hz) |
|---|---|
| Patients | 18,869 |
| Records | 21,799 |
| Leads | 12 |
| License | CC BY 4.0 |
| Origin | Physikalisch-Technische Bundesanstalt — Germany |
PTB-XL is a large, publicly available 12-lead ECG dataset with 21,799 clinical records from 18,869 patients, collected at the Physikalisch-Technische Bundesanstalt (PTB) between October 1989 and June 1996. Each record is 10 seconds long and provided at both 500 Hz and 100 Hz. Records are annotated with up to 71 SCP-ECG statements grouped into 5 diagnostic superclasses (NORM, MI, STTC, CD, HYP).
ECGBench bundles a deterministic 10-fold stratified patient-level split
derived from the SCP superclass labels, ready to consume via the
ECGDataset class. Note that the published fold CSVs carry identifiers,
signal paths and fold assignments only — no labels. Join them back to
ptbxl_database.csv on ecg_id for ground truth, as shown below.
| Superclass | Description | Records (v1.0.3) | Paper (v1.0.1) | Diff |
|---|---|---|---|---|
| NORM | Normal ECG | 9,514 | 9,528 | -14 |
| MI | Myocardial Infarction | 5,469 | 5,486 | -17 |
| STTC | ST/T changes | 5,235 | 5,250 | -15 |
| CD | Conduction disturbance | 4,898 | 4,907 | -9 |
| HYP | Hypertrophy | 2,649 | 2,655 | -6 |
The published figures do not match the version ECGBench splits. The
Scientific Data paper reports counts for v1.0.1 (21,837 records); v1.0.3
ships 21,799 after dropping 38 duplicate and triplicate records and
revising some labels by consensus, as documented in the dataset’s own
ptbxl_v103_changelog.txt. Every superclass is therefore a little smaller
than the paper says. The “Records (v1.0.3)” column above is recomputed from
the shipped files, so those are the numbers you will actually reproduce.
Two further caveats on reading the table:
diagnostic in scp_statements.csv.Recomputed with: SCP codes from ptbxl_database.csv, mapped through the
diagnostic_class column of scp_statements.csv for rows where
diagnostic == 1.
The stratification label is a different quantity from this table.
primary_superclass reduces each record to one superclass by summed
likelihood, so its distribution — NORM 9,243 / STTC 4,158 / MI 4,079 /
CD 3,162 / HYP 746 / OTHER 411 — is not the multi-label table above.
2,358 records (10.8%) have two or more superclasses tied on likelihood, and
PTB-XL likelihood 0.0 means “present but not graded” rather than
“absent”, so which class wins a tie is settled by a fixed arbitrary order,
not by the data. Train on superclasses or subclasses, never on
primary_superclass.
Both now come from the same derivation. PTBXLSplitter used to carry its
own hardcoded code map, which had drifted from scp_statements.csv — five
diagnostic codes missing, seven non-diagnostic ones treated as diagnostic,
putting 465 records in OTHER instead of 411. It now takes the label from
ecgbench.labels.ptbxl, so the two cannot disagree. Fold assignments are
unchanged, because PTB-XL folds come from the official strat_fold column
and the stratification label only records what they were balanced on.
from ecgbench import ECGDataset, ecg_collate_fn
from torch.utils.data import DataLoader
# Load the training split (folds 1-8) at 100 Hz.
# Fold CSVs come from the Hub; signals are read from your local copy.
dataset = ECGDataset(
"ptbxl",
split="train",
version="clean",
data_path="/path/to/ptb-xl/1.0.3/",
sampling_rate=100,
labels=True, # attach SCP codes, super/subclasses, report, demographics
)
loader = DataLoader(dataset, batch_size=32, collate_fn=ecg_collate_fn)
for batch in loader:
signals = batch["signal"] # (B, 12, 1000) float32, at 100 Hz
ecg_ids = batch["record_id"] # tensor of ecg_id values
folds = batch["fold"] # tensor of fold numbers (1-10)
labels = batch["labels"] # list of per-record dicts
break
# Lead order here is I II III AVR AVL AVF V1-V6 — note the uppercase
# spelling. Selecting by name is case-insensitive and dataset-independent:
limb = ECGDataset("ptbxl", split="train", data_path="/path/to/ptb-xl/1.0.3/",
leads=["I", "II", "aVL"], units="uV")
limb[0]["signal"].shape # (3, 5000)
limb.lead_names # ('I', 'II', 'AVL')
# ECGDataset(labels=True) does this join for you. Do it by hand only when
# you want the labels without building a Dataset — for class weights,
# filtering, or inspection. Fold CSVs are identification-only.
import ast
import pandas as pd
PTBXL = "/path/to/ptb-xl/1.0.3/"
src = pd.read_csv(PTBXL + "ptbxl_database.csv")
labelled = dataset.metadata_df.merge(
src[["ecg_id", "scp_codes", "age", "sex", "report"]],
on="ecg_id", how="left", validate="one_to_one",
)
# scp_codes is a dict-string of SCP code -> confidence. Map it to the five
# diagnostic superclasses via the statement table shipped with PTB-XL.
stmt = pd.read_csv(PTBXL + "scp_statements.csv", index_col=0)
code2class = stmt.loc[stmt.diagnostic == 1, "diagnostic_class"].to_dict()
labelled["superclasses"] = labelled.scp_codes.map(
lambda s: sorted({code2class[c] for c in ast.literal_eval(s) if c in code2class})
)
# -> ecg_id 1: ['NORM']; ecg_id 39: ['MI', 'STTC'] (fold 9, so it is in val)
# 5,144 of the 21,799 records carry more than one superclass, so treat this
# as multi-label. The single superclass used for stratification is a lossy view.
from ecgbench import get_dataset
entry = get_dataset("ptb-xl")
print(entry.patients, entry.records, entry.access)
# -> 18,869 21,799 open