Skip to content

Add PTB-XL dataset support (ECG) - #1187

Merged
Logiquo merged 19 commits into
sunlabuiuc:masterfrom
AxelNoun:feat/ptbxl-dataset
Oct 26, 2026
Merged

Logiquo merged 19 commits into
sunlabuiuc:masterfrom
AxelNoun:feat/ptbxl-dataset

Conversation

@AxelNoun

@AxelNoun AxelNoun commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Contributor: AxelNoun (GitHub: @AxelNoun), external contributor (no NetID)
Contribution Type: New Dataset + Task
Description: Adds PTB-XL (12-lead ECG, PhysioNet v1.0.3, open access) as a
PyHealth dataset, with a 5-superclass multi-label diagnosis task and a
split helper using the official stratified folds. Motivated by the CPBench
effort: it gives conformal prediction a multi-label ECG benchmark that needs
no credentialing, with metadata suited to covariate/label-shift splits.
Based on Wagner et al., Scientific Data 2020
(https://www.nature.com/articles/s41597-020-0495-6).

Relation to CardiologyDataset: master lists ptb-xl among the six
subsets of CardiologyDataset, which reads the PhysioNet/CinC Challenge 2020
copy (.mat/.hea files under ptb-xl/g*/, one patient id per g* folder)
for the binary cardiology_is*_fn tasks. It subclasses the 1.x
BaseSignalDataset, now a deprecation stub, so an instance on current
master has no set_task or stat. PTBXLDataset reads the PTB-XL v1.0.3
release itself on the BaseDataset/YAML API and exposes what that path does
not: PTB-XL patient ids, strat_fold, SCP statements with likelihoods,
site/device, and a choice of 100 or 500 Hz records. (The cardiology_is*_fn
tasks read only age, sex and Dx codes from the .hea headers and assume
500 Hz.)

Output label order: MultiLabelProcessor.fit sorts observed labels
alphabetically. On the full v1.0.3 corpus the multi-hot vector is
CD, HYP, MI, NORM, STTC. The task does not pin a custom vocabulary.

Files to Review:

  • pyhealth/datasets/ptbxl.py: dataset implementation
  • pyhealth/datasets/configs/ptbxl.yaml: table config (v1.0.3 template)
  • pyhealth/tasks/ptbxl.py: 5-superclass multi-label task
  • pyhealth/datasets/splitter.py: split_by_strat_fold helper
  • tests/core/test_ptbxl.py: tests with synthetic fixtures
  • docs/api/datasets/pyhealth.datasets.PTBXLDataset.rst, docs/api/tasks/pyhealth.tasks.ptbxl.rst
  • examples/ecg/ptbxl/ptbxl_superclass_quickstart.py

Implementation notes:

  • wfdb is an optional extra (pip install 'pyhealth[ptbxl]'), lazily imported.
  • PTBXLDataset.root is the user-provided data path. Derived metadata CSVs
    live under metadata_cache_dir (not root) so read-only mounts stay
    untouched. BaseDataset.load_table concatenates root / file_path and
    cannot take an absolute path, so load_table is overridden to scan the
    cache directory. Cache identity hashes the data-root path and the derived
    CSV bytes into dataset_name (EEGBCI pattern) so two roots or an in-place
    ptbxl_database.csv replace cannot share global_event_df.
  • Cache key after Key the dataset cache on the config and source files #1265: BaseDataset now also hashes the resolved config
    and fingerprints table sources, but it resolves file_path against root,
    where the derived CSV does not live. On the test fixture the fingerprint is
    empty (key["sources"] == {"records": [[]]}), so identity rests on the
    config hash (the derived filename encodes the root and
    ptbxl_database.csv hashes) and on dataset_name. The code comment in
    ptbxl.py now says this.
  • PYHEALTH_REQUIRE_CACHE_DIR (Warn about the default dataset cache; allow requiring cache_dir #1264): the derived CSV and the resolved
    YAML are written before super().__init__(). With the variable set and no
    cache_dir, they are written to metadata_cache_dir (default
    ~/.cache/pyhealth/datasets/ptbxl/) and then BaseDataset raises
    (observed on the test fixture). Not changed in this PR.
  • Resolved cache YAML: BaseDataset only accepts config_path, so PTB-XL
    writes a rate/root/source-keyed YAML next to the derived CSV before
    super().__init__(). CSV and YAML writes use tmp+os.replace under
    filelock.FileLock.
  • Unrelated pre-existing # noqa: E402 on the EEGBCI import line in
    pyhealth/datasets/__init__.py: left untouched (outside the diff).
  • Signals returned as (n_leads, n_samples), matching EEGBCI's convention.
  • Sample age is always an int: a missing age becomes -1
    (age_is_missing=True), and the HIPAA-censored raw value 300 is clipped to
    90 (age_is_censored=True). The raw 300 remains in the derived metadata
    CSV.
  • Empty superclass sets (about 400 records on v1.0.1, Wagner et al. Table 9;
    mainly pacemaker ECGs) are dropped by default via drop_empty_labels.
  • split_by_strat_fold(..., folds=) avoids decoding 12-lead signals just to
    read an integer. check_patient_disjoint=True raises if a patient_id
    appears in more than one split (official PTB-XL folds are patient-disjoint;
    the synthetic fixture leaks patient 15709 across folds 1 and 9 to exercise
    this).
  • Fixture under test-resources/core/ptbxl/ is synthetic CSVs; WFDB
    waveforms are generated at test time (not committed).

Testing / CI:

Follow-up: 71-SCP-code classification and age regression are anticipated
in the structure but not implemented here; they are planned as a separate PR.

Test plan

  • python -m unittest discover -t tests -s tests/core -p 'test_ptbxl.py' -v: 28 passed (with wfdb), on f4d5d07
  • test_dataset_cache_key.py (4 passed) and test_default_cache_dir.py (5 passed), on f4d5d07
  • python tools/check_pr_rules.py --base e94ca1d --head HEAD: passed
  • CI make testall / contribution-rules on the PR: green on fe1007f (18 Aug)
  • CI make testall / contribution-rules on f4d5d07 (after the master merge)

Recreates #1186, which was closed on 12 Aug when the fork hosting its branch
was detached during an account cleanup on my side. This was my error, and I
apologise for the noise. #1186 received no review; its only comment was my note
on the pre-existing # noqa: E402, repeated above.

AxelNoun and others added 12 commits August 19, 2026 00:13
Introduce PTBXLDataset (BaseDataset + YAML) with optional wfdb extra,
root-keyed metadata cache, and a resolved cache YAML so file_path is
correct before BaseDataset init.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add PTBXLSuperclassClassification and split_by_strat_fold (folds 1-8 /
9 / 10) in separate modules so they can move to benchmarks later.

Co-authored-by: Cursor <cursoragent@cursor.com>
Cover helpers, metadata cache keyed by root, event reads via
PTBXLDataset, waveform IO (wfdb), and the 5-superclass task path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Document the dataset/task Overview RST pages and a minimal ECG
superclass example for CPBench users.

Co-authored-by: Cursor <cursoragent@cursor.com>
…cally

BaseDataset keys global_event_df on root+dataset_name; hash data-root path
and CSV bytes into dataset_name (EEGBCI pattern) and write CSV/YAML via
tmp+os.replace under FileLock so DDP cannot read a truncated file.

Co-authored-by: Cursor <cursoragent@cursor.com>
Move the SCP class map to functools.cache so vars(task) is unchanged
after __call__. Emit age=-1 when missing and clip HIPAA-censored 300
to 90 so litdata can serialize the field.

Co-authored-by: Cursor <cursoragent@cursor.com>
Accept a precomputed folds sequence and a generic fold_field so callers
need not materialize 12-lead signals just to read an integer. Error
messages no longer mention PTB-XL.

Co-authored-by: Cursor <cursoragent@cursor.com>
Exercise the real litdata path without mocking wfdb, assert
MultiLabelProcessor vocab order, and pin cache-dir uniqueness plus
vars(task) stability.

Co-authored-by: Cursor <cursoragent@cursor.com>
v1.0.3 is 21,799 records / 18,869 patients. Document that
MultiLabelProcessor emits CD, HYP, MI, NORM, STTC. Harmonize the
datasets toctree and reuse the ptbxl extra in the pixi test env.

Co-authored-by: Cursor <cursoragent@cursor.com>
The redundant-looking 'as' is required by ruff's unused-import rule for
module re-exports, same as EEGBCIDataset / PTBXLDataset in this file.

Co-authored-by: Cursor <cursoragent@cursor.com>
BaseDataset.load_table concatenates root/file_path and rejects absolute
paths, so override load_table to read the derived CSV from
metadata_cache_dir while leaving self.root equal to the constructor path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Official PTB-XL folds are patient-disjoint; this flag verifies that
property via patient_to_index. Default False keeps current callers
working. The synthetic fixture leaks patient 15709 across folds 1 and 9
so the check is actually exercised.

Co-authored-by: Cursor <cursoragent@cursor.com>
@AxelNoun
AxelNoun force-pushed the feat/ptbxl-dataset branch from 47aa3a4 to fe1007f Compare August 18, 2026 22:57
…ataset

BaseDataset._init_cache_dir now also hashes the resolved config and
fingerprints the table sources. The source fingerprint resolves the
derived CSV against the user data root, where it does not live, so it
is empty for PTBXLDataset; say so, and say what the cache identity
rests on instead.
@AxelNoun

AxelNoun commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@jhnwu3 This PR (PTB-XL dataset and 5-superclass task) has not been reviewed yet. I merged current master (e94ca1d) into the branch; locally, test_ptbxl.py (28 tests), test_dataset_cache_key.py (4) and test_default_cache_dir.py (5) pass, as does tools/check_pr_rules.py, and CI on the new head is green (build and contribution-rules).

One question following #1264: with PYHEALTH_REQUIRE_CACHE_DIR=1 and no cache_dir, the derived metadata CSV and the resolved YAML are still written to metadata_cache_dir (default ~/.cache/pyhealth/datasets/ptbxl/) before BaseDataset raises. Should their default location follow cache_dir instead, so that nothing is written outside cache_dir when the variable is set?

Could you review this PR, or indicate who should?

@Logiquo Logiquo added the status: conflict There is a merge conflict label Oct 26, 2026
Seul conflit : les deux côtés ajoutaient un extra au même endroit de
[project.optional-dependencies], ptbxl ici et xgboost sur master. Les deux
sont conservés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Test Coverage

Total: 71.70% (18885 / 26338 lines)

Modified file Coverage Lines
pyhealth/datasets/__init__.py 83.33% 40 / 48
pyhealth/datasets/ptbxl.py 83.51% 157 / 188
pyhealth/datasets/splitter.py 96.62% 343 / 355
pyhealth/tasks/__init__.py 89.19% 33 / 37
pyhealth/tasks/ptbxl.py 93.42% 71 / 76

Workflow run · 5d874df

@AxelNoun

Copy link
Copy Markdown
Contributor Author

I have merged upstream/master (4a173f7) into this branch. build and contribution-rules both pass on the merge commit, and the PR reports mergeable: true again.

The only conflict was in pyproject.toml, where both sides had added an extra at the same place in [project.optional-dependencies]: ptbxl here and xgboost on master. Both are kept.

I also checked that the automatic merge of pyhealth/datasets/splitter.py is coherent rather than merely conflict free, since master reworked that module substantially while this PR adds a function to it. subset, patient_to_index and split_by_patient are all still present, split_by_strat_fold only ever uses those three, and pyhealth/datasets/__init__.py exports both the new Split and PatientSplit and split_by_strat_fold.

This PR has not been reviewed since it was opened on 13 August. Nothing mechanical blocks it now, so a review would be welcome whenever one of you has a slot. @jhnwu3

One request I cannot carry out myself: the status: conflict label is still on this PR although the conflict is resolved. It was applied by hand rather than by a workflow, and I only have read access here, so a maintainer needs to remove it.

@Logiquo Logiquo added status: automatic The PR will go through automatic review process. and removed status: conflict There is a merge conflict labels Oct 26, 2026
@Logiquo

Logiquo commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

/review

@github-actions github-actions Bot added status: manual Pending maintainer's review and removed status: automatic The PR will go through automatic review process. labels Oct 26, 2026

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review

AI review: findings

Adds PTBXLDataset (BaseDataset + YAML; reads PTB-XL v1.0.3 metadata and writes a derived CSV under metadata_cache_dir), PTBXLSuperclassClassification (5-superclass multi-label, drops empty label sets by default), and split_by_strat_fold, plus docs, an example, a ptbxl wfdb extra, a pixi test extra, and synthetic CSV fixtures. It does not modify any base class; it only adds public exports and an extra. Not redundant: CardiologyDataset and the cardiology_* tasks read the Challenge 2020 copy of PTB-XL for binary labels, a different loader and release, and the new 5-class task has no existing counterpart. Tests cover metadata writing, censored and missing age, label aggregation, the fold splitter, patient-leak detection, and set_task end to end, but the waveform and set_task tests skip without wfdb and nothing exercises the 500 Hz path. Blocking (medium): pyhealth/datasets/ptbxl.py:344 documents sex as 0 = female, 1 = male, which appears inverted relative to PhysioNet's PTB-XL documentation (0 = male); I could not check the release README offline, and the same mapping is repeated in configs/ptbxl.yaml:19 and tasks/ptbxl.py:269, so sex-based subgroup analysis would be silently mislabeled. Blocking (medium): the 500 Hz branch (filename_hr, records500) is untested; tests/core/test_ptbxl.py:219 only checks the derived filename, and no test loads a 250-sample record. Non-blocking (low): unused superclass_names ClassVar, example decodes every waveform through split_by_strat_fold, the v1.0.1/v1.0.2 rejection described in the docstring is not enforced, the output vocabulary depends on the data observed, and PYHEALTH_REQUIRE_CACHE_DIR writes files before failing. Overall risk is moderate-low: both blocking items are fixable without design changes.

Findings outside the diff

  • [low] Label vocabulary is data-dependent, not fixed to five classes tasks/ptbxl.py:211
    output_schema is multilabel, so MultiLabelProcessor.fit builds the vocabulary from the labels observed in the dataset it is given. A subset with no CD or STTC records gets a smaller output dimension without any error, even though the task is described as 5-superclass. The docstring states this, so this is non-blocking. Fix if desired: pin the vocabulary to PTBXL_DIAGNOSTIC_SUPERCLASSES through a processor configured with those labels.

Reviewed commit 2271703

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review

AI Review

suggestion: need change
decision: not available

Thank you for this contribution. PTB-XL support with a 5-superclass multi-label task, official fold splits and a cache-aware metadata pipeline is a useful addition, and the PR is well documented and well tested. It is close, but one blocking problem remains, plus one test gap.

To get this merged:

  1. Fix the sex encoding. The code, YAML and docs state sex: 0 = female, 1 = male. As I recall PhysioNet's PTB-XL documentation, the coding is the opposite: 0 = male, 1 = female. Please check this against the v1.0.3 release README. If it is inverted, fix every place the mapping is stated: the comment at pyhealth/datasets/configs/ptbxl.yaml:19, the PTBXLDataset docstring note, the comment in pyhealth/tasks/ptbxl.py, and the fixture README if it mentions it. The integer values pass through unchanged, so this is only a labelling fix. Left as is, it would silently flip sex-based subgroup and shift analyses.
  2. Add a test for the 500 Hz path. The 500 Hz fixtures are generated at test time, but nothing loads one. test_100_and_500_hz_coexist only checks that the derived filenames differ. Please add a test that builds the dataset with sampling_rate=500 and checks two things. First, that signal_file points at records500/..._hr. Second, that load_ptbxl_record returns shape (12, 250). A set_task test at 500 Hz would also work.

Optional suggestions:

  • superclass_names is defined on the task but never used. Either use it or remove it.
  • The example runs split_by_strat_fold without folds=, so it decodes every waveform. It would be better to pass precomputed folds, as the helper's docstring recommends.
  • The docstring says v1.0.1 and v1.0.2 are rejected, but only missing columns raise an error. Soften the wording or add a version check.
  • With PYHEALTH_REQUIRE_CACHE_DIR set, files are written before BaseDataset raises. You describe this as intentional, so a short note in the docs may be enough.

Coverage: total coverage is 71.7%, above the 60% bar. Every changed file is above 80%: ptbxl.py 83.5%, tasks/ptbxl.py 93.4%, splitter.py 96.6%, datasets/__init__.py 83.3%, tasks/__init__.py 89.2%.

  • Switched to status: manual: changes files outside docs/, examples/, pyhealth/, test-resources/, tests/ (pyproject.toml).

Reviewed commit 2271703

AxelNoun and others added 2 commits October 27, 2026 00:12
Wagner et al. (Scientific Data 7:154, 2020), Table 2, defines the sex
column of ptbxl_database.csv as "male 0, female 1". The PTBXLDataset
docstring, configs/ptbxl.yaml and the task comment stated the inverse.
Values were always passed through unchanged; only the documentation
was wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Check that sampling_rate=500 points every derived signal_file at
records500/..._hr, and that set_task at 500 Hz yields (12, 250)
signals. Both tests fail if filename_lr is used at 500 Hz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cost

- State what distinguishes v1.0.1/v1.0.2 (duplicate records removed
  later: 21,837 vs 21,799 records) and that the release version is not
  detected, instead of an unverified claim about differing columns.
- Note that the derived CSV and resolved YAML are written before
  BaseDataset raises under PYHEALTH_REQUIRE_CACHE_DIR.
- Comment in the quickstart that split_by_strat_fold without folds=
  decodes every stored sample once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AxelNoun

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Both blocking points are addressed: a53669a fixes the sex encoding and 2cc8539 adds the 500 Hz tests. 5d874df covers the optional documentation points.

1. Sex encoding. The review is right. Wagner et al. (Scientific Data 7:154, 2020), Table 2, lists sex as "male 0, female 1". The loader always passed the values through unchanged, so only the documentation was inverted. It is now corrected in the PTBXLDataset docstring, in configs/ptbxl.yaml and in the comment in tasks/ptbxl.py. The fixture README does not mention the encoding.

2. 500 Hz path. I added two tests:

  • test_500_hz_signal_file_points_at_records500 checks that every signal_file in the derived CSV lies under records500/ and ends in _hr. It does not need wfdb.
  • test_set_task_at_500_hz_loads_records500 runs set_task at 500 Hz and checks that the signals have shape (12, 250).

Both tests fail if _write_derived_metadata is forced to use filename_lr.

Optional points

  • Example: I added a comment saying that split_by_strat_fold without folds= decodes every stored sample, signal included, once. I did not switch the example to folds= read from the derived CSV. set_task drops records with empty label sets by default, so sample indices do not line up with CSV rows.
  • Version wording: the docstring now states only what is verified. v1.0.1 and v1.0.2 still contain duplicate records that later releases removed (21,837 records in v1.0.1, 21,799 in v1.0.3). The docstring also says that the release version is not detected.
  • PYHEALTH_REQUIRE_CACHE_DIR: the docstring now says that the derived CSV and the resolved YAML are written before BaseDataset raises. Where they should go in that case is still an open question (see my comment of 7 October).
  • superclass_names: I kept it for now as the public list of the five official classes on the task. It is the same object as PTBXL_DIAGNOSTIC_SUPERCLASSES, so there is still a single source. I can remove it if you prefer.
  • Label vocabulary: it stays data-dependent, as documented. Pinning it to the five classes would need a processor configured with those labels, which is a design choice I would rather leave to maintainers.

Locally, test_ptbxl.py (30 tests), test_dataset_cache_key.py (4) and test_default_cache_dir.py (5) pass.

@Logiquo

Logiquo commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review: findings

This PR adds PTBXLDataset (a BaseDataset subclass with a YAML config), PTBXLSuperclassClassification (5-superclass multi-label task), and split_by_strat_fold to splitter.py, plus docs, an example and an optional wfdb extra. It changes no base class or existing function, and PyHealth has no existing PTB-XL loader or superclass task; CardiologyDataset reads a different Challenge 2020 copy for binary tasks and is a deprecated stub, so the PR is not redundant. Tests cover helpers, metadata cache isolation, task label output, the splitter and the fold/patient-leak checks, with 28 passing cases on a synthetic fixture, and the set_task end-to-end tests are skipped unless wfdb is installed. No blocking problems were found. Non-blocking: the docs say releases before v1.0.3 are unsupported, but the loader does not detect them and would silently ingest duplicate records (ptbxl.py:309); metadata is written to the cache before the PYHEALTH_REQUIRE_CACHE_DIR check, a disclosed side effect that follows the EEGBCI pattern (ptbxl.py:400); the quickstart calls split_by_strat_fold without folds=, which decodes every 12-lead signal (example line 59); and drop_empty_labels=False is only tested at task-call level, not through set_task (test_ptbxl.py:345). Overall risk is low.

Reviewed commit 5d874df

Expects ``root`` (``data_root``) to point at the extracted **v1.0.3**
directory containing ``ptbxl_database.csv``, ``scp_statements.csv``,
``records100/``, and ``records500/``. Raw data must live outside the git
repo. Earlier PhysioNet releases (v1.0.1 / v1.0.2) are not supported:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] Unsupported PTB-XL releases are documented but silently accepted

The class docstring says v1.0.1/v1.0.2 are not supported because they contain duplicate records, but the loader only checks for missing columns, so a v1.0.1 root loads without warning and yields about 21,837 records including duplicates. Consider a record-count or version check that warns, or soften the wording to say the release is not validated. Non-blocking because the limitation is stated.

self.metadata_file_name = metadata_filename(
self.sampling_rate, self.data_root, self._source_key
)
self.prepare_metadata()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] Derived metadata is written before BaseDataset's cache check

