ECG-ID Database

Open Completed

Quick facts

Format1-lead (Lead I, limb clamps) · stored twice: raw + filtered · 20 s · 500 Hz · WFDB
Patients90
Records310
Leads1
LicenseODC-By 1.0
OriginElectrotechnical University "LETI" — Russia — St. Petersburg

Overview

The reference dataset for ECG biometrics. 310 twenty-second Lead I recordings from 90 volunteers, collected in 2004–2005 by Tatiana Lugovaya at the Electrotechnical University “LETI” in Saint Petersburg for a master’s thesis that asked whether an electrocardiogram identifies the person who produced it. Her answer was 96% correct identification over these 90 individuals.

The label is the person. There is no diagnosis anywhere in this release, no clinical assessment and no rhythm annotation — the ground truth is which of the 90 subjects a recording came from, and the cohort is students, colleagues and friends of the author rather than patients. subject_id is therefore both the label and patient_id_column, which has a consequence spelled out in its own section below: ECGBench’s folds cannot be used for the identification task.

Every record stores the same lead twice. The two channels are ECG I (raw) and ECG I filtered, so the returned tensor is (2, 10000) and both rows are Lead I — one is the author’s offline preprocessing of the other. The thesis deliberately switched off every filter in the cardiograph software, on the grounds that filtering might suppress features useful for identification, so the raw channel carries real baseline drift, 50 Hz interference and high-frequency noise. leads=["ECG I"] is how you avoid handing a model the same lead twice.

Length is uniform, unusually for a PhysioNet release. All 310 records hold exactly 10,000 samples at 500 Hz — 20.000 s, no exceptions — so any window=(start, length) inside (0, 10000) fits every record and WindowOutOfRangeError cannot fire here.

The .atr annotations cover only the first half of each record. Exactly 10 R-peaks and 10 T-peaks per record, from an automatic detector the release states was never audited, all inside the first 5.1–11.7 s. Section below.

Twenty of the 90 subjects were recorded on more than one day, up to six sessions spanning 156 days, which is what makes this database usable for studying whether an ECG biometric persists over time. The other 70 have all their records from a single session.

The cohort, recomputed from the 310 headers

SubjectsRecordsAge rangeMedian age
female4615613 – 6823
male4415416 – 7524
**total****90****310****13 – 75****23**

Records per subject — 1 to 22, not the documented 2 to 20

Records for the subjectSubjectsRecords
111
24896
31648
4520
51365
6212
717
818
11111
20120
22122
**total****90****310**

About those counts

Every figure on this page was recomputed from the 310 headers and 310 .atr annotation files, after verifying the shipped copy against the release’s own SHA256SUMS.txtall 977 listed data and documentation files match. The 22 listed files absent from a normal download are website assets under .old/ (biometric.shtml and 21 PNGs), not data.

Three of the release’s own headline figures reproduce exactly and one does not. The README and the PhysioNet abstract give 310 records, 90 subjects, 44 men and 46 women, ages 13 to 75 — all four confirmed. Both then say the number of records per subject runs “from 2 (collected during one day) to 20 (collected periodically during 6 months)”.

Figure Documented Shipped Cause
records 310 310
subjects 90 90
men / women 44 / 46 44 / 46
ages 13 – 75 13 – 75
records per subject 2 – 20 1 – 22 Person_74 has one record; Person_02 has 22

RECORDS and the files on disk agree with each other and disagree with the prose. There is no changelog in the release to explain it, and the directory timestamps show the 90 subject directories were written in two batches (September 2011 and April 2012) while the README was last touched in April 2012 — so the likeliest reading is that the prose describes the collection protocol rather than the final release. ECGBench reports the shipped figures.

Age is a subject attribute here, not a per-record one. No subject’s recorded age changes across sessions, even the two spanning 140 and 156 days: Person_02 is 23 in all six of its sessions. So a per-record age is safe to cross with sex for fold construction.

There are only ten acquisition days in the whole release, and one holds 43% of it. ecg_date is a session identifier as much as a date, which is why the session columns are derived from it.

Date Records Subjects
2004-12-07 5 2
2004-12-28 14 2
2005-03-15 26 9
2005-04-05 16 4
2005-04-26 37 16
2005-05-12 134 52
2005-05-13 21 10
2005-05-20 17 7
2005-05-21 26 12
2005-05-24 14 10

Any equipment, electrode or environmental peculiarity of 12 May 2005 is present in 134 of the 310 records.

The folds cannot be used for identification, and that is not a defect

This is the most important thing on this page.

ECG-ID’s ground truth is identity, so its label column is its patient column. ECGBench groups folds by patient, which means every subject’s records sit wholly inside one fold and no fold’s model has ever seen the person it would be asked to recognise. Folds hold 7–11 subjects each and no subject spans a fold.

That is the right default for every other use of these recordings. A model trained on 21 of Person_02’s 22 records and evaluated on the 22nd is measuring nothing; leaving the grouping off would produce exactly that, for the 89 subjects with more than one record. It is the wrong default for the one task this database was built for.

For identification, split within subject. The label loader exposes the session structure for precisely this:

