PhysioNet/CinC Challenge 2021

Open Completed

Quick facts

Format12-lead · 5–1,800 s · 257/500/1,000 Hz
Patients
Records88,253
Leads12
LicenseCC BY 4.0
OriginCPSC, INCART, PTB, PTB-XL, Georgia, Chapman-Shaoxing, Ningbo — Multi-national (China, Russia, Germany, USA)

Overview

The public training set of the 2021 PhysioNet/Computing in Cardiology Challenge, Will Two Do? Varying Dimensions in Electrocardiography: 88,253 twelve-lead records pooled from eight source cohorts across China, Russia, Germany and the USA, redistributed in one uniform WFDB tree.

This is a meta-dataset, and that is the single most important thing to know about it. Five of the eight cohorts are datasets ECGBench catalogues separately, and the records were renamed, so the overlap is invisible from the identifiers. Four of those overlaps are verified against the files in the relationships above — including PTB-XL, which is present in full and bit-identical. Training here and evaluating on PTB-XL, PTBDB, INCART, CPSC-2018, Chapman-Shaoxing, Ningbo or PhysioNet ecg-arrhythmia is testing on training data.

Only the training set was ever published. The challenge’s hidden validation and test sets are not in this release, which is why the record count is 88,253 and not the ~131,000 the challenge described. There is consequently no reproducible official split, and ECGBench generates its own 10 folds.

Sampling rate and record length are per-record properties here, unlike every other dataset in this catalogue. Rate is 500 Hz for 87,663 records, 1000 Hz for the 516 ptb ones and 257 Hz for the 74 st_petersburg_incart ones; length runs from 5 s to 1800 s, with 1,650 distinct lengths in the cpsc_2018 cohort alone. Both are exposed as label columns. Records cannot be batched as they are — take a fixed window=(start, length), or use batch_size=1.

Labels are multi-label SNOMED-CT codes from the #Dx header field: 133 distinct codes, 2.06 per record on average and up to 12, with no unlabelled record. No code table ships with the data, so ECGBench packages the challenge’s own.

Source cohorts

CohortRecordsRateLengthAlso catalogued as
ningbo34,905500 Hz10 spart of PhysioNet ecg-arrhythmia
ptb-xl21,837500 Hz10 sPTB-XL (v1.0.1, in full)
georgia10,344500 Hz5–10 s— (no standalone release)
chapman_shaoxing10,247500 Hz10 spart of PhysioNet ecg-arrhythmia
cpsc_20186,877500 Hz6–144 sCPSC-2018 training set
cpsc_2018_extra3,453500 Hz8–98 s— (unreleased CPSC extra set)
ptb5161,000 Hz32–120 sPTB Diagnostic ECG Database (516 of 549)
st_petersburg_incart74257 Hz1,800 sSt Petersburg INCART (74 of 75)
**total****88,253**

Most frequent diagnoses

DiagnosisAbbr.SNOMED-CTRecordsScored
sinus rhythmNSR42678300628,971yes
sinus bradycardiaSB42617700118,918yes
t wave abnormalTAb16493400211,716yes
sinus tachycardiaSTach4270840009,657yes
atrial flutterAFL1648900078,374yes
left axis deviationLAD397320037,631yes
myocardial infarctionMI1648650056,144no
left ventricular high voltageLVHV558270055,401no
atrial fibrillationAF1648890035,255yes
s t changesSTC559300025,009no
nonspecific st t abnormalityNSSTTA4287500054,712no
left ventricular hypertrophyLVH1648730014,406no
t wave inversionTInv599310053,989yes
sinus arrhythmiaSA4273930093,790yes
st depressionSTD4296220053,645no
(128 further codes)

About those counts

Records: 88,253, not ~131,000. The challenge description quoted the full corpus including the hidden validation and test sets; PhysioNet only ever published the training set. Recomputed as the number of .hea files under training/. An earlier version of this catalogue page said “~130,862” — that figure belongs to the challenge, not to this download.

Patients: not published. No patient identifier ships with any cohort and records were renamed, so patient counts are unrecoverable from these files. This is exact for six cohorts (one record per patient), but not for ptb and st_petersburg_incart, whose source datasets have several recordings per patient — 113 of PTBDB’s 290 patients and roughly 32 patients across INCART’s 75 records. Those 590 records (0.67%) can therefore place one patient in more than one fold. Every other dataset in ECGBench with repeated patients groups folds by patient; this one cannot.

The diagnosis table is multi-label and does not sum to the record total. It counts records carrying each code, over 181,817 code instances across 88,253 records. Derived from the #Dx field of all 88,253 headers, joined to the challenge’s code table. The Scored column marks the 30 classes the challenge metric evaluated (as 26 classes, four pairs being scored as equivalent); 81,966 records carry at least one scored code and 6,287 carry none. Every one of the 133 codes present in the data has a table entry, and the per-code totals agree with the official dx_mapping_*.csv Total column on all 133 codes.

