Skip to content

feat: Add PTB-XL dataset and ProtoECGNet model for multi-label ECG classification - #1323

Open
irvifa wants to merge 17 commits into
sunlabuiuc:masterfrom
irvifa:protoecgnet-ptbxl
Open

irvifa wants to merge 17 commits into
sunlabuiuc:masterfrom
irvifa:protoecgnet-ptbxl

Conversation

@irvifa

@irvifa irvifa commented Oct 21, 2026 •

Copy link
Copy Markdown

Contributor: NetID iaini2 (iani2@illinois.edu)
Course: CS 598 DLH (Fall 2026), PyHealth reproduction project, Option 4 (Task + Model)

Note: PTBXLDataset and scp_statements.csv support were merged in #1187. This PR
builds on top of that and adds the multi-label task, the ProtoECGNet model, and an
end-to-end example + ablation script.

Summary

  • PTBXLMultilabelClassification (pyhealth/tasks/ptbxl_multilabel_classification.py):
    Converts each labelled ECG from PTBXLDataset into a (12, 10 * fs) float32 signal
    with multi-hot labels. Supports all six SCP-ECG label sets (all, diagnostic,
    subdiagnostic, superdiagnostic, form, rhythm), 100/500 Hz, likelihood filtering,
    per-record z-score normalisation and an optional 0.5 Hz high-pass baseline removal.
    Shares the single wfdb-backed decoding path with PTBXLSuperclassClassification
    (load_ptbxl_record from pyhealth.datasets.ptbxl); for label_set="superdiagnostic"
    with min_likelihood=0 the label aggregation delegates to
    aggregate_diagnostic_superclasses, so the labels produced by both tasks match
    record-for-record (guarded by test_superdiagnostic_matches_superclass_task).

  • ProtoECGNet (pyhealth/models/protoecgnet.py):
    Re-implementation of Sethi et al., MLHC 2025 (https://arxiv.org/abs/2504.08713).
    A 1D or 2D ResNet extracts features; each prototype branch computes cosine or L2
    similarities between feature windows and learned prototype vectors assigned to labels;
    a final linear layer produces multi-label logits. Why prototype-based? Standard
    ECG classifiers are black boxes. After calling project_prototypes(), every prototype
    is replaced by the feature-space representation of a real training ECG segment (in
    global mode, proto_time_len=None, it is a time-averaged feature rather than a raw
    segment); the method returns the source-sample and window indices so that the matching
    ECG window can be retrieved for case-based explanations.
    prototype_contributions() then returns per-prototype contributions to each logit,
    which combined with the projection mapping (saved alongside the checkpoint — see the
    method docstring) lets a clinician inspect which training cases drove a prediction.
    set_pos_weight is overridden to route the inherited BaseModel argument into
    class_weights (including the "balanced" option), so callers like autoimprove
    work unchanged.
    Supports the paper's rhythm / morphology / global branches (16 / 52 / 3 labels),
    learned fusion, contrastive and diversity regularisation, and the staged training
    protocol (feature_extractor → joint → projection → classifier → fusion)
    through set_stage() and project_prototypes().

  • Example (examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py):
    End-to-end pipeline + ablation (contrastive on/off × cosine/L2 × prototype count).
    --synthetic runs on synthetic data with no download required.
    --device {auto,cpu,cuda,mps} controls the training device (default auto picks
    CUDA > MPS > CPU).

  • Docs: RST pages wired into the existing API reference.

Dataset and how to run

Dataset: PTB-XL v1.0.3 is open access on PhysioNet (no credentialing). Download with:

aws s3 sync --no-sign-request s3://physionet-open/ptb-xl/1.0.3/ ./ptb-xl

--root must point to the folder containing ptbxl_database.csv, scp_statements.csv
and records100/. The task requires the optional wfdb extra:
pip install 'pyhealth[ptbxl]'.

Quick check (no download):

pytest tests/core/test_ptbxl.py tests/core/test_protoecgnet.py -q
python examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py --synthetic
python examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py --synthetic --ablation

Real data:

# default: 5 superdiagnostic labels, single branch
python examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py --root /path/to/ptb-xl

# paper: 71 labels, rhythm / morphology / global branches
# (implemented and unit-tested on synthetic data; not benchmarked on real PTB-XL yet)
python examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py --root /path/to/ptb-xl \
    --label-set all --paper-groups --paper-prototypes

# ablation grid
python examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py --root /path/to/ptb-xl --ablation

# force CPU (matches the MacBook numbers in the table below)
python examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py --root /path/to/ptb-xl --device cpu

Paper

Sethi et al., ProtoECGNet: Case-Based Interpretable Deep Learning for Multi-Label ECG
Classification with Contrastive Learning
, MLHC 2025 (PMLR 298).
https://proceedings.mlr.press/v298/sethi25a.html
Authors' reference code: https://github.com/bbj-lab/protoecgnet

Differences from the paper and authors' code

  • Plain PyTorch with PyHealth's BaseModel / Trainer (reference code requires Lightning
    and hard-coded cluster paths).
  • Paper's per-branch prototype counts (5 / 18 / 7) are in PTBXL_BRANCH_KWARGS; default
    is --prototypes-per-label since the paper's counts are heavy for a free GPU.
  • Diversity loss is divided by P² (paper writes the unnormalised Frobenius norm).
  • feature_extractor stage pretrains each branch on its own labels; the paper pretrains
    black-box models on all 71.
  • No Optuna sweeps; published loss weights are the defaults.
  • L1-constrained convex step after projection is implemented as an L1 penalty during
    classifier/fusion stages (--lam-l1, off by default).
  • Projected prototype vectors are feature-space representations; in global mode
    (proto_time_len=None) specifically, the stored vector is a time-averaged feature.
    projection_info (the per-prototype source-sample + window mapping produced by
    project_prototypes) is not part of state_dict and must be saved separately
    alongside the checkpoint — see the method's docstring.

Files to review

File Purpose
pyhealth/tasks/ptbxl_multilabel_classification.py Multi-label task, label aggregation (delegates to aggregate_diagnostic_superclasses for the superdiagnostic path)
pyhealth/models/protoecgnet.py Model, branches, losses, stages, projection, set_pos_weight override
pyhealth/{tasks,models}/__init__.py Exports
tests/core/test_ptbxl.py (appended class) 15 task tests on synthetic data
tests/core/test_protoecgnet.py 41 model tests on synthetic data
examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py Pipeline and ablation
docs/api/{tasks,models}.rst and two new .rst pages Docs

Tests

pytest tests/core/test_ptbxl.py tests/core/test_protoecgnet.py: 56 tests, all on
synthetic data. No real data is read. The TestPTBXLMultilabelClassification suite is
@skipUnless(wfdb_installed). Cover loss terms (per-sample clustering and
separation, Eqs. 4–5), prototype shapes per branch, projection onto real segments, stage
switches, label grouping, all six label sets, a BN-stats regression for the
feature_extractor freeze fix, a state_dict round-trip (with class_weights and
label_cooccurrence set) that checks forward outputs match after save/load,
behavioural assertions for standardize='record' (per-lead mean≈0 / std≈1) and
remove_baseline=True, record-for-record parity with
PTBXLSuperclassClassification on the superdiagnostic path, exact signal match with
load_ptbxl_record, and set_pos_weight routing to class_weights.

Results

Real PTB-XL runs on the 5 superdiagnostic labels (single branch, 1D ResNet-18,
3 prototypes per label), folds 1–8 / 9 / 10 (train 17,084 / val 2,146 / test 2,158),
Adam 1e-3, batch 32, one seed per cell, invoked with --device cpu or on a
Kaggle T4.

