QT Database (QTDB)

Open Completed

Quick facts

Format2-lead · 20 lead layouts · 15 min · 250 Hz · WFDB
Patients103
Records105
Leads2
LicenseODC-By 1.0
OriginUniversidad de Zaragoza; MIT; Beth Israel Deaconess Medical Center — Spain / USA

Overview

The standard reference for ECG waveform delineation, and the only dataset in this catalogue whose ground truth is fiducial points rather than labels. 105 fifteen-minute two-channel excerpts at 250 Hz in which expert cardiologists marked, beat by beat, the onset, peak and end of the P wave, the onset and end of the QRS complex, the peak and end of the T wave and — where present — the peak and end of the U wave. 3,623 beats were annotated this way, on a graphic workstation showing both channels at once. It was built at the Universidad de Zaragoza and MIT and published in 1999 because no such reference existed: QT-measurement algorithms had nothing to be scored against.

Every record is an excerpt of another database’s recording, and six of the seven sources are already in this catalogue. European ST-T 33, sudden-death Holters 23, MIT-BIH Arrhythmia 15, Supraventricular 13, Normal Sinus Rhythm 10, ST Change 6, Long-Term 4, plus one matched control published nowhere else. 100 of the 105 records share signal samples with another ECGBench dataset, verified from the waveforms rather than inferred from the names. This is the first thing to know and it has its own section below.

The ground truth is in the last five minutes and nowhere else. Annotation began only after the first 10 minutes, deliberately, to leave an algorithm a learning period. Measured across the release, the earliest manual annotation sits at 600.464 s and the latest at 896.916 s. Read window=(150000, 74993) — exactly the annotated region, and it fits all 105 records. A window from sample 0 contains no ground truth at all.

The boundaries are not in the fold CSVs and cannot be. They are 3,623 rows of up to eleven sample indices each, not a record-level column. Call ecgbench.labels.qtdb.load_beat_annotations(data_path) for them; labels=True gives the per-record summary that stratifies the folds.

There are 20 lead layouts and 57 records decline to name their channels. ECG1/ECG2 are channel positions, not leads. Worse for cross-dataset work: the 33 European ST-T records use the ESC’s original electrode nomenclature (D3, CM5, CC5, ML5), and only 2 of the 33 agree with the names edb gives the very same channels.

Amplitude is unreliable for 34 of the 105 records — 24 sudden-death Holters whose gains the paper calls estimates, and 10 more that declare a gain of 0, which wfdb silently replaces with 200 adu/mV. Intervals, which is what this database is for, are unaffected. Four records additionally carry a constant +5.12 mV pedestal.

All 105 records pass every validation check, so clean/ equals original/. No NaN anywhere, no flat channel, no amplitude outlier — the excerpts were selected for signal quality, which is exactly the selection bias described below.

The seven sources, recomputed from the files

SourceRecsSubjSource HzBeatsPub. beatsQT-measurableP waveU wave`.atr`2nd annot.CalibratedMedian QT msMedian HR bpmClinical
European ST-T (`edb`)33312501,0411,0411,0411,04133233033412.063.333
Sudden death (`sddb`)2323250714714633562122**0**0**0**440.066.1**0**
MIT-BIH Arrhythmia (`mitdb`)1515360**674**67367446918615**11**15420.072.815
Supraventricular (`svdb`)1313128517517517477301307400.065.8**0**
Normal sinus (`nsrdb`)10101283003003003005010010403.067.9**0**
ST Change (`stdb`)6636020620620617643606392.073.1**0**
Long-Term (`ltdb`, not in catalogue)441281411411411395840**0**413.070.2**0**
BIH matched control (unpublished)1112830303030010**0**320.0**109.5****0**
**total****105****103**250 · 360 · 128**3,623**3,622**3,542****3,194****821****82****11****71**412.069.1**48**

About those counts

Every figure on this page was recomputed from the 105 headers and all nine annotation layers, after verifying the shipped data against the release’s own SHA256SUMS.txtall 1,132 files match. That total includes 57 superseded .hea- headers, which describe the channels as “record N, signal 0” instead of ECG1/ECG2, and 105 .xws WAVE display-settings files. The record list comes from the shipped RECORDS file, so neither can enter the partition.

3,623 annotated beats, against the paper’s 3,622. Table 2 of Laguna et al. gives a per-record count that sums to 3,622, and 104 of the 105 records match it exactly. sel223 carries 31 beats where the table says 30. The paper’s column ships as ecgbench.labels.qtdb.PUBLISHED_ANNOTATED_BEATS and annotated_beats_matches_published is the per-record check, so this is reproducible rather than a claim. The count is of beat annotations in .q1c, the audited second pass, which is also what .man holds (3,593 N plus 30 A); the audit reclassified 65 of those N beats as B, V or Q without changing the total.

