Long-Term AF Database (LTAFDB)

Open Completed

Quick facts

Format2-lead (both named ECG) · 6.1–26.4 h · 128 Hz · WFDB
Patients84
Records84
Leads2
LicenseODC-By 1.0
OriginNorthwestern University; MEDICALgorithmics — USA / Poland

Overview

1,960.6 hours of annotated two-lead Holter from 84 subjects with atrial fibrillation — about eight times the recorded time of the MIT-BIH Atrial Fibrillation Database, and the largest annotated AF recording set in this catalogue. Records run 6.1 to 26.4 hours, typically 24–25, digitised at 128 Hz.

The annotations are what make it worth having. The .atr files hold 8,995,973 typed beat annotations and 53,704 rhythm episodes in nine codes, produced by MEDICALgorithmics’ PocketECG algorithm and then manually verified by their ECG technicians. They were contributed to PhysioNet in 2012, four years after the signals. AFDB, by contrast, ships 623 rhythm episodes and unaudited beats. So AFDB is the AF-detection benchmark and this is what you use when you need reference beats at scale, or AF burden measured over a whole day rather than ten hours.

AF burden is the label, not a diagnosis. 83 of the 84 subjects have annotated AF, and burden runs the full range from 0% (record 30, the only AF-free record and also the shortest at 6.1 h) to 100%. The distribution is strongly bimodal — 33 records are in AF essentially throughout and 18 spend under 5% of the recording there — which is why af_class splits three ways rather than two.

There is no atrial flutter code. AFDB annotates N/AFIB/AFL/J; this release annotates nine codes and AFL is not among them, while ventricular bigeminy, trigeminy, atrial bigeminy, sinus bradycardia, SVT, VT and idioventricular rhythm are. af_burden here is AFIB alone, so the two databases’ identically named columns are computed over different code sets.

The two channels are not named leads — and unlike AFDB they are not even numbered. Every header calls both channels ECG, the same string twice, and states no electrode placement anywhere in the release. Two identically named channels cannot be told apart by name, so ECGBench declares the positional names ECG1/ECG2 and says so here. Do not read them as MLII/V1 by analogy with the MIT-BIH Arrhythmia Database.

Records are a full day long, so batching needs window=(start, length), which is read at load time rather than cropped afterwards. Length is very much not uniform: 55 distinct record lengths, from 2,826,240 samples (record 30) to 12,142,080 (record 70), so a window must fit inside the shortest.

Time spent in each annotated rhythm

RhythmCodeHoursShareRecords containing itEpisodes
atrial fibrillation`AFIB`1,030.8953.28%**83**7,358
sinus rhythm or any other unlisted rhythm`N`828.5542.82%5322,834
sinus bradycardia`SBR`55.582.87%3511,326
atrial bigeminy`AB`9.660.50%464,472
ventricular bigeminy`B`4.670.24%212,696
supraventricular tachyarrhythmia`SVTA`3.330.17%453,268
ventricular trigeminy`T`1.710.09%22785
ventricular tachycardia`VT`0.400.02%34828
idioventricular rhythm`IVR`0.130.01%4137
**total annotated****1,934.92**84**53,704**

Reference beat annotations (.atr)

SymbolBeat typeCountShareRecords containing it
`N`normal beat8,710,87396.831%84
`A`atrial premature beat152,3321.693%53
`V`premature ventricular contraction132,6791.475%84
`Q`unclassifiable beat890.001%6
**total****8,995,973**84

AF burden — how the 84 records distribute

ClassAF burdenRecordsObserved range
`minimal`under 5%180.0000 – 0.0490
`paroxysmal`5–95%330.0550 – 0.9306
`sustained`95% or more330.9511 – 1.0000

About those counts

Every figure on this page was recomputed from the 84 headers and the .atr and .qrs annotation files, after verifying all 339 shipped files against the release’s own SHA256SUMS.txt — all 339 match.

They were then checked against the release’s own summary tables (tables.shtml), cell by cell: all 336 beat counts (84 records × N/A/V/Q) and all 756 rhythm cells (84 × nine codes, episode count and duration) agree. Two differences are worth recording:

Two more things the numbers do not say on their own:

There are no demographics at all. This release’s headers carry no comment lines: no age, no sex, no medications, no clinical description, and no subject or tape identifier. That is why patient_id_column is null — one record per subject is the most that can be asserted, so folds are stratified but ungrouped. Mean heart rate, derived here from the .atr RR intervals, runs 40.4–129.8 bpm across the 84.

Reference beats, unaudited detections, and the T that means two things

The shipped ANNOTATORS file lists two annotators, and they are not interchangeable:

The .qrs files also carry 81 hand-placed T markers recording spontaneous ends of AF episodes lasting a minute or more, inserted by Steven Swiryn and George Moody. The AF Termination Challenge Database’s 80 one-minute excerpts were cut around them. They exist only in records 00–75 — all 24 records that carry any are in that range, and the 100- and 200-series carry none — so a model trained on terminations sees roughly half the database.

T means something else entirely in an .atr rhythm code: ventricular trigeminy. Same letter, different annotator, unrelated meanings.

The signal outlasts the annotation, by hours in some records

Both annotators stop at the same place in each record, so this is a property of the recording rather than of one annotator. The median record’s beat annotations stop 4.9 s from the end — but:

Beat annotation stops before the end by Records
more than 10 minutes 35
more than 1 hour 17
8.05 hours (record 117, a third of the record) 1

