Repository navigation
Add PTB-XL dataset support (ECG) - #1187
Conversation
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>
47aa3a4 to
fe1007f
Compare
…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.
|
@jhnwu3 This PR (PTB-XL dataset and 5-superclass task) has not been reviewed yet. I merged current One question following #1264: with Could you review this PR, or indicate who should? |
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>
Test CoverageTotal: 71.70% (18885 / 26338 lines)
|
|
I have merged The only conflict was in I also checked that the automatic merge of 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 |
|
/review |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
- 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 atpyhealth/datasets/configs/ptbxl.yaml:19, thePTBXLDatasetdocstring note, the comment inpyhealth/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. - 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_coexistonly checks that the derived filenames differ. Please add a test that builds the dataset withsampling_rate=500and checks two things. First, thatsignal_filepoints atrecords500/..._hr. Second, thatload_ptbxl_recordreturns shape(12, 250). Aset_tasktest at 500 Hz would also work.
Optional suggestions:
superclass_namesis defined on the task but never used. Either use it or remove it.- The example runs
split_by_strat_foldwithoutfolds=, 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_DIRset, files are written beforeBaseDatasetraises. 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 outsidedocs/,examples/,pyhealth/,test-resources/,tests/(pyproject.toml).
Reviewed commit 2271703
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>
|
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 2. 500 Hz path. I added two tests:
Both tests fail if Optional points
Locally, |
|
/review |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
[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() |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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.
| self.assertIn("device", s) | ||
| self.assertIn("sex", s) | ||
|
|
||
| def test_task_keeps_empty_when_disabled(self): |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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_foldwithoutfolds=. That decodes every 12-lead signal just to read the fold number. Collecting folds from the metadata and passingfolds=would make the example much faster. drop_empty_labels=Falseis tested only by calling the task directly. A shortset_taskend-to-end test would also cover it.- The metadata CSV and YAML are written before
BaseDatasetraises underPYHEALTH_REQUIRE_CACHE_DIR. You've documented this, so it isn't blocking, but it may surprise users.
Reviewed commit 5d874df
…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>
|
Correction to my previous comment about the example. Row order indeed cannot be used, but |
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:masterlistsptb-xlamong the sixsubsets of
CardiologyDataset, which reads the PhysioNet/CinC Challenge 2020copy (
.mat/.heafiles underptb-xl/g*/, one patient id perg*folder)for the binary
cardiology_is*_fntasks. It subclasses the 1.xBaseSignalDataset, now a deprecation stub, so an instance on currentmasterhas noset_taskorstat.PTBXLDatasetreads the PTB-XL v1.0.3release itself on the
BaseDataset/YAML API and exposes what that path doesnot: PTB-XL patient ids,
strat_fold, SCP statements with likelihoods,site/device, and a choice of 100 or 500 Hz records. (The
cardiology_is*_fntasks read only age, sex and Dx codes from the
.heaheaders and assume500 Hz.)
Output label order:
MultiLabelProcessor.fitsorts observed labelsalphabetically. 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 implementationpyhealth/datasets/configs/ptbxl.yaml: table config (v1.0.3 template)pyhealth/tasks/ptbxl.py: 5-superclass multi-label taskpyhealth/datasets/splitter.py:split_by_strat_foldhelpertests/core/test_ptbxl.py: tests with synthetic fixturesdocs/api/datasets/pyhealth.datasets.PTBXLDataset.rst,docs/api/tasks/pyhealth.tasks.ptbxl.rstexamples/ecg/ptbxl/ptbxl_superclass_quickstart.pyImplementation notes:
wfdbis an optional extra (pip install 'pyhealth[ptbxl]'), lazily imported.PTBXLDataset.rootis the user-provided data path. Derived metadata CSVslive under
metadata_cache_dir(notroot) so read-only mounts stayuntouched.
BaseDataset.load_tableconcatenatesroot / file_pathandcannot take an absolute path, so
load_tableis overridden to scan thecache directory. Cache identity hashes the data-root path and the derived
CSV bytes into
dataset_name(EEGBCI pattern) so two roots or an in-placeptbxl_database.csvreplace cannot shareglobal_event_df.BaseDatasetnow also hashes the resolved configand fingerprints table sources, but it resolves
file_pathagainstroot,where the derived CSV does not live. On the test fixture the fingerprint is
empty (
key["sources"] == {"records": [[]]}), so identity rests on theconfig hash (the derived filename encodes the root and
ptbxl_database.csvhashes) and ondataset_name. The code comment inptbxl.pynow says this.PYHEALTH_REQUIRE_CACHE_DIR(Warn about the default dataset cache; allow requiring cache_dir #1264): the derived CSV and the resolvedYAML are written before
super().__init__(). With the variable set and nocache_dir, they are written tometadata_cache_dir(default~/.cache/pyhealth/datasets/ptbxl/) and thenBaseDatasetraises(observed on the test fixture). Not changed in this PR.
BaseDatasetonly acceptsconfig_path, so PTB-XLwrites a rate/root/source-keyed YAML next to the derived CSV before
super().__init__(). CSV and YAML writes use tmp+os.replaceunderfilelock.FileLock.# noqa: E402on the EEGBCI import line inpyhealth/datasets/__init__.py: left untouched (outside the diff).(n_leads, n_samples), matching EEGBCI's convention.ageis always an int: a missing age becomes-1(
age_is_missing=True), and the HIPAA-censored raw value 300 is clipped to90(age_is_censored=True). The raw 300 remains in the derived metadataCSV.
mainly pacemaker ECGs) are dropped by default via
drop_empty_labels.split_by_strat_fold(..., folds=)avoids decoding 12-lead signals just toread an integer.
check_patient_disjoint=Trueraises if apatient_idappears 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).
test-resources/core/ptbxl/is synthetic CSVs; WFDBwaveforms are generated at test time (not committed).
Testing / CI:
masterate94ca1d(includes Truncate nested inputs longer than the fitted width #1263 to Key the dataset cache on the config and source files #1265 andFit processors on the training split in set_task (split=PatientSplit) #1273), head
f4d5d07(Windows, Python 3.13.5,
wfdb4.3.1), each file run withpython -m unittest discover -t tests -s tests/core -p '<file>' -v:test_ptbxl.py28 passed,test_dataset_cache_key.py4 passed,test_default_cache_dir.py5 passed.python tools/check_pr_rules.py --base e94ca1d --head HEADpassed(ruff 0.16.10).
wfdb/pyhealth[ptbxl], waveform I/O andset_taske2e tests skip.pyhealth = { extras = ["ptbxl"] }somake testallunder pixi covers waveform I/O.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 (withwfdb), onf4d5d07test_dataset_cache_key.py(4 passed) andtest_default_cache_dir.py(5 passed), onf4d5d07python tools/check_pr_rules.py --base e94ca1d --head HEAD: passedmake testall/ contribution-rules on the PR: green onfe1007f(18 Aug)make testall/ contribution-rules onf4d5d07(after themastermerge)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.