ADABase and REVELIO: Loading the Public Sets¶

ADABase [1] and REVELIO [2] are multimodal datasets for cognitive load estimation. While participants worked on tasks of graded difficulty, we recorded physiological, behavioral, subjective and performance data. The sessions are published on Zenodo in four records:

Subset Sessions DOI
ADABase: Cognitive Driving Public Set 30 10.5281/zenodo.24051552
ADABase: Cognitive Driving Private Set 21 10.5281/zenodo.24059812
REVELIO: Cognitive Gaming Public Set 30 10.5281/zenodo.24060698
REVELIO: Cognitive Gaming Private Set 15 10.5281/zenodo.24076078

The two private sets are kept back as hidden test sets. This notebook uses only the two public sets, 60 sessions in total. In code and tables we refer to them by a short name:

  • drive is the ADABase: Cognitive Driving Public Set [3] with the tasks n-back and k-drive.
  • game is the REVELIO: Cognitive Gaming Public Set [4] with the tasks n-back, u-harry (Hogwarts Legacy) and s-cook (Overcooked! 2).

The publications describe the experimental protocol. This notebook shows how the data are organized and how to turn them into a machine learning dataset:

  1. Files and tables
  2. Participants
  3. Protocol and annotation (STUDY, PHASE, LEVEL)
  4. Subjective ratings and task performance
  5. Biosignals
  6. Downsampling and normalization
  7. Window selection
  8. Building the dataset
  9. Suggested split of the public sets
  10. How to cite

The notebook needs only numpy, pandas, tables (PyTables) and matplotlib.

[1] Oppelt, M.P., Foltyn, A., Deuschel, J., Lang, N.R., Holzer, N., Eskofier, B.M., Yang, S.H. ADABase: A Multimodal Dataset for Cognitive Load Estimation. Sensors 2023, 23(1), 340. https://doi.org/10.3390/s23010340

[2] Oppelt, M.P., Foltyn, A., Lang-Richter, N.R., Eskofier, B.M. REVELIO: Multimodal Task Load Estimation for Cross-Domain Generalization. International Journal of Human-Computer Interaction, 2026, in press. Preprint: https://doi.org/10.48550/arXiv.2509.01642

[3] ADABase: Cognitive Driving Public Set. Zenodo, 2026. https://doi.org/10.5281/zenodo.24051552

[4] REVELIO: Cognitive Gaming Public Set. Zenodo, 2026. https://doi.org/10.5281/zenodo.24060698

Setup¶

Both public sets are restricted records. Researchers with a permanent position at an academic institution can request access with the Request access button on each Zenodo record page. Use is limited to non-commercial academic research under the license of the record.

Download each record into its own folder and keep the folder names below:

<DATA_PATH>/
    ADABase-driving-public/    0000-ACAA.h5 ... 0029-ZZOV.h5
    REVELIO-gaming-public/     0009-WMS15.h5 ... 0069-ZYP27.h5

Each record ships a file checksums.md5. Run md5sum -c checksums.md5 in each folder to verify the download.

By default the notebook looks for a folder data next to this notebook. To use another location, set the environment variable COGNITIVE_LOAD_DATA or change DATA_PATH in the cell below.

In [ ]:
import os
import pathlib as ptl

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import tables
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch

# folder with one subfolder per Zenodo record, see the setup notes above
DATA_PATH = ptl.Path(os.environ.get("COGNITIVE_LOAD_DATA", "data")).expanduser()

# the two public Zenodo records, by short name
SUBSETS = {
    "drive": {
        "title": "ADABase: Cognitive Driving Public Set",
        "folder": "ADABase-driving-public",
        "doi": "10.5281/zenodo.24051552",
    },
    "game": {
        "title": "REVELIO: Cognitive Gaming Public Set",
        "folder": "REVELIO-gaming-public",
        "doi": "10.5281/zenodo.24060698",
    },
}

# one file per session, named <participant>-<code>.h5
FILES = {
    experiment: sorted(
        p for p in (DATA_PATH / subset["folder"]).glob("*.h5") if p.stem.count("-") == 1
    )
    for experiment, subset in SUBSETS.items()
}
for experiment, files in FILES.items():
    if not files:
        raise FileNotFoundError(f"No .h5 files in {DATA_PATH / SUBSETS[experiment]['folder']}")

EXPERIMENT = {f.stem: experiment for experiment, files in FILES.items() for f in files}
# the first session of each set serves as example
EXAMPLE = {experiment: files[0] for experiment, files in FILES.items()}

for experiment, files in FILES.items():
    size_gb = sum(f.stat().st_size for f in files) / 1e9
    print(f"{experiment}: {SUBSETS[experiment]['title']}, {len(files)} sessions, {size_gb:.1f} GB")
In [ ]:
plt.rcParams.update(
    {
        "figure.dpi": 120,
        "font.size": 8,
        "axes.titlesize": 9,
        "axes.titleweight": "bold",
        "axes.titlelocation": "left",
        "axes.labelcolor": "#52514e",
        "axes.edgecolor": "#8a8984",
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.grid": True,
        "grid.color": "#e8e7e3",
        "grid.linewidth": 0.6,
        "xtick.color": "#52514e",
        "ytick.color": "#52514e",
        "legend.frameon": False,
    }
)

NEUTRAL, LIGHT = "#b4b3ad", "#e3e2dd"
STUDY_COLORS = {
    "n-back": "#2a78d6",
    "k-drive": "#eb6834",
    "u-harry": "#1baf7a",
    "s-cook": "#6250d6",
}
STUDY_LIGHT = {
    "n-back": "#bcd6f5",
    "k-drive": "#f8c9b3",
    "u-harry": "#bfe8d8",
    "s-cook": "#d3cef5",
}
# light to dark, encodes the difficulty level
RAMPS = {
    "n-back": ["#a9cbf2", "#5f9ee6", "#1f5fae"],
    "k-drive": ["#f7c1a8", "#ef8a5c", "#c0491b"],
    "u-harry": ["#9fdcc4", "#4cc49a", "#12845b"],
    "s-cook": ["#c3bcf2", "#9085e9", "#4a3bb0"],
}
SPLIT_COLORS = {"train": "#2a78d6", "validation": "#eb6834", "test": "#1baf7a"}