prepare_metadata() and _write_resolved_config() write to the default metadata cache before super().init(). With PYHEALTH_REQUIRE_CACHE_DIR set and no cache_dir, files are written to ~/.cache and then BaseDataset raises. The PR discloses this, and EEGBCI, TUAB and TUEV follow the same before-super pattern (eegbci.py:78), so it is consistent with existing code. Fix later by checking the env var before writing. Non-blocking.

samples = dataset.set_task(task)
# Without ``folds=``, fold values are read through ``samples[i]``, which
# decodes every stored sample, signal included, once before splitting.
train, val, test = split_by_strat_fold(samples)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] Quickstart decodes every sample to read fold numbers

split_by_strat_fold(samples) without folds= calls dataset[i] for every sample, which decodes all 12-lead signals just to read strat_fold. The inline comment explains this, but a quickstart could show the cheaper folds= path or note the cost. Non-blocking.

Comment thread tests/core/test_ptbxl.py
self.assertIn("device", s)
self.assertIn("sex", s)

def test_task_keeps_empty_when_disabled(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] drop_empty_labels=False is not tested through set_task

test_task_keeps_empty_when_disabled calls the task directly. No test runs set_task with drop_empty_labels=False, so the MultiLabelProcessor path with empty label lists is unexercised. The end-to-end set_task tests are also skipped without wfdb. Add one set_task case with drop_empty_labels=False that checks the zero vector for empty labels. Non-blocking.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review