ds  = ECGDataset("ecgiddb", split="train", labels=True, data_path=...)
ids = ds.metadata_df["record_id"]
multi   = ds.labels_df["is_multi_session"]          # 20 subjects across the release
enrol   = ids[multi & (ds.labels_df["session_index"] == 1)]
verify  = ids[multi & (ds.labels_df["session_index"] >  1)]

Holding out a subject’s later sessions is the protocol the thesis’s persistence experiment used, and it is the harder and more meaningful one: the electrodes were reattached from scratch for every recording, so a same-session pair shares an electrode placement that a cross-session pair does not.

  Subjects Records
one session only 70 175
two sessions 11 47
three sessions 7 24
five sessions 1 20
six sessions 1 22
multi-session 20 135

The longest-running subjects are Person_02 (22 records, 6 sessions, 156 days) and Person_01 (20 records, 5 sessions, 140 days). Together they are 42 of the 310 records.

The thesis’s own 195/115 train/test division is not recoverable. It divided these same 310 records into 195 training and 115 test records, chosen for “maximum difference between records in different sets both in monitoring time and human physical state” — and that assignment appears in no file. Not in RECORDS, not in a header comment, not in a separate list. So has_predefined_splits is false for lack of the data, not for lack of a split, and no ECGBench figure is comparable to the thesis’s 96%.

One lead, stored twice — raw and filtered

Both channels of every record are Lead I, the left-hand minus right-hand potential, taken with limb clamp electrodes with the subject seated. The thesis chose Lead I because it is easy to acquire and insensitive to small electrode displacements, imitating “the likely scenarios of user interaction with a practical identification system”.

Channel Name in the header Contents
0 ECG I raw — every cardiograph filter deliberately off
1 ECG I filtered the thesis’s own offline preprocessing of channel 0

The filter chain, from the thesis: baseline drift removed by level-9 db8 wavelet decomposition with the final approximation subtracted, an adaptive bandstop at 50 Hz for power-line noise, and a 5th-order Butterworth lowpass (Wp 40 Hz, Ws 60 Hz, Rp 0.1 dB, Rs 30 dB).

It is zero-phase, verified rather than assumed: peak cross-correlation between the two channels is at lag 0 in 273 of the 310 records and at ±1 sample in the other 37. So the channels are sample-aligned and the .atr annotation indices apply to both.

Because the residual raw − filtered is therefore noise rather than a phase difference, it is a usable per-record noise level. ecgbench.labels.ecgiddb.scan_noise_levels(data_path) computes it — it is a separate call rather than a label because it needs the waveforms decoded:

Quantity Median 90th pct 99th pct Max
removed_rms_mv 0.187 0.562 2.214 41.948 (Person_76/rec_2)

Correlation between the two channels runs 0.103 to 0.978 (median 0.691). A low value means drift dominates the raw channel, not that the filter failed.

config.leads is 2 because that is the shape of the returned tensor; this page’s leads field says 1 because that is the number of electrode pairs. Both are true of different things, and a test pins the pair so neither gets “corrected” to match the other.

The annotations stop about 40% of the way in, and nobody audited them

The shipped ANNOTATORS file is one line: “atr — unaudited R- and T-wave peaks annotations from an automated detector”. Taking it at its word:

N here means “the detector found a beat”, not “normal beat”: no beat in this database was ever classified. So mean_hr_bpm (50.0 – 132.7, median 76.7), sdnn_ms (4.1 – 393.5) and mean_rt_interval_ms (169.8 – 452.2) are estimates over nine RR intervals from an unaudited detector. They describe the record; they are not an HRV result, and this is not a beat-detection reference — use qtdb or ludb for that. mean_rt_interval_ms is R-peak to T-peak, not a QT interval, because the annotation marks the T peak rather than its offset.

The wide SDNN range is real and expected: the thesis deliberately did not restrict heart rate or physical and emotional state, so within-record rate variation is part of the design rather than a data-quality problem.

Validation summary (500 Hz)

VersionRecordsNote
original310all records, with is_valid + quality_issues
clean30799.0% pass rate — 3 records excluded

Three records fail on amplitude, and the 12-bit rail is not a rail here

No record has a NaN sample, a flat or all-zero channel, an unreadable header or a truncated signal. The only check that fires is amplitude_outlier, on three records:

Record Raw channel (mV) Filtered channel (mV)
Person_76/rec_2 −154.155 to 10.745 −163.800 to 151.375
Person_88/rec_1 −153.635 to 10.545 −48.945 to 16.245
Person_47/rec_2 −1.910 to 10.885 −5.035 to 3.835

The bound is [-10.24, 10.235] mV, and it is a physiological bound that merely looks like a hardware rail. Every one of the 620 signal lines in the release reads 16 200 12 0 — format 16, gain 200 adu/mV, adc_res 12, adc_zero 0, no baseline — which nominally confines every sample to [−2048, 2047] adu. The stored int16 samples go far past it: Person_76/rec_2 reaches −30,831 adu. The 12 bits describe the cardiograph’s converter, and the baseline drift the thesis chose not to filter rides on top of it. So unlike apnea_ecg, which uses the same two numbers as a rail its data sits inside, here the data is not rail-bounded at all.