Run Device Epochs per stage Test macro AUROC
Contrastive (default) MacBook CPU 20 / 20 / 10 0.8983
Contrastive (default) MacBook CPU 6 / 6 / 3 0.8907
No contrastive MacBook CPU 6 / 6 / 3 0.8914
No contrastive Kaggle T4 6 / 6 / 3 0.8997
Contrastive (default) Kaggle T4 6 / 6 / 3 0.8917

Note that the contrastive loss (--lam-cnrst 300, the paper's default) does not show
a consistent gain over --no-contrastive in these single-seed runs. A multi-seed
sweep would be needed to distinguish signal from seed noise; the paper reports an
Optuna search of hundreds of trials per variant, well beyond this reproduction's
compute budget.

Compute

Local: MacBook Pro M4 Pro (12 cores, 24 GB), CPU only (--device cpu).
Remote: Kaggle Tesla T4 (16 GB), 4 vCPU Xeon 2.0 GHz, 31 GB RAM.

@irvifa
irvifa marked this pull request as draft October 21, 2026 02:04
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Test Coverage

Total: 73.70% (20055 / 27210 lines)

Modified file Coverage Lines
pyhealth/calib/calibration/__init__.py 100.00% 6 / 6
pyhealth/calib/calibration/hb.py 75.00% 84 / 112
pyhealth/calib/calibration/logistic_recalibration.py 97.47% 77 / 79
pyhealth/calib/predictionset/__init__.py 100.00% 9 / 9
pyhealth/calib/predictionset/rc3p.py 96.06% 122 / 127
pyhealth/datasets/__init__.py 83.33% 40 / 48
pyhealth/datasets/base_dataset.py 90.84% 476 / 524
pyhealth/datasets/sample_dataset.py 94.41% 321 / 340
pyhealth/metrics/__init__.py 100.00% 11 / 11
pyhealth/metrics/binary.py 75.00% 63 / 84
pyhealth/metrics/multiclass.py 100.00% 116 / 116
pyhealth/metrics/multilabel.py 96.52% 111 / 115
pyhealth/metrics/regression.py 55.56% 20 / 36
pyhealth/models/__init__.py 100.00% 53 / 53
pyhealth/models/base_model.py 93.01% 133 / 143
pyhealth/models/protoecgnet.py 97.63% 370 / 379
pyhealth/models/rnn.py 89.18% 173 / 194
pyhealth/processors/__init__.py 94.12% 32 / 34
pyhealth/processors/temporal_timeseries_processor.py 78.57% 44 / 56
pyhealth/processors/tensor_processor.py 92.50% 74 / 80
pyhealth/processors/timeseries_processor.py 84.48% 98 / 116
pyhealth/processors/utils.py 96.55% 56 / 58
pyhealth/tasks/__init__.py 94.74% 36 / 38
pyhealth/tasks/ptbxl_multilabel_classification.py 97.50% 78 / 80
pyhealth/tasks/sleep_staging.py 93.33% 84 / 90
pyhealth/trainer.py 72.69% 197 / 271

Workflow run · fee6fd0

…lassification

  ---
  Summary

  - PTBXLDataset — wraps the open-access PTB-XL 12-lead ECG dataset (21,799 records, 18,869 patients). Reads ptbxl_database.csv + scp_statements.csv, aggregates SCP codes into six label sets (all, diagnostic,
  subdiagnostic, superdiagnostic, form, rhythm) and writes a derived metadata CSV so downstream tasks can load labels without re-parsing the raw files.
  - PTBXLMultilabelClassification — task that converts each labelled ECG into a (12, 10 × fs) float32 signal with multi-hot labels. Supports 100 Hz and 500 Hz, likelihood filtering, per-record z-score
  normalisation, and optional 0.5 Hz high-pass baseline removal. Ships a fallback WFDB reader so wfdb is not a hard dependency.
  - ProtoECGNet — re-implementation of Sethi et al., MLHC 2025 (https://arxiv.org/abs/2504.08713). A 1D or 2D ResNet extracts features; each prototype branch computes cosine or L2 similarities between feature
  windows and learned prototype vectors assigned to labels; a final linear layer produces multi-label logits. Prototypes can be projected onto real ECG segments after training, making every prediction explainable
  by example cases. Supports multi-branch setups (the paper's rhythm / morphology / global split), learned fusion, contrastive and diversity regularisation, and the paper's four-stage training protocol.
  - Docs — RST stubs wired into the existing API reference pages.
  - Example script — end-to-end pipeline with ablation study (contrastive loss on/off × cosine/L2 × prototype count); runs on synthetic data with --synthetic (no download needed).

Signed-off-by: Irvi Aini <irvi.fa@gmail.com>
@irvifa
irvifa force-pushed the protoecgnet-ptbxl branch from db1bc73 to 621377f Compare October 22, 2026 01:43
@irvifa
irvifa marked this pull request as ready for review October 22, 2026 01:45
@Logiquo Logiquo added the status: automatic The PR will go through automatic review process. label Oct 22, 2026
@Logiquo

Logiquo commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

/review

1 similar comment
@Logiquo

Logiquo commented Oct 23, 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.

Previous review

AI review: findings

The PR adds a PTB-XL dataset (builds a metadata CSV from ptbxl_database.csv and scp_statements.csv), a multi-label ECG task with a WFDB fallback reader, and ProtoECGNet (1D/2D ResNet prototype branches, contrastive/diversity/separation losses, staged training and prototype projection), plus docs, an example script and 45 synthetic-data unit tests. Nothing in the repo already provides a PTB-XL loader, a prototype model, or a 1D ResNet; CardiologyDataset targets the PhysioNet 2020 challenge with different labels, and the 2D backbone reuses torchvision ResNet-18, so no redundancy was found. The change only adds classes and exports and modifies no base class or existing behavior, so it is not core. Blocking (medium): in ProtoECGNet.set_stage (pyhealth/models/protoecgnet.py:707), frozen backbone parts are not put in eval mode, and Trainer.train calls model.train() every epoch (trainer.py:246), so BatchNorm running stats keep updating during the classifier and fusion stages after prototypes were projected. Blocking (medium): with fusion="learned" (the --paper-groups setting), predictions use branch logits in every stage except "fusion" (protoecgnet.py:783), and prototype_contributions (protoecgnet.py:898) reports branch contributions that do not sum to the output logit, so the explanations do not match the predictions. Non-blocking: the 500 Hz path and remove_baseline have only shape-level or no tests, the metadata CSV is written into the raw data root and never regenerated when label definitions change, and project_prototypes describes projected global prototypes as ECG segments even though the default global mode stores time-averaged features. Overall risk is moderate: parsing, label aggregation and loss math are covered by hand-checked tests, but the two blocking issues affect the staged protocol and the explanation API.

Reviewed commit 621377f

@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: need change

Thank you for this contribution. It adds a PTB-XL dataset, a multi-label ECG task and a ProtoECGNet re-implementation, with docs, an example script and a thorough synthetic-data test suite. Test coverage is above the bar: 97–98% on the three new modules, with the repository total at 68.45% against the 60% bar. You're close, but two problems in the staged protocol and the explanations need fixing first.

To get this merged:

  1. BatchNorm drifts in the frozen stages. set_stage only toggles requires_grad. Trainer.train calls model.train() every epoch, so the backbone and add-on BatchNorm running statistics keep updating during the classifier and fusion stages. That shifts the features after the prototypes have been projected onto real segments, so the projected prototypes no longer match the features they were taken from. Please keep the frozen modules in eval mode for those stages. One way is to override train() so that frozen backbone and add-on modules stay in eval(). Please also add a test that the BN running stats don't change during the classifier and fusion stages.
  2. Explanations don't match predictions with fusion="learned". In every stage except fusion, the output logits come from the per-branch classifiers. In the fusion stage they come from fusion_classifier. prototype_contributions() always reports similarity times the branch classifier weight, so with learned fusion (the --paper-groups setting) the contributions don't sum to the model's logits. Please make prototype_contributions() use the fusion weights when the stage is fusion or fusion is learned, and add a test that the contributions sum to the logit. If you'd rather keep it as is, document the limitation clearly.

Optional suggestions:

  • The project_prototypes docstring calls the result "real ECG segments". For global mode (proto_time_len=None), the stored vector is a time-averaged feature, so please say so.
  • Add a test for the 500 Hz path and for remove_baseline output values.
  • The metadata CSV is written into the raw data root and never regenerated if label definitions change. Consider an option to force regeneration, or write it to the cache directory.

Reviewed commit 621377f

irvifa and others added 3 commits October 24, 2026 12:25
  Blocking fixes:
  - Override train() to keep frozen backbone/add-on BatchNorm layers in
    eval mode during classifier and fusion stages; frozen module set is
    computed once in set_stage so train() has no per-call allocation
  - Fix prototype_contributions() to use fusion_classifier weights when
    stage="fusion" so contributions sum to the logit (branch classifier
    weights were used unconditionally before)

  Optional:
  - Add refresh_metadata parameter to PTBXLDataset for forced CSV rebuild
  - Add MPS device support in the example script for Apple Silicon
  - Clarify project_prototypes docstring: global mode stores a
    time-averaged feature vector, not a raw ECG segment

  Tests:
  - Verify BN running stats are unchanged during classifier and fusion
    stages after a train()+forward pass
  - Verify prototype_contributions sums to logit minus fusion bias
  - Add 500 Hz path test and remove_baseline DC-suppression value test
  - Add refresh_metadata round-trip test

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

irvifa commented Oct 24, 2026

Copy link
Copy Markdown
Author

/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.

Previous review

AI review: findings

The PR adds a PTB-XL dataset (pyhealth/datasets/ptbxl.py), a multi-label ECG task with a fallback WFDB reader (pyhealth/tasks/ptbxl_multilabel_classification.py), and ProtoECGNet (pyhealth/models/protoecgnet.py), a prototype model with staged training, prototype projection and per-prototype contributions. It builds on BaseDataset, BaseTask, BaseModel and Trainer without modifying them, and only adds exports and docs. No existing PyHealth model or interpretability method implements prototypes or a 1D/2D ResNet backbone, so nothing is redundant; CardiologyDataset already covers PTB-XL recordings via the Challenge 2020 bundle, but with a different label scheme and is not a duplicate. The code is mechanically consistent with the base classes: the cache keys on source fingerprints, set_task keys on task attributes, and the trainer's monitor and stage loop behave as the example expects. Tests use synthetic data and cover the loss terms, shapes, stage freezing, projection and fusion, but the PR states 45 tests while the files contain 51, and the example is never run in CI. Blocking (medium): the "every prediction can be explained by example cases" claim is not supported by the model, because project_prototypes returns the training-sample index and window only to the caller and nothing stores them, so they are lost after save/load, and prototype_contributions returns weights only (protoecgnet.py:894, :897). Blocking (medium): the paper command --label-set all --paper-groups has no reported run, and sklearn's macro ROC AUC raises if any of the 71 labels has no positives in the validation fold (example:222), so this advertised path may crash. Non-blocking (low): fusion="average" is only a scatter (protoecgnet.py:567), the fusion contribution docstring omits the bias (:908), torchvision's unused fc and avgpool stay in ResNet2dBackbone (:208-211), the example leaves a multi-GB temp cache per run (example:285), baked-in metadata paths can break with a relative root (ptbxl.py:179), and L1 is off by default in the example (example:260). Overall risk is moderate: the code is sound for the 5-label superdiagnostic setting that was run, but the paper-setting path and the explanation claim are unverified.

Reviewed commit 9e28dcb

@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: need change

Thank you for this contribution. It adds a PTB-XL dataset, a multi-label ECG task and a ProtoECGNet re-implementation, with thorough tests. Coverage is 69.59% for the whole repository, against a 60% bar. The new files are at 98.3% (ptbxl.py), 97.2% (protoecgnet.py) and 95.6% (ptbxl_multilabel_classification.py), against an 80% bar. The work is close, but two issues need attention first.

To get this merged:

  1. Paper-setting command (--label-set all --paper-groups --paper-prototypes).
    • It is advertised, but no run is reported for it.
    • Macro ROC AUC from sklearn raises an error if any of the 71 labels has no positive samples in the validation or test fold. Several rare labels may hit this.
    • Please run this path, or guard against it. For example, compute macro AUROC only over labels that have positives, or warn and skip those labels.
    • If you can't verify the path, remove the claim that it is supported.
  2. "Every prediction can be explained by example cases."
    • project_prototypes() returns the source sample index and window only to the caller. Nothing is stored on the model, so the information is lost after save/load.
    • prototype_contributions() returns weights only.
    • Please either persist the projection info, for example in a buffer or attribute included in state_dict, or soften the claim in the PR description and docstrings. A softened claim could say that projection returns the source segments to the caller.

Optional suggestions:

  • The description says 45 tests, but the files contain about 51. Please update the count.
  • fusion="average" is only a scatter of the branch logits. A docstring note would make that clearer.
  • The fusion contribution docstring omits the bias term.
  • ResNet2dBackbone keeps torchvision's unused fc and avgpool.
  • The example leaves a temporary cache directory behind on every run.
  • The metadata CSV stores paths built from root, so a relative root can break later. Consider using os.path.abspath.
  • The example leaves L1 off by default (--lam-l1 0).

Reviewed commit 9e28dcb

  - prototype_contributions docstring: clarify that in fusion mode
    contributions sum to logit minus fusion_classifier.bias (fusion
    classifier has a bias; branch classifiers do not)
  - ResNet2dBackbone: replace unused avgpool and fc with nn.Identity()
    to drop ~512K dead parameters
  - Example: wrap dataset/training in try/finally so the scratch
    directory (potentially multi-GB litdata cache) is always cleaned up;
    move parser.error() calls before mkdtemp so they exit cleanly
  - Example: expand --lam-l1 help text to explain when to enable it
  - ptbxl.py: use os.path.abspath(root) in prepare_metadata so
    record paths in the metadata CSV survive os.chdir

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

irvifa commented Oct 26, 2026

Copy link
Copy Markdown
Author

/review

  - prototype_contributions docstring: clarify that in fusion mode
    contributions sum to logit minus fusion_classifier.bias (fusion
    classifier has a bias; branch classifiers do not)
  - ResNet2dBackbone: replace unused avgpool and fc with nn.Identity()
    to drop ~512K dead parameters
  - Example: wrap dataset/training in try/finally so the scratch
    directory (potentially multi-GB litdata cache) is always cleaned up;
    move parser.error() calls before mkdtemp so they exit cleanly
  - Example: add --cache-dir option so repeated real-data runs reuse
    the processed cache instead of re-preprocessing several GB each time
  - Example: expand --lam-l1 help text to explain when to enable it
  - ptbxl.py: use os.path.abspath(root) in prepare_metadata so
    record paths in the metadata CSV survive os.chdir

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

@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 a PTB-XL dataset loader (PTBXLDataset), a 12-lead multilabel task with a fallback WFDB reader, a ProtoECGNet model (1D/2D ResNet prototype branches with cosine or L2 similarity, staged training, projection onto real training segments), RST docs, a pipeline/ablation example, and 51 unit tests on synthetic tensors and folders. All changes are additive: three init export lines and new files, with no base class, shared helper, default, or existing API modified. Redundancy: PyHealth has no other prototype model and no other WFDB reader, so the model and task are not redundant; PTBXLDataset only partly overlaps CardiologyDataset's Challenge-2020 ptb-xl subset (different labels and folds), and the PR does not acknowledge that (low). Blocking (medium): pyhealth/datasets/ptbxl.py:187 writes ptbxl-metadata-pyhealth.csv into the raw input root on first construction, which fails on read-only inputs such as Kaggle's /kaggle/input and is not disclosed in the PR. Non-blocking (low): lam_l1 defaults to 0, so the default run skips the L1 step the docstring describes as from the paper; the contrastive loss normalisation (protoecgnet.py:479) is not listed among the differences from the reference; ProtoECGNet silently ignores BaseModel.set_pos_weight because it has its own class_weights; the example selects MPS when available, contradicting the description's CPU-only claim; and project_prototypes silently mis-indexes projection_info if given a shuffled loader. I did not run the tests or the example, and real-data AUROC numbers cannot be checked from the repository. Overall risk is low to moderate: the model is isolated, and the main concern is the write into the input directory.

Reviewed commit b9a9d3a

@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: need change

Thank you for this contribution. It adds a PTB-XL dataset, a multi-label ECG task with a fallback WFDB reader, and a ProtoECGNet implementation with staged training, prototype projection, docs, an example script and 51 tests. Coverage is good. The whole repo is at 70.7%, against a bar of 60%. The new files are at 98.3% (ptbxl.py), 97.2% (protoecgnet.py) and 95.6% (ptbxl_multilabel_classification.py), against a bar of 80%. I found one blocking issue.

To get this merged:

  1. PTBXLDataset.init and prepare_metadata (pyhealth/datasets/ptbxl.py, around line 187) write ptbxl-metadata-pyhealth.csv into the raw data root. This fails on read-only inputs such as a Kaggle /kaggle/input mount or a shared read-only data directory. The PR description doesn't mention it. Please either:
    • write the derived CSV to cache_dir or another writable location, and fall back to it when root isn't writable, or
    • catch the OSError and give a clear message.
      Whichever you choose, document it in the docstring. Update split_by_fold in the example script to find the file in its new location.
  2. Add a test for the fallback or error path you choose.

Optional suggestions:

  • project_prototypes records sample indices by loader order, so a shuffled loader would silently give wrong indices. Consider raising an error or documenting this more prominently.
  • lam_l1 defaults to 0, so the default run skips the L1 step. Please state this next to the "Differences from the paper" list. Also add the contrastive-loss normalisation (the division by count**0.5) to that list.
  • ProtoECGNet uses its own class_weights and ignores BaseModel.set_pos_weight. A short docstring note would help.
  • The example script picks MPS when it is available, but the description says CPU only. Please align the two.
  • PTBXLDataset partly overlaps the PTB-XL subset in CardiologyDataset. A short note on how they differ would help users choose between them.

Reviewed commit b9a9d3a

  When cache_dir is provided, ptbxl-metadata-pyhealth.csv is now written
  there rather than into root, so PTBXLDataset works on Kaggle /kaggle/input
  mounts and other read-only data directories. A patched YAML config with a
  relative path is generated in cache_dir so BaseDataset can still locate the
  file. self.metadata_path exposes the resolved path to callers.

  Also adds: shuffled-loader guard in project_prototypes, CardiologyDataset
  overlap note in PTBXLDataset docstring, set_pos_weight note in
  ProtoECGNet docstring, and one new test for each guard.

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

irvifa commented Oct 26, 2026

Copy link
Copy Markdown
Author

/review

1 similar comment
@irvifa

irvifa commented Oct 26, 2026

Copy link
Copy Markdown
Author

/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.

Previous review

AI review: findings

The PR adds a PTB-XL dataset (PTBXLDataset plus a derived metadata CSV), a multi-label ECG task with a fallback WFDB reader, and ProtoECGNet (1D/2D ResNet prototype branches, staged training, projection, and losses), with exports, RST docs, a synthetic-data example, and synthetic-data tests. It is self-contained: it subclasses BaseDataset, BaseTask and BaseModel without changing them, and the package init edits only add exports. I found no redundancy: PyHealth has no PTB-XL loader, no prototype model, no WFDB reader and no 1D residual backbone, and CardiologyDataset reads the Challenge 2020 .mat files. Blocking (medium): pyhealth/datasets/ptbxl.py:230 writes absolute record paths into the metadata CSV, and line 132 reuses an existing CSV without checking the root, so moving the PTB-XL folder or pointing cache_dir at another root makes set_task fail with FileNotFoundError. Blocking (medium): examples/ptbxl_multilabel_classification_protoecgnet.py:313 selects Apple MPS when available, which contradicts the PR's "CPU only" claim for the MacBook results. Non-blocking (low): ProtoECGNet silently ignores the inherited set_pos_weight (documented in the class Note), and lam_l1 defaults to 0, so the default pipeline omits the L1 step that the PR's differences list describes as part of the method. Tests cover the dataset, task, WFDB reader, losses, stage freezing, projection and fusion on synthetic data, which matches repository practice; the example and real-data path are not exercised in CI. Core behaviour I checked is consistent (Trainer.inference returns [y_true, y_prob, loss], projection index mapping is deterministic, and the cache key includes task parameters). Overall risk is low to moderate, and the blocking items are small fixes.

Reviewed commit 8916bba

@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: need change

Thank you for this contribution. It adds a PTB-XL dataset, a multi-label ECG task with a WFDB fallback reader, and a ProtoECGNet re-implementation with staged training and prototype projection. The tests are thorough, and coverage for the three new files is well above the bar: 97% for ptbxl.py, 97% for protoecgnet.py and 96% for the task file. Repository-wide coverage is 70.7%, above the 60% bar. It's close, but there are two things to fix first.

To get this merged:

  1. Stale or moved-root metadata. prepare_metadata writes absolute record paths into ptbxl-metadata-pyhealth.csv. __init__ then reuses an existing CSV without checking that it matches root. If the PTB-XL folder is moved, or a cache_dir is reused with a different root, set_task fails with FileNotFoundError. Please do one of the following:
    • Store paths relative to root and join them with root when the task reads them.
    • Or record the root in the CSV, or beside it, and regenerate the file when it doesn't match.
      Please also add a test for this case.
  2. CPU-only claim versus the code. The description says the MacBook runs were CPU only and that the example script does not use the Apple GPU. But main() selects mps whenever it is available. Please either make the script match the description, for example by adding a --device flag that defaults to CPU or CUDA, or correct the description and say which device each result came from.

Optional suggestions:

  • lam_l1 defaults to 0, so the default pipeline skips the L1 step that the "Differences from the paper" section lists as part of the method. Consider mentioning this in the description, or enabling it in the paper-style settings.
  • Add a small test or doc note for set_pos_weight being ignored by this model.

Reviewed commit 8916bba

…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>
@irvifa

irvifa commented Oct 27, 2026

Copy link
Copy Markdown
Author

/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.

Previous review

AI review: findings

Adds PTBXLMultilabelClassification (six SCP label sets, a fallback WFDB reader and signal preprocessing), ProtoECGNet (a 1D or 2D ResNet prototype model with staged training, prototype projection and explanation helpers), an example script, RST docs and 51 synthetic-data tests. Base classes and existing APIs are unchanged; the new exports are additive. Blocking problems: (1) examples/ptbxl_multilabel_classification_protoecgnet.py:336 calls split_by_strat_fold(samples, folds=[range(1, 9), [9], [10]]), but splitter.py:442-447 requires one fold integer per sample and raises ValueError, so the example fails on every run, including --synthetic. (2) pyhealth/models/protoecgnet.py:766 (train override, with the frozen set built in set_stage at line 756) puts the whole PrototypeBranch into eval mode during the feature_extractor stage, because its own prototype_vectors are frozen, so the trainable backbone's BatchNorm never updates its running stats; no test covers this. (3) Redundant helpers: the task's read_wfdb_signal (ptbxl_multilabel_classification.py:25) duplicates pyhealth/datasets/ptbxl.py load_ptbxl_record, and its superdiagnostic aggregation (_aggregate_labels, line 96) duplicates PTBXLSuperclassClassification and aggregate_diagnostic_superclasses in pyhealth/tasks/ptbxl.py. Non-blocking: a duplicate PTBXLDataset import in pyhealth/datasets/init.py:55 and a duplicate toctree entry in docs/api/datasets.rst:308, plus no tests for the all, diagnostic, subdiagnostic and form label sets. Tests cover the loss terms, shapes, stage switching and projection on synthetic tensors, but nothing exercises the example or the feature_extractor BatchNorm behavior. Overall risk is medium-high as submitted: the example crash and the BatchNorm bug are small fixes, but the duplicated helpers should be consolidated before merge.

Reviewed commit b6d1ee8

@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: need change

Thank you for this contribution. It adds a multi-label PTB-XL task and a ProtoECGNet prototype-based model, with docs, an example and a large synthetic-data test suite. The model is well covered (97%) and the repository total (72%) meets the bar. It is close, but a few problems block merging.

To get this merged:

  1. The example script crashes. split_by_strat_fold(samples, folds=[range(1, 9), [9], [10]]) does not match the splitter's signature, which expects one fold integer per sample. As written it should fail on every run, including --synthetic. Please use the splitter correctly, or split by the strat_fold value in each sample. Then run --synthetic to confirm it works.
  2. BatchNorm stays frozen in the feature_extractor stage. set_stage marks a module as frozen when all its direct parameters have requires_grad=False. PrototypeBranch owns prototype_vectors, which is frozen in that stage, so the whole branch is put in eval mode on train(). The trainable backbone's BatchNorm running stats then never update. Please restrict the frozen set to the modules that are actually frozen, for example leaf modules, or exclude modules that have trainable children. Add a test covering this stage.
  3. pyhealth/tasks/ptbxl_multilabel_classification.py is only 68% covered, below the 80% bar. Please add tests for the all, diagnostic, subdiagnostic and form label sets. The fallback WFDB reader's error paths (missing header, unsupported format) also need tests.
  4. The task duplicates existing code. read_wfdb_signal overlaps load_ptbxl_record in pyhealth/datasets/ptbxl.py. The superdiagnostic aggregation overlaps PTBXLSuperclassClassification and aggregate_diagnostic_superclasses in pyhealth/tasks/ptbxl.py. Please reuse the existing helpers where possible. If a separate implementation is needed, say why in the PR.

Optional suggestions:

  • Remove the duplicate PTBXLDataset import in pyhealth/datasets/__init__.py.
  • Remove the duplicate PTBXLDataset toctree entry in docs/api/datasets.rst.

Thank you for all the effort you've put into this PR! Given its complexity, a maintainer will take over the review from here, and they can ask for more AI reviews whenever they'd be helpful.

Reviewed commit b6d1ee8

   1. examples: split_by_strat_fold(samples) — removed incorrect folds=[range(1,9),[9],[10]]
      argument that crashed at runtime; the default 1-8/9/10 split is correct.

   2. models/protoecgnet: _frozen_modules now checks recurse=True so a PrototypeBranch
      whose backbone has trainable children is never put in eval() during the
      feature_extractor stage (backbone BatchNorm was not updating its running stats).
      Added test_backbone_bn_stays_in_train_mode_in_feature_extractor_stage to confirm.

   3. tests: added 8 tests to TestPTBXLMultilabelClassification covering the all,
      diagnostic, subdiagnostic, form label sets, the missing-header error path,
      standardize=None, and strat_fold field presence. Task coverage now >80%.

   4. tasks/ptbxl_multilabel_classification: read_wfdb_signal delegates to
      load_ptbxl_record (master's canonical reader) when wfdb is installed, falling
      back to the built-in 16-bit reader otherwise — eliminates duplicate logic for
      the wfdb-available path. _aggregate_labels docstring notes it extends
      aggregate_diagnostic_superclasses to all six label sets.

   Two optional fixes: removed duplicate PTBXLDataset import in datasets/__init__.py
   and duplicate toctree entry in docs/api/datasets.rst.

Signed-off-by: Irvi Aini <irvi.fa@gmail.com>
@Logiquo Logiquo added status: automatic The PR will go through automatic review process. and removed status: manual Pending maintainer's review labels Oct 27, 2026
  - examples: add --device {auto,cpu,cuda,mps} flag (default auto picks
    CUDA > MPS > CPU) so MacBook CPU-only results are reproducible with
    --device cpu instead of being accidentally overridden by MPS.
  - examples: seed random and numpy alongside torch at the start of
    run_pipeline so the 8-cell ablation grid is reproducible, not just
    the first run.
  - tests: add test_state_dict_round_trip_preserves_forward_output —
    nudges parameters off initializer, saves state_dict into a fresh
    model, and asserts forward outputs match exactly. Covers prototypes,
    classifier/fusion weights, and buffers.

  Not fixed in this commit (documented instead): projection_info is still
  not part of state_dict. The "every prediction explained by example"
  claim is softened in the PR description and the project_prototypes
  docstring (already) explains the save-separately workflow. The paper
  --paper-groups path remains unbenchmarked on real PTB-XL; the PR
  description now flags that explicitly.

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

irvifa commented Oct 27, 2026

Copy link
Copy Markdown
Author

/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.

Previous review

Reviewed commit ca0f9a3

@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: need change

Thank you for this contribution. The PTB-XL multi-label task, the ProtoECGNet prototype model, the example/ablation script, the docs and the extensive tests are a valuable addition. The model code looks well structured and its coverage (97%) is strong. It is close, but a few things need fixing first.

To get this merged:

  1. Fix the state_dict loading problem. class_weights and label_cooccurrence are registered as None buffers (register_buffer(..., None)) and are later assigned tensors. A checkpoint saved from a model built with these statistics will then contain keys that a freshly constructed model lacks, so strict load_state_dict fails. Either register them as real tensors (for example zeros or ones of the right shape) or store them as persistent buffers whose shape is known up front. Also extend test_state_dict_round_trip_preserves_forward_output to set class_weights and label_cooccurrence, since its docstring says it covers them but it never sets them.
  2. Reduce duplication with existing PTB-XL code. _aggregate_labels overlaps with aggregate_diagnostic_superclasses. The fallback 16-bit WFDB reader in read_wfdb_signal overlaps with load_ptbxl_record. Please reuse the existing helpers where possible, or extend them. Alternatively, explain in the docstring and PR why a separate implementation is needed, and keep the fallback reader only if it is really required.
  3. Correct the interpretability claim. The module docstring and PR description say every prototype is snapped to a real ECG segment. In global mode (proto_time_len=None) the prototype is a time-averaged feature vector, not a raw segment. Please say so in the module docstring and the PR description. The project_prototypes docstring already notes this.
  4. Strengthen the tests. test_standardize_none_keeps_raw_scale only asserts assertIsNotNone. Please assert actual behaviour, for example that the standardized output has per-lead mean near 0 and std near 1 while the raw output does not. Add a check that remove_baseline=True changes the signal. Coverage of ptbxl_multilabel_classification.py is 72.9%, below the 80% bar, and these tests plus one for the fallback reader branch would help. The whole-repo coverage of 72% meets the 60% bar.

Optional suggestions:

  • set_pos_weight is inherited but has no effect on this model. This is documented, but you could override it to raise a warning.
  • The --synthetic example path is not exercised by any test. A small smoke test would help.
  • docs/api/models.rst ends with a stray blank line.

Reviewed commit ca0f9a3

  claim, test coverage and dedup justifications

  - protoecgnet: register class_weights and label_cooccurrence as
    real-tensor buffers (ones/zeros with known shape) instead of None.
    Previously a checkpoint saved from a model with label statistics
    carried keys that a freshly constructed model lacked, so strict
    load_state_dict failed. Forward uses label_cooccurrence.any() to
    gate the contrastive term so defaulting to zeros is a no-op.
  - test_state_dict_round_trip_preserves_forward_output now actually
    calls set_label_statistics before saving, so the round-trip covers
    the two buffers its docstring claims to cover.
  - protoecgnet module docstring: correct the interpretability claim.
    Projected prototypes are feature-space representations; in global
    mode (proto_time_len=None) specifically, the stored vector is a
    time-averaged feature rather than a raw segment. Case-based
    explanations should display the matching window from the source
    sample, retrieved via the returned projection mapping.
  - ptbxl_multilabel_classification: explain in the docstrings why
    read_wfdb_signal keeps a fallback (so tests + --synthetic run
    without the optional wfdb extra) and why _aggregate_labels doesn't
    call through to aggregate_diagnostic_superclasses (the public
    helper doesn't take a label set or likelihood threshold).
  - tests: replace the weak test_standardize_none_keeps_raw_scale
    (asserted assertIsNotNone) with test_standardize_record_versus_none
    that checks per-lead mean≈0 / std≈1 for 'record' and non-zero mean
    for None. Add test_remove_baseline_changes_signal and three
    wfdb-import-blocked tests (unsupported format, missing .dat,
    successful roundtrip) that exercise the fallback reader branches
    CI's wfdb install would otherwise skip. Task coverage expected to
    cross 80% once CI re-runs.

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

irvifa commented Oct 27, 2026

Copy link
Copy Markdown
Author

/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.

Previous review

Reviewed commit be90e67

@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: need change

Thank you for this contribution. It adds a multi-label PTB-XL task with six SCP label sets, a ProtoECGNet re-implementation with staged training and prototype projection, an example/ablation script, docs and 59 tests. Coverage is good: protoecgnet.py is at 97.5% and the task file at 96.3%, both above the 80% bar, and the whole repository is at 72.2%, above the 60% bar. The model math looks sound.

To get this merged:

  1. Duplication with the existing superclass task. PTBXLMultilabelClassification repeats the record loop and the superclass aggregation of the existing PTBXLSuperclassClassification in pyhealth/tasks/ptbxl.py. Its superdiagnostic output matches that task. Please reuse or extend the existing code instead of copying it. For example, share the record loop and signal loading, and add only what is new: the other label sets, likelihood filtering, and the baseline and standardization options.
  2. Second WFDB decoder. read_wfdb_signal adds a fallback decoder next to load_ptbxl_record. As a result, the decoded signal depends on whether wfdb is installed. Please use load_ptbxl_record only. In the tests, mock it or skip when wfdb is missing, so there is one decoding path.
  3. set_pos_weight is silently ignored. ProtoECGNet inherits BaseModel.set_pos_weight but does nothing with it, and the code that calls it (for example pos_weight="balanced" in autoimprove) would be dropped without any error. Please either honor it by mapping it to class_weights, or override it to raise a clear error or warning.

Optional suggestions:

  • Revert the unrelated title change in docs/api/datasets.rst.
  • Consider moving the example under examples/ecg/ptbxl/.
  • Use int(float(event.strat_fold)), as the existing task does, to be more tolerant of the CSV format.
  • The reported results use a single seed and show no consistent gain from the contrastive loss. A short note on that in the PR would help readers.

Reviewed commit be90e67

  claim, test rigor and docstring justifications

  The round-trip test added in the last commit passed only because
  class_weights and label_cooccurrence were never actually set — the
  underlying buffers were registered with register_buffer(name, None)
  and promoted to tensors only when supplied, so a checkpoint saved
  from a model with real label statistics carried keys a freshly
  constructed model lacked, and strict load_state_dict failed. Four
  fixes in response:

  - protoecgnet: register class_weights as torch.ones(num_labels) and
    label_cooccurrence as torch.zeros(num_labels, num_labels). ones is
    BCE's pos_weight identity, so the default is a no-op. The forward
    pass gates the contrastive term with
    bool(self.label_cooccurrence.any()), so an all-zeros buffer leaves
    the term off; the check is hoisted out of the per-branch loop so
    it fires once per forward pass. set_label_statistics docstring now
    spells out the buffer semantics.
  - test_state_dict_round_trip_preserves_forward_output: call
    set_label_statistics(class_weights=..., label_cooccurrence=...)
    before the save, so the test actually exercises the buffers its
    docstring claims to cover; also assert the two buffers match after
    load.
  - protoecgnet module docstring: correct the interpretability claim.
    Projected prototypes are feature-space representations, and in
    global mode (proto_time_len=None) the stored vector is a
    time-averaged feature rather than a raw ECG segment. Case-based
    explanations should display the matching window from the source
    sample, retrieved via the returned source-sample + window mapping.
    PR description updated to match.
  - ptbxl_multilabel_classification: strengthen the docstrings
    justifying read_wfdb_signal's fallback reader (needed so tests and
    --synthetic run without the optional wfdb extra; the wfdb-backed
    path still delegates to load_ptbxl_record) and _aggregate_labels
    (the public aggregate_diagnostic_superclasses doesn't take a label
    set or a likelihood threshold, so a call-through isn't viable).
  - tests: replace the weak test_standardize_none_keeps_raw_scale
    (asserted only assertIsNotNone) with test_standardize_record_versus_none
    that checks per-lead mean≈0 / std≈1 under 'record' and non-zero
    per-lead means under None. Add test_remove_baseline_changes_signal
    (filtered output differs, and its per-lead mean is closer to zero).
    Add three fallback-reader tests that block the wfdb import to force
    the fallback branches CI's wfdb install would otherwise skip
    (unsupported format, missing .dat, successful roundtrip). Task
    coverage expected to clear the 80% bar once CI reruns.

Signed-off-by: Irvi Aini <irvi.fa@gmail.com>
  honor set_pos_weight, plus the four optional suggestions

  Three blocking issues:

  1. Second WFDB decoder removed. read_wfdb_signal is deleted, the
     fallback 16-bit reader goes away, and _prepare_signal calls
     pyhealth.datasets.ptbxl.load_ptbxl_record directly — the same
     decoding path PTBXLSuperclassClassification uses. There is now one
     decoding path, so the decoded signal no longer depends on install
     state. test_ptbxl.py adds a module-level _WFDB_AVAILABLE check and
     @skipUnless on TestPTBXLMultilabelClassification; the three
     builtins.__import__-blocking fallback tests are removed because
     the code they covered is gone. A new
     test_load_signal_matches_superclass_task_signal asserts the
     multi-label task's _prepare_signal(standardize=None) matches
     load_ptbxl_record exactly.

  2. Reuse PTBXLSuperclassClassification's aggregation. For
     label_set="superdiagnostic" with min_likelihood=0 the task's
     __call__ now routes through aggregate_diagnostic_superclasses +
     load_diagnostic_class_map, so the labels produced for the
     overlapping configuration match PTBXLSuperclassClassification
     record-for-record. Other label sets or non-zero likelihood
     thresholds keep using the generalising _aggregate_labels helper
     (which the public helper cannot replace — it doesn't take a label
     set or a likelihood threshold). Guarded by
     test_superdiagnostic_matches_superclass_task.

  3. set_pos_weight no longer silently ignored. ProtoECGNet now
     overrides BaseModel.set_pos_weight to delegate to the base
     implementation (parses scalars, sequences, tensors and "balanced")
     and then mirrors self._pos_weight into self.class_weights. None
     resets class_weights to the ones default. The class docstring Note
     is corrected to describe the routing instead of a no-op. Guarded
     by test_set_pos_weight_routes_to_class_weights.

  Four optional suggestions:

  - docs/api/datasets.rst: revert the toctree title — master's titled
    entry (PTB-XL Dataset <...>) is restored and the untitled entry
    introduced on this branch is removed.
  - Example moved from examples/ to
    examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py
    (next to ptbxl_superclass_quickstart.py). PR description and run
    commands updated to the new path.
  - strat_fold now reads as int(float(event.strat_fold)) to match
    PTBXLSuperclassClassification and tolerate "9.0" strings from the
    CSV.
  - PR description now says results use one seed per cell and that the
    contrastive loss did not show a consistent gain over
    --no-contrastive at this compute budget; a multi-seed sweep would
    be needed to disentangle signal from seed noise.

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

irvifa commented Oct 27, 2026

Copy link
Copy Markdown
Author

/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.

Previous review

Reviewed commit f290e48

@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: need change

Thank you for this contribution. It adds a multi-label PTB-XL task, the ProtoECGNet prototype model, an end-to-end example with an ablation grid, and docs. The tests are thorough, and coverage clears the bar. The whole repository is at 72.16% against the 60% bar. Every added or modified file under pyhealth/ is above 80%, apart from pyhealth/datasets/init.py at 83.33%, which is also above it.

To get this merged:

  1. Fix the shape of class_weights after ProtoECGNet.set_pos_weight. When the argument is a scalar, the base-class parsing gives a (1,) tensor, and your override assigns it directly to the class_weights buffer. That changes the buffer's shape from (num_labels,) to (1,). A checkpoint saved after set_pos_weight(4.0) will then fail a strict load_state_dict into a freshly built model because the shapes differ. Expand the parsed weight to (num_labels,) in the override, for example .expand(self.num_labels).clone() when it has a single element. Also check that a wrong-length vector raises a clear error.
  2. Update test_set_pos_weight_routes_to_class_weights. It currently asserts torch.tensor([4.0]) for the scalar case. Change it to expect a length-num_labels tensor filled with 4.0, and ideally add a save/load round trip after set_pos_weight.

Optional suggestions:

  • Add a test for set_pos_weight("balanced", dataset), which is currently uncovered.
  • The usage lines in the example docstring point to examples/ptbxl_multilabel_classification_protoecgnet.py, but the file lives at examples/ecg/ptbxl/....
  • A Trainer-level test would help cover the staged training flow beyond the --synthetic example run.

The results are single-seed. You state that clearly, and that is fine as it stands.

Reviewed commit f290e48

  set_pos_weight, balanced test, example path, trainer-level test

  Blocker:

  set_pos_weight(scalar) left class_weights at shape (1,) because the
  base class's reshape(-1) produces a (1,)-tensor for a scalar. A
  subsequent state_dict() carried the (1,) shape, which failed strict
  load_state_dict into a freshly constructed model with the (num_labels,)
  default. Fix in protoecgnet.set_pos_weight: when the parsed weight has
  numel()==1, expand to (num_labels,) and clone; when it has
  numel()==num_labels, assign as-is; otherwise raise ValueError with a
  clear message. Docstring updated to describe the broadcast and the
  error case.

  Test coverage:

  - test_set_pos_weight_routes_to_class_weights now asserts
    class_weights.shape == (num_labels,) for both the scalar and the
    per-label paths, exercises the wrong-length ValueError, and ends
    with a state_dict round-trip after set_pos_weight(2.5) that
    verifies strict load_state_dict works.
  - Add test_set_pos_weight_balanced_uses_dataset: calls
    set_pos_weight("balanced", dataset), checks the resulting shape and
    that all weights are finite and positive, and checks that
    "balanced" without a dataset raises.
  - Add test_trainer_runs_through_staged_protocol: a real
    pyhealth.trainer.Trainer walks through feature_extractor → joint →
    project_prototypes → classifier, each for 1 epoch, and the final
    inference call must return finite logits across all labels.

  Optional:

  - examples/ecg/ptbxl/ptbxl_multilabel_classification_protoecgnet.py
    usage docstring: all three CLI examples now point at the file's
    current path (examples/ecg/ptbxl/...), not the old examples/ root.

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

irvifa commented Oct 28, 2026

Copy link
Copy Markdown
Author

/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.

Previous review

Reviewed commit 2216e3a

@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: need change

Thank you for this contribution. It adds a multi-label PTB-XL task, a ProtoECGNet implementation, an example with an ablation script, docs, and a large synthetic-data test suite. The work is thorough, and you state its limits openly. Coverage is above the bar: protoecgnet.py is at 97.6%, the new task file at 97.5%, and the whole repository at 73.7% against the 60% minimum. One correctness problem needs fixing first.

To get this merged:

  1. Persist the training stage with the checkpoint. ProtoECGNet.stage is a plain attribute and is not in state_dict. A model built with fusion="learned" and then reloaded starts in stage="all". Its forward therefore returns the branch-scatter logits instead of the fusion head output. prototype_contributions likewise returns branch-classifier contributions instead of fusion ones. Nothing warns the user. You could store the stage via get_extra_state/set_extra_state, or register it as a buffer. Alternatively, raise or warn when a learned-fusion model is used without an explicit set_stage. If you keep the current behaviour, document it prominently in the class docstring.
  2. Cover this in a test. test_state_dict_round_trip_preserves_forward_output uses fusion="learned" but never calls set_stage("fusion"). It compares outputs in the default stage, so it can't catch this problem. Please add a case that reloads a model in the fusion stage and checks that the logits match.

Optional suggestions:

  • _aggregate_labels re-implements part of the superclass aggregation logic. That's reasonable given the extra label sets and the likelihood filter, but a short note in the code on why it can't share more would help future maintainers.
  • The reported results are single-seed. You already say so, and that's fine.

Reviewed commit 2216e3a

  checkpoints reload with the correct forward path

  Blocker:

  self.stage was a plain Python attribute, not part of state_dict. A
  ProtoECGNet(..., fusion="learned") trained and saved in the "fusion"
  stage reloaded into stage="all", so forward returned branch-scatter
  logits instead of fusion_classifier output and prototype_contributions
  returned branch-classifier contributions instead of fusion ones.
  Silent, no warning. Fix with the PyTorch-native hook pair:

  - ProtoECGNet.get_extra_state() returns {"stage": self.stage}; PyTorch
    stores this under "_extra_state" in state_dict.
  - ProtoECGNet.set_extra_state(state) calls self.set_stage(state["stage"])
    on load, which also recomputes _frozen_modules so BN freezing stays
    correct after reload.

  Test coverage:

  The existing test_state_dict_round_trip_preserves_forward_output used
  fusion="learned" but never called set_stage("fusion"), so it couldn't
  catch this. Add test_state_dict_round_trip_preserves_fusion_stage: a
  model is moved into the fusion stage, parameters are nudged, then it
  is saved and loaded into a fresh model. Asserts clone.stage == "fusion"
  after load, logits match exactly, and prototype_contributions match
  per-branch (also pins the fusion-branch contribution path).

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

irvifa commented Oct 28, 2026

Copy link
Copy Markdown
Author

/review

1 similar comment
@irvifa

irvifa commented Oct 29, 2026

Copy link
Copy Markdown
Author

/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.

Reviewed commit 968f2cb

Comment thread pyhealth/models/protoecgnet.py Outdated
outputs = {n: self.branches[n](x) for n in self.branch_names}
sims = torch.cat([o[1] for o in outputs.values()], dim=1)
logits = self._scatter({n: o[0] for n, o in outputs.items()}, batch)
if self.stage == "fusion":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[medium] Learned fusion head is ignored outside the 'fusion' stage

In forward(), logits come from the branch-scatter path unless self.stage == "fusion". With fusion="learned", the default stage "all" and the "joint"/"classifier" stages therefore never use or train fusion_classifier, and it stays at its random init. The class docstring says "all" trains everything with one loss, and the PR describes learned fusion as supported, so this does not match the description. test_two_branches_with_learned_fusion only checks output shapes in the default stage, so the gap is not caught. Fix: use the fusion head whenever it exists in stages that compute the loss (or raise in "all" when fusion='learned'), and add a test that default-stage logits depend on fusion_classifier.

"""
y = labels.float()
counts = y.sum(dim=0).clamp_min(1.0)
weights = (counts.sum() - counts) / counts

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] class_weights use a different total than BaseModel's 'balanced' option

compute_label_statistics uses counts.sum() (the sum of label counts) as the total for (total - count) / count. BaseModel.set_pos_weight('balanced') (pyhealth/models/base_model.py, around line 257) uses the sample count. Both write to the same class_weights buffer, so its meaning depends on the path used. The docstring cites the reference code, which I could not verify. Fix: use the sample count, or document the difference and where it comes from.

signal = (signal - mean) / (std + 1e-6)
return np.ascontiguousarray(signal, dtype=np.float32)

def __call__(self, patient: Any) -> list[dict[str, Any]]:

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] New task duplicates the record loop of PTBXLSuperclassClassification

call repeats the per-record loop and label plumbing of PTBXLSuperclassClassification (pyhealth/tasks/ptbxl.py, lines 155-270). The superdiagnostic path already delegates to the same helpers, and the superclass docstring says the 71-SCP work belongs in that module. The new task is a reasonable extension (label sets, likelihood filter, preprocessing), so this is not redundant. It could instead be added as parameters to the existing class, or the shared loop could be factored out.

@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: need change
decision: need change

Thank you for this contribution. It adds a multi-label PTB-XL task and a prototype-based ProtoECGNet model, with an example, an ablation script and docs, and the tests are thorough. Coverage meets the bar: protoecgnet.py is at 97.6% and ptbxl_multilabel_classification.py at 97.5%, and the repository total is 73.7% against the 60% minimum. It looks close.

To get this merged:

  • Learned fusion outside the "fusion" stage. In ProtoECGNet.forward, fusion_classifier is used only when stage == "fusion". With fusion="learned" and the default "all" stage, or "joint" or "classifier", the fusion layer is never used or trained. The logits come from the per-branch scatter, so the model behaves like fusion="average" with extra unused parameters. Please make this explicit in one of these ways: raise an error or warning when fusion="learned" is used in a stage that bypasses the fusion head; route the logits through the fusion head in the "all" stage; or state the behaviour clearly in the fusion and set_stage docstrings.
  • Test for that behaviour. test_two_branches_with_learned_fusion checks only output shapes. Please add an assertion that covers the chosen behaviour, for example that the fusion head affects the logits only in the "fusion" stage.

Optional suggestions:

  • compute_label_statistics computes class weights as (sum of label counts − count) / count, which differs from the "balanced" meaning in BaseModel.set_pos_weight. A short docstring note would help users who mix the two.
  • The call record loop closely mirrors PTBXLSuperclassClassification. A shared helper would reduce duplication later.
  • _aggregate_labels is documented as a necessary reimplementation, which is reasonable. Consider extending the shared helper later.

Thank you for all the effort you've put into this PR! Given its complexity, a maintainer will take over the review from here, and they can ask for more AI reviews whenever they'd be helpful.

Reviewed commit 968f2cb

@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 29, 2026
  document class_weights difference from BaseModel.set_pos_weight

  Blocker 1 (medium): learned fusion head ignored outside "fusion" stage.

  With fusion="learned" and self.stage == "all" (the default, and the
  stage the class docstring describes as "trains everything with one
  loss"), forward() went through the branch-scatter path and never
  touched fusion_classifier. The fusion head stayed at random init and
  never received gradients, so the model silently behaved like
  fusion="average" with extra unused parameters. Same mismatch in
  prototype_contributions.

  Fix: add ProtoECGNet._uses_fusion_head() returning True iff
  fusion_classifier is not None and stage in {"all", "fusion"}. forward()
  and prototype_contributions() both route through fusion_classifier
  when this is True, keeping the paper's staged protocol intact for
  "joint" / "classifier" / "prototypes" (which still go through the
  per-branch classifiers so the branches get trained in isolation
  before the fusion head is fine-tuned). The fusion argument's docstring
  and prototype_contributions's Returns block are updated to point at
  _uses_fusion_head.

  Tests:

  - test_fusion_head_affects_logits_in_all_and_fusion_stages: perturb
    fusion_classifier.weight and check that logits change for stage in
    {"all", "fusion"} and are unchanged for stage in
    {"joint", "classifier"}. Catches regressions in both directions.
  - test_all_stage_trains_fusion_head_with_learned_fusion: backprop
    through model(**batch)["loss"] in the "all" stage must give
    fusion_classifier.weight a non-zero gradient.

  Blocker 2 (low): class_weights total differs from BaseModel.set_pos_weight.

  compute_label_statistics uses counts.sum() (sum of per-label positive
  counts) as the normalization total, following the paper's reference
  code. BaseModel.set_pos_weight("balanced") uses the sample count
  instead, and both end up in the same class_weights buffer. Docstring
  Note added spelling out the difference and recommending callers mix
  them deliberately rather than by accident.
@irvifa

irvifa commented Oct 29, 2026

Copy link
Copy Markdown
Author

@jhnwu3 All prior blockers addressed. Would you review, or route to someone who can?

# Conflicts:
#	docs/api/tasks.rst
#	pyhealth/tasks/__init__.py

This branch has not been deployed

No deployments
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