Preterm Infant Cardio-Respiratory Signals Database (PICSDB)

Open Completed

Quick facts

Format1-lead (bedside monitor) · 20.3–70.3 h · 500 Hz (8 records) / 250 Hz (2) · WFDB · bradycardia onsets + R peaks
Patients10
Records10
Leads1
LicenseODC Attribution
OriginUMass Memorial Healthcare NICU — USA — Worcester, MA

Overview

Ten simultaneous ECG and respiration recordings — 439.8 hours of ECG, 20.3 h to 70.3 h per infant — made at the bedside from Philips IntelliVue MP70 monitors in the neonatal intensive care unit of the University of Massachusetts Memorial Healthcare. The infants were 29 3/7 to 34 2/7 weeks post-conceptional age (mean 31 1/7) and 843 to 2,100 g at study (mean 1,468), all spontaneously breathing room air.

It is the only neonatal dataset in this catalogue, and the youngest cohort in it by decades. That is not a detail: these hearts run at 130–166 bpm, so a normal RR interval here is 0.36–0.46 s and a bradycardia is anything past 0.6 s. Adult HRV parameter choices, adult-trained detectors and adult amplitude expectations are all mis-specified on this data.

The ground truth is an event time, not a diagnosis. Every infant is a preterm infant in the same unit, so the record-level class is a constant and there is nothing to classify per record. What the database exists for is the 622 manually validated bradycardia onsets in the .atr files and the 3,797,503 manually verified R peaks in the .qrsc files — both time series, and both reachable aligned to window= through ecgbench.labels.picsdb.

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

Two sampling rates, so a window in samples is not a window in time

This is the trap most likely to produce quietly wrong results. infant1 and infant5 are the 250 Hz “compound” recordings the release describes; the other eight run at 500 Hz. window=(0, 15_000) is 30 s of infant2 and 60 s of infant5, and any code converting samples to seconds with one global rate is wrong for two records in ten.

Sampling rate is a per-record property here, not a choice of representation, so picsdb.yaml keys its single path column on the nominal 500 Hz and ECGDataset(sampling_rate=250) raises rather than handing back a mixed-rate subset — the same shape as challenge2021. To select by rate, filter on the per-record sampling_rate label column.

The respiration companions invert the same asymmetry: 50 Hz for nine infants and 500 Hz for infant1.

The bradycardia onset does not land on an R peak

The release defines a bradycardia as heart rate below 100 bpm — equivalently RR > 0.6 s — sustained over at least two beats (> 1.2 s), with successive events inside a 3-minute window aggregated. What the .atr file marks is the onset of that event, and measured against the .qrsc peaks it sits one sample after the R peak that opens the first long interval:

  Onsets
Within 2 samples of the opening R peak 526 of 622
…of which exactly one sample after it 493
…exactly on it 32
Within 10 s of one 622 of 622

So np.isin(onsets, rpeaks) finds 32 matches out of 622 and looks like an off-by-one bug in ECGBench rather than a property of the annotation. Re-measure it yourself — it reads only annotation files, so it is quick:

from ecgbench.labels.picsdb import verify_bradycardia_onsets
verify_bradycardia_onsets("/path/to/picsdb/1.0.0/")