Files and tables¶

Each session has one HDF5 file named <participant>-<code>.h5, for example 0000-ACAA.h5. The four-digit participant ID is unique across ADABase and REVELIO, so the same ID always denotes the same person. The code is a pseudonym of the session. A file holds four tables, which pandas.read_hdf reads directly. Driving and gaming files share this layout.

In [ ]:
TABLES = ["SIGNALS", "SUBJECTIVE", "PERFORMANCE", "META"]

rows = []
for experiment, path in EXAMPLE.items():
    with pd.HDFStore(path, "r") as store:
        for key in TABLES:
            storer = store.get_storer(key)
            rows.append(
                {
                    "table": key,
                    "experiment": experiment,
                    "rows": storer.nrows,
                    "columns": len(storer.non_index_axes[0][1]),
                }
            )

pd.DataFrame(rows).pivot(index="table", columns="experiment").loc[TABLES]

SIGNALS is indexed by time in milliseconds. A few example channels from ten consecutive rows:

In [ ]:
EXAMPLE_CHANNELS = [
    "STUDY",
    "PHASE",
    "LEVEL",
    "RAW_ECG_I",
    "RAW_EDA",
    "ECG_I_PEAKS",
    "EYE_LEFT_PUPIL_DIAMETER",
    "EYE_LEFT_GAZE_POINT_ON_DISPLAY_AREA_X",
    "FACE_AU12",
    "MOVE_X_NOSE",
]
pd.read_hdf(EXAMPLE["drive"], "SIGNALS", columns=EXAMPLE_CHANNELS, start=3_000_000, stop=3_000_010)
In [ ]:
with pd.HDFStore(EXAMPLE["drive"], "r") as store:
    all_channels = list(store.get_storer("SIGNALS").non_index_axes[0][1])
print(len(all_channels), "channels, for example", all_channels[:8])

All streams share one 1 kHz grid. Streams with a lower native rate are NaN between their samples, and .dropna() on a column recovers the native samples. The row number equals the time in ms, so start and stop select a time range without reading the whole file.

Finding the sampling rate of a channel. Take the native samples of a few seconds and measure the interval between them. For the eye tracker we use the validity flag, which exists for every sample, while the pupil diameter is missing during blinks.

In [ ]:
ten_seconds = pd.read_hdf(
    EXAMPLE["drive"],
    "SIGNALS",
    columns=["RAW_ECG_I", "EYE_LEFT_PUPIL_VALIDITY", "FACE_AU12"],
    start=3_000_000,
    stop=3_010_000,
)
for channel in ten_seconds:
    interval_ms = np.median(np.diff(ten_seconds[channel].dropna().index))
    print(f"{channel:<26} one sample every {interval_ms:.0f} ms = {1000 / interval_ms:.0f} Hz")

SUBJECTIVE and PERFORMANCE hold one row per rated or scored level. The performance columns differ between the tasks.

In [ ]:
pd.read_hdf(EXAMPLE["drive"], "SUBJECTIVE").head()
In [ ]:
pd.read_hdf(EXAMPLE["game"], "PERFORMANCE").dropna(axis=1, how="all").head()

META holds one row per session with demographics and questionnaires. We list its columns only.

In [ ]:
columns = {e: pd.read_hdf(path, "META").columns for e, path in EXAMPLE.items()}
pd.Series(
    {e: ", ".join(dict.fromkeys(c.get_level_values(0))) for e, c in columns.items()},
    name="column groups",
).to_frame()

Participants¶

Group statistics from META:

In [ ]:
def demographics(files):
    person = pd.concat([pd.read_hdf(f, "META") for f in files])["Subject"]
    age = person["Age"].astype(float)
    sexes = person["Sex"].value_counts()
    return {
        "Sessions": len(files),
        "Female / male / not stated": f"{sexes.get('female', 0)} / {sexes.get('male', 0)} / {sexes.get('unknown', 0)}",
        "Age [years]": f"{age.mean():.1f} ± {age.std():.1f} (range {age.min():.0f} to {age.max():.0f})",
    }


pd.DataFrame({experiment: demographics(files) for experiment, files in FILES.items()})
In [ ]:
person_of = {stem: stem.split("-")[0] for stem in EXPERIMENT}
people = {e: {person_of[f.stem] for f in files} for e, files in FILES.items()}
print(f"{len(person_of)} sessions from {len(set(person_of.values()))} people")
print(f"{len(people['drive'] & people['game'])} people took part in both experiments")
print(
    f"{len(FILES['game']) - len(people['game'])} gaming sessions come from a person with an earlier gaming session"
)

Several people contributed more than one session. This matters for splitting (see "Suggested split of the public dataset").

Protocol and annotation¶

Every sample in SIGNALS carries three labels:

  • STUDY is the task. Time outside all tasks is n/a.
  • PHASE is baseline, train, test, or n/a between the phases.
  • LEVEL numbers the conditions within a study and phase.

The meaning of every condition is given in [1] for driving and in [2] for gaming.

pd.read_hdf(path, "SIGNALS", columns=["STUDY", "PHASE", "LEVEL"]) reads the annotation, but it parses all 9 million rows (about 30 s per file). For an overview we read every 100th row (10 Hz) directly with PyTables, which takes about 2 s. In the gaming files STUDY and PHASE are stored as pandas categories, i.e. integer codes plus a small table of names.