unannotated_tail_secs in the labels reports it per record. A window= reaching into that tail returns waveform with no reference behind it, which is fine for unsupervised work and wrong for evaluation.

Rhythm durations nonetheless run to the end of the signal, because the last episode has no annotation after it to close it and PhysioNet’s own tables close it at the record end. ECGBench follows that convention — which is what makes its figures reproduce those tables — so a record like 117 attributes its final 8 unannotated hours to whatever rhythm was running at 15.9 h.

The gains are real — and one of them is anomalous

Unlike AFDB, whose every header declares a gain of 0 (WFDB’s “uncalibrated”) and leans on wfdb’s 200 adu/mV fallback, LTAFDB’s headers carry 50 distinct measured gains, sometimes different for the two channels of one record (record 100 is 88.968 and 131.062). wfdb applies each header’s own gain, so samples arrive in genuine millivolts and signal_unit_scale is 1.0. There is no calibration argument to have here.

With one exception, which ECGBench flags and does not correct. Record 62’s ECG1 declares 1123.6 adu/mV, where the other 167 signal lines run 75.0188 to 222.222 — a 5.5× outlier with nothing in between. Its raw swing is entirely normal (2,756 adu peak-to-peak, against a release median of 1,220 and a maximum of 2,808), so that gain turns the largest raw excursion in the release into its smallest calibrated one: 2.45 mV peak-to-peak against a release median of 6.65.

ECGBench reports what the header declares, because a silently corrected record would disagree with every other tool reading the same file. But anything comparing absolute amplitudes across records should exclude record 62’s ECG1 or rescale it. Its ECG2 (202.429) is unaffected, and adc_gains in the labels carries the pair for every record.

Validation summary (128 Hz)

VersionRecordsNote
original84all records, with is_valid + quality_issues
clean84100% pass rate — every record loads

About the checks that cannot fire

Nothing fails validation: all 84 records read, none has a NaN sample, and no lead is flat or all-zero. Two checks are worth explaining because they cannot fire on this release:

Record ids are zero-padded, and that is load-bearing

00 is not 0. Seven of the 84 record names begin with a zero — 00, 01, 03, 05, 06, 07, 08 — and pandas reads a column of digits as int64, which strips them. The id then no longer names a record, the label join misses, and data_path / "0" is not a file, so every record fails corrupt_header for a reason nothing in the traceback mentions.

This is the second ECGBench dataset to need zero_padded_identifiers: true, after AFDB. If you read the published fold CSVs yourself, pass dtype={"record_name": str, "signal_path": str}.

Overlap with the other AF databases: none found

LTAFDB and the MIT-BIH Atrial Fibrillation Database are both long-term two-lead Holter recordings of AF subjects, and neither ships a subject identifier that would join, so the question was settled from the annotation files rather than assumed. RR intervals in seconds are commensurable across sampling rates (128 Hz here, 250 Hz for AFDB, 360 Hz for MITDB), so the check compares sequences of 20 consecutive RR intervals quantised to 8 ms, on two half-bin-shifted grids.

Against controls that make a null result mean something — a positive control re-finding each LTAFDB record in itself at 100%, and a negative control of each record against the pool of the other 83 known-distinct subjects at a median of 0% and a maximum of 0.15% — the result is:

No related: edge is declared on those grounds. The AF Termination Challenge Database is a genuine derivative — its 80 one-minute excerpts are cut from records 00–75 of this release — but it is not in this catalogue, so there is nothing to link it to yet.

One limitation is worth stating rather than glossing: the RR signature survives refinement of annotations but not re-detection, so a shared recording annotated by two genuinely different detectors could evade it. Subject-level overlap cannot be checked at all, because none of these releases ships a subject identifier.

Building the splits

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

Loading with ECGBench

from ecgbench import ECGDataset

# Day-long records: a window is needed to batch at all, and because window=
# is pushed into the reader it also avoids decoding the other 24 hours.
ds = ECGDataset(
    "ltafdb",
    split="train",
    data_path="/path/to/ltafdb/1.0.0/",
    window=(0, 1280),        # first 10 s at 128 Hz
    labels=True,
)

len(ds)                                   # 68
ds[0]["signal"].shape                     # torch.Size([2, 1280])
ds[0]["record_id"]                        # '01' — a string, zero-padded
ds.lead_names                             # ('ECG1', 'ECG2') — channel positions
                                          # that ECGBench assigns; the headers
                                          # call both channels 'ECG'
ds[0]["labels"]["af_burden"]              # 0.7891 — 78.9% of the record in AF
ds[0]["labels"]["af_class"]               # 'paroxysmal'
ds[0]["labels"]["dominant_rhythm"]        # 'AFIB'
ds[0]["labels"]["n_episodes_AFIB"]        # 53
ds[0]["labels"]["n_beats"]                # 90546 typed reference beats (.atr)
ds[0]["labels"]["adc_gains"]              # '202.429|202.429' — measured, per channel
ds[0]["labels"]["record_hours"]           # 20.57
ds[0]["labels"]["unannotated_tail_secs"]  # 3.93 — this one annotates to the end

# AF burden across the split, straight off the reference annotations:
ds.labels_df["af_burden"].describe()      # min 0.0007, median 0.417, max 1.0
ds.labels_df["af_class"].value_counts()   # paroxysmal 27, sustained 27, minimal 14

# Record 30 is the shortest at 2,826,240 samples, so a window must fit
# inside that rather than inside a median record's 11,059,200, or it raises
# WindowOutOfRangeError naming the record and its true length. The binding
# limit is the shortest record in YOUR split — 8,371,200 in train.