ECG Effects of Dofetilide, Moxifloxacin and Combinations (ECGDMMLD)

Open Completed

Quick facts

Format12-lead · 10 s · 500 Hz → 1 kHz · + derived median beats
Patients22
Records4,211 segments
Leads12
LicenseODC Attribution
OriginUS FDA / CDER; Phase I Crossover Study (NCT02308748) — USA

Overview

4,211 ten-second 12-lead ECGs at 1 kHz from 22 healthy volunteers in a randomised, double-blind, placebo-controlled 5-period crossover Phase I trial (study SCR-003, ClinicalTrials.gov NCT02308748) run by the US FDA’s Center for Drug Evaluation and Research.

The question is narrower and sharper than CiPA’s: can blocking late sodium current undo the QT prolongation caused by a pure hERG blocker? Dofetilide is the pure hERG blocker and prolongs QT on its own. Mexiletine and lidocaine block late sodium current. If the CiPA theory is right, adding either to dofetilide should pull J-Tpeak — the early-repolarisation half of the QT interval — back down, even while QTc stays prolonged.

Every subject received all five regimens, in one of ten randomised sequences with a week of washout between periods:

Code Regimen Role
A Dofetilide pure hERG block — the positive control
B Lidocaine + Dofetilide hERG block + late sodium block (IV)
C Mexiletine + Dofetilide hERG block + late sodium block (oral)
D Moxifloxacin + Diltiazem hERG block + L-type calcium block
E Placebo control

Records were extracted in triplicate at 14 nominal timepoints from half an hour before dosing to 24 hours after, each paired with a plasma concentration draw, and each is published twice: as the raw 10 s segment and as a derived 16-channel median beat carrying semi-automatic fiducial annotations.

This is a pharmacology dataset, not a diagnosis dataset. Every participant was screened to exclude cardiac disease. There is no rhythm, morphology or arrhythmia label anywhere in it, and none of the usual ECGBench habits built around a diagnostic class transfers — starting with stratification, which uses the treatment arm.

The one thing to know before training on this: the label is not the drug

treatment names the period’s randomised regimen. It does not say what was circulating when a given record was taken.

Within each period the agents were staged hours apart — the late-sodium or calcium blocker first, the hERG blocker later in the day. So the arm’s second drug is simply absent from the early timepoints:

treatment On board at 1.5–3 h Second agent appears
Dofetilide dofetilide
Mexiletine + Dofetilide mexiletine only dofetilide, 6.5 h
Lidocaine + Dofetilide lidocaine only dofetilide, 6.5 h
Moxifloxacin + Diltiazem moxifloxacin only diltiazem, 12 h
Placebo

A record labelled Mexiletine + Dofetilide at timepoint_hours = 2.0 is a mexiletine-only ECG, and there are hundreds of them. Measured against the plasma columns, only 57% of the dofetilide-arm records have any dofetilide in them at all, and only 27% of the moxifloxacin arm has diltiazem — the rest are pre-dose or pre-second-agent.

treatment is ECGBench’s stratification label because it is the only patient-level categorical in a cohort with no disease. It is not a training target. Use the six plasma_* columns for actual exposure, or cross treatment with timepoint_hours.

Treatment arms — the stratification label

Treatment armSubjectsRecordsShareIon-channel profile
Mexiletine + Dofetilide2188220.9%hERG + late sodium (oral mexiletine)
Dofetilide2084019.9%predominant hERG (positive control)
Placebo2084019.9%control
Lidocaine + Dofetilide2082519.6%hERG + late sodium (IV lidocaine)
Moxifloxacin + Diltiazem2082419.6%hERG + L-type calcium
**Total****22****4,211****100%**5 values of `treatment` (`TRTA`)

Why the subject counts are all 20–21 and not 4 or 5

Because this is a complete crossover: 19 of the 22 subjects passed through all five arms, so nearly every subject appears in every row of the table above. Two consequences that a parallel-group dataset would not have:

The three subjects who withdrew early are why the counts are 20–21 rather than 22: 2015 completed 1 period, 2011 completed 2, and 2021 completed 3. Balancing those three across folds is the only real work stratification does here.

4,211 records are not 4,211 observations

Two levels of clustering sit between the record count and anything you could call an independent sample.

Triplicates. Three ten-second segments were extracted per subject per nominal timepoint — 1,403 of the 1,404 timepoint groups hold exactly 3 records (one holds 2). They are the same person, in the same posture, at the same plasma concentration, seconds apart: near-duplicates, not repeats of an experiment. The effective sample size is closer to 1,404 than to 4,211.