In [ ]:
def read_annotation(path, step=100):
    # STUDY, PHASE and LEVEL every `step` ms, indexed by time in seconds
    columns = {}
    with tables.open_file(path, "r") as h5:
        table = h5.root.SIGNALS.table
        rows = table.read(step=step)
        for block in table.colnames[1:]:
            names = list(getattr(table.attrs, f"{block}_kind"))
            for i, name in enumerate(names):
                if name in ("STUDY", "PHASE", "LEVEL"):
                    columns[name] = (block, rows[block][:, i])
        categorical = {
            b for b, _ in columns.values() if getattr(table.attrs, f"{b}_meta", None) == "category"
        }
    frame = pd.DataFrame(
        {n: v for n, (_, v) in columns.items()},
        index=pd.Index(rows["index"] / 1000, name="time [s]"),
    )
    for name, (block, _) in columns.items():
        if block in categorical:
            frame[name] = np.asarray(pd.read_hdf(path, f"SIGNALS/meta/{block}/meta"))[frame[name]]
        elif name != "LEVEL":
            frame[name] = frame[name].str.decode("utf-8")
    frame["LEVEL"] = frame["LEVEL"].astype(int)
    return frame[["STUDY", "PHASE", "LEVEL"]]


annotations = {f.stem: read_annotation(f) for files in FILES.values() for f in files}

All STUDY, PHASE and LEVEL combinations in the data, with their duration in minutes over sessions:

In [ ]:
rows = []
for stem, annotation in annotations.items():
    active = annotation[annotation["PHASE"] != "n/a"]
    for key, samples in active.value_counts().items():
        rows.append(
            {
                "experiment": EXPERIMENT[stem],
                "STUDY": key[0],
                "PHASE": key[1],
                "LEVEL": key[2],
                "minutes": samples / 600,
            }
        )

(
    pd.DataFrame(rows)
    .groupby(["experiment", "STUDY", "PHASE", "LEVEL"])["minutes"]
    .agg(sessions="count", mean="mean", min="min", max="max")
    .round(1)
)

The protocol of one session per experiment as a timeline, one lane per condition.

In [ ]:
# baseline and test levels per experiment and study
LEVELS = {
    "drive": {"n-back": ([1, 2], [1, 2, 3, 4, 5, 6]), "k-drive": ([1, 2, 3], [1, 2, 3])},
    "game": {
        "n-back": ([1, 2], [1, 2, 3]),
        "u-harry": ([1, 2], [1, 2]),
        "s-cook": ([1], [1, 2, 3]),
    },
}


def lanes(experiment, study):
    # (phase, levels, label, color) for every lane of a study
    baselines, tests = LEVELS[experiment][study]
    ramp = RAMPS[study]
    colors = [
        ramp[i % 3] if len(tests) > 3 else ramp[i + 3 - len(tests)] for i in range(len(tests))
    ]
    return (
        [("baseline", [b], f"baseline {b}", NEUTRAL) for b in baselines]
        + [("train", tests, "train", LIGHT)]
        + [("test", [t], f"test {t}", c) for t, c in zip(tests, colors)]
    )


def runs(mask):
    # start and stop index of every contiguous True run
    edges = np.flatnonzero(np.diff(np.r_[0, mask.astype(int), 0]))
    return list(zip(edges[::2], edges[1::2]))


def plot_lanes(ax, annotation, experiment, study):
    minutes = annotation.index.to_numpy() / 60
    study_lanes = lanes(experiment, study)
    for row, (phase, levels, label, color) in enumerate(study_lanes):
        mask = (
            (annotation["STUDY"] == study)
            & (annotation["PHASE"] == phase)
            & annotation["LEVEL"].isin(levels)
        ).to_numpy()
        for start, stop in runs(mask):
            ax.broken_barh(
                [(minutes[start], minutes[stop - 1] - minutes[start])],
                (row - 0.38, 0.76),
                color=color,
            )
    ax.set_yticks(range(len(study_lanes)), [lane[2] for lane in study_lanes])
    ax.set_ylim(len(study_lanes) - 0.5, -0.5)
    ax.grid(axis="y", visible=False)
    ax.set_title(study, color=STUDY_COLORS[study])


def plot_protocol(path, experiment):
    annotation = annotations[path.stem]
    studies = list(LEVELS[experiment])
    heights = [len(lanes(experiment, s)) for s in studies]
    fig, axes = plt.subplots(
        len(studies),
        1,
        figsize=(7.5, 0.28 * sum(heights) + 0.6 * len(studies)),
        sharex=True,
        gridspec_kw={"height_ratios": heights},
    )
    for ax, study in zip(axes, studies):
        plot_lanes(ax, annotation, experiment, study)
    axes[-1].set_xlabel("Time since start of recording [min]")
    axes[-1].set_xlim(0, annotation.index[-1] / 60)
    fig.suptitle(f"Session {path.stem} ({experiment})", x=0.01, ha="left", fontweight="bold")
    fig.tight_layout()
    plt.show()


plot_protocol(EXAMPLE["drive"], "drive")
plot_protocol(EXAMPLE["game"], "game")

All 60 sessions of the two public sets. Darker segments are test phases, lighter segments baselines and practice. Rows are sorted by task order.

In [ ]:
STUDIES = list(STUDY_COLORS)
CODE_COLORS = ["#f4f3ef"] + [c for s in STUDIES for c in (STUDY_LIGHT[s], STUDY_COLORS[s])]


def encode(annotation):
    # one code per second, 0 outside the tasks, then a light and a dark code per study
    frame = annotation.iloc[::10]
    code = np.zeros(len(frame))
    for i, study in enumerate(STUDIES):
        active = (frame["STUDY"] == study) & (frame["PHASE"] != "n/a")
        code[active.to_numpy()] = 2 * i + 1
        code[(active & (frame["PHASE"] == "test")).to_numpy()] = 2 * i + 2
    return code


def task_order(code):
    onsets = {
        s: np.argmax(code == 2 * i + 2) for i, s in enumerate(STUDIES) if (code == 2 * i + 2).any()
    }
    return tuple(sorted(onsets, key=onsets.get))