3,542 of the 3,623 beats have a measurable QT — a QRS onset and a T end. The 81 that do not are almost all in sel35 and sel37, the two records whose annotators marked QRS boundaries only and no T wave at all; their median QT is therefore NaN rather than zero. P waves are annotated in 3,194 beats and are absent from nine records (fully absent from seven). U waves are annotated in 821 beats across 23 records, and a T onset — the mark most often skipped — in 1,412 beats across 44 records.

The waveform-pattern column reproduces for 101 of 105 records. waveform_pattern recomputes Table 2’s notation ((p)(N)t) and so on) from the annotations, counting a mark as part of the pattern when at least half the record’s beats carry it. The four disagreements are the paper’s own column being inconsistent, not a parse failure: sel117 and sel14157 are listed with u) on the strength of 11 and 8 U waves in 30 beats, sele0704 is listed without a T onset it carries in 20 of 30, and sel37’s beats are N:24, B:20, Q:6 so its modal symbol is N where Table 2 writes (Q).

103 subjects is an upper bound, not a count. Almost every record is a different source recording, so the record name is a subject id for 101 of them. Two European ST-T subjects contributed two recordings each and both pairs are here: sele0121 with sele0122 (one 51-year-old man) and sele0124 with sele0126. Those come from running ecgbench.labels.edb.reconstruct_patient_ids over edb 1.0.0 and intersecting its seven multi-record subjects with QTDB’s 33; they are a literal in EDB_SHARED_SUBJECTS because QTDB’s own header text is too coarse to recover the second pair. The 13 Supraventricular and 6 ST Change records carry no subject information in any release, so a shared person among them is undetectable.

The paper describes annotation files that do not ship. It says each record has a .ari holding ARISTOTLE’s automatic QRS annotations. There is not one .ari file in the release and the shipped ANNOTATORS file does not list the extension. What does ship, per record: .man (beat locations), .qt1/.q1c (annotator 1, unaudited and audited), .qt2/.q2c for 11 records, .pu/.pu0/.pu1 (ecgpuwave over the whole record, unaudited) and .atr for 82 records — the source database’s own reference annotations, inherited.

100 of the 105 records are in another ECGBench dataset

This is the single most consequential fact about the QT Database and it is not a caveat about metadata: the signal samples are the same samples. Six of the seven sources are datasets ECGBench also partitions, and the fold CSVs here are disjoint within QTDB only.

Source Recs Of source’s Verification
European ST-T (edb) 33 37% 30 bit-identical at the stated offset; 3 identical up to a DC pedestal and a matched gain rescale (r ≥ 0.99985)
Sudden death (sddb) 23 100% 22 exact — 2 bit-identical, 20 as trunc(sddb/4); sel32 not located
MIT-BIH Arrhythmia (mitdb) 15 31% resampled 360→250 Hz, r > 0.96; header clinical blocks byte-identical
Supraventricular (svdb) 13 17% resampled 128→250 Hz, r 0.74–0.95
Normal sinus (nsrdb) 10 56% resampled 128→250 Hz, r 0.70–0.92
ST Change (stdb) 6 21% resampled 360→250 Hz, r > 0.94
Long-Term (ltdb) 4 not in this catalogue
BIH matched control 1 published nowhere else

Where a source is already 250 Hz the comparison is exact and the result is unambiguous. 30 of the 33 European ST-T excerpts are bit-identical to edb 1.0.0 at the offset their own header states; the remaining three (sele0112, sele0116, sele0136) are the same waveform with a large DC offset added and one channel scaled by exactly the gain ratio they re-declare — the fitted slope is 0.925, 0.677 and 0.602 against declared gains of 185, 135 and 120 versus edb’s 200. For the sudden-death group, sel39 and sel47 are bit-identical and the other 20 satisfy qtdb_digital == trunc(sddb_digital / 4) exactly, because QTDB re-declared the gain from 800 to 200 adu/mV; two bits of amplitude resolution are lost and the physical scale is preserved to within one quantisation step of 0.00375 mV.

sel32 is the one record whose stated provenance does not hold. Both the paper and the header place it at 20:52:20 of sddb record 32, but its opening 4,096 samples occur nowhere in that 24.3-hour record — searched in both channels, raw and divided by four. source_record_verified is False for it and True for the other 22 sudden-death records and all 33 European ST-T records; it is NA for the resampled sources, which cannot match sample-wise by construction.

