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
|
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame indexed by |
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
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):
FIELDSis a literal tuple ofField(...)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 withast(declared_fields_from_source) without executing the module; the runtime path (fields_for) imports the module and reads the same attribute, andtests/test_fields.pyasserts the two agree.typeis 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 |
type |
str
|
Frictionless type, or |
description |
str
|
What the value means, including any sentinel or encoding. |
unit |
str | None
|
Physical unit for measurements ( |
vocabulary |
tuple[str, ...] | None
|
The closed set of values a categorical column takes, as
strings even for integer codes ( |
nullable |
bool
|
|
example |
str | None
|
One representative value, as text. |
source |
str
|
|
validate_type
¶
Raise ValueError unless type_ is a Table Schema type or array[<type>].
Source code in ecgbench/labels/_fields.py
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
has_label_module
¶
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
|
|
Source code in ecgbench/labels/_fields.py
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 |
False
|
Source code in ecgbench/labels/_fields.py
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
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 extensionslabels/12sl_statements.csv— statements from the Marquette 12SL algorithmlabels/snomed_description.csv— the SNOMED vocabulary both map intofeatures/{unig,ecgdeli,12sl}_features.csv— 749 / 532 / 783 measured features from three independent providersmedian_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:
12sl_features.csvhides its key column in the middle of the table.ecg_idis column 145 of 783, betweenQRS_Area_aVFandP_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.- Neither 12SL table is sorted by
ecg_id. Both run1, 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. - Every
median_beats/12sl/*.heais unreadable bywfdb.rdrecord: the record line carries a stale producer-side prefix (ge_median_beats_wfdb/00001_medians) and wfdb rejects a/there. - unig median-beat amplitudes are about 1000x too large — they span roughly
−1361 to +602 against a declared
/mVgain, 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 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
load_features
¶
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
load_snomed_description
¶
The SNOMED vocabulary both statement sets map into, indexed by snomed_id.
Source code in ecgbench/labels/ptbxl_plus.py
load_feature_description
¶
The cross-provider feature dictionary: which columns mean the same thing.
Source code in ecgbench/labels/ptbxl_plus.py
median_beat_path
¶
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
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 ( |
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