Skip to content

Labels

load_labels() returns a dataset's label table, dispatching through _custom_loaders() to a per-dataset module in ecgbench/labels/ where one exists.

labels

Per-record labels and metadata.

Exported fold CSVs are identification-only — record ID, patient ID, signal paths, fold, split — so ground truth always lives in the source dataset. load_labels() is the one place that knows, per dataset, which file holds it and how to reach it.

Two paths:

  • Declarative. Most datasets need a column select and a join, described by the labels: block in the dataset's YAML. No Python.
  • Per-dataset module. Datasets needing a real derivation get a module here (see ptbxl.py, which reduces SCP codes to diagnostic superclasses). Dispatch is an explicit dict, not a decorator registry — these modules are meant to be imported directly by per-dataset scripts too.

The loader is authoritative: splitters derive their stratification labels from it rather than keeping a private copy of the same mapping. That is deliberate — PTB-XL previously had two derivations that had silently drifted apart.

Labels are not published to the HuggingFace Hub. The Hub tree carries only fold CSVs, so load_labels needs a local copy of the source dataset. That is also a licensing boundary: redistributing labels is fine for CC-BY datasets and not for credentialed ones.

LabelsUnavailableError

Bases: RuntimeError

The dataset genuinely ships no labels (not a configuration mistake).

LabelSourceMissingError

Bases: FileNotFoundError

The dataset has labels, but the file holding them is not on disk.

load_labels

load_labels(dataset: str | DatasetConfig, data_path: Path | str | None = None) -> DataFrame

Load per-record labels and metadata for a dataset.

Parameters:

Name Type Description Default
dataset str | DatasetConfig

Dataset slug or a DatasetConfig.

required
data_path Path | str | None

Root of a local copy of the source dataset. Resolved through resolve_data_path when omitted.

None

Returns:

Type Description
DataFrame

DataFrame indexed by config.record_id_column, one row per record in

DataFrame

the source dataset — not per record in a split. Reindex it against a

DataFrame

split's record IDs to align the two.

Raises:

Type Description
LabelsUnavailableError

the dataset ships no labels at all.

LabelSourceMissingError

it has labels, but the source file is absent.

Source code in ecgbench/labels/__init__.py
def load_labels(
    dataset: str | DatasetConfig,
    data_path: Path | str | None = None,
) -> pd.DataFrame:
    """Load per-record labels and metadata for a dataset.

    Args:
        dataset: Dataset slug or a DatasetConfig.
        data_path: Root of a local copy of the source dataset. Resolved through
            ``resolve_data_path`` when omitted.

    Returns:
        DataFrame indexed by ``config.record_id_column``, one row per record in
        the source dataset — not per record in a split. Reindex it against a
        split's record IDs to align the two.

    Raises:
        LabelsUnavailableError: the dataset ships no labels at all.
        LabelSourceMissingError: it has labels, but the source file is absent.
    """
    from ecgbench.config import DatasetConfig, load_config

    config = load_config(dataset) if isinstance(dataset, str) else dataset
    if not isinstance(config, DatasetConfig):
        raise TypeError(f"dataset must be str or DatasetConfig, got {type(dataset)}")

    spec = config.labels
    if spec is None:
        raise LabelsUnavailableError(
            f"Config '{config.slug}' has no labels block, so ECGBench does not know "
            "where its labels live. Add one (see ecgbench/data/configs/_template.yaml)."
        )
    if not spec.available:
        raise LabelsUnavailableError(
            f"'{config.slug}' ships no labels. {spec.unavailable_reason}".strip()
        )

    from ecgbench.download import resolve_data_path

    resolved = resolve_data_path(Path(data_path) if data_path else None, config)

    loader = _custom_loaders().get(config.slug)
    df = loader(resolved, config) if loader else _load_declarative(resolved, config)

    if df.index.has_duplicates:
        n = int(df.index.duplicated().sum())
        raise ValueError(
            f"Label source for '{config.slug}' has {n} duplicate record IDs in "
            f"'{df.index.name}'; a join on it would multiply rows."
        )
    return df