fig, axes = plt.subplots(2, 1, figsize=(7.5, 8.2))
for ax, (experiment, files) in zip(axes, FILES.items()):
    encoded = {f.stem: encode(annotations[f.stem]) for f in files}
    order = sorted(encoded, key=lambda s: task_order(encoded[s]))
    grid = np.full((len(order), max(len(c) for c in encoded.values())), np.nan)
    for row, stem in enumerate(order):
        grid[row, : len(encoded[stem])] = encoded[stem]
    cmap = ListedColormap(CODE_COLORS)
    cmap.set_bad("white")
    ax.imshow(
        np.ma.masked_invalid(grid),
        aspect="auto",
        cmap=cmap,
        vmin=-0.5,
        vmax=len(CODE_COLORS) - 0.5,
        interpolation="nearest",
        extent=(0, grid.shape[1] / 60, len(order) - 0.5, -0.5),
    )
    ax.set_yticks(range(len(order)), order, fontsize=6)
    ax.grid(False)
    ax.set_title(experiment)
    ax.set_xlim(0, 210)
axes[-1].set_xlabel("Time since start of recording [min]")
axes[0].legend(
    handles=[Patch(color=STUDY_COLORS[s], label=s) for s in STUDIES],
    ncol=4,
    loc="lower right",
    bbox_to_anchor=(1, 1.0),
)
fig.tight_layout()
plt.show()

Subjective ratings and task performance¶

After every test level the participants rated six NASA-TLX dimensions on a 21-point scale (0 to 20) and weighted them by pairwise comparison (weights sum to 15). We report the unweighted mean (RTLX) and the weighted score (TLX).

In [ ]:
ORDER = [
    (e, s, level)
    for e, studies in LEVELS.items()
    for s, (_, tests) in studies.items()
    for level in tests
]


def read_table(key):
    # one table for all sessions, restricted to the test levels listed in LEVELS
    frames = []
    for experiment, files in FILES.items():
        for f in files:
            frame = pd.read_hdf(f, key).reset_index(drop=True)
            frame.insert(0, "session", f.stem)
            frame.insert(0, "experiment", experiment)
            frames.append(frame)
    frame = pd.concat(frames, ignore_index=True)
    frame[["STUDY", "PHASE"]] = frame[["STUDY", "PHASE"]].astype(str)
    frame["LEVEL"] = frame["LEVEL"].astype(int)
    known = [
        phase == "test" and (e, study, level) in ORDER
        for e, study, phase, level in zip(
            frame["experiment"], frame["STUDY"], frame["PHASE"], frame["LEVEL"]
        )
    ]
    return frame[known].copy()


DIMENSIONS = ["MENTAL", "PHYSICAL", "TEMPORAL", "PERFORMANCE", "EFFORT", "FRUSTRATION"]

subjective = read_table("SUBJECTIVE")
subjective["RTLX"] = subjective[DIMENSIONS].mean(axis=1)
weights = subjective[[f"WEIGHT {d}" for d in DIMENSIONS]].to_numpy()
subjective["TLX"] = (subjective[DIMENSIONS].to_numpy() * weights).sum(axis=1) / weights.sum(axis=1)

table = (
    subjective.groupby(["experiment", "STUDY", "LEVEL"])[DIMENSIONS + ["RTLX", "TLX"]]
    .mean()
    .loc[ORDER]
    .rename(columns=str.title)
    .rename(columns={"Rtlx": "RTLX", "Tlx": "TLX"})
)
(
    table.style.format("{:.1f}")
    .background_gradient(cmap="Blues", vmin=0, vmax=20)
    .set_caption("Mean NASA-TLX rating per test level (0 to 20)")
)
In [ ]:
def plot_by_level(ax, experiment, study, column="RTLX"):
    data = subjective[(subjective["experiment"] == experiment) & (subjective["STUDY"] == study)]
    for _, person in data.groupby("session"):
        person = person.sort_values("LEVEL")
        ax.plot(person["LEVEL"], person[column], color=NEUTRAL, lw=0.5, alpha=0.5)
    stats = data.groupby("LEVEL")[column].agg(["mean", "std"])
    ax.errorbar(
        stats.index,
        stats["mean"],
        yerr=stats["std"],
        color=STUDY_COLORS[study],
        lw=2,
        marker="o",
        ms=4,
        capsize=2,
    )
    levels = LEVELS[experiment][study][1]
    ax.set_xticks(levels)
    ax.set_xlim(levels[0] - 0.4, levels[-1] + 0.4)
    ax.set_xlabel("LEVEL")
    ax.set_title(f"{study} ({experiment})", color=STUDY_COLORS[study])


fig = plt.figure(figsize=(7.5, 5.4))
for subfig, (experiment, studies) in zip(fig.subfigures(2, 1), LEVELS.items()):
    widths = [len(tests) + 0.8 for _, tests in studies.values()]
    axes = subfig.subplots(1, len(studies), sharey=True, gridspec_kw={"width_ratios": widths})
    for ax, study in zip(axes, studies):
        plot_by_level(ax, experiment, study)
    axes[0].set_ylabel("RTLX (0 to 20)")
    axes[0].set_ylim(-1, 20)
plt.show()

Gray lines are single sessions, colored lines show mean and standard deviation. Within each task the ratings rise with the level, except for driving n-back level 4, which is rated below level 3. The spread between people is large, so the same level does not mean the same load for everyone.

The PERFORMANCE columns differ between the tasks. For an overview we collect F1 score and reaction time where available and the scores of the two games. Some levels have no score.

In [ ]:
def race_seconds(text):
    # race times are stored as text, for example "02:41,56"
    if pd.isna(text):
        return np.nan
    minutes, seconds = str(text).split(":")
    return 60 * int(minutes) + float(seconds.replace(",", "."))


performance = read_table("PERFORMANCE")
performance["F1 score"] = performance[["VISUAL F1", "AUDITIVE F1", "F1"]].mean(axis=1)
performance["Reaction time [s]"] = performance[
    ["VISUAL MEAN REACTION TIME", "AUDITIVE MEAN REACTION TIME", "REACTION TIME"]
].mean(axis=1)
performance["RACE TIME [s]"] = performance["RACE TIME"].map(race_seconds)