Subjects. Those 1,404 groups come from 22 people, 42 to 210 records each (median 210), because three withdrew early. So every per-record statistic is weighted by trial compliance rather than by person.

Folds are grouped on patient_id, which handles both at once: verified on the shipped release, no subject spans two folds and none of the 1,404 timepoint triplicates is ever split. Group your own analyses the same way, and weight by subject before quoting any rate.

Every record ships twice — raw, and a derived median beat

Directory Contents Channels Samples ECGBench column
raw/<subject>/<uuid> the 10 s acquisition 12 10,000 signal_paththe dataset’s signal
medians/<subject>/<uuid> derived representative median beat 16 1,200 median_beat_path (labels only)

The median beat adds the vector-magnitude lead VCGMAG and the Frank vx, vy, vz components to the 12, and carries a .atr annotation file. Both directories are 1 kHz and use the same record ID, so they are two representations of one acquisition, not two records.

The medians get no fold of their own, deliberately. Every median beat is a derivation of a raw record ECGBench already partitions; generating a second ten-fold split over the same recordings would let someone train on one and evaluate on the other. ECGDataset therefore always reads raw/, and the median beats reach you as paths plus a fiducial loader.

from ecgbench import load_config
from ecgbench.labels.ecgdmmld import load_median_beat_fiducials

config = load_config("ecgdmmld")
fid = load_median_beat_fiducials("/path/to/ecgdmmld/1.0.0/", config)
fid.loc["39BF8219-C83A-4121-926F-2BC730FBE127"]
# p_onset_ms 193, qrs_onset_ms 359, qrs_offset_ms 431,
# t_peak_ms 694, t_peak_secondary_ms None, t_offset_ms 779

The fiducials and the interval table are not independent measurements. The annotations are what the published intervals were measured from, and they reproduce the clinical table exactly: across all 4,211 records, PR, QT and Tpeak-Tend recomputed from the fiducials equal the published value to the millisecond, as do QRS and J-Tpeak for the 4,202 that have a QRS offset (for the record above: 359−193 = PR 166, 431−359 = QRS 72, 779−359 = QT 420, 694−431 = J-Tpeak 263, 779−694 = Tpeak-Tend 85). Treat agreement between them as a format check, never as corroboration.

Three defects in the release — all of them upstream

All 21,059 files match the release’s own SHA256SUMS.txt, so none of these is download damage.

1. TPEAKTPEAKP is empty in all 4,211 rows. The column is documented — “interval between the two peaks of the T-wave (if secondary peak is present)” — and it is 100% NA. Consistently, no .atr file in the release marks a second T peak, though the PhysioNet page lists “secondary T peak (if present)” among the annotations. There is no secondary T-peak information in this dataset at all. ECGBench still exposes it as tpeak_tpeakp_ms so its absence is visible rather than something you infer.

2. Three median-beat headers are corrupt and raise IndexError from wfdb.rdrecord. In each, one channel’s .dat filename has digits from the gain field spliced into it:

Record Subject Broken channel Header says File is
9D7B03F2-…-FFDFD3526628 2004 vy …FFDFD3526620008.dat …FFDFD3526628.dat
DCA7A8CC-…-48F31964B73D 2007 VCGMAG …48F31000964B73D.dat …48F31964B73D.dat
79B4DFED-…-5B5C9D803D62 2012 VCGMAG …5B5C9D803D62000.dat …5B5C9D803D62.dat

The .dat payloads are intact (38,400 bytes, the correct size) and the .atr files parse, so only these three median signals are unreachable without repairing the header by hand. 4,208 of 4,211 load. The corresponding raw/ records are unaffected, so this costs the split nothing — filter on the median_beat_readable column.

3. Nine records have no QRS-offset annotation, so qrs_ms and jtpeak_ms are NA for them and their .atr carries 4 marks instead of 5. rr_ms, pr_ms, qt_ms and tpeak_tend_ms are complete for all 4,211.

Per-record labels (38 columns) — no class among them