Field declarations

Each label module declares the columns its loader returns as a module-level FIELDS tuple of Field(...) calls — a literal one, so the metadata build can read it with ast and never import pandas. Declarative datasets get the same from labels.columns plus an optional labels.fields: block in their YAML. ecgbench fields <id> prints them; tests/test_fields.py pins each declaration to the loader's actual output.

_fields

Declared label fields: the columns load_labels() returns, as data.

Every per-dataset module in ecgbench/labels/ describes its output in a docstring. That prose is excellent and unsearchable. FIELDS turns it into a module-level tuple of Field declarations — name, Frictionless Table Schema type, description, unit, vocabulary — that the metadata layer indexes (so ecgbench search recorder finds MIT-BIH), ecgbench fields <id> prints, and a consistency test pins to the loader's actual columns so the declaration cannot rot.

Two rules make the declarations usable by the metadata build, which must not import pandas (it runs inside the packaging hook with pyyaml alone):

  1. FIELDS is a literal tuple of Field(...) calls with constant arguments — strings, numbers, booleans, None, and tuples or lists of those. No comprehensions, no references to other names. The build reads it with ast (declared_fields_from_source) without executing the module; the runtime path (fields_for) imports the module and reads the same attribute, and tests/test_fields.py asserts the two agree.
  2. type is a Frictionless Table Schema type — string, integer, number, boolean, array, object, date, datetime, time, duration, any — optionally with an item type for arrays, array[string].

Declarative datasets (a labels: block in YAML with no module) get the same from labels.columns plus an optional labels.fields: block carrying types and descriptions; see _template.yaml.

FieldDeclarationError

Bases: ValueError

A FIELDS declaration is malformed or not statically readable.

Field dataclass

Field(name: str, type: str, description: str = '', unit: str | None = None, vocabulary: tuple[str, ...] | None = None, nullable: bool = True, example: str | None = None, source: str = 'labels')

One column of a dataset's label table.

Attributes:

Name Type Description
name str

Column name exactly as load_labels() returns it.

type str

Frictionless type, or array[<item type>] for list-valued columns.

description str

What the value means, including any sentinel or encoding.

unit str | None

Physical unit for measurements (ms, year, degree).

vocabulary tuple[str, ...] | None

The closed set of values a categorical column takes, as strings even for integer codes (("0", "1")). None when open.

nullable bool

False when every record has a value.

example str | None

One representative value, as text.

source str

labels for a loader module's output, config for a declarative labels: block.

base_type property

base_type: str

array for array[string], else the type itself.

item_type property

item_type: str | None

The item type of an array[...] field, else None.

to_dict

to_dict() -> dict

JSON-ready mapping (vocabulary as a list).

Source code in ecgbench/labels/_fields.py
def to_dict(self) -> dict:
    """JSON-ready mapping (``vocabulary`` as a list)."""
    return dataclasses.asdict(self)

validate_type

validate_type(type_: str) -> None

Raise ValueError unless type_ is a Table Schema type or array[<type>].

Source code in ecgbench/labels/_fields.py
def validate_type(type_: str) -> None:
    """Raise ``ValueError`` unless ``type_`` is a Table Schema type or ``array[<type>]``."""
    if type_ in BASE_TYPES:
        return
    match = _ARRAY_RE.match(type_)
    if match and match.group(1) in BASE_TYPES and match.group(1) != "array":
        return
    raise ValueError(
        f"field type {type_!r} is not a Frictionless Table Schema type "
        f"({', '.join(BASE_TYPES)}) or array[<type>]"
    )

fields_from_config

fields_from_config(labels: LabelConfig | None) -> tuple[Field, ...]

Fields for a declarative labels: block.