metrics = [
    "F1 score",
    "Reaction time [s]",
    "SONGS RECALL",
    "RACE TIME [s]",
    "MISSED RINGS",
    "SCORE",
]
summary = (
    performance.groupby(["experiment", "STUDY", "LEVEL"])[metrics]
    .agg(["mean", "std"])
    .reindex(ORDER)
)
pd.DataFrame(
    {
        m: [f"{a:.2f} ± {b:.2f}" if not np.isnan(a) else "" for a, b in summary[m].to_numpy()]
        for m in metrics
    },
    index=summary.index,
)

Biosignals¶

The helper below reads selected columns of a time range in chunks, which keeps memory low. We show 15 s of n-back test 3 of the driving example. Gaming sessions have the same channels.

In [ ]:
def read_signals(path, columns, start_s, stop_s):
    # selected SIGNALS columns between two time points in seconds, index in seconds
    chunks = pd.read_hdf(
        path,
        "SIGNALS",
        columns=columns,
        start=int(start_s * 1000),
        stop=int(stop_s * 1000),
        chunksize=200_000,
    )
    frame = pd.concat(chunks)
    frame.index = frame.index / 1000
    return frame


annotation = annotations[EXAMPLE["drive"].stem]
three_back = annotation[
    (annotation["STUDY"] == "n-back")
    & (annotation["PHASE"] == "test")
    & (annotation["LEVEL"] == 3)
]
t0 = three_back.index[0] + 40
segment = read_signals(
    EXAMPLE["drive"],
    [
        "RAW_ECG_I",
        "RAW_ECG_II",
        "RAW_PPG",
        "RAW_EDA",
        "RAW_RSP",
        "RAW_EMG",
        "RAW_SKT",
        "ECG_I_PEAKS",
        "PPG_PEAKS",
        "RSP_PEAKS",
        "EYE_LEFT_PUPIL_DIAMETER",
        "EYE_RIGHT_PUPIL_DIAMETER",
    ],
    t0,
    t0 + 15,
)
segment.head()
In [ ]:
PANELS = [
    ("RAW_ECG_I", "ECG I", "ECG_I_PEAKS"),
    ("RAW_ECG_II", "ECG II", None),
    ("RAW_PPG", "PPG", "PPG_PEAKS"),
    ("RAW_RSP", "Respiration", "RSP_PEAKS"),
    ("RAW_EDA", "EDA", None),
    ("RAW_EMG", "EMG", None),
    ("RAW_SKT", "Skin temp.", None),
]

fig, axes = plt.subplots(len(PANELS) + 1, 1, figsize=(7.5, 7.2), sharex=True)
time = segment.index - t0
for ax, (column, label, events) in zip(axes, PANELS):
    ax.plot(time, segment[column], color="#0b0b0b", lw=0.6)
    if events is not None:
        hits = segment[events].fillna(False).astype(bool).to_numpy()
        ax.plot(time[hits], segment.loc[hits, column], "o", ms=3, color=STUDY_COLORS["k-drive"])
    ax.set_ylabel(label, rotation=0, ha="right", va="center")
    ax.set_yticks([])

ax = axes[-1]
for column, side, color in [
    ("EYE_LEFT_PUPIL_DIAMETER", "left", STUDY_COLORS["n-back"]),
    ("EYE_RIGHT_PUPIL_DIAMETER", "right", STUDY_COLORS["u-harry"]),
]:
    pupil = segment[column].dropna()
    ax.plot(pupil.index - t0, pupil, lw=0.8, color=color, label=side)
ax.set_ylabel("Pupil [mm]", rotation=0, ha="right", va="center")
ax.legend(ncol=2, loc="lower right", bbox_to_anchor=(1, 0.95))
ax.set_xlabel("Time [s]")
ax.set_xlim(0, 15)
fig.suptitle(
    f"15 s of n-back test 3, session {EXAMPLE['drive'].stem}", x=0.01, ha="left", fontweight="bold"
)
fig.align_ylabels()
fig.tight_layout()
plt.show()

Orange dots mark the event columns that ship with the data: R peaks in ECG, pulse peaks in PPG and inspiration peaks in respiration. They give heart rate and breathing rate without own peak detection. The short dips in the pupil signal are blinks.

Downsampling and normalization¶

For learning we bring all channels to one common rate of 100 Hz in three steps:

  1. Downsample by averaging over 10 ms bins. The mean ignores NaN, so the same rule works for the 1 kHz biosignals and the 250 Hz eye tracker. Averaging also acts as a low-pass filter against aliasing.
  2. Fill gaps such as blinks by PCHIP interpolation. Gaps longer than 10 s stay empty.
  3. Normalize each channel by a z-score over the recording, which removes differences in electrode contact, skin and pupil size between people. Empty samples are then set to 0, the channel mean.

We leave out EMG, whose content reaches several hundred Hz and does not survive 100 Hz.

In [ ]:
FS = 100  # target rate in Hz