GroupColumnsNotes
Identity & paths`patient_id`, `signal_path`, `median_beat_path`, `median_beat_readable`the record ID is the `EGREFID` UUID, unique across the release; **neither path exists in the source** — both are derived
Drug exposure`treatment`, `treatment_sequence``treatment` is the **period's arm**, not the drug on board — see above
Timing`period`, `period_label`, `timepoint_hours`, `is_baseline`one clock only, unlike CiPA: hours from the period's first dose, −0.5 to 24
Intervals (ms; HR in bpm)`hr_bpm`, `rr_ms`, `pr_ms`, `qrs_ms`, `qt_ms`, `qtcf_ms`, `jtpeak_ms`, `tpeak_tend_ms`, `erd_30_ms`, `lrd_30_ms`, `tpeak_tpeakp_ms`**`hr_bpm` and `qtcf_ms` are derived** — the release ships neither; `tpeak_tpeakp_ms` is always empty
T-wave morphology`twave_amplitude_uv`, `twave_asymmetry`, `twave_flatness`**amplitude is µV** while the waveforms are mV; the other two are dimensionless
Plasma concentration`plasma_{lidocaine,mexiletine,moxifloxacin,moxifloxacin_m2,diltiazem}_ng_ml`, `plasma_dofetilide_pg_ml`**dofetilide is pg/mL, the other five ng/mL**; 2,637 of 4,211 records have at least one measured value
Subject`age_years`, `sex`, `race`, `ethnicity`, `height_cm`, `weight_kg`, `systolic_bp_mmhg`, `diastolic_bp_mmhg`constant within a subject, so repeated across their 42–210 records

Interval and morphology measurements over all 4,211 records

ParameterMeanSDMinMaxMissing
`hr_bpm` (HR, bpm) — derived67.19.245.1104.50
`rr_ms` (RR)910.8121.257413310
`pr_ms` (PR)162.020.51073530
`qrs_ms` (QRS)86.38.565106**9**
`qt_ms` (QT)388.224.33254750
`qtcf_ms` (QTcF) — derived401.522.9353.7499.10
`jtpeak_ms` (J-Tpeak)212.423.3138281**9**
`tpeak_tend_ms` (Tpeak-Tend)89.514.9572060
`erd_30_ms` (30% early repol. duration)54.813.3201940
`lrd_30_ms` (30% late repol. duration)35.310.47930
`twave_amplitude_uv` (µV)501.0166.781.71259.70
`twave_asymmetry`0.210.1101.340
`twave_flatness`0.440.060.220.620
`tpeak_tpeakp_ms` (Tpeak-Tpeak′)**4,211**

The endpoint that CiPA cannot give you, and this one can

The study’s result is change from baseline, not the absolute interval. In the sibling CiPA release those change values exist only on adeg.csv’s triplicate-average rows, which carry a blank record ID and therefore cannot be attached to any waveform. Here nothing is lost.

BASELINE = Y flags the three pre-dose (timepoint_hours = −0.5) records of each (subject, period) pair, and all 101 pairs in the release have one — 303 baseline records over 101 pairs. So a baseline is just their mean, and the delta is a per-record quantity:

from ecgbench import load_config
from ecgbench.labels.ecgdmmld import load_baseline_deltas

df = load_baseline_deltas("/path/to/ecgdmmld/1.0.0/", load_config("ecgdmmld"))
df.loc[~df["is_baseline"]].groupby("treatment")["delta_jtpeak_ms"].mean().round(1)
# Dofetilide                 -4.9
# Lidocaine + Dofetilide    -18.5
# Mexiletine + Dofetilide   -25.2
# Moxifloxacin + Diltiazem  -12.7
# Placebo                   -18.4

Note the placebo row: J-Tpeak falls by 18 ms on placebo alone. These raw deltas are dominated by diurnal drift, which is precisely why the published analysis is placebo-corrected and why you must be too.

Baselines are per period, not per subject — each crossover period has its own pre-dose triplicate, and sharing one across periods would attribute a washout drift to the drug.

What is not done for you is placebo-correction, which needs the placebo arm’s mean across subjects at the same nominal timepoint. That is an analysis decision rather than a label, so it is left to you — and it is why the figures here do not equal the published ones.

What the dataset is for: J-Tpeak separates the arms that QTc cannot

ArmQTcF pre-doseQTcF at CmaxΔQTcFΔJ-TpeakMean peak concentration
Dofetilide398.4446.1**+47.7****+7.1**1,822 pg/mL dofetilide
Lidocaine + Dofetilide398.4423.3+24.9−1.82,232 pg/mL dofetilide
Mexiletine + Dofetilide398.4422.0+23.6−13.12,086 pg/mL dofetilide
Moxifloxacin + Diltiazem398.7416.3+17.6−33.710,490 ng/mL moxifloxacin
Placebo397.2389.8−7.5−18.4n/a

How that table was computed, and how to read it