What to do about it. Filter on source_database before combining QTDB with anything. If you are evaluating delineation, QTDB is the reference and the other six should be treated as its training set, not as independent test data. If you are evaluating beat classification or ischaemia detection on one of the six, exclude the records QTDB draws from — source_record in the labels names each parent — or accept that the QT Database is contaminated with respect to your model.

The ground truth: 3,623 beats, in the last five minutes

load_labels returns one row per record; the boundaries themselves come from a second call, because they do not fit a record-level table:

from ecgbench.labels.qtdb import load_beat_annotations

beats = load_beat_annotations("/path/to/qtdb/1.0.0/")
len(beats)                       # 3623
beats.columns
# record_name beat_index symbol morphology_group
# p_onset p_peak p_offset  qrs_onset qrs_peak qrs_offset
# t_onset t_peak t_offset  u_onset u_peak u_offset
# qrs_ms p_ms pr_ms qt_ms rr_ms qtc_bazett_ms

Sample indices are in the record’s own 250 Hz frame, so a boundary at 152,000 is at 608.0 s. NaN means the annotator did not mark that point, which is information rather than missing data — see the pattern column. Marks are assigned to beats by position (P marks and the QRS onset to the following beat, everything from the QRS offset onward to the preceding one), which is unambiguous for this annotation style and needs no tolerance parameter.

rr_ms and therefore Bazett’s qtc_bazett_ms are defined only where the preceding annotated beat is the preceding actual beat. Records were annotated in runs of 30 consecutive beats plus up to 20 of each non-dominant morphology, so runs are separated by gaps of arbitrary length; anything over 3 s is treated as a gap rather than as a pause.

Recomputed over the release: median QT 412 ms per record (304–764 across records), median Bazett QTc 434.7 ms, median heart rate 69.1 bpm (35.5–128.2). The low central heart rate is the selection bias the authors warn about: excerpts were chosen to avoid noise, and “heart rates during these excerpts tend to be relatively low, probably since higher rates are frequently associated with noisy signals that would have failed to satisfy our selection criteria”. A delineator validated only here has not been tested at tachycardia, on baseline wander, or on ectopic beats — only beats ARISTOTLE called normal, with normal neighbours, were eligible for annotation.

.pu, .pu0 and .pu1 carry ecgpuwave’s automatic boundaries for every beat of every record: 222,319 beats from both signals, 111,031 from signal 0 and 111,288 from signal 1. They are unaudited and are the baseline the paper’s own method produced, not ground truth. Their num field on a t annotation does carry something the manual layer lacks — T-wave morphology — which dominant_t_morphology summarises: normal in 67 records, biphasic negative-positive in 18, biphasic positive-negative in 11, inverted in 8 and only-upwards in 1.

The 11 records with two annotators — and why parity cannot be assumed

RecordAnnotator 1 beatsAnnotator 2 beatsMedian QT msFold
sel1003030398.04
sel102**85****3**468.08
sel1033030408.03
sel1145050454.09 (val)
sel1165050368.010 (test)
sel1173030448.02
sel1233030458.01
sel2137170368.04
sel2213030402.07
sel223**31**31480.03
sel2305050372.05
**total****487****404**412.01–10

Inter-observer variability: 11 records, and one of them has three beats

The paper says a second annotator repeated the procedure for 11 records “to permit study of inter-observer variability”. All 11 come from MIT-BIH Arrhythmia, and the coverage is not matched: annotator 2 marked 404 of the 487 beats annotator 1 marked in those records.

The gap is concentrated. In sel102 the audit reduced annotator 2 from 97 first-pass annotations (.qt2) to 13 in the final file (.q2c) — three beats, against annotator 1’s 85. sel213 loses one beat, sel223 none. So an unweighted inter-observer statistic over “the 11 records” is dominated by the nine where the two agree on which beats to mark, and sel102’s disagreement — the largest — contributes almost nothing. Weight by n_annotated_beats_annotator2, which is why the column exists.

Note that sel114 is in fold 9 (val) and sel116 in fold 10 (test), so two of the 11 double-annotated records are outside the training folds. If your experiment is specifically about annotator disagreement, use split=None with fold_numbers and select on has_second_annotator rather than taking a standard split.

20 lead layouts, and the names disagree with `edb` for 31 of 33 records