suggestion: approve
decision: not available

Thank you for contributing PTB-XL support. The new dataset, the 5-superclass multi-label task and the split_by_strat_fold helper fill a real gap, since CardiologyDataset can't do this on the current API. The docs, the example and the synthetic-fixture tests make it easy to review. It looks ready to merge.

Coverage against the bar (80% per file, 60% overall):

  • Whole repository: 71.7%.
  • pyhealth/datasets/ptbxl.py (added): 83.5%.
  • pyhealth/tasks/ptbxl.py (added): 93.4%.
  • pyhealth/datasets/splitter.py (modified): 96.6%.
  • pyhealth/datasets/__init__.py (modified): 83.3%.
  • pyhealth/tasks/__init__.py (modified): 89.2%.

All of these meet the bar.

Optional suggestions:

  • The docs say releases before v1.0.3 are unsupported, but the loader doesn't detect them. A v1.0.1 download would be read silently and would include the duplicate records. A light check, such as a row count or a duplicate-record check, could give a clear warning.
  • The quickstart calls split_by_strat_fold without folds=. That decodes every 12-lead signal just to read the fold number. Collecting folds from the metadata and passing folds= would make the example much faster.
  • drop_empty_labels=False is tested only by calling the task directly. A short set_task end-to-end test would also cover it.
  • The metadata CSV and YAML are written before BaseDataset raises under PYHEALTH_REQUIRE_CACHE_DIR. You've documented this, so it isn't blocking, but it may surprise users.