All values in ms, recomputed over all 4,211 records from ecgbench.labels.ecgdmmld. “Pre-dose” is the mean over each arm’s is_baseline records; “Cmax” is the mean over its top decile of plasma concentration of the analyte named in the last column (for placebo, which has no analyte, every post-dose record).

This is a descriptive summary, not the study’s analysis. The published result fits a concentration–response model to placebo-corrected change from baseline, and its effect sizes differ from these.

Read every arm against placebo before concluding anything. Placebo is not flat: it drifts −7.5 ms on QTcF and −18.4 ms on J-Tpeak over the same hours, so the raw J-Tpeak column above is mostly diurnal drift and the naive reading (“all the combination arms shorten J-Tpeak”) is an artefact. Subtracting the placebo row is what the study does, and it is what makes the pattern appear:

Arm ΔQTcF vs placebo ΔJ-Tpeak vs placebo
Dofetilide +55.2 +25.5
Lidocaine + Dofetilide +32.4 +16.6
Mexiletine + Dofetilide +31.1 +5.3
Moxifloxacin + Diltiazem +25.1 −15.3

Now the result is legible. Dofetilide alone prolongs both QTcF (+55) and J-Tpeak (+26). Adding a late-sodium blocker leaves QTcF still clearly prolonged (+31 to +32, only about half undone) while collapsing J-Tpeak toward zero (+16.6 with lidocaine, +5.3 with mexiletine). Replace late-sodium block with calcium block (moxifloxacin+diltiazem) and J-Tpeak goes negative altogether (−15.3).

That is the point of the dataset: QTcF cannot separate these arms — every one of them is prolonged by +25 to +55 ms — and J-Tpeak can, spanning +26 to −15 over the same records. This is a descriptive reconstruction, not the published model, but the ordering it recovers is the study’s finding.

Units and scale — three ways to get this wrong

1. The signals are millivolts, and this is the opposite of its sibling. Every channel of every record declares its own gain against unit /mV — per-lead and per-record, e.g. 330979.8(-11915)/mV for lead I next to 33780.4(-16215)/mV for lead II of the same record, each fitted to that channel’s range. wfdb.rdrecord applies them, so signal_unit_scale is 1.0. CiPA declares /uV and needs 0.001; copying that value here divides every sample by 1000 and amplitude_outlier never fires again. units="uV" multiplies by 1000 if you want the microvolt scale.

2. The 1 kHz is interpolated. Acquisition was at 500 Hz with 2.5 µV resolution on a Mortara Surveyor; the depositors up-sampled the extracted segments to 1000 Hz. Every record is 10,000 samples and the headers say 1000 Hz, but there are only 5,000 measurements in there. Anything sensitive to true bandwidth — high-frequency QRS content, derivative-based delineation — should treat this as 500 Hz data.

3. twave_amplitude_uv is microvolts while the waveforms are millivolts. The source measures it on the median beat’s vector-magnitude lead; the range is 81.7–1259.7 µV. The unit is in the column name for the same reason dofetilide’s is: so a rename cannot lose it.

And on the plasma columns: dofetilide is pg/mL, the other five analytes are ng/mL, per the shipped column description. Pooling them numerically is a 1000× error. A missing concentration is NA and means either “not dosed / not drawn” or “below the limit of quantification” — unlike CiPA, this release has no censoring flag and no zeros to distinguish the two.

Lead order — uppercase, unlike its sibling

Index01234567891011
`lead_names`IIIIII**AVR****AVL****AVF**V1V2V3V4V5V6

On the spelling

Standard order, spelled with an uppercase AAVR/AVL/AVF, like PTB-XL. Verified identical in all 4,211 raw/ headers, and the medians/ headers agree.

This is the opposite of CiPA, whose raw/ headers write aVR/aVL/aVF while its medians/ headers write AVR/AVL/AVF — so the two sibling releases disagree with each other, and CiPA disagrees with itself. Matching in ECGDataset(leads=...) is case-insensitive, so leads=["aVL"] resolves in both; lead_names records what the files actually say. Never index leads positionally across datasets — signal[4] is aVL here and in CiPA, but aVF in MIMIC-IV-ECG.

Validation summary (1000 Hz)

MetricValue
Records validated4,211
Valid (`clean`)**4,209** (99.95%)
Excluded2
`amplitude_outlier`2 records, 4 lead-level issues
`missing_leads` / `nan_values` / `flat_line` / `truncated_signal` / `corrupt_header`**0** — none fires

What validation actually caught

