PTB-XL

Open Completed

Quick facts

Format12-lead · 10 s · 500 Hz (also 100 Hz)
Patients18,869
Records21,799
Leads12
LicenseCC BY 4.0
OriginPhysikalisch-Technische Bundesanstalt — Germany

Overview

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.

Diagnostic superclass breakdown

SuperclassDescriptionRecords (v1.0.3)Paper (v1.0.1)Diff
NORMNormal ECG9,5149,528-14
MIMyocardial Infarction5,4695,486-17
STTCST/T changes5,2355,250-15
CDConduction disturbance4,8984,907-9
HYPHypertrophy2,6492,655-6

About those counts

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:

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.

Loading with ECGBench

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')

Getting the labels

# 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.

Inspecting the catalogue entry

from ecgbench import get_dataset

entry = get_dataset("ptb-xl")
print(entry.patients, entry.records, entry.access)
# -> 18,869 21,799 open