Every entry of labels.columns becomes a string field unless labels.fields refines it; names present only in labels.fields are appended, which is how a block with columns: null (every column of the source CSV) can still enumerate them.

Source code in ecgbench/labels/_fields.py
def fields_from_config(labels: LabelConfig | None) -> tuple[Field, ...]:
    """Fields for a declarative ``labels:`` block.

    Every entry of ``labels.columns`` becomes a ``string`` field unless
    ``labels.fields`` refines it; names present only in ``labels.fields`` are
    appended, which is how a block with ``columns: null`` (every column of the
    source CSV) can still enumerate them.
    """
    if labels is None or not labels.available:
        return ()
    specs = labels.fields or {}
    names = list(labels.columns or [])
    names += [n for n in specs if n not in names]
    out = []
    for name in names:
        spec = specs.get(name)
        if spec is None:
            out.append(Field(name, "string", source="config"))
            continue
        out.append(
            Field(
                name,
                spec.type,
                spec.description,
                unit=spec.unit,
                vocabulary=tuple(spec.vocabulary) if spec.vocabulary else None,
                nullable=spec.nullable,
                example=spec.example,
                source="config",
            )
        )
    return tuple(out)

has_label_module

has_label_module(slug: str, labels_dir: Path | None = None) -> bool

Whether ecgbench/labels/<slug>.py exists.

Source code in ecgbench/labels/_fields.py
def has_label_module(slug: str, labels_dir: Path | None = None) -> bool:
    """Whether ``ecgbench/labels/<slug>.py`` exists."""
    return ((labels_dir or _LABELS_DIR) / f"{slug}.py").is_file()

declared_fields_from_source

declared_fields_from_source(slug: str, labels_dir: Path | None = None) -> tuple[Field, ...] | None

Read FIELDS out of ecgbench/labels/<slug>.py without importing it.

Returns None when the module does not exist or declares no FIELDS.

Raises:

Type Description
FieldDeclarationError

FIELDS is not a literal tuple of Field(...) calls with constant arguments, or a call is invalid.

Source code in ecgbench/labels/_fields.py
def declared_fields_from_source(
    slug: str, labels_dir: Path | None = None
) -> tuple[Field, ...] | None:
    """Read ``FIELDS`` out of ``ecgbench/labels/<slug>.py`` without importing it.

    Returns ``None`` when the module does not exist or declares no ``FIELDS``.

    Raises:
        FieldDeclarationError: ``FIELDS`` is not a literal tuple of ``Field(...)``
            calls with constant arguments, or a call is invalid.
    """
    path = (labels_dir or _LABELS_DIR) / f"{slug}.py"
    if not path.is_file():
        return None
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    for node in tree.body:
        value: ast.expr | None = None
        if isinstance(node, ast.Assign) and any(
            isinstance(t, ast.Name) and t.id == "FIELDS" for t in node.targets
        ):
            value = node.value
        elif (
            isinstance(node, ast.AnnAssign)
            and isinstance(node.target, ast.Name)
            and node.target.id == "FIELDS"
        ):
            value = node.value
        if value is not None:
            return _eval_fields(value, path)
    return None

fields_for

fields_for(config: DatasetConfig, *, static: bool = False) -> tuple[Field, ...]

The declared fields of config's label table.

A dataset with a module in ecgbench/labels/ answers with that module's FIELDS (empty until one is declared); a declarative dataset answers from its labels: block; a dataset whose labels are unavailable answers empty.

Parameters:

Name Type Description Default
config DatasetConfig

The dataset config.

required
static bool

Read FIELDS from the module source with ast instead of importing the module. This is what the metadata build uses, because importing a label module imports pandas.