amplitude_outlier is the only check that fires. Scanned over all 4,211 records × 12 leads × 10,000 samples the observed range is −8.40 to +18.49 mV, but the distribution is tight: the 99.9th percentile of per-record peak absolute amplitude is 3.91 mV and only 4 records exceed 5 mV. The house default [-10, 10] mV therefore excludes exactly two:

Record Subject Arm Range Leads
9779B087-4B95-4FA0-9C6E-D532B1366DAE 2016 Placebo −0.84 to +18.49 mV V5
1DA19FBE-DEB2-40BE-A657-AD9DCA8B9FBD 2007 Moxifloxacin + Diltiazem −1.29 to +13.60 mV II, III, AVF

Both are electrode artefacts, not physiology. Nothing else is wrong with the release’s signals: no record has a NaN sample, a missing lead or a flat lead (the minimum per-lead variance across the whole release is 2.3 × 10⁻⁴ mV², against the check’s 10⁻⁶ threshold), and every one of the 4,211 raw/ headers parses — the three corrupt headers are in medians/, which validation does not read.

Fold layout (10-fold, grouped on patient_id, stratified on treatment)

FoldSplitRecords (original)Records (clean)SubjectsSubject IDs
1train50450332004, 2007, 2011
2train46246232012, 2015, 2020
3train33633622005, 2021
4train40440422003, 2018
5train42042022001, 2019
6train40540522009, 2013
7train42042022014, 2017
8train42042022002, 2010
9**val**42041922016, 2022
10**test**42042022006, 2008
**Total****4,211****4,209****22**2001–2022

Reading the fold table

Fold sizes are uneven, and patient grouping is why. 22 subjects over 10 folds is 2–3 per fold while records per subject run 42–210, so folds run 336–504 records rather than a uniform 421. That is the correct trade — the alternative leaks triplicates — but it means a per-fold metric is computed over a varying number of records from a fixed and very small number of people. Fold 3 is two subjects. Fold 10, the test set, is two subjects. Any result quoted on one fold of this dataset is a result about two or three individuals.

The three early withdrawals were spread rather than clustered: 2011 (84 records) lands in fold 1, 2015 (42) in fold 2, 2021 (126) in fold 3, each alongside completers. That is the one thing stratifying on treatment accomplished here.

All five arms appear in all ten folds — 0 empty cells of 50 — with 84 records per arm per fold in 45 of them. The exceptions are Dofetilide and Placebo in fold 1 (126 each) and fold 3 (42 each), Mexiletine+Dofetilide in fold 2 (126), Moxifloxacin+Diltiazem in fold 4 (68) and Lidocaine+Dofetilide in fold 6 (69). As explained above, that completeness follows from the crossover design and not from the stratifier.

Fold membership is identical between original/ and clean/; clean/ is a row subset, differing only where the two excluded records sat (folds 1 and 9).

Loading with ECGBench

from ecgbench import ECGDataset

# Fold CSVs come from the Hub; data_path points at your local signal files.
ds = ECGDataset(
    "ecgdmmld",
    split="train",
    data_path="/path/to/ecgdmmld/1.0.0/",
    labels=True,
)
len(ds)                                  # 3370
sample = ds[0]
sample["signal"].shape                   # torch.Size([12, 10000])
sample["labels"]["patient_id"]            # '2021'
sample["labels"]["treatment"]             # 'Moxifloxacin + Diltiazem'
sample["labels"]["timepoint_hours"]       # 8.0
sample["labels"]["qtcf_ms"]               # 429.4 (derived — not in the source)

# And here is the trap, live. This record's arm is "Moxifloxacin + Diltiazem",
# but at 8 h only the moxifloxacin is on board — diltiazem is not dosed until
# 12 h, so its concentration is NaN. `treatment` is the randomisation arm.
sample["labels"]["plasma_moxifloxacin_ng_ml"]  # 6750.0
sample["labels"]["plasma_diltiazem_ng_ml"]     # nan

# The samples are already millivolts (signal_unit_scale=1.0, NOT CiPA's 0.001).
# units="uV" gets the microvolt scale if you want it.
uv = ECGDataset("ecgdmmld", split="train", data_path="...", units="uV")

# Records are a uniform 10,000 samples, so any window inside [0, 10000) fits
# all of them. window= is pushed into wfdb's sampfrom/sampto, so it decodes
# only what it returns — and unlike a cropping lambda it survives
# DataLoader(num_workers>0) under the spawn start method.
short = ECGDataset("ecgdmmld", split="train", data_path="...",
                   window=(2000, 5000), leads=["I", "II", "V5"])
short[0]["signal"].shape                 # torch.Size([3, 5000])