Two further points about the events. The .atr symbol is [, WFDB’s start-of-ventricular-flutter marker being reused as a generic episode start — it does not mean flutter, and nothing should count it as a beat. And the shortest observed gap between consecutive onsets in a record is 369 s, well outside the 3-minute aggregation window, so the counts below are events rather than beats.

Clipping and dead signal that no quality check can see

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

Every record clips at the 16-bit converter rail. For eight of them it is 1 to 15 samples. infant5 sits at the negative rail for 422,773 samples (1,691 s, 0.96% of the recording) and infant1 for 160,567 (642 s, 0.39%), at −40.96 mV — nothing an infant heart did. Because gain and baseline differ per record, the rail in millivolts differs too, so amplitude_range_mv is the union of ten different rails, [−40.9604, +40.915] mV. It is not a physiologic bound and must not be read as one; what it still catches is a mis-scaled copy.

Every record holds minutes of perfectly constant signal, 239 s to 3,147 s of it, the longest single run being 1,456 s — 24 minutes — in infant5. ECGBench’s flat_line check tests variance over the whole record, and a 20–70 hour recording passes that trivially. Read flat_secs, flat_fraction and longest_flat_secs from the labels before choosing a window.

R-peak annotation covers 94.0% to 99.9% of each record, not all of it. infant10 has a 7,667 s (2.13 h) unannotated tail and 49 internal gaps over 10 s; infant5 opens with 1,631 s of unannotated signal and infant2 with 409 s. rpeaks() returning an empty array there means “not annotated here”, not “no beats”, and nothing in the WFDB headers says where the annotated span ends.

One more geometry mismatch, since the release says the two signals are synchronised: infant5’s respiration record runs 3,597 s (1.0 h) longer than its ECG, and infant1’s 35 s longer. The other eight agree to within 4 s.

Ten records, ten infants, ten folds

Folds are grouped on subject_id, which is 1:1 here — one ECG record per infant — so each fold is exactly one infant and the partition is leave-one-infant-out. Unlike szdb, nothing is reconstructed: the record name states the infant. It is declared rather than left null so the grouping stays correct if a re-release ever adds a second recording for an infant.

With the default mapping that gives train = folds 1–8 (8 infants), val = fold 9 (infant3) and test = fold 10 (infant6). One infant is not an evaluation set; use split=None with fold_numbers=[...] for real cross-validation.

There is nothing to stratify on, and that is measured rather than assumed. StratifiedGroupKFold raises unless some class holds at least n_folds records, and over ten records that admits exactly one class. Every candidate axis fails before it can be tried: a median cut on bradycardia rate (0.85–1.83/h) gives 5/5 and raises n_splits=10 cannot be greater than the number of members in each class; sampling rate gives 8/2 and raises; lead name gives 7/2/1 and raises. The stratification label is therefore the constant cohort_label, which reduces the split to a plain partition of the ten infants. Do not read the fold layout as balanced on anything.

One channel, named three different ways

Every record stores exactly one channel, and the ten headers disagree what to call it: II in seven, ECG in infant1 and infant5, I in infant10. That is the mitdb problem at a lead count of one — alternate_lead_names is keyed by lead count, and the count never changes — so the config declares record_lead_layouts: [["II"], ["ECG"], ["I"]] and ECGDataset resolves leads= against each record’s own header.

The consequence is deliberate: leads=["II"] returns a signal for seven records and raises for the other three. The release says only “a single channel of a 3-lead electrocardiogram” and nothing anywhere states that the ECG channel is lead II, so handing it back under that name would let it be stacked with real lead II from other datasets. Omit leads= to take whatever channel each record holds.

About those counts

Every figure on this page is recomputed from the shipped files. Nothing contradicts the release, and two things it does not state are worth recording.

Quantity Release / paper This release Note
Infants 10 10 agrees
ECG records 10 10 plus 10 respiration records, which get no fold
Recording duration “approximately 20–70 hours” 20.34–70.32 h agrees; 439.84 h in total
ECG sampling rate 500 Hz, 250 Hz for infants 1 and 5 same agrees
Respiration rate 50 Hz, 500 Hz for infant 1 same agrees
Bradycardia onsets not stated on the landing page 622 28–97 per infant
R peaks not stated 3,797,503 manually verified
Per-infant age and weight cohort ranges only not shipped see below

The event counts check out against an independent source. The per-infant bradycardia counts recomputed here — 77, 72, 80, 66, 72, 56, 34, 28, 97, 40 — match, infant for infant, the table published in Automated Medical Care: Bradycardia Detection and Cardiac Monitoring of Preterm Infants (PMC8625917), which used the same database. So does its duration column. That paper additionally prints a per-infant post-conceptional age and birth weight, and the PhysioNet release ships neither — the headers carry no comment lines at all, no age, no sex, no start time. ECGBench attaches no demographics to any row, because the mapping in a third-party table cannot be verified against the released files.

The stratification label is not a class breakdown: cohort_label is preterm_infant for all ten records, asserted by the release of the cohort and derived from nothing in the files. There is no negative class and no control group here.

The 10 records, recomputed from the files

RecHzLeadHoursBrady/hR peaksHRSDNN msCoverRail sFlat sFold
infant8500II24.60281.142045321404198.9%0.09461 (train)
infant10500I47.27400.854112411543994.0%0.48492 (train)
infant5250ECG48.75721.484111491434498.2%1,6913,1473 (train)
infant1250ECG45.61771.694192331542999.5%6426424 (train)
infant7500II20.34341.671950721613499.2%0.05705 (train)
infant2500II43.84721.643336041305197.8%0.02,4746 (train)
infant9500II70.32971.386266281494099.7%0.08537 (train)
infant4500II46.78661.414655651662699.9%0.02398 (train)
infant3500II43.71801.833350871304598.4%0.02,5229 (val)
infant6500II48.61561.153953921362799.5%0.076510 (test)
**Total****439.84****622****3,797,503****98.5%****2,334****13,007****10 folds**

Loading with ECGBench

from ecgbench import ECGDataset
from ecgbench.labels.picsdb import bradycardia_onsets, rpeaks

# window= is not optional here: records are 36.6M to 253.1M samples, so a batch
# of whole records is impossible. It is pushed into wfdb as sampfrom/sampto, so
# only these samples are decoded.
#
# AND IT COUNTS SAMPLES, NOT SECONDS: 15,000 is 30 s of the eight 500 Hz
# records and 60 s of infant1 and infant5.
ds = ECGDataset(
    "picsdb",
    split="train",
    labels=True,
    window=(0, 15_000),
    data_path="/path/to/picsdb/1.0.0/",
)

len(ds)                                  # 8
sample = ds[0]
sample["signal"].shape                   # (1, 15000)
sample["record_id"]                      # 'infant10_ecg'
sample["labels"]["subject_id"]           # 'infant10'
sample["labels"]["sampling_rate"]        # 500        <- READ THIS BEFORE CONVERTING TO SECONDS
sample["labels"]["lead_name"]            # 'I'        <- 'II' in seven records, 'ECG' in two
sample["labels"]["n_bradycardias"]       # 40
sample["labels"]["n_rpeaks"]             # 411241
sample["labels"]["mean_hr_bpm"]          # 154.3      <- an infant, not an adult
sample["labels"]["annotated_fraction"]   # 0.9397     <- its last 2.13 h carry no R peaks
sample["labels"]["flat_secs"]            # 849.0      <- constant signal no check can see

# The intended use: cut a window around a real event. The onsets are seconds
# into the record, so scale by THAT record's rate.
fs = int(sample["labels"]["sampling_rate"])
onset = float(sample["labels"]["bradycardia_onsets_secs"].split("|")[0])   # 813.164
start = int((onset - 15) * fs)                                            # 399082
event = ECGDataset("picsdb", split="train", window=(start, 30 * fs),
                   data_path="/path/to/picsdb/1.0.0/")

# Both annotation layers re-base onto the same window, so they index the tensor.
bradycardia_onsets("/path/to/picsdb/1.0.0/", "infant10_ecg", start, 30 * fs)  # [7500]
rpeaks("/path/to/picsdb/1.0.0/", "infant10_ecg", start, 30 * fs)             # 59 peaks

Building the splits

# No flags: 10 records from 10 infants make ten folds of one infant each.
# The first run reads all 1.58 billion samples once to measure converter
# clipping and constant runs, and caches the result as ecgbench_metadata.csv
# in the dataset root — so that root must be writable.
ecgbench splits --dataset picsdb --data-path /path/to/picsdb/1.0.0/