False
Source code in ecgbench/labels/_fields.py
def fields_for(config: DatasetConfig, *, static: bool = False) -> tuple[Field, ...]:
    """The declared fields of ``config``'s label table.

    A dataset with a module in ``ecgbench/labels/`` answers with that module's
    ``FIELDS`` (empty until one is declared); a declarative dataset answers from
    its ``labels:`` block; a dataset whose labels are unavailable answers empty.

    Args:
        config: The dataset config.
        static: Read ``FIELDS`` from the module source with ``ast`` instead of
            importing the module. This is what the metadata build uses, because
            importing a label module imports pandas.
    """
    if config.labels is not None and not config.labels.available:
        return ()
    if has_label_module(config.slug):
        if static:
            return declared_fields_from_source(config.slug) or ()
        module = importlib.import_module(f"ecgbench.labels.{config.slug}")
        return tuple(getattr(module, "FIELDS", ()))
    return fields_from_config(config.labels)

to_frictionless

to_frictionless(fields: tuple[Field, ...], primary_key: str | None = None) -> dict

Render fields as a Frictionless Table Schema (v2) dict.

array[x] becomes type: array with arrayItem: {type: x}; a vocabulary becomes constraints.enum cast to the field's type; a non-nullable field gets constraints.required. unit and source are carried as custom properties, which the spec permits.

Source code in ecgbench/labels/_fields.py
def to_frictionless(fields: tuple[Field, ...], primary_key: str | None = None) -> dict:
    """Render fields as a Frictionless Table Schema (v2) ``dict``.

    ``array[x]`` becomes ``type: array`` with ``arrayItem: {type: x}``; a
    vocabulary becomes ``constraints.enum`` cast to the field's type; a
    non-nullable field gets ``constraints.required``. ``unit`` and ``source`` are
    carried as custom properties, which the spec permits.
    """
    out_fields = []
    for f in fields:
        entry: dict = {"name": f.name, "type": f.base_type}
        if f.item_type is not None:
            entry["arrayItem"] = {"type": f.item_type}
        if f.description:
            entry["description"] = f.description
        if f.unit:
            entry["unit"] = f.unit
        if f.example is not None:
            entry["example"] = f.example
        constraints: dict = {}
        if not f.nullable:
            constraints["required"] = True
        if f.vocabulary is not None:
            constraints["enum"] = _cast_vocabulary(f)
        if constraints:
            entry["constraints"] = constraints
        entry["source"] = f.source
        out_fields.append(entry)
    schema: dict = {"fields": out_fields}
    if primary_key:
        schema["primaryKey"] = primary_key
    return schema

Derived datasets

A release whose records belong to another dataset — a feature, annotation or relabelling layer — gets a label loader but deliberately no config, splitter or fold assignment, because generating folds for it would create a second ECGBench partition of recordings an existing config already partitions.

PTB-XL+ is the worked case, and a test enforces that no ptbxl_plus config appears. Its loader is not registered in _custom_loaders() either, since that dict is keyed by config slug.

ptbxl_plus

PTB-XL+ — derived features and annotations for PTB-XL's records.

PTB-XL+ ships no raw ECGs. It is a companion release that annotates the same 21,799 recordings PTB-XL holds, keyed by PTB-XL's own ecg_id:

  • labels/ptbxl_statements.csv — PTB-XL's SCP statements with SNOMED extensions
  • labels/12sl_statements.csv — statements from the Marquette 12SL algorithm
  • labels/snomed_description.csv — the SNOMED vocabulary both map into
  • features/{unig,ecgdeli,12sl}_features.csv — 749 / 532 / 783 measured features from three independent providers
  • median_beats/{12sl,unig}/ — derived single-beat waveforms (see the caveats below; ECGBench does not load these)
  • fiducial_points/ecgdeli/ — 283,326 per-lead WFDB annotation files

There is deliberately no ptbxl_plus dataset config. Because every row is a PTB-XL record, generating a separate ten-fold split would create a second partition over the same recordings that ptbxl already partitions, and a user who trained on one and evaluated on the other would leak. So this module is a label provider: you load PTB-XL as usual, on PTB-XL's official folds, and join these columns onto it. :func:load_ptbxl_plus returns a frame indexed by ecg_id for exactly that.

