St. Vincent's / UCD Sleep Apnea Database (UCDDB)

Open Completed

Quick facts

Format3-lead Holter (V5, CC5, V5R) · 7.52–8.68 h · 128 Hz · EDF · AHI + sleep stages + respiratory events
Patients25
Records25
Leads3
LicenseODC Attribution
OriginSt. Vincent's University Hospital / University College Dublin — Ireland — Dublin

Overview

25 full overnight sleep studies from the Sleep Disorders Clinic at St Vincent’s University Hospital, Dublin. Each ships two simultaneous recordings of the same night: a 14-channel Jaeger-Toennies polysomnogram and a three-channel Reynolds Lifecard CF Holter ECG. ECGBench splits and validates the Holter203.4 hours of V5/CC5/V5R at 128 Hz, 7.52 h to 8.68 h per record.

This is the first EDF dataset in ECGBench. signal_format: "edf" was added for it, with a reader that seeks to the data record a window starts in rather than decoding an eight-hour night to return thirty seconds of it. The polysomnograms are mixed-rate files (8, 64 and 128 Hz side by side) and the reader refuses them by name; their paths are still carried in the metadata for anyone who wants to read them separately.

The subjects were selected at random over six months (September 2002 to February 2003) from patients referred for possible obstructive sleep apnea, central sleep apnea or primary snoring, and had to be over 18, free of known cardiac disease and autonomic dysfunction, and not on medication affecting heart rate: 21 men and 4 women, aged 50 ± 10 years (28–68), BMI 31.6 ± 4.0 kg/m² (25.1–42.5), apnea-hypopnea index 24.1 ± 20.3 (1.7–90.9).

Beyond the per-subject AHI, each study carries 20,789 sleep epochs scored in 30 s to Rechtschaffen and Kales rules and 3,428 respiratory events annotated with onset, duration, oxygen desaturation, snoring, arousal and heart-rate change — all by the same experienced sleep technologist.

All 102 shipped files verify against the release’s own SHA256SUMS.txt, all 25 records pass every ECGBench quality check, and clean/ therefore equals original/. Read the next four sections before trusting that last sentence.

The annotations are stamped in polysomnogram time, and the Holter's clock is a placeholder

This is the thing to understand before using this dataset for anything event-level. The respiratory events carry a time of day and the sleep stages are 30 s epochs from polysomnogram onset. Both line up with the .rec header, whose start time equals SubjectDetails.xls’s “PSG Start Time” in all 25 records. The _lifecard.edf headers do not: they read 09:01:17 to 09:48:29 on 01.01.06, rising monotonically in filename order about a minute apart. Those are archive timestamps, and the landing page says as much — “The recording dates and times are not available.”

Taken at face value, that makes every one of the 3,428 events and 20,789 epochs unusable with the ECG this dataset is catalogued for.

ECGBench recovers the offset for 24 of the 25 records by cross-correlating heart rate between the two recordings: median-RR heart rate at 1 Hz, smoothed over 60 s, from the polysomnogram’s own ECG channel against each Holter channel, at every lag leaving at least 90% overlap. The Holter was fitted 17.6 to 132.2 minutes before the polysomnogram started.

  Records
Offset recovered 24 of 25
…passing both reliability thresholds (r ≥ 0.70, third-to-third spread ≤ 30 s) 22
…with all three Holter channels agreeing on the lag to within 20 s 24
No offset (ucddb028, whose Holter is another subject’s — see below) 1

The spread column is the instrument that matters: the offset is refitted independently on the first, middle and last third of the night, and a real constant offset does not move between thirds while a spurious correlation peak does. 22 records move by 3 s or less. The two that fail are ucddb023 (r = 0.74, spread 46 s), which is genuinely uncertain — only its middle third correlates above 0.7 — and ucddb013 (r = 0.40, spread 63 s), which fails for a milder reason: its first third is unusable signal and fits −60 s at r = 0.18, while its middle and last thirds both fit +3 s at r = 0.97. Check psg_offset_reliable rather than assuming, and recompute the whole table if you want to:

from ecgbench.labels.ucddb import verify_psg_alignment
verify_psg_alignment("/path/to/ucddb/1.0.0/")

respiratory_events() and sleep_stages() apply the offset for you and return a holter_secs column that indexes straight into window=.

ucddb028's ECG belongs to ucddb014, and nothing upstream says so

ucddb014_lifecard.edf and ucddb028_lifecard.edf differ in exactly four bytes — the EDF start-time field, 09:25:37 against 09:48:29 — and their 20,782,080-byte signal payloads are bit-identical. Their polysomnograms, sleep stages, respiratory events and demographics are all different, so these are two genuinely different men (56, AHI 36; 50, AHI 46) sharing one Holter recording.