No float32 slack is needed, and that was checked rather than assumed: the closest passing record reaches 10.115 mV and −3.060 mV, nowhere near the bound, so the rail-rounding trap that bit chfdb cannot arise. Widening the bound would only readmit two records whose drift is fifteen times the physiological range.

Person_47/rec_2 is a different case from the other two — a genuine 10.885 mV excursion rather than gross drift — and it is excluded on the same rule. version="original" returns all 310 with quality_issues attached.

Ten folds over 310 records, grouped on subject and balanced on sex x age

Folds are built with StratifiedGroupKFold, grouped on subject_id and stratified on stratify_class — sex crossed with a single age cut at 30 years.

Class Subjects Records
female_le30 36 124
male_le30 27 99
male_gt30 17 55
female_gt30 10 32

Why that cross and nothing finer. A class has to contain at least as many subjects as there are folds to appear in every fold, and the smallest cell here holds exactly 10 — the floor for 10 folds. Crossing sex with four age bands instead produces 5-subject cells and leaves empty cells in the fold table. Neither axis works alone either: stratifying on sex alone leaves four folds with no subject over 45 (age is skewed — 45 of the 90 subjects are 21–30 and only 11 are over 45), and stratifying on age bands alone leaves one fold at 9 female / 19 male records, because group sizes run 1 to 22 and a single subject can swing a fold.

Heart rate was rejected as an axis: it is not a subject attribute here, and it comes from an unaudited detector over ten beats. stratify_class is a fold-construction device and must not be trained on as one.

Use the folds, not the default split. With folds 1–8 → train, 9 → val, 10 → test:

Split Records Subjects female / male Age range Multi-session records
train 246 76 119 / 127 13 – 75 97
val 27 7 13 / 14 16 – 55 16
test 37 7 24 / 13 19 – 68 22

Seven subjects is not an evaluation set. For a real evaluation, cross-validate: split=None with fold_numbers=[...] selects by fold from folds.csv and ignores the default layout.

Fold 1 2 3 4 5 6 7 8 9 10
records (original) 30 28 29 39 33 30 27 30 27 37
subjects 9 9 9 9 11 10 9 10 7 7

Folds 4 and 10 are the largest because they drew Person_01 (20 records) and Person_02 (22). Sex is balanced 13–24 female against 13–25 male per fold; those two subjects are what pushes the extremes.

Overlap with other datasets in this catalogue: none

No related: edge is declared. Two other releases in this catalogue come from Russia — ludb (Lobachevsky University, Nizhny Novgorod) and incartdb (St. Petersburg Institute of Cardiological Technics) — and neither shares a recording, an institution or a decade with this one: ECG-ID is a 2004–2005 LETI student cohort with one lead at 20 s, ludb is 200 twelve-lead hospital records at 10 s, incartdb is 75 twelve-lead 30-minute Holter excerpts. Nothing else in the catalogue is a biometric-identification release.

The overlap that matters is inside this release — 89 of the 90 subjects contribute more than one record — and it is handled by the subject grouping.

Building the splits

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

Loading with ECGBench

from ecgbench import ECGDataset

ds = ECGDataset(
    "ecgiddb",
    split="train",
    data_path="/path/to/ecgiddb/1.0.0/",
    labels=True,
)

len(ds)                                    # 243
ds[0]["signal"].shape                      # torch.Size([2, 10000])
ds[0]["record_id"]                         # 'Person_01_rec_1'
ds.lead_names                              # ('ECG I', 'ECG I filtered')
                                           # ONE lead, stored twice
ds[0]["labels"]["subject_id"]              # 'Person_01'  <- the label
ds[0]["labels"]["record_name"]             # 'rec_1'  <- NOT unique: 90 records
                                           # in the release are called rec_1
ds[0]["labels"]["age"]                     # 25.0
ds[0]["labels"]["sex"]                     # 'male'
ds[0]["labels"]["ecg_date"]                # '2004-12-07'
ds[0]["labels"]["session_index"]           # 1  (of 5, spanning 140 days)
ds[0]["labels"]["is_multi_session"]        # True
ds[0]["labels"]["mean_hr_bpm"]             # 68.5
ds[0]["labels"]["annotated_fraction"]      # 0.4407  <- annotations stop at
                                           # sample 4,407 of 10,000

# Both channels are Lead I. Select one by name so a model is not handed the
# same lead twice:
raw = ECGDataset("ecgiddb", split="train", leads=["ECG I"],
                 data_path="/path/to/ecgiddb/1.0.0/")
raw[0]["signal"].shape                     # torch.Size([1, 10000])

# Length is uniform (10,000 samples), so any window fits every record.
# window= is pushed into wfdb's sampfrom/sampto, so the rest is never decoded.
first5s = ECGDataset("ecgiddb", split="train", window=(0, 2500),
                     data_path="/path/to/ecgiddb/1.0.0/")
first5s[0]["signal"].shape                 # torch.Size([2, 2500])

# For the identification task, re-split WITHIN subject — the folds group by
# subject, so no fold's model has seen the person it would have to recognise.