Four defects in the shipped release, all confirmed against its own SHA256SUMS.txt so they are upstream rather than download damage:

  1. 12sl_features.csv hides its key column in the middle of the table. ecg_id is column 145 of 783, between QRS_Area_aVF and P_On_Global, so inspecting the first or last few columns suggests the table has no key at all. Always locate the key by name; never by position.
  2. Neither 12SL table is sorted by ecg_id. Both run 1, 21803, 21804, 21805, 21806, … — identical order in the two files, but not ascending. So joining by row position, or assuming sorted ids, attaches values to the wrong records. Use the key column.
  3. Every median_beats/12sl/*.hea is unreadable by wfdb.rdrecord: the record line carries a stale producer-side prefix (ge_median_beats_wfdb/00001_medians) and wfdb rejects a / there.
  4. unig median-beat amplitudes are about 1000x too large — they span roughly −1361 to +602 against a declared /mV gain, i.e. the values are effectively microvolts. Coverage is also incomplete for both providers (20,914 and 21,794 of 21,799).

Because of 3 and 4 the median beats are exposed only as paths via :func:median_beat_path, with no decoding — ECGBench will not present a signal it cannot state the units of.

load_statements

load_statements(data_path: Path | str, provider: str = 'ptbxl') -> DataFrame

Load one statements table, indexed by ecg_id.

provider is "ptbxl" (SCP statements as PTB-XL assigns them, plus SNOMED extensions) or "12sl" (the Marquette 12SL algorithm's own statements).

Source code in ecgbench/labels/ptbxl_plus.py
def load_statements(data_path: Path | str, provider: str = "ptbxl") -> pd.DataFrame:
    """Load one statements table, indexed by ``ecg_id``.

    ``provider`` is ``"ptbxl"`` (SCP statements as PTB-XL assigns them, plus SNOMED
    extensions) or ``"12sl"`` (the Marquette 12SL algorithm's own statements).
    """
    if provider not in STATEMENTS:
        raise ValueError(f"provider must be one of {sorted(STATEMENTS)}, got {provider!r}")
    path = Path(data_path) / STATEMENTS[provider]
    _require(path, f"{provider} statements")

    df = pd.read_csv(path)
    df = _parse_literals(df).set_index(JOIN_COLUMN)
    logger.info("Loaded %d %s statements from %s", len(df), provider, path.name)
    return df

load_features

load_features(data_path: Path | str, provider: str = 'unig') -> DataFrame

Load one feature table, indexed by ecg_id.

provider is "unig" (University of Glasgow), "ecgdeli" (the KIT ECGdeli toolbox) or "12sl" (Marquette 12SL).

The 12sl table keeps ecg_id at column 145 of 783, not at the front, which is easy to miss when eyeballing the header — and neither 12SL table is sorted by ecg_id. This function keys on the column by name, so both traps are handled. Should a future release drop the column entirely, it falls back to the row order of 12sl_statements.csv (the two are aligned in v1.0.1) and refuses to guess if the row counts disagree.

Source code in ecgbench/labels/ptbxl_plus.py
def load_features(data_path: Path | str, provider: str = "unig") -> pd.DataFrame:
    """Load one feature table, indexed by ``ecg_id``.

    ``provider`` is ``"unig"`` (University of Glasgow), ``"ecgdeli"`` (the KIT
    ECGdeli toolbox) or ``"12sl"`` (Marquette 12SL).

    The ``12sl`` table keeps ``ecg_id`` at **column 145 of 783**, not at the front,
    which is easy to miss when eyeballing the header — and neither 12SL table is
    sorted by ``ecg_id``. This function keys on the column by name, so both traps
    are handled. Should a future release drop the column entirely, it falls back to
    the row order of ``12sl_statements.csv`` (the two are aligned in v1.0.1) and
    refuses to guess if the row counts disagree.
    """
    if provider not in FEATURES:
        raise ValueError(f"provider must be one of {sorted(FEATURES)}, got {provider!r}")
    path = Path(data_path) / FEATURES[provider]
    _require(path, f"{provider} features")

    df = pd.read_csv(path, low_memory=False)

    if provider == BURIED_KEY_FEATURES:
        if JOIN_COLUMN in df.columns:
            # The normal path in v1.0.1: the column exists, just not first.
            position = list(df.columns).index(JOIN_COLUMN) + 1
            logger.debug(
                "%s carries %s at column %d of %d",
                path.name,
                JOIN_COLUMN,
                position,
                len(df.columns),
            )
        else:
            statements_path = Path(data_path) / STATEMENTS[BURIED_KEY_FEATURES]
            _require(statements_path, "12sl statements (needed to key 12sl features)")
            keys = pd.read_csv(statements_path, usecols=[JOIN_COLUMN])[JOIN_COLUMN]
            if len(keys) != len(df):
                raise ValueError(
                    f"{path.name} has {len(df)} rows but "
                    f"{statements_path.name} has {len(keys)}; the two are supposed to be "
                    "row-aligned, so refusing to guess the key. Check both files against "
                    "the release's SHA256SUMS.txt."
                )
            logger.info(
                "%s ships no %s column; keying it from %s in file order "
                "(that file is not sorted by id, so ascending order would be wrong)",
                path.name,
                JOIN_COLUMN,
                statements_path.name,
            )
            df[JOIN_COLUMN] = keys.values

    df = df.set_index(JOIN_COLUMN)
    logger.info("Loaded %d x %d %s features", len(df), df.shape[1], provider)
    return df

load_snomed_description

load_snomed_description(data_path: Path | str) -> DataFrame

The SNOMED vocabulary both statement sets map into, indexed by snomed_id.

Source code in ecgbench/labels/ptbxl_plus.py
def load_snomed_description(data_path: Path | str) -> pd.DataFrame:
    """The SNOMED vocabulary both statement sets map into, indexed by ``snomed_id``."""
    path = Path(data_path) / SNOMED_DESCRIPTION
    _require(path, "SNOMED description")
    return pd.read_csv(path).set_index("snomed_id")

load_feature_description

load_feature_description(data_path: Path | str) -> DataFrame

The cross-provider feature dictionary: which columns mean the same thing.

Source code in ecgbench/labels/ptbxl_plus.py
def load_feature_description(data_path: Path | str) -> pd.DataFrame:
    """The cross-provider feature dictionary: which columns mean the same thing."""
    path = Path(data_path) / FEATURE_DESCRIPTION
    _require(path, "feature description")
    # The shipped file has a UTF-8 BOM on its first column name.
    return pd.read_csv(path, encoding="utf-8-sig")

median_beat_path

median_beat_path(data_path: Path | str, ecg_id: int, provider: str = 'unig') -> Path | None

Return the WFDB record stem of one derived median beat, or None if absent.

No decoding is offered, deliberately. The 12sl headers are unreadable by wfdb.rdrecord — their record line carries a stale ge_median_beats_wfdb/ prefix and wfdb rejects the / — and the unig amplitudes are about 1000x larger than their declared /mV gain implies, so ECGBench will not present them as millivolt signals. Coverage is also partial: 20,914 (12sl) and 21,794 (unig) of PTB-XL's 21,799 records.

Source code in ecgbench/labels/ptbxl_plus.py
def median_beat_path(data_path: Path | str, ecg_id: int, provider: str = "unig") -> Path | None:
    """Return the WFDB record stem of one derived median beat, or None if absent.

    No decoding is offered, deliberately. The ``12sl`` headers are unreadable by
    ``wfdb.rdrecord`` — their record line carries a stale ``ge_median_beats_wfdb/``
    prefix and wfdb rejects the ``/`` — and the ``unig`` amplitudes are about
    1000x larger than their declared ``/mV`` gain implies, so ECGBench will not
    present them as millivolt signals. Coverage is also partial: 20,914 (12sl) and
    21,794 (unig) of PTB-XL's 21,799 records.
    """
    if provider not in MEDIAN_BEAT_PROVIDERS:
        raise ValueError(
            f"provider must be one of {sorted(MEDIAN_BEAT_PROVIDERS)}, got {provider!r}"
        )
    width = MEDIAN_BEAT_PROVIDERS[provider]
    stem = f"{int(ecg_id):0{width}d}_medians"
    group = f"{(int(ecg_id) // 1000) * 1000:05d}"
    path = Path(data_path) / "median_beats" / provider / group / stem
    return path if path.with_suffix(".hea").exists() else None

load_ptbxl_plus

load_ptbxl_plus(data_path: Path | str, statements: tuple[str, ...] = ('ptbxl', '12sl'), features: tuple[str, ...] = (), prefix: bool = True) -> DataFrame

Return PTB-XL+ annotations indexed by ecg_id, ready to join onto PTB-XL.

Parameters:

Name Type Description Default
data_path Path | str

the PTB-XL+ root.

required
statements tuple[str, ...]

which statement tables to include.

('ptbxl', '12sl')
features tuple[str, ...]

which feature tables to include. Empty by default — the three together are over 2,000 columns, which is rarely what you want implicitly.

()
prefix bool

prefix each column with its provider (12sl_HR__Global), so columns from different providers cannot collide. The three feature sets share many names, so leaving this off risks silent overwrites.

True
Example

plus = load_ptbxl_plus("/data/ptb-xl-plus/1.0.1/", features=("unig",)) ds = ECGDataset("ptbxl", split="train", data_path="/data/ptb-xl/1.0.3/") joined = ds.labels_df.join(plus, how="left") # PTB-XL's own folds

Source code in ecgbench/labels/ptbxl_plus.py
def load_ptbxl_plus(
    data_path: Path | str,
    statements: tuple[str, ...] = ("ptbxl", "12sl"),
    features: tuple[str, ...] = (),
    prefix: bool = True,
) -> pd.DataFrame:
    """Return PTB-XL+ annotations indexed by ``ecg_id``, ready to join onto PTB-XL.

    Args:
        data_path: the PTB-XL+ root.
        statements: which statement tables to include.
        features: which feature tables to include. Empty by default — the three
            together are over 2,000 columns, which is rarely what you want
            implicitly.
        prefix: prefix each column with its provider (``12sl_HR__Global``), so
            columns from different providers cannot collide. The three feature
            sets share many names, so leaving this off risks silent overwrites.

    Example:
        >>> plus = load_ptbxl_plus("/data/ptb-xl-plus/1.0.1/", features=("unig",))
        >>> ds = ECGDataset("ptbxl", split="train", data_path="/data/ptb-xl/1.0.3/")
        >>> joined = ds.labels_df.join(plus, how="left")   # PTB-XL's own folds
    """
    frames: list[pd.DataFrame] = []
    for provider in statements:
        df = load_statements(data_path, provider)
        frames.append(df.add_prefix(f"{provider}_") if prefix else df)
    for provider in features:
        df = load_features(data_path, provider)
        frames.append(df.add_prefix(f"{provider}_") if prefix else df)

    if not frames:
        raise ValueError("Nothing requested: pass at least one statements or features set")

    out = pd.concat(frames, axis=1)
    out.index.name = JOIN_COLUMN
    if out.columns.duplicated().any():
        dupes = sorted(set(out.columns[out.columns.duplicated()]))
        raise ValueError(
            f"Duplicate columns after concatenation: {dupes[:5]}. Pass prefix=True "
            "(the default) so provider columns cannot collide."
        )
    logger.info(
        "PTB-XL+ frame: %d records x %d columns (statements=%s features=%s)",
        len(out),
        out.shape[1],
        list(statements),
        list(features),
    )
    return out