The alignment search confirms it from an independent direction:

Polysomnogram Holter Best offset r
ucddb014 ucddb014 3432 s 0.941
ucddb014 ucddb028 3432 s 0.940
ucddb028 ucddb014 7174 s −0.005
ucddb028 ucddb028 −20 s 0.010

ucddb028’s waveform matches ucddb014’s night, at the same offset and the same correlation as ucddb014’s own file, and does not match its own subject’s night at all.

Both records are kept, because each is an official record with its own official annotations and dropping one would silently diverge from the release. Two things follow instead. patient_id_column is recording_group, not a subject id, and it merges the pair into "ucddb014+ucddb028" so the shared waveform cannot land on both sides of a split — 25 records, 24 groups. And waveform_matches_subject is False for ucddb028, because its AHI of 46 labels a night belonging to a man whose AHI was 36. For record-level supervised work, filter it out:

df = df[df["waveform_matches_subject"]]

A second, smaller duplication is documented upstream: in ucddb002 “only two distinct ECG signals were recorded; the second ECG signal was also used as the third signal”. Verified — its channels 2 and 3 are equal at every one of its 3,525,120 samples, and n_distinct_leads reports 2. So the database holds 74 distinct ECG channels, not 75.

The first 67–119 seconds of every record are not ECG

window=(0, n) is the wrong window for this dataset, and it fails silently. Every Holter file opens with a calibration block — a two-level 2 Hz square wave alternating between digital 1843 and 2253, i.e. 4.5006 and 5.5018 mV, so 1.0012 mV peak to peak: the instrument’s 1 mV calibration pulse. Nothing in the release documents it.

Over the shortest block (67 s) the samples are byte-identical across all 25 records and all three channels, so a naive first-N-samples window returns the same array for every record in the database and none of it is anybody’s heart. The block runs 67.0 s (ucddb006) to 119.0 s (ucddb027), every length a whole number of seconds, and ucddb014 and ucddb028 agree at 87.0 s as their shared waveform requires.

from ecgbench.labels.ucddb import ECG_STARTS_AT_SAMPLE   # 15232 = 119.0 s
ECGDataset("ucddb", split="train", window=(ECG_STARTS_AT_SAMPLE, 3840), ...)

ECG_STARTS_AT_SAMPLE is the first sample past the longest block, so it is safe for every record; CALIBRATION_SAMPLES has the per-record length if you want to start earlier on a specific one, and calibration_samples is a label column. Recompute both — it reads only the first 15 minutes of each file, so it takes seconds:

from ecgbench.labels.ucddb import verify_calibration_block
verify_calibration_block("/path/to/ucddb/1.0.0/")

Positions derived from psg_offset_secs are unaffected: the recovered offsets are 1053 s to 7932 s, all well past every calibration block, so a window cut around a scored respiratory event lands in real ECG.

The ECG rides a 5 mV pedestal, and no quality check can see the duplicates

All 25 records pass every ECGBench check and clean/ equals original/. That is arithmetically correct and substantively incomplete, so here is what the validation report cannot tell you.

Every Holter channel declares digital 0–4095 mapping to physical 0–10 mV, so the baseline sits at mid-scale — measured mean 5.00 mV — and the signal is unipolar by construction. ECGBench applies EDF’s declared calibration verbatim, as any EDF reader does: an offset is not something a reader should silently remove, and signal_unit_scale is a multiplier that could not express it anyway. Subtract the median yourself if you want a centred trace.

One consequence is that amplitude_range_mv is [0.0, 10.001] — the ADC span, not a physiologic range. Both rails are attained: the float32 minimum across all 25 records is exactly 0.0 (11 records touch digital 0) and the maximum exactly 10.0 (ucddb015 channel 1 touches 4095). The upper bound carries a thousandth of a millivolt of slack so a platform whose float32 rounding differs cannot exclude the record the bound was computed from; the lower carries none, because a negative physical value could only come from a corrupt read. What the check still catches is a reader that forgot the EDF calibration and returned raw digital counts. What it does not do is police amplitude plausibility.

Neither duplication above is visible to any check either. ucddb002’s third channel is a perfectly good ECG — just not a third one — and ucddb028’s waveform is a perfectly good overnight Holter belonging to somebody else. Read n_distinct_leads and waveform_matches_subject, not the report.

25 records, 24 groups, and a stratification label coarser than the grade

Folds are grouped on recording_group, which is the record name for 23 records and "ucddb014+ucddb028" for the duplicated pair. With ten folds that is 2–3 records each; no fold is empty, and no group straddles a fold.