def downsample(frame, fs=FS):
    # mean over bins of 1000 / fs ms, index is the sample number at the new rate
    ms = np.round(frame.index.to_numpy() * 1000).astype(int)
    return frame.groupby(ms // (1000 // fs)).mean()


def fill_gaps(series, max_gap_s=10, fs=FS):
    missing = series.isna()
    gap_length = missing.groupby((~missing).cumsum()).transform("sum")
    filled = series.interpolate(method="pchip", limit_area="inside")
    return filled.mask(missing & (gap_length > max_gap_s * fs))


raw = read_signals(EXAMPLE["drive"], ["RAW_ECG_I", "EYE_LEFT_PUPIL_DIAMETER"], t0, t0 + 10)
processed = downsample(raw).apply(fill_gaps)
print(f"{len(raw)} rows at 1 kHz -> {len(processed)} rows at {FS} Hz")
In [ ]:
fig, axes = plt.subplots(2, 1, figsize=(7.5, 3.8))

ax = axes[0]
ecg = raw["RAW_ECG_I"].loc[t0 + 2 : t0 + 4]
ecg_100 = processed["RAW_ECG_I"]
ecg_100.index = ecg_100.index / FS - t0
ecg_100 = ecg_100.loc[2:4]
ax.plot(ecg.index - t0, ecg, color=NEUTRAL, lw=1.6, label="1000 Hz")
ax.plot(ecg_100.index, ecg_100, ".-", color=STUDY_COLORS["n-back"], ms=3, lw=0.8, label="100 Hz")
ax.set_title("ECG I, downsampled")
ax.legend(ncol=2, loc="lower right", bbox_to_anchor=(1, 0.98))

ax = axes[1]
pupil = raw["EYE_LEFT_PUPIL_DIAMETER"].dropna()
pupil_100 = processed["EYE_LEFT_PUPIL_DIAMETER"]
ax.plot(
    pupil_100.index / FS - t0,
    pupil_100,
    color=STUDY_COLORS["n-back"],
    lw=1.2,
    label="100 Hz, gaps filled",
)
ax.plot(pupil.index - t0, pupil, ".", color="#0b0b0b", ms=1.5, label="250 Hz, raw")
ax.set_title("Left pupil diameter [mm], blinks interpolated")
ax.set_xlabel("Time [s]")
ax.legend(ncol=2, loc="lower right", bbox_to_anchor=(1, 0.98))

fig.tight_layout()
plt.show()

At 100 Hz the R peaks stay in place, so heart rate and its variability survive, but the QRS complex is smoothed and loses amplitude. Use a higher rate if the waveform shape matters. Samples at the edge of a blink are partly occluded and too small, and the interpolation carries them into the gap. Stricter cleaning would drop a few samples around each gap.

Window selection by STUDY, PHASE and LEVEL¶

Models see short windows, not whole recordings. We slide a window of 40 s with a step of 20 s over each recording. A window is kept if one condition of interest covers at least 90 % of its samples. Windows across a condition change, in practice phases or in transitions are dropped. The kept window gets the label of that condition. These are the settings of [2].

Conditions are (experiment, study, phase, level) tuples. We use all baseline and test conditions and group them into low and high load as in [1, 2].

In [ ]:
WINDOW_S, STEP_S, MIN_SHARE = 40, 20, 0.9

LOW_LOAD = [
    ("drive", "n-back", "baseline", 1),
    ("drive", "n-back", "baseline", 2),
    ("drive", "n-back", "test", 1),
    ("drive", "n-back", "test", 4),
    ("drive", "k-drive", "baseline", 1),
    ("drive", "k-drive", "baseline", 2),
    ("drive", "k-drive", "baseline", 3),
    ("game", "n-back", "baseline", 1),
    ("game", "n-back", "baseline", 2),
    ("game", "n-back", "test", 1),
    ("game", "u-harry", "baseline", 1),
    ("game", "u-harry", "baseline", 2),
    ("game", "s-cook", "baseline", 1),
    ("game", "s-cook", "test", 1),
]
HIGH_LOAD = [
    ("drive", "n-back", "test", 2),
    ("drive", "n-back", "test", 3),
    ("drive", "n-back", "test", 5),
    ("drive", "n-back", "test", 6),
    ("drive", "k-drive", "test", 1),
    ("drive", "k-drive", "test", 2),
    ("drive", "k-drive", "test", 3),
    ("game", "n-back", "test", 2),
    ("game", "n-back", "test", 3),
    ("game", "u-harry", "test", 1),
    ("game", "u-harry", "test", 2),
    ("game", "s-cook", "test", 2),
    ("game", "s-cook", "test", 3),
]
CONDITIONS = LOW_LOAD + HIGH_LOAD


pd.DataFrame(
    {"load": ["low"] * len(LOW_LOAD) + ["high"] * len(HIGH_LOAD)},
    index=pd.MultiIndex.from_tuples(CONDITIONS, names=["experiment", "study", "phase", "level"]),
).unstack("experiment").fillna("")
In [ ]:
def select_windows(annotation, experiment, min_share=MIN_SHARE, fs=10):
    # keep a window if one condition covers at least min_share of it, annotation at fs Hz
    keys = list(
        zip(
            [experiment] * len(annotation),
            annotation["STUDY"],
            annotation["PHASE"],
            annotation["LEVEL"],
        )
    )
    size, step = WINDOW_S * fs, STEP_S * fs
    rows = []
    for first in range(0, len(keys) - size, step):
        shares = pd.Series(keys[first : first + size]).value_counts(normalize=True)
        condition, share = shares.index[0], shares.iloc[0]
        keep = condition in CONDITIONS and share >= min_share
        rows.append(
            {
                "start [s]": annotation.index[first],
                "condition": condition,
                "share": share,
                "kept": keep,
            }
        )
    return pd.DataFrame(rows)


windows = select_windows(annotation, "drive")
windows[windows["condition"].str[1] == "n-back"].head(8)
In [ ]:
nback = annotation[annotation["STUDY"] == "n-back"]

fig, axes = plt.subplots(
    2, 1, figsize=(7.5, 4.6), sharex=True, gridspec_kw={"height_ratios": [9, 2.2]}
)
plot_lanes(axes[0], annotation, "drive", "n-back")
ax = axes[1]
for i, w in windows.iterrows():
    color = STUDY_COLORS["n-back"] if w["kept"] else LIGHT
    ax.broken_barh([(w["start [s]"] / 60, WINDOW_S / 60 - 0.04)], (i % 2 - 0.35, 0.7), color=color)
ax.set_yticks([])
ax.set_ylim(1.6, -0.6)
ax.grid(False)
ax.set_title("40 s windows with 20 s step, blue kept, gray dropped", color="#52514e")
axes[0].set_xlim(nback.index[0] / 60 - 1, nback.index[-1] / 60 + 1)
axes[-1].set_xlabel("Time since start of recording [min]")
fig.tight_layout()
plt.show()

Consecutive windows overlap by half, so they are drawn in two rows. A 2 min n-back level yields four or five windows, a 5 min k-drive level about 13. The share threshold trades label purity against the number of windows, here for both example sessions:

In [ ]:
counts = {}
for experiment, path in EXAMPLE.items():
    for share in [0.5, 0.9, 1.0]:
        selected = select_windows(annotations[path.stem], experiment, min_share=share)
        counts[(experiment, f"share {share}")] = selected.loc[
            selected["kept"], "condition"
        ].value_counts()
counts = pd.DataFrame(counts).reindex(CONDITIONS).fillna(0).astype(int)
counts.index = [" ".join(map(str, c)) for c in counts.index]
counts.loc["total"] = counts.sum()
counts

Building the dataset¶

For each session we read the task blocks with the eight channels below, downsample, fill gaps, normalize and cut the selected windows. The result is a set of arrays:

  • X with shape windows by channels by samples,
  • y, the binary load label (0 low, 1 high),
  • condition, experiment and session of every window.

Reading takes about ten minutes for all 60 sessions. The arrays are stored in DATA_PATH and loaded from there on the next run.

In [ ]:
CHANNELS = [
    "RAW_ECG_I",
    "RAW_ECG_II",
    "RAW_PPG",
    "RAW_EDA",
    "RAW_RSP",
    "RAW_SKT",
    "EYE_LEFT_PUPIL_DIAMETER",
    "EYE_RIGHT_PUPIL_DIAMETER",
]


def build_session(path, experiment):
    # preprocessed windows of one session
    annotation = annotations[path.stem]
    blocks = []
    for study in annotation["STUDY"].unique():
        if study == "n/a":
            continue
        times = annotation.index[annotation["STUDY"] == study]
        blocks.append(downsample(read_signals(path, CHANNELS, times[0], times[-1] + 0.1)))
    signals = pd.concat(blocks).sort_index().apply(fill_gaps)
    signals = ((signals - signals.mean()) / signals.std()).fillna(0.0)

    selected = select_windows(annotation, experiment)
    selected = selected[selected["kept"]]
    X = []
    for start in selected["start [s]"]:
        first = int(round(start * FS))
        X.append(signals.reindex(range(first, first + WINDOW_S * FS)).fillna(0.0).to_numpy().T)
    n = len(selected)
    return {
        "X": np.stack(X).astype(np.float32),
        "y": np.array([c in HIGH_LOAD for c in selected["condition"]], dtype=np.int64),
        "condition": np.array([" ".join(map(str, c)) for c in selected["condition"]]),
        "experiment": np.array([experiment] * n),
        "session": np.array([path.stem] * n),
    }


CACHE = DATA_PATH / f"public-sets-windows-{FS}hz.npz"
if CACHE.exists():
    dataset = dict(np.load(CACHE))
else:
    parts = [build_session(f, experiment) for experiment, files in FILES.items() for f in files]
    dataset = {key: np.concatenate([p[key] for p in parts]) for key in parts[0]}
    np.savez(CACHE, **dataset)

X, y = dataset["X"], dataset["y"]
print(f"X {X.shape}, y {y.shape}, {y.mean():.0%} high load")
In [ ]:
per_condition = pd.crosstab(dataset["condition"], dataset["session"])
per_condition = per_condition.reindex([" ".join(map(str, c)) for c in CONDITIONS])
pd.DataFrame(
    {
        "load": ["low"] * len(LOW_LOAD) + ["high"] * len(HIGH_LOAD),
        "windows": per_condition.sum(axis=1),
        "sessions": (per_condition > 0).sum(axis=1),
        "per session": per_condition.replace(0, np.nan).mean(axis=1).round(1),
    }
).rename_axis("condition")

Windows per condition follow the durations: the 5 min k-drive levels give about 13 windows, the 2 min n-back levels four or five. A few gaming sessions lack single conditions.

One window of n-back test 3 as a model receives it, all channels z-scored:

In [ ]:
index = np.flatnonzero(dataset["condition"] == "drive n-back test 3")[0]
time = np.arange(X.shape[-1]) / FS

fig, axes = plt.subplots(len(CHANNELS), 1, figsize=(7.5, 5.2), sharex=True)
for ax, channel, values in zip(axes, CHANNELS, X[index]):
    color = "#0b0b0b" if channel.startswith("RAW_") else STUDY_COLORS["n-back"]
    ax.plot(time, values, color=color, lw=0.6)
    label = (
        channel.replace("RAW_", "").replace("EYE_", "").replace("_DIAMETER", "").replace("_", " ")
    )
    ax.set_ylabel(label.lower(), rotation=0, ha="right", va="center")
    ax.set_yticks([])
axes[-1].set_xlabel("Time [s]")
axes[-1].set_xlim(0, time[-1])
fig.suptitle(
    f"Window {index}, {dataset['session'][index]}, label y = {y[index]}",
    x=0.01,
    ha="left",
    fontweight="bold",
)
fig.align_ylabels()
fig.tight_layout()
plt.show()

Suggested split of the public sets¶

The private sets on Zenodo serve as hidden test sets. For model development within the public sets we suggest the split below.

Windows of one person are strongly correlated, also across sessions. A split must therefore separate people, not windows or sessions. Otherwise test scores are too optimistic.

We suggest five folds over people. The function below assigns people (groups g) to the folds such that every label of y, here the experiment, is spread evenly over them. All sessions of one person land in the same fold. Fold i is the test set, the first remaining fold the validation set and the other three folds the training set.

In [ ]:
def stratified_grouped_split(X, y, g, n_splits=5):
    # assign each group greedily to the fold that keeps the label shares most even
    labels = sorted(set(y))
    counts = {}
    for label, group in zip(y, g):
        counts.setdefault(group, np.zeros(len(labels)))[labels.index(label)] += 1
    total = sum(counts.values())
    fold_counts = np.zeros((n_splits, len(labels)))
    fold_groups = [[] for _ in range(n_splits)]
    for group, count in sorted(counts.items(), key=lambda item: -np.std(item[1])):
        cost = []
        for fold in range(n_splits):
            fold_counts[fold] += count
            cost.append(np.mean(np.std(fold_counts / total, axis=0)))
            fold_counts[fold] -= count
        best = int(np.argmin(cost))
        fold_counts[best] += count
        fold_groups[best].append(group)

    members = [[x for x, group in zip(X, g) if group in groups] for groups in fold_groups]
    splits = []
    for test in range(n_splits):
        validation = 1 if test == 0 else 0
        train = [
            x for fold in range(n_splits) if fold not in (test, validation) for x in members[fold]
        ]
        splits.append((train, members[validation], members[test]))
    return splits


sessions = sorted(EXPERIMENT, key=lambda s: (person_of[s], EXPERIMENT[s]))
splits = stratified_grouped_split(
    sessions, [EXPERIMENT[s] for s in sessions], [person_of[s] for s in sessions]
)

# no person in two sets of the same fold
for train, validation, test in splits:
    groups = [{person_of[s] for s in part} for part in (train, validation, test)]
    assert not (groups[0] & groups[1] or groups[0] & groups[2] or groups[1] & groups[2])
In [ ]:
matrix = np.zeros((len(splits), len(sessions)))
for fold, sets in enumerate(splits):
    for code, members in enumerate(sets):
        matrix[fold, [sessions.index(m) for m in members]] = code

fig, (top, ax) = plt.subplots(
    2, 1, figsize=(7.5, 2.6), sharex=True, gridspec_kw={"height_ratios": [1, 5]}
)
top.imshow(
    [[0 if EXPERIMENT[s] == "drive" else 1 for s in sessions]],
    aspect="auto",
    cmap=ListedColormap(["#52514e", LIGHT]),
    vmin=-0.5,
    vmax=1.5,
)
top.set_yticks([0], ["experiment"])
top.grid(False)
top.legend(
    handles=[Patch(color="#52514e", label="drive"), Patch(color=LIGHT, label="game")]
    + [Patch(color=color, label=name) for name, color in SPLIT_COLORS.items()],
    ncol=5,
    loc="lower left",
    bbox_to_anchor=(0, 1.0),
)
ax.imshow(
    matrix, aspect="auto", cmap=ListedColormap(list(SPLIT_COLORS.values())), vmin=-0.5, vmax=2.5
)
ax.set_xticks(np.arange(-0.5, len(sessions)), minor=True)
ax.set_yticks(np.arange(-0.5, len(splits)), minor=True)
ax.grid(which="minor", color="white", lw=1)
ax.grid(which="major", visible=False)
ax.tick_params(which="minor", length=0)
ax.set_xticks(range(len(sessions)), sessions, rotation=90, fontsize=5)
ax.set_yticks(range(len(splits)), [f"fold {i}" for i in range(len(splits))])
plt.show()

Sessions are sorted by person. Neighbors with the same participant number, for example 0009 in both experiments, always share their color in every fold. Selecting the windows of a set is a mask on session.

In [ ]:
rows = []
for fold, sets in enumerate(splits):
    for name, members in zip(SPLIT_COLORS, sets):
        mask = np.isin(dataset["session"], members)
        rows.append(
            {
                "fold": fold,
                "set": name,
                "people": len({person_of[s] for s in members}),
                "drive sessions": sum(EXPERIMENT[s] == "drive" for s in members),
                "game sessions": sum(EXPERIMENT[s] == "game" for s in members),
                "windows": mask.sum(),
                "high load": f"{y[mask].mean():.0%}",
            }
        )
pd.DataFrame(rows).pivot(index="fold", columns="set").reindex(
    columns=list(SPLIT_COLORS), level="set"
)
In [ ]:
masks = {
    name: np.isin(dataset["session"], members) for name, members in zip(SPLIT_COLORS, splits[0])
}
X_train, y_train = X[masks["train"]], y[masks["train"]]
X_val, y_val = X[masks["validation"]], y[masks["validation"]]
X_test, y_test = X[masks["test"]], y[masks["test"]]
print("train", X_train.shape, "validation", X_val.shape, "test", X_test.shape)

How to cite¶

If you use the data, please cite the Zenodo records you used and both papers. ADABase introduced the acquisition setup, and REVELIO extends it with the gaming cohort.

Record DOI
ADABase: Cognitive Driving Public Set 10.5281/zenodo.24051552
ADABase: Cognitive Driving Private Set 10.5281/zenodo.24059812
REVELIO: Cognitive Gaming Public Set 10.5281/zenodo.24060698
REVELIO: Cognitive Gaming Private Set 10.5281/zenodo.24076078

Each Zenodo record page offers a BibTeX export of the record. The papers:

@article{oppelt2023adabase,
  author  = {Oppelt, Maximilian P. and Foltyn, Andreas and Deuschel, Jessica and Lang, Nadine R.
             and Holzer, Nina and Eskofier, Bjoern M. and Yang, Seung Hee},
  title   = {{ADABase}: A Multimodal Dataset for Cognitive Load Estimation},
  journal = {Sensors},
  year    = {2023},
  volume  = {23},
  number  = {1},
  pages   = {340},
  doi     = {10.3390/s23010340}
}

@article{oppelt2026revelio,
  author    = {Oppelt, Maximilian P. and Foltyn, Andreas and Lang-Richter, Nadine R. and Eskofier, Bjoern M.},
  title     = {{REVELIO}: Multimodal Task Load Estimation for Cross-Domain Generalization},
  journal   = {International Journal of Human--Computer Interaction},
  publisher = {Taylor \& Francis},
  year      = {2026},
  doi       = {10.48550/arXiv.2509.01642},
  note      = {In press. DOI refers to the arXiv preprint arXiv:2509.01642}
}

Questions about the data: adabase@iis.fraunhofer.de