config.leads is 2 for every record; config.lead_names is the modal layout and it is the placeholder pair ECG1/ECG2, which 57 records use. Those 57 — every excerpt from Supraventricular, Normal Sinus Rhythm, ST Change, Long-Term and the sudden-death Holters — state no electrode placement anywhere in the release. They are spelled ECG1/ECG2 to match afdb, nsrdb, chfdb, ltafdb, svdb and sddb, so cross-dataset code sees one convention, and they mean channel 0 and channel 1.

All 20 layouts are declared in record_lead_layouts, so ECGDataset(leads=[...]) resolves the requested names against each record’s own header and raises for a record whose layout lacks one, rather than returning whichever signal sits at that index:

Layout Recs Layout Recs Layout Recs Layout Recs
ECG1/ECG2 57 D3/V3 3 V5/MLII 1 CM5/CM2 1
MLII/V1 8 CM5/ML5 3 D3/D4 1 CM2/ML5 1
V4/D3 7 MLII/V5 2 V3/D3 1 CM5/CM4 1
CM5/CC5 6 V5/V2 2 V2-V3/V5 1 V5/V1 1
D3/V4 5 MLII/V2 2 CM5/mod.V1 1 V1-V2/V4-V5 1

D3/V4 and V4/D3 are both present, 5 and 7 records: the same electrode pair stored in either order. No name is common to all 105 records, so every name-based selection raises for some of them.

The 15 MIT-BIH Arrhythmia records name their channels exactly as mitdb does. The 33 European ST-T records do not match edb 1.0.0: QTDB keeps the ESC’s original bipolar-electrode nomenclature and edb relabelled the same channels to standard names, so edb’s MLIII is QTDB’s D3 or ML5, its V5 is CM5, its V2 is CM2, V1-V2 or V2-V3. Only sele0107 and sele0704 agree. The consequence is quiet rather than loud: of the 33 records the two datasets share, leads=["V5"] selects 14 under edb’s names and 2 under QTDB’s, over signals that are bit-identical. (Across whole datasets it is 51 of edb’s 90 records and 7 of QTDB’s 105, the latter mostly MIT-BIH Arrhythmia excerpts.) No name maps to a different physical channel in the two releases, so nothing returns the wrong lead — but any code that selects by name silently covers a different set of records.

Amplitude: unreliable for 34 records, and four sit 5.12 mV high

Intervals are what this database is for and they are unaffected by any of this. Amplitudes are not.

24 records have gains the paper calls estimates. Of the sudden-death group it says: the Holters “are not calibrated with respect to amplitude; thus the signal gains recorded in the header files for these records are only estimates”. That is the 23 sddb excerpts plus sel17152. QTDB’s own re-estimates are visible in the numbers — sel51 declares 600 adu/mV and sel52 400, where sddb declares 800 for both, so their millivolt amplitudes differ from sddb’s by 3× and 2× for the same digital samples.

10 more records declare a gain of 0, which is WFDB for “uncalibrated”. wfdb substitutes its 200 adu/mV fallback, so p_signal looks like millivolts and is a nominal scale: all four Long-Term records (sel14046, sel14157, sel14172, sel15814) and six Supraventricular ones (sel820, sel821, sel847, sel853, sel883, sel891). amplitude_calibrated is False for all 34 of these records and True for the other 71.

Four records carry a constant +5.12 mV pedestal. sel100, sel102, sel103 and sel104 are the only records declaring an explicit baseline of 0 next to an adc_zero of 1024 (200(0) 11 1024); mitdb’s own copies declare 200 11 1024 and let wfdb use adc_zero. wfdb honours the explicit baseline, so these four come back offset by 1024/200 mV: their signals never go negative, and their minima sit at +4.0 to +4.6 mV. dc_pedestal_mv carries the value; subtract it if you need them comparable with the other 101.

Three European ST-T records also re-declare a gain below 200 — sele0112 at 185, sele0116 at 135, sele0136 at 120 — which is why amplitude_range_mv has to reach ±17.058 mV, the 12-bit rail at the loosest gain in the release. Nothing comes near it: over all 23.6 million sample-pairs the extremes are −7.800 and +16.675 mV, the latter being sele0136’s channel 0 riding its own DC offset to 97.7% of its own rail.

Clinical metadata exists for 48 records, and for 3 it contradicts `edb`

57 of the 105 headers carry no clinical line at all. The other 48 carry what the source database published, in two different formats, and clinical_source says which.

The 15 MIT-BIH Arrhythmia records carry mitdb’s own block byte-identical: # 69 M 1085 1629 x1 (age, sex, analog tape, recorder, playback speed), then medications, then a free-text description of the record’s arrhythmia. sel103 records its age as -1, mitdb’s sentinel for unknown, which this loader returns as NaN.