The stratification label is deliberately coarser than the clinical grade. The four AHI severity classes split the database normal 1 / mild 10 / moderate 6 / severe 8, and a class of one subject cannot be spread over ten folds — scikit-learn warns and then puts that subject wherever it lands. stratify_class pools at the standard moderate-or-severe cut point (AHI ≥ 15), giving 14 records against 11, or 13 recording groups against 11 once the pair merges. Both clear the fold count.

So label_column (ahi_severity) and the stratification axis are different quantities, on purpose. Train on psg_ahi or ahi_severity; the pooled class exists to build the partition.

With the default mapping that gives train = folds 1–8 (21 records), val = fold 9 (ucddb021, ucddb025) and test = fold 10 (ucddb008, ucddb027). Two subjects is not an evaluation set; use split=None with fold_numbers=[...] for real cross-validation.

About those counts

Every figure on this page is recomputed from the shipped files, which all verify against the release’s own SHA256SUMS.txt.

Quantity Landing page Recomputed Note
Subjects 25 (21 M, 4 F) 25 (21 M, 4 F) agrees; numbered 002–028, with 004 and 016 absent
Holter records 25 25 but only 24 distinct waveforms — see above
Age 50 ± 10, range 28–68 50.0 ± 9.5, range 28–68 agrees
BMI 31.6 ± 4.0, range 25.1–42.5 31.6 ± 4.0, range 25.1–42.5 agrees
AHI 24.1 ± 20.3, range 1.7–90.9 24.2 ± 20.3, range 2–91 SubjectDetails.xls rounds to whole numbers
Sampling rate not stated 128 Hz, all 75 channels  
Holter duration not stated 7.52–8.68 h, 203.4 h total  
Respiratory events not stated 3,428 9–436 per record
Scored epochs not stated 20,789 30 s each

The parsing behind every event and epoch count is checkable, and was checked. Counting apneas and hypopneas out of _respevt.txt and dividing by sleep time from _stage.txt recovers the shipped “PSG AHI” to within 1.0 for 23 of 25 records and within 3.9 for all 25 — the two outliers are the two highest indices in the database, ucddb025 (94.8 recomputed against 91 shipped) and ucddb028 (48.2 against 46). The same files recover “Sleep Efficiency (%)” to within 0.5 points for all 25. ahi_recomputed and sleep_efficiency_recomputed_pct ship beside the originals so the disagreement is visible rather than hidden.

Two things the release documents but does not contain, and one it contains but does not document. Sleep-stage codes 6 (Artifact) and 7 (Indeterminate) never occur; an undocumented code 8 occurs 15 times, 11 in ucddb008 and 4 in ucddb024. Counting 8 as non-sleep is what reproduces ucddb008’s shipped 64% sleep efficiency (64.3% excluding it against 65.8% including), so that is how it is counted, and n_epochs_undocumented reports it separately.

The lead names come from the landing page, not the files. Every one of the 25 headers labels its channels chan 1, chan 2, chan 3 and names no electrode. The page says “Three-channel Holter ECGs (V5, CC5, V5R)”, and lead_names is that sentence’s order. Nothing in the release corroborates it, so treat leads=["V5R"] as a channel position with a probable name rather than an anatomically certain lead.

The respiratory-event breakdown, by the scorer’s own categories:

Event Count Counts toward AHI
Obstructive hypopnea (HYP-O) 1,433 yes
Central hypopnea (HYP-C) 1,076 yes
Central apnea (APNEA-C) 343 yes
Obstructive apnea (APNEA-O) 216 yes
Mixed apnea (APNEA-M) 136 yes
Mixed hypopnea (HYP-M) 114 yes
Periodic breathing (PB) 91 no
Equivocal (POSSIBLE) 19 no
Total 3,428 3,318 counted

Excluding PB and POSSIBLE is not a guess — it is what reproduces the shipped AHI. And the sleep-stage totals across the database: wake 4,707, REM 3,016, stage 1 3,403, stage 2 6,985, stage 3 673, stage 4 1,990, undocumented 15.

The 25 records, recomputed from the files