The stratification label is not the diagnosis. Because #Dx ordering carries no clinical meaning in this release — PTB-XL’s lists are numerically sorted, Chapman’s are not — there is no primary diagnosis to read off. ECGBench derives stratify_dx as the globally rarest code each record carries, ties broken on the lowest numeric code, purely so stratified folds are well defined. It keeps all 133 classes representable, where taking the first listed code collapses to 123. Train on dx, never on stratify_dx.

The code table is packaged with ECGBench, not with the data. No mapping ships in the download, so ecgbench/data/challenge2021_dx_mapping.csv holds the concatenation of the challenge’s own dx_mapping_scored.csv (30) and dx_mapping_unscored.csv (103) from the official scoring code (evaluation-2021, BSD-2-Clause). It covers exactly the 133 codes present, with no duplicate code or abbreviation.

One record is misnamed. The ningbo cohort contains S23074 where every other record in that cohort is JS##### (it is JS23074 in the ecg-arrhythmia release). Record names are still globally unique, so nothing collides, but a join on names against ecg-arrhythmia silently drops it.

Validation summary

VersionRecordsNote
original88,253all records, with is_valid + quality_issues
clean85,72697.1% pass rate
excluded2,5271,531 with one or more all-zero leads, 1,021 amplitude outliers

About the excluded records

Two genuine defects, both concentrated in specific cohorts rather than spread evenly — another reason not to treat the eight sources as interchangeable.

All-zero leads (1,531 records). Entire leads recorded as exactly 0.0, most often the precordials: 976 records have all six of V1–V6 flat. Overwhelmingly ningbo (1,480 of the 1,531). Verified on disk rather than inferred — A0718’s lead 11 is all zeros in the file.

Amplitude outliers (1,021 records). Excursions past ±10 mV. Many sit exactly at ±32.767 mV, the 16-bit rail, so they are saturation rather than physiology. st_petersburg_incart is worst hit at 28 of its 74 records (37.8%). 105 records also contain NaN samples, and 892 records are excluded for an amplitude outlier alone.

Per-cohort exclusion rates: st_petersburg_incart 37.8%, ningbo 6.3%, cpsc_2018_extra 1.8%, cpsc_2018 1.7%, chapman_shaoxing 0.4%, ptb 0.4%, georgia 0.3%, ptb-xl 0.2%. clean/ drops them; original/ keeps them with the reason in quality_issues.

Building the splits

ecgbench splits --dataset challenge2021 --data-path /path/to/challenge-2021/1.0.3/

Loading with ECGBench

from ecgbench import ECGDataset

# Records vary in length (5 s to 1800 s) and in sampling rate, so take a
# fixed window before batching — a DataLoader raises as soon as one batch
# mixes two lengths. 2500 samples is the shortest record, so it always fits.
#
# This dataset's fold CSVs are not on the HuggingFace Hub yet, so pass
# metadata_source="local" after running `ecgbench splits` and copying
# output/challenge2021/{clean,original}/ into the dataset root.
ds = ECGDataset(
    "challenge2021",
    split="train",
    data_path="/path/to/challenge-2021/1.0.3/",
    metadata_source="local",
    window=(0, 2500),        # first 5 s at the nominal 500 Hz
    labels=True,
)

len(ds)                                    # 68573
ds[0]["signal"].shape                      # (12, 2500)
ds[0]["record_id"]                         # 'A0009'
ds[0]["labels"]["dx"]                      # '164889003' — SNOMED-CT, multi-label
ds[0]["labels"]["dx_abbreviations"]        # 'AF'
ds[0]["labels"]["source"]                  # 'cpsc_2018' — which cohort it came from
ds[0]["labels"]["sampling_rate"]           # 500 — per record, not dataset-wide
ds[0]["labels"]["n_samples"]               # 8000 — i.e. 16 s before windowing

# Lead order is the standard one, so leads= selects by name directly.
ds.config.lead_names   # ['I','II','III','aVR','aVL','aVF','V1',...,'V6']

# Multi-hot target over the 30 scored classes:
import pandas as pd
from ecgbench.labels.challenge2021 import load_dx_mapping

mapping = load_dx_mapping()
scored = list(mapping.index[mapping["scored"]])            # 30 SNOMED codes
codes = ds.labels_df["dx"].fillna("").astype(str).str.split(",")
targets = pd.DataFrame(
    {c: codes.apply(lambda lst, c=c: int(c in lst)) for c in scored}
)
targets.shape                          # (68573, 30)
(targets.sum(axis=1) == 0).sum()       # 4882 records carry no scored code

# Rate is a per-record property: filter, don't pass sampling_rate=.
ds.labels_df["sampling_rate"].value_counts()   # 500: 68120, 1000: 414, 257: 39

# Excluding a cohort you plan to evaluate on (see the leakage warnings above):
keep = ds.labels_df["source"] != "ptb-xl"      # 51114 of 68573 records remain