The 33 European ST-T records carry an earlier, coarser vintage of the same text edb 1.0.0 has. QTDB writes “Coronary artery disease” where edb gives the angina type, “Coronary angiography” where edb gives the vessel count and culprit arteries, and “unspecified medication” where edb lists the drugs. Not one of the 33 blocks matches edb’s.

For three records the two releases disagree substantively: QTDB’s sele0116, sele0121 and sele0122 say “Coronary artery disease”, while edb’s headers for e0116, e0121 and e0122 record normal coronary arteries. The recordings are the same samples; the clinical statement is not. Prefer edb for these 33 records. sele0166 records both age and sex as -, unknown, and sele0405 is the one record described as a normal subject.

Ages run 32–84 where recorded, over 46 records; sex is known for 47.

Building the splits

ecgbench splits --dataset qtdb --data-path /path/to/qtdb/1.0.0/

Loading with ECGBench

from ecgbench import ECGDataset

# window=(150000, 74993) is exactly the annotated region — samples 150,000 to
# 224,993, i.e. 600.0 s to the end of the shortest record. Without it you get
# 15 minutes of signal of which 98% has no ground truth. window= is pushed into
# the reader, so it also avoids decoding the first ten minutes.
ds = ECGDataset(
    "qtdb",
    split="train",
    data_path="/path/to/qtdb/1.0.0/",
    window=(150000, 74993),
    labels=True,
)

len(ds)                                        # 85
ds[0]["signal"].shape                          # torch.Size([2, 74993])
ds[0]["record_id"]                             # 'sel100'
ds.lead_names                                  # ('ECG1', 'ECG2') — the modal
                                               # layout, and a placeholder pair
ds[0]["labels"]["lead_names"]                  # 'MLII;V5'  <- this record's real
                                               #               channels
ds[0]["labels"]["source_database"]             # 'mitdb'
ds[0]["labels"]["source_record"]               # '100'
ds[0]["labels"]["source_offset_secs"]          # 420.0
ds[0]["labels"]["source_catalogue_slug"]       # 'mit-bih-arrhythmia-database'
                                               #   <- the leakage partner
ds[0]["labels"]["n_annotated_beats"]           # 30
ds[0]["labels"]["waveform_pattern"]            # '(p)(N)t)'
ds[0]["labels"]["median_qt_ms"]                # 398.0
ds[0]["labels"]["median_qtc_bazett_ms"]        # 447.4
ds[0]["labels"]["median_heart_rate_bpm"]       # 75.0
ds[0]["labels"]["has_second_annotator"]        # True  <- 11 records
ds[0]["labels"]["amplitude_calibrated"]        # True  <- False for 34
ds[0]["labels"]["dc_pedestal_mv"]              # 5.12  <- subtract for sel100-104
ds[0]["labels"]["source_record_verified"]      # None  <- NA: mitdb was resampled,
                                               #          so no sample-wise check

# The ground truth. It is per beat, so it is a separate call: 3,623 rows with
# up to eleven fiducial points each, in 250 Hz samples.
from ecgbench.labels.qtdb import load_beat_annotations

beats = load_beat_annotations("/path/to/qtdb/1.0.0/")
beats.query("record_name == 'sel100'")[
    ["qrs_onset", "qrs_peak", "qrs_offset", "t_offset", "qt_ms", "rr_ms"]
].head()

# Boundaries are absolute samples; subtract the window start to index the tensor.
row = beats.query("record_name == 'sel100'").iloc[0]
onset_in_window = int(row.qrs_onset) - 150000

# Annotator 2, for inter-observer work. 11 records, and sel102 has 3 beats.
second = load_beat_annotations("/path/to/qtdb/1.0.0/", annotator="q2c")
len(second)                                    # 404

# Do NOT train on source_database — it is provenance, not pathology. It is the
# stratification class because the release has no diagnostic label at all.
ds.labels_df["source_database"].value_counts()
# edb 27, sddb 18, mitdb 13, svdb 11, nsrdb 8, stdb 5, ltdb 2, bih_control 1

# 34 records have unreliable amplitude calibration. Filter before comparing
# millivolts across source databases; intervals are unaffected. Note that
# labels_df is reindexed positionally to align with metadata_df, so the record
# names come from metadata_df rather than from the labels index.
names = list(ds.metadata_df["record_name"])
[n for n, ok in zip(names, ds.labels_df["amplitude_calibrated"]) if not ok]
# ['sel14172', 'sel15814', 'sel17152', 'sel30', 'sel31', 'sel32', ...]  27 in train