RecSexAgeBMIAHIGradeHoursCal sA+HEpochsOffset srSpread sFold
ucddb014M5629.036severe7.528718277434320.94121 (train)
ucddb018M3526.32normal8.40104982252800.97431 (train)
ucddb028M5030.146severe7.52871987211 (train)
ucddb010M3839.334severe8.1510923390718370.97912 (train)
ucddb011M5128.68mild8.171013590021860.97422 (train)
ucddb019M4930.916moderate8.288710485239850.87222 (train)
ucddb005M6532.413mild8.10975782641630.816283 (train)
ucddb013F6234.216moderate8.521156581150430.401633 (train)
ucddb020M5234.015moderate7.85777375254220.83713 (train)
ucddb002M5433.923moderate7.6511612474847610.97114 (train)
ucddb015M2829.06mild8.62803791630320.97514 (train)
ucddb023F6832.739severe8.6811719186126990.742464 (train)
ucddb006M5230.231severe8.456718780858880.928155 (train)
ucddb022M3429.37mild7.83812778744470.92815 (train)
ucddb007M4725.112mild8.25747381348450.94716 (train)
ucddb009M5231.312mild8.051017692510530.96616 (train)
ucddb012M5130.425moderate8.2510215186432860.97616 (train)
ucddb017M5337.812mild7.63756878935050.91617 (train)
ucddb024M5433.824moderate8.3510315490825860.91907 (train)
ucddb003M4831.851severe8.089430588224640.97728 (train)
ucddb026M4927.414mild7.82798483825650.87918 (train)
ucddb021F4133.613mild8.43948091323810.93539 (val)
ucddb025M5242.591severe8.228643371179320.91809 (val)
ucddb008F6328.45mild8.37971976838850.952810 (test)
ucddb027M4528.155severe8.2511935389325220.970110 (test)

Loading with ECGBench

from ecgbench import ECGDataset
from ecgbench.labels.ucddb import (
    ECG_STARTS_AT_SAMPLE,          # 15232 — the first sample past every
    respiratory_events,            #         record's calibration block
    sleep_stages,
)

# window= is not optional here: a record is 3.46M to 4.00M samples across 3
# leads, so a whole one is ~40 MB. It is pushed into the EDF reader as a seek
# to the data record it starts in. AND IT MUST NOT START AT 0: the first
# 67-119 s of every record is a 1 mV calibration square wave.
ds = ECGDataset(
    "ucddb",
    split="train",
    labels=True,
    window=(ECG_STARTS_AT_SAMPLE, 3840),   # 30 s of ECG, from 119.0 s in
    data_path="/path/to/ucddb/1.0.0/",
)

len(ds)                                    # 21
sample = ds[0]
sample["signal"].shape                     # (3, 3840)
sample["record_id"]                        # 'ucddb002'
sample["labels"]["recording_group"]        # 'ucddb002'
sample["labels"]["psg_ahi"]                # 23.0
sample["labels"]["ahi_severity"]           # 'moderate'
sample["labels"]["n_apnea_hypopnea"]       # 124
sample["labels"]["psg_offset_secs"]        # 4761.0   <- the Holter started first
sample["labels"]["calibration_samples"]    # 14848    <- 116.0 s of it, on this record
sample["labels"]["n_distinct_leads"]       # 2        <- ucddb002's chan 3 copies chan 2
float(sample["signal"].mean())             # 5.040    <- mV: the EDF pedestal, not a bug

# The intended use: cut a window around a scored respiratory event. holter_secs
# already has psg_offset_secs applied, so it indexes the ECG directly.
events = respiratory_events("/path/to/ucddb/1.0.0/", "ucddb002")
apnea = events[events["event_type"] == "APNEA-O"].iloc[0]
apnea["holter_secs"]                       # 10145.0
start = int((apnea["holter_secs"] - 30) * 128)                     # 1294720
event = ECGDataset("ucddb", split="train", window=(start, 60 * 128),
                   data_path="/path/to/ucddb/1.0.0/")
# -> (3, 7680), 4.20 to 6.64 mV

# Sleep stage at the same moment, on the same clock.
stages = sleep_stages("/path/to/ucddb/1.0.0/", "ucddb002")
stages[stages["holter_secs"] <= apnea["holter_secs"]].iloc[-1]["stage_name"]   # 's1'

# None of V5, CC5, V5R is a standard 12-lead name, so select by name.
ECGDataset("ucddb", split="train", window=(ECG_STARTS_AT_SAMPLE, 3840),
           leads=["CC5"],
           data_path="/path/to/ucddb/1.0.0/")[0]["signal"].shape   # (1, 3840)

Building the splits

# No flags: 24 recording groups over 25 records make ten folds of 2-3.
# The first run reads SubjectDetails.xls, the 25 stage files, the 25
# respiratory-event files and the 25 EDF headers, and caches the result as
# ecgbench_metadata.csv in the dataset root — so that root must be writable.
# Reading the .xls needs xlrd: pip install 'ecgbench[xls]'.
ecgbench splits --dataset ucddb --data-path /path/to/ucddb/1.0.0/