Reviewed commit 5d874df

@Logiquo Logiquo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks.

@Logiquo
Logiquo merged commit a6eb222 into sunlabuiuc:master Oct 26, 2026
2 checks passed
irvifa added a commit to irvifa/PyHealth that referenced this pull request Oct 27, 2026
…r dataset API

  Resolves the add/add conflict between our branch and the PTB-XL dataset
  implementation merged in sunlabuiuc#1187 (AxelNoun). Our dataset implementation is
  replaced by the one in master, which is strictly better in every way:

    - Atomic writes under FileLock (safe for DDP / parallel workers)
    - Content-hash cache keys (ptbxl_database.csv SHA1 in dataset_name,
      so the cache auto-invalidates when the source changes; no manual
      refresh_metadata= flag needed)
    - HIPAA age-censor handling (age=300 sentinel → age_is_censored flag)
    - Clean data_root / cache separation via load_table() override; the
      metadata CSV never touches the user's data root
    - sampling_rate as a dataset constructor parameter; a single signal_file
      attribute per event (no record_path_lr / record_path_hr pair)

  What this PR still adds on top of sunlabuiuc#1187:

    • PTBXLMultilabelClassification task: all six SCP-ECG label sets
      (all 71 statements / diagnostic / subdiagnostic / superdiagnostic /
      form / rhythm) with per-lead z-scoring and optional 0.5 Hz baseline
      removal. Re-wired to master's PTBXLDataset (event_type="records",
      event.signal_file, event.scp_codes, event.sampling_rate).
      scp_statements.csv aggregation is now done on-the-fly in the task
      using a module-level functools.cache loader; no pre-computed label
      columns needed.

    • ProtoECGNet model: case-based interpretable multi-label ECG
      classifier with staged training, prototype projection, learned
      fusion, and BatchNorm freeze. Unchanged from the previous review
      round (all 38 unit tests pass).

    • Full pipeline example: synthetic and real-data modes; uses
      split_by_strat_fold from master's splitter module.

    • 8 new tests in TestPTBXLMultilabelClassification covering signal
      IO, label aggregation with likelihood filtering, and the full
      set_task path against the master fixture.

  References PR sunlabuiuc#1187 (merged) and PR sunlabuiuc#1323 (this PR).

Merge remote-tracking branch 'origin/master' into protoecgnet-ptbxl

Signed-off-by: Irvi Aini <irvi.fa@gmail.com>
@AxelNoun

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment about the example. Row order indeed cannot be used, but samples.record_to_index maps each record_id to its sample indices without decoding anything. In 244e561 the quickstart reads strat_fold from ptbxl_database.csv, aligns it through record_to_index and passes folds= to split_by_strat_fold. On the synthetic fixture, the split decodes no sample and gives the same folds as reading them through samples[i].

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: manual Pending maintainer's review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants